Compare commits
46 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b1f54976aa | ||
|
|
3caaf5f894 | ||
|
|
16a8520bb5 | ||
|
|
9dac7b8f67 | ||
|
|
c4c91f0f6b | ||
|
|
d0af79b496 | ||
|
|
89f1ab2c44 | ||
|
|
93e9a8a45c | ||
|
|
392dd3c83f | ||
|
|
eed24b8ad1 | ||
|
|
9f56dc7eff | ||
|
|
e2c8852b4c | ||
|
|
e0640220f2 | ||
|
|
90582522c7 | ||
|
|
0d117a3e57 | ||
|
|
e7637a0271 | ||
|
|
55dffb5b63 | ||
|
|
612d0c6dcb | ||
|
|
cfd110eddb | ||
|
|
d0888d7576 | ||
|
|
6695ab422a | ||
|
|
147ef2494e | ||
|
|
ca5b2963a5 | ||
|
|
cc7386d22e | ||
|
|
c33ebf3b95 | ||
|
|
7bc955a0c2 | ||
|
|
86fcd5d4fd | ||
|
|
2f7d95626e | ||
|
|
a2ec923439 | ||
|
|
861e7efb4f | ||
|
|
d94f804ddd | ||
|
|
498f4b4f3a | ||
|
|
05349d4b6d | ||
|
|
29a5fc1608 | ||
|
|
fa047559cf | ||
|
|
5429d46751 | ||
|
|
b3ca53cbc1 | ||
|
|
19fffd34d1 | ||
|
|
286603db8d | ||
|
|
c972ebd0cf | ||
|
|
ece31c3426 | ||
|
|
40a2a6584c | ||
|
|
b6b194e5eb | ||
|
|
a714bb4629 | ||
|
|
7554241e9d | ||
|
|
fa25b4e000 |
@@ -30,6 +30,10 @@ Decimal phases appear between their surrounding integers in numeric order.
|
||||
- [ ] **Phase 14.2: Local Fonoteka frontend on local SummerCMS backend** - vue-fonoteka-app locally against fonoteka.go
|
||||
- [ ] **Phase 15: Journal plugin** - Port Golem15.Journal to sm-journal-plugin
|
||||
- [ ] **Phase 16: grzybyfunkcjonalne.pl on reusable blog views** - First blog on SummerCMS
|
||||
- [ ] **Phase 17: Blog companions** - Port sitemap, menu and FAQ as shared plugins, with a framework menu-item type registry
|
||||
- [ ] **Phase 18: Payment Gateway** - Port Golem15.PaymentGateway and its Stripe operator, with Horoskopia as the acceptance consumer
|
||||
- [ ] **Phase 19: Chat and ChatChannels** - Port Golem15.Chat and Golem15.ChatChannels on lighthouse
|
||||
- [ ] **Phase 19.1: Chat satellites** - Port chatbots, chatvideo, forum, herald and userfriends
|
||||
- [ ] **Phase 20: Płytarium cutover** - Parity harness green, both real clients run unchanged in production
|
||||
|
||||
## Phase Details
|
||||
@@ -825,10 +829,21 @@ Plans:
|
||||
3. Failures against Go are visible (no silent fall-through to PHP); the local stack does not require the production origin.
|
||||
4. The new glue (env, proxy, CORS, run docs) has tests or a fail-closed check script, delivered in the phase's last plan.
|
||||
|
||||
**Plans:** 0 plans
|
||||
**Plans:** 4 plans
|
||||
|
||||
Plans:
|
||||
- [ ] TBD (run $gsd-plan-phase 14.2 to break down)
|
||||
|
||||
**Wave 1**
|
||||
- [ ] 14.2-01-PLAN.md — Go glue: `fonoteka:dev-seed`, plugin commands open their own DB (G1), dev-only marker route, framework `/storage` mount and opt-in access log with docs
|
||||
|
||||
**Wave 2** *(blocked on Wave 1 completion)*
|
||||
- [ ] 14.2-02-PLAN.md — `scripts/dev-stack.sh` (start, fail-closed guard, logs, e2e verdict), `.env.dev.example`, `.gitignore`, run docs
|
||||
|
||||
**Wave 3** *(blocked on Wave 2 completion)*
|
||||
- [ ] 14.2-03-PLAN.md — Nuxt `E2E_BACKEND=go` switch, realtime rewrite, edit/rate/cover, search and wishlist specs on Go and PHP, Centrifugo origin step, manual UAT session
|
||||
|
||||
**Wave 4** *(blocked on Wave 3 completion)*
|
||||
- [ ] 14.2-04-PLAN.md — Unit tests (seed, commands, marker, uploads mount, access log, switch contract) and `scripts/check-phase14.2.sh`
|
||||
|
||||
### Phase 14.2.1: Translate plugin (INSERTED)
|
||||
|
||||
@@ -869,6 +884,37 @@ Plans:
|
||||
- Nested locale writes are not dropped on the admin form path and on relation-child saves
|
||||
- The SPA lists every enabled locale, switches all ML controls together, and sends `Record<string,string>`
|
||||
|
||||
### Phase 14.3: One-command app scaffolding: summer new and summercms.io/new bootstrap script (INSERTED)
|
||||
|
||||
**Goal:** Starting a SummerCMS app takes one command. `summer new <name>` writes a ready-to-run `sm-<name>-app` next to the framework checkout, and `curl -fsSL https://summercms.io/new | bash -s -- <name>` checks the prerequisites, fetches the framework, installs the `summer` CLI and runs it. Ships as framework tag `v0.1.13`.
|
||||
**Requirements**: TBD
|
||||
**Depends on:** Phase 14.2.1
|
||||
**Repos:** `summercms.go` (generator, bootstrap script, docs); `sm-summercmsio-app` / `sm-summercmsio-plugin` (serve `/new` as `text/plain`)
|
||||
**Success Criteria** (what must be TRUE):
|
||||
|
||||
1. `summer new <name>` validates the name (lower-case letters and digits, starting with a letter), refuses an existing directory, and writes `go.mod` (module `git.golem15.com/golem15/sm-<name>-app`, go 1.27.0, relative `replace` to the framework found via `--framework`, then sibling detection by module path, else a clear error), `summer.yaml`, a starter plugin `golem15.<name>` through the `make:plugin`/`plugin:add` code paths with `go.work` (optional via `--no-plugin` if an empty plugin list builds), `config/` complete for `serve` and `route:list` (incl. `http.yaml` `body_limits`, app key, storage bucket defaults), `.env.example` and `.env` with a fresh app key, `.gitignore`, `compose.yaml` (PostgreSQL 16 matching `.env`), `CLAUDE.md`/`AGENTS.md`, and a first git commit (skippable with `--no-git`).
|
||||
2. The generated app works with no edits: `summer build`, `summer migrate` and `summer serve` succeed and a starter-plugin route answers over HTTP.
|
||||
3. The bootstrap script (wrapped in `main()`) checks Go 1.27+ and Docker, clones or fast-forwards the framework, runs `go install ./cmd/summer` with a PATH hint, runs `summer new`, and prints next steps; summercms.io serves it at `/new` as `text/plain`.
|
||||
4. In a clean `node:24-bookworm`-style container with Go 1.27, Docker, git and jq, `curl … | bash -s -- demo` → `cd sm-demo-app && docker compose up -d && summer build && summer migrate && summer serve` ends with the starter route answering.
|
||||
5. `docs/setup/installation.md` leads with the one-liner, `go-modules-and-workspaces.md` and the console docs cover `summer new`, and `llms.txt`/`llms-full.txt` include the changes; docs checks pass.
|
||||
6. Generator Go tests (golden output plus a real build of the scaffold in a temp dir) are the last plan.
|
||||
|
||||
**Plans:** 4/4 plans executed
|
||||
|
||||
Plans:
|
||||
|
||||
**Wave 1**
|
||||
- [x] 14.3-01-PLAN.md — `summer new` generator: starter plugin via make:plugin/plugin:add, relative go.work, config, .env + lagoon.GenerateAppKey, compose, CLAUDE.md/AGENTS.md, first commit, D-12 docker gate
|
||||
|
||||
**Wave 2** *(blocked on Wave 1 completion)*
|
||||
- [x] 14.3-02-PLAN.md — Bootstrap script in `modules/sunrise`, `GET /new` in the site plugin, node:24-bookworm acceptance, security review
|
||||
|
||||
**Wave 3** *(blocked on Wave 2 completion)*
|
||||
- [x] 14.3-03-PLAN.md — Docs (installation one-liner first, go modules, console, llms) and landing page "Get started" with its tests
|
||||
|
||||
**Wave 4** *(blocked on Wave 3 completion)*
|
||||
- [x] 14.3-04-PLAN.md — Unit tests last: goldens, real builds, error cases, script matrix, phase gate; post-phase release checklist (tag v0.1.13, site bump, deploy are user actions)
|
||||
|
||||
### Phase 15: Journal plugin
|
||||
|
||||
**Goal:** The Golem15 Journal plugin is ported to Go as `sm-journal-plugin` and mounts in a host application the same way `sm-user-plugin` does, so a blog can run on SummerCMS without the PHP plugin.
|
||||
@@ -918,6 +964,89 @@ Plans:
|
||||
Plans:
|
||||
- [ ] TBD (run $gsd-plan-phase 16 to break down)
|
||||
|
||||
### Phase 17: Blog companions
|
||||
|
||||
**Goal:** The sitemap, menu and FAQ plugins run on SummerCMS as shared `sm-*-plugin` repos, so a blog site can manage its menus, FAQs and sitemap in the admin again instead of hardcoding them in the Nuxt app.
|
||||
**Mode:** mvp
|
||||
**Depends on:** Phase 15, Phase 16
|
||||
**Repos:** `sm-sitemap-plugin`, `sm-menu-plugin`, `sm-faq-plugin` (new, under `git.golem15.com/golem15/`, see `.planning/notes/core-plugins-own-repos.md`); `summercms.go` for the menu-item type registry; `sm-journal-plugin` for an additive registration of its item types; `sm-grzybyfunkcjonalne-app` to move its FAQ and menu into the plugins
|
||||
**Port sources:** `Golem15.Sitemap` at `/media/nvme/dev/golem15/fonoteka/plugins/golem15/sitemap`; `Golem15.Menu` and `Golem15.Faq` at `/media/nvme/dev/golem15/grzybyfunkcjonalne.pl/plugins/golem15/{menu,faq}`. Todos: `sitemap-plugin-port.md`, `menu-plugin-port.md`, `faq-plugin-port.md`. Inventory: `.planning/notes/core-plugin-port-inventory.md`.
|
||||
**Requirements**: TBD
|
||||
**Success Criteria** (what must be TRUE):
|
||||
|
||||
1. The three plugins port their PHP schema, models, admin screens and import commands 1:1, each in its own repo with a README.
|
||||
2. The framework has a typed successor of the `pages.menuitem.listTypes`, `getTypeInfo` and `resolveItem` events. Journal registers its item types through it, and both menu and sitemap consume it.
|
||||
3. `sitemap.xml` and `sitemap.xsl` match the PHP output, including the `frontend_url` domain rewrite and hreflang. Menu and FAQ expose public read APIs that a Nuxt layer can consume.
|
||||
4. grzybyfunkcjonalne.pl reads its menu and FAQ from the plugins instead of app config.
|
||||
5. The new code has unit tests, delivered in the phase's last plan.
|
||||
|
||||
**Plans:** 0 plans
|
||||
|
||||
Plans:
|
||||
- [ ] TBD (run $gsd-plan-phase 17 to break down)
|
||||
|
||||
### Phase 18: Payment Gateway
|
||||
|
||||
**Goal:** `Golem15.PaymentGateway` and its Stripe operator run on SummerCMS as shared plugins whose contracts let Horoskopia's horoscope plugin create orders, take payments and react to payment events, as it does on PHP.
|
||||
**Mode:** mvp
|
||||
**Depends on:** Phase 15
|
||||
**Repos:** `sm-paymentgateway-plugin` and `sm-pgstripe-plugin` (new, under `git.golem15.com/golem15/`); `summercms.go` only if a framework feature is missing
|
||||
**Port sources:** `Golem15.PaymentGateway` v2.6.0 and `Golem15.PGStripe`, at `/media/nvme/dev/golem15/horoskopia.eu/plugins/golem15/{paymentgateway,pgstripe}` (starter submodule `wn-paymentgateway-plugin`, branch `upgrade`). Acceptance consumer: Horoskopia's `horoscope` plugin, which uses `Order`, `OrderFactory`, `Cart`/`Item`/`Details`, `Settings` and `Payment`.
|
||||
**Splits:** Stripe subscriptions (`Golem15.PaymentGatewaySubscriptions`, 5.5k LOC, used by Horoskopia and four other projects) and any further Stripe add-ons go into decimal phases 18.1, 18.2 and so on if the Phase 18 discussion decides they do not fit.
|
||||
**Requirements**: TBD
|
||||
**Success Criteria** (what must be TRUE):
|
||||
|
||||
1. Payment (Created, Sent, Accepted, Rejected, Canceled, Refunded) and Order (12 states, all refund paths) state machines port with the PHP transitions, and use exact decimal money.
|
||||
2. Payment operators register at build time through a Go interface that succeeds `core/Operator` and the `paymentgateway.booted` config push. Cash, manual transfer and Stripe operators are implemented. The `_paymentgateway/{operator}/{mode?}/{result?}` notification route keeps its PHP behavior.
|
||||
3. The Order and Payment events, their mail templates, the admin screens (orders, payments, settings) and the scheduled cancel/finish/update commands are ported. Plugin contracts (tables, event names, config keys) stay non-breaking.
|
||||
4. A host test proves the order-create, Stripe-pay and order-paid flow that Horoskopia relies on.
|
||||
5. The new code has unit tests, delivered in the phase's last plan.
|
||||
|
||||
**Plans:** 0 plans
|
||||
|
||||
Plans:
|
||||
- [ ] TBD (run $gsd-plan-phase 18 to break down)
|
||||
|
||||
### Phase 19: Chat and ChatChannels
|
||||
|
||||
**Goal:** `Golem15.Chat` and `Golem15.ChatChannels` run on SummerCMS as shared plugins, with realtime delivery through `lighthouse`, ready for the Wavepath port.
|
||||
**Mode:** mvp
|
||||
**Depends on:** Phase 11
|
||||
**Repos:** `sm-chat-plugin` and `sm-chatchannels-plugin` (new, under `git.golem15.com/golem15/`); `summercms.go` only if `lighthouse` is missing a feature
|
||||
**Port sources:** `/media/nvme/dev/jz/wavepath.org/plugins/golem15/{chat,chatchannels}`. Wavepath is the heaviest user. Check its copy (with `root_id` threading) against the starter's before choosing the source.
|
||||
**Requirements**: TBD
|
||||
**Success Criteria** (what must be TRUE):
|
||||
|
||||
1. The chat schema, models, adapters (context-agnostic groups), jobs, commands and routes port 1:1. ChatChannels plugs into chat through the same adapter contract.
|
||||
2. Realtime broadcasts and channel authorization go through `lighthouse` (publisher, `Authorizer` per namespace). No WebSockets plugin is ported.
|
||||
3. Public API payloads match PHP for the routes Wavepath's clients call.
|
||||
4. The new code has unit tests, delivered in the phase's last plan.
|
||||
|
||||
**Plans:** 0 plans
|
||||
|
||||
Plans:
|
||||
- [ ] TBD (run $gsd-plan-phase 19 to break down)
|
||||
|
||||
### Phase 19.1: Chat satellites
|
||||
|
||||
**Goal:** The remaining shared plugins that Wavepath's app plugin requires run on SummerCMS: chatbots, chatvideo, forum, herald and userfriends.
|
||||
**Mode:** mvp
|
||||
**Depends on:** Phase 19
|
||||
**Repos:** one `sm-*-plugin` repo each, under `git.golem15.com/golem15/`
|
||||
**Port sources:** `/media/nvme/dev/jz/wavepath.org/plugins/golem15/{chatbots,chatvideo,forum,herald,userfriends}`
|
||||
**Requirements**: TBD
|
||||
**Success Criteria** (what must be TRUE):
|
||||
|
||||
1. Each plugin ports its schema, models, routes and events 1:1 against its PHP original.
|
||||
2. Herald (Web Push) has a decision note naming its VAPID/Web Push dependency before code lands. Chatbots is wired to `sm-golem-plugin` where the PHP original calls Golem.
|
||||
3. Userfriends is checked against fonoteka first, so there is one shared copy.
|
||||
4. The new code has unit tests, delivered in the phase's last plan.
|
||||
|
||||
**Plans:** 0 plans
|
||||
|
||||
Plans:
|
||||
- [ ] TBD (run $gsd-plan-phase 19.1 to break down)
|
||||
|
||||
### Phase 20: Płytarium cutover
|
||||
|
||||
**Goal**: The parity harness is green on every manifest route and `vue-fonoteka-app` and `fonoteka-mcp` run unchanged against the Go backend in daily use in production — the Płytarium definition of done.
|
||||
@@ -937,7 +1066,7 @@ Plans:
|
||||
## Progress
|
||||
|
||||
**Execution Order:**
|
||||
Phases execute in numeric order: 1 → 2 → 3 → 4 → 5 → 6 → 7 → 8 → 9 → 10 → 11 → 12 → 13 → 14 → 14.1 → 14.2 → 15 → 16 → 20
|
||||
Phases execute in numeric order: 1 → 2 → 3 → 4 → 5 → 6 → 7 → 8 → 9 → 10 → 11 → 12 → 13 → 14 → 14.1 → 14.2 → 14.2.1 → 14.3 → 15 → 16 → 17 → 18 → 19 → 19.1 → 20
|
||||
(Phase 2 depends on Phase 1's command kernel; the two are no longer parallel.)
|
||||
|
||||
| Phase | Plans Complete | Status | Completed |
|
||||
@@ -960,8 +1089,13 @@ Phases execute in numeric order: 1 → 2 → 3 → 4 → 5 → 6 → 7 → 8 →
|
||||
| 14. Domain jobs and external integrations | 6/6 | In Progress| |
|
||||
| 14.1. OAuth identities and fonoteka me routes (INSERTED) | 2/2 | Complete | 2026-10-05 |
|
||||
| 14.2. Local Fonoteka frontend on local SummerCMS backend (INSERTED) | 0/TBD | Not started | - |
|
||||
| 14.3. One-command app scaffolding (INSERTED) | 4/4 | In Progress| |
|
||||
| 15. Journal plugin | 4/4 | Complete | 2026-10-06 |
|
||||
| 16. grzybyfunkcjonalne.pl on reusable blog views | 0/TBD | Not started | - |
|
||||
| 17. Blog companions | 0/TBD | Not started | - |
|
||||
| 18. Payment Gateway | 0/TBD | Not started | - |
|
||||
| 19. Chat and ChatChannels | 0/TBD | Not started | - |
|
||||
| 19.1. Chat satellites | 0/TBD | Not started | - |
|
||||
| 20. Płytarium cutover | 0/TBD | Not started | - |
|
||||
|
||||
## Backlog
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
---
|
||||
gsd_state_version: "1.0"
|
||||
milestone: v1.0
|
||||
current_phase: 15
|
||||
current_phase_name: Journal plugin
|
||||
status: executing
|
||||
stopped_at: Completed 15-04-PLAN.md
|
||||
last_updated: "2026-10-06T21:58:51.425Z"
|
||||
last_activity: 2026-10-06
|
||||
last_activity_desc: Completed quick task 261006-s0v (remove ML copy-from locale UI)
|
||||
state_head: 4a94380b5fe947b657dadd250e1960865ca3285e
|
||||
current_phase: "14.3"
|
||||
current_phase_name: "One-command app scaffolding: summer new and summercms.io/new bootstrap script"
|
||||
status: verifying
|
||||
stopped_at: Completed 14.3-04-PLAN.md
|
||||
last_updated: "2026-10-08T10:32:08.732Z"
|
||||
last_activity: 2026-10-08
|
||||
last_activity_desc: Phase 14.3 execution started
|
||||
state_head: 9dac7b8f67e4345c93e778b17c50ab6fd399d343
|
||||
progress:
|
||||
total_phases: 26
|
||||
completed_phases: 12
|
||||
total_plans: 135
|
||||
completed_plans: 135
|
||||
total_phases: 31
|
||||
completed_phases: 13
|
||||
total_plans: 143
|
||||
completed_plans: 139
|
||||
milestone_name: milestone
|
||||
---
|
||||
|
||||
@@ -24,14 +24,14 @@ milestone_name: milestone
|
||||
See: .planning/PROJECT.md (updated 2026-09-16)
|
||||
|
||||
**Core value:** An existing WinterCMS-shaped app can be ported plugin by plugin to a single Go binary without its frontend noticing: the PHP version's API contract is the acceptance test.
|
||||
**Current focus:** Phase 15 — Journal plugin
|
||||
**Current focus:** Phase 14.3 — One-command app scaffolding: summer new and summercms.io/new bootstrap script
|
||||
|
||||
## Current Position
|
||||
|
||||
Phase: 15 (Journal plugin) — EXECUTING
|
||||
Phase: 14.3 (One-command app scaffolding: summer new and summercms.io/new bootstrap script) — EXECUTING
|
||||
Plan: 4 of 4
|
||||
Status: Ready for phase verification
|
||||
Last activity: 2026-10-07 — Completed quick task 261007-p4y: toggle sizing and settings controller links
|
||||
Status: Phase complete — ready for verification
|
||||
Last activity: 2026-10-08 — Phase 14.3 execution started
|
||||
|
||||
Progress: [██████████] 100%
|
||||
|
||||
@@ -189,6 +189,10 @@ Progress: [██████████] 100%
|
||||
| Phase 15 P15-01 | 11 | 3 tasks | 31 files |
|
||||
| Phase 15 P15-02 | 75 min | 3 tasks | 39 files |
|
||||
| Phase 15 P15-03 | 80 min | 3 tasks | 22 files |
|
||||
| Phase 14.3 P01 | 13 min | 3 tasks | 17 files |
|
||||
| Phase 14.3 P02 | 11 min | 3 tasks | 11 files |
|
||||
| Phase 14.3 P03 | 9min | 3 tasks | 13 files |
|
||||
| Phase 14.3 P04 | 19min | 3 tasks | 32 files |
|
||||
|
||||
## Accumulated Context
|
||||
|
||||
@@ -210,6 +214,7 @@ Progress: [██████████] 100%
|
||||
- Phase 16 added: grzybyfunkcjonalne.pl on reusable blog views
|
||||
- Phase 20 edited: Płytarium production cutover moved here from Phase 15
|
||||
- Phase 14.2.1 inserted after Phase 14.2: sm-translate-plugin so Journal can keep translatable fields (URGENT)
|
||||
- Phase 14.3 inserted after Phase 14.2.1: One-command app scaffolding: summer new + summercms.io/new bootstrap script; ships as v0.1.13 (URGENT)
|
||||
|
||||
### Decisions
|
||||
|
||||
@@ -603,6 +608,16 @@ Recent decisions affecting current work:
|
||||
- [Phase 15]: NavigationItem has no IconSvg; journal-icon.svg is go:embed in AdminFS; user_id is WritableForeignKey and FormBeforeCreate stamps it when empty
|
||||
- [Phase 15]: 15-04 security review was executor-self-performed (no gsd-security-auditor in this Cursor session); every cited TestName was re-run. Wave-3 HTTP tests stay at plugin root; controllers/api holds clampPerPage/media path units plus EndToEnd stubs.
|
||||
- [Phase 15]: Host journal gitlink is a nested clone; bump via file:// fetch with protocol.file.allow=always. TestSearchGateOff must not skip.
|
||||
- [Phase 14.3]: bonfire accepts new as a bare command name for summer new
|
||||
- [Phase 14.3]: summer new compose.yaml publishes postgres:16 on 127.0.0.1:54320 only (user/password summer)
|
||||
- [Phase 14.3]: summer new falls back to SummerCMS <summer@localhost> only for missing git identity keys
|
||||
- [Phase 14.3]: plugin:add writes go.work use paths relative to the go.work directory
|
||||
- [Phase 14.3]: Bootstrap script lives in modules/sunrise (new.sh + Script()); served by the site plugin at GET /new as text/plain
|
||||
- [Phase 14.3]: new.sh installs summer with GOWORK=off and never deletes, resets or force-fetches; refusals for branch, dirty, moved-tag and non-fast-forward checkouts
|
||||
- [Phase 14.3]: 14.3-03: the gated terminal check stops summer serve's app binary (pkill -P) before summer, since summer serve has no signal handling
|
||||
- [Phase 14.3]: Phase 14.3 gate is scripts/check-phase14.3.sh --all: one process per stage (unit, generator, bootstrap, terminal, acceptance, security), a named PASS line per required test, goldens unchanged by the run
|
||||
- [Phase 14.3]: summer new goldens live in internal/build/testdata/new and new-no-plugin and are rewritten only with go test -update; the golden app key is base64 of bytes 0x00..0x1f
|
||||
- [Phase 14.3]: Tests that commit in temp git repos disable detached auto maintenance (maintenance.auto=false, gc.auto=0) to avoid cleanup races with git 2.55
|
||||
|
||||
### Pending Todos
|
||||
|
||||
@@ -652,6 +667,8 @@ Recent decisions affecting current work:
|
||||
| 15 | Commit accumulated planning documents properly | 2026-10-06 | c05ad09 | — |
|
||||
| 16 | Default icon shouldn't be a square but a sun. | 2026-10-06 | 4a94380 | — |
|
||||
| 261007-p4y | Switch toggle pixel sizing; settings entries linking to admin controllers; translate Locales moved to Settings | 2026-10-07 | 0b09acd | [261007-p4y-toggle-proportions-and-settings-controll](./quick/261007-p4y-toggle-proportions-and-settings-controll/) |
|
||||
| 18 | cabana list: join to-many relation column values with ", " | 2026-10-07 | fa25b4e | — |
|
||||
| 261007-qgx | Admin record heading, crumb and title from the first text or mltext field (default locale) | 2026-10-07 | a714bb4 | [261007-qgx-admin-record-heading-uses-translatable-m](./quick/261007-qgx-admin-record-heading-uses-translatable-m/) |
|
||||
|
||||
## Deferred Items
|
||||
|
||||
@@ -663,6 +680,6 @@ Items acknowledged and carried forward from previous milestone close:
|
||||
|
||||
## Session Continuity
|
||||
|
||||
Last session: 2026-10-06T17:12:00Z
|
||||
Stopped at: Completed 15-03-PLAN.md
|
||||
Last session: 2026-10-08T10:32:00.180Z
|
||||
Stopped at: Completed 14.3-04-PLAN.md
|
||||
Resume file: None
|
||||
|
||||
62
.planning/notes/core-plugin-port-inventory.md
Normal file
62
.planning/notes/core-plugin-port-inventory.md
Normal file
@@ -0,0 +1,62 @@
|
||||
---
|
||||
title: Golem15 plugin inventory and core plugin port order (Phases 17–19.1)
|
||||
date: 2026-10-07
|
||||
context: /gsd-explore session on porting the remaining core plugins after Journal
|
||||
---
|
||||
|
||||
# Golem15 plugin inventory and port order
|
||||
|
||||
A survey of every `plugins/golem15/*` copy under `/media/nvme/dev/golem15` and `/media/nvme/dev/jz/wavepath.org` on 2026-10-07. "Live" counts top-level projects. LOC is PHP without tests or vendor, measured on the largest copy.
|
||||
|
||||
## Port status
|
||||
|
||||
- **Ported:** user, translate, journal, golem, feedback. Apparatus became framework packages (`apparatus-dissolved-into-framework.md`).
|
||||
- **Not ported, covered by the framework:** websockets. `modules/lighthouse` and its `centrifugo` driver already provide the publisher, the token route, the subscribe proxy and per-namespace `Authorizer`s, which are what Wavepath's code calls (`BroadcastEventJob`, `CentrifugoClient`, `AuthorizerRegistry`, `ChannelAuthorizerInterface`).
|
||||
- **Not ported:** knobwidget and dualformfield/dualformwidget. They are about 150 LOC of form widgets each, and the admin SPA field types replace them.
|
||||
|
||||
## Shared plugins and how much each is worth porting
|
||||
|
||||
| Plugin | Live projects | LOC | Decision |
|
||||
|---|---|---|---|
|
||||
| paymentgateway + pgstripe | 19 installed, 7 real users | 18.7k + 1.9k | Phase 18 |
|
||||
| paymentgatewaysubscriptions | 5 | 5.5k | Phase 18.1 if it does not fit in 18 |
|
||||
| sitemap | 21 | 1.9k | Phase 17 |
|
||||
| menu | 8 | 1.0k | Phase 17 |
|
||||
| faq | 16 | 0.5k | Phase 17 |
|
||||
| chat | 17 installed, Wavepath is the heaviest user | 4.4k | Phase 19 |
|
||||
| chatchannels | Wavepath | 2.8k | Phase 19 |
|
||||
| chatbots, chatvideo, forum, herald, userfriends | Wavepath (userfriends also in PHP fonoteka; fonoteka.go has no port) | 0.6k–1.7k each | Phase 19.1 |
|
||||
| oauthserver | Wavepath | 5.5k | With the Wavepath port, on `wristband` |
|
||||
| newsletter | horoskopia | 2.3k | Backlog 999.1 |
|
||||
| github, quote | 16 / 13 | 3.4k / 0.8k | Not scheduled. Quote is cheap because golem and journal are ported |
|
||||
| seo, sitemanager | 6 | 4.3k / 0.9k | Not scheduled. Phase 16 replaces both with `@nuxtjs/seo` and app content |
|
||||
| blog, bloghub, ai | 3–4 | 7.5k–9.6k | Not scheduled. This is the older blog line; journal is its successor |
|
||||
|
||||
Third-party plugins in many projects: `winter/location` is in 19, `winter/blocks` in 13 and `winter/pages` in 10. Whether paymentgateway shipping or user profiles need `winter/location` is an open question.
|
||||
|
||||
## PaymentGateway facts that matter for Phase 18
|
||||
|
||||
- **Which projects really use it:** sklep.figs (shop), portal.golem15.com (invoicer), getaround.world (tourism with its own StripeConnect operator, quotify, subscriptions), quotify.pro, drzewo.net, horoskopia.eu and queststream.online. In the others it is installed only because the starter ships it.
|
||||
- **Drivers:** Stripe is the only real driver. No PayU, Przelewy24, PayPal or Tpay code exists anywhere.
|
||||
- **Operator registration:** operators extend the abstract `core/Operator` and push their class onto the config key `golem15.paymentgateway.operators` from inside the `paymentgateway.booted` event. In Go this becomes build-time registration through an interface.
|
||||
- **Webhooks:** one CSRF-exempt notification route, `_paymentgateway/{operator}/{mode?}/{result?}`. pgstripe adds `POST /webhook/stripe/subscriptions` for the subscriptions plugin.
|
||||
- **State machines and events:**
|
||||
- Payment: Created, Sent, Accepted, Rejected, Canceled, Refunded.
|
||||
- Order: 12 states and about 18 transitions, 7 of them refund paths.
|
||||
- 19 Order events and 5 Payment events drive about 26 mail templates.
|
||||
- `OrderPaid` is the event consumers listen to most.
|
||||
- **Acceptance consumer:** Horoskopia. Its `horoscope` plugin (37k LOC, 65 routes, Nuxt 4 frontend `vue-horoskopia-app`) requires PaymentGatewaySubscriptions and PGStripe. It uses `Order`, `OrderFactory`, `Cart`/`Item`/`Details`, `Settings`, `Payment`, `Subscription`, `SubscriptionPlan` and the `SubscriptionGateway` contract.
|
||||
|
||||
## Port order decided on 2026-10-07
|
||||
|
||||
1. **Phase 17, Blog companions:** sitemap, menu and FAQ, plus a framework menu-item type registry.
|
||||
2. **Phase 18, Payment Gateway:** paymentgateway and pgstripe. Subscriptions and Stripe add-ons go into 18.1, 18.2 and so on if the Phase 18 discussion splits them out.
|
||||
3. **Phase 19, Chat and ChatChannels.**
|
||||
4. **Phase 19.1, Chat satellites:** chatbots, chatvideo, forum, herald and userfriends.
|
||||
5. **Phase 20, Płytarium cutover.**
|
||||
6. **After Phase 20:** the Horoskopia port comes first, then the Wavepath port. Neither is planned yet. This stretch is about preparing the shared plugins, not migrating apps, and a stabilization phase may come first. See the seeds for both ports.
|
||||
|
||||
## Related
|
||||
|
||||
- `core-plugins-own-repos.md`: each shared plugin gets an `sm-<name>-plugin` repo
|
||||
- `../seeds/keios-port-themes-payments.md`: an earlier seed that also covers paymentgateway
|
||||
@@ -0,0 +1,318 @@
|
||||
---
|
||||
phase: 14.2-local-fonoteka-frontend-on-local-summercms-backend
|
||||
plan: 01
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
files_modified:
|
||||
- modules/surf/uploads.go
|
||||
- modules/surf/accesslog.go
|
||||
- modules/surf/accesslog_test.go
|
||||
- modules/surf/router.go
|
||||
- modules/surf/README.md
|
||||
- docs/services/storage.md
|
||||
- docs/services/logging.md
|
||||
- docs/setup/configuration.md
|
||||
- ../fonoteka.go/plugins/golem15/fonoteka/console/database.go
|
||||
- ../fonoteka.go/plugins/golem15/fonoteka/console/dev_seed.go
|
||||
- ../fonoteka.go/plugins/golem15/fonoteka/console/reindex.go
|
||||
- ../fonoteka.go/plugins/golem15/fonoteka/console/oauth_client.go
|
||||
- ../fonoteka.go/plugins/golem15/fonoteka/console/prune_notifications.go
|
||||
- ../fonoteka.go/plugins/golem15/fonoteka/plugin.go
|
||||
- ../fonoteka.go/plugins/golem15/fonoteka/dev_marker.go
|
||||
- ../fonoteka.go/plugins/golem15/fonoteka/routes.go
|
||||
- ../fonoteka.go/plugins/golem15/fonoteka/dev_stack_smoke_test.go
|
||||
- .planning/todos/pending/user-plugin-commands-open-database.md
|
||||
autonomous: true
|
||||
requirements: [QA-05]
|
||||
estimate:
|
||||
tokens: 260000
|
||||
raw_tokens: 260000
|
||||
tasks: 3
|
||||
confidence: low # uncalibrated (estimate-calibration factor 1, sample_count 0); plan count locked at 4 by D-22, do not split; see "Execution guidance (context budget)"
|
||||
must_haves:
|
||||
truths:
|
||||
- "Per D-01, D-02 and D-22, `fonoteka fonoteka:dev-seed` (the D-02 seed; the plugin-namespaced name is the planner's naming choice D-02 allows) runs after `fonoteka migrate` in a fresh process with no pre-published database and creates an activated user, an owned collection persisted as the user's context, three albums, one wishlist entry, one rating on the first album, a personal API token with the configured `inv_` prefix, and turns `search_use_typesense` on."
|
||||
- "Per D-02 (edge: idempotency), a second `fonoteka:dev-seed` run with the same inputs adds no row to `users`, `golem15_fonoteka_collections`, `golem15_fonoteka_albums`, `system_files`, `golem15_fonoteka_album_ratings` or the API token table, and prints `unchanged` for every entity."
|
||||
- "Per D-02 (edge: boundary), the seed runs only when the app environment is exactly `local` or `dev`; `production` (what an unset SUMMER_ENV resolves to), `development`, `testing`, `staging` and an empty value are refused before any database connection is opened."
|
||||
- "Per D-03, every seeded album carries one generated PNG cover attached through `classes.StoreAlbumPhoto` under the `storage/app/uploads/public` bucket, and the seed prints each cover's public `/storage/app/uploads/public/...` URL on both created and unchanged runs."
|
||||
- "Per D-04 (edge: empty), the seeded credentials are `E2E_EMAIL` / `E2E_PASSWORD` and fall back to `demo@example.com` / `changeme` when either is unset or empty; the password is never printed."
|
||||
- "Per D-19, the framework key `storage.uploads.serve` (default false) mounts `attach.StaticHandlerPublic` at `storage.uploads.public_path_prefix` plus `/{path...}` from the surf router with `X-Content-Type-Options: nosniff`; a seeded cover URL answers 200 `image/png` from the assembled router, and with the key off the route does not exist. The surf README, docs/services/storage.md and docs/setup/configuration.md describe the key in the same change."
|
||||
- "Per D-09 and D-10, `GET /_fonoteka/api/v1/_dev/backend` answers 200 `{\"backend\":\"summercms\",\"app\":\"fonoteka\",\"env\":\"local\"}` with `Cache-Control: no-store` only when the environment is `local` or `dev`, is absent otherwise, is not listed in `parity/manifest.yaml`, and `TestParityCorpus` still prints `recorded 175/175 passing 175 failing 0 unrecorded 0 pending 0`."
|
||||
- "Per D-05 and D-22 (research G1), `fonoteka:reindex`, `fonoteka:oauth-client` and `fonoteka:prune-notifications` use the published database when one exists and otherwise open and publish one from `database.dsn`, so none of them stops with `database is not configured` in the real binary."
|
||||
- "Per D-12, the framework key `http.access_log: true` logs one record per request (message `http request`) through the app's published `*slog.Logger` or `slog.Default` with `method`, `pattern`, `status` and `duration_ms`, adding `path` without its query string only for unmatched requests; with the key off nothing is logged."
|
||||
- "(edge: ordering) The seed writes the user, collections, albums, covers, wishlist entry, rating and token first and flips `search_use_typesense` last, so no seed write reaches Typesense before `fonoteka:reindex` builds the index."
|
||||
- "(edge: concurrency) Every seed row is written inside one `lagoon.Transaction` wrapped in `lighthouse.WithoutBroadcasting`; a failed or interrupted run commits no partial rows and queues no broadcast job, and a rerun completes the shape."
|
||||
- statement: "(edge: concurrency) A cover blob written before an aborted seed transaction can remain in the bucket without a system_files row; the rerun attaches a fresh blob and the orphan is never served because StaticHandlerPublic requires a public system_files row."
|
||||
verification: backstop
|
||||
- statement: "(edge: precision) The only numeric value this plan introduces is `duration_ms`, the elapsed request time truncated to whole milliseconds; seeded ratings use the existing 1-5 integer scale."
|
||||
verification: backstop
|
||||
artifacts:
|
||||
- path: "modules/surf/uploads.go"
|
||||
provides: "storage.uploads.serve opt-in mount of attach.StaticHandlerPublic with nosniff (D-19)"
|
||||
contains: "storage.uploads.serve"
|
||||
- path: "modules/surf/accesslog.go"
|
||||
provides: "http.access_log opt-in request log (D-12)"
|
||||
contains: "http.access_log"
|
||||
- path: "../fonoteka.go/plugins/golem15/fonoteka/console/dev_seed.go"
|
||||
provides: "DevSeedCommand (fonoteka:dev-seed) and DevEnvironment (D-02)"
|
||||
contains: "fonoteka:dev-seed"
|
||||
- path: "../fonoteka.go/plugins/golem15/fonoteka/console/database.go"
|
||||
provides: "commandDatabase and commandBucket: published handle, else open and publish (G1)"
|
||||
contains: "lagoon.Publish"
|
||||
- path: "../fonoteka.go/plugins/golem15/fonoteka/dev_marker.go"
|
||||
provides: "env-gated marker route GET /_fonoteka/api/v1/_dev/backend (D-09, D-10)"
|
||||
contains: "_dev"
|
||||
- path: "../fonoteka.go/plugins/golem15/fonoteka/dev_stack_smoke_test.go"
|
||||
provides: "TestDevStackSmoke: fresh-process seed, cover served, rerun unchanged, marker"
|
||||
contains: "TestDevStackSmoke"
|
||||
key_links:
|
||||
- from: "modules/surf/router.go"
|
||||
to: "modules/surf/uploads.go"
|
||||
via: "BuildRouter calls mountUploads after the plugin route loop and before cabana.Activate"
|
||||
pattern: "mountUploads"
|
||||
- from: "modules/surf/router.go"
|
||||
to: "modules/surf/accesslog.go"
|
||||
via: "compile wraps the final handler; wrap records each route pattern into the request slot"
|
||||
pattern: "accessLog"
|
||||
- from: "../fonoteka.go/plugins/golem15/fonoteka/plugin.go"
|
||||
to: "../fonoteka.go/plugins/golem15/fonoteka/console/dev_seed.go"
|
||||
via: "Commands() appends console.DevSeedCommand(p.app)"
|
||||
pattern: "DevSeedCommand"
|
||||
- from: "../fonoteka.go/plugins/golem15/fonoteka/console/dev_seed.go"
|
||||
to: "../fonoteka.go/plugins/golem15/fonoteka/classes/album_files.go"
|
||||
via: "covers stored through StoreAlbumPhoto inside the seed transaction"
|
||||
pattern: "StoreAlbumPhoto"
|
||||
- from: "../fonoteka.go/plugins/golem15/fonoteka/console/reindex.go"
|
||||
to: "../fonoteka.go/plugins/golem15/fonoteka/console/database.go"
|
||||
via: "reindex, oauth-client and prune-notifications resolve the database through commandDatabase"
|
||||
pattern: "commandDatabase"
|
||||
- from: "../fonoteka.go/plugins/golem15/fonoteka/routes.go"
|
||||
to: "../fonoteka.go/plugins/golem15/fonoteka/dev_marker.go"
|
||||
via: "Routes calls p.devMarkerRoutes(r), which registers nothing outside local/dev"
|
||||
pattern: "devMarkerRoutes"
|
||||
prohibitions:
|
||||
- requirement_id: QA-05
|
||||
category: safety
|
||||
statement: "fonoteka:dev-seed MUST NOT open a database connection or write any row unless the app environment is exactly local or dev; production is the default and is always refused"
|
||||
status: resolved
|
||||
verification: test
|
||||
- requirement_id: QA-05
|
||||
category: transparency
|
||||
statement: "No route listed in parity/manifest.yaml may change its response, and no header may be added to API responses; the marker route and the uploads mount stay outside the manifest"
|
||||
status: resolved
|
||||
verification: test
|
||||
- requirement_id: QA-05
|
||||
category: safety
|
||||
statement: "The shared sm-user-plugin submodule MUST NOT be modified by this plan; its user:token commands' database gap is recorded as a todo instead (core plugin rule)"
|
||||
status: resolved
|
||||
verification: test
|
||||
---
|
||||
|
||||
## Phase Goal
|
||||
|
||||
**As a** developer, **I want to** run the existing `vue-fonoteka-app` locally against the local `fonoteka.go` SummerCMS backend (not PHP), log in and use the real Nuxt flows, **so that** the port is proven in daily use before production cutover.
|
||||
|
||||
This plan's slice: the Go-side glue every later plan stands on. After it, a fresh `fonoteka` process can seed itself, serve the seeded covers under the WinterCMS upload URLs, name itself through a dev-only marker, rebuild search without a pre-published database, and log every request.
|
||||
|
||||
<objective>
|
||||
Close research gaps G1-G4 in Go (D-22 plan 01 scope): the `fonoteka:dev-seed` command (D-01..D-04), the plugin-command database fix including `fonoteka:reindex` (G1), the env-gated marker route (D-09, D-10), the framework `/storage` uploads mount (D-19) and the opt-in access log (D-12), with the surf README and docs pages updated in the same changes.
|
||||
|
||||
Purpose: plan 02's start script runs `migrate`, `fonoteka:dev-seed`, `fonoteka:reindex` and `serve` against these commands and probes the marker; plan 03's specs load covers through `/storage`.
|
||||
Output: two framework commits in summercms.go (uploads mount + docs, access log + docs), app commits in fonoteka.go, and one planning-doc commit (the user-plugin todo). Smoke tests only; the full unit matrix is plan 04.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/gsd-core/workflows/execute-plan.md
|
||||
@~/.claude/gsd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/PROJECT.md
|
||||
@.planning/ROADMAP.md
|
||||
@.planning/STATE.md
|
||||
@.planning/phases/14.2-local-fonoteka-frontend-on-local-summercms-backend/14.2-CONTEXT.md
|
||||
@.planning/phases/14.2-local-fonoteka-frontend-on-local-summercms-backend/14.2-RESEARCH.md
|
||||
@.planning/phases/14.2-local-fonoteka-frontend-on-local-summercms-backend/14.2-VALIDATION.md
|
||||
@modules/surf/router.go
|
||||
@modules/surf/serve.go
|
||||
@modules/lagoon/attach/static.go
|
||||
@../fonoteka.go/plugins/golem15/fonoteka/plugin.go
|
||||
@../fonoteka.go/plugins/golem15/fonoteka/console/reindex.go
|
||||
|
||||
<interfaces>
|
||||
- `lagoon.OpenFromApp(ctx, app) (*sql.DB, *gorm.DB, error)` reads `database.dsn` and loads `app.key`; `lagoon.Publish(app, sqlDB, gdb) error` publishes both and runs the `lagoon.OnDatabase` callbacks (the fonoteka slug and credential hooks register there, plugin.go:209). Opening without Publish leaves seeded slugs empty (research anti-pattern).
|
||||
- `attach.OpenBucket(ctx, cfg) (*blob.Bucket, error)` and `attach.Publish(app, bucket) error` (used by surf/serve.go publishUploads and lagoon/commands.go purgeBucket). `attach.StaticHandlerPublic(bucket *blob.Bucket, prefix string, db *gorm.DB) http.Handler` 404s missing or non-public rows and invalid keys; it sets no nosniff header.
|
||||
- `surf.BuildRouter` registers plugin routes in a loop with `r.BindPlugin(p.ID())`, then `cabana.Activate`; `r.app` holds the app; `compile()` returns `r.stripLocalePrefix(pathScopedCORS(r.corsCfg, mux))`; `wrap(rt route)` builds each route's chain inside `recoverJSON`/`recoverBare`.
|
||||
- `bonfire.Command{Name, Description, Flags []bonfire.Flag, Run func(ctx, in bonfire.Input, out bonfire.Output) error}`; console tests use `testInput` (console/oauth_client_test.go) and `bonfire.NewOutput(nil, &buf, &buf)`.
|
||||
- Seed building blocks (fonoteka plugin `classes` unless noted): `usercontrollers.RegisterUser(ctx, app, tx, fields, usercontrollers.RegisterOptions{Activate: true})` (sm-user-plugin controllers/registration.go:38); user model fields `MustChangePassword`, `HasSelfSetPassword`, `IsActivated`; `ProvisionCollection(ctx, tx, user)`; `ProvisionWishlist(ctx, tx, user)`; `CreateAlbum(ctx, tx, collectionID, input map[string]any)`; `StoreAlbumPhoto(ctx, db, bucket, albumID, filename, data) (*attach.File, error)`; `models.AlbumRating{AlbumID, UserID, Rating}`; `models.Settings{SearchUseTypesense}` singleton id 1; `userclasses.APITokenPrefix(app)`, `userclasses.MintAPIToken(prefix, userID, name, scopes, expiresAt, collectionIDs) (secret, *models.APIToken, error)`, `userclasses.HashAPIToken(secret)` (sm-user-plugin classes/api_tokens.go); `lighthouse.WithoutBroadcasting[T](ctx, fn)` (summercms.go modules/lighthouse/suppress.go:19).
|
||||
- Environment: `app.Config.Environment()` reads SUMMER_ENV, default `production` (compass/config.go:193-199). The plugin root test harness `bootConfig` uses `SUMMER_ENV=development`, so the marker stays absent from every existing route-table test.
|
||||
- fonoteka.go submodules `plugins/golem15/{user,golem,feedback}` already show pointer drift (research G13). Stage explicit paths only; never `git add -A` in fonoteka.go.
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
## Artifacts this phase produces
|
||||
|
||||
(This plan's share.)
|
||||
|
||||
- CLI command `fonoteka:dev-seed` (no flags) in the fonoteka plugin; exported `console.DevSeedCommand(app *backpack.App) bonfire.Command` and `console.DevEnvironment(app *backpack.App) (string, bool)`.
|
||||
- Environment variables read by the seed: `E2E_EMAIL`, `E2E_PASSWORD` (fallback `demo@example.com` / `changeme`), `DEV_SEED_API_TOKEN` (optional, must start with the configured prefix). Seeded token name `dev-seed-mcp`; seeded album names `Kind of Blue`, `Blue Train`, `Mingus Ah Um`; wishlist album `A Love Supreme`.
|
||||
- Seed output lines `<entity>: created|updated|unchanged` (cover lines carry the public URL; the token line shows the secret only on creation).
|
||||
- Unexported console helpers `commandDatabase(ctx, app) (*gorm.DB, func(), error)` and `commandBucket(ctx, app) (*blob.Bucket, func(), error)`.
|
||||
- Route `GET /_fonoteka/api/v1/_dev/backend` (local/dev only, `throttle:60,1`, not in the manifest); unexported `(*Plugin).devMarkerRoutes(r pact.Router)`.
|
||||
- Framework config keys `storage.uploads.serve` (env `SUMMER_STORAGE__UPLOADS__SERVE`, default false) and `http.access_log` (env `SUMMER_HTTP__ACCESS_LOG`, default false); framework route `GET {storage.uploads.public_path_prefix}/{path...}` bound to plugin id `summercms.surf`; unexported surf `mountUploads` and `accessLog`.
|
||||
- Log record `http request` with attributes `method`, `pattern`, `status`, `duration_ms` and, for unmatched requests only, `path`.
|
||||
- Tests `TestDevStackSmoke` (fonoteka plugin root package) and `TestAccessLogSmoke` (surf).
|
||||
- Planning todo `.planning/todos/pending/user-plugin-commands-open-database.md`.
|
||||
|
||||
## Assumptions
|
||||
|
||||
- Assumption-delta and API-coverage detectors did not fire for this phase (no core identity change, no external API integration).
|
||||
- `StoreAlbumPhoto` attaches album photos with `is_public = true`; the tracer test proves it by getting a 200 through `StaticHandlerPublic`. If it does not, the seed must not flip the flag by hand; stop and report.
|
||||
- `CreateAlbum` needs at least `name`; the executor reads `album_write_service.go` for any other required input and passes only fields the PHP create form accepts.
|
||||
- The executor must not read any `.env*` secret file; the Bash secret-read guard blocks it. Tests build their config in code.
|
||||
|
||||
## Execution guidance (context budget)
|
||||
|
||||
This plan touches 18 files with an uncalibrated 260k-token estimate. D-22 fixes the phase at 4 plans, so it is not split. Keep it inside one executor's budget this way:
|
||||
|
||||
- **Commit after each task, never across tasks.** Task 1 makes two commits (summercms.go: mount plus docs; fonoteka.go: helper, seed, registration, smoke test). Task 2 makes one fonoteka.go commit and one summercms.go planning-doc commit (the todo). Task 3 makes one summercms.go commit. Each commit happens right after that task's verify passes.
|
||||
- **Mid-plan SUMMARY checkpoint after Task 2.** Once Task 2's commits land, write `14.2-01-SUMMARY.md` with a status of in progress, listing Tasks 1-2 done with commit hashes, the seed output lines from `TestDevStackSmoke`, the parity summary line and any deviations, plus the Task 3 entry point. If the context is past about half at that point, stop there. A fresh executor resumes at Task 3 from that SUMMARY. The final SUMMARY replaces the in-progress status when Task 3 is done.
|
||||
- **Task 3 is self-contained and may run in a fresh context.** It changes only `modules/surf/` and two docs pages, and needs nothing from Tasks 1-2 beyond the committed `router.go`, `README.md` and `docs/setup/configuration.md`. A fresh executor reads Task 3's `read_first` list plus `modules/surf/uploads.go` (to keep the `BuildRouter` and `compile` edits consistent) and does not reopen any fonoteka.go file.
|
||||
- **Keep long test output out of the context.** Send the full output of `TestParityCorpus` (Task 2, up to 30 minutes) and the `-v` test runs to a file in the scratch directory. Read back only the exit status, the `--- PASS`/`--- FAIL`/`--- SKIP` lines and the parity summary line that each task's failure conditions check.
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tracer">
|
||||
<name>Task 1 (tracer): a seeded cover is served end to end — fresh-process fonoteka:dev-seed, framework /storage mount, 200 image/png</name>
|
||||
<reversibility rating="costly">`storage.uploads.serve` becomes a framework config key other applications and Phase 20 will set; renaming it later needs a deprecation in every app config.</reversibility>
|
||||
<files>modules/surf/uploads.go, modules/surf/router.go, modules/surf/README.md, docs/services/storage.md, docs/setup/configuration.md, ../fonoteka.go/plugins/golem15/fonoteka/console/database.go, ../fonoteka.go/plugins/golem15/fonoteka/console/dev_seed.go, ../fonoteka.go/plugins/golem15/fonoteka/plugin.go, ../fonoteka.go/plugins/golem15/fonoteka/dev_stack_smoke_test.go</files>
|
||||
<read_first>modules/surf/router.go (BuildRouter 460-568, compile 343-381, wrap 393-447), modules/surf/serve.go (publishUploads), modules/lagoon/attach/static.go, modules/lagoon/attach/bucket.go (OpenBucket default prefix, Publish), modules/lagoon/connection.go (OpenFromApp, Publish), modules/lagoon/commands.go (withDB, purgeBucket), modules/lighthouse/suppress.go, modules/surf/README.md, docs/services/storage.md, docs/setup/configuration.md, docs/database/attachments.md, ../fonoteka.go/plugins/golem15/fonoteka/plugin.go (Commands 292-301, OnDatabase 209), ../fonoteka.go/plugins/golem15/fonoteka/console/reindex.go, ../fonoteka.go/plugins/golem15/fonoteka/console/oauth_client_test.go (testInput), ../fonoteka.go/plugins/golem15/fonoteka/classes/album_files.go, ../fonoteka.go/plugins/golem15/fonoteka/classes/collection_provisioner.go, ../fonoteka.go/plugins/golem15/fonoteka/classes/album_write_service.go (CreateAlbum 378), ../fonoteka.go/plugins/golem15/user/controllers/registration.go, ../fonoteka.go/plugins/golem15/user/models/user.go, ../fonoteka.go/plugins/golem15/fonoteka/plugin_boot_test.go (bootPG, bootDB, bootConfig), ../fonoteka.go/plugins/golem15/fonoteka/routes_isolation_test.go (bouncer registry publish, party.Activate, stubInvToken, BuildRouter), ../fonoteka.go/config/storage.yaml, .planning/phases/14.2-local-fonoteka-frontend-on-local-summercms-backend/14.2-RESEARCH.md (G1, G2, Q4, Pattern 1, Pattern 2)</read_first>
|
||||
<action>Two commits, framework first (summercms.go: mount plus its docs), then app (fonoteka.go: helper, thin seed, registration, smoke test).
|
||||
|
||||
(1) summercms.go, per D-19. New `modules/surf/uploads.go` with unexported `mountUploads(r *Router, app *backpack.App) error`, called from `BuildRouter` right after the plugin route loop and before `cabana.Activate`. When `app` and `app.Config` are non-nil and `app.Config.Bool("storage.uploads.serve")` is true: bind plugin id `summercms.surf`, take `storage.uploads.public_path_prefix` trimmed of a trailing slash (fall back to the same default `attach.OpenBucket` records; read bucket.go for it), open a raw group at that prefix with no middleware and register `GET /{path...}`. The handler resolves `*blob.Bucket` and `*gorm.DB` with `app.Lookup` on every request (boot and route:list build the router before serve publishes either), answers 404 when either is missing, sets `X-Content-Type-Options: nosniff`, then delegates to `attach.StaticHandlerPublic(bucket, prefix, gdb)`. With the key false or absent, register nothing. A plugin route under the same prefix must keep failing boot through the existing ServeMux conflict error. Add no exported identifier. Docs in the same commit: surf README (Features bullet and a Configuration row for `storage.uploads.serve`, default `false`), docs/services/storage.md (rewrite the paragraph that says the framework mounts no file route: the opt-in key, the `is_public` gate, nosniff, and the existing advice to prefer a separate origin), docs/setup/configuration.md (a table row). Framework docs never name the consuming application.
|
||||
|
||||
(2) fonoteka.go `console/database.go`, per G1: `commandDatabase(ctx, app) (*gorm.DB, func(), error)` returns the published `*gorm.DB` with a no-op closer when one exists, else calls `lagoon.OpenFromApp` then `lagoon.Publish` and returns a closer for the `*sql.DB`. `commandBucket(ctx, app) (*blob.Bucket, func(), error)` does the same with `attach.OpenBucket` and `attach.Publish`.
|
||||
|
||||
(3) `console/dev_seed.go`, per D-01, D-02, D-03, D-04. Exported `DevEnvironment(app *backpack.App) (string, bool)`: the trimmed, lower-cased `app.Config.Environment()` and whether it is exactly `local` or `dev`. Exported `DevSeedCommand(app *backpack.App) bonfire.Command` named `fonoteka:dev-seed` (D-02 leaves the name to the planner; the plugin namespace follows docs/console/writing-commands.md and the bonfire duplicate-name todo). Run order: refuse first with the error `fonoteka:dev-seed: refused in environment "<env>" (set SUMMER_ENV=local)` before anything is opened; then `commandDatabase` and `commandBucket`; then, in one `lagoon.Transaction` inside `lighthouse.WithoutBroadcasting[models.Album]`, the thin path: the user looked up by email (`E2E_EMAIL` / `E2E_PASSWORD` via `os.Getenv`, falling back to `demo@example.com` / `changeme` when unset or empty) and created through `usercontrollers.RegisterUser` with `RegisterOptions{Activate: true}`, then `MustChangePassword` false and `HasSelfSetPassword` true; `ProvisionCollection`; the album `Kind of Blue` (year 1959) through `CreateAlbum` unless a live album with that name exists in the collection; a 64x64 PNG cover generated with `image/png` (solid colour derived from the album's position, no network, no committed binary) stored through `StoreAlbumPhoto` with the transaction handle only when the album has no photos. Print one line per entity, `<entity>: created` or `<entity>: unchanged`; the cover line appends the cover's public URL (`attach.File.URL` or `attach.PublicURL`). Never print the password.
|
||||
|
||||
(4) `plugin.go`: append `console.DevSeedCommand(p.app)` after `console.ReindexCommand(p.app)` in `Commands()` and update its doc comment. Do not touch `main.go` or `plugins.gen.go`.
|
||||
|
||||
(5) `dev_stack_smoke_test.go` (package fonoteka), `TestDevStackSmoke`: call `bootDB(t)` to migrate the shared container, then build a new compass config (`compass.Open` with `SUMMER_ENV=local` plus the JWT secrets bootConfig uses) setting `database.dsn` to `bootPG.ConnectionString(ctx, "sslmode=disable")`, a valid base64 32-byte `app.key`, `storage.uploads.bucket_url` to `file://` plus `t.TempDir()`, `storage.uploads.public_path_prefix` `/storage/app/uploads/public`, `storage.uploads.serve` true and the body limits. Activate `golem15.user`, `golem15.golem`, `golem15.fonoteka` WITHOUT publishing a database, run `console.DevSeedCommand(app).Run` with a minimal `bonfire.Input` value, assert the output has a cover URL, then `surf.Assemble(app, plugins)` and GET that URL through `httptest`: 200, `Content-Type: image/png`, `X-Content-Type-Options: nosniff`. A missing container is a failure, not a skip.
|
||||
|
||||
Stage explicit paths only (never the drifted submodule pointers).</action>
|
||||
<verify>
|
||||
<automated>go vet ./modules/surf/ && go test ./modules/surf/ -count=1 && go test ./cmd/summer -count=1 -run '^TestDocsTree$' && go run ./cmd/summer docs:build --check && go -C ../fonoteka.go vet ./plugins/golem15/fonoteka/... && go -C ../fonoteka.go test ./plugins/golem15/fonoteka -count=1 -v -run '^TestDevStackSmoke$'</automated>
|
||||
<fails_when>Non-zero exit; output contains "--- FAIL", "--- SKIP" or "no tests to run"; output lacks "--- PASS: TestDevStackSmoke"; docs:build --check prints any problem line.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -c 'storage.uploads.serve' modules/surf/uploads.go` prints at least 1, and `grep -c 'StaticHandlerPublic' modules/surf/uploads.go` prints at least 1, and `grep -c 'nosniff' modules/surf/uploads.go` prints at least 1.
|
||||
- `grep -l 'storage.uploads.serve' modules/surf/README.md docs/services/storage.md docs/setup/configuration.md | wc -l` prints 3.
|
||||
- `grep -c 'mountUploads(' modules/surf/router.go` prints at least 1.
|
||||
- `grep -c '"fonoteka:dev-seed"' ../fonoteka.go/plugins/golem15/fonoteka/console/dev_seed.go` prints 1, and `grep -c 'demo@example.com' ../fonoteka.go/plugins/golem15/fonoteka/console/dev_seed.go` prints at least 1.
|
||||
- `grep -c 'console.DevSeedCommand(p.app)' ../fonoteka.go/plugins/golem15/fonoteka/plugin.go` prints 1.
|
||||
- `grep -c 'lagoon.Publish' ../fonoteka.go/plugins/golem15/fonoteka/console/database.go` prints at least 1, and `grep -c 'attach.Publish' ../fonoteka.go/plugins/golem15/fonoteka/console/database.go` prints at least 1.
|
||||
- `git -C ../fonoteka.go show --name-only --format= HEAD -- main.go plugins.gen.go plugins/golem15/user plugins/golem15/golem plugins/golem15/feedback` prints nothing after the fonoteka.go commit (no generated file and no drifted submodule pointer was committed).
|
||||
</acceptance_criteria>
|
||||
<done>A fresh process with no published database seeds a user, a collection and one album with a cover, and the assembled Go router serves that cover under `/storage/app/uploads/public/...` with nosniff; the framework key is documented.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: full D-02 seed shape, idempotent rerun, the env-gated Go marker, and plugin commands that open their own database</name>
|
||||
<files>../fonoteka.go/plugins/golem15/fonoteka/console/dev_seed.go, ../fonoteka.go/plugins/golem15/fonoteka/console/reindex.go, ../fonoteka.go/plugins/golem15/fonoteka/console/oauth_client.go, ../fonoteka.go/plugins/golem15/fonoteka/console/prune_notifications.go, ../fonoteka.go/plugins/golem15/fonoteka/dev_marker.go, ../fonoteka.go/plugins/golem15/fonoteka/routes.go, ../fonoteka.go/plugins/golem15/fonoteka/dev_stack_smoke_test.go, .planning/todos/pending/user-plugin-commands-open-database.md</files>
|
||||
<read_first>../fonoteka.go/plugins/golem15/fonoteka/console/dev_seed.go (Task 1), ../fonoteka.go/plugins/golem15/fonoteka/console/database.go, ../fonoteka.go/plugins/golem15/fonoteka/console/reindex.go, ../fonoteka.go/plugins/golem15/fonoteka/console/oauth_client.go, ../fonoteka.go/plugins/golem15/fonoteka/console/prune_notifications.go, ../fonoteka.go/plugins/golem15/fonoteka/classes/wishlist_resolver.go, ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/wishlist_albums_controller.go (WishlistAlbumsStore 184, createAlbumIn), ../fonoteka.go/plugins/golem15/fonoteka/models/album_rating.go, ../fonoteka.go/plugins/golem15/fonoteka/models/settings.go, ../fonoteka.go/plugins/golem15/fonoteka/updates/20_remaining.go (settings seed 198-200), ../fonoteka.go/plugins/golem15/user/classes/api_tokens.go, ../fonoteka.go/plugins/golem15/user/console/api_tokens.go, ../fonoteka.go/plugins/golem15/fonoteka/routes.go (public groups 343-372, realtime mount), ../fonoteka.go/parity/manifest.yaml, ../fonoteka.go/parity/parity_contract_test.go, ../fonoteka.go/plugins/golem15/fonoteka/routes_table_phase14_test.go, .planning/todos/pending/bonfire-duplicate-command-names.md (todo frontmatter shape), .planning/phases/14.2-local-fonoteka-frontend-on-local-summercms-backend/14.2-RESEARCH.md (Pattern 1, Pattern 3, Q5, G9)</read_first>
|
||||
<action>(1) Seed shape per D-02, D-03 and research Pattern 1, all in the Task 1 transaction. Owned-collection albums keyed by (collection_id, name) among live rows: `Kind of Blue` (1959), `Blue Train` (1957), `Mingus Ah Um` (1959), each with its own generated cover (distinct colours) added only when the album has no photos. `ProvisionWishlist`, then the wishlist entry `A Love Supreme` (1965) created with `CreateAlbum` on the wishlist collection id, the same path `WishlistAlbumsStore` takes through `createAlbumIn`, keyed by (wishlist collection_id, name). One `models.AlbumRating` with rating 4 for `Kind of Blue`, keyed by (album_id, user_id). API token: when `DEV_SEED_API_TOKEN` is set it must start with `userclasses.APITokenPrefix(app)` (refuse otherwise) and its row is upserted by `HashAPIToken(secret)` under the name `dev-seed-mcp`; when unset, mint once with `MintAPIToken(prefix, userID, "dev-seed-mcp", []string{"read", "write", "ai"}, nil, nil)`, persist it, and print `api token: created <secret> (shown once)`; later runs find the row by name and user and print `api token: unchanged (secret not shown)`. Rerun with a different `E2E_PASSWORD`: when the bcrypt check fails, set the new password and print `user: updated`; the same inputs print `user: unchanged`. Last step (G9): `UPDATE golem15_fonoteka_settings SET search_use_typesense = true WHERE id = 1 AND NOT search_use_typesense`, printing `search: created` when a row changed and `search: unchanged` otherwise.
|
||||
|
||||
(2) G1 per D-05 and D-22: in `reindex.go`, `oauth_client.go` and `prune_notifications.go` replace the published-only lookup and its error with `commandDatabase(ctx, app)` plus a deferred closer. Keep every other line of behaviour, output and message as it is, so the existing console tests pass unchanged on the published-database path. The shared sm-user-plugin is not touched.
|
||||
|
||||
(3) Marker per D-09 and D-10 (research Pattern 3, discretion on the route): new `dev_marker.go` with `func (p *Plugin) devMarkerRoutes(r pact.Router)` that registers nothing unless `console.DevEnvironment(p.app)` reports local or dev. When allowed: group `/_fonoteka/api/v1/_dev` with `surf.Use("throttle:60,1")` and `GET /backend` answering 200 with `Content-Type: application/json`, `Cache-Control: no-store` and the body `{"backend":"summercms","app":"fonoteka","env":"<env>"}` encoded with encoding/json. No auth middleware, no other header. `routes.go` calls it before the realtime mount. Do not add it to `parity/manifest.yaml` or `parity/routes.snapshot`.
|
||||
|
||||
(4) Todo (planning doc, its own commit in summercms.go): `.planning/todos/pending/user-plugin-commands-open-database.md` with the frontmatter shape of the other todos (title, date 2026-10-07, priority low, area sm-user-plugin): `user:token:*` and `user:require-password-change` read only a published database and stop in the real binary; the fix is the same published-else-open pattern, additive and non-breaking, deferred under the core-plugin rule.
|
||||
|
||||
(5) Extend `TestDevStackSmoke`: a second run with the same env prints `unchanged` for every entity, row counts in users, collections, albums, system_files, album ratings and the token table stay equal, `search_use_typesense` is true; GET `/_fonoteka/api/v1/_dev/backend` on the assembled router answers 200 with exactly the three keys and `Cache-Control: no-store`.</action>
|
||||
<verify>
|
||||
<automated>go -C ../fonoteka.go vet ./plugins/golem15/fonoteka/... && go -C ../fonoteka.go test ./plugins/golem15/fonoteka -count=1 -v -run '^(TestDevStackSmoke|TestFullRouteTableAuthGroupMutualExclusivity|TestRouteTablePhase14|TestHooksRegisterWhenDatabasePublishedAfterBoot)$' && go -C ../fonoteka.go test ./plugins/golem15/fonoteka/console -count=1 -v -run '^(TestReindexCommand|TestPruneNotifications|TestOAuthClientCommandListPrintsClientIDAndURIsNeverTheSecret)$'</automated>
|
||||
<fails_when>Non-zero exit; output contains "--- FAIL", "--- SKIP" or "no tests to run"; output lacks "--- PASS: TestDevStackSmoke" or "--- PASS: TestReindexCommand".</fails_when>
|
||||
<automated>go -C ../fonoteka.go test ./parity -count=1 -run 'TestParityCorpus' -timeout 30m</automated>
|
||||
<fails_when>Non-zero exit; the summary line is not `recorded 175/175 passing 175 failing 0 unrecorded 0 pending 0`.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -c 'dev-seed-mcp' ../fonoteka.go/plugins/golem15/fonoteka/console/dev_seed.go` prints at least 1, and `grep -c 'DEV_SEED_API_TOKEN' ../fonoteka.go/plugins/golem15/fonoteka/console/dev_seed.go` prints at least 1.
|
||||
- `grep -c 'ProvisionWishlist' ../fonoteka.go/plugins/golem15/fonoteka/console/dev_seed.go` prints at least 1, and `grep -c 'search_use_typesense' ../fonoteka.go/plugins/golem15/fonoteka/console/dev_seed.go` prints at least 1.
|
||||
- `grep -l 'commandDatabase(' ../fonoteka.go/plugins/golem15/fonoteka/console/reindex.go ../fonoteka.go/plugins/golem15/fonoteka/console/oauth_client.go ../fonoteka.go/plugins/golem15/fonoteka/console/prune_notifications.go | wc -l` prints 3.
|
||||
- `grep -c 'devMarkerRoutes(r)' ../fonoteka.go/plugins/golem15/fonoteka/routes.go` prints 1, and `grep -c 'no-store' ../fonoteka.go/plugins/golem15/fonoteka/dev_marker.go` prints at least 1.
|
||||
- `grep -c '_dev/backend' ../fonoteka.go/parity/manifest.yaml` prints 0.
|
||||
- `git -C ../fonoteka.go/plugins/golem15/user status --porcelain` prints nothing (the shared user plugin is unchanged).
|
||||
- `test -f .planning/todos/pending/user-plugin-commands-open-database.md` exits 0.
|
||||
</acceptance_criteria>
|
||||
<done>The seed creates the full D-02 shape once and reports `unchanged` on a rerun, the marker answers only in local/dev and stays out of the manifest with the corpus at 175/175, and the three fonoteka plugin commands run without a pre-published database.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: opt-in request log — each Go request's route pattern and status reach the redacted log</name>
|
||||
<reversibility rating="costly">`http.access_log` is a new framework config key and log record shape other applications will grep; renaming it later touches every app config and log query.</reversibility>
|
||||
<files>modules/surf/accesslog.go, modules/surf/accesslog_test.go, modules/surf/router.go, modules/surf/README.md, docs/services/logging.md, docs/setup/configuration.md</files>
|
||||
<read_first>modules/surf/router.go (compile, wrap, stripLocalePrefix 722-731, bufferedResponse 668-707), modules/surf/router_test.go (router construction in tests), modules/sunscreen/README.md, docs/services/logging.md, modules/lighthouse/lighthouse.go (the `app.Lookup[*slog.Logger]()` fallback pattern near line 197), modules/surf/README.md, docs/setup/configuration.md, .planning/phases/14.2-local-fonoteka-frontend-on-local-summercms-backend/14.2-RESEARCH.md (G3, Open Question 3, Pitfall 8)</read_first>
|
||||
<action>Per D-12 (research G3, OQ3, Pitfall 8). New `modules/surf/accesslog.go`. In `compile()`, when `r.app` and its config are non-nil and `http.access_log` is true, wrap the final handler (outside `stripLocalePrefix` and CORS) with the unexported `accessLog` middleware; when false, add no wrapper. The middleware puts a request-scoped entry into the context, serves through a status-recording `http.ResponseWriter` (first `WriteHeader` wins, default 200; keep `Flush` and `Unwrap` working for `http.ResponseController`), then logs one record. In `wrap(rt)`, add an outermost per-route step that stores `rt.method + " " + rt.path` into that entry when one is present; read the pattern from the entry, not from `r.Pattern`, because `stripLocalePrefix` can hand ServeMux a copied request whose `Pattern` the outer middleware never sees. Logger: `app.Lookup[*slog.Logger]()` when published, else `slog.Default()` (sunscreen-wrapped in generated mains). Record: level Info, message `http request`, attributes `method`, `pattern`, `status`, `duration_ms` (whole milliseconds, int64); only when the pattern is empty (ServeMux 404 or 405, CORS preflight answered before routing) add `path` set to `r.URL.Path`. Never log the query string, headers, the remote address or a matched route's raw path (share and invitation tokens travel in paths). Add no exported identifier.
|
||||
|
||||
`accesslog_test.go` with `TestAccessLogSmoke`: a router with `http.access_log` true, a test plugin route `GET /blog/posts/{id}` and a published `*slog.Logger` writing JSON into a buffer. A request to `/blog/posts/7?token=secret` yields one record with pattern `GET /blog/posts/{id}`, status 200 and no `secret`, no `/blog/posts/7`; a request to an unknown path yields status 404 and `path` without its query string; with the key false the buffer stays empty. Use the plugin names `blog` / `acme` in fixtures.
|
||||
|
||||
Docs in the same commit: surf README (Features bullet and a Configuration row `http.access_log`, default `false`), docs/services/logging.md (new section "Request log": opt-in key and env name, record fields, what is never logged, sunscreen still applies), docs/setup/configuration.md (row). Framework docs never name the consuming application.</action>
|
||||
<verify>
|
||||
<automated>go vet ./modules/surf/ && go test ./modules/surf/ -count=1 -v -run '^TestAccessLogSmoke$' && go test ./modules/surf/ -count=1 && go test ./cmd/summer -count=1 -run '^TestDocsTree$' && go run ./cmd/summer docs:build --check</automated>
|
||||
<fails_when>Non-zero exit; output contains "--- FAIL", "--- SKIP" or "no tests to run"; output lacks "--- PASS: TestAccessLogSmoke"; docs:build --check prints any problem line.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -c 'http.access_log' modules/surf/accesslog.go` prints at least 1, and `grep -c 'duration_ms' modules/surf/accesslog.go` prints at least 1.
|
||||
- `grep -c 'RawQuery' modules/surf/accesslog.go` prints 0.
|
||||
- `grep -l 'http.access_log' modules/surf/README.md docs/services/logging.md docs/setup/configuration.md | wc -l` prints 3.
|
||||
- `grep -ci 'fonoteka\|plytarium\|płytarium' modules/surf/README.md docs/services/logging.md docs/services/storage.md` prints 0 for each file.
|
||||
</acceptance_criteria>
|
||||
<done>With `http.access_log` on, every request produces one redacted record naming the matched route pattern and status; unmatched requests carry their path without the query; with the key off nothing changes.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| Operator shell → `fonoteka:dev-seed` | Environment variables decide which database is written and which credentials are seeded |
|
||||
| Browser (via Nuxt proxy) → `/storage/app/uploads/public/...` | Untrusted path segments reach the blob store on the app origin |
|
||||
| Any request → access log | Paths can carry share and invitation tokens; logs leave the process |
|
||||
| Network → marker route | Unauthenticated callers learn the backend identity |
|
||||
| Go router → parity contract | Local-run glue must not alter any manifest route |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-14.2-01 | Tampering | `fonoteka:dev-seed` against a production database | high | mitigate | `DevEnvironment` allow-list is exactly `local`/`dev`; the refusal runs before `commandDatabase`; an unset SUMMER_ENV resolves to `production` and is refused (TestDevStackSmoke here, TestDevSeedRefusesProduction in plan 04) |
|
||||
| T-14.2-02 | Elevation of Privilege | `mountUploads` serving uploads same-origin | high | mitigate | Opt-in key default false; `attach.StaticHandlerPublic` 404s non-public and missing rows; `X-Content-Type-Options: nosniff` on every response; the seed writes PNG only |
|
||||
| T-14.2-03 | Information Disclosure | `accessLog` record | high | mitigate | Matched routes log the pattern, never the raw path; unmatched log `r.URL.Path` without the query; no headers, no remote address; sunscreen handler stays in front; default off |
|
||||
| T-14.2-04 | Information Disclosure | marker route `GET /_fonoteka/api/v1/_dev/backend` | medium | mitigate | Registered only when `DevEnvironment` reports local or dev; absent in production; `throttle:60,1`; body carries no version or secret |
|
||||
| T-14.2-05 | Information Disclosure | seeded API token secret | medium | mitigate | Stored as `HashAPIToken` only; the secret is printed once at creation, never on reruns; `DEV_SEED_API_TOKEN` comes from the environment only |
|
||||
| T-14.2-06 | Tampering | path segments of `/storage/...` | medium | mitigate | `parsePublicBlobPath` inside `StaticHandlerPublic` rejects `..`, empty segments and partition mismatches before `NewReader` (canon path traversal, existing T-05-13 control) |
|
||||
| T-14.2-07 | Tampering | parity contract (D-10) | high | mitigate | Marker and mount stay out of `parity/manifest.yaml` and `routes.snapshot`; `TestParityCorpus` must stay 175/175/0 |
|
||||
| T-14.2-SC | Tampering | Go module and npm installs | high | mitigate | No new dependency: stdlib `image/png`, `log/slog`, existing framework modules only; package legitimacy audit not applicable (research) |
|
||||
|
||||
ASVS L1; every high threat is mitigated with a test-backed control.
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- summercms.go: `go vet ./...` and `go test ./...` green (framework commits), `go test ./cmd/summer -run TestDocsTree` and `go run ./cmd/summer docs:build --check` clean. Config keys in docs are reviewed by hand (not checked automatically).
|
||||
- fonoteka.go: `go -C ../fonoteka.go vet ./...` and `go -C ../fonoteka.go test ./plugins/golem15/fonoteka/... -count=1` green; `TestParityCorpus` 175/175/0.
|
||||
- `git -C ../fonoteka.go status --porcelain` shows only the pre-existing submodule pointer drift (` M plugins/golem15/feedback`, `golem`, `user`), nothing staged from it.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- `fonoteka:dev-seed` creates the full D-02 shape in a fresh process, refuses outside local/dev, and is idempotent.
|
||||
- `storage.uploads.serve` serves a seeded cover with nosniff; `http.access_log` logs pattern and status; both documented in the same change.
|
||||
- The marker answers only in local/dev and the parity corpus is unchanged.
|
||||
- `fonoteka:reindex`, `fonoteka:oauth-client` and `fonoteka:prune-notifications` no longer need a pre-published database.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/14.2-local-fonoteka-frontend-on-local-summercms-backend/14.2-01-SUMMARY.md` when done
|
||||
</output>
|
||||
@@ -0,0 +1,334 @@
|
||||
---
|
||||
phase: 14.2-local-fonoteka-frontend-on-local-summercms-backend
|
||||
plan: 02
|
||||
type: execute
|
||||
wave: 2
|
||||
depends_on: ["14.2-01"]
|
||||
files_modified:
|
||||
- ../fonoteka.go/scripts/dev-stack.sh
|
||||
- ../fonoteka.go/.env.dev.example
|
||||
- ../fonoteka.go/.gitignore
|
||||
- ../fonoteka.go/config/storage.yaml
|
||||
- ../fonoteka.go/config/http.yaml
|
||||
- ../fonoteka.go/README.md
|
||||
autonomous: false
|
||||
requirements: [QA-05]
|
||||
user_setup:
|
||||
- service: postgres
|
||||
why: "A dedicated local database for the Go backend, never the PHP app's database (D-01, D-06)"
|
||||
env_vars:
|
||||
- name: SUMMER_DATABASE__DSN
|
||||
source: "Create a database such as fonoteka_go_dev with the Polish ICU collation from fonoteka.go/README.md § Database setup; the host must be loopback (127.0.0.1 or localhost)"
|
||||
- service: typesense
|
||||
why: "Album search, required by the start script (D-06)"
|
||||
env_vars:
|
||||
- name: SUMMER_SEARCH__TYPESENSE__API_KEY
|
||||
source: "API key of the plytarium-typesense-instance container (the value the PHP app uses as TYPESENSE_API_KEY)"
|
||||
dashboard_config:
|
||||
- task: "Start the container: docker start plytarium-typesense-instance"
|
||||
location: "host shell"
|
||||
- service: centrifugo
|
||||
why: "Realtime through the shared host Centrifugo v6 on :8787, no dedicated instance (D-18)"
|
||||
env_vars:
|
||||
- name: SUMMER_REALTIME__CENTRIFUGO__TOKEN_SECRET
|
||||
source: "/etc/centrifugo/config.json key client.token.hmac_secret_key (root-readable; the value the PHP app uses as CENTRIFUGO_SECRET)"
|
||||
- name: SUMMER_REALTIME__CENTRIFUGO__API_KEY
|
||||
source: "/etc/centrifugo/config.json key http_api.key (the value the PHP app uses as CENTRIFUGO_API_KEY)"
|
||||
- service: fonoteka-go
|
||||
why: "Boot secrets and paths for the local Go binary (D-08)"
|
||||
env_vars:
|
||||
- name: SUMMER_APP__KEY
|
||||
source: "Output of `fonoteka key:generate` (base64 of 32 bytes)"
|
||||
- name: SUMMER_GOLEM15__USER__JWT__SECRET
|
||||
source: "Any random string of 32+ bytes, e.g. `openssl rand -base64 48`"
|
||||
- name: SUMMER_ADMIN__JWT__SECRET
|
||||
source: "Any random string of 32+ bytes, e.g. `openssl rand -base64 48`"
|
||||
- name: DEV_STACK_NUXT_DIR
|
||||
source: "Absolute path of the vue-fonoteka-app checkout (/media/nvme/dev/golem15/fonoteka/vue-fonoteka-app on this machine)"
|
||||
estimate:
|
||||
tokens: 180000
|
||||
raw_tokens: 180000
|
||||
tasks: 4
|
||||
confidence: low # uncalibrated (factor 1, sample_count 0); 1 of 4 tasks is the env-file checkpoint; plan count locked at 4 by D-22
|
||||
must_haves:
|
||||
truths:
|
||||
- "Per D-05 and D-22, `scripts/dev-stack.sh up` runs, in this order: env check, service checks, a `summer` CLI built from `../summercms.go` source, `summer build`, `fonoteka migrate`, `fonoteka fonoteka:dev-seed`, `fonoteka fonoteka:reindex`, `fonoteka serve`, the guard, `pnpm dev` in `DEV_STACK_NUXT_DIR`, and the post-start probe; no docker compose and no dedicated Centrifugo is started (D-18)."
|
||||
- "Per D-06, the script refuses to start and names the service when Postgres (`pg_isready`), Typesense (`/health`) or Centrifugo (`POST <api_url>/info` with the API key) is unreachable or rejects the key; a Centrifugo that rejects the origin `http://localhost:3119` produces a `warn:` line naming the operator step (D-18)."
|
||||
- "Per D-23, `check` (and `up`) probes each realtime namespace in `DEV_STACK_CENTRIFUGO_NAMESPACES` (default `user collection wishlist`) read-only through the Centrifugo HTTP API, `POST <api_url>/history` with `{\"channel\":\"<ns>:dev_stack_probe\",\"limit\":0}`, and prints exactly one `warn: centrifugo namespace <ns> missing` line for every namespace answered with error code 102 (unknown channel); any other answer counts as present, a configured namespace (`user`) is never reported, warnings never refuse, and the script never reads `/etc/centrifugo/config.json` or calls `publish`. The subscribe permission (`allow_subscribe_for_client`) is not observable through the server API; the README states this limitation and plan 03's realtime spec proves the `collection:` subscribe."
|
||||
- "Per D-07, Go serves on `127.0.0.1:8423` (`DEV_STACK_GO_ADDR`), loopback only; an address on port 8422 or a non-loopback host is refused."
|
||||
- "Per D-08 (edge: empty), the script loads the git-ignored env file (`DEV_STACK_ENV_FILE`, default `fonoteka.go/.env.dev`), refuses when it is missing or not git-ignored, and prints one `missing: <KEY>` line for every required key that is unset or empty before refusing; `.env.dev.example` holds placeholders only."
|
||||
- "Per D-08, `pnpm dev` receives `NUXT_DEV_BACKEND_ORIGIN`, `NUXT_INTERNAL_API_ORIGIN`, `NUXT_PUBLIC_CENTRIFUGO_WS_URL` and an empty-by-default `NUXT_PUBLIC_FEEDBACK_WIDGET_KEY` as process environment, and the script writes no file inside `DEV_STACK_NUXT_DIR`."
|
||||
- "Per D-09 (edge: boundary), `dev-stack.sh guard` refuses an origin on port 8422 by the port rule before any network call, refuses a host other than localhost, 127.0.0.1 or ::1 (so any plytarium.com value), and accepts any other loopback port only when its marker answers `\"backend\":\"summercms\"` without an `X-Powered-By: PHP` header."
|
||||
- "Per D-09 (edge: adjacency), `guard` refuses when `NUXT_INTERNAL_API_ORIGIN` is unset or empty, when it differs from `NUXT_DEV_BACKEND_ORIGIN` after trailing-slash normalisation, or when either differs from `http://$DEV_STACK_GO_ADDR`; identical values pass the origin rules."
|
||||
- "Per D-09 and D-03, the post-start probe gets the marker JSON through `http://localhost:3119/_fonoteka/api/v1/_dev/backend`, a 401 from `http://localhost:3119/_fonoteka/api/v1/albums`, and 200 `image/png` for the first seeded cover URL through the Nuxt `/storage` proxy; any miss refuses and stops both processes."
|
||||
- "Per D-12, Go and Nuxt output is prefixed `[go] ` / `[nuxt] ` and tee'd into `tmp/dev-stack/go.log` and `tmp/dev-stack/nuxt.log`; the run docs tell the developer to watch them for 4xx/5xx and 404 responses."
|
||||
- "(edge: idempotency) `up --detach` on a stack this script already started (pid file alive, marker answering) prints `already up` and exits 0; `:8423` or `:3119` held by any other process is refused; `down` with nothing recorded is a no-op that exits 0."
|
||||
- "(edge: concurrency) `up` in the foreground stops both process groups and removes the pid file on Ctrl-C, TERM or any failure (EXIT trap); a second concurrent `up` is refused by the port checks."
|
||||
- "(edge: ordering) `fonoteka:reindex` runs only after `fonoteka:dev-seed` has turned `search_use_typesense` on, and `pnpm dev` starts only after the guard passed."
|
||||
- "`dev-stack.sh e2e-verdict <report.json>` refuses a Playwright JSON report unless `stats.expected > 0`, `stats.unexpected == 0`, `stats.skipped == 0` and `stats.flaky == 0` (research G7: a skip is how a misconfigured backend shows up)."
|
||||
artifacts:
|
||||
- path: "../fonoteka.go/scripts/dev-stack.sh"
|
||||
provides: "up [--detach] | down | status | check | guard | exec | e2e [--report-only] | e2e-verdict"
|
||||
contains: "_dev/backend"
|
||||
- path: "../fonoteka.go/.env.dev.example"
|
||||
provides: "placeholder template of every key the script requires (D-08)"
|
||||
contains: "NUXT_INTERNAL_API_ORIGIN"
|
||||
- path: "../fonoteka.go/.gitignore"
|
||||
provides: "ignores the dev env file and the seeded uploads"
|
||||
contains: "/storage/app/uploads/public/"
|
||||
- path: "../fonoteka.go/README.md"
|
||||
provides: "run docs: prerequisites, Centrifugo v6 operator step (origins and the D-23 namespaces), subcommands, logs, MCP pointing"
|
||||
contains: "dev-stack.sh"
|
||||
key_links:
|
||||
- from: "../fonoteka.go/scripts/dev-stack.sh"
|
||||
to: "../fonoteka.go/plugins/golem15/fonoteka/dev_marker.go"
|
||||
via: "guard and post-start probe GET /_fonoteka/api/v1/_dev/backend directly and through the Nuxt proxy"
|
||||
pattern: "_dev/backend"
|
||||
- from: "../fonoteka.go/scripts/dev-stack.sh"
|
||||
to: "../fonoteka.go/plugins/golem15/fonoteka/console/dev_seed.go"
|
||||
via: "up runs bin/fonoteka fonoteka:dev-seed and reads the first cover URL from its output"
|
||||
pattern: "fonoteka:dev-seed"
|
||||
- from: "../fonoteka.go/scripts/dev-stack.sh"
|
||||
to: "/media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/nuxt.config.ts"
|
||||
via: "pnpm dev with NUXT_DEV_BACKEND_ORIGIN and NUXT_INTERNAL_API_ORIGIN pinned to the Go origin"
|
||||
pattern: "NUXT_INTERNAL_API_ORIGIN"
|
||||
prohibitions:
|
||||
- requirement_id: QA-05
|
||||
category: safety
|
||||
statement: "dev-stack.sh MUST NOT write, copy, move or edit any file inside the vue-fonoteka-app checkout; the developer's PHP-targeted Nuxt setup stays intact"
|
||||
status: resolved
|
||||
verification: test
|
||||
- requirement_id: QA-05
|
||||
category: transparency
|
||||
statement: "The local stack MUST NOT fall through to PHP or the production origin: Nuxt is never started unless the guard proved the configured origin is the local Go binary"
|
||||
status: resolved
|
||||
verification: test
|
||||
- requirement_id: QA-05
|
||||
category: safety
|
||||
statement: "dev-stack.sh MUST NOT read or edit /etc/centrifugo/config.json, restart the shared Centrifugo or start a second instance; adding the Nuxt origin and the `collection` / `wishlist` namespaces stays a documented operator step (D-18, D-23), and the namespace probe uses only the read-only `history` API method"
|
||||
status: resolved
|
||||
verification: test
|
||||
---
|
||||
|
||||
## Phase Goal
|
||||
|
||||
**As a** developer, **I want to** run the existing `vue-fonoteka-app` locally against the local `fonoteka.go` SummerCMS backend (not PHP), log in and use the real Nuxt flows, **so that** the port is proven in daily use before production cutover.
|
||||
|
||||
This plan's slice: one command starts the whole stack. After it, `scripts/dev-stack.sh up` brings Go and Nuxt up together on host services, refuses to run when anything would reach PHP or production, and the README explains the local run end to end.
|
||||
|
||||
<objective>
|
||||
Deliver D-22 plan 02: `fonoteka.go/scripts/dev-stack.sh` (D-05, D-06, D-07, D-08, D-09, D-12, D-18, D-23), `.env.dev.example`, the `.gitignore` entries, the app config defaults for the two new framework keys, and the run docs.
|
||||
|
||||
Purpose: plan 03 runs Playwright through `dev-stack.sh e2e`, plan 04's gate exercises `dev-stack.sh guard` refusals and the e2e verdict.
|
||||
Output: fonoteka.go commits only (script, template and ignores, config defaults, README). No framework change.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/gsd-core/workflows/execute-plan.md
|
||||
@~/.claude/gsd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/PROJECT.md
|
||||
@.planning/ROADMAP.md
|
||||
@.planning/STATE.md
|
||||
@.planning/phases/14.2-local-fonoteka-frontend-on-local-summercms-backend/14.2-CONTEXT.md
|
||||
@.planning/phases/14.2-local-fonoteka-frontend-on-local-summercms-backend/14.2-RESEARCH.md
|
||||
@.planning/phases/14.2-local-fonoteka-frontend-on-local-summercms-backend/14.2-01-SUMMARY.md
|
||||
@../fonoteka.go/README.md
|
||||
|
||||
<interfaces>
|
||||
- From plan 01: `bin/fonoteka fonoteka:dev-seed` prints `<entity>: created|updated|unchanged` lines; cover lines end with the public URL `/storage/app/uploads/public/<partition>/<disk_name>`. Marker: `GET /_fonoteka/api/v1/_dev/backend` → 200 `{"backend":"summercms","app":"fonoteka","env":"local"}` in local/dev only. Framework keys: `SUMMER_STORAGE__UPLOADS__SERVE`, `SUMMER_HTTP__ACCESS_LOG` (both default false). `fonoteka:reindex`, `migrate` and `serve --addr` open their own database.
|
||||
- Config is cwd-relative (`compass.Load("config")` in main.go; bucket `file://./storage/app/uploads/public`): every `bin/fonoteka` call runs with cwd = fonoteka.go. Compass reads `fonoteka.go/.env` automatically but not `.env.dev`, so the script exports it (`set -a`; source; `set +a`).
|
||||
- `summer` is not on PATH for scripts and `~/go/bin/summer` may be stale: build it with `go -C <framework> build -o tmp/dev-stack/summer ./cmd/summer`, then run `summer build` with cwd = fonoteka.go (writes `bin/fonoteka`).
|
||||
- Nuxt (nuxt.config.ts): `NUXT_DEV_BACKEND_ORIGIN` read once at config load (default `http://localhost:8000`); `internalApiOrigin` falls back to `NUXT_DEV_BACKEND_ORIGIN`, then `NUXT_PUBLIC_SITE_URL`, then `https://plytarium.com`; dev port `NUXT_PORT || 3119` and silently moves to another port when 3119 is taken; proxied prefixes `/_user/api`, `/_journal/api`, `/_fonoteka/api`, `/_feedback/api`, `/plugins/golem15/feedback`, `/api/realtime`, `/storage`. Env exported on the `pnpm dev` process wins over `vue-fonoteka-app/.env` (c12 only fills unset keys).
|
||||
- Playwright (playwright.config.ts): loads `tests/e2e/.env.local` then `tests/e2e/.env` without overriding exported variables; `baseURL = E2E_FRONTEND_URL || http://127.0.0.1:3119`; `webServer.reuseExistingServer = !process.env.CI`; JSON reporter output via `PLAYWRIGHT_JSON_OUTPUT_NAME`. `fonotekaReachable()` probes `E2E_BACKEND_URL` (default `http://localhost:8422`).
|
||||
- Centrifugo v6 (D-18): shared systemd instance on :8787, HTTP API `POST /api/info` with header `X-API-Key`, WebSocket `/connection/websocket`; a WebSocket upgrade with a disallowed `Origin` is rejected (403).
|
||||
- Centrifugo v6 server API errors (D-23) [CITED: centrifugal.dev/docs/server/server_api, /docs/server/channels]: HTTP 200 with a body `{"error":{"code":N,"message":"..."}}`. `POST /api/history` with `{"channel":"<ns>:dev_stack_probe","limit":0}` answers code 102 `unknown channel` when namespace `<ns>` is not defined, code 108 `not available` when it exists without history, and a `result` when history is on; it is read-only and publishes nothing. Namespaces live in the `channel.namespaces` array of the v6 config; today the shared instance defines `user` but not `collection` or `wishlist` (D-23).
|
||||
- Local PHP stamps `X-Powered-By: PHP/8.5.10` on every response and has no route at the marker path; Go never sets that header.
|
||||
- The Bash secret-read guard blocks any shell command that names the dev env file (anything matching `.env.<suffix>` other than `.env.example`-style templates). Go through `dev-stack.sh` subcommands; edit `.gitignore` with the Edit tool; never print, cat or grep the env file.
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
## Artifacts this phase produces
|
||||
|
||||
(This plan's share.)
|
||||
|
||||
- `fonoteka.go/scripts/dev-stack.sh` subcommands: `up [--detach]`, `down`, `status`, `check`, `guard`, `exec -- <command...>`, `e2e [--report-only] <spec...>`, `e2e-verdict <report.json>`.
|
||||
- Script environment knobs: `DEV_STACK_ENV_FILE` (default `<app>/.env.dev`), `DEV_STACK_GO_ADDR` (default `127.0.0.1:8423`), `DEV_STACK_NUXT_DIR` (required), `DEV_STACK_FRAMEWORK_DIR` (default `<app>/../summercms.go`), `DEV_STACK_CENTRIFUGO_NAMESPACES` (default `user collection wishlist`, D-23).
|
||||
- Runtime files under `fonoteka.go/tmp/dev-stack/`: `go.log`, `nuxt.log`, `pids`, `summer`, `e2e-<timestamp>.json`.
|
||||
- Variables exported by `exec`/`e2e`: `E2E_BACKEND=go`, `E2E_GO_BIN=<app>/bin/fonoteka`, `E2E_GO_DIR=<app>`, `E2E_BACKEND_URL=http://$DEV_STACK_GO_ADDR`, `E2E_FRONTEND_URL=http://localhost:3119`, `E2E_CENTRIFUGO=1`, `E2E_EMAIL`/`E2E_PASSWORD` (env file values, else `demo@example.com` / `changeme`), the four `NUXT_*` values, with `CI` unset.
|
||||
- `fonoteka.go/.env.dev.example` (placeholders), `.gitignore` lines `/.env.dev` and `/storage/app/uploads/public/`.
|
||||
- App config defaults `storage.uploads.serve: false` (config/storage.yaml) and `access_log: false` (config/http.yaml).
|
||||
- README section "Running locally with the Nuxt app".
|
||||
|
||||
## Assumptions
|
||||
|
||||
- `pg_isready`, `curl`, `jq`, `python3`, `ss` and `setsid` are on PATH (verified during planning).
|
||||
- The developer fills the git-ignored env file from this plan's `user_setup` list at the Task 1 checkpoint (`checkpoint:human-action`, `gate="blocking-human"`), so the plan is not autonomous. The executor never reads, prints or greps that file or `/etc/centrifugo/config.json`. Completeness is checked by Task 2's `check_keys`, which prints key names only.
|
||||
- Adding `http://localhost:3119` to Centrifugo's allowed origins and the `collection` / `wishlist` namespaces (D-23) is a plan 03 human-action checkpoint; until then `check` prints the origin `warn:` line and one `warn: centrifugo namespace <ns> missing` line per missing namespace, and realtime stays off, which does not block this plan.
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="checkpoint:human-action" gate="blocking-human">
|
||||
<name>Task 1: developer creates the git-ignored dev env file, the Go dev database and starts Typesense</name>
|
||||
<read_first>This plan's `user_setup` frontmatter (every key and where its value comes from), ../fonoteka.go/README.md (§ Database setup), .planning/phases/14.2-local-fonoteka-frontend-on-local-summercms-backend/14.2-CONTEXT.md (D-06, D-08, D-18)</read_first>
|
||||
<action>Per D-06, D-08 and D-18, the developer creates `fonoteka.go/.env.dev` holding every `user_setup` variable of this plan plus the fixed local values listed below, creates the Go dev database and starts Typesense. The secret values come from the root-readable Centrifugo config and the developer's own setup. The executor must never read, print, cat or grep `/etc/centrifugo/config.json` or the dev env file; the Bash secret-read guard blocks that too.</action>
|
||||
<instructions>
|
||||
Claude has done nothing yet in this plan. The first automated task (Task 2) starts the real stack and needs secrets only a person can read: the HMAC secret and API key of the shared Centrifugo (root-owned config), the Typesense API key and a database password.
|
||||
The one human step, in a shell:
|
||||
1. Create a separate Postgres database for Go (for example `fonoteka_go_dev`) with the Polish ICU collation from fonoteka.go/README.md § Database setup, on 127.0.0.1 or localhost. Never use the PHP app's database.
|
||||
2. Run `docker start plytarium-typesense-instance` (port 8181).
|
||||
3. Create `fonoteka.go/.env.dev`. Task 2 adds it to `.gitignore` before anything is staged, and every commit stages explicit paths only. Set these keys:
|
||||
- Secrets, with sources as in `user_setup`: SUMMER_DATABASE__DSN, SUMMER_APP__KEY, SUMMER_GOLEM15__USER__JWT__SECRET, SUMMER_ADMIN__JWT__SECRET, SUMMER_SEARCH__TYPESENSE__API_KEY, SUMMER_REALTIME__CENTRIFUGO__TOKEN_SECRET (= `client.token.hmac_secret_key`), SUMMER_REALTIME__CENTRIFUGO__API_KEY (= `http_api.key`).
|
||||
- Fixed local values: SUMMER_ENV=local, SUMMER_APP__URL=http://localhost:3119, SUMMER_STORAGE__UPLOADS__SERVE=true, SUMMER_HTTP__ACCESS_LOG=true, DEV_STACK_GO_ADDR=127.0.0.1:8423, SUMMER_REALTIME__CENTRIFUGO__API_URL=http://127.0.0.1:8787/api, DEV_STACK_NUXT_DIR=/media/nvme/dev/golem15/fonoteka/vue-fonoteka-app, NUXT_DEV_BACKEND_ORIGIN=http://127.0.0.1:8423, NUXT_INTERNAL_API_ORIGIN=http://127.0.0.1:8423, NUXT_PUBLIC_CENTRIFUGO_WS_URL=ws://localhost:8787/connection/websocket.
|
||||
- Optional: E2E_EMAIL, E2E_PASSWORD (else `demo@example.com` / `changeme`), DEV_SEED_API_TOKEN, NUXT_PUBLIC_FEEDBACK_WIDGET_KEY.
|
||||
Task 2 commits `.env.dev.example` with the same keys as placeholders for later setups. Adding `http://localhost:3119` to Centrifugo's allowed origins is not part of this step; it is the plan 03 Task 2 checkpoint.
|
||||
</instructions>
|
||||
<verification>Without opening the env file: `docker ps --filter name=plytarium-typesense-instance --filter status=running --format '{{.Names}}'` prints the container name and `curl -fsS http://localhost:8181/health` succeeds. The env file's presence, git-ignore status and completeness are checked by Task 2's `load_env` and `check_keys`, which print `missing: <KEY>` names only, never values.</verification>
|
||||
<resume-signal>Type "env ready" once the env file, the database and Typesense are in place, or describe the problem.</resume-signal>
|
||||
</task>
|
||||
|
||||
<task type="tracer">
|
||||
<name>Task 2 (tracer): `dev-stack.sh up --detach` brings Go and Nuxt up and the Go marker and a seeded cover answer through Nuxt's proxy</name>
|
||||
<precondition>Task 1 is resolved ("env ready"): the developer has created fonoteka.go's git-ignored dev env file with every `user_setup` variable of this plan set, the Postgres dev database exists, and `plytarium-typesense-instance` is running.</precondition>
|
||||
<files>../fonoteka.go/scripts/dev-stack.sh, ../fonoteka.go/.env.dev.example, ../fonoteka.go/.gitignore, ../fonoteka.go/config/storage.yaml, ../fonoteka.go/config/http.yaml</files>
|
||||
<read_first>../fonoteka.go/.gitignore, ../fonoteka.go/config/storage.yaml, ../fonoteka.go/config/http.yaml, ../fonoteka.go/config/realtime.yaml, ../fonoteka.go/config/search.yaml, ../fonoteka.go/README.md (Database setup, Configuration), ../fonoteka.go/main.go (compass.Load), cmd/summer/main.go (buildCommand cwd), cmd/summer/runtime.go (bin output), /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/nuxt.config.ts (lines 30, 44-52, 110-113, 128, 155, 247-266, 364-372, 392-401), /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/.env.example (key names only), scripts/check-phase14.2.1.sh (header and structure style), .planning/phases/14.2-local-fonoteka-frontend-on-local-summercms-backend/14.2-RESEARCH.md (Q3, Q4, Q5, Q7, Pattern 4, Pattern 5, Pitfalls 5-6)</read_first>
|
||||
<action>Write `scripts/dev-stack.sh` (bash, `set -euo pipefail`, `unset FORCE_COLOR`, header comment describing every subcommand). APP is the script's parent directory; FRAMEWORK is `${DEV_STACK_FRAMEWORK_DIR:-$APP/../summercms.go}`; RUN is `$APP/tmp/dev-stack` (already git-ignored through `/tmp/`). This task implements the happy path of `up [--detach]` and `down`, with these functions, in this order (D-05):
|
||||
|
||||
1. `load_env` (D-08): refuse when `${DEV_STACK_ENV_FILE:-$APP/.env.dev}` is missing; when the file is inside APP refuse unless `git -C "$APP" check-ignore -q` accepts it; export it with `set -a` / `set +a`; force `SUMMER_ENV=local` when unset and refuse any value other than `local` or `dev`; default `DEV_STACK_GO_ADDR` to `127.0.0.1:8423` and refuse a non-loopback host or port 8422 (D-07).
|
||||
2. `check_keys` (D-08): REQUIRED_KEYS = SUMMER_DATABASE__DSN, SUMMER_APP__KEY, SUMMER_GOLEM15__USER__JWT__SECRET, SUMMER_ADMIN__JWT__SECRET, SUMMER_SEARCH__TYPESENSE__API_KEY, SUMMER_REALTIME__CENTRIFUGO__API_URL, SUMMER_REALTIME__CENTRIFUGO__API_KEY, SUMMER_REALTIME__CENTRIFUGO__TOKEN_SECRET, DEV_STACK_NUXT_DIR, NUXT_DEV_BACKEND_ORIGIN, NUXT_INTERNAL_API_ORIGIN, NUXT_PUBLIC_CENTRIFUGO_WS_URL. Print `missing: <KEY>` for each unset or empty one, then `refuse: <n> required keys missing in <file>` and exit 1. Never echo a value.
|
||||
3. `check_services` (D-06, D-18, D-23): Postgres with `pg_isready -d "$SUMMER_DATABASE__DSN"` and a refusal when the DSN host is not loopback; Typesense `GET http://${SUMMER_SEARCH__TYPESENSE__HOST:-localhost}:${SUMMER_SEARCH__TYPESENSE__PORT:-8181}/health` with a hint `docker start plytarium-typesense-instance`; Centrifugo `POST $SUMMER_REALTIME__CENTRIFUGO__API_URL/info` with the `X-API-Key` header passed on curl's stdin (`-H @-`) so the key never appears in the process list, distinguishing unreachable from key rejected. Each refusal is one `refuse: <service> ...` line. Then an origin probe: a WebSocket upgrade request to the Centrifugo host's `/connection/websocket` with `Origin: http://localhost:3119` and a 2-second limit; a 403 prints `warn: centrifugo does not allow origin http://localhost:3119 — add it to client.allowed_origins in /etc/centrifugo/config.json and restart the service (README)`; never edit that file or restart the service. Then the namespace probe (D-23): for each `<ns>` in `${DEV_STACK_CENTRIFUGO_NAMESPACES:-user collection wishlist}`, `POST $SUMMER_REALTIME__CENTRIFUGO__API_URL/history` with body `{"channel":"<ns>:dev_stack_probe","limit":0}` and the `X-API-Key` header again on curl's stdin; when jq finds `.error.code == 102` print `warn: centrifugo namespace <ns> missing — add {"name": "<ns>", "allow_subscribe_for_client": true} to channel.namespaces in /etc/centrifugo/config.json and restart the service (README)`; treat every other answer (a `result`, code 108 not available, any other code) as present. Use only the `history` method (read-only); never `publish`, `broadcast` or any other mutating API method, and never open the Centrifugo config file. Warnings never refuse: `check` exits 0 when it printed only `warn:` lines.
|
||||
4. `build`: `go -C "$FRAMEWORK" build -o "$RUN/summer" ./cmd/summer`, then `"$RUN/summer" build` with cwd APP. Never use a `summer` from PATH.
|
||||
5. `seed` (D-01, D-05): with cwd APP run `bin/fonoteka migrate`, `bin/fonoteka fonoteka:dev-seed` (keep its output in `$RUN/seed.out` and on the terminal), `bin/fonoteka fonoteka:reindex`, in that order; any non-zero exit refuses.
|
||||
6. `start_go`: refuse when `DEV_STACK_GO_ADDR` is already listening (`ss -ltn`); `setsid bin/fonoteka serve --addr "$DEV_STACK_GO_ADDR"` in the background with stdout and stderr prefixed `[go] ` and appended to `$RUN/go.log` (D-12); record the process group in `$RUN/pids`; wait up to 60 s for the marker on `http://$DEV_STACK_GO_ADDR/_fonoteka/api/v1/_dev/backend`.
|
||||
7. `guard` (D-09 part 1 and 2): the origin rules and marker check of the truths above, run against the env values; refusal lines start with `refuse:`.
|
||||
8. `start_nuxt` (D-08): refuse when `:3119` is already listening (Pitfall 6); from `DEV_STACK_NUXT_DIR` start `setsid env NUXT_DEV_BACKEND_ORIGIN=... NUXT_INTERNAL_API_ORIGIN=... NUXT_PUBLIC_CENTRIFUGO_WS_URL=... NUXT_PUBLIC_FEEDBACK_WIDGET_KEY="${NUXT_PUBLIC_FEEDBACK_WIDGET_KEY:-}" NUXT_PORT=3119 pnpm dev --port 3119`, output prefixed `[nuxt] ` into `$RUN/nuxt.log`; record its process group. Never create, write, copy or edit a file in the Nuxt checkout.
|
||||
9. `probe` (D-09 part 3, D-03): poll up to 180 s for the marker JSON through `http://localhost:3119/_fonoteka/api/v1/_dev/backend`; then require 401 from `http://localhost:3119/_fonoteka/api/v1/albums` and 200 `image/png` for the first `/storage/app/uploads/public/` URL found in `$RUN/seed.out`, fetched through `http://localhost:3119`.
|
||||
10. `up` without `--detach` waits on both process groups with an EXIT/INT/TERM trap that kills both groups and removes `$RUN/pids`; `up --detach` returns after the probe. Any failed step also stops whatever this run started. `down` sends TERM to every recorded group, KILL after 10 s, removes `$RUN/pids`, and exits 0 when nothing is recorded.
|
||||
|
||||
`.env.dev.example` (D-08): every REQUIRED_KEY plus SUMMER_ENV=local, SUMMER_APP__URL=http://localhost:3119, SUMMER_STORAGE__UPLOADS__SERVE=true, SUMMER_HTTP__ACCESS_LOG=true, DEV_STACK_GO_ADDR=127.0.0.1:8423, NUXT_DEV_BACKEND_ORIGIN and NUXT_INTERNAL_API_ORIGIN both `http://127.0.0.1:8423`, SUMMER_REALTIME__CENTRIFUGO__API_URL=http://127.0.0.1:8787/api, NUXT_PUBLIC_CENTRIFUGO_WS_URL=ws://localhost:8787/connection/websocket, and commented optional E2E_EMAIL, E2E_PASSWORD, DEV_SEED_API_TOKEN, NUXT_PUBLIC_FEEDBACK_WIDGET_KEY. Secrets get empty values or `<placeholder>` text with a comment naming their source (the `user_setup` sources). No real value.
|
||||
|
||||
`.gitignore`: add `/.env.dev` and `/storage/app/uploads/public/` (research G11), using the Edit tool. `config/storage.yaml`: add `serve: false` under `uploads` with a comment (set true locally through SUMMER_STORAGE__UPLOADS__SERVE and after cutover). `config/http.yaml`: add `access_log: false` with a comment (SUMMER_HTTP__ACCESS_LOG).
|
||||
|
||||
Commit in fonoteka.go with explicit paths only.</action>
|
||||
<verify>
|
||||
<automated>bash -n ../fonoteka.go/scripts/dev-stack.sh && bash ../fonoteka.go/scripts/dev-stack.sh up --detach && curl -fsS http://localhost:3119/_fonoteka/api/v1/_dev/backend | jq -e '.backend == "summercms"' && test "$(curl -s -o /dev/null -w '%{http_code}' http://localhost:3119/_fonoteka/api/v1/albums)" = 401; rc=$?; bash ../fonoteka.go/scripts/dev-stack.sh down; exit $rc</automated>
|
||||
<fails_when>Non-zero exit; any line starting with "refuse:" or "missing:"; jq prints false or curl fails; the albums probe is not 401.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `bash -n ../fonoteka.go/scripts/dev-stack.sh` exits 0.
|
||||
- `grep -c 'fonoteka:dev-seed' ../fonoteka.go/scripts/dev-stack.sh` prints at least 1, and `grep -c 'fonoteka:reindex' ../fonoteka.go/scripts/dev-stack.sh` prints at least 1.
|
||||
- `grep -c 'NUXT_INTERNAL_API_ORIGIN' ../fonoteka.go/.env.dev.example` prints at least 1, and `grep -c 'NUXT_DEV_BACKEND_ORIGIN=http://127.0.0.1:8423' ../fonoteka.go/.env.dev.example` prints 1.
|
||||
- `grep -nE '(>|>>|tee|cp|mv|install|sed -i)[^#]*DEV_STACK_NUXT_DIR' ../fonoteka.go/scripts/dev-stack.sh` prints nothing.
|
||||
- `grep -nE 'systemctl|/etc/centrifugo/config.json *>' ../fonoteka.go/scripts/dev-stack.sh` prints nothing.
|
||||
- `grep -c 'DEV_STACK_CENTRIFUGO_NAMESPACES' ../fonoteka.go/scripts/dev-stack.sh` prints at least 1, and `grep -c 'dev_stack_probe' ../fonoteka.go/scripts/dev-stack.sh` prints at least 1 (D-23 namespace probe).
|
||||
- `grep -nE '^[^#]*/(publish|broadcast)\b' ../fonoteka.go/scripts/dev-stack.sh` prints nothing (the probe uses the read-only `history` method only).
|
||||
- `git -C ../fonoteka.go check-ignore -q storage/app/uploads/public/probe.png` exits 0.
|
||||
- The `.gitignore` file read with the Read tool contains the line `/.env.dev` (Bash commands naming that file are blocked by the secret-read guard).
|
||||
- `grep -c 'serve: false' ../fonoteka.go/config/storage.yaml` prints 1, and `grep -c 'access_log: false' ../fonoteka.go/config/http.yaml` prints 1.
|
||||
</acceptance_criteria>
|
||||
<done>One command takes a configured machine from nothing to Go on 127.0.0.1:8423 and Nuxt on :3119, with the marker, a 401 and a seeded cover all answered through Nuxt's proxy, and `down` stops both.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: guard refusals and the lifecycle, exec and e2e subcommands fail closed</name>
|
||||
<files>../fonoteka.go/scripts/dev-stack.sh</files>
|
||||
<read_first>../fonoteka.go/scripts/dev-stack.sh (Task 2), /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/playwright.config.ts, /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/tests/e2e/helpers/fonotekaReachable.ts, /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/tests/e2e/helpers/runTinker.ts, .planning/phases/14.2-local-fonoteka-frontend-on-local-summercms-backend/14.2-RESEARCH.md (Q2, Q5 preflight logic, Pattern 4, G7)</read_first>
|
||||
<action>Make each piece callable on its own and fail closed (D-09, D-17 groundwork):
|
||||
|
||||
- `check`: `load_env`, `check_keys`, `check_services` only, including the origin probe and the D-23 namespace probe; it exits 1 on any `refuse:` line and 0 when it printed only `warn:` lines, so plan 03 can assert on the warnings.
|
||||
- `guard`: load the env file only (no key list, no services) so plan 04 can feed planted files through `DEV_STACK_ENV_FILE`. Rules in order, each a `refuse:` line and exit 1: internal origin unset or empty; the two origins differ after trailing-slash normalisation; host not localhost, 127.0.0.1 or ::1; port 8422 (checked before any network call); origin different from `http://$DEV_STACK_GO_ADDR`; marker GET not 200, body without `.backend == "summercms"` (jq), or any response header matching `x-powered-by: php` case-insensitively. `up` calls the same function.
|
||||
- `status`: print each recorded process group as running or gone, and the marker result on the Go origin and through :3119; exit 0 only when both are up.
|
||||
- `up` idempotency: when `$RUN/pids` lists live groups and the marker answers through :3119, print `already up` and exit 0; when either port is held by a process not in `$RUN/pids`, refuse naming the port.
|
||||
- `exec -- <command...>`: `load_env`, then export E2E_BACKEND=go, E2E_GO_BIN=$APP/bin/fonoteka, E2E_GO_DIR=$APP, E2E_BACKEND_URL=http://$DEV_STACK_GO_ADDR, E2E_FRONTEND_URL=http://localhost:3119, E2E_CENTRIFUGO=1, E2E_EMAIL and E2E_PASSWORD (env-file values, else `demo@example.com` / `changeme`, matching the D-04 seed fallback), the four NUXT_* values, unset CI, and exec the command.
|
||||
- `e2e [--report-only] <spec...>`: through `exec`, run `pnpm --dir "$DEV_STACK_NUXT_DIR" exec playwright test <spec...> --project=chromium --reporter=json` with `PLAYWRIGHT_JSON_OUTPUT_NAME=$RUN/e2e-<timestamp>.json`; print the report path and `expected/unexpected/skipped/flaky` counts; unless `--report-only`, finish with `e2e-verdict` on that report (D-15 report-only specs are printed, never blocking).
|
||||
- `e2e-verdict <file>`: refuse unless the file parses and `stats.expected > 0`, `stats.unexpected == 0`, `stats.skipped == 0`, `stats.flaky == 0` (jq); print the counts either way.
|
||||
- A `help` usage text listing every subcommand; an unknown subcommand prints it and exits 2.
|
||||
- Env requirements per subcommand: `e2e-verdict`, `down` and `help` read no env file; `guard` reads the env file but not the key list; `check`, `up`, `status`, `exec` and `e2e` load the full env. This keeps the refusal and verdict paths testable on planted inputs (plan 04 `--guard` and `--self-test`).</action>
|
||||
<verify>
|
||||
<automated>S=../fonoteka.go/scripts/dev-stack.sh; T=$(mktemp -d); bash -n "$S" || exit 1; for o in http://127.0.0.1:8422 https://plytarium.com; do printf 'NUXT_DEV_BACKEND_ORIGIN=%s\nNUXT_INTERNAL_API_ORIGIN=%s\n' "$o" "$o" > "$T/g.env"; DEV_STACK_ENV_FILE="$T/g.env" bash "$S" guard > "$T/out" 2>&1 && exit 1; grep -q '^refuse:' "$T/out" || exit 1; done; printf 'NUXT_DEV_BACKEND_ORIGIN=http://127.0.0.1:8423\n' > "$T/g.env"; DEV_STACK_ENV_FILE="$T/g.env" bash "$S" guard > "$T/out" 2>&1 && exit 1; grep -q '^refuse:' "$T/out" || exit 1; printf '{"stats":{"expected":3,"unexpected":0,"skipped":1,"flaky":0}}' > "$T/r.json"; bash "$S" e2e-verdict "$T/r.json" && exit 1; printf '{"stats":{"expected":3,"unexpected":0,"skipped":0,"flaky":0}}' > "$T/r.json"; bash "$S" e2e-verdict "$T/r.json"</automated>
|
||||
<fails_when>Non-zero exit: a planted :8422, plytarium.com or unset-internal-origin case was accepted or printed no "refuse:" line, the skipped report was accepted, or the clean report was refused.</fails_when>
|
||||
<automated>S=../fonoteka.go/scripts/dev-stack.sh; O=$(mktemp); DEV_STACK_CENTRIFUGO_NAMESPACES='user dev_stack_absent_ns' bash "$S" check > "$O" 2>&1; rc=$?; grep -E '^(warn|refuse|missing):' "$O"; test "$rc" -eq 0 && grep -q '^warn: centrifugo namespace dev_stack_absent_ns missing' "$O" && ! grep -q '^warn: centrifugo namespace user missing' "$O"</automated>
|
||||
<fails_when>Non-zero exit: `check` exited non-zero or printed a "refuse:" / "missing:" line, the undefined namespace `dev_stack_absent_ns` produced no `warn: centrifugo namespace dev_stack_absent_ns missing` line (positive control, D-23), or the configured `user` namespace was reported missing (negative control).</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -cE '^ *(up|down|status|check|guard|exec|e2e|e2e-verdict|help)\)' ../fonoteka.go/scripts/dev-stack.sh` prints at least 9.
|
||||
- `grep -ci 'x-powered-by' ../fonoteka.go/scripts/dev-stack.sh` prints at least 1.
|
||||
- `grep -c 'PLAYWRIGHT_JSON_OUTPUT_NAME' ../fonoteka.go/scripts/dev-stack.sh` prints at least 1, and `grep -c 'already up' ../fonoteka.go/scripts/dev-stack.sh` prints at least 1.
|
||||
- `bash ../fonoteka.go/scripts/dev-stack.sh nosuchcommand; test $? -eq 2` exits 0.
|
||||
</acceptance_criteria>
|
||||
<done>The guard refuses every stale, remote, mismatched or PHP origin before Nuxt could start, the stack lifecycle is idempotent, any Playwright run through the script fails on a skip, and `check` names every missing Centrifugo namespace (D-23) without refusing.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 4: run docs for the local stack in the fonoteka.go README</name>
|
||||
<files>../fonoteka.go/README.md</files>
|
||||
<read_first>../fonoteka.go/README.md, ../fonoteka.go/scripts/dev-stack.sh (help text), ../fonoteka.go/.env.dev.example, /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/.env.example (Centrifugo comments, lines 50-90), .planning/phases/14.2-local-fonoteka-frontend-on-local-summercms-backend/14.2-RESEARCH.md (Q7, Q8 proxy table, G8, G10)</read_first>
|
||||
<action>Add a README section `## Running locally with the Nuxt app` (the app README may name Płytarium and Nuxt). Cover, per D-05..D-09, D-12, D-18, D-23:
|
||||
- Prerequisites: a separate Postgres database with the Polish ICU collation (link to § Database setup), never the PHP database; `docker start plytarium-typesense-instance` (port 8181); the shared Centrifugo v6 on :8787 and the operator step (D-18, D-23), done once by a person with root: in `/etc/centrifugo/config.json` add `http://localhost:3119` and `http://127.0.0.1:3119` to the `client.allowed_origins` array, and add two entries to the `channel.namespaces` array (the one that already holds `user`), shown in a `json` fenced block in the README exactly as `{"name": "collection", "allow_subscribe_for_client": true}` and `{"name": "wishlist", "allow_subscribe_for_client": true}`, with no `subscribe_proxy_enabled` and no proxy, so PHP apps on the shared instance are unaffected (dev-grade; production namespace authorization is Phase 20's concern); validate with `sudo centrifugo checkconfig --config=/etc/centrifugo/config.json`, then `sudo systemctl restart centrifugo`. Explain why: the app's realtime code uses `collection:{id}` and `wishlist:{id}` channels (D-23), and without the namespaces Centrifugo answers `102: unknown channel` on either backend.
|
||||
- What `dev-stack.sh check` reports for Centrifugo: `warn: centrifugo does not allow origin ...` and `warn: centrifugo namespace <ns> missing ...` (one line per missing namespace among `user`, `collection`, `wishlist`, overridable with `DEV_STACK_CENTRIFUGO_NAMESPACES`); warnings do not stop the stack, realtime just stays off. Limitation: the namespace probe uses the read-only server API `history` call and detects a missing namespace only; whether `allow_subscribe_for_client` is set is not observable that way and is proven by the Playwright realtime spec (`collection:` subscribe answered without an error).
|
||||
- Configuration: copy `.env.dev.example` to the git-ignored `.env.dev` and fill each key (table: key, purpose, where the value comes from); `SUMMER_REALTIME__CENTRIFUGO__API_URL=http://127.0.0.1:8787/api` overrides the repository default port 8001.
|
||||
- Ports: Nuxt 3119, Go 8423 (loopback), PHP 8422 (may run side by side), Typesense 8181, Centrifugo 8787.
|
||||
- Subcommands of `scripts/dev-stack.sh`: `up`, `up --detach`, `down`, `status`, `check`, `guard`, `exec`, `e2e`, `e2e-verdict`, one line each, plus the order `up` runs in and what the guard refuses.
|
||||
- Logs (D-12): `tmp/dev-stack/go.log` and `tmp/dev-stack/nuxt.log`; watch them for 4xx/5xx and 404 responses; a `album search engine failed` warning means search fell back to SQL (Typesense down or unindexed).
|
||||
- The script never writes `vue-fonoteka-app/.env`; the PHP-targeted setup stays as it is. `/_journal/api` is proxied by Nuxt but answers 404 on Go (the app does not use it).
|
||||
- MCP: `fonoteka-mcp` talks to Go directly (Nuxt does not proxy `/api/v1/fonoteka/*`): `FONOTEKA_API_URL=http://localhost:8423`, `FONOTEKA_API_TOKEN=<inv_ secret printed once by fonoteka:dev-seed, or DEV_SEED_API_TOKEN>`.
|
||||
- `fonoteka:dev-seed` refuses outside SUMMER_ENV local/dev and is safe to rerun.</action>
|
||||
<verify>
|
||||
<automated>R=../fonoteka.go/README.md; for c in 'up' 'up --detach' 'down' 'status' 'check' 'guard' 'exec' 'e2e' 'e2e-verdict'; do grep -qF "dev-stack.sh $c" "$R" || { echo "missing: $c"; exit 1; }; done; grep -qF 'client.allowed_origins' "$R" && grep -qF 'http://localhost:3119' "$R" && grep -qF 'channel.namespaces' "$R" && grep -qF '{"name": "collection", "allow_subscribe_for_client": true}' "$R" && grep -qF '{"name": "wishlist", "allow_subscribe_for_client": true}' "$R" && grep -qF 'centrifugo namespace' "$R" && grep -qF '8423' "$R" && grep -qF 'tmp/dev-stack/go.log' "$R" && grep -qF 'FONOTEKA_API_URL' "$R" && grep -qF 'fonoteka:dev-seed' "$R"</automated>
|
||||
<fails_when>Non-zero exit, a "missing:" line naming an undocumented subcommand, or the README lacks the D-23 namespace snippet (`channel.namespaces` with the exact `collection` and `wishlist` entries) or the `centrifugo namespace` warning description.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -c '^## Running locally with the Nuxt app' ../fonoteka.go/README.md` prints 1.
|
||||
- `grep -c 'checkconfig' ../fonoteka.go/README.md` prints at least 1 (the operator validates the shared config before the D-23 restart).
|
||||
- `grep -c 'docker start plytarium-typesense-instance' ../fonoteka.go/README.md` prints at least 1.
|
||||
- `grep -c 'systemctl restart centrifugo' ../fonoteka.go/README.md` prints at least 1.
|
||||
</acceptance_criteria>
|
||||
<done>A developer who never saw this phase can configure, start, watch, test and stop the local stack from the README alone.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| Developer env file → script | Secrets and origins enter the process environment |
|
||||
| Browser and SSR (Nuxt) → backend origin | Requests must reach the local Go binary, never PHP or production |
|
||||
| Script → shared host services | Postgres, Typesense and the shared Centrifugo serve other apps |
|
||||
| Local network → Go dev server | The dev server must not be reachable from the LAN |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-14.2-08 | Spoofing | Nuxt backend origin (fall-through to PHP or plytarium.com) | high | mitigate | `guard` refuses :8422, non-loopback hosts, unset or mismatched internal origin, a missing marker and any `X-Powered-By: PHP`; `NUXT_INTERNAL_API_ORIGIN` always exported; post-start probe through :3119 |
|
||||
| T-14.2-09 | Information Disclosure | dev env file and Centrifugo API key | high | mitigate | `.gitignore` entry plus a refusal when the env file is not git-ignored; template holds placeholders only; values never echoed; the API key goes to curl through stdin, not argv |
|
||||
| T-14.2-10 | Spoofing | Go dev server exposure | medium | mitigate | `serve --addr 127.0.0.1:8423`; non-loopback `DEV_STACK_GO_ADDR` refused |
|
||||
| T-14.2-11 | Tampering | developer's `vue-fonoteka-app/.env` and PHP setup | high | mitigate | env passed on the `pnpm dev` process only; no write operation targets `DEV_STACK_NUXT_DIR` (acceptance grep) |
|
||||
| T-14.2-12 | Tampering | seeding a non-local database | high | mitigate | script forces SUMMER_ENV local/dev and refuses a non-loopback DSN host; the seed itself refuses outside local/dev (T-14.2-01) |
|
||||
| T-14.2-13 | Information Disclosure | `tmp/dev-stack/*.log` | low | accept | `tmp/` is git-ignored; Go output passes sunscreen and the access log records patterns only; the seed prints the minted token once by design for local MCP use |
|
||||
| T-14.2-21 | Tampering | shared Centrifugo (D-23 namespace probe) | medium | mitigate | The probe calls only the read-only `history` API method on `<ns>:dev_stack_probe` with the API key on curl's stdin; no `publish`/`broadcast` path in the script (acceptance grep); the config file is never read or edited and the restart stays an operator step |
|
||||
| T-14.2-SC | Tampering | package installs | high | mitigate | No install: host tools and existing pnpm dependencies only |
|
||||
|
||||
ASVS L1; every high threat is mitigated.
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- `bash -n ../fonoteka.go/scripts/dev-stack.sh`; the Task 3 refusal loop; `bash ../fonoteka.go/scripts/dev-stack.sh up --detach` then `status` exits 0, then `down`.
|
||||
- `go -C ../fonoteka.go vet ./...` and `go -C ../fonoteka.go test ./plugins/golem15/fonoteka/... -count=1` stay green (config defaults only).
|
||||
- `git -C ../fonoteka.go status --porcelain` shows no new untracked file outside the committed set (seeded covers and `tmp/` are ignored).
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- `scripts/dev-stack.sh up` starts Go and Nuxt together on host services, in the D-05 order, with logs.
|
||||
- Every D-09 refusal case is refused before Nuxt starts; the post-start probe proves the proxy reaches Go.
|
||||
- The README documents the run, the Centrifugo operator step (origins plus the D-23 `collection` / `wishlist` namespaces) and MCP pointing.
|
||||
- `dev-stack.sh check` names every missing Centrifugo origin or namespace as a `warn:` line (D-23).
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/14.2-local-fonoteka-frontend-on-local-summercms-backend/14.2-02-SUMMARY.md` when done
|
||||
</output>
|
||||
@@ -0,0 +1,296 @@
|
||||
---
|
||||
phase: 14.2-local-fonoteka-frontend-on-local-summercms-backend
|
||||
plan: 03
|
||||
type: execute
|
||||
wave: 3
|
||||
depends_on: ["14.2-02"]
|
||||
files_modified:
|
||||
- /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/tests/e2e/helpers/runTinker.ts
|
||||
- /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/tests/e2e/auth.setup.ts
|
||||
- /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/tests/e2e/.env.example
|
||||
- /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/tests/e2e/realtime.spec.ts
|
||||
- /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/tests/e2e/album-edit-rate-cover.spec.ts
|
||||
- /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/tests/e2e/album-search.spec.ts
|
||||
- /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/tests/e2e/wishlist.spec.ts
|
||||
- .planning/phases/14.2-local-fonoteka-frontend-on-local-summercms-backend/14.2-UAT.md
|
||||
autonomous: false
|
||||
requirements: [QA-05]
|
||||
estimate:
|
||||
tokens: 220000
|
||||
raw_tokens: 220000
|
||||
tasks: 4
|
||||
confidence: low # uncalibrated (factor 1, sample_count 0); 2 of 4 tasks are checkpoints; plan count locked at 4 by D-22
|
||||
must_haves:
|
||||
truths:
|
||||
- "Per D-14, with `E2E_BACKEND=go` the Playwright seed helper `seedE2EFixtures()` runs `$E2E_GO_BIN fonoteka:dev-seed` with cwd `E2E_GO_DIR` (default the binary's parent directory's parent) and throws on a missing binary or a non-zero exit; `runTinker()` throws in Go mode; with `E2E_BACKEND` unset every existing line of the PHP tinker path is unchanged."
|
||||
- "Per D-14, `auth.setup.ts` calls `seedE2EFixtures()` before logging in only when `E2E_BACKEND === 'go'`; with the switch unset its behaviour against PHP is unchanged."
|
||||
- "Per D-13 and D-15, through `dev-stack.sh e2e` the Go gate subset `auth.setup.ts`, `login.spec.ts`, `smoke.spec.ts`, `fonoteka-item.spec.ts`, `public-share.spec.ts`, `realtime.spec.ts`, `i18n.spec.ts`, `album-edit-rate-cover.spec.ts`, `album-search.spec.ts` and `wishlist.spec.ts` passes against the Go stack with `unexpected 0`, `skipped 0`, `flaky 0` and `expected > 0`."
|
||||
- "Per D-16, `album-edit-rate-cover.spec.ts` proves edit, rate and cover upload, `album-search.spec.ts` proves search and `wishlist.spec.ts` proves wishlist add and remove; each seeds its own uniquely named data through the UI or `page.request` on the Nuxt origin and depends on the seed only for the logged-in user."
|
||||
- "Per D-03 and D-19, after a cover upload the album page shows an `img` whose `src` contains `/storage/app/uploads/public/` and whose `naturalWidth` is greater than 0, loaded through the Nuxt `/storage` proxy."
|
||||
- "Per D-21 and D-23, `realtime.spec.ts` no longer uses the removed `realtime-status` / `realtime-degraded` test IDs; with `E2E_CENTRIFUGO=1` it observes the Centrifugo WebSocket: a connect reply, a `collection:` subscription answered without an error (in particular no `102 unknown channel`, which the D-23 namespace step removes), and a publication push on that channel within 15 s of an album created through the API."
|
||||
- "Per D-11, D-16 and D-21, the new and rewritten specs also pass against the PHP backend on :8422 with the app's own PHP-targeted `.env`, or, when PHP cannot be started on the machine, the reason and a manual PHP check are recorded in 14.2-UAT.md and never reported as passed."
|
||||
- "Per D-11 and D-20, this phase changes no file under `vue-fonoteka-app/app/`, adds no UI, and commits only to the vue-fonoteka-app repository itself."
|
||||
- "Per D-18 and D-23, the operator has added `http://localhost:3119` and `http://127.0.0.1:3119` to `client.allowed_origins` and the entries `{\"name\": \"collection\", \"allow_subscribe_for_client\": true}` and `{\"name\": \"wishlist\", \"allow_subscribe_for_client\": true}` (no proxy, no other option) to `channel.namespaces` of the shared Centrifugo, restarted it, and `dev-stack.sh check` prints neither the origin warning nor any `warn: centrifugo namespace` line; Task 3's realtime spec and Task 4's realtime item run only after this step."
|
||||
- "Per D-13, one checklist-driven manual session against the Go stack is recorded in `14.2-UAT.md`: login, browse collections and albums, search, edit, rate, cover upload, wishlist add and remove, realtime working, and no 5xx in `tmp/dev-stack/go.log`."
|
||||
artifacts:
|
||||
- path: "/media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/tests/e2e/helpers/runTinker.ts"
|
||||
provides: "E2E_BACKEND=go switch (D-14)"
|
||||
contains: "E2E_GO_BIN"
|
||||
- path: "/media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/tests/e2e/realtime.spec.ts"
|
||||
provides: "backend-agnostic realtime spec (D-21)"
|
||||
contains: "connection/websocket"
|
||||
- path: "/media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/tests/e2e/album-edit-rate-cover.spec.ts"
|
||||
provides: "edit, rate, cover upload (D-16)"
|
||||
contains: "rating-stars"
|
||||
- path: "/media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/tests/e2e/album-search.spec.ts"
|
||||
provides: "search (D-16)"
|
||||
contains: "albums-search-input"
|
||||
- path: "/media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/tests/e2e/wishlist.spec.ts"
|
||||
provides: "wishlist add and remove (D-16)"
|
||||
contains: "wishlist-item-remove"
|
||||
- path: ".planning/phases/14.2-local-fonoteka-frontend-on-local-summercms-backend/14.2-UAT.md"
|
||||
provides: "D-13 manual session evidence"
|
||||
contains: "realtime"
|
||||
key_links:
|
||||
- from: "/media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/tests/e2e/helpers/runTinker.ts"
|
||||
to: "../fonoteka.go/plugins/golem15/fonoteka/console/dev_seed.go"
|
||||
via: "execFileSync(E2E_GO_BIN, ['fonoteka:dev-seed'], { cwd: E2E_GO_DIR })"
|
||||
pattern: "fonoteka:dev-seed"
|
||||
- from: "/media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/tests/e2e/album-edit-rate-cover.spec.ts"
|
||||
to: "modules/surf/uploads.go"
|
||||
via: "the uploaded cover's /storage/app/uploads/public URL loads through the Nuxt proxy from the framework mount"
|
||||
pattern: "/storage/app/uploads/public/"
|
||||
- from: "../fonoteka.go/scripts/dev-stack.sh"
|
||||
to: "/media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/tests/e2e/auth.setup.ts"
|
||||
via: "dev-stack.sh e2e exports E2E_BACKEND=go, E2E_GO_BIN and E2E_GO_DIR before Playwright runs the setup project"
|
||||
pattern: "E2E_BACKEND"
|
||||
prohibitions:
|
||||
- requirement_id: QA-05
|
||||
category: safety
|
||||
statement: "vue-fonoteka-app MUST keep working against the PHP backend: no app/ source changes, and every new or rewritten spec is run against PHP as well as Go"
|
||||
status: resolved
|
||||
verification: test
|
||||
- requirement_id: QA-05
|
||||
category: safety
|
||||
statement: "Specs MUST NOT change or delete data they did not create; every created album and wishlist entry carries a Date.now() suffix and is removed by the spec, so the developer's PHP database keeps its real data"
|
||||
status: resolved
|
||||
verification: test
|
||||
- requirement_id: QA-05
|
||||
category: transparency
|
||||
statement: "With E2E_BACKEND unset, the PHP tinker seeding path MUST behave exactly as before; the Go switch only adds a branch"
|
||||
status: resolved
|
||||
verification: test
|
||||
---
|
||||
|
||||
## Phase Goal
|
||||
|
||||
**As a** developer, **I want to** run the existing `vue-fonoteka-app` locally against the local `fonoteka.go` SummerCMS backend (not PHP), log in and use the real Nuxt flows, **so that** the port is proven in daily use before production cutover.
|
||||
|
||||
This plan's slice: the proof. After it, Playwright exercises every core flow against Go through the real Nuxt app, the same specs pass on PHP, and one human session is on record.
|
||||
|
||||
<objective>
|
||||
Deliver D-22 plan 03 in vue-fonoteka-app: the `E2E_BACKEND=go` seed switch (D-14), the rewritten realtime spec (D-21), new backend-agnostic specs for edit/rate/cover, search and wishlist (D-16), all verified against Go and PHP (D-11), then the operator's Centrifugo step, origins plus the `collection` / `wishlist` namespaces (D-18, D-23), and the checklist-driven manual session (D-13) recorded as `14.2-UAT.md`.
|
||||
|
||||
Purpose: QA-05's "vue-fonoteka-app runs unchanged against the Go backend", proven by the app's own test suite and by a person.
|
||||
Output: vue-fonoteka-app commits (its own repository, a submodule of the PHP fonoteka repo; never committed from the superproject), and `14.2-UAT.md` (planning doc, separate commit in summercms.go).
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/gsd-core/workflows/execute-plan.md
|
||||
@~/.claude/gsd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/PROJECT.md
|
||||
@.planning/ROADMAP.md
|
||||
@.planning/STATE.md
|
||||
@.planning/phases/14.2-local-fonoteka-frontend-on-local-summercms-backend/14.2-CONTEXT.md
|
||||
@.planning/phases/14.2-local-fonoteka-frontend-on-local-summercms-backend/14.2-RESEARCH.md
|
||||
@.planning/phases/14.2-local-fonoteka-frontend-on-local-summercms-backend/14.2-02-SUMMARY.md
|
||||
|
||||
<interfaces>
|
||||
- From plan 02: `bash ../fonoteka.go/scripts/dev-stack.sh up --detach | down | status | check | e2e <spec...> | e2e-verdict <file>`. `e2e` exports `E2E_BACKEND=go`, `E2E_GO_BIN`, `E2E_GO_DIR`, `E2E_BACKEND_URL=http://127.0.0.1:8423`, `E2E_FRONTEND_URL=http://localhost:3119`, `E2E_CENTRIFUGO=1`, `E2E_EMAIL` / `E2E_PASSWORD` and the `NUXT_*` Go values, unsets `CI`, runs Playwright with the JSON reporter and fails on any skip.
|
||||
- From plan 01: `fonoteka:dev-seed` (idempotent, D-04 creds fallback `demo@example.com` / `changeme`); uploads served at `/storage/app/uploads/public/...` through the framework mount.
|
||||
- Existing helpers: `fonotekaReachable()` (tests/e2e/helpers/fonotekaReachable.ts) probes `${E2E_BACKEND_URL}/_fonoteka/api/v1/albums` and accepts 200 or 401; gate pattern in `fonoteka-item.spec.ts`: `test.skip(!(await fonotekaReachable()), ...)` plus the E2E creds skip.
|
||||
- Auth: JWT in the JS-readable `auth_token` cookie; API calls carry `Authorization: Bearer <token>`. Specs read the cookie from `page.context().cookies()` and call `page.request` on the Nuxt origin (`/_fonoteka/api/v1/...`, proxied, same-origin). Store endpoints (app/stores/fonoteka.ts): `POST /albums/{id}/photos` (multipart), `PUT /albums/{id}/rating` `{ rating }`, `DELETE /albums/{id}`, `DELETE /wishlist/albums/{id}`; read the store's album create call for the create body.
|
||||
- Selectors (verified in app/): `album-edit` (pages/albums/[id]/index.vue), `album-name` (AlbumDraftFields.vue), `album-save` (AlbumForm.vue), `rating-stars` (RatingStars.vue), `albums-search-input` (pages/albums/index.vue), `` wishlist-item-${album.id} `` and `wishlist-item-remove` (WishlistGrid.vue), page `/wishlist/new`; the cover file input in `AlbumCoverGallery.vue` (`type="file" accept="image/*"`); `PNG_1PX` buffer pattern in `fonoteka-ai-assist.spec.ts`.
|
||||
- Realtime client (app/composables/useCentrifugo.ts, useRealtimeSync.ts): `centrifuge` JSON protocol over `NUXT_PUBLIC_CENTRIFUGO_WS_URL`; after connect the layout resolves `GET /realtime/channels` → `{ data: { collection } }` and subscribes to that `collection:` channel. The acting user's own echo is delivered over the socket but does not refresh the UI, so the spec asserts on WebSocket frames, not on a UI refresh. Until the D-23 operator step, the shared Centrifugo has no `collection` namespace, so that subscribe reply carries `error` code 102 (`unknown channel`) on Go and on PHP alike.
|
||||
- From plan 02 (D-23): `dev-stack.sh check` prints `warn: centrifugo does not allow origin http://localhost:3119 ...` and one `warn: centrifugo namespace <ns> missing ...` line per namespace among `user`, `collection`, `wishlist` that the shared Centrifugo does not define, probed read-only through the HTTP API `history` call; it exits 0 when it printed only warnings. The README documents the namespace snippet.
|
||||
- The Bash secret-read guard blocks commands naming `.env` files other than templates; Playwright and Nuxt read them by themselves.
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
## Artifacts this phase produces
|
||||
|
||||
(This plan's share.)
|
||||
|
||||
- `tests/e2e/helpers/runTinker.ts`: Go branch in `backendAvailable()`, `runTinker()` and `seedE2EFixtures()`, driven by `E2E_BACKEND`, `E2E_GO_BIN`, `E2E_GO_DIR` (read at call time).
|
||||
- `tests/e2e/auth.setup.ts`: Go-only seed call before login.
|
||||
- `tests/e2e/.env.example`: documented `E2E_BACKEND`, `E2E_GO_BIN`, `E2E_GO_DIR`.
|
||||
- New specs `album-edit-rate-cover.spec.ts`, `album-search.spec.ts`, `wishlist.spec.ts`; rewritten `realtime.spec.ts` (tests `realtime baseline: albums page boots` and `realtime live: collection publication reaches the socket`).
|
||||
- `.planning/phases/14.2-local-fonoteka-frontend-on-local-summercms-backend/14.2-UAT.md`.
|
||||
|
||||
## Assumptions
|
||||
|
||||
- The developer's `tests/e2e/.env.local` holds E2E credentials of a PHP user; Go runs use the exported values from `dev-stack.sh`, which win over dotenv.
|
||||
- The album create body is the one `app/stores/fonoteka.ts` sends; the executor reads it rather than guessing.
|
||||
- Assumption A6 of the research (rewriting `realtime.spec.ts` is acceptable) is settled by D-21.
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tracer">
|
||||
<name>Task 1 (tracer): `E2E_BACKEND=go` seed switch, and the existing core specs pass on the Go stack with zero skips</name>
|
||||
<files>/media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/tests/e2e/helpers/runTinker.ts, /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/tests/e2e/auth.setup.ts, /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/tests/e2e/.env.example</files>
|
||||
<read_first>/media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/tests/e2e/helpers/runTinker.ts, /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/tests/e2e/auth.setup.ts, /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/tests/e2e/.env.example, /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/tests/e2e/helpers/fonotekaReachable.ts, /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/playwright.config.ts, /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/tests/contracts/session-recovery.test.mjs (contract style), ../fonoteka.go/scripts/dev-stack.sh (exec and e2e exports), .planning/phases/14.2-local-fonoteka-frontend-on-local-summercms-backend/14.2-RESEARCH.md (Q1, Q2, Pattern 6, G5, G7)</read_first>
|
||||
<action>Per D-14 (additions only; PHP lines stay byte-identical, no reformatting).
|
||||
|
||||
`runTinker.ts`: a small `goMode()` reading `process.env.E2E_BACKEND === 'go'` at call time (not at module load, so the plan 04 contract test can toggle it), and `goDir()` returning `E2E_GO_DIR` or `path.resolve(path.dirname(E2E_GO_BIN), '..')`. In Go mode: `backendAvailable()` returns true only when `E2E_GO_BIN` is set and executable (`fs.accessSync` with `X_OK`); `runTinker()` throws an Error saying PHP tinker is unavailable with `E2E_BACKEND=go` and naming `seedE2EFixtures()`; `seedE2EFixtures()`, placed before the E2E_EMAIL check because the Go seed has its own D-04 fallback, throws when `E2E_GO_BIN` is missing and otherwise runs `execFileSync(E2E_GO_BIN, ['fonoteka:dev-seed'], { cwd: goDir(), env: { ...process.env }, stdio: 'inherit' })`, letting a non-zero exit throw (fail closed, unlike the PHP warn-and-return path). Extend the header comment with the Go mode.
|
||||
|
||||
`auth.setup.ts`: at the start of the setup body, `if (process.env.E2E_BACKEND === 'go') seedE2EFixtures()` (import from `./helpers/runTinker`); nothing else changes.
|
||||
|
||||
`tests/e2e/.env.example`: a commented block documenting `E2E_BACKEND=go`, `E2E_GO_BIN=/path/to/fonoteka.go/bin/fonoteka`, `E2E_GO_DIR=/path/to/fonoteka.go`, and that `fonoteka.go/scripts/dev-stack.sh e2e` sets all three; placeholders only.
|
||||
|
||||
Commit inside the vue-fonoteka-app repository with explicit paths. Do not commit or bump the pointer in the PHP fonoteka superproject.</action>
|
||||
<verify>
|
||||
<automated>bash ../fonoteka.go/scripts/dev-stack.sh up --detach && bash ../fonoteka.go/scripts/dev-stack.sh e2e auth.setup.ts login.spec.ts smoke.spec.ts fonoteka-item.spec.ts i18n.spec.ts public-share.spec.ts</automated>
|
||||
<fails_when>Non-zero exit; a "refuse:" line; the printed counts show skipped greater than 0, unexpected greater than 0, flaky greater than 0 or expected 0.</fails_when>
|
||||
<automated>pnpm --dir /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app test:contracts</automated>
|
||||
<fails_when>Non-zero exit, or the node --test summary line "# fail" shows a number other than 0.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -c "E2E_BACKEND" /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/tests/e2e/helpers/runTinker.ts` prints at least 1, and `grep -c "'fonoteka:dev-seed'" /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/tests/e2e/helpers/runTinker.ts` prints 1.
|
||||
- `git -C /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app diff HEAD~1 -- tests/e2e/helpers/runTinker.ts | grep -c '^-[^-]'` prints 0 (additions only; no PHP line removed or reformatted).
|
||||
- `grep -c "E2E_BACKEND === 'go'" /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/tests/e2e/auth.setup.ts` prints 1.
|
||||
- `grep -c 'E2E_GO_BIN' /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/tests/e2e/.env.example` prints at least 1.
|
||||
- `git -C /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app diff --name-only HEAD~1 -- app` prints nothing.
|
||||
</acceptance_criteria>
|
||||
<done>The Playwright setup seeds the Go stack through `fonoteka:dev-seed`, and login, smoke, album create, i18n and public share pass against Go with no skipped test.</done>
|
||||
</task>
|
||||
|
||||
<task type="checkpoint:human-action" gate="blocking">
|
||||
<name>Task 2: operator adds the Nuxt origin and the `collection` / `wishlist` namespaces to the shared Centrifugo</name>
|
||||
<read_first>../fonoteka.go/README.md (§ Running locally with the Nuxt app, Centrifugo step), .planning/phases/14.2-local-fonoteka-frontend-on-local-summercms-backend/14.2-CONTEXT.md (D-18, D-23)</read_first>
|
||||
<action>Per D-18 and D-23, the operator edits `/etc/centrifugo/config.json` (Centrifugo v6 key names) in one sitting, keeping every existing entry:
|
||||
1. `client.allowed_origins` array: append `"http://localhost:3119"` and `"http://127.0.0.1:3119"`.
|
||||
2. `channel.namespaces` array (the one that already holds `presence`, `gxv`, `golemxv`, `user`, `family`, `drzewo`, `chat`, `friends`, `video`, `wave`): append the two objects `{"name": "collection", "allow_subscribe_for_client": true}` and `{"name": "wishlist", "allow_subscribe_for_client": true}`. Give them no `subscribe_proxy_enabled`, no proxy and no other option (dev-grade, D-23); production namespace authorization stays Phase 20's concern.
|
||||
3. Validate with `sudo centrifugo checkconfig --config=/etc/centrifugo/config.json`, then run `sudo systemctl restart centrifugo`.
|
||||
The executor never reads, prints or edits that file and never restarts the service; it only runs `dev-stack.sh check` before and after.</action>
|
||||
<instructions>
|
||||
Claude has already: built and started the Go stack, confirmed Centrifugo is reachable with the API key from the dev env file, and run `bash ../fonoteka.go/scripts/dev-stack.sh check`, which printed `warn: centrifugo does not allow origin http://localhost:3119` plus `warn: centrifugo namespace collection missing` and `warn: centrifugo namespace wishlist missing` (the `history` probe answered `102 unknown channel` for both).
|
||||
The one human step: the shared Centrifugo config is root-owned (mode 600, owner http) and serves the PHP apps too, so per D-18 the script never edits it. Adding two origins only widens the allow list, and the two new namespaces carry no proxy and touch no existing namespace, so PHP realtime keeps working (D-23). The exact snippet is in the README section "Running locally with the Nuxt app". `checkconfig` must pass before the restart: a broken JSON edit would take realtime down for every app on the instance.
|
||||
</instructions>
|
||||
<verification>`bash ../fonoteka.go/scripts/dev-stack.sh check` exits 0 and prints no `warn: centrifugo does not allow origin` line and no `warn: centrifugo namespace` line.</verification>
|
||||
<acceptance_criteria>
|
||||
- `bash ../fonoteka.go/scripts/dev-stack.sh check 2>&1 | grep -c 'does not allow origin'` prints 0.
|
||||
- `bash ../fonoteka.go/scripts/dev-stack.sh check 2>&1 | grep -c '^warn: centrifugo namespace'` prints 0 (D-23: `user`, `collection` and `wishlist` all defined).
|
||||
- `bash ../fonoteka.go/scripts/dev-stack.sh check > /dev/null 2>&1; test $? -eq 0` exits 0.
|
||||
</acceptance_criteria>
|
||||
<resume-signal>Type "centrifugo updated" once the origins and both namespaces are in, `checkconfig` passed and Centrifugo restarted, or describe the problem.</resume-signal>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: backend-agnostic specs for edit, rate, cover, search and wishlist, plus the realtime rewrite, green on Go and on PHP</name>
|
||||
<precondition>Task 2 is done (D-18, D-23): `dev-stack.sh check` prints no Centrifugo origin warning and no `warn: centrifugo namespace` line.</precondition>
|
||||
<files>/media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/tests/e2e/realtime.spec.ts, /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/tests/e2e/album-edit-rate-cover.spec.ts, /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/tests/e2e/album-search.spec.ts, /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/tests/e2e/wishlist.spec.ts, .planning/phases/14.2-local-fonoteka-frontend-on-local-summercms-backend/14.2-UAT.md (PHP-unavailable branch only)</files>
|
||||
<read_first>/media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/tests/e2e/realtime.spec.ts, /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/tests/e2e/fonoteka-item.spec.ts, /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/tests/e2e/fonoteka-ai-assist.spec.ts (PNG_1PX), /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/tests/e2e/responsive.spec.ts (albums-search-input), /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/app/stores/fonoteka.ts (album create, photos, rating, wishlist calls), /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/app/pages/albums/[id]/index.vue, /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/app/components/fonoteka/AlbumCoverGallery.vue, /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/app/components/fonoteka/RatingStars.vue, /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/app/components/fonoteka/WishlistGrid.vue, /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/app/pages/wishlist/new.vue, /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/app/composables/useCentrifugo.ts, /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/app/composables/useRealtimeSync.ts, .planning/phases/14.2-local-fonoteka-frontend-on-local-summercms-backend/14.2-RESEARCH.md (Q1 flow-to-spec map, self-seeding rule, G6, G8)</read_first>
|
||||
<action>Per D-11, D-16 and D-21. Shared rules for all four files: gate exactly like `fonoteka-item.spec.ts` (`test.skip` when `fonotekaReachable()` is false or E2E creds are missing, so PHP CI keeps its behaviour); create data through the UI or `page.request` on the Nuxt origin with the Bearer token from the `auth_token` cookie and a `Date.now()` suffix in every name; remove everything the spec created in a `finally` block or `afterEach`; confirm each `data-testid` with a grep of `app/` before using it; wait with `expect.poll`, `waitForResponse` or locator assertions, never fixed sleeps; never log the token.
|
||||
|
||||
`album-edit-rate-cover.spec.ts`: create an album through the API, open `/albums/{id}`; edit: `album-edit`, fill `album-name` with a new unique name, `album-save`, expect the new name on the page; rate: click a star in `rating-stars`, wait for the 2xx `PUT .../albums/{id}/rating`, reload, expect the rating shown; cover: `setInputFiles` on the gallery's file input with an in-memory PNG (the `PNG_1PX` pattern), wait for the 2xx `POST .../albums/{id}/photos`, then expect an `img` whose `src` contains `/storage/app/uploads/public/` with `naturalWidth > 0` (D-03 through the proxy).
|
||||
|
||||
`album-search.spec.ts`: create a uniquely named album through the API, open `/albums`, type the unique part into `albums-search-input`, `expect.poll` until a tile with that name is visible (15 s max), then clear and expect the list back.
|
||||
|
||||
`wishlist.spec.ts`: open `/wishlist/new`, add an entry with a unique name through the form, expect `` wishlist-item-<id> `` visible, click its `wishlist-item-remove`, confirm if the UI asks, expect it gone.
|
||||
|
||||
`realtime.spec.ts` (D-21, full rewrite, no old test IDs): test `realtime baseline: albums page boots` (always runs when gated in): `/albums` answers 200 and no `pageerror` fires. Test `realtime live: collection publication reaches the socket` (`test.skip` unless `E2E_CENTRIFUGO === '1'`): register `page.on('websocket')` for URLs containing `/connection/websocket` before navigating; open `/albums`; parse every received frame as one or more newline-separated JSON messages; expect a reply carrying `connect`; expect a sent `subscribe` whose `channel` starts with `collection:` and its reply without `error` (when the reply carries one, fail with a message naming the error code and, for 102 `unknown channel`, the D-23 namespace step in the fonoteka.go README); then create an album through the API and expect a received message whose `push.channel` starts with `collection:` and carries `pub` within 15 s; delete the album. These are Centrifugo protocol frames, the same for both backends.
|
||||
|
||||
PHP cross-check (D-11): after the Go run, stop the Go stack. Start PHP from `/media/nvme/dev/golem15/fonoteka` with `php artisan serve --host=127.0.0.1 --port=8422` in the background, wait up to 30 s, run the second verify command, then stop PHP. That command first probes PHP: `GET http://127.0.0.1:8422/_fonoteka/api/v1/albums`, the endpoint `fonotekaReachable()` uses, must answer 200 or 401 with an `X-Powered-By: PHP` header. It then takes one of two branches.
|
||||
- **Branch A, PHP answers.** Playwright runs the five specs against PHP. Nuxt starts from the app's own PHP-targeted `.env`. `e2e-verdict` must accept the report, and the command prints `PHP-PASSED`.
|
||||
- **Branch B, PHP cannot be started on this machine.** Before running the verify, add a section to `14.2-UAT.md` headed exactly `## PHP cross-check (Task 3): deferred`. It holds the startup error, the probe result and the manual PHP check: the same five specs, which the developer runs at Task 4 item 11. The verify then only confirms that the section exists and prints `PHP-DEFERRED`.
|
||||
|
||||
Branch B is never reported as a PHP pass, and nothing is skipped silently. The task record and the plan SUMMARY state "PHP cross-check deferred to Task 4 item 11", and the plan is complete only when item 11 is marked pass.
|
||||
|
||||
Commit in the vue-fonoteka-app repository; no file under `app/` changes (D-20).</action>
|
||||
<verify>
|
||||
<automated>bash ../fonoteka.go/scripts/dev-stack.sh up --detach && bash ../fonoteka.go/scripts/dev-stack.sh e2e auth.setup.ts realtime.spec.ts album-edit-rate-cover.spec.ts album-search.spec.ts wishlist.spec.ts</automated>
|
||||
<fails_when>Non-zero exit; a "refuse:" line; the printed counts show skipped greater than 0, unexpected greater than 0, flaky greater than 0 or expected 0.</fails_when>
|
||||
<automated>bash ../fonoteka.go/scripts/dev-stack.sh down && U=.planning/phases/14.2-local-fonoteka-frontend-on-local-summercms-backend/14.2-UAT.md && R="${TMPDIR:-/tmp}/php-e2e-14.2.json" && H="$(curl -s -m 5 -o /dev/null -D - http://127.0.0.1:8422/_fonoteka/api/v1/albums || true)" && if printf '%s\n' "$H" | grep -qE '^HTTP/[0-9.]+ (200|401)' && printf '%s\n' "$H" | grep -qi '^x-powered-by: *php'; then rm -f "$R"; env -u CI -u E2E_BACKEND E2E_FRONTEND_URL=http://localhost:3119 E2E_BACKEND_URL=http://localhost:8422 E2E_CENTRIFUGO=1 PLAYWRIGHT_JSON_OUTPUT_NAME="$R" pnpm --dir /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app exec playwright test auth.setup.ts realtime.spec.ts album-edit-rate-cover.spec.ts album-search.spec.ts wishlist.spec.ts --project=chromium --reporter=json > /dev/null; bash ../fonoteka.go/scripts/dev-stack.sh e2e-verdict "$R" && echo PHP-PASSED; else grep -qF '## PHP cross-check (Task 3): deferred' "$U" && echo 'PHP-DEFERRED: PHP not startable here; manual PHP run due at Task 4 item 11'; fi</automated>
|
||||
<fails_when>Branch A (the :8422 probe answered 200 or 401 with `X-Powered-By: PHP`): fails on a non-zero exit, i.e. `e2e-verdict` refused the PHP report because it is missing or shows skipped greater than 0, unexpected greater than 0, flaky greater than 0 or expected 0, or when the output lacks `PHP-PASSED`. Branch B (the probe failed, PHP not startable): fails on a non-zero exit, i.e. 14.2-UAT.md lacks the `## PHP cross-check (Task 3): deferred` section. A zero exit printing `PHP-DEFERRED` is not a pass. The task is recorded as "PHP cross-check deferred to Task 4 item 11", and the plan is not complete until item 11 passes.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -c 'realtime-status\|realtime-degraded' /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/tests/e2e/realtime.spec.ts` prints 0.
|
||||
- `grep -c 'connection/websocket' /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/tests/e2e/realtime.spec.ts` prints at least 1.
|
||||
- `grep -l 'fonotekaReachable' /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/tests/e2e/album-edit-rate-cover.spec.ts /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/tests/e2e/album-search.spec.ts /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/tests/e2e/wishlist.spec.ts /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/tests/e2e/realtime.spec.ts | wc -l` prints 4.
|
||||
- `grep -l 'Date.now()' /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/tests/e2e/album-edit-rate-cover.spec.ts /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/tests/e2e/album-search.spec.ts /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/tests/e2e/wishlist.spec.ts | wc -l` prints 3.
|
||||
- `grep -c '/storage/app/uploads/public/' /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/tests/e2e/album-edit-rate-cover.spec.ts` prints at least 1.
|
||||
- `grep -rc 'waitForTimeout' /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/tests/e2e/album-edit-rate-cover.spec.ts /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/tests/e2e/album-search.spec.ts /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/tests/e2e/wishlist.spec.ts /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/tests/e2e/realtime.spec.ts` prints 0 for each file.
|
||||
- `git -C /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app log -2 --name-only --format= -- app` prints nothing.
|
||||
</acceptance_criteria>
|
||||
<done>Edit, rate, cover upload, search, wishlist add/remove and realtime each have a self-seeding spec that passes on Go, and the stale realtime spec is gone. On PHP the specs either pass (`PHP-PASSED`) or, when PHP cannot be started here, sit in 14.2-UAT.md as deferred to Task 4 item 11 (`PHP-DEFERRED`), never reported as passed.</done>
|
||||
</task>
|
||||
|
||||
<task type="checkpoint:human-verify" gate="blocking">
|
||||
<name>Task 4: checklist-driven manual session against the Go stack, recorded as 14.2-UAT.md</name>
|
||||
<read_first>.planning/phases/14.2-local-fonoteka-frontend-on-local-summercms-backend/14.2-CONTEXT.md (D-13), ../fonoteka.go/README.md (§ Running locally with the Nuxt app), .planning/phases/14.1-oauth-identities-and-fonoteka-me-routes/14.1-VERIFICATION.md (evidence style)</read_first>
|
||||
<what-built>The local stack: `bash ../fonoteka.go/scripts/dev-stack.sh up --detach` is running (Claude starts it before this checkpoint) with Nuxt on http://localhost:3119 and Go on 127.0.0.1:8423; logs in `fonoteka.go/tmp/dev-stack/go.log` and `nuxt.log`. Log in with the E2E credentials from the dev env file, or `demo@example.com` / `changeme`.</what-built>
|
||||
<how-to-verify>
|
||||
In a normal browser at http://localhost:3119, tick each item (D-13):
|
||||
1. Login succeeds and lands outside /login.
|
||||
2. Browse collections: open the collection switcher and the collection page; the seeded "Moja kolekcja" lists Kind of Blue, Blue Train and Mingus Ah Um, each with its cover image visible.
|
||||
3. Browse albums: open an album detail page.
|
||||
4. Search: type "Mingus" in the albums search; the album appears; `grep -c 'album search engine failed' ../fonoteka.go/tmp/dev-stack/go.log` prints 0.
|
||||
5. Edit: change an album's name and save; the new name shows after reload.
|
||||
6. Rate: set a star rating; it persists after reload.
|
||||
7. Cover upload: add a photo to an album; it shows on the page.
|
||||
8. Wishlist: add an entry on /wishlist/new, then remove it.
|
||||
9. Realtime: with DevTools → Network → WS open on the Centrifugo socket, the reply to the `collection:` subscribe carries no `error` (no `102 unknown channel`, D-23); edit an album in a second tab; a `push` frame for the `collection:` channel arrives in the first tab.
|
||||
10. Failures are visible: `grep -cE 'status=5[0-9]{2}' ../fonoteka.go/tmp/dev-stack/go.log` prints 0, and `curl -sI http://localhost:3119/_fonoteka/api/v1/_dev/backend` shows no `X-Powered-By: PHP` header.
|
||||
11. PHP cross-check (D-11). If Task 3 printed `PHP-PASSED`, mark this item `n/a (automated in Task 3: PHP-PASSED)`. If Task 3 printed `PHP-DEFERRED`, 14.2-UAT.md has a `## PHP cross-check (Task 3): deferred` section. Start PHP on :8422 yourself, run the five specs listed there against it and report the counts. The item passes only on a run with no skipped, unexpected or flaky test. A failed run, or one not done, keeps the plan open.
|
||||
Claude then writes `.planning/phases/14.2-local-fonoteka-frontend-on-local-summercms-backend/14.2-UAT.md` with each item as pass or fail plus your notes, the date, the Go and Nuxt commits tested, and commits it as a planning doc. Any fail becomes a Go fix first (D-11), with a parity case where a recorded PHP response exists.
|
||||
</how-to-verify>
|
||||
<acceptance_criteria>
|
||||
- `.planning/phases/14.2-local-fonoteka-frontend-on-local-summercms-backend/14.2-UAT.md` exists and lists all eleven items with a pass or fail each. Item 11 may instead read `n/a`, but only when Task 3 printed `PHP-PASSED`.
|
||||
- When 14.2-UAT.md contains `## PHP cross-check (Task 3): deferred`, item 11 is marked pass and records the developer's PHP run counts. Otherwise the plan stays open (D-11).
|
||||
- `grep -ci 'realtime' .planning/phases/14.2-local-fonoteka-frontend-on-local-summercms-backend/14.2-UAT.md` prints at least 1.
|
||||
</acceptance_criteria>
|
||||
<resume-signal>Type "approved" with any notes, or list the failing items.</resume-signal>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| Playwright process → Go binary | The seed helper executes a binary path taken from the environment |
|
||||
| Specs → developer databases (Go and PHP) | Specs write and delete real rows |
|
||||
| Spec code → logs and reports | Bearer tokens are handled in test code |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-14.2-14 | Tampering | `runTinker.ts` PHP path | high | mitigate | Additions only, Go branch behind `E2E_BACKEND === 'go'` read at call time; diff gate on removed lines; plan 04 contract test pins the PHP argv and snippets |
|
||||
| T-14.2-15 | Information Disclosure | E2E credentials and the `auth_token` Bearer in specs | medium | mitigate | Credentials from env only; the token is read from the cookie jar and only set as a request header; never logged or attached to reports |
|
||||
| T-14.2-16 | Tampering | developer data in the PHP and Go databases | medium | mitigate | Every created row has a `Date.now()` name and is deleted by the spec; specs never edit or delete pre-existing rows |
|
||||
| T-14.2-SC | Tampering | npm installs | high | mitigate | No install: `@playwright/test` and node built-ins already in the app |
|
||||
|
||||
ASVS L1; the high threat is mitigated with a test-backed control.
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- Go gate subset (D-15) green through `dev-stack.sh e2e` with zero skips; the PHP run of the new and rewritten specs green through `e2e-verdict` (`PHP-PASSED`), or, when PHP cannot be started, the `## PHP cross-check (Task 3): deferred` section in 14.2-UAT.md with Task 4 item 11 marked pass by the developer's PHP run.
|
||||
- `pnpm --dir /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app test:contracts` green.
|
||||
- `git -C /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app status --porcelain -- app` prints nothing.
|
||||
- 14.2-UAT.md committed with every D-13 item.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- Every core flow named in D-13 has an automated spec that passes against Go.
|
||||
- The same specs pass against PHP (D-11), and no app source changed.
|
||||
- The operator step for Centrifugo (origins and the `collection` / `wishlist` namespaces, D-18 and D-23) is done, `check` prints no Centrifugo warning, and the manual session is on record.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/14.2-local-fonoteka-frontend-on-local-summercms-backend/14.2-03-SUMMARY.md` when done
|
||||
</output>
|
||||
@@ -0,0 +1,263 @@
|
||||
---
|
||||
phase: 14.2-local-fonoteka-frontend-on-local-summercms-backend
|
||||
plan: 04
|
||||
type: execute
|
||||
wave: 4
|
||||
depends_on: ["14.2-01", "14.2-02", "14.2-03"]
|
||||
files_modified:
|
||||
- scripts/check-phase14.2.sh
|
||||
- ../fonoteka.go/plugins/golem15/fonoteka/console/dev_seed_test.go
|
||||
- ../fonoteka.go/plugins/golem15/fonoteka/dev_stack_test.go
|
||||
- ../fonoteka.go/plugins/golem15/fonoteka/dev_marker_test.go
|
||||
- modules/surf/uploads_test.go
|
||||
- modules/surf/accesslog_test.go
|
||||
- /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/tests/contracts/e2e-backend-switch.test.mjs
|
||||
autonomous: true
|
||||
requirements: [QA-05]
|
||||
estimate:
|
||||
tokens: 240000
|
||||
raw_tokens: 240000
|
||||
tasks: 3
|
||||
confidence: low # uncalibrated (factor 1, sample_count 0); last plan of the phase per the project rule and D-17
|
||||
must_haves:
|
||||
truths:
|
||||
- "Per D-17 and D-22 (the last plan, project rule), `bash scripts/check-phase14.2.sh --all` runs `go vet ./...` and `go test ./...` in summercms.go and in the fonoteka.go workspace, the named phase tests through a skip-intolerant detector, the docs checks, the parity corpus, the Nuxt contracts, the guard refusal cases and the Go-gate Playwright subset, and its last line is `Phase 14.2 gate passed`."
|
||||
- "Per D-02 and D-17, `TestDevSeedRefusesProduction` proves an unset SUMMER_ENV, `production`, `development`, `testing` and `staging` are refused before any connection attempt, while `local` and `dev` pass the gate; `TestDevSeedIdempotent` proves a second run adds no row and prints `unchanged` for every entity; `TestDevSeedShape` proves the user, context collection, three albums with one stored cover blob each, wishlist entry, rating, `inv_` token row and `search_use_typesense = true`."
|
||||
- "Per D-04 (edge: empty), `TestDevSeedShape` proves the `demo@example.com` / `changeme` fallback when E2E_EMAIL and E2E_PASSWORD are empty, and a `DEV_SEED_API_TOKEN` without the configured prefix is refused."
|
||||
- "Per D-05 and research G1, `TestDevSeedOpensDatabase`, `TestReindexOpensDatabase` and `TestCommandsOpenDatabase` run the seed, `fonoteka:reindex`, `fonoteka:oauth-client` and `fonoteka:prune-notifications` on an app with no published database and prove each publishes its own handle (seeded slugs non-empty, so the OnDatabase hooks ran) and never prints `database is not configured`."
|
||||
- "Per D-09, D-10 and D-17, `TestDevMarkerRoute` proves the marker answers 200 with exactly `backend`, `app`, `env` and `Cache-Control: no-store` for `local` and `dev`, carries no auth middleware, is absent from the route table and 404 for `production`, `development` and `testing`, and does not appear in `parity/manifest.yaml`."
|
||||
- "Per D-19, `TestUploadsMount` proves the key off registers no route; the key on serves a public blob with its content type and `X-Content-Type-Options: nosniff`, answers HEAD, and 404s an `is_public = false` row, a missing row, a traversal path and an unpublished bucket or database; a plugin route under the prefix fails BuildRouter."
|
||||
- "Per D-12, `TestAccessLog` proves the key off logs nothing; the key on logs the matched pattern and status for 200, 404-from-handler and a recovered panic (500), the raw path without query only for an unmatched request, the pattern behind a locale-prefixed path, an integer `duration_ms`, never a query string, header or Authorization value, and keeps `http.ResponseController` flushing working."
|
||||
- "Per D-09 and D-17 (edge: boundary, adjacency), `--guard` feeds planted env files to `dev-stack.sh guard` and requires a `refuse:` line and a non-zero exit for an origin on :8422, an unset internal origin, mismatched origins, `https://plytarium.com`, a fake server that answers with `X-Powered-By: PHP/8.5.10`, and a fake marker reporting another backend."
|
||||
- "Per D-13, D-15 and research G7, `--e2e` runs the gate specs through `dev-stack.sh e2e` and fails on any skipped, unexpected or flaky test or zero expected tests; the D-15 report-only specs (`fonoteka-ai-assist`, `fonoteka-bulk-add`, `offline`, `registration-invitation`, `responsive`, `landing`) are run and their counts printed without blocking."
|
||||
- "Per research G8 and D-12, `--e2e` fails when `tmp/dev-stack/go.log` contains `album search engine failed` or a 5xx status record after the gate run."
|
||||
- "Per D-06, `--e2e` fails closed when Postgres, Typesense or Centrifugo is unreachable (the `dev-stack.sh` refusal propagates) and never turns a missing service or a missing Docker daemon into a skip."
|
||||
- "Per D-14, `tests/contracts/e2e-backend-switch.test.mjs` proves that with `E2E_BACKEND=go` `seedE2EFixtures()` executes `E2E_GO_BIN` with exactly `['fonoteka:dev-seed']` in `E2E_GO_DIR`, throws on a non-zero exit and on a missing binary, `runTinker()` throws, and with the switch unset the PHP tinker argv and seed snippets are unchanged."
|
||||
- "Per D-10, `--parity` requires the TestParityCorpus summary `recorded 175/175 passing 175 failing 0 unrecorded 0 pending 0`."
|
||||
artifacts:
|
||||
- path: "scripts/check-phase14.2.sh"
|
||||
provides: "fail-closed phase gate: --self-test --go --docs --parity --contracts --guard --e2e --all"
|
||||
contains: "Phase 14.2 gate passed"
|
||||
- path: "../fonoteka.go/plugins/golem15/fonoteka/console/dev_seed_test.go"
|
||||
provides: "TestDevSeedRefusesProduction, TestDevSeedIdempotent, TestDevSeedShape"
|
||||
contains: "TestDevSeedIdempotent"
|
||||
- path: "../fonoteka.go/plugins/golem15/fonoteka/dev_stack_test.go"
|
||||
provides: "TestDevSeedOpensDatabase, TestReindexOpensDatabase, TestCommandsOpenDatabase"
|
||||
contains: "TestReindexOpensDatabase"
|
||||
- path: "../fonoteka.go/plugins/golem15/fonoteka/dev_marker_test.go"
|
||||
provides: "TestDevMarkerRoute"
|
||||
contains: "TestDevMarkerRoute"
|
||||
- path: "modules/surf/uploads_test.go"
|
||||
provides: "TestUploadsMount"
|
||||
contains: "TestUploadsMount"
|
||||
- path: "modules/surf/accesslog_test.go"
|
||||
provides: "TestAccessLog (next to TestAccessLogSmoke from plan 01)"
|
||||
contains: "TestAccessLog"
|
||||
- path: "/media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/tests/contracts/e2e-backend-switch.test.mjs"
|
||||
provides: "D-14 switch contract"
|
||||
contains: "fonoteka:dev-seed"
|
||||
key_links:
|
||||
- from: "scripts/check-phase14.2.sh"
|
||||
to: "../fonoteka.go/scripts/dev-stack.sh"
|
||||
via: "--guard calls guard with planted DEV_STACK_ENV_FILE; --e2e calls up --detach, guard, e2e and down; --self-test calls e2e-verdict on planted reports"
|
||||
pattern: "dev-stack.sh"
|
||||
- from: "scripts/check-phase14.2.sh"
|
||||
to: "../fonoteka.go/plugins/golem15/fonoteka/console/dev_seed_test.go"
|
||||
via: "APP_REQUIRE names every phase test the detector must see pass"
|
||||
pattern: "TestDevSeedRefusesProduction"
|
||||
- from: "/media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/tests/contracts/e2e-backend-switch.test.mjs"
|
||||
to: "/media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/tests/e2e/helpers/runTinker.ts"
|
||||
via: "imports seedE2EFixtures, runTinker and backendAvailable through Node type stripping"
|
||||
pattern: "seedE2EFixtures"
|
||||
prohibitions:
|
||||
- requirement_id: QA-05
|
||||
category: transparency
|
||||
statement: "The phase gate MUST NOT pass when any gate spec or named Go test is skipped, missing or matched zero tests; a skip is a failure"
|
||||
status: resolved
|
||||
verification: test
|
||||
- requirement_id: QA-05
|
||||
category: transparency
|
||||
statement: "The phase gate MUST NOT downgrade an unreachable service, a missing PHP-free marker or an unavailable Docker daemon to a pass or a skip; it fails closed"
|
||||
status: resolved
|
||||
verification: test
|
||||
---
|
||||
|
||||
## Phase Goal
|
||||
|
||||
**As a** developer, **I want to** run the existing `vue-fonoteka-app` locally against the local `fonoteka.go` SummerCMS backend (not PHP), log in and use the real Nuxt flows, **so that** the port is proven in daily use before production cutover.
|
||||
|
||||
This plan's slice: the guarantee. After it, one command proves the local glue still works and fails closed on any regression, and every Go piece the phase added has unit coverage.
|
||||
|
||||
<objective>
|
||||
Deliver D-17 (and D-22 plan 04, the last plan per the project rule): the Go unit tests for `fonoteka:dev-seed` (refusal, idempotency, shape, own database), the reindex and command database fix, the marker route, the uploads mount and the access log; the Nuxt contract test for the `E2E_BACKEND=go` switch; and `summercms.go/scripts/check-phase14.2.sh` following the `check-phase*.sh` pattern.
|
||||
|
||||
Purpose: Success Criterion 4 of the phase (the new glue has tests and a fail-closed check script) and the evidence `/gsd-verify-work 14.2` consumes.
|
||||
Output: test files in fonoteka.go, summercms.go and vue-fonoteka-app (each in its own repository commit) and the gate script in summercms.go.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/gsd-core/workflows/execute-plan.md
|
||||
@~/.claude/gsd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/PROJECT.md
|
||||
@.planning/STATE.md
|
||||
@.planning/phases/14.2-local-fonoteka-frontend-on-local-summercms-backend/14.2-CONTEXT.md
|
||||
@.planning/phases/14.2-local-fonoteka-frontend-on-local-summercms-backend/14.2-RESEARCH.md
|
||||
@.planning/phases/14.2-local-fonoteka-frontend-on-local-summercms-backend/14.2-VALIDATION.md
|
||||
@.planning/phases/14.2-local-fonoteka-frontend-on-local-summercms-backend/14.2-01-SUMMARY.md
|
||||
@.planning/phases/14.2-local-fonoteka-frontend-on-local-summercms-backend/14.2-02-SUMMARY.md
|
||||
@.planning/phases/14.2-local-fonoteka-frontend-on-local-summercms-backend/14.2-03-SUMMARY.md
|
||||
@scripts/check-phase14.2.1.sh
|
||||
|
||||
<interfaces>
|
||||
- Plan 01: `console.DevSeedCommand(app)`, `console.DevEnvironment(app)`, command name `fonoteka:dev-seed`, output lines `<entity>: created|updated|unchanged`, token name `dev-seed-mcp`, env `E2E_EMAIL`, `E2E_PASSWORD`, `DEV_SEED_API_TOKEN`; unexported `commandDatabase`/`commandBucket`; marker `GET /_fonoteka/api/v1/_dev/backend` via `(*Plugin).devMarkerRoutes`; surf `mountUploads` (key `storage.uploads.serve`) and `accessLog` (key `http.access_log`, record `http request` with `method`, `pattern`, `status`, `duration_ms`, `path` on unmatched); smoke tests `TestDevStackSmoke`, `TestAccessLogSmoke`.
|
||||
- Plan 02: `dev-stack.sh guard` reads `DEV_STACK_ENV_FILE` only; `e2e-verdict <file>` reads no env; `e2e [--report-only] <spec...>`; `up --detach` is idempotent and `status` exits 0 only when both processes answer; logs in `fonoteka.go/tmp/dev-stack/go.log`.
|
||||
- Plan 03: `runTinker.ts` reads `E2E_BACKEND`, `E2E_GO_BIN`, `E2E_GO_DIR` at call time; gate specs `auth.setup.ts login.spec.ts smoke.spec.ts fonoteka-item.spec.ts public-share.spec.ts realtime.spec.ts i18n.spec.ts album-edit-rate-cover.spec.ts album-search.spec.ts wishlist.spec.ts`.
|
||||
- Test harnesses: console package `oauthConsoleDB(t)` (shared testcontainer, migrated user and fonoteka schemas, hooks registered), `testInput`, `stripANSI`, `reindexApp` (fake engine); plugin root package `bootPG`, `bootDB(t)`, `bootConfig(t)` (SUMMER_ENV=development), `stubInvToken`; attach `attachGorm` pattern (lagoon/attach/lifecycle_test.go:268) for a Postgres with `system_files`; Nuxt contracts import `.ts` through Node type stripping (tests/contracts/session-recovery.test.mjs).
|
||||
- `check-phase14.2.1.sh`: `detect()` python3 heredoc (exit 1 fail/build, 2 skip, 3 zero or "no tests to run", 4 non-JSON, 5 missing named test, 6 data race), `go_json DIR ARGS`, `expect_detect`, `run_self_test` mode-presence loop, `run_all` re-invoking each stage with PASS/FAIL lines, `case "${1:---all}"` dispatch.
|
||||
- The Bash secret-read guard blocks commands naming `.env` files other than templates: planted guard inputs are named `guard-*.conf`, never `.env*`.
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
## Artifacts this phase produces
|
||||
|
||||
(This plan's share.)
|
||||
|
||||
- `summercms.go/scripts/check-phase14.2.sh` modes `--self-test`, `--go`, `--docs`, `--parity`, `--contracts`, `--guard`, `--e2e`, `--all`; overridable paths `PHASE142_ROOT`, `PHASE142_APP` (default `$ROOT/../fonoteka.go`), `PHASE142_NUXT` (default `/media/nvme/dev/golem15/fonoteka/vue-fonoteka-app`); arrays `APP_REQUIRE`, `CONSOLE_REQUIRE`, `FRAMEWORK_REQUIRE`, `GATE_SPECS`, `REPORT_SPECS`; final line `Phase 14.2 gate passed`.
|
||||
- Go tests `TestDevSeedRefusesProduction`, `TestDevSeedIdempotent`, `TestDevSeedShape` (console), `TestDevSeedOpensDatabase`, `TestReindexOpensDatabase`, `TestCommandsOpenDatabase`, `TestDevMarkerRoute` (plugin root), `TestUploadsMount`, `TestAccessLog` (surf).
|
||||
- Nuxt contract test `tests/contracts/e2e-backend-switch.test.mjs`.
|
||||
|
||||
## Assumptions
|
||||
|
||||
- Docker is available for testcontainers; a missing daemon fails the gate (no `-short` evidence).
|
||||
- The full `go test ./...` runs are exit-code checks (other packages may skip by design); the skip-intolerant detector covers the phase's own named tests.
|
||||
- `--e2e` and `--guard`'s positive case need the developer's configured env file and running host services, like plan 02 and 03.
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tracer">
|
||||
<name>Task 1 (tracer): `check-phase14.2.sh --go` proves the seed's production refusal end to end (script, go test -json, detector)</name>
|
||||
<files>scripts/check-phase14.2.sh, ../fonoteka.go/plugins/golem15/fonoteka/console/dev_seed_test.go</files>
|
||||
<read_first>scripts/check-phase14.2.1.sh (whole file: header, detect, go_json, expect_detect, run_self_test, run_all, dispatch), scripts/check-phase14.sh (go -C usage for sibling repos), ../fonoteka.go/plugins/golem15/fonoteka/console/dev_seed.go, ../fonoteka.go/plugins/golem15/fonoteka/console/oauth_client_test.go (testInput, TestMain, oauthConsoleDB), ../fonoteka.go/plugins/golem15/fonoteka/console/phase14_commands_test.go (reindexApp config via compass.Open, stripANSI), modules/compass/config.go (Options, Environment)</read_first>
|
||||
<action>Per D-17. New `scripts/check-phase14.2.sh` modelled on check-phase14.2.1.sh: header comment describing the gate and every mode, `set -euo pipefail`, `unset FORCE_COLOR`, `ROOT="${PHASE142_ROOT:-...}"`, `APP="${PHASE142_APP:-$ROOT/../fonoteka.go}"`, `NUXT="${PHASE142_NUXT:-/media/nvme/dev/golem15/fonoteka/vue-fonoteka-app}"`. Copy `detect()`, `go_json`, `expect_detect` semantics unchanged. `run_self_test`: `bash -n` on this script, the planted detector cases for exit codes 0-6, and the mode-presence loop, ending with the line `phase14.2 self-test passed`. `run_go` for now: `go -C "$APP" vet ./plugins/golem15/fonoteka/...` and `go_json "$APP" ./plugins/golem15/fonoteka/console -count=1 -run '^TestDevSeedRefusesProduction$'` with `REQUIRE_TESTS` from a `CONSOLE_REQUIRE=(TestDevSeedRefusesProduction)` array. Dispatch `--self-test`, `--go`, `--all` (self-test then go, PASS/FAIL lines, final `Phase 14.2 gate passed`). Later modes are added in Task 3.
|
||||
|
||||
`console/dev_seed_test.go`, `TestDevSeedRefusesProduction` (no database needed): table over environment values unset, `production`, `development`, `testing`, `staging`, `local`, `dev`. Build each config with `compass.Open` (temp dir with `app.yaml`, `Environ` carrying `SUMMER_ENV` only when set) and `database.dsn` pointing at `postgres://seed:seed@127.0.0.1:1/none?sslmode=disable&connect_timeout=1`, no published database. For the refused values, `DevSeedCommand(app).Run` returns an error containing `refused in environment` and the app has no published `*gorm.DB` afterwards (nothing was opened). For `local` and `dev`, the error does not contain `refused in environment` (the gate passed and the connect failed). Also assert `DevEnvironment` returns the same verdicts.</action>
|
||||
<verify>
|
||||
<automated>bash scripts/check-phase14.2.sh --self-test && bash scripts/check-phase14.2.sh --go</automated>
|
||||
<fails_when>Non-zero exit; any line starting with "refuse:"; output lacks "phase14.2 self-test passed".</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `bash -n scripts/check-phase14.2.sh` exits 0.
|
||||
- `grep -c 'TestDevSeedRefusesProduction' scripts/check-phase14.2.sh` prints at least 1.
|
||||
- `grep -c '^func TestDevSeedRefusesProduction' ../fonoteka.go/plugins/golem15/fonoteka/console/dev_seed_test.go` prints 1.
|
||||
- `grep -c 'staging' ../fonoteka.go/plugins/golem15/fonoteka/console/dev_seed_test.go` prints at least 1.
|
||||
</acceptance_criteria>
|
||||
<done>The gate script runs a named Go test through the skip-intolerant detector and the seed's production refusal is proven without a database.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 2: unit tests for the seed shape and idempotency, the own-database commands, the marker, the uploads mount and the access log</name>
|
||||
<files>../fonoteka.go/plugins/golem15/fonoteka/console/dev_seed_test.go, ../fonoteka.go/plugins/golem15/fonoteka/dev_stack_test.go, ../fonoteka.go/plugins/golem15/fonoteka/dev_marker_test.go, modules/surf/uploads_test.go, modules/surf/accesslog_test.go</files>
|
||||
<read_first>../fonoteka.go/plugins/golem15/fonoteka/console/dev_seed.go, ../fonoteka.go/plugins/golem15/fonoteka/console/database.go, ../fonoteka.go/plugins/golem15/fonoteka/console/oauth_client_test.go, ../fonoteka.go/plugins/golem15/fonoteka/console/phase14_commands_test.go, ../fonoteka.go/plugins/golem15/fonoteka/dev_marker.go, ../fonoteka.go/plugins/golem15/fonoteka/dev_stack_smoke_test.go, ../fonoteka.go/plugins/golem15/fonoteka/plugin_boot_test.go, ../fonoteka.go/plugins/golem15/fonoteka/routes_isolation_test.go (BuildRouter table walk), ../fonoteka.go/parity/manifest.yaml, ../fonoteka.go/plugins/golem15/user/classes/api_tokens.go, modules/surf/uploads.go, modules/surf/accesslog.go, modules/surf/accesslog_test.go (TestAccessLogSmoke), modules/surf/router_test.go, modules/lagoon/attach/static_test.go, modules/lagoon/attach/lifecycle_test.go (attachGorm 268)</read_first>
|
||||
<behavior>
|
||||
- TestDevSeedShape: E2E_EMAIL/E2E_PASSWORD set → that user exists activated, MustChangePassword false, HasSelfSetPassword true, bcrypt matches; both empty → `demo@example.com` with `changeme`; context collection of kind collection with Kind of Blue, Blue Train, Mingus Ah Um, each with one `photos` attachment whose blob exists in the mem:// bucket; wishlist collection with A Love Supreme; one rating 4 on Kind of Blue; one token row named dev-seed-mcp whose stored hash matches the printed `inv_` secret; settings row 1 has search_use_typesense true; DEV_SEED_API_TOKEN without the prefix → refusal and no row written; DEV_SEED_API_TOKEN with the prefix → row hash equals HashAPIToken(value).
|
||||
- TestDevSeedIdempotent: second run prints only `unchanged` lines (token line without a secret) and every table count is equal; a third run with a different E2E_PASSWORD prints `user: updated` and changes nothing else.
|
||||
- TestDevSeedOpensDatabase / TestReindexOpensDatabase / TestCommandsOpenDatabase: app with only `database.dsn` (bootPG) and no published handle → each command publishes `*gorm.DB`, never prints `database is not configured`; seeded album slugs are non-empty; reindex with no Typesense key prints the not-configured line.
|
||||
- TestDevMarkerRoute: local and dev → 200, exact three keys, env matches, `Cache-Control: no-store`, no `jwt.auth` in the route's middleware; production, development, testing → absent from BuildRouter's table and 404 from Assemble; `parity/manifest.yaml` contains no `_dev/`.
|
||||
- TestUploadsMount: key off → no route and 404; key on → public blob 200 with its content type and nosniff, HEAD 200 with empty body; is_public false, missing row, `..` traversal and encoded traversal → 404; bucket or DB unpublished → 404; a custom `public_path_prefix` is honoured; a plugin route under the prefix makes BuildRouter return an error.
|
||||
- TestAccessLog: key off → no record; key on → pattern and status for 200, a handler 404 and a recovered panic 500; unmatched request → status 404 and `path` without query; a locale-prefixed request still records the route pattern; `duration_ms` is an integer ≥ 0; no record contains the query value, an `Authorization` header value or a header name; `http.NewResponseController(w).Flush()` inside a handler succeeds.
|
||||
</behavior>
|
||||
<action>Per D-02, D-04, D-09, D-10, D-12, D-17, D-19 and research G1. Write the tests in `<behavior>` first; they must pass against the plan 01 code. If one fails because the code is wrong, fix the code in the same commit with the test and say so in the summary; do not weaken an assertion.
|
||||
|
||||
Placement: `TestDevSeedShape` and `TestDevSeedIdempotent` in `console/dev_seed_test.go` on `oauthConsoleDB(t)` with a published mem:// bucket, `golem15.user.api_tokens.prefix` set to `inv_`, SUMMER_ENV local, and `t.Setenv` for the E2E and token variables; count rows per table before and after. The own-database tests in the plugin root `dev_stack_test.go`, using `bootDB(t)` to migrate and a fresh app with `database.dsn` from `bootPG`, plugins activated, no handle published (same set-up as `TestDevStackSmoke`). `TestDevMarkerRoute` in `dev_marker_test.go`, building configs with each SUMMER_ENV and reading the manifest through a path relative to the plugin directory. `TestUploadsMount` in `modules/surf/uploads_test.go` with a testcontainers Postgres holding `system_files` (the `attachGorm` pattern) and `memblob`; `TestAccessLog` added to `modules/surf/accesslog_test.go` next to `TestAccessLogSmoke`, using `blog` / `acme` fixture names. Missing Docker is a test failure, never a skip.
|
||||
|
||||
Commits: fonoteka.go tests, then summercms.go tests, each with explicit paths.</action>
|
||||
<verify>
|
||||
<automated>go -C ../fonoteka.go vet ./plugins/golem15/fonoteka/... && go -C ../fonoteka.go test ./plugins/golem15/fonoteka/console -count=1 -v -run '^(TestDevSeedRefusesProduction|TestDevSeedShape|TestDevSeedIdempotent|TestReindexCommand)$' && go -C ../fonoteka.go test ./plugins/golem15/fonoteka -count=1 -v -run '^(TestDevSeedOpensDatabase|TestReindexOpensDatabase|TestCommandsOpenDatabase|TestDevMarkerRoute|TestDevStackSmoke)$'</automated>
|
||||
<fails_when>Non-zero exit; output contains "--- FAIL", "--- SKIP" or "no tests to run"; output lacks "--- PASS: TestDevSeedIdempotent", "--- PASS: TestDevSeedShape" or "--- PASS: TestDevMarkerRoute".</fails_when>
|
||||
<automated>go vet ./modules/surf/ && go test ./modules/surf/ -count=1 -v -run '^(TestUploadsMount|TestAccessLog|TestAccessLogSmoke)$'</automated>
|
||||
<fails_when>Non-zero exit; output contains "--- FAIL", "--- SKIP" or "no tests to run"; output lacks "--- PASS: TestUploadsMount" or "--- PASS: TestAccessLog".</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -c '^func Test' ../fonoteka.go/plugins/golem15/fonoteka/console/dev_seed_test.go` prints at least 3.
|
||||
- `grep -cE '^func Test(DevSeedOpensDatabase|ReindexOpensDatabase|CommandsOpenDatabase)\(' ../fonoteka.go/plugins/golem15/fonoteka/dev_stack_test.go` prints 3.
|
||||
- `grep -c 'manifest.yaml' ../fonoteka.go/plugins/golem15/fonoteka/dev_marker_test.go` prints at least 1.
|
||||
- `grep -c 'nosniff' modules/surf/uploads_test.go` prints at least 1, and `grep -c '^func TestAccessLog(' modules/surf/accesslog_test.go` prints 1.
|
||||
- `grep -c 't.Skip' modules/surf/uploads_test.go ../fonoteka.go/plugins/golem15/fonoteka/dev_stack_test.go ../fonoteka.go/plugins/golem15/fonoteka/dev_marker_test.go` prints 0 for each file.
|
||||
</acceptance_criteria>
|
||||
<done>Every Go behaviour the phase added fails a named test when it regresses: seed refusal, shape, idempotency and own database, command database fix, marker gating, uploads mount and access log.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: Nuxt switch contract test and the remaining gate stages (docs, parity, contracts, guard, e2e, all)</name>
|
||||
<files>scripts/check-phase14.2.sh, /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/tests/contracts/e2e-backend-switch.test.mjs</files>
|
||||
<read_first>scripts/check-phase14.2.sh (Task 1), scripts/check-phase14.2.1.sh (run_docs, run_all), ../fonoteka.go/scripts/dev-stack.sh (guard, e2e, e2e-verdict, status, up, down), /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/tests/e2e/helpers/runTinker.ts, /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/tests/e2e/auth.setup.ts, /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/tests/contracts/session-recovery.test.mjs, /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/package.json (test:contracts), .planning/phases/14.2-local-fonoteka-frontend-on-local-summercms-backend/14.2-RESEARCH.md (Q2 gate run, Q6, Pattern 4, G7, G8)</read_first>
|
||||
<action>Contract test (D-14), `tests/contracts/e2e-backend-switch.test.mjs` with `node:test`: import `seedE2EFixtures`, `runTinker` and `backendAvailable` from `../e2e/helpers/runTinker.ts` (type stripping). Go mode cases, toggling `process.env` per test and restoring it: a temp executable shell script as `E2E_GO_BIN` that writes its argv and cwd to a temp file → exactly `fonoteka:dev-seed` and cwd equal to `E2E_GO_DIR`; a script exiting 3 → `seedE2EFixtures()` throws; `E2E_GO_BIN` unset → `backendAvailable()` false and `seedE2EFixtures()` throws; `runTinker('x')` throws. Source cases on the file text: the PHP argv `['artisan', 'tinker', '--execute', phpScript]`, `firstOrNew` and `e2e-seed-post` are present; `auth.setup.ts` calls `seedE2EFixtures()` only inside an `E2E_BACKEND === 'go'` condition. Commit in vue-fonoteka-app.
|
||||
|
||||
Gate stages in `scripts/check-phase14.2.sh` (D-17):
|
||||
- `--go`: `go vet ./...` and `go test ./... -count=1 -timeout 60m` in ROOT (exit code), the same with `go -C "$APP"` (workspace), then `go_json` with `CONSOLE_REQUIRE=(TestDevSeedRefusesProduction TestDevSeedShape TestDevSeedIdempotent TestReindexCommand)` on `./plugins/golem15/fonoteka/console`, `APP_REQUIRE=(TestDevStackSmoke TestDevSeedOpensDatabase TestReindexOpensDatabase TestCommandsOpenDatabase TestDevMarkerRoute)` on `./plugins/golem15/fonoteka`, `FRAMEWORK_REQUIRE=(TestUploadsMount TestAccessLog TestAccessLogSmoke)` on `./modules/surf`, each run with a `-run` regex built from its array.
|
||||
- `--docs`: `go test ./cmd/summer -count=1 -run '^TestDocsTree$'` and `go run ./cmd/summer docs:build --check` in ROOT.
|
||||
- `--parity`: `go -C "$APP" test ./parity -count=1 -run TestParityCorpus -timeout 30m -v`, refusing unless the output has `recorded 175/175 passing 175 failing 0 unrecorded 0 pending 0`.
|
||||
- `--contracts`: `pnpm --dir "$NUXT" test:contracts`, refusing unless it exits 0 and reports `# fail 0`.
|
||||
- `--guard`: in a temp dir, planted files `guard-*.conf` fed through `DEV_STACK_ENV_FILE` to `bash "$APP/scripts/dev-stack.sh" guard`; cases: both origins `http://127.0.0.1:8422`; internal origin unset; origins differing; both `https://plytarium.com`; a python3 `http.server` on a free loopback port answering 404 with `X-Powered-By: PHP/8.5.10` (origins and `DEV_STACK_GO_ADDR` set to it); a python3 server answering 200 `{"backend":"winter"}`. Each case must exit non-zero and print a `refuse:` line, else the stage refuses naming the case. Fake servers are killed by a trap.
|
||||
- `--e2e` (D-06, D-13, D-15): note whether `dev-stack.sh status` is already green; run `dev-stack.sh up --detach` (its refusals propagate; a missing service fails the stage), `dev-stack.sh guard` with the real env file, then `dev-stack.sh e2e` with `GATE_SPECS=(auth.setup.ts login.spec.ts smoke.spec.ts fonoteka-item.spec.ts public-share.spec.ts realtime.spec.ts i18n.spec.ts album-edit-rate-cover.spec.ts album-search.spec.ts wishlist.spec.ts)`; then `dev-stack.sh e2e --report-only` with `REPORT_SPECS=(fonoteka-ai-assist.spec.ts fonoteka-bulk-add.spec.ts offline.spec.ts registration-invitation.spec.ts responsive.spec.ts landing.spec.ts)`, printing counts without failing; then refuse when `$APP/tmp/dev-stack/go.log` contains `album search engine failed` or a line matching `status=5[0-9][0-9]`; run `dev-stack.sh down` only when this stage started the stack.
|
||||
- `--self-test` also: planted Playwright reports (skipped 1, unexpected 1, flaky 1, expected 0, malformed JSON) must each be refused by `dev-stack.sh e2e-verdict`, and a clean report accepted; `bash -n` on dev-stack.sh; the mode-presence loop covers every mode below.
|
||||
- `--all`: self-test, go, docs, parity, contracts, guard, e2e in that order with PASS/FAIL lines; final line `Phase 14.2 gate passed`.</action>
|
||||
<verify>
|
||||
<automated>pnpm --dir /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app test:contracts</automated>
|
||||
<fails_when>Non-zero exit, or the node --test summary line "# fail" shows a number other than 0.</fails_when>
|
||||
<automated>bash scripts/check-phase14.2.sh --all 2>&1 | tee "${TMPDIR:-/tmp}/check-phase14.2.log"; test "${PIPESTATUS[0]}" -eq 0 && test "$(tail -n 1 "${TMPDIR:-/tmp}/check-phase14.2.log")" = "Phase 14.2 gate passed"</automated>
|
||||
<fails_when>Non-zero exit; any "FAIL" stage line or "refuse:" line; the last line is not "Phase 14.2 gate passed".</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `for m in --self-test --go --docs --parity --contracts --guard --e2e --all; do grep -qF -- " $m)" scripts/check-phase14.2.sh || echo "missing $m"; done` prints nothing.
|
||||
- `grep -c 'plytarium.com' scripts/check-phase14.2.sh` prints at least 1, and `grep -c 'X-Powered-By: PHP/8.5.10' scripts/check-phase14.2.sh` prints at least 1.
|
||||
- `grep -c 'album search engine failed' scripts/check-phase14.2.sh` prints at least 1.
|
||||
- `grep -c 'recorded 175/175 passing 175 failing 0 unrecorded 0 pending 0' scripts/check-phase14.2.sh` prints 1.
|
||||
- `grep -c 'fonoteka:dev-seed' /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/tests/contracts/e2e-backend-switch.test.mjs` prints at least 1.
|
||||
- `grep -cE 'guard-[a-z0-9-]*\.conf' scripts/check-phase14.2.sh` prints at least 1 (planted guard inputs use `.conf` names the secret-read guard does not block).
|
||||
</acceptance_criteria>
|
||||
<done>`check-phase14.2.sh --all` is the single fail-closed proof of the phase: Go tests, docs, parity, Nuxt contracts, guard refusals and the Go-gate Playwright run, ending with "Phase 14.2 gate passed".</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| Test and gate output → phase verdict | Skips, zero matches and missing names must not read as passes |
|
||||
| Gate → guard | The guard's refusals are the phase's protection against PHP or production fall-through |
|
||||
| Gate → logs | Silent degradations (SQL search fallback, 5xx) only show in the Go log |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-14.2-17 | Tampering | Playwright gate verdict | high | mitigate | `e2e-verdict` requires expected > 0 and zero unexpected, skipped and flaky; `--self-test` plants each bad report and requires refusal |
|
||||
| T-14.2-18 | Tampering | Go test verdict | high | mitigate | `detect()` exits non-zero on fail, build failure, skip, zero tests, "no tests to run", non-JSON, missing named test and data race; planted cases in `--self-test` |
|
||||
| T-14.2-19 | Spoofing | guard regression | high | mitigate | `--guard` plants :8422, unset internal origin, mismatched origins, plytarium.com, fake PHP and a wrong marker; every case must refuse |
|
||||
| T-14.2-20 | Repudiation | hidden degradations during the gate run | medium | mitigate | `--e2e` refuses on `album search engine failed` and 5xx records in `go.log` (research G8, D-12) |
|
||||
| T-14.2-SC | Tampering | package installs | high | mitigate | No install: Go stdlib, existing test modules (testcontainers, memblob) and node built-ins |
|
||||
|
||||
ASVS L1; every high threat is mitigated with a self-tested control.
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- `bash scripts/check-phase14.2.sh --all` ends with `Phase 14.2 gate passed` (services up, Docker available).
|
||||
- `go vet ./...` and `go test ./...` green in summercms.go and fonoteka.go; `pnpm --dir /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app test:contracts` green.
|
||||
- Update `14.2-VALIDATION.md` per-task map statuses only through `/gsd-validate-phase` (not in this plan).
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- Every validation-map behaviour in 14.2-VALIDATION.md has a named, passing automated test or gate stage.
|
||||
- The gate script fails closed on every planted failure in `--self-test` and `--guard`.
|
||||
- Unit tests are the last plan of the phase (project rule, D-17).
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/14.2-local-fonoteka-frontend-on-local-summercms-backend/14.2-04-SUMMARY.md` when done
|
||||
</output>
|
||||
@@ -0,0 +1,152 @@
|
||||
# Phase 14.2: Local Fonoteka frontend on local SummerCMS backend - Context
|
||||
|
||||
**Gathered:** 2026-10-07
|
||||
**Status:** Ready for planning
|
||||
|
||||
<domain>
|
||||
## Phase Boundary
|
||||
|
||||
A developer runs the existing `vue-fonoteka-app` (Nuxt 4) locally against the local `fonoteka.go` binary, with no PHP involved. They sign in and use the core flows: browse collections and albums, search, edit, rate, upload a cover, and add and remove wishlist entries. The phase delivers four things: a repeatable local data seed, a single start script, a guard that fails closed when Nuxt would reach PHP or production, and proof of the flows (Playwright against Go plus one manual session). The production data import (`summer import:winter`), the nginx swap and the production soak belong to Phase 20.
|
||||
|
||||
Repos: `fonoteka.go` (seed command, start script, dev env template, any Go fixes), `vue-fonoteka-app` (small non-breaking edits only, see D-11), and `summercms.go` only if a framework helper is missing, plus the phase check script under `summercms.go/scripts/`.
|
||||
|
||||
</domain>
|
||||
|
||||
<decisions>
|
||||
## Implementation Decisions
|
||||
|
||||
### Local data
|
||||
- **D-01:** Local data comes from `fonoteka migrate` followed by a new seed command. The Phase 20 `import:winter` runner is not pulled forward, and PHP data is not imported in this phase.
|
||||
- **D-02:** The seed is an application command, `fonoteka dev:seed` (final name at the planner's discretion), in the fonoteka plugin, not a framework seeder hook. It refuses to run unless the app environment is local/dev, and it is idempotent: a second run changes nothing. It creates:
|
||||
- a verified user;
|
||||
- an owned collection;
|
||||
- a few albums;
|
||||
- a wishlist entry;
|
||||
- a personal API token (`inv_` prefix) so fonoteka-mcp can be pointed at the local stack.
|
||||
- **D-03:** Seeded albums get bundled test cover images (small, embedded or generated, no network), attached through the normal attach path into `storage/app/uploads/public`. Their `/storage/app/uploads/public/...` URLs then resolve through the Nuxt `/storage` proxy right away.
|
||||
- **D-04:** The seeded user's credentials come from `E2E_EMAIL` / `E2E_PASSWORD`, the same variables the Nuxt Playwright suite uses, with a fallback to `demo@example.com` / `changeme`. That way the seed and the Playwright login match without extra wiring.
|
||||
|
||||
### Stack startup
|
||||
- **D-05:** A single script, `fonoteka.go/scripts/dev-stack.sh`, uses host services and adds no docker compose. It:
|
||||
1. checks that the services are reachable;
|
||||
2. runs `summer build`, `migrate` and `dev:seed`;
|
||||
3. reindexes search after the seed;
|
||||
4. starts `fonoteka serve`;
|
||||
5. runs the fail-closed guard (D-09);
|
||||
6. starts `pnpm dev` in the sibling `vue-fonoteka-app`.
|
||||
- **D-06:** Postgres, Typesense and Centrifugo are all required. The script refuses to start, and names the missing service, if any of them is unreachable. Realtime is expected to work locally, which means `NUXT_PUBLIC_CENTRIFUGO_WS_URL` is set and the app origin `http://localhost:3119` is in Centrifugo's allowed origins (documented).
|
||||
- **D-07:** Go serves on `:8423`, which is distinct from PHP's `:8422` and from the framework default `:8080`. PHP and Go can then run side by side, and a stale origin is obvious.
|
||||
- **D-08:** Settings and secrets live in a gitignored `fonoteka.go/.env.dev`, with a committed `.env.dev.example` that holds placeholders only. It covers the DSN, JWT secret, app key, Typesense and Centrifugo keys, and the Nuxt-side values. The script sources it and fails, listing each missing key. The script passes `NUXT_DEV_BACKEND_ORIGIN`, `NUXT_INTERNAL_API_ORIGIN` and `NUXT_PUBLIC_CENTRIFUGO_WS_URL` as environment variables on the `pnpm dev` process. It never writes `vue-fonoteka-app/.env`, so the developer's PHP-targeted setup stays intact.
|
||||
|
||||
### PHP fall-through guard
|
||||
- **D-09:** The guard has three parts:
|
||||
1. **Preflight:** before Nuxt starts, the script probes the Go origin for a Go-only marker and refuses to continue if the marker is missing, if the target is `:8422`, or if a PHP backend answers.
|
||||
2. **Pinned SSR origin:** `NUXT_INTERNAL_API_ORIGIN` is always set explicitly to the Go origin, so SSR can never fall back to `https://plytarium.com`.
|
||||
3. **Post-start probe:** once Nuxt is up, the script probes through Nuxt's proxy (`http://localhost:3119/...`) and confirms the marker comes back, which proves the proxy reaches Go.
|
||||
- **D-10:** The marker is a route only the Go binary serves (an existing framework health/readiness route, or the smallest new one) that stays **outside the parity manifest**. No manifest route's response changes, and no new header is added to API responses.
|
||||
- **D-11:** `vue-fonoteka-app` may receive **small non-breaking changes** (user's decision). The constraint: the app must still run without issues against the PHP backend. Gaps found during the session are fixed in Go first, with a parity case added where a recorded PHP response exists. An app change is acceptable only when it leaves PHP behavior unchanged (for example an env-switched test helper, a new backend-agnostic spec, or docs and `.env.example` notes). Commits in `vue-fonoteka-app` go to its own repo, which is a submodule of the PHP `fonoteka` repo.
|
||||
- **D-12:** Failures show up through the Go request log, which is redacted by sunscreen and records each request's path and status, and through Nuxt's normal behavior. The script tees both processes into labeled log files, and the run docs say to watch them for 4xx/5xx and 404 responses. No 404 tally or dev-only error UI is added.
|
||||
|
||||
### Flow proof
|
||||
- **D-13:** The flows are proven twice:
|
||||
- **Playwright:** the existing Playwright e2e suite runs against the Go stack.
|
||||
- **Manual session:** one checklist-driven session, recorded as UAT evidence. It covers login, browse collections and albums, search, edit, rate, cover upload, wishlist add/remove, and realtime working.
|
||||
- **D-14:** The Playwright seed helper (`tests/e2e/helpers/runTinker.ts`) gets a non-breaking switch. When `E2E_BACKEND=go` is set, it seeds by running the Go binary's `dev:seed` (binary path from `E2E_GO_BIN` or similar) instead of `php artisan tinker`. When the switch is unset, today's PHP behavior is unchanged.
|
||||
- **D-15:** The Go gate is the core-flow subset of specs: `auth.setup`, `login`, `smoke`, `fonoteka-item` (browse/edit/rate/cover), `public-share`, `realtime`, `i18n`, plus whichever specs cover search and wishlist. The researcher maps each flow to a spec. `fonoteka-ai-assist`, `fonoteka-bulk-add`, `offline`, `registration-invitation` and `responsive` are run and reported but do not block, because they need external keys or are outside the core flows.
|
||||
- **D-16:** If no existing spec covers a core flow (wishlist add/remove and search are the likely candidates), a new spec is added to `vue-fonoteka-app`. It must be backend-agnostic, so it passes against both PHP and Go.
|
||||
- **D-17:** The last plan of the phase (unit tests last, project rule) adds Go unit tests for `dev:seed` (environment refusal, idempotency, created shape) and for the marker route. It also adds `summercms.go/scripts/check-phase14.2.sh`, following the existing `check-phase*.sh` pattern, which:
|
||||
- runs `go vet` and `go test ./...`;
|
||||
- exercises the guard against a stale `:8422` origin and against an unset internal origin, and expects a refusal each time;
|
||||
- runs the Go-gate Playwright subset (D-15) against the started stack.
|
||||
|
||||
### Planning checkpoint additions (2026-10-07, after research)
|
||||
- **D-18:** Realtime reuses the shared host Centrifugo (v6, systemd, `:8787`, `/etc/centrifugo/config.json`), the same topology as production, where PHP apps already use one instance. Inspected config: HMAC connection tokens (`client.token.hmac_secret_key`), HTTP API key (`http_api.key`), top-level channels with `allow_subscribe_for_client: true`, **no subscribe/connect proxy** (research assumption A1 was wrong). Go needs only the same HMAC secret and API key via `.env.dev` (`SUMMER_REALTIME__CENTRIFUGO__TOKEN_SECRET`, `SUMMER_REALTIME__CENTRIFUGO__API_KEY`, `SUMMER_REALTIME__CENTRIFUGO__API_URL=http://127.0.0.1:8787/api`). No dedicated Centrifugo is started. `http://localhost:3119` is not in `client.allowed_origins` today: adding it (and restarting the service) is a documented operator step, not done by the script; the script's preflight checks Centrifugo reachability and reports a missing origin when it can detect it.
|
||||
- **D-19:** The `/storage/app/uploads/public/...` mount (research G2) is a framework opt-in in `summercms.go` surf serve, behind a config key that mounts `attach.StaticHandlerPublic`; Phase 20 reuses it. Module README and docs updated in the same change.
|
||||
- **D-20:** No UI-SPEC: the phase adds no UI (the UI gate matched only the title). Plan as `--skip-ui`.
|
||||
- **D-21:** `vue-fonoteka-app/tests/e2e/realtime.spec.ts` (stale since 2026-06-12) is rewritten as a backend-agnostic spec and verified against PHP as well as Go, per D-11.
|
||||
- **D-22:** Plan count confirmed at 4: (01) Go glue — `dev:seed`, plugin-command DB fix (G1, incl. `fonoteka:reindex`), dev-only marker route, framework `/storage` mount, opt-in access log (G3), READMEs/docs; (02) `dev-stack.sh`, guard, `.env.dev.example`, `.gitignore`, run docs; (03) Nuxt `E2E_BACKEND=go` switch, realtime rewrite, new edit/rate/cover, search, wishlist specs, then the manual UAT session; (04, last) unit tests and `summercms.go/scripts/check-phase14.2.sh`.
|
||||
|
||||
- **D-23:** The shared Centrifugo config has a `user` namespace but **no `collection` or `wishlist` namespace** and no subscribe proxy; the app subscribes to `user:{id}`, `collection:{id}` and `wishlist:{id}` (`useCentrifugo.ts`, `useRealtimeSync.ts`), so `collection:`/`wishlist:` subscribes fail with "unknown channel" on either backend today. The D-18 operator step therefore also adds `collection` and `wishlist` namespaces (`allow_subscribe_for_client: true`, dev-grade; no proxy pointed at either backend, so PHP apps on the shared instance are unaffected) alongside the `http://localhost:3119` origin, then restarts the service. `dev-stack.sh check` reports a missing origin or namespace as a named warning. Production namespace/authorization config stays Phase 20's concern.
|
||||
|
||||
### Claude's Discretion
|
||||
- The exact command name and flags of the seed command, the number of seeded albums and their content, and how the test images are produced.
|
||||
- Which Go-only route serves as the marker, and how a PHP answer is detected in the preflight.
|
||||
- The script structure: subcommands, how processes are supervised and stopped, log file locations, and whether `summer build` runs on every start.
|
||||
- The run docs (a section in `fonoteka.go/README.md` or a dedicated `RUNNING-LOCALLY.md`) and the keys in `.env.dev.example`.
|
||||
- How River jobs run locally (`work_in_serve: true` is the current default and is fine).
|
||||
|
||||
</decisions>
|
||||
|
||||
<canonical_refs>
|
||||
## Canonical References
|
||||
|
||||
**Downstream agents MUST read these before planning or implementing.**
|
||||
|
||||
### Phase scope and neighbors
|
||||
- `.planning/ROADMAP.md` § Phase 14.2: goal and success criteria 1–4.
|
||||
- `.planning/REQUIREMENTS.md`: QA-05, the run-unchanged requirement.
|
||||
- `.planning/phases/20-cutover/20-CONTEXT.md`: D-01/D-02 (`import:winter` stays in Phase 20), D-06 (the nginx locations, which match the Nuxt proxy list), D-13 (14.2 is a hard prerequisite of 20) and D-15 (the production version of the manual session checklist).
|
||||
- `.planning/phases/14-domain-jobs-and-external-integrations/14-CONTEXT.md`: the jobs, Discogs and AI context behind the specs that do not block (D-15).
|
||||
- `.planning/phases/14.1-oauth-identities-and-fonoteka-me-routes/`: the last ported routes; the manifest has zero pending entries.
|
||||
|
||||
### Frontend contract (vue-fonoteka-app, at /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app)
|
||||
- `nuxt.config.ts`: `NUXT_DEV_BACKEND_ORIGIN`, the `internalApiOrigin` fallback chain (falls back to plytarium.com), the `routeRules` / `nitro.devProxy` / `vite.server.proxy` lists (`/_user/api`, `/_journal/api`, `/_fonoteka/api`, `/_feedback/api`, `/plugins/golem15/feedback`, `/api/realtime`, `/storage`) and the dev port 3119.
|
||||
- `.env.example`: the documented dev env keys, the Centrifugo setup steps and the `E2E_*` variables.
|
||||
- `playwright.config.ts`, `tests/e2e/*.spec.ts` and `tests/e2e/helpers/runTinker.ts`: the e2e suite and the tinker seed helper to switch (D-14).
|
||||
|
||||
### Backend (fonoteka.go)
|
||||
- `fonoteka.go/README.md`: database setup (Polish ICU collation), the `SUMMER_*` env mapping, and the migrate/serve commands.
|
||||
- `fonoteka.go/config/http.yaml` (CORS, body limits), `config/storage.yaml` (`/storage/app/uploads/public`), `config/search.yaml` (Typesense on :8181), `config/realtime.yaml`, `config/queue.yaml` (`work_in_serve`).
|
||||
- `fonoteka.go/parity/manifest.yaml`: the marker route must stay outside it (D-10).
|
||||
- `summercms.go/modules/surf/serve.go`: the `serve` address flag (default `:8080`) and the uploads bucket publishing.
|
||||
- `summercms.go/scripts/check-phase14.sh` (and the other `check-phase*.sh` scripts): the pattern for D-17.
|
||||
|
||||
</canonical_refs>
|
||||
|
||||
<code_context>
|
||||
## Existing Code Insights
|
||||
|
||||
### Reusable Assets
|
||||
- The Nuxt dev proxy already sends every backend path to a single `NUXT_DEV_BACKEND_ORIGIN`. Pointing it at Go needs no CORS, because the browser stays same-origin.
|
||||
- `fonoteka migrate`, `fonoteka:reindex` (Phase 14) and `fonoteka serve` cover the start sequence. The reindex command asserts zero `collection_id`-0 documents.
|
||||
- The lagoon attach path and the uploads bucket (`file://./storage/app/uploads/public`) give the seeded covers PHP-identical public URLs.
|
||||
- The `golem15.user` API tokens (`inv_` prefix) let the seed mint an MCP-usable token.
|
||||
- The sunscreen redacting slog handler is already in the generated main (Phase 14-01) and serves as the request log for D-12.
|
||||
|
||||
### Established Patterns
|
||||
- Phase check scripts live in `summercms.go/scripts/check-phase<N>.sh`.
|
||||
- The generated `main.go` / `plugins.gen.go` come from `summer build`. Do not hand-edit them.
|
||||
- Commit rules: one logical change per commit, planning docs separate from code, no co-author tags. `vue-fonoteka-app` commits go to its own repo.
|
||||
|
||||
### Integration Points
|
||||
- `fonoteka.go/scripts/dev-stack.sh` (new), `.env.dev.example` (new), `.gitignore` (`.env.dev`).
|
||||
- The fonoteka plugin's console commands (registers `dev:seed`).
|
||||
- `vue-fonoteka-app/tests/e2e/helpers/runTinker.ts` (`E2E_BACKEND=go` switch) and any new backend-agnostic specs.
|
||||
- Centrifugo's local allowed origins (operator config, documented).
|
||||
|
||||
</code_context>
|
||||
|
||||
<specifics>
|
||||
## Specific Ideas
|
||||
|
||||
- Ports: Nuxt on `:3119`, Go on `:8423`, PHP stays on `:8422`, Typesense on `:8181`.
|
||||
- During the phase, the PHP-connected frontend must keep working with no issues. Every app edit is checked against PHP as well as Go.
|
||||
|
||||
</specifics>
|
||||
|
||||
<deferred>
|
||||
## Deferred Ideas
|
||||
|
||||
- Bringing the Phase 20 `import:winter` runner forward to load real local PHP data was considered and not chosen. It stays in Phase 20.
|
||||
- A 404/5xx tally by path at script shutdown was offered and not chosen. It can be added later if the logs prove too noisy.
|
||||
|
||||
### Reviewed Todos (not folded)
|
||||
- `2026-10-01-benchmark-the-application-on-wintercms-vs-summercms.md`: a benchmark, not local-run glue (Phase 20 lists it as deferred too).
|
||||
- `nest-framework-packages-under-modules.md`, `bonfire-duplicate-command-names.md`, `backend-admin-api-tokens.md`: framework work unrelated to the local Nuxt run; matched by keyword only.
|
||||
|
||||
</deferred>
|
||||
|
||||
---
|
||||
|
||||
*Phase: 14.2-local-fonoteka-frontend-on-local-summercms-backend*
|
||||
*Context gathered: 2026-10-07*
|
||||
@@ -0,0 +1,142 @@
|
||||
# Phase 14.2: Local Fonoteka frontend on local SummerCMS backend - Discussion Log
|
||||
|
||||
> **Audit trail only.** Do not use as input to planning, research, or execution agents.
|
||||
> Decisions are captured in CONTEXT.md — this log preserves the alternatives considered.
|
||||
|
||||
**Date:** 2026-10-07
|
||||
**Phase:** 14.2-local-fonoteka-frontend-on-local-summercms-backend
|
||||
**Areas discussed:** Local data, Stack startup, PHP fall-through guard, Flow proof
|
||||
|
||||
---
|
||||
|
||||
## Local data
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| Seed command | Fresh migrate plus an app dev seed; import:winter stays in Phase 20 | ✓ |
|
||||
| Pull import:winter forward | Build the Phase 20 D-02 runner now and import local PHP MariaDB | |
|
||||
| Register through the UI | No seed; create the data by hand | |
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| App command, idempotent | `fonoteka dev:seed`, refuses non-local env, safe to re-run | ✓ |
|
||||
| App command, destructive reset | Truncates the domain tables first | |
|
||||
| Framework summer seed hook | A generic seeder capability in summercms.go | |
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| Yes, bundled test images | Covers attached through the normal attach path, no network | ✓ |
|
||||
| No covers | Cover upload is the only image check | |
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| Same E2E_* vars | Seed reads E2E_EMAIL/E2E_PASSWORD with a demo fallback | ✓ |
|
||||
| Own SUMMER_* keys | Separate config namespace | |
|
||||
|
||||
**User's choice:** the recommended option each time.
|
||||
|
||||
---
|
||||
|
||||
## Stack startup
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| One script, host services | scripts/dev-stack.sh using existing Postgres/Typesense | ✓ |
|
||||
| docker compose for services + script | Committed compose.yaml | |
|
||||
| Docs only | No orchestration script | |
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| PG required; Typesense + Centrifugo optional | Search fallback and realtime no-op | |
|
||||
| PG + Typesense required | Centrifugo optional | |
|
||||
| All three required | Full production parity, realtime included | ✓ |
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| Distinct port, e.g. :8423 | Runs side by side with PHP :8422 | ✓ |
|
||||
| Reuse :8422 | Existing .env files work as they are | |
|
||||
| Framework default :8080 | No override | |
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| Gitignored dev env file | .env.dev plus a committed .env.dev.example | ✓ |
|
||||
| Your shell env | No new file | |
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| Env vars on the pnpm dev process | Never writes vue-fonoteka-app/.env | ✓ |
|
||||
| Write the app's .env | Overwrites the PHP-targeted .env | |
|
||||
|
||||
**User's choice:** all three services required (not the recommended option), otherwise the recommended options.
|
||||
|
||||
---
|
||||
|
||||
## PHP fall-through guard
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| Preflight probe + pinned SSR origin | Probe Go before Nuxt starts, pin NUXT_INTERNAL_API_ORIGIN, probe through the Nuxt proxy after start | ✓ |
|
||||
| Preflight probe only | No check through the proxy | |
|
||||
| Docs + manual check | Devtools instructions | |
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| Existing Go-only route | Marker route outside the parity manifest | ✓ |
|
||||
| Dev-only response header | X-Summer-Backend when env is local | |
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| No app edits; fix Go only | The app runs unchanged | |
|
||||
| Docs-only edits allowed | Plus .env.example comments | |
|
||||
| Small app fixes allowed | Frontend fixes where the frontend is wrong | |
|
||||
|
||||
**User's choice (free text):** "Small non-breaking changes are allowed (PHP connected frontend still needs to run without issues)".
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| Go request log + Nuxt as is | Labeled log files, watch for 4xx/5xx | ✓ |
|
||||
| Plus an unported-route 404 tally | Summary at shutdown | |
|
||||
|
||||
---
|
||||
|
||||
## Flow proof
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| Playwright on Go + manual session | Existing specs against Go plus one checklist session | ✓ |
|
||||
| Manual checklist only | UAT evidence only | |
|
||||
| Playwright only | No manual session | |
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| E2E_BACKEND=go switch | Non-breaking helper switch to dev:seed | ✓ |
|
||||
| Pre-seeded, helper untouched | Tinker-gated specs skip | |
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| Core-flow subset | Named specs gate; AI, bulk-add and offline do not block | ✓ |
|
||||
| Whole suite must pass | Includes specs that need external keys | |
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| Add a backend-agnostic spec | Passes against both PHP and Go | ✓ |
|
||||
| Manual checklist covers it | No new specs | |
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| scripts/check-phase14.2.sh + Go tests | Seed/marker unit tests, guard refusal checks, Playwright subset | ✓ |
|
||||
| Go tests only | Script and Playwright not part of the gate | |
|
||||
|
||||
---
|
||||
|
||||
## Claude's Discretion
|
||||
|
||||
- Seed command name, fixture content and test image production.
|
||||
- Marker route choice and PHP detection in the preflight.
|
||||
- Script structure, process supervision, log locations.
|
||||
- Where the run docs live and the keys in .env.dev.example.
|
||||
|
||||
## Deferred Ideas
|
||||
|
||||
- Pulling import:winter forward (not chosen; stays in Phase 20).
|
||||
- 404/5xx tally at script shutdown (not chosen).
|
||||
@@ -0,0 +1,672 @@
|
||||
# Phase 14.2: Local Fonoteka frontend on local SummerCMS backend - Research
|
||||
|
||||
**Researched:** 2026-10-07
|
||||
**Domain:** Local dev-stack glue: Go seed command, start script and fail-closed guard, Nuxt dev proxy, Playwright e2e against the Go backend
|
||||
**Confidence:** HIGH for the code facts (read in this session, binary built and probed). MEDIUM for the Centrifugo side, because the shared `/etc/centrifugo/config.json` cannot be read. *Superseded by D-18: the operator inspected that config at the planning checkpoint (HMAC tokens, HTTP API key, `allow_subscribe_for_client: true`, no subscribe or connect proxy); every Open Question below is resolved.*
|
||||
|
||||
<user_constraints>
|
||||
## User Constraints (from CONTEXT.md)
|
||||
|
||||
### Locked Decisions
|
||||
|
||||
#### Local data
|
||||
- **D-01:** Local data comes from `fonoteka migrate` followed by a new seed command. The Phase 20 `import:winter` runner is not pulled forward, and PHP data is not imported in this phase.
|
||||
- **D-02:** The seed is an application command, `fonoteka dev:seed` (final name at the planner's discretion), in the fonoteka plugin, not a framework seeder hook. It refuses to run unless the app environment is local/dev, and it is idempotent: a second run changes nothing. It creates:
|
||||
- a verified user;
|
||||
- an owned collection;
|
||||
- a few albums;
|
||||
- a wishlist entry;
|
||||
- a personal API token (`inv_` prefix) so fonoteka-mcp can be pointed at the local stack.
|
||||
- **D-03:** Seeded albums get bundled test cover images (small, embedded or generated, no network), attached through the normal attach path into `storage/app/uploads/public`. Their `/storage/app/uploads/public/...` URLs then resolve through the Nuxt `/storage` proxy right away.
|
||||
- **D-04:** The seeded user's credentials come from `E2E_EMAIL` / `E2E_PASSWORD`, the same variables the Nuxt Playwright suite uses, with a fallback to `demo@example.com` / `changeme`. That way the seed and the Playwright login match without extra wiring.
|
||||
|
||||
#### Stack startup
|
||||
- **D-05:** A single script, `fonoteka.go/scripts/dev-stack.sh`, uses host services and adds no docker compose. It:
|
||||
1. checks that the services are reachable;
|
||||
2. runs `summer build`, `migrate` and `dev:seed`;
|
||||
3. reindexes search after the seed;
|
||||
4. starts `fonoteka serve`;
|
||||
5. runs the fail-closed guard (D-09);
|
||||
6. starts `pnpm dev` in the sibling `vue-fonoteka-app`.
|
||||
- **D-06:** Postgres, Typesense and Centrifugo are all required. The script refuses to start, and names the missing service, if any of them is unreachable. Realtime is expected to work locally, which means `NUXT_PUBLIC_CENTRIFUGO_WS_URL` is set and the app origin `http://localhost:3119` is in Centrifugo's allowed origins (documented).
|
||||
- **D-07:** Go serves on `:8423`, which is distinct from PHP's `:8422` and from the framework default `:8080`. PHP and Go can then run side by side, and a stale origin is obvious.
|
||||
- **D-08:** Settings and secrets live in a gitignored `fonoteka.go/.env.dev`, with a committed `.env.dev.example` that holds placeholders only. It covers the DSN, JWT secret, app key, Typesense and Centrifugo keys, and the Nuxt-side values. The script sources it and fails, listing each missing key. The script passes `NUXT_DEV_BACKEND_ORIGIN`, `NUXT_INTERNAL_API_ORIGIN` and `NUXT_PUBLIC_CENTRIFUGO_WS_URL` as environment variables on the `pnpm dev` process. It never writes `vue-fonoteka-app/.env`, so the developer's PHP-targeted setup stays intact.
|
||||
|
||||
#### PHP fall-through guard
|
||||
- **D-09:** The guard has three parts:
|
||||
1. **Preflight:** before Nuxt starts, the script probes the Go origin for a Go-only marker and refuses to continue if the marker is missing, if the target is `:8422`, or if a PHP backend answers.
|
||||
2. **Pinned SSR origin:** `NUXT_INTERNAL_API_ORIGIN` is always set explicitly to the Go origin, so SSR can never fall back to `https://plytarium.com`.
|
||||
3. **Post-start probe:** once Nuxt is up, the script probes through Nuxt's proxy (`http://localhost:3119/...`) and confirms the marker comes back, which proves the proxy reaches Go.
|
||||
- **D-10:** The marker is a route only the Go binary serves (an existing framework health/readiness route, or the smallest new one) that stays **outside the parity manifest**. No manifest route's response changes, and no new header is added to API responses.
|
||||
- **D-11:** `vue-fonoteka-app` may receive **small non-breaking changes** (user's decision). The constraint: the app must still run without issues against the PHP backend. Gaps found during the session are fixed in Go first, with a parity case added where a recorded PHP response exists. An app change is acceptable only when it leaves PHP behavior unchanged (for example an env-switched test helper, a new backend-agnostic spec, or docs and `.env.example` notes). Commits in `vue-fonoteka-app` go to its own repo, which is a submodule of the PHP `fonoteka` repo.
|
||||
- **D-12:** Failures show up through the Go request log, which is redacted by sunscreen and records each request's path and status, and through Nuxt's normal behavior. The script tees both processes into labeled log files, and the run docs say to watch them for 4xx/5xx and 404 responses. No 404 tally or dev-only error UI is added.
|
||||
|
||||
#### Flow proof
|
||||
- **D-13:** The flows are proven twice:
|
||||
- **Playwright:** the existing Playwright e2e suite runs against the Go stack.
|
||||
- **Manual session:** one checklist-driven session, recorded as UAT evidence. It covers login, browse collections and albums, search, edit, rate, cover upload, wishlist add/remove, and realtime working.
|
||||
- **D-14:** The Playwright seed helper (`tests/e2e/helpers/runTinker.ts`) gets a non-breaking switch. When `E2E_BACKEND=go` is set, it seeds by running the Go binary's `dev:seed` (binary path from `E2E_GO_BIN` or similar) instead of `php artisan tinker`. When the switch is unset, today's PHP behavior is unchanged.
|
||||
- **D-15:** The Go gate is the core-flow subset of specs: `auth.setup`, `login`, `smoke`, `fonoteka-item` (browse/edit/rate/cover), `public-share`, `realtime`, `i18n`, plus whichever specs cover search and wishlist. The researcher maps each flow to a spec. `fonoteka-ai-assist`, `fonoteka-bulk-add`, `offline`, `registration-invitation` and `responsive` are run and reported but do not block, because they need external keys or are outside the core flows.
|
||||
- **D-16:** If no existing spec covers a core flow (wishlist add/remove and search are the likely candidates), a new spec is added to `vue-fonoteka-app`. It must be backend-agnostic, so it passes against both PHP and Go.
|
||||
- **D-17:** The last plan of the phase (unit tests last, project rule) adds Go unit tests for `dev:seed` (environment refusal, idempotency, created shape) and for the marker route. It also adds `summercms.go/scripts/check-phase14.2.sh`, following the existing `check-phase*.sh` pattern, which:
|
||||
- runs `go vet` and `go test ./...`;
|
||||
- exercises the guard against a stale `:8422` origin and against an unset internal origin, and expects a refusal each time;
|
||||
- runs the Go-gate Playwright subset (D-15) against the started stack.
|
||||
|
||||
### Claude's Discretion
|
||||
- The exact command name and flags of the seed command, the number of seeded albums and their content, and how the test images are produced.
|
||||
- Which Go-only route serves as the marker, and how a PHP answer is detected in the preflight.
|
||||
- The script structure: subcommands, how processes are supervised and stopped, log file locations, and whether `summer build` runs on every start.
|
||||
- The run docs (a section in `fonoteka.go/README.md` or a dedicated `RUNNING-LOCALLY.md`) and the keys in `.env.dev.example`.
|
||||
- How River jobs run locally (`work_in_serve: true` is the current default and is fine).
|
||||
|
||||
### Deferred Ideas (OUT OF SCOPE)
|
||||
- Bringing the Phase 20 `import:winter` runner forward to load real local PHP data was considered and not chosen. It stays in Phase 20.
|
||||
- A 404/5xx tally by path at script shutdown was offered and not chosen. It can be added later if the logs prove too noisy.
|
||||
|
||||
#### Reviewed Todos (not folded)
|
||||
- `2026-10-01-benchmark-the-application-on-wintercms-vs-summercms.md`: a benchmark, not local-run glue (Phase 20 lists it as deferred too).
|
||||
- `nest-framework-packages-under-modules.md`, `bonfire-duplicate-command-names.md`, `backend-admin-api-tokens.md`: framework work unrelated to the local Nuxt run; matched by keyword only.
|
||||
</user_constraints>
|
||||
|
||||
<phase_requirements>
|
||||
## Phase Requirements
|
||||
|
||||
| ID | Description | Research Support |
|
||||
|----|-------------|------------------|
|
||||
| QA-05 | "Cutover: the parity harness is green on all 154 routes and vue-fonoteka-app and fonoteka-mcp run unchanged against the Go backend" [VERIFIED: .planning/REQUIREMENTS.md:124] | This phase covers the "vue-fonoteka-app runs unchanged against the Go backend" half locally. See the Flow-to-Spec map (§Q1), the Go gaps that block an unchanged run (G1–G4), the guard design (§Pattern 4), and the Validation Architecture. The fonoteka-mcp half is covered only as far as minting a local `inv_` token (D-02); MCP must call Go directly on `:8423` because Nuxt does not proxy `/api/v1/fonoteka/*`. Note: the REQUIREMENTS traceability table already marks QA-05 "Phase 15 / Complete" [VERIFIED: .planning/REQUIREMENTS.md:245]. That is a bookkeeping inconsistency for the planner/verifier to fix; it does not change this phase's scope. |
|
||||
</phase_requirements>
|
||||
|
||||
## Project Constraints (from CLAUDE.md)
|
||||
|
||||
- Lean planning: few, large plans per phase. Present the plan count with a one-line scope for each plan and wait for confirmation before writing PLAN.md files.
|
||||
- Unit tests are always the **last** plan of a phase. Earlier plans may carry smoke tests.
|
||||
- Go: standard library first. Add a dependency only when the research doc or a phase decision names it. Keep `go vet` and `go test ./...` green at every commit.
|
||||
- Compiled plugins only. No runtime plugin loading.
|
||||
- API parity is the acceptance test. Do not "improve" response shapes. (Relevant here: the marker route stays outside the manifest, and no manifest response changes, per D-10.)
|
||||
- Commits: no co-author tags, one logical change per commit, planning docs and code in separate commits. `vue-fonoteka-app` commits go to its own repo. Shared plugin submodules (`plugins/golem15/user` and others) are committed inside the submodule first, then the pointer is bumped in a separate commit. Never stage submodule files from the parent.
|
||||
- Core plugins (user, blog, pages, payment) must not get breaking changes. `sm-user-plugin` is shared, so do not add a marker route or change behavior there.
|
||||
- Documentation: any change to a `summercms.go/modules/*` exported API, config key or CLI command updates that module's `README.md` and the affected `docs/` pages in the same change. Check with `go test ./cmd/summer -run TestDocsTree` and `summer docs:build --check`. Framework READMEs never name a consuming application. The `fonoteka.go` README is the application, so naming Płytarium and Nuxt there is fine.
|
||||
- Do not hand-edit `fonoteka.go/main.go` or `plugins.gen.go`; `summer build` regenerates them.
|
||||
- Two repos: `summercms.go` is the framework only; `fonoteka.go` is the app. Planning docs stay in `summercms.go/.planning`.
|
||||
|
||||
## Summary
|
||||
|
||||
The phase is mostly glue, but this research turned up four **Go-side gaps** that would make an "unchanged" Nuxt run fail. Each was verified by building the current `fonoteka.go` binary and running it:
|
||||
|
||||
1. **Plugin CLI commands get no database.** `fonoteka fonoteka:reindex` and `fonoteka user:token:create` both exit 1 with `database is not configured` in the real binary. Only the lagoon, conga and surf runtime commands open and publish the pool. D-05 step 3 (reindex) is therefore broken today, and the new `dev:seed` must open the database itself.
|
||||
2. **The Go binary does not serve `/storage/app/uploads/public/...`.** `route:list` has no such route. The framework ships `attach.StaticHandlerPublic`, but nothing mounts it. Every cover through the Nuxt `/storage` proxy would 404, which breaks D-03.
|
||||
3. **There is no per-request log.** `surf` has no `slog` call at all, so the "Go request log … records each request's path and status" that D-12 relies on does not exist.
|
||||
4. **There is no health or marker route.** D-09's post-start probe goes *through the Nuxt proxy*, so the marker must sit under a prefix Nuxt proxies (`/_fonoteka/api`, `/_user/api`, `/_feedback/api`, `/api/realtime`, `/storage`, `/plugins/golem15/feedback`). A framework path such as `/_summer/health` would not be reachable through Nuxt.
|
||||
|
||||
On the Nuxt side, the biggest risk in the CONTEXT turns out to be smaller than expected: **`runTinker`/`seedE2EFixtures` has zero callers**. No spec seeds anything. The specs need only a logged-in user (E2E_EMAIL/E2E_PASSWORD), an active collection and, for browse/i18n/offline, some albums. The real gap is coverage. `fonoteka-item.spec.ts` only *creates* an album (no edit, rate or cover), and **no spec covers search, edit, rate, cover upload or wishlist add/remove**. `realtime.spec.ts` is also stale: its `realtime-status`/`realtime-degraded` test IDs were removed from the app on 2026-06-12, so it fails against *any* backend. `public-share.spec.ts` stubs every `/public/*` call with `page.route`, so it never touches the backend.
|
||||
|
||||
**Primary recommendation:** close G1–G4 in Go first (plan 1). Then build `dev-stack.sh` with `preflight` / `up` / `down` subcommands and a dev-only marker route under `/_fonoteka/api/v1/` (plan 2). Add backend-agnostic, self-seeding specs for album edit/rate/cover, search and wishlist, rewrite `realtime.spec.ts`, and add the `E2E_BACKEND=go` switch (plan 3). Finish with Go unit tests and `check-phase14.2.sh`, which parses the Playwright JSON report and treats any *skip* in a gate spec as a failure (plan 4).
|
||||
|
||||
## Architectural Responsibility Map
|
||||
|
||||
| Capability | Primary Tier | Secondary Tier | Rationale |
|
||||
|------------|-------------|----------------|-----------|
|
||||
| Login, JWT issue/refresh | API / Backend (Go `golem15.user`) | Browser (JS cookie `auth_token`, Bearer header) | JWT lives in a JS-readable cookie and is sent as `Authorization: Bearer`. The backend sets no cookies, so the proxy needs no cookie rewriting. |
|
||||
| Same-origin API routing in dev | Frontend Server (Nuxt dev: Vite proxy + Nitro devProxy + routeRules) | — | `NUXT_DEV_BACKEND_ORIGIN` is read once at config load. No CORS is involved. |
|
||||
| SSR API calls | Frontend Server (Nitro, `internalApiOrigin`) | API | SSR uses the absolute origin, not the proxy. Pinning it is D-09.2. |
|
||||
| Upload serving `/storage/app/uploads/public/*` | API / Backend (Go, **new mount**) | CDN/nginx in prod (Phase 20) | Nuxt proxies `/storage` to the backend origin. Go must answer it. |
|
||||
| Realtime token + subscribe proxy | API (Go `/api/realtime/token`, `/api/realtime/subscribe`) | Centrifugo (external service) | The browser connects to Centrifugo directly (not proxied). Centrifugo calls Go's subscribe proxy. *Superseded by D-18: the shared Centrifugo has no subscribe proxy; clients subscribe directly (`allow_subscribe_for_client: true`), so locally only `/api/realtime/token` and Go's HTTP API publishes are involved.* |
|
||||
| Search | API (Go → Typesense when `search_use_typesense` is on) | Database (fallback) | Go falls back to SQL on an engine error, logging a Warn. |
|
||||
| Seed data | CLI (fonoteka plugin command) | Database + upload bucket | App command (D-02), not a framework hook. |
|
||||
| Fail-closed guard | Shell script (`dev-stack.sh preflight`) | API marker route | The script decides; the Go route only identifies itself. |
|
||||
|
||||
## Key Findings and Gaps (read first)
|
||||
|
||||
| # | Finding | Evidence | Impact / what the plan must do |
|
||||
|---|---------|----------|---------------------------------|
|
||||
| G1 | Plugin commands run without a database. `fonoteka:reindex` prints `fonoteka:reindex: database is not configured` (rc=1); `user:token:create` prints `golem15.user: database is not configured` (rc=1). | [VERIFIED: ran freshly built binary with SUMMER_ENV=local and a bogus DSN; `migrate:status` reached the DB (`lagoon: ping postgres: failed to connect … password authentication failed`) while the two plugin commands failed before connecting]. Cause: `console/reindex.go:55-58` reads `app.Lookup[*gorm.DB]()`, and only `lagoon/commands.go:230-240 withDB`, `conga/commands.go:213-224 withDB` and `surf/serve.go:40-47` open and publish the pool. | `dev:seed` must call `lagoon.OpenFromApp` + `lagoon.Publish` itself. Publish is required (not just Open), because the fonoteka slug and credential hooks register through `lagoon.OnDatabase` (`plugin.go:209-210`). `fonoteka:reindex` needs the same fix (app-level), or D-05 step 3 fails. The `user:token:*` commands in the shared `sm-user-plugin` are broken the same way. This is out of scope here (the seed mints its own token), so log it as a todo. |
|
||||
| G2 | No `/storage` route in Go. | [VERIFIED: `route:list` of a fresh build; the non-API routes are only `_feedback`, `/plugins/golem15/feedback/assets/js/embed.js`, `/.well-known/oauth-authorization-server`, `/oauth/mcp/*`, `/api/realtime/{token,subscribe}`, `/plytadmin*`]. The framework has `func StaticHandlerPublic(bucket *blob.Bucket, prefix string, db *gorm.DB) http.Handler` [VERIFIED: summercms.go/modules/lagoon/attach/static.go:32], which no package mounts. | Mount `GET {storage.uploads.public_path_prefix}/{path...}` → `attach.StaticHandlerPublic`, resolving bucket and DB per request. Phase 20 D-06 also routes `/storage/app/uploads` to Go, so this is needed beyond local dev. |
|
||||
| G3 | No request access log. | [VERIFIED: `grep slog surf/*.go` (non-test) returns nothing; no `access_log` anywhere in modules]. The sunscreen README only describes a redacting handler. | D-12's "Go request log" must be added: an opt-in surf middleware (config key, default off) logging method, matched pattern, status and duration. Its exported surface needs README/docs updates. |
|
||||
| G4 | No health/marker route, and the marker must be Nuxt-proxied. | [VERIFIED: route list above; Nuxt proxy prefixes at nuxt.config.ts:247-266, 364-372, 392-401]. | Add a dev-only route under `/_fonoteka/api/v1/` in the fonoteka plugin (see Pattern 3). |
|
||||
| G5 | `runTinker`/`seedE2EFixtures` has **no callers**; specs do not seed. | [VERIFIED: `grep -rn "runTinker\|seedE2EFixtures" tests/` matches only `helpers/runTinker.ts` and comments]. | The D-14 switch is cheap. The real seeding need is a user, an active collection, a wishlist and some albums (with covers for offline). New specs should seed themselves through the UI or API. |
|
||||
| G6 | `realtime.spec.ts` is stale. | [VERIFIED: `realtime.spec.ts:28` `page.getByTestId('realtime-status')`, `:39` `getByTestId('realtime-degraded')`; neither ID exists in `app/` (grep). Git `-S"realtime-status"`: added ff33376 2026-06-10, removed 6fcdb8e 2026-06-12 "compose dashboard"]. | It fails on PHP and Go alike. Rewrite it as a backend-agnostic spec (a D-11 test-only change). |
|
||||
| G7 | `fonotekaReachable()` probes `E2E_BACKEND_URL` directly, defaulting to `'http://localhost:8422'`. | [VERIFIED: tests/e2e/helpers/fonotekaReachable.ts:3,8]. | If unset or left in `tests/e2e/.env.local`, gate specs **skip** (Go-only stack) or gate on PHP. The check script must export `E2E_BACKEND_URL=http://127.0.0.1:8423` and fail on any skip. |
|
||||
| G8 | Search silently falls back to SQL when Typesense errors. | [VERIFIED: classes/album_search.go:217-228, `logger.Warn("golem15.fonoteka: album search engine failed, falling back to the database search", …)`]. | A search spec passes even with Typesense broken. The check script should grep the Go log for that Warn and fail on it. |
|
||||
| G9 | `search_use_typesense` is seeded **false**, and `fonoteka:reindex` refuses while it is off. | [VERIFIED: updates/20_remaining.go:198-200 `SELECT 1, false, NOW(), NOW()`; console/reindex.go:68-70 `!searchEnabled(ctx, gdb)` → `reindexNotConfigured`]. | `dev:seed` must set it true. Flip it last (after the albums exist) so the seed's own writes do not hit Typesense before the index exists, then run reindex. |
|
||||
| G10 | The Typesense container is stopped. | [VERIFIED: `docker ps -a`: `plytarium-typesense-instance typesense/typesense:26.0 Exited`; port binding `{"8108/tcp":[{"HostIp":"127.0.0.1","HostPort":"8181"}]}`]. | The preflight names it and suggests `docker start plytarium-typesense-instance`. |
|
||||
| G11 | `fonoteka.go/storage/` is not gitignored. | [VERIFIED: fonoteka.go/.gitignore = `/bin/ /tmp/ *.exe go.work.sum *.sqlite parity/.vars/ parity/tmp/`; `storage/app/uploads` exists, empty]. | Seeded covers would show as untracked files. Add `/storage/app/uploads/public/` (and `.env.dev`) to `.gitignore`. |
|
||||
| G12 | The shared Centrifugo (v6.5.1, systemd, `/etc/centrifugo/config.json`, mode 600 owned by `http`) cannot be read. Its subscribe proxy most likely targets PHP. | [VERIFIED: process `/usr/local/bin/centrifugo -c /etc/centrifugo/config.json`, listening `*:8787`; `centrifugo version` = v6.5.1]. Proxy target: [ASSUMED]. | See Open Question 1. A Go-issued token with the same HMAC secret connects, but `collection:{id}` subscribes go to whatever proxy endpoint the config names. *Superseded by D-18: the config was inspected; no subscribe or connect proxy exists (the proxy-target assumption A1 was wrong). Go shares the HMAC secret and API key through `.env.dev`; the only gap is `http://localhost:3119` missing from `client.allowed_origins`, a documented operator step.* |
|
||||
| G13 | `fonoteka.go` submodules have pointer drift (`feedback`, `golem`, `user` each ahead by a "pin release dependencies" commit). | [VERIFIED: `git diff --submodule=log`]. | This was already the state before the phase. Plans must not sweep these pointers into phase commits by accident. |
|
||||
|
||||
## Q1. Flow → Spec map and seeding (D-15/D-16)
|
||||
|
||||
**No spec calls `runTinker` or `seedE2EFixtures`.** Seeding today is manual (the helper is "a DOCUMENTED, opt-in helper — it is NOT auto-run by the suite" [VERIFIED: tests/e2e/helpers/runTinker.ts:15-16]). What `seedE2EFixtures` would send if called [VERIFIED: runTinker.ts:92-106]:
|
||||
- `\Golem15\User\Models\User::firstOrNew(['email' => E2E_EMAIL])`, then set the password and confirmation and `is_activated = true`, then save. The Go seed covers this.
|
||||
- `\Golem15\Journal\Models\Post::updateOrCreate(['slug' => 'e2e-seed-post'], …)`. Journal is not part of `fonoteka.go`, and the app does not use `journalApiBase` anywhere (grep `journal` in `app/` returns nothing). Under `E2E_BACKEND=go` this step is **skipped**.
|
||||
|
||||
How each spec gets its data:
|
||||
|
||||
| Spec | Flow(s) it proves | Backend touched? | Data it needs | Gate (D-15) |
|
||||
|------|-------------------|------------------|---------------|-------------|
|
||||
| `auth.setup.ts` | login (UI, `/en/login`) → storageState `tests/e2e/.auth/user.json` | yes | user with E2E creds, `must_change_password=false` | gate (runs as `setup` project dependency) |
|
||||
| `login.spec.ts` | login + "JWT never in markup" | yes | same user | gate |
|
||||
| `smoke.spec.ts` | landing renders | no | — | gate |
|
||||
| `fonoteka-item.spec.ts` | **create** album at `/albums/new` (only that, no edit/rate/cover) | yes (`fonotekaReachable` + creds) | active collection; genres (seeded by migrate) | gate |
|
||||
| `public-share.spec.ts` | `/k/{token}` render, 404/429 panels | **no**: every `/_fonoteka/api/v1/public/{token}*` call is stubbed with `page.route` | none | gate (backend-agnostic, proves nothing about Go's `/public/*`) |
|
||||
| `realtime.spec.ts` | intended: realtime badge | **stale**: test IDs removed 2026-06-12 | — | gate → **must be rewritten** |
|
||||
| `i18n.spec.ts` | locale toggle, html lang, keys; one backend case `/kolekcja` export CTA `Eksportuj \d+ pozycj` | one case | albums in the active collection (count) | gate |
|
||||
| `landing.spec.ts` | marketing landing | no | — | not listed (report) |
|
||||
| `responsive.spec.ts` | `/albums` search input and filter sheet visible | yes | logged-in user | report-only |
|
||||
| `offline.spec.ts` | Dexie offline sync, covers offline | yes | albums **with covers** (`blobCoverCount > 0`) | report-only |
|
||||
| `fonoteka-ai-assist.spec.ts` | AI page shell (recognize stubbed) | yes | user | report-only |
|
||||
| `fonoteka-bulk-add.spec.ts` | bulk add (Discogs fully stubbed) | yes | user | report-only |
|
||||
| `registration-invitation.spec.ts` | registration/invite (fully stubbed) | no | — | report-only |
|
||||
|
||||
**Flows with NO covering spec, so new backend-agnostic specs are needed (D-16):**
|
||||
|
||||
| Flow | Existing selectors to use [VERIFIED: grep of `app/`] | Recommended spec |
|
||||
|------|------------------------------------------------------|------------------|
|
||||
| browse collections | collection switcher (no test ID found in this pass; the executor greps `useCollectionSwitcher` consumers) | covered in the manual session; optional assertion in `core-flows` |
|
||||
| edit album | `album-edit`, `album-edit-mobile` (`pages/albums/[id]/index.vue`), `album-name`, `album-save` (`AlbumForm.vue`) | `album-edit-rate-cover.spec.ts` |
|
||||
| rate | `rating-stars` (`RatingStars.vue`) | same spec |
|
||||
| cover upload | file input in `AlbumCoverGallery.vue:114` (`type="file" accept="image/*" multiple class="sr-only"`); `PhotoUploader.vue:312,372`; the store posts to `/albums/${album.id}/photos` (`stores/fonoteka.ts:234`) | same spec, `setInputFiles` with an in-memory PNG (like `fonoteka-ai-assist.spec.ts` `PNG_1PX`) |
|
||||
| search | `albums-search-input` (used by `responsive.spec.ts`) | `album-search.spec.ts`: create a uniquely named album, type its name, `expect.poll` for the tile |
|
||||
| wishlist add/remove | `/wishlist/new` page, `` `wishlist-item-${album.id}` ``, `wishlist-item-remove` | `wishlist.spec.ts` |
|
||||
|
||||
**Self-seeding rule for new specs:** create the data each spec needs through the UI, or through `page.request` against the **Nuxt origin** (same-origin, proxied, Bearer from the `auth_token` cookie), with a `Date.now()` suffix in names. Such specs pass on PHP and Go alike and do not depend on `dev:seed` content beyond the user. Specs must use `fonotekaReachable()` plus the E2E creds gate like the existing ones, so PHP CI behavior is unchanged.
|
||||
|
||||
**What `dev:seed` must provide for the gate (minimum):** a user matching E2E creds (activated, `must_change_password=false`, `has_self_set_password=true`), an owned `kind=collection` collection persisted as the user's context, at least 3 albums (the i18n count and offline covers), a `kind=wishlist` collection with at least 1 album (the wishlist entry), and `search_use_typesense=true`.
|
||||
|
||||
## Q2. playwright.config.ts
|
||||
|
||||
[VERIFIED: vue-fonoteka-app/playwright.config.ts:10-56]
|
||||
- Env loading: `dotenv.config({ path: 'tests/e2e/.env.local' })` then `tests/e2e/.env`. dotenv does **not** override variables already in the environment, so the check script's exports win.
|
||||
- `FRONTEND_URL = process.env.E2E_FRONTEND_URL || 'http://127.0.0.1:3119'`; `use.baseURL = FRONTEND_URL`; port derived from it.
|
||||
- `use.storageState: 'tests/e2e/.auth/user.json'`; projects `setup` (`testMatch: /.*\.setup\.ts/`, empty storage) and `chromium` (`dependencies: ['setup']`).
|
||||
- `workers: 1`, `fullyParallel: false`, `retries: 0`, reporters `html` + `list`.
|
||||
- `webServer: { command: \`pnpm dev --port ${FRONTEND_PORT} --host 127.0.0.1\`, url: FRONTEND_URL, reuseExistingServer: !process.env.CI, timeout: 120_000 }`.
|
||||
|
||||
Implications:
|
||||
- Running against the stack: start the stack first, then run with `CI` **unset**, so Playwright reuses the running Nuxt. If Playwright launches its own `pnpm dev`, that process inherits Playwright's env, so the `NUXT_*` Go values must be exported in the check script too.
|
||||
- Set `E2E_FRONTEND_URL=http://localhost:3119`. `i18n.spec.ts` and `landing.spec.ts` add cookies with the hard-coded `url: 'http://localhost:3119'`, so a `127.0.0.1` baseURL would not carry them. The D-06 Centrifugo origin is also `http://localhost:3119`.
|
||||
- E2E env vars in use: `E2E_EMAIL`, `E2E_PASSWORD`, `E2E_FRONTEND_URL`, `E2E_BACKEND_URL`, `E2E_CENTRIFUGO` (`=== '1'`), `BACKEND_DIR` (runTinker PHP cwd; defaults to `path.resolve(__dirname, '../../../..')`, which is the PHP fonoteka root). New: `E2E_BACKEND`, `E2E_GO_BIN`, optionally `E2E_GO_DIR`.
|
||||
- Gate run recommendation: `pnpm exec playwright test auth.setup.ts login.spec.ts smoke.spec.ts fonoteka-item.spec.ts public-share.spec.ts realtime.spec.ts i18n.spec.ts <new specs> --project=chromium --reporter=json`, with `PLAYWRIGHT_JSON_OUTPUT_NAME=<file>`. Parse `stats.unexpected == 0`, `stats.skipped == 0` and `stats.expected > 0`. A skip is how a misconfigured backend URL shows up (G7).
|
||||
|
||||
## Q3. nuxt.config.ts: env names, fallback chain, proxy list, port, auth
|
||||
|
||||
[VERIFIED: vue-fonoteka-app/nuxt.config.ts]
|
||||
- `const BACKEND_ORIGIN = process.env.NUXT_DEV_BACKEND_ORIGIN || 'http://localhost:8000'` (line 30). Read **once at config load**.
|
||||
- `devServer: { port: Number(process.env.NUXT_PORT) || 3119 }` (line 51).
|
||||
- `internalApiOrigin: process.env.NUXT_INTERNAL_API_ORIGIN || process.env.NUXT_DEV_BACKEND_ORIGIN || process.env.NUXT_PUBLIC_SITE_URL || 'https://plytarium.com'` (lines 110-113).
|
||||
- `public.centrifugoWsUrl: ''` (line 155). Overridden at runtime by `NUXT_PUBLIC_CENTRIFUGO_WS_URL`. Empty means realtime silently disabled (`useCentrifugo.ts:59-63`).
|
||||
- `public.feedbackWidgetKey: process.env.NUXT_PUBLIC_FEEDBACK_WIDGET_KEY || ''` (line 128). A PHP widget key left in `.env` would make the widget call Go's `/_feedback/api/v1/{key}/config` with an unknown key. Have the script export it explicitly (empty unless `.env.dev` sets a Go-side key).
|
||||
- Proxy prefixes, identical in `routeRules` (247-266), `nitro.devProxy` (364-372) and `vite.server.proxy` (392-401): `/_user/api`, `/_journal/api`, `/_fonoteka/api`, `/_feedback/api`, `/plugins/golem15/feedback`, `/api/realtime`, `/storage`. All use `changeOrigin: true`.
|
||||
- Auth: JWT in the JS-readable `auth_token` cookie (SameSite=Lax), attached as `Authorization: Bearer` by `app/plugins/api.ts` on both SSR and client. SSR uses `config.internalApiOrigin` as `baseURL` (api.ts:28-30). No backend cookies and no CORS: the browser only talks to `:3119`, and SSR talks to Go server-to-server.
|
||||
- **Env precedence:** Nuxt loads `vue-fonoteka-app/.env` through c12 `setupDotenv`, which only assigns a key `if (targetEnvironment[key] === void 0 || dotenvVars.has(key))` [VERIFIED: node_modules/.pnpm/c12@3.3.4…/c12/dist/index.mjs:28-31]. Variables exported on the `pnpm dev` process therefore win over `.env`, including deliberately empty strings. This confirms D-08's "pass env vars, never write `.env`" works.
|
||||
|
||||
## Q4. fonoteka.go facts for the seed, script and serve
|
||||
|
||||
- **Command registration:** `func (p *Plugin) Commands() []bonfire.Command { cmds := []bonfire.Command{ console.OAuthClientCommand(p.app), console.PruneNotificationsCommand(p.app), console.ReindexCommand(p.app), } … }` [VERIFIED: plugins/golem15/fonoteka/plugin.go:292-301]. Add `console.DevSeedCommand(p.app)` here, in a new file `console/dev_seed.go`. `bonfire.Command{Name, Description, Flags []bonfire.Flag{Name, Description, Default, Bare}, Args, Run func(ctx, in bonfire.Input, out bonfire.Output) error}` [VERIFIED: summercms.go/modules/bonfire/command.go:15-38].
|
||||
- **Environment:** `Environment()` returns the env name. Detection: `envName = lookupEnv(c.environ, "SUMMER_ENV")`; if empty, `envName = "production"` [VERIFIED: summercms.go/modules/compass/config.go:193-199]. The seed refuses unless `app.Config.Environment()` is one of the allowed dev names. Recommend `local` and `dev` (D-02); the script sets `SUMMER_ENV=local`. Compass also merges `<config dir>/../.env` (`readDotEnv(filepath.Join(filepath.Dir(c.dir), ".env"))`, config.go:187). That means a `fonoteka.go/.env` would be read automatically, but `.env.dev` is **not**, so the script must export it (`set -a; . ./.env.dev; set +a`).
|
||||
- **Config dir is cwd-relative:** `cfg, err := compass.Load("config")` [VERIFIED: fonoteka.go/main.go:31], and the bucket is `file://./storage/app/uploads/public` [VERIFIED: config/storage.yaml]. Every `bin/fonoteka` invocation (script, Playwright `E2E_GO_BIN`) must run with cwd = `fonoteka.go`.
|
||||
- **Boot requirements for any command:** `golem15.user: jwt.secret is empty (set SUMMER_GOLEM15__USER__JWT__SECRET)` and `cabana: admin.jwt.secret is empty (set SUMMER_ADMIN__JWT__SECRET)` both fail even `route:list` [VERIFIED: ran binary]. DB-opening commands also need a valid `SUMMER_APP__KEY` (base64 of 32 bytes; `fonoteka key:generate` prints one) [CITED: 11.2-RESEARCH.md citing lagoon/connection.go:77-90].
|
||||
- **Creating the data with existing code** (all in package `classes` of the fonoteka plugin unless noted):
|
||||
- User: `usercontrollers.RegisterUser(ctx, app, tx, fields, usercontrollers.RegisterOptions{Activate: true})` hashes with `golem15.user.password.bcrypt_cost` and validates the model rules [VERIFIED: sm-user-plugin/controllers/registration.go:26-38,57]. Lookup by email first for idempotency. `BootstrapOwner` is unsuitable because it refuses once any user exists.
|
||||
- Collection + context: `ProvisionCollection(ctx, tx, user)` returns the context collection, else the lowest accessible one, else creates `"Moja kolekcja"` (`DefaultCollectionName`) and persists the context [VERIFIED: classes/collection_provisioner.go:14-75].
|
||||
- Wishlist: `ProvisionWishlist(ctx, tx, user)` reuses the own wishlist or creates `"Moja lista życzeń"` (`DefaultWishlistName`, `Kind: kindWishlist` where `const kindWishlist = "wishlist"`) [VERIFIED: classes/wishlist_resolver.go:15-19,48-84]. A wishlist entry is an album whose `collection_id` is the wishlist collection [VERIFIED: parity/fonoteka_seed_test.go:1324-1340 inserts wishlist albums with `collection_id = wishlistID`].
|
||||
- Albums: `CreateAlbum(ctx, tx, collectionID, input map[string]any) (*models.Album, error)` [VERIFIED: classes/album_write_service.go:378-400]. Wrap it in `lighthouse.WithoutBroadcasting[models.Album](ctx, fn)` [VERIFIED: summercms.go/modules/lighthouse/suppress.go:19] so the seed queues no realtime broadcast jobs.
|
||||
- Covers: `StoreAlbumPhoto(ctx, db, bucket, albumID, filename, data) (*attach.File, error)` writes under a Winter disk name and attaches to `photos` [VERIFIED: classes/album_files.go:72-85]. The bucket in a CLI context comes from `attach.OpenBucket(ctx, app.Config)` + `attach.Publish(app, bucket)` (exported; used by `surf/serve.go:110-122` and `lagoon/commands.go:208-228`). Generate the images with stdlib `image/png` (solid or gradient squares, deterministic per album). No network and no committed binaries are needed.
|
||||
- Rating (optional, so "rate" has a baseline): `models.AlbumRating{AlbumID, UserID, Rating}` on table `golem15_fonoteka_album_ratings` [VERIFIED: models/album_rating.go].
|
||||
- API token: `userclasses.APITokenPrefix(app)` reads `golem15.user.api_tokens.prefix` (`inv_` in this app) and `userclasses.MintAPIToken(prefix, userID, name, scopes, expiresAt, collectionIDs)` returns `(secret, *models.APIToken, error)`; allowed scopes are `"read", "write", "ai"` [VERIFIED: sm-user-plugin/classes/api_tokens.go:19-23,48-94]. For idempotency, see Pattern 1.
|
||||
- Search setting: `models.Settings{SearchUseTypesense bool}` on table `golem15_fonoteka_settings`, singleton row `id = 1` [VERIFIED: models/settings.go; updates/20_remaining.go:198-200].
|
||||
- **Lagoon attach path / cover file functions:** `StorePublicFile` → `attach.BlobKey(diskName)` → `bucket.WriteAll`; `AttachAlbumPhoto` sets `Field = "photos"`, `AttachmentType = models.Album{}.MorphName()` and `sort_order = id` [VERIFIED: classes/album_files.go:20-69]. Public URLs come out as `/storage/app/uploads/public/<partition>/<disk_name>` (prefix from `storage.uploads.public_path_prefix`) [VERIFIED: config/storage.yaml].
|
||||
- **Serve address:** `serve` has flag `addr` with `Default: ":8080"`; an empty value falls back to `":8080"` [VERIFIED: summercms.go/modules/surf/serve.go:28-37]. Use `bin/fonoteka serve --addr 127.0.0.1:8423`, bound to loopback only.
|
||||
- **Reindex:** the command name is exactly `fonoteka:reindex`, with flag `drop-old-items-index` [VERIFIED: console/reindex.go:44-50]. It refuses unless the engine is `typesense`, configured (API key) and `search_use_typesense` is on (reindex.go:68-70), and needs the G1 fix.
|
||||
- **Other useful commands in the binary** [VERIFIED: `fonoteka --help`]: `migrate`, `migrate:status`, `key:generate`, `websockets:health` (works without a DB; prints `✗ Connection check failed: centrifugo: /info: HTTP 401` and exits 1 on a bad key [VERIFIED: ran]), `user:token:create` (broken by G1).
|
||||
- **summer build:** `summer` is at `~/go/bin/summer` (built 2026-10-05), which is **not on PATH** in non-interactive shells. `buildCommand` uses `os.Getwd()` as the app dir [VERIFIED: summercms.go/cmd/summer/main.go:56-66] and writes `bin/<binary>` (`cmd/summer/runtime.go:105`). Recommend that the script build the CLI from source (`go -C "$FRAMEWORK" build -o "$TMP/summer" ./cmd/summer`) and then run `(cd "$APP" && "$TMP/summer" build)`, so a stale `~/go/bin/summer` is never used. `fonoteka.go/go.mod` has `replace git.golem15.com/golem15/summercms => ../summercms.go`, so framework edits are picked up.
|
||||
|
||||
## Q5. Marker route (D-10)
|
||||
|
||||
Existing Go routes outside `parity/manifest.yaml`: `/plytadmin*` (cabana admin, not Nuxt-proxied), `GET /plugins/golem15/feedback/assets/js/embed.js` (PHP serves the same file statically), `/.well-known/oauth-authorization-server` and `/oauth/mcp/*` (PHP serves these too, and they are in the manifest's `oauth` group) [VERIFIED: route:list + manifest auth groups]. **None is both Go-only and under a Nuxt-proxied prefix.** There is no framework health route.
|
||||
|
||||
**Recommendation:** add a route in the fonoteka plugin (the app's own namespace, so no shared plugin is touched): `GET /_fonoteka/api/v1/_dev/backend`, registered **only when `app.Config.Environment()` is not `production`** (env-gated: zero production surface, invisible to parity). It returns `200 {"backend":"summercms","app":"fonoteka","env":"<env>"}` with `Cache-Control: no-store`. Put it in its own `r.GroupRaw` (or a public group) with no auth and a small throttle. It is not added to the manifest or `routes.snapshot`. `TestParityContract` compares the snapshot against **PHP** `routes.php`, not against the Go table, so a Go-only route does not break it [VERIFIED: parity/parity_contract_test.go:13-45]. Phase route-table tests (`routes_table_phase1x_test.go`) assert properties of named routes, so the executor should run them after adding the route.
|
||||
|
||||
**Preflight logic (script):**
|
||||
1. Refuse if `NUXT_DEV_BACKEND_ORIGIN` or `NUXT_INTERNAL_API_ORIGIN` is unset, if they differ, if the port is `8422`, or if the host is not loopback (`localhost`/`127.0.0.1`/`::1`). This covers any `plytarium.com` value.
|
||||
2. `curl -sS -D headers -o body "$ORIGIN/_fonoteka/api/v1/_dev/backend"`: refuse unless the status is 200 **and** `jq -e '.backend=="summercms"'` holds.
|
||||
3. Refuse if any response header matches `^X-Powered-By: PHP` (case-insensitive). The local PHP has `expose_php => On` [VERIFIED: `php -i`, PHP 8.5.10], so `php artisan serve` stamps `X-Powered-By: PHP/8.5.10` on every response. Go never sets it. PHP has no catch-all route in `plugins/golem15/fonoteka/routes.php` [VERIFIED: grep], so PHP answers 404 on the marker path, which step 2 already refuses.
|
||||
4. Post-start: the same request against `http://localhost:3119/_fonoteka/api/v1/_dev/backend` (the Vite proxy) must return the same JSON. Also probe an unauthenticated `GET http://localhost:3119/_fonoteka/api/v1/albums` and expect 401 from Go.
|
||||
|
||||
## Q6. check-phase script pattern (D-17)
|
||||
|
||||
[VERIFIED: summercms.go/scripts/check-phase14.2.1.sh (410 lines; the most recent and leanest), check-phase14.sh (1265 lines)]
|
||||
- Header comment describing the gate. `set -euo pipefail`, `unset FORCE_COLOR`.
|
||||
- Paths from overridable env (`ROOT="${PHASE1421_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}"`, sibling repos `$ROOT/../…`).
|
||||
- `*_REQUIRE=(TestA TestB …)` arrays of named tests that must pass.
|
||||
- `detect()`: an embedded `python3` heredoc reading `go test -json`, with exit codes 1 fail/build, 2 skip, 3 zero tests or "no tests to run", 4 non-JSON, 5 missing required name, 6 data race. `go_json DIR ARGS…` runs `go test -json` and the detector.
|
||||
- `run_self_test`: `bash -n`, then `expect_detect` on planted JSON lines for each exit code. It also checks that every `--mode)` case exists.
|
||||
- One `run_<stage>` per mode, `run_all` looping `bash "${BASH_SOURCE[0]}" "--$stage"` with `PASS/FAIL` lines, and a final `echo "Phase 14.2.1 gate passed"`.
|
||||
- `case "${1:---all}" in --self-test) … esac`.
|
||||
|
||||
Recommended modes for `check-phase14.2.sh`: `--self-test` (also plants a fake Playwright JSON with a skip and expects refusal), `--go` (`go vet` + `go_json` on the `fonoteka.go` plugin packages and the touched `summercms.go` modules, with `REQUIRE_TESTS` naming the new dev-seed, marker, storage-mount and access-log tests), `--docs` (`go test ./cmd/summer -run TestDocsTree` if framework modules changed), `--guard` (Pattern 4 refusal cases), `--e2e` (starts the stack via `dev-stack.sh up --detach` or requires it running, runs the Playwright gate with the JSON reporter, then greps the Go log for `album search engine failed` and for 5xx), and `--all`. The `--e2e` stage fails closed when services are missing (D-06). It is not silently skipped.
|
||||
|
||||
## Q7. Centrifugo local config and realtime keys
|
||||
|
||||
`config/realtime.yaml` keys and env mapping [VERIFIED: fonoteka.go/config/realtime.yaml; fonoteka.go/README.md:75-87]:
|
||||
|
||||
| Config key | Env | Default in repo |
|
||||
|------------|-----|-----------------|
|
||||
| `realtime.centrifugo.api_url` | `SUMMER_REALTIME__CENTRIFUGO__API_URL` | `http://127.0.0.1:8001/api` |
|
||||
| `realtime.centrifugo.api_key` | `SUMMER_REALTIME__CENTRIFUGO__API_KEY` | `""` (nothing published) |
|
||||
| `realtime.centrifugo.token_secret` | `SUMMER_REALTIME__CENTRIFUGO__TOKEN_SECRET` | `""` (token route answers 503 `{"error":"WebSocket not configured"}`) |
|
||||
| `realtime.centrifugo.token_ttl` | `SUMMER_REALTIME__CENTRIFUGO__TOKEN_TTL` | `3600` |
|
||||
| `realtime.centrifugo.ws_url` | `SUMMER_REALTIME__CENTRIFUGO__WS_URL` | `/ws` |
|
||||
| `realtime.centrifugo.proxy_secret` | `SUMMER_REALTIME__CENTRIFUGO__PROXY_SECRET` | `""` (every subscribe denied) |
|
||||
| `realtime.centrifugo.subscribe_path` | — | `/api/realtime/subscribe` |
|
||||
| `realtime.broadcast_namespace` | `SUMMER_REALTIME__BROADCAST_NAMESPACE` | `""` |
|
||||
|
||||
Centrifugo v6 config keys [CITED: centrifugal.dev/docs/server/configuration; centrifugal.dev/docs/server/proxy]: `client.token.hmac_secret_key` (must equal Go `token_secret`), `client.allowed_origins` (add `http://localhost:3119` **and** `http://127.0.0.1:3119`), `http_api.key` (= Go `api_key`), `http_server.port`, `channel.proxy.subscribe.endpoint` (→ `http://127.0.0.1:8423/api/realtime/subscribe`) with `channel.proxy.subscribe.http.static_headers` `{"X-Centrifugo-Secret": "<proxy_secret>"}`, and `channel.namespaces[]` entries `collection` and `wishlist` with `subscribe_proxy_enabled: true`. Env override form: `CENTRIFUGO_<OPTION>` with nested levels joined by `_` (e.g. `CENTRIFUGO_CLIENT_ALLOWED_ORIGINS`). The Go subscribe proxy answers HTTP 200 for both allow and deny, and denies unless the namespace has an authorizer [VERIFIED: lighthouse/README.md:49-53]. Go registers authorizers for `collection` and `wishlist` only [VERIFIED: fonoteka/realtime.go]. The Nuxt client's auto-subscription to `user:{id}` (`useCentrifugo.ts:90`) is therefore denied on Go, **exactly as on PHP** (PHP `ProxyController` denies "Unknown channel namespace" the same way). This is parity, not a gap. "Realtime working" in the manual session means a `collection:{id}` update reaches a second browser session.
|
||||
|
||||
*Superseded by D-18 for the local stack:* the shared instance configures no subscribe proxy, so the `channel.proxy.subscribe.*`, `X-Centrifugo-Secret` and `subscribe_proxy_enabled` settings above are not set and Go's `proxy_secret` is not needed. The deny-by-namespace behaviour described above applies only where a subscribe proxy is configured. Locally only `client.token.hmac_secret_key` (= `SUMMER_REALTIME__CENTRIFUGO__TOKEN_SECRET`), `http_api.key` (= `SUMMER_REALTIME__CENTRIFUGO__API_KEY`, with `SUMMER_REALTIME__CENTRIFUGO__API_URL=http://127.0.0.1:8787/api`) and `client.allowed_origins` matter.
|
||||
|
||||
Nuxt side: `NUXT_PUBLIC_CENTRIFUGO_WS_URL=ws://localhost:<port>/connection/websocket` (the current shared instance serves WS and API on `:8787` [VERIFIED: `ss -ltnp` and `curl /connection/websocket` → 400 with CORS headers]). Set `E2E_CENTRIFUGO=1` for the live realtime spec.
|
||||
|
||||
## Q8. Nuxt proxy prefixes vs Go routes
|
||||
|
||||
| Nuxt proxied prefix | Go serves? | Gap / action |
|
||||
|---------------------|-----------|--------------|
|
||||
| `/_user/api/**` | yes (`golem15.user`) | — |
|
||||
| `/_fonoteka/api/**` | yes | — (+ marker route) |
|
||||
| `/_feedback/api/**` | yes: `GET /{key}/config`, `POST /{key}/submit`, `PUT /me/hidden` | Widget only active if `NUXT_PUBLIC_FEEDBACK_WIDGET_KEY` is set; the Go key must exist in `golem15_feedback_settings`. Recommend exporting it empty for local Go runs. |
|
||||
| `/plugins/golem15/feedback/**` | yes: `GET /plugins/golem15/feedback/assets/js/embed.js` | — |
|
||||
| `/api/realtime/**` | yes: `GET /token`, `POST /subscribe` | — |
|
||||
| `/storage/**` | **no** | G2: mount `attach.StaticHandlerPublic` at `/storage/app/uploads/public/{path...}` |
|
||||
| `/_journal/api/**` | **no** | Not used by the app (no `journal` reference in `app/`). Journal is Phase 15 and not in `fonoteka.go`. No action; document that the proxy entry 404s on Go. |
|
||||
|
||||
Not proxied, but used by fonoteka-mcp: `/api/v1/fonoteka/*` (personal-token routes). For MCP, set `FONOTEKA_API_URL=http://localhost:8423` and `FONOTEKA_API_TOKEN=<inv_ secret>` (env names [VERIFIED: grep fonoteka-mcp/src]).
|
||||
|
||||
## Standard Stack
|
||||
|
||||
No new dependencies. Everything is stdlib or already in the module graph.
|
||||
|
||||
| Piece | Use | Source |
|
||||
|-------|-----|--------|
|
||||
| `bonfire.Command` | `dev:seed` command | framework [VERIFIED] |
|
||||
| `lagoon.OpenFromApp` / `lagoon.Publish` | open DB in plugin commands (G1) | framework [VERIFIED: lagoon/connection.go:77-120] |
|
||||
| `attach.OpenBucket` / `attach.Publish` / `attach.StaticHandlerPublic` | seed covers; `/storage` mount | framework [VERIFIED] |
|
||||
| `lighthouse.WithoutBroadcasting[T]` | no broadcast jobs from the seed | framework [VERIFIED] |
|
||||
| `image`, `image/png`, `image/color` | generated cover PNGs | Go stdlib |
|
||||
| bash + curl + jq + python3 | `dev-stack.sh`, `check-phase14.2.sh` | host tools [VERIFIED present] |
|
||||
| `@playwright/test` ^1.60.0 | e2e | already in `vue-fonoteka-app/package.json` [VERIFIED] |
|
||||
| `node --test tests/contracts/*.test.mjs` | contract test for the runTinker switch (source-level) | existing `pnpm test:contracts` [VERIFIED] |
|
||||
|
||||
**Installation:** none.
|
||||
|
||||
## Package Legitimacy Audit
|
||||
|
||||
Not applicable: this phase installs no external packages (Go stdlib and existing modules; existing npm devDependencies only).
|
||||
|
||||
**Packages removed due to [SLOP] verdict:** none
|
||||
**Packages flagged as suspicious [SUS]:** none
|
||||
|
||||
## Architecture Patterns
|
||||
|
||||
### System Architecture Diagram
|
||||
|
||||
```
|
||||
developer shell
|
||||
│ scripts/dev-stack.sh up (sources .env.dev, SUMMER_ENV=local)
|
||||
▼
|
||||
[1 preflight services] ── pg (DSN) ── Typesense :8181/health ── Centrifugo (websockets:health)
|
||||
│ any missing → exit 1 naming it
|
||||
▼
|
||||
[2 build] go build summer CLI → summer build → bin/fonoteka
|
||||
▼
|
||||
[3 data] bin/fonoteka migrate → bin/fonoteka dev:seed (env-gated, idempotent)
|
||||
→ writes users / collections / albums / wishlist / user_api_tokens / settings
|
||||
→ covers → storage/app/uploads/public/<partition>/<disk>
|
||||
→ bin/fonoteka fonoteka:reindex → Typesense
|
||||
▼
|
||||
[4 serve] bin/fonoteka serve --addr 127.0.0.1:8423 (access log on) ──tee──▶ tmp/dev-stack/go.log
|
||||
▼
|
||||
[5 guard] marker GET :8423/_fonoteka/api/v1/_dev/backend
|
||||
refuse: origin :8422 | non-loopback | internal origin unset/different | X-Powered-By: PHP | marker≠summercms
|
||||
▼
|
||||
[6 nuxt] NUXT_DEV_BACKEND_ORIGIN=NUXT_INTERNAL_API_ORIGIN=http://127.0.0.1:8423
|
||||
NUXT_PUBLIC_CENTRIFUGO_WS_URL=ws://… pnpm dev --port 3119 ──tee──▶ tmp/dev-stack/nuxt.log
|
||||
▼
|
||||
[7 post-start probe] GET :3119/_fonoteka/api/v1/_dev/backend (via Vite proxy) == marker JSON
|
||||
|
||||
browser ──▶ :3119 (Nuxt) ──proxy /_user /_fonoteka /_feedback /api/realtime /storage──▶ :8423 (Go) ──▶ Postgres / bucket / Typesense
|
||||
│ SSR fetch (internalApiOrigin) ───────────────────────────▶ :8423
|
||||
└──WS──▶ Centrifugo ──subscribe proxy (X-Centrifugo-Secret)──▶ :8423/api/realtime/subscribe
|
||||
Go album writes ──River job──▶ Centrifugo HTTP API ──▶ browser (collection:{id})
|
||||
```
|
||||
|
||||
*Superseded by D-18:* the subscribe-proxy edge (`X-Centrifugo-Secret` → `:8423/api/realtime/subscribe`) does not exist locally. The browser connects to the shared `:8787` instance with a Go-issued HMAC token and subscribes directly; Go only issues tokens and publishes through the HTTP API.
|
||||
|
||||
### Recommended file layout
|
||||
|
||||
```
|
||||
fonoteka.go/
|
||||
├── scripts/dev-stack.sh # up | preflight | seed | down | status (new)
|
||||
├── .env.dev.example # placeholders only (new); .env.dev gitignored
|
||||
├── .gitignore # + .env.dev, /storage/app/uploads/public/
|
||||
├── RUNNING-LOCALLY.md (or README §) # run docs, Centrifugo snippet, MCP pointing
|
||||
└── plugins/golem15/fonoteka/
|
||||
├── console/dev_seed.go # DevSeedCommand (+ _test.go in last plan)
|
||||
├── console/reindex.go # opens DB itself (G1)
|
||||
├── dev_marker.go # env-gated marker route (or inside routes.go)
|
||||
└── plugin.go # Commands() += DevSeedCommand
|
||||
summercms.go/
|
||||
├── modules/surf/… # opt-in access log; opt-in uploads mount (README + docs)
|
||||
└── scripts/check-phase14.2.sh
|
||||
vue-fonoteka-app/tests/e2e/
|
||||
├── helpers/runTinker.ts # E2E_BACKEND=go switch
|
||||
├── auth.setup.ts # if E2E_BACKEND=go → seedE2EFixtures() (no-op otherwise)
|
||||
├── realtime.spec.ts # rewritten, backend-agnostic
|
||||
├── album-edit-rate-cover.spec.ts # new
|
||||
├── album-search.spec.ts # new
|
||||
└── wishlist.spec.ts # new
|
||||
```
|
||||
|
||||
### Pattern 1: `dev:seed` command (env refusal, idempotency, own DB)
|
||||
|
||||
**What:** a `bonfire.Command` that (a) refuses unless `Environment()` ∈ {`local`,`dev`}, (b) opens and publishes the DB and bucket itself, and (c) upserts by natural keys inside one `lagoon.Transaction`, with album writes in `WithoutBroadcasting`.
|
||||
|
||||
Idempotency keys:
|
||||
- user by `email` (on the second run, if the bcrypt check of `E2E_PASSWORD` fails, print an explicit message and update; same inputs mean no change);
|
||||
- collection and wishlist by `ProvisionCollection`/`ProvisionWishlist` (both reuse existing ones);
|
||||
- albums by `(collection_id, name)` among live rows;
|
||||
- cover only when the album has no `photos`;
|
||||
- wishlist entry by `(wishlist collection_id, name)`;
|
||||
- token: when `DEV_SEED_API_TOKEN` is set (must start with the configured prefix), upsert the row by `HashAPIToken(secret)`. Otherwise mint once under the fixed name `dev-seed-mcp`, print the secret only at creation, and on later runs print "token exists, secret not shown";
|
||||
- `search_use_typesense`: `UPDATE … SET search_use_typesense = true WHERE id = 1 AND NOT search_use_typesense`, done last.
|
||||
|
||||
Print a summary line per entity (`created`/`unchanged`) so the idempotency test can assert on output as well as rows.
|
||||
|
||||
### Pattern 2: `/storage` mount (G2)
|
||||
|
||||
Register `GET /storage/app/uploads/public/{path...}` (better: the configured `storage.uploads.public_path_prefix` + `/{path...}`) in a raw group. The handler resolves `*blob.Bucket` and `*gorm.DB` via `app.Lookup` **per request** (the boot order precedent in `plugin.go:177-187`; `route:list` builds the router without a DB) and delegates to `attach.StaticHandlerPublic(bucket, prefix, gdb)`. Add `X-Content-Type-Options: nosniff`, because uploads are served same-origin with the app (see the static.go:24-25 warning). Two placements are possible: framework (opt-in config, reused by Phase 20) or fonoteka plugin. See Open Question 2.
|
||||
|
||||
### Pattern 3: dev-only marker route
|
||||
|
||||
Registered in the fonoteka plugin's `Routes()` only if `p.app.Config.Environment() != "production"`. It returns the fixed JSON in Q5. Unit test: present under `local`, absent (404 from the assembled router) under `production`, and absent from `parity/manifest.yaml`.
|
||||
|
||||
### Pattern 4: fail-closed guard as a standalone subcommand
|
||||
|
||||
`dev-stack.sh preflight` runs every guard check without starting anything and exits non-zero with a `refuse: …` line. `up` calls it before `pnpm dev`. That lets `check-phase14.2.sh --guard` test it directly:
|
||||
- `NUXT_DEV_BACKEND_ORIGIN=http://127.0.0.1:8422` → refuse (port rule; no network needed);
|
||||
- `NUXT_INTERNAL_API_ORIGIN` unset → refuse;
|
||||
- origin `https://plytarium.com` → refuse (non-loopback);
|
||||
- a fake PHP on a random port (`python3 -c` http.server that answers with `X-Powered-By: PHP/8.5.10` and 404) → refuse;
|
||||
- the real Go on `:8423` → pass.
|
||||
|
||||
### Pattern 5: process supervision in `dev-stack.sh`
|
||||
|
||||
Run `serve` and `pnpm dev` in the background with `… > >(sed 's/^/[go] /' | tee -a "$LOG/go.log") 2>&1 &`, record PIDs in `tmp/dev-stack/pids`, and `trap 'kill $(jobs -p)' EXIT INT TERM`. Wait for readiness by polling the marker (Go) and `http://localhost:3119/` (Nuxt) with a timeout. `tmp/` is already gitignored in `fonoteka.go` [VERIFIED: .gitignore `/tmp/`]. Provide `down` to kill recorded PIDs.
|
||||
|
||||
### Pattern 6: `runTinker` switch (D-14)
|
||||
|
||||
```ts
|
||||
// tests/e2e/helpers/runTinker.ts — additions only; PHP path untouched
|
||||
const GO = process.env.E2E_BACKEND === 'go'
|
||||
function goCwd() { return process.env.E2E_GO_DIR || path.resolve(path.dirname(process.env.E2E_GO_BIN!), '..') }
|
||||
export function backendAvailable(): boolean {
|
||||
if (GO) return !!process.env.E2E_GO_BIN && existsSync(process.env.E2E_GO_BIN)
|
||||
/* existing php artisan --version probe unchanged */
|
||||
}
|
||||
export function runTinker(phpScript: string): string {
|
||||
if (GO) throw new Error('[runTinker] PHP tinker is not available with E2E_BACKEND=go; use seedE2EFixtures()')
|
||||
/* unchanged */
|
||||
}
|
||||
export function seedE2EFixtures(): void {
|
||||
if (GO) { // fail closed: throws on non-zero exit
|
||||
execFileSync(process.env.E2E_GO_BIN!, ['dev:seed'], { cwd: goCwd(), env: { ...process.env }, stdio: 'inherit' })
|
||||
return
|
||||
}
|
||||
/* unchanged PHP path */
|
||||
}
|
||||
```
|
||||
|
||||
Optionally call `seedE2EFixtures()` from `auth.setup.ts` only `if (process.env.E2E_BACKEND === 'go')`, so a Playwright run is self-sufficient on Go and unchanged on PHP.
|
||||
|
||||
### Anti-Patterns to Avoid
|
||||
- **Probing a framework path Nuxt does not proxy** for the post-start check. It would prove nothing about the proxy.
|
||||
- **Opening the DB with `lagoon.Open`/`OpenFromApp` but skipping `lagoon.Publish`.** The OnDatabase slug and credential hooks never register, and the seeded collections and albums get empty slugs.
|
||||
- **Seeding albums through the HTTP handlers or with broadcasting on.** That queues River broadcast and wishlist-digest jobs from a CLI process.
|
||||
- **Writing `vue-fonoteka-app/.env`** (forbidden by D-08), or relying on `tests/e2e/.env.local` values (they may point at `:8422`).
|
||||
- **Treating a Playwright skip as a pass** (G7).
|
||||
- **Using `~/go/bin/summer`** (stale, and not on PATH for scripts).
|
||||
|
||||
## Don't Hand-Roll
|
||||
|
||||
| Problem | Don't Build | Use Instead | Why |
|
||||
|---------|-------------|-------------|-----|
|
||||
| Serving uploads | custom file server / `http.FileServer` on the storage dir | `attach.StaticHandlerPublic` | Validated 4-segment keys, `..` rejection, `is_public` gate, thumb sibling handling (T-05-13) |
|
||||
| Password hashing | direct bcrypt calls | `usercontrollers.RegisterUser` / `bouncer.HashPassword(cost, plain)` | Honors `golem15.user.password.bcrypt_cost` and the model rules |
|
||||
| Token minting | random string + sha256 by hand | `userclasses.MintAPIToken` + `HashAPIToken` | Prefix validation, scope allow-list, stored hash format |
|
||||
| Collection/wishlist creation | raw INSERTs (as the parity seed does) | `ProvisionCollection` / `ProvisionWishlist` | Context row, lock order, column defaults (`reservations_allowed`) |
|
||||
| Album creation | raw INSERT into `golem15_fonoteka_albums` | `CreateAlbum` | Field fill rules, artists and styles pivots, completion stamp, slug hooks |
|
||||
| Centrifugo health | curl `/api/info` by hand | `bin/fonoteka websockets:health` | Already reads the same config/env and exits 1 on failure |
|
||||
| go test result parsing | grep for `PASS` | the `detect()` python block from check-phase14.2.1.sh | Catches skips, zero tests, races and missing named tests |
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
### Pitfall 1: Plugin command silently has no DB (G1)
|
||||
**What goes wrong:** `dev:seed`/`fonoteka:reindex` exits 1 with `database is not configured`, even though unit tests pass (the tests pre-publish the DB).
|
||||
**How to avoid:** open and publish inside the command. Add a unit test that runs the command on an app **without** a published DB but with `database.dsn` pointing at the testcontainer.
|
||||
**Warning signs:** the error text above in the script output.
|
||||
|
||||
### Pitfall 2: Gate specs skip instead of fail (G7)
|
||||
**What goes wrong:** with `E2E_BACKEND_URL` unset, `fonotekaReachable()` probes `:8422`. If PHP is down the backend specs skip, and if PHP is up they run against a Nuxt that may still target Go (a mismatch).
|
||||
**How to avoid:** export `E2E_BACKEND_URL=http://127.0.0.1:8423`, and fail on `stats.skipped > 0` for gate files.
|
||||
|
||||
### Pitfall 3: Typesense silently bypassed (G8/G9)
|
||||
**What goes wrong:** search works through the SQL fallback while the Typesense key or index is wrong.
|
||||
**How to avoid:** `search_use_typesense=true` in the seed, reindex in the script, grep `go.log` for `album search engine failed` in `--e2e`.
|
||||
|
||||
### Pitfall 4: Centrifugo origin and proxy mismatch
|
||||
**What goes wrong:** the WS handshake is rejected silently (the origin is not allowed, for example `127.0.0.1:3119` vs `localhost:3119`), or `collection:{id}` subscribes are denied because the shared Centrifugo proxies to PHP or has a different `X-Centrifugo-Secret`.
|
||||
**How to avoid:** allow both origins. Make sure the HMAC secret, API key and proxy secret match `.env.dev`. Point the subscribe proxy at `:8423` (Open Question 1).
|
||||
*Superseded by D-18:* there is no subscribe proxy and no proxy secret on the shared instance. The remaining risks are the origin (add both `http://localhost:3119` and `http://127.0.0.1:3119` to `client.allowed_origins`, operator step) and a mismatched HMAC secret or API key in `.env.dev` (the script's `check` reports a rejected key).
|
||||
|
||||
### Pitfall 5: cwd-relative config and bucket
|
||||
**What goes wrong:** `bin/fonoteka` run from another directory finds no `config/` or writes covers under the wrong directory.
|
||||
**How to avoid:** always `cd "$APP"` (script) or `cwd: E2E_GO_DIR` (Playwright helper).
|
||||
|
||||
### Pitfall 6: Nuxt binds a different port or host
|
||||
**What goes wrong:** the nuxt.config comment notes that if 3119 is taken "the Nuxt listener silently falls back to another port (often :3000)" (nuxt.config.ts:47-50). Playwright `reuseExistingServer` then probes the wrong server.
|
||||
**How to avoid:** the preflight refuses if `:3119` is already listening, and the post-start probe targets `:3119` explicitly.
|
||||
|
||||
### Pitfall 7: Submodule pointer drift swept into commits (G13)
|
||||
**How to avoid:** stage explicit paths only. Never `git add -A` in `fonoteka.go`.
|
||||
|
||||
### Pitfall 8: Access log leaking secrets
|
||||
**What goes wrong:** raw paths carry share tokens (`/_fonoteka/api/v1/public/{token}`, `/invitations/{token}`) and query strings may carry `?token=`.
|
||||
**How to avoid:** log `r.Pattern` (Go 1.22+ ServeMux exposes the matched pattern) instead of the raw path for matched routes. For unmatched requests (404, which is exactly what D-12 wants to spot) log the raw path **without** the query string, and keep it behind an opt-in flag. Sunscreen does not scrub path segments.
|
||||
|
||||
## Code Examples
|
||||
|
||||
### Opening DB + bucket in a plugin command (pattern from serve/lagoon)
|
||||
```go
|
||||
// Source: summercms.go/modules/surf/serve.go:40-52 and lagoon/commands.go:230-240
|
||||
sqlDB, gdb, err := lagoon.OpenFromApp(ctx, app)
|
||||
if err != nil { return err }
|
||||
defer sqlDB.Close()
|
||||
if err := lagoon.Publish(app, sqlDB, gdb); err != nil { return err } // runs OnDatabase hooks (slugs)
|
||||
bucket, err := attach.OpenBucket(ctx, app.Config)
|
||||
if err != nil { return err }
|
||||
defer bucket.Close()
|
||||
if err := attach.Publish(app, bucket); err != nil { return err }
|
||||
```
|
||||
|
||||
### Env refusal
|
||||
```go
|
||||
// Source: compass/config.go:193-199 (SUMMER_ENV, default "production")
|
||||
switch strings.ToLower(strings.TrimSpace(app.Config.Environment())) {
|
||||
case "local", "dev":
|
||||
default:
|
||||
return fmt.Errorf("dev:seed: refused in environment %q (set SUMMER_ENV=local)", app.Config.Environment())
|
||||
}
|
||||
```
|
||||
|
||||
### Albums without broadcasts
|
||||
```go
|
||||
// Source: lighthouse/suppress.go:19; classes/album_write_service.go:378
|
||||
err := lighthouse.WithoutBroadcasting[models.Album](ctx, func(ctx context.Context) error {
|
||||
return lagoon.Transaction(ctx, gdb, func(ctx context.Context, tx *gorm.DB) error {
|
||||
a, err := classes.CreateAlbum(ctx, tx, colID, map[string]any{"name": "Kind of Blue", "year": 1959, "format": "LP"})
|
||||
_ = a
|
||||
return err
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
## State of the Art
|
||||
|
||||
| Old Approach | Current Approach | When Changed | Impact |
|
||||
|--------------|------------------|--------------|--------|
|
||||
| Realtime demo badge on `/` (`realtime-status`) | Dashboard composition; realtime is invisible plumbing (`useRealtimeSync`) | 2026-06-12 (6fcdb8e) | `realtime.spec.ts` must assert the WS connection or a cross-session update, not a badge |
|
||||
| PHP tinker seeding helper | Go `dev:seed` behind `E2E_BACKEND=go` | this phase | PHP path unchanged |
|
||||
| Centrifugo v5 flat keys (`token_hmac_secret_key`, `allowed_origins`, `api_key`) | v6 nested (`client.token.hmac_secret_key`, `client.allowed_origins`, `http_api.key`) | Centrifugo v6 | Docs and templates must use v6 keys; the local server is v6.5.1 |
|
||||
|
||||
## Assumptions Log
|
||||
|
||||
| # | Claim | Section | Risk if Wrong |
|
||||
|---|-------|---------|---------------|
|
||||
| A1 | The shared Centrifugo at `/etc/centrifugo/config.json` has its subscribe proxy pointed at PHP (`:8422`), with secrets that may differ from what Go will use | G12, Q7, OQ1 | Realtime subscribes are denied on Go. Medium impact: realtime is a manual-session item and a gate spec. **WRONG, superseded by D-18:** the inspected config has no subscribe or connect proxy; the secrets are shared with Go through `.env.dev`. |
|
||||
| A2 | `php artisan serve` on the PHP checkout answers the marker path with a 404 (any body) | Q5 | Low: the X-Powered-By check and the `.backend=="summercms"` check each refuse independently |
|
||||
| A3 | `localhost:3119` in the browser and Playwright reaches the Nuxt dev listener on both IPv4 and IPv6 | Q2 | Low: probes fail visibly |
|
||||
| A4 | Setting `app.url` to `http://localhost:3119` locally is right (frontend links such as activation and CSV export URLs) | .env.dev keys | Low: affects emails/links only. The OAuth issuer for local MCP OAuth would be wrong, but MCP OAuth is out of scope locally (personal token only). |
|
||||
| A5 | 3–5 seeded albums plus 1 wishlist album are enough for the i18n count, offline covers and the manual session | Pattern 1 | Low |
|
||||
| A6 | Rewriting `realtime.spec.ts` is acceptable under D-11 ("new backend-agnostic spec" / test helper) | Q1 | Low. If not, add `realtime-live.spec.ts` and mark the stale one report-only. Confirm with the user. **Settled by D-21:** rewrite it as a backend-agnostic spec verified on PHP and Go. |
|
||||
|
||||
## Open Questions (RESOLVED)
|
||||
|
||||
All four questions were answered at the planning checkpoint on 2026-10-07 (CONTEXT.md D-18..D-22). None is open.
|
||||
|
||||
1. **Which Centrifugo does the local Go stack use?** RESOLVED by **D-18**.
|
||||
- Resolution: the shared host Centrifugo (v6, systemd, `:8787`, `/etc/centrifugo/config.json`) is reused, the same topology as production. The operator inspected its config: HMAC connection tokens (`client.token.hmac_secret_key`), HTTP API key (`http_api.key`), top-level channels with `allow_subscribe_for_client: true`, and **no subscribe or connect proxy**. Assumption A1 was wrong, so PHP and Go can share the instance. Go needs only the same HMAC secret and API key in `.env.dev` (`SUMMER_REALTIME__CENTRIFUGO__TOKEN_SECRET`, `SUMMER_REALTIME__CENTRIFUGO__API_KEY`, `SUMMER_REALTIME__CENTRIFUGO__API_URL=http://127.0.0.1:8787/api`). No dedicated Centrifugo is started. Adding `http://localhost:3119` to `client.allowed_origins` and restarting the service is a documented operator step that the script never performs; its preflight checks reachability and warns about a missing origin.
|
||||
- Planned in: plan 02 (`check_services` reachability, key and origin probe; README operator step; the env-file checkpoint), plan 03 Task 2 (operator checkpoint) and Task 3 (`realtime.spec.ts`).
|
||||
- *Superseded by D-18:* the original research recommendation, a dedicated Centrifugo started by `dev-stack.sh` on another port with a subscribe proxy at `:8423`, was rejected. It rested on A1.
|
||||
2. **Where does the `/storage` mount live: framework or app?** RESOLVED by **D-19**.
|
||||
- Resolution: framework opt-in in `summercms.go` surf behind the config key `storage.uploads.serve` (default false), mounting `attach.StaticHandlerPublic` at `storage.uploads.public_path_prefix` with `X-Content-Type-Options: nosniff`; Phase 20 reuses it; module README and docs updated in the same change.
|
||||
- Planned in: plan 01 Task 1 (mount and docs), plan 02 Task 2 (`config/storage.yaml` default, `.env.dev.example` sets it true locally), plan 04 Task 2 (`TestUploadsMount`).
|
||||
3. **Access-log placement and key name.** RESOLVED in **plan 01 Task 3** (within D-12 and D-22, which name the opt-in access log; the key name was left to the planner).
|
||||
- Resolution: unexported `surf` middleware enabled by `http.access_log: true` (env `SUMMER_HTTP__ACCESS_LOG`), default off, logging `method`, `pattern`, `status`, `duration_ms`, plus `path` without its query string only for unmatched requests, through the published `*slog.Logger` or `slog.Default()` (sunscreen).
|
||||
- Planned in: plan 01 Task 3, plan 02 Task 2 (`config/http.yaml` default), plan 04 Task 2 (`TestAccessLog`).
|
||||
4. **Fix `user:token:*` (shared sm-user-plugin) for G1 now?** RESOLVED: no.
|
||||
- Resolution: the shared plugin is not changed (core-plugin rule); the seed mints its own token. The gap is recorded as the todo `.planning/todos/pending/user-plugin-commands-open-database.md` (plan 01 Task 2). The related question about rewriting the stale `realtime.spec.ts` (A6) is settled by D-21.
|
||||
|
||||
## Environment Availability
|
||||
|
||||
| Dependency | Required By | Available | Version | Fallback |
|
||||
|------------|------------|-----------|---------|----------|
|
||||
| Postgres (host) | Go DB | ✓ `127.0.0.1:5432 - accepting connections` | (auth required; Go DB/DSN not inspected) | Docker PG containers on 55432/55433/55415 exist |
|
||||
| Typesense | search, reindex | ✗ container `plytarium-typesense-instance` (typesense:26.0) **Exited**, binds 127.0.0.1:8181 | 26.0 | `docker start plytarium-typesense-instance` (no fallback per D-06) |
|
||||
| Centrifugo | realtime | ✓ running (systemd, `*:8787`) | v6.5.1 | none: the shared instance is reused (*superseded by D-18*; the dedicated-instance fallback was rejected) |
|
||||
| Go | build/test | ✓ | go1.27.0 | — |
|
||||
| Docker | testcontainers in Go tests | ✓ | — | — |
|
||||
| Node / pnpm | Nuxt, Playwright | ✓ | v22.23.2 (`.nvmrc` 22.22.0) / pnpm 11.3.0 | — |
|
||||
| Playwright browsers | e2e | ✓ chromium 1169–1223 in `~/.cache/ms-playwright` | @playwright/test ^1.60.0 | — |
|
||||
| python3, jq, curl | scripts | ✓ | — | — |
|
||||
| `summer` CLI | `summer build` | ⚠ `~/go/bin/summer` (2026-10-05), not on PATH for scripts | — | build from `summercms.go/cmd/summer` in the script |
|
||||
| PHP (side-by-side check, D-11) | verify app edits on PHP | ✓ PHP 8.5.10, `expose_php On`; `:8422` currently down | 8.5.10 | — |
|
||||
|
||||
**Missing dependencies with no fallback:** Typesense must be started (`docker start plytarium-typesense-instance`). Its API key must go into `.env.dev`.
|
||||
**Missing dependencies with fallback:** `summer` on PATH (build from source).
|
||||
|
||||
## Validation Architecture
|
||||
|
||||
### Test Framework
|
||||
|
||||
| Property | Value |
|
||||
|----------|-------|
|
||||
| Framework | Go `testing` + testcontainers-go Postgres (existing harness, e.g. `console/phase14_commands_test.go`, `classes/postgres_test.go`); Playwright ^1.60 for e2e; `node --test` for Nuxt contracts |
|
||||
| Config file | none for Go; `vue-fonoteka-app/playwright.config.ts`; `vitest.config.ts` (unit, not needed) |
|
||||
| Quick run command | `go -C ../fonoteka.go test ./plugins/golem15/fonoteka/console/ -run 'TestDevSeed' -count=1` |
|
||||
| Full suite command | `bash scripts/check-phase14.2.sh --all` (from `summercms.go`) |
|
||||
|
||||
### Phase Requirements → Test Map
|
||||
|
||||
| Req ID | Behavior | Test Type | Automated Command | File Exists? |
|
||||
|--------|----------|-----------|-------------------|-------------|
|
||||
| QA-05 | `dev:seed` refuses outside local/dev (unset SUMMER_ENV → production) | unit | `go -C ../fonoteka.go test ./plugins/golem15/fonoteka/console/ -run TestDevSeedRefusesProduction -count=1` | ❌ Wave 0 (last plan) |
|
||||
| QA-05 | `dev:seed` idempotent: second run creates no rows (counts per table equal), prints `unchanged` | unit (testcontainers) | `… -run TestDevSeedIdempotent` | ❌ |
|
||||
| QA-05 | created shape: activated user with E2E creds, context collection, ≥3 albums with one photo each (blob exists in mem/file bucket), wishlist album, `inv_` token row, `search_use_typesense=true` | unit | `… -run TestDevSeedShape` | ❌ |
|
||||
| QA-05 | seed opens its own DB (no pre-published handle) | unit | `… -run TestDevSeedOpensDatabase` | ❌ |
|
||||
| QA-05 | `fonoteka:reindex` opens its own DB (G1) | unit | `… -run TestReindexOpensDatabase` | ❌ |
|
||||
| QA-05 | marker route 200 JSON in local, absent in production, not in manifest | unit | `go -C ../fonoteka.go test ./plugins/golem15/fonoteka/ -run TestDevMarkerRoute` | ❌ |
|
||||
| QA-05 | `/storage/app/uploads/public/...` serves a public blob, 404s private/missing/traversal, sets nosniff | unit | `go test ./modules/surf/ -run TestUploadsMount` (or plugin path if app-level) | ❌ |
|
||||
| QA-05 | access log off by default; on: logs pattern+status, never query string or tokens | unit | `go test ./modules/surf/ -run TestAccessLog` | ❌ |
|
||||
| QA-05 | guard refuses `:8422`, unset internal origin, non-loopback, fake PHP | script | `bash scripts/check-phase14.2.sh --guard` | ❌ |
|
||||
| QA-05 | runTinker switch: PHP path byte-unchanged when unset; Go path calls `dev:seed` | contract | `pnpm test:contracts` (new `tests/contracts/e2e-backend-switch.test.mjs`) | ❌ |
|
||||
| QA-05 | core flows on Go (login, browse, search, edit, rate, cover, wishlist, realtime, i18n, public share) | e2e | `bash scripts/check-phase14.2.sh --e2e` | ❌ (new specs in plan 3) |
|
||||
| QA-05 | signed-in manual session incl. realtime between two sessions | manual | UAT checklist (14.2-UAT.md) | manual-only: realtime across two real browsers and visual cover checks |
|
||||
|
||||
### Sampling Rate
|
||||
- **Per task commit:** the quick `go test` for the touched package, plus `go vet` on the touched modules.
|
||||
- **Per wave merge:** `bash scripts/check-phase14.2.sh --go` (+ `--docs` when framework modules changed).
|
||||
- **Phase gate:** `bash scripts/check-phase14.2.sh --all` green (needs services up), then `/gsd-verify-work`.
|
||||
|
||||
### Wave 0 Gaps
|
||||
- [ ] `fonoteka.go/plugins/golem15/fonoteka/console/dev_seed_test.go`: refusal, idempotency, shape, own-DB.
|
||||
- [ ] `fonoteka.go/plugins/golem15/fonoteka/console/reindex` own-DB test.
|
||||
- [ ] marker route test in the fonoteka plugin package.
|
||||
- [ ] `summercms.go/modules/surf` tests for the access log and uploads mount (if framework-level).
|
||||
- [ ] `vue-fonoteka-app/tests/contracts/e2e-backend-switch.test.mjs`.
|
||||
- [ ] `summercms.go/scripts/check-phase14.2.sh`.
|
||||
- Framework install: none.
|
||||
|
||||
## Security Domain
|
||||
|
||||
### Applicable ASVS Categories
|
||||
|
||||
| ASVS Category | Applies | Standard Control |
|
||||
|---------------|---------|-----------------|
|
||||
| V2 Authentication | yes (seeded credentials, token) | Credentials only from env (`E2E_*`, `DEV_SEED_API_TOKEN`); bcrypt via `RegisterUser`; the token stored as a sha256 hash (`HashAPIToken`); the seed refuses in production |
|
||||
| V3 Session Management | no change | existing JWT cookie flow |
|
||||
| V4 Access Control | yes | Marker route env-gated (absent in production); `/storage` uses the `is_public` gate (`StaticHandlerPublic`) |
|
||||
| V5 Input Validation | yes | Upload paths validated by `parsePublicBlobPath` (`..`, empty segments, partition match) |
|
||||
| V6 Cryptography | no hand-rolling | `crypto/rand` inside `MintAPIToken`; app key from `key:generate` |
|
||||
| V7 Logging | yes | Access log through sunscreen. Log route patterns, not raw paths with tokens; no query strings. |
|
||||
| V14 Configuration | yes | `.env.dev` gitignored, `.env.dev.example` placeholders only; Go binds `127.0.0.1:8423`, not all interfaces |
|
||||
|
||||
### Known Threat Patterns for this stack
|
||||
|
||||
| Pattern | STRIDE | Standard Mitigation |
|
||||
|---------|--------|---------------------|
|
||||
| Seed run against production DB | Tampering | `SUMMER_ENV` must be `local`/`dev`; the default env is `production`, so refusal is the default |
|
||||
| Same-origin XSS via uploaded content served from `/storage` on the app origin | Elevation | `StaticHandlerPublic` + `X-Content-Type-Options: nosniff`; uploads are image-guarded at upload; the seed only writes PNG |
|
||||
| Secrets in logs (tokens in paths, Bearer headers) | Information disclosure | sunscreen + pattern-based access logging; never log headers |
|
||||
| Committing secrets (`.env.dev`, minted token) | Information disclosure | `.gitignore`; the token secret printed once to the terminal only; never written to the repo |
|
||||
| Backend identity disclosure via marker | Information disclosure | Route registered only outside production |
|
||||
| Dev server reachable from the LAN | Spoofing / exposure | `--addr 127.0.0.1:8423`; Centrifugo dedicated instance on loopback (*superseded by D-18*: no dedicated instance; the shared `:8787` instance is unchanged and the script never edits or restarts it) |
|
||||
|
||||
## Sources
|
||||
|
||||
### Primary (HIGH confidence, read or executed this session)
|
||||
- `fonoteka.go`: `plugins/golem15/fonoteka/{plugin.go, routes.go, realtime.go, search.go, console/reindex.go, classes/{onboarding,collection_provisioner,wishlist_resolver,album_write_service,album_files,album_search}.go, models/{settings,album_rating}.go, updates/20_remaining.go}`, `plugins/golem15/user/{models/user.go, models/api_token.go, classes/api_tokens.go, console/api_tokens.go, controllers/registration.go}`, `config/*.yaml`, `README.md`, `main.go`, `.gitignore`, `parity/{manifest.yaml, routes.snapshot, parity_contract_test.go, fonoteka_seed_test.go}`.
|
||||
- `summercms.go`: `modules/{surf/serve.go, surf/router.go, compass/config.go, compass/env.go, lagoon/connection.go, lagoon/commands.go, lagoon/attach/static.go, lagoon/attach/file.go, lighthouse/suppress.go, lighthouse/README.md, bonfire/command.go, conga/commands.go, sunscreen/README.md}`, `cmd/summer/main.go`, `scripts/check-phase14.2.1.sh`, `scripts/check-phase14.sh`.
|
||||
- `vue-fonoteka-app`: `nuxt.config.ts`, `playwright.config.ts`, `.env.example`, `tests/e2e/*.ts`, `tests/e2e/helpers/*.ts`, `app/plugins/api.ts`, `app/composables/useCentrifugo.ts`, grep of `app/` for test IDs; `node_modules/.pnpm/c12@3.3.4…/dist/index.mjs`.
|
||||
- Executed: freshly built `fonoteka` binary (`route:list`, `fonoteka:reindex`, `user:token:create`, `migrate:status`, `websockets:health`, `--help`); `docker ps -a`, `docker inspect`, `ss -ltnp`, `pg_isready`, `centrifugo version`, `php -i`.
|
||||
|
||||
### Secondary (MEDIUM)
|
||||
- centrifugal.dev/docs/server/configuration and /docs/server/proxy (v6 key names; fetched via a summarizing tool, cross-checked against each other).
|
||||
|
||||
### Tertiary (LOW)
|
||||
- None relied upon.
|
||||
|
||||
## Metadata
|
||||
|
||||
**Confidence breakdown:**
|
||||
- Go gaps and APIs: HIGH. Read the source and reproduced G1/G2 against a fresh build.
|
||||
- Nuxt/Playwright contract: HIGH. Read the config, specs and c12 source.
|
||||
- Centrifugo integration: MEDIUM. v6 keys cited from the docs; the shared instance's config could not be read (A1). *Superseded by D-18:* the operator inspected the config; A1 was wrong (no proxy).
|
||||
- Plan shape: MEDIUM. Depends on OQ1/OQ2 answers. *Resolved:* OQ1 by D-18, OQ2 by D-19, plan count fixed at 4 by D-22.
|
||||
|
||||
**Suggested plan breakdown (for the plan-count checkpoint):**
|
||||
1. **Go glue** (`fonoteka.go` + `summercms.go`): `dev:seed` (env-gated, idempotent, own DB/bucket, generated covers, token, search flag), the reindex own-DB fix, the dev-only marker route, the `/storage` uploads mount, the opt-in surf access log, module READMEs/docs. Smoke tests only.
|
||||
2. **Stack script and docs** (`fonoteka.go`): `scripts/dev-stack.sh` (`preflight`/`up`/`down`/`status`, service checks naming the missing one, build from source, migrate → seed → reindex → serve → guard → `pnpm dev` with pinned env, labeled tee'd logs, post-start probe), `.env.dev.example`, `.gitignore`, run docs including the Centrifugo v6 snippet and MCP pointing.
|
||||
3. **Nuxt specs and manual session** (`vue-fonoteka-app`): the `E2E_BACKEND=go` switch (+ optional `auth.setup` hook), the `realtime.spec.ts` rewrite, new `album-edit-rate-cover`, `album-search` and `wishlist` specs (self-seeding, backend-agnostic, verified on PHP as well), `.env.example` notes; then the checklist-driven manual session recorded as UAT evidence.
|
||||
4. **Unit tests and gate** (last, per project rule): Go unit tests from the Validation map, the Nuxt contract test, `summercms.go/scripts/check-phase14.2.sh` (`--self-test`/`--go`/`--docs`/`--guard`/`--e2e`/`--all`).
|
||||
|
||||
**Research date:** 2026-10-07
|
||||
**Valid until:** 2026-11-06 (stable internal code; re-check G1/G2 if framework commits land first)
|
||||
@@ -0,0 +1,90 @@
|
||||
---
|
||||
phase: "14.2"
|
||||
slug: "local-fonoteka-frontend-on-local-summercms-backend"
|
||||
# status lifecycle: draft (seeded by plan-phase) → validated (set by validate-phase §6)
|
||||
# audit-milestone §5.5 distinguishes NOT-VALIDATED (draft) from PARTIAL (validated + nyquist_compliant: false) (#2117)
|
||||
status: draft
|
||||
nyquist_compliant: false
|
||||
wave_0_complete: false
|
||||
created: "2026-10-07"
|
||||
---
|
||||
|
||||
# Phase 14.2 — Validation Strategy
|
||||
|
||||
> Per-phase validation contract for feedback sampling during execution.
|
||||
|
||||
---
|
||||
|
||||
## Test Infrastructure
|
||||
|
||||
| Property | Value |
|
||||
|----------|-------|
|
||||
| **Framework** | Go `testing` + testcontainers-go Postgres; Playwright ^1.60 (e2e); `node --test` (Nuxt contracts) |
|
||||
| **Config file** | none for Go; `vue-fonoteka-app/playwright.config.ts` |
|
||||
| **Quick run command** | `go -C ../fonoteka.go test ./plugins/golem15/fonoteka/console/ -run 'TestDevSeed' -count=1` |
|
||||
| **Full suite command** | `bash scripts/check-phase14.2.sh --all` (from `summercms.go`) |
|
||||
| **Estimated runtime** | ~300 seconds (full, with e2e); ~30 seconds (quick) |
|
||||
|
||||
---
|
||||
|
||||
## Sampling Rate
|
||||
|
||||
- **After every task commit:** quick `go test` for the touched package plus `go vet` on the touched modules
|
||||
- **After every plan wave:** `bash scripts/check-phase14.2.sh --go` (+ `--docs` when framework modules changed)
|
||||
- **Before `/gsd-verify-work`:** `bash scripts/check-phase14.2.sh --all` green (services up)
|
||||
- **Max feedback latency:** 60 seconds (per-task)
|
||||
|
||||
---
|
||||
|
||||
## Per-Task Verification Map
|
||||
|
||||
Filled in by the planner per task; requirement-level map from RESEARCH.md:
|
||||
|
||||
| Behavior | Requirement | Test Type | Automated Command | File Exists | Status |
|
||||
|----------|-------------|-----------|-------------------|-------------|--------|
|
||||
| `dev:seed` refuses outside local/dev | QA-05 | unit | `go -C ../fonoteka.go test ./plugins/golem15/fonoteka/console/ -run TestDevSeedRefusesProduction -count=1` | ❌ W0 | ⬜ pending |
|
||||
| `dev:seed` idempotent | QA-05 | unit (testcontainers) | `… -run TestDevSeedIdempotent` | ❌ W0 | ⬜ pending |
|
||||
| `dev:seed` created shape (user, collection, albums+covers, wishlist, `inv_` token, typesense flag) | QA-05 | unit | `… -run TestDevSeedShape` | ❌ W0 | ⬜ pending |
|
||||
| seed and reindex open their own DB (G1) | QA-05 | unit | `… -run 'TestDevSeedOpensDatabase|TestReindexOpensDatabase'` | ❌ W0 | ⬜ pending |
|
||||
| marker route present in local, absent in production, not in manifest | QA-05 | unit | `go -C ../fonoteka.go test ./plugins/golem15/fonoteka/ -run TestDevMarkerRoute` | ❌ W0 | ⬜ pending |
|
||||
| `/storage/app/uploads/public/...` serves public blobs, 404s private/traversal, nosniff (G2) | QA-05 | unit | `go test ./modules/surf/ -run TestUploadsMount` | ❌ W0 | ⬜ pending |
|
||||
| access log opt-in, logs pattern+status only (G3) | QA-05 | unit | `go test ./modules/surf/ -run TestAccessLog` | ❌ W0 | ⬜ pending |
|
||||
| guard refuses `:8422`, unset internal origin, non-loopback, PHP answer | QA-05 | script | `bash scripts/check-phase14.2.sh --guard` | ❌ W0 | ⬜ pending |
|
||||
| runTinker switch: PHP path unchanged when unset | QA-05 | contract | `pnpm test:contracts` | ❌ W0 | ⬜ pending |
|
||||
| core flows on Go | QA-05 | e2e | `bash scripts/check-phase14.2.sh --e2e` | ❌ W0 | ⬜ pending |
|
||||
|
||||
*Status: ⬜ pending · ✅ green · ❌ red · ⚠️ flaky*
|
||||
|
||||
---
|
||||
|
||||
## Wave 0 Requirements
|
||||
|
||||
- [ ] `fonoteka.go/plugins/golem15/fonoteka/console/dev_seed_test.go` — refusal, idempotency, shape, own-DB
|
||||
- [ ] reindex own-DB test in `fonoteka.go/plugins/golem15/fonoteka/console/`
|
||||
- [ ] marker route test in the fonoteka plugin package
|
||||
- [ ] `summercms.go/modules/surf` tests for access log and uploads mount
|
||||
- [ ] `vue-fonoteka-app/tests/contracts/e2e-backend-switch.test.mjs`
|
||||
- [ ] `summercms.go/scripts/check-phase14.2.sh`
|
||||
|
||||
Framework install: none.
|
||||
|
||||
---
|
||||
|
||||
## Manual-Only Verifications
|
||||
|
||||
| Behavior | Requirement | Why Manual | Test Instructions |
|
||||
|----------|-------------|------------|-------------------|
|
||||
| Signed-in session covering all core flows, realtime across two browsers, visual cover check | QA-05 | D-13 requires one checklist-driven human session as UAT evidence | Follow the checklist in `14.2-UAT.md` against `scripts/dev-stack.sh up` |
|
||||
|
||||
---
|
||||
|
||||
## Validation Sign-Off
|
||||
|
||||
- [ ] All tasks have `<automated>` verify or Wave 0 dependencies
|
||||
- [ ] Sampling continuity: no 3 consecutive tasks without automated verify
|
||||
- [ ] Wave 0 covers all MISSING references
|
||||
- [ ] No watch-mode flags
|
||||
- [ ] Feedback latency < 60s
|
||||
- [ ] `nyquist_compliant: true` set in frontmatter
|
||||
|
||||
**Approval:** pending
|
||||
@@ -0,0 +1 @@
|
||||
No external API integration: local-run glue around the Phase 14 Centrifugo and Typesense integrations; it adds only reachability probes plus a read-only Centrifugo namespace probe (server API `history`, D-23) and uses the existing publish and index paths unchanged.
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,303 @@
|
||||
---
|
||||
phase: 14.3-one-command-app-scaffolding-summer-new-and-summercms-io-new
|
||||
plan: 01
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
files_modified:
|
||||
- internal/build/new.go
|
||||
- internal/build/new_test.go
|
||||
- internal/build/stubs/new.tmpl
|
||||
- internal/build/stubs/plugin.tmpl
|
||||
- internal/build/scaffold.go
|
||||
- cmd/summer/main.go
|
||||
- cmd/summer/main_test.go
|
||||
- modules/lagoon/keygen.go
|
||||
- modules/lagoon/README.md
|
||||
- examples/hello/config/http.yaml
|
||||
- README.md
|
||||
- docs/setup/installation.md
|
||||
- scripts/check-phase14.3.sh
|
||||
autonomous: true
|
||||
requirements: [D-01, D-02, D-03, D-04, D-05, D-06, D-07, D-08, D-09, D-10, D-11, D-12, D-22, D-23, D-24, D-25, D-27]
|
||||
estimate:
|
||||
tokens: 150000
|
||||
raw_tokens: 150000
|
||||
tasks: 3
|
||||
confidence: low
|
||||
must_haves:
|
||||
truths:
|
||||
- "D-02/D-22: `summer new <name>` accepts only names matching ^[a-z][a-z0-9]*$ that are not a Go keyword, `main` or `internal`; any other name exits non-zero with a message naming the rule, before anything is written."
|
||||
- "D-02: when `sm-<name>-app` already exists in the current directory as a directory, file or symlink (dangling included), `summer new` exits non-zero and leaves it byte-for-byte untouched."
|
||||
- "D-03/D-04: the generated go.mod declares `module git.golem15.com/golem15/sm-<name>-app`, `go 1.27.0`, `toolchain go1.27.0`, requires `git.golem15.com/golem15/summercms v0.0.0` and replaces it with a relative path (starting ./ or ../) to the framework checkout chosen in the order --framework, ../summercms, ../summercms.go (each accepted only when its go.mod declares the framework module path), then the running binary's own source tree."
|
||||
- "Edge (missing framework): with no --framework, no matching sibling and no usable binary source, `summer new` exits non-zero, the error text contains `--framework`, and no sm-<name>-app directory remains."
|
||||
- "Edge (target inside framework): a target directory inside the resolved framework checkout is refused."
|
||||
- "D-05/D-27: the starter plugin golem15.<name> is written by the make:plugin and plugin:add code paths with a starter route GET /{$}; plain `summer make:plugin` output is unchanged; every `use` line of the generated go.work is relative (./plugins/<name>), and `summer plugin:add` now writes relative go.work paths too."
|
||||
- "D-06: `summer new <name> --no-plugin` writes summer.yaml with no plugin entries and the app still builds."
|
||||
- "D-07/D-27: config/ holds app.yaml, database.yaml, http.yaml (body_limits default_bytes 1048576, upload_bytes 10485760 as YAML numbers) and storage.yaml (uploads.bucket_url file://./storage/app/uploads); examples/hello gains the same http.yaml and `go -C examples/hello test ./...` passes."
|
||||
- "D-08/D-09/D-23: .env (mode 0600) holds SUMMER_DATABASE__DSN=postgres://summer:summer@127.0.0.1:54320/<name>?sslmode=disable and a 44-character SUMMER_APP__KEY from lagoon.GenerateAppKey; compose.yaml runs postgres:16 published only on 127.0.0.1:54320 with matching user, password and database; .env.example lists the DSN, an empty key placeholder and the commented uploads bucket."
|
||||
- "D-10/D-01: CLAUDE.md names the sm-<name>-app / sm-<name>-plugin / sm-<name>-theme convention, https://summercms.io/docs/llms.txt and https://summercms.io/docs/llms-full.txt, the commands make:plugin, plugin:add, build, dev, migrate and serve, and says not to edit the framework; AGENTS.md is a relative symlink to CLAUDE.md."
|
||||
- "D-11: without --no-git the app is a git repository with exactly one commit on branch main whose tree excludes .env; with no git identity configured the commit uses the fallback author SummerCMS <summer@localhost> for that commit only; a git failure prints a warning and the app stays."
|
||||
- "D-24: `summer new` prints next steps listing `docker compose up -d --wait`, `summer build`, `summer migrate` and `summer serve`."
|
||||
- "D-25: the app root has no go.sum; .gitignore lists /bin/, /tmp/, /storage/, .env, go.work.sum and *.exe; go.work, main.go and plugins.gen.go are committed."
|
||||
- "Edge (partial failure): any error after the target directory was created removes that directory, so a re-run with the same name is not blocked."
|
||||
- "D-12: with `docker compose up -d --wait` running, `summer build`, `summer migrate` and `summer serve` succeed in the generated app with no edits and GET http://127.0.0.1:8080/ answers 200 JSON containing \"plugin\":\"golem15.<name>\"."
|
||||
prohibitions:
|
||||
- requirement_id: D-08
|
||||
category: privacy
|
||||
status: resolved
|
||||
verification: judgment
|
||||
resolution: flagged-unverified
|
||||
reason: "Enforced by the generator gate and plan 04 tests; descriptor-less, human review recommended."
|
||||
statement: "summer new must never commit .env or any application key, and .env.example must never carry a real key."
|
||||
- requirement_id: D-03
|
||||
category: privacy
|
||||
status: resolved
|
||||
verification: judgment
|
||||
resolution: flagged-unverified
|
||||
reason: "Golden go.mod/go.work in plan 04 pin relative paths; descriptor-less, human review recommended."
|
||||
statement: "Committed go.mod and go.work files must never contain absolute, machine-specific paths."
|
||||
- requirement_id: D-23
|
||||
category: safety
|
||||
status: resolved
|
||||
verification: judgment
|
||||
resolution: flagged-unverified
|
||||
reason: "compose.yaml golden in plan 04; descriptor-less, human review recommended."
|
||||
statement: "The generated compose.yaml must never publish PostgreSQL on an address other than 127.0.0.1."
|
||||
- requirement_id: D-10
|
||||
category: transparency
|
||||
status: resolved
|
||||
verification: judgment
|
||||
resolution: flagged-unverified
|
||||
reason: "Framework README rule applies to template text; descriptor-less, human review recommended."
|
||||
statement: "Generated template text must never name a consuming application."
|
||||
- requirement_id: D-05
|
||||
category: safety
|
||||
status: resolved
|
||||
verification: judgment
|
||||
resolution: flagged-unverified
|
||||
reason: "Project rules 4 and 5; descriptor-less, human review recommended."
|
||||
statement: "The phase must add no new Go dependency and no runtime plugin loading."
|
||||
artifacts:
|
||||
- path: "internal/build/new.go"
|
||||
provides: "NewOptions, NewApp, ValidateAppName, planNewApp, framework resolution"
|
||||
contains: "func NewApp("
|
||||
- path: "internal/build/stubs/new.tmpl"
|
||||
provides: "templates for go.mod, config/*, .env, .env.example, compose.yaml, .gitignore, CLAUDE.md, next steps"
|
||||
contains: "new/compose.yaml"
|
||||
- path: "internal/build/stubs/plugin.tmpl"
|
||||
provides: "routes.go with opt-in starter route"
|
||||
contains: "StarterRoute"
|
||||
- path: "cmd/summer/main.go"
|
||||
provides: "summer new command with --framework, --no-plugin, --no-git"
|
||||
contains: "\"new\""
|
||||
- path: "modules/lagoon/keygen.go"
|
||||
provides: "exported key generator reused by key:generate and summer new"
|
||||
contains: "func GenerateAppKey("
|
||||
- path: "examples/hello/config/http.yaml"
|
||||
provides: "body_limits for serve and route:list"
|
||||
contains: "default_bytes"
|
||||
- path: "scripts/check-phase14.3.sh"
|
||||
provides: "generator stage: D-12 docker compose build/migrate/serve chain"
|
||||
contains: "--generator"
|
||||
key_links:
|
||||
- from: "cmd/summer/main.go"
|
||||
to: "modules/lagoon/keygen.go"
|
||||
via: "newCommand passes lagoon.GenerateAppKey() into build.NewOptions.AppKey"
|
||||
pattern: "GenerateAppKey"
|
||||
- from: "internal/build/new.go"
|
||||
to: "internal/build/scaffold.go"
|
||||
via: "starter plugin through makePlugin(StarterRoute) and AddPlugin, then generate()"
|
||||
pattern: "makePlugin("
|
||||
- from: "internal/build/scaffold.go"
|
||||
to: "go.work"
|
||||
via: "runGoWorkUse passes a path relative to the go.work directory"
|
||||
pattern: "go\", \"work\", \"use\""
|
||||
---
|
||||
|
||||
<objective>
|
||||
Add `summer new <name>`: one command that writes a ready-to-run `sm-<name>-app/` (go.mod with a relative framework replace, go.work, summer.yaml, a starter plugin with an HTTP route, complete config/, .env with a fresh key, .env.example, compose.yaml, .gitignore, CLAUDE.md/AGENTS.md, first git commit) in the current directory, reusing the make:plugin, plugin:add, generate and key:generate code paths.
|
||||
|
||||
Purpose: remove the hand-written go.mod/summer.yaml/config/ step from starting an app (phase goal, roadmap success criteria 1 and 2) and fix the two shared-path defects research found (absolute go.work paths, no starter route option) plus the examples/hello body_limits gap.
|
||||
Output: generator in internal/build, CLI command, exported lagoon.GenerateAppKey, examples/hello fix, generator gate stage proving D-12 on real PostgreSQL 16.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/gsd-core/workflows/execute-plan.md
|
||||
@~/.claude/gsd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/PROJECT.md
|
||||
@.planning/STATE.md
|
||||
@.planning/phases/14.3-one-command-app-scaffolding-summer-new-and-summercms-io-new/14.3-CONTEXT.md
|
||||
@.planning/phases/14.3-one-command-app-scaffolding-summer-new-and-summercms-io-new/14.3-RESEARCH.md
|
||||
@.planning/phases/14.3-one-command-app-scaffolding-summer-new-and-summercms-io-new/14.3-VALIDATION.md
|
||||
@internal/build/scaffold.go
|
||||
@internal/build/build.go
|
||||
@internal/build/manifest.go
|
||||
@internal/build/stubs/plugin.tmpl
|
||||
@cmd/summer/main.go
|
||||
@modules/lagoon/keygen.go
|
||||
</context>
|
||||
|
||||
## Spec-less probe fallback
|
||||
|
||||
Phase 14.3 has no REQUIREMENTS.md IDs and no SPEC.md, so requirement probing is visibly skipped. The acceptance contract is D-01..D-28 from 14.3-CONTEXT.md, the six roadmap success criteria, and the edge predicates and prohibitions authored into `must_haves` above (name validation, existing directory, missing framework, target inside framework, partial failure, no git identity; no committed secrets, no absolute paths, loopback-only Postgres, neutral template text, no new dependencies or runtime plugin loading).
|
||||
|
||||
## Artifacts this phase produces (this plan)
|
||||
|
||||
- `build.NewOptions` (fields `Name`, `Parent`, `Framework`, `AppKey`, `NoPlugin`, `NoGit`), `build.NewApp(ctx, opts, out) (string, error)`, `build.ValidateAppName(name) error`.
|
||||
- Unexported: `planNewApp`, `genFile`, `resolveFramework`, `frameworkFromBinary` (package variable, replaceable in tests), `binarySourceFramework`, `makePlugin` + `pluginOptions{StarterRoute}`, `stubData.StarterRoute`, a relative-path helper shared by `editAppModule`, `runGoWorkUse` and `NewApp`.
|
||||
- Template file `internal/build/stubs/new.tmpl` with defines `new/go.mod`, `new/config/app.yaml`, `new/config/database.yaml`, `new/config/http.yaml`, `new/config/storage.yaml`, `new/env`, `new/env.example`, `new/compose.yaml`, `new/gitignore`, `new/CLAUDE.md`, `new/next-steps`.
|
||||
- CLI: `summer new <name>` with flags `--framework <path>`, `--no-plugin`, `--no-git`.
|
||||
- `lagoon.GenerateAppKey() (string, error)`; `lagoon.KeyGenerateCommand` now calls it.
|
||||
- `examples/hello/config/http.yaml`.
|
||||
- `scripts/check-phase14.3.sh` with the `--generator` stage (final line `Phase 14.3 generator check passed`).
|
||||
- Generated app layout: `go.mod`, `go.work`, `summer.yaml`, `main.go`, `plugins.gen.go`, `config/{app,database,http,storage}.yaml`, `.env` (0600), `.env.example`, `compose.yaml` (postgres:16 on 127.0.0.1:54320, user/password `summer`, database `<name>`), `.gitignore`, `CLAUDE.md`, `AGENTS.md` -> `CLAUDE.md`, `plugins/<name>/` (starter plugin `golem15.<name>`, route `GET /{$}`).
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tracer">
|
||||
<name>Task 1: `summer new demo --framework <path>` writes an app whose starter route builds and lists</name>
|
||||
<reversibility rating="costly">D-02 name rule (plus the D-22 denylist) and the `sm-<name>-app` directory name are a public CLI contract; loosening the denylist later is non-breaking, changing the regex or directory shape is not.</reversibility>
|
||||
<files>internal/build/new.go, internal/build/stubs/new.tmpl, internal/build/stubs/plugin.tmpl, internal/build/scaffold.go, cmd/summer/main.go, cmd/summer/main_test.go, internal/build/new_test.go</files>
|
||||
<read_first>internal/build/scaffold.go (MakePlugin, AddPlugin, editAppModule lines 318-334, ensureGoWork, writeGoWork, runGoWorkUse, parseGoMod, underRoot, findGoWork, applyGoWork, renderStub), internal/build/build.go (generate, writeIfChanged), internal/build/manifest.go (SaveManifest, LoadManifest, validateBinary), internal/build/stubs/plugin.tmpl (routes.go and go.mod defines), internal/build/build_test.go (copyHelloApp, TestMakeAndAddPluginOnHelloCopy, assertScaffoldFiles), cmd/summer/main.go (makePluginCommand, addPluginCommand, flagTrue), cmd/summer/main_test.go (TestToolCommandNames helpWants), modules/pact/capabilities.go (Router.Get), .planning/phases/14.3-one-command-app-scaffolding-summer-new-and-summercms-io-new/14.3-RESEARCH.md (Q1, Q3, Q6, Pattern 1-4, Pitfalls 1-3)</read_first>
|
||||
<action>Wire the single happy path end to end, production quality, per D-02, D-03, D-05, D-07, D-22, D-25 and D-27.
|
||||
|
||||
Shared code paths (D-05, D-27): add `StarterRoute bool` to `stubData`; move the body of `MakePlugin` into an unexported `makePlugin(ctx, startDir, id string, opts pluginOptions)` with `pluginOptions{StarterRoute bool}`, and make `MakePlugin` call it with zero options so `summer make:plugin` output stays byte-identical. In the existing `routes.go` define of plugin.tmpl add an `{{if .StarterRoute}}` branch that imports `encoding/json` and `net/http` and registers `r.Get("/{$}", ...)` writing `Content-Type: application/json` and the JSON object with keys `message` = `Hello from SummerCMS` and `plugin` = `p.ID()`; the `{{else}}` branch is the current body unchanged. Do not add a second plugin template.
|
||||
|
||||
Relative go.work (D-27): extract the "./"-prefix logic of `editAppModule` into one helper that turns an absolute target into a slash-separated path relative to a base directory, prefixed with `./` unless it is `.`, `..` or starts with `../`. Use it in `editAppModule` (same behaviour as today), and in `runGoWorkUse`: run `go work use <relative path>` with `cmd.Dir` set to the directory that holds the go.work file, the path computed relative to that directory. This changes `summer plugin:add` output from absolute to relative paths.
|
||||
|
||||
Generator (`internal/build/new.go`): exported `NewOptions` (this task uses `Name`, `Parent`, `Framework`), `ValidateAppName(name string) error` and `NewApp(ctx context.Context, opts NewOptions, out io.Writer) (string, error)`. `ValidateAppName` enforces `^[a-z][a-z0-9]*$` and additionally rejects `go/token.IsKeyword(name)`, `main` and `internal`, each with an error that names the rule and says why (D-22). `NewApp`: validate the name first; make `Parent` absolute with `filepath.Abs` (lexical, never EvalSymlinks); target is `Parent/sm-<name>-app`; `os.Lstat` the target and refuse when anything exists there (D-02, no merge); resolve `Framework` relative to `Parent` when not absolute, read its go.mod with `parseGoMod` and require module `frameworkModule`, else error; create the target with `os.Mkdir`. Then write, in this order: go.mod rendered from `new/go.mod` (module `git.golem15.com/golem15/sm-<name>-app`, `goVersionLine`, `toolchainLine`, require framework `v0.0.0`, replace to the relative framework path from the helper, D-03); go.work through `writeGoWork` BEFORE the plugin step so an ancestor go.work is never used (research Pattern 2); summer.yaml through `SaveManifest` with module, binary `<name>` and no plugins; `config/app.yaml` (name, debug false, empty key with a comment naming the env override), `config/database.yaml` (empty dsn with a comment naming the env override), `config/http.yaml` (body_limits default_bytes 1048576, upload_bytes 10485760 as YAML numbers with a comment that env values are strings), `config/storage.yaml` (uploads bucket_url `file://./storage/app/uploads`, public_path_prefix `/storage/uploads`) (D-07). Render these through a pure `planNewApp` step that returns a slice of `genFile{Rel, Body, Mode}` and reads nothing but the framework go.mod, so plan 04 can golden-test it. Then `makePlugin(ctx, target, "golem15."+name, pluginOptions{StarterRoute: true})`, `AddPlugin(ctx, target, "plugins/"+name)`, `LoadManifest`, and `generate(target, m)` so main.go and plugins.gen.go exist. Never run `go mod tidy` at the app root (D-25: no root go.sum). Templates live in the new `internal/build/stubs/new.tmpl` (picked up by the existing `stubs/*.tmpl` embed) with define names prefixed `new/` so they cannot collide with plugin defines.
|
||||
|
||||
CLI: add `newCommand()` to `toolCommands()` in cmd/summer/main.go: name `new`, description `Create a SummerCMS application in sm-<name>-app`, required arg `name` (description states the letters/digits rule and the Go keyword, main and internal exclusions), flag `framework` (path to the framework checkout). Run uses `os.Getwd()` as `Parent`, prints the created directory. Add `new` to the required names in `TestToolCommandNames` and a helpWants entry `"new": {"--framework"}`.
|
||||
|
||||
Smoke test `TestNewAppSmoke` in internal/build/new_test.go (skip under `testing.Short()`, D-21): temp parent with a `summercms` symlink to the repository root (abs of `../..`); `NewApp` with `Framework` = that symlink path; assert go.mod contains `replace git.golem15.com/golem15/summercms => ../summercms`, go.work contains `./plugins/demo` and no line whose first non-blank character is `/`, `App(ctx, dir, &buf)` succeeds, and `bin/demo route:list` run with `cmd.Dir` = app dir prints `/{$}` and `golem15.demo`.</action>
|
||||
<verify>
|
||||
<automated>go vet ./internal/build/ ./cmd/summer/ && go test ./internal/build/ -run '^(TestNewAppSmoke|TestMakeAndAddPluginOnHelloCopy|TestScaffoldPluginSmoke|TestAddPluginRejectsConflictingID)$' -count=1 -v && go test ./cmd/summer/ -run '^TestToolCommandNames$' -count=1 -v</automated>
|
||||
<fails_when>Non-zero exit; any "--- FAIL"; "no tests to run"; or the output lacks "--- PASS: TestNewAppSmoke", "--- PASS: TestMakeAndAddPluginOnHelloCopy" or "--- PASS: TestToolCommandNames".</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -c 'StarterRoute' internal/build/stubs/plugin.tmpl` is at least 1 and plugin.tmpl still has exactly one `{{define "routes.go"}}`.
|
||||
- `grep -c '{{define "new/' internal/build/stubs/new.tmpl` is at least 5 (go.mod and four config files).
|
||||
- `grep -n 'func NewApp(\|func ValidateAppName(\|type NewOptions struct\|func planNewApp(' internal/build/new.go` prints four lines.
|
||||
- `go run ./cmd/summer new --help` output contains `--framework` and `name`.
|
||||
- TestNewAppSmoke passes: relative replace `../summercms`, relative go.work, build succeeds, route:list shows `GET /{$}` for `golem15.demo`.
|
||||
- `TestMakeAndAddPluginOnHelloCopy` still passes (make:plugin output unchanged, plugin:add relative).
|
||||
- `go vet ./...` and `go test ./... -short` pass at the commit.
|
||||
</acceptance_criteria>
|
||||
<done>`summer new demo --framework <path>` writes an app with a relative replace, a relative go.work, config/ and a starter plugin whose `GET /{$}` route compiles and is listed by route:list; committed as one feat commit in summercms.go.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Framework auto-detection, refusals, cleanup, --no-plugin, and the examples/hello config gap</name>
|
||||
<files>internal/build/new.go, cmd/summer/main.go, examples/hello/config/http.yaml, README.md, docs/setup/installation.md</files>
|
||||
<read_first>internal/build/new.go (from Task 1), internal/build/scaffold.go (parseGoMod, underRoot), cmd/summer/main.go (newCommand, flagTrue), examples/hello/hello_test.go (TestTypedItemRoute), examples/hello/config/app.yaml, README.md (Quick start step 2 and Known issues, lines 19-68), docs/setup/installation.md ("Configure the application" and the WARNING callout, lines 89-126), .planning/phases/14.3-one-command-app-scaffolding-summer-new-and-summercms-io-new/14.3-RESEARCH.md (Q2, Q3 examples/hello gap, Q6 binary source, Pitfalls 2 and 4)</read_first>
|
||||
<action>Complete the D-04 resolution order, the D-06 flag and the D-07 hello fix.
|
||||
|
||||
Framework resolution (D-04): in new.go add `resolveFramework(parent, flag string) (string, error)`. Order: (1) `--framework` when set: resolved against `parent`, must have a go.mod declaring `frameworkModule`, otherwise error naming the path and the module it found (never fall through); (2) `parent/summercms`, then `parent/summercms.go`, each accepted only when its go.mod declares `frameworkModule` (detection by module path, not folder name; a non-matching candidate is skipped); (3) the running binary's source tree via `binarySourceFramework()`, which uses `runtime.Caller(0)`, ignores a non-absolute file path (a `-trimpath` build), climbs from `internal/build/new.go` to the repository root and accepts it only when its go.mod declares `frameworkModule`; call it through an unexported package variable `frameworkFromBinary` so tests can replace it; (4) otherwise an error that names `--framework` and lists the sibling names it looked for. Keep every candidate path lexical (absolute, not symlink-resolved) so the relative replace for a symlinked sibling is `../summercms`.
|
||||
|
||||
Refusals and cleanup: refuse when the target directory would be inside the resolved framework checkout (use `underRoot`), with a message telling the user to run `summer new` next to the checkout. Refuse when `Parent` is not an existing directory. After `os.Mkdir` of the target succeeds, any later error removes the target with `os.RemoveAll` (it did not exist before this run, D-02) and returns the original error with context; nothing else is ever removed.
|
||||
|
||||
`--no-plugin` (D-06; research Q2 verified an empty plugin list builds and serves): add `NoPlugin` to `NewOptions` and the bare flag `no-plugin` to `newCommand` (read with `flagTrue`); when set skip `makePlugin` and `AddPlugin` and still call `generate`, so summer.yaml lists no plugins and go.work only `use .`.
|
||||
|
||||
examples/hello (D-07, D-27): add `examples/hello/config/http.yaml` with the same `body_limits` block the generator writes (default_bytes 1048576, upload_bytes 10485760). In README.md delete Quick start step 2 (the `config/http.yaml` instruction) and the first Known issues bullet about the missing `http.body_limits`; in docs/setup/installation.md delete the paragraph and yaml fence that tell the reader to create `config/http.yaml` and the matching first bullet of the WARNING callout. Leave the remaining text (the main.go bullet, the one-liner rewrite) to plan 03, which rewrites both pages.</action>
|
||||
<verify>
|
||||
<automated>go vet ./internal/build/ ./cmd/summer/ && go test ./internal/build/ ./cmd/summer/ -count=1 && go -C examples/hello test ./... -count=1 && go run ./cmd/summer docs:build --check && bash -c 'set -e; d=$(mktemp -d); go build -trimpath -o "$d/summer" ./cmd/summer; cd "$d"; for n in go main internal func Demo 1x a-b; do if ./summer new "$n" >out 2>&1; then echo "accepted $n"; exit 1; fi; done; mkdir sm-taken-app; if ./summer new taken >out 2>&1; then exit 1; fi; if ./summer new nofw >out 2>&1; then exit 1; fi; grep -q -- "--framework" out; test ! -e sm-nofw-app; ln -s "$OLDPWD" summercms; ./summer new bare --no-plugin; grep -q "replace git.golem15.com/golem15/summercms => ../summercms" sm-bare-app/go.mod; (cd sm-bare-app && ../summer build); echo RULES-OK'</automated>
|
||||
<fails_when>Non-zero exit; any "FAIL" line; "accepted" printed for a rejected name; `docs:build --check` reports problems; or the output lacks the final "RULES-OK" line.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -n 'func resolveFramework(\|frameworkFromBinary\|func binarySourceFramework(' internal/build/new.go` prints at least three lines.
|
||||
- A `-trimpath` summer with no sibling checkout fails `summer new nofw` with `--framework` in the error and leaves no `sm-nofw-app`.
|
||||
- `summer new` refuses `go`, `main`, `internal`, `func`, `Demo`, `1x`, `a-b` and an existing `sm-taken-app`.
|
||||
- With a `summercms` symlink sibling and no flag, `summer new bare --no-plugin` writes `replace ... => ../summercms` and `summer build` succeeds.
|
||||
- `examples/hello/config/http.yaml` exists with `default_bytes: 1048576`; `go -C examples/hello test ./...` passes (TestTypedItemRoute green).
|
||||
- README.md and docs/setup/installation.md no longer instruct creating `config/http.yaml`; `docs:build --check` passes.
|
||||
</acceptance_criteria>
|
||||
<done>Framework resolution follows D-04 with verified module paths, refusals and cleanup protect the user's files, `--no-plugin` builds, and examples/hello serves without a manual config step; committed as separate commits for the generator change and the hello fix.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: .env with a fresh key, compose, .gitignore, CLAUDE.md/AGENTS.md, first commit, next steps, and the D-12 docker gate</name>
|
||||
<precondition>The Docker daemon is reachable (`docker info` exits 0), the postgres:16 image can be pulled, and host ports 54320 and 8080 are free.</precondition>
|
||||
<files>modules/lagoon/keygen.go, modules/lagoon/README.md, internal/build/new.go, internal/build/new_test.go, internal/build/stubs/new.tmpl, cmd/summer/main.go, scripts/check-phase14.3.sh</files>
|
||||
<read_first>modules/lagoon/keygen.go, modules/lagoon/keygen_test.go, modules/lagoon/README.md (API reference rows near line 147-150 and the app.key config row), modules/lagoon/encrypted.go (encryptedKeySize, decodeAppKey), modules/compass/env.go (.env parsing, quotes, export prefix), internal/build/new.go, internal/build/stubs/new.tmpl, cmd/summer/main.go, scripts/check-phase14.2.1.sh (gate script conventions: set -euo pipefail, env overrides, final pass line), ../sm-summercmsio-app/.gitignore, ../sm-summercmsio-app/config/app.yaml, ../sm-summercmsio-app/CLAUDE.md (tone reference only; do not copy app-specific text), .planning/phases/14.3-one-command-app-scaffolding-summer-new-and-summercms-io-new/14.3-RESEARCH.md (Q4, Q5, compose.yaml shape, Pitfalls 5, 10, 11)</read_first>
|
||||
<action>Make the generated app run with no edits (D-08 to D-12, D-23 to D-25).
|
||||
|
||||
Key (D-08): export `GenerateAppKey() (string, error)` in modules/lagoon/keygen.go returning `base64.StdEncoding` of `encryptedKeySize` bytes from `crypto/rand`; `KeyGenerateCommand` calls it and still prints through `out.Success` (output unchanged). Add the `lagoon.GenerateAppKey` row to the API reference table of modules/lagoon/README.md in the same commit (project documentation rule). `internal/build` stays free of a lagoon import: add `AppKey` to `NewOptions`; `NewApp` errors when it is empty or does not base64-decode to 32 bytes; `newCommand` calls `lagoon.GenerateAppKey()` and passes the result. Update `TestNewAppSmoke` to pass a fixed valid 44-character test key and `NoGit: true` so it stays hermetic.
|
||||
|
||||
Files, all rendered by `planNewApp` from new.tmpl defines (golden-testable with a fixed key):
|
||||
- `.env` (mode 0600): `SUMMER_DATABASE__DSN=postgres://summer:summer@127.0.0.1:54320/<name>?sslmode=disable` and `SUMMER_APP__KEY=<key>`, unquoted, no `$` characters, with a header comment that the file holds secrets and is gitignored.
|
||||
- `.env.example` (0644): the same DSN, `SUMMER_APP__KEY=` empty with a comment on generating one with `./bin/<name> key:generate` (paste only the key), a commented `SUMMER_STORAGE__UPLOADS__BUCKET_URL=file://./storage/app/uploads`, and a commented `SUMMER_ENV`.
|
||||
- `compose.yaml` (D-09, D-23): one service `postgres` with image `postgres:16`, environment POSTGRES_USER `summer`, POSTGRES_PASSWORD `summer`, POSTGRES_DB `<name>`, ports `127.0.0.1:54320:5432` only, a named volume `pgdata` (no bind mounts), and a `pg_isready -U summer -d <name>` healthcheck (interval 2s, timeout 3s, retries 30) so `docker compose up -d --wait` returns once the database accepts connections.
|
||||
- `.gitignore` (D-08, D-25): `/bin/`, `/tmp/`, `/storage/`, `.env`, `go.work.sum`, `*.exe`.
|
||||
- `CLAUDE.md` (D-10, D-01): states this is a SummerCMS application built from `git.golem15.com/golem15/summercms`, replaced by the local checkout at the relative replace path, which agents must not edit from here; links https://summercms.io/docs/llms.txt and https://summercms.io/docs/llms-full.txt; lists `summer make:plugin`, `summer plugin:add`, `summer build`, `summer dev`, `summer migrate`, `summer serve` and `docker compose up -d --wait`; explains summer.yaml, plugins/ (starter plugin golem15.<name> only when present), config/, `.env` (secrets, never commit), compose.yaml, and that main.go and plugins.gen.go are generated by `summer build`; states plugins are compiled in at build time; states the Golem15 naming convention: `sm-<name>-app` application, `sm-<name>-plugin` standalone plugin module, `sm-<name>-theme` Nuxt/Vue frontend. Use only neutral wording (no consuming application names).
|
||||
- `AGENTS.md`: a relative symlink to `CLAUDE.md` (planner's call under D-10: one source, survives git clone on Linux and macOS); represent it in `genFile` as a link target.
|
||||
|
||||
Git (D-11): add `NoGit` to `NewOptions` and the bare flag `no-git` to `newCommand`. Unless set: look up `git` on PATH (missing means a warning and no repository); run `git init --quiet --initial-branch=main`, `git add -A`, then `git commit --quiet -m "Create sm-<name>-app with summer new"` with stdin closed and `GIT_TERMINAL_PROMPT=0`. When `git config user.name` or `git config user.email` (run inside the new repository) is unset and the matching `GIT_AUTHOR_*`/`GIT_COMMITTER_*` variables are absent, pass `-c user.name=SummerCMS -c user.email=summer@localhost` to that commit only and print a warning saying so. Any git failure (for example signing with an unavailable key) prints a warning with git's output and keeps the app. The commit message carries no trailers.
|
||||
|
||||
Next steps (D-24): after success, print from the `new/next-steps` define: the created directory, then "Next steps, inside sm-<name>-app:" followed by `docker compose up -d --wait`, `summer build`, `summer migrate`, `summer serve`, then `curl http://127.0.0.1:8080/` (with `--no-plugin`, a line pointing at `summer make:plugin` and `summer plugin:add` instead of the curl). Do not print a `cd` line (D-24 assigns it to the bootstrap script).
|
||||
|
||||
Gate (D-12): create executable `scripts/check-phase14.3.sh` (bash, `set -euo pipefail`, `PHASE143_*` overrides, root from the script location) with stage `--generator`: preflight `docker info` and that 127.0.0.1:54320 and :8080 are free (fail with a clear message otherwise); build the CLI with `go build -o "$tmp/bin/summer" ./cmd/summer` and put `$tmp/bin` first on PATH; in a temp parent create a `summercms` symlink to the repo root; run `summer new demo` WITHOUT `--framework` and with HOME set to an empty temp directory and `GIT_CONFIG_NOSYSTEM=1` for that one command (export GOMODCACHE, GOCACHE and GOPATH from `go env` first so the module cache is reused) to exercise sibling detection and the fallback identity; assert `.env` mode is 600, `git -C sm-demo-app rev-list --count HEAD` is 1, `git -C sm-demo-app ls-files .env` prints nothing, `.env.example`, `go.work`, `main.go`, `plugins.gen.go` are tracked, `AGENTS.md` is a symlink to `CLAUDE.md`, there is no root `go.sum`, and go.work has no absolute `use` path; then in the app with a unique `COMPOSE_PROJECT_NAME` run `docker compose up -d --wait`, `summer build`, `summer migrate`, start `summer serve` in the background, poll `http://127.0.0.1:8080/` for up to 60 s and require the body to contain `"plugin":"golem15.demo"`; a trap stops the server and runs `docker compose down -v` on every exit. End with the line `Phase 14.3 generator check passed`. Commit the lagoon change, the generator change and the gate script as separate commits.</action>
|
||||
<verify>
|
||||
<automated>go vet ./... && go test ./modules/lagoon/ -run 'Key' -short -count=1 -v && go test ./internal/build/ -run '^TestNewAppSmoke$' -count=1 -v && bash scripts/check-phase14.3.sh --generator</automated>
|
||||
<fails_when>Non-zero exit; any "--- FAIL"; "--- PASS: TestNewAppSmoke" or "--- PASS: TestEncryptedKeyGeneratePrints32ByteBase64" absent; or the output lacks the final line "Phase 14.3 generator check passed".</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -n 'func GenerateAppKey() (string, error)' modules/lagoon/keygen.go` prints one line and `grep -c 'lagoon.GenerateAppKey' modules/lagoon/README.md` is at least 1.
|
||||
- `grep -c '127.0.0.1:54320:5432' internal/build/stubs/new.tmpl` is 1 and `grep -c 'postgres:16' internal/build/stubs/new.tmpl` is at least 1.
|
||||
- `grep -c 'https://summercms.io/docs/llms-full.txt' internal/build/stubs/new.tmpl` is at least 1 and `grep -c 'sm-<name>-theme\|-theme' internal/build/stubs/new.tmpl` is at least 1.
|
||||
- `grep -Eci 'fonoteka|p[lł]ytarium' internal/build/stubs/new.tmpl` prints 0.
|
||||
- The `--generator` stage passes: sibling detection without `--framework`, fallback identity commit, `.env` 0600 and untracked, AGENTS.md symlink, no root go.sum, relative go.work, and the starter route answers 200 JSON after `docker compose up -d --wait`, `summer build`, `summer migrate`, `summer serve`.
|
||||
- `go run ./cmd/summer new --help` lists `--framework`, `--no-plugin` and `--no-git`.
|
||||
- `go vet ./...` and `go test ./... -short` pass; `git diff v0.1.12 -- go.mod` adds no require line.
|
||||
</acceptance_criteria>
|
||||
<done>A generated app holds a fresh key, a loopback-only PostgreSQL 16 compose file, a secret-free first commit and agent instructions, prints its next steps, and the gate proves `summer build`, `summer migrate` and `summer serve` work with no edits.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| CLI argument and flag -> filesystem | `<name>` and `--framework` become paths and module names |
|
||||
| Generator -> git history | Generated files enter the first commit, which may be pushed |
|
||||
| Generated compose.yaml -> host network | Docker publishes the dev database on the host |
|
||||
| crypto/rand -> .env | The application encryption key is created and stored |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-14.3-01 | Tampering | `ValidateAppName`, target path | high | mitigate | Regex `^[a-z][a-z0-9]*$` plus keyword/main/internal denylist before any path is built; target is always `Parent/sm-<name>-app` |
|
||||
| T-14.3-02 | Information Disclosure | `.env`, first git commit | high | mitigate | `.gitignore` written before `git add -A`; `.env` mode 0600; `.env.example` holds no key; gate asserts `git ls-files .env` is empty |
|
||||
| T-14.3-03 | Information Disclosure | app key generation | high | mitigate | Single implementation `lagoon.GenerateAppKey` on `crypto/rand`; NewApp rejects keys that do not decode to 32 bytes |
|
||||
| T-14.3-04 | Tampering | existing directory, cleanup | high | mitigate | `os.Lstat` refusal of any existing target; `os.Mkdir` (not MkdirAll); `os.RemoveAll` only on the directory this run created |
|
||||
| T-14.3-05 | Information Disclosure | compose.yaml Postgres port | medium | mitigate | Publish only `127.0.0.1:54320`; named volume, no bind mounts |
|
||||
| T-14.3-06 | Spoofing | framework auto-detection | medium | mitigate | Candidates accepted only when go.mod declares `git.golem15.com/golem15/summercms`; explicit `--framework` wins and never falls through |
|
||||
| T-14.3-07 | Information Disclosure | committed go.mod/go.work | low | mitigate | Relative replace and relative `go work use` paths; no absolute home paths in history |
|
||||
| T-14.3-08 | Information Disclosure | compose.yaml dev credentials | low | accept | `summer`/`summer` is a documented local default for a loopback-only dev database, not a production credential; the encryption key stays in `.env` |
|
||||
| T-14.3-09 | Information Disclosure | `summer serve` default `:8080` | low | accept | Existing surf default listens on all interfaces; out of scope for this phase; next steps point at 127.0.0.1 |
|
||||
| T-14.3-10 | Repudiation | fallback git identity | low | accept | Used only when no identity exists, for the first commit only, and announced with a warning |
|
||||
| T-14.3-SC | Tampering | npm/pip/cargo installs | high | mitigate | No package installs in this plan; `git diff v0.1.12 -- go.mod` adds no require line |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
Run each task's automated command; then `go vet ./... && go test ./... -short && go -C examples/hello test ./... -count=1`. `bash scripts/check-phase14.3.sh --generator` must end with `Phase 14.3 generator check passed`.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- Roadmap success criterion 1: `summer new <name>` validates, refuses existing directories, resolves the framework and writes the full layout with a first commit.
|
||||
- Roadmap success criterion 2: the generated app builds, migrates and serves with no edits; the starter route answers over HTTP.
|
||||
- `summer make:plugin` output unchanged; `summer plugin:add` writes relative go.work paths; examples/hello tests pass.
|
||||
- No new dependency; lagoon README documents `lagoon.GenerateAppKey`.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/14.3-one-command-app-scaffolding-summer-new-and-summercms-io-new/14.3-01-SUMMARY.md` when done.
|
||||
</output>
|
||||
</content>
|
||||
</invoke>
|
||||
@@ -0,0 +1,215 @@
|
||||
---
|
||||
phase: 14.3-one-command-app-scaffolding-summer-new-and-summercms-io-new
|
||||
plan: 01
|
||||
subsystem: cli
|
||||
tags: [summer-new, scaffolding, internal-build, go-work, lagoon, compose, postgres16]
|
||||
|
||||
requires:
|
||||
- phase: 01-framework-kernel-foundation
|
||||
provides: make:plugin, plugin:add, summer build (internal/build), bonfire commands
|
||||
- phase: 05-data-layer-full-fidelity
|
||||
provides: lagoon app.key handling and key:generate
|
||||
provides:
|
||||
- "summer new <name> [--framework] [--no-plugin] [--no-git] writing a ready-to-run sm-<name>-app"
|
||||
- "build.NewOptions, build.NewApp, build.ValidateAppName; unexported planNewApp/genFile for golden tests"
|
||||
- "make:plugin opt-in starter route (StarterRoute) in the single routes.go template"
|
||||
- "plugin:add writes relative go.work use paths"
|
||||
- "lagoon.GenerateAppKey"
|
||||
- "examples/hello/config/http.yaml (TestTypedItemRoute green)"
|
||||
- "scripts/check-phase14.3.sh --generator (D-12 on real PostgreSQL 16)"
|
||||
affects: [14.3-02 bootstrap script, 14.3-03 docs, 14.3-04 unit tests]
|
||||
|
||||
actuals:
|
||||
tokens: 11696
|
||||
tasks: 3
|
||||
commits: 6
|
||||
plan_head_before: 86fcd5d4fd2b8347629592a254029573cb67c7d3
|
||||
plan_head_after: 6695ab422aa0155f49c7ca87bdcb5fbf405c9e35
|
||||
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns:
|
||||
- "Pure planNewApp (renders genFile list, reads only the framework go.mod) plus an executor that writes, then reuses makePlugin/AddPlugin/generate"
|
||||
- "relModulePath helper shared by editAppModule, runGoWorkUse and the app replace"
|
||||
- "frameworkFromBinary package variable so tests can replace binary-source detection"
|
||||
|
||||
key-files:
|
||||
created:
|
||||
- internal/build/new.go
|
||||
- internal/build/new_test.go
|
||||
- internal/build/stubs/new.tmpl
|
||||
- examples/hello/config/http.yaml
|
||||
- scripts/check-phase14.3.sh
|
||||
modified:
|
||||
- internal/build/scaffold.go
|
||||
- internal/build/stubs/plugin.tmpl
|
||||
- cmd/summer/main.go
|
||||
- cmd/summer/main_test.go
|
||||
- modules/bonfire/command.go
|
||||
- modules/bonfire/command_test.go
|
||||
- modules/bonfire/README.md
|
||||
- modules/lagoon/keygen.go
|
||||
- modules/lagoon/keygen_test.go
|
||||
- modules/lagoon/README.md
|
||||
- README.md
|
||||
- docs/setup/installation.md
|
||||
|
||||
key-decisions:
|
||||
- "bonfire accepts `new` as a bare command name (alongside build, dev, serve, migrate); required for `summer new`"
|
||||
- "compose.yaml values (postgres:16, summer/summer, 127.0.0.1:54320) are literal in new.tmpl; Go constants build the matching .env DSN"
|
||||
- "Fallback git identity is applied per missing key only (user.name and/or user.email), with a warning"
|
||||
- "AGENTS.md is a relative symlink to CLAUDE.md"
|
||||
- "Generated-app commit carries no trailers; .env is excluded by .gitignore written before git add -A"
|
||||
|
||||
patterns-established:
|
||||
- "New app templates live in stubs/new.tmpl under define names prefixed new/"
|
||||
- "Any error after os.Mkdir of the target removes the target (it did not exist before the run)"
|
||||
|
||||
requirements-completed: [D-01, D-02, D-03, D-04, D-05, D-06, D-07, D-08, D-09, D-10, D-11, D-12, D-22, D-23, D-24, D-25, D-27]
|
||||
|
||||
coverage:
|
||||
- id: D1
|
||||
description: "summer new writes sm-<name>-app with a relative framework replace, relative go.work, config/ and a starter plugin whose GET /{$} route is listed by route:list"
|
||||
requirement: D-05
|
||||
verification:
|
||||
- kind: integration
|
||||
ref: "internal/build/new_test.go#TestNewAppSmoke"
|
||||
status: pass
|
||||
- kind: integration
|
||||
ref: "internal/build/build_test.go#TestMakeAndAddPluginOnHelloCopy"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D2
|
||||
description: "Name rule, existing-target refusal, missing-framework error naming --framework, sibling detection and --no-plugin build"
|
||||
requirement: D-02
|
||||
verification:
|
||||
- kind: other
|
||||
ref: "Task 2 verify shell block (-trimpath summer; RULES-OK)"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D3
|
||||
description: "Generated app builds, migrates on compose PostgreSQL 16 and serves the starter route with no edits; .env 0600 untracked, one fallback-identity commit, AGENTS.md symlink, no root go.sum"
|
||||
requirement: D-12
|
||||
verification:
|
||||
- kind: e2e
|
||||
ref: "bash scripts/check-phase14.3.sh --generator"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D4
|
||||
description: "lagoon.GenerateAppKey exported; key:generate output unchanged"
|
||||
requirement: D-08
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "modules/lagoon/keygen_test.go#TestEncryptedKeyGeneratePrints32ByteBase64"
|
||||
status: pass
|
||||
- kind: unit
|
||||
ref: "modules/lagoon/keygen_test.go#TestGenerateAppKeyIs32ByteBase64"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D5
|
||||
description: "examples/hello serves without a manual config step"
|
||||
requirement: D-07
|
||||
verification:
|
||||
- kind: integration
|
||||
ref: "go -C examples/hello test ./..."
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D6
|
||||
description: "Generated CLAUDE.md wording is neutral and useful for agents"
|
||||
requirement: D-10
|
||||
verification: []
|
||||
human_judgment: true
|
||||
rationale: "Tone and usefulness of agent instructions need a human read; greps only prove the required links and names are present"
|
||||
|
||||
duration: 13min
|
||||
completed: 2026-10-08
|
||||
status: complete
|
||||
---
|
||||
|
||||
# Phase 14.3 Plan 01: summer new generator Summary
|
||||
|
||||
**`summer new <name>` writes a ready-to-run sm-<name>-app (relative framework replace and go.work, config/, starter plugin with GET /, .env with a fresh key, postgres:16 compose on 127.0.0.1:54320, CLAUDE.md/AGENTS.md, first git commit) that builds, migrates and serves with no edits.**
|
||||
|
||||
## Performance
|
||||
|
||||
- **Duration:** 13 min
|
||||
- **Started:** 2026-10-08T09:32:14Z
|
||||
- **Completed:** 2026-10-08T09:44:54Z
|
||||
- **Tasks:** 3
|
||||
- **Files modified:** 17
|
||||
|
||||
## Accomplishments
|
||||
|
||||
- `build.NewApp` plus `summer new` with `--framework`, `--no-plugin`, `--no-git`; framework resolution `--framework` > sibling `summercms`/`summercms.go` > binary source tree, each checked by module path.
|
||||
- Shared code-path fixes: `make:plugin` gains an opt-in starter route in the same template (plain output byte-identical, verified against the previous template); `plugin:add` writes relative `go.work` paths.
|
||||
- Refusals: invalid names (regex, Go keywords, `main`, `internal`), any existing target, a target inside the framework checkout, a missing parent; a failed run removes its own target.
|
||||
- `lagoon.GenerateAppKey` exported and documented; `examples/hello` ships `config/http.yaml`.
|
||||
- `scripts/check-phase14.3.sh --generator` proves D-12 on real PostgreSQL 16: `GET /` returned `{"message":"Hello from SummerCMS","plugin":"golem15.demo"}`.
|
||||
|
||||
## Task Commits
|
||||
|
||||
1. **Task 1: summer new tracer** - `7bc955a` (feat)
|
||||
2. **Task 2: framework detection, refusals, cleanup, --no-plugin** - `c33ebf3` (feat), hello fix `cc7386d` (fix)
|
||||
3. **Task 3: key, compose, agent notes, git, next steps, gate** - `ca5b296` (feat, lagoon), `147ef24` (feat, generator), `6695ab4` (chore, gate)
|
||||
|
||||
## Files Created/Modified
|
||||
|
||||
- `internal/build/new.go` - NewOptions, NewApp, ValidateAppName, resolveFramework, binarySourceFramework, planNewApp, populateApp, initGit
|
||||
- `internal/build/stubs/new.tmpl` - go.mod, config/*, .env, .env.example, compose.yaml, .gitignore, CLAUDE.md, next steps
|
||||
- `internal/build/stubs/plugin.tmpl` - routes.go `{{if .StarterRoute}}` branch
|
||||
- `internal/build/scaffold.go` - makePlugin(pluginOptions), relModulePath, relative runGoWorkUse
|
||||
- `internal/build/new_test.go` - TestNewAppSmoke
|
||||
- `cmd/summer/main.go`, `cmd/summer/main_test.go` - `new` command and its test entries
|
||||
- `modules/bonfire/command.go`, `command_test.go`, `README.md` - `new` allowed as a bare name
|
||||
- `modules/lagoon/keygen.go`, `keygen_test.go`, `README.md` - GenerateAppKey
|
||||
- `examples/hello/config/http.yaml`, `README.md`, `docs/setup/installation.md` - body_limits gap closed
|
||||
- `scripts/check-phase14.3.sh` - generator gate
|
||||
|
||||
## Decisions Made
|
||||
|
||||
- `new` is added to bonfire's bare-name allowlist (see deviation 1).
|
||||
- Fallback git identity covers only the missing keys, so a user with `user.name` set but no email keeps their name.
|
||||
- Compose values are literal in the template so the loopback-only binding is greppable; the DSN is assembled from matching Go constants.
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
### Auto-fixed Issues
|
||||
|
||||
**1. [Rule 3 - Blocking] bonfire rejected the bare command name `new`**
|
||||
- **Found during:** Task 1
|
||||
- **Issue:** `bonfire.NewRoot` only accepts `build`, `dev`, `serve`, `migrate` as bare names; `TestToolCommandNames` failed with `plugin command name must be namespace:verb: "new"`.
|
||||
- **Fix:** Added `new` to `validCommandName`, to `TestValidBareRuntimeCommandNames`, and to the bonfire README sentence listing bare names (project documentation rule).
|
||||
- **Files modified:** modules/bonfire/command.go, modules/bonfire/command_test.go, modules/bonfire/README.md
|
||||
- **Verification:** `go test ./modules/bonfire/ ./cmd/summer/` pass
|
||||
- **Committed in:** 7bc955a
|
||||
|
||||
**2. [Rule 1 - Bug] Gate script EXIT trap referenced function locals**
|
||||
- **Found during:** Task 3
|
||||
- **Issue:** The cleanup trap runs after `stage_generator` returns, when its `local` variables are gone, so Docker cleanup would have been skipped.
|
||||
- **Fix:** Made `tmp`, `app`, `project`, `server_pid` globals.
|
||||
- **Verification:** After the gate run no `phase143` containers or volumes remain and the temp dir is removed.
|
||||
- **Committed in:** 6695ab4
|
||||
|
||||
---
|
||||
|
||||
**Total deviations:** 2 auto-fixed (1 blocking, 1 bug). **Impact:** Both necessary; the bonfire change only widens the accepted name set (non-breaking).
|
||||
|
||||
## Issues Encountered
|
||||
|
||||
- `internal/build/registry.go` is listed by `gofmt -l` before this plan; untouched (out of scope).
|
||||
|
||||
## User Setup Required
|
||||
|
||||
None - no external service configuration required.
|
||||
|
||||
## Next Phase Readiness
|
||||
|
||||
- Plan 02 can call `summer new <name> --framework <fw>` from the bootstrap script; next steps are printed by `summer new` itself (no `cd` line).
|
||||
- Plan 03 should document `summer new` (console docs, installation one-liner, go-modules page, relative `plugin:add` go.work paths) and may drop the remaining hello `main.go` known-issue bullet.
|
||||
- Plan 04 can golden-test `planNewApp` with `testAppKey` and replace `frameworkFromBinary`.
|
||||
|
||||
---
|
||||
*Phase: 14.3-one-command-app-scaffolding-summer-new-and-summercms-io-new*
|
||||
*Completed: 2026-10-08*
|
||||
|
||||
## Self-Check: PASSED
|
||||
@@ -0,0 +1,258 @@
|
||||
---
|
||||
phase: 14.3-one-command-app-scaffolding-summer-new-and-summercms-io-new
|
||||
plan: 02
|
||||
type: execute
|
||||
wave: 2
|
||||
depends_on: ["14.3-01"]
|
||||
files_modified:
|
||||
- modules/sunrise/new.sh
|
||||
- modules/sunrise/sunrise.go
|
||||
- modules/sunrise/sunrise_test.go
|
||||
- modules/sunrise/README.md
|
||||
- README.md
|
||||
- ../sm-summercmsio-app/plugins/golem15/summercms/script.go
|
||||
- ../sm-summercmsio-app/plugins/golem15/summercms/plugin.go
|
||||
- ../sm-summercmsio-app/plugins/golem15/summercms/routes_test.go
|
||||
- ../sm-summercmsio-app/plugins/golem15/summercms/README.md
|
||||
- scripts/check-phase14.3.sh
|
||||
- .planning/phases/14.3-one-command-app-scaffolding-summer-new-and-summercms-io-new/14.3-SECURITY-REVIEW.md
|
||||
autonomous: true
|
||||
requirements: [D-13, D-14, D-15, D-16, D-17, D-18, D-20, D-24, D-26, D-28]
|
||||
estimate:
|
||||
tokens: 140000
|
||||
raw_tokens: 140000
|
||||
tasks: 3
|
||||
confidence: low
|
||||
must_haves:
|
||||
truths:
|
||||
- "D-13/D-26: modules/sunrise embeds new.sh from its own package directory; sunrise.Script() returns exactly those bytes as a fresh copy on every call."
|
||||
- "D-13: the site plugin answers GET /new with 200, Content-Type `text/plain; charset=utf-8`, `X-Content-Type-Options: nosniff`, `Cache-Control: no-cache`, a strong ETag and a body equal to sunrise.Script(); HEAD /new is 200 with no body; If-None-Match with that ETag is 304; POST /new is 405."
|
||||
- "D-14: new.sh consists of comments, function definitions and a final line `main \"$@\"`; running any prefix of the file that ends on a line boundary, or any byte prefix of its final line, invokes no external command and creates no file."
|
||||
- "D-15 step 1: with Go missing or older than 1.27 the script exits non-zero with a hint naming https://go.dev/dl/ before it clones anything; a missing git is an error; a missing Docker or compose plugin is only a warning."
|
||||
- "D-15 step 2/D-16/D-17: with no checkout at SUMMERCMS_DIR (default ./summercms) the script clones SUMMERCMS_REPO (default https://git.golem15.com/golem15/summercms) and detaches at the newest vX.Y.Z tag, or at SUMMERCMS_REF when set."
|
||||
- "Edge (dirty clone): an existing checkout with tracked changes, on a branch, whose go.mod is not the framework module, or that is not a git repository is refused with a message and left untouched."
|
||||
- "Edge (non-fast-forward / moved tag): an existing detached checkout moves only when its HEAD is an ancestor of the target tag; tag fetches never overwrite a local tag; otherwise the script exits non-zero without changing the checkout."
|
||||
- "D-15 steps 3-4: the script installs summer with `go install ./cmd/summer` into GOBIN (or the first GOPATH entry's bin), then runs `<gobin>/summer new <name> --framework <absolute checkout path>` from the checkout's parent directory with stdin closed."
|
||||
- "D-24: the script prints `cd <absolute app dir>` and, only when the install directory is not on PATH, an `export PATH=\"<gobin>:$PATH\"` hint; the generic next steps come from summer new."
|
||||
- "Edge (invalid name / existing app): a name failing ^[a-z][a-z0-9]*$ or an existing sm-<name>-app next to the checkout stops the script before any clone or install."
|
||||
- "D-20: in a clean node:24-bookworm container with Go 1.27.0, the Docker CLI and compose plugin, git and jq, `curl -fsSL <script> | bash -s -- demo` then `cd sm-demo-app && docker compose up -d --wait && summer build && summer migrate && summer serve` ends with GET http://127.0.0.1:8080/ answering 200 JSON containing \"plugin\":\"golem15.demo\"."
|
||||
- "Security: 14.3-SECURITY-REVIEW.md lists T-14.3-01..T-14.3-19 and T-14.3-SC with a disposition; no high threat is open."
|
||||
prohibitions:
|
||||
- requirement_id: D-26
|
||||
category: safety
|
||||
status: resolved
|
||||
verification: judgment
|
||||
resolution: flagged-unverified
|
||||
reason: "compass reads the framework config prefix as config keys; descriptor-less, human review recommended."
|
||||
statement: "The bootstrap script must never read or set variables with the framework's config env prefix (SUMMER_*); overrides are SUMMERCMS_REPO, SUMMERCMS_DIR and SUMMERCMS_REF only."
|
||||
- requirement_id: D-16
|
||||
category: safety
|
||||
status: resolved
|
||||
verification: judgment
|
||||
resolution: flagged-unverified
|
||||
reason: "Refusal paths reviewed in the security review and tested in plan 04; descriptor-less, human review recommended."
|
||||
statement: "The bootstrap script must never discard, overwrite or move past local work in an existing framework checkout."
|
||||
- requirement_id: D-15
|
||||
category: safety
|
||||
status: resolved
|
||||
verification: judgment
|
||||
resolution: flagged-unverified
|
||||
reason: "Script is piped into a shell from the internet; descriptor-less, human review recommended."
|
||||
statement: "The bootstrap script must never escalate privileges or write outside SUMMERCMS_DIR, the new sm-<name>-app directory and the Go install directory."
|
||||
- requirement_id: D-28
|
||||
category: safety
|
||||
status: resolved
|
||||
verification: judgment
|
||||
resolution: flagged-unverified
|
||||
reason: "Release is a user action; descriptor-less, human review recommended."
|
||||
statement: "No task pushes a tag, pushes a branch or deploys the site."
|
||||
artifacts:
|
||||
- path: "modules/sunrise/new.sh"
|
||||
provides: "the curl | bash bootstrap script, single source of truth"
|
||||
contains: "main \"$@\""
|
||||
- path: "modules/sunrise/sunrise.go"
|
||||
provides: "embed and sunrise.Script()"
|
||||
contains: "//go:embed new.sh"
|
||||
- path: "modules/sunrise/README.md"
|
||||
provides: "standard module README"
|
||||
contains: "sunrise.Script"
|
||||
- path: "../sm-summercmsio-app/plugins/golem15/summercms/script.go"
|
||||
provides: "GET /new handler serving the embedded script"
|
||||
contains: "sunrise.Script"
|
||||
- path: "scripts/check-phase14.3.sh"
|
||||
provides: "D-20 container acceptance stages"
|
||||
contains: "--acceptance"
|
||||
- path: ".planning/phases/14.3-one-command-app-scaffolding-summer-new-and-summercms-io-new/14.3-SECURITY-REVIEW.md"
|
||||
provides: "ASVS L1 review of plans 01-02"
|
||||
contains: "T-14.3-11"
|
||||
key_links:
|
||||
- from: "../sm-summercmsio-app/plugins/golem15/summercms/plugin.go"
|
||||
to: "modules/sunrise/sunrise.go"
|
||||
via: "Routes registers GET /new with a handler over sunrise.Script(), built against the local framework through the plugin's replace (D-28)"
|
||||
pattern: "\"/new\""
|
||||
- from: "modules/sunrise/new.sh"
|
||||
to: "cmd/summer/main.go"
|
||||
via: "the script installs ./cmd/summer and calls `summer new <name> --framework <dir>`"
|
||||
pattern: "new \"\\$name\" --framework"
|
||||
- from: "scripts/check-phase14.3.sh"
|
||||
to: "modules/sunrise/new.sh"
|
||||
via: "--acceptance pipes the committed script into bash inside node:24-bookworm"
|
||||
pattern: "node:24-bookworm"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Ship the bootstrap script `curl -fsSL https://summercms.io/new | bash -s -- <name>`: a bash `main()` script versioned in a new framework module `modules/sunrise`, served by the site plugin at `GET /new`, proven end to end in a clean `node:24-bookworm` container, and security-reviewed.
|
||||
|
||||
Purpose: roadmap success criteria 3 and 4 — one command checks prerequisites, fetches the framework at its latest release tag, installs the summer CLI and runs `summer new`.
|
||||
Output: `modules/sunrise` (script, embed, README, smoke tests), the site plugin route, the `--acceptance` gate stages, and 14.3-SECURITY-REVIEW.md.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/gsd-core/workflows/execute-plan.md
|
||||
@~/.claude/gsd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/phases/14.3-one-command-app-scaffolding-summer-new-and-summercms-io-new/14.3-CONTEXT.md
|
||||
@.planning/phases/14.3-one-command-app-scaffolding-summer-new-and-summercms-io-new/14.3-RESEARCH.md
|
||||
@.planning/phases/14.3-one-command-app-scaffolding-summer-new-and-summercms-io-new/14.3-01-SUMMARY.md
|
||||
@modules/boardwalk/boardwalk.go
|
||||
@modules/boardwalk/README.md
|
||||
@../sm-summercmsio-app/plugins/golem15/summercms/plugin.go
|
||||
@../sm-summercmsio-app/plugins/golem15/summercms/static.go
|
||||
@../sm-summercmsio-app/plugins/golem15/summercms/routes_test.go
|
||||
</context>
|
||||
|
||||
## Spec-less probe fallback
|
||||
|
||||
No REQUIREMENTS.md IDs or SPEC.md exist for this phase; requirement probing is visibly skipped. The acceptance contract is D-13..D-17, D-20, D-24, D-26, D-28, roadmap success criteria 3 and 4, and the edge predicates (truncated script, invalid name, existing app directory, dirty or branch clone, non-fast-forward, moved tag, missing Go/git/Docker, POST /new) and prohibitions authored into `must_haves` above.
|
||||
|
||||
## Artifacts this phase produces (this plan)
|
||||
|
||||
- New framework module `git.golem15.com/golem15/summercms/modules/sunrise`, package `sunrise`: file `new.sh`, func `sunrise.Script() []byte`, README with the standard structure and a root README.md modules-table row.
|
||||
- Script contract: `bash -s -- <name>`; options `-h`/`--help`; env overrides `SUMMERCMS_REPO` (default `https://git.golem15.com/golem15/summercms`), `SUMMERCMS_DIR` (default `summercms` under the current directory), `SUMMERCMS_REF` (tag or commit; skips latest-tag selection).
|
||||
- Tests `TestScriptEmbedded`, `TestScriptShape`, `TestScriptTruncationRunsNothing`, gated `TestBootstrapEndToEnd` (`SUMMERCMS_BOOTSTRAP_CHECK=1`).
|
||||
- Site plugin: `scriptHandler` in `script.go`, route `GET /new`, test `TestNewScriptRoute` and three new `TestRoutesAssemble` rows; plugin README route row.
|
||||
- Gate stages `scripts/check-phase14.3.sh --acceptance` (local script and repo at HEAD, final line `Phase 14.3 acceptance passed`) and `--acceptance-production` (https://summercms.io/new and the public remote, run by the user after deploy).
|
||||
- `14.3-SECURITY-REVIEW.md`.
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tracer">
|
||||
<name>Task 1: Script → embed → GET /new → curl | bash → summer new, wired end to end</name>
|
||||
<reversibility rating="costly">The URL `https://summercms.io/new`, the `bash -s -- <name>` argument shape, the SUMMERCMS_REPO/DIR/REF names and the exported `sunrise` package become a published contract copied into docs, the landing page and agent instructions; changing them needs coordinated doc and site updates (old copies can be kept working by redirects), so it is costly, not one-way.</reversibility>
|
||||
<files>modules/sunrise/new.sh, modules/sunrise/sunrise.go, modules/sunrise/sunrise_test.go, modules/sunrise/README.md, README.md, ../sm-summercmsio-app/plugins/golem15/summercms/script.go, ../sm-summercmsio-app/plugins/golem15/summercms/plugin.go, ../sm-summercmsio-app/plugins/golem15/summercms/routes_test.go, ../sm-summercmsio-app/plugins/golem15/summercms/README.md</files>
|
||||
<read_first>modules/boardwalk/boardwalk.go (embed of a file inside the package dir), modules/boardwalk/README.md (module README structure), README.md (Modules table, lines 81-107), cmd/summer/main.go (newCommand flags from plan 01), internal/build/new.go (ValidateAppName rule text), ../sm-summercmsio-app/plugins/golem15/summercms/plugin.go, ../sm-summercmsio-app/plugins/golem15/summercms/static.go (file struct, contentTypes, cacheNoCache, setCommonHeaders, http.ServeContent use), ../sm-summercmsio-app/plugins/golem15/summercms/routes_test.go, ../sm-summercmsio-app/plugins/golem15/summercms/README.md, ../sm-summercmsio-app/plugins/golem15/summercms/go.mod (relative framework replace), .planning/phases/14.3-one-command-app-scaffolding-summer-new-and-summercms-io-new/14.3-RESEARCH.md (Bootstrap script design Q10 table, bash skeleton, Pitfalls 6-9, 13, Q7)</read_first>
|
||||
<action>Build the production script and every layer it passes through, per D-13 to D-17, D-24 and D-26.
|
||||
|
||||
Script `modules/sunrise/new.sh` (single source of truth; mode 0755): shebang `#!/usr/bin/env bash`, a header comment with the one-liner and the override names, helper functions, `main()`, and as the very last line `main "$@"` (D-14). Nothing at top level besides comments and function definitions. Inside `main`: `set -euo pipefail`; export `GIT_TERMINAL_PROMPT=0`; parse arguments first, before any external command: exactly one name, or `-h`/`--help` printing usage (exit 0); otherwise usage on stderr and exit 2. Validate the name with the bash regex `^[a-z][a-z0-9]*$` (summer new enforces the full denylist). Resolve the checkout directory from `SUMMERCMS_DIR` (default `summercms`) to an absolute path against `$PWD`, take its parent as the app parent, and stop with exit 1 when `<parent>/sm-<name>-app` already exists. Prerequisites (D-15 step 1): require `git`; require `go` and read its version with `go env GOVERSION` from `/` (so a local go.mod cannot switch toolchains), parse `^go([0-9]+)\.([0-9]+)`, require 1.27 or newer and print an install hint naming https://go.dev/dl/ when missing or older; an unparseable version (devel builds) is a warning; `docker` and `docker compose version` missing are warnings only. Checkout (D-15 step 2, D-16, D-17), following the research Q10 table: when the directory does not exist, `git clone --quiet "$repo" "$dir"` with `SUMMERCMS_REPO` defaulting to `https://git.golem15.com/golem15/summercms` (full clone, not shallow), then select the target and `git -c advice.detachedHead=false checkout --quiet --detach`. When it exists: it must be a git work tree whose go.mod first `module` line is `git.golem15.com/golem15/summercms` (read with a bash loop, not a pipeline into head), must have no tracked changes (`git status --porcelain --untracked-files=no` empty), must not be on a branch (`git symbolic-ref -q HEAD` fails); each violation stops with a message that suggests a separate `SUMMERCMS_DIR` or running `summer new --framework` by hand. Then `git fetch --quiet --tags origin` with no option that lets a moved upstream tag overwrite a local one (a failed fetch stops the script), select the target, do nothing when HEAD already equals it, check it out when `git merge-base --is-ancestor HEAD <target>` succeeds, otherwise stop (not a fast-forward). Target selection: `SUMMERCMS_REF` when set; otherwise capture `git tag -l 'v*' --sort=-v:refname` into a variable and take the first entry matching `^v[0-9]+\.[0-9]+\.[0-9]+$` in a bash loop (no pipe into head, research Pitfall 6); no release tag is an error. Install (D-15 step 3): run `go install ./cmd/summer` in the checkout with `-C`, `GOWORK` cleared and stdin from /dev/null; the install directory is `go env GOBIN`, else the first entry of `go env GOPATH` plus `/bin`. Create (D-15 step 4): from the parent directory run `"$gobin/summer" new "$name" --framework "$dir"` with stdin from /dev/null. Finish (D-24): print `cd <parent>/sm-<name>-app` as the first thing to run, and when `:$PATH:` lacks `:$gobin:` print `export PATH="$gobin:$PATH"` with a note to add it to the shell profile. Quote every expansion; no dynamic code strings, no privilege escalation, no writes outside the checkout, the new app directory and the install directory. Use only bash builtins plus the external commands `git`, `go` and `docker` (path manipulation through parameter expansion, not dirname or basename), so the script runs on a minimal PATH and plan 04 can test it with only git and stubs available. Environment overrides use only the `SUMMERCMS_` prefix (D-26). Messages go to stderr prefixed `==>` (errors `error:`), no colour codes.
|
||||
|
||||
Package `modules/sunrise/sunrise.go`: package doc comment; `//go:embed new.sh` into an unexported byte slice; `func Script() []byte` returning `bytes.Clone` of it. README.md with the standard structure: H1 `sunrise`, summary sentence `Bootstrap script that installs the summer CLI and creates a new SummerCMS application in one command, embedded so it is versioned with the framework.`, import line, Overview (what the one-liner does, served at https://summercms.io/new), Features (main wrapper, latest release tag, fast-forward-only updates and refusals, PATH hint), Usage (the one-liner, `bash -s -- <name>`, and a short Go handler that writes `sunrise.Script()` as `text/plain; charset=utf-8`), API reference (`sunrise.Script`), Configuration (the three `SUMMERCMS_` variables with defaults), Dependencies (SummerCMS modules none, third-party none, stdlib `bytes`, `embed`), Testing (`go test ./modules/sunrise/...`, the gated `SUMMERCMS_BOOTSTRAP_CHECK=1` run). No consuming application names. Add the row to the root README.md Modules table in alphabetical order with the same summary sentence (D-18, D-26).
|
||||
|
||||
Smoke tests `modules/sunrise/sunrise_test.go`: `TestScriptEmbedded` (Script equals the file bytes; mutating a returned slice does not change the next call); `TestScriptShape` (first line is the shebang, last non-empty line is exactly `main "$@"`, `bash -n` on the file exits 0); `TestScriptTruncationRunsNothing` (for every prefix ending on a line boundary and every byte prefix of the final line, write it to a temp file and run `bash <file> demo` with a clean environment whose PATH starts with a stub directory holding `git`, `go`, `docker`, `curl` and `summer` stubs that append to a marker file; assert the marker never exists and the working directory stays empty); gated `TestBootstrapEndToEnd` (runs only with `SUMMERCMS_BOOTSTRAP_CHECK=1`, 15 minute budget): serve `Script()` from an `httptest.Server`, run `curl -fsSL <url> | bash -s -- demo` in a temp work dir with `SUMMERCMS_REPO` = repository root, `SUMMERCMS_REF` = `git rev-parse HEAD`, `GOBIN` = a temp dir that is not on PATH; assert exit 0, the checkout `summercms` is detached at that commit, `sm-demo-app/go.mod` replaces the framework with `../summercms`, the output contains the `cd` line and the PATH hint, and `<gobin>/summer build` in the app succeeds.
|
||||
|
||||
Site route (D-13, D-28): new `script.go` in the plugin with `scriptHandler(body []byte) http.HandlerFunc` that precomputes a `file` with the same strong ETag scheme as `newTree` and ctype `contentTypes[".txt"]`, and serves with `Content-Type`, `Cache-Control: no-cache` (`cacheNoCache`), `ETag`, `setCommonHeaders` and `http.ServeContent` (HEAD and If-None-Match handled there). In `Routes` register `g.Get("/new", scriptHandler(sunrise.Script()))` inside the existing `GroupRaw` before the catch-all; update the package and Routes doc comments. Tests: add `{GET /new 200 body=Script}`, `{HEAD /new 200}`, `{POST /new 405}` rows to `TestRoutesAssemble` and a `TestNewScriptRoute` asserting the exact headers and a 304 for a matching If-None-Match. Add a `GET /new` row to the plugin README Routes table. The plugin builds against the local framework through its existing replace; its `require` stays `v0.1.12` until the post-phase release bump (D-28). Commit order: summercms.go (sunrise module, one commit), then inside the plugin submodule, then the gitlink bump in `../sm-summercmsio-app` as its own commit. Do not push.</action>
|
||||
<verify>
|
||||
<automated>go vet ./modules/sunrise/ && go test ./modules/sunrise/ -count=1 -v && go test ./cmd/summer -run '^(TestDocsTree|TestEveryModuleInSidebar)$' -count=1 -v && go -C ../sm-summercmsio-app/plugins/golem15/summercms vet ./... && go -C ../sm-summercmsio-app/plugins/golem15/summercms test ./... -count=1 -v -run '^(TestRoutesAssemble|TestNewScriptRoute)$' && SUMMERCMS_BOOTSTRAP_CHECK=1 go test ./modules/sunrise/ -run '^TestBootstrapEndToEnd$' -count=1 -v -timeout 20m</automated>
|
||||
<fails_when>Non-zero exit; any "--- FAIL"; "--- SKIP: TestBootstrapEndToEnd"; "no tests to run"; or any of "--- PASS: TestScriptTruncationRunsNothing", "--- PASS: TestEveryModuleInSidebar", "--- PASS: TestNewScriptRoute", "--- PASS: TestBootstrapEndToEnd" is absent.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `tail -n 1 modules/sunrise/new.sh` prints exactly `main "$@"` and `head -n 1 modules/sunrise/new.sh` prints `#!/usr/bin/env bash`.
|
||||
- `grep -c 'SUMMER_' modules/sunrise/new.sh` prints 0; `grep -c 'SUMMERCMS_REPO\|SUMMERCMS_DIR\|SUMMERCMS_REF' modules/sunrise/new.sh` is at least 3.
|
||||
- `grep -Ecw 'sudo|eval' modules/sunrise/new.sh` prints 0.
|
||||
- `grep -c 'go:embed new.sh' modules/sunrise/sunrise.go` is 1 and `grep -c 'modules/sunrise/README.md' README.md` is 1.
|
||||
- `grep -c '"/new"' ../sm-summercmsio-app/plugins/golem15/summercms/plugin.go` is 1.
|
||||
- TestBootstrapEndToEnd passes: clone detached at HEAD, relative `../summercms` replace, `cd` line and PATH hint printed, generated app builds.
|
||||
- `go test ./cmd/summer -run TestDocsTree` passes with the new README; `git -C ../sm-summercmsio-app status --porcelain plugins/golem15/summercms` is empty after the gitlink commit.
|
||||
</acceptance_criteria>
|
||||
<done>The embedded script is served at GET /new as text/plain and, piped from an HTTP server into bash, clones the framework at a pinned ref, installs summer and creates a buildable sm-demo-app; committed in summercms.go, the plugin submodule and the site app gitlink.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: D-20 container acceptance from nothing in node:24-bookworm</name>
|
||||
<precondition>Docker daemon reachable through /var/run/docker.sock, network access to go.dev, download.docker.com, deb.debian.org and proxy.golang.org, and host ports 54320 and 8080 free.</precondition>
|
||||
<files>scripts/check-phase14.3.sh</files>
|
||||
<read_first>scripts/check-phase14.3.sh (the --generator stage from plan 01), modules/sunrise/new.sh, .planning/phases/14.3-one-command-app-scaffolding-summer-new-and-summercms-io-new/14.3-RESEARCH.md (Container acceptance D-20, Pitfalls 9, 10, 11, 12, 13), ../sm-summercmsio-app/DEPLOY.md (step 4 checks)</read_first>
|
||||
<action>Add the D-20 acceptance stages to `scripts/check-phase14.3.sh`.
|
||||
|
||||
`--acceptance` (pre-tag, pre-deploy, D-20 and D-28): refuse to run when `modules/sunrise/new.sh` differs from HEAD (what is tested must be committed); preflight `docker info`, ports 54320 and 8080 free, and pull `node:24-bookworm`. Run `docker run --rm --network host -v /var/run/docker.sock:/var/run/docker.sock -v "$ROOT":/fw:ro -e COMPOSE_PROJECT_NAME=<unique> node:24-bookworm bash -euo pipefail -c '<inner>'`. The inner script, starting from the bare image: `apt-get update` and install `ca-certificates curl gnupg jq`; add Docker's official Debian bookworm apt repository with its signing key under /etc/apt/keyrings and install `docker-ce-cli docker-compose-plugin`; read the sha256 of `go1.27.0.linux-amd64.tar.gz` from `https://go.dev/dl/?mode=json&include=all` with jq, download the tarball from https://go.dev/dl/, verify it with `sha256sum -c`, unpack to /usr/local and put /usr/local/go/bin on PATH; `git config --global --add safe.directory '*'` (throwaway container, research Pitfall 12); in an empty /work run `curl -fsSL file:///fw/modules/sunrise/new.sh | SUMMERCMS_REPO=/fw SUMMERCMS_REF=<HEAD commit> bash -s -- demo` and capture its output; assert the output contains the `cd /work/sm-demo-app` line and the PATH hint, then apply the hint (`export PATH="$(go env GOPATH)/bin:$PATH"`); then exactly the D-20 chain: `cd sm-demo-app && docker compose up -d --wait && summer build && summer migrate`, start `summer serve` in the background, poll `http://127.0.0.1:8080/` for up to 60 s and require the body to contain `"plugin":"golem15.demo"`; an inner trap stops serve and runs `docker compose down -v`. compose.yaml uses a named volume only, so the host daemon needs no container paths (Pitfall 11). On the host a trap removes any container and volume labelled with the unique compose project (`docker ps -aq --filter label=com.docker.compose.project=<name>` and the volume equivalent) in case the inner cleanup did not run. End with `Phase 14.3 acceptance passed`.
|
||||
|
||||
`--acceptance-production` (run by the user after `v0.1.13` is tagged and the site is redeployed): the same container chain but `curl -fsSL https://summercms.io/new | bash -s -- demo` with no SUMMERCMS_ overrides, so the script selects the newest release tag from the public remote; ends with `Phase 14.3 production acceptance passed`. Do not run this stage in this plan.
|
||||
|
||||
Run `--acceptance` once and paste its tail (the chain and the final line) into the plan summary as D-20 evidence. Commit the script change on its own.</action>
|
||||
<verify>
|
||||
<automated>bash scripts/check-phase14.3.sh --acceptance</automated>
|
||||
<fails_when>Non-zero exit; "sha256sum: WARNING" or "FAILED" from the checksum; the output lacks "cd /work/sm-demo-app", lacks "\"plugin\":\"golem15.demo\"", or lacks the final line "Phase 14.3 acceptance passed".</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -c 'node:24-bookworm' scripts/check-phase14.3.sh` is at least 1 and `grep -c -- '--acceptance-production' scripts/check-phase14.3.sh` is at least 1.
|
||||
- `grep -c 'sha256sum -c' scripts/check-phase14.3.sh` is at least 1 and `grep -c 'docker-compose-plugin' scripts/check-phase14.3.sh` is at least 1.
|
||||
- The stage refuses when `git diff --quiet HEAD -- modules/sunrise/new.sh` fails.
|
||||
- A recorded run ends with `Phase 14.3 acceptance passed` and shows `docker compose up -d --wait`, `summer build`, `summer migrate`, `summer serve` and the starter route JSON.
|
||||
- After the run, `docker ps -a --filter label=com.docker.compose.project` shows no container of the acceptance project.
|
||||
</acceptance_criteria>
|
||||
<done>From a bare node:24-bookworm image, the one-liner (script file and repo at HEAD) followed by the D-20 chain serves the starter route, and the production variant is ready for the user to run after deploy.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: Security review of the generator, script and route</name>
|
||||
<files>.planning/phases/14.3-one-command-app-scaffolding-summer-new-and-summercms-io-new/14.3-SECURITY-REVIEW.md, modules/sunrise/new.sh, ../sm-summercmsio-app/plugins/golem15/summercms/script.go, internal/build/new.go</files>
|
||||
<read_first>.planning/phases/14.3-one-command-app-scaffolding-summer-new-and-summercms-io-new/14.3-01-PLAN.md (threat_model T-14.3-01..10), .planning/phases/14.3-one-command-app-scaffolding-summer-new-and-summercms-io-new/14.3-02-PLAN.md (threat_model T-14.3-11..19), .planning/phases/14.3-one-command-app-scaffolding-summer-new-and-summercms-io-new/14.3-RESEARCH.md (Security Domain), .planning/phases/14.2.1-translate-plugin/14.2.1-SECURITY-REVIEW.md (format reference), modules/sunrise/new.sh, internal/build/new.go, ../sm-summercmsio-app/plugins/golem15/summercms/script.go, ../sm-summercmsio-app/deploy/nginx/summercms.io.conf</read_first>
|
||||
<action>Run the security review lane (CONTEXT discretion: the script is piped into a shell and served publicly) over the plan 01 and plan 02 changes: `git diff v0.1.12..HEAD -- internal/build cmd/summer modules/lagoon modules/sunrise scripts/check-phase14.3.sh` in summercms.go and the plugin submodule's diff for `script.go`/`plugin.go`. Review at ASVS L1, blocking on high: input validation of `<name>` in both the script and the generator, quoting of every expansion in new.sh, behaviour under `curl | bash` (stdin, truncation, HTML error bodies with `curl -f`), tag and ancestry handling, refusal paths that protect local work, write locations, `.env` handling and the first commit, compose port binding, MIME type and nosniff on `/new`, nginx passthrough (GET/HEAD only), and the acceptance harness's use of the host Docker socket. Fix any high finding in the affected file within this task (new.sh, script.go or new.go) and re-run the matching Task 1 command.
|
||||
|
||||
Write `14.3-SECURITY-REVIEW.md`: scope, method, an ASVS L1 table (V5, V6, V10, V12, V14 rows per research), and one row per threat T-14.3-01 to T-14.3-19 plus T-14.3-SC with category, severity, disposition, the source-level control (file and function or script function), the evidence available now (Task 1 smoke tests, the generator gate, the acceptance run), status (`mitigated`, `accepted`, or `open`), and a column for the plan 04 test that will pin it. Any `open` high threat blocks the phase. Commit the review as a planning-docs commit, separate from any code fix.</action>
|
||||
<verify>
|
||||
<automated>bash -c 'f=.planning/phases/14.3-one-command-app-scaffolding-summer-new-and-summercms-io-new/14.3-SECURITY-REVIEW.md; test -f "$f"; for i in $(seq -w 1 19); do grep -q "T-14.3-$i" "$f" || { echo "missing T-14.3-$i"; exit 1; }; done; grep -q "T-14.3-SC" "$f"; if grep -E "\| *high *\|.*\| *open *\|" "$f"; then echo "open high threat"; exit 1; fi; echo REVIEW-OK' && go test ./modules/sunrise/ -count=1 && go test ./internal/build/ -run '^TestNewAppSmoke$' -count=1</automated>
|
||||
<fails_when>Non-zero exit; "missing T-14.3-" or "open high threat" printed; any "FAIL"; or the output lacks "REVIEW-OK".</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- 14.3-SECURITY-REVIEW.md contains every ID T-14.3-01 through T-14.3-19 and T-14.3-SC exactly once as a table row.
|
||||
- Every high-severity row has status `mitigated` and names the controlling file and function.
|
||||
- Any code fix made by the review is in a separate code commit and the Task 1 tests still pass.
|
||||
</acceptance_criteria>
|
||||
<done>The phase's security-relevant surfaces are reviewed at ASVS L1 with no open high threat, and the review names what plan 04 must pin with tests.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| Internet (summercms.io) -> user shell | The script is downloaded and executed with the user's privileges |
|
||||
| Remote git (tags, objects) -> local checkout | Upstream tags select the code that is compiled and installed |
|
||||
| Existing local checkout -> script | The user's own clone may hold uncommitted work |
|
||||
| Acceptance harness -> host Docker daemon | The container drives the host daemon through its socket |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-14.3-11 | Tampering | truncated download | high | mitigate | Whole script in `main()`, called on the last line; `TestScriptTruncationRunsNothing` covers line and byte prefixes |
|
||||
| T-14.3-12 | Tampering | error body piped to bash | medium | mitigate | Docs and landing page use `curl -fsSL` (fails on HTTP errors); `/new` answers only with the embedded bytes |
|
||||
| T-14.3-13 | Spoofing | `/new` rendered as HTML | medium | mitigate | `text/plain; charset=utf-8` plus `X-Content-Type-Options: nosniff`; no request data reflected; `TestNewScriptRoute` |
|
||||
| T-14.3-14 | Tampering | moved upstream tag, non-fast-forward | high | mitigate | Tags fetched without overwrite permission; checkout only when HEAD is an ancestor of the target; else exit non-zero |
|
||||
| T-14.3-15 | Tampering | user's existing checkout | high | mitigate | Refuse tracked changes, branch checkouts, non-framework go.mod and non-git dirs; no command that discards work |
|
||||
| T-14.3-16 | Elevation of Privilege | shell injection via name or overrides | high | mitigate | Name regex before use; every expansion quoted; overrides passed only as arguments; no dynamic code strings |
|
||||
| T-14.3-17 | Denial of Service | credential prompt hang with piped stdin | medium | mitigate | `GIT_TERMINAL_PROMPT=0`; children run with stdin from /dev/null |
|
||||
| T-14.3-18 | Tampering | supply chain of the served script | high | mitigate | Script embedded from the framework tag the site builds against; HTTPS default clone URL; release tags only; acceptance verifies the Go tarball sha256 from go.dev |
|
||||
| T-14.3-19 | Elevation of Privilege | acceptance harness on host Docker socket | medium | mitigate | Local harness only; framework mounted read-only; named volume only; unique compose project; host-side cleanup trap |
|
||||
| T-14.3-SC | Tampering | npm/pip/cargo installs | high | mitigate | No package-manager installs in the framework; container installs come from Debian and Docker's signed apt repositories and a checksum-verified Go tarball |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
Task 1 command (smoke, docs tree, plugin route, gated end to end), Task 2 `bash scripts/check-phase14.3.sh --acceptance` ending with `Phase 14.3 acceptance passed`, Task 3 review check. Then `go vet ./... && go test ./... -short` in summercms.go and `go -C ../sm-summercmsio-app/plugins/golem15/summercms test ./... -count=1`.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- Roadmap success criterion 3: the script checks Go 1.27+ and Docker, clones or fast-forwards the framework at its latest release tag, installs summer with a PATH hint, runs `summer new` and prints the cd line; summercms.io serves it at `/new` as `text/plain` once deployed.
|
||||
- Roadmap success criterion 4: the D-20 chain passes in node:24-bookworm (local variant now, production variant after deploy).
|
||||
- No open high threat in 14.3-SECURITY-REVIEW.md; no tag push, branch push or deploy happened.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/14.3-one-command-app-scaffolding-summer-new-and-summercms-io-new/14.3-02-SUMMARY.md` when done.
|
||||
</output>
|
||||
</content>
|
||||
</invoke>
|
||||
@@ -0,0 +1,247 @@
|
||||
---
|
||||
phase: 14.3-one-command-app-scaffolding-summer-new-and-summercms-io-new
|
||||
plan: 02
|
||||
subsystem: cli
|
||||
tags: [bootstrap, curl-bash, sunrise, embed, site-route, docker, acceptance, security-review]
|
||||
|
||||
requires:
|
||||
- phase: 14.3-01
|
||||
provides: "summer new <name> --framework, next steps printed by summer new, scripts/check-phase14.3.sh --generator"
|
||||
provides:
|
||||
- "modules/sunrise: new.sh (curl | bash bootstrap) and sunrise.Script()"
|
||||
- "site plugin GET /new serving sunrise.Script() as text/plain (sm-summercmsio-plugin 2157bb9, app gitlink fd721cf)"
|
||||
- "scripts/check-phase14.3.sh --acceptance and --acceptance-production (D-20 in node:24-bookworm)"
|
||||
- "14.3-SECURITY-REVIEW.md covering T-14.3-01..19 and T-14.3-SC"
|
||||
affects: [14.3-03 docs and landing page, 14.3-04 unit tests and gate, v0.1.13 release, site redeploy]
|
||||
|
||||
actuals:
|
||||
tokens: 12013
|
||||
tasks: 3
|
||||
commits: 3
|
||||
plan_head_before: d0888d7576901ef7043963d70792573581f40c22
|
||||
plan_head_after: 55dffb5b635b5096b02d8440b11041662d4cd248
|
||||
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns:
|
||||
- "Bootstrap script as functions plus a final `main \"$@\"`, using only bash builtins and git/go/docker so a stub PATH can prove truncation safety"
|
||||
- "Script embedded in a framework module and served by the site from the framework version it builds against"
|
||||
|
||||
key-files:
|
||||
created:
|
||||
- modules/sunrise/new.sh
|
||||
- modules/sunrise/sunrise.go
|
||||
- modules/sunrise/sunrise_test.go
|
||||
- modules/sunrise/README.md
|
||||
- ../sm-summercmsio-app/plugins/golem15/summercms/script.go
|
||||
- .planning/phases/14.3-one-command-app-scaffolding-summer-new-and-summercms-io-new/14.3-SECURITY-REVIEW.md
|
||||
modified:
|
||||
- README.md
|
||||
- scripts/check-phase14.3.sh
|
||||
- ../sm-summercmsio-app/plugins/golem15/summercms/plugin.go
|
||||
- ../sm-summercmsio-app/plugins/golem15/summercms/routes_test.go
|
||||
- ../sm-summercmsio-app/plugins/golem15/summercms/README.md
|
||||
|
||||
key-decisions:
|
||||
- "Framework module for the script is named sunrise; package exports only Script() []byte (fresh copy per call)"
|
||||
- "The script installs summer with GOWORK=off so the clone's committed go.work (examples) and missing go.work.sum do not affect go install"
|
||||
- "Progress, warnings and errors go to stderr; the closing cd line and PATH hint go to stdout"
|
||||
- "On a fresh clone with no usable target the clone is left in place and the error says to remove it; the script never deletes anything"
|
||||
- "Security review found no high issue, so no code fix; the dirty, branch, moved-tag and non-fast-forward refusals were probed on a throwaway repository"
|
||||
|
||||
patterns-established:
|
||||
- "Stub-PATH truncation harness: every line-boundary prefix and every byte prefix of the final line, with a positive control"
|
||||
- "Container acceptance through the host Docker socket: --network host, read-only framework mount, unique compose project, host-side label cleanup"
|
||||
|
||||
requirements-completed: [D-13, D-14, D-15, D-16, D-17, D-18, D-20, D-24, D-26, D-28]
|
||||
|
||||
coverage:
|
||||
- id: D1
|
||||
description: "new.sh embedded in modules/sunrise; Script() returns the file bytes as a fresh copy; shape and bash -n checked"
|
||||
requirement: D-13
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "modules/sunrise/sunrise_test.go#TestScriptEmbedded"
|
||||
status: pass
|
||||
- kind: unit
|
||||
ref: "modules/sunrise/sunrise_test.go#TestScriptShape"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D2
|
||||
description: "A truncated download (any line-boundary prefix, any byte prefix of the final line) invokes nothing and creates no file"
|
||||
requirement: D-14
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "modules/sunrise/sunrise_test.go#TestScriptTruncationRunsNothing"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D3
|
||||
description: "curl -fsSL <server> | bash -s -- demo clones the framework detached at a pinned ref, installs summer into a GOBIN off PATH, creates a buildable sm-demo-app with a ../summercms replace and prints the cd line and PATH hint"
|
||||
requirement: D-15
|
||||
verification:
|
||||
- kind: e2e
|
||||
ref: "SUMMERCMS_BOOTSTRAP_CHECK=1 go test ./modules/sunrise/ -run '^TestBootstrapEndToEnd$'"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D4
|
||||
description: "Site plugin GET /new: 200 text/plain; charset=utf-8, nosniff, no-cache, strong ETag, body = sunrise.Script(); HEAD 200 no body; matching If-None-Match 304; POST 405"
|
||||
requirement: D-13
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "sm-summercmsio-plugin routes_test.go#TestNewScriptRoute"
|
||||
status: pass
|
||||
- kind: unit
|
||||
ref: "sm-summercmsio-plugin routes_test.go#TestRoutesAssemble"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D5
|
||||
description: "D-20: from a bare node:24-bookworm image, the one-liner (committed script, repo at HEAD) then docker compose up -d --wait, summer build, summer migrate, summer serve answers GET / with \"plugin\":\"golem15.demo\""
|
||||
requirement: D-20
|
||||
verification:
|
||||
- kind: e2e
|
||||
ref: "bash scripts/check-phase14.3.sh --acceptance"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D6
|
||||
description: "Refusals that protect an existing checkout (branch, dirty, moved tag, non-fast-forward, non-framework dir) and early name/existing-app stops"
|
||||
requirement: D-16
|
||||
verification:
|
||||
- kind: manual_procedural
|
||||
ref: "14.3-SECURITY-REVIEW.md#refusal-probe-2026-10-08"
|
||||
status: pass
|
||||
human_judgment: true
|
||||
rationale: "Probed by hand on a throwaway repository during the review; plan 04 TestScriptBehavior pins these paths as automated tests"
|
||||
- id: D7
|
||||
description: "Security review of plans 01-02 at ASVS L1 with no open high threat"
|
||||
verification:
|
||||
- kind: other
|
||||
ref: "Task 3 verify (REVIEW-OK)"
|
||||
status: pass
|
||||
human_judgment: true
|
||||
rationale: "Self-performed review (no separate auditor agent); a human should read the register and accepted risks"
|
||||
- id: D8
|
||||
description: "Production acceptance against https://summercms.io/new after v0.1.13 is tagged and the site redeployed"
|
||||
requirement: D-28
|
||||
verification: []
|
||||
human_judgment: true
|
||||
rationale: "Release and deploy are user actions; bash scripts/check-phase14.3.sh --acceptance-production runs after them"
|
||||
|
||||
duration: 11min
|
||||
completed: 2026-10-08
|
||||
status: complete
|
||||
---
|
||||
|
||||
# Phase 14.3 Plan 02: curl | bash bootstrap, GET /new and D-20 acceptance Summary
|
||||
|
||||
**`modules/sunrise` embeds `new.sh`, a truncation-safe `curl -fsSL https://summercms.io/new | bash -s -- <name>` installer that checks Go 1.27+/git/Docker, clones or fast-forwards the framework at its newest release tag, installs summer and runs `summer new`; the site plugin serves it at `GET /new` as text/plain, and the full one-liner-to-serving chain passed from a bare node:24-bookworm container.**
|
||||
|
||||
## Performance
|
||||
|
||||
- **Duration:** 11 min
|
||||
- **Started:** 2026-10-08T09:46:59Z
|
||||
- **Completed:** 2026-10-08T09:58:30Z
|
||||
- **Tasks:** 3
|
||||
- **Files modified:** 11 (7 in summercms.go, 4 in sm-summercmsio-plugin) plus the site app gitlink
|
||||
|
||||
## Accomplishments
|
||||
|
||||
- `new.sh`: argument parsing first (`-h`/`--help`, exactly one name, regex), lexical absolute path for `SUMMERCMS_DIR`, early stop on an existing `sm-<name>-app`, Go version from `/` with a go.dev/dl hint, git required, Docker and compose as warnings, clone or ff-only update with refusals, `GOWORK=off go -C <dir> install ./cmd/summer`, `summer new <name> --framework <abs dir>` with stdin closed, then the `cd` line and the PATH hint.
|
||||
- `sunrise.Script()` plus README (standard structure) and root modules-table row; `TestDocsTree` and `TestEveryModuleInSidebar` pass.
|
||||
- Site plugin `scriptHandler` and `GET /new` registered before the catch-all; `TestNewScriptRoute`, three `TestRoutesAssemble` rows, README route row.
|
||||
- `--acceptance` / `--acceptance-production` gate stages; `--acceptance` passed.
|
||||
- `14.3-SECURITY-REVIEW.md`: 20 threats, 17 mitigated, 3 accepted (low), 0 open.
|
||||
|
||||
## D-20 evidence (`bash scripts/check-phase14.3.sh --acceptance`, 2026-10-08)
|
||||
|
||||
```
|
||||
== node:24-bookworm (local, compose project phase143acc-3499410-1791453239)
|
||||
Docker version 29.8.2, build 7fc2dff
|
||||
Docker Compose version v5.6.0
|
||||
== Go 1.27.0 from go.dev, sha256 checked
|
||||
go1.27.0.linux-amd64.tar.gz: OK
|
||||
go version go1.27.0 linux/amd64
|
||||
+ curl -fsSL file:///fw/modules/sunrise/new.sh | SUMMERCMS_REPO=/fw SUMMERCMS_REF=cfd110eddb2af449aae0a2a420008c4368dfd9c9 bash -s -- demo
|
||||
==> cloning /fw into /work/summercms
|
||||
==> framework target: cfd110eddb2af449aae0a2a420008c4368dfd9c9 (cfd110eddb2a)
|
||||
==> installing summer into /root/go/bin
|
||||
==> creating /work/sm-demo-app
|
||||
warning: git user.name and user.email not configured; the first commit uses SummerCMS <summer@localhost>
|
||||
==> Done. Start with:
|
||||
cd /work/sm-demo-app
|
||||
==> summer is installed in /root/go/bin, which is not on your PATH. Run this (and add it to your shell profile):
|
||||
export PATH="/root/go/bin:$PATH"
|
||||
== D-20 chain
|
||||
+ docker compose up -d --wait
|
||||
+ summer build
|
||||
+ summer migrate
|
||||
+ summer serve &
|
||||
GET http://127.0.0.1:8080/ -> {"message":"Hello from SummerCMS","plugin":"golem15.demo"}
|
||||
Phase 14.3 acceptance passed
|
||||
```
|
||||
|
||||
Afterwards no container or volume of the `phase143acc-*` project remained. With `new.sh` modified the stage refuses: `FAIL: modules/sunrise/new.sh differs from HEAD; commit it first`.
|
||||
|
||||
## Task Commits
|
||||
|
||||
1. **Task 1: script, embed, GET /new, wired end to end**
|
||||
- `cfd110e` (feat, summercms.go): sunrise module, tests, README, modules row
|
||||
- `2157bb9` (feat, sm-summercmsio-plugin): `GET /new` route, tests, README
|
||||
- `fd721cf` (feat, sm-summercmsio-app): plugin gitlink bump
|
||||
2. **Task 2: D-20 container acceptance** - `612d0c6` (chore)
|
||||
3. **Task 3: security review** - `55dffb5` (docs)
|
||||
|
||||
Nothing was pushed, tagged or deployed.
|
||||
|
||||
## Files Created/Modified
|
||||
|
||||
- `modules/sunrise/new.sh` - the bootstrap script (0755)
|
||||
- `modules/sunrise/sunrise.go` - `//go:embed new.sh`, `Script()`
|
||||
- `modules/sunrise/sunrise_test.go` - embedded, shape, truncation, gated end-to-end tests
|
||||
- `modules/sunrise/README.md`, `README.md` - module docs and table row
|
||||
- `scripts/check-phase14.3.sh` - `--acceptance`, `--acceptance-production`
|
||||
- `../sm-summercmsio-app/plugins/golem15/summercms/script.go` - `scriptHandler`
|
||||
- `../sm-summercmsio-app/plugins/golem15/summercms/plugin.go` - `GET /new`, doc comments
|
||||
- `../sm-summercmsio-app/plugins/golem15/summercms/routes_test.go` - `TestNewScriptRoute`, route rows, coexistence patterns
|
||||
- `../sm-summercmsio-app/plugins/golem15/summercms/README.md` - route row
|
||||
- `14.3-SECURITY-REVIEW.md` - ASVS L1 review
|
||||
|
||||
## Decisions Made
|
||||
|
||||
- Module name `sunrise` (beach-themed like the other modules); only `Script()` is exported.
|
||||
- `GOWORK=off` for `go install`: the framework commits a `go.work` for its examples but not `go.work.sum`, and the CLI builds from the main module alone.
|
||||
- No code changes came out of the security review; the non-fast-forward error wording ("<dir> must be an ancestor of the target") could say "HEAD of <dir>" — cosmetic, left so the acceptance evidence matches the committed bytes.
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
### Auto-fixed Issues
|
||||
|
||||
**1. [Rule 3 - Blocking] TestRoutesCoexistWithAdminPatterns pinned the plugin's exact route list**
|
||||
- **Found during:** Task 1
|
||||
- **Issue:** The test asserts the plugin registers exactly `GET /docs`, `GET /docs/{path...}`, `GET /`; adding `GET /new` would fail it.
|
||||
- **Fix:** Added `GET /new` to `wantPatterns` and its handler to the coexistence mux, so the test also proves `/new` does not conflict with the admin patterns.
|
||||
- **Files modified:** `../sm-summercmsio-app/plugins/golem15/summercms/routes_test.go`
|
||||
- **Verification:** `go -C ../sm-summercmsio-app/plugins/golem15/summercms test ./...` passes
|
||||
- **Committed in:** 2157bb9
|
||||
|
||||
---
|
||||
|
||||
**Total deviations:** 1 auto-fixed (1 blocking). **Impact:** test kept in step with the new route; no scope change.
|
||||
|
||||
## Issues Encountered
|
||||
|
||||
None.
|
||||
|
||||
## User Setup Required
|
||||
|
||||
None now. After `v0.1.13` is tagged, the site's `require` is bumped and the site redeployed (user actions, D-28), run `bash scripts/check-phase14.3.sh --acceptance-production`.
|
||||
|
||||
## Next Phase Readiness
|
||||
|
||||
- Plan 03 can document the one-liner, `SUMMERCMS_REPO/DIR/REF` and `summer new`, and switch the landing page; the site's gated `TestTerminalCommands` will need a script-URL override (research Q9) and can pass `SUMMERCMS_REPO/REF` through.
|
||||
- Plan 04: `TestScriptBehavior` should pin the refusal probe cases from the security review; `TestScriptShellcheck` can run `shellcheck` (clean today apart from one annotated SC2016).
|
||||
|
||||
---
|
||||
*Phase: 14.3-one-command-app-scaffolding-summer-new-and-summercms-io-new*
|
||||
*Completed: 2026-10-08*
|
||||
|
||||
## Self-Check: PASSED
|
||||
@@ -0,0 +1,238 @@
|
||||
---
|
||||
phase: 14.3-one-command-app-scaffolding-summer-new-and-summercms-io-new
|
||||
plan: 03
|
||||
type: execute
|
||||
wave: 3
|
||||
depends_on: ["14.3-02"]
|
||||
files_modified:
|
||||
- docs/setup/installation.md
|
||||
- docs/architecture/go-modules-and-workspaces.md
|
||||
- docs/console/scaffolding.md
|
||||
- docs/console/introduction.md
|
||||
- docs/site.yaml
|
||||
- README.md
|
||||
- ../sm-summercmsio-app/vue-summercmsio-app/app/data/terminal.json
|
||||
- ../sm-summercmsio-app/vue-summercmsio-app/i18n/locales/en.json
|
||||
- ../sm-summercmsio-app/vue-summercmsio-app/tests/terminal.test.ts
|
||||
- ../sm-summercmsio-app/vue-summercmsio-app/tests/output.test.ts
|
||||
- ../sm-summercmsio-app/terminal_check_test.go
|
||||
- ../sm-summercmsio-app/README.md
|
||||
- ../sm-summercmsio-app/CLAUDE.md
|
||||
- ../sm-summercmsio-app/DEPLOY.md
|
||||
autonomous: true
|
||||
requirements: [D-01, D-18, D-19, D-20, D-24, D-28]
|
||||
estimate:
|
||||
tokens: 120000
|
||||
raw_tokens: 120000
|
||||
tasks: 3
|
||||
confidence: low
|
||||
must_haves:
|
||||
truths:
|
||||
- "D-18: docs/setup/installation.md shows `curl -fsSL https://summercms.io/new | bash -s -- demo` in its first section, before any examples/hello step; the manual examples/hello walkthrough follows it; no Known issues callout remains."
|
||||
- "D-18: docs/architecture/go-modules-and-workspaces.md explains what `summer new` writes (relative framework replace, committed go.work with relative paths, no root go.sum, gitignored go.work.sum, the --framework / sibling / binary-source detection order) and says plugin:add adds a relative path; docs/console/scaffolding.md has a Creating an application section documenting `summer new` with `--framework`, `--no-plugin` and `--no-git`."
|
||||
- "D-18: a fresh `summer docs:build` writes llms-full.txt containing the one-liner and `summer new`; TestDocsTree, TestDocsBuildRealTree, TestDocsAIOutputsInSync and `summer docs:build --check` pass."
|
||||
- "D-19: terminal.json has exactly three groups and six commands: [`curl -fsSL https://summercms.io/new | bash -s -- demo`], [`cd sm-demo-app`, `docker compose up -d --wait`], [`summer build`, `summer migrate`, `summer serve`]; every comment key resolves to a non-empty en.json string and no unused terminal comment key remains; `pnpm generate && pnpm test` pass."
|
||||
- "D-19: TestLoadTerminal, TestTerminalScript, TestTerminalEnv and TestTerminalRunScript pass; SUMMERCMS_NEW_URL replaces the script URL only inside the curl command."
|
||||
- "D-19/D-20: with SUMMERCMS_TERMINAL_CHECK=1, SUMMERCMS_NEW_URL pointing at the local script, SUMMERCMS_REPO at the framework checkout and SUMMERCMS_REF at its HEAD, TestTerminalCommands runs the six page commands verbatim, sees JSON containing \"plugin\":\"golem15.demo\" from the backgrounded `summer serve`, and removes its compose project."
|
||||
- "D-28: the site's framework require stays at v0.1.12 in this plan; nothing is deployed and no tag is pushed."
|
||||
prohibitions:
|
||||
- requirement_id: D-19
|
||||
category: safety
|
||||
status: resolved
|
||||
verification: judgment
|
||||
resolution: flagged-unverified
|
||||
reason: "TestTerminalScript pins the exact string; descriptor-less, human review recommended."
|
||||
statement: "The landing page and docs must never show the bootstrap one-liner over plain HTTP or without curl's fail-on-error flag."
|
||||
- requirement_id: D-18
|
||||
category: transparency
|
||||
status: resolved
|
||||
verification: judgment
|
||||
resolution: flagged-unverified
|
||||
reason: "docs:build --check forbidden-name rule covers docs/; descriptor-less, human review recommended."
|
||||
statement: "Framework docs and READMEs must never name a consuming application."
|
||||
- requirement_id: D-28
|
||||
category: safety
|
||||
status: resolved
|
||||
verification: judgment
|
||||
resolution: flagged-unverified
|
||||
reason: "Release is a user action; descriptor-less, human review recommended."
|
||||
statement: "No task pushes, tags, bumps the site's framework require or deploys the site."
|
||||
artifacts:
|
||||
- path: "docs/setup/installation.md"
|
||||
provides: "one-liner first, manual walkthrough second"
|
||||
contains: "curl -fsSL https://summercms.io/new | bash -s -- demo"
|
||||
- path: "docs/console/scaffolding.md"
|
||||
provides: "summer new reference"
|
||||
contains: "summer new"
|
||||
- path: "../sm-summercmsio-app/vue-summercmsio-app/app/data/terminal.json"
|
||||
provides: "landing page Get started commands"
|
||||
contains: "https://summercms.io/new"
|
||||
- path: "../sm-summercmsio-app/terminal_check_test.go"
|
||||
provides: "terminal command checks with SUMMERCMS_NEW_URL and backgrounded serve"
|
||||
contains: "SUMMERCMS_NEW_URL"
|
||||
key_links:
|
||||
- from: "../sm-summercmsio-app/vue-summercmsio-app/app/data/terminal.json"
|
||||
to: "../sm-summercmsio-app/terminal_check_test.go"
|
||||
via: "loadTerminal reads the page's command list and TestTerminalCommands executes it"
|
||||
pattern: "terminal.json"
|
||||
- from: "docs/setup/installation.md"
|
||||
to: "modules/sunrise/new.sh"
|
||||
via: "the documented one-liner is the script served at /new"
|
||||
pattern: "summercms.io/new"
|
||||
- from: "docs/console/scaffolding.md"
|
||||
to: "cmd/summer/main.go"
|
||||
via: "the docs command checker validates `summer new` against toolCommands()"
|
||||
pattern: "summer new"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Document the one-command start and switch the summercms.io landing page "Get started" to the one-liner: installation guide leads with `curl -fsSL https://summercms.io/new | bash -s -- demo`, the architecture and console docs cover `summer new`, llms outputs pick it up, and the landing page plus its four test layers move to the new commands, including a gated run of the page's commands end to end.
|
||||
|
||||
Purpose: roadmap success criterion 5 and D-19; people and agents find the new path first.
|
||||
Output: updated framework docs and root README, new terminal.json/en.json with tests, reworked terminal command check and site docs.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/gsd-core/workflows/execute-plan.md
|
||||
@~/.claude/gsd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/phases/14.3-one-command-app-scaffolding-summer-new-and-summercms-io-new/14.3-CONTEXT.md
|
||||
@.planning/phases/14.3-one-command-app-scaffolding-summer-new-and-summercms-io-new/14.3-RESEARCH.md
|
||||
@.planning/phases/14.3-one-command-app-scaffolding-summer-new-and-summercms-io-new/14.3-01-SUMMARY.md
|
||||
@.planning/phases/14.3-one-command-app-scaffolding-summer-new-and-summercms-io-new/14.3-02-SUMMARY.md
|
||||
@docs/setup/installation.md
|
||||
@docs/architecture/go-modules-and-workspaces.md
|
||||
@docs/console/scaffolding.md
|
||||
@../sm-summercmsio-app/terminal_check_test.go
|
||||
@../sm-summercmsio-app/vue-summercmsio-app/app/data/terminal.json
|
||||
</context>
|
||||
|
||||
## Spec-less probe fallback
|
||||
|
||||
No REQUIREMENTS.md IDs or SPEC.md exist for this phase; requirement probing is visibly skipped. The acceptance contract is D-18, D-19, the D-20 terminal run, D-24 (cd line comes from the script, next steps from summer new), D-28, and roadmap success criterion 5.
|
||||
|
||||
## Artifacts this phase produces (this plan)
|
||||
|
||||
- Docs: rewritten `docs/setup/installation.md`; new section in `docs/architecture/go-modules-and-workspaces.md`; section "Creating an application" in `docs/console/scaffolding.md`; updated `docs/console/introduction.md`; one `llms_notes` entry in `docs/site.yaml`; root `README.md` Requirements and Quick start.
|
||||
- Landing page: `terminal.json` groups with comment keys `terminal.createApp`, `terminal.startDatabase`, `terminal.buildAndServe` and their en.json strings (old keys `terminal.getFramework`, `terminal.installCli`, `terminal.buildExample` removed).
|
||||
- Site app tests: const `terminalNewURL`, `terminalScript(groups, newURL)`, helper `terminalRunScript`, test `TestTerminalRunScript`, reworked gated `TestTerminalCommands`; env `SUMMERCMS_NEW_URL` replaces `SUMMERCMS_CLONE_URL`.
|
||||
- Site docs: README test-variable table, CLAUDE.md, DEPLOY.md step 4 wording, `/new` route mention.
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tracer">
|
||||
<name>Task 1: The one-liner flows from terminal.json to the rendered page, the site checks and the installation guide</name>
|
||||
<files>../sm-summercmsio-app/vue-summercmsio-app/app/data/terminal.json, ../sm-summercmsio-app/vue-summercmsio-app/i18n/locales/en.json, ../sm-summercmsio-app/vue-summercmsio-app/tests/terminal.test.ts, ../sm-summercmsio-app/vue-summercmsio-app/tests/output.test.ts, ../sm-summercmsio-app/terminal_check_test.go, docs/setup/installation.md</files>
|
||||
<read_first>../sm-summercmsio-app/vue-summercmsio-app/app/data/terminal.json, ../sm-summercmsio-app/vue-summercmsio-app/i18n/locales/en.json (start and terminal blocks), ../sm-summercmsio-app/vue-summercmsio-app/tests/terminal.test.ts, ../sm-summercmsio-app/vue-summercmsio-app/tests/output.test.ts (Get started test and assertLeavesRendered), ../sm-summercmsio-app/vue-summercmsio-app/app/components/TerminalCard.vue, ../sm-summercmsio-app/terminal_check_test.go, docs/setup/installation.md, modules/sunrise/README.md, .planning/phases/14.3-one-command-app-scaffolding-summer-new-and-summercms-io-new/14.3-RESEARCH.md (Q9)</read_first>
|
||||
<action>Switch the single command source and everything pinned to it, per D-19 and D-18.
|
||||
|
||||
terminal.json (contract shape unchanged, three groups and six commands): group `terminal.createApp` with `curl -fsSL https://summercms.io/new | bash -s -- demo`; group `terminal.startDatabase` with `cd sm-demo-app` and `docker compose up -d --wait`; group `terminal.buildAndServe` with `summer build`, `summer migrate`, `summer serve`. en.json: remove `terminal.getFramework`, `terminal.installCli`, `terminal.buildExample` (the "every en.json string is rendered" test requires unused keys to go) and add `createApp` "create an app and install the summer CLI", `startDatabase` "start PostgreSQL in Docker", `buildAndServe` "build, migrate and serve". Leave chips, title and links unchanged. tests/terminal.test.ts: expected `copyPayload` string becomes the six new commands joined by newlines, the comment-key list becomes the three new keys; keep the "no prompt, no comment, no trailing newline" test (the one-liner contains no `$` or `#`). tests/output.test.ts: adjust only if a literal of the old commands is pinned there.
|
||||
|
||||
terminal_check_test.go (ungated parts): replace `terminalCloneURL` with `terminalNewURL = "https://summercms.io/new"`; change `terminalScript(groups, newURL string)` so a non-empty `newURL` replaces `terminalNewURL` only inside a command that starts with `curl `; update its doc comment. `TestLoadTerminal` keeps the 3-groups/6-commands assertion and requires the first command to equal `curl -fsSL ` + `terminalNewURL` + ` | bash -s -- demo`. `TestTerminalScript` pins the six new commands verbatim, the override case (`file:///tmp/new.sh` rewrites only the first line) and the "only a curl command is rewritten" case. `TestTerminalCommands` reads the override from `SUMMERCMS_NEW_URL` (its execution flow is reworked in Task 2).
|
||||
|
||||
installation.md: add a first section after the intro, before Requirements content about examples/hello, titled with a plain ASCII heading such as "Create an application with one command", showing the one-liner in an `sh` fence, one sentence on what it does (checks Go 1.27 and Docker, fetches the framework at its latest release tag into `./summercms`, installs `summer`, runs `summer new demo`), and the follow-up commands `cd sm-demo-app`, `docker compose up -d --wait`, `summer build`, `summer migrate`, `summer serve` in a second `sh` fence. Headings stay plain ASCII without code spans (docs policy). Task 3 completes the page.
|
||||
|
||||
Commit the vue submodule change inside `vue-summercmsio-app`, then the test change plus the gitlink bump in `sm-summercmsio-app`, then the docs change in summercms.go. Do not push.</action>
|
||||
<verify>
|
||||
<automated>go -C ../sm-summercmsio-app vet . && go -C ../sm-summercmsio-app test -run '^(TestLoadTerminal|TestTerminalScript|TestTerminalEnv)$' -count=1 -v . && pnpm -C ../sm-summercmsio-app/vue-summercmsio-app generate && pnpm -C ../sm-summercmsio-app/vue-summercmsio-app test && go test ./cmd/summer -run '^TestDocsTree$' -count=1 && go run ./cmd/summer docs:build --check</automated>
|
||||
<fails_when>Non-zero exit; any "--- FAIL" or "not ok" line; the TAP summary shows "# fail" greater than 0; `docs:build --check` prints a problem count; or any of "--- PASS: TestLoadTerminal", "--- PASS: TestTerminalScript" is absent.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -c 'curl -fsSL https://summercms.io/new | bash -s -- demo' ../sm-summercmsio-app/vue-summercmsio-app/app/data/terminal.json` is 1 and `grep -c 'docker compose up -d --wait' ../sm-summercmsio-app/vue-summercmsio-app/app/data/terminal.json` is 1.
|
||||
- `grep -c 'getFramework\|installCli\|buildExample' ../sm-summercmsio-app/vue-summercmsio-app/i18n/locales/en.json` prints 0.
|
||||
- `grep -c 'SUMMERCMS_NEW_URL' ../sm-summercmsio-app/terminal_check_test.go` is at least 1 and `grep -c 'terminalNewURL' ../sm-summercmsio-app/terminal_check_test.go` is at least 2.
|
||||
- The first `sh` fence in docs/setup/installation.md contains the one-liner and appears before the first mention of `examples/hello`.
|
||||
- `pnpm test` reports 0 failures after `pnpm generate`.
|
||||
</acceptance_criteria>
|
||||
<done>The landing page renders and copies the one-liner flow, the site's ungated checks pin it, and the installation guide opens with the same command.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Run the page's commands end to end and update the site docs</name>
|
||||
<precondition>Plan 02 is committed in summercms.go (modules/sunrise/new.sh at HEAD), the Docker daemon is reachable, host ports 54320 and 8080 are free, and pnpm can run `nuxt generate`.</precondition>
|
||||
<files>../sm-summercmsio-app/terminal_check_test.go, ../sm-summercmsio-app/README.md, ../sm-summercmsio-app/CLAUDE.md, ../sm-summercmsio-app/DEPLOY.md</files>
|
||||
<read_first>../sm-summercmsio-app/terminal_check_test.go (terminalEnv, TestTerminalCommands), ../sm-summercmsio-app/README.md (gated test variable table, lines 40-55, and line 3), ../sm-summercmsio-app/CLAUDE.md (lines 7, 39, 55), ../sm-summercmsio-app/DEPLOY.md (launch checklist step 4), ../sm-summercmsio-app/scripts/build.sh (dev mode), ../sm-summercmsio-app/plugins/golem15/summercms/links_test.go (TestTerminalCommandsInPage), modules/sunrise/README.md (override variables)</read_first>
|
||||
<action>Rework the gated execution so the new six commands run verbatim (D-19, D-20 on the developer machine).
|
||||
|
||||
Add `terminalRunScript(script string) string`: returns the script unchanged except that a line equal to `summer serve` becomes: start `summer serve` in the background, poll `http://127.0.0.1:8080/` with curl for at most 60 seconds, print the response body, stop the server and fail when it never answered. Cover it with an ungated `TestTerminalRunScript` (serve line replaced, all other lines verbatim, a script without `summer serve` unchanged). `TestTerminalCommands`: run `bash -euo pipefail -c terminalRunScript(terminalScript(groups, os.Getenv("SUMMERCMS_NEW_URL")))` in a temp work dir with `terminalEnv` plus `COMPOSE_PROJECT_NAME=summercms-terminal-<pid>`; SUMMERCMS_REPO and SUMMERCMS_REF pass through untouched (terminalEnv only drops the framework config prefix, GOWORK and GOBIN); register a `t.Cleanup` that runs `docker compose down -v` with the same project name in the directory named by the page's `cd` command when it exists; require the output to contain `"plugin":"golem15.demo"` (this replaces the old greeter-output assertion). Keep the 10 minute budget, raise it to 20 if the first run needs it.
|
||||
|
||||
Site docs: README.md gated-variable table — `SUMMERCMS_TERMINAL_CHECK=1` runs the six Get started commands (the one-liner, PostgreSQL in Docker, build, migrate, serve) in a fresh shell with a temp GOBIN first on PATH, needs Docker and free ports 54320 and 8080, and requires the starter route JSON; replace the clone-URL override row with `SUMMERCMS_NEW_URL` (replaces only the script URL, for example `file://` + the framework checkout's `modules/sunrise/new.sh` before deploy) and note that `SUMMERCMS_REPO` and `SUMMERCMS_REF` pass through to the script for pre-tag runs; mention in the opening paragraph that the binary also serves the bootstrap script at `/new`. CLAUDE.md: same variable change on the gated-variables line, `/new` in the "What this is" paragraph. DEPLOY.md step 4: the terminal check now runs the six one-liner commands with no overrides and needs Docker; add that after the redeploy the framework's `scripts/check-phase14.3.sh --acceptance-production` repeats the run from a clean container. Leave every `v0.1.12` reference alone (the bump is a post-phase user action, D-28).
|
||||
|
||||
Run the gated check with local overrides (pre-tag, pre-deploy) and the page drift guard after a dev build, then commit in `sm-summercmsio-app`. Do not push.</action>
|
||||
<verify>
|
||||
<automated>FW="$PWD" && go -C ../sm-summercmsio-app test -run '^TestTerminalRunScript$' -count=1 -v . && SUMMERCMS_TERMINAL_CHECK=1 SUMMERCMS_NEW_URL="file://$FW/modules/sunrise/new.sh" SUMMERCMS_REPO="$FW" SUMMERCMS_REF="$(git -C "$FW" rev-parse HEAD)" go -C ../sm-summercmsio-app test -run '^TestTerminalCommands$' -count=1 -v -timeout 25m . && (cd ../sm-summercmsio-app && scripts/build.sh dev) && SUMMERCMS_REQUIRE_BUILD=1 go -C ../sm-summercmsio-app/plugins/golem15/summercms test -run '^TestTerminalCommandsInPage$' -count=1 -v ./...</automated>
|
||||
<fails_when>Non-zero exit; "--- FAIL"; "--- SKIP: TestTerminalCommands" or "--- SKIP: TestTerminalCommandsInPage"; or any of "--- PASS: TestTerminalRunScript", "--- PASS: TestTerminalCommands", "--- PASS: TestTerminalCommandsInPage" is absent.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -c 'SUMMERCMS_CLONE_URL' ../sm-summercmsio-app/terminal_check_test.go ../sm-summercmsio-app/README.md ../sm-summercmsio-app/CLAUDE.md` prints 0 for each file.
|
||||
- `grep -c 'golem15.demo' ../sm-summercmsio-app/terminal_check_test.go` is at least 1 and `grep -c 'handled=true' ../sm-summercmsio-app/terminal_check_test.go` prints 0.
|
||||
- `grep -c '/new' ../sm-summercmsio-app/README.md` is at least 1 and `grep -c 'acceptance-production' ../sm-summercmsio-app/DEPLOY.md` is at least 1.
|
||||
- `grep -c 'v0.1.12' ../sm-summercmsio-app/go.mod` is unchanged from before this plan.
|
||||
- After the gated run, `docker ps -a --filter label=com.docker.compose.project` lists no `summercms-terminal-` container.
|
||||
</acceptance_criteria>
|
||||
<done>The six landing-page commands run verbatim against the local script and framework and end with the starter route answering, the built page shows exactly those commands, and the site docs describe the new check and route.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: Framework docs for summer new and the regenerated llms outputs</name>
|
||||
<files>docs/setup/installation.md, docs/architecture/go-modules-and-workspaces.md, docs/console/scaffolding.md, docs/console/introduction.md, docs/site.yaml, README.md</files>
|
||||
<read_first>docs/setup/installation.md, docs/architecture/go-modules-and-workspaces.md, docs/console/scaffolding.md, docs/console/introduction.md, docs/site.yaml (llms_notes), README.md (Requirements, Quick start, Using SummerCMS in an application), internal/build/new.go (rules and resolution order as implemented), cmd/summer/main.go (newCommand flags), modules/sunrise/README.md, internal/docsite/check_commands.go (what the command checker reads), internal/docsite/check_policy.go (heading and src= rules), .planning/phases/14.3-one-command-app-scaffolding-summer-new-and-summercms-io-new/14.3-RESEARCH.md (Q8, the obsolete main.go warning)</read_first>
|
||||
<action>Finish D-18 in summercms.go, describing behaviour exactly as implemented in plans 01 and 02.
|
||||
|
||||
installation.md: description frontmatter mentions the one-command start. Order: intro; the one-command section from Task 1; a section on what the script does and its options (prerequisites Go 1.27, git, Docker; framework cloned into `./summercms` and checked out at the newest release tag; re-runs fetch tags and move forward only, refusing a checkout with local changes or on a branch; `SUMMERCMS_REPO`, `SUMMERCMS_DIR`, `SUMMERCMS_REF`; the PATH hint; the `cd` line); a section on running the app (compose PostgreSQL 16 on 127.0.0.1:54320, `.env` with the generated key, `summer build`, `summer migrate`, `summer serve`, `curl http://127.0.0.1:8080/`); a section on `summer new` without the script (clone, `go install ./cmd/summer`, run `summer new demo` next to the checkout or pass `--framework`); then the existing manual examples/hello walkthrough as the second path. Remove the callout that lists the old example defects entirely: the http.yaml gap is fixed and research found the main.go warning obsolete. Keep the existing Go fence with its `src=` reference intact.
|
||||
|
||||
go-modules-and-workspaces.md: add a section (plain ASCII heading, for example "Applications created by summer new") covering module path `git.golem15.com/golem15/sm-<name>-app`, the relative framework `replace`, the detection order (`--framework`, `../summercms`, `../summercms.go` matched by module path, then the summer binary's own source tree), the committed go.work with relative `use` paths, no root go.sum and gitignored go.work.sum; change the plugin:add sentence to say it adds a relative path to the nearest go.work. scaffolding.md: new section "Creating an application" before "Creating a plugin" with `summer new acme`, the name rule (lower-case letters and digits starting with a letter, not a Go keyword, `main` or `internal`), the refusal of an existing directory, a table of `--framework`, `--no-plugin`, `--no-git`, the files written (go.mod, go.work, summer.yaml, config/, .env, .env.example, compose.yaml, .gitignore, CLAUDE.md, AGENTS.md, the starter plugin with `GET /`), the first commit and the printed next steps, and the naming convention `sm-<name>-app`, `sm-<name>-plugin`, `sm-<name>-theme` (D-01). introduction.md: the summer row says it creates, builds and watches applications and is installed by the one-liner or `go install ./cmd/summer`; the chapter list mentions `summer new` under Scaffolding. site.yaml: add one `llms_notes` entry stating that a new application starts with `curl -fsSL https://summercms.io/new | bash -s -- <name>` or `summer new <name>` next to a framework checkout.
|
||||
|
||||
Root README.md: Requirements say Docker runs the generated application's PostgreSQL; Quick start leads with the one-liner and the follow-up commands, then `summer new` for an existing checkout, then the examples/hello walkthrough; remove the subsection that lists the old example defects; "Using SummerCMS in an application" says `summer new` writes the go.mod with a relative replace for you. Use neutral names (`demo`, `acme`) only. Then build the docs into a temp directory and confirm the llms outputs contain the new material (D-18 regenerate; the files are build output and are not committed). Commit as one docs commit.</action>
|
||||
<verify>
|
||||
<automated>go test ./cmd/summer -run '^(TestDocsTree|TestDocsBuildRealTree|TestDocsAIOutputsInSync|TestDocsRequiredPages|TestEveryModuleInSidebar)$' -count=1 -v && go run ./cmd/summer docs:build --check && bash -c 'set -e; o="$(mktemp -d)/site"; go run ./cmd/summer docs:build --out "$o" >/dev/null; grep -qF "curl -fsSL https://summercms.io/new | bash -s -- demo" "$o/llms-full.txt"; grep -qF "summer new" "$o/llms-full.txt"; grep -qF "summercms.io/new" "$o/llms.txt"; echo LLMS-OK'</automated>
|
||||
<fails_when>Non-zero exit; any "--- FAIL"; `docs:build --check` prints problems; any of "--- PASS: TestDocsTree", "--- PASS: TestDocsAIOutputsInSync" absent; or the output lacks "LLMS-OK".</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -c 'git checkout -- examples/hello/main.go' docs/setup/installation.md README.md` prints 0 for each file, `grep -c 'http.body_limits.default_bytes is required' docs/setup/installation.md` prints 0, and `grep -c 'Known issues' README.md` prints 0.
|
||||
- `grep -c 'summer new' docs/console/scaffolding.md docs/architecture/go-modules-and-workspaces.md docs/setup/installation.md` is at least 1 for each file.
|
||||
- `grep -c -- '--no-plugin' docs/console/scaffolding.md` and `grep -c -- '--no-git' docs/console/scaffolding.md` are at least 1.
|
||||
- `grep -c 'summercms.io/new' docs/site.yaml` is 1.
|
||||
- llms-full.txt from a fresh build contains the one-liner and `summer new`; llms.txt contains `summercms.io/new` through the llms note.
|
||||
- `grep -Eci 'fonoteka|p[lł]ytarium' docs/setup/installation.md docs/console/scaffolding.md docs/architecture/go-modules-and-workspaces.md README.md` prints 0 for each file.
|
||||
</acceptance_criteria>
|
||||
<done>Installation, architecture and console docs plus the root README lead with the one-command start and document `summer new` exactly as built; docs checks and llms outputs are green.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| Docs and landing page -> reader's shell | Readers copy and run the shown commands verbatim |
|
||||
| Gated terminal check -> local Docker | The check starts real containers and a server |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-14.3-20 | Tampering | one-liner shown on page and in docs | medium | mitigate | HTTPS URL with `curl -fsSL`, pinned verbatim by TestTerminalScript, terminal.test.ts and TestTerminalCommandsInPage; docs fence checked by the plan 03 Task 3 llms grep |
|
||||
| T-14.3-21 | Denial of Service | gated terminal check leftovers | low | mitigate | Unique COMPOSE_PROJECT_NAME, `t.Cleanup` with `docker compose down -v`, bounded serve poll that stops the server |
|
||||
| T-14.3-22 | Information Disclosure | docs naming a consuming application | low | mitigate | `docs:build --check` forbidden-name rule plus the acceptance grep on README.md |
|
||||
| T-14.3-SC | Tampering | npm/pip/cargo installs | high | mitigate | No new npm or Go packages; `pnpm generate` and `pnpm test` use the existing lockfile only |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
Task 1 (ungated site tests, pnpm generate and test, docs tree), Task 2 (gated six-command run, dev build, page drift guard), Task 3 (docs tests, docs:build --check, llms grep). Then `go vet ./... && go test ./... -short` in summercms.go and `go -C ../sm-summercmsio-app test ./... -count=1`.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- Roadmap success criterion 5: installation.md leads with the one-liner, go-modules-and-workspaces.md and the console docs cover `summer new`, llms outputs include the changes, docs checks pass.
|
||||
- D-19: the landing page "Get started" shows and copies the one-liner flow; TestTerminalCommands runs it end to end locally.
|
||||
- Release-gated items (site require bump to v0.1.13, redeploy, production acceptance) are left to the post-phase user steps listed in plan 04.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/14.3-one-command-app-scaffolding-summer-new-and-summercms-io-new/14.3-03-SUMMARY.md` when done.
|
||||
</output>
|
||||
</content>
|
||||
</invoke>
|
||||
@@ -0,0 +1,206 @@
|
||||
---
|
||||
phase: 14.3-one-command-app-scaffolding-summer-new-and-summercms-io-new
|
||||
plan: 03
|
||||
subsystem: docs
|
||||
tags: [docs, summer-new, bootstrap, landing-page, nuxt, terminal-check, llms]
|
||||
|
||||
requires:
|
||||
- phase: 14.3-01
|
||||
provides: "summer new with --framework, --no-plugin, --no-git; relative go.work paths from plugin:add"
|
||||
- phase: 14.3-02
|
||||
provides: "modules/sunrise new.sh, site GET /new, check-phase14.3.sh --acceptance-production"
|
||||
provides:
|
||||
- "docs/setup/installation.md led by curl -fsSL https://summercms.io/new | bash -s -- demo"
|
||||
- "go-modules-and-workspaces section Applications created by summer new"
|
||||
- "console scaffolding section Creating an application (flags, files, first commit, naming)"
|
||||
- "llms_notes entry for the one-command start; llms.txt and llms-full.txt carry it"
|
||||
- "landing page Get started: one-liner, compose, build, migrate, serve (vue 6140750)"
|
||||
- "terminal_check_test.go: terminalNewURL, SUMMERCMS_NEW_URL, terminalRunScript, reworked gated TestTerminalCommands (app d073cb9, 9539e90)"
|
||||
affects: [14.3-04 unit tests, v0.1.13 release, site redeploy]
|
||||
|
||||
actuals:
|
||||
tokens: 11499
|
||||
tasks: 3
|
||||
commits: 2
|
||||
plan_head_before: 0d117a3e57769f0b5082fb718e3d2fd755a9b406
|
||||
plan_head_after: e0640220f26fa1c25d3b1cc33e6c3e4ad5d5815b
|
||||
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns:
|
||||
- "Gated shell check rewrites a blocking serve line into a background run, a bounded poll of the starter route and a stop of the app binary (pkill -P) before summer itself"
|
||||
- "Unique COMPOSE_PROJECT_NAME plus t.Cleanup docker compose down -v in the directory the page's cd command names"
|
||||
|
||||
key-files:
|
||||
created:
|
||||
- .planning/phases/14.3-one-command-app-scaffolding-summer-new-and-summercms-io-new/14.3-03-SUMMARY.md
|
||||
modified:
|
||||
- docs/setup/installation.md
|
||||
- docs/architecture/go-modules-and-workspaces.md
|
||||
- docs/console/scaffolding.md
|
||||
- docs/console/introduction.md
|
||||
- docs/site.yaml
|
||||
- README.md
|
||||
- ../sm-summercmsio-app/vue-summercmsio-app/app/data/terminal.json
|
||||
- ../sm-summercmsio-app/vue-summercmsio-app/i18n/locales/en.json
|
||||
- ../sm-summercmsio-app/vue-summercmsio-app/tests/terminal.test.ts
|
||||
- ../sm-summercmsio-app/terminal_check_test.go
|
||||
- ../sm-summercmsio-app/README.md
|
||||
- ../sm-summercmsio-app/CLAUDE.md
|
||||
- ../sm-summercmsio-app/DEPLOY.md
|
||||
|
||||
key-decisions:
|
||||
- "The serve block stops the application binary first (pkill -P on the summer pid), because summer serve has no signal handling and killing summer alone would orphan the server on :8080"
|
||||
- "tests/output.test.ts needed no change: it reads terminal.json and en.json instead of pinning old command literals"
|
||||
- "The manual path in the installation guide uses go -C summercms install ./cmd/summer; verified on a fresh local clone without GOWORK=off"
|
||||
|
||||
patterns-established:
|
||||
- "A docs command line with summer new keeps flags after the name (summer new demo --framework <path>) so the docs command checker sees the command word"
|
||||
|
||||
requirements-completed: [D-01, D-18, D-19, D-20, D-24, D-28]
|
||||
|
||||
coverage:
|
||||
- id: D1
|
||||
description: "Landing page Get started shows and copies the six one-liner commands; every en.json string is rendered and no old terminal key remains"
|
||||
requirement: D-19
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "vue-summercmsio-app: pnpm generate && pnpm test (32 pass, 0 fail)"
|
||||
status: pass
|
||||
- kind: integration
|
||||
ref: "SUMMERCMS_REQUIRE_BUILD=1 go -C plugins/golem15/summercms test -run TestTerminalCommandsInPage (after scripts/build.sh dev)"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D2
|
||||
description: "Site checks pin the commands: TestLoadTerminal, TestTerminalScript (SUMMERCMS_NEW_URL rewrites only the curl command), TestTerminalEnv, TestTerminalRunScript"
|
||||
requirement: D-19
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "sm-summercmsio-app/terminal_check_test.go#TestLoadTerminal,TestTerminalScript,TestTerminalEnv,TestTerminalRunScript"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D3
|
||||
description: "The six page commands run verbatim against the local script and framework HEAD and the backgrounded summer serve answers with \"plugin\":\"golem15.demo\"; the compose project is removed"
|
||||
requirement: D-20
|
||||
verification:
|
||||
- kind: e2e
|
||||
ref: "SUMMERCMS_TERMINAL_CHECK=1 SUMMERCMS_NEW_URL=file://<fw>/modules/sunrise/new.sh SUMMERCMS_REPO=<fw> SUMMERCMS_REF=<HEAD> go test -run TestTerminalCommands"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D4
|
||||
description: "Installation, go modules and console docs plus the root README lead with the one-command start and document summer new as built; llms outputs include it"
|
||||
requirement: D-18
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "go test ./cmd/summer -run TestDocsTree|TestDocsBuildRealTree|TestDocsAIOutputsInSync|TestDocsRequiredPages|TestEveryModuleInSidebar"
|
||||
status: pass
|
||||
- kind: other
|
||||
ref: "summer docs:build --check; docs:build --out <tmp> then grep llms-full.txt and llms.txt (LLMS-OK)"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D5
|
||||
description: "Docs wording is accurate, readable and neutral (no consuming application named)"
|
||||
requirement: D-18
|
||||
verification:
|
||||
- kind: other
|
||||
ref: "grep -Eci 'fonoteka|p[lł]ytarium' on the four pages prints 0; docs:build --check forbidden-name rule"
|
||||
status: pass
|
||||
human_judgment: true
|
||||
rationale: "Prose quality and the correctness of every behavioural sentence need a human read; checks only prove identifiers, links and names"
|
||||
- id: D6
|
||||
description: "Site framework require stays at v0.1.12; nothing pushed, tagged or deployed"
|
||||
requirement: D-28
|
||||
verification:
|
||||
- kind: other
|
||||
ref: "grep -c v0.1.12 sm-summercmsio-app/go.mod = 1 before and after"
|
||||
status: pass
|
||||
human_judgment: true
|
||||
rationale: "Release, require bump and redeploy are user actions after the phase"
|
||||
|
||||
duration: 9min
|
||||
completed: 2026-10-08
|
||||
status: complete
|
||||
---
|
||||
|
||||
# Phase 14.3 Plan 03: Docs and landing page for the one-command start Summary
|
||||
|
||||
**The installation guide, the console and go-modules docs, the root README and llms outputs now lead with `curl -fsSL https://summercms.io/new | bash -s -- demo` and document `summer new` as built. The summercms.io Get started card shows the six new commands, and a gated site test runs those commands verbatim until the starter route answers.**
|
||||
|
||||
## Performance
|
||||
|
||||
- **Duration:** 9 min
|
||||
- **Started:** 2026-10-08T10:00:04Z
|
||||
- **Completed:** 2026-10-08T10:09:15Z
|
||||
- **Tasks:** 3
|
||||
- **Files modified:** 13 (6 in summercms.go, 4 in sm-summercmsio-app, 3 in vue-summercmsio-app)
|
||||
|
||||
## Accomplishments
|
||||
|
||||
- `terminal.json`: groups `terminal.createApp` (the one-liner), `terminal.startDatabase` (`cd sm-demo-app`, `docker compose up -d --wait`) and `terminal.buildAndServe` (`summer build`, `summer migrate`, `summer serve`). The matching en.json strings replace `getFramework`, `installCli` and `buildExample`.
|
||||
- `terminal_check_test.go`: `terminalNewURL` and the `SUMMERCMS_NEW_URL` override (curl command only), `terminalRunScript` (background serve, 60 s poll, print body, stop), `terminalCdDir`, and `TestTerminalCommands` with a unique compose project, `t.Cleanup` down -v and the `"plugin":"golem15.demo"` assertion.
|
||||
- Gated run on this machine: the six commands passed in 17.5 s with output `{"message":"Hello from SummerCMS","plugin":"golem15.demo"}`. No `summercms-terminal-*` container or volume was left behind, port 8080 was free afterwards and no orphaned app binary remained.
|
||||
- Framework docs: the installation guide has the one-command section, what the script does (steps, refusals, three overrides), running the app, `summer new` without the script, and then the examples/hello walkthrough. The obsolete known-issues callout is gone; `summer build` in examples/hello now leaves main.go unchanged. The go-modules page has "Applications created by summer new", and `plugin:add` now adds a relative go.work path. Scaffolding has "Creating an application", introduction and site.yaml carry the llms note, and the root README has a new quick start.
|
||||
|
||||
## Task Commits
|
||||
|
||||
1. **Task 1: one-liner through terminal.json, site checks, installation guide**: vue `6140750` (feat), app `d073cb9` (feat, with the gitlink bump), framework `9058252` (docs)
|
||||
2. **Task 2: end-to-end run of the page commands and site docs**: app `9539e90` (feat)
|
||||
3. **Task 3: framework docs for summer new and llms outputs**: framework `e064022` (docs)
|
||||
|
||||
Tracer gate after Task 1 (interactive, end-of-phase, automated-only verify): the verify chain was re-run green, so expansion continued.
|
||||
|
||||
## Files Created/Modified
|
||||
|
||||
- `docs/setup/installation.md`: one-command start first, then the manual path
|
||||
- `docs/architecture/go-modules-and-workspaces.md`: what summer new writes, the detection order and relative go.work paths
|
||||
- `docs/console/scaffolding.md`: Creating an application
|
||||
- `docs/console/introduction.md`: summer row and chapter list
|
||||
- `docs/site.yaml`: llms note
|
||||
- `README.md`: Requirements, Quick start, Using SummerCMS in an application
|
||||
- `sm-summercmsio-app/terminal_check_test.go`, `README.md`, `CLAUDE.md`, `DEPLOY.md`: the new check, its variables, `/new`, and step 4 of the deploy checklist
|
||||
- `vue-summercmsio-app/app/data/terminal.json`, `i18n/locales/en.json`, `tests/terminal.test.ts`: the landing page commands
|
||||
|
||||
## Decisions Made
|
||||
|
||||
- The serve stop kills summer's child (the app binary) before summer. `summer serve` delegates through `exec.CommandContext` and has no signal handler, so killing summer alone would leave the server running.
|
||||
- `tests/output.test.ts` was left unchanged because it derives everything from terminal.json and en.json.
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
### Auto-fixed Issues
|
||||
|
||||
**1. [Rule 1 - Bug] DEPLOY.md step 2 still referred to the page's `git clone` command**
|
||||
- **Found during:** Task 2
|
||||
- **Issue:** The page no longer shows a clone command, so step 2's reason for making the repository public was stale.
|
||||
- **Fix:** It now says the Source links and the bootstrap script behind the one-liner (which clones the repository) point at it.
|
||||
- **Files modified:** ../sm-summercmsio-app/DEPLOY.md
|
||||
- **Committed in:** 9539e90
|
||||
|
||||
**2. [Rule 3 - Blocking] Frontmatter descriptions over 160 characters**
|
||||
- **Found during:** Task 3
|
||||
- **Issue:** `TestDocsTree` rejected the longer descriptions on three pages.
|
||||
- **Fix:** Shortened them while keeping the summer new mention.
|
||||
- **Committed in:** e064022
|
||||
|
||||
---
|
||||
|
||||
**Total deviations:** 2 auto-fixed (1 bug, 1 blocking). **Impact:** None on scope.
|
||||
|
||||
## Issues Encountered
|
||||
|
||||
None.
|
||||
|
||||
## User Setup Required
|
||||
|
||||
None in this plan. After the phase the user tags v0.1.13, bumps the site's require, redeploys, runs DEPLOY.md step 4 with no overrides and runs `scripts/check-phase14.3.sh --acceptance-production` (plan 04 lists these).
|
||||
|
||||
## Next Phase Readiness
|
||||
|
||||
- Plan 04 (unit tests) can proceed. Docs and site already describe `summer new` and the one-liner exactly as implemented.
|
||||
- Nothing was pushed, tagged or deployed. The site go.mod still requires v0.1.12 (D-28).
|
||||
|
||||
---
|
||||
*Phase: 14.3-one-command-app-scaffolding-summer-new-and-summercms-io-new*
|
||||
*Completed: 2026-10-08*
|
||||
|
||||
## Self-Check: PASSED
|
||||
@@ -0,0 +1,312 @@
|
||||
---
|
||||
phase: 14.3-one-command-app-scaffolding-summer-new-and-summercms-io-new
|
||||
plan: 04
|
||||
type: execute
|
||||
wave: 4
|
||||
depends_on: ["14.3-03"]
|
||||
files_modified:
|
||||
- internal/build/new_golden_test.go
|
||||
- internal/build/new_test.go
|
||||
- internal/build/build_test.go
|
||||
- internal/build/testdata/new/go.mod.golden
|
||||
- internal/build/testdata/new/go.work.golden
|
||||
- internal/build/testdata/new/summer.yaml.golden
|
||||
- internal/build/testdata/new/main.go.golden
|
||||
- internal/build/testdata/new/plugins.gen.go.golden
|
||||
- internal/build/testdata/new/config__app.yaml.golden
|
||||
- internal/build/testdata/new/config__database.yaml.golden
|
||||
- internal/build/testdata/new/config__http.yaml.golden
|
||||
- internal/build/testdata/new/config__storage.yaml.golden
|
||||
- internal/build/testdata/new/dot-env.golden
|
||||
- internal/build/testdata/new/dot-env.example.golden
|
||||
- internal/build/testdata/new/dot-gitignore.golden
|
||||
- internal/build/testdata/new/compose.yaml.golden
|
||||
- internal/build/testdata/new/CLAUDE.md.golden
|
||||
- internal/build/testdata/new/plugins__demo__plugin.go.golden
|
||||
- internal/build/testdata/new/plugins__demo__routes.go.golden
|
||||
- internal/build/testdata/new/next-steps.txt.golden
|
||||
- internal/build/testdata/new-no-plugin/go.mod.golden
|
||||
- internal/build/testdata/new-no-plugin/go.work.golden
|
||||
- internal/build/testdata/new-no-plugin/summer.yaml.golden
|
||||
- internal/build/testdata/new-no-plugin/plugins.gen.go.golden
|
||||
- internal/build/testdata/new-no-plugin/CLAUDE.md.golden
|
||||
- internal/build/testdata/new-no-plugin/next-steps.txt.golden
|
||||
- modules/lagoon/keygen_test.go
|
||||
- cmd/summer/main_test.go
|
||||
- modules/sunrise/script_behavior_test.go
|
||||
- scripts/check-phase14.3.sh
|
||||
- .planning/phases/14.3-one-command-app-scaffolding-summer-new-and-summercms-io-new/14.3-SECURITY-REVIEW.md
|
||||
- .planning/phases/14.3-one-command-app-scaffolding-summer-new-and-summercms-io-new/14.3-VALIDATION.md
|
||||
autonomous: true
|
||||
requirements: [D-01, D-02, D-03, D-04, D-05, D-06, D-07, D-08, D-09, D-10, D-11, D-12, D-13, D-14, D-15, D-16, D-17, D-18, D-19, D-20, D-21, D-22, D-23, D-24, D-25, D-26, D-27, D-28]
|
||||
estimate:
|
||||
tokens: 150000
|
||||
raw_tokens: 150000
|
||||
tasks: 3
|
||||
confidence: low
|
||||
must_haves:
|
||||
truths:
|
||||
- "D-21: TestNewGolden renders `summer new demo` (fixed test key, --no-git, framework through a `summercms` symlink sibling) and every listed file matches its golden under internal/build/testdata/new byte for byte; TestNewGoldenNoPlugin does the same for --no-plugin; goldens change only with `-update`."
|
||||
- "D-21: the real build of a generated app (with and without the starter plugin) runs in TestNewAppSmoke and TestNewNoPluginBuilds and is skipped only under -short."
|
||||
- "D-02/D-22 error cases: TestNewValidateAppName rejects empty, upper-case, leading digit, punctuation, every Go keyword, main and internal, and accepts init, string, vendor, testdata, a1; TestNewRefusesExistingTarget covers a directory, a file, a symlink and a dangling symlink and proves each is untouched."
|
||||
- "D-04 error cases: TestNewFrameworkResolution proves --framework wins (and a mismatched module there is an error, not a fall-through), ../summercms before ../summercms.go, module-path matching skips a wrong sibling, the binary-source candidate is used last, and with none the error contains --framework; TestNewRefusesTargetInsideFramework refuses a target inside the checkout."
|
||||
- "Edge: TestNewCleansUpOnFailure forces a failure after the target was created and asserts the target is gone while a pre-existing sibling file is untouched."
|
||||
- "D-08/D-10/D-11: TestNewEnvFileMode (0600), TestNewAgentsSymlink (AGENTS.md -> CLAUDE.md), TestNewGitFirstCommit (one commit on main, fallback author SummerCMS <summer@localhost> only when no identity, .env untracked, .env.example/go.work/main.go/plugins.gen.go/AGENTS.md tracked), TestNewGitUsesConfiguredIdentity, TestNewGitMissingIsWarning and TestNewNoGit pass."
|
||||
- "D-27: TestMakeAndAddPluginOnHelloCopy asserts every go.work use path is relative; TestMakePluginRoutesUnchanged asserts plain make:plugin still renders the no-route stub; TestGenerateAppKey proves 44 characters, 32 decoded bytes and distinct values."
|
||||
- "D-14..D-17/D-26: TestScriptBehavior drives new.sh with only git and stubs on PATH and proves usage and name errors, existing app refusal, Go missing/old/devel handling, git required, Docker warning only, newest numeric release tag ignoring pre-releases, SUMMERCMS_REF, fast-forward re-runs, no-op when current, refusal of dirty, branch, non-fast-forward, moved-tag, non-framework and non-git checkouts with HEAD unchanged, summer invoked with `new demo --framework <abs>` from the parent with stdin not read, the cd line, and the PATH hint only when needed; TestScriptShellcheck passes."
|
||||
- "Gate: `bash scripts/check-phase14.3.sh --all` runs vet, full tests, examples/hello tests, docs check, site plugin/app/vue tests, the generator stage, the gated bootstrap and terminal runs, the D-20 acceptance stage, forbidden-surface scans and the security check, requires a named PASS for each required test, and ends with `Phase 14.3 gate passed`."
|
||||
- "14.3-SECURITY-REVIEW.md lists T-14.3-01..T-14.3-26 and T-14.3-SC, each high row mitigated with a named test or gate stage; 14.3-VALIDATION.md is validated and nyquist_compliant."
|
||||
prohibitions:
|
||||
- requirement_id: D-21
|
||||
category: transparency
|
||||
status: resolved
|
||||
verification: judgment
|
||||
resolution: flagged-unverified
|
||||
reason: "Gate requires named PASS lines; descriptor-less, human review recommended."
|
||||
statement: "The phase gate must never pass on a skipped, filtered-to-zero or -update test run."
|
||||
- requirement_id: D-28
|
||||
category: safety
|
||||
status: resolved
|
||||
verification: judgment
|
||||
resolution: flagged-unverified
|
||||
reason: "Release steps are listed for the user only; descriptor-less, human review recommended."
|
||||
statement: "No task pushes commits or tags, bumps the site's framework require, or deploys the site."
|
||||
- requirement_id: D-05
|
||||
category: safety
|
||||
status: resolved
|
||||
verification: judgment
|
||||
resolution: flagged-unverified
|
||||
reason: "Gate scans go.mod against v0.1.12 and the dependency graph; descriptor-less, human review recommended."
|
||||
statement: "The phase must add no Go module requirement and no runtime plugin loading."
|
||||
artifacts:
|
||||
- path: "internal/build/new_golden_test.go"
|
||||
provides: "golden harness with -update flag"
|
||||
contains: "TestNewGolden"
|
||||
- path: "internal/build/testdata/new/go.work.golden"
|
||||
provides: "pinned relative workspace"
|
||||
contains: "./plugins/demo"
|
||||
- path: "internal/build/new_test.go"
|
||||
provides: "generator error, git, file-mode and build tests"
|
||||
contains: "TestNewFrameworkResolution"
|
||||
- path: "modules/sunrise/script_behavior_test.go"
|
||||
provides: "bootstrap script behaviour matrix with stubs"
|
||||
contains: "TestScriptBehavior"
|
||||
- path: "scripts/check-phase14.3.sh"
|
||||
provides: "fail-closed phase gate"
|
||||
contains: "Phase 14.3 gate passed"
|
||||
- path: ".planning/phases/14.3-one-command-app-scaffolding-summer-new-and-summercms-io-new/14.3-VALIDATION.md"
|
||||
provides: "validated per-plan map"
|
||||
contains: "nyquist_compliant: true"
|
||||
key_links:
|
||||
- from: "internal/build/new_golden_test.go"
|
||||
to: "internal/build/new.go"
|
||||
via: "NewApp output compared with testdata/new goldens"
|
||||
pattern: "NewApp("
|
||||
- from: "modules/sunrise/script_behavior_test.go"
|
||||
to: "modules/sunrise/new.sh"
|
||||
via: "the test runs the embedded script bytes with bash on a stub PATH"
|
||||
pattern: "Script()"
|
||||
- from: "scripts/check-phase14.3.sh"
|
||||
to: ".planning/phases/14.3-one-command-app-scaffolding-summer-new-and-summercms-io-new/14.3-SECURITY-REVIEW.md"
|
||||
via: "gate fails on a missing threat ID or an open high threat"
|
||||
pattern: "T-14.3-"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Close Phase 14.3 with the dedicated unit-test plan (project rule 3, D-21): golden output for every file `summer new` writes, real builds in temp directories, the generator's error cases, the bootstrap script's behaviour matrix, a fail-closed phase gate, security evidence and validation sign-off, then hand the release steps to the user.
|
||||
|
||||
Purpose: make every locked decision observably fail when broken, before the framework is tagged `v0.1.13`.
|
||||
Output: golden harness and goldens, generator/CLI/lagoon/script tests, `scripts/check-phase14.3.sh --all`, updated security review and validation map, and the post-phase release checklist.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/gsd-core/workflows/execute-plan.md
|
||||
@~/.claude/gsd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/phases/14.3-one-command-app-scaffolding-summer-new-and-summercms-io-new/14.3-01-SUMMARY.md
|
||||
@.planning/phases/14.3-one-command-app-scaffolding-summer-new-and-summercms-io-new/14.3-02-SUMMARY.md
|
||||
@.planning/phases/14.3-one-command-app-scaffolding-summer-new-and-summercms-io-new/14.3-03-SUMMARY.md
|
||||
@.planning/phases/14.3-one-command-app-scaffolding-summer-new-and-summercms-io-new/14.3-VALIDATION.md
|
||||
@.planning/phases/14.3-one-command-app-scaffolding-summer-new-and-summercms-io-new/14.3-SECURITY-REVIEW.md
|
||||
@internal/build/new.go
|
||||
@internal/build/build_test.go
|
||||
@modules/sunrise/new.sh
|
||||
@scripts/check-phase14.3.sh
|
||||
</context>
|
||||
|
||||
## Spec-less probe fallback
|
||||
|
||||
No REQUIREMENTS.md IDs or SPEC.md exist; requirement probing is visibly skipped. Tests map directly to D-01..D-28, the six roadmap success criteria, the edge predicates and prohibitions carried in plans 01-03, and the threat register T-14.3-01..T-14.3-26.
|
||||
|
||||
## Artifacts this phase produces (this plan)
|
||||
|
||||
- Golden harness: `var update = flag.Bool("update", false, "rewrite golden files")` in package build tests; goldens under `internal/build/testdata/new/` and `internal/build/testdata/new-no-plugin/`, file names derived from the relative path with `/` replaced by `__` and a leading `.` replaced by `dot-` (keeps them clear of the framework's `.env*` ignore rules).
|
||||
- Tests: `TestNewGolden`, `TestNewGoldenNoPlugin`, `TestNewNoPluginBuilds`, `TestNewValidateAppName`, `TestNewRefusesExistingTarget`, `TestNewFrameworkResolution`, `TestNewRefusesTargetInsideFramework`, `TestNewCleansUpOnFailure`, `TestNewEnvFileMode`, `TestNewAgentsSymlink`, `TestNewGitFirstCommit`, `TestNewGitUsesConfiguredIdentity`, `TestNewGitMissingIsWarning`, `TestNewNoGit`, `TestNewRequiresValidAppKey`, `TestMakePluginRoutesUnchanged`, `TestGenerateAppKey`, `TestNewCommandViaCLI`, `TestScriptBehavior`, `TestScriptShellcheck`.
|
||||
- Gate stages `--unit` and `--all` in `scripts/check-phase14.3.sh` (final line `Phase 14.3 gate passed`).
|
||||
- Updated `14.3-SECURITY-REVIEW.md` (T-14.3-01..26 + SC with test evidence) and `14.3-VALIDATION.md` (validated).
|
||||
- Post-phase release checklist (user actions) below.
|
||||
|
||||
## Multi-source coverage audit
|
||||
|
||||
| SOURCE | ID | Feature/Requirement | Plan | Status | Notes |
|
||||
|---|---|---|---|---|---|
|
||||
| GOAL | — | One command starts an app: `summer new` + `curl … /new \| bash`, shipped as v0.1.13 | 01-04 | COVERED | Tag itself is a post-phase user action (D-28) |
|
||||
| REQ | — | No REQUIREMENTS.md IDs mapped | — | COVERED | Visible spec-less fallback; D-IDs used |
|
||||
| CONTEXT | D-01 | sm-<name>-app/-plugin/-theme convention in CLAUDE.md | 01, 03 | COVERED | Template + scaffolding docs |
|
||||
| CONTEXT | D-02 | Name regex, refuse existing dir | 01, 04 | COVERED | |
|
||||
| CONTEXT | D-03 | go.mod module, go 1.27.0, relative replace | 01, 04 | COVERED | Golden go.mod |
|
||||
| CONTEXT | D-04 | --framework > siblings by module path > binary source > error | 01, 04 | COVERED | |
|
||||
| CONTEXT | D-05 | Starter plugin via make:plugin/plugin:add, go.work, HTTP route | 01, 04 | COVERED | StarterRoute option, one template |
|
||||
| CONTEXT | D-06 | --no-plugin (empty list builds) | 01, 04 | COVERED | |
|
||||
| CONTEXT | D-07 | Complete config/, hello http.yaml | 01, 04 | COVERED | |
|
||||
| CONTEXT | D-08 | .env.example, .env with lagoon key, .gitignore | 01, 04 | COVERED | |
|
||||
| CONTEXT | D-09 | compose.yaml PostgreSQL 16 matching .env | 01, 04 | COVERED | |
|
||||
| CONTEXT | D-10 | CLAUDE.md + AGENTS.md | 01, 04 | COVERED | Symlink |
|
||||
| CONTEXT | D-11 | git init + first commit, --no-git, no-identity fallback | 01, 04 | COVERED | |
|
||||
| CONTEXT | D-12 | build/migrate/serve with no edits | 01 | COVERED | --generator gate stage |
|
||||
| CONTEXT | D-13 | Script in framework package, served at GET /new text/plain | 02 | COVERED | |
|
||||
| CONTEXT | D-14 | main() wrapper | 02, 04 | COVERED | Truncation tests |
|
||||
| CONTEXT | D-15 | Prerequisites, clone/update, install + PATH hint, summer new, next steps | 02, 04 | COVERED | |
|
||||
| CONTEXT | D-16 | Latest release tag, ff-only, refuse dirty/branch | 02, 04 | COVERED | |
|
||||
| CONTEXT | D-17 | Clone location and env overrides | 02, 04 | COVERED | |
|
||||
| CONTEXT | D-18 | Docs, llms, module READMEs | 01, 02, 03 | COVERED | lagoon README in 01, sunrise README in 02 |
|
||||
| CONTEXT | D-19 | Landing page one-liner + TestTerminalCommands | 03 | COVERED | Redeploy is post-phase |
|
||||
| CONTEXT | D-20 | Container acceptance | 02, 04 | COVERED | Production variant post-deploy |
|
||||
| CONTEXT | D-21 | Golden + real build + error cases, tests last | 04 | COVERED | |
|
||||
| CONTEXT | D-22 | Keyword/main/internal denylist | 01, 04 | COVERED | |
|
||||
| CONTEXT | D-23 | Fixed non-5432 loopback port | 01, 04 | COVERED | 127.0.0.1:54320 |
|
||||
| CONTEXT | D-24 | summer new prints next steps; script prints cd + PATH hint | 01, 02, 04 | COVERED | |
|
||||
| CONTEXT | D-25 | No root go.sum; go.work.sum ignored | 01, 04 | COVERED | |
|
||||
| CONTEXT | D-26 | modules/<pkg> embeds script; SUMMERCMS_* overrides | 02, 04 | COVERED | modules/sunrise |
|
||||
| CONTEXT | D-27 | Relative go.work, starter route option, GenerateAppKey, hello http.yaml | 01, 04 | COVERED | |
|
||||
| CONTEXT | D-28 | Release ordering | 02, 03, 04 | COVERED | Post-phase checklist |
|
||||
| RESEARCH | — | Pitfalls 1-14 (absolute go.work, ancestor go.work, keyword names, partial dir, git identity, pipefail, prompts, GOVERSION, GOBIN, Postgres readiness, docker socket, safe.directory, pre-tag ref, release coupling) | 01-04 | COVERED | |
|
||||
| RESEARCH | — | Security Domain (V5, V6, V10, V12, V14) | 01, 02, 04 | COVERED | 14.3-SECURITY-REVIEW.md |
|
||||
|
||||
Excluded by explicit scope: `summer new --theme` / sm-<name>-theme scaffolding, prebuilt release binaries, standalone plugin scaffolding beyond make:plugin, Windows support.
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tracer">
|
||||
<name>Task 1: Golden harness pins every file summer new writes</name>
|
||||
<files>internal/build/new_golden_test.go, internal/build/testdata/new/go.mod.golden, internal/build/testdata/new/go.work.golden, internal/build/testdata/new/summer.yaml.golden, internal/build/testdata/new/main.go.golden, internal/build/testdata/new/plugins.gen.go.golden, internal/build/testdata/new/config__app.yaml.golden, internal/build/testdata/new/config__database.yaml.golden, internal/build/testdata/new/config__http.yaml.golden, internal/build/testdata/new/config__storage.yaml.golden, internal/build/testdata/new/dot-env.golden, internal/build/testdata/new/dot-env.example.golden, internal/build/testdata/new/dot-gitignore.golden, internal/build/testdata/new/compose.yaml.golden, internal/build/testdata/new/CLAUDE.md.golden, internal/build/testdata/new/plugins__demo__plugin.go.golden, internal/build/testdata/new/plugins__demo__routes.go.golden, internal/build/testdata/new/next-steps.txt.golden, internal/build/testdata/new-no-plugin/go.mod.golden, internal/build/testdata/new-no-plugin/go.work.golden, internal/build/testdata/new-no-plugin/summer.yaml.golden, internal/build/testdata/new-no-plugin/plugins.gen.go.golden, internal/build/testdata/new-no-plugin/CLAUDE.md.golden, internal/build/testdata/new-no-plugin/next-steps.txt.golden</files>
|
||||
<read_first>internal/build/new.go, internal/build/new_test.go (TestNewAppSmoke helper for the symlinked framework), internal/build/stubs/new.tmpl, internal/build/build_test.go (copyHelloApp, execGo), .gitignore (framework ignore rules: .env, .env.*, *.out, coverage.*), .planning/phases/14.3-one-command-app-scaffolding-summer-new-and-summercms-io-new/14.3-VALIDATION.md (Wave 0 requirements)</read_first>
|
||||
<action>Introduce the golden infrastructure (none exists yet) and pin the D-03, D-05, D-07..D-10, D-23..D-25 output.
|
||||
|
||||
In `internal/build/new_golden_test.go` declare `var update = flag.Bool("update", false, "rewrite golden files")` and a shared helper that creates a temp parent with a `summercms` symlink to the repository root (so the replace is deterministically `../summercms`) and runs `NewApp` with name `demo`, a fixed test key (base64 of the 32 bytes 0x00..0x1f), `NoGit: true`, capturing `out`. `TestNewGolden` compares these files of the result with `testdata/new/<golden name>`: go.mod, go.work, summer.yaml, main.go, plugins.gen.go, config/app.yaml, config/database.yaml, config/http.yaml, config/storage.yaml, .env, .env.example, .gitignore, compose.yaml, CLAUDE.md, plugins/demo/plugin.go, plugins/demo/routes.go, plus the captured output with the temp parent path replaced by `<parent>` as `next-steps.txt`. Golden names: relative path with `/` replaced by `__` and a leading `.` replaced by `dot-`. Excluded and asserted instead: AGENTS.md (symlink, Task 2), plugins/demo/go.mod and go.sum (dependency versions drift; assert the plugin go.mod contains `replace git.golem15.com/golem15/summercms => ../../../summercms`). `TestNewGoldenNoPlugin` does the same with `NoPlugin: true` against `testdata/new-no-plugin/` for go.mod, go.work, summer.yaml, plugins.gen.go, CLAUDE.md and next-steps.txt. With `-update` the tests rewrite the goldens and still fail-safe compare on the next run; without it a mismatch prints a unified-style diff of the first differing lines and the golden path. Both tests run `go mod tidy` through makePlugin (module cache) and are not -short gated; they never build. Generate the goldens with `-update` once, review every golden by eye against D-03 (relative replace, go 1.27.0, toolchain), D-23 (`127.0.0.1:54320:5432`), D-25 (.gitignore entries, no go.sum) and D-10 (llms URLs, naming convention, neutral text), and commit tests and goldens together.</action>
|
||||
<verify>
|
||||
<automated>go test ./internal/build/ -run '^(TestNewGolden|TestNewGoldenNoPlugin)$' -count=1 -v && bash -c 'set -e; ! grep -nE "^\s*/|=> /" internal/build/testdata/new/go.mod.golden internal/build/testdata/new/go.work.golden internal/build/testdata/new-no-plugin/go.mod.golden internal/build/testdata/new-no-plugin/go.work.golden; git check-ignore -q internal/build/testdata/new/dot-env.golden && exit 1; echo GOLDEN-OK'</automated>
|
||||
<fails_when>Non-zero exit; any "--- FAIL"; "no tests to run"; "--- PASS: TestNewGolden" or "--- PASS: TestNewGoldenNoPlugin" absent; an absolute path printed from a golden go.mod/go.work; or the output lacks "GOLDEN-OK".</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- Every file in this task's file list exists and is tracked by git: `git ls-files internal/build/testdata` prints exactly the 23 golden paths listed above.
|
||||
- `grep -c './plugins/demo' internal/build/testdata/new/go.work.golden` is 1 and `grep -c 'replace git.golem15.com/golem15/summercms => ../summercms' internal/build/testdata/new/go.mod.golden` is 1.
|
||||
- `grep -c '127.0.0.1:54320:5432' internal/build/testdata/new/compose.yaml.golden` is 1 and `grep -c 'go.work.sum' internal/build/testdata/new/dot-gitignore.golden` is 1.
|
||||
- `grep -c 'https://summercms.io/docs/llms.txt' internal/build/testdata/new/CLAUDE.md.golden` is 1.
|
||||
- Changing one byte of a golden makes `TestNewGolden` fail (checked once by hand, then restored).
|
||||
</acceptance_criteria>
|
||||
<done>Every file `summer new` writes for the default and `--no-plugin` variants is pinned byte for byte, with an explicit `-update` path.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Generator, CLI and key error-case and behaviour matrix</name>
|
||||
<files>internal/build/new_test.go, internal/build/build_test.go, modules/lagoon/keygen_test.go, cmd/summer/main_test.go</files>
|
||||
<read_first>internal/build/new.go (ValidateAppName, resolveFramework, frameworkFromBinary, cleanup, git step), internal/build/new_test.go, internal/build/build_test.go (TestMakeAndAddPluginOnHelloCopy, TestScaffoldPluginSmoke), modules/lagoon/keygen.go, modules/lagoon/keygen_test.go, cmd/summer/main.go (newCommand), cmd/summer/main_test.go (TestToolCommandNames, copyHelloApp, t.Chdir use), .planning/phases/14.3-one-command-app-scaffolding-summer-new-and-summercms-io-new/14.3-RESEARCH.md (Pitfalls 3, 4, 5)</read_first>
|
||||
<action>Cover every generator branch from plan 01 with named tests (D-21: stdlib testing, -short gating only for real builds).
|
||||
|
||||
internal/build/new_test.go: `TestNewValidateAppName` table (reject "", "Demo", "1x", "a-b", "a.b", "a/b", every keyword from the Go spec list via `go/token`, "main", "internal"; accept "init", "string", "vendor", "testdata", "a1"; rejected keywords' errors mention keyword). `TestNewRefusesExistingTarget` (existing directory with a file inside, regular file, symlink to a directory, dangling symlink: each errors and the entry is unchanged afterwards). `TestNewFrameworkResolution` (replace `frameworkFromBinary` for the test: flag wins over siblings; a flag pointing at a go.mod with another module errors and does not fall through; `summercms` is preferred over `summercms.go`; a sibling whose go.mod declares another module is skipped; with no siblings the stubbed binary source is used; with nothing the error contains `--framework` and no target remains). `TestNewRefusesTargetInsideFramework`. `TestNewCleansUpOnFailure` (framework flag at a temp dir whose go.mod only declares the framework module, so the starter plugin's go mod tidy fails; assert an error, no `sm-demo-app`, and a sibling file created before the run untouched). `TestNewRequiresValidAppKey` (empty and short keys rejected before anything is written). `TestNewEnvFileMode` (0600). `TestNewAgentsSymlink` (`os.Readlink` gives `CLAUDE.md`). `TestNewGitFirstCommit` (HOME and XDG_CONFIG_HOME at temp dirs, `GIT_CONFIG_NOSYSTEM=1`, `GIT_CONFIG_GLOBAL=/dev/null`: one commit, branch main, author `SummerCMS <summer@localhost>`, `git config user.name` inside the repo still unset afterwards, `git ls-files` lists .env.example, go.work, main.go, plugins.gen.go, AGENTS.md and not .env). `TestNewGitUsesConfiguredIdentity` (GIT_AUTHOR_NAME/EMAIL and GIT_COMMITTER_NAME/EMAIL set: author taken from them). `TestNewGitMissingIsWarning` (PATH holding only a symlink to the real go binary: warning in output, app present, no .git). `TestNewNoGit`. `TestNewNoPluginBuilds` (skip under -short: `App` succeeds, plugins.gen.go has an empty PluginIDs). Keep `TestNewAppSmoke` as the real build with the starter plugin.
|
||||
|
||||
internal/build/build_test.go: in `TestMakeAndAddPluginOnHelloCopy` assert every `use` path in go.work is relative (no line whose first non-blank character is `/`) and `./plugins/demo` is present; add `TestMakePluginRoutesUnchanged` asserting a plain make:plugin routes.go contains `func (p *Plugin) Routes(pact.Router) error { return nil }` and no `net/http` import.
|
||||
|
||||
modules/lagoon/keygen_test.go: `TestGenerateAppKey` (44 characters, StdEncoding decodes to 32 bytes, two calls differ); keep the existing key:generate test.
|
||||
|
||||
cmd/summer/main_test.go: extend helpWants with `"new": {"--framework", "--no-plugin", "--no-git", "name"}`; add `TestNewCommandViaCLI` (t.Chdir into a temp parent holding a `summercms` symlink, run `new demo --no-git` through `bonfire.NewRoot("summer", toolCommands(), &buf)`, assert the app exists, `.env` carries a 44-character `SUMMER_APP__KEY`, output lists the next steps; `new` with no argument and `new Demo` both error).
|
||||
|
||||
Commit as one test commit.</action>
|
||||
<verify>
|
||||
<automated>go vet ./internal/build/ ./cmd/summer/ ./modules/lagoon/ && go test ./internal/build/ -run '^(TestNew|TestMakeAndAddPluginOnHelloCopy$|TestMakePluginRoutesUnchanged$)' -count=1 -v && go test ./modules/lagoon/ -run '^(TestGenerateAppKey|TestEncryptedKeyGeneratePrints32ByteBase64)$' -count=1 -v && go test ./cmd/summer/ -run '^(TestToolCommandNames|TestNewCommandViaCLI)$' -count=1 -v</automated>
|
||||
<fails_when>Non-zero exit; any "--- FAIL"; "--- SKIP" for any TestNew test (this run is not -short); "no tests to run"; or any of "--- PASS: TestNewFrameworkResolution", "--- PASS: TestNewCleansUpOnFailure", "--- PASS: TestNewGitFirstCommit", "--- PASS: TestNewNoPluginBuilds", "--- PASS: TestMakePluginRoutesUnchanged", "--- PASS: TestGenerateAppKey", "--- PASS: TestNewCommandViaCLI" is absent.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- Each test named in this task's action exists (`grep -c 'func TestNew' internal/build/new_test.go` is at least 13).
|
||||
- `go test ./internal/build/ -short -run TestNew -v` skips only TestNewAppSmoke and TestNewNoPluginBuilds.
|
||||
- TestNewGitFirstCommit proves the fallback identity is used for the first commit only and that .env is untracked.
|
||||
- TestNewCleansUpOnFailure proves only the directory created by the run is removed.
|
||||
- `go vet ./...` and `go test ./... -short` pass.
|
||||
</acceptance_criteria>
|
||||
<done>Every generator branch (validation, refusal, resolution order, cleanup, key, file modes, symlink, git paths, no-plugin) and the shared-path fixes have named, passing tests.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: Bootstrap script behaviour matrix, fail-closed phase gate, security evidence and validation sign-off</name>
|
||||
<files>modules/sunrise/script_behavior_test.go, scripts/check-phase14.3.sh, .planning/phases/14.3-one-command-app-scaffolding-summer-new-and-summercms-io-new/14.3-SECURITY-REVIEW.md, .planning/phases/14.3-one-command-app-scaffolding-summer-new-and-summercms-io-new/14.3-VALIDATION.md</files>
|
||||
<read_first>modules/sunrise/new.sh, modules/sunrise/sunrise_test.go (stub PATH harness from plan 02), scripts/check-phase14.3.sh (--generator, --acceptance stages), scripts/check-phase14.2.1.sh (named-PASS detection, --all pattern), .planning/phases/14.3-one-command-app-scaffolding-summer-new-and-summercms-io-new/14.3-SECURITY-REVIEW.md, .planning/phases/14.3-one-command-app-scaffolding-summer-new-and-summercms-io-new/14.3-VALIDATION.md, .planning/phases/14.3-one-command-app-scaffolding-summer-new-and-summercms-io-new/14.3-03-PLAN.md (threats T-14.3-20..22), ../sm-summercmsio-app/terminal_check_test.go</read_first>
|
||||
<action>Finish phase coverage and the gate.
|
||||
|
||||
`modules/sunrise/script_behavior_test.go`, `TestScriptBehavior` table: write `Script()` to a temp file and run it with `/bin/bash` (absolute), a fresh temp working directory, and PATH set to a temp tools directory that contains only a symlink to the real `git` plus stub `go` and `docker` scripts (the plan 02 script uses no other external commands). The `go` stub answers `env GOVERSION` from a test variable, `env GOBIN` and `env GOPATH` from temp paths, and for `-C <dir> install ./cmd/summer` writes a stub `summer` into GOBIN that records its working directory, arguments and whatever it reads from stdin. A helper builds a bare "remote" framework repository with go.mod `module git.golem15.com/golem15/summercms` and tags v0.1.0, v0.2.0, v0.10.0 on successive commits and v1.0.0-rc1 on a later one. Cases: no argument (exit 2, usage, no stub call); `Demo` (exit 2); existing `sm-demo-app` (exit 1, nothing cloned); Go missing (exit 1, stderr names https://go.dev/dl/, nothing cloned); `go1.26.3` (exit 1, hint); `devel` version (warning, success); git missing (exit 1); Docker missing (warning, success); fresh clone (detached at v0.10.0, summer called with `new demo --framework <abs>/summercms` from `<abs>`, stdin not read even though the test pipes data into the script, output contains `cd <abs>/sm-demo-app`); `SUMMERCMS_REF=v0.2.0`; re-run after the remote gains v0.11.0 (moves forward); re-run at the newest tag (no change, success); tracked change in the checkout (exit 1, file content unchanged); checkout on a branch (exit 1); detached on a commit that is not an ancestor of the newest tag (exit 1, HEAD unchanged); remote tag v0.10.0 deleted and recreated on another commit while the local tag exists (exit non-zero, HEAD unchanged); existing directory with another module's go.mod (exit 1); existing directory that is not a git repository (exit 1); GOBIN not on PATH (hint printed) versus on PATH (no hint). Add `TestScriptShellcheck` running `shellcheck -s bash` on new.sh, skipped only when shellcheck is not installed.
|
||||
|
||||
Gate: add to `scripts/check-phase14.3.sh` the `--unit` stage (go vet ./...; go test ./... -count=1 -v saved to a log; `go -C examples/hello test ./... -count=1`; `go run ./cmd/summer docs:build --check`; `go -C ../sm-summercmsio-app/plugins/golem15/summercms test ./... -count=1`; `go -C ../sm-summercmsio-app test ./... -count=1`; `pnpm -C ../sm-summercmsio-app/vue-summercmsio-app generate` and `test`; a required list of named tests that must each print `--- PASS:` in the log: TestNewGolden, TestNewGoldenNoPlugin, TestNewAppSmoke, TestNewNoPluginBuilds, TestNewValidateAppName, TestNewRefusesExistingTarget, TestNewFrameworkResolution, TestNewRefusesTargetInsideFramework, TestNewCleansUpOnFailure, TestNewRequiresValidAppKey, TestNewEnvFileMode, TestNewAgentsSymlink, TestNewGitFirstCommit, TestNewGitUsesConfiguredIdentity, TestNewGitMissingIsWarning, TestNewNoGit, TestMakeAndAddPluginOnHelloCopy, TestMakePluginRoutesUnchanged, TestGenerateAppKey, TestNewCommandViaCLI, TestToolCommandNames, TestScriptEmbedded, TestScriptShape, TestScriptTruncationRunsNothing, TestScriptBehavior, TestScriptShellcheck, TestDocsTree, TestDocsAIOutputsInSync; any `--- SKIP` of a required test or a `no tests to run` line fails the stage) and the forbidden-surface scan (new.sh contains no name with the framework config prefix; `git diff v0.1.12 -- go.mod` adds no require line; `go list -deps ./...` contains neither the stdlib `plugin` package nor yaegi; golden go.mod/go.work contain no absolute path; the framework .gitignore does not ignore any testdata golden). Add `--all`: `--unit`, `--generator`, the gated runs `SUMMERCMS_BOOTSTRAP_CHECK=1 go test ./modules/sunrise/ -run '^TestBootstrapEndToEnd$'` and the site's `TestTerminalCommands` with the local SUMMERCMS_NEW_URL/REPO/REF overrides (each requiring its named PASS), `--acceptance`, and the security check (every T-14.3-01..26 and T-14.3-SC row present, no high row with status open, every high row names a test or gate stage); end with `Phase 14.3 gate passed`.
|
||||
|
||||
Security review: add rows T-14.3-20..22 (plan 03) and T-14.3-23..26 (this plan's register) and fill the test column of every row with the named test or gate stage that now pins it; statuses stay `mitigated` or `accepted`. Validation: replace `<pkg>` with `sunrise`, map each per-plan row to the exact test names and commands above, set frontmatter `status: validated`, `nyquist_compliant: true`, `wave_0_complete: true` only after `--all` passes, and keep the manual-only row for the production run with the commands from the release checklist. Commit the test and gate as code commits and the two planning documents as a separate docs commit. Do not push, tag or deploy.</action>
|
||||
<verify>
|
||||
<automated>go test ./modules/sunrise/ -run '^(TestScriptBehavior|TestScriptShellcheck)$' -count=1 -v && bash scripts/check-phase14.3.sh --all</automated>
|
||||
<fails_when>Non-zero exit; any "--- FAIL"; "--- SKIP: TestScriptShellcheck" while shellcheck is installed; "no tests to run"; a required test without its "--- PASS:" line; an open high threat; or the output lacks the final line "Phase 14.3 gate passed".</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -c 'func TestScriptBehavior' modules/sunrise/script_behavior_test.go` is 1 and the table has at least 18 cases.
|
||||
- `grep -c -- '--all' scripts/check-phase14.3.sh` is at least 1 and `grep -c 'Phase 14.3 gate passed' scripts/check-phase14.3.sh` is 1.
|
||||
- 14.3-SECURITY-REVIEW.md contains T-14.3-01 through T-14.3-26 and T-14.3-SC; every high row is `mitigated` with a named test or stage.
|
||||
- 14.3-VALIDATION.md frontmatter has `status: validated`, `nyquist_compliant: true`, `wave_0_complete: true`, and no `<pkg>` placeholder remains.
|
||||
- `bash scripts/check-phase14.3.sh --all` ends with `Phase 14.3 gate passed`.
|
||||
</acceptance_criteria>
|
||||
<done>The bootstrap script's refusal and selection paths are tested, one fail-closed gate proves the whole phase across summercms.go and the site, and the security review and validation map are signed off.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
## Post-phase release checklist (user actions, not executed by this plan)
|
||||
|
||||
Per D-19 and D-28 these run after the phase is verified; they are listed here so they are not lost:
|
||||
|
||||
1. Review and push summercms.go `master`.
|
||||
2. Tag the framework: `git tag -a v0.1.13 -m "v0.1.13"` on the commit that holds all Phase 14.3 framework changes, then `git push origin v0.1.13`. One-way: module proxies cache the version, a defect afterwards ships as v0.1.14.
|
||||
3. In `sm-summercmsio-app`: bump `require git.golem15.com/golem15/summercms` to `v0.1.13` in `go.mod` and `plugins/golem15/summercms/go.mod`, update the `v0.1.12` references in DEPLOY.md and CLAUDE.md, commit in the plugin submodule and the app, push the submodules then the app (`ssu`).
|
||||
4. `scripts/build.sh` (release) and deploy per `sm-summercmsio-app/DEPLOY.md`.
|
||||
5. Verify in production: `curl -fsSI https://summercms.io/new` shows `200` and `text/plain; charset=utf-8`; `SUMMERCMS_TERMINAL_CHECK=1 go test -run TestTerminalCommands -count=1 -v .` in `sm-summercmsio-app` with no overrides; `bash scripts/check-phase14.3.sh --acceptance-production` in summercms.go (the final D-20 run against https://summercms.io/new).
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| Tests and gate -> phase completion claim | A skipped, filtered or self-updating test must not count as evidence |
|
||||
| Tests -> host Docker and filesystem | Gate stages start containers and write temp files |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-14.3-23 | Repudiation | phase gate | high | mitigate | Required named `--- PASS:` lines; any required SKIP or "no tests to run" fails; final marker line |
|
||||
| T-14.3-24 | Tampering | golden files | medium | mitigate | Goldens rewritten only with explicit `-update`, never by the gate; goldens reviewed and committed with the tests |
|
||||
| T-14.3-25 | Information Disclosure | fixed test key in goldens | low | accept | Deterministic non-secret test value (bytes 0x00..0x1f), never used outside tests |
|
||||
| T-14.3-26 | Denial of Service | Docker resources from gate stages | low | mitigate | Unique compose project names, traps with `docker compose down -v`, host-side label cleanup |
|
||||
| T-14.3-SC | Tampering | npm/pip/cargo installs | high | mitigate | No package installs; gate fails when go.mod gains a require line versus v0.1.12 |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
`bash scripts/check-phase14.3.sh --all` is the authoritative command and must end with `Phase 14.3 gate passed`. Then `go vet ./... && go test ./... -short` once more at the final commit.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- Roadmap success criterion 6: generator golden tests and real builds of the scaffold in temp directories exist and pass, as the last plan.
|
||||
- Every D-01..D-28 decision maps to a named test, gate stage or the post-phase checklist.
|
||||
- No open high threat; validation signed off; nothing pushed, tagged or deployed.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/14.3-one-command-app-scaffolding-summer-new-and-summercms-io-new/14.3-04-SUMMARY.md` when done.
|
||||
</output>
|
||||
</content>
|
||||
</invoke>
|
||||
@@ -0,0 +1,235 @@
|
||||
---
|
||||
phase: 14.3-one-command-app-scaffolding-summer-new-and-summercms-io-new
|
||||
plan: 04
|
||||
subsystem: testing
|
||||
tags: [golden-tests, summer-new, bootstrap-script, phase-gate, security-review, validation]
|
||||
|
||||
requires:
|
||||
- phase: 14.3-01
|
||||
provides: "build.NewApp, planNewApp, frameworkFromBinary seam, --generator gate stage"
|
||||
- phase: 14.3-02
|
||||
provides: "modules/sunrise new.sh and Script(), --acceptance stage, refusal probe, 14.3-SECURITY-REVIEW.md"
|
||||
- phase: 14.3-03
|
||||
provides: "site TestTerminalCommands with SUMMERCMS_NEW_URL/REPO/REF overrides, docs"
|
||||
provides:
|
||||
- "internal/build/new_golden_test.go: -update flag, 17 goldens for the default app and 6 for --no-plugin"
|
||||
- "18 generator/CLI/key tests (TestNew*, TestMakePluginRoutesUnchanged, TestGenerateAppKey, TestNewCommandViaCLI)"
|
||||
- "TestScriptBehavior (25 cases on a stub PATH against a bare fake framework remote) and TestScriptShellcheck"
|
||||
- "scripts/check-phase14.3.sh --unit, --forbidden, --bootstrap, --terminal, --security, --all"
|
||||
- "14.3-SECURITY-REVIEW.md with T-14.3-01..26 + SC pinned; 14.3-VALIDATION.md validated"
|
||||
affects: [v0.1.13 release, site require bump and redeploy, /gsd-verify-work 14.3]
|
||||
|
||||
actuals:
|
||||
tokens: 30074
|
||||
tasks: 3
|
||||
commits: 6
|
||||
plan_head_before: 9f56dc7eff3a569dae61496fb8997f9f8d18bde8
|
||||
plan_head_after: c4c91f0f6b41610be8db25bfcbf1eccc1cf788b8
|
||||
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns:
|
||||
- "Golden files named from the app-relative path ('/' -> '__', leading '.' -> 'dot-') so .env goldens stay clear of the framework's .env ignore rules"
|
||||
- "Script behaviour harness: real git through a symlink, bash stubs for go/docker/summer recording calls, cwd, args and stdin; a bare remote fed from a work clone so tags can move"
|
||||
- "Gate stages run as separate processes from --all so each stage's EXIT trap (Docker cleanup) fires before the next stage"
|
||||
- "Tests that make git commits in temp dirs disable detached auto maintenance (GIT_CONFIG_COUNT maintenance.auto=false, gc.auto=0)"
|
||||
|
||||
key-files:
|
||||
created:
|
||||
- internal/build/new_golden_test.go
|
||||
- internal/build/testdata/new/ (17 goldens)
|
||||
- internal/build/testdata/new-no-plugin/ (6 goldens)
|
||||
- modules/sunrise/script_behavior_test.go
|
||||
modified:
|
||||
- internal/build/new_test.go
|
||||
- internal/build/build_test.go
|
||||
- modules/lagoon/keygen_test.go
|
||||
- cmd/summer/main_test.go
|
||||
- scripts/check-phase14.3.sh
|
||||
- .planning/phases/14.3-one-command-app-scaffolding-summer-new-and-summercms-io-new/14.3-SECURITY-REVIEW.md
|
||||
- .planning/phases/14.3-one-command-app-scaffolding-summer-new-and-summercms-io-new/14.3-VALIDATION.md
|
||||
|
||||
key-decisions:
|
||||
- "Golden app key is base64 of bytes 0x00..0x1f (goldenAppKey); the existing testAppKey stays for the other tests"
|
||||
- "Goldens use framework auto-detection through a summercms sibling symlink, so the golden replace is ../summercms and the sibling path is exercised"
|
||||
- "TestGenerateAppKeyIs32ByteBase64 renamed to TestGenerateAppKey (the name the gate requires); body unchanged"
|
||||
- "The gate also requires named PASS lines for the site tests (TestNewScriptRoute, TestRoutesAssemble, TestLoadTerminal, TestTerminalScript, TestTerminalEnv, TestTerminalRunScript) and fails when a golden changed during the run"
|
||||
- "--security parses the threat table (escaped pipes respected): every ID present, no open row, every high row mitigated and pinned by a TestX name or a gate stage"
|
||||
|
||||
patterns-established:
|
||||
- "Phase gate --all: one process per stage, named PASS per required test, final marker line printed once"
|
||||
|
||||
requirements-completed: [D-01, D-02, D-03, D-04, D-05, D-06, D-07, D-08, D-09, D-10, D-11, D-12, D-13, D-14, D-15, D-16, D-17, D-18, D-19, D-20, D-21, D-22, D-23, D-24, D-25, D-26, D-27, D-28]
|
||||
|
||||
coverage:
|
||||
- id: D1
|
||||
description: "Every file summer new writes is pinned byte for byte for the default and --no-plugin apps, with an explicit -update path"
|
||||
requirement: D-21
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "internal/build/new_golden_test.go#TestNewGolden"
|
||||
status: pass
|
||||
- kind: unit
|
||||
ref: "internal/build/new_golden_test.go#TestNewGoldenNoPlugin"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D2
|
||||
description: "Generator error and behaviour matrix: name rule and keywords, existing targets, framework resolution order, inside-checkout refusal, cleanup on failure, key validation, .env 0600, AGENTS.md symlink, git first commit and identity paths, --no-git, --no-plugin real build"
|
||||
requirement: D-02
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "internal/build/new_test.go#TestNewValidateAppName,TestNewRefusesExistingTarget,TestNewFrameworkResolution,TestNewRefusesTargetInsideFramework,TestNewCleansUpOnFailure,TestNewRequiresValidAppKey"
|
||||
status: pass
|
||||
- kind: unit
|
||||
ref: "internal/build/new_test.go#TestNewEnvFileMode,TestNewAgentsSymlink,TestNewGitFirstCommit,TestNewGitUsesConfiguredIdentity,TestNewGitMissingIsWarning,TestNewNoGit"
|
||||
status: pass
|
||||
- kind: integration
|
||||
ref: "internal/build/new_test.go#TestNewAppSmoke,TestNewNoPluginBuilds"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D3
|
||||
description: "Shared-path and CLI pins: relative go.work from plugin:add, plain make:plugin routes unchanged, GenerateAppKey, summer new through the CLI root"
|
||||
requirement: D-27
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "internal/build/build_test.go#TestMakeAndAddPluginOnHelloCopy,TestMakePluginRoutesUnchanged"
|
||||
status: pass
|
||||
- kind: unit
|
||||
ref: "modules/lagoon/keygen_test.go#TestGenerateAppKey"
|
||||
status: pass
|
||||
- kind: unit
|
||||
ref: "cmd/summer/main_test.go#TestNewCommandViaCLI,TestToolCommandNames"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D4
|
||||
description: "Bootstrap script behaviour matrix (25 cases) and shellcheck"
|
||||
requirement: D-16
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "modules/sunrise/script_behavior_test.go#TestScriptBehavior"
|
||||
status: pass
|
||||
- kind: unit
|
||||
ref: "modules/sunrise/script_behavior_test.go#TestScriptShellcheck"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D5
|
||||
description: "Fail-closed phase gate across summercms.go and the site: unit, generator, bootstrap, terminal, acceptance, security"
|
||||
requirement: D-21
|
||||
verification:
|
||||
- kind: e2e
|
||||
ref: "bash scripts/check-phase14.3.sh --all (Phase 14.3 gate passed, 2026-10-08)"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D6
|
||||
description: "Security review T-14.3-01..26 + SC pinned and validation map signed off"
|
||||
verification:
|
||||
- kind: other
|
||||
ref: "bash scripts/check-phase14.3.sh --security"
|
||||
status: pass
|
||||
human_judgment: true
|
||||
rationale: "Self-performed review; a human should read the register, the accepted risks and the pins"
|
||||
- id: D7
|
||||
description: "Release steps (push, tag v0.1.13, site require bump, redeploy, production acceptance)"
|
||||
requirement: D-28
|
||||
verification: []
|
||||
human_judgment: true
|
||||
rationale: "User actions after the phase; listed below, nothing pushed, tagged or deployed"
|
||||
|
||||
duration: 19min
|
||||
completed: 2026-10-08
|
||||
status: complete
|
||||
---
|
||||
|
||||
# Phase 14.3 Plan 04: Unit tests, phase gate and sign-off Summary
|
||||
|
||||
**Golden files pin every file `summer new` writes, 18 named tests cover each generator, CLI and key branch, 25 stub-PATH cases pin the bootstrap script's selection and refusal paths, and `bash scripts/check-phase14.3.sh --all` (vet, full suite with named PASS lines, generator on PostgreSQL 16, gated bootstrap and landing-page runs, container acceptance, threat register) ends with `Phase 14.3 gate passed`.**
|
||||
|
||||
## Performance
|
||||
|
||||
- **Duration:** 19 min
|
||||
- **Started:** 2026-10-08T10:11:15Z
|
||||
- **Completed:** 2026-10-08T10:30:44Z
|
||||
- **Tasks:** 3
|
||||
- **Files modified:** 32 (23 goldens, 6 test files, the gate, 2 planning docs)
|
||||
|
||||
## Accomplishments
|
||||
|
||||
- Golden harness (`-update`, fixed 0x00..0x1f key, `summercms` sibling symlink): go.mod, go.work, summer.yaml, main.go, plugins.gen.go, config/*, .env, .env.example, .gitignore, compose.yaml, CLAUDE.md, the starter plugin's plugin.go and routes.go, and next steps; six goldens for `--no-plugin`. Reviewed by eye against D-03, D-10, D-23 and D-25. A one-byte edit made `TestNewGolden` fail with a diff (checked by hand, restored).
|
||||
- Generator matrix in `internal/build/new_test.go`: all 25 Go keywords plus `main`/`internal`, four existing-target kinds left untouched, eight framework-resolution subtests (flag wins, wrong flag module never falls through, `summercms` before `summercms.go`, foreign sibling skipped, binary source last, nothing found names `--framework`), cleanup leaving siblings intact, key validation before any filesystem access, git identity paths, missing git, `--no-git`, `--no-plugin` real build.
|
||||
- `TestScriptBehavior`: help/usage/name errors (including a shell-metacharacter name), existing app, Go missing/1.26.3/devel, git missing, Docker missing, fresh clone at v0.10.0 (numeric order, v1.0.0-rc1 ignored) with `summer new demo --framework <abs>` from the parent and stdin not read, `SUMMERCMS_REF`, dash ref, forward move to v0.11.0, no-op re-run, dirty/branch/non-fast-forward/moved-tag/foreign-module/non-git refusals with HEAD unchanged, PATH hint on/off, GOPATH/bin fallback. A mutation run (forced fetch, stdin left open) made three cases fail.
|
||||
- `scripts/check-phase14.3.sh --all` passed: 28 required framework tests plus 6 site tests with named PASS lines, generator stage, `TestBootstrapEndToEnd`, `TestTerminalCommands`, node:24-bookworm acceptance (`{"message":"Hello from SummerCMS","plugin":"golem15.demo"}`) and the security check. No `phase143*` or `summercms-terminal-*` container or volume remained, and the site checkouts stayed clean.
|
||||
- Fail-closed checks were exercised on planted inputs: an open high row, a missing T-14.3-26 and an unpinned SC row each fail `--security`, and a skipped required subtest, a missing PASS line or "no tests to run" each fail `require_named_pass`.
|
||||
|
||||
## Task Commits
|
||||
|
||||
1. **Task 1: golden harness** - `eed24b8` (test)
|
||||
2. **Task 2: generator, CLI and key matrix** - `392dd3c` (test)
|
||||
3. **Task 3: script matrix, gate, sign-off** - `93e9a8a` (test), `89f1ab2` (chore, gate), `d0af79b` (docs, security review and validation), `c4c91f0` (chore, unique gate marker)
|
||||
|
||||
The tracer gate after Task 1 (interactive, end-of-phase, automated-only verify) re-ran the verify chain, which passed, so expansion continued.
|
||||
|
||||
## Files Created/Modified
|
||||
|
||||
- `internal/build/new_golden_test.go`: `update` flag, `goldenAppKey`, `newGoldenApp`, `goldenName`, `assertGolden` with first-diff output, `TestNewGolden`, `TestNewGoldenNoPlugin`
|
||||
- `internal/build/testdata/new/*.golden` (17), `internal/build/testdata/new-no-plugin/*.golden` (6)
|
||||
- `internal/build/new_test.go`: helpers (`parentWithFramework`, `fakeFramework`, `stubBinaryFramework`, `isolateGit`) and 16 `TestNew*` tests
|
||||
- `internal/build/build_test.go`: relative go.work assertion, `TestMakePluginRoutesUnchanged`
|
||||
- `modules/lagoon/keygen_test.go`: `TestGenerateAppKey`
|
||||
- `cmd/summer/main_test.go`: `new` help flags, `TestNewCommandViaCLI`
|
||||
- `modules/sunrise/script_behavior_test.go`: `scriptHarness`, `fakeRemote`, `TestScriptBehavior`, `TestScriptShellcheck`
|
||||
- `scripts/check-phase14.3.sh`: `REQUIRED_TESTS`, `require_named_pass`, `--unit`, `--forbidden`, `--bootstrap`, `--terminal`, `--security`, `--all`
|
||||
- `14.3-SECURITY-REVIEW.md`: rows T-14.3-20..26, pins for every row, counts 27/27, 4 accepted
|
||||
- `14.3-VALIDATION.md`: `<pkg>` → `sunrise`, exact tests and commands per plan, validated and nyquist_compliant
|
||||
|
||||
## Decisions Made
|
||||
|
||||
- The goldens exercise sibling auto-detection (no `--framework`), so the golden replace is deterministic and the sibling path is covered.
|
||||
- `TestGenerateAppKeyIs32ByteBase64` became `TestGenerateAppKey`, the name the plan and the gate use. The assertions are unchanged.
|
||||
- The gate adds named site tests and a goldens-unchanged check beyond the plan's list. Both are cheap and close T-14.3-24 at gate level.
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
### Auto-fixed Issues
|
||||
|
||||
**1. [Rule 1 - Bug] Detached git auto maintenance raced temp-dir cleanup**
|
||||
- **Found during:** Task 2
|
||||
- **Issue:** In the full `TestNew` run, `TestNewGitFirstCommit` failed on `TempDir RemoveAll cleanup: unlinkat .../.git: directory not empty`. git 2.55 runs maintenance detached after a commit and wrote `.git/info/refs` and `objects/info/packs` while the directory was being removed.
|
||||
- **Fix:** `isolateGit` (and the script harness's git environment) sets `GIT_CONFIG_COUNT` with `maintenance.auto=false` and `gc.auto=0`. This is test-only; the generator itself is unchanged.
|
||||
- **Files modified:** internal/build/new_test.go, modules/sunrise/script_behavior_test.go
|
||||
- **Verification:** five consecutive `TestNewGit*` runs green, no leftover `/tmp/TestNew*`
|
||||
- **Committed in:** 392dd3c, 93e9a8a
|
||||
|
||||
**2. [Rule 3 - Blocking] Gate marker literal appeared twice**
|
||||
- **Found during:** Task 3 acceptance check (`grep -c 'Phase 14.3 gate passed'` must be 1)
|
||||
- **Issue:** The new header comment quoted the marker.
|
||||
- **Fix:** Reworded the comment (comment-only change after the `--all` run).
|
||||
- **Committed in:** c4c91f0
|
||||
|
||||
---
|
||||
|
||||
**Total deviations:** 2 auto-fixed (1 bug, 1 blocking). **Impact:** None on scope; the first one only affects how tests clean up.
|
||||
|
||||
## Issues Encountered
|
||||
|
||||
- `internal/build/registry.go` is still listed by `gofmt -l`. It predates this phase and is out of scope.
|
||||
- `shellcheck` on `scripts/check-phase14.3.sh` reports three SC2086 info notes in plan 02's `acc_cleanup`, which relies on word splitting. Left as is.
|
||||
|
||||
## User Setup Required
|
||||
|
||||
None for the tests. Release steps (D-19, D-28), for the user only. Nothing was pushed, tagged or deployed:
|
||||
|
||||
1. Review and push summercms.go `master`.
|
||||
2. Tag the framework: `git tag -a v0.1.13 -m "v0.1.13"` on the commit holding all Phase 14.3 framework changes, then `git push origin v0.1.13`. This is one-way: a defect found afterwards ships as v0.1.14.
|
||||
3. In `sm-summercmsio-app`, bump `require git.golem15.com/golem15/summercms` to `v0.1.13` in `go.mod` and `plugins/golem15/summercms/go.mod`, and update the `v0.1.12` references in DEPLOY.md and CLAUDE.md. Commit in the plugin submodule and the app, then push the submodules before the app (`ssu`).
|
||||
4. Run `scripts/build.sh` (release) and deploy per `sm-summercmsio-app/DEPLOY.md`.
|
||||
5. Verify production. `curl -fsSI https://summercms.io/new` should show `200` and `text/plain; charset=utf-8`. Run `SUMMERCMS_TERMINAL_CHECK=1 go test -run TestTerminalCommands -count=1 -v .` in `sm-summercmsio-app` with no overrides, then `bash scripts/check-phase14.3.sh --acceptance-production` in summercms.go.
|
||||
|
||||
## Next Phase Readiness
|
||||
|
||||
- Phase 14.3 complete, ready for `/gsd-verify-work 14.3` and then the release checklist above.
|
||||
|
||||
---
|
||||
*Phase: 14.3-one-command-app-scaffolding-summer-new-and-summercms-io-new*
|
||||
*Completed: 2026-10-08*
|
||||
|
||||
## Self-Check: PASSED
|
||||
@@ -0,0 +1,112 @@
|
||||
# Phase 14.3: One-command app scaffolding - Context
|
||||
|
||||
**Gathered:** 2026-10-08
|
||||
**Status:** Ready for planning
|
||||
**Source:** User task spec (pasted 2026-10-08) plus planning-checkpoint answers
|
||||
|
||||
<domain>
|
||||
## Phase Boundary
|
||||
|
||||
Starting a new SummerCMS app takes one command. Today it means hand-writing `go.mod` (with a `replace` to the framework checkout), `summer.yaml` and `config/`, following `docs/architecture/go-modules-and-workspaces.md`. A teammate is about to start a new app (built with MSD); this phase removes that friction.
|
||||
|
||||
Deliverables:
|
||||
1. `summer new <name>` in `cmd/summer` + `internal/build`: writes a ready-to-run `sm-<name>-app/` in the current directory.
|
||||
2. A bootstrap script, `curl -fsSL https://summercms.io/new | bash -s -- <name>`, versioned in the framework repo and served by summercms.io.
|
||||
3. Docs: installation, go modules and workspaces, console docs, `llms.txt`/`llms-full.txt`, and the summercms.io landing page "Get started".
|
||||
4. Go tests for the generator (golden output plus a real build of the scaffold in a temp dir), as the last plan.
|
||||
|
||||
Ships as framework tag **`v0.1.13`** (latest existing tag is `v0.1.12`).
|
||||
|
||||
Out of scope: `sm-<name>-theme` (Nuxt/Vue frontend) scaffolding — but nothing designed here may block adding it later (e.g. `summer new` must not claim the `theme` naming or assume an app has no frontend directory). Standalone `sm-<name>-plugin` scaffolding beyond what `make:plugin` already does. Windows support for the bootstrap script.
|
||||
|
||||
</domain>
|
||||
|
||||
<decisions>
|
||||
## Implementation Decisions
|
||||
|
||||
### Naming (Golem15 convention)
|
||||
- **D-01:** `sm-<name>-app` is an application (this phase), `sm-<name>-plugin` a standalone plugin module, `sm-<name>-theme` a Nuxt/Vue frontend (later). The generated `CLAUDE.md` states this convention.
|
||||
- **D-02:** `<name>` is validated as lower-case letters and digits, starting with a letter (`^[a-z][a-z0-9]*$`); it also becomes the second part of the starter plugin ID `golem15.<name>`. `summer new` refuses to overwrite an existing `sm-<name>-app/` directory (exists at all → error, no merge). — **Reversibility:** costly — the name rule is a public CLI contract.
|
||||
|
||||
### `summer new` output
|
||||
- **D-03:** `go.mod`: module `git.golem15.com/golem15/sm-<name>-app`, `go 1.27.0`, requiring `git.golem15.com/golem15/summercms` with a `replace` to the local framework checkout, written as a **relative** path. — **Reversibility:** reversible.
|
||||
- **D-04:** Framework checkout resolution order: (1) `--framework <path>` flag; (2) auto-detection of sibling directories of the target parent (at least `../summercms` and `../summercms.go`) whose `go.mod` declares module `git.golem15.com/golem15/summercms` — detection is by module path, not folder name, because the local checkout is `summercms.go` while the bootstrap clones `summercms`; (3) otherwise a clear error naming `--framework`. Research may add the running binary's own source location as a further candidate if cheap and reliable.
|
||||
- **D-05:** `summer.yaml` plus one starter plugin `golem15.<name>` created through the **same code paths** as `make:plugin` and `plugin:add` (no duplicated templates), so `go.work` is created too. The starter plugin exposes at least one HTTP route so acceptance can hit it.
|
||||
- **D-06:** Research must check whether an empty `plugins: []` builds. If it does, add `--no-plugin`; if it does not, either fix that in the build pipeline (preferred if small) or drop `--no-plugin` and record why.
|
||||
- **D-07:** `config/` contains everything `serve` and `route:list` need, including `http.yaml` with `body_limits` (the known gap in `examples/hello`), app key handling, and storage bucket defaults (uploads bucket). Fix the `examples/hello` gap too if it is the same config.
|
||||
- **D-08:** `.env.example` lists the `SUMMER_*` variables (DSN, `APP__KEY`, uploads bucket). `.env` is generated with a fresh app key by reusing the `key:generate` logic in `modules/lagoon/keygen.go` (export or move a helper; do not copy it). `.gitignore` covers `bin/`, `.env`, and generated files that should not be committed (research decides which: e.g. `plugins.gen.go`? `storage/`?).
|
||||
- **D-09:** `compose.yaml` runs PostgreSQL 16 with credentials matching `.env`'s DSN.
|
||||
- **D-10:** `CLAUDE.md` for AI agents, also usable as `AGENTS.md` (symlink or identical copy — planner's call, but it must survive `git clone` on Linux/macOS). It says: this is a SummerCMS app; do not edit the framework; docs at https://summercms.io/docs/llms.txt and https://summercms.io/docs/llms-full.txt; core commands `make:plugin`, `plugin:add`, `build`, `dev`, `migrate`, `serve`; the naming convention (D-01). Neutral of any consuming application (framework README rule applies to the template text).
|
||||
- **D-11:** `git init` and a first commit, skippable with `--no-git`. The commit must not fail when the user has no git identity configured (set a fallback author for that commit only, or skip with a warning — planner's call).
|
||||
- **D-12:** The generated app works with **no edits**: `summer build`, `summer migrate` and `summer serve` succeed once `docker compose up -d` is running.
|
||||
|
||||
### Bootstrap script
|
||||
- **D-13:** Script lives in `summercms.go` (e.g. `scripts/new.sh`) and is embedded in an exported framework package so it is versioned with the framework tag. `sm-summercmsio-plugin` serves it at `GET /new` as `text/plain; charset=utf-8`. No nginx change (nginx already allows GET/HEAD and proxies everything except `/backend`). No Cloudflare Worker. msd-core's `scripts/bootstrap.sh` is a pattern reference only. — **Reversibility:** reversible.
|
||||
- **D-14:** The whole script is wrapped in `main()` invoked on the last line, so a truncated download runs nothing.
|
||||
- **D-15:** Steps: (1) check Go ≥ 1.27 (clear install hint if missing/old) and Docker (warn only if missing); (2) clone the framework or update an existing clone, fast-forward only; (3) `go install ./cmd/summer`, then print a PATH hint if `$(go env GOPATH)/bin` (or `GOBIN`) is not on `PATH`; (4) run `summer new <name>` next to the framework (sibling directory, so D-04 auto-detection finds it); (5) print next steps: `docker compose up -d`, `summer build`, `summer migrate`, `summer serve`.
|
||||
- **D-16:** The script checks out the **latest release tag** (`vX.Y.Z`) of `git.golem15.com/golem15/summercms`, not `master`. Re-runs fetch tags and move forward to the newest tag (never a non-fast-forward). Research settles how this interacts with an existing clone that has local changes or sits on a branch (refuse with a clear message, do not reset).
|
||||
- **D-17:** Where the framework is cloned (the script's working directory, e.g. `./summercms`) and any env overrides (clone URL/dir, e.g. for tests) are planner's call; the clone URL defaults to the public HTTPS remote.
|
||||
|
||||
### Docs and site
|
||||
- **D-18:** Rewrite `docs/setup/installation.md`: the one-liner first, the manual `examples/hello` walkthrough second. Update `docs/architecture/go-modules-and-workspaces.md` to cover `summer new`. Add `summer new` to the console docs. `llms.txt`/`llms-full.txt` pick up the changes (regenerate; `summer docs:build --check` and `TestDocsTree` stay green). Module READMEs for any package whose exported API changes (e.g. `lagoon` if the keygen helper is exported) update in the same change.
|
||||
- **D-19:** The summercms.io landing page "Get started" switches to the one-liner in this phase (`vue-summercmsio-app`), and `TestTerminalCommands` in `sm-summercmsio-app` is updated to match. The site is redeployed after `v0.1.13` is tagged (deploy itself is a user action per `sm-summercmsio-app/DEPLOY.md`).
|
||||
|
||||
### Acceptance
|
||||
- **D-20:** In a clean `node:24-bookworm`-style container with Go 1.27, Docker access, git and jq, starting from nothing: `curl … | bash -s -- demo` → `cd sm-demo-app && docker compose up -d && summer build && summer migrate && summer serve`, then a starter-plugin route answers over HTTP. Before the site is deployed, the run may fetch the script from a local server or the repo file, and clone from a local path/tag via the D-17 overrides; the final run against https://summercms.io/new happens after deploy.
|
||||
- **D-21:** Generator Go tests follow the repo's existing test conventions (stdlib `testing`, golden files under `testdata/`, `-short` gating for slow paths such as the real build). Unit tests are the last plan.
|
||||
|
||||
### Post-research decisions (2026-10-08, researcher recommendations adopted)
|
||||
- **D-22:** On top of the D-02 regex, `summer new` rejects names that break a Go build: `main`, `internal`, and every Go keyword (e.g. `go`, `func`, `type`). Error text says why. — **Reversibility:** reversible — loosening later is non-breaking.
|
||||
- **D-23:** `compose.yaml` publishes Postgres on a fixed non-5432 host port bound to `127.0.0.1` (planner picks the number); `.env`/`.env.example` DSN, user, password and DB name match it.
|
||||
- **D-24:** `summer new` prints the next steps itself (so a manual `summer new` user sees them too); the bootstrap script prints only the extra bits (the `cd` line, the PATH hint).
|
||||
- **D-25:** No root `go.sum` in the scaffold; building goes through `go.work`. `go.work.sum` is gitignored.
|
||||
- **D-26:** The new exported framework package that embeds the bootstrap script lives under `modules/` (name is planner's call, it must follow the module README rules and get a root README table row). The script file sits inside that package directory (`//go:embed` cannot reach `../scripts/`). Script env overrides are `SUMMERCMS_REPO`, `SUMMERCMS_DIR`, `SUMMERCMS_REF` — never `SUMMER_*` (compass reads those as config).
|
||||
- **D-27:** Fix the two defects research found, in the shared code paths: `plugin:add` writes a relative path into `go.work` (not absolute), and the `make:plugin` routes stub gains an opt-in starter route used by `summer new` (no duplicated template). Export `lagoon.GenerateAppKey()` (or equivalent) so the key is generated without the `key:generate` CLI's `✓ ` prefix. Add `config/http.yaml` to `examples/hello` so `TestTypedItemRoute` passes.
|
||||
- **D-28:** Release ordering: all framework work lands before tagging `v0.1.13`. The site plugin's `/new` route and the landing-page change are built against the framework through the site's local `go.work`/replace during this phase; bumping the site's `require` to `v0.1.13` and redeploying happen after the tag (user action, per `sm-summercmsio-app/DEPLOY.md`). The bootstrap script's `SUMMERCMS_REF` override lets acceptance run before the tag exists.
|
||||
|
||||
### Claude's Discretion
|
||||
- Exact package layout inside `internal/build` (e.g. `new.go` + templates under `internal/build/stubs/new/`), flag names beyond those listed, and template engine (`text/template` like existing stubs).
|
||||
- Whether `summer new` also runs `summer build` at the end (default: no — the next-steps text covers it).
|
||||
- Default DB name/user/password and host port in `compose.yaml`/`.env` (avoid clashing with a host Postgres on 5432 if cheap, e.g. a non-default host port).
|
||||
- Security review: the bootstrap script is piped into a shell and served publicly — run the security-review agent for plan 2.
|
||||
|
||||
</decisions>
|
||||
|
||||
<plans>
|
||||
## Agreed Plan Split (checkpoint confirmed 2026-10-08)
|
||||
|
||||
1. **Generator** — `summer new` in `cmd/summer` + `internal/build` (D-02..D-12), with a smoke test. Repo: `summercms.go`.
|
||||
2. **Bootstrap script + `/new` route + acceptance** — `scripts/new.sh` embedded and exported (D-13..D-17), `GET /new` in `sm-summercmsio-plugin`, container acceptance run (D-20). Repos: `summercms.go`, `sm-summercmsio-app` (plugin submodule).
|
||||
3. **Docs and landing page** — D-18, D-19. Repos: `summercms.go`, `sm-summercmsio-app` / `vue-summercmsio-app`.
|
||||
4. **Unit tests** — golden output + real build in a temp dir (D-21). Repo: `summercms.go`.
|
||||
|
||||
</plans>
|
||||
|
||||
<canonical_refs>
|
||||
## Canonical References
|
||||
|
||||
**Downstream agents MUST read these before planning or implementing.**
|
||||
|
||||
- `cmd/summer/main.go` — existing commands: `build`, `make:plugin`, `dev`, `plugin:add`, delegated `migrate`/`serve`.
|
||||
- `internal/build/` — `scaffold.go`, `build.go`, `manifest.go`, `registry.go`, `stubs/` (existing templates and code paths to reuse).
|
||||
- `modules/lagoon/keygen.go` — `key:generate` logic to reuse for `.env`.
|
||||
- `examples/hello/` — minimal app (`go.mod`, `summer.yaml`, `config/`), including the missing `http.yaml` `body_limits`.
|
||||
- `docs/setup/installation.md`, `docs/architecture/go-modules-and-workspaces.md`, `docs/console/` — docs to update; `cmd/summer/docs.go` — docs build / llms output.
|
||||
- `../sm-bm-app/`, `../sm-summercmsio-app/` — real hand-built apps: `go.mod` with relative replace, `config/*.yaml` (`http.yaml`, `storage.yaml`, `database.yaml`, `app.yaml`), `go.work`.
|
||||
- `../sm-summercmsio-app/DEPLOY.md`, `../sm-summercmsio-app/plugins/golem15/summercms/` (site plugin, routes), `../sm-summercmsio-app/terminal_check_test.go`, `../sm-summercmsio-app/vue-summercmsio-app/` (landing page "Get started").
|
||||
- msd-core `scripts/bootstrap.sh` — pattern reference for the curl | bash script (locate via the msd-core checkout; reference only).
|
||||
|
||||
</canonical_refs>
|
||||
|
||||
<deferred>
|
||||
## Deferred Ideas
|
||||
|
||||
- `summer new --theme` / `sm-<name>-theme` Nuxt scaffold.
|
||||
- Publishing `summer` as prebuilt release binaries (would remove the Go prerequisite for running the CLI, not for building apps).
|
||||
|
||||
</deferred>
|
||||
|
||||
---
|
||||
|
||||
*Phase: 14.3-one-command-app-scaffolding*
|
||||
*Context gathered: 2026-10-08*
|
||||
@@ -0,0 +1,653 @@
|
||||
# Phase 14.3: One-command app scaffolding (`summer new` + summercms.io/new) - Research
|
||||
|
||||
**Researched:** 2026-10-08
|
||||
**Domain:** Go CLI scaffolding (internal/build), Go modules/workspaces, curl|bash bootstrap, static route on the site plugin, docs pipeline
|
||||
**Confidence:** HIGH (most claims were checked empirically in a scratch directory against the local framework and a throwaway PostgreSQL 16 container)
|
||||
|
||||
<user_constraints>
|
||||
## User Constraints (from CONTEXT.md)
|
||||
|
||||
### Locked Decisions
|
||||
|
||||
#### Naming (Golem15 convention)
|
||||
- **D-01:** `sm-<name>-app` is an application (this phase), `sm-<name>-plugin` a standalone plugin module, `sm-<name>-theme` a Nuxt/Vue frontend (later). The generated `CLAUDE.md` states this convention.
|
||||
- **D-02:** `<name>` is validated as lower-case letters and digits, starting with a letter (`^[a-z][a-z0-9]*$`); it also becomes the second part of the starter plugin ID `golem15.<name>`. `summer new` refuses to overwrite an existing `sm-<name>-app/` directory (exists at all → error, no merge). — **Reversibility:** costly — the name rule is a public CLI contract.
|
||||
|
||||
#### `summer new` output
|
||||
- **D-03:** `go.mod`: module `git.golem15.com/golem15/sm-<name>-app`, `go 1.27.0`, requiring `git.golem15.com/golem15/summercms` with a `replace` to the local framework checkout, written as a **relative** path. — **Reversibility:** reversible.
|
||||
- **D-04:** Framework checkout resolution order: (1) `--framework <path>` flag; (2) auto-detection of sibling directories of the target parent (at least `../summercms` and `../summercms.go`) whose `go.mod` declares module `git.golem15.com/golem15/summercms` — detection is by module path, not folder name, because the local checkout is `summercms.go` while the bootstrap clones `summercms`; (3) otherwise a clear error naming `--framework`. Research may add the running binary's own source location as a further candidate if cheap and reliable.
|
||||
- **D-05:** `summer.yaml` plus one starter plugin `golem15.<name>` created through the **same code paths** as `make:plugin` and `plugin:add` (no duplicated templates), so `go.work` is created too. The starter plugin exposes at least one HTTP route so acceptance can hit it.
|
||||
- **D-06:** Research must check whether an empty `plugins: []` builds. If it does, add `--no-plugin`; if it does not, either fix that in the build pipeline (preferred if small) or drop `--no-plugin` and record why.
|
||||
- **D-07:** `config/` contains everything `serve` and `route:list` need, including `http.yaml` with `body_limits` (the known gap in `examples/hello`), app key handling, and storage bucket defaults (uploads bucket). Fix the `examples/hello` gap too if it is the same config.
|
||||
- **D-08:** `.env.example` lists the `SUMMER_*` variables (DSN, `APP__KEY`, uploads bucket). `.env` is generated with a fresh app key by reusing the `key:generate` logic in `modules/lagoon/keygen.go` (export or move a helper; do not copy it). `.gitignore` covers `bin/`, `.env`, and generated files that should not be committed (research decides which: e.g. `plugins.gen.go`? `storage/`?).
|
||||
- **D-09:** `compose.yaml` runs PostgreSQL 16 with credentials matching `.env`'s DSN.
|
||||
- **D-10:** `CLAUDE.md` for AI agents, also usable as `AGENTS.md` (symlink or identical copy — planner's call, but it must survive `git clone` on Linux/macOS). It says: this is a SummerCMS app; do not edit the framework; docs at https://summercms.io/docs/llms.txt and https://summercms.io/docs/llms-full.txt; core commands `make:plugin`, `plugin:add`, `build`, `dev`, `migrate`, `serve`; the naming convention (D-01). Neutral of any consuming application (framework README rule applies to the template text).
|
||||
- **D-11:** `git init` and a first commit, skippable with `--no-git`. The commit must not fail when the user has no git identity configured (set a fallback author for that commit only, or skip with a warning — planner's call).
|
||||
- **D-12:** The generated app works with **no edits**: `summer build`, `summer migrate` and `summer serve` succeed once `docker compose up -d` is running.
|
||||
|
||||
#### Bootstrap script
|
||||
- **D-13:** Script lives in `summercms.go` (e.g. `scripts/new.sh`) and is embedded in an exported framework package so it is versioned with the framework tag. `sm-summercmsio-plugin` serves it at `GET /new` as `text/plain; charset=utf-8`. No nginx change (nginx already allows GET/HEAD and proxies everything except `/backend`). No Cloudflare Worker. msd-core's `scripts/bootstrap.sh` is a pattern reference only. — **Reversibility:** reversible.
|
||||
- **D-14:** The whole script is wrapped in `main()` invoked on the last line, so a truncated download runs nothing.
|
||||
- **D-15:** Steps: (1) check Go ≥ 1.27 (clear install hint if missing/old) and Docker (warn only if missing); (2) clone the framework or update an existing clone, fast-forward only; (3) `go install ./cmd/summer`, then print a PATH hint if `$(go env GOPATH)/bin` (or `GOBIN`) is not on `PATH`; (4) run `summer new <name>` next to the framework (sibling directory, so D-04 auto-detection finds it); (5) print next steps: `docker compose up -d`, `summer build`, `summer migrate`, `summer serve`.
|
||||
- **D-16:** The script checks out the **latest release tag** (`vX.Y.Z`) of `git.golem15.com/golem15/summercms`, not `master`. Re-runs fetch tags and move forward to the newest tag (never a non-fast-forward). Research settles how this interacts with an existing clone that has local changes or sits on a branch (refuse with a clear message, do not reset).
|
||||
- **D-17:** Where the framework is cloned (the script's working directory, e.g. `./summercms`) and any env overrides (clone URL/dir, e.g. for tests) are planner's call; the clone URL defaults to the public HTTPS remote.
|
||||
|
||||
#### Docs and site
|
||||
- **D-18:** Rewrite `docs/setup/installation.md`: the one-liner first, the manual `examples/hello` walkthrough second. Update `docs/architecture/go-modules-and-workspaces.md` to cover `summer new`. Add `summer new` to the console docs. `llms.txt`/`llms-full.txt` pick up the changes (regenerate; `summer docs:build --check` and `TestDocsTree` stay green). Module READMEs for any package whose exported API changes (e.g. `lagoon` if the keygen helper is exported) update in the same change.
|
||||
- **D-19:** The summercms.io landing page "Get started" switches to the one-liner in this phase (`vue-summercmsio-app`), and `TestTerminalCommands` in `sm-summercmsio-app` is updated to match. The site is redeployed after `v0.1.13` is tagged (deploy itself is a user action per `sm-summercmsio-app/DEPLOY.md`).
|
||||
|
||||
#### Acceptance
|
||||
- **D-20:** In a clean `node:24-bookworm`-style container with Go 1.27, Docker access, git and jq, starting from nothing: `curl … | bash -s -- demo` → `cd sm-demo-app && docker compose up -d && summer build && summer migrate && summer serve`, then a starter-plugin route answers over HTTP. Before the site is deployed, the run may fetch the script from a local server or the repo file, and clone from a local path/tag via the D-17 overrides; the final run against https://summercms.io/new happens after deploy.
|
||||
- **D-21:** Generator Go tests follow the repo's existing test conventions (stdlib `testing`, golden files under `testdata/`, `-short` gating for slow paths such as the real build). Unit tests are the last plan.
|
||||
|
||||
### Claude's Discretion
|
||||
- Exact package layout inside `internal/build` (e.g. `new.go` + templates under `internal/build/stubs/new/`), flag names beyond those listed, and template engine (`text/template` like existing stubs).
|
||||
- Whether `summer new` also runs `summer build` at the end (default: no — the next-steps text covers it).
|
||||
- Default DB name/user/password and host port in `compose.yaml`/`.env` (avoid clashing with a host Postgres on 5432 if cheap, e.g. a non-default host port).
|
||||
- Security review: the bootstrap script is piped into a shell and served publicly — run the security-review agent for plan 2.
|
||||
|
||||
### Agreed Plan Split (checkpoint confirmed 2026-10-08)
|
||||
1. **Generator** — `summer new` in `cmd/summer` + `internal/build` (D-02..D-12), with a smoke test. Repo: `summercms.go`.
|
||||
2. **Bootstrap script + `/new` route + acceptance** — `scripts/new.sh` embedded and exported (D-13..D-17), `GET /new` in `sm-summercmsio-plugin`, container acceptance run (D-20). Repos: `summercms.go`, `sm-summercmsio-app` (plugin submodule).
|
||||
3. **Docs and landing page** — D-18, D-19. Repos: `summercms.go`, `sm-summercmsio-app` / `vue-summercmsio-app`.
|
||||
4. **Unit tests** — golden output + real build in a temp dir (D-21). Repo: `summercms.go`.
|
||||
|
||||
### Deferred Ideas (OUT OF SCOPE)
|
||||
- `summer new --theme` / `sm-<name>-theme` Nuxt scaffold.
|
||||
- Publishing `summer` as prebuilt release binaries (would remove the Go prerequisite for running the CLI, not for building apps).
|
||||
</user_constraints>
|
||||
|
||||
## Project Constraints (from CLAUDE.md)
|
||||
|
||||
- Stdlib first (net/http ServeMux, text/template, encoding/json, os/exec, embed). **No new Go dependency is needed for this phase** — everything below uses stdlib plus existing framework packages.
|
||||
- Compiled plugins only; no runtime plugin loading.
|
||||
- `go vet ./...` and `go test ./...` green at every commit; one logical change per commit; planning docs and code in separate commits; **no co-author tags** in commit messages (user global rule; overrides the harness attribution reminder).
|
||||
- Unit tests are the last plan of the phase (plan 4).
|
||||
- Any change to a package under `modules/` that touches its exported API → update that module's `README.md` in the same change; a **new** module ships a README with the standard structure (H1, one-sentence summary, import line, Overview, Features, Usage, API reference, optional Configuration/CLI commands, Dependencies, Testing) and a row in the root `README.md` modules table reusing the summary sentence.
|
||||
- Framework READMEs and docs never name a consuming application (applies to the generated `CLAUDE.md` template text too, per D-10). Use neutral names (`acme`, `blog`, `demo`).
|
||||
- Every identifier named in a README/docs page must exist (docs checker). A change to exported API/config/CLI updates `docs/` in the same change; `go test ./cmd/summer -run TestDocsTree` and `summer docs:build --check` must pass.
|
||||
- Config keys in docs are not machine-checked — review by hand.
|
||||
- Core plugins (user, blog, pages, payment) must not get breaking changes; the site plugin and `sm-summercmsio-app` are not core plugins.
|
||||
- Use `ssu` for submodule status/push in `sm-summercmsio-app` (user global rule).
|
||||
- GSD workflow: work through `/gsd-execute-phase`.
|
||||
|
||||
## Summary
|
||||
|
||||
Almost everything `summer new` needs already exists as callable library code: `build.MakePlugin(ctx, startDir, id)` and `build.AddPlugin(ctx, startDir, pluginDir)` already take a directory argument (not cwd), and the unexported `generate(appDir, m)` writes `main.go` + `plugins.gen.go` without compiling. A new `internal/build/new.go` in the same package can orchestrate them. Two real defects surfaced that the plan must fix: (1) `plugin:add` writes an **absolute** path into `go.work` (because it passes an absolute dir to `go work use`), which would make the committed `go.work` of every generated app machine-specific; (2) the `make:plugin` `routes.go` stub registers no route, so the starter route needs a template option, not a second template.
|
||||
|
||||
An empty plugin list builds and serves (verified: `plugins: []` → `built empty in 1.947s`, `route:list` and `serve` work once config is present), so `--no-plugin` should be added. The minimal runtime contract was established empirically against PostgreSQL 16: `serve`/`route:list` require `http.body_limits.{default_bytes,upload_bytes}` as YAML numbers; `migrate`/`serve` require `database.dsn` and a valid `app.key` (32 bytes base64); `serve` additionally requires `storage.uploads.bucket_url`. Nothing else (no admin JWT secret — that is only required when an admin controller exists). The hand-built scratch app (`go.mod`, `go.work`, `summer.yaml`, `config/http.yaml`, `config/storage.yaml`, env for DSN+key, starter plugin) built, migrated and answered `GET /` with 200 JSON through `summer serve`.
|
||||
|
||||
The bootstrap script is a straightforward bash `main()` with git tag selection, `go install`, and `summer new --framework`. The main traps are pipefail+`head` SIGPIPE, git credential prompts hanging, `key:generate` printing a `✓ ` prefix (so it cannot be captured with `$(...)`), and `summer serve --addr` being rejected by the delegating `summer` command. The site serves the script from a new exported framework package; `//go:embed` cannot reach `../scripts/`, so the script file must live **inside** that package directory (precedent: `modules/boardwalk` embeds `dist/`).
|
||||
|
||||
**Primary recommendation:** Implement `build.NewApp(ctx, NewOptions)` in `internal/build/new.go` as a pure "plan files" step + an executor that writes `go.mod`/`go.work`/config first, then calls the existing `MakePlugin` (with a `StarterRoute` option rendered by the same `routes.go` template) and a fixed `AddPlugin` (relative `go work use`), then `generate()`, then git. Put the bootstrap script in a new `modules/<pkg>/new.sh` embedded by that package, and name all script env overrides `SUMMERCMS_*` (never `SUMMER_*`).
|
||||
|
||||
## Architectural Responsibility Map
|
||||
|
||||
| Capability | Primary Tier | Secondary Tier | Rationale |
|
||||
|------------|-------------|----------------|-----------|
|
||||
| Name validation, target dir guard | CLI (`cmd/summer` → `internal/build`) | — | Public CLI contract (D-02); lives next to `ValidatePluginID` |
|
||||
| Framework checkout detection, relative replace | CLI (`internal/build/new.go`) | Bootstrap passes `--framework` | Filesystem-only logic; reuse `parseGoMod` |
|
||||
| Starter plugin + go.work | CLI (existing `MakePlugin`/`AddPlugin`) | — | D-05 "same code paths" |
|
||||
| App key generation | Framework module `modules/lagoon` (exported helper) | CLI calls it | D-08: reuse, do not copy |
|
||||
| Bootstrap (clone/update, install, run) | Shell script embedded in framework package | — | Versioned with the tag (D-13) |
|
||||
| Serving `/new` | Site plugin (`sm-summercmsio-plugin`) route | nginx (unchanged passthrough) | D-13; nginx already proxies GET/HEAD |
|
||||
| Local DB | `compose.yaml` (Docker) | `.env` DSN | D-09 |
|
||||
| Docs/llms | `docs/` source; llms built by `docs:build` at site build time | site build.sh | llms files are build output, not committed |
|
||||
|
||||
## Standard Stack
|
||||
|
||||
### Core (all already in the repo; no new dependencies)
|
||||
| Library | Version | Purpose | Why Standard |
|
||||
|---------|---------|---------|--------------|
|
||||
| `text/template` + `embed` | stdlib (Go 1.27.0 local) | Render app files from `internal/build/stubs/*.tmpl` | Existing stub mechanism `stubTmpl = template.Must(template.ParseFS(stubFS, "stubs/*.tmpl"))` [VERIFIED: internal/build/scaffold.go:20-23] |
|
||||
| `os/exec` (`go`, `git`) | stdlib | `go mod tidy`, `go mod edit`, `go work use`, `git init/commit` | Existing `runGo`/`applyGoWork` helpers [VERIFIED: internal/build/scaffold.go:508-541] |
|
||||
| `runtime.Caller` / `runtime/debug` | stdlib | Optional framework source-location candidate (D-04) | Verified behaviour below |
|
||||
| `go/token.IsKeyword` | stdlib | Reject Go keywords as `<name>` | See Pitfall 3 |
|
||||
| `modules/bonfire` | in-repo | `summer new` command definition (Args, Flags with `Bare`) | `type Flag struct { Name, Shorthand, Description, Default string; Bare bool; Repeatable bool }` [VERIFIED: modules/bonfire/command.go:24-38] |
|
||||
| `modules/lagoon` | in-repo | Key generation helper to export | [VERIFIED: modules/lagoon/keygen.go:13-26] |
|
||||
|
||||
### Supporting (external tools, not Go deps)
|
||||
| Tool | Version found | Purpose |
|
||||
|------|---------------|---------|
|
||||
| shellcheck | 0.11.0 on host [VERIFIED: `shellcheck --version`] | Lint `new.sh` in a test (skip when absent) |
|
||||
| bash | 5.3.15 host, 5.2.15 in node:24-bookworm [VERIFIED] | Script runtime |
|
||||
| git | 2.55.0 host, 2.39.5 in node:24-bookworm [VERIFIED] | `init -b`, `ls-remote --sort` both supported |
|
||||
| Docker + compose | 29.7.2 / Compose 5.5.0 host [VERIFIED] | compose.yaml, acceptance |
|
||||
| postgres:16, node:24-bookworm images | present locally [VERIFIED: `docker images`] | DB + acceptance base |
|
||||
|
||||
**Installation:** none (no `go get`).
|
||||
|
||||
## Package Legitimacy Audit
|
||||
|
||||
No external packages are installed by this phase: no new Go modules, no npm packages. The acceptance container downloads the official Go tarball (`https://go.dev/dl/go1.27.0.linux-amd64.tar.gz`, which redirects to `dl.google.com` [VERIFIED: curl -sI]) and the Docker CLI/compose plugin from Docker's official distribution.
|
||||
|
||||
| Package | Registry | Age | Downloads | Source Repo | Verdict | Disposition |
|
||||
|---------|----------|-----|-----------|-------------|---------|-------------|
|
||||
| (none) | — | — | — | — | — | — |
|
||||
|
||||
**Packages removed due to [SLOP] verdict:** none
|
||||
**Packages flagged as suspicious [SUS]:** none
|
||||
|
||||
## Verified Internals (questions 1-6, 11)
|
||||
|
||||
### Q1. make:plugin / plugin:add internals and what `summer new` calls
|
||||
|
||||
- `func MakePlugin(ctx context.Context, startDir, id string) (string, error)` — validates the ID, `findAppDir(startDir)` (walks up for `summer.yaml`), creates `plugins/<name>` (refuses if it exists), reads `summer.yaml` for `m.Module`, sets plugin module `m.Module + "/plugins/" + name`, copies the app's framework `replace` rewritten relative to the plugin dir, writes the scaffold, runs `go mod tidy` in the plugin dir, appends `toolchain go1.27.0`. [VERIFIED: internal/build/scaffold.go:51-103]
|
||||
- `func AddPlugin(ctx context.Context, startDir, pluginDir string) error` — resolves the plugin dir, reads its module path and ID from `plugin.go`, appends `{ID, Module}` to `summer.yaml`, `go mod edit -require=<mod>@v0.0.0` and `-replace=<mod>=./plugins/<name>` on the app `go.mod`, then `ensureGoWork` (creates `go.work` in the app root if none is found upward, then `go work use`). [VERIFIED: internal/build/scaffold.go:105-173, 318-352]
|
||||
- Both already take a directory parameter, so **no cwd refactor is needed**; `cmd/summer` passes `os.Getwd()` [VERIFIED: cmd/summer/main.go:62-90, 282-298].
|
||||
- Scaffold layout written: `plugin.go`, `routes.go`, `registry.gen.go`, `go.mod` (+`go.sum` from tidy), leaf packages `models classes controllers console jobs middleware updates` (each `doc.go`), assets `config/config.yaml` (`{}`), `lang/en/lang.yaml` (`{}`), `views/mail/welcome.htm`. [VERIFIED: internal/build/scaffold.go:25-33, 180-231]
|
||||
- **The stub registers no route:** `{{define "routes.go"}}... func (p *Plugin) Routes(pact.Router) error { return nil }` [VERIFIED: internal/build/stubs/plugin.tmpl:63-70]. The plugin `go.mod` stub is `require {{.Framework}} v0.0.0` + optional `replace {{.Framework}} => {{.Replace}}` [VERIFIED: internal/build/stubs/plugin.tmpl:72-81].
|
||||
- Constants: `frameworkModule = "git.golem15.com/golem15/summercms"`, `goVersionLine = "go 1.27.0"`, `toolchainLine = "toolchain go1.27.0"` [VERIFIED: internal/build/scaffold.go:45-49].
|
||||
- `generate(appDir string, m Manifest) error` writes `main.go` and `plugins.gen.go` via `writeIfChanged` with no compile [VERIFIED: internal/build/build.go:62-78] — `summer new` (same package) can call it so the first commit contains the generated files.
|
||||
- `SaveManifest` writes `module:`, `binary:`, `plugins:` with entries ` - id:` / ` module:` [VERIFIED: internal/build/manifest.go:261-287]; with zero plugins it writes `plugins:` (null) which `LoadManifest` accepts (hello tests use it).
|
||||
|
||||
**Refactors required:**
|
||||
1. **AddPlugin writes an absolute path into `go.work`.** Empirically, `summer plugin:add plugins/demo` produced:
|
||||
```
|
||||
use (
|
||||
.
|
||||
/tmp/.../scratchpad/sib/sm-demo-app/plugins/demo
|
||||
)
|
||||
```
|
||||
because `runGoWorkUse(ctx, appDir, gw, pluginDir)` is called with the absolute `pluginDir` [VERIFIED: internal/build/scaffold.go:351, 362-371; empirical run]. Running `go work use ./plugins/demo` from the app dir writes `./plugins/demo` [VERIFIED: empirical]. Fix: compute `rel, _ := filepath.Rel(filepath.Dir(goWork), pluginDir)` and prefix `./` (same logic as `editAppModule` lines 322-329), also in the `ensureGoWork` loop for existing plugins (line 344-346). This changes `plugin:add` output for existing users from absolute to relative (an improvement; `TestMakeAndAddPluginOnHelloCopy` only checks `bytes.Contains(work, "plugins/demo")` [VERIFIED: internal/build/build_test.go:268-274]). Update the `go-modules-and-workspaces.md` text if it describes the path.
|
||||
2. **Starter route option.** Add a field to `stubData` (e.g. `StarterRoute bool`) and an `{{if .StarterRoute}}` branch in the existing `routes.go` define; add an unexported `makePlugin(ctx, dir, id, opts)` that `MakePlugin` wraps with zero options. This keeps one template (D-05) and leaves `make:plugin` output unchanged (two `make:plugin` plugins would otherwise collide on the same path).
|
||||
|
||||
### Q2. Does an empty `plugins: []` build and serve? — YES
|
||||
|
||||
Scratch app: `go.mod` (module + `require summercms v0.0.0` + relative replace), `summer.yaml` with `plugins: []`, no `go.work`:
|
||||
```
|
||||
$ summer build
|
||||
built empty in 1.947s
|
||||
$ ./bin/empty route:list
|
||||
surf: config http.body_limits.default_bytes is required # before config/http.yaml
|
||||
$ ./bin/empty route:list # after config/http.yaml
|
||||
Method Pattern Plugin Middleware Raw
|
||||
```
|
||||
`plugins.gen.go` became `var PluginIDs = []string{}` with no import block [VERIFIED: empirical; internal/build/build.go:139-157]. With DSN + key + bucket set, `serve` started (`job worker started on queues: default, scheduled`, `listening on 127.0.0.1:18731`) and returned 404 for `/` (no routes). The same app with its own `go.work` (`use .`) also builds (workspace mode writes `go.work.sum` automatically) [VERIFIED: empirical]. **Decision input for D-06: add `--no-plugin`; no pipeline fix needed.**
|
||||
|
||||
### Q3. Minimal complete config — verified against PostgreSQL 16
|
||||
|
||||
Failure sequence observed on the empty app (each error appeared until the next item was added) [VERIFIED: empirical]:
|
||||
1. `route:list`/`serve`: `surf: config http.body_limits.default_bytes is required` — from `requiredBytes(app, "http.body_limits.default_bytes")` and `"http.body_limits.upload_bytes"` [VERIFIED: modules/surf/router.go:491-498].
|
||||
2. `migrate`/`serve`: `lagoon: database.dsn is empty (set SUMMER_DATABASE__DSN)` [VERIFIED: modules/lagoon/connection.go:25].
|
||||
3. `migrate`/`serve`: `lagoon: app.key is empty or invalid (set SUMMER_APP__KEY to a 32-byte base64 value)` — `decodeAppKey` requires StdEncoding base64 of exactly `encryptedKeySize = 32` bytes [VERIFIED: modules/lagoon/encrypted.go:23, 197-207].
|
||||
4. `serve` only: `attach: storage.uploads.bucket_url is empty (set SUMMER_STORAGE__UPLOADS__BUCKET_URL)` [VERIFIED: modules/lagoon/attach/bucket.go:66-69]. `file://` URLs get `create_dir=true` added automatically [VERIFIED: modules/lagoon/attach/bucket.go:47-53]; `file://./storage/app/uploads` created `storage/app/uploads` relative to the working dir [VERIFIED: empirical].
|
||||
5. Admin JWT secret is **not** required: `cabana.Activate` returns early when no plugin has admin controllers ("No controllers means no admin routes and no secret check") [VERIFIED: modules/cabana/http.go:73-84].
|
||||
|
||||
Then `summer migrate` → `✓ migrations applied`; `summer serve` → `GET /` 200 `{"message":"root","plugin":"golem15.demo"}`, `GET /api/demo/hello` 200, `/nope` 404 [VERIFIED: empirical, starter plugin with routes `GET /{$}` and `GET /api/demo/hello`].
|
||||
|
||||
**Env mapping:** prefix `const envPrefix = "SUMMER_"`; `__` splits path segments, segments lower-cased (`SUMMER_DATABASE__DSN` → `database.dsn`); `SUMMER_ENV` is skipped and selects the environment (default `"production"`) [VERIFIED: modules/compass/env.go:13, 31-44; modules/compass/config.go:187-199]. The `.env` file is read from `filepath.Join(filepath.Dir(c.dir), ".env")`, i.e. **next to `config/`** (app root); real environment variables win over `.env`; values may be wrapped in single or double quotes, `export ` prefix allowed, `#` comment lines skipped [VERIFIED: modules/compass/config.go:187; modules/compass/env.go:46-98]. Env values are strings — `body_limits` must therefore be YAML numbers in `config/http.yaml` (existing app comment says the same) [VERIFIED: ../sm-summercmsio-app/config/http.yaml].
|
||||
|
||||
**Recommended generated file set** (each verified to be sufficient):
|
||||
|
||||
| File | Content | Required by |
|
||||
|------|---------|-------------|
|
||||
| `go.mod` | `module git.golem15.com/golem15/sm-<name>-app`, `go 1.27.0`, `toolchain go1.27.0`, `require git.golem15.com/golem15/summercms v0.0.0`, `replace … => ../summercms` (relative) | build |
|
||||
| `go.work` | `go 1.27.0` / `toolchain go1.27.0` / `use ( . ./plugins/<name> )` — relative | build (workspace) |
|
||||
| `summer.yaml` | `module:`, `binary: <name>`, `plugins:` | build |
|
||||
| `main.go`, `plugins.gen.go` | from `generate()` | committed (see Q5) |
|
||||
| `config/app.yaml` | `name: <name>`, `debug: false`, `key: ""` with a comment pointing at `SUMMER_APP__KEY` | documentation only |
|
||||
| `config/http.yaml` | `body_limits: {default_bytes: 1048576, upload_bytes: 10485760}` | serve, route:list |
|
||||
| `config/storage.yaml` | `uploads: {bucket_url: "file://./storage/app/uploads", public_path_prefix: "/storage/uploads"}` | serve |
|
||||
| `config/database.yaml` | `dsn: ""` + comment pointing at `SUMMER_DATABASE__DSN` | documentation only |
|
||||
| `.env` (mode 0600) | `SUMMER_DATABASE__DSN=postgres://…@127.0.0.1:<port>/<db>?sslmode=disable`, `SUMMER_APP__KEY=<fresh>` | migrate, serve |
|
||||
| `.env.example` | same keys, placeholder key, plus commented `SUMMER_STORAGE__UPLOADS__BUCKET_URL` and `SUMMER_ENV` | docs |
|
||||
| `compose.yaml` | `postgres:16`, credentials = DSN, port bound to `127.0.0.1`, named volume, `pg_isready` healthcheck | D-09 |
|
||||
| `.gitignore`, `CLAUDE.md`, `AGENTS.md`, `plugins/<name>/…` | see Q5 / D-10 | |
|
||||
|
||||
Optional `config/queue.yaml` is not needed: with no setting the in-process River worker starts in `serve` and works after `migrate` [VERIFIED: empirical log `River client started`].
|
||||
|
||||
**`examples/hello` gap (D-07):** confirmed — `go test ./...` in `examples/hello` fails today: `--- FAIL: TestTypedItemRoute ... surf: config http.body_limits.default_bytes is required` [VERIFIED: empirical]. Adding `examples/hello/config/http.yaml` with the same `body_limits` fixes it (hello tests load `compass.Load("config")`). The docs warning about a stale `examples/hello/main.go` is **obsolete**: a fresh `summer build` of a copy produced a byte-identical `main.go` [VERIFIED: empirical `diff` empty] — remove both bullets of the "Known issues" warning in `installation.md` when D-07 lands.
|
||||
|
||||
### Q4. key:generate reuse
|
||||
|
||||
```go
|
||||
// modules/lagoon/keygen.go:13-26
|
||||
func KeyGenerateCommand() bonfire.Command {
|
||||
...
|
||||
Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error {
|
||||
key := make([]byte, encryptedKeySize)
|
||||
if _, err := rand.Read(key); err != nil {
|
||||
return err
|
||||
}
|
||||
out.Success(base64.StdEncoding.EncodeToString(key))
|
||||
return nil
|
||||
},
|
||||
```
|
||||
[VERIFIED: modules/lagoon/keygen.go:13-26]. **Format:** `base64.StdEncoding` of 32 random bytes = 44 chars (with `=` padding). **Pitfall:** `out.Success` prefixes a check mark — `./bin/app key:generate | od -c` started with `342 234 223` (`✓`) and a space; `export SUMMER_APP__KEY=$(./bin/app key:generate)` produced a 46-char value that `decodeAppKey` rejects [VERIFIED: empirical]. Recommendation: export `func GenerateAppKey() (string, error)` in `keygen.go`, make `KeyGenerateCommand` call it, and have `cmd/summer` call it and pass the key into `build.NewOptions` (keeps `internal/build` free of a lagoon import; `cmd/summer` already imports lagoon [VERIFIED: cmd/summer/docs.go:18]). Update `modules/lagoon/README.md` API table (it already lists `lagoon.KeyGenerateCommand` at line 150) [VERIFIED: grep modules/lagoon/README.md:147-150].
|
||||
|
||||
### Q5. What to gitignore
|
||||
|
||||
Both hand-built apps use the identical `.gitignore`: `/bin/`, `/tmp/`, `*.exe`, `go.work.sum`, `.env`, `/storage/` and **commit** `main.go`, `plugins.gen.go`, `go.work` [VERIFIED: ../sm-bm-app/.gitignore, ../sm-summercmsio-app/.gitignore, `git ls-files`]. The site's `CLAUDE.md` states `main.go` and `plugins.gen.go` are "generated and committed". The framework itself ignores `go.work.sum` and `.env*` except `.env.example`.
|
||||
|
||||
Recommendation: generated `.gitignore` = `/bin/`, `/tmp/`, `/storage/`, `.env`, `go.work.sum` (+ `*.exe`). Commit `go.work`, `main.go`, `plugins.gen.go`, plugin `go.sum`. **Verified:** after committing with that `.gitignore`, a fresh `git clone` into another sibling dir ran `summer build` successfully and left `git status` clean (`go.work.sum` regenerated, ignored) [VERIFIED: empirical]. Note the app root has no `go.sum` (only `go.work.sum`) — fine in workspace mode.
|
||||
|
||||
### Q6. Framework detection and relative replace
|
||||
|
||||
- Read a candidate's module path with the existing `parseGoMod(path) (module string, replaces map[string]string, err error)` [VERIFIED: internal/build/scaffold.go:399-440]; compare to `frameworkModule`.
|
||||
- Relative replace: `filepath.Rel(appDir, frameworkAbs)` on **lexical** absolute paths for both sides (do not `EvalSymlinks` one side only); `filepath.ToSlash`; Go requires local replace paths to start with `./` or `../` — reuse the prefix logic from `editAppModule` [VERIFIED: internal/build/scaffold.go:322-329]. A symlinked sibling (`sib/summercms -> framework`) with `replace => ../summercms` built fine; the plugin got `../../../summercms` and the workspace accepted both replaces (same target) [VERIFIED: empirical].
|
||||
- **Binary source location (D-04 optional candidate): cheap and reliable enough as a last candidate.** `runtime.Caller(0)` inside `internal/build` returns the absolute compile-time path when built without `-trimpath` (the scratch `summer` binary embeds `/media/nvme/.../summercms.go/internal/build/scaffold.go`); with `-trimpath` it returns a module-relative path (`example.com/callertest/main.go`), so guard with `filepath.IsAbs` and always confirm `go.mod`'s module path at `<dir>/../../go.mod` [VERIFIED: empirical]. `debug.ReadBuildInfo()` gives no directory, only `Main.Path`/`Main.Version`; a build inside the checkout carries a VCS version such as `v0.1.13-0.20261007223035-d94f804dddd6+dirty` [VERIFIED: `go version -m`]. Order: `--framework` > siblings > binary source > error naming `--framework`.
|
||||
- Recommended sibling candidates: `<parent>/summercms`, `<parent>/summercms.go`; optionally scan all immediate subdirectories of the parent in sorted order (one `go.mod` read each). Refuse when the target would be created **inside** the framework checkout.
|
||||
- Require version: use `v0.0.0` (consistent with the plugin stub and `examples/hello`); a release tag from `ReadBuildInfo` is a possible refinement but not needed for building because the replace wins.
|
||||
|
||||
### Q11. Test conventions
|
||||
|
||||
- Real `go build` tests in `internal/build` are **not** `-short` gated today (`TestMakeAndAddPluginOnHelloCopy`, `TestScaffoldPluginSmoke` run `MakePlugin`+`AddPlugin`+`App`) [VERIFIED: internal/build/build_test.go:226-325, 423-494]; the whole package runs in ~17s [VERIFIED: `go test ./internal/build/` 17.3s]. `-short` gating with `t.Skip("requires testcontainers postgres")` is the convention for Docker-dependent tests [VERIFIED: modules/lagoon/postgres_test.go:91-93].
|
||||
- No golden-file infrastructure exists anywhere (no `-update` flag, no `testdata/*.golden`) [VERIFIED: grep]. Plan 4 introduces it: `internal/build/testdata/new/<file>.golden` + `var update = flag.Bool("update", false, "rewrite golden files")`.
|
||||
- `cmd/summer` tests drive commands through `bonfire.NewRoot("summer", toolCommands(), &buf)`, `t.Chdir`, and a `copyHelloApp` helper that rewrites the framework replace to an absolute path [VERIFIED: cmd/summer/main_test.go:102-218]. `TestToolCommandNames` lists required tool commands — add `"new"` there and a `helpWants` entry for its flags [VERIFIED: cmd/summer/main_test.go:17-70].
|
||||
- Phase gate scripts live in `scripts/check-phase<N>.sh` with `set -euo pipefail`, `PHASE<N>_*` env overrides and `go -C` for sibling repos [VERIFIED: scripts/check-phase14.2.1.sh:1-40]. Lean mode: optional, but the container acceptance (D-20) fits this convention as `scripts/check-phase14.3.sh`.
|
||||
|
||||
## Architecture Patterns
|
||||
|
||||
### System Architecture Diagram
|
||||
|
||||
```
|
||||
user shell
|
||||
│ curl -fsSL https://summercms.io/new | bash -s -- demo
|
||||
▼
|
||||
nginx (GET/HEAD passthrough) ──► summercms-io binary ──► site plugin GET /new
|
||||
│ bytes = <pkg>.Script() (embedded new.sh at tag v0.1.13)
|
||||
◄──────────── text/plain; charset=utf-8 ────────────────┘
|
||||
bash: main "$@"
|
||||
├─ check: go ≥ 1.27 (fail) · docker (warn) · git (fail) · name regex (fail)
|
||||
├─ framework dir ($SUMMERCMS_DIR or ./summercms)
|
||||
│ ├─ absent → git clone $SUMMERCMS_REPO → checkout latest vX.Y.Z (or $SUMMERCMS_REF)
|
||||
│ └─ present → verify go.mod module · clean · detached · fetch --tags · ff-only to newest tag, else refuse
|
||||
├─ go -C <fw> install ./cmd/summer → $GOBIN or $(first GOPATH)/bin (PATH hint if missing)
|
||||
├─ "$gobin/summer" new demo --framework <fw> (cwd = parent of <fw>)
|
||||
│ └─ internal/build.NewApp
|
||||
│ ├─ validate name / refuse existing sm-demo-app
|
||||
│ ├─ resolve framework (flag > siblings > binary source) → relative replace
|
||||
│ ├─ write go.mod, go.work, summer.yaml, config/, .env(0600), .env.example, compose.yaml, .gitignore, CLAUDE.md, AGENTS.md
|
||||
│ ├─ makePlugin(golem15.demo, StarterRoute) → go mod tidy (network/module cache)
|
||||
│ ├─ AddPlugin (relative go work use)
|
||||
│ ├─ generate() → main.go, plugins.gen.go
|
||||
│ └─ git init + commit (fallback identity) unless --no-git
|
||||
└─ print next steps
|
||||
then: cd sm-demo-app → docker compose up -d --wait → summer build → summer migrate → summer serve → curl :8080/
|
||||
```
|
||||
|
||||
### Recommended Project Structure
|
||||
|
||||
```
|
||||
internal/build/
|
||||
├── new.go # NewOptions, NewApp, validateAppName, findFramework, planNewApp (pure) + executor
|
||||
├── stubs/new.tmpl # {{define "new/go.mod"}}, "new/compose.yaml", "new/CLAUDE.md", "new/config/http.yaml", ... (flat: embed pattern is stubs/*.tmpl)
|
||||
├── stubs/plugin.tmpl # routes.go gains {{if .StarterRoute}}
|
||||
└── testdata/new/*.golden (plan 4)
|
||||
cmd/summer/main.go # newCommand(): Args name; Flags framework, no-git, no-plugin (Bare)
|
||||
modules/lagoon/keygen.go # + GenerateAppKey() (string, error)
|
||||
modules/<pkg>/ # NEW exported package (name is planner's call, e.g. "sunrise")
|
||||
├── new.sh # the bootstrap script (single source of truth)
|
||||
├── <pkg>.go # //go:embed new.sh ; func Script() []byte
|
||||
├── <pkg>_test.go
|
||||
└── README.md # standard module README + row in root README.md
|
||||
examples/hello/config/http.yaml # D-07 gap fix
|
||||
```
|
||||
|
||||
`//go:embed` patterns cannot contain `..`, so `scripts/new.sh` cannot be embedded from `modules/<pkg>` [CITED: pkg.go.dev/embed — "Patterns may not contain '.' or '..' path elements"; ASSUMED wording]. Precedent for "the embedded file lives in the package dir": `modules/boardwalk` uses `//go:embed all:dist` on its own `dist/` [VERIFIED: modules/boardwalk/boardwalk.go:24]. The root README describes `scripts/` as "Verification scripts used as phase gates" [VERIFIED: README.md:79], another reason to keep `new.sh` in the package.
|
||||
|
||||
### Pattern 1: Pure plan + executor
|
||||
**What:** `planNewApp(opts) ([]genFile, error)` renders every file `summer new` writes itself (no I/O beyond reading the framework `go.mod`); `NewApp` writes them, then runs the go/git steps.
|
||||
**Why:** plan 4 golden tests render without network or `go mod tidy`, and the deterministic files (everything except `.env`'s key and the plugin `go.sum`) can be compared byte for byte.
|
||||
|
||||
### Pattern 2: Write `go.work` before `makePlugin`
|
||||
`findGoWork` walks **up** from the app dir [VERIFIED: internal/build/scaffold.go:493-506]; if an ancestor directory has a `go.work`, `make:plugin`'s `go mod tidy` and `plugin:add` would use it. Writing the app's own `go.work` (`use .`) first isolates the new app; `make:plugin` with a pre-existing app `go.work` succeeded [VERIFIED: empirical], and `ensureGoWork` then just runs `go work use` [VERIFIED: internal/build/scaffold.go:336-352]. This also gives `--no-plugin` apps a consistent layout (a `use .`-only workspace builds [VERIFIED: empirical]).
|
||||
|
||||
### Pattern 3: Starter route
|
||||
```go
|
||||
// routes.go, rendered by the existing "routes.go" define when StarterRoute is true (verified to work through surf)
|
||||
func (p *Plugin) Routes(r pact.Router) error {
|
||||
r.Get("/{$}", func(w http.ResponseWriter, req *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{"plugin": p.ID(), "message": "Hello from SummerCMS"})
|
||||
})
|
||||
return nil
|
||||
}
|
||||
```
|
||||
`pact.Router` has `Get(path string, handler http.HandlerFunc, middleware ...string)` [VERIFIED: modules/pact/capabilities.go:64-74]. `GET /{$}` (exact root, Go ServeMux syntax) appeared in `route:list` as `GET /{$} golem15.demo false` and answered 200 while `/nope` stayed 404 [VERIFIED: empirical]. An extra namespaced route (e.g. `/api/<name>/hello`) is optional.
|
||||
|
||||
### Pattern 4: `summer new` command wiring
|
||||
```go
|
||||
bonfire.Command{
|
||||
Name: "new", Description: "Create a SummerCMS application in sm-<name>-app",
|
||||
Args: []bonfire.Arg{{Name: "name", Description: "Lower-case letters and digits, starting with a letter", Required: true}},
|
||||
Flags: []bonfire.Flag{
|
||||
{Name: "framework", Description: "Path to the framework checkout"},
|
||||
{Name: "no-plugin", Description: "Do not create the starter plugin", Bare: true},
|
||||
{Name: "no-git", Description: "Do not initialise a git repository", Bare: true},
|
||||
},
|
||||
Run: ..., // os.Getwd() as parent; lagoon.GenerateAppKey(); build.NewApp(ctx, opts, out)
|
||||
}
|
||||
```
|
||||
`flagTrue(in, name)` already parses Bare flags [VERIFIED: cmd/summer/main.go:240-251]. Adding the command to `toolCommands()` automatically allows `summer new` in docs (the docs checker's tool list is derived from `toolCommands()`) [VERIFIED: cmd/summer/docs.go:144-148].
|
||||
|
||||
### Anti-Patterns to Avoid
|
||||
- **A second plugin template for the starter plugin** — violates D-05; use the `StarterRoute` option.
|
||||
- **Running `go mod tidy` on the app before `main.go`/`plugins.gen.go` exist** — tidy would drop the plugin `require` added by `plugin:add` (nothing imports it yet). If the app should get a `go.sum`, run tidy only after `generate()`. Not required for building.
|
||||
- **Capturing `key:generate` output in shell** (`$(bin/app key:generate)`) — includes `✓ `.
|
||||
- **`summer serve --addr …` in docs/next steps** — the delegating `summer serve` declares no flags: `unknown flag: --addr`; `summer serve -- --addr X` or `./bin/<app> serve --addr X` work [VERIFIED: empirical; cmd/summer/runtime.go:16-26].
|
||||
- **Naming script env overrides `SUMMER_*`** — compass would read them as config keys, and `terminalEnv` in `terminal_check_test.go` strips every `SUMMER_*` variable [VERIFIED: ../sm-summercmsio-app/terminal_check_test.go:66-88]. Use `SUMMERCMS_*` (matches the site's existing `SUMMERCMS_FRAMEWORK`, `SUMMERCMS_CLONE_URL`).
|
||||
|
||||
## Don't Hand-Roll
|
||||
|
||||
| Problem | Don't Build | Use Instead | Why |
|
||||
|---------|-------------|-------------|-----|
|
||||
| Plugin scaffold | new templates | `MakePlugin`/`AddPlugin` (+option) | D-05; byte-stable existing behaviour |
|
||||
| `go.mod`/`go.work` edits | string editing | `go mod edit`, `go work use` via `runGo`/`runGoWorkUse` | Go validates syntax |
|
||||
| `main.go` generation | copy of generator | `generate(appDir, m)` | single source |
|
||||
| App key | new random code | exported `lagoon.GenerateAppKey` | D-08 |
|
||||
| Go keyword check | hand list | `go/token.IsKeyword` | complete, stdlib |
|
||||
| Module path read | regex | `parseGoMod` | existing helper |
|
||||
| ETag / headers for `/new` | new scheme | site plugin's `file{body, etag, ctype}` + `setCommonHeaders` + `cacheNoCache` | consistent with every other site response [VERIFIED: plugins/golem15/summercms/static.go:17-23, 140-147, 187-221] |
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
### Pitfall 1: Absolute paths in the committed go.work
|
||||
**What goes wrong:** generated app works on the author's machine only. **Why:** `AddPlugin` passes an absolute dir to `go work use`. **Avoid:** relative arg (see Q1 refactor 1). **Warning sign:** `/` at the start of a `use` line in the golden `go.work`.
|
||||
|
||||
### Pitfall 2: Ancestor `go.work` capture
|
||||
Creating the app under a directory tree with a `go.work` would route `go mod tidy`/`go build` through that workspace. **Avoid:** write the app `go.work` first (Pattern 2); refuse a target inside the framework checkout.
|
||||
|
||||
### Pitfall 3: Names that pass `^[a-z][a-z0-9]*$` but break the build
|
||||
Verified with `make:plugin` + `plugin:add` + `build` [VERIFIED: empirical]:
|
||||
- `main` → `import ".../plugins/main" is a program, not an importable package`
|
||||
- `internal` → `use of internal package .../plugins/internal not allowed`
|
||||
- `go` (any Go keyword) → stub render fails (`package go` does not parse)
|
||||
- `init`, `string`, `vendor`, `testdata` → built fine.
|
||||
**Avoid:** `summer new` additionally rejects `token.IsKeyword(name)`, `main`, `internal`, with a message naming the rule. This is stricter than D-02's regex; it is a necessary refinement of the costly contract — surface to the user in the plan checkpoint. (`make:plugin` has the same latent issue; out of scope unless trivial.)
|
||||
|
||||
### Pitfall 4: Partial directory blocks a re-run
|
||||
`summer new` refuses an existing `sm-<name>-app` (D-02), so a failure in `go mod tidy` (e.g. offline) would leave a directory that blocks retry. **Avoid:** on error, `os.RemoveAll` the target **only** because it did not exist before this run; print the cause.
|
||||
|
||||
### Pitfall 5: git commit in a fresh container/CI
|
||||
`node:24-bookworm` runs as root with no git identity [VERIFIED: `id -u` → 0]. **Avoid:** when `git config user.name`/`user.email` are unset (and `GIT_AUTHOR_*` absent), run the first commit as `git -c user.name=SummerCMS -c user.email=summer@localhost commit ...` [VERIFIED: empirical commit with `HOME=<empty>` succeeded]. Run git with stdin closed; treat a commit failure (e.g. `commit.gpgsign=true` with an unavailable key) as a warning, not a fatal error — the app is still usable. Use `git init -b main` (git ≥ 2.28; bookworm has 2.39.5).
|
||||
|
||||
### Pitfall 6: pipefail + `head` in the script
|
||||
`git tag … | grep … | head -1` under `set -o pipefail` can fail with SIGPIPE (exit 141). **Avoid:** capture the whole list into a variable (`tags=$(git tag -l 'v*' --sort=-v:refname)`) and pick the first exact `^v[0-9]+\.[0-9]+\.[0-9]+$` match in a bash loop. [ASSUMED: general bash behaviour; not reproduced here]
|
||||
|
||||
### Pitfall 7: hanging git credential prompts / stdin
|
||||
With `curl | bash`, stdin is the script pipe. **Avoid:** `export GIT_TERMINAL_PROMPT=0`; run children with `</dev/null`; keep `main "$@"` as the very last line so nothing follows it in the pipe.
|
||||
|
||||
### Pitfall 8: `go env GOVERSION` formats
|
||||
Local toolchain reports `go1.27.0-X:nodwarf5` [VERIFIED: `go env GOVERSION`]; release candidates look like `go1.27rc1`; dev builds start with `devel`. Parse with `[[ $v =~ ^go([0-9]+)\.([0-9]+) ]]`; treat no match as "unknown" (warn, continue or fail — planner's call). Run it from a directory without `go.mod` so `GOTOOLCHAIN=auto` does not switch toolchains during the check.
|
||||
|
||||
### Pitfall 9: GOBIN/GOPATH
|
||||
`go install` writes to `$GOBIN`, else the **first** element of `$GOPATH` + `/bin` (GOPATH may be a list). Host: `GOBIN=/home/jin/go/bin` [VERIFIED: `go env`]. The script must invoke `summer new` via the absolute `"$gobin/summer"` and print `export PATH="$gobin:$PATH"` when `":$PATH:"` lacks it. In the root container this is `/root/go/bin`, not on PATH by default.
|
||||
|
||||
### Pitfall 10: Postgres not ready yet
|
||||
`docker compose up -d` returns before PostgreSQL accepts connections; `lagoon` opens once. **Avoid:** healthcheck in `compose.yaml` and next steps `docker compose up -d --wait` (compatible with D-15's step). [ASSUMED: `--wait` available in compose v2.1+; host has 5.5.0]
|
||||
|
||||
### Pitfall 11: Docker socket mount in the acceptance container
|
||||
With `-v /var/run/docker.sock:/var/run/docker.sock`, `docker compose up` starts the DB on the **host** daemon: (a) the published `127.0.0.1:<port>` is the host's loopback, so the acceptance container needs `--network host`; (b) bind mounts in `compose.yaml` would resolve host paths — use a **named volume** only; (c) the compose project name defaults to the directory name `sm-demo-app` and would collide with a real host project of that name — set a unique `COMPOSE_PROJECT_NAME` (or demo name) and `docker compose down -v` in cleanup; (d) the host port must be free. [ASSUMED: standard Docker behaviour]
|
||||
|
||||
### Pitfall 12: Cloning a host checkout inside a root container
|
||||
`git clone /fw x` with a host-owned bind mount fails: `fatal: detected dubious ownership in repository at '/fw'` [VERIFIED: empirical in node:24-bookworm]. **Avoid:** `git config --global --add safe.directory '*'` in the throwaway container, or clone from a bare mirror created inside the container. The clone only sees **committed** HEAD and tags.
|
||||
|
||||
### Pitfall 13: Tags vs. testing before v0.1.13
|
||||
Before `v0.1.13` exists, "latest tag" = `v0.1.12`, which has no `summer new`. **Avoid:** a `SUMMERCMS_REF` override (tag or commit, skips latest-tag selection) for pre-tag acceptance; optionally exercise tag selection with a bare mirror + throwaway tag (e.g. `v9.9.9`) in the gated script test.
|
||||
|
||||
### Pitfall 14: Release-build coupling on the site
|
||||
`scripts/build.sh` (release) takes the framework version from the app's `go.mod` (`go list -m -f '{{.Version}}' git.golem15.com/golem15/summercms`) and refuses if that tag is missing [VERIFIED: ../sm-summercmsio-app/scripts/build.sh:56-66]. The app requires `v0.1.12` and the site plugin `require git.golem15.com/golem15/summercms v0.1.12` [VERIFIED: grep go.mod files]. Serving `<pkg>.Script()` needs both bumped to `v0.1.13` **after** the tag exists; the docs embedded in the site are also built from that tag. Therefore every framework change of this phase (generator, script, docs, tests) must land before tagging `v0.1.13`; the site require bump and `/new` route go last; the site plugin can be developed against the local framework with `scripts/build.sh dev`.
|
||||
|
||||
## Bootstrap script design (Q10)
|
||||
|
||||
Reference read (pattern only): msd-core `scripts/bootstrap.sh` — `main() { set -euo pipefail; ... }` / `main "$@"`, env overrides `MSD_REPO/MSD_REF/MSD_HOME`, `--help` heredoc, refuses update when `git status --porcelain --untracked-files=no` is non-empty, `say()` helper with colored `==>` [VERIFIED: read from a scratch clone of https://git.golem15.com/golem15/msd-core, branch `next`; clone deleted afterwards]. Its update step does `fetch` + `checkout --detach FETCH_HEAD` (not fast-forward checked) — this phase needs a stricter rule (D-16).
|
||||
|
||||
Recommended semantics (D-16 settled):
|
||||
|
||||
| State of `$dir` | Action |
|
||||
|-----------------|--------|
|
||||
| does not exist | `git clone --quiet "$repo" "$dir"`; pick tag; `git -c advice.detachedHead=false -C "$dir" checkout --quiet --detach "refs/tags/$tag"` |
|
||||
| exists, not a git repo or `go.mod` module ≠ `git.golem15.com/golem15/summercms` | refuse: "… is not a SummerCMS framework checkout; set SUMMERCMS_DIR" |
|
||||
| tracked local changes (`git status --porcelain --untracked-files=no` non-empty) | refuse, do not reset |
|
||||
| HEAD on a branch (`git symbolic-ref -q HEAD` succeeds) | refuse: "on branch X; the installer manages a detached release checkout — use SUMMERCMS_DIR for a separate clone, or run `summer new --framework` yourself" |
|
||||
| detached | `git fetch --quiet --tags origin` (no `--force`: git refuses to clobber a moved tag); pick newest tag; if `HEAD` = tag commit → nothing; elif `git merge-base --is-ancestor HEAD "refs/tags/$tag"` → checkout tag; else refuse (not a fast-forward) |
|
||||
|
||||
- Tag discovery on fresh clone can also use `git ls-remote --tags --refs --sort=-v:refname "$repo" 'v*'` (verified against the public remote: newest `refs/tags/v0.1.12`) [VERIFIED: empirical]; the public HTTPS remote answers without credentials [VERIFIED: `git ls-remote`]. Full clone is small: packs 10.5 MiB, 1000 commits [VERIFIED: `git count-objects -vH`]; shallow clones complicate the ancestry check — use a full clone.
|
||||
- Env overrides (D-17): `SUMMERCMS_REPO` (default `https://git.golem15.com/golem15/summercms`), `SUMMERCMS_DIR` (default `./summercms`, relative to cwd), `SUMMERCMS_REF` (pin a tag/commit; skips "latest tag").
|
||||
- Always call `summer new "$name" --framework "$dir"` from `$(dirname "$dir")`-equivalent cwd (explicit beats detection; detection still works for manual use).
|
||||
- Validate `$name` in the script with the same regex for an early error before cloning; also fail early if `sm-$name-app` already exists in cwd.
|
||||
- Docker: `command -v docker` and `docker compose version` → warning only.
|
||||
- Next steps printed (by the script or by `summer new`, not both): `cd sm-<name>-app`, `docker compose up -d --wait`, `summer build`, `summer migrate`, `summer serve`, then `curl http://127.0.0.1:8080/`.
|
||||
|
||||
### Container acceptance (D-20)
|
||||
`node:24-bookworm` has git 2.39.5, bash 5.2.15, curl; **no** jq, docker or go; runs as root [VERIFIED: docker run]. Host Docker CLI is dynamically linked (do not bind-mount it) [VERIFIED: `file /usr/bin/docker`]. Recommended: `docker run --rm --network host -v /var/run/docker.sock:/var/run/docker.sock -v <fw>:/fw:ro node:24-bookworm bash -c '...'` that installs jq (`apt-get install -y jq`), the Go 1.27.0 tarball from go.dev, and the Docker CLI + compose plugin (Docker static binaries or Docker's apt repo), sets `safe.directory`, serves/reads the script (`bash /fw/modules/<pkg>/new.sh demo` or `python3 -m http.server`/`curl file://`), sets `SUMMERCMS_REPO=/fw SUMMERCMS_REF=<commit>`, then runs the D-20 chain with `summer serve &` and a curl retry loop on the starter route, then `docker compose down -v`. go1.27.0 tarball exists (302 to dl.google.com); current Go is 1.27.1 [VERIFIED: curl go.dev/dl]. [ASSUMED: exact Docker CLI install method — pick one in the plan]
|
||||
|
||||
## Site plugin, docs, landing page (Q7-Q9)
|
||||
|
||||
### Q7. Site plugin routing
|
||||
- `Routes` registers inside `r.GroupRaw("", nil, func(g pact.Router) { g.Get("/docs", …); g.Get("/docs/{path...}", docs.ServeHTTP); g.Get("/", site.ServeHTTP) })` [VERIFIED: plugins/golem15/summercms/plugin.go:43-62]. Add `g.Get("/new", …)` there; `GET /new` is more specific than the catch-all `GET /`, and Go's ServeMux serves HEAD for GET patterns (existing tests check HEAD on `/`) [VERIFIED: routes_test.go:36-47].
|
||||
- Plugin imports framework packages via `require git.golem15.com/golem15/summercms v0.1.12` + `replace … => ../../../../summercms.go` [VERIFIED: plugin go.mod]. Needs `v0.1.13` (Pitfall 14).
|
||||
- Headers: reuse `file{body, etag, ctype}` with ctype `"text/plain; charset=utf-8"` (already the `.txt` mapping), `Cache-Control: no-cache` + strong ETag, and `setCommonHeaders` (`X-Content-Type-Options: nosniff`, `Referrer-Policy`) [VERIFIED: static.go:17-47, 140-147, 187-221]. No robots-blocking header, no CSP (site contract).
|
||||
- nginx: `location ^~ /backend { return 404; }` and `location / { limit_except GET HEAD { deny all; } proxy_pass http://127.0.0.1:8095; … }`; `gzip_types` includes `text/plain` [VERIFIED: deploy/nginx/summercms.io.conf:62-83]. No nginx change needed. Today `https://summercms.io/new` returns `404 text/html` [VERIFIED: curl], so `curl -f` fails cleanly until deploy. The Nuxt app has only `app/pages/index.vue` — no `/new` collision [VERIFIED: ls].
|
||||
- Tests to extend: `TestRoutesAssemble` cases table (add `GET /new` 200 body = script, `HEAD /new`, `POST /new` 405) [VERIFIED: routes_test.go:26-69]; plugin `README.md` route table (lines 11-15).
|
||||
|
||||
### Q8. Docs pipeline
|
||||
- `docsite.Check(Options{Root, Commands: docsCommands()})`; `TestDocsTree` reports every problem [VERIFIED: cmd/summer/docs_test.go:24-34]. Command names in shell fences and code spans are checked: a line counts when it starts with `summer`, `go run ./cmd/summer`, `./bin/<x>` or `bin/<x>`; segments are split on `&&`, `||`, `;`, `|`; `--` ends flag parsing (so `summer serve -- --addr …` is not checked) [VERIFIED: internal/docsite/check_commands.go:33-34, 108-151]. `curl -fsSL https://summercms.io/new | bash -s -- demo` is not checked (neither segment starts with a known program). `docker compose …` lines are ignored.
|
||||
- `llms.txt`/`llms-full.txt` are emitted by `docs:build` (`s.outputs["llms.txt"] = s.llmsTxt()`, `llms-full.txt` concatenates pages in reading order) [VERIFIED: internal/docsite/emit.go:165-166, 228-259]; they are **not committed** in the framework (`git ls-files | grep -i llms` empty; `/site/` ignored) — they are regenerated by the site build from the tag. "Regenerate" in this phase = `summer docs:build --out <tmp>` and check the one-liner and `summer new` appear in `llms-full.txt`; `TestDocsBuildRealTree` already asserts both files exist [VERIFIED: docs_test.go:52-72]. `site.yaml` `llms_notes` could gain a note about `summer new` (optional) [VERIFIED: docs/site.yaml:7-11].
|
||||
- Pages: `docs/setup/installation.md` (order 20; currently clone → `go install` → `examples/hello`; contains the obsolete "Known issues" warning at the end) [VERIFIED: file read]; `docs/architecture/go-modules-and-workspaces.md` (shows `replace … => ../summercms.go`; says `plugin:add` "adds the plugin directory to the nearest `go.work`") [VERIFIED]; `docs/console/scaffolding.md` has "Building and watching", "Creating a plugin", "Generating plugin parts" — add "Creating an application" with `summer new` [VERIFIED: headings grep].
|
||||
|
||||
### Q9. Landing page and TestTerminalCommands
|
||||
- Single source: `vue-summercmsio-app/app/data/terminal.json` (3 groups, 6 commands: `git clone https://git.golem15.com/golem15/summercms`, `cd summercms`, `go install ./cmd/summer`, `cd examples/hello`, `summer build`, `./bin/hello greeter:hello`) with comment keys `terminal.getFramework|installCli|buildExample` in `i18n/locales/en.json` [VERIFIED: files read].
|
||||
- Tests that pin today's content and must change together:
|
||||
- `sm-summercmsio-app/terminal_check_test.go`: `terminalCloneURL` const, `TestLoadTerminal` asserts `len(groups) != 3 || commands != 6` and first command = clone, `TestTerminalScript` verbatim list, gated `TestTerminalCommands` (`SUMMERCMS_TERMINAL_CHECK=1`, runs `bash -euo pipefail -c <script>` in a temp dir with `terminalEnv` that drops `SUMMER_*`, `GOWORK`, `GOBIN` and puts a temp GOBIN first on PATH; expects `handled=true`) [VERIFIED: terminal_check_test.go:19-137, 160-190].
|
||||
- `vue-summercmsio-app/tests/terminal.test.ts`: `copyPayload` equals the six-command string, `split('\n').length === 6` [VERIFIED].
|
||||
- `vue-summercmsio-app/tests/output.test.ts`: "Get started shows the chips, the six commands…" and **"every en.json string is rendered on the page"** (`assertLeavesRendered(readIndex(), undefined, ['terminal.copied'])`) — removing a group requires removing its `en.json` key [VERIFIED: output.test.ts:190-216].
|
||||
- Plugin `links_test.go` `TestTerminalCommandsInPage` (needs `SUMMERCMS_REQUIRE_BUILD`) checks every command and comment appears in the built page [VERIFIED: links_test.go:226-260].
|
||||
- Recommended new terminal content: group 1 `curl -fsSL https://summercms.io/new | bash -s -- demo`; group 2 `cd sm-demo-app`, `docker compose up -d --wait`; group 3 `summer build`, `summer migrate`, `summer serve`. The gated runner then needs: an override for the script source (e.g. `SUMMERCMS_NEW_URL` replacing the URL in the curl command, analogous to the clone-URL rewrite), passthrough of `SUMMERCMS_REPO/REF` (they survive `terminalEnv` because they are not `SUMMER_*`), a unique `COMPOSE_PROJECT_NAME` + `docker compose down -v` cleanup, and special handling of the blocking `summer serve` (run in background, curl the starter route, assert its JSON, kill). Keep `TestLoadTerminal`'s shape assertions updated to the new counts.
|
||||
|
||||
## Code Examples
|
||||
|
||||
### Go: relative `go work use`
|
||||
```go
|
||||
// Source: derived from internal/build/scaffold.go:322-329 (editAppModule prefix logic)
|
||||
rel, err := filepath.Rel(filepath.Dir(goWork), dir)
|
||||
if err != nil { return fmt.Errorf("build: go work use: %w", err) }
|
||||
rel = filepath.ToSlash(rel)
|
||||
if rel != "." && rel != ".." && !strings.HasPrefix(rel, "../") && !strings.HasPrefix(rel, "./") {
|
||||
rel = "./" + rel
|
||||
}
|
||||
cmd := exec.CommandContext(ctx, "go", "work", "use", rel)
|
||||
cmd.Dir = filepath.Dir(goWork)
|
||||
```
|
||||
|
||||
### Go: framework source candidate
|
||||
```go
|
||||
// Verified behaviour: absolute without -trimpath, module-relative with it.
|
||||
func binarySourceFramework() (string, bool) {
|
||||
_, file, _, ok := runtime.Caller(0) // .../internal/build/new.go
|
||||
if !ok || !filepath.IsAbs(file) {
|
||||
return "", false
|
||||
}
|
||||
root := filepath.Dir(filepath.Dir(filepath.Dir(file)))
|
||||
mod, _, err := parseGoMod(filepath.Join(root, "go.mod"))
|
||||
return root, err == nil && mod == frameworkModule
|
||||
}
|
||||
```
|
||||
|
||||
### Bash: skeleton
|
||||
```bash
|
||||
#!/usr/bin/env bash
|
||||
# SummerCMS bootstrap: curl -fsSL https://summercms.io/new | bash -s -- <name>
|
||||
# Everything lives in main() so a truncated download runs nothing.
|
||||
main() {
|
||||
set -euo pipefail
|
||||
export GIT_TERMINAL_PROMPT=0
|
||||
local repo="${SUMMERCMS_REPO:-https://git.golem15.com/golem15/summercms}"
|
||||
local dir="${SUMMERCMS_DIR:-summercms}" ref="${SUMMERCMS_REF:-}"
|
||||
local name="${1:-}"
|
||||
[[ $name =~ ^[a-z][a-z0-9]*$ ]] || { echo "usage: … | bash -s -- <name> (lower-case letters and digits, starting with a letter)" >&2; exit 2; }
|
||||
local v; v="$(cd / && go env GOVERSION 2>/dev/null || true)"
|
||||
if [[ ! $v =~ ^go([0-9]+)\.([0-9]+) ]] || (( BASH_REMATCH[1] == 1 && BASH_REMATCH[2] < 27 )); then
|
||||
echo "ERROR: SummerCMS needs Go 1.27 or newer (found: ${v:-none}). Install it from https://go.dev/dl/" >&2; exit 1
|
||||
fi
|
||||
# … clone/update (table above), go -C "$dir" install ./cmd/summer </dev/null,
|
||||
# gobin=$(go env GOBIN); [[ -n $gobin ]] || { IFS=: read -r gp _ <<<"$(go env GOPATH)"; gobin="$gp/bin"; }
|
||||
# "$gobin/summer" new "$name" --framework "$dir" </dev/null
|
||||
}
|
||||
main "$@"
|
||||
```
|
||||
(Skeleton values `SUMMERCMS_REPO/DIR/REF` are recommendations, `[ASSUMED]` names.)
|
||||
|
||||
### compose.yaml shape
|
||||
```yaml
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16
|
||||
environment:
|
||||
POSTGRES_USER: summer
|
||||
POSTGRES_PASSWORD: summer
|
||||
POSTGRES_DB: <name>
|
||||
ports:
|
||||
- "127.0.0.1:<port>:5432" # loopback only
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U summer -d <name>"]
|
||||
interval: 2s
|
||||
timeout: 3s
|
||||
retries: 30
|
||||
volumes:
|
||||
pgdata:
|
||||
```
|
||||
Values are discretionary; verified working DSN shape: `postgres://summer:summer@127.0.0.1:55439/summer?sslmode=disable` against `postgres:16` [VERIFIED: empirical]. Compose reads `.env` in the project dir for interpolation only; keep `.env` values unquoted and free of `$` (base64 keys and the DSN contain none). [ASSUMED: compose dotenv parsing details]
|
||||
|
||||
## State of the Art
|
||||
|
||||
| Old Approach | Current Approach | When Changed | Impact |
|
||||
|--------------|------------------|--------------|--------|
|
||||
| Hand-written go.mod/summer.yaml/config per docs | `summer new <name>` | this phase | Installation docs and landing page lead with the one-liner |
|
||||
| Landing page: clone + install + build `examples/hello` | `curl -fsSL https://summercms.io/new \| bash -s -- demo` | this phase | `terminal.json` + four test files change |
|
||||
| `plugin:add` writes absolute `go.work` paths | relative paths | this phase | Fix existing behaviour |
|
||||
|
||||
**Deprecated/outdated:** the `installation.md` warning about a stale `examples/hello/main.go` (no longer true).
|
||||
|
||||
## Assumptions Log
|
||||
|
||||
| # | Claim | Section | Risk if Wrong |
|
||||
|---|-------|---------|---------------|
|
||||
| A1 | `//go:embed` forbids `..` path elements, so `scripts/new.sh` cannot be embedded from `modules/<pkg>` | Recommended Project Structure | Low — alternative is a Go package in `scripts/` itself |
|
||||
| A2 | `pipefail` + early-exiting `head`/`awk` can yield SIGPIPE exit 141 | Pitfall 6 | Low — the capture-to-variable approach is safe regardless |
|
||||
| A3 | `docker compose up -d --wait` waits for the healthcheck | Pitfall 10 | Low — fall back to a retry loop |
|
||||
| A4 | Docker socket mount behaviour (host network, host paths, project-name collisions) | Pitfall 11 | Medium — acceptance harness may need adjusting |
|
||||
| A5 | Script env names `SUMMERCMS_REPO/DIR/REF` and the new-module name | Bootstrap design | Low — naming only |
|
||||
| A6 | Compose `.env` interpolation tolerates the generated `.env` | compose.yaml | Low — values verified free of `$` |
|
||||
| A7 | Exact Docker CLI install method for node:24-bookworm | Container acceptance | Low |
|
||||
|
||||
## Open Questions (RESOLVED)
|
||||
|
||||
1. **Denylist beyond D-02's regex (`main`, `internal`, Go keywords).** Known: they break the build (verified). Unclear: whether the user wants this stated in the public contract. Recommendation: reject them in `summer new` and mention the rule in docs and `--help`; confirm at the plan checkpoint. **RESOLVED:** D-22 (adopted).
|
||||
2. **Default DB port/credentials.** Recommendation: user/password `summer`/`summer`, DB `<name>`, a fixed non-5432 loopback host port (e.g. 54320–54399; optionally derived from the name to avoid clashes between several apps). Planner's call. **RESOLVED:** D-23; plan 01 picks 127.0.0.1:54320, summer/summer, DB <name>.
|
||||
3. **Where next steps are printed** (script vs `summer new`). Recommendation: `summer new` prints them (useful for manual runs); the script adds only the PATH hint. **RESOLVED:** D-24.
|
||||
4. **New module name** for the embedded script (e.g. `sunrise`) — planner's call; it needs a README and root table row. **RESOLVED:** D-26; plan 02 names it `sunrise`.
|
||||
5. **`go.sum` at the app root.** Not needed in workspace mode; running `go mod tidy` after `generate()` would add one for `GOWORK=off` builds. Recommendation: skip (matches both reference apps' reliance on `go.work`). **RESOLVED:** D-25.
|
||||
|
||||
## Environment Availability
|
||||
|
||||
| Dependency | Required By | Available | Version | Fallback |
|
||||
|------------|------------|-----------|---------|----------|
|
||||
| Go | everything | ✓ | go1.27.0-X:nodwarf5 | — |
|
||||
| git | generator git step, bootstrap | ✓ | 2.55.0 | `--no-git` |
|
||||
| Docker + compose | compose.yaml, acceptance, gated tests | ✓ | 29.7.2 / Compose 5.5.0 | skip gated tests with `-short` |
|
||||
| postgres:16 image | migrate/serve acceptance | ✓ | local image | pulled on demand |
|
||||
| node:24-bookworm image | D-20 acceptance | ✓ | local image | — |
|
||||
| shellcheck | script lint test | ✓ | 0.11.0 | `t.Skip` when absent |
|
||||
| jq | D-20 (listed prerequisite) | ✓ host / ✗ in node:24-bookworm | 1.x | `apt-get install jq` in container |
|
||||
| Network to proxy.golang.org | `go mod tidy` in `make:plugin` | ✓ | — | warm module cache |
|
||||
| https://git.golem15.com/golem15/summercms | bootstrap clone | ✓ public | tags up to v0.1.12 | `SUMMERCMS_REPO` local path |
|
||||
|
||||
**Missing dependencies with no fallback:** none.
|
||||
|
||||
## Validation Architecture
|
||||
|
||||
### Test Framework
|
||||
| Property | Value |
|
||||
|----------|-------|
|
||||
| Framework | Go stdlib `testing` (Go 1.27.0); node `node:test` in vue-summercmsio-app |
|
||||
| Config file | none |
|
||||
| Quick run command | `go test ./internal/build/ ./cmd/summer/ ./modules/lagoon/ ./modules/<pkg>/ -short` |
|
||||
| Full suite command | `go vet ./... && go test ./...` (framework root) + `go -C examples/hello test ./...` |
|
||||
|
||||
### Per-plan commands
|
||||
|
||||
| Plan | Behaviour | Test Type | Automated Command |
|
||||
|------|-----------|-----------|-------------------|
|
||||
| 1 Generator | `summer new demo` in a temp parent with a `summercms` symlink to the repo builds | smoke (real build) | `go test ./internal/build/ -run 'TestNewApp' -count=1` |
|
||||
| 1 | `summer new` wired in CLI, help shows flags | unit | `go test ./cmd/summer/ -run 'TestToolCommandNames' -count=1` |
|
||||
| 1 | `GenerateAppKey` returns 44-char StdEncoding of 32 bytes; `key:generate` uses it | unit | `go test ./modules/lagoon/ -run 'Key' -short -count=1` |
|
||||
| 1 | plugin:add writes relative `go.work` paths | unit | `go test ./internal/build/ -run 'TestMakeAndAddPluginOnHelloCopy' -count=1` |
|
||||
| 1 | hello gap fixed | integration | `go -C examples/hello test ./... -count=1` |
|
||||
| 2 Script | shebang, `main "$@"` last line, `bash -n`, shellcheck, truncation runs nothing, name validation, tag selection on a temp repo | unit + gated e2e | `go test ./modules/<pkg>/ -count=1` ; gated: `SUMMERCMS_BOOTSTRAP_CHECK=1 go test ./modules/<pkg>/ -run TestBootstrapEndToEnd -count=1` |
|
||||
| 2 Site route | `GET /new` 200 `text/plain; charset=utf-8`, body = `Script()`, HEAD ok, POST 405, nosniff, ETag/no-cache | unit | `go -C ../sm-summercmsio-app/plugins/golem15/summercms test ./... -count=1` |
|
||||
| 2 Acceptance | D-20 container chain ends with starter route 200 | manual/gated | `bash scripts/check-phase14.3.sh --acceptance` (or the documented `docker run` command) |
|
||||
| 3 Docs | docs tree valid, `summer new` known command, llms-full contains one-liner | unit | `go test ./cmd/summer -run 'TestDocsTree|TestDocsBuildRealTree' -count=1` and `go run ./cmd/summer docs:build --check` |
|
||||
| 3 Landing | terminal.json shape, copy payload, page render | unit | `go -C ../sm-summercmsio-app test -run 'TestLoadTerminal|TestTerminalScript|TestTerminalEnv' .` ; `cd ../sm-summercmsio-app/vue-summercmsio-app && pnpm generate && pnpm test` |
|
||||
| 3 Landing (gated) | one-liner runs end to end | gated e2e | `SUMMERCMS_TERMINAL_CHECK=1 SUMMERCMS_NEW_URL=… SUMMERCMS_REPO=… go -C ../sm-summercmsio-app test -run TestTerminalCommands .` |
|
||||
| 4 Unit tests | golden output for every rendered file; name/denylist/existing-dir/framework-detection errors; relative replace; `--no-plugin`, `--no-git`; no-identity commit; cleanup on failure; real build (`-short` skip) | unit + golden | `go test ./internal/build/ -run 'TestNew' -count=1` ; update goldens: `go test ./internal/build/ -run TestNewGolden -update` |
|
||||
|
||||
### Sampling Rate
|
||||
- **Per task commit:** quick run command above (~40s incl. real builds).
|
||||
- **Per plan:** full suite + the plan's sibling-repo command.
|
||||
- **Phase gate:** full suite green, docs check green, D-20 acceptance run recorded, before `/gsd-verify-work` and before tagging `v0.1.13`.
|
||||
|
||||
### Wave 0 Gaps
|
||||
- [ ] `internal/build/testdata/new/` golden files + `-update` flag (no golden infra exists yet).
|
||||
- [ ] `modules/<pkg>/` package, README, tests (new).
|
||||
- [ ] Test helper to create `<tmp>/summercms` → repo-root symlink so the generated replace is deterministic (`../summercms`).
|
||||
- [ ] Optional `scripts/check-phase14.3.sh` acceptance runner.
|
||||
|
||||
## Security Domain
|
||||
|
||||
`security_enforcement` is absent from `.planning/config.json` (treated as enabled). Plan 2 gets the security-review agent (CONTEXT discretion).
|
||||
|
||||
### Applicable ASVS Categories
|
||||
|
||||
| ASVS Category | Applies | Standard Control |
|
||||
|---------------|---------|-----------------|
|
||||
| V2 Authentication | no | — |
|
||||
| V3 Session Management | no | — |
|
||||
| V4 Access Control | no | `/new` is public and read-only by design |
|
||||
| V5 Input Validation | yes | `<name>` regex + keyword/`main`/`internal` denylist before any path use; `--framework` resolved and verified by `go.mod` module path; all script variables quoted |
|
||||
| V6 Cryptography | yes (key gen) | `crypto/rand` via exported `lagoon.GenerateAppKey`; never hand-roll |
|
||||
| V10 Malicious Code / supply chain | yes | HTTPS only, `curl -fsSL` (fails on HTTP errors), script versioned by tag, tags fetched without `--force`, no `eval`, no `sudo` |
|
||||
| V12 Files and Resources | yes | refuse existing target; create only under cwd; `.env` mode 0600; no writes outside cwd and GOBIN |
|
||||
| V14 Configuration | yes | secrets only in `.env` (gitignored); `.env.example` placeholders; compose port bound to `127.0.0.1` |
|
||||
|
||||
### Known Threat Patterns
|
||||
|
||||
| Pattern | STRIDE | Standard Mitigation |
|
||||
|---------|--------|---------------------|
|
||||
| Truncated download executes partial script | Tampering | `main()` wrapper, call on last line (D-14); test truncations |
|
||||
| HTML error page piped to bash | Tampering | `curl -f`; today `/new` answers 404 so curl exits non-zero [VERIFIED] |
|
||||
| Browser renders script as HTML | Spoofing/XSS | `text/plain; charset=utf-8` + `X-Content-Type-Options: nosniff`; no user input reflected |
|
||||
| Moved/re-pointed upstream tag | Tampering | `git fetch --tags` without `--force` refuses to clobber an existing tag; ff-only check |
|
||||
| Credential prompt hang / leak | DoS/Info | `GIT_TERMINAL_PROMPT=0`, children with `</dev/null` |
|
||||
| Dev Postgres exposed on all interfaces (Docker bypasses host firewalls) | Info disclosure | publish on `127.0.0.1:<port>` only |
|
||||
| Secret committed | Info disclosure | `.env` in `.gitignore` before the first `git add -A`; golden test asserts `.env` not in the commit |
|
||||
| Path traversal via name | Tampering | regex excludes `/`, `.`; `underRoot` checks already in `MakePlugin` [VERIFIED: scaffold.go:62-64] |
|
||||
| Running the user's existing checkout code | Tampering | refuse dirty/branch checkouts instead of resetting (D-16) |
|
||||
|
||||
## Sources
|
||||
|
||||
### Primary (HIGH confidence)
|
||||
- Framework source read this session: `cmd/summer/{main,runtime,docs}.go`, `internal/build/{scaffold,build,manifest}.go`, `internal/build/stubs/plugin.tmpl`, `internal/build/build_test.go`, `cmd/summer/{main,docs}_test.go`, `modules/lagoon/{keygen,encrypted,commands,connection}.go`, `modules/lagoon/attach/bucket.go`, `modules/compass/{env,config}.go`, `modules/surf/{serve,router}.go`, `modules/cabana/http.go`, `modules/pact/capabilities.go`, `modules/bonfire/{command,output}.go`, `internal/docsite/{check_commands,emit}.go`, `docs/setup/installation.md`, `docs/architecture/go-modules-and-workspaces.md`, `docs/site.yaml`, `scripts/check-phase14.2.1.sh`.
|
||||
- Sibling repos: `../sm-bm-app` and `../sm-summercmsio-app` (`.gitignore`, `summer.yaml`, `go.work`, `go.mod`, `config/*.yaml`, `CLAUDE.md`, `scripts/build.sh`, `deploy/nginx/summercms.io.conf`, `terminal_check_test.go`, plugin `plugin.go`/`static.go`/`routes_test.go`/`links_test.go`, `vue-summercmsio-app/app/data/terminal.json`, `i18n/locales/en.json`, `tests/{terminal,output}.test.ts`).
|
||||
- Empirical runs (scratchpad): empty-plugin build/route:list/serve; full simulated `summer new` (make:plugin + plugin:add + starter routes) with migrate + serve + curl against `postgres:16`; fresh-clone rebuild; keyword/`main`/`internal` names; `go work use` relative vs absolute; `runtime.Caller` with/without `-trimpath`; `go version -m`; container git `safe.directory`; `git ls-remote --sort`.
|
||||
- msd-core `scripts/bootstrap.sh` (scratch clone of the public remote, reference only, deleted).
|
||||
|
||||
### Secondary (MEDIUM confidence)
|
||||
- go.dev/dl availability of go1.27.0 / current go1.27.1 (curl).
|
||||
|
||||
### Tertiary (LOW confidence)
|
||||
- General bash/Docker behaviours listed in the Assumptions Log.
|
||||
|
||||
## Metadata
|
||||
|
||||
**Confidence breakdown:**
|
||||
- Standard stack: HIGH — no new dependencies; all internals read and exercised.
|
||||
- Architecture: HIGH — the full generated layout was hand-built and served end to end.
|
||||
- Pitfalls: HIGH for the empirically reproduced ones (1, 3, 5, 12, 14, `--addr`, `✓` prefix); MEDIUM for Docker/compose harness details.
|
||||
|
||||
**Research date:** 2026-10-08
|
||||
**Valid until:** 2026-11-07 (framework internals change quickly; re-check `internal/build` line numbers before executing)
|
||||
@@ -0,0 +1,86 @@
|
||||
---
|
||||
phase: 14.3
|
||||
review: 14.3-REVIEW.md
|
||||
titles: json
|
||||
findings:
|
||||
- id: CR-01
|
||||
severity: critical
|
||||
disposition: open
|
||||
title: "A framework path containing whitespace writes an unparseable `go.mod`, and `--no-plugin` reports success"
|
||||
- id: WR-01
|
||||
severity: warning
|
||||
disposition: open
|
||||
title: "Ctrl+C during `summer new` leaves a partial `sm-<name>-app`, and every re-run is then refused"
|
||||
- id: WR-02
|
||||
severity: warning
|
||||
disposition: open
|
||||
title: "The bootstrap script accepts Go keywords, `main` and `internal`, then clones and installs before `summer new` refuses them"
|
||||
- id: WR-03
|
||||
severity: warning
|
||||
disposition: open
|
||||
title: "An invalid `SUMMERCMS_REF` on a fresh clone leaves a branch checkout that the next run refuses"
|
||||
- id: WR-04
|
||||
severity: warning
|
||||
disposition: open
|
||||
title: "The binary-source fallback can resolve into the Go module cache"
|
||||
- id: WR-05
|
||||
severity: warning
|
||||
disposition: open
|
||||
title: "Every generated application publishes PostgreSQL on the same fixed host port 54320"
|
||||
- id: WR-06
|
||||
severity: warning
|
||||
disposition: open
|
||||
title: "`go.work` is committed but `go.work.sum` is ignored, so a `--no-plugin` app commits no module checksums at all"
|
||||
- id: WR-07
|
||||
severity: warning
|
||||
disposition: open
|
||||
title: "`TestTerminalCommands` passes the developer's `SUMMERCMS_DIR` through to the script"
|
||||
- id: WR-08
|
||||
severity: warning
|
||||
disposition: open
|
||||
title: "The `--generator` gate stage stops `summer serve` but not the app binary it started"
|
||||
- id: IN-01
|
||||
severity: info
|
||||
disposition: open
|
||||
title: "The security-gate pin check accepts any cell containing the word \"gate\", and only an exact \"open\" status fails"
|
||||
- id: IN-02
|
||||
severity: info
|
||||
disposition: open
|
||||
title: "The git identity fallback treats name and email independently of the author/committer split and ignores `EMAIL`"
|
||||
- id: IN-03
|
||||
severity: info
|
||||
disposition: open
|
||||
title: "The framework-containment guard is purely lexical"
|
||||
- id: IN-04
|
||||
severity: info
|
||||
disposition: open
|
||||
title: "`SUMMERCMS_REF` silently accepts branch names, which never move forward on a re-run"
|
||||
- id: IN-05
|
||||
severity: info
|
||||
disposition: open
|
||||
title: "The existing-checkout path fetches from `origin` and ignores `SUMMERCMS_REPO` without saying so"
|
||||
- id: IN-06
|
||||
severity: info
|
||||
disposition: open
|
||||
title: "The site plugin requires framework v0.1.12, which has no `modules/sunrise`"
|
||||
- id: IN-07
|
||||
severity: info
|
||||
disposition: open
|
||||
title: "`validCommandName` now also admits `new` for application binaries"
|
||||
- id: IN-08
|
||||
severity: info
|
||||
disposition: open
|
||||
title: "The printed `cd` and next-step lines are not shell-quoted"
|
||||
- id: IN-09
|
||||
severity: info
|
||||
disposition: open
|
||||
title: "The app name has no length limit, but PostgreSQL truncates database names at 63 bytes"
|
||||
- id: IN-10
|
||||
severity: info
|
||||
disposition: open
|
||||
title: "`TestNewGitUsesConfiguredIdentity` never exercises a configured `user.name`/`user.email`"
|
||||
---
|
||||
|
||||
# Phase 14.3 review disposition
|
||||
|
||||
Every finding starts as `open`. Update each row to `fixed`, `deferred` or `wontfix` as it is triaged.
|
||||
@@ -0,0 +1,270 @@
|
||||
---
|
||||
phase: 14.3-one-command-app-scaffolding-summer-new-and-summercms-io-new
|
||||
reviewed: 2026-10-08T10:41:00Z
|
||||
depth: standard
|
||||
files_reviewed: 35
|
||||
files_reviewed_list:
|
||||
- cmd/summer/main.go
|
||||
- cmd/summer/main_test.go
|
||||
- internal/build/new.go
|
||||
- internal/build/new_test.go
|
||||
- internal/build/new_golden_test.go
|
||||
- internal/build/build_test.go
|
||||
- internal/build/scaffold.go
|
||||
- internal/build/stubs/new.tmpl
|
||||
- internal/build/stubs/plugin.tmpl
|
||||
- modules/bonfire/command.go
|
||||
- modules/bonfire/command_test.go
|
||||
- modules/lagoon/keygen.go
|
||||
- modules/lagoon/keygen_test.go
|
||||
- modules/sunrise/new.sh
|
||||
- modules/sunrise/sunrise.go
|
||||
- modules/sunrise/sunrise_test.go
|
||||
- modules/sunrise/script_behavior_test.go
|
||||
- scripts/check-phase14.3.sh
|
||||
- examples/hello/config/http.yaml
|
||||
- README.md
|
||||
- modules/bonfire/README.md
|
||||
- modules/lagoon/README.md
|
||||
- modules/sunrise/README.md
|
||||
- docs/setup/installation.md
|
||||
- docs/architecture/go-modules-and-workspaces.md
|
||||
- docs/console/scaffolding.md
|
||||
- docs/console/introduction.md
|
||||
- docs/site.yaml
|
||||
- ../sm-summercmsio-app/plugins/golem15/summercms/script.go
|
||||
- ../sm-summercmsio-app/plugins/golem15/summercms/plugin.go
|
||||
- ../sm-summercmsio-app/plugins/golem15/summercms/routes_test.go
|
||||
- ../sm-summercmsio-app/terminal_check_test.go
|
||||
- ../sm-summercmsio-app/vue-summercmsio-app/app/data/terminal.json
|
||||
- ../sm-summercmsio-app/vue-summercmsio-app/tests/terminal.test.ts
|
||||
- internal/build/testdata (goldens, read for cross-checking only)
|
||||
findings:
|
||||
critical: 1
|
||||
warning: 8
|
||||
info: 10
|
||||
total: 19
|
||||
status: issues_found
|
||||
---
|
||||
|
||||
# Phase 14.3: Code Review Report
|
||||
|
||||
**Reviewed:** 2026-10-08T10:41:00Z
|
||||
**Depth:** standard
|
||||
**Files Reviewed:** 35
|
||||
**Status:** issues_found
|
||||
|
||||
## Summary
|
||||
|
||||
I reviewed the `summer new` generator (`internal/build/new.go`, the `new/*` stubs and the starter-route `plugin.tmpl` branch), the `new` CLI command, `lagoon.GenerateAppKey`, the `sunrise` bootstrap script and its embedding, the site's `GET /new` handler, the landing-page terminal check and the phase gate script.
|
||||
|
||||
The core guards hold up. The target check uses `Lstat`, so it covers a directory, a file, a symlink and a dangling symlink. `os.Mkdir` closes the TOCTOU window. `.env` is created 0600 before the chmod, so it is never briefly readable by others. `.gitignore` keeps `.env` out of the first commit. The fallback git identity is passed with `-c` and never written to the repository config. The bootstrap script is truncation-safe: only `main "$@"` runs at top level, and the test checks every line-boundary prefix of the script plus every byte prefix of its last line. Every child process gets `</dev/null`. Moved upstream tags and non-fast-forward moves are refused. The `GET /new` handler sets `text/plain; charset=utf-8`, `nosniff`, `no-cache` and a strong ETag, and `http.ServeContent` answers 304 correctly.
|
||||
|
||||
The defects found:
|
||||
|
||||
- **Unquoted framework replace path (blocker).** A framework path containing whitespace produces a `go.mod` that Go cannot parse. With `--no-plugin`, `summer new` still reports success. I reproduced this.
|
||||
- **Cleanup only on returned errors.** The cleanup promise covers returned errors but not Ctrl+C. The bootstrap script also leaves its own fresh clone in a state that a re-run refuses.
|
||||
- **Script and generator disagree on names.** The script's name check is weaker than `ValidateAppName`, so a keyword name clones and installs before it fails. The docs say this cannot happen.
|
||||
- **Binary-source fallback.** This fallback can point an app's `replace` into GOMODCACHE.
|
||||
- **Shared fixed port.** Every generated app publishes PostgreSQL on the same fixed host port.
|
||||
- **No checksum file in some apps.** `go.work` is committed but `go.work.sum` is ignored, so a `--no-plugin` app commits no checksums at all.
|
||||
- **Test and gate reliability.** `TestTerminalCommands` passes `SUMMERCMS_DIR` from the developer's environment through to the script. The `--generator` gate stage stops `summer serve` without stopping the app binary it started.
|
||||
|
||||
## Critical Issues
|
||||
|
||||
### CR-01: A framework path containing whitespace writes an unparseable `go.mod`, and `--no-plugin` reports success
|
||||
|
||||
**File:** `internal/build/stubs/new.tmpl:9`, `internal/build/new.go:324`, also `internal/build/scaffold.go:280-296` (`frameworkReplaceFor`)
|
||||
**Issue:** `relModulePath` returns the raw relative path, and the template writes it into `go.mod` without quotes: `replace {{.Framework}} => {{.Replace}}`. In `go.mod` syntax, a path containing whitespace must be a quoted string. I reproduced this with `summer new demo --framework "my fw" --no-git --no-plugin`. The command printed `created …` and the next steps, but `go.mod` contained `replace git.golem15.com/golem15/summercms => ../my fw`. `summer build` then failed with `go.mod:9: malformed module path "../my": invalid path element ".."`.
|
||||
|
||||
With the starter plugin, the same unquoted path goes into `plugins/<name>/go.mod` through `frameworkReplaceFor`. `go mod tidy` then fails, and the user gets a removed directory and a confusing parse error. The bootstrap script hits the same failure for any `SUMMERCMS_DIR` whose last path segment contains a space, because it passes `--framework "$dir"`. This happens after the clone and `go install` have finished. The generator promises a "ready-to-run application", and this output breaks that promise without any error.
|
||||
|
||||
**Fix:** Quote the path whenever it needs quoting, in both templates, or refuse such paths before `os.Mkdir`:
|
||||
|
||||
```go
|
||||
// modFilePath renders a local path as a go.mod/go.work token.
|
||||
func modFilePath(p string) string {
|
||||
if strings.ContainsAny(p, " \t\"'`\\") {
|
||||
return strconv.Quote(p)
|
||||
}
|
||||
return p
|
||||
}
|
||||
// planNewApp: Replace: modFilePath(replace)
|
||||
// frameworkReplaceFor: return modFilePath(filepath.ToSlash(rel)), nil
|
||||
```
|
||||
|
||||
Alternatively, write the replace with `go mod edit -replace=` (Go quotes it), as `editAppModule` already does. Add a `TestNew*` case with a framework directory named `"my fw"` that builds the result.
|
||||
|
||||
## Warnings
|
||||
|
||||
### WR-01: Ctrl+C during `summer new` leaves a partial `sm-<name>-app`, and every re-run is then refused
|
||||
|
||||
**File:** `cmd/summer/main.go:15-26`, `internal/build/new.go:161-171`
|
||||
**Issue:** `NewApp` removes the target only when `populateApp` returns an error. `summer`'s `main` installs no signal handling, and neither does `bonfire.NewRoot`/`Execute`, which pass `cmd.Context()` with no cancellation. SIGINT or SIGTERM therefore kills the process immediately. This most likely happens during the starter plugin's `go mod tidy`, which can download modules for a minute or more. A half-written `sm-<name>-app` (no `main.go`, `plugins.gen.go` or plugin `go.sum`) stays behind. Both `summer new` and the bootstrap script (`new.sh:230`) then refuse the name with "already exists". `docs/console/scaffolding.md` says: "When a step fails after it created the directory, it removes the directory again, so you can rerun the same command." That is not true for an interrupt.
|
||||
**Fix:** Cancel the context on a signal, so `exec.CommandContext` kills `go`, `populateApp` returns an error, and the existing `RemoveAll` runs:
|
||||
|
||||
```go
|
||||
func main() {
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
root, err := bonfire.NewRoot("summer", toolCommands(), os.Stdout)
|
||||
...
|
||||
if err := root.ExecuteContext(ctx); err != nil { ... }
|
||||
}
|
||||
```
|
||||
|
||||
This requires bonfire to expose `ExecuteContext`, or `NewRoot` to accept a context. Alternatively, handle it locally in `newCommand`.
|
||||
|
||||
### WR-02: The bootstrap script accepts Go keywords, `main` and `internal`, then clones and installs before `summer new` refuses them
|
||||
|
||||
**File:** `modules/sunrise/new.sh:215`
|
||||
**Issue:** The script only checks `^[a-z][a-z0-9]*$`. `ValidateAppName` also refuses the 25 Go keywords, `main` and `internal`. Running `curl … | bash -s -- go` (or `type`, `map` or `main`) clones the framework, runs `go install`, and only then fails inside `summer new`. `docs/setup/installation.md` says: "The script stops before cloning when the name is invalid". For these names it does not.
|
||||
**Fix:** Mirror the full rule before any side effect:
|
||||
|
||||
```bash
|
||||
case "$name" in
|
||||
break|case|chan|const|continue|default|defer|else|fallthrough|for|func|go|goto|if|import|interface|map|package|range|return|select|struct|switch|type|var|main|internal)
|
||||
printf 'error: application name %q is a Go keyword or reserved (main, internal)\n\n' "$name" >&2
|
||||
usage >&2; exit 2 ;;
|
||||
esac
|
||||
```
|
||||
|
||||
Add a `TestScriptBehavior` case asserting no clone for `go` and `main`.
|
||||
|
||||
### WR-03: An invalid `SUMMERCMS_REF` on a fresh clone leaves a branch checkout that the next run refuses
|
||||
|
||||
**File:** `modules/sunrise/new.sh:163-169`, `:119`
|
||||
**Issue:** In the fresh-clone branch, `git clone` runs first, and `target_commit` only checks `SUMMERCMS_REF` afterwards. A typo such as `SUMMERCMS_REF=v0.1.99`, or the dash-prefixed value that the test itself uses, ends with "remove $dir and run again". `./summercms` is left on the clone's default branch. On the next run, `check_checkout` refuses with "is on branch main", so the user must delete the directory by hand. The script created that directory in this run, so it can safely remove it. The test "SUMMERCMS_REF starting with a dash is refused" never checks what is left on disk.
|
||||
**Fix:** Run the cheap format check (`[[ $ref != -* ]]`) in `main` before `checkout_framework`. In the clone branch, remove the directory on failure, because it did not exist before this run:
|
||||
|
||||
```bash
|
||||
target="$(target_commit "$dir" "$ref")" || { rm -rf -- "$dir"; die "no usable target in the new clone; removed $dir"; }
|
||||
```
|
||||
|
||||
### WR-04: The binary-source fallback can resolve into the Go module cache
|
||||
|
||||
**File:** `internal/build/new.go:286-297`
|
||||
**Issue:** `binarySourceFramework` accepts any absolute `runtime.Caller` path whose root `go.mod` declares the framework. A binary installed with `go install git.golem15.com/golem15/summercms/cmd/summer@vX` records its source under `$GOMODCACHE/git.golem15.com/golem15/summercms@vX/…`, because `go install pkg@version` does not imply `-trimpath`. In that case `summer new` (with no sibling and no flag) commits a `replace => ../../…/go/pkg/mod/git.golem15.com/golem15/summercms@vX`. That points into a read-only cache, is machine-specific, and breaks after `go clean -modcache`. It also contradicts the generated CLAUDE.md, which calls it "the local checkout".
|
||||
**Fix:** Accept only development builds:
|
||||
|
||||
```go
|
||||
if bi, ok := debug.ReadBuildInfo(); ok && bi.Main.Version != "(devel)" && bi.Main.Version != "" {
|
||||
return "" // installed from a module version, not a checkout
|
||||
}
|
||||
```
|
||||
|
||||
Or reject roots containing `/pkg/mod/` together with an `@v` path element.
|
||||
|
||||
### WR-05: Every generated application publishes PostgreSQL on the same fixed host port 54320
|
||||
|
||||
**File:** `internal/build/stubs/new.tmpl:67`, `internal/build/new.go:51`
|
||||
**Issue:** Each app's compose project gets its own name and volume, but `127.0.0.1:54320` is hard-coded in both `compose.yaml` and `.env`. The generated CLAUDE.md says the framework checkout "is shared with every other application next to it", and the bootstrap script is meant to be re-run for more apps in the same directory. Once two such apps exist, the second `docker compose up -d --wait` fails with "port is already allocated" while the first app's database is running.
|
||||
**Fix:** Make the port overridable in both files from one variable, for example `"127.0.0.1:${SUMMER_DB_PORT:-54320}:5432"` in compose. Either derive a per-app default (for example a stable hash of the name into 54320-54999) or document the collision and the override in the next steps.
|
||||
|
||||
### WR-06: `go.work` is committed but `go.work.sum` is ignored, so a `--no-plugin` app commits no module checksums at all
|
||||
|
||||
**File:** `internal/build/stubs/new.tmpl:84`
|
||||
**Issue:** The app has no root `go.sum` (by design), and its `go.work` is committed. I confirmed that the first `summer build` writes every framework dependency checksum to `go.work.sum`. That file is gitignored. For a `--no-plugin` app, the repository therefore pins no checksums at all. On a fresh clone, integrity for third-party modules depends entirely on GOSUMDB being reachable and enabled, which `GONOSUMDB`, `GOPRIVATE=*`, `GOFLAGS=-insecure` or offline proxies all defeat. Go's guidance is to commit `go.work.sum` whenever `go.work` is committed.
|
||||
**Fix:** Drop `go.work.sum` from the generated `.gitignore`, and update the scaffolding and go-modules docs and the `dot-gitignore` golden. If it stays ignored by design, record the decision and the accepted risk in the threat register.
|
||||
|
||||
### WR-07: `TestTerminalCommands` passes the developer's `SUMMERCMS_DIR` through to the script
|
||||
|
||||
**File:** `../sm-summercmsio-app/terminal_check_test.go:68-88` (line 74)
|
||||
**Issue:** `terminalEnv` drops `SUMMER_*`, `GOWORK` and `GOBIN`, but `SUMMERCMS_DIR` starts with `SUMMERC`, not `SUMMER_`, so it is kept. If a developer has `SUMMERCMS_DIR` exported, the gated test (and the `--terminal` gate stage) runs the script against that directory instead of the temp work dir:
|
||||
|
||||
- It fetches and may move that checkout forward.
|
||||
- It creates `sm-demo-app` next to it, outside `t.TempDir()`.
|
||||
- The `cd sm-demo-app` step then fails.
|
||||
- Cleanup's `docker compose down -v` looks in the wrong directory.
|
||||
|
||||
`TestBootstrapEndToEnd` (`modules/sunrise/sunrise_test.go:161`) correctly strips every `SUMMERCMS_*` variable and `GOFLAGS`.
|
||||
**Fix:** Drop `SUMMERCMS_DIR` explicitly (REPO and REF are meant to pass through) and also drop `GOFLAGS`:
|
||||
|
||||
```go
|
||||
case strings.HasPrefix(name, "SUMMER_"), name == "SUMMERCMS_DIR", name == "GOWORK", name == "GOBIN", name == "GOFLAGS":
|
||||
continue
|
||||
```
|
||||
|
||||
Extend `TestTerminalEnv` to cover these variables.
|
||||
|
||||
### WR-08: The `--generator` gate stage stops `summer serve` but not the app binary it started
|
||||
|
||||
**File:** `scripts/check-phase14.3.sh:154-156`, `:225-226`
|
||||
**Issue:** `summer serve` is a delegate. `cmd/summer/runtime.go:117-123` runs `bin/<app> serve` as a child through `exec.CommandContext`, and `summer` installs no signal handler. `kill "$server_pid"` sends SIGTERM to `summer` only, so the app binary is orphaned and can keep holding `:8080`. It dies only when it next writes to its broken stdout or stderr pipe. If it survives, the next `--all` stage that checks ports (`--terminal`, `port_free 8080`) fails, or a stale server answers. The site's `terminalServeBlock` uses `pkill -TERM -P "$serve_pid"` for exactly this reason (`terminal_check_test.go:101`).
|
||||
**Fix:** Stop the children first, as the site test does:
|
||||
|
||||
```bash
|
||||
pkill -TERM -P "$server_pid" 2>/dev/null || true
|
||||
kill "$server_pid" 2>/dev/null || true
|
||||
wait "$server_pid" 2>/dev/null || true
|
||||
```
|
||||
|
||||
A better fix is to forward signals in `runAppBinary` (see WR-01).
|
||||
|
||||
## Info
|
||||
|
||||
### IN-01: The security-gate pin check accepts any cell containing the word "gate", and only an exact "open" status fails
|
||||
|
||||
**File:** `scripts/check-phase14.3.sh:524-531`
|
||||
**Issue:** `re.search(r"Test[A-Z]\w+|--(…)\b|gate", pinned)` treats any substring "gate" as a pin. That includes "no gate yet" and "delegate". `status == "open"` lets "Open (deferred)" or "open: tracked" pass for non-high rows. Every current row is pinned properly, so this only weakens the fail-closed claim.
|
||||
**Fix:** Match `\bgate\b` against `--all`, or drop that alternative. Use `status.startswith("open")`.
|
||||
|
||||
### IN-02: The git identity fallback treats name and email independently of the author/committer split and ignores `EMAIL`
|
||||
|
||||
**File:** `internal/build/new.go:216-223`, `:236-244`
|
||||
**Issue:** If only `GIT_AUTHOR_EMAIL` is set and there is no config, no fallback is added. The committer email then falls back to auto-detection, which fails in containers with a bare hostname, so the commit becomes a warning. Conversely, `-c user.email=summer@localhost` overrides a user's `EMAIL` environment variable, which git would otherwise use.
|
||||
**Fix:** Treat each of author and committer separately, or check `git var GIT_COMMITTER_IDENT` / `GIT_AUTHOR_IDENT` and fall back only when one of them fails.
|
||||
|
||||
### IN-03: The framework-containment guard is purely lexical
|
||||
|
||||
**File:** `internal/build/new.go:153`
|
||||
**Issue:** `underRoot(framework, target)` compares lexical paths. A parent reached through a symlink into the framework checkout, or a `--framework` symlink pointing at an ancestor, passes the guard.
|
||||
**Fix:** Also compare `filepath.EvalSymlinks` of both paths, keeping the lexical paths for the `replace`.
|
||||
|
||||
### IN-04: `SUMMERCMS_REF` silently accepts branch names, which never move forward on a re-run
|
||||
|
||||
**File:** `modules/sunrise/new.sh:118-127`
|
||||
**Issue:** `rev-parse --verify "${ref}^{commit}"` resolves `main` to the local `refs/heads/main` created by the clone. `fetch` updates only `origin/main`, so a re-run reports "already at the target" on a stale commit. The usage text says "tag or commit", but nothing enforces it.
|
||||
**Fix:** Try `refs/tags/$ref` first and then a full commit hash. Refuse anything that resolves under `refs/heads/` or `refs/remotes/`.
|
||||
|
||||
### IN-05: The existing-checkout path fetches from `origin` and ignores `SUMMERCMS_REPO` without saying so
|
||||
|
||||
**File:** `modules/sunrise/new.sh:175`
|
||||
**Issue:** A checkout whose `origin` is a fork or a stale mirror is moved to that remote's newest tag. `SUMMERCMS_REPO` is not compared and no message is printed. A missing `origin` remote produces the misleading message "a release tag may have moved upstream".
|
||||
**Fix:** Print `git remote get-url origin` in the "updating" line, and warn when it differs from `$repo`.
|
||||
|
||||
### IN-06: The site plugin requires framework v0.1.12, which has no `modules/sunrise`
|
||||
|
||||
**File:** `../sm-summercmsio-app/plugins/golem15/summercms/go.mod:5`, `plugin.go:62`
|
||||
**Issue:** `modules/sunrise` exists only in the untagged working tree. Until v0.1.13 is tagged and the require is bumped (D-28, documented in 14.3-04-SUMMARY), every site build resolves the framework only through the local `replace ../summercms.go`. The bytes served at `/new` are whatever that working tree holds at build time.
|
||||
**Fix:** Follow the documented release steps. Consider a site test that fails when `go list -m git.golem15.com/golem15/summercms` resolves to a replace while building for deploy.
|
||||
|
||||
### IN-07: `validCommandName` now also admits `new` for application binaries
|
||||
|
||||
**File:** `modules/bonfire/command.go:112`
|
||||
**Issue:** The whitelist is shared by the `summer` tool and every app binary, so a plugin can now register a bare `new` command in an application. This is harmless today but broadens the bare-name surface.
|
||||
**Fix:** Keep the tool-only bare names in the tool's root, or document that `new` is reserved.
|
||||
|
||||
### IN-08: The printed `cd` and next-step lines are not shell-quoted
|
||||
|
||||
**File:** `modules/sunrise/new.sh:248`, `internal/build/stubs/new.tmpl:134-146`
|
||||
**Issue:** `printf ' cd %s\n' "$app"` prints a path that, if it contains spaces, breaks when copied and pasted.
|
||||
**Fix:** Use `printf ' cd %q\n' "$app"`.
|
||||
|
||||
### IN-09: The app name has no length limit, but PostgreSQL truncates database names at 63 bytes
|
||||
|
||||
**File:** `internal/build/new.go:22`, `internal/build/stubs/new.tmpl:65`
|
||||
**Issue:** `POSTGRES_DB` is truncated by the server while the `.env` DSN keeps the full name, so `summer migrate` fails with "database does not exist".
|
||||
**Fix:** Cap names at, for example, 40 characters in `appNameRe`/`ValidateAppName` and in `new.sh`.
|
||||
|
||||
### IN-10: `TestNewGitUsesConfiguredIdentity` never exercises a configured `user.name`/`user.email`
|
||||
|
||||
**File:** `internal/build/new_test.go:573-591`
|
||||
**Issue:** The test sets only `GIT_*` environment variables. The `git config <key>` branch of `gitIdentityMissing` that returns "configured" is never covered. Several generator tests (`TestNewCommandViaCLI`, `TestNewEnvFileMode`, `TestNewAgentsSymlink`, `TestNewGitMissingIsWarning`) also run the starter plugin's `go mod tidy` without a `testing.Short()` guard.
|
||||
**Fix:** Add a case that writes `user.name`/`user.email` to the isolated `GIT_CONFIG_GLOBAL` file. Gate the network-touching tests behind `-short` like `TestNewAppSmoke`, or use `NoPlugin` where the plugin is irrelevant.
|
||||
|
||||
---
|
||||
|
||||
_Reviewed: 2026-10-08T10:41:00Z_
|
||||
_Reviewer: Claude (gsd-code-reviewer)_
|
||||
_Depth: standard_
|
||||
@@ -0,0 +1,108 @@
|
||||
---
|
||||
phase: 14.3
|
||||
slug: one-command-app-scaffolding
|
||||
status: reviewed
|
||||
threats_total: 27
|
||||
threats_closed: 27
|
||||
threats_open: 0
|
||||
accepted_risks: 4
|
||||
asvs_level: 1
|
||||
created: 2026-10-08
|
||||
reviewer: gsd-executor (14.3-02 Task 3 and 14.3-04 Task 3, self-performed; see Reviewer Note)
|
||||
updated: 2026-10-08
|
||||
---
|
||||
|
||||
# Phase 14.3 — Security Review (plans 01 to 04)
|
||||
|
||||
> `summer new` (generator), the `curl | bash` bootstrap script in
|
||||
> `modules/sunrise`, the site route `GET /new` and the D-20 acceptance harness.
|
||||
> Every threat registered in 14.3-01-PLAN.md (T-14.3-01..10, SC) and
|
||||
> 14.3-02-PLAN.md (T-14.3-11..19, SC), 14.3-03-PLAN.md (T-14.3-20..22) and
|
||||
> 14.3-04-PLAN.md (T-14.3-23..26) is mapped below to its source-level control
|
||||
> and evidence. The "Pinned by (plan 04)" column names the executed test or
|
||||
> gate stage of `scripts/check-phase14.3.sh` that fails when the control
|
||||
> breaks; `--security` checks this table.
|
||||
|
||||
**Date:** 2026-10-08
|
||||
**Scope:** `git diff v0.1.12..HEAD -- internal/build cmd/summer modules/lagoon modules/sunrise scripts/check-phase14.3.sh` in `summercms.go` (15 files, +1520/-15), and the site plugin commit `2157bb9` (`script.go`, `plugin.go`, `routes_test.go`, `README.md`) in `sm-summercmsio-plugin`. Read alongside `../sm-summercmsio-app/deploy/nginx/summercms.io.conf`.
|
||||
**Level:** ASVS L1. A high finding blocks the phase.
|
||||
|
||||
## Reviewer Note
|
||||
|
||||
The plan names a security-review lane; this session had no separate auditor agent, so the plan 02 executor performed the review directly against the diff. Evidence was re-executed during the review, not inherited: the Task 1 verify chain (smoke, truncation, docs tree, plugin route and the gated `TestBootstrapEndToEnd`), the plan 02 `--acceptance` container run, and a throwaway-repository probe of every refusal path of `new.sh` (results in "Refusal probe" below).
|
||||
|
||||
## Method
|
||||
|
||||
1. Read every changed line in the scope above, with attention to: input validation of `<name>` in the script and in `build.ValidateAppName`; quoting of every expansion in `new.sh` (`shellcheck` clean except one deliberate SC2016 for the literal `$PATH` in the hint); behaviour under `curl | bash` (stdin, truncation, `curl -f` on HTTP errors); tag and ancestry handling; refusal paths that protect local work; write locations; `.env` handling and the first commit; compose port binding; MIME type and `nosniff` on `/new`; nginx passthrough; the acceptance harness's use of the host Docker socket.
|
||||
2. Mapped each registered threat to a control (file and function) and to evidence.
|
||||
3. Probed the script's refusal paths against a fake framework repository (module `git.golem15.com/golem15/summercms`, tags `v0.0.1`, `v0.0.2`).
|
||||
|
||||
## Verdict
|
||||
|
||||
27 threats in scope (T-14.3-01..26 and T-14.3-SC): 23 mitigated, 4 accepted (all low), **0 open**. No high finding; no code fix was needed. Every high row is pinned by a named test or gate stage (plan 04), and `bash scripts/check-phase14.3.sh --all` passed with these pins on 2026-10-08.
|
||||
|
||||
## ASVS L1
|
||||
|
||||
| ASVS | Applies | Control | Evidence |
|
||||
|------|---------|---------|----------|
|
||||
| V5 Input Validation | yes | `new.sh` `main`: `^[a-z][a-z0-9]*$` before any path use; `internal/build/new.go` `ValidateAppName`: same regex plus `token.IsKeyword`, `main`, `internal`; `--framework` and siblings accepted only when `go.mod` declares the framework module (`checkFramework`); `SUMMERCMS_REF` starting with `-` rejected (`target_commit`) | `TestNewAppSmoke`; refusal probe (bad name exit 2); plan 01 Task 2 rules check |
|
||||
| V6 Cryptography | yes | `modules/lagoon/keygen.go` `GenerateAppKey` on `crypto/rand`, 32 bytes; `NewApp` `validateAppKey` rejects anything else | `TestGenerateAppKey`, `TestEncryptedKeyGeneratePrints32ByteBase64`, `TestNewRequiresValidAppKey` |
|
||||
| V10 Malicious Code | yes | Script embedded from the framework release (`sunrise.Script`); `curl -fsSL`; HTTPS default clone URL; release tags only; tags fetched without `--force`; ancestry check; no `eval`, no `sudo`, no dynamic code strings | `TestScriptTruncationRunsNothing`, `TestBootstrapEndToEnd`, refusal probe, `--acceptance` |
|
||||
| V12 Files and Resources | yes | Script writes only `SUMMERCMS_DIR` (clone), `sm-<name>-app` (via `summer new`) and the Go install directory; parent must already exist; `NewApp` refuses an existing target (`os.Lstat`), creates with `os.Mkdir`, removes only what it created; `.env` 0600 | `TestScriptTruncationRunsNothing` (empty work dir), `--generator`, refusal probe |
|
||||
| V14 Configuration | yes | Secrets only in `.env` (gitignored before `git add -A`); `.env.example` without a key; compose publishes `127.0.0.1:54320`; `/new` served `text/plain; charset=utf-8` with `X-Content-Type-Options: nosniff` | `--generator` (`git ls-files .env` empty, mode 600), `TestNewScriptRoute` |
|
||||
|
||||
## Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Control (file / function) | Evidence now | Status | Pinned by (plan 04) |
|
||||
|-----------|----------|-----------|----------|-------------|---------------------------|--------------|--------|---------------------|
|
||||
| T-14.3-01 | Tampering | app name, target path | high | mitigate | `internal/build/new.go` `ValidateAppName` before `filepath.Join(parent, appDirName(name))`; target is always `<parent>/sm-<name>-app` | `TestNewAppSmoke`; plan 01 rules check (RULES-OK) | mitigated | TestNewValidateAppName, TestNewCommandViaCLI, TestScriptBehavior (name cases) |
|
||||
| T-14.3-02 | Information Disclosure | `.env`, first git commit | high | mitigate | `internal/build/new.go` `planNewApp` (`.env` mode 0600, `.gitignore` written with the files) and `initGit` (`git add -A` after `.gitignore` exists) | `scripts/check-phase14.3.sh --generator` asserts `git ls-files .env` empty and mode 600; acceptance run committed the app the same way | mitigated | TestNewEnvFileMode, TestNewGitFirstCommit (.env untracked), TestNewGolden (dot-gitignore) |
|
||||
| T-14.3-03 | Information Disclosure | app key generation | high | mitigate | `modules/lagoon/keygen.go` `GenerateAppKey` (`crypto/rand`); `internal/build/new.go` `validateAppKey` | `TestGenerateAppKey` (formerly `TestGenerateAppKeyIs32ByteBase64`); `--generator` (44-character key) | mitigated | TestGenerateAppKey, TestNewRequiresValidAppKey |
|
||||
| T-14.3-04 | Tampering | existing directory, cleanup | high | mitigate | `internal/build/new.go` `NewApp`: `os.Lstat` refusal, `os.Mkdir` (not `MkdirAll`), `os.RemoveAll` only of the directory this run created; `new.sh` `main` also stops early when `sm-<name>-app` exists (`-e` or `-L`) | refusal probe: existing `sm-demo-app` stops the script before any clone, exit 1 | mitigated | TestNewRefusesExistingTarget, TestNewCleansUpOnFailure, TestNewRefusesTargetInsideFramework, TestScriptBehavior (existing app) |
|
||||
| T-14.3-05 | Information Disclosure | compose.yaml Postgres port | medium | mitigate | `internal/build/stubs/new.tmpl` compose: `127.0.0.1:54320:5432`, named volume only | `--generator` and `--acceptance` ran the compose file; template grep | mitigated | TestNewGolden (compose.yaml) |
|
||||
| T-14.3-06 | Spoofing | framework auto-detection | medium | mitigate | `internal/build/new.go` `resolveFramework` / `checkFramework`: module path must be `git.golem15.com/golem15/summercms`; an explicit `--framework` never falls through; the script always passes `--framework` | `TestNewAppSmoke`; `--generator` (sibling detection) | mitigated | TestNewFrameworkResolution, TestScriptBehavior (fresh clone passes --framework) |
|
||||
| T-14.3-07 | Information Disclosure | committed go.mod / go.work | low | mitigate | `internal/build/scaffold.go` `relModulePath`, relative `runGoWorkUse` | `--generator` (no absolute `use` line); `TestBootstrapEndToEnd` (relative `../summercms` replace) | mitigated | TestNewGolden (go.mod, go.work), TestMakeAndAddPluginOnHelloCopy, --forbidden |
|
||||
| T-14.3-08 | Information Disclosure | compose.yaml dev credentials | low | accept | `summer`/`summer` is a documented local default for a loopback-only database; the encryption key stays in `.env` | template review | accepted | TestNewGolden (compose.yaml) |
|
||||
| T-14.3-09 | Information Disclosure | `summer serve` default `:8080` | low | accept | Existing surf default listens on all interfaces; next steps point at `127.0.0.1`; out of scope for this phase | review | accepted | — |
|
||||
| T-14.3-10 | Repudiation | fallback git identity | low | accept | `internal/build/new.go` `initGit` / `gitIdentityMissing`: fallback only for the missing keys, first commit only, announced with a warning | `--generator` and `--acceptance` printed the warning | accepted | TestNewGitFirstCommit, TestNewGitUsesConfiguredIdentity |
|
||||
| T-14.3-11 | Tampering | truncated download | high | mitigate | `modules/sunrise/new.sh`: top level holds only comments and function definitions; the last line is `main "$@"`; `main` parses arguments before any external command | `TestScriptTruncationRunsNothing` (every line-boundary prefix and every byte prefix of the final line, with stub `git`, `go`, `docker`, `curl`, `summer`; positive control proves the stubs record calls); `TestScriptShape` | mitigated | TestScriptTruncationRunsNothing, TestScriptShape |
|
||||
| T-14.3-12 | Tampering | error body piped to bash | medium | mitigate | Documented one-liner uses `curl -fsSL` (no body on HTTP errors); `GET /new` answers only the embedded bytes (`script.go` `scriptHandler`); a cut-off body is covered by T-14.3-11 | `TestNewScriptRoute`; `TestBootstrapEndToEnd` runs the exact `curl -fsSL … \| bash -s -- demo` form | mitigated | TestNewScriptRoute, TestBootstrapEndToEnd (--bootstrap) |
|
||||
| T-14.3-13 | Spoofing | `/new` rendered as HTML | medium | mitigate | `sm-summercmsio-plugin` `script.go` `scriptHandler`: `Content-Type: text/plain; charset=utf-8`, `X-Content-Type-Options: nosniff` via `setCommonHeaders`; no request data reflected; nginx allows GET/HEAD only (`limit_except GET HEAD`) and gzips `text/plain` | `TestNewScriptRoute` (headers, strong ETag, 304), `TestRoutesAssemble` (`POST /new` 405) | mitigated | TestNewScriptRoute |
|
||||
| T-14.3-14 | Tampering | moved upstream tag, non-fast-forward | high | mitigate | `new.sh` `checkout_framework`: `git fetch --quiet --tags origin` without `--force` (git refuses to clobber a local tag and the script stops); checkout only when `git merge-base --is-ancestor HEAD <target>`; otherwise exit 1 with the checkout unchanged | refusal probe: moved `v0.0.2` upstream → fetch refused, local tag kept at its original commit, HEAD unchanged; non-ancestor HEAD → "not a fast-forward", exit 1, HEAD unchanged | mitigated | TestScriptBehavior (moved upstream tag, non-fast-forward, re-run cases) |
|
||||
| T-14.3-15 | Tampering | user's existing checkout | high | mitigate | `new.sh` `check_checkout`: refuses a non-directory, a missing or foreign `go.mod` module, a non-git directory or a subdirectory of another repository, tracked changes (`git status --porcelain --untracked-files=no`), a branch (`git symbolic-ref -q HEAD`); the script contains no reset, clean, stash, `--force` or delete | refusal probe: branch checkout, dirty tracked file and non-framework directory each refused with exit 1 and left untouched | mitigated | TestScriptBehavior (tracked change, branch, another module, not a git repository) |
|
||||
| T-14.3-16 | Elevation of Privilege | shell injection via name or overrides | high | mitigate | `new.sh` `main`: name regex before use; every expansion quoted (`shellcheck` clean); overrides used only as arguments (`git clone -- "$repo" "$dir"`, `-C "$dir"`); `SUMMERCMS_REF` rejected when it starts with `-`; no `eval`, `sudo` or dynamic code strings; `summer new` re-validates the name (`ValidateAppName`) | `grep -Ecw 'sudo\|eval'` = 0; `shellcheck`; refusal probe (bad name exit 2) | mitigated | TestScriptShellcheck, TestScriptBehavior (metacharacter name, dash SUMMERCMS_REF) |
|
||||
| T-14.3-17 | Denial of Service | credential prompt hang with piped stdin | medium | mitigate | `new.sh` `main` exports `GIT_TERMINAL_PROMPT=0`; every `git`, `go`, `docker` and `summer` child runs with `</dev/null`; `main "$@"` is the last line so nothing follows in the pipe | `TestBootstrapEndToEnd` and `--acceptance` ran the script from a pipe | mitigated | TestScriptBehavior (fresh clone: summer stdin not read although data is piped) |
|
||||
| T-14.3-18 | Tampering | supply chain of the served script | high | mitigate | `modules/sunrise/sunrise.go` `Script` embeds `new.sh` from the framework release the site binary is built against (the site's release build requires that tag); default clone URL is HTTPS; only `vX.Y.Z` tags are selected (`latest_tag`); the acceptance harness verifies the Go tarball against the sha256 published by go.dev | `TestScriptEmbedded`; `--acceptance` printed `go1.27.0.linux-amd64.tar.gz: OK` | mitigated | TestScriptEmbedded, TestScriptBehavior (pre-release ignored, numeric tag order) |
|
||||
| T-14.3-19 | Elevation of Privilege | acceptance harness on host Docker socket | medium | mitigate | `scripts/check-phase14.3.sh` `stage_acceptance`: local harness only (never CI or the site); framework mounted read-only (`/fw:ro`); compose uses a named volume only; unique `COMPOSE_PROJECT_NAME`; inner trap `docker compose down -v`; host-side `acc_cleanup` removes the runner and every container, volume and network labelled with the project | after the run, no `phase143acc-*` container or volume remained | mitigated | --acceptance (stage of --all) |
|
||||
| T-14.3-20 | Tampering | one-liner shown on the page and in docs | medium | mitigate | HTTPS URL with `curl -fsSL`, taken verbatim from `vue-summercmsio-app/app/data/terminal.json`; docs fence in `docs/setup/installation.md` | plan 03 Task 1 and 3 verify chains | mitigated | TestTerminalScript, terminal.test.ts (pnpm test), TestTerminalCommands (--terminal) |
|
||||
| T-14.3-21 | Denial of Service | gated terminal check leftovers | low | mitigate | `sm-summercmsio-app/terminal_check_test.go`: unique `COMPOSE_PROJECT_NAME`, `t.Cleanup` with `docker compose down -v`, bounded serve poll that stops the app binary and summer | plan 03 gated run left no container, volume or listener | mitigated | TestTerminalRunScript, TestTerminalCommands (--terminal) |
|
||||
| T-14.3-22 | Information Disclosure | docs naming a consuming application | low | mitigate | `summer docs:build --check` forbidden-name rule; plan 03 grep on the edited pages | `docs:build --check` green | mitigated | --unit (docs:build --check), TestDocsTree |
|
||||
| T-14.3-23 | Repudiation | phase gate | high | mitigate | `scripts/check-phase14.3.sh` `require_named_pass`: every required test needs a top-level `--- PASS:` line; a skipped required test, `no tests to run` or any failure fails the stage; `--all` runs each stage in its own process and prints `Phase 14.3 gate passed` only at the end | `--all` run 2026-10-08 | mitigated | --unit, --bootstrap, --terminal, --all |
|
||||
| T-14.3-24 | Tampering | golden files | medium | mitigate | `internal/build/new_golden_test.go`: goldens are rewritten only with `-update`; `--unit` never passes it and fails when `internal/build/testdata` differs from the index after the run; goldens reviewed and committed with the tests (eed24b8) | one-byte golden edit made `TestNewGolden` fail (checked by hand, restored) | mitigated | TestNewGolden, TestNewGoldenNoPlugin, --unit |
|
||||
| T-14.3-25 | Information Disclosure | fixed test key in goldens | low | accept | `goldenAppKey` is base64 of the bytes 0x00..0x1f, a deterministic non-secret test value used only by the golden tests | review | accepted | TestNewGolden |
|
||||
| T-14.3-26 | Denial of Service | Docker resources from gate stages | low | mitigate | `--generator`, `--acceptance` and `--terminal` use unique compose project names and EXIT traps with `docker compose down -v`; `acc_cleanup` removes containers, volumes and networks by project label; `--all` runs each stage in its own process so every trap fires before the next stage | no `phase143*` or `summercms-terminal-*` container or volume remained after `--all` | mitigated | --generator, --acceptance, --terminal |
|
||||
| T-14.3-SC | Tampering | npm/pip/cargo installs | high | mitigate | No package-manager installs in the framework or the plugin; `git diff v0.1.12..HEAD -- go.mod go.sum` is empty and the plugin's `go.mod`/`go.sum` are unchanged; the container installs come from Debian's and Docker's signed apt repositories and a checksum-verified Go tarball | `git diff` empty; `--acceptance` | mitigated | --forbidden (in --unit) |
|
||||
|
||||
## Refusal probe (2026-10-08)
|
||||
|
||||
Plan 04 turned every case below into an automated `TestScriptBehavior` subtest (25 cases, stub `go`/`docker`, real `git`, a bare fake framework remote with tags v0.1.0, v0.2.0, v0.10.0 and v1.0.0-rc1).
|
||||
|
||||
Fake framework repository with tags `v0.0.1`, `v0.0.2`; `SUMMERCMS_REPO=<fake>`; script run from a work directory holding `summercms`:
|
||||
|
||||
| Case | Result |
|
||||
|------|--------|
|
||||
| checkout on branch `master` | `error: … is on branch master; this installer only moves a detached release checkout …`, exit 1, untouched |
|
||||
| detached, tracked file modified | `error: … has uncommitted changes to tracked files …`, exit 1, untouched |
|
||||
| upstream `v0.0.2` moved to a new commit | fetch refused, `error: git fetch … failed (a release tag may have moved upstream); the checkout was not changed`; local `v0.0.2` still at the original commit (e343b45, upstream ea6dd36); HEAD unchanged |
|
||||
| HEAD has a local commit not reachable from `v0.0.2` | `error: moving … is not a fast-forward; the checkout was not changed`, exit 1, HEAD unchanged |
|
||||
| `sm-demo-app` already exists | `error: … already exists`, exit 1, before any git command |
|
||||
| name `Demo` | `error: invalid application name Demo …`, exit 2 |
|
||||
| no argument / `--help` | usage, exit 2 / 0 |
|
||||
| `summercms` exists without `go.mod` | `error: … is not a SummerCMS framework checkout (no go.mod) …`, exit 1 |
|
||||
|
||||
## Notes (no finding)
|
||||
|
||||
- Write locations: besides `SUMMERCMS_DIR`, `sm-<name>-app` and the Go install directory, `go install` and `summer new` write to the user's Go build and module caches (`GOCACHE`, `GOMODCACHE`). That is the Go toolchain's normal state, not a script write.
|
||||
- `SUMMERCMS_REPO`, `SUMMERCMS_DIR` and `SUMMERCMS_REF` come from the invoking user's own environment; they are not a trust boundary. git's default `protocol.allow` already refuses `ext::` URLs.
|
||||
- Tags are not signed; the release checkout trusts HTTPS to `git.golem15.com` plus the no-clobber fetch (trust on first use). Signed-tag verification is a possible later hardening, not an L1 requirement.
|
||||
- On a fresh clone whose target cannot be resolved (no release tag, unknown `SUMMERCMS_REF`), the clone stays on its default branch and the error says to remove it and run again; a re-run would otherwise refuse it as a branch checkout. The script never deletes it itself.
|
||||
@@ -0,0 +1,52 @@
|
||||
---
|
||||
status: testing
|
||||
phase: 14.3-one-command-app-scaffolding-summer-new-and-summercms-io-new
|
||||
source: [14.3-VERIFICATION.md]
|
||||
started: 2026-10-08T10:50:06Z
|
||||
updated: 2026-10-08T10:50:06Z
|
||||
---
|
||||
|
||||
## Current Test
|
||||
|
||||
number: 1
|
||||
name: Decide CR-01 and WR-02 before tagging
|
||||
expected: |
|
||||
Each of CR-01 (whitespace in the framework directory name breaks the go.mod replace) and WR-02 (new.sh does not reject Go keywords, main or internal before cloning) is either fixed or explicitly deferred in 14.3-REVIEW-DISPOSITION.md, and installation.md no longer claims the script stops before cloning on any invalid name unless that is true.
|
||||
awaiting: user response
|
||||
|
||||
## Tests
|
||||
|
||||
### 1. Decide CR-01 and WR-02 before tagging
|
||||
expected: CR-01 and WR-02 are fixed or explicitly deferred in 14.3-REVIEW-DISPOSITION.md; the installation.md sentence about stopping before the clone is accurate.
|
||||
result: [pending]
|
||||
|
||||
### 2. Review the generated CLAUDE.md and the rewritten docs pages
|
||||
expected: The generated app's CLAUDE.md and docs/setup/installation.md, go-modules-and-workspaces.md and the console pages read correctly.
|
||||
result: [pending]
|
||||
|
||||
### 3. Confirm the judgment-tier prohibition verdicts
|
||||
expected: The 17 judgment-tier prohibition verdicts in 14.3-VERIFICATION.md hold, including how WR-04 relates to "no machine-specific paths".
|
||||
result: [pending]
|
||||
|
||||
### 4. Tag and push framework v0.1.13
|
||||
expected: git tag v0.1.13 exists locally and on origin, and summercms.go master is pushed.
|
||||
result: [pending]
|
||||
|
||||
### 5. Bump the site's framework require to v0.1.13 and redeploy
|
||||
expected: sm-summercmsio-app and its plugin require v0.1.13. After the deploy, curl -fsSI https://summercms.io/new returns 200 with text/plain; charset=utf-8.
|
||||
result: [pending]
|
||||
|
||||
### 6. Run the production acceptance
|
||||
expected: bash scripts/check-phase14.3.sh --acceptance-production passes, and SUMMERCMS_TERMINAL_CHECK=1 go test -run TestTerminalCommands passes in sm-summercmsio-app with no overrides.
|
||||
result: [pending]
|
||||
|
||||
## Summary
|
||||
|
||||
total: 6
|
||||
passed: 0
|
||||
issues: 0
|
||||
pending: 6
|
||||
skipped: 0
|
||||
blocked: 0
|
||||
|
||||
## Gaps
|
||||
@@ -0,0 +1,93 @@
|
||||
---
|
||||
phase: "14.3"
|
||||
slug: "one-command-app-scaffolding-summer-new-and-summercms-io-new"
|
||||
status: validated
|
||||
nyquist_compliant: true
|
||||
wave_0_complete: true
|
||||
created: "2026-10-08"
|
||||
validated: "2026-10-08"
|
||||
---
|
||||
|
||||
# Phase 14.3 — Validation Strategy
|
||||
|
||||
> Per-phase validation contract for feedback sampling during execution. Source: 14.3-RESEARCH.md § Validation Architecture. Signed off by plan 04 after `bash scripts/check-phase14.3.sh --all` ended with `Phase 14.3 gate passed` on 2026-10-08.
|
||||
|
||||
---
|
||||
|
||||
## Test Infrastructure
|
||||
|
||||
| Property | Value |
|
||||
|----------|-------|
|
||||
| **Framework** | Go stdlib `testing` (Go 1.27.0); `node:test` in `vue-summercmsio-app` |
|
||||
| **Config file** | none |
|
||||
| **Quick run command** | `go test ./internal/build/ ./cmd/summer/ ./modules/lagoon/ ./modules/sunrise/ -short` |
|
||||
| **Full suite command** | `go vet ./... && go test ./... && go -C examples/hello test ./...` |
|
||||
| **Phase gate** | `bash scripts/check-phase14.3.sh --all` (stages `--unit`, `--generator`, `--bootstrap`, `--terminal`, `--acceptance`, `--security`; final line `Phase 14.3 gate passed`) |
|
||||
| **Golden update** | `go test ./internal/build/ -run '^(TestNewGolden|TestNewGoldenNoPlugin)$' -update`, then review and commit the diff; the gate never passes `-update` and fails when `internal/build/testdata` changes |
|
||||
| **Estimated runtime** | ~20 seconds quick (incl. real builds), ~90 seconds full suite, ~6 minutes `--all` (container acceptance) |
|
||||
|
||||
---
|
||||
|
||||
## Sampling Rate
|
||||
|
||||
- **After every task commit:** quick run command
|
||||
- **After every plan wave:** full suite + the plan's sibling-repo command (below)
|
||||
- **Before `/gsd-verify-work` and before tagging `v0.1.13`:** `bash scripts/check-phase14.3.sh --all` green
|
||||
- **Max feedback latency:** ~60 seconds for the quick run
|
||||
|
||||
---
|
||||
|
||||
## Per-Plan Verification Map
|
||||
|
||||
| Plan | Behavior | Test Type | Automated Command | Status |
|
||||
|------|----------|-----------|-------------------|--------|
|
||||
| 01 | `summer new demo` in a temp parent with a `summercms` symlink builds and lists the starter route; `--no-plugin` builds | smoke (real build) | `go test ./internal/build/ -run '^(TestNewAppSmoke|TestNewNoPluginBuilds)$' -count=1` | ✅ |
|
||||
| 01 | every file `summer new` writes, default and `--no-plugin` (go.mod relative replace, go.work, config/, .env, compose `127.0.0.1:54320`, .gitignore, CLAUDE.md, starter plugin, next steps) | golden | `go test ./internal/build/ -run '^(TestNewGolden|TestNewGoldenNoPlugin)$' -count=1` | ✅ |
|
||||
| 01 | name rule and denylist; existing target (dir, file, symlink, dangling symlink); framework resolution order; target inside the checkout; cleanup on failure; key validation | unit | `go test ./internal/build/ -run '^(TestNewValidateAppName|TestNewRefusesExistingTarget|TestNewFrameworkResolution|TestNewRefusesTargetInsideFramework|TestNewCleansUpOnFailure|TestNewRequiresValidAppKey)$' -count=1` | ✅ |
|
||||
| 01 | `.env` 0600; AGENTS.md symlink; first commit with fallback identity, `.env` untracked; configured identity; git missing is a warning; `--no-git` | unit | `go test ./internal/build/ -run '^(TestNewEnvFileMode|TestNewAgentsSymlink|TestNewGitFirstCommit|TestNewGitUsesConfiguredIdentity|TestNewGitMissingIsWarning|TestNewNoGit)$' -count=1` | ✅ |
|
||||
| 01 | `plugin:add` writes relative `go.work` paths; plain `make:plugin` keeps the no-route stub | unit | `go test ./internal/build/ -run '^(TestMakeAndAddPluginOnHelloCopy|TestMakePluginRoutesUnchanged)$' -count=1` | ✅ |
|
||||
| 01 | `summer new` wired in the CLI with its flags | unit | `go test ./cmd/summer/ -run '^(TestToolCommandNames|TestNewCommandViaCLI)$' -count=1` | ✅ |
|
||||
| 01 | `GenerateAppKey` returns StdEncoding of 32 random bytes; `key:generate` uses it | unit | `go test ./modules/lagoon/ -run '^(TestGenerateAppKey|TestEncryptedKeyGeneratePrints32ByteBase64)$' -count=1` | ✅ |
|
||||
| 01 | `examples/hello` config gap fixed | integration | `go -C examples/hello test ./... -count=1` | ✅ |
|
||||
| 01 | D-12: generated app builds, migrates on PostgreSQL 16 and serves the starter route with no edits | e2e | `bash scripts/check-phase14.3.sh --generator` | ✅ |
|
||||
| 02 | script: embedded bytes, shebang, `main "$@"` last line, `bash -n`, truncation runs nothing | unit | `go test ./modules/sunrise/ -run '^(TestScriptEmbedded|TestScriptShape|TestScriptTruncationRunsNothing)$' -count=1` | ✅ |
|
||||
| 02 | script behaviour matrix (usage/name errors, existing app, Go missing/old/devel, git required, Docker warning, newest release tag, `SUMMERCMS_REF`, fast-forward re-runs, refusals with HEAD unchanged, stdin closed, cd line, PATH hint) and shellcheck | unit | `go test ./modules/sunrise/ -run '^(TestScriptBehavior|TestScriptShellcheck)$' -count=1` | ✅ |
|
||||
| 02 | `curl \| bash` from an HTTP server, clone at HEAD, install into a GOBIN off PATH, build the app | gated e2e | `bash scripts/check-phase14.3.sh --bootstrap` (`SUMMERCMS_BOOTSTRAP_CHECK=1 go test ./modules/sunrise/ -run '^TestBootstrapEndToEnd$'`) | ✅ |
|
||||
| 02 | `GET /new` 200 `text/plain; charset=utf-8`, body = embedded script, HEAD ok, POST 405, nosniff | unit | `go -C ../sm-summercmsio-app/plugins/golem15/summercms test ./... -count=1` (`TestNewScriptRoute`, `TestRoutesAssemble`) | ✅ |
|
||||
| 02 | D-20 container chain ends with the starter route answering | e2e (Docker) | `bash scripts/check-phase14.3.sh --acceptance` | ✅ |
|
||||
| 03 | docs tree valid, `summer new` a known command, llms outputs in sync | unit | `go test ./cmd/summer -run '^(TestDocsTree|TestDocsAIOutputsInSync)$' -count=1` and `go run ./cmd/summer docs:build --check` | ✅ |
|
||||
| 03 | landing page terminal data and tests | unit | `go -C ../sm-summercmsio-app test ./... -count=1` (`TestLoadTerminal`, `TestTerminalScript`, `TestTerminalEnv`, `TestTerminalRunScript`); `pnpm -C ../sm-summercmsio-app/vue-summercmsio-app generate && pnpm -C ../sm-summercmsio-app/vue-summercmsio-app test` | ✅ |
|
||||
| 03 | the six page commands run verbatim until the starter route answers | gated e2e | `bash scripts/check-phase14.3.sh --terminal` | ✅ |
|
||||
| 04 | fail-closed gate: named PASS for every required test, forbidden surfaces, threat register | gate | `bash scripts/check-phase14.3.sh --all` | ✅ |
|
||||
|
||||
*Status: ⬜ pending · ✅ green · ❌ red · ⚠️ flaky*
|
||||
|
||||
---
|
||||
|
||||
## Wave 0 Requirements
|
||||
|
||||
- [x] `internal/build/testdata/new/` and `testdata/new-no-plugin/` golden files + `-update` flag (`internal/build/new_golden_test.go`)
|
||||
- [x] new `modules/sunrise/` package with README and tests
|
||||
- [x] test helper creating `<tmp>/summercms` → repo-root symlink so the generated replace is deterministic (`../summercms`): `newGoldenApp`, `parentWithFramework`
|
||||
- [x] acceptance runner script: `scripts/check-phase14.3.sh --acceptance`
|
||||
|
||||
---
|
||||
|
||||
## Manual-Only Verifications
|
||||
|
||||
| Behavior | Why Manual | Test Instructions |
|
||||
|----------|------------|-------------------|
|
||||
| `curl -fsSL https://summercms.io/new \| bash -s -- demo` against production | needs `v0.1.13` tagged and pushed, the site's require bumped and the site redeployed (user actions, D-28) | `curl -fsSI https://summercms.io/new` shows `200` and `text/plain; charset=utf-8`; in `sm-summercmsio-app`: `SUMMERCMS_TERMINAL_CHECK=1 go test -run TestTerminalCommands -count=1 -v .` with no overrides; in `summercms.go`: `bash scripts/check-phase14.3.sh --acceptance-production` |
|
||||
|
||||
---
|
||||
|
||||
## Validation Sign-Off
|
||||
|
||||
- [x] All tasks have `<automated>` verify or Wave 0 dependencies
|
||||
- [x] Sampling continuity: no 3 consecutive tasks without automated verify
|
||||
- [x] Wave 0 covers all MISSING references
|
||||
- [x] No watch-mode flags
|
||||
- [x] Feedback latency < 60s
|
||||
- [x] `nyquist_compliant: true` set in frontmatter
|
||||
|
||||
**Approval:** approved 2026-10-08 (`bash scripts/check-phase14.3.sh --all` → `Phase 14.3 gate passed`)
|
||||
@@ -0,0 +1,219 @@
|
||||
---
|
||||
phase: 14.3-one-command-app-scaffolding-summer-new-and-summercms-io-new
|
||||
verified: 2026-10-08T12:10:00Z
|
||||
status: human_needed
|
||||
score: 6/6 roadmap success criteria verified in code (release, deploy and production run pending as user actions)
|
||||
covered_files:
|
||||
- .planning/phases/14.3-one-command-app-scaffolding-summer-new-and-summercms-io-new/14.3-01-PLAN.md
|
||||
- .planning/phases/14.3-one-command-app-scaffolding-summer-new-and-summercms-io-new/14.3-01-SUMMARY.md
|
||||
- .planning/phases/14.3-one-command-app-scaffolding-summer-new-and-summercms-io-new/14.3-02-PLAN.md
|
||||
- .planning/phases/14.3-one-command-app-scaffolding-summer-new-and-summercms-io-new/14.3-02-SUMMARY.md
|
||||
- .planning/phases/14.3-one-command-app-scaffolding-summer-new-and-summercms-io-new/14.3-03-PLAN.md
|
||||
- .planning/phases/14.3-one-command-app-scaffolding-summer-new-and-summercms-io-new/14.3-03-SUMMARY.md
|
||||
- .planning/phases/14.3-one-command-app-scaffolding-summer-new-and-summercms-io-new/14.3-04-PLAN.md
|
||||
- .planning/phases/14.3-one-command-app-scaffolding-summer-new-and-summercms-io-new/14.3-04-SUMMARY.md
|
||||
- README.md
|
||||
- cmd/summer/main.go
|
||||
- docs/architecture/go-modules-and-workspaces.md
|
||||
- docs/console/introduction.md
|
||||
- docs/console/scaffolding.md
|
||||
- docs/setup/installation.md
|
||||
- docs/site.yaml
|
||||
- examples/hello/config/http.yaml
|
||||
- internal/build/new.go
|
||||
- internal/build/scaffold.go
|
||||
- internal/build/stubs/new.tmpl
|
||||
- internal/build/stubs/plugin.tmpl
|
||||
- modules/bonfire/command.go
|
||||
- modules/lagoon/keygen.go
|
||||
- modules/sunrise/new.sh
|
||||
- modules/sunrise/sunrise.go
|
||||
- scripts/check-phase14.3.sh
|
||||
covered_digest: "v2:sha256:e3a4adbf699b33af4ead50e3c65667d4d83f0f798492ef21f6b531e5276529fd"
|
||||
behavior_unverified: 0
|
||||
overrides_applied: 0
|
||||
human_verification:
|
||||
- test: "Tag framework v0.1.13 (all framework work is on master, 40 commits after v0.1.12) and push the tag"
|
||||
expected: "git ls-remote --tags https://git.golem15.com/golem15/summercms lists v0.1.13; the bootstrap script's newest-tag selection then picks a release that has `summer new`"
|
||||
why_human: "D-28 makes tagging a user action. Until v0.1.13 exists, the one-liner checks out v0.1.12, which has no `summer new` command, so the production path cannot work yet"
|
||||
- test: "Bump sm-summercmsio-plugin (and app) `require git.golem15.com/golem15/summercms` from v0.1.12 to v0.1.13, then redeploy summercms.io per sm-summercmsio-app/DEPLOY.md"
|
||||
expected: "curl -sI https://summercms.io/new answers 200 with Content-Type text/plain; charset=utf-8 and the body equals modules/sunrise/new.sh at v0.1.13"
|
||||
why_human: "Deploy is a user action (D-19, D-28). Checked on 2026-10-08: https://summercms.io/new currently answers 404 text/html"
|
||||
- test: "After the deploy, run `bash scripts/check-phase14.3.sh --acceptance-production`"
|
||||
expected: "Ends with `Phase 14.3 production acceptance passed` (the real `curl -fsSL https://summercms.io/new | bash -s -- demo` in node:24-bookworm, then compose/build/migrate/serve answering \"plugin\":\"golem15.demo\")"
|
||||
why_human: "Needs the tag and the deploy above; the pre-release local acceptance (script from the repo, SUMMERCMS_REF=HEAD) passed in this verification"
|
||||
- test: "Decide on code-review finding CR-01 (and WR-02) before tagging v0.1.13"
|
||||
expected: "Either fix (quote whitespace paths in go.mod/plugin go.mod replace, mirror the keyword/main/internal rule in new.sh) or record them as deferred in 14.3-REVIEW-DISPOSITION.md"
|
||||
why_human: "The tag freezes the generator and the script served at /new. CR-01 is reproduced (see Anti-Patterns) but only hits a framework directory whose name contains whitespace; whether it must block the release is a product call"
|
||||
- test: "Read the generated CLAUDE.md and the four rewritten docs pages"
|
||||
expected: "Agent instructions and docs are accurate, readable and name no consuming application"
|
||||
why_human: "Carried from plan summaries (human_judgment: true). Greps prove required names/links and the forbidden-name rule only. Note docs/setup/installation.md says the script stops before cloning on an invalid name, which is false for Go keywords, `main` and `internal` (WR-02)"
|
||||
- test: "Review the judgment-tier prohibitions listed under Prohibitions below (non-authoritative verifier verdicts)"
|
||||
expected: "Human confirms or rejects each flagged verdict"
|
||||
why_human: "All 17 prohibitions are verification: judgment; per ADR-550 an autonomous verdict is advisory only"
|
||||
---
|
||||
|
||||
# Phase 14.3: One-command app scaffolding Verification Report
|
||||
|
||||
**Phase Goal:** Starting a SummerCMS app takes one command. `summer new <name>` writes a ready-to-run `sm-<name>-app` next to the framework checkout, and `curl -fsSL https://summercms.io/new | bash -s -- <name>` checks the prerequisites, fetches the framework, installs the `summer` CLI and runs it. Ships as framework tag `v0.1.13`.
|
||||
**Verified:** 2026-10-08T12:10:00Z
|
||||
**Status:** human_needed
|
||||
**Re-verification:** No (initial verification)
|
||||
|
||||
## Goal Achievement
|
||||
|
||||
All code for the goal exists, is wired and was exercised end to end by this verifier in its own process (not taken from SUMMARY claims): the full unit gate, the D-12 generator run on real PostgreSQL 16, the gated bootstrap end-to-end test and the D-20 node:24-bookworm container acceptance all passed. What remains is the release leg of the goal ("ships as v0.1.13", summercms.io actually serving `/new`), which D-28 assigns to the user.
|
||||
|
||||
### Observable Truths (roadmap contract)
|
||||
|
||||
| # | Truth | Status | Evidence |
|
||||
|---|-------|--------|----------|
|
||||
| 1 | `summer new <name>` validates the name, refuses an existing dir, writes go.mod (module path, go 1.27.0, relative replace via --framework > sibling-by-module-path > clear error), summer.yaml, starter plugin via make:plugin/plugin:add + go.work, `--no-plugin`, complete config/ (http.yaml body_limits, app key, uploads bucket), .env.example + .env with fresh key, .gitignore, compose.yaml (PG16 matching .env), CLAUDE.md/AGENTS.md, first commit (`--no-git`) | ✓ VERIFIED | `internal/build/new.go` (ValidateAppName: regex + `token.IsKeyword` + main/internal; `os.Lstat` refusal; `resolveFramework` with `checkFramework` module-path match, binary-source last, error names `--framework`; `addStarterPlugin` -> `makePlugin(StarterRoute)` + `AddPlugin`); `stubs/new.tmpl` (all files); `cmd/summer/main.go:303` `newCommand` wires `lagoon.GenerateAppKey()` and the three flags. Goldens in `internal/build/testdata/new{,-no-plugin}` show `replace ... => ../summercms`, relative `./plugins/demo` go.work, empty plugin list. Unit gate: TestNewGolden, TestNewGoldenNoPlugin, TestNewValidateAppName, TestNewRefusesExistingTarget, TestNewFrameworkResolution, TestNewEnvFileMode, TestNewAgentsSymlink, TestNewGitFirstCommit, TestNewNoGit all named-PASS. Binary-source fallback added on top of the roadmap order (permitted by D-04). |
|
||||
| 2 | Generated app works with no edits: `summer build`, `summer migrate`, `summer serve`, starter route answers over HTTP | ✓ VERIFIED | Ran `bash scripts/check-phase14.3.sh --generator` myself: sibling detection, no git identity, `docker compose up -d --wait`, `built demo in 5.044s`, `✓ migrations applied`, `GET / -> {"message":"Hello from SummerCMS","plugin":"golem15.demo"}`, `Phase 14.3 generator check passed`. TestNewAppSmoke (real build + route:list) and TestNewNoPluginBuilds also passed in the unit gate. |
|
||||
| 3 | Bootstrap script wrapped in `main()`; checks Go 1.27+ and Docker; clones or fast-forwards the framework; `go install ./cmd/summer` with PATH hint; runs `summer new`; prints next steps; summercms.io serves it at `/new` as text/plain | ✓ VERIFIED (code); production serving pending (human item) | `modules/sunrise/new.sh` (functions only, last line `main "$@"`; `check_go` with go.dev/dl hint; `check_docker` warn-only; `checkout_framework` clone at newest vX.Y.Z or ff-only via `merge-base --is-ancestor`; `GOWORK=off go -C "$dir" install ./cmd/summer`; PATH hint; `summer new "$name" --framework "$dir"`); `sunrise.Script()` embeds it; site `plugins/golem15/summercms/plugin.go:62` `g.Get("/new", scriptHandler(sunrise.Script()))`, `script.go` sets text/plain, nosniff, no-cache, ETag. TestScriptTruncationRunsNothing, TestScriptBehavior, TestScriptShellcheck, TestNewScriptRoute, TestRoutesAssemble named-PASS; `--bootstrap` stage (TestBootstrapEndToEnd) passed in my run. https://summercms.io/new currently answers 404 (not deployed, D-28). |
|
||||
| 4 | In a clean node:24-bookworm container with Go 1.27, Docker, git, jq: one-liner -> `cd sm-demo-app && docker compose up -d && summer build && summer migrate && summer serve` ends with the starter route answering | ✓ VERIFIED (pre-release form); production form pending (human item) | Ran `bash scripts/check-phase14.3.sh --acceptance` myself: bare node:24-bookworm, Go 1.27.0 from go.dev (sha256 checked), `curl -fsSL file:///fw/modules/sunrise/new.sh \| SUMMERCMS_REPO=/fw SUMMERCMS_REF=<HEAD> bash -s -- demo`, compose up, build, migrate, serve -> `{"message":"Hello from SummerCMS","plugin":"golem15.demo"}`, `Phase 14.3 acceptance passed`. No containers, volumes or ports left behind. The literal `https://summercms.io/new` run needs the tag + deploy. |
|
||||
| 5 | installation.md leads with the one-liner; go-modules-and-workspaces.md and console docs cover `summer new`; llms.txt/llms-full.txt include the changes; docs checks pass | ✓ VERIFIED | `docs/setup/installation.md` first section "Create an application with one command" with the exact one-liner; `go-modules-and-workspaces.md` section "Applications created by summer new"; `docs/console/scaffolding.md` "Creating an application" with `--framework`, `--no-plugin`, `--no-git`. Fresh `summer docs:build --out <tmp>`: llms.txt has the one-liner (1), llms-full.txt has it (6) and `summer new` (20). `docs:build --check`, TestDocsTree, TestDocsAIOutputsInSync passed in the unit gate. |
|
||||
| 6 | Generator Go tests (golden output plus a real build of the scaffold in a temp dir) are the last plan | ✓ VERIFIED | Plan 04 (wave 4, last) delivered `new_golden_test.go` (TestNewGolden/NoPlugin, `-update` flag), 23 goldens, TestNewAppSmoke/TestNewNoPluginBuilds (real `App()` build, skipped only under `-short`). Gate asserts goldens unchanged after the run. |
|
||||
| G | Ships as framework tag `v0.1.13` | ? HUMAN | Not tagged locally or on the remote (latest remote tag v0.1.12). D-28 makes tagging a user action. |
|
||||
|
||||
**Score:** 6/6 roadmap success criteria verified in code (0 present-but-behavior-unverified). The release clause (tag, site bump, deploy, production acceptance) is routed to human verification.
|
||||
|
||||
### Locked decisions (D-01..D-28) coverage
|
||||
|
||||
| Decision | Status | Evidence |
|
||||
|----------|--------|----------|
|
||||
| D-01 naming convention | ✓ | CLAUDE.md template "Naming convention"; docs/console/scaffolding.md |
|
||||
| D-02 name rule, no overwrite | ✓ | `appNameRe`, `os.Lstat` refusal; TestNewValidateAppName, TestNewRefusesExistingTarget |
|
||||
| D-03 go.mod shape, relative replace | ✓ (CR-01 edge case) | go.mod.golden; relative only, but unquoted when the path contains whitespace |
|
||||
| D-04 framework resolution order | ✓ | `resolveFramework`; TestNewFrameworkResolution |
|
||||
| D-05 starter plugin via shared code paths | ✓ | `makePlugin(..., pluginOptions{StarterRoute: true})` + `AddPlugin`; single `routes.go` template; TestMakePluginRoutesUnchanged |
|
||||
| D-06 `--no-plugin` | ✓ | TestNewNoPluginBuilds (real build) |
|
||||
| D-07 config/ complete, examples/hello http.yaml | ✓ | four config templates; `examples/hello/config/http.yaml`; serve proven by `--generator` |
|
||||
| D-08 .env/.env.example/.gitignore, GenerateAppKey reused | ✓ | `lagoon.GenerateAppKey` used by `key:generate` and `newCommand`; TestGenerateAppKey, TestNewEnvFileMode |
|
||||
| D-09 compose PG16 matching .env | ✓ | compose template + `devDSN`; `--generator` migrate succeeded |
|
||||
| D-10 CLAUDE.md/AGENTS.md | ✓ | template; AGENTS.md relative symlink; TestNewAgentsSymlink |
|
||||
| D-11 git init + first commit, fallback identity | ✓ | `initGit`; `--generator` checked one commit on main by `SummerCMS <summer@localhost>`, .env untracked |
|
||||
| D-12 works with no edits | ✓ | `--generator` run (this verification) |
|
||||
| D-13 script embedded, served at GET /new | ✓ code / human deploy | `modules/sunrise`, site `script.go`; TestNewScriptRoute |
|
||||
| D-14 `main()` wrapper | ✓ | last line `main "$@"`; TestScriptTruncationRunsNothing |
|
||||
| D-15 script steps | ✓ | new.sh; TestScriptBehavior; TestBootstrapEndToEnd |
|
||||
| D-16 latest release tag, ff-only, refuse dirty/branch | ✓ | `latest_tag`, `check_checkout`, `merge-base --is-ancestor`; TestScriptBehavior |
|
||||
| D-17 clone location / overrides | ✓ | `SUMMERCMS_REPO/DIR/REF` |
|
||||
| D-18 docs | ✓ | see truth 5 |
|
||||
| D-19 landing page + TestTerminalCommands | ✓ code / human deploy | `terminal.json` three groups, six commands; pnpm test 32/32; TestLoadTerminal, TestTerminalScript, TestTerminalEnv, TestTerminalRunScript PASS (gated TestTerminalCommands not re-run here; summary reports pass) |
|
||||
| D-20 container acceptance | ✓ pre-release / human production | see truth 4 |
|
||||
| D-21 test conventions, last plan | ✓ | see truth 6 |
|
||||
| D-22 keyword/main/internal refusal | ✓ in `summer new`; ✗ not mirrored in new.sh (WR-02) | TestNewValidateAppName |
|
||||
| D-23 fixed non-5432 port on 127.0.0.1 | ✓ | `127.0.0.1:54320:5432` |
|
||||
| D-24 `summer new` prints next steps; script prints cd + PATH hint | ✓ | `new/next-steps` template; new.sh tail |
|
||||
| D-25 no root go.sum; go.work.sum ignored | ✓ | gitignore template; `--generator` checks no go.sum |
|
||||
| D-26 package under modules/, SUMMERCMS_* only | ✓ | `modules/sunrise`, README + root table row; forbidden scan passed |
|
||||
| D-27 relative plugin:add go.work, opt-in starter route, GenerateAppKey, hello http.yaml | ✓ | `runGoWorkUse` via `relModulePath`; TestMakeAndAddPluginOnHelloCopy |
|
||||
| D-28 release ordering, user actions | ? human | site require still v0.1.12 with local replace; no v0.1.13 tag; nothing deployed |
|
||||
|
||||
### Required Artifacts
|
||||
|
||||
| Artifact | Expected | Status | Details |
|
||||
|----------|----------|--------|---------|
|
||||
| `internal/build/new.go` | NewApp, ValidateAppName, planNewApp, resolution | ✓ VERIFIED | 419 lines, substantive, called from `cmd/summer/main.go` |
|
||||
| `internal/build/stubs/new.tmpl` | go.mod, config, .env, compose, gitignore, CLAUDE.md, next steps | ✓ VERIFIED | rendered by `planNewApp` |
|
||||
| `internal/build/stubs/plugin.tmpl` | opt-in starter route | ✓ VERIFIED | `{{if .StarterRoute}}` GET `/{$}` |
|
||||
| `cmd/summer/main.go` | `new` command with three flags | ✓ VERIFIED | `newCommand()` registered in `toolCommands()` |
|
||||
| `modules/lagoon/keygen.go` | `GenerateAppKey` | ✓ VERIFIED | reused by key:generate and summer new |
|
||||
| `examples/hello/config/http.yaml` | body_limits | ✓ VERIFIED | `go -C examples/hello test ./...` passed |
|
||||
| `modules/sunrise/new.sh`, `sunrise.go`, `README.md` | script, embed, README | ✓ VERIFIED | `//go:embed new.sh`, `Script()` clones bytes |
|
||||
| `sm-summercmsio-app/plugins/golem15/summercms/script.go` + `plugin.go` | GET /new | ✓ VERIFIED | committed in plugin `2157bb9`, gitlink in app `fd721cf` |
|
||||
| `vue-summercmsio-app/app/data/terminal.json` | one-liner Get started | ✓ VERIFIED | committed `6140750` |
|
||||
| `sm-summercmsio-app/terminal_check_test.go` | SUMMERCMS_NEW_URL, backgrounded serve | ✓ VERIFIED | unit tests PASS |
|
||||
| `internal/build/new_golden_test.go`, `testdata/new*/` | goldens | ✓ VERIFIED | 17 + 6 goldens, unchanged after the gate run |
|
||||
| `modules/sunrise/script_behavior_test.go` | behaviour matrix | ✓ VERIFIED | TestScriptBehavior PASS |
|
||||
| `scripts/check-phase14.3.sh` | fail-closed gate | ✓ VERIFIED | stages run here: --unit, --generator, --bootstrap, --acceptance |
|
||||
|
||||
### Key Link Verification
|
||||
|
||||
| From | To | Via | Status |
|
||||
|------|----|-----|--------|
|
||||
| `cmd/summer/main.go` | `modules/lagoon/keygen.go` | `lagoon.GenerateAppKey()` -> `NewOptions.AppKey` | ✓ WIRED |
|
||||
| `internal/build/new.go` | `internal/build/scaffold.go` | `makePlugin(StarterRoute)`, `AddPlugin`, `generate()` | ✓ WIRED |
|
||||
| `scaffold.go` | `go.work` | `runGoWorkUse` passes `relModulePath` result | ✓ WIRED |
|
||||
| site `plugin.go` | `modules/sunrise` | `g.Get("/new", scriptHandler(sunrise.Script()))` via local replace | ✓ WIRED |
|
||||
| `new.sh` | `cmd/summer` | `go install ./cmd/summer`; `summer new "$name" --framework "$dir"` | ✓ WIRED |
|
||||
| `terminal.json` | `terminal_check_test.go` | `loadTerminal` | ✓ WIRED |
|
||||
| `check-phase14.3.sh` | `14.3-SECURITY-REVIEW.md` | `--security` threat-ID scan | ✓ WIRED (not re-run; unit gate's forbidden scan passed) |
|
||||
|
||||
### Behavioral Spot-Checks (run by this verifier)
|
||||
|
||||
| Behavior | Command | Result | Status |
|
||||
|----------|---------|--------|--------|
|
||||
| vet, full tests, named PASS for 28 framework + 6 site tests, hello, docs check, site plugin/app, pnpm generate/test, forbidden scan | `bash scripts/check-phase14.3.sh --unit` | `Phase 14.3 unit stage passed`, exit 0 | ✓ PASS |
|
||||
| D-12 generated app builds, migrates, serves | `bash scripts/check-phase14.3.sh --generator` | `GET / -> {"message":"Hello from SummerCMS","plugin":"golem15.demo"}` | ✓ PASS |
|
||||
| Bootstrap end to end on the host | `bash scripts/check-phase14.3.sh --bootstrap` | `PASS TestBootstrapEndToEnd` | ✓ PASS |
|
||||
| D-20 in node:24-bookworm | `bash scripts/check-phase14.3.sh --acceptance` | `Phase 14.3 acceptance passed` | ✓ PASS |
|
||||
| llms outputs carry the change | `summer docs:build --out <tmp>`; grep | one-liner in llms.txt and llms-full.txt; `summer new` x20 | ✓ PASS |
|
||||
| Production `/new` | `curl -sS -o /dev/null -w '%{http_code}' https://summercms.io/new` | `404 text/html` | ? HUMAN (not deployed, D-28) |
|
||||
| CR-01 reproduction | `summer new demo --framework "my fw" --no-git --no-plugin` | prints `created ...`, go.mod `replace ... => ../my fw`, `go list -m` fails `malformed module path "../my"` | ✗ confirmed (edge case) |
|
||||
| Parent path with spaces, default sibling | `summer new demo --no-git --no-plugin` in `has space/` | `replace ... => ../summercms`, module loads | ✓ PASS |
|
||||
|
||||
All Docker resources and ports 8080/54320 were confirmed free after the runs; scratch files were removed.
|
||||
|
||||
### Probe Execution
|
||||
|
||||
Step 7c: no `scripts/*/tests/probe-*.sh` declared or present for this phase; the phase gate stages above serve that role and were run directly.
|
||||
|
||||
### Requirements Coverage
|
||||
|
||||
REQUIREMENTS.md maps no IDs to Phase 14.3 (roadmap says "Requirements: TBD"). Coverage is accounted per locked decision in the D-01..D-28 table above. No orphaned requirements.
|
||||
|
||||
### Prohibitions (judgment tier; non-authoritative verifier verdicts, human review recommended)
|
||||
|
||||
| Prohibition | Verdict | Flag |
|
||||
|-------------|---------|------|
|
||||
| summer new never commits .env or a key; .env.example has no real key | holds (`--generator` checked `git ls-files .env` empty; template `SUMMER_APP__KEY=`) | unverified-prohibition, human review recommended |
|
||||
| Committed go.mod/go.work never contain absolute, machine-specific paths | holds on default paths; WR-04: binary-source fallback can write a relative path into GOMODCACHE (machine-specific) | unverified-prohibition, human review recommended |
|
||||
| compose.yaml never publishes PG on other than 127.0.0.1 | holds (`"127.0.0.1:54320:5432"`) | unverified-prohibition |
|
||||
| Template text never names a consuming application | holds (grep clean; docs forbidden-name rule passed) | unverified-prohibition |
|
||||
| No new Go dependency / no runtime plugin loading (x2) | holds (`git diff v0.1.12 -- go.mod` empty; forbidden scan passed) | unverified-prohibition |
|
||||
| Script never reads/sets SUMMER_* | holds (only SUMMERCMS_*; forbidden scan) | unverified-prohibition |
|
||||
| Script never discards local work in an existing checkout | holds (refusals; TestScriptBehavior) | unverified-prohibition |
|
||||
| Script never escalates or writes outside its dirs | holds on reading new.sh (no sudo; writes clone dir, GOBIN, app dir) | unverified-prohibition |
|
||||
| No task pushes/tags/bumps require/deploys (x3) | holds (no v0.1.13 tag; site require v0.1.12; prod /new 404) | unverified-prohibition |
|
||||
| One-liner never shown over HTTP or without -f | holds (terminal.json, docs) | unverified-prohibition |
|
||||
| Framework docs never name a consuming application | holds (docs check) | unverified-prohibition |
|
||||
| Gate never passes on skipped/filtered/-update run | holds (`require_named_pass` rejects skip, "no tests to run", golden diffs) | unverified-prohibition |
|
||||
|
||||
### Anti-Patterns Found
|
||||
|
||||
No TBD/FIXME/XXX/TODO/placeholder markers in the phase's files.
|
||||
|
||||
Open code-review findings (14.3-REVIEW.md, all `open` in 14.3-REVIEW-DISPOSITION.md), classified against the success criteria:
|
||||
|
||||
| Finding | File | Severity here | Defeats a success criterion? |
|
||||
|---------|------|---------------|------------------------------|
|
||||
| CR-01 whitespace in framework path -> unparseable go.mod, `--no-plugin` reports success | `internal/build/stubs/new.tmpl:9`, `scaffold.go` `frameworkReplaceFor` | ⚠️ Warning (reproduced) | No for the documented flows: the bootstrap always puts the app next to the checkout, so only a checkout basename with whitespace (custom `SUMMERCMS_DIR` or `--framework`) triggers it; a parent path with spaces is fine (verified). It is a real correctness bug in the `--framework` path of SC1/SC2 and should be fixed or explicitly deferred before the v0.1.13 tag freezes it |
|
||||
| WR-01 Ctrl+C leaves a partial app; re-runs refused | `cmd/summer/main.go`, `new.go` | ⚠️ Warning | No; docs sentence about cleanup is over-broad |
|
||||
| WR-02 script accepts Go keywords/main/internal, clones and installs before summer new refuses | `new.sh:215` | ⚠️ Warning | No (summer new still refuses, nothing broken is written), but makes a docs claim in installation.md false |
|
||||
| WR-03 bad SUMMERCMS_REF on fresh clone leaves a branch checkout the next run refuses | `new.sh:163-169` | ⚠️ Warning | No (override-only path) |
|
||||
| WR-04 binary-source fallback can point into GOMODCACHE | `new.go:286-297` | ⚠️ Warning | No for the bootstrap path (always passes --framework); touches the "no machine-specific paths" prohibition |
|
||||
| WR-05 every app uses host port 54320 | `new.tmpl:67` | ⚠️ Warning | No (single-app criteria), second concurrent app's `docker compose up` fails |
|
||||
| WR-06 go.work.sum ignored; --no-plugin app commits no checksums | `new.tmpl:84` | ⚠️ Warning | No; D-25 locked this choice; supply-chain risk should be recorded |
|
||||
| WR-07 TestTerminalCommands passes SUMMERCMS_DIR through | `terminal_check_test.go:74` | ℹ️ Info (test hygiene) | No |
|
||||
| WR-08 `--generator` stage orphans the app binary | `check-phase14.3.sh:154` | ℹ️ Info (test hygiene) | No; in this run port 8080 was free afterwards |
|
||||
| IN-01..IN-10 | various | ℹ️ Info | No |
|
||||
|
||||
### Human Verification Required
|
||||
|
||||
1. **Tag v0.1.13.** Test: tag and push. Expected: the remote lists v0.1.13. Why: D-28 user action; until then the one-liner installs v0.1.12, which has no `summer new`.
|
||||
2. **Bump the site require and redeploy.** Test: bump `sm-summercmsio-plugin`/app to v0.1.13, deploy per DEPLOY.md, then `curl -sI https://summercms.io/new`. Expected: 200 `text/plain; charset=utf-8`. Why: currently 404.
|
||||
3. **Production acceptance.** Test: `bash scripts/check-phase14.3.sh --acceptance-production`. Expected: `Phase 14.3 production acceptance passed`.
|
||||
4. **Decide CR-01 / WR-02 before tagging.** Fix or mark deferred in 14.3-REVIEW-DISPOSITION.md.
|
||||
5. **Read generated CLAUDE.md and the docs pages** for accuracy and tone (WR-02 makes one installation.md sentence false).
|
||||
6. **Confirm the judgment-tier prohibition verdicts** in the table above.
|
||||
|
||||
### Gaps Summary
|
||||
|
||||
No goal gap blocks the phase: every success criterion is implemented, wired and was exercised by this verifier (unit gate, real PostgreSQL generator run, bootstrap end-to-end, node:24-bookworm container acceptance). The goal is not fully delivered until the user actions run: the v0.1.13 tag does not exist, summercms.io/new answers 404, and the production acceptance has not run. The open review findings are quality defects outside the documented happy path; CR-01 is the one worth fixing before the tag, because the tag freezes the generator and the script.
|
||||
|
||||
---
|
||||
|
||||
_Verified: 2026-10-08T12:10:00Z_
|
||||
_Verifier: Claude (gsd-verifier)_
|
||||
@@ -0,0 +1,144 @@
|
||||
# Phase 16: grzybyfunkcjonalne.pl on reusable blog views - Context
|
||||
|
||||
**Gathered:** 2026-10-07
|
||||
**Status:** Ready for planning
|
||||
|
||||
<domain>
|
||||
## Phase Boundary
|
||||
|
||||
grzybyfunkcjonalne.pl gets a new Nuxt frontend built the same way as `vue-summercmsio-app`. Its styling is based on the current arsha theme but refreshed. It reads Journal content from the Phase 15 `/_journal/api/v1`, and the Go binary (`sm-grzybyfunkcjonalne-app`) stays headless. The blog views (Journal pages, components and API client) live in a reusable Nuxt layer, so a later blog (golem15.com, a summercms.io blog) can mount them with its own content and styling.
|
||||
|
||||
The phase ends with the site **deploy-ready on staging** on the same server as the PHP site. The production nginx/DNS switch is a separate operational step after the phase.
|
||||
|
||||
Out of scope: porting the Winter arsha/Twig theme or a Go server-side template engine; the menu, FAQ, sitemanager, blocks and sitemap plugin ports; the contact and newsletter forms; an English locale; changes to `sm-journal-plugin`'s API contract (Phase 15 D-14). `summercms.go` is touched only if a framework feature turns out to be missing.
|
||||
|
||||
</domain>
|
||||
|
||||
<decisions>
|
||||
## Implementation Decisions
|
||||
|
||||
### Rendering approach (user-stated before discussion)
|
||||
- **D-01:** Build a new Nuxt theme following summercms standards (the `vue-summercmsio-app` setup: Nuxt 4, `@nuxtjs/i18n`, `@nuxtjs/seo`, `@nuxt/fonts`, pnpm, node `--test`). The styling is based on grzybyfunkcjonalne but refreshed, not a pixel copy of arsha/Bootstrap. Go stays headless. No Go `html/template` theme layer and no Twig port. — **Reversibility:** costly — every public page is a Nuxt page that consumes the Journal JSON API.
|
||||
|
||||
### Reuse shape
|
||||
- **D-02:** The reusable blog views are a **Nuxt layer** in a new repo `vue-journal-layer` (`git.golem15.com/golem15/vue-journal-layer`). Site apps consume it with `extends`. — **Reversibility:** costly — later blogs build on the layer's structure.
|
||||
- **D-03:** The layer ships a **neutral base design driven by CSS custom-property tokens** (colors, fonts, spacing, radii). It looks clean on its own. Site apps override the tokens and can override any layer component. The refreshed grzyby design lives in the site app, not in the layer.
|
||||
- **D-04:** Distribution is a **pnpm git dependency** with a pinned tag: `git+https://git.golem15.com/golem15/vue-journal-layer.git#<tag>`, with the commit locked in `pnpm-lock.yaml`. There is no npm registry and no submodule for the layer. (`git.golem15.com` is Gitea 1.25.4, and a public repo needs no auth.) Local development uses `pnpm link` or a `link:../vue-journal-layer` override. A release is a git tag.
|
||||
- **D-05:** The layer owns these Journal views and their data access: post list with pagination (successor of `journalPosts`, with sort and page size configurable), single post (successor of `journalPost`: rendered markdown, featured image, meta, related/random posts like `randomPosts`), category and tag listings, the author page (successor of `journalAuthor`), and the RSS feed link. It also owns a typed client for `/_journal/api/v1` (anonymous GET routes only).
|
||||
- **D-06:** Route paths in the layer must be configurable or overridable by the app, because grzyby uses specific PHP URLs (D-15) that another blog may not want.
|
||||
|
||||
### Render & deploy
|
||||
- **D-07:** **SSR**: `nuxt build` runs as a Node server, with `routeRules` swr/ISR caching so a newly published post shows up without a rebuild and pages stay full HTML for SEO. Unlike summercms.io (`nuxt generate` embedded in the binary), this site runs two processes: the Go binary and the Nuxt Node server. — **Reversibility:** costly — deploy scripts, supervisor and nginx topology depend on it.
|
||||
- **D-08:** The Nuxt app is a new repo `vue-grzybyfunkcjonalne-app`, mounted as a **submodule in `sm-grzybyfunkcjonalne-app`**, the same way `vue-summercmsio-app` is mounted in `sm-summercmsio-app`.
|
||||
- **D-09:** The admin is exposed on the site domain at **`/gfadmin`** (set with `backend.uri: /gfadmin` in the host config; cabana supports this without a framework change). nginx routes `/gfadmin` and `/_journal` (plus media/storage paths) to Go and everything else to Nuxt. PHP used `/backend`; the new path is intentional.
|
||||
- **D-10:** Deploy target is the **same server as the PHP site**. Staging runs next to the live Winter install. The later cutover switches nginx, and the Winter install stays as the rollback.
|
||||
|
||||
### Non-Journal content
|
||||
- **D-11:** The homepage marketing content (hero, about, why-us, CTA and the other sitemanager blocks, `content/blocks/*`, `content/homepage`, `content/blog` headers, the kontakt page content) moves **into the Nuxt app repo** as components plus i18n/markdown, with the current Polish copy carried over. The sitemanager `enable_*` toggles are dropped, and the design decides which sections stay. No sitemanager or blocks plugin port.
|
||||
- **D-12:** The main menu (`g15_menu`) becomes typed config in the app, and the FAQ (`g15_faq` slug `what-mushrooms-can-give-you`) becomes static i18n/markdown content in the app. Both get proper plugins later; todos `menu-plugin-port.md` and `faq-plugin-port.md` were added.
|
||||
- **D-13:** SEO and sitemap use **`@nuxtjs/seo` in the layer**: per-post meta, OG tags, canonical, robots (honor the per-page `robots_noindex` equivalents), and `sitemap.xml` generated from `/_journal/api/v1` plus the app's static pages. This replaces `eagle_seo` and `Golem15.Sitemap` for this site. `sm-sitemap-plugin` stays on the backlog.
|
||||
- **D-14:** The contact and newsletter forms are **dropped**. The kontakt page shows contact details and an email link only, and the newsletter stays in backlog 999.1. No new Go endpoints.
|
||||
|
||||
### URLs, i18n, migration, cutover
|
||||
- **D-15:** **The PHP public URLs stay identical**: `/`, `/blog` (with pagination), `/blog/:post`, `/autor/:author`, `/kontakt`, `/:category` (root-level catch-all, where known static routes take precedence), plus `/404` and `/error` behavior. Research must enumerate the real live URLs (including the pagination query/segment form and `trwaja-prace`) so none are lost. — **Reversibility:** one-way — search-indexed URLs; changing them later needs a 301 map.
|
||||
- **D-16:** **Polish only, i18n-ready**: `@nuxtjs/i18n` is wired in the layer and the app, the layer ships pl and en UI strings, and the site serves `pl` without a URL prefix. A later blog can enable more locales.
|
||||
- **D-17:** Content migration uses the Phase 15 **`journal:import-posts`** command fed by a PHP Journal export (the PHP repo already has `journal-export/*.json`), plus copying featured images and media into Go storage. Research must check that the export format matches the importer, and that slugs, authors, categories, tags, published dates and media references survive; it must flag gaps rather than patch over them.
|
||||
- **D-18:** **The phase stops at staging.** grzybyfunkcjonalne content runs end to end on staging (Go + Nuxt + imported posts) on the PHP site's server, with a smoke check that hits every old URL. The production switch is not a plan in this phase. Success criterion 1 counts as met once the site is served by SummerCMS on staging.
|
||||
|
||||
### Claude's Discretion
|
||||
- Plan count and split, subject to the plan-count checkpoint and "unit tests are the last plan" (the layer gets Vitest or `node --test` tests, and app-local Go code gets Go tests).
|
||||
- How the layer exposes route configuration (`app.config`, runtime config or pages override) and token names.
|
||||
- How markdown renders on the Nuxt side (render server-side from the API's markdown or HTML field, whichever the Phase 15 API returns), with sanitization consistent with Journal's XSS rules.
|
||||
- Exact swr/ISR TTLs, supervisor/nginx config and scripts layout, modeled on `sm-summercmsio-app/deploy` and `scripts/`.
|
||||
- Whether the refreshed visual direction gets a `/gsd-ui-phase` UI-SPEC before planning (recommended, not required).
|
||||
- Run the security-review agent only if new Go endpoints or auth surface appear. None are planned.
|
||||
|
||||
</decisions>
|
||||
|
||||
<canonical_refs>
|
||||
## Canonical References
|
||||
|
||||
**Downstream agents MUST read these before planning or implementing.**
|
||||
|
||||
### Roadmap and prior decisions
|
||||
- `.planning/ROADMAP.md` § Phase 16 — goal, repos, success criteria
|
||||
- `.planning/phases/15-journal-plugin/15-CONTEXT.md` — Journal API contract (D-14), host app layout (D-18–D-23), what Phase 16 inherits
|
||||
- `.planning/phases/15-journal-plugin/15-VERIFICATION.md`, `15-UAT.md` — what actually shipped in Phase 15
|
||||
- `.planning/notes/core-plugins-own-repos.md` — repo naming and submodule mount pattern
|
||||
- `.planning/todos/pending/menu-plugin-port.md`, `faq-plugin-port.md` — follow-ups created by D-12
|
||||
- `.planning/todos/pending/sitemap-plugin-port.md` — stays deferred (D-13)
|
||||
|
||||
### Journal API (consumed by the layer)
|
||||
- `../sm-journal-plugin/` — the Go plugin: routes and README for `/_journal/api/v1`
|
||||
- `/media/nvme/dev/golem15/fonoteka/plugins/golem15/journal/API.md` — request/response contract (frozen SHA `02110eb`)
|
||||
|
||||
### SummerCMS Nuxt standards (pattern to copy)
|
||||
- `../sm-summercmsio-app/README.md` — host + Nuxt submodule layout, build and deploy scripts
|
||||
- `../sm-summercmsio-app/vue-summercmsio-app/` — Nuxt 4 setup: `nuxt.config.ts`, i18n, seo, fonts, tests
|
||||
- `../sm-summercmsio-app/deploy/`, `scripts/` — nginx/supervisor/smoke precedents (adapt for SSR, D-07)
|
||||
|
||||
### Host application
|
||||
- `../sm-grzybyfunkcjonalne-app/` — `summer.yaml`, `config/`, `go.work`, plugins `golem15.{user,translate,journal}`
|
||||
- `modules/cabana/README.md` § Configuration — `backend.uri` (D-09)
|
||||
|
||||
### Legacy PHP site (styling, content and URL source, read-only)
|
||||
- `/media/nvme/dev/golem15/grzybyfunkcjonalne.pl/themes/arsha/pages/` — page URLs and component settings (D-15)
|
||||
- `/media/nvme/dev/golem15/grzybyfunkcjonalne.pl/themes/arsha/layouts/default.htm`, `partials/`, `assets/` — visual baseline for the refresh
|
||||
- `/media/nvme/dev/golem15/grzybyfunkcjonalne.pl/themes/arsha/content/`, `config/fields.yaml`, `lang/pl/` — homepage, blocks and kontakt copy (D-11)
|
||||
- `/media/nvme/dev/golem15/grzybyfunkcjonalne.pl/plugins/golem15/faq`, `plugins/golem15/menu` — FAQ and menu data to inline (D-12)
|
||||
- `/media/nvme/dev/golem15/grzybyfunkcjonalne.pl/journal-export/` — existing post export (D-17)
|
||||
|
||||
</canonical_refs>
|
||||
|
||||
<code_context>
|
||||
## Existing Code Insights
|
||||
|
||||
### Reusable Assets
|
||||
- `vue-summercmsio-app`: a working Nuxt 4 + `@nuxtjs/i18n` 10 + `@nuxtjs/seo` 5 + `@nuxt/fonts` config and `node --test` setup to copy for the layer and the app.
|
||||
- `sm-summercmsio-app` scripts: `build.sh`, `smoke.sh`, `check-deploy.sh` and `deploy/` (nginx, supervisor, `.env` template). Adapt them from static/embedded to SSR plus a Go upstream.
|
||||
- `sm-journal-plugin` `/_journal/api/v1`: posts, `posts/{slug}`, categories, tags and rss. The layer's only data source.
|
||||
- `journal:import-posts` / `journal:export-posts` (Phase 15 D-13) for the content migration.
|
||||
- cabana `backend.uri` for `/gfadmin`.
|
||||
|
||||
### Established Patterns
|
||||
- Naming: `sm-<name>-app` host, `vue-<name>-app` frontend submodule. The new shared frontend repo is `vue-journal-layer`.
|
||||
- Framework-neutral READMEs: the layer README must not name grzybyfunkcjonalne (use `blog` / `acme`).
|
||||
- `go vet` and `go test ./...` stay green in every Go repo touched. Unit tests are the last plan.
|
||||
|
||||
### Integration Points
|
||||
- New repos: `vue-journal-layer` (shared) and `vue-grzybyfunkcjonalne-app` (site, submodule of `sm-grzybyfunkcjonalne-app`).
|
||||
- `sm-grzybyfunkcjonalne-app`: `backend.uri: /gfadmin`, CORS/limiter config for the Nuxt server origin if needed, media serving, build/deploy scripts for two processes.
|
||||
- Staging server = the PHP site's server: nginx vhost for staging, supervisor entries for Go and Nuxt.
|
||||
|
||||
</code_context>
|
||||
|
||||
<specifics>
|
||||
## Specific Ideas
|
||||
|
||||
- "We will create new nuxt theme, per summercms standards, styling based on grzybyfunkcjonalne, but refreshed."
|
||||
- The admin path must be `/gfadmin`.
|
||||
- The user asked whether the layer could come straight from git.golem15.com the way PHP Composer VCS repos do. Yes: a pnpm git dependency, with no registry setup.
|
||||
- Menu and FAQ plugins will be needed later; they are recorded as todos and are not part of this phase.
|
||||
|
||||
</specifics>
|
||||
|
||||
<deferred>
|
||||
## Deferred Ideas
|
||||
|
||||
- `sm-menu-plugin` port (todo `menu-plugin-port.md`)
|
||||
- `sm-faq-plugin` port (todo `faq-plugin-port.md`)
|
||||
- Sitemanager/blocks plugin port for editor-managed homepage sections
|
||||
- Contact form via a Go endpoint, and the newsletter signup (backlog 999.1)
|
||||
- English locale for grzybyfunkcjonalne.pl
|
||||
- Production cutover (nginx switch on the PHP server, Winter kept as rollback): an operational step after the phase
|
||||
|
||||
### Reviewed Todos (not folded)
|
||||
- `sitemap-plugin-port.md`: superseded for this site by `@nuxtjs/seo` sitemap (D-13); stays on the backlog for a site that needs editor-defined sitemaps.
|
||||
- `nest-framework-packages-under-modules.md`, `2026-10-01-benchmark-the-application-on-wintercms-vs-summercms.md`, `2026-10-07-translate-messages-as-nuxt-i18n-json-editor.md`: keyword matches only, not this phase.
|
||||
|
||||
</deferred>
|
||||
|
||||
---
|
||||
|
||||
*Phase: 16-grzybyfunkcjonalne-pl-on-reusable-blog-views*
|
||||
*Context gathered: 2026-10-07*
|
||||
@@ -0,0 +1,72 @@
|
||||
# Phase 16: grzybyfunkcjonalne.pl on reusable blog views - Discussion Log
|
||||
|
||||
> **Audit trail only.** Do not use as input to planning, research, or execution agents.
|
||||
> Decisions are captured in CONTEXT.md — this log preserves the alternatives considered.
|
||||
|
||||
**Date:** 2026-10-07
|
||||
**Phase:** 16-grzybyfunkcjonalne-pl-on-reusable-blog-views
|
||||
**Areas discussed:** Reuse shape, Render & deploy, Non-Journal content, URLs/i18n/cutover
|
||||
|
||||
---
|
||||
|
||||
## Pre-discussion
|
||||
|
||||
While the arsha theme was being scouted, the user cut the research short: "we will create new nuxt theme, per summercms standards, styling based on grzybyfunkcjonalne, but refreshed." That settled the rendering approach before any gray areas were presented (no Go template engine, no Twig port).
|
||||
|
||||
## Reuse shape
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| Nuxt layer repo | `vue-journal-layer` consumed with `extends` | ✓ |
|
||||
| Starter template | Copy per blog | |
|
||||
| One configurable app | Branding set by config | |
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| Neutral base + tokens | CSS custom properties, the app overrides them | ✓ |
|
||||
| Headless only | Unstyled | |
|
||||
| Full grzyby design | Layer carries the grzyby look | |
|
||||
|
||||
Distribution: the user asked what the npm option would need and whether git.golem15.com could be used the way Composer VCS repos are. git.golem15.com turned out to be Gitea 1.25.4, and the options were explained again.
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| pnpm git dependency | `git+https://…#tag`, lockfile-pinned, no setup | ✓ |
|
||||
| Gitea npm registry | `.npmrc`, a token and a publish step | |
|
||||
| Git submodule + path extends | ssu-managed | |
|
||||
|
||||
Views in the layer (multi): post list + pagination, single post, category + tag listings, author page + RSS link (all selected).
|
||||
|
||||
## Render & deploy
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| SSR + cached routes | Node server, swr/ISR | ✓ |
|
||||
| Prerender + rebuild on publish | Same as summercms.io | |
|
||||
| Prerender shell + client fetch | Weak SEO | |
|
||||
|
||||
App repo: submodule in the host app (✓) vs a standalone repo.
|
||||
Admin access: "1, but we'll need /gfadmin for backend". The admin is exposed on the site domain at `/gfadmin`.
|
||||
Server: the same server as the PHP site (✓) vs the summercms.io server vs deciding later.
|
||||
|
||||
## Non-Journal content
|
||||
|
||||
Homepage: in the Nuxt app repo (✓) vs a sitemanager/blocks plugin vs Journal posts.
|
||||
FAQ & menu: "Static in the app, we'll need menu and faq plugins in future so add migrating these to todo". Todos were created.
|
||||
SEO/sitemap: `@nuxtjs/seo` in the layer (✓) vs porting `sm-sitemap-plugin`.
|
||||
Forms: contact info only, forms dropped (✓) vs a Go contact endpoint vs research first.
|
||||
|
||||
## URLs, i18n, cutover
|
||||
|
||||
URLs: keep the PHP URLs identical (✓) vs moving categories vs a new scheme with a 301 map.
|
||||
Locales: Polish only, i18n-ready (✓) vs pl + en.
|
||||
Migration: `journal:import-posts` + media copy (✓) vs a DB script vs letting research decide.
|
||||
Cutover: stop at staging (✓) vs a production cutover inside the phase.
|
||||
|
||||
## Claude's Discretion
|
||||
|
||||
Plan split, layer route configuration mechanism, markdown rendering, cache TTLs, deploy script layout, whether to run a UI-SPEC first.
|
||||
|
||||
## Deferred Ideas
|
||||
|
||||
The menu, FAQ and sitemanager/blocks plugin ports; the contact form and newsletter; an English locale; the production cutover.
|
||||
@@ -0,0 +1,33 @@
|
||||
---
|
||||
quick_id: 261007-qgx
|
||||
mode: quick
|
||||
---
|
||||
|
||||
# Quick 261007-qgx: admin record heading uses translatable (mltext) fields
|
||||
|
||||
The admin record heading, breadcrumb record crumb and document title read the
|
||||
first `text` field only, so records whose name field is `mltext` (journal Post
|
||||
title, Category name) fall back to "Edit record".
|
||||
|
||||
## Task 1: shared recordDisplayName helper
|
||||
|
||||
- files: admin/src/components/form/formState.ts, admin/src/views/FormView.vue, admin/src/views/PreviewView.vue
|
||||
- action: add `recordDisplayName(fields, values, enabledLocales)`: first field
|
||||
of type `text` or an ML type (`isMLFieldType`) in schema order; plain values
|
||||
as today (string/number, trimmed); ML values use the default (first enabled)
|
||||
locale, else the first non-empty locale, else ''. Both views use it with
|
||||
`schemaEnabledLocales(schema.meta)`.
|
||||
- done: plain `text` behaviour unchanged; an mltext first field names the record.
|
||||
|
||||
## Task 2: tests
|
||||
|
||||
- files: admin/tests/form/formState.test.ts, admin/tests/form/FormView.test.ts, admin/tests/form/PreviewView.test.ts
|
||||
- action: helper cases (plain, default locale filled, default empty + other
|
||||
filled, all empty, non-text first field skipped); FormView and PreviewView
|
||||
show the default-locale value as heading and record crumb for an mltext field.
|
||||
- verify: `npm test`, `vue-tsc --noEmit`.
|
||||
|
||||
## Task 3: ship
|
||||
|
||||
- rebuild the embedded admin shell (`npm run build` → modules/boardwalk/dist),
|
||||
`go vet ./... && go test ./...`, tag and push v0.1.12.
|
||||
@@ -0,0 +1,18 @@
|
||||
---
|
||||
quick_id: 261007-qgx
|
||||
status: complete
|
||||
commits: [a714bb4, b6b194e]
|
||||
---
|
||||
|
||||
# Quick 261007-qgx: admin record heading uses translatable (mltext) fields
|
||||
|
||||
- `recordDisplayName(fields, values, enabledLocales)` in
|
||||
`admin/src/components/form/formState.ts`: the first `text` or ML field in
|
||||
schema order names the record; an ML value reads the default (first enabled)
|
||||
locale, then the first non-empty locale, else '' (the generic message stays).
|
||||
- FormView and PreviewView both use it with `schemaEnabledLocales(schema.meta)`;
|
||||
plain `text` behaviour is unchanged.
|
||||
- Tests: five helper cases in `formState.test.ts`; FormView and PreviewView
|
||||
cases for an mltext first field (heading and record crumb). Both view tests
|
||||
fail without the fix. Admin suite 1090/1090, `go vet` and `go test ./...` green.
|
||||
- Embedded admin shell rebuilt (`modules/boardwalk/dist`), shipped as v0.1.12.
|
||||
9
.planning/research/questions.md
Normal file
9
.planning/research/questions.md
Normal file
@@ -0,0 +1,9 @@
|
||||
# Open research questions
|
||||
|
||||
## From the core plugin port exploration (2026-10-07)
|
||||
|
||||
- **Does PaymentGateway need `winter/location`?** It is installed in 19 projects. Check whether PaymentGateway shipping addresses, the shop or user profiles read its countries and states. If they do, Phase 18 needs a Go equivalent (a framework module or an `sm-location-plugin`). Phase: 18.
|
||||
- **Does Horoskopia define the full Phase 18 acceptance contract?** Which `vue-horoskopia-app` requests reach PaymentGateway or subscription code? Do they cover the order and refund paths, or only subscriptions? Are other order flows tested only through hand-written host tests? Phase: 18.
|
||||
- **Which chat source should Phase 19 port?** Wavepath's chat has `root_id` threading and `thread_summaries`. Compare it with the starter's `wn-chat-plugin` and the other 16 copies, and pick the newest as the 1:1 source. Phase: 19.
|
||||
- **Does any Płytarium route need userfriends?** PHP fonoteka has `golem15/userfriends`, but fonoteka.go has no port, and Phase 14 reached 175/175 routes without one. Confirm before porting it for Wavepath in Phase 19.1, so there is one shared copy. Phase: 19.1.
|
||||
- **How should Herald's Web Push be done in Go?** Herald uses `minishlink/web-push`. Choose a Go Web Push/VAPID library (or a stdlib implementation) and record it in a decision note before Phase 19.1 code lands. Phase: 19.1.
|
||||
43
.planning/seeds/SEED-261007-0tb-wavepath-port.md
Normal file
43
.planning/seeds/SEED-261007-0tb-wavepath-port.md
Normal file
@@ -0,0 +1,43 @@
|
||||
---
|
||||
id: SEED-261007-0tb
|
||||
status: dormant
|
||||
planted: 2026-10-07
|
||||
planted_during: Phase 16 (context gathered)
|
||||
trigger_when: The Horoskopia port (SEED-261007-j2b) is complete, and Phases 19 and 19.1 (chat and the chat satellites) are done.
|
||||
scope: Large
|
||||
---
|
||||
|
||||
# SEED-261007-0tb: Wavepath port to SummerCMS
|
||||
|
||||
## Why This Matters
|
||||
|
||||
Wavepath (`/media/nvme/dev/jz/wavepath.org`) is the largest Golem15 app and the heaviest user of chat and chatchannels. Phases 19 and 19.1 port the shared plugins Wavepath requires so that the app port itself is only the app plugin, OAuth and cutover.
|
||||
|
||||
## When to Surface
|
||||
|
||||
**Trigger:** after the Horoskopia port, with Phases 19 and 19.1 done. This stretch is about preparing the shared plugins, not migrating apps, and a stabilization phase after Phase 20 may come first.
|
||||
|
||||
## Scope Estimate
|
||||
|
||||
**Large:** a milestone, probably larger than Płytarium:
|
||||
|
||||
- **App plugin:** `wavepath` has 30.6k PHP LOC and **255 routes**, compared with Płytarium's 175. Expect 2–3 API phases plus a cutover phase.
|
||||
- **Clients:** `vue-wavepath-app`, `wavepath-mcp`, `tauri-wavepath-desktop`, `electron-wavepath` and `hubot-wavepath`. All of them define the parity contract.
|
||||
- **Required shared plugins:**
|
||||
- Ported: user.
|
||||
- Phase 19: chat, chatchannels.
|
||||
- Phase 19.1: chatbots, chatvideo, forum, herald, userfriends.
|
||||
- Already covered by framework `lighthouse`: websockets.
|
||||
- **OAuth:** `oauthserver` (5.5k LOC, 10 routes, OIDC) sits on the framework's `wristband` package. OIDC parts (id_token, userinfo) may be new to `wristband`.
|
||||
- **Also mounted:** feedback (ported), journal (ported), sitemap and faq (Phase 17), and paymentgateway/pgstripe. Wavepath's own code never references PaymentGateway, so check whether it can be dropped.
|
||||
|
||||
## Breadcrumbs
|
||||
|
||||
- `.planning/notes/core-plugin-port-inventory.md`
|
||||
- `.planning/notes/oauth2-wristband-direct-port.md`
|
||||
- `.planning/ROADMAP.md`: Phases 19 and 19.1
|
||||
- `.planning/research/questions.md`: chat source version, Herald Web Push dependency
|
||||
|
||||
## Notes
|
||||
|
||||
Planted during the 2026-10-07 `/gsd-explore` session on porting the core plugins.
|
||||
46
.planning/seeds/SEED-261007-j2b-horoskopia-port.md
Normal file
46
.planning/seeds/SEED-261007-j2b-horoskopia-port.md
Normal file
@@ -0,0 +1,46 @@
|
||||
---
|
||||
id: SEED-261007-j2b
|
||||
status: dormant
|
||||
planted: 2026-10-07
|
||||
planted_during: Phase 16 (context gathered)
|
||||
trigger_when: Phase 20 (Płytarium cutover) is complete, plus the stabilization phase if one is added. It comes before the Wavepath port.
|
||||
scope: Large
|
||||
---
|
||||
|
||||
# SEED-261007-j2b: Horoskopia port to SummerCMS
|
||||
|
||||
## Why This Matters
|
||||
|
||||
Horoskopia (`/media/nvme/dev/golem15/horoskopia.eu`) is the second app port after Płytarium, and the first that takes payments. Its `horoscope` plugin creates orders, uses Stripe subscriptions and reacts to payment events. That makes it the acceptance consumer for Phase 18 (Payment Gateway) and the 18.x subscription phases. Without a real app, the PaymentGateway port has no API contract to test against.
|
||||
|
||||
## When to Surface
|
||||
|
||||
**Trigger:** Phase 20 (Płytarium cutover) is complete, plus the stabilization phase if one is added. This port comes before the Wavepath port (SEED-261007-0tb).
|
||||
|
||||
## Scope Estimate
|
||||
|
||||
**Large:** probably a milestone, with the same shape as Płytarium (phases 12–14 and 20):
|
||||
|
||||
- **App plugin:** `horoscope` has 37k PHP LOC and 65 routes and requires Apparatus, User, PaymentGatewaySubscriptions and PGStripe.
|
||||
- **Frontend:** `vue-horoskopia-app` (Nuxt 4), so it gets parity-harness treatment like Płytarium.
|
||||
- **Shared plugins it needs:**
|
||||
- Ported: user, translate, journal, golem.
|
||||
- Phase 17: sitemap, faq.
|
||||
- Phase 18/18.x: paymentgateway, pgstripe, paymentgatewaysubscriptions.
|
||||
- Phase 19: chat.
|
||||
- Backlog 999.1: newsletter.
|
||||
- Not scheduled: quote and github, which are small.
|
||||
- Single-project: journalreposter (5.4k LOC), which stays in the app.
|
||||
- Third-party: `winter/location`.
|
||||
- **PaymentGateway API it calls:** `Order`, `OrderFactory`, `Cart`/`Item`/`Details`, `Settings`, `Payment`, `Subscription`, `SubscriptionPlan` and the `SubscriptionGateway` contract.
|
||||
|
||||
## Breadcrumbs
|
||||
|
||||
- `.planning/notes/core-plugin-port-inventory.md`: plugin inventory and port order
|
||||
- `.planning/ROADMAP.md`: Phase 18 (Payment Gateway) names Horoskopia as its acceptance consumer
|
||||
- `.planning/research/questions.md`: what the Horoskopia acceptance contract covers, and whether `winter/location` is needed
|
||||
- Backlog 999.1: the newsletter port source is checked out at `horoskopia.eu/plugins/golem15/newsletter`
|
||||
|
||||
## Notes
|
||||
|
||||
Planted during the 2026-10-07 `/gsd-explore` session on porting the core plugins.
|
||||
@@ -96,13 +96,33 @@
|
||||
{
|
||||
"number": "15",
|
||||
"name": "Journal plugin",
|
||||
"status": "pending"
|
||||
"status": "complete"
|
||||
},
|
||||
{
|
||||
"number": "16",
|
||||
"name": "grzybyfunkcjonalne.pl on reusable blog views",
|
||||
"status": "pending"
|
||||
},
|
||||
{
|
||||
"number": "17",
|
||||
"name": "Blog companions",
|
||||
"status": "pending"
|
||||
},
|
||||
{
|
||||
"number": "18",
|
||||
"name": "Payment Gateway",
|
||||
"status": "pending"
|
||||
},
|
||||
{
|
||||
"number": "19",
|
||||
"name": "Chat and ChatChannels",
|
||||
"status": "pending"
|
||||
},
|
||||
{
|
||||
"number": "19.1",
|
||||
"name": "Chat satellites",
|
||||
"status": "pending"
|
||||
},
|
||||
{
|
||||
"number": "20",
|
||||
"name": "Płytarium cutover",
|
||||
@@ -112,7 +132,7 @@
|
||||
"next": {
|
||||
"command": "/gsd:progress --next",
|
||||
"label": "Advance to the next step",
|
||||
"reason": "Phase 15 of 26 · executing"
|
||||
"reason": "Phase 14.2 of 30 · executing"
|
||||
},
|
||||
"updated_at": "2026-10-06T16:30:35.416Z"
|
||||
"updated_at": "2026-10-07T19:26:02.872Z"
|
||||
}
|
||||
|
||||
10
.planning/todos/pending/faq-plugin-port.md
Normal file
10
.planning/todos/pending/faq-plugin-port.md
Normal file
@@ -0,0 +1,10 @@
|
||||
---
|
||||
title: Port the FAQ plugin as sm-faq-plugin so FAQs become editable
|
||||
date: 2026-10-07
|
||||
priority: medium
|
||||
area: sm-faq-plugin
|
||||
---
|
||||
|
||||
Phase 16 ships the grzybyfunkcjonalne.pl FAQ (`g15_faq` slug `what-mushrooms-can-give-you`) as static i18n/markdown content in the Nuxt app. Later blogs need editors to manage FAQs, so port `Golem15.Faq` 1:1 as its own `sm-faq-plugin` repo (see `.planning/notes/core-plugins-own-repos.md`): models, admin screens and a public read API by slug.
|
||||
|
||||
Reference: `/media/nvme/dev/golem15/grzybyfunkcjonalne.pl/plugins/golem15/faq` and the theme partials `themes/arsha/partials/g15_faq/` and `g15_faq_whyus/`. When it lands, move the grzybyfunkcjonalne FAQ out of the app into the plugin.
|
||||
10
.planning/todos/pending/menu-plugin-port.md
Normal file
10
.planning/todos/pending/menu-plugin-port.md
Normal file
@@ -0,0 +1,10 @@
|
||||
---
|
||||
title: Port the menu plugin as sm-menu-plugin so blog menus become editable
|
||||
date: 2026-10-07
|
||||
priority: medium
|
||||
area: sm-menu-plugin
|
||||
---
|
||||
|
||||
Phase 16 hardcodes the grzybyfunkcjonalne.pl main menu as typed config in the Nuxt app, replacing the `g15_menu` component. Later blogs need editors to manage menus again, so port `Golem15.Menu` 1:1 as its own `sm-menu-plugin` repo (see `.planning/notes/core-plugins-own-repos.md`): models, admin screens and a public read API that the shared `vue-journal-layer` (or a sibling layer) can consume.
|
||||
|
||||
Reference: `/media/nvme/dev/golem15/grzybyfunkcjonalne.pl/plugins/golem15/menu` and the theme partial `themes/arsha/partials/g15_menu/default.htm` (nested link entries, target, css_class, active state). When it lands, move the grzybyfunkcjonalne menu from app config into the plugin.
|
||||
44
README.md
44
README.md
@@ -7,17 +7,39 @@ SummerCMS is a content management framework for Go, inspired by WinterCMS. An ap
|
||||
- **Compiled plugins.** A plugin is a Go module that implements the capability interfaces in [pact](modules/pact/README.md). The `summer build` command reads the application's `summer.yaml` manifest, generates the plugin import list and builds the binary. Plugins are registered at build time and never loaded at runtime. [party](modules/party/README.md) orders them by dependency and runs their register and boot lifecycle.
|
||||
- **YAML-driven admin.** Plugins describe admin lists, forms, filters and relations in WinterCMS-style YAML (`columns.yaml`, `fields.yaml`). [cabana](modules/cabana/README.md) compiles those definitions at boot and serves them as a JSON admin API, and [boardwalk](modules/boardwalk/README.md) serves the embedded Vue admin SPA that renders them.
|
||||
- **Headless API.** Plugins declare HTTP routes and middleware. [surf](modules/surf/README.md) assembles them onto a standard library `net/http` ServeMux, and [wire](modules/wire/README.md) keeps JSON responses byte-compatible with a PHP (WinterCMS/Laravel) backend.
|
||||
- **Scaffolding CLI.** The `summer` tool builds and watches applications and scaffolds plugins, models, migrations, console commands, jobs and admin controllers. Every application binary also carries runtime commands such as `migrate`, `serve` and `route:list`.
|
||||
- **Scaffolding CLI.** The `summer` tool creates, builds and watches applications and scaffolds plugins, models, migrations, console commands, jobs and admin controllers. Every application binary also carries runtime commands such as `migrate`, `serve` and `route:list`.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Go 1.27.
|
||||
- PostgreSQL 15 or newer for any application that uses the data layer ([lagoon](modules/lagoon/README.md)).
|
||||
- Node.js 22.6 or newer, only when working on the admin SPA in `admin/`.
|
||||
- Docker, only for the integration tests that start PostgreSQL or Mailpit containers through testcontainers-go.
|
||||
- Docker, to run the PostgreSQL 16 database of an application created by `summer new` (its `compose.yaml`), and for the integration tests that start PostgreSQL or Mailpit containers through testcontainers-go.
|
||||
|
||||
## Quick start
|
||||
|
||||
Create an application with one command. The script checks for Go 1.27 and Docker, clones the framework at its latest release tag into `./summercms`, installs the `summer` CLI and runs `summer new demo`:
|
||||
|
||||
```sh
|
||||
curl -fsSL https://summercms.io/new | bash -s -- demo
|
||||
cd sm-demo-app
|
||||
docker compose up -d --wait # PostgreSQL 16 on 127.0.0.1:54320
|
||||
summer build
|
||||
summer migrate
|
||||
summer serve # then: curl http://127.0.0.1:8080/
|
||||
```
|
||||
|
||||
The [sunrise](modules/sunrise/README.md) module holds the script and documents its `SUMMERCMS_REPO`, `SUMMERCMS_DIR` and `SUMMERCMS_REF` overrides.
|
||||
|
||||
With a framework checkout already in place, install the CLI from it and run `summer new` in the directory next to the checkout; it finds a sibling `summercms` or `summercms.go` checkout, or takes `--framework <path>`:
|
||||
|
||||
```sh
|
||||
go -C summercms install ./cmd/summer
|
||||
summer new demo
|
||||
```
|
||||
|
||||
### The example application
|
||||
|
||||
`examples/hello` is a small application with three compiled plugins. Install the `summer` CLI from the repository root, then build the example:
|
||||
|
||||
```sh
|
||||
@@ -38,15 +60,7 @@ The `migrate`, `migrate:status`, `migrate:rollback`, `serve` and admin commands
|
||||
CREATE DATABASE hello;
|
||||
```
|
||||
|
||||
2. Add the request body limits that `serve` and `route:list` require. The example does not ship them yet, and surf needs numeric values, which the string-valued environment overlay cannot supply. Create `config/http.yaml` in `examples/hello`:
|
||||
|
||||
```yaml
|
||||
body_limits:
|
||||
default_bytes: 1048576
|
||||
upload_bytes: 10485760
|
||||
```
|
||||
|
||||
3. Point the binary at the database, give it an application key and an uploads bucket, then migrate and serve:
|
||||
2. Point the binary at the database, give it an application key and an uploads bucket, then migrate and serve:
|
||||
|
||||
```sh
|
||||
./bin/hello key:generate # prints a base64 key; copy the value
|
||||
@@ -61,11 +75,6 @@ The `migrate`, `migrate:status`, `migrate:rollback`, `serve` and admin commands
|
||||
|
||||
`summer migrate`, `summer migrate:status`, `summer migrate:rollback` and `summer serve`, run from the application directory, delegate to the built binary in `bin/`.
|
||||
|
||||
### Known issues
|
||||
|
||||
- `examples/hello` has no `http.body_limits` configuration, so `serve` and `route:list` fail with `surf: config http.body_limits.default_bytes is required` until you add it as in step 2. The same gap makes the example's `TestTypedItemRoute` fail.
|
||||
- The committed `examples/hello/main.go` is older than what `summer build` generates now, so building the example (or running its tests, which build it) leaves that file modified. Restore it with `git checkout -- examples/hello/main.go` if you do not intend to commit it.
|
||||
|
||||
## Repository layout
|
||||
|
||||
| Path | Contents |
|
||||
@@ -99,6 +108,7 @@ The `migrate`, `migrate:status`, `migrate:rollback`, `serve` and admin commands
|
||||
| [party](modules/party/README.md) | Compiled plugin registry that orders plugins by their dependencies and runs their Register and Boot lifecycle. |
|
||||
| [phrasebook](modules/phrasebook/README.md) | Namespaced translation catalogs loaded from plugin YAML, with locale fallback, placeholder interpolation and CLDR pluralization. |
|
||||
| [postcard](modules/postcard/README.md) | Transactional mail from plugin-owned Markdown templates and layouts, delivered through a memory, log or SMTP driver. |
|
||||
| [sunrise](modules/sunrise/README.md) | Bootstrap script that installs the summer CLI and creates a new SummerCMS application in one command, embedded so it is versioned with the framework. |
|
||||
| [sunscreen](modules/sunscreen/README.md) | Credential-redacting slog handler that keeps API keys, tokens and passwords out of application logs. |
|
||||
| [surf](modules/surf/README.md) | HTTP routing for SummerCMS: collects plugin routes and named middleware into a `net/http` ServeMux with constraints, rate limiting, body limits, CORS and panic recovery, and provides the `serve` and `route:list` commands. |
|
||||
| [tide](modules/tide/README.md) | HTTP parity toolkit that records request and response fixtures from a reference backend, replays them against a new one and reports normalized differences. |
|
||||
@@ -108,7 +118,7 @@ The `migrate`, `migrate:status`, `migrate:rollback`, `serve` and admin commands
|
||||
|
||||
## Using SummerCMS in an application
|
||||
|
||||
The framework is a single Go module, `git.golem15.com/golem15/summercms`; each framework package is imported from `git.golem15.com/golem15/summercms/modules/<name>`. An application requires that module and, during development, points it at a local checkout with a `replace` directive. For an application module `acme` in a sibling directory:
|
||||
The framework is a single Go module, `git.golem15.com/golem15/summercms`; each framework package is imported from `git.golem15.com/golem15/summercms/modules/<name>`. An application requires that module and, during development, points it at a local checkout with a `replace` directive. `summer new` writes this `go.mod` for you, with a relative `replace` to the framework checkout and a committed `go.work` (see [Go modules and workspaces](docs/architecture/go-modules-and-workspaces.md)). Written by hand, for an application module `acme` in a sibling directory:
|
||||
|
||||
```
|
||||
module git.golem15.com/acme/acme
|
||||
|
||||
@@ -217,6 +217,35 @@ export function mergeMLValue(incoming: unknown, enabledLocales: readonly string[
|
||||
return { ...seeded, ...localeRecord(incoming) }
|
||||
}
|
||||
|
||||
/** The record's display name: the value of the first text-like field (plain
|
||||
* `text` or an ML type) in schema order. An ML value reads the default (first
|
||||
* enabled) locale, then the first non-empty locale; '' when nothing is set. */
|
||||
export function recordDisplayName(
|
||||
fields: readonly FormField[] | undefined,
|
||||
values: AdminRecord,
|
||||
enabledLocales: readonly string[],
|
||||
): string {
|
||||
const first = fields?.find((field) => field.type === 'text' || isMLFieldType(field.type))
|
||||
const value = first ? values[first.name] : undefined
|
||||
if (typeof value === 'string' || typeof value === 'number') {
|
||||
return String(value).trim()
|
||||
}
|
||||
if (!first || !isMLFieldType(first.type)) {
|
||||
return ''
|
||||
}
|
||||
const texts = localeRecord(value)
|
||||
const preferred = enabledLocales[0] !== undefined ? (texts[enabledLocales[0]] ?? '').trim() : ''
|
||||
if (preferred !== '') {
|
||||
return preferred
|
||||
}
|
||||
for (const text of Object.values(texts)) {
|
||||
if (text.trim() !== '') {
|
||||
return text.trim()
|
||||
}
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
/** D-10 422 details (field -> messages) as a string-list map. */
|
||||
export function fieldErrors(details: ErrorBody['details'] | undefined): Record<string, string[]> {
|
||||
const out: Record<string, string[]> = {}
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
mergeMLValue,
|
||||
panelDomId,
|
||||
presetUpdates,
|
||||
recordDisplayName,
|
||||
schemaEnabledLocales,
|
||||
snapshot,
|
||||
tabDomId,
|
||||
@@ -139,12 +140,10 @@ const panelFields = computed(() => {
|
||||
return fields.value.filter((field) => tabOf(field) === key)
|
||||
})
|
||||
|
||||
/** The record's display name: the first text field's value, in schema order. */
|
||||
const recordName = computed(() => {
|
||||
const first = schema.value?.fields.find((field) => field.type === 'text')
|
||||
const value = first ? values.value[first.name] : undefined
|
||||
return typeof value === 'string' || typeof value === 'number' ? String(value).trim() : ''
|
||||
})
|
||||
/** The record's display name: the first text-like field's value, in schema order. */
|
||||
const recordName = computed(() =>
|
||||
recordDisplayName(schema.value?.fields, values.value, schemaEnabledLocales(schema.value?.meta)),
|
||||
)
|
||||
|
||||
const title = computed(() => {
|
||||
if (mode === 'update' && recordName.value !== '') {
|
||||
|
||||
@@ -14,7 +14,16 @@ import FormTabs from '../components/form/FormTabs.vue'
|
||||
import PreviewField from '../components/form/PreviewField.vue'
|
||||
import RecordActions from '../components/form/RecordActions.vue'
|
||||
import { RELATION_MANAGER } from '../components/form/registry'
|
||||
import { DEFAULT_TAB, contextAllows, panelDomId, tabDomId, tabOf, type TabItem } from '../components/form/formState'
|
||||
import {
|
||||
DEFAULT_TAB,
|
||||
contextAllows,
|
||||
panelDomId,
|
||||
recordDisplayName,
|
||||
schemaEnabledLocales,
|
||||
tabDomId,
|
||||
tabOf,
|
||||
type TabItem,
|
||||
} from '../components/form/formState'
|
||||
import PartialHost from '../components/partial/PartialHost.vue'
|
||||
import Button from '../components/ui/Button.vue'
|
||||
import { clearRecordCrumb, setRecordCrumb } from '../state/useBreadcrumbs'
|
||||
@@ -96,12 +105,10 @@ const panelFields = computed(() => {
|
||||
return fields.value.filter((field) => tabOf(field) === key)
|
||||
})
|
||||
|
||||
/** The record's display name: the first text field's value, in schema order. */
|
||||
const recordName = computed(() => {
|
||||
const first = schema.value?.fields.find((field) => field.type === 'text')
|
||||
const value = first ? values.value[first.name] : undefined
|
||||
return typeof value === 'string' || typeof value === 'number' ? String(value).trim() : ''
|
||||
})
|
||||
/** The record's display name: the first text-like field's value, in schema order. */
|
||||
const recordName = computed(() =>
|
||||
recordDisplayName(schema.value?.fields, values.value, schemaEnabledLocales(schema.value?.meta)),
|
||||
)
|
||||
|
||||
const subtitle = computed(() => message(schema.value?.messages.preview, undefined, { name: recordName.value }))
|
||||
const hintPartial = computed(() => schema.value?.preview?.headerPartial ?? '')
|
||||
|
||||
@@ -611,6 +611,14 @@ describe('password and preset on the form (UI-SPEC S7, D-19, D-27 G7)', () => {
|
||||
expect(editor(wrapper, 'slug').element.value).toBe('ada-lovelace')
|
||||
})
|
||||
|
||||
it('titles a record by the default locale of an mltext first field, and publishes it as the crumb', async () => {
|
||||
const record = clone(rosterRecordFixture)
|
||||
record.data.name = { en: 'Ada Lovelace', pl: 'Ada Lovelace PL' }
|
||||
const { wrapper } = await mountApp('/acme/roster/people/1', people({ [`GET ${PEOPLE}/schema/form`]: mlSchema('mltext', 'text'), [`GET ${PERSON}`]: { body: record } }))
|
||||
expect(wrapper.find('h1').text()).toBe('Ada Lovelace')
|
||||
expect(recordCrumb.value).toBe('Ada Lovelace')
|
||||
})
|
||||
|
||||
it('drives a text slug from the active locale of an mltext name, until edited', async () => {
|
||||
const { wrapper } = await mountApp('/acme/roster/people/create', people({ [`GET ${PEOPLE}/schema/form`]: mlSchema('mltext', 'text') }))
|
||||
await editor(wrapper, 'name').setValue('Hello World')
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { enableAutoUnmount, flushPromises, type VueWrapper } from '@vue/test-utils'
|
||||
import { setBundle } from '../../src/app/i18n'
|
||||
import { recordCrumb } from '../../src/state/useBreadcrumbs'
|
||||
import { toasts } from '../../src/state/useToasts'
|
||||
import PreviewView from '../../src/views/PreviewView.vue'
|
||||
import { clone, langFixture, rosterFormSchemaFixture, rosterRecordFixture } from '../fixtures/typed'
|
||||
@@ -121,6 +122,23 @@ describe('preview view (UI-SPEC S3, D-11)', () => {
|
||||
expect(editor.findAll('[role="radiogroup"]').every((group) => group.attributes('aria-disabled') === 'true')).toBe(true)
|
||||
})
|
||||
|
||||
it('heads the record by the default locale of an mltext first field, and publishes it as the crumb', async () => {
|
||||
const { wrapper } = await mountApp(
|
||||
PREVIEW,
|
||||
routes({
|
||||
[`GET ${LIST}/schema/form`]: schemaWith((schema) => {
|
||||
schema.meta.enabledLocales = ['en', 'pl']
|
||||
schema.fields.find((field) => field.name === 'name')!.type = 'mltext'
|
||||
}),
|
||||
[`GET ${RECORD}`]: recordWith((record) => {
|
||||
record.data.name = { en: 'Ada Lovelace', pl: 'Ada Lovelace PL' }
|
||||
}),
|
||||
}),
|
||||
)
|
||||
expect(wrapper.find('h1').text()).toBe('Ada Lovelace')
|
||||
expect(recordCrumb.value).toBe('Ada Lovelace')
|
||||
})
|
||||
|
||||
it('shows the muted dash for an empty value and the label of a relation, never its id', async () => {
|
||||
const { wrapper } = await mountApp(PREVIEW, routes({ [`GET ${RECORD}`]: recordWith((record) => (record.data.joined_ip = null)) }))
|
||||
expect(wrapper.find('[data-preview-field="joined_ip"] [data-empty]').text()).toBe('—')
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
panelDomId,
|
||||
presetUpdates,
|
||||
presetValue,
|
||||
recordDisplayName,
|
||||
snapshot,
|
||||
tabDomId,
|
||||
tabOf,
|
||||
@@ -271,3 +272,33 @@ describe('ML create seed and adopt merge', () => {
|
||||
expect(mergeMLValue({ en: 'Hello', pl: 'Witaj' }, ['en', 'pl'])).toEqual({ en: 'Hello', pl: 'Witaj' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('recordDisplayName', () => {
|
||||
const field = (name: string, type: string): FormField => ({ ...formField('name'), name, type })
|
||||
const text = field('name', 'text')
|
||||
const title = field('title', 'mltext')
|
||||
const locales = ['en', 'pl']
|
||||
|
||||
it('reads a plain text value, trimmed', () => {
|
||||
expect(recordDisplayName([text], { name: ' Anna ' }, locales)).toBe('Anna')
|
||||
expect(recordDisplayName([text], { name: 42 }, locales)).toBe('42')
|
||||
})
|
||||
|
||||
it('reads the default locale of an ML value', () => {
|
||||
expect(recordDisplayName([title], { title: { en: 'Hello', pl: 'Cześć' } }, locales)).toBe('Hello')
|
||||
})
|
||||
|
||||
it('falls back to the first non-empty locale when the default is empty', () => {
|
||||
expect(recordDisplayName([title], { title: { en: ' ', pl: 'Cześć' } }, locales)).toBe('Cześć')
|
||||
})
|
||||
|
||||
it('is empty when every locale is empty', () => {
|
||||
expect(recordDisplayName([title], { title: { en: '', pl: '' } }, locales)).toBe('')
|
||||
expect(recordDisplayName([title], {}, locales)).toBe('')
|
||||
})
|
||||
|
||||
it('skips fields that are not text-like, in favour of a later text field', () => {
|
||||
const fields = [field('notify', 'checkbox'), field('tags', 'relation'), title, text]
|
||||
expect(recordDisplayName(fields, { notify: true, tags: [1], title: { en: 'Post', pl: '' }, name: 'Anna' }, locales)).toBe('Post')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"git.golem15.com/golem15/summercms/internal/build"
|
||||
"git.golem15.com/golem15/summercms/internal/dev"
|
||||
"git.golem15.com/golem15/summercms/modules/bonfire"
|
||||
"git.golem15.com/golem15/summercms/modules/lagoon"
|
||||
)
|
||||
|
||||
func main() {
|
||||
@@ -34,6 +35,7 @@ func toolCommands() []bonfire.Command {
|
||||
makeJobCommand(),
|
||||
makeAdminControllerCommand(),
|
||||
addPluginCommand(),
|
||||
newCommand(),
|
||||
devCommand(),
|
||||
parityProxyCommand(),
|
||||
parityRecordCommand(),
|
||||
@@ -297,3 +299,44 @@ func addPluginCommand() bonfire.Command {
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func newCommand() bonfire.Command {
|
||||
return bonfire.Command{
|
||||
Name: "new",
|
||||
Description: "Create a SummerCMS application in sm-<name>-app",
|
||||
Args: []bonfire.Arg{{
|
||||
Name: "name",
|
||||
Description: "Application name: lower-case letters and digits, starting with a letter; not a Go keyword, main or internal",
|
||||
Required: true,
|
||||
}},
|
||||
Flags: []bonfire.Flag{
|
||||
{Name: "framework", Description: "Path to the framework checkout (default: a sibling summercms or summercms.go checkout)"},
|
||||
{Name: "no-plugin", Description: "Do not create the starter plugin", Bare: true},
|
||||
{Name: "no-git", Description: "Do not initialise a git repository", Bare: true},
|
||||
},
|
||||
Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error {
|
||||
name, ok := in.Argument("name")
|
||||
if !ok || name == "" {
|
||||
return fmt.Errorf("new requires an application name")
|
||||
}
|
||||
dir, err := os.Getwd()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
framework, _ := in.Flag("framework")
|
||||
key, err := lagoon.GenerateAppKey()
|
||||
if err != nil {
|
||||
return fmt.Errorf("new: generate application key: %w", err)
|
||||
}
|
||||
_, err = build.NewApp(ctx, build.NewOptions{
|
||||
Name: name,
|
||||
Parent: dir,
|
||||
Framework: framework,
|
||||
NoPlugin: flagTrue(in, "no-plugin"),
|
||||
AppKey: key,
|
||||
NoGit: flagTrue(in, "no-git"),
|
||||
}, out)
|
||||
return err
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ func TestToolCommandNames(t *testing.T) {
|
||||
for _, c := range toolCommands() {
|
||||
names = append(names, c.Name)
|
||||
}
|
||||
for _, want := range []string{"build", "make:plugin", "make:model", "make:migration", "make:command", "make:job", "make:admin-controller", "plugin:add", "dev", "migrate", "migrate:rollback", "migrate:status", "serve", "queue:work", "queue:clear", "schedule:run", "parity:broadcasts", "parity:upstream", "docs:build", "docs:sync", "docs:serve"} {
|
||||
for _, want := range []string{"build", "make:plugin", "make:model", "make:migration", "make:command", "make:job", "make:admin-controller", "plugin:add", "new", "dev", "migrate", "migrate:rollback", "migrate:status", "serve", "queue:work", "queue:clear", "schedule:run", "parity:broadcasts", "parity:upstream", "docs:build", "docs:sync", "docs:serve"} {
|
||||
if !slices.Contains(names, want) {
|
||||
t.Fatalf("missing %s in %v", want, names)
|
||||
}
|
||||
@@ -27,6 +27,7 @@ func TestToolCommandNames(t *testing.T) {
|
||||
|
||||
helpWants := map[string][]string{
|
||||
"make:plugin": {"vendor.plugin"},
|
||||
"new": {"--framework", "--no-plugin", "--no-git", "name"},
|
||||
"make:model": {"[plugin] [name]", "no-migration"},
|
||||
"make:migration": {"[plugin] [name]"},
|
||||
"make:command": {"[plugin] [name]"},
|
||||
@@ -154,6 +155,76 @@ func TestMakeCommandsViaCLI(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewCommandViaCLI runs summer new through the CLI root in a parent
|
||||
// holding a summercms symlink to this checkout.
|
||||
func TestNewCommandViaCLI(t *testing.T) {
|
||||
repo, err := filepath.Abs(filepath.Join("..", ".."))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
parent := t.TempDir()
|
||||
if err := os.Symlink(repo, filepath.Join(parent, "summercms")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Chdir(parent)
|
||||
|
||||
run := func(args ...string) (string, error) {
|
||||
var buf bytes.Buffer
|
||||
root, err := bonfire.NewRoot("summer", toolCommands(), &buf)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
root.SetArgs(args)
|
||||
err = root.Execute()
|
||||
return buf.String(), err
|
||||
}
|
||||
|
||||
if out, err := run("new"); err == nil {
|
||||
t.Fatalf("new without a name succeeded:\n%s", out)
|
||||
}
|
||||
if out, err := run("new", "Demo"); err == nil {
|
||||
t.Fatalf("new Demo succeeded:\n%s", out)
|
||||
}
|
||||
if _, err := os.Lstat(filepath.Join(parent, "sm-Demo-app")); !os.IsNotExist(err) {
|
||||
t.Fatalf("new Demo left sm-Demo-app: %v", err)
|
||||
}
|
||||
|
||||
out, err := run("new", "demo", "--no-git")
|
||||
if err != nil {
|
||||
t.Fatalf("new demo --no-git: %v\n%s", err, out)
|
||||
}
|
||||
app := filepath.Join(parent, "sm-demo-app")
|
||||
for _, rel := range []string{"summer.yaml", "main.go", "plugins/demo/routes.go"} {
|
||||
if _, err := os.Stat(filepath.Join(app, rel)); err != nil {
|
||||
t.Fatalf("%s missing: %v", rel, err)
|
||||
}
|
||||
}
|
||||
if _, err := os.Lstat(filepath.Join(app, ".git")); !os.IsNotExist(err) {
|
||||
t.Fatalf("--no-git created .git: %v", err)
|
||||
}
|
||||
env, err := os.ReadFile(filepath.Join(app, ".env"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var key string
|
||||
for _, line := range strings.Split(string(env), "\n") {
|
||||
if v, ok := strings.CutPrefix(line, "SUMMER_APP__KEY="); ok {
|
||||
key = v
|
||||
}
|
||||
}
|
||||
if len(key) != 44 {
|
||||
t.Fatalf("SUMMER_APP__KEY = %q, want a 44-character key", key)
|
||||
}
|
||||
for _, want := range []string{"created " + app, "Next steps, inside sm-demo-app:", "summer migrate", "curl http://127.0.0.1:8080/"} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("output lacks %q:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
if out, err := run("new", "demo", "--no-git"); err == nil || !strings.Contains(err.Error(), "already exists") {
|
||||
t.Fatalf("second new demo: %v\n%s", err, out)
|
||||
}
|
||||
}
|
||||
|
||||
func copyHelloApp(t *testing.T) string {
|
||||
t.Helper()
|
||||
src, err := filepath.Abs(filepath.Join("..", "..", "examples", "hello"))
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
title: Go modules and workspaces
|
||||
description: Require the framework module, develop plugins as local modules in a Go workspace, list them in summer.yaml and fork a plugin with a replace directive.
|
||||
description: Require the framework module, see what summer new writes, develop plugins in a Go workspace, list them in summer.yaml and fork a plugin.
|
||||
section: architecture
|
||||
order: 20
|
||||
---
|
||||
@@ -26,6 +26,47 @@ replace git.golem15.com/golem15/summercms => ../summercms.go
|
||||
|
||||
`summer make:plugin` copies this framework `replace` into the new plugin's `go.mod`, rewritten relative to the plugin directory, so the plugin builds against the same checkout.
|
||||
|
||||
## Applications created by summer new
|
||||
|
||||
`summer new acme` writes these files for you in `sm-acme-app`, next to the framework checkout. The application module is `git.golem15.com/golem15/sm-acme-app`, and its `go.mod` replaces the framework with a relative path, so the directory pair can move together or be cloned elsewhere:
|
||||
|
||||
```text
|
||||
module git.golem15.com/golem15/sm-acme-app
|
||||
|
||||
go 1.27.0
|
||||
|
||||
toolchain go1.27.0
|
||||
|
||||
require git.golem15.com/golem15/summercms v0.0.0
|
||||
|
||||
replace git.golem15.com/golem15/summercms => ../summercms
|
||||
```
|
||||
|
||||
The starter plugin `golem15.acme` is added through `summer plugin:add`, so the same `go.mod` also gets a `require` of `git.golem15.com/golem15/sm-acme-app/plugins/acme` and a `replace` pointing at `./plugins/acme`.
|
||||
|
||||
`summer new` picks the framework checkout in this order:
|
||||
|
||||
1. the path given with `--framework`, relative to the current directory or absolute; a wrong path is an error, not a reason to keep looking;
|
||||
2. a sibling directory named `summercms`, then `summercms.go`;
|
||||
3. the source tree the `summer` binary was built from, when it was built from a checkout without `-trimpath`.
|
||||
|
||||
Every candidate counts only when its `go.mod` declares the module `git.golem15.com/golem15/summercms`; the directory name alone is not enough. Without a match, `summer new` stops and asks for `--framework`.
|
||||
|
||||
The application also gets a `go.work` that is committed with it. It lists the application itself and, unless `--no-plugin` was given, the starter plugin, both as relative `use` paths:
|
||||
|
||||
```text
|
||||
go 1.27.0
|
||||
|
||||
toolchain go1.27.0
|
||||
|
||||
use (
|
||||
.
|
||||
./plugins/acme
|
||||
)
|
||||
```
|
||||
|
||||
There is no `go.sum` in the application root: builds go through the workspace, and `go.work.sum`, which Go writes on the first build, is listed in `.gitignore`. The starter plugin keeps its own `go.mod` and `go.sum` like any other plugin module.
|
||||
|
||||
## The summer.yaml manifest
|
||||
|
||||
The manifest in the application root names the application module, the binary `summer build` writes to `bin/`, and the plugins in activation order:
|
||||
@@ -48,7 +89,7 @@ A plugin you develop inside the application lives in `plugins/<name>` as its own
|
||||
|
||||
- it adds the plugin to `summer.yaml`;
|
||||
- it adds a `require` and a `replace` pointing at the local directory to the application's `go.mod`;
|
||||
- it adds the plugin directory to the nearest `go.work`, creating one in the application root when there is none.
|
||||
- it adds the plugin directory to the nearest `go.work` as a path relative to that file, such as `./plugins/blog`, creating a `go.work` in the application root when there is none.
|
||||
|
||||
With a `go.work` in place, `go build`, `go test` and your editor see every local plugin module together. `summer build` uses the nearest `go.work` it finds; without one it builds in module mode.
|
||||
|
||||
|
||||
@@ -236,4 +236,4 @@ A relation's `messages` block overrides the relation manager's copy, each key a
|
||||
|
||||
## Relations in lists
|
||||
|
||||
A list column can show a related value with `relation` and `select` in `columns.yaml`; see [Lists and filters](lists-and-filters.md). A controller that maps a relation column to a physical column itself implements `pact.ListRelationColumnMapper`.
|
||||
A list column can show a related value with `relation` and `select` in `columns.yaml`; see [Lists and filters](lists-and-filters.md). On a to-many relation the column joins the selected value of every related record with `, `, as WinterCMS does, and stays empty when there are none. A controller that maps a relation column to a physical column itself implements `pact.ListRelationColumnMapper`.
|
||||
|
||||
@@ -10,7 +10,7 @@ WinterCMS has one console entry point, `php artisan`. SummerCMS has two programs
|
||||
|
||||
| Program | Where it comes from | What it does |
|
||||
|---------|---------------------|--------------|
|
||||
| `summer` | Installed once from the framework with `go install ./cmd/summer`. | Builds and watches applications, scaffolds plugins and their parts, records API parity fixtures and builds these docs. |
|
||||
| `summer` | Installed once, by the `curl -fsSL https://summercms.io/new \| bash -s -- <name>` one-liner or from a framework checkout with `go install ./cmd/summer`. | Creates, builds and watches applications, scaffolds plugins and their parts, records API parity fixtures and builds these docs. |
|
||||
| The application binary, for example `./bin/acme` | Written by `summer build` into the application's `bin/` directory. | Runs the application: the HTTP server, migrations, workers, the scheduler, admin accounts and every command its plugins add. |
|
||||
|
||||
The application binary is what you deploy, so everything that must run in production, such as migrations and workers, is a command of the binary rather than of `summer`.
|
||||
@@ -45,6 +45,6 @@ During development you often work from the application directory with `summer` a
|
||||
## The sections of this chapter
|
||||
|
||||
- [Setup and maintenance](setup-and-maintenance.md) lists every command of the application binary.
|
||||
- [Scaffolding](scaffolding.md) covers `summer build`, `summer dev` and the `make:` commands.
|
||||
- [Scaffolding](scaffolding.md) covers `summer new`, `summer build`, `summer dev` and the `make:` commands.
|
||||
- [Writing commands](writing-commands.md) shows how a plugin adds its own commands.
|
||||
- [Utilities](utilities.md) covers the parity and documentation commands of `summer`.
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
---
|
||||
title: Scaffolding
|
||||
description: Build and watch an application, and generate plugins, models, migrations, console commands, jobs and admin controllers with the summer make commands.
|
||||
description: Create an application with summer new, build and watch it, and generate plugins, models, migrations, commands, jobs and admin controllers.
|
||||
section: console
|
||||
order: 30
|
||||
---
|
||||
# Scaffolding
|
||||
|
||||
The `summer` tool builds applications and generates the files a plugin is made of, the way `create:plugin`, `create:model` and the other `create:` commands do in WinterCMS. Every generated file compiles as written, so you can build straight after running a command.
|
||||
The `summer` tool creates and builds applications and generates the files a plugin is made of, the way `create:plugin`, `create:model` and the other `create:` commands do in WinterCMS. Every generated file compiles as written, so you can build straight after running a command.
|
||||
|
||||
## Building and watching
|
||||
|
||||
@@ -22,6 +22,48 @@ summer dev
|
||||
|
||||
Do not edit `main.go` or `plugins.gen.go`: `summer build` rewrites them from the manifest.
|
||||
|
||||
## Creating an application
|
||||
|
||||
`summer new` creates a new application in the current directory, the way `composer create-project` starts a WinterCMS project. It takes the application name and writes `sm-<name>-app`:
|
||||
|
||||
```sh
|
||||
summer new acme
|
||||
```
|
||||
|
||||
The name must be lower-case letters and digits, starting with a letter. Go keywords such as `go` or `type`, and the names `main` and `internal`, are refused because the starter plugin package would not compile or could not be imported. `summer new` also refuses any existing `sm-<name>-app`, even an empty directory, and never merges into one. When a step fails after it created the directory, it removes the directory again, so you can rerun the same command.
|
||||
|
||||
| Flag | Effect |
|
||||
|------|--------|
|
||||
| `--framework <path>` | The framework checkout to build against. Without it, `summer new` looks for a sibling `summercms` or `summercms.go` checkout, then the source tree the `summer` binary was built from. [Go modules and workspaces](../architecture/go-modules-and-workspaces.md) describes the detection. |
|
||||
| `--no-plugin` | Skip the starter plugin; `summer.yaml` then lists no plugins, and the application still builds and serves. |
|
||||
| `--no-git` | Skip `git init` and the first commit. |
|
||||
|
||||
The application gets these files:
|
||||
|
||||
| File | Contents |
|
||||
|------|----------|
|
||||
| `go.mod` | Module `git.golem15.com/golem15/sm-<name>-app`, requiring the framework through a relative `replace`. |
|
||||
| `go.work` | The workspace with the application and its plugin modules, as relative paths. It is committed. |
|
||||
| `summer.yaml` | The manifest: module path, binary name and plugin list. |
|
||||
| `config/` | `app.yaml`, `database.yaml`, `http.yaml` and `storage.yaml`, enough for `serve` and `route:list`. |
|
||||
| `.env`, `.env.example` | The database DSN and a freshly generated application key. `.env` is readable by its owner only and never committed; `.env.example` lists the variables without the key. |
|
||||
| `compose.yaml` | PostgreSQL 16 on `127.0.0.1:54320`, with credentials matching `.env`. |
|
||||
| `.gitignore` | `bin/`, `tmp/`, `storage/`, `.env` and `go.work.sum`. |
|
||||
| `CLAUDE.md`, `AGENTS.md` | Instructions for AI coding agents: the commands, the layout, the links to the documentation for agents and the naming convention. `AGENTS.md` is a symlink to `CLAUDE.md`. |
|
||||
| `plugins/<name>` | The starter plugin `golem15.<name>`, created by the same code as `summer make:plugin` and registered by the same code as `summer plugin:add`. Its `routes.go` answers `GET /` with a JSON greeting. |
|
||||
| `main.go`, `plugins.gen.go` | Generated from `summer.yaml`, as `summer build` does. |
|
||||
|
||||
Unless you pass `--no-git`, `summer new` initialises a git repository on `main` and commits everything except `.env`. When git has no user name or email configured, that first commit uses the author `SummerCMS <summer@localhost>` and prints a warning; your git configuration is not changed. Finally it prints the next steps to run inside `sm-acme-app`:
|
||||
|
||||
```sh
|
||||
docker compose up -d --wait
|
||||
summer build
|
||||
summer migrate
|
||||
summer serve
|
||||
```
|
||||
|
||||
Golem15 names repositories by what they hold: `sm-<name>-app` for an application such as the one `summer new` writes, `sm-<name>-plugin` for a standalone plugin module and `sm-<name>-theme` for a Nuxt or Vue frontend.
|
||||
|
||||
## Creating a plugin
|
||||
|
||||
`summer make:plugin` takes a plugin ID in `vendor.plugin` form and creates the plugin module in `plugins/<plugin>` of the current application, with the directory layout described in [Plugin registration](../plugins/registration.md):
|
||||
|
||||
@@ -1,18 +1,92 @@
|
||||
---
|
||||
title: Installation
|
||||
description: Install the Go toolchain, PostgreSQL and the summer CLI, then build, configure, migrate and serve your first SummerCMS application.
|
||||
description: Create a SummerCMS application with one command, or install the summer CLI and build, migrate and serve the reference application by hand.
|
||||
section: setup
|
||||
order: 20
|
||||
---
|
||||
# Installation
|
||||
|
||||
SummerCMS is a Go module. An application requires it, lists its plugins in a `summer.yaml` manifest and builds everything into one binary with the `summer` CLI. This page installs the tool, then builds and runs `examples/hello`, the small reference application in the framework repository.
|
||||
SummerCMS is a Go module. An application requires it, lists its plugins in a `summer.yaml` manifest and builds everything into one binary with the `summer` CLI. This page starts with the one-command path that creates a new application, then walks through the manual path: installing the tool from a framework checkout and building the small reference application that ships with it.
|
||||
|
||||
## Create an application with one command
|
||||
|
||||
```sh
|
||||
curl -fsSL https://summercms.io/new | bash -s -- demo
|
||||
```
|
||||
|
||||
The script checks for Go 1.27 and Docker, fetches the framework at its latest release tag into `./summercms`, installs the `summer` CLI and runs `summer new demo`, which writes a ready-to-run application to `sm-demo-app`. Start its database, then build, migrate and serve it:
|
||||
|
||||
```sh
|
||||
cd sm-demo-app
|
||||
docker compose up -d --wait
|
||||
summer build
|
||||
summer migrate
|
||||
summer serve
|
||||
```
|
||||
|
||||
## What the script does
|
||||
|
||||
Run the one-liner from the directory that should hold both the framework checkout and the application. `bash -s -- demo` passes the application name to the piped script; the name must be lower-case letters and digits, starting with a letter.
|
||||
|
||||
1. It checks the prerequisites. Go older than 1.27 or missing, or a missing git, stops the script with an install hint before anything is cloned. A missing Docker or Docker Compose plugin is only a warning, because the application needs them to run its database, not to be created.
|
||||
2. It clones the framework into `./summercms` and checks out the newest `vX.Y.Z` release tag, detached. When `./summercms` already exists, the script fetches tags and moves the checkout forward to the newest release only when that is a fast-forward. It refuses, without changing anything, a directory that is not a framework checkout, a checkout with changes to tracked files and a checkout that is on a branch.
|
||||
3. It installs the `summer` CLI from that checkout with `go install ./cmd/summer`. When the Go install directory is not on your `PATH`, the script ends with the `export PATH=...` line to run and to add to your shell profile.
|
||||
4. It runs `summer new demo` next to the checkout, with `--framework` pointing at it, which writes `sm-demo-app` and prints the next steps. The script itself ends with the `cd` line into the new application.
|
||||
|
||||
The script stops before cloning when the name is invalid or `sm-demo-app` already exists. Three environment variables change its defaults:
|
||||
|
||||
| Variable | Default | Purpose |
|
||||
|----------|---------|---------|
|
||||
| `SUMMERCMS_REPO` | `https://git.golem15.com/golem15/summercms` | Repository to clone. |
|
||||
| `SUMMERCMS_DIR` | `summercms` in the current directory | Framework checkout; the application is created next to it. |
|
||||
| `SUMMERCMS_REF` | the newest release tag | Tag or commit to check out instead. |
|
||||
|
||||
Set them on the `bash` side of the pipe, for example `curl -fsSL https://summercms.io/new | SUMMERCMS_DIR=fw bash -s -- demo`. The [sunrise](../../modules/sunrise/README.md) module holds the script itself.
|
||||
|
||||
## Run the application
|
||||
|
||||
The generated application brings its own database and configuration:
|
||||
|
||||
- `compose.yaml` runs PostgreSQL 16, published on `127.0.0.1:54320` only, so it does not clash with a PostgreSQL already on port 5432.
|
||||
- `.env` holds the matching `SUMMER_DATABASE__DSN` and a freshly generated `SUMMER_APP__KEY`. It is excluded by `.gitignore`; `.env.example` lists the same variables.
|
||||
- `config/` holds `app.yaml`, `database.yaml`, `http.yaml` and `storage.yaml`, enough for `summer serve` and `route:list`.
|
||||
|
||||
From `sm-demo-app`, start the database, build the binary, run the migrations and serve:
|
||||
|
||||
```sh
|
||||
docker compose up -d --wait
|
||||
summer build
|
||||
summer migrate
|
||||
summer serve
|
||||
```
|
||||
|
||||
From another terminal, the starter plugin `golem15.demo` answers on the root route:
|
||||
|
||||
```sh
|
||||
curl http://127.0.0.1:8080/
|
||||
```
|
||||
|
||||
It returns `{"message":"Hello from SummerCMS","plugin":"golem15.demo"}`. Stop the database with `docker compose down`, or `docker compose down -v` to delete its data as well.
|
||||
|
||||
## Create an application without the script
|
||||
|
||||
When you already have a framework checkout, or want to read the code before running anything, do the script's steps yourself. Clone the framework into `summercms`, install the tool from it and run `summer new` in the directory next to the checkout:
|
||||
|
||||
```sh
|
||||
git clone https://git.golem15.com/golem15/summercms
|
||||
go -C summercms install ./cmd/summer
|
||||
summer new demo
|
||||
```
|
||||
|
||||
`summer new` finds a sibling `summercms` or `summercms.go` checkout by its module path. For a checkout anywhere else, pass it explicitly with `summer new demo --framework ../path/to/summercms`. [Scaffolding](../console/scaffolding.md) lists every flag and the files `summer new` writes.
|
||||
|
||||
The rest of this page is the manual path: it installs the tool and builds `examples/hello`, the small reference application in the framework repository.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Go 1.27.
|
||||
- PostgreSQL 15 or newer for any application that uses the data layer.
|
||||
- Docker, only for the integration tests that start PostgreSQL or Mailpit containers.
|
||||
- PostgreSQL 15 or newer for any application that uses the data layer. A generated application runs PostgreSQL 16 in Docker.
|
||||
- Docker, for the database of a generated application and for the integration tests that start PostgreSQL or Mailpit containers.
|
||||
|
||||
You do not need Node.js to build an application or these docs. It is needed only when you work on the admin SPA itself.
|
||||
|
||||
@@ -31,7 +105,7 @@ summer --help
|
||||
export PATH="$(go env GOPATH)/bin:$PATH"
|
||||
```
|
||||
|
||||
The tool builds and watches applications, scaffolds plugins, models, migrations and admin controllers, and builds this documentation. Check that the framework compiles and its unit tests pass:
|
||||
The tool creates, builds and watches applications, scaffolds plugins, models, migrations and admin controllers, and builds this documentation. Check that the framework compiles and its unit tests pass:
|
||||
|
||||
```sh
|
||||
go vet ./...
|
||||
@@ -90,15 +164,7 @@ CREATE DATABASE hello;
|
||||
|
||||
Configuration comes from YAML files in `config/`. Any key can be overridden with a `SUMMER_` environment variable, in which a double underscore separates path segments: `SUMMER_DATABASE__DSN` sets `database.dsn`. [Configuration](configuration.md) explains the layers.
|
||||
|
||||
`serve` and `route:list` need request body limits, and surf reads them as numbers, which the string-valued environment overlay cannot supply. Create `config/http.yaml` in `examples/hello`:
|
||||
|
||||
```yaml
|
||||
body_limits:
|
||||
default_bytes: 1048576
|
||||
upload_bytes: 10485760
|
||||
```
|
||||
|
||||
Then point the binary at the database, give it the application key and an uploads bucket. Replace the `<secret>` markers with your own values, and never commit them:
|
||||
Point the binary at the database, give it the application key and an uploads bucket. Replace the `<secret>` markers with your own values, and never commit them:
|
||||
|
||||
```sh
|
||||
export SUMMER_DATABASE__DSN='postgres://acme:<secret>@127.0.0.1:5432/hello?sslmode=disable'
|
||||
@@ -119,14 +185,9 @@ curl http://127.0.0.1:8080/items/1
|
||||
|
||||
From the application directory, `summer migrate`, `summer migrate:status` and `summer serve` run the same commands through the built binary, building it first when it is missing.
|
||||
|
||||
> [!WARNING]
|
||||
> Known issues in the current framework:
|
||||
>
|
||||
> - `examples/hello` ships no `http.body_limits` configuration, so `serve` and `route:list` fail with `surf: config http.body_limits.default_bytes is required` until you add `config/http.yaml` as shown above. The same gap makes the example's `TestTypedItemRoute` fail.
|
||||
> - The committed `examples/hello/main.go` is older than what `summer build` generates now, so building the example leaves that file modified. Restore it with `git checkout -- examples/hello/main.go` if you do not intend to commit it.
|
||||
|
||||
## Next steps
|
||||
|
||||
- Read [Coming from WinterCMS](coming-from-wintercms.md) if you are porting a WinterCMS plugin.
|
||||
- Read the [Architecture introduction](../architecture/introduction.md) to see how the pieces fit.
|
||||
- Read [Go modules and workspaces](../architecture/go-modules-and-workspaces.md) for what `summer new` writes into `go.mod` and `go.work`.
|
||||
- Scaffold your own plugin with `summer make:plugin` as described in [Plugin registration](../plugins/registration.md).
|
||||
|
||||
@@ -10,6 +10,7 @@ llms_notes:
|
||||
- "The data layer supports PostgreSQL only."
|
||||
- "SummerCMS is headless: it serves a JSON API and an admin SPA, with no frontend themes."
|
||||
- "It targets Go 1.27."
|
||||
- "A new application starts with `curl -fsSL https://summercms.io/new | bash -s -- <name>`, or with `summer new <name>` next to a framework checkout."
|
||||
sections:
|
||||
- name: setup
|
||||
title: Setup
|
||||
|
||||
5
examples/hello/config/http.yaml
Normal file
5
examples/hello/config/http.yaml
Normal file
@@ -0,0 +1,5 @@
|
||||
body_limits:
|
||||
# Request body limits in bytes, required by serve and route:list. Keep them
|
||||
# YAML numbers: values from the environment are strings and are rejected.
|
||||
default_bytes: 1048576
|
||||
upload_bytes: 10485760
|
||||
@@ -272,8 +272,15 @@ func TestMakeAndAddPluginOnHelloCopy(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !bytes.Contains(work, []byte("plugins/demo")) {
|
||||
t.Fatalf("go.work missing plugins/demo:\n%s", work)
|
||||
if !bytes.Contains(work, []byte("./plugins/demo")) {
|
||||
t.Fatalf("go.work missing ./plugins/demo:\n%s", work)
|
||||
}
|
||||
// plugin:add writes go.work use paths relative to the go.work directory,
|
||||
// so a committed go.work carries no machine-specific path.
|
||||
for _, line := range strings.Split(string(work), "\n") {
|
||||
if strings.HasPrefix(strings.TrimSpace(line), "/") {
|
||||
t.Fatalf("go.work has an absolute use path %q:\n%s", line, work)
|
||||
}
|
||||
}
|
||||
appMod, err := os.ReadFile(filepath.Join(dir, "go.mod"))
|
||||
if err != nil {
|
||||
@@ -324,6 +331,28 @@ func TestMakeAndAddPluginOnHelloCopy(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestMakePluginRoutesUnchanged: plain make:plugin still renders the
|
||||
// no-route stub; only summer new opts into the starter route.
|
||||
func TestMakePluginRoutesUnchanged(t *testing.T) {
|
||||
dir := copyHelloApp(t)
|
||||
pluginDir, err := MakePlugin(t.Context(), dir, "golem15.demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
routes, err := os.ReadFile(filepath.Join(pluginDir, "routes.go"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !bytes.Contains(routes, []byte("func (p *Plugin) Routes(pact.Router) error { return nil }")) {
|
||||
t.Fatalf("routes.go is not the no-route stub:\n%s", routes)
|
||||
}
|
||||
for _, unwanted := range []string{`"net/http"`, `"encoding/json"`, "r.Get("} {
|
||||
if bytes.Contains(routes, []byte(unwanted)) {
|
||||
t.Fatalf("plain routes.go contains %s:\n%s", unwanted, routes)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddPluginRejectsConflictingID(t *testing.T) {
|
||||
dir := copyHelloApp(t)
|
||||
ctx := t.Context()
|
||||
|
||||
419
internal/build/new.go
Normal file
419
internal/build/new.go
Normal file
@@ -0,0 +1,419 @@
|
||||
package build
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"go/token"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"runtime"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// appNameRe is the public `summer new <name>` rule: lower-case letters and
|
||||
// digits, starting with a letter. The name is also the second part of the
|
||||
// starter plugin ID golem15.<name>.
|
||||
var appNameRe = regexp.MustCompile(`^[a-z][a-z0-9]*$`)
|
||||
|
||||
// NewOptions configures NewApp.
|
||||
type NewOptions struct {
|
||||
// Name is the application name; the app is written to sm-<Name>-app.
|
||||
Name string
|
||||
// Parent is the directory that receives sm-<Name>-app. Empty means the
|
||||
// current directory.
|
||||
Parent string
|
||||
// Framework is the path to the framework checkout, resolved against
|
||||
// Parent when relative. Empty means auto-detection: a sibling
|
||||
// summercms or summercms.go checkout, then the source tree this binary
|
||||
// was built from.
|
||||
Framework string
|
||||
// NoPlugin skips the starter plugin; summer.yaml then lists no plugins.
|
||||
NoPlugin bool
|
||||
// AppKey is written to .env as SUMMER_APP__KEY. It must be the base64
|
||||
// (standard encoding) of 32 bytes; the CLI passes lagoon.GenerateAppKey.
|
||||
AppKey string
|
||||
// NoGit skips git init and the first commit.
|
||||
NoGit bool
|
||||
}
|
||||
|
||||
// Local development database written to compose.yaml and .env. The port is
|
||||
// not 5432 so it does not clash with a PostgreSQL already on the host, and
|
||||
// it is published on the loopback address only.
|
||||
const (
|
||||
devDBUser = "summer"
|
||||
devDBPassword = "summer"
|
||||
devDBHostPort = "127.0.0.1:54320"
|
||||
)
|
||||
|
||||
// Fallback identity for the first commit when git has none configured.
|
||||
const (
|
||||
fallbackGitName = "SummerCMS"
|
||||
fallbackGitEmail = "summer@localhost"
|
||||
)
|
||||
|
||||
// frameworkSiblings are the directory names auto-detection tries next to
|
||||
// the new application. A candidate counts only when its go.mod declares the
|
||||
// framework module.
|
||||
var frameworkSiblings = []string{"summercms", "summercms.go"}
|
||||
|
||||
// frameworkFromBinary returns the framework checkout this binary was built
|
||||
// from, or "" when unknown. Tests replace it.
|
||||
var frameworkFromBinary = binarySourceFramework
|
||||
|
||||
// genFile is one file NewApp writes itself, relative to the app root. A
|
||||
// non-empty Link makes it a symlink to Link (relative to the file) instead.
|
||||
type genFile struct {
|
||||
Rel string
|
||||
Body []byte
|
||||
Mode os.FileMode
|
||||
Link string
|
||||
}
|
||||
|
||||
// newData feeds the new/* templates.
|
||||
type newData struct {
|
||||
Name string
|
||||
Module string
|
||||
Framework string
|
||||
GoVersion string
|
||||
Toolchain string
|
||||
Replace string
|
||||
DSN string
|
||||
AppKey string
|
||||
NoPlugin bool
|
||||
}
|
||||
|
||||
// ValidateAppName reports whether name is usable as `summer new <name>`.
|
||||
func ValidateAppName(name string) error {
|
||||
if !appNameRe.MatchString(name) {
|
||||
return fmt.Errorf("build: app name %q must be lower-case letters and digits, starting with a letter", name)
|
||||
}
|
||||
if token.IsKeyword(name) {
|
||||
return fmt.Errorf("build: app name %q is a Go keyword; the starter plugin package would not compile", name)
|
||||
}
|
||||
switch name {
|
||||
case "main":
|
||||
return fmt.Errorf("build: app name %q is reserved: plugins/main would be a program, not an importable package", name)
|
||||
case "internal":
|
||||
return fmt.Errorf("build: app name %q is reserved: Go forbids importing plugins/internal from the app", name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// appModulePath is the module path of a generated application.
|
||||
func appModulePath(name string) string {
|
||||
return "git.golem15.com/golem15/sm-" + name + "-app"
|
||||
}
|
||||
|
||||
// appDirName is the directory a generated application lives in.
|
||||
func appDirName(name string) string {
|
||||
return "sm-" + name + "-app"
|
||||
}
|
||||
|
||||
// NewApp writes a ready-to-run application to <Parent>/sm-<Name>-app and
|
||||
// returns its path. It refuses a target that exists in any form.
|
||||
func NewApp(ctx context.Context, opts NewOptions, out io.Writer) (string, error) {
|
||||
if out == nil {
|
||||
out = io.Discard
|
||||
}
|
||||
if err := ValidateAppName(opts.Name); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := validateAppKey(opts.AppKey); err != nil {
|
||||
return "", err
|
||||
}
|
||||
parent := opts.Parent
|
||||
if parent == "" {
|
||||
parent = "."
|
||||
}
|
||||
parent, err := filepath.Abs(parent)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("build: resolve parent directory: %w", err)
|
||||
}
|
||||
if st, err := os.Stat(parent); err != nil {
|
||||
return "", fmt.Errorf("build: parent directory: %w", err)
|
||||
} else if !st.IsDir() {
|
||||
return "", fmt.Errorf("build: parent %s is not a directory", parent)
|
||||
}
|
||||
target := filepath.Join(parent, appDirName(opts.Name))
|
||||
if _, err := os.Lstat(target); err == nil {
|
||||
return "", fmt.Errorf("build: %s already exists; choose another name or remove it", target)
|
||||
} else if !errors.Is(err, os.ErrNotExist) {
|
||||
return "", fmt.Errorf("build: stat %s: %w", target, err)
|
||||
}
|
||||
framework, err := resolveFramework(parent, opts.Framework)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if underRoot(framework, target) {
|
||||
return "", fmt.Errorf("build: %s is inside the framework checkout %s; run summer new in the directory next to the checkout", target, framework)
|
||||
}
|
||||
|
||||
files, err := planNewApp(opts, target, framework)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := os.Mkdir(target, 0o755); err != nil {
|
||||
return "", fmt.Errorf("build: create %s: %w", target, err)
|
||||
}
|
||||
// target did not exist before this run, so a failure removes it and a
|
||||
// re-run with the same name is not blocked.
|
||||
if err := populateApp(ctx, opts, target, files); err != nil {
|
||||
if rmErr := os.RemoveAll(target); rmErr != nil {
|
||||
return "", fmt.Errorf("build: create %s: %w (removing it failed: %v)", target, err, rmErr)
|
||||
}
|
||||
return "", fmt.Errorf("build: create %s (removed it): %w", target, err)
|
||||
}
|
||||
if !opts.NoGit {
|
||||
initGit(ctx, target, opts.Name, out)
|
||||
}
|
||||
fmt.Fprintf(out, "created %s\n", target)
|
||||
next, err := renderStub("new/next-steps", newData{Name: opts.Name, NoPlugin: opts.NoPlugin})
|
||||
if err != nil {
|
||||
return target, err
|
||||
}
|
||||
_, _ = out.Write(next)
|
||||
return target, nil
|
||||
}
|
||||
|
||||
// validateAppKey requires the base64 (standard encoding) of 32 bytes, the
|
||||
// form lagoon accepts for app.key.
|
||||
func validateAppKey(key string) error {
|
||||
raw, err := base64.StdEncoding.DecodeString(key)
|
||||
if key == "" || err != nil || len(raw) != 32 {
|
||||
return fmt.Errorf("build: application key must be the base64 of 32 bytes")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// devDSN is the .env database DSN matching compose.yaml.
|
||||
func devDSN(name string) string {
|
||||
return "postgres://" + devDBUser + ":" + devDBPassword + "@" + devDBHostPort + "/" + name + "?sslmode=disable"
|
||||
}
|
||||
|
||||
// initGit creates the repository with one commit on main. Every failure is a
|
||||
// warning: the application is usable without git.
|
||||
func initGit(ctx context.Context, dir, name string, out io.Writer) {
|
||||
if _, err := exec.LookPath("git"); err != nil {
|
||||
fmt.Fprintf(out, "warning: git not found on PATH; skipped git init\n")
|
||||
return
|
||||
}
|
||||
if res, err := runGit(ctx, dir, "init", "--quiet", "--initial-branch=main"); err != nil {
|
||||
fmt.Fprintf(out, "warning: git init failed, the app has no repository: %v\n%s", err, res)
|
||||
return
|
||||
}
|
||||
if res, err := runGit(ctx, dir, "add", "-A"); err != nil {
|
||||
fmt.Fprintf(out, "warning: git add failed, nothing committed: %v\n%s", err, res)
|
||||
return
|
||||
}
|
||||
var args []string
|
||||
var missing []string
|
||||
if gitIdentityMissing(ctx, dir, "user.name", "GIT_AUTHOR_NAME", "GIT_COMMITTER_NAME") {
|
||||
args = append(args, "-c", "user.name="+fallbackGitName)
|
||||
missing = append(missing, "user.name")
|
||||
}
|
||||
if gitIdentityMissing(ctx, dir, "user.email", "GIT_AUTHOR_EMAIL", "GIT_COMMITTER_EMAIL") {
|
||||
args = append(args, "-c", "user.email="+fallbackGitEmail)
|
||||
missing = append(missing, "user.email")
|
||||
}
|
||||
if len(missing) > 0 {
|
||||
fmt.Fprintf(out, "warning: git %s not configured; the first commit uses %s <%s>\n",
|
||||
strings.Join(missing, " and "), fallbackGitName, fallbackGitEmail)
|
||||
}
|
||||
args = append(args, "commit", "--quiet", "-m", "Create "+appDirName(name)+" with summer new")
|
||||
if res, err := runGit(ctx, dir, args...); err != nil {
|
||||
fmt.Fprintf(out, "warning: git commit failed; the files are staged but not committed: %v\n%s", err, res)
|
||||
}
|
||||
}
|
||||
|
||||
// gitIdentityMissing reports whether key is unset in git config and none of
|
||||
// the environment variables that supply it are set.
|
||||
func gitIdentityMissing(ctx context.Context, dir, key string, envs ...string) bool {
|
||||
for _, e := range envs {
|
||||
if os.Getenv(e) != "" {
|
||||
return false
|
||||
}
|
||||
}
|
||||
res, err := runGit(ctx, dir, "config", key)
|
||||
return err != nil || len(bytes.TrimSpace(res)) == 0
|
||||
}
|
||||
|
||||
// runGit runs git in dir with stdin closed and terminal prompts disabled.
|
||||
func runGit(ctx context.Context, dir string, args ...string) ([]byte, error) {
|
||||
cmd := exec.CommandContext(ctx, "git", args...)
|
||||
cmd.Dir = dir
|
||||
cmd.Stdin = nil
|
||||
cmd.Env = append(os.Environ(), "GIT_TERMINAL_PROMPT=0")
|
||||
return cmd.CombinedOutput()
|
||||
}
|
||||
|
||||
// resolveFramework picks the framework checkout: the explicit flag (never
|
||||
// falling through when it is wrong), then a sibling of parent whose go.mod
|
||||
// declares the framework module, then the source tree of this binary. Paths
|
||||
// stay lexical so a symlinked sibling yields a ../<sibling> replace.
|
||||
func resolveFramework(parent, flag string) (string, error) {
|
||||
if flag != "" {
|
||||
dir := flag
|
||||
if !filepath.IsAbs(dir) {
|
||||
dir = filepath.Join(parent, dir)
|
||||
}
|
||||
dir = filepath.Clean(dir)
|
||||
if err := checkFramework(dir); err != nil {
|
||||
return "", fmt.Errorf("build: --framework %s: %w", flag, err)
|
||||
}
|
||||
return dir, nil
|
||||
}
|
||||
for _, name := range frameworkSiblings {
|
||||
dir := filepath.Join(parent, name)
|
||||
if checkFramework(dir) == nil {
|
||||
return dir, nil
|
||||
}
|
||||
}
|
||||
if dir := frameworkFromBinary(); dir != "" && checkFramework(dir) == nil {
|
||||
return dir, nil
|
||||
}
|
||||
return "", fmt.Errorf("build: no framework checkout found: looked for %s next to %s; pass --framework <path>",
|
||||
strings.Join(frameworkSiblings, " and "), parent)
|
||||
}
|
||||
|
||||
// binarySourceFramework returns the repository root this file was compiled
|
||||
// from. A -trimpath build records a module-relative path, which is ignored.
|
||||
func binarySourceFramework() string {
|
||||
_, file, _, ok := runtime.Caller(0)
|
||||
if !ok || !filepath.IsAbs(file) {
|
||||
return ""
|
||||
}
|
||||
// file is <root>/internal/build/new.go.
|
||||
root := filepath.Dir(filepath.Dir(filepath.Dir(file)))
|
||||
if checkFramework(root) != nil {
|
||||
return ""
|
||||
}
|
||||
return root
|
||||
}
|
||||
|
||||
// checkFramework requires dir/go.mod to declare the framework module.
|
||||
func checkFramework(dir string) error {
|
||||
module, _, err := parseGoMod(filepath.Join(dir, "go.mod"))
|
||||
if err != nil {
|
||||
return fmt.Errorf("framework checkout %s: %w", dir, err)
|
||||
}
|
||||
if module != frameworkModule {
|
||||
return fmt.Errorf("%s declares module %q, not the framework %s", dir, module, frameworkModule)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// planNewApp renders every file NewApp writes from its own templates. It
|
||||
// reads nothing but the framework go.mod, so the output can be compared
|
||||
// byte for byte.
|
||||
func planNewApp(opts NewOptions, target, framework string) ([]genFile, error) {
|
||||
if err := ValidateAppName(opts.Name); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := validateAppKey(opts.AppKey); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := checkFramework(framework); err != nil {
|
||||
return nil, fmt.Errorf("build: %w", err)
|
||||
}
|
||||
replace, err := relModulePath(target, framework)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("build: framework replace: %w", err)
|
||||
}
|
||||
data := newData{
|
||||
Name: opts.Name,
|
||||
Module: appModulePath(opts.Name),
|
||||
Framework: frameworkModule,
|
||||
GoVersion: goVersionLine,
|
||||
Toolchain: toolchainLine,
|
||||
Replace: replace,
|
||||
DSN: devDSN(opts.Name),
|
||||
AppKey: opts.AppKey,
|
||||
NoPlugin: opts.NoPlugin,
|
||||
}
|
||||
plan := []struct {
|
||||
rel string
|
||||
tmpl string
|
||||
mode os.FileMode
|
||||
}{
|
||||
{"go.mod", "new/go.mod", 0o644},
|
||||
{"config/app.yaml", "new/config/app.yaml", 0o644},
|
||||
{"config/database.yaml", "new/config/database.yaml", 0o644},
|
||||
{"config/http.yaml", "new/config/http.yaml", 0o644},
|
||||
{"config/storage.yaml", "new/config/storage.yaml", 0o644},
|
||||
{".env", "new/env", 0o600},
|
||||
{".env.example", "new/env.example", 0o644},
|
||||
{"compose.yaml", "new/compose.yaml", 0o644},
|
||||
{".gitignore", "new/gitignore", 0o644},
|
||||
{"CLAUDE.md", "new/CLAUDE.md", 0o644},
|
||||
}
|
||||
files := make([]genFile, 0, len(plan))
|
||||
for _, p := range plan {
|
||||
body, err := renderStub(p.tmpl, data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
files = append(files, genFile{Rel: p.rel, Body: body, Mode: p.mode})
|
||||
}
|
||||
// One source for agent instructions; a relative symlink survives git
|
||||
// clone on Linux and macOS.
|
||||
files = append(files, genFile{Rel: "AGENTS.md", Link: "CLAUDE.md"})
|
||||
return files, nil
|
||||
}
|
||||
|
||||
// populateApp writes the planned files, the workspace, the manifest and the
|
||||
// starter plugin into target, then generates main.go and plugins.gen.go.
|
||||
func populateApp(ctx context.Context, opts NewOptions, target string, files []genFile) error {
|
||||
for _, f := range files {
|
||||
path := filepath.Join(target, filepath.FromSlash(f.Rel))
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return fmt.Errorf("build: mkdir %s: %w", filepath.Dir(path), err)
|
||||
}
|
||||
if f.Link != "" {
|
||||
if err := os.Symlink(f.Link, path); err != nil {
|
||||
return fmt.Errorf("build: symlink %s: %w", path, err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err := os.WriteFile(path, f.Body, f.Mode); err != nil {
|
||||
return fmt.Errorf("build: write %s: %w", path, err)
|
||||
}
|
||||
// WriteFile honours the umask; set the mode exactly (.env is 0600).
|
||||
if err := os.Chmod(path, f.Mode); err != nil {
|
||||
return fmt.Errorf("build: chmod %s: %w", path, err)
|
||||
}
|
||||
}
|
||||
// The app's own go.work comes before the plugin step so a go.work in an
|
||||
// ancestor directory is never picked up.
|
||||
if err := writeGoWork(filepath.Join(target, "go.work")); err != nil {
|
||||
return err
|
||||
}
|
||||
manifestPath := filepath.Join(target, manifestName)
|
||||
if err := SaveManifest(manifestPath, Manifest{Module: appModulePath(opts.Name), Binary: opts.Name}); err != nil {
|
||||
return err
|
||||
}
|
||||
if !opts.NoPlugin {
|
||||
if err := addStarterPlugin(ctx, opts.Name, target); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
m, err := LoadManifest(manifestPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return generate(target, m)
|
||||
}
|
||||
|
||||
// addStarterPlugin creates golem15.<name> through the make:plugin and
|
||||
// plugin:add code paths, with the starter route.
|
||||
func addStarterPlugin(ctx context.Context, name, target string) error {
|
||||
if _, err := makePlugin(ctx, target, "golem15."+name, pluginOptions{StarterRoute: true}); err != nil {
|
||||
return err
|
||||
}
|
||||
return AddPlugin(ctx, target, filepath.Join("plugins", name))
|
||||
}
|
||||
152
internal/build/new_golden_test.go
Normal file
152
internal/build/new_golden_test.go
Normal file
@@ -0,0 +1,152 @@
|
||||
package build
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"flag"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
var update = flag.Bool("update", false, "rewrite golden files")
|
||||
|
||||
// goldenAppKey is the base64 of the bytes 0x00..0x1f: a fixed, non-secret
|
||||
// key that keeps .env byte-stable in the goldens.
|
||||
var goldenAppKey = func() string {
|
||||
raw := make([]byte, 32)
|
||||
for i := range raw {
|
||||
raw[i] = byte(i)
|
||||
}
|
||||
return base64.StdEncoding.EncodeToString(raw)
|
||||
}()
|
||||
|
||||
// newGoldenApp runs NewApp for "demo" in a temp parent whose sibling
|
||||
// summercms is a symlink to this repository, so auto-detection yields the
|
||||
// relative replace ../summercms. It returns the app dir and the output with
|
||||
// the parent path replaced by <parent>.
|
||||
func newGoldenApp(t *testing.T, noPlugin bool) (string, []byte) {
|
||||
t.Helper()
|
||||
repo, err := filepath.Abs(filepath.Join("..", ".."))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
parent := t.TempDir()
|
||||
if err := os.Symlink(repo, filepath.Join(parent, "summercms")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var out bytes.Buffer
|
||||
dir, err := NewApp(t.Context(), NewOptions{
|
||||
Name: "demo",
|
||||
Parent: parent,
|
||||
AppKey: goldenAppKey,
|
||||
NoGit: true,
|
||||
NoPlugin: noPlugin,
|
||||
}, &out)
|
||||
if err != nil {
|
||||
t.Fatalf("NewApp: %v\n%s", err, out.String())
|
||||
}
|
||||
return dir, bytes.ReplaceAll(out.Bytes(), []byte(parent), []byte("<parent>"))
|
||||
}
|
||||
|
||||
// goldenName maps an app-relative path to its golden file name: "/" becomes
|
||||
// "__" and a leading "." becomes "dot-", which keeps .env goldens clear of
|
||||
// the framework's .env ignore rules.
|
||||
func goldenName(rel string) string {
|
||||
name := strings.ReplaceAll(rel, "/", "__")
|
||||
if strings.HasPrefix(name, ".") {
|
||||
name = "dot-" + name[1:]
|
||||
}
|
||||
return name + ".golden"
|
||||
}
|
||||
|
||||
// assertGolden compares got with testdata/<dir>/<name>, rewriting the file
|
||||
// first when -update is set.
|
||||
func assertGolden(t *testing.T, dir, rel string, got []byte) {
|
||||
t.Helper()
|
||||
path := filepath.Join("testdata", dir, goldenName(rel))
|
||||
if *update {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, got, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
want, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v (run go test -run %s -update to create it)", rel, err, t.Name())
|
||||
}
|
||||
if !bytes.Equal(got, want) {
|
||||
t.Errorf("%s differs from %s\n%s", rel, path, firstDiff(want, got))
|
||||
}
|
||||
}
|
||||
|
||||
// firstDiff prints the first differing lines of want and got with a little
|
||||
// context, in a unified-diff-like form.
|
||||
func firstDiff(want, got []byte) string {
|
||||
wl := strings.Split(string(want), "\n")
|
||||
gl := strings.Split(string(got), "\n")
|
||||
i := 0
|
||||
for i < len(wl) && i < len(gl) && wl[i] == gl[i] {
|
||||
i++
|
||||
}
|
||||
var b strings.Builder
|
||||
start := max(i-2, 0)
|
||||
for j := start; j < i; j++ {
|
||||
b.WriteString(" " + wl[j] + "\n")
|
||||
}
|
||||
for j := i; j < len(wl) && j < i+3; j++ {
|
||||
b.WriteString("- " + wl[j] + "\n")
|
||||
}
|
||||
for j := i; j < len(gl) && j < i+3; j++ {
|
||||
b.WriteString("+ " + gl[j] + "\n")
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func readAppFile(t *testing.T, dir, rel string) []byte {
|
||||
t.Helper()
|
||||
data, err := os.ReadFile(filepath.Join(dir, filepath.FromSlash(rel)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
// TestNewGolden pins every file summer new writes for the default app.
|
||||
// AGENTS.md (a symlink) and the starter plugin's go.mod/go.sum (dependency
|
||||
// versions drift) are asserted instead of pinned.
|
||||
func TestNewGolden(t *testing.T) {
|
||||
dir, out := newGoldenApp(t, false)
|
||||
for _, rel := range []string{
|
||||
"go.mod", "go.work", "summer.yaml", "main.go", "plugins.gen.go",
|
||||
"config/app.yaml", "config/database.yaml", "config/http.yaml", "config/storage.yaml",
|
||||
".env", ".env.example", ".gitignore", "compose.yaml", "CLAUDE.md",
|
||||
"plugins/demo/plugin.go", "plugins/demo/routes.go",
|
||||
} {
|
||||
assertGolden(t, "new", rel, readAppFile(t, dir, rel))
|
||||
}
|
||||
assertGolden(t, "new", "next-steps.txt", out)
|
||||
|
||||
pluginMod := readAppFile(t, dir, "plugins/demo/go.mod")
|
||||
if !bytes.Contains(pluginMod, []byte("replace git.golem15.com/golem15/summercms => ../../../summercms")) {
|
||||
t.Errorf("plugins/demo/go.mod lacks the relative framework replace:\n%s", pluginMod)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, "go.sum")); !os.IsNotExist(err) {
|
||||
t.Errorf("app root go.sum exists or stat failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewGoldenNoPlugin pins the files that differ for summer new --no-plugin.
|
||||
func TestNewGoldenNoPlugin(t *testing.T) {
|
||||
dir, out := newGoldenApp(t, true)
|
||||
for _, rel := range []string{"go.mod", "go.work", "summer.yaml", "plugins.gen.go", "CLAUDE.md"} {
|
||||
assertGolden(t, "new-no-plugin", rel, readAppFile(t, dir, rel))
|
||||
}
|
||||
assertGolden(t, "new-no-plugin", "next-steps.txt", out)
|
||||
if _, err := os.Stat(filepath.Join(dir, "plugins")); !os.IsNotExist(err) {
|
||||
t.Errorf("--no-plugin created plugins/ or stat failed: %v", err)
|
||||
}
|
||||
}
|
||||
644
internal/build/new_test.go
Normal file
644
internal/build/new_test.go
Normal file
@@ -0,0 +1,644 @@
|
||||
package build
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"go/token"
|
||||
"io/fs"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// testAppKey is a fixed, valid app key (base64 of 32 bytes) for hermetic tests.
|
||||
const testAppKey = "c3VtbWVyY21zLXRlc3Qta2V5LTMyLWJ5dGVzLWxvbmc="
|
||||
|
||||
// repoRoot is the absolute path of this framework checkout.
|
||||
func repoRoot(t *testing.T) string {
|
||||
t.Helper()
|
||||
repo, err := filepath.Abs(filepath.Join("..", ".."))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return repo
|
||||
}
|
||||
|
||||
// parentWithFramework returns a temp parent holding a summercms symlink to
|
||||
// this checkout, so NewApp finds the framework as a sibling.
|
||||
func parentWithFramework(t *testing.T) string {
|
||||
t.Helper()
|
||||
parent := t.TempDir()
|
||||
if err := os.Symlink(repoRoot(t), filepath.Join(parent, "summercms")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return parent
|
||||
}
|
||||
|
||||
// fakeFramework writes dir/go.mod declaring module and returns dir.
|
||||
func fakeFramework(t *testing.T, dir, module string) string {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
write(t, filepath.Join(dir, "go.mod"), "module "+module+"\n\ngo 1.27.0\n")
|
||||
return dir
|
||||
}
|
||||
|
||||
// stubBinaryFramework replaces frameworkFromBinary for the test.
|
||||
func stubBinaryFramework(t *testing.T, dir string) {
|
||||
t.Helper()
|
||||
orig := frameworkFromBinary
|
||||
frameworkFromBinary = func() string { return dir }
|
||||
t.Cleanup(func() { frameworkFromBinary = orig })
|
||||
}
|
||||
|
||||
// gitOut runs git in dir and returns its trimmed output.
|
||||
func gitOut(t *testing.T, dir string, args ...string) string {
|
||||
t.Helper()
|
||||
cmd := exec.CommandContext(t.Context(), "git", args...)
|
||||
cmd.Dir = dir
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("git %v: %v\n%s", args, err, out)
|
||||
}
|
||||
return strings.TrimSpace(string(out))
|
||||
}
|
||||
|
||||
// requireGit skips when git is not installed.
|
||||
func requireGit(t *testing.T) {
|
||||
t.Helper()
|
||||
if _, err := exec.LookPath("git"); err != nil {
|
||||
t.Skip("git not installed")
|
||||
}
|
||||
}
|
||||
|
||||
// isolateGit points git at an empty configuration (no system or global
|
||||
// file, temp HOME) and clears every identity variable, while keeping the Go
|
||||
// caches where they are so the starter plugin's go mod tidy stays offline.
|
||||
func isolateGit(t *testing.T) {
|
||||
t.Helper()
|
||||
requireGit(t)
|
||||
envOut, err := exec.CommandContext(t.Context(), "go", "env", "GOMODCACHE", "GOCACHE", "GOPATH", "GOENV").Output()
|
||||
if err != nil {
|
||||
t.Fatalf("go env: %v", err)
|
||||
}
|
||||
vals := strings.Split(strings.TrimSpace(string(envOut)), "\n")
|
||||
if len(vals) != 4 {
|
||||
t.Fatalf("go env output %q", envOut)
|
||||
}
|
||||
for i, k := range []string{"GOMODCACHE", "GOCACHE", "GOPATH", "GOENV"} {
|
||||
t.Setenv(k, vals[i])
|
||||
}
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
|
||||
t.Setenv("GIT_CONFIG_NOSYSTEM", "1")
|
||||
t.Setenv("GIT_CONFIG_GLOBAL", os.DevNull)
|
||||
// Recent git runs auto maintenance detached after a commit; it would
|
||||
// write into .git while the temp dir is being removed.
|
||||
t.Setenv("GIT_CONFIG_COUNT", "2")
|
||||
t.Setenv("GIT_CONFIG_KEY_0", "maintenance.auto")
|
||||
t.Setenv("GIT_CONFIG_VALUE_0", "false")
|
||||
t.Setenv("GIT_CONFIG_KEY_1", "gc.auto")
|
||||
t.Setenv("GIT_CONFIG_VALUE_1", "0")
|
||||
for _, k := range []string{"GIT_AUTHOR_NAME", "GIT_AUTHOR_EMAIL", "GIT_COMMITTER_NAME", "GIT_COMMITTER_EMAIL", "EMAIL", "GIT_DIR", "GIT_WORK_TREE"} {
|
||||
t.Setenv(k, "")
|
||||
os.Unsetenv(k)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewAppSmoke writes an app next to a symlinked framework checkout,
|
||||
// builds it and lists the starter route.
|
||||
func TestNewAppSmoke(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("runs go mod tidy and go build")
|
||||
}
|
||||
parent := t.TempDir()
|
||||
fw := filepath.Join(parent, "summercms")
|
||||
if err := os.Symlink(repoRoot(t), fw); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx := t.Context()
|
||||
var log bytes.Buffer
|
||||
dir, err := NewApp(ctx, NewOptions{Name: "demo", Parent: parent, Framework: fw, AppKey: testAppKey, NoGit: true}, &log)
|
||||
if err != nil {
|
||||
t.Fatalf("NewApp: %v\n%s", err, log.String())
|
||||
}
|
||||
if want := filepath.Join(parent, "sm-demo-app"); dir != want {
|
||||
t.Fatalf("dir = %s, want %s", dir, want)
|
||||
}
|
||||
|
||||
mod, err := os.ReadFile(filepath.Join(dir, "go.mod"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !bytes.Contains(mod, []byte("replace git.golem15.com/golem15/summercms => ../summercms")) {
|
||||
t.Fatalf("go.mod lacks the relative framework replace:\n%s", mod)
|
||||
}
|
||||
work, err := os.ReadFile(filepath.Join(dir, "go.work"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !bytes.Contains(work, []byte("./plugins/demo")) {
|
||||
t.Fatalf("go.work lacks ./plugins/demo:\n%s", work)
|
||||
}
|
||||
for _, line := range strings.Split(string(work), "\n") {
|
||||
if strings.HasPrefix(strings.TrimSpace(line), "/") {
|
||||
t.Fatalf("go.work has an absolute path %q:\n%s", line, work)
|
||||
}
|
||||
}
|
||||
|
||||
var buildOut bytes.Buffer
|
||||
if err := App(ctx, dir, &buildOut); err != nil {
|
||||
t.Fatalf("build: %v\n%s", err, buildOut.String())
|
||||
}
|
||||
cmd := exec.CommandContext(ctx, filepath.Join(dir, "bin", "demo"), "route:list")
|
||||
cmd.Dir = dir
|
||||
listed, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("route:list: %v\n%s", err, listed)
|
||||
}
|
||||
for _, want := range []string{"/{$}", "golem15.demo"} {
|
||||
if !bytes.Contains(listed, []byte(want)) {
|
||||
t.Fatalf("route:list lacks %q:\n%s", want, listed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewNoPluginBuilds builds an app created with --no-plugin: an empty
|
||||
// plugin list must compile.
|
||||
func TestNewNoPluginBuilds(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("runs go build")
|
||||
}
|
||||
parent := parentWithFramework(t)
|
||||
ctx := t.Context()
|
||||
dir, err := NewApp(ctx, NewOptions{Name: "demo", Parent: parent, AppKey: testAppKey, NoGit: true, NoPlugin: true}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
gen := readAppFile(t, dir, "plugins.gen.go")
|
||||
if !bytes.Contains(gen, []byte("var PluginIDs = []string{}")) {
|
||||
t.Fatalf("plugins.gen.go has plugins:\n%s", gen)
|
||||
}
|
||||
var out bytes.Buffer
|
||||
if err := App(ctx, dir, &out); err != nil {
|
||||
t.Fatalf("build: %v\n%s", err, out.String())
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, "bin", "demo")); err != nil {
|
||||
t.Fatalf("binary missing: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewValidateAppName(t *testing.T) {
|
||||
var keywords []string
|
||||
for tok := token.Token(0); tok < 256; tok++ {
|
||||
if tok.IsKeyword() {
|
||||
keywords = append(keywords, tok.String())
|
||||
}
|
||||
}
|
||||
if len(keywords) != 25 {
|
||||
t.Fatalf("found %d Go keywords, want 25: %v", len(keywords), keywords)
|
||||
}
|
||||
for _, name := range []string{"", "Demo", "1x", "a-b", "a.b", "a/b", "a_b", "demo app", "main", "internal"} {
|
||||
if err := ValidateAppName(name); err == nil {
|
||||
t.Errorf("ValidateAppName(%q) = nil, want error", name)
|
||||
}
|
||||
}
|
||||
for _, kw := range keywords {
|
||||
err := ValidateAppName(kw)
|
||||
if err == nil || !strings.Contains(err.Error(), "keyword") {
|
||||
t.Errorf("ValidateAppName(%q) = %v, want a keyword error", kw, err)
|
||||
}
|
||||
}
|
||||
for _, name := range []string{"main", "internal"} {
|
||||
if err := ValidateAppName(name); err == nil || !strings.Contains(err.Error(), "reserved") {
|
||||
t.Errorf("ValidateAppName(%q) = %v, want a reserved error", name, err)
|
||||
}
|
||||
}
|
||||
for _, name := range []string{"init", "string", "vendor", "testdata", "a1", "demo"} {
|
||||
if err := ValidateAppName(name); err != nil {
|
||||
t.Errorf("ValidateAppName(%q) = %v, want nil", name, err)
|
||||
}
|
||||
}
|
||||
// NewApp applies the same rule before touching the filesystem.
|
||||
parent := t.TempDir()
|
||||
if _, err := NewApp(t.Context(), NewOptions{Name: "func", Parent: parent, AppKey: testAppKey, NoGit: true}, nil); err == nil {
|
||||
t.Fatal("NewApp accepted a keyword name")
|
||||
}
|
||||
assertDirEntries(t, parent, nil)
|
||||
}
|
||||
|
||||
// assertDirEntries requires dir to hold exactly want (names, sorted).
|
||||
func assertDirEntries(t *testing.T, dir string, want []string) {
|
||||
t.Helper()
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var got []string
|
||||
for _, e := range entries {
|
||||
got = append(got, e.Name())
|
||||
}
|
||||
if strings.Join(got, ",") != strings.Join(want, ",") {
|
||||
t.Fatalf("%s holds %v, want %v", dir, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewRefusesExistingTarget(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
setup func(t *testing.T, target string)
|
||||
check func(t *testing.T, target string)
|
||||
}{
|
||||
{
|
||||
name: "directory",
|
||||
setup: func(t *testing.T, target string) {
|
||||
if err := os.Mkdir(target, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
write(t, filepath.Join(target, "keep.txt"), "mine\n")
|
||||
},
|
||||
check: func(t *testing.T, target string) {
|
||||
assertDirEntries(t, target, []string{"keep.txt"})
|
||||
if got := string(readAppFile(t, target, "keep.txt")); got != "mine\n" {
|
||||
t.Fatalf("keep.txt = %q", got)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "file",
|
||||
setup: func(t *testing.T, target string) { write(t, target, "mine\n") },
|
||||
check: func(t *testing.T, target string) {
|
||||
data, err := os.ReadFile(target)
|
||||
if err != nil || string(data) != "mine\n" {
|
||||
t.Fatalf("file = %q, %v", data, err)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "symlink to a directory",
|
||||
setup: func(t *testing.T, target string) {
|
||||
dest := filepath.Join(filepath.Dir(target), "elsewhere")
|
||||
if err := os.Mkdir(dest, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink("elsewhere", target); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
},
|
||||
check: func(t *testing.T, target string) {
|
||||
if link, err := os.Readlink(target); err != nil || link != "elsewhere" {
|
||||
t.Fatalf("symlink = %q, %v", link, err)
|
||||
}
|
||||
assertDirEntries(t, filepath.Join(filepath.Dir(target), "elsewhere"), nil)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "dangling symlink",
|
||||
setup: func(t *testing.T, target string) { _ = os.Symlink("missing", target) },
|
||||
check: func(t *testing.T, target string) {
|
||||
if link, err := os.Readlink(target); err != nil || link != "missing" {
|
||||
t.Fatalf("symlink = %q, %v", link, err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(filepath.Dir(target), "missing")); !os.IsNotExist(err) {
|
||||
t.Fatalf("dangling symlink target was created: %v", err)
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
parent := parentWithFramework(t)
|
||||
target := filepath.Join(parent, "sm-demo-app")
|
||||
tc.setup(t, target)
|
||||
_, err := NewApp(t.Context(), NewOptions{Name: "demo", Parent: parent, AppKey: testAppKey, NoGit: true}, nil)
|
||||
if err == nil || !strings.Contains(err.Error(), "already exists") {
|
||||
t.Fatalf("NewApp error = %v, want already exists", err)
|
||||
}
|
||||
tc.check(t, target)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewFrameworkResolution(t *testing.T) {
|
||||
const fwModule = "git.golem15.com/golem15/summercms"
|
||||
|
||||
t.Run("flag wins over siblings", func(t *testing.T) {
|
||||
stubBinaryFramework(t, "")
|
||||
parent := t.TempDir()
|
||||
fakeFramework(t, filepath.Join(parent, "summercms"), fwModule)
|
||||
flagDir := fakeFramework(t, filepath.Join(t.TempDir(), "fw"), fwModule)
|
||||
got, err := resolveFramework(parent, flagDir)
|
||||
if err != nil || got != flagDir {
|
||||
t.Fatalf("resolveFramework = %q, %v; want %q", got, err, flagDir)
|
||||
}
|
||||
})
|
||||
t.Run("relative flag resolves against the parent", func(t *testing.T) {
|
||||
stubBinaryFramework(t, "")
|
||||
parent := t.TempDir()
|
||||
want := fakeFramework(t, filepath.Join(parent, "vendor", "fw"), fwModule)
|
||||
got, err := resolveFramework(parent, "vendor/fw")
|
||||
if err != nil || got != want {
|
||||
t.Fatalf("resolveFramework = %q, %v; want %q", got, err, want)
|
||||
}
|
||||
})
|
||||
t.Run("wrong module in the flag is an error, not a fall-through", func(t *testing.T) {
|
||||
stubBinaryFramework(t, repoRoot(t))
|
||||
parent := t.TempDir()
|
||||
fakeFramework(t, filepath.Join(parent, "summercms"), fwModule)
|
||||
flagDir := fakeFramework(t, filepath.Join(t.TempDir(), "other"), "example.com/other")
|
||||
got, err := resolveFramework(parent, flagDir)
|
||||
if err == nil || !strings.Contains(err.Error(), "--framework") || !strings.Contains(err.Error(), "example.com/other") {
|
||||
t.Fatalf("resolveFramework = %q, %v; want a --framework module error", got, err)
|
||||
}
|
||||
missing := filepath.Join(t.TempDir(), "nothing")
|
||||
if got, err := resolveFramework(parent, missing); err == nil {
|
||||
t.Fatalf("resolveFramework(missing flag) = %q, want error", got)
|
||||
}
|
||||
})
|
||||
t.Run("summercms before summercms.go", func(t *testing.T) {
|
||||
stubBinaryFramework(t, "")
|
||||
parent := t.TempDir()
|
||||
want := fakeFramework(t, filepath.Join(parent, "summercms"), fwModule)
|
||||
fakeFramework(t, filepath.Join(parent, "summercms.go"), fwModule)
|
||||
got, err := resolveFramework(parent, "")
|
||||
if err != nil || got != want {
|
||||
t.Fatalf("resolveFramework = %q, %v; want %q", got, err, want)
|
||||
}
|
||||
})
|
||||
t.Run("sibling with another module is skipped", func(t *testing.T) {
|
||||
stubBinaryFramework(t, "")
|
||||
parent := t.TempDir()
|
||||
fakeFramework(t, filepath.Join(parent, "summercms"), "example.com/notframework")
|
||||
want := fakeFramework(t, filepath.Join(parent, "summercms.go"), fwModule)
|
||||
got, err := resolveFramework(parent, "")
|
||||
if err != nil || got != want {
|
||||
t.Fatalf("resolveFramework = %q, %v; want %q", got, err, want)
|
||||
}
|
||||
})
|
||||
t.Run("binary source is used last", func(t *testing.T) {
|
||||
bin := fakeFramework(t, filepath.Join(t.TempDir(), "src"), fwModule)
|
||||
stubBinaryFramework(t, bin)
|
||||
parent := t.TempDir()
|
||||
got, err := resolveFramework(parent, "")
|
||||
if err != nil || got != bin {
|
||||
t.Fatalf("resolveFramework = %q, %v; want %q", got, err, bin)
|
||||
}
|
||||
// A sibling still wins over the binary source.
|
||||
sib := fakeFramework(t, filepath.Join(parent, "summercms.go"), fwModule)
|
||||
if got, err := resolveFramework(parent, ""); err != nil || got != sib {
|
||||
t.Fatalf("resolveFramework = %q, %v; want sibling %q", got, err, sib)
|
||||
}
|
||||
})
|
||||
t.Run("binary source with another module is ignored", func(t *testing.T) {
|
||||
stubBinaryFramework(t, fakeFramework(t, filepath.Join(t.TempDir(), "src"), "example.com/other"))
|
||||
if got, err := resolveFramework(t.TempDir(), ""); err == nil {
|
||||
t.Fatalf("resolveFramework = %q, want error", got)
|
||||
}
|
||||
})
|
||||
t.Run("nothing found names --framework and writes nothing", func(t *testing.T) {
|
||||
stubBinaryFramework(t, "")
|
||||
parent := t.TempDir()
|
||||
_, err := NewApp(t.Context(), NewOptions{Name: "demo", Parent: parent, AppKey: testAppKey, NoGit: true}, nil)
|
||||
if err == nil || !strings.Contains(err.Error(), "--framework") {
|
||||
t.Fatalf("NewApp error = %v, want one naming --framework", err)
|
||||
}
|
||||
assertDirEntries(t, parent, nil)
|
||||
})
|
||||
}
|
||||
|
||||
func TestNewRefusesTargetInsideFramework(t *testing.T) {
|
||||
stubBinaryFramework(t, "")
|
||||
fw := fakeFramework(t, filepath.Join(t.TempDir(), "summercms"), frameworkModule)
|
||||
for _, tc := range []struct {
|
||||
name, parent, flag string
|
||||
}{
|
||||
{"framework root", fw, "."},
|
||||
{"subdirectory", filepath.Join(fw, "apps"), ".."},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if err := os.MkdirAll(tc.parent, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
before, err := os.ReadDir(tc.parent)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = NewApp(t.Context(), NewOptions{Name: "demo", Parent: tc.parent, Framework: tc.flag, AppKey: testAppKey, NoGit: true}, nil)
|
||||
if err == nil || !strings.Contains(err.Error(), "inside the framework checkout") {
|
||||
t.Fatalf("NewApp error = %v, want inside the framework checkout", err)
|
||||
}
|
||||
after, err := os.ReadDir(tc.parent)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(after) != len(before) {
|
||||
t.Fatalf("%s changed: %d entries before, %d after", tc.parent, len(before), len(after))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewCleansUpOnFailure(t *testing.T) {
|
||||
stubBinaryFramework(t, "")
|
||||
// The framework go.mod is valid but the checkout has no packages, so the
|
||||
// starter plugin's go mod tidy fails after the target was created.
|
||||
t.Setenv("GOPROXY", "off")
|
||||
parent := t.TempDir()
|
||||
fw := fakeFramework(t, filepath.Join(parent, "summercms"), frameworkModule)
|
||||
write(t, filepath.Join(parent, "notes.txt"), "keep me\n")
|
||||
|
||||
var out bytes.Buffer
|
||||
_, err := NewApp(t.Context(), NewOptions{Name: "demo", Parent: parent, Framework: fw, AppKey: testAppKey, NoGit: true}, &out)
|
||||
if err == nil {
|
||||
t.Fatalf("NewApp succeeded against an empty framework\n%s", out.String())
|
||||
}
|
||||
if !strings.Contains(err.Error(), "removed it") {
|
||||
t.Fatalf("error %v does not say the target was removed", err)
|
||||
}
|
||||
if _, err := os.Lstat(filepath.Join(parent, "sm-demo-app")); !os.IsNotExist(err) {
|
||||
t.Fatalf("sm-demo-app remains after a failed run: %v", err)
|
||||
}
|
||||
assertDirEntries(t, parent, []string{"notes.txt", "summercms"})
|
||||
if got := string(readAppFile(t, parent, "notes.txt")); got != "keep me\n" {
|
||||
t.Fatalf("notes.txt = %q", got)
|
||||
}
|
||||
assertDirEntries(t, fw, []string{"go.mod"})
|
||||
}
|
||||
|
||||
func TestNewRequiresValidAppKey(t *testing.T) {
|
||||
stubBinaryFramework(t, "")
|
||||
// No framework is reachable: a key error must come first.
|
||||
parent := t.TempDir()
|
||||
for _, key := range []string{"", "AAAA", "c2hvcnQ=", "not base64 at all!", testAppKey[:43]} {
|
||||
_, err := NewApp(t.Context(), NewOptions{Name: "demo", Parent: parent, AppKey: key, NoGit: true}, nil)
|
||||
if err == nil || !strings.Contains(err.Error(), "application key") {
|
||||
t.Errorf("key %q: error = %v, want an application key error", key, err)
|
||||
}
|
||||
}
|
||||
assertDirEntries(t, parent, nil)
|
||||
}
|
||||
|
||||
func TestNewEnvFileMode(t *testing.T) {
|
||||
parent := parentWithFramework(t)
|
||||
dir, err := NewApp(t.Context(), NewOptions{Name: "demo", Parent: parent, AppKey: testAppKey, NoGit: true, NoPlugin: true}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for rel, want := range map[string]fs.FileMode{".env": 0o600, ".env.example": 0o644, "compose.yaml": 0o644} {
|
||||
st, err := os.Stat(filepath.Join(dir, rel))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := st.Mode().Perm(); got != want {
|
||||
t.Errorf("%s mode = %o, want %o", rel, got, want)
|
||||
}
|
||||
}
|
||||
env := string(readAppFile(t, dir, ".env"))
|
||||
if !strings.Contains(env, "SUMMER_APP__KEY="+testAppKey+"\n") {
|
||||
t.Fatalf(".env lacks the key:\n%s", env)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewAgentsSymlink(t *testing.T) {
|
||||
parent := parentWithFramework(t)
|
||||
dir, err := NewApp(t.Context(), NewOptions{Name: "demo", Parent: parent, AppKey: testAppKey, NoGit: true, NoPlugin: true}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
link, err := os.Readlink(filepath.Join(dir, "AGENTS.md"))
|
||||
if err != nil || link != "CLAUDE.md" {
|
||||
t.Fatalf("AGENTS.md -> %q, %v; want CLAUDE.md", link, err)
|
||||
}
|
||||
if !bytes.Equal(readAppFile(t, dir, "AGENTS.md"), readAppFile(t, dir, "CLAUDE.md")) {
|
||||
t.Fatal("AGENTS.md does not resolve to CLAUDE.md")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewGitFirstCommit(t *testing.T) {
|
||||
isolateGit(t)
|
||||
parent := parentWithFramework(t)
|
||||
var out bytes.Buffer
|
||||
dir, err := NewApp(t.Context(), NewOptions{Name: "demo", Parent: parent, AppKey: testAppKey}, &out)
|
||||
if err != nil {
|
||||
t.Fatalf("NewApp: %v\n%s", err, out.String())
|
||||
}
|
||||
if got := gitOut(t, dir, "rev-list", "--count", "HEAD"); got != "1" {
|
||||
t.Fatalf("commit count = %s, want 1", got)
|
||||
}
|
||||
if got := gitOut(t, dir, "symbolic-ref", "--short", "HEAD"); got != "main" {
|
||||
t.Fatalf("branch = %s, want main", got)
|
||||
}
|
||||
if got := gitOut(t, dir, "log", "-1", "--format=%an <%ae>|%cn <%ce>"); got != "SummerCMS <summer@localhost>|SummerCMS <summer@localhost>" {
|
||||
t.Fatalf("identity = %s", got)
|
||||
}
|
||||
if !strings.Contains(out.String(), "warning: git user.name and user.email not configured") {
|
||||
t.Fatalf("no fallback identity warning:\n%s", out.String())
|
||||
}
|
||||
// The fallback is passed per command, never written to the repository.
|
||||
for _, key := range []string{"user.name", "user.email"} {
|
||||
cmd := exec.CommandContext(t.Context(), "git", "config", key)
|
||||
cmd.Dir = dir
|
||||
if res, err := cmd.CombinedOutput(); err == nil {
|
||||
t.Fatalf("git config %s is set after summer new: %s", key, res)
|
||||
}
|
||||
}
|
||||
tracked := strings.Split(gitOut(t, dir, "ls-files"), "\n")
|
||||
has := func(name string) bool {
|
||||
for _, f := range tracked {
|
||||
if f == name {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
for _, want := range []string{".env.example", ".gitignore", "go.work", "main.go", "plugins.gen.go", "AGENTS.md", "CLAUDE.md", "summer.yaml", "plugins/demo/plugin.go"} {
|
||||
if !has(want) {
|
||||
t.Errorf("%s is not tracked: %v", want, tracked)
|
||||
}
|
||||
}
|
||||
if has(".env") {
|
||||
t.Errorf(".env is tracked: %v", tracked)
|
||||
}
|
||||
if got := gitOut(t, dir, "status", "--porcelain"); got != "" {
|
||||
t.Errorf("working tree not clean after the first commit:\n%s", got)
|
||||
}
|
||||
if got := gitOut(t, dir, "log", "-1", "--format=%B"); strings.Contains(got, "Co-Authored-By") || got != "Create sm-demo-app with summer new" {
|
||||
t.Errorf("commit message = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewGitUsesConfiguredIdentity(t *testing.T) {
|
||||
isolateGit(t)
|
||||
t.Setenv("GIT_AUTHOR_NAME", "Ada Lovelace")
|
||||
t.Setenv("GIT_AUTHOR_EMAIL", "ada@example.com")
|
||||
t.Setenv("GIT_COMMITTER_NAME", "Ada Lovelace")
|
||||
t.Setenv("GIT_COMMITTER_EMAIL", "ada@example.com")
|
||||
parent := parentWithFramework(t)
|
||||
var out bytes.Buffer
|
||||
dir, err := NewApp(t.Context(), NewOptions{Name: "demo", Parent: parent, AppKey: testAppKey, NoPlugin: true}, &out)
|
||||
if err != nil {
|
||||
t.Fatalf("NewApp: %v\n%s", err, out.String())
|
||||
}
|
||||
if got := gitOut(t, dir, "log", "-1", "--format=%an <%ae>|%cn <%ce>"); got != "Ada Lovelace <ada@example.com>|Ada Lovelace <ada@example.com>" {
|
||||
t.Fatalf("identity = %s", got)
|
||||
}
|
||||
if strings.Contains(out.String(), "warning") {
|
||||
t.Fatalf("unexpected warning with a configured identity:\n%s", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewGitMissingIsWarning(t *testing.T) {
|
||||
goBin, err := exec.LookPath("go")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
goBin, err = filepath.Abs(goBin)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tools := t.TempDir()
|
||||
if err := os.Symlink(goBin, filepath.Join(tools, "go")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("PATH", tools)
|
||||
if _, err := exec.LookPath("git"); err == nil {
|
||||
t.Fatal("git still on PATH")
|
||||
}
|
||||
parent := parentWithFramework(t)
|
||||
var out bytes.Buffer
|
||||
dir, err := NewApp(t.Context(), NewOptions{Name: "demo", Parent: parent, AppKey: testAppKey}, &out)
|
||||
if err != nil {
|
||||
t.Fatalf("NewApp without git: %v\n%s", err, out.String())
|
||||
}
|
||||
if !strings.Contains(out.String(), "warning: git not found on PATH") {
|
||||
t.Fatalf("no git warning:\n%s", out.String())
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, "summer.yaml")); err != nil {
|
||||
t.Fatalf("app incomplete: %v", err)
|
||||
}
|
||||
if _, err := os.Lstat(filepath.Join(dir, ".git")); !os.IsNotExist(err) {
|
||||
t.Fatalf(".git exists without git: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewNoGit(t *testing.T) {
|
||||
requireGit(t)
|
||||
parent := parentWithFramework(t)
|
||||
var out bytes.Buffer
|
||||
dir, err := NewApp(t.Context(), NewOptions{Name: "demo", Parent: parent, AppKey: testAppKey, NoGit: true, NoPlugin: true}, &out)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Lstat(filepath.Join(dir, ".git")); !os.IsNotExist(err) {
|
||||
t.Fatalf("--no-git created .git: %v", err)
|
||||
}
|
||||
if strings.Contains(out.String(), "warning") {
|
||||
t.Fatalf("--no-git printed a warning:\n%s", out.String())
|
||||
}
|
||||
if !strings.Contains(out.String(), "created "+dir) {
|
||||
t.Fatalf("output lacks the created line:\n%s", out.String())
|
||||
}
|
||||
}
|
||||
@@ -40,6 +40,14 @@ type stubData struct {
|
||||
GoVersion string
|
||||
Toolchain string
|
||||
Replace string
|
||||
// StarterRoute renders routes.go with a GET / route (summer new).
|
||||
StarterRoute bool
|
||||
}
|
||||
|
||||
// pluginOptions tunes the scaffold makePlugin writes. The zero value is the
|
||||
// plain make:plugin output.
|
||||
type pluginOptions struct {
|
||||
StarterRoute bool
|
||||
}
|
||||
|
||||
const (
|
||||
@@ -50,6 +58,10 @@ const (
|
||||
|
||||
// MakePlugin scaffolds a compiling plugin module under plugins/<name>.
|
||||
func MakePlugin(ctx context.Context, startDir, id string) (string, error) {
|
||||
return makePlugin(ctx, startDir, id, pluginOptions{})
|
||||
}
|
||||
|
||||
func makePlugin(ctx context.Context, startDir, id string, opts pluginOptions) (string, error) {
|
||||
if err := ValidatePluginID(id); err != nil {
|
||||
return "", fmt.Errorf("build: %w", err)
|
||||
}
|
||||
@@ -89,6 +101,8 @@ func MakePlugin(ctx context.Context, startDir, id string) (string, error) {
|
||||
GoVersion: goVersionLine,
|
||||
Toolchain: toolchainLine,
|
||||
Replace: replace,
|
||||
|
||||
StarterRoute: opts.StarterRoute,
|
||||
}
|
||||
if err := writePluginScaffold(pluginDir, data); err != nil {
|
||||
return "", err
|
||||
@@ -319,14 +333,10 @@ func editAppModule(ctx context.Context, appDir, pluginDir, modPath string) error
|
||||
if err := runGo(ctx, appDir, "mod", "edit", "-require="+modPath+"@v0.0.0"); err != nil {
|
||||
return err
|
||||
}
|
||||
rel, err := filepath.Rel(appDir, pluginDir)
|
||||
rel, err := relModulePath(appDir, pluginDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("build: plugin replace: %w", err)
|
||||
}
|
||||
rel = filepath.ToSlash(rel)
|
||||
if rel != ".." && !strings.HasPrefix(rel, "../") && !filepath.IsAbs(rel) && !strings.HasPrefix(rel, "./") {
|
||||
rel = "./" + rel
|
||||
}
|
||||
if err := runGo(ctx, appDir, "mod", "edit", "-replace="+modPath+"="+rel); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -342,13 +352,13 @@ func ensureGoWork(ctx context.Context, appDir string, m Manifest, pluginDir stri
|
||||
gw = filepath.Join(appDir, "go.work")
|
||||
for _, p := range m.Plugins {
|
||||
if dir := localModuleDir(appDir, p.Module); dir != "" && dir != pluginDir {
|
||||
if err := runGoWorkUse(ctx, appDir, gw, dir); err != nil {
|
||||
if err := runGoWorkUse(ctx, gw, dir); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return runGoWorkUse(ctx, appDir, gw, pluginDir)
|
||||
return runGoWorkUse(ctx, gw, pluginDir)
|
||||
}
|
||||
|
||||
func writeGoWork(path string) error {
|
||||
@@ -359,9 +369,32 @@ func writeGoWork(path string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func runGoWorkUse(ctx context.Context, appDir, goWork, dir string) error {
|
||||
cmd := exec.CommandContext(ctx, "go", "work", "use", dir)
|
||||
cmd.Dir = appDir
|
||||
// relModulePath returns target relative to base as a slash-separated local
|
||||
// module path: "./x", "../x", "." or "..". Go reads a replace or use path
|
||||
// without a ./ or ../ prefix as a module path, so the prefix is required.
|
||||
func relModulePath(base, target string) (string, error) {
|
||||
rel, err := filepath.Rel(base, target)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
rel = filepath.ToSlash(rel)
|
||||
if rel != "." && rel != ".." && !strings.HasPrefix(rel, "../") && !filepath.IsAbs(rel) && !strings.HasPrefix(rel, "./") {
|
||||
rel = "./" + rel
|
||||
}
|
||||
return rel, nil
|
||||
}
|
||||
|
||||
// runGoWorkUse adds dir to goWork with a path relative to the go.work
|
||||
// directory, so a committed go.work never carries a machine-specific
|
||||
// absolute path.
|
||||
func runGoWorkUse(ctx context.Context, goWork, dir string) error {
|
||||
workDir := filepath.Dir(goWork)
|
||||
arg := dir
|
||||
if rel, err := relModulePath(workDir, dir); err == nil {
|
||||
arg = rel
|
||||
}
|
||||
cmd := exec.CommandContext(ctx, "go", "work", "use", arg)
|
||||
cmd.Dir = workDir
|
||||
applyGoWorkFile(cmd, goWork)
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
|
||||
146
internal/build/stubs/new.tmpl
Normal file
146
internal/build/stubs/new.tmpl
Normal file
@@ -0,0 +1,146 @@
|
||||
{{define "new/go.mod"}}module {{.Module}}
|
||||
|
||||
{{.GoVersion}}
|
||||
|
||||
{{.Toolchain}}
|
||||
|
||||
require {{.Framework}} v0.0.0
|
||||
|
||||
replace {{.Framework}} => {{.Replace}}
|
||||
{{end}}
|
||||
|
||||
{{define "new/config/app.yaml"}}name: {{.Name}}
|
||||
debug: false
|
||||
# The application key encrypts data at rest. Set SUMMER_APP__KEY in .env (a
|
||||
# 32-byte base64 value); keep it out of this file and out of git.
|
||||
key: ""
|
||||
{{end}}
|
||||
|
||||
{{define "new/config/database.yaml"}}# Set SUMMER_DATABASE__DSN in .env; compose.yaml runs the matching PostgreSQL.
|
||||
dsn: ""
|
||||
{{end}}
|
||||
|
||||
{{define "new/config/http.yaml"}}body_limits:
|
||||
# Request body limits in bytes. Keep them YAML numbers: values from the
|
||||
# environment are strings and are rejected at boot.
|
||||
default_bytes: 1048576
|
||||
upload_bytes: 10485760
|
||||
{{end}}
|
||||
|
||||
{{define "new/config/storage.yaml"}}# Uploads bucket, required by summer serve. A file:// URL is a local
|
||||
# directory under the application working directory.
|
||||
uploads:
|
||||
bucket_url: "file://./storage/app/uploads"
|
||||
public_path_prefix: "/storage/uploads"
|
||||
{{end}}
|
||||
|
||||
{{define "new/env"}}# Local secrets for this application: the database DSN and the application
|
||||
# key. .gitignore excludes this file; never commit it. Real environment
|
||||
# variables override the values here.
|
||||
SUMMER_DATABASE__DSN={{.DSN}}
|
||||
SUMMER_APP__KEY={{.AppKey}}
|
||||
{{end}}
|
||||
|
||||
{{define "new/env.example"}}# The variables .env sets. summer new wrote a .env with a fresh key; copy this
|
||||
# file to .env only when you need to start over.
|
||||
SUMMER_DATABASE__DSN={{.DSN}}
|
||||
# A 32-byte base64 application key. Generate one with ./bin/{{.Name}} key:generate
|
||||
# and paste only the key, without the leading check mark.
|
||||
SUMMER_APP__KEY=
|
||||
# Uploads bucket; config/storage.yaml already sets this default.
|
||||
# SUMMER_STORAGE__UPLOADS__BUCKET_URL=file://./storage/app/uploads
|
||||
# Configuration environment; selects the config overlay (default production).
|
||||
# SUMMER_ENV=development
|
||||
{{end}}
|
||||
|
||||
{{define "new/compose.yaml"}}# Local development database: docker compose up -d --wait
|
||||
# PostgreSQL is published on the loopback address only, and the credentials
|
||||
# match SUMMER_DATABASE__DSN in .env.
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16
|
||||
environment:
|
||||
POSTGRES_USER: summer
|
||||
POSTGRES_PASSWORD: summer
|
||||
POSTGRES_DB: {{.Name}}
|
||||
ports:
|
||||
- "127.0.0.1:54320:5432"
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U summer -d {{.Name}}"]
|
||||
interval: 2s
|
||||
timeout: 3s
|
||||
retries: 30
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
{{end}}
|
||||
|
||||
{{define "new/gitignore"}}/bin/
|
||||
/tmp/
|
||||
/storage/
|
||||
.env
|
||||
go.work.sum
|
||||
*.exe
|
||||
{{end}}
|
||||
|
||||
{{define "new/CLAUDE.md"}}# CLAUDE.md
|
||||
|
||||
Guidance for AI coding agents working in this repository. `AGENTS.md` is a symlink to this file.
|
||||
|
||||
## What this is
|
||||
|
||||
`sm-{{.Name}}-app` is a SummerCMS application: a Go program built on the SummerCMS framework, module `{{.Framework}}`. `go.mod` replaces the framework with the local checkout at `{{.Replace}}`. That checkout is shared with every other application next to it: do not edit the framework from here. Change this application, or report the framework problem.
|
||||
|
||||
Framework documentation for agents:
|
||||
|
||||
- https://summercms.io/docs/llms.txt (index)
|
||||
- https://summercms.io/docs/llms-full.txt (full text in one file)
|
||||
|
||||
## Commands
|
||||
|
||||
Run them from this directory.
|
||||
|
||||
```sh
|
||||
docker compose up -d --wait # start PostgreSQL 16 on 127.0.0.1:54320
|
||||
summer build # generate main.go and plugins.gen.go, build bin/{{.Name}}
|
||||
summer dev # watch sources and rebuild
|
||||
summer migrate # run plugin migrations
|
||||
summer serve # start the HTTP server
|
||||
summer make:plugin golem15.<name> # scaffold a plugin module under plugins/<name>
|
||||
summer plugin:add plugins/<name> # register it in summer.yaml, go.mod and go.work
|
||||
```
|
||||
|
||||
## Layout
|
||||
|
||||
- `summer.yaml`: the application manifest (module path, binary name, ordered plugin list).
|
||||
- `plugins/`: local plugin modules.{{if not .NoPlugin}} `plugins/{{.Name}}` is the starter plugin `golem15.{{.Name}}`; its `routes.go` answers `GET /` with JSON.{{end}}
|
||||
- `config/`: YAML configuration (`app.yaml`, `database.yaml`, `http.yaml`, `storage.yaml`). Any key can be overridden with a `SUMMER_` environment variable, a double underscore separating path segments (`SUMMER_DATABASE__DSN` sets `database.dsn`).
|
||||
- `.env`: local secrets (database DSN, application key). Never commit it; `.gitignore` excludes it. `.env.example` lists the variables.
|
||||
- `compose.yaml`: the local PostgreSQL 16.
|
||||
- `main.go`, `plugins.gen.go`: generated by `summer build` from `summer.yaml`. Do not edit them by hand; they are committed.
|
||||
- `go.work`: the workspace that joins the application and its plugin modules.
|
||||
|
||||
Plugins are Go modules compiled into the binary at build time; there is no runtime plugin loading. Add a plugin with `summer make:plugin` and `summer plugin:add`, then run `summer build`.
|
||||
|
||||
## Naming convention
|
||||
|
||||
- `sm-<name>-app`: an application, like this repository.
|
||||
- `sm-<name>-plugin`: a standalone plugin module.
|
||||
- `sm-<name>-theme`: a Nuxt/Vue frontend.
|
||||
{{end}}
|
||||
|
||||
{{define "new/next-steps"}}
|
||||
Next steps, inside sm-{{.Name}}-app:
|
||||
docker compose up -d --wait
|
||||
summer build
|
||||
summer migrate
|
||||
summer serve
|
||||
{{if .NoPlugin}}
|
||||
The app has no plugins yet. Add one with summer make:plugin golem15.<name>
|
||||
and summer plugin:add plugins/<name>, then run summer build again.
|
||||
{{else}}
|
||||
Then, from another terminal:
|
||||
curl http://127.0.0.1:8080/
|
||||
{{end}}{{end}}
|
||||
@@ -61,13 +61,35 @@ func init() {
|
||||
{{end}}
|
||||
|
||||
{{define "routes.go"}}package {{.Package}}
|
||||
{{if .StarterRoute}}
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
)
|
||||
|
||||
var _ pact.HasRoutes = (*Plugin)(nil)
|
||||
|
||||
// Routes registers the starter route: GET / answers with a JSON greeting
|
||||
// naming this plugin.
|
||||
func (p *Plugin) Routes(r pact.Router) error {
|
||||
r.Get("/{$}", func(w http.ResponseWriter, req *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{
|
||||
"message": "Hello from SummerCMS",
|
||||
"plugin": p.ID(),
|
||||
})
|
||||
})
|
||||
return nil
|
||||
}
|
||||
{{else}}
|
||||
import "git.golem15.com/golem15/summercms/modules/pact"
|
||||
|
||||
var _ pact.HasRoutes = (*Plugin)(nil)
|
||||
|
||||
func (p *Plugin) Routes(pact.Router) error { return nil }
|
||||
{{end}}
|
||||
{{end}}{{end}}
|
||||
|
||||
{{define "go.mod"}}module {{.Module}}
|
||||
|
||||
|
||||
44
internal/build/testdata/new-no-plugin/CLAUDE.md.golden
vendored
Normal file
44
internal/build/testdata/new-no-plugin/CLAUDE.md.golden
vendored
Normal file
@@ -0,0 +1,44 @@
|
||||
# CLAUDE.md
|
||||
|
||||
Guidance for AI coding agents working in this repository. `AGENTS.md` is a symlink to this file.
|
||||
|
||||
## What this is
|
||||
|
||||
`sm-demo-app` is a SummerCMS application: a Go program built on the SummerCMS framework, module `git.golem15.com/golem15/summercms`. `go.mod` replaces the framework with the local checkout at `../summercms`. That checkout is shared with every other application next to it: do not edit the framework from here. Change this application, or report the framework problem.
|
||||
|
||||
Framework documentation for agents:
|
||||
|
||||
- https://summercms.io/docs/llms.txt (index)
|
||||
- https://summercms.io/docs/llms-full.txt (full text in one file)
|
||||
|
||||
## Commands
|
||||
|
||||
Run them from this directory.
|
||||
|
||||
```sh
|
||||
docker compose up -d --wait # start PostgreSQL 16 on 127.0.0.1:54320
|
||||
summer build # generate main.go and plugins.gen.go, build bin/demo
|
||||
summer dev # watch sources and rebuild
|
||||
summer migrate # run plugin migrations
|
||||
summer serve # start the HTTP server
|
||||
summer make:plugin golem15.<name> # scaffold a plugin module under plugins/<name>
|
||||
summer plugin:add plugins/<name> # register it in summer.yaml, go.mod and go.work
|
||||
```
|
||||
|
||||
## Layout
|
||||
|
||||
- `summer.yaml`: the application manifest (module path, binary name, ordered plugin list).
|
||||
- `plugins/`: local plugin modules.
|
||||
- `config/`: YAML configuration (`app.yaml`, `database.yaml`, `http.yaml`, `storage.yaml`). Any key can be overridden with a `SUMMER_` environment variable, a double underscore separating path segments (`SUMMER_DATABASE__DSN` sets `database.dsn`).
|
||||
- `.env`: local secrets (database DSN, application key). Never commit it; `.gitignore` excludes it. `.env.example` lists the variables.
|
||||
- `compose.yaml`: the local PostgreSQL 16.
|
||||
- `main.go`, `plugins.gen.go`: generated by `summer build` from `summer.yaml`. Do not edit them by hand; they are committed.
|
||||
- `go.work`: the workspace that joins the application and its plugin modules.
|
||||
|
||||
Plugins are Go modules compiled into the binary at build time; there is no runtime plugin loading. Add a plugin with `summer make:plugin` and `summer plugin:add`, then run `summer build`.
|
||||
|
||||
## Naming convention
|
||||
|
||||
- `sm-<name>-app`: an application, like this repository.
|
||||
- `sm-<name>-plugin`: a standalone plugin module.
|
||||
- `sm-<name>-theme`: a Nuxt/Vue frontend.
|
||||
9
internal/build/testdata/new-no-plugin/go.mod.golden
vendored
Normal file
9
internal/build/testdata/new-no-plugin/go.mod.golden
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
module git.golem15.com/golem15/sm-demo-app
|
||||
|
||||
go 1.27.0
|
||||
|
||||
toolchain go1.27.0
|
||||
|
||||
require git.golem15.com/golem15/summercms v0.0.0
|
||||
|
||||
replace git.golem15.com/golem15/summercms => ../summercms
|
||||
7
internal/build/testdata/new-no-plugin/go.work.golden
vendored
Normal file
7
internal/build/testdata/new-no-plugin/go.work.golden
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
go 1.27.0
|
||||
|
||||
toolchain go1.27.0
|
||||
|
||||
use (
|
||||
.
|
||||
)
|
||||
10
internal/build/testdata/new-no-plugin/next-steps.txt.golden
vendored
Normal file
10
internal/build/testdata/new-no-plugin/next-steps.txt.golden
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
created <parent>/sm-demo-app
|
||||
|
||||
Next steps, inside sm-demo-app:
|
||||
docker compose up -d --wait
|
||||
summer build
|
||||
summer migrate
|
||||
summer serve
|
||||
|
||||
The app has no plugins yet. Add one with summer make:plugin golem15.<name>
|
||||
and summer plugin:add plugins/<name>, then run summer build again.
|
||||
6
internal/build/testdata/new-no-plugin/plugins.gen.go.golden
vendored
Normal file
6
internal/build/testdata/new-no-plugin/plugins.gen.go.golden
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
// Code generated by summer build. DO NOT EDIT.
|
||||
|
||||
package main
|
||||
|
||||
// PluginIDs is the ordered plugin list from summer.yaml.
|
||||
var PluginIDs = []string{}
|
||||
3
internal/build/testdata/new-no-plugin/summer.yaml.golden
vendored
Normal file
3
internal/build/testdata/new-no-plugin/summer.yaml.golden
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
module: git.golem15.com/golem15/sm-demo-app
|
||||
binary: demo
|
||||
plugins:
|
||||
44
internal/build/testdata/new/CLAUDE.md.golden
vendored
Normal file
44
internal/build/testdata/new/CLAUDE.md.golden
vendored
Normal file
@@ -0,0 +1,44 @@
|
||||
# CLAUDE.md
|
||||
|
||||
Guidance for AI coding agents working in this repository. `AGENTS.md` is a symlink to this file.
|
||||
|
||||
## What this is
|
||||
|
||||
`sm-demo-app` is a SummerCMS application: a Go program built on the SummerCMS framework, module `git.golem15.com/golem15/summercms`. `go.mod` replaces the framework with the local checkout at `../summercms`. That checkout is shared with every other application next to it: do not edit the framework from here. Change this application, or report the framework problem.
|
||||
|
||||
Framework documentation for agents:
|
||||
|
||||
- https://summercms.io/docs/llms.txt (index)
|
||||
- https://summercms.io/docs/llms-full.txt (full text in one file)
|
||||
|
||||
## Commands
|
||||
|
||||
Run them from this directory.
|
||||
|
||||
```sh
|
||||
docker compose up -d --wait # start PostgreSQL 16 on 127.0.0.1:54320
|
||||
summer build # generate main.go and plugins.gen.go, build bin/demo
|
||||
summer dev # watch sources and rebuild
|
||||
summer migrate # run plugin migrations
|
||||
summer serve # start the HTTP server
|
||||
summer make:plugin golem15.<name> # scaffold a plugin module under plugins/<name>
|
||||
summer plugin:add plugins/<name> # register it in summer.yaml, go.mod and go.work
|
||||
```
|
||||
|
||||
## Layout
|
||||
|
||||
- `summer.yaml`: the application manifest (module path, binary name, ordered plugin list).
|
||||
- `plugins/`: local plugin modules. `plugins/demo` is the starter plugin `golem15.demo`; its `routes.go` answers `GET /` with JSON.
|
||||
- `config/`: YAML configuration (`app.yaml`, `database.yaml`, `http.yaml`, `storage.yaml`). Any key can be overridden with a `SUMMER_` environment variable, a double underscore separating path segments (`SUMMER_DATABASE__DSN` sets `database.dsn`).
|
||||
- `.env`: local secrets (database DSN, application key). Never commit it; `.gitignore` excludes it. `.env.example` lists the variables.
|
||||
- `compose.yaml`: the local PostgreSQL 16.
|
||||
- `main.go`, `plugins.gen.go`: generated by `summer build` from `summer.yaml`. Do not edit them by hand; they are committed.
|
||||
- `go.work`: the workspace that joins the application and its plugin modules.
|
||||
|
||||
Plugins are Go modules compiled into the binary at build time; there is no runtime plugin loading. Add a plugin with `summer make:plugin` and `summer plugin:add`, then run `summer build`.
|
||||
|
||||
## Naming convention
|
||||
|
||||
- `sm-<name>-app`: an application, like this repository.
|
||||
- `sm-<name>-plugin`: a standalone plugin module.
|
||||
- `sm-<name>-theme`: a Nuxt/Vue frontend.
|
||||
22
internal/build/testdata/new/compose.yaml.golden
vendored
Normal file
22
internal/build/testdata/new/compose.yaml.golden
vendored
Normal file
@@ -0,0 +1,22 @@
|
||||
# Local development database: docker compose up -d --wait
|
||||
# PostgreSQL is published on the loopback address only, and the credentials
|
||||
# match SUMMER_DATABASE__DSN in .env.
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16
|
||||
environment:
|
||||
POSTGRES_USER: summer
|
||||
POSTGRES_PASSWORD: summer
|
||||
POSTGRES_DB: demo
|
||||
ports:
|
||||
- "127.0.0.1:54320:5432"
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U summer -d demo"]
|
||||
interval: 2s
|
||||
timeout: 3s
|
||||
retries: 30
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
5
internal/build/testdata/new/config__app.yaml.golden
vendored
Normal file
5
internal/build/testdata/new/config__app.yaml.golden
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
name: demo
|
||||
debug: false
|
||||
# The application key encrypts data at rest. Set SUMMER_APP__KEY in .env (a
|
||||
# 32-byte base64 value); keep it out of this file and out of git.
|
||||
key: ""
|
||||
2
internal/build/testdata/new/config__database.yaml.golden
vendored
Normal file
2
internal/build/testdata/new/config__database.yaml.golden
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
# Set SUMMER_DATABASE__DSN in .env; compose.yaml runs the matching PostgreSQL.
|
||||
dsn: ""
|
||||
5
internal/build/testdata/new/config__http.yaml.golden
vendored
Normal file
5
internal/build/testdata/new/config__http.yaml.golden
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
body_limits:
|
||||
# Request body limits in bytes. Keep them YAML numbers: values from the
|
||||
# environment are strings and are rejected at boot.
|
||||
default_bytes: 1048576
|
||||
upload_bytes: 10485760
|
||||
5
internal/build/testdata/new/config__storage.yaml.golden
vendored
Normal file
5
internal/build/testdata/new/config__storage.yaml.golden
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
# Uploads bucket, required by summer serve. A file:// URL is a local
|
||||
# directory under the application working directory.
|
||||
uploads:
|
||||
bucket_url: "file://./storage/app/uploads"
|
||||
public_path_prefix: "/storage/uploads"
|
||||
10
internal/build/testdata/new/dot-env.example.golden
vendored
Normal file
10
internal/build/testdata/new/dot-env.example.golden
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
# The variables .env sets. summer new wrote a .env with a fresh key; copy this
|
||||
# file to .env only when you need to start over.
|
||||
SUMMER_DATABASE__DSN=postgres://summer:summer@127.0.0.1:54320/demo?sslmode=disable
|
||||
# A 32-byte base64 application key. Generate one with ./bin/demo key:generate
|
||||
# and paste only the key, without the leading check mark.
|
||||
SUMMER_APP__KEY=
|
||||
# Uploads bucket; config/storage.yaml already sets this default.
|
||||
# SUMMER_STORAGE__UPLOADS__BUCKET_URL=file://./storage/app/uploads
|
||||
# Configuration environment; selects the config overlay (default production).
|
||||
# SUMMER_ENV=development
|
||||
5
internal/build/testdata/new/dot-env.golden
vendored
Normal file
5
internal/build/testdata/new/dot-env.golden
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
# Local secrets for this application: the database DSN and the application
|
||||
# key. .gitignore excludes this file; never commit it. Real environment
|
||||
# variables override the values here.
|
||||
SUMMER_DATABASE__DSN=postgres://summer:summer@127.0.0.1:54320/demo?sslmode=disable
|
||||
SUMMER_APP__KEY=AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=
|
||||
6
internal/build/testdata/new/dot-gitignore.golden
vendored
Normal file
6
internal/build/testdata/new/dot-gitignore.golden
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
/bin/
|
||||
/tmp/
|
||||
/storage/
|
||||
.env
|
||||
go.work.sum
|
||||
*.exe
|
||||
14
internal/build/testdata/new/go.mod.golden
vendored
Normal file
14
internal/build/testdata/new/go.mod.golden
vendored
Normal file
@@ -0,0 +1,14 @@
|
||||
module git.golem15.com/golem15/sm-demo-app
|
||||
|
||||
go 1.27.0
|
||||
|
||||
toolchain go1.27.0
|
||||
|
||||
require (
|
||||
git.golem15.com/golem15/sm-demo-app/plugins/demo v0.0.0
|
||||
git.golem15.com/golem15/summercms v0.0.0
|
||||
)
|
||||
|
||||
replace git.golem15.com/golem15/summercms => ../summercms
|
||||
|
||||
replace git.golem15.com/golem15/sm-demo-app/plugins/demo => ./plugins/demo
|
||||
8
internal/build/testdata/new/go.work.golden
vendored
Normal file
8
internal/build/testdata/new/go.work.golden
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
go 1.27.0
|
||||
|
||||
toolchain go1.27.0
|
||||
|
||||
use (
|
||||
.
|
||||
./plugins/demo
|
||||
)
|
||||
59
internal/build/testdata/new/main.go.golden
vendored
Normal file
59
internal/build/testdata/new/main.go.golden
vendored
Normal file
@@ -0,0 +1,59 @@
|
||||
// Code generated by summer build. DO NOT EDIT.
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||
"git.golem15.com/golem15/summercms/modules/bonfire"
|
||||
"git.golem15.com/golem15/summercms/modules/cabana"
|
||||
"git.golem15.com/golem15/summercms/modules/compass"
|
||||
"git.golem15.com/golem15/summercms/modules/conga"
|
||||
"git.golem15.com/golem15/summercms/modules/lagoon"
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
"git.golem15.com/golem15/summercms/modules/party"
|
||||
"git.golem15.com/golem15/summercms/modules/sunscreen"
|
||||
"git.golem15.com/golem15/summercms/modules/surf"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if err := run(os.Args[1:], os.Stdout); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func run(args []string, out io.Writer) error {
|
||||
sunscreen.InstallDefault(os.Stderr)
|
||||
cfg, err := compass.Load("config")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
app := backpack.New(cfg)
|
||||
plugins, err := party.Activate(app, PluginIDs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
commands := lagoon.RuntimeCommands(app, plugins)
|
||||
commands = append(commands, conga.RuntimeCommands(app, plugins)...)
|
||||
commands = append(commands, surf.ServeCommand(app, plugins))
|
||||
commands = append(commands, surf.RouteListCommand(app, plugins))
|
||||
commands = append(commands, cabana.RuntimeCommands(app)...)
|
||||
for _, plugin := range plugins {
|
||||
if hasCommands, ok := plugin.(pact.HasCommands); ok {
|
||||
commands = append(commands, hasCommands.Commands()...)
|
||||
}
|
||||
}
|
||||
if err := app.Publish(bonfire.NewCatalog(commands)); err != nil {
|
||||
return err
|
||||
}
|
||||
root, err := bonfire.NewRoot("demo", commands, out)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
root.SetArgs(args)
|
||||
return root.Execute()
|
||||
}
|
||||
10
internal/build/testdata/new/next-steps.txt.golden
vendored
Normal file
10
internal/build/testdata/new/next-steps.txt.golden
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
created <parent>/sm-demo-app
|
||||
|
||||
Next steps, inside sm-demo-app:
|
||||
docker compose up -d --wait
|
||||
summer build
|
||||
summer migrate
|
||||
summer serve
|
||||
|
||||
Then, from another terminal:
|
||||
curl http://127.0.0.1:8080/
|
||||
12
internal/build/testdata/new/plugins.gen.go.golden
vendored
Normal file
12
internal/build/testdata/new/plugins.gen.go.golden
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
// Code generated by summer build. DO NOT EDIT.
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
_ "git.golem15.com/golem15/sm-demo-app/plugins/demo"
|
||||
)
|
||||
|
||||
// PluginIDs is the ordered plugin list from summer.yaml.
|
||||
var PluginIDs = []string{
|
||||
"golem15.demo",
|
||||
}
|
||||
60
internal/build/testdata/new/plugins__demo__plugin.go.golden
vendored
Normal file
60
internal/build/testdata/new/plugins__demo__plugin.go.golden
vendored
Normal file
@@ -0,0 +1,60 @@
|
||||
package demo
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"io/fs"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||
"git.golem15.com/golem15/summercms/modules/bonfire"
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
"git.golem15.com/golem15/summercms/modules/party"
|
||||
"github.com/go-gormigrate/gormigrate/v2"
|
||||
)
|
||||
|
||||
var (
|
||||
_ pact.HasConfig = (*Plugin)(nil)
|
||||
_ pact.HasLang = (*Plugin)(nil)
|
||||
_ pact.HasMailTemplates = (*Plugin)(nil)
|
||||
_ pact.HasModels = (*Plugin)(nil)
|
||||
_ pact.HasMigrations = (*Plugin)(nil)
|
||||
_ pact.HasCommands = (*Plugin)(nil)
|
||||
_ pact.HasJobs = (*Plugin)(nil)
|
||||
_ pact.HasAdminControllers = (*Plugin)(nil)
|
||||
)
|
||||
|
||||
//go:embed config
|
||||
var configFS embed.FS
|
||||
|
||||
//go:embed lang
|
||||
var langFS embed.FS
|
||||
|
||||
//go:embed views/mail
|
||||
var mailFS embed.FS
|
||||
|
||||
// Plugin is the golem15.demo plugin.
|
||||
type Plugin struct{}
|
||||
|
||||
func (p *Plugin) ID() string { return "golem15.demo" }
|
||||
func (p *Plugin) Requires() []string { return nil }
|
||||
|
||||
func (p *Plugin) Register(*backpack.App) error { return nil }
|
||||
func (p *Plugin) Boot(*backpack.App) error { return nil }
|
||||
|
||||
func (p *Plugin) ConfigFS() fs.FS { return configFS }
|
||||
func (p *Plugin) LangFS() fs.FS { return langFS }
|
||||
|
||||
func (p *Plugin) MailTemplatesFS() fs.FS { return mailFS }
|
||||
func (p *Plugin) MailTemplates() []string { return nil }
|
||||
func (p *Plugin) MailLayouts() map[string]string { return nil }
|
||||
|
||||
func (p *Plugin) Models() []any { return generatedModels() }
|
||||
func (p *Plugin) Migrations() []*gormigrate.Migration { return generatedMigrations() }
|
||||
func (p *Plugin) Commands() []bonfire.Command { return generatedCommands() }
|
||||
func (p *Plugin) Jobs() []pact.Job { return generatedJobs() }
|
||||
func (p *Plugin) AdminControllers() []pact.AdminController {
|
||||
return generatedAdminControllers()
|
||||
}
|
||||
|
||||
func init() {
|
||||
party.Register(&Plugin{})
|
||||
}
|
||||
23
internal/build/testdata/new/plugins__demo__routes.go.golden
vendored
Normal file
23
internal/build/testdata/new/plugins__demo__routes.go.golden
vendored
Normal file
@@ -0,0 +1,23 @@
|
||||
package demo
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
)
|
||||
|
||||
var _ pact.HasRoutes = (*Plugin)(nil)
|
||||
|
||||
// Routes registers the starter route: GET / answers with a JSON greeting
|
||||
// naming this plugin.
|
||||
func (p *Plugin) Routes(r pact.Router) error {
|
||||
r.Get("/{$}", func(w http.ResponseWriter, req *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{
|
||||
"message": "Hello from SummerCMS",
|
||||
"plugin": p.ID(),
|
||||
})
|
||||
})
|
||||
return nil
|
||||
}
|
||||
5
internal/build/testdata/new/summer.yaml.golden
vendored
Normal file
5
internal/build/testdata/new/summer.yaml.golden
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
module: git.golem15.com/golem15/sm-demo-app
|
||||
binary: demo
|
||||
plugins:
|
||||
- id: golem15.demo
|
||||
module: git.golem15.com/golem15/sm-demo-app/plugins/demo
|
||||
File diff suppressed because one or more lines are too long
2
modules/boardwalk/dist/index.html
vendored
2
modules/boardwalk/dist/index.html
vendored
@@ -7,7 +7,7 @@
|
||||
<meta name="summer-admin-base" content="__SUMMER_ADMIN_BASE__" />
|
||||
<link rel="icon" href="./favicon.ico" sizes="48x48" />
|
||||
<title>SummerCMS</title>
|
||||
<script type="module" crossorigin src="./assets/index-BK2Ktgov.js"></script>
|
||||
<script type="module" crossorigin src="./assets/index-BFvYYWYJ.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/index-BhDUy2Lr.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -11,7 +11,7 @@ bonfire is the console layer of SummerCMS. Plugins and framework modules describ
|
||||
## Features
|
||||
|
||||
- Command values with a description, positional arguments (`bonfire.Arg`) and string flags (`bonfire.Flag`), collected from plugins or the tool itself.
|
||||
- Command name validation: plugin commands must use the `namespace:verb` form (for example `blog:import`); `build`, `dev`, `serve` and `migrate` are the only bare names accepted. Invalid names make `bonfire.NewRoot` fail with `bonfire.ErrCommandName`.
|
||||
- Command name validation: plugin commands must use the `namespace:verb` form (for example `blog:import`); `build`, `dev`, `new`, `serve` and `migrate` are the only bare names accepted. Invalid names make `bonfire.NewRoot` fail with `bonfire.ErrCommandName`.
|
||||
- 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).
|
||||
|
||||
@@ -109,7 +109,7 @@ func (in cobraInput) Flag(name string) (string, bool) {
|
||||
|
||||
func validCommandName(name string) bool {
|
||||
switch name {
|
||||
case "build", "dev", "serve", "migrate":
|
||||
case "build", "dev", "new", "serve", "migrate":
|
||||
return true
|
||||
}
|
||||
ns, verb, ok := strings.Cut(name, ":")
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
)
|
||||
|
||||
func TestValidBareRuntimeCommandNames(t *testing.T) {
|
||||
for _, name := range []string{"serve", "migrate", "migrate:rollback", "migrate:status", "build", "dev"} {
|
||||
for _, name := range []string{"serve", "migrate", "migrate:rollback", "migrate:status", "build", "dev", "new"} {
|
||||
if _, err := NewRoot("app", []Command{{Name: name}}, io.Discard); err != nil {
|
||||
t.Fatalf("%s: %v", name, err)
|
||||
}
|
||||
|
||||
@@ -84,3 +84,41 @@ func TestFieldByColumnTagBeatsGoName(t *testing.T) {
|
||||
t.Fatalf("a tagged field matched by its Go name: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
type listTag struct {
|
||||
ID uint `gorm:"primarykey"`
|
||||
Name *string `gorm:"column:name"`
|
||||
}
|
||||
|
||||
type listTagged struct {
|
||||
ID uint `gorm:"primarykey"`
|
||||
Tags []listTag `gorm:"many2many:tagged_tags"`
|
||||
Pointers []*listTag `gorm:"many2many:tagged_pointer_tags"`
|
||||
}
|
||||
|
||||
// TestProjectRowJoinsToManyRelation pins a to-many relation column (relation:
|
||||
// tags, select: name): the list shows every related record's selected value
|
||||
// joined with ", " like Winter, and leaves the key unset when there are none.
|
||||
func TestProjectRowJoinsToManyRelation(t *testing.T) {
|
||||
news, guides := "News", "Guides"
|
||||
row := &listTagged{
|
||||
ID: 3,
|
||||
Tags: []listTag{{ID: 1, Name: &news}, {ID: 2, Name: &guides}, {ID: 4}},
|
||||
Pointers: []*listTag{nil, {ID: 2, Name: &guides}},
|
||||
}
|
||||
cols := []ListColumn{
|
||||
{Key: "tags", Relation: "tags", Select: "name"},
|
||||
{Key: "pointers", Relation: "pointers", Select: "name"},
|
||||
}
|
||||
projected := projectRow(row, nil, cols)
|
||||
if projected["tags"] != "News, Guides" {
|
||||
t.Fatalf("tags = %#v, want %q", projected["tags"], "News, Guides")
|
||||
}
|
||||
if projected["pointers"] != "Guides" {
|
||||
t.Fatalf("pointers = %#v, want %q", projected["pointers"], "Guides")
|
||||
}
|
||||
empty := projectRow(&listTagged{ID: 5}, nil, cols)
|
||||
if _, ok := empty["tags"]; ok {
|
||||
t.Fatalf("empty relation projected %#v, want the key unset", empty)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -580,16 +580,48 @@ func relatedSelect(row reflect.Value, controller pact.AdminController, col ListC
|
||||
}
|
||||
field = field.Elem()
|
||||
}
|
||||
column := listRelationColumn(controller, col.Relation, col.Select)
|
||||
if field.Kind() == reflect.Slice {
|
||||
return relatedSelectMany(field, column)
|
||||
}
|
||||
if field.Kind() != reflect.Struct {
|
||||
return nil, false
|
||||
}
|
||||
selected := fieldByColumn(field, listRelationColumn(controller, col.Relation, col.Select))
|
||||
selected := fieldByColumn(field, column)
|
||||
if !selected.IsValid() || !selected.CanInterface() {
|
||||
return nil, false
|
||||
}
|
||||
return selected.Interface(), true
|
||||
}
|
||||
|
||||
// relatedSelectMany joins the selected column of every related record with
|
||||
// ", ", as Winter's list does for a to-many relation column. No related
|
||||
// records leave the key unset, so the list shows its empty placeholder.
|
||||
func relatedSelectMany(records reflect.Value, column string) (any, bool) {
|
||||
values := make([]string, 0, records.Len())
|
||||
for i := 0; i < records.Len(); i++ {
|
||||
record := records.Index(i)
|
||||
for record.Kind() == reflect.Pointer && !record.IsNil() {
|
||||
record = record.Elem()
|
||||
}
|
||||
if record.Kind() != reflect.Struct {
|
||||
continue
|
||||
}
|
||||
selected := fieldByColumn(record, column)
|
||||
for selected.IsValid() && selected.Kind() == reflect.Pointer && !selected.IsNil() {
|
||||
selected = selected.Elem()
|
||||
}
|
||||
if !selected.IsValid() || !selected.CanInterface() || selected.Kind() == reflect.Pointer {
|
||||
continue
|
||||
}
|
||||
values = append(values, fmt.Sprint(selected.Interface()))
|
||||
}
|
||||
if len(values) == 0 {
|
||||
return nil, false
|
||||
}
|
||||
return strings.Join(values, ", "), true
|
||||
}
|
||||
|
||||
func listRelationColumn(controller pact.AdminController, relation, column string) string {
|
||||
mapper, ok := controller.(pact.ListRelationColumnMapper)
|
||||
if !ok || mapper == nil {
|
||||
|
||||
@@ -148,6 +148,7 @@ func (p *Plugin) Migrations() []*gormigrate.Migration {
|
||||
| `lagoon.FrameworkSchedule` | The framework's scheduled commands for conga: the daily `deferred:purge` at `database.deferred_bindings.purge_at`, or none when it is empty. |
|
||||
| `lagoon.FrameworkScheduleID` | Plugin id the framework's schedule entries are listed under, `summercms.lagoon`. |
|
||||
| `lagoon.KeyGenerateCommand` | Returns the key:generate command on its own. |
|
||||
| `lagoon.GenerateAppKey` | Returns a fresh 32-byte base64 `app.key`, the value key:generate prints, for tools that write it into a file. |
|
||||
| `lagoon.LoadAppKey` | Decodes `app.key` and `app.previous_keys`. |
|
||||
| `lagoon.PublishEncryptionKeys` | Installs the keys used by `lagoon.Encrypted` columns. |
|
||||
| `lagoon.Encrypted` | Encrypted text column; `lagoon.Encrypted.Reveal` is the only plaintext accessor. |
|
||||
|
||||
@@ -8,6 +8,17 @@ import (
|
||||
"git.golem15.com/golem15/summercms/modules/bonfire"
|
||||
)
|
||||
|
||||
// GenerateAppKey returns a fresh app.key: 32 random bytes from crypto/rand,
|
||||
// base64 (standard encoding) encoded, 44 characters. It is the value
|
||||
// key:generate prints, without the output decoration.
|
||||
func GenerateAppKey() (string, error) {
|
||||
key := make([]byte, encryptedKeySize)
|
||||
if _, err := rand.Read(key); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.StdEncoding.EncodeToString(key), nil
|
||||
}
|
||||
|
||||
// KeyGenerateCommand prints a fresh 32-byte base64 app.key and performs no
|
||||
// other side effect (D-11).
|
||||
func KeyGenerateCommand() bonfire.Command {
|
||||
@@ -15,11 +26,11 @@ func KeyGenerateCommand() bonfire.Command {
|
||||
Name: "key:generate",
|
||||
Description: "Print a fresh 32-byte base64 app.key",
|
||||
Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error {
|
||||
key := make([]byte, encryptedKeySize)
|
||||
if _, err := rand.Read(key); err != nil {
|
||||
key, err := GenerateAppKey()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out.Success(base64.StdEncoding.EncodeToString(key))
|
||||
out.Success(key)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
@@ -38,3 +38,22 @@ func TestEncryptedKeyGeneratePrints32ByteBase64(t *testing.T) {
|
||||
t.Fatalf("decoded key length = %d, want 32", len(raw))
|
||||
}
|
||||
}
|
||||
|
||||
// TestGenerateAppKey: 44 characters, 32 decoded bytes, distinct values.
|
||||
func TestGenerateAppKey(t *testing.T) {
|
||||
a, err := GenerateAppKey()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b, err := GenerateAppKey()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(a) != 44 || a == b {
|
||||
t.Fatalf("keys %q and %q: want two distinct 44-character values", a, b)
|
||||
}
|
||||
raw, err := base64.StdEncoding.DecodeString(a)
|
||||
if err != nil || len(raw) != 32 {
|
||||
t.Fatalf("key %q decodes to %d bytes, err %v", a, len(raw), err)
|
||||
}
|
||||
}
|
||||
|
||||
91
modules/sunrise/README.md
Normal file
91
modules/sunrise/README.md
Normal file
@@ -0,0 +1,91 @@
|
||||
# sunrise
|
||||
|
||||
Bootstrap script that installs the summer CLI and creates a new SummerCMS application in one command, embedded so it is versioned with the framework.
|
||||
|
||||
`import "git.golem15.com/golem15/summercms/modules/sunrise"`
|
||||
|
||||
## Overview
|
||||
|
||||
`sunrise` holds `new.sh`, the script behind the one-liner served at https://summercms.io/new:
|
||||
|
||||
```sh
|
||||
curl -fsSL https://summercms.io/new | bash -s -- blog
|
||||
```
|
||||
|
||||
The script checks for Go 1.27 or newer, git and Docker, clones the framework into `./summercms` at its newest release tag (or moves an existing release checkout forward to it), installs the `summer` CLI with `go install ./cmd/summer` and runs `summer new blog --framework <checkout>` next to the checkout, which writes `./sm-blog-app`. `summer new` prints the next steps; the script adds the `cd` line and, when the Go install directory is not on `PATH`, an `export PATH=...` hint.
|
||||
|
||||
The script lives inside this package so `//go:embed` can reach it, and a web server serves the bytes of the framework release it was built against. `sunrise.Script` returns them.
|
||||
|
||||
## Features
|
||||
|
||||
- Truncation-safe: everything is a function definition and the last line is `main "$@"`, so a download cut short runs nothing.
|
||||
- Prerequisites first: Go older than 1.27 or missing, or a missing git, stops the script with an install hint before anything is cloned; a missing Docker or compose plugin is only a warning.
|
||||
- Release tags: a fresh clone is checked out detached at the newest `vX.Y.Z` tag, or at `SUMMERCMS_REF` when set.
|
||||
- Fast-forward-only updates: an existing checkout is fetched (tags are never force-updated) and moved only when its `HEAD` is an ancestor of the target.
|
||||
- Refusals that protect local work: an existing directory that is not a git repository, whose `go.mod` is not the framework module, that has changes to tracked files, or that is on a branch is left untouched and the script stops with a message.
|
||||
- Early name check: a name that is not lower-case letters and digits starting with a letter, or an existing `sm-<name>-app`, stops the script before any clone or install.
|
||||
- No privilege escalation and no dynamic code; only bash builtins plus `git`, `go` and `docker` are run, with stdin closed and `GIT_TERMINAL_PROMPT=0` so nothing waits for input.
|
||||
|
||||
## Usage
|
||||
|
||||
Run the script from the directory that should hold the framework checkout and the application:
|
||||
|
||||
```sh
|
||||
curl -fsSL https://summercms.io/new | bash -s -- blog
|
||||
```
|
||||
|
||||
`bash -s -- <name>` passes the application name to the piped script; `-h` or `--help` prints the usage. A local copy runs the same way with `bash new.sh blog`.
|
||||
|
||||
Serving the script from a Go handler:
|
||||
|
||||
```go
|
||||
package acme
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/sunrise"
|
||||
)
|
||||
|
||||
func newScript(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
_, _ = w.Write(sunrise.Script())
|
||||
}
|
||||
```
|
||||
|
||||
## API reference
|
||||
|
||||
| Identifier | Description |
|
||||
|------------|-------------|
|
||||
| `sunrise.Script` | Returns a fresh copy of the embedded `new.sh` bytes on every call. |
|
||||
|
||||
## Configuration
|
||||
|
||||
The script reads three environment variables. It never reads or sets `SUMMER_`-prefixed variables, which the framework reads as configuration.
|
||||
|
||||
| Variable | Default | Purpose |
|
||||
|----------|---------|---------|
|
||||
| `SUMMERCMS_REPO` | `https://git.golem15.com/golem15/summercms` | Repository to clone. |
|
||||
| `SUMMERCMS_DIR` | `summercms` (relative to the current directory) | Framework checkout directory; the application is created next to it. |
|
||||
| `SUMMERCMS_REF` | newest `vX.Y.Z` tag | Tag or commit to check out instead of the newest release. |
|
||||
|
||||
## Dependencies
|
||||
|
||||
- SummerCMS modules: none.
|
||||
- Third-party: none.
|
||||
- Standard library: `bytes`, `embed`.
|
||||
|
||||
## Testing
|
||||
|
||||
```sh
|
||||
go test ./modules/sunrise/...
|
||||
```
|
||||
|
||||
The default run checks the embedded bytes, the script's shape and syntax (`bash -n`), and runs every line-boundary prefix and every byte prefix of the final line against stub commands to prove a truncated download invokes nothing.
|
||||
|
||||
```sh
|
||||
SUMMERCMS_BOOTSTRAP_CHECK=1 go test ./modules/sunrise/ -run TestBootstrapEndToEnd -timeout 20m
|
||||
```
|
||||
|
||||
The gated run serves the script from a local HTTP server, pipes it through `curl | bash` with `SUMMERCMS_REPO` set to this repository and `SUMMERCMS_REF` to its `HEAD`, and builds the generated application. It needs bash, curl, git, Go and access to the Go module proxy.
|
||||
257
modules/sunrise/new.sh
Executable file
257
modules/sunrise/new.sh
Executable file
@@ -0,0 +1,257 @@
|
||||
#!/usr/bin/env bash
|
||||
# SummerCMS bootstrap: create a new SummerCMS application in one command.
|
||||
#
|
||||
# curl -fsSL https://summercms.io/new | bash -s -- <name>
|
||||
#
|
||||
# The script checks for Go 1.27 or newer, git and (optionally) Docker, clones
|
||||
# the framework into ./summercms at its newest release tag (or moves an
|
||||
# existing release checkout forward to it), installs the summer CLI with
|
||||
# `go install` and runs `summer new <name>` next to the checkout, which
|
||||
# writes ./sm-<name>-app.
|
||||
#
|
||||
# Environment overrides:
|
||||
# SUMMERCMS_REPO framework repository to clone
|
||||
# (default https://git.golem15.com/golem15/summercms)
|
||||
# SUMMERCMS_DIR framework checkout directory (default ./summercms); the
|
||||
# application is created next to it
|
||||
# SUMMERCMS_REF tag or commit to check out instead of the newest release
|
||||
#
|
||||
# Everything below is a function definition and the last line calls main, so
|
||||
# a download cut short runs nothing. Only bash builtins and the git, go and
|
||||
# docker commands are used.
|
||||
|
||||
say() {
|
||||
printf '==> %s\n' "$*" >&2
|
||||
}
|
||||
|
||||
warn() {
|
||||
printf 'warning: %s\n' "$*" >&2
|
||||
}
|
||||
|
||||
die() {
|
||||
printf 'error: %s\n' "$*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
usage() {
|
||||
printf '%s\n' \
|
||||
'Usage: curl -fsSL https://summercms.io/new | bash -s -- <name>' \
|
||||
' bash new.sh <name>' \
|
||||
'' \
|
||||
'Creates sm-<name>-app next to a SummerCMS framework checkout. <name> is' \
|
||||
'lower-case letters and digits, starting with a letter.' \
|
||||
'' \
|
||||
'Steps: check Go 1.27+, git and Docker; clone the framework (or move an' \
|
||||
'existing release checkout forward) at its newest release tag; install' \
|
||||
'the summer CLI with go install; run summer new <name>.' \
|
||||
'' \
|
||||
'Environment:' \
|
||||
' SUMMERCMS_REPO repository to clone (default https://git.golem15.com/golem15/summercms)' \
|
||||
' SUMMERCMS_DIR framework checkout directory (default ./summercms)' \
|
||||
' SUMMERCMS_REF tag or commit to check out instead of the newest release'
|
||||
}
|
||||
|
||||
# abspath prints $1 as an absolute path against $PWD with ".", ".." and
|
||||
# repeated slashes removed lexically.
|
||||
abspath() {
|
||||
local p="$1" out="" seg
|
||||
local -a parts
|
||||
[[ $p == /* ]] || p="$PWD/$p"
|
||||
IFS=/ read -r -a parts <<<"$p"
|
||||
for seg in "${parts[@]}"; do
|
||||
case "$seg" in
|
||||
'' | .) ;;
|
||||
..) out="${out%/*}" ;;
|
||||
*) out="$out/$seg" ;;
|
||||
esac
|
||||
done
|
||||
printf '%s\n' "${out:-/}"
|
||||
}
|
||||
|
||||
# check_go requires go 1.27 or newer.
|
||||
check_go() {
|
||||
command -v go >/dev/null 2>&1 ||
|
||||
die "Go is not installed. SummerCMS needs Go 1.27 or newer: https://go.dev/dl/"
|
||||
local v
|
||||
# Read the version from / so a go.mod in the current directory cannot
|
||||
# make the go command switch toolchains for the check.
|
||||
v="$(cd / && go env GOVERSION </dev/null 2>/dev/null)" || v=""
|
||||
if [[ $v =~ ^go([0-9]+)\.([0-9]+) ]]; then
|
||||
local major="${BASH_REMATCH[1]}" minor="${BASH_REMATCH[2]}"
|
||||
if ((major < 1 || (major == 1 && minor < 27))); then
|
||||
die "SummerCMS needs Go 1.27 or newer, found $v. Install a newer Go from https://go.dev/dl/"
|
||||
fi
|
||||
else
|
||||
warn "could not read the Go version (go env GOVERSION printed '${v}'); SummerCMS needs Go 1.27 or newer"
|
||||
fi
|
||||
}
|
||||
|
||||
# check_docker only warns: the application builds without Docker, the
|
||||
# generated compose.yaml just needs it for the development database.
|
||||
check_docker() {
|
||||
if ! command -v docker >/dev/null 2>&1; then
|
||||
warn "Docker is not installed; the generated compose.yaml needs it to run PostgreSQL (https://docs.docker.com/engine/install/)"
|
||||
elif ! docker compose version </dev/null >/dev/null 2>&1; then
|
||||
warn "the Docker compose plugin is missing; the generated compose.yaml needs 'docker compose'"
|
||||
fi
|
||||
}
|
||||
|
||||
# latest_tag prints the newest vX.Y.Z tag of the checkout in $1.
|
||||
latest_tag() {
|
||||
local dir="$1" tags tag
|
||||
# Capture the whole list first: piping into head under pipefail can
|
||||
# fail with SIGPIPE.
|
||||
tags="$(git -C "$dir" tag -l 'v*' --sort=-v:refname </dev/null)" || return 1
|
||||
while IFS= read -r tag; do
|
||||
if [[ $tag =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
printf '%s\n' "$tag"
|
||||
return 0
|
||||
fi
|
||||
done <<<"$tags"
|
||||
return 1
|
||||
}
|
||||
|
||||
# target_commit prints the commit to check out in $1: SUMMERCMS_REF ($2)
|
||||
# when set, otherwise the newest release tag.
|
||||
target_commit() {
|
||||
local dir="$1" ref="$2" label commit
|
||||
if [[ -n $ref ]]; then
|
||||
[[ $ref != -* ]] || die "SUMMERCMS_REF must be a tag or commit, not '$ref'"
|
||||
label="$ref"
|
||||
else
|
||||
label="$(latest_tag "$dir")" ||
|
||||
die "no release tag (vX.Y.Z) found in $dir; set SUMMERCMS_REF to a tag or commit"
|
||||
ref="refs/tags/$label"
|
||||
fi
|
||||
commit="$(git -C "$dir" rev-parse --verify --quiet "${ref}^{commit}" </dev/null)" ||
|
||||
die "$label is not a tag or commit in $dir"
|
||||
say "framework target: $label (${commit:0:12})"
|
||||
printf '%s\n' "$commit"
|
||||
}
|
||||
|
||||
# check_checkout refuses an existing directory that is not a clean,
|
||||
# detached SummerCMS framework checkout. It never changes the directory.
|
||||
check_checkout() {
|
||||
local dir="$1" hint top real line module="" status branch
|
||||
hint="use SUMMERCMS_DIR for a separate clone, or run 'summer new --framework <checkout>' yourself"
|
||||
[[ -d $dir ]] || die "$dir exists and is not a directory; $hint"
|
||||
[[ -f $dir/go.mod ]] || die "$dir is not a SummerCMS framework checkout (no go.mod); $hint"
|
||||
while IFS= read -r line || [[ -n $line ]]; do
|
||||
if [[ $line =~ ^[[:space:]]*module[[:space:]]+\"?([^[:space:]\"]+) ]]; then
|
||||
module="${BASH_REMATCH[1]}"
|
||||
break
|
||||
fi
|
||||
done <"$dir/go.mod"
|
||||
[[ $module == git.golem15.com/golem15/summercms ]] ||
|
||||
die "$dir is not a SummerCMS framework checkout (go.mod module is '${module}'); $hint"
|
||||
top="$(git -C "$dir" rev-parse --show-toplevel </dev/null 2>/dev/null)" ||
|
||||
die "$dir is not a git repository; $hint"
|
||||
real="$(cd "$dir" && pwd -P)"
|
||||
[[ $top == "$real" ]] || die "$dir is not the root of a git repository; $hint"
|
||||
status="$(git -C "$dir" status --porcelain --untracked-files=no </dev/null)" ||
|
||||
die "git status failed in $dir"
|
||||
[[ -z $status ]] || die "$dir has uncommitted changes to tracked files; commit or stash them, or $hint"
|
||||
if branch="$(git -C "$dir" symbolic-ref -q --short HEAD </dev/null)"; then
|
||||
die "$dir is on branch $branch; this installer only moves a detached release checkout. To keep your branch, $hint"
|
||||
fi
|
||||
}
|
||||
|
||||
# checkout_framework clones the framework into $2 or moves an existing
|
||||
# detached checkout forward, fast-forward only.
|
||||
checkout_framework() {
|
||||
local repo="$1" dir="$2" ref="$3" target head
|
||||
if [[ ! -e $dir && ! -L $dir ]]; then
|
||||
say "cloning $repo into $dir"
|
||||
git clone --quiet -- "$repo" "$dir" </dev/null || die "git clone of $repo failed"
|
||||
target="$(target_commit "$dir" "$ref")" ||
|
||||
die "the new clone in $dir has no usable target; remove $dir and run again"
|
||||
git -C "$dir" -c advice.detachedHead=false checkout --quiet --detach "$target" </dev/null
|
||||
return 0
|
||||
fi
|
||||
check_checkout "$dir"
|
||||
say "updating the framework checkout in $dir"
|
||||
# No --force: a tag that moved upstream is never overwritten locally,
|
||||
# git refuses and the script stops.
|
||||
git -C "$dir" fetch --quiet --tags origin </dev/null ||
|
||||
die "git fetch in $dir failed (a release tag may have moved upstream); the checkout was not changed"
|
||||
target="$(target_commit "$dir" "$ref")" || exit 1
|
||||
head="$(git -C "$dir" rev-parse HEAD </dev/null)"
|
||||
if [[ $head == "$target" ]]; then
|
||||
say "framework checkout is already at the target"
|
||||
elif git -C "$dir" merge-base --is-ancestor "$head" "$target" </dev/null; then
|
||||
git -C "$dir" -c advice.detachedHead=false checkout --quiet --detach "$target" </dev/null
|
||||
else
|
||||
die "moving $dir from ${head:0:12} to ${target:0:12} is not a fast-forward; the checkout was not changed. $dir must be an ancestor of the target; use SUMMERCMS_DIR for a fresh clone"
|
||||
fi
|
||||
}
|
||||
|
||||
# go_bin_dir prints the directory go install writes to: GOBIN, else the
|
||||
# first GOPATH entry plus /bin.
|
||||
go_bin_dir() {
|
||||
local gobin gopath
|
||||
gobin="$(cd / && go env GOBIN </dev/null)" || return 1
|
||||
if [[ -z $gobin ]]; then
|
||||
gopath="$(cd / && go env GOPATH </dev/null)" || return 1
|
||||
gopath="${gopath%%:*}"
|
||||
[[ -n $gopath ]] || return 1
|
||||
gobin="$gopath/bin"
|
||||
fi
|
||||
printf '%s\n' "$gobin"
|
||||
}
|
||||
|
||||
main() {
|
||||
set -euo pipefail
|
||||
export GIT_TERMINAL_PROMPT=0
|
||||
|
||||
if (($# == 1)) && [[ $1 == -h || $1 == --help ]]; then
|
||||
usage
|
||||
exit 0
|
||||
fi
|
||||
if (($# != 1)); then
|
||||
usage >&2
|
||||
exit 2
|
||||
fi
|
||||
local name="$1"
|
||||
if [[ ! $name =~ ^[a-z][a-z0-9]*$ ]]; then
|
||||
printf 'error: invalid application name %q: use lower-case letters and digits, starting with a letter\n\n' "$name" >&2
|
||||
usage >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
local repo="${SUMMERCMS_REPO:-https://git.golem15.com/golem15/summercms}"
|
||||
local ref="${SUMMERCMS_REF:-}"
|
||||
local dir parent app
|
||||
dir="$(abspath "${SUMMERCMS_DIR:-summercms}")"
|
||||
[[ $dir != / ]] || die "SUMMERCMS_DIR must not be /"
|
||||
parent="${dir%/*}"
|
||||
parent="${parent:-/}"
|
||||
app="${parent%/}/sm-$name-app"
|
||||
[[ -d $parent ]] || die "$parent does not exist"
|
||||
if [[ -e $app || -L $app ]]; then
|
||||
die "$app already exists; choose another name or remove it"
|
||||
fi
|
||||
|
||||
command -v git >/dev/null 2>&1 || die "git is not installed; install git and run again"
|
||||
check_go
|
||||
check_docker
|
||||
|
||||
checkout_framework "$repo" "$dir" "$ref"
|
||||
|
||||
local gobin
|
||||
gobin="$(go_bin_dir)" || die "could not determine the Go install directory (go env GOBIN / GOPATH)"
|
||||
say "installing summer into $gobin"
|
||||
GOWORK=off go -C "$dir" install ./cmd/summer </dev/null
|
||||
|
||||
say "creating $app"
|
||||
(cd "$parent" && "$gobin/summer" new "$name" --framework "$dir" </dev/null)
|
||||
|
||||
printf '\n==> Done. Start with:\n cd %s\n' "$app"
|
||||
if [[ :${PATH:-}: != *":$gobin:"* ]]; then
|
||||
printf '\n==> summer is installed in %s, which is not on your PATH. Run this (and add it to your shell profile):\n' "$gobin"
|
||||
# $PATH stays literal: the user's shell expands it.
|
||||
# shellcheck disable=SC2016
|
||||
printf ' export PATH="%s:$PATH"\n' "$gobin"
|
||||
fi
|
||||
}
|
||||
|
||||
main "$@"
|
||||
629
modules/sunrise/script_behavior_test.go
Normal file
629
modules/sunrise/script_behavior_test.go
Normal file
@@ -0,0 +1,629 @@
|
||||
package sunrise
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// scriptHarness runs new.sh with bash on a PATH that holds only a symlink to
|
||||
// the real git plus stub go and docker commands. The go stub answers
|
||||
// `go env GOVERSION|GOBIN|GOPATH` from the harness and, for
|
||||
// `go -C <dir> install ./cmd/summer`, writes a stub summer into the Go bin
|
||||
// directory that records its working directory, arguments and stdin.
|
||||
type scriptHarness struct {
|
||||
t *testing.T
|
||||
bash string
|
||||
script string
|
||||
root string // per-case temp root
|
||||
work string // the directory the script runs in
|
||||
tools string // the PATH directory
|
||||
logs string // stub call records
|
||||
gobin string
|
||||
gopath string
|
||||
remote *fakeRemote
|
||||
|
||||
goVersion string // go env GOVERSION
|
||||
useGOBIN bool // go env GOBIN prints gobin; otherwise empty, GOPATH/bin is used
|
||||
gobinOnPath bool
|
||||
env []string // extra environment
|
||||
}
|
||||
|
||||
type scriptResult struct {
|
||||
code int
|
||||
stdout, stderr string
|
||||
}
|
||||
|
||||
// stdinProbe is piped into every run: the script must never let a child
|
||||
// read it.
|
||||
const stdinProbe = "SHOULD-NOT-BE-READ\n"
|
||||
|
||||
// fwModule is the framework module path the script requires.
|
||||
const fwModule = "git.golem15.com/golem15/summercms"
|
||||
|
||||
// gitTestEnv isolates git from the user's configuration and supplies an
|
||||
// identity for the commits the harness itself makes.
|
||||
func gitTestEnv(home string) []string {
|
||||
return []string{
|
||||
"HOME=" + home,
|
||||
"XDG_CONFIG_HOME=" + filepath.Join(home, ".config"),
|
||||
"GIT_CONFIG_NOSYSTEM=1",
|
||||
"GIT_CONFIG_GLOBAL=" + os.DevNull,
|
||||
// Detached auto maintenance would write into temp repos during cleanup.
|
||||
"GIT_CONFIG_COUNT=2",
|
||||
"GIT_CONFIG_KEY_0=maintenance.auto", "GIT_CONFIG_VALUE_0=false",
|
||||
"GIT_CONFIG_KEY_1=gc.auto", "GIT_CONFIG_VALUE_1=0",
|
||||
"GIT_AUTHOR_NAME=Test", "GIT_AUTHOR_EMAIL=test@example.com",
|
||||
"GIT_COMMITTER_NAME=Test", "GIT_COMMITTER_EMAIL=test@example.com",
|
||||
"LC_ALL=C",
|
||||
}
|
||||
}
|
||||
|
||||
// fakeRemote is a bare framework repository fed from a work clone.
|
||||
type fakeRemote struct {
|
||||
t *testing.T
|
||||
env []string
|
||||
bare string
|
||||
src string
|
||||
n int
|
||||
tags map[string]string // tag -> commit
|
||||
}
|
||||
|
||||
func (r *fakeRemote) git(dir string, args ...string) string {
|
||||
r.t.Helper()
|
||||
cmd := exec.Command("git", args...)
|
||||
cmd.Dir = dir
|
||||
cmd.Env = append(os.Environ()[:0:0], append([]string{"PATH=" + os.Getenv("PATH")}, r.env...)...)
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
r.t.Fatalf("git %v in %s: %v\n%s", args, dir, err, out)
|
||||
}
|
||||
return strings.TrimSpace(string(out))
|
||||
}
|
||||
|
||||
// commit adds a commit on main and returns its hash.
|
||||
func (r *fakeRemote) commit() string {
|
||||
r.t.Helper()
|
||||
r.n++
|
||||
if err := os.WriteFile(filepath.Join(r.src, "VERSION"), []byte(strings.Repeat("x", r.n)+"\n"), 0o644); err != nil {
|
||||
r.t.Fatal(err)
|
||||
}
|
||||
r.git(r.src, "add", "-A")
|
||||
r.git(r.src, "commit", "-q", "-m", "commit")
|
||||
r.git(r.src, "push", "-q", "origin", "main")
|
||||
return r.git(r.src, "rev-parse", "HEAD")
|
||||
}
|
||||
|
||||
// tag commits and tags the new commit, then pushes the tag.
|
||||
func (r *fakeRemote) tag(tag string) string {
|
||||
r.t.Helper()
|
||||
c := r.commit()
|
||||
r.git(r.src, "tag", tag)
|
||||
r.git(r.src, "push", "-q", "origin", "refs/tags/"+tag)
|
||||
r.tags[tag] = c
|
||||
return c
|
||||
}
|
||||
|
||||
// moveTag recreates tag on a new commit upstream.
|
||||
func (r *fakeRemote) moveTag(tag string) string {
|
||||
r.t.Helper()
|
||||
c := r.commit()
|
||||
r.git(r.src, "tag", "-f", tag)
|
||||
r.git(r.src, "push", "-q", "--force", "origin", "refs/tags/"+tag)
|
||||
r.tags[tag] = c
|
||||
return c
|
||||
}
|
||||
|
||||
func newFakeRemote(t *testing.T, root string, env []string) *fakeRemote {
|
||||
t.Helper()
|
||||
r := &fakeRemote{t: t, env: env, bare: filepath.Join(root, "remote.git"), src: filepath.Join(root, "remote-src"), tags: map[string]string{}}
|
||||
r.git(root, "init", "-q", "--bare", "--initial-branch=main", r.bare)
|
||||
r.git(root, "init", "-q", "--initial-branch=main", r.src)
|
||||
if err := os.WriteFile(filepath.Join(r.src, "go.mod"), []byte("module "+fwModule+"\n\ngo 1.27.0\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r.git(r.src, "remote", "add", "origin", r.bare)
|
||||
r.git(r.src, "add", "-A")
|
||||
r.git(r.src, "commit", "-q", "-m", "init")
|
||||
r.git(r.src, "push", "-q", "origin", "main")
|
||||
r.tag("v0.1.0")
|
||||
r.tag("v0.2.0")
|
||||
r.tag("v0.10.0")
|
||||
// A pre-release newer than every release: never selected.
|
||||
r.tag("v1.0.0-rc1")
|
||||
return r
|
||||
}
|
||||
|
||||
func newScriptHarness(t *testing.T) *scriptHarness {
|
||||
t.Helper()
|
||||
bash := lookBash(t)
|
||||
gitBin, err := exec.LookPath("git")
|
||||
if err != nil {
|
||||
t.Skip("git not installed")
|
||||
}
|
||||
gitBin, err = filepath.Abs(gitBin)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
chmod, err := exec.LookPath("chmod")
|
||||
if err != nil {
|
||||
t.Skip("chmod not found")
|
||||
}
|
||||
root := t.TempDir()
|
||||
h := &scriptHarness{
|
||||
t: t,
|
||||
bash: bash,
|
||||
root: root,
|
||||
work: filepath.Join(root, "work"),
|
||||
tools: filepath.Join(root, "tools"),
|
||||
logs: filepath.Join(root, "logs"),
|
||||
gobin: filepath.Join(root, "gobin"),
|
||||
gopath: filepath.Join(root, "gopath"),
|
||||
goVersion: "go1.27.0",
|
||||
useGOBIN: true,
|
||||
}
|
||||
for _, d := range []string{h.work, h.tools, h.logs, h.gobin, filepath.Join(h.gopath, "bin"), filepath.Join(root, "home")} {
|
||||
if err := os.MkdirAll(d, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
h.script = filepath.Join(root, "new.sh")
|
||||
if err := os.WriteFile(h.script, Script(), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(gitBin, filepath.Join(h.tools, "git")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
summerStub := "#!" + bash + `
|
||||
{ printf 'cwd=%s\n' "$(pwd)"; printf 'arg=%s\n' "$@"; } >"$STUB_LOG/summer.call"
|
||||
data=""
|
||||
while IFS= read -r line || [[ -n $line ]]; do data+="$line"; done
|
||||
printf '%s' "$data" >"$STUB_LOG/summer.stdin"
|
||||
`
|
||||
goStub := "#!" + bash + `
|
||||
printf '%s\n' "$*" >>"$STUB_LOG/go.calls"
|
||||
case "$1 ${2:-}" in
|
||||
"env GOVERSION") printf '%s\n' "$STUB_GOVERSION"; exit 0 ;;
|
||||
"env GOBIN") printf '%s\n' "$STUB_GOBIN"; exit 0 ;;
|
||||
"env GOPATH") printf '%s\n' "$STUB_GOPATH"; exit 0 ;;
|
||||
esac
|
||||
if [[ $1 == -C && $3 == install && $4 == ./cmd/summer ]]; then
|
||||
printf 'GOWORK=%s dir=%s\n' "${GOWORK-unset}" "$2" >>"$STUB_LOG/go.install"
|
||||
bin="$STUB_GOBIN"
|
||||
[[ -n $bin ]] || bin="${STUB_GOPATH%%:*}/bin"
|
||||
printf '%s' "$SUMMER_STUB" >"$bin/summer"
|
||||
'` + chmod + `' 0755 "$bin/summer"
|
||||
exit 0
|
||||
fi
|
||||
printf 'unexpected go %s\n' "$*" >&2
|
||||
exit 3
|
||||
`
|
||||
dockerStub := "#!" + bash + "\nprintf '%s\\n' \"$*\" >>\"$STUB_LOG/docker.calls\"\nexit 0\n"
|
||||
write := func(name, body string) {
|
||||
if err := os.WriteFile(filepath.Join(h.tools, name), []byte(body), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
write("go", goStub)
|
||||
write("docker", dockerStub)
|
||||
h.env = append(h.env, "SUMMER_STUB="+summerStub)
|
||||
h.remote = newFakeRemote(t, root, append(gitTestEnv(filepath.Join(root, "home")), "GIT_CEILING_DIRECTORIES="+root))
|
||||
return h
|
||||
}
|
||||
|
||||
// without removes a tool from the stub PATH.
|
||||
func (h *scriptHarness) without(tool string) {
|
||||
h.t.Helper()
|
||||
if err := os.Remove(filepath.Join(h.tools, tool)); err != nil {
|
||||
h.t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// run executes the script with args in h.work.
|
||||
func (h *scriptHarness) run(args ...string) scriptResult {
|
||||
h.t.Helper()
|
||||
path := h.tools
|
||||
if h.gobinOnPath {
|
||||
path += ":" + h.gobin
|
||||
}
|
||||
gobin := ""
|
||||
if h.useGOBIN {
|
||||
gobin = h.gobin
|
||||
}
|
||||
env := append(gitTestEnv(filepath.Join(h.root, "home")),
|
||||
"PATH="+path,
|
||||
"PWD="+h.work,
|
||||
"GIT_CEILING_DIRECTORIES="+h.root,
|
||||
"SUMMERCMS_REPO="+h.remote.bare,
|
||||
"STUB_LOG="+h.logs,
|
||||
"STUB_GOVERSION="+h.goVersion,
|
||||
"STUB_GOBIN="+gobin,
|
||||
"STUB_GOPATH="+h.gopath,
|
||||
)
|
||||
// The script must not depend on the harness identity: drop it.
|
||||
filtered := env[:0]
|
||||
for _, kv := range env {
|
||||
if strings.HasPrefix(kv, "GIT_AUTHOR_") || strings.HasPrefix(kv, "GIT_COMMITTER_") {
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, kv)
|
||||
}
|
||||
env = append(filtered, h.env...)
|
||||
cmd := exec.Command(h.bash, append([]string{h.script}, args...)...)
|
||||
cmd.Dir = h.work
|
||||
cmd.Env = env
|
||||
cmd.Stdin = strings.NewReader(stdinProbe)
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
err := cmd.Run()
|
||||
res := scriptResult{stdout: stdout.String(), stderr: stderr.String()}
|
||||
var exitErr *exec.ExitError
|
||||
switch {
|
||||
case err == nil:
|
||||
case errors.As(err, &exitErr):
|
||||
res.code = exitErr.ExitCode()
|
||||
default:
|
||||
h.t.Fatalf("run script: %v", err)
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
// mustRun runs the script and requires success.
|
||||
func (h *scriptHarness) mustRun(args ...string) scriptResult {
|
||||
h.t.Helper()
|
||||
res := h.run(args...)
|
||||
if res.code != 0 {
|
||||
h.t.Fatalf("script exit %d\nstdout:\n%s\nstderr:\n%s", res.code, res.stdout, res.stderr)
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
func (h *scriptHarness) checkout() string { return filepath.Join(h.work, "summercms") }
|
||||
|
||||
func (h *scriptHarness) head() string {
|
||||
h.t.Helper()
|
||||
return h.remote.git(h.checkout(), "rev-parse", "HEAD")
|
||||
}
|
||||
|
||||
func (h *scriptHarness) log(name string) string {
|
||||
data, err := os.ReadFile(filepath.Join(h.logs, name))
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return string(data)
|
||||
}
|
||||
|
||||
func (h *scriptHarness) exists(rel string) bool {
|
||||
_, err := os.Lstat(filepath.Join(h.work, rel))
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func expectCode(t *testing.T, res scriptResult, code int) {
|
||||
t.Helper()
|
||||
if res.code != code {
|
||||
t.Fatalf("exit %d, want %d\nstdout:\n%s\nstderr:\n%s", res.code, code, res.stdout, res.stderr)
|
||||
}
|
||||
}
|
||||
|
||||
func expectContains(t *testing.T, what, got string, wants ...string) {
|
||||
t.Helper()
|
||||
for _, want := range wants {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("%s lacks %q:\n%s", what, want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// expectNothingStarted asserts no clone and no go or summer call happened.
|
||||
func expectNothingStarted(t *testing.T, h *scriptHarness) {
|
||||
t.Helper()
|
||||
if h.exists("summercms") {
|
||||
t.Fatal("the framework was cloned")
|
||||
}
|
||||
if c := h.log("go.calls"); c != "" {
|
||||
t.Fatalf("go was called:\n%s", c)
|
||||
}
|
||||
if c := h.log("summer.call"); c != "" {
|
||||
t.Fatalf("summer was called:\n%s", c)
|
||||
}
|
||||
}
|
||||
|
||||
// expectRefusedUnchanged asserts a refusal with exit 1 that left HEAD at
|
||||
// before and never reached go install or summer.
|
||||
func expectRefusedUnchanged(t *testing.T, h *scriptHarness, res scriptResult, before string, msg string) {
|
||||
t.Helper()
|
||||
expectCode(t, res, 1)
|
||||
expectContains(t, "stderr", res.stderr, msg)
|
||||
if got := h.head(); got != before {
|
||||
t.Fatalf("HEAD moved from %s to %s", before, got)
|
||||
}
|
||||
if c := h.log("summer.call"); c != "" {
|
||||
t.Fatalf("summer was called after a refusal:\n%s", c)
|
||||
}
|
||||
}
|
||||
|
||||
// resetLogs clears stub records between runs of one case.
|
||||
func (h *scriptHarness) resetLogs() {
|
||||
h.t.Helper()
|
||||
for _, n := range []string{"go.calls", "go.install", "summer.call", "summer.stdin", "docker.calls"} {
|
||||
_ = os.Remove(filepath.Join(h.logs, n))
|
||||
}
|
||||
}
|
||||
|
||||
func TestScriptBehavior(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
run func(t *testing.T, h *scriptHarness)
|
||||
}{
|
||||
{"help prints usage and exits 0", func(t *testing.T, h *scriptHarness) {
|
||||
res := h.run("--help")
|
||||
expectCode(t, res, 0)
|
||||
expectContains(t, "stdout", res.stdout, "Usage: curl -fsSL https://summercms.io/new | bash -s -- <name>", "SUMMERCMS_REF")
|
||||
expectNothingStarted(t, h)
|
||||
}},
|
||||
{"no argument is a usage error", func(t *testing.T, h *scriptHarness) {
|
||||
res := h.run()
|
||||
expectCode(t, res, 2)
|
||||
expectContains(t, "stderr", res.stderr, "Usage:")
|
||||
expectNothingStarted(t, h)
|
||||
}},
|
||||
{"two arguments are a usage error", func(t *testing.T, h *scriptHarness) {
|
||||
res := h.run("demo", "extra")
|
||||
expectCode(t, res, 2)
|
||||
expectNothingStarted(t, h)
|
||||
}},
|
||||
{"upper-case name is rejected", func(t *testing.T, h *scriptHarness) {
|
||||
res := h.run("Demo")
|
||||
expectCode(t, res, 2)
|
||||
expectContains(t, "stderr", res.stderr, "invalid application name")
|
||||
expectNothingStarted(t, h)
|
||||
}},
|
||||
{"name with shell metacharacters is rejected", func(t *testing.T, h *scriptHarness) {
|
||||
res := h.run("demo;touch pwned")
|
||||
expectCode(t, res, 2)
|
||||
if h.exists("pwned") {
|
||||
t.Fatal("the name was executed")
|
||||
}
|
||||
expectNothingStarted(t, h)
|
||||
}},
|
||||
{"existing app stops before any clone", func(t *testing.T, h *scriptHarness) {
|
||||
if err := os.Mkdir(filepath.Join(h.work, "sm-demo-app"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
res := h.run("demo")
|
||||
expectCode(t, res, 1)
|
||||
expectContains(t, "stderr", res.stderr, "sm-demo-app already exists")
|
||||
expectNothingStarted(t, h)
|
||||
}},
|
||||
{"Go missing names go.dev/dl", func(t *testing.T, h *scriptHarness) {
|
||||
h.without("go")
|
||||
res := h.run("demo")
|
||||
expectCode(t, res, 1)
|
||||
expectContains(t, "stderr", res.stderr, "Go is not installed", "https://go.dev/dl/")
|
||||
expectNothingStarted(t, h)
|
||||
}},
|
||||
{"Go older than 1.27 is refused", func(t *testing.T, h *scriptHarness) {
|
||||
h.goVersion = "go1.26.3"
|
||||
res := h.run("demo")
|
||||
expectCode(t, res, 1)
|
||||
expectContains(t, "stderr", res.stderr, "found go1.26.3", "https://go.dev/dl/")
|
||||
if h.exists("summercms") {
|
||||
t.Fatal("cloned despite an old Go")
|
||||
}
|
||||
}},
|
||||
{"devel Go version only warns", func(t *testing.T, h *scriptHarness) {
|
||||
h.goVersion = "devel go1.28-0123456789ab"
|
||||
res := h.mustRun("demo")
|
||||
expectContains(t, "stderr", res.stderr, "warning: could not read the Go version")
|
||||
if h.log("summer.call") == "" {
|
||||
t.Fatal("summer was not called")
|
||||
}
|
||||
}},
|
||||
{"git missing is an error", func(t *testing.T, h *scriptHarness) {
|
||||
h.without("git")
|
||||
res := h.run("demo")
|
||||
expectCode(t, res, 1)
|
||||
expectContains(t, "stderr", res.stderr, "git is not installed")
|
||||
expectNothingStarted(t, h)
|
||||
}},
|
||||
{"Docker missing only warns", func(t *testing.T, h *scriptHarness) {
|
||||
h.without("docker")
|
||||
res := h.mustRun("demo")
|
||||
expectContains(t, "stderr", res.stderr, "warning: Docker is not installed")
|
||||
if h.log("summer.call") == "" {
|
||||
t.Fatal("summer was not called")
|
||||
}
|
||||
}},
|
||||
{"fresh clone at the newest release", func(t *testing.T, h *scriptHarness) {
|
||||
res := h.mustRun("demo")
|
||||
if got, want := h.head(), h.remote.tags["v0.10.0"]; got != want {
|
||||
t.Fatalf("HEAD = %s, want v0.10.0 %s (numeric order, pre-release ignored)", got, want)
|
||||
}
|
||||
cmd := exec.Command("git", "-C", h.checkout(), "symbolic-ref", "-q", "HEAD")
|
||||
if err := cmd.Run(); err == nil {
|
||||
t.Fatal("checkout is on a branch, want detached")
|
||||
}
|
||||
fw := filepath.Join(h.work, "summercms")
|
||||
call := h.log("summer.call")
|
||||
want := "cwd=" + h.work + "\narg=new\narg=demo\narg=--framework\narg=" + fw + "\n"
|
||||
if call != want {
|
||||
t.Fatalf("summer call:\n%s\nwant:\n%s", call, want)
|
||||
}
|
||||
if got := h.log("summer.stdin"); got != "" {
|
||||
t.Fatalf("summer read stdin %q", got)
|
||||
}
|
||||
if got, want := h.log("go.install"), "GOWORK=off dir="+fw+"\n"; got != want {
|
||||
t.Fatalf("go install = %q, want %q", got, want)
|
||||
}
|
||||
expectContains(t, "stdout", res.stdout, " cd "+filepath.Join(h.work, "sm-demo-app")+"\n")
|
||||
expectContains(t, "stderr", res.stderr, "framework target: v0.10.0")
|
||||
}},
|
||||
{"SUMMERCMS_REF selects a tag", func(t *testing.T, h *scriptHarness) {
|
||||
h.env = append(h.env, "SUMMERCMS_REF=v0.2.0")
|
||||
res := h.mustRun("demo")
|
||||
if got, want := h.head(), h.remote.tags["v0.2.0"]; got != want {
|
||||
t.Fatalf("HEAD = %s, want v0.2.0 %s", got, want)
|
||||
}
|
||||
expectContains(t, "stderr", res.stderr, "framework target: v0.2.0")
|
||||
}},
|
||||
{"SUMMERCMS_REF starting with a dash is refused", func(t *testing.T, h *scriptHarness) {
|
||||
h.env = append(h.env, "SUMMERCMS_REF=--upload-pack=x")
|
||||
res := h.run("demo")
|
||||
expectCode(t, res, 1)
|
||||
expectContains(t, "stderr", res.stderr, "SUMMERCMS_REF must be a tag or commit")
|
||||
if h.log("summer.call") != "" {
|
||||
t.Fatal("summer was called")
|
||||
}
|
||||
}},
|
||||
{"re-run moves forward to a new release", func(t *testing.T, h *scriptHarness) {
|
||||
h.mustRun("demo")
|
||||
next := h.remote.tag("v0.11.0")
|
||||
h.resetLogs()
|
||||
res := h.mustRun("demo")
|
||||
if got := h.head(); got != next {
|
||||
t.Fatalf("HEAD = %s, want v0.11.0 %s", got, next)
|
||||
}
|
||||
expectContains(t, "stderr", res.stderr, "updating the framework checkout", "framework target: v0.11.0")
|
||||
}},
|
||||
{"re-run at the newest release changes nothing", func(t *testing.T, h *scriptHarness) {
|
||||
h.mustRun("demo")
|
||||
before := h.head()
|
||||
h.resetLogs()
|
||||
res := h.mustRun("demo")
|
||||
if got := h.head(); got != before {
|
||||
t.Fatalf("HEAD moved from %s to %s", before, got)
|
||||
}
|
||||
expectContains(t, "stderr", res.stderr, "already at the target")
|
||||
}},
|
||||
{"tracked change refuses and is kept", func(t *testing.T, h *scriptHarness) {
|
||||
h.mustRun("demo")
|
||||
before := h.head()
|
||||
mod := filepath.Join(h.checkout(), "go.mod")
|
||||
edited := "module " + fwModule + "\n\ngo 1.27.0\n// local edit\n"
|
||||
if err := os.WriteFile(mod, []byte(edited), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h.resetLogs()
|
||||
res := h.run("demo")
|
||||
expectRefusedUnchanged(t, h, res, before, "uncommitted changes to tracked files")
|
||||
if got, _ := os.ReadFile(mod); string(got) != edited {
|
||||
t.Fatalf("go.mod changed: %q", got)
|
||||
}
|
||||
}},
|
||||
{"branch checkout refuses", func(t *testing.T, h *scriptHarness) {
|
||||
h.mustRun("demo")
|
||||
h.remote.git(h.checkout(), "switch", "-q", "-c", "mine")
|
||||
before := h.head()
|
||||
h.resetLogs()
|
||||
res := h.run("demo")
|
||||
expectRefusedUnchanged(t, h, res, before, "is on branch mine")
|
||||
if got := h.remote.git(h.checkout(), "symbolic-ref", "--short", "HEAD"); got != "mine" {
|
||||
t.Fatalf("branch = %s", got)
|
||||
}
|
||||
}},
|
||||
{"non-fast-forward refuses", func(t *testing.T, h *scriptHarness) {
|
||||
h.mustRun("demo")
|
||||
h.remote.git(h.checkout(), "commit", "-q", "--allow-empty", "-m", "local work")
|
||||
before := h.head()
|
||||
h.resetLogs()
|
||||
res := h.run("demo")
|
||||
expectRefusedUnchanged(t, h, res, before, "is not a fast-forward")
|
||||
}},
|
||||
{"moved upstream tag refuses", func(t *testing.T, h *scriptHarness) {
|
||||
h.mustRun("demo")
|
||||
before := h.head()
|
||||
moved := h.remote.moveTag("v0.10.0")
|
||||
h.resetLogs()
|
||||
res := h.run("demo")
|
||||
if res.code == 0 {
|
||||
t.Fatalf("script followed a moved tag\nstderr:\n%s", res.stderr)
|
||||
}
|
||||
if got := h.head(); got != before {
|
||||
t.Fatalf("HEAD moved from %s to %s", before, got)
|
||||
}
|
||||
if got := h.remote.git(h.checkout(), "rev-parse", "refs/tags/v0.10.0^{commit}"); got == moved || got != before {
|
||||
t.Fatalf("local v0.10.0 = %s, want the original %s (upstream moved to %s)", got, before, moved)
|
||||
}
|
||||
if h.log("summer.call") != "" {
|
||||
t.Fatal("summer was called")
|
||||
}
|
||||
}},
|
||||
{"another module in the directory refuses", func(t *testing.T, h *scriptHarness) {
|
||||
dir := h.checkout()
|
||||
if err := os.Mkdir(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "go.mod"), []byte("module example.com/other\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
res := h.run("demo")
|
||||
expectCode(t, res, 1)
|
||||
expectContains(t, "stderr", res.stderr, "is not a SummerCMS framework checkout", "example.com/other")
|
||||
if h.log("summer.call") != "" || h.log("go.install") != "" {
|
||||
t.Fatal("the script went on after refusing the directory")
|
||||
}
|
||||
}},
|
||||
{"framework directory that is not a git repository refuses", func(t *testing.T, h *scriptHarness) {
|
||||
dir := h.checkout()
|
||||
if err := os.Mkdir(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "go.mod"), []byte("module "+fwModule+"\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
res := h.run("demo")
|
||||
expectCode(t, res, 1)
|
||||
expectContains(t, "stderr", res.stderr, "is not a git repository")
|
||||
if _, err := os.Stat(filepath.Join(dir, ".git")); !os.IsNotExist(err) {
|
||||
t.Fatalf("the script created .git: %v", err)
|
||||
}
|
||||
}},
|
||||
{"GOBIN off PATH prints the hint", func(t *testing.T, h *scriptHarness) {
|
||||
res := h.mustRun("demo")
|
||||
expectContains(t, "stdout", res.stdout, "not on your PATH", `export PATH="`+h.gobin+`:$PATH"`)
|
||||
}},
|
||||
{"GOBIN on PATH prints no hint", func(t *testing.T, h *scriptHarness) {
|
||||
h.gobinOnPath = true
|
||||
res := h.mustRun("demo")
|
||||
if strings.Contains(res.stdout, "export PATH") || strings.Contains(res.stdout, "not on your PATH") {
|
||||
t.Fatalf("hint printed although GOBIN is on PATH:\n%s", res.stdout)
|
||||
}
|
||||
}},
|
||||
{"empty GOBIN installs into GOPATH/bin", func(t *testing.T, h *scriptHarness) {
|
||||
h.useGOBIN = false
|
||||
res := h.mustRun("demo")
|
||||
bin := filepath.Join(h.gopath, "bin")
|
||||
if _, err := os.Stat(filepath.Join(bin, "summer")); err != nil {
|
||||
t.Fatalf("summer not installed in GOPATH/bin: %v", err)
|
||||
}
|
||||
expectContains(t, "stderr", res.stderr, "installing summer into "+bin)
|
||||
expectContains(t, "stdout", res.stdout, `export PATH="`+bin+`:$PATH"`)
|
||||
}},
|
||||
}
|
||||
if len(cases) < 18 {
|
||||
t.Fatalf("only %d cases", len(cases))
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
tc.run(t, newScriptHarness(t))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestScriptShellcheck runs shellcheck on new.sh; it is skipped only when
|
||||
// shellcheck is not installed.
|
||||
func TestScriptShellcheck(t *testing.T) {
|
||||
sc, err := exec.LookPath("shellcheck")
|
||||
if err != nil {
|
||||
t.Skip("shellcheck not installed")
|
||||
}
|
||||
out, err := exec.Command(sc, "-s", "bash", "new.sh").CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("shellcheck -s bash new.sh: %v\n%s", err, out)
|
||||
}
|
||||
}
|
||||
23
modules/sunrise/sunrise.go
Normal file
23
modules/sunrise/sunrise.go
Normal file
@@ -0,0 +1,23 @@
|
||||
// Package sunrise embeds the SummerCMS bootstrap script, new.sh, so the
|
||||
// script is versioned with the framework release that ships it.
|
||||
//
|
||||
// The script is what `curl -fsSL https://summercms.io/new | bash -s -- <name>`
|
||||
// runs: it checks for Go 1.27 or newer, git and Docker, clones the framework
|
||||
// at its newest release tag (or moves an existing release checkout forward),
|
||||
// installs the summer CLI and runs `summer new <name>` next to the checkout.
|
||||
// A web server hands out Script() as text/plain.
|
||||
package sunrise
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
_ "embed"
|
||||
)
|
||||
|
||||
//go:embed new.sh
|
||||
var script []byte
|
||||
|
||||
// Script returns the bootstrap script. Each call returns a fresh copy, so a
|
||||
// caller may modify the slice without affecting later calls.
|
||||
func Script() []byte {
|
||||
return bytes.Clone(script)
|
||||
}
|
||||
216
modules/sunrise/sunrise_test.go
Normal file
216
modules/sunrise/sunrise_test.go
Normal file
@@ -0,0 +1,216 @@
|
||||
package sunrise
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestScriptEmbedded(t *testing.T) {
|
||||
want, err := os.ReadFile("new.sh")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := Script()
|
||||
if !bytes.Equal(got, want) {
|
||||
t.Fatal("Script() differs from new.sh")
|
||||
}
|
||||
for i := range got {
|
||||
got[i] = 'x'
|
||||
}
|
||||
if !bytes.Equal(Script(), want) {
|
||||
t.Fatal("modifying a returned slice changed the next Script() call")
|
||||
}
|
||||
}
|
||||
|
||||
func TestScriptShape(t *testing.T) {
|
||||
s := string(Script())
|
||||
lines := strings.Split(strings.TrimRight(s, "\n"), "\n")
|
||||
if lines[0] != "#!/usr/bin/env bash" {
|
||||
t.Fatalf("first line = %q, want the bash shebang", lines[0])
|
||||
}
|
||||
if last := lines[len(lines)-1]; last != `main "$@"` {
|
||||
t.Fatalf("last non-empty line = %q, want %q", last, `main "$@"`)
|
||||
}
|
||||
if strings.Count(s, "\nmain \"$@\"") != 1 {
|
||||
t.Fatal("main must be called exactly once, on the last line")
|
||||
}
|
||||
bash := lookBash(t)
|
||||
if out, err := exec.Command(bash, "-n", "new.sh").CombinedOutput(); err != nil {
|
||||
t.Fatalf("bash -n new.sh: %v\n%s", err, out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestScriptTruncationRunsNothing runs every prefix of the script that ends
|
||||
// on a line boundary and every byte prefix of its final line, as a cut-short
|
||||
// download would arrive, and asserts none of them invokes a command or
|
||||
// creates a file. A positive control proves the stubs do record calls.
|
||||
func TestScriptTruncationRunsNothing(t *testing.T) {
|
||||
bash := lookBash(t)
|
||||
stubs := t.TempDir()
|
||||
marker := filepath.Join(t.TempDir(), "invoked")
|
||||
for _, name := range []string{"git", "go", "docker", "curl", "summer"} {
|
||||
body := "#!/bin/sh\necho \"" + name + " $*\" >>'" + marker + "'\n"
|
||||
if err := os.WriteFile(filepath.Join(stubs, name), []byte(body), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
home := t.TempDir()
|
||||
scripts := t.TempDir()
|
||||
run := func(t *testing.T, prefix []byte) (work string) {
|
||||
t.Helper()
|
||||
file := filepath.Join(scripts, "new.sh")
|
||||
if err := os.WriteFile(file, prefix, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
work = t.TempDir()
|
||||
cmd := exec.Command(bash, file, "demo")
|
||||
cmd.Dir = work
|
||||
cmd.Env = []string{"PATH=" + stubs, "HOME=" + home, "PWD=" + work, "LC_ALL=C"}
|
||||
_ = cmd.Run()
|
||||
return work
|
||||
}
|
||||
|
||||
s := Script()
|
||||
body := bytes.TrimRight(s, "\n")
|
||||
lastStart := bytes.LastIndexByte(body, '\n') + 1
|
||||
var prefixes []int
|
||||
for i, c := range s[:lastStart] {
|
||||
if c == '\n' {
|
||||
prefixes = append(prefixes, i+1)
|
||||
}
|
||||
}
|
||||
prefixes = append([]int{0}, prefixes...)
|
||||
for k := 1; k < len(body)-lastStart; k++ {
|
||||
prefixes = append(prefixes, lastStart+k)
|
||||
}
|
||||
for _, n := range prefixes {
|
||||
work := run(t, s[:n])
|
||||
if out, err := os.ReadFile(marker); err == nil {
|
||||
t.Fatalf("prefix of %d bytes (ending %q) invoked: %s", n, tail(s[:n]), out)
|
||||
}
|
||||
if entries, err := os.ReadDir(work); err != nil || len(entries) != 0 {
|
||||
t.Fatalf("prefix of %d bytes (ending %q) changed the working directory: %v %v", n, tail(s[:n]), entries, err)
|
||||
}
|
||||
}
|
||||
if len(prefixes) < 50 {
|
||||
t.Fatalf("only %d prefixes tested", len(prefixes))
|
||||
}
|
||||
|
||||
run(t, s)
|
||||
if _, err := os.Stat(marker); err != nil {
|
||||
t.Fatalf("the complete script did not reach a stub, so the harness proves nothing: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func tail(b []byte) string {
|
||||
if len(b) > 30 {
|
||||
b = b[len(b)-30:]
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func lookBash(t *testing.T) string {
|
||||
t.Helper()
|
||||
bash, err := exec.LookPath("bash")
|
||||
if err != nil {
|
||||
t.Skip("bash not installed")
|
||||
}
|
||||
return bash
|
||||
}
|
||||
|
||||
// TestBootstrapEndToEnd serves Script() over HTTP, pipes it into bash with
|
||||
// curl the way the one-liner does, clones this repository at HEAD, installs
|
||||
// summer into a GOBIN that is not on PATH and builds the generated app. It
|
||||
// needs network access for Go modules and runs only with
|
||||
// SUMMERCMS_BOOTSTRAP_CHECK=1.
|
||||
func TestBootstrapEndToEnd(t *testing.T) {
|
||||
if os.Getenv("SUMMERCMS_BOOTSTRAP_CHECK") != "1" {
|
||||
t.Skip("set SUMMERCMS_BOOTSTRAP_CHECK=1 to run the bootstrap script end to end")
|
||||
}
|
||||
for _, tool := range []string{"bash", "curl", "git", "go"} {
|
||||
if _, err := exec.LookPath(tool); err != nil {
|
||||
t.Fatalf("%s is required: %v", tool, err)
|
||||
}
|
||||
}
|
||||
root, err := filepath.Abs(filepath.Join("..", ".."))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
head := gitOut(t, root, "rev-parse", "HEAD")
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
_, _ = w.Write(Script())
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
work := t.TempDir()
|
||||
gobin := t.TempDir()
|
||||
env := []string{"SUMMERCMS_REPO=" + root, "SUMMERCMS_REF=" + head, "GOBIN=" + gobin}
|
||||
for _, kv := range os.Environ() {
|
||||
key, _, _ := strings.Cut(kv, "=")
|
||||
switch {
|
||||
case strings.HasPrefix(key, "SUMMER_"), strings.HasPrefix(key, "SUMMERCMS_"),
|
||||
key == "GOWORK", key == "GOBIN", key == "PWD", key == "GOFLAGS":
|
||||
continue
|
||||
}
|
||||
env = append(env, kv)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(ctx, "bash", "-c", `set -o pipefail; curl -fsSL "$1" | bash -s -- demo`, "bash", srv.URL+"/new")
|
||||
cmd.Dir = work
|
||||
cmd.Env = append(env[:len(env):len(env)], "PWD="+work)
|
||||
out, err := cmd.CombinedOutput()
|
||||
t.Logf("bootstrap output:\n%s", out)
|
||||
if err != nil {
|
||||
t.Fatalf("curl | bash: %v", err)
|
||||
}
|
||||
|
||||
checkout := filepath.Join(work, "summercms")
|
||||
if got := gitOut(t, checkout, "rev-parse", "HEAD"); got != head {
|
||||
t.Fatalf("checkout HEAD = %s, want %s", got, head)
|
||||
}
|
||||
if err := exec.Command("git", "-C", checkout, "symbolic-ref", "-q", "HEAD").Run(); err == nil {
|
||||
t.Fatal("checkout is on a branch, want a detached HEAD")
|
||||
}
|
||||
app := filepath.Join(work, "sm-demo-app")
|
||||
gomod, err := os.ReadFile(filepath.Join(app, "go.mod"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(gomod), "replace git.golem15.com/golem15/summercms => ../summercms\n") {
|
||||
t.Fatalf("go.mod lacks the relative framework replace:\n%s", gomod)
|
||||
}
|
||||
if !strings.Contains(string(out), " cd "+app+"\n") {
|
||||
t.Fatalf("output lacks the cd line for %s", app)
|
||||
}
|
||||
if !strings.Contains(string(out), `export PATH="`+gobin+`:$PATH"`) {
|
||||
t.Fatalf("output lacks the PATH hint for %s", gobin)
|
||||
}
|
||||
|
||||
b := exec.CommandContext(ctx, filepath.Join(gobin, "summer"), "build")
|
||||
b.Dir = app
|
||||
b.Env = env
|
||||
if out, err := b.CombinedOutput(); err != nil {
|
||||
t.Fatalf("summer build in the generated app: %v\n%s", err, out)
|
||||
}
|
||||
}
|
||||
|
||||
func gitOut(t *testing.T, dir string, args ...string) string {
|
||||
t.Helper()
|
||||
out, err := exec.Command("git", append([]string{"-C", dir}, args...)...).Output()
|
||||
if err != nil {
|
||||
t.Fatalf("git %v in %s: %v", args, dir, err)
|
||||
}
|
||||
return strings.TrimSpace(string(out))
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user