Skip to content
Merged
Show file tree
Hide file tree
Changes from 13 commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
981 changes: 981 additions & 0 deletions docs/superpowers/plans/2026-03-24-init-connection-recovery.md

Large diffs are not rendered by default.

174 changes: 174 additions & 0 deletions docs/superpowers/specs/2026-03-24-init-connection-recovery-design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
# `mantle init` Connection Recovery & Quickstart Fix

**Date:** 2026-03-24
**Issue:** [#7 — Get Running in 5 Minutes](https://github.com/dvflw/mantle/issues/7)
**Status:** Draft

## Problem

The landing page quickstart tells users to run `docker compose up -d` after installing via `go install`. There's no `docker-compose.yml` when you install that way — step 2 immediately fails. The `mantle init` command needs to handle the "no database yet" case gracefully.

## Design

### Connection Recovery Flow

`mantle init` already loads config and calls `db.Open`. The change adds a recovery path when the connection fails:

```
mantle init
├─ db.Open succeeds → run migrations → done
└─ db.Open fails
├─ host is NOT loopback → print error with details, offer [R]etry or [Q]uit
└─ host IS loopback → offer Docker auto-provisioning
├─ user accepts
│ ├─ docker available → start container, wait for ready, run migrations → done
│ └─ docker unavailable → show message, offer [R]etry or [C]onnection string
└─ user declines → offer [R]etry or [C]onnection string
```

### Loopback Detection

Parse the host from the configured database URL. Treat as loopback if the host is:
- `localhost`
- `127.0.0.1`
- `::1`

Use `net/url` to parse the connection string and extract the host.

### Docker Auto-Provisioning

When the user accepts Docker provisioning:

1. Check Docker availability: exec `docker info` and check exit code
2. Run the container:
```
docker run -d \
--name mantle-postgres \
-p 5432:5432 \
-e POSTGRES_USER=mantle \
-e POSTGRES_PASSWORD=mantle \
-e POSTGRES_DB=mantle \
-v mantle-pgdata:/var/lib/postgresql/data \
postgres:16-alpine
```
3. Wait for readiness: poll `db.Open` with backoff (up to ~15s)
4. On success: continue to migrations
5. On timeout: error with "Container started but Postgres isn't accepting connections"

Use `os/exec` to run Docker commands. The container config matches the existing defaults in `config.go` so no config persistence is needed.

If the container name `mantle-postgres` already exists (stopped), remove it first and start fresh. If it's already running, skip straight to the readiness check.

### Fallback: No Docker / User Declined

Present two options:
```
Can't auto-provision — Docker isn't installed or isn't running.

[R] Retry (install or start Docker first)
[C] Enter a Postgres connection string

Choice [R/c]:
```

- **Retry**: loop back to Docker availability check
- **Connection string**: prompt for URL, validate with `db.Open`, on success continue to migrations, on failure show the error and re-prompt

### Non-Loopback Failure

When the configured URL points to a remote host and the connection fails:
```
Failed to connect to database at db.example.com:5432

Error: connection refused

[R] Retry (fix the issue and try again)
[Q] Quit

Choice [R/q]:
```

Include the underlying error from `db.Open` (timeout, auth failure, TLS, DNS resolution, etc.) so the user can diagnose without guessing.

- **Retry**: re-reads the config (picks up env var or config file changes made while waiting) and retries `db.Open`. This lets the user fix a typo, adjust a firewall rule, or start their database without restarting `mantle init`.
- **Quit**: exit 1

### Interactive Input

Follow the existing pattern from `login.go`: use `fmt.Fscanln(cmd.InOrStdin(), &input)` for prompts. No new dependencies needed.

When stdin is not a terminal (piped input, CI), skip all interactive prompts and return the connection error directly. Detect with `os.Stdin.Stat()` checking for `ModeCharDevice`.

## Constant Extraction

Before implementing the new init flow, extract shared constants that are currently duplicated across the codebase. This keeps the new code referencing a single source of truth.

### `internal/dbdefaults/dbdefaults.go` — shared database & Docker defaults

| Constant | Value | Current duplication |
|----------|-------|---------------------|
| `PostgresImage` | `"postgres:16-alpine"` | 7 test files + docker-compose.yml |
| `TestUser` | `"mantle"` | 6 testcontainers setups |
| `TestPassword` | `"mantle"` | 6 testcontainers setups |
| `TestDatabase` | `"mantle_test"` | 6 testcontainers setups |
| `ContainerName` | `"mantle-postgres"` | new (Docker provisioning) |

### `internal/netutil/loopback.go` — loopback detection

| Function/Const | Purpose | Current duplication |
|----------------|---------|---------------------|
| `IsLoopback(host string) bool` | Returns true for localhost, 127.0.0.1, ::1 | config.go SSL warning + new init.go recovery |

### `internal/budget/budget.go` — reset mode constants

| Constant | Value | Current duplication |
|----------|-------|---------------------|
| `ResetModeCalendar` | `"calendar"` | config.go validation + budget logic + tests |
| `ResetModeRolling` | `"rolling"` | config.go validation + budget logic + tests |

These already live in the budget package conceptually; just promote the string literals to exported constants.

## Files Changed

### Modified

| File | Change |
|------|--------|
| `internal/cli/init.go` | Add connection recovery flow, Docker provisioning, interactive prompts |
| `internal/config/config.go` | Use `netutil.IsLoopback` for SSL warning, use `budget.ResetMode*` constants |
| `internal/budget/budget.go` | Add `ResetModeCalendar` / `ResetModeRolling` constants, use them in existing logic |
| `internal/auth/auth_test.go` | Use `dbdefaults` constants for testcontainers setup |
| `internal/workflow/store_test.go` | Use `dbdefaults` constants |
| `internal/db/migrate_test.go` | Use `dbdefaults` constants |
| `internal/secret/store_test.go` | Use `dbdefaults` constants |
| `internal/engine/test_helpers_test.go` | Use `dbdefaults` constants |
| `internal/budget/store_test.go` | Use `dbdefaults` constants |
| `internal/connector/postgres_test.go` | Use `dbdefaults.PostgresImage` |
| `site/src/components/GetStarted.astro` | Remove `docker compose up -d` from step 2, simplify to just `mantle init` |
| `site/src/content/docs/getting-started/index.md` | Update quickstart to remove Docker prerequisite, explain `mantle init` handles DB setup |

### New

| File | Purpose |
|------|---------|
| `internal/dbdefaults/dbdefaults.go` | Shared Postgres image, test credentials, container name constants |
| `internal/netutil/loopback.go` | `IsLoopback` function for host classification |
| `internal/netutil/loopback_test.go` | Tests for loopback detection |
| `internal/cli/docker.go` | Docker availability check, container start, readiness polling |
| `internal/cli/init_test.go` | Tests for connection recovery flow, non-interactive fallback |
| `internal/cli/docker_test.go` | Tests for Docker command construction, container name conflict handling |

## Non-Goals

- **Config file generation**: `mantle init` does not create `mantle.yaml`. The defaults work with the Docker container.
- **Docker Compose**: we use `docker run`, not `docker compose`. No dependency on a compose file.
- **Custom port/user/password in Docker flow**: always matches defaults. Users who need custom config can use the connection string prompt.
- **Container lifecycle management**: `mantle init` starts the container; it doesn't stop or remove it. Users manage that themselves.

## Testing Strategy

- **Loopback detection**: unit test `isLoopback` with localhost, 127.0.0.1, ::1, remote hosts, IPv6
- **Non-interactive detection**: unit test that piped stdin skips prompts and returns error
- **Docker command construction**: verify the exact `docker run` args match defaults
- **Integration**: testcontainers already covers the migration path; the new code paths are the interactive/Docker shell-out portions which are unit-tested with mocked exec
- **Site content**: manual verification that quickstart steps are accurate
9 changes: 5 additions & 4 deletions internal/auth/auth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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).
Expand Down
8 changes: 7 additions & 1 deletion internal/budget/budget.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
9 changes: 5 additions & 4 deletions internal/budget/store_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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).
Expand Down
106 changes: 106 additions & 0 deletions internal/cli/docker.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
package cli

import (
"context"
"fmt"
"net/url"
"os/exec"
"strings"
"time"

"github.com/dvflw/mantle/internal/config"
"github.com/dvflw/mantle/internal/db"
"github.com/dvflw/mantle/internal/dbdefaults"
)

// dockerRunArgs returns the arguments for `docker run` to start a Postgres
// container matching Mantle's default configuration.
func dockerRunArgs() []string {
return []string{
"run", "-d",
"--name", dbdefaults.ContainerName,
"-p", "5432:5432",
"-e", "POSTGRES_USER=" + dbdefaults.User,
"-e", "POSTGRES_PASSWORD=" + dbdefaults.Password,
"-e", "POSTGRES_DB=" + dbdefaults.Database,
"-v", "mantle-pgdata:/var/lib/postgresql/data",
dbdefaults.PostgresImage,
}
}

// parseHostFromURL extracts the hostname from a Postgres connection URL.
func parseHostFromURL(rawURL string) string {
if rawURL == "" {
return ""
}
parsed, err := url.Parse(rawURL)
if err != nil {
return ""
}
return parsed.Hostname()
}

// dockerAvailable checks whether the Docker CLI is installed and the daemon is responsive.
func dockerAvailable() bool {
cmd := exec.Command("docker", "info")
return cmd.Run() == nil
}

// dockerContainerStatus returns "running", "exited", or "" (not found)
// for the mantle-postgres container.
func dockerContainerStatus() string {
out, err := exec.Command("docker", "inspect", "-f", "{{.State.Status}}", dbdefaults.ContainerName).Output()
if err != nil {
return ""
}
return strings.TrimSpace(string(out))
}

// dockerRemoveContainer removes the mantle-postgres container (stopped or otherwise).
func dockerRemoveContainer() error {
return exec.Command("docker", "rm", "-f", dbdefaults.ContainerName).Run()
}

// dockerStartPostgres starts a new Postgres container and waits for it to accept connections.
func dockerStartPostgres(cfg config.DatabaseConfig) error {
// Handle existing container.
switch dockerContainerStatus() {
case "running":
// Already running — just wait for readiness.
return waitForPostgres(cfg)
case "exited", "created", "dead":
_ = dockerRemoveContainer()
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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
}
}
}
}
41 changes: 41 additions & 0 deletions internal/cli/docker_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package cli

import (
"testing"

"github.com/stretchr/testify/assert"
)

func TestDockerRunArgs(t *testing.T) {
args := dockerRunArgs()
assert.Equal(t, []string{
"run", "-d",
"--name", "mantle-postgres",
"-p", "5432:5432",
"-e", "POSTGRES_USER=mantle",
"-e", "POSTGRES_PASSWORD=mantle",
"-e", "POSTGRES_DB=mantle",
"-v", "mantle-pgdata:/var/lib/postgresql/data",
"postgres:16-alpine",
}, args)
}

func TestParseHostFromURL(t *testing.T) {
tests := []struct {
name string
url string
expected string
}{
{"standard", "postgres://mantle:mantle@localhost:5432/mantle", "localhost"},
{"remote", "postgres://user:pass@db.example.com:5432/mydb", "db.example.com"},
{"ipv4", "postgres://user:pass@10.0.0.1:5432/mydb", "10.0.0.1"},
{"ipv6", "postgres://user:pass@[::1]:5432/mydb", "::1"},
{"no-port", "postgres://user:pass@myhost/mydb", "myhost"},
{"empty", "", ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.expected, parseHostFromURL(tt.url))
})
}
}
Loading
Loading