9 Commits
Author SHA1 Message Date
teste.inabaandClaude Sonnet 5 2fecad021b fix: persist host port and compat mode in config.toml
The host-side port mapping and seccomp compatibility-mode selections
were only used in-memory for docker run args and never written to
config.toml, so a re-run of the installer couldn't recover them as
form defaults (worse, the host port field silently defaulted to the
container's internal port). Add host_port and seccomp_unconfined to
[server] and decode them back via AppConfig.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-26 15:08:24 -03:00
teste.inaba a05b98fdf5 feat: auto-update app-dono-cliente when a new image is pushed to :latest
Adds a StepRunUpdater step that starts app-dono-updater after the app
container comes up: a poll loop (docker pull, compare image IDs,
recreate on change) baked into the official docker:cli image via
`sh -c`, reusing the same docker run argv as the initial container
start so the two can't drift.

Not built on Watchtower: containrrr/watchtower was archived upstream
in Dec 2025 with no maintained successor recommended for production
use, so this avoids taking on that dependency.
2026-08-26 12:30:44 -03:00
jbandClaude Opus 4.8 ef126eaf61 feat: add build-stamped version with --version flag and TUI header
Introduce tui.Version (defaults to "dev"), stamped at build time via a
VERSION_LDFLAG in the Makefile (-X .../internal/tui.Version=$(VERSION))
across all build targets. main.go handles "--version"/"-v" before
launching the TUI, and the version is shown next to the title in the
header. Docs updated (README + CLAUDE).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 14:57:05 -03:00
jbandClaude Opus 4.8 920fa5e904 refactor: show placeholder for certificate directory field too
Drop the hardcoded dummy fallback for Certificates.DirPath in loadConfig
so the field shows its placeholder instead of a pre-filled value, matching
the DB URL and central server URL fields. An existing config.toml still
seeds it on re-run.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 12:31:53 -03:00
jbandClaude Opus 4.8 d4ed79115c refactor: show placeholder for DB URL and central server URL fields
Drop the hardcoded dummy fallbacks for Database.URL and
Application.CentralServerURL in loadConfig so these inputs display their
placeholder instead of a pre-filled value. An existing config.toml still
seeds them on re-run via the decode, so real defaults are preserved.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 12:30:57 -03:00
jbandClaude Opus 4.8 9943d5f7b7 fix: render full placeholder text in form inputs
bubbles/v2 textinput truncates the placeholder to its first rune when the
input width is 0 (the default): it sizes the placeholder buffer to
Width()+1 and early-returns after the first character. Set a non-zero
width on every text input (defaultInputWidth) and resize inputs
responsively to the terminal on WindowSizeMsg via FormStep.SetWidth, so
placeholders render in full and inputs fill the available width.

Adds a regression test asserting the placeholder renders past its first
rune.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 12:28:34 -03:00
jbandClaude Opus 4.8 255b4cc299 feat: add back navigation, review step, and error retry to TUI
Implements three UX improvements (TODO #1–#3):

- Back navigation: Esc returns to the previous input step. The Model keeps
  a history stack of input steps (advance/goBack helpers); action/wait
  steps are excluded via isInputStep so back never re-enters a
  side-effecting step. Form values are preserved.
- Review step (StepReview): shows all collected config for confirmation
  before any file is written; Enter installs, Esc edits.
- Retry vs. fix: transient failures (image pull, container run) offer
  "r: tentar novamente" instead of quitting; validation failures route
  back to the relevant form to correct the value.

Adds nav_test.go for the history/navigation logic and updates
README/CLAUDE/docs (incl. docs/TODO.md tracking the remaining ideas).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 12:19:49 -03:00
jbandClaude Opus 4.8 3edfaa4ab2 test: add unit tests for config generation and form values
Cover the deploy-critical pure logic:

- GenerateConfigTOML: round-trip decode of values + a guard asserting the
  config port is always containerAppPort (host port must not leak)
- GenerateWireguardConfig: MTU emitted/commented, PROTO default UDP
- WriteConfigFile / WriteWireguardConfigFile: numeric validation rejects
  bad input and does not write a file
- FormStep.Values(): text/password/select resolution and select default
  fallback

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 12:04:31 -03:00
jbandClaude Opus 4.8 d2717de47a refactor: clarify fixed container port with containerAppPort constant
The container always listens on 8080 internally while the user-provided
port is only the host-side mapping. Make this explicit without changing
behavior or the generated config:

- add a named containerAppPort constant and use it in both the docker -p
  mapping and the config.toml generation (was a bare 8080 literal in two
  places)
- document why config.toml hardcodes the port, so it isn't "fixed" back
  to the form value
- relabel the form field to "Porta (host)"

The generated config.toml is byte-identical (port = 8080).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 12:00:54 -03:00
20 changed files with 1124 additions and 97 deletions
+34 -5
View File
@@ -72,9 +72,24 @@ Makefile Multi-arch build + S3 publish.
(writes `envs`) → `StepDownloadWireguard` → `StepRunWireguard`.
4. Config forms: `StepAppConfig` → `StepServerConfig` → `StepDatabaseConfig` →
`StepCertConfig`.
5. `StepGenerateFile` — writes `config.toml` (validates numeric fields first).
6. `StepRunDocker` — runs `app-dono-cliente` container.
7. `StepDone`.
5. `StepReview` — shows all collected config; Enter confirms.
6. `StepGenerateFile` — writes `config.toml` (validates numeric fields first).
7. `StepRunDocker` — runs `app-dono-cliente` container.
8. `StepRunUpdater` — runs `app-dono-updater`, which auto-updates `app-dono-cliente`
whenever a new image is pushed to `:latest`.
9. `StepDone`.
### Navigation & error handling
- **Back navigation:** `Esc` returns to the previous *input* step. The Model keeps a
`history []step` stack; `advance(next)` pushes the current input step, `goBack()`
pops. `isInputStep` ([steps.go](internal/tui/steps.go)) gates which steps participate
— action/wait steps (downloads, file gen, container runs) are excluded so back never
re-enters a side-effecting step. `Esc` is handled globally in `Update`; forms don't
consume it. Form values survive going back because the `FormStep`s live on the Model.
- **Retry vs. fix:** transient failures (image pull, container run) offer `r` to re-run
just that command (`q`/other quits). Validation failures (file generation) route back
to the relevant form to correct the value instead of quitting.
## Key constants
@@ -82,6 +97,7 @@ In [update.go](internal/tui/update.go):
- `imageName = hub.davinti.com.br:443/app-dono/app-cliente:latest`
- `wireguardImageName = hub.davinti.com.br:443/davinti-vproxy:latest`
- `updaterImageName = docker:cli`
- `configPath = config.toml`, `wireguardConfigPath = envs`
In [docker.go](internal/tui/docker.go): `networkName = app-dono_app` (all containers
@@ -102,6 +118,7 @@ Both are gitignored.
| ------------------ | -------------------- | -------------------------------------------------------- |
| `app-dono-cliente` | app-cliente | `<host port>:8080`, mounts config.toml + cert dir, `--restart unless-stopped`, runs as host uid:gid. |
| `vproxy` | davinti-vproxy | `--cap-add=NET_ADMIN`, `/dev/net/tun`, `--env-file envs`, only without public IP. |
| `app-dono-updater` | docker:cli | Not a third-party updater — a poll loop (`sh -c`) baked into the official `docker:cli` image, since Watchtower was archived upstream in Dec 2025 with no maintained successor recommended for production. Every `updaterPollIntervalSeconds` (300s) it `docker pull`s `app-dono-cliente`'s image, compares image IDs, and if changed, stops/removes/recreates the container using the exact same `docker run` argv as the original start (`appClienteRunArgs` in `docker.go`, shared by both call sites so they can't drift). Mounts `/var/run/docker.sock` and the host's `~/.docker/config.json` (written by the earlier `docker login`) for private-registry auth. Not on `app-dono_app` — only talks to the Docker daemon. |
`seccomp=unconfined` is added to either container when the "Modo Compatibilidade"
(`seccomp_unconfined`) select is `"Sim"` — for old machines.
@@ -132,8 +149,20 @@ make push S3_BUCKET=<bucket> VERSION=<v> # sync dist/ to S3 (version + late
make release S3_BUCKET=<bucket> VERSION=<v> # per-binary copy with OS/arch naming
```
There is no test suite. Verify changes by running `go build ./...` and exercising the
TUI manually. Requires a working Docker daemon and registry access to fully run.
**Versioning:** `tui.Version` ([internal/tui/version.go](internal/tui/version.go))
defaults to `"dev"` and is stamped at build time via `VERSION_LDFLAG`
(`-X .../internal/tui.Version=$(VERSION)`) in the Makefile. `installer --version`
(or `-v`) prints it and exits before the TUI starts; it is also shown in the TUI
header. A plain `go build` leaves it as `"dev"`. The Makefile build rules have no
source deps, so `make build` will not rebuild existing `dist/` artifacts — run
`make clean` first when re-stamping a new VERSION.
Tests (`go test ./...`) cover the pure logic in
[config_test.go](internal/tui/config_test.go) (config.toml/envs generation + numeric
validation) and [form_test.go](internal/tui/form_test.go) (`FormStep.Values()`). The
docker wrappers, Update and View are not unit-tested — verify those by running
`go build ./...` and exercising the TUI manually. Fully running the installer requires
a working Docker daemon and registry access.
## Conventions
+8 -5
View File
@@ -5,29 +5,32 @@ S3_BUCKET ?=
BINARY_NAME ?= installer
BUILD_DIR := ./dist
MODULE := git.davinti.com.br/davinTI/app-dono/tui
VERSION_LDFLAG := -X $(MODULE)/internal/tui.Version=$(VERSION)
ARCHS := linux/amd64 linux/arm64 darwin/amd64 darwin/arm64 windows/amd64
build: $(addprefix $(BUILD_DIR)/$(BINARY_NAME)-, $(subst /,-,$(ARCHS)))
$(BUILD_DIR)/$(BINARY_NAME)-linux-amd64:
@mkdir -p $(BUILD_DIR)
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-extldflags -static" -o $@ ./cmd
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-extldflags -static $(VERSION_LDFLAG)" -o $@ ./cmd
$(BUILD_DIR)/$(BINARY_NAME)-linux-arm64:
@mkdir -p $(BUILD_DIR)
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags="-extldflags -static" -o $@ ./cmd
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags="-extldflags -static $(VERSION_LDFLAG)" -o $@ ./cmd
$(BUILD_DIR)/$(BINARY_NAME)-darwin-amd64:
@mkdir -p $(BUILD_DIR)
CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build -o $@ ./cmd
CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build -ldflags="$(VERSION_LDFLAG)" -o $@ ./cmd
$(BUILD_DIR)/$(BINARY_NAME)-darwin-arm64:
@mkdir -p $(BUILD_DIR)
CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -o $@ ./cmd
CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -ldflags="$(VERSION_LDFLAG)" -o $@ ./cmd
$(BUILD_DIR)/$(BINARY_NAME)-windows-amd64:
@mkdir -p $(BUILD_DIR)
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -o $@ ./cmd
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -ldflags="$(VERSION_LDFLAG)" -o $@ ./cmd
clean:
rm -rf $(BUILD_DIR)
+53 -19
View File
@@ -17,15 +17,18 @@ os arquivos de configuração (`config.toml` e `envs`) e sobe os containers nece
## Sumário
- [O que ele faz](#o-que-ele-faz)
- [Pré-requisitos](#pré-requisitos)
- [Como usar](#como-usar)
- [Fluxo de instalação](#fluxo-de-instalação)
- [Conectividade: IP público vs. vproxy](#conectividade-ip-público-vs-vproxy)
- [Arquivos gerados](#arquivos-gerados)
- [Containers e rede Docker](#containers-e-rede-docker)
- [Build a partir do código](#build-a-partir-do-código)
- [Documentação adicional](#documentação-adicional)
- [App do Dono — Instalador Cliente (TUI)](#app-do-dono--instalador-cliente-tui)
- [Sumário](#sumário)
- [O que ele faz](#o-que-ele-faz)
- [Pré-requisitos](#pré-requisitos)
- [Como usar](#como-usar)
- [Navegação na interface](#navegação-na-interface)
- [Fluxo de instalação](#fluxo-de-instalação)
- [Conectividade: IP público vs. vproxy](#conectividade-ip-público-vs-vproxy)
- [Arquivos gerados](#arquivos-gerados)
- [Containers e rede Docker](#containers-e-rede-docker)
- [Build a partir do código](#build-a-partir-do-código)
- [Documentação adicional](#documentação-adicional)
---
@@ -42,7 +45,9 @@ O instalador conduz o operador por um assistente (wizard) no terminal que:
5. Coleta, via formulários, as configurações de **aplicação, servidor, banco de dados
e certificados**.
6. **Gera o `config.toml`** e sobe o container `app-dono-cliente`.
7. Exibe a confirmação de sucesso.
7. Sobe o container **`app-dono-updater`**, que mantém o `app-dono-cliente`
atualizado automaticamente a cada novo push em `:latest`.
8. Exibe a confirmação de sucesso.
## Pré-requisitos
@@ -80,6 +85,8 @@ go run ./cmd
| `Shift+Tab` / `↑` | Campo anterior |
| `←` / `→` | Alternar opção (campos de seleção) |
| `Enter` | Confirmar campo / avançar etapa |
| `Esc` | Voltar à etapa anterior |
| `r` | Tentar novamente (em telas de erro) |
| Qualquer tecla | Avançar em telas de status |
| `Ctrl+C` | Sair a qualquer momento |
@@ -111,17 +118,26 @@ go run ./cmd
│ │
▼ ▼
┌─────────────────────────────────────────┐
│ Config. Aplicação → Servidor → │
│ Banco de Dados → Certificados │
│ Config. Aplicação → Servidor → │
│ Banco de Dados → Certificados │
└────────────────────┬────────────────────┘
▼
┌──────────────────┐
│ Revisão (Review) │ ← esc volta para editar
└────────┬─────────┘
▼
┌──────────────────┐
│ Gera config.toml │
└────────┬─────────┘
▼
┌──────────────────┐
│ Sobe container │ (app-dono-cliente)
│ app-dono-cliente │
└────────┬─────────┘
▼
┌──────────────────┐
│ Sobe container │ (app-dono-updater,
│ de auto-update │ atualiza o app-cliente sozinho)
└────────┬─────────┘
▼
✅ Concluído
@@ -152,20 +168,29 @@ o instalador (no caso do `config.toml`).
## Containers e rede Docker
Todos os containers são conectados à rede Docker **`app-dono_app`** (criada
automaticamente se não existir).
`app-dono-cliente` e `vproxy` são conectados à rede Docker **`app-dono_app`** (criada
automaticamente se não existir). O `app-dono-updater` fica fora dessa rede — ele só fala
com o daemon Docker via socket, não com os outros containers pela rede.
| Container | Imagem | Quando sobe |
| ------------------ | ------------------------------------------------- | -------------------- |
| `app-dono-cliente` | `hub.davinti.com.br:443/app-dono/app-cliente` | Sempre |
| `vproxy` | `hub.davinti.com.br:443/davinti-vproxy` | Quando não há IP púb.|
| Container | Imagem | Quando sobe |
| --------------------- | ------------------------------------------------ | -------------------- |
| `app-dono-cliente` | `hub.davinti.com.br:443/app-dono/app-cliente` | Sempre |
| `vproxy` | `hub.davinti.com.br:443/davinti-vproxy` | Quando não há IP púb.|
| `app-dono-updater` | `docker:cli` | Sempre |
Características:
- Ambos sobem com `--restart unless-stopped`.
- Todos sobem com `--restart unless-stopped`.
- O container do app expõe a porta configurada no host, mapeando para a `8080` interna,
e monta o `config.toml` e o diretório de certificados como volumes.
- O `vproxy` roda com `--cap-add=NET_ADMIN` e acesso a `/dev/net/tun`.
- O `app-dono-updater` **não** é o Watchtower — esse projeto foi arquivado pelos
mantenedores originais em dez/2025 sem um sucessor mantido recomendado para produção.
Em vez disso, é um loop simples (`sh -c`) rodando na imagem oficial `docker:cli`: a
cada 5 minutos baixa a imagem do `app-dono-cliente`, compara com a que está rodando e,
se mudou, recria o container. Usa as mesmas credenciais do login feito no passo 2 (via
`~/.docker/config.json`) e precisa de acesso ao socket do Docker
(`/var/run/docker.sock`) para poder recriar o container.
- O **modo compatibilidade** (`seccomp=unconfined`) pode ser ativado para máquinas
antigas onde o seccomp padrão causa problemas.
@@ -191,6 +216,15 @@ go build -o installer ./cmd
./installer
```
A versão é gravada no binário em tempo de build (`make build VERSION=1.2.0`) e pode ser
consultada sem abrir a interface:
```bash
./installer --version # ex.: app-dono installer 1.2.0
```
Um `go build` simples (sem o `Makefile`) deixa a versão como `dev`.
## Documentação adicional
- [docs/fluxo.md](docs/fluxo.md) — detalhamento de cada etapa do assistente.
+8
View File
@@ -9,6 +9,14 @@ import (
)
func main() {
if len(os.Args) > 1 {
switch os.Args[1] {
case "--version", "-v":
fmt.Println("app-dono installer", tui.Version)
return
}
}
p := tea.NewProgram(tui.InitialModel())
if _, err := p.Run(); err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
+48
View File
@@ -0,0 +1,48 @@
# TODO — UX / UI improvements
Backlog of usability and interface improvements for the installer TUI. Ordered by
value-to-effort. Status: ☐ pending · ☑ done.
## High value
- ☑ **1. Back navigation between steps.** Maintain a history stack of *input* steps
(forms + IP question + review) and bind `Esc` to go back. Action steps (download,
generate, run) are not part of the stack and are not re-enterable via back. Form
values are preserved because the `FormStep`s live on the `Model`.
- ☑ **2. Review/summary step before running containers.** Show all collected config
(`StepReview`) for confirmation before `StepGenerateFile`/`StepRunDocker`. Natural
anchor for "go back and edit a section" via `Esc`.
- ☑ **3. Retry instead of quit on transient errors.** Image pull / container run
failures offer `r: tentar novamente` (re-runs just that command) instead of throwing
away the whole session. Validation errors (generate file) route back to the relevant
form to fix the value.
## Medium value
- ☐ **4. Center the layout (vertically + horizontally).** Use
`lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, body)` — the
Model already tracks `width`/`height`. Caveat: every `viewXxx`/`form.View` currently
hardcodes a manual left pad (`strings.Repeat(" ", padding)`); to center cleanly,
strip that and wrap the body in one padded/bordered box, then `Place` it. Consider
pinning the help footer to the bottom rather than centering it with the body.
- ☐ **5. Inline field validation.** Numeric validation currently happens only at file
write time (`WriteConfigFile`), far from input. Wire `textinput.Validate` per field
and render a red hint under invalid fields in `form.go`.
- ☐ **6. Step indicator.** Show "Etapa N / Total" or a breadcrumb in the header. Total
is dynamic because the vproxy branch adds steps.
## Low value / cosmetic
- ☐ **7. Replace the fake progress bar.** `viewCheckDocker` animates a random-increment
bar for an instant `LookPath` check, then waits for a keypress. Use a spinner that
resolves immediately (or auto-advance on success).
- ☐ **8. Bordered card + `bubbles/help` footer.** Wrap the body in a rounded
`lipgloss` border and render the footer via `bubbles/help` with a proper key map and
a `?` toggle. Pairs with #4.
- ☐ **9. Password reveal toggle** (`ctrl+r`) on the login password field.
+3 -2
View File
@@ -18,8 +18,9 @@ instalador, seus valores são usados como **padrão** nos formulários.
| `timeout_seconds` | Servidor → Timeout | Em segundos. Padrão `30`. |
| `environment` | Servidor → Ambiente | `development` ou `production`. |
> **Atenção:** o campo "Porta" do formulário define a porta exposta no host, não a
> porta interna. O `config.toml` sempre grava `port = 8080`.
> **Atenção:** o campo "Porta (host)" do formulário define a porta exposta no host,
> não a porta interna. O `config.toml` sempre grava `port = 8080` (constante
> `containerAppPort` em `docker.go`).
### `[database]`
+17 -3
View File
@@ -21,9 +21,23 @@ executadas em segundo plano. As etapas são definidas em
| 11 | `StepServerConfig` | Formulário do servidor (porta, timeout, ambiente, compatibilidade). |
| 12 | `StepDatabaseConfig` | Formulário do banco de dados. |
| 13 | `StepCertConfig` | Formulário do diretório de certificados. |
| 14 | `StepGenerateFile` | Gera o `config.toml`. |
| 15 | `StepRunDocker` | Sobe o container `app-dono-cliente`. |
| 16 | `StepDone` | Mensagem de sucesso. |
| 14 | `StepReview` | Revisão de todas as configurações antes de instalar. |
| 15 | `StepGenerateFile` | Gera o `config.toml`. |
| 16 | `StepRunDocker` | Sobe o container `app-dono-cliente`. |
| 17 | `StepDone` | Mensagem de sucesso. |
## Navegação e tratamento de erros
- **Voltar (`Esc`):** retorna à etapa de entrada anterior (formulários, pergunta de IP
e revisão). O `Model` mantém uma pilha `history` de etapas; etapas de ação (downloads,
geração de arquivo, subida de container) ficam de fora e não são reexecutadas ao
voltar. Os valores já digitados nos formulários são preservados.
- **Revisão (`StepReview`):** antes de gravar qualquer arquivo, todas as configurações
coletadas são exibidas para confirmação. `Enter` confirma e gera o `config.toml`;
`Esc` volta ao formulário anterior para editar.
- **Tentar novamente:** falhas transitórias (download de imagem, subida de container)
oferecem `r` para reexecutar apenas aquele passo (`q` sai). Erros de validação
(geração de arquivo) levam de volta ao formulário correspondente para correção.
## Detalhamento
+10
View File
@@ -114,3 +114,13 @@ func RunWireguardContainer(path string, cv ConfigValues) tea.Cmd {
}
}
}
func RunUpdaterContainer(appImage, appContainerName, configPath, configDestinationPath string, cv ConfigValues) tea.Cmd {
return func() tea.Msg {
err := RunUpdaterDockerContainer(appImage, appContainerName, configPath, configDestinationPath, cv)
return DockerRunMsg{
Err: err,
}
}
}
+6 -1
View File
@@ -13,9 +13,14 @@ func GenerateConfigTOML(cv ConfigValues) (string, error) {
// [server]
sb.WriteString("# Server Configuration\n")
sb.WriteString("[server]\n")
sb.WriteString("port = 8080\n")
// Always containerAppPort: the container listens on this port internally; the
// user's port is the host-side mapping (see RunAppClienteContainer). Persisted
// separately as host_port so it survives as the form default on a re-run.
sb.WriteString(fmt.Sprintf("port = %d\n", containerAppPort))
sb.WriteString(fmt.Sprintf("host_port = %s\n", cv.Server["port"]))
sb.WriteString(fmt.Sprintf("timeout_seconds = %s\n", cv.Server["timeout"]))
sb.WriteString(fmt.Sprintf("environment = %q\n", cv.Server["environment"]))
sb.WriteString(fmt.Sprintf("seccomp_unconfined = %t\n", cv.Server["seccomp_unconfined"] == "Sim"))
sb.WriteString("\n")
// [database]
+253
View File
@@ -0,0 +1,253 @@
package tui
import (
"os"
"path/filepath"
"strings"
"testing"
"github.com/BurntSushi/toml"
)
// fullConfigValues returns a ConfigValues with every field populated with valid data.
func fullConfigValues() ConfigValues {
return ConfigValues{
Login: map[string]string{"user": "u", "password": "p"},
Server: map[string]string{
"port": "9090",
"timeout": "45",
"environment": "production",
"seccomp_unconfined": "Não",
},
Database: map[string]string{
"database_type": "postgres",
"database_url": "postgres://user:pass@db:5432/app_dono_db",
"max_conns": "20",
"min_conns": "5",
},
Cert: map[string]string{"cert_dir_path": "/etc/app-dono/certs"},
Application: map[string]string{
"central_server_url": "https://central.example.com:8443",
"enrollment_token": "tok-123",
},
Wireguard: map[string]string{
"privkey": "PRIVKEY_VALUE",
"vip": "127.0.0.1",
"psk": "PSK_VALUE",
"proxy_edps": "22:127.0.0.1:22",
"mtu": "1380",
"proto": "UDP",
},
}
}
func TestGenerateConfigTOML_RoundTrip(t *testing.T) {
cv := fullConfigValues()
out, err := GenerateConfigTOML(cv)
if err != nil {
t.Fatalf("GenerateConfigTOML returned error: %v", err)
}
var cfg AppConfig
if _, err := toml.Decode(out, &cfg); err != nil {
t.Fatalf("generated output is not valid TOML / failed to decode: %v\n---\n%s", err, out)
}
if cfg.Server.Timeout != 45 {
t.Errorf("timeout_seconds = %d, want 45", cfg.Server.Timeout)
}
if cfg.Server.Environment != "production" {
t.Errorf("environment = %q, want %q", cfg.Server.Environment, "production")
}
if cfg.Server.HostPort != 9090 {
t.Errorf("host_port = %d, want 9090", cfg.Server.HostPort)
}
if cfg.Server.SeccompUnconfined {
t.Errorf("seccomp_unconfined = true, want false")
}
if cfg.Database.Type != "postgres" {
t.Errorf("database type = %q, want %q", cfg.Database.Type, "postgres")
}
if cfg.Database.URL != "postgres://user:pass@db:5432/app_dono_db" {
t.Errorf("database url = %q", cfg.Database.URL)
}
if cfg.Database.MaxConns != 20 {
t.Errorf("max_conns = %d, want 20", cfg.Database.MaxConns)
}
if cfg.Database.MinConns != 5 {
t.Errorf("min_conns = %d, want 5", cfg.Database.MinConns)
}
if cfg.Certificates.DirPath != "/etc/app-dono/certs" {
t.Errorf("mapped_dir = %q", cfg.Certificates.DirPath)
}
if cfg.Application.CentralServerURL != "https://central.example.com:8443" {
t.Errorf("central_server_url = %q", cfg.Application.CentralServerURL)
}
if cfg.Application.EnrollmentToken != "tok-123" {
t.Errorf("enrollment_token = %q", cfg.Application.EnrollmentToken)
}
}
// The container always listens on containerAppPort internally; the user-provided
// "Porta (host)" value is only the host-side mapping and must never leak into the
// generated config.toml. This guard fails loudly if that invariant is reverted.
func TestGenerateConfigTOML_PortIsAlwaysContainerPort(t *testing.T) {
cv := fullConfigValues()
cv.Server["port"] = "9090" // host port, must NOT appear as the config port
out, err := GenerateConfigTOML(cv)
if err != nil {
t.Fatalf("GenerateConfigTOML returned error: %v", err)
}
var cfg AppConfig
if _, err := toml.Decode(out, &cfg); err != nil {
t.Fatalf("failed to decode: %v", err)
}
if cfg.Server.Port != containerAppPort {
t.Errorf("config port = %d, want fixed containerAppPort %d", cfg.Server.Port, containerAppPort)
}
if cfg.Server.HostPort != 9090 {
t.Errorf("host_port = %d, want 9090", cfg.Server.HostPort)
}
}
func TestGenerateWireguardConfig(t *testing.T) {
tests := []struct {
name string
wireguard map[string]string
wantContains []string
wantNotContns []string
}{
{
name: "all fields set",
wireguard: map[string]string{
"privkey": "PK", "vip": "10.0.0.1", "psk": "PSK",
"proxy_edps": "22:127.0.0.1:22", "mtu": "1400", "proto": "TCP",
},
wantContains: []string{
"PRIVKEY=PK", "VIP=10.0.0.1", "PSK=PSK",
"PROXY_EDPS=22:127.0.0.1:22", "MTU=1400", "PROTO=TCP",
},
},
{
name: "empty mtu is commented out",
wireguard: map[string]string{
"privkey": "PK", "vip": "10.0.0.1", "psk": "PSK",
"proxy_edps": "22:127.0.0.1:22", "mtu": "", "proto": "UDP",
},
wantContains: []string{"# MTU=1380", "PROTO=UDP"},
wantNotContns: []string{"\nMTU="},
},
{
name: "empty proto defaults to UDP",
wireguard: map[string]string{
"privkey": "PK", "vip": "10.0.0.1", "psk": "PSK",
"proxy_edps": "22:127.0.0.1:22", "proto": "",
},
wantContains: []string{"PROTO=UDP"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
out, err := GenerateWireguardConfig(ConfigValues{Wireguard: tt.wireguard})
if err != nil {
t.Fatalf("GenerateWireguardConfig returned error: %v", err)
}
for _, want := range tt.wantContains {
if !strings.Contains(out, want) {
t.Errorf("output missing %q:\n%s", want, out)
}
}
for _, notWant := range tt.wantNotContns {
if strings.Contains(out, notWant) {
t.Errorf("output should not contain %q:\n%s", notWant, out)
}
}
})
}
}
func TestWriteConfigFile_NumericValidation(t *testing.T) {
numeric := []string{"port", "timeout", "max_conns", "min_conns"}
for _, field := range numeric {
t.Run("invalid_"+field, func(t *testing.T) {
cv := fullConfigValues()
switch field {
case "port", "timeout":
cv.Server[field] = "abc"
case "max_conns", "min_conns":
cv.Database[field] = "abc"
}
path := filepath.Join(t.TempDir(), "config.toml")
err := WriteConfigFile(cv, path)
if err == nil {
t.Fatalf("expected error for non-numeric %q, got nil", field)
}
if !strings.Contains(err.Error(), field) {
t.Errorf("error %q does not mention field %q", err.Error(), field)
}
if _, statErr := os.Stat(path); statErr == nil {
t.Errorf("file should not have been written on validation failure")
}
})
}
}
func TestWriteConfigFile_ValidWritesFile(t *testing.T) {
cv := fullConfigValues()
path := filepath.Join(t.TempDir(), "config.toml")
if err := WriteConfigFile(cv, path); err != nil {
t.Fatalf("WriteConfigFile returned error: %v", err)
}
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("config file was not written: %v", err)
}
var cfg AppConfig
if _, err := toml.Decode(string(data), &cfg); err != nil {
t.Fatalf("written file is not valid TOML: %v", err)
}
}
func TestWriteWireguardConfigFile_MTUValidation(t *testing.T) {
tests := []struct {
name string
mtu string
wantErr bool
}{
{name: "valid mtu", mtu: "1380", wantErr: false},
{name: "empty mtu allowed", mtu: "", wantErr: false},
{name: "non-numeric mtu rejected", mtu: "big", wantErr: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cv := fullConfigValues()
cv.Wireguard["mtu"] = tt.mtu
path := filepath.Join(t.TempDir(), "envs")
err := WriteWireguardConfigFile(cv, path)
if tt.wantErr && err == nil {
t.Fatalf("expected error for mtu %q, got nil", tt.mtu)
}
if !tt.wantErr && err != nil {
t.Fatalf("unexpected error for mtu %q: %v", tt.mtu, err)
}
if !tt.wantErr {
if _, statErr := os.Stat(path); statErr != nil {
t.Errorf("envs file should have been written: %v", statErr)
}
}
})
}
}
+117 -10
View File
@@ -2,6 +2,7 @@ package tui
import (
"fmt"
"os"
"os/exec"
"os/user"
"path/filepath"
@@ -11,6 +12,10 @@ import (
const networkName = "app-dono_app"
// containerAppPort is the port the app cliente container listens on internally.
// The user-provided port is only the host-side mapping (<host port>:containerAppPort).
const containerAppPort = 8080
func RunContainer(image string, name string, port int) error {
cmd := exec.Command(
@@ -102,28 +107,25 @@ func RunWireguardDockerContainer(envFilePath string, cv ConfigValues) error {
return verifyContainerRunning(containerID)
}
func RunAppClienteContainer(image, containerName, configPath, configDestinationPath string, cv ConfigValues) error {
removeExistingContainer(containerName)
if err := EnsureNetwork(networkName); err != nil {
return err
}
// appClienteRunArgs builds the `docker run` argv for the app-dono-cliente container.
// Shared by RunAppClienteContainer (initial start) and RunUpdaterDockerContainer (which
// re-embeds the same argv in its recreate script), so the two never drift apart.
func appClienteRunArgs(image, containerName, configPath, configDestinationPath string, cv ConfigValues) ([]string, error) {
absPath, err := filepath.Abs(configPath)
if err != nil {
return fmt.Errorf("erro ao resolver caminho absoluto: %w", err)
return nil, fmt.Errorf("erro ao resolver caminho absoluto: %w", err)
}
currentUser, err := user.Current()
if err != nil {
return err
return nil, err
}
uidGid := fmt.Sprintf("%s:%s", currentUser.Uid, currentUser.Gid)
args := []string{
"run", "-d",
"-u", uidGid,
"-p", fmt.Sprintf("%s:8080", cv.Server["port"]),
"-p", fmt.Sprintf("%s:%d", cv.Server["port"], containerAppPort),
"--name", containerName,
"--network", networkName,
"--restart", "unless-stopped",
@@ -134,6 +136,111 @@ func RunAppClienteContainer(image, containerName, configPath, configDestinationP
args = append(args, "--security-opt", "seccomp=unconfined")
}
args = append(args, image)
return args, nil
}
func RunAppClienteContainer(image, containerName, configPath, configDestinationPath string, cv ConfigValues) error {
removeExistingContainer(containerName)
if err := EnsureNetwork(networkName); err != nil {
return err
}
args, err := appClienteRunArgs(image, containerName, configPath, configDestinationPath, cv)
if err != nil {
return err
}
cmd := exec.Command("docker", args...)
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("docker run falhou: %w\noutput: %s", err, string(out))
}
time.Sleep(2 * time.Second)
containerID := strings.TrimSpace(string(out))
return verifyContainerRunning(containerID)
}
// shellQuote POSIX single-quotes s for safe embedding in the updater's poll script:
// wrap in '...', escaping any embedded ' as '\''. Needed because cv fields (e.g.
// cert_dir_path) are operator-entered and end up inside a shell script, not a plain
// argv slot.
func shellQuote(s string) string {
return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"
}
func shellQuoteArgs(args []string) string {
quoted := make([]string, len(args))
for i, a := range args {
quoted[i] = shellQuote(a)
}
return strings.Join(quoted, " ")
}
// updaterPollIntervalSeconds is how often the updater checks the registry for a new
// app-cliente image.
const updaterPollIntervalSeconds = 300
// RunUpdaterDockerContainer starts a tiny self-contained auto-updater for the
// app-dono-cliente container: no third-party updater project, just the official
// `docker:cli` image running a poll loop (docker pull, compare image IDs, recreate on
// change) written in Go and handed to it via `sh -c`. This exists because Watchtower
// (the previous approach) was archived upstream with no maintained drop-in successor
// recommended for production use — see StepRunUpdater in update.go.
//
// It mounts the docker socket (to pull/recreate) and the host's docker config.json
// (written by the StepDockerLogin `docker login`) so `docker pull` can authenticate
// against the private registry.
func RunUpdaterDockerContainer(appImage, appContainerName, configPath, configDestinationPath string, cv ConfigValues) error {
updaterName := "app-dono-updater"
removeExistingContainer(updaterName)
if out, err := PullImage(updaterImageName); err != nil {
return fmt.Errorf("erro ao baixar imagem do atualizador: %w\noutput: %s", err, out)
}
home, err := os.UserHomeDir()
if err != nil {
return fmt.Errorf("erro ao localizar diretório home: %w", err)
}
dockerConfigPath := filepath.Join(home, ".docker", "config.json")
recreateArgs, err := appClienteRunArgs(appImage, appContainerName, configPath, configDestinationPath, cv)
if err != nil {
return err
}
recreateCmd := "docker " + shellQuoteArgs(recreateArgs)
script := fmt.Sprintf(`set -e
IMAGE=%s
NAME=%s
while true; do
docker pull "$IMAGE" >/dev/null 2>&1 || true
CURRENT=$(docker inspect --format '{{.Image}}' "$NAME" 2>/dev/null || true)
LATEST=$(docker inspect --format '{{.Id}}' "$IMAGE" 2>/dev/null || true)
if [ -n "$LATEST" ] && [ "$CURRENT" != "$LATEST" ]; then
docker stop "$NAME" >/dev/null 2>&1 || true
docker rm "$NAME" >/dev/null 2>&1 || true
%s
fi
sleep %d
done
`, shellQuote(appImage), shellQuote(appContainerName), recreateCmd, updaterPollIntervalSeconds)
args := []string{
"run", "-d",
"--name", updaterName,
"--restart", "unless-stopped",
"-v", "/var/run/docker.sock:/var/run/docker.sock",
"-v", fmt.Sprintf("%s:/config.json", dockerConfigPath),
"--log-opt", "max-size=5m",
"--log-opt", "max-file=1",
"--entrypoint", "sh",
updaterImageName,
"-c", script,
}
cmd := exec.Command("docker", args...)
out, err := cmd.CombinedOutput()
+41
View File
@@ -0,0 +1,41 @@
package tui
import (
"os/exec"
"reflect"
"strings"
"testing"
)
// TestShellQuoteArgsRoundTrip guards the updater's poll-script generation: cv fields
// (e.g. cert_dir_path) are operator-entered and get embedded into a shell script run
// inside the updater container. If shellQuote/shellQuoteArgs mis-escapes a value, that's
// a command-injection bug, not just a cosmetic one. This feeds tricky values through a
// real `sh` and checks they come back out exactly as they went in.
func TestShellQuoteArgsRoundTrip(t *testing.T) {
if _, err := exec.LookPath("sh"); err != nil {
t.Skip("sh not available")
}
cases := [][]string{
{"simple"},
{"has space"},
{"it's got a quote"},
{"$(echo injected)"},
{"a;b|c&d"},
{"back`tick`"},
{"multi", "arg space", "o'clock", "$HOME", "'"},
}
for _, args := range cases {
script := "printf '%s\\n' " + shellQuoteArgs(args)
out, err := exec.Command("sh", "-c", script).Output()
if err != nil {
t.Fatalf("sh failed for %v: %v", args, err)
}
got := strings.Split(strings.TrimRight(string(out), "\n"), "\n")
if !reflect.DeepEqual(got, args) {
t.Errorf("round trip mismatch for %v: got %v", args, got)
}
}
}
+19
View File
@@ -17,6 +17,11 @@ const (
FieldTypeNumber
)
// defaultInputWidth is the visible width of text inputs before the first window-size
// message arrives. It must be > 0 (and ideally >= the longest placeholder) so the
// placeholder renders in full; it is overridden responsively via FormStep.SetWidth.
const defaultInputWidth = 50
type FormField struct {
Id string
Label string
@@ -51,6 +56,9 @@ func NewFormStep(title string, fields []FormField) FormStep {
ti := textinput.New()
ti.Placeholder = f.Fields[i].Placeholder
// A non-zero width is required for the placeholder to render in full: with the
// default width of 0 the textinput truncates the placeholder to its first rune.
ti.SetWidth(defaultInputWidth)
if f.Fields[i].Default != "" {
ti.SetValue(f.Fields[i].Default)
}
@@ -129,6 +137,17 @@ func (f *FormStep) Values() map[string]string {
return out
}
// SetWidth resizes every text input to w (selects are unaffected). Called on window
// resize so inputs fill the available terminal width and placeholders render in full.
func (f *FormStep) SetWidth(w int) {
for i := range f.Fields {
if f.Fields[i].Type == FieldTypeSelect {
continue
}
f.Fields[i].input.SetWidth(w)
}
}
// View
func (f FormStep) View() string {
pad := strings.Repeat(" ", padding)
+81
View File
@@ -0,0 +1,81 @@
package tui
import (
"strings"
"testing"
)
// Guards against the bubbles bug where a text input with width 0 renders only the
// first rune of its placeholder. NewFormStep must set a non-zero width.
func TestPlaceholderRendersBeyondFirstRune(t *testing.T) {
const placeholder = "postgres://user@host/db"
f := NewFormStep("t", []FormField{
{Id: "url", Label: "URL", Placeholder: placeholder, Type: FieldTypeText},
})
view := f.Fields[0].input.View()
if !strings.Contains(view, "ostgres://user@host/db") {
t.Errorf("placeholder appears truncated; rendered view = %q", view)
}
}
func TestFormStepValues(t *testing.T) {
f := NewFormStep("Test", []FormField{
{
Id: "name",
Label: "Name",
Default: "john",
Type: FieldTypeText,
},
{
Id: "secret",
Label: "Secret",
Default: "hunter2",
Type: FieldTypePassword,
},
{
Id: "proto",
Label: "Protocol",
Default: "TCP",
Type: FieldTypeSelect,
Options: []string{"UDP", "TCP"},
},
{
Id: "mode",
Label: "Mode",
Type: FieldTypeSelect,
Options: []string{"a", "b", "c"},
},
})
values := f.Values()
want := map[string]string{
"name": "john", // text default
"secret": "hunter2",
"proto": "TCP", // select default resolves to the matching option
"mode": "a", // select with no default falls back to first option
}
for id, exp := range want {
if got := values[id]; got != exp {
t.Errorf("Values()[%q] = %q, want %q", id, got, exp)
}
}
}
func TestFormStepValues_SelectInvalidDefaultFallsBackToFirst(t *testing.T) {
f := NewFormStep("Test", []FormField{
{
Id: "proto",
Label: "Protocol",
Default: "NOPE", // not among options
Type: FieldTypeSelect,
Options: []string{"UDP", "TCP"},
},
})
if got := f.Values()["proto"]; got != "UDP" {
t.Errorf("Values()[proto] = %q, want first option %q", got, "UDP")
}
}
+48 -11
View File
@@ -12,6 +12,7 @@ import (
type Model struct {
currentStep step
history []step // stack of input steps visited, for back navigation
cursor int
width int
height int
@@ -60,9 +61,11 @@ type ConfigValues struct {
type AppConfig struct {
Server struct {
Port int64 `toml:"port"`
Timeout int64 `toml:"timeout_seconds"`
Environment string `toml:"environment"`
Port int64 `toml:"port"`
HostPort int64 `toml:"host_port"`
Timeout int64 `toml:"timeout_seconds"`
Environment string `toml:"environment"`
SeccompUnconfined bool `toml:"seccomp_unconfined"`
} `toml:"server"`
Database struct {
Type string `toml:"type"`
@@ -82,18 +85,19 @@ type AppConfig struct {
func loadConfig() AppConfig {
var config AppConfig
config.Server.Port = 8081
config.Server.Port = containerAppPort
config.Server.HostPort = 8081
config.Server.Timeout = 30
config.Server.Environment = "production"
config.Database.Type = "postgres"
config.Database.URL = "postgres://usuario:senha@banco:5432/app_dono_db"
config.Database.MaxConns = 10
config.Database.MinConns = 2
config.Certificates.DirPath = "/caminho/para/diretorio"
config.Application.CentralServerURL = "https://servidor:8443"
// Database.URL, Certificates.DirPath and Application.CentralServerURL are
// intentionally left empty so the fields show their placeholder instead of a
// pre-filled dummy. A real config.toml (re-run) still seeds them through the
// decode below.
_, err := os.Stat("config.toml")
if err == nil {
@@ -112,6 +116,11 @@ func InitialModel() Model {
s.Spinner = spinner.Dot
s.Style = SpinnerStyle
seccompDefault := "Não"
if cfg.Server.SeccompUnconfined {
seccompDefault = "Sim"
}
return Model{
currentStep: StepCheckDocker,
loginForm: NewFormStep("Login no Repositório Docker", []FormField{
@@ -172,9 +181,9 @@ func InitialModel() Model {
serverForm: NewFormStep("Servidor", []FormField{
{
Id: "port",
Label: "Porta",
Label: "Porta (host)",
Placeholder: "8081",
Default: strconv.FormatInt(cfg.Server.Port, 10),
Default: strconv.FormatInt(cfg.Server.HostPort, 10),
Type: FieldTypeNumber,
CharLimit: 4,
},
@@ -196,7 +205,7 @@ func InitialModel() Model {
{
Id: "seccomp_unconfined",
Label: "Modo Compatibilidade (máquinas antigas)",
Default: "Não",
Default: seccompDefault,
Type: FieldTypeSelect,
Options: []string{"Não", "Sim"},
},
@@ -263,3 +272,31 @@ func InitialModel() Model {
func (m Model) Init() tea.Cmd {
return tea.Batch(CheckDockerCmd(), TickCmd(), m.spinner.Tick)
}
// advance moves to the next step, recording the current step on the history stack when
// it is an input step so that Esc can return to it. Consecutive duplicates are skipped.
func (m *Model) advance(next step) {
if isInputStep(m.currentStep) {
if n := len(m.history); n == 0 || m.history[n-1] != m.currentStep {
m.history = append(m.history, m.currentStep)
}
}
m.currentStep = next
}
// goBack returns to the previous input step on the history stack, if any. It reports
// whether the step changed.
func (m *Model) goBack() bool {
if len(m.history) == 0 {
return false
}
last := m.history[len(m.history)-1]
m.history = m.history[:len(m.history)-1]
m.currentStep = last
return true
}
// canGoBack reports whether Esc would return to a previous step from the current one.
func (m Model) canGoBack() bool {
return isInputStep(m.currentStep) && len(m.history) > 0
}
+105
View File
@@ -0,0 +1,105 @@
package tui
import "testing"
func TestIsInputStep(t *testing.T) {
input := []step{
StepDockerLogin, StepIPQuestion, StepWireguardConfig,
StepAppConfig, StepServerConfig, StepDatabaseConfig,
StepCertConfig, StepReview,
}
for _, s := range input {
if !isInputStep(s) {
t.Errorf("isInputStep(%d) = false, want true", s)
}
}
action := []step{
StepCheckDocker, StepDockerInstall, StepDownloadImage,
StepGenerateWireguardFile, StepDownloadWireguard, StepRunWireguard,
StepGenerateFile, StepRunDocker, StepDone,
}
for _, s := range action {
if isInputStep(s) {
t.Errorf("isInputStep(%d) = true, want false", s)
}
}
}
func TestAdvancePushesInputSteps(t *testing.T) {
m := Model{currentStep: StepAppConfig}
m.advance(StepServerConfig)
if m.currentStep != StepServerConfig {
t.Fatalf("currentStep = %d, want StepServerConfig", m.currentStep)
}
if len(m.history) != 1 || m.history[0] != StepAppConfig {
t.Fatalf("history = %v, want [StepAppConfig]", m.history)
}
}
func TestAdvanceSkipsActionSteps(t *testing.T) {
// Advancing away from an action step must not record it on the stack.
m := Model{currentStep: StepDownloadImage}
m.advance(StepIPQuestion)
if len(m.history) != 0 {
t.Fatalf("history = %v, want empty (action step not recorded)", m.history)
}
}
func TestAdvanceDedupesConsecutive(t *testing.T) {
m := Model{currentStep: StepIPQuestion}
m.advance(StepIPQuestion) // self-advance (e.g. re-entering) must not duplicate
m.currentStep = StepIPQuestion
m.advance(StepIPQuestion)
if len(m.history) > 1 {
t.Fatalf("history = %v, want at most one entry", m.history)
}
}
func TestGoBack(t *testing.T) {
m := Model{currentStep: StepCertConfig, history: []step{StepAppConfig, StepServerConfig, StepDatabaseConfig}}
if !m.goBack() {
t.Fatal("goBack() = false, want true")
}
if m.currentStep != StepDatabaseConfig {
t.Errorf("currentStep = %d, want StepDatabaseConfig", m.currentStep)
}
if len(m.history) != 2 {
t.Errorf("history len = %d, want 2", len(m.history))
}
}
func TestGoBackEmptyHistory(t *testing.T) {
m := Model{currentStep: StepDockerLogin}
if m.goBack() {
t.Error("goBack() = true on empty history, want false")
}
if m.currentStep != StepDockerLogin {
t.Error("currentStep changed on empty goBack")
}
}
func TestCanGoBack(t *testing.T) {
// Input step with history -> can go back.
m := Model{currentStep: StepServerConfig, history: []step{StepAppConfig}}
if !m.canGoBack() {
t.Error("canGoBack() = false, want true for input step with history")
}
// Input step, empty history -> cannot.
m = Model{currentStep: StepDockerLogin}
if m.canGoBack() {
t.Error("canGoBack() = true, want false with empty history")
}
// Action step with history -> cannot (esc handled by the step, not global back).
m = Model{currentStep: StepDownloadImage, history: []step{StepDockerLogin}}
if m.canGoBack() {
t.Error("canGoBack() = true on action step, want false")
}
}
+17
View File
@@ -24,8 +24,25 @@ const (
StepDatabaseConfig
StepCertConfig
// Review
StepReview
// Finalizing
StepGenerateFile
StepRunDocker
StepRunUpdater
StepDone
)
// isInputStep reports whether a step collects user input and therefore participates
// in back navigation. Action/wait steps (downloads, file generation, container runs)
// are excluded so that "back" never re-enters a side-effecting step.
func isInputStep(s step) bool {
switch s {
case StepDockerLogin, StepIPQuestion, StepWireguardConfig,
StepAppConfig, StepServerConfig, StepDatabaseConfig,
StepCertConfig, StepReview:
return true
}
return false
}
+150 -23
View File
@@ -11,6 +11,7 @@ import (
const (
imageName = "hub.davinti.com.br:443/app-dono/app-cliente:latest"
wireguardImageName = "hub.davinti.com.br:443/davinti-vproxy:latest"
updaterImageName = "docker:cli"
configPath = "config.toml"
wireguardConfigPath = "envs"
)
@@ -20,6 +21,12 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch key.String() {
case "ctrl+c":
return m, tea.Quit
case "esc":
// Global "back" on input steps. Action/wait steps handle esc themselves.
if m.canGoBack() {
m.goBack()
return m, nil
}
}
}
@@ -27,6 +34,19 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
// Fit inputs to the terminal: subtract the left padding, the "> " prompt and
// the cursor cell, with a sensible floor for very narrow terminals.
inputWidth := msg.Width - (padding * 2) - 4
if inputWidth < 20 {
inputWidth = 20
}
for _, f := range []*FormStep{
&m.loginForm, &m.wireguardForm, &m.appForm,
&m.serverForm, &m.dbForm, &m.certForm,
} {
f.SetWidth(inputWidth)
}
case spinner.TickMsg:
var cmd tea.Cmd
m.spinner, cmd = m.spinner.Update(msg)
@@ -43,7 +63,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if done {
m.configValues.Login = m.loginForm.Values()
m.currentStep = StepDownloadImage
m.advance(StepDownloadImage)
return m, DownloadImageCmd(imageName, m.configValues.Login["user"], m.configValues.Login["password"])
}
@@ -64,7 +84,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.downloadDone = false
m.downloadMessage = ""
m.downloadError = nil
m.currentStep = StepGenerateWireguardFile
m.advance(StepGenerateWireguardFile)
return m, GenerateWireguardConfigFile(m.configValues, wireguardConfigPath)
}
@@ -82,7 +102,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if done {
m.configValues.Application = m.appForm.Values()
m.currentStep = StepServerConfig
m.advance(StepServerConfig)
}
return m, cmd
@@ -91,7 +111,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if done {
m.configValues.Server = m.serverForm.Values()
m.currentStep = StepDatabaseConfig
m.advance(StepDatabaseConfig)
}
return m, cmd
@@ -100,7 +120,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if done {
m.configValues.Database = m.dbForm.Values()
m.currentStep = StepCertConfig
m.advance(StepCertConfig)
}
return m, cmd
@@ -109,19 +129,18 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if done {
m.configValues.Cert = m.certForm.Values()
m.currentStep = StepGenerateFile
m.finishedFile = false
m.configFileError = nil
return m, GenerateConfigFile(m.configValues, configPath)
m.advance(StepReview)
}
return m, cmd
case StepReview:
return m.updateReview(msg)
case StepGenerateFile:
return m.updateGenerateFile(msg)
case StepRunDocker:
return m.updateRunDocker(msg)
case StepRunUpdater:
return m.updateRunUpdater(msg)
case StepDone:
return m, tea.Quit
}
@@ -146,9 +165,9 @@ func (m Model) updateCheckDocker(msg tea.Msg) (tea.Model, tea.Cmd) {
if m.checkDockerDone && m.checkProgress == 1 {
if m.dockerInstalled {
m.loading = true
m.currentStep = StepDockerLogin
m.advance(StepDockerLogin)
} else {
m.currentStep = StepDockerInstall
m.advance(StepDockerInstall)
}
}
}
@@ -175,9 +194,17 @@ func (m Model) updateDownloadImage(msg tea.Msg) (tea.Model, tea.Cmd) {
case tea.KeyPressMsg:
if m.downloadDone && m.downloadError == nil {
m.currentStep = StepIPQuestion
m.advance(StepIPQuestion)
} else if m.downloadDone {
return m, tea.Quit
switch msg.String() {
case "r":
m.downloadDone = false
m.downloadMessage = ""
m.downloadError = nil
return m, DownloadImageCmd(imageName, m.configValues.Login["user"], m.configValues.Login["password"])
default:
return m, tea.Quit
}
}
}
@@ -201,11 +228,11 @@ func (m Model) updateIPQuestion(msg tea.Msg) (tea.Model, tea.Cmd) {
case "enter":
// Yes
if m.cursor == 0 {
m.currentStep = StepAppConfig
m.advance(StepAppConfig)
return m, nil
}
m.currentStep = StepWireguardConfig
m.advance(StepWireguardConfig)
}
}
@@ -229,7 +256,15 @@ func (m Model) updateDownloadWireguard(msg tea.Msg) (tea.Model, tea.Cmd) {
if m.downloadDone && m.downloadError == nil {
m.currentStep = StepRunWireguard
} else if m.downloadDone {
return m, tea.Quit
switch msg.String() {
case "r":
m.downloadDone = false
m.downloadMessage = ""
m.downloadError = nil
return m, DownloadImageCmd(wireguardImageName, m.configValues.Login["user"], m.configValues.Login["password"])
default:
return m, tea.Quit
}
}
}
@@ -250,7 +285,12 @@ func (m Model) updateGenerateWireguardFile(msg tea.Msg) (tea.Model, tea.Cmd) {
case tea.KeyPressMsg:
if m.finishedFile && m.configFileError != nil {
return m, tea.Quit
// Validation error (e.g. MTU): go back to the form to correct it. The form
// is the top of the history stack, so goBack lands on it without leaving a
// duplicate entry; fall back to a direct set if history is unexpectedly empty.
if !m.goBack() {
m.currentStep = StepWireguardConfig
}
} else if m.finishedFile && m.configFileError == nil {
m.currentStep = StepDownloadWireguard
@@ -269,9 +309,36 @@ func (m Model) updateRunWireguardDocker(msg tea.Msg) (tea.Model, tea.Cmd) {
case tea.KeyPressMsg:
if m.finishedDockerRun && m.dockerRunError != nil {
return m, tea.Quit
switch msg.String() {
case "r":
m.finishedDockerRun = false
m.dockerRunError = nil
return m, RunWireguardContainer(wireguardConfigPath, m.configValues)
default:
return m, tea.Quit
}
} else if m.finishedDockerRun && m.dockerRunError == nil {
m.currentStep = StepAppConfig
m.advance(StepAppConfig)
}
}
return m, nil
}
// updateReview shows the collected configuration for confirmation before any files are
// written. Enter confirms and generates config.toml; Esc (handled globally) goes back
// to the previous form to edit.
func (m Model) updateReview(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyPressMsg:
switch msg.String() {
case "enter":
m.advance(StepGenerateFile)
m.finishedFile = false
m.configFileError = nil
return m, GenerateConfigFile(m.configValues, configPath)
}
}
@@ -286,7 +353,11 @@ func (m Model) updateGenerateFile(msg tea.Msg) (tea.Model, tea.Cmd) {
case tea.KeyPressMsg:
if m.finishedFile && m.configFileError != nil {
return m, tea.Quit
// Validation error: return to the review (top of the stack) so the user can
// navigate back to the forms; direct set as a fallback.
if !m.goBack() {
m.currentStep = StepReview
}
} else if m.finishedFile && m.configFileError == nil {
m.currentStep = StepRunDocker
@@ -314,7 +385,63 @@ func (m Model) updateRunDocker(msg tea.Msg) (tea.Model, tea.Cmd) {
case tea.KeyPressMsg:
if m.finishedDockerRun && m.dockerRunError != nil {
return m, tea.Quit
switch msg.String() {
case "r":
m.finishedDockerRun = false
m.dockerRunError = nil
return m, RunAppContainer(
imageName,
"app-dono-cliente",
configPath,
fmt.Sprintf("/app/%s", configPath),
m.configValues,
)
default:
return m, tea.Quit
}
} else if m.finishedDockerRun && m.dockerRunError == nil {
m.currentStep = StepRunUpdater
m.finishedDockerRun = false
m.dockerRunError = nil
return m, RunUpdaterContainer(
imageName,
"app-dono-cliente",
configPath,
fmt.Sprintf("/app/%s", configPath),
m.configValues,
)
}
}
return m, nil
}
// updateRunUpdater starts the auto-update agent that watches the app-dono-cliente
// container and pulls/recreates it whenever a new image is pushed to :latest.
func (m Model) updateRunUpdater(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case DockerRunMsg:
m.finishedDockerRun = true
m.dockerRunError = msg.Err
case tea.KeyPressMsg:
if m.finishedDockerRun && m.dockerRunError != nil {
switch msg.String() {
case "r":
m.finishedDockerRun = false
m.dockerRunError = nil
return m, RunUpdaterContainer(
imageName,
"app-dono-cliente",
configPath,
fmt.Sprintf("/app/%s", configPath),
m.configValues,
)
default:
return m, tea.Quit
}
} else if m.finishedDockerRun && m.dockerRunError == nil {
m.currentStep = StepDone
}
+6
View File
@@ -0,0 +1,6 @@
package tui
// Version is the installer version. It is stamped at build time via
// -ldflags "-X git.davinti.com.br/davinTI/app-dono/tui/internal/tui.Version=<v>"
// (see the Makefile) and defaults to "dev" for local builds.
var Version = "dev"
+99 -17
View File
@@ -10,15 +10,33 @@ import (
const (
header = "App do Dono - Instalador Cliente"
defaultMsg = "ctrl+c: sair"
anyKeyOutMsg = "qualquer tecla: sair"
formMsg = "tab: próximo campo • enter: confirmar • ctrl+c: sair"
retryMsg = "r: tentar novamente • q: sair"
fixMsg = "qualquer tecla: voltar e corrigir"
formBase = "tab: próximo campo • enter: confirmar"
ipBase = "↑/↓: navegar • enter: selecionar"
reviewBase = "enter: confirmar e instalar"
)
// helpFor builds the footer for an input step, appending "esc: voltar" when back
// navigation is available and always ending with "ctrl+c: sair".
func (m Model) helpFor(base string) string {
parts := []string{}
if base != "" {
parts = append(parts, base)
}
if m.canGoBack() {
parts = append(parts, "esc: voltar")
}
parts = append(parts, "ctrl+c: sair")
return strings.Join(parts, " • ")
}
func (m Model) View() tea.View {
pad := strings.Repeat(" ", padding)
var body string
helpMsg := defaultMsg
helpMsg := m.helpFor("")
switch m.currentStep {
// Docker stuff
@@ -29,53 +47,70 @@ func (m Model) View() tea.View {
helpMsg = anyKeyOutMsg
case StepDockerLogin:
body = m.loginForm.View()
helpMsg = formMsg
helpMsg = m.helpFor(formBase)
case StepDownloadImage:
body = m.viewDownloadImage()
if m.downloadDone && m.downloadError != nil {
helpMsg = retryMsg
}
// IP Stuff
case StepIPQuestion:
body = m.viewIPQuestion()
helpMsg = m.helpFor(ipBase)
case StepWireguardConfig:
body = m.wireguardForm.View()
helpMsg = formMsg
helpMsg = m.helpFor(formBase)
case StepGenerateWireguardFile:
body = m.viewGenerateFile()
if m.finishedFile && m.configFileError != nil {
helpMsg = anyKeyOutMsg
helpMsg = fixMsg
}
case StepDownloadWireguard:
body = m.viewDownloadImage()
if m.downloadDone && m.downloadError != nil {
helpMsg = retryMsg
}
case StepRunWireguard:
body = m.viewDockerRun()
if m.finishedDockerRun && m.dockerRunError != nil {
helpMsg = anyKeyOutMsg
helpMsg = retryMsg
}
// App Config Stuff
case StepAppConfig:
body = m.appForm.View()
helpMsg = formMsg
helpMsg = m.helpFor(formBase)
case StepServerConfig:
body = m.serverForm.View()
helpMsg = formMsg
helpMsg = m.helpFor(formBase)
case StepDatabaseConfig:
body = m.dbForm.View()
helpMsg = formMsg
helpMsg = m.helpFor(formBase)
case StepCertConfig:
body = m.certForm.View()
helpMsg = formMsg
helpMsg = m.helpFor(formBase)
// Review
case StepReview:
body = m.viewReview()
helpMsg = m.helpFor(reviewBase)
// Finalize
case StepGenerateFile:
body = m.viewGenerateFile()
if m.finishedFile && m.configFileError != nil {
helpMsg = anyKeyOutMsg
helpMsg = fixMsg
}
case StepRunDocker:
body = m.viewDockerRun()
if m.finishedDockerRun && m.dockerRunError != nil {
helpMsg = anyKeyOutMsg
helpMsg = retryMsg
}
case StepRunUpdater:
body = m.viewDockerRun()
if m.finishedDockerRun && m.dockerRunError != nil {
helpMsg = retryMsg
}
case StepDone:
body = m.viewDoneMessage()
@@ -84,8 +119,10 @@ func (m Model) View() tea.View {
help := HelpStyle.Render(helpMsg)
title := TitleStyle.Render(header) + HelpStyle.Render(" v"+Version)
v := tea.NewView(fmt.Sprintf("\n%s%s\n\n%s\n\n%s%s\n",
pad, TitleStyle.Render(header),
pad, title,
body,
pad, help,
))
@@ -155,7 +192,7 @@ func (m Model) viewDownloadImage() string {
errText := dualPad + m.downloadMessage + "\n" + dualPad + m.downloadError.Error()
sb.WriteString(ErrorStyle.Width(m.width - (padding * 2)).Align(lipgloss.Left).Render(errText))
sb.WriteString("\n\n" + pad + "Pressione qualquer tecla para sair.")
sb.WriteString("\n\n" + pad + "Pressione 'r' para tentar novamente ou 'q' para sair.")
}
return sb.String()
@@ -187,6 +224,51 @@ func (m Model) viewIPQuestion() string {
return sb.String()
}
func (m Model) viewReview() string {
pad := strings.Repeat(" ", padding)
cv := m.configValues
var sb strings.Builder
sb.WriteString(pad + "Revise as configurações antes de instalar:\n")
section := func(title string, rows [][2]string) {
sb.WriteString("\n" + pad + TitleStyle.Render(title) + "\n")
for _, r := range rows {
sb.WriteString(pad + HelpStyle.Render(r[0]+": ") + r[1] + "\n")
}
}
if len(cv.Wireguard) > 0 {
section("vproxy", [][2]string{
{"IP Virtual", cv.Wireguard["vip"]},
{"Proxy EDPS", cv.Wireguard["proxy_edps"]},
{"MTU", cv.Wireguard["mtu"]},
{"Protocolo", cv.Wireguard["proto"]},
})
}
section("Aplicação", [][2]string{
{"Servidor Central", cv.Application["central_server_url"]},
{"Token de Inscrição", cv.Application["enrollment_token"]},
})
section("Servidor", [][2]string{
{"Porta (host)", cv.Server["port"]},
{"Timeout", cv.Server["timeout"]},
{"Ambiente", cv.Server["environment"]},
{"Modo Compatibilidade", cv.Server["seccomp_unconfined"]},
})
section("Banco de Dados", [][2]string{
{"Tipo", cv.Database["database_type"]},
{"URL", cv.Database["database_url"]},
{"Conexões (máx/mín)", cv.Database["max_conns"] + "/" + cv.Database["min_conns"]},
})
section("Certificado", [][2]string{
{"Diretório", cv.Cert["cert_dir_path"]},
})
return sb.String()
}
func (m Model) viewGenerateFile() string {
pad := strings.Repeat(" ", padding)
var sb strings.Builder
@@ -205,7 +287,7 @@ func (m Model) viewGenerateFile() string {
errText := dualPad + m.configFileError.Error()
sb.WriteString(ErrorStyle.Width(m.width - (padding * 2)).Align(lipgloss.Left).Render(errText))
sb.WriteString("\n\n" + pad + "Tente novamente.")
sb.WriteString("\n\n" + pad + "Pressione qualquer tecla para voltar e corrigir os dados.")
}
}
@@ -230,7 +312,7 @@ func (m Model) viewDockerRun() string {
errText := dualPad + m.dockerRunError.Error()
sb.WriteString(ErrorStyle.Width(m.width - (padding * 2)).Align(lipgloss.Left).Render(errText))
sb.WriteString("\n\n" + pad + "Tente novamente.")
sb.WriteString("\n\n" + pad + "Pressione 'r' para tentar novamente ou 'q' para sair.")
}
}