Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ee5b8844bf | ||
|
|
1e9927fa4b | ||
|
|
6267edb7c5 | ||
|
|
eaeb2347c0 | ||
|
|
4141cd916f | ||
|
|
b1fba7e24c | ||
|
|
139f5e3942 | ||
|
|
f5e9ee508b | ||
|
|
a48afbd11e | ||
|
|
0691d26f1f | ||
|
|
57a849d114 | ||
|
|
17c722da46 | ||
|
|
707372bf65 | ||
|
|
fce232d7c3 | ||
|
|
1ac75ad59a | ||
|
|
48e13f9b57 | ||
|
|
7c1509581b | ||
|
|
a28957f7d0 | ||
|
|
36e2240891 | ||
|
|
9516a2b36f | ||
|
|
9563bc836a | ||
|
|
28ad561481 | ||
|
|
0ebed687a0 | ||
|
|
349e2f810b |
@@ -47,5 +47,6 @@ A new phase, inserted or added, gets its task when it is added to `ROADMAP.md`.
|
||||
| 14.1 | v0j5lwvq | 31 | hgol7otp |
|
||||
| | | 19.4.1 | g02hcroj |
|
||||
| | | 19.6.1 | hioyvbxa |
|
||||
| | | 19.8 | t9k2efpy |
|
||||
|
||||
Backlog phase 999.1 (newsletter) has no task until it is promoted into the roadmap.
|
||||
|
||||
@@ -1470,7 +1470,7 @@ Plans:
|
||||
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:** 5/6 plans executed
|
||||
**Plans:** 6/6 plans executed
|
||||
|
||||
Plans:
|
||||
**Wave 1**
|
||||
@@ -1485,7 +1485,7 @@ Plans:
|
||||
**Wave 4** *(blocked on Wave 3 completion)*
|
||||
- [x] 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
|
||||
- [x] 19.8-05-PLAN.md — gap closure: fixes for the code review's critical and warning findings (live-view output order, summer delegates' exit codes and terminal, Ctrl+C cursor, dev shutdown grace, diary file retry, escape stripping, migration sets), each with a pinning test
|
||||
- [ ] 19.8-06-PLAN.md — gap closure: v0.4.2 release go-ahead, tag and changelog, demo roadmap sync, full gate
|
||||
- [x] 19.8-06-PLAN.md — gap closure: v0.4.2 release go-ahead, tag and changelog, demo roadmap sync, full gate
|
||||
|
||||
### Phase 20: Demo instance with every shared plugin
|
||||
|
||||
@@ -1590,7 +1590,7 @@ Phases execute in numeric order: 1 → 2 → 3 → 4 → 5 → 6 → 7 → 8 →
|
||||
| 19.6. Two-factor auth and device flow in sm-user-plugin | 0/TBD | Not started | - |
|
||||
| 19.6.1. Backend Administrators screen (INSERTED) | 0/TBD | Not started | - |
|
||||
| 19.7. Generic MCP module | 0/TBD | Not started | - |
|
||||
| 19.8. Console commands overhaul (INSERTED) | 5/6 | In Progress| |
|
||||
| 19.8. Console commands overhaul (INSERTED) | 6/6 | 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 | - |
|
||||
|
||||
81
.planning/debug/user-logout-session-stays-valid.md
Normal file
81
.planning/debug/user-logout-session-stays-valid.md
Normal file
@@ -0,0 +1,81 @@
|
||||
---
|
||||
status: awaiting_human_verify
|
||||
trigger: "User plugin logout: auth cookies not invalidated at once; requests keep authenticating ~60 min until token expiry (todo 2026-10-22-user-logout-cookie-stays-valid)"
|
||||
created: 2026-10-22T00:00:00Z
|
||||
updated: 2026-10-22T00:00:00Z
|
||||
---
|
||||
|
||||
## Current Focus
|
||||
|
||||
bug_class: Bohrbug (deterministic)
|
||||
reasoning_checkpoint:
|
||||
hypothesis: "Logout reads only the bearer header (cookie sessions get 401, nothing revoked, cookie kept, jwt guard accepts the cookie until exp = 60 min), never writes a forget cookie, and blacklists the jti only until the access exp (so /refresh revives a logged-out token after exp + sweep)"
|
||||
confirming_evidence:
|
||||
- "probe: cookie-only logout 401, then GET /tokens with the cookie 200"
|
||||
- "probe: jwt_blacklist.expires_at == exp; after the sweep, /refresh of the logged-out token 200"
|
||||
- "bearer logout already refuses the bearer at once (guard checks blacklist) - so server-side check exists; the gap is what logout revokes and for how long"
|
||||
falsification_test: "if cookie-only logout after the fix still leaves GET /tokens 200, or /refresh after the sweep still 200, hypothesis/fix wrong"
|
||||
fix_rationale: "read the token exactly as the jwt guard does, revoke it (and any other session token the request carries) until the refresh window closes, accept an expired-but-refreshable token like cabana does, and expire the session cookies with auth.cookie attributes"
|
||||
blind_spots: "demo frontend never calls /logout (app-side, not fixable here); auth_token cookie is frontend-owned, its real attributes unknown (forget uses auth.cookie path/domain)"
|
||||
candidate_causes:
|
||||
- "code: Logout bearer-only extraction + exp-only blacklist expiry"
|
||||
- "config: blacklist store not published (no DB) -> no revocation; ruled out, Boot publishes PostgresBlacklist when *sql.DB/*gorm.DB exist and bearer logout works"
|
||||
- "environment/frontend: demo frontend logout only clears its own cookie and never calls /logout"
|
||||
and_gate: "no for the cookie case (extraction alone suffices); the refresh revival is an independent second defect"
|
||||
next_action: human verification in a real app (cookie-only logout, then a jwt.auth request with the old cookie must be 401); after confirmation archive to resolved/ and add the KB entry
|
||||
|
||||
## Symptoms
|
||||
|
||||
expected: after POST /_user/api/v1/logout the old token/cookie is refused (401) on the next request, and the cookies are cleared
|
||||
actual: requests keep authenticating for about 60 minutes, until the token expires
|
||||
errors: none
|
||||
reproduction: login, logout, repeat an authenticated request with the old cookie or token
|
||||
started: reported 2026-10-22
|
||||
|
||||
## Eliminated
|
||||
|
||||
## Evidence
|
||||
|
||||
- timestamp: 2026-10-22
|
||||
checked: bouncer jwtGuard.Authenticate (summercms.go modules/bouncer/jwt.go)
|
||||
found: access JWTs are checked for HS256 signature, exp, audience, the subject user (GormUsers.FindByID), the jti blacklist (BlacklistStore) and users.tokens_valid_after; token taken from bearer first, then cookies "token", "auth_token" (plugin.go Boot)
|
||||
implication: server-side revocation exists (jwt_blacklist); a blacklisted jti is refused on the next request
|
||||
- timestamp: 2026-10-22
|
||||
checked: controllers.Logout (api_controller.go)
|
||||
found: reads bearerToken(r) only (Authorization header); no cookie fallback; never writes a Set-Cookie; blacklists with Add(jti, exp, now) i.e. storage expiry = access exp
|
||||
implication: cookie-only logout => 401, nothing revoked, cookie kept; blacklist row is dropped after exp although the refresh window (20160 min) is still open
|
||||
- timestamp: 2026-10-22
|
||||
checked: sociallogin writeSession (token_cookie: true) and bouncer.CookieConfig.TokenCookie
|
||||
found: sets httpOnly `token` cookie (auth.cookie.* name/path/domain/samesite, Max-Age = refresh_ttl) and leaves token out of the body, so the browser cannot send a bearer header
|
||||
implication: such a frontend can only log out with the cookie => H1 path
|
||||
- timestamp: 2026-10-22
|
||||
checked: bouncer.refreshAudience comment and blacklist storage expiry
|
||||
found: Refresh stores the old jti until max(exp, iat+refreshTTL)+1min precisely so "a logged-out token cannot be refreshed again"; Logout does not
|
||||
implication: H2 plausible
|
||||
- timestamp: 2026-10-22
|
||||
checked: PHP ApiController::logout + AuthManager::logout(true)
|
||||
found: token from Bearer or jwt_token param; JWTAuth::invalidate(forceForever=true) => blacklisted forever; body {"message":"Logged out"}
|
||||
implication: Go logout should keep the jti blocked at least for the refresh window
|
||||
|
||||
- timestamp: 2026-10-22
|
||||
checked: cabana admin logout (summercms.go modules/cabana/auth.go logout)
|
||||
found: framework reference: always expires the cookie, reads bearer then cookie, VerifyRefreshableClaimsAudience, blacklists until max(exp, iat+refreshTTL)+1min
|
||||
implication: the plugin's logout lags the framework's own pattern
|
||||
- timestamp: 2026-10-22
|
||||
checked: Docker probe through the assembled router (newAdminEnv), pre-fix
|
||||
found: (A) cookie-only POST /logout -> 401 {"error":true,"message":"Unauthorized"}, no Set-Cookie; GET /tokens (jwt.auth) with the same cookie afterwards -> 200; token exp-iat = 1h0m0s. (B) bearer logout -> 200 and the bearer is refused at once (401), but jwt_blacklist.expires_at == token exp; the sweep after exp deletes the row and POST /refresh with the logged-out token -> 200 with a fresh token
|
||||
implication: H1 and H2 CONFIRMED. New test logout_test.go fails 7/8 subtests pre-fix (bearer passes)
|
||||
|
||||
## Resolution
|
||||
|
||||
root_cause: "controllers.Logout read the token only from the Authorization bearer header, although the jwt guard (and RequestUser) also accept the token/auth_token cookies: a cookie-carried session (e.g. the httpOnly token cookie sociallogin token_cookie sets) got 401 at /logout, nothing was blacklisted and no cookie was cleared, so the cookie kept authenticating jwt.auth routes until exp (golem15.user.jwt.ttl, 60 min). Logout also never wrote a forget cookie; and it (and EndRequestSession) kept the jwt_blacklist row only until the access exp, so after the sweep /refresh revived a logged-out token for the rest of the 20160-min refresh window."
|
||||
fix: "classes.EndSession (new export): revokes every session JWT the request carries (bearer, token, auth_token) that verifies - unexpired, or user-audience within its refresh window - until max(exp, iat+refresh_ttl)+1m; always writes the auth.cookie forget cookie plus forgets for other carried session cookies with the same attributes; returns the guard-order subject. Logout uses it (responses unchanged: 200 Logged out / 401 Unauthorized / 500); EndRequestSession is now its best-effort wrapper."
|
||||
verification:
|
||||
target_test: { result: pass, test: "logout_test.go TestLogoutEndsTheSession (10 subtests); TestEndRequestSession updated" }
|
||||
mutation_check: { result: pass, reason_if_skipped: "no Stryker for Go - manual mutants", mutant_killed: "7/7 (expiry-at-exp, bearer-only, no-forget, no-refreshable-verify, primary-only revoke, config-cookie-only forget, no legacy fallback)" }
|
||||
no_op_deletion: { result: pass, deletion_justified_by_rca: true }
|
||||
adjacent_tests: { result: pass, suites_run: ["sm-user-plugin go test ./... (Docker, all ok)", "sm-demo-app workspace: oauthserver + sociallogin go test (only TestClientRegistry/verify_secret and TestPHPMethodMap fail: they read ../summercms.go and ../fonoteka.go paths that exist only in the plugins' own repo layout; unrelated to the change)"] }
|
||||
revert_and_reconfirm: { result: pass, bug_returned_on_revert: true, fixed_on_reapply: true }
|
||||
guardrail_verdict: accepted
|
||||
commit: 358ee5d (sm-user-plugin, unpushed)
|
||||
files_changed: [classes/session_user.go, controllers/api_controller.go, session_user_test.go, logout_test.go, README.md]
|
||||
@@ -3,13 +3,44 @@ phase: 19.8-console-commands-overhaul
|
||||
plan: 06
|
||||
subsystem: release
|
||||
tags: [release, v0.4.2, changelog, demo-roadmap, gate]
|
||||
status: draft
|
||||
status: complete
|
||||
gap_closure: true
|
||||
requires:
|
||||
- phase: 19.8-05
|
||||
provides: the eleven review fixes and the Release impact table
|
||||
provides:
|
||||
- "SummerCMS v0.4.2: annotated tag 60b13ae on ead8e42, on origin with master fast-forwarded to it"
|
||||
- "Changelog post https://summercms.io/changelog/summercms-0-4-2 (id 37)"
|
||||
- "sm-demo-app roadmap snapshot synced to origin/master ead8e42 (aa4de22, pushed)"
|
||||
- "Phase 19.8 --all gate green with Docker required"
|
||||
affects: [applications and plugins pinning SummerCMS (fixes available from v0.4.2), demo roadmap deck]
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns: []
|
||||
key-files:
|
||||
created: []
|
||||
modified:
|
||||
- .planning/phases/19.8-console-commands-overhaul/19.8-06-SUMMARY.md
|
||||
- ../sm-demo-app/plugins/demo/content/ROADMAP.md
|
||||
key-decisions:
|
||||
- "Reply 'release' read as the proposed tag v0.4.2"
|
||||
- "Tagged the commit that records the go-ahead (ead8e42, planning-only on top of the proposal) after vet, short tests and the docs checker passed there"
|
||||
- "No plugin require bumps or application regenerate commits: no row of plan 05's Release impact table asks for one"
|
||||
- "No Polish name needed: the roadmap change adds no phase (19.6.1 already had one, 762bb90)"
|
||||
requirements-completed: [D-26]
|
||||
actuals:
|
||||
tokens: 4000 # chars/4 over the realized diff (this SUMMARY and the 6-line roadmap snapshot change)
|
||||
tasks: 3
|
||||
commits: 2 # MEASURED: git rev-list --count plan_head_before..HEAD before the final docs commit
|
||||
plan_head_before: 8020b177ffc70f1b980ae769b80f55598998ce77
|
||||
plan_head_after: ead8e422113d43c9ee828a92a628783634f65294
|
||||
duration: 35min
|
||||
completed: 2026-10-22
|
||||
---
|
||||
|
||||
# Phase 19.8 Plan 06: Release of the Review Fixes Summary (draft)
|
||||
# Phase 19.8 Plan 06: Release of the Review Fixes Summary
|
||||
|
||||
**Draft. Task 1 (the release go-ahead) is waiting for the user's reply. Nothing has been pushed or tagged.**
|
||||
**SummerCMS v0.4.2 is tagged and on origin, with its changelog post at https://summercms.io/changelog/summercms-0-4-2. The tag is annotated, on ead8e42, and master was fast-forwarded to exactly that commit. The demo roadmap snapshot follows the published roadmap, and the phase 19.8 `--all` gate is green with Docker required. Nothing was deployed or force-pushed.**
|
||||
|
||||
## Release proposal
|
||||
|
||||
@@ -90,3 +121,109 @@ The user's reply, verbatim:
|
||||
> release
|
||||
|
||||
Read as the recommended option with the proposed tag: **release v0.4.2** (RELEASE_TAG=v0.4.2). The reply authorises an annotated tag v0.4.2 on the tested commit, a fast-forward push of exactly that commit to origin master followed by the tag, the same-day changelog post, the sm-demo-app roadmap snapshot commit and its push (when it is the only unpushed commit there), then the `--all` gate. No force push, no deploy.
|
||||
|
||||
The reply was recorded in `ead8e42 docs(19.8-06): record the go-ahead: release v0.4.2` before anything was tagged or pushed. That commit is the one tagged.
|
||||
|
||||
## Task 2: published
|
||||
|
||||
### Range check after a fresh fetch
|
||||
|
||||
- `git fetch origin`: exit 0, no output. origin/master was still `6c2164a`, so no other session had pushed.
|
||||
- Newest tag: still **v0.4.1**, and origin had only v0.4.0 and v0.4.1 in `v0.4.*`. The proposed tag was still correct.
|
||||
- `git log --format='%h %s' origin/master..ead8e42`: **20 commits**. These are the 18 classified in the proposal, plus `cf06185` (the proposal, which touches only `19.8-06-SUMMARY.md`) and `ead8e42` (the reply, the same file). Class totals: 12 plan 05 code commits, 8 planning documents of this phase, **0 planning documents of other sessions, 0 other code**.
|
||||
- `git merge-base --is-ancestor origin/master ead8e42`: the push is a fast-forward.
|
||||
|
||||
### Checks at the tested commit `ead8e42`
|
||||
|
||||
GOCACHE pointed at the session scratchpad (tmpfs) for every go command. `go clean` was not run.
|
||||
|
||||
| Check | Result |
|
||||
|-------|--------|
|
||||
| `go vet ./...` | PASS |
|
||||
| `go test -count=1 -short ./...` | PASS (exit 0, every package ok) |
|
||||
| `go run ./cmd/summer docs:build --check` | PASS ("docs:build: no problems found") |
|
||||
|
||||
### Tag
|
||||
|
||||
- `git tag -a v0.4.2 ead8e42`: tag object **`60b13aee5dca32b7202e35f92e605652f66b8a97`**, `git cat-file -t v0.4.2` = `tag`, `v0.4.2^{commit}` = `ead8e422113d43c9ee828a92a628783634f65294`.
|
||||
- Message: `SummerCMS v0.4.2`, then "Fixes from the review of the 0.4 console.", then a Fixed list of nine bullets in user words and an Added list (`bonfire.WithExitCode`, `bonfire.Sanitize`). It has no finding IDs and no planning or quick codes.
|
||||
|
||||
### Pushed ranges
|
||||
|
||||
| Repository | Push | Range |
|
||||
|------------|------|-------|
|
||||
| summercms (framework) | `git push origin ead8e42…:refs/heads/master` | `6c2164a..ead8e42` (fast-forward, 20 commits) |
|
||||
| summercms (framework) | `git push origin v0.4.2` | new tag v0.4.2. `git ls-remote --exit-code --tags origin v0.4.2` → `60b13ae… refs/tags/v0.4.2` |
|
||||
| sm-demo-app | `git push origin HEAD:refs/heads/master` | `762bb90..aa4de22` (1 commit, see Task 3) |
|
||||
|
||||
The Task 2 verify command (`ls-remote` the tag, fetch, the tagged commit is an ancestor of origin/master, the tag is annotated) passed.
|
||||
|
||||
### Changelog
|
||||
|
||||
- `scripts/changelog-post.py source v0.4.2` (from ../sm-summercmsio-app): TAG v0.4.2, DATE 2026-10-22T02:54:37+02:00, PREVIOUS v0.4.1, and 21 commit subjects.
|
||||
- The notes file is in the session scratchpad (`v0.4.2-notes.md`). It has a one-line intro, `## Fixed` (nine bullets) and `## Added` (the two new functions). It has no H1, no planning IDs and no quick codes.
|
||||
- `scripts/changelog-post.py publish v0.4.2 <notes> --excerpt "Console fixes for 0.4: ordered output under spinners, terminal restored on Ctrl+C, exit codes passed through by summer."` answered `posted id=37 https://summercms.io/changelog/summercms-0-4-2 (published_at 2026-10-22T00:54:37+00:00)`.
|
||||
- **Post URL: https://summercms.io/changelog/summercms-0-4-2** (HTTP 200 right after publishing). The script read the token file itself. The token was never printed, copied or minted.
|
||||
|
||||
### Plugins and applications
|
||||
|
||||
Plugins and applications: nothing to publish. No row of plan 05's Release impact table asks for a require bump or a regenerate. The `--all` gate's plugins and apps stages confirm that every shared plugin and application still builds against the local framework, and that `summer build` leaves the generated main.go and plugins.gen.go unchanged.
|
||||
|
||||
## Task 3: demo roadmap and the gate
|
||||
|
||||
### Roadmap snapshot
|
||||
|
||||
- After the push, `ROADMAP_REF=origin/master ../sm-demo-app/scripts/sync-roadmap.sh --check` failed as expected: the snapshot differed from origin/master. This is 19.8's own plan list and progress.
|
||||
- sm-demo-app before the change: `git status --porcelain` was empty and `origin/master..master` was empty after a fetch. Its HEAD was `762bb90 feat(demo): Polish name for roadmap phase 19.6.1`, already pushed by another session.
|
||||
- `ROADMAP_REF=origin/master scripts/sync-roadmap.sh` wrote the snapshot from origin/master (ead8e42): `plugins/demo/content/ROADMAP.md`, 4 insertions and 2 deletions.
|
||||
- TestRoadmapParse, TestRoadmapDeckOrder, TestRoadmapNoClientNames and TestRoadmapPolishTitles: PASS. No new phase, so **no Polish-name commit was needed**.
|
||||
- Commit **`aa4de22 chore: sync the ROADMAP.md snapshot (summercms ead8e42)`**. It touches only `plugins/demo/content/ROADMAP.md` and was the only unpushed commit. It was pushed `762bb90..aa4de22`, and `origin/master..master` is now empty. Nothing was deployed.
|
||||
|
||||
### `--all` gate
|
||||
|
||||
`PHASE198_REQUIRE_DOCKER=1 TESTCONTAINERS_HOST_OVERRIDE=127.0.0.1 bash scripts/check-phase19.8.sh --all` ran with GOCACHE and TMPDIR in the session scratchpad and Docker reached directly (no `sudo -u golem`). Exit 0. The landing pre-check `sync-roadmap --check` printed ok.
|
||||
|
||||
| Stage | Result | Notes |
|
||||
|-------|--------|-------|
|
||||
| self-test | PASS | |
|
||||
| go | PASS | vet, short and the named tests: diary 30, sunscreen 2, bonfire 42, docsite 2, conga 5, lagoon 7, surf 13, flare 1, dev 4, cmd/summer 6. Race stage over diary, sunscreen, bonfire and docsite |
|
||||
| hello | PASS | vet, 13 tests, then 5 named tests |
|
||||
| docs | PASS | TestDocsTree; docs:build reported no problems; 67 files contain no client name; all 35 summer commands have examples |
|
||||
| plugins | PASS | 15 shared plugins |
|
||||
| apps | PASS | 5 applications (sm-demo-app, sm-bm-app, sm-grzybyfunkcjonalne-app, sm-summercmsio-app, fonoteka.go): build, generated files unchanged, `list` and `help migrate` exit 0 |
|
||||
| landing | PASS | nuxt generate, tap 38/38, 4 terminal tests. **NOT RUN TestTerminalCommands** (allowed: needs network, Docker and ports; `SUMMERCMS_TERMINAL_CHECK=1`). 4 roadmap tests; "roadmap: snapshot matches origin/master" |
|
||||
| docker | PASS | TestServeLifecycle; 6 lagoon tests including TestMigrateAppliedLines; 3 cabana tests |
|
||||
|
||||
Final line: **`Phase 19.8 gate passed`**. No line starts with `FAIL` or `NOT RUN docker`. The full log is `check-phase19.8-06.log` in the session scratchpad.
|
||||
|
||||
### Suggested g15office comment (for the orchestrator)
|
||||
|
||||
> Phase 19.8 review fixes released as SummerCMS v0.4.2 (changelog: https://summercms.io/changelog/summercms-0-4-2); phase gate green; ready for /msd-verify-work
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
**1. [Rule 3 - Blocking] Build cache on tmpfs.** `/home` is at 99% (5.1G free) and `/media/nvme` is at 99%. Every go command, including the gate, ran with `GOCACHE` (and, for the gate, `TMPDIR`) in the session scratchpad on tmpfs. The user did not ask for space to be freed, so `go clean` was not run.
|
||||
|
||||
**2. Tagged commit.** The plan tags "master HEAD". HEAD at release time was `ead8e42`, the planning-only commit that records the reply. It was committed before the checks, so the checks ran on exactly the commit that was tagged and pushed.
|
||||
|
||||
Otherwise the plan was executed as written.
|
||||
|
||||
## Known Stubs
|
||||
|
||||
None.
|
||||
|
||||
## Threat Flags
|
||||
|
||||
None. The pushes, the tag, the post and the demo snapshot are the surfaces in the plan's threat register. T-19.8-34 to T-19.8-37 were mitigated as planned:
|
||||
- a fetch and range classification came before the push
|
||||
- the push was a fast-forward of exactly the tested, tagged commit
|
||||
- the token stayed in its file
|
||||
- TestRoadmapNoClientNames passed before the snapshot commit
|
||||
|
||||
## Final docs commit
|
||||
|
||||
This SUMMARY's completion commit is **local only**. The plan does not push it, and it is not part of v0.4.2.
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
Found: cf06185, ead8e42 (local and origin/master), tag object 60b13ae (local and on origin as refs/tags/v0.4.2), sm-demo-app aa4de22 (on origin/master), changelog post (HTTP 200).
|
||||
|
||||
@@ -3,6 +3,54 @@ phase: 19.8
|
||||
review: 19.8-REVIEW.md
|
||||
titles: json
|
||||
findings:
|
||||
- id: WR-09
|
||||
severity: warning
|
||||
disposition: fixed
|
||||
title: "Lines printed through a live view after its context is cancelled are silently dropped"
|
||||
- id: WR-10
|
||||
severity: warning
|
||||
disposition: fixed
|
||||
title: "A delegate killed by a signal prints its full argv, including a --password value, and exits 1"
|
||||
- id: WR-11
|
||||
severity: warning
|
||||
disposition: fixed
|
||||
title: "The wrong-directory hint leaves secret values that start with \"-\" unmasked"
|
||||
- id: WR-12
|
||||
severity: warning
|
||||
disposition: fixed
|
||||
title: "migrate:status --json still drops the framework sets that migrate applies"
|
||||
- id: WR-13
|
||||
severity: warning
|
||||
disposition: fixed
|
||||
title: "migrate reports failure after a successful Migrate when the follow-up read fails"
|
||||
- id: WR-14
|
||||
severity: warning
|
||||
disposition: fixed
|
||||
title: "Process's plain path prints item names without Sanitize"
|
||||
- id: IN-10
|
||||
severity: info
|
||||
disposition: deferred
|
||||
title: "console.Error during a live view writes straight to the terminal and tears the view"
|
||||
- id: IN-11
|
||||
severity: info
|
||||
disposition: deferred
|
||||
title: "A blank line printed during a live view is dropped"
|
||||
- id: IN-12
|
||||
severity: info
|
||||
disposition: deferred
|
||||
title: "Signal relay edge cases: a signal during view start-up, and Windows"
|
||||
- id: IN-13
|
||||
severity: info
|
||||
disposition: deferred
|
||||
title: "A delegate's stderr always goes to os.Stderr, bypassing the root's error writer"
|
||||
- id: IN-14
|
||||
severity: info
|
||||
disposition: fixed
|
||||
title: "The ErrReported doc comment still says the exit code is 1"
|
||||
- id: IN-15
|
||||
severity: info
|
||||
disposition: fixed
|
||||
title: "runLive builds the bubbletea program before it knows it may run"
|
||||
- id: CR-01
|
||||
severity: critical
|
||||
disposition: fixed
|
||||
@@ -84,34 +132,46 @@ findings:
|
||||
disposition: deferred
|
||||
title: "The phase gate names consuming applications"
|
||||
open: 0
|
||||
total: 20
|
||||
recorded: 2026-10-21T22:35:50.040Z
|
||||
total: 32
|
||||
recorded: 2026-10-22T01:19:33.337Z
|
||||
---
|
||||
|
||||
# Phase 19.8: Code Review Disposition
|
||||
|
||||
| Finding | Severity | Disposition | Source |
|
||||
|---------|----------|-------------|--------|
|
||||
| CR-01 | critical | fixed | fix b077dc3 (19.8-05) |
|
||||
| CR-02 | critical | fixed | fix 323413c (19.8-05) |
|
||||
| CR-03 | critical | fixed | fix 9c89705 (19.8-05) |
|
||||
| WR-01 | warning | fixed | fix d151a25 (19.8-05) |
|
||||
| WR-02 | warning | fixed | fix 86e4e27 (19.8-05) |
|
||||
| WR-03 | warning | fixed | fix fc7706d (19.8-05) |
|
||||
| WR-04 | warning | fixed | fix c586c6f (19.8-05) |
|
||||
| WR-05 | warning | fixed | fix a9025c0 (19.8-05) |
|
||||
| WR-06 | warning | fixed | fix 39677f1 (19.8-05) |
|
||||
| WR-07 | warning | fixed | fix 020fe83 (19.8-05) |
|
||||
| WR-08 | warning | fixed | fix 9b62285 (19.8-05) |
|
||||
| IN-01 | info | deferred | todo .planning/todos/pending/2026-10-22-console-review-info-findings.md |
|
||||
| IN-02 | info | deferred | todo .planning/todos/pending/2026-10-22-console-review-info-findings.md |
|
||||
| IN-03 | info | deferred | todo .planning/todos/pending/2026-10-22-console-review-info-findings.md |
|
||||
| IN-04 | info | deferred | todo .planning/todos/pending/2026-10-22-console-review-info-findings.md |
|
||||
| IN-05 | info | deferred | todo .planning/todos/pending/2026-10-22-console-review-info-findings.md |
|
||||
| IN-06 | info | deferred | todo .planning/todos/pending/2026-10-22-console-review-info-findings.md |
|
||||
| IN-07 | info | deferred | todo .planning/todos/pending/2026-10-22-console-review-info-findings.md |
|
||||
| IN-08 | info | deferred | todo .planning/todos/pending/2026-10-22-console-review-info-findings.md |
|
||||
| IN-09 | info | deferred | todo .planning/todos/pending/2026-10-22-console-review-info-findings.md |
|
||||
| WR-09 | warning | fixed | fix fce232d (quick 261022-4z8) |
|
||||
| WR-10 | warning | fixed | fix 17c722d (quick 261022-4z8); the exit-130 suggestion is deferred to todo .planning/todos/pending/2026-10-22-console-review-info-findings.md |
|
||||
| WR-11 | warning | fixed | fix 707372b (quick 261022-4z8) |
|
||||
| WR-12 | warning | fixed | fix 57a849d, reworked in 139f5e3 so that lagoon.Status stays plugin-only and migrate:status adds the framework sets itself (quick 261022-4z8) |
|
||||
| WR-13 | warning | fixed | fix 0691d26 (quick 261022-4z8) |
|
||||
| WR-14 | warning | fixed | fix 48e13f9 (quick 261022-4z8) |
|
||||
| IN-10 | info | deferred | todo .planning/todos/pending/2026-10-22-console-review-info-findings.md |
|
||||
| IN-11 | info | deferred | todo .planning/todos/pending/2026-10-22-console-review-info-findings.md |
|
||||
| IN-12 | info | deferred | todo .planning/todos/pending/2026-10-22-console-review-info-findings.md |
|
||||
| IN-13 | info | deferred | todo .planning/todos/pending/2026-10-22-console-review-info-findings.md |
|
||||
| IN-14 | info | fixed | docs 7c15095 (quick 261022-4z8) |
|
||||
| IN-15 | info | fixed | fix 1ac75ad (quick 261022-4z8) |
|
||||
| CR-01 | critical | fixed | fix b077dc3 (19.8-05) (not in the current review) |
|
||||
| CR-02 | critical | fixed | fix 323413c (19.8-05) (not in the current review) |
|
||||
| CR-03 | critical | fixed | fix 9c89705 (19.8-05) (not in the current review) |
|
||||
| WR-01 | warning | fixed | fix d151a25 (19.8-05) (not in the current review) |
|
||||
| WR-02 | warning | fixed | fix 86e4e27 (19.8-05) (not in the current review) |
|
||||
| WR-03 | warning | fixed | fix fc7706d (19.8-05) (not in the current review) |
|
||||
| WR-04 | warning | fixed | fix c586c6f (19.8-05) (not in the current review) |
|
||||
| WR-05 | warning | fixed | fix a9025c0 (19.8-05) (not in the current review); remainder WR-14 (fix 48e13f9) |
|
||||
| WR-06 | warning | fixed | fix 39677f1 (19.8-05) (not in the current review) |
|
||||
| WR-07 | warning | fixed | fix 020fe83 (19.8-05) (not in the current review); remainder WR-12 (fix 57a849d) |
|
||||
| WR-08 | warning | fixed | fix 9b62285 (19.8-05) (not in the current review); remainder WR-11 (fix 707372b) |
|
||||
| IN-01 | info | deferred | todo .planning/todos/pending/2026-10-22-console-review-info-findings.md (not in the current review) |
|
||||
| IN-02 | info | deferred | todo .planning/todos/pending/2026-10-22-console-review-info-findings.md (not in the current review) |
|
||||
| IN-03 | info | deferred | todo .planning/todos/pending/2026-10-22-console-review-info-findings.md (not in the current review) |
|
||||
| IN-04 | info | deferred | todo .planning/todos/pending/2026-10-22-console-review-info-findings.md (not in the current review) |
|
||||
| IN-05 | info | deferred | todo .planning/todos/pending/2026-10-22-console-review-info-findings.md (not in the current review) |
|
||||
| IN-06 | info | deferred | todo .planning/todos/pending/2026-10-22-console-review-info-findings.md (not in the current review) |
|
||||
| IN-07 | info | deferred | todo .planning/todos/pending/2026-10-22-console-review-info-findings.md (not in the current review) |
|
||||
| IN-08 | info | deferred | todo .planning/todos/pending/2026-10-22-console-review-info-findings.md (not in the current review) |
|
||||
| IN-09 | info | deferred | todo .planning/todos/pending/2026-10-22-console-review-info-findings.md (not in the current review) |
|
||||
|
||||
Dispositions: `open` (recorded, not yet triaged), `fixed`, `skipped`, `deferred`.
|
||||
Set `deferred` by hand and put the reason in the Source cell; both are preserved. A `|` in the reason is kept as prose and escaped on the next run.
|
||||
|
||||
@@ -1,336 +1,253 @@
|
||||
---
|
||||
phase: 19.8-console-commands-overhaul
|
||||
reviewed: 2026-10-21T22:33:36Z
|
||||
reviewed: 2026-10-22T01:17:37Z
|
||||
depth: standard
|
||||
diff_range: v0.3.1..v0.4.1 (19.5 commits and the cooler test commit excluded)
|
||||
files_reviewed: 93
|
||||
diff_range: dd12382..0ebed68 (gap-closure plan 19.8-05, released as v0.4.2)
|
||||
files_reviewed: 31
|
||||
files_reviewed_list:
|
||||
- cmd/summer/docs.go
|
||||
- cmd/summer/main.go
|
||||
- cmd/summer/parity.go
|
||||
- cmd/summer/runtime.go
|
||||
- docs/examples/blog/console/publish.go
|
||||
- examples/hello/main.go
|
||||
- examples/hello/plugins/greeter/plugin.go
|
||||
- internal/build/artifact.go
|
||||
- internal/build/build.go
|
||||
- internal/build/manifest.go
|
||||
- internal/build/new.go
|
||||
- internal/build/scaffold.go
|
||||
- cmd/summer/runtime_test.go
|
||||
- docs/console/introduction.md
|
||||
- docs/console/scaffolding.md
|
||||
- docs/console/writing-commands.md
|
||||
- docs/services/logging.md
|
||||
- docs/setup/upgrading.md
|
||||
- internal/dev/watch.go
|
||||
- internal/docsite/check_commands.go
|
||||
- modules/beachcomber/beachcomber.go
|
||||
- modules/bonfire/command.go
|
||||
- modules/bonfire/docs.go
|
||||
- internal/dev/watch_test.go
|
||||
- modules/bonfire/errors.go
|
||||
- modules/bonfire/errors_test.go
|
||||
- modules/bonfire/execute.go
|
||||
- modules/bonfire/globals.go
|
||||
- modules/bonfire/helpers.go
|
||||
- modules/bonfire/help.go
|
||||
- modules/bonfire/list.go
|
||||
- modules/bonfire/live.go
|
||||
- modules/bonfire/live_signal_unix_test.go
|
||||
- modules/bonfire/live_test.go
|
||||
- modules/bonfire/output.go
|
||||
- modules/bonfire/prompts.go
|
||||
- modules/bonfire/root.go
|
||||
- modules/bonfire/selector.go
|
||||
- modules/bonfire/styles.go
|
||||
- modules/bonfire/version.go
|
||||
- modules/bonfire/README.md
|
||||
- modules/bonfire/widgets.go
|
||||
- modules/bouncer/turnstile.go
|
||||
- modules/cabana/account.go
|
||||
- modules/cabana/account_preferences.go
|
||||
- modules/cabana/account_tokens.go
|
||||
- modules/cabana/actions.go
|
||||
- modules/cabana/api_tokens.go
|
||||
- modules/cabana/auth.go
|
||||
- modules/cabana/commands.go
|
||||
- modules/cabana/contracts.go
|
||||
- modules/cabana/crud.go
|
||||
- modules/cabana/field_dependent.go
|
||||
- modules/cabana/field_file.go
|
||||
- modules/cabana/field_type.go
|
||||
- modules/cabana/http.go
|
||||
- modules/cabana/log.go
|
||||
- modules/cabana/partial_render.go
|
||||
- modules/cabana/query.go
|
||||
- modules/cabana/relation_field.go
|
||||
- modules/cabana/reorder.go
|
||||
- modules/cabana/singleton.go
|
||||
- modules/cabana/token_commands.go
|
||||
- modules/cabana/token_guard.go
|
||||
- modules/conga/client.go
|
||||
- modules/conga/commands.go
|
||||
- modules/conga/conga.go
|
||||
- modules/conga/worker.go
|
||||
- modules/cooler/cooler.go
|
||||
- modules/cooler/memory.go
|
||||
- modules/cooler/ratelimit.go
|
||||
- modules/cooler/rediscache.go
|
||||
- modules/cooler/sitecache.go
|
||||
- modules/diary/channels.go
|
||||
- modules/diary/component.go
|
||||
- modules/diary/config.go
|
||||
- modules/bonfire/widgets_test.go
|
||||
- modules/diary/console.go
|
||||
- modules/diary/daily.go
|
||||
- modules/diary/daily_test.go
|
||||
- modules/diary/diary.go
|
||||
- modules/flare/commands.go
|
||||
- modules/flare/flare.go
|
||||
- modules/lagoon/attach/bucket.go
|
||||
- modules/lagoon/attach/thumb.go
|
||||
- modules/diary/README.md
|
||||
- modules/diary/retire_internal_test.go
|
||||
- modules/lagoon/commands.go
|
||||
- modules/lagoon/connection.go
|
||||
- modules/lagoon/encrypted.go
|
||||
- modules/lagoon/fill.go
|
||||
- modules/lagoon/keygen.go
|
||||
- modules/lagoon/migrations.go
|
||||
- modules/lagoon/purge.go
|
||||
- modules/lagoon/transaction.go
|
||||
- modules/lighthouse/centrifugo/client.go
|
||||
- modules/lighthouse/centrifugo/commands.go
|
||||
- modules/lighthouse/lighthouse.go
|
||||
- modules/phrasebook/translator.go
|
||||
- modules/postcard/drivers.go
|
||||
- modules/postcard/mailer.go
|
||||
- modules/sunscreen/sunscreen.go
|
||||
- modules/surf/accesslog.go
|
||||
- modules/surf/routelist_command.go
|
||||
- modules/surf/serve.go
|
||||
- modules/surf/summary.go
|
||||
- modules/wristband/oidc_token.go
|
||||
- modules/lagoon/pending_test.go
|
||||
- modules/lagoon/README.md
|
||||
- scripts/check-phase19.8.sh
|
||||
findings:
|
||||
critical: 3
|
||||
warning: 8
|
||||
info: 9
|
||||
total: 20
|
||||
critical: 0
|
||||
warning: 6
|
||||
info: 6
|
||||
total: 12
|
||||
status: issues_found
|
||||
---
|
||||
|
||||
# Phase 19.8: Code Review Report
|
||||
# Phase 19.8: Code Review Report (re-review after 19.8-05)
|
||||
|
||||
**Reviewed:** 2026-10-21T22:33:36Z
|
||||
**Reviewed:** 2026-10-22T01:17:37Z
|
||||
**Depth:** standard
|
||||
**Files Reviewed:** 93
|
||||
**Files Reviewed:** 31
|
||||
**Status:** issues_found
|
||||
|
||||
## Summary
|
||||
|
||||
I reviewed the phase's changed hunks in the 93 non-test source files (`git diff v0.3.1..v0.4.1`). I read the new modules (diary, and bonfire's globals, errors, execute, live, prompts, selector and list) in full. I checked the bubbletea v2.1.0, fang v2.0.1 and gorm v1.31.2 sources wherever bonfire, diary or lagoon depend on how they behave. I confirmed one finding at runtime: a scratch program under a pseudo-terminal (`script -qec`) shows that lines printed during a live view come out in the wrong order and are sometimes lost. Items already listed in `deferred-items.md` are not reported again.
|
||||
This is an incremental re-review of the gap-closure commits `dd12382..HEAD` (b077dc3 to 020fe83, plus the gate and docs commits). I read every listed file in full where it changed. I checked the fixes against the bubbletea v2.1.0 sources (`Program.Send`, `Kill`, `shutdown`, `eventLoop`, `cursedRenderer.insertAbove`) and against pflag v1.0.10. I also ran two scratch programs under a pseudo-terminal (`script -qec`).
|
||||
|
||||
The request log is clean. It logs the route pattern and never the path, query, headers or remote address. The GORM bridge logs SQL with placeholders. The startup summary shows the database only as host, port and name. The redaction wrapping sits on every diary leaf.
|
||||
Verification:
|
||||
|
||||
The main defects are in three places:
|
||||
- `go vet` is clean on the touched packages.
|
||||
- `go test -short -race` passes for bonfire and diary.
|
||||
- The new summer and dev tests (`TestDelegate*`, `TestAppStreams`, `TestDevStopWaitsForGracefulShutdown`) pass.
|
||||
|
||||
- **Printing above live views.** Lines printed while a spinner, progress bar or process view runs are reordered and sometimes dropped. Any console's output can be redirected to the terminal that owns the view.
|
||||
- **summer delegates.** Every `summer <app command>` merges the app's stderr into stdout and never gives the app a terminal. The production confirmation is skipped, `--json` output gets log lines mixed in, two error blocks are printed, and exit code 2 becomes 1.
|
||||
- **Process lifecycle.** Ctrl+C during a spinner leaves the cursor hidden. `summer dev` kills serve 8 seconds before serve's drain deadline. One failed open of a daily log file switches file logging off until the process exits.
|
||||
All three critical findings from the first review are fixed. So are WR-01 to WR-06. WR-05, WR-07 and WR-08 are fixed as described, but each leaves a gap of the same kind elsewhere, so they are only partly closed (WR-12, WR-14, WR-10 and WR-11 below).
|
||||
|
||||
## Critical Issues
|
||||
The fixes introduced no new critical defect. The main new problems are:
|
||||
|
||||
### CR-01: Lines printed above a live view are reordered and can be lost
|
||||
- **Lost lines after cancellation.** A live view whose context is cancelled stays registered, so lines printed through it are dropped while `printAbove` reports success. I confirmed this on a pty.
|
||||
- **Signal-killed delegates.** Since the CR-02 fix, only a signal-killed delegate gets summer's error block. That block repeats the full argv, including a `--password` value, and the exit code is 1.
|
||||
- **Migration accounting.** `migrate:status --json` still uses its own plugin-only list. `migrate` reports a failure when only the read after a successful `Migrate` fails.
|
||||
|
||||
**File:** `modules/bonfire/live.go:316-323` (with `printAboveLines` at 377-387 and `runLive` at 365-372)
|
||||
**Issue:** `printAboveLines` sends one `printAboveMsg` per line, and `liveModel.Update` answers each one with `tea.Println(...)`, which is a `tea.Cmd`. bubbletea v2 runs every Cmd in its own goroutine (`handleCommands`, tea.go:745-757), and that goroutine sends the resulting `printLineMessage` back into the event loop. Two consequences follow:
|
||||
New finding IDs continue after the first review's IDs (WR-09 and up, IN-10 and up). `19.8-REVIEW-DISPOSITION.md` drops a recorded decision when its ID is reused for a different finding, so the old IDs are not reused here.
|
||||
|
||||
1. **Order is not kept.** Successive lines race each other back into the event loop.
|
||||
2. **Lines can be dropped.** `runLive` sends `finishMsg` as soon as `work` returns, and `finishMsg` ends in `tea.Quit`. A print Cmd still in flight then reaches a loop that has already exited, and its line is never printed.
|
||||
## Prior Findings: Verified State
|
||||
|
||||
This affects every path that prints during a live view:
|
||||
| ID | Verified state | Evidence |
|
||||
|----|----------------|----------|
|
||||
| CR-01 | **Fixed** | `printAbove` sends one `printLineMessage` per call straight into the program's unbuffered queue while holding `liveMu` (live.go:477-487). The event loop handles it inline (`insertAbove`, bubbletea tea.go:888), so lines keep their order. `finishMsg` is sent under the same lock only after every earlier send has been received (live.go:387-393), so no line is lost when the view quits. On a pty the lines came out in order. `TestPrintAboveOrderedNoLoss` covers spinner, progress, process and a concurrent finish. A remaining gap after context cancellation is WR-09. |
|
||||
| CR-02 | **Fixed** | `runAppCmd` wraps an exited child as `Reported(WithExitCode(err, code))` (runtime.go:170-176), and `exitCode` honours the code (execute.go:76-85). `TestDelegateExitCodePassthrough` checks that the code is 2 and that summer prints no block of its own. The signal-killed branch has new problems, see WR-10. |
|
||||
| CR-03 | **Fixed** | `appStreams` gives the child `os.Stdout`/`os.Stderr` on a terminal and `out`/`os.Stderr` otherwise (runtime.go:155-160). The child's stderr no longer lands in summer's stdout, and on a terminal the child sees it and asks the production question. A minor gap is IN-13. |
|
||||
| WR-01 | **Fixed** | `watchSignals` relays SIGINT/SIGTERM, calls `prog.Kill()` (which restores the cursor) and re-raises the signal (live.go:410-466). `TestLiveViewRestoresTerminalOnSignal` covers both the default action and the command's own handler. Edge cases are in IN-12. |
|
||||
| WR-02 | **Fixed** | `activeView` records the owner, and `printAbove(c, …)` reroutes only the owner's output (live.go:477-481). The slot is claimed with check-and-set under `liveMu` (live.go:362-368), and cleanup is deferred, so it also runs on a panic (live.go:378-395). |
|
||||
| WR-03 | **Fixed** | `child.stop` waits `dev.ShutdownGrace` (15s), and summer's delegates use the same constant (watch.go:22-27, 243; runtime.go:185). |
|
||||
| WR-04 | **Fixed** | A failed open is retried after `dailyRetry` (1 minute) or on the next date, with one warning per date (daily.go:70-99). The test covers both paths. |
|
||||
| WR-05 | **Partly fixed** | Error blocks (every line, the Try lines and the causes), process-view names and errors, spinner and progress current items, failure lines and table cells now go through `Sanitize`. `Process`'s plain path still prints item names raw (WR-14). The deferred IN-08 (a raw C1 byte passes `Sanitize`'s fast path) now applies to every one of these sinks, not only diary. |
|
||||
| WR-06 | **Fixed** | Replaced writers are retired: a late record opens the file, appends and closes it again (daily.go:100-104, 166-174). `ApplyGlobals` holds `mu` for its whole run (diary.go:228-240). There is no reentrancy on `mu` from the handler path. |
|
||||
| WR-07 | **Partly fixed** | `Migrate` and `PendingMigrations` now share `migrationSets`, and the applied lines are computed from the pending list before and after the run. `Status` (used by `migrate:status`, and by its `--json` output) still walks only `plugins` (WR-12). A new error path is in WR-13. |
|
||||
| WR-08 | **Partly fixed** | `tryArgs` masks `--flag=value` and `--flag value` (errors.go:310-331). A value that starts with `-` is not masked, although pflag accepts it (WR-11). The same argv now leaks through the delegate's signal error (WR-10). |
|
||||
| IN-01..IN-09 | Deferred (todo `2026-10-22-console-review-info-findings.md`) | Not re-reported. IN-08 is now wider, as noted under WR-05. |
|
||||
|
||||
- `Output.Println` and `Printf`, which carry command data (tokens, JSON);
|
||||
- status lines sent through `emit`;
|
||||
- `liveProgress.Fail` failure lines (widgets.go:130-133), which are sent just before the work function returns;
|
||||
- every diary console record logged while a view runs (console.go:166).
|
||||
|
||||
**Runtime check:** a program printing `line-00` to `line-19` with `out.Println` inside `out.Spinner` on a pty printed them out of order in 5 of 5 runs (for example `line-01 line-00 line-02 line-04 line-03 …`) and lost a line in 1 of 40 runs.
|
||||
**Fix:** Deliver the lines through the event loop in order, and drain them before quitting. For example, buffer them in the model and flush them as a single ordered `tea.Sequence`; on finish, quit only after that sequence:
|
||||
```go
|
||||
type liveModel struct{ inner tea.Model }
|
||||
|
||||
func (m liveModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
if lines, ok := msg.(printAboveMsg); ok {
|
||||
// one message per PrintAbove call; tea.Sequence runs the prints in order
|
||||
var cmds []tea.Cmd
|
||||
for _, l := range strings.Split(string(lines), "\n") {
|
||||
cmds = append(cmds, tea.Println(l))
|
||||
}
|
||||
return m, tea.Sequence(cmds...)
|
||||
}
|
||||
inner, cmd := m.inner.Update(msg)
|
||||
m.inner = inner
|
||||
return m, cmd
|
||||
}
|
||||
```
|
||||
Ordering across separate PrintAbove calls, and draining before quit, still need one serial path. Either have `runLive` send `finishMsg` only after an acknowledgement message that is queued behind the pending prints, or keep a FIFO in the model and, on `finishMsg`, return `tea.Sequence(<pending prints>..., tea.Quit)` instead of `tea.Quit`. Send each PrintAbove call as one message (not one per line).
|
||||
Add a pty test that prints N lines during each widget and asserts all N arrive in order.
|
||||
|
||||
### CR-02: summer delegates print a second error block and turn exit code 2 into 1
|
||||
|
||||
**File:** `cmd/summer/runtime.go:146-150`
|
||||
**Issue:** When the app binary fails, `runAppBinary` returns `fmt.Errorf("summer: %s: %w", argv, *exec.ExitError)`. `bonfire.Execute` then:
|
||||
|
||||
1. renders a second ERROR block (`summer: migrate --bogus: exit status 2`) under the error block the app binary already printed;
|
||||
2. maps the error to `ExitFailure`, because `isUsage` does not match an `*exec.ExitError`.
|
||||
|
||||
So `summer migrate --bogus` exits 1, not 2. That breaks the D-18 exit-code contract for every delegate the docs present as the normal entry point ("summer fails when the app binary fails", runtime.go:93). Nothing in `cmd/summer/runtime_test.go` covers a failing child.
|
||||
**Fix:** Treat the child's failure as already reported and pass its exit code through. For example, add an exit-code carrier that `exitCode` honours:
|
||||
```go
|
||||
var ee *exec.ExitError
|
||||
if errors.As(err, &ee) {
|
||||
return bonfire.Reported(bonfire.WithExitCode(err, ee.ExitCode())) // new: exitCode() checks interface{ ExitCode() int }
|
||||
}
|
||||
```
|
||||
|
||||
### CR-03: summer delegates hide the terminal from the app and merge its stderr into stdout
|
||||
|
||||
**File:** `cmd/summer/runtime.go:163-168`
|
||||
**Issue:** `newAppCmd` sets both `cmd.Stdout` and `cmd.Stderr` to `out`. That is the bonfire console, not an `*os.File`, so the child gets a pipe on both streams, and every byte of the child's stderr lands on summer's stdout. Since this phase that has three effects:
|
||||
|
||||
1. **The production confirmation is never asked.** In the child, `Interactive()` needs stdout to be a terminal. `summer migrate`, `summer migrate:rollback`, `summer deferred:purge` and `summer queue:clear` run in production without the D-28 question, even when run in a terminal.
|
||||
2. **Data output gets log lines mixed in.** Records the child logs to stderr (diary records, error blocks) are mixed into its data on stdout. `summer route:list --json > routes.json` and `summer admin:token:create … --json` capture any INFO plugin boot record (deferred-items lists such records on every application command) or WARN line inside the JSON. `summer key:generate -q` captures any warning together with the key.
|
||||
3. **Styled output is lost.** `summer serve` and `summer queue:work` never print their styled summaries, and the delegate's widgets never animate.
|
||||
|
||||
`internal/dev/watch.go:81-88` already fixes the same problem for `summer dev` by passing `os.Stdout` and `os.Stderr` on a terminal. The delegates were not given the same fix.
|
||||
**Fix:** Give the child the process's own streams, and keep stderr separate:
|
||||
```go
|
||||
cmd.Stdout, cmd.Stderr = os.Stdout, os.Stderr
|
||||
if !bonfire.IsTerminal(out) { // tests and Call roots: keep the injected writer
|
||||
cmd.Stdout, cmd.Stderr = out, os.Stderr
|
||||
}
|
||||
```
|
||||
## Narrative Findings (AI reviewer)
|
||||
|
||||
## Warnings
|
||||
|
||||
### WR-01: Ctrl+C during a spinner, progress bar or process view leaves the cursor hidden
|
||||
### WR-09: Lines printed through a live view after its context is cancelled are silently dropped
|
||||
|
||||
**File:** `modules/bonfire/widgets.go:33,51`, `modules/bonfire/live.go:349-355`, `cmd/summer/main.go:28,76`, `internal/build/build.go:140`
|
||||
**Issue:** The live views run with `tea.WithoutSignalHandler()` and `tea.WithInput(nil)`, and `Spinner` and `Progress` pass `context.Background()`. Neither `summer` nor the generated main installs a signal handler (`bonfire.Execute(context.Background(), …)`), so Ctrl+C terminates the process with SIGINT's default action. bubbletea hides the cursor when it starts (`ESC[?25l`, confirmed in the pty run) and only shows it again on a clean exit. `summer build`, which always shows a spinner on a terminal, and every plugin command that uses `Progress` therefore leave the user's terminal without a cursor after Ctrl+C.
|
||||
**Fix:** Run `Execute` under `signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM)` and give the widgets the command's context (keep it on the console in `PersistentPreRunE`). Alternatively, have `runLive` register its own SIGINT handler that calls `prog.Kill()` (which restores the terminal) and then re-raises the signal.
|
||||
**File:** `modules/bonfire/live.go:354-360, 477-487` (with `modules/bonfire/helpers.go:150-170`)
|
||||
**Issue:** `runLive` passes the command's context to bubbletea (`tea.WithContext(ctx)`). `Process` passes the caller's `ctx`. When that context is cancelled, the program shuts down: `Program.Send` now returns at once because `p.ctx` is done (bubbletea tea.go:1211-1216). However, `activeView` still points at the view until `work` returns. Only the deferred cleanup or the signal relay clears it, and the relay runs only for SIGINT/SIGTERM. Meanwhile:
|
||||
|
||||
### WR-02: Any console's Println and Printf are sent to whichever live view is running
|
||||
- `printAbove` hands the line to a dead program and still returns `true`, so the caller never falls back to a plain write.
|
||||
- This applies to `out.Println`/`Printf` data, to Success/Info/Warning lines on stdout, and to every diary console record routed through `bonfire.PrintAbove`.
|
||||
- These lines are lost for as long as the current `fn(ctx, item)` keeps running after the cancellation.
|
||||
- Because the program was killed, the final frame and the failed rows are never rendered either.
|
||||
|
||||
**File:** `modules/bonfire/output.go:192-212`, `modules/bonfire/live.go:329-339,345-373`
|
||||
**Issue:** `activeProg` is a single process-wide value. `console.Println` and `Printf` call `printAboveLines` without checking that this console owns the view (`emit` checks only `w == c.out`). Any other console that prints while a view runs therefore has its data written to the terminal above that view. This includes a `bonfire.Call` root writing into a caller's `bytes.Buffer`, a nested command, or a console whose stdout is a file. For example, a `Process` loop that calls `bonfire.Call(..., &buf)` gets an empty buffer.
|
||||
**Runtime check:** a `Process` whose first item calls `cancel()`, waits 300 ms and then calls `out.Println("after-cancel-line")` and `bonfire.PrintAbove(...)` printed neither line in 3 of 3 pty runs. `PrintAbove` returned `true`. `out.Error`, which goes to stderr and so bypasses `printAbove`, did print.
|
||||
|
||||
Two more problems sit in the same code:
|
||||
|
||||
- **Check-then-act race.** `liveActive()` (in `console.live`) and the registration in `runLive` are separate steps. Two widgets started concurrently both start programs, and the first one to finish sets `activeProg = nil` while the other is still running.
|
||||
- **No cleanup on panic.** `runLive` unregisters without `defer`, so a panic in `work` leaves `activeProg` pointing at an orphaned program.
|
||||
|
||||
**Fix:** Store the owning console (or its `out` writer) together with the program, and reroute only when `c == owner`. Register under `liveMu` with a check-and-set that falls back to plain lines when a view is already active. Use `defer` for the unregister, `finishMsg` and wait steps.
|
||||
|
||||
### WR-03: summer dev kills serve 2 seconds after SIGTERM, but serve drains for up to 10
|
||||
|
||||
**File:** `internal/dev/watch.go:227-238`
|
||||
**Issue:** Since this phase `summer dev` runs `bin/<app> serve`, whose shutdown drains requests and the in-process worker for up to 10 seconds and then prints the shutdown report. Before, it ran the binary with no arguments. `child.stop` still sends SIGKILL after 2 seconds. A restart or Ctrl+C that arrives during a slow request or job therefore kills serve before its report is printed (as `summer dev`'s Long help promises). Jobs that are killed remain `running` in River until its stuck-job rescuer picks them up. `cmd/summer/runtime.go:155` (`appShutdownGrace = 15s`) is longer than serve's 10-second timeout for exactly this reason.
|
||||
**Fix:** Use the same grace in dev, for example `case <-time.After(appShutdownGrace):` with a shared constant longer than serve's 10-second shutdown context.
|
||||
|
||||
### WR-04: One failed open switches a daily log file off until the process exits
|
||||
|
||||
**File:** `modules/diary/daily.go:226-243`
|
||||
**Issue:** When `open` fails, `disabled = true` is set and never cleared. For a long-running `serve` or `queue:work`, a transient failure at midnight rotation (ENOSPC for a moment, EMFILE during a traffic spike, a directory briefly removed by logrotate or a backup tool) stops all file logging until the next restart. Only one WARN record is written, possibly to journald. The degrade-instead-of-fail rule (T-19.8-06) was meant to keep boot from failing. A server should not lose its WARN and ERROR file trail for days because of one transient failure.
|
||||
**Fix:** Disable the writer only for the date that failed, retrying on the next date (or after a backoff of about a minute), and log once per failed date:
|
||||
This is the CR-01 failure mode (a line reported as printed but never shown) on a new path. It hits any `Process` caller that uses a deadline, a parent cancel or a non-signal shutdown.
|
||||
**Fix:** Have `printAbove` refuse a program that has stopped, and unregister the view when the program ends on its own:
|
||||
```go
|
||||
if d.disabledDate == date { d.mu.Unlock(); return len(p), nil }
|
||||
if err := d.open(date); err != nil { d.disabledDate = date; … }
|
||||
type liveView struct {
|
||||
owner *console
|
||||
prog *tea.Program
|
||||
done <-chan struct{} // the `finished` channel of runLive
|
||||
}
|
||||
|
||||
func printAbove(owner *console, s string) bool {
|
||||
liveMu.Lock()
|
||||
defer liveMu.Unlock()
|
||||
v := activeView
|
||||
if v == nil || (owner != nil && v.owner != owner) {
|
||||
return false
|
||||
}
|
||||
select {
|
||||
case <-v.done: // program gone (ctx cancelled, Run failed): print plainly
|
||||
activeView = nil
|
||||
return false
|
||||
default:
|
||||
}
|
||||
v.prog.Send(printLine(strings.TrimSuffix(s, "\n")))
|
||||
return true
|
||||
}
|
||||
```
|
||||
`Send` can still race with a cancellation that lands between the check and the send. To close that window, also watch `ctx.Done()` in a goroutine that clears `activeView` under `liveMu`. Add a pty or buffer test that cancels the context mid-item and asserts that later lines appear.
|
||||
|
||||
### WR-10: A delegate killed by a signal prints its full argv, including a --password value, and exits 1
|
||||
|
||||
**File:** `cmd/summer/runtime.go:170-178`
|
||||
**Issue:** After the CR-02 fix, every child failure that is not a normal exit returns `fmt.Errorf("summer: %s: %w", strings.Join(argv, " "), err)` unmasked. This covers death by a signal (`exit.Exited()` false) and every `cmd.Start` error. `RenderError` passes the message only through `sunscreen.Scrub`, which recognises Bearer, `sk-` and `x-api-key` shapes, so `summer admin:create --email a@b.c --password hunter2` prints `ERROR summer: admin:create --email a@b.c --password hunter2: signal: interrupt` on stderr. It happens when the user presses Ctrl+C, when a CI timeout sends SIGKILL, or on an OOM kill.
|
||||
|
||||
This is the leak WR-08 fixed for the wrong-directory hint, on a path that the CR-02 fix now makes the only one that prints summer's own block.
|
||||
|
||||
In addition, Ctrl+C is the normal way to stop `summer serve` or `summer queue:work`. Since the WR-01 relay re-raises SIGINT, a child that dies by the signal (any delegate without its own handler, or one interrupted during a live view) now produces a spurious ERROR block and exit 1. Shells and CI expect 130 (128+SIGINT). The docs ("A binary killed by a signal gets an error block from summer and exit code 1") describe this behaviour but do not justify it.
|
||||
**Fix:** Mask the argv with the same helper the hint uses (export it, for example `bonfire.MaskArgs`), or name only the command word. Map signal deaths to 128+n, and treat SIGINT/SIGTERM as already reported:
|
||||
```go
|
||||
var exit *exec.ExitError
|
||||
if errors.As(err, &exit) {
|
||||
if ws, ok := exit.Sys().(syscall.WaitStatus); ok && ws.Signaled() {
|
||||
code := 128 + int(ws.Signal())
|
||||
if ws.Signal() == syscall.SIGINT || ws.Signal() == syscall.SIGTERM {
|
||||
return bonfire.Reported(bonfire.WithExitCode(err, code))
|
||||
}
|
||||
return bonfire.WithExitCode(fmt.Errorf("summer: %s: %w", argv[0], err), code)
|
||||
}
|
||||
…
|
||||
}
|
||||
```
|
||||
|
||||
### WR-05: Error blocks, process views and tables write untrusted text to the terminal without stripping escape sequences
|
||||
### WR-11: The wrong-directory hint leaves secret values that start with "-" unmasked
|
||||
|
||||
**File:** `modules/bonfire/errors.go:145-169`, `modules/bonfire/live.go:286-289`, `modules/bonfire/widgets.go:142-166`
|
||||
**Issue:** T-19.8-03 strips control characters only in diary's console handler. `RenderError` passes messages, explanations and the `-v` cause chain through `sunscreen.Scrub` only, and Scrub redacts credential shapes but leaves ESC and other control characters in place. The process view prints `it.err.Error()` and item names unchanged, and `Table` prints cell values unchanged. When the profile allows color, `colorprofile.Writer` passes those sequences through.
|
||||
**File:** `modules/bonfire/errors.go:325-329`
|
||||
**Issue:** `tryArgs` masks the next argument after `--password` only when it does not start with `-`. pflag takes the next argument as the value of a non-boolean flag whatever its first character is (pflag v1.0.10 flag.go:1020-1023, the `'--flag arg'` branch). So `bin/acme admin:create --email a@b.c --password -Xk9!q` runs with the password `-Xk9!q`, and the Try line then prints it in clear text. A dash is a common first character for generated passwords. The docs (writing-commands.md) promise that such values are masked.
|
||||
**Fix:** Mask the next argument whenever the flag takes a value. A secret-named long flag in `--name value` form always consumes the next argument, unless that argument is `--` or the flag is known to be boolean:
|
||||
```go
|
||||
if i+1 < len(args) && args[i+1] != "--" {
|
||||
b.WriteString(" ***")
|
||||
i++
|
||||
}
|
||||
```
|
||||
Add a test case with a value that starts with a dash.
|
||||
|
||||
The text can come from outside the operator's control: database or remote-service error text (the centrifugo health error, upstream HTTP bodies), and admin-supplied token names shown by `admin:token:list`. Such text can move the cursor, rewrite earlier lines or set the window title on the operator's terminal.
|
||||
**Fix:** Export diary's `sanitize` (for example as `bonfire.Sanitize`, with diary calling it) and apply it to every error-block line, process-view item and error, and table cell before styling.
|
||||
### WR-12: migrate:status --json still drops the framework sets that migrate applies
|
||||
|
||||
### WR-06: Setup and ApplyGlobals close daily files that other goroutines may still be writing through
|
||||
**File:** `modules/lagoon/migrations.go:141-160`, `modules/lagoon/commands.go:270-289`
|
||||
**Issue:** WR-07's point was that the commands which report on migrations must read the list `Migrate` applies. `Status` still walks only `plugins`. `migrate:status --json` then emits pending refs only when `ref.Plugin == row.Plugin`. The pending migrations of `summercms.attach`, `summercms.cabana`, the deferred-bindings set and the queue set (all returned by `PendingMigrations`) are therefore silently omitted, both from the JSON (whose example says "pending migrations included") and from the table.
|
||||
|
||||
**File:** `modules/diary/diary.go:156-179,216-227`
|
||||
**Issue:** `install` swaps the state and then closes the old channels' daily writers. A goroutine that loaded the old state just before the swap still writes through the old `dailyWriter`. Its `Write` finds `file == nil` and reopens the file (`daily.go:233`). Nothing ever closes that writer again, so the descriptor stays open until the GC finalizer closes it.
|
||||
After a framework upgrade that adds a River queue migration, `migrate --pretend` lists it but `migrate:status --json` reports nothing pending. A deploy gate that checks for `"applied": false` passes when it should not.
|
||||
**Fix:** Build `Status` from `migrationSets(gdb, plugins)` (skipping or including empty plugin sets consistently), so all three commands read one list. Extend `TestMigrateStatusJSON` to assert that a framework set's pending migration appears.
|
||||
|
||||
`ApplyGlobals` runs in `PersistentPreRunE`, after plugins have booted. By then plugin goroutines (cache, realtime, the worker) may already be logging, so the window is real.
|
||||
### WR-13: migrate reports failure after a successful Migrate when the follow-up read fails
|
||||
|
||||
There is also a check-then-act race: `ApplyGlobals` reads `current` outside `mu`, so two concurrent calls can both rebuild the channels.
|
||||
**Fix:** Mark the old writers closed for good (`d.closed = true`, with `Write` returning `len(p), nil` once it is set) and do not reopen them. Alternatively, reference-count the old state and close it after in-flight `Handle` calls finish. Take `mu` for the whole of `ApplyGlobals`.
|
||||
**File:** `modules/lagoon/commands.go:173-180`
|
||||
**Issue:** After `Migrate` returns, the command calls `PendingMigrations` again. If that read fails (a dropped connection, a statement timeout, a permission problem on a history table), it returns `errors.Join(migrateErr, err)`, which is non-nil even when `migrateErr` is nil. A migration that was fully applied then:
|
||||
|
||||
### WR-07: PendingMigrations keeps its own copy of Migrate's migration sets
|
||||
- prints no applied lines and no `Applied N migrations`;
|
||||
- exits 1.
|
||||
|
||||
**File:** `modules/lagoon/migrations.go:221-245` vs `modules/lagoon/migrations.go:64-115`; `modules/lagoon/commands.go:139-171`
|
||||
**Issue:** `migrationSets` re-lists the framework sets and the plugin loop by hand, and `Migrate` does not use it. When a set is added to `Migrate` (as the queue and deferred sets were), several things go wrong without any failing test:
|
||||
A deploy pipeline will treat it as failed and may roll back or alert, even though the schema change is in place.
|
||||
**Fix:** When `migrateErr == nil`, a failed follow-up read must not turn the run into a failure. Fall back to reporting `pending` as applied, with a warning:
|
||||
```go
|
||||
after, err := PendingMigrations(gdb, plugins)
|
||||
applied := pending
|
||||
if err == nil {
|
||||
applied = appliedSince(pending, after)
|
||||
} else if migrateErr == nil {
|
||||
out.Warning(fmt.Sprintf("Could not re-read the migration history: %v", err))
|
||||
} else {
|
||||
return errors.Join(migrateErr, err)
|
||||
}
|
||||
```
|
||||
|
||||
- the serve startup summary,
|
||||
- `migrate --pretend`,
|
||||
- the production question's count,
|
||||
- the "N applied" lines,
|
||||
- `migrate:status --json`.
|
||||
### WR-14: Process's plain path prints item names without Sanitize
|
||||
|
||||
The "applied" lines are also printed from the list computed before `Migrate` ran, not from what it actually applied.
|
||||
**Fix:** Make `Migrate` iterate over `migrationSets(gdb, plugins)`, so both functions read one list. Optionally, recompute the pending list after `Migrate` and report the difference.
|
||||
|
||||
### WR-08: The wrong-directory hint repeats every argument, including a --password value, on stderr
|
||||
|
||||
**File:** `modules/bonfire/errors.go:220-224` (via `Fail`, `modules/bonfire/execute.go:346-351`)
|
||||
**Issue:** When an application binary fails outside its root, `wrongDirectory` builds the Try line from all of `os.Args[1:]`. For example, `cd bin && ./acme admin:create --email a@b.c --password hunter2` fails at `compass.Open`, and the error block prints `cd ..; bin/acme admin:create --email a@b.c --password hunter2`. The deprecated `--password` flag is still accepted, so a password that was only in the shell history now also reaches CI and deploy logs that capture stderr. `sunscreen.Scrub` does not catch it, because it recognises only Bearer, `sk-` and `x-api-key` shapes.
|
||||
**Fix:** Mask the values of flags whose names look like secrets (`password`, `secret`, `token`, `key`) in the Try line (`--password=***`). Alternatively, print only the command word (`cd ..; bin/acme admin:create …`).
|
||||
**File:** `modules/bonfire/helpers.go:183` (also `modules/bonfire/live.go:313-317`, the `operation` label)
|
||||
**Issue:** The WR-05 fix sanitises item names in the live process view (live.go:292) and in failure lines (`failureText`). The plain path, however, prints a successful item with `out.Success(item)`, and `status` writes it unchanged. The plain path runs whenever stdout is a terminal but the view does not animate: with `-n`, inside another live view (now the normal fallback when `runLive` reports `started == false`), or with a forced color profile. In those cases an item name that holds an escape sequence (a remote file name, an admin-typed title, a post slug) reaches the terminal unchanged. The same item is cleaned on one path and raw on the other.
|
||||
**Fix:** `out.Success(Sanitize(item))` in `Process`, or sanitise inside `console.status` and `console.Error` so every status line is covered regardless of caller. Add a plain-path case to `TestProcessViewStripsControl`.
|
||||
|
||||
## Info
|
||||
|
||||
### IN-01: A forced-color console drops HideOnTTY records even when stderr is not a terminal
|
||||
### IN-10: console.Error during a live view writes straight to the terminal and tears the view
|
||||
|
||||
**File:** `modules/diary/console.go:117-122`
|
||||
**Issue:** The styled `consoleHandler` drops every `HideOnTTY` record unconditionally. It is also used when color is forced (`--ansi`, `FORCE_COLOR`) on a non-terminal stderr. With stdout on a terminal and stderr redirected (`FORCE_COLOR=1 bin/acme serve 2>serve.log`), the "startup summary" and "listening on" records never reach the file, although the HideOnTTY docs promise that only a terminal console skips them.
|
||||
**Fix:** Check `isTerminal(w)` once when the handler is built, and drop the records only when it is true (the same rule `ttyFilter` uses).
|
||||
**File:** `modules/bonfire/output.go:232-238`
|
||||
**Issue:** `Error` writes to `c.err`, and `emit` reroutes only writes to `c.out`. When stderr and stdout are the same terminal (the usual case), an `out.Error(...)` from inside `Spinner`, `Progress` or `Process` lands mid-frame. A pty run showed the spinner glyph left on the error line (`⠹ ✗ stderr-error-line`), with the renderer continuing on the next row. diary's console records on stderr do go above the view through `PrintAbove`, so the two stderr paths behave differently.
|
||||
**Fix:** In `emit`, also reroute `w == c.err` when the console owns the running view and `c.err` is a terminal (`printAbove(c, s)`).
|
||||
|
||||
### IN-02: PeekGlobals and cobra disagree on some argument forms
|
||||
### IN-11: A blank line printed during a live view is dropped
|
||||
|
||||
**File:** `modules/bonfire/globals.go:52-81`
|
||||
**Issue:** `--quiet=true`, `--no-interaction=true`, `--verbose=2` and `--ansi=false` are ignored by `PeekGlobals` but honoured by pflag. A command flag value that looks like a global (`--name -q`) is the reverse: pflag takes it as the flag's value, `PeekGlobals` reads it as a global. The pre-boot diary setup, the error block's `-v` chain and passthrough globals can therefore differ from what the command runs with.
|
||||
**Fix:** Parse `--flag=value` forms with `strconv.ParseBool` and `strconv.Atoi`, or re-parse the args with a throwaway pflag FlagSet that holds only the global flags (`ParseErrorsWhitelist.UnknownFlags = true`).
|
||||
**File:** `modules/bonfire/live.go:485`
|
||||
**Issue:** `out.Println()` gives `"\n"`. `TrimSuffix` turns that into `""`, and bubbletea's `insertAbove` returns early for an empty string (cursed_renderer.go:776-778). `printAbove` still reports `true`, so the blank separator line is lost. It is printed when no view runs.
|
||||
**Fix:** Send `" "` (or `"\u200b"`) for an empty block, or skip the trim when the block is only a newline.
|
||||
|
||||
### IN-03: isUsage recognises cobra's unknown-command error by its text
|
||||
### IN-12: Signal relay edge cases: a signal during view start-up, and Windows
|
||||
|
||||
**File:** `modules/bonfire/errors.go:41-47,119`
|
||||
**Issue:** Any error whose message starts with "unknown command" exits 2 and is rewritten as `Command "…" is not defined.`. A plugin error worded that way would be misreported.
|
||||
**Fix:** Turn cobra's error into a `UsageError` in one place (for example a root `Args` or `ValidArgsFunction` check, or by matching it once in `Execute` before rendering), and test it with `errors.As`.
|
||||
**File:** `modules/bonfire/live.go:370-375, 427-436, 454-466`
|
||||
**Issue:** This comes from reading bubbletea v2.1.0; I did not reproduce it.
|
||||
|
||||
### IN-04: The --json flag is read two different ways
|
||||
1. **Start-up race.** `prog.Run` starts on its own goroutine just before the relay is registered. If a signal arrives before `Run` has created and started its renderer, `Kill` runs `shutdown(true)` while `p.renderer` is still nil. `shutdownOnce` then makes `Run`'s own `shutdown(killed)` a no-op after it has started the renderer and hidden the cursor, so the cursor stays hidden.
|
||||
2. **Windows.** On Windows, `p.Signal(os.Interrupt)` fails, so `raise` calls `os.Exit(130)`. That skips the command's own handler (for example, `summer dev` stopping its serve child) and its deferred cleanup.
|
||||
|
||||
**File:** `modules/cabana/token_commands.go:150,217,299`, `modules/lighthouse/centrifugo/commands.go`, `modules/surf/routelist_command.go` vs `modules/lagoon/commands.go` (`flagOn`)
|
||||
**Issue:** cabana, centrifugo and route:list compare the value with `== "true"`, while lagoon uses `strconv.ParseBool`. As a result `--json=1` works for `migrate:status` but not for `admin:token:list`.
|
||||
**Fix:** Add one bonfire helper (for example `bonfire.FlagBool(in, name)`) and use it everywhere.
|
||||
**Fix:** Before killing, wait until the program reports it has started (for example, have the wrapped model's `Init` close a `ready` channel). On platforms where `raise` cannot deliver the signal, return without exiting when another handler is registered.
|
||||
|
||||
### IN-05: summer's scaffolding result lines are now hidden by -q
|
||||
### IN-13: A delegate's stderr always goes to os.Stderr, bypassing the root's error writer
|
||||
|
||||
**File:** `cmd/summer/main.go:126,334,401`
|
||||
**Issue:** `created <path>` and `added <dir>` used to print through `Printf`. They now print through `Success` as `Created …` and `Added …`, so `-q` hides them and their wording changed. Scripts that read the created paths stop working without notice.
|
||||
**Fix:** Print the created paths with `Println` as data (D-27), or mention the change in `docs/setup/upgrading.md`.
|
||||
**File:** `cmd/summer/runtime.go:155-160`
|
||||
**Issue:** Off a terminal, the child's stdout goes through `out`, but its stderr goes to the real `os.Stderr`, not to the error stream of the root that ran the command. For a summer root built with `NewRootIO(…, errW)` (tests, embedding), the app binary's error block cannot be captured. `TestDelegateExitCodePassthrough` therefore cannot check that the binary's own block was printed.
|
||||
**Fix:** Pass the root's error writer through (for example, a `bonfire.ErrWriter(out)` accessor), defaulting to `os.Stderr`.
|
||||
|
||||
### IN-06: fang replaces the root help function that NewRootIO installs
|
||||
### IN-14: The ErrReported doc comment still says the exit code is 1
|
||||
|
||||
**File:** `modules/bonfire/root.go:139-141`, `modules/bonfire/execute.go:327-337`
|
||||
**Issue:** `fang.Execute` calls `root.SetHelpFunc(fang's helpFunc)` (fang.go:140), so under `Execute` bonfire's root help function is dead code. Today `Execute` strips `-h` and `--help` when there is no command word, so the replacement is not reachable. A future path that calls the root's help would print fang's layout, not the Artisan list.
|
||||
**Fix:** Set the root help function again after `fang.Execute` sets its own (not possible from outside), or drop fang and call `root.ExecuteContext` with `RenderError` directly. bonfire already turns off every fang feature except the error handler.
|
||||
**File:** `modules/bonfire/errors.go:73-75`
|
||||
**Issue:** The comment says "the error block prints nothing and the exit code is 1". With `WithExitCode`, a Reported error can now exit with any code from 1 to 255 (the README was updated, the comment was not).
|
||||
**Fix:** "… and the exit code is 1, or the code of WithExitCode."
|
||||
|
||||
### IN-07: diary pulls the whole TUI stack into security modules
|
||||
### IN-15: runLive builds the bubbletea program before it knows it may run
|
||||
|
||||
**File:** `modules/diary/diary.go:21-22`, `modules/bouncer/turnstile.go`, `modules/wristband/oidc_token.go`
|
||||
**Issue:** `diary` imports `bonfire`, which imports cobra, fang, bubbletea, bubbles and huh. bouncer's README now says its "only framework dependency is diary", but in practice the security primitive links the console UI stack. That widens the code a security review of bouncer and wristband has to cover.
|
||||
**Fix:** Move `ColorMode`, `DetectProfile`, `Palette` and `PrintAbove` into a small leaf package (or into diary itself) so that diary does not import bonfire.
|
||||
|
||||
### IN-08: The console sanitizer passes raw C1 bytes through on its fast path
|
||||
|
||||
**File:** `modules/diary/console.go:278-288`
|
||||
**Issue:** A lone `0x9b` byte (8-bit CSI) is invalid UTF-8. `range` decodes it as `U+FFFD`, which is not a control character, so `sanitize` returns the original string with the raw byte in it. Terminals that interpret 8-bit C1 controls then see an escape sequence.
|
||||
**Fix:** Run `strings.ToValidUTF8(s, "<22>")` before the control-character scan.
|
||||
|
||||
### IN-09: The phase gate names consuming applications
|
||||
|
||||
**File:** `scripts/check-phase19.8.sh:71-77,229`
|
||||
**Issue:** The script hard-codes `sm-demo-app`, `sm-bm-app`, `sm-grzybyfunkcjonalne-app` and `sm-summercmsio-app`, and splits a consumer's name into two strings. CLAUDE.md says framework code never names a consuming application. Earlier phase gates do the same, so the precedent exists, but this framework repository is published.
|
||||
**Fix:** Read the sibling paths from environment variables only, with no defaults, or move the cross-repository stages to a gate outside the framework repository.
|
||||
**File:** `modules/bonfire/live.go:354-366`
|
||||
**Issue:** `tea.NewProgram` runs before the slot check. It calls `context.WithCancel(ctx)`, and with `TEA_TRACE` set it opens a trace file. When the slot is taken (`started == false`, now the normal fallback for nested widgets), that cancel is never called. With `Process`'s cancellable `ctx`, the child context stays registered on the parent until the parent ends, and the trace file descriptor leaks. A `Process` nested in a loop accumulates both.
|
||||
**Fix:** Claim the slot first and construct the program only after `activeView` is set (or call `prog.Kill()` on the losing path).
|
||||
|
||||
---
|
||||
|
||||
_Reviewed: 2026-10-21T22:33:36Z_
|
||||
_Reviewed: 2026-10-22T01:17:37Z_
|
||||
_Reviewer: Claude (msd-code-reviewer)_
|
||||
_Depth: standard_
|
||||
|
||||
44
.planning/phases/19.8-console-commands-overhaul/19.8-UAT.md
Normal file
44
.planning/phases/19.8-console-commands-overhaul/19.8-UAT.md
Normal file
@@ -0,0 +1,44 @@
|
||||
---
|
||||
status: testing
|
||||
phase: 19.8-console-commands-overhaul
|
||||
source: [19.8-VERIFICATION.md]
|
||||
started: 2026-10-22T02:41:10.440Z
|
||||
updated: 2026-10-22T02:41:10.440Z
|
||||
---
|
||||
|
||||
## Current Test
|
||||
|
||||
number: 1
|
||||
name: On a real terminal, run 'summer', 'bin/<app>', 'bin/<app> help admin:token:create' and 'bin/<app> bogus'
|
||||
expected: |
|
||||
Colors, alignment and sections match the approved D-13 list, D-14 help and D-18 error previews; --no-ansi removes every escape code
|
||||
awaiting: user response
|
||||
|
||||
## Tests
|
||||
|
||||
### 1. On a real terminal, run 'summer', 'bin/<app>', 'bin/<app> help admin:token:create' and 'bin/<app> bogus'
|
||||
expected: Colors, alignment and sections match the approved D-13 list, D-14 help and D-18 error previews; --no-ansi removes every escape code
|
||||
result: [pending]
|
||||
|
||||
### 2. On a real terminal, run a command that uses Spinner, Progress, Process, Choice, Ask, Confirm and Secret (or the throwaway pty program from plan 02); press Ctrl+C during a spinner
|
||||
expected: SSU-like animation, arrow-key selector with confirmation screen, huh prompts; diary log lines print above the view; after Ctrl+C the cursor is visible and the shell is usable
|
||||
result: [pending]
|
||||
|
||||
### 3. Run 'bin/<app> serve' against a real database on a terminal, then with -q, then with -v, then press Ctrl+C; run 'summer dev' and stop it
|
||||
expected: A compact startup block (version, env, URL, DB and migration state, plugins, queue, scheduler, mail, cache, realtime, search), then INFO lines with component prefixes. -q shows only warnings. -v adds per-plugin lines. Ctrl+C prints the shutdown report and exits 0
|
||||
result: [pending]
|
||||
|
||||
### 4. Resolve the judgment-tier prohibitions listed under 'Prohibitions' below (the LLM-judge verdicts there are non-authoritative)
|
||||
expected: Each prohibition is confirmed, or rejected with a reason. The plan 03 'never print a secret' prohibition is no longer flagged as violated: both known leaks (WR-10, WR-11) are fixed and reproduced as fixed with built binaries
|
||||
result: [pending]
|
||||
|
||||
## Summary
|
||||
|
||||
total: 4
|
||||
passed: 0
|
||||
issues: 0
|
||||
pending: 4
|
||||
skipped: 0
|
||||
blocked: 0
|
||||
|
||||
## Gaps
|
||||
@@ -0,0 +1,482 @@
|
||||
---
|
||||
phase: 19.8-console-commands-overhaul
|
||||
verified: 2026-10-22T02:37:57Z
|
||||
status: human_needed
|
||||
score: 57/57 must-haves verified
|
||||
covered_files:
|
||||
- ".gitignore"
|
||||
- ".planning/phases/19.8-console-commands-overhaul/19.8-01-PLAN.md"
|
||||
- ".planning/phases/19.8-console-commands-overhaul/19.8-01-SUMMARY.md"
|
||||
- ".planning/phases/19.8-console-commands-overhaul/19.8-02-PLAN.md"
|
||||
- ".planning/phases/19.8-console-commands-overhaul/19.8-02-SUMMARY.md"
|
||||
- ".planning/phases/19.8-console-commands-overhaul/19.8-03-PLAN.md"
|
||||
- ".planning/phases/19.8-console-commands-overhaul/19.8-03-SUMMARY.md"
|
||||
- ".planning/phases/19.8-console-commands-overhaul/19.8-04-PLAN.md"
|
||||
- ".planning/phases/19.8-console-commands-overhaul/19.8-04-SUMMARY.md"
|
||||
- ".planning/phases/19.8-console-commands-overhaul/19.8-05-PLAN.md"
|
||||
- ".planning/phases/19.8-console-commands-overhaul/19.8-05-SUMMARY.md"
|
||||
- ".planning/phases/19.8-console-commands-overhaul/19.8-06-PLAN.md"
|
||||
- ".planning/phases/19.8-console-commands-overhaul/19.8-06-SUMMARY.md"
|
||||
- ".planning/quick/261022-4z8-fix-the-open-19-8-re-review-findings-wr-/261022-4z8-PLAN.md"
|
||||
- ".planning/quick/261022-4z8-fix-the-open-19-8-re-review-findings-wr-/261022-4z8-SUMMARY.md"
|
||||
- "README.md"
|
||||
- "cmd/summer/commands_documented_test.go"
|
||||
- "cmd/summer/docs.go"
|
||||
- "cmd/summer/docs_test.go"
|
||||
- "cmd/summer/main.go"
|
||||
- "cmd/summer/parity.go"
|
||||
- "cmd/summer/runtime.go"
|
||||
- "cmd/summer/runtime_test.go"
|
||||
- "docs/console/introduction.md"
|
||||
- "docs/console/scaffolding.md"
|
||||
- "docs/console/setup-and-maintenance.md"
|
||||
- "docs/console/utilities.md"
|
||||
- "docs/console/writing-commands.md"
|
||||
- "docs/database/migrations.md"
|
||||
- "docs/examples/blog/console/publish.go"
|
||||
- "docs/examples/blog/console/publish_test.go"
|
||||
- "docs/services/configuration.md"
|
||||
- "docs/services/logging.md"
|
||||
- "docs/setup/configuration.md"
|
||||
- "docs/setup/installation.md"
|
||||
- "docs/setup/porting-a-plugin.md"
|
||||
- "docs/setup/upgrading.md"
|
||||
- "examples/hello/hello_test.go"
|
||||
- "examples/hello/main.go"
|
||||
- "examples/hello/plugins/greeter/plugin.go"
|
||||
- "go.mod"
|
||||
- "go.sum"
|
||||
- "internal/argmask/argmask.go"
|
||||
- "internal/build/artifact.go"
|
||||
- "internal/build/build.go"
|
||||
- "internal/build/build_test.go"
|
||||
- "internal/build/manifest.go"
|
||||
- "internal/build/new.go"
|
||||
- "internal/build/new_golden_test.go"
|
||||
- "internal/build/scaffold.go"
|
||||
- "internal/build/stubs/new.tmpl"
|
||||
- "internal/build/testdata/new-no-plugin/CLAUDE.md.golden"
|
||||
- "internal/build/testdata/new/CLAUDE.md.golden"
|
||||
- "internal/build/testdata/new/config__log.yaml.golden"
|
||||
- "internal/build/testdata/new/main.go.golden"
|
||||
- "internal/dev/watch.go"
|
||||
- "internal/dev/watch_test.go"
|
||||
- "internal/docsite/check_commands.go"
|
||||
- "internal/docsite/check_commands_test.go"
|
||||
- "internal/docsite/checks_test.go"
|
||||
- "modules/beachcomber/README.md"
|
||||
- "modules/beachcomber/beachcomber.go"
|
||||
- "modules/bonfire/README.md"
|
||||
- "modules/bonfire/command.go"
|
||||
- "modules/bonfire/docs.go"
|
||||
- "modules/bonfire/docs_test.go"
|
||||
- "modules/bonfire/errors.go"
|
||||
- "modules/bonfire/errors_test.go"
|
||||
- "modules/bonfire/example_test.go"
|
||||
- "modules/bonfire/execute.go"
|
||||
- "modules/bonfire/globals.go"
|
||||
- "modules/bonfire/globals_test.go"
|
||||
- "modules/bonfire/golden_test.go"
|
||||
- "modules/bonfire/help.go"
|
||||
- "modules/bonfire/help_smoke_test.go"
|
||||
- "modules/bonfire/helpers.go"
|
||||
- "modules/bonfire/list.go"
|
||||
- "modules/bonfire/list_smoke_test.go"
|
||||
- "modules/bonfire/live.go"
|
||||
- "modules/bonfire/live_signal_unix_test.go"
|
||||
- "modules/bonfire/live_test.go"
|
||||
- "modules/bonfire/output.go"
|
||||
- "modules/bonfire/prompts.go"
|
||||
- "modules/bonfire/root.go"
|
||||
- "modules/bonfire/root_test.go"
|
||||
- "modules/bonfire/selector.go"
|
||||
- "modules/bonfire/styles.go"
|
||||
- "modules/bonfire/testdata/error_chain_verbose.golden"
|
||||
- "modules/bonfire/testdata/error_hint.golden"
|
||||
- "modules/bonfire/testdata/error_unknown_command.golden"
|
||||
- "modules/bonfire/testdata/help_passthrough.golden"
|
||||
- "modules/bonfire/testdata/help_token_create.golden"
|
||||
- "modules/bonfire/testdata/list.golden"
|
||||
- "modules/bonfire/testdata/list.json.golden"
|
||||
- "modules/bonfire/testdata/list_all.golden"
|
||||
- "modules/bonfire/testdata/list_styled.golden"
|
||||
- "modules/bonfire/version.go"
|
||||
- "modules/bonfire/version_test.go"
|
||||
- "modules/bonfire/widgets.go"
|
||||
- "modules/bonfire/widgets_smoke_test.go"
|
||||
- "modules/bonfire/widgets_test.go"
|
||||
- "modules/bonfire/wrongdir_smoke_test.go"
|
||||
- "modules/bouncer/README.md"
|
||||
- "modules/bouncer/turnstile.go"
|
||||
- "modules/cabana/README.md"
|
||||
- "modules/cabana/account.go"
|
||||
- "modules/cabana/account_preferences.go"
|
||||
- "modules/cabana/account_tokens.go"
|
||||
- "modules/cabana/actions.go"
|
||||
- "modules/cabana/api_tokens.go"
|
||||
- "modules/cabana/auth.go"
|
||||
- "modules/cabana/commands.go"
|
||||
- "modules/cabana/contracts.go"
|
||||
- "modules/cabana/crud.go"
|
||||
- "modules/cabana/field_dependent.go"
|
||||
- "modules/cabana/field_file.go"
|
||||
- "modules/cabana/field_type.go"
|
||||
- "modules/cabana/http.go"
|
||||
- "modules/cabana/log.go"
|
||||
- "modules/cabana/partial_render.go"
|
||||
- "modules/cabana/query.go"
|
||||
- "modules/cabana/relation_field.go"
|
||||
- "modules/cabana/reorder.go"
|
||||
- "modules/cabana/singleton.go"
|
||||
- "modules/cabana/token_commands.go"
|
||||
- "modules/cabana/token_guard.go"
|
||||
- "modules/cabana/token_json_test.go"
|
||||
- "modules/conga/README.md"
|
||||
- "modules/conga/client.go"
|
||||
- "modules/conga/commands.go"
|
||||
- "modules/conga/commands_test.go"
|
||||
- "modules/conga/confirm_linux_test.go"
|
||||
- "modules/conga/conga.go"
|
||||
- "modules/conga/logger_smoke_test.go"
|
||||
- "modules/conga/logger_test.go"
|
||||
- "modules/conga/schedule_test.go"
|
||||
- "modules/conga/stats_test.go"
|
||||
- "modules/conga/worker.go"
|
||||
- "modules/cooler/README.md"
|
||||
- "modules/cooler/cooler.go"
|
||||
- "modules/cooler/memory.go"
|
||||
- "modules/cooler/ratelimit.go"
|
||||
- "modules/cooler/rediscache.go"
|
||||
- "modules/cooler/sitecache.go"
|
||||
- "modules/diary/README.md"
|
||||
- "modules/diary/channels.go"
|
||||
- "modules/diary/component.go"
|
||||
- "modules/diary/config.go"
|
||||
- "modules/diary/config_smoke_test.go"
|
||||
- "modules/diary/config_test.go"
|
||||
- "modules/diary/console.go"
|
||||
- "modules/diary/console_test.go"
|
||||
- "modules/diary/daily.go"
|
||||
- "modules/diary/daily_test.go"
|
||||
- "modules/diary/diary.go"
|
||||
- "modules/diary/diary_test.go"
|
||||
- "modules/diary/example_test.go"
|
||||
- "modules/diary/retire_internal_test.go"
|
||||
- "modules/diary/slogtest_test.go"
|
||||
- "modules/diary/tracer_smoke_test.go"
|
||||
- "modules/flare/README.md"
|
||||
- "modules/flare/commands.go"
|
||||
- "modules/flare/commands_test.go"
|
||||
- "modules/flare/flare.go"
|
||||
- "modules/lagoon/README.md"
|
||||
- "modules/lagoon/attach/bucket.go"
|
||||
- "modules/lagoon/attach/thumb.go"
|
||||
- "modules/lagoon/commands.go"
|
||||
- "modules/lagoon/commands_flags_test.go"
|
||||
- "modules/lagoon/connection.go"
|
||||
- "modules/lagoon/encrypted.go"
|
||||
- "modules/lagoon/fill.go"
|
||||
- "modules/lagoon/keygen.go"
|
||||
- "modules/lagoon/logger_smoke_test.go"
|
||||
- "modules/lagoon/logger_test.go"
|
||||
- "modules/lagoon/migrations.go"
|
||||
- "modules/lagoon/pending_test.go"
|
||||
- "modules/lagoon/pty_linux_test.go"
|
||||
- "modules/lagoon/purge.go"
|
||||
- "modules/lagoon/transaction.go"
|
||||
- "modules/lighthouse/README.md"
|
||||
- "modules/lighthouse/centrifugo/client.go"
|
||||
- "modules/lighthouse/centrifugo/commands.go"
|
||||
- "modules/lighthouse/lighthouse.go"
|
||||
- "modules/phrasebook/README.md"
|
||||
- "modules/phrasebook/translator.go"
|
||||
- "modules/postcard/README.md"
|
||||
- "modules/postcard/drivers.go"
|
||||
- "modules/postcard/mailer.go"
|
||||
- "modules/sunscreen/README.md"
|
||||
- "modules/sunscreen/sunscreen.go"
|
||||
- "modules/sunscreen/sunscreen_test.go"
|
||||
- "modules/surf/README.md"
|
||||
- "modules/surf/accesslog.go"
|
||||
- "modules/surf/accesslog_test.go"
|
||||
- "modules/surf/routelist_command.go"
|
||||
- "modules/surf/serve.go"
|
||||
- "modules/surf/serve_lifecycle_test.go"
|
||||
- "modules/surf/serve_summary_smoke_test.go"
|
||||
- "modules/surf/serve_test.go"
|
||||
- "modules/surf/summary.go"
|
||||
- "modules/surf/summary_test.go"
|
||||
- "modules/wristband/README.md"
|
||||
- "modules/wristband/oidc_token.go"
|
||||
- "scripts/check-phase19.8.sh"
|
||||
covered_digest: "v2:sha256:472046bb65cca6ab5a4a3a8b7f02a7c66f6f385e90fbecc1f4a7baf1b80b9f9d"
|
||||
behavior_unverified: 0
|
||||
overrides_applied: 0
|
||||
re_verification:
|
||||
previous_status: gaps_found
|
||||
previous_score: 54/57
|
||||
gaps_closed:
|
||||
- "CR-01 (D-03, D-27): lines printed while a live view runs appear in order and none is lost when the view ends, including after its context is cancelled (WR-09, fix fce232d)"
|
||||
- "WR-08 (D-19) and the plan 03 secrets prohibition: secret flag values are masked in the wrong-directory Try line whatever their first character (WR-11, fix 707372b) and in summer's delegate error on a signal death or start failure (WR-10, fix 17c722d), through the shared internal/argmask"
|
||||
- "WR-07 (D-21, D-04): migrate:status (table and --json) lists the same sets migrationSets gives Migrate, framework sets included (WR-12, fix 57a849d, reworked in 139f5e3 so that lagoon.Status stays plugin-only)"
|
||||
gaps_remaining: []
|
||||
regressions: []
|
||||
human_verification:
|
||||
- test: "On a real terminal, run 'summer', 'bin/<app>', 'bin/<app> help admin:token:create' and 'bin/<app> bogus'"
|
||||
expected: "Colors, alignment and sections match the approved D-13 list, D-14 help and D-18 error previews; --no-ansi removes every escape code"
|
||||
why_human: "Visual layout and palette; the goldens were produced with -update from the code itself, so only a person can compare them with the previews"
|
||||
- test: "On a real terminal, run a command that uses Spinner, Progress, Process, Choice, Ask, Confirm and Secret (or the throwaway pty program from plan 02); press Ctrl+C during a spinner"
|
||||
expected: "SSU-like animation, arrow-key selector with confirmation screen, huh prompts; diary log lines print above the view; after Ctrl+C the cursor is visible and the shell is usable"
|
||||
why_human: "Animation, keyboard interaction and terminal restore cannot be asserted by the short suite (plans 01, 02 and 04 recorded human_judgment: true)"
|
||||
- test: "Run 'bin/<app> serve' against a real database on a terminal, then with -q, then with -v, then press Ctrl+C; run 'summer dev' and stop it"
|
||||
expected: "A compact startup block (version, env, URL, DB and migration state, plugins, queue, scheduler, mail, cache, realtime, search), then INFO lines with component prefixes. -q shows only warnings. -v adds per-plugin lines. Ctrl+C prints the shutdown report and exits 0"
|
||||
why_human: "Readability of the styled block is a visual judgment; TestServeLifecycle (Docker) covers the records and exit code"
|
||||
- test: "Resolve the judgment-tier prohibitions listed under 'Prohibitions' below (the LLM-judge verdicts there are non-authoritative)"
|
||||
expected: "Each prohibition is confirmed, or rejected with a reason. The plan 03 'never print a secret' prohibition is no longer flagged as violated: both known leaks (WR-10, WR-11) are fixed and reproduced as fixed with built binaries"
|
||||
why_human: "Judgment-tier prohibitions need explicit human resolution in interactive verification"
|
||||
---
|
||||
|
||||
# Phase 19.8: Console commands overhaul Verification Report
|
||||
|
||||
**Phase Goal:** Console commands in `summer` and in application binaries reach the bar set by Laravel's Artisan. Logging, the shared console layer in `bonfire`, and every command's help and output change; the command logic stays. 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.
|
||||
**Verified:** 2026-10-22T02:37:57Z (initial verification 2026-10-22T01:32:12Z)
|
||||
**Status:** human_needed
|
||||
**Re-verification:** Yes, after gap closure (quick task 261022-4z8, commits 7c15095..a48afbd plus follow-ups 139f5e3 and b1fba7e)
|
||||
|
||||
**Plain verdict:** The phase goal is achieved. All 57 must-haves (5 roadmap success criteria and 52 plan truths) are verified in the codebase, and the three gaps of the first report are closed. Nothing is missing in the code. The status is `human_needed`, not `passed`, only because the verifier contract allows `passed` only when the human verification list is empty. That list still holds the four checks of the first report: three visual or terminal checks (palette and layout, live widgets and Ctrl+C on a real terminal, the serve startup block) and the explicit resolution of the judgment-tier prohibitions. No `*-UAT.md` exists for this phase, so none of them is settled by recorded evidence. They are confirmation checks, not suspected defects. Once a person has done them, the phase can be marked passed.
|
||||
|
||||
**MVP mode note:** ROADMAP marks the phase `mode: mvp`, but the goal is not a user story (`user-story.validate` returns `valid: false`). All six plans recorded the decision not to invent a story. This report applies the standard goal-backward method to the goal and SC-1..SC-5 and does not include a User Flow Coverage table.
|
||||
|
||||
## Re-verification (2026-10-22, after quick 261022-4z8)
|
||||
|
||||
The SUMMARY claims were not taken as evidence. Each gap was re-checked against the code at HEAD `4141cd9` (the code tree has no uncommitted changes; only foreign planning files are dirty), with the pinning tests run by the verifier and with an independent check using built binaries.
|
||||
|
||||
### Commits under review
|
||||
|
||||
| Commit | Finding | Gap |
|
||||
|--------|---------|-----|
|
||||
| `fce232d` fix(bonfire): end a live view when its context is cancelled and print later lines plainly | WR-09 | 1 |
|
||||
| `1ac75ad` fix(bonfire): claim the live view slot before building the program | IN-15 | (1, same code) |
|
||||
| `48e13f9` fix(bonfire): strip control characters from Process's plain item lines and the view's label | WR-14 | follow-up |
|
||||
| `707372b` fix(bonfire): mask secret flag values that start with a dash | WR-11 | 2 |
|
||||
| `17c722d` fix(summer): mask secret flag values in a delegate's error | WR-10 | 2 |
|
||||
| `57a849d` fix(lagoon): migrate:status reads the sets migrate applies | WR-12 | 3 |
|
||||
| `139f5e3` fix(lagoon): keep Status plugin-only; migrate:status adds the framework sets itself | WR-12 rework | 3 |
|
||||
| `0691d26` fix(lagoon): warn instead of failing when the re-read after a successful migrate fails | WR-13 | follow-up |
|
||||
| `7c15095`, `a48afbd`, `b1fba7e` | IN-14 doc comment; disposition and todo records | records |
|
||||
|
||||
`19.8-REVIEW-DISPOSITION.md` shows `open: 0`, `total: 32`. WR-09..WR-14, IN-14 and IN-15 are fixed with these hashes; IN-10..IN-13 and the exit-130 suggestion are deferred to `.planning/todos/pending/2026-10-22-console-review-info-findings.md`. The commits are local: 20 commits ahead of the local `origin/master` (`ead8e42`), and no tag contains `fce232d`. So the fixes are not in released v0.4.2 yet. That is a release matter, not a goal gap.
|
||||
|
||||
### Gap 1 (WR-09): lines after a cancelled live view. CLOSED
|
||||
|
||||
- **Code:** `modules/bonfire/live.go`. `tea.NewProgram` no longer takes the caller's ctx (no `tea.WithContext`). `runLive` claims the slot under `liveMu` before it builds the program (lines 368-382). `watchContext` (lines 426-448) does the following on `ctx.Done()`, all under `liveMu`: it clears `activeView`, sends `finishMsg{err: ctx.Err()}` and waits for `finished`. When the program ends first, the watcher only clears the view. `liveView.done` is the `finished` channel, and `printAbove` (lines 529-546) returns false through `case <-v.done:` for a program that has stopped, and unregisters it. The deferred cleanup's `prog.Send` after the watcher has finished the program cannot block, because bubbletea's Run cancels its own context on return. diary's console handler writes the line itself when `bonfire.PrintAbove` returns false (`modules/diary/console.go:165`).
|
||||
- **Pinning test:** `TestPrintAboveAfterCancel` cancels in the middle of an item. It asserts that the view is unregistered, that `Println`, `Info` and `PrintAbove` lines each appear exactly once, and that `PrintAbove` reports false. The verifier ran it with the rest of the live-view set (`TestNestedProcessBuildsNoProgram`, `TestProcessViewStripsControl`, `TestPrintAboveOrderedNoLoss`, `TestPrintlnStaysWithItsConsole`, `TestLiveViewSingleOwner`, `TestLiveViewPanicUnregisters`, `TestLiveViewRestoresTerminalOnSignal`) under `-race -count=3`: all PASS.
|
||||
- **Independent pty reproduction:** this is the same scenario that failed 3 of 3 in the first verification. A scratch probe was built from a `git archive HEAD` export: inside a `bonfire.Process` item it calls `cancel()`, then `out.Println("after-cancel-line")`, then `bonfire.PrintAbove(...)`. It ran 5 times under `script(1)`. In 5 of 5 runs `after-cancel-line` printed once after the last frame and `\e[?25h` (cursor restored), `PrintAbove` returned **false** (so its caller prints the line), and `Process` returned `context canceled`. Before the fix the line was lost and `PrintAbove` returned true.
|
||||
|
||||
### Gap 2 (WR-11 and WR-10): secret flag values in clear. CLOSED
|
||||
|
||||
- **Code:** `internal/argmask/argmask.go` is the one implementation and imports only `strings`. `Join` masks the argument after a secret-named long flag (`password`, `passwd`, `secret`, `token`, `key`) whatever its first character; only `--` is left alone. It also masks the `--flag=value` form. bonfire's `tryArgs` and the `cd` part of the Try line call `argmask.Join` and `argmask.Quote` (errors.go:288-301); bonfire's old local helpers are gone. `cmd/summer/runtime.go:174` `runAppCmd` builds its error from `argmask.Join(argv)` on every branch, and no `strings.Join(argv` remains. An exit code from the child is still passed through with `Reported(WithExitCode(...))`.
|
||||
- **Pinning tests (PASS, verifier run):** `TestWrongDirectoryMasksSecrets` (dash-leading cases included); `TestRunAppCmdMasksSecrets/killed_by_a_signal` and `/start_failure`; `TestDelegateExitCodePassthrough/exit_code`, `/killed_by_a_signal`, `/killed_by_a_signal_masks_secrets`.
|
||||
- **Independent checks with built binaries** (built into the scratchpad from HEAD):
|
||||
- `cd plugins; ../bin/hello admin:create --email a@b.c --password hunter2` printed `Try: cd ..; bin/hello admin:create --email a@b.c --password ***` (exit 1).
|
||||
- The same command with `--password -Xk9q` printed `--password ***` (it was in clear text before the fix).
|
||||
- `summer admin:create --email a@b.c --password hunter2 --api-token=t0k3n --client-secret -Xk9q` was delegated to a fake app binary that kills itself with SIGKILL. It printed `ERROR summer: admin:create --email a@b.c --password *** --api-token=*** --client-secret ***: signal: killed` and exited 1, which is what plan 05's CR-02 truth specifies. With `-v` the chain shows only `signal: killed`.
|
||||
- A delegate that exits 2 makes summer exit 2 with no second error block, so the CR-02 behavior is unchanged.
|
||||
|
||||
### Gap 3 (WR-12): migrate:status --json omits the framework sets. CLOSED
|
||||
|
||||
- **Code:** `modules/lagoon/migrations.go`. The new unexported `setStatus` (line 160) takes its sets from `migrationSets(gdb, plugins)`, the same list `Migrate` (line 69) and `PendingMigrations` (line 251) use. `migrate:status` reads `setStatus` (commands.go:282), so every `PendingMigrations` ref, whose `Plugin` field is the set id, matches a row, and framework pending migrations appear with `"applied": false`. The exported `lagoon.Status` is back to its pre-task behavior: the verifier compared it with the `a28957f` version. It has the same plugin-only `HasMigrations` iteration and the same empty row for a plugin without a history table; only the row-reading loop moved into the shared unexported `historyRows`. The exported API diff `a28957f..HEAD` over modules, cmd and internal adds only `argmask.Join` and `argmask.Quote`, which are in `internal/`, so they are not framework API. Nothing was removed or changed.
|
||||
- **Pinning tests (Docker, PASS, verifier run with `TESTCONTAINERS_HOST_OVERRIDE=127.0.0.1`):** `TestMigrateStatusJSON` deletes the newest `summercms.attach` history row and asserts that `--json` (and `--json -q`) lists that migration as pending. It also asserts that the table lists `summercms.attach, summercms.cabana, deferred, queue, demo.alpha, demo.beta` in order. `TestStatusPluginRows` pins the plugin-only `Status`. `TestMigrateRereadFailureWarns`, `TestMigrateAppliedLines`, `TestPendingMigrations`, `TestMigratePretend`, `TestTwoPluginMigrationSetsIsolated` and `TestDocsMigrate` also PASS.
|
||||
- **Consumer regression check:** `TestRollbackIsolatesFonotekaFullSchema` in `../fonoteka.go/parity` (it calls `lagoon.Status`, `replace => ../summercms.go`) PASSES against HEAD. It failed at `a48afbd`, before the 139f5e3 rework.
|
||||
|
||||
### Regression spot-checks (nothing else regressed)
|
||||
|
||||
| Check | Result |
|
||||
|-------|--------|
|
||||
| `go vet ./...` | PASS |
|
||||
| `go test -short -count=1 ./...` (all packages, live tree) | PASS, every package ok |
|
||||
| `scripts/check-phase19.8.sh self-test`, `go`, `hello`, `docs` (live tree, now clean of foreign code edits) | all PASS ("phase19.8 go passed", with race on diary, sunscreen, bonfire and docsite; SUMMER list 7 named tests including TestRunAppCmdMasksSecrets) |
|
||||
| `PHASE198_REQUIRE_DOCKER=1 scripts/check-phase19.8.sh docker` | PASS (8 lagoon tests including TestMigrateRereadFailureWarns and TestStatusPluginRows; 3 cabana token JSON tests) |
|
||||
| `summer` bare / `summer bogus` / `summer list --format=json` | exit 0 with the grouped list (59 lines) / ERROR block, `Try: summer list`, exit 2 / 35 commands, 0 without examples |
|
||||
| `summer help make:command`, `bin/hello help migrate:status` | Description, Usage, Arguments, global options, Help, Examples; migrate:status's Long help names the framework sets |
|
||||
| `bin/hello key:generate -q`, `bin/hello route:list --json` | key printed, exit 0; JSON route list |
|
||||
| Debt markers (TBD/FIXME/XXX) in files changed `a28957f..HEAD`; TODO/HACK added | none |
|
||||
| Generated main / internal/build changed since the first verification | no (`git diff 36e2240..HEAD -- cmd/summer/main.go internal/build` is empty) |
|
||||
|
||||
## Goal Achievement
|
||||
|
||||
### Roadmap Success Criteria
|
||||
|
||||
| # | Success criterion | Status | Evidence |
|
||||
|---|-------------------|--------|----------|
|
||||
| SC-1 | `diary` module owns logging (Laravel-shaped keys, stderr/stack/daily/console channels, component loggers), sunscreen on every channel, compat shim, daily files on by default, charm stack with a decision note before code | ✓ VERIFIED | `modules/diary` (3,248 lines): `log.default/level/channels/components` constants (config.go:16-24); `DefaultConfig` is a stack of console and daily (`storage/logs/summer.log`, JSON, warn, 14 days); every leaf goes through `sunscreen.Wrap` (channels.go:117-166). README has a `**Name:**` mnemonic; root README row at line 104. The note commit `e6039b4` is an ancestor of the first go.mod lipgloss commit `e903cc3` (merge-base check). go.mod has lipgloss v2.0.6, bubbletea v2.1.0, bubbles v2.2.1, huh v2.0.3, fang v2.0.1, colorprofile v0.4.3. `go list` shows only bonfire and diary import charm. `sunscreen.InstallDefault` keeps its signature. `summer new` writes `config/log.yaml` (golden). Re-verification: unchanged by the gap-closure commits; diary tests PASS under race. |
|
||||
| SC-2 | Global flags `-v/-vv/-vvv`, `-q`, `--ansi/--no-ansi`, `-n`, `--env`; verbosity sets the diary level at runtime, so River and plugin logs are hidden or consistent at the default level and shown in full at -vv | ✓ VERIFIED | The flags appear in the help output of both binaries (re-checked). The generated main does `PeekGlobals`, `diary.Bootstrap`, `compass.Open(...Env: g.Env)`, `diary.Setup`, `bonfire.OnGlobals(diary.ApplyGlobals)` (examples/hello/main.go, unchanged). TestVerbosityLevels, TestComponentFloors, TestQuietConsoleOnly, TestRiverLoggerComponent, TestGlobalFlagsAgree and TestCallNoGlobalsHook PASS in the gate's go stage. |
|
||||
| SC-3 | Every command has a description, long help with examples, documented args and options with defaults; `list` grouped by namespace; `summer help <cmd>` and `<binary> help <cmd>` work; the coverage check fails for the framework and warns in plugins | ✓ VERIFIED | Re-checked: bare `summer` prints the grouped list (exit 0); `summer help make:command` and `bin/hello help migrate:status` print the full help layout; `summer list --format=json` lists 35 commands, none without examples. `cmd/summer/commands_documented_test.go` (`t.Error` per `bonfire.CheckDocs` problem) PASSES in the go stage. |
|
||||
| SC-4 | Sweep of all commands with the Artisan checklist (`--force`, `--pretend`, progress/tables, `--json`) without breaking names or flags; serve and queue:work print a startup summary and a shutdown report; one-shot commands end with a result line and an exit code | ✓ VERIFIED | As in the first report (migrate `--pretend`/`--force`, `route:list --json`, `key:generate -q`, unknown command exit 2, missing DSN exit 1 with a hint, serve summary and report, 15 plugin repos swept). The edge paths flagged in the first report are now fixed: `migrate:status --json` covers the framework sets, and `migrate` no longer exits 1 after a successful run when only the re-read fails (WR-13). Docker: TestServeLifecycle, TestMigratePretend, TestRollbackPretend, TestDeferredPurgePretend, TestMigrateStatusJSON, TestMigrateAppliedLines, TestMigrateRereadFailureWarns and the cabana token JSON tests PASS. |
|
||||
| SC-5 | diary and bonfire READMEs, docs console and logging pages, landing site and roadmap deck reflect the new console; unit tests in the last plan | ✓ VERIFIED | As in the first report (5 docs/console pages, the logging page, "Upgrading to 0.4", the landing "Console" card, the deck row in sm-demo-app). The gap-closure commits updated the bonfire and lagoon READMEs, the docs/console pages and docs/database/migrations.md in the same commits (WR-10's README fold-in is recorded as a deviation in the quick SUMMARY). The gate's docs stage PASSES at HEAD (67 files clean, 35 commands with examples). |
|
||||
|
||||
### Plan must_haves (merged; 52 truths)
|
||||
|
||||
| Plan | Truths | Status | Evidence / notes |
|
||||
|------|--------|--------|------------------|
|
||||
| 01 diary | 8 | 8 ✓ | Unchanged since the first report; the diary named tests PASS in the gate's go stage at HEAD. |
|
||||
| 02 bonfire | 8 | 8 ✓ | Unchanged contract; goldens, globals, help, JSON listing, error block and exit codes, degradation matrix all PASS in the go stage. The Output, Input and Progress interfaces and the Command struct are not touched by the gap-closure commits. |
|
||||
| 03 sweep | 9 | 9 ✓ | As before. The secrets prohibition is no longer violated by a known path (see Prohibitions). |
|
||||
| 04 tests | 7 | 7 ✓ | `check-phase19.8.sh` self-test, go, hello, docs and docker stages PASS at HEAD (verifier run). The gate now requires TestNestedProcessBuildsNoProgram, TestPrintAboveAfterCancel, TestRunAppCmdMasksSecrets, TestMigrateRereadFailureWarns and TestStatusPluginRows by name. |
|
||||
| 05 review fixes | 13 | **13 ✓** (was 10 ✓, 3 partial) | **CR-01** ✓: gap 1 closed (TestPrintAboveAfterCancel plus the pty probe, 5/5). **WR-08** ✓: gap 2 closed (dash-leading values and the delegate error path masked; TestWrongDirectoryMasksSecrets, TestRunAppCmdMasksSecrets, built-binary checks). **WR-07** ✓: gap 3 closed (migrate:status reads migrationSets; TestMigrateStatusJSON with a pending framework migration). The API truth still holds: compared with the first report, the gap-closure diff adds no exported identifier under modules/ and changes none. CR-02, CR-03, WR-01..WR-06 and the commit/RED-replay/disposition truths are still verified (pinning tests PASS; disposition `open: 0`). |
|
||||
| 06 release | 7 | 7 ✓ | Unchanged: v0.4.2 annotated tag on `ead8e42`, changelog post, deck sync. |
|
||||
|
||||
**Score:** 57/57 truths verified (5 SC + 52 plan truths; 0 failed; 0 present-but-behavior-unverified). The three behavior-dependent truths that had failed (CR-01's cancellation/cleanup invariant, the WR-08 masking, the WR-07 status list) each have a passing behavioral test named above, so they count as verified on behavior, not on presence.
|
||||
|
||||
### Required Artifacts
|
||||
|
||||
| Artifact | Expected | Status | Details |
|
||||
|----------|----------|--------|---------|
|
||||
| `.planning/notes/2026-10-21-console-charm-stack.md` | D-02 decision note | ✓ VERIFIED | Committed before any charm import |
|
||||
| `modules/diary/{diary,config,component,console,daily,channels}.go` | diary module | ✓ VERIFIED | Substantive and wired |
|
||||
| `modules/bonfire/{globals,execute,list,help,docs,errors,live,helpers,styles,version}.go` | console layer | ✓ VERIFIED | live.go now has the watcher, `liveView.done` and the claim-before-build; helpers.go sanitizes the plain Process path |
|
||||
| `internal/argmask/argmask.go` | shared secret masking (new) | ✓ VERIFIED | Imported by `modules/bonfire/errors.go` and `cmd/summer/runtime.go`; the only masking implementation |
|
||||
| `modules/surf/summary.go`, `accesslog.go` | serve summary, report and request log | ✓ VERIFIED | |
|
||||
| `modules/lagoon/migrations.go` | PendingMigrations, migrationSets, Status | ✓ VERIFIED (was PARTIAL) | `migrationSets(gdb, plugins)` used by Migrate, setStatus and PendingMigrations; exported Status unchanged in behavior |
|
||||
| `modules/lagoon/commands.go` | migrate, migrate:status | ✓ VERIFIED | `setStatus` for the status command; re-read warning (WR-13) |
|
||||
| `modules/conga/worker.go` | Worker.Stats | ✓ VERIFIED | |
|
||||
| `internal/dev/watch.go` | dev runs serve | ✓ VERIFIED | |
|
||||
| `cmd/summer/commands_documented_test.go` | failing coverage gate | ✓ VERIFIED | |
|
||||
| `cmd/summer/runtime.go` | delegate exit code, streams, masked errors | ✓ VERIFIED (was PARTIAL) | `argmask.Join(argv)` on every error branch |
|
||||
| `scripts/check-phase19.8.sh` | fail-closed gate | ✓ VERIFIED | all local stages green at HEAD |
|
||||
|
||||
### Key Link Verification
|
||||
|
||||
| From | To | Via | Status |
|
||||
|------|----|-----|--------|
|
||||
| generated main (internal/build) | diary | `diary.Bootstrap` then `diary.Setup(cfg` | ✓ WIRED |
|
||||
| generated main | bonfire.Execute / OnGlobals | `os.Exit(run(...))`, `bonfire.OnGlobals(diary.ApplyGlobals)` | ✓ WIRED |
|
||||
| diary/channels.go | sunscreen | `sunscreen.Wrap(` on every leaf | ✓ WIRED |
|
||||
| conga/client.go | diary | `diary.For("river")` | ✓ WIRED |
|
||||
| lagoon/connection.go | diary | `NewSlogLogger(diary.For("db")` | ✓ WIRED |
|
||||
| diary/console.go | bonfire | `bonfire.PrintAbove(` (console.go:165), plain write on false; `bonfire.Sanitize` | ✓ WIRED (PrintAbove now reports false after a cancellation, so the line is written) |
|
||||
| surf/accesslog.go | diary | `diary.For("http")` | ✓ WIRED |
|
||||
| surf/serve.go | lagoon | `lagoon.PendingMigrations(` | ✓ WIRED |
|
||||
| lagoon/commands.go migrate:status | lagoon/migrations.go migrationSets | `setStatus(gdb, plugins)` | ✓ WIRED (new) |
|
||||
| modules/bonfire/errors.go | internal/argmask | `argmask.Join` / `argmask.Quote` | ✓ WIRED (new) |
|
||||
| cmd/summer/runtime.go | internal/argmask | `argmask.Join(argv)` | ✓ WIRED (new) |
|
||||
| cmd/summer tests | bonfire.CheckDocs | `bonfire.CheckDocs(` | ✓ WIRED |
|
||||
| cmd/summer/runtime.go | bonfire/execute.go | `Reported(WithExitCode(...))` | ✓ WIRED |
|
||||
| v0.4.2 tag | changelog | changelog-post.py | ✓ (post id 37, HTTP 200) |
|
||||
|
||||
### Data-Flow Trace (Level 4)
|
||||
|
||||
| Artifact | Data | Source | Real data | Status |
|
||||
|----------|------|--------|-----------|--------|
|
||||
| serve startup summary | migration state, plugins, drivers | `lagoon.PendingMigrations`, `party` plugins, config lookups | yes (TestServeLifecycle with Postgres) | ✓ FLOWING |
|
||||
| `migrate:status` / `--json` | applied and pending list per set | `setStatus` over `migrationSets` + `PendingMigrations` | yes, framework sets included (TestMigrateStatusJSON with Postgres) | ✓ FLOWING (was PARTIAL) |
|
||||
| `list --format=json` | command catalog | `bonfire.Describe` over registered commands | yes (35 commands) | ✓ FLOWING |
|
||||
| http request log | method, pattern, status | `recordPattern` in the route mux | yes (TestAccessLog*) | ✓ FLOWING |
|
||||
|
||||
### Behavioral Spot-Checks (verifier-run, re-verification)
|
||||
|
||||
| Behavior | Command | Result | Status |
|
||||
|----------|---------|--------|--------|
|
||||
| Bare tool prints the Artisan list | `summer --no-ansi` | grouped list, exit 0 | ✓ PASS |
|
||||
| Per-command help | `summer help make:command`, `bin/hello help migrate:status` | Description, Usage, Arguments, Global options, Help, Examples | ✓ PASS |
|
||||
| Unknown command is a usage error | `summer bogus` | ERROR block, `Try: summer list`, exit 2 | ✓ PASS |
|
||||
| JSON catalog complete | `summer list --format=json` | 35 commands, 0 without examples | ✓ PASS |
|
||||
| -q keeps data | `bin/hello key:generate -q` | key printed, exit 0 | ✓ PASS |
|
||||
| --json for scripts | `bin/hello route:list --json` | JSON route list | ✓ PASS |
|
||||
| Wrong-directory hint masks secrets | `cd plugins; ../bin/hello admin:create --email a@b.c --password hunter2` | `--password ***` | ✓ PASS |
|
||||
| Same with a dash-leading value | `... --password -Xk9q` | `--password ***` | ✓ PASS (was FAIL) |
|
||||
| Signal-killed delegate masks argv | `summer admin:create ... --password hunter2 --api-token=t0k3n --client-secret -Xk9q` (fake app binary that SIGKILLs itself) | `--password *** --api-token=*** --client-secret ***: signal: killed`, exit 1 | ✓ PASS (new) |
|
||||
| Delegate exit code passthrough | fake app binary exiting 2 | summer exit 2, no second block | ✓ PASS |
|
||||
| Lines after a cancelled Process view | scratch probe under `script(1)`, 5 runs | `after-cancel-line` printed once after the last frame in 5/5; PrintAbove returned false (caller writes); cursor restored | ✓ PASS (was FAIL) |
|
||||
| Pending framework migration in `migrate:status --json` | TestMigrateStatusJSON (Docker) | listed with `applied: false`; the table lists the framework sets first | ✓ PASS (was gap) |
|
||||
| Exported `lagoon.Status` consumer | `../fonoteka.go` `go test -run TestRollbackIsolatesFonotekaFullSchema ./parity/` (Docker) | ok | ✓ PASS |
|
||||
|
||||
### Probe Execution
|
||||
|
||||
No `scripts/*/tests/probe-*.sh` exist. The phase's probe is `scripts/check-phase19.8.sh`:
|
||||
|
||||
| Stage | Command | Result | Status |
|
||||
|-------|---------|--------|--------|
|
||||
| self-test | `bash scripts/check-phase19.8.sh self-test` | "phase19.8 self-test passed" | PASS |
|
||||
| go | `bash scripts/check-phase19.8.sh go` (live tree; the code tree is clean) | "phase19.8 go passed" (vet, short, race, named tests), exit 0 | PASS |
|
||||
| hello | `bash scripts/check-phase19.8.sh hello` | "phase19.8 hello passed", exit 0 | PASS |
|
||||
| docs | `bash scripts/check-phase19.8.sh docs` | "phase19.8 docs passed", exit 0 | PASS |
|
||||
| docker | `PHASE198_REQUIRE_DOCKER=1 TESTCONTAINERS_HOST_OVERRIDE=127.0.0.1 bash scripts/check-phase19.8.sh docker` | 8 lagoon and 3 cabana tests passed, exit 0 | PASS |
|
||||
| plugins, apps, landing | not re-run | These stages touch sibling repos, and another session is working in `../sm-demo-app`. The gap-closure commits do not change the generated main or any exported API, so no plugin or app pin is affected. Plan 06 recorded `--all` green at ead8e42 | NOT RUN by verifier |
|
||||
|
||||
### Requirements Coverage
|
||||
|
||||
There are no REQUIREMENTS.md IDs for this phase (none orphaned). The plans' `requirements:` fields cite SC-1..SC-5 and D-01..D-30:
|
||||
|
||||
| Source | IDs | Status |
|
||||
|--------|-----|--------|
|
||||
| CONTEXT decisions | D-01..D-30 | 30/30 honored; D-03, D-19, D-21 and D-27, which the plan 05 gaps touched, are now fully satisfied |
|
||||
| Plan 01 | SC-1, SC-2, D-01, D-02, D-04, D-05, D-08, D-09, D-10, D-12 | ✓ SATISFIED |
|
||||
| Plan 02 | SC-2, SC-3, D-01..D-04, D-13..D-18, D-27, D-28, D-30 | ✓ SATISFIED |
|
||||
| Plan 03 | SC-3, SC-4, SC-5, D-04..D-07, D-11, D-17..D-29 | ✓ SATISFIED |
|
||||
| Plan 04 | SC-5 and D-03, D-08..D-10, D-12..D-19, D-21, D-23, D-25, D-27..D-29 | ✓ SATISFIED |
|
||||
| Plan 05 | D-03, D-04, D-10, D-12, D-18, D-19, D-21, D-25, D-27..D-29 | ✓ SATISFIED (was PARTIAL) |
|
||||
| Plan 06 | D-26 | ✓ SATISFIED |
|
||||
|
||||
### Prohibitions
|
||||
|
||||
| Plan | Prohibition | Tier | Disposition |
|
||||
|------|-------------|------|-------------|
|
||||
| 01 | Only bonfire and diary import charm modules | judgment (checked mechanically) | Holds: `internal/argmask` imports only `strings` |
|
||||
| 01 | Nothing imports gopkg.in/yaml.v3 | judgment (checked mechanically) | Holds |
|
||||
| 01-03 | No consuming application named in framework code and docs | judgment | LLM-judge: holds (gate docs stage, 67 files clean at HEAD); non-authoritative |
|
||||
| 01-06 | Nothing pushed or tagged before the go-ahead; no force-push; no deploy | judgment | LLM-judge: holds (the gap-closure commits are local and untagged); non-authoritative |
|
||||
| 02 | No method added to Output, Input or Progress | judgment (checked mechanically) | Holds: no exported change in the gap-closure diff |
|
||||
| 02 | No prompt blocks a non-TTY, -n or -q run | judgment | Holds per TestPromptsNoInteraction, TestConfirmProceed, TestConfirmProductionNonTTYProceeds |
|
||||
| 03 | No command name or flag removed or renamed | judgment | LLM-judge: holds (migrate:status kept its name, flags, Description and Examples; only its Long help changed); non-authoritative |
|
||||
| 03 | Summary, hints and logs never print a DSN password, key, token or secret | judgment | LLM-judge: **holds for every known path** (was violated). The wrong-directory hint with a dash-leading value and the delegate signal and start-failure errors now mask through `internal/argmask`; reproduced with built binaries. Non-authoritative |
|
||||
| 04 | No test skipped or weakened; goldens free of credentials | judgment | LLM-judge: holds. The WR-12 rework returned TestTwoPluginMigrationSetsIsolated and TestDocsMigrate to their pre-57a849d form rather than weakening them; non-authoritative |
|
||||
| 05 | No exported identifier removed or changed | judgment (checked mechanically) | Holds: `git diff a28957f..HEAD` over modules, cmd and internal (non-test) shows only `argmask.Join` and `argmask.Quote` added, both under `internal/`; `lagoon.Status` keeps its signature and its plugin-only result |
|
||||
| 05 | WR-01 fixed inside live views; generated main unchanged | judgment | Holds: internal/build and cmd/summer/main.go are unchanged since the first verification |
|
||||
| 06 | Changelog token never printed | judgment | LLM-judge: holds per SUMMARY; non-authoritative |
|
||||
|
||||
### Test Quality Audit
|
||||
|
||||
| Test area | Linked | Skipped | Circular | Assertion level | Verdict |
|
||||
|-----------|--------|---------|----------|-----------------|---------|
|
||||
| diary (30 named) | SC-1, D-08..D-12 | 1 conditional (root) | no | value/behavioral (slogtest conformance) | OK |
|
||||
| bonfire goldens | D-13, D-14, D-18 | 0 | partial: created with `-update` | value | OK, provenance PARTIAL (human item 1) |
|
||||
| bonfire behavior (live, signals, exit codes) | D-03, D-18, D-27 | 0 | no | behavioral (helper processes, pty, cancellation) | OK; cancellation now covered by TestPrintAboveAfterCancel |
|
||||
| lagoon Docker | D-21, WR-07, WR-12, WR-13 | 0 under Docker | no | value | OK; TestMigrateStatusJSON asserts a pending framework migration |
|
||||
| cmd/summer runtime | CR-02, CR-03, WR-03, WR-10 | POSIX-only conditionals | no | behavioral | OK; signal-death and start-failure leak cases covered |
|
||||
|
||||
Disabled tests on requirements: 0. Circular: 0 blocking. Insufficient assertions: 0. One small note carried from the quick verification: printAbove's "Run ended on its own" branch (not via cancellation) has no committed pinning test; the cancellation branch it shares code with is pinned.
|
||||
|
||||
### Anti-Patterns Found
|
||||
|
||||
| File | Line | Pattern | Severity | Impact |
|
||||
|------|------|---------|----------|--------|
|
||||
| internal/build/artifact.go | 182 | `"TODO: describe " + cmdName` | ℹ️ Info | A template string the scaffolder writes into generated commands (pre-phase) |
|
||||
| cmd/summer/main.go, runtime.go, runtime_test.go, modules/flare/commands.go | n/a | gofmt drift | ℹ️ Info | Pre-existing, recorded in deferred-items.md and in the quick SUMMARY |
|
||||
| modules/lagoon/commands.go | migrate:status Description | "Show per-plugin migration history" / example "Show the applied migrations per plugin" | ℹ️ Info | The command now also lists framework sets; the Long help says so, but the one-line description still says per-plugin. Kept unchanged on purpose (P-06); cosmetic |
|
||||
|
||||
No TBD, FIXME or XXX in any file this phase or the gap-closure task changed; no TODO or HACK added.
|
||||
|
||||
### Human Verification Required
|
||||
|
||||
The goal is achieved; these are the confirmation checks from the first report. Three are visual or terminal checks the contract always routes to a person, and one is the explicit resolution of judgment-tier prohibitions. None is a suspected defect.
|
||||
|
||||
1. **Styled list, help and error on a real terminal.** Run `summer`, `bin/<app>`, `bin/<app> help admin:token:create` and `bin/<app> bogus`. Expected: the output matches the D-13, D-14 and D-18 previews in palette and alignment. Why human: the goldens came from the code itself.
|
||||
2. **Live widgets and prompts.** Run Spinner, Progress, Process, Choice, Ask, Confirm and Secret on a pty, then press Ctrl+C during a spinner. Expected: SSU-like look; log lines print above the view; the cursor comes back. Why human: animation and keyboard input. (The cancellation path of gap 1 is now covered by a test and a pty probe; this item is about look and feel.)
|
||||
3. **serve on a real application.** Run it at the default verbosity, with `-q` and with `-v`, then stop it with Ctrl+C; also run `summer dev`. Expected: a readable startup block, component-prefixed INFO lines, a shutdown report, exit 0. Why human: readability.
|
||||
4. **Judgment-tier prohibitions.** Confirm or reject the non-authoritative verdicts in the Prohibitions table. The secrets prohibition is no longer flagged as violated.
|
||||
|
||||
### Gaps Summary
|
||||
|
||||
No gaps remain. All five roadmap success criteria and all 52 plan truths hold in the codebase at HEAD `4141cd9`. The three gaps of the first report were each re-checked in the code, with the named pinning tests, and with an independent run that reproduced the original failure scenario:
|
||||
|
||||
1. **Live view lifecycle (WR-09):** a cancelled view now ends with its last frame and is unregistered without waiting for the work function. Later lines print plainly, and `PrintAbove` reports false. The pty probe passed 5/5, where the first verification had failed 3/3.
|
||||
2. **Argv masking (WR-11, WR-10):** one helper (`internal/argmask`) masks dash-leading values and is used by both the wrong-directory hint and summer's delegate errors. Built binaries no longer print `-Xk9q`, `hunter2` or `t0k3n`.
|
||||
3. **Migration reporting (WR-12):** `migrate:status` (table and `--json`) reads the same `migrationSets` as `migrate`, so framework sets and their pending migrations are listed. The exported `lagoon.Status` keeps its plugin-only contract, and the fonoteka parity consumer passes.
|
||||
|
||||
The follow-up items WR-13, WR-14, IN-14 and IN-15 are fixed too. IN-10..IN-13, IN-01..IN-09 and the exit-130 idea are recorded as deferred in the console review todo. The gap-closure commits are local and unreleased; shipping them needs a v0.4.x tag on the user's go-ahead.
|
||||
|
||||
---
|
||||
|
||||
_Verified: 2026-10-22T02:37:57Z_
|
||||
_Verifier: Claude (msd-verifier)_
|
||||
@@ -0,0 +1,392 @@
|
||||
---
|
||||
phase: quick-261022-2zy
|
||||
plan: 01
|
||||
quick_id: 261022-2zy
|
||||
slug: user-and-sociallogin-plugin-security-gap
|
||||
type: execute
|
||||
mode: quick
|
||||
wave: 1
|
||||
depends_on: []
|
||||
autonomous: false
|
||||
requirements:
|
||||
- 2zy-1 # /register mass assignment (organisation fields, IP columns, account flags)
|
||||
- 2zy-2 # no Entra tenant ID in sign-in data
|
||||
- 2zy-3 # no post-login event; listener errors discarded
|
||||
- 2zy-4 # offboarding: sessions and API tokens survive deactivate/ban, /refresh never reloads the user
|
||||
- 2zy-5 # stale organisation_role after a membership change
|
||||
files_modified:
|
||||
# sm-user-plugin, worked in the sm-demo-app submodule checkout
|
||||
- ../sm-demo-app/plugins/golem15/user/models/user.go
|
||||
- ../sm-demo-app/plugins/golem15/user/controllers/registration.go
|
||||
- ../sm-demo-app/plugins/golem15/user/controllers/api_controller.go
|
||||
- ../sm-demo-app/plugins/golem15/user/controllers/organisations_admin_controller.go
|
||||
- ../sm-demo-app/plugins/golem15/user/controllers/users_admin_controller.go
|
||||
- ../sm-demo-app/plugins/golem15/user/classes/admin_actions.go
|
||||
- ../sm-demo-app/plugins/golem15/user/classes/admin_actions_test.go
|
||||
- ../sm-demo-app/plugins/golem15/user/classes/events.go
|
||||
- ../sm-demo-app/plugins/golem15/user/register_test.go
|
||||
- ../sm-demo-app/plugins/golem15/user/offboarding_test.go
|
||||
- ../sm-demo-app/plugins/golem15/user/admin_organisations_edge_test.go
|
||||
- ../sm-demo-app/plugins/golem15/user/README.md
|
||||
# sm-sociallogin-plugin, standalone repo
|
||||
- ../sm-sociallogin-plugin/classes/flow.go
|
||||
- ../sm-sociallogin-plugin/classes/provider.go
|
||||
- ../sm-sociallogin-plugin/classes/providers_goth.go
|
||||
- ../sm-sociallogin-plugin/classes/codes.go
|
||||
- ../sm-sociallogin-plugin/classes/registration.go
|
||||
- ../sm-sociallogin-plugin/classes/events.go
|
||||
- ../sm-sociallogin-plugin/matching_test.go
|
||||
- ../sm-sociallogin-plugin/providers_goth_test.go
|
||||
- ../sm-sociallogin-plugin/fakeprovider_test.go
|
||||
- ../sm-sociallogin-plugin/signin_event_test.go
|
||||
- ../sm-sociallogin-plugin/README.md
|
||||
# summercms.go framework (Task 4, after phase 19.8 has released)
|
||||
- modules/cabana/relation.go
|
||||
- modules/cabana/relation_field.go
|
||||
- modules/cabana/crud.go
|
||||
- modules/cabana/clear_on_change_test.go
|
||||
- modules/cabana/README.md
|
||||
- docs/backend/relation-manager.md
|
||||
# only after the user's go-ahead (Task 5)
|
||||
- ../sm-demo-app/plugins/golem15/user (submodule pointer)
|
||||
- ../sm-demo-app/plugins/golem15/sociallogin (submodule pointer)
|
||||
- ../sm-bm-app/plugins/golem15/user (submodule pointer)
|
||||
- ../sm-bm-app/plugins/golem15/sociallogin (submodule pointer)
|
||||
- ../fonoteka.go/plugins/golem15/user (submodule pointer)
|
||||
- ../sm-grzybyfunkcjonalne-app/plugins/golem15/user (submodule pointer)
|
||||
- ../sm-summercmsio-app/plugins/golem15/user (submodule pointer)
|
||||
|
||||
estimate:
|
||||
tokens: 220000
|
||||
raw_tokens: 220000
|
||||
tasks: 5
|
||||
confidence: low
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "/register and controllers.RegisterUser fill only name, surname, email, password, preferred_locale and marketing_consent; body values for organisation_id, organisation_role, created_ip_address, last_ip_address, is_onboarded and must_change_password are ignored, and the IP columns come only from RegisterOptions.IP. models.User.Fillable() is unchanged, so admin forms, form extensions and RegisterUser callers keep working."
|
||||
- "Deactivation is self-service reversible: a password login or a social sign-in of a deactivated (soft-deleted) account restores it, sends the reactivate mail and issues a fresh working token. Deactivating also ends every earlier session JWT and revokes every personal API token, and those stay dead after the restore."
|
||||
- "A ban is the hard lock: a banned user (deactivated or not) is refused by password login, by social sign-in on every path (identity, verified email match, link, pending completion) without being restored, and by /refresh; banning ends every earlier session JWT and revokes every personal API token, and unban does not revive them."
|
||||
- "POST /_user/api/v1/refresh answers 401 {error: Could not refresh token, msg: User not found} for a token whose user is deactivated, banned, or that was issued before the user's tokens_valid_after; a valid refresh still answers 200 and stamps last_seen."
|
||||
- "A Microsoft (Entra) sign-in stores the tenant ID from the id_token tid claim in the identity's profile_data.tenant_id; other providers' profile_data keeps exactly name, email, avatar."
|
||||
- "Every successful social sign-in (login, register, link, pending completion) fires classes.SignedInEvent with provider, provider ID, identity row ID, tenant ID, provider email, action and created flag; its listener errors and the user.Register / user.Created listener errors (social and /register) are logged and never fail or undo the sign-in or registration."
|
||||
- "cabana's RelationContract (hasMany) and FieldRelationContract (belongsTo) accept ClearOnChange; the user plugin uses it so that adding or removing a member in the Organisations members manager, and changing the organisation in the Users form, clears organisation_role unless the same save writes it; deleting an organisation still clears both columns."
|
||||
artifacts:
|
||||
- path: ../sm-demo-app/plugins/golem15/user/models/user.go
|
||||
provides: "RegisterFillable, the explicit registration allow-list"
|
||||
contains: "func (User) RegisterFillable"
|
||||
- path: ../sm-demo-app/plugins/golem15/user/classes/admin_actions.go
|
||||
provides: "RevokeUserAccess; DeactivateUsers and BanUsers revoke sessions and API tokens"
|
||||
contains: "func RevokeUserAccess"
|
||||
- path: ../sm-demo-app/plugins/golem15/user/controllers/api_controller.go
|
||||
provides: "Refresh subject check; /register logs listener errors"
|
||||
contains: "ThrottleStates"
|
||||
- path: ../sm-sociallogin-plugin/classes/events.go
|
||||
provides: "SignedInEvent, SignedInEventName"
|
||||
contains: "type SignedInEvent struct"
|
||||
- path: ../sm-sociallogin-plugin/classes/providers_goth.go
|
||||
provides: "Microsoft code exchange that keeps the id_token and reads tid"
|
||||
contains: "id_token"
|
||||
- path: modules/cabana/relation_field.go
|
||||
provides: "FieldRelationContract.ClearOnChange applied in assignBelongsTo"
|
||||
contains: "ClearOnChange"
|
||||
- path: modules/cabana/relation.go
|
||||
provides: "RelationContract.ClearOnChange applied on hasMany link and unlink"
|
||||
contains: "ClearOnChange"
|
||||
key_links:
|
||||
- from: ../sm-demo-app/plugins/golem15/user/controllers/registration.go
|
||||
to: ../sm-demo-app/plugins/golem15/user/models/user.go
|
||||
via: "lagoon.Fill with RegisterFillable() instead of Fillable() at registration.go:73"
|
||||
pattern: "RegisterFillable\\(\\)"
|
||||
- from: ../sm-demo-app/plugins/golem15/user/middleware/api_token.go
|
||||
to: ../sm-demo-app/plugins/golem15/user/classes/admin_actions.go
|
||||
via: "token.IsUsableAt(now) refuses rows whose revoked_at RevokeUserAccess set"
|
||||
pattern: "revoked_at"
|
||||
- from: ../sm-sociallogin-plugin/classes/flow.go
|
||||
to: ../sm-sociallogin-plugin/classes/events.go
|
||||
via: "app.Events.Fire(ctx, &SignedInEvent{...}) in signIn and linkIdentity"
|
||||
pattern: "SignedInEvent\\{"
|
||||
- from: ../sm-sociallogin-plugin/classes/providers_goth.go
|
||||
to: ../sm-sociallogin-plugin/classes/flow.go
|
||||
via: "Profile.TenantID -> upsertIdentity profile_data tenant_id and PendingPayload.TenantID"
|
||||
pattern: "TenantID"
|
||||
- from: ../sm-demo-app/plugins/golem15/user/controllers/organisations_admin_controller.go
|
||||
to: modules/cabana/relation.go
|
||||
via: "members RelationContract ClearOnChange organisation_role, applied before Save(child) in linkRelated/unlinkRelated"
|
||||
pattern: "ClearOnChange"
|
||||
- from: ../sm-demo-app/plugins/golem15/user/controllers/users_admin_controller.go
|
||||
to: modules/cabana/relation_field.go
|
||||
via: "organisation FieldRelationContract ClearOnChange organisation_role, applied in assignBelongsTo before the row write"
|
||||
pattern: "ClearOnChange"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Close the five security gaps reported while planning sm-servers-app in the core plugins sm-user-plugin and sm-sociallogin-plugin, plus one small additive framework feature in cabana, without breaking any app that uses them (fonoteka.go, sm-bm-app, sm-demo-app, sm-grzybyfunkcjonalne-app, sm-summercmsio-app).
|
||||
|
||||
1. (2zy-1) /register mass assignment: organisation fields, the IP columns and account flags.
|
||||
2. (2zy-2) No Entra tenant ID in sign-in data.
|
||||
3. (2zy-3) No post-login event; register/created listener errors discarded.
|
||||
4. (2zy-4) Offboarding: sessions and personal API tokens survive deactivate and ban; /refresh never reloads the user.
|
||||
5. (2zy-5) Stale organisation_role after a membership change.
|
||||
|
||||
Purpose: an application (sm-servers-app first) can let users in through Entra, assign organisations by tenant and email domain, and lock people out with a ban.
|
||||
Output: local commits in the sm-user-plugin checkout at ../sm-demo-app/plugins/golem15/user, in ../sm-sociallogin-plugin and in summercms.go, each with tests and README/docs in the same commit; then a blocking go-ahead before any push, tag, changelog post or submodule pointer bump.
|
||||
|
||||
Decisions (user-confirmed or planner's discretion; the executor does not revisit them):
|
||||
- OD-1, confirmed by the user: deactivation is self-service and reversible, a ban is the lock. Password /login already restores a deactivated account (WinterCMS parity, session_test.go TestLogin around :140-156), and social sign-in keeps doing the same through controllers.FinishLogin (registration.go:98-123) — no change in sociallogin's checkCanSignIn and TestSoftDeleted stays as it is. What deactivation adds is the end of earlier sessions and personal API tokens: the user signs in again (which reactivates) and gets a fresh token. /refresh refuses deactivated users so a session never slides silently past a deactivation. A ban is refused everywhere: password login (classes.RejectIfThrottled, existing), social sign-in (checkCanSignIn, existing, now pinned for banned and deactivated accounts), /refresh (new), session JWTs and API tokens (revoked at ban, new).
|
||||
- OD-2, confirmed: the profile_data key is tenant_id, written only when a tenant is known.
|
||||
- OD-3, decided: cabana gets an additive ClearOnChange []string on RelationContract (hasMany) and on FieldRelationContract (belongsTo), instead of a GORM hook on models.User. Reason: the change is small (three call sites: the hasMany branches of linkRelated and unlinkRelated in modules/cabana/relation.go around :955 and :1250, and assignBelongsTo in modules/cabana/relation_field.go:648-663; plus two boot validations), declarative, a no-op at its zero value, and it covers both admin paths: the members manager saves the child with Save(child) and no controller hook, and the user form assigns the picked organisation after FormBeforeUpdate (modules/cabana/crud.go: formBeforeUpdate, then assignBelongsTo, then Save), so neither path can be handled in the plugin without a model-wide hook. A declarative column list was preferred over new pact hook interfaces because a hook for the belongsTo path would need the old and the new key, which ClearOnChange compares internally. Cost: the user plugin's stale-role commit needs a summercms tag that contains ClearOnChange, so it is the last user-plugin commit and ships in its own tag (see Task 5).
|
||||
- Registration allow-list (user's "yes add it"): RegisterUser fills from a new explicit list, User.RegisterFillable(): name, surname, email, password (the hash), preferred_locale, marketing_consent. Not settable from a registration body any more: organisation_id, organisation_role (membership is the admin's or the application's), created_ip_address, last_ip_address (server-set from RegisterOptions.IP only; today a body value survives when opts.IP is empty), is_onboarded (application-owned onboarding state), must_change_password (set by the admin and the require-password-change console command). Already outside Fillable and unchanged: is_activated, activated_at, activation_code(_issued_at), reset_password_code(_issued_at), has_self_set_password, tokens_valid_after, email_verified_at, handle, permissions, last_seen. User.Fillable() itself stays as it is: cabana intersects it with the admin form's fields (and an app's form extensions), and no other caller fills it from client input. Verified frontends send only email, password, password_confirmation, name, surname, marketing_consent plus event-only keys (fonoteka vue app stores/auth.ts:370-378, sm-bm-app frontend api/user.ts:29, vue-demo-app useDemoAuth.ts:34), and every RegisterUser caller passes keys inside the new list (fonoteka onboarding.go:102 and console/dev_seed.go:362, sociallogin registration.go:121-129).
|
||||
- Listener errors are logged, never returned: user.Register / user.Created in /register and in social account creation, and the new sociallogin.SignedIn event. A committed registration or sign-in is never undone by a listener, as in WinterCMS.
|
||||
- The login event lives in sociallogin (classes.SignedInEvent): provider, identity and tenant are sociallogin concepts. One event covers login, register, link and pending completion; Created=true marks an account created by that sign-in.
|
||||
- No framework change for tokens or refresh: front-end personal API tokens live in the user plugin (models/api_token.go:28 table user_api_tokens, guard middleware/api_token.go:35-83); cabana's BackendAPIToken (backend_api_tokens) is for backend administrators and untouched. /refresh is the plugin's controllers.Refresh (api_controller.go:178-215, bouncer.Refresh at :193); bouncer.RefreshAudienceFor would demand an aud claim (PHP-era tokens have none) and knows nothing of bans, so the plugin checks the subject itself.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/msd-core/workflows/execute-plan.md
|
||||
@~/.claude/msd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@CLAUDE.md
|
||||
@.planning/STATE.md
|
||||
@../sm-demo-app/plugins/golem15/user/README.md
|
||||
@../sm-sociallogin-plugin/README.md
|
||||
@modules/cabana/README.md
|
||||
@docs/backend/relation-manager.md
|
||||
|
||||
Working copies (observed at planning time, 2026-10-22):
|
||||
- sm-user-plugin: ../sm-demo-app/plugins/golem15/user, detached HEAD 9986342 (= tag v0.1.6 = origin/master); local branch master is stale at 24b16b1 (an ancestor). All five apps mount the plugin at 9986342. sm-demo-app's go.work only replaces the module (it is not in the use list), so every go command in this checkout runs with GOWORK=off; the plugin's go.mod line 176 replaces summercms with ../../../../summercms.go, so it builds against summercms.go's working tree, including other sessions' uncommitted edits (verified: GOWORK=off go vet ./... passes).
|
||||
- sm-sociallogin-plugin: ../sm-sociallogin-plugin on master at 9fd58db (= origin/master), clean. Its tracked go.work replaces sm-user-plugin with ../fonoteka.go/plugins/golem15/user (9986342), so its own tests run against the published user plugin; no sociallogin change depends on new user-plugin API.
|
||||
- summercms.go: phase 19.8 (console overhaul) is executing. At planning time the working tree had another session's uncommitted edits in modules/lagoon (19.8-05) and planning files; nothing in modules/cabana, modules/pact or docs/backend. Remaining 19.8 plans: 19.8-05 (bonfire, diary, lagoon, cmd/summer, internal/dev, docs/console, docs/services/logging.md: no overlap with Task 4's files) and 19.8-06 (release v0.4.2, whose range check requires origin/master..master to hold only 19.8-05's code commits and planning documents). A cabana commit before 19.8-06 runs would trip that check, hence Task 4's precondition. Latest tag: v0.4.1.
|
||||
- Do not edit fonoteka.go or other app checkouts before Task 5's go-ahead. In summercms.go and the plugin repos, stage files by explicit path, never git add -A, never touch .planning/STATE.md or state.json by hand, no co-author tags.
|
||||
|
||||
Source facts the tasks rely on (file:line in the checkouts above):
|
||||
- user models/user.go:84-92 Fillable(); controllers/registration.go:43-89 RegisterUser fills at :73 and copies opts.IP into the input map at :67-70 only when non-empty; api_controller.go:672 passes readFields(r) (the raw body). /update (api_controller.go:443+) writes only name, surname, email and does not use Fillable.
|
||||
- cabana admin fill: fillAllowed (modules/cabana/crud.go:1184-1205) intersects Fillable with the form's writable fields; protectedFillKey (:1407) excludes organisation_id; the user form writes organisation_id through the organisation FieldRelationContract with WritableForeignKey (users_admin_controller.go:370-388). fonoteka sets organisation_id and organisation_role with Updates(map)/Exec (onboarding.go:119-135, invitation_service.go:266 and :413, org_provisioner.go:63-70).
|
||||
- api_controller.go:685-686 discards FireRegisterEvent / FireCreatedEvent errors; sociallogin classes/registration.go:181-183 does the same.
|
||||
- Members manager: organisations_admin_controller.go:76-90 hasMany contract on users.organisation_id; docs/backend/relation-manager.md:190 (link adopts children whose ForeignKey is NULL, unlink sets it back to NULL). Known-gap test: admin_organisations_edge_test.go:183-213 TestAdminOrganisationRoleOutlivesMembership (assertions at :195-197 and :199-202). Organisation delete already clears both columns (organisations_admin_controller.go:99-117).
|
||||
- cabana relation code: RelationContract (modules/cabana/relation.go:38-58), validateRelationContract (:479) and validateHasManyContract (:558), linkRelated (:917-975, hasMany Save at :955), unlinkRelated (:1231-1260, hasMany Save at :1250); FieldRelationContract (relation_field.go:22-45), compileFieldRelation (:205), assignBelongsTo (:648-663); the form save order in crud.go: lagoon.Fill(target, fillAllowed(...), projected, ...), validation, formBeforeUpdate/formBeforeCreate, applyPermissionValues, checkRelationScope, checkRelationLocks, assignBelongsTo, Save/Create. Helpers: fieldByColumn (http.go:1204), setModelColumn (relation.go:1332), modelFields (model_fields.go:28). Existing tests: relation_child_test.go (hasMany CRUD, deferred), phase121_form_test.go:582 TestWritableForeignKeySmoke, phase121_relation_lock_test.go:235/:279, phase121_schema_boot_test.go.
|
||||
- Deactivation: classes/admin_actions.go:95-113 DeactivateUsers only sets deleted_at; BanUsers :140-168 only writes user_throttle; RestoreUsers :117-134; ThrottleStates :21-57. users_admin_controller.go:629-647 wraps DeactivateUsers and fires DeactivatingEvent. users_admin_controller.go:246 already stamps tokens_valid_after on an admin password reset.
|
||||
- Sessions: classes/user_lookup.go:35-60 PrincipalByID loads scoped and carries TokensValidAfter (the bouncer jwt guard refuses iat before it, modules/bouncer/jwt.go:172); classes/session_user.go:74 does the same for RequestUser. middleware/api_token.go:61-70 accepts any token row with IsUsableAt and a principal. JWT iat has whole-second precision.
|
||||
- Refresh: api_controller.go:178-215; the "never loads the user row" comment and TouchLastSeen at :204-212; session_test.go:225 TestRefresh (craftToken, bearer, insertUser helpers in session_test.go). bouncer.ErrSubjectRejected text is "User not found" (modules/bouncer/jwt.go:20,144). golang-jwt/jwt/v5 is already a direct requirement of the user plugin.
|
||||
- sociallogin classes/flow.go: identityUser :328-338 and userByEmail :272-281 load Unscoped; loginWithIdentity :287-307 calls checkCanSignIn :357-371 (activation, ban) before FinishLogin restores; linkIdentity :128-164 issues its own token; upsertIdentity :385-422 writes profile_data :412-416; signIn :311-324. matching_test.go:365 TestSoftDeleted (restore subtests, kept) and :461 TestInactiveAndBanned (inactive and banned on identity, verified_match, link, pending_completion).
|
||||
- sociallogin Microsoft: providers_goth.go:72-82 Complete calls goth's sess.Authorize, and goth v1.82.0 providers/azureadv2/session.go Authorize keeps only access/refresh token and expiry (the id_token is dropped; Graph /me has no tenant). newMicrosoft :225-254. Profile classes/provider.go:26-41. PendingPayload classes/codes.go:46-77. providers_goth_test.go:38 TestGothProviders (microsoft fake token at :128-130 and :181). settings_test.go:305 TestErrorMessages scans flowErr keys.
|
||||
- Tests that start Postgres need Docker and skip under -short. If the Docker socket is denied although the user is in the docker group, run the go test command through sudo -u golem (CLAUDE.md).
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 1: sm-user-plugin: explicit registration allow-list, server-set IP columns, logged /register listener errors</name>
|
||||
<files>../sm-demo-app/plugins/golem15/user/models/user.go, ../sm-demo-app/plugins/golem15/user/controllers/registration.go, ../sm-demo-app/plugins/golem15/user/controllers/api_controller.go, ../sm-demo-app/plugins/golem15/user/register_test.go, ../sm-demo-app/plugins/golem15/user/README.md</files>
|
||||
<precondition>Docker is reachable for testcontainers (directly or through sudo -u golem), and git -C ../sm-demo-app/plugins/golem15/user status --short is empty.</precondition>
|
||||
<behavior>
|
||||
- TestRegisterIgnoresProtectedFields: seed an organisation row; POST /_user/api/v1/register with email, password, name, surname, preferred_locale "pl", marketing_consent true, plus organisation_id (the seeded id), organisation_role "owner", created_ip_address and last_ip_address "203.0.113.9", is_onboarded true and must_change_password true, answers 200. The stored user has name, surname, preferred_locale and marketing_consent as sent; organisation_id and organisation_role NULL; is_onboarded and must_change_password false; created_ip_address and last_ip_address equal to the request's client address, not 203.0.113.9.
|
||||
- Direct controllers.RegisterUser with the same fields: with RegisterOptions.IP "198.51.100.7" both IP columns hold it; with an empty IP both are NULL whatever the fields say; the other columns as above.
|
||||
- Existing TestRegister, TestRegisterEventPayload (the register event payload still carries the client's extra keys, password and confirmation removed), TestRegisterUserPasswordLess and the sociallogin-shaped RegisterUser calls stay green; the admin Users form tests (admin_users_test.go) stay green, proving Fillable() is untouched.
|
||||
</behavior>
|
||||
<action>
|
||||
Branch setup first (the checkout is detached): in ../sm-demo-app/plugins/golem15/user run git fetch origin, git checkout master and git merge --ff-only origin/master. If origin/master is no longer 9986342, note the new base in the SUMMARY and continue on it. Every go command in this checkout runs with GOWORK=off.
|
||||
|
||||
Allow-list (2zy-1): add func (User) RegisterFillable() []string to models/user.go returning name, surname, email, password, preferred_locale, marketing_consent, with a doc comment naming what a registration body may set and listing what it may not (organisation_id, organisation_role, created_ip_address, last_ip_address, is_onboarded, must_change_password; the activation, reset, proof, handle and permission columns are never fillable). Leave Fillable() (:84-92) as it is and rewrite only its doc comment: the mass-assignment backstop of the admin form (cabana intersects it with the form's fields and keeps organisation_id protected) and of trusted application code; registration uses RegisterFillable.
|
||||
|
||||
RegisterUser (controllers/registration.go:43-89): fill with user.RegisterFillable() instead of user.Fillable() at :73; drop the copy of opts.IP into the input map (:67-70) and instead, after the fill, set CreatedIPAddress and LastIPAddress on the struct from opts.IP when it is not empty, so a body value can never reach those columns. Keep validation, hashing, PasswordLess, Activate and AssignHandle exactly as now. Rewrite the RegisterUser and RegisterOptions doc comments accordingly (fields can set only the RegisterFillable columns; organisation membership and role are set with an explicit update after creation, as an application's onboarding does; IP is the only source of the IP columns).
|
||||
|
||||
Listener errors (2zy-3, user side): at api_controller.go:685-686 replace the discarded results with Error-level logs through the package logger pluginLog (controllers/logger.go): "golem15.user: register listener failed" and "golem15.user: created listener failed", with user_id and err; the registration still succeeds. Update the FireRegisterEvent / FireCreatedEvent doc comments (registration.go:125-150): /register logs that error and continues.
|
||||
|
||||
Tests per the behavior block in register_test.go (reuse sessionApp, disableRegisterThrottle, postJSON; read how the harness sets the request's client address and assert against that).
|
||||
|
||||
README.md (same commits as the code): the registration part of Features/Usage lists what a /register body can and cannot set and that the IP columns are server-set; the Extension events bullet (:39) says /register logs listener errors and continues and that RegisterEvent.Payload is untrusted client input (it still carries every extra key); the RegisterUser / RegisterOptions / FireRegisterEvent / FireCreatedEvent entries (:170-178) match the new comments; add RegisterFillable to the models API list; update the Testing section.
|
||||
|
||||
Commits in the user-plugin checkout, explicit paths, no co-author tags, conventional style as in the repo log: (a) fix: registration fills only an explicit allow-list and never takes the IP columns from the body (models/user.go, registration.go, register_test.go, README); (b) fix: log /register listener failures instead of discarding them (api_controller.go, registration.go comments, README). go vet and the full go test before each commit. Do not push.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /media/nvme/dev/golem15/summercms.io/summercms/sm-demo-app/plugins/golem15/user && GOWORK=off go vet ./... && GOWORK=off go test -count=1 -run 'TestRegister|TestAdminUser' ./... && GOWORK=off go test -count=1 ./... && sed -n '/^func (User) Fillable/,/^}/p' models/user.go | grep -c 'organisation_role'</automated>
|
||||
</verify>
|
||||
<done>Registration fills only RegisterFillable columns and takes the IP columns only from RegisterOptions.IP; Fillable() still lists its twelve columns (the grep prints 1); /register listener failures are logged; the whole user-plugin suite is green; two commits on master in the user-plugin checkout; nothing pushed.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 2: Offboarding: deactivate and ban end sessions and API tokens, /refresh refuses revoked subjects (user plugin); bans pinned and semantics documented (both plugins)</name>
|
||||
<files>../sm-demo-app/plugins/golem15/user/classes/admin_actions.go, ../sm-demo-app/plugins/golem15/user/classes/admin_actions_test.go, ../sm-demo-app/plugins/golem15/user/classes/events.go, ../sm-demo-app/plugins/golem15/user/controllers/api_controller.go, ../sm-demo-app/plugins/golem15/user/offboarding_test.go, ../sm-demo-app/plugins/golem15/user/README.md, ../sm-sociallogin-plugin/matching_test.go, ../sm-sociallogin-plugin/README.md</files>
|
||||
<behavior>
|
||||
- classes: RevokeUserAccess(ctx, db, ids) stamps users.tokens_valid_after with the current time truncated to whole seconds (soft-deleted rows included) and sets revoked_at on every not-yet-revoked user_api_tokens row of those users, leaving earlier revoked_at values unchanged; an empty list writes nothing. DeactivateUsers and BanUsers call it for every selected user (already deactivated or banned ones included) and return the same counts as before.
|
||||
- offboarding_test.go, deactivate: a user with a session JWT issued at least one second before the action and a usr_ API token; after the admin deactivate bulk action the JWT is refused by GET /fetch and by POST /refresh (401, error "Could not refresh token"), the API token is refused by the APITokenGuard; a password login right after the deactivation (same second allowed) restores the account, sends the reactivate mail and returns a token that GET /fetch accepts; the old JWT and the old API token stay refused.
|
||||
- offboarding_test.go, ban: after the ban bulk action the old JWT is refused by /fetch and /refresh and the API token by the guard; password login answers the invalid-login response; after unban the old JWT and API token stay refused and a fresh login works.
|
||||
- offboarding_test.go, refresh subject checks without RevokeUserAccess: a user soft-deleted directly (deleted_at only) -> refresh 401; a user banned directly through a user_throttle row (no stamp) -> refresh 401; a token whose iat precedes tokens_valid_after set directly -> refresh 401; a suspended (not banned) user -> refresh 200; a normal user -> refresh 200 with last_seen stamped; a refused refresh stamps no last_seen. Existing TestRefresh, TestLogin (restore and reactivate mail) and TestDeactivatingEvent stay green.
|
||||
- sociallogin TestInactiveAndBanned (matching_test.go:461) gains a banned-and-deactivated user: identity sign-in, verified email match and pending completion all answer the banned message, the account stays soft-deleted, no reactivate mail is sent and no identity row is created for it. TestSoftDeleted (restore of a deactivated, not banned account) stays unchanged and green.
|
||||
</behavior>
|
||||
<action>
|
||||
User plugin, in ../sm-demo-app/plugins/golem15/user (master after Task 1, GOWORK=off):
|
||||
|
||||
Add the exported RevokeUserAccess(ctx context.Context, db *gorm.DB, userIDs []uint) error to classes/admin_actions.go with the file's transaction convention (callers pass their transaction; use fresh(ctx, db)). One cutoff for both writes: time.Now() truncated to whole seconds, because JWT iat has whole-second precision and a token minted right after the revocation (a reactivating login) must be valid; a token minted earlier within that same second also stays valid, which the doc comment states. Writes: an unscoped UpdateColumn of users.tokens_valid_after for the ids, and an update of models.APIToken setting revoked_at where user_id is in the ids and revoked_at IS NULL. Doc comment: it ends every session JWT issued before the cutoff (the jwt guard, RequestUser and /refresh honour tokens_valid_after) and every personal API token of the users; applications may call it for their own offboarding, and after writing a ban directly to user_throttle.
|
||||
|
||||
DeactivateUsers (:95-113): after the deleted_at writes call RevokeUserAccess for every non-nil selected user with an id, whether deactivated now or before (a repeated deactivate also cleans accounts deactivated before this change); keep the returned count. BanUsers (:140-168): call RevokeUserAccess for all ids after the throttle writes; keep the count. RestoreUsers and UnbanUsers stay unchanged: revoked tokens stay revoked. Update the doc comments of DeactivateUsers, BanUsers and DeactivatingEvent (classes/events.go:59-67: sessions and API tokens are already revoked when it fires).
|
||||
|
||||
Refresh (2zy-4) in controllers/api_controller.go:178-215: keep the bouncer.Refresh call and its existing 401 body for token-level failures. After it succeeds, read the presented token's sub and iat with github.com/golang-jwt/jwt/v5 ParseUnverified (bouncer.Refresh already verified signature, audience, refresh window and blacklist), load the user with a scoped query by id through gdb and the request context, and refuse when the user is not found (missing or deactivated: a deactivated user signs in again, which reactivates the account), when TokensValidAfter is set and iat is before it, or when classes.ThrottleStates (with classes.ContextWithThrottle(ctx, app)) reports a ban. A refusal answers 401 with the existing shape: error "Could not refresh token", msg bouncer.ErrSubjectRejected.Error(); a database failure answers writeOpaque500. Suspension is deliberately not checked: it is the failed-password throttle, and refusing refresh on it would let anyone who knows an email log its owner out. Move TouchLastSeen after the checks so a refused refresh stamps nothing; replace the comment at :204-205 with one describing the subject check.
|
||||
|
||||
Tests per the behavior block: unit cases in classes/admin_actions_test.go (its Postgres setup); offboarding_test.go in the plugin root with the admin harness for bulk actions (admin_harness_test.go) and the session helpers in session_test.go, plus the API-token guard path used by api_tokens_test.go; capture the reactivate mail the way session_test.go TestLogin does.
|
||||
|
||||
User README.md: a short "Deactivation and bans" subsection under Features, and matching edits to the JWT sessions bullet (:20), Last seen (:33), Admin screens (:28) and the Bulk actions row (:129): deactivation is self-service and reversible (a password login or a golem15.sociallogin sign-in restores the account and sends the reactivate mail; an administrator can restore it too), and it ends every earlier session and personal API token, so the user signs in again and gets a fresh token, while old API tokens stay revoked; /refresh refuses deactivated and banned users and tokens from before tokens_valid_after; a ban is the lock: password login, social sign-in and refresh refuse it and its sessions and API tokens are revoked; unban and restore revive no token. Add RevokeUserAccess to the classes API list (:184) and an upgrade note: accounts deactivated or banned before this version keep their API tokens until deactivated or banned again (the actions are idempotent) or RevokeUserAccess runs for them. Update the Testing section.
|
||||
|
||||
Commit in the user-plugin checkout: fix: deactivate and ban revoke sessions and API tokens, and /refresh refuses deactivated, banned and cut-off subjects (admin_actions.go, admin_actions_test.go, events.go, api_controller.go, offboarding_test.go, README.md). No push.
|
||||
|
||||
Sociallogin, in ../sm-sociallogin-plugin (master, own go.work): no code change. Extend TestInactiveAndBanned with the banned-and-deactivated case per the behavior block (seed it like the banned user plus a soft delete; reuse the trash/restored helpers pattern from TestSoftDeleted). README.md: the matching table row (:49) keeps the restore and the line at :55 becomes: every sign-in requires an activated account without a ban; a deactivated account is restored by a social sign-in exactly as by a golem15.user password login (with the reactivate mail), a banned one is refused on every path and never restored. Add a Security notes bullet stating the same split (deactivation is reversible by its owner; a ban is the lock, checked before any restore or identity write). Update the Testing paragraph.
|
||||
|
||||
Commit in ../sm-sociallogin-plugin: test: a banned account is refused and never restored by social sign-in, and the deactivation semantics are documented (matching_test.go, README.md). No push.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /media/nvme/dev/golem15/summercms.io/summercms/sm-demo-app/plugins/golem15/user && GOWORK=off go vet ./... && GOWORK=off go test -count=1 -run 'TestOffboarding|TestRefresh|TestLogin|TestDeactivatingEvent|TestAdminUserActions|TestRevokeUserAccess|TestSharedAPIToken|TestAPIToken' ./... && GOWORK=off go test -count=1 ./... && cd /media/nvme/dev/golem15/summercms.io/summercms/sm-sociallogin-plugin && go vet ./... && go test -count=1 -run 'TestSoftDeleted|TestInactiveAndBanned|TestMatching' ./... && go test -count=1 ./...</automated>
|
||||
</verify>
|
||||
<done>Deactivate and ban revoke earlier session JWTs and personal API tokens; a deactivated user reactivates by signing in and gets a working token; a banned user is refused by login, social sign-in and refresh and is never restored; /refresh refuses deactivated, banned and cut-off subjects with the existing 401 body; both suites green; one commit in each repo; nothing pushed.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 3: sm-sociallogin-plugin: Entra tenant ID in profile_data, SignedInEvent after every social sign-in, logged listener errors</name>
|
||||
<files>../sm-sociallogin-plugin/classes/provider.go, ../sm-sociallogin-plugin/classes/providers_goth.go, ../sm-sociallogin-plugin/classes/codes.go, ../sm-sociallogin-plugin/classes/flow.go, ../sm-sociallogin-plugin/classes/registration.go, ../sm-sociallogin-plugin/classes/events.go, ../sm-sociallogin-plugin/providers_goth_test.go, ../sm-sociallogin-plugin/fakeprovider_test.go, ../sm-sociallogin-plugin/signin_event_test.go, ../sm-sociallogin-plugin/README.md</files>
|
||||
<behavior>
|
||||
- TestGothProviders microsoft (providers_goth_test.go): the fake token answer carries an id_token whose payload has aud = the client id and tid = a GUID; Complete returns Profile.TenantID = that GUID in lower case. Without id_token, with a malformed id_token, with an aud that is not the client id, or with a tid that is not a GUID, TenantID is empty and the sign-in still succeeds. invalid_grant still maps to ErrCodeExpired; no request leaves the provider's hosts; the timeout case holds; the authorization URL cases are unchanged.
|
||||
- signin_event_test.go (Postgres, fake provider with a TenantID): an identity sign-in fires one SignedInEvent with User, Provider "fake", ProviderID, IdentityID (the golem15 oauth identity row id), TenantID, Email (provider email), Action "login", Created false; a register-action creation fires it with Action "register", Created true; a link fires it with Action "link"; a pending completion fires it once; a sign-in that restores a deactivated account fires it once with Action "login".
|
||||
- signin_event_test.go: profile_data holds tenant_id for a profile with a tenant and exactly name, email, avatar without one; a pending sign-in keeps the tenant through PendingPayload (tenant_id in the stored payload, omitted when empty) and the completed identity has profile_data.tenant_id.
|
||||
- signin_event_test.go: a SignedInEvent listener returning an error does not fail the sign-in (token and user payload returned) and the error is logged; a listener that changes the user's name in the database is reflected in the returned user payload; a register/created listener returning an error does not fail social account creation and is logged.
|
||||
</behavior>
|
||||
<action>
|
||||
All work in ../sm-sociallogin-plugin on master (after Task 2's commit). Keep the plugin's logging style (slog.Default(), as in controllers/*.go): sociallogin requires summercms v0.3.1, so do not use diary.
|
||||
|
||||
Tenant ID (2zy-2, key tenant_id per OD-2). Add TenantID string to Profile (classes/provider.go:26-41): the directory tenant the account signed in from (Microsoft Entra tid), empty for providers without tenants. Add TenantID to PendingPayload (classes/codes.go:46-59) with JSON key tenant_id and omitempty; copy it in pendingOutcome (flow.go:233-252) and back in PendingPayload.Profile() (codes.go:63-77). In upsertIdentity (flow.go:385-422) add "tenant_id" to profile_data only when p.TenantID is not empty, so other providers keep exactly name, email, avatar (profile_data is json:"-" on the user plugin's OAuthIdentity, so no API response changes).
|
||||
|
||||
Microsoft exchange (classes/providers_goth.go). goth's azureadv2 Session.Authorize drops the id_token, so the microsoft provider performs the code exchange itself and keeps goth for the authorization URL and the Graph profile. Give gothProvider an optional complete override (a func field taking ctx, the provider, the goth session string and the callback values, returning Profile and error); Complete (:72-82) delegates to it when set, so google, facebook and github are untouched. newMicrosoft (:225-254) builds an oauth2.Config identical to goth's: ClientID, ClientSecret, RedirectURL from cfg; AuthURL https://login.microsoftonline.com/{tenant}/oauth2/v2.0/authorize and TokenURL https://login.microsoftonline.com/{tenant}/oauth2/v2.0/token with the same tenant default (DefaultTenant); scopes from the same azureadv2 ScopeType constants converted with string(). The override exchanges the callback's code with context.WithValue(ctx, oauth2.HTTPClient, p.client); maps a failure through p.exchangeError (invalid_grant still becomes ErrCodeExpired, no response body in the error); refuses a token that is not Valid(); builds an azureadv2.Session with AccessToken, RefreshToken and ExpiresAt from the token; calls p.gp.FetchUser with it and passes the result through the existing microsoft profile mapping; then sets TenantID from token.Extra("id_token"). Add a helper (for example entraTenantID(idToken, clientID string) string) that splits the compact JWT into three parts, base64url-decodes the payload (raw URL encoding, tolerating padding), reads the string claims tid and aud, and returns the lower-cased tid only when aud equals clientID and tid is a canonical GUID (8-4-4-4-12 hex), otherwise the empty string. The signature is not verified: the id_token comes straight from the token endpoint over TLS in a client-authenticated back-channel exchange (OpenID Connect Core 3.1.3.7); write that reason in the helper's doc comment. Never log the id_token, the access token or the code. Update newMicrosoft's doc comment.
|
||||
|
||||
SignedInEvent (2zy-3). Create classes/events.go with SignedInEventName = "sociallogin.SignedIn" and type SignedInEvent struct with User (*usermodels.User), Provider (registry name), ProviderID (the identity's provider_id), IdentityID (uint, the golem15 oauth identity row id), TenantID, Email (provider-reported, possibly empty), Action (ActionLogin, ActionRegister or ActionLink) and Created (true when this sign-in created the account); doc comment: fired after every successful social sign-in, after the identity is stored and FinishLogin ran (so a deactivated account is already restored), before the session token is minted, outside any transaction; listener errors are logged and never fail the sign-in; the user row is re-read afterwards so listener writes reach the token payload. Make upsertIdentity return the identity row id with the error. Change signIn (flow.go:311-324) to receive the provider, the profile, the identity id and the created flag: after FinishLogin it fires the event through app.Events (skipped when app or app.Events is nil), logs a listener error with slog.Default().Error("golem15.sociallogin: sign-in listener failed", "provider", ..., "user_id", ..., "err", ...), then re-reads the user row by id (scoped; any read failure, including a row a listener removed, is returned as an internal error), then IssueToken and APIArray as now. loginWithIdentity (:287-307) and createUser (registration.go:112-185; Created true, Action register) pass the values through; linkIdentity (:128-164) fires the event with Action link and Created false after its transaction and re-reads the user the same way before IssueToken. In createUser replace the discarded FireRegisterEvent / FireCreatedEvent results (registration.go:181-183) with the same Error-level logging ("register listener failed", "created listener failed") and keep the comment that a committed registration is never undone.
|
||||
|
||||
Tests per the behavior block: extend the microsoft cases in providers_goth_test.go (build the unsigned id_token in the test from base64url-encoded JSON header and payload plus any signature segment; the fake token bodies at :128-130 and :181 gain an id_token field); extend fakeprovider_test.go so a fake profile can carry a TenantID; add signin_event_test.go with a listener on the app's event bus registered the way existing tests do (search this repo's and the user plugin's tests for event listener registration) and a slog handler swap to assert the logged failure.
|
||||
|
||||
README.md: Features gains the Microsoft tenant line (profile_data.tenant_id from the id_token tid claim, only when known; other providers unchanged) and an Events subsection documenting SignedInEvent / SignedInEventName with every field, when it fires (login, register, link, pending completion, including a restoring sign-in), Created, the re-read, and that listener errors (and user.Register / user.Created listener errors during social registration) are logged and never fail the sign-in; the Account matching section notes that the pending payload keeps the tenant; Security notes gain the tid trust basis (back-channel id_token, aud must equal the client id, GUID format, signature not checked and why) and the advice to match TenantID together with Provider "microsoft" when assigning organisations by tenant; the API reference adds SignedInEvent, SignedInEventName, Profile.TenantID and PendingPayload.TenantID; the Testing paragraph lists the new tests.
|
||||
|
||||
Commits in ../sm-sociallogin-plugin, explicit paths, no co-author tags: (a) feat: record the Entra tenant ID of a Microsoft sign-in in profile_data (provider.go, providers_goth.go, codes.go, flow.go tenant parts, providers_goth_test.go, fakeprovider_test.go, README); keep tenant assertions that need the event out of this commit; (b) feat: fire SignedInEvent after every social sign-in and log listener failures (events.go, flow.go, registration.go, signin_event_test.go, README). No push.
|
||||
|
||||
Cross-check both plugins together (no repo is modified): write a go.work in your scratchpad directory with go 1.27.0, a use directive with the absolute path of ../sm-sociallogin-plugin, and replace directives sending git.golem15.com/golem15/summercms to the absolute path of summercms.go and git.golem15.com/golem15/sm-user-plugin to the absolute path of ../sm-demo-app/plugins/golem15/user; run the sociallogin suite with GOWORK set to that file and report the result in the SUMMARY.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /media/nvme/dev/golem15/summercms.io/summercms/sm-sociallogin-plugin && go vet ./... && go test -count=1 -run 'TestGothProviders|TestSignedInEvent|TestSoftDeleted|TestInactiveAndBanned|TestMatching|TestRegisterComplete|TestLink|TestErrorMessages' ./... && go test -count=1 ./... && grep -c 'tenant_id' README.md</automated>
|
||||
</verify>
|
||||
<done>Microsoft sign-ins store profile_data.tenant_id from a validated tid; SignedInEvent fires once per successful login, register, link and pending completion with every field; listener errors are logged and never fail a sign-in; the sociallogin suite is green against the published user plugin (its go.work) and against the Task 1-2 user plugin (scratch GOWORK); two commits; nothing pushed.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 4: cabana ClearOnChange (summercms.go) and the user plugin's stale-role fix on top of it</name>
|
||||
<files>modules/cabana/relation.go, modules/cabana/relation_field.go, modules/cabana/crud.go, modules/cabana/clear_on_change_test.go, modules/cabana/README.md, docs/backend/relation-manager.md, ../sm-demo-app/plugins/golem15/user/controllers/organisations_admin_controller.go, ../sm-demo-app/plugins/golem15/user/controllers/users_admin_controller.go, ../sm-demo-app/plugins/golem15/user/admin_organisations_edge_test.go, ../sm-demo-app/plugins/golem15/user/README.md</files>
|
||||
<precondition>Phase 19.8 has released (summercms.go tag v0.4.2 exists and 19.8-06 is complete), or the user has explicitly allowed this cabana commit into the 19.8 release range; and git status --porcelain -- modules/cabana modules/pact docs/backend in summercms.go is empty (no other session editing those paths).</precondition>
|
||||
<behavior>
|
||||
- clear_on_change_test.go, hasMany (Postgres, the relation_child_test.go fixtures): a RelationContract with ClearOnChange naming a nullable child column; link sets the foreign key and clears that column on the linked child; unlink clears the foreign key and the column; a contract without ClearOnChange leaves the column untouched (existing behaviour); a deferred link committed by the parent's first save clears it too.
|
||||
- clear_on_change_test.go, belongsTo form field: a FieldRelationContract with ClearOnChange; an update that changes the key (to another record, and to empty) clears the column; an update that keeps the key leaves it; an update that changes the key and also submits the column through its own form field keeps the submitted value; a create that sets the key and does not submit the column clears it.
|
||||
- clear_on_change_test.go, boot: ClearOnChange on a belongsToMany field contract or a belongsToMany relation contract, a column the model does not have, a non-pointer (not nullable) column, the ForeignKey itself, or the primary key each stop the start-up with an error naming the controller and the column.
|
||||
- user plugin admin_organisations_edge_test.go:183-213 flipped and renamed TestAdminOrganisationMembershipClearsRole: after the members-manager unlink both organisation_id and organisation_role are NULL; after linking into the second organisation the role stays NULL; an organisation-less user with a leftover role, linked through the members manager, ends with role NULL; changing a user's organisation in the Users form to another organisation, and to none, clears the role; a Users form save that keeps the organisation keeps the role; deleting an organisation still clears both (existing part).
|
||||
</behavior>
|
||||
<action>
|
||||
Framework, in summercms.go (OD-3). Add ClearOnChange []string to cabana.RelationContract (modules/cabana/relation.go:38-58) and to cabana.FieldRelationContract (modules/cabana/relation_field.go:22-45), each with a doc comment: nullable columns of the related child (hasMany) or of the record (belongsTo) set to NULL whenever this relation changes the foreign key, so values that only make sense for one parent (a role in an organisation, for example) never move to another or outlive the membership; the zero value changes nothing.
|
||||
|
||||
Validation at boot: in validateHasManyContract / validateRelationContract (relation.go:479 and :558) and in compileFieldRelation (relation_field.go:205), refuse ClearOnChange on a belongsToMany contract, and refuse any listed column that the model does not have (fieldByColumn over modelFields), whose field is not a pointer, or that is the contract's ForeignKey or the model's primary key; the error names the controller and the column, in the style of the existing contract errors.
|
||||
|
||||
Application: in linkRelated's hasMany branch (relation.go:917-975, before the Save at :955) and in unlinkRelated's hasMany branch (:1231-1260, before the Save at :1250), after setModelColumn sets the foreign key, set each ClearOnChange column of the child to nil with setModelColumn (link and unlink always change the key: link adopts only children whose key is NULL, unlink only children of this parent). In assignBelongsTo (relation_field.go:648-663), before setting the foreign key, read the current key value of the model (fieldByColumn; nil pointer or zero counts as empty) and compare it with the submitted id (or empty); when they differ, set each ClearOnChange column to nil unless the same save writes that column through its own form field. To know that, assignBelongsTo receives the set of fill keys this save wrote: the keys of fillAllowed(cc, target, op) that are present in projected, computed in the save function in crud.go where both are in scope; pass it from the single assignBelongsTo call site in crud.go. No other behaviour changes; the deferred unlink of a not-yet-saved parent writes no child row and needs nothing.
|
||||
|
||||
Tests: modules/cabana/clear_on_change_test.go per the behavior block, reusing the Postgres fixtures and helpers of relation_child_test.go / phase122_fixture_test.go and the form-save helpers of phase121_form_test.go (its TestWritableForeignKeySmoke is the closest model); boot cases next to the style of phase121_schema_boot_test.go. Docker-backed parts skip under -short like their neighbours.
|
||||
|
||||
Docs in the same commit (CLAUDE.md): modules/cabana/README.md, the relations sentence in Features (around :28) and the API reference rows for FieldRelationContract and RelationContract (around :272 and :275) mention ClearOnChange; docs/backend/relation-manager.md gets a short section after the WritableForeignKey paragraph (around :52) for belongsTo fields and a sentence in the hasMany part (around :117-133 and the link/unlink description at :190), with an organisation membership and role as the example and the "unless the same save writes it" rule. If you add a code example through an src= snippet, put it in an example_*_test.go and run summer docs:sync. Run go run ./cmd/summer docs:build --check and go test -run TestDocsTree ./cmd/summer.
|
||||
|
||||
Commit in summercms.go, explicit paths only (other sessions have uncommitted files there; leave them alone), no co-author tags: feat(cabana): ClearOnChange clears dependent columns when a relation changes a foreign key (relation.go, relation_field.go, crud.go, clear_on_change_test.go, README.md, docs/backend/relation-manager.md). go vet ./... and go test -short ./... green (and go test ./modules/cabana/... with Docker); if a failure comes only from another session's uncommitted files, record it in the SUMMARY and do not touch those files. Do not push or tag.
|
||||
|
||||
User plugin, in ../sm-demo-app/plugins/golem15/user (master after Task 2, GOWORK=off; it builds against summercms.go's working tree through the go.mod replace, so it sees the commit above). Set ClearOnChange: organisation_role on the members RelationContract (organisations_admin_controller.go:76-90) and on the organisation FieldRelationContract (users_admin_controller.go:370-388). Update the doc comments at organisations_admin_controller.go:21-27 ("sets and clears users.organisation_id and nothing else") and :73-75, and at users_admin_controller.go:364-369: adding or removing a member, and changing the organisation in the user form, clear organisation_role unless the same save writes it. Flip and rename the known-gap test and add the user-form cases per the behavior block (adminEnv harness: newAdminEnv, admin, call/expect, seedUser, reload; find the users update route in admin_users_test.go), and replace the test's doc comment (12.1-SECURITY-REVIEW open item 6 is closed). README.md: the Organisations bullet (:26) and the Organisations screen bullet (:30) state the role rule; add a Dependencies note that this behaviour needs a summercms version with cabana ClearOnChange (the tag decided at Task 5); update the Testing section. Leave go.mod's summercms require as it is in this commit: the require bump to the released tag is a separate commit at Task 5, once the tag exists.
|
||||
|
||||
Commit in the user-plugin checkout: fix: clear a stale organisation_role when a user's organisation changes in the admin (organisations_admin_controller.go, users_admin_controller.go, admin_organisations_edge_test.go, README.md). This must be the last commit on the user plugin's master, so the earlier fixes can be tagged without it. No push.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /media/nvme/dev/golem15/summercms.io/summercms/summercms.go && go vet ./... && go test -count=1 -run 'TestClearOnChange|TestRelationChild|TestWritableForeignKey' ./modules/cabana/... && go test -count=1 -short ./... && go test -count=1 -run '^TestDocsTree$' ./cmd/summer && go run ./cmd/summer docs:build --check && cd /media/nvme/dev/golem15/summercms.io/summercms/sm-demo-app/plugins/golem15/user && GOWORK=off go vet ./... && GOWORK=off go test -count=1 -run 'TestAdminOrganisation|TestAdminUser' ./... && GOWORK=off go test -count=1 ./...</automated>
|
||||
</verify>
|
||||
<done>cabana.RelationContract and cabana.FieldRelationContract accept ClearOnChange with boot validation, documented in the cabana README and docs/backend/relation-manager.md, tested on Postgres; the user plugin clears organisation_role on member add/remove and on a user-form organisation change; the known-gap test asserts the closed gap; framework short suite, docs checker and user-plugin suite green; one commit in summercms.go and one (last) commit in the user plugin; nothing pushed or tagged.</done>
|
||||
</task>
|
||||
|
||||
<task type="checkpoint:decision" gate="blocking">
|
||||
<name>Task 5: Go-ahead for push, tags, changelog and submodule pointer bumps</name>
|
||||
<decision>Whether and how far to publish: the summercms.go cabana commit, the user-plugin commits (two releasable sets), the sociallogin commits, and the submodule pointers in the apps.</decision>
|
||||
<context>
|
||||
Local commits after Tasks 1-4. sm-user-plugin master (../sm-demo-app/plugins/golem15/user): Task 1 (two commits) and Task 2 (one) need only summercms v0.4.0; Task 4's commit, the last one, needs a summercms tag containing ClearOnChange. sm-sociallogin-plugin master: Task 2 (one) and Task 3 (two); go.mod unchanged (still requires sm-user-plugin v0.1.5 and summercms v0.3.1). summercms.go master: one cabana commit after the 19.8 release. Review with git log --oneline origin/master..master in each repo.
|
||||
Publishing steps for publish-all, in this order (CLAUDE.md: framework and plugins first, then each app's pointer bump as its own commit; ssu:status / ssu:push can drive the submodule parts):
|
||||
(1) summercms.go: git fetch, confirm origin/master..master holds the cabana commit (plus planning docs), vet, short suite and docs checker green, push master, annotated tag v0.4.3 (proposed: the tag after 19.8's v0.4.2), push the tag, and the same-day changelog post from ../sm-summercmsio-app with scripts/changelog-post.py (source, then publish with user-facing notes; never print the token).
|
||||
(2) sm-user-plugin: tag v0.1.7 at the Task 2 commit (registration allow-list, listener logging, offboarding; compatible with summercms v0.4.0); then a commit "chore: require summercms v0.4.3" (go mod edit of the require, then GOWORK=off go mod tidy once the tag is fetchable, tests green), tag v0.1.8 there (adds the stale-role fix); push master and both tags.
|
||||
(3) sm-sociallogin-plugin: push master, tag v0.1.3, push the tag.
|
||||
(4) Apps, one commit per app and plugin, staging only the pointer and go.mod/go.sum: the user plugin in fonoteka.go, sm-bm-app, sm-demo-app, sm-grzybyfunkcjonalne-app and sm-summercmsio-app; sociallogin in sm-bm-app and sm-demo-app. An app moving to user v0.1.8 also needs summercms v0.4.3 or newer (bump its summercms require where it pins one); an app that cannot move its framework yet takes v0.1.7. Run each app's go build ./... before committing; leave other sessions' edits alone; push each app.
|
||||
</context>
|
||||
<options>
|
||||
<option id="publish-all">
|
||||
<name>Release the framework (v0.4.3 + changelog), both user-plugin tags, sociallogin, then bump every app</name>
|
||||
<pros>All fixes, the stale-role cleanup included, reach every app.</pros>
|
||||
<cons>Needs 19.8 released first and a framework bump in each app taking v0.1.8; touches five app repos where other sessions may be working.</cons>
|
||||
</option>
|
||||
<option id="plugins-first">
|
||||
<name>Push the user plugin up to the Task 2 commit only (git push origin with that commit as master), tag v0.1.7; push and tag sociallogin v0.1.3; keep the framework and stale-role commits local</name>
|
||||
<pros>The register, offboarding, tenant and event fixes ship now with no framework release.</pros>
|
||||
<cons>The stale-role fix waits for the framework release; app pointer bumps are still separate decisions.</cons>
|
||||
</option>
|
||||
<option id="hold">
|
||||
<name>Keep everything local</name>
|
||||
<pros>Nothing leaves the machine; the plan can still change.</pros>
|
||||
<cons>The gaps stay open in every app.</cons>
|
||||
</option>
|
||||
</options>
|
||||
<resume-signal>Select: publish-all (optionally naming which apps), plugins-first, or hold.</resume-signal>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| anonymous client -> POST /_user/api/v1/register | untrusted JSON body is mass-assigned into the users row |
|
||||
| session JWT holder -> POST /_user/api/v1/refresh | a token of a user who has since been deactivated, banned or cut off |
|
||||
| personal API token holder -> plugin APIs (APITokenGuard) | long-lived secret of a user who has since been deactivated or banned |
|
||||
| OAuth provider (Microsoft token endpoint, Graph) -> sociallogin | identity, email and tenant asserted by the provider |
|
||||
| administrator -> cabana Organisations / Users screens | membership changes that must not carry privileges across organisations |
|
||||
| sociallogin -> application listeners | plugin code reacting to sign-ins; failures must not change the sign-in outcome |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-2zy-01 | E | /register via RegisterUser mass assignment (organisation fields, is_onboarded, must_change_password) | critical | mitigate | Task 1: explicit RegisterFillable allow-list; TestRegisterIgnoresProtectedFields over HTTP and RegisterUser |
|
||||
| T-2zy-02 | S | created_ip_address / last_ip_address from the body when RegisterOptions.IP is empty | low | mitigate | Task 1: IP columns set only from RegisterOptions.IP after the fill |
|
||||
| T-2zy-03 | E | organisation_role kept after a membership change (members manager, Users form) | high | mitigate | Task 4: cabana ClearOnChange on both contracts; flipped known-gap test plus form cases |
|
||||
| T-2zy-04 | E | controllers.Refresh sliding sessions of deactivated, banned or cut-off users | high | mitigate | Task 2: subject check after bouncer.Refresh (scoped load, tokens_valid_after, ThrottleStates ban); offboarding_test.go |
|
||||
| T-2zy-05 | E | session JWTs and user_api_tokens surviving deactivate/ban and reviving on restore/unban | high | mitigate | Task 2: RevokeUserAccess from DeactivateUsers and BanUsers; restore/unban revive nothing |
|
||||
| T-2zy-06 | E | a banned account restored or signed in socially | high | mitigate | Existing checkCanSignIn ban check runs before FinishLogin; Task 2 pins it for banned-and-deactivated accounts on every path |
|
||||
| T-2zy-07 | S | Entra tid read from an id_token without signature verification | medium | mitigate | Task 3: only from the back-channel token response over TLS, aud must equal the client id, GUID format, empty otherwise; README trust note; apps pair TenantID with Provider microsoft |
|
||||
| T-2zy-08 | I | new log lines | medium | mitigate | Tasks 1 and 3: log only provider, user_id and err; never id_token, access token, code or secrets (TestSettings no-secret-in-logs stays green) |
|
||||
| T-2zy-09 | R | discarded user.Register / user.Created / sign-in listener errors | low | mitigate | Tasks 1 and 3: Error-level log lines with user_id; control flow unchanged |
|
||||
| T-2zy-10 | D | suspension (failed-password throttle) used as a refresh refusal | medium | accept | Refresh checks bans only, so failing logins against an email cannot log its owner out |
|
||||
| T-2zy-11 | E | deactivated account reactivated by its owner (password or social sign-in) | low | accept | User decision OD-1: deactivation is self-service and reversible; a ban is the lock and is refused everywhere |
|
||||
| T-2zy-12 | E | a JWT minted within the same second before a revocation stays valid until it expires | low | accept | Cutoff truncated to whole seconds (JWT iat precision) so a reactivating login in that second works; refresh and login still refuse bans |
|
||||
| T-2zy-SC | T | npm/pip/cargo/go installs | low | accept | No new dependency: golang-jwt/jwt/v5 (user plugin), golang.org/x/oauth2 and goth (sociallogin) are already direct requirements; cabana uses only existing code |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- User plugin (../sm-demo-app/plugins/golem15/user): GOWORK=off go vet ./... and GOWORK=off go test -count=1 ./... green with Docker after every task.
|
||||
- Sociallogin (../sm-sociallogin-plugin): go vet ./... and go test -count=1 ./... green with its own go.work, and the scratch-GOWORK run against the modified user plugin green.
|
||||
- summercms.go after Task 4: go vet ./..., go test -short ./..., go test ./modules/cabana/... (Docker), TestDocsTree and summer docs:build --check green; only the Task 4 paths staged.
|
||||
- No push, tag, changelog post or app commit before the Task 5 decision.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- 2zy-1: a /register body sets only name, surname, email, password, preferred_locale and marketing_consent; IP columns are server-set; admin and application flows unchanged.
|
||||
- 2zy-2: Microsoft identities carry profile_data.tenant_id; SignedInEvent carries TenantID.
|
||||
- 2zy-3: SignedInEvent fires on every successful social sign-in with provider and identity; register, created and sign-in listener errors are logged.
|
||||
- 2zy-4: deactivation ends sessions and API tokens and is undone only by the owner signing in again (fresh token) or an administrator; a ban is refused by login, social sign-in and refresh and revokes sessions and API tokens; /refresh refuses deactivated, banned and cut-off subjects.
|
||||
- 2zy-5: cabana ClearOnChange exists and is documented; adding or removing a member and changing the organisation in the Users form clear organisation_role; the known-gap test asserts the closed gap.
|
||||
- Both plugin READMEs document the allow-list, the new event, the profile_data key, the refresh rule and the deactivation/ban semantics in the commits that change them; the cabana README and docs/backend/relation-manager.md document ClearOnChange in the framework commit.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/quick/261022-2zy-user-and-sociallogin-plugin-security-gap/261022-2zy-SUMMARY.md` when done, listing every commit hash per repo (summercms.go, sm-user-plugin, sm-sociallogin-plugin), the scratch-GOWORK cross-check result, anything skipped because another session had the files open, and the Task 5 outcome.
|
||||
</output>
|
||||
@@ -0,0 +1,149 @@
|
||||
---
|
||||
phase: quick-261022-2zy
|
||||
plan: 01
|
||||
quick_id: 261022-2zy
|
||||
subsystem: sm-user-plugin, sm-sociallogin-plugin, cabana
|
||||
tags: [security, registration, offboarding, entra, events, cabana, relations]
|
||||
status: complete
|
||||
requires: [summercms v0.4.2 released]
|
||||
provides:
|
||||
- User.RegisterFillable (registration allow-list)
|
||||
- classes.RevokeUserAccess; deactivate/ban revoke sessions and API tokens; /refresh subject check
|
||||
- sociallogin Profile.TenantID, PendingPayload.TenantID, profile_data.tenant_id
|
||||
- sociallogin classes.SignedInEvent / SignedInEventName
|
||||
- cabana RelationContract.ClearOnChange and FieldRelationContract.ClearOnChange
|
||||
affects: [fonoteka.go, sm-bm-app, sm-demo-app, sm-grzybyfunkcjonalne-app, sm-summercmsio-app]
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns: [explicit registration allow-list, revocation cutoff in whole seconds, declarative ClearOnChange on relation contracts]
|
||||
key-files:
|
||||
created:
|
||||
- ../sm-demo-app/plugins/golem15/user/offboarding_test.go
|
||||
- ../sm-sociallogin-plugin/classes/events.go
|
||||
- ../sm-sociallogin-plugin/signin_event_test.go
|
||||
- ../sm-sociallogin-plugin/tenant_test.go
|
||||
- modules/cabana/clear_on_change_test.go
|
||||
- modules/cabana/clear_on_change_field_test.go
|
||||
modified:
|
||||
- ../sm-demo-app/plugins/golem15/user/models/user.go
|
||||
- ../sm-demo-app/plugins/golem15/user/controllers/registration.go
|
||||
- ../sm-demo-app/plugins/golem15/user/controllers/api_controller.go
|
||||
- ../sm-demo-app/plugins/golem15/user/classes/admin_actions.go
|
||||
- ../sm-demo-app/plugins/golem15/user/controllers/organisations_admin_controller.go
|
||||
- ../sm-demo-app/plugins/golem15/user/controllers/users_admin_controller.go
|
||||
- ../sm-sociallogin-plugin/classes/flow.go
|
||||
- ../sm-sociallogin-plugin/classes/providers_goth.go
|
||||
- ../sm-sociallogin-plugin/classes/registration.go
|
||||
- modules/cabana/relation.go
|
||||
- modules/cabana/relation_field.go
|
||||
- modules/cabana/crud.go
|
||||
decisions:
|
||||
- "Registration fills only User.RegisterFillable (name, surname, email, password, preferred_locale, marketing_consent); IP columns only from RegisterOptions.IP; Fillable() unchanged"
|
||||
- "RevokeUserAccess cutoff is time.Now() truncated to whole seconds (JWT iat precision)"
|
||||
- "/refresh refuses missing/deactivated, banned and pre-tokens_valid_after subjects with the existing 401 body; suspension is not checked"
|
||||
- "Microsoft provider exchanges the code itself to keep the id_token; tid trusted only with aud == client id and GUID format"
|
||||
- "cabana ClearOnChange is applied only on hasMany link/unlink and on a belongsTo key change in the form save; created-child commits are untouched"
|
||||
metrics:
|
||||
duration: ~95 min
|
||||
completed: 2026-10-22
|
||||
actuals:
|
||||
tokens: 46000
|
||||
tasks: 4
|
||||
commits: 1
|
||||
plan_head_before: 9563bc8
|
||||
plan_head_after: 9516a2b
|
||||
---
|
||||
|
||||
# Quick 261022-2zy: user and sociallogin plugin security gaps Summary
|
||||
|
||||
Registration allow-list, session and API-token revocation on deactivate/ban with a /refresh subject check, Entra tenant ID and a SignedInEvent in sociallogin, and an additive cabana `ClearOnChange` that clears a stale `organisation_role`. Tasks 1 to 4 are committed locally in three repos. Task 5 (push, tags, changelog, pointer bumps) is waiting for the user's go-ahead.
|
||||
|
||||
`commits`/`plan_head_*` in the frontmatter measure summercms.go only (one framework commit since 9563bc8). The plugin repos are listed below.
|
||||
|
||||
## Commits
|
||||
|
||||
### sm-user-plugin (`../sm-demo-app/plugins/golem15/user`, branch master, base 9986342 = origin/master = v0.1.6)
|
||||
|
||||
| Hash | Subject |
|
||||
|------|---------|
|
||||
| 499b6dc | test: migration rollback counts include the email_verified_at migration (deviation, see below) |
|
||||
| b6b7ac7 | fix: registration fills only an explicit allow-list and never takes the IP columns from the body |
|
||||
| e0b5ffe | fix: log /register listener failures instead of discarding them |
|
||||
| c3712dc | fix: deactivate and ban revoke sessions and API tokens, and /refresh refuses deactivated, banned and cut-off subjects |
|
||||
| aa83969 | fix: clear a stale organisation_role when a user's organisation changes in the admin (last commit; needs summercms with ClearOnChange) |
|
||||
|
||||
### sm-sociallogin-plugin (`../sm-sociallogin-plugin`, master, base 9fd58db = origin/master)
|
||||
|
||||
| Hash | Subject |
|
||||
|------|---------|
|
||||
| 04f4583 | test: a banned account is refused and never restored by social sign-in, and the deactivation semantics are documented |
|
||||
| 5c9f846 | feat: record the Entra tenant ID of a Microsoft sign-in in profile_data |
|
||||
| 4d3b2dd | feat: fire SignedInEvent after every social sign-in and log listener failures |
|
||||
|
||||
go.mod unchanged (still requires sm-user-plugin v0.1.5 and summercms v0.3.1).
|
||||
|
||||
### summercms.go (master, after v0.4.2)
|
||||
|
||||
| Hash | Subject |
|
||||
|------|---------|
|
||||
| 9516a2b | feat(cabana): ClearOnChange clears dependent columns when a relation changes a foreign key |
|
||||
|
||||
Nothing pushed or tagged. The sm-demo-app working tree now shows `M plugins/golem15/user`, the checkout moved to aa83969. That pointer bump is not committed; it belongs to Task 5.
|
||||
|
||||
## Test results
|
||||
|
||||
- User plugin: `GOWORK=off go vet ./...` and `GOWORK=off go test -count=1 ./...` (Docker) green before every commit, the last one included.
|
||||
- Sociallogin: `go vet ./...` and `go test -count=1 ./...` green with its own go.work, which uses the published user plugin at 9986342.
|
||||
- Scratch-GOWORK cross-check: sociallogin against summercms.go's working tree and the modified user plugin (aa83969), using `/tmp/claude-1000/.../scratchpad/go.work`. Green: `ok git.golem15.com/golem15/sm-sociallogin-plugin`.
|
||||
- summercms.go: `go vet ./...`, `go test -count=1 -short ./...`, `go test -count=1 ./modules/cabana/...` (Docker, 82 s), `go test -run '^TestDocsTree$' ./cmd/summer` and `go run ./cmd/summer docs:build --check` all green.
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
### Auto-fixed Issues
|
||||
|
||||
**1. [Rule 3 - Blocking] Pre-existing migration rollback test failures in the user plugin**
|
||||
- **Found during:** Task 1, the first full suite run.
|
||||
- **Issue:** `updates/` TestUserGroupsMigration, TestExtendUsersMigration, TestCreateUserThrottleMigration and TestCreateJwtBlacklistMigration failed at origin/master. Their rollback counts were one short since migration 202610210001 (email_verified_at, v0.1.6) was added. This blocked "full suite green at every commit".
|
||||
- **Fix:** Bumped the four rollback counts and their comments. Test-only change, committed separately.
|
||||
- **Commit:** 499b6dc
|
||||
|
||||
**2. [Rule 2] Extra test for the /register listener-error logging**
|
||||
- Added TestRegisterListenerErrors (register_test.go) to commit e0b5ffe. The plan had no test for that commit.
|
||||
|
||||
**3. [Rule 1] TestAdminOrganisationMembers pinned the old behaviour**
|
||||
- admin_organisations_test.go expected a foreign member's role to survive unlink and link. It now expects the unlink to clear the role. Committed with aa83969.
|
||||
|
||||
**4. Test file layout (sociallogin)**
|
||||
- The tenant tests that do not need the event live in a new `tenant_test.go` (TestTenantProfileData) inside commit 5c9f846. The plan kept event-dependent assertions out of that commit; this keeps the tenant behaviour tested in the same commit that adds it.
|
||||
- `fakeprovider_test.go` was not changed: the fake provider already returns whatever `classes.Profile` it is given, so the new `TenantID` field passes through.
|
||||
- To capture mails, TestInactiveAndBanned now boots with the `log` mail driver and an slog handler. A deactivated-without-ban control subtest proves the capture sees the reactivate mail.
|
||||
|
||||
**5. Test file layout (cabana)**
|
||||
- The belongsTo cases are in an internal-package file, `clear_on_change_field_test.go` (TestClearOnChangeFieldBoot, TestClearOnChangeBelongsTo). They use a small dedicated fixture because no roster column is both nullable and fillable.
|
||||
- The hasMany cases are in `clear_on_change_test.go` (TestClearOnChangeRelationBoot, TestClearOnChangeHasMany), on the df fixtures.
|
||||
- The docs checker refuses Go code blocks without `src=`. So the docs example is the existing `example_form_seams_test.go` MembersController team contract, which gained `ClearOnChange: []string{"team_role"}` and a `Member.TeamRole` field; `summer docs:sync` refreshed the snippet. Its Example output is unchanged.
|
||||
- Boot errors name the relation or field and the column, and `bootErr` adds the plugin and controller, as the existing contract errors do.
|
||||
|
||||
**6. ClearOnChange scope**
|
||||
- Applied in linkRelated and unlinkRelated for hasMany, which covers deferred link and unlink because they use the same path, and in assignBelongsTo.
|
||||
- Not applied when a deferred commit attaches a child created in the form session, or when CreateChild sets the key: that would wipe values the admin just entered in the child form ("no other behaviour changes").
|
||||
|
||||
**7. User README dependency note**
|
||||
- It says the role cleanup needs "SummerCMS v0.4.3 or later". If Task 5 picks a different tag, update this line together with the go.mod require bump.
|
||||
|
||||
## Known Stubs
|
||||
|
||||
None.
|
||||
|
||||
## Threat Flags
|
||||
|
||||
None beyond the plan's threat model. The new log lines carry only provider, user_id and err; the id_token, access token and code are never logged.
|
||||
|
||||
## Task 5: awaiting go-ahead
|
||||
|
||||
Blocking checkpoint:decision. Choose publish-all, plugins-first or hold. Nothing has been pushed, tagged, posted or pointer-bumped.
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
- Files exist: offboarding_test.go, classes/events.go (sociallogin), signin_event_test.go, tenant_test.go, modules/cabana/clear_on_change_test.go, modules/cabana/clear_on_change_field_test.go.
|
||||
- Commits exist: 499b6dc, b6b7ac7, e0b5ffe, c3712dc, aa83969 (user plugin); 04f4583, 5c9f846, 4d3b2dd (sociallogin); 9516a2b (summercms.go).
|
||||
@@ -0,0 +1,367 @@
|
||||
---
|
||||
phase: quick-261022-4z8
|
||||
plan: 01
|
||||
quick_id: 261022-4z8
|
||||
slug: fix-the-open-19-8-re-review-findings-wr-
|
||||
type: execute
|
||||
mode: quick-full
|
||||
wave: 1
|
||||
depends_on: []
|
||||
autonomous: true
|
||||
requirements:
|
||||
- WR-09 # lines lost through a live view after its context is cancelled (verification gap 1)
|
||||
- WR-10 # signal-killed delegate prints raw argv with secrets (verification gap 2)
|
||||
- WR-11 # dash-leading secret values not masked in tryArgs (verification gap 2)
|
||||
- WR-12 # migrate:status --json omits the framework sets (verification gap 3)
|
||||
- WR-13 # migrate exits 1 when only the re-read after a successful Migrate fails
|
||||
- WR-14 # Process's plain path prints item names without Sanitize
|
||||
- IN-14 # ErrReported doc comment still says exit code 1
|
||||
- IN-15 # runLive builds the program before claiming the slot
|
||||
- 4z8-docs # disposition record + todo (IN-10..IN-13, exit-130 suggestion deferred)
|
||||
files_modified:
|
||||
# Task 1: bonfire (IN-14, WR-14, IN-15, WR-09)
|
||||
- modules/bonfire/errors.go
|
||||
- modules/bonfire/helpers.go
|
||||
- modules/bonfire/live.go
|
||||
- modules/bonfire/live_test.go
|
||||
- modules/bonfire/README.md
|
||||
- docs/console/writing-commands.md
|
||||
- scripts/check-phase19.8.sh
|
||||
# Task 2: secret masking (WR-11, WR-10)
|
||||
- modules/bonfire/errors.go
|
||||
- modules/bonfire/errors_test.go
|
||||
- internal/argmask/argmask.go
|
||||
- cmd/summer/runtime.go
|
||||
- cmd/summer/runtime_test.go
|
||||
- docs/console/introduction.md
|
||||
- docs/console/writing-commands.md
|
||||
- scripts/check-phase19.8.sh
|
||||
# Task 3: lagoon (WR-12, WR-13) + records
|
||||
- modules/lagoon/migrations.go
|
||||
- modules/lagoon/commands.go
|
||||
- modules/lagoon/migrations_test.go
|
||||
- modules/lagoon/example_test.go
|
||||
- modules/lagoon/pending_test.go
|
||||
- modules/lagoon/README.md
|
||||
- docs/console/setup-and-maintenance.md
|
||||
- docs/database/migrations.md
|
||||
- scripts/check-phase19.8.sh
|
||||
- .planning/phases/19.8-console-commands-overhaul/19.8-REVIEW-DISPOSITION.md
|
||||
- .planning/todos/pending/2026-10-22-console-review-info-findings.md
|
||||
|
||||
estimate:
|
||||
tokens: 160000
|
||||
raw_tokens: 160000
|
||||
tasks: 3
|
||||
confidence: low # estimate-calibration: factor 1, sample_count 0
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "WR-09: when the context passed to bonfire.Process is cancelled while its live view runs, the view ends with its last frame and is unregistered without waiting for the work function to return; afterwards Output.Println, Printf, Info and bonfire.PrintAbove print plainly (PrintAbove reports false) and none of those lines is lost. printAbove also reports false for a program whose Run has ended on its own."
|
||||
- "WR-14: bonfire.Process's plain path (no animation: -n on a terminal, forced color off a terminal, nested in another view) prints item names through bonfire.Sanitize; the process view's operation label is sanitized too."
|
||||
- "IN-15: runLive claims the single live slot before it builds the bubbletea program; a widget that loses the slot builds no program, so no child context is registered on the caller's context and no TEA_TRACE file is opened."
|
||||
- "IN-14: the ErrReported doc comment says the exit code is 1 or the code given with WithExitCode."
|
||||
- "WR-11: the wrong-directory Try line masks the argument after a secret-named long flag in the '--flag value' form whatever its first character (a value such as -Xk9!q becomes ***); only a following '--' is left unmasked."
|
||||
- "WR-10: summer's error for an app binary killed by a signal or failing to start names the argv with the same masking as the Try line (one shared implementation in internal/argmask), so a --password, --api-token=... or dash-leading secret value never reaches stderr; the exit code for a signal death stays 1. No exported framework identifier is added, removed or changed."
|
||||
- "WR-12: lagoon.Status (and so migrate:status and migrate:status --json) reads migrationSets, the list Migrate and PendingMigrations use: framework sets first, then plugins with migrations; --json lists every pending migration PendingMigrations returns, a pending framework-set migration included. Status keeps its signature and StatusRow its fields."
|
||||
- "WR-13: when Migrate succeeds but the follow-up PendingMigrations read fails, migrate prints a warning, reports every migration that was pending before the run as applied, prints 'Applied N migrations' and exits 0; when both fail it still returns both errors."
|
||||
- "Every code finding is its own commit with its pinning test (assertion RED recorded on the unchanged code), README/docs updated in the same commit; no Co-Authored-By trailer; nothing pushed or tagged."
|
||||
- "19.8-REVIEW-DISPOSITION.md marks WR-09..WR-14, IN-14 and IN-15 fixed with their commit hashes (frontmatter and table, open: 0), notes the WR-05/WR-07/WR-08 remainders (WR-14, WR-12, WR-11), and marks IN-10..IN-13 deferred; the todo carries IN-10..IN-13 and the exit-130 suggestion."
|
||||
artifacts:
|
||||
- path: modules/bonfire/live.go
|
||||
provides: "runLive claims the slot before tea.NewProgram, ends and unregisters the view on ctx cancellation; liveView.done; printAbove refuses a finished program"
|
||||
contains: "case <-v.done:"
|
||||
- path: modules/bonfire/helpers.go
|
||||
provides: "Process's plain path sanitizes item names"
|
||||
contains: "out.Success(Sanitize(item))"
|
||||
- path: modules/bonfire/errors.go
|
||||
provides: "ErrReported comment with WithExitCode; tryArgs delegating to internal/argmask"
|
||||
contains: "argmask.Join"
|
||||
- path: internal/argmask/argmask.go
|
||||
provides: "Join (shell-quoted argv with secret flag values masked) and Quote, shared by bonfire and cmd/summer"
|
||||
contains: "func Join(args []string) string"
|
||||
- path: cmd/summer/runtime.go
|
||||
provides: "runAppCmd names argv through argmask.Join"
|
||||
contains: "argmask.Join(argv)"
|
||||
- path: modules/lagoon/migrations.go
|
||||
provides: "Status built from migrationSets"
|
||||
contains: "func Status(gdb *gorm.DB, plugins []party.Plugin) ([]StatusRow, error)"
|
||||
- path: modules/lagoon/commands.go
|
||||
provides: "migrate warns instead of failing on a failed re-read after success"
|
||||
contains: "Could not re-read the migration history"
|
||||
- path: modules/bonfire/live_test.go
|
||||
provides: "TestPrintAboveAfterCancel (WR-09), TestNestedProcessBuildsNoProgram (IN-15), plain-path cases in TestProcessViewStripsControl (WR-14)"
|
||||
contains: "func TestPrintAboveAfterCancel"
|
||||
- path: cmd/summer/runtime_test.go
|
||||
provides: "TestRunAppCmdMasksSecrets and the masked signal subtest of TestDelegateExitCodePassthrough (WR-10)"
|
||||
contains: "func TestRunAppCmdMasksSecrets"
|
||||
- path: modules/lagoon/pending_test.go
|
||||
provides: "TestMigrateStatusJSON pending framework migration (WR-12), TestMigrateRereadFailureWarns (WR-13)"
|
||||
contains: "func TestMigrateRereadFailureWarns"
|
||||
- path: .planning/phases/19.8-console-commands-overhaul/19.8-REVIEW-DISPOSITION.md
|
||||
provides: "dispositions recorded"
|
||||
contains: "open: 0"
|
||||
key_links:
|
||||
- from: cmd/summer/runtime.go
|
||||
to: internal/argmask/argmask.go
|
||||
via: "runAppCmd wraps every child error as summer: <argmask.Join(argv)>: <err>"
|
||||
pattern: "argmask\\.Join\\(argv\\)"
|
||||
- from: modules/bonfire/errors.go
|
||||
to: internal/argmask/argmask.go
|
||||
via: "tryArgs and the cd line use argmask.Join and argmask.Quote (one masking implementation)"
|
||||
pattern: "argmask\\.(Join|Quote)"
|
||||
- from: modules/lagoon/migrations.go
|
||||
to: modules/lagoon/migrations.go
|
||||
via: "Migrate, Status and PendingMigrations all call migrationSets(gdb, plugins)"
|
||||
pattern: "migrationSets\\(gdb, plugins\\)"
|
||||
- from: modules/bonfire/live.go
|
||||
to: modules/bonfire/helpers.go
|
||||
via: "Process passes its ctx to runLive; runLive's cancel watcher finishes and unregisters the view; printAbove checks liveView.done"
|
||||
pattern: "case <-v\\.done"
|
||||
- from: modules/bonfire/helpers.go
|
||||
to: modules/bonfire/errors.go
|
||||
via: "Process plain path calls Sanitize on the item"
|
||||
pattern: "Sanitize\\(item\\)"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Fix the open findings of the 19.8 code re-review (`19.8-REVIEW.md`): WR-09, WR-10, WR-11 and WR-12 (the three verification gaps in `19.8-VERIFICATION.md`), WR-13, WR-14, IN-14 and IN-15. Then record the dispositions and defer IN-10..IN-13 plus WR-10's exit-130 suggestion to the existing todo.
|
||||
|
||||
Purpose: close the three gaps that keep phase 19.8 at `gaps_found`: lines lost after a cancelled live view, secrets leaking through argv, and `migrate:status --json` missing framework sets. The four quality follow-ups go in the same pass.
|
||||
Output: nine local commits on master. There is one `fix(...)`/`docs(bonfire)` commit per finding, each with its pinning test and README/docs, and then one `docs(19.8)` records commit. Nothing is pushed, tagged, released or posted to the changelog.
|
||||
|
||||
Shape: this is a gap-closure-style quick task. The orchestrator fixed the grouping (three tasks), so there is no tracer task. Each finding follows RED → fix → GREEN → commit, the pattern of plan 19.8-05 (`19.8-05-SUMMARY.md`, "Each review finding: pinning test red on unchanged code, fix, green, one fix(...) commit with test and docs").
|
||||
|
||||
**One commit per finding (hard rule).** Each task holds several findings, and each finding is its own commit, in the order the task lists. The commit subject follows the repo's style and ends with the finding ID in parentheses. There is NO `Co-Authored-By` trailer: the user's CLAUDE.md overrides any attribution reminder. Planning docs and code are never in the same commit.
|
||||
|
||||
Decisions (planner's discretion; the user is asleep and the run is unattended, so the executor does not revisit these and records them in the SUMMARY):
|
||||
|
||||
- **P-01 (WR-09 design).** The live program no longer takes the caller's context. Drop bubbletea's context program option from `tea.NewProgram` in `runLive`. Instead, `runLive` starts a watcher goroutine. When `ctx` is done, the watcher takes `liveMu`, unregisters the view if it is still the active one, sends `finishMsg{err: ctx.Err()}`, waits for `finished` and releases `liveMu`. This is the same unregister/finish/wait-under-one-lock step as the deferred cleanup. When `finished` closes first (Run ended on its own, a signal kill, or the normal end), the watcher only unregisters the view under `liveMu` if it is still active. In addition, `liveView` gets a `done <-chan struct{}` field (runLive's `finished` channel, created before the claim). `printAbove` checks it under `liveMu` after the owner check. When it is closed, `printAbove` unregisters the view and returns false. Why not the review's check-then-Send: a cancellation landing between the check and `Program.Send` would still drop the line, because bubbletea's Send returns as soon as its context is done. With P-01 the program stays alive until the watcher has unregistered it under the lock, so every line is either delivered to a running program or printed plainly. P-01 also renders the final frame with the failed rows, which the review notes the killed program never did. Spinner and Progress pass `context.Background()`, so P-01 does not change them. Fallback: if the graceful finish deadlocks or cannot be made to pass under `-race`, implement the review's version and record the deviation. That version keeps the context option, checks `ctx.Done()` and `done` in `printAbove`, and clears `activeView` when `finished` closes.
|
||||
- **P-02 (IN-15).** Claim first, then build. Under `liveMu`, return `false, nil` if `activeView` is set. Otherwise build the program while still holding the lock, register the view and unlock. `NewProgram` takes no bonfire lock and calls nothing that prints, so this cannot deadlock.
|
||||
- **P-03 (WR-14).** Sanitize at the call sites: `out.Success(Sanitize(item))` in `Process`'s plain path, and `Sanitize(op)` for the process view's operation label. Do not sanitize inside `console.status`: callers pass intentionally styled (ANSI) messages to Info/Success/Warning, and those must keep their styling.
|
||||
- **P-04 (WR-11).** In the `--flag value` form, mask the next argument after a secret-named long flag whenever it exists and is not `--`, whatever its first character. `tryArgs` cannot see flag types, so a secret-named boolean flag over-masks the following argument. That is the safe side. The existing case `sync --token --force` therefore changes from `--token --force` to `--token ***`, and the test comment says why. pflag also takes `--force` as the token's value there (v1.0.10 flag.go, the `'--flag arg'` branch).
|
||||
- **P-05 (WR-10).** The user asked for the same masking as `tryArgs` with no new exported API unless needed. It is not needed: the masking moves to a new internal package `internal/argmask`, importable from both `modules/bonfire` and `cmd/summer` (same module root). It provides `Join(args []string) string` (shell-quoted, space-separated, secret values masked, no leading space) and `Quote(s string) string`, which is bonfire's former `shellQuote`. bonfire keeps the unexported `tryArgs` as a thin wrapper (empty for no args, otherwise a space plus `argmask.Join(args)`). It calls `argmask.Quote` for the `cd` path and drops its own `secretWords`/`secretFlag`/`shellQuote`, which nothing else references (verified by grep at planning time). `runAppCmd` builds its error with `argmask.Join(argv)` in every branch: start failure, signal death, and the already-Reported exit branch. The exit code stays 1 on a signal death (plan 05's CR-02 truth). The exit-130 suggestion goes to the todo.
|
||||
- **P-06 (WR-12).** `Status` iterates `migrationSets(gdb, plugins)`: framework sets first (`StatusRow.Plugin` is the set's history ID, for example `summercms.attach`, the same convention as `MigrationRef.Plugin`), then plugins with at least one migration. A plugin whose `HasMigrations` returns none no longer gets an empty row; Migrate and PendingMigrations skip it too, which keeps the three readers consistent. The signature of `Status`, the `StatusRow` type and its fields are unchanged. No consumer outside this repo calls `lagoon.Status` (grep over the meta repo at planning time). The `migrate:status` Description stays as it is, because changing it would churn the list goldens and the list excerpt in `docs/console/introduction.md`. Its Long help and the docs change. The `--json` builder in commands.go keeps its loop: it now covers framework rows, because `row.Plugin` equals `ref.Plugin` for them.
|
||||
- **P-07 (WR-13).** When `migrateErr == nil` and the re-read fails, `applied` is the pending list from before the run. migrate prints `out.Warning("Could not re-read the migration history: " + bonfire.Sanitize(err.Error()))` (wording may be refined; it must contain `Could not re-read the migration history`), then the per-set lines and `Applied N migrations`, and returns nil. When both fail, it keeps `errors.Join(migrateErr, err)`.
|
||||
- **P-08.** Each new top-level pinning test name joins its list in `scripts/check-phase19.8.sh`, in the same commit as the test, so the phase gate keeps requiring it. Tests extended in place are already listed.
|
||||
- **P-09.** `docs/setup/upgrading.md` is not changed, because no release is cut. The SUMMARY gets a "Release-note input" section for whoever tags next: `lagoon.Status` and `migrate:status` now list framework sets; a cancelled Process view ends with its final frame; secret masking now covers dash-leading values and delegate signal errors.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/msd-core/workflows/execute-plan.md
|
||||
@~/.claude/msd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@CLAUDE.md
|
||||
@.planning/phases/19.8-console-commands-overhaul/19.8-REVIEW.md
|
||||
@.planning/phases/19.8-console-commands-overhaul/19.8-VERIFICATION.md
|
||||
@.planning/phases/19.8-console-commands-overhaul/19.8-05-SUMMARY.md
|
||||
@.planning/phases/19.8-console-commands-overhaul/19.8-REVIEW-DISPOSITION.md
|
||||
@.planning/todos/pending/2026-10-22-console-review-info-findings.md
|
||||
|
||||
Source, read once each, only the regions named:
|
||||
@modules/bonfire/live.go (runLive, liveView, watchSignals, printAbove: about lines 323-487; processModel.View: about lines 287-320)
|
||||
@modules/bonfire/helpers.go (Process: lines 140-180)
|
||||
@modules/bonfire/errors.go (ErrReported: about line 73; wrongDirectory, tryArgs, shellQuote: about lines 250-340)
|
||||
@modules/bonfire/output.go (emit, Println, Printf, status: lines 170-245)
|
||||
@modules/bonfire/live_test.go (lockedBuffer, TestPrintAboveOrderedNoLoss, TestProcessViewStripsControl)
|
||||
@modules/bonfire/errors_test.go (TestWrongDirectoryMasksSecrets, carriesInjected)
|
||||
@modules/bonfire/widgets_test.go (TestTableStripsControl: the console construction with Globals{Color: ColorAlways})
|
||||
@cmd/summer/runtime.go (appStreams, runAppCmd, newAppCmd, runChild: lines 130-238)
|
||||
@cmd/summer/runtime_test.go (TestExitHelperProcess, exitHelperRunner, TestDelegateExitCodePassthrough: lines 290-370)
|
||||
@modules/lagoon/migrations.go (Status, migrationSets, PendingMigrations)
|
||||
@modules/lagoon/commands.go (migrate Run: lines 151-195; migrate:status: lines 255-295)
|
||||
@modules/lagoon/pending_test.go (runLagoon, TestMigrateStatusJSON, TestMigrateAppliedLines)
|
||||
@modules/lagoon/migrations_test.go (TestTwoPluginMigrationSetsIsolated: lines 138-260)
|
||||
@modules/lagoon/example_test.go (migrateBlog, TestDocsMigrate: lines 335-372)
|
||||
@scripts/check-phase19.8.sh (test name lists: lines 110-200)
|
||||
|
||||
Environment rules for every step (from the orchestrator; non-negotiable):
|
||||
- **GOCACHE.** Every go command, including any script that calls go (`scripts/check-phase19.8.sh`), runs with `env GOCACHE=/tmp/claude-1000/-media-nvme-dev-golem15-summercms-io-summercms-summercms-go/e016575e-9db1-44e1-a366-55d9ae1a9f8b/scratchpad/gocache`. Below, `$GC` stands for that literal path; write it out in full, because the Bash tool runs fish. Wrap multi-statement scripts or `VAR=$(…)` in `bash -c '…'`. The disk is nearly full: do not create other build caches and do not delete the gocache. The orchestrator deletes it at the end.
|
||||
- **Docker.** Docker-backed tests run with `env GOCACHE=$GC TESTCONTAINERS_HOST_OVERRIDE=127.0.0.1 go test …`. If the Docker socket is denied, run the same command through `sudo -u golem` (CLAUDE.md).
|
||||
- **Concurrent sessions.** Record `BASE=$(git rev-parse HEAD)` before the first edit. Before editing any file, run `git status --short -- <file>`. If it shows changes this task did not make, do not edit that file. Record the skipped update in the SUMMARY as a follow-up. Never touch, stage or commit `.planning/STATE.md`, `.planning/state.json`, `.msd/`, any cabana or `docs/backend` file, or other quick or phase directories. Commit with explicit paths only: `git add -- <paths>` then `git commit -m "<subject>" -- <paths>`. The `-- <paths>` form commits only those paths, even if another session has staged something. Never use `git add -A` or `git add .`.
|
||||
- **Foreign test failures.** If `cmd/summer` docs tests (TestDocsTree and others) fail only because of another session's uncommitted files, export clean HEAD with `bash -c 'D=/tmp/claude-1000/-media-nvme-dev-golem15-summercms-io-summercms-summercms-go/e016575e-9db1-44e1-a366-55d9ae1a9f8b/scratchpad/clean-head; mkdir -p "$D" && git archive HEAD | tar -x -C "$D"'`, run the failing package's tests there with the same GOCACHE, record both results in the SUMMARY, then remove that export directory.
|
||||
- **Known flake.** `TestOIDCSubjectIsRandomSmoke` (wristband; deferred-items.md) can fail once at random. Rerun it once before treating it as a failure.
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 1: bonfire live views and Process: IN-14, WR-14, IN-15, WR-09 (four commits)</name>
|
||||
<files>modules/bonfire/errors.go, modules/bonfire/helpers.go, modules/bonfire/live.go, modules/bonfire/live_test.go, modules/bonfire/README.md, docs/console/writing-commands.md, scripts/check-phase19.8.sh</files>
|
||||
<behavior>
|
||||
- IN-14: no test (doc comment only).
|
||||
- WR-14 (extend TestProcessViewStripsControl): (a) the existing model case also builds the process model with operation "Push\x1b]0;pwned\x07ing", and the View carries none of the injected sequences while showing "Push" and "ing"; (b) plain path, terminal: newConsole(tty true, interactive true) plus setGlobals(Globals{Color: ColorAlways, NoInteraction: true}), so live() is false and the profile is colored; Process over the item "evil\x1b]0;pwned\x07item" with fn returning nil prints no injected sequence, and "evil" and "item" are visible; (c) plain path off a terminal: tty false plus Globals{Color: ColorAlways}, same assertions. (b) and (c) fail on the unchanged code; TestTableStripsControl shows the colored writer passes OSC and BEL through.
|
||||
- IN-15 (new TestNestedProcessBuildsNoProgram): on a tty console, inside c.Spinner("outer", …), call t.Setenv("TEA_TRACE", <tempdir>/trace.log), then run Process with a test-only counting context over one item "x". The counting context is a type whose Done returns a non-nil channel that is never closed, whose Err and Value return nil, and which implements AfterFunc(f func()) (stop func() bool), counting registrations minus stops under a mutex. context.WithCancel registers a child through the parent's AfterFunc when the parent is not a stdlib cancel context. Assert: fn ran once; the trace file does not exist; outstanding registrations are 0; the "x" success line reached the buffer. On the unchanged code NewProgram opens the trace file and registers one child that is never stopped, so the test fails.
|
||||
- WR-09 (new TestPrintAboveAfterCancel): use a tty console on a lockedBuffer, ctx and cancel from context.WithCancel, and items "first", "second", "third". fn records each item it gets. On "second" it calls cancel(), then polls for up to 2s (every 5ms) until activeView is nil, reading it under liveMu; if the view is still registered it reports t.Errorf("view still registered … after the cancellation"). Then it calls c.Println("after-cancel-println"), c.Info("after-cancel-info") and PrintAbove("after-cancel-printabove"). PrintAbove must report false; when it does, fn writes "after-cancel-printabove\n" to the buffer, as diary does. fn then returns nil. After Process: errors.Is(err, context.Canceled) is true; fn never got "third"; ansi.Strip(output) contains each of the three after-cancel lines exactly once; PrintAbove("after") is false. On the unchanged code the poll times out, PrintAbove reports true and the lines are lost (the review reproduced this 3 of 3 on a pty), so the test fails without a sleep-based race.
|
||||
</behavior>
|
||||
<action>
|
||||
Work in this order. Each step is one commit. Before the first edit, record BASE (see context). For every code finding, write or extend the test first and run it on the unchanged code. Paste the failing output (the RED) into your notes for the SUMMARY. Only then fix, run it GREEN, update the docs and commit.
|
||||
|
||||
1. **IN-14 (doc only, commit 1).** In modules/bonfire/errors.go, change the ErrReported doc comment so it says the error block prints nothing and the exit code is 1, or the code given with WithExitCode (review IN-14 fix text). The `var ErrReported` line itself stays byte-identical. No test. Commit: `docs(bonfire): the ErrReported comment names the WithExitCode code (IN-14)`, paths modules/bonfire/errors.go.
|
||||
|
||||
2. **WR-14 (commit 2).** Extend TestProcessViewStripsControl in modules/bonfire/live_test.go as described in behavior (a)-(c), reusing carriesInjected from errors_test.go. It is the same package, `bonfire`. Run it RED. Fix per P-03: in modules/bonfire/helpers.go, the plain path's success line becomes out.Success(Sanitize(item)). The failure line already goes through failureText. In modules/bonfire/live.go processModel.View, sanitize the operation label (`op`) before rendering. Update modules/bonfire/README.md line 30 ("Table cells, process-view item names and errors, and failure lines are stripped…") to cover Process item names on both paths and the operation label. writing-commands.md:155 already promises this, so leave it. Commit: `fix(bonfire): strip control characters from Process's plain item lines and the view's label (WR-14)`, paths helpers.go, live.go, live_test.go, README.md.
|
||||
|
||||
3. **IN-15 (commit 3).** Add TestNestedProcessBuildsNoProgram to modules/bonfire/live_test.go per behavior and run it RED. Fix per P-02 in runLive: take liveMu; if activeView is set, unlock and return false, nil. Otherwise build the program with the existing options, set activeView and unlock. Keep the rest of runLive unchanged in this commit. Update runLive's doc comment: it claims the slot before building the program, so a widget that loses the slot builds no program. Add TestNestedProcessBuildsNoProgram to the bonfire list in scripts/check-phase19.8.sh (the array that ends with `TestProcessViewStripsControl TestTableStripsControl`), under a comment line `# quick 261022-4z8 (re-review fixes): IN-15, WR-09.` Commit: `fix(bonfire): claim the live view slot before building the program (IN-15)`, paths live.go, live_test.go, scripts/check-phase19.8.sh.
|
||||
|
||||
4. **WR-09 (commit 4).** Add TestPrintAboveAfterCancel per behavior and run it RED. It must fail on the poll and on the lost lines, not hang. Fix per P-01 in modules/bonfire/live.go:
|
||||
- (a) Add `done <-chan struct{}` to liveView, with a field comment. Create `finished` before the claim and set it on the view.
|
||||
- (b) Remove bubbletea's context program option from the NewProgram call, so the program's lifetime is the view's.
|
||||
- (c) After `go prog.Run()`, start the watcher goroutine described in P-01. Select on ctx.Done() and finished. The ctx branch unregisters, sends finishMsg{err: ctx.Err()} and waits for finished, all while holding liveMu. The finished branch only unregisters under liveMu. Both branches clear activeView only when it is still this view. A nil ctx.Done() (Background) just waits for finished.
|
||||
- (d) In printAbove, read activeView into a local named `v`, as in the review's sketch. After the owner check, add a non-blocking select with a `case <-v.done:` branch. When `done` is closed, set activeView to nil and return false.
|
||||
- (e) Update the doc comments of runLive, liveView and printAbove. Say that cancelling ctx ends the view with its last frame and unregisters it without waiting for work, so lines printed after the cancellation print plainly below it. Say also that a program whose Run ended on its own is never sent lines.
|
||||
|
||||
Check that the deferred cleanup still works when the watcher has already finished the program. Its Send returns at once (Run's deferred cancel) and finished is closed. Keep watchSignals as it is.
|
||||
|
||||
Run TestPrintAboveAfterCancel, TestNestedProcessBuildsNoProgram, TestPrintAboveOrderedNoLoss, TestPrintlnStaysWithItsConsole, TestLiveViewSingleOwner, TestLiveViewPanicUnregisters, TestLiveViewRestoresTerminalOnSignal, TestPrintAbove and TestLiveModels with -race -count=3. All must pass.
|
||||
|
||||
Docs: in modules/bonfire/README.md line 30 and docs/console/writing-commands.md line 77, add one sentence: when the context given to bonfire.Process is cancelled, the view ends with its last frame and later lines print plainly below it. Add TestPrintAboveAfterCancel to the same gate list line as IN-15.
|
||||
|
||||
Commit: `fix(bonfire): end a live view when its context is cancelled and print later lines plainly (WR-09)`, paths live.go, live_test.go, README.md, docs/console/writing-commands.md, scripts/check-phase19.8.sh.
|
||||
|
||||
After each commit, run `env GOCACHE=$GC go vet ./modules/bonfire/` and `env GOCACHE=$GC go test -short ./modules/bonfire/ ./modules/diary/`. diary routes console records through bonfire.PrintAbove.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>env GOCACHE=/tmp/claude-1000/-media-nvme-dev-golem15-summercms-io-summercms-summercms-go/e016575e-9db1-44e1-a366-55d9ae1a9f8b/scratchpad/gocache go test -count=3 -race ./modules/bonfire/ -run 'TestPrintAboveAfterCancel|TestNestedProcessBuildsNoProgram|TestProcessViewStripsControl|TestPrintAboveOrderedNoLoss|TestPrintlnStaysWithItsConsole|TestLiveViewSingleOwner|TestLiveViewPanicUnregisters|TestLiveViewRestoresTerminalOnSignal|TestPrintAbove$|TestLiveModels' && env GOCACHE=/tmp/claude-1000/-media-nvme-dev-golem15-summercms-io-summercms-summercms-go/e016575e-9db1-44e1-a366-55d9ae1a9f8b/scratchpad/gocache go test -short -count=1 ./modules/bonfire/ ./modules/diary/ && env GOCACHE=/tmp/claude-1000/-media-nvme-dev-golem15-summercms-io-summercms-summercms-go/e016575e-9db1-44e1-a366-55d9ae1a9f8b/scratchpad/gocache go vet ./modules/bonfire/ && grep -v '^\s*//' modules/bonfire/live.go | grep -c 'WithContext(' | grep -qx 0 && git log --format=%s -4 > /tmp/claude-1000/-media-nvme-dev-golem15-summercms-io-summercms-summercms-go/e016575e-9db1-44e1-a366-55d9ae1a9f8b/scratchpad/t1-subjects && grep -c '(IN-14)\|(WR-14)\|(IN-15)\|(WR-09)' /tmp/claude-1000/-media-nvme-dev-golem15-summercms-io-summercms-summercms-go/e016575e-9db1-44e1-a366-55d9ae1a9f8b/scratchpad/t1-subjects | grep -qx 4</automated>
|
||||
</verify>
|
||||
<done>Four commits exist, in the order IN-14, WR-14, IN-15, WR-09, each touching only its listed paths and none with a Co-Authored-By trailer. The RED output of WR-14, IN-15 and WR-09 on unchanged code is captured for the SUMMARY. The bonfire tests pass with -race -count=3. TestPrintAboveAfterCancel and TestNestedProcessBuildsNoProgram are in the gate's bonfire list. The README and writing-commands.md describe the cancel behaviour and the sanitized plain path.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 2: secret masking: WR-11, WR-10 (two commits)</name>
|
||||
<files>modules/bonfire/errors.go, modules/bonfire/errors_test.go, internal/argmask/argmask.go, cmd/summer/runtime.go, cmd/summer/runtime_test.go, docs/console/introduction.md, docs/console/writing-commands.md, scripts/check-phase19.8.sh</files>
|
||||
<behavior>
|
||||
- WR-11 (extend TestWrongDirectoryMasksSecrets): new cases. `admin:create --email a@b.c --password -Xk9!q` gives `cd ..; {bin} admin:create --email a@b.c --password ***`. `sync --client-secret -s3cr3t --verbose` gives `cd ..; {bin} sync --client-secret *** --verbose`. `sync --token -- x` gives `cd ..; {bin} sync --token -- x`. The existing case `sync --token --force` now expects `cd ..; {bin} sync --token ***` (P-04; the test comment says pflag takes `--force` as the value). Add "-Xk9" and "-s3cr3t" to the list of secrets that must not appear. The new dash cases fail on the unchanged code.
|
||||
- WR-10 (new TestRunAppCmdMasksSecrets; skipped on Windows like its neighbours). argv is `admin:create --email a@b.c --password hunter2 --api-token=t0k3n --client-secret -Xk9q`. Subtest "killed by a signal": newAppCmd(ctx, os.Args[0], ".", []string{"-test.run=^TestExitHelperProcess$"}, io.Discard, io.Discard) with SUMMER_EXIT_HELPER=sigkill appended to cmd.Env, then runAppCmd(cmd, argv). The error is not ErrReported, contains "signal: killed", "--password ***", "--api-token=***" and "--client-secret ***", and contains none of hunter2, t0k3n, -Xk9q. Subtest "start failure": newAppCmd with a binary path inside t.TempDir() that does not exist; same masking assertions. Extend TestDelegateExitCodePassthrough: give its run helper an args parameter, keep the two existing subtests unchanged, and add a subtest "killed by a signal masks secrets". It runs `migrate --password hunter2` through bonfire.Execute with the sigkill helper and asserts exit 1 (bonfire.ExitFailure; P-05 keeps it), exactly one " ERROR " block, "--password ***" in stderr, and no "hunter2" anywhere. All new assertions fail on the unchanged code (raw join of argv).
|
||||
</behavior>
|
||||
<action>
|
||||
Do WR-11 first, then WR-10. Each is its own commit.
|
||||
|
||||
1. **WR-11 (commit 5).** Extend TestWrongDirectoryMasksSecrets per behavior and run it RED. Fix per P-04 in tryArgs (modules/bonfire/errors.go): in the `--flag value` branch, mask args[i+1] when it exists and is not exactly `--`, and drop the leading-dash exclusion. Update tryArgs's doc comment: the next argument is masked whatever its first character, except `--`; a secret-named boolean flag over-masks its follower, which is the safe side. docs/console/writing-commands.md line 205 already promises masking. Add "(also a value that starts with `-`)" after `--password ***` there, so the docs state the rule. Commit: `fix(bonfire): mask secret flag values that start with a dash (WR-11)`, paths errors.go, errors_test.go, docs/console/writing-commands.md.
|
||||
|
||||
2. **WR-10 (commit 6).** Add TestRunAppCmdMasksSecrets and the TestDelegateExitCodePassthrough subtest per behavior, and run them RED. Fix per P-05:
|
||||
- (a) Create internal/argmask/argmask.go with a package doc comment ("Package argmask renders a command line for messages with the values of secret-named long flags masked; bonfire's wrong-directory hint and summer's delegate errors share it"). Move bonfire's secret-word list, the secret-flag check, the masking loop (as `Join`, which joins with single spaces and has no leading space) and the shell quoting (as `Quote`) into it unchanged, apart from the WR-11 rule now in place. It imports only the standard library.
|
||||
- (b) In modules/bonfire/errors.go, tryArgs returns "" for no args and otherwise a space followed by argmask.Join(args). The `cd` path uses argmask.Quote. Delete bonfire's now-unused secretWords, secretFlag and shellQuote, after grepping the package (tests included) to confirm nothing else uses them. TestWrongDirectoryMasksSecrets and TestWrongDirectoryHint must pass unchanged.
|
||||
- (c) In cmd/summer/runtime.go runAppCmd, compute `args := argmask.Join(argv)` once and build the wrapped error from it in place of the raw join. The `strings` import may become unused; remove it if so. Update runAppCmd's doc comment: the error names the command with secret-looking flag values masked; a binary ended by a signal keeps exit code 1.
|
||||
- (d) Add TestRunAppCmdMasksSecrets to the SUMMER list in scripts/check-phase19.8.sh, after the line holding TestDelegateExitCodePassthrough, with a comment `# quick 261022-4z8 (re-review fixes): WR-10.`
|
||||
- (e) Docs: in docs/console/introduction.md line 146, extend the sentence about a binary killed by a signal. The block names the command with the values of secret-looking flags masked as `***`. Keep exit code `1` in the text.
|
||||
|
||||
Run the API check (see verification) to confirm no exported identifier changed. Commit: `fix(summer): mask secret flag values in a delegate's error (WR-10)`, paths internal/argmask/argmask.go, modules/bonfire/errors.go, cmd/summer/runtime.go, cmd/summer/runtime_test.go, docs/console/introduction.md, scripts/check-phase19.8.sh.
|
||||
|
||||
After each commit, run `env GOCACHE=$GC go vet ./internal/argmask/ ./modules/bonfire/ ./cmd/summer/` and the verify command below.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>env GOCACHE=/tmp/claude-1000/-media-nvme-dev-golem15-summercms-io-summercms-summercms-go/e016575e-9db1-44e1-a366-55d9ae1a9f8b/scratchpad/gocache go test -count=1 ./modules/bonfire/ -run 'TestWrongDirectory' && env GOCACHE=/tmp/claude-1000/-media-nvme-dev-golem15-summercms-io-summercms-summercms-go/e016575e-9db1-44e1-a366-55d9ae1a9f8b/scratchpad/gocache go test -count=1 ./cmd/summer/ -run 'TestDelegateExitCodePassthrough|TestRunAppCmdMasksSecrets|TestDelegateStreams|TestAppStreams|TestRunChildForwardsSignals' && env GOCACHE=/tmp/claude-1000/-media-nvme-dev-golem15-summercms-io-summercms-summercms-go/e016575e-9db1-44e1-a366-55d9ae1a9f8b/scratchpad/gocache go vet ./internal/argmask/ ./modules/bonfire/ ./cmd/summer/ && grep -c 'strings.Join(argv' cmd/summer/runtime.go | grep -qx 0 && grep -q 'argmask.Join(argv)' cmd/summer/runtime.go</automated>
|
||||
</verify>
|
||||
<done>Two commits exist, WR-11 then WR-10, with no trailers. RED output is captured for both. internal/argmask is the only masking implementation, used by bonfire's Try line and by runAppCmd. A signal-killed or unstartable delegate's error never contains a secret value, and a signal death still exits 1. The API check shows no exported identifier removed or changed and none added under modules/. introduction.md and writing-commands.md describe the masking.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 3: lagoon WR-12 and WR-13 (two commits), then the records commit</name>
|
||||
<files>modules/lagoon/migrations.go, modules/lagoon/commands.go, modules/lagoon/migrations_test.go, modules/lagoon/example_test.go, modules/lagoon/pending_test.go, modules/lagoon/README.md, docs/console/setup-and-maintenance.md, docs/database/migrations.md, scripts/check-phase19.8.sh, .planning/phases/19.8-console-commands-overhaul/19.8-REVIEW-DISPOSITION.md, .planning/todos/pending/2026-10-22-console-review-info-findings.md</files>
|
||||
<precondition>Docker is reachable for testcontainers: `docker info` succeeds, or `sudo -u golem docker info` does. Otherwise stop: WR-12/WR-13 tests are Docker-only.</precondition>
|
||||
<behavior>
|
||||
- WR-12 (extend TestMigrateStatusJSON, Docker). After Migrate(gdb, [alpha]), delete the newest attach history row: the ID of the last entry of attach.Migrations, from the table HistoryTableName("summercms.attach"). That migration is now pending. Compute pending := PendingMigrations(gdb, plugins). For both arg variants, check three things. (1) The entries with applied false, in order, equal pending (plugin and id), each with Table equal to HistoryTableName(ref.Plugin). (2) An entry {summercms.attach, <deleted id>, applied false} is present. (3) The entries whose plugin starts with "demo." equal the old want list exactly. It fails on the unchanged code because the framework entry is missing.
|
||||
- WR-12 existing tests updated to the new Status contract (not RED tests). TestTwoPluginMigrationSetsIsolated (migrations_test.go) asserts the framework rows come first in migrationSets order (summercms.attach, summercms.cabana, DeferredHistoryID, QueueHistoryID). It then checks the demo.alpha and demo.beta rows by Plugin, not by index 0 and 1, with all existing ID assertions kept. TestDocsMigrate (example_test.go, package lagoon_test) asserts that the last line is the existing acme.blog want line and the lines before it start with lagoon's framework set IDs. Leave the docs region code between `docs:start migrate` and `docs:end migrate` byte-identical, so docs/database/migrations.md needs no `summer docs:sync`.
|
||||
- WR-13 (new TestMigrateRereadFailureWarns, Docker, dedicatedDB "lagoon_reread_warns"). Use plugin demo.rereadfirst, whose one migration creates a table, then plugin demo.rereadlast, whose one migration runs `ALTER TABLE <HistoryTableName("demo.rereadfirst")> RENAME COLUMN id TO id_gone`. Plugin IDs allow only lowercase letters, digits and dots. Compute before := PendingMigrations on the fresh DB. Run runLagoon(t, "local", dsn, plugins, "migrate"). Expect exit 0, stdout containing "Could not re-read the migration history", "demo.rereadfirst: 1 applied", "demo.rereadlast: 1 applied" and fmt.Sprintf("Applied %d migrations", len(before)). On the unchanged code the run exits 1 and prints no applied lines.
|
||||
</behavior>
|
||||
<action>
|
||||
1. **WR-12 (commit 7).** Extend TestMigrateStatusJSON per behavior and run it RED with Docker. Fix per P-06 in modules/lagoon/migrations.go: Status calls migrationSets(gdb, plugins) and builds one StatusRow per set (Plugin = set id, Table = HistoryTableName(set id); IDs from the history table ordered by id, or none when the table is missing, exactly as today). The error label becomes `lagoon: status <set id>`. Update the doc comments of StatusRow ("one migration set's recorded IDs: Plugin is the plugin ID or the framework set's history ID") and Status (it reads the history of every set Migrate applies, framework sets first). In modules/lagoon/commands.go, update the migrate:status Long text: it lists the framework's own sets and each plugin with migrations, and --json prints every declared migration of those sets. Keep the Description and Examples. Keep the JSON loop; it is now correct, see P-06.
|
||||
|
||||
Update the existing tests per behavior: TestTwoPluginMigrationSetsIsolated and TestDocsMigrate. Grep `Status(` across modules/lagoon tests for any other caller.
|
||||
|
||||
Docs:
|
||||
- modules/lagoon/README.md line 138 (`lagoon.Status` row): it lists the applied IDs of every set Migrate applies, framework sets first, then plugins.
|
||||
- modules/lagoon/README.md line 261 and docs/console/setup-and-maintenance.md line 17: the table covers the framework sets and every plugin with migrations; --json prints every declared migration of those sets.
|
||||
- docs/database/migrations.md line 84: migrate:status prints the history table and applied IDs of the framework's sets and of each plugin.
|
||||
|
||||
Commit: `fix(lagoon): migrate:status reads the sets migrate applies (WR-12)`, paths migrations.go, commands.go, migrations_test.go, example_test.go, pending_test.go, README.md, docs/console/setup-and-maintenance.md, docs/database/migrations.md.
|
||||
|
||||
2. **WR-13 (commit 8).** Add TestMigrateRereadFailureWarns to modules/lagoon/pending_test.go per behavior and run it RED with Docker. Fix per P-07 in the migrate Run of modules/lagoon/commands.go. After the follow-up PendingMigrations, the applied list starts as the pre-run pending list. On a nil re-read error it becomes appliedSince(pending, after). On a re-read error with migrateErr nil, print the warning, which includes the sanitized error, and continue. When both fail, return both errors joined. The per-set lines, the migrateErr return and the success line keep their current order.
|
||||
|
||||
Add a comment explaining why a failed read after a successful Migrate must not fail the run: the schema change is in place, and a deploy would roll back or alert. In modules/lagoon/README.md and docs/console/setup-and-maintenance.md (the migrate row, line 15), add one clause: if the history cannot be re-read after a successful run, migrate warns and counts every pending migration as applied. Add TestMigrateRereadFailureWarns to DOCKER_LAGOON_TESTS in scripts/check-phase19.8.sh, under `# quick 261022-4z8 (re-review fixes): WR-13.`
|
||||
|
||||
Commit: `fix(lagoon): warn instead of failing when the re-read after a successful migrate fails (WR-13)`, paths commands.go, pending_test.go, README.md, docs/console/setup-and-maintenance.md, scripts/check-phase19.8.sh.
|
||||
|
||||
3. **Full verification** (before the records commit). Run the commands in the plan-level verification section: vet, the full Docker test run, the hello workspace, the gate's self-test, go and docker stages, the API check and gofmt on the touched Go files. Fix any failure in the commit of the finding it belongs to; amend only if that commit is still the newest, otherwise add a follow-up `fix(...)` commit and record it. Apply the foreign-failure rule from the context section if it applies.
|
||||
|
||||
4. **Records commit (commit 9, docs only).** Edit .planning/phases/19.8-console-commands-overhaul/19.8-REVIEW-DISPOSITION.md:
|
||||
- In the frontmatter `findings`, set disposition `fixed` for WR-09, WR-10, WR-11, WR-12, WR-13, WR-14, IN-14 and IN-15, and `deferred` for IN-10, IN-11, IN-12 and IN-13. Set `open: 0` and keep `total: 32`.
|
||||
- In the table, set the same dispositions. Each fixed row's Source cell is `fix <short hash> (quick 261022-4z8)`, matching the existing `fix b077dc3 (19.8-05)` style; IN-14's says `docs <hash> (quick 261022-4z8)`. The WR-10 cell adds `; the exit-130 suggestion is deferred to todo .planning/todos/pending/2026-10-22-console-review-info-findings.md`. IN-10..IN-13 cells read `todo .planning/todos/pending/2026-10-22-console-review-info-findings.md`.
|
||||
- Keep the WR-05, WR-07 and WR-08 dispositions `fixed` and append to their Source cells `; remainder WR-14 (fix <hash>)`, `; remainder WR-12 (fix <hash>)` and `; remainder WR-11 (fix <hash>)` respectively. Leave every other row unchanged.
|
||||
|
||||
Edit .planning/todos/pending/2026-10-22-console-review-info-findings.md:
|
||||
- Append sections `## IN-10`, `## IN-11`, `## IN-12` and `## IN-13`, each with File, Problem and Suggested fix taken from 19.8-REVIEW.md (locate code by symbol, as the existing sections do).
|
||||
- Append a section `## WR-10 follow-up: exit code 128+n for a delegate killed by a signal`. It records that plan 05 specified exit 1, which this quick task kept, and gives the review's suggested mapping: SIGINT/SIGTERM as already reported with 128+n, other signals 128+n with the masked block. It also notes that docs/console/introduction.md documents exit 1 and must change with it.
|
||||
- Update the frontmatter: title "Console review info findings (IN-01..IN-13) and the delegate signal exit code"; add modules/bonfire/live.go, modules/bonfire/output.go and cmd/summer/runtime.go to `files`; leave `g15office: pending`.
|
||||
- Extend the Context paragraph with one sentence naming the re-review and this quick task.
|
||||
|
||||
Commit: `docs(19.8): re-review fixes recorded, IN-10..IN-13 and the exit-130 suggestion deferred`, paths the two files only.
|
||||
|
||||
5. Write the SUMMARY (261022-4z8-SUMMARY.md in this quick directory). Model it on 19.8-05-SUMMARY.md and include: plan_head_before (BASE) and plan_head_after; a commits-per-finding table; the RED output per finding; the verification table; the API check; decisions P-01..P-09 as applied, with any deviation; the release-note input (P-09); skipped doc updates, if any; and a suggested g15office comment. Do not commit the SUMMARY or PLAN and do not touch STATE.md; the orchestrator does that. Do not delete the gocache; the orchestrator removes it at the end.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>env GOCACHE=/tmp/claude-1000/-media-nvme-dev-golem15-summercms-io-summercms-summercms-go/e016575e-9db1-44e1-a366-55d9ae1a9f8b/scratchpad/gocache TESTCONTAINERS_HOST_OVERRIDE=127.0.0.1 go test -count=1 ./modules/lagoon/ -run 'TestMigrateStatusJSON|TestMigrateRereadFailureWarns|TestMigrateAppliedLines|TestPendingMigrations|TestTwoPluginMigrationSetsIsolated|TestDocsMigrate|TestMigratePretend' && grep -v '^\s*//' modules/lagoon/migrations.go | grep -c 'migrationSets(gdb, plugins)' | grep -qx 3 && grep -q '^open: 0$' .planning/phases/19.8-console-commands-overhaul/19.8-REVIEW-DISPOSITION.md && grep -q '^## IN-13' .planning/todos/pending/2026-10-22-console-review-info-findings.md</automated>
|
||||
</verify>
|
||||
<done>Commits 7 (WR-12), 8 (WR-13) and 9 (records) exist, with no trailers and only their listed paths. The Docker-backed lagoon tests pass, with RED output captured for WR-12 and WR-13. migrate:status --json lists pending framework migrations. A failed re-read after a successful migrate exits 0 with a warning. The disposition shows open: 0 with hashes in the Source cells. The todo carries IN-10..IN-13 and the exit-130 follow-up. The full verification passes. The SUMMARY is written and not committed.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| command line → stderr/CI logs | argv typed by an operator (passwords, tokens) is echoed in hints and errors that CI and log collectors capture |
|
||||
| untrusted text → terminal | item names (remote file names, admin-typed titles, slugs) reach the terminal through Process lines |
|
||||
| database → deploy pipeline | migrate's exit code and applied lines drive deploy gates and rollbacks |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-4z8-01 | Information disclosure | modules/bonfire/errors.go tryArgs (WR-11) | high | mitigate | Mask the argument after a secret-named long flag whatever its first character, except `--`; pinned by the dash cases in TestWrongDirectoryMasksSecrets |
|
||||
| T-4z8-02 | Information disclosure | cmd/summer/runtime.go runAppCmd (WR-10) | high | mitigate | Error built from internal/argmask.Join(argv) on every branch; TestRunAppCmdMasksSecrets (signal death, start failure) and the masked TestDelegateExitCodePassthrough subtest |
|
||||
| T-4z8-03 | Tampering | modules/bonfire/helpers.go Process plain path, processModel operation label (WR-14) | medium | mitigate | Sanitize(item) and Sanitize(op); plain-path cases in TestProcessViewStripsControl |
|
||||
| T-4z8-04 | Repudiation | lagoon migrate:status --json (WR-12) | medium | mitigate | Status reads migrationSets, so a deploy gate on "applied": false sees pending framework migrations; TestMigrateStatusJSON |
|
||||
| T-4z8-05 | Denial of service (ops) | lagoon migrate re-read (WR-13) | medium | mitigate | A successful Migrate never exits 1 because of a failed read; warning printed; TestMigrateRereadFailureWarns |
|
||||
| T-4z8-06 | Repudiation | bonfire live views (WR-09) | low | mitigate | Lines after a cancellation print plainly instead of being dropped while reported as printed; TestPrintAboveAfterCancel |
|
||||
| T-4z8-07 | Denial of service | runLive on the losing path (IN-15) | low | mitigate | No program, child context or TEA_TRACE fd created when the slot is taken; TestNestedProcessBuildsNoProgram |
|
||||
| T-4z8-08 | Information disclosure | IN-08 C1 bytes through Sanitize (deferred earlier) | low | accept | Still deferred in the todo; out of this task's scope |
|
||||
| T-4z8-SC | Tampering | npm/pip/cargo installs | high | accept | No package installs and no new Go module dependency in this plan (internal/argmask is stdlib-only) |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
Run after the WR-13 commit, before the records commit. Write `$GC` out in full; it is the gocache path from the context section.
|
||||
|
||||
1. `env GOCACHE=$GC go vet ./...`, which must pass.
|
||||
2. `env GOCACHE=$GC TESTCONTAINERS_HOST_OVERRIDE=127.0.0.1 go test -count=1 ./...`, which must pass. Rerun the wristband flake once; apply the foreign-failure rule to cmd/summer docs tests.
|
||||
3. `env GOCACHE=$GC go test -count=3 -race ./modules/bonfire/ ./modules/diary/`, which must pass.
|
||||
4. `env GOCACHE=$GC go -C examples/hello test -count=1 ./...` (the separate workspace module), which must pass.
|
||||
5. `env GOCACHE=$GC bash scripts/check-phase19.8.sh self-test`, then `env GOCACHE=$GC bash scripts/check-phase19.8.sh go`, `env GOCACHE=$GC bash scripts/check-phase19.8.sh docs` and `env GOCACHE=$GC TESTCONTAINERS_HOST_OVERRIDE=127.0.0.1 bash scripts/check-phase19.8.sh docker`, which must pass. Do not run the plugins, apps or landing stages; they touch sibling repos.
|
||||
6. API check (no exported break). Replace BASE with the recorded hash and run this. S is the session scratchpad, so the repo gets no stray files:
|
||||
`bash -c 'S=/tmp/claude-1000/-media-nvme-dev-golem15-summercms-io-summercms-summercms-go/e016575e-9db1-44e1-a366-55d9ae1a9f8b/scratchpad; d() { git diff BASE..HEAD -U0 -- "$@" ":(exclude)*_test.go"; }; X="^(func (\([^)]*\) )?[A-Z]|type [A-Z]|var [A-Z]|const [A-Z])"; d modules cmd internal | grep -E "^-(func|type|var|const) " | sed "s/^-//" | sort > $S/R; d modules cmd internal | grep -E "^\+(func|type|var|const) " | sed "s/^+//" | sort > $S/A; d modules | grep -E "^\+(func|type|var|const) " | sed "s/^+//" | sort > $S/AM; echo removed-or-changed:; comm -23 $S/R $S/A | grep -E "$X"; echo added-under-modules:; comm -13 $S/R $S/AM | grep -E "$X"; rm -f $S/R $S/A $S/AM'`
|
||||
Both lists must be empty. internal/argmask's exported Join and Quote live in an internal package, so they are not framework API and do not count.
|
||||
7. `gofmt -l` on every Go file this task touched must print nothing.
|
||||
8. Write `git log --format='%s%n%b' BASE..HEAD` to a scratchpad file, check that git exited 0, then `grep -ci 'co-authored-by'` on that file must print 0. `git log --format=%s BASE..HEAD` must show eight finding subjects ending in (IN-14), (WR-14), (IN-15), (WR-09), (WR-11), (WR-10), (WR-12) and (WR-13), plus the docs(19.8) records commit.
|
||||
9. `git status --short` must show no change of ours left unstaged and no staged foreign file; foreign dirty files (STATE.md, state.json, .msd/, other sessions' edits) are left as they were.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- All three verification gaps of 19.8 are closed by code with pinning tests: gap 1 by WR-09, gap 2 by WR-11 and WR-10, gap 3 by WR-12. WR-13, WR-14, IN-14 and IN-15 are fixed too.
|
||||
- Nine local commits: eight findings, each with its test and docs, and one records commit. No trailers, no push, no tag, no changelog post.
|
||||
- `go vet ./...` and `go test ./...` (with Docker) pass, as do the bonfire and diary race runs, the hello workspace, and the gate's self-test, go, docs and docker stages.
|
||||
- No exported framework identifier is removed, added or changed. `lagoon.Status` keeps its signature and now returns the framework sets first.
|
||||
- The disposition record shows open: 0. IN-10..IN-13 and the exit-130 suggestion are in the todo.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Write `.planning/quick/261022-4z8-fix-the-open-19-8-re-review-findings-wr-/261022-4z8-SUMMARY.md` when done (not committed by the executor).
|
||||
|
||||
For the orchestrator after execution:
|
||||
- Commit PLAN and SUMMARY per the quick workflow, in a separate docs commit. Do not touch `.planning/STATE.md` or `state.json`; the user forbade it for this run.
|
||||
- Delete the gocache directory `/tmp/claude-1000/-media-nvme-dev-golem15-summercms-io-summercms-summercms-go/e016575e-9db1-44e1-a366-55d9ae1a9f8b/scratchpad/gocache` after the last go command (executor and any verifier).
|
||||
- Phase 19.8 can now be re-verified to clear its `gaps_found` status. Releasing the fixes (v0.4.x) needs the user's go-ahead.
|
||||
</output>
|
||||
@@ -0,0 +1,258 @@
|
||||
---
|
||||
phase: quick-261022-4z8
|
||||
plan: 01
|
||||
quick_id: 261022-4z8
|
||||
subsystem: console
|
||||
tags: [review-fixes, bonfire, bubbletea, live-views, secrets, summer, delegates, lagoon, migrations, gate]
|
||||
status: complete
|
||||
gap_closure: true
|
||||
|
||||
requires:
|
||||
- phase: 19.8-05
|
||||
provides: the review fixes released as v0.4.2 and their re-review (19.8-REVIEW.md, 19.8-VERIFICATION.md gaps 1-3)
|
||||
provides:
|
||||
- "WR-09..WR-14, IN-14 and IN-15 fixed, one commit each, each code fix with a pinning test proven red on the unchanged code"
|
||||
- "internal/argmask: one secret-masking implementation shared by bonfire's Try line and summer's delegate errors"
|
||||
- "lagoon.Status / migrate:status list the framework sets first (same list as Migrate and PendingMigrations)"
|
||||
- "scripts/check-phase19.8.sh requires the 4 new pinning tests by name"
|
||||
- "19.8-REVIEW-DISPOSITION.md at open: 0; IN-10..IN-13 and the exit-130 suggestion in the console review todo"
|
||||
affects: [phase 19.8 re-verification (gaps_found), next v0.4.x release, bonfire live views, summer delegates, lagoon migrate]
|
||||
|
||||
actuals:
|
||||
tokens: 12206 # chars/4 over the added and removed lines of this task's diff (48825 chars)
|
||||
tasks: 3
|
||||
commits: 9 # MEASURED: git rev-list --count plan_head_before..plan_head_after
|
||||
plan_head_before: a28957f7d0bc6dfe17b6b79fd7b5aada3d222546
|
||||
plan_head_after: a48afbd11e648ba7024c687a18cce3bede24bf51
|
||||
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns:
|
||||
- "Live view lifetime decoupled from the caller's context: a watcher ends and unregisters the view under liveMu on ctx cancellation"
|
||||
- "Claim the single live slot before building the bubbletea program"
|
||||
- "printAbove refuses a view whose Run has ended (liveView.done)"
|
||||
- "Framework-internal helper package (internal/argmask) shared by a module and the summer CLI instead of a new exported API"
|
||||
- "Counting context (AfterFunc) to observe child-context registrations in a test"
|
||||
|
||||
key-files:
|
||||
created:
|
||||
- internal/argmask/argmask.go
|
||||
modified:
|
||||
- modules/bonfire/errors.go
|
||||
- modules/bonfire/helpers.go
|
||||
- modules/bonfire/live.go
|
||||
- modules/bonfire/live_test.go
|
||||
- modules/bonfire/errors_test.go
|
||||
- modules/bonfire/README.md
|
||||
- cmd/summer/runtime.go
|
||||
- cmd/summer/runtime_test.go
|
||||
- modules/lagoon/migrations.go
|
||||
- modules/lagoon/commands.go
|
||||
- modules/lagoon/migrations_test.go
|
||||
- modules/lagoon/example_test.go
|
||||
- modules/lagoon/pending_test.go
|
||||
- modules/lagoon/README.md
|
||||
- docs/console/writing-commands.md
|
||||
- docs/console/introduction.md
|
||||
- docs/console/setup-and-maintenance.md
|
||||
- docs/database/migrations.md
|
||||
- scripts/check-phase19.8.sh
|
||||
- .planning/phases/19.8-console-commands-overhaul/19.8-REVIEW-DISPOSITION.md
|
||||
- .planning/todos/pending/2026-10-22-console-review-info-findings.md
|
||||
|
||||
key-decisions:
|
||||
- "P-01 applied as planned: the live program no longer takes the caller's context; watchContext ends the view with its last frame and unregisters it under liveMu; printAbove checks liveView.done. The review's fallback (check-then-Send) was not needed: the design passes -race -count=3."
|
||||
- "P-05 applied: the masking moved to internal/argmask (Join, Quote); no exported framework identifier added, removed or changed."
|
||||
- "P-06 applied: lagoon.Status iterates migrationSets; a plugin whose HasMigrations returns none no longer gets an empty row."
|
||||
- "P-07 applied: a failed re-read after a successful Migrate warns and exits 0, counting the pre-run pending list as applied."
|
||||
|
||||
metrics:
|
||||
duration: "~20 min"
|
||||
completed: 2026-10-22
|
||||
---
|
||||
|
||||
# Quick 261022-4z8: the open 19.8 re-review findings Summary
|
||||
|
||||
**Cancelled live views now end with their last frame and print later lines plainly, delegate errors and Try lines mask every secret flag value (dash-leading included) through one shared `internal/argmask`, and `migrate:status` lists the framework sets that `migrate` applies. Eight review findings were fixed in eight commits, plus one records commit.**
|
||||
|
||||
This closes the three verification gaps of phase 19.8: gap 1 by WR-09, gap 2 by WR-11 and WR-10, gap 3 by WR-12. WR-13, WR-14, IN-14 and IN-15 are fixed as well. IN-10..IN-13 and WR-10's exit-130 suggestion are deferred to the existing todo.
|
||||
|
||||
## Commits per finding
|
||||
|
||||
| # | Finding | Commit | Subject | Pinning test |
|
||||
|---|---------|--------|---------|--------------|
|
||||
| 1 | IN-14 | 7c15095 | docs(bonfire): the ErrReported comment names the WithExitCode code (IN-14) | none (doc comment only) |
|
||||
| 2 | WR-14 | 48e13f9 | fix(bonfire): strip control characters from Process's plain item lines and the view's label (WR-14) | TestProcessViewStripsControl (extended: operation label, two plain-path subtests) |
|
||||
| 3 | IN-15 | 1ac75ad | fix(bonfire): claim the live view slot before building the program (IN-15) | TestNestedProcessBuildsNoProgram (new) |
|
||||
| 4 | WR-09 | fce232d | fix(bonfire): end a live view when its context is cancelled and print later lines plainly (WR-09) | TestPrintAboveAfterCancel (new) |
|
||||
| 5 | WR-11 | 707372b | fix(bonfire): mask secret flag values that start with a dash (WR-11) | TestWrongDirectoryMasksSecrets (extended) |
|
||||
| 6 | WR-10 | 17c722d | fix(summer): mask secret flag values in a delegate's error (WR-10) | TestRunAppCmdMasksSecrets (new), TestDelegateExitCodePassthrough (new subtest) |
|
||||
| 7 | WR-12 | 57a849d | fix(lagoon): migrate:status reads the sets migrate applies (WR-12) | TestMigrateStatusJSON (extended); TestTwoPluginMigrationSetsIsolated and TestDocsMigrate updated to the new contract |
|
||||
| 8 | WR-13 | 0691d26 | fix(lagoon): warn instead of failing when the re-read after a successful migrate fails (WR-13) | TestMigrateRereadFailureWarns (new, Docker) |
|
||||
| 9 | records | a48afbd | docs(19.8): re-review fixes recorded, IN-10..IN-13 and the exit-130 suggestion deferred | n/a |
|
||||
|
||||
No commit carries a Co-Authored-By trailer (`grep -ci co-authored-by` over `git log --format='%s%n%b' BASE..HEAD` prints 0). Nothing was pushed or tagged, and nothing was posted to the changelog.
|
||||
|
||||
## RED output on the unchanged code
|
||||
|
||||
**WR-14** (TestProcessViewStripsControl):
|
||||
```
|
||||
--- FAIL: TestProcessViewStripsControl (0.00s)
|
||||
live_test.go:546: process view carries "\x1b]0;pwned\a": "... Push\x1b]0;pwned\aing 1/2 ..."
|
||||
--- FAIL: TestProcessViewStripsControl/terminal_with_-n (0.00s)
|
||||
live_test.go:577: plain Process line carries "\x1b]0;pwned\a": "\x1b[32m✓\x1b[m evil\x1b]0;pwned\aitem\n"
|
||||
--- FAIL: TestProcessViewStripsControl/no_terminal,_color_forced (0.00s)
|
||||
live_test.go:577: plain Process line carries "\x1b]0;pwned\a": "\x1b[32m✓\x1b[m evil\x1b]0;pwned\aitem\n"
|
||||
```
|
||||
|
||||
**IN-15** (TestNestedProcessBuildsNoProgram):
|
||||
```
|
||||
--- FAIL: TestNestedProcessBuildsNoProgram (0.00s)
|
||||
live_test.go:649: the nested Process built a program: the TEA_TRACE file exists
|
||||
live_test.go:652: 1 child contexts still registered on the caller's context, want 0
|
||||
```
|
||||
|
||||
**WR-09** (TestPrintAboveAfterCancel). It fails on the 2s poll, not by hanging:
|
||||
```
|
||||
--- FAIL: TestPrintAboveAfterCancel (2.00s)
|
||||
live_test.go:686: view still registered 2s after the cancellation
|
||||
live_test.go:691: PrintAbove reported true after the cancellation
|
||||
live_test.go:708: "after-cancel-println" printed 0 times, want 1:
|
||||
live_test.go:708: "after-cancel-info" printed 0 times, want 1:
|
||||
live_test.go:708: "after-cancel-printabove" printed 0 times, want 1:
|
||||
```
|
||||
|
||||
**WR-11** (TestWrongDirectoryMasksSecrets):
|
||||
```
|
||||
["sync" "--token" "--force"]: Try = "cd ..; {bin} sync --token --force", want "cd ..; {bin} sync --token ***"
|
||||
["admin:create" "--email" "a@b.c" "--password" "-Xk9!q"]: Try = "... --password '-Xk9!q'", want "... --password ***"
|
||||
["admin:create" ... "-Xk9!q"]: Try line repeats "-Xk9"
|
||||
["sync" "--client-secret" "-s3cr3t" "--verbose"]: Try = "... --client-secret -s3cr3t --verbose", want "... --client-secret *** --verbose"
|
||||
```
|
||||
|
||||
**WR-10** (TestDelegateExitCodePassthrough, TestRunAppCmdMasksSecrets):
|
||||
```
|
||||
--- FAIL: TestDelegateExitCodePassthrough/killed_by_a_signal_masks_secrets
|
||||
want one error block with the password masked, got 1:
|
||||
stderr "\n ERROR summer: migrate --password hunter2: signal: killed\n\n Run with -v for details.\n\n"
|
||||
the password reached the output
|
||||
--- FAIL: TestRunAppCmdMasksSecrets/killed_by_a_signal
|
||||
error lacks "--password ***": summer: admin:create --email a@b.c --password hunter2 --api-token=t0k3n --client-secret -Xk9q: signal: killed
|
||||
error repeats "hunter2" / "t0k3n" / "-Xk9q"
|
||||
--- FAIL: TestRunAppCmdMasksSecrets/start_failure
|
||||
error lacks "--password ***": summer: admin:create ... --password hunter2 ...: fork/exec .../missing: no such file or directory
|
||||
```
|
||||
|
||||
**WR-12** (TestMigrateStatusJSON, Docker):
|
||||
```
|
||||
--- FAIL: TestMigrateStatusJSON (0.38s)
|
||||
pending_test.go:318: [migrate:status --json]: pending entries = [{demo.beta ... 202610210001_create_pending_beta} {demo.beta ... 202610210002_seed_pending_beta}],
|
||||
want PendingMigrations' [{summercms.attach summer_migrations_summercms_attach 202609180001_create_system_files ...} ...]
|
||||
pending_test.go:321: [migrate:status --json]: the pending framework migration 202609180001_create_system_files is not listed
|
||||
(same two failures for [migrate:status --json -q])
|
||||
```
|
||||
|
||||
**WR-13** (TestMigrateRereadFailureWarns, Docker):
|
||||
```
|
||||
--- FAIL: TestMigrateRereadFailureWarns (0.42s)
|
||||
pending_test.go:428: migrate: exit 1, want 0
|
||||
ERROR lagoon: pending demo.rereadfirst: ERROR: column "id" does not exist (SQLSTATE 42703)
|
||||
```
|
||||
|
||||
## Verification
|
||||
|
||||
| Check | Result |
|
||||
|-------|--------|
|
||||
| `go vet ./...` | pass |
|
||||
| `TESTCONTAINERS_HOST_OVERRIDE=127.0.0.1 go test -count=1 ./...` (Docker suite) | pass, 43 packages ok, exit 0. The wristband flake did not occur. |
|
||||
| `go test -count=3 -race ./modules/bonfire/ ./modules/diary/` | pass (bonfire 15.6s, diary 2.2s) |
|
||||
| Task 1 `-race -count=3` live-view set (TestPrintAboveAfterCancel, TestNestedProcessBuildsNoProgram, TestProcessViewStripsControl, TestPrintAboveOrderedNoLoss, TestPrintlnStaysWithItsConsole, TestLiveViewSingleOwner, TestLiveViewPanicUnregisters, TestLiveViewRestoresTerminalOnSignal, TestPrintAbove, TestLiveModels) | pass |
|
||||
| `go -C examples/hello test -count=1 ./...` | pass |
|
||||
| `scripts/check-phase19.8.sh self-test` | pass |
|
||||
| `scripts/check-phase19.8.sh go` | pass (bonfire list now 44 tests, SUMMER list 7) |
|
||||
| `scripts/check-phase19.8.sh docs` | pass |
|
||||
| `scripts/check-phase19.8.sh docker` | pass (lagoon list now 7 tests, TestMigrateRereadFailureWarns included) |
|
||||
| Task verify greps | `WithContext(` outside comments in live.go: 0; `strings.Join(argv` in runtime.go: 0; `argmask.Join(argv)` present; `migrationSets(gdb, plugins)` outside comments in migrations.go: 3; `open: 0`; `## IN-13` in the todo |
|
||||
| gofmt on touched Go files | Clean in every hunk this task wrote. `gofmt -l` still lists `cmd/summer/runtime.go` and `cmd/summer/runtime_test.go`, but they were already unformatted at BASE (a struct field alignment at runtime.go:27 and a map alignment at runtime_test.go:81, both untouched by this task). Out of scope, not fixed. |
|
||||
| `git status --short` | Only foreign files remain (STATE.md, state.json, .msd/, two .gitkeep files, this quick dir). Nothing of ours is unstaged, and nothing foreign was staged. |
|
||||
|
||||
The plugins, apps and landing stages of the gate were not run, as the plan says (they touch sibling repos).
|
||||
|
||||
## API check (no exported break)
|
||||
|
||||
`git diff BASE..HEAD -U0 -- modules cmd internal ':(exclude)*_test.go'`:
|
||||
|
||||
- removed-or-changed exported: **(empty)**
|
||||
- added under modules/ exported: **(empty)**
|
||||
|
||||
Every changed declaration is unexported: bonfire's `secretWords`, `secretFlag` and `shellQuote` were removed (moved), and `watchContext` was added. `argmask.Join` and `argmask.Quote` are exported but sit in `internal/`, so they are not framework API. `lagoon.Status` keeps its signature, and `lagoon.StatusRow` keeps its fields.
|
||||
|
||||
## Decisions as applied
|
||||
|
||||
- **P-01 (WR-09):** Applied as designed. `tea.WithContext` was removed from `tea.NewProgram`, and `liveView` gained `done <-chan struct{}`, which is runLive's `finished` channel, created before the claim. The new unexported `watchContext(ctx, view, finished)` holds the watcher. On `ctx.Done()` it unregisters the view, sends `finishMsg{err: ctx.Err()}` and waits for `finished`, all under `liveMu`. On `finished` it only unregisters. In both branches it clears `activeView` only if the view is still the active one. The deferred cleanup now also waits for the watcher (`stopWatch()`) after releasing `liveMu`, so no goroutine outlives `runLive`. `printAbove` reads `activeView` into `v` and, after the owner check, does a non-blocking `case <-v.done:` that unregisters and returns false. watchSignals is unchanged. The fallback was not needed.
|
||||
- **P-02 (IN-15):** Applied. `finished` is created before the lock, and the program is built under `liveMu` after the slot check.
|
||||
- **P-03 (WR-14):** Applied at the call sites: `out.Success(Sanitize(item))` and `op := Sanitize(m.operation)`. `console.status` is unchanged.
|
||||
- **P-04 (WR-11):** Applied. The `sync --token --force` case now expects `--token ***`, and the test comment explains why (pflag takes `--force` as the value).
|
||||
- **P-05 (WR-10):** Applied. `internal/argmask` provides `Join` and `Quote` and imports only `strings`. bonfire keeps `tryArgs` as a thin wrapper. `runAppCmd` uses `argmask.Join(argv)` on every branch, a signal death still exits 1, and runtime.go's `strings` import was dropped. modules/bonfire is the first module to import a package under `internal/`; Go's internal rule allows it, because both sit in the same module root.
|
||||
- **P-06 (WR-12):** Applied. The `migrate:status` Description and Examples are unchanged, and only the Long help changed. The goldens are untouched, since no golden carries the Long text. The `--json` loop in commands.go is unchanged.
|
||||
- **P-07 (WR-13):** Applied. A `switch` takes the re-read result: on success it uses `appliedSince`, when Migrate and the re-read both fail it returns `errors.Join`, and when only the re-read fails it warns (`Could not re-read the migration history: <sanitized error>`) and keeps the pre-run pending list.
|
||||
- **P-08:** Applied. TestNestedProcessBuildsNoProgram and TestPrintAboveAfterCancel joined the BONFIRE list, TestRunAppCmdMasksSecrets the SUMMER list, and TestMigrateRereadFailureWarns DOCKER_LAGOON_TESTS. Each joined in the same commit as its test.
|
||||
- **P-09:** `docs/setup/upgrading.md` was not changed.
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
**1. [CLAUDE.md doc rule] The WR-10 commit also updates modules/bonfire/README.md**
|
||||
- **Found during:** Task 2, after the WR-10 commit.
|
||||
- **Issue:** CLAUDE.md says a change to a module's dependencies updates its README in the same commit. bonfire now imports `internal/argmask`, and the README's Dependencies section did not say so. The README's error-block paragraph also did not state the dash-leading masking rule.
|
||||
- **Fix:** Added `internal/argmask` to the README's Dependencies line, and "also a value that starts with `-`" to the Try-line masking sentence. Folded into the WR-10 commit with `git commit --amend -- modules/bonfire/README.md` while it was still the newest commit, and before any hash was recorded anywhere. The final WR-10 hash is 17c722d.
|
||||
- **Note:** The plan's WR-10 path list did not include the README. The WR-11 sentence in the README landed in the WR-10 commit rather than in the WR-11 commit; writing-commands.md got the WR-11 sentence in the WR-11 commit as planned.
|
||||
|
||||
**2. [Test detail] TestNestedProcessBuildsNoProgram checks the success line as "✓ x"**
|
||||
- The plan says the "x" success line must reach the buffer. A bare `"x"` substring check would be weak, so the test matches `symCheck+" x"` in the stripped output. The behaviour pinned is unchanged.
|
||||
|
||||
**3. [Test detail] TestMigrateStatusJSON's pending entries use the table from HistoryTableName(ref.Plugin)**
|
||||
- Implemented as planned. `attach.Migrations` holds one migration, `202609180001_create_system_files`, so "the newest attach row" is that one.
|
||||
|
||||
No other deviations. No finding was skipped.
|
||||
|
||||
## Skipped doc updates / follow-ups
|
||||
|
||||
- None skipped. None of the files this task edited had foreign uncommitted changes, which `git status --short -- <file>` checked before each edit.
|
||||
- Pre-existing gofmt drift in `cmd/summer/runtime.go:27`, `cmd/summer/runtime_test.go:81` and `cmd/summer/main.go`, plus several cabana, compass, flare and tide files. It is not caused by this task and was not fixed, per the scope boundary. It may be worth a `/msd-fast` gofmt pass when no other session is editing cabana.
|
||||
|
||||
## Release-note input (P-09, for whoever tags next)
|
||||
|
||||
- `lagoon.Status` and `migrate:status` (table and `--json`) now list the framework's own migration sets first (`summercms.attach`, `summercms.cabana`, deferred bindings, queue), then each plugin with migrations. `--json` now shows a pending framework migration as `"applied": false`. A plugin whose `Migrations()` returns none no longer gets an empty row.
|
||||
- `migrate` no longer exits 1 when the migrations succeeded but reading the history back failed. It prints a warning and counts the pending migrations as applied.
|
||||
- When the context of a `bonfire.Process` view is cancelled, the view ends with its last frame and later lines print plainly below it. Before, they were lost.
|
||||
- `bonfire.Process` strips control characters from item names on its plain path and from the operation label.
|
||||
- Secret masking now also covers values that start with `-` (`--password -Xk9`), and summer's error for a delegate killed by a signal, or failing to start, masks secret flag values. A signal death still exits 1.
|
||||
|
||||
## Suggested g15office comment
|
||||
|
||||
> 19.8 re-review fixes landed locally (quick 261022-4z8). There are nine commits on master, not pushed: WR-09..WR-14, IN-14 and IN-15 are fixed with pinning tests, and IN-10..IN-13 plus the exit-130 idea are deferred to the console review todo. The disposition is at open: 0. Next: re-verify phase 19.8 to clear gaps_found, then a v0.4.x release on the user's go-ahead.
|
||||
|
||||
## Known Stubs
|
||||
|
||||
None.
|
||||
|
||||
## Threat Flags
|
||||
|
||||
None. No new network endpoint, auth path or schema change was added. The threat register's mitigations T-4z8-01..07 are each pinned by the tests listed above.
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
- FOUND: internal/argmask/argmask.go
|
||||
- FOUND: 7c15095, 48e13f9, 1ac75ad, fce232d, 707372b, 17c722d, 57a849d, 0691d26, a48afbd (`git rev-list --count a28957f..a48afbd` = 9)
|
||||
|
||||
## Follow-up: WR-12 rework
|
||||
|
||||
Verification found that 57a849d changed what the exported `lagoon.Status` returns (framework sets first, no row for a plugin with no migrations). That broke `TestRollbackIsolatesFonotekaFullSchema` in `../fonoteka.go/parity`, so the plan's P-06 premise (no outside caller) was wrong. The orchestrator decided to keep `Status` as it was and to keep the WR-12 fix in the command only.
|
||||
|
||||
- **139f5e3** `fix(lagoon): keep Status plugin-only; migrate:status adds the framework sets itself (WR-12)`. `lagoon.Status` is back to its pre-57a849d behaviour: one row per plugin that implements `pact.HasMigrations`, in plugin order, with an empty row for a plugin with no migrations. `migrate:status` (table and `--json`) reads the new unexported `setStatus`, which takes its sets from `migrationSets`, so it still agrees with `migrate --pretend`. Both share the unexported `historyRows`. No exported API was added or changed. `TestTwoPluginMigrationSetsIsolated` and `TestDocsMigrate` are back to their pre-57a849d form. The new `TestStatusPluginRows` pins `Status`, and `TestMigrateStatusJSON` now also checks the table output. The new test is added to `scripts/check-phase19.8.sh`. The lagoon README and `docs/database/migrations.md` describe `Status` as plugin-only again.
|
||||
- **b1fba7e** `docs(19.8): ...`. The WR-12 disposition row names both 57a849d and 139f5e3.
|
||||
- **RED/GREEN.** In a scratch copy where the command calls the restored `Status` (that is, the command as it was before 57a849d), `TestMigrateStatusJSON` fails: the pending framework migration is missing from `--json`, and the table lists only the `demo.*` rows. In a scratch copy of the 57a849d code with the new tests, `TestStatusPluginRows`, `TestTwoPluginMigrationSetsIsolated` and `TestDocsMigrate` fail. All of them pass at 139f5e3.
|
||||
- **Checks.** `go vet ./...` passes, as do `summer docs:build --check` and `scripts/check-phase19.8.sh self-test`. `TESTCONTAINERS_HOST_OVERRIDE=127.0.0.1 go test -count=1 ./modules/lagoon/...` and the full Docker `go test -count=1 ./...` exit 0.
|
||||
- **fonoteka** (not modified, `replace => ../summercms.go`). `TestRollbackIsolatesFonotekaFullSchema` passes again. `TestMigrateSeedsCanonicalGenres` and `TestRollbackLastIsolatesFonoteka` still fail, and they did before this task: they now get past the row check (`status[0]` is `golem15.user`) but expect 11 user-plugin migrations, and the user plugin has 13.
|
||||
- **Release-note correction.** The first bullet of "Release-note input" above is superseded. `lagoon.Status` does not change. Only `migrate:status` (table and `--json`) now lists the framework's own sets first, and it lists only plugins that have migrations.
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
---
|
||||
phase: quick-261022-4z8
|
||||
verified: 2026-10-22T04:30:00Z
|
||||
status: passed
|
||||
score: 12/12 must-haves verified (WR-12 resolved by follow-up 139f5e3)
|
||||
covered_files:
|
||||
- .planning/phases/19.8-console-commands-overhaul/19.8-REVIEW-DISPOSITION.md
|
||||
- .planning/quick/261022-4z8-fix-the-open-19-8-re-review-findings-wr-/261022-4z8-PLAN.md
|
||||
- .planning/quick/261022-4z8-fix-the-open-19-8-re-review-findings-wr-/261022-4z8-SUMMARY.md
|
||||
- .planning/todos/pending/2026-10-22-console-review-info-findings.md
|
||||
- cmd/summer/runtime.go
|
||||
- cmd/summer/runtime_test.go
|
||||
- docs/console/introduction.md
|
||||
- docs/console/setup-and-maintenance.md
|
||||
- docs/console/writing-commands.md
|
||||
- docs/database/migrations.md
|
||||
- internal/argmask/argmask.go
|
||||
- modules/bonfire/README.md
|
||||
- modules/bonfire/errors.go
|
||||
- modules/bonfire/errors_test.go
|
||||
- modules/bonfire/helpers.go
|
||||
- modules/bonfire/live.go
|
||||
- modules/bonfire/live_test.go
|
||||
- modules/lagoon/README.md
|
||||
- modules/lagoon/commands.go
|
||||
- modules/lagoon/example_test.go
|
||||
- modules/lagoon/migrations.go
|
||||
- modules/lagoon/migrations_test.go
|
||||
- modules/lagoon/pending_test.go
|
||||
- scripts/check-phase19.8.sh
|
||||
covered_digest: "v2:sha256:a7e6e0cc40bcaa62cc411ab11ce6be4f7002259775e1e01c5e30862d9a068518"
|
||||
behavior_unverified: 0
|
||||
overrides_applied: 0
|
||||
human_verification:
|
||||
- test: "Decide whether the lagoon.Status contract change (WR-12) is acceptable as non-breaking, given that a sibling consumer depends on the old row set"
|
||||
expected: "Either (a) accept it: update fonoteka.go/parity (withoutSharedPlugins or the index assertions) to the new framework-first rows, and record lagoon.Status's changed result in the next release notes / docs/setup/upgrading.md as a behaviour change; or (b) reject it: keep lagoon.Status plugin-only and add the framework sets only in migrate:status"
|
||||
why_human: "The function signature and StatusRow fields are unchanged, so no compile-level API break exists; but the result set changed and the plan's P-06 premise ('No consumer outside this repo calls lagoon.Status') is false. fonoteka.go/parity calls lagoon.Status in three tests and asserts len(status)==2 and status[0].Plugin=='golem15.user'. Verified: TestRollbackIsolatesFonotekaFullSchema passes against summercms.go at a28957f and fails against a48afbd (status now starts with summercms.attach, summercms.cabana, summercms.deferred, summercms.conga). Whether that counts as an 'exported API break' under the task's constraint is a policy decision."
|
||||
---
|
||||
|
||||
# Quick 261022-4z8: the open 19.8 re-review findings Verification Report
|
||||
|
||||
**Task goal:** Fix the open findings of the 19.8 code re-review (WR-09, WR-10, WR-11, WR-12, WR-13, WR-14, IN-14, IN-15). The constraints are one commit per finding, each with a test that fails without the fix; no exported API breaks; `go vet` and the Docker `go test ./...` pass; the disposition record and the info todo are updated; local commits only.
|
||||
**Verified:** 2026-10-22T04:30:00Z
|
||||
**Status:** human_needed
|
||||
**Re-verification:** No (initial verification)
|
||||
|
||||
Every RED claim in SUMMARY.md was replayed independently. For each finding the parent of the fix commit was exported with `git archive` to the scratchpad, the test file(s) from the fix commit were overlaid, and the named test was run. The same test was then run on the fix commit itself. Every finding was red before its fix and green after it.
|
||||
|
||||
## Goal Achievement
|
||||
|
||||
### Observable Truths
|
||||
|
||||
| # | Truth | Status | Evidence |
|
||||
|---|-------|--------|----------|
|
||||
| 1 | WR-09: cancelling Process's ctx ends the view with its last frame, unregisters it without waiting for work; later Println/Info/PrintAbove print plainly (PrintAbove false), none lost; printAbove refuses a program whose Run ended on its own | ✓ VERIFIED | live.go: `tea.WithContext` removed (no non-comment `WithContext(`); `watchContext` unregisters, sends `finishMsg{ctx.Err()}` and waits for `finished` under liveMu; `liveView.done`; `printAbove` has `case <-v.done:`. TestPrintAboveAfterCancel: RED at fce232d^ (the view was still registered after 2s, PrintAbove returned true, and all three lines were printed 0 times), GREEN at fce232d. `-race -count=10` on the live-view set passes. bubbletea v2.1.0 `Send` selects on `p.ctx.Done()` and `Run` does `defer p.cancel()`, so the deferred cleanup's Send after the watcher finishes cannot block. The "ended on its own" branch was confirmed by a throwaway verifier probe in a scratch copy (activeView with a closed `done`: printAbove returns false and unregisters). No committed test pins that branch (Info). |
|
||||
| 2 | WR-14: Process's plain path prints item names through Sanitize; the view's operation label is sanitized | ✓ VERIFIED | helpers.go:183 `out.Success(Sanitize(item))`; live.go:313 `op := Sanitize(m.operation)`. TestProcessViewStripsControl: RED at 48e13f9^ (label, both plain-path subtests), GREEN at 48e13f9. |
|
||||
| 3 | IN-15: runLive claims the slot before building the program; the loser builds none (no child ctx, no TEA_TRACE file) | ✓ VERIFIED | live.go: the `activeView != nil` check under liveMu comes before `tea.NewProgram`. TestNestedProcessBuildsNoProgram: RED at 1ac75ad^ (the trace file existed and 1 child ctx stayed registered), GREEN at 1ac75ad. |
|
||||
| 4 | IN-14: the ErrReported doc comment names the WithExitCode code | ✓ VERIFIED | errors.go:74-76. The `var ErrReported` line is unchanged. This finding only changes a doc comment, so no test is possible. |
|
||||
| 5 | WR-11: the Try line masks the argument after a secret-named long flag whatever its first character; only `--` is left alone | ✓ VERIFIED | argmask.go `args[i+1] != "--"`. TestWrongDirectoryMasksSecrets: RED at 707372b^ (`-Xk9!q` and `-s3cr3t` in clear text), GREEN at 707372b. |
|
||||
| 6 | WR-10: the error for a signal-killed or unstartable delegate masks argv through the shared internal/argmask; exit 1 is kept; no exported identifier changes | ✓ VERIFIED | runtime.go:174 `argmask.Join(argv)` wraps every branch, and no `strings.Join(argv` remains. bonfire's tryArgs and cd line use `argmask.Join` and `argmask.Quote`; bonfire's own secretWords, secretFlag and shellQuote are gone, so only one implementation exists. TestRunAppCmdMasksSecrets and TestDelegateExitCodePassthrough/killed_by_a_signal_masks_secrets: RED at 17c722d^ (hunter2, t0k3n and -Xk9q in the error), GREEN at 17c722d. |
|
||||
| 7 | WR-12: lagoon.Status reads migrationSets (framework sets first); --json lists every pending migration including framework ones; the signature and StatusRow are unchanged | ✓ VERIFIED | migrations.go:150. `migrationSets(gdb, plugins)` appears 3 times outside comments (Migrate, Status, PendingMigrations). TestMigrateStatusJSON (Docker): RED at 57a849d^ (pending `summercms.attach 202609180001_create_system_files` missing), GREEN at 57a849d. See truth 12 for the consumer impact. |
|
||||
| 8 | WR-13: when Migrate succeeds but the re-read fails, migrate warns, reports the pre-run pending list as applied and exits 0; when both fail, both errors are returned | ✓ VERIFIED | commands.go:177-192 `switch`: `err == nil` gives appliedSince; `migrateErr != nil` gives `errors.Join`; otherwise it warns with `Could not re-read the migration history`. TestMigrateRereadFailureWarns (Docker): RED at 0691d26^ (exit 1), GREEN at 0691d26. The both-fail branch is checked by reading the code only; it is a one-line return kept from before. |
|
||||
| 9 | One commit per finding with its pinning test and docs; no Co-Authored-By trailer; nothing pushed or tagged | ✓ VERIFIED | `git log a28957f..a48afbd` shows 9 commits: 8 finding commits (IN-14, WR-14, IN-15, WR-09, WR-11, WR-10, WR-12, WR-13) and 1 docs(19.8) records commit. `grep -ci co-authored` = 0. No tag points at or contains these commits. The branch is `ahead 16` of origin/master. Minor deviation: modules/bonfire/README.md's WR-11 masking sentence landed in the WR-10 commit (recorded in SUMMARY). |
|
||||
| 10 | The disposition marks WR-09..WR-14, IN-14 and IN-15 fixed with hashes (open: 0) plus the WR-05/07/08 remainders, and IN-10..IN-13 deferred; the todo carries IN-10..IN-13 and the exit-130 suggestion | ✓ VERIFIED | 19.8-REVIEW-DISPOSITION.md has `open: 0`, `total: 32`, and table rows 143-154 and 162-165 with the matching hashes. The todo has `## IN-10`..`## IN-13` and `## WR-10 follow-up: exit code 128+n …`, and its title and files are updated. |
|
||||
| 11 | `go vet ./...` and the Docker `go test ./...` pass | ✓ VERIFIED | `go vet ./...` passes. `TESTCONTAINERS_HOST_OVERRIDE=127.0.0.1 go test -count=1 ./...` exits 0 with 43 packages ok (lagoon 40s and lagoon/attach 54s, so the Docker tests ran). |
|
||||
| 12 | No exported API breaks | ? UNCERTAIN | The compile-level API is intact: the diff shows no exported func, type, var or const removed or changed, and none added under modules/ (argmask.Join and argmask.Quote are in internal/). However, **lagoon.Status's result changed** (framework rows first; a plugin with zero migrations no longer gets a row). The plan's P-06 premise that no outside consumer calls it is false: `fonoteka.go/parity/migrate_test.go` (3 calls) and `rollback_isolation_full_test.go` call it, and fonoteka uses `replace => ../summercms.go`. Reproduced: TestRollbackIsolatesFonotekaFullSchema PASSES against a28957f and FAILS against a48afbd (`status = [summercms.attach …]`, `len != 2`). Needs a human decision (see Human Verification). |
|
||||
|
||||
**Score:** 11/12 truths verified (0 present but behavior-unverified, 1 uncertain)
|
||||
|
||||
### Required Artifacts
|
||||
|
||||
| Artifact | Expected | Status | Details |
|
||||
|----------|----------|--------|---------|
|
||||
| `modules/bonfire/live.go` | slot claimed before NewProgram; watcher; liveView.done; printAbove refuses a finished program | ✓ VERIFIED | contains `case <-v.done:`; watchContext wired in runLive |
|
||||
| `modules/bonfire/helpers.go` | Sanitize on plain path | ✓ VERIFIED | `out.Success(Sanitize(item))` |
|
||||
| `modules/bonfire/errors.go` | ErrReported comment; tryArgs via argmask | ✓ VERIFIED | `argmask.Join`, `argmask.Quote` |
|
||||
| `internal/argmask/argmask.go` | Join + Quote, stdlib only | ✓ VERIFIED | imports only `strings`; used by bonfire and cmd/summer |
|
||||
| `cmd/summer/runtime.go` | runAppCmd uses argmask.Join(argv) | ✓ VERIFIED | line 174 |
|
||||
| `modules/lagoon/migrations.go` | Status from migrationSets, signature unchanged | ✓ VERIFIED | line 146 signature identical |
|
||||
| `modules/lagoon/commands.go` | re-read warning | ✓ VERIFIED | `Could not re-read the migration history` |
|
||||
| `modules/bonfire/live_test.go` | TestPrintAboveAfterCancel, TestNestedProcessBuildsNoProgram, plain-path cases | ✓ VERIFIED | present, red-before/green-after replayed |
|
||||
| `cmd/summer/runtime_test.go` | TestRunAppCmdMasksSecrets + masked signal subtest | ✓ VERIFIED | present, replayed |
|
||||
| `modules/lagoon/pending_test.go` | TestMigrateStatusJSON framework pending, TestMigrateRereadFailureWarns | ✓ VERIFIED | present, replayed with Docker |
|
||||
| `19.8-REVIEW-DISPOSITION.md` | open: 0 | ✓ VERIFIED | |
|
||||
|
||||
### Key Link Verification
|
||||
|
||||
| From | To | Via | Status | Details |
|
||||
|------|----|-----|--------|---------|
|
||||
| cmd/summer/runtime.go | internal/argmask | `argmask.Join(argv)` | WIRED | the only argv rendering in runAppCmd |
|
||||
| modules/bonfire/errors.go | internal/argmask | `argmask.Join` / `argmask.Quote` | WIRED | the old local helpers were deleted |
|
||||
| lagoon Migrate/Status/PendingMigrations | migrationSets | `migrationSets(gdb, plugins)` | WIRED | 3 call sites |
|
||||
| modules/bonfire/live.go | helpers.go Process | ctx → runLive → watchContext; printAbove `case <-v.done` | WIRED | |
|
||||
| helpers.go | Sanitize | `Sanitize(item)` | WIRED | |
|
||||
|
||||
### Behavioral Spot-Checks
|
||||
|
||||
| Behavior | Command | Result | Status |
|
||||
|----------|---------|--------|--------|
|
||||
| RED/GREEN replay per finding (WR-14, IN-15, WR-09, WR-11, WR-10) | `git archive <fix>^` + overlay the fix commit's test file(s), `go test -run <test>`; then the same at `<fix>` | all red before the fix, all green after | ✓ PASS |
|
||||
| RED/GREEN replay WR-12, WR-13 (Docker) | same, with `TESTCONTAINERS_HOST_OVERRIDE=127.0.0.1` | red before the fix (framework pending missing; exit 1), green after | ✓ PASS |
|
||||
| bonfire + diary under race | `go test -count=3 -race ./modules/bonfire/ ./modules/diary/` | ok | ✓ PASS |
|
||||
| live-view set stress | `go test -count=10 -race -run 'TestPrintAboveAfterCancel\|TestNestedProcessBuildsNoProgram\|TestPrintAboveOrderedNoLoss\|TestLiveViewSingleOwner\|TestLiveViewPanicUnregisters\|TestLiveViewRestoresTerminalOnSignal'` | ok (49s) | ✓ PASS |
|
||||
| printAbove ended-program branch | throwaway probe test in a scratch copy (not committed) | PASS | ✓ PASS |
|
||||
| hello workspace | `go -C examples/hello test -count=1 ./...` | ok | ✓ PASS |
|
||||
| Downstream consumer (fonoteka parity) | copies of fonoteka.go against summercms.go at a28957f and at a48afbd | TestRollbackIsolatesFonotekaFullSchema passes at base and fails at head; the other two Status tests already fail at base (user plugin migration count 13 vs 11, unrelated drift) and fail earlier at head | ✗ regression in a sibling repo (see truth 12) |
|
||||
|
||||
### Probe Execution
|
||||
|
||||
| Probe | Command | Result | Status |
|
||||
|-------|---------|--------|--------|
|
||||
| `scripts/check-phase19.8.sh self-test` | `bash scripts/check-phase19.8.sh self-test` | exit 0 | PASS |
|
||||
| `scripts/check-phase19.8.sh go` | `bash scripts/check-phase19.8.sh go` | exit 0 | PASS |
|
||||
| `scripts/check-phase19.8.sh docs` | `bash scripts/check-phase19.8.sh docs` | exit 0 | PASS |
|
||||
| `scripts/check-phase19.8.sh docker` | with `TESTCONTAINERS_HOST_OVERRIDE=127.0.0.1` | exit 0 | PASS |
|
||||
|
||||
### Requirements Coverage
|
||||
|
||||
| Requirement | Source Plan | Status | Evidence |
|
||||
|-------------|-------------|--------|----------|
|
||||
| WR-09 | 261022-4z8-PLAN | ✓ SATISFIED | truth 1 |
|
||||
| WR-10 | 261022-4z8-PLAN | ✓ SATISFIED | truth 6 |
|
||||
| WR-11 | 261022-4z8-PLAN | ✓ SATISFIED | truth 5 |
|
||||
| WR-12 | 261022-4z8-PLAN | ✓ SATISFIED (consumer impact: truth 12) | truth 7 |
|
||||
| WR-13 | 261022-4z8-PLAN | ✓ SATISFIED | truth 8 |
|
||||
| WR-14 | 261022-4z8-PLAN | ✓ SATISFIED | truth 2 |
|
||||
| IN-14 | 261022-4z8-PLAN | ✓ SATISFIED | truth 4 |
|
||||
| IN-15 | 261022-4z8-PLAN | ✓ SATISFIED | truth 3 |
|
||||
| 4z8-docs | 261022-4z8-PLAN | ✓ SATISFIED | truth 10 |
|
||||
|
||||
### Anti-Patterns Found
|
||||
|
||||
| File | Line | Pattern | Severity | Impact |
|
||||
|------|------|---------|----------|--------|
|
||||
| (added lines in a28957f..a48afbd) | none | TBD/FIXME/XXX/TODO/HACK | none | no debt markers added |
|
||||
| cmd/summer/runtime.go, cmd/summer/runtime_test.go | 27, 81 | gofmt drift | ℹ️ Info | already present at a28957f (checked); not introduced by this task |
|
||||
| modules/bonfire/live.go | printAbove `case <-v.done` | branch not pinned by a committed test | ℹ️ Info | behaviour confirmed by a verifier probe; a small committed test would pin it |
|
||||
| modules/lagoon/migrations.go | Status | consumer-visible result change with no upgrade note | ⚠️ Warning | P-09 deliberately skipped docs/setup/upgrading.md; the SUMMARY's release-note input mentions it, but the false "no outside consumer" premise means fonoteka.go needs a follow-up |
|
||||
|
||||
### Human Verification Required
|
||||
|
||||
### 1. Accept or reject the lagoon.Status contract change
|
||||
|
||||
**Test:** Decide whether WR-12's change to `lagoon.Status` (framework sets first; a plugin with zero migrations no longer gets a row) is acceptable under "no exported API breaks".
|
||||
**Expected:** If you accept it, update `fonoteka.go/parity` (`withoutSharedPlugins` should also drop the `summercms.*` rows, or the index assertions should change) and note the behaviour change in the next release notes and upgrade docs. If you reject it, keep `Status` plugin-only and add the framework sets inside the `migrate:status` command instead.
|
||||
**Why human:** This is a policy call. The Go signature is unchanged. But a sibling consumer (fonoteka.go, `replace => ../summercms.go`) calls `lagoon.Status` and now fails `TestRollbackIsolatesFonotekaFullSchema`, which passes against the pre-task framework. The plan's statement that no outside consumer exists was wrong.
|
||||
|
||||
### Gaps Summary
|
||||
|
||||
No code gap. All eight findings are fixed as the review asked. Each has a pinning test that was independently proven red on the parent commit and green on the fix. The shared masking is in one place (`internal/argmask`). `go vet`, the full Docker suite, the race runs, the hello workspace and the four phase-gate stages all pass. The records are updated, and the commits are local, untagged and without trailers.
|
||||
|
||||
One item needs a human decision. The WR-12 fix changes what `lagoon.Status` returns. That is a behavioural, not compile-level, change to an exported function, and it breaks one previously passing test in the sibling consumer `fonoteka.go/parity`, contrary to the plan's premise. Note also that fonoteka's other two Status-based parity tests were already failing before this task (user plugin migration count drift), so fonoteka's parity suite was not green either way.
|
||||
|
||||
---
|
||||
|
||||
_Verified: 2026-10-22T04:30:00Z_
|
||||
_Verifier: Claude (msd-verifier)_
|
||||
|
||||
## Resolution of the human_needed item (orchestrator, 2026-10-22)
|
||||
|
||||
The open item was the WR-12 behaviour change to the exported `lagoon.Status`, which broke
|
||||
`fonoteka.go/parity` `TestRollbackIsolatesFonotekaFullSchema`. Decision (no consumer breaks):
|
||||
keep `lagoon.Status` plugin-only as before 57a849d; `migrate:status` builds its rows from the
|
||||
shared `migrationSets` through the unexported `setStatus`. Follow-up commits `139f5e3` (fix,
|
||||
`TestStatusPluginRows` pins `Status`, `TestMigrateStatusJSON` still red without the fix) and
|
||||
`b1fba7e` (disposition). Full Docker `go test ./...` green; the fonoteka test passes again. The two
|
||||
other fonoteka `Status` tests fail as before this task (they expect 11 user-plugin migrations, the
|
||||
plugin now has 13) — unrelated, pre-existing.
|
||||
@@ -1,9 +1,9 @@
|
||||
---
|
||||
created: 2026-10-22T00:43:48.000Z
|
||||
title: Console review info findings (IN-01..IN-09)
|
||||
title: Console review info findings (IN-01..IN-13) and the delegate signal exit code
|
||||
area: framework
|
||||
severity: minor
|
||||
g15office: pending
|
||||
g15office: rwb3fd9p
|
||||
files:
|
||||
- modules/diary/console.go
|
||||
- modules/diary/diary.go
|
||||
@@ -19,11 +19,14 @@ files:
|
||||
- modules/bouncer/turnstile.go
|
||||
- modules/wristband/oidc_token.go
|
||||
- scripts/check-phase19.8.sh
|
||||
- modules/bonfire/live.go
|
||||
- modules/bonfire/output.go
|
||||
- cmd/summer/runtime.go
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
The 19.8 code review (`.planning/phases/19.8-console-commands-overhaul/19.8-REVIEW.md`, v0.3.1..v0.4.1) found 3 critical issues, 8 warnings and 9 info findings. Plan 19.8-05 fixed the critical issues and the warnings. The nine info findings below are deferred to this todo. Line numbers are the review's; locate the code by symbol.
|
||||
The 19.8 code review (`.planning/phases/19.8-console-commands-overhaul/19.8-REVIEW.md`, v0.3.1..v0.4.1) found 3 critical issues, 8 warnings and 9 info findings. Plan 19.8-05 fixed the critical issues and the warnings. The nine info findings below are deferred to this todo. Line numbers are the review's; locate the code by symbol. The re-review of those fixes (same file, gap-closure range dd12382..0ebed68) found six warnings and six info findings; quick task 261022-4z8 fixed the warnings, IN-14 and IN-15, and deferred IN-10..IN-13 and WR-10's exit-code suggestion to this todo (the sections after IN-09).
|
||||
|
||||
## IN-01: A forced-color console drops HideOnTTY records even when stderr is not a terminal
|
||||
|
||||
@@ -96,3 +99,46 @@ The 19.8 code review (`.planning/phases/19.8-console-commands-overhaul/19.8-REVI
|
||||
**Problem:** The script hard-codes `sm-demo-app`, `sm-bm-app`, `sm-grzybyfunkcjonalne-app` and `sm-summercmsio-app`, and splits a consumer's name into two strings to get past the name check. CLAUDE.md says framework code never names a consuming application. Earlier phase gates do the same, but this framework repository is published.
|
||||
|
||||
**Suggested fix:** Read the sibling paths from environment variables only, with no defaults, or move the cross-repository stages to a gate outside the framework repository.
|
||||
|
||||
## IN-10: console.Error during a live view writes straight to the terminal and tears the view
|
||||
|
||||
**File:** `modules/bonfire/output.go` (`console.Error`, `emit`)
|
||||
|
||||
**Problem:** `Error` writes to `c.err`, and `emit` reroutes only writes to `c.out`. When stderr and stdout are the same terminal (the usual case), an `out.Error(...)` from inside `Spinner`, `Progress` or `Process` lands mid-frame. A pty run showed the spinner glyph left on the error line (`⠹ ✗ stderr-error-line`), with the renderer continuing on the next row. diary's console records on stderr do go above the view through `PrintAbove`, so the two stderr paths behave differently.
|
||||
|
||||
**Suggested fix:** In `emit`, also reroute `w == c.err` when the console owns the running view and `c.err` is a terminal (`printAbove(c, s)`).
|
||||
|
||||
## IN-11: A blank line printed during a live view is dropped
|
||||
|
||||
**File:** `modules/bonfire/live.go` (`printAbove`, the `strings.TrimSuffix` of the block)
|
||||
|
||||
**Problem:** `out.Println()` gives `"\n"`. `TrimSuffix` turns that into `""`, and bubbletea's `insertAbove` returns early for an empty string (cursed_renderer.go in bubbletea v2.1.0). `printAbove` still reports `true`, so the blank separator line is lost. It is printed when no view runs.
|
||||
|
||||
**Suggested fix:** Send `" "` (or `"\u200b"`) for an empty block, or skip the trim when the block is only a newline.
|
||||
|
||||
## IN-12: Signal relay edge cases: a signal during view start-up, and Windows
|
||||
|
||||
**File:** `modules/bonfire/live.go` (`runLive`, `watchSignals`, `raise`)
|
||||
|
||||
**Problem:** This comes from reading bubbletea v2.1.0; the reviewer did not reproduce it.
|
||||
|
||||
1. **Start-up race.** `prog.Run` starts on its own goroutine just before the relay is registered. If a signal arrives before `Run` has created and started its renderer, `Kill` runs `shutdown(true)` while `p.renderer` is still nil. `shutdownOnce` then makes `Run`'s own `shutdown(killed)` a no-op after it has started the renderer and hidden the cursor, so the cursor stays hidden.
|
||||
2. **Windows.** On Windows, `p.Signal(os.Interrupt)` fails, so `raise` calls `os.Exit(130)`. That skips the command's own handler (for example, `summer dev` stopping its serve child) and its deferred cleanup.
|
||||
|
||||
**Suggested fix:** Before killing, wait until the program reports it has started (for example, have the wrapped model's `Init` close a `ready` channel). On platforms where `raise` cannot deliver the signal, return without exiting when another handler is registered.
|
||||
|
||||
## IN-13: A delegate's stderr always goes to os.Stderr, bypassing the root's error writer
|
||||
|
||||
**File:** `cmd/summer/runtime.go` (`appStreams`)
|
||||
|
||||
**Problem:** Off a terminal, the child's stdout goes through `out`, but its stderr goes to the real `os.Stderr`, not to the error stream of the root that ran the command. For a summer root built with `NewRootIO(…, errW)` (tests, embedding), the app binary's error block cannot be captured. `TestDelegateExitCodePassthrough` therefore cannot check that the binary's own block was printed.
|
||||
|
||||
**Suggested fix:** Pass the root's error writer through (for example, a `bonfire.ErrWriter(out)` accessor), defaulting to `os.Stderr`.
|
||||
|
||||
## WR-10 follow-up: exit code 128+n for a delegate killed by a signal
|
||||
|
||||
**File:** `cmd/summer/runtime.go` (`runAppCmd`)
|
||||
|
||||
**Problem:** Plan 19.8-05 specified exit code 1 for an app binary that dies by a signal, and quick task 261022-4z8 kept it while masking the argv in summer's error block (WR-10). The re-review points out that Ctrl+C is the normal way to stop `summer serve` or `summer queue:work`: since the WR-01 relay re-raises SIGINT, a child that dies by the signal produces an ERROR block and exit 1, where shells and CI expect 130 (128+SIGINT).
|
||||
|
||||
**Suggested fix:** Map signal deaths to 128+n: treat SIGINT and SIGTERM as already reported (`bonfire.Reported(bonfire.WithExitCode(err, 128+n))`, no block), and give other signals exit 128+n with summer's masked block (`argmask.Join(argv)`). `docs/console/introduction.md` documents exit code 1 for a signal death and must change with it, as must `TestDelegateExitCodePassthrough`'s signal subtests.
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
---
|
||||
created: 2026-10-22T03:30:00.000Z
|
||||
title: User plugin logout leaves the session cookie valid for up to 60 minutes
|
||||
area: user-plugin
|
||||
severity: major
|
||||
g15office: ttf7tinw
|
||||
files:
|
||||
- ../sm-user-plugin (mounted at plugins/golem15/user in the apps)
|
||||
- controllers/api_controller.go
|
||||
- classes/session_user.go
|
||||
- routes.go
|
||||
---
|
||||
|
||||
## Problem
|
||||
|
||||
Reported by the user: after logout the auth cookie is not invalidated at once; requests keep
|
||||
authenticating until the token expires about 60 minutes later. If true, a logout on a shared
|
||||
machine does not end the session, and a stolen cookie survives the victim's logout.
|
||||
|
||||
## To check
|
||||
|
||||
- What logout does: clear the cookie only, or also revoke the server-side session / refresh
|
||||
token, and whether the access JWT is checked against a revocation list (bouncer) or only by
|
||||
signature and `exp`.
|
||||
- Whether the 60 minutes is the access-token TTL.
|
||||
- Parity with the PHP (WinterCMS) user plugin's logout behaviour; the frontend contract stays.
|
||||
|
||||
## Fix direction
|
||||
|
||||
Logout revokes the server-side session so the access token is refused on the next request
|
||||
(same mechanism c3712dc uses for deactivate/ban), clears the cookies with matching
|
||||
attributes, and a test pins "request after logout is 401". No breaking change to the plugin
|
||||
API (core plugin, many projects).
|
||||
|
||||
## Outcome (2026-10-22)
|
||||
|
||||
Confirmed and fixed locally; closes when released. Debug session:
|
||||
`.planning/debug/user-logout-session-stays-valid.md`.
|
||||
|
||||
- Plugin root cause: `Logout` read the token from the `Authorization` header only, so a
|
||||
cookie-only logout (sociallogin `token_cookie: true`) answered 401 and revoked nothing, and the
|
||||
cookie kept working for the 60-minute access TTL; logout cleared no cookie; the blacklist row
|
||||
lived only until `exp`, so a logged-out token could be refreshed again once it was swept.
|
||||
Fix: sm-user-plugin `358ee5d` (new `classes.EndSession`, blacklist until the end of the refresh
|
||||
window, forget cookies with the configured attributes, `TestLogoutEndsTheSession`).
|
||||
- Demo frontend root cause: `useDemoAuth().logout()` only cleared its own cookie and never called
|
||||
`/logout`. Fix: vue-demo-app `f397df2`.
|
||||
- Ships in sm-user-plugin v0.1.8 (with `aa83969`, needs SummerCMS v0.4.3) and the sm-demo-app
|
||||
pointer bumps. Hand check after deploy: cookie-only logout, then a `jwt.auth` route with the old
|
||||
cookie answers 401.
|
||||
@@ -9,9 +9,9 @@ import (
|
||||
"os/exec"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"syscall"
|
||||
|
||||
"git.golem15.com/golem15/summercms/internal/argmask"
|
||||
"git.golem15.com/golem15/summercms/internal/build"
|
||||
"git.golem15.com/golem15/summercms/internal/dev"
|
||||
"git.golem15.com/golem15/summercms/modules/bonfire"
|
||||
@@ -161,13 +161,17 @@ func appStreams(out bonfire.Output, terminal bool) (stdout, stderr io.Writer) {
|
||||
|
||||
// runAppCmd runs cmd, the app binary started with argv, forwarding SIGINT
|
||||
// and SIGTERM to it. When the binary exits with a code, the error carries
|
||||
// that code (bonfire.WithExitCode) and is marked as already reported.
|
||||
// that code (bonfire.WithExitCode) and is marked as already reported. The
|
||||
// error names the command through argmask.Join, with the values of
|
||||
// secret-looking flags masked, on every branch: a binary that fails to
|
||||
// start or is ended by a signal gets summer's error block, and a signal
|
||||
// death keeps exit code 1.
|
||||
func runAppCmd(cmd *exec.Cmd, argv []string) error {
|
||||
sigs := make(chan os.Signal, 2)
|
||||
signal.Notify(sigs, os.Interrupt, syscall.SIGTERM)
|
||||
defer signal.Stop(sigs)
|
||||
if err := runChild(cmd, sigs); err != nil {
|
||||
err = fmt.Errorf("summer: %s: %w", strings.Join(argv, " "), err)
|
||||
err = fmt.Errorf("summer: %s: %w", argmask.Join(argv), err)
|
||||
var exit *exec.ExitError
|
||||
if errors.As(err, &exit) && exit.Exited() {
|
||||
// The binary printed its own error; summer exits with its
|
||||
|
||||
@@ -321,14 +321,15 @@ func exitHelperRunner(t *testing.T, mode string) {
|
||||
t.Cleanup(func() { appRunner = prev })
|
||||
}
|
||||
|
||||
// TestDelegateExitCodePassthrough (CR-02): summer exits with the app
|
||||
// binary's exit code and prints no error block of its own; a binary killed
|
||||
// by a signal gets summer's error block and exit 1.
|
||||
// TestDelegateExitCodePassthrough (CR-02, WR-10): summer exits with the
|
||||
// app binary's exit code and prints no error block of its own; a binary
|
||||
// killed by a signal gets summer's error block, with secret flag values
|
||||
// masked, and exit 1.
|
||||
func TestDelegateExitCodePassthrough(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("SIGKILL is POSIX-only")
|
||||
}
|
||||
run := func(t *testing.T, mode string) (string, string, int) {
|
||||
run := func(t *testing.T, mode string, args ...string) (string, string, int) {
|
||||
t.Helper()
|
||||
exitHelperRunner(t, mode)
|
||||
var out, errW syncBuffer
|
||||
@@ -336,12 +337,12 @@ func TestDelegateExitCodePassthrough(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
code := bonfire.Execute(context.Background(), root, []string{"migrate", "--bogus"})
|
||||
code := bonfire.Execute(context.Background(), root, args)
|
||||
return out.String(), errW.String(), code
|
||||
}
|
||||
|
||||
t.Run("exit code", func(t *testing.T) {
|
||||
out, errOut, code := run(t, "exit2")
|
||||
out, errOut, code := run(t, "exit2", "migrate", "--bogus")
|
||||
if code != 2 {
|
||||
t.Errorf("exit %d, want the binary's 2", code)
|
||||
}
|
||||
@@ -354,7 +355,7 @@ func TestDelegateExitCodePassthrough(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("killed by a signal", func(t *testing.T) {
|
||||
out, errOut, code := run(t, "sigkill")
|
||||
out, errOut, code := run(t, "sigkill", "migrate", "--bogus")
|
||||
if code != bonfire.ExitFailure {
|
||||
t.Errorf("exit %d, want 1", code)
|
||||
}
|
||||
@@ -362,6 +363,60 @@ func TestDelegateExitCodePassthrough(t *testing.T) {
|
||||
t.Errorf("want one error block naming the signal, got %d:\nstdout %q\nstderr %q", n, out, errOut)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("killed by a signal masks secrets", func(t *testing.T) {
|
||||
out, errOut, code := run(t, "sigkill", "migrate", "--password", "hunter2")
|
||||
if code != bonfire.ExitFailure {
|
||||
t.Errorf("exit %d, want 1", code)
|
||||
}
|
||||
if n := strings.Count(errOut, " ERROR "); n != 1 || !strings.Contains(errOut, "--password ***") {
|
||||
t.Errorf("want one error block with the password masked, got %d:\nstdout %q\nstderr %q", n, out, errOut)
|
||||
}
|
||||
if strings.Contains(out+errOut, "hunter2") {
|
||||
t.Errorf("the password reached the output:\nstdout %q\nstderr %q", out, errOut)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestRunAppCmdMasksSecrets (WR-10): the error of an app binary killed by a
|
||||
// signal or failing to start names the command with the values of
|
||||
// secret-named flags masked, as the wrong-directory Try line does.
|
||||
func TestRunAppCmdMasksSecrets(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("SIGKILL is POSIX-only")
|
||||
}
|
||||
argv := []string{"admin:create", "--email", "a@b.c", "--password", "hunter2", "--api-token=t0k3n", "--client-secret", "-Xk9q"}
|
||||
check := func(t *testing.T, err error, want ...string) {
|
||||
t.Helper()
|
||||
if err == nil {
|
||||
t.Fatal("runAppCmd returned nil")
|
||||
}
|
||||
if errors.Is(err, bonfire.ErrReported) {
|
||||
t.Errorf("error is marked as reported: %v", err)
|
||||
}
|
||||
msg := err.Error()
|
||||
for _, w := range append(want, "--password ***", "--api-token=***", "--client-secret ***") {
|
||||
if !strings.Contains(msg, w) {
|
||||
t.Errorf("error lacks %q: %s", w, msg)
|
||||
}
|
||||
}
|
||||
for _, secret := range []string{"hunter2", "t0k3n", "-Xk9q"} {
|
||||
if strings.Contains(msg, secret) {
|
||||
t.Errorf("error repeats %q: %s", secret, msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
t.Run("killed by a signal", func(t *testing.T) {
|
||||
cmd := newAppCmd(context.Background(), os.Args[0], ".", []string{"-test.run=^TestExitHelperProcess$"}, io.Discard, io.Discard)
|
||||
cmd.Env = append(cmd.Env, "SUMMER_EXIT_HELPER=sigkill")
|
||||
check(t, runAppCmd(cmd, argv), "signal: killed")
|
||||
})
|
||||
|
||||
t.Run("start failure", func(t *testing.T) {
|
||||
cmd := newAppCmd(context.Background(), filepath.Join(t.TempDir(), "missing"), ".", argv, io.Discard, io.Discard)
|
||||
check(t, runAppCmd(cmd, argv))
|
||||
})
|
||||
}
|
||||
|
||||
// TestDelegateStreams (CR-03): off a terminal the app binary's stdout goes
|
||||
|
||||
@@ -30,7 +30,8 @@ Some column names are never written from a request body, whatever the form decla
|
||||
```go src=modules/cabana/example_form_seams_test.go#MembersController.AdminFieldRelations
|
||||
// AdminFieldRelations binds the form's two relation fields. organisation_id
|
||||
// is a protected column, so the team field would be read-only; the contract
|
||||
// opts in to writing it through this field.
|
||||
// opts in to writing it through this field. A save that moves the member to
|
||||
// another team (or to none) clears team_role, unless it writes a new one.
|
||||
func (MembersController) AdminFieldRelations() []cabana.FieldRelationContract {
|
||||
return []cabana.FieldRelationContract{{
|
||||
Field: "team",
|
||||
@@ -38,6 +39,7 @@ func (MembersController) AdminFieldRelations() []cabana.FieldRelationContract {
|
||||
NewRelated: func() any { return &Team{} },
|
||||
ForeignKey: "organisation_id",
|
||||
WritableForeignKey: true,
|
||||
ClearOnChange: []string{"team_role"},
|
||||
}, {
|
||||
Field: "groups",
|
||||
Kind: "belongsToMany",
|
||||
@@ -51,6 +53,12 @@ func (MembersController) AdminFieldRelations() []cabana.FieldRelationContract {
|
||||
|
||||
The protection itself does not change. A plain field named `organisation_id` is still dropped from a body, and the id submitted through the relation field is still rechecked against the scoped options query, so only a record the controller offers can be set. `WritableForeignKey` on a belongsToMany contract stops the start-up.
|
||||
|
||||
### Clearing dependent columns
|
||||
|
||||
Some columns only make sense for one parent: a member's role in an organisation means nothing in the next organisation. `ClearOnChange` on a belongsTo contract names such columns of the record; a save that changes the field's foreign key, to another record or to none, sets them to NULL, so the role never moves along with the member. The team contract above clears `team_role` this way.
|
||||
|
||||
A save that keeps the key leaves the columns alone, and so does a save that writes a column through its own form field: an administrator who moves a member and picks the new role in the same save keeps that role. Each column must be a nullable column of the record (a pointer field), and neither the foreign key nor the primary key; anything else, or `ClearOnChange` on a belongsToMany contract, stops the start-up. The zero value changes nothing. A hasMany relation manager has the same option on `cabana.RelationContract` (see [Relation kinds](#relation-kinds)).
|
||||
|
||||
### Locked options
|
||||
|
||||
A controller may let an administrator see a choice without letting them change it, for example membership of a group that grants access. It implements `cabana.RelationLockProvider` and returns, per field and per request, a `cabana.RelationLock`: the related ids that are locked for the signed-in administrator, and a message.
|
||||
@@ -132,6 +140,8 @@ func (PostsController) AdminRelationContracts() []cabana.RelationContract {
|
||||
|
||||
A hasMany `ForeignKey` of a pointer type (`*uint`, a nullable column) is needed for `unlink` and for managing the relation before the parent is saved.
|
||||
|
||||
A hasMany contract may also name `ClearOnChange` columns of the related model: nullable columns that a link and an unlink set to NULL together with the `ForeignKey`, deferred links and unlinks included. With an organisation's `members` relation and `ClearOnChange: []string{"organisation_role"}`, a user added to or removed from the organisation never keeps a role from elsewhere. The rules are those of the belongsTo option: a pointer field, neither the `ForeignKey` nor the primary key, and only on a hasMany; the zero value changes nothing.
|
||||
|
||||
### Relation forms
|
||||
|
||||
The relation manager creates and edits related records in a modal through a form of their own. `manage.form` names the form used to create and update a child, `view.form` the read-only preview; a top-level `form` is the fallback of both, as in WinterCMS:
|
||||
@@ -187,7 +197,7 @@ The `manage` panel may declare only `link`. An unknown or duplicate button stops
|
||||
|
||||
What delete, link and unlink do depends on the kind:
|
||||
|
||||
- On a hasMany, `delete` deletes the child through its model, so its hooks and soft delete run. `link` adopts records whose `ForeignKey` is NULL, setting it to the parent's key through the child model; `unlink` sets it back to NULL.
|
||||
- On a hasMany, `delete` deletes the child through its model, so its hooks and soft delete run. `link` adopts records whose `ForeignKey` is NULL, setting it to the parent's key through the child model; `unlink` sets it back to NULL. Both also set the contract's `ClearOnChange` columns to NULL.
|
||||
- On a belongsToMany, `delete` removes this parent's pivot row and then deletes the related record through its model. `link` writes pivot rows and `unlink` deletes them, as before.
|
||||
|
||||
Every child route is scoped to its parent. The parent is loaded through `pact.FormExtendQuery`, and the child must belong to it: on a hasMany by its `ForeignKey`, on a belongsToMany by a pivot row. A child of another parent, or any child of a parent the administrator cannot open, answers 404 `not_found`, never 403, so a request cannot tell a foreign record from a missing one. A delete is all or nothing: when one of the ids is not a child of the parent, nothing is deleted.
|
||||
|
||||
@@ -143,7 +143,7 @@ summer admin:token:create jane --scope journal.posts --json
|
||||
|
||||
The binary writes to your terminal directly, so it asks the production question, prints its styled startup summary and animates its spinners as if you had run it yourself. Its error stream stays separate from its output: `summer route:list --json > routes.json` captures only the JSON, while warnings and log lines still reach the terminal on stderr.
|
||||
|
||||
When the binary fails, `summer` exits with the binary's exit code (a usage error stays `2`) and prints nothing more: the binary has already printed its error. A binary killed by a signal gets an error block from `summer` and exit code `1`.
|
||||
When the binary fails, `summer` exits with the binary's exit code (a usage error stays `2`) and prints nothing more: the binary has already printed its error. A binary killed by a signal gets an error block from `summer` and exit code `1`; the block names the command with the values of secret-looking flags (`--password`, `--api-token=...`) masked as `***`.
|
||||
|
||||
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.
|
||||
|
||||
|
||||
@@ -12,9 +12,9 @@ Every application binary carries the framework's runtime commands. The examples
|
||||
|
||||
| Command | Flags | Purpose |
|
||||
|---------|-------|---------|
|
||||
| `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` | `--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`). If the history cannot be re-read after a successful run, it warns and counts every pending migration as applied. `--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`. |
|
||||
| `migrate:status` | `--json` | Prints a table of the framework's own migration sets and every plugin with migrations, with history table and applied migration IDs. `--json` prints every declared migration of those sets with `plugin`, `table`, `id` and `applied`. |
|
||||
|
||||
```sh
|
||||
./bin/acme migrate --pretend
|
||||
|
||||
@@ -74,7 +74,7 @@ Print the data your command exists to produce (a token, a key, JSON) with `bonfi
|
||||
|
||||
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`).
|
||||
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`). When the context given to `bonfire.Process` is cancelled, the view ends with its last frame and later lines print plainly below it.
|
||||
|
||||
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).
|
||||
|
||||
@@ -202,7 +202,7 @@ An application binary also explains the most common mistake: running it from the
|
||||
cd ../..; bin/acme migrate
|
||||
```
|
||||
|
||||
The suggested command repeats your arguments, except that the value of a flag whose name contains `password`, `passwd`, `secret`, `token` or `key` is masked as `***` (`--password ***`, `--api-token=***`), so a secret typed on the command line does not reach logs that capture stderr. 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.
|
||||
The suggested command repeats your arguments, except that the value of a flag whose name contains `password`, `passwd`, `secret`, `token` or `key` is masked as `***` (`--password ***` (also a value that starts with `-`), `--api-token=***`), so a secret typed on the command line does not reach logs that capture stderr. 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
|
||||
|
||||
|
||||
@@ -81,7 +81,7 @@ The application binary has the migration commands:
|
||||
|
||||
`migrate:rollback` rolls back the last applied migration of one plugin. Without `--plugin` it picks the last activated plugin that has migrations. To fix a migration you just wrote, roll it back, edit it and run `migrate` again.
|
||||
|
||||
`migrate:status` prints each plugin's history table and applied IDs.
|
||||
`migrate:status` prints the history table and applied IDs of the framework's own sets (under history IDs such as `summercms.attach`) and of each plugin with migrations.
|
||||
|
||||
The same operations are Go functions, which is how tests migrate a fresh database:
|
||||
|
||||
@@ -104,6 +104,8 @@ if err := lagoon.RollbackLast(db, plugins, "acme.blog"); err != nil {
|
||||
|
||||
`lagoon.Migrate`, `lagoon.Status` and `lagoon.RollbackLast` take the activated plugins; a plugin that does not implement `pact.HasMigrations` is skipped.
|
||||
|
||||
`lagoon.Status` returns one row per plugin, a plugin with no migrations included, and leaves out the framework's own sets that `migrate:status` prints.
|
||||
|
||||
## Porting version.yaml
|
||||
|
||||
WinterCMS runs a plugin's update scripts by version number and seeds data from the same files. When you port a plugin:
|
||||
|
||||
63
internal/argmask/argmask.go
Normal file
63
internal/argmask/argmask.go
Normal file
@@ -0,0 +1,63 @@
|
||||
// Package argmask renders a command line for messages with the values of
|
||||
// secret-named long flags masked; bonfire's wrong-directory hint and
|
||||
// summer's delegate errors share it.
|
||||
package argmask
|
||||
|
||||
import "strings"
|
||||
|
||||
// secretWords mark a long flag whose value is masked.
|
||||
var secretWords = []string{"password", "passwd", "secret", "token", "key"}
|
||||
|
||||
// secretFlag reports whether the long flag name (without "--") looks like it
|
||||
// carries a secret.
|
||||
func secretFlag(name string) bool {
|
||||
name = strings.ToLower(name)
|
||||
for _, w := range secretWords {
|
||||
if strings.Contains(name, w) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Join renders args as one line, separated by single spaces and each
|
||||
// quoted for a shell (Quote). The value of a long flag named like a secret
|
||||
// becomes ***, in the "--flag=value" form and in the "--flag value" form,
|
||||
// so a password typed on the command line does not reach stderr and CI
|
||||
// logs. In the "--flag value" form the next argument is masked whatever its
|
||||
// first character (pflag takes "-Xk9" as the value too); only "--" is left
|
||||
// alone. Join cannot see flag types, so a secret-named boolean flag
|
||||
// over-masks the argument after it, which is the safe side.
|
||||
func Join(args []string) string {
|
||||
var b strings.Builder
|
||||
for i := 0; i < len(args); i++ {
|
||||
if i > 0 {
|
||||
b.WriteByte(' ')
|
||||
}
|
||||
a := args[i]
|
||||
name, long := strings.CutPrefix(a, "--")
|
||||
flag, _, inline := strings.Cut(name, "=")
|
||||
if !long || flag == "" || !secretFlag(flag) {
|
||||
b.WriteString(Quote(a))
|
||||
continue
|
||||
}
|
||||
if inline {
|
||||
b.WriteString(Quote("--"+flag) + "=***")
|
||||
continue
|
||||
}
|
||||
b.WriteString(Quote(a))
|
||||
if i+1 < len(args) && args[i+1] != "--" {
|
||||
b.WriteString(" ***")
|
||||
i++
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// Quote single-quotes s when a shell would split or expand it.
|
||||
func Quote(s string) string {
|
||||
if s != "" && !strings.ContainsAny(s, " \t\n'\"\\$`*?[]{}()<>|&;#~!") {
|
||||
return s
|
||||
}
|
||||
return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"
|
||||
}
|
||||
@@ -27,7 +27,7 @@ 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 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: Ctrl+C or SIGTERM during a view restores the cursor and the terminal, then stops the command as before (or reaches its own signal handler). Log lines, and output the command prints through the console that started the view, appear above a running view (`bonfire.PrintAbove`); other consoles (a `bonfire.Call` buffer, a console on a file) keep writing to their own writers. Table cells, process-view item names and errors, and failure lines are stripped of control characters (`bonfire.Sanitize`).
|
||||
- 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: Ctrl+C or SIGTERM during a view restores the cursor and the terminal, then stops the command as before (or reaches its own signal handler). Log lines, and output the command prints through the console that started the view, appear above a running view (`bonfire.PrintAbove`); other consoles (a `bonfire.Call` buffer, a console on a file) keep writing to their own writers. When the context given to `bonfire.Process` is cancelled, the view ends with its last frame and later lines print plainly below it. Table cells, the item names and errors of `bonfire.Process` (in the process view and in its plain lines), the process view's operation label, and failure lines are stripped of control characters (`bonfire.Sanitize`).
|
||||
- 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:
|
||||
|
||||
@@ -46,7 +46,7 @@ Use cases:
|
||||
- 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` and every line through `bonfire.Sanitize`, which strips control characters. Errors wrapped by `bonfire.Reported` print nothing. An error carrying a code through `bonfire.WithExitCode` exits with that code; summer uses it to exit with the code of an app binary it ran. `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); the values of flags named like a password, secret, token or key are masked as `***` in that line.
|
||||
- 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` and every line through `bonfire.Sanitize`, which strips control characters. Errors wrapped by `bonfire.Reported` print nothing. An error carrying a code through `bonfire.WithExitCode` exits with that code; summer uses it to exit with the code of an app binary it ran. `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); the values of flags named like a password, secret, token or key are masked as `***` in that line, also a value that starts with `-`.
|
||||
- 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.
|
||||
@@ -191,7 +191,7 @@ Without these variables, color is enabled only when stdout is a terminal. `--ans
|
||||
|
||||
## Dependencies
|
||||
|
||||
- SummerCMS modules: `sunscreen` (scrubs error messages).
|
||||
- SummerCMS modules: `sunscreen` (scrubs error messages); the framework-internal package `internal/argmask` (masks secret flag values in the Try line, shared with summer's delegate errors).
|
||||
- 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`.
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"git.golem15.com/golem15/summercms/internal/argmask"
|
||||
"git.golem15.com/golem15/summercms/modules/sunscreen"
|
||||
"github.com/charmbracelet/colorprofile"
|
||||
)
|
||||
@@ -71,7 +72,8 @@ func WithHint(err error, explanation, try string) error {
|
||||
}
|
||||
|
||||
// ErrReported marks an error whose details the command already printed:
|
||||
// the error block prints nothing and the exit code is 1.
|
||||
// the error block prints nothing and the exit code is 1, or the code given
|
||||
// with WithExitCode.
|
||||
var ErrReported = errors.New("bonfire: error already reported")
|
||||
|
||||
type reportedError struct {
|
||||
@@ -283,60 +285,20 @@ func wrongDirectory(name string, err error, args []string, dir string) error {
|
||||
if rerr != nil {
|
||||
return err
|
||||
}
|
||||
try := "cd " + shellQuote(rel) + "; {bin}" + tryArgs(args)
|
||||
try := "cd " + argmask.Quote(rel) + "; {bin}" + tryArgs(args)
|
||||
return &HintError{Err: err, Explanation: wrongDirectoryExplanation, Try: try}
|
||||
}
|
||||
|
||||
// secretWords mark a long flag whose value the Try line masks.
|
||||
var secretWords = []string{"password", "passwd", "secret", "token", "key"}
|
||||
|
||||
// secretFlag reports whether the long flag name (without "--") looks like it
|
||||
// carries a secret.
|
||||
func secretFlag(name string) bool {
|
||||
name = strings.ToLower(name)
|
||||
for _, w := range secretWords {
|
||||
if strings.Contains(name, w) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// tryArgs renders args for the wrong-directory Try line, each preceded by a
|
||||
// space and quoted for a shell. The value of a long flag named like a
|
||||
// secret becomes ***, in the "--flag=value" form and in the "--flag value"
|
||||
// form (the next argument, when it does not start with "-"), so a password
|
||||
// typed on the command line does not reach stderr and CI logs.
|
||||
// tryArgs renders args for the wrong-directory Try line: empty for no
|
||||
// args, otherwise a space and argmask.Join(args), which quotes each
|
||||
// argument for a shell and masks the values of secret-named long flags
|
||||
// (also a value that starts with "-"; only "--" is left alone), so a
|
||||
// password typed on the command line does not reach stderr and CI logs.
|
||||
func tryArgs(args []string) string {
|
||||
var b strings.Builder
|
||||
for i := 0; i < len(args); i++ {
|
||||
a := args[i]
|
||||
b.WriteByte(' ')
|
||||
name, long := strings.CutPrefix(a, "--")
|
||||
flag, _, inline := strings.Cut(name, "=")
|
||||
if !long || flag == "" || !secretFlag(flag) {
|
||||
b.WriteString(shellQuote(a))
|
||||
continue
|
||||
}
|
||||
if inline {
|
||||
b.WriteString(shellQuote("--"+flag) + "=***")
|
||||
continue
|
||||
}
|
||||
b.WriteString(shellQuote(a))
|
||||
if i+1 < len(args) && !strings.HasPrefix(args[i+1], "-") {
|
||||
b.WriteString(" ***")
|
||||
i++
|
||||
}
|
||||
if len(args) == 0 {
|
||||
return ""
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// 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, "'", `'\''`) + "'"
|
||||
return " " + argmask.Join(args)
|
||||
}
|
||||
|
||||
// parseUnknownCommand reads the command name and cobra's "Did you mean
|
||||
|
||||
@@ -219,10 +219,11 @@ func TestWithExitCode(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestWrongDirectoryMasksSecrets (WR-08): the wrong-directory Try line
|
||||
// replaces the value of every flag named like a password, secret, token or
|
||||
// key with ***, in both the "--flag value" and "--flag=value" forms, and
|
||||
// keeps every other argument.
|
||||
// TestWrongDirectoryMasksSecrets (WR-08, WR-11): the wrong-directory Try
|
||||
// line replaces the value of every flag named like a password, secret,
|
||||
// token or key with ***, in both the "--flag value" and "--flag=value"
|
||||
// forms and whatever the value's first character, and keeps every other
|
||||
// argument; only a following "--" is not taken as the value.
|
||||
func TestWrongDirectoryMasksSecrets(t *testing.T) {
|
||||
plainEnv(t)
|
||||
root := t.TempDir()
|
||||
@@ -244,7 +245,12 @@ func TestWrongDirectoryMasksSecrets(t *testing.T) {
|
||||
{[]string{"sync", "--api-token=t0k3n"}, "cd ..; {bin} sync --api-token=***"},
|
||||
{[]string{"sync", "--key", "k3y", "acme blog"}, "cd ..; {bin} sync --key *** 'acme blog'"},
|
||||
{[]string{"sync", "--PASSWD", "hunter2"}, "cd ..; {bin} sync --PASSWD ***"},
|
||||
{[]string{"sync", "--token", "--force"}, "cd ..; {bin} sync --token --force"},
|
||||
// pflag takes the next argument as the value of a non-boolean
|
||||
// flag even when it starts with "-": "--force" is the token here.
|
||||
{[]string{"sync", "--token", "--force"}, "cd ..; {bin} sync --token ***"},
|
||||
{[]string{"admin:create", "--email", "a@b.c", "--password", "-Xk9!q"}, "cd ..; {bin} admin:create --email a@b.c --password ***"},
|
||||
{[]string{"sync", "--client-secret", "-s3cr3t", "--verbose"}, "cd ..; {bin} sync --client-secret *** --verbose"},
|
||||
{[]string{"sync", "--token", "--", "x"}, "cd ..; {bin} sync --token -- x"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
got := wrongDirectory("acme", fail, tc.args, sub)
|
||||
@@ -255,7 +261,7 @@ func TestWrongDirectoryMasksSecrets(t *testing.T) {
|
||||
if hint.Try != tc.want {
|
||||
t.Errorf("%q: Try = %q, want %q", tc.args, hint.Try, tc.want)
|
||||
}
|
||||
for _, secret := range []string{"hunter2", "s3cr3t", "t0k3n", "k3y"} {
|
||||
for _, secret := range []string{"hunter2", "s3cr3t", "t0k3n", "k3y", "-Xk9", "-s3cr3t"} {
|
||||
if strings.Contains(hint.Try, secret) {
|
||||
t.Errorf("%q: Try line repeats %q: %q", tc.args, secret, hint.Try)
|
||||
}
|
||||
|
||||
@@ -180,7 +180,7 @@ func Process(ctx context.Context, out Output, operation string, items []string,
|
||||
out.Error(failureText(item, err))
|
||||
continue
|
||||
}
|
||||
out.Success(item)
|
||||
out.Success(Sanitize(item))
|
||||
}
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
|
||||
@@ -310,7 +310,7 @@ func (m processModel) View() tea.View {
|
||||
if total > 0 {
|
||||
pct = float64(m.done) / float64(total)
|
||||
}
|
||||
op := m.operation
|
||||
op := Sanitize(m.operation)
|
||||
if op == "" {
|
||||
op = "Processing"
|
||||
}
|
||||
@@ -325,6 +325,9 @@ func (m processModel) View() tea.View {
|
||||
type liveView struct {
|
||||
owner *console
|
||||
prog *tea.Program
|
||||
// done is closed when the program's Run has returned; printAbove never
|
||||
// sends to a program whose Run has ended, whatever ended it.
|
||||
done <-chan struct{}
|
||||
}
|
||||
|
||||
// The running live view, for PrintAbove and the owning console's output.
|
||||
@@ -341,37 +344,48 @@ var errLivePanic = errors.New("bonfire: live view work panicked")
|
||||
|
||||
// runLive runs model as a bubbletea program on out while work runs on the
|
||||
// calling goroutine, with c as the owner of the view. It first claims the
|
||||
// single live slot under liveMu; when another view holds it, runLive
|
||||
// returns started false without running work, and the caller prints plain
|
||||
// lines instead. work gets a send function for progress messages. When
|
||||
// single live slot under liveMu and only then builds the program; when
|
||||
// another view holds the slot, runLive returns started false without
|
||||
// building a program or running work, and the caller prints plain lines
|
||||
// instead. work gets a send function for progress messages. When
|
||||
// work returns or panics, the view is unregistered (so PrintAbove stops
|
||||
// sending to it), told to finish and waited for, all under liveMu; it
|
||||
// never receives lines after that, and a panic goes on after the cleanup.
|
||||
//
|
||||
// The program does not take ctx: when ctx is cancelled, a watcher ends
|
||||
// the view with its last frame and unregisters it in the same step under
|
||||
// liveMu, without waiting for work, so lines printed after the
|
||||
// cancellation print plainly below the view instead of reaching a stopped
|
||||
// program. A program whose Run ended on its own is unregistered as well
|
||||
// and is never sent lines.
|
||||
func runLive(ctx context.Context, out io.Writer, c *console, model tea.Model, work func(send func(tea.Msg)) error) (started bool, err error) {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
prog := tea.NewProgram(model,
|
||||
tea.WithInput(nil),
|
||||
tea.WithOutput(out),
|
||||
tea.WithoutSignalHandler(),
|
||||
tea.WithContext(ctx),
|
||||
tea.WithColorProfile(c.profile()),
|
||||
)
|
||||
view := &liveView{owner: c, prog: prog}
|
||||
// Claim the slot before building the program: a widget that loses it
|
||||
// builds none, so it registers no child context on ctx and opens no
|
||||
// TEA_TRACE file. NewProgram takes no bonfire lock and prints nothing.
|
||||
finished := make(chan struct{})
|
||||
liveMu.Lock()
|
||||
if activeView != nil {
|
||||
liveMu.Unlock()
|
||||
return false, nil
|
||||
}
|
||||
prog := tea.NewProgram(model,
|
||||
tea.WithInput(nil),
|
||||
tea.WithOutput(out),
|
||||
tea.WithoutSignalHandler(),
|
||||
tea.WithColorProfile(c.profile()),
|
||||
)
|
||||
view := &liveView{owner: c, prog: prog, done: finished}
|
||||
activeView = view
|
||||
liveMu.Unlock()
|
||||
|
||||
finished := make(chan struct{})
|
||||
go func() {
|
||||
defer close(finished)
|
||||
_, _ = prog.Run()
|
||||
}()
|
||||
stopWatch := watchContext(ctx, view, finished)
|
||||
stopSignals := watchSignals(view, finished)
|
||||
|
||||
completed := false
|
||||
@@ -388,9 +402,12 @@ func runLive(ctx context.Context, out io.Writer, c *console, model tea.Model, wo
|
||||
if activeView == view {
|
||||
activeView = nil
|
||||
}
|
||||
// When the context watcher already finished the program, its Run
|
||||
// has returned: Send returns at once and finished is closed.
|
||||
prog.Send(finishMsg{err: result})
|
||||
<-finished
|
||||
liveMu.Unlock()
|
||||
stopWatch()
|
||||
stopSignals()
|
||||
}()
|
||||
err = work(prog.Send)
|
||||
@@ -398,6 +415,38 @@ func runLive(ctx context.Context, out io.Writer, c *console, model tea.Model, wo
|
||||
return true, err
|
||||
}
|
||||
|
||||
// watchContext ends view when ctx is cancelled before the program
|
||||
// finishes: under liveMu it unregisters the view, sends the finish message
|
||||
// with ctx's error and waits for the program, the same single step as
|
||||
// runLive's cleanup, so a printer either reaches the running program or
|
||||
// waits and prints plainly after the last frame. When the program finishes
|
||||
// first (normal end, a signal, Run failing), the view is only unregistered.
|
||||
// The returned function waits for the watcher; call it after finished is
|
||||
// closed and without holding liveMu.
|
||||
func watchContext(ctx context.Context, view *liveView, finished <-chan struct{}) (wait func()) {
|
||||
exited := make(chan struct{})
|
||||
go func() {
|
||||
defer close(exited)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
liveMu.Lock()
|
||||
if activeView == view {
|
||||
activeView = nil
|
||||
}
|
||||
view.prog.Send(finishMsg{err: ctx.Err()})
|
||||
<-finished
|
||||
liveMu.Unlock()
|
||||
case <-finished:
|
||||
liveMu.Lock()
|
||||
if activeView == view {
|
||||
activeView = nil
|
||||
}
|
||||
liveMu.Unlock()
|
||||
}
|
||||
}()
|
||||
return func() { <-exited }
|
||||
}
|
||||
|
||||
// watchSignals relays SIGINT and SIGTERM (those not ignored) while view
|
||||
// runs. The views run without bubbletea's own signal handling, so without
|
||||
// this a Ctrl+C would end the process with the cursor still hidden. On a
|
||||
@@ -473,15 +522,25 @@ func printAboveLines(s string) bool {
|
||||
}
|
||||
|
||||
// printAbove prints s above the running live view when owner is nil or is
|
||||
// the console that started the view, and reports whether it did.
|
||||
// the console that started the view, and reports whether it did. A view
|
||||
// whose program has ended (its context was cancelled, or Run returned on
|
||||
// its own) is not running: printAbove unregisters it if it is still
|
||||
// registered and reports false, so the caller prints s plainly.
|
||||
func printAbove(owner *console, s string) bool {
|
||||
liveMu.Lock()
|
||||
defer liveMu.Unlock()
|
||||
if activeView == nil || (owner != nil && activeView.owner != owner) {
|
||||
v := activeView
|
||||
if v == nil || (owner != nil && v.owner != owner) {
|
||||
return false
|
||||
}
|
||||
select {
|
||||
case <-v.done:
|
||||
activeView = nil
|
||||
return false
|
||||
default:
|
||||
}
|
||||
// One message for the whole block, sent while holding liveMu: blocks
|
||||
// from different goroutines keep the order of their calls.
|
||||
activeView.prog.Send(printLine(strings.TrimSuffix(s, "\n")))
|
||||
v.prog.Send(printLine(strings.TrimSuffix(s, "\n")))
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -531,11 +532,13 @@ func TestLiveViewPanicUnregisters(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestProcessViewStripsControl (WR-05): an item name and an item error
|
||||
// carrying control sequences render without them.
|
||||
// TestProcessViewStripsControl (WR-05, WR-14): an item name, an item error
|
||||
// and the operation label carrying control sequences render without them,
|
||||
// in the live view and on the plain path (-n on a terminal, forced color
|
||||
// off a terminal).
|
||||
func TestProcessViewStripsControl(t *testing.T) {
|
||||
name := "evil\x1b]0;pwned\x07item"
|
||||
var m tea.Model = newProcessModel(DefaultPalette(), "Pushing", []string{name, "next"})
|
||||
var m tea.Model = newProcessModel(DefaultPalette(), "Push\x1b]0;pwned\x07ing", []string{name, "next"})
|
||||
m, _ = m.Update(tea.WindowSizeMsg{Width: 120, Height: 24})
|
||||
m, _ = m.Update(stepMsg{item: name, state: itemFailed, err: errors.New("rejected \x1b[2J by the remote"), done: 1})
|
||||
m, _ = m.Update(stepMsg{item: "next", state: itemRunning, done: 1})
|
||||
@@ -543,9 +546,169 @@ func TestProcessViewStripsControl(t *testing.T) {
|
||||
if bad := carriesInjected(out); bad != "" {
|
||||
t.Errorf("process view carries %q:\n%q", bad, out)
|
||||
}
|
||||
for _, want := range []string{"evil", "item", "rejected", "by the remote"} {
|
||||
for _, want := range []string{"evil", "item", "rejected", "by the remote", "Push", "ing"} {
|
||||
if !strings.Contains(ansi.Strip(out), want) {
|
||||
t.Errorf("process view lacks %q:\n%q", want, out)
|
||||
}
|
||||
}
|
||||
|
||||
// The plain path: no live view, a colored writer that passes control
|
||||
// sequences through (TestTableStripsControl).
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
tty bool
|
||||
globals Globals
|
||||
}{
|
||||
{"terminal with -n", true, Globals{Color: ColorAlways, NoInteraction: true}},
|
||||
{"no terminal, color forced", false, Globals{Color: ColorAlways}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
c := newConsole(strings.NewReader(""), &buf, &buf, noEnv, boolPtr(tc.tty), boolPtr(tc.tty))
|
||||
c.setGlobals(tc.globals)
|
||||
if c.live() {
|
||||
t.Fatal("the console runs live views; the plain path is not exercised")
|
||||
}
|
||||
err := Process(context.Background(), c, "Push\x1b]0;pwned\x07ing", []string{name}, func(context.Context, string) error { return nil })
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := buf.String()
|
||||
if bad := carriesInjected(got); bad != "" {
|
||||
t.Errorf("plain Process line carries %q:\n%q", bad, got)
|
||||
}
|
||||
for _, want := range []string{"evil", "item"} {
|
||||
if !strings.Contains(ansi.Strip(got), want) {
|
||||
t.Errorf("plain Process line lacks %q:\n%q", want, got)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// countingCtx is a context that never ends and counts the children that
|
||||
// context.WithCancel registers on it through AfterFunc and has not stopped.
|
||||
type countingCtx struct {
|
||||
mu sync.Mutex
|
||||
outstanding int
|
||||
done chan struct{}
|
||||
}
|
||||
|
||||
func (c *countingCtx) Deadline() (time.Time, bool) { return time.Time{}, false }
|
||||
func (c *countingCtx) Done() <-chan struct{} { return c.done }
|
||||
func (c *countingCtx) Err() error { return nil }
|
||||
func (c *countingCtx) Value(any) any { return nil }
|
||||
|
||||
func (c *countingCtx) AfterFunc(func()) (stop func() bool) {
|
||||
c.mu.Lock()
|
||||
c.outstanding++
|
||||
c.mu.Unlock()
|
||||
var once sync.Once
|
||||
return func() bool {
|
||||
stopped := false
|
||||
once.Do(func() {
|
||||
c.mu.Lock()
|
||||
c.outstanding--
|
||||
c.mu.Unlock()
|
||||
stopped = true
|
||||
})
|
||||
return stopped
|
||||
}
|
||||
}
|
||||
|
||||
func (c *countingCtx) registered() int {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return c.outstanding
|
||||
}
|
||||
|
||||
// TestNestedProcessBuildsNoProgram (IN-15): a Process nested in a running
|
||||
// view loses the live slot before any program is built, so it registers no
|
||||
// child context on the caller's context and opens no TEA_TRACE file; its
|
||||
// items print as plain lines.
|
||||
func TestNestedProcessBuildsNoProgram(t *testing.T) {
|
||||
out := &lockedBuffer{}
|
||||
c := newConsole(strings.NewReader(""), out, io.Discard, noEnv, boolPtr(true), boolPtr(true))
|
||||
trace := t.TempDir() + "/trace.log"
|
||||
ctx := &countingCtx{done: make(chan struct{})}
|
||||
calls := 0
|
||||
err := c.Spinner("outer", func() error {
|
||||
t.Setenv("TEA_TRACE", trace)
|
||||
return Process(ctx, c, "Nested", []string{"x"}, func(context.Context, string) error {
|
||||
calls++
|
||||
return nil
|
||||
})
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if calls != 1 {
|
||||
t.Errorf("fn ran %d times, want 1", calls)
|
||||
}
|
||||
if _, serr := os.Stat(trace); serr == nil {
|
||||
t.Error("the nested Process built a program: the TEA_TRACE file exists")
|
||||
}
|
||||
if n := ctx.registered(); n != 0 {
|
||||
t.Errorf("%d child contexts still registered on the caller's context, want 0", n)
|
||||
}
|
||||
if !strings.Contains(ansi.Strip(out.String()), symCheck+" x") {
|
||||
t.Errorf("the item's success line is missing:\n%s", ansi.Strip(out.String()))
|
||||
}
|
||||
}
|
||||
|
||||
// TestPrintAboveAfterCancel (WR-09): cancelling the context given to
|
||||
// Process ends its view and unregisters it without waiting for the work
|
||||
// function; the lines printed afterwards through Println, Info and
|
||||
// bonfire.PrintAbove (which reports false, so the caller prints the line
|
||||
// itself, as diary does) are printed plainly once each, none lost.
|
||||
func TestPrintAboveAfterCancel(t *testing.T) {
|
||||
out := &lockedBuffer{}
|
||||
c := newConsole(strings.NewReader(""), out, io.Discard, noEnv, boolPtr(true), boolPtr(true))
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
var got []string
|
||||
err := Process(ctx, c, "Pushing", []string{"first", "second", "third"}, func(_ context.Context, item string) error {
|
||||
got = append(got, item)
|
||||
if item != "second" {
|
||||
return nil
|
||||
}
|
||||
cancel()
|
||||
registered := true
|
||||
for deadline := time.Now().Add(2 * time.Second); time.Now().Before(deadline); time.Sleep(5 * time.Millisecond) {
|
||||
liveMu.Lock()
|
||||
registered = activeView != nil
|
||||
liveMu.Unlock()
|
||||
if !registered {
|
||||
break
|
||||
}
|
||||
}
|
||||
if registered {
|
||||
t.Errorf("view still registered 2s after the cancellation")
|
||||
}
|
||||
c.Println("after-cancel-println")
|
||||
c.Info("after-cancel-info")
|
||||
if PrintAbove("after-cancel-printabove") {
|
||||
t.Errorf("PrintAbove reported true after the cancellation")
|
||||
} else {
|
||||
_, _ = io.WriteString(out, "after-cancel-printabove\n")
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
t.Errorf("Process returned %v, want context.Canceled", err)
|
||||
}
|
||||
for _, item := range got {
|
||||
if item == "third" {
|
||||
t.Error("fn ran for the item after the cancellation")
|
||||
}
|
||||
}
|
||||
plain := ansi.Strip(out.String())
|
||||
for _, line := range []string{"after-cancel-println", "after-cancel-info", "after-cancel-printabove"} {
|
||||
if n := strings.Count(plain, line); n != 1 {
|
||||
t.Errorf("%q printed %d times, want 1:\n%s", line, n, plain)
|
||||
}
|
||||
}
|
||||
if PrintAbove("after") {
|
||||
t.Error("a view is still registered after Process returned")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ Use cases:
|
||||
- Boot-time schema compilation: `cabana.CompileList` and `cabana.CompileForm` read a controller's YAML from the plugin's embedded tree, check that `modelClass` matches the controller's model name, and cache a locale-neutral schema. Each request gets a translated copy (`cabana.ListSchema.Localize`, `cabana.FormSchema.Localize`, `cabana.RelationSchema.Localize`) through [phrasebook](../phrasebook/README.md), with CLDR plural forms for the SPA's messages.
|
||||
- Generic CRUD with `cabana.CRUDService`: list, show, create, update, delete and bulk delete. Writes run in transactions, and reads and writes are scoped by the controller's `pact.ListExtendQuery` and `pact.FormExtendQuery` hooks. `cabana.ExecuteList` applies search, sort, filters and pagination only on columns declared in the schema, so request parameters never reach SQL directly.
|
||||
- Mass-assignment protection: writable form fields are bound to model columns at activation (`cabana.BindWritableFields`), and `cabana.ProjectWritableFields` drops unknown keys, case variants, nested objects and protected columns from request bodies; only a `type: repeater` field's list of rows is exempt from the nested-value drop. Declared `mltext`, `mltextarea` and `mlmarkdown` locale maps are lifted before that drop: the default locale becomes the host scalar and the other locales are written through a published `cabana.TranslationWriter` inside the same permissioned save transaction. Values are filled and validated through [lagoon](../lagoon/README.md); a value that does not fit its column (a `lagoon.FillTypeError`, such as a fraction for an integer field) is a 422 `validation_failed` on that field, and the form lifecycle hooks declared in `pact` (before and after create, update and delete) run around each write.
|
||||
- Relations: `type: relation` form fields for belongsTo and belongsToMany (`cabana.FieldRelationProvider`, `cabana.FieldRelationContract`) with a paginated options endpoint and display labels in every record response; relation managers (`cabana.AdminRelationContractProvider`, `cabana.RelationContract`) served by `cabana.RelationService` for listing linked records and candidates, linking and unlinking, and creating related records. A contract is a belongsToMany (`cabana.RelationBelongsToMany`, the kind of a contract that leaves `Kind` empty) with a pivot model, or a hasMany (`cabana.RelationHasMany`) whose `ForeignKey` column on the related model points at the parent. A relation's child form comes from `manage.form` in `config_relation.yaml` (or a top-level `form`, WinterCMS's fallback), its read-only preview from `view.form` (same fallback), and a belongsToMany's pivot columns are edited through `pivot.form` (WinterCMS `pivot[x]` field names compile to `x`; pivot keys, timestamps and hook columns are refused); WinterCMS `$/<vendor>/<plugin>/...` paths resolve inside the same plugin only. A relation form accepts the scalar field types plus `datepicker` and `fileupload`; `relation`, `relation-manager`, `widget` and `partial` fail boot. The view panel's `toolbarButtons` (`create`, `update`, `delete`, `link`, `unlink`) are the capability of their routes. A relation's `messages` block takes the link keys (`link`, `linkHint`, `candidateSearch`, `linked`, `unlinkSelected`, `unlinkConfirm`, `unlinked`, `empty`) and the child and pivot modal keys (`create`, `createTitle`, `updateTitle`, `previewTitle`, `created`, `updated`, `deleteSelected`, `deleteConfirm`, `deleteOneConfirm`, `deleted`, `pivotTitle`, `pivotSaved`, `editPivot`, `createSubmit`, `updateSubmit`, `pivotSubmit`, `linkSubmit`), each defaulting to `backend::lang.messages.relation.*`. Framework code never guesses table, pivot or foreign-key names: the controller supplies them. A belongsTo field over a protected foreign key is read-only unless its contract sets `WritableForeignKey`; the protected key list itself does not change, and the submitted id is still rechecked through the scoped options query. A controller implementing `cabana.RelationLockProvider` returns a `cabana.RelationLock` per field and request: options and labels carry `locked: true` for those ids, and a create or update that adds or removes a locked id is answered 403 `forbidden` before any row is written.
|
||||
- Relations: `type: relation` form fields for belongsTo and belongsToMany (`cabana.FieldRelationProvider`, `cabana.FieldRelationContract`) with a paginated options endpoint and display labels in every record response; relation managers (`cabana.AdminRelationContractProvider`, `cabana.RelationContract`) served by `cabana.RelationService` for listing linked records and candidates, linking and unlinking, and creating related records. A contract is a belongsToMany (`cabana.RelationBelongsToMany`, the kind of a contract that leaves `Kind` empty) with a pivot model, or a hasMany (`cabana.RelationHasMany`) whose `ForeignKey` column on the related model points at the parent. A relation's child form comes from `manage.form` in `config_relation.yaml` (or a top-level `form`, WinterCMS's fallback), its read-only preview from `view.form` (same fallback), and a belongsToMany's pivot columns are edited through `pivot.form` (WinterCMS `pivot[x]` field names compile to `x`; pivot keys, timestamps and hook columns are refused); WinterCMS `$/<vendor>/<plugin>/...` paths resolve inside the same plugin only. A relation form accepts the scalar field types plus `datepicker` and `fileupload`; `relation`, `relation-manager`, `widget` and `partial` fail boot. The view panel's `toolbarButtons` (`create`, `update`, `delete`, `link`, `unlink`) are the capability of their routes. A relation's `messages` block takes the link keys (`link`, `linkHint`, `candidateSearch`, `linked`, `unlinkSelected`, `unlinkConfirm`, `unlinked`, `empty`) and the child and pivot modal keys (`create`, `createTitle`, `updateTitle`, `previewTitle`, `created`, `updated`, `deleteSelected`, `deleteConfirm`, `deleteOneConfirm`, `deleted`, `pivotTitle`, `pivotSaved`, `editPivot`, `createSubmit`, `updateSubmit`, `pivotSubmit`, `linkSubmit`), each defaulting to `backend::lang.messages.relation.*`. Framework code never guesses table, pivot or foreign-key names: the controller supplies them. A belongsTo field over a protected foreign key is read-only unless its contract sets `WritableForeignKey`; the protected key list itself does not change, and the submitted id is still rechecked through the scoped options query. `ClearOnChange` on a belongsTo `cabana.FieldRelationContract` or a hasMany `cabana.RelationContract` names nullable columns (of the record, or of the related child) that are set to NULL whenever the relation changes the foreign key, so a value that only makes sense for one parent (a role in an organisation) never moves to another or outlives the membership; a form save that writes such a column through its own field keeps the submitted value. A controller implementing `cabana.RelationLockProvider` returns a `cabana.RelationLock` per field and request: options and labels carry `locked: true` for those ids, and a create or update that adds or removes a locked id is answered 403 `forbidden` before any row is written.
|
||||
- Form widgets and controller actions: a `type: widget` field in `fields.yaml` names a plugin custom element (`widget:`, which must start with the owning plugin's `{vendor}-{plugin}-` prefix), the controller action it runs (`action:`, registered through `pact.HasAdminActions`) and the writable scalar fields of the same form the action may write back (`fill:`). The admin SPA posts the action to a cabana-owned route, so the CSRF check, permissions (the controller's plus the action's own) and record scoping (`pact.FormExtendQuery`) never depend on plugin code; the response carries only the declared fill keys whose values encode as JSON scalars (a value whose `MarshalJSON` writes an array or object, NaN or an infinity is dropped). Like the list schema's `toolbarActions`, the form schema carries a widget field only when the requesting administrator may run its action. The field's `context` applies to the action route as it does on save: a request without `record_id` is the create form's and one with it the update form's, and a widget its context hides on that form answers 404. Besides `record_id` and `values`, the request may carry `payload`, the widget's own JSON value (at most 64 KiB, a 422 above that; refused on toolbar and record routes), which reaches the action unfiltered as the `Payload` field of `pact.AdminActionInput`; the action may answer with `Data` on `pact.AdminActionResult`, written to the response as `data` exactly as it encodes (any JSON up to 256 KiB encoded, a 500 above that or when it cannot be encoded), not subject to the fill allowlist and omitted when nil. The element asks for its action with a `summer-action` event whose optional `detail.payload` becomes the request `payload`, and receives the answer as a `data` attribute plus a `summer-result` event with `{data, fill, message}`. Unknown keys, a foreign or invalid tag, an unregistered action or a fill key that is not a writable scalar field fail boot.
|
||||
- Controller assets: a controller implementing `pact.AdminClientAssets` names JS (`.js`, `.mjs`) and CSS files under its plugin's `assets/` directory, Winter's `addJs`/`addCss`. They are read from the plugin's embedded tree at boot (a missing file fails boot; there is no disk override) and listed in the list and form schemas under `assets` as same-origin URLs with a `?v=` content hash. A form with a widget needs at least one JS file.
|
||||
- Toolbar actions: `toolbar.buttons` in `config_list.yaml` lists the built-in `create` and `delete` next to names the controller registers through `pact.HasAdminActions`. Registered actions share one namespace with widget actions, `create` and `delete` are reserved, and each toolbar action needs a label. The list schema's `toolbarActions` carries only the actions the requesting administrator may run, with localized labels; an unknown name fails boot.
|
||||
@@ -99,7 +99,7 @@ Guarded routes run surf's `locale.from-principal` after the backend guard: an in
|
||||
| GET `/{vendor}/{plugin}/{controller}/singleton` | The id of a `pact.AdminSingleton` controller's one record, created when absent. |
|
||||
| GET `.../fields/{field}/dependent-options` | Choices of a dropdown with `dependsOn` for the sibling value passed as `values[<sibling>]`, from the controller's `pact.DependentOptionsProvider`: `{applies, options: [{value, label, depth}]}` (`cabana.AdminDependentOptionsResult`). A field that is not a dependsOn dropdown answers 404. |
|
||||
| GET `.../{id}/relations/{name}`, GET `.../{id}/relations/{name}/candidates` | Linked records and link candidates of a relation manager. |
|
||||
| POST `.../{id}/relations/{name}/link`, POST `.../{id}/relations/{name}/unlink` | Link and unlink related records. A link body may carry `pivot` values for one id on a relation with a pivot form. On a hasMany, link sets and unlink clears the related record's foreign key. |
|
||||
| POST `.../{id}/relations/{name}/link`, POST `.../{id}/relations/{name}/unlink` | Link and unlink related records. A link body may carry `pivot` values for one id on a relation with a pivot form. On a hasMany, link sets and unlink clears the related record's foreign key (and the contract's `ClearOnChange` columns). |
|
||||
| POST `.../{id}/relations/{name}/records` | Create a related record through the relation's manage form and attach it to the record (a hasMany foreign key is set by the server; a belongsToMany gets its pivot row). Needs `create` in the view panel's `toolbarButtons` (403 otherwise). |
|
||||
| GET and PUT `.../{id}/relations/{name}/records/{child}` | Show a child (needs `update` or a view form) and save it through the manage form (needs `update`). |
|
||||
| POST `.../{id}/relations/{name}/delete` | `{ids}`: delete children through their model (needs `delete`). A belongsToMany record loses this record's pivot row first. All or nothing. |
|
||||
@@ -269,10 +269,10 @@ func (p *Plugin) AdminFS() fs.FS { return adminFS }
|
||||
| `cabana.SettingsMeta` | Meta of a settings result: `secrets` (field to `stored`, `env` or `empty`) and the richeditor `notices`; omitted when empty. |
|
||||
| `cabana.SecretFallbacks` | Implemented by a settings model whose password fields fall back to a config key when the column is empty: field name to compass key. |
|
||||
| `cabana.SecretStored` / `cabana.SecretEnv` / `cabana.SecretEmpty` | The states of a write-only settings secret. |
|
||||
| `cabana.FieldRelationProvider` / `cabana.FieldRelationContract` | Controller-supplied bindings for `type: relation` form fields. `WritableForeignKey` on a belongsTo contract makes a protected foreign key writable through that field. |
|
||||
| `cabana.FieldRelationProvider` / `cabana.FieldRelationContract` | Controller-supplied bindings for `type: relation` form fields. `WritableForeignKey` on a belongsTo contract makes a protected foreign key writable through that field; `ClearOnChange` lists nullable columns of the record set to NULL when a save changes the key, unless the save writes them. |
|
||||
| `cabana.RelationLock` | The related ids of one relation field the requesting administrator may not add or remove, and the message of the 403. |
|
||||
| `cabana.RelationLockProvider` | Controller capability that returns the `cabana.RelationLock` of a relation field per request; enforced on create and update. |
|
||||
| `cabana.AdminRelationContractProvider` / `cabana.RelationContract` | Controller-supplied bindings for relation managers. |
|
||||
| `cabana.AdminRelationContractProvider` / `cabana.RelationContract` | Controller-supplied bindings for relation managers. `ClearOnChange` on a hasMany lists nullable columns of the child set to NULL by link and unlink. |
|
||||
| `cabana.RelationBelongsToMany` / `cabana.RelationHasMany` | The two `RelationContract.Kind` values; an empty `Kind` is a belongsToMany. |
|
||||
| `cabana.AdminRelationChildCreate` / `cabana.AdminRelationChildShow` / `cabana.AdminRelationChildUpdate` / `cabana.AdminRelationChildDelete` / `cabana.AdminRelationPivotShow` / `cabana.AdminRelationPivotUpdate` | Swag annotations of the relation child and pivot routes. |
|
||||
| `cabana.RelationMutationInput` | Body of the link and unlink routes: `IDs` and, for a link of one id, `Pivot` form values. |
|
||||
@@ -387,6 +387,8 @@ The Phase 18 pieces (money field and column, write-only settings secrets, riched
|
||||
|
||||
The Phase 18.1 pieces (the taglist field and the flat reorder) are covered by Docker-free unit tests in `taglist_field_test.go` (TestTaglistFieldCompile, TestTaglistFieldValidate for the save check in both modes, TestTaglistFieldHydrate for the record GET) and `reorder_flat_test.go` (TestReorderFlatCompile, which also checks the localized list schema, and TestReorderFlatNodes for the save's node check), and by Postgres tests in `catalog_smoke_test.go` (TestTaglistFieldSmoke, TestReorderFlatSmoke with a partitioned subtest) and `reorder_test.go` (TestReorderPostgres for the tree reorder). The admin specs are `admin/tests/form/TaglistField.test.ts` and `admin/tests/views/ReorderView.test.ts`. `scripts/check-phase18.1.sh` runs them by name.
|
||||
|
||||
`ClearOnChange` is covered by `clear_on_change_field_test.go` (TestClearOnChangeFieldBoot, Docker-free, and TestClearOnChangeBelongsTo on Postgres: a changed, emptied, kept or co-submitted belongsTo key) and `clear_on_change_test.go` (TestClearOnChangeRelationBoot and, on Postgres, TestClearOnChangeHasMany: link, unlink and a deferred link committed by the parent's first save).
|
||||
|
||||
A Detached bulk action (`pact.AdminBulkAction.Detached`) is covered by the Postgres test TestDetachedBulkAction in `bulk_detached_test.go`, on the roster fixture with two test-only actions: Run sees no transaction (`TxFromContext`) and holds no row lock (another connection's `SELECT ... FOR UPDATE NOWAIT` succeeds during Run); the empty, absent and partial-selection rules match the locked path; a `ValidationError` from Run is 422 and the rows written before it stay written; the same action without Detached still runs in the transaction holding the row locks. `scripts/check-phase18.1.sh` requires it in its postgres stage.
|
||||
|
||||
The localization of refusals is covered by Docker-free unit tests in `localize_error_test.go` (TestLocalizeControllerErrorValidation, TestLocalizeControllerErrorForbidden and TestWriteCRUDErrorLocalized, on a two-locale catalog; the plugin's error value stays unchanged and text without `::` is never looked up) and by the Postgres tests TestValidationErrorLocalized (a form hook's `cabana.ValidationError` in en and pl) and TestReorderHookRefusalLocalized (both refusal types from `pact.ReorderAfterSave`).
|
||||
|
||||
222
modules/cabana/clear_on_change_field_test.go
Normal file
222
modules/cabana/clear_on_change_field_test.go
Normal file
@@ -0,0 +1,222 @@
|
||||
package cabana
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
)
|
||||
|
||||
// cocTeam is the related model of the ClearOnChange belongsTo fixture.
|
||||
type cocTeam struct {
|
||||
ID uint `gorm:"column:id;primaryKey"`
|
||||
Name string `gorm:"column:name"`
|
||||
}
|
||||
|
||||
func (cocTeam) TableName() string { return "cabana_coc_teams" }
|
||||
|
||||
// cocMember belongs to a team through the protected organisation_id; its
|
||||
// role only makes sense inside that team.
|
||||
type cocMember struct {
|
||||
ID uint `gorm:"column:id;primaryKey"`
|
||||
Name string `gorm:"column:name"`
|
||||
OrganisationID *uint `gorm:"column:organisation_id"`
|
||||
Role *string `gorm:"column:role"`
|
||||
Note *string `gorm:"column:note"`
|
||||
}
|
||||
|
||||
func (cocMember) TableName() string { return "cabana_coc_members" }
|
||||
func (cocMember) Fillable() []string { return []string{"name", "role", "note"} }
|
||||
func (cocMember) Rules() map[string]string { return map[string]string{"name": "required"} }
|
||||
|
||||
type cocController struct {
|
||||
relations []FieldRelationContract
|
||||
}
|
||||
|
||||
func (cocController) ID() string { return "acme.demo.members" }
|
||||
func (cocController) ModelName() string { return "Member" }
|
||||
func (cocController) ConfigDir() string { return "controllers/members" }
|
||||
func (cocController) NewRecord() any { return &cocMember{} }
|
||||
func (c cocController) AdminFieldRelations() []FieldRelationContract {
|
||||
return c.relations
|
||||
}
|
||||
|
||||
// cocTeamContract is the team field's contract with ClearOnChange role.
|
||||
func cocTeamContract(clear ...string) FieldRelationContract {
|
||||
return FieldRelationContract{
|
||||
Field: "team", Kind: relationKindBelongsTo, NewRelated: func() any { return &cocTeam{} },
|
||||
ForeignKey: "organisation_id", WritableForeignKey: true, ClearOnChange: clear,
|
||||
}
|
||||
}
|
||||
|
||||
func cocFS() fstest.MapFS {
|
||||
return fstest.MapFS{
|
||||
"controllers/members/config_list.yaml": &fstest.MapFile{Data: []byte("modelClass: Member\nlist: ~/plugins/acme/demo/models/member/columns.yaml\n")},
|
||||
"controllers/members/config_form.yaml": &fstest.MapFile{Data: []byte("name: members\nform: ~/plugins/acme/demo/models/member/fields.yaml\nmodelClass: Member\n")},
|
||||
"models/member/columns.yaml": &fstest.MapFile{Data: []byte("columns:\n name:\n label: Name\n")},
|
||||
"models/member/fields.yaml": &fstest.MapFile{Data: []byte(`fields:
|
||||
name:
|
||||
label: Name
|
||||
type: text
|
||||
team:
|
||||
label: Team
|
||||
type: relation
|
||||
nameFrom: name
|
||||
role:
|
||||
label: Role
|
||||
type: text
|
||||
note:
|
||||
label: Note
|
||||
type: text
|
||||
`)},
|
||||
}
|
||||
}
|
||||
|
||||
func cocCompile(relations ...FieldRelationContract) (*CompiledController, error) {
|
||||
reg, err := compileRegistry([]controllerRef{{plugin: formPlugin{fsys: cocFS()}, ctl: cocController{relations: relations}}})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cc, _ := reg.Get("acme.demo.members")
|
||||
return cc, nil
|
||||
}
|
||||
|
||||
// TestClearOnChangeFieldBoot: ClearOnChange on a form relation field is
|
||||
// refused at boot on a belongsToMany, and for a column the record lacks, a
|
||||
// column that is not nullable, the foreign key and the primary key; the
|
||||
// error names the controller, the field and the column.
|
||||
func TestClearOnChangeFieldBoot(t *testing.T) {
|
||||
if _, err := cocCompile(cocTeamContract("role", "note")); err != nil {
|
||||
t.Fatalf("a valid ClearOnChange: %v", err)
|
||||
}
|
||||
many := cocTeamContract("role")
|
||||
many.Kind, many.ForeignKey, many.WritableForeignKey = relationKindBelongsToMany, "", false
|
||||
for name, tc := range map[string]struct {
|
||||
contract FieldRelationContract
|
||||
want string
|
||||
}{
|
||||
"belongsToMany": {many, "ClearOnChange is only valid on belongsTo"},
|
||||
"missing column": {cocTeamContract("rank"), "ClearOnChange column rank is not a column of the model"},
|
||||
"not nullable": {cocTeamContract("name"), "ClearOnChange column name is not nullable"},
|
||||
"foreign key": {cocTeamContract("organisation_id"), "ClearOnChange column organisation_id is the foreign key"},
|
||||
"primary key": {cocTeamContract("id"), "ClearOnChange column id is the primary key"},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
_, err := cocCompile(tc.contract)
|
||||
if err == nil || !strings.Contains(err.Error(), tc.want) || !strings.Contains(err.Error(), "acme.demo.members") || !strings.Contains(err.Error(), "field team") {
|
||||
t.Fatalf("err = %v, want %q naming the controller and the field", err, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestClearOnChangeBelongsTo: a save that changes a belongsTo key clears the
|
||||
// contract's ClearOnChange columns unless the same save writes them; a save
|
||||
// that keeps the key, and a contract without ClearOnChange, leave them.
|
||||
func TestClearOnChangeBelongsTo(t *testing.T) {
|
||||
_, db := newListService(t)
|
||||
if err := db.Migrator().DropTable(&cocMember{}, &cocTeam{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.AutoMigrate(&cocMember{}, &cocTeam{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
red, blue := cocTeam{Name: "Red"}, cocTeam{Name: "Blue"}
|
||||
for _, team := range []*cocTeam{&red, &blue} {
|
||||
if err := db.Create(team).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
cc, err := cocCompile(cocTeamContract("role"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
svc := CRUDService{DB: db}
|
||||
ctx := context.Background()
|
||||
str := func(s string) *string { return &s }
|
||||
seed := func(t *testing.T, team *uint, role string) uint {
|
||||
t.Helper()
|
||||
m := cocMember{Name: "M", OrganisationID: team, Role: str(role), Note: str("note")}
|
||||
if err := db.Create(&m).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return m.ID
|
||||
}
|
||||
load := func(t *testing.T, id uint) cocMember {
|
||||
t.Helper()
|
||||
var m cocMember
|
||||
if err := db.Take(&m, id).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return m
|
||||
}
|
||||
update := func(t *testing.T, cc *CompiledController, id uint, body map[string]any) cocMember {
|
||||
t.Helper()
|
||||
body["name"] = "M"
|
||||
if _, err := svc.Update(ctx, cc, id, RecordInput{Body: body}); err != nil {
|
||||
t.Fatalf("update %v: %v", body, err)
|
||||
}
|
||||
return load(t, id)
|
||||
}
|
||||
roleIs := func(t *testing.T, m cocMember, want string) {
|
||||
t.Helper()
|
||||
switch {
|
||||
case want == "" && m.Role != nil:
|
||||
t.Fatalf("role = %q, want NULL", *m.Role)
|
||||
case want != "" && (m.Role == nil || *m.Role != want):
|
||||
t.Fatalf("role = %v, want %q", m.Role, want)
|
||||
}
|
||||
if m.Note == nil || *m.Note != "note" {
|
||||
t.Fatalf("a column outside ClearOnChange changed: note = %v", m.Note)
|
||||
}
|
||||
}
|
||||
|
||||
t.Run("another record clears", func(t *testing.T) {
|
||||
m := update(t, cc, seed(t, &red.ID, "owner"), map[string]any{"team": blue.ID})
|
||||
if m.OrganisationID == nil || *m.OrganisationID != blue.ID {
|
||||
t.Fatalf("organisation_id = %v", m.OrganisationID)
|
||||
}
|
||||
roleIs(t, m, "")
|
||||
})
|
||||
t.Run("none clears", func(t *testing.T) {
|
||||
m := update(t, cc, seed(t, &red.ID, "owner"), map[string]any{"team": nil})
|
||||
if m.OrganisationID != nil {
|
||||
t.Fatalf("organisation_id = %v", *m.OrganisationID)
|
||||
}
|
||||
roleIs(t, m, "")
|
||||
})
|
||||
t.Run("the same key keeps", func(t *testing.T) {
|
||||
roleIs(t, update(t, cc, seed(t, &red.ID, "owner"), map[string]any{"team": red.ID}), "owner")
|
||||
})
|
||||
t.Run("no relation value keeps", func(t *testing.T) {
|
||||
roleIs(t, update(t, cc, seed(t, &red.ID, "owner"), map[string]any{}), "owner")
|
||||
})
|
||||
t.Run("a submitted column wins", func(t *testing.T) {
|
||||
roleIs(t, update(t, cc, seed(t, &red.ID, "owner"), map[string]any{"team": blue.ID, "role": "member"}), "member")
|
||||
})
|
||||
t.Run("a create that sets the key", func(t *testing.T) {
|
||||
rec, err := svc.Create(ctx, cc, RecordInput{Body: map[string]any{"name": "New", "team": red.ID, "note": "note"}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
id, _ := asUint(rec["id"])
|
||||
m := load(t, id)
|
||||
if m.OrganisationID == nil || *m.OrganisationID != red.ID {
|
||||
t.Fatalf("organisation_id = %v", m.OrganisationID)
|
||||
}
|
||||
roleIs(t, m, "")
|
||||
rec, err = svc.Create(ctx, cc, RecordInput{Body: map[string]any{"name": "New", "team": red.ID, "role": "admin", "note": "note"}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
id, _ = asUint(rec["id"])
|
||||
roleIs(t, load(t, id), "admin")
|
||||
})
|
||||
t.Run("without ClearOnChange nothing is cleared", func(t *testing.T) {
|
||||
plain, err := cocCompile(cocTeamContract())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
roleIs(t, update(t, plain, seed(t, &red.ID, "owner"), map[string]any{"team": blue.ID}), "owner")
|
||||
})
|
||||
}
|
||||
119
modules/cabana/clear_on_change_test.go
Normal file
119
modules/cabana/clear_on_change_test.go
Normal file
@@ -0,0 +1,119 @@
|
||||
package cabana_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/cabana"
|
||||
)
|
||||
|
||||
// clearOnChangeContracts is dfContracts with ClearOnChange columns on the
|
||||
// parts hasMany.
|
||||
func clearOnChangeContracts(columns ...string) func() []cabana.RelationContract {
|
||||
return func() []cabana.RelationContract {
|
||||
cs := dfContracts()
|
||||
cs[1].ClearOnChange = columns
|
||||
return cs
|
||||
}
|
||||
}
|
||||
|
||||
// TestClearOnChangeRelationBoot: ClearOnChange on a relation contract is
|
||||
// refused at boot on a belongsToMany, and for a column the child lacks, a
|
||||
// column that is not nullable, the ForeignKey and the primary key; the
|
||||
// error names the controller, the relation and the column.
|
||||
func TestClearOnChangeRelationBoot(t *testing.T) {
|
||||
member := func() []cabana.RelationContract {
|
||||
cs := dfContracts()
|
||||
cs[0].ClearOnChange = []string{"email"}
|
||||
return cs
|
||||
}
|
||||
for name, tc := range map[string]struct {
|
||||
contracts func() []cabana.RelationContract
|
||||
want string
|
||||
}{
|
||||
"belongsToMany": {member, "relation members: ClearOnChange is only valid on a hasMany contract"},
|
||||
"missing column": {clearOnChangeContracts("rank"), "relation parts: ClearOnChange column rank is not a column of the model"},
|
||||
"not nullable": {clearOnChangeContracts("label"), "relation parts: ClearOnChange column label is not nullable"},
|
||||
"foreign key": {clearOnChangeContracts("gadget_id"), "relation parts: ClearOnChange column gadget_id is the foreign key"},
|
||||
"primary key": {clearOnChangeContracts("id"), "relation parts: ClearOnChange column id is the primary key"},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
err := activateDeferred(t, withContracts(nil, tc.contracts)(t), nil)
|
||||
if err == nil || !strings.Contains(err.Error(), tc.want) || !strings.Contains(err.Error(), "acme.deferred.gadgets") {
|
||||
t.Fatalf("err = %v, want %q naming the controller", err, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
if err := activateDeferred(t, withContracts(nil, clearOnChangeContracts("due_on"))(t), nil); err != nil {
|
||||
t.Fatalf("a valid ClearOnChange: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestClearOnChangeHasMany: a hasMany link and unlink in the relation
|
||||
// manager clear the contract's ClearOnChange columns of the child, a
|
||||
// deferred link committed by the parent's first save too, and a contract
|
||||
// without ClearOnChange leaves them.
|
||||
func TestClearOnChangeHasMany(t *testing.T) {
|
||||
env := newDeferredEnvWith(t, withContracts(nil, clearOnChangeContracts("due_on"))(t))
|
||||
g := env.gadget(t, "g-"+env.stamp, false)
|
||||
parts := func(gadget uint, rest string) string { return dfPath(gadget, "/relations/parts"+rest) }
|
||||
dated := func(t *testing.T, owner uint, label string) uint {
|
||||
t.Helper()
|
||||
id := env.part(t, owner, label)
|
||||
if err := env.db.Exec(`UPDATE cabana_deferred_parts SET due_on = '2026-11-01' WHERE id = ?`, id).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
dueOn := func(t *testing.T, id uint) bool {
|
||||
t.Helper()
|
||||
p, ok := env.partRow(t, id)
|
||||
if !ok {
|
||||
t.Fatalf("part %d is gone", id)
|
||||
}
|
||||
return p.DueOn != nil
|
||||
}
|
||||
|
||||
t.Run("link clears", func(t *testing.T) {
|
||||
free := dated(t, 0, "free")
|
||||
want(t, "link", env.a.do(t, http.MethodPost, parts(g, "/link"), map[string]any{"ids": []uint{free}}, nil), http.StatusOK)
|
||||
if p, _ := env.partRow(t, free); p.GadgetID == nil || *p.GadgetID != g || p.Label != "free" || dueOn(t, free) {
|
||||
t.Fatalf("linked part %+v, want owned by %d with due_on NULL", p, g)
|
||||
}
|
||||
})
|
||||
t.Run("unlink clears", func(t *testing.T) {
|
||||
owned := dated(t, g, "owned")
|
||||
want(t, "unlink", env.a.do(t, http.MethodPost, parts(g, "/unlink"), map[string]any{"ids": []uint{owned}}, nil), http.StatusOK)
|
||||
if p, _ := env.partRow(t, owned); p.GadgetID != nil || dueOn(t, owned) {
|
||||
t.Fatalf("unlinked part %+v, want a NULL key and due_on", p)
|
||||
}
|
||||
})
|
||||
t.Run("deferred link clears on the parent's first save", func(t *testing.T) {
|
||||
h := sk(newSessionKey(t))
|
||||
free := dated(t, 0, "deferred")
|
||||
want(t, "deferred link", env.a.do(t, http.MethodPost, parts(0, "/link"), map[string]any{"ids": []uint{free}}, h), http.StatusOK)
|
||||
if !dueOn(t, free) {
|
||||
t.Fatal("a deferred link wrote the child before the save")
|
||||
}
|
||||
saved := env.a.do(t, http.MethodPost, "/acme/deferred/gadgets", map[string]any{"name": "d-" + env.stamp}, h)
|
||||
want(t, "save", saved, http.StatusCreated)
|
||||
parent := dataID(t, saved.Body.Bytes())
|
||||
if p, _ := env.partRow(t, free); p.GadgetID == nil || *p.GadgetID != parent || dueOn(t, free) {
|
||||
t.Fatalf("committed part %+v, want owned by %d with due_on NULL", p, parent)
|
||||
}
|
||||
})
|
||||
t.Run("without ClearOnChange nothing is cleared", func(t *testing.T) {
|
||||
plain := newDeferredEnv(t)
|
||||
pg := plain.gadget(t, "p-"+plain.stamp, false)
|
||||
free := plain.part(t, 0, "plain")
|
||||
if err := plain.db.Exec(`UPDATE cabana_deferred_parts SET due_on = '2026-11-01' WHERE id = ?`, free).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want(t, "link", plain.a.do(t, http.MethodPost, dfPath(pg, "/relations/parts/link"), map[string]any{"ids": []uint{free}}, nil), http.StatusOK)
|
||||
want(t, "unlink", plain.a.do(t, http.MethodPost, dfPath(pg, "/relations/parts/unlink"), map[string]any{"ids": []uint{free}}, nil), http.StatusOK)
|
||||
if p, _ := plain.partRow(t, free); p.DueOn == nil {
|
||||
t.Fatalf("a contract without ClearOnChange cleared due_on: %+v", p)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -768,7 +768,8 @@ func (s CRUDService) save(ctx context.Context, cc *CompiledController, id any, i
|
||||
}
|
||||
// The Form hooks read what this save writes for plugin field types.
|
||||
ctx = withFieldWrites(ctx, decoded.writes)
|
||||
if err := lagoon.Fill(target, fillAllowed(cc, target, op), projected, false); err != nil {
|
||||
allowed := fillAllowed(cc, target, op)
|
||||
if err := lagoon.Fill(target, allowed, projected, false); err != nil {
|
||||
// A value that does not fit its column is the admin's input,
|
||||
// not a missing capability: answer it on the field.
|
||||
var typed *lagoon.FillTypeError
|
||||
@@ -819,7 +820,15 @@ func (s CRUDService) save(ctx context.Context, cc *CompiledController, id any, i
|
||||
if err := checkRelationLocks(ctx, tx, cc, target, relations); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := assignBelongsTo(cc, target, relations); err != nil {
|
||||
// A belongsTo whose key changes clears its ClearOnChange columns,
|
||||
// except those this save writes from its own form fields.
|
||||
written := map[string]bool{}
|
||||
for _, key := range allowed {
|
||||
if _, ok := projected[key]; ok {
|
||||
written[key] = true
|
||||
}
|
||||
}
|
||||
if err := assignBelongsTo(cc, target, relations, written); err != nil {
|
||||
return err
|
||||
}
|
||||
if update {
|
||||
|
||||
@@ -25,6 +25,8 @@ type Member struct {
|
||||
// OrganisationID is a protected column: the form writes it only through
|
||||
// the team relation field.
|
||||
OrganisationID *uint `gorm:"column:organisation_id"`
|
||||
// TeamRole only makes sense inside the member's team.
|
||||
TeamRole *string `gorm:"column:team_role"`
|
||||
}
|
||||
|
||||
// Team is what a member belongs to; Group is what a member is put into.
|
||||
@@ -156,7 +158,8 @@ func (MembersController) AdminSetPermissionValues(_ context.Context, field strin
|
||||
|
||||
// AdminFieldRelations binds the form's two relation fields. organisation_id
|
||||
// is a protected column, so the team field would be read-only; the contract
|
||||
// opts in to writing it through this field.
|
||||
// opts in to writing it through this field. A save that moves the member to
|
||||
// another team (or to none) clears team_role, unless it writes a new one.
|
||||
func (MembersController) AdminFieldRelations() []cabana.FieldRelationContract {
|
||||
return []cabana.FieldRelationContract{{
|
||||
Field: "team",
|
||||
@@ -164,6 +167,7 @@ func (MembersController) AdminFieldRelations() []cabana.FieldRelationContract {
|
||||
NewRelated: func() any { return &Team{} },
|
||||
ForeignKey: "organisation_id",
|
||||
WritableForeignKey: true,
|
||||
ClearOnChange: []string{"team_role"},
|
||||
}, {
|
||||
Field: "groups",
|
||||
Kind: "belongsToMany",
|
||||
|
||||
@@ -55,6 +55,15 @@ type RelationContract struct {
|
||||
Columns map[string]string
|
||||
HookPivotColumns []string
|
||||
ExcludedRelatedIDs func(parent any) ([]uint, error)
|
||||
// ClearOnChange names nullable columns of the related child (hasMany
|
||||
// only) that are set to NULL whenever this relation changes the child's
|
||||
// ForeignKey: a link and an unlink in the relation manager, deferred ones
|
||||
// included. Values that only make sense for one parent (a member's role
|
||||
// in an organisation, for example) then never move to another parent or
|
||||
// outlive the membership. Each column must be a pointer field of the
|
||||
// related model and neither the ForeignKey nor the primary key. The zero
|
||||
// value changes nothing.
|
||||
ClearOnChange []string
|
||||
}
|
||||
|
||||
// kind is the contract's normalized kind.
|
||||
@@ -482,10 +491,16 @@ func validateRelationContract(ctl pact.AdminController, contract RelationContrac
|
||||
if err := validatePivotContract(contract); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(contract.ClearOnChange) > 0 {
|
||||
return fmt.Errorf("relation %s: ClearOnChange is only valid on a hasMany contract", contract.Name)
|
||||
}
|
||||
case RelationHasMany:
|
||||
if err := validateHasManyContract(contract); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateClearOnChange(contract.NewRelated(), contract.ClearOnChange, contract.ForeignKey); err != nil {
|
||||
return fmt.Errorf("relation %s: %w", contract.Name, err)
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("relation %s has unknown kind %q (want %s or %s)", contract.Name, contract.Kind, RelationBelongsToMany, RelationHasMany)
|
||||
}
|
||||
@@ -578,6 +593,46 @@ func validateHasManyContract(contract RelationContract) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateClearOnChange checks the ClearOnChange columns of a contract
|
||||
// against model: each must be a column of the model, held in a pointer
|
||||
// field (a nullable column), and neither foreignKey nor the primary key.
|
||||
func validateClearOnChange(model any, columns []string, foreignKey string) error {
|
||||
pk := primaryColumn(model)
|
||||
for _, col := range columns {
|
||||
if !identifier(col) {
|
||||
return fmt.Errorf("ClearOnChange column %q is not an identifier", col)
|
||||
}
|
||||
if col == foreignKey {
|
||||
return fmt.Errorf("ClearOnChange column %s is the foreign key", col)
|
||||
}
|
||||
if col == pk {
|
||||
return fmt.Errorf("ClearOnChange column %s is the primary key", col)
|
||||
}
|
||||
field, ok := structFieldByColumn(model, col)
|
||||
if !ok {
|
||||
return fmt.Errorf("ClearOnChange column %s is not a column of the model", col)
|
||||
}
|
||||
if field.Type.Kind() != reflect.Pointer {
|
||||
return fmt.Errorf("ClearOnChange column %s is not nullable (it needs a pointer field)", col)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// clearColumns sets each column of model to NULL, except the columns in
|
||||
// keep.
|
||||
func clearColumns(model any, columns []string, keep map[string]bool) error {
|
||||
for _, col := range columns {
|
||||
if keep[col] {
|
||||
continue
|
||||
}
|
||||
if err := setModelColumn(model, col, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// relationDeferrable reports whether a relation can be managed before its
|
||||
// parent is saved: a belongsToMany always (the pivot row is written on
|
||||
// save), a hasMany only with a nullable ForeignKey (the child is inserted
|
||||
@@ -945,6 +1000,11 @@ func (s RelationService) linkRelated(ctx context.Context, tx *gorm.DB, cc *Compi
|
||||
if err := setModelColumn(related, cr.Contract.ForeignKey, ownerPK); err != nil {
|
||||
return 0, lifecycleFailure(cc, err)
|
||||
}
|
||||
// A link always changes the key: it adopts only children whose
|
||||
// key is NULL.
|
||||
if err := clearColumns(related, cr.Contract.ClearOnChange, nil); err != nil {
|
||||
return 0, lifecycleFailure(cc, err)
|
||||
}
|
||||
if err := tx.WithContext(ctx).Save(related).Error; err != nil {
|
||||
return 0, lifecycleFailure(cc, err)
|
||||
}
|
||||
@@ -1245,6 +1305,9 @@ func (s RelationService) unlinkRelated(ctx context.Context, tx *gorm.DB, cc *Com
|
||||
if err := setModelColumn(child, cr.Contract.ForeignKey, nil); err != nil {
|
||||
return 0, lifecycleFailure(cc, err)
|
||||
}
|
||||
if err := clearColumns(child, cr.Contract.ClearOnChange, nil); err != nil {
|
||||
return 0, lifecycleFailure(cc, err)
|
||||
}
|
||||
if err := tx.WithContext(ctx).Save(child).Error; err != nil {
|
||||
return 0, lifecycleFailure(cc, err)
|
||||
}
|
||||
|
||||
@@ -42,6 +42,15 @@ type FieldRelationContract struct {
|
||||
// WritableForeignKey declares a belongsTo foreign key that is a protected
|
||||
// fill key writable through this relation field.
|
||||
WritableForeignKey bool
|
||||
// ClearOnChange names nullable columns of the record (belongsTo only)
|
||||
// that are set to NULL whenever a save changes this field's foreign key
|
||||
// (to another record or to none), unless the same save writes the column
|
||||
// through its own form field. Values that only make sense for one parent
|
||||
// (a member's role in an organisation, for example) then never move to
|
||||
// another parent or outlive the membership. Each column must be a
|
||||
// pointer field of the record and neither the ForeignKey nor the primary
|
||||
// key. The zero value changes nothing.
|
||||
ClearOnChange []string
|
||||
}
|
||||
|
||||
// RelationLock names the related records of one relation field that the
|
||||
@@ -245,10 +254,16 @@ func compileFieldRelation(ctl pact.AdminController, field FormField, contract Fi
|
||||
}
|
||||
out.Nullable = fk.Type.Kind() == reflect.Pointer
|
||||
out.ReadOnly = protectedFillKey(contract.ForeignKey) && !contract.WritableForeignKey
|
||||
if err := validateClearOnChange(parent, contract.ClearOnChange, contract.ForeignKey); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
case relationKindBelongsToMany:
|
||||
if contract.WritableForeignKey {
|
||||
return nil, errors.New("WritableForeignKey is only valid on belongsTo")
|
||||
}
|
||||
if len(contract.ClearOnChange) > 0 {
|
||||
return nil, errors.New("ClearOnChange is only valid on belongsTo")
|
||||
}
|
||||
if contract.ForeignKey != "" {
|
||||
return nil, errors.New("belongsToMany cannot declare a ForeignKey")
|
||||
}
|
||||
@@ -644,16 +659,26 @@ func checkRelationScope(ctx context.Context, tx *gorm.DB, cc *CompiledController
|
||||
// assignBelongsTo writes validated belongsTo ids (or null) onto the parent's
|
||||
// foreign key before the row write. Read-only keys never reach this point: a
|
||||
// protected foreign key is written only when its contract declares
|
||||
// WritableForeignKey, the same rule compileFieldRelation applies.
|
||||
func assignBelongsTo(cc *CompiledController, model any, values []relationValue) error {
|
||||
// WritableForeignKey, the same rule compileFieldRelation applies. When the
|
||||
// submitted key differs from the record's current one, the contract's
|
||||
// ClearOnChange columns are set to NULL, except those in written (the fill
|
||||
// keys this save wrote from its own form fields).
|
||||
func assignBelongsTo(cc *CompiledController, model any, values []relationValue, written map[string]bool) error {
|
||||
for _, value := range values {
|
||||
c := value.fr.Contract
|
||||
if value.fr.Multiple || value.fr.ReadOnly || (protectedFillKey(c.ForeignKey) && !c.WritableForeignKey) {
|
||||
continue
|
||||
}
|
||||
var id any
|
||||
var next uint
|
||||
if !value.null {
|
||||
id = value.ids[0]
|
||||
next = value.ids[0]
|
||||
}
|
||||
if len(c.ClearOnChange) > 0 && currentKey(model, c.ForeignKey) != next {
|
||||
if err := clearColumns(model, c.ClearOnChange, written); err != nil {
|
||||
return lifecycleFailure(cc, err)
|
||||
}
|
||||
}
|
||||
if err := setModelColumn(model, value.fr.Contract.ForeignKey, id); err != nil {
|
||||
return lifecycleFailure(cc, err)
|
||||
@@ -662,6 +687,33 @@ func assignBelongsTo(cc *CompiledController, model any, values []relationValue)
|
||||
return nil
|
||||
}
|
||||
|
||||
// currentKey is the value of model's foreign key column; a nil pointer and
|
||||
// zero both read as 0 (no related record).
|
||||
func currentKey(model any, column string) uint {
|
||||
v := reflect.ValueOf(model)
|
||||
for v.Kind() == reflect.Pointer {
|
||||
if v.IsNil() {
|
||||
return 0
|
||||
}
|
||||
v = v.Elem()
|
||||
}
|
||||
field := fieldByColumn(v, column)
|
||||
for field.IsValid() && field.Kind() == reflect.Pointer {
|
||||
if field.IsNil() {
|
||||
return 0
|
||||
}
|
||||
field = field.Elem()
|
||||
}
|
||||
if !field.IsValid() || !field.CanInterface() {
|
||||
return 0
|
||||
}
|
||||
n, err := asUint(field.Interface())
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// syncBelongsToMany replaces the parent's pivot rows in submitted order: an
|
||||
// explicit delete, then one bulk insert (Phase 5 join-table contract).
|
||||
func syncBelongsToMany(ctx context.Context, tx *gorm.DB, cc *CompiledController, model any, values []relationValue) error {
|
||||
|
||||
@@ -135,7 +135,7 @@ func (p *Plugin) Migrations() []*gormigrate.Migration {
|
||||
| `lagoon.DSN` | Reads `database.dsn` from config. |
|
||||
| `lagoon.Migrate` | Runs framework and plugin migrations in order. |
|
||||
| `lagoon.RollbackLast` | Rolls back the last migration of one plugin. |
|
||||
| `lagoon.Status` | Lists applied migration IDs per plugin as `lagoon.StatusRow` values. |
|
||||
| `lagoon.Status` | Lists applied migration IDs per plugin as `lagoon.StatusRow` values: one row for each plugin that implements `pact.HasMigrations`, in plugin order; a plugin with no migrations, or without a history table yet, gets a row without IDs. The framework's own sets are not listed; `migrate:status` adds them. |
|
||||
| `lagoon.DatabaseLabel` | Describes a DSN for a person as `host:port/name` (parsed with `pgconn.ParseConfig`), never the user or password; `not configured` when empty, `configured` when it does not parse. Used by the startup summaries of `serve` and `queue:work`. |
|
||||
| `lagoon.PendingMigrations` | Lists the migrations `lagoon.Migrate` would apply, as `lagoon.MigrationRef` values (`Plugin`, `ID`): the framework sets first, then each plugin in order, each in declaration order. A missing history table means the whole set is pending. Read-only; `serve` reports the count in its startup summary. |
|
||||
| `lagoon.HistoryTableName` | Returns the gormigrate history table for a plugin ID. |
|
||||
@@ -256,9 +256,9 @@ storage:
|
||||
|
||||
| Command | Flags | Description |
|
||||
|---------|-------|-------------|
|
||||
| `migrate` | `--pretend`, `--force` | Runs the framework migrations (`system_files`, the backend admin tables, `deferred_bindings` and the job queue), then each plugin's migrations in dependency order; prints `<set>: N applied` per set and `Applied N migrations`, or `Nothing to migrate`. The applied lines count what was actually applied: when a migration fails, `migrate` prints the lines for the sets applied before it, then the error, and no `Applied` line. `--pretend` prints each pending migration as `<plugin>: <id>` and `N migrations would run`, and applies nothing. In production on an interactive terminal it asks first (`--force` skips the question); off a terminal it never asks. |
|
||||
| `migrate` | `--pretend`, `--force` | Runs the framework migrations (`system_files`, the backend admin tables, `deferred_bindings` and the job queue), then each plugin's migrations in dependency order; prints `<set>: N applied` per set and `Applied N migrations`, or `Nothing to migrate`. The applied lines count what was actually applied: when a migration fails, `migrate` prints the lines for the sets applied before it, then the error, and no `Applied` line. If the history cannot be re-read after a successful run, `migrate` warns and counts every pending migration as applied (exit 0). `--pretend` prints each pending migration as `<plugin>: <id>` and `N migrations would run`, and applies nothing. In production on an interactive terminal it asks first (`--force` skips the question); off a terminal it never asks. |
|
||||
| `migrate:rollback` | `--plugin <id>`, `--pretend`, `--force` | Rolls back the last applied migration of the given plugin; without the flag, of the last activated plugin that has migrations. Prints `Rolled back <id> of <plugin>`; `--pretend` names the migration and changes nothing. Production confirmation as for `migrate`. |
|
||||
| `migrate:status` | `--json` | Prints a table of plugin, history table and applied migration IDs. `--json` prints a JSON array of every declared migration of those plugins: `plugin`, `table`, `id`, `applied`. |
|
||||
| `migrate:status` | `--json` | Prints a table of the framework's own migration sets and every plugin with migrations, with history table and applied migration IDs. `--json` prints a JSON array of every declared migration of those sets: `plugin`, `table`, `id`, `applied`. |
|
||||
| `deferred:purge` | `--days <n>`, `--pretend`, `--force` | Deletes deferred bindings older than `n` days (default `database.deferred_bindings.purge_days`, else 5), with the unattached uploads they hold (blobs after the commit) and the records created under deferral, and prints the counts. Records that were only linked are kept. Model types come from every plugin's `pact.HasModels`; the uploads bucket from the published one or `storage.uploads.bucket_url`. `--pretend` counts the bindings past the cut-off and deletes nothing; production confirmation as for `migrate`. |
|
||||
| `key:generate` | `--json` | Prints a fresh base64 32-byte key for `app.key`; writes nothing. Under `-q` it prints only the key; `--json` prints `{"key": "..."}`. |
|
||||
|
||||
|
||||
@@ -174,11 +174,22 @@ asks.`,
|
||||
// Report what Migrate actually applied: the pending
|
||||
// migrations before it ran that are no longer pending,
|
||||
// also when it failed partway.
|
||||
applied := pending
|
||||
after, err := PendingMigrations(gdb, plugins)
|
||||
if err != nil {
|
||||
switch {
|
||||
case err == nil:
|
||||
applied = appliedSince(pending, after)
|
||||
case migrateErr != nil:
|
||||
return errors.Join(migrateErr, err)
|
||||
default:
|
||||
// Migrate succeeded, so every pending migration is
|
||||
// applied and the schema change is in place; only
|
||||
// the read for the report failed. Failing the run
|
||||
// now would make a deploy roll back or alert over
|
||||
// a database that is up to date, so warn and count
|
||||
// the pending list instead.
|
||||
out.Warning("Could not re-read the migration history: " + bonfire.Sanitize(err.Error()))
|
||||
}
|
||||
applied := appliedSince(pending, after)
|
||||
counts := map[string]int{}
|
||||
var order []string
|
||||
for _, ref := range applied {
|
||||
@@ -257,9 +268,10 @@ question.`,
|
||||
{
|
||||
Name: "migrate:status",
|
||||
Description: "Show per-plugin migration history",
|
||||
Long: `Lists each plugin with migrations, its history table and the migration IDs
|
||||
applied. --json prints every declared migration of those plugins with its
|
||||
table and whether it is applied, for scripts.`,
|
||||
Long: `Lists the framework's own migration sets (uploads, backend admin, deferred
|
||||
bindings, job queue) and each plugin with migrations, with its history table
|
||||
and the migration IDs applied. --json prints every declared migration of those
|
||||
sets with its table and whether it is applied, for scripts.`,
|
||||
Flags: []bonfire.Flag{jsonFlag},
|
||||
Examples: []bonfire.Example{
|
||||
{Command: "migrate:status", Description: "Show the applied migrations per plugin"},
|
||||
@@ -267,7 +279,7 @@ table and whether it is applied, for scripts.`,
|
||||
},
|
||||
Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error {
|
||||
return withDB(ctx, app, func(gdb *gorm.DB) error {
|
||||
rows, err := Status(gdb, plugins)
|
||||
rows, err := setStatus(gdb, plugins)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -137,27 +137,58 @@ type StatusRow struct {
|
||||
IDs []string
|
||||
}
|
||||
|
||||
// Status reads each plugin history table.
|
||||
// Status reads each plugin history table: one row per plugin that
|
||||
// implements pact.HasMigrations, in plugin order, a plugin with no
|
||||
// migrations included. The framework's own sets are not listed.
|
||||
func Status(gdb *gorm.DB, plugins []party.Plugin) ([]StatusRow, error) {
|
||||
if gdb == nil {
|
||||
return nil, fmt.Errorf("lagoon: gorm db is nil")
|
||||
}
|
||||
var rows []StatusRow
|
||||
var ids []string
|
||||
for _, p := range plugins {
|
||||
if _, ok := p.(pact.HasMigrations); !ok {
|
||||
continue
|
||||
if _, ok := p.(pact.HasMigrations); ok {
|
||||
ids = append(ids, p.ID())
|
||||
}
|
||||
table, err := HistoryTableName(p.ID())
|
||||
}
|
||||
return historyRows(gdb, ids)
|
||||
}
|
||||
|
||||
// setStatus reads the history table of every set Migrate applies, in its
|
||||
// order (migrationSets): the framework's own sets first, then each plugin
|
||||
// with at least one migration. migrate:status prints it, so that it agrees
|
||||
// with migrate --pretend.
|
||||
func setStatus(gdb *gorm.DB, plugins []party.Plugin) ([]StatusRow, error) {
|
||||
if gdb == nil {
|
||||
return nil, fmt.Errorf("lagoon: gorm db is nil")
|
||||
}
|
||||
sets, err := migrationSets(gdb, plugins)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ids := make([]string, 0, len(sets))
|
||||
for _, s := range sets {
|
||||
ids = append(ids, s.id)
|
||||
}
|
||||
return historyRows(gdb, ids)
|
||||
}
|
||||
|
||||
// historyRows reads the applied IDs of each history ID's table, sorted. A
|
||||
// missing history table gives a row without IDs (nil), an empty one a row
|
||||
// with an empty slice.
|
||||
func historyRows(gdb *gorm.DB, ids []string) ([]StatusRow, error) {
|
||||
var rows []StatusRow
|
||||
for _, id := range ids {
|
||||
table, err := HistoryTableName(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
row := StatusRow{Plugin: p.ID(), Table: table}
|
||||
row := StatusRow{Plugin: id, Table: table}
|
||||
if !gdb.Migrator().HasTable(table) {
|
||||
rows = append(rows, row)
|
||||
continue
|
||||
}
|
||||
if err := gdb.Table(table).Order("id").Pluck("id", &row.IDs).Error; err != nil {
|
||||
return nil, fmt.Errorf("lagoon: status %s: %w", p.ID(), err)
|
||||
return nil, fmt.Errorf("lagoon: status %s: %w", id, err)
|
||||
}
|
||||
if row.IDs == nil {
|
||||
row.IDs = []string{}
|
||||
|
||||
@@ -5,11 +5,14 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/bonfire"
|
||||
"git.golem15.com/golem15/summercms/modules/lagoon/attach"
|
||||
"git.golem15.com/golem15/summercms/modules/party"
|
||||
"github.com/go-gormigrate/gormigrate/v2"
|
||||
"gorm.io/gorm"
|
||||
@@ -259,8 +262,10 @@ func TestDeferredPurgePretend(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestMigrateStatusJSON: migrate:status --json lists every declared
|
||||
// migration of the plugins with its table and applied flag, under -q too.
|
||||
// TestMigrateStatusJSON (WR-12): migrate:status --json lists every declared
|
||||
// migration of the framework sets and the plugins with its table and
|
||||
// applied flag, under -q too: its pending entries are exactly what
|
||||
// PendingMigrations returns, a pending framework migration included.
|
||||
func TestMigrateStatusJSON(t *testing.T) {
|
||||
db, dsn := dedicatedDB(t, "lagoon_status_json")
|
||||
gdb, err := Use(t.Context(), db)
|
||||
@@ -272,6 +277,20 @@ func TestMigrateStatusJSON(t *testing.T) {
|
||||
if err := Migrate(gdb, []party.Plugin{alpha}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// WR-12: a pending framework migration is listed too. Forget the newest
|
||||
// attach migration so that it is pending again.
|
||||
attachTable, err := HistoryTableName("summercms.attach")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
forgotten := attach.Migrations[len(attach.Migrations)-1].ID
|
||||
if err := gdb.Exec(`DELETE FROM `+attachTable+` WHERE id = ?`, forgotten).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pending, err := PendingMigrations(gdb, plugins)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, args := range [][]string{{"migrate:status", "--json"}, {"migrate:status", "--json", "-q"}} {
|
||||
out, errOut, code, _ := runLagoon(t, "development", dsn, plugins, args...)
|
||||
if code != bonfire.ExitOK {
|
||||
@@ -281,18 +300,86 @@ func TestMigrateStatusJSON(t *testing.T) {
|
||||
if err := json.Unmarshal([]byte(out), &list); err != nil {
|
||||
t.Fatalf("%v: not JSON: %v\n%s", args, err, out)
|
||||
}
|
||||
var unapplied, demo []statusJSON
|
||||
for _, e := range list {
|
||||
if !e.Applied {
|
||||
unapplied = append(unapplied, e)
|
||||
}
|
||||
if strings.HasPrefix(e.Plugin, "demo.") {
|
||||
demo = append(demo, e)
|
||||
}
|
||||
}
|
||||
var wantUnapplied []statusJSON
|
||||
for _, ref := range pending {
|
||||
table, err := HistoryTableName(ref.Plugin)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wantUnapplied = append(wantUnapplied, statusJSON{Plugin: ref.Plugin, Table: table, ID: ref.ID})
|
||||
}
|
||||
if fmt.Sprint(unapplied) != fmt.Sprint(wantUnapplied) {
|
||||
t.Errorf("%v: pending entries = %+v, want PendingMigrations' %+v", args, unapplied, wantUnapplied)
|
||||
}
|
||||
if !slices.Contains(list, statusJSON{Plugin: "summercms.attach", Table: attachTable, ID: forgotten}) {
|
||||
t.Errorf("%v: the pending framework migration %s is not listed:\n%s", args, forgotten, out)
|
||||
}
|
||||
want := []statusJSON{
|
||||
{Plugin: "demo.alpha", Table: "summer_migrations_demo_alpha", ID: "202610210001_create_pending_alpha", Applied: true},
|
||||
{Plugin: "demo.beta", Table: "summer_migrations_demo_beta", ID: "202610210001_create_pending_beta"},
|
||||
{Plugin: "demo.beta", Table: "summer_migrations_demo_beta", ID: "202610210002_seed_pending_beta"},
|
||||
}
|
||||
if fmt.Sprint(list) != fmt.Sprint(want) {
|
||||
t.Errorf("%v: status = %+v, want %+v", args, list, want)
|
||||
if fmt.Sprint(demo) != fmt.Sprint(want) {
|
||||
t.Errorf("%v: plugin status = %+v, want %+v", args, demo, want)
|
||||
}
|
||||
if !strings.Contains(out, `"applied": false`) {
|
||||
t.Errorf("%v: pending entries lack \"applied\": false:\n%s", args, out)
|
||||
}
|
||||
}
|
||||
|
||||
// The table lists the same sets: the framework's own first, in the
|
||||
// order migrate applies them, then the plugins.
|
||||
out, errOut, code, _ := runLagoon(t, "development", dsn, plugins, "migrate:status")
|
||||
if code != bonfire.ExitOK {
|
||||
t.Fatalf("migrate:status: exit %d\n%s", code, errOut)
|
||||
}
|
||||
var sets []string
|
||||
for _, line := range strings.Split(strings.TrimSpace(out), "\n")[1:] {
|
||||
sets = append(sets, strings.Fields(line)[0])
|
||||
}
|
||||
wantSets := []string{"summercms.attach", "summercms.cabana", DeferredHistoryID, QueueHistoryID, "demo.alpha", "demo.beta"}
|
||||
if !slices.Equal(sets, wantSets) {
|
||||
t.Errorf("migrate:status sets = %q, want %q:\n%s", sets, wantSets, out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestStatusPluginRows: Status lists the plugins only, never the
|
||||
// framework's own sets (migrate:status adds those itself). Every plugin
|
||||
// that implements pact.HasMigrations gets a row in plugin order, one with
|
||||
// no migrations and one whose history table does not exist yet included;
|
||||
// a plugin without HasMigrations gets none.
|
||||
func TestStatusPluginRows(t *testing.T) {
|
||||
db, _ := dedicatedDB(t, "lagoon_status_plugin_rows")
|
||||
gdb, err := Use(t.Context(), db)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
alpha, beta := pendingPlugins()
|
||||
empty := migPlugin{id: "demo.empty"}
|
||||
if err := Migrate(gdb, []party.Plugin{alpha}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rows, err := Status(gdb, []party.Plugin{alpha, empty, stubPlugin{id: "demo.plain"}, beta})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := []StatusRow{
|
||||
{Plugin: "demo.alpha", Table: "summer_migrations_demo_alpha", IDs: []string{"202610210001_create_pending_alpha"}},
|
||||
{Plugin: "demo.empty", Table: "summer_migrations_demo_empty"},
|
||||
{Plugin: "demo.beta", Table: "summer_migrations_demo_beta"},
|
||||
}
|
||||
if !reflect.DeepEqual(rows, want) {
|
||||
t.Fatalf("Status = %+v, want %+v", rows, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMigrateAppliedLines (WR-07): migrate's "N applied" lines count what
|
||||
@@ -350,3 +437,54 @@ func TestMigrateAppliedLines(t *testing.T) {
|
||||
t.Errorf("third migrate:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMigrateRereadFailureWarns (WR-13): when Migrate succeeds but reading
|
||||
// the history afterwards fails, migrate warns, reports every migration that
|
||||
// was pending as applied and exits 0: the schema change is in place.
|
||||
func TestMigrateRereadFailureWarns(t *testing.T) {
|
||||
db, dsn := dedicatedDB(t, "lagoon_reread_warns")
|
||||
gdb, err := Use(t.Context(), db)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
firstHistory, err := HistoryTableName("demo.rereadfirst")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
first := migPlugin{id: "demo.rereadfirst", migrations: []*gormigrate.Migration{{
|
||||
ID: "202610220001_create_reread_first",
|
||||
Migrate: func(tx *gorm.DB) error { return tx.Exec(`CREATE TABLE reread_first (id BIGSERIAL PRIMARY KEY)`).Error },
|
||||
Rollback: func(tx *gorm.DB) error { return tx.Exec(`DROP TABLE IF EXISTS reread_first`).Error },
|
||||
}}}
|
||||
// The last migration breaks the first plugin's history table, so the
|
||||
// read after a successful Migrate fails.
|
||||
last := migPlugin{id: "demo.rereadlast", migrations: []*gormigrate.Migration{{
|
||||
ID: "202610220002_break_reread_history",
|
||||
Migrate: func(tx *gorm.DB) error {
|
||||
return tx.Exec(`ALTER TABLE ` + firstHistory + ` RENAME COLUMN id TO id_gone`).Error
|
||||
},
|
||||
Rollback: func(tx *gorm.DB) error {
|
||||
return tx.Exec(`ALTER TABLE ` + firstHistory + ` RENAME COLUMN id_gone TO id`).Error
|
||||
},
|
||||
}}}
|
||||
plugins := []party.Plugin{first, last}
|
||||
before, err := PendingMigrations(gdb, plugins)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
out, errOut, code, _ := runLagoon(t, "local", dsn, plugins, "migrate")
|
||||
if code != bonfire.ExitOK {
|
||||
t.Fatalf("migrate: exit %d, want 0\n%s%s", code, out, errOut)
|
||||
}
|
||||
for _, want := range []string{
|
||||
"Could not re-read the migration history",
|
||||
"demo.rereadfirst: 1 applied",
|
||||
"demo.rereadlast: 1 applied",
|
||||
fmt.Sprintf("Applied %d migrations", len(before)),
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("migrate output lacks %q:\n%s%s", want, out, errOut)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -129,6 +129,8 @@ BONFIRE_TESTS=(
|
||||
TestLiveViewRestoresTerminalOnSignal TestWithExitCode
|
||||
TestWrongDirectoryMasksSecrets TestSanitize TestRenderErrorStripsControl
|
||||
TestProcessViewStripsControl TestTableStripsControl
|
||||
# quick 261022-4z8 (re-review fixes): IN-15, WR-09.
|
||||
TestNestedProcessBuildsNoProgram TestPrintAboveAfterCancel
|
||||
)
|
||||
DOCSITE_TESTS=(
|
||||
# Plan 04: the docs checker reads past the global options.
|
||||
@@ -180,6 +182,8 @@ SUMMER_TESTS=(
|
||||
TestDelegateExamples
|
||||
# Plan 05 (review fixes): delegate exit codes and streams (CR-02, CR-03).
|
||||
TestDelegateExitCodePassthrough TestAppStreams TestDelegateStreams
|
||||
# quick 261022-4z8 (re-review fixes): WR-10.
|
||||
TestRunAppCmdMasksSecrets
|
||||
)
|
||||
|
||||
# hello stage.
|
||||
@@ -195,6 +199,10 @@ DOCKER_LAGOON_TESTS=(
|
||||
TestDeferredPurgePretend TestMigrateStatusJSON
|
||||
# Plan 05 (review fixes): applied lines from one set list (WR-07).
|
||||
TestMigrateAppliedLines
|
||||
# quick 261022-4z8 (re-review fixes): WR-13.
|
||||
TestMigrateRereadFailureWarns
|
||||
# quick 261022-4z8: WR-12 rework, lagoon.Status stays plugin-only.
|
||||
TestStatusPluginRows
|
||||
)
|
||||
DOCKER_CABANA_TESTS=(TestTokenCreateJSON TestTokenListJSON TestTokenCreateQuietKeepsToken)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user