diff --git a/.planning/phases/19.8-console-commands-overhaul/19.8-PATTERNS.md b/.planning/phases/19.8-console-commands-overhaul/19.8-PATTERNS.md new file mode 100644 index 00000000..81dd9763 --- /dev/null +++ b/.planning/phases/19.8-console-commands-overhaul/19.8-PATTERNS.md @@ -0,0 +1,238 @@ +# Phase 19.8: Console commands overhaul - Pattern Map + +**Mapped:** 2026-10-21 +**Files analyzed:** 22 (new + modified, plus the plugin sweep as one class) +**Analogs found:** 20 / 22 + +## File Classification + +| New/Modified File | Role | Data Flow | Closest Analog | Match Quality | +|---|---|---|---|---| +| `modules/diary/diary.go` (Setup, For, SetVerbosity, Install compat) | service/provider | transform (slog pipeline) | `modules/sunscreen/sunscreen.go` | role-match | +| `modules/diary/config.go` (log.* parsing) | config | transform | `modules/conga/client.go` `settingsFromApp` (34-60), `modules/cooler/cooler.go` `maxEntries` (297-307) | exact | +| `modules/diary/component.go`, `channels.go` | middleware (slog.Handler) | transform | `modules/sunscreen/sunscreen.go` `Wrap`/`handler` (82-125) | exact | +| `modules/diary/console.go` | middleware (slog.Handler) | streaming to TTY | sunscreen handler + SSU `tui/styles.go` | partial | +| `modules/diary/daily.go` | utility | file-I/O | none (stdlib, see RESEARCH Pattern 3) | none | +| `modules/diary/*_test.go` | test | - | `modules/sunscreen/sunscreen_test.go`, `example_test.go` | exact | +| `modules/diary/README.md` + root README row | docs | - | `modules/sunscreen/README.md` | exact | +| `modules/bonfire/command.go` (+Examples, Hidden) | model | - | itself (lines 15-56) | exact | +| `modules/bonfire/root.go` (RootOption, persistent flags, duplicates) | controller | request-response | itself (13-100) | exact | +| `modules/bonfire/globals.go`, `help.go`, `list.go`, `errors.go` | utility | transform | `root.go` + `call.go` (`Catalog`, `findCommand`) | role-match | +| `modules/bonfire/styles.go` | config | - | SSU `internal/cli/tui/styles.go` + `bonfire/output.go` 11-25 | exact | +| `modules/bonfire/output.go`, `widgets.go` (charm TTY path) | component | streaming | itself (output.go 27-80, widgets.go 27-66) | exact | +| `modules/bonfire/live.go` (spinner/process/progress) | component | event-driven (tea) | SSU `tui/spinner.go`, `process.go`, `progress.go` | exact (port v1->v2) | +| `modules/bonfire/prompts.go` (huh + fallback; selector/confirm) | component | request-response | itself + SSU `tui/selector.go`, `confirm.go` | exact | +| `modules/bonfire/testdata/*.golden` + tests | test | - | `internal/build/new_golden_test.go` `assertGolden` (76-96) | exact | +| `internal/build/build.go` `generateMain` + goldens | config/codegen | transform | itself (lines ~94-151) | exact | +| `modules/surf/accesslog.go` (component `http`, route pattern) | middleware | request-response | itself (61-108) | exact | +| `modules/surf/serve.go` (startup summary, shutdown report, exit 0) | controller | long-running | itself (28-~100) | exact | +| `modules/conga/client.go` / `commands.go` / `worker.go` (River logger, counters, report) | service | event-driven | `client.go` 102-123, `commands.go` 27-60 | exact | +| `modules/lagoon` `Pending(gdb, plugins)` | service | CRUD (read) | `lagoon.Status` | role-match | +| `internal/dev/watch.go` `startBin` (+`serve`) | utility | process | itself (201-210) | exact | +| `cmd/summer/docs.go`, `internal/docsite/check_commands.go` | utility | batch | itself (`docsCommands` 144-165; `commandWord` 108) | exact | +| Plugin sweep (`../sm-*-plugin/console/*.go`) | controller | request-response | `../sm-sitemap-plugin/console/import.go` | exact | + +## Pattern Assignments + +### `modules/diary/*` (new module) + +**Analog:** `modules/sunscreen/sunscreen.go` (198 lines, leaf module, slog handler, README in standard structure). + +Handler wrap pattern (lines 82-100) to copy for the component filter / proxy handler; diary channels must call `sunscreen.Wrap` on every leaf (Wrap is idempotent): +```go +func Wrap(next slog.Handler) slog.Handler { + if h, ok := next.(*handler); ok { + return h + } + return &handler{next: next} +} + +func InstallDefault(w io.Writer) { + slog.SetDefault(slog.New(Wrap(slog.NewTextHandler(w, nil)))) +} + +type handler struct { + next slog.Handler +} +``` +Implement all four methods (`Enabled`, `Handle`, `WithAttrs`, `WithGroup`, lines 102-125) and keep `InstallDefault` working as a shim (D-12). + +**Config reading** (copy `modules/conga/client.go` 34-60): defaults first, nil-safe on `app.Config`, `Has`/`Lookup` for maps, tolerant parse of strings vs ints (RESEARCH Pitfall 4, env strings like `"14"`, `"console,daily"`): +```go +if app == nil || app.Config == nil { + return s +} +c := app.Config +if raw := strings.TrimSpace(c.String("queue.job_timeout")); raw != "" { + if d, err := time.ParseDuration(raw); err == nil && d > 0 { + s.jobTimeout = d + } else if n := c.Int("queue.job_timeout"); n > 0 { ... } +} +if raw, ok := c.Lookup("queue.queues"); ok { + if m, ok := raw.(map[string]any); ok { ... } +} +``` +Errors name the key, as in `modules/cooler/cooler.go` 297-307: +`fmt.Errorf("cooler: %s must be a whole number of at least 1", CacheMaxEntriesKey)` -> `diary: log.channels.daily.days must ...`. +Compass accessors available: `String`, `Int`, `Bool`, `Has`, `Lookup`, `LoadSection` (`modules/compass/config.go` 113-165). + +**README:** copy the structure of `modules/sunscreen/README.md` (H1, one sentence, import line, `**Name:**`, Overview, Role in the framework, Features, Usage, API reference, Configuration, Dependencies, Testing) and add a row in root `README.md`. Every identifier must exist (docs checker). + +### `modules/bonfire/command.go` (additive fields) + +Current struct (lines 15-32): `Name, Description, Long, Passthrough, Flags []Flag, Args []Arg, Run`. Add `Examples []Example` and `Hidden bool` at the end only; `Example{Command, Description string}` as a new type next to `Flag`/`Arg` (35-56). No change to `Input`. + +### `modules/bonfire/root.go` + +Analog: itself. Keep `NewRoot(name, commands, out)` and `NewRootIO` signatures; add variadic `opts ...RootOption` only. Current root setup (lines 29-47): +```go +root := &cobra.Command{ + Use: name, + SilenceUsage: true, + SilenceErrors: true, + CompletionOptions: cobra.CompletionOptions{DisableDefaultCmd: true}, +} +for _, command := range commands { + cmd, err := wrap(command, output) + if err != nil { + return nil, err + } + root.AddCommand(cmd) +} +``` +Duplicate-name check goes in this loop (todo `bonfire-duplicate-command-names.md`; RESEARCH Pitfall 15). `wrap` (50-100) maps `Hidden` -> `cmd.Hidden`; flag registration pattern (`StringP`/`NoOptDefVal = "true"` for `Bare`) is where Pitfall 1 (global shorthand collisions with `-v`/`-q`/`-n`) must be checked. + +`list`/`help` resolve commands through `Catalog`/`findCommand` (`modules/bonfire/call.go` 44-72); reuse it for `list --format=json` and `CheckDocs`. + +### `modules/bonfire/output.go`, `widgets.go`, `styles.go`, `live.go` + +Interface stays (output.go 27-43); add optional interfaces + helpers (RESEARCH anti-pattern: no new methods on `Output`). TTY policy lives in `console` (50-80: `tty`, `interactive`, `color`, injectable `lookup`) and is the switch for D-03 degradation. + +Existing non-TTY fallback to preserve (widgets.go 27-34): +```go +func (c *console) Spinner(message string, fn func() error) error { + if fn == nil { fn = func() error { return nil } } + if !c.tty { + c.Printf("[...] %s\n", message) + return fn() + } +``` +**Port source (outside repo, not git-tracked here, read-only):** `/media/nvme/dev/pxpx/ssu/internal/cli/tui/{spinner,process,progress,selector,selector_keys,confirm,styles,tui}.go`. SSU is on bubbletea/bubbles/lipgloss **v1**; translate to v2 import paths and API per RESEARCH "Porting SSU's spinner" (line ~487). Model shape to keep (spinner.go): +```go +type SpinnerModel struct { + spinner spinner.Model + label string + total, done, failed int + current string + err error + complete bool +} +func NewSpinnerModel() SpinnerModel { + s := spinner.New() + s.Spinner = spinner.MiniDot + s.Style = StatusCurrentStyle + ... +} +func (m SpinnerModel) Init() tea.Cmd { return m.spinner.Tick } +``` +Palette (styles.go): `Color("2")` green success, `"6"` cyan current, `"3"` yellow warn, `"1"` red / bold red error, `"240"` muted; `HeaderStyle = Bold`. Drop the `git.SubmoduleStatus` coupling (`StyleForStatus`). Skip the multi-select selector (D-30). + +### `modules/bonfire/testdata/*.golden` and golden tests + +Copy `assertGolden` + `-update` flag from `internal/build/new_golden_test.go` (line 13 and 76-96): +```go +var update = flag.Bool("update", false, "rewrite golden files") + +func assertGolden(t *testing.T, dir, rel string, got []byte) { + t.Helper() + path := filepath.Join("testdata", dir, goldenName(rel)) + if *update { os.MkdirAll(...); os.WriteFile(path, got, 0o644) } + want, err := os.ReadFile(path) + if err != nil { t.Fatalf("%s: %v (run go test -run %s -update to create it)", rel, err, t.Name()) } + if !bytes.Equal(got, want) { t.Errorf(... firstDiff(want, got)) } +} +``` +Render goldens with color off (inject `lookup`/`tty` via `newConsole`). + +### `internal/build/build.go` `generateMain` + +Analog: itself. Strings.Builder template, `formatSource` at end. Current body to change: +```go +b.WriteString("func main() {\n") +b.WriteString("\tif err := run(os.Args[1:], os.Stdout); err != nil {\n") +b.WriteString("\t\tfmt.Fprintln(os.Stderr, err)\n") +b.WriteString("\t\tos.Exit(1)\n") +... +b.WriteString("\tsunscreen.InstallDefault(os.Stderr)\n") +b.WriteString("\tcfg, err := compass.Load(\"config\")\n") +... +b.WriteString("\tcommands := lagoon.RuntimeCommands(app, plugins)\n") +b.WriteString("\tcommands = append(commands, conga.RuntimeCommands(app, plugins)...)\n") +... +fmt.Fprintf(&b, "\troot, err := bonfire.NewRoot(%s, commands, out)\n", strconv.Quote(m.Binary)) +b.WriteString("\troot.SetArgs(args)\n") +b.WriteString("\treturn root.Execute()\n") +``` +Target shape: RESEARCH "Generated main after this phase" (line ~461): `os.Exit(run(...))`, `bonfire.PeekGlobals` + `diary.Setup` before `compass.Open`, `bonfire.Execute` returning exit code. Keep every `commands = append(commands, pkg.Ctor(app...` line verbatim (docsite parses them). Update `internal/build/testdata/new/` and `new-no-plugin/` goldens with `go test ./internal/build -run Golden -update`; add `config/log.yaml` to `summer new` output. + +### `modules/surf/accesslog.go` + +Analog: itself (61-108). Already logs `pattern`, never the path (D-23). Change `accessLogger` to `diary.For("http")` with fallback to the app-published logger (keep this lookup order): +```go +func accessLogger(app *backpack.App) *slog.Logger { + if app != nil { + if log, ok := app.Lookup[*slog.Logger](); ok && log != nil { + return log + } + } + return slog.Default() +} +``` +Add request id attr to the `attrs` slice (74-79); add atomic served/in-flight counters here for the shutdown report. + +### `modules/surf/serve.go` + +Analog: itself (28-~100). Sequence: flag read, `signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM)`, `lagoon.OpenFromApp`, `lagoon.Publish`, `publishUploads`, `installCooler`, `BuildRouter`, `r.compile()`. Insert summary after bind (values collected once, rendered as lipgloss block on TTY and as INFO records component `serve`), report after drain; graceful exit returns nil (D-29). Mirror for `conga` `queue:work`/`schedule:run` in `modules/conga/commands.go` 27-60 (`withDB(ctx, app, func() error { return work(...) })`). + +### `modules/conga/client.go` (River logger) + +Lines 102-123: `baseConfig(s, log)` sets `Logger: log`; `loggerFromApp` is the same lookup as surf. Replace the source with `diary.For("river")` (default component level `warn`, D-09). Counters go in `Manager.runAttempt` (`worker.go:243`). + +### `internal/dev/watch.go` `startBin` + +Line 201-202: `cmd := exec.Command(binPath)` -> `exec.Command(binPath, "serve")` (D-25). Test via the injectable `opts.Start` (line 49). + +### `cmd/summer/docs.go` + `internal/docsite/check_commands.go` + +`docsCommands()` (144-165) builds the app command list from module constructors with `backpack.New(&compass.Config{})`; add `list`, `help` and wire `bonfire.CheckDocs` (fail for framework commands, D-17). `commandWord` (check_commands.go:108) must learn bool globals (`-v`, `-q`, `-n`, `--no-ansi`) per Pitfall 2. Errors reported via `reportDocsProblems` (168). + +### Plugin sweep (`../sm-*-plugin`) + +**Analog:** `../sm-sitemap-plugin/console/import.go` (lines 27-45). Constructor `func X(app *backpack.App) bonfire.Command`, Args/Flags literal, `Bare: true` for booleans, `Run` reads `in.Argument`/`in.Flag`, DB via local `lookupDB`. Sweep adds `Examples: []bonfire.Example{...}`, a result line on success, `--json` / `--force` / `--pretend` where D-21 says, without changing existing `--force` meanings (D-28). Commit in each plugin repo first, then app pointer bumps. + +## Shared Patterns + +### Logger lookup (apply to surf, conga, cabana, lagoon GORM bridge) +Source: `modules/surf/accesslog.go` 101-108 / `modules/conga/client.go` 116-123. Replace with `diary.For("")`; keep app-published logger override for tests. + +### Command definition +Source: `modules/conga/commands.go` 27-60, `modules/surf/serve.go` 28-41. Name `namespace:verb` (validated by `validCommandName`), Description sentence-case without period, `Bare: true` flags parsed with `strconv.ParseBool`. + +### Errors +No hint type exists yet; known-failure sites listed in RESEARCH line ~526 (e.g. `modules/lagoon/connection.go:25`). Wrap with new `bonfire.Hint`; drop the print-then-sentinel pattern in `modules/flare` (Pitfall 13). + +### Piped stdin prompts +`modules/cabana/commands_test.go` 241-262 (WR-13) is the regression test that `Secret` reads a plain line on non-TTY; huh must only engage on interactive TTY. + +## No Analog Found + +| File | Role | Data Flow | Reason | +|---|---|---|---| +| `modules/diary/daily.go` | utility | file-I/O | No rotating writer in repo; use RESEARCH Pattern 3 (stdlib, injected clock) | +| `modules/bonfire/errors.go` (Hint/UsageError/Execute) | utility | transform | No styled error renderer exists; use RESEARCH "Error with hint" | + +## Metadata + +**Analog search scope:** `modules/{bonfire,sunscreen,surf,conga,cooler,compass}`, `internal/{build,dev,docsite}`, `cmd/summer`, `../sm-sitemap-plugin/console`, `/media/nvme/dev/pxpx/ssu/internal/cli/tui` (external port source, not part of this repo). +**Tracked-source check:** analogs are tracked repo files; no `.msd/` mirror paths used. +**Pattern extraction date:** 2026-10-21