From 644d24feffc1c9b93783089e527011e2d1b32e7f Mon Sep 17 00:00:00 2001 From: Michael McNees Date: Tue, 24 Mar 2026 09:28:21 -0400 Subject: [PATCH 01/15] docs: add spec for mantle init connection recovery (#7) Co-Authored-By: Claude Opus 4.6 (1M context) --- ...6-03-24-init-connection-recovery-design.md | 128 ++++++++++++++++++ 1 file changed, 128 insertions(+) create mode 100644 docs/superpowers/specs/2026-03-24-init-connection-recovery-design.md diff --git a/docs/superpowers/specs/2026-03-24-init-connection-recovery-design.md b/docs/superpowers/specs/2026-03-24-init-connection-recovery-design.md new file mode 100644 index 0000000..a43b903 --- /dev/null +++ b/docs/superpowers/specs/2026-03-24-init-connection-recovery-design.md @@ -0,0 +1,128 @@ +# `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, suggest --database-url / env var, exit 1 + └─ 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 + +Check that the database is running and accessible. +Override with --database-url or MANTLE_DATABASE_URL. +``` + +Include the underlying error from `db.Open` (timeout, auth failure, TLS, DNS resolution, etc.) so the user can diagnose without guessing. + +### 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`. + +## Files Changed + +### Modified + +| File | Change | +|------|--------| +| `internal/cli/init.go` | Add connection recovery flow, Docker provisioning, interactive prompts | +| `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/cli/docker.go` | Docker availability check, container start, readiness polling — keeps init.go focused | +| `internal/cli/init_test.go` | Tests for loopback detection, non-interactive fallback, prompt flow (mocked stdin/exec) | +| `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 From 89842d4740f37e3234987021d8be1dab19056cae Mon Sep 17 00:00:00 2001 From: Michael McNees Date: Tue, 24 Mar 2026 09:32:08 -0400 Subject: [PATCH 02/15] 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) --- .../2026-03-24-init-connection-recovery-design.md | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/docs/superpowers/specs/2026-03-24-init-connection-recovery-design.md b/docs/superpowers/specs/2026-03-24-init-connection-recovery-design.md index a43b903..f3d6b48 100644 --- a/docs/superpowers/specs/2026-03-24-init-connection-recovery-design.md +++ b/docs/superpowers/specs/2026-03-24-init-connection-recovery-design.md @@ -18,7 +18,7 @@ The landing page quickstart tells users to run `docker compose up -d` after inst mantle init ├─ db.Open succeeds → run migrations → done └─ db.Open fails - ├─ host is NOT loopback → print error with details, suggest --database-url / env var, exit 1 + ├─ 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 @@ -82,12 +82,17 @@ Failed to connect to database at db.example.com:5432 Error: connection refused -Check that the database is running and accessible. -Override with --database-url or MANTLE_DATABASE_URL. + [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. From 02526958464a23f3f766fcd828cd92e9e900eff4 Mon Sep 17 00:00:00 2001 From: Michael McNees Date: Tue, 24 Mar 2026 09:36:47 -0400 Subject: [PATCH 03/15] 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) --- ...6-03-24-init-connection-recovery-design.md | 45 ++++++++++++++++++- 1 file changed, 43 insertions(+), 2 deletions(-) diff --git a/docs/superpowers/specs/2026-03-24-init-connection-recovery-design.md b/docs/superpowers/specs/2026-03-24-init-connection-recovery-design.md index f3d6b48..a67fde3 100644 --- a/docs/superpowers/specs/2026-03-24-init-connection-recovery-design.md +++ b/docs/superpowers/specs/2026-03-24-init-connection-recovery-design.md @@ -99,6 +99,35 @@ Follow the existing pattern from `login.go`: use `fmt.Fscanln(cmd.InOrStdin(), & 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 @@ -106,6 +135,15 @@ When stdin is not a terminal (piped input, CI), skip all interactive prompts and | 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 | @@ -113,8 +151,11 @@ When stdin is not a terminal (piped input, CI), skip all interactive prompts and | File | Purpose | |------|---------| -| `internal/cli/docker.go` | Docker availability check, container start, readiness polling — keeps init.go focused | -| `internal/cli/init_test.go` | Tests for loopback detection, non-interactive fallback, prompt flow (mocked stdin/exec) | +| `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 From de57136df576c23110b05d82a1655b609392c8d3 Mon Sep 17 00:00:00 2001 From: Michael McNees Date: Tue, 24 Mar 2026 09:44:02 -0400 Subject: [PATCH 04/15] 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) --- .../2026-03-24-init-connection-recovery.md | 981 ++++++++++++++++++ 1 file changed, 981 insertions(+) create mode 100644 docs/superpowers/plans/2026-03-24-init-connection-recovery.md diff --git a/docs/superpowers/plans/2026-03-24-init-connection-recovery.md b/docs/superpowers/plans/2026-03-24-init-connection-recovery.md new file mode 100644 index 0000000..715f962 --- /dev/null +++ b/docs/superpowers/plans/2026-03-24-init-connection-recovery.md @@ -0,0 +1,981 @@ +# `mantle init` Connection Recovery Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make `mantle init` handle missing Postgres gracefully — auto-provision via Docker on localhost, retry/quit on remote hosts — and update the quickstart docs to match. + +**Architecture:** When `db.Open` fails, classify the host as loopback or remote. Loopback failures offer Docker auto-provisioning; remote failures offer retry/quit. Extract duplicated constants (testcontainers defaults, loopback detection, budget modes) into shared packages first. + +**Tech Stack:** Go, Cobra (`cmd.InOrStdin()`/`cmd.OutOrStdout()`), `os/exec` for Docker commands, `net/url` + `net` for host parsing. + +**Spec:** `docs/superpowers/specs/2026-03-24-init-connection-recovery-design.md` + +--- + +### Task 1: Create `internal/netutil/loopback.go` — loopback detection + +**Files:** +- Create: `internal/netutil/loopback.go` +- Create: `internal/netutil/loopback_test.go` + +- [ ] **Step 1: Write the failing tests** + +In `internal/netutil/loopback_test.go`: + +```go +package netutil_test + +import ( + "testing" + + "github.com/dvflw/mantle/internal/netutil" + "github.com/stretchr/testify/assert" +) + +func TestIsLoopback(t *testing.T) { + tests := []struct { + host string + expected bool + }{ + {"localhost", true}, + {"LOCALHOST", true}, + {"Localhost", true}, + {"127.0.0.1", true}, + {"::1", true}, + {"db.example.com", false}, + {"10.0.0.1", false}, + {"192.168.1.1", false}, + {"", false}, + } + for _, tt := range tests { + t.Run(tt.host, func(t *testing.T) { + assert.Equal(t, tt.expected, netutil.IsLoopback(tt.host)) + }) + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./internal/netutil/ -v` +Expected: FAIL — package does not exist yet + +- [ ] **Step 3: Write minimal implementation** + +In `internal/netutil/loopback.go`: + +```go +package netutil + +import ( + "net" + "strings" +) + +// IsLoopback returns true if host is a loopback address: localhost, 127.0.0.1, or ::1. +func IsLoopback(host string) bool { + if strings.EqualFold(host, "localhost") { + return true + } + ip := net.ParseIP(host) + return ip != nil && ip.IsLoopback() +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `go test ./internal/netutil/ -v` +Expected: PASS — all 9 cases + +- [ ] **Step 5: Commit** + +```bash +git add internal/netutil/loopback.go internal/netutil/loopback_test.go +git commit -m "feat(netutil): add IsLoopback host classifier" +``` + +--- + +### Task 2: Adopt `netutil.IsLoopback` in `internal/config/config.go` + +**Files:** +- Modify: `internal/config/config.go:268-280` (SSL warning block) + +- [ ] **Step 1: Run existing config tests as baseline** + +Run: `go test ./internal/config/ -v` +Expected: PASS — all existing tests green + +- [ ] **Step 2: Replace inline loopback logic with `netutil.IsLoopback`** + +In `internal/config/config.go`, replace the SSL warning block (lines ~268-281): + +```go +// Current code: + if dbURL := cfg.Database.URL; dbURL != "" { + if parsed, err := url.Parse(dbURL); err == nil { + host := parsed.Hostname() + ip := net.ParseIP(host) + isLoopback := host != "" && (strings.EqualFold(host, "localhost") || (ip != nil && ip.IsLoopback())) + if !isLoopback { + q := parsed.Query() + if q.Get("sslmode") == "prefer" { + log.Printf("WARNING: database URL uses sslmode=prefer for non-loopback host %q; consider sslmode=require for production", host) + } + } + } + } +``` + +Replace with: + +```go + if dbURL := cfg.Database.URL; dbURL != "" { + if parsed, err := url.Parse(dbURL); err == nil { + host := parsed.Hostname() + if !netutil.IsLoopback(host) { + q := parsed.Query() + if q.Get("sslmode") == "prefer" { + log.Printf("WARNING: database URL uses sslmode=prefer for non-loopback host %q; consider sslmode=require for production", host) + } + } + } + } +``` + +Add import `"github.com/dvflw/mantle/internal/netutil"`. Remove `"net"` from imports if no longer used (check — `net` may be used elsewhere in the file). Remove `"strings"` only if no longer used elsewhere. + +- [ ] **Step 3: Run config tests to verify no regression** + +Run: `go test ./internal/config/ -v` +Expected: PASS — identical behavior + +- [ ] **Step 4: Commit** + +```bash +git add internal/config/config.go +git commit -m "refactor(config): use netutil.IsLoopback for SSL warning" +``` + +--- + +### Task 3: Create `internal/dbdefaults/dbdefaults.go` — shared constants + +**Files:** +- Create: `internal/dbdefaults/dbdefaults.go` + +- [ ] **Step 1: Create the constants package** + +In `internal/dbdefaults/dbdefaults.go`: + +```go +package dbdefaults + +// Runtime defaults — used by Docker auto-provisioning and config defaults. +// These match the default database URL in config.go. +const ( + PostgresImage = "postgres:16-alpine" + User = "mantle" + Password = "mantle" + Database = "mantle" + ContainerName = "mantle-postgres" +) + +// Test defaults — used by testcontainers setups. +const ( + TestDatabase = "mantle_test" +) +``` + +- [ ] **Step 2: Verify it compiles** + +Run: `go build ./internal/dbdefaults/` +Expected: success (no output) + +- [ ] **Step 3: Commit** + +```bash +git add internal/dbdefaults/dbdefaults.go +git commit -m "feat(dbdefaults): add shared Postgres image and test credential constants" +``` + +--- + +### Task 4: Adopt `dbdefaults` in all testcontainers setups + +**Files:** +- Modify: `internal/db/migrate_test.go:19-22` +- Modify: `internal/budget/store_test.go:23-26` +- Modify: `internal/engine/test_helpers_test.go:21-24` +- Modify: `internal/auth/auth_test.go` (find `setupTestDB`) +- Modify: `internal/workflow/store_test.go` (find `setupTestDB`) +- Modify: `internal/secret/store_test.go` (find `setupTestDB`) +- Modify: `internal/connector/postgres_test.go` (find postgres image literal) + +- [ ] **Step 1: Run all tests as baseline** + +Run: `go test ./internal/db/ ./internal/budget/ ./internal/engine/ ./internal/auth/ ./internal/workflow/ ./internal/secret/ ./internal/connector/ -count=1 -short` +Expected: PASS (or SKIP if Docker not available) + +- [ ] **Step 2: Update each test file** + +In each file's `setupTestDB` function, replace the string literals with `dbdefaults` constants. The pattern is the same in every file. Replace: + +```go + pgContainer, err := postgres.Run(ctx, + "postgres:16-alpine", + postgres.WithDatabase("mantle_test"), + postgres.WithUsername("mantle"), + postgres.WithPassword("mantle"), +``` + +With: + +```go + pgContainer, err := postgres.Run(ctx, + dbdefaults.PostgresImage, + postgres.WithDatabase(dbdefaults.TestDatabase), + postgres.WithUsername(dbdefaults.User), + postgres.WithPassword(dbdefaults.Password), +``` + +Add import `"github.com/dvflw/mantle/internal/dbdefaults"` to each file. + +Files to update (7 total): +1. `internal/db/migrate_test.go` +2. `internal/budget/store_test.go` +3. `internal/engine/test_helpers_test.go` +4. `internal/auth/auth_test.go` +5. `internal/workflow/store_test.go` +6. `internal/secret/store_test.go` +7. `internal/connector/postgres_test.go` (only `PostgresImage` — check if it uses different user/db) + +- [ ] **Step 3: Verify compilation** + +Run: `go build ./internal/...` +Expected: success + +- [ ] **Step 4: Run tests to verify no regression** + +Run: `go test ./internal/db/ ./internal/budget/ ./internal/engine/ ./internal/auth/ ./internal/workflow/ ./internal/secret/ ./internal/connector/ -count=1 -short` +Expected: same results as baseline + +- [ ] **Step 5: Commit** + +```bash +git add internal/db/migrate_test.go internal/budget/store_test.go internal/engine/test_helpers_test.go internal/auth/auth_test.go internal/workflow/store_test.go internal/secret/store_test.go internal/connector/postgres_test.go +git commit -m "refactor(tests): use dbdefaults constants in all testcontainers setups" +``` + +--- + +### Task 5: Add budget reset mode constants + +**Files:** +- Modify: `internal/budget/budget.go:1-22` +- Modify: `internal/config/config.go:260-261` + +- [ ] **Step 1: Run baseline tests** + +Run: `go test ./internal/budget/ ./internal/config/ -v` +Expected: PASS + +- [ ] **Step 2: Add constants to budget.go** + +At the top of `internal/budget/budget.go`, after the imports, add: + +```go +// Reset mode constants for budget period calculation. +const ( + ResetModeCalendar = "calendar" + ResetModeRolling = "rolling" +) +``` + +Update `CurrentPeriodStart` to use the constant: + +```go +func CurrentPeriodStart(now time.Time, mode string, resetDay int) time.Time { + now = now.UTC() + if mode == ResetModeRolling && resetDay >= 1 && resetDay <= 28 { +``` + +- [ ] **Step 3: Update config.go validation to use budget constants** + +In `internal/config/config.go`, replace the string literals in validation (line ~261): + +```go +// Current: + if cfg.Engine.Budget.ResetMode == "rolling" { +// Replace with: + if cfg.Engine.Budget.ResetMode == budget.ResetModeRolling { +``` + +Also update the default value assignment (in the defaults block where `ResetMode` is set) if it uses the string literal `"calendar"` — replace with `budget.ResetModeCalendar`. + +Add import `"github.com/dvflw/mantle/internal/budget"` to config.go. + +- [ ] **Step 4: Run tests to verify no regression** + +Run: `go test ./internal/budget/ ./internal/config/ -v` +Expected: PASS — identical behavior + +- [ ] **Step 5: Commit** + +```bash +git add internal/budget/budget.go internal/config/config.go +git commit -m "refactor(budget): extract ResetModeCalendar/ResetModeRolling constants" +``` + +--- + +### Task 6: Create `internal/cli/docker.go` — Docker operations + +**Files:** +- Create: `internal/cli/docker.go` +- Create: `internal/cli/docker_test.go` + +- [ ] **Step 1: Write failing tests** + +In `internal/cli/docker_test.go`: + +```go +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)) + }) + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./internal/cli/ -run "TestDockerRunArgs|TestParseHostFromURL" -v` +Expected: FAIL — functions not defined + +- [ ] **Step 3: Write implementation** + +In `internal/cli/docker.go`: + +```go +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": + _ = 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 + } + } + } +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./internal/cli/ -run "TestDockerRunArgs|TestParseHostFromURL" -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add internal/cli/docker.go internal/cli/docker_test.go +git commit -m "feat(cli): add Docker auto-provisioning helpers for mantle init" +``` + +--- + +### Task 7: Implement connection recovery in `internal/cli/init.go` + +**Files:** +- Modify: `internal/cli/init.go` +- Create: `internal/cli/init_test.go` + +- [ ] **Step 1: Write tests for non-interactive mode and isInteractive** + +In `internal/cli/init_test.go`: + +```go +package cli + +import ( + "bytes" + "testing" + + "github.com/dvflw/mantle/internal/config" + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" +) + +func TestIsInteractive_ReturnsBool(t *testing.T) { + // In test context, stdin is not a TTY — isInteractive should return false. + assert.False(t, isInteractive()) +} + +func TestHandleConnectionFailure_NonInteractive_ReturnsError(t *testing.T) { + // When stdin is not a TTY, handleConnectionFailure should return the + // connection error immediately without prompting. + cmd := &cobra.Command{} + var buf bytes.Buffer + cmd.SetOut(&buf) + + cfg := &config.Config{} + cfg.Database.URL = "postgres://mantle:mantle@localhost:5432/mantle" + + _, err := handleConnectionFailure(cmd, cfg, fmt.Errorf("connection refused")) + assert.Error(t, err) + assert.Contains(t, err.Error(), "connection refused") + // No prompt text should have been written to stdout. + assert.Empty(t, buf.String()) +} +``` + +Add `"fmt"` to imports. + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./internal/cli/ -run "TestIsInteractive|TestHandleConnectionFailure_NonInteractive" -v` +Expected: FAIL — functions not defined yet + +- [ ] **Step 3: Rewrite init.go with connection recovery flow** + +Replace the contents of `internal/cli/init.go` with: + +```go +package cli + +import ( + "database/sql" + "fmt" + "os" + "strings" + + "github.com/dvflw/mantle/internal/config" + "github.com/dvflw/mantle/internal/db" + "github.com/dvflw/mantle/internal/netutil" + "github.com/spf13/cobra" +) + +func newInitCommand() *cobra.Command { + return &cobra.Command{ + Use: "init", + Short: "Initialize Mantle — run database migrations", + Long: "Runs all pending database migrations to set up or upgrade the Mantle schema.\nIf Postgres is not reachable, offers to start one automatically via Docker.", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + cfg := config.FromContext(cmd.Context()) + if cfg == nil { + return fmt.Errorf("config not loaded") + } + + database, err := db.Open(cfg.Database) + if err != nil { + database, err = handleConnectionFailure(cmd, cfg, err) + if err != nil { + return err + } + } + defer database.Close() + + fmt.Fprintln(cmd.OutOrStdout(), "Running migrations...") + if err := db.Migrate(cmd.Context(), database); err != nil { + return fmt.Errorf("migration failed: %w", err) + } + + fmt.Fprintln(cmd.OutOrStdout(), "Migrations complete.") + return nil + }, + } +} + +// handleConnectionFailure is called when the initial db.Open fails. +// It classifies the host and offers interactive recovery options. +func handleConnectionFailure(cmd *cobra.Command, cfg *config.Config, connErr error) (*sql.DB, error) { + host := parseHostFromURL(cfg.Database.URL) + + // Non-interactive mode (piped stdin, CI): just return the error. + if !isInteractive() { + return nil, fmt.Errorf("failed to connect to database: %w", connErr) + } + + if netutil.IsLoopback(host) { + return handleLoopbackFailure(cmd, cfg, connErr) + } + return handleRemoteFailure(cmd, cfg, host, connErr) +} + +// isInteractive returns true if stdin is a terminal (not piped). +func isInteractive() bool { + fi, err := os.Stdin.Stat() + if err != nil { + return false + } + return fi.Mode()&os.ModeCharDevice != 0 +} + +// handleLoopbackFailure offers Docker auto-provisioning for localhost connections. +func handleLoopbackFailure(cmd *cobra.Command, cfg *config.Config, connErr error) (*sql.DB, error) { + out := cmd.OutOrStdout() + in := cmd.InOrStdin() + + fmt.Fprintf(out, "No Postgres found on localhost: %v\n\n", connErr) + fmt.Fprint(out, "Start a Postgres container with Docker? [Y/n]: ") + + var answer string + fmt.Fscanln(in, &answer) + answer = strings.TrimSpace(strings.ToLower(answer)) + + if answer != "" && answer != "y" && answer != "yes" { + return promptConnectionStringOrRetryDocker(cmd, cfg) + } + + // User accepted Docker provisioning. + return attemptDockerProvisioning(cmd, cfg) +} + +// attemptDockerProvisioning checks Docker availability and starts the container. +func attemptDockerProvisioning(cmd *cobra.Command, cfg *config.Config) (*sql.DB, error) { + out := cmd.OutOrStdout() + + if !dockerAvailable() { + fmt.Fprintln(out, "\nDocker isn't installed or isn't running.") + return promptConnectionStringOrRetryDocker(cmd, cfg) + } + + fmt.Fprintln(out, "Starting Postgres container...") + if err := dockerStartPostgres(cfg.Database); err != nil { + return nil, fmt.Errorf("docker provisioning failed: %w", err) + } + + fmt.Fprintln(out, "Postgres is ready.") + return db.Open(cfg.Database) +} + +// promptConnectionStringOrRetryDocker offers [R]etry or [C]onnection string. +func promptConnectionStringOrRetryDocker(cmd *cobra.Command, cfg *config.Config) (*sql.DB, error) { + out := cmd.OutOrStdout() + in := cmd.InOrStdin() + + for { + fmt.Fprintln(out, "") + fmt.Fprintln(out, " [R] Retry (install or start Docker first)") + fmt.Fprintln(out, " [C] Enter a Postgres connection string") + fmt.Fprint(out, "\nChoice [R/c]: ") + + var choice string + fmt.Fscanln(in, &choice) + choice = strings.TrimSpace(strings.ToLower(choice)) + + switch choice { + case "c": + return promptConnectionString(cmd, cfg) + default: + // Retry Docker provisioning. + return attemptDockerProvisioning(cmd, cfg) + } + } +} + +// promptConnectionString asks the user for a connection URL and validates it. +func promptConnectionString(cmd *cobra.Command, cfg *config.Config) (*sql.DB, error) { + out := cmd.OutOrStdout() + in := cmd.InOrStdin() + + for { + fmt.Fprint(out, "Postgres connection string: ") + + var connStr string + fmt.Fscanln(in, &connStr) + connStr = strings.TrimSpace(connStr) + + if connStr == "" { + continue + } + + cfg.Database.URL = connStr + database, err := db.Open(cfg.Database) + if err != nil { + fmt.Fprintf(out, "Connection failed: %v\n", err) + continue + } + return database, nil + } +} + +// handleRemoteFailure shows the error and offers retry/quit for non-loopback hosts. +func handleRemoteFailure(cmd *cobra.Command, cfg *config.Config, host string, connErr error) (*sql.DB, error) { + out := cmd.OutOrStdout() + in := cmd.InOrStdin() + + for { + fmt.Fprintf(out, "Failed to connect to database at %s\n\n", host) + fmt.Fprintf(out, " Error: %v\n\n", connErr) + fmt.Fprintln(out, " [R] Retry (fix the issue and try again)") + fmt.Fprintln(out, " [Q] Quit") + fmt.Fprint(out, "\nChoice [R/q]: ") + + var choice string + fmt.Fscanln(in, &choice) + choice = strings.TrimSpace(strings.ToLower(choice)) + + if choice == "q" { + return nil, fmt.Errorf("failed to connect to database at %s: %w", host, connErr) + } + + // Retry: re-load config to pick up env var / config file changes. + newCfg, err := config.Load(cmd) + if err != nil { + fmt.Fprintf(out, "Config reload error: %v\n", err) + continue + } + cfg.Database = newCfg.Database + + database, err := db.Open(cfg.Database) + if err != nil { + connErr = err + host = parseHostFromURL(cfg.Database.URL) + continue + } + return database, nil + } +} +``` + +- [ ] **Step 4: Fix compilation — verify build succeeds** + +Run: `go build ./internal/cli/` +Expected: success + +- [ ] **Step 5: Run all CLI tests including the new ones** + +Run: `go test ./internal/cli/ -v -short` +Expected: PASS — `TestIsInteractive`, `TestHandleConnectionFailure_NonInteractive`, `TestDockerRunArgs`, `TestParseHostFromURL` all pass + +- [ ] **Step 6: Commit** + +```bash +git add internal/cli/init.go internal/cli/init_test.go +git commit -m "feat(cli): add connection recovery flow to mantle init (#7)" +``` + +--- + +### Task 8: Update landing page quickstart + +**Files:** +- Modify: `site/src/components/GetStarted.astro` + +- [ ] **Step 1: Update the steps array** + +In `site/src/components/GetStarted.astro`, replace the steps array (lines 2-23): + +```javascript +const steps = [ + { + number: '1', + title: 'Install', + code: 'go install github.com/dvflw/mantle/cmd/mantle@latest', + }, + { + number: '2', + title: 'Initialize', + code: 'mantle init\n# Starts Postgres via Docker if needed, then runs migrations', + }, + { + number: '3', + title: 'Apply your first workflow', + code: 'mantle apply examples/hello-world.yaml\n# Applied hello-world version 1', + }, + { + number: '4', + title: 'Run it', + code: 'mantle run hello-world\n# Running hello-world (version 1)...\n# Execution a1b2c3d4: completed\n# fetch: completed (1.0s)', + }, +]; +``` + +Key changes: Step 2 title changes from "Start Postgres and initialize" to "Initialize". The `docker compose up -d` line is removed. A comment explains what `mantle init` does. + +- [ ] **Step 2: Verify the site builds** + +Run: `cd site && npm run build` (or whatever the build command is — check `site/package.json`) +Expected: success + +- [ ] **Step 3: Commit** + +```bash +git add site/src/components/GetStarted.astro +git commit -m "docs(site): simplify quickstart — mantle init handles DB setup (#7)" +``` + +--- + +### Task 9: Update getting-started docs + +**Files:** +- Modify: `site/src/content/docs/getting-started/index.md` + +- [ ] **Step 1: Update prerequisites section** + +Replace the prerequisites section (lines 9-22) — Docker is no longer required: + +```markdown +## Prerequisites + +You need the following installed on your machine: + +- **Go 1.25+** -- [install instructions](https://go.dev/doc/install) +- **Docker** (optional) -- [install instructions](https://docs.docker.com/get-docker/) -- used for automatic local Postgres provisioning + +Verify your setup: + +```bash +go version # go1.25 or later +``` +``` + +- [ ] **Step 2: Update the install/start section** + +Replace the "Install and Start" section (lines 24-43) with two paths — `go install` (primary) and clone (development): + +```markdown +## Install and Start (< 2 minutes) + +Install the binary and initialize: + +```bash +go install github.com/dvflw/mantle/cmd/mantle@latest +mantle init +``` + +`mantle init` connects to Postgres and runs migrations. If no database is reachable on localhost, it offers to start one automatically via Docker. For remote databases, set the URL before running init: + +```bash +export MANTLE_DATABASE_URL="postgres://mantle:secret@db.example.com:5432/mantle?sslmode=require" +mantle init +``` + +You should see: + +``` +Running migrations... +Migrations complete. +``` + +**Development setup:** If you want to build from source, clone the repository and use `make build` instead of `go install`: + +```bash +git clone https://github.com/dvflw/mantle.git && cd mantle +make build +./mantle init +``` + +See [Configuration](/docs/configuration) for all database options. +``` + +Remove the paragraph about `docker compose up -d` and `sslmode=prefer` (lines 35-43). The new text covers both install paths and explains the Docker auto-provisioning. + +- [ ] **Step 3: Verify the site builds** + +Run: `cd site && npm run build` +Expected: success + +- [ ] **Step 4: Commit** + +```bash +git add site/src/content/docs/getting-started/index.md +git commit -m "docs: update getting-started guide for new mantle init flow (#7)" +``` + +--- + +### Task 10: Manual smoke test + +- [ ] **Step 1: Build the binary** + +```bash +cd /Users/michael/Development/mantle +make build +``` + +- [ ] **Step 2: Test happy path (Docker running, Postgres available)** + +```bash +docker compose up -d # ensure Postgres is running +./mantle init +``` + +Expected: "Running migrations... Migrations complete." + +- [ ] **Step 3: Test Docker auto-provisioning (no Postgres running)** + +```bash +docker compose down +docker rm -f mantle-postgres 2>/dev/null +./mantle init +``` + +Expected: prompts "Start a Postgres container with Docker? [Y/n]". Accept with Enter/Y. Should start container, wait for readiness, run migrations. + +- [ ] **Step 4: Test non-interactive mode** + +```bash +docker compose down +echo "" | ./mantle init +``` + +Expected: returns error immediately, no prompts. + +- [ ] **Step 5: Test remote failure with retry** + +```bash +MANTLE_DATABASE_URL="postgres://user:pass@db.doesnotexist.com:5432/mantle" ./mantle init +``` + +Expected: shows connection error with host, offers Retry/Quit. Press Q to quit. + +- [ ] **Step 6: Run full test suite** + +```bash +make test +make lint +``` + +Expected: all tests pass, no lint errors. + +- [ ] **Step 7: Clean up and final commit if needed** + +```bash +docker rm -f mantle-postgres 2>/dev/null +docker compose up -d # restore normal dev state +``` From a1dcabeb63390b8ee2baa3564515f899dd74b99d Mon Sep 17 00:00:00 2001 From: Michael McNees Date: Tue, 24 Mar 2026 09:45:34 -0400 Subject: [PATCH 05/15] feat(netutil): add IsLoopback host classifier --- internal/netutil/loopback.go | 15 +++++++++++++++ internal/netutil/loopback_test.go | 30 ++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+) create mode 100644 internal/netutil/loopback.go create mode 100644 internal/netutil/loopback_test.go diff --git a/internal/netutil/loopback.go b/internal/netutil/loopback.go new file mode 100644 index 0000000..90e8e81 --- /dev/null +++ b/internal/netutil/loopback.go @@ -0,0 +1,15 @@ +package netutil + +import ( + "net" + "strings" +) + +// IsLoopback returns true if host is a loopback address: localhost, 127.0.0.1, or ::1. +func IsLoopback(host string) bool { + if strings.EqualFold(host, "localhost") { + return true + } + ip := net.ParseIP(host) + return ip != nil && ip.IsLoopback() +} diff --git a/internal/netutil/loopback_test.go b/internal/netutil/loopback_test.go new file mode 100644 index 0000000..635a372 --- /dev/null +++ b/internal/netutil/loopback_test.go @@ -0,0 +1,30 @@ +package netutil_test + +import ( + "testing" + + "github.com/dvflw/mantle/internal/netutil" + "github.com/stretchr/testify/assert" +) + +func TestIsLoopback(t *testing.T) { + tests := []struct { + host string + expected bool + }{ + {"localhost", true}, + {"LOCALHOST", true}, + {"Localhost", true}, + {"127.0.0.1", true}, + {"::1", true}, + {"db.example.com", false}, + {"10.0.0.1", false}, + {"192.168.1.1", false}, + {"", false}, + } + for _, tt := range tests { + t.Run(tt.host, func(t *testing.T) { + assert.Equal(t, tt.expected, netutil.IsLoopback(tt.host)) + }) + } +} From 99c0298f7c09e66db153fa44fd214df8a832cc9f Mon Sep 17 00:00:00 2001 From: Michael McNees Date: Tue, 24 Mar 2026 09:45:40 -0400 Subject: [PATCH 06/15] feat(dbdefaults): add shared Postgres image and test credential constants --- internal/dbdefaults/dbdefaults.go | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 internal/dbdefaults/dbdefaults.go diff --git a/internal/dbdefaults/dbdefaults.go b/internal/dbdefaults/dbdefaults.go new file mode 100644 index 0000000..e9d8222 --- /dev/null +++ b/internal/dbdefaults/dbdefaults.go @@ -0,0 +1,16 @@ +package dbdefaults + +// Runtime defaults — used by Docker auto-provisioning and config defaults. +// These match the default database URL in config.go. +const ( + PostgresImage = "postgres:16-alpine" + User = "mantle" + Password = "mantle" + Database = "mantle" + ContainerName = "mantle-postgres" +) + +// Test defaults — used by testcontainers setups. +const ( + TestDatabase = "mantle_test" +) From 94953ae33873c105295f753eb5e1685a79f312bd Mon Sep 17 00:00:00 2001 From: Michael McNees Date: Tue, 24 Mar 2026 09:46:06 -0400 Subject: [PATCH 07/15] refactor(budget): extract ResetModeCalendar/ResetModeRolling constants --- internal/budget/budget.go | 8 +++++++- internal/config/config.go | 5 +++-- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/internal/budget/budget.go b/internal/budget/budget.go index 91948c2..c597272 100644 --- a/internal/budget/budget.go +++ b/internal/budget/budget.go @@ -6,12 +6,18 @@ import ( "time" ) +// Reset mode constants for budget period calculation. +const ( + ResetModeCalendar = "calendar" + ResetModeRolling = "rolling" +) + // CurrentPeriodStart returns the start date of the current budget period. // For "calendar" mode: first day of the current month. // For "rolling" mode: the most recent occurrence of resetDay (1-28). func CurrentPeriodStart(now time.Time, mode string, resetDay int) time.Time { now = now.UTC() - if mode == "rolling" && resetDay >= 1 && resetDay <= 28 { + if mode == ResetModeRolling && resetDay >= 1 && resetDay <= 28 { if now.Day() >= resetDay { return time.Date(now.Year(), now.Month(), resetDay, 0, 0, 0, 0, time.UTC) } diff --git a/internal/config/config.go b/internal/config/config.go index 2825021..f749078 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -12,6 +12,7 @@ import ( "strings" "time" + "github.com/dvflw/mantle/internal/budget" "github.com/spf13/cobra" "github.com/spf13/viper" ) @@ -162,7 +163,7 @@ func Load(cmd *cobra.Command) (*Config, error) { v.SetDefault("engine.default_max_tool_calls_per_round", 10) // Budget defaults - v.SetDefault("engine.budget.reset_mode", "calendar") + v.SetDefault("engine.budget.reset_mode", budget.ResetModeCalendar) v.SetDefault("engine.budget.reset_day", 1) v.SetDefault("engine.budget.global_monthly_token_limit", 0) v.SetDefault("engine.budget.default_team_monthly_token_limit", 0) @@ -258,7 +259,7 @@ func Load(cmd *cobra.Command) (*Config, error) { // Validate budget reset_day range. if cfg.Engine.Budget.ResetDay < 1 || cfg.Engine.Budget.ResetDay > 28 { - if cfg.Engine.Budget.ResetMode == "rolling" { + if cfg.Engine.Budget.ResetMode == budget.ResetModeRolling { return nil, fmt.Errorf("engine.budget.reset_day must be between 1 and 28, got %d", cfg.Engine.Budget.ResetDay) } // For calendar mode, reset_day is ignored, so just clamp it silently. From de9883d47e592427b2e854ce8b2003ebc739e83e Mon Sep 17 00:00:00 2001 From: Michael McNees Date: Tue, 24 Mar 2026 09:47:14 -0400 Subject: [PATCH 08/15] refactor(config): use netutil.IsLoopback for SSL warning --- internal/config/config.go | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/internal/config/config.go b/internal/config/config.go index f749078..e1b5952 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -6,13 +6,12 @@ import ( "encoding/hex" "fmt" "log" - "net" "net/url" "os" - "strings" "time" "github.com/dvflw/mantle/internal/budget" + "github.com/dvflw/mantle/internal/netutil" "github.com/spf13/cobra" "github.com/spf13/viper" ) @@ -270,9 +269,7 @@ func Load(cmd *cobra.Command) (*Config, error) { if dbURL := cfg.Database.URL; dbURL != "" { if parsed, err := url.Parse(dbURL); err == nil { host := parsed.Hostname() - ip := net.ParseIP(host) - isLoopback := host != "" && (strings.EqualFold(host, "localhost") || (ip != nil && ip.IsLoopback())) - if !isLoopback { + if !netutil.IsLoopback(host) { q := parsed.Query() if q.Get("sslmode") == "prefer" { log.Printf("WARNING: database URL uses sslmode=prefer for non-loopback host %q; consider sslmode=require for production", host) From c528051122827b4e00b888696c43626e9e21e21f Mon Sep 17 00:00:00 2001 From: Michael McNees Date: Tue, 24 Mar 2026 19:31:28 -0400 Subject: [PATCH 09/15] 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 --- internal/auth/auth_test.go | 9 +++++---- internal/budget/store_test.go | 9 +++++---- internal/connector/postgres_test.go | 3 ++- internal/db/migrate_test.go | 9 +++++---- internal/engine/test_helpers_test.go | 9 +++++---- internal/secret/store_test.go | 9 +++++---- internal/workflow/store_test.go | 9 +++++---- 7 files changed, 32 insertions(+), 25 deletions(-) diff --git a/internal/auth/auth_test.go b/internal/auth/auth_test.go index 8ba2ba0..e2667a6 100644 --- a/internal/auth/auth_test.go +++ b/internal/auth/auth_test.go @@ -10,6 +10,7 @@ import ( "github.com/dvflw/mantle/internal/config" "github.com/dvflw/mantle/internal/db" + "github.com/dvflw/mantle/internal/dbdefaults" "github.com/golang-jwt/jwt/v5" "github.com/testcontainers/testcontainers-go" "github.com/testcontainers/testcontainers-go/modules/postgres" @@ -20,10 +21,10 @@ func setupTestStore(t *testing.T) *Store { t.Helper() ctx := context.Background() pgContainer, err := postgres.Run(ctx, - "postgres:16-alpine", - postgres.WithDatabase("mantle_test"), - postgres.WithUsername("mantle"), - postgres.WithPassword("mantle"), + dbdefaults.PostgresImage, + postgres.WithDatabase(dbdefaults.TestDatabase), + postgres.WithUsername(dbdefaults.User), + postgres.WithPassword(dbdefaults.Password), testcontainers.WithWaitStrategy( wait.ForLog("database system is ready to accept connections"). WithOccurrence(2). diff --git a/internal/budget/store_test.go b/internal/budget/store_test.go index 99bd164..aa413a6 100644 --- a/internal/budget/store_test.go +++ b/internal/budget/store_test.go @@ -9,6 +9,7 @@ import ( "github.com/dvflw/mantle/internal/budget" "github.com/dvflw/mantle/internal/config" "github.com/dvflw/mantle/internal/db" + "github.com/dvflw/mantle/internal/dbdefaults" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/testcontainers/testcontainers-go" @@ -20,10 +21,10 @@ func setupTestDB(t *testing.T) *sql.DB { t.Helper() ctx := context.Background() pgContainer, err := postgres.Run(ctx, - "postgres:16-alpine", - postgres.WithDatabase("mantle_test"), - postgres.WithUsername("mantle"), - postgres.WithPassword("mantle"), + dbdefaults.PostgresImage, + postgres.WithDatabase(dbdefaults.TestDatabase), + postgres.WithUsername(dbdefaults.User), + postgres.WithPassword(dbdefaults.Password), testcontainers.WithWaitStrategy( wait.ForLog("database system is ready to accept connections"). WithOccurrence(2). diff --git a/internal/connector/postgres_test.go b/internal/connector/postgres_test.go index f22aaee..8befff9 100644 --- a/internal/connector/postgres_test.go +++ b/internal/connector/postgres_test.go @@ -5,6 +5,7 @@ import ( "testing" "time" + "github.com/dvflw/mantle/internal/dbdefaults" "github.com/jackc/pgx/v5" "github.com/testcontainers/testcontainers-go" tcpostgres "github.com/testcontainers/testcontainers-go/modules/postgres" @@ -17,7 +18,7 @@ func setupExternalPG(t *testing.T) string { t.Helper() ctx := context.Background() pgContainer, err := tcpostgres.Run(ctx, - "postgres:16-alpine", + dbdefaults.PostgresImage, tcpostgres.WithDatabase("ext_test"), tcpostgres.WithUsername("testuser"), tcpostgres.WithPassword("testpass"), diff --git a/internal/db/migrate_test.go b/internal/db/migrate_test.go index 20efdf6..738041c 100644 --- a/internal/db/migrate_test.go +++ b/internal/db/migrate_test.go @@ -7,6 +7,7 @@ import ( "time" "github.com/dvflw/mantle/internal/config" + "github.com/dvflw/mantle/internal/dbdefaults" "github.com/testcontainers/testcontainers-go" "github.com/testcontainers/testcontainers-go/modules/postgres" "github.com/testcontainers/testcontainers-go/wait" @@ -16,10 +17,10 @@ func setupTestDB(t *testing.T) string { t.Helper() ctx := context.Background() pgContainer, err := postgres.Run(ctx, - "postgres:16-alpine", - postgres.WithDatabase("mantle_test"), - postgres.WithUsername("mantle"), - postgres.WithPassword("mantle"), + dbdefaults.PostgresImage, + postgres.WithDatabase(dbdefaults.TestDatabase), + postgres.WithUsername(dbdefaults.User), + postgres.WithPassword(dbdefaults.Password), testcontainers.WithWaitStrategy( wait.ForLog("database system is ready to accept connections"). WithOccurrence(2). diff --git a/internal/engine/test_helpers_test.go b/internal/engine/test_helpers_test.go index 0fcc14f..91fa152 100644 --- a/internal/engine/test_helpers_test.go +++ b/internal/engine/test_helpers_test.go @@ -8,6 +8,7 @@ import ( "github.com/dvflw/mantle/internal/config" "github.com/dvflw/mantle/internal/db" + "github.com/dvflw/mantle/internal/dbdefaults" "github.com/testcontainers/testcontainers-go" "github.com/testcontainers/testcontainers-go/modules/postgres" @@ -18,10 +19,10 @@ func setupTestDB(t *testing.T) *sql.DB { t.Helper() ctx := context.Background() pgContainer, err := postgres.Run(ctx, - "postgres:16-alpine", - postgres.WithDatabase("mantle_test"), - postgres.WithUsername("mantle"), - postgres.WithPassword("mantle"), + dbdefaults.PostgresImage, + postgres.WithDatabase(dbdefaults.TestDatabase), + postgres.WithUsername(dbdefaults.User), + postgres.WithPassword(dbdefaults.Password), testcontainers.WithWaitStrategy( wait.ForLog("database system is ready to accept connections"). WithOccurrence(2). diff --git a/internal/secret/store_test.go b/internal/secret/store_test.go index a0805f0..366feb6 100644 --- a/internal/secret/store_test.go +++ b/internal/secret/store_test.go @@ -8,6 +8,7 @@ import ( "github.com/dvflw/mantle/internal/config" "github.com/dvflw/mantle/internal/db" + "github.com/dvflw/mantle/internal/dbdefaults" "github.com/testcontainers/testcontainers-go" "github.com/testcontainers/testcontainers-go/modules/postgres" "github.com/testcontainers/testcontainers-go/wait" @@ -17,10 +18,10 @@ func setupTestStore(t *testing.T) *Store { t.Helper() ctx := context.Background() pgContainer, err := postgres.Run(ctx, - "postgres:16-alpine", - postgres.WithDatabase("mantle_test"), - postgres.WithUsername("mantle"), - postgres.WithPassword("mantle"), + dbdefaults.PostgresImage, + postgres.WithDatabase(dbdefaults.TestDatabase), + postgres.WithUsername(dbdefaults.User), + postgres.WithPassword(dbdefaults.Password), testcontainers.WithWaitStrategy( wait.ForLog("database system is ready to accept connections"). WithOccurrence(2). diff --git a/internal/workflow/store_test.go b/internal/workflow/store_test.go index 6465975..b2e63ab 100644 --- a/internal/workflow/store_test.go +++ b/internal/workflow/store_test.go @@ -10,6 +10,7 @@ import ( "github.com/dvflw/mantle/internal/config" "github.com/dvflw/mantle/internal/db" + "github.com/dvflw/mantle/internal/dbdefaults" "github.com/testcontainers/testcontainers-go" "github.com/testcontainers/testcontainers-go/modules/postgres" "github.com/testcontainers/testcontainers-go/wait" @@ -19,10 +20,10 @@ func setupTestDB(t *testing.T) *sql.DB { t.Helper() ctx := context.Background() pgContainer, err := postgres.Run(ctx, - "postgres:16-alpine", - postgres.WithDatabase("mantle_test"), - postgres.WithUsername("mantle"), - postgres.WithPassword("mantle"), + dbdefaults.PostgresImage, + postgres.WithDatabase(dbdefaults.TestDatabase), + postgres.WithUsername(dbdefaults.User), + postgres.WithPassword(dbdefaults.Password), testcontainers.WithWaitStrategy( wait.ForLog("database system is ready to accept connections"). WithOccurrence(2). From 8fab49a08d70e7fea59a29d02fa02e135380a645 Mon Sep 17 00:00:00 2001 From: Michael McNees Date: Tue, 24 Mar 2026 19:32:56 -0400 Subject: [PATCH 10/15] feat(cli): add Docker auto-provisioning helpers for mantle init MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- internal/cli/docker.go | 106 ++++++++++++++++++++++++++++++++++++ internal/cli/docker_test.go | 41 ++++++++++++++ 2 files changed, 147 insertions(+) create mode 100644 internal/cli/docker.go create mode 100644 internal/cli/docker_test.go diff --git a/internal/cli/docker.go b/internal/cli/docker.go new file mode 100644 index 0000000..ff87f08 --- /dev/null +++ b/internal/cli/docker.go @@ -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": + _ = 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 + } + } + } +} diff --git a/internal/cli/docker_test.go b/internal/cli/docker_test.go new file mode 100644 index 0000000..674a942 --- /dev/null +++ b/internal/cli/docker_test.go @@ -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)) + }) + } +} From b04699d2abcd312f0cdca676b71faa0a0bb6dc30 Mon Sep 17 00:00:00 2001 From: Michael McNees Date: Tue, 24 Mar 2026 19:38:02 -0400 Subject: [PATCH 11/15] 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 --- internal/cli/init.go | 154 +++++++++++++++++++++++++++- internal/cli/init_test.go | 33 ++++++ internal/cli/interactive_darwin.go | 20 ++++ internal/cli/interactive_linux.go | 19 ++++ internal/cli/interactive_windows.go | 16 +++ 5 files changed, 240 insertions(+), 2 deletions(-) create mode 100644 internal/cli/init_test.go create mode 100644 internal/cli/interactive_darwin.go create mode 100644 internal/cli/interactive_linux.go create mode 100644 internal/cli/interactive_windows.go diff --git a/internal/cli/init.go b/internal/cli/init.go index 63a8e12..4a1f747 100644 --- a/internal/cli/init.go +++ b/internal/cli/init.go @@ -1,10 +1,13 @@ package cli import ( + "database/sql" "fmt" + "strings" "github.com/dvflw/mantle/internal/config" "github.com/dvflw/mantle/internal/db" + "github.com/dvflw/mantle/internal/netutil" "github.com/spf13/cobra" ) @@ -12,7 +15,7 @@ func newInitCommand() *cobra.Command { return &cobra.Command{ Use: "init", Short: "Initialize Mantle — run database migrations", - Long: "Runs all pending database migrations to set up or upgrade the Mantle schema.", + Long: "Runs all pending database migrations to set up or upgrade the Mantle schema.\nIf Postgres is not reachable, offers to start one automatically via Docker.", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { cfg := config.FromContext(cmd.Context()) @@ -22,7 +25,10 @@ func newInitCommand() *cobra.Command { database, err := db.Open(cfg.Database) if err != nil { - return fmt.Errorf("failed to connect to database: %w", err) + database, err = handleConnectionFailure(cmd, cfg, err) + if err != nil { + return err + } } defer database.Close() @@ -36,3 +42,147 @@ func newInitCommand() *cobra.Command { }, } } + +// handleConnectionFailure is called when the initial db.Open fails. +// It classifies the host and offers interactive recovery options. +func handleConnectionFailure(cmd *cobra.Command, cfg *config.Config, connErr error) (*sql.DB, error) { + host := parseHostFromURL(cfg.Database.URL) + + // Non-interactive mode (piped stdin, CI): just return the error. + if !isInteractive() { + return nil, fmt.Errorf("failed to connect to database: %w", connErr) + } + + if netutil.IsLoopback(host) { + return handleLoopbackFailure(cmd, cfg, connErr) + } + return handleRemoteFailure(cmd, cfg, host, connErr) +} + + +// handleLoopbackFailure offers Docker auto-provisioning for localhost connections. +func handleLoopbackFailure(cmd *cobra.Command, cfg *config.Config, connErr error) (*sql.DB, error) { + out := cmd.OutOrStdout() + in := cmd.InOrStdin() + + fmt.Fprintf(out, "No Postgres found on localhost: %v\n\n", connErr) + fmt.Fprint(out, "Start a Postgres container with Docker? [Y/n]: ") + + var answer string + fmt.Fscanln(in, &answer) + answer = strings.TrimSpace(strings.ToLower(answer)) + + if answer != "" && answer != "y" && answer != "yes" { + return promptConnectionStringOrRetryDocker(cmd, cfg) + } + + // User accepted Docker provisioning. + return attemptDockerProvisioning(cmd, cfg) +} + +// attemptDockerProvisioning checks Docker availability and starts the container. +func attemptDockerProvisioning(cmd *cobra.Command, cfg *config.Config) (*sql.DB, error) { + out := cmd.OutOrStdout() + + if !dockerAvailable() { + fmt.Fprintln(out, "\nDocker isn't installed or isn't running.") + return promptConnectionStringOrRetryDocker(cmd, cfg) + } + + fmt.Fprintln(out, "Starting Postgres container...") + if err := dockerStartPostgres(cfg.Database); err != nil { + return nil, fmt.Errorf("docker provisioning failed: %w", err) + } + + fmt.Fprintln(out, "Postgres is ready.") + return db.Open(cfg.Database) +} + +// promptConnectionStringOrRetryDocker offers [R]etry or [C]onnection string. +func promptConnectionStringOrRetryDocker(cmd *cobra.Command, cfg *config.Config) (*sql.DB, error) { + out := cmd.OutOrStdout() + in := cmd.InOrStdin() + + for { + fmt.Fprintln(out, "") + fmt.Fprintln(out, " [R] Retry (install or start Docker first)") + fmt.Fprintln(out, " [C] Enter a Postgres connection string") + fmt.Fprint(out, "\nChoice [R/c]: ") + + var choice string + fmt.Fscanln(in, &choice) + choice = strings.TrimSpace(strings.ToLower(choice)) + + switch choice { + case "c": + return promptConnectionString(cmd, cfg) + default: + // Retry Docker provisioning. + return attemptDockerProvisioning(cmd, cfg) + } + } +} + +// promptConnectionString asks the user for a connection URL and validates it. +func promptConnectionString(cmd *cobra.Command, cfg *config.Config) (*sql.DB, error) { + out := cmd.OutOrStdout() + in := cmd.InOrStdin() + + for { + fmt.Fprint(out, "Postgres connection string: ") + + var connStr string + fmt.Fscanln(in, &connStr) + connStr = strings.TrimSpace(connStr) + + if connStr == "" { + continue + } + + cfg.Database.URL = connStr + database, err := db.Open(cfg.Database) + if err != nil { + fmt.Fprintf(out, "Connection failed: %v\n", err) + continue + } + return database, nil + } +} + +// handleRemoteFailure shows the error and offers retry/quit for non-loopback hosts. +func handleRemoteFailure(cmd *cobra.Command, cfg *config.Config, host string, connErr error) (*sql.DB, error) { + out := cmd.OutOrStdout() + in := cmd.InOrStdin() + + for { + fmt.Fprintf(out, "Failed to connect to database at %s\n\n", host) + fmt.Fprintf(out, " Error: %v\n\n", connErr) + fmt.Fprintln(out, " [R] Retry (fix the issue and try again)") + fmt.Fprintln(out, " [Q] Quit") + fmt.Fprint(out, "\nChoice [R/q]: ") + + var choice string + fmt.Fscanln(in, &choice) + choice = strings.TrimSpace(strings.ToLower(choice)) + + if choice == "q" { + return nil, fmt.Errorf("failed to connect to database at %s: %w", host, connErr) + } + + // Retry: re-load config to pick up env var / config file changes. + newCfg, err := config.Load(cmd) + if err != nil { + fmt.Fprintf(out, "Config reload error: %v\n", err) + continue + } + cfg.Database = newCfg.Database + + database, err := db.Open(cfg.Database) + if err != nil { + connErr = err + host = parseHostFromURL(cfg.Database.URL) + continue + } + return database, nil + } +} diff --git a/internal/cli/init_test.go b/internal/cli/init_test.go new file mode 100644 index 0000000..ce90a54 --- /dev/null +++ b/internal/cli/init_test.go @@ -0,0 +1,33 @@ +package cli + +import ( + "bytes" + "fmt" + "testing" + + "github.com/dvflw/mantle/internal/config" + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" +) + +func TestIsInteractive_ReturnsBool(t *testing.T) { + // In test context, stdin is not a TTY — isInteractive should return false. + assert.False(t, isInteractive()) +} + +func TestHandleConnectionFailure_NonInteractive_ReturnsError(t *testing.T) { + // When stdin is not a TTY, handleConnectionFailure should return the + // connection error immediately without prompting. + cmd := &cobra.Command{} + var buf bytes.Buffer + cmd.SetOut(&buf) + + cfg := &config.Config{} + cfg.Database.URL = "postgres://mantle:mantle@localhost:5432/mantle" + + _, err := handleConnectionFailure(cmd, cfg, fmt.Errorf("connection refused")) + assert.Error(t, err) + assert.Contains(t, err.Error(), "connection refused") + // No prompt text should have been written to stdout. + assert.Empty(t, buf.String()) +} diff --git a/internal/cli/interactive_darwin.go b/internal/cli/interactive_darwin.go new file mode 100644 index 0000000..735c337 --- /dev/null +++ b/internal/cli/interactive_darwin.go @@ -0,0 +1,20 @@ +//go:build darwin + +package cli + +import ( + "os" + "syscall" + "unsafe" +) + +// isInteractive returns true if stdin is a real terminal (not piped or redirected). +// +// It uses the TIOCGETA ioctl rather than a simple ModeCharDevice check because +// go test on macOS sets stdin to /dev/null, which reports as a char device but +// is not a terminal. The ioctl succeeds only on real TTYs. +func isInteractive() bool { + var termios syscall.Termios + _, _, errno := syscall.Syscall(syscall.SYS_IOCTL, os.Stdin.Fd(), syscall.TIOCGETA, uintptr(unsafe.Pointer(&termios))) + return errno == 0 +} diff --git a/internal/cli/interactive_linux.go b/internal/cli/interactive_linux.go new file mode 100644 index 0000000..9452477 --- /dev/null +++ b/internal/cli/interactive_linux.go @@ -0,0 +1,19 @@ +//go:build linux + +package cli + +import ( + "os" + "syscall" + "unsafe" +) + +// isInteractive returns true if stdin is a real terminal (not piped or redirected). +// +// It uses the TCGETS ioctl rather than a simple ModeCharDevice check to +// correctly identify real TTYs vs. devices like /dev/null. +func isInteractive() bool { + var termios syscall.Termios + _, _, errno := syscall.Syscall(syscall.SYS_IOCTL, os.Stdin.Fd(), syscall.TCGETS, uintptr(unsafe.Pointer(&termios))) + return errno == 0 +} diff --git a/internal/cli/interactive_windows.go b/internal/cli/interactive_windows.go new file mode 100644 index 0000000..d1253cb --- /dev/null +++ b/internal/cli/interactive_windows.go @@ -0,0 +1,16 @@ +//go:build windows + +package cli + +import ( + "os" +) + +// isInteractive returns true if stdin is a real terminal on Windows. +func isInteractive() bool { + fi, err := os.Stdin.Stat() + if err != nil { + return false + } + return fi.Mode()&os.ModeCharDevice != 0 +} From 8a2eba1fc7be906a656e717414173a30a4e2803a Mon Sep 17 00:00:00 2001 From: Michael McNees Date: Tue, 24 Mar 2026 19:38:47 -0400 Subject: [PATCH 12/15] =?UTF-8?q?docs(site):=20simplify=20quickstart=20?= =?UTF-8?q?=E2=80=94=20mantle=20init=20handles=20DB=20setup=20(#7)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- site/src/components/GetStarted.astro | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/site/src/components/GetStarted.astro b/site/src/components/GetStarted.astro index 2a654b2..c6ae6d7 100644 --- a/site/src/components/GetStarted.astro +++ b/site/src/components/GetStarted.astro @@ -7,8 +7,8 @@ const steps = [ }, { number: '2', - title: 'Start Postgres and initialize', - code: 'docker compose up -d\nmantle init', + title: 'Initialize', + code: 'mantle init\n# Starts Postgres via Docker if needed, then runs migrations', }, { number: '3', From 94c45bb01e25ea952ecafd9a4c356c87809e4981 Mon Sep 17 00:00:00 2001 From: Michael McNees Date: Tue, 24 Mar 2026 19:39:03 -0400 Subject: [PATCH 13/15] docs: update getting-started guide for new mantle init flow (#7) --- .../src/content/docs/getting-started/index.md | 30 +++++++++---------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/site/src/content/docs/getting-started/index.md b/site/src/content/docs/getting-started/index.md index deab232..0973e0b 100644 --- a/site/src/content/docs/getting-started/index.md +++ b/site/src/content/docs/getting-started/index.md @@ -11,37 +11,33 @@ Mantle is a headless AI workflow automation platform. You define workflows as YA You need the following installed on your machine: - **Go 1.25+** -- [install instructions](https://go.dev/doc/install) -- **Docker and Docker Compose** -- [install instructions](https://docs.docker.com/get-docker/) -- **Make** -- included on macOS and most Linux distributions + +Optional (for automatic local Postgres provisioning): + +- **Docker** -- [install instructions](https://docs.docker.com/get-docker/) Verify your setup: ```bash go version # go1.25 or later -docker --version ``` ## Install and Start (< 2 minutes) -Clone the repository, start Postgres, build the binary, and run migrations: +Install the binary and initialize: ```bash -git clone https://github.com/dvflw/mantle.git && cd mantle -docker compose up -d -make build -./mantle init +go install github.com/dvflw/mantle/cmd/mantle@latest +mantle init ``` -The `docker compose up -d` command starts Postgres 16 on `localhost:5432` with user `mantle`, password `mantle`, and database `mantle`. The `make build` command produces a single `mantle` binary in the project root. The `mantle init` command creates all required database tables. - -By default, Mantle uses `sslmode=prefer` in the database URL. This works for local development with the provided Docker Compose setup (it will connect without TLS if the server does not offer TLS). For production, always configure TLS explicitly with `sslmode=require` or `sslmode=verify-full`: +`mantle init` connects to Postgres and runs migrations. If no database is reachable on localhost, it offers to start one automatically via Docker. For remote databases, set the URL before running init: ```bash export MANTLE_DATABASE_URL="postgres://mantle:secret@db.example.com:5432/mantle?sslmode=require" +mantle init ``` -See [Configuration](/docs/configuration) for all database options. - You should see: ``` @@ -49,12 +45,16 @@ Running migrations... Migrations complete. ``` -Optionally, move the binary onto your PATH: +**Development setup:** If you want to build from source, clone the repository and use `make build` instead of `go install`: ```bash -sudo mv mantle /usr/local/bin/ +git clone https://github.com/dvflw/mantle.git && cd mantle +make build +./mantle init ``` +See [Configuration](/docs/configuration) for all database options. + Verify it works: ```bash From 4a13a4d099a546db23d78e1db40ba1d7728eb8f1 Mon Sep 17 00:00:00 2001 From: Michael McNees Date: Tue, 24 Mar 2026 19:45:52 -0400 Subject: [PATCH 14/15] 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) --- go.mod | 1 + internal/cli/interactive.go | 12 ++++++++++++ internal/cli/interactive_darwin.go | 20 -------------------- internal/cli/interactive_linux.go | 19 ------------------- internal/cli/interactive_windows.go | 16 ---------------- 5 files changed, 13 insertions(+), 55 deletions(-) create mode 100644 internal/cli/interactive.go delete mode 100644 internal/cli/interactive_darwin.go delete mode 100644 internal/cli/interactive_linux.go delete mode 100644 internal/cli/interactive_windows.go diff --git a/go.mod b/go.mod index 098784d..233bd86 100644 --- a/go.mod +++ b/go.mod @@ -137,6 +137,7 @@ require ( golang.org/x/net v0.52.0 // indirect golang.org/x/sync v0.20.0 // indirect golang.org/x/sys v0.42.0 // indirect + golang.org/x/term v0.41.0 // indirect golang.org/x/text v0.35.0 // indirect google.golang.org/api v0.247.0 // indirect google.golang.org/genproto v0.0.0-20250603155806-513f23925822 // indirect diff --git a/internal/cli/interactive.go b/internal/cli/interactive.go new file mode 100644 index 0000000..1accb9d --- /dev/null +++ b/internal/cli/interactive.go @@ -0,0 +1,12 @@ +package cli + +import ( + "os" + + "golang.org/x/term" +) + +// isInteractive returns true if stdin is a real terminal (not piped or redirected). +func isInteractive() bool { + return term.IsTerminal(int(os.Stdin.Fd())) +} diff --git a/internal/cli/interactive_darwin.go b/internal/cli/interactive_darwin.go deleted file mode 100644 index 735c337..0000000 --- a/internal/cli/interactive_darwin.go +++ /dev/null @@ -1,20 +0,0 @@ -//go:build darwin - -package cli - -import ( - "os" - "syscall" - "unsafe" -) - -// isInteractive returns true if stdin is a real terminal (not piped or redirected). -// -// It uses the TIOCGETA ioctl rather than a simple ModeCharDevice check because -// go test on macOS sets stdin to /dev/null, which reports as a char device but -// is not a terminal. The ioctl succeeds only on real TTYs. -func isInteractive() bool { - var termios syscall.Termios - _, _, errno := syscall.Syscall(syscall.SYS_IOCTL, os.Stdin.Fd(), syscall.TIOCGETA, uintptr(unsafe.Pointer(&termios))) - return errno == 0 -} diff --git a/internal/cli/interactive_linux.go b/internal/cli/interactive_linux.go deleted file mode 100644 index 9452477..0000000 --- a/internal/cli/interactive_linux.go +++ /dev/null @@ -1,19 +0,0 @@ -//go:build linux - -package cli - -import ( - "os" - "syscall" - "unsafe" -) - -// isInteractive returns true if stdin is a real terminal (not piped or redirected). -// -// It uses the TCGETS ioctl rather than a simple ModeCharDevice check to -// correctly identify real TTYs vs. devices like /dev/null. -func isInteractive() bool { - var termios syscall.Termios - _, _, errno := syscall.Syscall(syscall.SYS_IOCTL, os.Stdin.Fd(), syscall.TCGETS, uintptr(unsafe.Pointer(&termios))) - return errno == 0 -} diff --git a/internal/cli/interactive_windows.go b/internal/cli/interactive_windows.go deleted file mode 100644 index d1253cb..0000000 --- a/internal/cli/interactive_windows.go +++ /dev/null @@ -1,16 +0,0 @@ -//go:build windows - -package cli - -import ( - "os" -) - -// isInteractive returns true if stdin is a real terminal on Windows. -func isInteractive() bool { - fi, err := os.Stdin.Stat() - if err != nil { - return false - } - return fi.Mode()&os.ModeCharDevice != 0 -} From 750520de69a7740b7a4f936b64308f58aef21a68 Mon Sep 17 00:00:00 2001 From: Michael McNees Date: Tue, 24 Mar 2026 19:52:48 -0400 Subject: [PATCH 15/15] =?UTF-8?q?fix:=20address=20PR=20review=20=E2=80=94?= =?UTF-8?q?=20paused=20container,=20quit=20option,=20dbdefaults=20in=20con?= =?UTF-8?q?fig,=20test=20names?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- internal/cli/docker.go | 2 +- internal/cli/init.go | 5 +++++ internal/config/config.go | 3 ++- internal/netutil/loopback_test.go | 22 ++++++++++++---------- 4 files changed, 20 insertions(+), 12 deletions(-) diff --git a/internal/cli/docker.go b/internal/cli/docker.go index ff87f08..fd17664 100644 --- a/internal/cli/docker.go +++ b/internal/cli/docker.go @@ -68,7 +68,7 @@ func dockerStartPostgres(cfg config.DatabaseConfig) error { case "running": // Already running — just wait for readiness. return waitForPostgres(cfg) - case "exited", "created", "dead": + case "exited", "created", "dead", "paused": _ = dockerRemoveContainer() } diff --git a/internal/cli/init.go b/internal/cli/init.go index 4a1f747..8fc17c9 100644 --- a/internal/cli/init.go +++ b/internal/cli/init.go @@ -139,6 +139,11 @@ func promptConnectionString(cmd *cobra.Command, cfg *config.Config) (*sql.DB, er continue } + lower := strings.ToLower(connStr) + if lower == "q" || lower == "quit" || lower == "back" { + return nil, fmt.Errorf("connection string entry cancelled") + } + cfg.Database.URL = connStr database, err := db.Open(cfg.Database) if err != nil { diff --git a/internal/config/config.go b/internal/config/config.go index e1b5952..8d05196 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -11,6 +11,7 @@ import ( "time" "github.com/dvflw/mantle/internal/budget" + "github.com/dvflw/mantle/internal/dbdefaults" "github.com/dvflw/mantle/internal/netutil" "github.com/spf13/cobra" "github.com/spf13/viper" @@ -142,7 +143,7 @@ func Load(cmd *cobra.Command) (*Config, error) { v := viper.New() // Defaults - v.SetDefault("database.url", "postgres://mantle:mantle@localhost:5432/mantle?sslmode=prefer") + v.SetDefault("database.url", fmt.Sprintf("postgres://%s:%s@localhost:5432/%s?sslmode=prefer", dbdefaults.User, dbdefaults.Password, dbdefaults.Database)) v.SetDefault("database.max_open_conns", 25) v.SetDefault("database.max_idle_conns", 25) v.SetDefault("database.conn_max_lifetime", 5*time.Minute) diff --git a/internal/netutil/loopback_test.go b/internal/netutil/loopback_test.go index 635a372..9e8e834 100644 --- a/internal/netutil/loopback_test.go +++ b/internal/netutil/loopback_test.go @@ -9,21 +9,23 @@ import ( func TestIsLoopback(t *testing.T) { tests := []struct { + name string host string expected bool }{ - {"localhost", true}, - {"LOCALHOST", true}, - {"Localhost", true}, - {"127.0.0.1", true}, - {"::1", true}, - {"db.example.com", false}, - {"10.0.0.1", false}, - {"192.168.1.1", false}, - {"", false}, + {"localhost", "localhost", true}, + {"localhost-upper", "LOCALHOST", true}, + {"localhost-mixed", "Localhost", true}, + {"ipv4-loopback", "127.0.0.1", true}, + {"ipv4-loopback-range", "127.0.0.2", true}, + {"ipv6-loopback", "::1", true}, + {"remote-host", "db.example.com", false}, + {"private-ipv4", "10.0.0.1", false}, + {"lan-ipv4", "192.168.1.1", false}, + {"empty", "", false}, } for _, tt := range tests { - t.Run(tt.host, func(t *testing.T) { + t.Run(tt.name, func(t *testing.T) { assert.Equal(t, tt.expected, netutil.IsLoopback(tt.host)) }) }