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 +``` 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..a67fde3 --- /dev/null +++ b/docs/superpowers/specs/2026-03-24-init-connection-recovery-design.md @@ -0,0 +1,174 @@ +# `mantle init` Connection Recovery & Quickstart Fix + +**Date:** 2026-03-24 +**Issue:** [#7 — Get Running in 5 Minutes](https://github.com/dvflw/mantle/issues/7) +**Status:** Draft + +## Problem + +The landing page quickstart tells users to run `docker compose up -d` after installing via `go install`. There's no `docker-compose.yml` when you install that way — step 2 immediately fails. The `mantle init` command needs to handle the "no database yet" case gracefully. + +## Design + +### Connection Recovery Flow + +`mantle init` already loads config and calls `db.Open`. The change adds a recovery path when the connection fails: + +``` +mantle init + ├─ db.Open succeeds → run migrations → done + └─ db.Open fails + ├─ host is NOT loopback → print error with details, offer [R]etry or [Q]uit + └─ host IS loopback → offer Docker auto-provisioning + ├─ user accepts + │ ├─ docker available → start container, wait for ready, run migrations → done + │ └─ docker unavailable → show message, offer [R]etry or [C]onnection string + └─ user declines → offer [R]etry or [C]onnection string +``` + +### Loopback Detection + +Parse the host from the configured database URL. Treat as loopback if the host is: +- `localhost` +- `127.0.0.1` +- `::1` + +Use `net/url` to parse the connection string and extract the host. + +### Docker Auto-Provisioning + +When the user accepts Docker provisioning: + +1. Check Docker availability: exec `docker info` and check exit code +2. Run the container: + ``` + docker run -d \ + --name mantle-postgres \ + -p 5432:5432 \ + -e POSTGRES_USER=mantle \ + -e POSTGRES_PASSWORD=mantle \ + -e POSTGRES_DB=mantle \ + -v mantle-pgdata:/var/lib/postgresql/data \ + postgres:16-alpine + ``` +3. Wait for readiness: poll `db.Open` with backoff (up to ~15s) +4. On success: continue to migrations +5. On timeout: error with "Container started but Postgres isn't accepting connections" + +Use `os/exec` to run Docker commands. The container config matches the existing defaults in `config.go` so no config persistence is needed. + +If the container name `mantle-postgres` already exists (stopped), remove it first and start fresh. If it's already running, skip straight to the readiness check. + +### Fallback: No Docker / User Declined + +Present two options: +``` +Can't auto-provision — Docker isn't installed or isn't running. + + [R] Retry (install or start Docker first) + [C] Enter a Postgres connection string + +Choice [R/c]: +``` + +- **Retry**: loop back to Docker availability check +- **Connection string**: prompt for URL, validate with `db.Open`, on success continue to migrations, on failure show the error and re-prompt + +### Non-Loopback Failure + +When the configured URL points to a remote host and the connection fails: +``` +Failed to connect to database at db.example.com:5432 + + Error: connection refused + + [R] Retry (fix the issue and try again) + [Q] Quit + +Choice [R/q]: +``` + +Include the underlying error from `db.Open` (timeout, auth failure, TLS, DNS resolution, etc.) so the user can diagnose without guessing. + +- **Retry**: re-reads the config (picks up env var or config file changes made while waiting) and retries `db.Open`. This lets the user fix a typo, adjust a firewall rule, or start their database without restarting `mantle init`. +- **Quit**: exit 1 + +### Interactive Input + +Follow the existing pattern from `login.go`: use `fmt.Fscanln(cmd.InOrStdin(), &input)` for prompts. No new dependencies needed. + +When stdin is not a terminal (piped input, CI), skip all interactive prompts and return the connection error directly. Detect with `os.Stdin.Stat()` checking for `ModeCharDevice`. + +## Constant Extraction + +Before implementing the new init flow, extract shared constants that are currently duplicated across the codebase. This keeps the new code referencing a single source of truth. + +### `internal/dbdefaults/dbdefaults.go` — shared database & Docker defaults + +| Constant | Value | Current duplication | +|----------|-------|---------------------| +| `PostgresImage` | `"postgres:16-alpine"` | 7 test files + docker-compose.yml | +| `TestUser` | `"mantle"` | 6 testcontainers setups | +| `TestPassword` | `"mantle"` | 6 testcontainers setups | +| `TestDatabase` | `"mantle_test"` | 6 testcontainers setups | +| `ContainerName` | `"mantle-postgres"` | new (Docker provisioning) | + +### `internal/netutil/loopback.go` — loopback detection + +| Function/Const | Purpose | Current duplication | +|----------------|---------|---------------------| +| `IsLoopback(host string) bool` | Returns true for localhost, 127.0.0.1, ::1 | config.go SSL warning + new init.go recovery | + +### `internal/budget/budget.go` — reset mode constants + +| Constant | Value | Current duplication | +|----------|-------|---------------------| +| `ResetModeCalendar` | `"calendar"` | config.go validation + budget logic + tests | +| `ResetModeRolling` | `"rolling"` | config.go validation + budget logic + tests | + +These already live in the budget package conceptually; just promote the string literals to exported constants. + +## Files Changed + +### Modified + +| File | Change | +|------|--------| +| `internal/cli/init.go` | Add connection recovery flow, Docker provisioning, interactive prompts | +| `internal/config/config.go` | Use `netutil.IsLoopback` for SSL warning, use `budget.ResetMode*` constants | +| `internal/budget/budget.go` | Add `ResetModeCalendar` / `ResetModeRolling` constants, use them in existing logic | +| `internal/auth/auth_test.go` | Use `dbdefaults` constants for testcontainers setup | +| `internal/workflow/store_test.go` | Use `dbdefaults` constants | +| `internal/db/migrate_test.go` | Use `dbdefaults` constants | +| `internal/secret/store_test.go` | Use `dbdefaults` constants | +| `internal/engine/test_helpers_test.go` | Use `dbdefaults` constants | +| `internal/budget/store_test.go` | Use `dbdefaults` constants | +| `internal/connector/postgres_test.go` | Use `dbdefaults.PostgresImage` | +| `site/src/components/GetStarted.astro` | Remove `docker compose up -d` from step 2, simplify to just `mantle init` | +| `site/src/content/docs/getting-started/index.md` | Update quickstart to remove Docker prerequisite, explain `mantle init` handles DB setup | + +### New + +| File | Purpose | +|------|---------| +| `internal/dbdefaults/dbdefaults.go` | Shared Postgres image, test credentials, container name constants | +| `internal/netutil/loopback.go` | `IsLoopback` function for host classification | +| `internal/netutil/loopback_test.go` | Tests for loopback detection | +| `internal/cli/docker.go` | Docker availability check, container start, readiness polling | +| `internal/cli/init_test.go` | Tests for connection recovery flow, non-interactive fallback | +| `internal/cli/docker_test.go` | Tests for Docker command construction, container name conflict handling | + +## Non-Goals + +- **Config file generation**: `mantle init` does not create `mantle.yaml`. The defaults work with the Docker container. +- **Docker Compose**: we use `docker run`, not `docker compose`. No dependency on a compose file. +- **Custom port/user/password in Docker flow**: always matches defaults. Users who need custom config can use the connection string prompt. +- **Container lifecycle management**: `mantle init` starts the container; it doesn't stop or remove it. Users manage that themselves. + +## Testing Strategy + +- **Loopback detection**: unit test `isLoopback` with localhost, 127.0.0.1, ::1, remote hosts, IPv6 +- **Non-interactive detection**: unit test that piped stdin skips prompts and returns error +- **Docker command construction**: verify the exact `docker run` args match defaults +- **Integration**: testcontainers already covers the migration path; the new code paths are the interactive/Docker shell-out portions which are unit-tested with mocked exec +- **Site content**: manual verification that quickstart steps are accurate 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/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/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/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/cli/docker.go b/internal/cli/docker.go new file mode 100644 index 0000000..fd17664 --- /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", "paused": + _ = dockerRemoveContainer() + } + + args := dockerRunArgs() + out, err := exec.Command("docker", args...).CombinedOutput() + if err != nil { + return fmt.Errorf("docker run failed: %w\n%s", err, string(out)) + } + + return waitForPostgres(cfg) +} + +// waitForPostgres polls db.Open with backoff until the database accepts connections +// or the timeout (~15s) is exceeded. +func waitForPostgres(cfg config.DatabaseConfig) error { + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + delay := 500 * time.Millisecond + for { + database, err := db.Open(cfg) + if err == nil { + database.Close() + return nil + } + select { + case <-ctx.Done(): + return fmt.Errorf("container started but Postgres isn't accepting connections after 15s: %w", err) + case <-time.After(delay): + if delay < 2*time.Second { + delay *= 2 + } + } + } +} 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)) + }) + } +} diff --git a/internal/cli/init.go b/internal/cli/init.go index 63a8e12..8fc17c9 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,152 @@ 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 + } + + 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 { + 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.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/config/config.go b/internal/config/config.go index 2825021..8d05196 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -6,12 +6,13 @@ import ( "encoding/hex" "fmt" "log" - "net" "net/url" "os" - "strings" "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) @@ -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. @@ -269,9 +270,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) 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/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" +) 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/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..9e8e834 --- /dev/null +++ b/internal/netutil/loopback_test.go @@ -0,0 +1,32 @@ +package netutil_test + +import ( + "testing" + + "github.com/dvflw/mantle/internal/netutil" + "github.com/stretchr/testify/assert" +) + +func TestIsLoopback(t *testing.T) { + tests := []struct { + name string + host string + expected bool + }{ + {"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.name, func(t *testing.T) { + assert.Equal(t, tt.expected, netutil.IsLoopback(tt.host)) + }) + } +} 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). 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', 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