Compare commits
64 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
99b6df44d6 | ||
|
|
9ab28e1d27 | ||
|
|
fdc89b107d | ||
|
|
08a52890c6 | ||
|
|
ff4e0fa7ad | ||
|
|
f1fa9e05ba | ||
|
|
d3323efbe2 | ||
|
|
ff0e3a6c69 | ||
|
|
8af7c22cfc | ||
|
|
074a8f250e | ||
|
|
e819f7b865 | ||
|
|
5ddb23d241 | ||
|
|
20ecc4167d | ||
|
|
b57d2e109d | ||
|
|
8c7794efa2 | ||
|
|
7e4387311e | ||
|
|
c2c8c6a2f9 | ||
|
|
30e0cd983d | ||
|
|
4895b250e1 | ||
|
|
205764300e | ||
|
|
7170c5bac6 | ||
|
|
33460607db | ||
|
|
9d06a78f62 | ||
|
|
168d65cd1b | ||
|
|
c38dc1fe48 | ||
|
|
f0ee26f39f | ||
|
|
128d1985a1 | ||
|
|
3f38862b22 | ||
|
|
cb38c493ce | ||
|
|
fb32886775 | ||
|
|
80fae5c279 | ||
|
|
8d4d7ce7a7 | ||
|
|
b7c07591cb | ||
|
|
7dbf6d1a45 | ||
|
|
44291adcca | ||
|
|
71b99317ad | ||
|
|
e6b20e2cb6 | ||
|
|
5b09b32512 | ||
|
|
e903cc32be | ||
|
|
e6039b445f | ||
|
|
bde7d9fcb7 | ||
|
|
deff424c4f | ||
|
|
a999055cbf | ||
|
|
b73f9d971f | ||
|
|
85f0814e0a | ||
|
|
cdd647243c | ||
|
|
f38713f540 | ||
|
|
1d5dc364dd | ||
|
|
cdddaa384f | ||
|
|
09fd4d0712 | ||
|
|
e830298862 | ||
|
|
2c2ff81abd | ||
|
|
52f17e41fc | ||
|
|
18598c17d7 | ||
|
|
63a85a8a65 | ||
|
|
6b8f0600c8 | ||
|
|
671d388ca1 | ||
|
|
b4c0e0d2a2 | ||
|
|
bc5a9dbc17 | ||
|
|
877bf0a425 | ||
|
|
0a87d64d4e | ||
|
|
7f7f5fc171 | ||
|
|
ecc77a3f6b | ||
|
|
c996790808 |
4
.gitignore
vendored
4
.gitignore
vendored
@@ -1,6 +1,10 @@
|
||||
# Go
|
||||
/bin/
|
||||
/examples/hello/bin/
|
||||
|
||||
# Application log files (diary daily channel)
|
||||
/storage/
|
||||
/examples/hello/storage/
|
||||
/dist/
|
||||
/site/
|
||||
*.test
|
||||
|
||||
@@ -63,6 +63,10 @@ Validated in Phase 19.4: OIDC provider on wristband
|
||||
|
||||
- [x] `wristband` has an OpenID Connect flavour (discovery, JWKS with RS256/ES256 and key rotation, id_token with nonce and at_hash, userinfo, resumable authorize with consent and account switching, pairwise subjects, opt-in refresh), and the new sm-oauthserver-plugin hosts it with PHP-path routes, branded consent and error pages, Clients and Branding admin screens and a Winter import that keeps kids, tokens and secrets; `scripts/check-phase19.4.sh` is the fail-closed gate (UAT and security 2026-10-20). PHP parity recording deferred to the WavePath cutover (D-14 amended)
|
||||
|
||||
Validated in Phase 19.5: Social login
|
||||
|
||||
- [x] The new sm-sociallogin-plugin signs users in with Google, GitHub, Microsoft and Facebook through PHP's `/oauth/{provider}` paths, with a provider registry other plugins extend, the SPA one-time-code handoff, the pending-registration branch and linking through a one-time link ticket; a verified email auto-links only onto an account that proved its address (`users.email_verified_at` in sm-user-plugin, a deliberate divergence from PHP); `scripts/check-phase19.5.sh` is the fail-closed gate (UAT and security 2026-10-21). The email-proof fix (sm-user-plugin v0.1.5, sm-sociallogin-plugin v0.1.2) is released with the 19.8 release
|
||||
|
||||
### Active
|
||||
|
||||
Framework kernel
|
||||
@@ -202,4 +206,4 @@ This document evolves at phase transitions and milestone boundaries.
|
||||
4. Update Context with current state
|
||||
|
||||
---
|
||||
*Last updated: 2026-10-20 after Phase 19.4 completion*
|
||||
*Last updated: 2026-10-21 after Phase 19.5 completion*
|
||||
|
||||
@@ -39,9 +39,10 @@ Decimal phases appear between their surrounding integers in numeric order.
|
||||
- [ ] **Phase 19.3: Framework transport and infrastructure for WavePath** - Cookie refresh, credentialed CORS, cache and Redis rate limits, S3 option, OpenAPI page, Turnstile
|
||||
- [x] **Phase 19.4: OIDC provider on wristband** - Discovery, JWKS with key rotation, id_token, userinfo, consent and pairwise subjects (completed 2026-10-20)
|
||||
- [ ] **Phase 19.4.1: Early public demo on demo.summercms.io** - sm-demo-app with today's shared plugins on rome: open registration, guided tour, journal, bm's presentation system, quizzes, payments; backend by request
|
||||
- [ ] **Phase 19.5: Social login in sm-user-plugin** - Google and Facebook login with the SPA one-time-code handoff
|
||||
- [x] **Phase 19.5: Social login in sm-user-plugin** - Google and Facebook login with the SPA one-time-code handoff (completed 2026-10-21)
|
||||
- [ ] **Phase 19.6: Two-factor auth and device flow in sm-user-plugin** - TOTP, WebAuthn, email and recovery codes, trusted devices, device authorization
|
||||
- [ ] **Phase 19.7: Generic MCP module** - Opt-in MCP endpoint, plugin-declared tools filtered by permissions, one connector per person via OAuth and a verified account link
|
||||
- [ ] **Phase 19.8: Console commands overhaul** - `diary` logging module, Artisan-grade global flags, help and `list` in `bonfire`, sweep of every command
|
||||
- [ ] **Phase 20: Demo instance with every shared plugin** - Add the chat family (chat, channels, video calls, chatbots, forum, herald, friends) to the live demo from Phase 19.4.1
|
||||
- [ ] **Phase 30: Płytarium cutover** - Parity harness green, both real clients run unchanged in production
|
||||
- [ ] **Phase 31: Chat polish against WavePath clients** - Replay WavePath's chat traffic through `tide` against the ported chat plugins until green
|
||||
@@ -1362,20 +1363,23 @@ Plans:
|
||||
3. Identities are linked on login and listed and unlinked through the existing identity routes. Both applications that consume the plugin still build and pass their tests.
|
||||
4. The new code has unit tests, delivered in the phase's last plan.
|
||||
|
||||
**Plans:** 1/4 plans executed
|
||||
**Plans:** 5/5 plans complete
|
||||
|
||||
Plans:
|
||||
**Wave 1**
|
||||
- [x] 19.5-01-PLAN.md — Foundations: goth decision note, `cooler.Cache.Pull` with README and docs, sm-user-plugin hooks (provider-list source, row-locked PHP unlink rule, sign-in helpers, user:backfill-handles opens its DB), human goth package check (wave 1)
|
||||
|
||||
**Wave 2** *(blocked on Wave 1 completion)*
|
||||
- [ ] 19.5-02-PLAN.md — New repo sm-sociallogin-plugin: fake-provider tracer, PHP matching and pending registration, link ticket and request validation, Google/Facebook/GitHub/Microsoft through goth with a settings override, release go-ahead and pushes in D-21 order (wave 2)
|
||||
- [x] 19.5-02-PLAN.md — New repo sm-sociallogin-plugin: fake-provider tracer, PHP matching and pending registration, link ticket and request validation, Google/Facebook/GitHub/Microsoft through goth with a settings override, release go-ahead and pushes in D-21 order (wave 2)
|
||||
|
||||
**Wave 3** *(blocked on Wave 2 completion)*
|
||||
- [ ] 19.5-03-PLAN.md — Adoption: sm-demo-app mount next to the OIDC provider, sm-bm-app Microsoft 365 (backend, `/oauth` reserved, frontend callback, consent and link views), fonoteka.go green at the bumped user-plugin pin (wave 3)
|
||||
- [x] 19.5-03-PLAN.md — Adoption: sm-demo-app mount next to the OIDC provider, sm-bm-app Microsoft 365 (backend, `/oauth` reserved, frontend callback, consent and link views), fonoteka.go green at the bumped user-plugin pin (wave 3)
|
||||
|
||||
**Wave 4** *(blocked on Wave 3 completion)*
|
||||
- [ ] 19.5-04-PLAN.md — Unit tests for every decision, PHP social-auth test map, security review, scripts/check-phase19.5.sh (wave 4)
|
||||
- [x] 19.5-04-PLAN.md — Unit tests for every decision, PHP social-auth test map, security review, scripts/check-phase19.5.sh (wave 4)
|
||||
|
||||
**Wave 5** *(gap closure, blocked on Wave 4 completion)*
|
||||
- [x] 19.5-05-PLAN.md — Gap closure (D-26..D-28): `users.email_verified_at` proof in sm-user-plugin, auto-link only onto a proven account in sociallogin, bm OAuthCallbackView vitest test, gate extended, release v0.1.5 / v0.1.2 and pointer bumps under the go-ahead (wave 5)
|
||||
|
||||
### Phase 19.6: Two-factor auth and device flow in sm-user-plugin (INSERTED)
|
||||
|
||||
@@ -1415,6 +1419,37 @@ Plans:
|
||||
Plans:
|
||||
- [ ] TBD (run /msd-plan-phase 19.7 to break down)
|
||||
|
||||
### Phase 19.8: Console commands overhaul (INSERTED)
|
||||
|
||||
**Goal:** Console commands in `summer` and in application binaries reach the bar set by Laravel's Artisan. The command logic stays; what changes is logging, the shared console layer in `bonfire`, and every command's help and output. A user sees what a command did, can ask for more or less of it, and learns from `--help` how to use it without opening the docs. Example of today's gap: `serve` prints raw `slog` lines from plugins and River mixed with styled `ℹ` lines, says nothing about what it booted (plugins, migrations state, mail driver, cache, realtime, scheduler), and has no way to see more or less. Logging today is one hard-coded `slog.NewTextHandler` at INFO in `sunscreen.Install`, with no level, format or destination config.
|
||||
**Mode:** mvp
|
||||
**Depends on:** none (framework `diary`, `bonfire` and the commands that modules and plugins register; can run in parallel with the 19.x phases; added as Phase 32 on 2026-10-20 and renumbered 2026-10-21)
|
||||
**Repos:** `summercms.go`; shared plugin repos only for their commands' help text and logger names, without breaking changes to command names or flags; `sm-summercmsio-app` / `vue-summercmsio-app` for the landing site and roadmap presentation
|
||||
**Planned shape:** 4 plans: (1) `diary` logging module, (2) `bonfire` global flags, help and `list`, (3) sweep of every command, (4) unit tests. Docs, landing-site and roadmap-presentation updates ride along or go to `/msd-quick` tasks.
|
||||
**Requirements**: TBD
|
||||
**Success Criteria** (what must be TRUE):
|
||||
|
||||
1. A new `diary` module (beach-themed README, root table row) owns the application's logging, modelled on Laravel's `config/logging.php`: `log.default`, `log.level`, `log.channels` and `log.components` config keys, channels for stderr, a `stack` of several channels, a daily file with retention and JSON output, a console handler that renders records in `bonfire`'s style with a component prefix, and named component loggers. `sunscreen` redaction wraps every channel; `sunscreen.Install` keeps working as a compatibility shim. Daily files under `storage/logs/` are on by default. The console uses the charm stack (lipgloss, bubbles/bubbletea, huh, fang) after SSU's terminal UX, with a decision note for the new dependencies before code.
|
||||
2. `bonfire` provides global flags on every command, modelled on Artisan: `-v`/`-vv`/`-vvv`, `-q`/`--quiet`, `--no-ansi`/`--ansi`, `-n`/`--no-interaction` and `--env`. Verbosity sets the `diary` level at runtime, so framework and third-party logs (River, plugins) are hidden or formatted consistently at the default level and shown in full at `-vv`.
|
||||
3. Every command has a description, a long help with usage examples, and documented arguments and options with defaults. `list` output is grouped by namespace like `php artisan list`, and `summer help <command>` and `<binary> help <command>` work. A coverage check reports registered commands lacking a description or examples: it fails for the framework's own commands and warns in plugin repos.
|
||||
4. A sweep of all existing commands (framework modules and shared plugins) checks them against an Artisan feature checklist and fixes the gaps found, without changing command logic or breaking existing names and flags. The checklist covers confirmation with `--force` on destructive actions, `--pretend`/dry-run where it applies, progress bars and tables for bulk work, and `--json` output for scripting. Long-running commands such as `serve` and `queue:work` print a startup summary of what booted (environment, plugins, database and migrations state, queues, scheduler, mail, cache and realtime drivers, URLs) and report what they did on shutdown. One-shot commands end with a result line and a meaningful exit code.
|
||||
5. The `diary` and `bonfire` READMEs, the docs console and logging pages, the summercms.io landing site and its roadmap presentation reflect the new console. The new code has unit tests, delivered in the phase's last plan.
|
||||
|
||||
**Plans:** 2/4 plans executed
|
||||
|
||||
Plans:
|
||||
**Wave 1**
|
||||
- [x] 19.8-01-PLAN.md — charm-stack decision note, then the `diary` module (component loggers, Laravel-shaped channels, daily files, redaction) wired into the generated main; River, GORM and framework modules routed through it
|
||||
|
||||
**Wave 2** *(blocked on Wave 1 completion)*
|
||||
- [x] 19.8-02-PLAN.md — `bonfire`: global flags, Artisan list and help, Examples/Hidden, JSON listing, CheckDocs, styled errors with hints and exit codes, duplicate-name rejection, SSU widgets on charm v2
|
||||
|
||||
**Wave 3** *(blocked on Wave 2 completion)*
|
||||
- [ ] 19.8-03-PLAN.md — sweep of every command (checklist, hints, serve/queue:work/dev summaries and reports, http request log), docs, landing and roadmap deck; release checkpoint, then plugin commits and app pointer bumps
|
||||
|
||||
**Wave 4** *(blocked on Wave 3 completion)*
|
||||
- [ ] 19.8-04-PLAN.md — unit and golden tests for all new code, `scripts/check-phase19.8.sh` gate across repos, post-release fixes go-ahead
|
||||
|
||||
### Phase 20: Demo instance with every shared plugin
|
||||
|
||||
**Goal:** `sm-demo-app` runs every shared Golem15 plugin collected so far in one binary, and its public Nuxt site shows them working: chat and channels with realtime, video calls, presentations, quizzes, journal, FAQ, subscriptions and the rest. The admin backend is not public; the site carries a call to action to contact Golem15 for a backend tour. Phase 19.4.1 deploys the demo early with the plugins available then; this phase adds the chat family to that live instance.
|
||||
@@ -1469,31 +1504,10 @@ Plans:
|
||||
Plans:
|
||||
- [ ] TBD (run /msd-plan-phase 31 to break down)
|
||||
|
||||
### Phase 32: Console commands overhaul
|
||||
|
||||
**Goal:** Console commands in `summer` and in application binaries reach the bar set by Laravel's Artisan. The command logic stays; what changes is logging, the shared console layer in `bonfire`, and every command's help and output. A user sees what a command did, can ask for more or less of it, and learns from `--help` how to use it without opening the docs. Example of today's gap: `serve` prints raw `slog` lines from plugins and River mixed with styled `ℹ` lines, says nothing about what it booted (plugins, migrations state, mail driver, cache, realtime, scheduler), and has no way to see more or less. Logging today is one hard-coded `slog.NewTextHandler` at INFO in `sunscreen.Install`, with no level, format or destination config.
|
||||
**Mode:** mvp
|
||||
**Depends on:** none (framework `diary`, `bonfire` and the commands that modules and plugins register; can run in parallel with the 19.x phases)
|
||||
**Repos:** `summercms.go`; shared plugin repos only for their commands' help text and logger names, without breaking changes to command names or flags; `sm-summercmsio-app` / `vue-summercmsio-app` for the landing site and roadmap presentation
|
||||
**Planned shape:** 4 plans: (1) `diary` logging module, (2) `bonfire` global flags, help and `list`, (3) sweep of every command, (4) unit tests. Docs, landing-site and roadmap-presentation updates ride along or go to `/msd-quick` tasks.
|
||||
**Requirements**: TBD
|
||||
**Success Criteria** (what must be TRUE):
|
||||
|
||||
1. A new `diary` module (beach-themed README, root table row) owns the application's logging, modelled on Laravel's `config/logging.php`: `log.level` and `log.channel` config keys, channels for stderr, a `stack` of several channels, a daily file with retention and JSON output, a console handler that renders records in `bonfire`'s style with a component prefix, and named component loggers. `sunscreen` redaction wraps every channel; `sunscreen.Install` keeps working as a compatibility shim. Standard library only, or a decision note for any new dependency.
|
||||
2. `bonfire` provides global flags on every command, modelled on Artisan: `-v`/`-vv`/`-vvv`, `-q`/`--quiet`, `--no-ansi`/`--ansi`, `-n`/`--no-interaction` and `--env`. Verbosity sets the `diary` level at runtime, so framework and third-party logs (River, plugins) are hidden or formatted consistently at the default level and shown in full at `-vv`.
|
||||
3. Every command has a description, a long help with usage examples, and documented arguments and options with defaults. `list` output is grouped by namespace like `php artisan list`, and `summer help <command>` and `<binary> help <command>` work. A test fails when a registered command lacks a description or examples.
|
||||
4. A sweep of all existing commands (framework modules and shared plugins) checks them against an Artisan feature checklist and fixes the gaps found, without changing command logic or breaking existing names and flags. The checklist covers confirmation with `--force` on destructive actions, `--pretend`/dry-run where it applies, progress bars and tables for bulk work, and `--json` output for scripting. Long-running commands such as `serve` and `queue:work` print a startup summary of what booted (environment, plugins, database and migrations state, queues, scheduler, mail, cache and realtime drivers, URLs) and report what they did on shutdown. One-shot commands end with a result line and a meaningful exit code.
|
||||
5. The `diary` and `bonfire` READMEs, the docs console and logging pages, the summercms.io landing site and its roadmap presentation reflect the new console. The new code has unit tests, delivered in the phase's last plan.
|
||||
|
||||
**Plans:** 0 plans
|
||||
|
||||
Plans:
|
||||
- [ ] TBD (run /msd-plan-phase 32 to break down)
|
||||
|
||||
## Progress
|
||||
|
||||
**Execution Order:**
|
||||
Phases execute in numeric order: 1 → 2 → 3 → 4 → 5 → 6 → 7 → 8 → 9 → 10 → 11 → 12 → 13 → 14 → 14.1 → 14.2 → 14.2.1 → 14.3 → 15 → 16 → 17 → 18 → 18.1 → 19 → 19.1 → 19.3 → 19.4 → 19.4.1 → 19.5 → 19.6 → 19.7 → 20 → (21 to 29, added as planned) → 30 → 31
|
||||
Phases execute in numeric order: 1 → 2 → 3 → 4 → 5 → 6 → 7 → 8 → 9 → 10 → 11 → 12 → 13 → 14 → 14.1 → 14.2 → 14.2.1 → 14.3 → 15 → 16 → 17 → 18 → 18.1 → 19 → 19.1 → 19.3 → 19.4 → 19.4.1 → 19.5 → 19.6 → 19.7 → 19.8 → 20 → (21 to 29, added as planned) → 30 → 31
|
||||
(Phase 2 depends on Phase 1's command kernel; the two are no longer parallel.)
|
||||
|
||||
| Phase | Plans Complete | Status | Completed |
|
||||
@@ -1535,13 +1549,13 @@ Phases execute in numeric order: 1 → 2 → 3 → 4 → 5 → 6 → 7 → 8 →
|
||||
| 19.3. Framework transport and infrastructure for WavePath | 6/6 | In Progress| |
|
||||
| 19.4. OIDC provider on wristband | 4/4 | Complete | 2026-10-20 |
|
||||
| 19.4.1. Early public demo on demo.summercms.io | 5/5 | In Progress| |
|
||||
| 19.5. Social login in sm-user-plugin | 1/4 | In Progress| |
|
||||
| 19.5. Social login in sm-user-plugin | 5/5 | Complete | 2026-10-21 |
|
||||
| 19.6. Two-factor auth and device flow in sm-user-plugin | 0/TBD | Not started | - |
|
||||
| 19.7. Generic MCP module | 0/TBD | Not started | - |
|
||||
| 19.8. Console commands overhaul (INSERTED) | 2/4 | In Progress| |
|
||||
| 20. Demo instance with every shared plugin | 0/TBD | Not started | - |
|
||||
| 30. Płytarium cutover | 0/TBD | Not started | - |
|
||||
| 31. Chat polish against WavePath clients | 0/TBD | Not started | - |
|
||||
| 32. Console commands overhaul | 0/TBD | Not started | - |
|
||||
|
||||
## Backlog
|
||||
|
||||
|
||||
@@ -35,6 +35,6 @@ This note supersedes the zitadel/oidc row of `.planning/research/STACK.md` for O
|
||||
## Consequences
|
||||
|
||||
- The framework `go.mod` gains no module; `grep -E 'zitadel|go-jose|coreos' go.mod` stays empty.
|
||||
- `wristband` stays a protocol leaf: no ORM, no framework module, no application package. Storage, keys at rest, pages and routes belong to `sm-oauthserver-plugin` (D-01).
|
||||
- `wristband` stays a protocol leaf: no ORM, no framework module except `diary`, no application package. Logging through `diary` is required (amended 2026-10-21, phase 19.8: every framework module logs through diary components). Storage, keys at rest, pages and routes belong to `sm-oauthserver-plugin` (D-01).
|
||||
- The OIDC flavour owns every byte a relying party sees (discovery field order, redirect encoding, error bodies, headers), so parity with the PHP provider is a matter of tests and `tide` recordings, not of fighting library defaults.
|
||||
- Replacing golang-jwt later means re-checking the id_token header (`alg`, `kid`) and the string `aud` against the go-oidc interop test.
|
||||
|
||||
44
.planning/notes/2026-10-21-console-charm-stack.md
Normal file
44
.planning/notes/2026-10-21-console-charm-stack.md
Normal file
@@ -0,0 +1,44 @@
|
||||
# Decision: the console runs on charm's v2 stack, logging stays on stdlib slog
|
||||
|
||||
**Date:** 2026-10-21
|
||||
**Status:** Accepted
|
||||
**Context:** Phase 19.8 (console commands overhaul: D-01, D-02, D-05, D-08 and the CONTEXT discretion item on the console handler), 19.8-RESEARCH.md Standard Stack, Alternatives Considered, Package Legitimacy Audit and the probes quoted there
|
||||
|
||||
## Decision
|
||||
|
||||
The console layer (`modules/bonfire`) and the new logging module (`modules/diary`) adopt charm's v2 libraries on the `charm.land` vanity paths. Log routing itself stays on the standard library's `log/slog`.
|
||||
|
||||
- **Modules and versions.** All six were checked with `go list -m -versions` on 2026-10-21; each is the newest tag, so the research pins stand:
|
||||
- `charm.land/lipgloss/v2` v2.0.6: styles, layout and `lipgloss/v2/table` for list, help, errors, tables and log lines.
|
||||
- `charm.land/bubbletea/v2` v2.1.0: program runtime for the live spinner, process and progress views.
|
||||
- `charm.land/bubbles/v2` v2.2.1: `spinner` (MiniDot, the frames `bonfire` already draws), `progress` (the cyan-to-green bar), `key` and `help`.
|
||||
- `charm.land/huh/v2` v2.0.3: Ask, Confirm, Choice and Secret on an interactive terminal, with accessible mode.
|
||||
- `charm.land/fang/v2` v2.0.1: cobra error rendering hook and Windows VT enabling.
|
||||
- `github.com/charmbracelet/colorprofile` v0.4.3: imported directly for `colorprofile.Writer` and `colorprofile.Detect` (already a transitive dependency of all of the above).
|
||||
- **v2, not SSU's v1 pins.** SSU (`/media/nvme/dev/pxpx/ssu`) pins `github.com/charmbracelet/lipgloss` v1.0.0, `bubbletea` v1.3.0 and `bubbles` v0.20.0. fang v1 already depends on a `charm.land/lipgloss/v2` beta, so v1 widgets plus any fang would compile two lipgloss majors into every binary, and the v1 line is maintenance-only. SSU's widgets are ported with the v2 renames: `View()` returns `tea.View`, key events are `tea.KeyPressMsg`, the progress gradient is `progress.WithColors` / `WithDefaultBlend`, and widths are set with `SetWidth`.
|
||||
- **Who imports them.** Only `modules/bonfire` (all five charm libraries plus colorprofile) and `modules/diary` (lipgloss and colorprofile) import charm modules. Plugins and other framework modules reach them through `bonfire.Output` and `diary.For`, never directly.
|
||||
- **fang is used narrowly.** `fang.Execute` runs with `fang.WithoutVersion()` (its `-v`/`--version` would collide with the verbosity flags), `fang.WithoutManpage()` (it would add a hidden `man` command to the list and to duplicate checks), `fang.WithoutCompletions()`, and `fang.WithErrorHandler` set to bonfire's own error renderer. The help and list layouts are bonfire's own Artisan layouts (D-14); fang's help func never reaches subcommands because bonfire sets a help func on every command.
|
||||
- **Color is applied at the writer.** lipgloss v2 `Render` always emits ANSI, so `--ansi`, `--no-ansi`, `NO_COLOR`, `TERM=dumb` and `FORCE_COLOR` are applied through `colorprofile.Writer` with a profile that bonfire detects.
|
||||
- **The diary console handler is our own** lipgloss-styled `slog.Handler`. charm's slog logger module (`charm.land/log/v2` v2.0.1) is rejected: it fails `testing/slogtest` (`WithGroup` treated as a prefix, empty attributes printed, attributes of empty-key groups not inlined), and it brings its own level and prefix model that duplicates diary's component levels. This settles the CONTEXT discretion item.
|
||||
- **Daily files use a stdlib writer** of about 120 lines (one file per day, `name-YYYY-MM-DD.log`, retention by days, mode 0640). `gopkg.in/natefinch/lumberjack.v2`, which SSU uses, is rejected: it rotates by size, has no per-day naming, and would need its own note.
|
||||
- **Fan-out and GORM.** The `stack` driver is the standard library's `slog.NewMultiHandler` (Go 1.26+), and GORM logs through gorm's own `logger.NewSlogLogger` (already in gorm v1.31.2). No logging library is added.
|
||||
- **Cost.** The probe measured about +2.1 MB on a stripped binary (`-trimpath -s -w`): 2,527,495 bytes for cobra and x/term alone, 4,649,223 bytes with lipgloss/table, bubbletea, the bubbles spinner and progress, huh and fang.
|
||||
- **Toolchain fit.** The module go directives are at most 1.26 (lipgloss 1.25.0, bubbletea 1.26.0, bubbles 1.25.0, huh 1.25.8, fang 1.25.0) and fit this module's `go 1.27.0`. Minimal version selection keeps `github.com/spf13/cobra` v1.10.2, `github.com/spf13/pflag` v1.0.9 and `golang.org/x/text` v0.42.0 (fang asks for older cobra and pflag).
|
||||
- **YAML rule.** `gopkg.in/yaml.v3` enters the module graph only as testify's indirect test dependency of fang and huh. Nothing in this repository imports it; YAML stays on `goccy/go-yaml`.
|
||||
- **Package legitimacy.** The MSD package-legitimacy seam rejects the Go ecosystem, so the six modules were checked by hand against proxy.golang.org (`@latest`, `.mod`, `@v/list`) and the `charm.land` go-import meta tags, whose origins are `github.com/charmbracelet/*`. Verdict: approved. fang is young (two v2 tags, repository first tagged 2025-06-18), which is acceptable because it is used only for the error hook.
|
||||
- **Install order.** Plan 19.8-01 installs `charm.land/lipgloss/v2` and `github.com/charmbracelet/colorprofile` with the code that imports them (the palette and the diary console handler); plan 19.8-02 installs bubbletea, bubbles, huh and fang with the widgets. `go mod tidy` drops requirements that no package imports, so each plan adds only what it uses.
|
||||
|
||||
**Rejected alternatives.** SSU's v1 pins were rejected for the two-majors problem above. `charm.land/log/v2` was rejected for the slogtest failures and the duplicated level model. lumberjack was rejected because it rotates by size and names files differently. A hand-written fan-out handler (SSU's `MultiHandler`) was rejected in favour of `slog.NewMultiHandler`, which clones records, honours `Enabled` and joins errors.
|
||||
|
||||
## Related dependencies
|
||||
|
||||
- **Standard library only for logging:** `log/slog` (`NewMultiHandler`, `JSONHandler`, `TextHandler`, `DiscardHandler`, `LevelVar`), `testing/slogtest` for handler conformance tests, `os` and `path/filepath` for the daily writer.
|
||||
- **gorm.io/gorm** v1.31.2 (already required) for `logger.NewSlogLogger`.
|
||||
- **github.com/spf13/cobra** v1.10.2 (already required) stays the command parser under bonfire.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Every application binary grows by about 2.1 MB once plan 19.8-02 lands the widgets.
|
||||
- Removing the stack later means rewriting bonfire's widgets and diary's console handler; D-01 and D-02 are rated costly for that reason.
|
||||
- Plugins never import charm modules directly, so a later change of styling library touches only `modules/bonfire` and `modules/diary`.
|
||||
- A check in the plan's acceptance criteria (`go list` over every package's imports) keeps the import boundary honest.
|
||||
@@ -0,0 +1,300 @@
|
||||
---
|
||||
phase: 19.5-social-login-in-sm-user-plugin
|
||||
plan: 02
|
||||
subsystem: auth
|
||||
tags: [goth, oauth, social-login, sm-sociallogin-plugin, aes-gcm, cooler, release, changelog]
|
||||
|
||||
# Dependency graph
|
||||
requires:
|
||||
- phase: 19.5-01
|
||||
provides: "goth v1.82.0 approval, cooler.Cache.Pull, sm-user-plugin OAuthProviderSource, FinishLogin, RegisterOptions.PasswordLess, PHP last-identity unlink"
|
||||
provides:
|
||||
- "New repository golem15/sm-sociallogin-plugin (private), module git.golem15.com/golem15/sm-sociallogin-plugin, package sociallogin, plugin golem15.sociallogin, tag v0.1.0"
|
||||
- "GET /oauth/{provider} and /oauth/{provider}/callback (throttle:20,1) for google, facebook, github, microsoft; sealed AES-256-GCM state cookie summer_oauth_<provider>"
|
||||
- "GET /_user/api/v1/oauth-complete, POST /_user/api/v1/oauth-register-complete, POST /_user/api/v1/oauth-link-ticket"
|
||||
- "Provider, Profile, ProviderConfig, Factory, Registry (Register, Names, Label, Build, Has); goth only in classes/providers_goth.go"
|
||||
- "Admin settings singleton golem15_sociallogin_settings (SettingsCode sociallogin) with encrypted write-only secrets overriding config per field"
|
||||
- "summercms v0.3.1 tag (pushed, changelog post), sm-user-plugin v0.1.4 tag (pushed)"
|
||||
affects: [19.5-03, 19.5-04, sm-demo-app, sm-bm-app, fonoteka.go, sm-summercmsio-app]
|
||||
|
||||
actuals:
|
||||
tokens: 46300
|
||||
tasks: 6
|
||||
commits: 6
|
||||
plan_head_before: null
|
||||
plan_head_after: adfcfbff0af65c3b0eee19ab55d02ffec13e0604
|
||||
# No ledger in summercms.go: this plan made no code commit there (only this SUMMARY).
|
||||
# Measured per repo:
|
||||
# sm-sociallogin-plugin: created by this plan, git rev-list --count master = 5 (root c15e8b9 .. adfcfbf)
|
||||
# sm-summercmsio-app: git rev-list --count 752099d..8581576 = 1 (unpushed)
|
||||
# tokens: chars/4 over the tracked files of sm-sociallogin-plugin without go.sum (185152 chars), plus a one-line script change.
|
||||
|
||||
tech-stack:
|
||||
added:
|
||||
- "github.com/markbates/goth v1.82.0 (core + google, facebook, github, azureadv2; never gothic), sm-sociallogin-plugin only"
|
||||
- "golang.org/x/oauth2 v0.36.0 (token exchange errors), sm-sociallogin-plugin only"
|
||||
patterns:
|
||||
- "Plugin-owned provider interface and Registry; third-party OAuth library wrapped in one file so no exported identifier mentions it"
|
||||
- "Per-browser OAuth state sealed with AES-256-GCM under an HKDF key from the app key, AAD the cookie name"
|
||||
- "One-time handoff codes in cooler consumed with Cache.Pull; validation before the pull so a 422 never consumes"
|
||||
- "Config defaults overridden per field by a settings singleton read per request"
|
||||
|
||||
key-files:
|
||||
created:
|
||||
- ../sm-sociallogin-plugin/plugin.go
|
||||
- ../sm-sociallogin-plugin/routes.go
|
||||
- ../sm-sociallogin-plugin/config.go
|
||||
- ../sm-sociallogin-plugin/service.go
|
||||
- ../sm-sociallogin-plugin/admin_settings.go
|
||||
- ../sm-sociallogin-plugin/admin_permissions.go
|
||||
- ../sm-sociallogin-plugin/classes/provider.go
|
||||
- ../sm-sociallogin-plugin/classes/config.go
|
||||
- ../sm-sociallogin-plugin/classes/credentials.go
|
||||
- ../sm-sociallogin-plugin/classes/statecookie.go
|
||||
- ../sm-sociallogin-plugin/classes/callback.go
|
||||
- ../sm-sociallogin-plugin/classes/codes.go
|
||||
- ../sm-sociallogin-plugin/classes/flow.go
|
||||
- ../sm-sociallogin-plugin/classes/registration.go
|
||||
- ../sm-sociallogin-plugin/classes/messages.go
|
||||
- ../sm-sociallogin-plugin/classes/providers_goth.go
|
||||
- ../sm-sociallogin-plugin/controllers/redirect.go
|
||||
- ../sm-sociallogin-plugin/controllers/callback.go
|
||||
- ../sm-sociallogin-plugin/controllers/complete.go
|
||||
- ../sm-sociallogin-plugin/controllers/register_complete.go
|
||||
- ../sm-sociallogin-plugin/controllers/link_ticket.go
|
||||
- ../sm-sociallogin-plugin/controllers/helpers.go
|
||||
- ../sm-sociallogin-plugin/models/registry.go
|
||||
- ../sm-sociallogin-plugin/models/settings.go
|
||||
- ../sm-sociallogin-plugin/models/settings/fields.yaml
|
||||
- ../sm-sociallogin-plugin/updates/registry.go
|
||||
- ../sm-sociallogin-plugin/updates/202610200001_create_golem15_sociallogin_settings.go
|
||||
- ../sm-sociallogin-plugin/config/config.yaml
|
||||
- ../sm-sociallogin-plugin/lang/en/lang.yaml
|
||||
- ../sm-sociallogin-plugin/lang/pl/lang.yaml
|
||||
- ../sm-sociallogin-plugin/testmain_test.go
|
||||
- ../sm-sociallogin-plugin/harness_test.go
|
||||
- ../sm-sociallogin-plugin/fakeprovider_test.go
|
||||
- ../sm-sociallogin-plugin/tracer_smoke_test.go
|
||||
- ../sm-sociallogin-plugin/flow_smoke_test.go
|
||||
- ../sm-sociallogin-plugin/link_smoke_test.go
|
||||
- ../sm-sociallogin-plugin/providers_smoke_test.go
|
||||
- ../sm-sociallogin-plugin/settings_smoke_test.go
|
||||
- ../sm-sociallogin-plugin/README.md
|
||||
- ../sm-sociallogin-plugin/go.mod
|
||||
- ../sm-sociallogin-plugin/go.sum
|
||||
- ../sm-sociallogin-plugin/go.work
|
||||
- ../sm-sociallogin-plugin/.gitignore
|
||||
modified:
|
||||
- ../sm-summercmsio-app/scripts/changelog-post.py
|
||||
|
||||
key-decisions:
|
||||
- "Task 5 reply: release v0.3.1 (Recommended); framework v0.3.1, sm-user-plugin v0.1.4, sm-sociallogin-plugin v0.1.0, private repository"
|
||||
- "Changelog blocker reply: fix-ua (Recommended); changelog-post.py sends User-Agent summercms-changelog-post/1 (Cloudflare bans Python-urllib)"
|
||||
- "sm-user-plugin tagged v0.1.4, not the planned v0.1.2 (v0.1.2 and v0.1.3 already existed)"
|
||||
- "SettingsCode is sociallogin (cabana rejects dotted settings codes)"
|
||||
- "The pending registration code is pulled inside the final DB transaction after every 422 check; a lost race answers 410, a 422 never consumes"
|
||||
- "Inactive or banned users get the real messages (A7); Polish copy for every message, PHP Polish byte-for-byte where PHP has it"
|
||||
- "providers.microsoft.tenant defaults to empty, which falls back to common"
|
||||
|
||||
patterns-established:
|
||||
- "Plugin-owned provider interface wrapping a third-party OAuth library in a single file"
|
||||
- "Sealed per-browser state cookie with HKDF purpose label separate from lagoon's"
|
||||
|
||||
requirements-completed: [SC-1, SC-2, SC-3, D-01, D-02, D-03, D-04, D-05, D-06, D-07, D-08, D-09, D-10, D-11, D-12, D-14, D-16, D-17, D-18, D-19, D-20, D-21, D-22, D-23]
|
||||
|
||||
coverage:
|
||||
- id: D1
|
||||
description: "Returning user signs in through a fake provider: sealed state, identity match, one-time oauth-complete, oauth-providers lists the provider"
|
||||
requirement: "SC-1"
|
||||
verification:
|
||||
- kind: integration
|
||||
ref: "../sm-sociallogin-plugin/tracer_smoke_test.go#TestTracerSmoke (Postgres)"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D2
|
||||
description: "Email matching, pending registration, account creation at the callback and at oauth-register-complete, consent, throttle, beta gate, token cookie"
|
||||
requirement: "SC-2"
|
||||
verification:
|
||||
- kind: integration
|
||||
ref: "../sm-sociallogin-plugin/flow_smoke_test.go#TestFlowSmoke (5 subtests, Postgres)"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D3
|
||||
description: "Linking through the link ticket and bearer; start-request errors land on the SPA"
|
||||
requirement: "SC-3"
|
||||
verification:
|
||||
- kind: integration
|
||||
ref: "../sm-sociallogin-plugin/link_smoke_test.go#TestLinkSmoke (8 subtests, Postgres)"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D4
|
||||
description: "Google, Facebook, GitHub and Microsoft wrappers with the D-11 email-verified rules against fake provider APIs"
|
||||
requirement: "D-11"
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "../sm-sociallogin-plugin/providers_smoke_test.go#TestGothProvidersSmoke (8 subtests, no Docker)"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D5
|
||||
description: "Admin settings override config per field; secrets never returned by the admin JSON"
|
||||
requirement: "D-16"
|
||||
verification:
|
||||
- kind: integration
|
||||
ref: "../sm-sociallogin-plugin/settings_smoke_test.go#TestSettingsOverrideSmoke (Postgres)"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D6
|
||||
description: "Remote operations in D-21 order: framework tag + changelog, sm-user-plugin push + tag, new repository + tag"
|
||||
requirement: "D-21"
|
||||
verification:
|
||||
- kind: other
|
||||
ref: "git ls-remote --exit-code --tags origin v0.3.1 (summercms.go); ... v0.1.4 (sm-user-plugin); ... v0.1.0 and refs/heads/master (sm-sociallogin-plugin); curl https://summercms.io/changelog/summercms-0-3-1 = 200"
|
||||
status: pass
|
||||
human_judgment: true
|
||||
rationale: "The release train needed the human go-ahead (Task 5) and a decision on the changelog blocker (fix-ua)"
|
||||
|
||||
duration: 25min
|
||||
completed: 2026-10-21
|
||||
status: complete
|
||||
---
|
||||
|
||||
# Phase 19.5 Plan 02: Social sign-in plugin Summary
|
||||
|
||||
**New private repository sm-sociallogin-plugin v0.1.0: PHP-compatible /oauth/{provider} sign-in for Google, Facebook, GitHub and Microsoft through goth wrapped in one file, a sealed AES-256-GCM state cookie, one-time SPA handoff, pending registration and link-ticket linking, released after summercms v0.3.1 (changelog posted) and sm-user-plugin v0.1.4**
|
||||
|
||||
## Performance
|
||||
|
||||
- **Duration:** about 25 min from the first plan commit (2026-10-20T21:42:34Z) to the last tag push (about 22:06Z), not counting the work before the first commit; includes the Task 5 and changelog checkpoint waits
|
||||
- **Started:** 2026-10-20T21:42:34Z (first plan commit)
|
||||
- **Completed:** 2026-10-20T22:06:00Z (2026-10-21 local)
|
||||
- **Tasks:** 6 of 6 (Task 5 is the human go-ahead)
|
||||
- **Files:** 43 tracked files created in sm-sociallogin-plugin, 1 modified in sm-summercmsio-app
|
||||
|
||||
## Accomplishments
|
||||
|
||||
- sm-sociallogin-plugin requires `golem15.user`; an application without it has no goth dependency, no /oauth routes and no sociallogin config (framework go.mod has no goth).
|
||||
- Its own `Provider`/`Profile`/`ProviderConfig`/`Registry`; goth imported only in `classes/providers_goth.go`; no gothic, no gorilla/sessions, no gopkg.in/yaml.v3 in the dependency graph.
|
||||
- PHP branches ported: identity login, no-email pending (fb_no_email), verified email auto-link, unverified match pending, direct creation on register with consent, throttle and beta gate; action=link through a 60-second link ticket or the request user; no JWT in a URL.
|
||||
- Admin settings screen with encrypted, write-only client secrets overriding config per field without a restart.
|
||||
- Release train in D-21 order: summercms v0.3.1 tagged, pushed and posted to the changelog; sm-user-plugin pushed and tagged v0.1.4; the plugin pinned to both, repository created, pushed and tagged v0.1.0.
|
||||
|
||||
## Task Commits
|
||||
|
||||
sm-sociallogin-plugin (`/media/nvme/dev/golem15/summercms.io/summercms/sm-sociallogin-plugin`):
|
||||
|
||||
1. **Task 1: tracer through a fake provider** - `c15e8b9` (feat)
|
||||
2. **Task 2: pending registration, account creation and email matching** - `f588a38` (feat)
|
||||
3. **Task 3: identity linking with a one-time link ticket and start-request errors** - `62c8586` (feat)
|
||||
4. **Task 4: Google, Facebook, GitHub and Microsoft through goth with a settings override** - `65244a9` (feat)
|
||||
5. **Task 5: go-ahead** - no commit (reply below)
|
||||
6. **Task 6: require summercms v0.3.1 and sm-user-plugin v0.1.4** - `adfcfbf` (chore)
|
||||
|
||||
sm-summercmsio-app (`/media/nvme/dev/golem15/summercms.io/summercms/sm-summercmsio-app`):
|
||||
|
||||
- **Task 6 (fix-ua): changelog-post sends its own User-Agent** - `8581576` (fix), **not pushed** (push not authorised)
|
||||
|
||||
**Plan metadata:** this SUMMARY commit, `docs(19.5-02): ...` (summercms.go, local, not pushed)
|
||||
|
||||
## Task 5 reply
|
||||
|
||||
> release v0.3.1 (Recommended)
|
||||
|
||||
Recorded verbatim. Chosen framework tag: **v0.3.1**. Interpreted with the orchestrator as: framework v0.3.1, sm-user-plugin v0.1.4, sm-sociallogin-plugin v0.1.0, private repository via `tea repos create --owner golem15 --name sm-sociallogin-plugin`.
|
||||
|
||||
Per the Task 5 resume signal, the reply also authorises **plan 03** to push sm-bm-plugin before its pointer bump and to push sm-demo-app, sm-bm-app and fonoteka.go after its commits, each only when the unpushed commits belong to this phase. Nothing is ever force-pushed or deployed.
|
||||
|
||||
## Changelog blocker reply
|
||||
|
||||
> fix-ua (Recommended)
|
||||
|
||||
Recorded verbatim. The first publish failed with Cloudflare 403, error 1010 `browser_signature_banned` (ray `a4db4c6b7efc60a4`, 2026-10-20T22:00:59Z), because `scripts/changelog-post.py` sent the default `Python-urllib` User-Agent. The orchestrator confirmed `GET https://summercms.io/_journal/api/v1/posts` answers 403 with `Python-urllib/3.13` and 200 with `summercms-changelog-post/1`. Fix: one `req.add_header("User-Agent", "summercms-changelog-post/1")` line in `call()`, committed as `8581576`; the retried publish succeeded.
|
||||
|
||||
## Remote operations (Task 6, D-21 order)
|
||||
|
||||
1. **summercms.go** (previous continuation): `git push origin master` `dd047eb..5cf5c4f`; annotated tag `v0.3.1` (tag object `e13723c`, on `5cf5c4f`) pushed. The push also carried another session's planning commit `5cf5c4f` "docs(roadmap): add phase 32 console commands overhaul" (that session has since renumbered it to 19.8 in the unpushed local commit `c996790`, not ours).
|
||||
- Changelog post: id 34, https://summercms.io/changelog/summercms-0-3-1 (published_at 2026-10-20T22:00:28Z; GET answers 200). Excerpt: "One-time cache values with Cache.Pull and branded error pages for browser visitors."
|
||||
2. **sm-user-plugin** (`fonoteka.go/plugins/golem15/user`): fetched; 0 behind, 3 ahead (`78a867b`, `1741618`, `c43ba87`, all plan 19.5-01's); v0.1.4 absent locally and on origin; vet and short tests green. `git push origin master` `1fc0ce7..c43ba87`; annotated tag `v0.1.4` (tag object `2e32ed0`) pushed. `origin/master..master` empty.
|
||||
3. **sm-sociallogin-plugin**: go.mod requires `summercms v0.3.1` and `sm-user-plugin v0.1.4` (the go.mod replace of summercms to `../summercms.go` kept; go.work keeps both local replaces); `GOWORK=off go mod tidy` downloaded sm-user-plugin v0.1.4 from origin; vet, short and full suites green, `GOWORK=off go vet/build` green; README Dependencies names both versions; committed `adfcfbf`. `tea repos create --owner golem15 --name sm-sociallogin-plugin --private` created https://git.golem15.com/golem15/sm-sociallogin-plugin (anonymous GET answers 404, so private). `git remote add origin git@git.golem15.com:golem15/sm-sociallogin-plugin.git`; `git push -u origin master` (new branch, `c15e8b9..adfcfbf`, 5 commits); annotated tag `v0.1.0` (tag object `76c65fb`) pushed. `ls-remote --exit-code` for `refs/heads/master` and `v0.1.0` both exit 0.
|
||||
|
||||
Nothing deployed. Nothing force-pushed.
|
||||
|
||||
## Files Created/Modified
|
||||
|
||||
sm-sociallogin-plugin (new repository):
|
||||
- `plugin.go`, `routes.go`, `config.go`, `service.go` - plugin, routes, config loader, boot wiring
|
||||
- `admin_settings.go`, `admin_permissions.go`, `models/settings.go`, `models/settings/fields.yaml`, `updates/*` - settings singleton and migration
|
||||
- `classes/provider.go`, `models/registry.go` - provider interface and Registry
|
||||
- `classes/credentials.go`, `classes/config.go` - credentials (config, env, settings overlay), config types
|
||||
- `classes/statecookie.go` - sealed state cookie
|
||||
- `classes/callback.go`, `classes/flow.go`, `classes/registration.go`, `classes/codes.go`, `classes/messages.go` - callback branches, account creation, one-time codes, messages
|
||||
- `classes/providers_goth.go` - the only goth importer
|
||||
- `controllers/*` - redirect, callback, oauth-complete, oauth-register-complete, oauth-link-ticket
|
||||
- `config/config.yaml`, `lang/en/lang.yaml`, `lang/pl/lang.yaml`
|
||||
- `*_test.go` - harness, fake provider, five smoke tests
|
||||
- `README.md`, `go.mod`, `go.sum`, `go.work`, `.gitignore`
|
||||
|
||||
sm-summercmsio-app:
|
||||
- `scripts/changelog-post.py` - User-Agent header
|
||||
|
||||
## Verification
|
||||
|
||||
- sm-sociallogin-plugin after the require bump: `go vet ./...`, `go test -count=1 -short ./...`, `go test -count=1 ./...` green; `-v` shows PASS for TestTracerSmoke, TestFlowSmoke, TestLinkSmoke, TestSettingsOverrideSmoke, TestGothProvidersSmoke (none skipped).
|
||||
- At the Task 5 checkpoint: vet, short and full suites green (TestTracerSmoke, TestFlowSmoke 5, TestLinkSmoke 8, TestSettingsOverrideSmoke, TestGothProvidersSmoke 8); goth only in `classes/providers_goth.go`, no gothic, UseProviders, gorilla/sessions or yaml.v3; framework go.mod has no goth; summercms.go vet and short suite green; fonoteka.go builds.
|
||||
- sm-user-plugin before push: vet and short suite green.
|
||||
- Acceptance: `git ls-remote --exit-code --tags origin v0.3.1` (summercms.go), `... v0.1.4` (sm-user-plugin), `... v0.1.0` (sm-sociallogin-plugin) all exit 0; `grep -c 'git.golem15.com/golem15/sm-user-plugin v0.1.4' go.mod` = 1 (the plan's v0.1.2 string is replaced by v0.1.4, deviation 13); `origin/master..master` empty in sm-user-plugin.
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
### Auto-fixed Issues and adjustments
|
||||
|
||||
1. **[Rule 1] SettingsCode `sociallogin`** instead of `golem15.sociallogin.settings`: cabana rejects dotted settings codes. (`65244a9`)
|
||||
2. **[Rule 2] `models.Settings` has `Rules()`** for admin validation. (`65244a9`)
|
||||
3. **[Rule 3] Config types live in `classes`** (root `sociallogin.Config` is an alias plus `LoadConfig` wrapper) to avoid an import cycle. (`c15e8b9`)
|
||||
4. **[Rule 3] `classes.Message(ctx, app, key, params)`** plus `RegisterMessage` for localized messages. (`c15e8b9`, `f588a38`)
|
||||
5. **[Rule 2] `classes.ClientIP`** (surf.ClientIP with trusted proxies) for last IP, creation IP and the register throttle. (`f588a38`)
|
||||
6. **[Rule 1] Pending code pulled inside the final DB transaction after every 422 check**: a lost race answers 410 (the winner 200); a 422 never consumes. (`f588a38`)
|
||||
7. **[Rule 1] `providers.microsoft.tenant` defaults to ""**, which falls back to `common` (`DefaultTenant`). (`65244a9`)
|
||||
8. **[Rule 2] Beta key carried from any start request** into the sealed cookie and the pending payload (Pitfall 14). (`f588a38`)
|
||||
9. **[Rule 2] Inactive or banned link user answers `must_be_logged_in`.** (`62c8586`)
|
||||
10. **[Rule 2] Real messages for inactive and banned users (A7)**, Polish copy for every message, PHP Polish byte-for-byte where PHP has it. (`f588a38`, `62c8586`)
|
||||
11. **[Rule 3] `bootSocial` returns `*socialEnv`** to share the wiring between Boot and tests. (`c15e8b9`)
|
||||
12. **[Rule 3] Extra exported identifiers** beyond the plan's list: `ErrInvalidName`, `Registry.Has`, `ErrInvalidState`, `StateCookieSecure`, `WithQuery`, `CallbackRoute`, `NewHostSet`, `OutcomePending`, `ReasonNoEmail`, `ReasonUnverifiedMatch`, `PendingPayload.Profile()`, `CompletePending`, `ErrPendingConsumed`, `CheckBetaKey`, `ValidationError`, `IsValidation`, `PasswordBootstrapSource`, `ClientIP`, `DefaultTenant`, `models.Providers`, `models.ConfigSecretKey`, `Settings.Credentials`. README documents them.
|
||||
13. **[Rule 3] sm-user-plugin tag v0.1.4 instead of the plan's v0.1.2**: v0.1.2 and v0.1.3 already existed. The plugin's requires were initially `summercms v0.3.0` / `sm-user-plugin v0.1.3`, bumped to `v0.3.1` / `v0.1.4` in `adfcfbf`.
|
||||
14. **[Rule 3, human decision] Changelog blocked by the Cloudflare User-Agent ban**, fixed in sm-summercmsio-app (`8581576`, reply "fix-ua (Recommended)"); the commit is **unpushed** because pushing sm-summercmsio-app was not authorised.
|
||||
15. **[Context] Extra commit pushed with summercms.go master**: another session's planning commit `5cf5c4f` "docs(roadmap): add phase 32 console commands overhaul" went out with the v0.3.1 push.
|
||||
|
||||
---
|
||||
|
||||
**Total deviations:** 15 (no architectural changes)
|
||||
**Impact on plan:** Contracts and routes as planned; names extended, not changed; version numbers adjusted to existing tags.
|
||||
|
||||
## Issues Encountered
|
||||
|
||||
- The first changelog publish failed (Cloudflare 403, error 1010); resolved by deviation 14 and a successful retry.
|
||||
|
||||
## Known Stubs
|
||||
|
||||
None.
|
||||
|
||||
## Threat Flags
|
||||
|
||||
None beyond the plan's threat model (all new surface is the planned /oauth and /_user/api/v1/oauth-* routes and the settings screen).
|
||||
|
||||
## User Setup Required
|
||||
|
||||
To enable a provider in an application: set `golem15.sociallogin.providers.<p>.client_id` and `client_secret` (or the legacy env names, or the admin settings screen) and register the redirect URI `app.url + /oauth/<p>/callback` with the provider.
|
||||
|
||||
## Next Phase Readiness
|
||||
|
||||
- Plan 03 can mount `git@git.golem15.com:golem15/sm-sociallogin-plugin.git` (v0.1.0) as a submodule and bump sm-user-plugin pointers to `c43ba87` (v0.1.4, pushed); its pushes are authorised as recorded above.
|
||||
- Open, outside this plan: sm-summercmsio-app `8581576` unpushed; summercms.go has this SUMMARY commit and another session's `c996790` unpushed.
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
- Files: all sm-sociallogin-plugin key files tracked (43); `sm-summercmsio-app/scripts/changelog-post.py` carries the header.
|
||||
- Commits: `c15e8b9`, `f588a38`, `62c8586`, `65244a9`, `adfcfbf` (sm-sociallogin-plugin, all on origin/master), `8581576` (sm-summercmsio-app), `c43ba87` (sm-user-plugin, on origin/master) exist.
|
||||
- Tags on origin: summercms `v0.3.1`, sm-user-plugin `v0.1.4`, sm-sociallogin-plugin `v0.1.0`.
|
||||
|
||||
---
|
||||
*Phase: 19.5-social-login-in-sm-user-plugin*
|
||||
*Completed: 2026-10-21*
|
||||
@@ -0,0 +1,243 @@
|
||||
---
|
||||
phase: 19.5-social-login-in-sm-user-plugin
|
||||
plan: 03
|
||||
subsystem: auth
|
||||
tags: [sociallogin, oauth, microsoft-365, entra, adoption, sm-demo-app, sm-bm-app, fonoteka]
|
||||
|
||||
requires:
|
||||
- phase: 19.5-02
|
||||
provides: "sm-sociallogin-plugin v0.1.0 (private), sm-user-plugin v0.1.4 (c43ba87), summercms v0.3.1, push go-ahead (release v0.3.1)"
|
||||
provides:
|
||||
- "sm-demo-app mounts golem15.sociallogin v0.1.0 next to golem15.oauthserver (go.mod requires it; providers off until credentials are set)"
|
||||
- "sm-bm-app mounts golem15.sociallogin for Microsoft 365 with tenant and redirect from the environment, DEPLOY.md Entra section"
|
||||
- "bm frontend: Microsoft 365 button, /auth/oauth/callback, /auth/oauth/rejestracja consent view, profile link through the link ticket"
|
||||
- "sm-bm-plugin: /oauth reserved from the SPA fallback; rebuilt public/"
|
||||
- "fonoteka.go pinned to sm-user-plugin c43ba87 (relaxed unlink rule), sociallogin not mounted"
|
||||
affects: [19.5-04, sm-demo-app, sm-bm-app, sm-bm-plugin, fonoteka.go]
|
||||
|
||||
actuals:
|
||||
tokens: 10560
|
||||
tasks: 4
|
||||
commits: 9
|
||||
plan_head_before: 7f7f5fcb
|
||||
plan_head_after: 7f7f5fcb
|
||||
# No code commit in summercms.go (only this SUMMARY). Measured per repository:
|
||||
# sm-demo-app: git rev-list --count 4b16f50..d5a98d9 = 2 (pushed)
|
||||
# sm-bm-plugin: git rev-list --count 2536bcb..85cae45 = 2 (pushed)
|
||||
# sm-bm-app: git rev-list --count 1d446cc..0374c3f = 4 (not pushed)
|
||||
# fonoteka.go: git rev-list --count 687e25a..f4d5728 = 1 (not pushed)
|
||||
# tokens: chars/4 over the changed lines of the four repositories, without go.sum and the generated public/ build.
|
||||
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns:
|
||||
- "Private shared plugin fetched in module mode through an env-scoped git insteadOf (GIT_CONFIG_COUNT), no global config change"
|
||||
- "Workspace-level framework replace when a workspace module's go.mod replaces the framework with its own sibling path"
|
||||
- "SPA OAuth callback view that replaces the route before redeeming a single-use code once"
|
||||
|
||||
key-files:
|
||||
created:
|
||||
- ../sm-demo-app/sociallogin_test.go
|
||||
- ../sm-bm-app/sociallogin_test.go
|
||||
- ../sm-bm-app/config/golem15.sociallogin.yaml
|
||||
- ../sm-bm-app/frontend/src/lib/oauth.ts
|
||||
- ../sm-bm-app/frontend/src/lib/oauth.test.ts
|
||||
- ../sm-bm-app/frontend/src/views/OAuthCallbackView.vue
|
||||
- ../sm-bm-app/frontend/src/views/OAuthRegisterView.vue
|
||||
- .planning/phases/19.5-social-login-in-sm-user-plugin/19.5-USER-SETUP.md
|
||||
- .planning/phases/19.5-social-login-in-sm-user-plugin/deferred-items.md
|
||||
modified:
|
||||
- ../sm-demo-app/{.gitmodules,go.mod,go.sum,go.work,summer.yaml,plugins.gen.go,boot_test.go,deploy/env.example,README.md}
|
||||
- ../sm-bm-app/{.gitmodules,go.mod,go.sum,go.work,summer.yaml,plugins.gen.go,realtime_test.go,scripts/build.sh,deploy/env.example,DEPLOY.md,README.md}
|
||||
- ../sm-bm-app/frontend/{vite.config.ts,src/router.ts,src/api/user.ts,src/stores/auth.ts,src/views/LoginView.vue,src/views/ProfileView.vue,src/pwa/sw.js,src/pwa/pwa.test.ts}
|
||||
- ../sm-bm-app/plugins/jz/bm/{static.go,static_test.go,public/}
|
||||
- ../fonoteka.go/plugins/golem15/user (pointer)
|
||||
|
||||
key-decisions:
|
||||
- "bm keeps providers.microsoft.tenant empty in committed config (empty means common): the MICROSOFT_TENANT legacy variable only fills an empty key, so a committed 'common' would shadow it"
|
||||
- "sm-demo-app go.mod requires sm-sociallogin-plugin v0.1.0, sm-user-plugin v0.1.4 and summercms v0.3.1 (TestGoModRequiresTags and check-pins.sh demand a tag for every summer.yaml plugin)"
|
||||
- "sm-bm-app go.work replaces the framework for the whole workspace, because sm-sociallogin-plugin's go.mod replaces it with a sibling path that conflicts with bm's"
|
||||
- "The bm service worker skips /oauth like the other reserved prefixes"
|
||||
- "sm-bm-app and fonoteka.go not pushed: both carry other sessions' unpushed commits"
|
||||
|
||||
patterns-established:
|
||||
- "An application that mounts sociallogin reserves /oauth in its SPA fallback and service worker"
|
||||
|
||||
requirements-completed: [SC-1, SC-3, D-01, D-04, D-05, D-06, D-11, D-14, D-17, D-18, D-21, D-23, D-24]
|
||||
|
||||
coverage:
|
||||
- id: D1
|
||||
description: "sm-demo-app mounts sociallogin; Google redirect with prompt=select_account, state and a summer_oauth_google cookie on /oauth; oauth-providers lists google"
|
||||
requirement: "D-21"
|
||||
verification:
|
||||
- kind: integration
|
||||
ref: "../sm-demo-app/sociallogin_test.go#TestSocialLoginRedirect"
|
||||
status: pass
|
||||
- kind: integration
|
||||
ref: "../sm-demo-app/boot_test.go#TestBootAllPlugins"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D2
|
||||
description: "Pitfall 9: /oauth/jwks, /oauth/authorize, /oauth/userinfo stay with oauthserver; GET /oauth/token and /oauth/nope answer 404 without a state cookie"
|
||||
requirement: "SC-1"
|
||||
verification:
|
||||
- kind: integration
|
||||
ref: "../sm-demo-app/sociallogin_test.go#TestSocialLoginCoexistsWithOAuthServer"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D3
|
||||
description: "sm-bm-app: Microsoft redirect to login.microsoftonline.com/<tenant>/oauth2/v2.0/authorize with offline_access from MICROSOFT_* env, provider list {microsoft, Microsoft}, unconfigured provider errors to the callback; /oauth reserved from the SPA fallback"
|
||||
requirement: "D-04"
|
||||
verification:
|
||||
- kind: integration
|
||||
ref: "../sm-bm-app/sociallogin_test.go#TestSocialLoginMounted"
|
||||
status: pass
|
||||
- kind: unit
|
||||
ref: "../sm-bm-app/plugins/jz/bm/static_test.go#TestStaticReservedPaths"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D4
|
||||
description: "bm frontend start URLs and callback parsing (login, register consent, link ticket, return_to encoding, array queries)"
|
||||
requirement: "D-06"
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "../sm-bm-app/frontend/src/lib/oauth.test.ts (10 tests)"
|
||||
status: pass
|
||||
- kind: other
|
||||
ref: "npm run typecheck; npm run build; TestEmbeddedPublicHasIndex"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D5
|
||||
description: "bm callback, consent and profile link views behave in a browser against a live Entra registration"
|
||||
requirement: "D-23"
|
||||
human_judgment: true
|
||||
rationale: "Needs a real Entra app registration and a browser; no credentials exist in this session"
|
||||
- id: D6
|
||||
description: "fonoteka.go builds and passes at sm-user-plugin c43ba87"
|
||||
requirement: "D-24"
|
||||
verification:
|
||||
- kind: other
|
||||
ref: "go -C ../fonoteka.go vet ./... && build ./... && test -count=1 -short ./..."
|
||||
status: pass
|
||||
- kind: integration
|
||||
ref: "go -C ../fonoteka.go test -count=1 ./plugins/golem15/user/... ./plugins/golem15/fonoteka/... (user plugin all ok; one unrelated cabana inventory failure, deferred-items.md)"
|
||||
status: pass
|
||||
human_judgment: true
|
||||
rationale: "TestPhase09SecurityRoutes fails on five summercms.cabana admin routes unrelated to the user plugin; the verifier should confirm it is pre-existing"
|
||||
|
||||
duration: 18min
|
||||
completed: 2026-10-21
|
||||
status: complete
|
||||
---
|
||||
|
||||
# Phase 19.5 Plan 03: Adoption in sm-demo-app, sm-bm-app and fonoteka.go Summary
|
||||
|
||||
**sm-sociallogin-plugin v0.1.0 mounted in the demo next to the OIDC provider and in bm for Microsoft 365 (tenant and redirect from the environment), the bm student app signs in, registers and links Microsoft through the one-time code and link ticket, and fonoteka.go is pinned green to sm-user-plugin v0.1.4**
|
||||
|
||||
## Performance
|
||||
|
||||
- **Duration:** about 18 min
|
||||
- **Started:** 2026-10-20T22:09:59Z
|
||||
- **Completed:** 2026-10-20T22:28:22Z (2026-10-21 local)
|
||||
- **Tasks:** 4 of 4
|
||||
- **Files:** about 45 source files across four repositories, plus the regenerated `public/` build (62 files) in sm-bm-plugin
|
||||
|
||||
## Accomplishments
|
||||
|
||||
- Demo: `golem15.sociallogin` boots without a database next to all 16 other plugins; GET `/oauth/google` redirects to Google's account chooser with a sealed state cookie; the OIDC provider's `/oauth/*` routes are untouched and its reserved names answer 404 from sociallogin.
|
||||
- bm: Microsoft 365 with a configurable tenant (default common); `/oauth` reserved from the SPA fallback and the service worker; DEPLOY.md explains the Entra registration, the env values, D-11 (no auto-link by email), linking from the profile and the one-instance cache caveat.
|
||||
- bm frontend: login button shown only when the provider is configured, a callback view that strips the code from the address bar before redeeming it once, a consent view for first sign-ins (with the unverified-match explanation), and a profile button that links through `oauth-link-ticket`. No new npm dependency.
|
||||
- fonoteka.go: only the user pointer moved; vet, build and the short suite green; the Docker-backed user plugin suite green.
|
||||
|
||||
## Task Commits
|
||||
|
||||
**sm-demo-app** (`/media/nvme/dev/golem15/summercms.io/summercms/sm-demo-app`), pushed `4b16f50..d5a98d9`:
|
||||
1. Task 1a: `9eb8492` chore: bump golem15.user for social sign-in hooks (pointer 7b9faa3 to c43ba87, go.mod `sm-user-plugin v0.1.4`)
|
||||
2. Task 1b: `d5a98d9` feat: mount golem15.sociallogin
|
||||
|
||||
**sm-bm-plugin** (`sm-bm-app/plugins/jz/bm`), pushed `2536bcb..85cae45` before the pointer bump:
|
||||
3. Task 2: `438c0b9` fix: reserve /oauth for social sign-in
|
||||
4. Task 3: `85cae45` build: frontend with Microsoft 365 sign-in
|
||||
|
||||
**sm-bm-app** (`/media/nvme/dev/golem15/summercms.io/summercms/sm-bm-app`), **not pushed**:
|
||||
5. Task 2: `bb800a5` chore: bump golem15.user for social sign-in hooks (pointer 24b16b1 to c43ba87, realtime test expectation)
|
||||
6. Task 2: `117ef8f` feat: mount golem15.sociallogin for Microsoft 365
|
||||
7. Task 3: `e76f8bc` feat: Microsoft 365 sign-in in the frontend
|
||||
8. Task 3: `0374c3f` chore: bump jz.bm for /oauth and the rebuilt frontend (pointer 85cae45)
|
||||
|
||||
**fonoteka.go** (`/media/nvme/dev/golem15/summercms.io/summercms/fonoteka.go`), **not pushed**:
|
||||
9. Task 4: `f4d5728` chore: bump golem15.user to the social sign-in hooks (pointer cf5c6b9 to c43ba87; only that path staged)
|
||||
|
||||
**Plan metadata:** this SUMMARY commit in summercms.go, `docs(19.5-03): ...` (local).
|
||||
|
||||
## Pushes
|
||||
|
||||
- **sm-demo-app:** fetched; `origin/master..master` listed only `9eb8492`, `d5a98d9`; pushed. Now in sync.
|
||||
- **sm-bm-plugin:** fetched; only `438c0b9`, `85cae45`; pushed before `0374c3f`. Now in sync; the committed pointer equals the pushed head.
|
||||
- **sm-bm-app: left to the user.** `origin/master..master` also holds another session's `3a97157` (bump golem15.presentations, summer theme option) and `1d446cc` (feat: add the summer presentation theme), so it was not pushed.
|
||||
- **fonoteka.go: left to the user.** 30 other unpushed commits (phase 14.2, 14.1, 12.1 and parity work) sit below `f4d5728`.
|
||||
- Nothing force-pushed, nothing deployed.
|
||||
|
||||
## Checked-out submodule commits
|
||||
|
||||
| Repository | Path | Commit |
|
||||
|---|---|---|
|
||||
| sm-demo-app | plugins/golem15/sociallogin | adfcfbf (v0.1.0) |
|
||||
| sm-demo-app | plugins/golem15/user | c43ba87 (v0.1.4) |
|
||||
| sm-bm-app | plugins/golem15/sociallogin | adfcfbf (v0.1.0) |
|
||||
| sm-bm-app | plugins/golem15/user | c43ba87 (v0.1.4) |
|
||||
| sm-bm-app | plugins/jz/bm | 85cae45 |
|
||||
| fonoteka.go | plugins/golem15/user | c43ba87 (v0.1.4) |
|
||||
|
||||
## Verification
|
||||
|
||||
- Task 1: `go build ./...`, `go vet .`, `go test -count=1 -short .` green; `TestBootAllPlugins`, `TestSocialLoginRedirect`, `TestSocialLoginCoexistsWithOAuthServer` = 3 PASS; the full root suite with Docker (`go test -count=1 .`) green; `GOWORK=off GOPRIVATE='git.golem15.com/*' go vet ./...` green; `scripts/check-pins.sh` ok (15 modules); `scripts/check-deploy.sh` ok.
|
||||
- Task 2: `TestStaticReservedPaths` PASS; sm-bm-plugin `go test ./...` green; bm `go build ./...`, `go vet .`, short tests green; `TestSocialLoginMounted` PASS; `GOWORK=off go build ./...` now works (it needed `go mod tidy` before this plan); a simulation of `scripts/build.sh`'s generated go.work vets sociallogin and builds bm.
|
||||
- Task 3: vitest 17 files / 211 tests green (oauth.test.ts 10 tests); `vue-tsc --noEmit` clean; `vite build` green and byte-identical on a second run; `TestEmbeddedPublicHasIndex` and `TestStaticReservedPaths` = 2 PASS; `go test -short ./plugins/jz/bm/...` green.
|
||||
- Task 4: fonoteka.go `go vet ./...`, `go build ./...`, `go test -count=1 -short ./...` green. Docker-backed run of `./plugins/golem15/user/... ./plugins/golem15/fonoteka/...` (Docker available, no sudo needed): every sm-user-plugin package ok; the fonoteka plugin packages ok except `TestPhase09SecurityRoutes` (see Issues).
|
||||
- Acceptance criteria: all pass except `grep -c 'tenant: common' config/golem15.sociallogin.yaml` (0, deviation 1).
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
1. **[Rule 1 - Bug] bm config keeps the tenant empty instead of `tenant: common`.** Found in Task 2: the plugin copies `MICROSOFT_TENANT` only onto an empty key (`copyLegacyEnv`), so a committed `common` would silently override the deployment's tenant and `TestSocialLoginMounted` (MICROSOFT_TENANT=organizations) would fail. Empty means common (`DefaultTenant`); the file says so. The acceptance grep for `tenant: common` prints 0. (`117ef8f`)
|
||||
2. **[Rule 3 - Blocking] sm-demo-app go.mod and go.sum changed** (not in the plan's file list): `TestGoModRequiresTags` and `check-pins.sh` require a tag for every summer.yaml plugin, so the user bump requires `sm-user-plugin v0.1.4` and the mount requires `sm-sociallogin-plugin v0.1.0`, which moves `summercms` to v0.3.1 and adds goth, `golang.org/x/oauth2` and `cloud.google.com/go/compute/metadata` (goth's graph only). (`9eb8492`, `d5a98d9`)
|
||||
3. **[Rule 3 - Blocking] The private module needs SSH for module-mode fetches.** `go get` over https asked for credentials. Fetched with an env-scoped rewrite (`GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=url.git@git.golem15.com:.insteadOf GIT_CONFIG_VALUE_0=https://git.golem15.com/`), no global git config change; the module is now in this machine's cache. Other machines need the rewrite or a public repository (19.5-USER-SETUP.md).
|
||||
4. **[Rule 3 - Blocking] sm-bm-app go.work gained `replace summercms => ../summercms.go`.** The plugin's go.mod replaces the framework with its own sibling path, which conflicts with bm's replace in workspace mode. (`117ef8f`)
|
||||
5. **[Rule 1 - Bug] bm realtime tests expected only the `presentations` namespace.** sm-user-plugin registers a `user` namespace since v0.1.2, so the bump turned `TestRealtimeDefaultDriver` and `TestRealtimeRoutes` red; the expectation is now `[presentations user]`. (`bb800a5`)
|
||||
6. **[Rule 2 - Missing critical] `scripts/build.sh` lists the sociallogin submodule** so the release build's go.work uses it, vets it and refuses its uncommitted changes. (`117ef8f`)
|
||||
7. **[Rule 2 - Missing critical] The bm service worker skips `/oauth`.** Its skip list mirrors the reserved prefixes; without it, `/oauth` navigations would go through the worker's offline fallback. `pwa.test.ts` covers `/oauth/microsoft` and its callback. (`e76f8bc`)
|
||||
8. **[Context] The rebuilt `public/` includes the summer presentation theme** from another session's committed but unpushed sm-bm-app commit `1d446cc` (fonts and CSS from the frontend sources). It is the frontend HEAD's build output; the pushed sm-bm-plugin therefore carries that theme before sm-bm-app's source commit is pushed. (`85cae45`)
|
||||
9. **[Context] Demo nginx does not route `/oauth` to Go** (`check-deploy.sh` refuses it on purpose), so the demo cannot reach social sign-in live; the env template says so and nothing in deploy/ was changed. Not needed for D-21 (mount and config).
|
||||
10. **[Context] g15office comment not posted:** no g15office tool in this executor session; the orchestrator posts it on `onunbz3l` (as for plans 01 and 02).
|
||||
|
||||
**Total deviations:** 10 (2 bug, 3 blocking, 2 missing critical, 3 context). **Impact:** contracts as planned; the bm tenant is configured only through the environment.
|
||||
|
||||
## Issues Encountered
|
||||
|
||||
- fonoteka.go `TestPhase09SecurityRoutes` fails in the Docker-backed run: five `summercms.cabana` admin routes (account preferences, reorder, singleton) are missing from fonoteka's route inventory. All belong to cabana, none to golem15.user, so the bump does not cause it. Logged in `deferred-items.md`.
|
||||
|
||||
## Known Stubs
|
||||
|
||||
None.
|
||||
|
||||
## Threat Flags
|
||||
|
||||
None beyond the plan's threat model. Templates and config hold no credential values (`grep -cE 'CLIENT_SECRET=[^ ]+'` and `client_secret: *[^ ]+` both 0); the tests use test-only strings.
|
||||
|
||||
## User Setup Required
|
||||
|
||||
See `19.5-USER-SETUP.md`: Entra registration and four env values for bm, Google credentials for the demo (live use only after the demo's nginx routes `/oauth`), and SSH access for module downloads of the private plugin.
|
||||
|
||||
## Next Phase Readiness
|
||||
|
||||
- Plan 04 (unit tests and the PHP test map) can run; all three applications build at the new pins.
|
||||
- Left to the user: push sm-bm-app (after the summer theme commits are cleared for push) and fonoteka.go; deploy bm with the Entra values.
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
- Files: sm-demo-app/sociallogin_test.go, sm-bm-app/sociallogin_test.go, sm-bm-app/config/golem15.sociallogin.yaml, frontend/src/lib/oauth.ts, oauth.test.ts, OAuthCallbackView.vue, OAuthRegisterView.vue exist.
|
||||
- Commits: 9eb8492, d5a98d9 (sm-demo-app, on origin), 438c0b9, 85cae45 (sm-bm-plugin, on origin), bb800a5, 117ef8f, e76f8bc, 0374c3f (sm-bm-app), f4d5728 (fonoteka.go) exist.
|
||||
|
||||
---
|
||||
*Phase: 19.5-social-login-in-sm-user-plugin*
|
||||
*Completed: 2026-10-21*
|
||||
@@ -0,0 +1,370 @@
|
||||
---
|
||||
phase: 19.5-social-login-in-sm-user-plugin
|
||||
plan: 04
|
||||
subsystem: auth
|
||||
tags: [sociallogin, oauth, unit-tests, php-map, security-review, gate, release, pre-account-takeover]
|
||||
|
||||
requires:
|
||||
- phase: 19.5-01
|
||||
provides: "cooler.Cache.Pull, sm-user-plugin social sign-in hooks (v0.1.4)"
|
||||
- phase: 19.5-02
|
||||
provides: "sm-sociallogin-plugin v0.1.0, summercms v0.3.1"
|
||||
- phase: 19.5-03
|
||||
provides: "sm-demo-app and sm-bm-app mounting sociallogin v0.1.0, fonoteka.go at sm-user-plugin v0.1.4"
|
||||
provides:
|
||||
- "scripts/check-phase19.5.sh: fail-closed gate across six repositories (go, userplugin, sociallogin, demo, bm, fonoteka, docker, docs, self-test, all)"
|
||||
- "Unit tests: cooler Pull (4), sm-user-plugin (6 + 1 PHP-named subtest), sm-sociallogin-plugin (26 decision tests + TestPHPMethodMap + 5 TestPHP* ports)"
|
||||
- "testdata/PHP_METHOD_MAP.md: 38 PHP social-auth methods, 17 ported, 18 adapted, 3 not-applicable"
|
||||
- "19.5-SECURITY-REVIEW.md: every T-19.5 threat pinned or accepted; T-19.5-38 found and fixed"
|
||||
- "sm-sociallogin-plugin v0.1.1 (three fixes), pinned in sm-demo-app and sm-bm-app"
|
||||
affects: [sm-sociallogin-plugin, sm-user-plugin, sm-demo-app, sm-bm-app, /msd-verify-work]
|
||||
|
||||
estimate:
|
||||
tokens: 320000
|
||||
tasks: 7
|
||||
actuals:
|
||||
tokens: 78000
|
||||
tasks: 7
|
||||
commits: 9
|
||||
plan_head_before: 877bf0a425d952df8b323a56d1a0b284474ff97e
|
||||
plan_head_after: f38713f
|
||||
# commits counts this plan's summercms.go commits before this SUMMARY: git rev-list --count 877bf0a..f38713f = 13,
|
||||
# of which 4 belong to another session (bc5a9db, 63a85a8, 09fd4d0, cdddaa3, phase 19.8 docs), so 9 are this plan's.
|
||||
# Other repositories (measured from their ledgers):
|
||||
# sm-sociallogin-plugin: git rev-list --count adfcfbf..d7ee28d = 6 (pushed, tag v0.1.1)
|
||||
# sm-user-plugin: git rev-list --count c43ba87..c7635c5 = 2 (pushed, no tag)
|
||||
# sm-demo-app: 61a00e9 (pushed); sm-bm-app: 99af8c2 (pushed with the approved earlier commits)
|
||||
# tokens: chars/4 over the test files, the gate script, the three fixes and the planning docs written by this plan.
|
||||
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns:
|
||||
- "Fail-closed phase gate: go test -json detector (fail, skip, zero, no-tests, missing name), vitest detector, NOT RUN docker rule, --self-test on planted output"
|
||||
- "Forced races: explicit transactions with row locks in both orders; start barriers with N goroutines; mutation checks on the mitigation"
|
||||
- "Built-in goth wrappers under test through a registry published before the plugin registers (bootSocialWith) with a fake http.RoundTripper"
|
||||
- "Transaction-scoped advisory locks (pg_advisory_xact_lock) to serialise concurrent account creations of one identity and email"
|
||||
|
||||
key-files:
|
||||
created:
|
||||
- scripts/check-phase19.5.sh
|
||||
- modules/cooler/pull_test.go
|
||||
- .planning/phases/19.5-social-login-in-sm-user-plugin/19.5-SECURITY-REVIEW.md
|
||||
- .planning/todos/pending/oauth-password-bootstrap-change-password.md
|
||||
- ../fonoteka.go/plugins/golem15/user/oauth_providers_test.go
|
||||
- ../fonoteka.go/plugins/golem15/user/oauth_identities_rule_test.go
|
||||
- ../fonoteka.go/plugins/golem15/user/social_hooks_test.go
|
||||
- ../sm-sociallogin-plugin/unit_helpers_test.go
|
||||
- ../sm-sociallogin-plugin/registry_test.go
|
||||
- ../sm-sociallogin-plugin/statecookie_test.go
|
||||
- ../sm-sociallogin-plugin/normalize_test.go
|
||||
- ../sm-sociallogin-plugin/codes_test.go
|
||||
- ../sm-sociallogin-plugin/routes_test.go
|
||||
- ../sm-sociallogin-plugin/matching_test.go
|
||||
- ../sm-sociallogin-plugin/registration_test.go
|
||||
- ../sm-sociallogin-plugin/link_test.go
|
||||
- ../sm-sociallogin-plugin/complete_test.go
|
||||
- ../sm-sociallogin-plugin/register_complete_test.go
|
||||
- ../sm-sociallogin-plugin/settings_test.go
|
||||
- ../sm-sociallogin-plugin/providers_goth_test.go
|
||||
- ../sm-sociallogin-plugin/php_port_test.go
|
||||
- ../sm-sociallogin-plugin/php_method_map_test.go
|
||||
- ../sm-sociallogin-plugin/testdata/php_methods.txt
|
||||
- ../sm-sociallogin-plugin/testdata/PHP_METHOD_MAP.md
|
||||
modified:
|
||||
- modules/cooler/docker_test.go
|
||||
- .planning/phases/19.5-social-login-in-sm-user-plugin/19.5-VALIDATION.md
|
||||
- ../fonoteka.go/plugins/golem15/user/profile_test.go
|
||||
- ../sm-sociallogin-plugin/classes/flow.go
|
||||
- ../sm-sociallogin-plugin/classes/registration.go
|
||||
- ../sm-sociallogin-plugin/models/settings.go
|
||||
- ../sm-sociallogin-plugin/harness_test.go
|
||||
- ../sm-sociallogin-plugin/README.md
|
||||
- ../sm-demo-app/plugins/golem15/sociallogin (pointer), go.mod, go.sum
|
||||
- ../sm-bm-app/plugins/golem15/sociallogin (pointer)
|
||||
|
||||
key-decisions:
|
||||
- "Task 6 reply: release sm-sociallogin-plugin v0.1.1; push sm-user-plugin test commits; push sm-bm-app including the earlier approved commits; fix T-19.5-38 with option (b)"
|
||||
- "T-19.5-38 option (b): a verified email auto-links only onto an account with a self-set password; a password-less account goes pending and its completion answers email_exists_link_account (deliberate divergence from PHP)"
|
||||
- "Gate scripts and .planning docs count as non-production for the Post-release fixes table (no release needed)"
|
||||
- "Concurrent account creations are serialised with transaction-scoped advisory locks on the provider identity and the email"
|
||||
- "The relaxed change-password bootstrap of the PHP plugin is not ported (3 not-applicable map rows); tracked as a todo"
|
||||
|
||||
patterns-established:
|
||||
- "A phase's last plan ships a fail-closed gate script plus a PHP method map with a checker that fails on missing, extra or nonexistent tests"
|
||||
|
||||
requirements-completed: [SC-4, D-03, D-04, D-06, D-07, D-08, D-09, D-10, D-11, D-12, D-13, D-14, D-15, D-16, D-17, D-19, D-20, D-21, D-23, D-25]
|
||||
|
||||
coverage:
|
||||
- id: D1
|
||||
description: "Fail-closed gate across six repositories, detectors proven on planted output"
|
||||
requirement: "SC-4"
|
||||
verification:
|
||||
- kind: other
|
||||
ref: "scripts/check-phase19.5.sh --all (exit 0, docker stage run) and --self-test"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D2
|
||||
description: "cooler.Cache.Pull single winner, expiry and prefix isolation on memory, miniredis and redis:7-alpine"
|
||||
requirement: "D-08"
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "modules/cooler/pull_test.go#TestPullConcurrent, TestPullExpired, TestPullPrefixIsolation; docker_test.go#TestPullDocker"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D3
|
||||
description: "sm-user-plugin provider source, unlink rule and race, sign-in helpers"
|
||||
requirement: "D-13"
|
||||
verification:
|
||||
- kind: integration
|
||||
ref: "sm-user-plugin oauth_providers_test.go, oauth_identities_rule_test.go, social_hooks_test.go (Postgres)"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D4
|
||||
description: "One unit test per sociallogin decision, incl. nOAuth, state cookie, concurrent completion, pre-account takeover"
|
||||
requirement: "D-10"
|
||||
verification:
|
||||
- kind: integration
|
||||
ref: "sm-sociallogin-plugin gate --sociallogin (9 tests) and --docker (28 tests)"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D5
|
||||
description: "PHP social-auth suite mapped case by case (38 methods) with a checker"
|
||||
requirement: "D-25"
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "sm-sociallogin-plugin php_method_map_test.go#TestPHPMethodMap (mutation-checked) and the five TestPHP* ports"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D6
|
||||
description: "Security review: every T-19.5 threat pinned by a test or accepted; T-19.5-38 fixed"
|
||||
requirement: "D-11"
|
||||
verification:
|
||||
- kind: other
|
||||
ref: ".planning/phases/19.5-social-login-in-sm-user-plugin/19.5-SECURITY-REVIEW.md; TestPreAccountTakeover"
|
||||
status: pass
|
||||
human_judgment: true
|
||||
rationale: "Review completeness and the T-19.5-38 product decision are judgments; the verifier should read the register"
|
||||
- id: D7
|
||||
description: "Post-release fixes released as v0.1.1 and contained in every application pin"
|
||||
requirement: "D-21"
|
||||
verification:
|
||||
- kind: other
|
||||
ref: "git ls-remote --exit-code --tags origin v0.1.1; merge-base --is-ancestor of 21f3fe5, c99f5f9, d7ee28d against the sociallogin gitlinks of sm-demo-app and sm-bm-app (all exit 0); gate --demo and --bm at the new pointers"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D8
|
||||
description: "Live sign-in with real provider credentials in the demo and bm"
|
||||
requirement: "D-21"
|
||||
human_judgment: true
|
||||
rationale: "Needs real provider registrations; manual row in VALIDATION.md"
|
||||
|
||||
duration: 1h42m
|
||||
completed: 2026-10-21
|
||||
status: complete
|
||||
---
|
||||
|
||||
# Phase 19.5 Plan 04: Unit tests, PHP map, security review and v0.1.1 Summary
|
||||
|
||||
**A fail-closed six-repository gate, unit tests for every social sign-in decision (cooler Pull, sm-user-plugin hooks, 26 plugin tests on Postgres and fake providers), the 38-method PHP social-auth map with a checker, and a security review that found three defects in sm-sociallogin-plugin (500s on concurrent completions and on every settings save, and a pre-account takeover through unverified-email accounts), fixed and released as v0.1.1 into sm-demo-app and sm-bm-app**
|
||||
|
||||
## Performance
|
||||
|
||||
- **Duration:** about 1h42m (2026-10-20T22:31:38Z to 2026-10-21T00:14Z), including the Task 6 checkpoint wait
|
||||
- **Tasks:** 7 of 7 (Task 6 the user's go-ahead, Task 7 the release)
|
||||
- **Files:** about 40 created or modified across six repositories
|
||||
|
||||
## Accomplishments
|
||||
|
||||
- `scripts/check-phase19.5.sh --all` exits 0 with every stage PASS, including docker (no NOT RUN).
|
||||
- Every decision of the validation map has a unit test, and the gate runs it by name.
|
||||
- The PHP social-auth suite is mapped: 17 ported, 18 adapted, 3 not-applicable (the relaxed change-password bootstrap, now a todo).
|
||||
- The security review pins every threat T-19.5-01..37 and T-19.5-SC, or accepts it with a reason; it lists every log call.
|
||||
- Three production defects found by the new tests and the review were fixed, released as sm-sociallogin-plugin v0.1.1 and pinned in both applications.
|
||||
|
||||
## Task Commits
|
||||
|
||||
summercms.go:
|
||||
1. Task 1: fail-closed phase gate. `b4c0e0d`
|
||||
2. Task 2: cooler Pull tests. `671d388`
|
||||
3. Task 2: gate update. `6b8f060`
|
||||
4. Task 3: gate update. `18598c1`
|
||||
5. Task 4: password-bootstrap todo. `52f17e4`
|
||||
6. Task 4: gate the PHP map. `2c2ff81`
|
||||
7. Task 5: security review and validation map. `e830298`
|
||||
8. Post-checkpoint: gate `TestPreAccountTakeover`. `1d5dc36`
|
||||
9. Post-checkpoint: close T-19.5-38 in the review. `f38713f`
|
||||
|
||||
sm-user-plugin (`fonoteka.go/plugins/golem15/user`), pushed `c43ba87..c7635c5`, no tag:
|
||||
- Task 2: provider source, unlink rule and race, sign-in helpers. `d6d8f11`
|
||||
- Task 4: `TestChangePassword/real_password_account_still_requires_current_password`. `c7635c5`
|
||||
|
||||
sm-sociallogin-plugin, pushed `adfcfbf..d7ee28d`, tag **v0.1.1** (tag object `dd1c1de`, on `d7ee28d`):
|
||||
- Task 3: fix concurrent pending completions (410, not 500). `21f3fe5`
|
||||
- Task 3: fix the admin settings save. `c99f5f9`
|
||||
- Task 3: unit tests for every decision. `f18830d`
|
||||
- Task 4: PHP map and ports. `d9a72a7`
|
||||
- Task 5: key domain-separation pins. `b7fa407`
|
||||
- Task 7 (after the Task 6 reply): fix the pre-account takeover (T-19.5-38). `d7ee28d`
|
||||
|
||||
Applications:
|
||||
- sm-demo-app: `61a00e9` chore: bump sm-sociallogin-plugin to v0.1.1 (gitlink and go.mod require v0.1.1), pushed `d5a98d9..61a00e9`.
|
||||
- sm-bm-app: `99af8c2` chore: bump sm-sociallogin-plugin to v0.1.1, pushed `9d5a371..99af8c2`. The push carried, as approved, another session's `3a97157` and `1d446cc` and plan 03's `bb800a5`, `117ef8f`, `e76f8bc`, `0374c3f`. Nothing else was unpushed, and every submodule pointer in the push was already on its remote.
|
||||
- fonoteka.go: not touched (user pin unchanged at c43ba87).
|
||||
|
||||
## Gate output
|
||||
|
||||
`scripts/check-phase19.5.sh --all`, last run after the T-19.5-38 fix: exit 0.
|
||||
|
||||
- self-test: PASS
|
||||
- go: PASS (4 cooler tests)
|
||||
- userplugin: PASS (2 tests; additive check against 4c5d3b8, 10 declarations added, no migration changed)
|
||||
- sociallogin: PASS (9 tests; no gorilla/sessions or gopkg.in/yaml.v3; goth only in `classes/providers_goth.go`)
|
||||
- demo: PASS (3)
|
||||
- bm: PASS (Go 1 + 2; vitest 211 passed; typecheck)
|
||||
- fonoteka: PASS (vet, short suite, sociallogin not mounted)
|
||||
- docs: PASS
|
||||
- docker: PASS (`TestPullDocker`; 10 user-plugin tests; 28 sociallogin tests)
|
||||
|
||||
After the release: `--demo`, `--bm` and `--sociallogin` passed again at the new pointers. `--fonoteka` was not rerun: nothing it tests changed since the `--all` run.
|
||||
|
||||
## Post-release fixes
|
||||
|
||||
| Repo | Commit | Subject | Found by | Pins that lacked it | Outcome |
|
||||
|---|---|---|---|---|---|
|
||||
| sm-sociallogin-plugin | `21f3fe5` | fix: concurrent pending completions answer 410, not 500 | `TestRegisterComplete/concurrent_submit_one_winner` (T-19.5-12) | sm-demo-app and sm-bm-app `plugins/golem15/sociallogin` (adfcfbf) | released in v0.1.1; both pins at d7ee28d; `merge-base --is-ancestor` exit 0 |
|
||||
| sm-sociallogin-plugin | `c99f5f9` | fix: the admin settings form saves (tenant checked without a regex rule) | `TestSettings/admin_json_hides_secrets` (T-19.5-16) | same two pins | released in v0.1.1; exit 0 for both |
|
||||
| sm-sociallogin-plugin | `d7ee28d` | fix: a verified sign-in never links onto a password-less account | security review finding T-19.5-38, pinned by `TestPreAccountTakeover` | same two pins | released in v0.1.1; exit 0 for both |
|
||||
|
||||
sm-user-plugin and summercms.go had no production change (test, gate and planning commits only). So there was no user-plugin or framework tag, no changelog post for the framework, and no change to the plugin's requires (still summercms v0.3.1, sm-user-plugin v0.1.4).
|
||||
|
||||
## Task 6 reply
|
||||
|
||||
> release sm-sociallogin-plugin v0.1.1, approve both extra pushes, fix T-19.5-38 with option (b)
|
||||
|
||||
As relayed by the coordinator from the user's answers in the main session. Interpreted as:
|
||||
- tag v0.1.1 after fixing T-19.5-38;
|
||||
- push sm-user-plugin `d6d8f11` and `c7635c5` without a tag;
|
||||
- push sm-bm-app including the earlier approved commits;
|
||||
- no changelog post from this executor (the main session handles it).
|
||||
|
||||
## Tags and final pins
|
||||
|
||||
| Repository | Tag | Commit |
|
||||
|---|---|---|
|
||||
| sm-sociallogin-plugin | v0.1.1 (new, pushed) | d7ee28d |
|
||||
| sm-user-plugin | v0.1.4 (unchanged; master pushed to c7635c5) | c43ba87 |
|
||||
| summercms.go | v0.3.1 (unchanged) | |
|
||||
|
||||
| Application | sociallogin | user |
|
||||
|---|---|---|
|
||||
| sm-demo-app | d7ee28d (v0.1.1; go.mod v0.1.1) | c43ba87 (v0.1.4) |
|
||||
| sm-bm-app | d7ee28d (v0.1.1; go.mod replace to the submodule) | c43ba87 (v0.1.4) |
|
||||
| fonoteka.go | not mounted | c43ba87 (v0.1.4) |
|
||||
|
||||
v0.1.1 tag message (user-facing):
|
||||
|
||||
```
|
||||
sm-sociallogin-plugin v0.1.1: security and reliability fixes
|
||||
|
||||
- a verified sign-in (Google, GitHub) no longer links onto an existing
|
||||
account that has no self-set password: an account created from an email
|
||||
the provider did not verify (Facebook, Microsoft) goes to the pending
|
||||
screen instead, and its owner links further providers from the settings
|
||||
or proves the address with a password reset (closes a pre-account
|
||||
takeover)
|
||||
- the admin settings page saves again: the Microsoft tenant is checked by
|
||||
the model instead of an unsupported validation rule, and a malformed
|
||||
tenant is a validation error on the field
|
||||
- two simultaneous submissions of one pending registration answer one 200
|
||||
and one 410 instead of a 500
|
||||
- unit tests for every decision and the PHP social-auth test map
|
||||
- still requires summercms v0.3.1 and sm-user-plugin v0.1.4
|
||||
```
|
||||
|
||||
## PHP map
|
||||
|
||||
38 methods: 17 ported, 18 adapted, 3 not-applicable. 26 rows point to sm-sociallogin-plugin tests and 9 to sm-user-plugin tests.
|
||||
- The three not-applicable rows are PHP's relaxed change-password bootstrap; the todo `oauth-password-bootstrap-change-password.md` tracks it.
|
||||
- `test_verified_provider_email_still_auto_links` is adapted, marking the deliberate T-19.5-38 divergence.
|
||||
|
||||
## Decisions Made
|
||||
|
||||
- T-19.5-38 option (b): `ownerVerifiedEmail` = the matched account has a self-set password. A password reset through the mailed code sets it, so the owner can prove the address. No sm-user-plugin change was needed; the fix only reads the existing `HasSelfSetPassword` field.
|
||||
- The refused auto-link reuses the existing shapes: pending `reason=unverified_match` at the callback, 422 `email_exists_link_account` at the completion.
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
### Auto-fixed Issues
|
||||
|
||||
**1. [Rule 1 - Bug] Concurrent pending completions answered 500**
|
||||
- **Found during:** Task 3 (`TestRegisterComplete/concurrent_submit_one_winner`: 15 × 500 instead of 410)
|
||||
- **Fix:** advisory locks on the identity and the email during account creation; a completion that loses the race finds the identity and returns `ErrPendingConsumed`
|
||||
- **Files modified:** `classes/flow.go`, `classes/registration.go`
|
||||
- **Commit:** `21f3fe5`
|
||||
|
||||
**2. [Rule 1 - Bug] Every admin settings save answered 500**
|
||||
- **Found during:** Task 3 (`TestSettings`). lagoon's model validation has no `regex:` rule.
|
||||
- **Fix:** tenant format checked in `Settings.BeforeValidate` (422); README updated
|
||||
- **Files modified:** `models/settings.go`, `README.md`
|
||||
- **Commit:** `c99f5f9`
|
||||
|
||||
**3. [Rule 4 - User decision] Pre-account takeover (T-19.5-38), found by the security review**
|
||||
- **Process:** raised at the Task 6 checkpoint; the user chose option (b)
|
||||
- **Fix:** a verified email auto-links only onto an account with a self-set password
|
||||
- **Test:** `TestPreAccountTakeover` (mutation-checked)
|
||||
- **Docs:** README matching table and security notes; PHP map row adapted
|
||||
- **Commit:** `d7ee28d`
|
||||
|
||||
### Other deviations
|
||||
|
||||
4. **[Process] One context for Tasks 1–5** instead of the planned hand-off after Task 3 (the budget allowed). At the Task 6 checkpoint, the Post-release fixes table went into the checkpoint return instead of a draft SUMMARY, as the coordinator instructed.
|
||||
5. **[Rule 3] Harness changes in sm-sociallogin-plugin:**
|
||||
- the harness trusts the loopback proxy, so tests choose the client address with `X-Forwarded-For` (deterministic throttle tests);
|
||||
- `bootSocialWith` publishes a test registry before the plugins register;
|
||||
- added `unit_helpers_test.go`.
|
||||
6. **[Rule 2] User-plugin Postgres tests skip under `-short`** (CLAUDE.md rule); the gate runs them by name in the docker stage.
|
||||
7. **[Scope] Non-production commits not counted as post-release fixes:** gate scripts and `.planning` docs, though the plan's literal rule would list them.
|
||||
8. **[Context] Normaliser difference from PHP:** a percent-encoded callback path whose decoded form equals the callback path is accepted and rebuilt to the canonical path. Safe, since the output is canonical; PHP refuses it. Pinned by `TestNormalizeFrontendCallback/encoded_path_rebuilt`.
|
||||
9. **[Context] `TestErrorMessages`** lives in `settings_test.go` (no `messages_test.go` in the plan's file list).
|
||||
10. **[Context] Pre-existing fonoteka failure:** `TestPhase09SecurityRoutes` (deferred-items.md) is outside the gate. The fonoteka stage runs `-short`, and the docker stage runs only this phase's named tests, so it never blocked a stage.
|
||||
11. **[Context] summercms.go push:** summercms.go was not pushed (not in the reply). Its unpushed range mixes this plan's commits with another session's 19.8 commits.
|
||||
12. **[Context] No g15office:** no g15office task for the new todo and no milestone comment (no tool in this session; the main session posts it).
|
||||
|
||||
**Total deviations:** 12 (2 bug fixes, 1 user-decided security fix, 9 process or context notes). **Impact:** three production fixes released as v0.1.1; contracts otherwise unchanged.
|
||||
|
||||
## Issues Encountered
|
||||
|
||||
None open. T-19.5-38 is closed.
|
||||
|
||||
## Known Stubs
|
||||
|
||||
None.
|
||||
|
||||
## Threat Flags
|
||||
|
||||
None. No new network surface beyond the plan's threat model; the fixes narrow existing paths.
|
||||
|
||||
## User Setup Required
|
||||
|
||||
None new (see 19.5-USER-SETUP.md for live provider credentials).
|
||||
|
||||
## Next Phase Readiness
|
||||
|
||||
- Phase complete; ready for `/msd-verify-work 19.5`.
|
||||
- The manual row in VALIDATION.md (live sign-in with real providers) remains.
|
||||
- The main session may post the v0.1.1 notes and the g15office comment.
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
- Files exist: `scripts/check-phase19.5.sh`, `19.5-SECURITY-REVIEW.md`, `19.5-VALIDATION.md`, the todo, `sm-sociallogin-plugin/testdata/PHP_METHOD_MAP.md` and `php_methods.txt`, `php_method_map_test.go`, `php_port_test.go`, the user-plugin test files.
|
||||
- Commits exist in their repositories:
|
||||
- summercms.go: `b4c0e0d 671d388 6b8f060 18598c1 52f17e4 2c2ff81 e830298 1d5dc36 f38713f`
|
||||
- sm-user-plugin: `d6d8f11 c7635c5` (on origin)
|
||||
- sm-sociallogin-plugin: `21f3fe5 c99f5f9 f18830d d9a72a7 b7fa407 d7ee28d` (on origin)
|
||||
- sm-demo-app: `61a00e9`; sm-bm-app: `99af8c2` (both on origin)
|
||||
- `git ls-remote --exit-code --tags origin v0.1.1` exits 0.
|
||||
- `origin/master..master` is empty in sm-sociallogin-plugin, sm-user-plugin, sm-demo-app and sm-bm-app.
|
||||
|
||||
---
|
||||
*Phase: 19.5-social-login-in-sm-user-plugin*
|
||||
*Completed: 2026-10-21*
|
||||
@@ -0,0 +1,671 @@
|
||||
---
|
||||
phase: 19.5-social-login-in-sm-user-plugin
|
||||
plan: 05
|
||||
type: execute
|
||||
wave: 5
|
||||
depends_on: ["19.5-04"]
|
||||
gap_closure: true
|
||||
files_modified:
|
||||
- ../fonoteka.go/plugins/golem15/user/updates/202610210001_add_users_email_verified_at.go
|
||||
- ../fonoteka.go/plugins/golem15/user/updates/organisations_test.go
|
||||
- ../fonoteka.go/plugins/golem15/user/updates/email_verified_at_test.go
|
||||
- ../fonoteka.go/plugins/golem15/user/models/user.go
|
||||
- ../fonoteka.go/plugins/golem15/user/classes/codes.go
|
||||
- ../fonoteka.go/plugins/golem15/user/controllers/api_controller.go
|
||||
- ../fonoteka.go/plugins/golem15/user/controllers/users_admin_controller.go
|
||||
- ../fonoteka.go/plugins/golem15/user/email_verified_test.go
|
||||
- ../fonoteka.go/plugins/golem15/user/README.md
|
||||
- ../sm-sociallogin-plugin/classes/flow.go
|
||||
- ../sm-sociallogin-plugin/harness_test.go
|
||||
- ../sm-sociallogin-plugin/owner_verified_test.go
|
||||
- ../sm-sociallogin-plugin/matching_test.go
|
||||
- ../sm-sociallogin-plugin/testdata/PHP_METHOD_MAP.md
|
||||
- ../sm-sociallogin-plugin/README.md
|
||||
- ../sm-sociallogin-plugin/go.mod
|
||||
- ../sm-sociallogin-plugin/go.sum
|
||||
- ../sm-bm-app/frontend/src/views/OAuthCallbackView.test.ts
|
||||
- ../sm-bm-app/plugins/golem15/user
|
||||
- ../sm-bm-app/plugins/golem15/sociallogin
|
||||
- ../sm-bm-app/go.mod
|
||||
- ../sm-bm-app/go.sum
|
||||
- ../sm-demo-app/plugins/golem15/user
|
||||
- ../sm-demo-app/plugins/golem15/sociallogin
|
||||
- ../sm-demo-app/go.mod
|
||||
- ../sm-demo-app/go.sum
|
||||
- ../fonoteka.go/plugins/golem15/user
|
||||
- scripts/check-phase19.5.sh
|
||||
- .planning/phases/19.5-social-login-in-sm-user-plugin/19.5-SECURITY-REVIEW.md
|
||||
- .planning/phases/19.5-social-login-in-sm-user-plugin/19.5-VALIDATION.md
|
||||
- .planning/todos/pending/email-proof-release.md
|
||||
autonomous: false
|
||||
requirements: [D-26, D-27, D-28, SC-3]
|
||||
estimate:
|
||||
tokens: 180000
|
||||
raw_tokens: 180000
|
||||
tasks: 7
|
||||
confidence: low
|
||||
must_haves:
|
||||
truths:
|
||||
- "D-26: on an email match, a verified provider's identity is linked automatically only when the matched account's users.email_verified_at is set. POST /_user/api/v1/register in activate_mode=auto followed by a verified Google sign-in for the same email ends on the pending screen with reason=unverified_match and provider=google, links no identity, and its oauth-register-complete answers 422 email_exists_link_account without consuming the pending code (TestOwnerVerifiedEmail/register_auto_then_google_pending)."
|
||||
- "D-26: an account whose owner activated it with the mailed code (activate-by-code or activate) or completed a mailed reset-code password reset receives the verified identity and is signed in (TestOwnerVerifiedEmail/mail_activated_by_code_links, reset_code_password_links; TestPreAccountTakeover/after_mailbox_proof_links drives the real reset routes)."
|
||||
- "D-26: accounts without recorded proof take the pending path: /register in auto or admin mode, an administrator's create and Activate action, a social sign-in (even from a verifying provider), and every account that existed before sm-user-plugin v0.1.5. No migration or code path backfills the column."
|
||||
- "D-27: users.email_verified_at is a nullable TIMESTAMPTZ added by migration 202610210001_add_users_email_verified_at (ADD COLUMN IF NOT EXISTS, no row written, no-op rollback). classes.VerifyActivationCode and the ResetPassword completion stamp it; /update and the admin Users form clear it when the email changes (a case-only change keeps it); it is not fillable, not in any user API payload and not in the admin form or list. No route, payload key or config key of sm-user-plugin changes."
|
||||
- "D-27 wording: the READMEs of sm-user-plugin and sm-sociallogin-plugin, the PHP map row of test_verified_provider_email_still_auto_links and 19.5-SECURITY-REVIEW.md describe proof as email_verified_at (mail activation or the reset code); none of them says a self-set password proves the address."
|
||||
- "D-28: src/views/OAuthCallbackView.test.ts mounts the real view in vitest and proves that router.replace drops ?code= before the single completeOAuth call (the route has no code when the call starts), that a second mount with the same code does not call completeOAuth again, that ?error= renders its message in the alert and that ?pending_registration= replaces to the oauth-register route with pending, provider, reason and email. frontend/package.json and package-lock.json are unchanged."
|
||||
- "scripts/check-phase19.5.sh fails closed on the new tests (TestOwnerVerifiedEmail, TestEmailVerifiedAt, TestAdminEmailVerifiedAt, TestEmailVerifiedAtMigration, TestCreateOrganisationsMigration, src/views/OAuthCallbackView.test.ts). Its migration check accepts exactly the one added migration file and refuses any other added, modified, renamed or deleted non-test file under updates/, and a migration that writes or drops anything; --self-test proves both rules; PHASE195_REQUIRE_DOCKER=1 --all passes."
|
||||
- "SC-3 and D-21 order: under the Task 6 go-ahead, sm-user-plugin v0.1.5 and then sm-sociallogin-plugin v0.1.2 (requiring sm-user-plugin v0.1.5) are pushed and tagged. sm-demo-app and sm-bm-app move plugins/golem15/user and then plugins/golem15/sociallogin in separate commits, and fonoteka.go moves plugins/golem15/user. Each application's gate stage is green at its new pins. On hold, nothing is pushed or tagged and a todo with a g15office task tracks the release."
|
||||
artifacts:
|
||||
- path: "../fonoteka.go/plugins/golem15/user/updates/202610210001_add_users_email_verified_at.go"
|
||||
provides: "the additive users.email_verified_at column"
|
||||
contains: "ADD COLUMN IF NOT EXISTS email_verified_at"
|
||||
- path: "../fonoteka.go/plugins/golem15/user/models/user.go"
|
||||
provides: "User.EmailVerifiedAt"
|
||||
contains: "gorm:\"column:email_verified_at\" json:\"-\""
|
||||
- path: "../fonoteka.go/plugins/golem15/user/email_verified_test.go"
|
||||
provides: "TestEmailVerifiedAt, TestAdminEmailVerifiedAt"
|
||||
contains: "TestAdminEmailVerifiedAt"
|
||||
- path: "../fonoteka.go/plugins/golem15/user/updates/email_verified_at_test.go"
|
||||
provides: "TestEmailVerifiedAtMigration"
|
||||
contains: "TestEmailVerifiedAtMigration"
|
||||
- path: "../sm-sociallogin-plugin/classes/flow.go"
|
||||
provides: "ownerVerifiedEmail reads the proof column"
|
||||
contains: "EmailVerifiedAt != nil"
|
||||
- path: "../sm-sociallogin-plugin/owner_verified_test.go"
|
||||
provides: "TestOwnerVerifiedEmail"
|
||||
contains: "register_auto_then_google_pending"
|
||||
- path: "../sm-bm-app/frontend/src/views/OAuthCallbackView.test.ts"
|
||||
provides: "the bm callback view test (D-28)"
|
||||
contains: "completeOAuth"
|
||||
- path: "scripts/check-phase19.5.sh"
|
||||
provides: "the fail-closed gate with the new tests and the migration allow-list"
|
||||
contains: "USER_ADDED_MIGRATIONS"
|
||||
key_links:
|
||||
- from: "../fonoteka.go/plugins/golem15/user/classes/codes.go"
|
||||
to: "users.email_verified_at"
|
||||
via: "VerifyActivationCode stamps the column in the same column write that activates the account"
|
||||
pattern: "email_verified_at"
|
||||
- from: "../fonoteka.go/plugins/golem15/user/controllers/api_controller.go"
|
||||
to: "users.email_verified_at"
|
||||
via: "ResetPassword stamps it in the write that stores the new password; Update clears it on an email change"
|
||||
pattern: "email_verified_at"
|
||||
- from: "../sm-sociallogin-plugin/classes/flow.go"
|
||||
to: "../fonoteka.go/plugins/golem15/user/models/user.go"
|
||||
via: "ownerVerifiedEmail reads User.EmailVerifiedAt in HandleCallback and CompletePending"
|
||||
pattern: "EmailVerifiedAt"
|
||||
- from: "scripts/check-phase19.5.sh"
|
||||
to: "../sm-bm-app/frontend/src/views/OAuthCallbackView.test.ts"
|
||||
via: "the bm stage requires the view test file to pass by name (REQUIRE_FILES)"
|
||||
pattern: "OAuthCallbackView.test.ts"
|
||||
- from: "../sm-sociallogin-plugin/go.mod"
|
||||
to: "sm-user-plugin v0.1.5"
|
||||
via: "Task 7 requires the tag that carries the column; app pins move user first, then sociallogin"
|
||||
pattern: "sm-user-plugin v0.1.5"
|
||||
prohibitions:
|
||||
- statement: "sm-user-plugin stays additive (core plugin, D-27): no route, user API payload key, config key, existing migration file or migration ID changes; the one new migration adds a nullable column and writes no rows."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "Proof is recorded only by the mailed activation code and the completed reset-code password reset. /register in auto or admin mode, RegisterUser, the admin create, the admin Activate action, change-password and every sociallogin path never write email_verified_at."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "No new npm dependency in sm-bm-app/frontend and no new Go module anywhere. If the view cannot be mounted with the installed packages, the plan stops at a checkpoint instead of adding one."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "Nothing is pushed or tagged before the Task 6 reply. Nothing is force-pushed or deployed. fonoteka.go and summercms.go are never pushed by this plan, and only plugins/golem15/user is staged in fonoteka.go."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
---
|
||||
|
||||
## Phase Goal
|
||||
|
||||
ROADMAP Goal: `sm-user-plugin` signs users in with Google and Facebook through `/oauth/{provider}`, with the SPA one-time-code handoff and the pending-registration branch, matching the PHP user plugin's contract. Per CONTEXT D-01, the code is delivered by `sm-sociallogin-plugin`. The goal is not a user story, and plans 01-04 recorded the decision not to invent one, so this plan does not invent one either. SC-3 ("identities are linked on login ... both applications still build and pass their tests") is the success criterion this gap closure touches.
|
||||
|
||||
This plan (5 of 5, wave 5, `gap_closure: true`) closes the two open items of 19.5-VERIFICATION.md under the user decisions D-26, D-27 and D-28 (CONTEXT, "Decisions after verification"):
|
||||
|
||||
1. **Human verification item 1, the residual pre-account takeover** (19.5-REVIEW CR-01). `ownerVerifiedEmail` treats a self-set password as proof. A `/register` in `activate_mode: auto` (the default) creates an activated account with a self-set password and no mailbox check, so a later verified Google or GitHub sign-in for the same email is linked onto the account of whoever registered it. D-26 and D-27 replace the password flag with recorded proof, `users.email_verified_at`.
|
||||
2. **Human verification item 2 / truth 16, the bm callback view.** `OAuthCallbackView.vue` has no test. D-28 adds a vitest view test.
|
||||
|
||||
Item 3 (the prohibition verdicts) stays a manual sign-off and is not touched here.
|
||||
|
||||
## Spec-less probe fallback
|
||||
|
||||
spec-less probe fallback skipped: the phase has no REQUIREMENTS.md IDs (visible skip). `must_haves` derive from D-26..D-28 and SC-3.
|
||||
|
||||
## Decisions taken in this plan (Claude's discretion inside D-26..D-28)
|
||||
|
||||
- **Proof writers.** `classes.VerifyActivationCode` stamps `email_verified_at` in the same column write that activates the account. It serves `POST /activate`, `POST /activate-by-code` and the administrator's invitation link, which mails the same activation code, so an invited user who follows the mail has proved the address. `controllers.ResetPassword` stamps it in the same write that stores the new password, so a reset that fails records nothing (D-27: "a completed reset-code password reset"). `classes.VerifyResetCode` is unchanged. The value is the time of the proof and overwrites an earlier one.
|
||||
- **Not proof (D-26).**
|
||||
- `/register` in `auto` or `admin` mode and `RegisterUser`: nobody read a mail.
|
||||
- The admin create (`FormBeforeCreate`) and the admin Activate action (`classes.ActivateUsers`): an administrator typing or vouching for an address does not show that the user controls the mailbox.
|
||||
- `ChangePassword`: it needs the current password, not the mailbox.
|
||||
- Any sociallogin path, including an account created from a verifying provider: D-27 names exactly two proof sources, and the provider's claim is already the other half of the auto-link test.
|
||||
- **The proof follows the address.** `/update` and the admin Users form set `email_verified_at` to NULL when the email changes. A change of letter case only (`strings.EqualFold`) keeps it, because matching is case-insensitive. Without this, an account could prove `attacker@…`, switch to the victim's address and receive the victim's verified identity: the same takeover by another route (T-19.5-40). This changes only an internal column, with no payload, route or config change, so D-27's additive rule holds.
|
||||
- **Migration shape.** ID `202610210001_add_users_email_verified_at`, `ADD COLUMN IF NOT EXISTS email_verified_at TIMESTAMPTZ NULL`, rollback a no-op. This follows the `202610160002_add_handle_to_users` precedent for a shared `users` column that may already exist: a Laravel-derived schema can carry `email_verified_at`, and its values mean the same thing, an address confirmed by a mailed link. The migration writes no rows.
|
||||
- **No backfill (D-26 default).** Accounts from before v0.1.5, PHP-imported data included, have no proof and take the pending path; their owners link from the settings or reset the password once. The README tells an application that trusts its imported addresses to set the column in its own data migration. Neither plugin does that.
|
||||
- **Field.** `models.User.EmailVerifiedAt *time.Time` with `gorm:"column:email_verified_at" json:"-"`. It is not added to `Fillable()`, to `apiArray`, or to the admin `fields.yaml` and `columns.yaml`.
|
||||
- **Matching.** sociallogin's `ownerVerifiedEmail(u)` becomes `u != nil && u.EmailVerifiedAt != nil` and no longer reads the self-set-password flag. sm-user-plugin's D-13 unlink rule still uses that flag, which is unrelated and unchanged.
|
||||
- **Test fixtures.** sociallogin's harness `e.user` now creates a proven account (`EmailVerifiedAt` set, as after a mail activation), so the verified-link cases of plans 02-04 keep testing the link path. A new `e.unprovenUser` models an account without proof.
|
||||
- **bm view test without a new dependency.** The bm frontend has no `@vue/test-utils`, jsdom or happy-dom (`test.environment: 'node'`). The test mounts the real SFC with `createRenderer` from `vue`, using a small in-memory host. It uses a vue-router `createMemoryHistory` router whose routes include `oauth-register`, and `vi.mock` stubs for `@/stores/auth` and `@/stores/toast`. Those stubs are required because `@/stores/auth` imports `@/router`, whose `createWebHistory` needs `window`. If that cannot work with the installed packages, the plan stops (Task 4).
|
||||
- **Versions.** sm-user-plugin **v0.1.5** and sm-sociallogin-plugin **v0.1.2**. If either tag already exists on its remote, use the next free patch and record the change as a deviation. summercms.go gets no production change, so it gets no framework tag and no changelog post (the CLAUDE.md changelog rule covers framework tags, as in 19.5-04).
|
||||
- **Out of scope, surfaced instead of decided.** 19.5-REVIEW CR-02 is not covered by D-26..D-28 and has no user decision. In that scenario, a pre-planted unverified identity survives the owner's reset and keeps signing in. Task 5 records it in 19.5-SECURITY-REVIEW.md as an open finding awaiting a decision, with no disposition invented.
|
||||
- **Context budget and hand-offs.** The estimate is 180k across 7 tasks, above the 100k smart zone. The user confirmed one plan, so execution splits it into three contexts, as plan 04 did:
|
||||
1. The first agent runs Tasks 1-3, writes `19.5-05-SUMMARY.md` as a draft headed `Status: draft (hand-off after Task 3)`, and stops. The draft lists the commits per repository with hashes, the test names and the gate stages with their state.
|
||||
2. A fresh agent runs Tasks 4 and 5 and stops at the Task 6 checkpoint.
|
||||
3. A continuation agent runs Task 7 after the reply and finalizes the SUMMARY.
|
||||
|
||||
## Source coverage audit
|
||||
|
||||
| Source | Item | Covered by |
|
||||
|---|---|---|
|
||||
| GOAL / SC-3 | Identities linked on login; consuming apps build and pass | Tasks 1, 3 (linking rule), 7 (app pins and gates) |
|
||||
| CONTEXT | D-26 (proof required; auto-mode and admin-created are not proof; existing accounts pending) | Tasks 1, 2, 3 |
|
||||
| CONTEXT | D-27 (additive column, two writers, v0.1.5 then v0.1.2 then pointer bumps, wording corrected) | Tasks 1, 2, 3, 5, 6, 7 |
|
||||
| CONTEXT | D-28 (bm view test, no new npm dependency) | Task 4 |
|
||||
| VERIFICATION | human item 1 / item 2 (truth 16) | Tasks 1-3 / Task 4 |
|
||||
| REQ | none (phase has no REQUIREMENTS.md IDs) | n/a |
|
||||
| RESEARCH | no new item for this follow-up | n/a |
|
||||
|
||||
## Artifacts this phase produces (this plan)
|
||||
|
||||
- **sm-user-plugin** (`../fonoteka.go/plugins/golem15/user`):
|
||||
- migration file `updates/202610210001_add_users_email_verified_at.go` (variable `usersEmailVerifiedAtMigrations`, ID `202610210001_add_users_email_verified_at`) and column `users.email_verified_at TIMESTAMPTZ NULL`;
|
||||
- field `models.User.EmailVerifiedAt`;
|
||||
- stamping in `classes.VerifyActivationCode` and `controllers.ResetPassword`, clearing in `controllers.Update` and `usersAdminController.FormBeforeUpdate`;
|
||||
- tests `TestEmailVerifiedAt` and `TestAdminEmailVerifiedAt` (root package), `TestEmailVerifiedAtMigration` (`updates`), and an updated `TestCreateOrganisationsMigration`;
|
||||
- README: the Features bullet "Address proof" and a migrations row;
|
||||
- tag `v0.1.5`.
|
||||
- **sm-sociallogin-plugin**:
|
||||
- `ownerVerifiedEmail` reads `EmailVerifiedAt`;
|
||||
- harness `e.user` (proven) and `e.unprovenUser`;
|
||||
- `owner_verified_test.go` with `TestOwnerVerifiedEmail`, subtests `register_auto_then_google_pending`, `mail_activated_by_code_links`, `reset_code_password_links`, `legacy_account_pending`, `register_action_pending`, `admin_activation_not_proof`, `email_change_drops_proof` and `unverified_provider_never_links_proven_account`;
|
||||
- `TestPreAccountTakeover/after_mailbox_proof_links` now drives `/forgot-password` and `/reset-password`;
|
||||
- the PHP map reason, the README matching table, security notes and Dependencies;
|
||||
- commit `chore: require sm-user-plugin v0.1.5`; tag `v0.1.2`.
|
||||
- **sm-bm-app**:
|
||||
- `frontend/src/views/OAuthCallbackView.test.ts`;
|
||||
- commits `chore: bump sm-user-plugin to v0.1.5` and `chore: bump sm-sociallogin-plugin to v0.1.2`.
|
||||
- **sm-demo-app**: the same two bump commits.
|
||||
- **fonoteka.go**: `chore: bump sm-user-plugin to v0.1.5`, staging only `plugins/golem15/user`, committed locally.
|
||||
- **summercms.go**:
|
||||
- `scripts/check-phase19.5.sh`:
|
||||
- array `USER_ADDED_MIGRATIONS`;
|
||||
- functions `refused_migration_changes` and `migration_writes_rows`;
|
||||
- array `DOCKER_USER_UPDATES_TESTS` and the `REQUIRE_FILES` list in `detect_vitest`;
|
||||
- the new required names and new `--self-test` cases;
|
||||
- `19.5-SECURITY-REVIEW.md`: T-19.5-38 corrected, T-19.5-39..T-19.5-46 added, T-19.5-26 closed by the view test, CR-02 recorded as open;
|
||||
- `19.5-VALIDATION.md`: rows `19.5-05-01`..`-07` and D-26, D-27 and D-28.
|
||||
- **On "hold" only:** todo `.planning/todos/pending/email-proof-release.md` with its g15office task.
|
||||
|
||||
<objective>
|
||||
Close the password-registration pre-account takeover by making recorded proof of the address, `users.email_verified_at`, the only owner-side condition for auto-linking a verified provider (D-26, D-27). Pin the bm OAuth callback view's ordering with a vitest view test (D-28). Extend the fail-closed gate to every new test. Then release in D-21 order under the user's go-ahead.
|
||||
|
||||
Purpose: VERIFICATION human items 1 and 2; SC-3's linking rule made safe under the default `activate_mode: auto`.
|
||||
Output: commits in sm-user-plugin, sm-sociallogin-plugin, sm-bm-app and summercms.go (gate and planning docs). Under the Task 6 reply, also tags v0.1.5 and v0.1.2 and the pointer bumps in sm-demo-app, sm-bm-app and fonoteka.go.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/msd-core/workflows/execute-plan.md
|
||||
@~/.claude/msd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/phases/19.5-social-login-in-sm-user-plugin/19.5-CONTEXT.md
|
||||
@.planning/phases/19.5-social-login-in-sm-user-plugin/19.5-VERIFICATION.md
|
||||
@.planning/phases/19.5-social-login-in-sm-user-plugin/19.5-04-SUMMARY.md
|
||||
|
||||
Repos and paths (verified with `git ls-files` at planning time):
|
||||
- sm-user-plugin is the checkout at `../fonoteka.go/plugins/golem15/user` (remote `git@git.golem15.com:golem15/sm-user-plugin.git`, HEAD c7635c5, latest tag v0.1.4). It is tested through the fonoteka.go workspace.
|
||||
- sm-sociallogin-plugin is at `../sm-sociallogin-plugin` (HEAD d7ee28d = v0.1.1). Its committed `go.work` replaces sm-user-plugin with `../fonoteka.go/plugins/golem15/user`, so the plugin compiles against the new field before the tag exists; its go.mod still requires v0.1.4 until Task 7.
|
||||
- sm-bm-app frontend: `../sm-bm-app/frontend`; the user and sociallogin plugins are submodules at `plugins/golem15/user` and `plugins/golem15/sociallogin` (both pinned c43ba87 / d7ee28d); go.mod replaces them with the submodule paths.
|
||||
- sm-demo-app: submodules at the same paths and pins; go.work replaces sm-user-plugin with `./plugins/golem15/user`; go.mod requires `sm-user-plugin v0.1.4` and `sm-sociallogin-plugin v0.1.1`, which `TestGoModRequiresTags` and `scripts/check-pins.sh` hold to tags.
|
||||
- fonoteka.go: gitlink `plugins/golem15/user` at c43ba87. Its worktree already shows other sessions' modified gitlinks (`plugins/golem15/feedback`, `plugins/golem15/golem`), and `origin/master..master` holds commits that are not this phase's, so this plan commits there locally and never pushes.
|
||||
|
||||
Rules: no co-author tags; stage by path, never `git add -A`; one logical change per commit; planning docs and code in separate commits. Before touching a shared file (the gate script, the planning docs), check `git status` for another session's uncommitted edits and leave them alone. Docker-backed tests skip under `-short`; run them on a Docker host (through `sudo -u golem` when the socket is denied). Keep `go vet` and `go test -short` green in every repository at every commit.
|
||||
|
||||
<interfaces>
|
||||
- sm-user-plugin:
|
||||
- `models.User` (models/user.go): fields `HasSelfSetPassword bool`, `ActivatedAt *time.Time`, `LastSeen *time.Time` (`json:"-"` precedent); `Fillable()` excludes timestamps.
|
||||
- `classes.VerifyActivationCode(ctx, db, userID, code) (*models.User, bool)` (classes/codes.go:117): the `UpdateColumns` map with `is_activated`, `activated_at`, `activation_code`, `activation_code_issued_at`.
|
||||
- `classes.VerifyResetCode` (codes.go:94) sets `has_self_set_password` and clears the code.
|
||||
- `controllers.ResetPassword` (api_controller.go:290): `Updates(map{password, tokens_valid_after})` after `VerifyResetCode`.
|
||||
- `controllers.Update` (api_controller.go:439): `Updates(map{name, surname, email})` on the authenticated user.
|
||||
- `controllers.Register` (:634) with `registrationSettings` mode auto|user|admin.
|
||||
- `usersAdminController.FormBeforeUpdate` (users_admin_controller.go:215) and `guardCredentials` (:468), which already plucks the stored email; cabana writes the record with `tx.Unscoped().Save(target)` (summercms.go `modules/cabana/crud.go:830`), so a field set on the model in `FormBeforeUpdate` is persisted.
|
||||
- `classes.ActivateUsers` (admin_actions.go:64).
|
||||
- Migrations self-register through `updates.Register` in `init()`, ordered by file name; `TestCreateOrganisationsMigration` (updates/organisations_test.go) asserts the exact history list and rolls back 11 steps.
|
||||
- Test helpers: `sessionApp`, `insertUser`, `loginToken`, `postJSON`, `bearer` (session_test.go), `bearerJSON`, `captureMail`; `newAdminEnv`, `env.admin`, `env.seedUser`, `env.reload`, `env.bulk`, `env.mails` (admin_harness_test.go); `dedicatedDB` (updates/postgres_test.go); `TestAdminUserInvite` (admin_users_test.go:1105) shows the invitation flow.
|
||||
- sm-sociallogin-plugin:
|
||||
- `ownerVerifiedEmail` (classes/flow.go:262), used in `HandleCallback` (:98) and `CompletePending` (:203).
|
||||
- Harness: `bootSocial`, `bootSocialWith`, `e.user` (harness_test.go:176), `e.credentials`, `e.setConfig`, `e.identityOwner`, `e.complete`, `e.do`, `e.browser` (harness_test.go); `e.flow`, `registerQuery`, `spaQuery`, `freshIP`, `fromIP`, `e.postRegisterComplete` (unit_helpers_test.go); `gothFakeRegistry(t, apis)` and `e.flowAt` (php_port_test.go:38-75); `newFakeAPIs`, `fakeToken` (providers_goth_test.go); `e.putPending`, `e.pendingExists` (register_complete_test.go); `e.userCount` (registration_test.go).
|
||||
- `TestPreAccountTakeover` (matching_test.go:561).
|
||||
- The Google userinfo route `www.googleapis.com/oauth2/v2/userinfo` with `verified_email`.
|
||||
- The en message for a refused completion: "An account with this email already exists. Please sign in and link your Google account from settings." (label substituted).
|
||||
- bm frontend:
|
||||
- `src/views/OAuthCallbackView.vue`: a module-level `redeemed` Set; `onMounted` replaces to `oauth-register` for pending, else `router.replace({ path })` then `auth.completeOAuth(code)`.
|
||||
- `src/lib/oauth.ts` `parseOAuthCallback`; `src/stores/auth.ts` imports `@/router`; `src/router.ts` uses `createWebHistory`.
|
||||
- `vite.config.ts` `test: { environment: 'node', include: ['src/**/*.test.ts'] }`; tsconfig includes `src/**/*.ts` (tests are typechecked, `noUnusedLocals`).
|
||||
- package.json devDependencies: `@vitejs/plugin-vue`, `typescript`, `vite`, `vitest`, `vue-tsc` only.
|
||||
- Gate: `scripts/check-phase19.5.sh` (`run_userplugin` migration check at :467-478, `detect_vitest` at :215 with `REQUIRE_FILE`, `run_bm` at :521, `run_docker` at :570, `run_self_test` at :349).
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tracer">
|
||||
<name>Task 1: Tracer: users.email_verified_at end to end: mail activation and the reset code stamp it, sociallogin links a verified email only onto a proven account, and an auto-mode /register account goes pending</name>
|
||||
<files>../fonoteka.go/plugins/golem15/user/updates/202610210001_add_users_email_verified_at.go, ../fonoteka.go/plugins/golem15/user/updates/organisations_test.go, ../fonoteka.go/plugins/golem15/user/models/user.go, ../fonoteka.go/plugins/golem15/user/classes/codes.go, ../fonoteka.go/plugins/golem15/user/controllers/api_controller.go, ../fonoteka.go/plugins/golem15/user/README.md, ../sm-sociallogin-plugin/classes/flow.go, ../sm-sociallogin-plugin/harness_test.go, ../sm-sociallogin-plugin/owner_verified_test.go, ../sm-sociallogin-plugin/matching_test.go, ../sm-sociallogin-plugin/README.md, scripts/check-phase19.5.sh</files>
|
||||
<read_first>../fonoteka.go/plugins/golem15/user/updates/202610160002_add_handle_to_users.go, ../fonoteka.go/plugins/golem15/user/updates/registry.go, ../fonoteka.go/plugins/golem15/user/updates/organisations_test.go, ../fonoteka.go/plugins/golem15/user/models/user.go, ../fonoteka.go/plugins/golem15/user/classes/codes.go, ../fonoteka.go/plugins/golem15/user/controllers/api_controller.go (ResetPassword :290-342), ../fonoteka.go/plugins/golem15/user/README.md (Features, migrations tables :141-160), ../sm-sociallogin-plugin/classes/flow.go (:55-120, :190-265), ../sm-sociallogin-plugin/harness_test.go, ../sm-sociallogin-plugin/unit_helpers_test.go, ../sm-sociallogin-plugin/php_port_test.go (:30-75), ../sm-sociallogin-plugin/matching_test.go (:555-640), ../sm-sociallogin-plugin/README.md (:40-60, :138-155), scripts/check-phase19.5.sh, 19.5-CONTEXT.md (D-26, D-27), 19.5-REVIEW.md (CR-01)</read_first>
|
||||
<action>The thinnest path through every layer, per D-26 and D-27: the column, two writers, the reader in the matching, one end-to-end test, and the gate.
|
||||
|
||||
sm-user-plugin:
|
||||
- New migration file `updates/202610210001_add_users_email_verified_at.go`, shaped like `202610160002_add_handle_to_users.go`:
|
||||
- variable `usersEmailVerifiedAtMigrations`, ID `202610210001_add_users_email_verified_at`;
|
||||
- Migrate runs `ALTER TABLE users ADD COLUMN IF NOT EXISTS email_verified_at TIMESTAMPTZ NULL` and nothing else;
|
||||
- Rollback is a no-op, with the handle file's reason (a shared users column that may predate the plugin);
|
||||
- registered in `init()`;
|
||||
- the doc comment says the column records when the owner proved the address (D-27), that the migration writes no rows (no backfill, D-26) and that an existing column keeps its values.
|
||||
- `models.User` gains `EmailVerifiedAt *time.Time` with tag `gorm:"column:email_verified_at" json:"-"` and a doc comment naming its writers. Do not add it to `Fillable()`.
|
||||
- `classes.VerifyActivationCode`: add `email_verified_at: now` to the existing `UpdateColumns` map and set `user.EmailVerifiedAt` (one write: activation and proof together). Update its doc comment.
|
||||
- `controllers.ResetPassword`: add `email_verified_at: now` to the `Updates` map that stores the new password and `tokens_valid_after`, so the proof exists only when the reset completed.
|
||||
- `updates/organisations_test.go`: append the new ID to the expected history and roll back 12 steps instead of 11, updating the comment.
|
||||
- README:
|
||||
- a Features bullet "Address proof": the column, its two writers, that /register in auto or admin mode, the admin create and Activate action and social sign-ins record none, and no backfill;
|
||||
- a table "Migrations added for address proof, all additive" with the row for `202610210001_add_users_email_verified_at` (`TIMESTAMPTZ` NULL, `ADD COLUMN IF NOT EXISTS`, no backfill, rollback a no-op).
|
||||
- One commit: `feat: users.email_verified_at records a proved address (mail activation, reset code)`.
|
||||
|
||||
sm-sociallogin-plugin:
|
||||
- `ownerVerifiedEmail` returns `u != nil && u.EmailVerifiedAt != nil`. Rewrite its doc comment: proof means a mailed activation code or a completed reset-code password reset (D-26); a self-set password alone is not proof (`/register` in auto mode, admin-created); accounts without proof go to the pending screen and their owner links from the settings. Keep the line in `HandleCallback`'s doc comment that names `ownerVerifiedEmail`.
|
||||
- Harness:
|
||||
- `e.user` sets `EmailVerifiedAt` to now and its doc comment says the account is proven (as after a mail activation), so the verified-link cases of plans 02-04 keep testing the link path;
|
||||
- add `e.unprovenUser(t, email)`: activated, self-set password, no proof.
|
||||
- New `owner_verified_test.go` with helpers on `*socialEnv`, each going through the user plugin's real routes on the test server with `fromIP(nil, freshIP())`:
|
||||
- `registerWithPassword` posts JSON to `/_user/api/v1/register` (email, password, password_confirmation);
|
||||
- `activateByMail` reads `activation_code` from the users row and posts `{id}!{code}` to `/_user/api/v1/activate-by-code`;
|
||||
- `resetByMail` posts `/_user/api/v1/forgot-password`, reads `reset_password_code` and posts `/_user/api/v1/reset-password`;
|
||||
- a Google sign-in helper through `gothFakeRegistry` (published in `bootSocialWith`), `e.credentials(t, "google")`, the token route `oauth2.googleapis.com/token` and a userinfo body with `verified_email` true, driven with `e.flowAt`.
|
||||
- `TestOwnerVerifiedEmail` has three subtests:
|
||||
- `register_auto_then_google_pending`:
|
||||
- with `golem15.user.activation.activate_mode` set to `auto` explicitly, `/register` answers 200 with a token, and the row has `is_activated` true and `email_verified_at` NULL;
|
||||
- a verified Google sign-in for the same email (upper-cased to prove the case-insensitive match) redirects with `pending_registration`, `reason=unverified_match`, `provider=google` and no `code`, and no google identity exists;
|
||||
- posting that code to `oauth-register-complete` answers 422 with the en `email_exists_link_account` message for Google, and `e.pendingExists` stays true;
|
||||
- `action=register` with consent for the same email also goes pending with no new users row (`e.userCount` 1).
|
||||
- `mail_activated_by_code_links`: with `activate_mode` user, `/register` answers "Activation email sent"; `activateByMail` sets the column; the verified Google sign-in redirects with `code`, the identity owner is that user, and `e.complete` returns that user's id.
|
||||
- `reset_code_password_links`: an auto-mode `/register` account, then `resetByMail`, then the verified Google sign-in links and signs in.
|
||||
- `TestPreAccountTakeover`:
|
||||
- its setup also requires the squatted account's `EmailVerifiedAt` to be nil;
|
||||
- `after_mailbox_proof_links` no longer writes the self-set-password column directly; it calls `resetByMail` for the squatted account, then asserts the link;
|
||||
- its doc comment says the owner proves the address with the mailed reset (`email_verified_at`).
|
||||
- README:
|
||||
- the "Account matching" rows: a match links only when the provider verified the email and the account's owner proved the address (`users.email_verified_at`: mail activation or a password set through the mailed reset code);
|
||||
- an account without proof (`/register` with `activate_mode: auto`, created by an administrator or by a social sign-in, or from before sm-user-plugin v0.1.5) goes pending with `unverified_match` even for a verified email, completion 422; the owner links from the settings or proves the address with a password reset;
|
||||
- the "Pre-account takeover" security note now explains this;
|
||||
- Dependencies name golem15.user v0.1.5 or newer (`users.email_verified_at`).
|
||||
- One commit: `fix: a verified sign-in links only onto an account that proved its address`.
|
||||
|
||||
Gate (`scripts/check-phase19.5.sh`):
|
||||
- Add `USER_ADDED_MIGRATIONS=(updates/202610210001_add_users_email_verified_at.go)` with a comment (D-27 relaxes D-22 for this one additive migration).
|
||||
- Add `refused_migration_changes`: it reads `git diff --name-status` lines and prints every line whose status is not `A` or whose path is not in `USER_ADDED_MIGRATIONS`.
|
||||
- Add `migration_writes_rows`: it reads a Go file, drops comment lines (`^\s*//`), and prints any line matching the uppercase SQL words that write or drop data.
|
||||
- In `run_userplugin`:
|
||||
- use `git diff --name-status "$USER_BASE" -- updates/ ':(exclude)*_test.go'`, keeping the existing capture of git's exit status;
|
||||
- refuse what `refused_migration_changes` prints, with the message "(D-22, D-27: only the email_verified_at migration may be added)";
|
||||
- for each allowed file present, require `ADD COLUMN IF NOT EXISTS email_verified_at` and refuse anything `migration_writes_rows` prints;
|
||||
- update the header comment of the userplugin stage.
|
||||
- `--self-test` cases:
|
||||
- accepted: an `A` line of the allowed file;
|
||||
- refused: an `M` line of `updates/00_base.go`; an `A` line of another file; a `D` line; an `R100` rename;
|
||||
- `migration_writes_rows` refuses a planted Exec of a row update and accepts a planted file whose only such word is in a `//` comment.
|
||||
- Append `TestOwnerVerifiedEmail` to `DOCKER_SOCIAL_TESTS` under a "Plan 05 (D-26)" comment.
|
||||
- Commit: `test(19.5): gate the email_verified_at migration and TestOwnerVerifiedEmail`.</action>
|
||||
<verify>
|
||||
<automated>go -C ../sm-sociallogin-plugin test -count=1 -run '^(TestOwnerVerifiedEmail|TestPreAccountTakeover)$' -v . 2>&1 | grep -c -- '^--- PASS: Test' | grep -qx 2</automated>
|
||||
<fails_when>The count of top-level "--- PASS: Test" lines is not 2: the auto-mode account was linked, a proven account was not linked, a subtest failed or skipped (Docker unreachable: rerun through sudo -u golem), or the package does not compile against the workspace user plugin.</fails_when>
|
||||
<automated>bash scripts/check-phase19.5.sh --self-test && bash scripts/check-phase19.5.sh --userplugin && bash scripts/check-phase19.5.sh --sociallogin</automated>
|
||||
<fails_when>Non-zero exit: a planted migration case is not caught or the allowed file is refused, the user plugin's vet or short tests fail, a removed export is found, a non-allowed file under updates/ changed, the new migration writes rows or lacks the ADD COLUMN, or a sociallogin Docker-free test fails.</fails_when>
|
||||
<automated>bash scripts/check-phase19.5.sh --docker</automated>
|
||||
<fails_when>Non-zero exit: an existing Postgres test of the user plugin or sociallogin (for example TestMatching, TestFlowSmoke, TestPHPPendingRegistrationTakeover) no longer passes with the proven-fixture semantics, TestOwnerVerifiedEmail is missing or skipped, or no Docker daemon answered (rerun through sudo -u golem; otherwise record NOT RUN).</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -c 'ADD COLUMN IF NOT EXISTS email_verified_at' ../fonoteka.go/plugins/golem15/user/updates/202610210001_add_users_email_verified_at.go` prints 1, and `grep -v '^\s*//' ../fonoteka.go/plugins/golem15/user/updates/202610210001_add_users_email_verified_at.go | grep -cE '\b(UPDATE|INSERT|DELETE|DROP)\b'` prints 0.
|
||||
- `grep -c 'gorm:"column:email_verified_at" json:"-"' ../fonoteka.go/plugins/golem15/user/models/user.go` prints 1.
|
||||
- `grep -c 'email_verified_at' ../fonoteka.go/plugins/golem15/user/classes/codes.go` prints at least 1 and `grep -c 'email_verified_at' ../fonoteka.go/plugins/golem15/user/controllers/api_controller.go` prints at least 1.
|
||||
- `grep -c '202610210001_add_users_email_verified_at' ../fonoteka.go/plugins/golem15/user/updates/organisations_test.go` prints 1.
|
||||
- `grep -c 'EmailVerifiedAt != nil' ../sm-sociallogin-plugin/classes/flow.go` prints at least 1 and `grep -c 'HasSelfSetPassword' ../sm-sociallogin-plugin/classes/flow.go` prints 0.
|
||||
- `grep -cE 't.Run\("(register_auto_then_google_pending|mail_activated_by_code_links|reset_code_password_links)"' ../sm-sociallogin-plugin/owner_verified_test.go` prints 3.
|
||||
- `grep -c 'and the account has a self-set password' ../sm-sociallogin-plugin/README.md` prints 0 and `grep -c 'email_verified_at' ../sm-sociallogin-plugin/README.md` prints at least 2.
|
||||
- `grep -c 'USER_ADDED_MIGRATIONS' scripts/check-phase19.5.sh` prints at least 2 and `grep -c 'TestOwnerVerifiedEmail' scripts/check-phase19.5.sh` prints at least 1.
|
||||
</acceptance_criteria>
|
||||
<done>A password-registered account without mailbox proof no longer receives a verified provider's identity, a mail-activated or reset account does, and the gate runs the end-to-end test by name.</done>
|
||||
<reversibility rating="costly">A published migration in a shared core plugin: once v0.1.5 runs in an application, the column exists there for good (the rollback is a no-op on purpose). The user approved the column in D-27.</reversibility>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 2: sm-user-plugin: the proof follows the address (an email change through /update or the admin form clears it), only the two mailed flows write it, and the migration writes no rows: TestEmailVerifiedAt, TestAdminEmailVerifiedAt, TestEmailVerifiedAtMigration</name>
|
||||
<files>../fonoteka.go/plugins/golem15/user/controllers/api_controller.go, ../fonoteka.go/plugins/golem15/user/controllers/users_admin_controller.go, ../fonoteka.go/plugins/golem15/user/email_verified_test.go, ../fonoteka.go/plugins/golem15/user/updates/email_verified_at_test.go, ../fonoteka.go/plugins/golem15/user/README.md, scripts/check-phase19.5.sh</files>
|
||||
<read_first>../fonoteka.go/plugins/golem15/user/controllers/api_controller.go (Update :439-486, Register :634-700, ChangePassword :492-588, apiArray :829), ../fonoteka.go/plugins/golem15/user/controllers/users_admin_controller.go (FormBeforeUpdate :215-237, FormBeforeCreate :243, guardCredentials :468-496, FormAfterCreate invite :305-350), ../fonoteka.go/plugins/golem15/user/classes/admin_actions.go (ActivateUsers :64), ../fonoteka.go/plugins/golem15/user/account_test.go, ../fonoteka.go/plugins/golem15/user/register_test.go, ../fonoteka.go/plugins/golem15/user/session_test.go (helpers :359-540), ../fonoteka.go/plugins/golem15/user/admin_harness_test.go, ../fonoteka.go/plugins/golem15/user/admin_users_test.go (TestAdminUserInvite :1105, TestAdminUserActions :575), ../fonoteka.go/plugins/golem15/user/updates/postgres_test.go, ../fonoteka.go/plugins/golem15/user/updates/admin_columns_test.go, ../fonoteka.go/plugins/golem15/user/README.md, scripts/check-phase19.5.sh</read_first>
|
||||
<behavior>
|
||||
- /register in auto mode and in admin mode leaves email_verified_at NULL; a submitted email_verified_at field is ignored (not fillable) on /register and on /update.
|
||||
- activate-by-code with the right code stamps it; /activate with the right code stamps it and with a wrong code leaves NULL; the response bodies carry no email_verified_at key.
|
||||
- reset-password with a valid code stamps it; a wrong or malformed code answers 422 and leaves NULL; change-password never stamps it.
|
||||
- /update with a different email clears a stamped proof; the same email in another letter case keeps it; the response body carries no email_verified_at key.
|
||||
- Admin: a user created on the Users form has NULL; the activate bulk action activates but leaves NULL; an invitation followed through activate-by-code stamps it; a form update that changes the email clears it; a form update that keeps the email keeps it; the admin form and list JSON carry no email_verified_at key.
|
||||
- Migration: the column is TIMESTAMPTZ and nullable; rows that exist before it runs stay NULL; a pre-existing column keeps its values; rollback leaves the column in place; the ID appears once in updates.All().
|
||||
</behavior>
|
||||
<action>Production changes, in one commit: `fix: an email change drops the address proof`.
|
||||
- `controllers.Update`: when the submitted email differs from the authenticated user's current email under `strings.EqualFold`, add `email_verified_at: nil` to the same `Updates` map and clear `user.EmailVerifiedAt`. Otherwise leave the column alone. The user payload is unchanged.
|
||||
- `usersAdminController.FormBeforeUpdate`: after the existing guards, when the stored email (the value `guardCredentials` already reads; an unexported helper may return it) differs from the submitted one under `strings.EqualFold`, set `user.EmailVerifiedAt` to nil. cabana's `Save` persists it.
|
||||
- Write the proof nowhere else (D-26): `Register`, `RegisterUser`, `FormBeforeCreate`, `classes.ActivateUsers` and `ChangePassword` stay as they are.
|
||||
- README: extend the "Address proof" bullet. An email change through /update or the admin form clears the proof. The column is never mass-assigned and never in a payload. An application that trusts its imported addresses may set the column in its own data migration; this plugin does not.
|
||||
|
||||
Tests, in one commit: `test: email_verified_at writers, non-writers and the migration`.
|
||||
- Root package `email_verified_test.go` (Postgres, skips under `-short`):
|
||||
- `TestEmailVerifiedAt` covers the API cases of the behavior block, one subtest each, through `sessionApp`, `insertUser`, `loginToken`, `postJSON`, `bearerJSON`, the controller handlers, `classes.IssueActivationCode` and `classes.IssueResetCode`. Subtest names: `register_auto_no_proof`, `register_admin_mode_no_proof`, `register_ignores_submitted_proof`, `activate_by_code_stamps`, `activate_stamps`, `activate_wrong_code_no_proof`, `reset_password_stamps`, `reset_wrong_code_no_proof`, `change_password_no_proof`, `update_email_change_clears`, `update_same_email_keeps`, `update_ignores_submitted_proof`. Each stamp is asserted to lie between the times taken before and after the request.
|
||||
- `TestAdminEmailVerifiedAt` covers the admin cases through `newAdminEnv`, `env.admin(t, permAccessUsers)`, the form create and update calls, `env.bulk` with the activate action and the invitation mail as in `TestAdminUserInvite`. Subtest names: `admin_create_no_proof`, `admin_activate_action_no_proof`, `admin_invite_link_stamps`, `admin_email_change_clears`, `admin_save_keeps_proof`, `admin_json_hides_proof`.
|
||||
- `updates/email_verified_at_test.go`: `TestEmailVerifiedAtMigration` on `dedicatedDB`, with subtests:
|
||||
- `column_shape`: information_schema `data_type` is `timestamp with time zone` and `is_nullable` is `YES`;
|
||||
- `no_backfill`: gormigrate `MigrateTo("202610160002_add_handle_to_users")`, insert activated users with self-set passwords, then `Migrate()`; every row has NULL;
|
||||
- `existing_column_kept`: on a fresh database, add the column by hand with a value before the migration runs; afterwards the migration ran and the value is unchanged;
|
||||
- `rollback_keeps_column`: `RollbackMigration(usersEmailVerifiedAtMigrations[0])` leaves the column;
|
||||
- `history_once`.
|
||||
|
||||
Gate, in one commit: `test(19.5): gate the user-plugin proof tests`.
|
||||
- Append `TestEmailVerifiedAt` and `TestAdminEmailVerifiedAt` to `DOCKER_USER_ROOT_TESTS` under a "Plan 05 (D-26, D-27)" comment.
|
||||
- Add `DOCKER_USER_UPDATES_TESTS=(TestEmailVerifiedAtMigration TestCreateOrganisationsMigration)` and run it in `run_docker` with `go_named "$FONOTEKA" ./plugins/golem15/user/updates "-timeout 20m"`.
|
||||
- Update the docker stage's header comment.</action>
|
||||
<verify>
|
||||
<automated>go -C ../fonoteka.go test -count=1 -run '^(TestEmailVerifiedAt|TestAdminEmailVerifiedAt)$' -v ./plugins/golem15/user 2>&1 | grep -c -- '^--- PASS: Test' | grep -qx 2</automated>
|
||||
<fails_when>The count of top-level "--- PASS: Test" lines is not 2: a writer or non-writer behaves differently from the behavior block, an email change keeps the proof, a payload gained a key, or the tests skipped (Docker unreachable: rerun through sudo -u golem).</fails_when>
|
||||
<automated>go -C ../fonoteka.go test -count=1 -run '^(TestEmailVerifiedAtMigration|TestCreateOrganisationsMigration)$' -v ./plugins/golem15/user/updates 2>&1 | grep -c -- '^--- PASS: Test' | grep -qx 2</automated>
|
||||
<fails_when>The count is not 2: the column has the wrong type or nullability, existing rows were stamped, a pre-existing column lost its value, the rollback removed the column, or the history list is wrong.</fails_when>
|
||||
<automated>bash scripts/check-phase19.5.sh --userplugin && bash scripts/check-phase19.5.sh --fonoteka && bash scripts/check-phase19.5.sh --docker</automated>
|
||||
<fails_when>Non-zero exit: the user plugin's vet or short suite, the additive and migration checks, the fonoteka.go short suite at the changed user plugin, or a required Docker test (including the new names) fails, skips or is missing.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -c 'EqualFold' ../fonoteka.go/plugins/golem15/user/controllers/api_controller.go` prints at least 1 and `grep -c 'EmailVerifiedAt' ../fonoteka.go/plugins/golem15/user/controllers/users_admin_controller.go` prints at least 1.
|
||||
- `grep -c 'func TestEmailVerifiedAt(' ../fonoteka.go/plugins/golem15/user/email_verified_test.go` prints 1 and `grep -c 'func TestAdminEmailVerifiedAt(' ../fonoteka.go/plugins/golem15/user/email_verified_test.go` prints 1.
|
||||
- `grep -cE 't.Run\("(update_email_change_clears|admin_email_change_clears|admin_activate_action_no_proof|register_auto_no_proof|reset_password_stamps|activate_by_code_stamps)"' ../fonoteka.go/plugins/golem15/user/email_verified_test.go` prints 6.
|
||||
- `grep -c 'func TestEmailVerifiedAtMigration(' ../fonoteka.go/plugins/golem15/user/updates/email_verified_at_test.go` prints 1 and `grep -c 't.Run("no_backfill"' ../fonoteka.go/plugins/golem15/user/updates/email_verified_at_test.go` prints 1.
|
||||
- `grep -c 'DOCKER_USER_UPDATES_TESTS' scripts/check-phase19.5.sh` prints at least 2 and `grep -cE 'TestEmailVerifiedAt|TestAdminEmailVerifiedAt' scripts/check-phase19.5.sh` prints at least 2.
|
||||
- `git -C ../fonoteka.go/plugins/golem15/user diff --name-only v0.1.4 -- routes.go config/ lang/ models/user/` prints nothing (no route, config key, phrase or admin YAML changed).
|
||||
</acceptance_criteria>
|
||||
<done>The proof column has exactly two writers, follows the address it proves, never leaks into a payload, and its migration is additive with no backfill. The gate runs every case by name.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 3: sm-sociallogin-plugin: the full owner-proof table end to end (legacy, register action, admin activation, email change, unverified provider), with the PHP map row and README wording corrected</name>
|
||||
<files>../sm-sociallogin-plugin/owner_verified_test.go, ../sm-sociallogin-plugin/testdata/PHP_METHOD_MAP.md, ../sm-sociallogin-plugin/README.md</files>
|
||||
<read_first>../sm-sociallogin-plugin/owner_verified_test.go (Task 1), ../sm-sociallogin-plugin/harness_test.go, ../sm-sociallogin-plugin/unit_helpers_test.go, ../sm-sociallogin-plugin/php_port_test.go (:120-160, :340-360), ../sm-sociallogin-plugin/testdata/PHP_METHOD_MAP.md (row test_verified_provider_email_still_auto_links), ../sm-sociallogin-plugin/php_method_map_test.go, ../sm-sociallogin-plugin/README.md, ../fonoteka.go/plugins/golem15/user/classes/admin_actions.go (ActivateUsers), ../fonoteka.go/plugins/golem15/user/controllers/api_controller.go (Update)</read_first>
|
||||
<behavior>
|
||||
- legacy_account_pending: an account made with e.unprovenUser (self-set password, no proof: an account from before v0.1.5) gets pending unverified_match from a verified Google sign-in; no identity; completion 422 keeps the code.
|
||||
- register_action_pending: action=register with consent for an unproven account's email goes pending unverified_match and creates no second users row.
|
||||
- admin_activation_not_proof: an account registered in activate_mode admin and activated through userclasses.ActivateUsers stays without proof and goes pending.
|
||||
- email_change_drops_proof: a proven account (activated by mail) changes its email to the victim's through POST /_user/api/v1/update with its bearer token; the row's proof is NULL; a verified Google sign-in for the victim's email goes pending and links nothing.
|
||||
- unverified_provider_never_links_proven_account: a Facebook sign-in (never verified, D-11) whose email matches a proven account goes pending unverified_match.
|
||||
</behavior>
|
||||
<action>Extend `TestOwnerVerifiedEmail` with the five subtests of the behavior block (D-26), using the Task 1 helpers. The Facebook case uses `gothFakeRegistry` with the Graph routes as in `TestPHPSocialAuthGate`. Every pending case also asserts that `e.identityOwner` is 0 for the provider id and that the redirect carries no `code`. One commit in the plugin: `test: owner proof table for verified sign-ins`.
|
||||
|
||||
`testdata/PHP_METHOD_MAP.md`, row `test_verified_provider_email_still_auto_links`: keep it `adapted` and rewrite its reason. The account in the port is proven (its email_verified_at is set, as after a mail activation). Go auto-links a verified email only onto an account whose owner proved the address (`users.email_verified_at`: mail activation or the reset code, D-26), a deliberate divergence from PHP against pre-account takeover (`TestPreAccountTakeover`, `TestOwnerVerifiedEmail`).
|
||||
|
||||
README:
|
||||
- In the "Account matching" section, add a sentence that an email change through golem15.user's /update or admin form drops the proof.
|
||||
- In "Security notes", the "Pre-account takeover" bullet covers both variants: an account created from an unverified provider email, and an account registered with a password but never proved. It says why a self-set password is not proof under `activate_mode: auto`.
|
||||
- One commit with the map: `docs: proof of address in the matching table and the PHP map`.
|
||||
|
||||
`TestPHPMethodMap` must stay green.
|
||||
|
||||
Hand-off point: after these commits, write the `19.5-05-SUMMARY.md` draft described under "Decisions taken in this plan" (context budget). This context ends here; a fresh agent runs Tasks 4 and 5.</action>
|
||||
<verify>
|
||||
<automated>go -C ../sm-sociallogin-plugin test -count=1 -run '^(TestOwnerVerifiedEmail|TestPreAccountTakeover|TestPHPPendingRegistrationTakeover)$' -v . 2>&1 | grep -c -- '^--- PASS: Test' | grep -qx 3</automated>
|
||||
<fails_when>The count of top-level "--- PASS: Test" lines is not 3: an unproven, admin-activated or email-changed account was linked, a proven account stopped linking, a subtest skipped (Docker unreachable: rerun through sudo -u golem), or the PHP port no longer passes.</fails_when>
|
||||
<automated>bash scripts/check-phase19.5.sh --sociallogin && bash scripts/check-phase19.5.sh --docker</automated>
|
||||
<fails_when>Non-zero exit: TestPHPMethodMap fails on the edited map, a Docker-free plugin test fails, or a required Postgres test of any repository fails, skips or is missing.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -cE 't.Run\("(legacy_account_pending|register_action_pending|admin_activation_not_proof|email_change_drops_proof|unverified_provider_never_links_proven_account)"' ../sm-sociallogin-plugin/owner_verified_test.go` prints 5.
|
||||
- `grep 'test_verified_provider_email_still_auto_links' ../sm-sociallogin-plugin/testdata/PHP_METHOD_MAP.md | grep -c 'email_verified_at'` prints 1.
|
||||
- `grep -c 'activate_mode: auto' ../sm-sociallogin-plugin/README.md` prints at least 1.
|
||||
- `test -f .planning/phases/19.5-social-login-in-sm-user-plugin/19.5-05-SUMMARY.md` exits 0 and `grep -c 'Status: draft (hand-off after Task 3)' .planning/phases/19.5-social-login-in-sm-user-plugin/19.5-05-SUMMARY.md` prints 1 (until Task 7 finalizes it).
|
||||
</acceptance_criteria>
|
||||
<done>Every way an account can reach an email match without proof ends on the pending screen, and every mailed proof links. The README and the PHP map say so.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 4: bm: vitest view test for OAuthCallbackView (code out of the URL before one redeem, no second redeem, error message, pending route) with no new npm dependency, required by name in the gate</name>
|
||||
<files>../sm-bm-app/frontend/src/views/OAuthCallbackView.test.ts, scripts/check-phase19.5.sh</files>
|
||||
<read_first>19.5-05-SUMMARY.md draft (hand-off from Tasks 1-3), ../sm-bm-app/frontend/src/views/OAuthCallbackView.vue, ../sm-bm-app/frontend/src/lib/oauth.ts, ../sm-bm-app/frontend/src/lib/oauth.test.ts, ../sm-bm-app/frontend/src/stores/auth.ts, ../sm-bm-app/frontend/src/stores/toast.ts, ../sm-bm-app/frontend/src/router.ts (:1-20), ../sm-bm-app/frontend/src/api/http.ts (ApiError), ../sm-bm-app/frontend/src/api/http.test.ts (test style), ../sm-bm-app/frontend/vite.config.ts, ../sm-bm-app/frontend/tsconfig.json, ../sm-bm-app/frontend/package.json, scripts/check-phase19.5.sh (detect_vitest :213-232, run_bm :521-551, self-test :374-383)</read_first>
|
||||
<action>D-28. Create `src/views/OAuthCallbackView.test.ts` in `../sm-bm-app/frontend`, with no new dependency.
|
||||
- Stubs: `vi.mock('@/stores/auth')` returns a stub whose `completeOAuth` is a `vi.fn` and whose `loggedIn` is false. `vi.mock('@/stores/toast')` returns a stub with `show` as a `vi.fn`. This is needed because the real auth store imports `@/router`, which calls `createWebHistory` and needs `window`.
|
||||
- Mounting: mount the real `OAuthCallbackView.vue` with an app from `createRenderer` (exported by `vue`), over a minimal in-memory host defined in the test file: element and text nodes, `insert`, `remove`, `createElement`, `createText`, `createComment`, `setText`, `setElementText`, `parentNode`, `nextSibling`, and a `patchProp` that stores attributes. Use a router from `createRouter` with `createMemoryHistory` and the routes `/` (name dashboard), `/auth/oauth/callback` (this view), `/auth/oauth/rejestracja` (name `oauth-register`, a stub component), `/profil` and `/logowanie`. Push `/` first, then the callback URL, await `router.isReady()`, mount, and flush pending promises until the view settles.
|
||||
- A text helper reads the element whose `role` is `alert`.
|
||||
- Cases, one `it` each, each with a distinct code because the view's `redeemed` set lives at module level:
|
||||
1. `replaces the route before the single redeem`: `router.replace` (a `vi.spyOn`) is invoked before `completeOAuth` (compare `mock.invocationCallOrder`); inside the `completeOAuth` stub, `router.currentRoute.value.query.code` is undefined; `completeOAuth` is called exactly once with the code; afterwards `router.back()` lands on `/`, not on a URL with `code`.
|
||||
2. `does not redeem a code twice`: unmount and mount again on the same callback URL with the same code; `completeOAuth` is still called once in total.
|
||||
3. `shows the error message`: `?error=Test%20message` renders `Test message` in the alert, and `completeOAuth` is not called.
|
||||
4. `routes a pending registration`: `?pending_registration=p1&provider=microsoft&reason=unverified_match&email=a%40b.test` ends on the route named `oauth-register` with query pending, provider, reason and email, through `replace` (Back does not return to the callback); `completeOAuth` is not called.
|
||||
5. `explains an expired code`: `completeOAuth` rejects with `new ApiError(410, 'gone')`, and the alert reads `Link logowania wygasł lub został już użyty.`
|
||||
- The file must pass `npm run typecheck` (strict, `noUnusedLocals`).
|
||||
- If mounting turns out to be impossible with the installed packages (for example the SFC cannot run under the custom renderer in the node environment): do not add a dependency and do not change `vite.config.ts`'s environment. Stop and return `## CHECKPOINT REACHED` with the error and the dependency that would be needed, as the user asked.
|
||||
- Commit in sm-bm-app (stage only the test file): `test(frontend): OAuth callback view drops the code before one redeem`.
|
||||
|
||||
Gate:
|
||||
- `detect_vitest` takes `REQUIRE_FILES` (space-separated), and every listed file must appear as a passing `✓ <file> (N tests)` line. Keep reading `REQUIRE_FILE` as a one-entry fallback.
|
||||
- `run_bm` passes `src/lib/oauth.test.ts src/views/OAuthCallbackView.test.ts`.
|
||||
- The self-test adapts the five existing vitest cases and adds `missing-view-file`: output with only oauth.test.ts passing while both are required gives exit 5.
|
||||
- Update the bm stage's header comment.
|
||||
- Commit: `test(19.5): gate the bm callback view test`.</action>
|
||||
<verify>
|
||||
<automated>npm --prefix ../sm-bm-app/frontend run test -- src/views/OAuthCallbackView.test.ts 2>&1 | grep -qE '✓ src/views/OAuthCallbackView.test.ts \([5-9] tests?\)' && npm --prefix ../sm-bm-app/frontend run typecheck</automated>
|
||||
<fails_when>No passing line for the view test file with at least 5 tests (a case failed, the view could not mount, or the file is missing), or vue-tsc reports an error in the test file.</fails_when>
|
||||
<automated>git -C ../sm-bm-app diff --quiet HEAD -- frontend/package.json frontend/package-lock.json && bash scripts/check-phase19.5.sh --self-test && bash scripts/check-phase19.5.sh --bm</automated>
|
||||
<fails_when>Non-zero exit: package.json or the lockfile changed (a new dependency), the vitest detector does not refuse the planted missing-view-file output, or the bm stage (Go tests, the whole vitest suite with both required files, typecheck) fails.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `test -f ../sm-bm-app/frontend/src/views/OAuthCallbackView.test.ts` exits 0 and `grep -c 'createRenderer' ../sm-bm-app/frontend/src/views/OAuthCallbackView.test.ts` prints at least 1.
|
||||
- `grep -c 'createMemoryHistory' ../sm-bm-app/frontend/src/views/OAuthCallbackView.test.ts` prints at least 1 and `grep -c 'oauth-register' ../sm-bm-app/frontend/src/views/OAuthCallbackView.test.ts` prints at least 1.
|
||||
- `git -C ../sm-bm-app log -1 --format=%H -- frontend/package.json frontend/package-lock.json` prints the same hash before and after this task (record both in the SUMMARY): no commit of this plan touches the dependency files.
|
||||
- `grep -c 'OAuthCallbackView.test.ts' scripts/check-phase19.5.sh` prints at least 2 and `grep -c 'REQUIRE_FILES' scripts/check-phase19.5.sh` prints at least 2.
|
||||
</acceptance_criteria>
|
||||
<done>The bm callback view's ordering, single redeem, error display and pending routing are pinned by a test that runs in the gate, with the frontend's dependencies unchanged.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 5: Security review and validation map record the proof-of-address fix, the whole gate is green with Docker required, and the release table is ready for the go-ahead</name>
|
||||
<files>.planning/phases/19.5-social-login-in-sm-user-plugin/19.5-SECURITY-REVIEW.md, .planning/phases/19.5-social-login-in-sm-user-plugin/19.5-VALIDATION.md</files>
|
||||
<read_first>19.5-05-SUMMARY.md draft, .planning/phases/19.5-social-login-in-sm-user-plugin/19.5-SECURITY-REVIEW.md, .planning/phases/19.5-social-login-in-sm-user-plugin/19.5-VALIDATION.md, .planning/phases/19.5-social-login-in-sm-user-plugin/19.5-REVIEW.md (CR-01, CR-02), this plan's threat_model, .planning/G15OFFICE.md</read_first>
|
||||
<action>19.5-SECURITY-REVIEW.md (D-27: correct the wording):
|
||||
- Add one sentence to the Result paragraph naming 19.5-05: it closes the password-registration variant through `users.email_verified_at`.
|
||||
- In the defects table, row d7ee28d: replace the closing claim about the reset with a note that 19.5-05 superseded the self-set-password test (T-19.5-39).
|
||||
- In the T-19.5-38 "New finding" row:
|
||||
- remove the parenthetical that says the reset works by setting the self-set-password flag;
|
||||
- describe the proof as `email_verified_at` (mail activation or a completed reset-code reset);
|
||||
- status "fixed in d7ee28d; the password-registration variant fixed in 19.5-05 (T-19.5-39)".
|
||||
- Rows T-19.5-10 and T-19.5-27: point their "creation path" notes at T-19.5-38 and T-19.5-39.
|
||||
- Row T-19.5-26: closed, with `src/views/OAuthCallbackView.test.ts` (B) as the view half's test.
|
||||
- Add register rows T-19.5-39 to T-19.5-46 from this plan's threat model, each with the implementation (file and function) and its test (name and repository) or the release control.
|
||||
- Under a new `## Open findings` heading, record 19.5-REVIEW CR-02 (a pre-planted unverified identity survives the owner's reset): status "open, no user decision; not covered by D-26..D-28". Invent no disposition.
|
||||
- Extend "Checks the plan names" with "the proof follows the address".
|
||||
|
||||
19.5-VALIDATION.md:
|
||||
- Rows `19.5-05-01` to `19.5-05-07` (task, decision, test names, gate stage, status). Tasks 6 and 7 are remote operations that name Task 7's acceptance criteria.
|
||||
- Decision-coverage rows D-26, D-27 and D-28.
|
||||
- The manual-only table keeps the live provider sign-in. The bm callback view is now automated (D-28), so note it under the table.
|
||||
- Keep `nyquist_compliant: true` and `status: validated`.
|
||||
|
||||
Run `PHASE195_REQUIRE_DOCKER=1 bash scripts/check-phase19.5.sh --all` on a Docker host (through `sudo -u golem` if needed) until every stage passes, and record each stage's result in the SUMMARY draft.
|
||||
|
||||
Commit the two planning docs together: `docs(19.5): proof of address in the security review and validation map`.
|
||||
|
||||
Then add a `## Release table` to the SUMMARY draft:
|
||||
- per repository (sm-user-plugin, sm-sociallogin-plugin, sm-bm-app, sm-demo-app, fonoteka.go, summercms.go): `git log --oneline origin/master..master` after `git fetch origin`, each commit marked as this plan's or foreign;
|
||||
- the proposed tags, each confirmed free with `git ls-remote --tags origin <tag>` printing nothing (else the next free patch);
|
||||
- the pointer-bump commits Task 7 would make.
|
||||
|
||||
The Task 6 checkpoint follows.</action>
|
||||
<verify>
|
||||
<automated>PHASE195_REQUIRE_DOCKER=1 bash scripts/check-phase19.5.sh --all</automated>
|
||||
<fails_when>Non-zero exit: any stage (self-test, go, userplugin, sociallogin, demo, bm, fonoteka, docs, docker) reports FAIL, a required test or test file is missing or skipped, or no Docker daemon answered.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -cE 'T-19\.5-(39|4[0-6])' .planning/phases/19.5-social-login-in-sm-user-plugin/19.5-SECURITY-REVIEW.md` prints at least 8.
|
||||
- `grep -c 'email_verified_at' .planning/phases/19.5-social-login-in-sm-user-plugin/19.5-SECURITY-REVIEW.md` prints at least 3 and `grep -c 'which sets .has_self_set_password' .planning/phases/19.5-social-login-in-sm-user-plugin/19.5-SECURITY-REVIEW.md` prints 0.
|
||||
- `grep -c 'CR-02' .planning/phases/19.5-social-login-in-sm-user-plugin/19.5-SECURITY-REVIEW.md` prints at least 1 and `grep -c 'OAuthCallbackView.test.ts' .planning/phases/19.5-social-login-in-sm-user-plugin/19.5-SECURITY-REVIEW.md` prints at least 1.
|
||||
- `grep -cE '^\| 19\.5-05-0[1-7] ' .planning/phases/19.5-social-login-in-sm-user-plugin/19.5-VALIDATION.md` prints 7 and `grep -cE '^\| D-2[678] ' .planning/phases/19.5-social-login-in-sm-user-plugin/19.5-VALIDATION.md` prints at least 3.
|
||||
- `grep -c '^## Release table' .planning/phases/19.5-social-login-in-sm-user-plugin/19.5-05-SUMMARY.md` prints 1.
|
||||
</acceptance_criteria>
|
||||
<done>The phase's security record matches the code, the validation map names a test for D-26..D-28, the whole gate is green across six repositories with Docker required, and the release table is ready for the checkpoint.</done>
|
||||
</task>
|
||||
|
||||
<task type="checkpoint:decision" gate="blocking-human">
|
||||
<name>Task 6: Go-ahead to release sm-user-plugin v0.1.5 and sm-sociallogin-plugin v0.1.2 and to move the application pins (D-21 order)</name>
|
||||
<read_first>19.5-05-SUMMARY.md draft (Release table, gate results), 19.5-04-SUMMARY.md (Tags and final pins), 19.5-CONTEXT.md (D-21, D-27), CLAUDE.md (Commits and concurrency)</read_first>
|
||||
<acceptance_criteria>
|
||||
- The SUMMARY records the reply verbatim under a `## Task 6 reply` heading.
|
||||
- Before the reply, `T=$(git -C ../fonoteka.go/plugins/golem15/user tag --points-at master) && test -z "$T"` and `T=$(git -C ../sm-sociallogin-plugin tag --points-at master) && test -z "$T"` exit 0, and `git -C ../sm-sociallogin-plugin ls-remote --tags origin v0.1.2` prints nothing (no tag before the go-ahead).
|
||||
</acceptance_criteria>
|
||||
<decision>Do the email-proof fix and the bm view test go out now, in D-21 order?</decision>
|
||||
<context>Tasks 1-5 are committed locally and the gate is green. Nothing is pushed yet.
|
||||
|
||||
What applications pin today:
|
||||
- sm-demo-app and sm-bm-app: sm-user-plugin v0.1.4 (c43ba87) and sm-sociallogin-plugin v0.1.1 (d7ee28d);
|
||||
- fonoteka.go: v0.1.4.
|
||||
|
||||
Until a release, the applications that mount sociallogin keep the password-registration takeover (VERIFICATION item 1). The Release table lists every unpushed commit per repository and flags any that are not this plan's.
|
||||
|
||||
Proposed, in order, never force-pushed and never deployed:
|
||||
1. sm-user-plugin: push master, tag v0.1.5 (the migration adds a nullable column and writes nothing; existing accounts get no proof).
|
||||
2. sm-sociallogin-plugin: commit `chore: require sm-user-plugin v0.1.5`, push, tag v0.1.2.
|
||||
3. sm-demo-app and sm-bm-app: commit `chore: bump sm-user-plugin to v0.1.5`, then `chore: bump sm-sociallogin-plugin to v0.1.2`, each with its gate stage green, then push. The sm-bm-app push includes the Task 4 test commit and any foreign commit listed in the table only if you allow it.
|
||||
4. fonoteka.go: commit `chore: bump sm-user-plugin to v0.1.5` locally (its unpushed range holds other work, so it is not pushed).
|
||||
|
||||
There is no framework release, so no changelog post.</context>
|
||||
<options>
|
||||
<option id="release">
|
||||
<name>release</name>
|
||||
<pros>Both applications that mount sociallogin get the fix as reproducible tags; the plugin's go.mod requires the user plugin that carries the column; D-21 holds.</pros>
|
||||
<cons>Two tags and pushes of four repositories now; the first boot of each application runs the new migration.</cons>
|
||||
</option>
|
||||
<option id="hold">
|
||||
<name>hold</name>
|
||||
<pros>Nothing leaves this machine.</pros>
|
||||
<cons>The applications keep the takeover path; the SUMMARY records the unreleased fix as a deviation and a todo with a g15office task tracks the release.</cons>
|
||||
</option>
|
||||
</options>
|
||||
<resume-signal>Reply "release" or "hold", optionally naming other versions or allowing the foreign commits in the sm-bm-app push. Nothing is ever force-pushed or deployed.</resume-signal>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 7: Under the Task 6 reply, release in D-21 order: sm-user-plugin v0.1.5, sm-sociallogin-plugin v0.1.2 requiring it, then separate pointer-bump commits (user first, then sociallogin) in sm-demo-app and sm-bm-app and the user pointer in fonoteka.go</name>
|
||||
<precondition>19.5-05-SUMMARY.md (draft) has a "Task 6 reply" heading with "release" or "hold".</precondition>
|
||||
<files>../sm-sociallogin-plugin/go.mod, ../sm-sociallogin-plugin/go.sum, ../sm-demo-app/plugins/golem15/user, ../sm-demo-app/plugins/golem15/sociallogin, ../sm-demo-app/go.mod, ../sm-demo-app/go.sum, ../sm-bm-app/plugins/golem15/user, ../sm-bm-app/plugins/golem15/sociallogin, ../sm-bm-app/go.mod, ../sm-bm-app/go.sum, ../fonoteka.go/plugins/golem15/user, .planning/todos/pending/email-proof-release.md</files>
|
||||
<read_first>19.5-05-SUMMARY.md draft (Release table, Task 6 reply), 19.5-02-SUMMARY.md ("Remote operations": GOWORK=off go mod tidy, annotated tags), 19.5-03-SUMMARY.md (how the app pointers and go.mod lines were bumped, check-pins.sh, TestGoModRequiresTags), 19.5-04-SUMMARY.md (Tags and final pins), CLAUDE.md (Commits and concurrency), .planning/G15OFFICE.md</read_first>
|
||||
<action>Act only as the reply allows. Before every push:
|
||||
- run `git fetch origin`;
|
||||
- `git status` shows no uncommitted change to this plan's paths;
|
||||
- `git log --oneline origin/master..master` lists only this plan's commits, plus foreign commits only when the reply allows them. Otherwise stop and report.
|
||||
Never force-push, never deploy, stage by path.
|
||||
|
||||
On "release" (D-21 order):
|
||||
1. sm-user-plugin (`../fonoteka.go/plugins/golem15/user`):
|
||||
- vet and the short suite are green through the fonoteka.go workspace;
|
||||
- push master;
|
||||
- create an annotated tag v0.1.5 whose user-facing message says: users.email_verified_at records a proved address (mail activation, reset code); an email change clears it; no backfill; the routes, payloads and config are unchanged;
|
||||
- push the tag.
|
||||
2. sm-sociallogin-plugin:
|
||||
- set the go.mod require to `git.golem15.com/golem15/sm-user-plugin v0.1.5`, keeping the summercms replace;
|
||||
- run `GOWORK=off GOPRIVATE='git.golem15.com/*' go mod tidy`; vet, the short suite and `GOWORK=off go build ./...` are green;
|
||||
- commit `chore: require sm-user-plugin v0.1.5`;
|
||||
- push master;
|
||||
- create an annotated tag v0.1.2 whose user-facing message says: a verified sign-in links only onto an account whose owner proved the address; accounts registered with a password in auto mode, created by an administrator or created before golem15.user v0.1.5 go to the pending screen; requires sm-user-plugin v0.1.5;
|
||||
- push the tag.
|
||||
3. sm-demo-app, two commits:
|
||||
- in `plugins/golem15/user`, fetch tags and check out v0.1.5; set the go.mod require to v0.1.5 and update go.sum as 19.5-03 did; `bash scripts/check-phase19.5.sh --demo` and `scripts/check-pins.sh` are green; commit `chore: bump sm-user-plugin to v0.1.5` (gitlink, go.mod, go.sum);
|
||||
- then the same for `plugins/golem15/sociallogin` at v0.1.2: commit `chore: bump sm-sociallogin-plugin to v0.1.2`;
|
||||
- push.
|
||||
4. sm-bm-app: the same two commits in the same order. Change the go.mod require lines only where they name versions, keep the replace lines, and add the go.sum change Go asks for to the commit it belongs to. `bash scripts/check-phase19.5.sh --bm` is green after each commit. Push, including the Task 4 test commit.
|
||||
5. fonoteka.go: `plugins/golem15/user` is already the v0.1.5 commit (check with `git -C plugins/golem15/user describe --exact-match`). Run `bash scripts/check-phase19.5.sh --fonoteka`. Stage only `plugins/golem15/user` and commit `chore: bump sm-user-plugin to v0.1.5`. Do not push. Other sessions' changes to feedback and golem stay untouched.
|
||||
6. Post a short comment on the g15office Phase 19.5 milestone task `onunbz3l`: the gap closure executed and v0.1.5 and v0.1.2 were released, awaiting re-verification. Set its status to Testing (.planning/G15OFFICE.md). If the tool is unavailable, record that in the SUMMARY.
|
||||
|
||||
On "hold": push nothing, tag nothing, move no pointer.
|
||||
- In the SUMMARY, record a deviation that lists the unreleased commits and the pins that lack them.
|
||||
- Create `.planning/todos/pending/email-proof-release.md` in the existing todo frontmatter style: priority high (security), area sm-user-plugin and sm-sociallogin-plugin. Its body holds the Release table and steps 1-5 above.
|
||||
- Create its g15office task per .planning/G15OFFICE.md and write the ID into the frontmatter.
|
||||
- Commit the todo alone: `docs(19.5): capture the email-proof release todo`.
|
||||
|
||||
Finalize the SUMMARY: tags, pushed ranges, the pointer-bump commits per application, final pins, and remove the draft status line.</action>
|
||||
<verify>
|
||||
<automated>bash scripts/check-phase19.5.sh --sociallogin && bash scripts/check-phase19.5.sh --demo && bash scripts/check-phase19.5.sh --bm && bash scripts/check-phase19.5.sh --fonoteka</automated>
|
||||
<fails_when>Non-zero exit: the plugin no longer builds at its new require, or an application no longer builds or passes its named tests at the new pins (for example, sociallogin v0.1.2 checked out before the user pin moved). On "hold", this command is not run and the SUMMARY says so.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- On "release":
|
||||
- `git -C ../fonoteka.go/plugins/golem15/user ls-remote --exit-code --tags origin v0.1.5` and `git -C ../sm-sociallogin-plugin ls-remote --exit-code --tags origin v0.1.2` exit 0 (or the versions the reply named);
|
||||
- `grep -c 'git.golem15.com/golem15/sm-user-plugin v0.1.5' ../sm-sociallogin-plugin/go.mod` prints 1.
|
||||
- On "release", for A in sm-demo-app and sm-bm-app:
|
||||
- `test "$(git -C ../A rev-parse HEAD:plugins/golem15/user)" = "$(git -C ../fonoteka.go/plugins/golem15/user rev-parse 'v0.1.5^{commit}')"` exits 0;
|
||||
- `test "$(git -C ../A rev-parse HEAD:plugins/golem15/sociallogin)" = "$(git -C ../sm-sociallogin-plugin rev-parse 'v0.1.2^{commit}')"` exits 0;
|
||||
- `git -C ../A log --format=%s -2` prints `chore: bump sm-sociallogin-plugin to v0.1.2` above `chore: bump sm-user-plugin to v0.1.5`;
|
||||
- `L=$(git -C ../A log --oneline origin/master..master) && test -z "$L"` exits 0.
|
||||
- On "release", for fonoteka.go:
|
||||
- `test "$(git -C ../fonoteka.go rev-parse HEAD:plugins/golem15/user)" = "$(git -C ../fonoteka.go/plugins/golem15/user rev-parse 'v0.1.5^{commit}')"` exits 0;
|
||||
- `git -C ../fonoteka.go show --name-only --format= HEAD` prints only `plugins/golem15/user`.
|
||||
- On "hold", `test -f .planning/todos/pending/email-proof-release.md` exits 0 and the SUMMARY's deviations name every unreleased commit.
|
||||
</acceptance_criteria>
|
||||
<done>The applications that mount sociallogin run the email-proof rule at tagged versions, fonoteka.go carries the new user plugin pin locally, or the user's hold is recorded with a tracked todo.</done>
|
||||
<reversibility rating="costly">Pushed tags and pointer bumps of shared plugins are consumed by other applications. A fix needs a new patch tag, never a moved tag.</reversibility>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| anonymous client to /_user/api/v1/register and /update | anyone can register any address in auto mode and change an account's email; neither proves the mailbox |
|
||||
| provider callback to account matching | a verified provider email meets an existing account; linking hands that account the provider's sign-in |
|
||||
| administrator to the Users screen | an administrator creates, activates and edits accounts; none of that proves the user's mailbox |
|
||||
| migration to production users tables | a shared core plugin's migration runs on every application that bumps it |
|
||||
| gate script to the phase verdict | a missing or skipped test must fail the gate |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-19.5-39 | Elevation of privilege | sociallogin `ownerVerifiedEmail` (password-registration pre-account takeover, 19.5-REVIEW CR-01) | high | mitigate | Auto-link requires `users.email_verified_at`, which only the mailed activation code and the completed reset code write. Proved by `TestOwnerVerifiedEmail/register_auto_then_google_pending` (callback, register action and completion all refuse) and `legacy_account_pending`. |
|
||||
| T-19.5-40 | Spoofing | sm-user-plugin `Update` and `FormBeforeUpdate` (proof carried over an email change) | high | mitigate | An email change (not case-only) sets the column to NULL in the same write. Proved by `TestEmailVerifiedAt/update_email_change_clears`, `TestAdminEmailVerifiedAt/admin_email_change_clears` and `TestOwnerVerifiedEmail/email_change_drops_proof`. |
|
||||
| T-19.5-41 | Tampering | /register and /update request bodies (mass assignment of the proof) | medium | mitigate | `email_verified_at` is not in `Fillable()`, and Update writes an explicit map. Proved by `TestEmailVerifiedAt/register_ignores_submitted_proof` and `update_ignores_submitted_proof`. |
|
||||
| T-19.5-42 | Elevation of privilege | admin create and Activate action, `/register` auto and admin modes, social sign-in (non-mailbox paths treated as proof) | medium | mitigate | None of these paths writes the column. Proved by `TestEmailVerifiedAt/register_auto_no_proof`, `register_admin_mode_no_proof`, `TestAdminEmailVerifiedAt/admin_create_no_proof`, `admin_activate_action_no_proof` and `TestOwnerVerifiedEmail/admin_activation_not_proof`. |
|
||||
| T-19.5-43 | Information disclosure | user API payloads and admin JSON (contract drift in a core plugin) | low | mitigate | `json:"-"`; `apiArray` and the admin YAML are unchanged. Proved by the payload-key assertions in `TestEmailVerifiedAt` and by `TestAdminEmailVerifiedAt/admin_json_hides_proof`; the Task 2 acceptance diff shows routes, config, lang and admin YAML unchanged since v0.1.4. |
|
||||
| T-19.5-44 | Tampering | migration `202610210001_add_users_email_verified_at` (data rewrite or a broken upgrade) | medium | mitigate | `ADD COLUMN IF NOT EXISTS`, no row writes, no-op rollback. The gate's `migration_writes_rows` and `refused_migration_changes` enforce it, and `TestEmailVerifiedAtMigration` (`no_backfill`, `existing_column_kept`, `rollback_keeps_column`) proves it. |
|
||||
| T-19.5-45 | Repudiation | scripts/check-phase19.5.sh (false green for the new tests) | medium | mitigate | The new names are required in the docker stage. `REQUIRE_FILES` refuses a vitest run without a passing view test file. `--self-test` covers the planted missing-view-file output and every migration-check case. |
|
||||
| T-19.5-46 | Tampering | release and pointer bumps (Task 7) | medium | mitigate | Runs only after the blocking-human Task 6 reply. The user pointer moves before sociallogin's, so each commit builds. Each app's gate stage runs after every bump. There is a fetch and `origin/master..master` check before every push, with a stop on foreign commits the reply did not allow. Never force-pushed or deployed. fonoteka.go and summercms.go are not pushed. |
|
||||
| T-19.5-SC | Tampering | module and npm installs | high | mitigate | No Go module and no npm package is added. The bm view test uses `vue`'s own `createRenderer`, and Task 4 refuses a package.json or lockfile change. The gate's `--sociallogin` dependency check still refuses gorilla/sessions and gopkg.in/yaml.v3. |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- Task 1: TestOwnerVerifiedEmail and TestPreAccountTakeover by name; --self-test, --userplugin, --sociallogin, --docker.
|
||||
- Task 2: TestEmailVerifiedAt, TestAdminEmailVerifiedAt; TestEmailVerifiedAtMigration and TestCreateOrganisationsMigration; --userplugin, --fonoteka, --docker.
|
||||
- Task 3: TestOwnerVerifiedEmail, TestPreAccountTakeover and TestPHPPendingRegistrationTakeover; --sociallogin (TestPHPMethodMap), --docker.
|
||||
- Task 4: the view test file and typecheck; no package.json or lockfile change; --self-test, --bm.
|
||||
- Task 5: `PHASE195_REQUIRE_DOCKER=1 bash scripts/check-phase19.5.sh --all`; the security review and validation map counts.
|
||||
- Task 6: the reply recorded; no tag before it. Task 7: --sociallogin, --demo, --bm, --fonoteka at the new pins; tags on the remotes; pins equal the tags.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- D-26: a verified email auto-links only onto an account that proved its address; every no-proof path, including `/register` in auto mode, goes pending, and every mailed proof links.
|
||||
- D-27: `users.email_verified_at` is additive (no payload, route or config change), has exactly two writers, follows the address and is not backfilled. The READMEs and the security review no longer claim that a self-set password proves the address.
|
||||
- D-28: the bm callback view's ordering, single redeem, error and pending behaviour are tested without a new npm dependency.
|
||||
- The gate fails closed on every new test, and `--all` passes with Docker required.
|
||||
- SC-3 and D-21: under the go-ahead, v0.1.5 and v0.1.2 are tagged and both applications that mount sociallogin, plus fonoteka.go, are green at the new pins; or the hold is recorded and tracked.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/19.5-social-login-in-sm-user-plugin/19.5-05-SUMMARY.md`. It starts as the Task 3 hand-off draft. Include:
|
||||
- the commits per repository;
|
||||
- the gate output per stage (PASS, or NOT RUN with the reason);
|
||||
- the test names added and their subtests;
|
||||
- any production defect found, with its fix commit;
|
||||
- the Release table and the Task 6 reply verbatim;
|
||||
- the tags, the pushed ranges, the pointer bumps and the final pins (or the hold todo and its g15office ID);
|
||||
- the g15office comment;
|
||||
- any deviation, including a fallback version number or a Task 4 checkpoint.
|
||||
</output>
|
||||
@@ -0,0 +1,263 @@
|
||||
---
|
||||
phase: 19.5-social-login-in-sm-user-plugin
|
||||
plan: 05
|
||||
subsystem: auth
|
||||
tags: [sociallogin, sm-user-plugin, email-proof, pre-account-takeover, postgres, gormigrate, gate, vitest, bm]
|
||||
status: complete
|
||||
requires:
|
||||
- phase: 19.5-04
|
||||
provides: "sm-user-plugin v0.1.4 social sign-in hooks, sm-sociallogin-plugin v0.1.1 matching (ownerVerifiedEmail), scripts/check-phase19.5.sh"
|
||||
provides:
|
||||
- "users.email_verified_at (migration 202610210001_add_users_email_verified_at), written only by VerifyActivationCode and ResetPassword, cleared on an email change"
|
||||
- "sociallogin ownerVerifiedEmail reads EmailVerifiedAt (D-26)"
|
||||
- "TestOwnerVerifiedEmail (8 subtests), TestEmailVerifiedAt, TestAdminEmailVerifiedAt, TestEmailVerifiedAtMigration"
|
||||
- "bm src/views/OAuthCallbackView.test.ts (D-28): the real view mounted with vue's createRenderer, no new npm dependency"
|
||||
- "gate: USER_ADDED_MIGRATIONS, refused_migration_changes, migration_writes_rows, DOCKER_USER_UPDATES_TESTS, detect_vitest REQUIRE_FILES"
|
||||
- "19.5-SECURITY-REVIEW.md T-19.5-39..46 and CR-02 open; 19.5-VALIDATION.md rows 19.5-05-01..07, D-26..D-28"
|
||||
affects: [".planning/todos/pending/email-proof-release.md (release on hold, hand-off to the 19.8 session)", "sm-demo-app and sm-bm-app pins (unchanged until the release)"]
|
||||
actuals:
|
||||
tokens: 33300 # chars/4: Tasks 1-3 diffs 18621 + Tasks 4-5 diffs 12208 + Task 7 (hold) todo about 2470
|
||||
tasks: 7 # Tasks 1-5 executed, Task 6 answered "hold", Task 7 recorded the hold
|
||||
commits: 9 # MEASURED: git rev-list --count 3346060..20ecc41; 7 are this plan's (7170c5b, 30e0cd9, c2c8c6a, 7e43873, 8c7794e, b57d2e1, 20ecc41), 2 are foreign 19.8-03 commits (2057643, 4895b25); the final SUMMARY commit comes on top
|
||||
plan_head_before: 33460607db95276e8180487a8ece7e4f0070b1e7
|
||||
plan_head_after: 20ecc41
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns:
|
||||
- "Proof of address as a nullable timestamp column, stamped in the same write as the mailed-code action that proves it"
|
||||
- "Gate migration allow-list: git diff --name-status filtered through refused_migration_changes, plus a comment-blind SQL write scan"
|
||||
- "SFC view test without a DOM package: vue createRenderer over an in-memory host, the template compiled for the client with the script's binding metadata, createMemoryHistory router"
|
||||
key-files:
|
||||
created:
|
||||
- ../fonoteka.go/plugins/golem15/user/updates/202610210001_add_users_email_verified_at.go
|
||||
- ../fonoteka.go/plugins/golem15/user/updates/email_verified_at_test.go
|
||||
- ../fonoteka.go/plugins/golem15/user/email_verified_test.go
|
||||
- ../sm-sociallogin-plugin/owner_verified_test.go
|
||||
- ../sm-bm-app/frontend/src/views/OAuthCallbackView.test.ts
|
||||
modified:
|
||||
- ../fonoteka.go/plugins/golem15/user/models/user.go
|
||||
- ../fonoteka.go/plugins/golem15/user/classes/codes.go
|
||||
- ../fonoteka.go/plugins/golem15/user/controllers/api_controller.go
|
||||
- ../fonoteka.go/plugins/golem15/user/controllers/users_admin_controller.go
|
||||
- ../fonoteka.go/plugins/golem15/user/updates/organisations_test.go
|
||||
- ../fonoteka.go/plugins/golem15/user/README.md
|
||||
- ../sm-sociallogin-plugin/classes/flow.go
|
||||
- ../sm-sociallogin-plugin/harness_test.go
|
||||
- ../sm-sociallogin-plugin/matching_test.go
|
||||
- ../sm-sociallogin-plugin/testdata/PHP_METHOD_MAP.md
|
||||
- ../sm-sociallogin-plugin/README.md
|
||||
- scripts/check-phase19.5.sh
|
||||
- .planning/phases/19.5-social-login-in-sm-user-plugin/19.5-SECURITY-REVIEW.md
|
||||
- .planning/phases/19.5-social-login-in-sm-user-plugin/19.5-VALIDATION.md
|
||||
- .planning/phases/19.5-social-login-in-sm-user-plugin/deferred-items.md
|
||||
key-decisions:
|
||||
- "Proof writers are exactly VerifyActivationCode (activate, activate-by-code, invitation link) and ResetPassword's password write; RegisterUser, admin create, ActivateUsers, ChangePassword and every sociallogin path write nothing"
|
||||
- "An email change through /update or the admin form clears email_verified_at; a case-only change (strings.EqualFold) keeps it"
|
||||
- "Harness e.user is a proven account so plan 02-04 link cases keep testing the link path; e.unprovenUser models accounts without proof"
|
||||
- "The bm view test compiles the view's own template for the client at test time (vitest's node environment gives the SFC only ssrRender) and provides an empty SSR context; vite.config.ts and the dependencies stay unchanged"
|
||||
- "Task 6 answered hold: nothing pushed or tagged, no pointer moved; the release is handed to the 19.8 session to decide with the framework v0.4.0 release (todo email-proof-release)"
|
||||
requirements-completed: [D-26, D-27, D-28] # in local commits; SC-3 (tags and app pins, D-21 release) is open under the hold, tracked by email-proof-release.md
|
||||
duration: 75min
|
||||
completed: 2026-10-21
|
||||
---
|
||||
|
||||
# Phase 19.5 Plan 05: Proof of Address for Social Sign-in Linking Summary
|
||||
|
||||
**users.email_verified_at (stamped only by the mailed activation code and the completed reset-code password reset, cleared on an email change) is now the owner-side condition for auto-linking a verified provider, so an auto-mode `/register` account goes pending instead of handing its sign-in to a squatter; the bm OAuth callback view's code handling is pinned by a vitest view test without a new dependency**
|
||||
|
||||
All 7 tasks are done. Task 6 was answered **hold**, so Task 7 recorded the hold: the fix lives in local commits in four repositories, nothing is pushed or tagged, no application pin moved, and `.planning/todos/pending/email-proof-release.md` hands the release to the 19.8 session (decided together with the framework v0.4.0 release).
|
||||
|
||||
## Performance
|
||||
|
||||
- **Duration:** 29 min (Tasks 1-3) + 39 min (Tasks 4-5, about 25 of them spent on two gate runs that timed out on blocked loopback; see Issues)
|
||||
- **Started:** 2026-10-21T11:37:14Z
|
||||
- **Second context:** 2026-10-21T12:08Z to 12:47Z
|
||||
- **Third context (Task 7, hold):** about 7 min
|
||||
- **Tasks:** 7 of 7 (Task 7 on the hold branch)
|
||||
- **Files modified:** 20 across four repositories (plus deferred-items.md and the release todo)
|
||||
|
||||
## Accomplishments
|
||||
|
||||
- Migration `202610210001_add_users_email_verified_at`: `ALTER TABLE users ADD COLUMN IF NOT EXISTS email_verified_at TIMESTAMPTZ NULL`, no row written, no-op rollback; `User.EmailVerifiedAt` with `json:"-"`, not fillable.
|
||||
- `classes.VerifyActivationCode` stamps the proof in the same `UpdateColumns` write that activates; `controllers.ResetPassword` stamps it in the `Updates` that stores the new password.
|
||||
- `controllers.Update` and `usersAdminController.FormBeforeUpdate` clear it when the email changes (case-only change keeps it). `storedEmail` is an unexported helper now shared with `guardCredentials`.
|
||||
- sociallogin `ownerVerifiedEmail(u)` is `u != nil && u.EmailVerifiedAt != nil`; `classes/flow.go` no longer reads `HasSelfSetPassword`.
|
||||
- bm `src/views/OAuthCallbackView.test.ts`: the real view mounted with `createRenderer` from `vue` on an in-memory host and a `createMemoryHistory` router; 5 cases (route replaced before the single redeem, no second redeem, `?error=` in the alert, pending registration replaced to `oauth-register`, expired code explained). `frontend/package.json` and `package-lock.json` unchanged (last commit touching them `fe967c4` before and after).
|
||||
- Gate: the userplugin stage accepts exactly the one added migration and refuses any other change under `updates/` and a migration that writes or drops data; the new Go tests are required by name; `detect_vitest` takes `REQUIRE_FILES` and the bm stage requires both `src/lib/oauth.test.ts` and `src/views/OAuthCallbackView.test.ts`.
|
||||
- 19.5-SECURITY-REVIEW.md: T-19.5-38 describes the proof as `email_verified_at`, T-19.5-39..T-19.5-46 added, T-19.5-26 closed by the view test, CR-02 under "Open findings" (open, no user decision). 19.5-VALIDATION.md: rows 19.5-05-01..07, D-26, D-27, D-28, the view check moved from manual to automated.
|
||||
|
||||
## Commits per repository
|
||||
|
||||
**sm-user-plugin** (`../fonoteka.go/plugins/golem15/user`, base c7635c5 = v0.1.4+1, nothing pushed):
|
||||
|
||||
| Task | Commit | Message |
|
||||
|------|--------|---------|
|
||||
| 1 | `add1f7a` | feat: users.email_verified_at records a proved address (mail activation, reset code) |
|
||||
| 2 | `a99cb46` | fix: an email change drops the address proof |
|
||||
| 2 | `35cf166` | test: email_verified_at writers, non-writers and the migration |
|
||||
|
||||
**sm-sociallogin-plugin** (`../sm-sociallogin-plugin`, base d7ee28d = v0.1.1, nothing pushed; go.mod still requires sm-user-plugin v0.1.4 until Task 7, the committed go.work points at the workspace checkout):
|
||||
|
||||
| Task | Commit | Message |
|
||||
|------|--------|---------|
|
||||
| 1 | `3bb5650` | fix: a verified sign-in links only onto an account that proved its address |
|
||||
| 3 | `962190b` | test: owner proof table for verified sign-ins |
|
||||
| 3 | `c4ebf05` | docs: proof of address in the matching table and the PHP map |
|
||||
|
||||
**sm-bm-app** (base 99af8c2, nothing pushed):
|
||||
|
||||
| Task | Commit | Message |
|
||||
|------|--------|---------|
|
||||
| 4 | `5fd0cc3` | test(frontend): OAuth callback view drops the code before one redeem |
|
||||
|
||||
**summercms.go** (base 3346060, never pushed by this plan):
|
||||
|
||||
| Task | Commit | Message |
|
||||
|------|--------|---------|
|
||||
| 1 | `7170c5b` | test(19.5): gate the email_verified_at migration and TestOwnerVerifiedEmail |
|
||||
| 2 | `30e0cd9` | test(19.5): gate the user-plugin proof tests |
|
||||
| hand-off | `c2c8c6a` | docs(19.5-05): hand-off draft after Task 3 (email proof in user and sociallogin) |
|
||||
| 4 | `7e43873` | test(19.5): gate the bm callback view test |
|
||||
| 5 | `8c7794e` | docs(19.5): proof of address in the security review and validation map |
|
||||
| 6 | `b57d2e1` | docs(19.5-05): draft at the Task 6 checkpoint (bm view test, review, release table) |
|
||||
| 7 | `20ecc41` | docs(19.5): capture the email-proof release todo |
|
||||
|
||||
Two foreign commits of another session (`2057643`, `4895b25`, both `fix(19.8-03)`) landed between them on summercms.go master; they are not this plan's.
|
||||
|
||||
**sm-demo-app, fonoteka.go:** no commit (release on hold). fonoteka.go's worktree shows `plugins/golem15/user` modified (the checkout is at 35cf166); it stays unstaged until the release.
|
||||
|
||||
## Tests added (all pass)
|
||||
|
||||
- **sm-sociallogin-plugin** `TestOwnerVerifiedEmail` (owner_verified_test.go): `register_auto_then_google_pending`, `mail_activated_by_code_links`, `reset_code_password_links` (Task 1); `legacy_account_pending`, `register_action_pending`, `admin_activation_not_proof`, `email_change_drops_proof`, `unverified_provider_never_links_proven_account` (Task 3). Helpers on `*socialEnv`: `ownerEnv`, `postUserAPI`, `registerWithPassword`, `userRow`, `activateByMail`, `resetByMail`, `googleSignIn`, `assertPendingNoLink`, `assertLinkedAndSignedIn`. Harness: `e.user` (proven), `e.unprovenUser`.
|
||||
- **sm-sociallogin-plugin** `TestPreAccountTakeover`: setup also requires `EmailVerifiedAt == nil`; `after_mailbox_proof_links` now drives `/forgot-password` and `/reset-password` through `resetByMail`.
|
||||
- **sm-user-plugin** `TestEmailVerifiedAt` (email_verified_test.go): `register_auto_no_proof`, `register_admin_mode_no_proof`, `register_ignores_submitted_proof`, `activate_by_code_stamps`, `activate_stamps`, `activate_wrong_code_no_proof`, `reset_password_stamps`, `reset_wrong_code_no_proof`, `change_password_no_proof`, `update_email_change_clears`, `update_same_email_keeps`, `update_ignores_submitted_proof`.
|
||||
- **sm-user-plugin** `TestAdminEmailVerifiedAt`: `admin_create_no_proof`, `admin_activate_action_no_proof`, `admin_invite_link_stamps`, `admin_email_change_clears`, `admin_save_keeps_proof`, `admin_json_hides_proof`.
|
||||
- **sm-user-plugin** `TestEmailVerifiedAtMigration` (updates/email_verified_at_test.go): `column_shape`, `no_backfill`, `existing_column_kept`, `rollback_keeps_column`, `history_once`. `TestCreateOrganisationsMigration` expects the new ID last and rolls back 12 steps.
|
||||
- **sm-bm-app** vitest `src/views/OAuthCallbackView.test.ts`, describe `OAuthCallbackView`: `replaces the route before the single redeem`, `does not redeem a code twice`, `shows the error message`, `routes a pending registration`, `explains an expired code`.
|
||||
- **summercms.go** gate self-test: vitest cases on both required files, `missing-view-file` (exit 5), `single-file-fallback` (REQUIRE_FILE, exit 5).
|
||||
|
||||
RED evidence:
|
||||
- With the old `ownerVerifiedEmail` (self-set password), `TestOwnerVerifiedEmail/register_auto_then_google_pending` failed (the redirect carried a `code`, i.e. the auto-mode account was linked).
|
||||
- With the Task 2 controllers reverted to HEAD, exactly `TestEmailVerifiedAt/update_email_change_clears` and `TestAdminEmailVerifiedAt/admin_email_change_clears` failed.
|
||||
- View mutations, each reverted: removing the `router.replace({ path })` before the redeem fails 3 cases (`replaces the route…`, `does not redeem…`, `shows the error…`); dropping the `redeemed` check fails `does not redeem a code twice`; `push` instead of `replace` for the pending route fails `routes a pending registration`.
|
||||
|
||||
## Gate stages (scripts/check-phase19.5.sh)
|
||||
|
||||
Final run, 14:42-14:46 local: `PHASE195_REQUIRE_DOCKER=1 TESTCONTAINERS_HOST_OVERRIDE=127.0.0.1 bash scripts/check-phase19.5.sh --all`, exit 0.
|
||||
|
||||
| Stage | State | Notes |
|
||||
|-------|-------|-------|
|
||||
| --self-test | PASS | detectors, migration-check cases, vitest `REQUIRE_FILES` cases incl. `missing-view-file`, docker NOT RUN rule |
|
||||
| --go | PASS | summercms.go vet and short suite; cooler 4 named tests |
|
||||
| --userplugin | PASS | 2 named tests; "13 declarations added; 1 allowed migration(s) added, column only" |
|
||||
| --sociallogin | PASS | 9 named Docker-free tests incl. TestPHPMethodMap; deps and goth importer checks |
|
||||
| --demo | PASS | build, vet, 3 named tests |
|
||||
| --bm | PASS | build, vet, 1 + 2 named tests; vitest 216 passed (216) with both required files; typecheck |
|
||||
| --fonoteka | PASS | vet and short suite of the workspace at the changed user plugin |
|
||||
| --docs | PASS | TestDocsTree, docs:build --check, decision note, no consumer names |
|
||||
| --docker | PASS | cooler 1, user root 12, user updates 2, sociallogin 29 top-level tests |
|
||||
|
||||
## Decisions Made
|
||||
|
||||
Followed the plan's decisions. Within them:
|
||||
- `storedEmail` was extracted from `guardCredentials` so `FormBeforeUpdate` compares the stored and submitted email without a second copy of the query; `guardCredentials`' behaviour is unchanged.
|
||||
- `TestOwnerVerifiedEmail` boots its own environment with `gothFakeRegistry` (helper `ownerEnv`) so the Google and Facebook wrappers run on fake APIs, as `TestPHPSocialAuthGate` does.
|
||||
- `mountAt(url, router)` builds a fresh router and app per mount (vue-router resets its state when its last app unmounts), so the second-mount case pushes the same callback URL again on a new router while the view module's `redeemed` set carries over.
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
**1. [Rule 3 - Blocking] The bm view test compiles the view's template at test time**
|
||||
- **Found during:** Task 4
|
||||
- **Issue:** Under vitest's `environment: 'node'`, @vitejs/plugin-vue compiles SFCs in SSR mode: the imported component has `setup` and `ssrRender` but no `render`, so `createRenderer` alone mounts nothing. The SSR module's setup also calls `useSSRContext()` and fails without one. A client-compiled template without binding metadata cannot read `<script setup>` bindings (they are not exposed on the render context).
|
||||
- **Fix:** In the test file only: `parse` and `compileScript` from `vue/compiler-sfc` (part of the `vue` package) read the view's own source (`?raw`), `compile` from `vue` builds the client render function with `bindingMetadata` and `prefixIdentifiers`, and the app provides an empty SSR context (`ssrContextKey`). The `<script setup>` that runs is the real module, `redeemed` set included. No dependency added, `vite.config.ts` unchanged, so the plan's "stop at a checkpoint" branch did not apply.
|
||||
- **Files modified:** `../sm-bm-app/frontend/src/views/OAuthCallbackView.test.ts`
|
||||
- **Commit:** `5fd0cc3`
|
||||
|
||||
**2. [Task 6 hold] The fix is not released; the applications keep the takeover path**
|
||||
- **Found during:** Task 6 / Task 7
|
||||
- **Issue:** The user answered the Task 6 checkpoint with "hold". SC-3 and D-21 (tags v0.1.5 and v0.1.2, app pins at them) are not met by this plan.
|
||||
- **Unreleased commits:** sm-user-plugin `add1f7a`, `a99cb46`, `35cf166`; sm-sociallogin-plugin `3bb5650`, `962190b`, `c4ebf05` (plus the `chore: require sm-user-plugin v0.1.5` commit still to be made); sm-bm-app `5fd0cc3`.
|
||||
- **Pins that lack them:** sm-demo-app and sm-bm-app `plugins/golem15/user` c43ba87 (v0.1.4) and `plugins/golem15/sociallogin` d7ee28d (v0.1.1), with the matching go.mod requires; fonoteka.go gitlink c43ba87 (worktree at 35cf166, unstaged). Until they move, an auto-mode `/register` account squatting an address is still linked on the owner's verified Google sign-in in both apps that mount sociallogin.
|
||||
- **Tracked by:** `.planning/todos/pending/email-proof-release.md` (commit `20ecc41`), addressed to the 19.8 session to review and decide with the framework v0.4.0 release. Task 7's verify command (the `--sociallogin`, `--demo`, `--bm`, `--fonoteka` stages at the new pins) was not run, because no pin moved.
|
||||
|
||||
Environment-only adjustments (no repository change):
|
||||
- Docker-backed runs used `TESTCONTAINERS_HOST_OVERRIDE=127.0.0.1` and ran outside the sandbox.
|
||||
- `change_password_no_proof` sets `has_self_set_password` before calling change-password, as `TestChangePassword` does (`insertUser` creates the row with the flag false, which change-password answers with an opaque 500).
|
||||
|
||||
## Issues Encountered
|
||||
|
||||
- **Docker loopback blocked by the NordLayer VPN.** The VPN's nftables table drops loopback traffic to published container ports, so pgx timed out against the testcontainers Postgres (`127.0.0.1:<port>: failed to receive message: timeout`). The first two `--all` runs of this context failed at `--userplugin`: `sm-user-plugin/classes` hit `go test`'s 10-minute timeout (`TestPermissionSetScan` starts Postgres even under `-short`) and `updates` failed after 2 minutes. The user restored loopback with `sudo nft insert rule inet nordlayer input iifname "lo" accept` and `... output oifname "lo" accept`. These rules disappear when the VPN reconnects, and Task 7's gate runs need them again. The third run passed in 3.5 minutes.
|
||||
- **Pre-existing:** sm-user-plugin `updates/postgres_test.go` `testShort` scans `os.Args`, and `classes.TestPermissionSetScan` has no `-short` skip, so `go test -short` still starts Postgres for those packages. Out of scope, both logged in deferred-items.md, as is the VPN loopback rule.
|
||||
|
||||
## Release table
|
||||
|
||||
Fetched `origin` in every repository during Task 5 (about 12:25Z). `git log --oneline origin/master..master`:
|
||||
|
||||
| Repository | Unpushed commits | This plan's? |
|
||||
|------------|------------------|--------------|
|
||||
| sm-user-plugin | `35cf166`, `a99cb46`, `add1f7a` | all this plan's |
|
||||
| sm-sociallogin-plugin | `c4ebf05`, `962190b`, `3bb5650` | all this plan's |
|
||||
| sm-bm-app | `5fd0cc3` | this plan's (Task 4) |
|
||||
| sm-demo-app | `b325460` feat(demo): roadmap deck describes the new console | **foreign** (another session, 13:27 today; touches only `plugins/demo/content/ROADMAP.md` and `plugins/demo/roadmap/`) |
|
||||
| fonoteka.go | 31 commits (`f4d5728` … `ca746fc`: 14.1, 14.2, 12.1, parity and earlier bump commits) | all foreign to this plan; fonoteka.go is never pushed by this plan |
|
||||
| summercms.go | 49 commits before this draft's commit; this plan's: `7170c5b`, `30e0cd9`, `c2c8c6a`, `7e43873`, `8c7794e` | the rest foreign (19.8 and earlier 19.5 planning); summercms.go is never pushed by this plan |
|
||||
|
||||
Proposed tags (each `git ls-remote --tags origin <tag>` printed nothing; no tag points at either plugin's master):
|
||||
|
||||
| Repository | Tag | Points at | Free? |
|
||||
|------------|-----|-----------|-------|
|
||||
| sm-user-plugin | `v0.1.5` | `35cf166` | yes (also `v0.1.6` free) |
|
||||
| sm-sociallogin-plugin | `v0.1.2` | the Task 7 commit `chore: require sm-user-plugin v0.1.5` on top of `c4ebf05` | yes (also `v0.1.3` free) |
|
||||
|
||||
Current pins: sm-demo-app and sm-bm-app `plugins/golem15/user` c43ba87 (v0.1.4), `plugins/golem15/sociallogin` d7ee28d (v0.1.1); sm-demo-app go.mod requires sm-user-plugin v0.1.4 and sm-sociallogin-plugin v0.1.1; sm-bm-app go.mod requires sm-user-plugin v0.1.4 and sm-sociallogin-plugin v0.0.0 (replace to the submodule; only the user line names a version); fonoteka.go gitlink c43ba87.
|
||||
|
||||
Pointer-bump commits a release makes (not made under the hold; the todo carries them):
|
||||
1. sm-sociallogin-plugin: `chore: require sm-user-plugin v0.1.5` (go.mod, go.sum), then tag v0.1.2.
|
||||
2. sm-demo-app: `chore: bump sm-user-plugin to v0.1.5` (gitlink, go.mod, go.sum), then `chore: bump sm-sociallogin-plugin to v0.1.2` (gitlink, go.mod, go.sum); `--demo` and `scripts/check-pins.sh` green after each; push. **The push would include the foreign `b325460` unless the reply excludes it.**
|
||||
3. sm-bm-app: the same two commits (only the versioned require line changes); `--bm` green after each; push including `5fd0cc3`.
|
||||
4. fonoteka.go: `chore: bump sm-user-plugin to v0.1.5` (only `plugins/golem15/user`), local only.
|
||||
|
||||
## Task 6 reply
|
||||
|
||||
> hold
|
||||
|
||||
Added by the user with the reply: the 19.8 session (console commands overhaul) is on hold awaiting this gap closure, and the 19.8 agent will review and decide on the release together with its framework v0.4.0 release.
|
||||
|
||||
## Task 7 (hold)
|
||||
|
||||
- Pushed: nothing. Tagged: nothing. Pointers moved: none. fonoteka.go `plugins/golem15/user` stays unstaged at 35cf166.
|
||||
- Todo: `.planning/todos/pending/email-proof-release.md` (commit `20ecc41`, `docs(19.5): capture the email-proof release todo`). It holds the release table, the D-21 steps (sm-user-plugin v0.1.5 at `35cf166`; sm-sociallogin-plugin `chore: require sm-user-plugin v0.1.5` then v0.1.2; sm-demo-app and sm-bm-app user bump then sociallogin bump, gate green after each, then push; fonoteka.go user bump local only), the note that plugin tags live in the plugins' own repositories apart from the framework v0.4.0 tag, the foreign unpushed `b325460` in sm-demo-app, the NordLayer loopback rules for Docker gate runs, and the open takeover path in the apps until the release. It does not set `resolves_phase`, so completing Phase 19.5 does not close it.
|
||||
- g15office task: **not created.** No g15office tool was available in the executing session (the g15office MCP server is not exposed to subagents). The todo's frontmatter says so; the orchestrator or the next session with the tool creates the task (type Bug or Task, description ending `Local: .planning/todos/pending/email-proof-release.md`), writes its ID into the todo, and makes it a subtask of the Phase 19.5 milestone task `onunbz3l`.
|
||||
- Task 7's verify command was not run (hold: no pin moved).
|
||||
|
||||
## Release (2026-10-21, after the hold)
|
||||
|
||||
The 19.8 session released the fix under the user's go-ahead for the v0.4.0 release (19.8-03 Task 6 reply "release v0.4.0", which delegated this decision), following `email-proof-release.md` steps 0-5. Docker gate: the probe `TestPermissionSetScan` passed in about 6 s with the NordLayer loopback rules in place.
|
||||
|
||||
| Repository | Pushed range | Tag | Checks |
|
||||
|------------|--------------|-----|--------|
|
||||
| sm-user-plugin | `c7635c5..35cf166` (`add1f7a`, `a99cb46`, `35cf166`) | `v0.1.5` (annotated) at `35cf166` | vet and short suite green through the fonoteka.go workspace |
|
||||
| sm-sociallogin-plugin | `d7ee28d..9fd58db` (`3bb5650`, `962190b`, `c4ebf05`, `79cebb9`, `9fd58db` chore: require sm-user-plugin v0.1.5) | `v0.1.2` (annotated) at `9fd58db` | vet, short suite, `GOWORK=off go build ./...` green |
|
||||
| sm-demo-app | `61a00e9..a5703f9` (`b325460` approved foreign deck commit, `f16ef6c` user bump, `1afd7ff` sociallogin bump, `a5703f9` snapshot sync from summercms `9ab28e1`) | none | `--demo` and `scripts/check-pins.sh` green after each bump; `sync-roadmap.sh --check` ok |
|
||||
| sm-bm-app | `99af8c2..cc4b23f` (`5fd0cc3`, `1e09c23` user bump, `cc4b23f` sociallogin bump) | none | `--bm` green after each bump (vitest 216 passed, typecheck) |
|
||||
| fonoteka.go | not pushed; local `2be9e1f` chore: bump sm-user-plugin to v0.1.5 | none | `--fonoteka` green; `describe --exact-match` names v0.1.5 |
|
||||
|
||||
Final pins: sm-demo-app `plugins/golem15/user` `35cf166` (v0.1.5) and `plugins/golem15/sociallogin` `9fd58db` (v0.1.2), go.mod requires sm-user-plugin v0.1.5 and sm-sociallogin-plugin v0.1.2; sm-bm-app the same gitlinks, go.mod requires sm-user-plugin v0.1.5 (sociallogin stays `v0.0.0` with its directory replace, so only its gitlink moved; go.sum unchanged); fonoteka.go gitlink `35cf166`.
|
||||
|
||||
Notes:
|
||||
- sm-sociallogin-plugin's `GOWORK=off go mod tidy` also added the framework's new indirect dependencies (charm.land v2 modules and their transitive set), because its go.mod replaces the framework with `../summercms.go`, whose master carries the 19.8 console work.
|
||||
- sm-sociallogin-plugin cannot be fetched over anonymous https; sm-demo-app's `go get` of v0.1.2 ran with a process-scoped `url.git@git.golem15.com:.insteadOf https://git.golem15.com/` (environment only, no config change). Machines that resolve the module with `GOWORK=off` need the same SSH access.
|
||||
- sm-bm-app: `go mod tidy` would also add the framework's 19.8 indirect dependencies through its `../summercms.go` replace; it was not run, so the commit changes only the require line.
|
||||
- Still open: g15office (comment on `onunbz3l`, set it to Testing, move this todo's task `sqnwn1zi` to Live) needs a session with the g15office tool; the user re-runs `/msd-verify-work 19.5`.
|
||||
|
||||
## User Setup Required
|
||||
|
||||
None.
|
||||
|
||||
---
|
||||
*Phase: 19.5-social-login-in-sm-user-plugin*
|
||||
*Completed: 2026-10-21 (Task 7 on the hold branch)*
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
All 14 commits listed exist (U 3, S 3, B 1, F 7); the five created files and the todo exist; no draft status line remains; the Release table heading is present once; no tag points at either plugin master and v0.1.2 is absent on the remote.
|
||||
@@ -82,6 +82,12 @@ These answer RESEARCH.md's Open Questions 1, 4 and 6.
|
||||
- **D-24:** **fonoteka gets the relaxed D-13 unlink rule.** There is no opt-out flag. If a fonoteka parity or test case covers the old 409, it is updated.
|
||||
- **D-25:** **The phase has 4 plans.** (1) Foundations: `cooler.Pull`, the goth decision note, the user-plugin changes D-13 (unlink with a `SELECT … FOR UPDATE` lock on the user row) and D-15, and the remaining folded-todo fix. (2) sm-sociallogin-plugin. (3) Adoption in sm-demo-app, sm-bm-app and fonoteka. (4) Unit tests and the PHP test map.
|
||||
|
||||
### Decisions after verification (2026-10-21, gap closure)
|
||||
|
||||
- **D-26:** **Close the password-registration pre-account takeover (VERIFICATION human item 1).** An email match gets a verified provider's identity automatically only when the account has proved its address: activated by mail (`Activate` / `ActivateByCode`) or set its password through the mailed reset code. A self-set password alone (`/register` with `activate_mode=auto`, admin-created) is no longer proof; such a match goes to the pending screen with reason `unverified_match`, and the owner links from the settings. Diverges from PHP D-10 on purpose. Existing accounts with no recorded proof take the pending path.
|
||||
- **D-27:** **Proof is an additive column in sm-user-plugin: `users.email_verified_at` (nullable timestamp), set by mail activation and by a completed reset-code password reset.** This relaxes D-22's "no sm-user-plugin migration" for this follow-up only; the change is additive (no API payload, route or config key changes) and ships as sm-user-plugin v0.1.5, then sm-sociallogin-plugin v0.1.2, then pointer bumps in sm-demo-app, sm-bm-app and fonoteka.go (D-21 order, push and tags on the user's go-ahead). README and 19.5-SECURITY-REVIEW.md wording that a self-set password "proved the address" is corrected.
|
||||
- **D-28:** **bm's `OAuthCallbackView` gets a vitest view test** (VERIFICATION truth 16): `router.replace` drops `?code=` before the single `oauth-complete` call, a second mount does not redeem again, `?error=` shows the message and `?pending_registration=` routes to `oauth-register`. No new npm dependency.
|
||||
|
||||
### Claude's Discretion
|
||||
- RESEARCH.md's recommendations settle the other open questions:
|
||||
- Login touches last-seen and the IP address, with no new event.
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
---
|
||||
phase: 19.5-social-login-in-sm-user-plugin
|
||||
reviewed: 2026-10-21T12:00:00Z
|
||||
depth: standard
|
||||
files_reviewed: 49
|
||||
files_reviewed_list:
|
||||
- summercms.go/modules/cooler/cooler.go
|
||||
- summercms.go/modules/cooler/memory.go
|
||||
- summercms.go/modules/cooler/rediscache.go
|
||||
- summercms.go/modules/cooler/pull_smoke_test.go
|
||||
- summercms.go/modules/cooler/pull_test.go
|
||||
- summercms.go/modules/cooler/docker_test.go
|
||||
- summercms.go/scripts/check-phase19.5.sh
|
||||
- fonoteka.go/plugins/golem15/user/classes/oauth_providers.go
|
||||
- fonoteka.go/plugins/golem15/user/console/backfill_handles.go
|
||||
- fonoteka.go/plugins/golem15/user/controllers/api_controller.go
|
||||
- fonoteka.go/plugins/golem15/user/controllers/oauth_identities.go
|
||||
- fonoteka.go/plugins/golem15/user/controllers/registration.go
|
||||
- fonoteka.go/plugins/golem15/user/README.md
|
||||
- sm-sociallogin-plugin/plugin.go
|
||||
- sm-sociallogin-plugin/service.go
|
||||
- sm-sociallogin-plugin/routes.go
|
||||
- sm-sociallogin-plugin/config.go
|
||||
- sm-sociallogin-plugin/admin_settings.go
|
||||
- sm-sociallogin-plugin/admin_permissions.go
|
||||
- sm-sociallogin-plugin/classes/flow.go
|
||||
- sm-sociallogin-plugin/classes/registration.go
|
||||
- sm-sociallogin-plugin/classes/callback.go
|
||||
- sm-sociallogin-plugin/classes/statecookie.go
|
||||
- sm-sociallogin-plugin/classes/codes.go
|
||||
- sm-sociallogin-plugin/classes/providers_goth.go
|
||||
- sm-sociallogin-plugin/classes/provider.go
|
||||
- sm-sociallogin-plugin/classes/credentials.go
|
||||
- sm-sociallogin-plugin/classes/config.go
|
||||
- sm-sociallogin-plugin/classes/messages.go
|
||||
- sm-sociallogin-plugin/controllers/callback.go
|
||||
- sm-sociallogin-plugin/controllers/redirect.go
|
||||
- sm-sociallogin-plugin/controllers/helpers.go
|
||||
- sm-sociallogin-plugin/controllers/complete.go
|
||||
- sm-sociallogin-plugin/controllers/link_ticket.go
|
||||
- sm-sociallogin-plugin/controllers/register_complete.go
|
||||
- sm-sociallogin-plugin/models/settings.go
|
||||
- sm-sociallogin-plugin/updates/202610200001_create_golem15_sociallogin_settings.go
|
||||
- sm-bm-app/frontend/src/lib/oauth.ts
|
||||
- sm-bm-app/frontend/src/views/OAuthCallbackView.vue
|
||||
- sm-bm-app/frontend/src/views/OAuthRegisterView.vue
|
||||
- sm-bm-app/frontend/src/views/LoginView.vue
|
||||
- sm-bm-app/frontend/src/views/ProfileView.vue
|
||||
- sm-bm-app/frontend/src/stores/auth.ts
|
||||
- sm-bm-app/frontend/src/pwa/sw.js
|
||||
- sm-bm-app/sociallogin_test.go
|
||||
- sm-bm-app/config/golem15.sociallogin.yaml
|
||||
- sm-demo-app/sociallogin_test.go
|
||||
- sm-demo-app/deploy/env.example
|
||||
- sm-demo-app/go.work
|
||||
findings:
|
||||
critical: 2
|
||||
warning: 5
|
||||
info: 9
|
||||
total: 16
|
||||
status: issues_found
|
||||
---
|
||||
|
||||
# Phase 19.5: Code Review Report
|
||||
|
||||
**Reviewed:** 2026-10-21
|
||||
**Depth:** standard
|
||||
**Files Reviewed:** 49 (listed in the frontmatter; paths prefixed with the repo dir)
|
||||
**Status:** issues_found
|
||||
|
||||
## Summary
|
||||
|
||||
Scope: `cooler.Cache.Pull` (5e0a04e, 671d388), the gate script, the sm-user-plugin diff 1fc0ce7..c7635c5, the whole of sm-sociallogin-plugin, the bm frontend and wiring (e76f8bc, 117ef8f) and the demo wiring (9eb8492, d5a98d9, 61a00e9).
|
||||
|
||||
The low-level pieces are solid. AES-GCM sealing with HKDF purpose keys, the cookie name as AAD, constant-time state compare, `GETDEL` and otter `Compute` for Pull, the `FOR UPDATE` unlink and the callback-target rebuild all match what the security review says. I found no defect in them.
|
||||
|
||||
The account-linking model is still not safe, and the review lists it as closed:
|
||||
|
||||
- **CR-01 (T-19.5-38 mitigation broken under default config).** `ownerVerifiedEmail` treats `has_self_set_password` as proof that the owner controls the address. With golem15.user's default `activate_mode: auto`, `POST /register` creates an activated account with a self-set password for any email, and nobody proves the address. An attacker who registers the victim's email first gets the victim's later verified Google or GitHub sign-in linked onto the attacker's account. This is the same pre-account takeover, now via password registration.
|
||||
- **CR-02 (the "reset proves the address" path leaves the attacker's identity linked).** The fix's recovery path, a password reset, does not remove a pre-planted Microsoft or Facebook identity. bm runs Microsoft with tenant `common`, so the attacker keeps permanent access to an account the victim "recovered".
|
||||
|
||||
The warnings cover:
|
||||
|
||||
- the one-time handoff code is not bound to the browser that started the flow (login CSRF; in `token_cookie` mode a plain GET is enough);
|
||||
- registration consent, including marketing, comes from a GET that any site can trigger;
|
||||
- the reusable pending code and the email sit in URLs and browser history;
|
||||
- `localhost` and `127.0.0.1` are always allowed as callback hosts in production;
|
||||
- the gate's additive-API check has blind spots.
|
||||
|
||||
Core-plugin check (sm-user-plugin): no exported declaration was removed or changed in signature. The only behaviour change is the relaxed unlink rule and the wider default allow-list (IN-01). It is intentional (D-13), documented in the README, and not raised as critical.
|
||||
|
||||
## Narrative Findings (AI reviewer)
|
||||
|
||||
## Critical Issues
|
||||
|
||||
### CR-01: A verified sign-in still auto-links onto an attacker-created account, because a self-set password does not prove the address under `activate_mode: auto` (T-19.5-38 not actually closed)
|
||||
|
||||
**File:** `sm-sociallogin-plugin/classes/flow.go:97-101`, `sm-sociallogin-plugin/classes/flow.go:202-207`, `sm-sociallogin-plugin/classes/flow.go:262-264`; root cause `fonoteka.go/plugins/golem15/user/controllers/api_controller.go:658` with `fonoteka.go/plugins/golem15/user/config/config.yaml:10` (`activate_mode: auto`, the default; neither sm-bm-app nor sm-demo-app overrides it)
|
||||
|
||||
**Issue:** `ownerVerifiedEmail(u)` returns `u.HasSelfSetPassword`. Its doc comment and the security review assume a self-set password means "registered with one, or set it through the mailed reset code", i.e. the owner proved the address. That is false for `POST /_user/api/v1/register` with the default `activate_mode: auto`. `RegisterUser(..., RegisterOptions{Activate: true})` stores `has_self_set_password = true` and an activated account, and no mail is ever sent to the address.
|
||||
|
||||
Failure scenario (default config, any app with Google or GitHub enabled):
|
||||
1. The attacker calls `POST /register` with `email=victim@gmail.com` and a password of their choice. The account is activated immediately (auto mode) with `has_self_set_password = true`.
|
||||
2. Later the victim clicks "Sign in with Google". Google asserts `email_verified = true` for `victim@gmail.com`.
|
||||
3. `HandleCallback` finds the email match. `profile.EmailVerified && ownerVerifiedEmail(match)` is true, so `loginWithIdentity` links the victim's Google identity onto the attacker's account and signs the victim in.
|
||||
4. The victim now uses an account the attacker can enter at any time with the password. This is the classic-federated-merge pre-account takeover that T-19.5-38 was meant to close. `CompletePending` (lines 202-207) has the same hole.
|
||||
|
||||
`TestPreAccountTakeover` only covers the password-less (social-created) variant, so it stays green.
|
||||
|
||||
**Fix:** Auto-link only on proof of address ownership, not on a password flag. Options, from simplest:
|
||||
```go
|
||||
// Simplest and safest: never auto-link on an email match. Always go pending
|
||||
// (unverified_match / 422 email_exists_link_account); the owner signs in and
|
||||
// links from the profile with a link ticket, as bm already tells its users.
|
||||
if match != nil {
|
||||
return pendingOutcome(provider, profile, returnTo, ReasonUnverifiedMatch, st.BetaKey), nil
|
||||
}
|
||||
```
|
||||
Alternatively, keep auto-link but gate it on a real proof signal: an additive `email_verified_at` column in golem15.user, set by `VerifyActivationCode`, `VerifyResetCode` and admin activation, never by auto-mode registration. Then use `ownerVerifiedEmail(u) = u.EmailVerifiedAt != nil`. At minimum, refuse auto-link when `RegistrationSettings` reports `mode == "auto"`. Update README rows 49-51 and the security review's T-19.5-38 entry.
|
||||
|
||||
### CR-02: An account pre-created from an unverified provider email keeps the attacker's identity after the victim "proves the address" with a password reset
|
||||
|
||||
**File:** `sm-sociallogin-plugin/classes/registration.go:141` (`Activate: true` for every social account, also when `p.EmailVerified` is false); `fonoteka.go/plugins/golem15/user/classes/codes.go:94-114` (`VerifyResetCode` sets `has_self_set_password` and leaves `golem15_user_oauth_identities` alone); the documented recovery path in `sm-sociallogin-plugin/README.md:50` and `sm-sociallogin-plugin/classes/flow.go:254-261`
|
||||
|
||||
**Issue:** The T-19.5-38 fix stops the *auto-link* onto a password-less account. It still creates an **activated** account from an email the provider did not verify (Microsoft, Facebook, or Google without the flag). Its recovery advice ("the owner proves the address with the mailed password reset, after which verified providers link again") hands the victim an account that still carries the attacker's identity.
|
||||
|
||||
Failure scenario on sm-bm-app (`config/golem15.sociallogin.yaml`, tenant `common` by default; DEPLOY.md lists `common` as a valid choice):
|
||||
1. The attacker controls an Entra tenant and sets `mail = student@uwr.edu.pl` on a user. They sign in at `/oauth/microsoft`, get pending (no match), accept the consent, and `CompletePending` creates an activated password-less account for `student@uwr.edu.pl` with the attacker's Microsoft identity linked.
|
||||
2. The real student tries to register: the email is taken. They use "Nie pamiętam hasła", receive the reset code (the address really is theirs) and set a password. `VerifyResetCode` sets `has_self_set_password = true` and nothing else.
|
||||
3. The student uses the account (quizzes, presentations, profile). The attacker signs in with their Microsoft identity whenever they like (`identityUser` → `loginWithIdentity`), because the identity was never removed. bm's profile does not list linked identities, so the student cannot see it.
|
||||
|
||||
**Fix:** Close both ends.
|
||||
- Do not create an activated account from an unverified email. Use `RegisterOptions{Activate: p.EmailVerified, ...}`. When it is false, send golem15.user's activation mail (as `activate_mode: user` does) and answer the frontend with "check your mail". `checkCanSignIn` already refuses inactive users.
|
||||
- When a password-less account's owner proves the address (activation or reset code), revoke what was attached before the proof: delete the user's `oauth_identities` rows and set `tokens_valid_after = now`. sm-user-plugin is a core plugin, so do it additively: fire a new event (e.g. `user.EmailProven`) from `VerifyResetCode` and `VerifyActivationCode` when `HasSelfSetPassword` flips from false, and have golem15.sociallogin drop the identities in its listener. Add a test: pre-created Microsoft account → reset → the Microsoft identity no longer signs in.
|
||||
|
||||
## Warnings
|
||||
|
||||
### WR-01: The one-time completion code is not bound to the browser that started the flow (login CSRF; a plain GET in `token_cookie` mode)
|
||||
|
||||
**File:** `sm-sociallogin-plugin/controllers/callback.go:113-124`, `sm-sociallogin-plugin/controllers/complete.go:17-35`, `sm-sociallogin-plugin/controllers/helpers.go:148-168`; `sm-bm-app/frontend/src/views/OAuthCallbackView.vue:36-52`
|
||||
|
||||
**Issue:** The state cookie protects `/oauth/{provider}/callback`, but the second leg does not. `?code=` on the frontend callback, redeemed by `GET /_user/api/v1/oauth-complete`, is a bearer value that any browser can redeem.
|
||||
|
||||
An attacker runs the flow with their own provider account, stops at `https://app/auth/oauth/callback?code=X` and sends that link to the victim (or iframes or redirects to it). The bm view redeems it and calls `setSession(token, user, true)`: the victim is now persistently signed into the attacker's account. If the victim was already signed in, their session is silently replaced. Anything they upload or enter (quiz answers, presentations) lands in the attacker's account.
|
||||
|
||||
With `golem15.sociallogin.token_cookie: true` it is worse. A top-level GET to `/_user/api/v1/oauth-complete?code=X` sets the session cookie directly, with no SPA involved.
|
||||
|
||||
**Fix:** Bind the code to the initiating browser while keeping the PHP response shape. The frontend generates a random verifier, keeps it in `sessionStorage` and sends `code_challenge = base64url(SHA-256(verifier))` with the start request. Seal the challenge in `State` and copy it into `CompletionPayload` and `PendingPayload`. `oauth-complete` and `oauth-register-complete` then require a matching `code_verifier` and answer 410 otherwise. Clients that send no challenge can keep the old behaviour behind a config flag during migration. For same-site deployments, a `__Host-` binding cookie set at `/oauth/{provider}` and checked at `oauth-complete` also works.
|
||||
|
||||
### WR-02: Registration consent (terms, privacy and **marketing**) is read from a cross-site-triggerable GET and recorded as given
|
||||
|
||||
**File:** `sm-sociallogin-plugin/controllers/redirect.go:69-72`, `sm-sociallogin-plugin/controllers/redirect.go:143-155`; `sm-sociallogin-plugin/classes/flow.go:103-119`; `sm-sociallogin-plugin/classes/registration.go:127-129`, `:165-177`
|
||||
|
||||
**Issue:** `action=register&terms_accepted=1&privacy_accepted=1&marketing_consent=1` on `GET /oauth/{provider}` is sealed into the state cookie as the user's consent. On callback the account is created with `marketing_consent = true`, and the register event records the consent with the victim's IP and user agent.
|
||||
|
||||
Any page can start that navigation. GitHub, and Microsoft or Google with an existing session, often return without showing anything new, so a victim who never saw the terms ends up with an account and a recorded opt-in to marketing. That is a forged GDPR consent record, and the "repudiation" protection in T-19.5-21 records the forgery as genuine.
|
||||
|
||||
**Fix:** Do not take consent from the start request. Always route a first sign-in through the pending screen and collect consent with the POST `oauth-register-complete`, which already exists. If the PHP contract must keep `action=register`, at least ignore `marketing_consent` on the GET and require a same-origin or CSRF-bound nonce (e.g. the WR-01 challenge) before honouring the GET's consent.
|
||||
|
||||
### WR-03: The pending registration code (reusable for 10 minutes) and the user's email are kept in URLs and browser history
|
||||
|
||||
**File:** `sm-sociallogin-plugin/controllers/callback.go:102-110`; `sm-bm-app/frontend/src/views/OAuthCallbackView.vue:27-31`; `sm-bm-app/frontend/src/views/OAuthRegisterView.vue:17-18`
|
||||
|
||||
**Issue:** The callback view takes the completion code out of the URL, but for the pending case it does `router.replace({ name: 'oauth-register', query: { pending, provider, reason, email } })`. The pending code and the email stay in the address bar and history.
|
||||
|
||||
The pending code is not consumed by reads (`GetPending`). It stays valid for `PendingTTL` (10 min) until someone completes it, and completing it returns a session token for the new account. On a shared or lab computer, through history sync or screen sharing, or in a separate frontend host's access log (the Nuxt apps that will use this plugin), a third party can finish the registration first and take the session. The email in the query is also PII in logs. T-19.5-26 covers only the completion code.
|
||||
|
||||
**Fix:** In the frontend, move `pending`, `provider`, `reason` and `email` into `history.state` (`router.replace({ name: 'oauth-register', state: {...} })`) or `sessionStorage`, and keep the URL bare. On the server, keep the PHP redirect shape but consider shortening `PendingTTL` and binding the pending code to the browser as in WR-01.
|
||||
|
||||
### WR-04: `localhost` and `127.0.0.1` are always allowed `frontend_callback` hosts, also in production
|
||||
|
||||
**File:** `sm-sociallogin-plugin/classes/callback.go:20-36` (lines 22-23)
|
||||
|
||||
**Issue:** `NewHostSet` always adds `localhost` and `127.0.0.1`. A crafted link `https://app/oauth/github?frontend_callback=http://localhost:4444/auth/oauth/callback` sends the victim's one-time login code to whatever listens on that port on the victim's machine: another local user's process on a shared machine, a dev server or a malicious local app. Providers that auto-approve (GitHub, or Microsoft with an existing session) need no interaction beyond the click. The config comment presents this as intended, but nothing ties it to development.
|
||||
|
||||
**Fix:** Add the loopback hosts only outside production (e.g. when `app.debug` is true or `SUMMER_ENV` is `local`/`testing`), or only through an explicit `allowed_hosts` entry:
|
||||
```go
|
||||
if cfg.AllowLoopback { // golem15.sociallogin.allow_loopback, default false; true in dev config
|
||||
s.Add("localhost"); s.Add("127.0.0.1")
|
||||
}
|
||||
```
|
||||
|
||||
### WR-05: The gate's "additive only" check for the core user plugin misses several kinds of breaking change
|
||||
|
||||
**File:** `summercms.go/scripts/check-phase19.5.sh:328-330` (`removed_exports`), `:427-437`
|
||||
|
||||
**Issue:** `removed_exports` only catches a removed line that starts with `func <Exported>`, `func (recv) <Exported>` or `type <Exported>`. T-19.5-04 relies on it as a guarantee, but it is green for:
|
||||
- a removed or renamed exported struct field, such as a field of `RegisterOptions` or `OAuthIdentitiesOptions`;
|
||||
- an exported `const` or `var`, including members of `const (...)` and `var (...)` blocks;
|
||||
- a method **added** to an exported interface, which breaks every implementer;
|
||||
- a changed config key in `config/*.yaml`, a route path or middleware in `routes.go`, or a JSON payload key.
|
||||
|
||||
The check is limited to `*.go` lines starting with `func`/`type`. So the claim "no route, payload, table, migration or config key changed" is checked only for migrations.
|
||||
|
||||
**Fix:** Compare the exported API of the two commits instead of grepping diff lines. For example, run `go doc -all ./... | sort` (or `apidiff` from golang.org/x/exp, which would need a dependency note) at `$USER_BASE` (via `git worktree`) and at HEAD, and refuse any removed or changed line. Also add a `git diff --name-only "$USER_BASE" -- config/ routes.go` check that requires review.
|
||||
|
||||
## Info
|
||||
|
||||
### IN-01: Core plugin behaviour change in `OAuthIdentitiesDestroy` (intentional, D-13)
|
||||
|
||||
**File:** `fonoteka.go/plugins/golem15/user/controllers/oauth_identities.go:31-35`, `:116-121`, `:126-134`
|
||||
**Issue:** A user with a self-set password and a single identity used to get 409 and now gets 204. When no `Providers` option is set, the allow-list is now the provider source's `KnownOAuthProviders()`, or the default list, which now includes `microsoft`. The change is documented in the README and was decided in D-13. I note it only so that consumers that relied on the old 409 (fonoteka, the PHP-ported frontends) are checked. There is also a small race: an identity deleted between the `First` lookup (line 82) and the guarded DELETE yields 409 `last_method_blocked` instead of 404.
|
||||
**Fix:** None required for the rule. For the race, return 404 when `deleted == 0` and the row no longer exists (re-check inside the transaction after the lock).
|
||||
|
||||
### IN-02: The registration throttle fails open
|
||||
|
||||
**File:** `fonoteka.go/plugins/golem15/user/controllers/api_controller.go:744-750` (used by `sm-sociallogin-plugin/classes/registration.go:76`)
|
||||
**Issue:** `RegistrationsFromIP` ignores the query error and returns 0, and `checkRegistrationAllowed` skips the throttle when `ClientIP` is empty. On a database hiccup both creation paths stop throttling. This matches `/register`, but the exported helper now carries the behaviour into a second plugin.
|
||||
**Fix:** Have the exported helper return `(int64, error)` (a new function, to stay additive) and fail closed in golem15.sociallogin.
|
||||
|
||||
### IN-03: The `fb_no_email` pending registration can never complete
|
||||
|
||||
**File:** `sm-sociallogin-plugin/classes/flow.go:90-91`, `:197-229`; `sm-sociallogin-plugin/controllers/register_complete.go:37-189`
|
||||
**Issue:** A provider without an email goes pending. `oauth-register-complete` accepts no email field and the payload's email is empty, so `RegisterUser` always answers 422 `errors.email` (pinned by `register_complete_test.go:187`). bm's consent form shows no email field error, so the user is stuck on an unsolvable form.
|
||||
**Fix:** Either accept an `email` field when `payload.Email == ""` (treated as unverified, sent through activation), or have the frontend show "your provider shared no email, register with a password" for `reason=fb_no_email`.
|
||||
|
||||
### IN-04: The Microsoft tenant is validated loosely in the admin form and not at all from config or env
|
||||
|
||||
**File:** `sm-sociallogin-plugin/models/settings.go:79-90`; `sm-sociallogin-plugin/classes/credentials.go:38`
|
||||
**Issue:** `^[A-Za-z0-9.-]+$` accepts `.` and `..`, and the tenant is put into the authority path (`login.microsoftonline.com/<tenant>/oauth2/...`). A tenant from config or `MICROSOFT_TENANT` is not checked at all. Only administrators control it, so the impact is a misdirected authority URL, not an attack.
|
||||
**Fix:** Require `common|organizations|consumers|<GUID>|<domain with at least one dot, no leading or trailing dot>`, and apply the same check in `ResolveCredentials` (refuse the provider as not configured on a bad value).
|
||||
|
||||
### IN-05: The Redis `Pull` needs Redis 6.2 or newer, with no boot-time check
|
||||
|
||||
**File:** `summercms.go/modules/cooler/rediscache.go:55-69`
|
||||
**Issue:** On an older server `GETDEL` fails with "unknown command" at request time. Every oauth-complete, register-complete and link ticket then answers 500. The requirement is only documented.
|
||||
**Fix:** Check the version once (`INFO server` → `redis_version`) when the Redis cache is built, and log or refuse a version below 6.2. Alternatively, fall back to a `MULTI GET DEL EXEC` transaction.
|
||||
|
||||
### IN-06: An added method on an exported interface shipped as a patch release
|
||||
|
||||
**File:** `summercms.go/modules/cooler/cooler.go:77-81`
|
||||
**Issue:** Adding `Pull` to `cooler.Cache` breaks any out-of-tree `Cache` implementation (decorators, test fakes) and shipped as v0.3.0 → v0.3.1. No implementation exists in the sibling repositories (checked), so nothing breaks today.
|
||||
**Fix:** Bump the minor version for interface additions, or mention it in the changelog post for the tag.
|
||||
|
||||
### IN-07: The state cookie is not `Secure` on sm-bm-app
|
||||
|
||||
**File:** `sm-sociallogin-plugin/classes/statecookie.go:79-88`; `sm-bm-app/config/golem15.sociallogin.yaml`
|
||||
**Issue:** `StateCookieSecure` falls back to `auth.cookie.secure`, else an https `app.url`. bm has no `app.url`, so the sealed state cookie goes out over plain http too. The value is encrypted and short-lived, so the impact is low.
|
||||
**Fix:** Set `auth.cookie.secure: true` (or `app.url`) in bm's production config, or derive Secure from `r.TLS` / a trusted `X-Forwarded-Proto` when nothing is configured.
|
||||
|
||||
### IN-08: The app-level social login tests depend on the host environment
|
||||
|
||||
**File:** `sm-bm-app/sociallogin_test.go:26-29`, `:99-102`; `sm-demo-app/sociallogin_test.go` (`socialServer`)
|
||||
**Issue:** `copyLegacyEnv` reads `GOOGLE_CLIENT_ID`, `FACEBOOK_*`, `GITHUB_*`, `MICROSOFT_*`, `BETATESTER_KEY` and `FRONTEND_URL` from the process environment. A developer or CI host with any of them set changes the provider list ("only microsoft") or the redirect assertions, which makes the tests flaky on such hosts.
|
||||
**Fix:** `t.Setenv(name, "")` for every `legacyEnv` variable the test does not set on purpose (export the list from the plugin, or copy it into a test helper).
|
||||
|
||||
### IN-09: The gate's vitest detector refuses on words in test titles or logs
|
||||
|
||||
**File:** `summercms.go/scripts/check-phase19.5.sh:221`
|
||||
**Issue:** `\bfailed\b|\bskipped\b|\btodo\b|\bFAIL\b` is matched against the whole output. A test titled "shows an error when the login failed", or a component logging "FAIL" to stderr, fails the gate although every test passed. This fails closed, so it gives a false red, never a false green. Separately, `mktemp` logs in `go_json` and `run_bm` are not removed when the script is interrupted (no `trap`).
|
||||
**Fix:** Run vitest with `--reporter=json --outputFile=...` and check `numFailedTests`, `numPendingTests` and `numTodoTests` and the per-file status. Add `trap 'rm -f "$log" "$err"' RETURN` in `go_json`.
|
||||
|
||||
---
|
||||
|
||||
_Reviewed: 2026-10-21_
|
||||
_Reviewer: Claude (msd-code-reviewer)_
|
||||
_Depth: standard_
|
||||
@@ -0,0 +1,146 @@
|
||||
# Phase 19.5 Security Review: social sign-in (sm-sociallogin-plugin)
|
||||
|
||||
**Date:** 2026-10-21
|
||||
**Level:** OWASP ASVS L1 (RESEARCH "Security Domain"), block on high; D-11 asks for this review of the matching and state code.
|
||||
**Scope:** `cooler.Cache.Pull` (summercms.go `modules/cooler`), this phase's sm-user-plugin additions (provider source, row-locked unlink, exported sign-in helpers), the new `sm-sociallogin-plugin` (registry, goth wrappers, state cookie, callback rules, one-time codes, matching, registration, linking, settings), the adoption in sm-demo-app, sm-bm-app and fonoteka.go, and this plan's gate.
|
||||
**Threats reviewed:** T-19.5-01..T-19.5-37 and T-19.5-SC from the threat models of plans 01, 02, 03 and 04, plus RESEARCH's Security Domain table; T-19.5-39..T-19.5-46 from plan 05 (gap closure, D-26..D-28).
|
||||
**Result:** every threat is closed by a test that fails when the mitigation is removed, or accepted with a reason (process controls). Two defects found by the new tests were fixed in their own commits (one of them on T-19.5-12). One new finding, T-19.5-38 (pre-account takeover through an account created from an unverified provider email, the PHP behaviour D-10 ported), was raised at the Task 6 checkpoint; the user chose option (b), and it is closed by commit d7ee28d with `TestPreAccountTakeover`. Plan 19.5-05 closes its password-registration variant (19.5-REVIEW CR-01, T-19.5-39): the owner-side proof is now `users.email_verified_at`, which only the mailed activation code and a completed reset-code password reset write, instead of a self-set password. 19.5-REVIEW CR-02 stays open (section "Open findings").
|
||||
|
||||
Repositories and abbreviations: **F** = summercms.go, **U** = sm-user-plugin (`fonoteka.go/plugins/golem15/user`), **S** = `sm-sociallogin-plugin`, **D** = sm-demo-app, **B** = sm-bm-app (with `plugins/jz/bm` and `frontend/`), **A** = the fonoteka.go workspace.
|
||||
|
||||
## Method
|
||||
|
||||
1. For each threat, read the code that implements the mitigation (file and line below) and find the test that fails without it. Where a test was missing, add it as a separate commit in the owning repository.
|
||||
2. Mutation checks on the race-sensitive mitigations: making `memoryCache.Pull` a Get followed by a delete fails `TestPullConcurrent/memory`; removing `FOR UPDATE` from `OAuthIdentitiesDestroy` fails `TestOAuthIdentitiesDestroyRace` (both handler variants and the concurrent handlers); six edits of `PHP_METHOD_MAP.md` (missing, extra and unknown rows, a wrong subtest, a wrong repository, a missing reason) each fail `TestPHPMethodMap`. Every mutation was reverted.
|
||||
3. Sweeps: every non-test `slog` call (section "Log calls"), every response that carries a credential, the provider HTTP clients, the URL of every redirect.
|
||||
4. Run the whole gate: `scripts/check-phase19.5.sh --all` on a Docker host.
|
||||
|
||||
## Defects fixed during this plan
|
||||
|
||||
| Commit | Repo | Threat | Found by | Fix |
|
||||
|--------|------|--------|----------|-----|
|
||||
| 21f3fe5 | S | T-19.5-12 | `TestRegisterComplete/concurrent_submit_one_winner` | Two submissions of one pending code that both reached account creation raced on the `users.email` unique index: the loser answered **500** instead of 410. Account creation now takes transaction-scoped advisory locks on the provider identity and the email (`lockCreation`), and a completion that gets them after a winner committed finds the identity and returns `ErrPendingConsumed` (410), writing nothing. |
|
||||
| c99f5f9 | S | T-19.5-16 (settings write path) | `TestSettings/admin_json_hides_secrets` | Every save of the admin settings page answered 500: `Settings.Rules()` used `regex:`, which lagoon's model validation does not support, so the encrypted secrets could not be managed from the admin at all. The tenant format moved to `Settings.BeforeValidate` (422 on `microsoft_tenant`); README updated. |
|
||||
| d7ee28d | S | T-19.5-38 | the review (finding), pinned by `TestPreAccountTakeover` | A verified sign-in linked onto a password-less account created from an unverified provider email (pre-account takeover). The callback and the pending completion now auto-link a verified email only onto an account with a self-set password (`ownerVerifiedEmail`); otherwise pending `unverified_match` and 422 `email_exists_link_account`. Deliberate divergence from PHP (user decision, option b). 19.5-05 superseded the self-set-password test with `users.email_verified_at` (T-19.5-39). |
|
||||
|
||||
All three are production changes after plan 02's release (v0.1.0); they reach the applications through the v0.1.1 release (T-19.5-36).
|
||||
|
||||
## Test pins added by the review
|
||||
|
||||
| Commit | Repo | Threats | What the test pins |
|
||||
|--------|------|---------|--------------------|
|
||||
| b7fa407 | S | T-19.5-08, T-19.5-12 | `TestStateCookie/key_domain_separated` and `TestCodes/key_domain_separated`: a state cookie opens only under HKDF(app.key, `golem15.sociallogin.oauth-state.v1`), a code value only under HKDF(app.key, `golem15.sociallogin.code.v1`); neither opens with the raw application key, lagoon's column key (`summercms.lagoon.encrypted.v1`) or the other purpose's key. |
|
||||
|
||||
The rest of the pins are this plan's unit tests (Tasks 2 to 4); the register names them.
|
||||
|
||||
## Threat register
|
||||
|
||||
| Threat | Component | Mitigation as built | Implementation | Test (repo) | Status |
|
||||
|--------|-----------|---------------------|----------------|-------------|--------|
|
||||
| T-19.5-01 | `cooler.Cache.Pull` | Redis `GETDEL` and otter `Compute` with `InvalidateOp`: get and delete are one step; an expired entry is removed and reported not found. | F `modules/cooler/rediscache.go:57`, `memory.go:90` | `TestPullConcurrent` (64 pullers, 20 rounds, memory and miniredis), `TestPullExpired`, `TestPullPrefixIsolation`, `TestPullDocker` (redis:7-alpine) (F) | closed (mutation-checked) |
|
||||
| T-19.5-02 | `OAuthIdentitiesDestroy` (DoS: zero sign-in methods) | `SELECT id FROM users WHERE id = ? FOR UPDATE` before the guarded DELETE, in one transaction. | U `controllers/oauth_identities.go:101-111` | `TestOAuthIdentitiesDestroyRace` (two transactions in both orders, the handler waiting for the lock, concurrent handlers), `TestOAuthIdentitiesDestroyConcurrentSmoke` (U) | closed (mutation-checked) |
|
||||
| T-19.5-03 | unlink rule relaxation (D-13) | The DELETE is scoped to the JWT principal's user id and the path provider; it succeeds only with a self-set password or another identity; 409 and 404 bodies unchanged. | U `controllers/oauth_identities.go:32-35`, `:71-124` | `TestOAuthIdentitiesDestroyRule` (table incl. another user's identity 404, EN and PL 409 bodies) (U) | closed |
|
||||
| T-19.5-04 | sm-user-plugin core contract | Additive exports only; no route, payload, table, migration or config key changed. | U commits 78a867b, 1741618, c43ba87 | gate `--userplugin`: no exported declaration removed and no file under `updates/` changed since 4c5d3b8; `--fonoteka` green (F) | closed |
|
||||
| T-19.5-05 | `FinishLogin` restoring a soft-deleted account | FinishLogin authenticates nobody; S calls it only after an identity or a verified-email match (`signIn`), and the reactivate mail tells the owner. | U `controllers/registration.go:99`; S `classes/flow.go:269-305` | `TestFinishLogin/trashed_user_restored_with_one_mail` (U); `TestSoftDeleted/unverified_match_pending_and_no_second_row` (an unverified match never restores) (S) | closed |
|
||||
| T-19.5-06 | logs of the user-plugin additions | The one new log call logs `user_id` and a database error. | U `controllers/registration.go:121` | section "Log calls" | accepted: a single fixed-shape log line with no token, secret or code; nothing to pin beyond the review |
|
||||
| T-19.5-07 | callback (login CSRF, injected code) | Sealed per-browser state cookie; empty state refused; `subtle.ConstantTimeCompare`; provider bound by the cookie name (AAD) and the `Provider` field; cookie cleared on every outcome. | S `controllers/callback.go:27`, `:35-45`; `classes/statecookie.go:134-162` | `TestStateCookie/callback` (missing cookie, empty and unequal state, replay after clear, another provider's callback, cleared on success, provider error, failed exchange, expired code) (S) | closed |
|
||||
| T-19.5-08 | `SealState` / `OpenState` | AES-256-GCM, fresh nonce, HKDF purpose key separate from lagoon's, AAD the cookie name, version and iat (600 s, 60 s skew) checked, 3800-byte bound. | S `classes/statecookie.go:93-129`, `:180-220` | `TestStateCookie/refused` (16 cases), `age_window`, `seal_limits`, `key_domain_separated` (new) (S) | closed |
|
||||
| T-19.5-09 | `NormalizeFrontendCallback`, `NormalizeReturnTo` | Exact-host allow-list, fixed path, the target rebuilt from scheme, host and port; return_to local only; invalid input falls back to `default_callback`. The callback uses the target sealed at the start, never a request value. | S `classes/callback.go:56-107`; `controllers/callback.go:36-39` | `TestNormalizeFrontendCallback` (44 subtests), `TestNormalizeReturnTo` (22 subtests) (S); `TestLinkSmoke/bad_callback_falls_back` (S) | closed |
|
||||
| T-19.5-10 | email matching (account takeover) | Verified only where the provider asserts it: Google a boolean `verified_email`/`email_verified`, GitHub the primary verified `/user/emails` address, Facebook and Microsoft never; an unverified match goes pending `unverified_match`; case-insensitive match. | S `classes/providers_goth.go:126-254` (Facebook `:154`, Microsoft `:251`); `classes/flow.go:92-101` | `TestEmailVerified`, `TestNOAuth` (Microsoft mail of an existing user in four tenants: pending, no identity, completion 422), `TestMatching`, `TestPHPSocialAuthGate` (S) | closed (the creation paths: T-19.5-38 and T-19.5-39, closed) |
|
||||
| T-19.5-11 | `RegisterComplete` (pending hijack) | Completion re-checks identity and email match; `email_verified` false or absent gives 422 `email_exists_link_account`, never a login; payloads sealed. | S `classes/flow.go:175-228` | `TestRegisterComplete/email_exists_link_account_keeps_code`, `TestPHPPendingRegistrationTakeover` (incl. a payload sealed without the flag) (S) | closed |
|
||||
| T-19.5-12 | one-time codes (replay, race) | 64-character `crypto/rand` codes sealed under the cache key, consumed with `Pull`; TTLs 5 min, 10 min, 60 s; register-complete decides before it pulls; concurrent completions are serialised (21f3fe5). | S `classes/codes.go:81-224`; `controllers/register_complete.go:80-87`; `classes/registration.go:192` | `TestCodes` (incl. moved values, 32 concurrent pulls), `TestOAuthComplete`, `TestRegisterComplete/concurrent_submit_one_winner` (S) | fixed in commit 21f3fe5 (with its test) |
|
||||
| T-19.5-13 | beta gate (D-12) | Constant-time comparison on both creation paths; the start request's key carried in the sealed cookie and pending payload. | S `classes/registration.go:55-67`; `controllers/register_complete.go:71`, `:116-125` | `TestBetaGate` (S) | closed |
|
||||
| T-19.5-14 | `action=link`, link ticket | Link user from `RequestUser` (blacklist, `tokens_valid_after`, soft delete) or a single-use 60 s ticket issued to an authenticated POST; the user id sealed in the cookie; another user's identity refused; the callback re-checks the user. | S `controllers/redirect.go:93-106`; `controllers/link_ticket.go:19-42`; `classes/flow.go:127-163` | `TestLink` (bearer, both cookies, blacklisted token, `tokens_valid_after`, trashed at start and at callback, token in query ignored), `TestLinkTicket`, `TestPHPOAuthLinkCookie` (S) | closed |
|
||||
| T-19.5-15 | URLs and logs | No JWT in a URL (ticket, one-time code); the access log records no path or query; no log line carries a token, secret, code, ticket or provider error body. | S controllers (section "Log calls"); F `modules/surf/accesslog.go:42-49`; S `classes/providers_goth.go:85-99` | `TestLinkTicket/no_jwt_in_locations`, `TestSettings/no_secret_in_logs` (a provider whose errors carry the secret and a token), `TestGothProviders/expired_code_without_body`, `other_errors_without_body` (S); `TestAccessLogSmoke` (F) | closed |
|
||||
| T-19.5-16 | client secrets and provider tokens at rest | Secrets in `lagoon.Encrypted` write-only columns, never in the admin JSON; provider tokens in the identity's encrypted columns; cache values sealed. The settings page saves again (c99f5f9). | S `models/settings.go:35-48`, `:84-92`; `classes/flow.go:367-404` | `TestSettings/admin_json_hides_secrets` (stored, env and empty states; a saved secret is ciphertext), `TestIdentityTokens/encrypted_tokens`, `TestCodes/completion_sealed_and_consumed_once` (S) | fixed in commit c99f5f9 (the write path; confidentiality held) |
|
||||
| T-19.5-17 | provider calls and routes (DoS) | Every provider client has a timeout (`http_timeout`, default 10 s; a fallback client too); `throttle:20,1` on /oauth, `throttle:user-api` on the API routes. | S `classes/credentials.go:32`; `classes/providers_goth.go:116-121`; `routes.go:13-23` | `TestGothProviders/timeout` (all four wrappers against a stalled transport, the configured timeout), `TestRouteMiddleware` (route table, 21st request 429) (S) | closed |
|
||||
| T-19.5-18 | route shadowing with the OIDC provider | Reserved names refused by the registry; unknown names answer 404 without a cookie. | S `classes/provider.go:76`, `:122-149`; `controllers/redirect.go:30-34` | `TestRegistry/reserved_names`, `TestRoutes/unknown_and_reserved_404` (S); `TestSocialLoginCoexistsWithOAuthServer` (D) | closed |
|
||||
| T-19.5-19 | inactive or banned users | `checkCanSignIn` (activation, `user_throttle.is_banned`) on identity, verified-match, link and pending paths; restore only on identity or verified match. | S `classes/flow.go:340-355`, `:127-163`, `:269-290` | `TestInactiveAndBanned` (identity, verified match, link, pending completion), `TestSoftDeleted` (S) | closed |
|
||||
| T-19.5-20 | account creation flood | `allow_registration` and the per-IP register throttle (3 an hour) on both creation paths. | S `classes/registration.go:71-80` | `TestRegistrationGates` (S) | closed |
|
||||
| T-19.5-21 | consent records (repudiation) | Consent flags, policy versions, IP, user agent, provider and `password_bootstrap_source` in the register event's payload; `marketing_consent` column on opt-in; no password. | S `classes/registration.go:151-171` | `TestIdentityTokens/register_event_payload`, `TestRegisterComplete/form_encoded` (S) | closed |
|
||||
| T-19.5-22 | stale consent | Callback consent at most 600 s old. | S `classes/registration.go:85-96` | `TestConsent/expired_after_600s` (forged 601 s and 590 s cookies) (S) | closed |
|
||||
| T-19.5-23 | remote operations of plan 02 | Blocking go-ahead, fetch and range check before every push, never forced, no deploy. | 19.5-02-SUMMARY "Remote operations" | release control | accepted: a process control, recorded in 19.5-02-SUMMARY |
|
||||
| T-19.5-24 | bm SPA fallback | `/oauth` among the reserved prefixes of the static handler and the service worker. | B `plugins/jz/bm/static.go`; `frontend/src/pwa/sw.js` | `TestStaticReservedPaths` (B, gate `--bm`); `pwa.test.ts` (B) | closed |
|
||||
| T-19.5-25 | committed configuration and env templates | Credentials only in `.env` or the encrypted settings. | D, B `config/`, `deploy/` | grep in this review: no non-empty `CLIENT_SECRET=` or `client_secret:` in D or B config and templates | accepted: verified by grep; no runtime behaviour to test |
|
||||
| T-19.5-26 | bm callback view (code in history) | The view replaces the route before redeeming the code once; the server consumes it with `Pull`, so a code left in history is dead. | B `frontend/src/views/OAuthCallbackView.vue`; S `controllers/complete.go:24` | `TestOAuthComplete/once_with_shape` (second use 410) (S); `oauth.test.ts` (B); `src/views/OAuthCallbackView.test.ts` (B, 5fd0cc3: the real view mounted with vue's `createRenderer` and a memory-history router; the code is out of the route when the single `completeOAuth` starts, a second mount does not redeem again, `?error=` shows in the alert, a pending registration replaces to `oauth-register`; D-28) | closed (the view half by `OAuthCallbackView.test.ts`, plan 19.5-05) |
|
||||
| T-19.5-27 | Microsoft on bm (nOAuth, tenant) | Microsoft never verified, whatever the tenant; existing students link through the ticket. | S `classes/providers_goth.go:225-254` | `TestNOAuth` (four tenants) (S); `TestSocialLoginMounted` (B) | closed (the creation paths: T-19.5-38 and T-19.5-39, closed) |
|
||||
| T-19.5-28 | link ticket in the bm profile flow | Ticket from an authenticated POST, 60 s, single use, the only value in the URL. | S `controllers/link_ticket.go`; B `frontend/src/lib/oauth.ts` | `TestLinkTicket` (S); `oauth.test.ts` (B) | closed |
|
||||
| T-19.5-29 | pushes of shared repositories (plan 03) | Push only under plan 02's reply, plugin before pointer, after fetch and a range check. | 19.5-03-SUMMARY "Pushes" | release control | accepted: a process control, recorded in 19.5-03-SUMMARY |
|
||||
| T-19.5-30 | fonoteka regression from D-13 | fonoteka's vet and short suite at the new pointer; the Docker-backed user plugin suite. | A | gate `--fonoteka` (F); `TestOAuthIdentitiesDestroyRule` (U) | closed (the unrelated `TestPhase09SecurityRoutes` failure is outside this phase, deferred-items.md) |
|
||||
| T-19.5-31 | gate false green | go test -json detector fails on fail, skip (subtests too), zero tests, "no tests to run", a missing name, a prefix or a subtest standing in for a name; the vitest detector refuses skips and a missing required file (`oauth.test.ts`, `OAuthCallbackView.test.ts`); NOT RUN docker is loud and fails with `PHASE195_REQUIRE_DOCKER=1`. | F `scripts/check-phase19.5.sh` | `scripts/check-phase19.5.sh --self-test` (F) | closed |
|
||||
| T-19.5-32 | race tests (D-13, D-08) | Forced with explicit transactions and start barriers, both orders, repeated. | F `modules/cooler/pull_test.go`; U `oauth_identities_rule_test.go` | the mutation checks above (F, U) | closed |
|
||||
| T-19.5-33 | completeness of this review | Every T-19.5 ID of plans 01-04 appears here with a test or an accepted reason. | this document | the plan's acceptance count | closed |
|
||||
| T-19.5-34 | test fixtures and logs | Test-only literal secrets; provider HTTP faked through `ProviderConfig.HTTPClient`; the fake transport refuses hosts outside its table and the tests check every request stayed on the provider's hosts. | S `providers_goth_test.go`, `settings_test.go` | `TestGothProviders/fake_transport_refuses_unknown_hosts`, `TestSettings/no_secret_in_logs` (S) | closed |
|
||||
| T-19.5-35 | PHP map honesty | The map is checked against the list, the live PHP files and the Go test sources of S and U. | S `php_method_map_test.go` | `TestPHPMethodMap` (mutation-checked) (S) | closed |
|
||||
| T-19.5-36 | post-release fixes | The plan's "Post-release fixes" table lists 21f3fe5, c99f5f9 and d7ee28d against every pin that lacks them; the blocking-human Task 6 asks for the go-ahead; Task 7 releases in D-21 order and checks every pin with `merge-base --is-ancestor`. | 19.5-04 Tasks 5-7 | Task 7's acceptance criteria | release control: Task 6 reply "release"; v0.1.1 and the pointer bumps checked by Task 7's acceptance criteria (19.5-04-SUMMARY) |
|
||||
| T-19.5-37 | remote operations of Task 7 | Only after the Task 6 reply; fetch and range check before every push; never forced or deployed; only `plugins/golem15/user` staged in fonoteka.go. | 19.5-04 Task 7 | Task 7's acceptance criteria | release control: recorded with Task 7's acceptance criteria |
|
||||
| T-19.5-39 | sociallogin `ownerVerifiedEmail` (password-registration pre-account takeover, 19.5-REVIEW CR-01) | A verified provider email auto-links only onto an account whose `users.email_verified_at` is set; only the mailed activation code and a completed reset-code password reset write it. An auto-mode `/register` account, an admin-created or admin-activated account and every account from before sm-user-plugin v0.1.5 go pending `unverified_match`; the completion answers 422 `email_exists_link_account` and keeps the code. No backfill. | S `classes/flow.go:266` `ownerVerifiedEmail` (used by `HandleCallback` `:98` and `CompletePending` `:203`), 3bb5650; U `classes/codes.go:119` `VerifyActivationCode`, `controllers/api_controller.go:290` `ResetPassword` (add1f7a) | `TestOwnerVerifiedEmail/register_auto_then_google_pending`, `register_action_pending`, `legacy_account_pending`, `mail_activated_by_code_links`, `reset_code_password_links`; `TestPreAccountTakeover/after_mailbox_proof_links` (drives `/forgot-password` and `/reset-password`) (S) | closed (RED: the self-set-password rule fails `register_auto_then_google_pending`) |
|
||||
| T-19.5-40 | U `Update` and `FormBeforeUpdate` (proof carried over an email change) | An email change sets `email_verified_at` to NULL in the same write; a change of letter case only (`strings.EqualFold`) keeps it. | U `controllers/api_controller.go:444` `Update` (`:485`), `controllers/users_admin_controller.go:217` `FormBeforeUpdate` with the shared `storedEmail` (a99cb46) | `TestEmailVerifiedAt/update_email_change_clears`, `update_same_email_keeps`, `TestAdminEmailVerifiedAt/admin_email_change_clears`, `admin_save_keeps_proof` (U); `TestOwnerVerifiedEmail/email_change_drops_proof` (S) | closed (RED: the controllers before a99cb46 fail exactly the two `*_email_change_clears` cases) |
|
||||
| T-19.5-41 | `/register` and `/update` bodies (mass assignment of the proof) | `EmailVerifiedAt` is not in `Fillable()`; `Update` writes an explicit column map. | U `models/user.go:50`; `controllers/api_controller.go:444` | `TestEmailVerifiedAt/register_ignores_submitted_proof`, `update_ignores_submitted_proof` (U) | closed |
|
||||
| T-19.5-42 | non-mailbox paths treated as proof (`/register` auto and admin modes, `RegisterUser`, admin create, admin Activate action, change-password, social sign-in) | None of these paths writes the column. | U `controllers.Register`, `classes.RegisterUser`, `usersAdminController.FormBeforeCreate`, `classes.ActivateUsers`, `controllers.ChangePassword`; S `classes/registration.go` (account creation) | `TestEmailVerifiedAt/register_auto_no_proof`, `register_admin_mode_no_proof`, `change_password_no_proof`, `TestAdminEmailVerifiedAt/admin_create_no_proof`, `admin_activate_action_no_proof` (U); `TestOwnerVerifiedEmail/admin_activation_not_proof`, `unverified_provider_never_links_proven_account`, `TestPreAccountTakeover` (a social-created account has no proof) (S) | closed |
|
||||
| T-19.5-43 | user API payloads and admin JSON (contract drift in a core plugin) | `json:"-"`; `apiArray`, the admin `fields.yaml` and `columns.yaml`, routes, config and lang unchanged since v0.1.4. | U `models/user.go:50` | the payload-key assertion in `TestEmailVerifiedAt` (no `email_verified_at` in any answer), `TestAdminEmailVerifiedAt/admin_json_hides_proof` (U); gate `--userplugin` additive check (F) | closed |
|
||||
| T-19.5-44 | migration `202610210001_add_users_email_verified_at` (data rewrite, broken upgrade) | `ALTER TABLE users ADD COLUMN IF NOT EXISTS email_verified_at TIMESTAMPTZ NULL`, no row written, no-op rollback. The gate accepts exactly this one added file under `updates/` and refuses a migration that writes or drops data. | U `updates/202610210001_add_users_email_verified_at.go:20-22`; F `scripts/check-phase19.5.sh` `USER_ADDED_MIGRATIONS`, `refused_migration_changes`, `migration_writes_rows` (7170c5b) | `TestEmailVerifiedAtMigration` (`column_shape`, `no_backfill`, `existing_column_kept`, `rollback_keeps_column`, `history_once`), `TestCreateOrganisationsMigration` (U); `--self-test` migration cases (allowed add, modified base, other add, delete, rename, planted row update, comment-only word) (F) | closed |
|
||||
| T-19.5-45 | scripts/check-phase19.5.sh (false green for the plan 05 tests) | The new Go tests are required by name (`DOCKER_USER_UPDATES_TESTS` and the root, sociallogin lists); `detect_vitest` takes `REQUIRE_FILES` and `run_bm` requires `src/views/OAuthCallbackView.test.ts` next to `src/lib/oauth.test.ts`. | F `scripts/check-phase19.5.sh` (7170c5b, 30e0cd9, 7e43873) | `--self-test` (`missing-view-file` exits 5, the migration-check cases) (F) | closed |
|
||||
| T-19.5-46 | release and pointer bumps of plan 05 (Task 7) | Only after the blocking-human Task 6 reply; the user pointer moves before sociallogin's; each application's gate stage after every bump; fetch and `origin/master..master` check before every push; never forced or deployed; fonoteka.go and summercms.go not pushed. | 19.5-05 Tasks 6-7 | Task 7's acceptance criteria | release control: awaiting the Task 6 reply |
|
||||
| T-19.5-SC | module installs | goth v1.82.0 approved in plan 01 Task 4; no module installed in plan 04 (S `go.mod` and `go.sum` unchanged since adfcfbf); goth imported only by `classes/providers_goth.go`; no gorilla/sessions or gopkg.in/yaml.v3 in `go list -deps .`. | S `go.mod`; `classes/providers_goth.go:13-18` | gate `--sociallogin` (deps and importer checks) (F) | closed |
|
||||
|
||||
## New finding
|
||||
|
||||
| Threat | Category | Component | Severity | Description | Status |
|
||||
|--------|----------|-----------|----------|-------------|--------|
|
||||
| T-19.5-38 | Elevation of privilege | account creation from an unverified provider email (`HandleCallback` with `action=register`, `CompletePending`) | high | An account is created **activated** with an email the provider did not verify (Facebook, Microsoft, a Google answer without the flag). An attacker who controls such a provider account with a victim's address (any Entra tenant can set `mail`; nOAuth) creates the account before the victim does; when the victim later signs in with a verifying provider (Google, GitHub), the verified match linked that identity onto the attacker's account, and the attacker kept access through the original identity (pre-account takeover, classic-federated merge). PHP's SocialAuth behaves the same (D-10). Mitigation (user decision at the Task 6 checkpoint, option b, a deliberate divergence from PHP): the callback and the pending completion auto-link a verified email only onto an account whose owner proved the address (S `classes/flow.go` `ownerVerifiedEmail`, used in `HandleCallback` and `CompletePending`); an account without proof goes pending `unverified_match` and its completion answers 422 `email_exists_link_account`. The proof is `users.email_verified_at` (19.5-05, D-27), written only by the mail activation code and a completed reset-code password reset; d7ee28d had used a self-set password, which an auto-mode `/register` also sets (CR-01, T-19.5-39). After the owner proves the address, verified providers link again. Test: `TestPreAccountTakeover` (S; mutation-checked: an always-true `ownerVerifiedEmail` fails it), `TestOwnerVerifiedEmail` (S). | fixed in d7ee28d; the password-registration variant fixed in 19.5-05 (T-19.5-39) |
|
||||
|
||||
## Open findings
|
||||
|
||||
| Finding | Severity | Description | Status |
|
||||
|---------|----------|-------------|--------|
|
||||
| 19.5-REVIEW CR-02 | high (review) | An account pre-created from an unverified provider email (sociallogin creates it activated, S `classes/registration.go`) keeps the attacker's identity after the real owner proves the address with a reset-code password reset: the reset stamps `email_verified_at` and sets a password but leaves `golem15_user_oauth_identities` alone, so the attacker keeps signing in through the pre-planted Microsoft or Facebook identity. | accepted by the user 2026-10-21 (wording fixed in sm-sociallogin-plugin 79cebb9; 19.5-SECURITY.md AR-19.5-01) |
|
||||
|
||||
## Log calls
|
||||
|
||||
Every non-test `slog` call of S and of this phase's U changes, with what it logs. None logs a token, secret, code, ticket, cookie value, request URL or provider response body; the `err` attributes carry configuration, cache or database errors whose texts the review read (pgx errors carry the SQLSTATE message without row values; `exchangeError` drops `RetrieveError` bodies; the provider's own errors are never logged).
|
||||
|
||||
| Call | Level | Attributes | What `err` can hold |
|
||||
|------|-------|-----------|---------------------|
|
||||
| S `controllers/callback.go:30` "config" | Error | err | `LoadConfig`: a malformed `callback_path`, `default_callback` or `http_timeout` (configuration values, no secrets) |
|
||||
| S `controllers/callback.go:52` "credentials" | Error | provider, err | `ResolveCredentials`: only `LoadConfig` errors (the settings overlay never fails the request) |
|
||||
| S `controllers/callback.go:59` "build provider" | Error | provider, err | a registry factory's error (built-ins never fail; an application's own provider controls its text) |
|
||||
| S `controllers/callback.go:69` "provider refused the sign-in" | Warn | provider | nothing else: the provider's error is not logged (`TestSettings/no_secret_in_logs`) |
|
||||
| S `controllers/callback.go:83` "callback" | Error | provider, err | database errors of the matching and linking (pgx/GORM, no values), `IssueToken` and `APIArray` errors |
|
||||
| S `controllers/callback.go:98` "store pending registration" | Error | err | cache or app-key errors (`putSealed`) |
|
||||
| S `controllers/callback.go:120` "store completion code" | Error | err | cache or app-key errors |
|
||||
| S `controllers/complete.go:26` "pull completion code" | Error | err | cache errors, or the fixed "undecodable code payload" |
|
||||
| S `controllers/link_ticket.go:23` "link ticket user" | Error | err | `RequestUser` infrastructure errors (database, blacklist store) |
|
||||
| S `controllers/link_ticket.go:33` "store link ticket" | Error | err | cache or app-key errors |
|
||||
| S `controllers/register_complete.go:39` "read pending registration" | Error | err | cache errors, or the fixed "undecodable code payload" |
|
||||
| S `controllers/register_complete.go:67` "config" | Error | err | `LoadConfig` (as above) |
|
||||
| S `controllers/register_complete.go:105` "complete pending registration" | Error | err | database errors of `CompletePending` (validation and flow errors are answered, not logged) |
|
||||
| S `controllers/redirect.go:37` "config" | Error | err | `LoadConfig` (as above) |
|
||||
| S `controllers/redirect.go:53` "credentials" | Error | provider, err | `LoadConfig` (as above) |
|
||||
| S `controllers/redirect.go:75` "resolve link user" | Error | err | `PullLinkTicket` cache errors or `RequestUser` infrastructure errors |
|
||||
| S `controllers/redirect.go:113` "build provider" | Error | provider, err | a registry factory's error (as above) |
|
||||
| S `controllers/redirect.go:124` "begin" | Error | provider, err | a provider's `Begin` error (goth's `BeginAuth` has none for the built-ins) |
|
||||
| S `controllers/redirect.go:132` "seal state" | Error | provider, err | the fixed size error ("state cookie of N bytes exceeds 3800") or app-key errors |
|
||||
| S `controllers/helpers.go:160` "token cookie config" | Error | err | `bouncer.LoadCookieConfig`: an invalid `auth.cookie.*` value |
|
||||
| S `classes/credentials.go:76` "settings unavailable, using the configuration" | Debug | err | the settings query's database error |
|
||||
| U `controllers/registration.go:121` "stamp last_seen on login" | Warn | user_id, err | the `last_seen` update's database error |
|
||||
|
||||
## Checks the plan names
|
||||
|
||||
- State compare is constant time and refuses an empty state: S `controllers/callback.go:41-42`; `TestStateCookie/callback/empty_state`, `unequal_state`.
|
||||
- The cookie key is domain-separated from lagoon's: S `classes/statecookie.go:35`, `:180-197`; `TestStateCookie/key_domain_separated`.
|
||||
- The callback target is rebuilt, never echoed: S `classes/callback.go:74-81`; the callback reads the target sealed at the start (`controllers/callback.go:36-39`); `TestNormalizeFrontendCallback/userinfo_dropped`, `query_and_fragment_dropped`, `encoded_path_rebuilt`.
|
||||
- Every login path checks activation and ban: identity, verified match and pending completion through `loginWithIdentity`, link through `linkIdentity` (`classes/flow.go:138`, `:270`); `TestInactiveAndBanned`.
|
||||
- Microsoft and Facebook can never set `EmailVerified`: `classes/providers_goth.go:154`, `:251`; `TestEmailVerified`.
|
||||
- Register-complete decides before it pulls: every 422 path returns before `consume`, which runs last inside the final transaction (`classes/flow.go:183-188`, `classes/registration.go:140-145`); `TestRegisterComplete` (consent, beta, `email_exists_link_account` and validation 422s keep the code).
|
||||
- Link tickets are single use: `classes/codes.go:141-148`; `TestLinkTicket/links_once`.
|
||||
- No JWT, provider token, secret, code or `RetrieveError` body reaches a log line: section "Log calls"; `TestSettings/no_secret_in_logs`, `TestGothProviders/expired_code_without_body`.
|
||||
- The unlink lock is taken before the DELETE: U `controllers/oauth_identities.go:102`; `TestOAuthIdentitiesDestroyRace` (mutation-checked).
|
||||
- goth has a timeout on every provider: `classes/credentials.go:32`, `classes/providers_goth.go:116-121`; `TestGothProviders/timeout`.
|
||||
- The proof follows the address: an email change through `/update` or the admin Users form clears `email_verified_at` in the same write, a case-only change keeps it (U `controllers/api_controller.go:485`, `controllers/users_admin_controller.go:217`); `TestEmailVerifiedAt/update_email_change_clears`, `TestAdminEmailVerifiedAt/admin_email_change_clears`, `TestOwnerVerifiedEmail/email_change_drops_proof`.
|
||||
|
||||
## Accepted residuals (low)
|
||||
|
||||
- Without `app.url`, the redirect URI sent to the provider is completed from the request's `Host` and `X-Forwarded-Proto` (`controllers/helpers.go:50-67`); a client can only change its own flow, and the provider refuses an unregistered redirect URI. Applications set `app.url`.
|
||||
- A registry factory or `Begin` error of an application-provided provider is logged as its author wrote it; the built-in wrappers never put a secret in an error.
|
||||
- `oauth-register-complete` ignores a `parental_authority` sent as false, as PHP's completion did (the callback path refuses it); the value is recorded in the register event's payload.
|
||||
@@ -0,0 +1,111 @@
|
||||
---
|
||||
phase: "19.5"
|
||||
slug: "social-login-in-sm-user-plugin"
|
||||
status: verified
|
||||
# threats_open = count of OPEN threats at or above workflow.security_block_on severity (the blocking gate)
|
||||
threats_open: 0
|
||||
asvs_level: 1
|
||||
created: "2026-10-21"
|
||||
---
|
||||
|
||||
# Phase 19.5 — Security
|
||||
|
||||
> Per-phase security contract: threat register, accepted risks, and audit trail. The per-threat evidence (implementation lines, the test that fails without each mitigation, mutation checks, log sweep) is in `19.5-SECURITY-REVIEW.md`; this file is the register and sign-off.
|
||||
|
||||
---
|
||||
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description | Data Crossing |
|
||||
|----------|-------------|---------------|
|
||||
| Browser → `/oauth/{provider}`, `/oauth/{provider}/callback` | Untrusted query (state, code, callback, return_to, action, link_ticket) and the sealed state cookie | OAuth state, provider authorization code |
|
||||
| Server → OAuth providers (Google, GitHub, Microsoft, Facebook) | goth wrappers over HTTPS; provider profile and email-verified claims are untrusted input | Provider tokens, profile, email |
|
||||
| SPA → `/_user/api/v1/oauth-*` | One-time completion, pending-registration and link-ticket codes | JWT, user payload, consent |
|
||||
| sociallogin → golem15.user | Account matching, creation, linking under row locks; `users.email_verified_at` proof | User records, identities (encrypted tokens) |
|
||||
| Admin → settings | Client ids and encrypted, write-only secrets | Client secrets |
|
||||
|
||||
---
|
||||
|
||||
## Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation | Status |
|
||||
|-----------|----------|-----------|----------|-------------|------------|--------|
|
||||
| T-19.5-01 | Spoofing | cooler.Cache.Pull (memory.go, rediscache.go) | high | mitigate | see 19.5-SECURITY-REVIEW.md; tests: `TestPullConcurrent` (64 pullers, 20 rounds, memory and miniredis), `TestPullExpired`, `TestPullPrefixIsolation`, `TestPullDocker` (redis:7-alpine) (F) | closed (mutation-checked) |
|
||||
| T-19.5-02 | Denial of service | OAuthIdentitiesDestroy | high | mitigate | see 19.5-SECURITY-REVIEW.md; tests: `TestOAuthIdentitiesDestroyRace` (two transactions in both orders, the handler waiting for the lock, concurrent handlers), `TestOAuthIdentitiesDestroyConcurrent | closed (mutation-checked) |
|
||||
| T-19.5-03 | Elevation of privilege | OAuthIdentitiesDestroy rule relaxation (D-13) | medium | mitigate | see 19.5-SECURITY-REVIEW.md; tests: `TestOAuthIdentitiesDestroyRule` (table incl. another user's identity 404, EN and PL 409 bodies) (U) | closed |
|
||||
| T-19.5-04 | Tampering | sm-user-plugin core contract | medium | mitigate | see 19.5-SECURITY-REVIEW.md; tests: gate `--userplugin`: no exported declaration removed and no file under `updates/` changed since 4c5d3b8; `--fonoteka` green (F) | closed |
|
||||
| T-19.5-05 | Spoofing | controllers.FinishLogin restoring a soft-deleted account | medium | mitigate | see 19.5-SECURITY-REVIEW.md; tests: `TestFinishLogin/trashed_user_restored_with_one_mail` (U); `TestSoftDeleted/unverified_match_pending_and_no_second_row` (an unverified match never restores) (S) | closed |
|
||||
| T-19.5-06 | Information disclosure | logs of the new code | medium | mitigate | see 19.5-SECURITY-REVIEW.md; tests: section "Log calls" | closed: accepted: a single fixed-shape log line with no token, secret or code; nothing to pin beyond the review |
|
||||
| T-19.5-07 | Spoofing | Callback (login CSRF, injected code) | high | mitigate | see 19.5-SECURITY-REVIEW.md; tests: `TestStateCookie/callback` (missing cookie, empty and unequal state, replay after clear, another provider's callback, cleared on success, provider error, failed | closed |
|
||||
| T-19.5-08 | Tampering | SealState and OpenState | high | mitigate | see 19.5-SECURITY-REVIEW.md; tests: `TestStateCookie/refused` (16 cases), `age_window`, `seal_limits`, `key_domain_separated` (new) (S) | closed |
|
||||
| T-19.5-09 | Tampering | NormalizeFrontendCallback, NormalizeReturnTo | high | mitigate | see 19.5-SECURITY-REVIEW.md; tests: `TestNormalizeFrontendCallback` (44 subtests), `TestNormalizeReturnTo` (22 subtests) (S); `TestLinkSmoke/bad_callback_falls_back` (S) | closed |
|
||||
| T-19.5-10 | Elevation of privilege | HandleCallback email matching (account takeover through an unverified or attacker-set email) | critical | mitigate | see 19.5-SECURITY-REVIEW.md; tests: `TestEmailVerified`, `TestNOAuth` (Microsoft mail of an existing user in four tenants: pending, no identity, completion 422), `TestMatching`, `TestPHPSocialAuth | closed (the creation paths: T-19.5-38 and T-19.5-39, closed) |
|
||||
| T-19.5-11 | Elevation of privilege | RegisterComplete (pending registration hijack) | high | mitigate | see 19.5-SECURITY-REVIEW.md; tests: `TestRegisterComplete/email_exists_link_account_keeps_code`, `TestPHPPendingRegistrationTakeover` (incl. a payload sealed without the flag) (S) | closed |
|
||||
| T-19.5-12 | Spoofing | PullCompletion, PullPending, PullLinkTicket (code replay or race) | high | mitigate | see 19.5-SECURITY-REVIEW.md; tests: `TestCodes` (incl. moved values, 32 concurrent pulls), `TestOAuthComplete`, `TestRegisterComplete/concurrent_submit_one_winner` (S) | closed: fixed in commit 21f3fe5 (with its test) |
|
||||
| T-19.5-13 | Elevation of privilege | beta gate (D-12) | medium | mitigate | see 19.5-SECURITY-REVIEW.md; tests: `TestBetaGate` (S) | closed |
|
||||
| T-19.5-14 | Spoofing | action=link and LinkTicket (linking a provider to the wrong account) | high | mitigate | see 19.5-SECURITY-REVIEW.md; tests: `TestLink` (bearer, both cookies, blacklisted token, `tokens_valid_after`, trashed at start and at callback, token in query ignored), `TestLinkTicket`, `TestPHP | closed |
|
||||
| T-19.5-15 | Information disclosure | URLs and logs | high | mitigate | see 19.5-SECURITY-REVIEW.md; tests: `TestLinkTicket/no_jwt_in_locations`, `TestSettings/no_secret_in_logs` (a provider whose errors carry the secret and a token), `TestGothProviders/expired_code_w | closed |
|
||||
| T-19.5-16 | Information disclosure | client secrets and provider tokens at rest | high | mitigate | see 19.5-SECURITY-REVIEW.md; tests: `TestSettings/admin_json_hides_secrets` (stored, env and empty states; a saved secret is ciphertext), `TestIdentityTokens/encrypted_tokens`, `TestCodes/completi | closed: fixed in commit c99f5f9 (the write path; confidentiality held) |
|
||||
| T-19.5-17 | Denial of service | provider HTTP calls and /oauth routes | medium | mitigate | see 19.5-SECURITY-REVIEW.md; tests: `TestGothProviders/timeout` (all four wrappers against a stalled transport, the configured timeout), `TestRouteMiddleware` (route table, 21st request 429) (S) | closed |
|
||||
| T-19.5-18 | Tampering | route shadowing with sm-oauthserver-plugin | medium | mitigate | see 19.5-SECURITY-REVIEW.md; tests: `TestRegistry/reserved_names`, `TestRoutes/unknown_and_reserved_404` (S); `TestSocialLoginCoexistsWithOAuthServer` (D) | closed |
|
||||
| T-19.5-19 | Elevation of privilege | login of inactive or banned users | medium | mitigate | see 19.5-SECURITY-REVIEW.md; tests: `TestInactiveAndBanned` (identity, verified match, link, pending completion), `TestSoftDeleted` (S) | closed |
|
||||
| T-19.5-20 | Denial of service | account creation flood | medium | mitigate | see 19.5-SECURITY-REVIEW.md; tests: `TestRegistrationGates` (S) | closed |
|
||||
| T-19.5-21 | Repudiation | consent records | low | mitigate | see 19.5-SECURITY-REVIEW.md; tests: `TestIdentityTokens/register_event_payload`, `TestRegisterComplete/form_encoded` (S) | closed |
|
||||
| T-19.5-22 | Spoofing | stale consent | low | mitigate | see 19.5-SECURITY-REVIEW.md; tests: `TestConsent/expired_after_600s` (forged 601 s and 590 s cookies) (S) | closed |
|
||||
| T-19.5-23 | Tampering | remote operations (Task 6) | medium | mitigate | see 19.5-SECURITY-REVIEW.md; tests: release control | closed: accepted: a process control, recorded in 19.5-02-SUMMARY |
|
||||
| T-19.5-24 | Tampering | jz.bm static SPA fallback | medium | mitigate | see 19.5-SECURITY-REVIEW.md; tests: `TestStaticReservedPaths` (B, gate `--bm`); `pwa.test.ts` (B) | closed |
|
||||
| T-19.5-25 | Information disclosure | committed configuration and env templates | high | mitigate | see 19.5-SECURITY-REVIEW.md; tests: grep in this review: no non-empty `CLIENT_SECRET=` or `client_secret:` in D or B config and templates | closed: accepted: verified by grep; no runtime behaviour to test |
|
||||
| T-19.5-26 | Information disclosure | OAuthCallbackView (code in history and logs) | medium | mitigate | see 19.5-SECURITY-REVIEW.md; tests: `TestOAuthComplete/once_with_shape` (second use 410) (S); `oauth.test.ts` (B); `src/views/OAuthCallbackView.test.ts` (B, 5fd0cc3: the real view mounted with vue | closed (the view half by `OAuthCallbackView.test.ts`, plan 19.5-05) |
|
||||
| T-19.5-27 | Spoofing | Microsoft sign-in on bm (nOAuth, tenant misconfiguration) | high | mitigate | see 19.5-SECURITY-REVIEW.md; tests: `TestNOAuth` (four tenants) (S); `TestSocialLoginMounted` (B) | closed (the creation paths: T-19.5-38 and T-19.5-39, closed) |
|
||||
| T-19.5-28 | Information disclosure | link ticket in the bm profile flow | medium | mitigate | see 19.5-SECURITY-REVIEW.md; tests: `TestLinkTicket` (S); `oauth.test.ts` (B) | closed |
|
||||
| T-19.5-29 | Tampering | pushes of shared repositories | medium | mitigate | see 19.5-SECURITY-REVIEW.md; tests: release control | closed: accepted: a process control, recorded in 19.5-03-SUMMARY |
|
||||
| T-19.5-30 | Denial of service | fonoteka regression from the D-13 relaxation | low | mitigate | see 19.5-SECURITY-REVIEW.md; tests: gate `--fonoteka` (F); `TestOAuthIdentitiesDestroyRule` (U) | closed (the unrelated `TestPhase09SecurityRoutes` failure is outside this phase, deferred-items.md) |
|
||||
| T-19.5-31 | Repudiation | scripts/check-phase19.5.sh (false green) | medium | mitigate | see 19.5-SECURITY-REVIEW.md; tests: `scripts/check-phase19.5.sh --self-test` (F) | closed |
|
||||
| T-19.5-32 | Tampering | race tests (D-13, D-08) | medium | mitigate | see 19.5-SECURITY-REVIEW.md; tests: the mutation checks above (F, U) | closed |
|
||||
| T-19.5-33 | Elevation of privilege | security review completeness | high | mitigate | see 19.5-SECURITY-REVIEW.md; tests: the plan's acceptance count | closed |
|
||||
| T-19.5-34 | Information disclosure | test fixtures and logs | medium | mitigate | see 19.5-SECURITY-REVIEW.md; tests: `TestGothProviders/fake_transport_refuses_unknown_hosts`, `TestSettings/no_secret_in_logs` (S) | closed |
|
||||
| T-19.5-35 | Spoofing | PHP map honesty | low | mitigate | see 19.5-SECURITY-REVIEW.md; tests: `TestPHPMethodMap` (mutation-checked) (S) | closed |
|
||||
| T-19.5-36 | Elevation of privilege | post-release fixes (Tasks 5-7) | high | mitigate | see 19.5-SECURITY-REVIEW.md; tests: Task 7's acceptance criteria | closed: release control: Task 6 reply "release"; v0.1.1 and the pointer bumps checked by Task 7's acceptance criteria (19.5-04-SUMMARY) |
|
||||
| T-19.5-37 | Tampering | remote operations in Task 7 | medium | mitigate | see 19.5-SECURITY-REVIEW.md; tests: Task 7's acceptance criteria | closed: release control: recorded with Task 7's acceptance criteria |
|
||||
| T-19.5-39 | Elevation of privilege | sociallogin `ownerVerifiedEmail` (password-registration pre-account takeover, 19.5-REVIEW CR-01) | high | mitigate | see 19.5-SECURITY-REVIEW.md; tests: `TestOwnerVerifiedEmail/register_auto_then_google_pending`, `register_action_pending`, `legacy_account_pending`, `mail_activated_by_code_links`, `reset_code_pas | closed (RED: the self-set-password rule fails `register_auto_then_google_pending`) |
|
||||
| T-19.5-40 | Spoofing | sm-user-plugin `Update` and `FormBeforeUpdate` (proof carried over an email change) | high | mitigate | see 19.5-SECURITY-REVIEW.md; tests: `TestEmailVerifiedAt/update_email_change_clears`, `update_same_email_keeps`, `TestAdminEmailVerifiedAt/admin_email_change_clears`, `admin_save_keeps_proof` (U); | closed (RED: the controllers before a99cb46 fail exactly the two `*_email_change_clears` cases) |
|
||||
| T-19.5-41 | Tampering | /register and /update request bodies (mass assignment of the proof) | medium | mitigate | see 19.5-SECURITY-REVIEW.md; tests: `TestEmailVerifiedAt/register_ignores_submitted_proof`, `update_ignores_submitted_proof` (U) | closed |
|
||||
| T-19.5-42 | Elevation of privilege | admin create and Activate action, `/register` auto and admin modes, social sign-in (non-mailbox paths treated as proof) | medium | mitigate | see 19.5-SECURITY-REVIEW.md; tests: `TestEmailVerifiedAt/register_auto_no_proof`, `register_admin_mode_no_proof`, `change_password_no_proof`, `TestAdminEmailVerifiedAt/admin_create_no_proof`, `adm | closed |
|
||||
| T-19.5-43 | Information disclosure | user API payloads and admin JSON (contract drift in a core plugin) | low | mitigate | see 19.5-SECURITY-REVIEW.md; tests: the payload-key assertion in `TestEmailVerifiedAt` (no `email_verified_at` in any answer), `TestAdminEmailVerifiedAt/admin_json_hides_proof` (U); gate `--userpl | closed |
|
||||
| T-19.5-44 | Tampering | migration `202610210001_add_users_email_verified_at` (data rewrite or a broken upgrade) | medium | mitigate | see 19.5-SECURITY-REVIEW.md; tests: `TestEmailVerifiedAtMigration` (`column_shape`, `no_backfill`, `existing_column_kept`, `rollback_keeps_column`, `history_once`), `TestCreateOrganisationsMigrati | closed |
|
||||
| T-19.5-45 | Repudiation | scripts/check-phase19.5.sh (false green for the new tests) | medium | mitigate | see 19.5-SECURITY-REVIEW.md; tests: `--self-test` (`missing-view-file` exits 5, the migration-check cases) (F) | closed |
|
||||
| T-19.5-46 | Tampering | release and pointer bumps (Task 7) | medium | mitigate | see 19.5-SECURITY-REVIEW.md; tests: Task 7's acceptance criteria | closed: no remote operation ran (user answered hold); control carried by the release todo (g15office sqnwn1zi) |
|
||||
| T-19.5-SC | Tampering | Go module installs | high | mitigate | see 19.5-SECURITY-REVIEW.md; tests: gate `--sociallogin` (deps and importer checks) (F) | closed |
|
||||
|
||||
*Status: open · closed · open — below high threshold (non-blocking)*
|
||||
*Severity: critical > high > medium > low — only open threats at or above workflow.security_block_on count toward threats_open*
|
||||
*Disposition: mitigate (implementation required) · accept (documented risk) · transfer (third-party)*
|
||||
|
||||
---
|
||||
|
||||
## Accepted Risks Log
|
||||
|
||||
| Risk ID | Threat Ref | Rationale | Accepted By | Date |
|
||||
|---------|------------|-----------|-------------|------|
|
||||
| AR-19.5-01 | 19.5-REVIEW CR-02 | An account sociallogin created from an unverified provider email (Facebook, Microsoft) keeps that identity after the real owner proves the address with a reset-code password reset. Behaviour accepted; the sociallogin README (79cebb9) states that proving the address removes no identity already linked and that the owner removes it from the identities list. | user | 2026-10-21 |
|
||||
| AR-19.5-02 | T-19.5-39 (release) | The email-proof fix is committed but not released (user answered hold at 19.5-05 Task 6); deployed sm-demo-app and sm-bm-app keep the password-registration takeover path until `.planning/todos/pending/email-proof-release.md` (g15office sqnwn1zi) ships with the 19.8 release. | user | 2026-10-21 |
|
||||
| AR-19.5-03 | low residuals | Redirect URI from Host/X-Forwarded-Proto without `app.url`; provider factory errors logged as written; `parental_authority=false` ignored at completion (PHP parity). See the review's "Accepted residuals (low)". | review (low, below block threshold) | 2026-10-21 |
|
||||
|
||||
---
|
||||
|
||||
## Security Audit Trail
|
||||
|
||||
| Audit Date | Threats Total | Closed | Open | Run By |
|
||||
|------------|---------------|--------|------|--------|
|
||||
| 2026-10-21 | 46 | 46 | 0 | 19.5-04/05 security review (msd-executor) + /msd-secure-phase (orchestrator, ASVS L1 register check) |
|
||||
|
||||
---
|
||||
|
||||
## Sign-Off
|
||||
|
||||
- [x] All threats have a disposition (mitigate / accept / transfer)
|
||||
- [x] Accepted risks documented in Accepted Risks Log
|
||||
- [x] `threats_open: 0` confirmed
|
||||
- [x] `status: verified` set in frontmatter
|
||||
|
||||
**Approval:** verified 2026-10-21
|
||||
@@ -0,0 +1,47 @@
|
||||
---
|
||||
status: complete
|
||||
phase: 19.5-social-login-in-sm-user-plugin
|
||||
source: [19.5-VERIFICATION.md]
|
||||
started: 2026-10-21T00:24:54.347Z
|
||||
updated: 2026-10-21T13:40:00.000Z
|
||||
---
|
||||
|
||||
## Current Test
|
||||
|
||||
[testing complete]
|
||||
|
||||
## Tests
|
||||
|
||||
### 1. Decide on the residual pre-account-takeover path left open by the T-19.5-38 fix. ownerVerifiedEmail treats has_self_set_password=true as proof the owner controls the address, but POST /register with golem15.user activation.activate_mode=auto (the default) creates an activated account with has_self_set_password=true and no email check. An attacker who registers victim@example.com with a password before the victim's first Google/GitHub sign-in gets the victim's verified identity auto-linked onto the attacker's account, and keeps password access.
|
||||
expected: Either accept it explicitly (it is exactly PHP's D-10 behaviour, and the variant T-19.5-38 closed was the social-created one), or schedule a follow-up (for example: auto-link only when the account was activated by mail or set its password through the reset code, or require activate_mode=user for apps mounting sociallogin). Also correct 19.5-SECURITY-REVIEW.md / README wording that a self-set password means the owner 'proved the address'.
|
||||
result: pass
|
||||
reported: "(a) Tighten auto-link" (Column in sm-user-plugin; chosen 2026-10-21 during /msd-plan-phase 19.5 --gaps)
|
||||
retested_by: 19.5-05
|
||||
|
||||
### 2. Walk the bm callback view in a browser (see behavior_unverified_items).
|
||||
expected: As in behavior_unverified_items.
|
||||
result: pass
|
||||
reported: "yes" (2026-10-21: record as passed on the automated view test)
|
||||
evidence: verified by the automated view test sm-bm-app frontend/src/views/OAuthCallbackView.test.ts (5/5, required by name in scripts/check-phase19.5.sh); no manual browser walk was done
|
||||
retested_by: 19.5-05
|
||||
|
||||
### 3. Review the judgment-tier prohibition verdicts in the 'Prohibitions' section of this report (non-authoritative LLM-judge verdicts).
|
||||
expected: Confirm or dispute each verdict; all are recorded as no violation found.
|
||||
result: pass
|
||||
reported: "prohibitions ok"
|
||||
|
||||
### 4. Decide on 19.5-REVIEW CR-02: an account pre-created by sociallogin from an unverified provider email (created activated, S classes/registration.go) keeps the attacker's Microsoft or Facebook identity after the real owner proves the address with a reset-code password reset.
|
||||
expected: Either accept it (PHP parity on account creation) and correct sm-sociallogin-plugin README.md:50, which offers 'proves the address with a password reset' as the recovery; or schedule the follow-up the review proposes (create the account inactive unless p.EmailVerified, and/or drop unverified identities when the reset stamps email_verified_at).
|
||||
result: pass
|
||||
reported: "fix README wording quickly" (behaviour accepted; sm-sociallogin-plugin 79cebb9 says a reset does not unlink identities linked before the proof and points to the identities list)
|
||||
|
||||
## Summary
|
||||
|
||||
total: 4
|
||||
passed: 4
|
||||
issues: 0
|
||||
pending: 0
|
||||
skipped: 0
|
||||
blocked: 0
|
||||
|
||||
## Gaps
|
||||
@@ -0,0 +1,47 @@
|
||||
# Phase 19.5: User Setup Required
|
||||
|
||||
**Generated:** 2026-10-21
|
||||
**Phase:** 19.5-social-login-in-sm-user-plugin
|
||||
**Status:** Incomplete
|
||||
|
||||
These items are needed only for live use of social sign-in. The tests need no credentials. Claude automated everything else; these need access to external dashboards and the servers.
|
||||
|
||||
## Environment Variables
|
||||
|
||||
| Status | Variable | Source | Add to |
|
||||
|--------|----------|--------|--------|
|
||||
| [ ] | `MICROSOFT_CLIENT_ID` | Entra admin center, App registrations, the bm app, Overview (Application (client) ID) | `.env` of bm on rome |
|
||||
| [ ] | `MICROSOFT_CLIENT_SECRET` | Entra admin center, App registrations, the bm app, Certificates & secrets (the value, not the ID) | `.env` of bm on rome |
|
||||
| [ ] | `MICROSOFT_TENANT` | `common`, `organizations` or the university's directory (tenant) ID | `.env` of bm on rome |
|
||||
| [ ] | `SUMMER_GOLEM15__SOCIALLOGIN__PROVIDERS__MICROSOFT__REDIRECT` | `https://bm.jakubzych.com/oauth/microsoft/callback` (template value in `sm-bm-app/deploy/env.example`) | `.env` of bm on rome |
|
||||
| [ ] | `SUMMER_GOLEM15__SOCIALLOGIN__PROVIDERS__GOOGLE__CLIENT_ID` (or `GOOGLE_CLIENT_ID`) | Google Cloud console, APIs & Services, Credentials (OAuth client, web) | `/srv/summercms-demo/.env` |
|
||||
| [ ] | `SUMMER_GOLEM15__SOCIALLOGIN__PROVIDERS__GOOGLE__CLIENT_SECRET` (or `GOOGLE_CLIENT_SECRET`) | same OAuth client | `/srv/summercms-demo/.env` |
|
||||
|
||||
## Dashboard Configuration
|
||||
|
||||
- [ ] **Entra redirect URI and permissions (bm)**
|
||||
- Location: Entra admin center, App registrations, the bm app, Authentication and API permissions
|
||||
- Set to: Web redirect URI `https://bm.jakubzych.com/oauth/microsoft/callback`; delegated Microsoft Graph permissions `openid`, `profile`, `email`, `offline_access`, `User.Read`
|
||||
- Notes: note the client secret's expiry date; details in `sm-bm-app/DEPLOY.md`, "Microsoft 365 sign-in".
|
||||
- [ ] **Google OAuth client (demo)**
|
||||
- Location: Google Cloud console, APIs & Services, Credentials
|
||||
- Set to: authorised redirect URI `https://demo.summercms.io/oauth/google/callback`
|
||||
- Notes: the demo's nginx does not route `/oauth` to Go yet (`sm-demo-app/scripts/check-deploy.sh` refuses it), so the demo cannot use social sign-in live until that guard and the nginx site change.
|
||||
|
||||
## Access to the private module
|
||||
|
||||
- [ ] **Module downloads of sm-sociallogin-plugin**
|
||||
- The repository is private, so `GOWORK=off` builds (sm-demo-app releases) download it over SSH only. On a machine without the module in its cache, run once: `git config --global url."git@git.golem15.com:".insteadOf "https://git.golem15.com/"`, or make the repository public like the other shared plugins.
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
curl -sI https://bm.jakubzych.com/oauth/microsoft | grep -i '^location'
|
||||
```
|
||||
|
||||
Expected results:
|
||||
- The Location points at `login.microsoftonline.com/<tenant>/oauth2/v2.0/authorize`; without credentials it points at `/auth/oauth/callback?error=…`.
|
||||
|
||||
---
|
||||
|
||||
**Once all items complete:** Mark status as "Complete" at top of file.
|
||||
@@ -3,10 +3,11 @@ phase: "19.5"
|
||||
slug: "social-login-in-sm-user-plugin"
|
||||
# status lifecycle: draft (seeded by plan-phase) → validated (set by validate-phase §6)
|
||||
# audit-milestone §5.5 distinguishes NOT-VALIDATED (draft) from PARTIAL (validated + nyquist_compliant: false) (#2117)
|
||||
status: draft
|
||||
nyquist_compliant: false
|
||||
wave_0_complete: false
|
||||
status: validated
|
||||
nyquist_compliant: true
|
||||
wave_0_complete: true
|
||||
created: "2026-10-19"
|
||||
validated: "2026-10-21"
|
||||
---
|
||||
|
||||
# Phase 19.5 — Validation Strategy
|
||||
@@ -20,10 +21,10 @@ created: "2026-10-19"
|
||||
| Property | Value |
|
||||
|----------|-------|
|
||||
| **Framework** | Go `testing` + `httptest`; testcontainers-go Postgres (skips under `-short`); miniredis and `redis:7-alpine` for cooler; vitest for the bm frontend |
|
||||
| **Config file** | none (Go). New `sm-sociallogin-plugin/testmain_test.go` + `harness_test.go`, copied from `sm-oauthserver-plugin` |
|
||||
| **Config file** | none (Go). `sm-sociallogin-plugin/testmain_test.go` + `harness_test.go` (`bootSocial`, `bootSocialWith`, `freshDB`), `fakeprovider_test.go`, `unit_helpers_test.go` |
|
||||
| **Quick run command** | `go vet ./... && go test -short ./...` in the touched repo |
|
||||
| **Full suite command** | `go vet ./... && go test ./...` in summercms.go, the user plugin checkout, sm-sociallogin-plugin, sm-demo-app, sm-bm-app (+ `npm --prefix frontend run test && npm --prefix frontend run typecheck`) and fonoteka.go |
|
||||
| **Estimated runtime** | ~30 seconds quick; ~5 minutes full (Docker) |
|
||||
| **Full suite command** | `scripts/check-phase19.5.sh --all` (summercms.go, sm-user-plugin through the fonoteka.go workspace, sm-sociallogin-plugin, sm-demo-app, sm-bm-app Go and frontend, fonoteka.go; the docker stage on a Docker host) |
|
||||
| **Estimated runtime** | ~30 seconds quick per repo; ~6 minutes for `--all` with Docker |
|
||||
|
||||
---
|
||||
|
||||
@@ -31,35 +32,71 @@ created: "2026-10-19"
|
||||
|
||||
- **After every task commit:** Run the quick command in the touched repo
|
||||
- **After every plan wave:** Run the full command in every touched repo
|
||||
- **Before `/msd-verify-work`:** Full suite must be green in all six repos
|
||||
- **Before `/msd-verify-work`:** `scripts/check-phase19.5.sh --all` green (docker stage run, not NOT RUN)
|
||||
- **Max feedback latency:** 60 seconds
|
||||
|
||||
---
|
||||
|
||||
## Per-Task Verification Map
|
||||
|
||||
Filled in by the planner and executor. Decision → test map from RESEARCH.md § Validation Architecture:
|
||||
One row per task of plans 01-05. Gate stages are modes of `scripts/check-phase19.5.sh`; repositories: F summercms.go, U sm-user-plugin, S sm-sociallogin-plugin, D sm-demo-app, B sm-bm-app, A fonoteka.go.
|
||||
|
||||
| Item | Behavior | Test Type | Automated Command | File Exists | Status |
|
||||
|------|----------|-----------|-------------------|-------------|--------|
|
||||
| D-08 | `Pull` returns and removes once; expired → not found; N concurrent pullers → one winner (memory, miniredis, Redis) | unit + docker | `go test ./modules/cooler -run 'TestPull'` | ❌ W0 | ⬜ pending |
|
||||
| D-13 | password user unlinks last identity → 204; password-less last → 409; two concurrent unlinks → one survives | integration | user plugin `go test ./... -run 'TestOAuthIdentitiesDestroy'` | ⚠ update existing | ⬜ pending |
|
||||
| D-15 | no sociallogin → `{"success":true,"providers":[]}`; enabled fakes → `[{name,label}]`; disabled excluded | unit | `-run 'TestOAuthProviders|TestProvidersList'` | ❌ | ⬜ pending |
|
||||
| D-03 | fake provider drives redirect → callback → code → complete end to end | integration | sociallogin `-run TestFlow` | ❌ | ⬜ pending |
|
||||
| D-04 | unknown/reserved name → 404; disabled → `?error=`; registry rejects reserved names | unit | `-run 'TestRoutes|TestRegistry'` | ❌ | ⬜ pending |
|
||||
| D-07 | missing/tampered/expired/mismatched state → `invalid_state`; cookie cleared; attributes | unit | `-run TestStateCookie` | ❌ | ⬜ pending |
|
||||
| D-06/D-17 | callback and `return_to` normalisation tables | unit | `-run TestNormalize` | ❌ | ⬜ pending |
|
||||
| D-09 | complete 422/410/200/second-use 410; cookie mode sets token cookie | integration | `-run TestOAuthComplete` | ❌ | ⬜ pending |
|
||||
| D-09 | register-complete 422 consent keeps code; 410; `email_exists_link_account`; concurrent submit → one 200 | integration | `-run TestRegisterComplete` | ❌ | ⬜ pending |
|
||||
| D-10 | matching branch table | integration | `-run TestMatching` | ❌ | ⬜ pending |
|
||||
| D-11 | email-verified per provider, nOAuth case | unit + integration | `-run 'TestEmailVerified|TestNOAuth'` | ❌ | ⬜ pending |
|
||||
| D-12 | beta gate off/wrong/pending path | integration | `-run TestBetaGate` | ❌ | ⬜ pending |
|
||||
| D-14 | link via bearer/`token`/`auth_token`; no JWT; owned by another user | integration | `-run TestLink` | ❌ | ⬜ pending |
|
||||
| D-16 | settings override, secret never echoed, enabled iff id+secret, live change | integration | `-run TestSettings` | ❌ | ⬜ pending |
|
||||
| D-19 | provider tokens encrypted with `token_expires_at` | integration | `-run TestIdentityTokens` | ❌ | ⬜ pending |
|
||||
| D-20 | route middleware throttles | unit | `-run TestRouteMiddleware` | ❌ | ⬜ pending |
|
||||
| PHP map | one Go test per PHP social-auth test case | integration | `-run TestPHP` | ❌ | ⬜ pending |
|
||||
| D-21 | demo and bm boot with sociallogin; bm `/oauth` reserved from SPA fallback; fonoteka green at new pin | app tests | per-app full commands | ⚠ update existing | ⬜ pending |
|
||||
| Task | Decision | Test names | Gate stage | Status |
|
||||
|------|----------|------------|------------|--------|
|
||||
| 19.5-01-01 | D-02, D-08 | `TestPullSmoke`, `TestPullConcurrent`, `TestPullExpired`, `TestPullPrefixIsolation` (F); `TestPullDocker` (F); decision note names goth v1.82.0 | `--go`, `--docker`, `--docs` | ✅ green |
|
||||
| 19.5-01-02 | D-15, D-22 | `TestOAuthProvidersSourceSmoke`, `TestOAuthProviders` (U); `TestSocialHooksSmoke`, `TestRegisterUserPasswordLess`, `TestFinishLogin`, `TestRegistrationExports` (U) | `--userplugin`, `--docker` | ✅ green |
|
||||
| 19.5-01-03 | D-13, D-24 | `TestOAuthIdentitiesDestroyRule`, `TestOAuthIdentitiesDestroyRace`, `TestOAuthIdentitiesDestroyConcurrentSmoke`, `TestOAuthIdentitiesDestroyLastIdentityWithPassword`, `TestBackfillHandlesOpensFromConfigWhenUnpublished` (U); additive and migration check | `--docker`, `--userplugin` | ✅ green |
|
||||
| 19.5-01-04 | D-02 (package check) | human checkpoint, reply "approved" (19.5-01-SUMMARY); `go list -deps .` has no gorilla/sessions or gopkg.in/yaml.v3, goth only in `classes/providers_goth.go` (S) | `--sociallogin` | ✅ green |
|
||||
| 19.5-02-01 | D-03, D-07, D-09 | `TestTracerSmoke`, `TestRegistry`, `TestStateCookie`, `TestCodes`, `TestOAuthComplete` (S) | `--sociallogin`, `--docker` | ✅ green |
|
||||
| 19.5-02-02 | D-10, D-11, D-12, D-19, D-23 | `TestFlowSmoke`, `TestMatching`, `TestNOAuth`, `TestSoftDeleted`, `TestInactiveAndBanned`, `TestRegistrationGates`, `TestConsent`, `TestBetaGate`, `TestIdentityTokens`, `TestRegisterComplete`, `TestTokenCookieMode` (S) | `--docker` | ✅ green |
|
||||
| 19.5-02-03 | D-04, D-06, D-14, D-17 | `TestLinkSmoke`, `TestLink`, `TestLinkTicket`, `TestRoutes`, `TestNormalizeFrontendCallback`, `TestNormalizeReturnTo` (S) | `--sociallogin`, `--docker` | ✅ green |
|
||||
| 19.5-02-04 | D-02, D-11, D-16, D-20 | `TestGothProvidersSmoke`, `TestGothProviders`, `TestEmailVerified`, `TestErrorMessages` (S); `TestSettingsOverrideSmoke`, `TestSettings`, `TestRouteMiddleware`, `TestProvidersList` (S) | `--sociallogin`, `--docker` | ✅ green |
|
||||
| 19.5-02-05 | D-21 (go-ahead) | remote operation: human checkpoint, reply "release v0.3.1 (Recommended)" (19.5-02-SUMMARY) | (none: remote operation) | ✅ recorded |
|
||||
| 19.5-02-06 | D-21 (release) | remote operation: `git ls-remote --exit-code --tags origin` for summercms v0.3.1, sm-user-plugin v0.1.4, sm-sociallogin-plugin v0.1.0 (19.5-02-SUMMARY) | (none: remote operation) | ✅ recorded |
|
||||
| 19.5-03-01 | D-21 (demo) | `TestBootAllPlugins`, `TestSocialLoginRedirect`, `TestSocialLoginCoexistsWithOAuthServer` (D) | `--demo` | ✅ green |
|
||||
| 19.5-03-02 | D-04, D-21 (bm backend) | `TestSocialLoginMounted` (B); `TestStaticReservedPaths`, `TestEmbeddedPublicHasIndex` (B `plugins/jz/bm`) | `--bm` | ✅ green |
|
||||
| 19.5-03-03 | D-06, D-23 (bm frontend) | vitest `src/lib/oauth.test.ts` and the frontend suite, `npm run typecheck` (B) | `--bm` | ✅ green |
|
||||
| 19.5-03-04 | D-24 (fonoteka) | fonoteka.go `go vet ./...`, `go test -short ./...`; `summer.yaml` does not mount golem15.sociallogin (A) | `--fonoteka` | ✅ green |
|
||||
| 19.5-04-01 | gate (T-19.5-31) | `scripts/check-phase19.5.sh --self-test` (F) | `--self-test` | ✅ green |
|
||||
| 19.5-04-02 | D-08, D-13, D-15, D-22 | the cooler and sm-user-plugin unit tests of rows 19.5-01-01 to -03 (F, U) | `--go`, `--userplugin`, `--docker` | ✅ green |
|
||||
| 19.5-04-03 | D-03..D-20, D-23 | the 25 plugin unit tests of rows 19.5-02-01 to -04 (S) | `--sociallogin`, `--docker` | ✅ green |
|
||||
| 19.5-04-04 | PHP map | `TestPHPMethodMap` (S); `TestPHPSocialAuthGate`, `TestPHPOAuthLinkCookie`, `TestPHPPendingRegistrationTakeover`, `TestPHPPasswordBootstrap`, `TestPHPProfileOAuthLink` (S); `TestChangePassword/real_password_account_still_requires_current_password` (U) | `--sociallogin`, `--docker` | ✅ green |
|
||||
| 19.5-04-05 | D-11 review, SC-4 | `19.5-SECURITY-REVIEW.md` (every T-19.5 threat to a test or an accepted reason); the whole gate | `--all` | ✅ green |
|
||||
| 19.5-04-06 | D-21 (post-release go-ahead) | remote operation: Task 7's acceptance criteria (no patch tag before the reply; the reply recorded verbatim) | (none: remote operation) | ⬜ awaiting the Task 6 reply |
|
||||
| 19.5-04-07 | D-21 (post-release release) | remote operation: Task 7's acceptance criteria (`merge-base --is-ancestor` per fix and pin, tags on the remotes, `origin/master..master` empty) and `--sociallogin`, `--demo`, `--bm`, `--fonoteka` at the new pins | `--sociallogin`, `--demo`, `--bm`, `--fonoteka` | ⬜ awaiting the Task 6 reply |
|
||||
| 19.5-05-01 | D-26, D-27 (tracer) | `TestOwnerVerifiedEmail/register_auto_then_google_pending`, `mail_activated_by_code_links`, `reset_code_password_links`; `TestPreAccountTakeover` (S); `TestEmailVerifiedAtMigration`, `TestCreateOrganisationsMigration` (U); migration allow-list self-test cases (F) | `--self-test`, `--userplugin`, `--sociallogin`, `--docker` | ✅ green |
|
||||
| 19.5-05-02 | D-26, D-27 (writers, clearing) | `TestEmailVerifiedAt` (12 subtests), `TestAdminEmailVerifiedAt` (6 subtests), `TestEmailVerifiedAtMigration` (5 subtests) (U) | `--userplugin`, `--fonoteka`, `--docker` | ✅ green |
|
||||
| 19.5-05-03 | D-26, D-27 (owner proof table, wording) | `TestOwnerVerifiedEmail` (8 subtests), `TestPreAccountTakeover`, `TestPHPPendingRegistrationTakeover`, `TestPHPMethodMap` (S) | `--sociallogin`, `--docker` | ✅ green |
|
||||
| 19.5-05-04 | D-28 | vitest `src/views/OAuthCallbackView.test.ts` (5 tests), `npm run typecheck`, package.json and lockfile unchanged (B); `--self-test` `missing-view-file` (F) | `--self-test`, `--bm` | ✅ green |
|
||||
| 19.5-05-05 | D-27 wording, gate | `19.5-SECURITY-REVIEW.md` (T-19.5-39..46, CR-02 open), this map; the whole gate with `PHASE195_REQUIRE_DOCKER=1` | `--all` | ✅ green |
|
||||
| 19.5-05-06 | D-21, D-27 (release go-ahead) | remote operation: Task 7's acceptance criteria (no tag on either plugin before the reply; the reply recorded verbatim) | (none: remote operation) | ⬜ awaiting the Task 6 reply |
|
||||
| 19.5-05-07 | D-21, D-27, SC-3 (release) | remote operation: Task 7's acceptance criteria (v0.1.5 and v0.1.2 on the remotes, sociallogin go.mod requires sm-user-plugin v0.1.5, app pins equal the tags, user bump before sociallogin bump, `origin/master..master` empty) and `--sociallogin`, `--demo`, `--bm`, `--fonoteka` at the new pins | `--sociallogin`, `--demo`, `--bm`, `--fonoteka` | ⬜ awaiting the Task 6 reply |
|
||||
|
||||
Decision coverage (from RESEARCH § Validation Architecture):
|
||||
|
||||
| Item | Behavior | Tests (repo) | Gate stage | Status |
|
||||
|------|----------|--------------|------------|--------|
|
||||
| D-08 | `Pull` returns and removes once; expired → not found; 64 concurrent pullers → one winner (memory, miniredis, Redis) | `TestPullConcurrent`, `TestPullExpired`, `TestPullPrefixIsolation`, `TestPullDocker` (F) | `--go`, `--docker` | ✅ green |
|
||||
| D-13 | self-set password unlinks the last identity → 204; password-less last → 409; two concurrent unlinks → one survives | `TestOAuthIdentitiesDestroyRule`, `TestOAuthIdentitiesDestroyRace` (U) | `--docker` | ✅ green |
|
||||
| D-15 | no source → `{"providers":[],"success":true}`; enabled providers in order; disabled excluded | `TestOAuthProviders` (U), `TestProvidersList` (S) | `--userplugin`, `--docker` | ✅ green |
|
||||
| D-03 | fake provider drives redirect → callback → code → complete | `TestTracerSmoke`, `TestRegistry` (S) | `--sociallogin`, `--docker` | ✅ green |
|
||||
| D-04 | unknown/reserved name → 404; disabled → `?error=`; registry refuses reserved names | `TestRoutes`, `TestRegistry` (S) | `--sociallogin`, `--docker` | ✅ green |
|
||||
| D-07 | missing/tampered/expired/mismatched state → `invalid_state`; cookie cleared; attributes; key separation | `TestStateCookie` (S) | `--docker` | ✅ green |
|
||||
| D-06/D-17 | callback and `return_to` normalisation tables | `TestNormalizeFrontendCallback`, `TestNormalizeReturnTo` (S) | `--sociallogin` | ✅ green |
|
||||
| D-09 | complete 422/410/200/second-use 410; cookie mode sets the token cookie | `TestOAuthComplete`, `TestTokenCookieMode` (S) | `--docker` | ✅ green |
|
||||
| D-09 | register-complete 422 consent keeps the code; 410; `email_exists_link_account`; 16 concurrent submits → one 200 | `TestRegisterComplete` (S) | `--docker` | ✅ green |
|
||||
| D-10 | matching branch table, soft-deleted, inactive and banned, no auto-link onto a password-less account (T-19.5-38) | `TestMatching`, `TestSoftDeleted`, `TestInactiveAndBanned`, `TestPreAccountTakeover` (S) | `--docker` | ✅ green |
|
||||
| D-11 | email-verified per provider, nOAuth case | `TestEmailVerified` (S, `--sociallogin`), `TestNOAuth` (S, `--docker`) | `--sociallogin`, `--docker` | ✅ green |
|
||||
| D-12 | beta gate off/wrong/pending path | `TestBetaGate` (S) | `--docker` | ✅ green |
|
||||
| D-14 | link via bearer/`token`/`auth_token`/ticket; no JWT in URLs; owned by another user | `TestLink`, `TestLinkTicket` (S) | `--docker` | ✅ green |
|
||||
| D-16 | settings override, secret never echoed or logged, enabled iff id+secret, live change, legacy env | `TestSettings` (S) | `--docker` | ✅ green |
|
||||
| D-19 | provider tokens encrypted with `token_expires_at`; profile data only | `TestIdentityTokens` (S) | `--docker` | ✅ green |
|
||||
| D-20 | route middleware throttles | `TestRouteMiddleware` (S) | `--docker` | ✅ green |
|
||||
| PHP map | one Go test per PHP social-auth test case (38 methods) | `TestPHPMethodMap` and the five `TestPHP*` ports (S) | `--sociallogin`, `--docker` | ✅ green |
|
||||
| D-21 | demo and bm boot with sociallogin; bm `/oauth` reserved from the SPA fallback; fonoteka green at the new pin | `TestBootAllPlugins`, `TestSocialLoginRedirect`, `TestSocialLoginCoexistsWithOAuthServer` (D); `TestSocialLoginMounted`, `TestStaticReservedPaths` (B); fonoteka short suite (A) | `--demo`, `--bm`, `--fonoteka` | ✅ green |
|
||||
| D-26 | a verified email auto-links only onto an account with recorded proof; `/register` auto and admin modes, admin create and Activate, social sign-in and legacy accounts go pending; mail activation and the reset code link | `TestOwnerVerifiedEmail` (8 subtests), `TestPreAccountTakeover` (S); `TestEmailVerifiedAt`, `TestAdminEmailVerifiedAt` (U) | `--docker` | ✅ green |
|
||||
| D-27 | `users.email_verified_at` additive (nullable column, no backfill, no-op rollback), two writers, cleared on an email change, not fillable, not in any payload; READMEs and the security review describe proof as `email_verified_at` | `TestEmailVerifiedAtMigration`, `TestCreateOrganisationsMigration`, `TestEmailVerifiedAt`, `TestAdminEmailVerifiedAt` (U); `TestPHPMethodMap` (S); migration allow-list and additive check (F) | `--userplugin`, `--sociallogin`, `--docker` | ✅ green |
|
||||
| D-28 | the bm callback view drops `?code=` before the single redeem, never redeems twice, shows `?error=`, replaces to `oauth-register` for a pending registration, explains an expired code; no new npm dependency | vitest `src/views/OAuthCallbackView.test.ts` (B) | `--bm` | ✅ green |
|
||||
|
||||
*Status: ⬜ pending · ✅ green · ❌ red · ⚠️ flaky*
|
||||
|
||||
@@ -67,10 +104,10 @@ Filled in by the planner and executor. Decision → test map from RESEARCH.md §
|
||||
|
||||
## Wave 0 Requirements
|
||||
|
||||
- [ ] `sm-sociallogin-plugin/testmain_test.go`, `harness_test.go` (Postgres harness booting `golem15.user` + `golem15.sociallogin`, memory cooler)
|
||||
- [ ] Fake `Provider` test double + `http.RoundTripper` fake for the goth wrappers
|
||||
- [ ] `modules/cooler` Pull tests (memory, miniredis, docker Redis)
|
||||
- [ ] User plugin unlink race test with two explicit transactions
|
||||
- [x] `sm-sociallogin-plugin/testmain_test.go`, `harness_test.go` (Postgres harness booting `golem15.user` + `golem15.sociallogin`, memory cooler)
|
||||
- [x] Fake `Provider` test double + `http.RoundTripper` fake for the goth wrappers
|
||||
- [x] `modules/cooler` Pull tests (memory, miniredis, docker Redis)
|
||||
- [x] User plugin unlink race test with two explicit transactions
|
||||
|
||||
---
|
||||
|
||||
@@ -80,15 +117,17 @@ Filled in by the planner and executor. Decision → test map from RESEARCH.md §
|
||||
|----------|-------------|------------|-------------------|
|
||||
| Live sign-in with Google, Facebook, GitHub and Microsoft | D-04, D-21 | Needs real provider app registrations and secrets | Configure credentials in sm-demo-app (and Entra in sm-bm-app), sign in through the SPA, confirm the callback lands and the identity is listed |
|
||||
|
||||
The bm callback view's history handling (the code leaves the URL before the single redeem) was a manual check until plan 05; it is now automated by `src/views/OAuthCallbackView.test.ts` (D-28, row 19.5-05-04).
|
||||
|
||||
---
|
||||
|
||||
## Validation Sign-Off
|
||||
|
||||
- [ ] All tasks have `<automated>` verify or Wave 0 dependencies
|
||||
- [ ] Sampling continuity: no 3 consecutive tasks without automated verify
|
||||
- [ ] Wave 0 covers all MISSING references
|
||||
- [ ] No watch-mode flags
|
||||
- [ ] Feedback latency < 60s
|
||||
- [ ] `nyquist_compliant: true` set in frontmatter
|
||||
- [x] All tasks have `<automated>` verify or Wave 0 dependencies
|
||||
- [x] Sampling continuity: no 3 consecutive tasks without automated verify
|
||||
- [x] Wave 0 covers all MISSING references
|
||||
- [x] No watch-mode flags
|
||||
- [x] Feedback latency < 60s
|
||||
- [x] `nyquist_compliant: true` set in frontmatter
|
||||
|
||||
**Approval:** pending
|
||||
**Approval:** validated 2026-10-21 (plan 19.5-04; plan 19.5-05 rows added the same day); rows 19.5-04-06 and -07 and 19.5-05-06 and -07 are remote operations that follow their plan's Task 6 reply.
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
---
|
||||
phase: 19.5-social-login-in-sm-user-plugin
|
||||
verified: 2026-10-21T12:59:50Z
|
||||
status: passed
|
||||
score: 23/23 must-haves verified
|
||||
covered_files:
|
||||
- .planning/phases/19.5-social-login-in-sm-user-plugin/19.5-01-PLAN.md
|
||||
- .planning/phases/19.5-social-login-in-sm-user-plugin/19.5-01-SUMMARY.md
|
||||
- .planning/phases/19.5-social-login-in-sm-user-plugin/19.5-02-PLAN.md
|
||||
- .planning/phases/19.5-social-login-in-sm-user-plugin/19.5-02-SUMMARY.md
|
||||
- .planning/phases/19.5-social-login-in-sm-user-plugin/19.5-03-PLAN.md
|
||||
- .planning/phases/19.5-social-login-in-sm-user-plugin/19.5-03-SUMMARY.md
|
||||
- .planning/phases/19.5-social-login-in-sm-user-plugin/19.5-04-PLAN.md
|
||||
- .planning/phases/19.5-social-login-in-sm-user-plugin/19.5-04-SUMMARY.md
|
||||
- .planning/phases/19.5-social-login-in-sm-user-plugin/19.5-05-PLAN.md
|
||||
- .planning/phases/19.5-social-login-in-sm-user-plugin/19.5-05-SUMMARY.md
|
||||
- docs/services/caching.md
|
||||
- modules/cooler/README.md
|
||||
- modules/cooler/cooler.go
|
||||
- modules/cooler/docker_test.go
|
||||
- modules/cooler/example_test.go
|
||||
- modules/cooler/memory.go
|
||||
- modules/cooler/pull_smoke_test.go
|
||||
- modules/cooler/pull_test.go
|
||||
- modules/cooler/rediscache.go
|
||||
- scripts/check-phase19.5.sh
|
||||
covered_digest: "v2:sha256:73370391391a90bf632aabe78b77fb693a74535477d0245c0a2f279c06a698fa"
|
||||
# Files outside this repository are pinned by commit instead of digest:
|
||||
# sm-user-plugin 35cf166 (local, unpushed; released v0.1.4 = c43ba87), sm-sociallogin-plugin c4ebf05 (local, unpushed;
|
||||
# released v0.1.1 = d7ee28d), sm-bm-app 5fd0cc3 (local, unpushed), sm-demo-app b325460 (foreign 19.8 commit on 61a00e9),
|
||||
# fonoteka.go f4d5728 (worktree plugins/golem15/user at 35cf166, unstaged).
|
||||
# RELEASE HOLD (user decision at 19.5-05 Task 6): the D-26/D-27 fix is not in any app pin. sm-demo-app and sm-bm-app
|
||||
# still run sm-user-plugin v0.1.4 + sm-sociallogin-plugin v0.1.1, i.e. the password-registration takeover path stays
|
||||
# open in the deployed apps until the release in .planning/todos/pending/email-proof-release.md (g15office sqnwn1zi).
|
||||
behavior_unverified: 0
|
||||
overrides_applied: 0
|
||||
re_verification:
|
||||
previous_status: human_needed
|
||||
previous_score: 15/16
|
||||
gaps_closed:
|
||||
- "Truth 16 (bm OAuthCallbackView: code dropped before the single redeem, pending and error routing): was PRESENT_BEHAVIOR_UNVERIFIED, now VERIFIED by src/views/OAuthCallbackView.test.ts (D-28)"
|
||||
- "Human item 1 (password-registration pre-account takeover): user chose (a) tighten auto-link; implemented as users.email_verified_at (D-26, D-27) and verified below"
|
||||
gaps_remaining: []
|
||||
regressions: []
|
||||
human_verified:
|
||||
- test: "Decide on the residual pre-account-takeover path left open by the T-19.5-38 fix. ownerVerifiedEmail treats has_self_set_password=true as proof the owner controls the address, but POST /register with golem15.user activation.activate_mode=auto (the default) creates an activated account with has_self_set_password=true and no email check. An attacker who registers victim@example.com with a password before the victim's first Google/GitHub sign-in gets the victim's verified identity auto-linked onto the attacker's account, and keeps password access."
|
||||
uat_row: 1
|
||||
recorded_at: "2026-10-21T12:54:14.000Z"
|
||||
human_verification:
|
||||
- test: "Review the judgment-tier prohibition verdicts in the 'Prohibitions' section of this report (non-authoritative LLM-judge verdicts)."
|
||||
expected: "Confirm or dispute each verdict, including the four 19.5-05 prohibitions added in this re-verification; all are recorded as no violation found."
|
||||
why_human: "Judgment-tier prohibitions never pass silently (ADR-550 D4)."
|
||||
- test: "Decide on 19.5-REVIEW CR-02: an account pre-created by sociallogin from an unverified provider email (created activated, S classes/registration.go) keeps the attacker's Microsoft or Facebook identity after the real owner proves the address with a reset-code password reset."
|
||||
expected: "Either accept it (PHP parity on account creation) and correct sm-sociallogin-plugin README.md:50, which offers 'proves the address with a password reset' as the recovery; or schedule the follow-up the review proposes (create the account inactive unless p.EmailVerified, and/or drop unverified identities when the reset stamps email_verified_at)."
|
||||
why_human: "Open high-severity review finding with no user decision; not covered by D-26..D-28. Since D-26 makes the reset the route to auto-linking, the documented recovery hands the owner an account that still carries the attacker's identity. bm runs Microsoft with tenant common."
|
||||
---
|
||||
|
||||
# Phase 19.5: Social login in sm-user-plugin Verification Report
|
||||
|
||||
**Phase Goal:** `sm-user-plugin` signs users in with Google and Facebook through `/oauth/{provider}` and its callback, with a provider registry open to more providers, the SPA one-time-code handoff and the pending-registration branch, matching the PHP user plugin's contract. Per CONTEXT D-01 the code lives in the new `sm-sociallogin-plugin`, with hooks in sm-user-plugin. The 19.5-05 gap closure adds D-26..D-28.
|
||||
**Verified:** 2026-10-21T12:59:50Z
|
||||
**Status:** human_needed
|
||||
**Re-verification:** Yes, after gap-closure plan 19.5-05 (previous: human_needed, 15/16)
|
||||
|
||||
**Release state.** The user answered the 19.5-05 Task 6 checkpoint with **hold**. I confirmed it in the repositories. The unpushed commits are sm-user-plugin `add1f7a`, `a99cb46`, `35cf166`; sm-sociallogin-plugin `3bb5650`, `962190b`, `c4ebf05`; sm-bm-app `5fd0cc3`. Neither `v0.1.5` (user) nor a new sociallogin tag exists on the remotes. The app gitlinks are unchanged: sm-demo-app and sm-bm-app `plugins/golem15/user` = c43ba87 (v0.1.4) and `plugins/golem15/sociallogin` = d7ee28d (v0.1.1); the fonoteka.go gitlink is c43ba87. sm-sociallogin-plugin go.mod still requires sm-user-plugin v0.1.4. I count this as a recorded user decision, and plan 05's must-have has an explicit hold branch for it. It is not a code gap. **But the deployed sm-demo-app and sm-bm-app keep the password-registration pre-account-takeover path (CR-01 / T-19.5-39) until the release in `.planning/todos/pending/email-proof-release.md` (g15office sqnwn1zi) ships.** The demo and bm stages of the gate ran at the old pins. Plan 05 Task 7's check at the new pins has not run.
|
||||
|
||||
**MVP-mode note:** As in the previous report, the ROADMAP goal is not a user story. All five plans recorded the decision not to invent one, so this report runs standard goal-backward verification against SC-1..SC-4, D-01..D-28 and the plan must-haves.
|
||||
|
||||
## Goal Achievement
|
||||
|
||||
### Observable Truths
|
||||
|
||||
Truths 1-15 come from the previous report and got a regression check here: the full gate, plus spot reads of the code paths 19.5-05 touched. Truth 16 was rerun in full. Truths 17-23 are plan 19.5-05's must-haves, and each was verified at all three levels.
|
||||
|
||||
| # | Truth | Status | Evidence |
|
||||
|---|-------|--------|----------|
|
||||
| 1 | SC-1: `/oauth/{google,facebook,github,microsoft}` and `/callback` keep the PHP paths with `throttle:20,1` | VERIFIED (regression) | Gate `--sociallogin` and `--docker`: `TestRoutes`, `TestRouteMiddleware`, `TestTracerSmoke`; `--demo` `TestSocialLoginRedirect`; `--bm` `TestSocialLoginMounted`. All PASS in my run. |
|
||||
| 2 | SC-2a: SPA one-time code, `oauth-complete` exchanges once (200 / 422 / 410) | VERIFIED (regression) | `TestTracerSmoke`, `TestOAuthComplete`, `TestCodes` in the 29 Docker sociallogin tests (PASS). |
|
||||
| 3 | SC-2b: pending registration via `?pending_registration=`, `oauth-register-complete` with PHP fields, 422 does not consume the code | VERIFIED (regression) | `TestFlowSmoke`, `TestRegisterComplete`, `TestPHPPendingRegistrationTakeover` (PASS). |
|
||||
| 4 | SC-3a: identities linked on login (identity match, verified owner-proved email match, `action=link`) | VERIFIED | Matching now also requires owner proof (truth 17). `TestMatching`, `TestLink`, `TestLinkTicket` PASS. The harness's `e.user` carries proof, so link-path tests still exercise linking, and `e.unprovenUser` models accounts without it. |
|
||||
| 5 | SC-3b: list and unlink via host-mounted identity routes, PHP unlink rule, race-safe | VERIFIED (regression) | U `controllers/oauth_identities.go` unchanged by 05 (`git diff c7635c5 --stat` does not list it); `TestOAuthIdentitiesDestroyConcurrentSmoke` in the 12 Docker user tests PASS. |
|
||||
| 6 | SC-3c: the consuming applications still build and pass their tests | VERIFIED (at current pins) | `--demo` (build, vet, 3 tests), `--bm` (build, vet, 1+2 Go tests, vitest 216/216, typecheck), `--fonoteka` (vet and short suite with the user plugin worktree at 35cf166) all PASS. The apps at the *new* pins are not tested (release on hold). |
|
||||
| 7 | SC-4: the new code has unit tests in the last plan | VERIFIED | 19.5-04 delivered the phase suite. 19.5-05, the gap closure and now the last plan, adds `TestOwnerVerifiedEmail` (8), `TestEmailVerifiedAt` (12), `TestAdminEmailVerifiedAt` (6), `TestEmailVerifiedAtMigration` (5) and the 5-case view test. All are required by name in the gate and all PASS. |
|
||||
| 8 | D-03 provider registry open, no goth type exported | VERIFIED (regression) | Gate deps check: "goth only in classes/providers_goth.go"; `TestRegistry` PASS. |
|
||||
| 9 | D-01/D-02 separate plugin, goth v1.82.0 without gothic, no gorilla/sessions, framework go.mod untouched by this phase | VERIFIED | Deps check PASS. The framework go.mod's latest commits (e903cc3, 8d4d7ce, 3f38862) belong to phase 19.8 (diary, bonfire), not 19.5. |
|
||||
| 10 | D-07 sealed state cookie | VERIFIED (regression) | `TestStateCookie` PASS. |
|
||||
| 11 | D-08 atomic `cooler.Cache.Pull`; plugin pins a release with it | VERIFIED (regression) | Covered files are unchanged; `TestPullConcurrent`, `TestPullDocker` PASS. |
|
||||
| 12 | D-10/D-11 matching table, Google flag, GitHub `/user/emails`, Facebook and Microsoft never verified (nOAuth); the owner side is now recorded proof (D-26) | VERIFIED | S `classes/flow.go:98` (`HandleCallback`) and `:203` (`CompletePending`) both gate on `profile.EmailVerified && ownerVerifiedEmail(match)`; `ownerVerifiedEmail` at `:266` is `u != nil && u.EmailVerifiedAt != nil`; `HasSelfSetPassword` is no longer read in S non-test code (grep). `TestMatching`, `TestEmailVerified`, `TestNOAuth`, `TestPreAccountTakeover` PASS. |
|
||||
| 13 | D-15/D-22 `oauth-providers` stays in sm-user-plugin; D-22's no-migration rule now relaxed only by D-27 | VERIFIED | Gate additive check: "no exported func, method or type removed and no migration changed beyond the allowed additions in the user plugin since 4c5d3b8 (13 declarations added; 1 allowed migration(s) added, column only)". `TestOAuthProvidersSourceSmoke`, `TestOAuthProviders` PASS. |
|
||||
| 14 | D-16/D-18 credentials and settings overlay | VERIFIED (regression) | `TestSettings`, `TestSettingsOverrideSmoke` PASS. |
|
||||
| 15 | D-21/D-23 sm-demo-app and sm-bm-app mount sociallogin v0.1.1; bm links Microsoft via the link ticket | VERIFIED (released state) | Gitlinks: sociallogin d7ee28d and user c43ba87 in both apps; `TestLinkTicket` PASS. Remotes are no longer in sync: S, U and B each carry the unpushed 19.5-05 commits (hold), and sm-demo-app carries foreign `b325460` (19.8). |
|
||||
| 16 | D-28 / T-19.5-26: the bm callback view removes `?code=` before the single redeem, never redeems twice, shows `?error=`, replaces to `oauth-register` for pending; no new npm dependency | VERIFIED (behavioral test) | `sm-bm-app/frontend/src/views/OAuthCallbackView.test.ts` mounts the real `<script setup>` module (module-level `redeemed` set included) on a `createMemoryHistory` router. It asserts that the route at the moment `completeOAuth` starts has no `code` and fullPath `/auth/oauth/callback`, that `replace` comes before `completeOAuth` in `invocationCallOrder`, and that Back lands on `/`. Remounting with the same code gives no second call; the alert text equals the `?error=`; pending gives `oauth-register` with `{pending, provider, reason, email}` and Back lands on `/`; a 410 shows the expiry message. I ran it: 5/5 PASS, `oauth.test.ts` 10/10. The gate's `--bm` stage requires both files (`REQUIRE_FILES`). The last commit touching `package.json`/`package-lock.json` is `fe967c4`, from before the phase. |
|
||||
| 17 | D-26: `/register` in `activate_mode=auto` then a verified Google sign-in for the same email goes pending `reason=unverified_match`, `provider=google`, links nothing; `oauth-register-complete` answers 422 `email_exists_link_account` and keeps the code | VERIFIED | `owner_verified_test.go:173` drives the real `/register`, asserts the account is activated with `EmailVerifiedAt == nil`, then signs in with an uppercase email, asserts pending with no link, posts the completion (422 + `linkMessage`), and checks `pendingExists` and that the identity has no owner. It also covers `action=register`. `TestOwnerVerifiedEmail` PASS in my Docker run. SUMMARY records RED against the old rule. |
|
||||
| 18 | D-26: a mail-activated (activate / activate-by-code) or reset-code-reset account receives the verified identity and is signed in | VERIFIED | U `classes/codes.go:133-145`: `VerifyActivationCode` sets `email_verified_at` in the same `UpdateColumns` that activates (callers `api_controller.go:369` `/activate`, `:420` `/activate-by-code`). U `controllers/api_controller.go:336-344`: `ResetPassword` sets it in the `Updates` that stores the new password, after `VerifyResetCode` and hashing. A failed reset writes nothing. `TestOwnerVerifiedEmail/mail_activated_by_code_links`, `reset_code_password_links`, and `TestPreAccountTakeover/after_mailbox_proof_links` (real `/forgot-password` + `/reset-password`) PASS. |
|
||||
| 19 | D-26: accounts without proof go pending: `/register` auto/admin, admin create and Activate action, social-created accounts, pre-v0.1.5 accounts; nothing backfills | VERIFIED | `classes.ActivateUsers` (`admin_actions.go:64-90`) writes no `email_verified_at`; the S sources write nothing (grep); the migration has no UPDATE. Tests: `TestOwnerVerifiedEmail/legacy_account_pending`, `register_action_pending`, `admin_activation_not_proof`, `unverified_provider_never_links_proven_account`; `TestEmailVerifiedAt/register_auto_no_proof`, `register_admin_mode_no_proof`, `register_ignores_submitted_proof`, `change_password_no_proof`; `TestAdminEmailVerifiedAt/admin_create_no_proof`, `admin_activate_action_no_proof`; `TestEmailVerifiedAtMigration/no_backfill`. All PASS. |
|
||||
| 20 | D-27: `users.email_verified_at` nullable TIMESTAMPTZ via migration `202610210001_add_users_email_verified_at` (ADD COLUMN IF NOT EXISTS, no rows, no-op rollback); two writers; cleared on email change through `/update` and the admin form (case-only change keeps it); not fillable, in no payload, not in admin YAML; no route, payload or config key change | VERIFIED | Migration file: exactly that SQL, registered once (`history_once`). `models/user.go:50`: `gorm:"column:email_verified_at" json:"-"`, not in `Fillable()` (`models/user.go:88`). No YAML under the plugin mentions it (grep). `api_controller.go:483-496` and `users_admin_controller.go:229-236` clear on `!strings.EqualFold`. Tests: `update_email_change_clears`, `update_same_email_keeps`, `update_ignores_submitted_proof`, `admin_email_change_clears`, `admin_save_keeps_proof`, `admin_json_hides_proof`, `column_shape`, `rollback_keeps_column`, `existing_column_kept` all PASS. The gate's migration allow-list accepts only this file (`refused_migration_changes`, self-tested). |
|
||||
| 21 | D-27 wording: the READMEs, the PHP-map row and 19.5-SECURITY-REVIEW describe proof as `email_verified_at`; none says a self-set password proves the address | VERIFIED | U `README.md:34` and S `README.md:49-55,145` ("a self-set password is not proof"); `PHP_METHOD_MAP.md:26`; SECURITY-REVIEW T-19.5-38/-39/-42. The only remaining "self-set password" mentions concern the unlink rule (D-13), which is correct. |
|
||||
| 22 | The gate fails closed on the new tests and the migration rules; `PHASE195_REQUIRE_DOCKER=1 --all` passes | VERIFIED | `scripts/check-phase19.5.sh`: `USER_ADDED_MIGRATIONS` (`:82`), `refused_migration_changes` (`:378`), `migration_writes_rows` (`:398`), and self-tests for both (`:487-512`); `DOCKER_USER_UPDATES_TESTS` (`:145`); `REQUIRE_FILES` with `missing-view-file` (`:434-439`, `:638`). My run of `PHASE195_REQUIRE_DOCKER=1 TESTCONTAINERS_HOST_OVERRIDE=127.0.0.1 bash scripts/check-phase19.5.sh --all` exited 0. All nine stages PASS, and every new test appears by name in a passed run. |
|
||||
| 23 | SC-3/D-21 release, hold branch: nothing pushed or tagged, a todo with a g15office task tracks the release | VERIFIED (hold branch) | `ls-remote --tags`: user tags end at v0.1.4, sociallogin tags at v0.1.1. No pin moved, and nothing is staged in fonoteka.go. The todo `email-proof-release.md` exists, carries `g15office: sqnwn1zi` (e819f7b) and the full D-21 step list. **The release-branch half of this truth (tags, pin bumps, gate at new pins) has not happened by user decision.** |
|
||||
|
||||
**Score:** 23/23 truths verified (0 present, behavior-unverified)
|
||||
|
||||
### Required Artifacts (19.5-05)
|
||||
|
||||
| Artifact | Expected | Status | Details |
|
||||
|----------|----------|--------|---------|
|
||||
| U `updates/202610210001_add_users_email_verified_at.go` | `ADD COLUMN IF NOT EXISTS email_verified_at` | VERIFIED | 33 lines, registered via `init()`, wired into `All()` (`TestCreateOrganisationsMigration` expects it last) |
|
||||
| U `models/user.go` | `gorm:"column:email_verified_at" json:"-"` | VERIFIED | Read by S `flow.go:267` |
|
||||
| U `email_verified_test.go` | `TestEmailVerifiedAt`, `TestAdminEmailVerifiedAt` | VERIFIED | 377 lines; run by name in `--docker` |
|
||||
| U `updates/email_verified_at_test.go` | `TestEmailVerifiedAtMigration` | VERIFIED | 5 subtests; run by name |
|
||||
| S `classes/flow.go` | `EmailVerifiedAt != nil` | VERIFIED | Used in both match sites |
|
||||
| S `owner_verified_test.go` | `register_auto_then_google_pending` | VERIFIED | 8 subtests driving real user-plugin routes |
|
||||
| B `frontend/src/views/OAuthCallbackView.test.ts` | `completeOAuth` | VERIFIED | 5 cases; required by the gate |
|
||||
| `scripts/check-phase19.5.sh` | `USER_ADDED_MIGRATIONS` | VERIFIED | Self-test plus `--all` exit 0 |
|
||||
|
||||
### Key Link Verification (19.5-05)
|
||||
|
||||
| From | To | Via | Status |
|
||||
|------|----|-----|--------|
|
||||
| U `classes/codes.go` `VerifyActivationCode` | `users.email_verified_at` | same `UpdateColumns` as `is_activated` | WIRED |
|
||||
| U `controllers/api_controller.go` `ResetPassword` / `Update` | `users.email_verified_at` | stamped with the new password; cleared on a changed email | WIRED |
|
||||
| U `users_admin_controller.go` `FormBeforeUpdate` | `users.email_verified_at` | `user.EmailVerifiedAt = nil`, persisted by cabana Save (`admin_email_change_clears` PASS, RED recorded) | WIRED |
|
||||
| S `classes/flow.go` | U `models.User.EmailVerifiedAt` | `ownerVerifiedEmail` in `HandleCallback` and `CompletePending`; `userByEmail` loads the full row (no `Select`) | WIRED |
|
||||
| `scripts/check-phase19.5.sh` | B `OAuthCallbackView.test.ts` | `REQUIRE_FILES` in the bm stage | WIRED |
|
||||
| S `go.mod` | sm-user-plugin v0.1.5 | Task 7 require | NOT YET (hold); S builds against the workspace checkout through go.work, while go.mod still says v0.1.4 |
|
||||
|
||||
### Data-Flow Trace (Level 4)
|
||||
|
||||
| Artifact | Data | Source | Real data | Status |
|
||||
|----------|------|--------|-----------|--------|
|
||||
| auto-link decision | `match.EmailVerifiedAt` | `userByEmail` → Postgres `users.email_verified_at`, written only by the mail activation code and the reset-password write | yes | FLOWING |
|
||||
| bm callback view | `code`, `error`, `pending_registration` | `route.query` → `parseOAuthCallback` → `auth.completeOAuth` (stubbed in the view test; real store → `userApi.oauthComplete`, as in the previous report) | yes | FLOWING |
|
||||
|
||||
### Behavioral Spot-Checks
|
||||
|
||||
| Behavior | Command | Result | Status |
|
||||
|----------|---------|--------|--------|
|
||||
| Docker loopback usable | `TESTCONTAINERS_HOST_OVERRIDE=127.0.0.1 go test -short -run '^TestPermissionSetScan$' ./plugins/golem15/user/classes` (fonoteka.go) | ok 4.66 s (the VPN did not block it) | PASS |
|
||||
| Whole phase gate, Docker required | `PHASE195_REQUIRE_DOCKER=1 TESTCONTAINERS_HOST_OVERRIDE=127.0.0.1 bash scripts/check-phase19.5.sh --all` | exit 0; self-test, go (4), userplugin (2 + additive/migration check), sociallogin (9), demo (3), bm (1+2, vitest 216/216, typecheck), fonoteka, docs, docker (cooler 1, user 12, user updates 2, sociallogin 29) | PASS |
|
||||
| bm view test directly | `npx vitest run src/views/OAuthCallbackView.test.ts src/lib/oauth.test.ts` | 15/15 | PASS |
|
||||
| Hold intact | `git ls-remote --tags origin` in U and S; `git ls-tree HEAD` app gitlinks | no v0.1.5 or new sociallogin tag; pins c43ba87 / d7ee28d | PASS |
|
||||
|
||||
### Probe Execution
|
||||
|
||||
| Probe | Command | Result | Status |
|
||||
|-------|---------|--------|--------|
|
||||
| (none declared; no `scripts/*/tests/probe-*.sh`) | n/a | the phase gate above plays this role | SKIPPED |
|
||||
|
||||
### Requirements Coverage
|
||||
|
||||
No REQUIREMENTS.md IDs. Plan 05 declares D-26, D-27, D-28 and SC-3. The previous report covered SC-1..SC-4 and D-01..D-25, and every one is still satisfied (regression above).
|
||||
|
||||
| Requirement | Source Plan | Status | Evidence |
|
||||
|-------------|-------------|--------|----------|
|
||||
| D-26 | 05 | SATISFIED (in local commits) | Truths 12, 17-19 |
|
||||
| D-27 | 05 | SATISFIED (in local commits; release pending per hold) | Truths 13, 20, 21; the "ships as v0.1.5 / v0.1.2 / pointer bumps" part is the held release (truth 23) |
|
||||
| D-28 | 05 | SATISFIED | Truth 16 |
|
||||
| SC-3 | 01-05 | SATISFIED at current pins | Truths 4-6; the D-26 fix is not in the apps until the release |
|
||||
|
||||
No orphaned requirements.
|
||||
|
||||
### Prohibitions (judgment tier; non-authoritative LLM-judge verdicts, unverified-prohibition: human review recommended)
|
||||
|
||||
The verdicts for plans 01-04 are unchanged from the previous report (all "no violation found"), with one update: the framework go.mod has new commits, but they are 19.8's (e903cc3, 8d4d7ce, 3f38862), not 19.5's. Plan 05 adds four:
|
||||
|
||||
| Plan | Prohibition | Judge verdict | Evidence |
|
||||
|------|-------------|---------------|----------|
|
||||
| 05 | sm-user-plugin stays additive: no route, payload key, config key, existing migration file or ID changes; the new migration adds a nullable column and writes no rows | no violation found | `git diff c7635c5 -- updates/` lists only the new migration and its test, plus the history list in `organisations_test.go`. The gate's additive and migration checks pass. `json:"-"`; no YAML hit. |
|
||||
| 05 | Proof is recorded only by the mailed activation code and the completed reset-code reset | no violation found | grep: the only writers are `codes.go:139` and `api_controller.go:339`; `ActivateUsers`, `Register`, `ChangePassword` and S write nothing. The non-writer tests pass. |
|
||||
| 05 | No new npm dependency in sm-bm-app/frontend and no new Go module anywhere | no violation found | `package.json`/lockfile last touched in `fe967c4` (before the phase). U and S go.mod are unchanged since their release tags (S `git diff d7ee28d --stat` does not list go.mod). |
|
||||
| 05 | Nothing pushed or tagged before the Task 6 reply; no force-push or deploy; fonoteka.go and summercms.go never pushed; only `plugins/golem15/user` staged in fonoteka.go | no violation found | Remote tags unchanged. The unpushed ranges hold the 05 commits. fonoteka.go has nothing staged; the user gitlink is modified but unstaged, and feedback/golem belong to other sessions. |
|
||||
|
||||
### Anti-Patterns Found
|
||||
|
||||
| File | Line | Pattern | Severity | Impact |
|
||||
|------|------|---------|----------|--------|
|
||||
| (all 05-changed files in U, S, B and the gate) | | No TBD, FIXME, XXX, TODO, HACK or placeholder markers | | |
|
||||
| S `README.md` | 50 | Offers "proves the address with a password reset" as the recovery for an unproven match. Per CR-02, a reset does not remove a pre-planted unverified identity on a social-created account | Warning | Recovery advice overstates the result for that case; see Human Verification 2 |
|
||||
| `19.5-VALIDATION.md` | 72-73 | Rows 19.5-05-06/-07 still read "awaiting the Task 6 reply"; the reply was "hold" | Info | Stale status text; the todo tracks the release |
|
||||
| `sm-bm-app/sm-bm-app` | | 70 MB untracked binary left in the bm worktree by the gate's `go build ./...` (single main package writes its binary); not gitignored | Info | Risk of an accidental commit; the gate could build with `-o /dev/null` or into a temp dir |
|
||||
| B `OAuthCallbackView.test.ts` | | Compiles the view's template at test time and uses memory history with stubbed stores | Info | The `<script setup>` ordering invariant is tested on the real module. Real `window.history` and the build-compiled template are not, which is acceptable for D-28 as written |
|
||||
|
||||
### Human Verification Required
|
||||
|
||||
UAT row 1 (the takeover decision) passed on 2026-10-21 with decision (a), and its implementation is verified above (truths 17-21), so it is recorded under `human_verified`. A behavioral test now covers UAT row 2 (bm callback view in a browser): truth 16 is VERIFIED, so this report does not ask for it again. The orchestrator may mark row 2 as superseded by `OAuthCallbackView.test.ts`.
|
||||
|
||||
#### 1. Prohibition verdicts (UAT row 3)
|
||||
|
||||
**Test:** Review the judgment-tier prohibition verdicts in the 'Prohibitions' section of this report (non-authoritative LLM-judge verdicts).
|
||||
**Expected:** Confirm or dispute each verdict, including the four new 19.5-05 rows.
|
||||
**Why human:** Judgment-tier prohibitions need human sign-off.
|
||||
|
||||
#### 2. CR-02 decision (new)
|
||||
|
||||
**Test:** Read 19.5-REVIEW CR-02 and 19.5-SECURITY-REVIEW "Open findings".
|
||||
**Expected:** Either accept the behaviour and correct the S README recovery wording, or schedule the follow-up (inactive account unless `p.EmailVerified`, or removal of unverified identities when a reset stamps the proof).
|
||||
**Why human:** This is a security and product decision on an open high-severity finding outside D-26..D-28.
|
||||
|
||||
### Gaps Summary
|
||||
|
||||
No blocking gaps in code. D-26, D-27 and D-28 are implemented as specified. The proof column has exactly two writers and two clearers; the sociallogin matcher reads it at both match sites; nothing backfills it; the bm callback view's ordering invariant is pinned by a passing test. I re-ran the full fail-closed gate with Docker required, and it exited 0 with every new test passing by name.
|
||||
|
||||
What is not done is deliberate: the **release is on hold** by user decision. sm-user-plugin v0.1.5, sm-sociallogin-plugin v0.1.2 and the app pin bumps wait in `email-proof-release.md` (g15office sqnwn1zi) for the 19.8 session. Until then, sm-demo-app and sm-bm-app run v0.1.4/v0.1.1, where an auto-mode `/register` account squatting an address still receives the owner's verified Google or GitHub identity.
|
||||
|
||||
Two items remain for a human: sign-off on the judgment-tier prohibitions, and a decision on CR-02.
|
||||
|
||||
---
|
||||
|
||||
_Verified: 2026-10-21T12:59:50Z_
|
||||
_Verifier: Claude (msd-verifier)_
|
||||
@@ -0,0 +1,14 @@
|
||||
# Deferred items (Phase 19.5)
|
||||
|
||||
Out-of-scope discoveries logged by executors; not fixed in this phase.
|
||||
|
||||
## From plan 19.5-03
|
||||
|
||||
- **fonoteka.go `TestPhase09SecurityRoutes` (plugins/golem15/fonoteka/admin_phase09_security_test.go:52) fails in the Docker-backed run.** The admin route inventory does not list five `summercms.cabana` routes the framework working tree now mounts: `GET`/`PUT /plytadmin/api/v1/account/preferences`, `GET`/`POST /plytadmin/api/v1/{vendor}/{plugin}/{controller}/reorder` and `GET /plytadmin/api/v1/{vendor}/{plugin}/{controller}/singleton`. Every reported route belongs to cabana, none to golem15.user, so the sm-user-plugin bump does not cause it; the inventory needs the framework's newer admin routes (the pattern of fonoteka's earlier "list the post-Phase 9 cabana admin routes" commit).
|
||||
|
||||
## From plan 19.5-05
|
||||
|
||||
- **sm-user-plugin `updates/postgres_test.go` `testShort` scans `os.Args` for `-test.short`.** `go test` passes `-test.short=true`, so `go test -short ./plugins/golem15/user/...` still starts the Postgres container for the `updates` package (the pitfall CLAUDE.md names). Pre-existing, not caused by plan 05; the fix is `flag.Parse()` and `testing.Short()` in its `TestMain`.
|
||||
- **Docker host: published container ports do not answer on `[::1]`.** On this host testcontainers resolves `localhost`, pgx dials `[::1]` first and times out, and every Docker-backed suite fails with `postgres unavailable`. Plan 05 ran the Docker stages with `TESTCONTAINERS_HOST_OVERRIDE=127.0.0.1`; Task 5's `--all` needs the same until the host's IPv6 loopback forwarding is fixed.
|
||||
- **sm-user-plugin `classes.TestPermissionSetScan` starts Postgres under `-short`.** It calls `throttleDB` without a `testing.Short()` skip, so the gate's `--userplugin` short run needs a working Docker host; when loopback to the container is blocked the package hangs until `go test`'s 10-minute timeout. Pre-existing, not caused by plan 05.
|
||||
- **NordLayer VPN drops loopback traffic to container ports.** With the VPN connected, testcontainers Postgres times out on `127.0.0.1` too. The user's workaround is `sudo nft insert rule inet nordlayer input iifname "lo" accept` and `sudo nft insert rule inet nordlayer output oifname "lo" accept`; both rules disappear when the VPN reconnects.
|
||||
362
.planning/phases/19.8-console-commands-overhaul/19.8-01-PLAN.md
Normal file
362
.planning/phases/19.8-console-commands-overhaul/19.8-01-PLAN.md
Normal file
@@ -0,0 +1,362 @@
|
||||
---
|
||||
phase: 19.8-console-commands-overhaul
|
||||
plan: 01
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
files_modified:
|
||||
- .planning/notes/2026-10-21-console-charm-stack.md
|
||||
- .planning/research/STACK.md
|
||||
- .gitignore
|
||||
- go.mod
|
||||
- go.sum
|
||||
- README.md
|
||||
- modules/bonfire/styles.go
|
||||
- modules/bonfire/README.md
|
||||
- modules/diary/diary.go
|
||||
- modules/diary/config.go
|
||||
- modules/diary/component.go
|
||||
- modules/diary/console.go
|
||||
- modules/diary/daily.go
|
||||
- modules/diary/channels.go
|
||||
- modules/diary/tracer_smoke_test.go
|
||||
- modules/diary/config_smoke_test.go
|
||||
- modules/diary/README.md
|
||||
- internal/build/build.go
|
||||
- internal/build/build_test.go
|
||||
- internal/build/new.go
|
||||
- internal/build/stubs/new.tmpl
|
||||
- internal/build/testdata/new/main.go.golden
|
||||
- internal/build/testdata/new/config__log.yaml.golden
|
||||
- internal/build/testdata/new/CLAUDE.md.golden
|
||||
- internal/build/testdata/new-no-plugin/CLAUDE.md.golden
|
||||
- examples/hello/main.go
|
||||
- examples/hello/go.sum
|
||||
- examples/hello/plugins/greeter/go.sum
|
||||
- docs/services/logging.md
|
||||
- docs/setup/configuration.md
|
||||
- docs/services/configuration.md
|
||||
- modules/sunscreen/sunscreen.go
|
||||
- modules/sunscreen/README.md
|
||||
- modules/conga/client.go
|
||||
- modules/conga/worker.go
|
||||
- modules/conga/scheduler.go
|
||||
- modules/conga/conga.go
|
||||
- modules/conga/commands.go
|
||||
- modules/conga/logger_smoke_test.go
|
||||
- modules/conga/README.md
|
||||
- modules/lagoon/connection.go
|
||||
- modules/lagoon/purge.go
|
||||
- modules/lagoon/transaction.go
|
||||
- modules/lagoon/fill.go
|
||||
- modules/lagoon/attach/thumb.go
|
||||
- modules/lagoon/attach/bucket.go
|
||||
- modules/lagoon/logger_smoke_test.go
|
||||
- modules/lagoon/README.md
|
||||
- modules/cabana/log.go
|
||||
- modules/cabana/account.go
|
||||
- modules/cabana/account_tokens.go
|
||||
- modules/cabana/account_preferences.go
|
||||
- modules/cabana/field_file.go
|
||||
- modules/cabana/query.go
|
||||
- modules/cabana/actions.go
|
||||
- modules/cabana/http.go
|
||||
- modules/cabana/auth.go
|
||||
- modules/cabana/api_tokens.go
|
||||
- modules/cabana/token_guard.go
|
||||
- modules/cabana/singleton.go
|
||||
- modules/cabana/reorder.go
|
||||
- modules/cabana/relation_field.go
|
||||
- modules/cabana/partial_render.go
|
||||
- modules/cabana/field_type.go
|
||||
- modules/cabana/field_dependent.go
|
||||
- modules/cabana/crud.go
|
||||
- modules/cabana/contracts.go
|
||||
- modules/cooler/cooler.go
|
||||
- modules/cooler/memory.go
|
||||
- modules/cooler/rediscache.go
|
||||
- modules/cooler/ratelimit.go
|
||||
- modules/cooler/sitecache.go
|
||||
- modules/postcard/mailer.go
|
||||
- modules/postcard/drivers.go
|
||||
- modules/beachcomber/beachcomber.go
|
||||
- modules/lighthouse/lighthouse.go
|
||||
- modules/lighthouse/centrifugo/client.go
|
||||
- modules/flare/flare.go
|
||||
- modules/phrasebook/translator.go
|
||||
- modules/wristband/oidc_token.go
|
||||
- modules/bouncer/turnstile.go
|
||||
autonomous: true
|
||||
requirements: [SC-1, SC-2, D-01, D-02, D-04, D-05, D-08, D-09, D-10, D-12]
|
||||
user_setup: []
|
||||
|
||||
estimate:
|
||||
tokens: 210000
|
||||
raw_tokens: 210000
|
||||
tasks: 3
|
||||
confidence: low
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "D-02: .planning/notes/2026-10-21-console-charm-stack.md names charm.land/lipgloss/v2 v2.0.6, charm.land/bubbletea/v2 v2.1.0, charm.land/bubbles/v2 v2.2.1, charm.land/huh/v2 v2.0.3, charm.land/fang/v2 v2.0.1 and github.com/charmbracelet/colorprofile v0.4.3 (or the patch versions actually installed, recorded in the note), the v2-over-v1 reason, fang used narrowly, the own diary console handler, the stdlib daily writer, the measured binary delta of about +2.1 MB, and that only modules/bonfire and modules/diary import them; its commit is an ancestor of the first commit that makes go.mod require charm.land/lipgloss/v2."
|
||||
- "D-08: diary reads log.default, log.level, log.channels (driver console, stderr, daily, stack or null, with level, format, path, days and channels) and log.components; SUMMER_LOG__* overrides that arrive as strings parse ('30' days, 'console,daily' for a stack list) and overlay the framework defaults key by key; an unknown driver, format or level fails boot with an error naming the key."
|
||||
- "D-09: diary.For(name) returns a component logger that is safe in package var blocks: before diary is set up its records go to slog.Default's handler with a component attribute, after Setup they pass the component floor (log.components[name], else log.level) and reach every channel; River logs through diary.For(\"river\"), GORM through diary.For(\"db\") with parameterized SQL, and every framework module except surf's request log uses a diary component logger instead of the process default logger."
|
||||
- "D-10: with no config/log.yaml an application logs to a stack of console and daily; the daily file is storage/logs/summer-YYYY-MM-DD.log under the application root, JSON, level warn, kept 14 days; summer new writes the same shape as config/log.yaml; an unwritable log directory warns once on the console and disables that channel without failing boot."
|
||||
- "D-04 (logging half), D-05: the console channel renders styled lines with a fixed-width component prefix in bonfire's palette when its writer is a terminal, and logfmt (json when format: json) otherwise; SetVerbosity(1, false) lets INFO through for every component, SetVerbosity(2, false) DEBUG, and quiet raises only the console channel to WARN."
|
||||
- "D-12: every channel leaf is wrapped by sunscreen.Wrap, so a password attribute reads [REDACTED] on the console and in the daily file; sunscreen.InstallDefault keeps its signature and behaviour (sunscreen.Install never existed, so no shim is added and the README says so)."
|
||||
- "D-01 (palette): modules/bonfire/styles.go ports SSU's status styles (green success, cyan current, yellow warning, red and bold red error, 240 muted, bold header) to lipgloss v2, and the diary console handler takes its colors from it."
|
||||
- "The generated main runs diary.Bootstrap before compass loads and diary.Setup right after, examples/hello/main.go is regenerated, and go vet ./... plus go test -short ./... stay green at every commit."
|
||||
artifacts:
|
||||
- path: ".planning/notes/2026-10-21-console-charm-stack.md"
|
||||
provides: "D-02 dependency decision note"
|
||||
contains: "charm.land/lipgloss/v2"
|
||||
- path: "modules/diary/diary.go"
|
||||
provides: "For, Setup, Bootstrap, SetVerbosity, Verbosity, Reset, Close"
|
||||
contains: "func For(component string) *slog.Logger"
|
||||
- path: "modules/diary/daily.go"
|
||||
provides: "daily rotating writer with retention"
|
||||
contains: "0o640"
|
||||
- path: "modules/bonfire/styles.go"
|
||||
provides: "shared console palette and color mode"
|
||||
contains: "type Palette struct"
|
||||
- path: "internal/build/stubs/new.tmpl"
|
||||
provides: "summer new writes config/log.yaml"
|
||||
contains: "new/config/log.yaml"
|
||||
key_links:
|
||||
- from: "internal/build/build.go"
|
||||
to: "modules/diary/diary.go"
|
||||
via: "generated main calls diary.Bootstrap then diary.Setup"
|
||||
pattern: "diary.Setup\\(cfg"
|
||||
- from: "modules/diary/channels.go"
|
||||
to: "modules/sunscreen/sunscreen.go"
|
||||
via: "every channel leaf is wrapped"
|
||||
pattern: "sunscreen.Wrap\\("
|
||||
- from: "modules/conga/client.go"
|
||||
to: "modules/diary/diary.go"
|
||||
via: "River's logger is the river component"
|
||||
pattern: "diary.For\\(\"river\"\\)"
|
||||
- from: "modules/lagoon/connection.go"
|
||||
to: "modules/diary/diary.go"
|
||||
via: "GORM logs through the db component"
|
||||
pattern: "NewSlogLogger\\(diary.For\\(\"db\"\\)"
|
||||
prohibitions:
|
||||
- statement: "No package outside modules/bonfire and modules/diary imports a charm.land or github.com/charmbracelet module."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "Nothing in this repository imports gopkg.in/yaml.v3."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "Framework code, READMEs and docs name no consuming application (neutral names such as acme or blog)."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "Nothing is pushed or tagged in this plan."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
---
|
||||
|
||||
## Phase Goal
|
||||
|
||||
ROADMAP Goal (not in user-story format; MVP mode takes a story from `/msd mvp-phase 19.8`, which was not run, so no story is invented here): console commands in `summer` and in application binaries reach the bar set by Laravel's Artisan. The command logic stays; logging, the shared console layer in `bonfire`, and every command's help and output change. A user sees what a command did, can ask for more or less of it, and learns from `--help` how to use it without opening the docs.
|
||||
|
||||
This plan's slice (plan 1 of 4, wave 1): the `diary` logging module. The charm-stack decision note lands first (project rule, D-02). Then one record from a named component logger flows from `config/log.yaml` through `compass` into a `sunscreen`-wrapped stack of console and daily file, wired into the generated `main` (the tracer). Then the full Laravel-shaped channel config with `summer new`'s `config/log.yaml` and the logging docs. Last, River, GORM and every framework module log through named components, so plan 2's verbosity flags govern third-party and framework lines alike.
|
||||
|
||||
## Spec-less probe fallback
|
||||
|
||||
Spec-less probe fallback skipped: phase has no requirement IDs mapped (phase_req_ids null) and no SPEC.md. This is a visible choice. `must_haves` derive from the ROADMAP success criteria SC-1..SC-5 and CONTEXT D-01..D-30; `requirements` lists those IDs for traceability.
|
||||
|
||||
## Decisions taken in this plan (recorded assumptions and Claude's discretion)
|
||||
|
||||
- Console handler (CONTEXT discretion): diary's own lipgloss-styled `slog.Handler`, not `charm.land/log/v2`, which fails `testing/slogtest` (RESEARCH probe). The decision note records it.
|
||||
- Install order: this plan installs only `charm.land/lipgloss/v2` and `github.com/charmbracelet/colorprofile` (the palette and the console handler import them; `go mod tidy` drops unused requirements). Plan 2 installs bubbletea, bubbles, huh and fang with the code that imports them. The note names all six.
|
||||
- diary imports bonfire for the palette (RESEARCH Architectural Responsibility Map: one palette for list, help, widgets and log lines). bonfire never imports diary; plan 2 connects them through a `bonfire.OnGlobals` hook.
|
||||
- Framework defaults equal the approved D-08 example: `default: stack`, `level: info`, `stack` = `[console, daily]`, `console` format `auto`, `daily` at `storage/logs/summer.log`, 14 days, `json`, level `warn`, `components: {river: warn}`. The example's `surf: info` line is written as `http: info` in `summer new`'s file with a comment that `http: warn` silences the request log (D-11 and D-23 name the component `http`; plan 3 moves the request log onto it).
|
||||
- Verbosity mapping (RESEARCH A4): `-v` lowers every component floor to at most INFO, `-vv` to DEBUG, `-vvv` adds source locations on the console and GORM SQL traces at INFO (parameterized). Quiet raises only the console channel to WARN; files keep their own levels. Plan 2 wires the flags; this plan provides `SetVerbosity`.
|
||||
- Daily files open lazily on the first record (no empty files), in the process's local time zone (RESEARCH A7), mode 0o640 in directories created 0o750. Relative paths resolve against `Options.Root` (default: the working directory the generated main loads `config` from) and may not escape it; absolute paths are the operator's choice and accepted as given.
|
||||
- Env overrides use compass's double-underscore separator (`SUMMER_LOG__CHANNELS__DAILY__DAYS=30`); D-08's `SUMMER_LOG_*` is shorthand for these. diary parses `log.*` from `Config.Lookup` by hand rather than `LoadSection` (RESEARCH A3), so env strings are accepted where YAML would give numbers or lists.
|
||||
- Component names are plugin or module codes without dots (koanf splits keys on `.`). Framework components: the package name (`cabana`, `lagoon`, `attach`, `cooler`, `postcard`, `beachcomber`, `lighthouse`, `centrifugo`, `flare`, `phrasebook`, `wristband`, `bouncer`, `conga`), plus `river` and `db`. Where a module already prefers an app-published `*slog.Logger` (conga, cooler, postcard, beachcomber), that override stays first.
|
||||
- `sunscreen.Install` does not exist (RESEARCH docs table); the roadmap's wording refers to `sunscreen.InstallDefault`, which keeps working unchanged as the compatibility entry point.
|
||||
|
||||
## Artifacts this phase produces (this plan)
|
||||
|
||||
- Planning: `.planning/notes/2026-10-21-console-charm-stack.md`; Supporting Libraries rows in `.planning/research/STACK.md` for the six charm modules.
|
||||
- New module `modules/diary` (`git.golem15.com/golem15/summercms/modules/diary`):
|
||||
- Functions: `For(component string) *slog.Logger`, `Bootstrap(w io.Writer, opts Options)`, `Setup(cfg *compass.Config, opts Options) error`, `SetVerbosity(verbosity int, quiet bool)`, `Verbosity() int`, `Reset()`, `Close() error`, `ParseConfig(cfg *compass.Config) (Config, error)`, `DefaultConfig() Config`, `NewConsoleHandler(w io.Writer, opts ConsoleOptions) slog.Handler`.
|
||||
- Types: `Options{Verbosity int; Quiet bool; Color bonfire.ColorMode; Root string; Stderr io.Writer; Environ []string; Now func() time.Time}`, `Config{Default string; Level slog.Level; Channels map[string]ChannelConfig; Components map[string]slog.Level}`, `ChannelConfig{Driver, Format, Path string; Level slog.Level; HasLevel bool; Days int; Channels []string}`, `ConsoleOptions{Level slog.Leveler; Profile colorprofile.Profile; AddSource bool}`.
|
||||
- Constants: `ComponentKey = "component"`, `DriverConsole`, `DriverStderr`, `DriverDaily`, `DriverStack`, `DriverNull`, `FormatAuto`, `FormatJSON`, `FormatLogfmt`, config keys `DefaultKey = "log.default"`, `LevelKey = "log.level"`, `ChannelsKey = "log.channels"`, `ComponentsKey = "log.components"`.
|
||||
- Smoke tests: `TestDiaryTracerSmoke`, `TestConfigEnvSmoke`, `TestDailyDegradesSmoke`.
|
||||
- bonfire: `ColorMode` with `ColorAuto`, `ColorAlways`, `ColorNever`; `Palette{Success, Current, Warning, Error, ErrorBold, Muted, Header lipgloss.Style}`; `DefaultPalette() Palette`; `DetectProfile(w io.Writer, mode ColorMode, environ []string) colorprofile.Profile`.
|
||||
- Config keys (published): `log.default`, `log.level`, `log.channels.<name>.driver|level|format|path|days|channels`, `log.components.<name>`.
|
||||
- Generated main: `diary.Bootstrap(os.Stderr, diary.Options{})` as the first statement of `run`, `diary.Setup(cfg, diary.Options{})` after `compass.Load`; no `sunscreen` import. `summer new` writes `config/log.yaml`.
|
||||
- conga: River logger `diary.For("river")`, conga's own lines `diary.For("conga")`; smoke `TestRiverLoggerUsesDiarySmoke`. lagoon: GORM logger `logger.NewSlogLogger(diary.For("db"), ...)`; smoke `TestGormLoggerUsesDiarySmoke`.
|
||||
- `.gitignore` entries `/storage/` and `/examples/hello/storage/`; root README row for diary.
|
||||
|
||||
<objective>
|
||||
Record the charm-stack decision, then give every application one logging pipeline: named component loggers, Laravel-shaped channels configured in `config/log.yaml`, redaction on every channel, daily files on by default, and River, GORM and framework modules routed through it.
|
||||
|
||||
Purpose: D-02 (note first), D-08, D-09, D-10, D-12, D-05 and the logging half of D-04, the palette half of D-01; SC-1 and the diary side of SC-2.
|
||||
Output: one planning-doc commit (note + STACK.md), the diary tracer commit with READMEs and the generated main, the channel-config commit with docs and `summer new`, and one or two commits routing River, GORM and framework modules.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/msd-core/workflows/execute-plan.md
|
||||
@~/.claude/msd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/phases/19.8-console-commands-overhaul/19.8-CONTEXT.md
|
||||
@.planning/phases/19.8-console-commands-overhaul/19.8-RESEARCH.md
|
||||
@.planning/phases/19.8-console-commands-overhaul/19.8-PATTERNS.md
|
||||
@modules/sunscreen/README.md
|
||||
|
||||
Repos and concurrency: this plan writes only in summercms.go. Several sessions work here (Phase 19.5 is executing; its in-flight files are `.planning/STATE.md`, `.planning/state.json`, `scripts/check-phase19.5.sh` and 19.5 phase docs). Before each task run `git status --porcelain` on the paths the task touches; if a file this plan edits is modified by someone else, stop and report (for cabana files in Task 3: skip only those files, record them in the SUMMARY, continue with the rest). Stage files by explicit path, never `git add -A`. No co-author tags. Planning docs and code in separate commits; a README and docs change in the same commit as the API it describes. Keep `go vet ./...` and `go test -short ./...` green at every commit (and `go -C examples/hello test ./...`, a separate workspace module). Nothing is pushed or tagged.
|
||||
|
||||
<interfaces>
|
||||
Existing seams (extend, do not fork):
|
||||
|
||||
- modules/sunscreen/sunscreen.go: `Wrap(next slog.Handler) slog.Handler` (idempotent: an already-wrapped handler is returned unchanged, lines 82-87), `InstallDefault(w io.Writer)` (line 94), `Scrub(s string) string`, `RedactedKeys()`; handler implements Enabled, Handle, WithAttrs, WithGroup (lines 102-125) — the shape to copy for diary's handlers.
|
||||
- modules/compass/config.go: `Options{Dir, Env string; Environ []string}`, `Load(dir)`, `Open(opts)`, `(*Config).Lookup(path) (any, bool)`, `String`, `Int`, `Bool`, `Has`, `Environment()`; compass imports no framework module. Env layering: modules/compass/env.go:31-44 (`SUMMER_` prefix, `__` separates path segments, values arrive as strings).
|
||||
- internal/build/build.go:94-151 `generateMain(m Manifest)`: strings.Builder template; keep every `commands = append(commands, pkg.Ctor(app...` line verbatim (cmd/summer/docs_test.go:418 regex `generatedConstructor` parses them). `formatSource` at the end.
|
||||
- internal/build/build_test.go:148-170 `TestGenerateMainInstallsRedactingLogger` (asserts the sunscreen install line; replaced in Task 1). Goldens: `go test ./internal/build -run Golden -update` (flag at internal/build/new_golden_test.go:13), files under internal/build/testdata/new/ (`main.go.golden`, `config__<name>.yaml.golden`, `CLAUDE.md.golden`) and testdata/new-no-plugin/.
|
||||
- internal/build/new.go:388-391 the planned file list (`{"config/app.yaml", "new/config/app.yaml", 0o644}` and siblings); internal/build/stubs/new.tmpl `{{define "new/config/http.yaml"}}` (line 23) is the template shape; CLAUDE.md template line 126 lists the config files.
|
||||
- examples/hello: committed generated `main.go`, regenerated by `go -C examples/hello run ../../cmd/summer build` (hello_test.go:282 does the same); its own go.mod plus plugin modules under plugins/ with their own go.sum; the repo go.work uses all of them.
|
||||
- modules/conga/client.go:102-123 `baseConfig(s, log)` sets `river.Config.Logger`; `loggerFromApp(app)` returns the app-published `*slog.Logger` else the process default. modules/lagoon/connection.go:57 `gormFromSQL` opens GORM with `&gorm.Config{}` (GORM's own stdout logger, RESEARCH Pitfall 6). gorm v1.31.2 has `logger.NewSlogLogger(*slog.Logger, logger.Config)` (logger/slog.go:24-32).
|
||||
- SSU palette to port (external repo, read-only): /media/nvme/dev/pxpx/ssu/internal/cli/tui/styles.go (Color "2" green, "6" cyan, "3" yellow, "1" red and bold red, "240" muted, bold header; drop `StyleForStatus` and the git coupling).
|
||||
- Decision-note structure to copy: .planning/notes/2026-10-19-oidc-on-wristband-golang-jwt.md (Decision, Date, Status, Context, Decision bullets, rejected alternatives, Related dependencies, Consequences).
|
||||
- RESEARCH Pattern 1 (proxy-backed named loggers), Pattern 2 (channels as sunscreen-wrapped leaves under slog.NewMultiHandler), Pattern 3 (daily writer), Pitfalls 4-8.
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tracer">
|
||||
<name>Task 1: Tracer: the decision note lands first, then one diary.For("journal") record flows from config/log.yaml through compass into a sunscreen-wrapped stack of console and daily file, wired into the generated main</name>
|
||||
<reversibility rating="costly">D-02: the charm modules enter every application binary; D-08: log.* keys become published config that every application's config/log.yaml uses.</reversibility>
|
||||
<files>.planning/notes/2026-10-21-console-charm-stack.md, .planning/research/STACK.md, go.mod, go.sum, .gitignore, README.md, modules/bonfire/styles.go, modules/bonfire/README.md, modules/diary/diary.go, modules/diary/config.go, modules/diary/component.go, modules/diary/console.go, modules/diary/daily.go, modules/diary/channels.go, modules/diary/tracer_smoke_test.go, modules/diary/README.md, internal/build/build.go, internal/build/build_test.go, internal/build/testdata/new/main.go.golden, examples/hello/main.go, examples/hello/go.sum, examples/hello/plugins/greeter/go.sum</files>
|
||||
<read_first>.planning/notes/2026-10-19-oidc-on-wristband-golang-jwt.md (structure), .planning/research/STACK.md (Supporting Libraries table, lines 28-49), 19.8-RESEARCH.md (Summary, Standard Stack, Alternatives Considered, Package Legitimacy Audit, Patterns 1-3, Pitfalls 4-8, Probe evidence), 19.8-PATTERNS.md (modules/diary section), modules/sunscreen/sunscreen.go, modules/sunscreen/README.md (README structure to copy), modules/compass/config.go, modules/compass/env.go, modules/bonfire/output.go (current color policy: NO_COLOR, TERM=dumb, FORCE_COLOR), modules/bonfire/README.md, /media/nvme/dev/pxpx/ssu/internal/cli/tui/styles.go, internal/build/build.go, internal/build/build_test.go, examples/hello/main.go, examples/hello/hello_test.go, README.md (module table rows near lines 97 and 117), .gitignore</read_first>
|
||||
<action>Planning doc first (D-02). Run `go list -m -versions` (or `go list -m <path>@latest`) for the six modules and keep RESEARCH's versions unless a newer patch of the same minor exists, in which case use and record that patch. Write `.planning/notes/2026-10-21-console-charm-stack.md` in the structure of the 2026-10-19 note: title "Decision: the console runs on charm's v2 stack, logging stays on stdlib slog"; Date 2026-10-21; Status Accepted; Context Phase 19.8 (D-01, D-02, D-05, D-08 and the CONTEXT discretion item on the console handler), 19.8-RESEARCH.md Standard Stack, Alternatives Considered, Package Legitimacy Audit and probes. Decision bullets: the modules `charm.land/lipgloss/v2` v2.0.6, `charm.land/bubbletea/v2` v2.1.0, `charm.land/bubbles/v2` v2.2.1, `charm.land/huh/v2` v2.0.3, `charm.land/fang/v2` v2.0.1 and `github.com/charmbracelet/colorprofile` v0.4.3 on the charm.land vanity paths; why v2 and not SSU's v1 pins (fang v1 already depends on a lipgloss v2 beta, so v1 widgets plus fang would compile two lipgloss majors; v1 is maintenance-only), and that SSU's widgets are ported with the v2 renames (View returns tea.View, KeyPressMsg, progress WithColors, SetWidth); only modules/bonfire (all five) and modules/diary (lipgloss, colorprofile) import them, plugins reach them through bonfire.Output and diary.For; fang is used narrowly (Execute with WithoutVersion because its -v would collide with verbosity, WithoutManpage because it adds a hidden man command, WithoutCompletions, and WithErrorHandler set to bonfire's renderer), help and list layouts are bonfire's own (D-14); colorprofile.Writer applies --ansi, --no-ansi and NO_COLOR because lipgloss v2 Render always emits ANSI; the diary console handler is our own lipgloss handler and charm's slog logger module is rejected because it fails testing/slogtest (groups treated as prefixes, empty attributes printed, empty-key groups not inlined) and duplicates diary's level and prefix model, which settles the CONTEXT discretion item; daily files use a stdlib writer of about 120 lines and lumberjack is rejected (it rotates by size, has no name-YYYY-MM-DD files, and would need its own note); fan-out is stdlib slog.NewMultiHandler and GORM uses gorm's own logger.NewSlogLogger; cost about +2.1 MB on a stripped binary (2,527,495 to 4,649,223 bytes in the probe); module go directives are at most 1.26 and fit go 1.27.0; MVS keeps cobra v1.10.2, pflag v1.0.9 and x/text v0.42.0; gopkg.in/yaml.v3 enters the module graph only as testify's indirect test dependency of fang and huh and is never imported (goccy rule); package legitimacy was checked by hand against proxy.golang.org and the charm.land go-import meta tags (origins github.com/charmbracelet/*) because the MSD seam rejects Go, verdict approved, fang noted as young (two v2 tags); install order: plan 19.8-01 installs lipgloss and colorprofile, plan 19.8-02 the other four. A rejected-alternatives paragraph (v1 pins, charm's slog logger, lumberjack, hand-written fan-out) and Consequences (every binary grows; removing the stack later means rewriting the widgets, D-01 and D-02 are rated costly; plugins never import charm modules directly). In .planning/research/STACK.md add one Supporting Libraries row per module (purpose; "Decided in .planning/notes/2026-10-21-console-charm-stack.md (Phase 19.8 D-02); imported only by modules/bonfire and modules/diary"). Commit the note and STACK.md together, alone: `docs(19.8): decide the charm v2 console stack`.
|
||||
|
||||
Then the tracer. `go get charm.land/lipgloss/v2@<version> github.com/charmbracelet/colorprofile@<version>`; after the code below imports them run `go mod tidy`, then `go -C examples/hello build ./...` and `go mod tidy` in every examples/hello module whose build asks for go.sum entries.
|
||||
|
||||
modules/bonfire/styles.go (new): `type ColorMode int` with `ColorAuto`, `ColorAlways`, `ColorNever`; `type Palette struct` with lipgloss.Style fields `Success`, `Current`, `Warning`, `Error`, `ErrorBold`, `Muted`, `Header`; `DefaultPalette()` ported from SSU styles.go (Color "2", "6", "3", "1", "1" plus Bold, "240", Bold); `DetectProfile(w io.Writer, mode ColorMode, environ []string) colorprofile.Profile`: ColorNever gives colorprofile.NoTTY (no escape codes at all), ColorAlways gives the detected profile raised to at least ANSI256, ColorAuto treats a non-empty FORCE_COLOR as ColorAlways and otherwise uses colorprofile.Detect(w, environ) (which honours NO_COLOR and TERM=dumb). output.go is not changed in this plan (plan 2 moves the console onto the palette). bonfire README: API reference rows for the four names, Dependencies gains lipgloss and colorprofile.
|
||||
|
||||
modules/diary (new package, doc comment naming D-08 and D-09). diary.go: `Options` (fields listed under Artifacts; Stderr defaults to os.Stderr, Root to ".", Now to time.Now, Environ to os.Environ()); package state in an `atomic.Pointer` to a state struct holding the root handler, the floors, the console channel's `*slog.LevelVar`, verbosity, quiet and a generation counter; `Bootstrap(w, opts)` installs a state with one console channel on w and no config (used before config loads and by the summer tool) and calls slog.SetDefault with a logger over an unnamed component handler; `Setup(cfg, opts)` parses the config, builds the channels, swaps the state, calls slog.SetDefault, and returns parse errors unchanged; `For(component)` returns `slog.New` over a component handler; `SetVerbosity(verbosity, quiet)` recomputes floors and sets the console LevelVar to WARN when quiet (otherwise back to its configured level); `Verbosity()`; `Reset()` (tests) drops the state and restores the slog default captured at the first Bootstrap or Setup; `Close()` closes open daily files. component.go: the proxy handler of RESEARCH Pattern 1: name, pre-bound attrs and groups; with no state it delegates to the process default handler (and to a plain stderr TextHandler if that default is itself a diary component handler, so it can never recurse), adding the component attribute; with a state, Enabled is `level >= floor(name)` and the root's Enabled, Handle adds `slog.String(ComponentKey, name)` when name is non-empty and replays attrs and groups onto the live root (cached per state generation); WithAttrs with a string attribute keyed `component` rebinds the name. In this task floor(name) is log.level lowered by verbosity (Task 2 adds log.components). console.go: `NewConsoleHandler(w, opts)` writing one line per record in one Write under a mutex shared by its WithAttrs and WithGroup clones: time (15:04:05, Muted), level padded to 5 (DEBUG Muted, INFO Current, WARN Warning, ERROR ErrorBold), the component attribute as a fixed-width prefix (10 columns, Header), the message, then key=value attributes (keys Muted) with groups as dotted key prefixes, empty attributes skipped and empty-key groups inlined (slogtest semantics); control characters other than tab are stripped from the message and attribute values (terminal escape injection); output goes through colorprofile.Writer with opts.Profile; AddSource prints file:line. daily.go: an unexported daily writer for `path` (dir, base name and extension split; `storage/logs/summer.log` gives files `summer-2026-10-21.log`): Write locks, picks the file by Now's local date, opens lazily with os.O_APPEND|os.O_CREATE|os.O_WRONLY and mode 0o640 after os.MkdirAll(dir, 0o750), reopens on a date change, and on each open deletes only files matching base-YYYY-MM-DD+ext whose date is more than `days` days old (days 0 keeps all); an open failure reports once through a warn callback and disables the writer (later writes succeed silently). channels.go: builds the channel named by log.default: console (format auto picks the console handler when the writer is a terminal per bonfire.DetectProfile and a TextHandler otherwise; json and logfmt force slog.JSONHandler or slog.TextHandler), daily (JSONHandler by default, TextHandler for logfmt, on the daily writer), stack (slog.NewMultiHandler over the named children); every leaf is `sunscreen.Wrap(...)` and gated by its own `*slog.LevelVar` (the channel level when set, else no extra floor). config.go: `Config`, `ChannelConfig`, constants and `DefaultConfig()` (the framework defaults listed in this plan's decisions), `ParseConfig(cfg)` reading log.default, log.level and log.channels (driver, level, format, path, days, channels) via Lookup in this task (Task 2 completes validation, stderr, null and components).
|
||||
|
||||
Generated main (internal/build/build.go generateMain): import diary, drop the sunscreen import; first statement of run `diary.Bootstrap(os.Stderr, diary.Options{})`; after `compass.Load("config")` and its error check, `if err := diary.Setup(cfg, diary.Options{}); err != nil { return err }` before `backpack.New(cfg)`; every other line unchanged. Replace `TestGenerateMainInstallsRedactingLogger` with `TestGenerateMainInstallsDiary`: Bootstrap appears once and is the first statement of run before `compass.Load(`; Setup appears once between `compass.Load(` and `backpack.New(`; the sunscreen import is absent. Update the goldens with `go test ./internal/build -run Golden -update` and regenerate examples/hello/main.go with `go -C examples/hello run ../../cmd/summer build`. Add `/storage/` and `/examples/hello/storage/` to the root .gitignore (hello's tests run the binary in its directory, which now writes daily files).
|
||||
|
||||
Smoke test modules/diary/tracer_smoke_test.go `TestDiaryTracerSmoke` (runs under -short): write `<tmp>/config/log.yaml` with a stack of console and daily, daily level info and path storage/logs/summer.log; `compass.Open(compass.Options{Dir: <tmp>/config})`; `diary.Setup(cfg, diary.Options{Root: tmp, Stderr: &buf, Color: bonfire.ColorNever, Now: fixed 2026-10-21 12:00})`; `t.Cleanup(diary.Reset)`; `diary.For("journal").Info("post saved", "password", "hunter2", "id", 7)`; assert buf holds `component=journal`, `post saved` and `[REDACTED]` and not the secret; `<tmp>/storage/logs/summer-2026-10-21.log` exists with mode 0o640 and its single JSON line decodes with component journal and password `[REDACTED]`. Also assert a record from `diary.For("x")` created before Setup (package-level var in the test file) still reaches buf after Setup.
|
||||
|
||||
READMEs in the same commit: modules/diary/README.md in the sunscreen structure (H1 `diary`, summary sentence "Application logging modelled on Laravel's logging config: named channels (console, stderr, daily files, stacks), per-component levels and redaction on every channel.", import line, `**Name:** A beach diary records each day; the log keeps one file per day.`, Overview, Role in the framework, Features, Usage with diary.For, API reference for every exported name above, Configuration with the default stack, Dependencies, Testing naming TestDiaryTracerSmoke); a root README.md table row with the same summary sentence and mnemonic, placed alphabetically. Neutral wording; no application named. Commit: `feat(diary): component loggers through a redacting console and daily stack`.</action>
|
||||
<verify>
|
||||
<automated>go vet ./... && go test -count=1 -short ./modules/diary/... ./modules/bonfire/... ./modules/sunscreen/... ./internal/build/... && go test -count=1 -short -run '^TestDiaryTracerSmoke$' -v ./modules/diary 2>&1 | grep -q -- '^--- PASS: TestDiaryTracerSmoke' && go -C examples/hello vet ./... && go -C examples/hello test -count=1 ./... && go test -count=1 -run '^TestDocsTree$' ./cmd/summer && go run ./cmd/summer docs:build --check</automated>
|
||||
<fails_when>Non-zero exit: vet or a short suite is red (generated main, goldens or hello no longer match), no "--- PASS: TestDiaryTracerSmoke" line (the record did not reach both channels redacted, or the test did not run), examples/hello fails to build or test against the regenerated main, or the docs checker reports an identifier in the diary or bonfire README that does not exist.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `N=$(git log --format=%H --diff-filter=A -- .planning/notes/2026-10-21-console-charm-stack.md) && A=$(git log --format=%H -S 'charm.land/lipgloss/v2' -- go.mod) && L=$(printf '%s\n' "$A" | tail -1) && test -n "$N" && test -n "$L" && git merge-base --is-ancestor "$N" "$L"` exits 0 (note committed before go.mod requires lipgloss).
|
||||
- `for s in charm.land/lipgloss/v2 charm.land/bubbletea/v2 charm.land/bubbles/v2 charm.land/huh/v2 charm.land/fang/v2 github.com/charmbracelet/colorprofile WithoutVersion slogtest lumberjack '2.1 MB'; do grep -qF -- "$s" .planning/notes/2026-10-21-console-charm-stack.md || exit 1; done` exits 0.
|
||||
- `grep -c '2026-10-21-console-charm-stack.md' .planning/research/STACK.md` prints at least 1.
|
||||
- `go list -f '{{.ImportPath}} {{join .Imports " "}}' ./... | grep -E ' (charm\.land/|github\.com/charmbracelet/)' | grep -vcE '^git\.golem15\.com/golem15/summercms/modules/(bonfire|diary) '` prints 0.
|
||||
- `grep -c 'sunscreen.Wrap(' modules/diary/channels.go` prints at least 1 and `grep -c '0o640' modules/diary/daily.go` prints at least 1.
|
||||
- `grep -c 'diary.Bootstrap(os.Stderr' internal/build/testdata/new/main.go.golden` prints 1, `grep -c 'diary.Setup(cfg' examples/hello/main.go` prints 1, and `grep -c 'modules/sunscreen"' examples/hello/main.go` prints 0.
|
||||
- `grep -c '\[diary\](modules/diary/README.md)' README.md` prints 1 and `grep -c '^\*\*Name:\*\*' modules/diary/README.md` prints 1.
|
||||
- `git check-ignore -q examples/hello/storage/logs/summer-2026-10-21.log` exits 0.
|
||||
</acceptance_criteria>
|
||||
<done>The dependency decision is on record before any charm code, and a record from a named component logger reaches a styled-or-logfmt console line and a JSON daily file, both redacted, in every generated application.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Operators configure logging the Laravel way: stderr and null drivers, per-channel level and format, log.components, env overrides as strings, a confined path and a daily channel that degrades; summer new writes config/log.yaml and the docs describe it</name>
|
||||
<reversibility rating="costly">D-08: the log.* key shape and its defaults are published in summer new's config/log.yaml and the docs; changing them later breaks every application's file.</reversibility>
|
||||
<files>modules/diary/config.go, modules/diary/channels.go, modules/diary/component.go, modules/diary/daily.go, modules/diary/config_smoke_test.go, modules/diary/README.md, internal/build/new.go, internal/build/stubs/new.tmpl, internal/build/testdata/new/config__log.yaml.golden, internal/build/testdata/new/CLAUDE.md.golden, internal/build/testdata/new-no-plugin/CLAUDE.md.golden, docs/services/logging.md, docs/setup/configuration.md, docs/services/configuration.md</files>
|
||||
<read_first>modules/diary/config.go, modules/diary/channels.go, modules/diary/component.go, modules/diary/daily.go (from Task 1), modules/conga/client.go:34-60 (settingsFromApp: defaults first, tolerant string-or-int parsing), modules/cooler/cooler.go:297-307 (errors that name the key), modules/compass/env.go, internal/build/new.go (planNewApp list near line 388), internal/build/stubs/new.tmpl (config templates and the CLAUDE.md template line 126), internal/build/testdata/new/config__http.yaml.golden, docs/services/logging.md, docs/setup/configuration.md, docs/services/configuration.md, 19.8-CONTEXT.md (D-08 example YAML, D-10), 19.8-RESEARCH.md (Pitfalls 4 and 8, Security Domain V5 and V12)</read_first>
|
||||
<action>Per D-08, D-09 and D-10. config.go `ParseConfig(cfg)`: start from `DefaultConfig()` and overlay what the config holds, channel by channel and key by key, so a single env override such as `SUMMER_LOG__CHANNELS__DAILY__DAYS=30` with no log.yaml keeps every other default. Rules: log.level and every level value accept debug, info, warn, warning, error (case-insensitive); log.default must name a channel; `log.channels.<name>.driver` must be one of console, stderr, daily, stack, null (a channel that is not a default and has no driver is an error naming `log.channels.<name>.driver`); format must be auto, json or logfmt, auto only for console (stderr defaults to logfmt, daily to json); days accepts an int or a decimal string, at least 0; channels accepts a YAML list or a comma-separated string, trimmed; `log.components.<name>` values are levels. Every error names the full key, for example `diary: log.channels.daily.days must be a whole number of at least 0`. Stacks may nest; unknown child names and cycles are errors naming the stack.
|
||||
|
||||
channels.go: add `stderr` (TextHandler or JSONHandler on Options.Stderr, never styled) and `null` (slog.DiscardHandler); each still wrapped by sunscreen.Wrap. Path handling for daily: a relative path is cleaned and joined to Options.Root, and one that escapes Root (a `..` prefix after cleaning) is a config error naming `log.channels.<name>.path`; an absolute path is used as given. daily.go: when opening fails (unwritable or read-only directory), the warn callback logs one WARN record from component `diary` on the console channel naming the path and the error, and the channel is disabled; Setup never fails because of a log file. component.go: floor(name) is `Components[name]` when set, else `Level`; verbosity 1 lowers every floor to at most INFO, 2 and more to DEBUG; records from loggers without a component use `Level`.
|
||||
|
||||
summer new: add `{"config/log.yaml", "new/config/log.yaml", 0o644}` to the planned list in internal/build/new.go after storage.yaml, and a `{{define "new/config/log.yaml"}}` template in new.tmpl holding the D-08 example with short comments: console is styled on a terminal and json or logfmt otherwise; the daily file rotates per day under storage/logs and is kept 14 days; components lists `river: warn` and `http: info` with the comment that `http: warn` silences the request log; env overrides use `SUMMER_LOG__` with double underscores. Add `log.yaml` to the config-file list in the CLAUDE.md template. Update goldens with `go test ./internal/build -run Golden -update` (a new `config__log.yaml.golden`; CLAUDE.md goldens).
|
||||
|
||||
Docs in the same commit. docs/services/logging.md: keep its front matter; rewrite "The redacting default logger" into "Channels" (table: console, stderr, daily, stack, null and what each writes), "config/log.yaml" (the D-08 example, defaults when the file is absent, the Laravel config/logging.php comparison), "Component loggers" (diary.For, name rule: plugin or module code without dots, levels per component), "Daily files" (naming, retention, 0640 mode, path relative to the application root, an unwritable directory disables the channel with one warning), "Redaction" (sunscreen.Wrap on every channel; sunscreen.InstallDefault remains for code that installs its own default). Keep "Request log" and "Panics" unchanged (plan 3 updates the request log). docs/setup/configuration.md and docs/services/configuration.md: the log keys and an example `SUMMER_LOG__CHANNELS__DAILY__DAYS=30` (double underscore; string values accepted). diary README: Configuration section with every key and its default, Features gains stderr, null and components; Testing names the new smoke tests.
|
||||
|
||||
Smoke tests (config_smoke_test.go, -short): `TestConfigEnvSmoke` builds a compass config from a temp dir with no log.yaml and an Environ of `SUMMER_LOG__CHANNELS__DAILY__DAYS=30`, `SUMMER_LOG__CHANNELS__STACK__CHANNELS=console,daily`, `SUMMER_LOG__COMPONENTS__RIVER=error`, and asserts the parsed Config (days 30, stack children console and daily, river ERROR, every other default intact) plus one error case per rule naming its key. `TestDailyDegradesSmoke` points daily at a directory made read-only (skip when running as root) and asserts Setup returns nil, one warning line naming the path reaches the console buffer, and a later record still reaches the console. Commit: `feat(diary): channels, components and config/log.yaml`.</action>
|
||||
<verify>
|
||||
<automated>go vet ./... && go test -count=1 -short ./modules/diary/... ./internal/build/... && go test -count=1 -short -run '^(TestConfigEnvSmoke|TestDailyDegradesSmoke|TestDiaryTracerSmoke)$' -v ./modules/diary 2>&1 | grep -c -- '^--- PASS: Test' | grep -qx 3 && go test -count=1 -run '^TestDocsTree$' ./cmd/summer && go run ./cmd/summer docs:build --check</automated>
|
||||
<fails_when>Non-zero exit, or the count of top-level "--- PASS: Test" lines is not 3 (an env-string or error case failed, the degrade test failed or skipped, or a name did not run), or the new-app goldens or the docs checker disagree with the code.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -c 'define "new/config/log.yaml"' internal/build/stubs/new.tmpl` prints 1 and `test -f internal/build/testdata/new/config__log.yaml.golden` exits 0.
|
||||
- `grep -cE '^ *driver: (stack|console|daily)' internal/build/testdata/new/config__log.yaml.golden` prints at least 3 and `grep -c 'http: info' internal/build/testdata/new/config__log.yaml.golden` prints 1.
|
||||
- `grep -cE 'DriverStderr|DriverNull' modules/diary/config.go` prints at least 2.
|
||||
- `grep -c 'SUMMER_LOG__' docs/setup/configuration.md` prints at least 1 and `grep -c 'diary.For' docs/services/logging.md` prints at least 1.
|
||||
- `grep -c 'log.channels' modules/diary/README.md` prints at least 1 and `grep -c 'log.components' modules/diary/README.md` prints at least 1.
|
||||
</acceptance_criteria>
|
||||
<done>An operator can shape logging entirely from config/log.yaml or SUMMER_LOG__ variables, bad keys fail with the key named, an unwritable log directory never stops boot, and new applications ship the file.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: River, GORM and every framework module log through named diary components, so log.components and the coming verbosity flags govern third-party and framework lines alike</name>
|
||||
<files>modules/conga/client.go, modules/conga/worker.go, modules/conga/scheduler.go, modules/conga/conga.go, modules/conga/commands.go, modules/conga/logger_smoke_test.go, modules/conga/README.md, modules/lagoon/connection.go, modules/lagoon/purge.go, modules/lagoon/transaction.go, modules/lagoon/fill.go, modules/lagoon/attach/thumb.go, modules/lagoon/attach/bucket.go, modules/lagoon/logger_smoke_test.go, modules/lagoon/README.md, modules/cabana/log.go, modules/cabana/account.go, modules/cabana/account_tokens.go, modules/cabana/account_preferences.go, modules/cabana/field_file.go, modules/cabana/query.go, modules/cabana/actions.go, modules/cabana/http.go, modules/cabana/auth.go, modules/cabana/api_tokens.go, modules/cabana/token_guard.go, modules/cabana/singleton.go, modules/cabana/reorder.go, modules/cabana/relation_field.go, modules/cabana/partial_render.go, modules/cabana/field_type.go, modules/cabana/field_dependent.go, modules/cabana/crud.go, modules/cabana/contracts.go, modules/cooler/cooler.go, modules/cooler/memory.go, modules/cooler/rediscache.go, modules/cooler/ratelimit.go, modules/cooler/sitecache.go, modules/postcard/mailer.go, modules/postcard/drivers.go, modules/beachcomber/beachcomber.go, modules/lighthouse/lighthouse.go, modules/lighthouse/centrifugo/client.go, modules/flare/flare.go, modules/phrasebook/translator.go, modules/wristband/oidc_token.go, modules/bouncer/turnstile.go, modules/sunscreen/sunscreen.go, modules/sunscreen/README.md, modules/diary/README.md, docs/services/logging.md</files>
|
||||
<read_first>modules/conga/client.go (baseConfig :102-110, loggerFromApp :116-123), modules/lagoon/connection.go (gormFromSQL :56-75), modules/cooler/cooler.go (loggerFromApp :384-390, warner :400-440), modules/postcard/mailer.go (:150-161), modules/beachcomber/beachcomber.go (:130-182), modules/cabana/auth.go (:390-396), modules/bouncer/turnstile.go (:225-240, the turnstile verification log lines) and modules/bouncer/turnstile_test.go (TestTurnstileNeverLogsSecret :348, captures through slog.SetDefault), modules/sunscreen/sunscreen.go (InstallDefault doc :89-99), modules/sunscreen/README.md, docs/services/logging.md ("Logging from a plugin"), modules/diary/diary.go (For and the no-state fallback), 19.8-RESEARCH.md (Pitfalls 5 and 6, Don't Hand-Roll row "GORM to slog"), 19.8-PATTERNS.md (Shared Patterns: Logger lookup)</read_first>
|
||||
<action>Per D-09 and D-12. Before editing, run `git status --porcelain -- modules/cabana modules/lagoon modules/conga modules/cooler modules/postcard modules/bouncer`; files another session has modified are skipped and listed in the SUMMARY.
|
||||
|
||||
River and GORM first (commit `feat(conga,lagoon): River and GORM log through diary components`). conga/client.go: split the lookup into `riverLogger(app)` (the app-published `*slog.Logger` when present, else `diary.For("river")`) used by `baseConfig`, and conga's own lines through the published logger else `diary.For("conga")`; the framework default `components: {river: warn}` keeps River quiet at INFO. lagoon/connection.go gormFromSQL: `&gorm.Config{Logger: gormLogger()}` where unexported `gormLogger()` returns `logger.NewSlogLogger(diary.For("db"), logger.Config{SlowThreshold: 200 * time.Millisecond, LogLevel: logger.Warn, IgnoreRecordNotFoundError: true, ParameterizedQueries: true})`, with LogLevel `logger.Info` when `diary.Verbosity() >= 3` (SQL traces only at -vvv; parameterized so values never reach the log). README lagoon: a Logging paragraph (GORM lines leave stdout and arrive as component db, record-not-found is no longer logged, -vvv shows parameterized SQL); README conga: River logs as component river (warn by default).
|
||||
|
||||
Then framework modules (commit `refactor: framework modules log through diary components`). Every non-test call that logs through the process default logger in the files listed above moves to a package-level component logger: add an unexported `var moduleLog = diary.For("<package>")` in one file per package (modules/cabana/log.go for cabana; modules/bouncer/turnstile.go for bouncer, whose turnstile middleware holds its only two log calls; in the existing main file of the other packages), component name = package name (cabana, lagoon, attach, cooler, postcard, beachcomber, lighthouse, centrifugo, flare, phrasebook, wristband, bouncer, conga). Where a module prefers an app-published `*slog.Logger` (conga, cooler, postcard, beachcomber `Service.Logger` and `loggerFromApp`), keep that override first and fall back to the component logger. cooler's warner keeps its setLogger seam and starts from `moduleLog`. Messages and attributes do not change (cabana's `admin.auth` audit record keeps its message and gains only the component attribute). modules/surf/accesslog.go is not touched (plan 3 moves the request log to component http, D-23). Tests that install a capture handler with slog.SetDefault keep working because a component logger without diary setup delegates to the process default handler; if a test compares the exact attribute set, add the component attribute to its expectation.
|
||||
|
||||
sunscreen (same refactor commit): the InstallDefault doc comment and README Overview say that generated applications now install diary, which wraps every channel with sunscreen.Wrap, and that InstallDefault stays as the compatibility entry point for programs that install their own default (D-12; note that no sunscreen.Install function exists). diary README Role section lists River (river), GORM (db) and the framework components. docs/services/logging.md "Logging from a plugin": plugins call `diary.For("<plugin code>")` instead of the process default logger; the logger is safe to create in a package var.
|
||||
|
||||
Smoke tests (-short): modules/conga/logger_smoke_test.go `TestRiverLoggerUsesDiarySmoke`: `diary.Bootstrap(&buf, diary.Options{Color: bonfire.ColorNever})`, `t.Cleanup(diary.Reset)`, `riverLogger(nil).Warn("probe")` and buf contains `component=river`; with an app that publishes a capture logger the capture receives it instead. modules/lagoon/logger_smoke_test.go `TestGormLoggerUsesDiarySmoke`: same setup, `gormLogger().Warn(ctx, "slow %s", "query")` and buf contains `component=db`; `diary.SetVerbosity(3, false)` then a fresh gormLogger reports logger.Info as its level (assert via the returned logger.Interface's LogMode comparison or by logging a Trace and seeing a line).</action>
|
||||
<verify>
|
||||
<automated>go vet ./... && go test -count=1 -short ./... && go test -count=1 -short -run '^TestRiverLoggerUsesDiarySmoke$' -v ./modules/conga 2>&1 | grep -q -- '^--- PASS: TestRiverLoggerUsesDiarySmoke' && go test -count=1 -short -run '^TestGormLoggerUsesDiarySmoke$' -v ./modules/lagoon 2>&1 | grep -q -- '^--- PASS: TestGormLoggerUsesDiarySmoke' && go -C examples/hello test -count=1 ./... && go test -count=1 -run '^TestDocsTree$' ./cmd/summer && go run ./cmd/summer docs:build --check</automated>
|
||||
<fails_when>Non-zero exit: a module suite is red after the logger move (for example a capture test now misses records), no "--- PASS" line for either smoke test (River or GORM records do not carry their component, or the test did not run), hello fails, or the docs checker rejects an identifier.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
<!-- planner-discipline-allow: slog.Default() -->
|
||||
- `grep -rn 'slog.Default()' modules --include=*.go | grep -v '_test.go' | grep -v '^modules/surf/accesslog.go' | grep -v '^modules/diary/' | wc -l` prints 0 (files skipped because another session had them modified are listed in the SUMMARY and excluded from this count by name).
|
||||
- `grep -rnE 'slog\.(Info|Warn|Error|Debug)(Context)?\(' modules --include=*.go | grep -v '_test.go' | grep -v '^modules/diary/' | wc -l` prints 0.
|
||||
- `grep -c 'diary.For("river")' modules/conga/client.go` prints at least 1 and `grep -c 'NewSlogLogger(diary.For("db")' modules/lagoon/connection.go` prints 1.
|
||||
- `grep -c 'ParameterizedQueries: true' modules/lagoon/connection.go` prints 1.
|
||||
- `grep -c 'InstallDefault' modules/sunscreen/README.md` prints at least 1 and `grep -c 'diary' modules/sunscreen/README.md` prints at least 1.
|
||||
</acceptance_criteria>
|
||||
<done>River, GORM and every framework module write through named diary components, so their lines carry a component, obey log.components and verbosity, and are redacted on every channel.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| application and plugin code to log channels | attribute values may carry user data, tokens or SQL parameters |
|
||||
| operator config and environment to diary | log.yaml and SUMMER_LOG__ variables choose paths, levels and drivers |
|
||||
| log files on the host | daily files are read by other host users and by backups |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-19.8-01 | Information disclosure | diary channels (channels.go) | high | mitigate | every leaf wrapped by sunscreen.Wrap, including stderr, daily and null; TestDiaryTracerSmoke asserts [REDACTED] on the console and in the daily file; plan 4 asserts it per driver |
|
||||
| T-19.8-02 | Information disclosure | GORM logger (lagoon/connection.go) | medium | mitigate | ParameterizedQueries true; SQL traces only at -vvv; record-not-found not logged; values never interpolated into log lines |
|
||||
| T-19.8-03 | Tampering | console handler (console.go) | medium | mitigate | control characters other than tab stripped from messages and attribute values before writing to a terminal, so logged user data cannot inject escape sequences |
|
||||
| T-19.8-04 | Tampering | daily path (log.channels.*.path) | low | mitigate | relative paths cleaned and confined to the application root, an escaping path is a config error naming the key; absolute paths are an explicit operator choice |
|
||||
| T-19.8-05 | Information disclosure | daily files (daily.go) | medium | mitigate | files 0o640 and directories 0o750; retention deletes only base-YYYY-MM-DD+ext files |
|
||||
| T-19.8-06 | Denial of service | daily writer | medium | mitigate | lazy open, retention days (14 by default), an unwritable directory warns once and disables the channel, boot never fails on a log file (TestDailyDegradesSmoke) |
|
||||
| T-19.8-SC | Tampering | Go module installs (go.mod) | high | mitigate | versions pinned from the RESEARCH legitimacy audit (proxy.golang.org and charm.land go-import origins), decision note committed first, go.sum verified by the toolchain, acceptance criterion proves only bonfire and diary import charm modules |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- Task 1: the note precedes go.mod's lipgloss requirement; TestDiaryTracerSmoke; the generated-main test, goldens and examples/hello; docs checker.
|
||||
- Task 2: TestConfigEnvSmoke, TestDailyDegradesSmoke; summer new goldens; docs checker.
|
||||
- Task 3: the whole short suite; TestRiverLoggerUsesDiarySmoke and TestGormLoggerUsesDiarySmoke; no framework module logs through the process default logger except surf's request log.
|
||||
- Feedback sampling: after each commit run `go test -short ./modules/diary/... ./modules/bonfire/... ./internal/build/...`; after the plan, `go vet ./... && go test -short ./... && go -C examples/hello test ./...`.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- D-02: decision note on record before any charm import.
|
||||
- D-08, D-10: Laravel-shaped config with framework defaults, env strings, daily files on by default, summer new's config/log.yaml.
|
||||
- D-09, D-12: component loggers everywhere, redaction on every channel, InstallDefault unchanged.
|
||||
- D-04 and D-05 (logging): styled on a terminal, logfmt or json otherwise; SetVerbosity ready for plan 2.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/19.8-console-commands-overhaul/19.8-01-SUMMARY.md` when done, with the commits in order (planning doc first), the charm versions actually installed, the final exported diary and bonfire names (plan 2 builds on them), any cabana or other files skipped because another session had them modified, and any deviation from this plan's names.
|
||||
</output>
|
||||
@@ -0,0 +1,261 @@
|
||||
---
|
||||
phase: 19.8-console-commands-overhaul
|
||||
plan: 01
|
||||
subsystem: logging
|
||||
tags: [slog, diary, lipgloss, colorprofile, logging, gorm, river, sunscreen]
|
||||
|
||||
requires:
|
||||
- phase: 08
|
||||
provides: sunscreen redaction handler (sunscreen.Wrap)
|
||||
provides:
|
||||
- "modules/diary: For, Bootstrap, Setup, SetVerbosity, Verbosity, Reset, Close, ParseConfig, DefaultConfig, NewConsoleHandler"
|
||||
- "Laravel-shaped config/log.yaml (log.default, log.level, log.channels, log.components) with console, stderr, daily, stack and null drivers"
|
||||
- "bonfire palette and color detection: ColorMode, Palette, DefaultPalette, DetectProfile"
|
||||
- "Generated main runs diary.Bootstrap first and diary.Setup after compass.Load; summer new writes config/log.yaml"
|
||||
- "River (river), GORM (db) and every framework module log through diary component loggers"
|
||||
affects: [19.8-02, 19.8-03, 19.8-04, console, logging, surf request log]
|
||||
|
||||
actuals:
|
||||
tokens: 53887
|
||||
tasks: 3
|
||||
commits: 6
|
||||
plan_head_before: bde7d9fcb7a2a8dcb0ba9eaa7bcbfd2e22975679
|
||||
plan_head_after: 44291adccaf711cf1a32fd7fdb203a7a375081b9
|
||||
|
||||
tech-stack:
|
||||
added:
|
||||
- "charm.land/lipgloss/v2 v2.0.6"
|
||||
- "github.com/charmbracelet/colorprofile v0.4.3"
|
||||
patterns:
|
||||
- "Proxy component handler: diary.For returns a logger whose handler looks up the live channel tree at log time, so package-level loggers created before setup route correctly"
|
||||
- "Every channel leaf wrapped by sunscreen.Wrap and gated by its own slog.LevelVar; stacks via slog.NewMultiHandler"
|
||||
- "Per-package unexported moduleLog = diary.For(<package>); app-published *slog.Logger still wins where a module already preferred it"
|
||||
|
||||
key-files:
|
||||
created:
|
||||
- .planning/notes/2026-10-21-console-charm-stack.md
|
||||
- modules/bonfire/styles.go
|
||||
- modules/diary/diary.go
|
||||
- modules/diary/config.go
|
||||
- modules/diary/component.go
|
||||
- modules/diary/console.go
|
||||
- modules/diary/daily.go
|
||||
- modules/diary/channels.go
|
||||
- modules/diary/README.md
|
||||
- modules/diary/tracer_smoke_test.go
|
||||
- modules/diary/config_smoke_test.go
|
||||
- modules/diary/example_test.go
|
||||
- modules/cabana/log.go
|
||||
- modules/conga/logger_smoke_test.go
|
||||
- modules/lagoon/logger_smoke_test.go
|
||||
- internal/build/testdata/new/config__log.yaml.golden
|
||||
modified:
|
||||
- internal/build/build.go
|
||||
- internal/build/new.go
|
||||
- internal/build/stubs/new.tmpl
|
||||
- examples/hello/main.go
|
||||
- modules/conga/client.go
|
||||
- modules/lagoon/connection.go
|
||||
- modules/sunscreen/sunscreen.go
|
||||
- docs/services/logging.md
|
||||
- docs/setup/configuration.md
|
||||
- docs/services/configuration.md
|
||||
- README.md
|
||||
- go.mod
|
||||
|
||||
key-decisions:
|
||||
- "Charm v2 stack on charm.land paths recorded before any charm import; this plan installs only lipgloss v2.0.6 and colorprofile v0.4.3, plan 2 installs bubbletea v2.1.0, bubbles v2.2.1, huh v2.0.3 and fang v2.0.1 (newest tags on 2026-10-21, equal to the research pins)"
|
||||
- "diary's console handler is its own lipgloss handler; charm's slog logger rejected (fails testing/slogtest); daily files use a stdlib writer, lumberjack rejected"
|
||||
- "format auto picks the styled console handler when bonfire.DetectProfile reports more than NoTTY, so --no-ansi (ColorNever) gives logfmt lines, not unstyled console lines"
|
||||
- "Daily retention keeps the files of today and the days-1 days before (Laravel's maxFiles count), not days+1 files"
|
||||
- "Framework defaults also define stderr (logfmt) and null channels so log.default can name them without defining them"
|
||||
- "wristband and bouncer now import diary (plan: every framework module); their READMEs no longer call them dependency-free leaves. This amends the leaf consequence of .planning/notes/2026-10-19-oidc-on-wristband-golang-jwt.md and needs the user's confirmation"
|
||||
|
||||
patterns-established:
|
||||
- "Component names are plugin or module codes without dots; framework modules use their package name"
|
||||
- "Tests that set diary up call t.Cleanup(diary.Reset), which restores slog.Default and the log package's output"
|
||||
|
||||
requirements-completed: [SC-1, SC-2, D-01, D-02, D-04, D-05, D-08, D-09, D-10, D-12]
|
||||
|
||||
coverage:
|
||||
- id: D1
|
||||
description: "Charm v2 decision note committed before go.mod requires lipgloss, with STACK.md rows"
|
||||
requirement: D-02
|
||||
verification:
|
||||
- kind: other
|
||||
ref: "git merge-base --is-ancestor e6039b4 e903cc3 (acceptance criterion 1 of Task 1)"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D2
|
||||
description: "A component logger record flows from config/log.yaml through compass into a redacting console and daily stack"
|
||||
requirement: SC-1
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "modules/diary/tracer_smoke_test.go#TestDiaryTracerSmoke"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D3
|
||||
description: "SUMMER_LOG__ overrides parse as strings and overlay defaults; invalid values fail naming the key"
|
||||
requirement: D-08
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "modules/diary/config_smoke_test.go#TestConfigEnvSmoke"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D4
|
||||
description: "Unwritable daily directory warns once on the console and never fails Setup"
|
||||
requirement: D-10
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "modules/diary/config_smoke_test.go#TestDailyDegradesSmoke"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D5
|
||||
description: "Generated main installs diary (Bootstrap first, Setup after compass.Load); summer new writes config/log.yaml"
|
||||
requirement: D-10
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "internal/build/build_test.go#TestGenerateMainInstallsDiary"
|
||||
status: pass
|
||||
- kind: unit
|
||||
ref: "internal/build/new_golden_test.go#TestNewGolden"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D6
|
||||
description: "River logs as component river and GORM as component db with parameterized SQL traces only at verbosity 3"
|
||||
requirement: D-09
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "modules/conga/logger_smoke_test.go#TestRiverLoggerUsesDiarySmoke"
|
||||
status: pass
|
||||
- kind: unit
|
||||
ref: "modules/lagoon/logger_smoke_test.go#TestGormLoggerUsesDiarySmoke"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D7
|
||||
description: "Styled console lines on a real terminal look right in bonfire's palette (time, level colors, component prefix)"
|
||||
requirement: D-04
|
||||
verification: []
|
||||
human_judgment: true
|
||||
rationale: "Color rendering on a real terminal cannot be asserted by the short suite; a throwaway probe showed the expected ANSI sequences, plan 4 adds goldens"
|
||||
|
||||
duration: 30min
|
||||
completed: 2026-10-21
|
||||
status: complete
|
||||
---
|
||||
|
||||
# Phase 19.8 Plan 01: diary logging module Summary
|
||||
|
||||
**New `diary` module: Laravel-shaped `config/log.yaml` channels (console, stderr, daily, stack, null) with per-component levels, sunscreen on every channel, daily JSON files on by default, and River, GORM and every framework module logging through named component loggers.**
|
||||
|
||||
## Performance
|
||||
|
||||
- **Duration:** 30 min
|
||||
- **Started:** 2026-10-21T09:28:13Z
|
||||
- **Completed:** 2026-10-21T09:58:29Z
|
||||
- **Tasks:** 3
|
||||
- **Files modified:** 86 (code, docs and goldens; 2,414 insertions, 172 deletions)
|
||||
|
||||
## Accomplishments
|
||||
|
||||
- Decision note `.planning/notes/2026-10-21-console-charm-stack.md` and STACK.md rows committed first (D-02).
|
||||
- `modules/diary`: proxy component loggers safe in package vars, a lipgloss console handler in bonfire's palette with control-character stripping, a stdlib daily writer (0640 files, 0750 dirs, lazy open, retention, degrade-once), channels built as sunscreen-wrapped leaves under `slog.NewMultiHandler`, runtime `SetVerbosity` with quiet affecting only console channels.
|
||||
- `bonfire`: `ColorMode` (`ColorAuto`, `ColorAlways`, `ColorNever`), `Palette`, `DefaultPalette`, `DetectProfile`.
|
||||
- Generated main: `diary.Bootstrap(os.Stderr, diary.Options{})` as the first statement of `run`, `diary.Setup(cfg, diary.Options{})` after `compass.Load`; no sunscreen import. `examples/hello/main.go` regenerated. `summer new` writes `config/log.yaml`.
|
||||
- River through `diary.For("river")`, GORM through `logger.NewSlogLogger(diary.For("db"), ...)`, and every framework module through a package `moduleLog`; only surf's request log still uses the process default (plan 3).
|
||||
- Docs: `docs/services/logging.md` rewritten (Channels, config/log.yaml, Component loggers, Daily files, Redaction, Logging from a plugin), log keys in both configuration pages, READMEs for diary, bonfire, sunscreen, conga, lagoon and every module that now imports diary.
|
||||
|
||||
## Task Commits
|
||||
|
||||
1. **Planning doc (D-02)**: `e6039b4` docs(19.8): decide the charm v2 console stack
|
||||
2. **Task 1: tracer**: `e903cc3` feat(diary): component loggers through a redacting console and daily stack
|
||||
3. **Task 2: channels and config**: `5b09b32` feat(diary): channels, components and config/log.yaml
|
||||
4. **Task 3a: River and GORM**: `e6b20e2` feat(conga,lagoon): River and GORM log through diary components
|
||||
5. **Task 3b: framework modules**: `44291ad` refactor: framework modules log through diary components
|
||||
|
||||
`commits: 6` is measured from the ledger (`bde7d9f..44291ad`); one of the six, `71b9931` docs(19.5), belongs to another session that committed in between. This plan made five commits.
|
||||
|
||||
## Charm versions installed
|
||||
|
||||
- `charm.land/lipgloss/v2` v2.0.6 and `github.com/charmbracelet/colorprofile` v0.4.3 (direct requirements in go.mod).
|
||||
- Not installed yet (plan 2): `charm.land/bubbletea/v2` v2.1.0, `charm.land/bubbles/v2` v2.2.1, `charm.land/huh/v2` v2.0.3, `charm.land/fang/v2` v2.0.1. `go list -m -versions` on 2026-10-21 showed these as the newest tags.
|
||||
|
||||
## Exported names for plan 2
|
||||
|
||||
- **diary:** `For`, `Bootstrap(w io.Writer, opts Options)`, `Setup(cfg *compass.Config, opts Options) error`, `SetVerbosity(v int, quiet bool)`, `Verbosity() int`, `Reset()`, `Close() error`, `ParseConfig`, `DefaultConfig`, `NewConsoleHandler(w io.Writer, opts ConsoleOptions) slog.Handler`; types `Options{Verbosity, Quiet, Color, Root, Stderr, Environ, Now}`, `Config{Default, Level, Channels, Components}`, `ChannelConfig{Driver, Format, Path, Level, HasLevel, Days, Channels}`, `ConsoleOptions{Level, Profile, AddSource}`; constants `ComponentKey`, `DriverConsole`, `DriverStderr`, `DriverDaily`, `DriverStack`, `DriverNull`, `FormatAuto`, `FormatJSON`, `FormatLogfmt`, `DefaultKey`, `LevelKey`, `ChannelsKey`, `ComponentsKey`. All names match the plan.
|
||||
- **bonfire:** `ColorMode`, `ColorAuto`, `ColorAlways`, `ColorNever`, `Palette{Success, Current, Warning, Error, ErrorBold, Muted, Header}`, `DefaultPalette()`, `DetectProfile(w io.Writer, mode ColorMode, environ []string) colorprofile.Profile`. `output.go` unchanged.
|
||||
- Verbosity semantics for plan 2's flags: 1 lowers every component floor to at most info, 2 or more to debug, 3 or more adds source locations on styled console lines and GORM SQL traces (`gormLogger` reads `diary.Verbosity()` when the connection opens); quiet raises only console channels to warn. Before Bootstrap or Setup, `SetVerbosity` only changes what `Verbosity` returns; `Options.Verbosity`/`Options.Quiet` set a new setup's values.
|
||||
|
||||
## Files skipped because of other sessions
|
||||
|
||||
None. `git status` showed no other session's edits under `modules/` before Task 3 (the only foreign edits were `.planning/STATE.md`, `.planning/state.json`, `.msd/`, a 19.4.1 `.gitkeep`, and a 19.5 CONTEXT edit that was committed by its session meanwhile); none were staged.
|
||||
|
||||
## Decisions Made
|
||||
|
||||
See `key-decisions` in the front matter. The two worth a second look:
|
||||
|
||||
- **wristband and bouncer imports.** The plan's "every framework module" rule (D-09, acceptance criteria) makes `wristband` and `bouncer` import `diary`, which pulls `bonfire` and `compass` into modules documented as dependency-free leaves. The 2026-10-19 decision note says "wristband stays a protocol leaf: no ORM, no framework module". I followed the plan and amended both READMEs; reverting is two lines per module if the user prefers to keep them leaves (their log calls would then stay on the process default logger).
|
||||
- **Daily retention count.** `days: 14` keeps 14 files (today and 13 before), as Laravel's `days` does; the plan's wording ("more than days days old") would keep 15. The docs and README say which.
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
### Auto-fixed Issues
|
||||
|
||||
**1. [Rule 3 - Blocking] Supplied-root pin guard always failed**
|
||||
- **Found during:** start of execution
|
||||
- **Issue:** the orchestrator's pin script substituted the pinned path into its own unbound-placeholder sentinel (`''|'<pinned path>') PIN_STAGE=pin-unbound`), so it reported `pin-unbound` FATAL for the correct root.
|
||||
- **Fix:** ran a scratchpad copy with only that sentinel restored to the literal `{PINNED_ROOT}`; every real check (actual toplevel against the pinned toplevel) unchanged and passing before each write and commit. The orchestrator's script was not modified.
|
||||
|
||||
**2. [Rule 3 - Blocking] TestNewGolden pins a fixed file list**
|
||||
- **Found during:** Task 2
|
||||
- **Fix:** added `config/log.yaml` to the list in `internal/build/new_golden_test.go` (not in the plan's file list) so `config__log.yaml.golden` is created and checked.
|
||||
|
||||
**3. [Rule 3 - Blocking] Go fences in docs need a src= reference**
|
||||
- **Found during:** Task 2 (docs checker)
|
||||
- **Fix:** added `modules/diary/example_test.go` (`ExampleFor`, runnable with output; package var `blogLog`) and referenced it from `docs/services/logging.md` with `src=`; shortened the page's front-matter description below 160 characters.
|
||||
|
||||
**4. [Rule 2 - CLAUDE.md docs rule] READMEs of every module that now imports diary**
|
||||
- **Found during:** Task 3
|
||||
- **Fix:** Dependencies sections of cabana, cooler, postcard, beachcomber, lighthouse, flare, phrasebook, wristband and bouncer list `diary`; postcard's `NewLogDriver` row and doc comment, and the `Logger()` doc comments of beachcomber, lighthouse and flare, describe the new fallback. Not in the plan's file list.
|
||||
|
||||
**5. [Rule 1 - Bug] gofmt import order in console.go**
|
||||
- **Found during:** Task 2; the Task 1 commit had an unsorted import block in `modules/diary/console.go`, fixed by `gofmt -w` in the Task 2 commit.
|
||||
|
||||
**6. Example modules' go.mod/go.sum left unchanged**
|
||||
- **Plan said:** run `go mod tidy` in the examples/hello modules whose build asks for go.sum entries (files list names hello and greeter go.sum).
|
||||
- **Found:** inside the repository's go.work every example build and test passes without changes; `go mod tidy` there only pulled pre-existing drift (aws-sdk and others, and dropped a toolchain line). Reverted; recorded in `deferred-items.md`.
|
||||
|
||||
**7. Tracer test logs two records to the daily file**
|
||||
- The plan's tracer asserts "a single JSON line"; the same test also logs from the pre-setup package-var logger (as the plan asks), so the daily file holds two lines and the test checks the first one.
|
||||
|
||||
---
|
||||
|
||||
**Total deviations:** 7 (3 blocking fixes, 1 docs rule, 1 formatting fix, 2 recorded differences). **Impact:** none on the plan's API names or behaviour; extra README updates keep the documentation rule.
|
||||
|
||||
## Issues Encountered
|
||||
|
||||
- `TestOIDCSubjectIsRandomSmoke` (wristband) failed once: the random subject contained `ada`, which the test rejects as the fixture user's name. Pre-existing flake, passed on rerun; logged in `deferred-items.md`.
|
||||
|
||||
## Known Stubs
|
||||
|
||||
None.
|
||||
|
||||
## Threat Flags
|
||||
|
||||
None beyond the plan's threat model: daily files under the application root (T-19.8-04, T-19.8-05, T-19.8-06), redaction on every leaf including stderr and null (T-19.8-01), parameterized GORM traces (T-19.8-02), control-character stripping on the console (T-19.8-03).
|
||||
|
||||
## Verification
|
||||
|
||||
- `go vet ./...`: clean.
|
||||
- `go test -count=1 -short ./...`: green (after the one wristband flake rerun).
|
||||
- `go -C examples/hello vet ./...` and `go -C examples/hello test -count=1 ./...`: green.
|
||||
- `TestDiaryTracerSmoke`, `TestConfigEnvSmoke`, `TestDailyDegradesSmoke`, `TestRiverLoggerUsesDiarySmoke`, `TestGormLoggerUsesDiarySmoke`: `--- PASS` by name.
|
||||
- `TestDocsTree` and `summer docs:build --check`: no problems.
|
||||
- Acceptance criteria of all three tasks re-run: pass (no `slog.Default()` or package-level `slog.Info/Warn/Error/Debug` calls left in non-test framework code outside diary and surf's access log; only bonfire and diary import charm modules; nothing imports `gopkg.in/yaml.v3`; no consuming application named).
|
||||
- Nothing pushed or tagged.
|
||||
|
||||
## Next Phase Readiness
|
||||
|
||||
Ready for 19.8-02: global flags call `diary.SetVerbosity`, the bonfire widgets move onto `Palette`/`DetectProfile`, and plan 2 installs the remaining four charm modules.
|
||||
|
||||
## Self-Check: PASSED
|
||||
347
.planning/phases/19.8-console-commands-overhaul/19.8-02-PLAN.md
Normal file
347
.planning/phases/19.8-console-commands-overhaul/19.8-02-PLAN.md
Normal file
@@ -0,0 +1,347 @@
|
||||
---
|
||||
phase: 19.8-console-commands-overhaul
|
||||
plan: 02
|
||||
type: execute
|
||||
wave: 2
|
||||
depends_on: ["19.8-01"]
|
||||
files_modified:
|
||||
- go.mod
|
||||
- go.sum
|
||||
- modules/bonfire/command.go
|
||||
- modules/bonfire/root.go
|
||||
- modules/bonfire/globals.go
|
||||
- modules/bonfire/version.go
|
||||
- modules/bonfire/execute.go
|
||||
- modules/bonfire/list.go
|
||||
- modules/bonfire/help.go
|
||||
- modules/bonfire/docs.go
|
||||
- modules/bonfire/errors.go
|
||||
- modules/bonfire/styles.go
|
||||
- modules/bonfire/output.go
|
||||
- modules/bonfire/widgets.go
|
||||
- modules/bonfire/prompts.go
|
||||
- modules/bonfire/live.go
|
||||
- modules/bonfire/selector.go
|
||||
- modules/bonfire/helpers.go
|
||||
- modules/bonfire/list_smoke_test.go
|
||||
- modules/bonfire/help_smoke_test.go
|
||||
- modules/bonfire/widgets_smoke_test.go
|
||||
- modules/bonfire/README.md
|
||||
- modules/diary/diary.go
|
||||
- modules/diary/console.go
|
||||
- modules/diary/README.md
|
||||
- internal/build/build.go
|
||||
- internal/build/build_test.go
|
||||
- internal/build/testdata/new/main.go.golden
|
||||
- examples/hello/main.go
|
||||
- examples/hello/hello_test.go
|
||||
- examples/hello/go.sum
|
||||
- examples/hello/plugins/greeter/go.sum
|
||||
- cmd/summer/main.go
|
||||
- cmd/summer/docs.go
|
||||
- internal/docsite/check_commands.go
|
||||
- docs/console/introduction.md
|
||||
- docs/console/writing-commands.md
|
||||
- docs/services/logging.md
|
||||
- .planning/todos/pending/bonfire-duplicate-command-names.md
|
||||
- .planning/todos/done/bonfire-duplicate-command-names.md
|
||||
autonomous: true
|
||||
requirements: [SC-2, SC-3, D-01, D-02, D-03, D-04, D-13, D-14, D-15, D-16, D-17, D-18, D-27, D-28, D-30]
|
||||
user_setup: []
|
||||
|
||||
estimate:
|
||||
tokens: 230000
|
||||
raw_tokens: 230000
|
||||
tasks: 3
|
||||
confidence: low
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "D-13: running an application binary or summer with no arguments (or with only -h/--help) prints the Artisan list: a 'SummerCMS <name> <version> (framework <version>)' header from debug.ReadBuildInfo, Usage, the global options, then 'Available commands:' with top-level commands first and namespaces (the part before the first colon) sorted under their own header lines, descriptions aligned; exit code 0."
|
||||
- "SC-2, D-04: every command accepts -v/-vv/-vvv (--verbose), -q/--quiet, --ansi/--no-ansi, -n/--no-interaction and --env; bonfire.PeekGlobals reads them before config loads so --env picks the compass environment and diary starts at the right verbosity, and the root's OnGlobals hook re-applies them to diary after cobra parses; a bonfire.Call root never runs the hook."
|
||||
- "D-14, D-15: '<binary> help <command>', 'summer help <command>' and '<command> --help' print Description, Usage, Arguments, Options with [default: ...], Global options, Help (Long) and Examples; bonfire.Command gains Examples []Example and Hidden bool at the end of the struct, so existing plugins compile unchanged; Hidden commands run but are left out of list unless list --all."
|
||||
- "D-16, D-17: 'list --format=json' prints every command (hidden ones flagged) with arguments, options, defaults and examples as a bonfire.Listing; bonfire.CheckDocs(commands) returns one DocProblem per command lacking a description, long help, examples, or an argument or option description."
|
||||
- "D-18: errors render as a styled block on stderr: ERROR and the cause, the explanation and a Try: line from bonfire.WithHint (with {bin} replaced by bin/<name> or summer), 'Run with -v for details.' at default verbosity and the wrapped chain at -v; cobra's 'Did you mean' becomes the Try line; bonfire.Execute returns 2 for usage errors (unknown command or flag, wrong argument count), 1 for failures, and prints nothing more for errors wrapped by bonfire.Reported; message text passes through sunscreen.Scrub."
|
||||
- "D-01, D-02, D-30: on a terminal, Spinner, Progress, bonfire.Process, Table and Choice render through ported SSU widgets on bubbletea/bubbles/lipgloss v2 (spinner, multi-row process view, gradient progress bar, single arrow-key selector with a confirmation screen; no multi-select), Ask/Confirm/Secret use huh; fang.Execute runs with WithoutVersion, WithoutManpage, WithoutCompletions and WithErrorHandler; the Output interface is unchanged."
|
||||
- "D-03, D-27, D-28: off a terminal, under -q, --no-ansi or -n every widget prints plain lines without animation and today's non-TTY bytes are unchanged; -q suppresses Info, Success, Warning, spinners and progress but keeps Println/Printf/Write, tables, --json data and Error; -q implies -n; with -n prompts return their defaults and Secret without piped stdin fails with a hint; bonfire.ConfirmProceed returns true without asking whenever the Output is not interactive."
|
||||
- "Folded todo: bonfire.NewRoot fails with ErrDuplicateCommand naming the command when two commands share a name (or a command is named list or help), and with ErrReservedFlag when a command flag reuses a global name or shorthand; the bonfire README and docs/console/writing-commands.md say so and the todo moves to .planning/todos/done/."
|
||||
artifacts:
|
||||
- path: "modules/bonfire/globals.go"
|
||||
provides: "Globals, PeekGlobals, Globals.Args"
|
||||
contains: "func PeekGlobals(args []string) Globals"
|
||||
- path: "modules/bonfire/execute.go"
|
||||
provides: "Execute, Fail, exit codes, narrow fang"
|
||||
contains: "fang.WithoutVersion()"
|
||||
- path: "modules/bonfire/list.go"
|
||||
provides: "Artisan list, list --all, list --format=json, Listing"
|
||||
contains: "Available commands:"
|
||||
- path: "modules/bonfire/help.go"
|
||||
provides: "Artisan per-command help"
|
||||
contains: "Examples:"
|
||||
- path: "modules/bonfire/live.go"
|
||||
provides: "ported spinner, process and progress models (bubbletea v2)"
|
||||
contains: "tea.WithInput(nil)"
|
||||
key_links:
|
||||
- from: "internal/build/build.go"
|
||||
to: "modules/bonfire/execute.go"
|
||||
via: "generated main returns bonfire.Execute's exit code"
|
||||
pattern: "bonfire.Execute\\("
|
||||
- from: "internal/build/build.go"
|
||||
to: "modules/diary/diary.go"
|
||||
via: "PeekGlobals feeds diary before compass, OnGlobals re-applies after parsing"
|
||||
pattern: "bonfire.OnGlobals\\(diary.ApplyGlobals\\)"
|
||||
- from: "modules/diary/console.go"
|
||||
to: "modules/bonfire/helpers.go"
|
||||
via: "log lines print above a running live view"
|
||||
pattern: "bonfire.PrintAbove\\("
|
||||
prohibitions:
|
||||
- statement: "No method is added to bonfire.Output, Input, Progress or Command's existing fields; new behaviour arrives through added struct fields, optional interfaces and helper functions only."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "No prompt blocks a run whose stdin or stdout is not a terminal, or that has -n or -q."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "Framework code, READMEs and docs name no consuming application."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "Nothing is pushed or tagged in this plan."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
---
|
||||
|
||||
## Phase Goal
|
||||
|
||||
ROADMAP Goal (not in user-story format; no story is invented, see plan 01): console commands reach the Artisan bar; a user sees what a command did, can ask for more or less of it, and learns from `--help` how to use it.
|
||||
|
||||
This plan's slice (plan 2 of 4, wave 2): the shared console layer in `bonfire`. The tracer makes a bare application binary print the Artisan list, carries the global flags into `diary` before plugins boot, and returns exit codes through `bonfire.Execute` in the generated `main` and in `summer`. Then per-command help, `help <command>`, Examples and Hidden, the JSON listing, the documentation coverage helper and the styled error block with hints. Last, SSU's widgets ported to charm v2 behind the unchanged `Output` interface, with the degradation rules for non-TTY, `-q`, `--no-ansi` and `-n`.
|
||||
|
||||
## Spec-less probe fallback
|
||||
|
||||
Spec-less probe fallback skipped: no requirement IDs (phase_req_ids null) and no SPEC.md; visible choice. `must_haves` cite SC-2, SC-3 and D-01..D-30.
|
||||
|
||||
## Decisions taken in this plan (recorded assumptions and Claude's discretion)
|
||||
|
||||
- Global flag semantics (CONTEXT discretion, following Artisan and Symfony): `-q` implies `-n` (Symfony sets non-interactive when quiet); `--ansi` and `--no-ansi` are last-wins; `--env X` and `--env=X` both work and reach `compass.Options.Env`, which already validates the value; `-v` counts up to 3.
|
||||
- Usage name: `summer` for the tool, `bin/<name>` for application binaries (the D-13 preview). Example commands are stored without the binary (`admin:token:create jane --json`) and rendered with the usage name, so one Example serves both `summer` delegates and the application binary.
|
||||
- Per-command help puts the command's own options under "Options:" (the D-14 preview) and the global flags in a separate compact "Global options:" block, then "Help:" with Long, then "Examples:". The preview's Options block stays exactly as approved; the extra blocks teach the global flags and show the long help SC-3 requires.
|
||||
- Defaults render as `[default: "x"]`, unquoted when the default parses as an integer (`[default: 90]`, D-14 preview); bare flags show no default; repeatable flags add "(multiple values allowed)".
|
||||
- fang (D-02) is used through `fang.Execute` with `WithoutVersion`, `WithoutManpage`, `WithoutCompletions` and `WithErrorHandler`; `-h`/`--help` without a command is routed to the list before fang runs (RESEARCH Pattern 5, option b); every subcommand gets its own help func, which cobra prefers over the root's.
|
||||
- Choice uses the ported single selector with SSU's confirmation screen (D-01, D-30); Ask, Confirm and Secret use huh (D-02). Multi-select, the filter and the detail pane are not ported (D-30).
|
||||
- `ConfirmProceed` encodes D-28 for plan 3: it asks only on an interactive terminal; off a terminal, under `-n`/`-q` or in `bonfire.Call` it returns true without asking, so deploys over SSH never block.
|
||||
- `-n` with Secret: when stdin is piped the line is read (admin:create in scripts keeps working); when stdin is a terminal the prompt fails with a hint to pipe the value, because Secret has no default.
|
||||
|
||||
## Artifacts this phase produces (this plan)
|
||||
|
||||
- bonfire (`git.golem15.com/golem15/summercms/modules/bonfire`):
|
||||
- Types: `Globals{Verbosity int; Quiet bool; Color ColorMode; NoInteraction bool; Env string}`, `RootOption`, `Version{App, Framework string; Local bool}`, `Example{Command, Description string}`, `Listing{Application ApplicationInfo; GlobalOptions []OptionInfo; Commands []CommandInfo}`, `ApplicationInfo`, `CommandInfo`, `ArgumentInfo`, `OptionInfo`, `ExampleInfo`, `DocProblem{Command string; Missing []string}`, `HintError{Err error; Explanation, Try string}`, `UsageError{Err error}`, `ItemProgress` (optional interface: Progress plus `Current(item string)` and `Fail(item string, err error)`).
|
||||
- Command fields (appended): `Examples []Example`, `Hidden bool`.
|
||||
- Functions: `PeekGlobals(args []string) Globals`, `(Globals).Args() []string`, `WithVersion(v Version) RootOption`, `OnGlobals(fn func(Globals)) RootOption`, `BuildVersion() Version`, `(Version).Header(name string) string`, `Execute(ctx context.Context, root *cobra.Command, args []string) int`, `Fail(w io.Writer, name string, err error, g Globals) int`, `RenderError(w io.Writer, name string, err error, g Globals)`, `WithHint(err error, explanation, try string) error`, `Reported(err error) error`, `Usage(err error) error`, `Describe(name string, commands []Command, v Version) Listing`, `CheckDocs(commands []Command) []DocProblem`, `GlobalsOf(out Output) Globals`, `VerbosityOf(out Output) int`, `IsQuiet(out Output) bool`, `Interactive(out Output) bool`, `ConfirmProceed(out Output, question string) (bool, error)`, `Process(ctx context.Context, out Output, operation string, items []string, fn func(ctx context.Context, item string) error) error`, `Current(p Progress, item string)`, `Failed(p Progress, item string, err error)`, `PrintAbove(line string) bool`.
|
||||
- Variables and constants: `ExitOK = 0`, `ExitFailure = 1`, `ExitUsage = 2`, `ErrDuplicateCommand`, `ErrReservedFlag`, `ErrReported`, `ErrAborted`.
|
||||
- CLI: `list` (`--all`, `--format=txt|json`), `help [command]`; persistent flags `-v|--verbose` (count), `-q|--quiet`, `--ansi`, `--no-ansi`, `-n|--no-interaction`, `--env`.
|
||||
- Smoke tests: `TestListTracerSmoke`, `TestHelpSmoke`, `TestListJSONSmoke`, `TestErrorRenderSmoke`, `TestWidgetMatrixSmoke`.
|
||||
- diary: `ApplyGlobals(g bonfire.Globals)`, `FromGlobals(g bonfire.Globals) Options`; the console handler prints through `bonfire.PrintAbove` while a live view runs.
|
||||
- Generated main: `func main() { os.Exit(run(os.Args[1:], os.Stdout)) }`, `func run(args []string, out io.Writer) int` with `bonfire.PeekGlobals`, `compass.Open(compass.Options{Dir: "config", Env: g.Env})`, `bonfire.Fail` before the root exists, `bonfire.NewRoot(..., bonfire.WithVersion(bonfire.BuildVersion()), bonfire.OnGlobals(diary.ApplyGlobals))`, `bonfire.Execute`.
|
||||
- examples/hello: `TestBuiltBinaryListsCommands`; updated `TestHelpListsServe`, `TestMigrateWithoutDSNFailsLoudly`, `TestBuiltBinaryUnknownCommandExitsNonZero` (exit 2).
|
||||
- docs checker: `commandWord` knows the bool globals and `--env`; `docsCommands` lists `list` and `help` for the tool and applications.
|
||||
- Dependencies installed: `charm.land/fang/v2`, `charm.land/bubbletea/v2`, `charm.land/bubbles/v2`, `charm.land/huh/v2` at the decision note's versions.
|
||||
|
||||
<objective>
|
||||
Make bonfire Artisan-grade: global flags on every command that reach diary before boot, the grouped list and per-command help with examples, a JSON listing and a coverage helper, styled errors with hints and exit codes, duplicate-name rejection, and SSU's widgets on charm v2 that degrade cleanly, all behind the unchanged Output interface.
|
||||
|
||||
Purpose: SC-2, SC-3 (framework side), D-01, D-02 (rest of the stack), D-03, D-04 (flags), D-13..D-18, D-27, D-28 (helper), D-30, folded todo bonfire-duplicate-command-names.
|
||||
Output: three code commits with README and docs, one planning-doc commit closing the todo.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/msd-core/workflows/execute-plan.md
|
||||
@~/.claude/msd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/phases/19.8-console-commands-overhaul/19.8-CONTEXT.md
|
||||
@.planning/phases/19.8-console-commands-overhaul/19.8-RESEARCH.md
|
||||
@.planning/phases/19.8-console-commands-overhaul/19.8-PATTERNS.md
|
||||
@.planning/phases/19.8-console-commands-overhaul/19.8-01-SUMMARY.md
|
||||
@modules/bonfire/README.md
|
||||
|
||||
Repos and concurrency: summercms.go only. Check `git status --porcelain` on each task's paths first and stop if another session has one of them modified. Stage by explicit path; no co-author tags; planning docs and code in separate commits; README and docs in the same commit as the API they describe; `go vet ./...`, `go test -short ./...` and `go -C examples/hello test ./...` green at every commit. Nothing is pushed or tagged.
|
||||
|
||||
<interfaces>
|
||||
Existing seams:
|
||||
|
||||
- modules/bonfire/command.go: `Command{Name, Description, Long string; Passthrough bool; Flags []Flag; Args []Arg; Run}` (lines 15-32), `Flag{Name, Shorthand, Description, Default string; Bare, Repeatable bool}`, `Arg{Name, Description string; Required bool}`, `Input`, `validCommandName` (bare names build, dev, new, serve, migrate), `commandUsage`, `positionalArgs`.
|
||||
- modules/bonfire/root.go: `NewRoot(name, commands, out)`, `NewRootIO(name, commands, in, out, errW)` (root with SilenceUsage, SilenceErrors, DisableDefaultCmd), `wrap(c, out)` (flag registration with StringP/StringSliceP, `NoOptDefVal = "true"` for Bare).
|
||||
- modules/bonfire/call.go: `Call(ctx, commands, name, args, out)` builds a fresh NewRootIO per call with empty stdin (scheduler path), `Catalog`, `findCommand`.
|
||||
- modules/bonfire/output.go: `Output` interface (Println, Printf, Info, Success, Error, Warning, Table, Spinner, Progress, Ask, Confirm, Choice, Secret, io.Writer), `console` struct with `tty`, `interactive`, `color`, injectable `lookup`; `newConsole(in, out, errW, lookup, tty, interactive *bool)`; widgets.go non-TTY fallbacks (`[...] message`, decile percentage lines, tab-separated table); prompts.go line-based prompts and `Secret` reading a plain line when stdin is not a terminal.
|
||||
- Existing tests that must stay green unchanged in intent: modules/bonfire/output_test.go (non-TTY output, NO_COLOR/TERM=dumb/FORCE_COLOR presence of escape codes, `TestHelpUsesSharedAdapter`), prompts_test.go, call_test.go, modules/cabana/commands_test.go:241-262 (WR-13 piped password), cmd/summer/main_test.go (help output contains each flag name and default).
|
||||
- modules/diary (plan 01): `Options`, `Bootstrap`, `Setup`, `SetVerbosity`, `NewConsoleHandler`; modules/bonfire/styles.go: `ColorMode`, `Palette`, `DefaultPalette`, `DetectProfile`.
|
||||
- internal/build/build.go generateMain (plan 01 shape) and `TestGenerateMainInstallsDiary`; examples/hello/hello_test.go (run(...) error today; TestHelpListsServe, TestMigrateWithoutDSNFailsLoudly, TestBuiltBinaryUnknownCommandExitsNonZero, runSummerBuild); internal/build/build_test.go `TestBuiltHelloFailsOnInvalidRequires` (combined output must still name both plugin IDs).
|
||||
- internal/docsite/check_commands.go:99-125 `commandWord` (assumes only --help/-h on the root), cmd/summer/docs.go:144-165 `docsCommands`, cmd/summer/docs_test.go:418 `generatedConstructor` regex over `commands = append(commands, pkg.Ctor(app` lines.
|
||||
- fang v2 source (module cache after go get): fang.go `Execute`, `WithoutVersion`, `WithoutManpage`, `WithoutCompletions`, `WithErrorHandler`, `ErrorHandler` signature; it wraps stderr in colorprofile.NewWriter before calling the handler (RESEARCH Pattern 5).
|
||||
- SSU widgets to port (external repo, read-only): /media/nvme/dev/pxpx/ssu/internal/cli/tui/spinner.go, process.go, progress.go, selector.go, selector_keys.go, confirm.go, styles.go; RESEARCH "Porting SSU's spinner to bubbletea v2" for the API renames, progress WithColors cyan #00afaf to green #00af00 and SetWidth(40).
|
||||
- cobra v1.10.2: a command's own helpFunc wins over its parent's; `SuggestionsFor`; `SetHelpCommand`; `SetFlagErrorFunc`.
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tracer">
|
||||
<name>Task 1: Tracer: a bare application binary prints the Artisan list with the version header and global options, -vv and -q on any command reach diary before plugins boot, and the process exits 0, 1 or 2 through bonfire.Execute</name>
|
||||
<reversibility rating="reversible">Rendering and flag plumbing sit behind NewRoot's unchanged signature (options are variadic); the generated main is rewritten by every summer build.</reversibility>
|
||||
<files>go.mod, go.sum, modules/bonfire/globals.go, modules/bonfire/root.go, modules/bonfire/version.go, modules/bonfire/execute.go, modules/bonfire/list.go, modules/bonfire/list_smoke_test.go, modules/bonfire/README.md, modules/diary/diary.go, modules/diary/README.md, internal/build/build.go, internal/build/build_test.go, internal/build/testdata/new/main.go.golden, examples/hello/main.go, examples/hello/hello_test.go, examples/hello/go.sum, examples/hello/plugins/greeter/go.sum, cmd/summer/main.go, cmd/summer/docs.go, internal/docsite/check_commands.go, docs/console/introduction.md, docs/console/writing-commands.md, .planning/todos/pending/bonfire-duplicate-command-names.md, .planning/todos/done/bonfire-duplicate-command-names.md</files>
|
||||
<read_first>modules/bonfire/root.go, modules/bonfire/command.go, modules/bonfire/call.go, modules/bonfire/output.go, modules/bonfire/README.md, modules/diary/diary.go, internal/build/build.go, internal/build/build_test.go, examples/hello/hello_test.go, cmd/summer/main.go, cmd/summer/docs.go, internal/docsite/check_commands.go, docs/console/introduction.md ("Getting help"), docs/console/writing-commands.md (the duplicate-names paragraph), .planning/todos/pending/bonfire-duplicate-command-names.md, .planning/G15OFFICE.md, 19.8-RESEARCH.md (Pattern 4, Pattern 5, Pitfalls 1-3 and 15, Code Examples "Generated main after this phase", Probe evidence), 19.8-CONTEXT.md (D-13 preview)</read_first>
|
||||
<action>Install `charm.land/fang/v2` at the decision note's version (`go get`, then `go mod tidy` once execute.go imports it, and tidy the examples/hello modules that need go.sum entries).
|
||||
|
||||
globals.go: `Globals` (fields under Artifacts). `PeekGlobals(args)`: a tolerant scan that never errors and stops at `--`: `-v`, `-vv`, `-vvv` and each `--verbose` add to Verbosity (capped at 3); `-q`/`--quiet`; `-n`/`--no-interaction`; `--ansi` and `--no-ansi` set Color to ColorAlways or ColorNever, last wins; `--env X` and `--env=X`; Quiet also sets NoInteraction. `(Globals).Args()` re-serialises for forwarding (`-v`/`-vv`/`-vvv`, `-q`, `-n`, `--ansi` or `--no-ansi`, `--env=<value>`), empty for the zero value.
|
||||
|
||||
root.go: `type RootOption func(*rootConfig)`, `WithVersion(v Version)`, `OnGlobals(fn func(Globals))`; `NewRoot(name, commands, out, opts ...RootOption)` and `NewRootIO(name, commands, in, out, errW, opts ...RootOption)`. Register persistent flags on the root: `--verbose`/`-v` as a count, `--quiet`/`-q`, `--ansi`, `--no-ansi`, `--no-interaction`/`-n`, `--env` (string), with the D-13 descriptions. In the command loop: a second command with the same name, or a command named `list` or `help`, returns `fmt.Errorf("%w: %q", ErrDuplicateCommand, name)`; in wrap, a flag whose Name is verbose, quiet, ansi, no-ansi, no-interaction, env or help, or whose Shorthand is v, q, n or h, returns an error wrapping ErrReservedFlag that names the command and the flag (checked before pflag registration so it never panics). Root PersistentPreRunE: read the parsed flags into Globals (Quiet implies NoInteraction), store them on the console Output (new unexported fields; Task 3 acts on them) and call every OnGlobals hook. `bonfire.Call` passes no options, so the scheduler never changes process-wide logging (RESEARCH Pitfall 3). Root RunE renders the list (bare binary). Add the `list` command (Description "List commands"; flags `--all` Bare and `--format` default `txt`; in this task only txt, Task 2 adds json and Hidden).
|
||||
|
||||
version.go: `Version`, `BuildVersion()` from debug.ReadBuildInfo: App is Main.Version with "(devel)" shown as "devel"; Framework is the version of the dep `git.golem15.com/golem15/summercms` ("(devel)" as "devel", Local true when the dep has a Replace); when the main module is the framework itself (the summer tool) Framework equals App. `(Version).Header(name)`: `SummerCMS acme v1.4.0 (framework v0.3.2)`, `(framework v0.3.2, local)` when Local, and `SummerCMS summer v0.3.2` when App and Framework are the same build.
|
||||
|
||||
list.go (txt): Version header line, blank line, "Usage:" and ` <usage name> <command> [options] [arguments]` (usage name `summer` for the tool, else `bin/<name>`), "Options:" with exactly the D-13 global option lines, "Available commands:" with top-level names (no colon, including list and help) first, then each namespace (the text before the first colon) sorted with a one-space-indented header line and its commands sorted, every name padded to the longest plus two spaces before the description. Styling: section titles in Palette.Header, command names in Palette.Success, through a colorprofile.Writer built with DetectProfile(out, globals.Color, os.Environ()).
|
||||
|
||||
execute.go: constants ExitOK, ExitFailure, ExitUsage; `Execute(ctx, root, args)`: SetArgs(args); when the args before `--` hold no command word (only globals and -h/--help), render the list to root's out and return ExitOK; otherwise `fang.Execute(ctx, root, fang.WithoutVersion(), fang.WithoutManpage(), fang.WithoutCompletions(), fang.WithErrorHandler(h))` where h calls RenderError (in this task a plain block: a blank line, ` ERROR <message>`, a blank line); map the returned error: nil to ExitOK, a UsageError (root.SetFlagErrorFunc wraps flag errors with Usage; the positional-args validator wraps count errors; messages starting with "unknown command" count too) to ExitUsage, anything else to ExitFailure. `Fail(w, name, err, g)` renders and returns ExitFailure for errors before the root exists. Usage, UsageError and RenderError are declared here or in errors.go (Task 2 completes the renderer).
|
||||
|
||||
diary: `FromGlobals(g bonfire.Globals) Options` (Verbosity, Quiet, Color) and `ApplyGlobals(g bonfire.Globals)` (SetVerbosity plus the console color mode). README rows for both.
|
||||
|
||||
Generated main: `func main() { os.Exit(run(os.Args[1:], os.Stdout)) }`; `func run(args []string, out io.Writer) int`: `g := bonfire.PeekGlobals(args)`, `diary.Bootstrap(os.Stderr, diary.FromGlobals(g))`, `cfg, err := compass.Open(compass.Options{Dir: "config", Env: g.Env})` with `return bonfire.Fail(os.Stderr, "<binary>", err, g)` on error (same for diary.Setup, party.Activate, app.Publish and NewRoot errors), `diary.Setup(cfg, diary.FromGlobals(g))`, unchanged command assembly lines, `root, err := bonfire.NewRoot("<binary>", commands, out, bonfire.WithVersion(bonfire.BuildVersion()), bonfire.OnGlobals(diary.ApplyGlobals))`, `return bonfire.Execute(context.Background(), root, args)`. Update `TestGenerateMainInstallsDiary` for the new order (PeekGlobals first, then Bootstrap, then compass.Open, then Setup, then backpack.New) and the goldens (`-update`); regenerate examples/hello/main.go. cmd/summer/main.go: same shape (PeekGlobals, diary.Bootstrap, NewRoot with WithVersion and OnGlobals, `os.Exit(bonfire.Execute(...))`).
|
||||
|
||||
examples/hello/hello_test.go: `TestHelpListsServe` expects run to return 0 and the list to name serve, migrate, migrate:status and migrate:rollback plus "Available commands:"; `TestMigrateWithoutDSNFailsLoudly` asserts run returns 1 and that the built binary's stderr for `migrate` with an empty DSN contains `database.dsn`; `TestBuiltBinaryUnknownCommandExitsNonZero` asserts exit code 2; new `TestBuiltBinaryListsCommands`: `./bin/hello` exits 0 and stdout holds "SummerCMS hello", "Available commands:", a line equal to " greeter" and "greeter:hello"; `./bin/hello -vv list` exits 0.
|
||||
|
||||
Docs checker: commandWord treats `-v`, `-vv`, `-vvv`, `--verbose`, `-q`, `--quiet`, `-n`, `--no-interaction`, `--ansi`, `--no-ansi` as value-less and `--env` as taking a value (`--env X` or `--env=X`); update its doc comment. docsCommands adds "list" and "help" to both the tool and the application lists. Keep the generated-main append lines that docs_test parses.
|
||||
|
||||
Docs and README in the same commit: docs/console/introduction.md "Getting help" shows the bare binary's list, the global options table and the exit codes 0, 1 and 2; writing-commands.md replaces "names are not checked for duplicates" with the rejection rule (ErrDuplicateCommand at boot; global flag names and shorthands are reserved, ErrReservedFlag); bonfire README Features and API reference rows for every new name in this task. Commit: `feat(bonfire): global flags, Artisan list and exit codes`.
|
||||
|
||||
Smoke test list_smoke_test.go `TestListTracerSmoke` (-short): NewRootIO("acme") with commands migrate, admin:create, admin:token:create, make:model and an OnGlobals hook that records Globals; Execute with no args returns 0 and the output holds the header, "Usage:", " bin/acme <command> [options] [arguments]", the global option lines and, in order, migrate, then " admin" with its two commands, then " make"; `-vvv migrate` records Verbosity 3; `migrate -q` records Quiet and NoInteraction; `migrat` returns 2; a command whose Run fails returns 1; a duplicate name and a flag with Shorthand "v" each make NewRootIO return an error that names the command. PeekGlobals agrees with the hook for the same args.
|
||||
|
||||
Close the folded todo (planning commit, alone): `git mv .planning/todos/pending/bonfire-duplicate-command-names.md .planning/todos/done/`, append one line naming the commit; move g15office task u4rtpcl1 to Live with a one-line comment (if the g15office tool is not available in this session, record that in the SUMMARY). Commit: `docs(19.8): close the duplicate command names todo`.</action>
|
||||
<verify>
|
||||
<automated>go vet ./... && go test -count=1 -short ./modules/bonfire/... ./modules/diary/... ./internal/build/... ./internal/docsite/... ./cmd/summer/... && go test -count=1 -short -run '^TestListTracerSmoke$' -v ./modules/bonfire 2>&1 | grep -q -- '^--- PASS: TestListTracerSmoke' && go -C examples/hello test -count=1 -run '^(TestBuiltBinaryListsCommands|TestHelpListsServe|TestBuiltBinaryUnknownCommandExitsNonZero)$' -v . 2>&1 | grep -c -- '^--- PASS: Test' | grep -qx 3 && go test -count=1 -run '^TestDocsTree$' ./cmd/summer && go run ./cmd/summer docs:build --check</automated>
|
||||
<fails_when>Non-zero exit; no "--- PASS: TestListTracerSmoke" line; the count of top-level "--- PASS: Test" lines from examples/hello is not 3 (the built binary does not list, the help test fails, or an unknown command does not exit 2); or the docs checker rejects a command line or identifier.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -c 'func PeekGlobals(args \[\]string) Globals' modules/bonfire/globals.go` prints 1.
|
||||
- `grep -c 'fang.WithoutVersion()' modules/bonfire/execute.go` prints 1 and `grep -c 'fang.WithoutManpage()' modules/bonfire/execute.go` prints 1.
|
||||
- `grep -c 'bonfire.OnGlobals(diary.ApplyGlobals)' internal/build/testdata/new/main.go.golden` prints 1 and `grep -c 'os.Exit(run(os.Args\[1:\], os.Stdout))' examples/hello/main.go` prints 1.
|
||||
- `grep -c 'compass.Options{Dir: "config", Env: g.Env}' internal/build/testdata/new/main.go.golden` prints 1.
|
||||
- `grep -c 'ErrDuplicateCommand' modules/bonfire/README.md` prints at least 1 and `grep -c 'ErrDuplicateCommand' docs/console/writing-commands.md` prints at least 1.
|
||||
- `test -f .planning/todos/done/bonfire-duplicate-command-names.md && test ! -f .planning/todos/pending/bonfire-duplicate-command-names.md` exits 0.
|
||||
- `grep -cE '"--env"|--no-interaction' internal/docsite/check_commands.go` prints at least 1.
|
||||
</acceptance_criteria>
|
||||
<done>A user who runs an application binary or summer with no arguments sees the grouped Artisan list, every command takes the global flags with diary following them from the first log line, and scripts get exit code 2 for usage mistakes.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: help <command> and --help print the Artisan layout with defaults, long help and examples; Hidden, list --all and list --format=json; CheckDocs; errors render as a styled block with a Try: hint and the -v chain</name>
|
||||
<reversibility rating="costly">D-15 and D-16: Command.Examples, Command.Hidden and the JSON listing's field names become API that plugins and docs checks rely on.</reversibility>
|
||||
<files>modules/bonfire/command.go, modules/bonfire/root.go, modules/bonfire/help.go, modules/bonfire/list.go, modules/bonfire/docs.go, modules/bonfire/errors.go, modules/bonfire/execute.go, modules/bonfire/help_smoke_test.go, modules/bonfire/README.md, docs/console/writing-commands.md, docs/console/introduction.md</files>
|
||||
<read_first>modules/bonfire/command.go, modules/bonfire/root.go, modules/bonfire/list.go, modules/bonfire/execute.go (Task 1), modules/bonfire/output_test.go (TestHelpUsesSharedAdapter), cmd/summer/main_test.go (help expectations :20-58), modules/sunscreen/sunscreen.go (Scrub), docs/console/writing-commands.md, docs/console/introduction.md, 19.8-CONTEXT.md (D-14 preview, D-16, D-17, D-18 preview), 19.8-RESEARCH.md (Pattern 5 JSON schema and CheckDocs, Pitfall 13, Code Examples "Error with hint")</read_first>
|
||||
<action>command.go (D-15): append `Examples []Example` and `Hidden bool` to Command (after Run, with doc comments) and add `type Example struct { Command string; Description string }`: Command is the command line without the binary, Description a short sentence shown as a `#` comment above it. wrap maps Hidden to cobra's Hidden and sets `cmd.SetHelpFunc` on every subcommand to the Artisan help renderer (RESEARCH: cobra prefers a command's own help func); root help func renders the list. Replace cobra's help command with `help [command]` via root.SetHelpCommand: no argument prints the list, a name prints that command's help, an unknown name is a Usage error whose Try comes from root.SuggestionsFor.
|
||||
|
||||
help.go (D-14): sections in order, each title in Palette.Header: "Description:" with the Description; "Usage:" with `<commandUsage> [options]` (append `[--] <args...>` only for Passthrough commands); "Arguments:" (name padded, description) when there are Args; "Options:" one line per flag: `-x, --name=NAME` for value flags (` --name=NAME` without shorthand, NAME the upper-cased flag name with dashes as underscores), `--name` for Bare flags, description, then `[default: "value"]` when Default is non-empty (unquoted when it parses as an integer), and "(multiple values allowed)" for Repeatable; "Global options:" the compact global flag lines; "Help:" with Long when set, indented two spaces and wrapped as written; "Examples:" each example as ` # <Description>` then ` <usage name> <Command>`, a blank line between examples. Output through the same colorprofile writer as the list.
|
||||
|
||||
list.go: Hidden commands are skipped unless `--all`; `--format=json` writes `Describe(rootName, commands, version)` as indented JSON; any other format is a Usage error. `Describe` builds `Listing` with JSON keys application{name, version, framework}, global_options, commands[{name, description, long, usage, hidden, passthrough, arguments[{name, description, required}], options[{name, shorthand, description, default, bare, repeatable}], examples[{command, description}]}] covering every command including hidden ones, list and help.
|
||||
|
||||
docs.go (D-17): `DocProblem` with `String()` ("<command>: missing description, examples"), `CheckDocs(commands)` checking Description, Long, at least one Example, every Arg.Description and every Flag.Description; results sorted by command name. It only reports; callers decide to fail (framework, plan 3) or log (plugins).
|
||||
|
||||
errors.go (D-18): `HintError` (Error returns Err.Error(), Unwrap returns Err), `WithHint(err, explanation, try)` (nil stays nil), `ErrReported` and `Reported(err)` (an error matching both ErrReported and err), `UsageError` and `Usage(err)`. `RenderError(w, name, err, g)`: nothing for ErrReported; otherwise, through a colorprofile writer with DetectProfile(w, g.Color, os.Environ()), a blank line, ` ` + an ` ERROR ` badge (Palette.ErrorBold, reversed) + two spaces + the error message (every line indented, text passed through sunscreen.Scrub); when the chain holds a HintError, a blank line and its explanation, then " Try:" and ` <try>` with `{bin}` replaced by the usage name; when the error is cobra's unknown command with suggestions, the message becomes `Command "<name>" is not defined.` and Try lists `<usage name> <suggestion>`; for other usage errors Try is `<usage name> help <command>`; at Verbosity 0 a final " Run with -v for details." line, at Verbosity 1 or more a " Caused by:" list of each wrapped error's message (errors.Unwrap walk, errors.Join members expanded), scrubbed. execute.go uses RenderError from fang's handler and in Fail. Existing callers that only check `err != nil` are unaffected.
|
||||
|
||||
README (same commit): Features (Artisan help and list, Examples and Hidden, JSON listing, CheckDocs, error block with hints and exit codes), API reference rows for every name added in this task. docs/console/writing-commands.md: sections "Examples and hidden commands" (the Example shape, rendering with bin/<name> or summer), "Errors and hints" (WithHint with the {bin} placeholder, Reported for commands that already printed their problems, exit codes), "Documentation coverage" (CheckDocs; the framework test fails, plugin repos log). docs/console/introduction.md "Getting help": `summer help migrate`, `bin/acme migrate --help`, `bin/acme list --all`, `bin/acme list --format=json`. Commit: `feat(bonfire): Artisan help, examples, JSON listing and error hints`.
|
||||
|
||||
Smoke tests help_smoke_test.go (-short): `TestHelpSmoke`: a command with one required Arg, a value flag with Default "*", a value flag with Default "90", a Bare flag, a Repeatable flag, Long and two Examples renders the sections in order with `[default: "*"]`, `[default: 90]`, "(multiple values allowed)" and `bin/acme` prefixes; `help <name>` output equals `<name> --help` output. `TestListJSONSmoke`: `list --format=json` decodes into Listing, includes a Hidden command with hidden true while plain `list` omits it and `list --all` shows it. `TestErrorRenderSmoke`: WithHint renders the explanation and the Try line with {bin} replaced; Verbosity 0 shows "Run with -v for details." and Verbosity 1 shows "Caused by:"; Reported renders nothing and Execute returns 1; an unknown command returns 2 with a Try naming the suggestion; a "Bearer abc..." string in an error is scrubbed. Existing TestHelpUsesSharedAdapter and cmd/summer help tests stay green.</action>
|
||||
<verify>
|
||||
<automated>go vet ./... && go test -count=1 -short ./modules/bonfire/... ./cmd/summer/... ./internal/build/... && go test -count=1 -short -run '^(TestHelpSmoke|TestListJSONSmoke|TestErrorRenderSmoke|TestHelpUsesSharedAdapter)$' -v ./modules/bonfire 2>&1 | grep -c -- '^--- PASS: Test' | grep -qx 4 && go -C examples/hello test -count=1 ./... && go test -count=1 -run '^TestDocsTree$' ./cmd/summer && go run ./cmd/summer docs:build --check</automated>
|
||||
<fails_when>Non-zero exit, or the count of top-level "--- PASS: Test" lines is not 4 (a help section, the JSON listing, the error block or the old help test failed or did not run), or examples/hello or the docs checker fail.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -cE '^\s+Examples +\[\]Example' modules/bonfire/command.go` prints 1 and `grep -cE '^\s+Hidden +bool' modules/bonfire/command.go` prints 1.
|
||||
- `S=$(awk '/^type Command struct/,/^}/' modules/bonfire/command.go) && R=$(printf '%s\n' "$S" | grep -nE '^\s+Run\s' | cut -d: -f1) && E=$(printf '%s\n' "$S" | grep -nE '^\s+Examples\s' | cut -d: -f1) && H=$(printf '%s\n' "$S" | grep -nE '^\s+Hidden\s' | cut -d: -f1) && test "$R" -lt "$E" && test "$R" -lt "$H"` exits 0 (Examples and Hidden are appended after Run, additive).
|
||||
- `grep -c 'func CheckDocs(commands \[\]Command) \[\]DocProblem' modules/bonfire/docs.go` prints 1 and `grep -c 'func WithHint(err error, explanation, try string) error' modules/bonfire/errors.go` prints 1.
|
||||
- `grep -c 'sunscreen.Scrub' modules/bonfire/errors.go` prints at least 1.
|
||||
- `grep -c '"global_options"' modules/bonfire/list.go` prints 1.
|
||||
- `grep -cE 'WithHint|CheckDocs|Reported' docs/console/writing-commands.md` prints at least 3.
|
||||
</acceptance_criteria>
|
||||
<done>Any command explains itself in the Artisan layout with its defaults and examples, scripts and agents can read the whole catalog as JSON, and failures end in a readable block that says what to type next.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: On a terminal, spinners, the multi-row process view, the gradient progress bar, tables and the arrow-key selector run on charm v2 (ported from SSU); off a terminal, under -q, --no-ansi or -n every widget prints plain lines and prompts never block</name>
|
||||
<reversibility rating="costly">D-01 and D-02: every command's terminal output and the widget behaviour depend on the ported models and the charm runtime.</reversibility>
|
||||
<files>go.mod, go.sum, modules/bonfire/styles.go, modules/bonfire/output.go, modules/bonfire/widgets.go, modules/bonfire/prompts.go, modules/bonfire/live.go, modules/bonfire/selector.go, modules/bonfire/helpers.go, modules/bonfire/widgets_smoke_test.go, modules/bonfire/README.md, modules/diary/console.go, modules/diary/README.md, docs/console/writing-commands.md, docs/services/logging.md, examples/hello/go.sum, examples/hello/plugins/greeter/go.sum</files>
|
||||
<read_first>modules/bonfire/output.go, modules/bonfire/widgets.go, modules/bonfire/prompts.go, modules/bonfire/styles.go, modules/bonfire/output_test.go, modules/bonfire/prompts_test.go, modules/cabana/commands_test.go (:241-262 WR-13), modules/diary/console.go, /media/nvme/dev/pxpx/ssu/internal/cli/tui/spinner.go, /media/nvme/dev/pxpx/ssu/internal/cli/tui/process.go, /media/nvme/dev/pxpx/ssu/internal/cli/tui/progress.go, /media/nvme/dev/pxpx/ssu/internal/cli/tui/selector.go, /media/nvme/dev/pxpx/ssu/internal/cli/tui/selector_keys.go, /media/nvme/dev/pxpx/ssu/internal/cli/tui/confirm.go, 19.8-RESEARCH.md (Anti-Patterns, Don't Hand-Roll, Porting SSU's spinner, Pitfalls 7, 10 and 12, Testing techniques), 19.8-CONTEXT.md (D-01, D-03, D-27, D-28, D-30)</read_first>
|
||||
<action>Install `charm.land/bubbletea/v2`, `charm.land/bubbles/v2` and `charm.land/huh/v2` at the decision note's versions; tidy the root and examples/hello modules.
|
||||
|
||||
Console policy (output.go): the console keeps its fields and gains quiet, noInteraction, verbosity and color mode from Task 1's PersistentPreRunE. live = tty and not quiet and color mode not ColorNever and not noInteraction (D-03). interactive = stdin and stdout are terminals and not noInteraction (quiet implies noInteraction). Styling moves from the raw ANSI constants to DefaultPalette through a colorprofile.Writer whose profile comes from DetectProfile (NO_COLOR, TERM=dumb and FORCE_COLOR keep their meaning; the symbols ✓ ✗ ⚠ ℹ stay). Info, Success and Warning print nothing when quiet; Error always prints to stderr; Println, Printf, Write and Table always print (data, D-27). Off a terminal the bytes are today's (output_test.go and prompts_test.go must pass unchanged).
|
||||
|
||||
live.go: port SSU's SpinnerModel, ProcessModel and ProgressModel to bubbletea v2 as unexported models (View returns tea.View via tea.NewView, key messages are tea.KeyPressMsg, progress.New(progress.WithColors(lipgloss.Color("#00afaf"), lipgloss.Color("#00af00")), progress.WithoutPercentage()) with SetWidth(40), spinner.MiniDot with Palette.Current) without the git and engine coupling. A helper runs a model with `tea.NewProgram(m, tea.WithInput(nil), tea.WithOutput(out), tea.WithoutSignalHandler(), tea.WithContext(ctx))`, keeps a package-level pointer to the running program under a mutex for PrintAbove, and clears it before Quit returns (never Println after quit). Spinner: live runs the spinner model while fn runs in a goroutine and ends with "✓ message" or "✗ message"; not live keeps `[...] message`; quiet runs fn silently. Progress: live shows the bar with done/total and the current item; the value passed to fn implements Advance plus the optional ItemProgress methods; not live keeps the decile percentage lines; quiet prints nothing. Table: on a terminal lipgloss/v2/table with lipgloss.NormalBorder() and Palette.Header for the header row; off a terminal the existing tab-separated output.
|
||||
|
||||
selector.go: a single-select port of SSU's SelectorModel and its key map (up/down and k/j move, enter selects, esc or ctrl+c cancels) plus SSU's confirmation screen (selected item, enter to confirm, esc to go back); no multi-select, filter or detail pane (D-30).
|
||||
|
||||
prompts.go: interactive Ask uses huh.NewInput (default as placeholder and returned on empty input), Confirm huh.NewConfirm, Secret huh.NewInput().EchoMode(huh.EchoModePassword), Choice the selector; forms run with WithInput(c.in) and WithOutput(c.out); huh.ErrUserAborted and a cancelled selector return ErrAborted. Not interactive and not noInteraction: today's line-based prompts unchanged (piped stdin, empty stdin takes defaults, WR-13). noInteraction: Ask returns def, Confirm returns def, Choice returns choices[def], Secret reads one line when stdin is not a terminal and otherwise returns `WithHint(errors.New("bonfire: a secret is needed and -n turned prompts off"), "This command reads the value from a prompt or from stdin.", "printf '%s\n' \"$VALUE\" | {bin} <command> -n")`.
|
||||
|
||||
helpers.go: GlobalsOf, VerbosityOf, IsQuiet and Interactive read an unexported optional interface the console implements (zero values for other Output implementations); ConfirmProceed returns true without asking when Interactive is false and otherwise asks Confirm(question, false) (D-28); Process runs items one at a time with the process model on a live console (per-row pending, running, done or failed with the error) and one line per item otherwise ("✓ item" or "✗ item: error"; quiet prints failures only through Error), returning errors.Join of the failures; Current and Failed call the ItemProgress methods when the Progress has them; PrintAbove prints a line above the running program and reports whether it did.
|
||||
|
||||
diary console.go: when the console channel renders styled lines on a terminal it first offers the formatted line to bonfire.PrintAbove and writes directly only when that returns false, so log lines from other goroutines never tear a live view.
|
||||
|
||||
README and docs in the same commit: bonfire README Features (charm-backed widgets; a degradation table with rows terminal, not a terminal, -q, --no-ansi, -n and columns animation, decoration, data, prompts), API reference rows for the helpers, ItemProgress and ErrAborted, Dependencies list the charm modules; docs/console/writing-commands.md Output section (the same table, -q keeps data, ConfirmProceed for destructive commands, Process and Current for bulk loops); docs/services/logging.md one paragraph on log lines printing above live views. Commit: `feat(bonfire): SSU widgets on charm v2 behind the Output interface`.
|
||||
|
||||
Smoke test widgets_smoke_test.go `TestWidgetMatrixSmoke` (-short, no terminal): through newConsole with injected tty and interactive flags: quiet suppresses Info, Success and Warning but keeps Println, Table and Error; noInteraction makes Ask, Confirm and Choice return defaults without reading stdin and Secret with a terminal-like stdin returns an error whose chain holds a HintError; ConfirmProceed on a non-interactive console returns true without reading; the spinner, progress, process and selector models produce non-empty frames from Update and View called directly (window size 80x24), and the selector returns the item after enter on the confirmation screen.</action>
|
||||
<verify>
|
||||
<automated>go vet ./... && go test -count=1 -short ./modules/bonfire/... ./modules/diary/... ./modules/cabana/... && go test -count=1 -race -short ./modules/bonfire/... ./modules/diary/... && go test -count=1 -short -run '^(TestWidgetMatrixSmoke|TestNonTTYSpinner|TestNonTTYProgress|TestNonTTYTable|TestColorPolicy|TestSecretReadsPlainStdinLine|TestClosedStdinDoesNotHang)$' -v ./modules/bonfire 2>&1 | grep -c -- '^--- PASS: Test' | grep -qx 7 && go -C examples/hello test -count=1 ./... && go test -count=1 -run '^TestDocsTree$' ./cmd/summer && go run ./cmd/summer docs:build --check</automated>
|
||||
<fails_when>Non-zero exit (including a data race reported by -race), or the count of top-level "--- PASS: Test" lines is not 7 (the matrix smoke or one of the unchanged non-TTY, color or piped-secret tests failed or did not run), or examples/hello, the cabana piped-password test or the docs checker fail.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -c 'tea.WithInput(nil)' modules/bonfire/live.go` prints at least 1 and `grep -c 'tea.WithoutSignalHandler()' modules/bonfire/live.go` prints at least 1.
|
||||
- `go list -deps ./modules/bonfire | grep -cE '^charm.land/(bubbletea|bubbles|huh|fang|lipgloss)/v2$'` prints 5.
|
||||
- `grep -c 'huh.EchoModePassword' modules/bonfire/prompts.go` prints 1 and `grep -cE 'engine\.|git\.Submodule' modules/bonfire/live.go modules/bonfire/selector.go | awk -F: '{s+=$2} END {print s}'` prints 0.
|
||||
- `grep -c 'bonfire.PrintAbove(' modules/diary/console.go` prints at least 1.
|
||||
- `C=$(git log --oneline cdddaa3..HEAD -- modules/bonfire/output_test.go modules/bonfire/prompts_test.go) && test -z "$C"` exits 0 (the existing non-TTY and prompt tests were not edited in this phase).
|
||||
- `grep -c 'ConfirmProceed' docs/console/writing-commands.md` prints at least 1.
|
||||
</acceptance_criteria>
|
||||
<done>On a terminal commands look like SSU, off a terminal or with -q, --no-ansi or -n they print the same plain lines as before and never wait for input, and plugins needed no change to get any of it.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| argv to bonfire and the generated main | global flags are read before config loads and before plugins boot |
|
||||
| commands to the terminal | error text, hints and data printed to stdout and stderr, possibly captured by CI logs |
|
||||
| operator stdin to prompts | passwords and confirmations, piped in scripts or typed on a terminal |
|
||||
| plugins to the command root | plugin commands are registered next to framework commands |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-19.8-07 | Information disclosure | RenderError (errors.go) | medium | mitigate | every rendered message and chain entry passes through sunscreen.Scrub; the wrapped chain prints only at -v and only to stderr; TestErrorRenderSmoke checks a Bearer token is scrubbed |
|
||||
| T-19.8-08 | Denial of service | prompts and ConfirmProceed | high | mitigate | huh and the selector run only when stdin and stdout are terminals and -n/-q are off; Call roots have empty stdin; ConfirmProceed returns true off a terminal (D-28); TestWidgetMatrixSmoke and the unchanged piped-stdin tests |
|
||||
| T-19.8-09 | Information disclosure | -q data path | medium | mitigate | tokens and keys print only through Println/Printf/Write or --json, never through diary; -q suppresses decoration only (D-27), so a script's data is neither lost nor duplicated into logs |
|
||||
| T-19.8-10 | Elevation of privilege | NewRoot command registration | medium | mitigate | duplicate names (and list or help) make NewRoot fail at boot naming the command, so a plugin cannot shadow a framework command such as migrate; reserved global flags are rejected instead of panicking |
|
||||
| T-19.8-11 | Denial of service | bubbletea programs (live.go) | medium | mitigate | tea.WithInput(nil) keeps stdin out of raw mode, tea.WithoutSignalHandler leaves Ctrl+C to the command's signal.NotifyContext, tea.WithContext stops the view with the command |
|
||||
| T-19.8-12 | Tampering | --env flag | low | mitigate | the value goes only to compass.Options.Env, whose sanitizer rejects path separators and dot-dot |
|
||||
| T-19.8-SC | Tampering | Go module installs (fang, bubbletea, bubbles, huh) | high | mitigate | versions pinned by the plan 01 decision note and the RESEARCH legitimacy audit; go.sum verified; only modules/bonfire imports them (acceptance criterion counts the module set) |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- Task 1: TestListTracerSmoke; examples/hello lists, helps and exits 2 on an unknown command; docs checker accepts global flags, list and help.
|
||||
- Task 2: TestHelpSmoke, TestListJSONSmoke, TestErrorRenderSmoke; existing help tests unchanged.
|
||||
- Task 3: TestWidgetMatrixSmoke plus the unchanged non-TTY, color and piped-secret tests; -race on bonfire and diary.
|
||||
- Feedback sampling: after each commit `go test -short ./modules/bonfire/... ./modules/diary/... ./internal/build/... ./internal/docsite/... ./cmd/summer/...`; after the plan, `go vet ./... && go test -short ./... && go -C examples/hello test ./...`.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- D-13, D-14, D-15, D-16: list, help, Examples, Hidden and the JSON listing.
|
||||
- D-17: CheckDocs helper ready for plan 3's failing framework test and warn-only plugin tests.
|
||||
- D-18: styled error block with hints and exit codes 1 and 2.
|
||||
- D-01, D-02, D-03, D-27, D-28, D-30: SSU widgets on charm v2 with clean degradation; Output unchanged.
|
||||
- SC-2: global flags on every command drive diary.
|
||||
- Folded todo closed.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/19.8-console-commands-overhaul/19.8-02-SUMMARY.md` when done, with the commits in order, the charm versions installed, the final exported names (plan 3 uses WithHint, Reported, ConfirmProceed, Process, GlobalsOf, Examples), the g15office result for u4rtpcl1, and any deviation from this plan's names.
|
||||
</output>
|
||||
@@ -0,0 +1,346 @@
|
||||
---
|
||||
phase: 19.8-console-commands-overhaul
|
||||
plan: 02
|
||||
subsystem: console
|
||||
tags: [bonfire, cobra, fang, bubbletea, bubbles, huh, lipgloss, colorprofile, diary, cli]
|
||||
|
||||
requires:
|
||||
- phase: 19.8-01
|
||||
provides: "diary (Bootstrap, Setup, SetVerbosity, console handler) and bonfire ColorMode, Palette, DefaultPalette, DetectProfile"
|
||||
provides:
|
||||
- "Global options -v/-vv/-vvv, -q, --ansi/--no-ansi, -n, --env on every command; PeekGlobals before boot, OnGlobals after parsing"
|
||||
- "Artisan list (bare binary, --help, list, list --all, list --format=json) with the BuildVersion header"
|
||||
- "Artisan per-command help (help <command>, <command> --help) with defaults, long help and Examples"
|
||||
- "Command.Examples, Command.Hidden, Listing, Describe, CheckDocs"
|
||||
- "Error block with WithHint, Reported, Usage; Execute and Fail return exit codes 0, 1, 2"
|
||||
- "Duplicate command names and reserved global flags rejected by NewRoot"
|
||||
- "SSU spinner, progress, process view and selector on charm v2; huh prompts; plain-line degradation"
|
||||
- "Helpers GlobalsOf, VerbosityOf, IsQuiet, Interactive, ConfirmProceed, Process, Current, Failed, PrintAbove"
|
||||
- "Generated main: PeekGlobals, diary.FromGlobals, compass.Open with --env, bonfire.Execute exit code"
|
||||
affects: [19.8-03, 19.8-04, console, plugins, generated main]
|
||||
|
||||
actuals:
|
||||
tokens: 34921
|
||||
tasks: 3
|
||||
commits: 5
|
||||
plan_head_before: b7c07591cb3906d5b83403d18837f17af9189ffa
|
||||
plan_head_after: 3f388629e14ea2e46dc5c97e9cfb01a1f8bad58f
|
||||
|
||||
tech-stack:
|
||||
added:
|
||||
- "charm.land/fang/v2 v2.0.1"
|
||||
- "charm.land/bubbletea/v2 v2.1.0"
|
||||
- "charm.land/bubbles/v2 v2.2.1"
|
||||
- "charm.land/huh/v2 v2.0.3"
|
||||
- "github.com/spf13/pflag v1.0.9 (was indirect, now imported directly)"
|
||||
- "github.com/charmbracelet/x/ansi v0.11.9 (direct, test only: stripping frames)"
|
||||
patterns:
|
||||
- "Root state (rootState) shared by list, help, the help command and per-subcommand help funcs; cobra prefers a command's own help func, so fang's root help func never reaches them"
|
||||
- "Live views run as bubbletea programs with tea.WithInput(nil), tea.WithoutSignalHandler(), tea.WithContext; one at a time; PrintAbove sends lines to the running program with Program.Send (a no-op after it ends), so callers never block on a finished program"
|
||||
- "Optional interface (consoleState) behind GlobalsOf/Interactive instead of new Output methods"
|
||||
|
||||
key-files:
|
||||
created:
|
||||
- modules/bonfire/globals.go
|
||||
- modules/bonfire/version.go
|
||||
- modules/bonfire/execute.go
|
||||
- modules/bonfire/list.go
|
||||
- modules/bonfire/help.go
|
||||
- modules/bonfire/docs.go
|
||||
- modules/bonfire/errors.go
|
||||
- modules/bonfire/live.go
|
||||
- modules/bonfire/selector.go
|
||||
- modules/bonfire/helpers.go
|
||||
- modules/bonfire/list_smoke_test.go
|
||||
- modules/bonfire/help_smoke_test.go
|
||||
- modules/bonfire/widgets_smoke_test.go
|
||||
modified:
|
||||
- modules/bonfire/root.go
|
||||
- modules/bonfire/command.go
|
||||
- modules/bonfire/output.go
|
||||
- modules/bonfire/widgets.go
|
||||
- modules/bonfire/prompts.go
|
||||
- modules/bonfire/example_test.go
|
||||
- modules/bonfire/README.md
|
||||
- modules/diary/diary.go
|
||||
- modules/diary/console.go
|
||||
- modules/diary/README.md
|
||||
- internal/build/build.go
|
||||
- internal/build/build_test.go
|
||||
- internal/build/testdata/new/main.go.golden
|
||||
- examples/hello/main.go
|
||||
- examples/hello/hello_test.go
|
||||
- cmd/summer/main.go
|
||||
- cmd/summer/docs.go
|
||||
- cmd/summer/docs_test.go
|
||||
- internal/docsite/check_commands.go
|
||||
- internal/docsite/checks_test.go
|
||||
- docs/console/introduction.md
|
||||
- docs/console/writing-commands.md
|
||||
- docs/services/logging.md
|
||||
- go.mod
|
||||
- go.sum
|
||||
|
||||
key-decisions:
|
||||
- "NewRoot sends the error block to stderr when its output is the process stdout (root.SetErr); Output.Error keeps writing where it did, so today's stdout bytes do not move"
|
||||
- "Execute resets the root's persistent flags before each run and pre-routes a call without a command word (only globals, -h, --help) to the list by dropping -h/--help; fang runs with WithoutVersion, WithoutManpage, WithoutCompletions and WithErrorHandler"
|
||||
- "Passthrough commands (summer's delegates) parse no flags, so the root reads their globals with PeekGlobals over the raw args, as the generated main did before boot"
|
||||
- "UsageError carries an unexported command name set by the flag error func and the argument-count check, so the error block's Try line is '<bin> help <command>' (or '<bin> list' at the root)"
|
||||
- "The builtin list and help commands carry Long and Examples, so plan 3's framework CheckDocs test can include them"
|
||||
- "--no-ansi on an interactive terminal uses the plain line prompts instead of huh and the selector (D-03: no styled widget under --no-ansi); -n and -q return defaults"
|
||||
- "While a live view runs, Println, Printf (complete lines), status lines and tables print above it; a widget started inside a running view prints plain lines (one live view at a time)"
|
||||
- "diary.ApplyGlobals rebuilds the channels only when the color mode differs from the one they were built with; otherwise it is SetVerbosity"
|
||||
|
||||
patterns-established:
|
||||
- "Command examples are stored without the binary and rendered after bin/<name> or summer"
|
||||
- "Commands that already printed their problems return bonfire.Reported(err); usage checks wrap with bonfire.Usage"
|
||||
- "Destructive steps ask through bonfire.ConfirmProceed, which never asks off an interactive terminal"
|
||||
|
||||
requirements-completed: [SC-2, SC-3, D-01, D-02, D-03, D-04, D-13, D-14, D-15, D-16, D-17, D-18, D-27, D-28, D-30]
|
||||
|
||||
coverage:
|
||||
- id: D1
|
||||
description: "A bare binary, --help and list print the Artisan list with the version header, usage, global options and namespace blocks"
|
||||
requirement: D-13
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "modules/bonfire/list_smoke_test.go#TestListTracerSmoke"
|
||||
status: pass
|
||||
- kind: integration
|
||||
ref: "examples/hello/hello_test.go#TestBuiltBinaryListsCommands"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D2
|
||||
description: "Global options reach the OnGlobals hook (diary) and agree with PeekGlobals; Call runs no hook"
|
||||
requirement: SC-2
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "modules/bonfire/list_smoke_test.go#TestListTracerSmoke"
|
||||
status: pass
|
||||
- kind: unit
|
||||
ref: "internal/build/build_test.go#TestGenerateMainInstallsDiary"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D3
|
||||
description: "Exit codes 0, 1 and 2 from Execute in the generated main; migrate without a DSN prints database.dsn on stderr and exits 1"
|
||||
requirement: D-18
|
||||
verification:
|
||||
- kind: integration
|
||||
ref: "examples/hello/hello_test.go#TestBuiltBinaryUnknownCommandExitsNonZero"
|
||||
status: pass
|
||||
- kind: integration
|
||||
ref: "examples/hello/hello_test.go#TestMigrateWithoutDSNFailsLoudly"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D4
|
||||
description: "Per-command help in the Artisan layout with defaults, repeatable marker, long help and examples; help <command> equals <command> --help"
|
||||
requirement: D-14
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "modules/bonfire/help_smoke_test.go#TestHelpSmoke"
|
||||
status: pass
|
||||
- kind: unit
|
||||
ref: "modules/bonfire/example_test.go#ExampleExample"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D5
|
||||
description: "Hidden commands, list --all and list --format=json (Listing); CheckDocs reports documentation gaps"
|
||||
requirement: D-16
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "modules/bonfire/help_smoke_test.go#TestListJSONSmoke"
|
||||
status: pass
|
||||
- kind: unit
|
||||
ref: "modules/bonfire/example_test.go#ExampleCheckDocs"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D6
|
||||
description: "Error block: hint and Try with {bin}, -v chain, Reported silent, unknown command suggestion, scrubbed Bearer token"
|
||||
requirement: D-18
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "modules/bonfire/help_smoke_test.go#TestErrorRenderSmoke"
|
||||
status: pass
|
||||
- kind: unit
|
||||
ref: "modules/bonfire/example_test.go#ExampleWithHint"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D7
|
||||
description: "Duplicate command names and reserved global flags rejected at boot; todo closed"
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "modules/bonfire/list_smoke_test.go#TestListTracerSmoke"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D8
|
||||
description: "Degradation matrix: -q keeps data and errors, -n returns defaults and Secret fails with a hint on a terminal stdin, ConfirmProceed never asks off a terminal, non-TTY bytes unchanged; ported models render frames"
|
||||
requirement: D-03
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "modules/bonfire/widgets_smoke_test.go#TestWidgetMatrixSmoke"
|
||||
status: pass
|
||||
- kind: unit
|
||||
ref: "modules/bonfire/output_test.go#TestNonTTYSpinner, TestNonTTYProgress, TestNonTTYTable, TestColorPolicy; prompts_test.go#TestSecretReadsPlainStdinLine, TestClosedStdinDoesNotHang (unchanged files)"
|
||||
status: pass
|
||||
- kind: other
|
||||
ref: "go test -race -short ./modules/bonfire/... ./modules/diary/..."
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D9
|
||||
description: "Live spinner, progress bar, process view, huh prompts and the arrow-key selector look and behave like SSU on a real terminal; diary log lines print above a running view"
|
||||
requirement: D-01
|
||||
verification:
|
||||
- kind: manual_procedural
|
||||
ref: "throwaway program run under script(1) on a pty: spinner, progress, Process with diary logs, table; exit 0, logs above the view"
|
||||
status: pass
|
||||
human_judgment: true
|
||||
rationale: "Animation, colors and keyboard interaction on a real terminal cannot be asserted by the short suite; the pty run only showed no hang and correct ordering. Plan 4 adds goldens; a person should try Choice/Ask/Secret on a terminal."
|
||||
|
||||
duration: 28min
|
||||
completed: 2026-10-21
|
||||
status: complete
|
||||
---
|
||||
|
||||
# Phase 19.8 Plan 02: bonfire console layer Summary
|
||||
|
||||
**bonfire reaches the Artisan bar: global -v/-q/--ansi/-n/--env flags that reach diary before plugins boot, the grouped list and per-command help with examples, a JSON catalog and CheckDocs, a styled error block with Try hints and exit codes 0/1/2, duplicate-name rejection, and SSU's spinner, progress, process view and selector on charm v2 that fall back to today's plain lines, with the Output interface unchanged.**
|
||||
|
||||
## Performance
|
||||
|
||||
- **Duration:** 28 min
|
||||
- **Started:** 2026-10-21T10:02:41Z
|
||||
- **Completed:** 2026-10-21T10:31:06Z
|
||||
- **Tasks:** 3
|
||||
- **Files modified:** 38 (3,505 insertions, 353 deletions)
|
||||
|
||||
## Accomplishments
|
||||
|
||||
- Tracer: `./bin/hello` prints `SummerCMS hello <version> (framework devel)`, usage, the D-13 global option lines and the commands grouped by namespace; `-vv list` works; an unknown command exits 2; a missing DSN exits 1 with `database.dsn` on stderr.
|
||||
- Generated main and `summer`: `bonfire.PeekGlobals` first, `diary.Bootstrap(os.Stderr, diary.FromGlobals(g))`, `compass.Open(compass.Options{Dir: "config", Env: g.Env})`, `bonfire.Fail` for errors before the root exists, `bonfire.NewRoot(..., bonfire.WithVersion(bonfire.BuildVersion()), bonfire.OnGlobals(diary.ApplyGlobals))`, `os.Exit(...)` of `bonfire.Execute`.
|
||||
- Help: Description, Usage, Arguments, Options (`[default: "*"]`, `[default: 90]`, "(multiple values allowed)"), Global options, Help, Examples; `help` command replaces cobra's.
|
||||
- JSON catalog (`list --format=json`, `bonfire.Describe`), `Hidden` with `list --all`, `bonfire.CheckDocs`.
|
||||
- Error block: ` ERROR ` badge, scrubbed message, explanation and Try lines from `WithHint` (`{bin}` replaced), cobra suggestions as Try, "Run with -v for details." or the `Caused by:` chain at `-v`; `Reported` prints nothing.
|
||||
- Widgets: SSU's spinner, gradient progress bar and process view ported to bubbletea v2; lipgloss boxed tables; huh Ask/Confirm/Secret and a single-select selector with SSU's confirmation screen; `-q`, `-n`, `--no-ansi` and non-TTY degrade to plain lines; `ConfirmProceed`, `Process`, `Current`, `Failed`, `PrintAbove`; diary's styled log lines print above a running view.
|
||||
- Docs: `docs/console/introduction.md` (list, global options, exit codes, help commands), `docs/console/writing-commands.md` (duplicates, Examples and hidden commands, Errors and hints, Documentation coverage, the degradation table, ConfirmProceed and Process), `docs/services/logging.md` (log lines above live views); bonfire and diary READMEs.
|
||||
|
||||
## Task Commits
|
||||
|
||||
1. **Task 1: tracer (global flags, list, exit codes)** - `8d4d7ce` feat(bonfire): global flags, Artisan list and exit codes
|
||||
2. **Folded todo** - `80fae5c` docs(19.8): close the duplicate command names todo
|
||||
3. **Task 2: help, examples, JSON listing, error hints** - `fb32886` feat(bonfire): Artisan help, examples, JSON listing and error hints
|
||||
4. **Task 3: widgets on charm v2** - `3f38862` feat(bonfire): SSU widgets on charm v2 behind the Output interface
|
||||
|
||||
`commits: 5` is measured from the ledger (`b7c0759..3f38862`); one of them, `cb38c49` docs(notes), belongs to another session that committed in between. This plan made four commits before this SUMMARY.
|
||||
|
||||
The tracer feedback gate re-ran Task 1's automated verify after the commit (all green, no human check), so expansion continued.
|
||||
|
||||
## Charm versions installed
|
||||
|
||||
`charm.land/fang/v2` v2.0.1 (Task 1), `charm.land/bubbletea/v2` v2.1.0, `charm.land/bubbles/v2` v2.2.1 and `charm.land/huh/v2` v2.0.3 (Task 3), the versions of `.planning/notes/2026-10-21-console-charm-stack.md`. `go mod tidy` also raised `charmbracelet/ultraviolet`, `x/ansi` (v0.11.9) and `mattn/go-runewidth` (v0.0.30) as the new modules require, and pulled fang's man-page helpers (`muesli/mango*`, `muesli/roff`) as indirect requirements. Nothing imports `gopkg.in/yaml.v3`.
|
||||
|
||||
## Exported names for plan 3
|
||||
|
||||
- **Commands and help:** `Command.Examples []Example`, `Command.Hidden bool`, `Example{Command, Description}`; `CheckDocs(commands []Command) []DocProblem`, `DocProblem{Command, Missing}` with `String()`; `Describe(name, commands, v) Listing`, `Listing`, `ApplicationInfo`, `CommandInfo`, `ArgumentInfo`, `OptionInfo`, `ExampleInfo`.
|
||||
- **Errors:** `WithHint(err, explanation, try) error`, `HintError{Err, Explanation, Try}`, `Reported(err) error`, `ErrReported`, `Usage(err) error`, `UsageError{Err}`, `RenderError(w, name, err, g)`, `Fail(w, name, err, g) int`, `Execute(ctx, root, args) int`, `ExitOK`, `ExitFailure`, `ExitUsage`, `ErrDuplicateCommand`, `ErrReservedFlag`.
|
||||
- **Globals:** `Globals{Verbosity, Quiet, Color, NoInteraction, Env}`, `PeekGlobals(args)`, `(Globals).Args()`, `RootOption`, `WithVersion`, `OnGlobals`, `Version{App, Framework, Local}`, `BuildVersion()`, `(Version).Header(name)`; diary `FromGlobals`, `ApplyGlobals`.
|
||||
- **Output helpers:** `ConfirmProceed(out, question) (bool, error)`, `Process(ctx, out, operation, items, fn) error`, `GlobalsOf`, `VerbosityOf`, `IsQuiet`, `Interactive`, `Current`, `Failed`, `ItemProgress`, `PrintAbove`, `ErrAborted`.
|
||||
|
||||
All names match the plan. `summer dev`'s child binary can get the verbosity forwarded with `bonfire.GlobalsOf(out).Args()`.
|
||||
|
||||
## g15office
|
||||
|
||||
Task u4rtpcl1 (the duplicate command names todo) was **not** moved to Live: no g15office tool is available in this executor session. The todo is closed locally (`80fae5c`); the orchestrator or the user should move u4rtpcl1 to Live with a comment such as "Done in Phase 19.8 plan 02 (8d4d7ce): bonfire.NewRoot rejects duplicate command names".
|
||||
|
||||
## Decisions Made
|
||||
|
||||
See `key-decisions` in the front matter. The two worth a second look:
|
||||
|
||||
- **Error stream.** The error block goes to stderr in real binaries (`NewRoot` sets the cobra error stream to `os.Stderr` when its output is `os.Stdout`, as the old `main` printed errors there). `Output.Error` still writes where it did before (stdout for `NewRoot`), so no command's output moves between streams.
|
||||
- **`--no-ansi` prompts.** On an interactive terminal, `--no-ansi` keeps prompts working but uses the plain line prompts instead of huh and the selector. `-n` and `-q` are the switches that turn prompts into defaults.
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
### Auto-fixed Issues
|
||||
|
||||
**1. [Rule 2 - Correctness] Error block on stderr**
|
||||
- **Found during:** Task 1
|
||||
- **Issue:** `NewRoot(name, commands, out)` passed `out` as the error stream, so fang's error handler would have printed errors on stdout in every binary, against D-18 ("on stderr") and the plan's own `TestMigrateWithoutDSNFailsLoudly` (stderr must name `database.dsn`).
|
||||
- **Fix:** `NewRoot` calls `root.SetErr(os.Stderr)` when `out` is the process stdout; the console's `Output.Error` stream is unchanged.
|
||||
- **Files modified:** modules/bonfire/root.go
|
||||
- **Commit:** 8d4d7ce
|
||||
|
||||
**2. [Rule 1 - Bug] Re-executed roots kept the previous run's globals**
|
||||
- **Found during:** Task 1 (smoke test)
|
||||
- **Issue:** pflag count flags add to their current value, so a root executed twice kept `-vvv`.
|
||||
- **Fix:** `Execute` resets the root's persistent flags to their defaults before each run. Command-local flags still persist between executions of one root (pflag behaviour, unchanged); the Task 2 smoke tests build a fresh root per run.
|
||||
- **Files modified:** modules/bonfire/globals.go, modules/bonfire/execute.go, modules/bonfire/help_smoke_test.go
|
||||
- **Commits:** 8d4d7ce, fb32886
|
||||
|
||||
**3. [Rule 3 - Blocking] Go fences in docs need runnable `src=` examples**
|
||||
- **Found during:** Task 2 (docs checker)
|
||||
- **Fix:** added `ExampleExample`, `ExampleWithHint` and `ExampleCheckDocs` to `modules/bonfire/example_test.go` (with `// Output:`) and referenced them from `docs/console/writing-commands.md`; `summer docs:sync` filled the copies.
|
||||
- **Commit:** fb32886
|
||||
|
||||
**4. [Rule 2 - Docs checker] Commands of the docs checker**
|
||||
- **Found during:** Task 1
|
||||
- **Fix:** `TestCommandWord` cases for the global flags (`bin/acme -v serve`, `--env staging`, `--env=dev`, `summer -q --no-ansi -n list`) and `TestDocsCommandNames` wants for `list` and `help`. Files not in the plan's list: `internal/docsite/checks_test.go`, `cmd/summer/docs_test.go`.
|
||||
- **Commit:** 8d4d7ce
|
||||
|
||||
**5. [Rule 2 - Correctness] Output printed during a live view**
|
||||
- **Found during:** Task 3 (pty run)
|
||||
- **Issue:** the plan routes only diary lines through `PrintAbove`; a command that prints with `Println`, a status line or a table inside `Spinner` (the README's own example does) would tear the view.
|
||||
- **Fix:** while a live view runs, complete lines for stdout print above it; a widget started inside a running view prints plain lines (one live view at a time).
|
||||
- **Files modified:** modules/bonfire/output.go, modules/bonfire/live.go
|
||||
- **Commit:** 3f38862
|
||||
|
||||
**6. [Interpretation - D-03] `--no-ansi` on a terminal uses the line prompts**
|
||||
- The plan defines interactive as "stdin and stdout are terminals and not noInteraction", which would run huh under `--no-ansi`. D-03 lists `--no-ansi` among the cases with no styled widgets, so huh and the selector need `Color != ColorNever` too; the line prompts (unchanged code path) serve `--no-ansi`.
|
||||
- **Commit:** 3f38862
|
||||
|
||||
### Recorded differences
|
||||
|
||||
**7. Acceptance check `go list -deps ./modules/bonfire | grep -cE '^charm.land/(bubbletea|bubbles|huh|fang|lipgloss)/v2$'` prints 4, not 5.** The check counts packages; `charm.land/bubbles/v2` has only a doc package at its root, and bonfire imports `bubbles/v2/spinner`, `bubbles/v2/progress` and `bubbles/v2/key` (as the plan asks). Counted per module (`go list -deps -f '{{.Module.Path}}' ./modules/bonfire | sort -u`), the result is 5. No blank import was added to satisfy the literal grep.
|
||||
|
||||
**8. examples/hello go.mod/go.sum unchanged.** As in plan 01, the example modules build and test inside the repository's go.work without changes (deferred-items.md, "examples/hello go.mod is stale outside the workspace").
|
||||
|
||||
**9. `TestMigrateWithoutDSNFailsLoudly` builds the binary** (`runSummerBuild`) to check stderr, so it takes about half a second more.
|
||||
|
||||
**10. Builtin `list` and `help` carry `Long` and `Examples`** (not specified), so plan 3's framework `CheckDocs` test can cover them without exceptions.
|
||||
|
||||
---
|
||||
|
||||
**Total deviations:** 6 auto-fixed or interpreted (2 correctness, 1 bug, 1 blocking, 1 docs checker, 1 D-03 interpretation), 4 recorded differences. **Impact:** no change to the plan's exported names; the Output, Input and Progress interfaces are byte-identical to the plan's base commit.
|
||||
|
||||
## Issues Encountered
|
||||
|
||||
None beyond the deviations. `go test -count=1 -short ./...` was green after every task (no wristband flake this time).
|
||||
|
||||
## Known Stubs
|
||||
|
||||
None.
|
||||
|
||||
## Threat Flags
|
||||
|
||||
None beyond the plan's threat model. The only new terminal input path is huh and the selector, which run only when stdin and stdout are terminals, `-n`/`-q` are off and `--no-ansi` is not set (T-19.8-08); live views never read stdin (T-19.8-11); error text is scrubbed and the chain shows only at `-v` (T-19.8-07).
|
||||
|
||||
## Verification
|
||||
|
||||
- `go vet ./...`: clean.
|
||||
- `go test -count=1 -short ./...`: green after each task.
|
||||
- `go test -count=1 -race -short ./modules/bonfire/... ./modules/diary/...`: green.
|
||||
- `go -C examples/hello test -count=1 ./...`: green.
|
||||
- By name: `TestListTracerSmoke`, `TestHelpSmoke`, `TestListJSONSmoke`, `TestErrorRenderSmoke`, `TestHelpUsesSharedAdapter`, `TestWidgetMatrixSmoke`, `TestNonTTYSpinner`, `TestNonTTYProgress`, `TestNonTTYTable`, `TestColorPolicy`, `TestSecretReadsPlainStdinLine`, `TestClosedStdinDoesNotHang`, `TestBuiltBinaryListsCommands`, `TestHelpListsServe`, `TestBuiltBinaryUnknownCommandExitsNonZero`: `--- PASS`.
|
||||
- `TestDocsTree` and `summer docs:build --check`: no problems.
|
||||
- Acceptance criteria of all three tasks: pass, except the package-count check recorded above (module count 5).
|
||||
- `modules/bonfire/output_test.go` and `prompts_test.go`: no commits since `cdddaa3`.
|
||||
- No consuming application named in changed files. Nothing pushed or tagged.
|
||||
|
||||
## User Setup Required
|
||||
|
||||
None - no external service configuration required.
|
||||
|
||||
## Next Phase Readiness
|
||||
|
||||
Ready for 19.8-03: commands can use `Examples`, `Hidden`, `WithHint`, `Reported`, `Usage`, `ConfirmProceed`, `Process` and `GlobalsOf`; the framework coverage test can call `bonfire.CheckDocs`. Before the sweep's apps boot with the new main, Pitfall 15 still applies: build each app and run `bin/<app> list` to catch duplicate command names. A person should try the live widgets and prompts on a real terminal (coverage D9).
|
||||
|
||||
## Self-Check: PASSED
|
||||
710
.planning/phases/19.8-console-commands-overhaul/19.8-03-PLAN.md
Normal file
710
.planning/phases/19.8-console-commands-overhaul/19.8-03-PLAN.md
Normal file
@@ -0,0 +1,710 @@
|
||||
---
|
||||
phase: 19.8-console-commands-overhaul
|
||||
plan: 03
|
||||
type: execute
|
||||
wave: 3
|
||||
depends_on: ["19.8-01", "19.8-02"]
|
||||
files_modified:
|
||||
- modules/surf/serve.go
|
||||
- modules/surf/summary.go
|
||||
- modules/surf/accesslog.go
|
||||
- modules/surf/routelist_command.go
|
||||
- modules/surf/serve_summary_smoke_test.go
|
||||
- modules/surf/serve_lifecycle_test.go
|
||||
- modules/surf/README.md
|
||||
- modules/lagoon/migrations.go
|
||||
- modules/lagoon/commands.go
|
||||
- modules/lagoon/keygen.go
|
||||
- modules/lagoon/connection.go
|
||||
- modules/lagoon/README.md
|
||||
- modules/conga/worker.go
|
||||
- modules/conga/commands.go
|
||||
- modules/conga/README.md
|
||||
- modules/cabana/commands.go
|
||||
- modules/cabana/token_commands.go
|
||||
- modules/cabana/README.md
|
||||
- modules/lighthouse/centrifugo/commands.go
|
||||
- modules/lighthouse/README.md
|
||||
- modules/flare/commands.go
|
||||
- modules/flare/README.md
|
||||
- modules/bonfire/errors.go
|
||||
- modules/bonfire/README.md
|
||||
- modules/diary/diary.go
|
||||
- modules/diary/console.go
|
||||
- modules/diary/README.md
|
||||
- internal/dev/watch.go
|
||||
- internal/dev/watch_test.go
|
||||
- internal/build/manifest.go
|
||||
- internal/build/artifact.go
|
||||
- internal/build/scaffold.go
|
||||
- cmd/summer/main.go
|
||||
- cmd/summer/runtime.go
|
||||
- cmd/summer/parity.go
|
||||
- cmd/summer/docs.go
|
||||
- cmd/summer/commands_documented_test.go
|
||||
- examples/hello/plugins/greeter/plugin.go
|
||||
- examples/hello/hello_test.go
|
||||
- docs/examples/blog/console/publish.go
|
||||
- docs/console/introduction.md
|
||||
- docs/console/setup-and-maintenance.md
|
||||
- docs/console/scaffolding.md
|
||||
- docs/console/utilities.md
|
||||
- docs/console/writing-commands.md
|
||||
- docs/services/logging.md
|
||||
- docs/setup/upgrading.md
|
||||
- ../sm-summercmsio-app/vue-summercmsio-app/i18n/locales/en.json
|
||||
- ../sm-summercmsio-app/vue-summercmsio-app
|
||||
- ../sm-demo-app/plugins/demo/roadmap/curated.go
|
||||
- ../sm-demo-app/plugins/demo/content/ROADMAP.md
|
||||
- ../sm-paymentgateway-plugin/console/pg_cancel_created_payments.go
|
||||
- ../sm-paymentgateway-plugin/console/pg_cancel_sent_payments.go
|
||||
- ../sm-paymentgateway-plugin/console/pg_cancel_placed_orders.go
|
||||
- ../sm-paymentgateway-plugin/console/pg_finish_orders.go
|
||||
- ../sm-paymentgateway-plugin/console/pg_update_currencies.go
|
||||
- ../sm-paymentgateway-plugin/console/import_winter.go
|
||||
- ../sm-paymentgateway-plugin/admin_settings.go
|
||||
- ../sm-paymentgateway-plugin/api/actions.go
|
||||
- ../sm-paymentgateway-plugin/api/errors.go
|
||||
- ../sm-paymentgateway-plugin/boot.go
|
||||
- ../sm-paymentgateway-plugin/controllers/orders.go
|
||||
- ../sm-paymentgateway-plugin/currency/fixer.go
|
||||
- ../sm-paymentgateway-plugin/events/mailers.go
|
||||
- ../sm-paymentgateway-plugin/orders/bridge.go
|
||||
- ../sm-paymentgateway-plugin/orders/service.go
|
||||
- ../sm-paymentgateway-plugin/payments/service.go
|
||||
- ../sm-paymentgateway-plugin/commands_documented_test.go
|
||||
- ../sm-paymentgateway-plugin/README.md
|
||||
- ../sm-paymentgateway-plugin/go.mod
|
||||
- ../sm-paymentgateway-plugin/go.sum
|
||||
- ../sm-pgstripe-plugin/console/import_winter.go
|
||||
- ../sm-pgstripe-plugin/operator.go
|
||||
- ../sm-pgstripe-plugin/subscriptions/gateway.go
|
||||
- ../sm-pgstripe-plugin/subscriptions/plugin.go
|
||||
- ../sm-pgstripe-plugin/commands_documented_test.go
|
||||
- ../sm-pgstripe-plugin/README.md
|
||||
- ../sm-pgstripe-plugin/go.mod
|
||||
- ../sm-pgstripe-plugin/go.sum
|
||||
- ../sm-oauthserver-plugin/console/prune.go
|
||||
- ../sm-oauthserver-plugin/console/rotate_keys.go
|
||||
- ../sm-oauthserver-plugin/console/import_winter.go
|
||||
- ../sm-oauthserver-plugin/classes/branding.go
|
||||
- ../sm-oauthserver-plugin/classes/claims.go
|
||||
- ../sm-oauthserver-plugin/classes/host.go
|
||||
- ../sm-oauthserver-plugin/commands_documented_test.go
|
||||
- ../sm-oauthserver-plugin/README.md
|
||||
- ../sm-oauthserver-plugin/go.mod
|
||||
- ../sm-oauthserver-plugin/go.sum
|
||||
- ../sm-journal-plugin/console/export_posts.go
|
||||
- ../sm-journal-plugin/console/import_posts.go
|
||||
- ../sm-journal-plugin/plugin.go
|
||||
- ../sm-journal-plugin/search.go
|
||||
- ../sm-journal-plugin/controllers/api/authors.go
|
||||
- ../sm-journal-plugin/controllers/api/posts_search.go
|
||||
- ../sm-journal-plugin/commands_documented_test.go
|
||||
- ../sm-journal-plugin/README.md
|
||||
- ../sm-journal-plugin/go.mod
|
||||
- ../sm-journal-plugin/go.sum
|
||||
- ../sm-sitemap-plugin/console/import.go
|
||||
- ../sm-sitemap-plugin/console/import_winter.go
|
||||
- ../sm-sitemap-plugin/classes/manifest.go
|
||||
- ../sm-sitemap-plugin/routes.go
|
||||
- ../sm-sitemap-plugin/commands_documented_test.go
|
||||
- ../sm-sitemap-plugin/README.md
|
||||
- ../sm-sitemap-plugin/go.mod
|
||||
- ../sm-sitemap-plugin/go.sum
|
||||
- ../sm-faq-plugin/console/import_winter.go
|
||||
- ../sm-faq-plugin/controllers/api/faqs.go
|
||||
- ../sm-faq-plugin/commands_documented_test.go
|
||||
- ../sm-faq-plugin/README.md
|
||||
- ../sm-faq-plugin/go.mod
|
||||
- ../sm-faq-plugin/go.sum
|
||||
- ../sm-menu-plugin/console/import_winter.go
|
||||
- ../sm-menu-plugin/classes/tree.go
|
||||
- ../sm-menu-plugin/controllers/api/menus.go
|
||||
- ../sm-menu-plugin/commands_documented_test.go
|
||||
- ../sm-menu-plugin/README.md
|
||||
- ../sm-menu-plugin/go.mod
|
||||
- ../sm-menu-plugin/go.sum
|
||||
- ../sm-chat-plugin/console/rewrite_context_types.go
|
||||
- ../sm-chat-plugin/api/errors.go
|
||||
- ../sm-chat-plugin/classes/service.go
|
||||
- ../sm-chat-plugin/jobs/maintain_thread_summary.go
|
||||
- ../sm-chat-plugin/commands_documented_test.go
|
||||
- ../sm-chat-plugin/README.md
|
||||
- ../sm-chat-plugin/go.mod
|
||||
- ../sm-chat-plugin/go.sum
|
||||
- ../sm-chatchannels-plugin/console/rewrite_context_types.go
|
||||
- ../sm-chatchannels-plugin/api/errors.go
|
||||
- ../sm-chatchannels-plugin/boot.go
|
||||
- ../sm-chatchannels-plugin/classes/channel_service.go
|
||||
- ../sm-chatchannels-plugin/classes/search_indexer.go
|
||||
- ../sm-chatchannels-plugin/commands_documented_test.go
|
||||
- ../sm-chatchannels-plugin/README.md
|
||||
- ../sm-chatchannels-plugin/go.mod
|
||||
- ../sm-chatchannels-plugin/go.sum
|
||||
- ../sm-chatvideo-plugin/console/reconcile.go
|
||||
- ../sm-chatvideo-plugin/api/errors.go
|
||||
- ../sm-chatvideo-plugin/classes/notifier.go
|
||||
- ../sm-chatvideo-plugin/classes/user_deletion.go
|
||||
- ../sm-chatvideo-plugin/commands_documented_test.go
|
||||
- ../sm-chatvideo-plugin/README.md
|
||||
- ../sm-chatvideo-plugin/go.mod
|
||||
- ../sm-chatvideo-plugin/go.sum
|
||||
- ../sm-forum-plugin/console/rewrite_context_types.go
|
||||
- ../sm-forum-plugin/api/errors.go
|
||||
- ../sm-forum-plugin/boot.go
|
||||
- ../sm-forum-plugin/classes/forum_service.go
|
||||
- ../sm-forum-plugin/commands_documented_test.go
|
||||
- ../sm-forum-plugin/README.md
|
||||
- ../sm-forum-plugin/go.mod
|
||||
- ../sm-forum-plugin/go.sum
|
||||
- ../sm-herald-plugin/console/vapid_generate.go
|
||||
- ../sm-herald-plugin/api/errors.go
|
||||
- ../sm-herald-plugin/boot.go
|
||||
- ../sm-herald-plugin/classes/sender.go
|
||||
- ../sm-herald-plugin/commands_documented_test.go
|
||||
- ../sm-herald-plugin/README.md
|
||||
- ../sm-herald-plugin/go.mod
|
||||
- ../sm-herald-plugin/go.sum
|
||||
- ../fonoteka.go/plugins/golem15/golem/console/import_settings.go
|
||||
- ../fonoteka.go/plugins/golem15/golem/commands_documented_test.go
|
||||
- ../fonoteka.go/plugins/golem15/golem/README.md
|
||||
- ../fonoteka.go/plugins/golem15/golem/go.mod
|
||||
- ../fonoteka.go/plugins/golem15/golem/go.sum
|
||||
- ../fonoteka.go/plugins/golem15/feedback/console/import_settings.go
|
||||
- ../fonoteka.go/plugins/golem15/feedback/controllers/api/feedback_api_controller.go
|
||||
- ../fonoteka.go/plugins/golem15/feedback/jobs.go
|
||||
- ../fonoteka.go/plugins/golem15/feedback/commands_documented_test.go
|
||||
- ../fonoteka.go/plugins/golem15/feedback/README.md
|
||||
- ../fonoteka.go/plugins/golem15/feedback/go.mod
|
||||
- ../fonoteka.go/plugins/golem15/feedback/go.sum
|
||||
- ../fonoteka.go/plugins/golem15/user/console/api_tokens.go
|
||||
- ../fonoteka.go/plugins/golem15/user/console/backfill_handles.go
|
||||
- ../fonoteka.go/plugins/golem15/user/console/require_password_change.go
|
||||
- ../fonoteka.go/plugins/golem15/user/classes/admin_actions.go
|
||||
- ../fonoteka.go/plugins/golem15/user/classes/authorizer.go
|
||||
- ../fonoteka.go/plugins/golem15/user/classes/avatar.go
|
||||
- ../fonoteka.go/plugins/golem15/user/classes/session_user.go
|
||||
- ../fonoteka.go/plugins/golem15/user/controllers/api_controller.go
|
||||
- ../fonoteka.go/plugins/golem15/user/controllers/registration.go
|
||||
- ../fonoteka.go/plugins/golem15/user/plugin.go
|
||||
- ../fonoteka.go/plugins/golem15/user/commands_documented_test.go
|
||||
- ../fonoteka.go/plugins/golem15/user/README.md
|
||||
- ../fonoteka.go/plugins/golem15/user/go.mod
|
||||
- ../fonoteka.go/plugins/golem15/user/go.sum
|
||||
- ../sm-demo-app/plugins/golem15/user
|
||||
- ../sm-demo-app/plugins/golem15/journal
|
||||
- ../sm-demo-app/plugins/golem15/sitemap
|
||||
- ../sm-demo-app/plugins/golem15/menu
|
||||
- ../sm-demo-app/plugins/golem15/faq
|
||||
- ../sm-demo-app/plugins/golem15/paymentgateway
|
||||
- ../sm-demo-app/plugins/golem15/pgstripe
|
||||
- ../sm-demo-app/plugins/golem15/oauthserver
|
||||
- ../sm-demo-app/plugins/golem15/golem
|
||||
- ../sm-demo-app/plugins/golem15/feedback
|
||||
- ../sm-demo-app/main.go
|
||||
- ../sm-bm-app/plugins/golem15/user
|
||||
- ../sm-bm-app/main.go
|
||||
- ../sm-grzybyfunkcjonalne-app/plugins/golem15/user
|
||||
- ../sm-grzybyfunkcjonalne-app/plugins/golem15/journal
|
||||
- ../sm-grzybyfunkcjonalne-app/plugins/golem15/sitemap
|
||||
- ../sm-grzybyfunkcjonalne-app/plugins/golem15/menu
|
||||
- ../sm-grzybyfunkcjonalne-app/plugins/golem15/faq
|
||||
- ../sm-grzybyfunkcjonalne-app/main.go
|
||||
- ../sm-summercmsio-app/plugins/golem15/user
|
||||
- ../sm-summercmsio-app/plugins/golem15/journal
|
||||
- ../sm-summercmsio-app/main.go
|
||||
- ../fonoteka.go/plugins/golem15/user
|
||||
- ../fonoteka.go/plugins/golem15/golem
|
||||
- ../fonoteka.go/plugins/golem15/feedback
|
||||
- ../fonoteka.go/main.go
|
||||
- .planning/todos/pending/user-plugin-console-sweep.md
|
||||
- .planning/G15OFFICE.md
|
||||
autonomous: false
|
||||
requirements: [SC-3, SC-4, SC-5, D-04, D-05, D-06, D-07, D-11, D-17, D-18, D-19, D-20, D-21, D-22, D-23, D-24, D-25, D-26, D-27, D-28, D-29]
|
||||
user_setup: []
|
||||
|
||||
estimate:
|
||||
tokens: 320000
|
||||
raw_tokens: 320000
|
||||
tasks: 8
|
||||
confidence: low
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "D-04, D-05, D-06: at default verbosity serve prints a compact aligned startup block on a terminal (app and framework version, environment, URL and admin URL, database host and migration state 'up to date' or 'N pending', plugin count and names, queue, scheduler, mail, cache, realtime and search drivers) followed by INFO logs restyled by diary; -v adds one line per plugin; -q shows only warnings and errors; off a terminal the same facts arrive as an INFO record 'startup summary' and the record 'listening on <addr>' keeps its exact text for the applications' smoke scripts."
|
||||
- "D-07, D-29: on Ctrl+C or SIGTERM, serve, queue:work and summer dev print a short report (draining, requests drained or aborted, worker job counts done, failed and handed back to the queue, uptime, drain errors) and exit 0; a drain error exits 1."
|
||||
- "D-11, D-23: every request produces one INFO record 'http request' from component http with method, the matched route pattern (never the raw path, query or headers), status, duration_ms and request_id (X-Request-Id, at most 128 bytes); it is on by default, components.http: warn or http.access_log: false silences it."
|
||||
- "D-25: summer dev starts bin/<app> serve with the global flags it was given and restarts it on rebuild."
|
||||
- "D-17, D-21, D-22: every command in summercms.go (modules, cmd/summer, internal, examples/hello's greeter and docs/examples/blog) has a description, long help and examples, and cmd/summer's TestCommandsDocumented fails when bonfire.CheckDocs reports any problem; each command's Artisan checklist row in this plan is implemented as recorded (added --force, --pretend and --json flags, progress, result lines), and declined rows say why."
|
||||
- "D-18, D-19: known failures end in a hint block: running an application binary from a directory without config/ below an application root suggests 'cd <rel>; bin/<app> <args>'; an empty DSN, an unreachable database, pending migrations, a missing app.key or uploads bucket each name the command that fixes them; summer make:* outside an application suggests 'cd <app>; summer make:...' (folded todo, messages only); flare and docs commands that already printed their problems return bonfire.Reported."
|
||||
- "D-27, D-28: key:generate, admin:token:create and herald:vapid-generate still print their data under -q; migrate, migrate:rollback, queue:clear and deferred:purge ask for confirmation only in production on an interactive terminal, --force skips it, and runs off a terminal (deploys over SSH, the scheduler) proceed as today; existing --force meanings in the importers and pg:update-currencies are unchanged."
|
||||
- "D-20, D-24, D-26: no framework tag or push happens before the recorded go-ahead; after it the framework is tagged and pushed with a same-day changelog post, then each shared plugin (the D-20 list plus sm-golem-plugin and sm-feedback-plugin) gets exactly one commit (description, long help, examples, named loggers, output polish, a warn-only TestCommandsDocumented, require of the new framework tag) pushed before its applications get separate pointer-bump commits; no command name or flag is removed or renamed. When Phase 19.5 still holds the user plugin, sm-user-plugin is deferred to .planning/todos/pending/user-plugin-console-sweep.md with a g15office task, and the SUMMARY and verification report D-20 as partial."
|
||||
- "SC-5: module READMEs, docs/console pages, docs/services/logging.md, docs/setup/upgrading.md, the summercms.io landing copy and the roadmap deck in sm-demo-app describe the new console."
|
||||
artifacts:
|
||||
- path: "modules/surf/summary.go"
|
||||
provides: "startup summary and shutdown report builders, request counters"
|
||||
contains: "listening on "
|
||||
- path: "modules/lagoon/migrations.go"
|
||||
provides: "PendingMigrations"
|
||||
contains: "func PendingMigrations("
|
||||
- path: "modules/conga/worker.go"
|
||||
provides: "Worker.Stats for the shutdown report"
|
||||
contains: "func (w *Worker) Stats() WorkerStats"
|
||||
- path: "cmd/summer/commands_documented_test.go"
|
||||
provides: "framework documentation coverage gate (fails)"
|
||||
contains: "CheckDocs"
|
||||
- path: "internal/dev/watch.go"
|
||||
provides: "summer dev runs serve and reports on shutdown"
|
||||
contains: "\"serve\""
|
||||
key_links:
|
||||
- from: "modules/surf/accesslog.go"
|
||||
to: "modules/diary/diary.go"
|
||||
via: "request log is the http component"
|
||||
pattern: "diary.For\\(\"http\"\\)"
|
||||
- from: "modules/surf/serve.go"
|
||||
to: "modules/lagoon/migrations.go"
|
||||
via: "startup summary reports pending migrations"
|
||||
pattern: "PendingMigrations\\("
|
||||
- from: "cmd/summer/commands_documented_test.go"
|
||||
to: "modules/bonfire/docs.go"
|
||||
via: "framework commands must pass CheckDocs"
|
||||
pattern: "bonfire.CheckDocs\\("
|
||||
prohibitions:
|
||||
- statement: "No command name or existing flag in summercms.go or any shared plugin is removed, renamed or given a different meaning; new flags are additive."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "Nothing is pushed, tagged, posted to the changelog or deployed before the Task 6 reply allows it, and nothing is ever force-pushed or deployed."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "The startup summary, hints and logs never print a DSN password, an app key, a token or a secret."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "Framework code, READMEs and docs name no consuming application."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
---
|
||||
|
||||
## Phase Goal
|
||||
|
||||
ROADMAP Goal (not in user-story format; no story is invented, see plan 01): console commands reach the Artisan bar; a user sees what a command did, can ask for more or less of it, and learns from `--help` how to use it.
|
||||
|
||||
This plan's slice (plan 3 of 4, wave 3): the sweep. The tracer takes `serve` all the way: help and examples, the startup summary as a block and as log records, the `http` request log by route pattern, the shutdown report and exit 0. Then the other long-running commands (`queue:work`, `schedule:run`, `summer dev` running `serve`), the application binary's one-shot commands with the Artisan checklist and the D-19 hints, the `summer` tool's commands with the failing framework coverage gate, and the landing site and roadmap deck. A human checkpoint (D-26) stands before anything leaves the machine. After the go-ahead: tag and push the framework with the changelog post, sweep and push the shared plugins one commit each, and bump the applications' pointers in separate commits.
|
||||
|
||||
## Spec-less probe fallback
|
||||
|
||||
Spec-less probe fallback skipped: no requirement IDs (phase_req_ids null) and no SPEC.md; visible choice. `must_haves` cite SC-3..SC-5 and D-04..D-29.
|
||||
|
||||
## Decisions taken in this plan (recorded assumptions and Claude's discretion)
|
||||
|
||||
- Confirmation rule (D-21 with D-28, following Artisan's ConfirmableTrait): migrate, migrate:rollback, queue:clear and deferred:purge ask only when the environment is `production` and the Output is interactive (`bonfire.ConfirmProceed`); they gain `--force` to skip the question. Everywhere else they behave as today.
|
||||
- `--pretend` (Artisan's name) is added where it can be read-only without touching the command's write path: migrate (lists pending migration IDs per plugin), migrate:rollback (names the migration that would roll back), deferred:purge (counts expired bindings past the cut-off with one read query). The importers already have `--dry-run`; no alias is added (RESEARCH Pitfall 11). Bulk writers whose writes run through a service (paymentgateway's cancel and finish commands, the rewrite-context-types commands, oauthserver:prune, chatvideo:reconcile-sessions, user:backfill-handles) are recorded as declined: a faithful dry run needs a dry mode inside the service, which is command logic and outside this phase (CONTEXT: command logic does not change).
|
||||
- On a terminal the startup summary is a block on stdout and its log records are tagged with `diary.HideOnTTY(ctx)` so the styled console does not print the same facts twice; daily files and non-terminal consoles keep the records (D-05). Under `-q` the block is not printed.
|
||||
- The database line prints host, port and database name parsed with pgconn.ParseConfig, never the user, password or full DSN; a DSN that does not parse shows "configured".
|
||||
- In-flight jobs at stop are reported as "handed back to the queue" after the executor confirms in River's source that StopAndCancel leaves cancelled jobs retryable (RESEARCH A2); if River discards them, the wording becomes "cancelled".
|
||||
- The framework release version is chosen at the Task 6 checkpoint; the recommendation is the next minor above the latest tag at that time (a new module, new dependencies, new config keys and a new generated main). `docs/setup/upgrading.md` gets a section for that version.
|
||||
- The user plugin's checkout (`../fonoteka.go/plugins/golem15/user`) is shared with Phase 19.5, which is executing. It is swept last in Task 8, only when 19.5-04-SUMMARY.md exists and the checkout is clean and in sync with origin; otherwise it is deferred: Task 8 files `.planning/todos/pending/user-plugin-console-sweep.md` with a g15office task (subtask of the Phase 19.8 milestone task), the SUMMARY and the verification report mark D-20 as partial and name both, and sm-bm-app, which mounts only the user plugin, is left untouched and listed in the todo (no breaking change either way).
|
||||
- Plugin descriptions are normalised to imperative sentence case without a trailing period and without planning IDs ("(D-15)", "(D-21)"); names and flags stay.
|
||||
|
||||
## Execution segments (fresh-context resume at the Task 6 checkpoint)
|
||||
|
||||
The phase keeps 4 plans (user-locked), so this plan's size is handled inside the plan: it runs in two executor contexts, split at the release checkpoint that already stops execution. The plugin sweep therefore starts on fresh context instead of last in a degraded one.
|
||||
|
||||
- Segment A: Tasks 1-5 and the Task 6 checkpoint (summercms.go, the landing repository, sm-demo-app; about 190k of the 320k estimate). Before returning the Task 6 checkpoint, the executor writes the `## Handoff` section (below) into the draft `19.8-03-SUMMARY.md` on disk. The draft is not committed here, so it adds nothing to the origin/master..master range shown at the checkpoint; the plan's final SUMMARY commit carries it.
|
||||
- Segment B: Tasks 7 and 8 (about 130k) in a fresh executor context. Under `/msd-execute-phase` this is the continuation agent spawned after the reply (never a resume of the segment A agent); in inline execution, `/clear` and resume the plan at Task 7. Segment B loads only: the handoff, this plan's Decisions, the shared-plugin rows of the Artisan checklist, Tasks 7 and 8, CONTEXT D-20 to D-28, CLAUDE.md, and RESEARCH "Apps that need pointer bumps" with Pitfalls 15 and 16. It does not reread the plan 01 and 02 SUMMARYs or the framework diffs; the handoff names the API it needs. Its first step checks the handoff against the repositories (Task 7 action).
|
||||
- Handoff contents (the draft SUMMARY's `## Handoff`): (1) segment A's commits per repository, hash and subject, and the summercms.go origin/master..master list shown at the checkpoint; (2) the framework API the plugin sweep uses, with the names as built (bonfire.Example and its fields, Command.Examples, Command.Hidden, the CheckDocs signature, Reported, WithHint, IsQuiet, the progress and Process helpers, diary.For); (3) the recommended tag and the upgrading section heading; (4) for each plugin repository and application: path, branch, HEAD, uncommitted paths, origin/master..master count; (5) Docker tests run or NOT RUN; (6) whether 19.5-04-SUMMARY.md exists and the user plugin checkout's state; (7) deviations from this plan so far. After the reply the continuation adds the "Task 6 reply" verbatim; Task 7 appends its results before Task 8 starts, so Task 8 can also resume from the handoff alone.
|
||||
|
||||
## Artisan checklist (D-21), per command
|
||||
|
||||
Columns: Force = confirmation with `--force`; Pretend = `--pretend` or an existing dry run; Json = `--json`; Prog = progress bar, process view or table; Result = result line and exit code. "add" means this plan implements it; "–" means it does not fit, with the reason in Notes. Every command also gets Long and Examples (D-17).
|
||||
|
||||
Framework, application binary (Task 1 to Task 3):
|
||||
|
||||
| Command | Force | Pretend | Json | Prog | Result | Notes |
|
||||
|---|---|---|---|---|---|---|
|
||||
| serve | – | – | – | startup summary, shutdown report | exit 0 graceful, 1 drain error | long-running (Task 1) |
|
||||
| queue:work | – | – | – | summary, report | exit 0 graceful, 1 drain error | long-running (Task 2) |
|
||||
| schedule:run | – | – | – | summary, report | `--once`: "Ran N due entries (M failed)", exit code unchanged from today | long-running (Task 2) |
|
||||
| migrate | add (production + TTY) | add | – | one line per plugin with applied count | "Applied N migrations" or "Nothing to migrate" | deploys over SSH never prompt |
|
||||
| migrate:rollback | add (production + TTY) | add | – | – | "Rolled back <id> of <plugin>" | |
|
||||
| migrate:status | – read-only | – | add | table kept | – | |
|
||||
| deferred:purge | add (production + TTY) | add (count only) | – | – | existing purge line | |
|
||||
| key:generate | – | – | add | – | key on stdout, bare key under -q (D-27) | non-TTY output unchanged |
|
||||
| queue:clear | add (production + TTY) | – | – | – | "Cleared N jobs from <queue>" | description loses its trailing period |
|
||||
| route:list | – | – | add | table kept | – | |
|
||||
| admin:create | – | – | – | – | existing success line | prompt rules from plan 02 (piped password kept) |
|
||||
| admin:reset-password | – | – | – | – | existing | |
|
||||
| admin:token:create | – | – | add | – | token on stdout (data, kept under -q) | |
|
||||
| admin:token:list | – | – | add | table kept | – | |
|
||||
| admin:token:revoke | – single explicit id | – | – | – | existing | |
|
||||
| admin:token:scopes | – | – | add | table kept | – | |
|
||||
| websockets:health | – | – | add | – | exit 1 when unhealthy | |
|
||||
| websockets:generate-vapid-keys | – | – | – | – | Reported replaces the print-then-sentinel error | flags unchanged |
|
||||
| websockets:test-push | – | – | – | – | Reported | Confirm default stays true under -n |
|
||||
| greeter:hello (examples/hello) | – | – | – | – | – | Long, Examples |
|
||||
| blog:publish (docs/examples/blog) | – | – | – | – | existing | Long, Examples |
|
||||
|
||||
Framework, summer tool (Task 2 and Task 4):
|
||||
|
||||
| Command | Force | Pretend | Json | Prog | Result | Notes |
|
||||
|---|---|---|---|---|---|---|
|
||||
| build | – | – | – | spinner around go build on a terminal | "Built bin/<name> in <d>" | |
|
||||
| make:plugin, make:model, make:migration, make:command, make:job, make:admin-controller | – | – | – | – | "Created <path>" per file | D-19 hints (folded todo, messages only) |
|
||||
| plugin:add | – | – | – | – | result line | hint when not in an application |
|
||||
| new | – | – | – | – | existing next steps; writes config/log.yaml (plan 01) | |
|
||||
| dev | – | – | – | header, rebuild lines, shutdown report | exit 0 on Ctrl+C | runs serve (D-25) |
|
||||
| parity:proxy, parity:record, parity:replay, parity:broadcasts, parity:upstream | – | – | – | – | existing | Long, Examples; defaults shown by help |
|
||||
| docs:build, docs:sync | – | `--check` exists | – | – | Reported after the problem list | |
|
||||
| docs:serve | – | – | – | – | exit 0 on Ctrl+C | |
|
||||
| 15 delegates (migrate … admin:token:scopes) | – the app binary decides | – | – | – | the app binary's exit code | Examples per delegate row |
|
||||
|
||||
Shared plugins (Tasks 7 and 8; existing flags unchanged):
|
||||
|
||||
| Command | Force | Pretend | Json | Prog | Result | Notes |
|
||||
|---|---|---|---|---|---|---|
|
||||
| pg:cancel-created-payments, pg:cancel-sent-payments, pg:cancel-placed-orders, pg:finish-orders | – scheduled state transitions | – declined (service dry mode needed) | – | – | "Cancelled N payments" / "Cancelled N orders" / "Finished N orders" | scheduled daily, run through Call |
|
||||
| pg:update-currencies | – (`--force` keeps "force update") | – | – | – | "Updated N currencies" | every 10 minutes |
|
||||
| paymentgateway:import-winter, pgstripe:import-winter, oauthserver:import-winter, sitemap:import-winter, faq:import-winter, menu:import-winter | – (`--force` keeps "replace") | already `--dry-run` | – | – | existing summary lines, final result line | |
|
||||
| oauthserver:prune | – | – declined | – | – | "Pruned N codes, M tokens, K keys" | scheduled daily |
|
||||
| oauthserver:rotate-keys | – | – | – | – | "Promoted key <kid>" | |
|
||||
| journal:export-posts, journal:import-posts | – (`--force` keeps "update existing") | already `--dry-run` | – | add progress over files | final counts line | |
|
||||
| sitemap:import | – | – | – | – | "Imported N URLs" | |
|
||||
| chat:, chatchannels:, forum:rewrite-context-types | – idempotent | – declined | – | – | "Rewrote N rows" | descriptions lose "(idempotent)." wording into Long |
|
||||
| chatvideo:reconcile-sessions | – | – declined | – | – | "Ended N sessions" | |
|
||||
| herald:vapid-generate | – | – | add | – | env lines on stdout (data, kept under -q) | |
|
||||
| golem:import-settings, feedback:import-settings | – (`--force` keeps its meaning) | – | – | – | result line | |
|
||||
| user:token:create | – | – | add | – | token on stdout (data) | |
|
||||
| user:token:list | – | – | add | table kept | – | |
|
||||
| user:token:revoke, user:require-password-change | – | – | – | – | result line | planning IDs removed from descriptions |
|
||||
| user:backfill-handles | – | – declined | – | add progress | "Assigned N handles" | |
|
||||
|
||||
## Artifacts this phase produces (this plan)
|
||||
|
||||
- surf: unexported summary and report builders and a request counter in `modules/surf/summary.go`; request log on component `http` with `request_id`; `http.access_log` defaults to on; smoke `TestServeSummarySmoke`, `TestShutdownReportSmoke`; Docker test `TestServeLifecycle`; `route:list --json`.
|
||||
- lagoon: `MigrationRef{Plugin, ID string}`, `PendingMigrations(gdb *gorm.DB, plugins []party.Plugin) ([]MigrationRef, error)`; flags `migrate --pretend --force`, `migrate:rollback --pretend --force`, `migrate:status --json`, `deferred:purge --pretend --force`, `key:generate --json`; hinted errors for an empty DSN and an unreachable database.
|
||||
- conga: `WorkerStats{Done, Failed, InFlight int64}`, `(*Worker).Stats() WorkerStats`; `queue:clear --force`; queue:work and schedule:run summary and report.
|
||||
- cabana: `admin:token:create --json`, `admin:token:list --json`, `admin:token:scopes --json`. lighthouse: `websockets:health --json`. flare: Reported errors.
|
||||
- diary: `HideOnTTY(ctx context.Context) context.Context`.
|
||||
- bonfire: wrong-directory hint for application binaries inside RenderError and Fail.
|
||||
- internal/dev: `Watch(ctx context.Context, appDir string, out bonfire.Output, args []string) error` runs `bin/<app> serve <args>` and prints a report; `startBin` passes `serve`.
|
||||
- cmd/summer: `TestCommandsDocumented` (fails on any CheckDocs problem); Examples for every tool command and delegate.
|
||||
- Each shared plugin repo: `commands_documented_test.go` with a warn-only `TestCommandsDocumented`; a package-level `diary.For("<code>")` logger; go.mod requires the released framework tag.
|
||||
- Landing copy (`features.console`, `winter.concepts.commands`) and roadmap deck text in sm-demo-app.
|
||||
|
||||
<objective>
|
||||
Sweep every command to the Artisan bar (help, examples, checklist, result lines, hints), give the long-running commands a startup summary and a shutdown report, move the request log onto the http component, describe it all in the docs, landing site and roadmap deck, then release in D-26 order after a human go-ahead.
|
||||
|
||||
Purpose: SC-3 (coverage gate), SC-4, SC-5 and D-04..D-07, D-11, D-17..D-29.
|
||||
Output: framework commits per task with READMEs and docs, local landing and deck commits, the recorded checkpoint reply, the tag and changelog post, one commit per shared plugin, pointer-bump and regenerate commits per application.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/msd-core/workflows/execute-plan.md
|
||||
@~/.claude/msd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/phases/19.8-console-commands-overhaul/19.8-CONTEXT.md
|
||||
@.planning/phases/19.8-console-commands-overhaul/19.8-RESEARCH.md
|
||||
@.planning/phases/19.8-console-commands-overhaul/19.8-PATTERNS.md
|
||||
@.planning/phases/19.8-console-commands-overhaul/19.8-01-SUMMARY.md
|
||||
@.planning/phases/19.8-console-commands-overhaul/19.8-02-SUMMARY.md
|
||||
|
||||
Repos and concurrency: Tasks 1-4 write in summercms.go only; Task 5 in ../sm-summercmsio-app (and its vue-summercmsio-app submodule) and ../sm-demo-app; Tasks 7-8 in the shared plugin repos and the applications. Before each task run `git status --porcelain` in every repository the task touches and `git log --oneline origin/master..master` (after `git fetch origin`) in each plugin and application; stop and report when a path this plan edits is modified by someone else or a repository holds commits this plan did not make (except summercms.go master, whose unpushed commits are listed at the checkpoint). Superprojects (sm-demo-app, sm-bm-app, sm-grzybyfunkcjonalne-app, sm-summercmsio-app, fonoteka.go) often carry other sessions' pointer changes: stage only the submodule paths and files this plan names. Stage by explicit path; no co-author tags; planning docs and code in separate commits; READMEs and docs in the same commit as the change they describe; `go vet ./...`, `go test -short ./...` and `go -C examples/hello test ./...` green at every framework commit. Docker-backed tests skip under -short; on a Docker host run them, through `sudo -u golem` if the socket is denied (CLAUDE.md); a Docker test that could not run is NOT RUN in the SUMMARY, never passed.
|
||||
|
||||
<interfaces>
|
||||
- plan 02 API: `bonfire.Example`, `Command.Examples`, `Command.Hidden`, `WithHint`, `Reported`, `Usage`, `ConfirmProceed`, `Process`, `Current`, `GlobalsOf`, `IsQuiet`, `Interactive`, `Globals.Args`, `CheckDocs`, `RenderError`, `Fail`; plan 01 API: `diary.For`, `diary.Bootstrap`, `diary.Verbosity`, `diary.NewConsoleHandler`.
|
||||
- modules/surf/serve.go:28-122 ServeCommand (NotifyContext, OpenFromApp, Publish, publishUploads, installCooler, BuildRouter, compile, net.Listen, StartServeWorker, banner, http.Server, 10 s shutdown, stopWorker), serveURL :178, countAdmins :212, serveBanner :243 (`"listening on " + addr`), printBanner :273; modules/surf/serve_test.go TestServeBanner.
|
||||
- modules/surf/accesslog.go: accessLogKey "http.access_log", accessLogMessage "http request", accessEntry{pattern, panicked}, accessLogEnabled :38, the doc comment :42-60 (why paths are never logged), attrs :74-79, accessLogger :101-108; modules/surf/errorpage.go errorPageMaxRequestID = 128 and its request-id trimming.
|
||||
- modules/lagoon/migrations.go: Migrate :64, RollbackLast :121, lastMigrationPlugin :156, StatusRow :169, Status :176; modules/lagoon/commands.go migrate :26, rollback :39, status :60, deferred:purge :82 (PurgeDeferred, PurgeResult{Bindings, Files, Children, Skipped}); modules/lagoon/keygen.go :24-37; modules/lagoon/connection.go:22-24 empty-DSN error, :30-32 ping error.
|
||||
- modules/conga/worker.go: Worker.Queues :44, StartWorker :63, StartServeWorker :163, Stop :172 (StopAndCancel after the deadline), Manager.runAttempt :243; modules/conga/commands.go queue:work :31, schedule:run :45, queue:clear :64, work :84, runScheduler :100.
|
||||
- modules/cabana/token_commands.go:21-60 and :117 (`out.Println(plain)` for the token); modules/cabana/commands.go:21-45; modules/lighthouse/centrifugo/commands.go:31; modules/flare/commands.go:20-21 (errGenerateFailed, errTestPushFailed), :50, :212.
|
||||
- internal/dev/watch.go: Options{Build, Start, Debounce} :21, Watch :29, rebuild output via fmt.Fprintf :79-88, startBin :201 (`exec.Command(binPath)`); internal/dev/watch_test.go fakes Start.
|
||||
- cmd/summer/main.go: toolCommands :28, make* :65-211, resolveMakeArgs :220, printArtifact :245, devCommand :255, addPluginCommand :269, newCommand :296; cmd/summer/runtime.go delegateCommands table :26-44 and delegateCommand :55; cmd/summer/docs.go docs:build/sync/serve and reportDocsProblems :168 (`errors.New(cmd + " failed")`); cmd/summer/parity.go flags; internal/build/manifest.go:215 (`build: no summer.yaml found from`).
|
||||
- examples/hello/plugins/greeter/plugin.go:80 greeter:hello; docs/examples/blog/console/publish.go:18 blog:publish.
|
||||
- Sibling repos: plugin constructors in each ../sm-*-plugin/console/*.go (analog ../sm-sitemap-plugin/console/import.go:27-45); the user, golem and feedback plugins under ../fonoteka.go/plugins/golem15/; application submodules per RESEARCH "Apps that need pointer bumps"; ../sm-summercmsio-app/scripts/changelog-post.py and DEPLOY.md ("Posting to the changelog"); ../sm-demo-app/scripts/sync-roadmap.sh and README "Roadmap deck"; ../sm-summercmsio-app/vue-summercmsio-app/i18n/locales/en.json (`features.console`, `winter.concepts.commands`).
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tracer">
|
||||
<name>Task 1: Tracer: serve shows what it booted, logs each request by route pattern under the http component, reports what it did on Ctrl+C and exits 0, with help, examples and docs</name>
|
||||
<reversibility rating="costly">D-23 flips the request log to on by default and its record shape (component http, request_id) becomes what operators grep and ship to log stores.</reversibility>
|
||||
<files>modules/surf/serve.go, modules/surf/summary.go, modules/surf/accesslog.go, modules/surf/serve_summary_smoke_test.go, modules/surf/serve_lifecycle_test.go, modules/surf/README.md, modules/lagoon/migrations.go, modules/lagoon/README.md, modules/conga/worker.go, modules/conga/README.md, modules/diary/diary.go, modules/diary/console.go, modules/diary/README.md, docs/console/setup-and-maintenance.md, docs/services/logging.md</files>
|
||||
<read_first>modules/surf/serve.go, modules/surf/accesslog.go, modules/surf/accesslog_test.go (TestAccessLogSmoke), modules/surf/serve_test.go, modules/surf/errorpage.go (request id cap), modules/lagoon/migrations.go, modules/conga/worker.go, modules/conga/postgres_test.go (testcontainers Postgres setup to copy), modules/cooler/cooler.go (CacheDriverKey), modules/postcard/mailer.go (mail.driver default), modules/beachcomber/beachcomber.go (search.driver), modules/lighthouse/lighthouse.go (realtime.driver), modules/conga/conga.go (queue.work_in_serve, queue.queues), modules/diary/console.go, docs/console/setup-and-maintenance.md ("The HTTP server"), docs/services/logging.md ("Request log"), ../sm-grzybyfunkcjonalne-app/scripts/smoke.sh (:292-306, 2>&1 and the "listening on" grep), 19.8-RESEARCH.md (Pattern 6, Pitfall 9), 19.8-CONTEXT.md (D-04..D-07, D-23, D-29)</read_first>
|
||||
<action>Per D-04..D-07, D-11, D-23 and D-29.
|
||||
|
||||
lagoon/migrations.go: `type MigrationRef struct { Plugin, ID string }` and `PendingMigrations(gdb, plugins)` returning each plugin's declared migration IDs that its history table does not hold, in plugin and declaration order (reuse Status's table reading; a missing history table means all pending). README API row.
|
||||
|
||||
conga/worker.go: `type WorkerStats struct { Done, Failed, InFlight int64 }`; atomic counters updated in Manager.runAttempt (InFlight up on entry and down on exit; Done on a nil error, Failed otherwise); `(*Worker).Stats()` returns them. Confirm in River's source how StopAndCancel leaves cancelled jobs (RESEARCH A2) and word the report accordingly. README API row.
|
||||
|
||||
diary: `HideOnTTY(ctx)` returns a context carrying a marker; a console channel that renders styled lines on a terminal skips records whose context carries it; every other channel and a non-terminal console write them. README row.
|
||||
|
||||
surf/summary.go (new, unexported): a request counter wrapping the compiled handler (atomic in-flight and served counts, independent of the access-log setting); `summaryFacts` collected once after binding: app name (app.name) and `bonfire.BuildVersion()`, environment (`app.Config.Environment()`), URL (serveURL) and admin URL with admin presence (existing countAdmins), database host, port and name from pgconn.ParseConfig of database.dsn (never user or password; "configured" when it does not parse), pending migrations from lagoon.PendingMigrations ("up to date" or "N pending — run bin/<app> migrate"), plugins (count and IDs), queue (worker queues or "off"), scheduler (in serve or off, as conga runs it), mail (mail.driver), cache (cooler.CacheDriverKey), realtime (realtime.driver), search (search.driver), each defaulting the way its module does; a block renderer (8 to 12 aligned label/value lines in the bonfire palette; with verbosity 1 or more one extra line per plugin with its ID, required plugins and pending migration count) and a records emitter (one INFO record "startup summary" from `diary.For("serve")` with one attribute per fact, and one INFO record whose message is exactly `listening on <addr>`); a report builder (draining, in-flight requests drained or aborted at the deadline, served total, worker Done/Failed/InFlight, uptime, drain errors) rendered as lines and as one INFO record "shutdown report".
|
||||
|
||||
surf/serve.go: wrap the handler with the counter; after the listener and worker start, on a non-quiet terminal (`!bonfire.IsQuiet(out)` and the console is a terminal) print the block and emit the records with `diary.HideOnTTY(ctx)`; otherwise emit the records only (the old ℹ banner lines and the separate "job worker started" Info line are folded into the summary). On ctx.Done (signal): print "Shutting down: draining N requests" (decoration, suppressed under -q), Shutdown with the 10 s deadline, stop the worker, print and log the report, and return nil when there were no drain errors (D-29), else the joined drain errors (exit 1). A server error unrelated to a signal keeps today's behaviour. Add Long (what serve boots, how it shuts down, exit codes) and Examples (`serve`, `serve --addr 127.0.0.1:8096`, `serve -v` for per-plugin details, `serve -q` for warnings and errors only).
|
||||
|
||||
surf/accesslog.go (D-23): logger is the app-published `*slog.Logger` when present, else `diary.For("http")`; attributes method, pattern, status, duration_ms, request_id (X-Request-Id trimmed and capped at 128 bytes, omitted when empty), client_gone as today; the URL path, query, headers and remote address stay out (keep the doc comment's reasoning and add D-23); accessLogEnabled returns true unless `http.access_log` is set to false. Update TestAccessLogSmoke expectations for request_id and the default.
|
||||
|
||||
Docs and READMEs in the same commit: docs/console/setup-and-maintenance.md "The HTTP server" (the summary block, what -v and -q change, the shutdown report, exit codes, the records off a terminal); docs/services/logging.md "Request log" (on by default at INFO under component http, fields, why the route pattern and never the path, silencing with `components: {http: warn}` or `http.access_log: false`); surf README (Features, Configuration default for http.access_log). Commit: `feat(surf): serve startup summary, shutdown report and http request log`.
|
||||
|
||||
Tests. serve_summary_smoke_test.go (-short): `TestServeSummarySmoke` builds the facts from a compass config with a DSN containing a password and checks the block lines (environment, URL, database host and name, "3 pending", plugin count, drivers) and the record attributes, that the password and user never appear, and that one record's message equals `listening on 127.0.0.1:8096`; `TestShutdownReportSmoke` renders the report for fixed counters and a drain error and checks each line. serve_lifecycle_test.go (Docker, skips under -short through a TestMain that calls flag.Parse and testing.Short, or the package's existing TestMain): `TestServeLifecycle` starts Postgres with testcontainers, runs ServeCommand's Run with `diary.Bootstrap` on a buffer, a file:// uploads bucket in t.TempDir and an addr on 127.0.0.1:0-free port, waits for the listener, sends one request with X-Request-Id, cancels the context, and asserts Run returned nil, the buffer holds `listening on 127.0.0.1:<port>`, "startup summary", one "http request" record with component http, pattern and request_id and without the request path's literal value, and "shutdown report" with served 1.</action>
|
||||
<verify>
|
||||
<automated>go vet ./... && go test -count=1 -short ./modules/surf/... ./modules/lagoon/... ./modules/conga/... ./modules/diary/... && go test -count=1 -short -run '^(TestServeSummarySmoke|TestShutdownReportSmoke|TestAccessLogSmoke|TestServeBanner)$' -v ./modules/surf 2>&1 | grep -c -- '^--- PASS: Test' | grep -qE '^[34]$' && go -C examples/hello test -count=1 ./... && go test -count=1 -run '^TestDocsTree$' ./cmd/summer && go run ./cmd/summer docs:build --check</automated>
|
||||
<fails_when>Non-zero exit, or fewer than 3 top-level "--- PASS: Test" lines (the summary, report or access-log smoke failed or did not run; TestServeBanner may be removed if the banner builder is folded into the summary), or hello or the docs checker fail.</fails_when>
|
||||
<automated>go test -count=1 -run '^TestServeLifecycle$' -v ./modules/surf 2>&1 | grep -q -- '^--- PASS: TestServeLifecycle'</automated>
|
||||
<fails_when>No "--- PASS: TestServeLifecycle" line: the end-to-end serve run failed, or it printed "--- SKIP" because Docker was unreachable (rerun through sudo -u golem; otherwise record NOT RUN in the SUMMARY).</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -c 'diary.For("http")' modules/surf/accesslog.go` prints 1 and `grep -c '"request_id"' modules/surf/accesslog.go` prints at least 1.
|
||||
- `grep -cE 'r\.URL\.Path|req\.URL\.Path|RequestURI' modules/surf/accesslog.go` prints 0.
|
||||
- `grep -c 'func PendingMigrations(' modules/lagoon/migrations.go` prints 1 and `grep -c 'func (w \*Worker) Stats() WorkerStats' modules/conga/worker.go` prints 1.
|
||||
- `grep -c '"listening on "' modules/surf/summary.go` prints at least 1.
|
||||
- `grep -c 'Examples:' modules/surf/serve.go` prints at least 1 and `grep -c 'Long:' modules/surf/serve.go` prints at least 1.
|
||||
- `grep -c 'components' docs/services/logging.md` prints at least 1 and `grep -c 'request_id' docs/services/logging.md` prints at least 1.
|
||||
</acceptance_criteria>
|
||||
<done>An operator starting serve sees what booted and whether migrations are pending, sees each request by route, and on Ctrl+C gets a short report and exit code 0.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: queue:work and schedule:run report what booted and what they did; summer dev runs bin/<app> serve with the global flags and reports on Ctrl+C; docs:serve stops cleanly</name>
|
||||
<files>modules/conga/commands.go, modules/conga/README.md, internal/dev/watch.go, internal/dev/watch_test.go, cmd/summer/main.go, cmd/summer/docs.go, docs/console/setup-and-maintenance.md, docs/console/scaffolding.md, docs/console/utilities.md</files>
|
||||
<read_first>modules/conga/commands.go, modules/conga/worker.go (Stats from Task 1), modules/conga/scheduler.go, modules/surf/summary.go (Task 1 block and report helpers to mirror), internal/dev/watch.go, internal/dev/watch_test.go, cmd/summer/main.go (devCommand :255), cmd/summer/docs.go (docs:serve), docs/console/setup-and-maintenance.md ("Queues and the scheduler"), docs/console/scaffolding.md ("Building and watching"), docs/console/utilities.md, 19.8-RESEARCH.md (Pitfalls 13 and 14)</read_first>
|
||||
<action>Per D-07, D-22, D-25 and D-29.
|
||||
|
||||
conga/commands.go queue:work: after the worker starts, a summary (environment, queues with their concurrency, database host, scheduler included or not) as a block on a non-quiet terminal and as an INFO record "startup summary" from `diary.For("queue")` (HideOnTTY when the block was printed); on signal, the report from `Worker.Stats` (done, failed, handed back), uptime and stop errors as lines and an INFO record "shutdown report"; return nil on a clean stop (D-29). schedule:run: summary (entry count, the next due entry); report (entries run, failures, uptime); `--once` ends with "Ran N due entries (M failed)" and keeps today's error and exit behaviour. Long and Examples for queue:work (`queue:work`, `queue:work --queue mail --queue default`), schedule:run (`schedule:run`, `schedule:run --once` for cron) and queue:clear is left to Task 3.
|
||||
|
||||
internal/dev/watch.go (D-25): `Watch(ctx, appDir, out bonfire.Output, args []string) error`; startBin runs `exec.Command(binPath, append([]string{"serve"}, args...)...)` and Options.Start gains the args; the header ("Watching <dir>; running bin/<binary> serve"), rebuild lines ("Rebuilt in <d>", "Build failed: <err>" through out.Error, "Restarted bin/<binary>") go through Output instead of raw Fprintf; on ctx.Done stop the child with SIGTERM (it prints its own report), then print the dev report (rebuilds, failed builds, restarts, uptime) and return nil. cmd/summer/main.go devCommand: wrap ctx with signal.NotifyContext(os.Interrupt, syscall.SIGTERM) and pass `bonfire.GlobalsOf(out).Args()`; Long (what is watched, that it runs serve, flags forwarded) and Examples (`dev`, `dev -v`). watch_test.go: the fake Start asserts it receives "serve" followed by the forwarded args, and a cancelled context returns nil.
|
||||
|
||||
cmd/summer/docs.go docs:serve: Ctrl+C returns nil; Long and Examples.
|
||||
|
||||
Docs in the same commit: setup-and-maintenance.md "Queues and the scheduler" (summary and report, exit codes); scaffolding.md "Building and watching" (dev runs serve, forwards -v/-q, reports on Ctrl+C); utilities.md docs:serve line; conga README. Commit: `feat(conga,dev): worker and scheduler reports, summer dev runs serve`.</action>
|
||||
<verify>
|
||||
<automated>go vet ./... && go test -count=1 -short ./modules/conga/... ./internal/dev/... ./cmd/summer/... && go test -count=1 -short -v ./internal/dev 2>&1 | grep -q -- '^--- PASS' && go test -count=1 -run '^TestDocsTree$' ./cmd/summer && go run ./cmd/summer docs:build --check</automated>
|
||||
<fails_when>Non-zero exit (a conga, dev or summer test is red, for example the fake Start did not receive "serve"), no "--- PASS" line from internal/dev (no test ran), or the docs checker rejects the new pages.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -c 'append(\[\]string{"serve"}, args...)' internal/dev/watch.go` prints 1.
|
||||
- `grep -cE 'fmt\.Fprintf\(out' internal/dev/watch.go` prints 0.
|
||||
- `grep -c 'GlobalsOf(out).Args()' cmd/summer/main.go` prints 1.
|
||||
- `grep -c 'shutdown report' modules/conga/commands.go` prints at least 1.
|
||||
- `grep -c 'serve' docs/console/scaffolding.md` prints at least 1.
|
||||
</acceptance_criteria>
|
||||
<done>Every long-running command tells the operator what it started and what it did, summer dev actually serves the application, and Ctrl+C ends each of them with exit code 0.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: Application-binary commands meet their checklist rows (help, examples, --force, --pretend, --json, result lines) and known failures end in hints, including the wrong-directory hint</name>
|
||||
<files>modules/lagoon/commands.go, modules/lagoon/keygen.go, modules/lagoon/connection.go, modules/lagoon/README.md, modules/conga/commands.go, modules/surf/routelist_command.go, modules/surf/serve.go, modules/surf/README.md, modules/cabana/commands.go, modules/cabana/token_commands.go, modules/cabana/README.md, modules/lighthouse/centrifugo/commands.go, modules/lighthouse/README.md, modules/flare/commands.go, modules/flare/README.md, modules/bonfire/errors.go, modules/bonfire/README.md, examples/hello/plugins/greeter/plugin.go, examples/hello/hello_test.go, docs/examples/blog/console/publish.go, cmd/summer/commands_documented_test.go, docs/console/setup-and-maintenance.md, docs/console/writing-commands.md</files>
|
||||
<read_first>modules/lagoon/commands.go, modules/lagoon/keygen.go, modules/lagoon/connection.go, modules/conga/commands.go (queue:clear :64), modules/surf/routelist_command.go, modules/surf/serve.go (publishUploads error, app.key use), modules/cabana/commands.go, modules/cabana/token_commands.go, modules/cabana/commands_test.go (:241-262), modules/lighthouse/centrifugo/commands.go, modules/flare/commands.go, modules/bonfire/errors.go, cmd/summer/docs.go (docsCommands constructors to reuse in the test), examples/hello/plugins/greeter/plugin.go, docs/examples/blog/console/publish.go, docs/console/setup-and-maintenance.md, this plan's checklist table (application binary rows), 19.8-RESEARCH.md (Command Inventory, Code Examples "Error with hint", Pitfalls 10, 11 and 13)</read_first>
|
||||
<action>Implement the application-binary rows of this plan's checklist exactly; every command gets Long and Examples; names and existing flags stay.
|
||||
|
||||
lagoon: migrate gains `--pretend` (print each pending MigrationRef as "<plugin>: <id>" and "N migrations would run", apply nothing) and `--force` (Bare, "Skip the production confirmation"); before applying, when the environment is production and ConfirmProceed asks and gets no, return a Reported error "Migration cancelled."; result "Applied N migrations" (N from PendingMigrations before Migrate) or "Nothing to migrate". migrate:rollback: `--pretend` names the last applied migration of the plugin (from Status) and `--force` with the same confirmation rule; result "Rolled back <id> of <plugin>". migrate:status `--json` prints the rows as a JSON array of {plugin, id, applied_at}. deferred:purge `--pretend` reports the number of bindings older than the cut-off (one COUNT query with the same filter, nothing deleted) and `--force` with the confirmation rule. key:generate `--json` prints {"key": ...}; under -q (bonfire.IsQuiet) the key prints through Println; otherwise unchanged. connection.go: the empty-DSN error becomes WithHint(err, "The application reads database.dsn from config/database.yaml or SUMMER_DATABASE__DSN.", "cp .env.example .env") and the ping failure WithHint(err, "PostgreSQL is not reachable at the configured host.", "docker compose up -d --wait"). Missing uploads bucket and empty app.key errors in serve get hints naming config/storage.yaml and `{bin} key:generate`.
|
||||
|
||||
conga queue:clear: description "Clear pending jobs from a queue" (no trailing period), `--force` with the confirmation rule, result "Cleared N jobs from <queue>". surf route:list `--json` (array of {method, pattern, name, middleware} as the table shows). cabana: admin:token:create `--json` ({id, name, token, scopes, expires_at}; the plain token still prints through Println otherwise), admin:token:list `--json`, admin:token:scopes `--json`. lighthouse websockets:health `--json` ({healthy, ...} without the API key) and exit 1 when unhealthy (as today). flare: replace the print-then-sentinel errors (errGenerateFailed, errTestPushFailed) with `bonfire.Reported(...)` so the renderer adds no second message.
|
||||
|
||||
bonfire/errors.go (D-19, message only): for application binaries (root name other than summer), when a command fails or Fail is called, and the working directory has no `config` directory, and a parent directory holds `summer.yaml`, add a hint "This command runs from the application root." with Try `cd <relative path to that parent>; bin/<name> <original args>`; summer is unaffected (its commands walk up already). Database detection from the wrong directory is not changed.
|
||||
|
||||
Examples: greeter:hello (examples/hello) and blog:publish (docs/examples/blog) get Long and Examples; examples/hello/hello_test.go gains `TestGreeterDocumented` asserting bonfire.CheckDocs returns nothing for the greeter plugin's commands.
|
||||
|
||||
cmd/summer/commands_documented_test.go (D-17): `TestCommandsDocumented` builds the application command set the way docsCommands does (lagoon, key:generate, conga, surf serve and route:list, cabana, centrifugo, flare) plus blog.Publish, runs bonfire.CheckDocs and fails listing every problem. (Task 4 adds the tool commands.)
|
||||
|
||||
Docs and READMEs in the same commit(s): docs/console/setup-and-maintenance.md (new flags with examples, the production confirmation rule and --force, -q keeps keys and tokens), writing-commands.md (the wrong-directory hint), each touched module README's CLI commands section. Commits by module are fine (`feat(lagoon): checklist flags and hints for migration commands`, `feat(cabana,surf,conga): --json and result lines`, `feat(bonfire): wrong-directory hint`), each green.</action>
|
||||
<verify>
|
||||
<automated>go vet ./... && go test -count=1 -short ./... && go test -count=1 -short -run '^TestCommandsDocumented$' -v ./cmd/summer 2>&1 | grep -q -- '^--- PASS: TestCommandsDocumented' && go -C examples/hello test -count=1 -run '^TestGreeterDocumented$' -v . 2>&1 | grep -q -- '^--- PASS: TestGreeterDocumented' && go -C examples/hello test -count=1 ./... && go test -count=1 -run '^TestDocsTree$' ./cmd/summer && go run ./cmd/summer docs:build --check</automated>
|
||||
<fails_when>Non-zero exit; no "--- PASS: TestCommandsDocumented" (an application command lacks a description, long help, examples or a flag description, or the test did not run); no "--- PASS: TestGreeterDocumented"; or the WR-13 piped-password test, hello or the docs checker fail.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -c '"pretend"' modules/lagoon/commands.go` prints at least 3 and `grep -c '"force"' modules/lagoon/commands.go` prints at least 3.
|
||||
- `grep -c 'ConfirmProceed' modules/lagoon/commands.go` prints at least 1 and `grep -c 'ConfirmProceed' modules/conga/commands.go` prints at least 1.
|
||||
- `grep -c '"json"' modules/cabana/token_commands.go` prints at least 3 and `grep -c '"json"' modules/surf/routelist_command.go` prints 1.
|
||||
- `grep -cE 'errGenerateFailed|errTestPushFailed' modules/flare/commands.go` prints 0 and `grep -c 'bonfire.Reported(' modules/flare/commands.go` prints at least 1.
|
||||
- `grep -c 'WithHint(' modules/lagoon/connection.go` prints 2.
|
||||
- `grep -c 'Clear all queued jobs, by deleting all pending jobs\.' modules/conga/commands.go` prints 0.
|
||||
- `grep -c 'bonfire.CheckDocs(' cmd/summer/commands_documented_test.go` prints at least 1.
|
||||
</acceptance_criteria>
|
||||
<done>Every application-binary command explains itself with examples, scripts get JSON where they read output, destructive commands ask only where a human is watching in production, and the common failures say exactly what to run next.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 4: summer tool commands meet their checklist rows and pass the failing coverage gate; make:* errors carry hints; the upgrading notes and console docs are complete</name>
|
||||
<files>cmd/summer/main.go, cmd/summer/runtime.go, cmd/summer/parity.go, cmd/summer/docs.go, cmd/summer/commands_documented_test.go, internal/build/manifest.go, internal/build/artifact.go, internal/build/scaffold.go, docs/console/introduction.md, docs/console/scaffolding.md, docs/console/utilities.md, docs/console/writing-commands.md, docs/setup/upgrading.md, modules/bonfire/README.md, modules/diary/README.md</files>
|
||||
<read_first>cmd/summer/main.go, cmd/summer/runtime.go, cmd/summer/parity.go, cmd/summer/docs.go, cmd/summer/main_test.go, cmd/summer/runtime_test.go, internal/build/manifest.go (:205-220), internal/build/artifact.go, internal/build/scaffold.go, .planning/todos/pending/scaffold-generated-header-and-command-deps.md, docs/console/introduction.md ("Commands that summer delegates"), docs/console/scaffolding.md, docs/console/utilities.md, docs/setup/upgrading.md ("Upgrading to 0.2" structure), this plan's checklist table (summer tool rows)</read_first>
|
||||
<action>Implement the summer tool rows of this plan's checklist; names and flags stay.
|
||||
|
||||
build: on a live console wrap the go build in out.Spinner; end with "Built bin/<name> in <duration>". make:plugin, make:model, make:migration, make:command, make:job, make:admin-controller: one "Created <path>" line per written file (printArtifact), Long (what is generated, where, how the registry picks it up, and for make:command the current limitation that the generated function takes no arguments, from the folded todo), Examples (`make:model acme.blog Post`, `make:command acme.blog publish` and so on). Hints (folded todo, partial, messages only; the scaffolder is unchanged): internal/build/manifest.go's "no summer.yaml found from <dir>" becomes WithHint(err, "summer make: commands run inside an application (a directory with summer.yaml).", "cd <app>; summer <command> <args>"); invalid plugin IDs and identifiers, an artifact that already exists and a plugin that is not in summer.yaml each get an explanation and a Try with a valid form. plugin:add and new: result lines and Examples. parity:* commands: Long (what each does in the parity workflow) and Examples; flag descriptions keep their "(true/false)" wording. docs:build and docs:sync: reportDocsProblems returns `bonfire.Reported(...)` after printing the problem list; Long and Examples. Delegates (runtime.go): the table gains an examples column, each delegate gets two Examples rendered with summer (for example `migrate`, `migrate --pretend`; `admin:token:create jane --scope journal.posts --json`), and Long adds that global flags pass through to the application binary.
|
||||
|
||||
cmd/summer/commands_documented_test.go: extend `TestCommandsDocumented` to `toolCommands()` as well; it fails on any problem.
|
||||
|
||||
docs/setup/upgrading.md: a section "Upgrading to 0.4" (Task 7 renames it if the user picks another version) with Boot refusals and Behaviour changes in the existing structure: run `summer build` to regenerate main.go; daily log files under storage/logs (directory must be writable, or set log.default to console); GORM lines move to component db and record-not-found is no longer logged; the request log is on by default (route pattern, silence with components.http: warn); duplicate command names and reserved global flag names stop the binary at boot; usage errors exit 2; -q keeps data output; summer dev runs serve; destructive commands confirm in production on a terminal (--force skips). docs/console/introduction.md "Commands that summer delegates" (examples and global flags), scaffolding.md (make:* results and hints), utilities.md (parity and docs examples), writing-commands.md (a complete example command with Long and Examples). Commit: `feat(summer): help, examples and hints for every tool command`.</action>
|
||||
<verify>
|
||||
<automated>go vet ./... && go test -count=1 -short ./cmd/summer/... ./internal/build/... && go test -count=1 -short -run '^TestCommandsDocumented$' -v ./cmd/summer 2>&1 | grep -q -- '^--- PASS: TestCommandsDocumented' && go test -count=1 -short ./... && go -C examples/hello test -count=1 ./... && go test -count=1 -run '^TestDocsTree$' ./cmd/summer && go run ./cmd/summer docs:build --check</automated>
|
||||
<fails_when>Non-zero exit, or no "--- PASS: TestCommandsDocumented" (a tool or delegate command lacks a description, long help or examples), or the build, summer, hello or docs checks fail.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -c 'toolCommands()' cmd/summer/commands_documented_test.go` prints at least 1.
|
||||
- `grep -c 'WithHint(' internal/build/manifest.go` prints at least 1.
|
||||
- `grep -c 'bonfire.Reported(' cmd/summer/docs.go` prints at least 1 and `grep -c 'errors.New(cmd + " failed")' cmd/summer/docs.go` prints 0.
|
||||
- `grep -c '^## Upgrading to 0.4' docs/setup/upgrading.md` prints 1 and the section matches each of `storage/logs`, `summer build`, `components`, `exit` (checked with grep -c on the file, each at least 1).
|
||||
- `go run ./cmd/summer list --format=json | python3 -c 'import json,sys; d=json.load(sys.stdin); bad=[c["name"] for c in d["commands"] if not c["examples"] and c["name"] not in ("help","list")]; sys.exit(1 if bad else 0)'` exits 0.
|
||||
</acceptance_criteria>
|
||||
<done>summer's own commands, including every delegate, explain themselves with examples, make:* failures say where to run them, and the upgrading page lists every behaviour change of this phase.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 5: The summercms.io landing copy and the roadmap deck in sm-demo-app describe the new console (local commits, nothing pushed or deployed)</name>
|
||||
<files>../sm-summercmsio-app/vue-summercmsio-app/i18n/locales/en.json, ../sm-summercmsio-app/vue-summercmsio-app, ../sm-demo-app/plugins/demo/roadmap/curated.go, ../sm-demo-app/plugins/demo/content/ROADMAP.md</files>
|
||||
<read_first>../sm-summercmsio-app/vue-summercmsio-app/i18n/locales/en.json (features.console :117-120, winter.concepts.commands :139), ../sm-summercmsio-app/vue-summercmsio-app/app/components/FeaturesSection.vue, ../sm-summercmsio-app/vue-summercmsio-app/app/data/terminal.json, ../sm-summercmsio-app/terminal_check_test.go, ../sm-summercmsio-app/CLAUDE.md, ../sm-demo-app/README.md ("Roadmap deck"), ../sm-demo-app/plugins/demo/roadmap/curated.go (console feature lines near :104 and :145), ../sm-demo-app/plugins/demo/roadmap/roadmap_test.go, ../sm-demo-app/scripts/sync-roadmap.sh</read_first>
|
||||
<action>Per SC-5. Landing (vue-summercmsio-app): `features.console.body` names the new console in one sentence of the same length class (Artisan-style list and help with examples, -v and -q on every command, daily log files); `winter.concepts.commands` keeps "Artisan commands" on the Winter side and shows "bonfire.Command + summer list" (or similar short text) on the Summer side. terminal.json stays unchanged (its commands still work). Run `npm --prefix ../sm-summercmsio-app/vue-summercmsio-app run test`. Commit in vue-summercmsio-app (`feat: describe the Artisan-style console`), then in sm-summercmsio-app stage only the `vue-summercmsio-app` submodule path and commit the pointer (`chore: bump vue-summercmsio-app for console copy`).
|
||||
|
||||
Roadmap deck (sm-demo-app): in plugins/demo/roadmap/curated.go update the console feature text (EN and PL) to name the Artisan-style list and help, global flags, the diary logging with daily files and the startup summary; refresh the snapshot with `scripts/sync-roadmap.sh` (from framework master as of now) and run `go -C ../sm-demo-app test ./plugins/demo/roadmap/...` (TestRoadmapNoClientNames, TestRoadmapPolishTitles must pass). Commit in sm-demo-app staging only those two paths (`feat(demo): roadmap deck describes the new console`). Nothing is pushed or deployed here; Task 7 or 8 pushes under the Task 6 reply.</action>
|
||||
<verify>
|
||||
<automated>npm --prefix ../sm-summercmsio-app/vue-summercmsio-app run test && go -C ../sm-summercmsio-app test -count=1 -run '^(TestLoadTerminal|TestTerminalScript|TestTerminalEnv)$' . && go -C ../sm-demo-app test -count=1 ./plugins/demo/roadmap/... && ../sm-demo-app/scripts/sync-roadmap.sh --check</automated>
|
||||
<fails_when>Non-zero exit: a landing node test fails, the terminal data no longer loads, a roadmap test fails (for example a client name slipped into the deck text), or sync-roadmap --check reports the snapshot stale.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -c 'Scaffolding, scheduler, workers and your own commands.' ../sm-summercmsio-app/vue-summercmsio-app/i18n/locales/en.json` prints 0.
|
||||
- `L=$(git -C ../sm-summercmsio-app/vue-summercmsio-app log --oneline -1 -- i18n/locales/en.json) && test -n "$L"` exits 0 and `S=$(git -C ../sm-summercmsio-app status --porcelain -- vue-summercmsio-app) && test -z "$S"` exits 0 (pointer committed).
|
||||
- `S=$(git -C ../sm-demo-app status --porcelain -- plugins/demo/roadmap plugins/demo/content/ROADMAP.md) && test -z "$S"` exits 0.
|
||||
- `B=$(git -C ../sm-summercmsio-app/vue-summercmsio-app branch -r --contains HEAD) && test -z "$B"` exits 0 (not pushed yet).
|
||||
</acceptance_criteria>
|
||||
<done>The landing copy and the roadmap deck describe the new console, committed locally and ready to publish with the release.</done>
|
||||
</task>
|
||||
|
||||
<task type="checkpoint:decision" gate="blocking">
|
||||
<name>Task 6: Go-ahead for the release in D-26 order: framework tag, push and changelog post, then the shared plugins' commits and pushes, then the applications' pointer bumps</name>
|
||||
<read_first>19.8-CONTEXT.md (D-20, D-24, D-26), this plan's "Execution segments" section (handoff contents), this plan's SUMMARY draft (Tasks 1-5), CLAUDE.md (Commits and concurrency, the changelog rule), ../sm-summercmsio-app/DEPLOY.md ("Posting to the changelog"), .planning/phases/19.5-social-login-in-sm-user-plugin/ (whether 19.5-04-SUMMARY.md exists)</read_first>
|
||||
<acceptance_criteria>
|
||||
- Before the checkpoint is returned, the draft SUMMARY holds the handoff (items 1-7 of "Execution segments"): `grep -c '^## Handoff' .planning/phases/19.8-console-commands-overhaul/19.8-03-SUMMARY.md` prints 1.
|
||||
- The SUMMARY records the reply verbatim under a "Task 6 reply" heading, and the chosen tag when the reply is a release.
|
||||
- Before the reply, `T=$(git tag --points-at HEAD) && test -z "$T"` exits 0 in summercms.go and no plugin repository has a commit from this plan (`git -C ../sm-journal-plugin log --oneline origin/master..master` lists none of this phase's subjects).
|
||||
</acceptance_criteria>
|
||||
<decision>May the phase publish now, and with which framework tag?</decision>
|
||||
<context>Tasks 1-5 are committed locally and green (the SUMMARY draft lists the commits, the Docker results and anything NOT RUN). The shared plugins cannot be swept until a framework tag exists, because each plugin's single commit (D-20) requires the tag that carries bonfire.Example and diary. Facts to weigh: summercms.go master is ahead of origin/master by this phase's commits plus other sessions' unpushed commits (at planning time 20, mostly Phase 19.5); pushing master publishes all of them, and the tag needs a same-day changelog post (scripts/changelog-post.py in ../sm-summercmsio-app; the token file must exist). The latest tag at planning time was v0.3.1; a new module, new dependencies, new config keys and a new generated main suggest the next minor (v0.4.0), unless another phase tagged in between. After the tag: 15 plugin repositories get one commit each and are pushed (sm-user-plugin only if Phase 19.5 has finished with it; otherwise it is deferred to a tracked todo and g15office task and D-20 is reported partial); then sm-demo-app, sm-bm-app, sm-grzybyfunkcjonalne-app, sm-summercmsio-app and fonoteka.go get pointer-bump commits and a regenerated main.go; the landing and deck commits from Task 5 go out with their repositories. Nothing is deployed in any option. This checkpoint ends segment A: the handoff is written to the SUMMARY draft before it returns, and after the reply Tasks 7 and 8 run in a fresh executor context from that handoff (see Execution segments).</context>
|
||||
<options>
|
||||
<option id="release">
|
||||
<name>release <tag> (for example "release v0.4.0")</name>
|
||||
<pros>D-26 exactly: tag, push, changelog, then each plugin pinned to the tag and pushed, then the applications' commits pushed; the deck and landing copy are published with their repositories (deploys stay manual).</pros>
|
||||
<cons>Publishes the other sessions' unpushed framework commits with the tag; the changelog notes are written now.</cons>
|
||||
</option>
|
||||
<option id="release-keep-apps-local">
|
||||
<name>release-keep-apps-local <tag></name>
|
||||
<pros>Framework and plugins are published as in "release"; the applications' pointer-bump and regenerate commits are made but stay local for you to push with your next deploy of each application.</pros>
|
||||
<cons>Applications keep building against the old plugin pointers on other machines until you push them.</cons>
|
||||
</option>
|
||||
<option id="hold">
|
||||
<name>hold</name>
|
||||
<pros>Nothing leaves this machine.</pros>
|
||||
<cons>The plugin sweep (D-20, D-24) cannot start, so SC-3 and SC-4 stay unproven for the plugins; the phase pauses after Task 5. The local Task 5 commits (the sm-demo-app deck, whose snapshot was synced from unpushed framework master, and the landing copy) describe a console that is not released; the SUMMARY says so and they stay unpushed until a release.</cons>
|
||||
</option>
|
||||
</options>
|
||||
<resume-signal>Reply "release vX.Y.Z", "release-keep-apps-local vX.Y.Z" or "hold". A release reply also authorises pushing each plugin repository after its single commit and, for "release", pushing each application, landing and deck repository whose unpushed commits are this phase's; nothing is ever force-pushed or deployed. The continuation runs Tasks 7 and 8 in a fresh context from the SUMMARY draft's handoff.</resume-signal>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 7: Under the Task 6 reply, tag and push the framework with the changelog post, then sweep and push paymentgateway, pgstripe, oauthserver, journal, sitemap, faq and menu, one commit each pinned to the tag</name>
|
||||
<precondition>19.8-03-SUMMARY.md (draft) holds the `## Handoff` section from segment A and records the Task 6 reply "release vX.Y.Z" or "release-keep-apps-local vX.Y.Z"; on "hold" this task records "release held", notes that the local Task 5 deck and landing commits describe an unreleased console, and does nothing else.</precondition>
|
||||
<files>docs/setup/upgrading.md, ../sm-paymentgateway-plugin/console/pg_cancel_created_payments.go, ../sm-paymentgateway-plugin/console/pg_cancel_sent_payments.go, ../sm-paymentgateway-plugin/console/pg_cancel_placed_orders.go, ../sm-paymentgateway-plugin/console/pg_finish_orders.go, ../sm-paymentgateway-plugin/console/pg_update_currencies.go, ../sm-paymentgateway-plugin/console/import_winter.go, ../sm-paymentgateway-plugin/commands_documented_test.go, ../sm-paymentgateway-plugin/README.md, ../sm-paymentgateway-plugin/go.mod, ../sm-paymentgateway-plugin/go.sum, ../sm-pgstripe-plugin/console/import_winter.go, ../sm-pgstripe-plugin/commands_documented_test.go, ../sm-pgstripe-plugin/README.md, ../sm-pgstripe-plugin/go.mod, ../sm-oauthserver-plugin/console/prune.go, ../sm-oauthserver-plugin/console/rotate_keys.go, ../sm-oauthserver-plugin/console/import_winter.go, ../sm-oauthserver-plugin/commands_documented_test.go, ../sm-oauthserver-plugin/README.md, ../sm-oauthserver-plugin/go.mod, ../sm-journal-plugin/console/export_posts.go, ../sm-journal-plugin/console/import_posts.go, ../sm-journal-plugin/commands_documented_test.go, ../sm-journal-plugin/README.md, ../sm-journal-plugin/go.mod, ../sm-sitemap-plugin/console/import.go, ../sm-sitemap-plugin/console/import_winter.go, ../sm-sitemap-plugin/commands_documented_test.go, ../sm-sitemap-plugin/README.md, ../sm-sitemap-plugin/go.mod, ../sm-faq-plugin/console/import_winter.go, ../sm-faq-plugin/commands_documented_test.go, ../sm-faq-plugin/README.md, ../sm-faq-plugin/go.mod, ../sm-menu-plugin/console/import_winter.go, ../sm-menu-plugin/commands_documented_test.go, ../sm-menu-plugin/README.md, ../sm-menu-plugin/go.mod (plus each repository's go.sum and the non-console files listed in this plan's frontmatter that log through the process default logger)</files>
|
||||
<read_first>19.8-03-SUMMARY.md draft (`## Handoff` and the Task 6 reply; segment B starts here in a fresh executor context, see Execution segments), CLAUDE.md (Commits and concurrency, changelog rule), ../sm-summercmsio-app/DEPLOY.md ("Posting to the changelog"), ../sm-summercmsio-app/scripts/changelog-post.py (usage header), ../sm-sitemap-plugin/console/import.go (analog), ../sm-paymentgateway-plugin/schedule.go, each repository's console/*.go, go.mod, go.work and README.md, this plan's checklist table (shared plugin rows)</read_first>
|
||||
<action>Per D-20, D-21, D-22, D-24 and D-26, under the recorded reply. Segment B start (fresh context): work from the handoff, not from memory of Tasks 1-5; first check that every hash it lists resolves (`git -C <repo> cat-file -e <hash>`) and that each repository's HEAD, uncommitted paths and origin/master..master count match what it recorded; a mismatch stops the task and is reported. Before every push: `git fetch origin`, `git status` shows no uncommitted change to this phase's paths, and `git log --oneline origin/master..master` lists only commits the user approved (summercms.go: the commits named at the checkpoint; a plugin: its one sweep commit); anything else stops the task. Never force-push; never deploy.
|
||||
|
||||
(1) Framework: if the reply's tag is not v0.4.0, rename the upgrading section heading to that version and commit (`docs: upgrading notes for vX.Y.Z`). `go vet ./...` and `go test -short ./...` green; annotated tag vX.Y.Z on master HEAD with a one-paragraph message (Artisan-style console, diary logging with daily files, startup summaries and reports, request log by route pattern, plus what else the commits since the previous tag contain); `git push origin master`, then `git push origin vX.Y.Z`. From ../sm-summercmsio-app run `scripts/changelog-post.py source vX.Y.Z`, write user-facing notes (## Highlights, ## Added, ## Changed, ## Fixed; no H1, no planning IDs, no quick codes; Changed lists the upgrading items) to a file in the session scratchpad, then `scripts/changelog-post.py publish vX.Y.Z <notes> --excerpt "<one line>"`. If the token file is missing or refused, ask the user; never print, copy or mint a token.
|
||||
|
||||
(2) For each repository in the order sm-paymentgateway-plugin, sm-pgstripe-plugin, sm-oauthserver-plugin, sm-journal-plugin, sm-sitemap-plugin, sm-faq-plugin, sm-menu-plugin, one commit containing all of: `go get git.golem15.com/golem15/summercms@vX.Y.Z` and `go mod tidy` (existing replace and go.work setups kept); each command per its checklist row: description in imperative sentence case without a trailing period, Long (what it does, when to run it, scheduling and flag interplay, for importers the meaning of their existing --force and --dry-run), two or three Examples, the result line, the added flags (none of the existing flags renamed or re-meant); a package-level `diary.For("<plugin code>")` logger (paymentgateway, pgstripe, oauthserver, journal, sitemap, faq, menu) replacing every non-test use of the process default logger in the repository's files (frontmatter list); `commands_documented_test.go` with `TestCommandsDocumented` that builds the plugin's commands with an empty app, calls bonfire.CheckDocs and logs each problem with t.Logf without failing (D-17 warn-only); README CLI commands section with each command, its flags and an example, and Dependencies naming SummerCMS vX.Y.Z. Checks before committing: `go vet ./...`, `go test -short ./...`, `GOWORK=off go build ./...` (proves the tag carries the API) and the warn-only test runs. Commit `feat: console help, examples and named loggers (SummerCMS vX.Y.Z)`, then `git push origin master`.
|
||||
|
||||
Record in the SUMMARY's handoff, before Task 8 starts (so Task 8 can resume from the handoff alone if segment B is interrupted): the tag and its pushed range, the changelog post URL, each plugin's commit hash and push result, each plugin's CheckDocs warnings (expected none).</action>
|
||||
<verify>
|
||||
<automated>git ls-remote --exit-code --tags origin "$(git describe --tags --abbrev=0)" && for r in sm-paymentgateway-plugin sm-pgstripe-plugin sm-oauthserver-plugin sm-journal-plugin sm-sitemap-plugin sm-faq-plugin sm-menu-plugin; do go -C ../$r vet ./... && go -C ../$r test -count=1 -short ./... && go -C ../$r test -count=1 -short -run '^TestCommandsDocumented$' -v . 2>&1 | grep -q -- '^--- PASS: TestCommandsDocumented' && test -z "$(git -C ../$r log --oneline origin/master..master)" || exit 1; done</automated>
|
||||
<fails_when>Non-zero exit: the framework tag is not on origin, a plugin fails vet or its short suite at the tag, its warn-only TestCommandsDocumented is missing or did not run, or the plugin still has unpushed commits. On "hold" the command is not run and the SUMMARY says so.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- On a release reply: `git ls-remote --exit-code --tags origin vX.Y.Z` exits 0 in summercms.go and the SUMMARY holds the changelog post URL.
|
||||
- For each of the seven repositories: `grep -c 'git.golem15.com/golem15/summercms vX.Y.Z' go.mod` prints 1, `git log --oneline -1` is the sweep commit, and `git log --oneline origin/master..master` is empty.
|
||||
- `for r in sm-paymentgateway-plugin sm-pgstripe-plugin sm-oauthserver-plugin sm-journal-plugin sm-sitemap-plugin sm-faq-plugin sm-menu-plugin; do C=$(git -C ../$r ls-files '*.go' | grep -v _test.go | sed "s|^|../$r/|" | xargs grep -lE 'slog\.Default\(\)|slog\.(Info|Warn|Error|Debug)(Context)?\(' 2>/dev/null); test -z "$C" || exit 1; done` exits 0.
|
||||
- `for r in sm-paymentgateway-plugin sm-journal-plugin sm-sitemap-plugin; do B=$(git -C ../$r show HEAD~1:README.md 2>/dev/null | grep -oE '^\| `[a-z]+:[a-z:-]+`' | sort -u) && A=$(grep -oE '^\| `[a-z]+:[a-z:-]+`' ../$r/README.md | sort -u) && test -z "$(comm -23 <(printf '%s\n' "$B") <(printf '%s\n' "$A"))" || exit 1; done` exits 0 (no command name disappeared from a README table; adapt the pattern if a README lists commands differently and record it).
|
||||
</acceptance_criteria>
|
||||
<done>The framework release is public with its changelog post, and seven shared plugins explain their commands, log through named components and are pinned to it, each in one pushed commit.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 8: Under the Task 6 reply, sweep and push chat, chatchannels, chatvideo, forum, herald, golem, feedback and (when Phase 19.5 has released it) user, then bump the applications' pointers and regenerate their main.go in separate commits</name>
|
||||
<precondition>Task 7 recorded the framework tag and seven pushed plugin commits; on "hold" this task records "release held" and does nothing else. For sm-user-plugin additionally: .planning/phases/19.5-social-login-in-sm-user-plugin/19.5-04-SUMMARY.md exists and `git -C ../fonoteka.go/plugins/golem15/user status --porcelain` is empty and in sync with origin/master; otherwise the user plugin is deferred as step (1) describes (todo, g15office task, D-20 reported partial).</precondition>
|
||||
<files>../sm-chat-plugin/console/rewrite_context_types.go, ../sm-chatchannels-plugin/console/rewrite_context_types.go, ../sm-chatvideo-plugin/console/reconcile.go, ../sm-forum-plugin/console/rewrite_context_types.go, ../sm-herald-plugin/console/vapid_generate.go, ../fonoteka.go/plugins/golem15/golem/console/import_settings.go, ../fonoteka.go/plugins/golem15/feedback/console/import_settings.go, ../fonoteka.go/plugins/golem15/user/console/api_tokens.go, ../fonoteka.go/plugins/golem15/user/console/backfill_handles.go, ../fonoteka.go/plugins/golem15/user/console/require_password_change.go, each repository's commands_documented_test.go, README.md, go.mod and go.sum, the non-console files listed in this plan's frontmatter that log through the process default logger, and the application paths ../sm-demo-app/plugins/golem15/*, ../sm-demo-app/main.go, ../sm-bm-app/plugins/golem15/user, ../sm-bm-app/main.go, ../sm-grzybyfunkcjonalne-app/plugins/golem15/*, ../sm-grzybyfunkcjonalne-app/main.go, ../sm-summercmsio-app/plugins/golem15/*, ../sm-summercmsio-app/main.go, ../fonoteka.go/plugins/golem15/user, ../fonoteka.go/plugins/golem15/golem, ../fonoteka.go/plugins/golem15/feedback, ../fonoteka.go/main.go, and in summercms.go .planning/G15OFFICE.md (step 3) and, only when sm-user-plugin is deferred, .planning/todos/pending/user-plugin-console-sweep.md</files>
|
||||
<read_first>19.8-03-SUMMARY.md draft (`## Handoff` with the Task 6 reply and the Task 7 results), each repository's console files, plugin.go, go.mod and README.md, ../fonoteka.go/plugins/golem15/user/console_test.go, 19.8-RESEARCH.md ("Apps that need pointer bumps", Pitfalls 15 and 16), this plan's checklist table (shared plugin rows), CLAUDE.md (Sibling repositories), .planning/G15OFFICE.md</read_first>
|
||||
<action>Per D-20, D-21, D-22, D-24 and D-26; same push rules as Task 7.
|
||||
|
||||
(1) Plugins, one commit each, in the order sm-chat-plugin, sm-chatchannels-plugin, sm-forum-plugin, sm-chatvideo-plugin, sm-herald-plugin, sm-golem-plugin (../fonoteka.go/plugins/golem15/golem), sm-feedback-plugin (../fonoteka.go/plugins/golem15/feedback), then sm-user-plugin (../fonoteka.go/plugins/golem15/user) only when the precondition holds: the same content as Task 7 step (2) (require the tag, checklist row per command, description normalised, Long, Examples, result line, `--json` for herald:vapid-generate, user:token:create and user:token:list with the data on stdout kept under -q, progress for user:backfill-handles, planning IDs removed from user descriptions, a package-level `diary.For("<plugin code>")` logger across the repository, the warn-only TestCommandsDocumented, README CLI section and Dependencies), the same checks (vet, short tests, `GOWORK=off go build ./...`, user plugin through `go -C ../fonoteka.go test -short ./plugins/golem15/user/...` as in Phase 19.5), then push. The rewrite-context-types descriptions move "(idempotent)" into Long. When the sm-user-plugin precondition fails, do not touch that checkout; instead, in summercms.go, write `.planning/todos/pending/user-plugin-console-sweep.md` (frontmatter title "Sweep sm-user-plugin console commands to the Phase 19.8 bar", date, priority medium, area "sm-user-plugin console", `g15office: <id>`; body: the observed reason for the deferral (19.5-04-SUMMARY.md missing, or the checkout dirty or out of sync), the user rows of this plan's Artisan checklist, the Task 7 step (2) content to apply, the framework tag to require, and the follow-up pointer bumps and regenerate commits in sm-demo-app, sm-bm-app, sm-grzybyfunkcjonalne-app, sm-summercmsio-app and fonoteka.go), create its g15office task (type Task, subtask of the Phase 19.8 milestone task, description ending `Local: .planning/todos/pending/user-plugin-console-sweep.md`) and write the ID into the frontmatter (if g15office is unavailable, `g15office: pending` and the SUMMARY says so), and commit the todo alone (`docs(19.8): defer the user plugin console sweep`). Record "D-20: partial (sm-user-plugin deferred, see .planning/todos/pending/user-plugin-console-sweep.md)" in the SUMMARY.
|
||||
|
||||
(2) Applications, after every plugin they mount is pushed (mapping: user to sm-demo-app, sm-bm-app, sm-grzybyfunkcjonalne-app, sm-summercmsio-app and fonoteka.go; journal to sm-demo-app, sm-grzybyfunkcjonalne-app, sm-summercmsio-app; sitemap, menu, faq to sm-demo-app and sm-grzybyfunkcjonalne-app; paymentgateway, pgstripe, oauthserver to sm-demo-app; golem and feedback to sm-demo-app and fonoteka.go; chat, chatchannels, chatvideo, forum, herald to none; with the user plugin deferred, applications skip the user pointer, and sm-bm-app, whose only bumped plugin would be user, gets no commit in this task). In each application: fetch and check out each bumped submodule at its pushed commit (fonoteka.go's user, golem and feedback checkouts are the canonical ones and already there); `go build ./...`; commit only those submodule paths (`chore: bump <plugins> for the console overhaul`); then `go run ../summercms.go/cmd/summer build` (or the installed summer) to regenerate main.go and build bin/<app>, run `./bin/<app> list` (exit 0, no duplicate-name error, RESEARCH Pitfall 15) and `./bin/<app> help migrate` (exit 0), and commit only main.go (`build: regenerate main for SummerCMS vX.Y.Z`). Never stage other sessions' pointer changes (check `git diff --submodule` first). On "release" push each application and the Task 5 landing (vue-summercmsio-app, then sm-summercmsio-app) and deck (sm-demo-app) commits whose unpushed commits are all this phase's; on "release-keep-apps-local" leave them local and list them in the SUMMARY.
|
||||
|
||||
(3) Post a short comment on the Phase 19.8 milestone task in g15office (find it by title; it is not yet in .planning/G15OFFICE.md, so add its ID there in a planning-doc commit when found) naming the release, the plugins and the applications; if the tool is unavailable, record that in the SUMMARY.</action>
|
||||
<verify>
|
||||
<automated>for r in sm-chat-plugin sm-chatchannels-plugin sm-forum-plugin sm-chatvideo-plugin sm-herald-plugin fonoteka.go/plugins/golem15/golem fonoteka.go/plugins/golem15/feedback; do go -C ../$r vet ./... && go -C ../$r test -count=1 -short ./... && test -z "$(git -C ../$r log --oneline origin/master..master)" || exit 1; done && for a in sm-demo-app sm-bm-app sm-grzybyfunkcjonalne-app sm-summercmsio-app fonoteka.go; do go -C ../$a build ./... || exit 1; done</automated>
|
||||
<fails_when>Non-zero exit: a plugin fails vet or tests at the tag or still has unpushed commits, or an application no longer builds with the bumped pointers. On "hold" the command is not run and the SUMMARY says so.</fails_when>
|
||||
<automated>for a in sm-demo-app sm-bm-app sm-grzybyfunkcjonalne-app sm-summercmsio-app fonoteka.go; do if [ "$a" = sm-bm-app ] && test -f .planning/todos/pending/user-plugin-console-sweep.md; then continue; fi; B=$(awk '/^binary:/{print $2}' ../$a/summer.yaml) && (cd ../$a && ./bin/$B list >/dev/null && ./bin/$B help migrate >/dev/null) || exit 1; done</automated>
|
||||
<fails_when>Non-zero exit: an application binary fails to boot its command root (for example a duplicate command name across plugins) or its help for migrate fails.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- For each swept plugin: `grep -c 'git.golem15.com/golem15/summercms vX.Y.Z' go.mod` prints 1 and `git log --oneline origin/master..master` is empty. When the sm-user-plugin precondition failed: the SUMMARY lists it as deferred with "D-20: partial", `test -f .planning/todos/pending/user-plugin-console-sweep.md` exits 0, and `grep -c '^g15office:' .planning/todos/pending/user-plugin-console-sweep.md` prints 1.
|
||||
- `grep -c '(D-15)\|(D-21)' ../fonoteka.go/plugins/golem15/user/console/backfill_handles.go ../fonoteka.go/plugins/golem15/user/console/require_password_change.go | awk -F: '{s+=$2} END {print s}'` prints 0 when the user plugin was swept.
|
||||
- In each application (except sm-bm-app when the user plugin was deferred): `git log --oneline -2` shows a pointer-bump commit and a regenerate commit as separate commits, and `grep -c 'bonfire.Execute(' main.go` prints 1.
|
||||
- The SUMMARY lists every pushed repository with its pushed range, or the local commits on "release-keep-apps-local".
|
||||
</acceptance_criteria>
|
||||
<done>Every shared plugin with commands is swept and pushed in one commit each, and every application runs the new console with the swept plugins, pinned through separate pointer-bump and regenerate commits.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| HTTP clients to the request log | paths carry share and invitation tokens; headers carry request ids of any length |
|
||||
| config and environment to the startup summary and hints | the DSN and keys are in config; summaries and hints are printed to terminals and journald |
|
||||
| operators over non-interactive SSH to destructive commands | deploys run migrate without a terminal |
|
||||
| this machine to git.golem15.com and summercms.io | tags, pushes and the changelog post publish work, including other sessions' commits |
|
||||
| framework to shared core plugins and applications | plugin APIs and command names are relied on by every application |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-19.8-13 | Information disclosure | surf request log (accesslog.go) | high | mitigate | records carry the route pattern only, never the path, query, headers or remote address (D-23); request_id capped at 128 bytes; acceptance greps for URL path access; TestServeLifecycle asserts the request path value is absent |
|
||||
| T-19.8-14 | Information disclosure | startup summary (summary.go) | high | mitigate | database shown as host, port and name from pgconn.ParseConfig, never user, password or DSN; driver names only for mail, cache, realtime and search; TestServeSummarySmoke asserts the password and user never appear |
|
||||
| T-19.8-15 | Denial of service | production confirmation (lagoon, conga) | high | mitigate | ConfirmProceed asks only on an interactive terminal; non-TTY deploys and scheduler Call runs proceed as today (D-28); --force skips; existing --force meanings untouched |
|
||||
| T-19.8-16 | Tampering | shared plugin contracts | medium | mitigate | one additive commit per plugin, names and flags unchanged (README command-table check), GOWORK=off build at the tag, short suites green; the plan 04 gate adds an exported-symbol additive check |
|
||||
| T-19.8-17 | Repudiation | release publication | medium | mitigate | blocking checkpoint (Task 6) lists origin..master before any push; pushes only after the recorded reply; never force-push or deploy |
|
||||
| T-19.8-18 | Information disclosure | changelog token | medium | mitigate | scripts/changelog-post.py reads the token file itself; the token is never printed, copied or minted; a missing token stops and asks the user |
|
||||
| T-19.8-19 | Information disclosure | hints (bonfire errors.go) | low | accept | Try lines show local relative paths and command names an operator already knows; messages still pass through sunscreen.Scrub |
|
||||
| T-19.8-20 | Denial of service | worker shutdown (conga) | low | mitigate | StopAndCancel after the deadline as today; in-flight jobs reported as handed back only after confirming River's retry behaviour |
|
||||
| T-19.8-SC | Tampering | module requires in plugins | medium | mitigate | plugins require only the framework tag produced in Task 7; no new third-party module is added by this plan |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- Task 1: serve smokes and TestServeLifecycle (Docker); request log by pattern; docs checker.
|
||||
- Task 2: conga, dev and summer suites; dev runs serve.
|
||||
- Task 3: whole short suite, TestCommandsDocumented over application commands, TestGreeterDocumented.
|
||||
- Task 4: TestCommandsDocumented over all framework commands; every listed command has examples.
|
||||
- Task 5: landing node tests, terminal data, roadmap tests and snapshot check.
|
||||
- Task 6: reply recorded; nothing published before it.
|
||||
- Tasks 7-8: tag on origin, changelog post, each plugin's suite at the tag and pushed, each application builds and boots list and help. When sm-user-plugin was deferred, D-20 is reported partial, naming .planning/todos/pending/user-plugin-console-sweep.md and its g15office ID, never as passed.
|
||||
- Segments: the `## Handoff` section exists in the SUMMARY draft before the Task 6 checkpoint returns, and Tasks 7-8 ran from it in a fresh context.
|
||||
- On "hold": the SUMMARY notes that the local deck and landing commits describe an unreleased console.
|
||||
- Feedback sampling: after each framework commit `go test -short ./...` and `go -C examples/hello test ./...`; after Task 4, `scripts`-free full run `go vet ./... && go test ./...` on a Docker host.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- D-04..D-07, D-29: summaries and reports for serve, queue:work, schedule:run and dev, exit 0 on graceful stop.
|
||||
- D-11, D-23: request log by route pattern, on by default.
|
||||
- D-17, D-21, D-22: every framework command documented and gated; the checklist table implemented; result lines.
|
||||
- D-18, D-19: hints for the known failures; Reported for self-reporting commands.
|
||||
- D-20, D-24, D-26: release in order after the go-ahead; one commit per plugin; separate application commits.
|
||||
- D-25: summer dev serves.
|
||||
- SC-5: docs, READMEs, landing and deck updated.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/19.8-console-commands-overhaul/19.8-03-SUMMARY.md` when done, with the commits in order per repository, the Task 6 reply verbatim, the tag, the changelog post URL, the pushed ranges (or local commits), Docker tests run or NOT RUN, each plugin's CheckDocs warnings, whether sm-user-plugin was swept or deferred (when deferred: the todo path, its g15office ID and "D-20: partial"), the `## Handoff` section (kept in the final SUMMARY), on "hold" the note that the local deck and landing commits describe an unreleased console, the g15office result, and any checklist row that changed during execution with its reason.
|
||||
</output>
|
||||
353
.planning/phases/19.8-console-commands-overhaul/19.8-04-PLAN.md
Normal file
353
.planning/phases/19.8-console-commands-overhaul/19.8-04-PLAN.md
Normal file
@@ -0,0 +1,353 @@
|
||||
---
|
||||
phase: 19.8-console-commands-overhaul
|
||||
plan: 04
|
||||
type: execute
|
||||
wave: 4
|
||||
depends_on: ["19.8-01", "19.8-02", "19.8-03"]
|
||||
files_modified:
|
||||
- scripts/check-phase19.8.sh
|
||||
- modules/diary/diary_test.go
|
||||
- modules/diary/config_test.go
|
||||
- modules/diary/daily_test.go
|
||||
- modules/diary/console_test.go
|
||||
- modules/diary/slogtest_test.go
|
||||
- modules/sunscreen/sunscreen_test.go
|
||||
- modules/bonfire/globals_test.go
|
||||
- modules/bonfire/root_test.go
|
||||
- modules/bonfire/golden_test.go
|
||||
- modules/bonfire/errors_test.go
|
||||
- modules/bonfire/docs_test.go
|
||||
- modules/bonfire/widgets_test.go
|
||||
- modules/bonfire/live_test.go
|
||||
- modules/bonfire/version_test.go
|
||||
- modules/bonfire/testdata/list.golden
|
||||
- modules/bonfire/testdata/list_all.golden
|
||||
- modules/bonfire/testdata/list.json.golden
|
||||
- modules/bonfire/testdata/help_token_create.golden
|
||||
- modules/bonfire/testdata/help_passthrough.golden
|
||||
- modules/bonfire/testdata/error_hint.golden
|
||||
- modules/bonfire/testdata/error_unknown_command.golden
|
||||
- modules/bonfire/testdata/error_chain_verbose.golden
|
||||
- modules/bonfire/testdata/list_styled.golden
|
||||
- internal/docsite/check_commands_test.go
|
||||
- modules/surf/summary_test.go
|
||||
- modules/surf/accesslog_test.go
|
||||
- modules/conga/stats_test.go
|
||||
- modules/conga/logger_test.go
|
||||
- modules/lagoon/pending_test.go
|
||||
- modules/lagoon/commands_flags_test.go
|
||||
- modules/lagoon/logger_test.go
|
||||
- modules/cabana/token_json_test.go
|
||||
- modules/flare/commands_test.go
|
||||
- internal/dev/watch_test.go
|
||||
- cmd/summer/runtime_test.go
|
||||
- .planning/phases/19.8-console-commands-overhaul/19.8-04-SUMMARY.md
|
||||
autonomous: false
|
||||
requirements: [SC-5, D-03, D-08, D-09, D-10, D-12, D-13, D-14, D-15, D-16, D-17, D-18, D-19, D-21, D-23, D-25, D-27, D-28, D-29]
|
||||
user_setup: []
|
||||
|
||||
estimate:
|
||||
tokens: 230000
|
||||
raw_tokens: 230000
|
||||
tasks: 6
|
||||
confidence: low
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "scripts/check-phase19.8.sh runs stages self-test, go, hello, docs, plugins, apps, landing and docker; a stage fails on a failing command or on a go test -json run that fails, skips a required test, matches zero tests, prints 'no tests to run' or misses a required name; --self-test proves those detectors on planted output; --all is green before /msd-verify-work (docker NOT RUN only when no daemon answers and PHASE198_REQUIRE_DOCKER is unset)."
|
||||
- "D-08, D-09, D-10, D-12: diary has unit tests for every config rule and its key-naming error, env strings, component floors and verbosity, quiet on the console only, daily rotation across midnight with an injected clock, retention deleting only base-YYYY-MM-DD+ext files, 0640 files, an unwritable directory, path confinement, redaction on every driver, control-character stripping, loggers created before Setup, Reset, HideOnTTY and concurrent writes under -race; the console handler and the JSON pipeline pass testing/slogtest."
|
||||
- "D-13..D-18: bonfire golden files pin the list (plain and --all), the JSON listing, per-command help (value, bare and repeatable flags with defaults, examples, long help, passthrough) and the error blocks (hint with {bin}, unknown command with suggestion, -v chain), rendered with the color profile forced off plus one styled list golden; exit codes 0, 1 and 2, Reported silence, duplicate and reserved-flag errors, PeekGlobals and the cobra flags agree, and Call never runs OnGlobals."
|
||||
- "D-03, D-27, D-28: a widget matrix test covers Spinner, Progress, Process, Table and every prompt across terminal, not a terminal, -q, --no-ansi and -n; the live models are tested through Update and View without a terminal; ConfirmProceed never reads input off a terminal; Secret with -n reads piped stdin and fails with a hint otherwise."
|
||||
- "D-19, D-21, D-23, D-25, D-29: tests cover the wrong-directory hint, migrate and rollback --pretend and the production confirmation rule (non-TTY proceeds), --json outputs (migrate:status, route:list, admin tokens, key:generate) and -q data, the request log's route pattern, request_id cap and default, Worker.Stats, PendingMigrations, the serve summary and shutdown report builders, summer dev starting serve with forwarded flags, and graceful shutdown returning nil."
|
||||
- "D-17: the gate's plugins stage runs each shared plugin's warn-only TestCommandsDocumented and an additive check (no exported func, method or type and no command Name or flag Name literal removed since the commit before its sweep commit); the apps stage builds each application and boots bin/<app> list."
|
||||
- "A bug found by these tests in code that is already in the plan 03 release is fixed in its own commit and reaches the applications only after a recorded go-ahead (patch tag, plugin requires, pointer bumps)."
|
||||
artifacts:
|
||||
- path: "scripts/check-phase19.8.sh"
|
||||
provides: "fail-closed multi-repository phase gate with --self-test"
|
||||
contains: "--self-test"
|
||||
- path: "modules/diary/slogtest_test.go"
|
||||
provides: "testing/slogtest conformance for the console handler and the pipeline"
|
||||
contains: "slogtest.TestHandler"
|
||||
- path: "modules/bonfire/golden_test.go"
|
||||
provides: "golden tests with an -update flag"
|
||||
contains: "flag.Bool(\"update\""
|
||||
- path: "modules/bonfire/widgets_test.go"
|
||||
provides: "widget degradation matrix"
|
||||
contains: "TestWidgetMatrix"
|
||||
key_links:
|
||||
- from: "scripts/check-phase19.8.sh"
|
||||
to: "modules/diary/slogtest_test.go"
|
||||
via: "required test names in the go stage"
|
||||
pattern: "TestSlogtest"
|
||||
- from: "modules/bonfire/golden_test.go"
|
||||
to: "modules/bonfire/testdata/list.golden"
|
||||
via: "assertGolden compares rendered output"
|
||||
pattern: "testdata"
|
||||
prohibitions:
|
||||
- statement: "No test is skipped, deleted or weakened to make the gate pass; a failing behaviour is fixed in code or reported."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "Golden files and test fixtures contain no real token, key, password or DSN credential."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "Nothing is pushed or tagged without the Task 5 reply."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
---
|
||||
|
||||
## Phase Goal
|
||||
|
||||
ROADMAP Goal (not in user-story format; no story is invented, see plan 01): console commands reach the Artisan bar; a user sees what a command did, can ask for more or less of it, and learns from `--help` how to use it.
|
||||
|
||||
This plan's slice (plan 4 of 4, wave 4; project rule: unit tests are the last plan of every phase): a fail-closed phase gate first (the tracer), then unit and golden tests for diary, bonfire and every framework change of plans 1-3, wired into the gate together with the plugin, application, landing and Docker stages. The shared plugins already carry their tests in their single sweep commits (D-20 allows one commit per plugin repository), so this plan runs them from the gate rather than adding commits there. A conditional go-ahead covers fixes to code that plan 3 already released.
|
||||
|
||||
## Spec-less probe fallback
|
||||
|
||||
Spec-less probe fallback skipped: no requirement IDs (phase_req_ids null) and no SPEC.md; visible choice. Tests trace to SC-1..SC-5 and the D-IDs in `requirements`.
|
||||
|
||||
## Decisions taken in this plan (recorded assumptions and Claude's discretion)
|
||||
|
||||
- Goldens render with the color profile forced to NoTTY (no escape codes) through the injectable console constructor, plus one styled golden (`list_styled.golden`, ANSI256) so a palette change is visible in review. The `-update` flag and `assertGolden` helper copy internal/build/new_golden_test.go.
|
||||
- The console handler is checked with testing/slogtest through a small parser of its `key=value` attribute output (dotted group keys become nested maps); the full pipeline (component handler, sunscreen, stack) is checked through its JSON output.
|
||||
- No teatest dependency: models are tested by calling Update and View; one end-to-end run uses `tea.WithInput(nil)`, a buffer output and a fixed window size (RESEARCH Testing techniques).
|
||||
- The plugin additive check finds each plugin's sweep commit by its subject ("console help, examples and named loggers (SummerCMS v") and compares against its parent, so no base hash has to be typed; sm-user-plugin is skipped with a NOT SWEPT line when plan 3 deferred it.
|
||||
- Post-release fixes follow the Phase 19.5 precedent: Task 5 is presented only when Tasks 2-4 changed non-test code that is in the plan 3 tag.
|
||||
|
||||
## Artifacts this phase produces (this plan)
|
||||
|
||||
- `scripts/check-phase19.8.sh` with stages `self-test`, `go`, `hello`, `docs`, `plugins`, `apps`, `landing`, `docker` and `--all`; environment overrides `PHASE198_ROOT`, `PHASE198_REQUIRE_DOCKER`, `PLUGINS_ROOT`, `FONOTEKA`, `DEMO_APP`, `BM_APP`, `GRZYBY_APP`, `SUMMERCMSIO_APP`.
|
||||
- Test files (names under each task): diary `diary_test.go`, `config_test.go`, `daily_test.go`, `console_test.go`, `slogtest_test.go`; bonfire `globals_test.go`, `root_test.go`, `golden_test.go`, `errors_test.go`, `docs_test.go`, `widgets_test.go`, `live_test.go`, `version_test.go` and goldens under `modules/bonfire/testdata/`; docsite `check_commands_test.go`; surf `summary_test.go`; conga `stats_test.go`, `logger_test.go`; lagoon `pending_test.go`, `commands_flags_test.go`, `logger_test.go`; cabana `token_json_test.go`; flare `commands_test.go`; internal/dev `watch_test.go`; cmd/summer `runtime_test.go`.
|
||||
|
||||
<objective>
|
||||
Prove the phase: a gate that cannot pass on skipped or missing tests, unit and golden tests for every new behaviour in diary, bonfire and the swept framework commands, the shared plugins' and applications' checks run from one script, and a controlled path for fixing anything already released.
|
||||
|
||||
Purpose: SC-5 (unit tests in the last plan) and the D-IDs listed in requirements.
|
||||
Output: the gate script, test commits per module, a green `--all` run recorded in the SUMMARY, and, only if needed, the post-release go-ahead and its publication.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/msd-core/workflows/execute-plan.md
|
||||
@~/.claude/msd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/phases/19.8-console-commands-overhaul/19.8-CONTEXT.md
|
||||
@.planning/phases/19.8-console-commands-overhaul/19.8-RESEARCH.md
|
||||
@.planning/phases/19.8-console-commands-overhaul/19.8-01-SUMMARY.md
|
||||
@.planning/phases/19.8-console-commands-overhaul/19.8-02-SUMMARY.md
|
||||
@.planning/phases/19.8-console-commands-overhaul/19.8-03-SUMMARY.md
|
||||
|
||||
Repos and concurrency: tests and the gate are written in summercms.go only; the gate reads the sibling repositories and builds the applications without committing there (a build that rewrites an application's main.go must leave no diff, because plan 3 committed the regenerated main.go; if it does, report). Check `git status --porcelain` on each task's paths first. Stage by explicit path; no co-author tags; test commits separate from any fix commit; `go vet ./...`, `go test -short ./...` and `go -C examples/hello test ./...` green at every commit. Docker tests run on a Docker host, through `sudo -u golem` if the socket is denied; a Docker test that could not run is NOT RUN in the SUMMARY.
|
||||
|
||||
<interfaces>
|
||||
- Gate pattern: scripts/check-phase19.4.sh and scripts/check-phase19.5.sh (stage functions, required-name arrays run with `-run '^(a|b)$'`, go test -json parsing that fails on fail, skip, zero tests or a missing name, `--self-test` with planted output, `unset FORCE_COLOR`, `export NO_COLOR=1`, app names split as `"fono""teka.go"` so the docs checker never sees them whole).
|
||||
- Golden helper to copy: internal/build/new_golden_test.go:13 (`var update = flag.Bool("update", false, ...)`) and :76-96 (`assertGolden`, `firstDiff`).
|
||||
- Console test seam: modules/bonfire/output.go `newConsole(in, out, errW, lookup, tty, interactive *bool)` (plan 02 adds quiet, noInteraction and color mode fields); bonfire `DetectProfile`, `Globals`, `PeekGlobals`, `Execute`, `RenderError`, `Describe`, `CheckDocs`, `ConfirmProceed`, `Process`, `PrintAbove`, `BuildVersion` (plan 02 SUMMARY lists the final names).
|
||||
- diary: `Setup`, `Bootstrap`, `For`, `SetVerbosity`, `Verbosity`, `Reset`, `Close`, `ParseConfig`, `DefaultConfig`, `NewConsoleHandler`, `HideOnTTY`, `Options.Now` (plan 01 and 03 SUMMARYs).
|
||||
- Smoke tests already present (keep them; the gate requires them): TestDiaryTracerSmoke, TestConfigEnvSmoke, TestDailyDegradesSmoke, TestRiverLoggerUsesDiarySmoke, TestGormLoggerUsesDiarySmoke, TestListTracerSmoke, TestHelpSmoke, TestListJSONSmoke, TestErrorRenderSmoke, TestWidgetMatrixSmoke, TestServeSummarySmoke, TestShutdownReportSmoke, TestServeLifecycle (Docker), TestCommandsDocumented, TestGreeterDocumented (examples/hello), TestBuiltBinaryListsCommands (examples/hello).
|
||||
- Plugin repositories and application paths: plan 03 frontmatter and SUMMARY (which plugins were swept, the tag, which applications were bumped).
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tracer">
|
||||
<name>Task 1: Tracer: scripts/check-phase19.8.sh runs the go stage with plans 1-3's smoke tests by name and fails when one is missing, skipped or red, proven by --self-test</name>
|
||||
<files>scripts/check-phase19.8.sh</files>
|
||||
<read_first>scripts/check-phase19.5.sh (whole file: structure, detectors, self-test), scripts/check-phase19.4.sh (stages, NOT RUN rules, additive check), 19.8-01-SUMMARY.md, 19.8-02-SUMMARY.md, 19.8-03-SUMMARY.md (final test names), 19.8-RESEARCH.md (Validation Architecture)</read_first>
|
||||
<action>Create scripts/check-phase19.8.sh (bash, `set -euo pipefail`, `unset FORCE_COLOR`, `export NO_COLOR=1`) in the 19.5 gate's structure with a header comment describing every stage. Roots: `ROOT` from PHASE198_ROOT or the script's parent; `PLUGINS_ROOT` default `$ROOT/..`; `FONOTEKA` default `$ROOT/../fono""teka.go`; application defaults next to ROOT. Stages in this task: `self-test` (planted go test -json streams for a pass, a fail, a skip, a subtest-only pass, zero tests and "no tests to run" must be classified correctly, and a missing required name must fail) and `go` (`go vet ./...`, `go test -count=1 -short ./...`, then required names per package with `go test -count=1 -short -json -run '^(...)$'`: diary TestDiaryTracerSmoke TestConfigEnvSmoke TestDailyDegradesSmoke; bonfire TestListTracerSmoke TestHelpSmoke TestListJSONSmoke TestErrorRenderSmoke TestWidgetMatrixSmoke; conga TestRiverLoggerUsesDiarySmoke; lagoon TestGormLoggerUsesDiarySmoke; surf TestServeSummarySmoke TestShutdownReportSmoke TestAccessLogSmoke; cmd/summer TestCommandsDocumented TestDocsTree; then `go test -count=1 -race -short ./modules/diary/... ./modules/bonfire/...`). Stubs that print "stage <name>: not implemented" and exit 1 for hello, docs, plugins, apps, landing and docker until Task 4 (so `--all` cannot pass early). `--all` runs self-test, go, hello, docs, plugins, apps, landing, then docker (NOT RUN docker when `docker info` fails, a failure with PHASE198_REQUIRE_DOCKER=1). Make it executable (git mode 100755). Commit: `test(19.8): phase gate with the smoke tests by name`.</action>
|
||||
<verify>
|
||||
<automated>bash scripts/check-phase19.8.sh --self-test && bash scripts/check-phase19.8.sh go</automated>
|
||||
<fails_when>Non-zero exit: a detector misclassifies planted output in --self-test, or a required smoke test is missing, skipped or failing, or vet, the short suite or the race run fail.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `test -x scripts/check-phase19.8.sh` exits 0 and `M=$(git ls-files -s scripts/check-phase19.8.sh) && test "${M:0:6}" = 100755` exits 0.
|
||||
- `grep -c 'NO_COLOR=1' scripts/check-phase19.8.sh` prints at least 1 and `grep -cE '"?fono""teka' scripts/check-phase19.8.sh` prints at least 1.
|
||||
- `bash scripts/check-phase19.8.sh --all; test $? -ne 0` exits 0 at this point (the unimplemented stages keep --all red).
|
||||
- `grep -cE 'TestDiaryTracerSmoke|TestListTracerSmoke|TestServeSummarySmoke' scripts/check-phase19.8.sh` prints at least 3.
|
||||
</acceptance_criteria>
|
||||
<done>One command proves the phase's smoke tests ran and passed by name, and the gate cannot be fooled by a skip, a typo or an empty run.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 2: diary, sunscreen and the River and GORM bridges have unit tests: config rules, env strings, floors, verbosity, quiet, daily rotation and retention, redaction on every driver, slogtest conformance, concurrency</name>
|
||||
<files>modules/diary/diary_test.go, modules/diary/config_test.go, modules/diary/daily_test.go, modules/diary/console_test.go, modules/diary/slogtest_test.go, modules/sunscreen/sunscreen_test.go, modules/conga/logger_test.go, modules/lagoon/logger_test.go, scripts/check-phase19.8.sh</files>
|
||||
<behavior>
|
||||
- TestConfigRules (table): every invalid driver, format, level, days, stack child, stack cycle and escaping path returns an error naming its exact key; valid YAML lists and comma strings parse alike.
|
||||
- TestConfigEnvStrings: SUMMER_LOG__ overrides as strings overlay defaults key by key; TestDefaultConfig equals the D-08 example with components river warn.
|
||||
- TestComponentFloors and TestVerbosityLevels: components[name] beats log.level; verbosity 1 lets INFO through for a warn component, 2 lets DEBUG through, 3 also sets AddSource; TestQuietConsoleOnly: quiet raises the console to WARN while a daily INFO record is still written.
|
||||
- TestDailyRotation (clock 23:59:59 to 00:00:01 gives two files), TestDailyRetention (days 2 deletes only matching names older than 2 days, keeps other files), TestDailyPermissions (0640 file, 0750 dir), TestDailyUnwritable (one warning, later writes succeed silently), TestDailyLazyOpen (no file without a record), TestPathConfinement (relative escape rejected, absolute accepted).
|
||||
- TestRedactionEveryDriver: console, stderr, daily and stack outputs show [REDACTED] for password, token and api_key attributes and a scrubbed Bearer string in the message; null writes nothing.
|
||||
- TestConsoleStripsControlChars, TestConsoleComponentPrefix (fixed width), TestConsoleStyledOnlyOnTerminal (NoTTY profile has no escape codes, ANSI256 has them).
|
||||
- TestForBeforeSetup, TestResetRestoresDefault, TestNoRecursionWithoutState, TestHideOnTTY (skipped by the styled console only), TestConcurrentWrites (100 goroutines, -race, every line intact).
|
||||
- TestSlogtestConsoleHandler and TestSlogtestPipeline pass testing/slogtest.TestHandler.
|
||||
- TestInstallDefaultUnchanged (sunscreen): InstallDefault still installs a redacting TextHandler on the given writer.
|
||||
- TestRiverLoggerComponent and TestRiverLoggerPublishedOverride (conga); TestGormLoggerLevels (lagoon): Warn by default, Info at verbosity 3, record-not-found ignored, parameterized SQL in the trace.
|
||||
</behavior>
|
||||
<read_first>modules/diary/*.go (all, from plans 01-03), modules/diary/tracer_smoke_test.go, modules/diary/config_smoke_test.go, modules/sunscreen/sunscreen_test.go, modules/conga/logger_smoke_test.go, modules/lagoon/logger_smoke_test.go, scripts/check-phase19.8.sh, Go source of testing/slogtest (TestHandler and its results contract), 19.8-RESEARCH.md (Pitfalls 4-8, Security Domain)</read_first>
|
||||
<action>Write the tests in the behavior list (red first against any behaviour that turns out wrong; fix the code in a separate `fix(diary): ...` commit when a test exposes a bug, and record it in the SUMMARY's "Post-release fixes" table when the code is already in the plan 03 tag). The slogtest console parser reads one line per record: the fields after the message as key=value pairs, dotted keys nested into maps, the time and level keys mapped to slog.TimeKey and slog.LevelKey, the message to slog.MessageKey. Concurrency and time come from Options.Now and goroutines with a WaitGroup; no sleeps as synchronisation. Add every new test name to the gate's go stage required lists (diary, sunscreen, conga, lagoon). Commits: `test(diary): config, channels, rotation, redaction and slogtest` and `test(conga,lagoon): River and GORM bridges`.</action>
|
||||
<verify>
|
||||
<automated>go test -count=1 -short -race ./modules/diary/... ./modules/sunscreen/... && go test -count=1 -short -run '^(TestSlogtestConsoleHandler|TestSlogtestPipeline|TestRedactionEveryDriver|TestDailyRotation|TestDailyRetention|TestConfigRules)$' -v ./modules/diary 2>&1 | grep -c -- '^--- PASS: Test' | grep -qx 6 && go test -count=1 -short ./modules/conga/... ./modules/lagoon/... && bash scripts/check-phase19.8.sh go</automated>
|
||||
<fails_when>Non-zero exit (including a race report), or the count of top-level "--- PASS: Test" lines is not 6 (slogtest, redaction, rotation, retention or the config table failed or did not run), or the gate's go stage misses a newly required name.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -c 'slogtest.TestHandler' modules/diary/slogtest_test.go` prints at least 2.
|
||||
- `grep -cE 'func Test(ConfigRules|ConfigEnvStrings|ComponentFloors|VerbosityLevels|QuietConsoleOnly|DailyRotation|DailyRetention|DailyPermissions|DailyUnwritable|DailyLazyOpen|PathConfinement|RedactionEveryDriver|ConsoleStripsControlChars|ForBeforeSetup|ResetRestoresDefault|HideOnTTY|ConcurrentWrites)\(' modules/diary/*_test.go | awk -F: '{s+=$2} END {print s}'` prints 17.
|
||||
- `grep -cE 'TestSlogtestConsoleHandler|TestRedactionEveryDriver|TestGormLoggerLevels' scripts/check-phase19.8.sh` prints at least 3.
|
||||
- `grep -rnE 'time\.Sleep' modules/diary/*_test.go | wc -l` prints 0.
|
||||
</acceptance_criteria>
|
||||
<done>Every logging rule of D-08, D-09, D-10 and D-12 is pinned by a test that runs in the gate, including slogtest conformance and race-free concurrent writes.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 3: bonfire has golden and unit tests for list, help, the JSON listing, error blocks, exit codes, global flags, duplicates, the widget matrix, the live models and prompts; the docs checker knows the global flags</name>
|
||||
<files>modules/bonfire/globals_test.go, modules/bonfire/root_test.go, modules/bonfire/golden_test.go, modules/bonfire/errors_test.go, modules/bonfire/docs_test.go, modules/bonfire/widgets_test.go, modules/bonfire/live_test.go, modules/bonfire/version_test.go, modules/bonfire/testdata/list.golden, modules/bonfire/testdata/list_all.golden, modules/bonfire/testdata/list.json.golden, modules/bonfire/testdata/help_token_create.golden, modules/bonfire/testdata/help_passthrough.golden, modules/bonfire/testdata/error_hint.golden, modules/bonfire/testdata/error_unknown_command.golden, modules/bonfire/testdata/error_chain_verbose.golden, modules/bonfire/testdata/list_styled.golden, internal/docsite/check_commands_test.go, scripts/check-phase19.8.sh</files>
|
||||
<behavior>
|
||||
- TestPeekGlobals (table): -v, -vv, -vvv, --verbose twice, -q implies -n, --ansi then --no-ansi (last wins), --env X and --env=X, stop at --, unknown flags ignored, never an error; TestGlobalsArgs round-trips; TestGlobalFlagsAgree: the OnGlobals hook sees the same Globals as PeekGlobals for each table row.
|
||||
- TestReservedFlags (each reserved name and shorthand returns an error naming command and flag, no panic), TestDuplicateNames (two commands, and a command named list or help), TestCallNoGlobalsHook.
|
||||
- TestListGolden, TestListAllGolden, TestListJSONGolden, TestListStyledGolden; TestHelpGolden (admin:token:create shape from D-14 with value, bare and repeatable flags, defaults "*" and 90, long help, two examples) and TestHelpPassthroughGolden; TestHelpEqualsHelpFlag.
|
||||
- TestErrorGolden (hint with {bin} as bin/acme, unknown command with suggestion, chain at -v), TestExitCodes (0 success, 1 failure, 2 unknown command, unknown flag and wrong argument count), TestReportedSilent, TestErrorScrubbed (a Bearer token and a password attribute in the message), TestWrongDirectoryHint (temp tree: app root with summer.yaml, run from plugins/ without config/ gives `cd ..; bin/acme migrate`; summer gets no such hint).
|
||||
- TestCheckDocs: each missing item is reported, results sorted, a complete command reports nothing.
|
||||
- TestVersionHeader: tagged dep, replaced dep (local), go.work devel and the summer tool's own build (through an injectable build-info reader).
|
||||
- TestWidgetMatrix: Spinner, Progress, Process, Table, Info, Success, Warning, Error, Println across terminal, not a terminal, -q, --no-ansi and -n; not-a-terminal output byte-identical to the pre-phase format.
|
||||
- TestLiveModels: spinner, progress (Current and Fail), process (pending, running, done, failed rows) and selector (move, enter, confirmation screen, esc back, ctrl+c abort) through Update and View; one end-to-end program run with tea.WithInput(nil) into a buffer.
|
||||
- TestPromptsNoInteraction (defaults without reading stdin), TestSecretWithNoInteraction (piped stdin read; terminal-like stdin returns a HintError), TestConfirmProceed (non-interactive returns true without reading; interactive asks), TestPrintAbove (false without a program; a running program receives the line).
|
||||
- TestCommandWordGlobals (docsite): `bin/acme -v serve`, `summer -q migrate`, `bin/acme --env staging migrate`, `bin/acme --env=staging migrate`, `summer list`, `bin/acme help migrate` resolve to the right command word.
|
||||
</behavior>
|
||||
<read_first>modules/bonfire/*.go (all, from plans 01-03), modules/bonfire/*_smoke_test.go, modules/bonfire/output_test.go, modules/bonfire/prompts_test.go, internal/build/new_golden_test.go (:13, :76-96), internal/docsite/check_commands.go, existing internal/docsite tests for commandWord, scripts/check-phase19.8.sh, 19.8-CONTEXT.md (D-13, D-14 and D-18 previews)</read_first>
|
||||
<action>Write the tests in the behavior list. golden_test.go copies the `update` flag and `assertGolden` (path `testdata/<name>.golden`, first-difference message, "run go test -run <Test> -update to create it"). Goldens use a fixed Version (`SummerCMS acme v1.4.0 (framework v0.3.2)`) and the NoTTY profile except list_styled.golden (ANSI256). Create the goldens with `-update`, then read each one and confirm it matches the D-13, D-14 and D-18 previews in substance (sections, order, alignment); record any intentional difference in the SUMMARY. A behaviour that a test shows to be wrong is fixed in a separate `fix(bonfire): ...` commit (Post-release fixes table when already tagged). Add every new test name to the gate's go stage (bonfire and docsite lists). Commits: `test(bonfire): goldens for list, help and errors`, `test(bonfire): flags, exit codes and the widget matrix`, `test(docsite): command word with global flags`.</action>
|
||||
<verify>
|
||||
<automated>go test -count=1 -short -race ./modules/bonfire/... ./internal/docsite/... && go test -count=1 -short -run '^(TestListGolden|TestHelpGolden|TestErrorGolden|TestExitCodes|TestWidgetMatrix|TestLiveModels|TestPeekGlobals|TestWrongDirectoryHint)$' -v ./modules/bonfire 2>&1 | grep -c -- '^--- PASS: Test' | grep -qx 8 && go test -count=1 -short -run '^TestCommandWordGlobals$' -v ./internal/docsite 2>&1 | grep -q -- '^--- PASS: TestCommandWordGlobals' && bash scripts/check-phase19.8.sh go</automated>
|
||||
<fails_when>Non-zero exit (including a race report or a golden mismatch), the count of top-level "--- PASS: Test" lines is not 8, no "--- PASS: TestCommandWordGlobals", or the gate's go stage misses a newly required name.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `ls modules/bonfire/testdata/*.golden | wc -l` prints 9.
|
||||
- `grep -c 'flag.Bool("update"' modules/bonfire/golden_test.go` prints 1.
|
||||
- `grep -c 'Available commands:' modules/bonfire/testdata/list.golden` prints 1, `grep -c 'Examples:' modules/bonfire/testdata/help_token_create.golden` prints 1 and `grep -c 'Try:' modules/bonfire/testdata/error_hint.golden` prints 1.
|
||||
- `grep -c $'\x1b\\[' modules/bonfire/testdata/list.golden` prints 0 and `grep -c $'\x1b\\[' modules/bonfire/testdata/list_styled.golden` prints at least 1.
|
||||
- `grep -rnEi 'eyJ[a-zA-Z0-9_-]{10,}|sk-[A-Za-z0-9]{20,}|BEGIN (RSA |EC )?PRIVATE KEY' modules/bonfire/testdata | wc -l` prints 0.
|
||||
</acceptance_criteria>
|
||||
<done>The approved list, help and error layouts are pinned by goldens, and every flag, exit-code, widget and prompt rule of D-03, D-13..D-18, D-27 and D-28 is a test in the gate.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 4: The swept framework commands have unit tests (request log, summaries, reports, stats, pending migrations, --pretend, confirmation, --json, -q data, Reported, dev runs serve), the gate's remaining stages run, and --all is green</name>
|
||||
<files>modules/surf/summary_test.go, modules/surf/accesslog_test.go, modules/conga/stats_test.go, modules/lagoon/pending_test.go, modules/lagoon/commands_flags_test.go, modules/cabana/token_json_test.go, modules/flare/commands_test.go, internal/dev/watch_test.go, cmd/summer/runtime_test.go, scripts/check-phase19.8.sh</files>
|
||||
<behavior>
|
||||
- TestServeSummaryLines (verbosity 0 block, verbosity 1 per-plugin lines, quiet prints no block, DSN user and password never present, keyword-form DSN and unparsable DSN), TestServeRecords (one "startup summary" record with every fact, one record whose message equals "listening on <addr>", HideOnTTY used only when the block printed), TestShutdownReportAborted (in-flight requests past the deadline counted as aborted, drain error makes Run return it), TestServeGracefulNil.
|
||||
- TestAccessLogRequestIDCap (300-byte X-Request-Id stored as 128 bytes, control characters trimmed), TestAccessLogDefaultOn, TestAccessLogDisabled (http.access_log false), TestAccessLogNeverLogsPath (a token-bearing path and query never appear for matched, 404 and 405 routes), TestAccessLogComponentHTTP.
|
||||
- TestWorkerStats (done, failed and in-flight counters through runAttempt with fake job functions).
|
||||
- TestPendingMigrations (Docker: none applied, some applied, missing history table) and TestMigratePretend (Docker: lists pending IDs, applies nothing), TestRollbackPretend, TestDeferredPurgePretend (count only, no rows deleted).
|
||||
- TestConfirmProductionNonTTYProceeds (production, non-interactive console: migrate, migrate:rollback, queue:clear, deferred:purge run without reading stdin), TestConfirmProductionInteractiveNo (interactive console answering no returns a Reported cancel), TestForceSkipsConfirm.
|
||||
- TestKeyGenerateJSON and TestKeyGenerateQuiet (bare key under -q), TestMigrateStatusJSON, TestRouteListJSON, TestTokenCreateJSON and TestTokenListJSON (cabana, Docker or a fake store as the package's tests allow), TestTokenCreateQuietKeepsToken.
|
||||
- TestFlareReported (generate and test-push failures return errors matching bonfire.ErrReported and print one message).
|
||||
- TestStartBinServe (dev passes "serve" then the forwarded globals), TestDevReport (rebuild, failure and restart counts), TestDevGracefulNil.
|
||||
- TestDelegateExamples (every delegate has examples rendered with summer, and Long mentions that global flags pass through).
|
||||
</behavior>
|
||||
<read_first>modules/surf/summary.go, modules/surf/serve.go, modules/surf/accesslog.go, modules/surf/accesslog_test.go, modules/surf/serve_summary_smoke_test.go, modules/surf/serve_lifecycle_test.go, modules/conga/worker.go, modules/conga/commands.go, modules/lagoon/migrations.go, modules/lagoon/commands.go, modules/lagoon/keygen.go, modules/lagoon/postgres_test.go, modules/cabana/token_commands.go, modules/cabana/commands_test.go, modules/flare/commands.go, internal/dev/watch.go, internal/dev/watch_test.go, cmd/summer/runtime.go, cmd/summer/runtime_test.go, scripts/check-phase19.8.sh, scripts/check-phase19.4.sh (additive check and NOT RUN rules), 19.8-03-SUMMARY.md (tag, swept plugins, bumped applications, landing and deck commits)</read_first>
|
||||
<action>Write the tests in the behavior list (Docker ones skip under -short through the packages' TestMain that calls flag.Parse and testing.Short). Fix exposed bugs in separate `fix(<module>): ...` commits and list released ones in the SUMMARY's Post-release fixes table.
|
||||
|
||||
Then implement the gate's remaining stages in scripts/check-phase19.8.sh. `hello`: `go -C examples/hello vet ./...`, `go -C examples/hello test -count=1 ./...` and required names TestBuiltBinaryListsCommands, TestGreeterDocumented, TestHelpListsServe, TestBuiltBinaryUnknownCommandExitsNonZero. `docs`: the decision note names all six modules; TestDocsTree; `go run ./cmd/summer docs:build --check`; no file under modules/diary, modules/bonfire, docs/console or docs/services/logging.md names a consuming application (names built from split strings); every listed command in `go run ./cmd/summer list --format=json` has examples. `plugins`: for each swept repository from 19.8-03-SUMMARY.md (default list: the 12 D-20 repositories, sm-golem-plugin and sm-feedback-plugin under FONOTEKA, sm-user-plugin under FONOTEKA unless plan 3 deferred it, printed as NOT SWEPT): `go vet ./...`, `go test -count=1 -short ./...`, its `TestCommandsDocumented` passed by name (warn-only, D-17), `GOWORK=off go build ./...`, and the additive check: find the sweep commit by its subject, and in `git diff <sweep>^ <sweep> -- '*.go' ':!*_test.go'` no removed line declares an exported func, method or type and no removed `Name: "<x>"` literal in console files is missing from the added lines. `apps`: for each application, `go build ./...`, the generated main.go has no diff after `summer build` in a temporary copy (or `git diff --exit-code main.go` after build), `bin/<app> list` and `bin/<app> help migrate` exit 0. `landing`: `npm --prefix $SUMMERCMSIO_APP/vue-summercmsio-app run test`, the Go terminal tests of sm-summercmsio-app, sm-demo-app's roadmap tests and `scripts/sync-roadmap.sh --check`. `docker`: TestServeLifecycle, TestPendingMigrations, TestMigratePretend and the cabana JSON tests by name without -short; NOT RUN rules as in the 19.4 gate. Add every new name from this task to the go and docker lists, extend --self-test with one planted case per new detector (additive-check removal, NOT SWEPT handling).
|
||||
|
||||
Run `bash scripts/check-phase19.8.sh --all` on a Docker host until green and paste its stage summary into the SUMMARY. Post a short comment on the Phase 19.8 milestone task in g15office (ID recorded in .planning/G15OFFICE.md by plan 03) that the phase is executed and waiting on verification; if unavailable, record that. Commits: `test(surf,conga,lagoon): summaries, reports, stats and checklist flags`, `test(cabana,flare,dev,summer): json, reported errors and dev serve`, `test(19.8): gate stages for hello, docs, plugins, apps, landing and docker`.</action>
|
||||
<verify>
|
||||
<automated>go vet ./... && go test -count=1 -short ./... && bash scripts/check-phase19.8.sh --self-test && LOG="${TMPDIR:-/tmp}/check-phase19.8.log" && bash scripts/check-phase19.8.sh --all >"$LOG" 2>&1 && tail -20 "$LOG" && ! grep -qE '^(FAIL|stage [a-z]+: not implemented)' "$LOG"</automated>
|
||||
<fails_when>Non-zero exit from vet, the short suite, the self-test or --all (any stage red, docker red with PHASE198_REQUIRE_DOCKER=1), or the log still contains a FAIL line or a "not implemented" stage.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -c 'not implemented' scripts/check-phase19.8.sh` prints 0.
|
||||
- `grep -cE 'TestAccessLogNeverLogsPath|TestConfirmProductionNonTTYProceeds|TestStartBinServe|TestWorkerStats|TestPendingMigrations' scripts/check-phase19.8.sh` prints at least 5.
|
||||
- `grep -c 'GOWORK=off go' scripts/check-phase19.8.sh` prints at least 1 and `grep -c 'NOT RUN' scripts/check-phase19.8.sh` prints at least 1.
|
||||
- The SUMMARY holds the --all stage summary with every stage PASS, or docker as NOT RUN with the reason.
|
||||
</acceptance_criteria>
|
||||
<done>Every swept framework behaviour has a test, and one green --all run proves the framework, the example application, the docs, every shared plugin, every application, the landing site and the Docker-backed paths together.</done>
|
||||
</task>
|
||||
|
||||
<task type="checkpoint:decision" gate="blocking">
|
||||
<name>Task 5: Go-ahead to release this plan's post-release fixes (presented only when the Post-release fixes table has a row)</name>
|
||||
<read_first>19.8-04-SUMMARY.md draft (Post-release fixes table), 19.8-03-SUMMARY.md (tag, plugin commits, application commits), CLAUDE.md (Commits and concurrency, changelog rule)</read_first>
|
||||
<acceptance_criteria>
|
||||
- When the table is empty, the SUMMARY says "no post-release fixes" and this checkpoint and Task 6 are skipped.
|
||||
- Otherwise the SUMMARY records the reply verbatim under a "Task 5 reply" heading, and before the reply `T=$(git tag --points-at HEAD) && test -z "$T"` exits 0.
|
||||
</acceptance_criteria>
|
||||
<decision>Release the listed fixes now, and with which patch tag?</decision>
|
||||
<context>Tasks 2-4 found bugs in code that is already in the plan 03 tag; each fix is committed separately on master and listed in the Post-release fixes table with the module, the symptom and the test that pins it. Releasing means a patch tag of the framework with a same-day changelog post; plugins only need a require bump when a fix changes an API they use (the table says so per row), and applications only need a regenerate commit when a fix changes the generated main. Nothing is deployed in any option.</context>
|
||||
<options>
|
||||
<option id="release">
|
||||
<name>release <tag> (for example "release v0.4.1")</name>
|
||||
<pros>Applications and plugins get the fixes pinned and reproducible; the changelog shows them.</pros>
|
||||
<cons>Another tag and changelog post the same day.</cons>
|
||||
</option>
|
||||
<option id="push-only">
|
||||
<name>push-only</name>
|
||||
<pros>Fixes are on origin/master for the next release; no tag today.</pros>
|
||||
<cons>Tagged consumers keep the plan 03 release until the next tag.</cons>
|
||||
</option>
|
||||
<option id="hold">
|
||||
<name>hold</name>
|
||||
<pros>Nothing leaves this machine.</pros>
|
||||
<cons>The fixes stay local; the verifier sees them as unpublished.</cons>
|
||||
</option>
|
||||
</options>
|
||||
<resume-signal>Reply "release vX.Y.Z", "push-only" or "hold". A release also authorises plugin require bumps and application regenerate commits named in the table, pushed when their unpushed commits are this phase's; nothing is ever force-pushed or deployed.</resume-signal>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 6: Under the Task 5 reply, publish the post-release fixes: patch tag and changelog post, then the plugin requires and application commits the table names</name>
|
||||
<precondition>19.8-04-SUMMARY.md (draft) records the Task 5 reply, or "no post-release fixes" (then this task records "nothing to publish" and stops).</precondition>
|
||||
<files>.planning/phases/19.8-console-commands-overhaul/19.8-04-SUMMARY.md</files>
|
||||
<read_first>19.8-04-SUMMARY.md draft (Post-release fixes table, Task 5 reply), 19.8-03-PLAN.md Task 7 (release steps to repeat), ../sm-summercmsio-app/DEPLOY.md ("Posting to the changelog")</read_first>
|
||||
<action>On "release vX.Y.Z": same push rules and steps as plan 03 Task 7 step (1) (fetch, origin..master holds only approved commits, vet and short tests green, annotated tag with the fixes listed, push master then the tag, changelog post with user-facing notes under ## Fixed, never print the token); then for each plugin row marked "needs require": `go get git.golem15.com/golem15/summercms@vX.Y.Z`, `go mod tidy`, vet and short tests, one commit `chore: require SummerCMS vX.Y.Z`, push; then for each application row marked "regenerate": regenerate main.go, build, `bin/<app> list`, one commit `build: regenerate main for SummerCMS vX.Y.Z`, push. On "push-only": push framework master only. On "hold": nothing. Record every tag, push range and the changelog post URL in the SUMMARY, then re-run `bash scripts/check-phase19.8.sh --all` and paste the summary.</action>
|
||||
<verify>
|
||||
<automated>LOG="${TMPDIR:-/tmp}/check-phase19.8-post.log" && bash scripts/check-phase19.8.sh --all >"$LOG" 2>&1 && tail -20 "$LOG" && ! grep -q '^FAIL' "$LOG"</automated>
|
||||
<fails_when>Non-zero exit from the gate, or a line starting with FAIL in its log (for example a plugin no longer builds at the patch tag). On "hold" or with no fixes the command is not run and the SUMMARY says nothing changed.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- On release: `git ls-remote --exit-code --tags origin vX.Y.Z` exits 0 and the SUMMARY holds the changelog post URL.
|
||||
- The SUMMARY lists each published repository with its pushed range, or "nothing to publish".
|
||||
</acceptance_criteria>
|
||||
<done>Any fix to released code reaches its consumers only with the user's go-ahead, and the gate is green afterwards.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| test fixtures and goldens to the repository | committed files are public with the next push |
|
||||
| the gate to sibling repositories | the gate builds applications and reads plugin history without committing there |
|
||||
| this machine to git.golem15.com and summercms.io | only post-release fixes, only after the Task 5 reply |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-19.8-21 | Repudiation | scripts/check-phase19.8.sh | medium | mitigate | fails on failed, skipped, zero or missing required tests and on "no tests to run"; --self-test proves each detector on planted output; unimplemented stages keep --all red |
|
||||
| T-19.8-22 | Information disclosure | goldens and fixtures | low | mitigate | goldens use fixed fake values; acceptance scans testdata for JWT, sk- keys and private key headers |
|
||||
| T-19.8-23 | Tampering | released code with bugs | medium | mitigate | fixes in separate commits, listed with their pinning test; published only under the Task 5 reply with a patch tag and changelog |
|
||||
| T-19.8-24 | Tampering | shared plugin contracts | medium | mitigate | the plugins stage's additive check fails on any removed exported declaration or command or flag name in a sweep commit |
|
||||
| T-19.8-25 | Information disclosure | request log regression | high | mitigate | TestAccessLogNeverLogsPath covers matched, 404 and 405 routes with token-bearing paths and queries, run in the go stage |
|
||||
| T-19.8-SC | Tampering | dependencies | low | accept | this plan adds no module; tests use the standard library, testing/slogtest and the already-required testcontainers |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- Task 1: --self-test and the go stage.
|
||||
- Task 2: diary, sunscreen, conga and lagoon unit tests with -race; slogtest.
|
||||
- Task 3: bonfire goldens and unit tests with -race; docsite command word.
|
||||
- Task 4: framework command tests; `--all` green across every stage (docker NOT RUN only without a daemon).
|
||||
- Tasks 5-6: only with post-release fixes; reply recorded; gate re-run.
|
||||
- Feedback sampling: after each test commit `go test -short` of the touched packages plus `bash scripts/check-phase19.8.sh go`; before /msd-verify-work, `bash scripts/check-phase19.8.sh --all`.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- The phase gate exists, is fail-closed and green with --all.
|
||||
- Unit and golden tests cover diary, bonfire and every swept framework behaviour (project rule: unit tests in the last plan).
|
||||
- Shared plugins' warn-only coverage tests and additive checks run from the gate; applications build and boot list.
|
||||
- Released code receives fixes only through the recorded go-ahead.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/19.8-console-commands-overhaul/19.8-04-SUMMARY.md` when done, with the test commits, any fix commits and the Post-release fixes table (module, symptom, pinning test, in tag yes or no, needs require, regenerate), the --all stage summary, Docker tests run or NOT RUN, the Task 5 reply (or "no post-release fixes"), anything published in Task 6, and the g15office comment.
|
||||
</output>
|
||||
224
.planning/phases/19.8-console-commands-overhaul/19.8-CONTEXT.md
Normal file
224
.planning/phases/19.8-console-commands-overhaul/19.8-CONTEXT.md
Normal file
@@ -0,0 +1,224 @@
|
||||
# Phase 19.8: Console commands overhaul - Context
|
||||
|
||||
**Gathered:** 2026-10-21
|
||||
**Status:** Ready for planning
|
||||
|
||||
<domain>
|
||||
## Phase Boundary
|
||||
|
||||
Console commands in `summer` and in application binaries reach the bar set by Laravel's Artisan. Three pieces: a new `diary` logging module, the shared console layer in `bonfire` (global flags, help, `list`, widgets, errors), and a sweep of every existing command's help and output across `summercms.go` and the shared plugin repos. Command logic, names and flags do not change. Planned shape stays at 4 plans: (1) `diary`, (2) `bonfire` flags/help/list/widgets, (3) sweep, (4) unit tests.
|
||||
|
||||
The phase was added as Phase 32 on 2026-10-20 and renumbered to 19.8 on 2026-10-21.
|
||||
|
||||
</domain>
|
||||
|
||||
<decisions>
|
||||
## Implementation Decisions
|
||||
|
||||
### Console stack and look
|
||||
- **D-01:** The console look follows SSU's terminal UX (`/media/nvme/dev/pxpx/ssu/internal/cli/tui`): spinner with counter and current item, multi-row live process view, progress model, arrow-key selector with confirmation screen, lipgloss status styles. The earlier Scala `summer-bonfire` design was itself modelled on SSU; the braille spinner and cyan-to-green gradient bar already in Go `bonfire` came from it. — **Reversibility:** costly — every command's output and the widget API depend on it
|
||||
- **D-02:** Adopt the charm stack in `bonfire`: `charmbracelet/lipgloss` (styling, tables), `charmbracelet/bubbles` + `bubbletea` (live views, spinner, progress), `charmbracelet/huh` (prompts), `charmbracelet/fang` (cobra styling and error rendering). A decision note naming these dependencies and versions lands before any code (project rule: new dependency needs a decision note). Port SSU's widgets rather than inventing new ones. The existing `bonfire.Output` interface stays source-compatible for plugins (no breaking changes in shared plugins). — **Reversibility:** costly — adds dependencies to every application binary; removing them later means rewriting the widgets
|
||||
- **D-03:** Every widget degrades on non-TTY, `-q`, `--no-ansi` and `-n`: no animation, plain lines, prompts return defaults (or fail with a hint when no default exists).
|
||||
|
||||
### What `serve` shows
|
||||
- **D-04:** At default verbosity `serve` prints a startup summary and then **all INFO logs**, restyled through `diary`'s console handler (consistent prefixes per component instead of today's raw `slog` text mixed with styled lines). `-q` reduces to warnings/errors; `-v`/`-vv`/`-vvv` add debug detail.
|
||||
- **D-05:** The output format follows the TTY: styled console output on a terminal; structured logs (JSON or logfmt per channel config) when stdout is not a TTY (systemd, Docker). The startup summary is also emitted as log records, so journald keeps it.
|
||||
- **D-06:** Startup summary is a compact aligned block of about 8–12 lines: app name and version (plus framework version), environment, URL(s), database and migration state ("up to date" / "3 pending"), plugins (count and names), queue and scheduler, mail, cache and realtime drivers. `-v` adds per-plugin details.
|
||||
- **D-07:** Shutdown (Ctrl+C / SIGTERM) prints a short report: draining, requests drained, workers stopped with job counts (done, in flight returned to the queue), uptime, and any drain errors. The same pattern applies to other long-running commands (`queue:work`, `dev`).
|
||||
|
||||
### Logging (`diary`)
|
||||
- **D-08:** `config/log.yaml` mirrors Laravel's `config/logging.php` shape: `log.default` names the default channel, `log.level` the global level, `log.channels` is a map of named channels with `driver` (`console`, `stderr`, `daily`, `stack`, `null`), `level`, `format`, `path`, `days`, and `channels` for a stack. `SUMMER_LOG_*` env overrides work through `compass` as for any key. This replaces the `log.level`/`log.channel` pair in the roadmap text with `log.level`/`log.default`. — **Reversibility:** costly — published config keys in every application's `config/log.yaml`
|
||||
```yaml
|
||||
# config/log.yaml
|
||||
default: stack
|
||||
level: info
|
||||
channels:
|
||||
stack:
|
||||
driver: stack
|
||||
channels: [console, daily]
|
||||
console:
|
||||
driver: console # styled on TTY, json/logfmt otherwise
|
||||
format: auto
|
||||
daily:
|
||||
driver: daily
|
||||
path: storage/logs/summer.log
|
||||
days: 14
|
||||
format: json
|
||||
level: warn
|
||||
components:
|
||||
river: warn
|
||||
surf: info
|
||||
```
|
||||
- **D-09:** Per-component levels through a `log.components` map (`river: warn`, `journal: debug`). Plugins and modules get a named logger from `diary` (for example `diary.For("journal")`) instead of `slog.Default`; verbosity flags raise every component.
|
||||
- **D-10:** Daily log files are **on everywhere** by default, like Laravel: `summer new` and the framework defaults use a `stack` of `console` + `daily` at `storage/logs/`, with retention (14 days default). Console output stays on as well.
|
||||
- **D-11:** The request log is INFO from `surf` under a named `http` component (method, path, status, duration, request id), so it shows by default and can be silenced with `components.http: warn`.
|
||||
- **D-12:** `sunscreen` redaction wraps every channel; `sunscreen.Install`/`InstallDefault` keep working as compatibility shims.
|
||||
|
||||
### Help and `list`
|
||||
- **D-13:** Bare `<binary>` / `summer` prints an Artisan-style list: version header (`SummerCMS acme v1.4.0 (framework v0.3.2)`), usage, global options, then commands grouped by namespace (top-level first, then `admin`, `make`, `migrate`…) with aligned descriptions. Styled with lipgloss in the same palette as the rest of the console.
|
||||
```
|
||||
SummerCMS acme v1.4.0 (framework v0.3.2)
|
||||
|
||||
Usage:
|
||||
bin/acme <command> [options] [arguments]
|
||||
|
||||
Options:
|
||||
-h, --help Display help for the given command
|
||||
-q, --quiet Do not output any message
|
||||
-v|vv|vvv Increase verbosity
|
||||
--ansi|no-ansi Force (or disable) ANSI output
|
||||
-n, --no-interaction Do not ask any interactive question
|
||||
--env The environment the command runs under
|
||||
|
||||
Available commands:
|
||||
list List commands
|
||||
migrate Run the database migrations
|
||||
serve Serve the application
|
||||
admin
|
||||
admin:create Create a backend administrator
|
||||
admin:token:create Create a personal API token
|
||||
make
|
||||
make:command Create a new console command
|
||||
make:model Create a new model and migration
|
||||
```
|
||||
- **D-14:** Per-command help uses the Artisan layout: Description / Usage / Arguments / Options (with defaults) / Examples. fang is used for styling and error rendering, not for its own help layout.
|
||||
```
|
||||
Description:
|
||||
Create a personal API token for a backend admin
|
||||
|
||||
Usage:
|
||||
admin:token:create <login> [options]
|
||||
|
||||
Arguments:
|
||||
login The admin's login
|
||||
|
||||
Options:
|
||||
--scopes=SCOPES Comma-separated scopes [default: "*"]
|
||||
--expires=DAYS Days until expiry [default: 90]
|
||||
--json Print the token as JSON
|
||||
|
||||
Examples:
|
||||
# Token for the MCP server, journal only
|
||||
bin/acme admin:token:create jane --scopes=journal.*
|
||||
|
||||
# Script-friendly output
|
||||
bin/acme admin:token:create jane --json
|
||||
```
|
||||
- **D-15:** New additive `bonfire.Command` fields: `Examples []Example` (command line + description, rendered as a section) and `Hidden bool` (runnable, left out of `list`; `list --all` shows it). Additive only, so existing plugins compile unchanged.
|
||||
- **D-16:** `list --format=json` outputs every command with arguments, options and examples, for docs checks and AI agents.
|
||||
- **D-17:** Documentation coverage check: `bonfire` exports a helper that reports commands lacking a description or examples. It **fails** for `summercms.go`'s own commands and **warns only** in plugin repos. Roadmap success criterion 3 is reworded to match.
|
||||
|
||||
### Errors
|
||||
- **D-18:** Errors render as a styled block: the cause, a short explanation, then a `Try:` hint with the exact command that fixes it, then "Run with -v for details". `-v` adds the wrapped error chain. Exit codes: 1 for failure, 2 for usage errors.
|
||||
```
|
||||
ERROR No application found in /home/jane/acme/plugins.
|
||||
|
||||
summer make:command needs an application root (summer.yaml).
|
||||
|
||||
Try:
|
||||
cd ..; bin/acme make:command acme.blog:publish
|
||||
|
||||
Run with -v for details.
|
||||
```
|
||||
- **D-19:** Hints for known failure modes are part of the sweep: run from the wrong directory (suggest `cd ..; bin/<app> <command>`), pending migrations (suggest `bin/<app> migrate`), missing config or env, database unreachable. Detecting the database when run from the wrong directory is **not** fixed; only the message improves.
|
||||
|
||||
### Sweep
|
||||
- **D-20:** The sweep covers `summercms.go` and every shared plugin repo with commands: `sm-paymentgateway-plugin`, `sm-oauthserver-plugin`, `sm-journal-plugin`, `sm-sitemap-plugin`, `sm-chat-plugin`, `sm-chatchannels-plugin`, `sm-chatvideo-plugin`, `sm-faq-plugin`, `sm-menu-plugin`, `sm-herald-plugin`, `sm-forum-plugin`, `sm-pgstripe-plugin`, and `sm-user-plugin`. Changes per plugin: description, long help, examples, named loggers, output polish. One commit per plugin repo, pushed first, then pointer bumps in the apps as separate commits. No breaking changes to command names or flags.
|
||||
- **D-21:** The Artisan checklist applies where it fits, not uniformly: `--force` confirmation on destructive commands, `--pretend` on migrate-like and bulk writers, `--json` on commands whose output scripts read (lists, tokens, status), progress for bulk loops. The plan records each command's checklist result so the gaps are explicit.
|
||||
- **D-22:** One-shot commands end with a result line and a meaningful exit code.
|
||||
|
||||
### Settled after research (2026-10-21)
|
||||
- **D-23:** Amends D-11. The `http` request log records the **route pattern** (e.g. `/share/{token}`), never the raw path, because share/invite tokens travel in paths. It is on by default at INFO (method, route pattern, status, duration, request id), and `components.http: warn` silences it. The `http.access_log` default flips accordingly.
|
||||
- **D-24:** The sweep also covers `sm-golem-plugin` and `sm-feedback-plugin`, in addition to the D-20 list.
|
||||
- **D-25:** `summer dev` starts the application binary with `serve` (today it runs the binary with no arguments, which prints help and exits). This is the one logic fix allowed in the sweep, together with the D-07 shutdown report for `dev`.
|
||||
- **D-26:** Release order: plan 3 reaches a human checkpoint before tagging and pushing the framework (plus the same-day changelog post). Plugin commits and pushes, then app pointer bumps, follow after the go-ahead. Nothing is pushed or tagged without it.
|
||||
- **D-27:** `-q` suppresses decoration and info, but still prints the data a command exists to produce (tokens, keys, `--json` output) and errors.
|
||||
- **D-28:** Production confirmation prompts appear only on an interactive TTY. Non-TTY runs (deploys over SSH) proceed as they do today and never block. Existing `--force` meanings (importers, `pg:update-currencies`: "replace existing data") are not changed; where `--force` is already taken, confirmation is skipped with `-n`.
|
||||
- **D-29:** A graceful Ctrl+C/SIGTERM shutdown of `serve`/`dev`/`queue:work` exits 0. A drain error exits 1.
|
||||
- **D-30:** SSU's multi-select selector is not ported in this phase (no command needs it). Port spinner, process view, progress, single selector and confirm.
|
||||
|
||||
### Claude's Discretion
|
||||
- Exact palette, symbols and spacing, as long as they match SSU's styles and stay consistent between `list`, help, widgets and log lines.
|
||||
- Console handler choice inside `diary` (lipgloss-styled handler of our own, or `charmbracelet/log`), to be settled in the dependency decision note.
|
||||
- Exact semantics of `--env` and `-n` defaults, following Artisan.
|
||||
|
||||
### Folded Todos
|
||||
- **Reject duplicate console command names in bonfire.NewRoot** (`.planning/todos/pending/bonfire-duplicate-command-names.md`): `NewRoot` fails with an error naming the duplicated command; update the bonfire README and `docs/console/writing-commands.md`. Fits plan 2.
|
||||
- **Scaffolded files carry a DO NOT EDIT header, and make:command cannot reach the application** (`.planning/todos/pending/scaffold-generated-header-and-command-deps.md`): folded **partially**. The scaffolder itself is not changed; the errors these commands throw get proper messages with actionable hints (D-18, D-19). The todo stays open for the scaffolder fix.
|
||||
|
||||
</decisions>
|
||||
|
||||
<canonical_refs>
|
||||
## Canonical References
|
||||
|
||||
**Downstream agents MUST read these before planning or implementing.**
|
||||
|
||||
### Phase scope
|
||||
- `.planning/ROADMAP.md` § "Phase 19.8: Console commands overhaul": goal, success criteria, planned shape
|
||||
|
||||
### Console look (reference implementation)
|
||||
- `/media/nvme/dev/pxpx/ssu/internal/cli/tui/`: SSU's widgets on the charm stack (`spinner.go`, `process.go`, `progress.go`, `selector.go`, `selector_keys.go`, `confirm.go`, `styles.go`, `tui.go`); the target look
|
||||
- `../.depreciated/modules/summer-bonfire/PLAN.md` § "Custom-Built Console Widgets" and "SSU Feature Mapping": the earlier Scala design mapping SSU's widgets, including non-TTY fallbacks
|
||||
|
||||
### Existing modules
|
||||
- `modules/bonfire/README.md`, `modules/bonfire/command.go`, `modules/bonfire/root.go`, `modules/bonfire/output.go`, `modules/bonfire/widgets.go`: current command API, cobra root, Output interface, spinner and gradient bar
|
||||
- `modules/sunscreen/sunscreen.go`, `modules/sunscreen/README.md`: redaction handler and `InstallDefault` (one hard-coded TextHandler at INFO)
|
||||
- `modules/compass/README.md`: config layering, `SUMMER_` env overrides, per-environment directories
|
||||
- `internal/build/build.go`: generated `main`, where `sunscreen` is installed and plugin commands are appended
|
||||
|
||||
### Todos
|
||||
- `.planning/todos/pending/bonfire-duplicate-command-names.md`
|
||||
- `.planning/todos/pending/scaffold-generated-header-and-command-deps.md`
|
||||
|
||||
### Project rules
|
||||
- `.planning/research/STACK.md`: decided dependency set; new charm dependencies need a decision note in `.planning/notes/`
|
||||
- `CLAUDE.md` § Documentation rules and § Sibling repositories: README/docs in the same commit; plugin commit first, then app pointer bump
|
||||
|
||||
</canonical_refs>
|
||||
|
||||
<code_context>
|
||||
## Existing Code Insights
|
||||
|
||||
### Reusable Assets
|
||||
- `bonfire.Output` (`output.go`): Info/Success/Error/Warning, Table, Spinner, Progress, Ask/Confirm/Choice/Secret; has TTY, interactive and color detection already. Keep the interface; re-implement behind it with the charm stack.
|
||||
- `bonfire.Command`/`Flag`/`Arg` (`command.go`): add `Examples` and `Hidden` additively; flag defaults are already declared and can render as `[default: …]`.
|
||||
- `widgets.go`: braille spinner frames and 256-color gradient (cyan to green), the Scala/SSU heritage to keep visually.
|
||||
- `sunscreen.Wrap(slog.Handler)`: wraps any handler, so every `diary` channel can be wrapped.
|
||||
|
||||
### Established Patterns
|
||||
- Commands are `namespace:verb` (plus bare `build`, `dev`, `new`, `serve`, `migrate`); grouping by namespace in `list` follows the name before the colon.
|
||||
- cobra root with `SilenceUsage`/`SilenceErrors`; help currently falls back to cobra's default layout.
|
||||
- Config is YAML sections per file through `compass` (`config/log.yaml` → `log.*`), with per-environment overrides.
|
||||
|
||||
### Integration Points
|
||||
- `internal/build` generated `main`: replaces the `sunscreen.InstallDefault` call with `diary` setup and builds the root through `bonfire`.
|
||||
- `surf`: request logging under the `http` component.
|
||||
- `conga`/River: River's logger is routed through a `diary` component logger (`river`).
|
||||
- `serve`, `dev`, `queue:work`: startup summary and shutdown report.
|
||||
- About 40 real commands: 13 framework files in `modules/`, `cmd/summer`, `internal/`, plus 12 plugin repos and `sm-user-plugin`.
|
||||
|
||||
</code_context>
|
||||
|
||||
<specifics>
|
||||
## Specific Ideas
|
||||
|
||||
- "Magic animations" means SSU's terminal UX: the Scala-era bonfire plan was explicitly "inspired by SSU", so SSU's TUI package is the source to port.
|
||||
- The Artisan list and help layouts above (D-13, D-14) and the error block (D-18) are the approved previews.
|
||||
- Error hints must be useful in practice, e.g. `cd ..; bin/<appname> <command>` when a command runs from the wrong directory.
|
||||
|
||||
</specifics>
|
||||
|
||||
<deferred>
|
||||
## Deferred Ideas
|
||||
|
||||
None; the discussion stayed within phase scope.
|
||||
|
||||
### Reviewed Todos (not folded)
|
||||
- The 20 other keyword matches (benchmark, cache purge, uploads XSS, plugin ports, OIDC parity, etc.) are unrelated to the console and stay in their own phases or todos.
|
||||
|
||||
</deferred>
|
||||
|
||||
---
|
||||
|
||||
*Phase: 19.8-console-commands-overhaul*
|
||||
*Context gathered: 2026-10-21*
|
||||
@@ -0,0 +1,162 @@
|
||||
# Phase 19.8: Console commands overhaul - Discussion Log
|
||||
|
||||
> **Audit trail only.** Do not use as input to planning, research, or execution agents.
|
||||
> Decisions are captured in CONTEXT.md — this log preserves the alternatives considered.
|
||||
|
||||
**Date:** 2026-10-21
|
||||
**Phase:** 19.8-console-commands-overhaul
|
||||
**Areas discussed:** Todos, What serve shows, Console stack, Logging config shape, Help and list look, Sweep depth and plugins
|
||||
|
||||
---
|
||||
|
||||
## Todos
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| Duplicate command names | bonfire.NewRoot fails on duplicate names | ✓ |
|
||||
| make:command app access | Scaffolder header and app access for generated commands | partial |
|
||||
|
||||
**User's choice:** Duplicate names folded fully. For make:command: "errors like these that make:command throw need a proper messaging too. They may error, but they should suggest `cd ..; bin/<appname> <command>` and be useful. We don't need to fix database detection when run from wrong directory, but think about these when working on commands console outputs."
|
||||
|
||||
---
|
||||
|
||||
## What serve shows
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| Summary + requests | Summary, then one line per request; plugin INFO at -v | |
|
||||
| Summary only | Summary, then warnings/errors only | |
|
||||
| Summary + all INFO | Summary, then every INFO log restyled | ✓ |
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| TTY decides | Styled on terminal, structured logs otherwise | ✓ |
|
||||
| Config decides | `log.channel` alone picks the format | |
|
||||
| Env decides | app.env picks the format | |
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| Compact block | ~8–12 aligned lines | ✓ |
|
||||
| One line | Single summary line | |
|
||||
| Full table | Every subsystem as a table | |
|
||||
|
||||
**Notes:** The user asked about "magic animations from the previous Scala project". The Scala research repo only had a static ASCII sun banner; the deprecated Scala `summer-bonfire` plan was "inspired by SSU's terminal UX". The user confirmed SSU (`/media/nvme/dev/pxpx/ssu`) is the source to reuse. Banner options were withdrawn.
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| Short report | Drained requests, workers, uptime, errors | ✓ |
|
||||
| Single line | "stopped after …" | |
|
||||
| You decide | Planner picks | |
|
||||
|
||||
---
|
||||
|
||||
## Console stack
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| Adopt charmbracelet | bubbletea/bubbles/lipgloss as in SSU | |
|
||||
| Port to stdlib | Hand-written ANSI widgets | |
|
||||
| lipgloss only | Styling only | |
|
||||
|
||||
**User's choice:** asked for Go alternatives. Presented: charm stack (bubbletea, bubbles, lipgloss, huh, fang, log), tint, pterm, mpb, go-pretty, termenv.
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| Charm stack | lipgloss + bubbles/bubbletea + huh + fang | ✓ |
|
||||
| pterm | One batteries-included dependency | |
|
||||
| Researcher decides | Research compares first | |
|
||||
|
||||
---
|
||||
|
||||
## Logging config shape
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| Laravel shape | `default`, `channels` map, drivers | ✓ |
|
||||
| Flat keys | `log.level`, `log.format`, `log.file` | |
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| Yes, config map | `log.components` per-logger levels | ✓ |
|
||||
| Global level only | | |
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| Off by default | Console only; daily channel commented out | |
|
||||
| On in production | Daily JSON in production | |
|
||||
| On everywhere | Always write storage/logs plus console | ✓ |
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| INFO from surf | `http` component at INFO | ✓ |
|
||||
| Only at -v | DEBUG | |
|
||||
| Separate access channel | Own channel | |
|
||||
|
||||
---
|
||||
|
||||
## Help and list look
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| Artisan list | Version header, options, namespace groups | ✓ |
|
||||
| fang default | fang root help, flat list | |
|
||||
| fang + namespace groups | fang look with cobra groups | |
|
||||
|
||||
**Notes:** The user asked for a preview of fang's default, which was missing from the first question; the question was re-asked with previews for all options.
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| Examples field | `Command.Examples []Example`, checked | ✓ |
|
||||
| Inside Long | Text only | |
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| Yes, Hidden field | Out of `list`, `list --all` shows | ✓ |
|
||||
| No | | |
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| list --format=json | Machine-readable list | ✓ |
|
||||
| Not needed | | |
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| Artisan layout | Description/Usage/Arguments/Options/Examples | ✓ |
|
||||
| fang layout | Uppercase sections | |
|
||||
|
||||
---
|
||||
|
||||
## Sweep depth and plugins
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| Framework + all plugins | summercms.go and every sm-*-plugin with commands | ✓ |
|
||||
| Framework + core only | user, journal, paymentgateway, oauthserver | |
|
||||
| Framework only | | |
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| Where it applies | --force/--pretend/--json/progress where they fit | ✓ |
|
||||
| Uniform everywhere | | |
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| Error + hint | Styled block with `Try:` fix command | ✓ |
|
||||
| fang default errors | Message only | |
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| Fail in each repo | Coverage test fails everywhere | |
|
||||
| Warn only | Logs gaps, passes | ✓ |
|
||||
|
||||
**Notes:** Warn-only conflicted with roadmap criterion 3 (test fails). Resolved: fail for framework commands, warn in plugin repos; criterion 3 reworded.
|
||||
|
||||
## Claude's Discretion
|
||||
|
||||
- Exact palette, symbols, spacing (consistent with SSU's styles)
|
||||
- Console slog handler inside diary (own lipgloss handler or charmbracelet/log), settled in the decision note
|
||||
- Exact `--env` and `-n` semantics, following Artisan
|
||||
|
||||
## Deferred Ideas
|
||||
|
||||
None.
|
||||
238
.planning/phases/19.8-console-commands-overhaul/19.8-PATTERNS.md
Normal file
238
.planning/phases/19.8-console-commands-overhaul/19.8-PATTERNS.md
Normal 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
|
||||
794
.planning/phases/19.8-console-commands-overhaul/19.8-RESEARCH.md
Normal file
794
.planning/phases/19.8-console-commands-overhaul/19.8-RESEARCH.md
Normal file
@@ -0,0 +1,794 @@
|
||||
# Phase 19.8: Console commands overhaul - Research
|
||||
|
||||
**Researched:** 2026-10-21
|
||||
**Domain:** Go CLI UX (cobra + charm v2 stack), log/slog architecture, cross-repo command sweep
|
||||
**Confidence:** HIGH for stack, APIs and inventory (read from source and probed); MEDIUM for UX semantics the CONTEXT leaves open (flagged in Open Questions)
|
||||
|
||||
<user_constraints>
|
||||
## User Constraints (from CONTEXT.md)
|
||||
|
||||
### Locked Decisions
|
||||
|
||||
#### Console stack and look
|
||||
- **D-01:** The console look follows SSU's terminal UX (`/media/nvme/dev/pxpx/ssu/internal/cli/tui`): spinner with counter and current item, multi-row live process view, progress model, arrow-key selector with confirmation screen, lipgloss status styles. The earlier Scala `summer-bonfire` design was itself modelled on SSU; the braille spinner and cyan-to-green gradient bar already in Go `bonfire` came from it. — **Reversibility:** costly — every command's output and the widget API depend on it
|
||||
- **D-02:** Adopt the charm stack in `bonfire`: `charmbracelet/lipgloss` (styling, tables), `charmbracelet/bubbles` + `bubbletea` (live views, spinner, progress), `charmbracelet/huh` (prompts), `charmbracelet/fang` (cobra styling and error rendering). A decision note naming these dependencies and versions lands before any code (project rule: new dependency needs a decision note). Port SSU's widgets rather than inventing new ones. The existing `bonfire.Output` interface stays source-compatible for plugins (no breaking changes in shared plugins). — **Reversibility:** costly — adds dependencies to every application binary; removing them later means rewriting the widgets
|
||||
- **D-03:** Every widget degrades on non-TTY, `-q`, `--no-ansi` and `-n`: no animation, plain lines, prompts return defaults (or fail with a hint when no default exists).
|
||||
|
||||
#### What `serve` shows
|
||||
- **D-04:** At default verbosity `serve` prints a startup summary and then **all INFO logs**, restyled through `diary`'s console handler (consistent prefixes per component instead of today's raw `slog` text mixed with styled lines). `-q` reduces to warnings/errors; `-v`/`-vv`/`-vvv` add debug detail.
|
||||
- **D-05:** The output format follows the TTY: styled console output on a terminal; structured logs (JSON or logfmt per channel config) when stdout is not a TTY (systemd, Docker). The startup summary is also emitted as log records, so journald keeps it.
|
||||
- **D-06:** Startup summary is a compact aligned block of about 8–12 lines: app name and version (plus framework version), environment, URL(s), database and migration state ("up to date" / "3 pending"), plugins (count and names), queue and scheduler, mail, cache and realtime drivers. `-v` adds per-plugin details.
|
||||
- **D-07:** Shutdown (Ctrl+C / SIGTERM) prints a short report: draining, requests drained, workers stopped with job counts (done, in flight returned to the queue), uptime, and any drain errors. The same pattern applies to other long-running commands (`queue:work`, `dev`).
|
||||
|
||||
#### Logging (`diary`)
|
||||
- **D-08:** `config/log.yaml` mirrors Laravel's `config/logging.php` shape: `log.default` names the default channel, `log.level` the global level, `log.channels` is a map of named channels with `driver` (`console`, `stderr`, `daily`, `stack`, `null`), `level`, `format`, `path`, `days`, and `channels` for a stack. `SUMMER_LOG_*` env overrides work through `compass` as for any key. This replaces the `log.level`/`log.channel` pair in the roadmap text with `log.level`/`log.default`. — **Reversibility:** costly — published config keys in every application's `config/log.yaml`
|
||||
```yaml
|
||||
# config/log.yaml
|
||||
default: stack
|
||||
level: info
|
||||
channels:
|
||||
stack:
|
||||
driver: stack
|
||||
channels: [console, daily]
|
||||
console:
|
||||
driver: console # styled on TTY, json/logfmt otherwise
|
||||
format: auto
|
||||
daily:
|
||||
driver: daily
|
||||
path: storage/logs/summer.log
|
||||
days: 14
|
||||
format: json
|
||||
level: warn
|
||||
components:
|
||||
river: warn
|
||||
surf: info
|
||||
```
|
||||
- **D-09:** Per-component levels through a `log.components` map (`river: warn`, `journal: debug`). Plugins and modules get a named logger from `diary` (for example `diary.For("journal")`) instead of `slog.Default`; verbosity flags raise every component.
|
||||
- **D-10:** Daily log files are **on everywhere** by default, like Laravel: `summer new` and the framework defaults use a `stack` of `console` + `daily` at `storage/logs/`, with retention (14 days default). Console output stays on as well.
|
||||
- **D-11:** The request log is INFO from `surf` under a named `http` component (method, path, status, duration, request id), so it shows by default and can be silenced with `components.http: warn`.
|
||||
- **D-12:** `sunscreen` redaction wraps every channel; `sunscreen.Install`/`InstallDefault` keep working as compatibility shims.
|
||||
|
||||
#### Help and `list`
|
||||
- **D-13:** Bare `<binary>` / `summer` prints an Artisan-style list: version header (`SummerCMS acme v1.4.0 (framework v0.3.2)`), usage, global options, then commands grouped by namespace (top-level first, then `admin`, `make`, `migrate`…) with aligned descriptions. Styled with lipgloss in the same palette as the rest of the console.
|
||||
```
|
||||
SummerCMS acme v1.4.0 (framework v0.3.2)
|
||||
|
||||
Usage:
|
||||
bin/acme <command> [options] [arguments]
|
||||
|
||||
Options:
|
||||
-h, --help Display help for the given command
|
||||
-q, --quiet Do not output any message
|
||||
-v|vv|vvv Increase verbosity
|
||||
--ansi|no-ansi Force (or disable) ANSI output
|
||||
-n, --no-interaction Do not ask any interactive question
|
||||
--env The environment the command runs under
|
||||
|
||||
Available commands:
|
||||
list List commands
|
||||
migrate Run the database migrations
|
||||
serve Serve the application
|
||||
admin
|
||||
admin:create Create a backend administrator
|
||||
admin:token:create Create a personal API token
|
||||
make
|
||||
make:command Create a new console command
|
||||
make:model Create a new model and migration
|
||||
```
|
||||
- **D-14:** Per-command help uses the Artisan layout: Description / Usage / Arguments / Options (with defaults) / Examples. fang is used for styling and error rendering, not for its own help layout.
|
||||
```
|
||||
Description:
|
||||
Create a personal API token for a backend admin
|
||||
|
||||
Usage:
|
||||
admin:token:create <login> [options]
|
||||
|
||||
Arguments:
|
||||
login The admin's login
|
||||
|
||||
Options:
|
||||
--scopes=SCOPES Comma-separated scopes [default: "*"]
|
||||
--expires=DAYS Days until expiry [default: 90]
|
||||
--json Print the token as JSON
|
||||
|
||||
Examples:
|
||||
# Token for the MCP server, journal only
|
||||
bin/acme admin:token:create jane --scopes=journal.*
|
||||
|
||||
# Script-friendly output
|
||||
bin/acme admin:token:create jane --json
|
||||
```
|
||||
- **D-15:** New additive `bonfire.Command` fields: `Examples []Example` (command line + description, rendered as a section) and `Hidden bool` (runnable, left out of `list`; `list --all` shows it). Additive only, so existing plugins compile unchanged.
|
||||
- **D-16:** `list --format=json` outputs every command with arguments, options and examples, for docs checks and AI agents.
|
||||
- **D-17:** Documentation coverage check: `bonfire` exports a helper that reports commands lacking a description or examples. It **fails** for `summercms.go`'s own commands and **warns only** in plugin repos. Roadmap success criterion 3 is reworded to match.
|
||||
|
||||
#### Errors
|
||||
- **D-18:** Errors render as a styled block: the cause, a short explanation, then a `Try:` hint with the exact command that fixes it, then "Run with -v for details". `-v` adds the wrapped error chain. Exit codes: 1 for failure, 2 for usage errors.
|
||||
```
|
||||
ERROR No application found in /home/jane/acme/plugins.
|
||||
|
||||
summer make:command needs an application root (summer.yaml).
|
||||
|
||||
Try:
|
||||
cd ..; bin/acme make:command acme.blog:publish
|
||||
|
||||
Run with -v for details.
|
||||
```
|
||||
- **D-19:** Hints for known failure modes are part of the sweep: run from the wrong directory (suggest `cd ..; bin/<app> <command>`), pending migrations (suggest `bin/<app> migrate`), missing config or env, database unreachable. Detecting the database when run from the wrong directory is **not** fixed; only the message improves.
|
||||
|
||||
#### Sweep
|
||||
- **D-20:** The sweep covers `summercms.go` and every shared plugin repo with commands: `sm-paymentgateway-plugin`, `sm-oauthserver-plugin`, `sm-journal-plugin`, `sm-sitemap-plugin`, `sm-chat-plugin`, `sm-chatchannels-plugin`, `sm-chatvideo-plugin`, `sm-faq-plugin`, `sm-menu-plugin`, `sm-herald-plugin`, `sm-forum-plugin`, `sm-pgstripe-plugin`, and `sm-user-plugin`. Changes per plugin: description, long help, examples, named loggers, output polish. One commit per plugin repo, pushed first, then pointer bumps in the apps as separate commits. No breaking changes to command names or flags.
|
||||
- **D-21:** The Artisan checklist applies where it fits, not uniformly: `--force` confirmation on destructive commands, `--pretend` on migrate-like and bulk writers, `--json` on commands whose output scripts read (lists, tokens, status), progress for bulk loops. The plan records each command's checklist result so the gaps are explicit.
|
||||
- **D-22:** One-shot commands end with a result line and a meaningful exit code.
|
||||
|
||||
### Claude's Discretion
|
||||
- Exact palette, symbols and spacing, as long as they match SSU's styles and stay consistent between `list`, help, widgets and log lines.
|
||||
- Console handler choice inside `diary` (lipgloss-styled handler of our own, or `charmbracelet/log`), to be settled in the dependency decision note.
|
||||
- Exact semantics of `--env` and `-n` defaults, following Artisan.
|
||||
|
||||
### Folded Todos
|
||||
- **Reject duplicate console command names in bonfire.NewRoot** (`.planning/todos/pending/bonfire-duplicate-command-names.md`): `NewRoot` fails with an error naming the duplicated command; update the bonfire README and `docs/console/writing-commands.md`. Fits plan 2.
|
||||
- **Scaffolded files carry a DO NOT EDIT header, and make:command cannot reach the application** (`.planning/todos/pending/scaffold-generated-header-and-command-deps.md`): folded **partially**. The scaffolder itself is not changed; the errors these commands throw get proper messages with actionable hints (D-18, D-19). The todo stays open for the scaffolder fix.
|
||||
|
||||
### Deferred Ideas (OUT OF SCOPE)
|
||||
None; the discussion stayed within phase scope.
|
||||
|
||||
#### Reviewed Todos (not folded)
|
||||
- The 20 other keyword matches (benchmark, cache purge, uploads XSS, plugin ports, OIDC parity, etc.) are unrelated to the console and stay in their own phases or todos.
|
||||
</user_constraints>
|
||||
|
||||
<phase_requirements>
|
||||
## Phase Requirements
|
||||
|
||||
No requirement IDs are mapped (null). The sources are the ROADMAP success criteria (SC1..SC5) and D-01..D-22. Related completed requirement: CLI-01 (rich output with non-TTY degradation, summer-bonfire design), which this phase must not regress.
|
||||
|
||||
| Source | Description | Research support |
|
||||
|--------|-------------|------------------|
|
||||
| SC1 | `diary` module: `log.default/level/channels/components`, stderr, stack, daily+retention+JSON, styled console handler, component loggers, sunscreen on every channel, daily files on by default; charm stack with decision note first | Standard Stack, Pattern 1-3, Code Examples (diary), Pitfalls 4-8 |
|
||||
| SC2 | Global flags `-v/-vv/-vvv`, `-q`, `--ansi/--no-ansi`, `-n`, `--env`; verbosity sets diary level at runtime (River, plugins) | Pattern 4 (globals pre-scan + persistent flags), probe results, Pitfalls 1-3 |
|
||||
| SC3 | Description + long help + examples + documented args/options with defaults; namespaced `list`; `help <command>`; coverage check fails in framework, warns in plugins | Pattern 5 (help/list), Command Inventory, Validation Architecture |
|
||||
| SC4 | Sweep with Artisan checklist; serve/queue:work startup summary + shutdown report; result lines and exit codes | Command Inventory with per-command checklist, Pattern 6 (summary/shutdown), Pitfalls 9-14 |
|
||||
| SC5 | READMEs, docs console+logging pages, landing site, roadmap presentation; unit tests in last plan | Docs Locations, Validation Architecture |
|
||||
</phase_requirements>
|
||||
|
||||
## Project Constraints (from CLAUDE.md)
|
||||
|
||||
- Standard library first; a new dependency needs a research doc or a decision note naming it (`.planning/notes/`). The decision note for the charm stack lands before code (D-02). [VERIFIED: CLAUDE.md]
|
||||
- YAML via `goccy/go-yaml`, never `gopkg.in/yaml.v3`. (fang/huh pull `gopkg.in/yaml.v3` only as an indirect test dependency of testify; nothing in this repo may import it.) [VERIFIED: CLAUDE.md; proxy go.mod of fang v2.0.1]
|
||||
- `go vet ./...` and `go test ./...` green at every commit; Docker tests skip under `-short` with `flag.Parse()` + `testing.Short()` in TestMain. [VERIFIED: CLAUDE.md]
|
||||
- New module: beach name, README in the standard structure (H1, summary sentence, import line, `**Name:**`, Overview, Features, Usage, API reference, Configuration, CLI commands, Dependencies, Testing), and a row in the root `README.md` table reusing the summary sentence. [VERIFIED: CLAUDE.md]
|
||||
- Exported API, config key, CLI command or dependency change updates the module README and the affected `docs/` pages in the same commit; every identifier in README/docs must exist (docs checker); config keys are checked by hand. [VERIFIED: CLAUDE.md]
|
||||
- Framework code and docs never name a consuming application (use `acme`, `blog`). Gate scripts in this repo split app names (`"fono""teka.go"`) to dodge the checker. [VERIFIED: CLAUDE.md; scripts/check-phase19.4.sh:62]
|
||||
- Core plugins (user, blog, pages, payment...) must not get breaking changes unless asked. [VERIFIED: ~/.claude/CLAUDE.md, ../CLAUDE.md]
|
||||
- Plugins: commit and push the plugin first, then the pointer bump in the app as a separate commit. No push/tag/deploy without the user's go-ahead. No co-author tags. Planning docs and code in separate commits. Stage files by path; never `git add -A`; several sessions run concurrently. [VERIFIED: CLAUDE.md]
|
||||
- MSD overrides: lean plans (plan count confirmed with the user: 4); unit tests are the last plan; STATE.md via `msd_run query state.*`; g15office comment when the phase advances. [VERIFIED: CLAUDE.md]
|
||||
- Release tags get a summercms.io changelog post the same day (only if this phase is tagged). [VERIFIED: CLAUDE.md]
|
||||
|
||||
## Summary
|
||||
|
||||
The console layer today is a thin cobra adapter (`modules/bonfire`) with hand-written ANSI widgets, and logging is a single `slog.NewTextHandler(os.Stderr)` wrapped by `sunscreen` and installed by the generated `main` before config loads. About 60 call sites across framework modules log through `slog.Default()` or a per-module `loggerFromApp(app)` helper that falls back to `slog.Default()`; River gets that logger through `river.Config.Logger`; GORM uses its own default logger that writes straight to stdout. That is exactly why `serve` output looks mixed today.
|
||||
|
||||
The charm stack must be the **v2 line on `charm.land/...` import paths**, not the v1 line SSU pins (`github.com/charmbracelet/lipgloss v1.0.0`, `bubbletea v1.3.0`, `bubbles v0.20.0`). fang v1.0.0 already depends on `charm.land/lipgloss/v2` (beta), so mixing SSU's v1 versions with any fang would compile two lipgloss majors into every binary. All five v2 modules (lipgloss v2.0.6, bubbletea v2.1.0, bubbles v2.2.1, huh v2.0.3, fang v2.0.1) declare `go` ≤ 1.26 and work with this repo's `go 1.27.0`. Porting SSU's widgets means translating v1 APIs: `View() string` becomes `View() tea.View`, `tea.KeyMsg` becomes `tea.KeyPressMsg`, `progress.WithDefaultGradient/WithGradient` become `WithDefaultBlend/WithColors`, and `bar.Width = n` becomes `SetWidth(n)`. lipgloss v2 `Render()` always emits ANSI, so `--no-ansi`/`NO_COLOR` must be applied at the writer through `colorprofile.Writer`. A probe showed the full stack adds about 2.1 MB to a stripped binary.
|
||||
|
||||
fang is the weakest fit. `fang.Execute` always calls `root.SetHelpFunc`, sets `root.Version` (unless `WithoutVersion()`), adds a hidden `man` command (unless `WithoutManpage()`), and reads the color profile from the environment only. Its exported value for us is `ErrorHandler`/`WithErrorHandler` and the `Styles`/`ColorScheme` types. Errors that occur before the root exists (config load in the generated `main`) never pass through it. Recommendation: keep fang (D-02 locks it) but use it narrowly: `fang.Execute(ctx, root, fang.WithoutVersion(), fang.WithoutManpage(), fang.WithoutCompletions(), fang.WithErrorHandler(bonfire.renderError))`, set the Artisan help func on **every subcommand** (cobra resolves a command's own `helpFunc` before its parent's), and render the root's help/list through root `RunE` plus our own `help` command. The bonfire error renderer also has to be callable directly from `main`. For `diary`, use stdlib `slog.NewMultiHandler` (stack, Go 1.26+), `slog.JSONHandler`/`TextHandler` (json/logfmt), `slog.DiscardHandler` (null), a stdlib daily-rotating writer, and **our own lipgloss console handler**. `charm.land/log/v2` fails `testing/slogtest` on group semantics (probe output below), so it is the wrong choice for a handler that sits under `sunscreen` and every module.
|
||||
|
||||
**Primary recommendation:** Plan 1 writes the decision note (charm v2 set + "no charm log, no lumberjack"), then builds `diary` (proxy-backed `diary.For`, component filter, channels, daily writer) and rewires the generated `main`. Plan 2 adds globals pre-scan + persistent flags, Artisan help/list/JSON, error renderer with hints, duplicate rejection and charm-backed widgets behind the unchanged `Output` interface. Plan 3 sweeps 52 framework commands (18 summer tool, 15 delegates, 19 app binary) plus the 2 in-repo examples, and 26 plugin commands with a recorded checklist, startup summary and shutdown report, and the docs/landing/roadmap updates. Plan 4 adds goldens, slogtest, TTY/non-TTY matrix and the phase gate script.
|
||||
|
||||
## Architectural Responsibility Map
|
||||
|
||||
| Capability | Primary Tier | Secondary Tier | Rationale |
|
||||
|------------|-------------|----------------|-----------|
|
||||
| Global flag parsing (-v/-q/-n/--env/--ansi) | `bonfire` (cobra persistent flags + `PeekGlobals` pre-scan) | generated `main` (applies env before `compass`) | Flags must be known before config load and plugin boot, which happen before cobra parses |
|
||||
| Log routing, levels, channels, rotation | `diary` (new framework module) | `sunscreen` (redaction leaf wrapper) | Single owner of slog default; modules ask it for named loggers |
|
||||
| Console styling palette | `bonfire` (exported styles) | `diary` console handler imports them | One palette for list/help/widgets/log lines (D-01 discretion) |
|
||||
| Help, list, JSON catalog, coverage check | `bonfire` | `cmd/summer` docs checker consumes JSON/coverage | Commands are bonfire values; bonfire owns rendering |
|
||||
| Error rendering + hints + exit codes | `bonfire` (`Hint` error type, renderer, `Execute`) | modules wrap known failures (`lagoon`, `internal/build`) | Hints are domain knowledge; rendering is console concern |
|
||||
| Startup summary / shutdown report | `surf` (serve), `conga` (queue:work, schedule:run), `cmd/summer` (dev) | `lagoon` (pending-migration count), `conga` (job counters) | Each long-running command knows what it booted |
|
||||
| Request log | `surf` (`http` component) | `diary` | Already in `surf/accesslog.go`; only the logger and defaults change |
|
||||
| Third-party loggers (River, GORM) | `conga`, `lagoon` | `diary.For("river")`, `diary.For("db")` | Owners of the client configs |
|
||||
| Plugin command help/loggers | each `sm-*-plugin` repo | apps (pointer bumps) | D-20 one commit per plugin repo |
|
||||
|
||||
## Standard Stack
|
||||
|
||||
### Core (new direct dependencies, all need the decision note)
|
||||
|
||||
| Library (import path) | Version | Purpose | Why standard |
|
||||
|---|---|---|---|
|
||||
| `charm.land/lipgloss/v2` | v2.0.6 (2026-08-11) | Styles, `lipgloss/v2/table` for boxed tables, layout for list/help/errors | De facto Go terminal styling; v2 is the only line fang/huh v2 build on [VERIFIED: proxy.golang.org @latest + go.mod] |
|
||||
| `charm.land/bubbletea/v2` | v2.1.0 (2026-10-08) | Program runtime for live spinner/process/progress views | SSU's runtime; v2 API (`View() tea.View`, `KeyPressMsg`) [VERIFIED: proxy + UPGRADE_GUIDE_V2.md] |
|
||||
| `charm.land/bubbles/v2` | v2.2.1 (2026-08-24) | `spinner` (MiniDot), `progress` (gradient bar), `key`/`help` | SSU's components [VERIFIED: proxy + module source] |
|
||||
| `charm.land/huh/v2` | v2.0.3 (2026-03-10) | Ask/Confirm/Choice/Secret on an interactive TTY; `WithAccessible` | Charm's prompt library; `Form.WithInput/WithOutput/WithAccessible`, `ErrUserAborted` exist [VERIFIED: module source form.go:54-61, 235-351] |
|
||||
| `charm.land/fang/v2` | v2.0.1 (2026-03-11) | Error rendering hook (`WithErrorHandler`) and Windows VT enabling around cobra | Locked by D-02; use narrowly (see Pattern 5) [VERIFIED: module source fang.go:21-170] |
|
||||
| `github.com/charmbracelet/colorprofile` | v0.4.3 (transitive, imported directly) | `colorprofile.Writer` to apply `--ansi/--no-ansi/NO_COLOR` to lipgloss v2 output | lipgloss v2 `Render()` always emits ANSI; downsampling is the writer's job [CITED: lipgloss v2 UPGRADE_GUIDE_V2.md "Printing and Color Downsampling"] |
|
||||
|
||||
Module go directives: lipgloss `go 1.25.0`, bubbletea `go 1.26.0`, bubbles `go 1.25.0`, huh `go 1.25.8`, fang `go 1.25.0`; repo `go 1.27.0`; toolchain here `go1.27.2`. [VERIFIED: proxy .mod files; go.mod:3; `go version`]
|
||||
MVS side effects: fang requires `spf13/cobra v1.9.1`/`pflag v1.0.6`, repo keeps `cobra v1.10.2`/`pflag v1.0.9`; `golang.org/x/text` stays at repo's v0.42.0. [VERIFIED: proxy go.mod + repo go.mod]
|
||||
|
||||
### Supporting (stdlib, no new dependency)
|
||||
|
||||
| Package | Purpose | When |
|
||||
|---|---|---|
|
||||
| `log/slog` `NewMultiHandler` | `stack` driver fan-out; calls only enabled handlers, clones the record, joins errors | Go 1.26+ [VERIFIED: $GOROOT/src/log/slog/multi_handler.go:35-45] |
|
||||
| `log/slog` `JSONHandler`, `TextHandler`, `DiscardHandler`, `LevelVar` | json / logfmt / null / runtime levels | [VERIFIED: `go doc log/slog.DiscardHandler`] |
|
||||
| `testing/slogtest` | Conformance test for the console and daily handlers | Plan 4 |
|
||||
| `runtime/debug.ReadBuildInfo` | App and framework versions for the list header | See Pattern 5 [VERIFIED: probe below] |
|
||||
| `gorm.io/gorm/logger.NewSlogLogger` | Route GORM's logger through `diary.For("db")` | Already in gorm v1.31.2 [VERIFIED: gorm logger/slog.go:24-32] |
|
||||
|
||||
### Alternatives Considered
|
||||
|
||||
| Instead of | Could use | Tradeoff / verdict |
|
||||
|---|---|---|
|
||||
| charm v2 (`charm.land/*/v2`) | SSU's v1 pins (`github.com/charmbracelet/*` v1) | Rejected: fang v1.0.0 requires `charm.land/lipgloss/v2 v2.0.0-beta.3...` → two lipgloss majors; v1 is maintenance-only. Port SSU code with the v2 API renames. [VERIFIED: proxy go.mod of fang v1.0.0] |
|
||||
| Own lipgloss console handler | `charm.land/log/v2` v2.0.1 | Rejected: fails `testing/slogtest` (WithGroup treated as a prefix, empty attrs printed, empty-key groups not inlined). It also brings its own level and prefix model that duplicates diary's. [VERIFIED: probe output in Code Examples] |
|
||||
| Stdlib daily writer (~120 lines) | `gopkg.in/natefinch/lumberjack.v2` (SSU uses it) | Rejected: rotates by **size**, not by day; no `name-YYYY-MM-DD.log` naming; would need a decision note anyway. [VERIFIED: SSU internal/logging/logging.go uses `MaxSizeMB`/`MaxBackups`] |
|
||||
| `slog.NewMultiHandler` | hand-written fan-out (SSU `MultiHandler`) | Use stdlib; SSU's version does not clone records. |
|
||||
| huh for prompts on TTY | keep line prompts only | huh gives the arrow-key selector D-01 asks for; keep the line-based path for non-TTY (existing tests and piped-stdin contracts). |
|
||||
|
||||
**Installation (plan 1, after the decision note):**
|
||||
```bash
|
||||
go get charm.land/lipgloss/v2@v2.0.6 charm.land/bubbletea/v2@v2.1.0 charm.land/bubbles/v2@v2.2.1 charm.land/huh/v2@v2.0.3 charm.land/fang/v2@v2.0.1 github.com/charmbracelet/colorprofile@v0.4.3
|
||||
go mod tidy
|
||||
```
|
||||
Only `modules/bonfire` (and `modules/diary` for lipgloss/colorprofile) may import them.
|
||||
|
||||
## Package Legitimacy Audit
|
||||
|
||||
The MSD seam only supports npm/pypi/crates (`package-legitimacy check --ecosystem go` is rejected with a usage error), so Go modules were checked by hand against proxy.golang.org and the `charm.land` vanity server.
|
||||
|
||||
| Module | Registry | Age (first tag of repo) | Source repo (go-import meta / proxy Origin) | Verdict | Disposition |
|
||||
|---|---|---|---|---|---|
|
||||
| charm.land/lipgloss/v2 v2.0.6 | proxy.golang.org | 2021-04-02 (v0.1.0) | `charm.land/lipgloss git https://github.com/charmbracelet/lipgloss`, hash 733ce535 | OK | Approved |
|
||||
| charm.land/bubbletea/v2 v2.1.0 | proxy.golang.org | 2020-04-20 | github.com/charmbracelet/bubbletea, hash f9df43c4 | OK | Approved |
|
||||
| charm.land/bubbles/v2 v2.2.1 | proxy.golang.org | 2020-06-08 | github.com/charmbracelet/bubbles, hash 49094810 | OK | Approved |
|
||||
| charm.land/huh/v2 v2.0.3 | proxy.golang.org | 2023-12-08 | github.com/charmbracelet/huh, hash 3c0116c6 | OK | Approved |
|
||||
| charm.land/fang/v2 v2.0.1 | proxy.golang.org | 2025-06-18 | github.com/charmbracelet/fang, hash d89b30af | OK (young, 2 v2 tags) | Approved; note in decision note |
|
||||
| github.com/charmbracelet/colorprofile v0.4.3 | proxy.golang.org | transitive of all above | github.com/charmbracelet/colorprofile | OK | Approved |
|
||||
| charm.land/log/v2 | — | — | — | not recommended (slogtest failures) | Not added |
|
||||
| gopkg.in/natefinch/lumberjack.v2 | — | — | — | not needed | Not added |
|
||||
|
||||
All names come from the projects' own go.mod/README files in the module cache and the `charm.land` go-import meta tags. They were not taken from web search. [VERIFIED: curl proxy.golang.org `@latest`, `.mod`, `@v/list`; curl `https://charm.land/<m>/v2?go-get=1`]
|
||||
**Packages removed due to [SLOP]:** none. **Flagged [SUS]:** none. There are no postinstall hooks (Go modules have none).
|
||||
|
||||
## Architecture Patterns
|
||||
|
||||
### System Architecture Diagram
|
||||
|
||||
```
|
||||
argv ──► generated main ──► bonfire.PeekGlobals(args) ──► {env, verbosity, quiet, ansi, noInteraction}
|
||||
│ │
|
||||
│ ├─► compass.Open({Dir:"config", Env: env})
|
||||
│ └─► diary.Setup(cfg, {Verbosity, Quiet, Color, Stdout/Stderr})
|
||||
│ │ slog.SetDefault(root logger)
|
||||
▼ ▼
|
||||
backpack.New(cfg) ─► party.Activate ─► plugins log via diary.For("<plugin>") ─┐
|
||||
│ │
|
||||
▼ ▼
|
||||
commands = lagoon/conga/surf/cabana + plugins diary root handler (proxy, swappable)
|
||||
│ │ component filter (log.components + verbosity floor)
|
||||
bonfire.NewRoot(name, commands, out, opts...) │ sunscreen.Wrap per channel
|
||||
│ rejects duplicate names ▼
|
||||
│ persistent flags (-v -q -n --env --ansi) slog.NewMultiHandler(stack)
|
||||
│ help func on every subcommand ├─ console: TTY → lipgloss lines (stderr)
|
||||
│ list / help commands │ non-TTY → logfmt|json
|
||||
▼ ├─ daily: storage/logs/summer-YYYY-MM-DD.log (json), retention
|
||||
bonfire.Execute(ctx, root) ─► fang.Execute(... WithErrorHandler(render))
|
||||
│ │ └─ null / stderr
|
||||
│ └─ error → styled block (cause, explanation, Try:, -v chain) → exit 1|2
|
||||
▼
|
||||
Command.Run(ctx, Input, Output)
|
||||
│ Output: console impl; TTY & !quiet & !noAnsi & !noInteraction → bubbletea views (spinner/process/progress)
|
||||
│ otherwise → plain lines (existing fallbacks)
|
||||
│ prompts: interactive → huh; piped stdin → line read; -n → default or hinted error
|
||||
▼
|
||||
serve / queue:work / dev: startup summary (rendered block + log records) … signal … shutdown report
|
||||
```
|
||||
|
||||
### Recommended Project Structure
|
||||
|
||||
```
|
||||
modules/diary/ # NEW (beach name: a beach diary/logbook; README "**Name:**" line)
|
||||
├── diary.go # Setup, For, SetVerbosity, Root(), Install compat
|
||||
├── config.go # log.* parsing (tolerant of env strings: "14", "console,daily")
|
||||
├── component.go # component filter handler + proxy handler
|
||||
├── console.go # lipgloss console handler (TTY) using bonfire styles
|
||||
├── daily.go # daily rotating writer + retention
|
||||
├── channels.go # driver factory: console, stderr, daily, stack, null
|
||||
├── *_test.go # slogtest, rotation with injected clock, env overrides
|
||||
└── README.md
|
||||
modules/bonfire/
|
||||
├── command.go # + Examples, Hidden (additive)
|
||||
├── root.go # NewRoot(name, cmds, out, opts ...RootOption); duplicates; persistent flags
|
||||
├── globals.go # Globals, PeekGlobals(args), Verbosity
|
||||
├── help.go / list.go # Artisan help + list (txt/json), Catalog JSON, CheckDocs
|
||||
├── errors.go # Hint, UsageError, ErrReported, render, Execute → exit code
|
||||
├── styles.go # palette (ported from SSU tui/styles.go + current ANSI set)
|
||||
├── output.go / widgets.go # same Output interface; charm-backed TTY path
|
||||
├── live.go # ported SSU SpinnerModel / ProcessModel / ProgressModel (v2 API)
|
||||
├── prompts.go # huh on TTY; line fallback unchanged
|
||||
└── testdata/*.golden # list/help/error goldens
|
||||
```
|
||||
|
||||
### Pattern 1: Proxy-backed named loggers (init-order safe)
|
||||
**What:** `diary.For(name)` returns `slog.New(&componentHandler{name: name})`. The handler resolves the current root handler at `Enabled`/`Handle` time from an `atomic.Pointer`, so loggers created in package `var` blocks or before `diary.Setup` still route correctly after setup and after `SetVerbosity`.
|
||||
**When:** All framework modules (replace `loggerFromApp` fallbacks and bare `slog.Default()`), River (`diary.For("river")`), GORM (`diary.For("db")`), plugins (`diary.For("journal")`).
|
||||
**Component filter:** `Enabled(ctx, lvl)` = `lvl >= floor(name)` && `root.Enabled(ctx, lvl)`, where `floor(name)` = `components[name]` else `log.level`, lowered by verbosity. Records from plain `slog.Default()` carry no component and use `log.level`. `WithAttrs` that sets key `component` rebinds the name, so `slog.Default().With("component","x")` also works. The console handler prints the component as a fixed-width prefix; JSON/logfmt get a `component` attribute.
|
||||
**Example:**
|
||||
```go
|
||||
// Source: design for this phase (stdlib log/slog semantics)
|
||||
type componentHandler struct {
|
||||
name string
|
||||
attrs []slog.Attr // pre-bound; replayed onto the live root at Handle time
|
||||
group []string
|
||||
}
|
||||
|
||||
func (h *componentHandler) Enabled(ctx context.Context, l slog.Level) bool {
|
||||
return l >= current().floor(h.name) && current().handler.Enabled(ctx, l)
|
||||
}
|
||||
|
||||
func (h *componentHandler) Handle(ctx context.Context, r slog.Record) error {
|
||||
r.AddAttrs(slog.String("component", h.name))
|
||||
return current().bind(h.attrs, h.group).Handle(ctx, r)
|
||||
}
|
||||
```
|
||||
Note: rebinding `WithAttrs/WithGroup` onto a swapped root needs care. Keep `attrs`/`group` on the proxy and apply them to the live handler lazily (cache per root generation) so a swap after `With(...)` keeps the attributes.
|
||||
|
||||
### Pattern 2: Channels as sunscreen-wrapped leaves under a stdlib MultiHandler
|
||||
**What:** Each configured channel builds a leaf handler, which is wrapped with `sunscreen.Wrap` (literal D-12; `Wrap` returns an already-wrapped handler unchanged [VERIFIED: modules/sunscreen/sunscreen.go:82-87]). A `stack` is `slog.NewMultiHandler(children...)`. Each leaf owns a `slog.LevelVar` for its `level`, and the console leaf's LevelVar is what `-q` raises to WARN.
|
||||
**Level rule (recommendation):** a record reaches a channel when `level >= max(floor(component), channel.level)`. Verbosity: `-v` sets every component floor to at most INFO (so `river: warn` shows INFO), `-vv` sets DEBUG everywhere, and `-vvv` adds DEBUG plus `AddSource` on the console and GORM `Info` (SQL traces, parameterized). `-q` sets the console leaf to WARN; files are unaffected.
|
||||
|
||||
### Pattern 3: Daily writer (stdlib)
|
||||
`path: storage/logs/summer.log` → files `storage/logs/summer-2026-10-21.log` (Laravel naming). Under a mutex: pick the file by `now()` date, reopen on date change with `O_APPEND|O_CREATE|O_WRONLY` (0o640), `MkdirAll` (0o750), then delete `summer-*.log` whose date is older than `days` at open and on each rotation. Each record is buffered and written with one `Write` call (append writes of a whole line from `serve` and `queue:work` processes do not interleave on a local filesystem). The path is relative to the **application root** (the parent of the config dir), not the process CWD. If opening fails, warn once on the console and disable the channel. **Boot never fails because of a log file.** Inject `now func() time.Time` for tests.
|
||||
|
||||
### Pattern 4: Globals before boot, flags after
|
||||
The generated `main` loads config and activates plugins **before** cobra parses flags [VERIFIED: internal/build/build.go:119-148]. So:
|
||||
1. `g := bonfire.PeekGlobals(args)`: a tolerant scan (stops at `--`, recognises `-v/-vv/-vvv/--verbose`, `-q/--quiet`, `-n/--no-interaction`, `--ansi/--no-ansi`, `--env X`/`--env=X`), never errors.
|
||||
2. `compass.Open(compass.Options{Dir: "config", Env: g.Env})` (`Options.Env` wins over `SUMMER_ENV` [VERIFIED: modules/compass/config.go:193-199]).
|
||||
3. `diary.Setup(cfg, diary.Options{Verbosity: g.Verbosity, Quiet: g.Quiet, ...})`.
|
||||
4. `bonfire.NewRoot(...)` registers the same flags as cobra persistent flags so help, validation and `Input` all see them. A `RootOption` callback (`bonfire.OnGlobals(func(Globals))`) lets `main` re-apply them to `diary` in `PersistentPreRunE` (same values; keeps `bonfire` free of a `diary` import).
|
||||
Cobra probe: `-vvv migrate` → 3, `migrate -vv -q -n --env staging x` → all set, `--env staging migrate` works, bare root with `RunE` runs (list), `-v` alone runs root, and **passthrough commands receive `-v` as a raw arg** (`-v serve --addr :1 -vv` → args `["-v","--addr",":1","-vv"]`), so `summer serve -v` forwards verbosity to the app binary unchanged. [VERIFIED: probe in scratchpad, output quoted in Code Examples]
|
||||
|
||||
### Pattern 5: Artisan help/list with fang narrowly
|
||||
- `wrap()` sets `cmd.SetHelpFunc(artisanHelp)` on every subcommand. Cobra's `HelpFunc()` returns the command's own `helpFunc` before walking to the parent, so fang's root-level `SetHelpFunc` never reaches subcommands. [VERIFIED: cobra@v1.10.2 command.go:484-501; fang v2 fang.go:129-139]
|
||||
- Root: `RunE` renders the list (bare binary). `bin --help` hits the root help func, which fang has set, so either (a) skip `fang.Execute` and call `root.ExecuteContext` + our renderer directly (fang then contributes only `Styles`/`DefaultErrorHandler`/types), or (b) call `fang.Execute` and pre-route `-h/--help` with no command to the list in `bonfire.Execute` before delegating. **Recommendation: (b)**, which honours D-02 and D-14 and stays small. Always pass `fang.WithoutVersion()` (fang's `--version`/`-v` would collide with verbosity; cobra only adds `-v` for version when no `v` shorthand exists [VERIFIED: cobra command.go:1238-1258]), `fang.WithoutManpage()` (else a hidden `man` command appears in our list/duplicate checks), `fang.WithoutCompletions()`.
|
||||
- fang wraps stderr with `colorprofile.NewWriter(root.ErrOrStderr(), os.Environ())` before calling the error handler [VERIFIED: fang.go:166-169]. To honour `--no-ansi/--ansi`, the handler type-asserts `*colorprofile.Writer` and overrides `.Profile`.
|
||||
- Version header: `debug.ReadBuildInfo()`. The app version is `Main.Version` (Go ≥1.24 stamps the VCS tag/pseudo-version, `+dirty` when modified [CITED: go.dev/doc/go1.24]). For the framework version, find the `Deps` entry for `git.golem15.com/golem15/summercms`. Probe results: with `replace => ../fw` the dep reads `version="v0.3.2"`, `Replace={"../fw","(devel)"}`; under go.work the dep reads `version="(devel)"`, `Replace=nil`. Render `framework v0.3.2 (local)` / `framework devel`. [VERIFIED: probe]
|
||||
- Grouping: top-level names (no colon) first, then by the segment before the first colon (`admin:token:create` → `admin`), sorted; `Hidden` skipped unless `list --all`; include cobra's `help` and our `list`.
|
||||
- `list --format=json`: `{"application":{name,version,framework},"global_options":[...],"commands":[{name,description,long,usage,hidden,passthrough,arguments:[{name,description,required}],options:[{name,shorthand,description,default,bare,repeatable}],examples:[{command,description}]}]}`.
|
||||
- `bonfire.CheckDocs(cmds []Command) []DocProblem` (name, missing: description|long|examples|arg description|flag description). The framework test fails on any problem; plugin repos call it from a test with `t.Log` (warn only).
|
||||
|
||||
### Pattern 6: Startup summary and shutdown report
|
||||
- Summary values are collected after binding and rendered twice: as an aligned lipgloss block on a TTY, and as INFO log records (component `serve`) so journald and files keep them (D-05). Keep one record whose message contains **`listening on <addr>`** verbatim: app smoke scripts grep `listening on 127.0.0.1:$PORT` [VERIFIED: ../sm-grzybyfunkcjonalne-app/scripts/smoke.sh:306, ../sm-summercmsio-app/scripts/smoke.sh:125; modules/surf/serve.go:245].
|
||||
- Migration state: new additive `lagoon.Pending(gdb, plugins) (int, error)` comparing each plugin's `Migrations()` IDs with the history table (`lagoon.Status` only returns applied IDs [VERIFIED: modules/lagoon/migrations.go:169-205]).
|
||||
- Drivers: read from config keys seen via grep in modules (`mail.driver`, `cache.driver`, `realtime.driver`, `search.driver`, `queue.work_in_serve`, `queue.queues.*`, `app.name`, `app.url`). [ASSUMED: grep-observed only; the planner opens each defining file before use]
|
||||
- Shutdown counters: `surf` adds an atomic in-flight/served counter next to `accessLog`. `conga` counts done/failed/in-flight in `Manager.runAttempt` [VERIFIED: modules/conga/worker.go:243] and exposes an additive `Worker.Stats()`. Uptime comes from the start time. `Worker.Stop` already escalates to `StopAndCancel` after the context deadline [VERIFIED: worker.go:172-192]. Report in-flight-at-stop as "returned to the queue" (River retries cancelled jobs).
|
||||
|
||||
### Anti-Patterns to Avoid
|
||||
- **Adding methods to `bonfire.Output`.** Add optional interfaces plus helpers instead (`bonfire.VerbosityOf(out)`, `bonfire.Process(out, items, op, fn)`, `bonfire.ItemProgress` for `Current(item)`/`Fail(item, err)`). No external implementers exist today (only `modules/bonfire/widgets.go` defines `Spinner(message string`), but D-02 asks for source compatibility.
|
||||
- **Starting a bubbletea program without `tea.WithInput(nil)` for spinners/progress.** With input enabled, bubbletea puts stdin in raw mode (Ctrl+C becomes a key, not SIGINT) and, if stdin is not a TTY, opens `/dev/tty` or fails with `bubbletea: error opening TTY` [VERIFIED: bubbletea v2 tea.go:1034-1044].
|
||||
- **Letting bubbletea own signals inside commands.** Use `tea.WithoutSignalHandler()` and `tea.WithContext(ctx)`; the command's own `signal.NotifyContext` stays authoritative (serve/conga/docs:serve/parity use it).
|
||||
- **Writing log lines to the terminal while a live view renders.** The view redraws over them. During a live view, the console handler must route lines through `Program.Println` (prints above the view [VERIFIED: tea.go:1412-1434]). It must never call it after the program has quit (`p.msgs` send would block).
|
||||
- **Logging URL paths in the request log.** See Pitfall 9.
|
||||
|
||||
## Don't Hand-Roll
|
||||
|
||||
| Problem | Don't build | Use instead | Why |
|
||||
|---|---|---|---|
|
||||
| Fan-out handler | custom MultiHandler | `slog.NewMultiHandler` | Clones records, honours Enabled, joins errors [VERIFIED: GOROOT multi_handler.go] |
|
||||
| JSON / logfmt | custom encoders | `slog.JSONHandler` / `slog.TextHandler` | Correct escaping, groups, time format |
|
||||
| Color downsampling / NO_COLOR | env checks around every Render | `colorprofile.Writer{Forward, Profile}` | lipgloss v2 always emits ANSI |
|
||||
| Box tables | the current `boxLine/boxRow` | `lipgloss/v2/table` with `lipgloss.NormalBorder()` (same ┌┬┐ glyphs) | Width-aware (East Asian, emoji); keep the non-TTY tab-separated path as is |
|
||||
| Spinner/progress animation | goroutine + `\r` loops | bubbles `spinner.MiniDot` (same frames as today's `spinnerFrames` [VERIFIED: widgets.go:11; SSU spinner.go]) + `progress.New(WithColors(cyan, green))` | Already SSU's look |
|
||||
| Arrow-key prompts | raw-mode key handling | `huh.NewSelect/NewConfirm/NewInput().EchoMode(huh.EchoModePassword)` | Accessible mode, aborts |
|
||||
| "Did you mean" | Levenshtein | `cmd.SuggestionsFor(arg)` (cobra, exported) | Cobra already formats suggestions into the error text (probe) |
|
||||
| GORM → slog | custom gorm logger | `logger.NewSlogLogger(diary.For("db"), logger.Config{SlowThreshold: 200*time.Millisecond, LogLevel: logger.Warn, IgnoreRecordNotFoundError: true, ParameterizedQueries: true})` | Exists in gorm v1.31.2; parameterized avoids secrets in SQL traces |
|
||||
| slog conformance | ad-hoc assertions | `testing/slogtest.TestHandler` | Catches group/attr bugs (it caught charm log) |
|
||||
|
||||
**Key insight:** most of `diary` is configuration and policy over stdlib slog. The genuinely new code is the component filter/proxy, the console renderer and the daily writer, and each is small and testable.
|
||||
|
||||
## Runtime / Deployed-State Impact
|
||||
|
||||
This is not a rename phase; the full Runtime State Inventory is omitted. Deployed state still changes:
|
||||
- **Files on disk:** daily logs appear under `<app>/storage/logs/` on every deployed instance once binaries are rebuilt (D-10). `/storage/` is already git-ignored in new apps [VERIFIED: internal/build/testdata/new/dot-gitignore.golden]. Hosts must have the directory writable (supervisor/systemd users on rome; Docker volumes); the channel must degrade, not crash.
|
||||
- **Config:** new `config/log.yaml` in `summer new` output and goldens. Existing apps rely on framework defaults (no file needed). `SUMMER_LOG__*` env vars use the `__` separator (`SUMMER_LOG__COMPONENTS__RIVER=warn`), **not** `SUMMER_LOG_*` as written in D-08 [VERIFIED: modules/compass/env.go:31-44].
|
||||
- **Generated code:** every app's committed `main.go` (generated) changes on the next `summer build`; `examples/hello/main.go` is committed generated code and must be regenerated.
|
||||
- **Deploy scripts:** production runs `./bin/<app>.new migrate` over non-interactive SSH [VERIFIED: ../sm-bm-app/DEPLOY.md:35; ../sm-demo-app/DEPLOY.md:359]. Any new confirmation on `migrate` must not block non-TTY runs (Pitfall 11).
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
### Pitfall 1: Global shorthands vs command flags
|
||||
**What goes wrong:** pflag panics (`unable to redefine "v" shorthand`) when a command's flag reuses `v`, `q` or `n` under another name; same-name local flags silently shadow persistent ones. [VERIFIED: pflag@v1.0.9 flag.go:898-917]
|
||||
**Status:** No command in summercms.go, the D-20 plugins or the user plugin declares any `Shorthand`, and none uses the names `verbose/quiet/ansi/no-ansi/no-interaction/env/format/all`. [VERIFIED: grep across repos]
|
||||
**How to avoid:** `NewRoot` validates command flags against reserved names/shorthands and returns an error (not a panic) naming the command. `list` owns `--format/--all` locally.
|
||||
|
||||
### Pitfall 2: The docs checker's flag model
|
||||
`internal/docsite.commandWord` assumes the root has only `--help/-h` and that "any other --name or two-character -x consumes the next token". With bool globals, docs lines like `bin/acme -v serve` would be misread (it would take `serve` as the value of `-v`). Update `commandWord` to know the bool globals (`-v -q -n --ansi --no-ansi --quiet --verbose --no-interaction`) and the value-taking `--env`. [VERIFIED: internal/docsite/check_commands.go:96-110]
|
||||
Also add `list` and `help` to `docsCommands()` (they come from bonfire, not `toolCommands()`), and keep the generated-main lines in the `commands = append(commands, pkg.Ctor(app...` shape that `TestDocsCommandsMirrorGeneratedMain` parses with a regex. [VERIFIED: cmd/summer/docs_test.go:415-470]
|
||||
|
||||
### Pitfall 3: Scheduler `Call` and process-global verbosity
|
||||
`bonfire.Call` builds a fresh root per call (used by the scheduler through `Catalog`) [VERIFIED: call.go:17-34]. Global flags will exist there too, but the `OnGlobals` hook that mutates `diary` must be installed only on the binary's main root, not in `Call`. Otherwise a scheduled entry with `-v` changes logging for the whole process. `Call` output is non-TTY with empty stdin, so prompts take defaults and confirmations must treat it as non-interactive.
|
||||
|
||||
### Pitfall 4: `compass` map keys and env strings
|
||||
- koanf uses `.` as delimiter, so component names must not contain dots (`acme.blog` would nest). Use plugin codes like `journal`, `oauthserver`. Document it.
|
||||
- Env overrides arrive as strings: `SUMMER_LOG__CHANNELS__DAILY__DAYS=30` → `"30"`, `SUMMER_LOG__CHANNELS__STACK__CHANNELS=console,daily` → a string, not a list. diary's parser must accept both (strconv for ints, comma split for lists). The `summer new` http.yaml stub warns that env strings are rejected for numeric keys there [VERIFIED: internal/build/stubs/new.tmpl]; diary must not copy that strictness.
|
||||
|
||||
### Pitfall 5: Loggers captured before setup
|
||||
`sunscreen.InstallDefault` runs first today, so later `slog.Default()` calls are already redacted. With diary, `main` must call `diary.Setup` (or an early `diary.Bootstrap(stderr)` before config) **before** `compass`/`party`, because config errors and plugin `Register` logs happen there. The proxy pattern (Pattern 1) removes ordering bugs for `diary.For`. Calls to `slog.Default()` made before setup still get the bootstrap handler.
|
||||
|
||||
### Pitfall 6: GORM bypasses slog entirely
|
||||
`lagoon` opens GORM with `&gorm.Config{}` [VERIFIED: modules/lagoon/connection.go:57], so GORM uses `logger.Default = New(log.New(os.Stdout, "\r\n", log.LstdFlags), Config{SlowThreshold: 200 * time.Millisecond, LogLevel: Warn, IgnoreRecordNotFoundError: false, Colorful: true})` [VERIFIED: gorm@v1.31.2 logger/logger.go:76-81]. That is colored raw text on **stdout**, including every "record not found". Route it through `logger.NewSlogLogger` with `ParameterizedQueries: true` (SQL values can carry tokens/PII). Setting `IgnoreRecordNotFoundError: true` changes output and is intended.
|
||||
|
||||
### Pitfall 7: Handler concurrency and the shared terminal
|
||||
slog handlers are called concurrently (HTTP handlers, River workers). The console handler holds a `*sync.Mutex` shared across `WithAttrs/WithGroup` clones and writes each record in one `Write`. The daily writer locks around rotate+write. Bonfire output (stdout) and logs (stderr) share one terminal: the live-view rule in Anti-Patterns applies. Run `go test -race ./modules/diary/... ./modules/bonfire/...` (CGO/gcc available here).
|
||||
|
||||
### Pitfall 8: Daily files in production
|
||||
The path resolves against the app root. Unwritable dirs (read-only container FS) → warn + disable channel. Retention deletes only files matching `<base>-YYYY-MM-DD<ext>`, never other files in the directory. File mode 0o640 because logs carry user identifiers.
|
||||
|
||||
### Pitfall 9: D-11 vs the request log's security contract
|
||||
D-11 asks for "method, **path**, status, duration, request id" and "shows by default". Today:
|
||||
- the URL path is **deliberately never logged** because share/invitation tokens travel in paths, including for 404/405 before routing (documented in `docs/services/logging.md` "Request log" and `modules/surf/accesslog.go:42-60`);
|
||||
- the attributes are `slog.String("method", req.Method)`, `slog.String("pattern", entry.pattern)`, status, `duration_ms`, optional `client_gone` [VERIFIED: accesslog.go:75-76];
|
||||
- the log is **off by default** (`const accessLogKey = "http.access_log"`) [VERIFIED: accesslog.go:14, 38].
|
||||
**Recommendation:** keep `pattern` instead of `path`. Add `request_id` from `X-Request-Id`, trimmed and capped at 128 bytes the way `surf/errorpage.go` already does (`errorPageMaxRequestID = 128`). Turning the log on by default needs a user decision: either flip `http.access_log` default to true with `components.http` as the silencer, or keep the key and document it. This is in Open Questions; do not silently log paths.
|
||||
|
||||
### Pitfall 10: `-q` must not swallow data
|
||||
Scripts read `admin:token:create` output via `out.Println(plain)` [VERIFIED: modules/cabana/token_commands.go:117], `key:generate` via `out.Success(key)`, `herald:vapid-generate` env lines. Artisan's `-q` silences everything. Recommendation: under `-q`, suppress `Info/Success/Warning`, banners, spinners and progress; keep `Println/Printf/Write` (data), `--json` output and `Error`. For `key:generate`, print the bare key when stdout is not a TTY (or add `--json`). This is in Open Questions.
|
||||
|
||||
### Pitfall 11: `--force`/`--pretend` semantics already taken, and deploys
|
||||
- In the importers and `pg:update-currencies`, `--force` already means "replace existing data"/"force update", not "skip confirmation" [VERIFIED: plugin console files]. Do not repurpose it.
|
||||
- Importers already have `--dry-run`. Adding `--pretend` aliases is additive but optional; `--pretend` fits `migrate`/`migrate:rollback` (Artisan name).
|
||||
- Artisan's ConfirmableTrait asks in production. Production deploys run `migrate` over non-TTY SSH, so a production confirmation that fails without `--force` on non-TTY would break every deploy. Recommendation: confirm only on an interactive TTY. Non-TTY keeps today's behaviour, so the change stays non-breaking (D-20); `--force` skips the prompt.
|
||||
|
||||
### Pitfall 12: Prompts and piped stdin
|
||||
`admin:create`/`admin:reset-password` read the password from piped stdin through `out.Secret` (test "WR-13" in `modules/cabana/commands_test.go:241-262`). `Secret` reads a plain line when stdin is not a terminal [VERIFIED: prompts.go:88-106]. Keep the line path whenever stdin is not a TTY. Use huh only when both stdin and stdout are TTYs and `-n` is unset. With `-n` and no default (Secret), fail with a hint ("pipe the password on stdin").
|
||||
|
||||
### Pitfall 13: Errors printed twice and odd exit codes
|
||||
- `flare` returns sentinel errors after printing its own message: `errGenerateFailed = errors.New(GenerateVAPIDKeysCommandName + ": failed")`, `errTestPushFailed = ...` [VERIFIED: modules/flare/commands.go:20-21]. `docs:build --check` does the same (`errors.New(cmd + " failed")`). Add `bonfire.ErrReported` (and `bonfire.Reported(err)`) so the renderer exits 1 without a second message. Migrate those sites.
|
||||
- `summer dev` returns `ctx.Err()` on Ctrl+C, and `summer main` prints it and exits 1. Map `context.Canceled` from a signal to a clean shutdown (exit 0 after the report, or 130). Decide one convention for dev/serve/queue:work.
|
||||
- Usage errors → exit 2: wrap `positionalArgs` failures and `root.SetFlagErrorFunc` errors in `bonfire.UsageError`. Detect `unknown command` by prefix (fang's own approach). Turn cobra's appended "Did you mean this?" text into the `Try:` hint.
|
||||
|
||||
### Pitfall 14: `summer dev` starts the binary with no arguments
|
||||
`startBin` runs `exec.Command(binPath)` [VERIFIED: internal/dev/watch.go:201-210]. A bare app binary prints help (today) or the list (after this phase), then exits, so `summer dev` never serves. D-07 asks for a shutdown report for `dev`. Recommendation: start `bin/<app> serve` and forward `-v/-q`. This is a behaviour fix and must be confirmed (Open Questions).
|
||||
|
||||
### Pitfall 15: Duplicate-name rejection can stop apps from starting
|
||||
Each app has exactly one plugin appending `centrifugo.Commands` (demo plugin, bm plugin, the Płytarium domain plugin, which also appends `flare.Commands`) [VERIFIED: grep]. No duplicates exist today. After the change, a second plugin appending the same shared command would make `NewRoot` fail at boot. Plan 2 verification: build each app in the meta repo and run `bin/<app> list`.
|
||||
|
||||
### Pitfall 16: Cross-repo ordering and concurrency
|
||||
- Plugins compile against the working framework through `replace ... => ../summercms.go` in their go.mod/go.work, but tagged consumers ignore dependency replaces. Plugin commits that use `Examples`, `Hidden` or `diary` need a framework release tag before their `require summercms vX` bump is meaningful. Tagging needs the user's go-ahead. [VERIFIED: sibling go.mod files]
|
||||
- Phase 19.5 is executing in `sm-user-plugin` (canonical checkout `../fonoteka.go/plugins/golem15/user`, per the 19.4/19.5 gate scripts) and `sm-sociallogin-plugin` right now [VERIFIED: STATE.md; scripts/check-phase19.5.sh:65-66]. Sweep the user plugin after 19.5 lands or coordinate.
|
||||
|
||||
## Code Examples
|
||||
|
||||
### Generated main after this phase (shape; keep the `commands = append(commands, …(app…` lines)
|
||||
```go
|
||||
// Source: derived from internal/build/build.go:94-151 (current template)
|
||||
func run(args []string, out io.Writer) int {
|
||||
g := bonfire.PeekGlobals(args)
|
||||
diary.Bootstrap(os.Stderr, g) // redacting console until config is read
|
||||
cfg, err := compass.Open(compass.Options{Dir: "config", Env: g.Env})
|
||||
if err != nil {
|
||||
return bonfire.Fail(os.Stderr, err, g) // styled block, exit 1
|
||||
}
|
||||
if err := diary.Setup(cfg, diary.Options{Globals: g}); err != nil {
|
||||
return bonfire.Fail(os.Stderr, err, g)
|
||||
}
|
||||
app := backpack.New(cfg)
|
||||
plugins, err := party.Activate(app, PluginIDs)
|
||||
// ... unchanged command assembly ...
|
||||
root, err := bonfire.NewRoot("acme", commands, out,
|
||||
bonfire.WithVersion(bonfire.BuildVersion()), bonfire.OnGlobals(diary.Apply))
|
||||
if err != nil {
|
||||
return bonfire.Fail(os.Stderr, err, g)
|
||||
}
|
||||
return bonfire.Execute(context.Background(), root, args) // 0, 1 or 2
|
||||
}
|
||||
```
|
||||
Keep `func main() { os.Exit(run(os.Args[1:], os.Stdout)) }`. The `internal/build` goldens (`main.go.golden`) are updated with `go test ./internal/build -run Golden -update` (flag defined at `internal/build/new_golden_test.go:13`).
|
||||
|
||||
### Porting SSU's spinner to bubbletea v2 (API translation)
|
||||
```go
|
||||
// Source: SSU internal/cli/tui/spinner.go (v1) translated per charm v2 upgrade guides
|
||||
func (m spinnerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case progressMsg: // {Done, Total, Item, Err}
|
||||
m.done, m.total, m.current = msg.Done, msg.Total, msg.Item
|
||||
return m, nil
|
||||
case doneMsg:
|
||||
m.err = msg.Err
|
||||
return m, tea.Quit
|
||||
case spinner.TickMsg:
|
||||
var cmd tea.Cmd
|
||||
m.spin, cmd = m.spin.Update(msg)
|
||||
return m, cmd
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m spinnerModel) View() tea.View { // v1: View() string
|
||||
line := fmt.Sprintf("%s %s %d/%d", m.spin.View(), m.label, m.done, m.total)
|
||||
if m.current != "" {
|
||||
line += " " + styles.Muted.Render(m.current+"…")
|
||||
}
|
||||
return tea.NewView(line)
|
||||
}
|
||||
|
||||
// Run with: tea.NewProgram(model, tea.WithInput(nil), tea.WithOutput(w),
|
||||
// tea.WithoutSignalHandler(), tea.WithContext(ctx))
|
||||
```
|
||||
Progress bar: `progress.New(progress.WithColors(lipgloss.Color("#00afaf"), lipgloss.Color("#00af00")), progress.WithoutPercentage())` (cyan→green, matching today's `progressColor` 256-color codes 37/36/34 [VERIFIED: widgets.go:133-142]); `bar.SetWidth(40)` replaces SSU's `bar.Width = 40`.
|
||||
|
||||
### Error with hint
|
||||
```go
|
||||
// Source: design for D-18/D-19
|
||||
return bonfire.WithHint(err,
|
||||
"summer make:command needs an application root (summer.yaml).",
|
||||
"cd ..; {bin} make:command acme.blog:publish") // {bin} → "bin/<root name>" or "summer"
|
||||
```
|
||||
Known-failure sites for hints: `lagoon: database.dsn is empty (set SUMMER_DATABASE__DSN)` [VERIFIED: modules/lagoon/connection.go:25] → Try: `cp .env.example .env` / `docker compose up -d --wait`; `lagoon: ping postgres:` (unreachable) → Try: `docker compose up -d --wait`; `build: no summer.yaml found from <dir>` [VERIFIED: internal/build/manifest.go:215] → Try: `cd <app>; summer <cmd>`. For the wrong directory: `compass` silently returns an empty config when `config/` is missing [VERIFIED: config.go:237-242]. So when a command fails **and** `config/` is absent in CWD **and** a parent holds `summer.yaml`, decorate the error with `cd <rel>; bin/<app> <args>` (D-19: message only). The generated main cannot import `internal/build`, so the parent-walk lives in `bonfire` or `compass`.
|
||||
|
||||
### Probe evidence (quoted)
|
||||
charm log v2 against `testing/slogtest` (abridged):
|
||||
```
|
||||
missing key "time": this test expects slog.TimeKey, slog.LevelKey and slog.MessageKey
|
||||
unexpected key "": a Handler should ignore an empty Attr
|
||||
missing key "c": a Handler should inline the Attrs of a group with an empty key
|
||||
unexpected key "a": a Handler should handle the WithGroup method
|
||||
missing group "G": a Handler should handle the WithGroup method
|
||||
missing group "G": a Handler should handle multiple WithGroup and WithAttr calls
|
||||
FAIL probe 0.004s
|
||||
```
|
||||
(The "time" lines come from `ReportTimestamp: false`; the group and empty-attr failures are real.)
|
||||
|
||||
Cobra global flags:
|
||||
```
|
||||
["-vvv" "migrate"] => migrate v=3 q=false n=false env="" args=[]
|
||||
["migrate" "-vv" "-q" "-n" "--env" "staging" "x"] => migrate v=2 q=true n=true env="staging" args=[x]
|
||||
["-v" "serve" "--addr" ":1" "-vv"] => serve(passthrough) v=0 args=["-v" "--addr" ":1" "-vv"]
|
||||
["--env" "staging" "migrate"] => migrate v=0 q=false n=false env="staging" args=[]
|
||||
["migrat"] => ERR: unknown command "migrat" for "app"\n\nDid you mean this?\n\tmigrate
|
||||
[] => ROOT list v= 0
|
||||
```
|
||||
BuildInfo:
|
||||
```
|
||||
main="v1.4.0"
|
||||
dep example.com/fw version="v0.3.2" replace={"../fw" "(devel)"}
|
||||
--- go.work mode
|
||||
main="v1.4.0+dirty"
|
||||
dep example.com/fw version="(devel)" replace=<nil>
|
||||
```
|
||||
Binary size (stripped, `-trimpath -s -w`): cobra+x/term baseline 2,527,495 B; + lipgloss/table, bubbletea, bubbles spinner+progress, huh, fang 4,649,223 B (≈ +2.1 MB).
|
||||
|
||||
## Command Inventory (sweep input, D-20/D-21)
|
||||
|
||||
Legend: **D** description, **L** long help, **E** examples (no command has E today; the field does not exist yet). Checklist columns hold proposed outcomes for the plan to confirm or decline: Force = `--force` confirmation, Pretend = `--pretend`/dry-run, Json = `--json`, Prog = progress/table for bulk, Res = result line + exit code.
|
||||
|
||||
### summercms.go: `summer` tool (`cmd/summer`, 18 own + 15 delegates)
|
||||
|
||||
| Command | File | D | L | Notes / checklist |
|
||||
|---|---|---|---|---|
|
||||
| build | main.go:51 | ✓ | – | Res ✓ (`built X in Y`); Prog: spinner around `go build` on TTY |
|
||||
| make:plugin, make:model (`--no-migration`), make:migration, make:command, make:job, make:admin-controller | main.go:65-211 | ✓ | – | Res: `created <path>` lines; D-19 hints for wrong dir (folded todo); E needed |
|
||||
| plugin:add `<dir>` | main.go:269 | ✓ | – | Res ✓ |
|
||||
| new `<name>` (`--framework --no-plugin --no-git`) | main.go:296 | ✓ | – | Writes `config/log.yaml` (D-10); golden update |
|
||||
| dev | main.go:255 | ✓ | – | Long-running: summary + shutdown report (D-07); Pitfall 14; output via raw `fmt.Fprintf` in internal/dev/watch.go:79-150 |
|
||||
| parity:proxy/record/replay/broadcasts/upstream | parity.go:19-104 | ✓ | – | Many `(true/false)` string flags: show defaults; E needed |
|
||||
| docs:build (`--check`), docs:sync, docs:serve | docs.go:25-99 | ✓ | – | `ErrReported` for problem reports; docs:serve long-running |
|
||||
| 15 delegates (migrate, migrate:rollback, migrate:status, serve, queue:work, queue:clear, schedule:run, route:list, key:generate, admin:create, admin:reset-password, admin:token:create/list/revoke/scopes) | runtime.go:27-43 | ✓ | ✓ (generated) | Passthrough; add E per row; `deferred:purge` and `websockets:*` are not delegated (by design, documented) |
|
||||
|
||||
### summercms.go: application binary (framework, 19)
|
||||
|
||||
| Command | File | D | L | Force | Pretend | Json | Prog | Res |
|
||||
|---|---|---|---|---|---|---|---|---|
|
||||
| migrate | lagoon/commands.go:26 | ✓ | – | TTY+production only (Pitfall 11) | add `--pretend` | – | per-plugin progress | ✓ "migrations applied" → count |
|
||||
| migrate:rollback `--plugin` | lagoon/commands.go:39 | ✓ | – | TTY confirm | `--pretend` | – | – | ✓ |
|
||||
| migrate:status | lagoon/commands.go:60 | ✓ | – | – | – | add | table ✓ | – |
|
||||
| deferred:purge `--days` | lagoon/commands.go:82 | ✓ | – | TTY confirm | add (reports counts) | – | progress | ✓ |
|
||||
| key:generate | lagoon/keygen.go:26 | ✓ | – | – | – | bare key on non-TTY / `--json` | – | ✓ |
|
||||
| queue:work `--queue`* | conga/commands.go:31 | ✓ | – | – | – | – | summary + shutdown | – |
|
||||
| schedule:run `--once` | conga/commands.go:45 | ✓ | – | – | – | – | summary + shutdown | ✓ (once) |
|
||||
| queue:clear `[queue]` | conga/commands.go:64 | ✓ (ends with "."; fix) | – | TTY confirm | – | – | – | ✓ "Cleared N jobs" |
|
||||
| serve `--addr` | surf/serve.go:30 | ✓ | – | – | – | – | summary + shutdown | – |
|
||||
| route:list | surf/routelist_command.go:18 | ✓ | – | – | – | add | table ✓ | – |
|
||||
| admin:create `--email --password --login --superuser --role` | cabana/commands.go:21 | ✓ | – | – | – | – | – | ✓ |
|
||||
| admin:reset-password `<identifier>` | cabana/commands.go:35 | ✓ | – | – | – | – | – | ✓ |
|
||||
| admin:token:create `<identifier> --name --scope* --expires=90d` | cabana/token_commands.go:21 | ✓ | – | – | – | add | – | ✓ (token on stdout) |
|
||||
| admin:token:list `[identifier] --revoked` | token_commands.go:36 | ✓ | – | – | – | add | table ✓ | – |
|
||||
| admin:token:revoke `<id>` | token_commands.go:49 | ✓ | – | – | – | – | – | ✓ |
|
||||
| admin:token:scopes | token_commands.go:59 | ✓ | – | – | – | add | table ✓ | – |
|
||||
| websockets:health | lighthouse/centrifugo/commands.go:31 | ✓ | – | – | – | add | – | exit 1 on failure |
|
||||
| websockets:generate-vapid-keys `--update --show-current` | flare/commands.go:50 | ✓ | – | – | – | – | – | `ErrReported` |
|
||||
| websockets:test-push `<user_id> --show-config` | flare/commands.go:212 | ✓ | – | – | – | – | – | `ErrReported`; Confirm default true under `-n` |
|
||||
| examples: greeter:hello, blog:publish | examples/hello/plugins/greeter/plugin.go:80; docs/examples/blog/console/publish.go:18 | ✓ | – | – | – | – | – | include in the framework coverage check |
|
||||
|
||||
### Shared plugins (D-20)
|
||||
|
||||
| Repo | Commands (file) | Notes |
|
||||
|---|---|---|
|
||||
| sm-paymentgateway-plugin | pg:cancel-created-payments, pg:cancel-sent-payments, pg:cancel-placed-orders, pg:finish-orders (bulk writers, **scheduled daily**), pg:update-currencies (`--force` = force update; **every 10 min**), paymentgateway:import-winter (`--settings --force --dry-run`) (console/*.go; schedule.go:17-21) | Pretend candidates for the bulk writers; run under scheduler `Call` (non-interactive); 16 slog sites |
|
||||
| sm-oauthserver-plugin | oauthserver:prune (scheduled daily), oauthserver:rotate-keys (`--prune`, scheduled interval), oauthserver:import-winter (`--file --key-dir --settings --uploads-dir --force --dry-run`) | Pretend for prune |
|
||||
| sm-journal-plugin | journal:export-posts (`--path --dry-run`), journal:import-posts (`--path --force --dry-run`) | Progress for bulk; `--force` = update existing |
|
||||
| sm-sitemap-plugin | sitemap:import `<file>` (`--theme --append`), sitemap:import-winter (`--definitions --settings --theme --map* --page-url* --force --dry-run`) | |
|
||||
| sm-chat-plugin / sm-chatchannels-plugin / sm-forum-plugin | chat:/chatchannels:/forum:rewrite-context-types (idempotent bulk rewrite; description ends with ".") | Pretend candidate |
|
||||
| sm-chatvideo-plugin | chatvideo:reconcile-sessions (console/reconcile.go:14) | |
|
||||
| sm-faq-plugin | faq:import-winter (`--file --force --dry-run`) | |
|
||||
| sm-menu-plugin | menu:import-winter (`--menus --entries --map* --force --dry-run`) | |
|
||||
| sm-herald-plugin | herald:vapid-generate (prints env lines with a private key) | Data on stdout under `-q` |
|
||||
| sm-pgstripe-plugin | pgstripe:import-winter (`--settings --app-key --force --dry-run --webhook-secret-target`) | |
|
||||
| sm-user-plugin (`../fonoteka.go/plugins/golem15/user`) | user:token:create `<email>` (`--name=api --scopes=read,write --expires`), user:token:list, user:token:revoke `<id>`, user:require-password-change `<email>`, user:backfill-handles (bulk) | Json for token create/list; descriptions carry planning IDs ("(D-15)", "(D-21)"): remove them |
|
||||
|
||||
Total plugin commands: 26 (paymentgateway 6, user 5, oauthserver 3, journal 2, sitemap 2, one each in chat, chatchannels, chatvideo, faq, forum, herald, menu, pgstripe). **No plugin command has a long help or examples today.** Descriptions mix trailing periods and capitalised verbs ("Cancels…", "Updates…"); normalise to imperative, no trailing period.
|
||||
|
||||
**Apps that need pointer bumps after each plugin commit** [VERIFIED: app .gitmodules]:
|
||||
- sm-user-plugin → sm-bm-app, sm-demo-app, sm-grzybyfunkcjonalne-app, sm-summercmsio-app, fonoteka.go
|
||||
- sm-journal-plugin → sm-demo-app, sm-grzybyfunkcjonalne-app, sm-summercmsio-app
|
||||
- sm-sitemap-plugin, sm-menu-plugin, sm-faq-plugin → sm-demo-app, sm-grzybyfunkcjonalne-app
|
||||
- sm-paymentgateway-plugin, sm-pgstripe-plugin, sm-oauthserver-plugin → sm-demo-app
|
||||
- sm-chat, sm-chatchannels, sm-chatvideo, sm-forum, sm-herald → no app in `..` mounts them (nothing to bump)
|
||||
|
||||
Not in D-20 but carrying commands: `sm-golem-plugin`, `sm-feedback-plugin` (`ImportSettings`, mounted in sm-demo-app/fonoteka.go). App-local plugins (demo:seed/reset, the Płytarium domain plugin, bm) are out of scope.
|
||||
|
||||
## Docs and Presentation Locations
|
||||
|
||||
| What | Path |
|
||||
|---|---|
|
||||
| Logging page (rewrite: diary, config/log.yaml, channels, components, daily, request log) | `docs/services/logging.md` |
|
||||
| Console pages | `docs/console/introduction.md` ("Getting help" → list/help/global options), `writing-commands.md` (Examples, Hidden, duplicates now rejected, hints, ErrReported, named loggers, -q/-v semantics), `setup-and-maintenance.md` (serve/queue:work summary, new `--json/--pretend`), `scaffolding.md` (`summer new` writes config/log.yaml; dev report), `utilities.md` |
|
||||
| Configuration | `docs/services/configuration.md`, `docs/setup/configuration.md` (log keys, `SUMMER_LOG__*`) |
|
||||
| Upgrade notes | `docs/setup/upgrading.md` (daily logs on, duplicate names fail, GORM log routing, request log) |
|
||||
| Module READMEs | new `modules/diary/README.md`; `bonfire`, `sunscreen` (shim wording: `InstallDefault` is the only install function; `sunscreen.Install` in the roadmap does not exist), `surf`, `conga`, `lagoon`, `cabana`, `flare`, `lighthouse` |
|
||||
| Root module table | `README.md` (new diary row; bonfire row at line 97, sunscreen at 117) |
|
||||
| Example app | `examples/hello/main.go` (committed generated main; separate workspace module) |
|
||||
| Landing site | `../sm-summercmsio-app/vue-summercmsio-app/i18n/locales/en.json` (`features.console`, `winter.concepts.commands`), `app/components/FeaturesSection.vue`, `TerminalCard.vue`, `app/data/terminal.json` (checked by `../sm-summercmsio-app/terminal_check_test.go`) |
|
||||
| Roadmap presentation | Lives in **`../sm-demo-app`**, not the summercms.io app: `plugins/demo/content/ROADMAP.md` (snapshot, refresh with `scripts/sync-roadmap.sh`, `--check` fails when stale), curated text `plugins/demo/roadmap/curated.go` and `names.go` (sm-demo-app README "Roadmap deck") |
|
||||
|
||||
## State of the Art
|
||||
|
||||
| Old approach | Current approach | When changed | Impact |
|
||||
|---|---|---|---|
|
||||
| `github.com/charmbracelet/{lipgloss,bubbletea,bubbles,huh,fang}` v1 | `charm.land/*/v2` vanity paths | lipgloss v2 GA / bubbletea v2 GA in 2026 | SSU code must be translated |
|
||||
| lipgloss Renderer downsampling | `Render()` emits full ANSI; `colorprofile.Writer`/`lipgloss.Fprintln` downsample | lipgloss v2 | Apply `--no-ansi` at the writer |
|
||||
| `View() string`, `tea.KeyMsg` struct | `View() tea.View`, `tea.KeyPressMsg` | bubbletea v2 | Port all SSU models |
|
||||
| `progress.WithGradient/WithDefaultGradient`, `.Width` field | `WithColors/WithDefaultBlend`, `SetWidth()` | bubbles v2 | |
|
||||
| Hand-rolled multi handler | `slog.NewMultiHandler` | Go 1.26 | |
|
||||
| Binary version via `-ldflags -X` | `debug.ReadBuildInfo().Main.Version` VCS stamp | Go 1.24 | No ldflags in `summer build` needed |
|
||||
|
||||
## Assumptions Log
|
||||
|
||||
| # | Claim | Section | Risk if wrong |
|
||||
|---|---|---|---|
|
||||
| A1 | Config keys for the summary (`mail.driver`, `cache.driver`, `realtime.driver`, `search.driver`, `queue.work_in_serve`, `queue.queues.*`) are the canonical ones | Pattern 6 | Wrong summary lines; planner reads the defining files first |
|
||||
| A2 | River retries jobs cancelled by `StopAndCancel`, so in-flight-at-stop is "returned to the queue" | Pattern 6 | Report wording wrong; verify against River docs/tests |
|
||||
| A3 | koanf's `LoadSection`/unmarshal is weakly typed for the `log` subtree | Pitfall 4 | Parse diary config manually from `Lookup` to avoid depending on it |
|
||||
| A4 | Verbosity mapping (-v INFO floor, -vv DEBUG, -vvv DEBUG+source+SQL) | Pattern 2 | User may want a different mapping (D-04 only fixes -q and "debug detail") |
|
||||
| A5 | `-q` keeps data output (Println/--json/Error) | Pitfall 10 | Deviates from Artisan; needs confirmation |
|
||||
| A6 | Logfmt (`TextHandler`) as the `format: auto` non-TTY default | Pattern 2 | D-05 allows JSON or logfmt; logfmt keeps today's text and smoke greps |
|
||||
| A7 | Daily file dates use the process local time zone (not `app.timezone`) | Pattern 3 | File boundaries differ from the scheduler's |
|
||||
|
||||
## Open Questions (RESOLVED)
|
||||
|
||||
1. **Request log path and default (D-11 vs the security contract).** What we know: paths are never logged by design, and the log is off by default via `http.access_log`. Recommendation: log `pattern` + `request_id` (no path), and ask whether to flip `http.access_log` to default-on with `components.http` as the silencer. RESOLVED: D-23.
|
||||
2. **`-q` semantics for data output.** Recommendation: keep data/`--json`/errors (Pitfall 10). RESOLVED: D-27.
|
||||
3. **Production confirmation on `migrate`/destructive commands.** Recommendation: TTY-only confirmation, non-TTY unchanged, `--force` skips (Pitfall 11). RESOLVED: D-28.
|
||||
4. **`summer dev` should run `serve`.** Recommendation: yes, with forwarded verbosity (Pitfall 14). RESOLVED: D-25.
|
||||
5. **Exit code after a signal-initiated graceful shutdown** (0 or 130). Recommendation: 0 when the shutdown report has no drain errors. RESOLVED: D-29.
|
||||
6. **`sm-golem-plugin` / `sm-feedback-plugin` commands** are outside the D-20 list. Include them in the sweep or leave them. RESOLVED: D-24.
|
||||
7. **Framework release tag for plugin `require` bumps.** Plugin commits using new APIs compile via replace, but tagged consumers need a framework tag; the user's go-ahead is needed for tag and push. RESOLVED: D-26.
|
||||
8. **Selector widget scope.** No command needs multi-select today. Recommendation: huh Select for `Choice`; port SSU's SelectorModel only when a command needs it (keeps plan 2 lean). RESOLVED: D-30.
|
||||
|
||||
## Environment Availability
|
||||
|
||||
| Dependency | Required by | Available | Version | Fallback |
|
||||
|---|---|---|---|---|
|
||||
| Go toolchain | everything | ✓ | go1.27.2 (go.mod `go 1.27.0`) | — |
|
||||
| proxy.golang.org / charm.land | `go get` charm modules | ✓ | reachable this session | — |
|
||||
| gcc / CGO | `go test -race` | ✓ | CGO_ENABLED=1 | skip -race |
|
||||
| Docker | Postgres-backed serve/migrate tests (pending count) | client 29.9.1 present; daemon access not confirmed | — | `-short` tests; run Docker stage via `sudo -u golem` per CLAUDE.md |
|
||||
| Node/npm/pnpm | landing site tests (`../sm-summercmsio-app/vue-summercmsio-app`) | ✓ | node v22.23.3, npm 12.2.1, pnpm 12.14.0 | — |
|
||||
| SSU source | port reference | ✓ | `/media/nvme/dev/pxpx/ssu` @ 0061e3d (2026-09-11) | — |
|
||||
|
||||
No blocking gaps.
|
||||
|
||||
## Validation Architecture
|
||||
|
||||
### Test Framework
|
||||
| Property | Value |
|
||||
|---|---|
|
||||
| Framework | Go `testing` (+ `testing/slogtest`), golden files with an `-update` flag (pattern: `internal/build/new_golden_test.go:13`) |
|
||||
| Config file | none (go test) |
|
||||
| Quick run command | `go test -short ./modules/bonfire/... ./modules/diary/... ./modules/sunscreen/... ./internal/build/... ./internal/docsite/... ./cmd/summer/...` |
|
||||
| Full suite command | `go vet ./... && go test ./... && (cd examples/hello && go test ./...)` |
|
||||
| Phase gate | `scripts/check-phase19.8.sh --all` (new; pattern of `scripts/check-phase19.4.sh`: per-repo stages, required test names, fail on skip/zero tests, `NO_COLOR=1`, additive-export check for plugin repos) |
|
||||
|
||||
### Phase Requirements → Test Map
|
||||
| Ref | Behavior | Type | Automated command | Exists? |
|
||||
|---|---|---|---|---|
|
||||
| SC1 | diary console/daily handlers pass slogtest | unit | `go test ./modules/diary -run TestSlogtest` | ❌ Wave 0 |
|
||||
| SC1 | component floors, verbosity changes, `-q` on console only | unit | `go test ./modules/diary -run 'TestComponent|TestVerbosity'` | ❌ |
|
||||
| SC1 | daily rotation + retention with injected clock; unwritable dir degrades | unit | `go test ./modules/diary -run TestDaily` | ❌ |
|
||||
| SC1 | `SUMMER_LOG__…` env strings (`"30"`, `"console,daily"`) parse | unit | `go test ./modules/diary -run TestConfigEnv` | ❌ |
|
||||
| SC1 | every channel redacts (`sunscreen`), `InstallDefault` still works | unit | `go test ./modules/diary ./modules/sunscreen -run 'TestRedact|TestInstallDefault'` | partial (sunscreen ✓) |
|
||||
| SC1 | River/GORM/plain `slog.Default()` records route through diary with components | unit | `go test ./modules/conga ./modules/lagoon -short -run TestLogger` | ❌ |
|
||||
| SC2 | `PeekGlobals` table (stops at `--`, `-vvv`, `--env=x`) and cobra persistent flags agree | unit | `go test ./modules/bonfire -run 'TestPeekGlobals|TestGlobalFlags'` | ❌ |
|
||||
| SC2 | reserved shorthand/name collision returns an error (no panic) | unit | `go test ./modules/bonfire -run TestReservedFlags` | ❌ |
|
||||
| SC2 | `Call` does not trigger `OnGlobals` | unit | `go test ./modules/bonfire -run TestCallNoGlobalsHook` | ❌ |
|
||||
| SC3 | list golden (namespaces, hidden, `--all`), help golden (Description/Usage/Arguments/Options defaults/Examples), passthrough help unchanged | golden | `go test ./modules/bonfire -run 'TestListGolden|TestHelpGolden'` (`-update` to rewrite) | ❌ |
|
||||
| SC3 | `list --format=json` schema | unit | `go test ./modules/bonfire -run TestListJSON` | ❌ |
|
||||
| SC3 | coverage: framework commands documented (fails) | unit | `go test ./cmd/summer -run TestCommandsDocumented` | ❌ |
|
||||
| SC3 | duplicate names rejected with the name in the error | unit | `go test ./modules/bonfire -run TestDuplicateNames` | ❌ |
|
||||
| SC3 | docs checker accepts `bin/acme -v serve`, `summer list`, `bin/acme help migrate` | unit | `go test ./internal/docsite -run TestCommandWord` + `go test ./cmd/summer -run TestDocsTree` | partial |
|
||||
| D-18 | error block golden; exit 1 vs 2; `ErrReported` silent; hint `{bin}` substitution; `-v` chain | golden/unit | `go test ./modules/bonfire -run 'TestErrorGolden|TestExitCodes'` | ❌ |
|
||||
| D-03 | widget matrix: TTY vs non-TTY vs `-q` vs `--no-ansi` vs `-n` (existing non-TTY fallbacks unchanged) | unit | `go test ./modules/bonfire -run 'TestNonTTY|TestWidgetMatrix'` | partial (non-TTY ✓) |
|
||||
| D-03 | ported models: Update/View as pure functions (no terminal) | unit | `go test ./modules/bonfire -run TestLiveModels` | ❌ |
|
||||
| D-03 | piped Secret still reads stdin; `-n` Secret without default fails with hint | unit | `go test ./modules/bonfire ./modules/cabana -short -run 'TestSecret|TestAdminPasswordWithoutFlag'` | partial |
|
||||
| SC4 | serve summary lines (pure builder like `serveBanner`), `listening on` record kept | unit | `go test ./modules/surf -short -run TestServeSummary` | partial (`serve_test.go` checks banner) |
|
||||
| SC4 | shutdown report counters (surf in-flight, conga Stats) | unit | `go test ./modules/surf ./modules/conga -short -run 'TestShutdownReport|TestWorkerStats'` | ❌ |
|
||||
| SC4 | `lagoon.Pending` | integration (Docker) | `go test ./modules/lagoon -run TestPending` | ❌ |
|
||||
| SC4 | per-plugin warn-only coverage | unit | `go test ./...` in each plugin repo (`TestCommandsDocumented` logs) | ❌ |
|
||||
| SC5 | generated main + `summer new` goldens | golden | `go test ./internal/build -run Golden` | ✓ (update) |
|
||||
| SC5 | landing terminal commands still valid | unit | `cd ../sm-summercmsio-app && go test -run Terminal` | ✓ |
|
||||
|
||||
### Testing techniques
|
||||
- **TTY simulation:** `newConsole(in, out, err, lookup, tty, interactive *bool)` already injects TTY/interactive flags [VERIFIED: modules/bonfire/output.go:65-91]. Extend it with a forced `colorprofile.Profile` so goldens contain no ANSI (or a pinned `ANSI` profile for one styled golden).
|
||||
- **bubbletea:** test models by calling `Update`/`View` directly. For an end-to-end run use `tea.WithInput(nil)`, `tea.WithOutput(&buf)`, `tea.WithWindowSize(80, 24)`. Do not add `teatest` (another dependency).
|
||||
- **slog handlers:** `slogtest.TestHandler` (JSON parse of the output) for the console handler's non-TTY/JSON modes and the daily writer; the race detector for concurrent writes.
|
||||
- **Goldens:** `modules/bonfire/testdata/{list,help_*,error_*}.golden`, rewritten with `go test ./modules/bonfire -update`.
|
||||
|
||||
### Sampling Rate
|
||||
- **Per task commit:** quick run command above.
|
||||
- **Per wave merge:** full suite + `summer docs:build --check`.
|
||||
- **Phase gate:** `scripts/check-phase19.8.sh --all` green before `/msd-verify-work`; manual UAT on a real terminal (spinner/progress/huh prompts, Ctrl+C report) is required because animations cannot be asserted fully.
|
||||
|
||||
### Wave 0 Gaps
|
||||
- [ ] `modules/diary/*_test.go` (new module)
|
||||
- [ ] `modules/bonfire/testdata/*.golden` + `-update` flag in bonfire tests
|
||||
- [ ] `scripts/check-phase19.8.sh` (multi-repo gate with `--self-test`)
|
||||
- [ ] `TestCommandsDocumented` in `cmd/summer` and in each D-20 plugin repo (warn-only)
|
||||
|
||||
## Security Domain
|
||||
|
||||
| ASVS category | Applies | Standard control |
|
||||
|---|---|---|
|
||||
| V2 Authentication | no (no auth changes) | — |
|
||||
| V3 Session | no | — |
|
||||
| V4 Access Control | no | — |
|
||||
| V5 Input Validation | yes | `--env` value validated by `compass.sanitizeEnv` (letters, digits, `-`, `_`; rejects `..`, `/`, `\`) [VERIFIED: modules/compass/env.go:110-118]; diary config: whitelist drivers/formats, reject unknown levels |
|
||||
| V6 Cryptography | no | — |
|
||||
| V7 Error handling & logging | **yes** | sunscreen on every channel; no URL paths/query/headers in the request log; GORM SQL parameterized; `-v` error chains on stderr only; log files 0o640, dirs 0o750; never print secrets in hints/summaries (summary shows driver names, never DSNs or keys; centrifugo API key is already never printed) |
|
||||
| V12 Files | yes | Daily writer path confined under the app root (clean + prefix check like `compass.Persist` does for overrides); retention deletes only `<base>-YYYY-MM-DD<ext>` matches |
|
||||
|
||||
| Threat pattern | STRIDE | Mitigation |
|
||||
|---|---|---|
|
||||
| Tokens in logged paths (share/invite links) | Information disclosure | Keep `pattern`, not path (Pitfall 9) |
|
||||
| Secrets in SQL traces at `-vvv` | Information disclosure | `ParameterizedQueries: true`; sunscreen still applies |
|
||||
| DSN/keys in the startup summary or error chain | Information disclosure | Summary prints driver/host only; run error text through `sunscreen.Scrub` in the renderer |
|
||||
| Log path traversal via `log.channels.*.path` | Tampering | Clean and confine to the app root; refuse absolute paths outside it unless explicitly allowed |
|
||||
| Terminal escape injection via logged user data | Tampering | The console handler strips control characters from message/attr values (stdlib Text/JSON handlers already quote) |
|
||||
| Log disk exhaustion | DoS | Retention days; daily files only |
|
||||
|
||||
## Sources
|
||||
|
||||
### Primary (HIGH confidence)
|
||||
- proxy.golang.org `@latest`/`.mod`/`@v/list` for charm.land/{lipgloss,bubbletea,bubbles,huh,fang,log}/v2 and github.com/charmbracelet/* v1; charm.land go-import meta tags
|
||||
- Module source read this session (scratch module cache): fang v2.0.1 `fang.go`, `help.go`, `theme.go`; bubbletea v2.1.0 `tea.go`, `options.go`, `UPGRADE_GUIDE_V2.md`; bubbles v2.2.1 `progress`, `spinner`, `UPGRADE_GUIDE_V2.md`; huh v2.0.3 `form.go`, `field_input.go`; lipgloss v2.0.6 `query.go`, `table`, `UPGRADE_GUIDE_V2.md`; charm log v2.0.1 `logger_121.go`
|
||||
- cobra v1.10.2 `command.go`; pflag v1.0.9 `flag.go`; gorm v1.31.2 `logger/logger.go`, `logger/slog.go`; Go stdlib `log/slog/multi_handler.go`
|
||||
- Repo files read: modules/bonfire/*, modules/sunscreen/sunscreen.go, modules/compass/{config,env}.go + README, internal/build/build.go + stubs + goldens, internal/dev/watch.go, internal/docsite/check_commands.go, cmd/summer/{main,runtime,docs,parity}.go + docs_test.go, modules/{lagoon,conga,surf,cabana,flare,lighthouse/centrifugo} command files, surf/accesslog.go, conga/{client,worker}.go, lagoon/{connection,migrations}.go
|
||||
- SSU `/media/nvme/dev/pxpx/ssu`: go.mod, internal/cli/tui/*.go, internal/cli/root.go, internal/logging/*.go
|
||||
- Probes (scratchpad): slogtest vs charm log; cobra global flags; BuildInfo replace/go.work; binary size
|
||||
- go.dev/doc/go1.24 (VCS version stamping)
|
||||
|
||||
### Secondary (MEDIUM)
|
||||
- `../.depreciated/modules/summer-bonfire/PLAN.md` (Scala design, non-TTY table)
|
||||
- Sibling repos: plugin console files, app `.gitmodules`/go.mod/go.work, DEPLOY.md, smoke scripts, sm-demo-app README roadmap deck
|
||||
|
||||
### Tertiary (LOW)
|
||||
- None used for recommendations.
|
||||
|
||||
## Metadata
|
||||
|
||||
**Confidence breakdown:**
|
||||
- Standard stack: HIGH (versions, go directives, dependency graph and APIs read from source; size probed)
|
||||
- Architecture: HIGH for the flag/help/fang mechanics (source + probes); MEDIUM for level-mapping policy (A4)
|
||||
- Pitfalls: HIGH (each tied to a file/line or a probe)
|
||||
- Inventory: HIGH (grep + read of every command file across 14 repos)
|
||||
|
||||
**Research date:** 2026-10-21
|
||||
**Valid until:** 2026-11-20 (charm v2 is moving; re-check `@latest` before `go get`)
|
||||
@@ -0,0 +1,78 @@
|
||||
---
|
||||
phase: "19.8"
|
||||
slug: "console-commands-overhaul"
|
||||
# status lifecycle: draft (seeded by plan-phase) → validated (set by validate-phase §6)
|
||||
# audit-milestone §5.5 distinguishes NOT-VALIDATED (draft) from PARTIAL (validated + nyquist_compliant: false) (#2117)
|
||||
status: draft
|
||||
nyquist_compliant: false
|
||||
wave_0_complete: false
|
||||
created: "2026-10-21"
|
||||
---
|
||||
|
||||
# Phase 19.8 — Validation Strategy
|
||||
|
||||
> Per-phase validation contract for feedback sampling during execution.
|
||||
|
||||
---
|
||||
|
||||
## Test Infrastructure
|
||||
|
||||
| Property | Value |
|
||||
|----------|-------|
|
||||
| **Framework** | {pytest 7.x / jest 29.x / vitest / go test / other} |
|
||||
| **Config file** | {path or "none — Wave 0 installs"} |
|
||||
| **Quick run command** | `{quick command}` |
|
||||
| **Full suite command** | `{full command}` |
|
||||
| **Estimated runtime** | ~19.8 seconds |
|
||||
|
||||
---
|
||||
|
||||
## Sampling Rate
|
||||
|
||||
- **After every task commit:** Run `{quick run command}`
|
||||
- **After every plan wave:** Run `{full suite command}`
|
||||
- **Before `/msd-verify-work`:** Full suite must be green
|
||||
- **Max feedback latency:** 19.8 seconds
|
||||
|
||||
---
|
||||
|
||||
## Per-Task Verification Map
|
||||
|
||||
| Task ID | Plan | Wave | Requirement | Threat Ref | Secure Behavior | Test Type | Automated Command | File Exists | Status |
|
||||
|---------|------|------|-------------|------------|-----------------|-----------|-------------------|-------------|--------|
|
||||
| 19.8-01-01 | 01 | 1 | REQ-{XX} | T-19.8-01 / — | {expected secure behavior or "N/A"} | unit | `{command}` | ✅ / ❌ W0 | ⬜ pending |
|
||||
|
||||
*Status: ⬜ pending · ✅ green · ❌ red · ⚠️ flaky*
|
||||
|
||||
---
|
||||
|
||||
## Wave 0 Requirements
|
||||
|
||||
- [ ] `{tests/test_file.py}` — stubs for REQ-{XX}
|
||||
- [ ] `{tests/conftest.py}` — shared fixtures
|
||||
- [ ] `{framework install}` — if no framework detected
|
||||
|
||||
*If none: "Existing infrastructure covers all phase requirements."*
|
||||
|
||||
---
|
||||
|
||||
## Manual-Only Verifications
|
||||
|
||||
| Behavior | Requirement | Why Manual | Test Instructions |
|
||||
|----------|-------------|------------|-------------------|
|
||||
| {behavior} | REQ-{XX} | {reason} | {steps} |
|
||||
|
||||
*If none: "All phase behaviors have automated verification."*
|
||||
|
||||
---
|
||||
|
||||
## Validation Sign-Off
|
||||
|
||||
- [ ] All tasks have `<automated>` verify or Wave 0 dependencies
|
||||
- [ ] Sampling continuity: no 3 consecutive tasks without automated verify
|
||||
- [ ] Wave 0 covers all MISSING references
|
||||
- [ ] No watch-mode flags
|
||||
- [ ] Feedback latency < 19.8s
|
||||
- [ ] `nyquist_compliant: true` set in frontmatter
|
||||
|
||||
**Approval:** {pending / approved YYYY-MM-DD}
|
||||
@@ -0,0 +1,3 @@
|
||||
# Phase 19.8 API coverage
|
||||
|
||||
No external API integration: phase restyles local console output and logging using in-process Go libraries.
|
||||
@@ -0,0 +1,9 @@
|
||||
# Phase 19.8 deferred items
|
||||
|
||||
Out-of-scope findings recorded during execution. Not fixed in this phase.
|
||||
|
||||
## Found during 19.8-01
|
||||
|
||||
- **Flaky wristband test.** `TestOIDCSubjectIsRandomSmoke` (`modules/wristband/oidc_security_test.go:151`) rejects a subject that contains `ada` (to catch the fixture user's name), but a random UUIDv4 contains `ada` in about 0.7% of runs. It failed once during the 19.8-01 short suite (`sub = "64a89757-94a7-4699-bf28-4329d8c7eada"`) and passed on three reruns. Fix: compare against the fixture's name as a whole, or check that the subject differs from it, instead of matching a substring of hex.
|
||||
- **Pre-existing gofmt drift.** `gofmt -l` lists `internal/build/registry.go`, `modules/cabana/account_tokens.go` (no newline at end of file) and some test files (`modules/cabana/*_test.go`, `modules/compass/*_test.go`, `modules/tide/*_test.go`). Unchanged by this plan beyond the logger lines.
|
||||
- **examples/hello go.mod is stale outside the workspace.** `GOWORK=off go -C examples/hello build .` asks for `go mod tidy`: its go.mod lacks requirements the framework already had before this phase (aws-sdk, bluemonday, redis, among others). Inside the repository's go.work every build and test passes, so the example modules' go.mod and go.sum were left as they were.
|
||||
@@ -47,6 +47,12 @@ This extends `.planning/research/go-ecosystem.md` (2026-09-16 GitHub-API-verifie
|
||||
| air-verse/air | v1.67.4 (Aug 1, 2026), actively maintained | Dev watch-rebuild loop | This is the "watch loop that rebuilds the compiled-plugin binary on change" the plugin architecture already calls for. Config via `.air.toml`; point `cmd`/`bin` at `go build -o ./tmp/summer ./cmd/summer && ./tmp/summer serve`. |
|
||||
| golangci-lint | v2.13.2 (Aug 27, 2026) | Linting | v2's config schema (`version: "2"` in `.golangci.yml`) is a breaking change from v1 — do not copy a v1 config from an older Go project without migrating it. |
|
||||
| `github.com/markbates/goth` (core plus providers google, facebook, github, azureadv2; never gothic) | v1.82.0 | OAuth 2.0 client for social sign-in in sm-sociallogin-plugin | Decided in .planning/notes/2026-10-20-sociallogin-goth.md (Phase 19.5 D-02); plugin-only, the framework go.mod does not require it. |
|
||||
| `charm.land/lipgloss/v2` | v2.0.6 | Console styles, layout and tables (bonfire palette, diary console handler) | Decided in .planning/notes/2026-10-21-console-charm-stack.md (Phase 19.8 D-02); imported only by modules/bonfire and modules/diary |
|
||||
| `charm.land/bubbletea/v2` | v2.1.0 | Runtime for the live spinner, process and progress views | Decided in .planning/notes/2026-10-21-console-charm-stack.md (Phase 19.8 D-02); imported only by modules/bonfire and modules/diary |
|
||||
| `charm.land/bubbles/v2` | v2.2.1 | Spinner and progress components | Decided in .planning/notes/2026-10-21-console-charm-stack.md (Phase 19.8 D-02); imported only by modules/bonfire and modules/diary |
|
||||
| `charm.land/huh/v2` | v2.0.3 | Interactive prompts on a terminal | Decided in .planning/notes/2026-10-21-console-charm-stack.md (Phase 19.8 D-02); imported only by modules/bonfire and modules/diary |
|
||||
| `charm.land/fang/v2` | v2.0.1 | Cobra error rendering hook (used narrowly) | Decided in .planning/notes/2026-10-21-console-charm-stack.md (Phase 19.8 D-02); imported only by modules/bonfire and modules/diary |
|
||||
| `github.com/charmbracelet/colorprofile` | v0.4.3 | Color profile detection and downsampling writer (`--ansi`, `--no-ansi`, `NO_COLOR`) | Decided in .planning/notes/2026-10-21-console-charm-stack.md (Phase 19.8 D-02); imported only by modules/bonfire and modules/diary |
|
||||
|
||||
### Development Tools
|
||||
|
||||
|
||||
@@ -11,3 +11,5 @@ g15office: u4rtpcl1
|
||||
Found while writing `docs/console/writing-commands.md` (Phase 11.1 plan 03). The page describes the current behaviour: names are not checked for duplicates, so keep commands in the plugin's own namespace.
|
||||
|
||||
Suggested fix: make `bonfire.NewRoot` fail with an error naming the duplicated command when two commands share a name. `bonfire.NewCatalog`, which the scheduler calls through, has no error return, but the generated `main` calls `bonfire.NewRoot` before it runs any command, so that check stops the binary before a duplicate can run. Update the bonfire README and the docs page in the same change (the D-13 docs rule). The API change is outside the docs phase boundary, so it is not made in Phase 11.1.
|
||||
|
||||
Done in Phase 19.8 plan 02, commit 8d4d7ce: `bonfire.NewRoot` fails with `bonfire.ErrDuplicateCommand` naming the command (also for commands named `list` or `help`), and with `bonfire.ErrReservedFlag` for flags that reuse a global option; the bonfire README and `docs/console/writing-commands.md` describe it.
|
||||
70
.planning/todos/done/email-proof-release.md
Normal file
70
.planning/todos/done/email-proof-release.md
Normal file
@@ -0,0 +1,70 @@
|
||||
---
|
||||
created: 2026-10-21T15:00:00.000Z
|
||||
title: Release the email-proof fix (sm-user-plugin v0.1.5, sm-sociallogin-plugin v0.1.2) and move the app pins
|
||||
priority: high
|
||||
area: sm-user-plugin, sm-sociallogin-plugin
|
||||
severity: major
|
||||
g15office: sqnwn1zi
|
||||
files:
|
||||
- ../fonoteka.go/plugins/golem15/user
|
||||
- ../sm-sociallogin-plugin/go.mod
|
||||
- ../sm-sociallogin-plugin/go.sum
|
||||
- ../sm-demo-app/plugins/golem15/user
|
||||
- ../sm-demo-app/plugins/golem15/sociallogin
|
||||
- ../sm-demo-app/go.mod
|
||||
- ../sm-demo-app/go.sum
|
||||
- ../sm-bm-app/plugins/golem15/user
|
||||
- ../sm-bm-app/plugins/golem15/sociallogin
|
||||
- ../sm-bm-app/go.mod
|
||||
- ../sm-bm-app/go.sum
|
||||
---
|
||||
|
||||
## Hand-off
|
||||
|
||||
For the 19.8 session (console commands overhaul): review this release and decide on it together with the framework v0.4.0 release. On 2026-10-21 the user answered the 19.5-05 Task 6 checkpoint with **hold**: nothing was pushed or tagged and no pointer moved. Every step below needs the user's go-ahead; never force-push, never deploy.
|
||||
|
||||
The plugin tags live in the plugins' own repositories (`sm-user-plugin`, `sm-sociallogin-plugin`). They are separate from the framework `v0.4.0` tag on summercms.go and need no framework release; the framework tag does not carry them.
|
||||
|
||||
## Problem
|
||||
|
||||
Phase 19.5 plan 05 (gap closure) made a verified social sign-in link only onto an account whose owner proved the address: `users.email_verified_at` (migration `202610210001_add_users_email_verified_at`, stamped by the mailed activation code and the reset-code password reset, cleared on an email change), and sociallogin's `ownerVerifiedEmail` reads it (D-26). Details: `.planning/phases/19.5-social-login-in-sm-user-plugin/19.5-05-SUMMARY.md`.
|
||||
|
||||
The fix sits in local commits only. Until it is released, sm-demo-app and sm-bm-app keep the pre-account-takeover path (19.5 VERIFICATION item 1): an auto-mode `/register` account with someone else's address is linked when that address's owner signs in with Google, handing the sign-in to the squatter.
|
||||
|
||||
Unreleased commits (`git log --oneline origin/master..master`, fetched 2026-10-21 about 12:25Z):
|
||||
|
||||
| Repository | Unpushed commits | This plan's? |
|
||||
|------------|------------------|--------------|
|
||||
| sm-user-plugin (`../fonoteka.go/plugins/golem15/user`) | `35cf166`, `a99cb46`, `add1f7a` | all 19.5-05 |
|
||||
| sm-sociallogin-plugin | `79cebb9`, `c4ebf05`, `962190b`, `3bb5650` | all 19.5-05 (79cebb9: README, CR-02 wording) |
|
||||
| sm-bm-app | `5fd0cc3` (bm OAuth callback view test) | 19.5-05 Task 4 |
|
||||
| sm-demo-app | `b325460` feat(demo): roadmap deck describes the new console | **foreign**: another session (19.8), touches only `plugins/demo/content/ROADMAP.md` and `plugins/demo/roadmap/` |
|
||||
| fonoteka.go | 31 commits (`f4d5728` … `ca746fc`) | all foreign; fonoteka.go is never pushed by this release |
|
||||
| summercms.go | this plan's `7170c5b`, `30e0cd9`, `c2c8c6a`, `7e43873`, `8c7794e` among others | never pushed by this release |
|
||||
|
||||
Proposed tags (free on the remotes at the time of the check; no tag points at either plugin's master):
|
||||
|
||||
| Repository | Tag | Points at |
|
||||
|------------|-----|-----------|
|
||||
| sm-user-plugin | `v0.1.5` | `35cf166` |
|
||||
| sm-sociallogin-plugin | `v0.1.2` | the new commit `chore: require sm-user-plugin v0.1.5` on top of `79cebb9` |
|
||||
|
||||
Current pins: sm-demo-app and sm-bm-app `plugins/golem15/user` c43ba87 (v0.1.4) and `plugins/golem15/sociallogin` d7ee28d (v0.1.1); sm-demo-app go.mod requires sm-user-plugin v0.1.4 and sm-sociallogin-plugin v0.1.1; sm-bm-app go.mod requires sm-user-plugin v0.1.4 and sm-sociallogin-plugin v0.0.0 (replace to the submodule; only the user line names a version); fonoteka.go gitlink c43ba87, with the worktree already at `35cf166` and left unstaged.
|
||||
|
||||
## Solution
|
||||
|
||||
D-21 order: user plugin first, then sociallogin, then the apps (user bump, then sociallogin bump). Before every push: `git fetch origin`, no uncommitted change to the release paths, and `git log --oneline origin/master..master` lists only the expected commits; otherwise stop and report. Stage by path.
|
||||
|
||||
0. Docker gate runs: the NordLayer VPN's nftables table drops loopback traffic to published container ports, so testcontainers Postgres times out. Restore it with `sudo nft insert rule inet nordlayer input iifname "lo" accept` and `sudo nft insert rule inet nordlayer output oifname "lo" accept` (lost when the VPN reconnects). Probe in fonoteka.go: `TESTCONTAINERS_HOST_OVERRIDE=127.0.0.1 go test -short -count=1 -timeout 120s -run '^TestPermissionSetScan$' ./plugins/golem15/user/classes` finishes in about 15 s when it works. Full gate: `PHASE195_REQUIRE_DOCKER=1 TESTCONTAINERS_HOST_OVERRIDE=127.0.0.1 bash scripts/check-phase19.5.sh --all` (summercms.go).
|
||||
1. sm-user-plugin (`../fonoteka.go/plugins/golem15/user`): vet and the short suite green through the fonoteka.go workspace; push master; annotated tag `v0.1.5` at `35cf166` (message: users.email_verified_at records a proved address (mail activation, reset code); an email change clears it; no backfill; routes, payloads and config unchanged); push the tag.
|
||||
2. sm-sociallogin-plugin: go.mod require `git.golem15.com/golem15/sm-user-plugin v0.1.5`, keep the summercms replace; `GOWORK=off GOPRIVATE='git.golem15.com/*' go mod tidy`; vet, short suite and `GOWORK=off go build ./...` green; commit `chore: require sm-user-plugin v0.1.5`; push master; annotated tag `v0.1.2` (message: a verified sign-in links only onto an account whose owner proved the address; accounts registered with a password in auto mode, created by an administrator or created before golem15.user v0.1.5 go to the pending screen; requires sm-user-plugin v0.1.5); push the tag.
|
||||
3. sm-demo-app, two commits: `plugins/golem15/user` to v0.1.5 with go.mod/go.sum, `bash scripts/check-phase19.5.sh --demo` and `scripts/check-pins.sh` green, commit `chore: bump sm-user-plugin to v0.1.5`; then `plugins/golem15/sociallogin` to v0.1.2 the same way, commit `chore: bump sm-sociallogin-plugin to v0.1.2`; gate green after each; push. **The push carries the foreign `b325460` (19.8 roadmap deck) unless that session has pushed it first; include it only with the user's or the 19.8 session's say-so.**
|
||||
4. sm-bm-app: the same two commits in the same order (only the versioned require line changes, keep the replace lines, add the go.sum change to the commit it belongs to); `bash scripts/check-phase19.5.sh --bm` green after each; push, including `5fd0cc3`.
|
||||
5. fonoteka.go: `git -C plugins/golem15/user describe --exact-match` names v0.1.5; `bash scripts/check-phase19.5.sh --fonoteka`; stage only `plugins/golem15/user`, commit `chore: bump sm-user-plugin to v0.1.5`, local only (its unpushed range is other work). Other sessions' gitlink changes (feedback, golem) stay untouched.
|
||||
6. g15office: comment on the Phase 19.5 milestone task `onunbz3l` (released, awaiting re-verification), set it to Testing, and close this todo's task to Live.
|
||||
|
||||
When done: record the tags, pushed ranges and final pins in 19.5-05-SUMMARY.md (or a follow-up note), move this todo to `todos/done/`, and re-run `/msd-verify-work 19.5`.
|
||||
|
||||
## Done
|
||||
|
||||
Released 2026-10-21 by the 19.8 session (plan 19.8-03, before Task 7) under the user's "release v0.4.0" go-ahead, steps 0-5 as written: sm-user-plugin `v0.1.5` at `35cf166` (pushed `c7635c5..35cf166`), sm-sociallogin-plugin `9fd58db` chore: require sm-user-plugin v0.1.5 and `v0.1.2` (pushed `d7ee28d..9fd58db`), sm-demo-app `f16ef6c` and `1afd7ff` plus a snapshot sync `a5703f9` (pushed `61a00e9..a5703f9`, carrying `b325460`), sm-bm-app `1e09c23` and `cc4b23f` (pushed `99af8c2..cc4b23f`, carrying `5fd0cc3`), fonoteka.go `2be9e1f` local only. Tags, ranges and final pins: 19.5-05-SUMMARY.md, "Release (2026-10-21, after the hold)". Step 6 (g15office: comment on `onunbz3l`, set it to Testing, this task `sqnwn1zi` to Live) was not done: no g15office tool in that session.
|
||||
@@ -0,0 +1,20 @@
|
||||
---
|
||||
created: 2026-10-21T00:00:00.000Z
|
||||
title: Let password-less social accounts set a first password through change-password
|
||||
area: sm-user-plugin
|
||||
priority: medium
|
||||
files:
|
||||
- ../fonoteka.go/plugins/golem15/user/controllers/api_controller.go
|
||||
- ../sm-sociallogin-plugin/testdata/PHP_METHOD_MAP.md
|
||||
g15office: not created (no g15office tool in the executing session; see 19.5-04 checkpoint)
|
||||
---
|
||||
|
||||
## Problem
|
||||
|
||||
Social sign-in (golem15.sociallogin, Phase 19.5, D-10) creates password-less accounts: a random password nobody knows and `has_self_set_password` false. The PHP user plugin lets such an account set its first password through change-password without the current one, after confirming a short-lived, single-use code emailed to its own address, and disarms the relaxation after the first successful write (`OAuthPasswordBootstrapTest`: `test_oauth_account_sets_password_without_current_password`, `test_disarm_closes_the_no_current_password_window`, `test_confirmation_code_is_single_use_and_time_limited`).
|
||||
|
||||
The Go user plugin has no such flow: `POST /_user/api/v1/change-password` answers an opaque 500 for an account without a self-set password, and only forgot/reset works. Phase 19.5 had no CONTEXT decision for it, so the three PHP methods are mapped not-applicable in `sm-sociallogin-plugin/testdata/PHP_METHOD_MAP.md`. An application cutover whose frontend relies on the PHP flow (428 `requires_confirmation`, then `password_bootstrap_code`) needs it.
|
||||
|
||||
## Solution
|
||||
|
||||
Port the PHP bootstrap to sm-user-plugin as an additive change: for `has_self_set_password = false`, change-password without `current_password` issues a confirmation code mailed to the account (answers 428 with `requires_confirmation`), verifies `password_bootstrap_code` (hashed, single use, time limited), writes the password and sets `has_self_set_password` true (disarm). A real-password account stays byte-identical (`TestChangePassword/real_password_account_still_requires_current_password`). The code needs columns or a cache entry (decide in the phase; a migration needs a decision note), a mail template, and the PHP tests above ported, which turns their three map rows into ported rows.
|
||||
@@ -101,6 +101,7 @@ The `migrate`, `migrate:status`, `migrate:rollback`, `serve` and admin commands
|
||||
| [compass](modules/compass/README.md) | Layered YAML configuration with per-environment directories, `SUMMER_` environment overrides, embedded plugin defaults and dot-path access. | Points you the right way; configuration tells the application how to behave in each environment. |
|
||||
| [conga](modules/conga/README.md) | Background jobs on River over the shared Postgres pool: transactional dispatch, a `summer_jobs` progress record, in-process or dedicated workers, and a wall-clock scheduler. | A line of dancers moving one after another; jobs queue up and are worked through in order. |
|
||||
| [cooler](modules/cooler/README.md) | Cache drivers and shared counters for SummerCMS: memory and Redis caches with a key prefix and TTLs, a Redis-backed rate-limit store, and the site-cache purge after admin saves. | The cool box you bring to the beach; it keeps things fresh and at hand for everyone in the group. |
|
||||
| [diary](modules/diary/README.md) | Application logging modelled on Laravel's logging config: named channels (console, stderr, daily files, stacks), per-component levels and redaction on every channel. | A beach diary records each day; the log keeps one file per day. |
|
||||
| [festival](modules/festival/README.md) | Typed, synchronous event bus with listener priorities, payload collection and stop-when-handled dispatch. | A summer festival announces events and the crowd reacts; it is the event bus. |
|
||||
| [fetchguard](modules/fetchguard/README.md) | Guarded outbound HTTP client and fetcher that blocks private and reserved addresses, enforces host, size and timeout limits, and offers an explicit trusted mode for operator-configured endpoints. | A lifeguard for outbound fetches; it watches every request the application sends out and stops the dangerous ones. |
|
||||
| [flare](modules/flare/README.md) | Web Push delivery with VAPID (RFC 8292) and aes128gcm payload encryption (RFC 8291) behind a small Pusher interface. | A signal flare reaches people far away; it sends Web Push notifications to browsers. |
|
||||
|
||||
42
cmd/summer/commands_documented_test.go
Normal file
42
cmd/summer/commands_documented_test.go
Normal file
@@ -0,0 +1,42 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||
"git.golem15.com/golem15/summercms/modules/bonfire"
|
||||
"git.golem15.com/golem15/summercms/modules/cabana"
|
||||
"git.golem15.com/golem15/summercms/modules/compass"
|
||||
"git.golem15.com/golem15/summercms/modules/conga"
|
||||
"git.golem15.com/golem15/summercms/modules/flare"
|
||||
"git.golem15.com/golem15/summercms/modules/lagoon"
|
||||
"git.golem15.com/golem15/summercms/modules/lighthouse/centrifugo"
|
||||
"git.golem15.com/golem15/summercms/modules/surf"
|
||||
)
|
||||
|
||||
// applicationCommands is every command the framework gives an application
|
||||
// binary, built the way docsCommands does. The blog example's blog:publish
|
||||
// is checked by its own package's test: the tool must not import example
|
||||
// plugins (TestToolDoesNotImportExamplePlugins).
|
||||
func applicationCommands() []bonfire.Command {
|
||||
app := backpack.New(&compass.Config{})
|
||||
var cmds []bonfire.Command
|
||||
cmds = append(cmds, lagoon.RuntimeCommands(app, nil)...)
|
||||
cmds = append(cmds, conga.RuntimeCommands(app, nil)...)
|
||||
cmds = append(cmds, surf.ServeCommand(app, nil), surf.RouteListCommand(app, nil))
|
||||
cmds = append(cmds, cabana.RuntimeCommands(app)...)
|
||||
cmds = append(cmds, centrifugo.Commands(app)...)
|
||||
cmds = append(cmds, flare.Commands(app)...)
|
||||
return cmds
|
||||
}
|
||||
|
||||
// TestCommandsDocumented is the framework's documentation coverage gate
|
||||
// (D-17): every command of the summer tool (its delegates included) and of
|
||||
// an application binary has a description, long help, examples and
|
||||
// described arguments and options, and the test fails on any gap.
|
||||
func TestCommandsDocumented(t *testing.T) {
|
||||
commands := append(toolCommands(), applicationCommands()...)
|
||||
for _, problem := range bonfire.CheckDocs(commands) {
|
||||
t.Error(problem)
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/signal"
|
||||
"sync"
|
||||
@@ -24,6 +24,17 @@ func docsBuildCommand() bonfire.Command {
|
||||
return bonfire.Command{
|
||||
Name: "docs:build",
|
||||
Description: "Build the documentation site",
|
||||
Long: `Checks every page under docs/ and every module README, then writes the static
|
||||
site to site/ (or --out): HTML pages, a raw .md copy of each page, llms.txt,
|
||||
llms-full.txt and the search index. It fails, writing nothing, when a page
|
||||
names an identifier that does not exist, links to a missing page or anchor,
|
||||
shows a command neither summer nor an application binary has, or has a src=
|
||||
code block that differs from its source. --check only reports. Run it from
|
||||
the framework root.`,
|
||||
Examples: []bonfire.Example{
|
||||
{Command: "docs:build --check", Description: "Check the docs and write nothing"},
|
||||
{Command: "docs:build --base-url /docs/ --out /tmp/site", Description: "Build the site for /docs/ into /tmp/site"},
|
||||
},
|
||||
Flags: []bonfire.Flag{
|
||||
{Name: "root", Description: "Repository root; src= paths and modules/ resolve against it", Default: "."},
|
||||
{Name: "src", Description: "Docs source directory (default <root>/docs)"},
|
||||
@@ -63,6 +74,14 @@ func docsSyncCommand() bonfire.Command {
|
||||
return bonfire.Command{
|
||||
Name: "docs:sync",
|
||||
Description: "Rewrite src= code blocks from their sources",
|
||||
Long: `Rewrites every code block that has a src= reference with the current text of
|
||||
its source (a file, or a function, test or example in it), so the docs show
|
||||
what the code does. Run it after changing code a page shows; docs:build
|
||||
--check fails until you do.`,
|
||||
Examples: []bonfire.Example{
|
||||
{Command: "docs:sync", Description: "Refresh every src= block of the docs in this repository"},
|
||||
{Command: "docs:sync --root ../summercms.go", Description: "Refresh the docs of another checkout"},
|
||||
},
|
||||
Flags: []bonfire.Flag{
|
||||
{Name: "root", Description: "Repository root; src= paths resolve against it", Default: "."},
|
||||
{Name: "src", Description: "Docs source directory (default <root>/docs)"},
|
||||
@@ -89,6 +108,14 @@ func docsServeCommand() bonfire.Command {
|
||||
return bonfire.Command{
|
||||
Name: "docs:serve",
|
||||
Description: "Build the documentation site and preview it on a local address",
|
||||
Long: `Builds the documentation site like docs:build into a temporary directory and
|
||||
serves it on a loopback address. It watches the docs sources and the files
|
||||
their src= references point to, and rebuilds on change; a failed rebuild keeps
|
||||
the last good site. Ctrl+C stops the server and exits 0.`,
|
||||
Examples: []bonfire.Example{
|
||||
{Command: "docs:serve", Description: "Preview the docs of the current repository on " + docsite.DefaultServeAddr},
|
||||
{Command: "docs:serve --addr 127.0.0.1:4100", Description: "Use another port"},
|
||||
},
|
||||
Flags: []bonfire.Flag{
|
||||
{Name: "root", Description: "Repository root; src= paths and modules/ resolve against it", Default: "."},
|
||||
{Name: "src", Description: "Docs source directory (default <root>/docs)"},
|
||||
@@ -136,13 +163,15 @@ func docsOptions(in bonfire.Input) docsite.Options {
|
||||
}
|
||||
|
||||
// docsCommands collects the command names docs pages may show: the summer
|
||||
// tool's own commands, and every command the generated application main
|
||||
// tool's own commands, list and help (added by bonfire.NewRoot), and every
|
||||
// command the generated application main
|
||||
// registers (TestDocsCommandsMirrorGeneratedMain keeps this list in step
|
||||
// with internal/build) plus the realtime and push commands applications
|
||||
// append. The constructors only capture the app, so an empty config is
|
||||
// enough; nothing runs.
|
||||
func docsCommands() *docsite.Commands {
|
||||
var tool []string
|
||||
// list and help come from bonfire.NewRoot, in both programs.
|
||||
tool := []string{"list", "help"}
|
||||
for _, c := range toolCommands() {
|
||||
tool = append(tool, c.Name)
|
||||
}
|
||||
@@ -156,7 +185,7 @@ func docsCommands() *docsite.Commands {
|
||||
appCmds = append(appCmds, cabana.RuntimeCommands(app)...)
|
||||
appCmds = append(appCmds, centrifugo.Commands(app)...)
|
||||
appCmds = append(appCmds, flare.Commands(app)...)
|
||||
names := make([]string, 0, len(appCmds))
|
||||
names := []string{"list", "help"}
|
||||
for _, c := range appCmds {
|
||||
names = append(names, c.Name)
|
||||
}
|
||||
@@ -164,11 +193,11 @@ func docsCommands() *docsite.Commands {
|
||||
}
|
||||
|
||||
// reportDocsProblems prints one line per problem and a summary, and returns
|
||||
// a short error so the binary exits 1.
|
||||
// a Reported error, so the binary exits 1 without repeating the list.
|
||||
func reportDocsProblems(out bonfire.Output, cmd string, problems []docsite.Problem) error {
|
||||
for _, p := range problems {
|
||||
out.Printf("%s\n", p)
|
||||
}
|
||||
out.Printf("%s: %d problems, nothing written\n", cmd, len(problems))
|
||||
return errors.New(cmd + " failed")
|
||||
return bonfire.Reported(fmt.Errorf("%s failed", cmd))
|
||||
}
|
||||
|
||||
@@ -401,12 +401,12 @@ func first(lines []string) string {
|
||||
|
||||
func TestDocsCommandNames(t *testing.T) {
|
||||
cmds := docsCommands()
|
||||
for _, want := range []string{"docs:build", "make:plugin", "migrate:status"} {
|
||||
for _, want := range []string{"docs:build", "make:plugin", "migrate:status", "list", "help"} {
|
||||
if !slices.Contains(cmds.Tool, want) {
|
||||
t.Errorf("Tool is missing %s: %v", want, cmds.Tool)
|
||||
}
|
||||
}
|
||||
for _, want := range []string{"key:generate", "route:list", "admin:create", "queue:clear", "websockets:health"} {
|
||||
for _, want := range []string{"key:generate", "route:list", "admin:create", "queue:clear", "websockets:health", "list", "help"} {
|
||||
if !slices.Contains(cmds.App, want) {
|
||||
t.Errorf("App is missing %s: %v", want, cmds.App)
|
||||
}
|
||||
|
||||
@@ -1,28 +1,31 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strings"
|
||||
"syscall"
|
||||
|
||||
"git.golem15.com/golem15/summercms/internal/build"
|
||||
"git.golem15.com/golem15/summercms/internal/dev"
|
||||
"git.golem15.com/golem15/summercms/modules/bonfire"
|
||||
"git.golem15.com/golem15/summercms/modules/diary"
|
||||
"git.golem15.com/golem15/summercms/modules/lagoon"
|
||||
)
|
||||
|
||||
func main() {
|
||||
root, err := bonfire.NewRoot("summer", toolCommands(), os.Stdout)
|
||||
args := os.Args[1:]
|
||||
g := bonfire.PeekGlobals(args)
|
||||
diary.Bootstrap(os.Stderr, diary.FromGlobals(g))
|
||||
root, err := bonfire.NewRoot("summer", toolCommands(), os.Stdout,
|
||||
bonfire.WithVersion(bonfire.BuildVersion()), bonfire.OnGlobals(diary.ApplyGlobals))
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
root.SetArgs(os.Args[1:])
|
||||
if err := root.Execute(); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
os.Exit(bonfire.Fail(os.Stderr, "summer", err, g))
|
||||
}
|
||||
os.Exit(bonfire.Execute(context.Background(), root, args))
|
||||
}
|
||||
|
||||
func toolCommands() []bonfire.Command {
|
||||
@@ -52,12 +55,39 @@ func buildCommand() bonfire.Command {
|
||||
return bonfire.Command{
|
||||
Name: "build",
|
||||
Description: "Generate plugin imports and build the app binary",
|
||||
Long: `Reads summer.yaml of the application in the current directory (or the nearest
|
||||
one above it), generates plugins.gen.go and main.go from it, and runs go build
|
||||
into bin/<binary>, passing the manifest's build.tags as -tags. On a terminal a
|
||||
spinner runs while go build works. It ends with "Built bin/<binary> in <time>";
|
||||
a compile error is printed and summer exits 1. Do not edit the generated
|
||||
files: every build rewrites them.`,
|
||||
Examples: []bonfire.Example{
|
||||
{Command: "build", Description: "Regenerate main.go and build bin/<binary>"},
|
||||
{Command: "build -q", Description: "Build and print only errors"},
|
||||
},
|
||||
Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error {
|
||||
dir, err := os.Getwd()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return build.App(ctx, dir, out)
|
||||
var log bytes.Buffer
|
||||
run := func() error { return build.App(ctx, dir, &log) }
|
||||
if bonfire.IsTerminal(out) {
|
||||
err = out.Spinner("Building", run)
|
||||
} else {
|
||||
err = run()
|
||||
}
|
||||
for _, line := range strings.SplitAfter(log.String(), "\n") {
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
if rest, ok := strings.CutPrefix(line, "built "); ok && err == nil {
|
||||
out.Success("Built bin/" + strings.TrimSuffix(rest, "\n"))
|
||||
continue
|
||||
}
|
||||
out.Printf("%s", line)
|
||||
}
|
||||
return err
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -66,6 +96,15 @@ func makePluginCommand() bonfire.Command {
|
||||
return bonfire.Command{
|
||||
Name: "make:plugin",
|
||||
Description: "Scaffold a compiling plugin module from a vendor.plugin id",
|
||||
Long: `Creates plugins/<plugin> in the current application: a Go module with plugin.go
|
||||
(the plugin ID, Register and Boot) and go.mod, with a replace for a local
|
||||
framework checkout when the application uses one. Register it with plugin:add
|
||||
so the next summer build compiles it in. Run it inside an application; the
|
||||
directory must not exist yet.`,
|
||||
Examples: []bonfire.Example{
|
||||
{Command: "make:plugin acme.blog", Description: "Create plugins/blog with the ID acme.blog"},
|
||||
{Command: "plugin:add plugins/blog", Description: "Then register it in summer.yaml, go.mod and go.work"},
|
||||
},
|
||||
Args: []bonfire.Arg{{
|
||||
Name: "id",
|
||||
Description: "Plugin ID in vendor.plugin form",
|
||||
@@ -84,7 +123,7 @@ func makePluginCommand() bonfire.Command {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out.Printf("created %s\n", created)
|
||||
out.Success("Created " + created)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
@@ -94,6 +133,17 @@ func makeModelCommand() bonfire.Command {
|
||||
return bonfire.Command{
|
||||
Name: "make:model",
|
||||
Description: "Generate a GORM model and create-table migration",
|
||||
Long: `Writes models/<name>.go with a GORM model and, unless --no-migration, a
|
||||
create-table migration updates/<timestamp>_create_<table>.go, in the plugin
|
||||
with this ID (or the plugin whose directory you are in, when the ID is left
|
||||
out). The table is the plugin ID and the plural name in snake case, such as
|
||||
acme_blog_posts. The plugin's registry.gen.go lists the new files, so the
|
||||
plugin picks them up on the next build. Existing files and names are never
|
||||
overwritten.`,
|
||||
Examples: []bonfire.Example{
|
||||
{Command: "make:model acme.blog Post", Description: "A Post model and its create-table migration"},
|
||||
{Command: "make:model Post --no-migration", Description: "Inside plugins/blog: only the model"},
|
||||
},
|
||||
Args: makeArtifactArgs(),
|
||||
Flags: []bonfire.Flag{{
|
||||
Name: "no-migration",
|
||||
@@ -122,6 +172,15 @@ func makeMigrationCommand() bonfire.Command {
|
||||
return bonfire.Command{
|
||||
Name: "make:migration",
|
||||
Description: "Generate an ordered gormigrate stub",
|
||||
Long: `Writes updates/<timestamp>_<name>.go with an empty gormigrate migration (up
|
||||
and down steps to fill in) in the plugin with this ID or the plugin whose
|
||||
directory you are in. The 14-digit timestamp keeps migrations in the order you
|
||||
created them. registry.gen.go adds it to the plugin's migrations; run migrate
|
||||
to apply it.`,
|
||||
Examples: []bonfire.Example{
|
||||
{Command: "make:migration acme.blog AddSlugToPosts", Description: "An empty migration in acme.blog"},
|
||||
{Command: "make:migration AddSlugToPosts", Description: "The same from inside plugins/blog"},
|
||||
},
|
||||
Args: makeArtifactArgs(),
|
||||
Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error {
|
||||
dir, err := os.Getwd()
|
||||
@@ -145,6 +204,16 @@ func makeCommandCommand() bonfire.Command {
|
||||
return bonfire.Command{
|
||||
Name: "make:command",
|
||||
Description: "Generate a plugin console command",
|
||||
Long: `Writes console/<name>.go with a bonfire.Command named <plugin>:<name> in the
|
||||
plugin with this ID or the plugin whose directory you are in; the generated
|
||||
registry adds it to the commands the plugin returns. The generated function
|
||||
takes no arguments, so a command that needs the database or another service
|
||||
takes a parameter instead and is returned from the plugin's Commands by hand
|
||||
(see the porting guide's blog:publish).`,
|
||||
Examples: []bonfire.Example{
|
||||
{Command: "make:command acme.blog Publish", Description: "A blog:publish command in acme.blog"},
|
||||
{Command: "make:command Publish", Description: "The same from inside plugins/blog"},
|
||||
},
|
||||
Args: makeArtifactArgs(),
|
||||
Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error {
|
||||
dir, err := os.Getwd()
|
||||
@@ -168,6 +237,14 @@ func makeJobCommand() bonfire.Command {
|
||||
return bonfire.Command{
|
||||
Name: "make:job",
|
||||
Description: "Generate a plugin job without importing River",
|
||||
Long: `Writes jobs/<name>.go with a queued job built with conga.Job (no River import)
|
||||
in the plugin with this ID or the plugin whose directory you are in; the
|
||||
generated registry adds it to the jobs the plugin returns. Dispatch it with
|
||||
conga.Manager.Dispatch; queue:work or serve runs it.`,
|
||||
Examples: []bonfire.Example{
|
||||
{Command: "make:job acme.blog SendDigest", Description: "A SendDigest job in acme.blog"},
|
||||
{Command: "make:job SendDigest", Description: "The same from inside plugins/blog"},
|
||||
},
|
||||
Args: makeArtifactArgs(),
|
||||
Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error {
|
||||
dir, err := os.Getwd()
|
||||
@@ -191,6 +268,16 @@ func makeAdminControllerCommand() bonfire.Command {
|
||||
return bonfire.Command{
|
||||
Name: "make:admin-controller",
|
||||
Description: "Generate an admin controller with fields.yaml and columns.yaml",
|
||||
Long: `Writes controllers/<name>.go, its config_form.yaml and config_list.yaml, and
|
||||
starter fields.yaml and columns.yaml under models/<name>/, in the plugin with
|
||||
this ID or the plugin whose directory you are in. The controller requires the
|
||||
permission <plugin>.access_<name>, which you declare in the plugin's
|
||||
Permissions(), and its NewRecord returns nil until you return your model, so
|
||||
the screens stay closed until you finish it.`,
|
||||
Examples: []bonfire.Example{
|
||||
{Command: "make:admin-controller acme.blog Posts", Description: "A Posts controller in acme.blog"},
|
||||
{Command: "make:admin-controller Posts", Description: "The same from inside plugins/blog"},
|
||||
},
|
||||
Args: makeArtifactArgs(),
|
||||
Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error {
|
||||
dir, err := os.Getwd()
|
||||
@@ -244,7 +331,7 @@ func flagTrue(in bonfire.Input, name string) bool {
|
||||
|
||||
func printArtifact(out bonfire.Output, result build.ArtifactResult) error {
|
||||
for _, path := range result.Files {
|
||||
out.Printf("created %s\n", path)
|
||||
out.Success("Created " + path)
|
||||
}
|
||||
if result.Hint != "" {
|
||||
out.Printf("%s\n", result.Hint)
|
||||
@@ -256,12 +343,27 @@ func devCommand() bonfire.Command {
|
||||
return bonfire.Command{
|
||||
Name: "dev",
|
||||
Description: "Watch sources and rebuild the app binary",
|
||||
Long: `Builds the application in the current directory, runs bin/<binary> serve and
|
||||
watches the application's Go, YAML, go.mod, go.work, summer.yaml and .env
|
||||
files (bin/, tmp/, .git/, main.go and plugins.gen.go excluded). After a change
|
||||
it rebuilds and restarts serve; a failed build keeps the running server.
|
||||
|
||||
The global options summer dev was given (-v, -q, --ansi, --no-ansi, -n,
|
||||
--env) are passed on to serve. Ctrl+C stops serve, which prints its own
|
||||
shutdown report, then prints the dev report (rebuilds, failed builds,
|
||||
restarts, uptime) and exits 0.`,
|
||||
Examples: []bonfire.Example{
|
||||
{Command: "dev", Description: "Build, serve and rebuild on every change"},
|
||||
{Command: "dev -v", Description: "Pass -v on to serve: per-plugin summary lines and info logs of every component"},
|
||||
},
|
||||
Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error {
|
||||
dir, err := os.Getwd()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return dev.Watch(ctx, dir, out)
|
||||
ctx, stop := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
return dev.Watch(ctx, dir, out, bonfire.GlobalsOf(out).Args())
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -270,6 +372,15 @@ func addPluginCommand() bonfire.Command {
|
||||
return bonfire.Command{
|
||||
Name: "plugin:add",
|
||||
Description: "Register a local plugin module in the app manifest and workspace",
|
||||
Long: `Adds a plugin that already exists on disk (a directory with plugin.go and
|
||||
go.mod, for example one made by make:plugin or a shared plugin checked out as
|
||||
a git submodule) to the application: its ID and module go into summer.yaml, a
|
||||
require and a local replace into the application's go.mod, and the directory
|
||||
into go.work. Run summer build afterwards. A plugin ID or module that is
|
||||
already registered is an error.`,
|
||||
Examples: []bonfire.Example{
|
||||
{Command: "plugin:add plugins/acme/blog", Description: "Register the plugin in plugins/acme/blog"},
|
||||
},
|
||||
Args: []bonfire.Arg{{
|
||||
Name: "dir",
|
||||
Description: "Local plugin directory",
|
||||
@@ -287,7 +398,7 @@ func addPluginCommand() bonfire.Command {
|
||||
if err := build.AddPlugin(ctx, dir, pluginDir); err != nil {
|
||||
return err
|
||||
}
|
||||
out.Printf("added %s\n", pluginDir)
|
||||
out.Success("Added " + pluginDir + " to summer.yaml, go.mod and go.work; run summer build")
|
||||
return nil
|
||||
},
|
||||
}
|
||||
@@ -297,6 +408,16 @@ func newCommand() bonfire.Command {
|
||||
return bonfire.Command{
|
||||
Name: "new",
|
||||
Description: "Create a SummerCMS application in sm-<name>-app",
|
||||
Long: `Creates sm-<name>-app in the current directory: summer.yaml, go.mod and go.work
|
||||
(pointing at a local framework checkout found next to it, or --framework),
|
||||
config/ including config/log.yaml, .env with a fresh app.key and
|
||||
.env.example, compose.yaml for PostgreSQL, a starter plugin (unless
|
||||
--no-plugin) and a git repository (unless --no-git). It prints the next steps:
|
||||
start the database, summer build, summer migrate and summer serve.`,
|
||||
Examples: []bonfire.Example{
|
||||
{Command: "new acme", Description: "Create sm-acme-app with a starter plugin"},
|
||||
{Command: "new acme --no-plugin --no-git", Description: "An empty application without a git repository"},
|
||||
},
|
||||
Args: []bonfire.Arg{{
|
||||
Name: "name",
|
||||
Description: "Application name: lower-case letters and digits, starting with a letter; not a Go keyword, main or internal",
|
||||
|
||||
@@ -18,6 +18,16 @@ func parityProxyCommand() bonfire.Command {
|
||||
return bonfire.Command{
|
||||
Name: "parity:proxy",
|
||||
Description: "Record named HTTP sessions through a loopback reverse proxy",
|
||||
Long: `Runs a recording reverse proxy on a loopback address in front of the reference
|
||||
backend (--upstream). Point a real client (the frontend, an MCP server) at it
|
||||
and use it: each named session, taken from the X-Parity-Session request header
|
||||
or --session, is written as one fixture under --fixtures (nuxt/ or mcp/).
|
||||
Captured secrets go to the --vars file (mode 0600), never into fixtures.
|
||||
Existing fixtures are kept unless --update is true. Ctrl+C stops it.`,
|
||||
Examples: []bonfire.Example{
|
||||
{Command: "parity:proxy --upstream http://127.0.0.1:8000 --fixtures testdata/parity --vars /tmp/parity/vars.yaml", Description: "Record sessions against a reference backend on port 8000"},
|
||||
{Command: "parity:proxy --session checkout --rules testdata/parity/rules.yaml --vars /tmp/parity/vars.yaml", Description: "Record everything as the checkout session with capture rules"},
|
||||
},
|
||||
Flags: []bonfire.Flag{
|
||||
{Name: "listen", Description: "Loopback bind address", Default: tide.DefaultListen},
|
||||
{Name: "upstream", Description: "Fixed loopback HTTP origin", Default: tide.DefaultUpstream},
|
||||
@@ -35,6 +45,16 @@ func parityRecordCommand() bonfire.Command {
|
||||
return bonfire.Command{
|
||||
Name: "parity:record",
|
||||
Description: "Record HTTP responses for a one-flow YAML spec",
|
||||
Long: `Sends the requests of a YAML spec (--spec) to the reference backend (--target)
|
||||
and records the responses as a fixture (--output). With --manifest it instead
|
||||
records the cases of a route manifest that have no fixture yet, at most
|
||||
--next-batch (15) per run; --resume skips valid existing fixtures. Captured
|
||||
secrets go to the --vars file. The true/false flags take the words true or
|
||||
false.`,
|
||||
Examples: []bonfire.Example{
|
||||
{Command: "parity:record --spec testdata/parity/posts.spec.yaml --target http://127.0.0.1:8000 --output testdata/parity/posts.yaml --vars /tmp/parity/vars.yaml", Description: "Record one flow"},
|
||||
{Command: "parity:record --manifest testdata/parity/routes.yaml --fixtures testdata/parity --target http://127.0.0.1:8000 --vars /tmp/parity/vars.yaml --next-batch 10", Description: "Record the next ten unrecorded routes of a manifest"},
|
||||
},
|
||||
Flags: []bonfire.Flag{
|
||||
{Name: "spec", Description: "YAML request spec path"},
|
||||
{Name: "target", Description: "Base URL of the HTTP backend"},
|
||||
@@ -57,6 +77,15 @@ func parityReplayCommand() bonfire.Command {
|
||||
return bonfire.Command{
|
||||
Name: "parity:replay",
|
||||
Description: "Replay recorded fixtures against an HTTP backend",
|
||||
Long: `Replays recorded fixtures (a file or a directory, --fixtures) against a backend,
|
||||
normally the port (--target), and reports each difference after masking IDs
|
||||
and timestamps. --self-check fails on any mismatch; with --manifest and
|
||||
--require-recorded a required case without a fixture fails too. It exits 1
|
||||
when a fixture does not match.`,
|
||||
Examples: []bonfire.Example{
|
||||
{Command: "parity:replay --fixtures testdata/parity --target http://127.0.0.1:8080 --vars /tmp/parity/vars.yaml", Description: "Replay every fixture against the port"},
|
||||
{Command: "parity:replay --fixtures testdata/parity/posts.yaml --target http://127.0.0.1:8000 --self-check true", Description: "Check a fixture against the reference backend itself"},
|
||||
},
|
||||
Flags: []bonfire.Flag{
|
||||
{Name: "fixtures", Description: "Recorded YAML fixture path or directory"},
|
||||
{Name: "target", Description: "Base URL of the HTTP backend"},
|
||||
@@ -73,6 +102,17 @@ func parityBroadcastsCommand() bonfire.Command {
|
||||
return bonfire.Command{
|
||||
Name: "parity:broadcasts",
|
||||
Description: "Record the Centrifugo publications a reference backend sends during a flow into a golden",
|
||||
Long: `Starts a fake Centrifugo server on a loopback address (--listen), runs the flow
|
||||
(--flow) against the reference backend (--target), which must be configured
|
||||
to publish to it, and writes the publications it received into a golden file
|
||||
(--out). --step records only one step, earlier steps running as setup; --ids
|
||||
replaces id:* variables with placeholders; --settle waits for late
|
||||
publications after each step; --pending marks the golden as recorded but not
|
||||
yet asserted.`,
|
||||
Examples: []bonfire.Example{
|
||||
{Command: "parity:broadcasts --flow testdata/parity/chat.flow.yaml --target http://127.0.0.1:8000 --vars /tmp/parity/vars.yaml --out testdata/parity/chat.broadcasts.yaml", Description: "Record the publications of a chat flow"},
|
||||
{Command: "parity:broadcasts --flow testdata/parity/chat.flow.yaml --target http://127.0.0.1:8000 --vars /tmp/parity/vars.yaml --out testdata/parity/send.broadcasts.yaml --step send --ids id:thread", Description: "Record one step with the thread id as a placeholder"},
|
||||
},
|
||||
Flags: []bonfire.Flag{
|
||||
{Name: "flow", Description: "YAML flow spec to run against the target"},
|
||||
{Name: "target", Description: "Loopback base URL of the reference backend"},
|
||||
@@ -95,6 +135,16 @@ func parityUpstreamCommand() bonfire.Command {
|
||||
return bonfire.Command{
|
||||
Name: "parity:upstream",
|
||||
Description: "Record the vendor HTTPS calls a reference backend sends through a loopback recording proxy into an upstream sidecar",
|
||||
Long: `Runs a loopback HTTPS recording proxy (--listen) with a locally generated CA
|
||||
kept in --ca-dir. Configure the reference backend to send its vendor API calls
|
||||
(payments, mail, maps) through it and to trust the CA. In script mode each
|
||||
call is answered from --script; in forward mode it is sent once to the real
|
||||
vendor. When interrupted with Ctrl+C it writes the calls, credentials masked
|
||||
with the --vars values, into the upstream sidecar --out.`,
|
||||
Examples: []bonfire.Example{
|
||||
{Command: "parity:upstream --ca-dir /tmp/parity/ca --out testdata/parity/pay.upstream.yaml --script testdata/parity/pay.script.yaml --vars /tmp/parity/vars.yaml", Description: "Answer vendor calls from a script"},
|
||||
{Command: "parity:upstream --ca-dir /tmp/parity/ca --out testdata/parity/pay.upstream.yaml --mode forward --vars /tmp/parity/vars.yaml", Description: "Forward each call once to the real vendor"},
|
||||
},
|
||||
Flags: []bonfire.Flag{
|
||||
{Name: "listen", Description: "Loopback address of the recording proxy", Default: tide.DefaultUpstreamProxyListen},
|
||||
{Name: "ca-dir", Description: "Directory of the local parity CA, outside the fixtures tree (key kept mode 0600)"},
|
||||
|
||||
@@ -21,29 +21,65 @@ import (
|
||||
var appRunner = runAppBinary
|
||||
|
||||
// delegateCommands returns every command summer runs on the app binary. One
|
||||
// table is the single source: name, base description and a hint naming the
|
||||
// common flags or arguments the app binary accepts.
|
||||
// table is the single source: name, base description, a hint naming the
|
||||
// common flags or arguments the app binary accepts, and two examples.
|
||||
func delegateCommands() []bonfire.Command {
|
||||
rows := []struct{ name, description, hint string }{
|
||||
{"migrate", "Run plugin migrations in the app binary", ""},
|
||||
{"migrate:rollback", "Roll back the last migration of a plugin in the app binary", "--plugin <id>"},
|
||||
{"migrate:status", "Show per-plugin migration history in the app binary", ""},
|
||||
{"serve", "Run the app HTTP server", "--addr <host:port> (default :8080)"},
|
||||
{"queue:work", "Run background job workers in the app binary", "--queue <name> (repeatable)"},
|
||||
{"queue:clear", "Clear pending queued jobs in the app binary", `[queue] argument (default "default")`},
|
||||
{"schedule:run", "Run the scheduler in the app binary (or once for system cron)", "--once"},
|
||||
{"route:list", "List the app's routes from the app binary", ""},
|
||||
{"key:generate", "Print a fresh application key from the app binary", ""},
|
||||
{"admin:create", "Create a backend administrator in the app binary", "--email <email> (required), --login, --role <code>, --superuser; the password is prompted or read from stdin"},
|
||||
{"admin:reset-password", "Reset a backend administrator's password in the app binary", "<identifier> (login or email); the password is prompted or read from stdin"},
|
||||
{"admin:token:create", "Create a personal API token for a backend administrator in the app binary", "<identifier> --name <text> --scope <name> (repeatable) [--expires 30d|90d|365d|never|YYYY-MM-DD]"},
|
||||
{"admin:token:list", "List personal API tokens in the app binary", "[<identifier>] [--revoked]"},
|
||||
{"admin:token:revoke", "Revoke a personal API token by id in the app binary", "<id>"},
|
||||
{"admin:token:scopes", "List available personal API token scopes in the app binary", ""},
|
||||
ex := func(cmd, desc string) bonfire.Example { return bonfire.Example{Command: cmd, Description: desc} }
|
||||
rows := []struct {
|
||||
name, description, hint string
|
||||
examples []bonfire.Example
|
||||
}{
|
||||
{"migrate", "Run plugin migrations in the app binary", "--pretend, --force", []bonfire.Example{
|
||||
ex("migrate", "Apply every pending migration"),
|
||||
ex("migrate --pretend", "List the pending migrations without applying them")}},
|
||||
{"migrate:rollback", "Roll back the last migration of a plugin in the app binary", "--plugin <id>, --pretend, --force", []bonfire.Example{
|
||||
ex("migrate:rollback --plugin acme.blog", "Roll back the last migration of acme.blog"),
|
||||
ex("migrate:rollback --plugin acme.blog --pretend", "Name the migration that would roll back")}},
|
||||
{"migrate:status", "Show per-plugin migration history in the app binary", "--json", []bonfire.Example{
|
||||
ex("migrate:status", "Show the applied migrations per plugin"),
|
||||
ex("migrate:status --json", "Machine-readable status")}},
|
||||
{"serve", "Run the app HTTP server", "--addr <host:port> (default :8080)", []bonfire.Example{
|
||||
ex("serve", "Serve on port 8080"),
|
||||
ex("serve --addr 127.0.0.1:8123", "Serve on loopback port 8123")}},
|
||||
{"queue:work", "Run background job workers in the app binary", "--queue <name> (repeatable)", []bonfire.Example{
|
||||
ex("queue:work", "Work every known queue"),
|
||||
ex("queue:work --queue mail", "Work only the mail queue")}},
|
||||
{"queue:clear", "Clear pending queued jobs in the app binary", `[queue] argument (default "default"), --force`, []bonfire.Example{
|
||||
ex("queue:clear", "Clear the default queue"),
|
||||
ex("queue:clear imports --force", "Clear the imports queue without the production question")}},
|
||||
{"schedule:run", "Run the scheduler in the app binary (or once for system cron)", "--once", []bonfire.Example{
|
||||
ex("schedule:run", "Run the scheduler in the foreground"),
|
||||
ex("schedule:run --once", "Run the entries due now and exit")}},
|
||||
{"route:list", "List the app's routes from the app binary", "--json", []bonfire.Example{
|
||||
ex("route:list", "Show every route"),
|
||||
ex("route:list --json", "Machine-readable route list")}},
|
||||
{"key:generate", "Print a fresh application key from the app binary", "--json", []bonfire.Example{
|
||||
ex("key:generate", "Print a new key"),
|
||||
ex("key:generate -q", "Print only the key")}},
|
||||
{"admin:create", "Create a backend administrator in the app binary", "--email <email> (required), --login, --role <code>, --superuser; the password is prompted or read from stdin", []bonfire.Example{
|
||||
ex("admin:create --email admin@example.com --superuser", "Create a superuser; the password is asked for"),
|
||||
ex("admin:create --email editor@example.com --role editor", "Create an administrator with a role")}},
|
||||
{"admin:reset-password", "Reset a backend administrator's password in the app binary", "<identifier> (login or email); the password is prompted or read from stdin", []bonfire.Example{
|
||||
ex("admin:reset-password admin@example.com", "Reset a password; it is asked for"),
|
||||
ex("admin:reset-password admin", "Reset by login")}},
|
||||
{"admin:token:create", "Create a personal API token for a backend administrator in the app binary", "<identifier> --name <text> --scope <name> (repeatable) [--expires 30d|90d|365d|never|YYYY-MM-DD] [--json]", []bonfire.Example{
|
||||
ex("admin:token:create jane --name agent --scope journal.posts", "A token limited to one scope"),
|
||||
ex("admin:token:create jane --scope journal.posts --json", "The token and its details as JSON")}},
|
||||
{"admin:token:list", "List personal API tokens in the app binary", "[<identifier>] [--revoked] [--json]", []bonfire.Example{
|
||||
ex("admin:token:list", "Every active token"),
|
||||
ex("admin:token:list jane --revoked", "Every token of one administrator")}},
|
||||
{"admin:token:revoke", "Revoke a personal API token by id in the app binary", "<id>", []bonfire.Example{
|
||||
ex("admin:token:revoke 12", "Revoke token 12"),
|
||||
ex("admin:token:revoke 7 -q", "Revoke token 7 and print nothing but errors")}},
|
||||
{"admin:token:scopes", "List available personal API token scopes in the app binary", "--json", []bonfire.Example{
|
||||
ex("admin:token:scopes", "Show every scope"),
|
||||
ex("admin:token:scopes --json", "Machine-readable scope list")}},
|
||||
}
|
||||
cmds := make([]bonfire.Command, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
cmds = append(cmds, delegateCommand(r.name, r.description, r.hint))
|
||||
c := delegateCommand(r.name, r.description, r.hint)
|
||||
c.Examples = r.examples
|
||||
cmds = append(cmds, c)
|
||||
}
|
||||
return cmds
|
||||
}
|
||||
@@ -53,7 +89,8 @@ func delegateCommands() []bonfire.Command {
|
||||
func delegateCommand(name, description, hint string) bonfire.Command {
|
||||
long := description + ".\n\nsummer runs bin/<binary> " + name +
|
||||
" in the application directory, building the binary first when it is missing. Every flag and argument after " +
|
||||
name + ", including --help, is passed to the app binary unchanged."
|
||||
name + ", including --help, is passed to the app binary unchanged, and so are the global options (-v, -q, --ansi, --no-ansi, -n, --env). " +
|
||||
"summer fails when the app binary fails."
|
||||
if hint != "" {
|
||||
long += "\n\nCommon flags: " + hint + "."
|
||||
}
|
||||
|
||||
@@ -15,56 +15,92 @@ WinterCMS has one console entry point, `php artisan`. SummerCMS has two programs
|
||||
|
||||
The application binary is what you deploy, so everything that must run in production, such as migrations and workers, is a command of the binary rather than of `summer`.
|
||||
|
||||
Run with no arguments, the binary prints its usage and every command it has, the framework's and those its plugins add; `--help` prints the same:
|
||||
Run with no arguments, the binary prints the list of every command it has, the framework's and those its plugins add, the way `php artisan` does; `--help` and `list` print the same:
|
||||
|
||||
```sh
|
||||
./bin/acme
|
||||
./bin/acme --help
|
||||
./bin/acme list
|
||||
```
|
||||
|
||||
```text
|
||||
SummerCMS acme v1.4.0 (framework v0.3.2)
|
||||
|
||||
Usage:
|
||||
acme [command]
|
||||
bin/acme <command> [options] [arguments]
|
||||
|
||||
Available Commands:
|
||||
admin:create Create an activated backend administrator
|
||||
admin:reset-password Reset a backend administrator password and revoke existing tokens
|
||||
admin:token:create Create a personal API token for a backend administrator
|
||||
admin:token:list List personal API tokens for a backend administrator (or all)
|
||||
admin:token:revoke Revoke a personal API token by id
|
||||
admin:token:scopes List available personal API token scopes
|
||||
deferred:purge Delete expired deferred bindings with their pending uploads and created records
|
||||
greeter:hello Print the configured application name
|
||||
help Help about any command
|
||||
key:generate Print a fresh 32-byte base64 app.key
|
||||
migrate Run plugin migrations in dependency order
|
||||
migrate:rollback Roll back the last migration of a plugin
|
||||
migrate:status Show per-plugin migration history
|
||||
queue:clear Clear all queued jobs, by deleting all pending jobs.
|
||||
queue:work Run background job workers in the foreground
|
||||
route:list List registered HTTP routes
|
||||
schedule:run Run the scheduler in the foreground (or once for system cron)
|
||||
serve Serve the application HTTP API
|
||||
Options:
|
||||
-h, --help Display help for the given command
|
||||
-q, --quiet Do not output any message
|
||||
-v|vv|vvv Increase verbosity
|
||||
--ansi|no-ansi Force (or disable) ANSI output
|
||||
-n, --no-interaction Do not ask any interactive question
|
||||
--env The environment the command runs under
|
||||
|
||||
Flags:
|
||||
-h, --help help for acme
|
||||
|
||||
Use "acme [command] --help" for more information about a command.
|
||||
Available commands:
|
||||
help Display help for a command
|
||||
list List commands
|
||||
migrate Run plugin migrations in dependency order
|
||||
serve Serve the application HTTP API
|
||||
admin
|
||||
admin:create Create an activated backend administrator
|
||||
admin:reset-password Reset a backend administrator password and revoke existing tokens
|
||||
admin:token:create Create a personal API token for a backend administrator
|
||||
migrate
|
||||
migrate:rollback Roll back the last migration of a plugin
|
||||
migrate:status Show per-plugin migration history
|
||||
queue
|
||||
queue:work Run background job workers in the foreground
|
||||
```
|
||||
|
||||
The header names the application, its version and the framework version it was built against (`local` when the framework comes from a local checkout). Commands without a namespace come first, then one block per namespace, the part of the name before the first colon. `summer` with no arguments prints its own list in the same layout.
|
||||
|
||||
## Getting help
|
||||
|
||||
Both programs list their commands with `--help`, and every command accepts `--help` for its arguments and flags:
|
||||
Both programs list their commands with no arguments, with `--help` and with `list`, and every command accepts `--help` for its arguments and flags:
|
||||
|
||||
```sh
|
||||
summer --help
|
||||
summer
|
||||
summer make:model --help
|
||||
summer help migrate
|
||||
./bin/acme --help
|
||||
./bin/acme migrate:rollback --help
|
||||
./bin/acme help migrate:rollback
|
||||
./bin/acme list --all
|
||||
./bin/acme list --format=json
|
||||
```
|
||||
|
||||
A command's help shows its description, usage, arguments, options with their defaults, the global options, the long help and examples. `list --all` includes hidden commands, and `list --format=json` prints every command with its arguments, options and examples for scripts and agents.
|
||||
|
||||
A command that `summer` delegates to the application binary, such as `summer serve`, answers `--help` with the binary's own help, so run it inside an application directory. Asking the help command of `summer` about a delegated command instead prints a short description and its common flags without running the binary.
|
||||
|
||||
### Global options
|
||||
|
||||
Every command of both programs accepts these options, before or after the command name:
|
||||
|
||||
| Option | Effect |
|
||||
|--------|--------|
|
||||
| `-v`, `-vv`, `-vvv` (`--verbose`) | More output: `-v` shows info logs of every component, `-vv` debug logs, `-vvv` also source locations and SQL traces. |
|
||||
| `-q`, `--quiet` | Hides info and success lines, spinners and progress, and shows only warnings and errors in the console log; data a command prints (tokens, keys, JSON) and errors still appear. Implies `-n`. |
|
||||
| `--ansi`, `--no-ansi` | Forces or turns off colors and styling; `--no-ansi` also stops animations. The last one given wins. Without either, colors follow the terminal, `NO_COLOR` and `FORCE_COLOR`. |
|
||||
| `-n`, `--no-interaction` | Asks no questions: prompts take their defaults, and a password prompt reads piped stdin or fails with a hint. |
|
||||
| `--env` | The configuration environment, as `SUMMER_ENV` would set it: `--env staging` or `--env=staging`. |
|
||||
|
||||
The application binary reads these options before it loads `config/` and starts plugins, so `--env` picks the configuration and `-vv` applies from the first log line:
|
||||
|
||||
```sh
|
||||
./bin/acme -vv migrate
|
||||
./bin/acme migrate:status --env staging
|
||||
```
|
||||
|
||||
### Exit codes
|
||||
|
||||
| Code | Meaning |
|
||||
|------|---------|
|
||||
| `0` | The command succeeded. |
|
||||
| `1` | The command failed; the error is printed on stderr. |
|
||||
| `2` | The command was called wrongly: an unknown command or flag, or a wrong number of arguments. |
|
||||
|
||||
## Commands that summer delegates
|
||||
|
||||
During development you often work from the application directory with `summer` alone. These `summer` commands find the application's `summer.yaml`, build `bin/<binary>` when it does not exist yet, and run the same command of the binary with the same arguments:
|
||||
@@ -98,6 +134,13 @@ summer admin:create --email admin@example.com --superuser
|
||||
|
||||
One leading `--` is ignored, so `summer serve -- --addr 127.0.0.1:8123` behaves the same as `summer serve --addr 127.0.0.1:8123`.
|
||||
|
||||
The global options go to the binary too, wherever you put them: `summer -v migrate --pretend` runs `./bin/acme -v migrate --pretend`, and `summer -q key:generate` prints only the key. `summer help migrate` shows each delegate's examples; the binary's own help, with every flag, is `summer migrate --help`:
|
||||
|
||||
```sh
|
||||
summer -v migrate --pretend
|
||||
summer admin:token:create jane --scope journal.posts --json
|
||||
```
|
||||
|
||||
When `summer` receives SIGINT or SIGTERM while the binary runs, it passes the signal on and waits for the binary to shut down, so stopping `summer serve` with Ctrl+C or `kill` frees its port.
|
||||
|
||||
`summer` does not rebuild an existing binary before it delegates. Run `summer build` after you change code, or keep `summer dev` running. Every other runtime command, such as `deferred:purge` or the commands your plugins add, is run on the binary directly.
|
||||
|
||||
@@ -12,14 +12,16 @@ The `summer` tool creates and builds applications and generates the files a plug
|
||||
|
||||
| Command | Purpose |
|
||||
|---------|---------|
|
||||
| `summer build` | Reads `summer.yaml`, generates `plugins.gen.go` and `main.go`, and builds the binary into `bin/`, passing the manifest's `build.tags` to `go build` as `-tags`. Run it from the application directory or any directory below it. |
|
||||
| `summer dev` | Builds the application as `summer build` does (build tags included), starts the binary, and rebuilds and restarts it whenever a Go or YAML source, `go.mod`, `go.work`, `.env` or `summer.yaml` changes. |
|
||||
| `summer build` | Reads `summer.yaml`, generates `plugins.gen.go` and `main.go`, and builds the binary into `bin/`, passing the manifest's `build.tags` to `go build` as `-tags`. On a terminal a spinner runs while `go build` works; it ends with `Built bin/<binary> in <time>`. Run it from the application directory or any directory below it. |
|
||||
| `summer dev` | Builds the application as `summer build` does (build tags included), runs `bin/<binary> serve`, and rebuilds and restarts it whenever a Go or YAML source, `go.mod`, `go.work`, `.env` or `summer.yaml` changes. A failed build keeps the running server. |
|
||||
|
||||
```sh
|
||||
summer build
|
||||
summer dev
|
||||
```
|
||||
|
||||
`summer dev` passes its global options on to `serve`: `summer dev -v` shows the per-plugin lines of the startup summary and every component's info logs, `summer dev -q` only warnings and errors. Ctrl+C stops `serve`, which prints its shutdown report, then `summer dev` prints its own report (rebuilds, failed builds, restarts, uptime) and exits 0.
|
||||
|
||||
Do not edit `main.go` or `plugins.gen.go`: `summer build` rewrites them from the manifest.
|
||||
|
||||
## Creating an application
|
||||
@@ -62,7 +64,7 @@ summer migrate
|
||||
summer serve
|
||||
```
|
||||
|
||||
The next steps also explain the admin. It opens at `/backend` once a plugin registers an admin controller; until then `summer serve` prints `admin: off` with these steps:
|
||||
The next steps also explain the admin. It opens at `/backend` once a plugin registers an admin controller; until then the Admin line of `summer serve`'s startup summary reads `off`, followed by these steps:
|
||||
|
||||
```sh
|
||||
summer make:admin-controller golem15.acme Posts
|
||||
@@ -117,4 +119,17 @@ File names are the snake-case form of the name: `AddPublishedAt` becomes `add_pu
|
||||
|
||||
After writing the files, every `make:` command regenerates the plugin's `registry.gen.go`, which lists the plugin's models, migrations, commands, jobs and admin controllers, and runs `go mod tidy` in the plugin. The capability methods of a scaffolded `plugin.go` return those generated lists. If your `plugin.go` was written by hand and does not call them, the command prints a note naming the accessors to add.
|
||||
|
||||
A command refuses to overwrite an existing file or to declare a name the package already has.
|
||||
Each command prints `Created <path>` for every file it writes. A command refuses to overwrite an existing file or to declare a name the package already has.
|
||||
|
||||
When a `make:` command cannot do its work, the error says why and what to run instead. Outside an application (no `summer.yaml` in the directory or above it) it reads:
|
||||
|
||||
```text
|
||||
ERROR build: no summer.yaml found from /home/jane
|
||||
|
||||
summer make:model runs inside an application: a directory with summer.yaml, or one below it.
|
||||
|
||||
Try:
|
||||
cd <app>; summer make:model acme.blog Post
|
||||
```
|
||||
|
||||
An invalid plugin ID (`Acme.Blog`), a name that is not an exported Go identifier (`post`), an artifact that already exists and a plugin that `summer.yaml` does not list get the same kind of explanation, with a valid command form to try.
|
||||
|
||||
@@ -12,28 +12,39 @@ Every application binary carries the framework's runtime commands. The examples
|
||||
|
||||
| Command | Flags | Purpose |
|
||||
|---------|-------|---------|
|
||||
| `migrate` | none | Runs the framework migrations, then every plugin's migrations in dependency order. |
|
||||
| `migrate:rollback` | `--plugin <id>` | Rolls back the last migration of the plugin; without the flag, of the last activated plugin that has migrations. |
|
||||
| `migrate:status` | none | Prints a table of plugin, history table and applied migration IDs. |
|
||||
| `migrate` | `--pretend`, `--force` | Runs the framework migrations, then every plugin's migrations in dependency order, printing how many each set applied and `Applied N migrations` (or `Nothing to migrate`). `--pretend` lists the pending migrations as `<plugin>: <id>` and applies nothing. |
|
||||
| `migrate:rollback` | `--plugin <id>`, `--pretend`, `--force` | Rolls back the last migration of the plugin; without the flag, of the last activated plugin that has migrations. Prints `Rolled back <id> of <plugin>`; `--pretend` only names it. |
|
||||
| `migrate:status` | `--json` | Prints a table of plugin, history table and applied migration IDs. `--json` prints every declared migration of those plugins with `plugin`, `table`, `id` and `applied`. |
|
||||
|
||||
```sh
|
||||
./bin/acme migrate --pretend
|
||||
./bin/acme migrate
|
||||
./bin/acme migrate:status
|
||||
./bin/acme migrate:status --json
|
||||
./bin/acme migrate:rollback --plugin acme.blog
|
||||
```
|
||||
|
||||
Each plugin keeps its own migration history table, so rolling back one plugin never touches another. The migrations are listed in [lagoon](../../modules/lagoon/README.md).
|
||||
|
||||
### Confirmation in production
|
||||
|
||||
`migrate`, `migrate:rollback`, `deferred:purge` and `queue:clear` change or delete data. When the environment is `production` and the command runs on an interactive terminal, they ask before doing it; answering no prints a cancellation and exits 1. `--force` skips the question. Off a terminal (a deploy over SSH, a container entrypoint, the scheduler) and with `-n` they never ask and proceed as before, so deploy scripts need no change.
|
||||
|
||||
```sh
|
||||
./bin/acme migrate --force
|
||||
```
|
||||
|
||||
When the database cannot be reached, or `database.dsn` is empty, the error names the fix (`docker compose up -d --wait`, `cp .env.example .env`). Run from a directory below the application root, an application binary finds no `config/` and adds a hint to its error with the command to run instead, for example `cd ../..; bin/acme migrate`.
|
||||
|
||||
## Pending uploads and related records
|
||||
|
||||
An admin form holds the files uploaded and the related records created for a record that is not saved yet as deferred bindings. Saving the record commits them; abandoned ones are removed by `deferred:purge`:
|
||||
|
||||
| Command | Flags | Purpose |
|
||||
|---------|-------|---------|
|
||||
| `deferred:purge` | `--days <n>` | Deletes deferred bindings older than `n` days, the unattached uploads they hold and the records created under deferral, and prints the counts. Records that were only linked are kept. Without the flag, `database.deferred_bindings.purge_days` applies, else 5 days. |
|
||||
| `deferred:purge` | `--days <n>`, `--pretend`, `--force` | Deletes deferred bindings older than `n` days, the unattached uploads they hold and the records created under deferral, and prints the counts. Records that were only linked are kept. Without the flag, `database.deferred_bindings.purge_days` applies, else 5 days. `--pretend` counts the bindings that would go and deletes nothing. |
|
||||
|
||||
```sh
|
||||
./bin/acme deferred:purge
|
||||
./bin/acme deferred:purge --pretend
|
||||
./bin/acme deferred:purge --days=1
|
||||
```
|
||||
|
||||
@@ -43,10 +54,11 @@ The scheduler already runs it every day at 03:00; see [Task scheduling](../plugi
|
||||
|
||||
| Command | Flags | Purpose |
|
||||
|---------|-------|---------|
|
||||
| `key:generate` | none | Prints a fresh base64 32-byte key for `app.key`. It writes nothing and needs no database. |
|
||||
| `key:generate` | `--json` | Prints a fresh base64 32-byte key for `app.key`. It writes nothing and needs no database. With `-q` it prints only the key; `--json` prints `{"key": "..."}`. |
|
||||
|
||||
```sh
|
||||
./bin/acme key:generate
|
||||
KEY=$(./bin/acme key:generate -q)
|
||||
```
|
||||
|
||||
Copy the printed value into `SUMMER_APP__KEY`. When you rotate the key, keep the old one in `app.previous_keys` so existing encrypted columns can still be read.
|
||||
@@ -55,8 +67,8 @@ Copy the printed value into `SUMMER_APP__KEY`. When you rotate the key, keep the
|
||||
|
||||
| Command | Flags | Purpose |
|
||||
|---------|-------|---------|
|
||||
| `serve` | `--addr` (default `:8080`) | Opens the database and uploads bucket, builds the router, binds the address, starts the in-process job worker and serves HTTP until SIGINT or SIGTERM, then shuts down within 10 seconds. After binding it prints the listen address with a browser URL (an unspecified host shows as 127.0.0.1) and an admin status line: the admin URL, or `admin: off` with the steps to add an admin controller. |
|
||||
| `route:list` | none | Builds the router the way `serve` does, without opening the database or listening, and prints every route with its method, pattern, plugin, middleware and raw flag, followed by the admin status line. |
|
||||
| `serve` | `--addr` (default `:8080`) | Opens the database and uploads bucket, builds the router, binds the address, starts the in-process job worker and serves HTTP until SIGINT or SIGTERM. Once it listens it prints a startup summary; on a signal it drains requests and the worker within 10 seconds and prints a shutdown report. |
|
||||
| `route:list` | `--json` | Builds the router the way `serve` does, without opening the database or listening, and prints every route with its method, pattern, plugin, middleware and raw flag, followed by the admin status line. `--json` prints the routes as a JSON array. |
|
||||
|
||||
```sh
|
||||
./bin/acme route:list
|
||||
@@ -67,16 +79,54 @@ The default `--addr` listens on every interface. Pass a loopback address during
|
||||
|
||||
From the application directory, `summer serve --addr 127.0.0.1:8123` passes the flag through to the built binary.
|
||||
|
||||
### Startup summary
|
||||
|
||||
On a terminal `serve` prints a block once the port is bound:
|
||||
|
||||
```text
|
||||
SummerCMS acme v1.4.0 (framework v0.4.0)
|
||||
Environment production
|
||||
URL http://127.0.0.1:8080/
|
||||
Admin http://127.0.0.1:8080/backend
|
||||
Database db.internal:5432/acme
|
||||
Migrations up to date
|
||||
Plugins 3 (acme.user, acme.blog, acme.shop)
|
||||
Queue default, mail, scheduled (worker in serve)
|
||||
Scheduler in serve
|
||||
Mail smtp
|
||||
Cache redis
|
||||
Realtime null
|
||||
Search null
|
||||
```
|
||||
|
||||
The database line shows the host, port and database name, never the user or password. Pending migrations read `3 pending — run bin/acme migrate`. The admin line shows the admin URL, or `off` with the steps to add an admin controller; a warning naming `summer admin:create` follows when the `backend_users` table is empty. `-v` adds one line per plugin with the plugins it requires and its pending migrations, and lets every component's info logs through; `-q` prints no summary, only warnings and errors.
|
||||
|
||||
Off a terminal (systemd, Docker, a redirected log) nothing is printed on stdout: the same facts are logged as one `startup summary` record and one record whose message is exactly `listening on <addr>`, both from the `serve` component, so journald and the daily files keep them and scripts can wait for the second one. On a terminal the console does not repeat the two records after the block; a file channel whose level lets Info through still gets them (the default daily channel keeps only warnings and errors).
|
||||
|
||||
### Shutting down
|
||||
|
||||
Ctrl+C or SIGTERM drains the requests in flight for up to 10 seconds, stops the in-process worker and prints a report (on a terminal) and a `shutdown report` record:
|
||||
|
||||
```text
|
||||
Shutdown report
|
||||
Requests 1520 served, 2 drained, 0 aborted
|
||||
Worker 84 done, 1 failed, 0 handed back to the queue
|
||||
Uptime 3h12m40s
|
||||
Errors none
|
||||
```
|
||||
|
||||
Jobs still running at the deadline are cancelled; River records the cancelled attempt and retries the job later, which the report counts as handed back to the queue. `serve` exits 0 after a clean stop and 1 when draining fails (requests still running at the deadline, or the worker failing to stop).
|
||||
|
||||
## Admin accounts
|
||||
|
||||
| Command | Arguments and flags | Purpose |
|
||||
|---------|---------------------|---------|
|
||||
| `admin:create` | `--email` (required), `--login`, `--role <code>`, `--superuser`, `--password` (deprecated) | Creates an activated backend administrator. `--login` defaults to the lower-cased email. The password is read from a hidden prompt, or from stdin when the input is not a terminal. |
|
||||
| `admin:reset-password` | `<identifier>` (login or email), `--password` (deprecated) | Sets a new password, read like the one of `admin:create`, and revokes every JWT issued before the reset. Personal API tokens are unchanged. |
|
||||
| `admin:token:create` | `<identifier>`, `--name`, `--scope` (repeatable), `--expires` (`30d`, `90d`, `365d`, `never`, or `YYYY-MM-DD`) | Creates a personal API token for the named administrator and prints exactly the plaintext and a newline on stdout. |
|
||||
| `admin:token:list` | `[<identifier>]`, `--revoked` | Lists tokens (hint only, never the hash or plaintext). |
|
||||
| `admin:token:create` | `<identifier>`, `--name`, `--scope` (repeatable), `--expires` (`30d`, `90d`, `365d`, `never`, or `YYYY-MM-DD`), `--json` | Creates a personal API token for the named administrator and prints exactly the plaintext and a newline on stdout, also under `-q`. `--json` prints `id`, `name`, `token`, `scopes` and `expires_at`. |
|
||||
| `admin:token:list` | `[<identifier>]`, `--revoked`, `--json` | Lists tokens (hint only, never the hash or plaintext); `--json` as a JSON array. |
|
||||
| `admin:token:revoke` | `<id>` | Soft-revokes any user's active token by id. |
|
||||
| `admin:token:scopes` | none | Lists framework and plugin scopes available for tokens. |
|
||||
| `admin:token:scopes` | `--json` | Lists framework and plugin scopes available for tokens; `--json` as a JSON array. |
|
||||
|
||||
```sh
|
||||
./bin/acme admin:create --email admin@example.com --superuser
|
||||
@@ -88,15 +138,15 @@ TOKEN=$(./bin/acme admin:token:create admin@example.com --name agent --scope blo
|
||||
|
||||
From the application directory, `summer admin:create --email admin@example.com --superuser` runs the same command on the built binary, prompt included. The four `admin:token:*` commands have matching `summer` delegates.
|
||||
|
||||
When the admin is mounted and the `backend_users` table is empty, `serve` prints a warning at start-up naming `summer admin:create`.
|
||||
When the admin is mounted and the `backend_users` table is empty, `serve` prints a warning after its startup summary naming `summer admin:create`.
|
||||
|
||||
## Queues and the scheduler
|
||||
|
||||
| Command | Arguments and flags | Purpose |
|
||||
|---------|---------------------|---------|
|
||||
| `queue:work` | `--queue <name>`, repeatable | Runs a job worker in the foreground on the named queues (default: every known queue) until SIGINT or SIGTERM. An unknown queue is an error that lists the known ones. |
|
||||
| `queue:clear` | `[queue]` (default `default`) | Deletes the waiting, scheduled and retryable jobs of one queue and prints how many it cleared. Running jobs are never touched. |
|
||||
| `schedule:run` | `--once` | Without `--once`, runs a scheduler-only worker until stopped. With `--once`, runs the entries due in the current minute and exits, for system cron. |
|
||||
| `queue:work` | `--queue <name>`, repeatable | Runs a job worker in the foreground on the named queues (default: every known queue) until SIGINT or SIGTERM, with a startup summary and a shutdown report. An unknown queue is an error that lists the known ones. |
|
||||
| `queue:clear` | `[queue]` (default `default`), `--force` | Deletes the waiting, scheduled and retryable jobs of one queue and prints `Cleared N jobs from <queue>`. Running jobs are never touched. |
|
||||
| `schedule:run` | `--once` | Without `--once`, runs a scheduler-only worker until stopped, with a startup summary and a shutdown report. With `--once`, runs the entries due in the current minute, prints `Ran N due entries (M failed)` and exits, for system cron. |
|
||||
|
||||
```sh
|
||||
./bin/acme queue:work --queue default --queue imports
|
||||
@@ -104,6 +154,8 @@ When the admin is mounted and the `backend_users` table is empty, `serve` prints
|
||||
./bin/acme schedule:run --once
|
||||
```
|
||||
|
||||
`queue:work` prints a startup summary on a terminal: the environment, each queue with its concurrency, the database host and whether the `scheduled` queue runs in this worker. `schedule:run` prints the number of schedule entries and the next one due. Off a terminal they log the same facts as a `startup summary` record (components `queue` and `schedule`). On Ctrl+C or SIGTERM both stop within 10 seconds and print a shutdown report: jobs done, failed and handed back to the queue (jobs still running at the deadline are cancelled and River retries them), the uptime and any stop error. They exit 0 after a clean stop and 1 when the worker fails to stop.
|
||||
|
||||
`serve` runs a job worker in the same process unless `queue.work_in_serve` is `false`; set it to `false` when you run `queue:work` separately. [Task scheduling](../plugins/scheduling.md) explains the scheduler, and [conga](../../modules/conga/README.md) the queue settings.
|
||||
|
||||
## Realtime and push
|
||||
@@ -112,9 +164,9 @@ These commands are not added by the generated `main`. An application that uses C
|
||||
|
||||
| Command | Arguments and flags | Purpose |
|
||||
|---------|---------------------|---------|
|
||||
| `websockets:health` | none | Calls the Centrifugo `info` API and prints the configuration. Exits 1 when the API key is missing or the call fails. The key itself is never printed. |
|
||||
| `websockets:health` | `--json` | Calls the Centrifugo `info` API and prints the configuration. Exits 1 when the API key is missing or the call fails. The key itself is never printed. `--json` prints `healthy`, `api_url`, `enabled`, `api_key_set` and `error` for monitoring. |
|
||||
| `websockets:generate-vapid-keys` | `--update`, `--show-current` | Shows the configured VAPID keys, truncated, then generates a new pair. With `--update` it saves them to the environment's `overrides.yaml`; without it, it prints the variables to set by hand. |
|
||||
| `websockets:test-push` | `<user_id>`, `--show-config` | Lists a user's push subscriptions and, after confirmation, sends one encrypted test notification to each. |
|
||||
| `websockets:test-push` | `<user_id>`, `--show-config` | Lists a user's push subscriptions and, after confirmation (yes by default, so `-n` sends without asking), sends one encrypted test notification to each. |
|
||||
|
||||
```sh
|
||||
./bin/acme websockets:health
|
||||
|
||||
@@ -20,7 +20,7 @@ When you port an existing backend to SummerCMS, its real responses are the contr
|
||||
| `summer parity:broadcasts` | `--flow`, `--target`, `--vars`, `--listen` (default `127.0.0.1:8424`), `--out`, `--name`, `--step`, `--ids`, `--rules`, `--api-key`, `--settle` (default `500ms`), `--pending` | Runs a flow against the reference backend with a fake Centrifugo server and records the realtime publications it sends into a golden file. |
|
||||
| `summer parity:upstream` | `--listen` (default `127.0.0.1:8425`), `--ca-dir`, `--out`, `--mode` (default `script`; or `forward`), `--script`, `--vars` | Runs a loopback HTTPS recording proxy with a locally generated CA. The reference backend sends its vendor API calls through it; each call is answered from the `--script` file (or forwarded once to the vendor in `forward` mode) and written, with credentials masked, into an upstream sidecar (`<fixture>.upstream.yaml`) when the command is interrupted. |
|
||||
|
||||
A typical port records once against the reference backend and replays against the Go backend on every change:
|
||||
Each command's help (`summer help parity:record`) explains its place in this workflow and shows examples. A typical port records once against the reference backend and replays against the Go backend on every change:
|
||||
|
||||
```sh
|
||||
summer parity:record --spec testdata/parity/posts.spec.yaml --target http://127.0.0.1:8000 --output testdata/parity/posts.yaml --vars /tmp/parity/vars.yaml
|
||||
@@ -35,7 +35,7 @@ These docs are Markdown files under `docs/`, plus every module README, built int
|
||||
|---------|-------|---------|
|
||||
| `summer docs:build` | `--root` (default `.`), `--src`, `--out`, `--base-url`, `--site-url`, `--site-label`, `--check` | Checks every page and writes the site to `site/` (or `--out`): HTML pages, a raw `.md` copy of each page, `llms.txt`, `llms-full.txt` and the search index. With `--check` it only reports problems and writes nothing. |
|
||||
| `summer docs:sync` | `--root` (default `.`), `--src` | Rewrites every code block that has a `src=` reference from its source file. |
|
||||
| `summer docs:serve` | `--root` (default `.`), `--src`, `--base-url`, `--site-url`, `--site-label`, `--addr` (default `127.0.0.1:8088`), `--allow-remote` | Builds the site into a temporary directory, serves it and rebuilds when a page, a module or a referenced source changes. A failed rebuild prints its problems and keeps serving the last good build. |
|
||||
| `summer docs:serve` | `--root` (default `.`), `--src`, `--base-url`, `--site-url`, `--site-label`, `--addr` (default `127.0.0.1:8088`), `--allow-remote` | Builds the site into a temporary directory, serves it and rebuilds when a page, a module or a referenced source changes. A failed rebuild prints its problems and keeps serving the last good build. Ctrl+C stops it and exits 0. |
|
||||
|
||||
`docs/site.yaml` accepts two optional keys, `site_url` and `site_label`, that add a link back to the main site to every page header. With `site_url: https://acme.example/` the link reads "acme.example". Without `site_label` the label is the URL's host, or Home when `site_url` is a path such as `/`. `site_url` must be an `http://` or `https://` URL with a host, or a path starting with a single `/`. The `--site-url` and `--site-label` flags override the two keys the way `--base-url` overrides `base_url`.
|
||||
|
||||
@@ -45,6 +45,6 @@ summer docs:sync
|
||||
summer docs:serve
|
||||
```
|
||||
|
||||
`docs:build` fails, and writes nothing, when a page names an identifier that does not exist, links to a missing page or anchor, shows a command that neither `summer` nor an application binary has, or has a `src=` code block that differs from its source. It also fails on a Go code block (including `golang`) without a `src=` reference, a `src=` code block inside a callout, blockquote or list item (`src=` blocks must be top-level), and a `src=` code block in a module README. A `src=` target must be code `go test ./...` compiles and runs, that is a file in the default build, inside a `Test` function or an `Example` with an `// Output:` comment, or a function one of them calls. After you change code that a page shows, run `summer docs:sync` to refresh the copies.
|
||||
`summer help docs:build` and `summer help docs:sync` show examples. `docs:build` fails, and writes nothing, when a page names an identifier that does not exist, links to a missing page or anchor, shows a command that neither `summer` nor an application binary has, or has a `src=` code block that differs from its source. It also fails on a Go code block (including `golang`) without a `src=` reference, a `src=` code block inside a callout, blockquote or list item (`src=` blocks must be top-level), and a `src=` code block in a module README. A `src=` target must be code `go test ./...` compiles and runs, that is a file in the default build, inside a `Test` function or an `Example` with an `// Output:` comment, or a function one of them calls. After you change code that a page shows, run `summer docs:sync` to refresh the copies.
|
||||
|
||||
`docs:serve` listens only on a loopback address unless you pass `--allow-remote`. Use it to preview search, which browsers block when you open the built files directly from disk.
|
||||
|
||||
@@ -16,7 +16,7 @@ A `bonfire.Command` has a name, a description, its positional arguments and flag
|
||||
- Each `bonfire.Arg` is a positional argument with a name, a description and a `Required` marker. The usage line shows required arguments as `<name>` and optional ones as `[name]`.
|
||||
- Each `bonfire.Flag` is a string flag. Set `bonfire.Flag.Bare` for a switch such as `--dry-run` that stores `true` when given alone, and `bonfire.Flag.Repeatable` for a flag that can be given several times.
|
||||
- `bonfire.Command.Run` receives the context, a `bonfire.Input` and a `bonfire.Output`.
|
||||
- `bonfire.Command.Long` is optional extended help shown by the help command; without it the description is shown. Set `bonfire.Command.Passthrough` for a command that forwards to another program: flag parsing is turned off, so every argument after the command name, including `--help`, `-h` and `--`, reaches `bonfire.Input.Args` unchanged. Declared flags then appear in the usage text only, and declared arguments are not count-checked.
|
||||
- `bonfire.Command.Long` is optional extended help, shown under "Help:" by `help <command>` and `<command> --help`. `bonfire.Command.Examples` and `bonfire.Command.Hidden` are described below. Set `bonfire.Command.Passthrough` for a command that forwards to another program: flag parsing is turned off, so every argument after the command name, including `--help`, `-h` and `--`, reaches `bonfire.Input.Args` unchanged. Declared flags then appear in the usage text only, and declared arguments are not count-checked.
|
||||
|
||||
Read arguments with `bonfire.Input.Argument`, scalar and bare flags with `bonfire.Input.Flag`, and repeatable flags with `bonfire.Input.Flags`, which returns the values in the order given:
|
||||
|
||||
@@ -48,7 +48,7 @@ if err := catalog.Call(context.Background(), "blog:publish", args, os.Stdout); e
|
||||
|
||||
## Registering commands
|
||||
|
||||
Return the commands from the plugin's `Commands` method, which implements `pact.HasCommands`. The generated `main` appends every plugin's commands after the framework's runtime commands. Command names are not checked for duplicates, so keep your commands in your plugin's own namespace, such as `blog:`.
|
||||
Return the commands from the plugin's `Commands` method, which implements `pact.HasCommands`. The generated `main` appends every plugin's commands after the framework's runtime commands. Command names must be unique: when two commands share a name, or a command is named `list` or `help`, `bonfire.NewRoot` fails with `bonfire.ErrDuplicateCommand` naming the command, and the binary does not start. Keep your commands in your plugin's own namespace, such as `blog:`. The global options are reserved as well: a flag named `verbose`, `quiet`, `ansi`, `no-ansi`, `no-interaction`, `env` or `help`, or with the shorthand `v`, `q`, `n` or `h`, makes `bonfire.NewRoot` fail with `bonfire.ErrReservedFlag`.
|
||||
|
||||
A scaffolded plugin's `Commands` method returns the generated list of everything in `console/`, so commands created with `summer make:command` are registered without editing `plugin.go`.
|
||||
|
||||
@@ -57,10 +57,177 @@ A scaffolded plugin's `Commands` method returns the generated list of everything
|
||||
`bonfire.Output` is the console your command writes to. Besides `bonfire.Output.Printf` and `bonfire.Output.Println`, it provides:
|
||||
|
||||
- status lines: `bonfire.Output.Info`, `bonfire.Output.Success`, `bonfire.Output.Warning` and `bonfire.Output.Error` (the last one writes to the error stream);
|
||||
- widgets: `bonfire.Output.Table`, `bonfire.Output.Spinner` around a function and `bonfire.Output.Progress` for a progress bar, which fall back to plain lines when the output is not a terminal;
|
||||
- prompts: `bonfire.Output.Ask`, `bonfire.Output.Confirm`, `bonfire.Output.Choice` and `bonfire.Output.Secret`. Prompts return their defaults when input ends, so a command run from cron or a script never hangs.
|
||||
- widgets: `bonfire.Output.Table`, `bonfire.Output.Spinner` around a function and `bonfire.Output.Progress` for a progress bar. On a terminal they are SSU's live views on the charm stack: a spinner, a gradient bar and boxed tables;
|
||||
- prompts: `bonfire.Output.Ask`, `bonfire.Output.Confirm`, `bonfire.Output.Choice` and `bonfire.Output.Secret`. On an interactive terminal they are huh fields and an arrow-key selector; otherwise they read lines from stdin, so a password can be piped in, and return their defaults when input ends, so a command run from cron or a script never hangs.
|
||||
|
||||
Return an error from `Run` to fail the command. The binary prints it and exits with status 1.
|
||||
How much of this appears depends on where the command runs and on the global options:
|
||||
|
||||
| Run | Animation | Decoration | Data | Prompts |
|
||||
|-----|-----------|------------|------|---------|
|
||||
| Terminal | spinner, progress bar, process view | colors, symbols, boxed tables | printed | huh fields and the selector |
|
||||
| Not a terminal | none: `[...] message` and a line per tenth of progress | symbols, no colors | printed, tables tab-separated | read a line from stdin; `Confirm` returns its default |
|
||||
| `-q` | none | info, success and warning lines hidden; errors kept | printed | defaults, as with `-n` |
|
||||
| `--no-ansi` | none | symbols, no colors | printed | plain line prompts |
|
||||
| `-n` | none | as on the terminal | printed | defaults; `Secret` reads piped stdin or fails with a hint |
|
||||
|
||||
Print the data your command exists to produce (a token, a key, JSON) with `bonfire.Output.Println` or `bonfire.Output.Printf`, never as a status line: `-q` keeps that output and drops only the decoration around it. `bonfire.IsQuiet`, `bonfire.VerbosityOf` and `bonfire.GlobalsOf` tell a command which options it runs under.
|
||||
|
||||
Before a destructive step, ask with `bonfire.ConfirmProceed`. It asks only on an interactive terminal; off a terminal, with `-n` or `-q`, and when the scheduler calls the command, it returns true without asking, so deploys over SSH never wait for an answer.
|
||||
|
||||
For a loop over many items, `bonfire.Process` runs a function per item and shows SSU's process view on a terminal, one row per item with its state, or prints one `✓ item` or `✗ item: error` line per item elsewhere; it returns the failures joined. Inside `bonfire.Output.Progress`, `bonfire.Current` names the item being worked on and `bonfire.Failed` records a failed one. Log lines and output printed while a live view runs appear above the view (`bonfire.PrintAbove`).
|
||||
|
||||
Return an error from `Run` to fail the command. The binary prints it as an error block on stderr and exits with status 1; see [Errors and hints](#errors-and-hints).
|
||||
|
||||
## Examples and hidden commands
|
||||
|
||||
`bonfire.Command.Examples` lists usage examples. Each `bonfire.Example` holds the command line without the binary and a short description; help prints the description as a `#` comment and the line after `bin/<name>` (or `summer` for the tool's own commands), so one example serves both. `bonfire.Command.Hidden` keeps a command that still runs out of the list; `list --all` and `list --format=json` show it:
|
||||
|
||||
```go src=modules/bonfire/example_test.go#ExampleExample
|
||||
export := bonfire.Command{
|
||||
Name: "blog:export",
|
||||
Description: "Export published posts as JSON",
|
||||
Long: "Writes every published post, newest first, to a JSON file.",
|
||||
Flags: []bonfire.Flag{
|
||||
{Name: "path", Description: "Output file", Default: "posts.json"},
|
||||
},
|
||||
Examples: []bonfire.Example{
|
||||
{Command: "blog:export --path=backup.json", Description: "Export to another file"},
|
||||
},
|
||||
Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error {
|
||||
return nil
|
||||
},
|
||||
}
|
||||
// Hidden commands run, but list shows them only with --all.
|
||||
cleanup := bonfire.Command{
|
||||
Name: "blog:cleanup",
|
||||
Description: "Remove orphaned uploads",
|
||||
Hidden: true,
|
||||
}
|
||||
|
||||
root, err := bonfire.NewRootIO("acme", []bonfire.Command{export, cleanup}, strings.NewReader(""), os.Stdout, os.Stdout)
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
bonfire.Execute(context.Background(), root, []string{"help", "blog:export"})
|
||||
// Output:
|
||||
// Description:
|
||||
// Export published posts as JSON
|
||||
//
|
||||
// Usage:
|
||||
// blog:export [options]
|
||||
//
|
||||
// Options:
|
||||
// --path=PATH Output file [default: "posts.json"]
|
||||
//
|
||||
// Global options:
|
||||
// -h, --help Display help for the given command
|
||||
// -q, --quiet Do not output any message
|
||||
// -v|vv|vvv Increase verbosity
|
||||
// --ansi|no-ansi Force (or disable) ANSI output
|
||||
// -n, --no-interaction Do not ask any interactive question
|
||||
// --env The environment the command runs under
|
||||
//
|
||||
// Help:
|
||||
// Writes every published post, newest first, to a JSON file.
|
||||
//
|
||||
// Examples:
|
||||
// # Export to another file
|
||||
// bin/acme blog:export --path=backup.json
|
||||
```
|
||||
|
||||
`list --format=json` prints every command with its arguments, options (with defaults) and examples as a `bonfire.Listing`, for documentation checks, scripts and agents. `bonfire.Describe` builds the same listing from a command slice.
|
||||
|
||||
### What a finished command has
|
||||
|
||||
The framework's own commands follow these rules, and so should a plugin's:
|
||||
|
||||
- **Description:** one line in the imperative, sentence case, without a trailing period: "Publish a blog post by its slug".
|
||||
- **Long help:** what the command does, when to run it (by hand, from cron, from the scheduler), what each flag changes, and its exit codes.
|
||||
- **Two or three examples:** the common call first, then the flags people reach for.
|
||||
- **A result line:** a one-shot command ends with a line that says what it did, such as `Published hello-world` or `Imported 120 posts (3 skipped)`, and returns an error when it failed, so the exit code is 1.
|
||||
- **Data on stdout:** what a script reads (a token, a key, `--json` output) goes through `Println` or `Printf`, which `-q` does not hide; information and success lines go through `Info` and `Success`, which it does.
|
||||
- **Flags with a meaning people know:** `--json` for output scripts read, `--pretend` or `--dry-run` for a run that changes nothing, `--force` to skip a confirmation. A destructive step asks through `bonfire.ConfirmProceed`, which never asks off an interactive terminal; never give an existing flag a new meaning.
|
||||
- **A named logger:** log through `diary.For("<plugin code>")` from a package variable, not `slog.Default`, so `log.components` can raise or silence the plugin. See [Logging](../services/logging.md).
|
||||
|
||||
## Errors and hints
|
||||
|
||||
When `Run` returns an error, the binary prints a block on stderr: the ERROR badge and the message, then "Run with -v for details.". With `-v` the block lists the wrapped errors under "Caused by:" instead. Messages pass through `sunscreen.Scrub`, so a token inside an error text is not printed.
|
||||
|
||||
Wrap an error with `bonfire.WithHint` to explain it and to say what to run next. `{bin}` in the Try text becomes `bin/<name>` or `summer`, so the hint names the right program:
|
||||
|
||||
```go src=modules/bonfire/example_test.go#ExampleWithHint
|
||||
publish := bonfire.Command{
|
||||
Name: "blog:publish",
|
||||
Description: "Publish a draft post",
|
||||
Args: []bonfire.Arg{{Name: "slug", Description: "Post slug", Required: true}},
|
||||
Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error {
|
||||
slug, _ := in.Argument("slug")
|
||||
return bonfire.WithHint(fmt.Errorf("blog: no draft named %q", slug),
|
||||
"Only drafts can be published; published posts keep their slug.",
|
||||
"{bin} blog:drafts")
|
||||
},
|
||||
}
|
||||
|
||||
root, err := bonfire.NewRootIO("acme", []bonfire.Command{publish}, strings.NewReader(""), os.Stdout, os.Stdout)
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
code := bonfire.Execute(context.Background(), root, []string{"blog:publish", "hello-world"})
|
||||
fmt.Println("exit code:", code)
|
||||
// Output:
|
||||
// ERROR blog: no draft named "hello-world"
|
||||
//
|
||||
// Only drafts can be published; published posts keep their slug.
|
||||
//
|
||||
// Try:
|
||||
// bin/acme blog:drafts
|
||||
//
|
||||
// Run with -v for details.
|
||||
//
|
||||
// exit code: 1
|
||||
```
|
||||
|
||||
A command that has already printed its problems, such as a checker that lists every finding, returns `bonfire.Reported(err)`: the binary exits 1 and prints nothing more. A mistake in how the command was called, such as an unknown flag or a missing argument, exits 2 and the block suggests `help <command>`; wrap your own usage checks with `bonfire.Usage` to get the same exit code.
|
||||
|
||||
An application binary also explains the most common mistake: running it from the wrong directory. When a command fails, the working directory has no `config/` directory and a parent directory holds `summer.yaml`, the block says "This command runs from the application root." and suggests the same command from there:
|
||||
|
||||
```text
|
||||
ERROR lagoon: database.dsn is empty (set SUMMER_DATABASE__DSN)
|
||||
|
||||
This command runs from the application root.
|
||||
|
||||
Try:
|
||||
cd ../..; bin/acme migrate
|
||||
```
|
||||
|
||||
Only the message changes: the binary still reads its configuration from `config/` in the working directory. `summer` is not affected, because its commands find the application root themselves.
|
||||
|
||||
## Documentation coverage
|
||||
|
||||
`bonfire.CheckDocs` reports each command that lacks a description, long help, an example, or a description of one of its arguments or options. It only reports; the framework's own test fails on any problem, and a plugin repository can call it from a test and log the problems:
|
||||
|
||||
```go src=modules/bonfire/example_test.go#ExampleCheckDocs
|
||||
commands := []bonfire.Command{
|
||||
{
|
||||
Name: "blog:publish",
|
||||
Description: "Publish a draft post",
|
||||
Long: "Publishes the draft with the given slug and notifies subscribers.",
|
||||
Args: []bonfire.Arg{{Name: "slug", Description: "Post slug", Required: true}},
|
||||
Examples: []bonfire.Example{{Command: "blog:publish hello-world"}},
|
||||
},
|
||||
{
|
||||
Name: "blog:reindex",
|
||||
Description: "Rebuild the search index",
|
||||
Flags: []bonfire.Flag{{Name: "batch", Default: "500"}},
|
||||
},
|
||||
}
|
||||
for _, problem := range bonfire.CheckDocs(commands) {
|
||||
fmt.Println(problem)
|
||||
}
|
||||
// Output: blog:reindex: missing long help, examples, option --batch description
|
||||
```
|
||||
|
||||
## Calling commands in-process
|
||||
|
||||
|
||||
@@ -17,7 +17,12 @@ func PublishCommand(withDB func(ctx context.Context, fn func(*gorm.DB) error) er
|
||||
return bonfire.Command{
|
||||
Name: "blog:publish",
|
||||
Description: "Publish a blog post by its slug",
|
||||
Args: []bonfire.Arg{{Name: "slug", Description: "Slug of the post to publish", Required: true}},
|
||||
Long: `Sets published_at on the post with this slug. Publishing a post twice keeps
|
||||
the first publication time. It fails when no post has the slug.`,
|
||||
Args: []bonfire.Arg{{Name: "slug", Description: "Slug of the post to publish", Required: true}},
|
||||
Examples: []bonfire.Example{
|
||||
{Command: "blog:publish hello-world", Description: "Publish the post hello-world"},
|
||||
},
|
||||
Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error {
|
||||
slug, _ := in.Argument("slug")
|
||||
if slug == "" {
|
||||
|
||||
@@ -27,6 +27,9 @@ func TestPublishCommandShape(t *testing.T) {
|
||||
if cmd.Description == "" {
|
||||
t.Error("Description is empty")
|
||||
}
|
||||
for _, problem := range bonfire.CheckDocs([]bonfire.Command{cmd}) {
|
||||
t.Error(problem)
|
||||
}
|
||||
|
||||
cmds := []bonfire.Command{cmd}
|
||||
var out bytes.Buffer
|
||||
|
||||
@@ -15,7 +15,7 @@ WinterCMS reads configuration with `Config::get('app.name')` from `config/*.php`
|
||||
1. Plugin defaults, merged with `compass.Config.MergePlugin` when the plugin is activated. A plugin's `config/config.yaml` becomes `<plugin id>.<key>`, so `acme.blog.posts_per_page` is the WinterCMS `acme.blog::posts_per_page`. Any other `config/<name>.yaml` becomes `<plugin id>.<name>.<key>`.
|
||||
2. `config/*.yaml`. Each file is a section named after the file, so `config/app.yaml` provides `app.*`.
|
||||
3. `config/env/<environment>/*.yaml`, the per-environment sections.
|
||||
4. `SUMMER_` environment variables and the `.env` file next to `config/`. `SUMMER_MAIL__DRIVER` sets `mail.driver`: the prefix is removed, `__` separates the path segments and the name is lower-cased. A `.env` value applies only when the real environment does not set the same variable.
|
||||
4. `SUMMER_` environment variables and the `.env` file next to `config/`. `SUMMER_MAIL__DRIVER` sets `mail.driver`: the prefix is removed, `__` separates the path segments and the name is lower-cased. A `.env` value applies only when the real environment does not set the same variable. Values arrive as strings; modules that expect numbers or lists either parse them (`SUMMER_LOG__CHANNELS__DAILY__DAYS=30` and `SUMMER_LOG__CHANNELS__STACK__CHANNELS=console,daily` work for the [logging](logging.md) keys) or document that the key belongs in YAML.
|
||||
5. `config/env/<environment>/overrides.yaml`, written by `compass.Config.Persist`.
|
||||
6. Values set in memory with `compass.Config.Set`.
|
||||
|
||||
|
||||
@@ -1,18 +1,107 @@
|
||||
---
|
||||
title: Logging
|
||||
description: Log through log/slog with sunscreen, the redacting handler every generated main installs, and keep tokens, keys and request bodies out of log records.
|
||||
description: Configure log channels in config/log.yaml, log through diary component loggers and keep credentials out of log records.
|
||||
section: services
|
||||
order: 105
|
||||
---
|
||||
# Logging
|
||||
|
||||
SummerCMS logs through the standard library's `log/slog`. Where a WinterCMS plugin calls `Log::error` and relies on a Monolog tap to scrub credentials, a SummerCMS plugin logs through a `*slog.Logger`, and [sunscreen](../../modules/sunscreen/README.md) does the scrubbing.
|
||||
SummerCMS logs through the standard library's `log/slog`, configured by [diary](../../modules/diary/README.md). Where a WinterCMS application lists channels in `config/logging.php` and calls `Log::error`, a SummerCMS application lists channels in `config/log.yaml` and logs through a component logger from `diary.For`. Every channel redacts credentials through [sunscreen](../../modules/sunscreen/README.md).
|
||||
|
||||
## The redacting default logger
|
||||
The application `main` that `summer build` generates calls `diary.Bootstrap(os.Stderr, diary.Options{})` as the first statement of `run`, so even configuration errors are logged, and `diary.Setup` right after `compass` has loaded `config/`. From then on component loggers, `slog.Default`, the package-level `slog` functions and the standard `log` package all write through the configured channels.
|
||||
|
||||
Every application `main` that `summer build` generates calls `sunscreen.InstallDefault(os.Stderr)` as the first statement of `run`, before the configuration is loaded and before any plugin is activated. From then on `slog.Default`, the package-level `slog` functions and the standard `log` package all write through a text handler wrapped by `sunscreen.Wrap`.
|
||||
## Channels
|
||||
|
||||
The handler rewrites every record before it is written:
|
||||
A channel is a named destination with a driver:
|
||||
|
||||
| Driver | Writes |
|
||||
|--------|--------|
|
||||
| `console` | One line per record to stderr. With `format: auto` (the default) the line is styled when stderr is a terminal (time, level, the component as a fixed-width prefix, the message, `key=value` attributes) and logfmt otherwise, for systemd and Docker; `format: json` writes JSON lines. |
|
||||
| `stderr` | logfmt (the default) or JSON to stderr, never styled. |
|
||||
| `daily` | One file per day, JSON by default or logfmt; see [Daily files](#daily-files). |
|
||||
| `stack` | Every record to each channel listed under `channels`. |
|
||||
| `null` | Nothing. |
|
||||
|
||||
Each channel may set its own `level`; a record reaches a channel when it passes both its component's level and the channel's level.
|
||||
|
||||
While a command shows a live view on the terminal (a spinner, a progress bar or the process view of `bonfire.Process`), styled console lines print above the view through `bonfire.PrintAbove` instead of being written into it, so log records from workers and other goroutines never tear the animation. Without a live view, and on channels that are not styled, lines are written directly as usual.
|
||||
|
||||
## config/log.yaml
|
||||
|
||||
`summer new` writes this file, and an application without it gets the same values as framework defaults:
|
||||
|
||||
```yaml
|
||||
# config/log.yaml
|
||||
default: stack
|
||||
level: info
|
||||
channels:
|
||||
stack:
|
||||
driver: stack
|
||||
channels: [console, daily]
|
||||
console:
|
||||
driver: console
|
||||
format: auto
|
||||
daily:
|
||||
driver: daily
|
||||
path: storage/logs/summer.log
|
||||
days: 14
|
||||
format: json
|
||||
level: warn
|
||||
components:
|
||||
river: warn
|
||||
http: info
|
||||
```
|
||||
|
||||
`default` names the channel every record goes to, `level` is the global minimum, `channels` defines the channels and `components` sets levels per component (the `http: info` line only spells out what `level: info` already gives). It is the shape of Laravel's `config/logging.php` (`default`, `channels` with a `driver` each, `stack` and `daily` drivers, `days` for retention), written in YAML. The framework defaults also define a `stderr` channel (logfmt) and a `null` channel, so `default: stderr` or `default: null` works without defining them.
|
||||
|
||||
Like any key, these can be overridden from the environment with a double underscore between path segments, and string values are accepted where YAML would give a number or a list:
|
||||
|
||||
```sh
|
||||
SUMMER_LOG__CHANNELS__DAILY__DAYS=30
|
||||
SUMMER_LOG__CHANNELS__STACK__CHANNELS=console,daily
|
||||
SUMMER_LOG__COMPONENTS__RIVER=error
|
||||
```
|
||||
|
||||
An override changes one key and keeps every other default. An unknown driver, format or level, a stack that names a missing channel or contains itself, and a daily path outside the application root stop the start-up with an error that names the key, for example `diary: log.channels.daily.days must be a whole number of at least 0`.
|
||||
|
||||
## Component loggers
|
||||
|
||||
Code logs through the logger of its component:
|
||||
|
||||
```go src=modules/diary/example_test.go#ExampleFor
|
||||
// Before diary.Setup runs, records go to the process default logger.
|
||||
// This one drops the time so the output is stable.
|
||||
previous := slog.Default()
|
||||
defer slog.SetDefault(previous)
|
||||
slog.SetDefault(slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
|
||||
ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr {
|
||||
if len(groups) == 0 && a.Key == slog.TimeKey {
|
||||
return slog.Attr{}
|
||||
}
|
||||
return a
|
||||
},
|
||||
})))
|
||||
|
||||
// After diary.Setup the same record reaches the configured channels and
|
||||
// passes log.components.blog, else log.level.
|
||||
blogLog.InfoContext(context.Background(), "post published", "id", 7)
|
||||
// Output:
|
||||
// level=INFO msg="post published" component=blog id=7
|
||||
```
|
||||
|
||||
Every record carries the attribute `component=blog`, and passes the component's level: `log.components.blog` when it is set, `log.level` otherwise. The name is the plugin or module code without dots, because configuration keys split on dots (`acme.blog` would nest). Framework modules use their package name (`cabana`, `lagoon`, `conga`), River logs as `river` and GORM as `db`. `diary.SetVerbosity` lowers every component to info (1) or debug (2 and more) at runtime.
|
||||
|
||||
`diary.For` is safe in a package `var` block: before `diary.Setup` runs, its records go to the process default logger, and after it they reach the configured channels. Records logged through `slog.Default` carry no component and use `log.level`.
|
||||
|
||||
## Daily files
|
||||
|
||||
A daily channel with `path: storage/logs/summer.log` writes `storage/logs/summer-2026-10-21.log` today and `storage/logs/summer-2026-10-22.log` after local midnight. A relative path resolves against the application root, the directory that holds `config/`, and may not leave it; an absolute path is used as given. Files are created with mode 0640 in directories created with mode 0750, because logs carry user identifiers, and a file is only created once a record reaches it. With `days: 14` the files of today and the 13 days before are kept and older files of the same name pattern are deleted; other files in the directory are never touched. `days: 0` keeps every file.
|
||||
|
||||
If the directory cannot be created or the file cannot be opened (a read-only container filesystem, missing permissions), the channel logs one warning from component `diary` on the console naming the path and the error, and is disabled. The start-up never fails because of a log file.
|
||||
|
||||
## Redaction
|
||||
|
||||
Every channel, console, stderr, daily and null alike, is wrapped by `sunscreen.Wrap`, which rewrites every record before it is written:
|
||||
|
||||
- The value of an attribute whose key is one of `sunscreen.RedactedKeys` becomes `[REDACTED]`, at any group depth and whatever the letter case: `api_key`, `apikey`, `authorization`, `bearer`, `password`, `secret`, `token`, `webhook_secret`, `admin_password` and the `openai_api_key`, `anthropic_api_key` and `perplexity_api_key` variants.
|
||||
- The message, string values, error values and the formatted text of other values are passed through `sunscreen.Scrub`, which replaces `Bearer <token>`, `sk-` style API keys and `x-api-key: <value>`.
|
||||
@@ -39,22 +128,25 @@ logger.Info("vendor call failed",
|
||||
// level=INFO msg="vendor call failed" api_key=[REDACTED] request.Authorization=[REDACTED] err="401 for key sk-[REDACTED]"
|
||||
```
|
||||
|
||||
`sunscreen.InstallDefault` remains for programs that install their own default logger instead of diary.
|
||||
|
||||
## Logging from a plugin
|
||||
|
||||
A plugin resolves a `*slog.Logger` from the application with `backpack.Lookup` and falls back to `slog.Default` when none is published, as the framework modules do. Either way the record goes through the redacting handler.
|
||||
A plugin logs through its own component logger, named after its plugin code without dots, instead of the process default logger. The logger is safe to create in a package `var` block, before diary is set up:
|
||||
|
||||
```go src=modules/diary/example_test.go#blogLog
|
||||
// blogLog is the component logger of a blog plugin, created once in a
|
||||
// package var block, before diary is set up.
|
||||
var blogLog = diary.For("blog")
|
||||
```
|
||||
|
||||
Its records carry `component=blog`, follow `log.components.blog` and go through the redacting channels.
|
||||
|
||||
Redaction is a safety net for mistakes, not a way to log secrets on purpose. Log identifiers and outcomes: the record id, the vendor, the status code, how long it took. Never log request arguments, tokens, credentials or request and response bodies, which can carry personal data the patterns do not recognise.
|
||||
|
||||
## Request log
|
||||
|
||||
The router can log every request. It is off by default; turn it on with the `http.access_log` key (environment `SUMMER_HTTP__ACCESS_LOG=true`):
|
||||
|
||||
```yaml
|
||||
# config/http.yaml
|
||||
access_log: true
|
||||
```
|
||||
|
||||
Each request then writes one Info record with the message `http request` once it has been served:
|
||||
The router logs every request. Each request writes one Info record with the message `http request` from the component `http` once it has been served:
|
||||
|
||||
| Attribute | Value |
|
||||
|-----------|-------|
|
||||
@@ -62,9 +154,25 @@ Each request then writes one Info record with the message `http request` once it
|
||||
| `pattern` | The matched route, method and path pattern, for example `GET /posts/{id}`. Empty when no route matched. |
|
||||
| `status` | The response status; a handler that writes without setting one counts as 200. A 5xx is logged as 499 (client closed request) only when the client had already gone away when the status was written and the handler did not panic: the error came from the cancellation, and no client received it. A 5xx written before the client went away keeps its status, and a recovered panic is always 500, so a search for 5xx records finds every real server error. |
|
||||
| `duration_ms` | Time spent serving the request, in whole milliseconds. |
|
||||
| `request_id` | The `X-Request-Id` request header, trimmed and cut to 128 bytes; absent when the header is empty. Set it in your reverse proxy to follow one request across the proxy's and the application's logs. |
|
||||
| `client_gone` | `true` when the client went away before the request was served (its context ended with `context.Canceled`); absent otherwise. |
|
||||
|
||||
The record never carries the URL path, the query string, request or response headers or the client address, because tokens often travel in paths and query strings. That holds for a request that reached no route too: a token route requested with another method gets a 405 from the router, and its trailing-slash variant a 404, before any route handler runs, so the path would carry the token. It goes to the published `*slog.Logger` or `slog.Default`, so the redacting handler still applies. Grep for the empty `pattern` to find requests that reached no route; the client's own log (a browser, a proxy) shows which path it asked for.
|
||||
The record carries the route pattern, never the URL path, the query string, request or response headers or the client address, because tokens travel in paths and query strings: share and invitation links carry them in the path. That holds for a request that reached no route too: a token route requested with another method gets a 405 from the router, and its trailing-slash variant a 404, before any route handler runs, so the path would carry the token. Grep for the empty `pattern` to find requests that reached no route; the client's own log (a browser, a proxy) shows which path it asked for.
|
||||
|
||||
The log is on by default, at Info, so the console shows it and the daily files keep what their level lets through. Silence it with the component level, or turn it off with `http.access_log` (environment `SUMMER_HTTP__ACCESS_LOG=false`):
|
||||
|
||||
```yaml
|
||||
# config/log.yaml
|
||||
components:
|
||||
http: warn
|
||||
```
|
||||
|
||||
```yaml
|
||||
# config/http.yaml
|
||||
access_log: false
|
||||
```
|
||||
|
||||
It goes to the published `*slog.Logger` when a plugin publishes one, else to the `http` component logger, so the redacting handler applies on every channel.
|
||||
|
||||
## Panics
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ Any key can be overridden with an environment variable. Take the dot path, repla
|
||||
|
||||
A `.env` file next to the `config/` directory supplies `KEY=VALUE` lines for variables that are not already set in the real environment. Keep secrets in the environment or in `.env`, never in committed YAML.
|
||||
|
||||
Environment values are strings. Keys that must be numbers, such as `http.body_limits.default_bytes`, belong in a YAML file.
|
||||
Environment values are strings. Keys that must be numbers, such as `http.body_limits.default_bytes`, belong in a YAML file. The `log.*` keys accept strings: `SUMMER_LOG__CHANNELS__DAILY__DAYS=30` keeps daily log files for 30 days, and `SUMMER_LOG__CHANNELS__STACK__CHANNELS=console,daily` sets a list.
|
||||
|
||||
The layers merge in this order, each overriding the ones before it: plugin defaults, `config/*.yaml`, `config/env/<environment>/*.yaml`, `SUMMER_` variables, then runtime overrides that a command saved to `config/env/<environment>/overrides.yaml`.
|
||||
|
||||
@@ -59,6 +59,7 @@ These are the keys most applications set. Each module's reference page lists all
|
||||
| `cache.driver`, `cache.prefix`, `cache.memory.max_entries` | The cache driver (`memory`, the default, or `redis`), the key prefix (default from `app.name`) and the memory driver's size bound (default 10000). | [cooler](../../modules/cooler/README.md) |
|
||||
| `site_cache.purge_url`, `site_cache.purge_token` | The host site's page-cache purge route and its bearer token (at least 32 characters). Set both or neither; http only to a loopback host. Keep the token in `SUMMER_SITE_CACHE__PURGE_TOKEN`. | [cooler](../../modules/cooler/README.md) |
|
||||
| `redis.url` | Redis URL (`redis://`, `rediss://` or `unix://`), required when a Redis cache or rate-limit store is selected. Keep it in `SUMMER_REDIS__URL` when it carries a password. | [cooler](../../modules/cooler/README.md) |
|
||||
| `log.default`, `log.level`, `log.channels.*`, `log.components.*` | Logging: the default channel (`stack` of `console` and `daily`), the global level (`info`), the channels (`driver`, `level`, `format`, `path`, `days`, `channels`) and per-component levels. Daily files go to `storage/logs/` and are kept 14 days. See [Logging](../services/logging.md). | [diary](../../modules/diary/README.md) |
|
||||
| `http.access_log` | Logs one record per request with method, matched route pattern, status and duration. Defaults to `false`. | [surf](../../modules/surf/README.md) |
|
||||
| `http.error_pages.enabled` | HTML error pages for browser navigations (defaults to `true`); request details appear when `app.debug` is true or outside production. | [surf](../../modules/surf/README.md) |
|
||||
| `storage.uploads.bucket_url` | Uploads bucket, `file://`, `mem://` or `s3://` (the last only in a binary built with the `summercms_s3` tag from `build.tags` in `summer.yaml`; credentials from the AWS environment). Required by `serve`. | [lagoon](../../modules/lagoon/README.md) |
|
||||
|
||||
@@ -68,7 +68,7 @@ curl http://127.0.0.1:8080/
|
||||
|
||||
It returns `{"message":"Hello from SummerCMS","plugin":"golem15.demo"}`. Stop the database with `docker compose down`, or `docker compose down -v` to delete its data as well.
|
||||
|
||||
`summer serve` prints the URL to open and the admin status. The admin lives at `http://127.0.0.1:8080/backend` once a plugin registers an admin controller; until then the status line reads `admin: off` and names the steps. When the admin is there, create the first administrator:
|
||||
`summer serve` prints a startup summary with the URL to open and the admin status. The admin lives at `http://127.0.0.1:8080/backend` once a plugin registers an admin controller; until then the summary's Admin line reads `off` and the steps follow it. When the admin is there, create the first administrator:
|
||||
|
||||
```sh
|
||||
summer admin:create --email you@example.com --superuser
|
||||
|
||||
@@ -748,7 +748,12 @@ func PublishCommand(withDB func(ctx context.Context, fn func(*gorm.DB) error) er
|
||||
return bonfire.Command{
|
||||
Name: "blog:publish",
|
||||
Description: "Publish a blog post by its slug",
|
||||
Args: []bonfire.Arg{{Name: "slug", Description: "Slug of the post to publish", Required: true}},
|
||||
Long: `Sets published_at on the post with this slug. Publishing a post twice keeps
|
||||
the first publication time. It fails when no post has the slug.`,
|
||||
Args: []bonfire.Arg{{Name: "slug", Description: "Slug of the post to publish", Required: true}},
|
||||
Examples: []bonfire.Example{
|
||||
{Command: "blog:publish hello-world", Description: "Publish the post hello-world"},
|
||||
},
|
||||
Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error {
|
||||
slug, _ := in.Argument("slug")
|
||||
if slug == "" {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
title: Upgrading
|
||||
description: Move an application to a newer framework release, what the version numbers promise, and the config to check before you take 0.2.
|
||||
description: Move an application to a newer framework release, what the version numbers promise, and what to check before you take 0.2 and 0.4.
|
||||
section: setup
|
||||
order: 60
|
||||
---
|
||||
@@ -24,6 +24,28 @@ SummerCMS is in the 0.x series, and the version tells you whether to read before
|
||||
|
||||
Applications pin exact versions, so a new release never reaches an application until it moves its `go.mod` or its checkout.
|
||||
|
||||
## Upgrading to 0.4
|
||||
|
||||
0.4 rebuilds the console: Artisan-style `list` and help with examples, global options on every command, a logging pipeline with daily files, startup summaries and shutdown reports. Plugins compile unchanged. After you move the framework version, run `summer build` to regenerate `main.go`: the new generated main sets up logging before the configuration is read, parses the global options and returns the exit codes below. Then run `bin/<app> list` once with the new binary: it boots every plugin and runs the checks below.
|
||||
|
||||
### Boot refusals
|
||||
|
||||
**Duplicate command names.** Two commands with the same name, from two plugins or from a plugin and the framework, stop the binary at start-up with `bonfire.ErrDuplicateCommand` naming the command. Remove or rename one of them; a plugin that appends `centrifugo.Commands` or `flare.Commands` must be the only one that does.
|
||||
|
||||
**Reserved flag names.** A command flag named like a global option (`verbose`, `quiet`, `ansi`, `no-ansi`, `no-interaction`, `env`, or the shorthands `v`, `q`, `n`) stops the binary with `bonfire.ErrReservedFlag`. Rename the flag.
|
||||
|
||||
### Behaviour changes
|
||||
|
||||
- **Daily log files.** Logs go to the console and, from warnings up, to `storage/logs/summer-YYYY-MM-DD.log`, kept for 14 days. The directory must be writable by the user that runs the binary; when it is not, the binary logs one warning and keeps logging to the console. To keep the old console-only behaviour, set `log.default` to `console` (`SUMMER_LOG__DEFAULT=console`). See [Logging](../services/logging.md).
|
||||
- **Component loggers.** Every log record carries a `component` attribute. GORM's lines move to component `db` and record-not-found is no longer logged; River's lines to component `river` (warnings and up by default). Raise or lower a component with `log.components`, for example `components: {db: debug}`.
|
||||
- **The request log is on by default.** Every request writes one Info record `http request` from component `http` with the route pattern (never the path), status, duration and `request_id`. Silence it with `components: {http: warn}` in `config/log.yaml`, or turn it off with `http.access_log: false`.
|
||||
- **`serve` prints a startup summary.** On a terminal it is a block; off a terminal `serve` prints nothing on stdout and logs a `startup summary` record and a `listening on <addr>` record on stderr instead, so a script that waits for `listening on 127.0.0.1:<port>` in the combined output still finds it. Ctrl+C prints a shutdown report and exits 0; a failed drain exits 1. `queue:work` and `schedule:run` do the same.
|
||||
- **Exit codes.** A failed command exits 1 and a usage error (an unknown command or flag, a wrong number of arguments) exits 2. Errors print as a block on stderr with a hint where the framework knows the fix, and `-v` shows the wrapped errors.
|
||||
- **`-q` keeps data.** `-q` hides information and success lines, spinners and progress bars, but still prints what a command exists to produce: the token of `admin:token:create`, the key of `key:generate` and every `--json` output.
|
||||
- **Production confirmation.** `migrate`, `migrate:rollback`, `deferred:purge` and `queue:clear` ask before they run when the environment is `production` and they run on an interactive terminal; `--force` skips the question. Off a terminal, as in deploy scripts over SSH, nothing changes.
|
||||
- **`summer dev` runs `serve`.** It used to start the binary without a command, which printed the help and stopped; it now runs `bin/<app> serve` with the global options you give `summer dev`.
|
||||
- **Result lines.** `summer build` ends with `Built bin/<app> in <time>`, the `make:` commands print `Created <path>` per file, `migrate` prints `Applied N migrations` or `Nothing to migrate`, and `queue:clear` prints `Cleared N jobs from <queue>`. Adjust scripts that matched the old lower-case lines.
|
||||
|
||||
## Upgrading to 0.2
|
||||
|
||||
0.2 tightens two configuration checks so that settings which leaked credentials stop the start-up instead. Most applications are not affected: the first concerns applications that load an `auth.cookie` block, the second applications that send credentialed CORS answers. Start the new binary once in each environment before you switch traffic to it; a refused configuration fails `serve` at start-up with an error that names the key.
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"os/exec"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -48,8 +49,8 @@ func runHello(t *testing.T, env map[string]string) string {
|
||||
t.Setenv(k, v)
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := run([]string{"greeter:hello"}, &buf); err != nil {
|
||||
t.Fatalf("greeter:hello: %v", err)
|
||||
if code := run([]string{"greeter:hello"}, &buf); code != 0 {
|
||||
t.Fatalf("greeter:hello: exit %d\n%s", code, buf.String())
|
||||
}
|
||||
return strings.TrimSpace(buf.String())
|
||||
}
|
||||
@@ -231,11 +232,11 @@ func TestTypedItemRoute(t *testing.T) {
|
||||
|
||||
func TestHelpListsServe(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
if err := run([]string{"--help"}, &buf); err != nil {
|
||||
t.Fatal(err)
|
||||
if code := run([]string{"--help"}, &buf); code != 0 {
|
||||
t.Fatalf("--help: exit %d\n%s", code, buf.String())
|
||||
}
|
||||
help := buf.String()
|
||||
for _, name := range []string{"serve", "migrate", "migrate:status", "migrate:rollback"} {
|
||||
for _, name := range []string{"Available commands:", "serve", "migrate", "migrate:status", "migrate:rollback"} {
|
||||
if !strings.Contains(help, name) {
|
||||
t.Fatalf("help missing %s:\n%s", name, help)
|
||||
}
|
||||
@@ -244,9 +245,19 @@ func TestHelpListsServe(t *testing.T) {
|
||||
|
||||
func TestMigrateWithoutDSNFailsLoudly(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
err := run([]string{"migrate"}, &buf)
|
||||
if err == nil || !strings.Contains(err.Error(), "database.dsn") {
|
||||
t.Fatalf("want empty DSN error, got %v\n%s", err, buf.String())
|
||||
if code := run([]string{"migrate"}, &buf); code != 1 {
|
||||
t.Fatalf("migrate without a DSN: exit %d, want 1\n%s", code, buf.String())
|
||||
}
|
||||
runSummerBuild(t)
|
||||
cmd := exec.Command("./bin/hello", "migrate")
|
||||
var stderr bytes.Buffer
|
||||
cmd.Stderr = &stderr
|
||||
err := cmd.Run()
|
||||
if exit, ok := err.(*exec.ExitError); !ok || exit.ExitCode() != 1 {
|
||||
t.Fatalf("bin/hello migrate: %v, want exit 1\n%s", err, stderr.String())
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "database.dsn") {
|
||||
t.Fatalf("stderr does not name database.dsn:\n%s", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -254,8 +265,34 @@ func TestBuiltBinaryUnknownCommandExitsNonZero(t *testing.T) {
|
||||
runSummerBuild(t)
|
||||
cmd := exec.Command("./bin/hello", "does:not-exist")
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err == nil {
|
||||
t.Fatalf("expected non-zero exit, output:\n%s", out)
|
||||
if exit, ok := err.(*exec.ExitError); !ok || exit.ExitCode() != 2 {
|
||||
t.Fatalf("unknown command: %v, want exit 2, output:\n%s", err, out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuiltBinaryListsCommands: the bare binary prints the Artisan list with
|
||||
// the version header and the plugin namespace, and global options are
|
||||
// accepted before a command.
|
||||
func TestBuiltBinaryListsCommands(t *testing.T) {
|
||||
runSummerBuild(t)
|
||||
cmd := exec.Command("./bin/hello")
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd.Stdout, cmd.Stderr = &stdout, &stderr
|
||||
if err := cmd.Run(); err != nil {
|
||||
t.Fatalf("bin/hello: %v\n%s%s", err, stdout.String(), stderr.String())
|
||||
}
|
||||
got := stdout.String()
|
||||
for _, want := range []string{"SummerCMS hello", "Available commands:", "greeter:hello"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("list missing %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
if !slices.Contains(strings.Split(got, "\n"), " greeter") {
|
||||
t.Fatalf("list has no greeter namespace line:\n%s", got)
|
||||
}
|
||||
verbose := exec.Command("./bin/hello", "-vv", "list")
|
||||
if out, err := verbose.CombinedOutput(); err != nil {
|
||||
t.Fatalf("bin/hello -vv list: %v\n%s", err, out)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -300,3 +337,28 @@ func generatedHash(t *testing.T) [32]byte {
|
||||
}
|
||||
return sha256.Sum256(buf.Bytes())
|
||||
}
|
||||
|
||||
// TestGreeterDocumented: the greeter plugin's commands carry a description,
|
||||
// long help, examples and flag descriptions (bonfire.CheckDocs).
|
||||
func TestGreeterDocumented(t *testing.T) {
|
||||
cfg, err := compass.Load("config")
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
plugins, err := party.Activate(backpack.New(cfg), []string{"golem15.hello", "golem15.greeter"})
|
||||
if err != nil {
|
||||
t.Fatalf("Activate: %v", err)
|
||||
}
|
||||
var commands []bonfire.Command
|
||||
for _, p := range plugins {
|
||||
if hc, ok := p.(pact.HasCommands); ok && p.ID() == "golem15.greeter" {
|
||||
commands = append(commands, hc.Commands()...)
|
||||
}
|
||||
}
|
||||
if len(commands) == 0 {
|
||||
t.Fatal("the greeter plugin has no commands")
|
||||
}
|
||||
for _, problem := range bonfire.CheckDocs(commands) {
|
||||
t.Error(problem)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"context"
|
||||
"io"
|
||||
"os"
|
||||
|
||||
@@ -12,30 +12,31 @@ import (
|
||||
"git.golem15.com/golem15/summercms/modules/cabana"
|
||||
"git.golem15.com/golem15/summercms/modules/compass"
|
||||
"git.golem15.com/golem15/summercms/modules/conga"
|
||||
"git.golem15.com/golem15/summercms/modules/diary"
|
||||
"git.golem15.com/golem15/summercms/modules/lagoon"
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
"git.golem15.com/golem15/summercms/modules/party"
|
||||
"git.golem15.com/golem15/summercms/modules/sunscreen"
|
||||
"git.golem15.com/golem15/summercms/modules/surf"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if err := run(os.Args[1:], os.Stdout); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
os.Exit(run(os.Args[1:], os.Stdout))
|
||||
}
|
||||
|
||||
func run(args []string, out io.Writer) error {
|
||||
sunscreen.InstallDefault(os.Stderr)
|
||||
cfg, err := compass.Load("config")
|
||||
func run(args []string, out io.Writer) int {
|
||||
g := bonfire.PeekGlobals(args)
|
||||
diary.Bootstrap(os.Stderr, diary.FromGlobals(g))
|
||||
cfg, err := compass.Open(compass.Options{Dir: "config", Env: g.Env})
|
||||
if err != nil {
|
||||
return err
|
||||
return bonfire.Fail(os.Stderr, "hello", err, g)
|
||||
}
|
||||
if err := diary.Setup(cfg, diary.FromGlobals(g)); err != nil {
|
||||
return bonfire.Fail(os.Stderr, "hello", err, g)
|
||||
}
|
||||
app := backpack.New(cfg)
|
||||
plugins, err := party.Activate(app, PluginIDs)
|
||||
if err != nil {
|
||||
return err
|
||||
return bonfire.Fail(os.Stderr, "hello", err, g)
|
||||
}
|
||||
commands := lagoon.RuntimeCommands(app, plugins)
|
||||
commands = append(commands, conga.RuntimeCommands(app, plugins)...)
|
||||
@@ -48,12 +49,11 @@ func run(args []string, out io.Writer) error {
|
||||
}
|
||||
}
|
||||
if err := app.Publish(bonfire.NewCatalog(commands)); err != nil {
|
||||
return err
|
||||
return bonfire.Fail(os.Stderr, "hello", err, g)
|
||||
}
|
||||
root, err := bonfire.NewRoot("hello", commands, out)
|
||||
root, err := bonfire.NewRoot("hello", commands, out, bonfire.WithVersion(bonfire.BuildVersion()), bonfire.OnGlobals(diary.ApplyGlobals))
|
||||
if err != nil {
|
||||
return err
|
||||
return bonfire.Fail(os.Stderr, "hello", err, g)
|
||||
}
|
||||
root.SetArgs(args)
|
||||
return root.Execute()
|
||||
return bonfire.Execute(context.Background(), root, args)
|
||||
}
|
||||
|
||||
@@ -79,6 +79,14 @@ func (p *Plugin) Commands() []bonfire.Command {
|
||||
return []bonfire.Command{{
|
||||
Name: "greeter:hello",
|
||||
Description: "Print the configured application name",
|
||||
Long: `Prints app.name, golem15.hello.posts_per_page and app.debug from the layered
|
||||
configuration, then fires, collects and hands a HelloEvent to the event bus
|
||||
and reports what came back. The example application's tests use it to check
|
||||
configuration layering and events end to end.`,
|
||||
Examples: []bonfire.Example{
|
||||
{Command: "greeter:hello", Description: "Print the configured values"},
|
||||
{Command: "greeter:hello --env production", Description: "Print the values of the production configuration"},
|
||||
},
|
||||
Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error {
|
||||
name := ""
|
||||
posts := 0
|
||||
|
||||
32
go.mod
32
go.mod
@@ -6,8 +6,15 @@ go 1.27.0
|
||||
ignore ./admin/node_modules
|
||||
|
||||
require (
|
||||
charm.land/bubbles/v2 v2.2.1
|
||||
charm.land/bubbletea/v2 v2.1.0
|
||||
charm.land/fang/v2 v2.0.1
|
||||
charm.land/huh/v2 v2.0.3
|
||||
charm.land/lipgloss/v2 v2.0.6
|
||||
github.com/alecthomas/chroma/v2 v2.27.0
|
||||
github.com/alicebob/miniredis/v2 v2.40.0
|
||||
github.com/charmbracelet/colorprofile v0.4.3
|
||||
github.com/charmbracelet/x/ansi v0.11.9
|
||||
github.com/disintegration/imaging v1.6.2
|
||||
github.com/fsnotify/fsnotify v1.10.1
|
||||
github.com/go-gormigrate/gormigrate/v2 v2.1.7
|
||||
@@ -28,6 +35,7 @@ require (
|
||||
github.com/riverqueue/river/riverdriver/riverdatabasesql v0.47.0
|
||||
github.com/riverqueue/river/rivertype v0.47.0
|
||||
github.com/spf13/cobra v1.10.2
|
||||
github.com/spf13/pflag v1.0.9
|
||||
github.com/testcontainers/testcontainers-go v0.44.0
|
||||
github.com/testcontainers/testcontainers-go/modules/postgres v0.44.0
|
||||
github.com/wneessen/go-mail v0.8.1
|
||||
@@ -46,6 +54,7 @@ require (
|
||||
dario.cat/mergo v1.0.2 // indirect
|
||||
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect
|
||||
github.com/Microsoft/go-winio v0.6.2 // indirect
|
||||
github.com/atotto/clipboard v0.1.4 // indirect
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.9 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.11 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.20 // indirect
|
||||
@@ -66,8 +75,19 @@ require (
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.42.3 // indirect
|
||||
github.com/aws/smithy-go v1.26.0 // indirect
|
||||
github.com/aymerick/douceur v0.2.0 // indirect
|
||||
github.com/catppuccin/go v0.2.0 // indirect
|
||||
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/charmbracelet/harmonica v0.2.0 // indirect
|
||||
github.com/charmbracelet/ultraviolet v0.0.0-20261008173134-6b8d4baf91b4 // indirect
|
||||
github.com/charmbracelet/x/exp/charmtone v0.0.0-20250603201427-c31516f43444 // indirect
|
||||
github.com/charmbracelet/x/exp/ordered v0.1.0 // indirect
|
||||
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect
|
||||
github.com/charmbracelet/x/term v0.2.2 // indirect
|
||||
github.com/charmbracelet/x/termios v0.1.1 // indirect
|
||||
github.com/charmbracelet/x/windows v0.2.2 // indirect
|
||||
github.com/clipperhouse/displaywidth v0.11.0 // indirect
|
||||
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
|
||||
github.com/containerd/errdefs v1.0.0 // indirect
|
||||
github.com/containerd/errdefs/pkg v0.3.0 // indirect
|
||||
github.com/containerd/log v0.1.0 // indirect
|
||||
@@ -77,6 +97,7 @@ require (
|
||||
github.com/dlclark/regexp2/v2 v2.2.1 // indirect
|
||||
github.com/docker/go-connections v0.7.0 // indirect
|
||||
github.com/docker/go-units v0.5.0 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/ebitengine/purego v0.10.1 // indirect
|
||||
github.com/felixge/httpsnoop v1.1.0 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.15 // indirect
|
||||
@@ -100,9 +121,12 @@ require (
|
||||
github.com/knadh/koanf/maps v0.1.2 // indirect
|
||||
github.com/leodido/go-urn v1.5.0 // indirect
|
||||
github.com/lib/pq v1.12.3 // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.4.1 // indirect
|
||||
github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e // indirect
|
||||
github.com/magiconair/properties v1.8.10 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.30 // indirect
|
||||
github.com/mitchellh/copystructure v1.2.0 // indirect
|
||||
github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect
|
||||
github.com/mitchellh/reflectwalk v1.0.2 // indirect
|
||||
github.com/moby/docker-image-spec v1.3.1 // indirect
|
||||
github.com/moby/go-archive v0.2.0 // indirect
|
||||
@@ -113,15 +137,20 @@ require (
|
||||
github.com/moby/sys/user v0.4.0 // indirect
|
||||
github.com/moby/sys/userns v0.1.0 // indirect
|
||||
github.com/moby/term v0.5.2 // indirect
|
||||
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||
github.com/muesli/mango v0.1.0 // indirect
|
||||
github.com/muesli/mango-cobra v1.2.0 // indirect
|
||||
github.com/muesli/mango-pflag v0.1.0 // indirect
|
||||
github.com/muesli/roff v0.1.0 // indirect
|
||||
github.com/opencontainers/go-digest v1.0.0 // indirect
|
||||
github.com/opencontainers/image-spec v1.1.1 // indirect
|
||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
|
||||
github.com/riverqueue/river/riverdriver v0.47.0 // indirect
|
||||
github.com/riverqueue/river/riverdriver/riverpgxv5 v0.47.0 // indirect
|
||||
github.com/riverqueue/river/rivershared v0.47.0 // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/shirou/gopsutil/v4 v4.26.6 // indirect
|
||||
github.com/sirupsen/logrus v1.9.4 // indirect
|
||||
github.com/spf13/pflag v1.0.9 // indirect
|
||||
github.com/stretchr/testify v1.12.1 // indirect
|
||||
github.com/tidwall/gjson v1.19.0 // indirect
|
||||
github.com/tidwall/match v1.2.0 // indirect
|
||||
@@ -129,6 +158,7 @@ require (
|
||||
github.com/tidwall/sjson v1.2.5 // indirect
|
||||
github.com/tklauser/go-sysconf v0.4.0 // indirect
|
||||
github.com/tklauser/numcpus v0.12.0 // indirect
|
||||
github.com/xo/terminfo v1.0.0 // indirect
|
||||
github.com/yuin/gopher-lua v1.1.1 // indirect
|
||||
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||
|
||||
74
go.sum
74
go.sum
@@ -1,5 +1,15 @@
|
||||
cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4=
|
||||
cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4=
|
||||
charm.land/bubbles/v2 v2.2.1 h1:Fq1+qm5hV6GkvzLQDhCBpXXE5tLgvh1PRriCLwSvIQU=
|
||||
charm.land/bubbles/v2 v2.2.1/go.mod h1:wdMgn+sje1KNXdwFizIWjbf328fIUBxqEmJ/vYPo8yc=
|
||||
charm.land/bubbletea/v2 v2.1.0 h1:A9fj9h5LFrvQhQHFNggp8K8rvY6CUfgn9dMbb6p2HIw=
|
||||
charm.land/bubbletea/v2 v2.1.0/go.mod h1:xWHeyxIX9/wiFU2kgPW04ojOZyTzrWf+8lGjUCaftiU=
|
||||
charm.land/fang/v2 v2.0.1 h1:zQCM8JQJ1JnQX/66B5jlCYBUxL2as5JXQZ2KJ6EL0mY=
|
||||
charm.land/fang/v2 v2.0.1/go.mod h1:S1GmkpcvK+OB5w9caywUnJcsMew45Ot8FXqoz8ALrII=
|
||||
charm.land/huh/v2 v2.0.3 h1:2cJsMqEPwSywGHvdlKsJyQKPtSJLVnFKyFbsYZTlLkU=
|
||||
charm.land/huh/v2 v2.0.3/go.mod h1:93eEveeeqn47MwiC3tf+2atZ2l7Is88rAtmZNZ8x9Wc=
|
||||
charm.land/lipgloss/v2 v2.0.6 h1:EaGKeuA8FvF+v2BT5VmZd2LoYLaMZJXA5n34th8nCIQ=
|
||||
charm.land/lipgloss/v2 v2.0.6/go.mod h1:ipDDJNSGa1hlwDtSfW1s2/xR8Vdhbut4PXh2zEKZd0Q=
|
||||
cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE=
|
||||
cloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU=
|
||||
cloud.google.com/go/auth v0.18.2 h1:+Nbt5Ev0xEqxlNjd6c+yYUeosQ5TtEUaNcN/3FozlaM=
|
||||
@@ -28,6 +38,8 @@ github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.55
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.55.0/go.mod h1:IA1C1U7jO/ENqm/vhi7V9YYpBsp+IMyqNrEN94N7tVc=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.55.0 h1:0s6TxfCu2KHkkZPnBfsQ2y5qia0jl3MMrmBhu3nCOYk=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.55.0/go.mod h1:Mf6O40IAyB9zR/1J8nGDDPirZQQPbYJni8Yisy7NTMc=
|
||||
github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
|
||||
github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
|
||||
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
||||
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
|
||||
@@ -38,6 +50,8 @@ github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs
|
||||
github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
|
||||
github.com/alicebob/miniredis/v2 v2.40.0 h1:6IgTQbvm2JiMYZ8yur/acNJzVjtSOWCelxXbk/LUBXc=
|
||||
github.com/alicebob/miniredis/v2 v2.40.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM=
|
||||
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
|
||||
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.9 h1:/rYeyO2+HrMztAmxAq9++XJtFMqSIpSsNA0yDGALYq4=
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.9/go.mod h1:+HsoOEX80qAVUitj1A2DhCNTjmb3edVyuDypb6LNEeo=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.11 h1:h5+3VT69KUBK24grGuuA5saDJTj2IIjLb9au668Fo5I=
|
||||
@@ -76,16 +90,52 @@ github.com/aws/aws-sdk-go-v2/service/sts v1.42.3 h1:ErklX/7uhSbkAAeyQD/Y1OoQ9hO3
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.42.3/go.mod h1:ULe4HCzfKPiR6R3HEurE3b1upEkuk8AkMrOKtaOxKO8=
|
||||
github.com/aws/smithy-go v1.26.0 h1:9ouqbi+NyKP7fV3Te7UElCwdAb6Y8uk7LGwPE5tVe/s=
|
||||
github.com/aws/smithy-go v1.26.0/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
|
||||
github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o=
|
||||
github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w=
|
||||
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
|
||||
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
|
||||
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
|
||||
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
|
||||
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
|
||||
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
|
||||
github.com/catppuccin/go v0.2.0 h1:ktBeIrIP42b/8FGiScP9sgrWOss3lw0Z5SktRoithGA=
|
||||
github.com/catppuccin/go v0.2.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc=
|
||||
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
|
||||
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q=
|
||||
github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q=
|
||||
github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ=
|
||||
github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao=
|
||||
github.com/charmbracelet/ultraviolet v0.0.0-20261008173134-6b8d4baf91b4 h1:hISLzUbwcvJbGoNR6A3L/TpguRRnACBmZOee8LcHLJw=
|
||||
github.com/charmbracelet/ultraviolet v0.0.0-20261008173134-6b8d4baf91b4/go.mod h1:aJDVk+WOVM1K04rdc/71PMPoUGfjxjhvXfP3Pjz1Zio=
|
||||
github.com/charmbracelet/x/ansi v0.11.9 h1:kIrqLBxtTVbLyyHO2Evoi93EjQo1mylWmpNEsfPxXAM=
|
||||
github.com/charmbracelet/x/ansi v0.11.9/go.mod h1:DG/hpOnaI2QJ/KMMqLL2h0KVhluuJLazf8973P+Vy5w=
|
||||
github.com/charmbracelet/x/conpty v0.1.1 h1:s1bUxjoi7EpqiXysVtC+a8RrvPPNcNvAjfi4jxsAuEs=
|
||||
github.com/charmbracelet/x/conpty v0.1.1/go.mod h1:OmtR77VODEFbiTzGE9G1XiRJAga6011PIm4u5fTNZpk=
|
||||
github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA=
|
||||
github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0=
|
||||
github.com/charmbracelet/x/exp/charmtone v0.0.0-20250603201427-c31516f43444 h1:IJDiTgVE56gkAGfq0lBEloWgkXMk4hl/bmuPoicI4R0=
|
||||
github.com/charmbracelet/x/exp/charmtone v0.0.0-20250603201427-c31516f43444/go.mod h1:T9jr8CzFpjhFVHjNjKwbAD7KwBNyFnj2pntAO7F2zw0=
|
||||
github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA=
|
||||
github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I=
|
||||
github.com/charmbracelet/x/exp/ordered v0.1.0 h1:55/qLwjIh0gL0Vni+QAWk7T/qRVP6sBf+2agPBgnOFE=
|
||||
github.com/charmbracelet/x/exp/ordered v0.1.0/go.mod h1:5UHwmG+is5THxMyCJHNPCn2/ecI07aKNrW+LcResjJ8=
|
||||
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4=
|
||||
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ=
|
||||
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
|
||||
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
|
||||
github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=
|
||||
github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo=
|
||||
github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM=
|
||||
github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k=
|
||||
github.com/charmbracelet/x/xpty v0.1.3 h1:eGSitii4suhzrISYH50ZfufV3v085BXQwIytcOdFSsw=
|
||||
github.com/charmbracelet/x/xpty v0.1.3/go.mod h1:poPYpWuLDBFCKmKLDnhBp51ATa0ooD8FhypRwEFtH3Y=
|
||||
github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8=
|
||||
github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=
|
||||
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
|
||||
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
|
||||
github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 h1:aBangftG7EVZoUb69Os8IaYg++6uMOdKK83QtkkvJik=
|
||||
github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2/go.mod h1:qwXFYgsP6T7XnJtbKlf1HP8AjxZZyzxMmc+Lq5GjlU4=
|
||||
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
|
||||
@@ -112,6 +162,8 @@ github.com/docker/go-connections v0.7.0 h1:6SsRfJddP22WMrCkj19x9WKjEDTB+ahsdiGYf
|
||||
github.com/docker/go-connections v0.7.0/go.mod h1:no1qkHdjq7kLMGUXYAduOhYPSJxxvgWBh7ogVvptn3Q=
|
||||
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
|
||||
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/ebitengine/purego v0.10.1 h1:dewVBCBT2GaMu1SrNTYxQhgQBethzfhiwvZiLGP/qyY=
|
||||
github.com/ebitengine/purego v0.10.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
|
||||
github.com/envoyproxy/go-control-plane v0.14.0 h1:hbG2kr4RuFj222B6+7T83thSPqLjwBIfQawTkC++2HA=
|
||||
@@ -211,10 +263,14 @@ github.com/leodido/go-urn v1.5.0 h1:pLqT2kq1zpHW/1D18QMjMpdtX7cekxqtJJjg5ANyWw0=
|
||||
github.com/leodido/go-urn v1.5.0/go.mod h1:9BORnCDhdPBJNDEX+w1bJisa8yOKYi116VeO96s4ifE=
|
||||
github.com/lib/pq v1.12.3 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ=
|
||||
github.com/lib/pq v1.12.3/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA=
|
||||
github.com/lucasb-eyer/go-colorful v1.4.1 h1:1EO+WB73+EH8EVbzlrG3KLAfEypQWVHIBqlTf+2hNss=
|
||||
github.com/lucasb-eyer/go-colorful v1.4.1/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||
github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e h1:Q6MvJtQK/iRcRtzAscm/zF23XxJlbECiGPyRicsX+Ak=
|
||||
github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
|
||||
github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE=
|
||||
github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
|
||||
github.com/mattn/go-runewidth v0.0.30 h1:+KUuiDA4fF0R1p5FeueHefjDm+GIM+kWfFnDjybOPgk=
|
||||
github.com/mattn/go-runewidth v0.0.30/go.mod h1:3qAiGCV4Koz/yuveO58qUefmUTRm8r0IGEXZ9jeHp/8=
|
||||
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
|
||||
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/maypok86/otter/v2 v2.3.0 h1:8H8AVVFUSzJwIegKwv1uF5aGitTY+AIrtktg7OcLs8w=
|
||||
@@ -225,6 +281,8 @@ github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwX
|
||||
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
|
||||
github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw=
|
||||
github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s=
|
||||
github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4=
|
||||
github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE=
|
||||
github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ=
|
||||
github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw=
|
||||
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
|
||||
@@ -245,6 +303,16 @@ github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g
|
||||
github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28=
|
||||
github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ=
|
||||
github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc=
|
||||
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
|
||||
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
|
||||
github.com/muesli/mango v0.1.0 h1:DZQK45d2gGbql1arsYA4vfg4d7I9Hfx5rX/GCmzsAvI=
|
||||
github.com/muesli/mango v0.1.0/go.mod h1:5XFpbC8jY5UUv89YQciiXNlbi+iJgt29VDC5xbzrLL4=
|
||||
github.com/muesli/mango-cobra v1.2.0 h1:DQvjzAM0PMZr85Iv9LIMaYISpTOliMEg+uMFtNbYvWg=
|
||||
github.com/muesli/mango-cobra v1.2.0/go.mod h1:vMJL54QytZAJhCT13LPVDfkvCUJ5/4jNUKF/8NC2UjA=
|
||||
github.com/muesli/mango-pflag v0.1.0 h1:UADqbYgpUyRoBja3g6LUL+3LErjpsOwaC9ywvBWe7Sg=
|
||||
github.com/muesli/mango-pflag v0.1.0/go.mod h1:YEQomTxaCUp8PrbhFh10UfbhbQrM/xJ4i2PB8VTLLW0=
|
||||
github.com/muesli/roff v0.1.0 h1:YD0lalCotmYuF5HhZliKWlIx7IEhiXeSfq7hNjFqGF8=
|
||||
github.com/muesli/roff v0.1.0/go.mod h1:pjAHQM9hdUUwm/krAfrLGgJkXJ+YuhtsfZ42kieB2Ig=
|
||||
github.com/nicksnyder/go-i18n/v2 v2.6.1 h1:JDEJraFsQE17Dut9HFDHzCoAWGEQJom5s0TRd17NIEQ=
|
||||
github.com/nicksnyder/go-i18n/v2 v2.6.1/go.mod h1:Vee0/9RD3Quc/NmwEjzzD7VTZ+Ir7QbXocrkhOzmUKA=
|
||||
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
|
||||
@@ -270,6 +338,8 @@ github.com/riverqueue/river/rivershared v0.47.0 h1:jdtFsBexCvLqTXf8wnDnGXvB/eeOt
|
||||
github.com/riverqueue/river/rivershared v0.47.0/go.mod h1:w8Pi1T+6ypyko5/hs9Mv7IIIKo4fAL9eXYnkVV/Y418=
|
||||
github.com/riverqueue/river/rivertype v0.47.0 h1:SzNavtLGR4nMT1QkrEYQ7n96OMatYsn/z3aJWhewmv0=
|
||||
github.com/riverqueue/river/rivertype v0.47.0/go.mod h1:XKkcRQR6zm8RR/JQa1Q2ywpj8uXQu21quPa4Lpw1Xhw=
|
||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
|
||||
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
@@ -311,6 +381,8 @@ github.com/tklauser/numcpus v0.12.0 h1:NR85qdvHA9pFse3x3weVZ0r0ST8R6l5RHbZrlRaqo
|
||||
github.com/tklauser/numcpus v0.12.0/go.mod h1:ABHeXzJnr/qqwguhClkZKT1/8VABcYrsyUiUGobwWJg=
|
||||
github.com/wneessen/go-mail v0.8.1 h1:tVcncj02/QySVFw3zr/kXOzZcuFQqBNT6K+Rbgm/pcM=
|
||||
github.com/wneessen/go-mail v0.8.1/go.mod h1:dWZ61zadzCIyvB4y1/YzC5O7MrbbzBfPkARmbosdf8w=
|
||||
github.com/xo/terminfo v1.0.0 h1:2ZpYzqWzyyytjk3TP6aJVDhkMAkc99/1xKQdA3TDTBY=
|
||||
github.com/xo/terminfo v1.0.0/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||
github.com/yuin/goldmark v1.8.6 h1:d0VcaP1sx9GkFVkoW+KtggpGi2KZ965i14b0+bDQST4=
|
||||
github.com/yuin/goldmark v1.8.6/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
|
||||
github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M=
|
||||
@@ -350,6 +422,8 @@ gocloud.dev v0.46.0 h1:niIuZwSjMtBx8K+ITB2s5kZullB13PGOS2ZoQPZxQ4Q=
|
||||
gocloud.dev v0.46.0/go.mod h1:ACQe+2qO+hEO+pdcvvsM+RB63r8TyGD1W3ESCLFyzvM=
|
||||
golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
|
||||
golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
|
||||
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
|
||||
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
|
||||
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||
golang.org/x/image v0.46.0 h1:b1+oYj0Jbp6K5MDT4i4/eZpYlk3V8SJhhDKh6LBHAyQ=
|
||||
golang.org/x/image v0.46.0/go.mod h1:3B3W05VGVQyuXucLINLjXKrqISASfi4Xj+iCVkLMwew=
|
||||
|
||||
@@ -10,6 +10,8 @@ import (
|
||||
"time"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/bonfire"
|
||||
)
|
||||
|
||||
// ArtifactResult is the outcome of a make:* command.
|
||||
@@ -46,7 +48,7 @@ func MakeModel(ctx context.Context, startDir, pluginID, name string, noMigration
|
||||
return ArtifactResult{}, err
|
||||
}
|
||||
if err := validateExportedIdent(name); err != nil {
|
||||
return ArtifactResult{}, fmt.Errorf("build: model: %w", err)
|
||||
return ArtifactResult{}, identHint(fmt.Errorf("build: model: %w", err))
|
||||
}
|
||||
table, err := modelTableName(plugin.ID, name)
|
||||
if err != nil {
|
||||
@@ -125,7 +127,7 @@ func MakeMigration(ctx context.Context, startDir, pluginID, name string) (Artifa
|
||||
return ArtifactResult{}, err
|
||||
}
|
||||
if err := validateExportedIdent(name); err != nil {
|
||||
return ArtifactResult{}, fmt.Errorf("build: migration: %w", err)
|
||||
return ArtifactResult{}, identHint(fmt.Errorf("build: migration: %w", err))
|
||||
}
|
||||
if err := rejectDuplicateIdent(plugin.Dir, "updates", name); err != nil {
|
||||
return ArtifactResult{}, err
|
||||
@@ -163,7 +165,7 @@ func MakeCommand(ctx context.Context, startDir, pluginID, name string) (Artifact
|
||||
return ArtifactResult{}, err
|
||||
}
|
||||
if err := validateExportedIdent(name); err != nil {
|
||||
return ArtifactResult{}, fmt.Errorf("build: command: %w", err)
|
||||
return ArtifactResult{}, identHint(fmt.Errorf("build: command: %w", err))
|
||||
}
|
||||
fn := name + "Command"
|
||||
if err := rejectDuplicateIdent(plugin.Dir, "console", fn); err != nil {
|
||||
@@ -201,7 +203,7 @@ func MakeJob(ctx context.Context, startDir, pluginID, name string) (ArtifactResu
|
||||
return ArtifactResult{}, err
|
||||
}
|
||||
if err := validateExportedIdent(name); err != nil {
|
||||
return ArtifactResult{}, fmt.Errorf("build: job: %w", err)
|
||||
return ArtifactResult{}, identHint(fmt.Errorf("build: job: %w", err))
|
||||
}
|
||||
fn := name + "Job"
|
||||
if err := rejectDuplicateIdent(plugin.Dir, "jobs", fn); err != nil {
|
||||
@@ -237,7 +239,7 @@ func MakeAdminController(ctx context.Context, startDir, pluginID, name string) (
|
||||
return ArtifactResult{}, err
|
||||
}
|
||||
if err := validateExportedIdent(name); err != nil {
|
||||
return ArtifactResult{}, fmt.Errorf("build: admin-controller: %w", err)
|
||||
return ArtifactResult{}, identHint(fmt.Errorf("build: admin-controller: %w", err))
|
||||
}
|
||||
fn := name + "Controller"
|
||||
if err := rejectDuplicateIdent(plugin.Dir, "controllers", fn); err != nil {
|
||||
@@ -325,6 +327,35 @@ func MakeAdminController(ctx context.Context, startDir, pluginID, name string) (
|
||||
return ArtifactResult{PluginDir: plugin.Dir, Files: created, Hint: plugin.Hint}, nil
|
||||
}
|
||||
|
||||
// pluginIDHint explains an invalid plugin ID.
|
||||
func pluginIDHint(err error) error {
|
||||
return bonfire.WithHint(err,
|
||||
"A plugin ID is the vendor and the plugin name in lower case letters and digits, joined by a dot.",
|
||||
"{bin} "+cliCommand("make:model")+" acme.blog <Name>")
|
||||
}
|
||||
|
||||
// identHint explains an invalid artifact name.
|
||||
func identHint(err error) error {
|
||||
return bonfire.WithHint(err,
|
||||
"The name becomes an exported Go identifier: a capital letter, then letters and digits.",
|
||||
"{bin} "+cliCommand("make:model")+" <vendor.plugin> BlogPost")
|
||||
}
|
||||
|
||||
// existsHint explains an artifact that is already there.
|
||||
func existsHint(err error) error {
|
||||
return bonfire.WithHint(err,
|
||||
"summer make: never overwrites a file or a name the plugin already has; choose another name, or edit the existing one.",
|
||||
"{bin} "+cliCommand("make:model")+" <vendor.plugin> <OtherName>")
|
||||
}
|
||||
|
||||
// pluginMissingHint explains a plugin that summer.yaml does not list and
|
||||
// plugins/ does not hold.
|
||||
func pluginMissingHint(err error, id string) error {
|
||||
return bonfire.WithHint(err,
|
||||
"The plugin is not listed in summer.yaml and has no directory under plugins/.",
|
||||
"{bin} make:plugin "+id+"\n{bin} plugin:add <dir>")
|
||||
}
|
||||
|
||||
func unexportedIdent(name string) string {
|
||||
r, size := utf8.DecodeRuneInString(name)
|
||||
return string(unicode.ToLower(r)) + name[size:]
|
||||
@@ -348,7 +379,7 @@ func resolvePlugin(startDir, id string) (pluginRef, error) {
|
||||
}
|
||||
}
|
||||
if err := ValidatePluginID(id); err != nil {
|
||||
return pluginRef{}, fmt.Errorf("build: %w", err)
|
||||
return pluginRef{}, pluginIDHint(fmt.Errorf("build: %w", err))
|
||||
}
|
||||
pluginDir, err := locatePluginDir(appDir, id)
|
||||
if err != nil {
|
||||
@@ -400,7 +431,9 @@ func inferPluginID(start, appDir string) (string, error) {
|
||||
}
|
||||
dir = parent
|
||||
}
|
||||
return "", fmt.Errorf("build: plugin id omitted and no plugin.go found from %s", start)
|
||||
return "", bonfire.WithHint(fmt.Errorf("build: plugin id omitted and no plugin.go found from %s", start),
|
||||
"Without a plugin ID, summer "+cliCommand("make:model")+" looks for plugin.go in this directory and above it.",
|
||||
"{bin} "+cliCommand("make:model")+" <vendor.plugin> <Name>")
|
||||
}
|
||||
|
||||
func locatePluginDir(appDir, id string) (string, error) {
|
||||
@@ -425,12 +458,12 @@ func locatePluginDir(appDir, id string) (string, error) {
|
||||
st, err := os.Stat(candidate)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return "", fmt.Errorf("build: plugin %s not found", id)
|
||||
return "", pluginMissingHint(fmt.Errorf("build: plugin %s not found", id), id)
|
||||
}
|
||||
return "", fmt.Errorf("build: stat %s: %w", candidate, err)
|
||||
}
|
||||
if !st.IsDir() {
|
||||
return "", fmt.Errorf("build: plugin %s not found", id)
|
||||
return "", pluginMissingHint(fmt.Errorf("build: plugin %s not found", id), id)
|
||||
}
|
||||
return candidate, nil
|
||||
}
|
||||
@@ -492,7 +525,7 @@ func rejectDuplicateFile(pluginDir, rel string) error {
|
||||
return fmt.Errorf("build: artifact path %q escapes the plugin directory", rel)
|
||||
}
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
return fmt.Errorf("build: %s already exists", rel)
|
||||
return existsHint(fmt.Errorf("build: %s already exists", rel))
|
||||
} else if !os.IsNotExist(err) {
|
||||
return fmt.Errorf("build: stat %s: %w", path, err)
|
||||
}
|
||||
@@ -506,7 +539,7 @@ func rejectDuplicateIdent(pluginDir, pkg, ident string) error {
|
||||
}
|
||||
for _, ref := range refs {
|
||||
if ref.Ident == ident {
|
||||
return fmt.Errorf("build: %s.%s already exists", pkg, ident)
|
||||
return existsHint(fmt.Errorf("build: %s.%s already exists", pkg, ident))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
|
||||
@@ -96,7 +96,7 @@ func generateMain(m Manifest) ([]byte, error) {
|
||||
b.WriteString("// Code generated by summer build. DO NOT EDIT.\n\n")
|
||||
b.WriteString("package main\n\n")
|
||||
b.WriteString("import (\n")
|
||||
b.WriteString("\t\"fmt\"\n")
|
||||
b.WriteString("\t\"context\"\n")
|
||||
b.WriteString("\t\"io\"\n")
|
||||
b.WriteString("\t\"os\"\n\n")
|
||||
b.WriteString("\t\"git.golem15.com/golem15/summercms/modules/backpack\"\n")
|
||||
@@ -104,29 +104,26 @@ func generateMain(m Manifest) ([]byte, error) {
|
||||
b.WriteString("\t\"git.golem15.com/golem15/summercms/modules/cabana\"\n")
|
||||
b.WriteString("\t\"git.golem15.com/golem15/summercms/modules/compass\"\n")
|
||||
b.WriteString("\t\"git.golem15.com/golem15/summercms/modules/conga\"\n")
|
||||
b.WriteString("\t\"git.golem15.com/golem15/summercms/modules/diary\"\n")
|
||||
b.WriteString("\t\"git.golem15.com/golem15/summercms/modules/lagoon\"\n")
|
||||
b.WriteString("\t\"git.golem15.com/golem15/summercms/modules/pact\"\n")
|
||||
b.WriteString("\t\"git.golem15.com/golem15/summercms/modules/party\"\n")
|
||||
b.WriteString("\t\"git.golem15.com/golem15/summercms/modules/sunscreen\"\n")
|
||||
b.WriteString("\t\"git.golem15.com/golem15/summercms/modules/surf\"\n")
|
||||
b.WriteString(")\n\n")
|
||||
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("\t}\n")
|
||||
b.WriteString("\tos.Exit(run(os.Args[1:], os.Stdout))\n")
|
||||
b.WriteString("}\n\n")
|
||||
b.WriteString("func run(args []string, out io.Writer) error {\n")
|
||||
b.WriteString("\tsunscreen.InstallDefault(os.Stderr)\n")
|
||||
b.WriteString("\tcfg, err := compass.Load(\"config\")\n")
|
||||
b.WriteString("\tif err != nil {\n")
|
||||
b.WriteString("\t\treturn err\n")
|
||||
b.WriteString("\t}\n")
|
||||
binary := strconv.Quote(m.Binary)
|
||||
fail := "\t\treturn bonfire.Fail(os.Stderr, " + binary + ", err, g)\n"
|
||||
b.WriteString("func run(args []string, out io.Writer) int {\n")
|
||||
b.WriteString("\tg := bonfire.PeekGlobals(args)\n")
|
||||
b.WriteString("\tdiary.Bootstrap(os.Stderr, diary.FromGlobals(g))\n")
|
||||
b.WriteString("\tcfg, err := compass.Open(compass.Options{Dir: \"config\", Env: g.Env})\n")
|
||||
b.WriteString("\tif err != nil {\n" + fail + "\t}\n")
|
||||
b.WriteString("\tif err := diary.Setup(cfg, diary.FromGlobals(g)); err != nil {\n" + fail + "\t}\n")
|
||||
b.WriteString("\tapp := backpack.New(cfg)\n")
|
||||
b.WriteString("\tplugins, err := party.Activate(app, PluginIDs)\n")
|
||||
b.WriteString("\tif err != nil {\n")
|
||||
b.WriteString("\t\treturn err\n")
|
||||
b.WriteString("\t}\n")
|
||||
b.WriteString("\tif err != nil {\n" + fail + "\t}\n")
|
||||
b.WriteString("\tcommands := lagoon.RuntimeCommands(app, plugins)\n")
|
||||
b.WriteString("\tcommands = append(commands, conga.RuntimeCommands(app, plugins)...)\n")
|
||||
b.WriteString("\tcommands = append(commands, surf.ServeCommand(app, plugins))\n")
|
||||
@@ -137,15 +134,10 @@ func generateMain(m Manifest) ([]byte, error) {
|
||||
b.WriteString("\t\t\tcommands = append(commands, hasCommands.Commands()...)\n")
|
||||
b.WriteString("\t\t}\n")
|
||||
b.WriteString("\t}\n")
|
||||
b.WriteString("\tif err := app.Publish(bonfire.NewCatalog(commands)); err != nil {\n")
|
||||
b.WriteString("\t\treturn err\n")
|
||||
b.WriteString("\t}\n")
|
||||
fmt.Fprintf(&b, "\troot, err := bonfire.NewRoot(%s, commands, out)\n", strconv.Quote(m.Binary))
|
||||
b.WriteString("\tif err != nil {\n")
|
||||
b.WriteString("\t\treturn err\n")
|
||||
b.WriteString("\t}\n")
|
||||
b.WriteString("\troot.SetArgs(args)\n")
|
||||
b.WriteString("\treturn root.Execute()\n")
|
||||
b.WriteString("\tif err := app.Publish(bonfire.NewCatalog(commands)); err != nil {\n" + fail + "\t}\n")
|
||||
fmt.Fprintf(&b, "\troot, err := bonfire.NewRoot(%s, commands, out, bonfire.WithVersion(bonfire.BuildVersion()), bonfire.OnGlobals(diary.ApplyGlobals))\n", binary)
|
||||
b.WriteString("\tif err != nil {\n" + fail + "\t}\n")
|
||||
b.WriteString("\treturn bonfire.Execute(context.Background(), root, args)\n")
|
||||
b.WriteString("}\n")
|
||||
return formatSource([]byte(b.String()))
|
||||
}
|
||||
|
||||
@@ -142,29 +142,45 @@ func TestGenerateMainRegistersCongaRuntimeCommands(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestGenerateMainInstallsRedactingLogger: the first statement of run makes
|
||||
// the credential-redacting handler the default logger, before config loads
|
||||
// or any plugin can log.
|
||||
func TestGenerateMainInstallsRedactingLogger(t *testing.T) {
|
||||
// TestGenerateMainInstallsDiary: run first reads the console global options
|
||||
// (bonfire.PeekGlobals), then installs the diary console logger with them,
|
||||
// before config loads (in the --env environment) or any plugin can log;
|
||||
// diary.Setup applies config/log.yaml right after compass loads it and before
|
||||
// the application container exists, and the root re-applies the parsed
|
||||
// options to diary through bonfire.OnGlobals.
|
||||
func TestGenerateMainInstallsDiary(t *testing.T) {
|
||||
mainSrc, err := generateMain(Manifest{Module: "example.com/app", Binary: "hello"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
line := []byte("sunscreen.InstallDefault(os.Stderr)")
|
||||
if n := bytes.Count(mainSrc, line); n != 1 {
|
||||
t.Fatalf("InstallDefault count = %d, want 1\n%s", n, mainSrc)
|
||||
peek := []byte("g := bonfire.PeekGlobals(args)")
|
||||
bootstrap := []byte("diary.Bootstrap(os.Stderr, diary.FromGlobals(g))")
|
||||
open := []byte(`compass.Open(compass.Options{Dir: "config", Env: g.Env})`)
|
||||
setup := []byte("diary.Setup(cfg, diary.FromGlobals(g))")
|
||||
hook := []byte("bonfire.OnGlobals(diary.ApplyGlobals)")
|
||||
for _, want := range [][]byte{peek, bootstrap, open, setup, hook, []byte("os.Exit(run(os.Args[1:], os.Stdout))"), []byte("return bonfire.Execute(context.Background(), root, args)")} {
|
||||
if n := bytes.Count(mainSrc, want); n != 1 {
|
||||
t.Fatalf("%s count = %d, want 1\n%s", want, n, mainSrc)
|
||||
}
|
||||
}
|
||||
if !bytes.Contains(mainSrc, []byte(`"git.golem15.com/golem15/summercms/modules/sunscreen"`)) {
|
||||
t.Fatalf("generated main does not import sunscreen:\n%s", mainSrc)
|
||||
if !bytes.Contains(mainSrc, []byte(`"git.golem15.com/golem15/summercms/modules/diary"`)) {
|
||||
t.Fatalf("generated main does not import diary:\n%s", mainSrc)
|
||||
}
|
||||
install := bytes.Index(mainSrc, line)
|
||||
runStart := bytes.Index(mainSrc, []byte("func run(args []string, out io.Writer) error {"))
|
||||
load := bytes.Index(mainSrc, []byte("compass.Load("))
|
||||
if runStart < 0 || load < 0 || !(runStart < install && install < load) {
|
||||
t.Fatalf("InstallDefault must be the first statement of run, before compass.Load:\n%s", mainSrc)
|
||||
if bytes.Contains(mainSrc, []byte(`"git.golem15.com/golem15/summercms/modules/sunscreen"`)) {
|
||||
t.Fatalf("generated main still imports sunscreen:\n%s", mainSrc)
|
||||
}
|
||||
if between := bytes.TrimSpace(mainSrc[runStart+len("func run(args []string, out io.Writer) error {") : install]); len(between) != 0 {
|
||||
t.Fatalf("statements before InstallDefault: %q", between)
|
||||
runSig := []byte("func run(args []string, out io.Writer) int {")
|
||||
runStart := bytes.Index(mainSrc, runSig)
|
||||
pk := bytes.Index(mainSrc, peek)
|
||||
boot := bytes.Index(mainSrc, bootstrap)
|
||||
load := bytes.Index(mainSrc, open)
|
||||
set := bytes.Index(mainSrc, setup)
|
||||
newApp := bytes.Index(mainSrc, []byte("backpack.New("))
|
||||
if runStart < 0 || newApp < 0 || !(runStart < pk && pk < boot && boot < load && load < set && set < newApp) {
|
||||
t.Fatalf("want run { PeekGlobals; Bootstrap; compass.Open; Setup; backpack.New }:\n%s", mainSrc)
|
||||
}
|
||||
if between := bytes.TrimSpace(mainSrc[runStart+len(runSig) : pk]); len(between) != 0 {
|
||||
t.Fatalf("statements before PeekGlobals: %q", between)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/bonfire"
|
||||
"github.com/knadh/koanf/parsers/yaml"
|
||||
)
|
||||
|
||||
@@ -193,6 +194,34 @@ func ValidatePluginID(id string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// cliInvocation is the summer command line being run (os.Args without the
|
||||
// program), shell-quoted, for the Try line of a hint.
|
||||
func cliInvocation() string {
|
||||
parts := make([]string, 0, len(os.Args))
|
||||
for _, a := range os.Args[1:] {
|
||||
if a == "" || strings.ContainsAny(a, " \t\n'\"\\$`*?[]{}()<>|&;#~!") {
|
||||
a = "'" + strings.ReplaceAll(a, "'", `'\''`) + "'"
|
||||
}
|
||||
parts = append(parts, a)
|
||||
}
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
|
||||
// cliCommand is the summer command being run (the first argument that is
|
||||
// not an option), or def when there is none, for hint texts.
|
||||
func cliCommand(def string) string {
|
||||
args := os.Args[1:]
|
||||
for i := 0; i < len(args); i++ {
|
||||
switch a := args[i]; {
|
||||
case a == "--env":
|
||||
i++
|
||||
case a != "" && !strings.HasPrefix(a, "-"):
|
||||
return a
|
||||
}
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
// FindAppDir walks up from start looking for summer.yaml.
|
||||
func FindAppDir(start string) (string, error) {
|
||||
return findAppDir(start)
|
||||
@@ -212,7 +241,9 @@ func findAppDir(start string) (string, error) {
|
||||
}
|
||||
parent := filepath.Dir(dir)
|
||||
if parent == dir {
|
||||
return "", fmt.Errorf("build: no %s found from %s", manifestName, start)
|
||||
return "", bonfire.WithHint(fmt.Errorf("build: no %s found from %s", manifestName, start),
|
||||
"summer "+cliCommand("build")+" runs inside an application: a directory with summer.yaml, or one below it.",
|
||||
"cd <app>; {bin} "+cliInvocation())
|
||||
}
|
||||
dir = parent
|
||||
}
|
||||
|
||||
@@ -389,6 +389,7 @@ func planNewApp(opts NewOptions, target, framework string) ([]genFile, error) {
|
||||
{"config/database.yaml", "new/config/database.yaml", 0o644},
|
||||
{"config/http.yaml", "new/config/http.yaml", 0o644},
|
||||
{"config/storage.yaml", "new/config/storage.yaml", 0o644},
|
||||
{"config/log.yaml", "new/config/log.yaml", 0o644},
|
||||
{".env", "new/env", 0o600},
|
||||
{".env.example", "new/env.example", 0o644},
|
||||
{"compose.yaml", "new/compose.yaml", 0o644},
|
||||
|
||||
@@ -133,7 +133,7 @@ func TestNewGolden(t *testing.T) {
|
||||
dir, out := newGoldenApp(t, false)
|
||||
for _, rel := range []string{
|
||||
"go.mod", "go.work", "summer.yaml", "main.go", "plugins.gen.go",
|
||||
"config/app.yaml", "config/database.yaml", "config/http.yaml", "config/storage.yaml",
|
||||
"config/app.yaml", "config/database.yaml", "config/http.yaml", "config/storage.yaml", "config/log.yaml",
|
||||
".env", ".env.example", ".gitignore", "compose.yaml", "CLAUDE.md",
|
||||
"plugins/demo/plugin.go", "plugins/demo/routes.go",
|
||||
} {
|
||||
|
||||
@@ -15,6 +15,8 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"text/template"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/bonfire"
|
||||
)
|
||||
|
||||
//go:embed stubs/*.tmpl
|
||||
@@ -63,7 +65,9 @@ func MakePlugin(ctx context.Context, startDir, id string) (string, error) {
|
||||
|
||||
func makePlugin(ctx context.Context, startDir, id string, opts pluginOptions) (string, error) {
|
||||
if err := ValidatePluginID(id); err != nil {
|
||||
return "", fmt.Errorf("build: %w", err)
|
||||
return "", bonfire.WithHint(fmt.Errorf("build: %w", err),
|
||||
"A plugin ID is the vendor and the plugin name in lower case letters and digits, joined by a dot.",
|
||||
"{bin} make:plugin acme.blog")
|
||||
}
|
||||
appDir, err := findAppDir(startDir)
|
||||
if err != nil {
|
||||
@@ -75,7 +79,9 @@ func makePlugin(ctx context.Context, startDir, id string, opts pluginOptions) (s
|
||||
return "", fmt.Errorf("build: plugin path %q escapes the app root", pluginDir)
|
||||
}
|
||||
if _, err := os.Stat(pluginDir); err == nil {
|
||||
return "", fmt.Errorf("build: plugin directory %s already exists", pluginDir)
|
||||
return "", bonfire.WithHint(fmt.Errorf("build: plugin directory %s already exists", pluginDir),
|
||||
"summer make:plugin creates a new directory under plugins/ and never overwrites one; register an existing plugin with plugin:add.",
|
||||
"{bin} plugin:add "+filepath.Join("plugins", name))
|
||||
} else if !os.IsNotExist(err) {
|
||||
return "", fmt.Errorf("build: stat %s: %w", pluginDir, err)
|
||||
}
|
||||
|
||||
@@ -34,6 +34,34 @@ uploads:
|
||||
public_path_prefix: "/storage/uploads"
|
||||
{{end}}
|
||||
|
||||
{{define "new/config/log.yaml"}}# Logging, shaped like Laravel's config/logging.php: records go to the
|
||||
# default channel, and a stack sends them to several channels at once.
|
||||
# Override any key with a SUMMER_LOG__ variable, a double underscore
|
||||
# separating path segments: SUMMER_LOG__CHANNELS__DAILY__DAYS=30.
|
||||
default: stack
|
||||
level: info
|
||||
channels:
|
||||
stack:
|
||||
driver: stack
|
||||
channels: [console, daily]
|
||||
console:
|
||||
# auto: styled lines on a terminal, logfmt otherwise (systemd, Docker);
|
||||
# json writes JSON lines instead.
|
||||
driver: console
|
||||
format: auto
|
||||
daily:
|
||||
# One file per day under storage/logs (summer-YYYY-MM-DD.log), kept 14 days.
|
||||
driver: daily
|
||||
path: storage/logs/summer.log
|
||||
days: 14
|
||||
format: json
|
||||
level: warn
|
||||
components:
|
||||
# Per-component levels. http: warn silences the request log.
|
||||
river: warn
|
||||
http: info
|
||||
{{end}}
|
||||
|
||||
{{define "new/env"}}# Local secrets for this application: the database DSN, the application key
|
||||
# and the admin token secret. .gitignore excludes this file; never commit it.
|
||||
# Real environment variables override the values here.
|
||||
@@ -123,7 +151,7 @@ summer admin:create --email you@example.com --superuser # create a backend admi
|
||||
|
||||
- `summer.yaml`: the application manifest (module path, binary name, ordered plugin list).
|
||||
- `plugins/`: local plugin modules.{{if not .NoPlugin}} `plugins/{{.Name}}` is the starter plugin `golem15.{{.Name}}`; its `routes.go` answers `GET /` with JSON.{{end}}
|
||||
- `config/`: YAML configuration (`app.yaml`, `database.yaml`, `http.yaml`, `storage.yaml`). Any key can be overridden with a `SUMMER_` environment variable, a double underscore separating path segments (`SUMMER_DATABASE__DSN` sets `database.dsn`).
|
||||
- `config/`: YAML configuration (`app.yaml`, `database.yaml`, `http.yaml`, `log.yaml`, `storage.yaml`). Any key can be overridden with a `SUMMER_` environment variable, a double underscore separating path segments (`SUMMER_DATABASE__DSN` sets `database.dsn`).
|
||||
- `.env`: local secrets (database DSN, application key, admin token secret). Never commit it; `.gitignore` excludes it. `.env.example` lists the variables.
|
||||
- `compose.yaml`: the local PostgreSQL 16.
|
||||
- `main.go`, `plugins.gen.go`: generated by `summer build` from `summer.yaml`. Do not edit them by hand; they are committed.
|
||||
|
||||
@@ -31,7 +31,7 @@ summer admin:create --email you@example.com --superuser # create a backend admi
|
||||
|
||||
- `summer.yaml`: the application manifest (module path, binary name, ordered plugin list).
|
||||
- `plugins/`: local plugin modules.
|
||||
- `config/`: YAML configuration (`app.yaml`, `database.yaml`, `http.yaml`, `storage.yaml`). Any key can be overridden with a `SUMMER_` environment variable, a double underscore separating path segments (`SUMMER_DATABASE__DSN` sets `database.dsn`).
|
||||
- `config/`: YAML configuration (`app.yaml`, `database.yaml`, `http.yaml`, `log.yaml`, `storage.yaml`). Any key can be overridden with a `SUMMER_` environment variable, a double underscore separating path segments (`SUMMER_DATABASE__DSN` sets `database.dsn`).
|
||||
- `.env`: local secrets (database DSN, application key, admin token secret). Never commit it; `.gitignore` excludes it. `.env.example` lists the variables.
|
||||
- `compose.yaml`: the local PostgreSQL 16.
|
||||
- `main.go`, `plugins.gen.go`: generated by `summer build` from `summer.yaml`. Do not edit them by hand; they are committed.
|
||||
|
||||
2
internal/build/testdata/new/CLAUDE.md.golden
vendored
2
internal/build/testdata/new/CLAUDE.md.golden
vendored
@@ -31,7 +31,7 @@ summer admin:create --email you@example.com --superuser # create a backend admi
|
||||
|
||||
- `summer.yaml`: the application manifest (module path, binary name, ordered plugin list).
|
||||
- `plugins/`: local plugin modules. `plugins/demo` is the starter plugin `golem15.demo`; its `routes.go` answers `GET /` with JSON.
|
||||
- `config/`: YAML configuration (`app.yaml`, `database.yaml`, `http.yaml`, `storage.yaml`). Any key can be overridden with a `SUMMER_` environment variable, a double underscore separating path segments (`SUMMER_DATABASE__DSN` sets `database.dsn`).
|
||||
- `config/`: YAML configuration (`app.yaml`, `database.yaml`, `http.yaml`, `log.yaml`, `storage.yaml`). Any key can be overridden with a `SUMMER_` environment variable, a double underscore separating path segments (`SUMMER_DATABASE__DSN` sets `database.dsn`).
|
||||
- `.env`: local secrets (database DSN, application key, admin token secret). Never commit it; `.gitignore` excludes it. `.env.example` lists the variables.
|
||||
- `compose.yaml`: the local PostgreSQL 16.
|
||||
- `main.go`, `plugins.gen.go`: generated by `summer build` from `summer.yaml`. Do not edit them by hand; they are committed.
|
||||
|
||||
26
internal/build/testdata/new/config__log.yaml.golden
vendored
Normal file
26
internal/build/testdata/new/config__log.yaml.golden
vendored
Normal file
@@ -0,0 +1,26 @@
|
||||
# Logging, shaped like Laravel's config/logging.php: records go to the
|
||||
# default channel, and a stack sends them to several channels at once.
|
||||
# Override any key with a SUMMER_LOG__ variable, a double underscore
|
||||
# separating path segments: SUMMER_LOG__CHANNELS__DAILY__DAYS=30.
|
||||
default: stack
|
||||
level: info
|
||||
channels:
|
||||
stack:
|
||||
driver: stack
|
||||
channels: [console, daily]
|
||||
console:
|
||||
# auto: styled lines on a terminal, logfmt otherwise (systemd, Docker);
|
||||
# json writes JSON lines instead.
|
||||
driver: console
|
||||
format: auto
|
||||
daily:
|
||||
# One file per day under storage/logs (summer-YYYY-MM-DD.log), kept 14 days.
|
||||
driver: daily
|
||||
path: storage/logs/summer.log
|
||||
days: 14
|
||||
format: json
|
||||
level: warn
|
||||
components:
|
||||
# Per-component levels. http: warn silences the request log.
|
||||
river: warn
|
||||
http: info
|
||||
32
internal/build/testdata/new/main.go.golden
vendored
32
internal/build/testdata/new/main.go.golden
vendored
@@ -3,7 +3,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"context"
|
||||
"io"
|
||||
"os"
|
||||
|
||||
@@ -12,30 +12,31 @@ import (
|
||||
"git.golem15.com/golem15/summercms/modules/cabana"
|
||||
"git.golem15.com/golem15/summercms/modules/compass"
|
||||
"git.golem15.com/golem15/summercms/modules/conga"
|
||||
"git.golem15.com/golem15/summercms/modules/diary"
|
||||
"git.golem15.com/golem15/summercms/modules/lagoon"
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
"git.golem15.com/golem15/summercms/modules/party"
|
||||
"git.golem15.com/golem15/summercms/modules/sunscreen"
|
||||
"git.golem15.com/golem15/summercms/modules/surf"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if err := run(os.Args[1:], os.Stdout); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
os.Exit(run(os.Args[1:], os.Stdout))
|
||||
}
|
||||
|
||||
func run(args []string, out io.Writer) error {
|
||||
sunscreen.InstallDefault(os.Stderr)
|
||||
cfg, err := compass.Load("config")
|
||||
func run(args []string, out io.Writer) int {
|
||||
g := bonfire.PeekGlobals(args)
|
||||
diary.Bootstrap(os.Stderr, diary.FromGlobals(g))
|
||||
cfg, err := compass.Open(compass.Options{Dir: "config", Env: g.Env})
|
||||
if err != nil {
|
||||
return err
|
||||
return bonfire.Fail(os.Stderr, "demo", err, g)
|
||||
}
|
||||
if err := diary.Setup(cfg, diary.FromGlobals(g)); err != nil {
|
||||
return bonfire.Fail(os.Stderr, "demo", err, g)
|
||||
}
|
||||
app := backpack.New(cfg)
|
||||
plugins, err := party.Activate(app, PluginIDs)
|
||||
if err != nil {
|
||||
return err
|
||||
return bonfire.Fail(os.Stderr, "demo", err, g)
|
||||
}
|
||||
commands := lagoon.RuntimeCommands(app, plugins)
|
||||
commands = append(commands, conga.RuntimeCommands(app, plugins)...)
|
||||
@@ -48,12 +49,11 @@ func run(args []string, out io.Writer) error {
|
||||
}
|
||||
}
|
||||
if err := app.Publish(bonfire.NewCatalog(commands)); err != nil {
|
||||
return err
|
||||
return bonfire.Fail(os.Stderr, "demo", err, g)
|
||||
}
|
||||
root, err := bonfire.NewRoot("demo", commands, out)
|
||||
root, err := bonfire.NewRoot("demo", commands, out, bonfire.WithVersion(bonfire.BuildVersion()), bonfire.OnGlobals(diary.ApplyGlobals))
|
||||
if err != nil {
|
||||
return err
|
||||
return bonfire.Fail(os.Stderr, "demo", err, g)
|
||||
}
|
||||
root.SetArgs(args)
|
||||
return root.Execute()
|
||||
return bonfire.Execute(context.Background(), root, args)
|
||||
}
|
||||
|
||||
@@ -14,23 +14,44 @@ import (
|
||||
"github.com/fsnotify/fsnotify"
|
||||
|
||||
"git.golem15.com/golem15/summercms/internal/build"
|
||||
"git.golem15.com/golem15/summercms/modules/bonfire"
|
||||
)
|
||||
|
||||
const defaultDebounce = 200 * time.Millisecond
|
||||
|
||||
// Options customizes the watch loop. Tests inject Build and Start.
|
||||
type Options struct {
|
||||
Build func(ctx context.Context, appDir string, out io.Writer) error
|
||||
Start func(ctx context.Context, binPath string, stdout, stderr io.Writer) (*exec.Cmd, error)
|
||||
Build func(ctx context.Context, appDir string, out io.Writer) error
|
||||
// Start runs binPath with args ("serve" followed by the forwarded
|
||||
// global options).
|
||||
Start func(ctx context.Context, binPath string, args []string, stdout, stderr io.Writer) (*exec.Cmd, error)
|
||||
Debounce time.Duration
|
||||
}
|
||||
|
||||
// Watch rebuilds the app on source changes and restarts the child binary.
|
||||
func Watch(ctx context.Context, appDir string, out io.Writer) error {
|
||||
return watch(ctx, appDir, out, Options{})
|
||||
// Watch builds the app, runs bin/<binary> serve with args (the global
|
||||
// options summer dev was given, such as -v) and rebuilds and restarts it on
|
||||
// source changes. When ctx ends it stops the child with SIGTERM (serve
|
||||
// prints its own shutdown report), prints the dev report (rebuilds, failed
|
||||
// builds, restarts, uptime) and returns nil.
|
||||
func Watch(ctx context.Context, appDir string, out bonfire.Output, args []string) error {
|
||||
return watch(ctx, appDir, out, args, Options{})
|
||||
}
|
||||
|
||||
func watch(ctx context.Context, appDir string, out io.Writer, opts Options) error {
|
||||
// devReport counts what a watch session did.
|
||||
type devReport struct {
|
||||
rebuilds, failed, restarts int
|
||||
started time.Time
|
||||
}
|
||||
|
||||
func (r devReport) details() []bonfire.Detail {
|
||||
return []bonfire.Detail{
|
||||
{Label: "Rebuilds", Value: fmt.Sprintf("%d (%d failed)", r.rebuilds+r.failed, r.failed), Warn: r.failed > 0},
|
||||
{Label: "Restarts", Value: fmt.Sprint(r.restarts)},
|
||||
{Label: "Uptime", Value: time.Since(r.started).Round(time.Second).String()},
|
||||
}
|
||||
}
|
||||
|
||||
func watch(ctx context.Context, appDir string, out bonfire.Output, args []string, opts Options) error {
|
||||
if appDir == "" {
|
||||
return fmt.Errorf("dev: app directory is empty")
|
||||
}
|
||||
@@ -40,7 +61,7 @@ func watch(ctx context.Context, appDir string, out io.Writer, opts Options) erro
|
||||
}
|
||||
appDir = abs
|
||||
if out == nil {
|
||||
out = os.Stdout
|
||||
out = bonfire.NewOutput(os.Stdin, os.Stdout, os.Stderr)
|
||||
}
|
||||
if opts.Build == nil {
|
||||
opts.Build = build.App
|
||||
@@ -57,6 +78,14 @@ func watch(ctx context.Context, appDir string, out io.Writer, opts Options) erro
|
||||
return err
|
||||
}
|
||||
binPath := filepath.Join(appDir, "bin", m.Binary)
|
||||
childArgs := append([]string{"serve"}, args...)
|
||||
// On a terminal the child gets the process's own stdout and stderr, so
|
||||
// serve sees a terminal and prints its styled summary and report.
|
||||
childOut, childErr := io.Writer(out), io.Writer(out)
|
||||
if bonfire.IsTerminal(out) {
|
||||
childOut, childErr = os.Stdout, os.Stderr
|
||||
}
|
||||
report := devReport{started: time.Now()}
|
||||
|
||||
watcher, err := fsnotify.NewWatcher()
|
||||
if err != nil {
|
||||
@@ -70,26 +99,35 @@ func watch(ctx context.Context, appDir string, out io.Writer, opts Options) erro
|
||||
var current *child
|
||||
defer func() { current.stop() }()
|
||||
|
||||
out.Info(fmt.Sprintf("Watching %s; running bin/%s %s", appDir, m.Binary, strings.Join(childArgs, " ")))
|
||||
rebuild := func() {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
start := time.Now()
|
||||
if err := opts.Build(ctx, appDir, out); err != nil {
|
||||
fmt.Fprintf(out, "build error: %v\n", err)
|
||||
report.failed++
|
||||
out.Error(fmt.Sprintf("Build failed: %v", err))
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(out, "rebuild: %s\n", time.Since(start).Round(time.Millisecond))
|
||||
report.rebuilds++
|
||||
out.Success(fmt.Sprintf("Rebuilt in %s", time.Since(start).Round(time.Millisecond)))
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
next, err := startChild(ctx, opts, binPath, out)
|
||||
restart := current != nil
|
||||
current.stop()
|
||||
current = nil
|
||||
next, err := startChild(ctx, opts, binPath, childArgs, childOut, childErr)
|
||||
if err != nil {
|
||||
fmt.Fprintf(out, "start error: %v\n", err)
|
||||
out.Error(fmt.Sprintf("Start failed: %v", err))
|
||||
return
|
||||
}
|
||||
current.stop()
|
||||
current = next
|
||||
if restart {
|
||||
report.restarts++
|
||||
out.Info("Restarted bin/" + m.Binary)
|
||||
}
|
||||
}
|
||||
|
||||
rebuild()
|
||||
@@ -145,9 +183,10 @@ func watch(ctx context.Context, appDir string, out io.Writer, opts Options) erro
|
||||
case <-ctx.Done():
|
||||
current.stop()
|
||||
current = nil
|
||||
return ctx.Err()
|
||||
bonfire.Details(out, "Dev report", report.details())
|
||||
return nil
|
||||
case err := <-watchErrs:
|
||||
fmt.Fprintf(out, "watch error: %v\n", err)
|
||||
out.Warning(fmt.Sprintf("Watch error: %v", err))
|
||||
case <-events:
|
||||
if timer == nil {
|
||||
timer = time.NewTimer(opts.Debounce)
|
||||
@@ -172,8 +211,8 @@ type child struct {
|
||||
wait <-chan error
|
||||
}
|
||||
|
||||
func startChild(ctx context.Context, opts Options, binPath string, out io.Writer) (*child, error) {
|
||||
cmd, err := opts.Start(ctx, binPath, out, out)
|
||||
func startChild(ctx context.Context, opts Options, binPath string, args []string, stdout, stderr io.Writer) (*child, error) {
|
||||
cmd, err := opts.Start(ctx, binPath, args, stdout, stderr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -198,8 +237,10 @@ func (c *child) stop() {
|
||||
}
|
||||
}
|
||||
|
||||
func startBin(_ context.Context, binPath string, stdout, stderr io.Writer) (*exec.Cmd, error) {
|
||||
cmd := exec.Command(binPath)
|
||||
// startBin runs binPath with args, which watch builds as "serve" followed
|
||||
// by the forwarded global options.
|
||||
func startBin(_ context.Context, binPath string, args []string, stdout, stderr io.Writer) (*exec.Cmd, error) {
|
||||
cmd := exec.Command(binPath, args...)
|
||||
cmd.Stdout = stdout
|
||||
cmd.Stderr = stderr
|
||||
cmd.Stdin = os.Stdin
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
"time"
|
||||
|
||||
"git.golem15.com/golem15/summercms/internal/build"
|
||||
"git.golem15.com/golem15/summercms/modules/bonfire"
|
||||
)
|
||||
|
||||
type safeBuffer struct {
|
||||
@@ -35,6 +36,11 @@ func (s *safeBuffer) String() string {
|
||||
return s.b.String()
|
||||
}
|
||||
|
||||
// testOutput is a non-terminal bonfire console writing to w.
|
||||
func testOutput(w io.Writer) bonfire.Output {
|
||||
return bonfire.NewOutput(strings.NewReader(""), w, w)
|
||||
}
|
||||
|
||||
func TestWatchRebuildsOnSourceEdit(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
writeWatchApp(t, dir)
|
||||
@@ -53,7 +59,10 @@ func TestWatchRebuildsOnSourceEdit(t *testing.T) {
|
||||
fmt.Fprintln(w, "built hello in 1ms")
|
||||
return nil
|
||||
},
|
||||
Start: func(ctx context.Context, binPath string, stdout, stderr io.Writer) (*exec.Cmd, error) {
|
||||
Start: func(ctx context.Context, binPath string, args []string, stdout, stderr io.Writer) (*exec.Cmd, error) {
|
||||
if got := strings.Join(args, " "); got != "serve -v --env staging" {
|
||||
return nil, fmt.Errorf("start args = %q, want serve and the forwarded globals", got)
|
||||
}
|
||||
starts.Add(1)
|
||||
cmd := exec.Command("sleep", "30")
|
||||
if err := cmd.Start(); err != nil {
|
||||
@@ -64,7 +73,7 @@ func TestWatchRebuildsOnSourceEdit(t *testing.T) {
|
||||
}
|
||||
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- watch(ctx, dir, &out, opts) }()
|
||||
go func() { done <- watch(ctx, dir, testOutput(&out), []string{"-v", "--env", "staging"}, opts) }()
|
||||
|
||||
waitUntil(t, "initial start", func() bool { return starts.Load() == 1 })
|
||||
if builds.Load() != 1 {
|
||||
@@ -91,15 +100,18 @@ func TestWatchRebuildsOnSourceEdit(t *testing.T) {
|
||||
}
|
||||
|
||||
got := out.String()
|
||||
if strings.Count(got, "rebuild:") != 2 {
|
||||
if strings.Count(got, "Rebuilt in ") != 2 {
|
||||
t.Fatalf("want 2 rebuild latency lines, got %q", got)
|
||||
}
|
||||
|
||||
cancel()
|
||||
select {
|
||||
case err := <-done:
|
||||
if err != nil && err != context.Canceled {
|
||||
t.Fatalf("watch exit: %v", err)
|
||||
if err != nil {
|
||||
t.Fatalf("watch returned %v after Ctrl+C, want nil (exit 0)", err)
|
||||
}
|
||||
if !strings.Contains(out.String(), "Dev report") || !strings.Contains(out.String(), "Restarts 1") {
|
||||
t.Fatalf("missing dev report:\n%s", out.String())
|
||||
}
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("watch did not exit on cancel")
|
||||
@@ -127,7 +139,7 @@ func TestWatchKeepsChildOnBuildFailure(t *testing.T) {
|
||||
}
|
||||
return nil
|
||||
},
|
||||
Start: func(ctx context.Context, binPath string, stdout, stderr io.Writer) (*exec.Cmd, error) {
|
||||
Start: func(ctx context.Context, binPath string, args []string, stdout, stderr io.Writer) (*exec.Cmd, error) {
|
||||
starts.Add(1)
|
||||
cmd := exec.Command("sleep", "30")
|
||||
if err := cmd.Start(); err != nil {
|
||||
@@ -139,7 +151,7 @@ func TestWatchKeepsChildOnBuildFailure(t *testing.T) {
|
||||
}
|
||||
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- watch(ctx, dir, &out, opts) }()
|
||||
go func() { done <- watch(ctx, dir, testOutput(&out), nil, opts) }()
|
||||
waitUntil(t, "initial start", func() bool { return starts.Load() == 1 })
|
||||
|
||||
if err := os.WriteFile(filepath.Join(dir, "plugin.go"), []byte("package p\n// bad\n"), 0o644); err != nil {
|
||||
@@ -192,7 +204,7 @@ func TestWatchDebouncesRapidEdits(t *testing.T) {
|
||||
builds.Add(1)
|
||||
return nil
|
||||
},
|
||||
Start: func(ctx context.Context, binPath string, stdout, stderr io.Writer) (*exec.Cmd, error) {
|
||||
Start: func(ctx context.Context, binPath string, args []string, stdout, stderr io.Writer) (*exec.Cmd, error) {
|
||||
cmd := exec.Command("sleep", "30")
|
||||
if err := cmd.Start(); err != nil {
|
||||
return nil, err
|
||||
@@ -201,7 +213,7 @@ func TestWatchDebouncesRapidEdits(t *testing.T) {
|
||||
},
|
||||
}
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- watch(ctx, dir, &out, opts) }()
|
||||
go func() { done <- watch(ctx, dir, testOutput(&out), nil, opts) }()
|
||||
waitUntil(t, "initial build", func() bool { return builds.Load() == 1 })
|
||||
|
||||
for i := 0; i < 5; i++ {
|
||||
@@ -243,7 +255,7 @@ func TestWatchIgnoresBinAndTmp(t *testing.T) {
|
||||
builds.Add(1)
|
||||
return nil
|
||||
},
|
||||
Start: func(ctx context.Context, binPath string, stdout, stderr io.Writer) (*exec.Cmd, error) {
|
||||
Start: func(ctx context.Context, binPath string, args []string, stdout, stderr io.Writer) (*exec.Cmd, error) {
|
||||
cmd := exec.Command("sleep", "30")
|
||||
if err := cmd.Start(); err != nil {
|
||||
return nil, err
|
||||
@@ -252,7 +264,7 @@ func TestWatchIgnoresBinAndTmp(t *testing.T) {
|
||||
},
|
||||
}
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- watch(ctx, dir, &out, opts) }()
|
||||
go func() { done <- watch(ctx, dir, testOutput(&out), nil, opts) }()
|
||||
waitUntil(t, "initial build", func() bool { return builds.Load() == 1 })
|
||||
|
||||
if err := os.WriteFile(filepath.Join(dir, "bin", "hello.go"), []byte("package main\n"), 0o644); err != nil {
|
||||
@@ -290,7 +302,7 @@ func TestWatchHelloWorkspaceRebuildLatency(t *testing.T) {
|
||||
}
|
||||
return nil
|
||||
},
|
||||
Start: func(ctx context.Context, binPath string, stdout, stderr io.Writer) (*exec.Cmd, error) {
|
||||
Start: func(ctx context.Context, binPath string, args []string, stdout, stderr io.Writer) (*exec.Cmd, error) {
|
||||
starts.Add(1)
|
||||
cmd := exec.Command("sleep", "30")
|
||||
if err := cmd.Start(); err != nil {
|
||||
@@ -301,7 +313,7 @@ func TestWatchHelloWorkspaceRebuildLatency(t *testing.T) {
|
||||
}
|
||||
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- watch(ctx, dir, &out, opts) }()
|
||||
go func() { done <- watch(ctx, dir, testOutput(&out), nil, opts) }()
|
||||
waitUntilTimeout(t, 60*time.Second, "initial real rebuild", func() bool { return starts.Load() == 1 })
|
||||
if builds.Load() != 1 {
|
||||
t.Fatalf("initial builds = %d", builds.Load())
|
||||
@@ -320,7 +332,7 @@ func TestWatchHelloWorkspaceRebuildLatency(t *testing.T) {
|
||||
}
|
||||
waitUntilTimeout(t, 60*time.Second, "rebuild after source edit", func() bool { return starts.Load() == 2 })
|
||||
got := out.String()
|
||||
if strings.Count(got, "rebuild:") < 2 {
|
||||
if strings.Count(got, "Rebuilt in ") < 2 {
|
||||
t.Fatalf("want rebuild latency lines after restart, got %q", got)
|
||||
}
|
||||
if !rebuildLinePresent(got) {
|
||||
@@ -352,7 +364,7 @@ func TestWatchHelloWorkspaceRebuildLatency(t *testing.T) {
|
||||
func rebuildLinePresent(s string) bool {
|
||||
for _, line := range strings.Split(s, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if strings.HasPrefix(line, "rebuild:") && len(strings.TrimSpace(strings.TrimPrefix(line, "rebuild:"))) > 0 {
|
||||
if _, d, ok := strings.Cut(line, "Rebuilt in "); ok && strings.TrimSpace(d) != "" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,10 +101,12 @@ func (s *site) checkCommands(docs []parsedDoc) ([]Problem, error) {
|
||||
// command. tool is true for summer and `go run ./cmd/summer`, false for
|
||||
// ./bin/{app} and bin/{app}. ok is false when the line is not one of
|
||||
// those programs or when flags leave no command word. Flag handling
|
||||
// matches cobra's stripFlags for a root whose only flag is the bool
|
||||
// --help / -h: "--" ends the search, --help and -h take no value, any
|
||||
// other --name or two-character -x consumes the next token, and a token
|
||||
// that already contains "=" is skipped alone.
|
||||
// matches cobra's stripFlags for the bonfire root and its global options:
|
||||
// "--" ends the search; --help, -h, -v, -vv, -vvv, --verbose, -q, --quiet,
|
||||
// -n, --no-interaction, --ansi and --no-ansi take no value; --env takes
|
||||
// the next token (or --env=X); any other --name or two-character -x
|
||||
// consumes the next token, and a token that already contains "=" is
|
||||
// skipped alone.
|
||||
func commandWord(cmd string) (name string, tool bool, ok bool) {
|
||||
fields := strings.Fields(strings.TrimSpace(cmd))
|
||||
if len(fields) > 0 && fields[0] == "$" {
|
||||
@@ -134,7 +136,7 @@ func commandWord(cmd string) (name string, tool bool, ok bool) {
|
||||
return "", tool, false
|
||||
case strings.HasPrefix(s, "-") && strings.Contains(s, "="):
|
||||
fields = fields[1:]
|
||||
case s == "--help" || s == "-h":
|
||||
case valuelessGlobal(s):
|
||||
fields = fields[1:]
|
||||
case strings.HasPrefix(s, "--") || (strings.HasPrefix(s, "-") && len(s) == 2):
|
||||
if len(fields) < 3 {
|
||||
@@ -150,6 +152,18 @@ func commandWord(cmd string) (name string, tool bool, ok bool) {
|
||||
return "", tool, false
|
||||
}
|
||||
|
||||
// valuelessGlobal reports the root flags that take no value: --help and -h
|
||||
// and bonfire's bool global options. "--env" takes a value and is handled
|
||||
// with the other value flags.
|
||||
func valuelessGlobal(s string) bool {
|
||||
switch s {
|
||||
case "--help", "-h", "-v", "-vv", "-vvv", "--verbose", "-q", "--quiet",
|
||||
"-n", "--no-interaction", "--ansi", "--no-ansi":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// isAssignment reports a leading VAR=value token: a name of letters,
|
||||
// digits and underscores that does not start with a digit.
|
||||
func isAssignment(tok string) bool {
|
||||
|
||||
@@ -593,6 +593,11 @@ func TestCommandWord(t *testing.T) {
|
||||
{"summer", "", tool, false},
|
||||
{"FOO=1", "", false, false},
|
||||
{"summer --version", "", tool, false},
|
||||
{"bin/acme -v serve", "serve", app, true},
|
||||
{"bin/acme -vvv --env=dev migrate", "migrate", app, true},
|
||||
{"bin/acme --env staging migrate", "migrate", app, true},
|
||||
{"summer -q --no-ansi -n list", "list", tool, true},
|
||||
{"bin/acme --verbose --quiet --no-interaction --ansi help migrate", "help", app, true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
name, isTool, ok := commandWord(tc.in)
|
||||
|
||||
@@ -183,7 +183,7 @@ ids, err := svc.Engine().SearchIDs(ctx, svc.IndexName(&models.Post{}), beachcomb
|
||||
|
||||
## Dependencies
|
||||
|
||||
- `backpack`, `compass` and `lagoon` (callback installation and `lagoon.AfterCommit`) from this repository.
|
||||
- `backpack`, `compass`, `diary` (component logger `beachcomber` when the app publishes no logger) and `lagoon` (callback installation and `lagoon.AfterCommit`) from this repository.
|
||||
- `gorm.io/gorm` (sync callbacks and reloads).
|
||||
- The Typesense client is plain `net/http`; no Typesense SDK is used.
|
||||
|
||||
|
||||
@@ -16,10 +16,15 @@ import (
|
||||
"sync"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||
"git.golem15.com/golem15/summercms/modules/diary"
|
||||
"git.golem15.com/golem15/summercms/modules/lagoon"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// moduleLog is beachcomber's logger: records carry component=beachcomber and follow
|
||||
// log.components.beachcomber (diary).
|
||||
var moduleLog = diary.For("beachcomber")
|
||||
|
||||
// DefaultDriver is the engine used when search.driver is empty.
|
||||
const DefaultDriver = NullEngine
|
||||
|
||||
@@ -130,10 +135,11 @@ func (s *Service) IndexName(m Searchable) string {
|
||||
return s.Prefix() + m.SearchableAs()
|
||||
}
|
||||
|
||||
// Logger returns the app logger sync failures are logged through.
|
||||
// Logger returns the app logger sync failures are logged through: the
|
||||
// logger the application published, else the beachcomber component logger.
|
||||
func (s *Service) Logger() *slog.Logger {
|
||||
if s == nil || s.log == nil {
|
||||
return slog.Default()
|
||||
return moduleLog
|
||||
}
|
||||
return s.log
|
||||
}
|
||||
@@ -179,5 +185,5 @@ func loggerFromApp(app *backpack.App) *slog.Logger {
|
||||
return log
|
||||
}
|
||||
}
|
||||
return slog.Default()
|
||||
return moduleLog
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ bonfire is the console layer of SummerCMS. Plugins and framework modules describ
|
||||
|
||||
## Role in the framework
|
||||
|
||||
A dependency-free base layer for console commands. `pact` uses it so plugins can contribute commands, and `lagoon`, `conga`, `cabana`, `lighthouse`, `flare` and `surf` build their own commands (migrate, workers, serve, admin tools) on it. Keeping it separate means every module describes commands the same way without depending on the CLI binary.
|
||||
A base layer for console commands; its only framework dependency is `sunscreen`, which scrubs credentials from error messages. `pact` uses it so plugins can contribute commands, and `lagoon`, `conga`, `cabana`, `lighthouse`, `flare` and `surf` build their own commands (migrate, workers, serve, admin tools) on it. Keeping it separate means every module describes commands the same way without depending on the CLI binary.
|
||||
|
||||
Use cases:
|
||||
|
||||
@@ -27,9 +27,27 @@ Use cases:
|
||||
- Usage strings and argument-count checks derived from the declared arguments (`<name>` for required, `[name]` for optional).
|
||||
- Scalar flags, bare flags (`bonfire.Flag.Bare`, so `--force` alone stores `true`) and ordered repeatable flags (`bonfire.Flag.Repeatable`, read back through `bonfire.Input.Flags`).
|
||||
- Styled status lines: `bonfire.Output.Info`, `bonfire.Output.Success`, `bonfire.Output.Warning` and `bonfire.Output.Error` (the last one writes to the error stream).
|
||||
- Widgets: box-drawn tables (`bonfire.Output.Table`), a spinner around a function (`bonfire.Output.Spinner`) and a progress bar (`bonfire.Output.Progress`); both fall back to plain lines when output is not a terminal.
|
||||
- Prompts: `bonfire.Output.Ask`, `bonfire.Output.Confirm`, `bonfire.Output.Choice` and `bonfire.Output.Secret`, which reads a hidden value on a terminal. Prompts return their defaults when input ends, and `bonfire.Output.Confirm` returns its default without asking when the session is not interactive.
|
||||
- Widgets on the charm stack, ported from SSU's terminal UI: box-drawn tables (`bonfire.Output.Table`, lipgloss), a spinner around a function (`bonfire.Output.Spinner`), a gradient progress bar (`bonfire.Output.Progress`, whose `bonfire.Progress` also implements `bonfire.ItemProgress`) and a multi-row process view for bulk loops (`bonfire.Process`), each a bubbletea program that keeps stdin and signals to the command. Log lines and output printed while a view runs appear above it (`bonfire.PrintAbove`).
|
||||
- Prompts: `bonfire.Output.Ask`, `bonfire.Output.Confirm` and `bonfire.Output.Secret` use huh fields on an interactive terminal, and `bonfire.Output.Choice` an arrow-key selector with a confirmation screen. Off a terminal they read lines from stdin as before (a piped password still works), return their defaults when input ends, and `bonfire.Output.Confirm` returns its default without asking. A cancelled prompt returns `bonfire.ErrAborted`. `bonfire.ConfirmProceed` asks before a destructive step only on an interactive terminal.
|
||||
- Degradation: every widget follows the terminal and the global options, and the `bonfire.Output` interface is unchanged for plugins:
|
||||
|
||||
| Run | Animation | Decoration | Data | Prompts |
|
||||
|-----|-----------|------------|------|---------|
|
||||
| Terminal | spinner, progress bar, process view | colors, symbols, boxed tables | printed | huh fields and the selector |
|
||||
| Not a terminal | none: `[...] message` and a line per tenth of progress | symbols, no colors unless `FORCE_COLOR` | printed, tables tab-separated | read a line from stdin; `Confirm` returns its default |
|
||||
| `-q` | none | info, success and warning lines hidden; errors kept | printed (`Println`, `Printf`, tables) | defaults, as with `-n` |
|
||||
| `--no-ansi` | none | symbols, no colors | printed | plain line prompts |
|
||||
| `-n` | none | as on the terminal | printed | defaults; `Secret` reads piped stdin or fails with a hint |
|
||||
- Command state for helpers: `bonfire.GlobalsOf`, `bonfire.VerbosityOf`, `bonfire.IsQuiet` and `bonfire.Interactive` read the running command's global options from its `bonfire.Output`.
|
||||
- Passthrough commands (`bonfire.Command.Passthrough`) skip flag parsing, so every argument, including `--help`, reaches `bonfire.Input.Args` unchanged; this suits commands that forward to another program. `bonfire.Command.Long` adds extended help text that the help command shows instead of the description.
|
||||
- Artisan-style command list: a bare binary (or `--help`, or `list`) prints a version header (`bonfire.Version.Header`, read from the build information by `bonfire.BuildVersion`), the usage line, the global options and the commands, top-level names first and then one block per namespace, with aligned descriptions.
|
||||
- Global options on every command: `-v`/`-vv`/`-vvv` (`--verbose`), `-q` (`--quiet`, implies `-n`), `--ansi`/`--no-ansi` (last wins), `-n` (`--no-interaction`) and `--env`. `bonfire.PeekGlobals` reads them from the raw arguments before the root exists, so the generated main can pick the configuration environment and the log verbosity before plugins boot; `bonfire.OnGlobals` hooks receive the parsed `bonfire.Globals` before every command runs (roots built by `bonfire.Call` run no hook).
|
||||
- Artisan-style help: `help <command>` and `<command> --help` print Description, Usage, Arguments, Options with `[default: ...]` and "(multiple values allowed)", the global options, Help (`bonfire.Command.Long`) and Examples (`bonfire.Command.Examples`, printed after `bin/<name>` or `summer`).
|
||||
- Hidden commands (`bonfire.Command.Hidden`) run but stay out of the list unless `list --all`.
|
||||
- JSON catalog: `list --format=json` prints every command, hidden ones flagged, with arguments, options, defaults and examples as a `bonfire.Listing` (`bonfire.Describe` builds it from a command slice).
|
||||
- Documentation coverage: `bonfire.CheckDocs` reports commands lacking a description, long help, examples, or argument and option descriptions.
|
||||
- Error block and exit codes: `bonfire.Execute` runs the root through fang and returns `bonfire.ExitOK`, `bonfire.ExitFailure`, or `bonfire.ExitUsage` for an unknown command or flag and a wrong number of arguments (`bonfire.UsageError`). Errors print on the error stream as a block (`bonfire.RenderError`): the ERROR badge and the message, the explanation and Try lines of `bonfire.WithHint` (`{bin}` becomes `bin/<name>` or `summer`), cobra's "did you mean" suggestions as Try lines, and "Run with -v for details." or, at `-v`, the wrapped error chain; every message passes through `sunscreen.Scrub`. Errors wrapped by `bonfire.Reported` print nothing. `bonfire.Fail` prints errors that happen before the root exists. For an application binary (any root other than `summer`) that fails, other than with a usage error, while the working directory has no `config/` directory and a parent directory holds `summer.yaml`, both add the hint "This command runs from the application root." with `cd <relative path>; bin/<name> <args>` (only the message changes).
|
||||
- Name checks at boot: two commands with the same name, or a command named `list` or `help`, make `bonfire.NewRoot` fail with `bonfire.ErrDuplicateCommand`; a command flag that reuses a global option's name or shorthand fails with `bonfire.ErrReservedFlag` instead of panicking.
|
||||
- Injectable streams (`bonfire.NewRootIO`, `bonfire.NewOutput`) so commands can be tested against buffers.
|
||||
- In-process calls: `bonfire.Call` runs a named command with arguments against any writer (Laravel `Artisan::call`), and `bonfire.Catalog` holds an application binary's final command list so code outside the Cobra root, such as the conga scheduler, can call any registered command.
|
||||
|
||||
@@ -67,13 +85,14 @@ func main() {
|
||||
},
|
||||
}
|
||||
|
||||
root, err := bonfire.NewRoot("acme", []bonfire.Command{importPosts}, os.Stdout)
|
||||
args := os.Args[1:]
|
||||
g := bonfire.PeekGlobals(args)
|
||||
root, err := bonfire.NewRoot("acme", []bonfire.Command{importPosts}, os.Stdout,
|
||||
bonfire.WithVersion(bonfire.BuildVersion()))
|
||||
if err != nil {
|
||||
os.Exit(1)
|
||||
}
|
||||
if err := root.Execute(); err != nil {
|
||||
os.Exit(1)
|
||||
os.Exit(bonfire.Fail(os.Stderr, "acme", err, g))
|
||||
}
|
||||
os.Exit(bonfire.Execute(context.Background(), root, args))
|
||||
}
|
||||
```
|
||||
|
||||
@@ -93,20 +112,68 @@ if catalog.Has("blog:import") {
|
||||
|
||||
| Identifier | Description |
|
||||
|------------|-------------|
|
||||
| `bonfire.Command` | A console command: name, description, optional extended help (`bonfire.Command.Long`), flags, arguments, the `bonfire.Command.Passthrough` switch that hands every argument to Run unparsed, and the `bonfire.Command.Run` function. |
|
||||
| `bonfire.Command` | A console command: name, description, optional extended help (`bonfire.Command.Long`), flags, arguments, the `bonfire.Command.Passthrough` switch that hands every argument to Run unparsed, the `bonfire.Command.Run` function, usage examples (`bonfire.Command.Examples`) and the `bonfire.Command.Hidden` switch. |
|
||||
| `bonfire.Example` | One usage example: the command line without the binary (`Command`) and a short `Description`. |
|
||||
| `bonfire.Flag` | A string flag; `bonfire.Flag.Bare` allows the flag without a value, `bonfire.Flag.Repeatable` makes it an ordered multi-value flag. |
|
||||
| `bonfire.Arg` | A positional argument with a name, description and required marker. |
|
||||
| `bonfire.Input` | Parsed view handed to `bonfire.Command.Run`: `bonfire.Input.Args`, `bonfire.Input.Argument`, `bonfire.Input.Flag` and `bonfire.Input.Flags`. |
|
||||
| `bonfire.Output` | Injected console: printing, status lines, tables, spinner, progress bar and prompts. |
|
||||
| `bonfire.Progress` | A progress bar advanced from inside `bonfire.Output.Progress`. |
|
||||
| `bonfire.NewRoot` | Builds the Cobra root command for a binary from a slice of commands, using the process stdin. |
|
||||
| `bonfire.NewRoot` | Builds the Cobra root command for a binary from a slice of commands, using the process stdin, with the global options, the `list` command and the command list on a bare call; takes `bonfire.RootOption` values. When the output is the process stdout, the error block goes to stderr. |
|
||||
| `bonfire.NewRootIO` | `bonfire.NewRoot` with injected stdin, stdout and stderr. |
|
||||
| `bonfire.RootOption` | An option of `bonfire.NewRoot` and `bonfire.NewRootIO`. |
|
||||
| `bonfire.WithVersion` | Sets the `bonfire.Version` shown in the list header; without it the root reads `bonfire.BuildVersion`. |
|
||||
| `bonfire.OnGlobals` | Registers a function that receives the parsed `bonfire.Globals` before every command of the root runs. |
|
||||
| `bonfire.Globals` | The global options: `Verbosity` (0 to 3), `Quiet`, `Color` (a `bonfire.ColorMode`), `NoInteraction` and `Env`. |
|
||||
| `bonfire.PeekGlobals` | Reads `bonfire.Globals` from raw arguments without failing; stops at `--`. |
|
||||
| `bonfire.Globals.Args` | Turns `bonfire.Globals` back into flags (`-vv`, `-q`, `-n`, `--ansi` or `--no-ansi`, `--env=<value>`) for forwarding. |
|
||||
| `bonfire.Version` | Application and framework versions of a binary (`App`, `Framework`, `Local`). |
|
||||
| `bonfire.BuildVersion` | Reads the `bonfire.Version` from the binary's build information. |
|
||||
| `bonfire.Version.Header` | The list header: `SummerCMS acme v1.4.0 (framework v0.3.2)`. |
|
||||
| `bonfire.Execute` | Runs a root with arguments through fang and returns the exit code; prints the list when no command is given. |
|
||||
| `bonfire.Fail` | Renders an error that happened before the root exists and returns `bonfire.ExitFailure`. |
|
||||
| `bonfire.ExitOK`, `bonfire.ExitFailure`, `bonfire.ExitUsage` | The exit codes 0, 1 and 2. |
|
||||
| `bonfire.RenderError` | Writes an error as the console error block. |
|
||||
| `bonfire.UsageError` | Marks a usage mistake (exit code 2); `bonfire.Usage` wraps an error in it. |
|
||||
| `bonfire.Usage` | Wraps an error as a `bonfire.UsageError`. |
|
||||
| `bonfire.HintError` | An error with an `Explanation` and a `Try` text that the error block prints. |
|
||||
| `bonfire.WithHint` | Wraps an error in a `bonfire.HintError`; `{bin}` in the Try text becomes `bin/<name>` or `summer`. |
|
||||
| `bonfire.ErrReported` | Marks an error whose details the command already printed. |
|
||||
| `bonfire.Reported` | Wraps an error so it matches `bonfire.ErrReported`: exit code 1, no error block. |
|
||||
| `bonfire.Listing` | The JSON catalog of `list --format=json`: `Application` (`bonfire.ApplicationInfo`), `GlobalOptions` and `Commands`. |
|
||||
| `bonfire.ApplicationInfo` | Name, version and framework version of a binary in a `bonfire.Listing`. |
|
||||
| `bonfire.CommandInfo` | One command of a `bonfire.Listing`: name, description, long help, usage, hidden and passthrough switches, `Arguments`, `Options` and `Examples`. |
|
||||
| `bonfire.ArgumentInfo` | One argument of a `bonfire.CommandInfo`. |
|
||||
| `bonfire.OptionInfo` | One flag of a `bonfire.CommandInfo`, or a global option, with its default. |
|
||||
| `bonfire.ExampleInfo` | One example of a `bonfire.CommandInfo`. |
|
||||
| `bonfire.Describe` | Builds the `bonfire.Listing` of a binary from its commands and `bonfire.Version`. |
|
||||
| `bonfire.CheckDocs` | Reports commands lacking a description, long help, examples or argument and option descriptions, as `bonfire.DocProblem` values. |
|
||||
| `bonfire.DocProblem` | One command's documentation gaps; `bonfire.DocProblem.String` reads "<command>: missing description, examples". |
|
||||
| `bonfire.ItemProgress` | The optional side of a `bonfire.Progress` that names the current item (`Current`) and records failures (`Fail`); `bonfire.Output.Progress` hands one over. |
|
||||
| `bonfire.Current` | Names the current item on a `bonfire.Progress` that is an `bonfire.ItemProgress`. |
|
||||
| `bonfire.Failed` | Records a failed item on a `bonfire.Progress` that is an `bonfire.ItemProgress`. |
|
||||
| `bonfire.Process` | Runs a function per item with the process view on a terminal, or one `✓`/`✗` line per item; returns the joined failures. |
|
||||
| `bonfire.PrintAbove` | Prints a line above the running live view and reports whether one was running. |
|
||||
| `bonfire.GlobalsOf` | The `bonfire.Globals` of the command an Output belongs to (zero for other Output implementations). |
|
||||
| `bonfire.VerbosityOf` | The `-v` level of the command an Output belongs to. |
|
||||
| `bonfire.IsQuiet` | Whether the command runs with `-q`. |
|
||||
| `bonfire.IsTerminal` | Whether the Output writes to a terminal; long-running commands print a summary block for a person there and log records otherwise. |
|
||||
| `bonfire.Details`, `bonfire.Detail` | Prints a title and aligned label/value lines in the palette (a `Warn` value in the warning color); nothing under `-q`. Used for startup summaries and shutdown reports. |
|
||||
| `bonfire.Interactive` | Whether the Output can ask a person: stdin and stdout are terminals and neither `-n` nor `-q` is set. |
|
||||
| `bonfire.ConfirmProceed` | Asks before a destructive step on an interactive terminal; returns true without asking otherwise. |
|
||||
| `bonfire.ErrAborted` | Returned by a prompt the person cancelled. |
|
||||
| `bonfire.ErrDuplicateCommand` | Returned by `bonfire.NewRoot` when two commands share a name or a command is named `list` or `help`. |
|
||||
| `bonfire.ErrReservedFlag` | Returned by `bonfire.NewRoot` when a command flag reuses a global option's name or shorthand. |
|
||||
| `bonfire.NewOutput` | Builds a `bonfire.Output` over the given streams, applying the terminal and color policy. |
|
||||
| `bonfire.ErrCommandName` | Returned when a plugin command name is not in `namespace:verb` form. |
|
||||
| `bonfire.Call` | Runs one command of a slice by exact name with arguments, writing output to a writer; stdin is empty. |
|
||||
| `bonfire.ErrUnknownCommand` | Returned by `bonfire.Call` when no command has the name. |
|
||||
| `bonfire.Catalog` | An immutable copy of a binary's command list; the generated app main publishes one on the app. |
|
||||
| `bonfire.NewCatalog` | Builds a `bonfire.Catalog` from a command slice. |
|
||||
| `bonfire.ColorMode` | How console output decides on color: `bonfire.ColorAuto` (terminal and environment), `bonfire.ColorAlways` or `bonfire.ColorNever`. |
|
||||
| `bonfire.Palette` | The console styles (`Success`, `Current`, `Warning`, `Error`, `ErrorBold`, `Muted`, `Header`), shared with the `diary` console log lines. |
|
||||
| `bonfire.DefaultPalette` | Returns the palette ported from SSU's status styles: green success, cyan current, yellow warning, red and bold red errors, gray muted text, bold headers. |
|
||||
| `bonfire.DetectProfile` | Returns the color profile for a writer under a `bonfire.ColorMode`, honouring `NO_COLOR`, `TERM=dumb` and `FORCE_COLOR`; render palette styles through a `colorprofile.Writer` with it. |
|
||||
|
||||
## Configuration
|
||||
|
||||
@@ -118,13 +185,13 @@ bonfire reads no config keys. Output color follows these environment variables:
|
||||
| `TERM` | The value `dumb` disables color. |
|
||||
| `FORCE_COLOR` | Any non-empty value enables color even when output is not a terminal (ignored when color is disabled by `NO_COLOR` or `TERM`). |
|
||||
|
||||
Without these variables, color is enabled only when stdout is a terminal.
|
||||
Without these variables, color is enabled only when stdout is a terminal. `--ansi` forces color and `--no-ansi` turns it off whatever the environment says.
|
||||
|
||||
## Dependencies
|
||||
|
||||
- SummerCMS modules: none.
|
||||
- Third-party: `github.com/spf13/cobra`, `golang.org/x/term`.
|
||||
- Standard library: `bufio`, `context`, `errors`, `fmt`, `io`, `os`, `strconv`, `strings`, `sync`, `time`, `unicode/utf8`.
|
||||
- SummerCMS modules: `sunscreen` (scrubs error messages).
|
||||
- Third-party: `github.com/spf13/cobra`, `github.com/spf13/pflag`, `golang.org/x/term`, `charm.land/lipgloss/v2` (with `lipgloss/v2/table`), `charm.land/bubbletea/v2`, `charm.land/bubbles/v2` (spinner, progress, key), `charm.land/huh/v2`, `charm.land/fang/v2` and `github.com/charmbracelet/colorprofile` (decided in `.planning/notes/2026-10-21-console-charm-stack.md`).
|
||||
- Standard library: `bufio`, `context`, `encoding/json`, `errors`, `fmt`, `io`, `os`, `path/filepath`, `runtime/debug`, `slices`, `strconv`, `strings`, `sync`, `time`, `unicode/utf8`.
|
||||
|
||||
## Testing
|
||||
|
||||
|
||||
@@ -15,8 +15,8 @@ var ErrCommandName = errors.New("bonfire: plugin command name must be namespace:
|
||||
type Command struct {
|
||||
Name string
|
||||
Description string
|
||||
// Long is optional extended help that cobra shows for the help command
|
||||
// (help <command>). When empty, cobra falls back to Description.
|
||||
// Long is optional extended help, shown under "Help:" by help
|
||||
// <command> and <command> --help.
|
||||
Long string
|
||||
// Passthrough turns off flag parsing for the command: every argument
|
||||
// after the command name, including --help, -h and --, reaches Run
|
||||
@@ -29,6 +29,22 @@ type Command struct {
|
||||
Flags []Flag
|
||||
Args []Arg
|
||||
Run func(ctx context.Context, in Input, out Output) error
|
||||
// Examples are shown under "Examples:" in the command's help and in
|
||||
// the JSON listing, each as a "#" comment and the command line.
|
||||
Examples []Example
|
||||
// Hidden keeps a runnable command out of the list; list --all and the
|
||||
// JSON listing still show it.
|
||||
Hidden bool
|
||||
}
|
||||
|
||||
// Example is one usage example of a command.
|
||||
type Example struct {
|
||||
// Command is the command line without the binary, for example
|
||||
// "admin:token:create jane --json"; help prints it after summer or
|
||||
// bin/<name>.
|
||||
Command string
|
||||
// Description is a short sentence shown as a "#" comment above it.
|
||||
Description string
|
||||
}
|
||||
|
||||
// Flag describes a string flag passed to a command.
|
||||
|
||||
55
modules/bonfire/docs.go
Normal file
55
modules/bonfire/docs.go
Normal file
@@ -0,0 +1,55 @@
|
||||
package bonfire
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// DocProblem lists what one command's documentation lacks.
|
||||
type DocProblem struct {
|
||||
// Command is the command name.
|
||||
Command string
|
||||
// Missing names each gap: "description", "long help", "examples",
|
||||
// "argument <name> description" or "option --<name> description".
|
||||
Missing []string
|
||||
}
|
||||
|
||||
// String formats the problem as "<command>: missing description, examples".
|
||||
func (p DocProblem) String() string {
|
||||
return p.Command + ": missing " + strings.Join(p.Missing, ", ")
|
||||
}
|
||||
|
||||
// CheckDocs reports every command that lacks a description, long help, at
|
||||
// least one example, or a description of one of its arguments or options,
|
||||
// sorted by command name. It only reports: the framework's own test fails
|
||||
// on a problem, plugin repositories log them.
|
||||
func CheckDocs(commands []Command) []DocProblem {
|
||||
var problems []DocProblem
|
||||
for _, c := range commands {
|
||||
var missing []string
|
||||
if strings.TrimSpace(c.Description) == "" {
|
||||
missing = append(missing, "description")
|
||||
}
|
||||
if strings.TrimSpace(c.Long) == "" {
|
||||
missing = append(missing, "long help")
|
||||
}
|
||||
if len(c.Examples) == 0 {
|
||||
missing = append(missing, "examples")
|
||||
}
|
||||
for _, a := range c.Args {
|
||||
if strings.TrimSpace(a.Description) == "" {
|
||||
missing = append(missing, "argument "+a.Name+" description")
|
||||
}
|
||||
}
|
||||
for _, f := range c.Flags {
|
||||
if strings.TrimSpace(f.Description) == "" {
|
||||
missing = append(missing, "option --"+f.Name+" description")
|
||||
}
|
||||
}
|
||||
if len(missing) > 0 {
|
||||
problems = append(problems, DocProblem{Command: c.Name, Missing: missing})
|
||||
}
|
||||
}
|
||||
slices.SortStableFunc(problems, func(a, b DocProblem) int { return strings.Compare(a.Command, b.Command) })
|
||||
return problems
|
||||
}
|
||||
283
modules/bonfire/errors.go
Normal file
283
modules/bonfire/errors.go
Normal file
@@ -0,0 +1,283 @@
|
||||
package bonfire
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/sunscreen"
|
||||
"github.com/charmbracelet/colorprofile"
|
||||
)
|
||||
|
||||
// UsageError marks a mistake in how a command was called: an unknown
|
||||
// command or flag, or a wrong number of arguments. Execute returns ExitUsage
|
||||
// for it.
|
||||
type UsageError struct {
|
||||
Err error
|
||||
// command is the command that was called wrongly, when known; the
|
||||
// error block suggests its help.
|
||||
command string
|
||||
}
|
||||
|
||||
func (e *UsageError) Error() string { return e.Err.Error() }
|
||||
|
||||
func (e *UsageError) Unwrap() error { return e.Err }
|
||||
|
||||
// Usage wraps err as a UsageError; nil stays nil.
|
||||
func Usage(err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
var ue *UsageError
|
||||
if errors.As(err, &ue) {
|
||||
return err
|
||||
}
|
||||
return &UsageError{Err: err}
|
||||
}
|
||||
|
||||
// isUsage reports a UsageError in the chain or cobra's unknown command error.
|
||||
func isUsage(err error) bool {
|
||||
var ue *UsageError
|
||||
if errors.As(err, &ue) {
|
||||
return true
|
||||
}
|
||||
return strings.HasPrefix(err.Error(), "unknown command")
|
||||
}
|
||||
|
||||
// HintError adds an explanation and a command to try to an error. The error
|
||||
// block prints both under the error message; {bin} in Try becomes summer or
|
||||
// bin/<name>.
|
||||
type HintError struct {
|
||||
Err error
|
||||
Explanation string
|
||||
Try string
|
||||
}
|
||||
|
||||
func (e *HintError) Error() string { return e.Err.Error() }
|
||||
|
||||
func (e *HintError) Unwrap() error { return e.Err }
|
||||
|
||||
// WithHint returns err with an explanation and a Try line (several lines are
|
||||
// allowed); "{bin}" in try is replaced by how the binary is called. A nil err
|
||||
// stays nil.
|
||||
func WithHint(err error, explanation, try string) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
return &HintError{Err: err, Explanation: explanation, Try: try}
|
||||
}
|
||||
|
||||
// ErrReported marks an error whose details the command already printed:
|
||||
// the error block prints nothing and the exit code is 1.
|
||||
var ErrReported = errors.New("bonfire: error already reported")
|
||||
|
||||
type reportedError struct {
|
||||
err error
|
||||
}
|
||||
|
||||
func (e *reportedError) Error() string { return e.err.Error() }
|
||||
|
||||
func (e *reportedError) Unwrap() error { return e.err }
|
||||
|
||||
func (e *reportedError) Is(target error) bool { return target == ErrReported }
|
||||
|
||||
// Reported returns an error that matches both ErrReported and err, for a
|
||||
// command that has already printed its problems. A nil err gives
|
||||
// ErrReported.
|
||||
func Reported(err error) error {
|
||||
if err == nil {
|
||||
return ErrReported
|
||||
}
|
||||
return &reportedError{err: err}
|
||||
}
|
||||
|
||||
// RenderError writes err to w as the console error block: the ERROR badge
|
||||
// and the message, the explanation and the Try lines of a HintError ({bin}
|
||||
// replaced by summer or bin/<name>, for the root named name), then "Run with
|
||||
// -v for details." at verbosity 0 or the wrapped error chain under "Caused
|
||||
// by:" at -v and above. cobra's unknown command error becomes `Command "x"
|
||||
// is not defined.` with its suggestions as Try lines, and other usage errors
|
||||
// suggest the command's help. Every message passes through sunscreen.Scrub.
|
||||
// Errors wrapped by Reported print nothing.
|
||||
func RenderError(w io.Writer, name string, err error, g Globals) {
|
||||
if err == nil || w == nil || errors.Is(err, ErrReported) {
|
||||
return
|
||||
}
|
||||
if cw, ok := w.(*colorprofile.Writer); ok {
|
||||
w = cw.Forward
|
||||
}
|
||||
bin := usageName(name)
|
||||
p := DefaultPalette()
|
||||
message := err.Error()
|
||||
explanation, try := "", ""
|
||||
var hint *HintError
|
||||
if errors.As(err, &hint) {
|
||||
explanation, try = hint.Explanation, hint.Try
|
||||
}
|
||||
if strings.HasPrefix(message, "unknown command") {
|
||||
cmdName, suggestions := parseUnknownCommand(message)
|
||||
message = `Command "` + cmdName + `" is not defined.`
|
||||
if try == "" {
|
||||
var lines []string
|
||||
for _, s := range suggestions {
|
||||
lines = append(lines, "{bin} "+s)
|
||||
}
|
||||
if len(lines) == 0 {
|
||||
lines = []string{"{bin} list"}
|
||||
}
|
||||
try = strings.Join(lines, "\n")
|
||||
}
|
||||
} else if try == "" {
|
||||
var ue *UsageError
|
||||
if errors.As(err, &ue) {
|
||||
if ue.command != "" {
|
||||
try = "{bin} help " + ue.command
|
||||
} else {
|
||||
try = "{bin} list"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString("\n")
|
||||
for i, line := range strings.Split(sunscreen.Scrub(strings.TrimRight(message, "\n")), "\n") {
|
||||
if i == 0 {
|
||||
b.WriteString(" " + p.ErrorBold.Reverse(true).Render(" ERROR ") + " " + line + "\n")
|
||||
continue
|
||||
}
|
||||
b.WriteString(" " + line + "\n")
|
||||
}
|
||||
if explanation != "" {
|
||||
b.WriteString("\n")
|
||||
for _, line := range strings.Split(sunscreen.Scrub(explanation), "\n") {
|
||||
b.WriteString(" " + line + "\n")
|
||||
}
|
||||
}
|
||||
if try != "" {
|
||||
b.WriteString("\n " + p.Header.Render("Try:") + "\n")
|
||||
for _, line := range strings.Split(strings.ReplaceAll(try, "{bin}", bin), "\n") {
|
||||
b.WriteString(" " + p.Success.Render(sunscreen.Scrub(line)) + "\n")
|
||||
}
|
||||
}
|
||||
if g.Verbosity > 0 {
|
||||
if chain := causes(err, err.Error()); len(chain) > 0 {
|
||||
b.WriteString("\n " + p.Header.Render("Caused by:") + "\n")
|
||||
for _, c := range chain {
|
||||
b.WriteString(" " + p.Muted.Render("- "+sunscreen.Scrub(c)) + "\n")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
b.WriteString("\n " + p.Muted.Render("Run with -v for details.") + "\n")
|
||||
}
|
||||
b.WriteString("\n")
|
||||
_ = writeStyled(w, g, b.String())
|
||||
}
|
||||
|
||||
// wrongDirectoryExplanation is the hint added when an application binary
|
||||
// fails outside its application root.
|
||||
const wrongDirectoryExplanation = "This command runs from the application root."
|
||||
|
||||
// wrongDirectory adds a hint to err when an application binary (any root
|
||||
// name other than summer) failed while the working directory dir ("" means
|
||||
// os.Getwd) has no config directory and a parent directory holds
|
||||
// summer.yaml: compass then found no configuration, which is why the command
|
||||
// failed (D-19). The Try line is "cd <relative path>; bin/<name> <args>".
|
||||
// Usage errors, Reported errors and summer are left alone; nothing else
|
||||
// about the failure changes.
|
||||
func wrongDirectory(name string, err error, args []string, dir string) error {
|
||||
if err == nil || name == "summer" || errors.Is(err, ErrReported) || isUsage(err) {
|
||||
return err
|
||||
}
|
||||
if dir == "" {
|
||||
wd, werr := os.Getwd()
|
||||
if werr != nil {
|
||||
return err
|
||||
}
|
||||
dir = wd
|
||||
}
|
||||
if info, serr := os.Stat(filepath.Join(dir, "config")); serr == nil && info.IsDir() {
|
||||
return err
|
||||
}
|
||||
root := ""
|
||||
for d := filepath.Dir(dir); ; d = filepath.Dir(d) {
|
||||
if _, serr := os.Stat(filepath.Join(d, "summer.yaml")); serr == nil {
|
||||
root = d
|
||||
break
|
||||
}
|
||||
if filepath.Dir(d) == d {
|
||||
break
|
||||
}
|
||||
}
|
||||
if root == "" {
|
||||
return err
|
||||
}
|
||||
rel, rerr := filepath.Rel(dir, root)
|
||||
if rerr != nil {
|
||||
return err
|
||||
}
|
||||
try := "cd " + shellQuote(rel) + "; {bin}"
|
||||
for _, a := range args {
|
||||
try += " " + shellQuote(a)
|
||||
}
|
||||
return &HintError{Err: err, Explanation: wrongDirectoryExplanation, Try: try}
|
||||
}
|
||||
|
||||
// shellQuote single-quotes s when a shell would split or expand it.
|
||||
func shellQuote(s string) string {
|
||||
if s != "" && !strings.ContainsAny(s, " \t\n'\"\\$`*?[]{}()<>|&;#~!") {
|
||||
return s
|
||||
}
|
||||
return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"
|
||||
}
|
||||
|
||||
// parseUnknownCommand reads the command name and cobra's "Did you mean
|
||||
// this?" suggestions from an unknown command error.
|
||||
func parseUnknownCommand(msg string) (string, []string) {
|
||||
name := ""
|
||||
if i := strings.Index(msg, `"`); i >= 0 {
|
||||
if j := strings.Index(msg[i+1:], `"`); j >= 0 {
|
||||
name = msg[i+1 : i+1+j]
|
||||
}
|
||||
}
|
||||
var suggestions []string
|
||||
if _, rest, ok := strings.Cut(msg, "Did you mean this?"); ok {
|
||||
for _, line := range strings.Split(rest, "\n") {
|
||||
if s := strings.TrimSpace(line); s != "" {
|
||||
suggestions = append(suggestions, s)
|
||||
}
|
||||
}
|
||||
}
|
||||
return name, suggestions
|
||||
}
|
||||
|
||||
// causes walks the wrapped errors below err (errors.Join members expanded)
|
||||
// and returns each distinct message other than top.
|
||||
func causes(err error, top string) []string {
|
||||
var out []string
|
||||
seen := map[string]bool{top: true}
|
||||
var walk func(error)
|
||||
walk = func(e error) {
|
||||
for e != nil {
|
||||
if msg := e.Error(); !seen[msg] {
|
||||
seen[msg] = true
|
||||
out = append(out, msg)
|
||||
}
|
||||
if joined, ok := e.(interface{ Unwrap() []error }); ok {
|
||||
for _, m := range joined.Unwrap() {
|
||||
walk(m)
|
||||
}
|
||||
return
|
||||
}
|
||||
e = errors.Unwrap(e)
|
||||
}
|
||||
}
|
||||
walk(errors.Unwrap(err))
|
||||
if joined, ok := err.(interface{ Unwrap() []error }); ok {
|
||||
for _, m := range joined.Unwrap() {
|
||||
walk(m)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/bonfire"
|
||||
)
|
||||
@@ -51,3 +52,111 @@ func ExampleCatalog() {
|
||||
}
|
||||
// Output: publish hello-world, tags [news go], dry run true
|
||||
}
|
||||
|
||||
func ExampleExample() {
|
||||
export := bonfire.Command{
|
||||
Name: "blog:export",
|
||||
Description: "Export published posts as JSON",
|
||||
Long: "Writes every published post, newest first, to a JSON file.",
|
||||
Flags: []bonfire.Flag{
|
||||
{Name: "path", Description: "Output file", Default: "posts.json"},
|
||||
},
|
||||
Examples: []bonfire.Example{
|
||||
{Command: "blog:export --path=backup.json", Description: "Export to another file"},
|
||||
},
|
||||
Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error {
|
||||
return nil
|
||||
},
|
||||
}
|
||||
// Hidden commands run, but list shows them only with --all.
|
||||
cleanup := bonfire.Command{
|
||||
Name: "blog:cleanup",
|
||||
Description: "Remove orphaned uploads",
|
||||
Hidden: true,
|
||||
}
|
||||
|
||||
root, err := bonfire.NewRootIO("acme", []bonfire.Command{export, cleanup}, strings.NewReader(""), os.Stdout, os.Stdout)
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
bonfire.Execute(context.Background(), root, []string{"help", "blog:export"})
|
||||
// Output:
|
||||
// Description:
|
||||
// Export published posts as JSON
|
||||
//
|
||||
// Usage:
|
||||
// blog:export [options]
|
||||
//
|
||||
// Options:
|
||||
// --path=PATH Output file [default: "posts.json"]
|
||||
//
|
||||
// Global options:
|
||||
// -h, --help Display help for the given command
|
||||
// -q, --quiet Do not output any message
|
||||
// -v|vv|vvv Increase verbosity
|
||||
// --ansi|no-ansi Force (or disable) ANSI output
|
||||
// -n, --no-interaction Do not ask any interactive question
|
||||
// --env The environment the command runs under
|
||||
//
|
||||
// Help:
|
||||
// Writes every published post, newest first, to a JSON file.
|
||||
//
|
||||
// Examples:
|
||||
// # Export to another file
|
||||
// bin/acme blog:export --path=backup.json
|
||||
}
|
||||
|
||||
func ExampleWithHint() {
|
||||
publish := bonfire.Command{
|
||||
Name: "blog:publish",
|
||||
Description: "Publish a draft post",
|
||||
Args: []bonfire.Arg{{Name: "slug", Description: "Post slug", Required: true}},
|
||||
Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error {
|
||||
slug, _ := in.Argument("slug")
|
||||
return bonfire.WithHint(fmt.Errorf("blog: no draft named %q", slug),
|
||||
"Only drafts can be published; published posts keep their slug.",
|
||||
"{bin} blog:drafts")
|
||||
},
|
||||
}
|
||||
|
||||
root, err := bonfire.NewRootIO("acme", []bonfire.Command{publish}, strings.NewReader(""), os.Stdout, os.Stdout)
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
code := bonfire.Execute(context.Background(), root, []string{"blog:publish", "hello-world"})
|
||||
fmt.Println("exit code:", code)
|
||||
// Output:
|
||||
// ERROR blog: no draft named "hello-world"
|
||||
//
|
||||
// Only drafts can be published; published posts keep their slug.
|
||||
//
|
||||
// Try:
|
||||
// bin/acme blog:drafts
|
||||
//
|
||||
// Run with -v for details.
|
||||
//
|
||||
// exit code: 1
|
||||
}
|
||||
|
||||
func ExampleCheckDocs() {
|
||||
commands := []bonfire.Command{
|
||||
{
|
||||
Name: "blog:publish",
|
||||
Description: "Publish a draft post",
|
||||
Long: "Publishes the draft with the given slug and notifies subscribers.",
|
||||
Args: []bonfire.Arg{{Name: "slug", Description: "Post slug", Required: true}},
|
||||
Examples: []bonfire.Example{{Command: "blog:publish hello-world"}},
|
||||
},
|
||||
{
|
||||
Name: "blog:reindex",
|
||||
Description: "Rebuild the search index",
|
||||
Flags: []bonfire.Flag{{Name: "batch", Default: "500"}},
|
||||
},
|
||||
}
|
||||
for _, problem := range bonfire.CheckDocs(commands) {
|
||||
fmt.Println(problem)
|
||||
}
|
||||
// Output: blog:reindex: missing long help, examples, option --batch description
|
||||
}
|
||||
|
||||
102
modules/bonfire/execute.go
Normal file
102
modules/bonfire/execute.go
Normal file
@@ -0,0 +1,102 @@
|
||||
package bonfire
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"os"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"charm.land/fang/v2"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// Exit codes returned by Execute and Fail.
|
||||
const (
|
||||
// ExitOK means the command succeeded.
|
||||
ExitOK = 0
|
||||
// ExitFailure means the command failed.
|
||||
ExitFailure = 1
|
||||
// ExitUsage means the command was called wrongly: an unknown command or
|
||||
// flag, or a wrong number of arguments.
|
||||
ExitUsage = 2
|
||||
)
|
||||
|
||||
// Execute runs root with args and returns the process exit code: ExitOK,
|
||||
// ExitFailure, or ExitUsage for usage errors. With no command word in args
|
||||
// (only global options, -h or --help) it prints the command list. Errors are
|
||||
// rendered on the root's error stream by RenderError through fang's error
|
||||
// handler; fang is used without its version flag, man page and completion
|
||||
// commands.
|
||||
func Execute(ctx context.Context, root *cobra.Command, args []string) int {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
if args == nil {
|
||||
// cobra reads os.Args when the args are nil.
|
||||
args = []string{}
|
||||
}
|
||||
g := PeekGlobals(args)
|
||||
if !hasCommandWord(args) {
|
||||
args = slices.DeleteFunc(slices.Clone(args), func(a string) bool {
|
||||
return a == "-h" || a == "--help"
|
||||
})
|
||||
}
|
||||
resetFlags(root)
|
||||
root.SetArgs(args)
|
||||
name := root.Name()
|
||||
err := fang.Execute(ctx, root,
|
||||
fang.WithoutVersion(),
|
||||
fang.WithoutManpage(),
|
||||
fang.WithoutCompletions(),
|
||||
fang.WithErrorHandler(func(w io.Writer, _ fang.Styles, err error) {
|
||||
// fang hands over a colorprofile writer built from the
|
||||
// environment only; RenderError unwraps it and applies
|
||||
// --ansi and --no-ansi itself.
|
||||
RenderError(w, name, wrongDirectory(name, err, args, ""), g)
|
||||
}),
|
||||
)
|
||||
return exitCode(err)
|
||||
}
|
||||
|
||||
// Fail renders err like Execute does and returns ExitFailure (ExitOK for a
|
||||
// nil error). The generated main uses it for errors that happen before the
|
||||
// command root exists, such as a configuration that does not load. Like
|
||||
// Execute, it adds the wrong-directory hint for an application binary run
|
||||
// outside its application root.
|
||||
func Fail(w io.Writer, name string, err error, g Globals) int {
|
||||
if err == nil {
|
||||
return ExitOK
|
||||
}
|
||||
RenderError(w, name, wrongDirectory(name, err, os.Args[1:], ""), g)
|
||||
return ExitFailure
|
||||
}
|
||||
|
||||
func exitCode(err error) int {
|
||||
switch {
|
||||
case err == nil:
|
||||
return ExitOK
|
||||
case isUsage(err):
|
||||
return ExitUsage
|
||||
default:
|
||||
return ExitFailure
|
||||
}
|
||||
}
|
||||
|
||||
// hasCommandWord reports whether args, before "--", hold a token that is
|
||||
// neither a flag nor the value of --env.
|
||||
func hasCommandWord(args []string) bool {
|
||||
for i := 0; i < len(args); i++ {
|
||||
a := args[i]
|
||||
switch {
|
||||
case a == "--":
|
||||
return false
|
||||
case a == "--"+flagEnv:
|
||||
i++
|
||||
case strings.HasPrefix(a, "-"):
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
206
modules/bonfire/globals.go
Normal file
206
modules/bonfire/globals.go
Normal file
@@ -0,0 +1,206 @@
|
||||
package bonfire
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/pflag"
|
||||
)
|
||||
|
||||
// maxVerbosity caps -v, -vv and -vvv (and repeated --verbose).
|
||||
const maxVerbosity = 3
|
||||
|
||||
// Globals are the global console options every command accepts: -v, -vv and
|
||||
// -vvv (--verbose), -q (--quiet), --ansi and --no-ansi, -n
|
||||
// (--no-interaction) and --env.
|
||||
type Globals struct {
|
||||
// Verbosity is 0 by default and 1 to 3 for -v, -vv and -vvv.
|
||||
Verbosity int
|
||||
// Quiet suppresses decoration and info lines; it implies NoInteraction.
|
||||
Quiet bool
|
||||
// Color is ColorAlways for --ansi, ColorNever for --no-ansi and
|
||||
// ColorAuto otherwise; the last of the two flags wins.
|
||||
Color ColorMode
|
||||
// NoInteraction makes prompts return their defaults without asking.
|
||||
NoInteraction bool
|
||||
// Env is the --env value: the environment the command runs under.
|
||||
Env string
|
||||
}
|
||||
|
||||
// Global option names and shorthands, reserved on every command.
|
||||
const (
|
||||
flagVerbose = "verbose"
|
||||
flagQuiet = "quiet"
|
||||
flagANSI = "ansi"
|
||||
flagNoANSI = "no-ansi"
|
||||
flagNoInteraction = "no-interaction"
|
||||
flagEnv = "env"
|
||||
flagHelp = "help"
|
||||
)
|
||||
|
||||
var (
|
||||
reservedFlagNames = []string{flagVerbose, flagQuiet, flagANSI, flagNoANSI, flagNoInteraction, flagEnv, flagHelp}
|
||||
reservedFlagShorthands = []string{"v", "q", "n", "h"}
|
||||
)
|
||||
|
||||
// PeekGlobals reads the global options from args before the command root
|
||||
// exists, so the generated main can pick the configuration environment and
|
||||
// the log verbosity before config loads and plugins boot. The scan is
|
||||
// tolerant: it never fails, ignores every other token and stops at "--".
|
||||
// Quiet also sets NoInteraction.
|
||||
func PeekGlobals(args []string) Globals {
|
||||
var g Globals
|
||||
for i := 0; i < len(args); i++ {
|
||||
a := args[i]
|
||||
switch {
|
||||
case a == "--":
|
||||
return g.normalize()
|
||||
case a == "--"+flagVerbose:
|
||||
g.Verbosity++
|
||||
case a == "-q" || a == "--"+flagQuiet:
|
||||
g.Quiet = true
|
||||
case a == "-n" || a == "--"+flagNoInteraction:
|
||||
g.NoInteraction = true
|
||||
case a == "--"+flagANSI:
|
||||
g.Color = ColorAlways
|
||||
case a == "--"+flagNoANSI:
|
||||
g.Color = ColorNever
|
||||
case a == "--"+flagEnv:
|
||||
if i+1 < len(args) {
|
||||
g.Env = args[i+1]
|
||||
i++
|
||||
}
|
||||
case strings.HasPrefix(a, "--"+flagEnv+"="):
|
||||
g.Env = strings.TrimPrefix(a, "--"+flagEnv+"=")
|
||||
case isVerboseShorthand(a):
|
||||
g.Verbosity += len(a) - 1
|
||||
}
|
||||
}
|
||||
return g.normalize()
|
||||
}
|
||||
|
||||
func isVerboseShorthand(a string) bool {
|
||||
if len(a) < 2 || a[0] != '-' || a[1] == '-' {
|
||||
return false
|
||||
}
|
||||
return strings.Trim(a[1:], "v") == ""
|
||||
}
|
||||
|
||||
func (g Globals) normalize() Globals {
|
||||
g.Verbosity = min(max(g.Verbosity, 0), maxVerbosity)
|
||||
if g.Quiet {
|
||||
g.NoInteraction = true
|
||||
}
|
||||
return g
|
||||
}
|
||||
|
||||
// Args re-serialises the globals as command-line flags, for forwarding to
|
||||
// another program: -v, -vv or -vvv, -q, -n, --ansi or --no-ansi and
|
||||
// --env=<value>. The zero value gives no arguments.
|
||||
func (g Globals) Args() []string {
|
||||
var out []string
|
||||
if v := min(g.Verbosity, maxVerbosity); v > 0 {
|
||||
out = append(out, "-"+strings.Repeat("v", v))
|
||||
}
|
||||
if g.Quiet {
|
||||
out = append(out, "-q")
|
||||
}
|
||||
if g.NoInteraction && !g.Quiet {
|
||||
out = append(out, "-n")
|
||||
}
|
||||
switch g.Color {
|
||||
case ColorAlways:
|
||||
out = append(out, "--"+flagANSI)
|
||||
case ColorNever:
|
||||
out = append(out, "--"+flagNoANSI)
|
||||
}
|
||||
if g.Env != "" {
|
||||
out = append(out, "--"+flagEnv+"="+g.Env)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// globalFlags holds the parsed persistent flags of one root.
|
||||
type globalFlags struct {
|
||||
verbosity int
|
||||
quiet bool
|
||||
color ColorMode
|
||||
noInteraction bool
|
||||
env string
|
||||
}
|
||||
|
||||
// colorFlag is --ansi or --no-ansi: both write one shared ColorMode, so the
|
||||
// last flag on the command line wins.
|
||||
type colorFlag struct {
|
||||
mode *ColorMode
|
||||
set ColorMode
|
||||
}
|
||||
|
||||
func (f colorFlag) String() string {
|
||||
return strconv.FormatBool(*f.mode == f.set)
|
||||
}
|
||||
|
||||
func (f colorFlag) Set(s string) error {
|
||||
on, err := strconv.ParseBool(s)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
switch {
|
||||
case on:
|
||||
*f.mode = f.set
|
||||
case *f.mode == f.set:
|
||||
*f.mode = ColorAuto
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f colorFlag) Type() string { return "bool" }
|
||||
|
||||
// register adds the global options to the root's persistent flags.
|
||||
func (gf *globalFlags) register(fs *pflag.FlagSet) {
|
||||
fs.CountVarP(&gf.verbosity, flagVerbose, "v", "Increase verbosity (-v, -vv or -vvv)")
|
||||
fs.BoolVarP(&gf.quiet, flagQuiet, "q", false, "Do not output any message")
|
||||
fs.Var(colorFlag{mode: &gf.color, set: ColorAlways}, flagANSI, "Force ANSI output")
|
||||
fs.Lookup(flagANSI).NoOptDefVal = "true"
|
||||
fs.Var(colorFlag{mode: &gf.color, set: ColorNever}, flagNoANSI, "Disable ANSI output")
|
||||
fs.Lookup(flagNoANSI).NoOptDefVal = "true"
|
||||
fs.BoolVarP(&gf.noInteraction, flagNoInteraction, "n", false, "Do not ask any interactive question")
|
||||
fs.StringVar(&gf.env, flagEnv, "", "The environment the command runs under")
|
||||
}
|
||||
|
||||
func (gf *globalFlags) globals() Globals {
|
||||
return Globals{
|
||||
Verbosity: gf.verbosity,
|
||||
Quiet: gf.quiet,
|
||||
Color: gf.color,
|
||||
NoInteraction: gf.noInteraction,
|
||||
Env: gf.env,
|
||||
}.normalize()
|
||||
}
|
||||
|
||||
// resetFlags puts every persistent flag of root back to its default, so a
|
||||
// root executed twice (tests, Execute) does not keep the previous run's
|
||||
// globals: pflag count flags add to their current value.
|
||||
func resetFlags(root *cobra.Command) {
|
||||
root.PersistentFlags().VisitAll(func(f *pflag.Flag) {
|
||||
_ = f.Value.Set(f.DefValue)
|
||||
f.Changed = false
|
||||
})
|
||||
}
|
||||
|
||||
// globalOption is one line of the global options block in list and help.
|
||||
type globalOption struct {
|
||||
flags string
|
||||
description string
|
||||
}
|
||||
|
||||
// globalOptions are the global option lines of the list (D-13).
|
||||
var globalOptions = []globalOption{
|
||||
{"-h, --help", "Display help for the given command"},
|
||||
{"-q, --quiet", "Do not output any message"},
|
||||
{"-v|vv|vvv", "Increase verbosity"},
|
||||
{" --ansi|no-ansi", "Force (or disable) ANSI output"},
|
||||
{"-n, --no-interaction", "Do not ask any interactive question"},
|
||||
{" --env", "The environment the command runs under"},
|
||||
}
|
||||
125
modules/bonfire/help.go
Normal file
125
modules/bonfire/help.go
Normal file
@@ -0,0 +1,125 @@
|
||||
package bonfire
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// helpUsage is the usage line of a command in help and the JSON listing:
|
||||
// the name, its arguments and [options]; passthrough commands end with
|
||||
// [--] <args...> because everything after the name reaches them unparsed.
|
||||
func helpUsage(c Command) string {
|
||||
u := commandUsage(c) + " [options]"
|
||||
if c.Passthrough {
|
||||
u += " [--] <args...>"
|
||||
}
|
||||
return u
|
||||
}
|
||||
|
||||
// optionLabel is the left column of a flag: "-x, --name=NAME" for value
|
||||
// flags, "--name" for bare flags, indented four spaces without shorthand.
|
||||
func optionLabel(f Flag) string {
|
||||
label := " "
|
||||
if f.Shorthand != "" {
|
||||
label = "-" + f.Shorthand + ", "
|
||||
}
|
||||
label += "--" + f.Name
|
||||
if !f.Bare {
|
||||
label += "=" + strings.ToUpper(strings.ReplaceAll(f.Name, "-", "_"))
|
||||
}
|
||||
return label
|
||||
}
|
||||
|
||||
// optionDetail is a flag's description followed by its default and the
|
||||
// repeatable marker.
|
||||
func optionDetail(f Flag) string {
|
||||
parts := []string{}
|
||||
if f.Description != "" {
|
||||
parts = append(parts, f.Description)
|
||||
}
|
||||
if f.Default != "" && !f.Bare {
|
||||
if _, err := strconv.Atoi(f.Default); err == nil {
|
||||
parts = append(parts, "[default: "+f.Default+"]")
|
||||
} else {
|
||||
parts = append(parts, "[default: "+strconv.Quote(f.Default)+"]")
|
||||
}
|
||||
}
|
||||
if f.Repeatable {
|
||||
parts = append(parts, "(multiple values allowed)")
|
||||
}
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
|
||||
// renderHelp writes a command's help in the Artisan layout: Description,
|
||||
// Usage, Arguments, Options with defaults, Global options, Help (the long
|
||||
// text) and Examples, each section present only when it has content.
|
||||
func (st *rootState) renderHelp(c Command, g Globals) error {
|
||||
p := DefaultPalette()
|
||||
var b strings.Builder
|
||||
section := func(title string) {
|
||||
if b.Len() > 0 {
|
||||
b.WriteString("\n")
|
||||
}
|
||||
b.WriteString(p.Header.Render(title+":") + "\n")
|
||||
}
|
||||
|
||||
if c.Description != "" {
|
||||
section("Description")
|
||||
b.WriteString(" " + c.Description + "\n")
|
||||
}
|
||||
section("Usage")
|
||||
b.WriteString(" " + helpUsage(c) + "\n")
|
||||
|
||||
width := 0
|
||||
for _, a := range c.Args {
|
||||
width = max(width, len(a.Name))
|
||||
}
|
||||
for _, f := range c.Flags {
|
||||
width = max(width, len(optionLabel(f)))
|
||||
}
|
||||
row := func(label, detail string) {
|
||||
line := " " + p.Success.Render(label)
|
||||
if detail != "" {
|
||||
line += strings.Repeat(" ", width-len(label)+2) + detail
|
||||
}
|
||||
b.WriteString(line + "\n")
|
||||
}
|
||||
if len(c.Args) > 0 {
|
||||
section("Arguments")
|
||||
for _, a := range c.Args {
|
||||
row(a.Name, a.Description)
|
||||
}
|
||||
}
|
||||
if len(c.Flags) > 0 {
|
||||
section("Options")
|
||||
for _, f := range c.Flags {
|
||||
row(optionLabel(f), optionDetail(f))
|
||||
}
|
||||
}
|
||||
section("Global options")
|
||||
writeGlobalOptions(&b, p)
|
||||
if long := strings.TrimSpace(c.Long); long != "" {
|
||||
section("Help")
|
||||
for _, line := range strings.Split(long, "\n") {
|
||||
if strings.TrimSpace(line) == "" {
|
||||
b.WriteString("\n")
|
||||
continue
|
||||
}
|
||||
b.WriteString(" " + line + "\n")
|
||||
}
|
||||
}
|
||||
if len(c.Examples) > 0 {
|
||||
section("Examples")
|
||||
bin := usageName(st.name)
|
||||
for i, e := range c.Examples {
|
||||
if i > 0 {
|
||||
b.WriteString("\n")
|
||||
}
|
||||
if e.Description != "" {
|
||||
b.WriteString(" " + p.Muted.Render("# "+e.Description) + "\n")
|
||||
}
|
||||
b.WriteString(" " + bin + " " + e.Command + "\n")
|
||||
}
|
||||
}
|
||||
return writeStyled(st.out, g, b.String())
|
||||
}
|
||||
235
modules/bonfire/help_smoke_test.go
Normal file
235
modules/bonfire/help_smoke_test.go
Normal file
@@ -0,0 +1,235 @@
|
||||
package bonfire
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func smokeTokenCommand() Command {
|
||||
return Command{
|
||||
Name: "admin:token:create",
|
||||
Description: "Create a personal API token for a backend admin",
|
||||
Long: "Creates a token for the admin with the given login.\nThe token is printed once.",
|
||||
Args: []Arg{{Name: "login", Description: "The admin's login", Required: true}},
|
||||
Flags: []Flag{
|
||||
{Name: "scopes", Description: "Comma-separated scopes", Default: "*"},
|
||||
{Name: "expires", Description: "Days until expiry", Default: "90"},
|
||||
{Name: "json", Description: "Print the token as JSON", Bare: true},
|
||||
{Name: "tag", Description: "Tag to add", Repeatable: true},
|
||||
},
|
||||
Examples: []Example{
|
||||
{Command: "admin:token:create jane --scopes=journal.*", Description: "Token for the MCP server, journal only"},
|
||||
{Command: "admin:token:create jane --json", Description: "Script-friendly output"},
|
||||
},
|
||||
Run: func(context.Context, Input, Output) error { return nil },
|
||||
}
|
||||
}
|
||||
|
||||
// smokeRoot returns a runner that executes args on a fresh root each time
|
||||
// (pflag keeps a command's local flag values between executions).
|
||||
func smokeRoot(t *testing.T, out *bytes.Buffer, commands ...Command) func(args ...string) int {
|
||||
t.Helper()
|
||||
return func(args ...string) int {
|
||||
t.Helper()
|
||||
root, err := NewRootIO("acme", commands, strings.NewReader(""), out, out,
|
||||
WithVersion(Version{App: "v1.4.0", Framework: "v0.3.2"}))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out.Reset()
|
||||
return Execute(context.Background(), root, args)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHelpSmoke: <command> --help renders the Artisan sections in order,
|
||||
// with defaults, the repeatable marker and examples prefixed by bin/<name>,
|
||||
// and help <command> prints the same.
|
||||
func TestHelpSmoke(t *testing.T) {
|
||||
var out bytes.Buffer
|
||||
run := smokeRoot(t, &out, smokeTokenCommand())
|
||||
if code := run("admin:token:create", "--help"); code != ExitOK {
|
||||
t.Fatalf("--help exit = %d\n%s", code, out.String())
|
||||
}
|
||||
got := out.String()
|
||||
order := []string{
|
||||
"Description:\n Create a personal API token for a backend admin\n",
|
||||
"Usage:\n admin:token:create <login> [options]\n",
|
||||
"Arguments:\n login ",
|
||||
"Options:\n",
|
||||
` --scopes=SCOPES Comma-separated scopes [default: "*"]`,
|
||||
" --expires=EXPIRES Days until expiry [default: 90]",
|
||||
" --json ",
|
||||
"Tag to add (multiple values allowed)",
|
||||
"Global options:\n -h, --help",
|
||||
"Help:\n Creates a token for the admin with the given login.\n The token is printed once.\n",
|
||||
"Examples:\n # Token for the MCP server, journal only\n bin/acme admin:token:create jane --scopes=journal.*\n\n # Script-friendly output\n bin/acme admin:token:create jane --json\n",
|
||||
}
|
||||
last := -1
|
||||
for _, want := range order {
|
||||
if want == ` --scopes=SCOPES Comma-separated scopes [default: "*"]` || want == " --expires=EXPIRES Days until expiry [default: 90]" {
|
||||
// The detail column is aligned to the longest label.
|
||||
label, detail, _ := strings.Cut(strings.TrimSpace(want), " ")
|
||||
i := strings.Index(got, label)
|
||||
if i < 0 || !strings.Contains(got[i:], detail) {
|
||||
t.Fatalf("help missing %q / %q:\n%s", label, detail, got)
|
||||
}
|
||||
continue
|
||||
}
|
||||
i := strings.Index(got, want)
|
||||
if i < 0 {
|
||||
t.Fatalf("help missing %q:\n%s", want, got)
|
||||
}
|
||||
if i < last {
|
||||
t.Fatalf("%q out of order:\n%s", want, got)
|
||||
}
|
||||
last = i
|
||||
}
|
||||
if strings.Contains(got, "--json=") || strings.Contains(got, "[default: \"true\"]") {
|
||||
t.Fatalf("bare flag rendered with a value:\n%s", got)
|
||||
}
|
||||
viaFlag := got
|
||||
if code := run("help", "admin:token:create"); code != ExitOK {
|
||||
t.Fatalf("help exit = %d", code)
|
||||
}
|
||||
if out.String() != viaFlag {
|
||||
t.Fatalf("help <command> differs from <command> --help:\n%s\n---\n%s", out.String(), viaFlag)
|
||||
}
|
||||
if code := run("help"); code != ExitOK || !strings.Contains(out.String(), "Available commands:") {
|
||||
t.Fatalf("bare help exit = %d\n%s", code, out.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestListJSONSmoke: list --format=json decodes into Listing with every
|
||||
// command, a Hidden one flagged; the text list hides it unless --all.
|
||||
func TestListJSONSmoke(t *testing.T) {
|
||||
hidden := Command{Name: "acme:secret", Description: "A hidden maintenance command", Hidden: true,
|
||||
Run: func(context.Context, Input, Output) error { return nil }}
|
||||
var out bytes.Buffer
|
||||
run := smokeRoot(t, &out, smokeTokenCommand(), hidden)
|
||||
if code := run("list", "--format=json"); code != ExitOK {
|
||||
t.Fatalf("json exit = %d\n%s", code, out.String())
|
||||
}
|
||||
var l Listing
|
||||
if err := json.Unmarshal(out.Bytes(), &l); err != nil {
|
||||
t.Fatalf("decode: %v\n%s", err, out.String())
|
||||
}
|
||||
if l.Application.Name != "acme" || l.Application.Version != "v1.4.0" || l.Application.Framework != "v0.3.2" {
|
||||
t.Fatalf("application = %+v", l.Application)
|
||||
}
|
||||
if len(l.GlobalOptions) == 0 {
|
||||
t.Fatal("no global options")
|
||||
}
|
||||
byName := map[string]CommandInfo{}
|
||||
for _, c := range l.Commands {
|
||||
byName[c.Name] = c
|
||||
}
|
||||
for _, name := range []string{"acme:secret", "admin:token:create", "list", "help"} {
|
||||
if _, ok := byName[name]; !ok {
|
||||
t.Fatalf("listing misses %s: %+v", name, l.Commands)
|
||||
}
|
||||
}
|
||||
if !byName["acme:secret"].Hidden {
|
||||
t.Fatal("hidden command not flagged")
|
||||
}
|
||||
tok := byName["admin:token:create"]
|
||||
if len(tok.Arguments) != 1 || len(tok.Options) != 4 || len(tok.Examples) != 2 || tok.Options[1].Default != "90" {
|
||||
t.Fatalf("token command = %+v", tok)
|
||||
}
|
||||
if !strings.Contains(out.String(), `"global_options"`) {
|
||||
t.Fatal("json keys are not snake case")
|
||||
}
|
||||
|
||||
if code := run("list"); code != ExitOK || strings.Contains(out.String(), "acme:secret") {
|
||||
t.Fatalf("list shows the hidden command (exit %d):\n%s", code, out.String())
|
||||
}
|
||||
if code := run("list", "--all"); code != ExitOK || !strings.Contains(out.String(), "acme:secret") {
|
||||
t.Fatalf("list --all hides the command (exit %d):\n%s", code, out.String())
|
||||
}
|
||||
if code := run("acme:secret"); code != ExitOK {
|
||||
t.Fatalf("hidden command does not run: %d", code)
|
||||
}
|
||||
if code := run("list", "--format=xml"); code != ExitUsage {
|
||||
t.Fatalf("unknown format exit = %d", code)
|
||||
}
|
||||
|
||||
problems := CheckDocs([]Command{smokeTokenCommand(), hidden, {Name: "acme:bare", Args: []Arg{{Name: "x"}}, Flags: []Flag{{Name: "y"}}}})
|
||||
if len(problems) != 2 || problems[0].Command != "acme:bare" || problems[1].Command != "acme:secret" {
|
||||
t.Fatalf("CheckDocs = %v", problems)
|
||||
}
|
||||
if got := problems[0].String(); got != "acme:bare: missing description, long help, examples, argument x description, option --y description" {
|
||||
t.Fatalf("problem = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestErrorRenderSmoke: the error block shows hints with {bin} replaced,
|
||||
// the -v hint or the wrapped chain, nothing for Reported errors, the exit
|
||||
// codes, the unknown command suggestion and scrubbed secrets.
|
||||
func TestErrorRenderSmoke(t *testing.T) {
|
||||
inner := errors.New("open summer.yaml: no such file")
|
||||
hinted := WithHint(fmt.Errorf("no application found in /tmp/x: %w", inner),
|
||||
"summer make:command needs an application root (summer.yaml).",
|
||||
"cd ..; {bin} make:command acme.blog:publish")
|
||||
|
||||
var buf bytes.Buffer
|
||||
RenderError(&buf, "acme", hinted, Globals{})
|
||||
got := buf.String()
|
||||
for _, want := range []string{
|
||||
" ERROR no application found in /tmp/x: open summer.yaml: no such file\n",
|
||||
"\n summer make:command needs an application root (summer.yaml).\n",
|
||||
"\n Try:\n cd ..; bin/acme make:command acme.blog:publish\n",
|
||||
"\n Run with -v for details.\n",
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("block missing %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
if strings.Contains(got, "Caused by:") {
|
||||
t.Fatalf("chain shown at verbosity 0:\n%s", got)
|
||||
}
|
||||
buf.Reset()
|
||||
RenderError(&buf, "summer", hinted, Globals{Verbosity: 1})
|
||||
got = buf.String()
|
||||
if !strings.Contains(got, "Caused by:\n - open summer.yaml: no such file\n") || strings.Contains(got, "Run with -v") || !strings.Contains(got, "cd ..; summer make:command") {
|
||||
t.Fatalf("verbose block:\n%s", got)
|
||||
}
|
||||
buf.Reset()
|
||||
RenderError(&buf, "acme", errors.New("upstream said Bearer abcdefghijklmnop0123456789"), Globals{Verbosity: 1})
|
||||
if strings.Contains(buf.String(), "abcdefghijklmnop0123456789") {
|
||||
t.Fatalf("bearer token not scrubbed:\n%s", buf.String())
|
||||
}
|
||||
buf.Reset()
|
||||
RenderError(&buf, "acme", Reported(errors.New("3 problems")), Globals{})
|
||||
if buf.Len() != 0 {
|
||||
t.Fatalf("reported error printed: %q", buf.String())
|
||||
}
|
||||
|
||||
var out bytes.Buffer
|
||||
run := smokeRoot(t, &out,
|
||||
Command{Name: "migrate", Description: "Run the migrations", Run: func(context.Context, Input, Output) error { return nil }},
|
||||
Command{Name: "docs:build", Description: "Build docs", Run: func(_ context.Context, _ Input, o Output) error {
|
||||
o.Println("problem 1")
|
||||
return Reported(errors.New("docs:build failed"))
|
||||
}},
|
||||
)
|
||||
if code := run("docs:build"); code != ExitFailure || strings.Contains(out.String(), "ERROR") {
|
||||
t.Fatalf("reported: exit %d\n%s", code, out.String())
|
||||
}
|
||||
if code := run("migrat"); code != ExitUsage {
|
||||
t.Fatalf("unknown command exit = %d", code)
|
||||
}
|
||||
got = out.String()
|
||||
if !strings.Contains(got, `ERROR Command "migrat" is not defined.`) || !strings.Contains(got, "Try:\n bin/acme migrate\n") {
|
||||
t.Fatalf("unknown command block:\n%s", got)
|
||||
}
|
||||
if code := run("migrate", "extra", "--bogus"); code != ExitUsage || !strings.Contains(out.String(), "bin/acme help migrate") {
|
||||
t.Fatalf("unknown flag: exit %d\n%s", code, out.String())
|
||||
}
|
||||
if code := run("help", "migrat"); code != ExitUsage || !strings.Contains(out.String(), "bin/acme help migrate") {
|
||||
t.Fatalf("help unknown: exit %d\n%s", code, out.String())
|
||||
}
|
||||
}
|
||||
206
modules/bonfire/helpers.go
Normal file
206
modules/bonfire/helpers.go
Normal file
@@ -0,0 +1,206 @@
|
||||
package bonfire
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
)
|
||||
|
||||
// consoleState is the optional side of an Output that knows the running
|
||||
// command's global options. The bonfire console implements it; other
|
||||
// Output implementations give the zero values.
|
||||
type consoleState interface {
|
||||
consoleGlobals() Globals
|
||||
consoleInteractive() bool
|
||||
consoleTerminal() bool
|
||||
}
|
||||
|
||||
func (c *console) consoleGlobals() Globals { return c.globals }
|
||||
|
||||
func (c *console) consoleInteractive() bool { return c.canPrompt() }
|
||||
|
||||
func (c *console) consoleTerminal() bool { return c.tty }
|
||||
|
||||
// GlobalsOf returns the global options of the command out belongs to, or
|
||||
// the zero Globals when out is not a bonfire console.
|
||||
func GlobalsOf(out Output) Globals {
|
||||
if s, ok := out.(consoleState); ok {
|
||||
return s.consoleGlobals()
|
||||
}
|
||||
return Globals{}
|
||||
}
|
||||
|
||||
// VerbosityOf returns the -v level (0 to 3) of the command out belongs to.
|
||||
func VerbosityOf(out Output) int {
|
||||
return GlobalsOf(out).Verbosity
|
||||
}
|
||||
|
||||
// IsQuiet reports whether the command runs with -q.
|
||||
func IsQuiet(out Output) bool {
|
||||
return GlobalsOf(out).Quiet
|
||||
}
|
||||
|
||||
// Interactive reports whether out can ask a person: stdin and stdout are
|
||||
// terminals and neither -n nor -q is set. It is false for other Output
|
||||
// implementations and inside bonfire.Call.
|
||||
func Interactive(out Output) bool {
|
||||
if s, ok := out.(consoleState); ok {
|
||||
return s.consoleInteractive()
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// IsTerminal reports whether out writes to a terminal (its stdout is one).
|
||||
// It is false for other Output implementations and inside bonfire.Call.
|
||||
// Long-running commands use it to choose between a styled summary block for
|
||||
// a person and log records for journald.
|
||||
func IsTerminal(out Output) bool {
|
||||
if s, ok := out.(consoleState); ok {
|
||||
return s.consoleTerminal()
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Detail is one line of a Details block: a label and its value. Warn
|
||||
// prints the value in the warning color.
|
||||
type Detail struct {
|
||||
Label string
|
||||
Value string
|
||||
Warn bool
|
||||
}
|
||||
|
||||
// Details prints title (when not empty) and rows as aligned label/value
|
||||
// lines in the console palette: muted labels padded to the longest one,
|
||||
// values in the default color or, with Warn, in the warning color. It
|
||||
// prints nothing under -q. serve, queue:work and summer dev use it for
|
||||
// their startup summaries and shutdown reports.
|
||||
func Details(out Output, title string, rows []Detail) {
|
||||
if IsQuiet(out) {
|
||||
return
|
||||
}
|
||||
width := 0
|
||||
for _, r := range rows {
|
||||
width = max(width, len([]rune(r.Label)))
|
||||
}
|
||||
c, styled := out.(*console)
|
||||
var b strings.Builder
|
||||
if title != "" {
|
||||
if styled {
|
||||
b.WriteString(c.palette.Header.Render(title))
|
||||
} else {
|
||||
b.WriteString(title)
|
||||
}
|
||||
b.WriteByte('\n')
|
||||
}
|
||||
for _, r := range rows {
|
||||
label := r.Label + strings.Repeat(" ", width-len([]rune(r.Label)))
|
||||
value := r.Value
|
||||
if styled {
|
||||
label = c.palette.Muted.Render(label)
|
||||
if r.Warn {
|
||||
value = c.palette.Warning.Render(value)
|
||||
}
|
||||
}
|
||||
b.WriteString(" " + label + " " + value + "\n")
|
||||
}
|
||||
if styled {
|
||||
c.emit(c.out, b.String())
|
||||
return
|
||||
}
|
||||
out.Printf("%s", b.String())
|
||||
}
|
||||
|
||||
// ConfirmProceed asks question before a destructive step, defaulting to
|
||||
// no, only when out is Interactive. Off a terminal, with -n or -q and in
|
||||
// bonfire.Call it returns true without asking, so deploys over SSH and
|
||||
// scheduled runs never block (D-28).
|
||||
func ConfirmProceed(out Output, question string) (bool, error) {
|
||||
if !Interactive(out) {
|
||||
return true, nil
|
||||
}
|
||||
return out.Confirm(question, false)
|
||||
}
|
||||
|
||||
// Current tells p which item is being worked on, when p is an ItemProgress.
|
||||
func Current(p Progress, item string) {
|
||||
if ip, ok := p.(ItemProgress); ok {
|
||||
ip.Current(item)
|
||||
}
|
||||
}
|
||||
|
||||
// Failed records a failed item on p, when p is an ItemProgress.
|
||||
func Failed(p Progress, item string, err error) {
|
||||
if ip, ok := p.(ItemProgress); ok {
|
||||
ip.Fail(item, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Process runs fn for each item in turn. On a live console it shows SSU's
|
||||
// process view: one row per item (waiting, running, done or failed with its
|
||||
// error) and a progress bar labelled operation. Otherwise it prints one line
|
||||
// per item, "✓ item" through Output.Success or "✗ item: error" through
|
||||
// Output.Error, so -q prints only the failures. It stops early when ctx is
|
||||
// cancelled and returns errors.Join of the failures.
|
||||
func Process(ctx context.Context, out Output, operation string, items []string, fn func(ctx context.Context, item string) error) error {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
if c, ok := out.(*console); ok && c.live() && len(items) > 0 {
|
||||
var errs []error
|
||||
_ = runLive(ctx, c.out, c, newProcessModel(c.palette, operation, items), func(send func(tea.Msg)) error {
|
||||
for i, item := range items {
|
||||
if err := ctx.Err(); err != nil {
|
||||
errs = append(errs, err)
|
||||
return nil
|
||||
}
|
||||
send(stepMsg{item: item, state: itemRunning, done: i})
|
||||
if err := fn(ctx, item); err != nil {
|
||||
errs = append(errs, failure(item, err))
|
||||
send(stepMsg{item: item, state: itemFailed, err: err, done: i + 1})
|
||||
continue
|
||||
}
|
||||
send(stepMsg{item: item, state: itemDone, done: i + 1})
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
var errs []error
|
||||
for _, item := range items {
|
||||
if err := ctx.Err(); err != nil {
|
||||
errs = append(errs, err)
|
||||
break
|
||||
}
|
||||
if err := fn(ctx, item); err != nil {
|
||||
errs = append(errs, failure(item, err))
|
||||
out.Error(failureLine(item, err))
|
||||
continue
|
||||
}
|
||||
out.Success(item)
|
||||
}
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
|
||||
// failure wraps err with the item it belongs to.
|
||||
func failure(item string, err error) error {
|
||||
return &itemError{item: item, err: err}
|
||||
}
|
||||
|
||||
type itemError struct {
|
||||
item string
|
||||
err error
|
||||
}
|
||||
|
||||
func (e *itemError) Error() string { return failureLine(e.item, e.err) }
|
||||
|
||||
func (e *itemError) Unwrap() error { return e.err }
|
||||
|
||||
// PrintAbove prints line above the running live view (spinner, progress or
|
||||
// process view) and reports whether one was running. diary's console
|
||||
// channel uses it so log lines from other goroutines never tear a view; it
|
||||
// writes directly when PrintAbove returns false.
|
||||
func PrintAbove(line string) bool {
|
||||
return printAboveLines(line)
|
||||
}
|
||||
304
modules/bonfire/list.go
Normal file
304
modules/bonfire/list.go
Normal file
@@ -0,0 +1,304 @@
|
||||
package bonfire
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/colorprofile"
|
||||
)
|
||||
|
||||
// builtinCommands describes the root's own commands, help and list. They are
|
||||
// registered by NewRoot, so plugins cannot use their names.
|
||||
func builtinCommands() []Command {
|
||||
return []Command{
|
||||
{
|
||||
Name: "help",
|
||||
Description: "Display help for a command",
|
||||
Long: "Prints the description, usage, arguments, options, long help and examples of a command. Without a command name it prints the command list.",
|
||||
Args: []Arg{{Name: "command_name", Description: "The command name"}},
|
||||
Examples: []Example{
|
||||
{Command: "help migrate", Description: "Help for the migrate command"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "list",
|
||||
Description: "List commands",
|
||||
Long: "Prints the commands grouped by namespace. --all includes hidden commands; --format=json prints every command with its arguments, options and examples.",
|
||||
Flags: []Flag{
|
||||
{Name: "all", Description: "Show hidden commands too", Bare: true},
|
||||
{Name: "format", Description: "The output format (txt or json)", Default: "txt"},
|
||||
},
|
||||
Examples: []Example{
|
||||
{Command: "list --all", Description: "Every command, hidden ones included"},
|
||||
{Command: "list --format=json", Description: "The catalog for scripts and agents"},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func builtinCommand(name string) Command {
|
||||
for _, c := range builtinCommands() {
|
||||
if c.Name == name {
|
||||
return c
|
||||
}
|
||||
}
|
||||
return Command{Name: name}
|
||||
}
|
||||
|
||||
// usageName is how a binary is invoked in usage lines and examples: summer
|
||||
// for the tool, bin/<name> for an application binary.
|
||||
func usageName(name string) string {
|
||||
if name == "summer" {
|
||||
return name
|
||||
}
|
||||
return "bin/" + name
|
||||
}
|
||||
|
||||
// listCommand is the list command with its run function.
|
||||
func (st *rootState) listCommand() Command {
|
||||
c := builtinCommand("list")
|
||||
c.Run = func(_ context.Context, in Input, _ Output) error {
|
||||
all, _ := in.Flag("all")
|
||||
format, _ := in.Flag("format")
|
||||
switch format {
|
||||
case "", "txt":
|
||||
return st.renderList(st.globals(), all == "true")
|
||||
case "json":
|
||||
return st.renderJSON()
|
||||
default:
|
||||
return WithHint(Usage(fmt.Errorf("list: unknown format %q", format)),
|
||||
"list prints txt (the default) or json.", "{bin} list --format=json")
|
||||
}
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// helpCommand is the help command: the list without an argument, a
|
||||
// command's help with one.
|
||||
func (st *rootState) helpCommand() Command {
|
||||
c := builtinCommand("help")
|
||||
c.Run = func(_ context.Context, in Input, _ Output) error {
|
||||
args := in.Args()
|
||||
if len(args) == 0 {
|
||||
return st.renderList(st.globals(), false)
|
||||
}
|
||||
name := args[0]
|
||||
for _, cmd := range st.all() {
|
||||
if cmd.Name == name {
|
||||
return st.renderHelp(cmd, st.globals())
|
||||
}
|
||||
}
|
||||
err := Usage(fmt.Errorf("Command %q is not defined.", name))
|
||||
var try []string
|
||||
for _, s := range st.root.SuggestionsFor(name) {
|
||||
try = append(try, "{bin} help "+s)
|
||||
}
|
||||
if len(try) == 0 {
|
||||
try = []string{"{bin} list"}
|
||||
}
|
||||
return WithHint(err, "", strings.Join(try, "\n"))
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// all returns the root's commands and its builtins.
|
||||
func (st *rootState) all() []Command {
|
||||
return append(append([]Command(nil), st.commands...), builtinCommands()...)
|
||||
}
|
||||
|
||||
// renderList writes the Artisan list: the version header, usage, the global
|
||||
// options and the commands, top-level names first and then one block per
|
||||
// namespace (the text before the first colon). Hidden commands are left out
|
||||
// unless all is true.
|
||||
func (st *rootState) renderList(g Globals, all bool) error {
|
||||
p := DefaultPalette()
|
||||
var top []Command
|
||||
groups := map[string][]Command{}
|
||||
width := 0
|
||||
for _, c := range st.all() {
|
||||
if c.Hidden && !all {
|
||||
continue
|
||||
}
|
||||
ns, _, namespaced := strings.Cut(c.Name, ":")
|
||||
if namespaced {
|
||||
groups[ns] = append(groups[ns], c)
|
||||
} else {
|
||||
top = append(top, c)
|
||||
}
|
||||
width = max(width, len(c.Name))
|
||||
}
|
||||
byName := func(a, b Command) int { return strings.Compare(a.Name, b.Name) }
|
||||
slices.SortFunc(top, byName)
|
||||
namespaces := make([]string, 0, len(groups))
|
||||
for ns := range groups {
|
||||
namespaces = append(namespaces, ns)
|
||||
}
|
||||
slices.Sort(namespaces)
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString(p.Header.Render(st.version.Header(st.name)) + "\n\n")
|
||||
b.WriteString(p.Header.Render("Usage:") + "\n")
|
||||
b.WriteString(" " + usageName(st.name) + " <command> [options] [arguments]\n\n")
|
||||
b.WriteString(p.Header.Render("Options:") + "\n")
|
||||
writeGlobalOptions(&b, p)
|
||||
b.WriteString("\n" + p.Header.Render("Available commands:") + "\n")
|
||||
line := func(c Command) {
|
||||
pad := strings.Repeat(" ", width-len(c.Name)+2)
|
||||
b.WriteString(" " + p.Success.Render(c.Name) + pad + c.Description + "\n")
|
||||
}
|
||||
for _, c := range top {
|
||||
line(c)
|
||||
}
|
||||
for _, ns := range namespaces {
|
||||
b.WriteString(" " + p.Header.Render(ns) + "\n")
|
||||
cmds := groups[ns]
|
||||
slices.SortFunc(cmds, byName)
|
||||
for _, c := range cmds {
|
||||
line(c)
|
||||
}
|
||||
}
|
||||
return writeStyled(st.out, g, b.String())
|
||||
}
|
||||
|
||||
// renderJSON writes Describe of the root as indented JSON.
|
||||
func (st *rootState) renderJSON() error {
|
||||
data, err := json.MarshalIndent(Describe(st.name, st.all(), st.version), "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = st.out.Write(append(data, '\n'))
|
||||
return err
|
||||
}
|
||||
|
||||
// writeGlobalOptions writes the global option lines, aligned.
|
||||
func writeGlobalOptions(b *strings.Builder, p Palette) {
|
||||
width := 0
|
||||
for _, o := range globalOptions {
|
||||
width = max(width, len(o.flags))
|
||||
}
|
||||
for _, o := range globalOptions {
|
||||
pad := strings.Repeat(" ", width-len(o.flags)+2)
|
||||
b.WriteString(" " + p.Success.Render(o.flags) + pad + o.description + "\n")
|
||||
}
|
||||
}
|
||||
|
||||
// writeStyled writes s to w through a colorprofile writer, so palette
|
||||
// styles follow --ansi, --no-ansi, NO_COLOR and the terminal.
|
||||
func writeStyled(w io.Writer, g Globals, s string) error {
|
||||
cw := &colorprofile.Writer{Forward: w, Profile: DetectProfile(w, g.Color, nil)}
|
||||
_, err := io.WriteString(cw, s)
|
||||
return err
|
||||
}
|
||||
|
||||
// Listing is the JSON catalog of a binary's commands (list --format=json).
|
||||
type Listing struct {
|
||||
Application ApplicationInfo `json:"application"`
|
||||
GlobalOptions []OptionInfo `json:"global_options"`
|
||||
Commands []CommandInfo `json:"commands"`
|
||||
}
|
||||
|
||||
// ApplicationInfo names the binary and its versions in a Listing.
|
||||
type ApplicationInfo struct {
|
||||
Name string `json:"name"`
|
||||
Version string `json:"version"`
|
||||
Framework string `json:"framework"`
|
||||
}
|
||||
|
||||
// CommandInfo is one command of a Listing.
|
||||
type CommandInfo struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Long string `json:"long"`
|
||||
Usage string `json:"usage"`
|
||||
Hidden bool `json:"hidden"`
|
||||
Passthrough bool `json:"passthrough"`
|
||||
Arguments []ArgumentInfo `json:"arguments"`
|
||||
Options []OptionInfo `json:"options"`
|
||||
Examples []ExampleInfo `json:"examples"`
|
||||
}
|
||||
|
||||
// ArgumentInfo is one positional argument of a CommandInfo.
|
||||
type ArgumentInfo struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Required bool `json:"required"`
|
||||
}
|
||||
|
||||
// OptionInfo is one flag of a CommandInfo or a global option.
|
||||
type OptionInfo struct {
|
||||
Name string `json:"name"`
|
||||
Shorthand string `json:"shorthand"`
|
||||
Description string `json:"description"`
|
||||
Default string `json:"default"`
|
||||
Bare bool `json:"bare"`
|
||||
Repeatable bool `json:"repeatable"`
|
||||
}
|
||||
|
||||
// ExampleInfo is one example of a CommandInfo, the command line without the
|
||||
// binary.
|
||||
type ExampleInfo struct {
|
||||
Command string `json:"command"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
// globalOptionInfos are the global options as Listing entries.
|
||||
var globalOptionInfos = []OptionInfo{
|
||||
{Name: flagHelp, Shorthand: "h", Description: "Display help for the given command", Bare: true},
|
||||
{Name: flagQuiet, Shorthand: "q", Description: "Do not output any message", Bare: true},
|
||||
{Name: flagVerbose, Shorthand: "v", Description: "Increase verbosity (-v, -vv or -vvv)", Bare: true, Repeatable: true},
|
||||
{Name: flagANSI, Description: "Force ANSI output", Bare: true},
|
||||
{Name: flagNoANSI, Description: "Disable ANSI output", Bare: true},
|
||||
{Name: flagNoInteraction, Shorthand: "n", Description: "Do not ask any interactive question", Bare: true},
|
||||
{Name: flagEnv, Description: "The environment the command runs under"},
|
||||
}
|
||||
|
||||
// Describe returns the Listing of a binary named name with the given
|
||||
// commands, sorted by name: every command, hidden ones included, with its
|
||||
// arguments, options and examples. NewRoot's list --format=json adds the
|
||||
// list and help commands.
|
||||
func Describe(name string, commands []Command, v Version) Listing {
|
||||
l := Listing{
|
||||
Application: ApplicationInfo{
|
||||
Name: name,
|
||||
Version: cleanVersion(v.App),
|
||||
Framework: cleanVersion(v.Framework),
|
||||
},
|
||||
GlobalOptions: append([]OptionInfo(nil), globalOptionInfos...),
|
||||
Commands: make([]CommandInfo, 0, len(commands)),
|
||||
}
|
||||
if v.Framework == "" {
|
||||
l.Application.Framework = l.Application.Version
|
||||
}
|
||||
for _, c := range commands {
|
||||
info := CommandInfo{
|
||||
Name: c.Name,
|
||||
Description: c.Description,
|
||||
Long: c.Long,
|
||||
Usage: helpUsage(c),
|
||||
Hidden: c.Hidden,
|
||||
Passthrough: c.Passthrough,
|
||||
Arguments: []ArgumentInfo{},
|
||||
Options: []OptionInfo{},
|
||||
Examples: []ExampleInfo{},
|
||||
}
|
||||
for _, a := range c.Args {
|
||||
info.Arguments = append(info.Arguments, ArgumentInfo{Name: a.Name, Description: a.Description, Required: a.Required})
|
||||
}
|
||||
for _, f := range c.Flags {
|
||||
info.Options = append(info.Options, OptionInfo{
|
||||
Name: f.Name, Shorthand: f.Shorthand, Description: f.Description,
|
||||
Default: f.Default, Bare: f.Bare, Repeatable: f.Repeatable,
|
||||
})
|
||||
}
|
||||
for _, e := range c.Examples {
|
||||
info.Examples = append(info.Examples, ExampleInfo{Command: e.Command, Description: e.Description})
|
||||
}
|
||||
l.Commands = append(l.Commands, info)
|
||||
}
|
||||
slices.SortFunc(l.Commands, func(a, b CommandInfo) int { return strings.Compare(a.Name, b.Name) })
|
||||
return l
|
||||
}
|
||||
142
modules/bonfire/list_smoke_test.go
Normal file
142
modules/bonfire/list_smoke_test.go
Normal file
@@ -0,0 +1,142 @@
|
||||
package bonfire
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestListTracerSmoke: a bare root prints the Artisan list (header, usage,
|
||||
// global options, top-level commands then namespaces), the global options
|
||||
// reach the OnGlobals hook the same way PeekGlobals reads them, and Execute
|
||||
// returns 0, 1 or 2.
|
||||
func TestListTracerSmoke(t *testing.T) {
|
||||
var recorded []Globals
|
||||
commands := func() []Command {
|
||||
ok := func(context.Context, Input, Output) error { return nil }
|
||||
return []Command{
|
||||
{Name: "migrate", Description: "Run the database migrations", Run: ok},
|
||||
{Name: "admin:create", Description: "Create a backend administrator", Run: ok},
|
||||
{Name: "admin:token:create", Description: "Create a personal API token", Run: ok},
|
||||
{Name: "make:model", Description: "Create a new model and migration", Run: ok},
|
||||
{Name: "acme:fail", Description: "Fail on purpose", Run: func(context.Context, Input, Output) error {
|
||||
return errors.New("boom")
|
||||
}},
|
||||
}
|
||||
}
|
||||
newRoot := func(t *testing.T, out *bytes.Buffer) func(args ...string) int {
|
||||
t.Helper()
|
||||
root, err := NewRootIO("acme", commands(), strings.NewReader(""), out, out,
|
||||
WithVersion(Version{App: "v1.4.0", Framework: "v0.3.2"}),
|
||||
OnGlobals(func(g Globals) { recorded = append(recorded, g) }))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return func(args ...string) int {
|
||||
recorded = nil
|
||||
out.Reset()
|
||||
return Execute(context.Background(), root, args)
|
||||
}
|
||||
}
|
||||
|
||||
var out bytes.Buffer
|
||||
run := newRoot(t, &out)
|
||||
if code := run(); code != ExitOK {
|
||||
t.Fatalf("bare root exit = %d\n%s", code, out.String())
|
||||
}
|
||||
got := out.String()
|
||||
for _, want := range []string{
|
||||
"SummerCMS acme v1.4.0 (framework v0.3.2)\n",
|
||||
"Usage:\n bin/acme <command> [options] [arguments]\n",
|
||||
" -h, --help Display help for the given command\n",
|
||||
" -q, --quiet Do not output any message\n",
|
||||
" -v|vv|vvv Increase verbosity\n",
|
||||
" --ansi|no-ansi Force (or disable) ANSI output\n",
|
||||
" -n, --no-interaction Do not ask any interactive question\n",
|
||||
" --env The environment the command runs under\n",
|
||||
"Available commands:\n",
|
||||
" migrate Run the database migrations\n",
|
||||
" admin:token:create Create a personal API token\n",
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("list missing %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
order := []string{"\n list ", "\n migrate ", "\n acme\n", "\n admin\n", "\n admin:create ", "\n admin:token:create ", "\n make\n", "\n make:model "}
|
||||
last := -1
|
||||
for _, s := range order {
|
||||
i := strings.Index(got, s)
|
||||
if i <= last {
|
||||
t.Fatalf("%q out of order (at %d, previous at %d):\n%s", s, i, last, got)
|
||||
}
|
||||
last = i
|
||||
}
|
||||
if strings.Contains(got, "\x1b[") {
|
||||
t.Fatalf("non-tty list has ansi:\n%q", got)
|
||||
}
|
||||
if code := run("--help"); code != ExitOK || !strings.Contains(out.String(), "Available commands:") {
|
||||
t.Fatalf("--help exit = %d\n%s", code, out.String())
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
args []string
|
||||
want Globals
|
||||
}{
|
||||
{[]string{"-vvv", "migrate"}, Globals{Verbosity: 3}},
|
||||
{[]string{"migrate", "-q"}, Globals{Quiet: true, NoInteraction: true}},
|
||||
{[]string{"migrate", "--env", "staging", "-n", "--ansi", "--no-ansi"}, Globals{NoInteraction: true, Env: "staging", Color: ColorNever}},
|
||||
{[]string{"--env=dev", "-v", "migrate", "--verbose"}, Globals{Verbosity: 2, Env: "dev"}},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if code := run(c.args...); code != ExitOK {
|
||||
t.Fatalf("%v exit = %d\n%s", c.args, code, out.String())
|
||||
}
|
||||
if len(recorded) != 1 || recorded[0] != c.want {
|
||||
t.Fatalf("%v: hook got %+v, want %+v", c.args, recorded, c.want)
|
||||
}
|
||||
if peek := PeekGlobals(c.args); peek != c.want {
|
||||
t.Fatalf("%v: PeekGlobals = %+v, want %+v", c.args, peek, c.want)
|
||||
}
|
||||
}
|
||||
|
||||
if code := run("migrat"); code != ExitUsage {
|
||||
t.Fatalf("unknown command exit = %d, want %d\n%s", code, ExitUsage, out.String())
|
||||
}
|
||||
if code := run("migrate", "--bogus"); code != ExitUsage {
|
||||
t.Fatalf("unknown flag exit = %d, want %d", code, ExitUsage)
|
||||
}
|
||||
if code := run("acme:fail"); code != ExitFailure || !strings.Contains(out.String(), "ERROR") || !strings.Contains(out.String(), "boom") {
|
||||
t.Fatalf("failing command exit = %d\n%s", code, out.String())
|
||||
}
|
||||
|
||||
dup := append(commands(), Command{Name: "migrate", Description: "Again"})
|
||||
if _, err := NewRootIO("acme", dup, strings.NewReader(""), &out, &out); !errors.Is(err, ErrDuplicateCommand) || !strings.Contains(err.Error(), `"migrate"`) {
|
||||
t.Fatalf("duplicate name err = %v", err)
|
||||
}
|
||||
if _, err := NewRootIO("acme", []Command{{Name: "acme:list"}, {Name: "list"}}, strings.NewReader(""), &out, &out); !errors.Is(err, ErrDuplicateCommand) {
|
||||
t.Fatalf("command named list err = %v", err)
|
||||
}
|
||||
reserved := []Command{{Name: "acme:loud", Flags: []Flag{{Name: "volume", Shorthand: "v"}}}}
|
||||
if _, err := NewRootIO("acme", reserved, strings.NewReader(""), &out, &out); !errors.Is(err, ErrReservedFlag) || !strings.Contains(err.Error(), "acme:loud") {
|
||||
t.Fatalf("reserved shorthand err = %v", err)
|
||||
}
|
||||
reservedName := []Command{{Name: "acme:loud", Flags: []Flag{{Name: "env"}}}}
|
||||
if _, err := NewRootIO("acme", reservedName, strings.NewReader(""), &out, &out); !errors.Is(err, ErrReservedFlag) {
|
||||
t.Fatalf("reserved name err = %v", err)
|
||||
}
|
||||
|
||||
// Call builds its own root and never runs a hook.
|
||||
recorded = nil
|
||||
if err := Call(context.Background(), commands(), "migrate", []string{"-vv"}, &out); err != nil || len(recorded) != 0 {
|
||||
t.Fatalf("Call: err %v, hooks %v", err, recorded)
|
||||
}
|
||||
|
||||
if args := (Globals{Verbosity: 2, Quiet: true, NoInteraction: true, Color: ColorNever, Env: "staging"}).Args(); strings.Join(args, " ") != "-vv -q --no-ansi --env=staging" {
|
||||
t.Fatalf("Globals.Args = %v", args)
|
||||
}
|
||||
if args := (Globals{}).Args(); len(args) != 0 {
|
||||
t.Fatalf("zero Globals.Args = %v", args)
|
||||
}
|
||||
}
|
||||
387
modules/bonfire/live.go
Normal file
387
modules/bonfire/live.go
Normal file
@@ -0,0 +1,387 @@
|
||||
package bonfire
|
||||
|
||||
// Live views ported from SSU's terminal UI (internal/cli/tui spinner.go,
|
||||
// process.go and progress.go) to bubbletea v2, without SSU's git and
|
||||
// engine types. Each runs as a bubbletea program with input off (stdin
|
||||
// stays in cooked mode, so Ctrl+C reaches the command's own signal
|
||||
// handling), no signal handler of its own and the command's context.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"charm.land/bubbles/v2/progress"
|
||||
"charm.land/bubbles/v2/spinner"
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"charm.land/lipgloss/v2"
|
||||
)
|
||||
|
||||
// Progress bar colors: the cyan to green gradient of the earlier bonfire
|
||||
// bar (256-color 37 and 34).
|
||||
var (
|
||||
barFrom = lipgloss.Color("#00afaf")
|
||||
barTo = lipgloss.Color("#00af00")
|
||||
)
|
||||
|
||||
const barWidth = 40
|
||||
|
||||
// Messages sent to the live models from the command's goroutine.
|
||||
type (
|
||||
// itemMsg reports progress: done of total, the current item, and a
|
||||
// failure of the current item when err is set.
|
||||
itemMsg struct {
|
||||
done, total int
|
||||
item string
|
||||
err error
|
||||
}
|
||||
// stepMsg reports one item of the process view changing state.
|
||||
stepMsg struct {
|
||||
item string
|
||||
state itemState
|
||||
err error
|
||||
done int
|
||||
}
|
||||
// finishMsg ends a live view; err is the result of the work.
|
||||
finishMsg struct{ err error }
|
||||
// printAboveMsg is a line to print above the view.
|
||||
printAboveMsg string
|
||||
)
|
||||
|
||||
// itemState is the display state of one item in the process view.
|
||||
type itemState int
|
||||
|
||||
const (
|
||||
itemWaiting itemState = iota
|
||||
itemRunning
|
||||
itemDone
|
||||
itemFailed
|
||||
)
|
||||
|
||||
const symWaiting = "○"
|
||||
|
||||
// newSpinner is SSU's MiniDot spinner in the current (cyan) style.
|
||||
func newSpinner(p Palette) spinner.Model {
|
||||
return spinner.New(spinner.WithSpinner(spinner.MiniDot), spinner.WithStyle(p.Current))
|
||||
}
|
||||
|
||||
// newBar is the gradient progress bar without its own percentage.
|
||||
func newBar() progress.Model {
|
||||
bar := progress.New(progress.WithColors(barFrom, barTo), progress.WithoutPercentage())
|
||||
bar.SetWidth(barWidth)
|
||||
return bar
|
||||
}
|
||||
|
||||
// spinnerModel shows a spinner, the label and, once known, a done/total
|
||||
// counter with the current item; it ends with ✓ or ✗ and the label.
|
||||
type spinnerModel struct {
|
||||
palette Palette
|
||||
spin spinner.Model
|
||||
label string
|
||||
done int
|
||||
total int
|
||||
current string
|
||||
failed int
|
||||
finished bool
|
||||
err error
|
||||
}
|
||||
|
||||
func newSpinnerModel(p Palette, label string) spinnerModel {
|
||||
return spinnerModel{palette: p, spin: newSpinner(p), label: label}
|
||||
}
|
||||
|
||||
func (m spinnerModel) Init() tea.Cmd { return m.spin.Tick }
|
||||
|
||||
func (m spinnerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case itemMsg:
|
||||
m.done, m.total, m.current = msg.done, msg.total, msg.item
|
||||
if msg.err != nil {
|
||||
m.failed++
|
||||
}
|
||||
return m, nil
|
||||
case finishMsg:
|
||||
m.finished, m.err = true, msg.err
|
||||
return m, tea.Quit
|
||||
case spinner.TickMsg:
|
||||
var cmd tea.Cmd
|
||||
m.spin, cmd = m.spin.Update(msg)
|
||||
return m, cmd
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m spinnerModel) View() tea.View {
|
||||
p := m.palette
|
||||
if m.finished {
|
||||
if m.err != nil {
|
||||
return tea.NewView(p.Error.Render(symCross) + " " + m.label + "\n")
|
||||
}
|
||||
return tea.NewView(p.Success.Render(symCheck) + " " + m.label + "\n")
|
||||
}
|
||||
line := m.spin.View() + " " + m.label
|
||||
if m.total > 0 {
|
||||
line += fmt.Sprintf(" %d/%d", m.done, m.total)
|
||||
}
|
||||
if m.current != "" {
|
||||
line += " " + p.Muted.Render(m.current+"…")
|
||||
}
|
||||
if m.failed > 0 {
|
||||
line += " " + p.ErrorBold.Render(fmt.Sprintf("! %d failed", m.failed))
|
||||
}
|
||||
return tea.NewView(line)
|
||||
}
|
||||
|
||||
// progressModel shows the gradient bar, done/total and the current item.
|
||||
type progressModel struct {
|
||||
palette Palette
|
||||
bar progress.Model
|
||||
done int
|
||||
total int
|
||||
current string
|
||||
failed int
|
||||
finished bool
|
||||
}
|
||||
|
||||
func newProgressModel(p Palette, total int) progressModel {
|
||||
return progressModel{palette: p, bar: newBar(), total: total}
|
||||
}
|
||||
|
||||
func (m progressModel) Init() tea.Cmd { return nil }
|
||||
|
||||
func (m progressModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.WindowSizeMsg:
|
||||
m.bar.SetWidth(min(max(msg.Width-30, 10), barWidth))
|
||||
return m, nil
|
||||
case itemMsg:
|
||||
m.done, m.total = msg.done, msg.total
|
||||
if msg.item != "" {
|
||||
m.current = msg.item
|
||||
}
|
||||
if msg.err != nil {
|
||||
m.failed++
|
||||
}
|
||||
return m, nil
|
||||
case finishMsg:
|
||||
m.finished = true
|
||||
return m, tea.Quit
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m progressModel) View() tea.View {
|
||||
pct := 0.0
|
||||
if m.total > 0 {
|
||||
pct = float64(m.done) / float64(m.total)
|
||||
}
|
||||
line := " " + m.bar.ViewAs(pct) + m.palette.Muted.Render(fmt.Sprintf(" %d/%d", m.done, m.total))
|
||||
if m.current != "" && !m.finished {
|
||||
line += " " + m.palette.Muted.Render(m.current)
|
||||
}
|
||||
if m.failed > 0 {
|
||||
line += " " + m.palette.ErrorBold.Render(fmt.Sprintf("! %d failed", m.failed))
|
||||
}
|
||||
if m.finished {
|
||||
line += "\n"
|
||||
}
|
||||
return tea.NewView(line)
|
||||
}
|
||||
|
||||
// processItem is one row of the process view.
|
||||
type processItem struct {
|
||||
name string
|
||||
state itemState
|
||||
err error
|
||||
}
|
||||
|
||||
// processModel is SSU's multi-row view: one row per item (waiting, running
|
||||
// with a spinner, done or failed with its error) and the bar below.
|
||||
type processModel struct {
|
||||
palette Palette
|
||||
items []processItem
|
||||
spin spinner.Model
|
||||
bar progress.Model
|
||||
operation string
|
||||
done int
|
||||
height int
|
||||
finished bool
|
||||
}
|
||||
|
||||
func newProcessModel(p Palette, operation string, items []string) processModel {
|
||||
rows := make([]processItem, len(items))
|
||||
for i, name := range items {
|
||||
rows[i] = processItem{name: name}
|
||||
}
|
||||
return processModel{palette: p, items: rows, spin: newSpinner(p), bar: newBar(), operation: operation}
|
||||
}
|
||||
|
||||
func (m processModel) Init() tea.Cmd { return m.spin.Tick }
|
||||
|
||||
func (m processModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.WindowSizeMsg:
|
||||
m.height = msg.Height
|
||||
return m, nil
|
||||
case stepMsg:
|
||||
m.done = msg.done
|
||||
for i := range m.items {
|
||||
if m.items[i].name == msg.item && m.items[i].state < msg.state {
|
||||
m.items[i].state, m.items[i].err = msg.state, msg.err
|
||||
break
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
case finishMsg:
|
||||
m.finished = true
|
||||
return m, tea.Quit
|
||||
case spinner.TickMsg:
|
||||
var cmd tea.Cmd
|
||||
m.spin, cmd = m.spin.Update(msg)
|
||||
return m, cmd
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// window is the range of rows that fits the terminal: around the running
|
||||
// item, or the last finished ones.
|
||||
func (m processModel) window() (int, int) {
|
||||
visible := len(m.items)
|
||||
if m.height > 0 && visible > m.height-3 {
|
||||
visible = max(m.height-3, 3)
|
||||
}
|
||||
start := 0
|
||||
if visible < len(m.items) {
|
||||
active := -1
|
||||
for i, it := range m.items {
|
||||
if it.state == itemRunning {
|
||||
active = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if active >= 0 {
|
||||
start = min(max(active-visible/2, 0), len(m.items)-visible)
|
||||
} else {
|
||||
start = max(m.done-visible, 0)
|
||||
}
|
||||
}
|
||||
return start, min(start+visible, len(m.items))
|
||||
}
|
||||
|
||||
func (m processModel) View() tea.View {
|
||||
p := m.palette
|
||||
var b strings.Builder
|
||||
start, end := m.window()
|
||||
for _, it := range m.items[start:end] {
|
||||
switch it.state {
|
||||
case itemWaiting:
|
||||
b.WriteString(p.Muted.Render(" "+symWaiting) + " " + p.Muted.Render(it.name))
|
||||
case itemRunning:
|
||||
b.WriteString(" " + m.spin.View() + " " + it.name)
|
||||
case itemDone:
|
||||
b.WriteString(p.Success.Render(" "+symCheck) + " " + it.name)
|
||||
case itemFailed:
|
||||
b.WriteString(p.Error.Render(" "+symCross) + " " + it.name)
|
||||
if it.err != nil {
|
||||
b.WriteString(" " + p.Error.Render(it.err.Error()))
|
||||
}
|
||||
}
|
||||
b.WriteString("\n")
|
||||
}
|
||||
total := len(m.items)
|
||||
pct := 0.0
|
||||
if total > 0 {
|
||||
pct = float64(m.done) / float64(total)
|
||||
}
|
||||
op := m.operation
|
||||
if op == "" {
|
||||
op = "Processing"
|
||||
}
|
||||
b.WriteString(fmt.Sprintf("\n %s %d/%d %s %s", op, m.done, total, m.bar.ViewAs(pct), p.Muted.Render(fmt.Sprintf("%d%%", int(pct*100)))))
|
||||
if m.finished {
|
||||
b.WriteString("\n")
|
||||
}
|
||||
return tea.NewView(b.String())
|
||||
}
|
||||
|
||||
// liveModel wraps a view so lines from PrintAbove print above it.
|
||||
type liveModel struct {
|
||||
inner tea.Model
|
||||
}
|
||||
|
||||
func (m liveModel) Init() tea.Cmd { return m.inner.Init() }
|
||||
|
||||
func (m liveModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
if line, ok := msg.(printAboveMsg); ok {
|
||||
return m, tea.Println(string(line))
|
||||
}
|
||||
inner, cmd := m.inner.Update(msg)
|
||||
m.inner = inner
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
func (m liveModel) View() tea.View { return m.inner.View() }
|
||||
|
||||
// The running live program, for PrintAbove. Only one live view runs at a
|
||||
// time; widgets started while one runs print plain lines.
|
||||
var (
|
||||
liveMu sync.Mutex
|
||||
activeProg *tea.Program
|
||||
)
|
||||
|
||||
// liveActive reports whether a live view is running.
|
||||
func liveActive() bool {
|
||||
liveMu.Lock()
|
||||
defer liveMu.Unlock()
|
||||
return activeProg != nil
|
||||
}
|
||||
|
||||
// runLive runs model as a bubbletea program on out while work runs on the
|
||||
// calling goroutine. work gets a send function for progress messages. When
|
||||
// work returns, the program is unregistered (so PrintAbove stops sending to
|
||||
// it), told to finish and waited for; it never receives lines after that.
|
||||
func runLive(ctx context.Context, out io.Writer, c *console, model tea.Model, work func(send func(tea.Msg)) error) error {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
prog := tea.NewProgram(liveModel{inner: model},
|
||||
tea.WithInput(nil),
|
||||
tea.WithOutput(out),
|
||||
tea.WithoutSignalHandler(),
|
||||
tea.WithContext(ctx),
|
||||
tea.WithColorProfile(c.profile()),
|
||||
)
|
||||
finished := make(chan struct{})
|
||||
go func() {
|
||||
defer close(finished)
|
||||
_, _ = prog.Run()
|
||||
}()
|
||||
liveMu.Lock()
|
||||
activeProg = prog
|
||||
liveMu.Unlock()
|
||||
|
||||
err := work(prog.Send)
|
||||
|
||||
liveMu.Lock()
|
||||
activeProg = nil
|
||||
liveMu.Unlock()
|
||||
prog.Send(finishMsg{err: err})
|
||||
<-finished
|
||||
return err
|
||||
}
|
||||
|
||||
// printAboveLines prints s, one or more lines, above the running live view.
|
||||
// It reports false when no view runs.
|
||||
func printAboveLines(s string) bool {
|
||||
liveMu.Lock()
|
||||
defer liveMu.Unlock()
|
||||
if activeProg == nil {
|
||||
return false
|
||||
}
|
||||
for _, line := range strings.Split(strings.TrimSuffix(s, "\n"), "\n") {
|
||||
activeProg.Send(printAboveMsg(line))
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -4,18 +4,14 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"charm.land/lipgloss/v2"
|
||||
"github.com/charmbracelet/colorprofile"
|
||||
"golang.org/x/term"
|
||||
)
|
||||
|
||||
const (
|
||||
ansiReset = "\x1b[0m"
|
||||
ansiRed = "\x1b[31m"
|
||||
ansiGreen = "\x1b[32m"
|
||||
ansiYellow = "\x1b[33m"
|
||||
ansiCyan = "\x1b[36m"
|
||||
ansiDim = "\x1b[90m"
|
||||
|
||||
symCheck = "✓"
|
||||
symCross = "✗"
|
||||
symWarn = "⚠"
|
||||
@@ -48,13 +44,25 @@ type Progress interface {
|
||||
}
|
||||
|
||||
type console struct {
|
||||
in io.Reader
|
||||
out io.Writer
|
||||
err io.Writer
|
||||
lookup func(string) string
|
||||
tty bool
|
||||
in io.Reader
|
||||
out io.Writer
|
||||
err io.Writer
|
||||
lookup func(string) string
|
||||
// tty is true when stdout is a terminal; it picks animated widgets
|
||||
// and boxed tables.
|
||||
tty bool
|
||||
// interactive is true when stdin and stdout are terminals.
|
||||
interactive bool
|
||||
color bool
|
||||
// inTTY is true when stdin is a terminal; Secret then reads a hidden
|
||||
// value instead of a line.
|
||||
inTTY bool
|
||||
// color is the environment's color policy (NO_COLOR, TERM=dumb,
|
||||
// FORCE_COLOR, terminal) for ColorAuto.
|
||||
color bool
|
||||
// globals are the parsed global options of the running command, set
|
||||
// by the root before the command runs.
|
||||
globals Globals
|
||||
palette Palette
|
||||
}
|
||||
|
||||
// NewOutput builds Output from injected streams and the process color policy.
|
||||
@@ -75,16 +83,17 @@ func newConsole(in io.Reader, out, errW io.Writer, lookup func(string) string, t
|
||||
if lookup == nil {
|
||||
lookup = os.Getenv
|
||||
}
|
||||
c := &console{in: in, out: out, err: errW, lookup: lookup}
|
||||
c := &console{in: in, out: out, err: errW, lookup: lookup, palette: DefaultPalette()}
|
||||
if tty != nil {
|
||||
c.tty = *tty
|
||||
} else {
|
||||
c.tty = isTerminal(out)
|
||||
}
|
||||
c.inTTY = isTerminal(in)
|
||||
if interactive != nil {
|
||||
c.interactive = *interactive
|
||||
} else {
|
||||
c.interactive = isTerminal(in) && c.tty
|
||||
c.interactive = c.inTTY && c.tty
|
||||
}
|
||||
c.color = supportsColor(lookup, c.tty)
|
||||
return c
|
||||
@@ -112,6 +121,67 @@ func isTerminal(rw any) bool {
|
||||
return term.IsTerminal(int(f.Fd()))
|
||||
}
|
||||
|
||||
// setGlobals records the parsed global options of the running command.
|
||||
func (c *console) setGlobals(g Globals) {
|
||||
c.globals = g
|
||||
}
|
||||
|
||||
// live reports whether widgets animate: stdout is a terminal and none of
|
||||
// -q, --no-ansi and -n is set (D-03). A live view that is already running
|
||||
// makes nested widgets print plain lines.
|
||||
func (c *console) live() bool {
|
||||
g := c.globals
|
||||
return c.tty && !g.Quiet && g.Color != ColorNever && !g.NoInteraction && !liveActive()
|
||||
}
|
||||
|
||||
// canPrompt reports whether prompts may wait for input from a person:
|
||||
// stdin and stdout are terminals and -n (or -q) is not set.
|
||||
func (c *console) canPrompt() bool {
|
||||
return c.interactive && !c.globals.NoInteraction
|
||||
}
|
||||
|
||||
// profile is the color profile of styled output: none for --no-ansi or
|
||||
// when the environment turns color off, at least 256 colors otherwise.
|
||||
func (c *console) profile() colorprofile.Profile {
|
||||
switch c.globals.Color {
|
||||
case ColorNever:
|
||||
return colorprofile.NoTTY
|
||||
case ColorAuto:
|
||||
if !c.color {
|
||||
return colorprofile.NoTTY
|
||||
}
|
||||
}
|
||||
return DetectProfile(c.out, ColorAlways, c.environ())
|
||||
}
|
||||
|
||||
// environ rebuilds the color-related environment from lookup.
|
||||
func (c *console) environ() []string {
|
||||
var env []string
|
||||
for _, k := range []string{"TERM", "COLORTERM", "NO_COLOR", "FORCE_COLOR", "CLICOLOR", "CLICOLOR_FORCE"} {
|
||||
if v := c.lookup(k); v != "" {
|
||||
env = append(env, k+"="+v)
|
||||
}
|
||||
}
|
||||
return env
|
||||
}
|
||||
|
||||
// styled wraps w so palette styles follow the color profile.
|
||||
func (c *console) styled(w io.Writer) io.Writer {
|
||||
return &colorprofile.Writer{Forward: w, Profile: c.profile()}
|
||||
}
|
||||
|
||||
// emit writes s, a styled line or block, to w. While a live view runs on
|
||||
// stdout, lines for stdout print above it instead of tearing it.
|
||||
func (c *console) emit(w io.Writer, s string) {
|
||||
if w == nil {
|
||||
return
|
||||
}
|
||||
if w == c.out && printAboveLines(s) {
|
||||
return
|
||||
}
|
||||
_, _ = io.WriteString(c.styled(w), s)
|
||||
}
|
||||
|
||||
func (c *console) Write(p []byte) (int, error) {
|
||||
if c.out == nil {
|
||||
return 0, io.ErrClosedPipe
|
||||
@@ -123,22 +193,38 @@ func (c *console) Println(a ...any) {
|
||||
if c.out == nil {
|
||||
return
|
||||
}
|
||||
fmt.Fprintln(c.out, a...)
|
||||
s := fmt.Sprintln(a...)
|
||||
if printAboveLines(s) {
|
||||
return
|
||||
}
|
||||
_, _ = io.WriteString(c.out, s)
|
||||
}
|
||||
|
||||
func (c *console) Printf(format string, a ...any) {
|
||||
if c.out == nil {
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(c.out, format, a...)
|
||||
s := fmt.Sprintf(format, a...)
|
||||
if strings.HasSuffix(s, "\n") && printAboveLines(s) {
|
||||
return
|
||||
}
|
||||
_, _ = io.WriteString(c.out, s)
|
||||
}
|
||||
|
||||
// status prints a status line unless -q is set.
|
||||
func (c *console) status(style lipgloss.Style, symbol, message string) {
|
||||
if c.globals.Quiet {
|
||||
return
|
||||
}
|
||||
c.emit(c.out, style.Render(symbol)+" "+message+"\n")
|
||||
}
|
||||
|
||||
func (c *console) Info(message string) {
|
||||
c.Println(c.style(ansiCyan, symInfo) + " " + message)
|
||||
c.status(c.palette.Current, symInfo, message)
|
||||
}
|
||||
|
||||
func (c *console) Success(message string) {
|
||||
c.Println(c.style(ansiGreen, symCheck) + " " + message)
|
||||
c.status(c.palette.Success, symCheck, message)
|
||||
}
|
||||
|
||||
func (c *console) Error(message string) {
|
||||
@@ -146,25 +232,11 @@ func (c *console) Error(message string) {
|
||||
if w == nil {
|
||||
w = c.out
|
||||
}
|
||||
if w == nil {
|
||||
return
|
||||
}
|
||||
fmt.Fprintln(w, c.style(ansiRed, symCross)+" "+message)
|
||||
c.emit(w, c.palette.Error.Render(symCross)+" "+message+"\n")
|
||||
}
|
||||
|
||||
func (c *console) Warning(message string) {
|
||||
c.Println(c.style(ansiYellow, symWarn) + " " + message)
|
||||
}
|
||||
|
||||
func (c *console) style(code, s string) string {
|
||||
if !c.color {
|
||||
return s
|
||||
}
|
||||
return code + s + ansiReset
|
||||
}
|
||||
|
||||
func (c *console) dim(s string) string {
|
||||
return c.style(ansiDim, s)
|
||||
c.status(c.palette.Warning, symWarn, message)
|
||||
}
|
||||
|
||||
func boolPtr(v bool) *bool { return &v }
|
||||
|
||||
@@ -2,16 +2,60 @@ package bonfire
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"charm.land/huh/v2"
|
||||
"golang.org/x/term"
|
||||
)
|
||||
|
||||
// ErrAborted is returned by a prompt that the person cancelled (esc or
|
||||
// ctrl+c on a terminal).
|
||||
var ErrAborted = errors.New("bonfire: prompt aborted")
|
||||
|
||||
// errSecretNoInteraction is the error of Secret under -n when stdin is a
|
||||
// terminal: a secret has no default.
|
||||
var errSecretNoInteraction = errors.New("bonfire: a secret is needed and -n turned prompts off")
|
||||
|
||||
// richPrompts reports whether prompts use huh and the selector: the run is
|
||||
// interactive and --no-ansi is not set (with --no-ansi a terminal gets the
|
||||
// plain line prompts).
|
||||
func (c *console) richPrompts() bool {
|
||||
return c.canPrompt() && c.globals.Color != ColorNever
|
||||
}
|
||||
|
||||
// runForm runs a huh form on the console's streams.
|
||||
func (c *console) runForm(field huh.Field) error {
|
||||
err := huh.NewForm(huh.NewGroup(field)).WithInput(c.in).WithOutput(c.out).Run()
|
||||
if errors.Is(err, huh.ErrUserAborted) {
|
||||
return ErrAborted
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *console) Ask(question, def string) (string, error) {
|
||||
if c.globals.NoInteraction {
|
||||
return def, nil
|
||||
}
|
||||
if c.richPrompts() {
|
||||
var answer string
|
||||
input := huh.NewInput().Title(question).Value(&answer)
|
||||
if def != "" {
|
||||
input = input.Placeholder(def)
|
||||
}
|
||||
if err := c.runForm(input); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if strings.TrimSpace(answer) == "" {
|
||||
return def, nil
|
||||
}
|
||||
return strings.TrimSpace(answer), nil
|
||||
}
|
||||
c.writeQuestion(question, def)
|
||||
line, err := c.readLine()
|
||||
if err != nil {
|
||||
@@ -27,9 +71,16 @@ func (c *console) Ask(question, def string) (string, error) {
|
||||
}
|
||||
|
||||
func (c *console) Confirm(question string, def bool) (bool, error) {
|
||||
if !c.interactive {
|
||||
if c.globals.NoInteraction || !c.interactive {
|
||||
return def, nil
|
||||
}
|
||||
if c.richPrompts() {
|
||||
answer := def
|
||||
if err := c.runForm(huh.NewConfirm().Title(question).Value(&answer)); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return answer, nil
|
||||
}
|
||||
hint := "y/N"
|
||||
if def {
|
||||
hint = "Y/n"
|
||||
@@ -38,9 +89,6 @@ func (c *console) Confirm(question string, def bool) (bool, error) {
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if line == "" {
|
||||
return def, nil
|
||||
}
|
||||
switch strings.ToLower(line) {
|
||||
case "y", "yes":
|
||||
return true, nil
|
||||
@@ -58,7 +106,13 @@ func (c *console) Choice(question string, choices []string, def int) (string, er
|
||||
if def < 0 || def >= len(choices) {
|
||||
def = 0
|
||||
}
|
||||
c.Println(c.style(ansiCyan, symPrompt) + " " + question)
|
||||
if c.globals.NoInteraction {
|
||||
return choices[def], nil
|
||||
}
|
||||
if c.richPrompts() {
|
||||
return c.selectOne(question, choices, def)
|
||||
}
|
||||
c.Println(c.styledText(c.palette.Current.Render(symPrompt)) + " " + question)
|
||||
for i, choice := range choices {
|
||||
mark := " "
|
||||
if i == def {
|
||||
@@ -83,11 +137,47 @@ func (c *console) Choice(question string, choices []string, def int) (string, er
|
||||
return choices[n-1], nil
|
||||
}
|
||||
|
||||
// Secret reads a hidden password on a TTY via term.ReadPassword.
|
||||
// When stdin is not a terminal it reads a plain line and never logs the value.
|
||||
// selectOne runs the ported selector on the terminal.
|
||||
func (c *console) selectOne(question string, choices []string, def int) (string, error) {
|
||||
prog := tea.NewProgram(newSelectorModel(c.palette, question, choices, def),
|
||||
tea.WithInput(c.in),
|
||||
tea.WithOutput(c.out),
|
||||
tea.WithoutSignalHandler(),
|
||||
tea.WithColorProfile(c.profile()),
|
||||
)
|
||||
final, err := prog.Run()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
m, ok := final.(selectorModel)
|
||||
if !ok || m.cancelled || m.chosen < 0 {
|
||||
return "", ErrAborted
|
||||
}
|
||||
return choices[m.chosen], nil
|
||||
}
|
||||
|
||||
// Secret reads a hidden value: through a password field on an interactive
|
||||
// terminal, through term.ReadPassword when only stdin is a terminal, and as
|
||||
// a plain line when stdin is not a terminal (piped input). Under -n a
|
||||
// terminal stdin fails with a hint, because a secret has no default.
|
||||
func (c *console) Secret(question string) (string, error) {
|
||||
if c.globals.NoInteraction {
|
||||
if c.inTTY {
|
||||
return "", WithHint(errSecretNoInteraction,
|
||||
"This command reads the value from a prompt or from stdin.",
|
||||
`printf '%s\n' "$VALUE" | {bin} <command> -n`)
|
||||
}
|
||||
return c.readSecretLine()
|
||||
}
|
||||
if c.richPrompts() {
|
||||
var answer string
|
||||
if err := c.runForm(huh.NewInput().Title(question).EchoMode(huh.EchoModePassword).Value(&answer)); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return answer, nil
|
||||
}
|
||||
c.writeQuestion(question, "")
|
||||
if f, ok := c.in.(*os.File); ok && term.IsTerminal(int(f.Fd())) {
|
||||
if f, ok := c.in.(*os.File); ok && c.inTTY {
|
||||
b, err := term.ReadPassword(int(f.Fd()))
|
||||
c.Println()
|
||||
if err != nil {
|
||||
@@ -95,6 +185,11 @@ func (c *console) Secret(question string) (string, error) {
|
||||
}
|
||||
return string(b), nil
|
||||
}
|
||||
return c.readSecretLine()
|
||||
}
|
||||
|
||||
// readSecretLine reads one line of piped input; the value is never logged.
|
||||
func (c *console) readSecretLine() (string, error) {
|
||||
line, err := c.readLine()
|
||||
if err != nil {
|
||||
if err == io.EOF {
|
||||
@@ -105,12 +200,19 @@ func (c *console) Secret(question string) (string, error) {
|
||||
return line, nil
|
||||
}
|
||||
|
||||
// styledText applies the color profile to s for output written with Printf.
|
||||
func (c *console) styledText(s string) string {
|
||||
var b strings.Builder
|
||||
_, _ = io.WriteString(c.styled(&b), s)
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func (c *console) writeQuestion(question, def string) {
|
||||
suffix := ""
|
||||
if def != "" {
|
||||
suffix = " " + c.dim("("+def+")")
|
||||
suffix = " " + c.palette.Muted.Render("("+def+")")
|
||||
}
|
||||
c.Printf("%s %s%s %s ", c.style(ansiCyan, symPrompt), question, suffix, symArrow)
|
||||
c.Printf("%s", c.styledText(c.palette.Current.Render(symPrompt)+" "+question+suffix+" "+symArrow+" "))
|
||||
}
|
||||
|
||||
func (c *console) readLine() (string, error) {
|
||||
|
||||
@@ -1,21 +1,70 @@
|
||||
package bonfire
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"slices"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// ErrDuplicateCommand is returned by NewRoot when two commands share a name,
|
||||
// or a command is named list or help (the root's own commands).
|
||||
var ErrDuplicateCommand = errors.New("bonfire: duplicate command name")
|
||||
|
||||
// ErrReservedFlag is returned by NewRoot when a command flag reuses the name
|
||||
// or shorthand of a global option (verbose, quiet, ansi, no-ansi,
|
||||
// no-interaction, env, help; -v, -q, -n, -h).
|
||||
var ErrReservedFlag = errors.New("bonfire: flag name is reserved for a global option")
|
||||
|
||||
// RootOption configures NewRoot and NewRootIO.
|
||||
type RootOption func(*rootConfig)
|
||||
|
||||
type rootConfig struct {
|
||||
version Version
|
||||
hasVersion bool
|
||||
hooks []func(Globals)
|
||||
}
|
||||
|
||||
// WithVersion sets the version shown in the header of the command list.
|
||||
// Without it the root reads BuildVersion.
|
||||
func WithVersion(v Version) RootOption {
|
||||
return func(c *rootConfig) {
|
||||
c.version = v
|
||||
c.hasVersion = true
|
||||
}
|
||||
}
|
||||
|
||||
// OnGlobals registers fn to run with the parsed global options before every
|
||||
// command of the root runs. The generated main uses it to re-apply the
|
||||
// options to diary once cobra has parsed them; roots built by Call never run
|
||||
// a hook, so a scheduled command cannot change process-wide settings.
|
||||
func OnGlobals(fn func(Globals)) RootOption {
|
||||
return func(c *rootConfig) {
|
||||
if fn != nil {
|
||||
c.hooks = append(c.hooks, fn)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// NewRoot builds the shared cobra root used by the summer tool and app binaries.
|
||||
// It accepts command values rather than a party registry to avoid an import cycle.
|
||||
func NewRoot(name string, commands []Command, out io.Writer) (*cobra.Command, error) {
|
||||
return NewRootIO(name, commands, os.Stdin, out, out)
|
||||
// When out is the process stdout, the error block of Execute goes to stderr.
|
||||
func NewRoot(name string, commands []Command, out io.Writer, opts ...RootOption) (*cobra.Command, error) {
|
||||
root, err := NewRootIO(name, commands, os.Stdin, out, out, opts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if out == nil || out == os.Stdout {
|
||||
root.SetErr(os.Stderr)
|
||||
}
|
||||
return root, nil
|
||||
}
|
||||
|
||||
// NewRootIO is NewRoot with injected stdin/stdout/stderr for tests.
|
||||
func NewRootIO(name string, commands []Command, in io.Reader, out, errW io.Writer) (*cobra.Command, error) {
|
||||
func NewRootIO(name string, commands []Command, in io.Reader, out, errW io.Writer, opts ...RootOption) (*cobra.Command, error) {
|
||||
if out == nil {
|
||||
out = os.Stdout
|
||||
}
|
||||
@@ -25,7 +74,16 @@ func NewRootIO(name string, commands []Command, in io.Reader, out, errW io.Write
|
||||
if in == nil {
|
||||
in = os.Stdin
|
||||
}
|
||||
output := NewOutput(in, out, errW)
|
||||
cfg := &rootConfig{}
|
||||
for _, opt := range opts {
|
||||
if opt != nil {
|
||||
opt(cfg)
|
||||
}
|
||||
}
|
||||
if !cfg.hasVersion {
|
||||
cfg.version = BuildVersion()
|
||||
}
|
||||
output := newConsole(in, out, errW, os.Getenv, nil, nil)
|
||||
root := &cobra.Command{
|
||||
Use: name,
|
||||
SilenceUsage: true,
|
||||
@@ -37,25 +95,122 @@ func NewRootIO(name string, commands []Command, in io.Reader, out, errW io.Write
|
||||
root.SetIn(in)
|
||||
root.SetOut(out)
|
||||
root.SetErr(errW)
|
||||
flags := &globalFlags{}
|
||||
flags.register(root.PersistentFlags())
|
||||
root.SetFlagErrorFunc(func(c *cobra.Command, err error) error {
|
||||
return usageFor(c, root, err)
|
||||
})
|
||||
|
||||
st := &rootState{
|
||||
root: root,
|
||||
name: name,
|
||||
commands: commands,
|
||||
version: cfg.version,
|
||||
out: out,
|
||||
console: output,
|
||||
flags: flags,
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, b := range builtinCommands() {
|
||||
seen[b.Name] = true
|
||||
}
|
||||
for _, command := range commands {
|
||||
cmd, err := wrap(command, output)
|
||||
if seen[command.Name] {
|
||||
return nil, fmt.Errorf("%w: %q", ErrDuplicateCommand, command.Name)
|
||||
}
|
||||
seen[command.Name] = true
|
||||
cmd, err := st.wrap(command, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
root.AddCommand(cmd)
|
||||
}
|
||||
|
||||
listCmd, err := st.wrap(st.listCommand(), false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
root.AddCommand(listCmd)
|
||||
helpCmd, err := st.wrap(st.helpCommand(), false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
root.SetHelpCommand(helpCmd)
|
||||
root.SetHelpFunc(func(*cobra.Command, []string) {
|
||||
_ = st.renderList(st.globals(), false)
|
||||
})
|
||||
root.RunE = func(cmd *cobra.Command, _ []string) error {
|
||||
return st.renderList(st.globals(), false)
|
||||
}
|
||||
root.PersistentPreRunE = func(cmd *cobra.Command, args []string) error {
|
||||
var g Globals
|
||||
if cmd.DisableFlagParsing {
|
||||
// Passthrough commands parse no flags; read the globals the
|
||||
// way the generated main did before boot.
|
||||
g = PeekGlobals(args)
|
||||
} else {
|
||||
g = flags.globals()
|
||||
}
|
||||
output.setGlobals(g)
|
||||
for _, hook := range cfg.hooks {
|
||||
hook(g)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return root, nil
|
||||
}
|
||||
|
||||
// rootState is what the list, help and error rendering of one root share.
|
||||
type rootState struct {
|
||||
root *cobra.Command
|
||||
name string
|
||||
commands []Command
|
||||
version Version
|
||||
out io.Writer
|
||||
console *console
|
||||
flags *globalFlags
|
||||
}
|
||||
|
||||
// globals returns the parsed global options of the current run.
|
||||
func (st *rootState) globals() Globals {
|
||||
return st.flags.globals()
|
||||
}
|
||||
|
||||
// wrap turns a command value into a cobra command. validate checks the
|
||||
// name form (the builtins list and help skip it).
|
||||
func (st *rootState) wrap(c Command, validate bool) (*cobra.Command, error) {
|
||||
cmd, err := wrapCommand(c, st.console, validate)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cmd.Hidden = c.Hidden
|
||||
cmd.SetHelpFunc(func(*cobra.Command, []string) {
|
||||
_ = st.renderHelp(c, st.globals())
|
||||
})
|
||||
return cmd, nil
|
||||
}
|
||||
|
||||
func wrap(c Command, out Output) (*cobra.Command, error) {
|
||||
if !validCommandName(c.Name) {
|
||||
return wrapCommand(c, out, true)
|
||||
}
|
||||
|
||||
func wrapCommand(c Command, out Output, validate bool) (*cobra.Command, error) {
|
||||
if validate && !validCommandName(c.Name) {
|
||||
return nil, fmt.Errorf("%w: %q", ErrCommandName, c.Name)
|
||||
}
|
||||
for _, flag := range c.Flags {
|
||||
if slices.Contains(reservedFlagNames, flag.Name) {
|
||||
return nil, fmt.Errorf("%w: command %q declares --%s", ErrReservedFlag, c.Name, flag.Name)
|
||||
}
|
||||
if flag.Shorthand != "" && slices.Contains(reservedFlagShorthands, flag.Shorthand) {
|
||||
return nil, fmt.Errorf("%w: command %q declares -%s for --%s", ErrReservedFlag, c.Name, flag.Shorthand, flag.Name)
|
||||
}
|
||||
}
|
||||
cmd := &cobra.Command{
|
||||
Use: commandUsage(c),
|
||||
Short: c.Description,
|
||||
Long: c.Long,
|
||||
Args: positionalArgs(c.Args),
|
||||
Args: usageArgs(positionalArgs(c.Args)),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if c.Run == nil {
|
||||
return nil
|
||||
@@ -98,3 +253,27 @@ func wrap(c Command, out Output) (*cobra.Command, error) {
|
||||
}
|
||||
return cmd, nil
|
||||
}
|
||||
|
||||
// usageArgs marks argument-count failures as usage errors (exit code 2).
|
||||
func usageArgs(fn cobra.PositionalArgs) cobra.PositionalArgs {
|
||||
return func(cmd *cobra.Command, args []string) error {
|
||||
return usageFor(cmd, cmd.Root(), fn(cmd, args))
|
||||
}
|
||||
}
|
||||
|
||||
// usageFor wraps err as a UsageError that remembers the command, so the
|
||||
// error block can suggest its help.
|
||||
func usageFor(cmd, root *cobra.Command, err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
var ue *UsageError
|
||||
if errors.As(err, &ue) {
|
||||
return err
|
||||
}
|
||||
ue = &UsageError{Err: err}
|
||||
if cmd != nil && cmd != root {
|
||||
ue.command = cmd.Name()
|
||||
}
|
||||
return ue
|
||||
}
|
||||
|
||||
140
modules/bonfire/selector.go
Normal file
140
modules/bonfire/selector.go
Normal file
@@ -0,0 +1,140 @@
|
||||
package bonfire
|
||||
|
||||
// A single-select port of SSU's selector (internal/cli/tui selector.go,
|
||||
// selector_keys.go and confirm.go) on bubbletea v2: arrow keys or k/j move,
|
||||
// enter selects and opens SSU's confirmation screen, enter there confirms
|
||||
// and esc goes back; esc or ctrl+c in the list cancels. Multi-select, the
|
||||
// filter and the detail pane are not ported (D-30).
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"charm.land/bubbles/v2/key"
|
||||
tea "charm.land/bubbletea/v2"
|
||||
)
|
||||
|
||||
// selectorKeys are the selector's key bindings.
|
||||
type selectorKeys struct {
|
||||
Up key.Binding
|
||||
Down key.Binding
|
||||
Confirm key.Binding
|
||||
Back key.Binding
|
||||
Quit key.Binding
|
||||
}
|
||||
|
||||
func defaultSelectorKeys() selectorKeys {
|
||||
return selectorKeys{
|
||||
Up: key.NewBinding(key.WithKeys("up", "k"), key.WithHelp("↑/k", "up")),
|
||||
Down: key.NewBinding(key.WithKeys("down", "j"), key.WithHelp("↓/j", "down")),
|
||||
Confirm: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "select")),
|
||||
Back: key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "back")),
|
||||
Quit: key.NewBinding(key.WithKeys("esc", "ctrl+c"), key.WithHelp("esc", "cancel")),
|
||||
}
|
||||
}
|
||||
|
||||
// selectorModel picks one item from a list and asks for confirmation.
|
||||
type selectorModel struct {
|
||||
palette Palette
|
||||
keys selectorKeys
|
||||
title string
|
||||
items []string
|
||||
cursor int
|
||||
height int
|
||||
confirming bool
|
||||
chosen int
|
||||
cancelled bool
|
||||
}
|
||||
|
||||
func newSelectorModel(p Palette, title string, items []string, def int) selectorModel {
|
||||
if def < 0 || def >= len(items) {
|
||||
def = 0
|
||||
}
|
||||
return selectorModel{palette: p, keys: defaultSelectorKeys(), title: title, items: items, cursor: def, chosen: -1}
|
||||
}
|
||||
|
||||
func (m selectorModel) Init() tea.Cmd { return nil }
|
||||
|
||||
func (m selectorModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.WindowSizeMsg:
|
||||
m.height = msg.Height
|
||||
return m, nil
|
||||
case tea.KeyPressMsg:
|
||||
if msg.String() == "ctrl+c" {
|
||||
m.cancelled = true
|
||||
return m, tea.Quit
|
||||
}
|
||||
if m.confirming {
|
||||
switch {
|
||||
case key.Matches(msg, m.keys.Confirm):
|
||||
m.chosen = m.cursor
|
||||
return m, tea.Quit
|
||||
case key.Matches(msg, m.keys.Back):
|
||||
m.confirming = false
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
switch {
|
||||
case key.Matches(msg, m.keys.Up):
|
||||
if m.cursor > 0 {
|
||||
m.cursor--
|
||||
}
|
||||
case key.Matches(msg, m.keys.Down):
|
||||
if m.cursor < len(m.items)-1 {
|
||||
m.cursor++
|
||||
}
|
||||
case key.Matches(msg, m.keys.Confirm):
|
||||
if len(m.items) > 0 {
|
||||
m.confirming = true
|
||||
}
|
||||
case key.Matches(msg, m.keys.Quit):
|
||||
m.cancelled = true
|
||||
return m, tea.Quit
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// visible is the range of items that fits the terminal around the cursor.
|
||||
func (m selectorModel) visible() (int, int) {
|
||||
rows := len(m.items)
|
||||
if m.height > 0 {
|
||||
rows = min(rows, max(m.height-4, 3))
|
||||
}
|
||||
start := 0
|
||||
if m.cursor >= rows {
|
||||
start = m.cursor - rows + 1
|
||||
}
|
||||
return start, min(start+rows, len(m.items))
|
||||
}
|
||||
|
||||
func (m selectorModel) View() tea.View {
|
||||
p := m.palette
|
||||
var b strings.Builder
|
||||
if m.chosen >= 0 || m.cancelled {
|
||||
// Leave one summary line once the program ends.
|
||||
b.WriteString(p.Current.Render(symPrompt) + " " + m.title)
|
||||
if m.chosen >= 0 {
|
||||
b.WriteString(" " + p.Success.Render(m.items[m.chosen]))
|
||||
}
|
||||
b.WriteString("\n")
|
||||
return tea.NewView(b.String())
|
||||
}
|
||||
if m.confirming {
|
||||
b.WriteString(p.Header.Render("Selected:") + "\n\n")
|
||||
b.WriteString(" " + m.items[m.cursor] + "\n\n")
|
||||
b.WriteString(p.Muted.Render("enter confirm • esc back"))
|
||||
return tea.NewView(b.String())
|
||||
}
|
||||
b.WriteString(p.Current.Render(symPrompt) + " " + p.Header.Render(m.title) + "\n")
|
||||
start, end := m.visible()
|
||||
for i := start; i < end; i++ {
|
||||
if i == m.cursor {
|
||||
b.WriteString(p.Current.Render("> ") + m.items[i] + "\n")
|
||||
continue
|
||||
}
|
||||
b.WriteString(" " + m.items[i] + "\n")
|
||||
}
|
||||
b.WriteString(p.Muted.Render("↑/k up • ↓/j down • enter select • esc cancel"))
|
||||
return tea.NewView(b.String())
|
||||
}
|
||||
100
modules/bonfire/styles.go
Normal file
100
modules/bonfire/styles.go
Normal file
@@ -0,0 +1,100 @@
|
||||
package bonfire
|
||||
|
||||
import (
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"charm.land/lipgloss/v2"
|
||||
"github.com/charmbracelet/colorprofile"
|
||||
)
|
||||
|
||||
// ColorMode selects how console output decides on color: from the terminal
|
||||
// and the environment, always, or never.
|
||||
type ColorMode int
|
||||
|
||||
const (
|
||||
// ColorAuto colors output when the writer is a terminal and the
|
||||
// environment allows it (NO_COLOR and TERM=dumb disable color,
|
||||
// FORCE_COLOR enables it on any writer).
|
||||
ColorAuto ColorMode = iota
|
||||
// ColorAlways colors output whatever the writer is (--ansi).
|
||||
ColorAlways
|
||||
// ColorNever writes no escape codes at all (--no-ansi).
|
||||
ColorNever
|
||||
)
|
||||
|
||||
// Palette is the shared console look: the status styles of list, help,
|
||||
// widgets and log lines. Render through a writer that applies the color
|
||||
// profile (DetectProfile), because lipgloss always emits ANSI codes.
|
||||
type Palette struct {
|
||||
// Success marks completed work (green).
|
||||
Success lipgloss.Style
|
||||
// Current marks the item in progress and informational lines (cyan).
|
||||
Current lipgloss.Style
|
||||
// Warning marks warnings (yellow).
|
||||
Warning lipgloss.Style
|
||||
// Error marks failures (red).
|
||||
Error lipgloss.Style
|
||||
// ErrorBold marks fatal failures and error levels (bold red).
|
||||
ErrorBold lipgloss.Style
|
||||
// Muted marks secondary detail such as times and keys (gray).
|
||||
Muted lipgloss.Style
|
||||
// Header marks headings and names (bold).
|
||||
Header lipgloss.Style
|
||||
}
|
||||
|
||||
// DefaultPalette returns the console palette, ported from SSU's status
|
||||
// styles: green success, cyan current, yellow warning, red and bold red
|
||||
// errors, gray (240) muted text and bold headers.
|
||||
func DefaultPalette() Palette {
|
||||
return Palette{
|
||||
Success: lipgloss.NewStyle().Foreground(lipgloss.Color("2")),
|
||||
Current: lipgloss.NewStyle().Foreground(lipgloss.Color("6")),
|
||||
Warning: lipgloss.NewStyle().Foreground(lipgloss.Color("3")),
|
||||
Error: lipgloss.NewStyle().Foreground(lipgloss.Color("1")),
|
||||
ErrorBold: lipgloss.NewStyle().Foreground(lipgloss.Color("1")).Bold(true),
|
||||
Muted: lipgloss.NewStyle().Foreground(lipgloss.Color("240")),
|
||||
Header: lipgloss.NewStyle().Bold(true),
|
||||
}
|
||||
}
|
||||
|
||||
// DetectProfile returns the color profile for console output written to w.
|
||||
// ColorNever gives colorprofile.NoTTY (no escape codes at all). ColorAlways
|
||||
// gives the detected profile raised to at least 256 colors. ColorAuto treats
|
||||
// a non-empty FORCE_COLOR as ColorAlways unless NO_COLOR is set or TERM is
|
||||
// dumb, and otherwise detects the profile from w and environ, which honours
|
||||
// NO_COLOR and TERM=dumb. A nil environ means os.Environ().
|
||||
func DetectProfile(w io.Writer, mode ColorMode, environ []string) colorprofile.Profile {
|
||||
if mode == ColorNever {
|
||||
return colorprofile.NoTTY
|
||||
}
|
||||
if environ == nil {
|
||||
environ = os.Environ()
|
||||
}
|
||||
if mode == ColorAuto && forceColor(environ) {
|
||||
mode = ColorAlways
|
||||
}
|
||||
p := colorprofile.Detect(w, environ)
|
||||
if mode == ColorAlways && p < colorprofile.ANSI256 {
|
||||
p = colorprofile.ANSI256
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func forceColor(environ []string) bool {
|
||||
if envValue(environ, "NO_COLOR") != "" || envValue(environ, "TERM") == "dumb" {
|
||||
return false
|
||||
}
|
||||
return envValue(environ, "FORCE_COLOR") != ""
|
||||
}
|
||||
|
||||
func envValue(environ []string, key string) string {
|
||||
for i := len(environ) - 1; i >= 0; i-- {
|
||||
k, v, ok := strings.Cut(environ[i], "=")
|
||||
if ok && k == key {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
73
modules/bonfire/version.go
Normal file
73
modules/bonfire/version.go
Normal file
@@ -0,0 +1,73 @@
|
||||
package bonfire
|
||||
|
||||
import (
|
||||
"runtime/debug"
|
||||
)
|
||||
|
||||
// frameworkModule is the module path of the SummerCMS framework.
|
||||
const frameworkModule = "git.golem15.com/golem15/summercms"
|
||||
|
||||
// Version is the version line of a binary: the application's own version and
|
||||
// the framework version it was built against.
|
||||
type Version struct {
|
||||
// App is the application module version ("devel" for an untagged
|
||||
// local build).
|
||||
App string
|
||||
// Framework is the framework module version; it equals App when the
|
||||
// binary is the framework's own summer tool.
|
||||
Framework string
|
||||
// Local is true when the framework comes from a replace directive
|
||||
// (a local checkout) rather than the module proxy.
|
||||
Local bool
|
||||
}
|
||||
|
||||
// BuildVersion reads the versions from the binary's build information
|
||||
// (runtime/debug.ReadBuildInfo): App is the main module version and Framework
|
||||
// the version of the git.golem15.com/golem15/summercms dependency, or App
|
||||
// when the main module is the framework itself. "(devel)" reads as "devel".
|
||||
func BuildVersion() Version {
|
||||
info, ok := debug.ReadBuildInfo()
|
||||
if !ok {
|
||||
return Version{App: "devel", Framework: "devel"}
|
||||
}
|
||||
v := Version{App: cleanVersion(info.Main.Version)}
|
||||
if info.Main.Path == frameworkModule {
|
||||
v.Framework = v.App
|
||||
return v
|
||||
}
|
||||
v.Framework = "devel"
|
||||
for _, dep := range info.Deps {
|
||||
if dep.Path != frameworkModule {
|
||||
continue
|
||||
}
|
||||
v.Framework = cleanVersion(dep.Version)
|
||||
v.Local = dep.Replace != nil
|
||||
break
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func cleanVersion(s string) string {
|
||||
if s == "" || s == "(devel)" {
|
||||
return "devel"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// Header is the first line of the command list: "SummerCMS acme v1.4.0
|
||||
// (framework v0.3.2)", with ", local" added inside the parentheses when the
|
||||
// framework is a local checkout, and "SummerCMS summer v0.3.2" when App and
|
||||
// Framework are the same build.
|
||||
func (v Version) Header(name string) string {
|
||||
app := cleanVersion(v.App)
|
||||
line := "SummerCMS " + name + " " + app
|
||||
fw := v.Framework
|
||||
if fw == "" || (fw == v.App && !v.Local) {
|
||||
return line
|
||||
}
|
||||
fw = "framework " + cleanVersion(fw)
|
||||
if v.Local {
|
||||
fw += ", local"
|
||||
}
|
||||
return line + " (" + fw + ")"
|
||||
}
|
||||
@@ -1,85 +1,60 @@
|
||||
package bonfire
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"context"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"charm.land/lipgloss/v2"
|
||||
"charm.land/lipgloss/v2/table"
|
||||
)
|
||||
|
||||
var spinnerFrames = []rune{'⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'}
|
||||
|
||||
const (
|
||||
boxTopLeft = "┌"
|
||||
boxTopRight = "┐"
|
||||
boxBottomLeft = "└"
|
||||
boxBottomRight = "┘"
|
||||
boxHorizontal = "─"
|
||||
boxVertical = "│"
|
||||
boxTeeRight = "├"
|
||||
boxTeeLeft = "┤"
|
||||
boxTeeDown = "┬"
|
||||
boxTeeUp = "┴"
|
||||
boxCross = "┼"
|
||||
)
|
||||
// ItemProgress is the optional side of a Progress that knows about items:
|
||||
// Current names the item being worked on, Fail records a failed item. The
|
||||
// Progress handed to Output.Progress implements it; Current and Failed call
|
||||
// it when present.
|
||||
type ItemProgress interface {
|
||||
Progress
|
||||
Current(item string)
|
||||
Fail(item string, err error)
|
||||
}
|
||||
|
||||
func (c *console) Spinner(message string, fn func() error) error {
|
||||
if fn == nil {
|
||||
fn = func() error { return nil }
|
||||
}
|
||||
if !c.tty {
|
||||
if c.globals.Quiet {
|
||||
return fn()
|
||||
}
|
||||
if !c.live() {
|
||||
c.Printf("[...] %s\n", message)
|
||||
return fn()
|
||||
}
|
||||
|
||||
done := make(chan struct{})
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
i := 0
|
||||
ticker := time.NewTicker(80 * time.Millisecond)
|
||||
defer ticker.Stop()
|
||||
c.Printf("\r%s %s", c.style(ansiCyan, string(spinnerFrames[0])), message)
|
||||
for {
|
||||
select {
|
||||
case <-done:
|
||||
return
|
||||
case <-ticker.C:
|
||||
i++
|
||||
frame := spinnerFrames[i%len(spinnerFrames)]
|
||||
c.Printf("\r%s %s", c.style(ansiCyan, string(frame)), message)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
err := fn()
|
||||
close(done)
|
||||
wg.Wait()
|
||||
if err != nil {
|
||||
c.Printf("\r%s %s\n", c.style(ansiRed, symCross), message)
|
||||
return err
|
||||
}
|
||||
c.Printf("\r%s %s\n", c.style(ansiGreen, symCheck), message)
|
||||
return nil
|
||||
return runLive(context.Background(), c.out, c, newSpinnerModel(c.palette, message), func(func(tea.Msg)) error {
|
||||
return fn()
|
||||
})
|
||||
}
|
||||
|
||||
func (c *console) Progress(total int, fn func(Progress) error) error {
|
||||
if total < 1 {
|
||||
total = 1
|
||||
}
|
||||
bar := &progressBar{c: c, total: total, lastPct: -1}
|
||||
if fn == nil {
|
||||
return nil
|
||||
}
|
||||
err := fn(bar)
|
||||
if c.tty && bar.current >= bar.total {
|
||||
c.Println()
|
||||
if c.globals.Quiet {
|
||||
return fn(&quietProgress{c: c})
|
||||
}
|
||||
return err
|
||||
if !c.live() {
|
||||
return fn(&progressBar{c: c, total: total, lastPct: -1})
|
||||
}
|
||||
return runLive(context.Background(), c.out, c, newProgressModel(c.palette, total), func(send func(tea.Msg)) error {
|
||||
return fn(&liveProgress{send: send, total: total})
|
||||
})
|
||||
}
|
||||
|
||||
// progressBar prints a line at every tenth of the work when widgets do not
|
||||
// animate.
|
||||
type progressBar struct {
|
||||
c *console
|
||||
total int
|
||||
@@ -99,10 +74,6 @@ func (p *progressBar) Advance(step int) {
|
||||
if p.total > 0 {
|
||||
pct = p.current * 100 / p.total
|
||||
}
|
||||
if p.c.tty {
|
||||
p.renderTTY(pct)
|
||||
return
|
||||
}
|
||||
if p.lastPct >= 0 && pct/10 == p.lastPct/10 && pct != 100 {
|
||||
return
|
||||
}
|
||||
@@ -116,29 +87,56 @@ func (p *progressBar) Advance(step int) {
|
||||
}
|
||||
}
|
||||
|
||||
func (p *progressBar) renderTTY(pct int) {
|
||||
width := 40
|
||||
filled := 0
|
||||
if p.total > 0 {
|
||||
filled = p.current * width / p.total
|
||||
}
|
||||
if filled > width {
|
||||
filled = width
|
||||
}
|
||||
bar := strings.Repeat("█", filled) + strings.Repeat(" ", width-filled)
|
||||
color := progressColor(pct)
|
||||
p.c.Printf("\r %s %5.1f%% %d/%d", p.c.style(color, bar), float64(pct), p.current, p.total)
|
||||
func (p *progressBar) Current(string) {}
|
||||
|
||||
func (p *progressBar) Fail(item string, err error) {
|
||||
p.c.Error(failureLine(item, err))
|
||||
}
|
||||
|
||||
func progressColor(pct int) string {
|
||||
switch {
|
||||
case pct >= 100:
|
||||
return "\x1b[38;5;34m"
|
||||
case pct >= 50:
|
||||
return "\x1b[38;5;36m"
|
||||
default:
|
||||
return "\x1b[38;5;37m"
|
||||
// quietProgress prints nothing but failures (-q).
|
||||
type quietProgress struct {
|
||||
c *console
|
||||
}
|
||||
|
||||
func (p *quietProgress) Advance(int) {}
|
||||
|
||||
func (p *quietProgress) Current(string) {}
|
||||
|
||||
func (p *quietProgress) Fail(item string, err error) {
|
||||
p.c.Error(failureLine(item, err))
|
||||
}
|
||||
|
||||
// liveProgress drives the live progress model.
|
||||
type liveProgress struct {
|
||||
send func(tea.Msg)
|
||||
total int
|
||||
current int
|
||||
item string
|
||||
}
|
||||
|
||||
func (p *liveProgress) Advance(step int) {
|
||||
if step < 1 {
|
||||
step = 1
|
||||
}
|
||||
p.current = min(p.current+step, p.total)
|
||||
p.send(itemMsg{done: p.current, total: p.total, item: p.item})
|
||||
}
|
||||
|
||||
func (p *liveProgress) Current(item string) {
|
||||
p.item = item
|
||||
p.send(itemMsg{done: p.current, total: p.total, item: item})
|
||||
}
|
||||
|
||||
func (p *liveProgress) Fail(item string, err error) {
|
||||
p.send(itemMsg{done: p.current, total: p.total, item: item, err: err})
|
||||
p.send(printAboveMsg(failureLine(item, err)))
|
||||
}
|
||||
|
||||
func failureLine(item string, err error) string {
|
||||
if err == nil {
|
||||
return item
|
||||
}
|
||||
return item + ": " + err.Error()
|
||||
}
|
||||
|
||||
func (c *console) Table(headers []string, rows [][]string) {
|
||||
@@ -149,27 +147,22 @@ func (c *console) Table(headers []string, rows [][]string) {
|
||||
}
|
||||
return
|
||||
}
|
||||
cols := len(headers)
|
||||
all := make([][]string, 0, 1+len(rows))
|
||||
all = append(all, headers)
|
||||
padded := make([][]string, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
all = append(all, padRow(row, cols))
|
||||
padded = append(padded, padRow(row, len(headers)))
|
||||
}
|
||||
widths := make([]int, cols)
|
||||
for _, row := range all {
|
||||
for i, cell := range row {
|
||||
if n := utf8.RuneCountInString(cell); n > widths[i] {
|
||||
widths[i] = n
|
||||
header := c.palette.Header
|
||||
t := table.New().
|
||||
Border(lipgloss.NormalBorder()).
|
||||
Headers(headers...).
|
||||
Rows(padded...).
|
||||
StyleFunc(func(row, _ int) lipgloss.Style {
|
||||
if row == table.HeaderRow {
|
||||
return header.Padding(0, 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
c.Println(boxLine(widths, boxTopLeft, boxTeeDown, boxTopRight))
|
||||
c.Println(boxRow(headers, widths, true, c))
|
||||
c.Println(boxLine(widths, boxTeeRight, boxCross, boxTeeLeft))
|
||||
for _, row := range rows {
|
||||
c.Println(boxRow(padRow(row, cols), widths, false, c))
|
||||
}
|
||||
c.Println(boxLine(widths, boxBottomLeft, boxTeeUp, boxBottomRight))
|
||||
return lipgloss.NewStyle().Padding(0, 1)
|
||||
})
|
||||
c.emit(c.out, t.String()+"\n")
|
||||
}
|
||||
|
||||
func padRow(row []string, n int) []string {
|
||||
@@ -181,30 +174,3 @@ func padRow(row []string, n int) []string {
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func boxLine(widths []int, left, mid, right string) string {
|
||||
var b strings.Builder
|
||||
b.WriteString(left)
|
||||
for i, w := range widths {
|
||||
if i > 0 {
|
||||
b.WriteString(mid)
|
||||
}
|
||||
b.WriteString(strings.Repeat(boxHorizontal, w+2))
|
||||
}
|
||||
b.WriteString(right)
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func boxRow(cells []string, widths []int, header bool, c *console) string {
|
||||
var b strings.Builder
|
||||
b.WriteString(boxVertical)
|
||||
for i, cell := range cells {
|
||||
pad := widths[i] - utf8.RuneCountInString(cell)
|
||||
text := cell
|
||||
if header {
|
||||
text = c.style("\x1b[1m", cell)
|
||||
}
|
||||
fmt.Fprintf(&b, " %s%s %s", text, strings.Repeat(" ", pad), boxVertical)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user