-
Notifications
You must be signed in to change notification settings - Fork 0
feat(cli): mantle init connection recovery & Docker auto-provisioning (#7) #19
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
15 commits
Select commit
Hold shift + click to select a range
644d24f
docs: add spec for mantle init connection recovery (#7)
michaelmcnees 89842d4
docs: add retry/quit option for non-loopback connection failures
michaelmcnees 0252695
docs: add constant extraction to init recovery spec
michaelmcnees de57136
docs: add implementation plan for init connection recovery (#7)
michaelmcnees a1dcabe
feat(netutil): add IsLoopback host classifier
michaelmcnees 99c0298
feat(dbdefaults): add shared Postgres image and test credential const…
michaelmcnees 94953ae
refactor(budget): extract ResetModeCalendar/ResetModeRolling constants
michaelmcnees de9883d
refactor(config): use netutil.IsLoopback for SSL warning
michaelmcnees c528051
refactor(tests): use dbdefaults constants in all testcontainers setups
michaelmcnees 8fab49a
feat(cli): add Docker auto-provisioning helpers for mantle init
michaelmcnees b04699d
feat(cli): add connection recovery flow to mantle init (#7)
michaelmcnees 8a2eba1
docs(site): simplify quickstart — mantle init handles DB setup (#7)
michaelmcnees 94c45bb
docs: update getting-started guide for new mantle init flow (#7)
michaelmcnees 4a13a4d
fix(cli): replace unsafe syscalls with x/term.IsTerminal
michaelmcnees 750520d
fix: address PR review — paused container, quit option, dbdefaults in…
michaelmcnees File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
981 changes: 981 additions & 0 deletions
981
docs/superpowers/plans/2026-03-24-init-connection-recovery.md
Large diffs are not rendered by default.
Oops, something went wrong.
174 changes: 174 additions & 0 deletions
174
docs/superpowers/specs/2026-03-24-init-connection-recovery-design.md
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,174 @@ | ||
| # `mantle init` Connection Recovery & Quickstart Fix | ||
|
|
||
| **Date:** 2026-03-24 | ||
| **Issue:** [#7 — Get Running in 5 Minutes](https://github.com/dvflw/mantle/issues/7) | ||
| **Status:** Draft | ||
|
|
||
| ## Problem | ||
|
|
||
| The landing page quickstart tells users to run `docker compose up -d` after installing via `go install`. There's no `docker-compose.yml` when you install that way — step 2 immediately fails. The `mantle init` command needs to handle the "no database yet" case gracefully. | ||
|
|
||
| ## Design | ||
|
|
||
| ### Connection Recovery Flow | ||
|
|
||
| `mantle init` already loads config and calls `db.Open`. The change adds a recovery path when the connection fails: | ||
|
|
||
| ``` | ||
| mantle init | ||
| ├─ db.Open succeeds → run migrations → done | ||
| └─ db.Open fails | ||
| ├─ host is NOT loopback → print error with details, offer [R]etry or [Q]uit | ||
| └─ host IS loopback → offer Docker auto-provisioning | ||
| ├─ user accepts | ||
| │ ├─ docker available → start container, wait for ready, run migrations → done | ||
| │ └─ docker unavailable → show message, offer [R]etry or [C]onnection string | ||
| └─ user declines → offer [R]etry or [C]onnection string | ||
| ``` | ||
|
|
||
| ### Loopback Detection | ||
|
|
||
| Parse the host from the configured database URL. Treat as loopback if the host is: | ||
| - `localhost` | ||
| - `127.0.0.1` | ||
| - `::1` | ||
|
|
||
| Use `net/url` to parse the connection string and extract the host. | ||
|
|
||
| ### Docker Auto-Provisioning | ||
|
|
||
| When the user accepts Docker provisioning: | ||
|
|
||
| 1. Check Docker availability: exec `docker info` and check exit code | ||
| 2. Run the container: | ||
| ``` | ||
| docker run -d \ | ||
| --name mantle-postgres \ | ||
| -p 5432:5432 \ | ||
| -e POSTGRES_USER=mantle \ | ||
| -e POSTGRES_PASSWORD=mantle \ | ||
| -e POSTGRES_DB=mantle \ | ||
| -v mantle-pgdata:/var/lib/postgresql/data \ | ||
| postgres:16-alpine | ||
| ``` | ||
| 3. Wait for readiness: poll `db.Open` with backoff (up to ~15s) | ||
| 4. On success: continue to migrations | ||
| 5. On timeout: error with "Container started but Postgres isn't accepting connections" | ||
|
|
||
| Use `os/exec` to run Docker commands. The container config matches the existing defaults in `config.go` so no config persistence is needed. | ||
|
|
||
| If the container name `mantle-postgres` already exists (stopped), remove it first and start fresh. If it's already running, skip straight to the readiness check. | ||
|
|
||
| ### Fallback: No Docker / User Declined | ||
|
|
||
| Present two options: | ||
| ``` | ||
| Can't auto-provision — Docker isn't installed or isn't running. | ||
|
|
||
| [R] Retry (install or start Docker first) | ||
| [C] Enter a Postgres connection string | ||
|
|
||
| Choice [R/c]: | ||
| ``` | ||
|
|
||
| - **Retry**: loop back to Docker availability check | ||
| - **Connection string**: prompt for URL, validate with `db.Open`, on success continue to migrations, on failure show the error and re-prompt | ||
|
|
||
| ### Non-Loopback Failure | ||
|
|
||
| When the configured URL points to a remote host and the connection fails: | ||
| ``` | ||
| Failed to connect to database at db.example.com:5432 | ||
|
|
||
| Error: connection refused | ||
|
|
||
| [R] Retry (fix the issue and try again) | ||
| [Q] Quit | ||
|
|
||
| Choice [R/q]: | ||
| ``` | ||
|
|
||
| Include the underlying error from `db.Open` (timeout, auth failure, TLS, DNS resolution, etc.) so the user can diagnose without guessing. | ||
|
|
||
| - **Retry**: re-reads the config (picks up env var or config file changes made while waiting) and retries `db.Open`. This lets the user fix a typo, adjust a firewall rule, or start their database without restarting `mantle init`. | ||
| - **Quit**: exit 1 | ||
|
|
||
| ### Interactive Input | ||
|
|
||
| Follow the existing pattern from `login.go`: use `fmt.Fscanln(cmd.InOrStdin(), &input)` for prompts. No new dependencies needed. | ||
|
|
||
| When stdin is not a terminal (piped input, CI), skip all interactive prompts and return the connection error directly. Detect with `os.Stdin.Stat()` checking for `ModeCharDevice`. | ||
|
|
||
| ## Constant Extraction | ||
|
|
||
| Before implementing the new init flow, extract shared constants that are currently duplicated across the codebase. This keeps the new code referencing a single source of truth. | ||
|
|
||
| ### `internal/dbdefaults/dbdefaults.go` — shared database & Docker defaults | ||
|
|
||
| | Constant | Value | Current duplication | | ||
| |----------|-------|---------------------| | ||
| | `PostgresImage` | `"postgres:16-alpine"` | 7 test files + docker-compose.yml | | ||
| | `TestUser` | `"mantle"` | 6 testcontainers setups | | ||
| | `TestPassword` | `"mantle"` | 6 testcontainers setups | | ||
| | `TestDatabase` | `"mantle_test"` | 6 testcontainers setups | | ||
| | `ContainerName` | `"mantle-postgres"` | new (Docker provisioning) | | ||
|
|
||
| ### `internal/netutil/loopback.go` — loopback detection | ||
|
|
||
| | Function/Const | Purpose | Current duplication | | ||
| |----------------|---------|---------------------| | ||
| | `IsLoopback(host string) bool` | Returns true for localhost, 127.0.0.1, ::1 | config.go SSL warning + new init.go recovery | | ||
|
|
||
| ### `internal/budget/budget.go` — reset mode constants | ||
|
|
||
| | Constant | Value | Current duplication | | ||
| |----------|-------|---------------------| | ||
| | `ResetModeCalendar` | `"calendar"` | config.go validation + budget logic + tests | | ||
| | `ResetModeRolling` | `"rolling"` | config.go validation + budget logic + tests | | ||
|
|
||
| These already live in the budget package conceptually; just promote the string literals to exported constants. | ||
|
|
||
| ## Files Changed | ||
|
|
||
| ### Modified | ||
|
|
||
| | File | Change | | ||
| |------|--------| | ||
| | `internal/cli/init.go` | Add connection recovery flow, Docker provisioning, interactive prompts | | ||
| | `internal/config/config.go` | Use `netutil.IsLoopback` for SSL warning, use `budget.ResetMode*` constants | | ||
| | `internal/budget/budget.go` | Add `ResetModeCalendar` / `ResetModeRolling` constants, use them in existing logic | | ||
| | `internal/auth/auth_test.go` | Use `dbdefaults` constants for testcontainers setup | | ||
| | `internal/workflow/store_test.go` | Use `dbdefaults` constants | | ||
| | `internal/db/migrate_test.go` | Use `dbdefaults` constants | | ||
| | `internal/secret/store_test.go` | Use `dbdefaults` constants | | ||
| | `internal/engine/test_helpers_test.go` | Use `dbdefaults` constants | | ||
| | `internal/budget/store_test.go` | Use `dbdefaults` constants | | ||
| | `internal/connector/postgres_test.go` | Use `dbdefaults.PostgresImage` | | ||
| | `site/src/components/GetStarted.astro` | Remove `docker compose up -d` from step 2, simplify to just `mantle init` | | ||
| | `site/src/content/docs/getting-started/index.md` | Update quickstart to remove Docker prerequisite, explain `mantle init` handles DB setup | | ||
|
|
||
| ### New | ||
|
|
||
| | File | Purpose | | ||
| |------|---------| | ||
| | `internal/dbdefaults/dbdefaults.go` | Shared Postgres image, test credentials, container name constants | | ||
| | `internal/netutil/loopback.go` | `IsLoopback` function for host classification | | ||
| | `internal/netutil/loopback_test.go` | Tests for loopback detection | | ||
| | `internal/cli/docker.go` | Docker availability check, container start, readiness polling | | ||
| | `internal/cli/init_test.go` | Tests for connection recovery flow, non-interactive fallback | | ||
| | `internal/cli/docker_test.go` | Tests for Docker command construction, container name conflict handling | | ||
|
|
||
| ## Non-Goals | ||
|
|
||
| - **Config file generation**: `mantle init` does not create `mantle.yaml`. The defaults work with the Docker container. | ||
| - **Docker Compose**: we use `docker run`, not `docker compose`. No dependency on a compose file. | ||
| - **Custom port/user/password in Docker flow**: always matches defaults. Users who need custom config can use the connection string prompt. | ||
| - **Container lifecycle management**: `mantle init` starts the container; it doesn't stop or remove it. Users manage that themselves. | ||
|
|
||
| ## Testing Strategy | ||
|
|
||
| - **Loopback detection**: unit test `isLoopback` with localhost, 127.0.0.1, ::1, remote hosts, IPv6 | ||
| - **Non-interactive detection**: unit test that piped stdin skips prompts and returns error | ||
| - **Docker command construction**: verify the exact `docker run` args match defaults | ||
| - **Integration**: testcontainers already covers the migration path; the new code paths are the interactive/Docker shell-out portions which are unit-tested with mocked exec | ||
| - **Site content**: manual verification that quickstart steps are accurate |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,106 @@ | ||
| package cli | ||
|
|
||
| import ( | ||
| "context" | ||
| "fmt" | ||
| "net/url" | ||
| "os/exec" | ||
| "strings" | ||
| "time" | ||
|
|
||
| "github.com/dvflw/mantle/internal/config" | ||
| "github.com/dvflw/mantle/internal/db" | ||
| "github.com/dvflw/mantle/internal/dbdefaults" | ||
| ) | ||
|
|
||
| // dockerRunArgs returns the arguments for `docker run` to start a Postgres | ||
| // container matching Mantle's default configuration. | ||
| func dockerRunArgs() []string { | ||
| return []string{ | ||
| "run", "-d", | ||
| "--name", dbdefaults.ContainerName, | ||
| "-p", "5432:5432", | ||
| "-e", "POSTGRES_USER=" + dbdefaults.User, | ||
| "-e", "POSTGRES_PASSWORD=" + dbdefaults.Password, | ||
| "-e", "POSTGRES_DB=" + dbdefaults.Database, | ||
| "-v", "mantle-pgdata:/var/lib/postgresql/data", | ||
| dbdefaults.PostgresImage, | ||
| } | ||
| } | ||
|
|
||
| // parseHostFromURL extracts the hostname from a Postgres connection URL. | ||
| func parseHostFromURL(rawURL string) string { | ||
| if rawURL == "" { | ||
| return "" | ||
| } | ||
| parsed, err := url.Parse(rawURL) | ||
| if err != nil { | ||
| return "" | ||
| } | ||
| return parsed.Hostname() | ||
| } | ||
|
|
||
| // dockerAvailable checks whether the Docker CLI is installed and the daemon is responsive. | ||
| func dockerAvailable() bool { | ||
| cmd := exec.Command("docker", "info") | ||
| return cmd.Run() == nil | ||
| } | ||
|
|
||
| // dockerContainerStatus returns "running", "exited", or "" (not found) | ||
| // for the mantle-postgres container. | ||
| func dockerContainerStatus() string { | ||
| out, err := exec.Command("docker", "inspect", "-f", "{{.State.Status}}", dbdefaults.ContainerName).Output() | ||
| if err != nil { | ||
| return "" | ||
| } | ||
| return strings.TrimSpace(string(out)) | ||
| } | ||
|
|
||
| // dockerRemoveContainer removes the mantle-postgres container (stopped or otherwise). | ||
| func dockerRemoveContainer() error { | ||
| return exec.Command("docker", "rm", "-f", dbdefaults.ContainerName).Run() | ||
| } | ||
|
|
||
| // dockerStartPostgres starts a new Postgres container and waits for it to accept connections. | ||
| func dockerStartPostgres(cfg config.DatabaseConfig) error { | ||
| // Handle existing container. | ||
| switch dockerContainerStatus() { | ||
| case "running": | ||
| // Already running — just wait for readiness. | ||
| return waitForPostgres(cfg) | ||
| case "exited", "created", "dead", "paused": | ||
| _ = dockerRemoveContainer() | ||
| } | ||
|
|
||
| args := dockerRunArgs() | ||
| out, err := exec.Command("docker", args...).CombinedOutput() | ||
| if err != nil { | ||
| return fmt.Errorf("docker run failed: %w\n%s", err, string(out)) | ||
| } | ||
|
|
||
| return waitForPostgres(cfg) | ||
| } | ||
|
|
||
| // waitForPostgres polls db.Open with backoff until the database accepts connections | ||
| // or the timeout (~15s) is exceeded. | ||
| func waitForPostgres(cfg config.DatabaseConfig) error { | ||
| ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) | ||
| defer cancel() | ||
|
|
||
| delay := 500 * time.Millisecond | ||
| for { | ||
| database, err := db.Open(cfg) | ||
| if err == nil { | ||
| database.Close() | ||
| return nil | ||
| } | ||
| select { | ||
| case <-ctx.Done(): | ||
| return fmt.Errorf("container started but Postgres isn't accepting connections after 15s: %w", err) | ||
| case <-time.After(delay): | ||
| if delay < 2*time.Second { | ||
| delay *= 2 | ||
| } | ||
| } | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,41 @@ | ||
| package cli | ||
|
|
||
| import ( | ||
| "testing" | ||
|
|
||
| "github.com/stretchr/testify/assert" | ||
| ) | ||
|
|
||
| func TestDockerRunArgs(t *testing.T) { | ||
| args := dockerRunArgs() | ||
| assert.Equal(t, []string{ | ||
| "run", "-d", | ||
| "--name", "mantle-postgres", | ||
| "-p", "5432:5432", | ||
| "-e", "POSTGRES_USER=mantle", | ||
| "-e", "POSTGRES_PASSWORD=mantle", | ||
| "-e", "POSTGRES_DB=mantle", | ||
| "-v", "mantle-pgdata:/var/lib/postgresql/data", | ||
| "postgres:16-alpine", | ||
| }, args) | ||
| } | ||
|
|
||
| func TestParseHostFromURL(t *testing.T) { | ||
| tests := []struct { | ||
| name string | ||
| url string | ||
| expected string | ||
| }{ | ||
| {"standard", "postgres://mantle:mantle@localhost:5432/mantle", "localhost"}, | ||
| {"remote", "postgres://user:pass@db.example.com:5432/mydb", "db.example.com"}, | ||
| {"ipv4", "postgres://user:pass@10.0.0.1:5432/mydb", "10.0.0.1"}, | ||
| {"ipv6", "postgres://user:pass@[::1]:5432/mydb", "::1"}, | ||
| {"no-port", "postgres://user:pass@myhost/mydb", "myhost"}, | ||
| {"empty", "", ""}, | ||
| } | ||
| for _, tt := range tests { | ||
| t.Run(tt.name, func(t *testing.T) { | ||
| assert.Equal(t, tt.expected, parseHostFromURL(tt.url)) | ||
| }) | ||
| } | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.