docs(phase-19.8): add pattern map

This commit is contained in:
Jakub Zych
2026-10-21 02:07:08 +02:00
parent 09fd4d0712
commit cdddaa384f

View File

@@ -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("<component>")`; 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