Skip to content

Commit 236e14f

Browse files
feat(cli): mantle init connection recovery & Docker auto-provisioning (#7) (#19)
* docs: add spec for mantle init connection recovery (#7) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * docs: add retry/quit option for non-loopback connection failures Users can fix typos, firewall rules, or start their DB without restarting mantle init. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * docs: add constant extraction to init recovery spec Extract duplicated testcontainers defaults (dbdefaults), loopback detection (netutil), and budget reset modes into shared packages. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * docs: add implementation plan for init connection recovery (#7) 10-task plan covering constant extraction (netutil, dbdefaults, budget modes), Docker auto-provisioning, interactive recovery flow, and quickstart doc updates. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat(netutil): add IsLoopback host classifier * feat(dbdefaults): add shared Postgres image and test credential constants * refactor(budget): extract ResetModeCalendar/ResetModeRolling constants * refactor(config): use netutil.IsLoopback for SSL warning * refactor(tests): use dbdefaults constants in all testcontainers setups Replace hard-coded "postgres:16-alpine", "mantle_test", "mantle"/"mantle" string literals in all 7 test files with dbdefaults.PostgresImage, dbdefaults.TestDatabase, dbdefaults.User, and dbdefaults.Password. The connector/postgres_test.go retains its intentionally distinct ext_test/testuser/testpass credentials and only adopts the image constant. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(cli): add Docker auto-provisioning helpers for mantle init Adds dockerRunArgs, parseHostFromURL, dockerAvailable, dockerContainerStatus, dockerRemoveContainer, dockerStartPostgres, and waitForPostgres — the building blocks for automatic Postgres container provisioning when init detects the database isn't reachable on localhost. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(cli): add connection recovery flow to mantle init (#7) When db.Open fails, classify the host as loopback or remote and offer interactive recovery: Docker auto-provisioning for localhost, retry/quit with config reload for remote hosts. Non-interactive (CI/piped) mode returns the error immediately. Split isInteractive() into platform-specific files using TIOCGETA/TCGETS ioctl instead of ModeCharDevice, so go test on macOS (where /dev/null registers as a char device) correctly reports non-interactive. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * docs(site): simplify quickstart — mantle init handles DB setup (#7) * docs: update getting-started guide for new mantle init flow (#7) * fix(cli): replace unsafe syscalls with x/term.IsTerminal Gosec G103 flagged the raw ioctl calls. Use golang.org/x/term which handles platform-specific TTY detection internally, and consolidate three platform files into one. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: address PR review — paused container, quit option, dbdefaults in config, test names - docker.go: handle "paused" container state in dockerStartPostgres - init.go: add q/quit/back escape from connection string prompt - config.go: build default DB URL from dbdefaults constants - loopback_test.go: add 127.0.0.2 case and named subtests Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent a996ec3 commit 236e14f

22 files changed

Lines changed: 1631 additions & 53 deletions

File tree

docs/superpowers/plans/2026-03-24-init-connection-recovery.md

Lines changed: 981 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
# `mantle init` Connection Recovery & Quickstart Fix
2+
3+
**Date:** 2026-03-24
4+
**Issue:** [#7 — Get Running in 5 Minutes](https://github.com/dvflw/mantle/issues/7)
5+
**Status:** Draft
6+
7+
## Problem
8+
9+
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.
10+
11+
## Design
12+
13+
### Connection Recovery Flow
14+
15+
`mantle init` already loads config and calls `db.Open`. The change adds a recovery path when the connection fails:
16+
17+
```
18+
mantle init
19+
├─ db.Open succeeds → run migrations → done
20+
└─ db.Open fails
21+
├─ host is NOT loopback → print error with details, offer [R]etry or [Q]uit
22+
└─ host IS loopback → offer Docker auto-provisioning
23+
├─ user accepts
24+
│ ├─ docker available → start container, wait for ready, run migrations → done
25+
│ └─ docker unavailable → show message, offer [R]etry or [C]onnection string
26+
└─ user declines → offer [R]etry or [C]onnection string
27+
```
28+
29+
### Loopback Detection
30+
31+
Parse the host from the configured database URL. Treat as loopback if the host is:
32+
- `localhost`
33+
- `127.0.0.1`
34+
- `::1`
35+
36+
Use `net/url` to parse the connection string and extract the host.
37+
38+
### Docker Auto-Provisioning
39+
40+
When the user accepts Docker provisioning:
41+
42+
1. Check Docker availability: exec `docker info` and check exit code
43+
2. Run the container:
44+
```
45+
docker run -d \
46+
--name mantle-postgres \
47+
-p 5432:5432 \
48+
-e POSTGRES_USER=mantle \
49+
-e POSTGRES_PASSWORD=mantle \
50+
-e POSTGRES_DB=mantle \
51+
-v mantle-pgdata:/var/lib/postgresql/data \
52+
postgres:16-alpine
53+
```
54+
3. Wait for readiness: poll `db.Open` with backoff (up to ~15s)
55+
4. On success: continue to migrations
56+
5. On timeout: error with "Container started but Postgres isn't accepting connections"
57+
58+
Use `os/exec` to run Docker commands. The container config matches the existing defaults in `config.go` so no config persistence is needed.
59+
60+
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.
61+
62+
### Fallback: No Docker / User Declined
63+
64+
Present two options:
65+
```
66+
Can't auto-provision — Docker isn't installed or isn't running.
67+
68+
[R] Retry (install or start Docker first)
69+
[C] Enter a Postgres connection string
70+
71+
Choice [R/c]:
72+
```
73+
74+
- **Retry**: loop back to Docker availability check
75+
- **Connection string**: prompt for URL, validate with `db.Open`, on success continue to migrations, on failure show the error and re-prompt
76+
77+
### Non-Loopback Failure
78+
79+
When the configured URL points to a remote host and the connection fails:
80+
```
81+
Failed to connect to database at db.example.com:5432
82+
83+
Error: connection refused
84+
85+
[R] Retry (fix the issue and try again)
86+
[Q] Quit
87+
88+
Choice [R/q]:
89+
```
90+
91+
Include the underlying error from `db.Open` (timeout, auth failure, TLS, DNS resolution, etc.) so the user can diagnose without guessing.
92+
93+
- **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`.
94+
- **Quit**: exit 1
95+
96+
### Interactive Input
97+
98+
Follow the existing pattern from `login.go`: use `fmt.Fscanln(cmd.InOrStdin(), &input)` for prompts. No new dependencies needed.
99+
100+
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`.
101+
102+
## Constant Extraction
103+
104+
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.
105+
106+
### `internal/dbdefaults/dbdefaults.go` — shared database & Docker defaults
107+
108+
| Constant | Value | Current duplication |
109+
|----------|-------|---------------------|
110+
| `PostgresImage` | `"postgres:16-alpine"` | 7 test files + docker-compose.yml |
111+
| `TestUser` | `"mantle"` | 6 testcontainers setups |
112+
| `TestPassword` | `"mantle"` | 6 testcontainers setups |
113+
| `TestDatabase` | `"mantle_test"` | 6 testcontainers setups |
114+
| `ContainerName` | `"mantle-postgres"` | new (Docker provisioning) |
115+
116+
### `internal/netutil/loopback.go` — loopback detection
117+
118+
| Function/Const | Purpose | Current duplication |
119+
|----------------|---------|---------------------|
120+
| `IsLoopback(host string) bool` | Returns true for localhost, 127.0.0.1, ::1 | config.go SSL warning + new init.go recovery |
121+
122+
### `internal/budget/budget.go` — reset mode constants
123+
124+
| Constant | Value | Current duplication |
125+
|----------|-------|---------------------|
126+
| `ResetModeCalendar` | `"calendar"` | config.go validation + budget logic + tests |
127+
| `ResetModeRolling` | `"rolling"` | config.go validation + budget logic + tests |
128+
129+
These already live in the budget package conceptually; just promote the string literals to exported constants.
130+
131+
## Files Changed
132+
133+
### Modified
134+
135+
| File | Change |
136+
|------|--------|
137+
| `internal/cli/init.go` | Add connection recovery flow, Docker provisioning, interactive prompts |
138+
| `internal/config/config.go` | Use `netutil.IsLoopback` for SSL warning, use `budget.ResetMode*` constants |
139+
| `internal/budget/budget.go` | Add `ResetModeCalendar` / `ResetModeRolling` constants, use them in existing logic |
140+
| `internal/auth/auth_test.go` | Use `dbdefaults` constants for testcontainers setup |
141+
| `internal/workflow/store_test.go` | Use `dbdefaults` constants |
142+
| `internal/db/migrate_test.go` | Use `dbdefaults` constants |
143+
| `internal/secret/store_test.go` | Use `dbdefaults` constants |
144+
| `internal/engine/test_helpers_test.go` | Use `dbdefaults` constants |
145+
| `internal/budget/store_test.go` | Use `dbdefaults` constants |
146+
| `internal/connector/postgres_test.go` | Use `dbdefaults.PostgresImage` |
147+
| `site/src/components/GetStarted.astro` | Remove `docker compose up -d` from step 2, simplify to just `mantle init` |
148+
| `site/src/content/docs/getting-started/index.md` | Update quickstart to remove Docker prerequisite, explain `mantle init` handles DB setup |
149+
150+
### New
151+
152+
| File | Purpose |
153+
|------|---------|
154+
| `internal/dbdefaults/dbdefaults.go` | Shared Postgres image, test credentials, container name constants |
155+
| `internal/netutil/loopback.go` | `IsLoopback` function for host classification |
156+
| `internal/netutil/loopback_test.go` | Tests for loopback detection |
157+
| `internal/cli/docker.go` | Docker availability check, container start, readiness polling |
158+
| `internal/cli/init_test.go` | Tests for connection recovery flow, non-interactive fallback |
159+
| `internal/cli/docker_test.go` | Tests for Docker command construction, container name conflict handling |
160+
161+
## Non-Goals
162+
163+
- **Config file generation**: `mantle init` does not create `mantle.yaml`. The defaults work with the Docker container.
164+
- **Docker Compose**: we use `docker run`, not `docker compose`. No dependency on a compose file.
165+
- **Custom port/user/password in Docker flow**: always matches defaults. Users who need custom config can use the connection string prompt.
166+
- **Container lifecycle management**: `mantle init` starts the container; it doesn't stop or remove it. Users manage that themselves.
167+
168+
## Testing Strategy
169+
170+
- **Loopback detection**: unit test `isLoopback` with localhost, 127.0.0.1, ::1, remote hosts, IPv6
171+
- **Non-interactive detection**: unit test that piped stdin skips prompts and returns error
172+
- **Docker command construction**: verify the exact `docker run` args match defaults
173+
- **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
174+
- **Site content**: manual verification that quickstart steps are accurate

go.mod

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,7 @@ require (
137137
golang.org/x/net v0.52.0 // indirect
138138
golang.org/x/sync v0.20.0 // indirect
139139
golang.org/x/sys v0.42.0 // indirect
140+
golang.org/x/term v0.41.0 // indirect
140141
golang.org/x/text v0.35.0 // indirect
141142
google.golang.org/api v0.247.0 // indirect
142143
google.golang.org/genproto v0.0.0-20250603155806-513f23925822 // indirect

internal/auth/auth_test.go

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010

1111
"github.com/dvflw/mantle/internal/config"
1212
"github.com/dvflw/mantle/internal/db"
13+
"github.com/dvflw/mantle/internal/dbdefaults"
1314
"github.com/golang-jwt/jwt/v5"
1415
"github.com/testcontainers/testcontainers-go"
1516
"github.com/testcontainers/testcontainers-go/modules/postgres"
@@ -20,10 +21,10 @@ func setupTestStore(t *testing.T) *Store {
2021
t.Helper()
2122
ctx := context.Background()
2223
pgContainer, err := postgres.Run(ctx,
23-
"postgres:16-alpine",
24-
postgres.WithDatabase("mantle_test"),
25-
postgres.WithUsername("mantle"),
26-
postgres.WithPassword("mantle"),
24+
dbdefaults.PostgresImage,
25+
postgres.WithDatabase(dbdefaults.TestDatabase),
26+
postgres.WithUsername(dbdefaults.User),
27+
postgres.WithPassword(dbdefaults.Password),
2728
testcontainers.WithWaitStrategy(
2829
wait.ForLog("database system is ready to accept connections").
2930
WithOccurrence(2).

internal/budget/budget.go

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,18 @@ import (
66
"time"
77
)
88

9+
// Reset mode constants for budget period calculation.
10+
const (
11+
ResetModeCalendar = "calendar"
12+
ResetModeRolling = "rolling"
13+
)
14+
915
// CurrentPeriodStart returns the start date of the current budget period.
1016
// For "calendar" mode: first day of the current month.
1117
// For "rolling" mode: the most recent occurrence of resetDay (1-28).
1218
func CurrentPeriodStart(now time.Time, mode string, resetDay int) time.Time {
1319
now = now.UTC()
14-
if mode == "rolling" && resetDay >= 1 && resetDay <= 28 {
20+
if mode == ResetModeRolling && resetDay >= 1 && resetDay <= 28 {
1521
if now.Day() >= resetDay {
1622
return time.Date(now.Year(), now.Month(), resetDay, 0, 0, 0, 0, time.UTC)
1723
}

internal/budget/store_test.go

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"github.com/dvflw/mantle/internal/budget"
1010
"github.com/dvflw/mantle/internal/config"
1111
"github.com/dvflw/mantle/internal/db"
12+
"github.com/dvflw/mantle/internal/dbdefaults"
1213
"github.com/stretchr/testify/assert"
1314
"github.com/stretchr/testify/require"
1415
"github.com/testcontainers/testcontainers-go"
@@ -20,10 +21,10 @@ func setupTestDB(t *testing.T) *sql.DB {
2021
t.Helper()
2122
ctx := context.Background()
2223
pgContainer, err := postgres.Run(ctx,
23-
"postgres:16-alpine",
24-
postgres.WithDatabase("mantle_test"),
25-
postgres.WithUsername("mantle"),
26-
postgres.WithPassword("mantle"),
24+
dbdefaults.PostgresImage,
25+
postgres.WithDatabase(dbdefaults.TestDatabase),
26+
postgres.WithUsername(dbdefaults.User),
27+
postgres.WithPassword(dbdefaults.Password),
2728
testcontainers.WithWaitStrategy(
2829
wait.ForLog("database system is ready to accept connections").
2930
WithOccurrence(2).

internal/cli/docker.go

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
package cli
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"net/url"
7+
"os/exec"
8+
"strings"
9+
"time"
10+
11+
"github.com/dvflw/mantle/internal/config"
12+
"github.com/dvflw/mantle/internal/db"
13+
"github.com/dvflw/mantle/internal/dbdefaults"
14+
)
15+
16+
// dockerRunArgs returns the arguments for `docker run` to start a Postgres
17+
// container matching Mantle's default configuration.
18+
func dockerRunArgs() []string {
19+
return []string{
20+
"run", "-d",
21+
"--name", dbdefaults.ContainerName,
22+
"-p", "5432:5432",
23+
"-e", "POSTGRES_USER=" + dbdefaults.User,
24+
"-e", "POSTGRES_PASSWORD=" + dbdefaults.Password,
25+
"-e", "POSTGRES_DB=" + dbdefaults.Database,
26+
"-v", "mantle-pgdata:/var/lib/postgresql/data",
27+
dbdefaults.PostgresImage,
28+
}
29+
}
30+
31+
// parseHostFromURL extracts the hostname from a Postgres connection URL.
32+
func parseHostFromURL(rawURL string) string {
33+
if rawURL == "" {
34+
return ""
35+
}
36+
parsed, err := url.Parse(rawURL)
37+
if err != nil {
38+
return ""
39+
}
40+
return parsed.Hostname()
41+
}
42+
43+
// dockerAvailable checks whether the Docker CLI is installed and the daemon is responsive.
44+
func dockerAvailable() bool {
45+
cmd := exec.Command("docker", "info")
46+
return cmd.Run() == nil
47+
}
48+
49+
// dockerContainerStatus returns "running", "exited", or "" (not found)
50+
// for the mantle-postgres container.
51+
func dockerContainerStatus() string {
52+
out, err := exec.Command("docker", "inspect", "-f", "{{.State.Status}}", dbdefaults.ContainerName).Output()
53+
if err != nil {
54+
return ""
55+
}
56+
return strings.TrimSpace(string(out))
57+
}
58+
59+
// dockerRemoveContainer removes the mantle-postgres container (stopped or otherwise).
60+
func dockerRemoveContainer() error {
61+
return exec.Command("docker", "rm", "-f", dbdefaults.ContainerName).Run()
62+
}
63+
64+
// dockerStartPostgres starts a new Postgres container and waits for it to accept connections.
65+
func dockerStartPostgres(cfg config.DatabaseConfig) error {
66+
// Handle existing container.
67+
switch dockerContainerStatus() {
68+
case "running":
69+
// Already running — just wait for readiness.
70+
return waitForPostgres(cfg)
71+
case "exited", "created", "dead", "paused":
72+
_ = dockerRemoveContainer()
73+
}
74+
75+
args := dockerRunArgs()
76+
out, err := exec.Command("docker", args...).CombinedOutput()
77+
if err != nil {
78+
return fmt.Errorf("docker run failed: %w\n%s", err, string(out))
79+
}
80+
81+
return waitForPostgres(cfg)
82+
}
83+
84+
// waitForPostgres polls db.Open with backoff until the database accepts connections
85+
// or the timeout (~15s) is exceeded.
86+
func waitForPostgres(cfg config.DatabaseConfig) error {
87+
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
88+
defer cancel()
89+
90+
delay := 500 * time.Millisecond
91+
for {
92+
database, err := db.Open(cfg)
93+
if err == nil {
94+
database.Close()
95+
return nil
96+
}
97+
select {
98+
case <-ctx.Done():
99+
return fmt.Errorf("container started but Postgres isn't accepting connections after 15s: %w", err)
100+
case <-time.After(delay):
101+
if delay < 2*time.Second {
102+
delay *= 2
103+
}
104+
}
105+
}
106+
}

internal/cli/docker_test.go

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
package cli
2+
3+
import (
4+
"testing"
5+
6+
"github.com/stretchr/testify/assert"
7+
)
8+
9+
func TestDockerRunArgs(t *testing.T) {
10+
args := dockerRunArgs()
11+
assert.Equal(t, []string{
12+
"run", "-d",
13+
"--name", "mantle-postgres",
14+
"-p", "5432:5432",
15+
"-e", "POSTGRES_USER=mantle",
16+
"-e", "POSTGRES_PASSWORD=mantle",
17+
"-e", "POSTGRES_DB=mantle",
18+
"-v", "mantle-pgdata:/var/lib/postgresql/data",
19+
"postgres:16-alpine",
20+
}, args)
21+
}
22+
23+
func TestParseHostFromURL(t *testing.T) {
24+
tests := []struct {
25+
name string
26+
url string
27+
expected string
28+
}{
29+
{"standard", "postgres://mantle:mantle@localhost:5432/mantle", "localhost"},
30+
{"remote", "postgres://user:pass@db.example.com:5432/mydb", "db.example.com"},
31+
{"ipv4", "postgres://user:pass@10.0.0.1:5432/mydb", "10.0.0.1"},
32+
{"ipv6", "postgres://user:pass@[::1]:5432/mydb", "::1"},
33+
{"no-port", "postgres://user:pass@myhost/mydb", "myhost"},
34+
{"empty", "", ""},
35+
}
36+
for _, tt := range tests {
37+
t.Run(tt.name, func(t *testing.T) {
38+
assert.Equal(t, tt.expected, parseHostFromURL(tt.url))
39+
})
40+
}
41+
}

0 commit comments

Comments
 (0)