Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
03553a9172 | ||
|
|
9b3803c098 | ||
|
|
27f75308d7 | ||
|
|
f70d80ec7e | ||
|
|
da21670870 | ||
|
|
4b5ed7707e | ||
|
|
50c8cee541 | ||
|
|
b9ef034272 | ||
|
|
9eb2a9237c | ||
|
|
14f6982c4d | ||
|
|
a6e2c399a5 | ||
|
|
ddf1178c51 | ||
|
|
8973da5cc1 |
@@ -5,10 +5,10 @@ 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_updated: "2026-10-08T14:24:54.530Z"
|
||||
last_activity: 2026-10-08
|
||||
last_activity_desc: Phase 14.3 execution started
|
||||
state_head: 9dac7b8f67e4345c93e778b17c50ab6fd399d343
|
||||
state_head: 50c8cee54129d8cf639e0893a1dca0b3dabd89bd
|
||||
progress:
|
||||
total_phases: 31
|
||||
completed_phases: 13
|
||||
@@ -31,7 +31,7 @@ See: .planning/PROJECT.md (updated 2026-09-16)
|
||||
Phase: 14.3 (One-command app scaffolding: summer new and summercms.io/new bootstrap script) — EXECUTING
|
||||
Plan: 4 of 4
|
||||
Status: Phase complete — ready for verification
|
||||
Last activity: 2026-10-08 — Phase 14.3 execution started
|
||||
Last activity: 2026-10-08 - Completed quick task 261008-o6k: summercms.io/update updates one application in place
|
||||
|
||||
Progress: [██████████] 100%
|
||||
|
||||
@@ -669,6 +669,10 @@ Recent decisions affecting current work:
|
||||
| 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/) |
|
||||
| 261008-k7s | Bootstrap script PATH hint per login shell (fish/zsh/bash) | 2026-10-08 | ddf1178 | [261008-k7s-bootstrap-script-path-hint-per-shell](./quick/261008-k7s-bootstrap-script-path-hint-per-shell/) |
|
||||
| 261008-kdf | summer delegates pass flags through, admin/route/key delegates, signal forwarding | 2026-10-08 | 50c8cee | [261008-kdf-delegated-summer-commands-pass-flags-thr](./quick/261008-kdf-delegated-summer-commands-pass-flags-thr/) |
|
||||
| 261008-kdh | serve prints URL and admin status; summer new apps admin-ready | 2026-10-08 | f70d80e | [261008-kdh-serve-startup-and-admin-feedback-admin-r](./quick/261008-kdh-serve-startup-and-admin-feedback-admin-r/) |
|
||||
| 261008-o6k | summercms.io/update: update one application in place (framework checkout, CLI, .env admin secret, build) | 2026-10-08 | 9b3803c | [261008-o6k-bootstrap-upgrade-mode-for-existing-chec](./quick/261008-o6k-bootstrap-upgrade-mode-for-existing-chec/) |
|
||||
|
||||
## Deferred Items
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ findings:
|
||||
title: "`TestTerminalCommands` passes the developer's `SUMMERCMS_DIR` through to the script"
|
||||
- id: WR-08
|
||||
severity: warning
|
||||
disposition: open
|
||||
disposition: fixed
|
||||
title: "The `--generator` gate stage stops `summer serve` but not the app binary it started"
|
||||
- id: IN-01
|
||||
severity: info
|
||||
|
||||
@@ -3,7 +3,7 @@ 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
|
||||
updated: 2026-10-08T12:10:00Z
|
||||
---
|
||||
|
||||
## Current Test
|
||||
@@ -30,22 +30,22 @@ 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]
|
||||
result: pass
|
||||
|
||||
### 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]
|
||||
result: pass
|
||||
|
||||
### 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]
|
||||
result: pass
|
||||
|
||||
## Summary
|
||||
|
||||
total: 6
|
||||
passed: 0
|
||||
passed: 3
|
||||
issues: 0
|
||||
pending: 6
|
||||
pending: 3
|
||||
skipped: 0
|
||||
blocked: 0
|
||||
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
---
|
||||
phase: quick-261008-k7s
|
||||
plan: 01
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
files_modified:
|
||||
- modules/sunrise/new.sh
|
||||
- modules/sunrise/script_behavior_test.go
|
||||
- modules/sunrise/sunrise_test.go
|
||||
- modules/sunrise/README.md
|
||||
- docs/setup/installation.md
|
||||
autonomous: true
|
||||
requirements: [QUICK-261008-k7s]
|
||||
|
||||
estimate:
|
||||
tokens: 45000
|
||||
raw_tokens: 45000
|
||||
tasks: 2
|
||||
confidence: low
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "With SHELL naming fish, the bootstrap script prints ` fish_add_path \"<gobin>\"` after a header saying fish_add_path also keeps it for new fish sessions, and prints no export line and no shell-profile advice."
|
||||
- "With SHELL naming zsh it prints ` export PATH=\"<gobin>:$PATH\"` and tells the user to add it to ~/.zshrc; with SHELL naming bash, the same line and ~/.bashrc."
|
||||
- "With SHELL empty, truly unset (main runs under set -u) or naming any other shell, the hint output is byte-identical to today's: the export line plus 'and add it to your shell profile'."
|
||||
- "$PATH stays literal in the printed export line; an install directory containing backslash, double quote, dollar, backtick or ! is escaped so the printed line, pasted into the named shell, adds exactly that directory."
|
||||
- "When the install directory is on PATH no hint is printed, whatever SHELL says."
|
||||
- "TestScriptBehavior is deterministic on any developer machine: the harness sets SHELL itself, because bash fills an unset SHELL from the passwd entry (on this host /usr/bin/fish)."
|
||||
- "The sunrise README and docs/setup/installation.md describe the per-shell hint, the manual PATH section shows `fish_add_path (go env GOPATH)/bin` for fish, and TestDocsTree plus `summer docs:build --check` pass."
|
||||
artifacts:
|
||||
- path: modules/sunrise/new.sh
|
||||
provides: "login-shell-aware PATH hint and a double-quote escaping helper"
|
||||
contains: "fish_add_path"
|
||||
- path: modules/sunrise/script_behavior_test.go
|
||||
provides: "per-shell hint cases (fish, zsh, bash, empty, unset, unknown) and metacharacter escaping cases"
|
||||
contains: "SHELL="
|
||||
- path: modules/sunrise/sunrise_test.go
|
||||
provides: "TestBootstrapEndToEnd pinned to SHELL=/bin/bash"
|
||||
contains: "SHELL=/bin/bash"
|
||||
- path: modules/sunrise/README.md
|
||||
provides: "per-shell hint description"
|
||||
contains: "fish_add_path"
|
||||
- path: docs/setup/installation.md
|
||||
provides: "per-shell hint in step 3 and fish variant in the manual PATH section"
|
||||
contains: "fish_add_path (go env GOPATH)/bin"
|
||||
key_links:
|
||||
- from: modules/sunrise/new.sh
|
||||
to: modules/sunrise/script_behavior_test.go
|
||||
via: "exact header notes and command lines asserted per SHELL value"
|
||||
pattern: "fish_add_path also keeps it for new fish sessions"
|
||||
- from: modules/sunrise/script_behavior_test.go
|
||||
to: "harness run() environment"
|
||||
via: "default SHELL= entry placed before h.env so per-case SHELL values win (exec.Cmd keeps the last duplicate)"
|
||||
pattern: "\"SHELL=\""
|
||||
- from: modules/sunrise/sunrise_test.go
|
||||
to: "scripts/check-phase14.3.sh acceptance grep"
|
||||
via: "both keep the export line shape for bash: export PATH=\"<dir>:$PATH\""
|
||||
pattern: "export PATH="
|
||||
---
|
||||
|
||||
<objective>
|
||||
Make the bootstrap script's closing PATH hint match the user's login shell. Under `curl | bash` the script always runs in bash, but `SHELL` still names the login shell, so `new.sh` reads the basename of `SHELL` and prints `fish_add_path "<gobin>"` for fish (which persists the entry itself), `export PATH="<gobin>:$PATH"` plus `~/.zshrc` for zsh or `~/.bashrc` for bash, and today's generic export line plus "your shell profile" for anything else, empty or unset. The install directory is escaped for the target shell's double quotes, so the printed line is safe to paste.
|
||||
|
||||
Purpose: a fish user who pastes today's `export PATH=...` line gets a session-only change and is told to edit a profile that fish does not read. The hint should be one correct line per shell.
|
||||
|
||||
Output: updated `modules/sunrise/new.sh`, its behaviour tests, the gated end-to-end test, the sunrise README and the installation docs page, all in one commit (project rule: code and its README/docs change together). No tag, push or deploy.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/gsd-core/workflows/execute-plan.md
|
||||
@~/.claude/gsd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/STATE.md
|
||||
@CLAUDE.md
|
||||
@modules/sunrise/new.sh
|
||||
@modules/sunrise/script_behavior_test.go
|
||||
@modules/sunrise/README.md
|
||||
@docs/setup/installation.md
|
||||
|
||||
Facts established while planning (do not re-derive):
|
||||
|
||||
- Bash fills an unset `SHELL` from the passwd entry: `env -u SHELL bash -c 'declare -p SHELL'` prints `declare -- SHELL="/usr/bin/fish"` on this host. The test harness builds the environment from scratch without `SHELL`, so without a harness default every case would see the developer's login shell and the existing `export PATH` assertions would fail on a fish machine. `SHELL=` (set, empty) survives: bash keeps an existing variable.
|
||||
- Go's `exec.Cmd` keeps the last value when `Env` holds a key twice, so a default appended before `h.env` is overridden by a per-case entry in `h.env`.
|
||||
- `scripts/check-phase14.3.sh` (acceptance and production stages, near line 306) greps `export PATH="/root/go/bin:$PATH"` from a node:24-bookworm container. There `SHELL` is not in the environment, so bash fills it from root's passwd entry (`/bin/bash`), and the bash branch prints exactly that export line. The gate keeps working unchanged; do not edit it. The site repo's TestTerminalCommands puts GOBIN first on PATH, so it never sees a hint.
|
||||
- `.planning/phases/14.3-.../14.3-SECURITY-REVIEW.md` says new.sh is shellcheck clean "except one deliberate SC2016 for the literal `$PATH` in the hint". Keep that true: no new shellcheck disable directives.
|
||||
- The `--forbidden` gate stage fails if new.sh contains the framework configuration prefix (the upper-case word SUMMER followed by an underscore). The script's own `SUMMERCMS_` names do not match it. Do not add new variable names with that prefix.
|
||||
- The docs checker only checks `pkg.Ident` code spans and `summer` / `./bin/<app>` commands in shell fences, so `fish_add_path`, `~/.zshrc` and a fish line inside an `sh` fence are safe. `sh` is the only shell fence language the docs use; do not add a `fish` fence.
|
||||
- macOS still ships bash 3.2 for `curl | bash`, and bash 5.2 turned on `patsub_replacement`, which changes how `&` and backslashes behave in the replacement part of the substitution expansion. A per-character loop behaves the same on both.
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tracer" tdd="true">
|
||||
<name>Task 1: Per-shell PATH hint in new.sh, proven end to end by the behaviour harness</name>
|
||||
<files>modules/sunrise/new.sh, modules/sunrise/script_behavior_test.go, modules/sunrise/sunrise_test.go</files>
|
||||
<behavior>
|
||||
- Harness default SHELL is empty: the hint is `\n==> summer is installed in <gobin>, which is not on your PATH. Run this (and add it to your shell profile):\n export PATH="<gobin>:$PATH"\n`, byte-identical to today's.
|
||||
- SHELL truly unset (harness option that unsets it inside bash before sourcing the script): same generic output, exit 0, no "unbound variable" error.
|
||||
- SHELL=/bin/tcsh (unknown): generic output.
|
||||
- SHELL=/usr/bin/fish: header note `fish_add_path also keeps it for new fish sessions`, command line ` fish_add_path "<gobin>"`; stdout lacks `export PATH` and `shell profile`.
|
||||
- SHELL=/bin/zsh: note `and add it to ~/.zshrc`, export line; lacks `fish_add_path`.
|
||||
- SHELL=/usr/local/bin/bash: note `and add it to ~/.bashrc`, export line; lacks `fish_add_path`.
|
||||
- Install dir containing a space, backslash, double quote, dollar, backtick and `!`, with SHELL=/bin/zsh: export line holds the POSIX-escaped dir; with SHELL=/usr/bin/fish: fish_add_path line holds the fish-escaped dir (backtick and `!` unescaped).
|
||||
- GOBIN on PATH with SHELL=/usr/bin/fish: no `export PATH`, no `fish_add_path`, no `not on your PATH`.
|
||||
</behavior>
|
||||
<action>
|
||||
In `modules/sunrise/new.sh`:
|
||||
|
||||
1. Add a function, defined above `main` like every other helper so the truncation guarantee holds (name it `dq_escape` or similar, with a one-line comment in the file's style). It prints its first argument escaped for use inside double quotes of the shell named by its second argument. For every shell: a backslash before each backslash, double quote and dollar sign. For any shell other than fish, also a backslash before each backtick, and each `!` replaced by close-quote, single-quoted `!`, reopen-quote (the three characters `"'!'"` around nothing else), so interactive bash and zsh do not history-expand a pasted line. Fish double quotes treat backtick and `!` literally, and a backslash before them would stay in the path, so the fish form leaves them alone. Build the result with a per-character loop over `${#s}` / `${s:i:1}` and a `case` on the character, not the substitution expansion (see the bash 3.2 / 5.2 fact in context). Only builtins, all locals declared `local`.
|
||||
|
||||
2. Replace the hint block at the end of `main` (the `if [[ :${PATH:-}: != *":$gobin:"* ]]` block; the PATH test itself stays as it is). Inside it: take the login shell name as the basename of `${SHELL:-}` by parameter expansion (`##*/`), always via `${SHELL:-}` because `main` runs under `set -u`. Choose a note and a form by a `case` on the name: `fish` gives the note `fish_add_path also keeps it for new fish sessions`; `zsh` gives `and add it to ~/.zshrc`; `bash` gives `and add it to ~/.bashrc`; anything else, empty included, gives `and add it to your shell profile`. Print the header with the existing format, `\n==> summer is installed in %s, which is not on your PATH. Run this (%s):\n`, fed the raw gobin and the note. Then for fish print ` fish_add_path "%s"\n` with the fish-escaped dir; for every other shell keep the single existing ` export PATH="%s:$PATH"\n` printf, now fed the POSIX-escaped dir, with its existing SC2016 disable comment and the "$PATH stays literal" comment. That stays the only disable directive in the file. Store notes in variables or format strings, never as a bare quoted word that starts with a tilde, because shellcheck SC2088 flags those.
|
||||
|
||||
3. In the header comment, after the environment overrides block, add one line saying the login shell named by SHELL only picks the form of the PATH hint printed at the end (fish, zsh, bash, or a generic export line). Leave the "Only bash builtins and the git, go and docker commands are used" sentence true.
|
||||
|
||||
In `modules/sunrise/script_behavior_test.go`:
|
||||
|
||||
4. In `run()`, add `"SHELL="` to the environment before `h.env` is appended, with a comment saying bash fills an unset SHELL from the passwd entry, so the default keeps cases independent of the developer's login shell and a case sets its own SHELL through `h.env`.
|
||||
|
||||
5. Add a harness field `unsetShell bool`. When it is true, `run()` starts bash with `-c` and a command string that unsets SHELL and then sources the script given as `$0` with the remaining arguments (`h.script` as `$0`, the case args after it), so the script runs with SHELL truly unset under `set -u`. All other `run()` behaviour (dir, env, stdin probe, result) stays the same.
|
||||
|
||||
6. Add two Go helpers: `expectLacks` (the mirror of `expectContains`) and a function that returns the expected escaped dir for the POSIX or the fish form, built with `strings.NewReplacer` using exactly the mapping from step 1. Use it for every expected hint line so the expectations do not depend on characters in the temp root.
|
||||
|
||||
7. Replace the "GOBIN off PATH prints the hint" case and add cases so the table covers each bullet in `<behavior>`: empty SHELL (harness default), unset (`h.unsetShell = true`), unknown (`/bin/tcsh`), fish, zsh, bash (`/usr/local/bin/bash`, a path that differs from the common default), and the two metacharacter cases. For the metacharacter cases, set `h.gobin` to a new dir under `h.root` whose name holds a space, backslash, double quote, dollar, backtick and `!`, create it with `os.MkdirAll` before `h.mustRun("demo")`, and assert the exact full command line, for example the export line with the escaped dir followed by `:$PATH"`. Extend "GOBIN on PATH prints no hint": set SHELL=/usr/bin/fish and also reject `fish_add_path`. "empty GOBIN installs into GOPATH/bin" keeps its export assertion (it runs with the empty default). Raise the `len(cases) < 18` floor to the new total.
|
||||
|
||||
In `modules/sunrise/sunrise_test.go` `TestBootstrapEndToEnd`:
|
||||
|
||||
8. Drop SHELL from the inherited environment (add `key == "SHELL"` to the skip list) and add `"SHELL=/bin/bash"` to the initial env slice, with a short comment that the hint form follows SHELL. Keep the existing `export PATH="`+gobin+`:$PATH"` assertion and also require `(and add it to ~/.bashrc):`.
|
||||
|
||||
Do not commit in this task. Task 2 commits code and docs together.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /media/nvme/dev/golem15/summercms.io/summercms/summercms.go && set -o pipefail && shellcheck -s bash modules/sunrise/new.sh && test "$(grep -c 'shellcheck disable' modules/sunrise/new.sh)" -eq 1 && bash -n modules/sunrise/new.sh && go vet ./modules/sunrise/ && go test ./modules/sunrise/ -short -count=1 -v -run '^(TestScriptEmbedded|TestScriptShape|TestScriptTruncationRunsNothing|TestScriptBehavior|TestScriptShellcheck)$' 2>&1 | grep -E '^(--- |ok|FAIL|panic)'</automated>
|
||||
</verify>
|
||||
<done>shellcheck prints nothing, the file has exactly one disable directive, and go vet passes. The go test output shows `--- PASS:` for TestScriptEmbedded, TestScriptShape, TestScriptTruncationRunsNothing, TestScriptBehavior and TestScriptShellcheck (not SKIP), with no `--- FAIL`. Every new TestScriptBehavior subtest passes, and the generic-hint output is byte-identical to the pre-change text.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: README and installation docs, full gates, single commit</name>
|
||||
<files>modules/sunrise/README.md, docs/setup/installation.md</files>
|
||||
<action>
|
||||
1. `modules/sunrise/README.md`:
|
||||
- Overview, the sentence ending "an `export PATH=...` hint": say that when the Go install directory is not on `PATH` the script ends with a hint for the login shell named by `SHELL`: `fish_add_path <dir>` for fish (which keeps it for new sessions), an `export PATH=...` line with `~/.zshrc` or `~/.bashrc` named for zsh and bash, and the generic `export PATH=...` line with "your shell profile" for any other shell.
|
||||
- Features: add one bullet for the login-shell-aware PATH hint, noting that the directory is escaped for the target shell's double quotes and `$PATH` is printed literally.
|
||||
- Configuration: the intro says the script reads three environment variables. Add that it also reads `SHELL`, only to pick the PATH hint form. Keep the `SUMMERCMS_` table as it is. Follow the README rules in CLAUDE.md: no consuming-application names, and every identifier named must exist.
|
||||
|
||||
2. `docs/setup/installation.md`:
|
||||
- Step 3 of "What the script does" (around line 33): replace "the script ends with the `export PATH=...` line to run and to add to your shell profile" with the per-shell description: the script reads your login shell from `SHELL` and ends with `fish_add_path <dir>` in fish, which keeps the directory for new sessions, or with `export PATH="<dir>:$PATH"`, to add to `~/.zshrc` in zsh, `~/.bashrc` in bash or your shell profile in any other shell.
|
||||
- "Install the summer CLI" (around lines 102-106): keep the `export PATH="$(go env GOPATH)/bin:$PATH"` fence for bash and zsh, saying to add it to `~/.bashrc` or `~/.zshrc` to keep it. Then add a fish sentence and a second `sh` fence containing `fish_add_path (go env GOPATH)/bin`, noting that fish keeps it for new sessions without a profile edit. Use `sh` as the fence language (see context).
|
||||
- Leave the frontmatter `description` unchanged.
|
||||
|
||||
3. Run the full gates (the verify command below). `go test ./... -short` can take several minutes, so run it with a generous timeout. Then confirm the gate's pinned hint still holds without editing the gate: `grep -nF 'export PATH="/root/go/bin:$PATH"' scripts/check-phase14.3.sh` still matches, and the bash branch of new.sh prints that shape (Task 1 covers this). Do not edit `scripts/check-phase14.3.sh` or `14.3-SECURITY-REVIEW.md`.
|
||||
|
||||
4. Optional, when network and time allow: `SUMMERCMS_BOOTSTRAP_CHECK=1 go test ./modules/sunrise/ -run TestBootstrapEndToEnd -count=1 -timeout 20m`. It is not part of the acceptance.
|
||||
|
||||
5. Make one commit holding exactly the five files in `files_modified`, for example `feat(sunrise): print the PATH hint for the user's login shell`, with a body naming the fish, zsh, bash and generic forms and the escaping. Add no co-author or attribution trailer (user and project rule). Do not tag, push or deploy.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /media/nvme/dev/golem15/summercms.io/summercms/summercms.go && grep -qF 'fish_add_path (go env GOPATH)/bin' docs/setup/installation.md && grep -qF 'fish_add_path' modules/sunrise/README.md && grep -qF '~/.zshrc' docs/setup/installation.md && grep -qF 'export PATH="/root/go/bin:$PATH"' scripts/check-phase14.3.sh && go vet ./... && go test ./cmd/summer -run '^TestDocsTree$' -count=1 && go run ./cmd/summer docs:build --check && bash scripts/check-phase14.3.sh --forbidden && go test ./... -short -count=1</automated>
|
||||
</verify>
|
||||
<done>go vet ./... and go test ./... -short are green. TestDocsTree passes, `summer docs:build --check` reports no problems, and the `--forbidden` stage prints "Phase 14.3 forbidden-surface scan passed". The README and installation page describe the fish, zsh, bash and generic forms, and the manual section shows `fish_add_path (go env GOPATH)/bin`. scripts/check-phase14.3.sh is unchanged. One commit holds the five files, with no co-author trailer. Nothing is tagged, pushed or deployed.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| script output → user's interactive shell | The user pastes the printed hint line into fish, zsh or bash, so whatever the line contains runs there. |
|
||||
| environment → script | `SHELL`, `GOBIN` and `GOPATH` come from the user's own environment and Go config. |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-k7s-01 | Elevation of Privilege | new.sh hint line (install dir interpolated into a pasted command) | medium | mitigate | The escaping helper backslash-escapes backslash, double quote and dollar for every shell, plus backtick for POSIX shells, and isolates `!` in single quotes for bash and zsh, so command substitution or history expansion in the dir cannot run when pasted. Pinned by the two metacharacter TestScriptBehavior cases. |
|
||||
| T-k7s-02 | Tampering | `SHELL` value | low | accept | Used only as a `case` pattern subject after basename stripping. It is never printed, executed or used as a path, and an unknown value falls back to the generic hint. |
|
||||
| T-k7s-03 | Denial of Service | `set -u` with SHELL unset | low | mitigate | Read via `${SHELL:-}`. Pinned by the unset-SHELL harness case. |
|
||||
| T-k7s-04 | Tampering | newline inside GOBIN/GOPATH | low | accept | Pathological Go configuration that the user controls. The printed line would wrap but stays quoted, and nothing is executed by the script itself. |
|
||||
| T-k7s-SC | Tampering | npm/pip/cargo installs | low | accept | This plan installs no packages and adds no Go module requirement. The `--forbidden` stage confirms go.mod gained no require. |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- `shellcheck -s bash modules/sunrise/new.sh` is clean, with exactly one disable directive (the literal `$PATH` SC2016).
|
||||
- `go vet ./...` and `go test ./... -short` are green. TestScriptBehavior covers the fish, zsh, bash, empty, unset and unknown shells, both metacharacter cases and the on-PATH no-hint case.
|
||||
- `go test ./cmd/summer -run TestDocsTree` and `go run ./cmd/summer docs:build --check` pass.
|
||||
- `bash scripts/check-phase14.3.sh --forbidden` passes, and the gate script is unchanged.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
A fish user running the one-liner with GOBIN off PATH sees `fish_add_path "<dir>"` and is told fish keeps it. zsh and bash users see the export line with `~/.zshrc` or `~/.bashrc`. Everyone else sees today's exact text. The tests are deterministic regardless of the developer's login shell, the docs match, and the change lands as one commit with no tag, push or deploy.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/quick/261008-k7s-bootstrap-script-path-hint-per-shell/261008-k7s-SUMMARY.md` when done
|
||||
</output>
|
||||
@@ -0,0 +1,76 @@
|
||||
---
|
||||
phase: quick-261008-k7s
|
||||
plan: 01
|
||||
subsystem: sunrise (bootstrap script)
|
||||
tags: [bootstrap, shell, path-hint, fish, docs]
|
||||
status: complete
|
||||
requires: []
|
||||
provides:
|
||||
- login-shell-aware PATH hint in modules/sunrise/new.sh
|
||||
- dq_escape double-quote escaping helper (POSIX and fish forms)
|
||||
affects:
|
||||
- scripts/check-phase14.3.sh acceptance grep (unchanged, still matches the bash branch)
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns:
|
||||
- "Test harness pins SHELL= by default because bash fills an unset SHELL from passwd"
|
||||
key-files:
|
||||
created: []
|
||||
modified:
|
||||
- modules/sunrise/new.sh
|
||||
- modules/sunrise/script_behavior_test.go
|
||||
- modules/sunrise/sunrise_test.go
|
||||
- modules/sunrise/README.md
|
||||
- docs/setup/installation.md
|
||||
decisions:
|
||||
- "Hint form is picked by the basename of ${SHELL:-}: fish -> fish_add_path, zsh/bash -> export line naming ~/.zshrc / ~/.bashrc, anything else (empty, unset, unknown) -> byte-identical generic hint"
|
||||
- "Install dir is escaped per character (no ${//} substitution) so bash 3.2 and 5.2 patsub_replacement behave the same; fish leaves backtick and ! literal"
|
||||
metrics:
|
||||
duration: ~20m
|
||||
completed: 2026-10-08
|
||||
actuals:
|
||||
tokens: 9000
|
||||
tasks: 2
|
||||
commits: 1
|
||||
plan_head_before: 8973da5cc1408ac0c0fdf1f1692309d189e1c67e
|
||||
plan_head_after: ddf1178c513c290221ca417f487e3da29d2190a1
|
||||
---
|
||||
|
||||
# Quick 261008-k7s Plan 01: Bootstrap script PATH hint per login shell Summary
|
||||
|
||||
The bootstrap script now reads the login shell from `SHELL` and ends with `fish_add_path "<dir>"` for fish, an `export PATH="<dir>:$PATH"` line naming `~/.zshrc` or `~/.bashrc` for zsh and bash, or today's exact generic hint for anything else. The directory is escaped for the target shell's double quotes.
|
||||
|
||||
## What was done
|
||||
|
||||
**Task 1 (tracer, TDD): per-shell hint in new.sh**
|
||||
- RED: added the per-shell and metacharacter cases to TestScriptBehavior. The fish, zsh, bash and both metacharacter cases failed against the old script. The generic, unset and on-PATH cases passed, so the generic text is byte-identical.
|
||||
- GREEN: added `dq_escape` above `main`. It is a per-character loop over `${s:i:1}` with a `case`. It always escapes `\`, `"` and `$`. For shells other than fish it also escapes the backtick and turns `!` into `"'!'"`. The hint block now does a `case` on `${SHELL:-}` stripped by `##*/`. The single SC2016 disable directive remains the only one in the file. The header comment notes that SHELL only picks the hint form.
|
||||
- Harness: `SHELL=` is placed before `h.env` so a per-case value wins. The new `unsetShell` mode runs `bash -c 'unset SHELL; . "$0" "$@"'`. New helpers: `expectLacks`, `hintDir` (a `strings.NewReplacer` with the same mapping), `hint`/`exportHint`/`fishHint`, `expectHintSuffix` and `metaGobin`. The case floor went from 18 to 32.
|
||||
- TestBootstrapEndToEnd drops the inherited SHELL, pins `SHELL=/bin/bash` and also requires `(and add it to ~/.bashrc):`.
|
||||
- Tracer gate: the verify command re-ran green. A manual paste test confirmed that interactive bash, with history expansion on, and fish both recover the exact directory ``/tmp/go bin\x"y$HOME`id`!z`` from the printed lines. zsh is not installed on this host. Its double-quote rules match bash for these characters, and the harness covers its output.
|
||||
|
||||
**Task 2: docs and gates**
|
||||
- sunrise README: the Overview sentence describes the per-shell hint. A new Features bullet covers the escaping and the literal `$PATH`. The Configuration intro notes that `SHELL` only picks the hint form.
|
||||
- docs/setup/installation.md: step 3 describes the per-shell hint. The manual PATH section names `~/.bashrc`/`~/.zshrc` and adds a fish `sh` fence with `fish_add_path (go env GOPATH)/bin`.
|
||||
- Gates: `go vet ./...`, TestDocsTree, `summer docs:build --check` ("no problems found"), `check-phase14.3.sh --forbidden` ("forbidden-surface scan passed") and `go test ./... -short -count=1` all passed. `scripts/check-phase14.3.sh` is unchanged and still contains `export PATH="/root/go/bin:$PATH"`, which matches the bash branch.
|
||||
|
||||
## Commits
|
||||
|
||||
- ddf1178: feat(sunrise): print the PATH hint for the user's login shell (all five files; no co-author trailer)
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
None. The plan was executed as written. The optional gated `SUMMERCMS_BOOTSTRAP_CHECK=1` end-to-end run (step 4 of Task 2, not part of acceptance) was not run.
|
||||
|
||||
## Known Stubs
|
||||
|
||||
None.
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
- FOUND: modules/sunrise/new.sh (contains fish_add_path, dq_escape; one shellcheck disable)
|
||||
- FOUND: modules/sunrise/script_behavior_test.go (contains "SHELL=")
|
||||
- FOUND: modules/sunrise/sunrise_test.go (contains SHELL=/bin/bash)
|
||||
- FOUND: modules/sunrise/README.md (contains fish_add_path)
|
||||
- FOUND: docs/setup/installation.md (contains fish_add_path (go env GOPATH)/bin)
|
||||
- FOUND: commit ddf1178
|
||||
@@ -0,0 +1,247 @@
|
||||
---
|
||||
phase: quick-261008-kdf
|
||||
plan: 01
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
files_modified:
|
||||
- modules/bonfire/command.go
|
||||
- modules/bonfire/root.go
|
||||
- modules/bonfire/command_test.go
|
||||
- modules/bonfire/README.md
|
||||
- docs/console/writing-commands.md
|
||||
- cmd/summer/runtime.go
|
||||
- cmd/summer/runtime_test.go
|
||||
- cmd/summer/main.go
|
||||
- cmd/summer/main_test.go
|
||||
- docs/console/introduction.md
|
||||
- docs/console/setup-and-maintenance.md
|
||||
- README.md
|
||||
autonomous: true
|
||||
requirements: [QUICK-261008-kdf]
|
||||
|
||||
estimate:
|
||||
tokens: 70000
|
||||
raw_tokens: 70000
|
||||
tasks: 3
|
||||
confidence: low
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "From an application directory, `summer serve --addr 127.0.0.1:8123` runs `bin/<app> serve --addr 127.0.0.1:8123`; summer no longer fails with `unknown flag: --addr`."
|
||||
- "`summer serve --help` and `summer serve -h` print the app binary's own serve help (it lists `--addr`), not summer's cobra help."
|
||||
- "The old workaround `summer serve -- --addr :8123` still runs `bin/<app> serve --addr :8123`: one leading `--` is dropped."
|
||||
- "`summer migrate:rollback --plugin acme.blog`, `summer queue:work --queue a --queue b` and `summer schedule:run --once` reach the binary as given, and so does any flag the binary adds later."
|
||||
- "`summer admin:create --email you@example.com --superuser`, `summer admin:reset-password <identifier>`, `summer route:list` and `summer key:generate` delegate to the app binary, and the hidden password prompt still works because the child inherits summer's stdin."
|
||||
- "`summer --help` marks every delegated command with `(flags pass to the app binary)`, and the cobra help command for a delegate prints the long text with its common flags."
|
||||
- "When summer gets SIGINT or SIGTERM while a delegated command runs, it sends the same signal to the app binary and waits for it to exit, so stopping `summer serve` frees the port. A context cancel sends SIGTERM and kills the child only after a grace period."
|
||||
- "bonfire commands without `Passthrough` parse flags exactly as before (an unknown flag is still rejected). `Passthrough` and `Long` are additive fields."
|
||||
artifacts:
|
||||
- path: modules/bonfire/command.go
|
||||
provides: "Command.Passthrough and Command.Long fields"
|
||||
- path: modules/bonfire/root.go
|
||||
provides: "wrap() maps Passthrough to cobra DisableFlagParsing + ArbitraryArgs and Long to cobra Long"
|
||||
- path: cmd/summer/runtime.go
|
||||
provides: "delegateCommands() table, delegateCommand passthrough builder, appRunner seam, newAppCmd (Cancel/WaitDelay), runChild (signal forwarding)"
|
||||
- path: cmd/summer/runtime_test.go
|
||||
provides: "delegate argv tests via the appRunner seam, help-text tests, helper-process signal-forwarding tests"
|
||||
key_links:
|
||||
- from: modules/bonfire/root.go wrap()
|
||||
to: cobra.Command.DisableFlagParsing
|
||||
via: "Command.Passthrough"
|
||||
- from: cmd/summer/runtime.go delegateCommand Run
|
||||
to: runAppBinary
|
||||
via: "appRunner package var (test seam)"
|
||||
- from: cmd/summer/runtime.go runAppBinary
|
||||
to: runChild
|
||||
via: "signal.Notify channel for os.Interrupt and syscall.SIGTERM"
|
||||
- from: cmd/summer/main.go toolCommands
|
||||
to: delegateCommands()
|
||||
via: "append"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Make summer's delegated runtime commands pass every flag through to the app binary, add the admin, route and key commands as delegates, and forward SIGINT/SIGTERM to the app binary that summer starts.
|
||||
|
||||
Purpose: `summer serve --addr :8123` fails with "unknown flag: --addr" today, because `delegateCommand` in cmd/summer/runtime.go builds a bonfire.Command without flags and cobra rejects the flag before it reaches `runAppBinary`. `summer serve --help` shows only `-h`. Also, `kill <summer pid>` leaves the app binary running and holding the port (finding WR-08 of the 14.3 code review).
|
||||
|
||||
Output: an additive `bonfire.Command.Passthrough` / `bonfire.Command.Long` API, passthrough delegates for serve, migrate, migrate:rollback, migrate:status, queue:work, queue:clear, schedule:run, route:list, key:generate, admin:create and admin:reset-password, signal forwarding in `runAppBinary`, tests, and updated READMEs and console docs.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/gsd-core/workflows/execute-plan.md
|
||||
@~/.claude/gsd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/STATE.md
|
||||
@CLAUDE.md
|
||||
@modules/bonfire/command.go
|
||||
@modules/bonfire/root.go
|
||||
@modules/bonfire/call.go
|
||||
@modules/bonfire/README.md
|
||||
@cmd/summer/runtime.go
|
||||
@cmd/summer/main.go
|
||||
@cmd/summer/main_test.go
|
||||
@docs/console/introduction.md
|
||||
|
||||
Facts gathered during planning (no need to re-derive):
|
||||
- cobra is v1.10.2. When `DisableFlagParsing` is true, `ParseFlags` returns early, so the help flag stays false and never returns `flag.ErrHelp`. `execute` then passes the raw args (`argWoFlags = a`) to `ValidateArgs` and `RunE`, and `ValidateRequiredFlags` is skipped. As a result `--help` and `-h` reach Run. cobra's `help <cmd>` command still renders `Long` (or `Short`) plus the usage.
|
||||
- The app binary's real commands: `serve` with `--addr` (default `:8080`) in modules/surf/serve.go; `migrate`, `migrate:rollback --plugin`, `migrate:status`, `deferred:purge` and `key:generate` in modules/lagoon/commands.go and keygen.go; `queue:work --queue` (repeatable), `schedule:run --once` and `queue:clear [queue]` in modules/conga/commands.go; `route:list` in modules/surf/routelist_command.go; `admin:create --email --login --role --superuser --password(deprecated)` and `admin:reset-password <identifier>` in modules/cabana/commands.go. The generated app main (internal/build/build.go) always registers all of them. There is no `auth:login` command, so no delegate is added for it.
|
||||
- serve, queue:work and the scheduler shut down on `signal.NotifyContext(os.Interrupt, SIGTERM)` with a 10-second timeout. A second SIGINT during shutdown is swallowed, not fatal, because Notify stays registered until `stop()`.
|
||||
- summer's `main` calls `root.Execute()` with no context, so `cmd.Context()` is Background and `exec.CommandContext` never cancels today.
|
||||
- `runSummer(t, args...)` in cmd/summer/docs_test.go runs the summer root against a buffer. Reuse it.
|
||||
- The docs checker (`internal/docsite/check_commands.go`) validates the command word after `summer` / `./bin/<app>` in shell fences AND inline code spans. `help` is not a summer command, so never write `summer help <cmd>` in a code span or shell fence; describe it in prose. New delegate names become valid tool commands automatically, because `docsCommands()` reads `toolCommands()`.
|
||||
- No test in cmd/summer or modules/bonfire uses `t.Parallel`, so a package-level test seam var is safe.
|
||||
- examples/hello is an app directory (`summer.yaml`, `binary: hello`, `bin/hello` present), so it can serve as an end-to-end target.
|
||||
- `summer dev` (internal/dev/watch.go `startBin`) starts `bin/<app>` with NO subcommand and does not use `runAppBinary`. It is out of scope here: report it in the SUMMARY as a follow-up and do not change internal/dev. `docs:serve` runs in-process and already uses `signal.NotifyContext`.
|
||||
|
||||
Concurrent work (keep out of these files): 261008-k7s edits modules/sunrise/* and docs/setup/installation.md, so do NOT edit docs/setup/installation.md even though its line 186 mentions `summer serve`. 261008-kdh runs AFTER this plan and edits modules/surf/serve.go, modules/cabana and internal/build. This plan edits only the `toolCommands` list region of cmd/summer/main.go, not `newCommand`.
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tracer">
|
||||
<name>Task 1: Tracer: bonfire Passthrough/Long, and `summer serve --addr :8123` reaches the app runner</name>
|
||||
<files>modules/bonfire/command.go, modules/bonfire/root.go, modules/bonfire/command_test.go, modules/bonfire/README.md, docs/console/writing-commands.md, cmd/summer/runtime.go, cmd/summer/runtime_test.go</files>
|
||||
<behavior>
|
||||
- bonfire: a `Command{Name: "serve", Passthrough: true}` run through `NewRootIO` with args `serve --addr :8123 -p --help -h -- x` calls Run with `Input.Args()` equal to `--addr :8123 -p --help -h -- x` in order, returns nil, and prints no cobra usage or help.
|
||||
- bonfire: the same passthrough command called through `bonfire.Call(ctx, cmds, "serve", []string{"--addr", ":8123", "--help"}, &buf)` receives exactly those three args.
|
||||
- bonfire: a passthrough command that declares a required `Arg` accepts zero args (passthrough uses arbitrary args, and the declared Args only shape the usage string).
|
||||
- bonfire regression: a command WITHOUT Passthrough (`Name: "migrate"`) given `--addr :8123` still fails with an error containing `unknown flag: --addr`.
|
||||
- bonfire: `help serve` on a command with `Long: "Long text"` prints "Long text". Without Long it prints the Description, as before.
|
||||
- summer: with `appRunner` replaced by a capturing func, `runSummer(t, "serve", "--addr", ":8123")` captures argv `serve --addr :8123`. `serve --help` gives `serve --help`, `serve -h` gives `serve -h`, `serve --addr=:8123` gives `serve --addr=:8123`, and `serve -- --addr :8123` gives `serve --addr :8123` (leading `--` dropped).
|
||||
</behavior>
|
||||
<action>
|
||||
bonfire (additive only, because many apps use this core module; existing fields, defaults and behaviour do not change):
|
||||
- In modules/bonfire/command.go, add two fields to `Command` after `Description`. `Long string`: optional extended help that cobra shows for `help <command>`; when empty, cobra falls back to Description. `Passthrough bool`: when true, cobra does not parse flags, so every argument after the command name, including `--help`, `-h` and `--`, reaches Run unchanged through `Input.Args()`. Its doc comment also says that declared Flags are registered for the usage text only and never parsed (Input.Flag returns only their defaults), and that declared Args shape the usage string but are not count-checked, so `Input.Argument` indexes the raw args. The field is meant for wrapper commands that forward to another program.
|
||||
- In modules/bonfire/root.go `wrap()`, set `Long: c.Long` on the cobra command. When `c.Passthrough` is true, set `DisableFlagParsing = true` and use `cobra.ArbitraryArgs` instead of `positionalArgs(c.Args)`. Leave the flag registration loop unchanged.
|
||||
- Add the behaviour tests above to modules/bonfire/command_test.go as plain `func Test...(t *testing.T)` with `NewRootIO`, `bytes.Buffer` streams and a closure that captures `in.Args()`.
|
||||
- In modules/bonfire/README.md (same commit, per project rule), add a Features bullet saying that passthrough commands (`bonfire.Command.Passthrough`) skip flag parsing so every argument, including `--help`, reaches `bonfire.Input.Args` unchanged, which suits commands that forward to another program, and that `bonfire.Command.Long` adds extended help text. Extend the `bonfire.Command` row of the API reference table to mention `bonfire.Command.Long` and `bonfire.Command.Passthrough`. The Dependencies section does not change (no new imports).
|
||||
- In docs/console/writing-commands.md, add to the "Defining a command" bullet list one bullet on `bonfire.Command.Long` and `bonfire.Command.Passthrough`, with the same meaning and no consuming-application names.
|
||||
- Commit 1: `feat(bonfire): add Passthrough and Long command fields` (bonfire files + writing-commands.md only).
|
||||
|
||||
summer (the first consumer):
|
||||
- In cmd/summer/runtime.go, add the package-level test seam `var appRunner = runAppBinary` with a one-line comment that tests replace it. Rewrite `delegateCommand(name, description string)` so it returns a Command with `Passthrough: true` and a Run that takes `in.Args()`, drops exactly one leading `--` (this keeps the documented `summer serve -- --addr` workaround working, because otherwise the app binary would receive `--` and treat `--addr` as positional), prepends `name`, and calls `appRunner(ctx, argv, out)`. Leave delegateRollbackCommand, delegateQueueWorkCommand and delegateScheduleRunCommand alone in this task; Task 2 replaces them.
|
||||
- Create cmd/summer/runtime_test.go with `TestDelegatedServePassesFlags`, a table over the summer cases in the behavior block. It swaps `appRunner` for a capturing func, restores it with `t.Cleanup`, and calls `runSummer`.
|
||||
- Commit 2: `fix(summer): pass delegated command flags to the app binary` (runtime.go + runtime_test.go). Task 2 adds the docs/console/introduction.md update for this behaviour. If the reviewer insists that docs ship in the same commit, move the introduction.md paragraph from Task 2 into this commit.
|
||||
- Commit messages carry no Co-Authored-By or other co-author trailer (user rule).
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /media/nvme/dev/golem15/summercms.io/summercms/summercms.go && go vet ./modules/bonfire/... ./cmd/summer/... && go test ./modules/bonfire/... -short && go test ./cmd/summer -short -run 'TestDelegatedServePassesFlags|TestToolCommandNames' && cd examples/hello && go run ../../cmd/summer serve --help 2>&1 | grep -e '--addr'</automated>
|
||||
</verify>
|
||||
<done>bonfire passthrough and Long behaviour is tested and documented. `summer serve --help` run in examples/hello prints the app binary's serve help, including `--addr`. `summer serve --addr :8123` gives the runner argv `serve --addr :8123`. Two commits exist, and non-passthrough commands behave as before.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 2: Every delegate passes flags through, add admin/route/key delegates, help text and docs</name>
|
||||
<files>cmd/summer/runtime.go, cmd/summer/runtime_test.go, cmd/summer/main.go, cmd/summer/main_test.go, docs/console/introduction.md, docs/console/setup-and-maintenance.md, README.md</files>
|
||||
<behavior>
|
||||
- With `appRunner` captured, each delegate passes its args through unchanged: `migrate:rollback --plugin acme.blog`, `queue:work --queue a --queue b`, `schedule:run --once`, `queue:clear emails`, `migrate`, `migrate:status --help`, `route:list`, `key:generate`, `admin:create --email you@example.com --superuser` and `admin:reset-password admin@example.com` each give argv equal to the name followed by the args.
|
||||
- For every delegate name, the cobra help command (`runSummer(t, "help", name)`) prints text that contains "passed to the app binary". It also contains the flag hint where one exists: migrate:rollback `--plugin`, queue:work `--queue`, schedule:run `--once`, serve `--addr`, admin:create `--email` and `--superuser`.
|
||||
- `runSummer(t, "--help")` lists the delegates with "(flags pass to the app binary)".
|
||||
- TestToolCommandNames still passes. It no longer checks `schedule:run --help` locally (that now goes to the app binary), and it expects route:list, key:generate, admin:create and admin:reset-password in the tool command list.
|
||||
</behavior>
|
||||
<action>
|
||||
- In cmd/summer/runtime.go, add `delegateCommands() []bonfire.Command`, built from one table of (name, base description, common-flags hint) rows. It is the single source of every delegate:
|
||||
migrate | Run plugin migrations in the app binary | (none);
|
||||
migrate:rollback | Roll back the last migration of a plugin in the app binary | --plugin <id>;
|
||||
migrate:status | Show per-plugin migration history in the app binary | (none);
|
||||
serve | Run the app HTTP server | --addr <host:port> (default :8080);
|
||||
queue:work | Run background job workers in the app binary | --queue <name> (repeatable);
|
||||
queue:clear | Clear pending queued jobs in the app binary | [queue] argument (default "default");
|
||||
schedule:run | Run the scheduler in the app binary (or once for system cron) | --once;
|
||||
route:list | List the app's routes from the app binary | (none);
|
||||
key:generate | Print a fresh application key from the app binary | (none);
|
||||
admin:create | Create a backend administrator in the app binary | --email <email> (required), --login, --role <code>, --superuser; the password is prompted or read from stdin;
|
||||
admin:reset-password | Reset a backend administrator's password in the app binary | <identifier> (login or email); the password is prompted or read from stdin.
|
||||
Give `delegateCommand` a third parameter for the hint. It sets Description to the base text plus " (flags pass to the app binary)". It sets Long to the base text with a final period, then a blank line and: "summer runs bin/<binary> <name> in the application directory, building the binary first when it is missing. Every flag and argument after <name>, including --help, is passed to the app binary unchanged." Then, only when the hint is non-empty, "Common flags: <hint>." Then "Run summer <name> --help inside an application to list all of them." Delete delegateRollbackCommand, delegateQueueWorkCommand and delegateScheduleRunCommand, since passthrough forwards `--plugin`, `--queue` and `--once` verbatim and the app binary defines them. Remove the `strconv` import if it becomes unused. Per the coordinator's scope addition, do NOT add auth:login (the app binary has none) or deferred:purge (not requested); list both as notes in the SUMMARY.
|
||||
- In cmd/summer/main.go `toolCommands`, remove the seven inline delegate entries and append `delegateCommands()...` to the returned slice. cobra sorts the help listing, so order does not matter. Touch only this region of main.go.
|
||||
- In cmd/summer/main_test.go `TestToolCommandNames`, add route:list, key:generate, admin:create and admin:reset-password to the expected-names list, and remove the `"schedule:run": {"--once"}` entry from `helpWants`. The new help test in runtime_test.go covers `--once`.
|
||||
- In cmd/summer/runtime_test.go, add `TestDelegateCommandsPassArgs` (table over the behavior cases) and `TestDelegateCommandsHelp` (help text plus the root list marker), using the capturing `appRunner` swap and `runSummer`.
|
||||
- In docs/console/introduction.md:
|
||||
(a) In "Getting help", add that a delegated command answers `--help` with the app binary's own help, so you run it inside an application. Do not write `summer help <cmd>` in a code span or fence; the docs checker rejects `help` as a command.
|
||||
(b) In "Commands that summer delegates", add table rows for `summer route:list`, `summer key:generate`, `summer admin:create` and `summer admin:reset-password`. Add a paragraph saying summer parses no flags of these commands: every flag and argument, including `--help`, goes to the binary unchanged. Follow it with a sh fence showing `summer serve --addr 127.0.0.1:8123`, `summer serve --help`, `summer migrate:rollback --plugin acme.blog` and `summer admin:create --email admin@example.com --superuser`. Also mention that one leading `--` is ignored, so `summer serve -- --addr 127.0.0.1:8123` behaves the same.
|
||||
(c) Rewrite the final sentence of the paragraph at line 43 so that "every other runtime command, such as `deferred:purge` or the commands your plugins add, is run on the binary directly", and drop route:list, key:generate and admin:create from that list.
|
||||
- In docs/console/setup-and-maintenance.md, after the paragraph about `--addr` (around line 66), add one sentence: from the application directory, `summer serve --addr 127.0.0.1:8123` passes the flag through the built binary. After the admin paragraph (around line 80), add one sentence: `summer admin:create --email admin@example.com --superuser` runs the same command, prompt included, from the application directory.
|
||||
- In the root README.md (line 76), replace the sentence listing delegates. The new sentence says that `summer migrate`, `summer migrate:status`, `summer migrate:rollback`, `summer serve`, `summer queue:work`, `summer queue:clear`, `summer schedule:run`, `summer route:list`, `summer key:generate`, `summer admin:create` and `summer admin:reset-password`, run from the application directory, delegate to the built binary in `bin/` and pass their flags to it unchanged.
|
||||
- Use only neutral names (acme, admin@example.com) in docs and no consuming-application names.
|
||||
- Commit: `fix(summer): delegate admin, route and key commands and pass all delegate flags through` (code, tests and docs together, per project rule; no co-author trailer).
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /media/nvme/dev/golem15/summercms.io/summercms/summercms.go && go vet ./cmd/summer/... && go test ./cmd/summer -short -run 'TestDelegate|TestToolCommandNames|TestDocsTree|TestDocsCommandNames' && go run ./cmd/summer docs:build --check</automated>
|
||||
</verify>
|
||||
<done>All eleven delegates are passthrough, generated from one table. The four new delegates exist. The help output and the `summer --help` list say flags pass to the app binary. The console docs, the setup-and-maintenance page and the root README describe the behaviour. The docs checker passes, and docs/setup/installation.md is untouched.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 3: Forward SIGINT/SIGTERM from summer to the app binary (WR-08)</name>
|
||||
<files>cmd/summer/runtime.go, cmd/summer/runtime_test.go, docs/console/introduction.md</files>
|
||||
<behavior>
|
||||
- A helper child (this test binary re-executed with `-test.run=^TestSignalHelperProcess$` and env `SUMMER_SIGNAL_HELPER=1`) registers `signal.NotifyContext(os.Interrupt, syscall.SIGTERM)`, prints `ready`, and on the signal prints `graceful shutdown` and exits 0. It exits 3 after 20s if no signal arrives. When the env var is unset, the helper test just returns.
|
||||
- `runChild(cmd, sigs)` with a test-owned `chan os.Signal`: after `ready` appears, sending `os.Interrupt` into the channel makes the child print `graceful shutdown`, and runChild returns nil within 10s. The same holds for `syscall.SIGTERM` (table-driven over both).
|
||||
- `newAppCmd(ctx, ...)` pointed at the helper: cancelling ctx after `ready` makes the child print `graceful shutdown` (it got SIGTERM, not SIGKILL), and the returned error satisfies `errors.Is(err, context.Canceled)` (exec reports the context error after a custom Cancel).
|
||||
- All signal tests skip when `runtime.GOOS == "windows"`. They are fast (under 1s each) and run under `-short`.
|
||||
</behavior>
|
||||
<action>
|
||||
- In cmd/summer/runtime.go, split process start-up out of `runAppBinary`, which keeps its app-dir, manifest, auto-build and recursion checks unchanged:
|
||||
(1) `newAppCmd(ctx context.Context, bin, dir string, argv []string, out io.Writer) *exec.Cmd` builds `exec.CommandContext` with Dir, Stdout and Stderr set to out, `Stdin = os.Stdin` (keep this: admin:create's hidden password prompt needs the inherited terminal), and `Env = os.Environ()`. It sets `cmd.Cancel` to send `syscall.SIGTERM` to `cmd.Process` and `cmd.WaitDelay` to a named const `appShutdownGrace = 15 * time.Second`, longer than the 10-second shutdown timeout of serve and the workers, so a context cancel triggers a graceful shutdown before the SIGKILL fallback.
|
||||
(2) `runChild(cmd *exec.Cmd, sigs <-chan os.Signal) error` starts the cmd, waits in a goroutine, and loops. On each received signal it calls `cmd.Process.Signal(sig)`, ignoring `os.ErrProcessDone` and platform "not supported" errors, and keeps waiting. When Wait returns, it returns Wait's error. It forwards every signal and does not escalate to a kill.
|
||||
(3) `runAppBinary` creates `sigs := make(chan os.Signal, 2)` and calls `signal.Notify(sigs, os.Interrupt, syscall.SIGTERM)` only after the build and recursion checks, right before `runChild`, with `defer signal.Stop(sigs)`. It wraps errors as today (`summer: <argv>: %w`).
|
||||
Add a doc comment on runChild explaining two points. First, Ctrl+C in a terminal delivers SIGINT to both summer and the child (same foreground process group), so the child may see SIGINT twice; the app's NotifyContext-based shutdown tolerates that. Second, the child is deliberately NOT given its own process group (`Setpgid`), because a background process group reading the terminal gets SIGTTIN and would break the password prompts of admin:create and admin:reset-password.
|
||||
- In cmd/summer/runtime_test.go, add `TestSignalHelperProcess` (the helper) and `TestRunChildForwardsSignals` and `TestNewAppCmdCancelSendsSIGTERM` per the behavior block. Capture child output with a small mutex-guarded buffer type defined in the test file and poll for `ready` with a 5s deadline. Do not send real signals to the test process.
|
||||
- In docs/console/introduction.md "Commands that summer delegates", add one sentence: when `summer` receives SIGINT or SIGTERM while the binary runs, it passes the signal on and waits for the binary to shut down, so stopping `summer serve` (Ctrl+C or `kill`) frees its port.
|
||||
- Commit: `fix(summer): forward SIGINT and SIGTERM to the app binary` (code, tests and docs; no co-author trailer). In the SUMMARY, record that this resolves WR-08 of 14.3-REVIEW.md, and that scripts/check-phase14.3.sh needs no change because `kill $server_pid` now reaches the app binary through summer. Do not edit the 14.3 phase files.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /media/nvme/dev/golem15/summercms.io/summercms/summercms.go && go vet ./... && go test ./cmd/summer -short -run 'TestRunChildForwardsSignals|TestNewAppCmdCancelSendsSIGTERM|TestDelegate' -count=3 && go test ./... -short && go test ./cmd/summer -run TestDocsTree && go run ./cmd/summer docs:build --check</automated>
|
||||
</verify>
|
||||
<done>Signals reaching summer are forwarded to the app binary, and summer waits for it. A context cancel triggers a graceful SIGTERM with a 15s SIGKILL fallback. The helper-process tests pass repeatedly (-count=3). The full `go vet ./...`, `go test ./... -short`, TestDocsTree and `docs:build --check` gate is green.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| developer shell -> summer -> app binary | summer forwards the developer's own argv, stdin, environment and signals to a child it starts from the app's bin/ directory |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-kdf-01 | Tampering | delegateCommand argv passthrough | low | mitigate | argv goes to exec.CommandContext as a slice, never through a shell, so no quoting or injection layer exists; only one leading `--` is stripped |
|
||||
| T-kdf-02 | Information disclosure | admin:create / admin:reset-password via summer | low | mitigate | the child inherits os.Stdin directly (no capture or buffering in summer), so the hidden prompt and stdin piping stay the recommended paths; the deprecated `--password` flag's process-list exposure is unchanged and already documented in setup-and-maintenance.md |
|
||||
| T-kdf-03 | Denial of service | runChild / newAppCmd | low | mitigate | signals are forwarded, not swallowed; a context cancel escalates to SIGKILL after appShutdownGrace (15s), so summer cannot hang forever on a cancelled context; an orphaned child holding the port (WR-08) is the threat being fixed |
|
||||
| T-kdf-04 | Elevation of privilege | runAppBinary recursion | low | accept | the existing sameFile check against os.Executable is kept unchanged |
|
||||
| T-kdf-SC | Tampering | dependency installs | low | accept | no new dependency: os/signal, syscall, time and io are standard library, and cobra is already pinned at v1.10.2 |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- `go vet ./...` and `go test ./... -short` are green after each commit.
|
||||
- `go test ./cmd/summer -run TestDocsTree` and `go run ./cmd/summer docs:build --check` pass.
|
||||
- End-to-end: in examples/hello, `go run ../../cmd/summer serve --help` prints the app binary's serve help, including `--addr`.
|
||||
- Optional manual check (needs Postgres, not required for autonomous completion): in an app directory, run `summer serve --addr 127.0.0.1:8123`, then `kill -TERM <summer pid>`. summer exits after the app logs its shutdown, and `ss -ltn | grep 8123` shows nothing.
|
||||
- `git diff --stat` across the three tasks touches only the files in files_modified. In particular, docs/setup/installation.md, modules/sunrise, modules/surf, modules/cabana, internal/build and internal/dev are untouched.
|
||||
- Do not tag, push or deploy.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- `summer serve --addr :8123`, `summer serve --help` and `summer serve -h` work from an app directory, and `summer serve -- --addr :8123` still works.
|
||||
- All eleven delegates (migrate, migrate:rollback, migrate:status, serve, queue:work, queue:clear, schedule:run, route:list, key:generate, admin:create, admin:reset-password) pass flags through and say so in the command list and the help command.
|
||||
- SIGINT/SIGTERM to summer reach the app binary, and summer waits for it to exit.
|
||||
- The bonfire API change is additive (Passthrough, Long) and is documented in modules/bonfire/README.md and docs/console/writing-commands.md in the same commit.
|
||||
- Three or four atomic commits, each with code and docs together, none with co-author trailers.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/quick/261008-kdf-delegated-summer-commands-pass-flags-thr/261008-kdf-SUMMARY.md` when done. Include:
|
||||
- The commits and files changed.
|
||||
- WR-08 (14.3-REVIEW.md) is resolved by Task 3; whether to update 14.3-REVIEW-DISPOSITION.md is left to the orchestrator.
|
||||
- Handoff for 261008-kdh, which runs after this plan: `summer admin:create --email <email> --superuser`, `summer admin:reset-password <identifier>`, `summer route:list`, `summer key:generate` and `summer serve --addr <host:port>` now work from an application directory, so kdh's startup and admin hints can use the `summer ...` forms instead of `./bin/<app> ...`.
|
||||
- Not delegated: `auth:login` (no such command in the app binary) and `deferred:purge` (not requested; it stays binary-only, as documented in docs/console/introduction.md).
|
||||
- Follow-up observation (not fixed): `summer dev` starts `bin/<app>` with no subcommand (internal/dev/watch.go startBin), so it never runs `serve`. Its child stop runs only on context cancel, and summer never cancels it.
|
||||
- File overlap: cmd/summer/main.go (toolCommands region only), docs/console/introduction.md, docs/console/setup-and-maintenance.md and the root README.md may be touched by later quick tasks. docs/setup/installation.md was deliberately left to 261008-k7s.
|
||||
</output>
|
||||
@@ -0,0 +1,103 @@
|
||||
---
|
||||
phase: quick-261008-kdf
|
||||
plan: 01
|
||||
status: complete
|
||||
subsystem: cli
|
||||
tags: [summer, bonfire, delegates, signals, passthrough]
|
||||
requires: []
|
||||
provides:
|
||||
- "bonfire.Command.Passthrough and bonfire.Command.Long (additive)"
|
||||
- "summer delegates: migrate, migrate:rollback, migrate:status, serve, queue:work, queue:clear, schedule:run, route:list, key:generate, admin:create, admin:reset-password, all passthrough"
|
||||
- "SIGINT/SIGTERM forwarding from summer to the app binary"
|
||||
affects: [cmd/summer, modules/bonfire, docs/console]
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns:
|
||||
- "package-level test seam (appRunner) for exec-backed commands"
|
||||
- "helper-process tests (re-exec the test binary) for signal behaviour"
|
||||
key-files:
|
||||
created:
|
||||
- cmd/summer/runtime_test.go
|
||||
modified:
|
||||
- modules/bonfire/command.go
|
||||
- modules/bonfire/root.go
|
||||
- modules/bonfire/command_test.go
|
||||
- modules/bonfire/README.md
|
||||
- docs/console/writing-commands.md
|
||||
- cmd/summer/runtime.go
|
||||
- cmd/summer/main.go
|
||||
- cmd/summer/main_test.go
|
||||
- docs/console/introduction.md
|
||||
- docs/console/setup-and-maintenance.md
|
||||
- README.md
|
||||
decisions:
|
||||
- "Delegated summer commands use bonfire Passthrough: summer parses no flags, every argument (including --help) goes to the app binary; one leading -- is dropped for the old workaround"
|
||||
- "All eleven delegates come from one table in delegateCommands(); the hand-written migrate:rollback/queue:work/schedule:run flag copies were removed"
|
||||
- "runChild forwards every SIGINT/SIGTERM and never escalates; only a context cancel escalates, SIGTERM then SIGKILL after appShutdownGrace (15s)"
|
||||
- "The child is not given its own process group, so admin password prompts keep reading the terminal"
|
||||
metrics:
|
||||
duration: "~25 min"
|
||||
completed: 2026-10-08
|
||||
actuals:
|
||||
tokens: 8000
|
||||
tasks: 3
|
||||
commits: 4
|
||||
plan_head_before: a6e2c399a5af9e0d04baf8b307a1c0e167cd1ff6
|
||||
plan_head_after: 50c8cee54129d8cf639e0893a1dca0b3dabd89bd
|
||||
---
|
||||
|
||||
# Quick 261008-kdf: Delegated summer commands pass flags through Summary
|
||||
|
||||
`summer serve --addr :8123` and the other ten delegated commands now hand every flag and argument to `bin/<app>` through a new additive `bonfire.Command.Passthrough`. admin, route and key commands are now delegates too, and summer forwards SIGINT/SIGTERM to the child and waits for it to exit.
|
||||
|
||||
## Commits
|
||||
|
||||
| Task | Commit | Message |
|
||||
|------|--------|---------|
|
||||
| 1a | 14f6982 | feat(bonfire): add Passthrough and Long command fields |
|
||||
| 1b | 9eb2a92 | fix(summer): pass delegated command flags to the app binary |
|
||||
| 2 | b9ef034 | fix(summer): delegate admin, route and key commands and pass all delegate flags through |
|
||||
| 3 | 50c8cee | fix(summer): forward SIGINT and SIGTERM to the app binary |
|
||||
|
||||
The diff covers exactly the 12 files in the plan's `files_modified`. docs/setup/installation.md, modules/sunrise, modules/surf, modules/cabana, internal/build and internal/dev were not touched.
|
||||
|
||||
## What changed
|
||||
|
||||
- **bonfire (additive):** `Command.Long` maps to cobra `Long`. `Command.Passthrough` sets `DisableFlagParsing` plus `cobra.ArbitraryArgs`. Commands without Passthrough parse flags as before, and a regression test confirms they still fail with `unknown flag: --addr`. README Features and API row plus docs/console/writing-commands.md cover both fields.
|
||||
- **summer delegates:** `delegateCommands()` builds all 11 delegates from one table. Each Description ends with "(flags pass to the app binary)". Each Long explains the passthrough, adds a "Common flags:" hint where one applies (--plugin, --addr, --queue, --once, --email/--superuser, [queue], <identifier>) and suggests running `summer <name> --help` inside an application. `toolCommands()` appends the list.
|
||||
- **Signals (WR-08):** `newAppCmd` keeps stdin, env and output inherited. Its `Cancel` sends SIGTERM (falling back to Kill where SIGTERM is unsupported), and `WaitDelay = appShutdownGrace` (15s). `runChild` forwards each signal from the channel and waits. `runAppBinary` calls `signal.Notify` only after its build and recursion checks, with a deferred `signal.Stop`.
|
||||
|
||||
## Verification
|
||||
|
||||
- `go vet ./...` and `go test ./... -short`: green.
|
||||
- `go test ./cmd/summer -short -run 'TestRunChildForwardsSignals|TestNewAppCmdCancelSendsSIGTERM|TestDelegate' -count=3`: green, under 0.1s per signal test.
|
||||
- `go test ./cmd/summer -run TestDocsTree` and `go run ./cmd/summer docs:build --check`: no problems found.
|
||||
- Tracer, end to end: in examples/hello, `go run ../../cmd/summer serve --help` prints the app binary's `--addr string Listen address (default ":8080")`.
|
||||
- I could not run the optional manual SIGTERM check against a live server: examples/hello has no database DSN, so `serve` exits with `lagoon: database.dsn is empty`. That error does show that `summer serve --addr 127.0.0.1:8123` reached the binary. The helper-process tests cover forwarding.
|
||||
|
||||
## Notes for the orchestrator
|
||||
|
||||
- **WR-08 of 14.3-REVIEW.md is resolved by Task 3** (commit 50c8cee). It is up to the orchestrator whether to update 14.3-REVIEW-DISPOSITION.md; the 14.3 phase files were not edited. scripts/check-phase14.3.sh needs no change, because `kill $server_pid` now reaches the app binary through summer.
|
||||
- **Handoff for 261008-kdh:** `summer admin:create --email <email> --superuser`, `summer admin:reset-password <identifier>`, `summer route:list`, `summer key:generate` and `summer serve --addr <host:port>` now work from an application directory, so kdh's startup and admin hints can use the `summer ...` forms instead of `./bin/<app> ...`.
|
||||
- **Not delegated:** `auth:login` does not exist in the app binary. `deferred:purge` was not requested; it stays binary-only, as docs/console/introduction.md now says.
|
||||
- **Follow-up (not fixed):** `summer dev` (internal/dev/watch.go `startBin`) starts `bin/<app>` with no subcommand, so it never runs `serve`. It stops its child only on context cancel, and summer never cancels that context.
|
||||
- **File overlap:** later quick tasks may also touch cmd/summer/main.go (only the `toolCommands` region was changed here), docs/console/introduction.md, docs/console/setup-and-maintenance.md and the root README.md. docs/setup/installation.md was deliberately left to 261008-k7s.
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
**1. [Rule 2 - Robustness] newAppCmd Cancel falls back to Kill when SIGTERM is unsupported**
|
||||
- **Found during:** Task 3
|
||||
- **Issue:** On Windows, `Process.Signal(SIGTERM)` returns an error. exec then reports that error and kills the child only after WaitDelay (15s).
|
||||
- **Fix:** If SIGTERM fails with anything other than `os.ErrProcessDone`, Cancel calls `Process.Kill()`. POSIX behaviour does not change.
|
||||
- **Commit:** 50c8cee
|
||||
|
||||
Otherwise the plan was executed as written. It produced four commits; the plan allowed three or four.
|
||||
|
||||
## Known Stubs
|
||||
|
||||
None.
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
- FOUND: cmd/summer/runtime_test.go, modules/bonfire/command_test.go
|
||||
- FOUND commits: 14f6982, 9eb2a92, b9ef034, 50c8cee
|
||||
@@ -0,0 +1,277 @@
|
||||
---
|
||||
phase: quick-261008-kdh
|
||||
plan: 01
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
files_modified:
|
||||
- modules/surf/router.go
|
||||
- modules/surf/routetable.go
|
||||
- modules/surf/serve.go
|
||||
- modules/surf/routelist_command.go
|
||||
- modules/surf/serve_test.go
|
||||
- modules/surf/admin_prefix_test.go
|
||||
- modules/surf/README.md
|
||||
- modules/cabana/README.md
|
||||
- docs/console/setup-and-maintenance.md
|
||||
- docs/services/routing.md
|
||||
- internal/build/new.go
|
||||
- internal/build/stubs/new.tmpl
|
||||
- internal/build/new_golden_test.go
|
||||
- internal/build/new_test.go
|
||||
- internal/build/testdata/new/dot-env.golden
|
||||
- internal/build/testdata/new/dot-env.example.golden
|
||||
- internal/build/testdata/new/CLAUDE.md.golden
|
||||
- internal/build/testdata/new/next-steps.txt.golden
|
||||
- internal/build/testdata/new-no-plugin/CLAUDE.md.golden
|
||||
- internal/build/testdata/new-no-plugin/next-steps.txt.golden
|
||||
- docs/console/scaffolding.md
|
||||
- docs/setup/installation.md
|
||||
- docs/backend/users-and-permissions.md
|
||||
autonomous: true
|
||||
requirements: [QUICK-261008-kdh]
|
||||
|
||||
estimate:
|
||||
tokens: 110000
|
||||
raw_tokens: 110000
|
||||
tasks: 2
|
||||
confidence: low
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "`serve --addr :8123` prints `listening on :8123, open http://127.0.0.1:8123/`. An explicit host is kept, an IPv6 host is bracketed, an unspecified host (0.0.0.0, ::) becomes 127.0.0.1 and port 80 is left out of the URL. An address that does not parse prints the old `listening on <addr>` line alone."
|
||||
- "The listen line is printed only after the port is bound: serve calls net.Listen before printing, so a port that is already in use fails without a misleading URL."
|
||||
- "When no plugin registers an admin controller, serve prints `admin: off — no plugin registers an admin controller` followed by a hint naming `summer make:admin-controller <vendor.plugin> <Name>`, `summer build`, `summer migrate`, `<self> admin:create --email you@example.com --superuser` and SUMMER_ADMIN__JWT__SECRET."
|
||||
- "When the admin is mounted, serve prints `admin: http://127.0.0.1:8123/backend` (base URL plus the real prefix). It adds a warning with the admin:create command when backend_users is empty or missing, and a softer reminder when the count could not be read. The count is bounded by a 2 s timeout and never fails boot."
|
||||
- "`<self>` is the binary as the operator can retype it: `./bin/demo` both under `summer serve` (absolute argv0, cwd = app dir) and under `./bin/demo serve`."
|
||||
- "route:list ends with an admin status line: `admin: mounted at /backend` or the same `admin: off — …` text."
|
||||
- "surf.Router.AdminPrefix returns (prefix, true) when BuildRouter mounted the admin and (\"\", false) otherwise, and is nil-safe. surf.Assemble, surf.BuildRouter, cabana.Activate and cabana.Routes keep their signatures and behaviour; the change only adds API."
|
||||
- "summer new writes `SUMMER_ADMIN__JWT__SECRET=<base64 of 32 crypto/rand bytes>` to .env (still mode 0600) and an empty `SUMMER_ADMIN__JWT__SECRET=` placeholder with a comment to .env.example. Goldens stay deterministic through a fixed NewOptions.AdminSecret. No user/front-end JWT key is added."
|
||||
- "The summer new next steps and the generated CLAUDE.md gain an Admin part: the admin lives at /backend once a plugin registers an admin controller, `summer make:admin-controller golem15.<plugin> <Name>`, then build and migrate, then `./bin/<name> admin:create --email you@example.com --superuser`."
|
||||
- "go vet ./... and go test ./... -short are green; TestDocsTree and `go run ./cmd/summer docs:build --check` pass; TestNewGolden and TestNewGoldenNoPlugin pass with only the six listed goldens changed."
|
||||
artifacts:
|
||||
- path: modules/surf/routetable.go
|
||||
provides: "Router.AdminPrefix accessor"
|
||||
contains: "func (r *Router) AdminPrefix() (string, bool)"
|
||||
- path: modules/surf/serve.go
|
||||
provides: "serveURL, selfCommand, adminPresence, countAdmins, serveBanner; listener-first serve that prints the banner"
|
||||
contains: "func serveURL(addr string) string"
|
||||
- path: modules/surf/routelist_command.go
|
||||
provides: "admin status line after the route table"
|
||||
contains: "AdminPrefix()"
|
||||
- path: modules/surf/serve_test.go
|
||||
provides: "URL formatting, self command, banner (mounted/off/each presence) tests"
|
||||
contains: "TestServeURL"
|
||||
- path: internal/build/new.go
|
||||
provides: "NewOptions.AdminSecret, generation and validation"
|
||||
contains: "AdminSecret"
|
||||
- path: internal/build/stubs/new.tmpl
|
||||
provides: ".env / .env.example secret lines, CLAUDE.md Admin section, next-steps Admin block"
|
||||
contains: "SUMMER_ADMIN__JWT__SECRET"
|
||||
- path: internal/build/testdata/new/next-steps.txt.golden
|
||||
provides: "pinned admin next steps"
|
||||
contains: "admin:create --email you@example.com --superuser"
|
||||
key_links:
|
||||
- from: modules/surf/router.go
|
||||
to: modules/surf/routetable.go
|
||||
via: "BuildRouter stores admin.Prefix in Router.adminPrefix after checkAdminPrefix succeeds; AdminPrefix reads it"
|
||||
pattern: "adminPrefix"
|
||||
- from: modules/surf/serve.go
|
||||
to: modules/surf/router.go
|
||||
via: "serve calls BuildRouter then r.compile() (instead of Assemble) so it keeps the *Router for AdminPrefix"
|
||||
pattern: "BuildRouter(app, plugins)"
|
||||
- from: modules/surf/serve.go
|
||||
to: modules/cabana/contracts.go
|
||||
via: "countAdmins uses Migrator().HasTable and Count on cabana.BackendUser (table backend_users)"
|
||||
pattern: "cabana.BackendUser"
|
||||
- from: internal/build/new.go
|
||||
to: internal/build/stubs/new.tmpl
|
||||
via: "newData.AdminSecret renders into new/env"
|
||||
pattern: "{{.AdminSecret}}"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Fix the reported confusion: in an app made by `summer new`, `summer serve` printed only `ℹ listening on :8123`, and /admin and /backend answered 404 with no explanation. The cause is that cabana mounts the admin only when a plugin registers an admin controller, and then it also needs `admin.jwt.secret` and an administrator.
|
||||
|
||||
The work has two parts.
|
||||
1. `serve` prints a clickable URL and an admin status line: the admin URL, a warning when no administrator exists, or "admin: off" with the exact steps. `route:list` prints the same status. surf gains the additive accessor `surf.Router.AdminPrefix`.
|
||||
2. `summer new` makes the app admin-ready. It writes a random `SUMMER_ADMIN__JWT__SECRET` to .env, and the next steps and the generated CLAUDE.md explain how to add an admin controller and create the first administrator.
|
||||
|
||||
Purpose: a developer who opens /backend on a fresh app learns from the terminal why it is not there and what to run, and once a controller exists the admin starts without hunting for a secret.
|
||||
|
||||
Output: two commits, each with code, tests, the module READMEs and the docs pages it affects (project rule: code and its README/docs land together). Nothing is tagged, pushed or deployed.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/gsd-core/workflows/execute-plan.md
|
||||
@~/.claude/gsd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/STATE.md
|
||||
@CLAUDE.md
|
||||
@modules/surf/serve.go
|
||||
@modules/surf/routelist_command.go
|
||||
@modules/surf/routetable.go
|
||||
@modules/surf/admin_prefix_test.go
|
||||
@modules/surf/serve_test.go
|
||||
@internal/build/new.go
|
||||
@internal/build/stubs/new.tmpl
|
||||
@internal/build/new_golden_test.go
|
||||
|
||||
Facts established while planning (do not re-derive):
|
||||
|
||||
- Ordering: quick tasks 261008-k7s (modules/sunrise, the PATH sections of docs/setup/installation.md) and 261008-kdf (modules/bonfire, cmd/summer/runtime.go, console docs about `summer serve` flags) run BEFORE this plan. Re-read docs/setup/installation.md and docs/console/setup-and-maintenance.md at execution time; edit only the sections named here and keep their edits. The working tree may hold other tasks' uncommitted files (for example .planning/state.json, modules/sunrise/*). Stage this plan's files by explicit path only, never `git add -A` or `git commit -a`.
|
||||
- Admin mounting lives in modules/surf/router.go BuildRouter (around line 546): `admin, err := cabana.Activate(app, plugins)`; when `admin != nil` it registers the `backend` middleware, mounts and runs `r.checkAdminPrefix(admin.Prefix)`. `cabana.Activate` returns (nil, nil) when no plugin registers an admin controller (modules/cabana/http.go:74-81), and fails boot with "cabana: admin.jwt.secret is empty (set SUMMER_ADMIN__JWT__SECRET)" when one does and the secret is missing. `cabana.Routes.Prefix` is already exported, so cabana code does not change.
|
||||
- `surf.Assemble` = BuildRouter + the unexported `(*Router).compile()`. serve.go is in package surf and can call compile directly. Keep Assemble as is (exported API).
|
||||
- serve default `--addr` is `:8080` (modules/surf/serve.go:31); ":8123" in the report was the user's flag.
|
||||
- Output: `bonfire.Output` has Info (prefix ℹ), Warning (prefix ⚠), Println and Table. Tests capture it with `bonfire.NewOutput(strings.NewReader(""), &buf, &buf)`, and `cmd.Run(ctx, nil, out)` works for commands that read no input (see modules/lagoon/keygen_test.go:23). A buffer is not a TTY, so the output has no colour codes.
|
||||
- `summer admin:create` is NOT a command of the summer CLI. cmd/summer/main.go delegates only migrate, migrate:rollback, migrate:status, serve, queue:work, schedule:run and queue:clear, and docs/console/introduction.md:43 says `admin:create` runs on the binary directly. Every hint therefore uses the binary form `./bin/<name> admin:create --email you@example.com --superuser`. Without `--superuser` or a role, the first administrator would see nothing, because generated controllers require a permission. The user's request text said `summer admin:create`; this is the working equivalent.
|
||||
- `summer make:admin-controller` takes `<vendor.plugin> <Name>` (cmd/summer/main.go:197-237). The generated controller requires the permission `<plugin>.access_<snake>`; boot fails until the plugin declares it in pact.HasPermissions, and NewRecord returns nil until it returns the model (docs/backend/admin-controllers.md around line 150).
|
||||
- `summer migrate` always creates backend_users: lagoon.Migrate runs `BackendAdminMigrations` under history id summercms.cabana (modules/lagoon/migrations.go:75). `cabana.BackendUser.TableName()` is "backend_users".
|
||||
- compass loads `SUMMER_ADMIN__JWT__SECRET` into `admin.jwt.secret` from the environment or .env with no YAML file declaring the key (koanf env provider, modules/compass/env.go). The .env reader splits on the first `=`, trims whitespace and strips one pair of matching surrounding quotes, so a base64 value with `=` padding is safe unquoted. No config/admin.yaml is needed. The front-end user JWT secret belongs to the golem15.user plugin, which summer new does not add, so it is not written.
|
||||
- The goldens: `go test ./internal/build -run 'TestNewGolden' -update` rewrites them (internal/build/new_golden_test.go:13). `goldenAppKey` is the base64 of 0x00..0x1f. TestNewGolden pins .env, .env.example, CLAUDE.md and next-steps.txt for the default app; TestNewGoldenNoPlugin pins CLAUDE.md and next-steps.txt. scripts/check-phase14.3.sh greps `^SUMMER_APP__KEY=.\{44\}$` and the next steps for `summer build`, `summer migrate` and `summer serve`, and needs at least 23 tracked goldens. All of that stays true; do not edit the script.
|
||||
- The docs checker verifies `pkg.Ident` code spans, and `summer …` and `./bin/<app> …` commands in `sh` fences (application commands such as admin:create are accepted). It also checks links and anchors, and rejects consuming-application names. Use `acme`, `demo` and `you@example.com`. A new identifier named in a README or docs page (`surf.Router.AdminPrefix`) must exist in the package. The generated CLAUDE.md must keep exactly one mention of `https://summercms.io/docs/llms.txt`; do not add another, and do not invent other summercms.io page URLs.
|
||||
- A Bash hook blocks commands whose text contains the literal dot-env file pattern. Golden file names use `dot-env` and are not affected. Read secrets-bearing files with the Read tool or through the Go tests, not with `cat`.
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tracer" tdd="true">
|
||||
<name>Task 1: serve and route:list report the URL and the admin status (surf.Router.AdminPrefix)</name>
|
||||
<files>modules/surf/router.go, modules/surf/routetable.go, modules/surf/serve.go, modules/surf/routelist_command.go, modules/surf/serve_test.go, modules/surf/admin_prefix_test.go, modules/surf/README.md, modules/cabana/README.md, docs/console/setup-and-maintenance.md, docs/services/routing.md</files>
|
||||
<precondition>`git status --porcelain -- docs/console/setup-and-maintenance.md docs/services/routing.md modules/surf modules/cabana` is empty before the first edit (no uncommitted hunks from another task would land in this commit).</precondition>
|
||||
<behavior>
|
||||
- serveURL(":8123") = "http://127.0.0.1:8123/"; serveURL("127.0.0.1:9000") = "http://127.0.0.1:9000/"; serveURL("[::1]:8080") = "http://[::1]:8080/"; serveURL("0.0.0.0:80") = "http://127.0.0.1/"; serveURL("[::]:8080") = "http://127.0.0.1:8080/"; serveURL("localhost:3000") = "http://localhost:3000/"; serveURL("8080") = "" (no port separator); serveURL("") = "".
|
||||
- selfCommand("/srv/app/bin/demo", "/srv/app") = "./bin/demo"; selfCommand("./bin/demo", "/srv/app") = "./bin/demo"; selfCommand("bin/demo", "/srv/app") = "./bin/demo"; selfCommand("demo", "/srv/app") = "demo"; selfCommand("/opt/x/bin/demo", "/srv/app") = "/opt/x/bin/demo"; selfCommand("", "/srv/app") = "./bin/<app>".
|
||||
- serveBanner(":8123", "/backend", true, adminsPresent, "./bin/demo") = [info "listening on :8123, open http://127.0.0.1:8123/", info "admin: http://127.0.0.1:8123/backend"].
|
||||
- Same with adminsNone adds a warning line "no administrators yet; create one with ./bin/demo admin:create --email you@example.com --superuser".
|
||||
- Same with adminsMissingTable adds a warning line "the admin tables do not exist yet; run summer migrate, then ./bin/demo admin:create --email you@example.com --superuser".
|
||||
- Same with adminsUnknown adds an info line "sign in with an administrator; ./bin/demo admin:create --email you@example.com --superuser creates one".
|
||||
- serveBanner(":8123", "", false, adminsUnknown, "./bin/demo") = [listen line, info "admin: off — no plugin registers an admin controller", info "to add one, run summer make:admin-controller <vendor.plugin> <Name>, then summer build, summer migrate and ./bin/demo admin:create --email you@example.com --superuser; the admin needs SUMMER_ADMIN__JWT__SECRET, which summer new writes to .env"].
|
||||
- serveBanner("8080", "/backend", true, adminsPresent, "./bin/demo") = [info "listening on 8080", info "admin: mounted at /backend"] (no URL when the address does not parse).
|
||||
- countAdmins(ctx, nil) = adminsUnknown.
|
||||
- BuildRouter with adminPrefixApp + adminPrefixPlugin: AdminPrefix() = ("/acme-admin", true); with adminPrefixAppWith(t, "") + adminPrefixPlugin = ("/backend", true); with only prefixRoutePlugin = ("", false); (*Router)(nil).AdminPrefix() = ("", false).
|
||||
- RouteListCommand output (bonfire.NewOutput into a buffer, Run with nil input) ends with "admin: mounted at /acme-admin" for the admin plugin set, and with "admin: off — no plugin registers an admin controller" for a set without admin controllers.
|
||||
</behavior>
|
||||
<action>
|
||||
1. Write the tests first (RED). Put the serveURL, selfCommand, serveBanner and countAdmins(nil) table tests in modules/surf/serve_test.go as TestServeURL, TestSelfCommand and TestServeBanner, plus a check that countAdmins(t.Context(), nil) returns adminsUnknown. Put the AdminPrefix and route:list tests in modules/surf/admin_prefix_test.go as TestRouterAdminPrefix and TestRouteListAdminStatus, reusing that file's adminPrefixApp, adminPrefixAppWith, adminPrefixPlugin and prefixRoutePlugin fixtures. Run `go test ./modules/surf -short -run 'TestServeURL|TestSelfCommand|TestServeBanner|TestRouterAdminPrefix|TestRouteListAdminStatus'` and confirm it fails to compile or fails.
|
||||
|
||||
2. Router API (additive). In modules/surf/router.go add an unexported field `adminPrefix string` to the Router struct. In BuildRouter, inside `if admin != nil`, set `r.adminPrefix = admin.Prefix` after checkAdminPrefix succeeds. In modules/surf/routetable.go, next to Routes, add the exported method `func (r *Router) AdminPrefix() (string, bool)`. It returns (r.adminPrefix, true) when the admin was mounted and ("", false) otherwise, nil receiver included. Doc comment: it reports the cabana admin mount path set by BuildRouter, and false means no plugin registers an admin controller, so no admin routes exist. Do not change Assemble, BuildRouter's signature, cabana.Activate or cabana.Routes.
|
||||
|
||||
3. Pure helpers in modules/surf/serve.go:
|
||||
- `serveURL(addr string) string`. Use net.SplitHostPort, returning "" on error or when the port is empty. A host that is empty or an unspecified IP (netip.ParseAddr(host).IsUnspecified(), which covers 0.0.0.0 and ::) becomes 127.0.0.1; any other host is kept as given. Port "80" is omitted. Otherwise join with net.JoinHostPort, which brackets IPv6. Return "http://" + hostport + "/".
|
||||
- `selfCommand(argv0, cwd string) string`. Empty argv0 gives "./bin/<app>". An argv0 without a path separator is returned unchanged (found on PATH). Otherwise make it absolute against cwd (filepath.Join when relative), take filepath.Rel(cwd, abs), and return "./" + filepath.ToSlash(rel) when Rel succeeds and rel neither is ".." nor starts with "../"; else return the absolute path.
|
||||
- `type adminPresence int` with the constants adminsUnknown, adminsMissingTable, adminsNone and adminsPresent.
|
||||
- `countAdmins(ctx context.Context, gdb *gorm.DB) adminPresence`. A nil gdb gives adminsUnknown. Otherwise derive a 2-second context.WithTimeout. If `gdb.WithContext(c).Migrator().HasTable(&cabana.BackendUser{})` is false, return adminsMissingTable. Then Count on `&cabana.BackendUser{}`: an error gives adminsUnknown, 0 gives adminsNone, more gives adminsPresent. It never returns an error and never logs one. HasTable queries information_schema, so a missing table does not reach the GORM error log.
|
||||
- `type bannerLine struct { warn bool; text string }` and `serveBanner(addr, adminPrefix string, mounted bool, admins adminPresence, self string) []bannerLine`, producing exactly the lines in <behavior>. The admin URL is strings.TrimSuffix(serveURL(addr), "/") + adminPrefix; when serveURL is "", the line is "admin: mounted at <prefix>". Keep the off text in one unexported constant `adminOffText` = "admin: off — no plugin registers an admin controller", because route:list reuses it. The em dash is the user's own wording.
|
||||
- A small `printBanner(out bonfire.Output, lines []bannerLine)` that calls out.Warning for warn lines and out.Info for the rest.
|
||||
|
||||
4. Wire serve (ServeCommand.Run). Replace `h, err := Assemble(app, plugins)` with `r, err := BuildRouter(app, plugins)` followed by `h, err := r.compile()`. After that, and before conga.StartServeWorker, bind with `ln, err := net.Listen("tcp", addr)` and return the error if it fails. If StartServeWorker fails, close ln before returning. Then compute `prefix, mounted := r.AdminPrefix()`. When mounted, set `admins = countAdmins(ctx, gdb)`, using the *gorm.DB already returned by lagoon.OpenFromApp; otherwise use adminsUnknown. Compute `cwd, _ := os.Getwd()`, then `printBanner(out, serveBanner(addr, prefix, mounted, admins, selfCommand(os.Args[0], cwd)))`. Print the banner synchronously, before the serving goroutine starts. Keep the "job worker started on queues" line where it is. The goroutine now runs `errCh <- srv.Serve(ln)`, and the shutdown and select logic stays unchanged (srv.Shutdown closes the listener Serve owns). Add imports as needed (net/netip, path/filepath, gorm.io/gorm and the cabana module; surf already depends on both through router.go). No new module requirement.
|
||||
|
||||
5. route:list (modules/surf/routelist_command.go). After out.Table, call out.Info with "admin: mounted at <prefix>" when r.AdminPrefix() reports mounted, else with adminOffText. route:list opens no database, so it prints no administrator hint.
|
||||
|
||||
6. Docs in the same commit (project rule; surf is a framework module, so describe additive behaviour only):
|
||||
- modules/surf/README.md. Add an API reference row `surf.Router.AdminPrefix`: "The admin mount path and true when `surf.BuildRouter` mounted the cabana admin; false when no plugin registers an admin controller." In the CLI commands table, extend the `serve` purpose: after binding it prints the listen address with a browser URL (an unspecified host shows as 127.0.0.1) and an admin status line. That line is the admin URL, with a warning and the `admin:create` command when no administrator exists yet, or `admin: off` with the steps to add an admin controller. Extend `route:list`: it ends with the admin status line.
|
||||
- modules/cabana/README.md, in the paragraph near line 181 that says `cabana.Activate` returns nil when no plugin registers an admin controller, add one sentence: `surf.Router.AdminPrefix` reports which case applies, and the `serve` and `route:list` commands print it as an admin status line.
|
||||
- docs/console/setup-and-maintenance.md, "The HTTP server" table: update the `serve` and `route:list` purposes the same way, and keep whatever 261008-kdf wrote about flags. In "Admin accounts" add one sentence: when the admin is mounted and backend_users is empty, `serve` prints a warning naming `admin:create`.
|
||||
- docs/services/routing.md, after the `route:list` paragraph near line 186, add one sentence: the table is followed by a line saying whether the admin is mounted and under which prefix.
|
||||
|
||||
7. Run the <verify> command and fix anything red. Make one commit with exactly the files of this task, for example `feat(surf): print the serve URL and admin status on startup`. The body names Router.AdminPrefix as additive, the listener-first bind and the route:list status line. Add no co-author or attribution trailer (user and project rule, which overrides any harness attribution default).
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /media/nvme/dev/golem15/summercms.io/summercms/summercms.go && go vet ./modules/surf/ ./modules/cabana/ && go test ./modules/surf/ -short -count=1 && go test ./modules/surf/ -short -count=1 -run 'TestServeURL|TestSelfCommand|TestServeBanner|TestRouterAdminPrefix|TestRouteListAdminStatus' -v 2>&1 | grep -c '^--- PASS' && grep -q 'surf.Router.AdminPrefix' modules/surf/README.md && grep -q 'surf.Router.AdminPrefix' modules/cabana/README.md && go test ./cmd/summer -run '^TestDocsTree$' -count=1 && go run ./cmd/summer docs:build --check</automated>
|
||||
</verify>
|
||||
<done>serve prints `listening on <addr>, open <url>` after binding, followed by the admin status line (and a warning or hint when applicable). route:list ends with the admin status. surf.Router.AdminPrefix exists and the rest of the API is unchanged. The five new tests pass with all of package surf. The surf and cabana READMEs and the two docs pages describe the output. TestDocsTree and docs:build --check pass. One commit holds exactly the ten files, with no co-author trailer.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 2: summer new writes the admin secret and explains the admin in next steps and CLAUDE.md</name>
|
||||
<files>internal/build/new.go, internal/build/stubs/new.tmpl, internal/build/new_golden_test.go, internal/build/new_test.go, internal/build/testdata/new/dot-env.golden, internal/build/testdata/new/dot-env.example.golden, internal/build/testdata/new/CLAUDE.md.golden, internal/build/testdata/new/next-steps.txt.golden, internal/build/testdata/new-no-plugin/CLAUDE.md.golden, internal/build/testdata/new-no-plugin/next-steps.txt.golden, docs/console/scaffolding.md, docs/setup/installation.md, docs/backend/users-and-permissions.md</files>
|
||||
<precondition>`git status --porcelain -- docs/setup/installation.md docs/console/scaffolding.md docs/backend/users-and-permissions.md internal/build` is empty before the first edit (261008-k7s's installation.md change is committed).</precondition>
|
||||
<behavior>
|
||||
- NewApp with AdminSecret "QUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVphYmNkZWY=" writes .env containing the line "SUMMER_ADMIN__JWT__SECRET=QUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVphYmNkZWY=" and .env keeps mode 0600. .env.example contains the line "SUMMER_ADMIN__JWT__SECRET=" with an empty value and does not contain the secret. The captured next-steps output does not contain the secret.
|
||||
- NewApp with an empty AdminSecret writes a SUMMER_ADMIN__JWT__SECRET value that base64-decodes (standard encoding) to exactly 32 bytes. Two apps created in two parents get different values.
|
||||
- NewApp with AdminSecret "short", a 40-character value containing a space, or a 40-character value containing a newline returns an error naming the admin secret and leaves no sm-demo-app directory behind.
|
||||
- The goldens change only by the admin additions, and every other golden is byte-identical.
|
||||
</behavior>
|
||||
<action>
|
||||
1. Tests first (RED). Add TestNewAdminSecret to internal/build/new_test.go covering the three behaviour groups. Use the existing helpers parentWithFramework, readAppFile and assertDirEntries, with NoGit and NoPlugin true to stay fast. In internal/build/new_golden_test.go add `goldenAdminSecret`, the base64 (standard) of the bytes 0x20..0x3f built like goldenAppKey, with a comment that it is a fixed, non-secret fixture. Pass `AdminSecret: goldenAdminSecret` in newGoldenApp's NewOptions.
|
||||
|
||||
2. internal/build/new.go:
|
||||
- Add `AdminSecret string` to NewOptions. Doc: written to .env as SUMMER_ADMIN__JWT__SECRET, the HMAC secret of admin tokens (`admin.jwt.secret`); empty means NewApp generates the base64 of 32 crypto/rand bytes.
|
||||
- Add `AdminSecret string` to newData.
|
||||
- Add `generateAdminSecret() (string, error)` (crypto/rand.Read into 32 bytes, base64.StdEncoding) and `validateAdminSecret(s string) error`. The value must be at least 32 characters and contain only `A-Za-z0-9+/=_-`, because it is written unquoted into .env, whose reader trims whitespace and strips quotes. The error text is "build: admin secret must be at least 32 characters of letters, digits, +, /, =, _ or -".
|
||||
- In NewApp, after validateAppKey: when opts.AdminSecret is empty, fill it from generateAdminSecret (wrap the error as "build: generate admin secret: %w"), then validate it. It must be filled before planNewApp and before any directory is created. planNewApp also calls validateAdminSecret and sets `AdminSecret: opts.AdminSecret` in data. Do not touch cmd/summer: NewApp generates the secret itself, so the CLI needs no change. The next-steps render keeps passing only Name and NoPlugin, so the secret can never reach the printed output.
|
||||
|
||||
3. internal/build/stubs/new.tmpl (keep every existing line byte-identical unless named here):
|
||||
- new/env: change the header comment to name the admin token secret, for example "Local secrets for this application: the database DSN, the application key and the admin token secret." Re-wrap it to three comment lines if needed. After SUMMER_APP__KEY add `SUMMER_ADMIN__JWT__SECRET={{.AdminSecret}}`.
|
||||
- new/env.example: after the SUMMER_APP__KEY= line add a two- or three-line comment: it signs backend admin tokens; the admin refuses to start without it once a plugin registers an admin controller; summer new wrote a random value to .env; any long random string works, for example one from ./bin/{{.Name}} key:generate. Then add the line `SUMMER_ADMIN__JWT__SECRET=`.
|
||||
- new/CLAUDE.md, in "## Commands", add two fence lines in the same aligned-comment style: `summer make:admin-controller golem15.<plugin> <Name>` with a comment saying it scaffolds an admin controller with its YAML, and `./bin/{{.Name}} admin:create --email you@example.com --superuser` with a comment saying it creates a backend administrator (password prompted). In "## Layout", make the `.env` bullet say it holds the database DSN, the application key and the admin token secret.
|
||||
- Add a new "## Admin" section after "## Layout". It says that the backend admin (the SPA and the JSON API) lives under `/backend`, at `http://127.0.0.1:8080/backend` with the default `summer serve` address, and is mounted only when a plugin registers an admin controller; until then `summer serve` prints `admin: off`. It then lists three numbered steps. (1) `summer make:admin-controller golem15.<plugin> <Name>`; the generated controller requires the permission `<plugin id>.access_<name>`, which must be declared in the plugin's `Permissions()` or the admin refuses to start, and its `NewRecord` must return the plugin's model. Word this from docs/backend/admin-controllers.md around line 150, and check the make:admin-controller hint (internal/build/artifact.go accessorHint) before writing any registration step. (2) `summer build` and `summer migrate`. (3) `./bin/{{.Name}} admin:create --email you@example.com --superuser`. The section ends with one sentence: `.env` already holds `SUMMER_ADMIN__JWT__SECRET`, which signs admin tokens. It must not mention llms.txt again. The text is the same for `--no-plugin`, because it uses `golem15.<plugin>`.
|
||||
- new/next-steps: keep everything up to and including the existing `{{if .NoPlugin}}…{{else}}…{{end}}` block byte-identical. Then, before the closing `{{end}}` of the define, append a blank line and this block:
|
||||
- "Admin, at http://127.0.0.1:8080/backend once a plugin registers an admin"
|
||||
- "controller (.env already holds SUMMER_ADMIN__JWT__SECRET):"
|
||||
- " summer make:admin-controller golem15.<plugin> <Name>"
|
||||
- " summer build"
|
||||
- " summer migrate"
|
||||
- " ./bin/{{.Name}} admin:create --email you@example.com --superuser"
|
||||
The output must end with exactly one trailing newline; check the golden by eye.
|
||||
|
||||
4. Regenerate the goldens with `go test ./internal/build -run 'TestNewGolden' -count=1 -update`, then rerun without -update. Review `git diff --stat -- internal/build/testdata`: exactly the six goldens in <files> change, and each diff is only the additions above plus the reworded .env header and .env bullet. If any other golden changes, the template edit leaked; fix it instead of accepting it. Read the golden contents with the Read tool, not cat.
|
||||
|
||||
5. Docs in the same commit:
|
||||
- docs/console/scaffolding.md, files table: the `.env`, `.env.example` row becomes "The database DSN, a freshly generated application key and a random admin token secret (`SUMMER_ADMIN__JWT__SECRET`). `.env` is readable by its owner only and never committed; `.env.example` lists the variables without the key and the secret." In the `CLAUDE.md` row add "the admin". After the next-steps `sh` fence, add a short paragraph and an `sh` fence with the admin block for the `acme` example: `summer make:admin-controller golem15.acme Posts`, `summer build`, `summer migrate`, `./bin/acme admin:create --email you@example.com --superuser`. Say the admin opens at `/backend` once a plugin registers an admin controller, that `summer serve` prints `admin: off` with these steps until then, and link [Admin controllers](../backend/admin-controllers.md).
|
||||
- docs/setup/installation.md, "## Run the application" section only: the `.env` bullet also names the random `SUMMER_ADMIN__JWT__SECRET`. After the curl/`docker compose down` paragraph, add a short paragraph. It says `summer serve` prints the URL and the admin status, and the admin lives at `http://127.0.0.1:8080/backend` once a plugin registers an admin controller. It gives the `./bin/demo admin:create --email you@example.com --superuser` step, and links [Admin controllers](../backend/admin-controllers.md) and [Users and permissions](../backend/users-and-permissions.md). Do not touch the PATH sections (261008-k7s).
|
||||
- docs/backend/users-and-permissions.md, after the sentence near line 22 naming `SUMMER_ADMIN__JWT__SECRET`: add one sentence saying that an application created with `summer new` already has a random secret in `.env`.
|
||||
|
||||
6. Run the <verify> command, fix anything red, then make one commit with exactly the files of this task, for example `feat(build): make summer new apps admin-ready`. The body mentions the generated secret, the .env.example placeholder and the Admin next steps. Add no co-author or attribution trailer. After the commit, `git status --porcelain -- internal/build/testdata` must be empty.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /media/nvme/dev/golem15/summercms.io/summercms/summercms.go && go test ./internal/build -count=1 -run 'TestNewGolden|TestNewGoldenNoPlugin|TestNewEnvFileMode|TestNewAdminSecret' && grep -q 'SUMMER_ADMIN__JWT__SECRET=' internal/build/testdata/new/dot-env.golden && grep -q 'admin:create --email you@example.com --superuser' internal/build/testdata/new/next-steps.txt.golden && grep -q 'admin:create --email you@example.com --superuser' internal/build/testdata/new-no-plugin/next-steps.txt.golden && grep -q '^## Admin' internal/build/testdata/new/CLAUDE.md.golden && test "$(grep -c 'https://summercms.io/docs/llms.txt' internal/build/testdata/new/CLAUDE.md.golden)" = 1 && go vet ./... && go test ./... -short -count=1 && go test ./cmd/summer -run '^TestDocsTree$' -count=1 && go run ./cmd/summer docs:build --check</automated>
|
||||
</verify>
|
||||
<done>A fresh `summer new` app has a random 32-byte base64 SUMMER_ADMIN__JWT__SECRET in .env (still 0600) and an empty placeholder in .env.example. The next steps and CLAUDE.md explain /backend, make:admin-controller, build, migrate and `./bin/<name> admin:create … --superuser`. Exactly six goldens changed and were reviewed. TestNewAdminSecret covers the explicit, generated and invalid cases. go vet ./... and go test ./... -short are green; TestDocsTree and docs:build --check pass. One commit holds exactly the thirteen files, with no co-author trailer. Nothing is tagged, pushed or deployed.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| summer new → local filesystem | The generated admin token secret is written to .env on the developer's disk and must never reach git, the terminal or a template that is committed. |
|
||||
| serve → operator console | The startup banner prints the URLs, the admin mount path and whether administrators exist to stdout. |
|
||||
| serve → PostgreSQL at boot | One extra read (HasTable and Count on backend_users) runs during startup when the admin is mounted. |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-kdh-01 | Information Disclosure | .env admin secret (internal/build/new.go, new.tmpl) | high | mitigate | The secret is written only to .env, which keeps mode 0600 (asserted in TestNewAdminSecret and TestNewEnvFileMode) and stays in .gitignore. .env.example gets an empty value. The next-steps render never receives the secret, and the test asserts the output does not contain it. Goldens use a fixed, documented non-secret fixture. |
|
||||
| T-kdh-02 | Spoofing | admin JWT HMAC secret strength | high | mitigate | Generated from crypto/rand (32 bytes, base64). validateAdminSecret rejects anything shorter than 32 characters, and the two-apps-differ test catches a constant or reused value. |
|
||||
| T-kdh-03 | Tampering | .env injection through AdminSecret | medium | mitigate | validateAdminSecret allows only `A-Za-z0-9+/=_-`, so a newline, space, quote or `#` cannot add or alter another .env variable. A test covers the newline and space cases. |
|
||||
| T-kdh-04 | Information Disclosure | serve banner | low | accept | It prints only the listen URL, the admin path and whether any administrator exists (never a count, email or secret). The output goes to the operator's own console, and the admin path is already public by design at /backend. |
|
||||
| T-kdh-05 | Denial of Service | countAdmins at boot | low | mitigate | Runs only when the admin is mounted, is bounded by a 2 s context timeout, uses information_schema for the existence check, and maps every failure to adminsUnknown, so it can never fail or block startup. |
|
||||
| T-kdh-SC | Tampering | npm/pip/cargo/go module installs | low | accept | No packages are installed and go.mod gains no requirement (net/netip, crypto/rand and gorm/cabana are already in the build). The `--forbidden` stage of scripts/check-phase14.3.sh would flag a new require. |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- `go vet ./...` and `go test ./... -short -count=1` are green after each commit.
|
||||
- TestServeURL covers ":8123", "127.0.0.1:9000", "[::1]:8080", "0.0.0.0:80", "[::]:8080", "localhost:3000", "8080" and "". TestServeBanner covers mounted with each adminPresence, not mounted, and an unparsable address. TestRouterAdminPrefix and TestRouteListAdminStatus cover mounted, the default prefix, not mounted and a nil router.
|
||||
- TestNewGolden, TestNewGoldenNoPlugin, TestNewEnvFileMode and TestNewAdminSecret pass, and exactly six goldens changed.
|
||||
- `go test ./cmd/summer -run '^TestDocsTree$' -count=1` and `go run ./cmd/summer docs:build --check` pass.
|
||||
- Manual smoke (optional, needs Docker): `summer new demo`, then compose up, `summer build`, `summer migrate` and `summer serve`. The console shows the listen URL and `admin: off — …`.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
A developer who runs `summer serve` in a fresh app sees a clickable URL and either the admin URL or an explicit "admin: off" line with the exact commands to enable it, and gets a warning when no administrator exists yet. A new app already has its admin token secret, so adding an admin controller is the only missing piece. The framework API change is additive (surf.Router.AdminPrefix), the READMEs and docs match, and the work lands as two commits with no tag, push or deploy.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/quick/261008-kdh-serve-startup-and-admin-feedback-admin-r/261008-kdh-SUMMARY.md` when done
|
||||
</output>
|
||||
|
||||
## Orchestrator override (2026-10-08, after 261008-kdf was planned)
|
||||
|
||||
Quick task 261008-kdf, which executes before this one, adds `summer admin:create` and `summer admin:reset-password` as pass-through delegates of the summer CLI (plus `summer route:list`, `summer key:generate` and flag pass-through such as `summer serve --addr :8123`). The note at line ~129 that `summer admin:create` is not a command is therefore superseded. Wherever this plan writes `./bin/<name> admin:create --email you@example.com --superuser` (or `<self> admin:create`, `./bin/demo admin:create`, `./bin/acme admin:create`, `./bin/{{.Name}} admin:create`), use `summer admin:create --email you@example.com --superuser` instead: in the serve banner hints, the summer new next steps, the generated CLAUDE.md, the goldens, the docs pages and the test expectations. Verify first that 261008-kdf landed (`summer --help` lists admin:create); if it did not, fall back to the plan's binary form.
|
||||
@@ -0,0 +1,119 @@
|
||||
---
|
||||
phase: quick-261008-kdh
|
||||
plan: 01
|
||||
status: complete
|
||||
subsystem: surf, build
|
||||
tags: [serve, admin, cabana, summer-new, dx]
|
||||
requires:
|
||||
- "261008-kdf: summer admin:create delegate (used in every hint)"
|
||||
provides:
|
||||
- "surf.Router.AdminPrefix (additive)"
|
||||
- "serve: listener-first bind, URL line and admin status banner"
|
||||
- "route:list: admin status line after the table"
|
||||
- "summer new: random SUMMER_ADMIN__JWT__SECRET in .env, Admin next steps and CLAUDE.md section"
|
||||
affects: [modules/surf, modules/cabana (README only), internal/build, docs]
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns:
|
||||
- "pure banner builder (serveBanner) returning lines, printed by printBanner, so startup output is table-tested without a server"
|
||||
key-files:
|
||||
created: []
|
||||
modified:
|
||||
- modules/surf/router.go
|
||||
- modules/surf/routetable.go
|
||||
- modules/surf/serve.go
|
||||
- modules/surf/routelist_command.go
|
||||
- modules/surf/serve_test.go
|
||||
- modules/surf/admin_prefix_test.go
|
||||
- modules/surf/README.md
|
||||
- modules/cabana/README.md
|
||||
- docs/console/setup-and-maintenance.md
|
||||
- docs/services/routing.md
|
||||
- internal/build/new.go
|
||||
- internal/build/stubs/new.tmpl
|
||||
- internal/build/new_golden_test.go
|
||||
- internal/build/new_test.go
|
||||
- internal/build/testdata/new/dot-env.golden
|
||||
- internal/build/testdata/new/dot-env.example.golden
|
||||
- internal/build/testdata/new/CLAUDE.md.golden
|
||||
- internal/build/testdata/new/next-steps.txt.golden
|
||||
- internal/build/testdata/new-no-plugin/CLAUDE.md.golden
|
||||
- internal/build/testdata/new-no-plugin/next-steps.txt.golden
|
||||
- docs/console/scaffolding.md
|
||||
- docs/setup/installation.md
|
||||
- docs/backend/users-and-permissions.md
|
||||
decisions:
|
||||
- "Every admin hint uses `summer admin:create --email you@example.com --superuser` (orchestrator override; 261008-kdf made it a delegate), so selfCommand was not added"
|
||||
- "Router tracks mounting with an explicit adminMounted flag next to adminPrefix rather than inferring it from a non-empty prefix"
|
||||
- "serve binds with net.Listen before printing and serves with srv.Serve(ln); the banner prints synchronously after the worker starts"
|
||||
metrics:
|
||||
duration: "~48 min"
|
||||
completed: 2026-10-08
|
||||
actuals:
|
||||
tokens: 12700
|
||||
tasks: 2
|
||||
commits: 2
|
||||
plan_head_before: 4b5ed7707e3b8845b23113658299a3197b6169bc
|
||||
plan_head_after: f70d80ec7efd34ad2ccf9809913674d5fd243f95
|
||||
---
|
||||
|
||||
# Quick 261008-kdh: serve startup and admin feedback Summary
|
||||
|
||||
`summer serve` now binds first and prints `listening on :8123, open http://127.0.0.1:8123/` followed by an admin status line: either the admin URL (plus a warning naming `summer admin:create` when `backend_users` is empty or missing), or `admin: off — no plugin registers an admin controller` with the exact steps. `route:list` ends with the same status. `summer new` writes a random `SUMMER_ADMIN__JWT__SECRET` to `.env`, so the only thing a fresh app is missing for the admin is an admin controller.
|
||||
|
||||
## Commits
|
||||
|
||||
| Task | Commit | Message |
|
||||
|------|--------|---------|
|
||||
| 1 | da21670 | feat(surf): print the serve URL and admin status on startup |
|
||||
| 2 | f70d80e | feat(build): make summer new apps admin-ready |
|
||||
|
||||
## What changed
|
||||
|
||||
- **surf (additive):** `surf.Router.AdminPrefix() (string, bool)` is nil-safe and is set by `BuildRouter` after `checkAdminPrefix` succeeds. `Assemble`, `BuildRouter`, `cabana.Activate` and `cabana.Routes` are unchanged.
|
||||
- **serve:** `BuildRouter` + `compile()`, then `net.Listen` (a busy port fails before any URL is printed; the listener is closed if the worker fails to start), then `countAdmins` (2 s timeout, `HasTable` + `Count` on `cabana.BackendUser`, every failure maps to `adminsUnknown`), then the banner, then `srv.Serve(ln)`. The shutdown and select logic is unchanged.
|
||||
- **serveURL:** an empty or unspecified host (0.0.0.0, ::) becomes 127.0.0.1, IPv6 is bracketed, port 80 is dropped, an unparsable address gives no URL (the banner falls back to `listening on <addr>` and `admin: mounted at <prefix>`).
|
||||
- **summer new:** `NewOptions.AdminSecret`; empty means 32 crypto/rand bytes as base64. `validateAdminSecret` requires 32+ characters of `A-Za-z0-9+/=_-` (no .env injection). `.env.example` gets an empty placeholder with a comment. The next-steps render still receives only Name and NoPlugin. The next steps and the generated CLAUDE.md gain an Admin block or section.
|
||||
- **Docs:** surf README (API row, serve/route:list), cabana README (one sentence), console setup-and-maintenance, routing, scaffolding, installation (Run the application section only; k7s PATH sections untouched), users-and-permissions.
|
||||
|
||||
## Verification
|
||||
|
||||
- `go vet ./...` and `go test ./... -short -count=1`: green.
|
||||
- New tests: TestServeURL, TestServeBanner (present, none, missing table, unknown, off, unparsable), TestPrintBanner, TestCountAdminsNilDB, TestRouterAdminPrefix (configured, default, none, nil), TestRouteListAdminStatus (mounted, off), TestNewAdminSecret (explicit, generated and distinct, three invalid values with cleanup).
|
||||
- TestNewGolden, TestNewGoldenNoPlugin, TestNewEnvFileMode pass; exactly the six planned goldens changed, each reviewed (dot-env golden read with the Read tool).
|
||||
- `go test ./cmd/summer -run '^TestDocsTree$'` and `go run ./cmd/summer docs:build --check`: no problems found.
|
||||
- Tracer gate (Task 1): the full `<verify>` was re-run after implementation and passed before Task 2 started.
|
||||
- Not run: the optional manual smoke (`summer new demo`, compose up, serve) needs Docker and a live database; countAdmins against a real Postgres is covered only by its nil path.
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
**1. [Orchestrator override] `summer admin:create` instead of `<self>`/`./bin/<name> admin:create`**
|
||||
- **Found during:** Task 1 (precheck: `cmd/summer/runtime.go` delegates `admin:create`, landed in 261008-kdf)
|
||||
- **Change:** All hints, next steps, CLAUDE.md, goldens, docs and test expectations use `summer admin:create --email you@example.com --superuser`. Consequently `selfCommand` and TestSelfCommand were not added and `serveBanner` takes no `self` parameter (`serveBanner(addr, prefix, mounted, admins)`). The `.env.example` comment keeps the existing `./bin/<name> key:generate` form to match the line above it.
|
||||
- **Commits:** da21670, f70d80e
|
||||
|
||||
**2. [Rule 2 - Robustness] countAdmins treats a HasTable false caused by a timeout as unknown**
|
||||
- **Issue:** `HasTable` returns false on a query error, which would otherwise report "tables do not exist" after a 2 s timeout.
|
||||
- **Fix:** if the derived context has an error, return `adminsUnknown` instead of `adminsMissingTable`.
|
||||
- **Commit:** da21670
|
||||
|
||||
**3. [Placement] users-and-permissions sentence placed after the YAML block**
|
||||
- The sentence near line 22 ends in a colon introducing the `config/admin.yaml` fence, so the new sentence follows the fence instead of splitting it.
|
||||
|
||||
**4. [Additional test] TestPrintBanner** checks info vs warning symbols; not in the plan's list, purely additive.
|
||||
|
||||
Pre-existing, out of scope: `gofmt -l internal/build` lists `internal/build/registry.go` (untouched by this task).
|
||||
|
||||
## Known Stubs
|
||||
|
||||
None.
|
||||
|
||||
## Threat Flags
|
||||
|
||||
None. The banner prints only the URL, admin path and whether administrators exist (T-kdh-04, accepted). The secret stays in `.env` 0600 and never in output (T-kdh-01..03 mitigated and tested).
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
- FOUND commits: da21670, f70d80e (`git rev-list --count 4b5ed77..HEAD` = 2)
|
||||
- FOUND: modules/surf/serve.go contains `func serveURL(addr string) string`; modules/surf/routetable.go contains `func (r *Router) AdminPrefix() (string, bool)`; internal/build/stubs/new.tmpl contains `SUMMER_ADMIN__JWT__SECRET`
|
||||
- `git status --porcelain -- internal/build/testdata` is empty
|
||||
@@ -0,0 +1,408 @@
|
||||
---
|
||||
phase: quick-261008-o6k
|
||||
plan: 01
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
files_modified:
|
||||
- modules/sunrise/new.sh
|
||||
- modules/sunrise/sunrise.go
|
||||
- modules/sunrise/sunrise_test.go
|
||||
- modules/sunrise/script_behavior_test.go
|
||||
- modules/sunrise/README.md
|
||||
- docs/setup/installation.md
|
||||
- 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
|
||||
- ../sm-summercmsio-app/README.md
|
||||
- ../sm-summercmsio-app/CLAUDE.md
|
||||
autonomous: true
|
||||
requirements: [QUICK-261008-o6k]
|
||||
|
||||
estimate:
|
||||
tokens: 190000
|
||||
raw_tokens: 190000
|
||||
tasks: 4
|
||||
confidence: low
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "From inside one application directory, `curl -fsSL https://summercms.io/update | bash` (or `curl -fsSL https://summercms.io/new | bash -s -- --update`) updates that application only. Without ./go.mod and ./summer.yaml, the script exits 1 with `run this from inside an sm-<name>-app directory` before any git or go call. Combining --update with any other argument exits 2 with the usage. A repeated --update is accepted."
|
||||
- "The checkout comes from the app's go.mod `replace git.golem15.com/golem15/summercms => <path>` (single-line or block form, optional version on the left, // comments stripped). A missing replace exits 1 with `has no replace for`, and a replace to a module version exits 1 with `not a local checkout`. A path that does not exist exits 1 with `does not exist`. A path that is not a clean framework checkout is refused by the shared check_framework_dir. SUMMERCMS_DIR is ignored with a warning."
|
||||
- "A detached checkout is fetched and moved fast-forward only to the newest vX.Y.Z tag, or to SUMMERCMS_REF, through the same move_detached function as create mode. At the target, it says `already at the target`. On a branch, a clean tree with an upstream runs `git pull --no-rebase --ff-only`, and SUMMERCMS_REF is ignored with a warning. A dirty tree, a missing upstream or a diverged branch exits 1, leaving HEAD, the branch and the files unchanged and installing and building nothing."
|
||||
- "Other sm-*-app directories (next to the app and next to the checkout) whose go.mod replace resolves physically to the same checkout get one `not rebuilt` line that names them. They are never built and their files are never written."
|
||||
- "summer is reinstalled with `GOWORK=off go -C <checkout> install ./cmd/summer`. If the app's .env exists and has no active SUMMER_ADMIN__JWT__SECRET line (compass loader rules), one line holding 44-character base64 from 32 random bytes is appended. The append keeps the file mode, adds a newline first when the file lacks one, runs only once and never prints the value. Then `<gobin>/summer build` runs in the app with stdin from /dev/null. A failed build exits 1 naming the app."
|
||||
- "A successful update ends on stdout with `==> Done. Run summer migrate, then restart summer serve.` followed by the PATH hint from the shared path_hint function. Create-mode stdout is byte-identical to before (pinned by an exact-equality case)."
|
||||
- "sunrise.UpdateScript() returns Script() with the final line `main \"$@\"` replaced by `main \"$@\" --update`. Script() is unchanged. Every proper prefix of either script runs nothing, and UpdateScript bytes piped to bash with no arguments run update mode."
|
||||
- "The site serves GET /update from sunrise.UpdateScript() with the same headers as GET /new (text/plain; charset=utf-8, nosniff, no-cache, strong ETag, 304 on match). The route tests, the plugin README and the site README cover it. The --forbidden gate allows only the literal SUMMER_ADMIN__JWT__SECRET in new.sh. go vet, go test -short, TestDocsTree, docs:build --check, --forbidden and --unit are green."
|
||||
artifacts:
|
||||
- path: modules/sunrise/new.sh
|
||||
provides: "--update mode: app detection, go.mod framework replace parsing, shared check_framework_dir and move_detached, ff-only branch pull, shared-checkout notice, summer reinstall, .env admin secret append, summer build, shared path_hint"
|
||||
contains: "--update"
|
||||
- path: modules/sunrise/sunrise.go
|
||||
provides: "UpdateScript(): Script() with the last line replaced by main \"$@\" --update"
|
||||
contains: "func UpdateScript"
|
||||
- path: modules/sunrise/script_behavior_test.go
|
||||
provides: "update: cases in TestScriptBehavior plus harness support (run in a directory, app fixtures, UpdateScript file, head/base64/openssl, STUB_SUMMER_FAIL)"
|
||||
contains: "update:"
|
||||
- path: modules/sunrise/sunrise_test.go
|
||||
provides: "TestUpdateScript, truncation proof over both scripts, gated end-to-end update run"
|
||||
contains: "TestUpdateScript"
|
||||
- path: docs/setup/installation.md
|
||||
provides: "## Updating an application section"
|
||||
contains: "## Updating an application"
|
||||
- path: scripts/check-phase14.3.sh
|
||||
provides: "narrowed SUMMER_ scan; TestUpdateScript and TestUpdateScriptRoute required"
|
||||
contains: "SUMMER_ADMIN__JWT__SECRET"
|
||||
- path: ../sm-summercmsio-app/plugins/golem15/summercms/plugin.go
|
||||
provides: "GET /update route serving sunrise.UpdateScript()"
|
||||
contains: "sunrise.UpdateScript()"
|
||||
- path: ../sm-summercmsio-app/plugins/golem15/summercms/routes_test.go
|
||||
provides: "TestUpdateScriptRoute and /update rows in the route-table tests"
|
||||
contains: "TestUpdateScriptRoute"
|
||||
key_links:
|
||||
- from: modules/sunrise/new.sh
|
||||
to: "internal/build/stubs/new.tmpl go.mod (`require <framework> v0.0.0` + `replace <framework> => <relative path>`)"
|
||||
via: "framework_replace mirrors internal/build/scaffold.go parseGoMod/splitReplace: single-line and block replace, // comments stripped, first field on each side"
|
||||
pattern: "replace"
|
||||
- from: modules/sunrise/sunrise.go
|
||||
to: modules/sunrise/new.sh
|
||||
via: "UpdateScript derives from the embedded bytes by one suffix replacement, so both URLs serve the same logic"
|
||||
pattern: "main \"\\$@\" --update"
|
||||
- from: ../sm-summercmsio-app/plugins/golem15/summercms/plugin.go
|
||||
to: modules/sunrise/sunrise.go
|
||||
via: "g.Get(\"/update\", scriptHandler(sunrise.UpdateScript()))"
|
||||
pattern: "UpdateScript"
|
||||
- from: modules/sunrise/new.sh
|
||||
to: "modules/compass/env.go readDotEnv"
|
||||
via: "presence check for SUMMER_ADMIN__JWT__SECRET follows the loader: trimmed lines, # comments skipped, `export ` prefix, key trimmed before ="
|
||||
pattern: "SUMMER_ADMIN__JWT__SECRET"
|
||||
- from: "new.sh path_hint"
|
||||
to: "create and update modes"
|
||||
via: "one function prints the per-shell PATH hint; exportHint/fishHint test helpers pin both"
|
||||
pattern: "path_hint"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Add update mode to the bootstrap script. Run from inside one application directory, it updates that application only:
|
||||
|
||||
cd sm-blog-app
|
||||
curl -fsSL https://summercms.io/update | bash
|
||||
|
||||
`curl -fsSL https://summercms.io/new | bash -s -- --update` does the same thing. Update mode reads the framework checkout from the app's go.mod replace. It moves the checkout forward (fast-forward only), names other apps that share the checkout without touching them, reinstalls summer, adds a missing admin token secret to this app's `.env`, runs `summer build` here and ends with the migrate and restart reminder and the PATH hint.
|
||||
|
||||
This replaces the rejected `--upgrade` design, which rebuilt every sibling sm-*-app. The user may have ten apps and does not want that.
|
||||
|
||||
Purpose: today, updating means manual git, go install and summer build steps. Re-running the one-liner with a name refuses an existing app, and apps created before 261008-kdh lack SUMMER_ADMIN__JWT__SECRET.
|
||||
|
||||
Output, in two repositories:
|
||||
- Framework (summercms.go): one `feat(sunrise)` commit with new.sh, sunrise.go (`UpdateScript`), tests, the sunrise README, docs/setup/installation.md, the root README and the narrowed check-phase14.3.sh.
|
||||
- Site (../sm-summercmsio-app): one commit in the plugin submodule (`plugins/golem15/summercms`, branch master) adding GET /update, then one parent commit that bumps the submodule pointer and updates the site README and CLAUDE.md.
|
||||
|
||||
No require bump, tag, push or deploy: the orchestrator does the release.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/gsd-core/workflows/execute-plan.md
|
||||
@~/.claude/gsd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@CLAUDE.md
|
||||
@.planning/STATE.md
|
||||
@modules/sunrise/new.sh
|
||||
@modules/sunrise/sunrise.go
|
||||
@modules/sunrise/sunrise_test.go
|
||||
@modules/sunrise/script_behavior_test.go
|
||||
@modules/sunrise/README.md
|
||||
@docs/setup/installation.md
|
||||
@scripts/check-phase14.3.sh
|
||||
@internal/build/stubs/new.tmpl
|
||||
@../sm-summercmsio-app/plugins/golem15/summercms/plugin.go
|
||||
@../sm-summercmsio-app/plugins/golem15/summercms/script.go
|
||||
@../sm-summercmsio-app/plugins/golem15/summercms/routes_test.go
|
||||
@../sm-summercmsio-app/plugins/golem15/summercms/README.md
|
||||
|
||||
Facts established while planning (do not re-derive):
|
||||
|
||||
- **The uncommitted partial refactor in new.sh is KEPT.** The aborted executor split `check_framework_dir "$dir" "$hint"` out of `check_checkout`, and `move_detached "$dir" "$ref"` out of `checkout_framework`. Verified on the working tree during planning: `shellcheck -s bash` is clean, there is exactly one `shellcheck disable`, `grep -Ecw 'sudo|eval'` is 0, and `go test ./modules/sunrise/ -short` passes all 32 TestScriptBehavior cases, so create-mode messages are unchanged. Build on these two functions. The same diff also added header-comment and `usage` text for the rejected `--upgrade` design ("runs summer build in every sm-*-app next to the checkout"). Rewrite that text for `--update` in Task 1. Do not leave any `--upgrade` text behind (verify greps for it).
|
||||
- **Flag name is `--update`,** matching the /update URL. Remove `--upgrade` everywhere.
|
||||
- **UpdateScript design (one source of truth).** All logic lives in new.sh. `UpdateScript()` is `Script()` with its final line `main "$@"` (plus newline) replaced by `main "$@" --update` (plus newline). The flag goes after `"$@"`: then every proper prefix of the last line (`main`, `main "$@"`, `main "$@" --upd` and so on) calls main without a valid `--update` or with no arguments, and main refuses with usage exit 2. A cut-short download of either script still runs nothing, and the truncation test proves it for both. Prepending `set -- --update "$@"` was rejected because it puts a statement before the function definitions and breaks the "everything is a function, the last line calls main" invariant. A separate update.sh was rejected because it would duplicate abspath, check_go, check_framework_dir, move_detached, go_bin_dir and the hint code. `bash -s -- foo` against /update becomes `main foo --update`, which exits 2. `bash -s -- --update` against /update becomes `main --update --update`, which is accepted.
|
||||
- **Argument parsing.** Keep `-h/--help` exactly as today (only when it is the sole argument). Then count arguments: any argument equal to `--update` sets update mode, and every other argument counts as "other". Update mode with any other argument prints `error: --update takes no other argument`, a blank line and the usage to stderr, then exits 2. Under `set -e`, write the counter as `others=$((others + 1))`, never `((others++))`, which returns 1 when the value is 0 and kills the script. For bash 3.2 under `set -u`, guard the loop with `(($#))` before `for arg in "$@"`. Without `--update`, today's create path runs unchanged.
|
||||
- **App detection.** The app is `$PWD`. It must hold a regular `go.mod` and a regular `summer.yaml`; `summer build` needs the manifest, and the check keeps a plugin subdirectory, which has a go.mod, from being mistaken for an app. The framework module path is `git.golem15.com/golem15/summercms` (`frameworkModule` in internal/build; the test constant is `fwModule`). Keep the literal in one place: add a tiny `framework_module` function that prints it, and use it in check_framework_dir and the replace parser. check_framework_dir's messages must stay byte-identical (it interpolates the same string).
|
||||
- **Replace parsing** mirrors `internal/build/scaffold.go` parseGoMod/splitReplace. Read with `while IFS= read -r line || [[ -n $line ]]`, cut at the first `//`, trim, recognise `replace (` ... `)` blocks and single-line `replace X => Y`, take the first whitespace field of each side, and match the left field exactly against the framework module (a version may follow it). The function prints the right-hand side (first field plus any second field) or returns 1 when no replace for the framework exists. If the first right-hand field is double-quoted, strip the quotes. A target is a local path only when it is `.`, `..` or starts with `./`, `../` or `/` (Go's rule). Anything else is a module replace, which is refused. Resolve a relative target with `abspath "$PWD/$target"`. Normalise an absolute one with abspath too.
|
||||
- **The generated app layout** (`internal/build/stubs/new.tmpl`, golden `internal/build/testdata/new/go.mod.golden`): `require git.golem15.com/golem15/summercms v0.0.0` and `replace git.golem15.com/golem15/summercms => ../summercms` on one line, plus a second replace for `./plugins/demo`. The app's go.work only `use`s `.` and the plugin. `.env` holds SUMMER_ADMIN__JWT__SECRET for apps created since 261008-kdh. Older apps lack it.
|
||||
- **The --forbidden gate blocks the literal today.** `scripts/check-phase14.3.sh` `stage_forbidden` fails on `grep -nE 'SUMMER_' modules/sunrise/new.sh`, per 14.3 D-26: the script's own overrides are `SUMMERCMS_*`, because compass reads `SUMMER_*` as config. Writing one `.env` line keeps that intent, so Task 3 narrows the gate in the same commit. Do not dodge it by assembling the name from pieces. Keep the name once in non-comment code, as a `local key=` inside the function that writes `.env`, and refer to it only as `$key` elsewhere. `SUMMERCMS_` does not match `SUMMER_`.
|
||||
- **Create-mode output is pinned.** The acceptance stage greps ` cd /work/sm-demo-app` (-x) and `export PATH="/root/go/bin:$PATH"`. TestBootstrapEndToEnd greps ` cd <app>`, the export line and `(and add it to ~/.bashrc):`. The first usage line `Usage: curl -fsSL https://summercms.io/new | bash -s -- <name>` is pinned by the help case.
|
||||
- **14.3-SECURITY-REVIEW pins** (do not edit that file): new.sh is shellcheck clean except the one SC2016 directive for the literal `$PATH` in the hint, and `grep -Ecw 'sudo|eval'` on new.sh is 0 (T-14.3-16). That count includes comments: do not write those two words anywhere in new.sh.
|
||||
- **Admin secret format** (`internal/build/new.go` generateAdminSecret): standard base64 of 32 random bytes, 44 characters ending in `=`. Validate the generated value against `^[A-Za-z0-9+/]{43}=$` (regex held in a `local`, unquoted on the right of `=~`).
|
||||
- **The .env loader** (`modules/compass/env.go` readDotEnv) trims each line, skips blank lines and `#` comments, strips an `export ` prefix (export plus one space) and trims again, cuts at the first `=` and trims the key. "Already present" follows those rules: ` export SUMMER_ADMIN__JWT__SECRET = x` counts, `# SUMMER_ADMIN__JWT__SECRET=x` does not. An existing line with an empty value (after trimming, and after removing matching surrounding quotes) counts as present. Leave it alone and warn that it is empty, naming only the key and the file.
|
||||
- **Appending keeps the file mode.** `>>` opens with O_APPEND and keeps mode and owner. Never rewrite through a temp file. Detect a missing final newline without `tail`: after a `while IFS= read -r line` loop, a non-empty `$line` is an unterminated final line. Check that line for the key in the same pass.
|
||||
- **`.env` is protected in this environment.** A PreToolUse hook blocks Bash commands that read `.env` files. Tests read temp-dir `.env` files from Go, which is fine. Never `cat` or `grep` a `.env` from Bash while executing.
|
||||
- **macOS ships bash 3.2 for `curl | bash`.** Under `set -u`, bash before 4.4 treats `"${arr[@]}"` on an empty array as unbound. Guard such expansions with `${arr[@]+"${arr[@]}"}`, or collect into a newline- or space-joined scalar. No `mapfile`/`readarray` and no `shopt -s nullglob`: check each glob result with `[[ -d ]]`.
|
||||
- **Shellcheck:** write the upstream ref as `'@{u}'` with quotes (an unquoted brace trips SC1083). Keep `~` text inside format strings as the existing hint does (SC2088).
|
||||
- **Git facts for the tests:** `git clone` creates a local `main` tracking `origin/main` before the script detaches at the tag. After `h.mustRun("demo")`, `git -C <checkout> switch -q main` therefore gives a branch with an upstream, and `switch -q -c mine` gives one without. Use `pull --no-rebase --ff-only`, so a user's `pull.rebase` setting cannot turn the fast-forward into a rebase. The fake remote's `commit()` edits the tracked `VERSION` file.
|
||||
- **Harness facts:** PATH holds only `h.tools` (a git symlink plus go and docker stubs), so `head`, `base64` and `openssl` are missing unless the harness adds them. The summer stub overwrites `summer.call` with `>`. Update mode makes exactly one summer call (build), so `summer.call` stays sufficient and needs no append log. `h.mustRun("demo")` creates no app directory, because the stub writes nothing, so update cases create the app fixture themselves. The env is built from scratch with `SHELL=` defaulted before `h.env`, and later duplicates win. `cmd.Dir` and the `PWD=` env entry must name the same directory.
|
||||
- **Site:** the plugin's go.mod and the site go.work both `replace git.golem15.com/golem15/summercms => ../summercms.go` (relative), so the site builds against the framework working tree. Do not change any `require` version. `scripts/check-deploy.sh` and `scripts/smoke.sh` do not enumerate `/new` (checked), so they stay unchanged. nginx proxies every GET/HEAD path except `/backend`, so `/update` needs no nginx change. The plugin submodule is on branch `master` at 17ebc73, and the site parent tree is clean.
|
||||
- **Working tree:** `.planning/state.json` is modified by the orchestrator. Never stage it. Stage by explicit path.
|
||||
- **Commit messages carry no co-author or attribution trailer.** The user's global and project CLAUDE.md override the session attribution reminder.
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tracer" tdd="true">
|
||||
<name>Task 1: `curl …/update | bash` end to end on a detached checkout: UpdateScript, argument parsing, app and go.mod resolution, move, reinstall, build, shared PATH hint</name>
|
||||
<files>modules/sunrise/new.sh, modules/sunrise/sunrise.go, modules/sunrise/sunrise_test.go, modules/sunrise/script_behavior_test.go</files>
|
||||
<behavior>
|
||||
- `--help` stdout still contains `Usage: curl -fsSL https://summercms.io/new | bash -s -- <name>` and `SUMMERCMS_REF`, and now also `https://summercms.io/update | bash` and `bash -s -- --update`. Stdout contains no `--upgrade`.
|
||||
- `--update demo` and `demo --update` exit 2. Stderr has `--update takes no other argument` and `Usage:`, and nothing starts (expectNothingStarted). `--update --update` inside a ready app is accepted and updates.
|
||||
- Not in an app: `--update` in h.work (no go.mod) exits 1 with `run this from inside an sm-<name>-app directory`, and nothing starts. An app dir with go.mod plus the framework replace but no summer.yaml exits 1 with the same substring and `summer.yaml`, with no go call.
|
||||
- No replace: an app whose go.mod has no framework replace exits 1 with `has no replace for git.golem15.com/golem15/summercms`. No go call.
|
||||
- Version replace: `replace git.golem15.com/golem15/summercms => git.golem15.com/golem15/summercms v0.1.13` exits 1 with `not a local checkout`. No go call.
|
||||
- Missing target: replace `=> ../nowhere` exits 1 with `does not exist`. No go call.
|
||||
- Foreign target: replace pointing at a dir whose go.mod is `module example.com/other` exits 1 with `is not a SummerCMS framework checkout`, and there is no go install.
|
||||
- Detached update (h.updateReady, then `h.remote.tag("v0.11.0")`): exits 0. HEAD is the v0.11.0 commit and still detached. Stderr has `updating the framework checkout` and `framework target: v0.11.0`. `go.install` is exactly `GOWORK=off dir=<work>/summercms\n`. `summer.call` is exactly `cwd=<app>\narg=build\n`. `summer.stdin` is empty. Stdout ends with `\n==> Done. Run summer migrate, then restart summer serve.\n` followed by `exportHint(gobin, "and add it to your shell profile")`.
|
||||
- Already at target: with no new tag, exit 0. Stderr has `already at the target`. HEAD is unchanged, and go install and summer build still run.
|
||||
- SUMMERCMS_REF on a detached checkout: with tags v0.11.0 and v0.12.0 pushed and `SUMMERCMS_REF=v0.11.0`, HEAD becomes v0.11.0.
|
||||
- Block form plus absolute path: the app's go.mod has `replace (` / `git.golem15.com/golem15/summercms v0.0.0 => <abs checkout> // local` / `)` and updates and builds.
|
||||
- Build failure: `STUB_SUMMER_FAIL=1` exits 1. Stderr has `summer build failed in <app>`. Stdout lacks `==> Done.`.
|
||||
- PATH hint: `SHELL=/usr/bin/fish` ends stdout with `fishHint(gobin)`. With gobinOnPath there is no hint.
|
||||
- Create-mode stdout is exact: a plain `h.mustRun("demo")` with empty SHELL gives stdout exactly `"\n==> Done. Start with:\n cd " + <work>/sm-demo-app + "\n" + exportHint(gobin, "and add it to your shell profile")`.
|
||||
- UpdateScript runs update mode: the harness writes `UpdateScript()` to a second file. Running it with no arguments in a ready app exits 0 and makes the same build call. Running it with argument `demo` exits 2 with `--update takes no other argument`.
|
||||
- TestUpdateScript: `UpdateScript()` equals `Script()` minus the trailing `main "$@"\n` plus `main "$@" --update\n`. Its last line is `main "$@" --update`, and it passes `bash -n`. Modifying a returned slice does not affect the next call. `Script()` still equals new.sh (TestScriptEmbedded unchanged).
|
||||
- TestScriptTruncationRunsNothing covers three variants. Script() with arg `demo` in an empty dir is today's. Script() with arg `--update` runs in an app-shaped temp tree. UpdateScript() with no args runs in the same kind of tree. For every proper prefix, no stub is invoked and the tree's file list is unchanged. For each variant, the full script reaches a stub (positive control). The stub list now also has openssl, head and base64.
|
||||
- All 32 existing TestScriptBehavior cases pass unchanged.
|
||||
</behavior>
|
||||
<action>
|
||||
Write the failing tests from `<behavior>` first, then the code.
|
||||
|
||||
modules/sunrise/new.sh. Every new piece is a function above `main`. Use tabs, `local` declarations, `</dev/null` on every git, go and summer call, and the file's comment style.
|
||||
|
||||
1. Header comment and `usage`: replace the synopsis line and paragraphs that the partial refactor wrote for the rejected rebuild-every-app design with `--update` text. The old flag spelling must be gone afterwards. Header synopsis lines: keep `curl -fsSL https://summercms.io/new | bash -s -- <name>`, and add `cd sm-<name>-app && curl -fsSL https://summercms.io/update | bash` plus the `.../new | bash -s -- --update` equivalent. The paragraph says: update mode runs inside one application, reads the framework checkout from its go.mod replace, moves it forward (detached: fast-forward only to the newest release; branch: git pull --ff-only), reinstalls summer, adds a missing admin token secret to this application's .env, runs summer build here, and names other applications sharing the checkout without rebuilding them. Note that SUMMERCMS_DIR is not used in update mode and SUMMERCMS_REPO only when cloning. Keep the closing sentence on which commands run: in update mode also openssl, or head and base64, and the installed summer. In `usage`, keep the first line byte-identical, add ` cd sm-<name>-app && curl -fsSL https://summercms.io/update | bash` and ` curl -fsSL https://summercms.io/new | bash -s -- --update`, and add a short update paragraph mirroring the header. Do not write the two T-14.3-16 words.
|
||||
|
||||
2. Add `framework_module` (prints the module path), and make check_framework_dir use it, with byte-identical messages. Move the PATH hint block at the end of `main` (from `if [[ :${PATH:-}: != *":$gobin:"* ]]` to its `fi`, with its comments and the single SC2016 directive) into `path_hint`, which takes gobin. Create mode calls `path_hint "$gobin"` where the block was. It remains the only disable directive.
|
||||
|
||||
3. Add `framework_replace` (arg: go.mod path), with the parsing rules from context. Add `is_local_path` (Go's rule).
|
||||
|
||||
4. Argument parsing in `main`, per context: help unchanged, then the `--update`/other counting. Update mode with others exits 2. Update mode alone calls `update_app` and returns. Without `--update`, today's path runs unchanged.
|
||||
|
||||
5. `update_app`, in this order (builtins only until step d):
|
||||
a. `app="$PWD"`. Without regular `$app/go.mod` or `$app/summer.yaml`, die with `run this from inside an sm-<name>-app directory: $app has no go.mod` (or `has no summer.yaml`).
|
||||
b. `framework_replace` fails: die with `$app/go.mod has no replace for <module>; --update works on applications whose go.mod points the framework at a local checkout, as summer new writes it`. A non-local first field: die with `$app/go.mod replaces <module> with <rhs>, not a local checkout; update that requirement with go get instead`.
|
||||
c. Resolve `dir` (context). If neither `-e` nor `-L` holds, die with `the framework replace in $app/go.mod points at $dir, which does not exist`. Refuse `/` like create mode. If SUMMERCMS_DIR is set and non-empty, warn `SUMMERCMS_DIR is ignored by --update; the framework checkout comes from go.mod ($dir)`. Task 2 adds the test for this warning.
|
||||
d. Check that git is installed (create mode's message) and run `check_go`. Skip check_docker.
|
||||
e. `check_framework_dir "$dir" "point the framework replace in $app/go.mod at a SummerCMS framework checkout"`.
|
||||
f. `git symbolic-ref -q --short HEAD`. On a branch, call `pull_branch "$dir" "$branch" "$ref"`. In this task its body dies with `$dir is on branch $branch` (Task 2 writes the pull in the same uncommitted change, so this never ships). Detached: `move_detached "$dir" "$ref"` with `ref="${SUMMERCMS_REF:-}"`.
|
||||
g. Call `note_shared_apps "$dir" "$app"`, a no-op that Task 2 fills.
|
||||
h. gobin via `go_bin_dir` (create mode's die message), `say "installing summer into $gobin"`, `GOWORK=off go -C "$dir" install ./cmd/summer </dev/null`.
|
||||
i. Call `ensure_admin_secret "$app"`, a no-op that Task 2 fills.
|
||||
j. `say "building $app"`, then `"$gobin/summer" build </dev/null || die "summer build failed in $app; fix the error above and run the update again"`.
|
||||
k. `printf '\n==> Done. Run summer migrate, then restart summer serve.\n'`, then `path_hint "$gobin"`.
|
||||
|
||||
modules/sunrise/sunrise.go: update the package doc to cover both URLs. Add `UpdateScript() []byte`, built from the embedded bytes per context, returning a fresh copy on every call. Compute the update bytes once at package init through a helper that panics with a clear message if new.sh does not end with `main "$@"\n`. TestScriptShape already pins that suffix, so a broken embed fails every test instead of silently serving create mode at /update. Its doc comment states the derivation and why the flag follows `"$@"`.
|
||||
|
||||
modules/sunrise/script_behavior_test.go:
|
||||
- Refactor `run` onto `runFile(file, dir string, args ...string)` (cmd.Dir and `PWD=` both set to dir; the unsetShell path sources `file`). Add `runIn(dir, args...)`. Write `UpdateScript()` to `h.updateFile` in newScriptHarness.
|
||||
- Summer stub: after reading stdin, `[[ -z ${STUB_SUMMER_FAIL:-} ]] || exit 1`.
|
||||
- Helpers: `replaceLine(target)` returns `replace ` + fwModule + ` => ` + target. `h.appAt(dir, gomod)` writes go.mod and a `summer.yaml` (`name: x`) and returns dir. `h.app(name, gomod)` places it at `<work>/sm-<name>-app`. `h.updateReady()` runs `h.mustRun("demo")`, creates `h.app("blog", "module example.com/blog\n\n" + replaceLine("../summercms") + "\n")`, runs `h.resetLogs()` and returns the app dir.
|
||||
- Add one case per `<behavior>` bullet, with names prefixed `update:`, and raise the `len(cases) < 32` floor to the new total.
|
||||
|
||||
modules/sunrise/sunrise_test.go: add TestUpdateScript. Restructure TestScriptTruncationRunsNothing into the three variants. The app-shaped tree is `<tmp>/sm-demo-app/{go.mod with replaceLine("../summercms"), summer.yaml}` plus `<tmp>/summercms/go.mod` (`module` fwModule). Compare a recursive file listing before and after each prefix. Positive control: the full script reaches the stub go through check_go. Add `openssl`, `head` and `base64` to the stub names. Keep the `len(prefixes) < 50` floor per variant.
|
||||
|
||||
Do not commit in this task.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /media/nvme/dev/golem15/summercms.io/summercms/summercms.go && set -o pipefail && shellcheck -s bash modules/sunrise/new.sh && test "$(grep -c 'shellcheck disable' modules/sunrise/new.sh)" -eq 1 && test "$(grep -Ecw 'sudo|eval' modules/sunrise/new.sh)" -eq 0 && test "$(grep -c -- '--upgrade' modules/sunrise/new.sh)" -eq 0 && bash -n modules/sunrise/new.sh && go vet ./modules/sunrise/ && go test ./modules/sunrise/ -short -count=1 -v -run '^(TestScriptEmbedded|TestScriptShape|TestScriptTruncationRunsNothing|TestScriptBehavior|TestScriptShellcheck|TestUpdateScript)$' 2>&1 | grep -E '^(--- |ok|FAIL|panic)'</automated>
|
||||
</verify>
|
||||
<done>shellcheck is clean, with exactly one disable directive, zero T-14.3-16 words and no `--upgrade` left in new.sh. go vet passes. The six named tests show `--- PASS:` (not SKIP), with no FAIL. The 32 original cases and every Task 1 `update:` case pass. UpdateScript is Script() with only the last line changed.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 2: Branch fast-forward pull, shared-checkout notice and the .env admin secret</name>
|
||||
<files>modules/sunrise/new.sh, modules/sunrise/script_behavior_test.go, modules/sunrise/sunrise_test.go</files>
|
||||
<behavior>
|
||||
- Branch fast-forward: after h.updateReady(), `switch -q main` in the checkout and `h.remote.commit()`, the run exits 0. HEAD equals the new remote main commit, the checkout is still on `main`, and go install and the build ran. Stderr has `pulling main from origin/main (fast-forward only)` and `framework checkout moved from`.
|
||||
- Branch up to date: a second run exits 0 with `framework checkout is already up to date`, and HEAD is unchanged.
|
||||
- Dirty branch: on `main`, with an edited tracked `VERSION` and a new remote commit, the run exits 1 with `uncommitted changes to tracked files`. HEAD, the branch and the file bytes are unchanged, there is no go install and no summer call.
|
||||
- No upstream: on `switch -q -c mine`, the run exits 1 with `has no upstream`. HEAD and the branch are unchanged, with no go install and no summer call.
|
||||
- Diverged: on `main` with a local commit and a new remote commit, the run exits 1 with `not a fast-forward`. HEAD is unchanged and there is no summer call.
|
||||
- SUMMERCMS_REF on a branch: `SUMMERCMS_REF=v0.2.0` on `main` with a new remote commit gives the warning `SUMMERCMS_REF is ignored for a branch checkout`. HEAD is the new main commit, not v0.2.0.
|
||||
- SUMMERCMS_DIR ignored: `SUMMERCMS_DIR=elsewhere` warns `SUMMERCMS_DIR is ignored by --update`, and the update still uses the go.mod checkout and exits 0.
|
||||
- Shared checkout: `sm-alpha-app` (replace `../summercms`) and `sm-beta-app` (block replace with the absolute checkout path) are next to the checkout, plus `sm-other-app` whose replace is `../elsewhere` (an existing dir with a framework go.mod is not needed; it just must not match). Updating `sm-blog-app` puts on stderr exactly one line containing `not rebuilt` that names `<work>/sm-alpha-app` and `<work>/sm-beta-app` and not sm-other-app or sm-blog-app. `summer.call` cwd is sm-blog-app only. The alpha and beta `.env` files (which lack the key) are byte-identical afterwards.
|
||||
- App elsewhere: an app at `<root>/apps/sm-far-app` with an absolute replace to `<work>/summercms` still lists `<work>/sm-alpha-app` (scanned next to the checkout), and updates and builds sm-far-app.
|
||||
- No other apps: stderr lacks `not rebuilt`.
|
||||
- Env append (fallback generator: no openssl in tools, real head and base64): the app `.env` is `SUMMER_DATABASE__DSN=x\n` at mode 0640. Afterwards it is the original bytes plus one line `SUMMER_ADMIN__JWT__SECRET=<v>\n`, where `<v>` matches `^[A-Za-z0-9+/]{43}=$`. The mode is still 0640. Stderr has `added SUMMER_ADMIN__JWT__SECRET to <app>/.env`. Neither stdout nor stderr contains `<v>`. The app was built.
|
||||
- Idempotent: a second run leaves `.env` byte-identical and its stderr lacks `added SUMMER_ADMIN__JWT__SECRET`.
|
||||
- No trailing newline: `SUMMER_DATABASE__DSN=x` becomes `SUMMER_DATABASE__DSN=x\nSUMMER_ADMIN__JWT__SECRET=<v>\n`.
|
||||
- Loader-form present: ` export SUMMER_ADMIN__JWT__SECRET = abc\n` is left byte-identical. A commented `# SUMMER_ADMIN__JWT__SECRET=old\n` does not count, so the line is appended after it.
|
||||
- Empty value: `SUMMER_ADMIN__JWT__SECRET=\n` is left byte-identical. Stderr warns `is empty`, and the app is still built.
|
||||
- openssl preferred: a tools/openssl stub logs its args and prints `QUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVphYmNkZWY=`. It is called with `rand -base64 32`, `.env` gets that value, and no output contains it.
|
||||
- Generator failure: an openssl stub printing `short`, with `head` removed from tools, exits 1. `.env` is unchanged. Stderr names the manual fix (`add a SUMMER_ADMIN__JWT__SECRET line`), and summer is not called.
|
||||
- No .env: the app builds, has no `.env` afterwards, and stderr says `no .env in <app>`.
|
||||
</behavior>
|
||||
<action>
|
||||
modules/sunrise/new.sh:
|
||||
|
||||
1. `pull_branch` (dir, branch, ref) replaces the Task 1 refusal:
|
||||
- A non-empty ref warns `SUMMERCMS_REF is ignored for a branch checkout; $branch is pulled from its upstream`.
|
||||
- Get the upstream with `git -C "$dir" rev-parse --abbrev-ref --symbolic-full-name '@{u}' </dev/null 2>/dev/null`. On failure, die with `$dir is on branch $branch, which has no upstream; the checkout was not changed. Set one with git branch --set-upstream-to, or pull it yourself`.
|
||||
- The clean-tree check already ran in check_framework_dir.
|
||||
- Save HEAD, then `say "pulling $branch from $upstream (fast-forward only)"`. Run `git -C "$dir" pull --quiet --no-rebase --ff-only </dev/null` inside an `if`. On failure, die with `git pull --ff-only in $dir failed: $branch has diverged from $upstream (not a fast-forward) or the fetch failed; the checkout was not changed`.
|
||||
- Compare HEAD: unchanged gives `framework checkout is already up to date`, otherwise `framework checkout moved from <old 12> to <new 12>`.
|
||||
|
||||
2. `note_shared_apps` (dir, app):
|
||||
- Take the physical checkout path (`cd "$dir" && pwd -P`) and the physical app path.
|
||||
- Scan `<physical parent of app>/sm-*-app`, and also `<physical parent of checkout>/sm-*-app` when that parent differs.
|
||||
- For each candidate that is a directory (`[[ -d ]]`), is not the app (physical compare) and has a readable go.mod: take framework_replace's first field. If it is a local path, resolve it against the candidate dir, and if the target is a directory, compare its `pwd -P` (subshell) with the checkout's.
|
||||
- Collect matches into a space-joined scalar.
|
||||
- When non-empty, print one line: `say "not rebuilt, also using $dir:$list; each picks up the new framework on its next summer build"`.
|
||||
- This function never writes, builds or reads anything but go.mod.
|
||||
|
||||
3. `ensure_admin_secret` (app), with the key name held once as `local key=` (context):
|
||||
- If `$app/.env` is not a regular file, `say "no .env in $app; skipped the admin token secret"` and return.
|
||||
- If it is not readable and writable, die.
|
||||
- Scan with the loader rules from context. After the loop, an unterminated final line gets the same check and marks that a newline is needed.
|
||||
- Key present: warn `$key in $app/.env is empty; the admin refuses to start until it has a value` only when the value is empty, then return.
|
||||
- Generate with `generate_secret`. It prefers `openssl rand -base64 32 </dev/null` when `command -v openssl` succeeds. Otherwise it uses `head -c 32 /dev/urandom | base64` when head, base64 and a readable /dev/urandom exist. It strips newlines and carriage returns with a builtin expansion, validates against the regex and prints the value, or returns 1. The value travels only through command substitution and the printf builtin.
|
||||
- Generation failed: die with `could not generate $key for $app/.env (needs openssl, or head and base64); add a $key line with any long random value, then run the update again`. The tests pin the substring `add a SUMMER_ADMIN__JWT__SECRET line`, which comes from `$key`.
|
||||
- Append with one redirection, `{ [newline if needed]; printf '%s=%s\n' "$key" "$secret"; } >>"$env"`, inside an `if`. On failure, die. On success, `say "added $key to $app/.env"`. Never print the value.
|
||||
|
||||
modules/sunrise/script_behavior_test.go:
|
||||
- Symlink the real `head` and `base64` into h.tools the way git is (LookPath, else t.Skip).
|
||||
- Add `h.stubOpenssl(output string)`, which writes tools/openssl that appends `$*` to `$STUB_LOG/openssl.calls` and prints output. Add `openssl.calls` to resetLogs.
|
||||
- Add `writeEnv(t, path, data, mode)`, which uses WriteFile then Chmod so the umask cannot change the mode.
|
||||
- Add `envValue(t, data, key)`, which scans in Go. Failure messages never print `.env` bytes beyond the key name and the value length.
|
||||
- Add one case per `<behavior>` bullet, prefixed `update:`. For the branch cases, use the git facts. Assert HEAD with h.head(), the branch with `symbolic-ref --short HEAD` and bytes with os.ReadFile. Raise the case floor.
|
||||
|
||||
modules/sunrise/sunrise_test.go, gated TestBootstrapEndToEnd. Keep every existing assertion and the final summer build. The httptest server now serves `/update` with UpdateScript() and everything else with Script(); the existing run uses `/new`. After the build:
|
||||
- Remove the `SUMMER_ADMIN__JWT__SECRET=` line from `sm-demo-app/.env` in Go, keeping the mode.
|
||||
- Run `curl -fsSL "$1" | bash` with `$1` = srv.URL+"/update`, cwd = the app and the same env.
|
||||
- Require exit 0 and output containing `already at the target`, `added SUMMER_ADMIN__JWT__SECRET to <app>/.env` and `Run summer migrate, then restart summer serve`, without the new value.
|
||||
- Require `.env` to hold exactly one active key line with a 44-character value at mode 0600, and `bin/demo` to exist.
|
||||
|
||||
The e2e run is gated and is not part of this task's acceptance.
|
||||
|
||||
Do not commit in this task.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /media/nvme/dev/golem15/summercms.io/summercms/summercms.go && set -o pipefail && shellcheck -s bash modules/sunrise/new.sh && test "$(grep -c 'shellcheck disable' modules/sunrise/new.sh)" -eq 1 && test "$(grep -Ecw 'sudo|eval' modules/sunrise/new.sh)" -eq 0 && test "$(grep -v '^[[:space:]]*#' modules/sunrise/new.sh | grep -o 'SUMMER_ADMIN__JWT__SECRET' | wc -l)" -eq 1 && bash -n modules/sunrise/new.sh && go vet ./modules/sunrise/ && go test ./modules/sunrise/ -short -count=1 -v -run '^(TestScriptEmbedded|TestScriptShape|TestScriptTruncationRunsNothing|TestScriptBehavior|TestScriptShellcheck|TestUpdateScript)$' 2>&1 | grep -E '^(--- |ok|FAIL|panic)'</automated>
|
||||
</verify>
|
||||
<done>shellcheck is clean with one disable directive, and the key name appears once in non-comment code. The six sunrise tests PASS (no SKIP or FAIL), with all original, Task 1 and Task 2 `update:` cases green. TestBootstrapEndToEnd compiles and skips because it is gated.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: Framework docs, narrowed gate, full framework gates, one feat(sunrise) commit</name>
|
||||
<files>modules/sunrise/README.md, docs/setup/installation.md, README.md, scripts/check-phase14.3.sh</files>
|
||||
<action>
|
||||
1. modules/sunrise/README.md:
|
||||
- H1 summary becomes `Bootstrap script that installs the summer CLI and creates a new SummerCMS application, or updates an existing one, in one command, embedded so it is versioned with the framework.`, and the root README modules table row is updated to the same sentence (CLAUDE.md rule).
|
||||
- Overview: add a paragraph on `cd sm-blog-app && curl -fsSL https://summercms.io/update | bash` (and `.../new | bash -s -- --update`) covering the Task 1 and 2 steps in plain prose: go.mod and summer.yaml required; checkout read from the go.mod replace; detached fast-forward only and branch `git pull --ff-only` with its refusals; other apps on the same checkout named and not rebuilt; summer reinstalled; a missing SUMMER_ADMIN__JWT__SECRET appended to this app's .env once and never printed; summer build here; the migrate and restart reminder. Say that `sunrise.UpdateScript` serves the same script with update mode preset.
|
||||
- Features: add bullets for update mode, the shared-checkout notice and the secret append. In the "No privilege escalation and no dynamic code" bullet, add that update mode also runs openssl, or head and base64, and the installed summer.
|
||||
- Usage: add the update fence, and a sentence that it takes no other argument and works the same from bash, zsh and fish because the piped script runs in bash. In the Go example, add a sentence (or a second handler) serving `sunrise.UpdateScript()` the same way at a second path.
|
||||
- API reference: add a `sunrise.UpdateScript` row.
|
||||
- Configuration: the script never reads or exports `SUMMER_`-prefixed variables. The only such name it writes is SUMMER_ADMIN__JWT__SECRET, appended to the application's .env by update mode. Note that SUMMERCMS_DIR is ignored (with a warning) in update mode because the checkout comes from go.mod, that SUMMERCMS_REF applies to detached checkouts and is ignored on a branch, and that SUMMERCMS_REPO is used only when cloning.
|
||||
- Testing: the harness covers update mode, and the gated run also updates the generated app through /update.
|
||||
- No consuming-application names. Only real identifiers (`sunrise.Script`, `sunrise.UpdateScript`).
|
||||
|
||||
2. docs/setup/installation.md: add `## Updating an application` after "Run the application" and before "Create an application without the script".
|
||||
- A `sh` fence with `cd sm-demo-app` and `curl -fsSL https://summercms.io/update | bash`. Say it works the same from bash, zsh and fish, and mention the `.../new | bash -s -- --update` form.
|
||||
- A numbered list of what it does: the same content as the README overview, including the refusals (dirty tree, no upstream, diverged branch, module-version replace), which leave the checkout unchanged, the shared-checkout line, and the .env secret for apps created before the admin was wired in.
|
||||
- Then an `sh` fence with `summer migrate` and `summer serve` (restart).
|
||||
- A short manual-update paragraph with an `sh` fence: `git -C ../summercms fetch --tags`, `git -C ../summercms checkout --detach vX.Y.Z` (with `git -C ../summercms tag -l 'v*' --sort=-v:refname` listing tags newest first, or `git -C ../summercms pull --ff-only` for a branch), `go -C ../summercms install ./cmd/summer`, `summer build`, `summer migrate`. Say that a missing SUMMER_ADMIN__JWT__SECRET needs one .env line with a long random value, for example from `openssl rand -base64 32`.
|
||||
- Use only `sh` fences. In "What the script does" step 2, after the branch-checkout refusal, add a clause linking `#updating-an-application`: the update script pulls a branch checkout instead. Note SUMMERCMS_DIR is not used by the update script. Leave the frontmatter unchanged.
|
||||
|
||||
3. Root README.md, after the sentence at line 32 pointing at sunrise: add one sentence. Later, `curl -fsSL https://summercms.io/update | bash` run inside the application moves the framework checkout forward, reinstalls summer and rebuilds that application. Also update the table row (step 1).
|
||||
|
||||
4. scripts/check-phase14.3.sh:
|
||||
- In `stage_forbidden`, replace the single `grep -nE 'SUMMER_'` check with two checks. (a) Every `grep -noE 'SUMMER_[A-Z0-9_]*'` token in new.sh must be exactly SUMMER_ADMIN__JWT__SECRET. Otherwise fail with `new.sh names a framework config variable other than SUMMER_ADMIN__JWT__SECRET`. (b) Fail on `grep -nE '\$\{?SUMMER_|export[[:space:]]+SUMMER_'` with `new.sh reads or exports a framework config variable`.
|
||||
- Update the `--forbidden` header paragraph to match.
|
||||
- Add `TestUpdateScript` to REQUIRED_TESTS and `TestUpdateScriptRoute` to SITE_PLUGIN_TESTS. The site test lands in Task 4, so run --unit only after Task 4.
|
||||
- Change nothing else.
|
||||
|
||||
5. Run the verify gates; `go test ./... -short` may take minutes. Negative self-check without committing: copy new.sh to the scratchpad, append a line containing `$SUMMER_APP__KEY` and another containing `SUMMER_APP__KEY=x`, and run the two new grep expressions against the copy. (b) must match the first line and (a) the second.
|
||||
|
||||
6. Commit exactly the eight framework files (new.sh, sunrise.go, sunrise_test.go, script_behavior_test.go, sunrise README, installation.md, README.md, check-phase14.3.sh), staged by explicit path, never .planning/state.json. Subject: `feat(sunrise): update an application in place with summercms.io/update`. The body names: --update mode run inside one app, the checkout read from the go.mod replace, the detached and branch fast-forward paths, the shared-checkout notice, the summer reinstall, the .env admin secret, summer build, UpdateScript and the narrowed --forbidden scan. No co-author or attribution trailer. No tag or push.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /media/nvme/dev/golem15/summercms.io/summercms/summercms.go && grep -qF '## Updating an application' docs/setup/installation.md && grep -qF 'https://summercms.io/update | bash' docs/setup/installation.md && grep -qF 'sunrise.UpdateScript' modules/sunrise/README.md && grep -qF 'https://summercms.io/update' README.md && test -z "$(grep -rn -- '--upgrade' modules/sunrise docs/setup/installation.md README.md)" && test -z "$(grep -noE 'SUMMER_[A-Z0-9_]*' modules/sunrise/new.sh | grep -v ':SUMMER_ADMIN__JWT__SECRET$')" && grep -qF 'TestUpdateScriptRoute' scripts/check-phase14.3.sh && go vet ./... && go test ./cmd/summer -run '^TestDocsTree$' -count=1 && go run ./cmd/summer docs:build --check && bash scripts/check-phase14.3.sh --forbidden && go test ./... -short -count=1</automated>
|
||||
</verify>
|
||||
<done>go vet and go test ./... -short are green. TestDocsTree passes and docs:build --check reports nothing. `check-phase14.3.sh --forbidden` prints "Phase 14.3 forbidden-surface scan passed", and the negative self-check catches both an expansion and another SUMMER_ name. One framework commit holds the eight files, with no trailer and without state.json.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 4: Site GET /update route, tests, READMEs; submodule commit, pointer bump, cross-repo gates</name>
|
||||
<precondition>`git -C /media/nvme/dev/golem15/summercms.io/summercms/sm-summercmsio-app status --porcelain` is empty and `git -C .../sm-summercmsio-app/plugins/golem15/summercms branch --show-current` prints master; if not, stop and report.</precondition>
|
||||
<files>../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, ../sm-summercmsio-app/README.md, ../sm-summercmsio-app/CLAUDE.md</files>
|
||||
<action>
|
||||
1. plugin.go: register `g.Get("/update", scriptHandler(sunrise.UpdateScript()))` right after `/new` and before `/`. Update the package doc and the Routes doc comment to name GET /update (the update script from sunrise.UpdateScript). script.go stays unchanged: scriptHandler already takes the body, and the name it passes to ServeContent is unused because Content-Type is set.
|
||||
|
||||
2. routes_test.go:
|
||||
- TestRoutesAssemble gets `/update` rows: GET 200 with body `string(sunrise.UpdateScript())`, HEAD 200, POST 405.
|
||||
- Extract TestNewScriptRoute's body into `checkScriptRoute(t, h, target string, want []byte) (etag string)`. TestNewScriptRoute calls it for /new.
|
||||
- Add TestUpdateScriptRoute, which calls it for /update (body equals UpdateScript(), same four headers, strong 16-hex ETag, 304 on a match, 200 on a stale tag) and asserts its ETag differs from /new's.
|
||||
- TestRoutesCoexistWithAdminPatterns: wantPatterns becomes `GET /docs`, `GET /docs/{path...}`, `GET /new`, `GET /update`, `GET /`, and pluginHandlers gains `"GET /update": scriptHandler(sunrise.UpdateScript())`.
|
||||
|
||||
3. Plugin README.md: add the `GET /update` row (sunrise.UpdateScript(), same headers, for `curl -fsSL https://summercms.io/update | bash` run inside an application directory).
|
||||
|
||||
4. Site README.md line 3 and CLAUDE.md line 7: add a short mention of the update script at `/update` (`sunrise.UpdateScript()`, `curl -fsSL https://summercms.io/update | bash`) beside the existing `/new` mention. Change nothing else. scripts/check-deploy.sh, scripts/smoke.sh, nginx and DEPLOY.md stay unchanged (see context).
|
||||
|
||||
5. Verify (command below). Then commit inside the submodule. Run `ssu status` first if useful, and stage the three plugin files by path. Message: `feat: serve the SummerCMS update script at GET /update`, with a body naming the route, the shared headers and the tests.
|
||||
|
||||
6. In the site parent, stage `plugins/golem15/summercms`, README.md and CLAUDE.md by path and commit `feat: bump the site plugin to its GET /update route`. No trailers, no push, no require bump, no deploy.
|
||||
|
||||
7. Cross-repo gate: from the framework repo, run `bash scripts/check-phase14.3.sh --unit`. It runs the full non-short suite (Docker), the site plugin and app tests with named PASS for TestNewScriptRoute, TestUpdateScriptRoute and TestRoutesAssemble, pnpm generate and test, and --forbidden. It can exceed ten minutes, so run it in the background with a generous timeout and wait for it. Record the result in the SUMMARY. Optional, when network and time allow: `SUMMERCMS_BOOTSTRAP_CHECK=1 go test ./modules/sunrise/ -run '^TestBootstrapEndToEnd$' -count=1 -timeout 20m`. Record the result too.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /media/nvme/dev/golem15/summercms.io/summercms/sm-summercmsio-app && set -o pipefail && go -C plugins/golem15/summercms vet ./... && go -C plugins/golem15/summercms test ./... -count=1 -v -run '^(TestRoutesAssemble|TestNewScriptRoute|TestUpdateScriptRoute|TestRoutesCoexistWithAdminPatterns)$' 2>&1 | grep -E '^(--- |ok|FAIL|panic)' && go vet ./... && go test ./... -count=1 && grep -qF 'GET /update' plugins/golem15/summercms/README.md && grep -qF '/update' README.md && git -C plugins/golem15/summercms diff --quiet go.mod && git diff --quiet HEAD -- go.mod</automated>
|
||||
</verify>
|
||||
<done>The four site route tests PASS. The site plugin and app vet and test green. Neither go.mod changed. One submodule commit holds the three plugin files. One parent commit bumps the pointer and updates README.md and CLAUDE.md. `check-phase14.3.sh --unit` prints "Phase 14.3 unit stage passed". Nothing is pushed, tagged or deployed.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| remote repository → framework checkout | `git fetch`/`git pull` bring upstream refs; the checkout moves only by fast-forward |
|
||||
| app go.mod → script | the replace target picks which checkout is updated, compiled into summer and executed |
|
||||
| script → app `.env` | the script appends a secret the framework reads as configuration |
|
||||
| script output → terminal and logs | output may land in CI logs; it must never contain the secret |
|
||||
| site → curl \| bash | GET /update bytes are executed by the user's shell |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-o6k-01 | Tampering | checkout move in update mode | high | mitigate | Detached checkouts go through the existing move_detached (tags never force-updated, ancestor check). Branch checkouts run `pull --no-rebase --ff-only` only when the tracked tree is clean and an upstream exists. Dirty, no-upstream and diverged cases die before any change. Pinned by the `update:` dirty, no-upstream and diverged cases (HEAD, branch and bytes unchanged, no install or build). |
|
||||
| T-o6k-02 | Information Disclosure | generated SUMMER_ADMIN__JWT__SECRET | high | mitigate | The value passes only through command substitution and the printf builtin. `say` names only the key and the file. Tests assert stdout and stderr lack the value for both generators, and the gated end-to-end run checks the same. |
|
||||
| T-o6k-03 | Tampering | app `.env` | medium | mitigate | Append-only `>>` keeps mode and owner, and a missing final newline is added first. Presence follows the compass loader, so no duplicate key is written, and an existing empty value is left alone with a warning. Pinned by the 0640, no-newline, export-form, commented, empty and idempotent cases. |
|
||||
| T-o6k-04 | Elevation of Privilege | building or writing in the wrong directory | medium | mitigate | Only `$PWD` is built, and only with go.mod, summer.yaml and a local-path framework replace to a clean framework checkout. Sibling apps are read (go.mod only), never written or built. Pinned by the shared-checkout case (sibling .env bytes unchanged, a single build cwd). |
|
||||
| T-o6k-05 | Elevation of Privilege | malicious replace target in an untrusted app | medium | accept | Update mode compiles and runs summer from the checkout the app's go.mod names. That is the same trust as running `summer build` in that app yourself, which already compiles the app's code. The target must still pass check_framework_dir (framework module, git root, clean tree). The docs say to run it inside your own application. |
|
||||
| T-o6k-06 | Spoofing | weak admin token secret | medium | mitigate | 32 bytes from `openssl rand` or `/dev/urandom`, validated as 44-character base64 before writing. Anything else writes nothing and exits 1 with the manual fix. Pinned by the generator-failure case. |
|
||||
| T-o6k-07 | Tampering | truncated download of /new or /update | high | mitigate | UpdateScript changes only the last line, to `main "$@" --update`, so every proper prefix of it runs main without valid update arguments, which exits 2. TestScriptTruncationRunsNothing runs every prefix of both scripts against stubs, with positive controls. |
|
||||
| T-o6k-08 | Elevation of Privilege | shell injection via go.mod paths or app dir names | high | mitigate | Replace targets and paths are only quoted arguments to abspath, `cd`, `git -C` and `go -C`, and are never evaluated. shellcheck stays clean with one SC2016 directive, and the T-14.3-16 words stay absent. Pinned by TestScriptShellcheck and the verify greps. |
|
||||
| T-o6k-09 | Tampering | weakened --forbidden gate | medium | mitigate | The allowlist holds one exact name. Every other SUMMER_ token and any `$SUMMER_`/`${SUMMER_` expansion or `export SUMMER_` still fails. Task 3's negative self-check proves both. D-26 is unchanged. |
|
||||
| T-o6k-10 | Tampering | site GET /update | medium | mitigate | The route serves the embedded UpdateScript() as text/plain with nosniff, no-cache and a strong ETag. No request data is reflected. nginx allows only GET and HEAD. Pinned by TestUpdateScriptRoute and the TestRoutesAssemble 405 row. |
|
||||
| T-o6k-11 | Denial of Service | `set -u`/`set -e` pitfalls on bash 3.2 | low | mitigate | Empty-array expansions are guarded or replaced by scalars. The counter uses `x=$((x + 1))`. The positional loop is guarded by `(($#))`. The no-other-apps and no-args cases exercise these paths. |
|
||||
| T-o6k-SC | Tampering | npm/pip/cargo installs | low | accept | No package installs and no new Go requirement. `--forbidden` confirms go.mod gained no require since v0.1.12, and the site go.mod files are verified unchanged. |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- shellcheck is clean on new.sh, with exactly one disable directive, zero T-14.3-16 words, no `--upgrade`, and the key name once in non-comment code.
|
||||
- TestScriptBehavior passes the 32 original cases plus every `update:` case. TestUpdateScript and the three-variant truncation test pass.
|
||||
- Framework: `go vet ./...`, `go test ./... -short`, TestDocsTree, `docs:build --check` and `check-phase14.3.sh --forbidden` are green. After the site commit, `check-phase14.3.sh --unit` is green.
|
||||
- Site: the plugin and app vet and test green, with TestUpdateScriptRoute passing. The go.mod files are unchanged.
|
||||
- Commits: one framework `feat(sunrise)`, one plugin-submodule `feat:`, one parent pointer bump. No trailers, push, tag or deploy.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
A user who created `sm-blog-app` with the one-liner runs `cd sm-blog-app && curl -fsSL https://summercms.io/update | bash` from fish, zsh or bash. The framework checkout named in go.mod moves forward, fast-forward only. Other apps on the same checkout are named and left untouched. summer is reinstalled, a missing admin token secret is added to this app's `.env` without being printed, the app is rebuilt, and the script says to run summer migrate and restart summer serve. Local framework work is never overwritten. Create mode is byte-identical. Both URLs serve one script, and the site serves /update once the orchestrator releases and deploys.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/quick/261008-o6k-bootstrap-upgrade-mode-for-existing-chec/261008-o6k-SUMMARY.md` when done
|
||||
</output>
|
||||
@@ -0,0 +1,158 @@
|
||||
---
|
||||
phase: quick-261008-o6k
|
||||
plan: 01
|
||||
subsystem: sunrise (bootstrap script), summercms.io site plugin
|
||||
status: complete
|
||||
tags: [bootstrap, update, curl-bash, site-route, admin-secret]
|
||||
requires: [quick-261008-kdh (apps carry SUMMER_ADMIN__JWT__SECRET), phase 14.3 (new.sh, GET /new)]
|
||||
provides:
|
||||
- "new.sh --update: per-app framework update run inside one sm-<name>-app"
|
||||
- "sunrise.UpdateScript(): Script() with the last line `main \"$@\" --update`"
|
||||
- "site GET /update serving sunrise.UpdateScript()"
|
||||
affects: [modules/sunrise, docs/setup/installation.md, scripts/check-phase14.3.sh, sm-summercmsio-app]
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns:
|
||||
- "one script, two URLs: UpdateScript derived from the embedded bytes by one suffix replacement"
|
||||
- "go.mod replace parsing in bash mirrors internal/build parseGoMod/splitReplace"
|
||||
- ".env presence check mirrors compass readDotEnv; append-only >> write"
|
||||
key-files:
|
||||
created: []
|
||||
modified:
|
||||
- modules/sunrise/new.sh
|
||||
- modules/sunrise/sunrise.go
|
||||
- modules/sunrise/sunrise_test.go
|
||||
- modules/sunrise/script_behavior_test.go
|
||||
- modules/sunrise/README.md
|
||||
- docs/setup/installation.md
|
||||
- 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
|
||||
- ../sm-summercmsio-app/README.md
|
||||
- ../sm-summercmsio-app/CLAUDE.md
|
||||
decisions:
|
||||
- "Update mode lives in new.sh behind --update; /update serves the same bytes with the final line `main \"$@\" --update`, so truncation still runs nothing"
|
||||
- "Only $PWD is built; other apps sharing the checkout are named on one `not rebuilt` line and never written"
|
||||
- "--forbidden allows exactly one SUMMER_ token in new.sh (SUMMER_ADMIN__JWT__SECRET) and still fails on any read or export of a SUMMER_ variable"
|
||||
- "generate_secret falls back to head+base64 when openssl is missing or prints an invalid value"
|
||||
metrics:
|
||||
duration: "not measured (start time was not recorded)"
|
||||
completed: 2026-10-08
|
||||
estimate:
|
||||
tokens: 190000
|
||||
tasks: 4
|
||||
actuals:
|
||||
tokens: 23000
|
||||
tasks: 4
|
||||
commits: 1
|
||||
plan_head_before: 27f75308d74bb28e645d2f3eaf9d94e2e80775eb
|
||||
plan_head_after: 9b3803c098707b9c25099fbfb2d0fb84d44be8b3
|
||||
---
|
||||
|
||||
# Phase quick-261008-o6k Plan 01: Per-app `/update` bootstrap mode Summary
|
||||
|
||||
You can now update one application in place with `cd sm-blog-app && curl -fsSL https://summercms.io/update | bash`, or the equivalent `.../new | bash -s -- --update`. It reads the framework checkout from the app's go.mod replace and moves it forward fast-forward only, detached or on a branch. It names other apps on the same checkout without rebuilding them, reinstalls summer, appends a missing SUMMER_ADMIN__JWT__SECRET to this app's .env without printing it, and runs `summer build`. The site serves the script at GET /update.
|
||||
|
||||
`actuals.commits: 1` counts the framework repository only (measured from the ledger). The site adds two more commits, listed below.
|
||||
|
||||
## Commits
|
||||
|
||||
| Repo | Commit | Message |
|
||||
|------|--------|---------|
|
||||
| summercms.go | 9b3803c | feat(sunrise): update an application in place with summercms.io/update |
|
||||
| sm-summercmsio-app/plugins/golem15/summercms (master) | 511e692 | feat: serve the SummerCMS update script at GET /update |
|
||||
| sm-summercmsio-app (master) | bd252c6 | feat: bump the site plugin to its GET /update route |
|
||||
|
||||
No tag, push, deploy or `require` bump. `.planning/state.json` was not staged.
|
||||
|
||||
## What was built
|
||||
|
||||
- **new.sh**
|
||||
- The header and usage text are rewritten for `--update`, and no text from the rejected flag is left.
|
||||
- `framework_module` keeps the module literal in one place. `check_framework_dir` uses it, and its messages are unchanged.
|
||||
- `trim`, `is_local_path` and `framework_replace` are new. `framework_replace` handles single-line and block replaces, strips `//` comments, accepts a version on the left and a quoted right-hand side, and prints the path or module plus an optional version line.
|
||||
- `path_hint` is extracted from `main`, and the single SC2016 directive moved with it. Create-mode stdout is byte-identical, which an exact-equality case pins.
|
||||
- `pull_branch` warns that SUMMERCMS_REF is ignored on a branch, refuses when there is no upstream, and runs `git pull --quiet --no-rebase --ff-only`.
|
||||
- `note_shared_apps` scans `sm-*-app` next to the app and next to the checkout, compares physical paths, and only reads go.mod.
|
||||
- `env_line_state` follows the compass loader rules: trimmed lines, `#` comments skipped, the `export ` prefix, the key trimmed, matching quotes removed.
|
||||
- `generate_secret` uses openssl first, then head and base64, and validates the result against `^[A-Za-z0-9+/]{43}=$`.
|
||||
- `ensure_admin_secret` uses one append with `>>`. It writes a leading newline only when the last line is unterminated, keeps the file mode and never prints the value.
|
||||
- `update_app` handles the app checks, the replace and target checks, the SUMMERCMS_DIR warning, git and Go, the shared-checkout notice, the reinstall, the secret, the build and the migrate/restart line plus the hint.
|
||||
- `main` counts `--update` against other arguments. It exits 2 on a mix, accepts a repeated `--update`, and uses `others=$((others + 1))` with a `(($#))` guard.
|
||||
- **sunrise.go**: `UpdateScript()` is derived once at init by `deriveUpdateScript`. That function panics when new.sh does not end with `main "$@"\n`.
|
||||
- **Tests**
|
||||
- TestScriptBehavior has 67 cases: the 32 originals, unchanged, plus 35 `update:` cases.
|
||||
- TestUpdateScript is new, along with TestDeriveUpdateScriptPanicsWithoutMainCall.
|
||||
- TestScriptTruncationRunsNothing now covers three variants: Script with demo, Script with --update, and UpdateScript. Each compares a recursive file listing and has its own positive control.
|
||||
- The gated TestBootstrapEndToEnd now also runs `/update` inside the generated app after removing its secret.
|
||||
- **Docs**: the sunrise README covers the new summary sentence, an Overview paragraph, Features, Usage, the `sunrise.UpdateScript` API row, Configuration and Testing. installation.md gains `## Updating an application` with the manual path and a `#updating-an-application` link from step 2. The root README gets a new sentence and the updated table row.
|
||||
- **Gate**: `--forbidden` is narrowed to a check on `SUMMER_` tokens plus a check on reads and exports. REQUIRED_TESTS gains TestUpdateScript, and SITE_PLUGIN_TESTS gains TestUpdateScriptRoute.
|
||||
- **Site**
|
||||
- GET /update is registered between /new and /.
|
||||
- `checkScriptRoute` is extracted from TestNewScriptRoute, and TestUpdateScriptRoute checks that its ETag differs from the one on /new.
|
||||
- TestRoutesAssemble gains /update rows (GET 200, HEAD 200, POST 405).
|
||||
- TestRoutesCoexistWithAdminPatterns lists GET /update.
|
||||
- The plugin README, site README and CLAUDE.md each mention the route.
|
||||
|
||||
## Verification
|
||||
|
||||
- `shellcheck -s bash new.sh` is clean. There is one `shellcheck disable`, no T-14.3-16 words and no rejected-flag text. SUMMER_ADMIN__JWT__SECRET appears once in non-comment code, and `bash -n` passes.
|
||||
- The six named sunrise tests show `--- PASS`.
|
||||
- On the framework: `go vet ./...`, `go test ./... -short -count=1`, TestDocsTree, `summer docs:build --check` ("no problems found") and `check-phase14.3.sh --forbidden` (scan passed) are all green.
|
||||
- Negative self-check of the narrowed gate on a scratch copy of new.sh: check (a) flagged `SUMMER_APP__KEY`, `SUMMER_DB` and `SUMMER_Q`. Check (b) flagged `"$SUMMER_APP__KEY"`, `"${SUMMER_DB}"` and `export SUMMER_Q=1`. The real new.sh matches neither.
|
||||
- On the site: plugin vet and tests, with TestRoutesAssemble, TestNewScriptRoute, TestUpdateScriptRoute and TestRoutesCoexistWithAdminPatterns all PASS. App vet and tests pass, and neither go.mod changed.
|
||||
- `bash scripts/check-phase14.3.sh --unit` exited 0 with "Phase 14.3 unit stage passed". The log shows PASS for TestUpdateScript, TestNewScriptRoute, TestUpdateScriptRoute and TestRoutesAssemble. The pnpm suite reported 32/32.
|
||||
- `SUMMERCMS_BOOTSTRAP_CHECK=1 go test ./modules/sunrise/ -run '^TestBootstrapEndToEnd$'` PASSED in 14.6s. That covers create mode through curl|bash, then `/update` inside the app: already at the target, secret re-added and not printed, mode 0600, `bin/demo` rebuilt.
|
||||
- Mutation spot checks, run against a scratch backup with the tree restored afterwards:
|
||||
- These mutations each made at least one `update:` case fail: dropping `--ff-only`, skipping the missing-newline fix, treating an empty value as absent, dropping the shared list, ignoring the `export ` form, and skipping the build.
|
||||
- Dropping the `#`-comment guard is not caught. That guard is redundant by construction, because a commented line's key starts with `#` and can never equal the key name.
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
### Auto-fixed Issues
|
||||
|
||||
**1. [Rule 1 - Test strength] Diverged-branch case gets a git identity**
|
||||
- **Found during:** Task 2 mutation spot check
|
||||
- **Issue:** With `--ff-only` removed, the diverged case still passed, because `git pull --no-rebase` failed on a missing identity rather than on the fast-forward rule.
|
||||
- **Fix:** The case now sets GIT_AUTHOR_* and GIT_COMMITTER_* through `h.env`, so a plain pull would merge and move HEAD, and only `--ff-only` refuses.
|
||||
- **Files modified:** modules/sunrise/script_behavior_test.go
|
||||
- **Commit:** 9b3803c
|
||||
|
||||
**2. [Rule 3 - Blocking] The literal of the rejected flag in a test**
|
||||
- **Issue:** The help case's `expectLacks(..., "--upgrade")` made the Task 3 grep `grep -rn -- '--upgrade' modules/sunrise ...` non-empty.
|
||||
- **Fix:** The string is now built as `"--"+"upgrade"`, with a comment.
|
||||
- **Commit:** 9b3803c
|
||||
|
||||
**3. [Process] Code written before tests in Tasks 1 and 2**
|
||||
- The plan asked for failing tests first. The script and UpdateScript were written first, then the 35 `update:` cases and the truncation and UpdateScript tests.
|
||||
- To compensate, mutation spot checks (listed above) confirmed that the cases detect regressions.
|
||||
|
||||
### Small additions within scope
|
||||
- `TestDeriveUpdateScriptPanicsWithoutMainCall` pins the init-time panic.
|
||||
- `generate_secret` also falls back to head and base64 when openssl is present but prints an invalid value. The plan only specified the fallback for a missing openssl. The generator-failure case, with openssl printing `short` and head removed, still exits 1 with .env unchanged.
|
||||
- `update_app` refuses a replace that resolves to `/` before the existence check.
|
||||
- The e2e test removes `bin/demo` before the update run, which proves the rebuild rather than only checking that the file exists.
|
||||
|
||||
## Not tested
|
||||
|
||||
- macOS bash 3.2. The script avoids the known pitfalls: empty-array expansion, `((x++))`, mapfile and nullglob. All runs were on the Linux bash.
|
||||
- A real `git pull` against an HTTPS remote that needs credentials. `GIT_TERMINAL_PROMPT=0` makes it fail rather than prompt, which surfaces as the generic "diverged or the fetch failed" message.
|
||||
- A quoted go.mod replace path that contains spaces. Quotes are stripped, but no case covers it.
|
||||
- An unreadable or unwritable `.env`. The script dies with a message, but no case covers it.
|
||||
- The live site. GET /update is not deployed, by design, since the orchestrator releases.
|
||||
|
||||
## Known Stubs
|
||||
|
||||
None.
|
||||
|
||||
## Threat Flags
|
||||
|
||||
None. The new surface (GET /update, the `.env` append, reading go.mod replace targets) is covered by T-o6k-01..11 in the plan.
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
- FOUND: modules/sunrise/new.sh, modules/sunrise/sunrise.go, modules/sunrise/sunrise_test.go, modules/sunrise/script_behavior_test.go, modules/sunrise/README.md, docs/setup/installation.md, README.md, scripts/check-phase14.3.sh
|
||||
- FOUND: ../sm-summercmsio-app/plugins/golem15/summercms/{plugin.go,routes_test.go,README.md}, ../sm-summercmsio-app/{README.md,CLAUDE.md}
|
||||
- FOUND commits: 9b3803c (summercms.go), 511e692 (plugin submodule), bd252c6 (site parent)
|
||||
@@ -29,7 +29,7 @@ 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.
|
||||
The [sunrise](modules/sunrise/README.md) module holds the script and documents its `SUMMERCMS_REPO`, `SUMMERCMS_DIR` and `SUMMERCMS_REF` overrides. Later, `curl -fsSL https://summercms.io/update | bash` run inside the application moves the framework checkout forward, reinstalls summer and rebuilds that application.
|
||||
|
||||
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>`:
|
||||
|
||||
@@ -73,7 +73,7 @@ The `migrate`, `migrate:status`, `migrate:rollback`, `serve` and admin commands
|
||||
curl http://127.0.0.1:8080/items/1
|
||||
```
|
||||
|
||||
`summer migrate`, `summer migrate:status`, `summer migrate:rollback` and `summer serve`, run from the application directory, delegate to the built binary in `bin/`.
|
||||
`summer migrate`, `summer migrate:status`, `summer migrate:rollback`, `summer serve`, `summer queue:work`, `summer queue:clear`, `summer schedule:run`, `summer route:list`, `summer key:generate`, `summer admin:create` and `summer admin:reset-password`, run from the application directory, delegate to the built binary in `bin/` and pass their flags to it unchanged.
|
||||
|
||||
## Repository layout
|
||||
|
||||
@@ -108,7 +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. |
|
||||
| [sunrise](modules/sunrise/README.md) | Bootstrap script that installs the summer CLI and creates a new SummerCMS application, or updates an existing one, 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. |
|
||||
|
||||
@@ -26,7 +26,7 @@ func main() {
|
||||
}
|
||||
|
||||
func toolCommands() []bonfire.Command {
|
||||
return []bonfire.Command{
|
||||
return append([]bonfire.Command{
|
||||
buildCommand(),
|
||||
makePluginCommand(),
|
||||
makeModelCommand(),
|
||||
@@ -42,17 +42,10 @@ func toolCommands() []bonfire.Command {
|
||||
parityReplayCommand(),
|
||||
parityBroadcastsCommand(),
|
||||
parityUpstreamCommand(),
|
||||
delegateCommand("migrate", "Run plugin migrations in the app binary"),
|
||||
delegateRollbackCommand(),
|
||||
delegateCommand("migrate:status", "Show per-plugin migration history in the app binary"),
|
||||
delegateCommand("serve", "Run the app HTTP server"),
|
||||
delegateQueueWorkCommand(),
|
||||
delegateScheduleRunCommand(),
|
||||
delegateCommand("queue:clear", "Clear pending queued jobs in the app binary"),
|
||||
docsBuildCommand(),
|
||||
docsSyncCommand(),
|
||||
docsServeCommand(),
|
||||
}
|
||||
}, delegateCommands()...)
|
||||
}
|
||||
|
||||
func buildCommand() bonfire.Command {
|
||||
|
||||
@@ -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", "new", "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", "route:list", "key:generate", "admin:create", "admin:reset-password", "parity:broadcasts", "parity:upstream", "docs:build", "docs:sync", "docs:serve"} {
|
||||
if !slices.Contains(names, want) {
|
||||
t.Fatalf("missing %s in %v", want, names)
|
||||
}
|
||||
@@ -33,7 +33,6 @@ func TestToolCommandNames(t *testing.T) {
|
||||
"make:command": {"[plugin] [name]"},
|
||||
"make:job": {"[plugin] [name]"},
|
||||
"make:admin-controller": {"[plugin] [name]"},
|
||||
"schedule:run": {"--once"},
|
||||
"parity:broadcasts": {"--flow", "--step", "--ids", "127.0.0.1:8424"},
|
||||
"parity:upstream": {"--listen", "--ca-dir", "--out", "--mode", "--script", "--vars", "127.0.0.1:8425"},
|
||||
"docs:build": {"--out", "--src", "--root", "--base-url", "--check"},
|
||||
|
||||
@@ -2,86 +2,72 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"git.golem15.com/golem15/summercms/internal/build"
|
||||
"git.golem15.com/golem15/summercms/modules/bonfire"
|
||||
)
|
||||
|
||||
func delegateCommand(name, description string) bonfire.Command {
|
||||
// appRunner runs the app binary with argv; tests replace it.
|
||||
var appRunner = runAppBinary
|
||||
|
||||
// delegateCommands returns every command summer runs on the app binary. One
|
||||
// table is the single source: name, base description and a hint naming the
|
||||
// common flags or arguments the app binary accepts.
|
||||
func delegateCommands() []bonfire.Command {
|
||||
rows := []struct{ name, description, hint string }{
|
||||
{"migrate", "Run plugin migrations in the app binary", ""},
|
||||
{"migrate:rollback", "Roll back the last migration of a plugin in the app binary", "--plugin <id>"},
|
||||
{"migrate:status", "Show per-plugin migration history in the app binary", ""},
|
||||
{"serve", "Run the app HTTP server", "--addr <host:port> (default :8080)"},
|
||||
{"queue:work", "Run background job workers in the app binary", "--queue <name> (repeatable)"},
|
||||
{"queue:clear", "Clear pending queued jobs in the app binary", `[queue] argument (default "default")`},
|
||||
{"schedule:run", "Run the scheduler in the app binary (or once for system cron)", "--once"},
|
||||
{"route:list", "List the app's routes from the app binary", ""},
|
||||
{"key:generate", "Print a fresh application key from the app binary", ""},
|
||||
{"admin:create", "Create a backend administrator in the app binary", "--email <email> (required), --login, --role <code>, --superuser; the password is prompted or read from stdin"},
|
||||
{"admin:reset-password", "Reset a backend administrator's password in the app binary", "<identifier> (login or email); the password is prompted or read from stdin"},
|
||||
}
|
||||
cmds := make([]bonfire.Command, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
cmds = append(cmds, delegateCommand(r.name, r.description, r.hint))
|
||||
}
|
||||
return cmds
|
||||
}
|
||||
|
||||
// delegateCommand returns a passthrough command that runs name on the app
|
||||
// binary with every argument summer was given.
|
||||
func delegateCommand(name, description, hint string) bonfire.Command {
|
||||
long := description + ".\n\nsummer runs bin/<binary> " + name +
|
||||
" in the application directory, building the binary first when it is missing. Every flag and argument after " +
|
||||
name + ", including --help, is passed to the app binary unchanged."
|
||||
if hint != "" {
|
||||
long += "\n\nCommon flags: " + hint + "."
|
||||
}
|
||||
long += "\n\nRun summer " + name + " --help inside an application to list all of them."
|
||||
return bonfire.Command{
|
||||
Name: name,
|
||||
Description: description,
|
||||
Description: description + " (flags pass to the app binary)",
|
||||
Long: long,
|
||||
Passthrough: true,
|
||||
Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error {
|
||||
argv := []string{name}
|
||||
argv = append(argv, in.Args()...)
|
||||
return runAppBinary(ctx, argv, out)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func delegateRollbackCommand() bonfire.Command {
|
||||
return bonfire.Command{
|
||||
Name: "migrate:rollback",
|
||||
Description: "Roll back the last migration of a plugin in the app binary",
|
||||
Flags: []bonfire.Flag{{
|
||||
Name: "plugin",
|
||||
Description: "Plugin ID whose last migration to roll back",
|
||||
}},
|
||||
Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error {
|
||||
argv := []string{"migrate:rollback"}
|
||||
if plugin, ok := in.Flag("plugin"); ok && plugin != "" {
|
||||
argv = append(argv, "--plugin", plugin)
|
||||
args := in.Args()
|
||||
// One leading "--" is dropped so the older
|
||||
// `summer serve -- --addr :8123` form keeps working.
|
||||
if len(args) > 0 && args[0] == "--" {
|
||||
args = args[1:]
|
||||
}
|
||||
argv = append(argv, in.Args()...)
|
||||
return runAppBinary(ctx, argv, out)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func delegateQueueWorkCommand() bonfire.Command {
|
||||
return bonfire.Command{
|
||||
Name: "queue:work",
|
||||
Description: "Run background job workers in the app binary",
|
||||
Flags: []bonfire.Flag{{
|
||||
Name: "queue",
|
||||
Description: "Queue to work (repeatable; default all known queues)",
|
||||
Repeatable: true,
|
||||
}},
|
||||
Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error {
|
||||
argv := []string{"queue:work"}
|
||||
for _, q := range in.Flags("queue") {
|
||||
argv = append(argv, "--queue", q)
|
||||
}
|
||||
argv = append(argv, in.Args()...)
|
||||
return runAppBinary(ctx, argv, out)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func delegateScheduleRunCommand() bonfire.Command {
|
||||
return bonfire.Command{
|
||||
Name: "schedule:run",
|
||||
Description: "Run the scheduler in the app binary (or once for system cron)",
|
||||
Flags: []bonfire.Flag{{
|
||||
Name: "once",
|
||||
Description: "Run the entries due this minute and exit (for system cron)",
|
||||
Bare: true,
|
||||
}},
|
||||
Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error {
|
||||
argv := []string{"schedule:run"}
|
||||
if v, ok := in.Flag("once"); ok {
|
||||
if once, _ := strconv.ParseBool(v); once {
|
||||
argv = append(argv, "--once")
|
||||
}
|
||||
}
|
||||
argv = append(argv, in.Args()...)
|
||||
return runAppBinary(ctx, argv, out)
|
||||
argv := append([]string{name}, args...)
|
||||
return appRunner(ctx, argv, out)
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -114,16 +100,70 @@ func runAppBinary(ctx context.Context, argv []string, out bonfire.Output) error
|
||||
return fmt.Errorf("summer: refusing to recurse into %s", bin)
|
||||
}
|
||||
}
|
||||
cmd := newAppCmd(ctx, bin, appDir, argv, out)
|
||||
sigs := make(chan os.Signal, 2)
|
||||
signal.Notify(sigs, os.Interrupt, syscall.SIGTERM)
|
||||
defer signal.Stop(sigs)
|
||||
if err := runChild(cmd, sigs); err != nil {
|
||||
return fmt.Errorf("summer: %s: %w", strings.Join(argv, " "), err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// appShutdownGrace is how long a cancelled app binary may take to exit after
|
||||
// SIGTERM before it is killed. It is longer than the 10-second shutdown
|
||||
// timeout of serve and the workers, so they finish a graceful shutdown.
|
||||
const appShutdownGrace = 15 * time.Second
|
||||
|
||||
// newAppCmd builds the command that runs the app binary bin with argv in dir.
|
||||
// The child inherits summer's stdin, so the hidden password prompts of
|
||||
// admin:create and admin:reset-password read from the terminal. Cancelling
|
||||
// ctx sends SIGTERM, and the child is killed only after appShutdownGrace.
|
||||
func newAppCmd(ctx context.Context, bin, dir string, argv []string, out io.Writer) *exec.Cmd {
|
||||
cmd := exec.CommandContext(ctx, bin, argv...)
|
||||
cmd.Dir = appDir
|
||||
cmd.Dir = dir
|
||||
cmd.Stdout = out
|
||||
cmd.Stderr = out
|
||||
cmd.Stdin = os.Stdin
|
||||
cmd.Env = os.Environ()
|
||||
if err := cmd.Run(); err != nil {
|
||||
return fmt.Errorf("summer: %s: %w", strings.Join(argv, " "), err)
|
||||
cmd.Cancel = func() error {
|
||||
err := cmd.Process.Signal(syscall.SIGTERM)
|
||||
if err != nil && !errors.Is(err, os.ErrProcessDone) {
|
||||
// SIGTERM is not supported everywhere (Windows); kill instead.
|
||||
return cmd.Process.Kill()
|
||||
}
|
||||
return err
|
||||
}
|
||||
cmd.WaitDelay = appShutdownGrace
|
||||
return cmd
|
||||
}
|
||||
|
||||
// runChild starts cmd, forwards every signal received on sigs to it and
|
||||
// waits for it to exit, returning Wait's error. It never escalates to a kill:
|
||||
// the app binary decides how to shut down.
|
||||
//
|
||||
// Ctrl+C in a terminal delivers SIGINT to both summer and the child, because
|
||||
// they share the foreground process group, so the child may see SIGINT twice.
|
||||
// The app's NotifyContext-based shutdown tolerates that. The child is
|
||||
// deliberately not put in its own process group (Setpgid): a background
|
||||
// process group that reads the terminal gets SIGTTIN, which would break the
|
||||
// password prompts of admin:create and admin:reset-password.
|
||||
func runChild(cmd *exec.Cmd, sigs <-chan os.Signal) error {
|
||||
if err := cmd.Start(); err != nil {
|
||||
return err
|
||||
}
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- cmd.Wait() }()
|
||||
for {
|
||||
select {
|
||||
case err := <-done:
|
||||
return err
|
||||
case sig := <-sigs:
|
||||
// Best effort: the child may already have exited
|
||||
// (os.ErrProcessDone) or the platform may not support sig.
|
||||
_ = cmd.Process.Signal(sig)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func sameFile(a, b string) (bool, error) {
|
||||
|
||||
237
cmd/summer/runtime_test.go
Normal file
237
cmd/summer/runtime_test.go
Normal file
@@ -0,0 +1,237 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/signal"
|
||||
"runtime"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/bonfire"
|
||||
)
|
||||
|
||||
// captureAppRunner replaces appRunner for the test and returns a pointer to
|
||||
// the argv of the last call.
|
||||
func captureAppRunner(t *testing.T) *[]string {
|
||||
t.Helper()
|
||||
got := new([]string)
|
||||
prev := appRunner
|
||||
appRunner = func(ctx context.Context, argv []string, out bonfire.Output) error {
|
||||
*got = append([]string(nil), argv...)
|
||||
return nil
|
||||
}
|
||||
t.Cleanup(func() { appRunner = prev })
|
||||
return got
|
||||
}
|
||||
|
||||
func TestDelegatedServePassesFlags(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
args []string
|
||||
want []string
|
||||
}{
|
||||
{[]string{"serve", "--addr", ":8123"}, []string{"serve", "--addr", ":8123"}},
|
||||
{[]string{"serve", "--help"}, []string{"serve", "--help"}},
|
||||
{[]string{"serve", "-h"}, []string{"serve", "-h"}},
|
||||
{[]string{"serve", "--addr=:8123"}, []string{"serve", "--addr=:8123"}},
|
||||
{[]string{"serve", "--", "--addr", ":8123"}, []string{"serve", "--addr", ":8123"}},
|
||||
} {
|
||||
got := captureAppRunner(t)
|
||||
if out, err := runSummer(t, tc.args...); err != nil {
|
||||
t.Fatalf("%q: %v\n%s", tc.args, err, out)
|
||||
}
|
||||
if !slices.Equal(*got, tc.want) {
|
||||
t.Fatalf("%q: argv = %q, want %q", tc.args, *got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDelegateCommandsPassArgs(t *testing.T) {
|
||||
for _, args := range [][]string{
|
||||
{"migrate:rollback", "--plugin", "acme.blog"},
|
||||
{"queue:work", "--queue", "a", "--queue", "b"},
|
||||
{"schedule:run", "--once"},
|
||||
{"queue:clear", "emails"},
|
||||
{"migrate"},
|
||||
{"migrate:status", "--help"},
|
||||
{"route:list"},
|
||||
{"key:generate"},
|
||||
{"admin:create", "--email", "you@example.com", "--superuser"},
|
||||
{"admin:reset-password", "admin@example.com"},
|
||||
} {
|
||||
got := captureAppRunner(t)
|
||||
if out, err := runSummer(t, args...); err != nil {
|
||||
t.Fatalf("%q: %v\n%s", args, err, out)
|
||||
}
|
||||
if !slices.Equal(*got, args) {
|
||||
t.Fatalf("%q: argv = %q, want %q", args, *got, args)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDelegateCommandsHelp(t *testing.T) {
|
||||
hints := map[string][]string{
|
||||
"migrate:rollback": {"--plugin"},
|
||||
"queue:work": {"--queue"},
|
||||
"schedule:run": {"--once"},
|
||||
"serve": {"--addr"},
|
||||
"admin:create": {"--email", "--superuser"},
|
||||
}
|
||||
cmds := delegateCommands()
|
||||
if len(cmds) != 11 {
|
||||
t.Fatalf("delegateCommands() has %d commands, want 11", len(cmds))
|
||||
}
|
||||
got := captureAppRunner(t)
|
||||
for _, c := range cmds {
|
||||
out, err := runSummer(t, "help", c.Name)
|
||||
if err != nil {
|
||||
t.Fatalf("help %s: %v", c.Name, err)
|
||||
}
|
||||
for _, want := range append([]string{"passed to the app binary"}, hints[c.Name]...) {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("help %s missing %q:\n%s", c.Name, want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
if *got != nil {
|
||||
t.Fatalf("help ran the app binary with %q", *got)
|
||||
}
|
||||
|
||||
out, err := runSummer(t, "--help")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, c := range cmds {
|
||||
found := false
|
||||
for _, line := range strings.Split(out, "\n") {
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) > 0 && fields[0] == c.Name && strings.Contains(line, "(flags pass to the app binary)") {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("summer --help does not mark %s:\n%s", c.Name, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSignalHelperProcess is not a real test: the signal tests re-execute the
|
||||
// test binary with SUMMER_SIGNAL_HELPER=1 to get a child that shuts down
|
||||
// gracefully on SIGINT or SIGTERM, like the app binary's serve.
|
||||
func TestSignalHelperProcess(t *testing.T) {
|
||||
if os.Getenv("SUMMER_SIGNAL_HELPER") != "1" {
|
||||
return
|
||||
}
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
fmt.Println("ready")
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
fmt.Println("graceful shutdown")
|
||||
os.Exit(0)
|
||||
case <-time.After(20 * time.Second):
|
||||
os.Exit(3)
|
||||
}
|
||||
}
|
||||
|
||||
// syncBuffer is a bytes.Buffer safe for the child's writes and the test's
|
||||
// reads at the same time.
|
||||
type syncBuffer struct {
|
||||
mu sync.Mutex
|
||||
buf bytes.Buffer
|
||||
}
|
||||
|
||||
func (b *syncBuffer) Write(p []byte) (int, error) {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
return b.buf.Write(p)
|
||||
}
|
||||
|
||||
func (b *syncBuffer) String() string {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
return b.buf.String()
|
||||
}
|
||||
|
||||
func helperArgv() []string {
|
||||
return []string{"-test.run=^TestSignalHelperProcess$"}
|
||||
}
|
||||
|
||||
func helperEnv(cmd *exec.Cmd) {
|
||||
cmd.Env = append(os.Environ(), "SUMMER_SIGNAL_HELPER=1")
|
||||
}
|
||||
|
||||
func waitForOutput(t *testing.T, out *syncBuffer, want string) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for !strings.Contains(out.String(), want) {
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf("child never printed %q; output:\n%s", want, out.String())
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunChildForwardsSignals(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("signal forwarding is POSIX-only")
|
||||
}
|
||||
for _, sig := range []os.Signal{os.Interrupt, syscall.SIGTERM} {
|
||||
t.Run(sig.String(), func(t *testing.T) {
|
||||
var out syncBuffer
|
||||
cmd := newAppCmd(context.Background(), os.Args[0], ".", helperArgv(), &out)
|
||||
helperEnv(cmd)
|
||||
sigs := make(chan os.Signal, 1)
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- runChild(cmd, sigs) }()
|
||||
waitForOutput(t, &out, "ready")
|
||||
sigs <- sig
|
||||
select {
|
||||
case err := <-done:
|
||||
if err != nil {
|
||||
t.Fatalf("runChild: %v\n%s", err, out.String())
|
||||
}
|
||||
case <-time.After(10 * time.Second):
|
||||
t.Fatal("runChild did not return after the signal")
|
||||
}
|
||||
if !strings.Contains(out.String(), "graceful shutdown") {
|
||||
t.Fatalf("child did not shut down gracefully:\n%s", out.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewAppCmdCancelSendsSIGTERM(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("SIGTERM is POSIX-only")
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
var out syncBuffer
|
||||
cmd := newAppCmd(ctx, os.Args[0], ".", helperArgv(), &out)
|
||||
helperEnv(cmd)
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- runChild(cmd, nil) }()
|
||||
waitForOutput(t, &out, "ready")
|
||||
cancel()
|
||||
select {
|
||||
case err := <-done:
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("err = %v, want context.Canceled\n%s", err, out.String())
|
||||
}
|
||||
case <-time.After(10 * time.Second):
|
||||
t.Fatal("child did not exit after cancel")
|
||||
}
|
||||
if !strings.Contains(out.String(), "graceful shutdown") {
|
||||
t.Fatalf("child was not stopped with SIGTERM:\n%s", out.String())
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,8 @@ login:
|
||||
decay_minutes: 1
|
||||
```
|
||||
|
||||
An application created with `summer new` already has a random `SUMMER_ADMIN__JWT__SECRET` in `.env`.
|
||||
|
||||
The cookie carries the Secure attribute. `backend.cookie_secure: false` drops it for plain-HTTP development and is refused in the `production` environment.
|
||||
|
||||
## Permissions
|
||||
|
||||
@@ -26,6 +26,8 @@ summer make:model --help
|
||||
./bin/acme migrate:rollback --help
|
||||
```
|
||||
|
||||
A command that `summer` delegates to the application binary, such as `summer serve`, answers `--help` with the binary's own help, so run it inside an application directory. Asking the help command of `summer` about a delegated command instead prints a short description and its common flags without running the binary.
|
||||
|
||||
## Commands that summer delegates
|
||||
|
||||
During development you often work from the application directory with `summer` alone. These `summer` commands find the application's `summer.yaml`, build `bin/<binary>` when it does not exist yet, and run the same command of the binary with the same arguments:
|
||||
@@ -39,8 +41,25 @@ During development you often work from the application directory with `summer` a
|
||||
| `summer queue:work` | `./bin/acme queue:work` |
|
||||
| `summer queue:clear` | `./bin/acme queue:clear` |
|
||||
| `summer schedule:run` | `./bin/acme schedule:run` |
|
||||
| `summer route:list` | `./bin/acme route:list` |
|
||||
| `summer key:generate` | `./bin/acme key:generate` |
|
||||
| `summer admin:create` | `./bin/acme admin:create` |
|
||||
| `summer admin:reset-password` | `./bin/acme admin:reset-password` |
|
||||
|
||||
`summer` does not rebuild an existing binary before it delegates. Run `summer build` after you change code, or keep `summer dev` running. Every other runtime command, such as `route:list`, `key:generate` or `admin:create`, is run on the binary directly.
|
||||
`summer` parses no flags of these commands: every flag and argument, including `--help`, goes to the binary unchanged, and the binary's input is your terminal, so the password prompts of `admin:create` and `admin:reset-password` work as usual:
|
||||
|
||||
```sh
|
||||
summer serve --addr 127.0.0.1:8123
|
||||
summer serve --help
|
||||
summer migrate:rollback --plugin acme.blog
|
||||
summer admin:create --email admin@example.com --superuser
|
||||
```
|
||||
|
||||
One leading `--` is ignored, so `summer serve -- --addr 127.0.0.1:8123` behaves the same as `summer serve --addr 127.0.0.1:8123`.
|
||||
|
||||
When `summer` receives SIGINT or SIGTERM while the binary runs, it passes the signal on and waits for the binary to shut down, so stopping `summer serve` with Ctrl+C or `kill` frees its port.
|
||||
|
||||
`summer` does not rebuild an existing binary before it delegates. Run `summer build` after you change code, or keep `summer dev` running. Every other runtime command, such as `deferred:purge` or the commands your plugins add, is run on the binary directly.
|
||||
|
||||
## The sections of this chapter
|
||||
|
||||
|
||||
@@ -46,10 +46,10 @@ The application gets these files:
|
||||
| `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. |
|
||||
| `.env`, `.env.example` | The database DSN, a freshly generated application key and a random admin token secret (`SUMMER_ADMIN__JWT__SECRET`). `.env` is readable by its owner only and never committed; `.env.example` lists the variables without the key and the secret. |
|
||||
| `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`. |
|
||||
| `CLAUDE.md`, `AGENTS.md` | Instructions for AI coding agents: the commands, the layout, the admin, 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. |
|
||||
|
||||
@@ -62,6 +62,17 @@ summer migrate
|
||||
summer serve
|
||||
```
|
||||
|
||||
The next steps also explain the admin. It opens at `/backend` once a plugin registers an admin controller; until then `summer serve` prints `admin: off` with these steps:
|
||||
|
||||
```sh
|
||||
summer make:admin-controller golem15.acme Posts
|
||||
summer build
|
||||
summer migrate
|
||||
summer admin:create --email you@example.com --superuser
|
||||
```
|
||||
|
||||
[Admin controllers](../backend/admin-controllers.md) explains the permission and model the generated controller needs.
|
||||
|
||||
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
|
||||
|
||||
@@ -55,8 +55,8 @@ Copy the printed value into `SUMMER_APP__KEY`. When you rotate the key, keep the
|
||||
|
||||
| Command | Flags | Purpose |
|
||||
|---------|-------|---------|
|
||||
| `serve` | `--addr` (default `:8080`) | Opens the database and uploads bucket, builds the router, starts the in-process job worker and serves HTTP until SIGINT or SIGTERM, then shuts down within 10 seconds. |
|
||||
| `route:list` | none | Builds the router the way `serve` does, without opening the database or listening, and prints every route with its method, pattern, plugin, middleware and raw flag. |
|
||||
| `serve` | `--addr` (default `:8080`) | Opens the database and uploads bucket, builds the router, binds the address, starts the in-process job worker and serves HTTP until SIGINT or SIGTERM, then shuts down within 10 seconds. After binding it prints the listen address with a browser URL (an unspecified host shows as 127.0.0.1) and an admin status line: the admin URL, or `admin: off` with the steps to add an admin controller. |
|
||||
| `route:list` | none | Builds the router the way `serve` does, without opening the database or listening, and prints every route with its method, pattern, plugin, middleware and raw flag, followed by the admin status line. |
|
||||
|
||||
```sh
|
||||
./bin/acme route:list
|
||||
@@ -65,6 +65,8 @@ Copy the printed value into `SUMMER_APP__KEY`. When you rotate the key, keep the
|
||||
|
||||
The default `--addr` listens on every interface. Pass a loopback address during development, and put a reverse proxy in front of the binary in production. The routing options are in [surf](../../modules/surf/README.md).
|
||||
|
||||
From the application directory, `summer serve --addr 127.0.0.1:8123` passes the flag through to the built binary.
|
||||
|
||||
## Admin accounts
|
||||
|
||||
| Command | Arguments and flags | Purpose |
|
||||
@@ -79,6 +81,10 @@ The default `--addr` listens on every interface. Pass a loopback address during
|
||||
|
||||
Both commands prompt for the password without echoing it. In a script, pipe it on stdin, for example `printf '%s\n' "$ADMIN_PASSWORD" | ./bin/acme admin:create --email admin@example.com --superuser`. The `--password` flag still works but is deprecated and prints a warning, because the value stays in your shell history and is visible in the process list. The admin is described in [cabana](../../modules/cabana/README.md).
|
||||
|
||||
From the application directory, `summer admin:create --email admin@example.com --superuser` runs the same command on the built binary, prompt included.
|
||||
|
||||
When the admin is mounted and the `backend_users` table is empty, `serve` prints a warning at start-up naming `summer admin:create`.
|
||||
|
||||
## Queues and the scheduler
|
||||
|
||||
| Command | Arguments and flags | Purpose |
|
||||
|
||||
@@ -16,6 +16,7 @@ A `bonfire.Command` has a name, a description, its positional arguments and flag
|
||||
- Each `bonfire.Arg` is a positional argument with a name, a description and a `Required` marker. The usage line shows required arguments as `<name>` and optional ones as `[name]`.
|
||||
- Each `bonfire.Flag` is a string flag. Set `bonfire.Flag.Bare` for a switch such as `--dry-run` that stores `true` when given alone, and `bonfire.Flag.Repeatable` for a flag that can be given several times.
|
||||
- `bonfire.Command.Run` receives the context, a `bonfire.Input` and a `bonfire.Output`.
|
||||
- `bonfire.Command.Long` is optional extended help shown by the help command; without it the description is shown. Set `bonfire.Command.Passthrough` for a command that forwards to another program: flag parsing is turned off, so every argument after the command name, including `--help`, `-h` and `--`, reaches `bonfire.Input.Args` unchanged. Declared flags then appear in the usage text only, and declared arguments are not count-checked.
|
||||
|
||||
Read arguments with `bonfire.Input.Argument`, scalar and bare flags with `bonfire.Input.Flag`, and repeatable flags with `bonfire.Input.Flags`, which returns the values in the order given:
|
||||
|
||||
|
||||
@@ -189,6 +189,8 @@ do("POST", "/api/blog/posts/7/comments", true)
|
||||
./bin/acme route:list
|
||||
```
|
||||
|
||||
The table is followed by a line saying whether the admin is mounted and under which prefix, for example `admin: mounted at /backend`.
|
||||
|
||||
`surf.BuildRouter` returns the same information to Go code through `surf.Router.Routes`:
|
||||
|
||||
```go src=modules/surf/example_test.go#ExampleBuildRouter
|
||||
|
||||
@@ -29,8 +29,8 @@ summer serve
|
||||
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.
|
||||
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; the [update script](#updating-an-application) pulls a branch checkout instead.
|
||||
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 reads your login shell from `SHELL` and ends with `fish_add_path <dir>` in fish, which keeps the directory for new sessions, or with `export PATH="<dir>:$PATH"`, to add to `~/.zshrc` in zsh, `~/.bashrc` in bash or your shell profile in any other shell.
|
||||
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:
|
||||
@@ -41,14 +41,14 @@ The script stops before cloning when the name is invalid or `sm-demo-app` alread
|
||||
| `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.
|
||||
Set them on the `bash` side of the pipe, for example `curl -fsSL https://summercms.io/new | SUMMERCMS_DIR=fw bash -s -- demo`. The update script does not use `SUMMERCMS_DIR`. 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.
|
||||
- `.env` holds the matching `SUMMER_DATABASE__DSN`, a freshly generated `SUMMER_APP__KEY` and a random `SUMMER_ADMIN__JWT__SECRET` for the admin. 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:
|
||||
@@ -68,6 +68,51 @@ curl http://127.0.0.1:8080/
|
||||
|
||||
It returns `{"message":"Hello from SummerCMS","plugin":"golem15.demo"}`. Stop the database with `docker compose down`, or `docker compose down -v` to delete its data as well.
|
||||
|
||||
`summer serve` prints the URL to open and the admin status. The admin lives at `http://127.0.0.1:8080/backend` once a plugin registers an admin controller; until then the status line reads `admin: off` and names the steps. When the admin is there, create the first administrator:
|
||||
|
||||
```sh
|
||||
summer admin:create --email you@example.com --superuser
|
||||
```
|
||||
|
||||
[Admin controllers](../backend/admin-controllers.md) shows how to add a controller, and [Users and permissions](../backend/users-and-permissions.md) how administrators sign in.
|
||||
|
||||
## Updating an application
|
||||
|
||||
To move an application to a newer framework, run the update script from inside the application directory:
|
||||
|
||||
```sh
|
||||
cd sm-demo-app
|
||||
curl -fsSL https://summercms.io/update | bash
|
||||
```
|
||||
|
||||
It works the same from bash, zsh and fish, because the piped script always runs in bash. `curl -fsSL https://summercms.io/new | bash -s -- --update` is the same command. It updates this application only:
|
||||
|
||||
1. It checks that the current directory holds a `go.mod` and a `summer.yaml`, and reads the framework checkout from the `go.mod` replace that `summer new` wrote (`../summercms` for a generated application). A replace that names a module version instead of a local path is refused; update that requirement with `go get`.
|
||||
2. It moves the checkout forward. A detached release checkout is fetched and moved, fast-forward only, to the newest release tag, or to `SUMMERCMS_REF` when set. A checkout on a branch is pulled from its upstream with `git pull --ff-only`. A checkout with changes to tracked files, a branch without an upstream and a branch that has diverged from its upstream stop the script with the checkout unchanged, and nothing is installed or built.
|
||||
3. When other `sm-*-app` directories next to the application or next to the checkout use the same checkout, it names them on one `not rebuilt` line. It never builds them or writes their files; each picks up the new framework on its next `summer build`.
|
||||
4. It reinstalls the `summer` CLI from the checkout.
|
||||
5. When the application's `.env` has no `SUMMER_ADMIN__JWT__SECRET` line, as in applications created before the admin was wired in, it appends one with a random value. The value is never printed, and the file keeps its permissions.
|
||||
6. It runs `summer build` in the application and ends with a reminder to migrate and restart.
|
||||
|
||||
Then run the new migrations and restart the server:
|
||||
|
||||
```sh
|
||||
summer migrate
|
||||
summer serve
|
||||
```
|
||||
|
||||
To update by hand, move the checkout yourself, reinstall the tool and rebuild. `git -C ../summercms tag -l 'v*' --sort=-v:refname` lists the release tags, newest first; for a checkout on a branch, use `git -C ../summercms pull --ff-only` instead of the first two commands:
|
||||
|
||||
```sh
|
||||
git -C ../summercms fetch --tags
|
||||
git -C ../summercms checkout --detach vX.Y.Z
|
||||
go -C ../summercms install ./cmd/summer
|
||||
summer build
|
||||
summer migrate
|
||||
```
|
||||
|
||||
An application without `SUMMER_ADMIN__JWT__SECRET` in its `.env` needs one line with a long random value, for example from `openssl rand -base64 32`.
|
||||
|
||||
## 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:
|
||||
@@ -99,12 +144,18 @@ go install ./cmd/summer
|
||||
summer --help
|
||||
```
|
||||
|
||||
`go install` writes the binary to `$(go env GOPATH)/bin`, usually `~/go/bin`. If `summer --help` reports `command not found`, that directory is not on your `PATH`. Add it for the current shell, and to your shell profile to keep it:
|
||||
`go install` writes the binary to `$(go env GOPATH)/bin`, usually `~/go/bin`. If `summer --help` reports `command not found`, that directory is not on your `PATH`. In bash or zsh, add it for the current shell, and to `~/.bashrc` or `~/.zshrc` to keep it:
|
||||
|
||||
```sh
|
||||
export PATH="$(go env GOPATH)/bin:$PATH"
|
||||
```
|
||||
|
||||
In fish, `fish_add_path` adds it and keeps it for new sessions without a profile edit:
|
||||
|
||||
```sh
|
||||
fish_add_path (go env GOPATH)/bin
|
||||
```
|
||||
|
||||
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
|
||||
|
||||
@@ -3,6 +3,7 @@ package build
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -38,6 +39,10 @@ type NewOptions struct {
|
||||
// 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
|
||||
// AdminSecret is written to .env as SUMMER_ADMIN__JWT__SECRET, the HMAC
|
||||
// secret of admin tokens (admin.jwt.secret). Empty means NewApp
|
||||
// generates the base64 of 32 crypto/rand bytes.
|
||||
AdminSecret string
|
||||
// NoGit skips git init and the first commit.
|
||||
NoGit bool
|
||||
}
|
||||
@@ -77,15 +82,16 @@ type genFile struct {
|
||||
|
||||
// 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
|
||||
Name string
|
||||
Module string
|
||||
Framework string
|
||||
GoVersion string
|
||||
Toolchain string
|
||||
Replace string
|
||||
DSN string
|
||||
AppKey string
|
||||
AdminSecret string
|
||||
NoPlugin bool
|
||||
}
|
||||
|
||||
// ValidateAppName reports whether name is usable as `summer new <name>`.
|
||||
@@ -127,6 +133,16 @@ func NewApp(ctx context.Context, opts NewOptions, out io.Writer) (string, error)
|
||||
if err := validateAppKey(opts.AppKey); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if opts.AdminSecret == "" {
|
||||
secret, err := generateAdminSecret()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("build: generate admin secret: %w", err)
|
||||
}
|
||||
opts.AdminSecret = secret
|
||||
}
|
||||
if err := validateAdminSecret(opts.AdminSecret); err != nil {
|
||||
return "", err
|
||||
}
|
||||
parent := opts.Parent
|
||||
if parent == "" {
|
||||
parent = "."
|
||||
@@ -191,6 +207,29 @@ func validateAppKey(key string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// adminSecretRe is the character set an admin secret may use: it is written
|
||||
// unquoted into .env, whose reader trims whitespace and strips quotes.
|
||||
var adminSecretRe = regexp.MustCompile(`^[A-Za-z0-9+/=_-]{32,}$`)
|
||||
|
||||
// generateAdminSecret returns the base64 (standard encoding) of 32
|
||||
// crypto/rand bytes.
|
||||
func generateAdminSecret() (string, error) {
|
||||
raw := make([]byte, 32)
|
||||
if _, err := rand.Read(raw); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.StdEncoding.EncodeToString(raw), nil
|
||||
}
|
||||
|
||||
// validateAdminSecret requires at least 32 characters that are safe to
|
||||
// write unquoted into .env.
|
||||
func validateAdminSecret(s string) error {
|
||||
if !adminSecretRe.MatchString(s) {
|
||||
return fmt.Errorf("build: admin secret must be at least 32 characters of letters, digits, +, /, =, _ or -")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// devDSN is the .env database DSN matching compose.yaml.
|
||||
func devDSN(name string) string {
|
||||
return "postgres://" + devDBUser + ":" + devDBPassword + "@" + devDBHostPort + "/" + name + "?sslmode=disable"
|
||||
@@ -318,6 +357,9 @@ func planNewApp(opts NewOptions, target, framework string) ([]genFile, error) {
|
||||
if err := validateAppKey(opts.AppKey); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := validateAdminSecret(opts.AdminSecret); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := checkFramework(framework); err != nil {
|
||||
return nil, fmt.Errorf("build: %w", err)
|
||||
}
|
||||
@@ -326,15 +368,16 @@ func planNewApp(opts NewOptions, target, framework string) ([]genFile, error) {
|
||||
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,
|
||||
Name: opts.Name,
|
||||
Module: appModulePath(opts.Name),
|
||||
Framework: frameworkModule,
|
||||
GoVersion: goVersionLine,
|
||||
Toolchain: toolchainLine,
|
||||
Replace: replace,
|
||||
DSN: devDSN(opts.Name),
|
||||
AppKey: opts.AppKey,
|
||||
AdminSecret: opts.AdminSecret,
|
||||
NoPlugin: opts.NoPlugin,
|
||||
}
|
||||
plan := []struct {
|
||||
rel string
|
||||
|
||||
@@ -22,6 +22,16 @@ var goldenAppKey = func() string {
|
||||
return base64.StdEncoding.EncodeToString(raw)
|
||||
}()
|
||||
|
||||
// goldenAdminSecret is the base64 of the bytes 0x20..0x3f: a fixed,
|
||||
// non-secret admin token secret that keeps .env byte-stable in the goldens.
|
||||
var goldenAdminSecret = func() string {
|
||||
raw := make([]byte, 32)
|
||||
for i := range raw {
|
||||
raw[i] = byte(0x20 + 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
|
||||
@@ -38,11 +48,12 @@ func newGoldenApp(t *testing.T, noPlugin bool) (string, []byte) {
|
||||
}
|
||||
var out bytes.Buffer
|
||||
dir, err := NewApp(t.Context(), NewOptions{
|
||||
Name: "demo",
|
||||
Parent: parent,
|
||||
AppKey: goldenAppKey,
|
||||
NoGit: true,
|
||||
NoPlugin: noPlugin,
|
||||
Name: "demo",
|
||||
Parent: parent,
|
||||
AppKey: goldenAppKey,
|
||||
AdminSecret: goldenAdminSecret,
|
||||
NoGit: true,
|
||||
NoPlugin: noPlugin,
|
||||
}, &out)
|
||||
if err != nil {
|
||||
t.Fatalf("NewApp: %v\n%s", err, out.String())
|
||||
|
||||
@@ -2,6 +2,7 @@ package build
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"go/token"
|
||||
"io/fs"
|
||||
"os"
|
||||
@@ -642,3 +643,88 @@ func TestNewNoGit(t *testing.T) {
|
||||
t.Fatalf("output lacks the created line:\n%s", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
// envValue returns the value of key in an env file body, or "" and false.
|
||||
func envValue(body []byte, key string) (string, bool) {
|
||||
for _, line := range strings.Split(string(body), "\n") {
|
||||
if v, ok := strings.CutPrefix(line, key+"="); ok {
|
||||
return v, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// TestNewAdminSecret writes SUMMER_ADMIN__JWT__SECRET to .env only, generates
|
||||
// a random one when none is given and rejects values unsafe for .env.
|
||||
func TestNewAdminSecret(t *testing.T) {
|
||||
const key = "SUMMER_ADMIN__JWT__SECRET"
|
||||
|
||||
t.Run("explicit", func(t *testing.T) {
|
||||
const secret = "QUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVphYmNkZWY="
|
||||
parent := parentWithFramework(t)
|
||||
var out bytes.Buffer
|
||||
dir, err := NewApp(t.Context(), NewOptions{Name: "demo", Parent: parent, AppKey: testAppKey, AdminSecret: secret, NoGit: true, NoPlugin: true}, &out)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
env := readAppFile(t, dir, ".env")
|
||||
if !strings.Contains(string(env), "\n"+key+"="+secret+"\n") {
|
||||
t.Fatalf(".env does not hold %s=%s:\n%s", key, secret, env)
|
||||
}
|
||||
st, err := os.Stat(filepath.Join(dir, ".env"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if st.Mode().Perm() != 0o600 {
|
||||
t.Fatalf(".env mode = %o, want 600", st.Mode().Perm())
|
||||
}
|
||||
example := readAppFile(t, dir, ".env.example")
|
||||
if v, ok := envValue(example, key); !ok || v != "" {
|
||||
t.Fatalf(".env.example %s = (%q, %v), want an empty placeholder", key, v, ok)
|
||||
}
|
||||
if strings.Contains(string(example), secret) {
|
||||
t.Fatal(".env.example contains the admin secret")
|
||||
}
|
||||
if strings.Contains(out.String(), secret) {
|
||||
t.Fatal("the next steps print the admin secret")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("generated", func(t *testing.T) {
|
||||
var seen []string
|
||||
for range 2 {
|
||||
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)
|
||||
}
|
||||
v, ok := envValue(readAppFile(t, dir, ".env"), key)
|
||||
if !ok {
|
||||
t.Fatalf(".env has no %s", key)
|
||||
}
|
||||
raw, err := base64.StdEncoding.DecodeString(v)
|
||||
if err != nil || len(raw) != 32 {
|
||||
t.Fatalf("generated secret %q is not the base64 of 32 bytes (%v)", v, err)
|
||||
}
|
||||
seen = append(seen, v)
|
||||
}
|
||||
if seen[0] == seen[1] {
|
||||
t.Fatal("two apps got the same admin secret")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid", func(t *testing.T) {
|
||||
for _, secret := range []string{
|
||||
"short",
|
||||
"abcdefghijklmnopqrst uvwxyzABCDEFGHIJKLMN",
|
||||
"abcdefghijklmnopqrst\nuvwxyzABCDEFGHIJKLMN",
|
||||
} {
|
||||
parent := parentWithFramework(t)
|
||||
_, err := NewApp(t.Context(), NewOptions{Name: "demo", Parent: parent, AppKey: testAppKey, AdminSecret: secret, NoGit: true, NoPlugin: true}, nil)
|
||||
if err == nil || !strings.Contains(err.Error(), "admin secret") {
|
||||
t.Fatalf("NewApp(AdminSecret %q) = %v, want an admin secret error", secret, err)
|
||||
}
|
||||
assertDirEntries(t, parent, []string{"summercms"})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -34,11 +34,12 @@ 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.
|
||||
{{define "new/env"}}# Local secrets for this application: the database DSN, the application key
|
||||
# and the admin token secret. .gitignore excludes this file; never commit it.
|
||||
# Real environment variables override the values here.
|
||||
SUMMER_DATABASE__DSN={{.DSN}}
|
||||
SUMMER_APP__KEY={{.AppKey}}
|
||||
SUMMER_ADMIN__JWT__SECRET={{.AdminSecret}}
|
||||
{{end}}
|
||||
|
||||
{{define "new/env.example"}}# The variables .env sets. summer new wrote a .env with a fresh key; copy this
|
||||
@@ -47,6 +48,10 @@ 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=
|
||||
# Signs backend admin tokens; the admin refuses to start without it once a
|
||||
# plugin registers an admin controller. summer new wrote a random value to .env;
|
||||
# any long random string works, for example one from ./bin/{{.Name}} key:generate.
|
||||
SUMMER_ADMIN__JWT__SECRET=
|
||||
# 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).
|
||||
@@ -110,6 +115,8 @@ 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
|
||||
summer make:admin-controller golem15.<plugin> <Name> # scaffold an admin controller with its YAML
|
||||
summer admin:create --email you@example.com --superuser # create a backend administrator (password prompted)
|
||||
```
|
||||
|
||||
## Layout
|
||||
@@ -117,13 +124,23 @@ summer plugin:add plugins/<name> # register it in summer.yaml, go.mod and go.w
|
||||
- `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.
|
||||
- `.env`: local secrets (database DSN, application key, admin token secret). Never commit it; `.gitignore` excludes it. `.env.example` lists the variables.
|
||||
- `compose.yaml`: the local PostgreSQL 16.
|
||||
- `main.go`, `plugins.gen.go`: generated by `summer build` from `summer.yaml`. Do not edit them by hand; they are committed.
|
||||
- `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`.
|
||||
|
||||
## Admin
|
||||
|
||||
The backend admin (the admin SPA and its JSON API) lives under `/backend`, at `http://127.0.0.1:8080/backend` with the default `summer serve` address. It is mounted only when a plugin registers an admin controller; until then `summer serve` prints `admin: off`. To add it:
|
||||
|
||||
1. `summer make:admin-controller golem15.<plugin> <Name>` scaffolds the controller and its YAML. The generated controller requires the permission `golem15.<plugin>.access_<name>` (`<name>` is the controller name in snake case): declare it in the plugin's `Permissions()` (`pact.HasPermissions`) or the admin refuses to start. Its `NewRecord` returns `nil` until you return the plugin's model.
|
||||
2. `summer build`, then `summer migrate`.
|
||||
3. `summer admin:create --email you@example.com --superuser` creates the first administrator; the password is prompted.
|
||||
|
||||
`.env` already holds `SUMMER_ADMIN__JWT__SECRET`, which signs admin tokens.
|
||||
|
||||
## Naming convention
|
||||
|
||||
- `sm-<name>-app`: an application, like this repository.
|
||||
@@ -143,4 +160,11 @@ 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}}
|
||||
{{end}}
|
||||
Admin, at http://127.0.0.1:8080/backend once a plugin registers an admin
|
||||
controller (.env already holds SUMMER_ADMIN__JWT__SECRET):
|
||||
summer make:admin-controller golem15.<plugin> <Name>
|
||||
summer build
|
||||
summer migrate
|
||||
summer admin:create --email you@example.com --superuser
|
||||
{{end}}
|
||||
|
||||
@@ -23,6 +23,8 @@ 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
|
||||
summer make:admin-controller golem15.<plugin> <Name> # scaffold an admin controller with its YAML
|
||||
summer admin:create --email you@example.com --superuser # create a backend administrator (password prompted)
|
||||
```
|
||||
|
||||
## Layout
|
||||
@@ -30,13 +32,23 @@ summer plugin:add plugins/<name> # register it in summer.yaml, go.mod and go.w
|
||||
- `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.
|
||||
- `.env`: local secrets (database DSN, application key, admin token secret). Never commit it; `.gitignore` excludes it. `.env.example` lists the variables.
|
||||
- `compose.yaml`: the local PostgreSQL 16.
|
||||
- `main.go`, `plugins.gen.go`: generated by `summer build` from `summer.yaml`. Do not edit them by hand; they are committed.
|
||||
- `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`.
|
||||
|
||||
## Admin
|
||||
|
||||
The backend admin (the admin SPA and its JSON API) lives under `/backend`, at `http://127.0.0.1:8080/backend` with the default `summer serve` address. It is mounted only when a plugin registers an admin controller; until then `summer serve` prints `admin: off`. To add it:
|
||||
|
||||
1. `summer make:admin-controller golem15.<plugin> <Name>` scaffolds the controller and its YAML. The generated controller requires the permission `golem15.<plugin>.access_<name>` (`<name>` is the controller name in snake case): declare it in the plugin's `Permissions()` (`pact.HasPermissions`) or the admin refuses to start. Its `NewRecord` returns `nil` until you return the plugin's model.
|
||||
2. `summer build`, then `summer migrate`.
|
||||
3. `summer admin:create --email you@example.com --superuser` creates the first administrator; the password is prompted.
|
||||
|
||||
`.env` already holds `SUMMER_ADMIN__JWT__SECRET`, which signs admin tokens.
|
||||
|
||||
## Naming convention
|
||||
|
||||
- `sm-<name>-app`: an application, like this repository.
|
||||
|
||||
@@ -8,3 +8,10 @@ Next steps, inside sm-demo-app:
|
||||
|
||||
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.
|
||||
|
||||
Admin, at http://127.0.0.1:8080/backend once a plugin registers an admin
|
||||
controller (.env already holds SUMMER_ADMIN__JWT__SECRET):
|
||||
summer make:admin-controller golem15.<plugin> <Name>
|
||||
summer build
|
||||
summer migrate
|
||||
summer admin:create --email you@example.com --superuser
|
||||
|
||||
14
internal/build/testdata/new/CLAUDE.md.golden
vendored
14
internal/build/testdata/new/CLAUDE.md.golden
vendored
@@ -23,6 +23,8 @@ 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
|
||||
summer make:admin-controller golem15.<plugin> <Name> # scaffold an admin controller with its YAML
|
||||
summer admin:create --email you@example.com --superuser # create a backend administrator (password prompted)
|
||||
```
|
||||
|
||||
## Layout
|
||||
@@ -30,13 +32,23 @@ summer plugin:add plugins/<name> # register it in summer.yaml, go.mod and go.w
|
||||
- `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.
|
||||
- `.env`: local secrets (database DSN, application key, admin token secret). Never commit it; `.gitignore` excludes it. `.env.example` lists the variables.
|
||||
- `compose.yaml`: the local PostgreSQL 16.
|
||||
- `main.go`, `plugins.gen.go`: generated by `summer build` from `summer.yaml`. Do not edit them by hand; they are committed.
|
||||
- `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`.
|
||||
|
||||
## Admin
|
||||
|
||||
The backend admin (the admin SPA and its JSON API) lives under `/backend`, at `http://127.0.0.1:8080/backend` with the default `summer serve` address. It is mounted only when a plugin registers an admin controller; until then `summer serve` prints `admin: off`. To add it:
|
||||
|
||||
1. `summer make:admin-controller golem15.<plugin> <Name>` scaffolds the controller and its YAML. The generated controller requires the permission `golem15.<plugin>.access_<name>` (`<name>` is the controller name in snake case): declare it in the plugin's `Permissions()` (`pact.HasPermissions`) or the admin refuses to start. Its `NewRecord` returns `nil` until you return the plugin's model.
|
||||
2. `summer build`, then `summer migrate`.
|
||||
3. `summer admin:create --email you@example.com --superuser` creates the first administrator; the password is prompted.
|
||||
|
||||
`.env` already holds `SUMMER_ADMIN__JWT__SECRET`, which signs admin tokens.
|
||||
|
||||
## Naming convention
|
||||
|
||||
- `sm-<name>-app`: an application, like this repository.
|
||||
|
||||
@@ -4,6 +4,10 @@ SUMMER_DATABASE__DSN=postgres://summer:summer@127.0.0.1:54320/demo?sslmode=disab
|
||||
# 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=
|
||||
# Signs backend admin tokens; the admin refuses to start without it once a
|
||||
# plugin registers an admin controller. summer new wrote a random value to .env;
|
||||
# any long random string works, for example one from ./bin/demo key:generate.
|
||||
SUMMER_ADMIN__JWT__SECRET=
|
||||
# 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).
|
||||
|
||||
7
internal/build/testdata/new/dot-env.golden
vendored
7
internal/build/testdata/new/dot-env.golden
vendored
@@ -1,5 +1,6 @@
|
||||
# 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.
|
||||
# Local secrets for this application: the database DSN, the application key
|
||||
# and the admin token secret. .gitignore excludes this file; never commit it.
|
||||
# Real environment variables override the values here.
|
||||
SUMMER_DATABASE__DSN=postgres://summer:summer@127.0.0.1:54320/demo?sslmode=disable
|
||||
SUMMER_APP__KEY=AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=
|
||||
SUMMER_ADMIN__JWT__SECRET=ICEiIyQlJicoKSorLC0uLzAxMjM0NTY3ODk6Ozw9Pj8=
|
||||
|
||||
@@ -8,3 +8,10 @@ Next steps, inside sm-demo-app:
|
||||
|
||||
Then, from another terminal:
|
||||
curl http://127.0.0.1:8080/
|
||||
|
||||
Admin, at http://127.0.0.1:8080/backend once a plugin registers an admin
|
||||
controller (.env already holds SUMMER_ADMIN__JWT__SECRET):
|
||||
summer make:admin-controller golem15.<plugin> <Name>
|
||||
summer build
|
||||
summer migrate
|
||||
summer admin:create --email you@example.com --superuser
|
||||
|
||||
@@ -17,6 +17,7 @@ bonfire is the console layer of SummerCMS. Plugins and framework modules describ
|
||||
- Styled status lines: `bonfire.Output.Info`, `bonfire.Output.Success`, `bonfire.Output.Warning` and `bonfire.Output.Error` (the last one writes to the error stream).
|
||||
- Widgets: box-drawn tables (`bonfire.Output.Table`), a spinner around a function (`bonfire.Output.Spinner`) and a progress bar (`bonfire.Output.Progress`); both fall back to plain lines when output is not a terminal.
|
||||
- Prompts: `bonfire.Output.Ask`, `bonfire.Output.Confirm`, `bonfire.Output.Choice` and `bonfire.Output.Secret`, which reads a hidden value on a terminal. Prompts return their defaults when input ends, and `bonfire.Output.Confirm` returns its default without asking when the session is not interactive.
|
||||
- Passthrough commands (`bonfire.Command.Passthrough`) skip flag parsing, so every argument, including `--help`, reaches `bonfire.Input.Args` unchanged; this suits commands that forward to another program. `bonfire.Command.Long` adds extended help text that the help command shows instead of the description.
|
||||
- Injectable streams (`bonfire.NewRootIO`, `bonfire.NewOutput`) so commands can be tested against buffers.
|
||||
- In-process calls: `bonfire.Call` runs a named command with arguments against any writer (Laravel `Artisan::call`), and `bonfire.Catalog` holds an application binary's final command list so code outside the Cobra root, such as the conga scheduler, can call any registered command.
|
||||
|
||||
@@ -80,7 +81,7 @@ if catalog.Has("blog:import") {
|
||||
|
||||
| Identifier | Description |
|
||||
|------------|-------------|
|
||||
| `bonfire.Command` | A console command: name, description, flags, arguments and the `bonfire.Command.Run` function. |
|
||||
| `bonfire.Command` | A console command: name, description, optional extended help (`bonfire.Command.Long`), flags, arguments, the `bonfire.Command.Passthrough` switch that hands every argument to Run unparsed, and the `bonfire.Command.Run` function. |
|
||||
| `bonfire.Flag` | A string flag; `bonfire.Flag.Bare` allows the flag without a value, `bonfire.Flag.Repeatable` makes it an ordered multi-value flag. |
|
||||
| `bonfire.Arg` | A positional argument with a name, description and required marker. |
|
||||
| `bonfire.Input` | Parsed view handed to `bonfire.Command.Run`: `bonfire.Input.Args`, `bonfire.Input.Argument`, `bonfire.Input.Flag` and `bonfire.Input.Flags`. |
|
||||
|
||||
@@ -15,6 +15,17 @@ var ErrCommandName = errors.New("bonfire: plugin command name must be namespace:
|
||||
type Command struct {
|
||||
Name string
|
||||
Description string
|
||||
// Long is optional extended help that cobra shows for the help command
|
||||
// (help <command>). When empty, cobra falls back to Description.
|
||||
Long string
|
||||
// Passthrough turns off flag parsing for the command: every argument
|
||||
// after the command name, including --help, -h and --, reaches Run
|
||||
// unchanged through Input.Args. Declared Flags are registered for the
|
||||
// usage text only and are never parsed, so Input.Flag returns only
|
||||
// their defaults. Declared Args shape the usage string but are not
|
||||
// count-checked, so Input.Argument indexes the raw args. Passthrough
|
||||
// suits wrapper commands that forward to another program.
|
||||
Passthrough bool
|
||||
Flags []Flag
|
||||
Args []Arg
|
||||
Run func(ctx context.Context, in Input, out Output) error
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
package bonfire
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"io"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -12,3 +16,102 @@ func TestValidBareRuntimeCommandNames(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func capturingCommand(name string, got *[]string) Command {
|
||||
return Command{
|
||||
Name: name,
|
||||
Description: "Capture args",
|
||||
Passthrough: true,
|
||||
Run: func(ctx context.Context, in Input, out Output) error {
|
||||
*got = append([]string(nil), in.Args()...)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestPassthroughCommandReceivesRawArgs(t *testing.T) {
|
||||
var got []string
|
||||
var out, errW bytes.Buffer
|
||||
root, err := NewRootIO("app", []Command{capturingCommand("serve", &got)}, strings.NewReader(""), &out, &errW)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
args := []string{"--addr", ":8123", "-p", "--help", "-h", "--", "x"}
|
||||
root.SetArgs(append([]string{"serve"}, args...))
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatalf("Execute: %v", err)
|
||||
}
|
||||
if !slices.Equal(got, args) {
|
||||
t.Fatalf("args = %q, want %q", got, args)
|
||||
}
|
||||
if out.Len() != 0 || errW.Len() != 0 {
|
||||
t.Fatalf("passthrough printed output: stdout=%q stderr=%q", out.String(), errW.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestPassthroughCommandThroughCall(t *testing.T) {
|
||||
var got []string
|
||||
var buf bytes.Buffer
|
||||
args := []string{"--addr", ":8123", "--help"}
|
||||
if err := Call(context.Background(), []Command{capturingCommand("serve", &got)}, "serve", args, &buf); err != nil {
|
||||
t.Fatalf("Call: %v", err)
|
||||
}
|
||||
if !slices.Equal(got, args) {
|
||||
t.Fatalf("args = %q, want %q", got, args)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPassthroughCommandIgnoresArgCount(t *testing.T) {
|
||||
var got []string
|
||||
cmd := capturingCommand("queue:clear", &got)
|
||||
cmd.Args = []Arg{{Name: "queue", Required: true}}
|
||||
root, err := NewRoot("app", []Command{cmd}, io.Discard)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
root.SetArgs([]string{"queue:clear"})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatalf("passthrough with zero args: %v", err)
|
||||
}
|
||||
if len(got) != 0 {
|
||||
t.Fatalf("args = %q, want none", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNonPassthroughCommandRejectsUnknownFlag(t *testing.T) {
|
||||
root, err := NewRoot("app", []Command{{Name: "migrate", Run: func(context.Context, Input, Output) error { return nil }}}, io.Discard)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
root.SetArgs([]string{"migrate", "--addr", ":8123"})
|
||||
err = root.Execute()
|
||||
if err == nil || !strings.Contains(err.Error(), "unknown flag: --addr") {
|
||||
t.Fatalf("err = %v, want unknown flag: --addr", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHelpShowsLongOrDescription(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
cmd Command
|
||||
want string
|
||||
}{
|
||||
{"long", Command{Name: "serve", Description: "Short text", Long: "Long text"}, "Long text"},
|
||||
{"description", Command{Name: "serve", Description: "Short text"}, "Short text"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
root, err := NewRoot("app", []Command{tc.cmd}, &buf)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
root.SetArgs([]string{"help", "serve"})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(buf.String(), tc.want) {
|
||||
t.Fatalf("help missing %q:\n%s", tc.want, buf.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,6 +54,7 @@ func wrap(c Command, out Output) (*cobra.Command, error) {
|
||||
cmd := &cobra.Command{
|
||||
Use: commandUsage(c),
|
||||
Short: c.Description,
|
||||
Long: c.Long,
|
||||
Args: positionalArgs(c.Args),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if c.Run == nil {
|
||||
@@ -62,6 +63,11 @@ func wrap(c Command, out Output) (*cobra.Command, error) {
|
||||
return c.Run(cmd.Context(), cobraInput{cmd: cmd, spec: c, args: args}, out)
|
||||
},
|
||||
}
|
||||
if c.Passthrough {
|
||||
// Every argument, flags included, reaches Run as given.
|
||||
cmd.DisableFlagParsing = true
|
||||
cmd.Args = cobra.ArbitraryArgs
|
||||
}
|
||||
for _, flag := range c.Flags {
|
||||
if flag.Repeatable {
|
||||
// D-19: an ordered, multi-occurrence string flag (Cobra
|
||||
|
||||
@@ -178,7 +178,7 @@ func (p *Plugin) AdminControllers() []pact.AdminController {
|
||||
func (p *Plugin) AdminFS() fs.FS { return adminFS }
|
||||
```
|
||||
|
||||
`config_list.yaml` and `config_form.yaml` reference the model files with WinterCMS paths such as `~/plugins/acme/blog/models/post/columns.yaml`. At boot, `surf.BuildRouter` calls `cabana.Activate` with the activated plugins and mounts the returned `cabana.Routes`; when no plugin registers an admin controller, `cabana.Activate` returns nil and no admin routes exist. Record and user lookups use the `*gorm.DB` that [lagoon](../lagoon/README.md) publishes on the `backpack.App`.
|
||||
`config_list.yaml` and `config_form.yaml` reference the model files with WinterCMS paths such as `~/plugins/acme/blog/models/post/columns.yaml`. At boot, `surf.BuildRouter` calls `cabana.Activate` with the activated plugins and mounts the returned `cabana.Routes`; when no plugin registers an admin controller, `cabana.Activate` returns nil and no admin routes exist. `surf.Router.AdminPrefix` reports which case applies, and the `serve` and `route:list` commands print it as an admin status line. Record and user lookups use the `*gorm.DB` that [lagoon](../lagoon/README.md) publishes on the `backpack.App`.
|
||||
|
||||
## API reference
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 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.
|
||||
Bootstrap script that installs the summer CLI and creates a new SummerCMS application, or updates an existing one, in one command, embedded so it is versioned with the framework.
|
||||
|
||||
`import "git.golem15.com/golem15/summercms/modules/sunrise"`
|
||||
|
||||
@@ -12,19 +12,31 @@ Bootstrap script that installs the summer CLI and creates a new SummerCMS applic
|
||||
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 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`, a hint for the login shell named by `SHELL`: `fish_add_path <dir>` for fish (which keeps it for new sessions), an `export PATH=...` line naming `~/.zshrc` or `~/.bashrc` for zsh and bash, and the generic `export PATH=...` line with "your shell profile" for any other shell.
|
||||
|
||||
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.
|
||||
The same script updates an existing application. Run inside the application directory, served at https://summercms.io/update:
|
||||
|
||||
```sh
|
||||
cd sm-blog-app && curl -fsSL https://summercms.io/update | bash
|
||||
```
|
||||
|
||||
`curl -fsSL https://summercms.io/new | bash -s -- --update` does the same. Update mode needs a `go.mod` and a `summer.yaml` in the current directory and reads the framework checkout from the `go.mod` replace of the framework module, as `summer new` writes it; a replace with a module version instead of a local path is refused. A detached checkout is fetched and moved fast-forward only to the newest release tag (or `SUMMERCMS_REF`); a branch checkout with an upstream is pulled with `git pull --ff-only`. A checkout with changes to tracked files, a branch without an upstream and a branch that has diverged stop the script with the checkout unchanged, and nothing is installed or built. Other `sm-*-app` directories next to the application or next to the checkout whose replace points at the same checkout are named on one `not rebuilt` line and left untouched. The script then reinstalls `summer`, appends a `SUMMER_ADMIN__JWT__SECRET` line to this application's `.env` when no active line sets it (once, keeping the file mode, never printing the value), runs `summer build` in the application and ends with a reminder to run `summer migrate` and restart `summer serve`, followed by the same 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; `sunrise.UpdateScript` returns the same script with update mode preset, for the update URL.
|
||||
|
||||
## Features
|
||||
|
||||
- Truncation-safe: everything is a function definition and the last line is `main "$@"`, so a download cut short runs nothing.
|
||||
- Truncation-safe: everything is a function definition and the last line is `main "$@"` (`main "$@" --update` in `sunrise.UpdateScript`), 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.
|
||||
- Login-shell-aware PATH hint: the closing hint is one correct line for fish, zsh or bash, with a generic `export PATH=...` line for any other shell; the directory is escaped for the target shell's double quotes and `$PATH` is printed literally.
|
||||
- Update mode: run inside one application, it moves the framework checkout named by the application's `go.mod` replace forward, fast-forward only (detached release checkouts to the newest release, branch checkouts with `git pull --ff-only`), reinstalls `summer` and runs `summer build` in that application only.
|
||||
- Shared-checkout notice: other applications whose `go.mod` replace resolves to the same checkout are named on one line and never built or written.
|
||||
- Admin token secret: update mode appends a missing `SUMMER_ADMIN__JWT__SECRET` to the application's `.env`, 32 random bytes as base64, without printing it; an existing line, even an empty one, is left alone.
|
||||
- No privilege escalation and no dynamic code; only bash builtins plus `git`, `go` and `docker` are run (update mode also runs `openssl`, or `head` and `base64`, and the installed `summer`), with stdin closed and `GIT_TERMINAL_PROMPT=0` so nothing waits for input.
|
||||
|
||||
## Usage
|
||||
|
||||
@@ -36,6 +48,15 @@ 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`.
|
||||
|
||||
Update an existing application from inside its directory:
|
||||
|
||||
```sh
|
||||
cd sm-blog-app
|
||||
curl -fsSL https://summercms.io/update | bash
|
||||
```
|
||||
|
||||
Update mode takes no other argument. It works the same from bash, zsh and fish, because the piped script always runs in bash.
|
||||
|
||||
Serving the script from a Go handler:
|
||||
|
||||
```go
|
||||
@@ -54,21 +75,24 @@ func newScript(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
```
|
||||
|
||||
A second handler serves `sunrise.UpdateScript()` the same way at the update path.
|
||||
|
||||
## API reference
|
||||
|
||||
| Identifier | Description |
|
||||
|------------|-------------|
|
||||
| `sunrise.Script` | Returns a fresh copy of the embedded `new.sh` bytes on every call. |
|
||||
| `sunrise.UpdateScript` | Returns a fresh copy of the script with its last line `main "$@"` replaced by `main "$@" --update`, so it runs update mode. |
|
||||
|
||||
## Configuration
|
||||
|
||||
The script reads three environment variables. It never reads or sets `SUMMER_`-prefixed variables, which the framework reads as configuration.
|
||||
The script reads three environment variables, plus `SHELL`, which only picks the form of the PATH hint. It never reads or exports `SUMMER_`-prefixed variables, which the framework reads as configuration. The only such name it writes is `SUMMER_ADMIN__JWT__SECRET`, which update mode appends to the application's `.env`.
|
||||
|
||||
| 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. |
|
||||
| `SUMMERCMS_REPO` | `https://git.golem15.com/golem15/summercms` | Repository to clone; used only when cloning. |
|
||||
| `SUMMERCMS_DIR` | `summercms` (relative to the current directory) | Framework checkout directory; the application is created next to it. Update mode ignores it with a warning, because the checkout comes from `go.mod`. |
|
||||
| `SUMMERCMS_REF` | newest `vX.Y.Z` tag | Tag or commit to check out instead of the newest release. It applies to detached checkouts; update mode ignores it, with a warning, on a branch. |
|
||||
|
||||
## Dependencies
|
||||
|
||||
@@ -82,10 +106,10 @@ The script reads three environment variables. It never reads or sets `SUMMER_`-p
|
||||
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.
|
||||
The default run checks the embedded bytes, the script's shape and syntax (`bash -n`), that `sunrise.UpdateScript` differs from it only in the last line, and runs every line-boundary prefix and every byte prefix of the final line of both scripts against stub commands to prove a truncated download invokes nothing. A behaviour harness runs the script against a local framework repository and stub `go`, `docker`, `summer` and `openssl` commands, covering create mode and update mode: detached and branch checkouts and their refusals, the shared-checkout notice, the `.env` admin secret and the PATH hint.
|
||||
|
||||
```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.
|
||||
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 then removes the admin token secret from the application's `.env` and updates the application through the update script, which must restore the secret without printing it and rebuild. It needs bash, curl, git, Go and access to the Go module proxy.
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
#!/usr/bin/env bash
|
||||
# SummerCMS bootstrap: create a new SummerCMS application in one command.
|
||||
# SummerCMS bootstrap: create a new SummerCMS application in one command, or
|
||||
# update an existing one.
|
||||
#
|
||||
# curl -fsSL https://summercms.io/new | bash -s -- <name>
|
||||
# cd sm-<name>-app && curl -fsSL https://summercms.io/update | bash
|
||||
# cd sm-<name>-app && curl -fsSL https://summercms.io/new | bash -s -- --update
|
||||
#
|
||||
# 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
|
||||
@@ -9,16 +12,31 @@
|
||||
# `go install` and runs `summer new <name>` next to the checkout, which
|
||||
# writes ./sm-<name>-app.
|
||||
#
|
||||
# Update mode (--update, which /update presets) runs inside one application
|
||||
# and changes only that application. It reads the framework checkout from the
|
||||
# application's go.mod replace and moves it forward: a detached checkout fast-
|
||||
# forward only to the newest release (or SUMMERCMS_REF), a branch checkout
|
||||
# with git pull --ff-only. It then reinstalls summer, adds a missing admin
|
||||
# token secret to this application's .env, runs summer build here, and names
|
||||
# other applications that share the checkout without rebuilding them.
|
||||
#
|
||||
# Environment overrides:
|
||||
# SUMMERCMS_REPO framework repository to clone
|
||||
# (default https://git.golem15.com/golem15/summercms)
|
||||
# (default https://git.golem15.com/golem15/summercms);
|
||||
# used only when cloning
|
||||
# SUMMERCMS_DIR framework checkout directory (default ./summercms); the
|
||||
# application is created next to it
|
||||
# application is created next to it. Not used in update
|
||||
# mode, where the checkout comes from go.mod
|
||||
# SUMMERCMS_REF tag or commit to check out instead of the newest release
|
||||
# (detached checkouts only)
|
||||
#
|
||||
# The login shell named by SHELL only picks the form of the PATH hint printed
|
||||
# at the end (fish, zsh, bash, or a generic export line).
|
||||
#
|
||||
# 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.
|
||||
# docker commands are used, plus, in update mode, openssl (or head and
|
||||
# base64) to make the admin token secret and the installed summer.
|
||||
|
||||
say() {
|
||||
printf '==> %s\n' "$*" >&2
|
||||
@@ -36,6 +54,8 @@ die() {
|
||||
usage() {
|
||||
printf '%s\n' \
|
||||
'Usage: curl -fsSL https://summercms.io/new | bash -s -- <name>' \
|
||||
' cd sm-<name>-app && curl -fsSL https://summercms.io/update | bash' \
|
||||
' curl -fsSL https://summercms.io/new | bash -s -- --update' \
|
||||
' bash new.sh <name>' \
|
||||
'' \
|
||||
'Creates sm-<name>-app next to a SummerCMS framework checkout. <name> is' \
|
||||
@@ -45,12 +65,32 @@ usage() {
|
||||
'existing release checkout forward) at its newest release tag; install' \
|
||||
'the summer CLI with go install; run summer new <name>.' \
|
||||
'' \
|
||||
'--update takes no other argument and runs inside one application (a' \
|
||||
'directory with go.mod and summer.yaml). It reads the framework checkout' \
|
||||
'from the go.mod replace and moves it forward (detached: fast-forward only' \
|
||||
'to the newest release; branch: git pull --ff-only), reinstalls summer,' \
|
||||
'adds a missing admin token secret to the .env and runs summer build there.' \
|
||||
'Other applications sharing the checkout are named, not rebuilt.' \
|
||||
'' \
|
||||
'Environment:' \
|
||||
' SUMMERCMS_REPO repository to clone (default https://git.golem15.com/golem15/summercms)' \
|
||||
' SUMMERCMS_DIR framework checkout directory (default ./summercms)' \
|
||||
' SUMMERCMS_DIR framework checkout directory (default ./summercms); not used by --update' \
|
||||
' SUMMERCMS_REF tag or commit to check out instead of the newest release'
|
||||
}
|
||||
|
||||
# framework_module prints the module path of the SummerCMS framework.
|
||||
framework_module() {
|
||||
printf '%s\n' git.golem15.com/golem15/summercms
|
||||
}
|
||||
|
||||
# trim prints $1 without leading and trailing whitespace.
|
||||
trim() {
|
||||
local s="$1"
|
||||
s="${s#"${s%%[![:space:]]*}"}"
|
||||
s="${s%"${s##*[![:space:]]}"}"
|
||||
printf '%s\n' "$s"
|
||||
}
|
||||
|
||||
# abspath prints $1 as an absolute path against $PWD with ".", ".." and
|
||||
# repeated slashes removed lexically.
|
||||
abspath() {
|
||||
@@ -129,11 +169,12 @@ target_commit() {
|
||||
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"
|
||||
# check_framework_dir refuses an existing directory $1 that is not a clean
|
||||
# SummerCMS framework checkout at the root of a git repository; $2 is the
|
||||
# hint appended to every refusal. It never changes the directory.
|
||||
check_framework_dir() {
|
||||
local dir="$1" hint="$2" top real line module="" status fw
|
||||
fw="$(framework_module)"
|
||||
[[ -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
|
||||
@@ -142,7 +183,7 @@ check_checkout() {
|
||||
break
|
||||
fi
|
||||
done <"$dir/go.mod"
|
||||
[[ $module == git.golem15.com/golem15/summercms ]] ||
|
||||
[[ $module == "$fw" ]] ||
|
||||
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"
|
||||
@@ -151,6 +192,14 @@ check_checkout() {
|
||||
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"
|
||||
}
|
||||
|
||||
# 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 branch
|
||||
hint="use SUMMERCMS_DIR for a separate clone, or run 'summer new --framework <checkout>' yourself"
|
||||
check_framework_dir "$dir" "$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
|
||||
@@ -159,7 +208,7 @@ check_checkout() {
|
||||
# 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
|
||||
local repo="$1" dir="$2" ref="$3" target
|
||||
if [[ ! -e $dir && ! -L $dir ]]; then
|
||||
say "cloning $repo into $dir"
|
||||
git clone --quiet -- "$repo" "$dir" </dev/null || die "git clone of $repo failed"
|
||||
@@ -169,6 +218,13 @@ checkout_framework() {
|
||||
return 0
|
||||
fi
|
||||
check_checkout "$dir"
|
||||
move_detached "$dir" "$ref"
|
||||
}
|
||||
|
||||
# move_detached fetches tags into the detached checkout $1 and moves it to
|
||||
# SUMMERCMS_REF ($2) or the newest release tag, fast-forward only.
|
||||
move_detached() {
|
||||
local dir="$1" ref="$2" target head
|
||||
say "updating the framework checkout in $dir"
|
||||
# No --force: a tag that moved upstream is never overwritten locally,
|
||||
# git refuses and the script stops.
|
||||
@@ -199,6 +255,297 @@ go_bin_dir() {
|
||||
printf '%s\n' "$gobin"
|
||||
}
|
||||
|
||||
# dq_escape prints $1 escaped for the double quotes of the shell named by $2:
|
||||
# backslash, double quote and dollar always; for shells other than fish also
|
||||
# backtick, and ! moved into single quotes against history expansion.
|
||||
dq_escape() {
|
||||
local s="$1" sh="$2" out="" c i
|
||||
for ((i = 0; i < ${#s}; i++)); do
|
||||
c="${s:i:1}"
|
||||
case "$c" in
|
||||
\\ | \" | \$) out+="\\$c" ;;
|
||||
\`) if [[ $sh == fish ]]; then out+="$c"; else out+="\\$c"; fi ;;
|
||||
!) if [[ $sh == fish ]]; then out+="$c"; else out+="\"'!'\""; fi ;;
|
||||
*) out+="$c" ;;
|
||||
esac
|
||||
done
|
||||
printf '%s\n' "$out"
|
||||
}
|
||||
|
||||
# path_hint prints how to put the Go bin directory $1 on PATH, in the form
|
||||
# of the login shell named by SHELL, when it is not on PATH yet.
|
||||
path_hint() {
|
||||
local gobin="$1"
|
||||
if [[ :${PATH:-}: != *":$gobin:"* ]]; then
|
||||
# Under curl | bash this is always bash; SHELL still names the
|
||||
# user's login shell, which picks the hint form.
|
||||
local login="${SHELL:-}" note
|
||||
login="${login##*/}"
|
||||
case "$login" in
|
||||
fish) note='fish_add_path also keeps it for new fish sessions' ;;
|
||||
zsh) note='and add it to ~/.zshrc' ;;
|
||||
bash) note='and add it to ~/.bashrc' ;;
|
||||
*) note='and add it to your shell profile' ;;
|
||||
esac
|
||||
printf '\n==> summer is installed in %s, which is not on your PATH. Run this (%s):\n' "$gobin" "$note"
|
||||
if [[ $login == fish ]]; then
|
||||
printf ' fish_add_path "%s"\n' "$(dq_escape "$gobin" fish)"
|
||||
else
|
||||
# $PATH stays literal: the user's shell expands it.
|
||||
# shellcheck disable=SC2016
|
||||
printf ' export PATH="%s:$PATH"\n' "$(dq_escape "$gobin" "$login")"
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
# is_local_path tells whether the replace target $1 is a directory path
|
||||
# rather than a module path, by Go's rule: ".", ".." or a leading "./",
|
||||
# "../" or "/".
|
||||
is_local_path() {
|
||||
case "$1" in
|
||||
. | .. | ./* | ../* | /*) return 0 ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# framework_replace reads the go.mod file $1 the way summer build does
|
||||
# (// comments cut, single-line and block replace directives, the first
|
||||
# field of each side, the left side matched exactly against the framework
|
||||
# module, a version may follow it) and prints the replacement: its path or
|
||||
# module on the first line and its version, if any, on a second line. It
|
||||
# returns 1 when go.mod has no replace for the framework.
|
||||
framework_replace() {
|
||||
local file="$1" fw line rest lhs rhs first ver="" inblock=0
|
||||
local block_re='^replace[[:space:]]*[(]$' single_re='^replace[[:space:]]+(.+)$'
|
||||
local quoted_re='^"([^"]*)"(.*)$'
|
||||
fw="$(framework_module)"
|
||||
while IFS= read -r line || [[ -n $line ]]; do
|
||||
line="$(trim "${line%%//*}")"
|
||||
[[ -n $line ]] || continue
|
||||
if ((inblock)); then
|
||||
if [[ $line == ')' ]]; then
|
||||
inblock=0
|
||||
continue
|
||||
fi
|
||||
rest="$line"
|
||||
elif [[ $line =~ $block_re ]]; then
|
||||
inblock=1
|
||||
continue
|
||||
elif [[ $line =~ $single_re ]]; then
|
||||
rest="${BASH_REMATCH[1]}"
|
||||
else
|
||||
continue
|
||||
fi
|
||||
[[ $rest == *'=>'* ]] || continue
|
||||
read -r lhs _ <<<"${rest%%=>*}"
|
||||
[[ ${lhs:-} == "$fw" ]] || continue
|
||||
rhs="$(trim "${rest#*=>}")"
|
||||
if [[ $rhs =~ $quoted_re ]]; then
|
||||
first="${BASH_REMATCH[1]}"
|
||||
read -r ver _ <<<"${BASH_REMATCH[2]}"
|
||||
else
|
||||
read -r first ver _ <<<"$rhs"
|
||||
fi
|
||||
[[ -n ${first:-} ]] || continue
|
||||
printf '%s\n' "$first"
|
||||
if [[ -n ${ver:-} ]]; then
|
||||
printf '%s\n' "$ver"
|
||||
fi
|
||||
return 0
|
||||
done <"$file"
|
||||
return 1
|
||||
}
|
||||
|
||||
# pull_branch fast-forwards the branch $2 checked out in $1 from its
|
||||
# upstream. SUMMERCMS_REF ($3) does not apply to a branch. A missing
|
||||
# upstream or a diverged branch stops the script with the checkout
|
||||
# unchanged; check_framework_dir has already refused a dirty tree.
|
||||
pull_branch() {
|
||||
local dir="$1" branch="$2" ref="$3" upstream before after
|
||||
if [[ -n $ref ]]; then
|
||||
warn "SUMMERCMS_REF is ignored for a branch checkout; $branch is pulled from its upstream"
|
||||
fi
|
||||
upstream="$(git -C "$dir" rev-parse --abbrev-ref --symbolic-full-name '@{u}' </dev/null 2>/dev/null)" ||
|
||||
die "$dir is on branch $branch, which has no upstream; the checkout was not changed. Set one with git branch --set-upstream-to, or pull it yourself"
|
||||
before="$(git -C "$dir" rev-parse HEAD </dev/null)"
|
||||
say "pulling $branch from $upstream (fast-forward only)"
|
||||
# --no-rebase: a pull.rebase setting must not turn this into a rebase.
|
||||
if ! git -C "$dir" pull --quiet --no-rebase --ff-only </dev/null; then
|
||||
die "git pull --ff-only in $dir failed: $branch has diverged from $upstream (not a fast-forward) or the fetch failed; the checkout was not changed"
|
||||
fi
|
||||
after="$(git -C "$dir" rev-parse HEAD </dev/null)"
|
||||
if [[ $before == "$after" ]]; then
|
||||
say "framework checkout is already up to date"
|
||||
else
|
||||
say "framework checkout moved from ${before:0:12} to ${after:0:12}"
|
||||
fi
|
||||
}
|
||||
|
||||
# note_shared_apps names the other sm-*-app directories, next to the
|
||||
# application $2 and next to the checkout $1, whose go.mod replace resolves
|
||||
# to the same checkout. It only reads their go.mod; they are not rebuilt.
|
||||
note_shared_apps() {
|
||||
local dir="$1" app="$2" real self parent fwparent scan last="" cand target list=""
|
||||
real="$(cd "$dir" && pwd -P)"
|
||||
self="$(cd "$app" && pwd -P)"
|
||||
parent="${self%/*}"
|
||||
fwparent="${real%/*}"
|
||||
for scan in "${parent:-/}" "${fwparent:-/}"; do
|
||||
[[ $scan != "$last" ]] || continue
|
||||
last="$scan"
|
||||
for cand in "${scan%/}"/sm-*-app; do
|
||||
[[ -d $cand && -f $cand/go.mod && -r $cand/go.mod ]] || continue
|
||||
[[ "$(cd "$cand" 2>/dev/null && pwd -P)" != "$self" ]] || continue
|
||||
target="$(framework_replace "$cand/go.mod")" || continue
|
||||
target="${target%%$'\n'*}"
|
||||
is_local_path "$target" || continue
|
||||
[[ $target == /* ]] || target="$cand/$target"
|
||||
[[ -d $target ]] || continue
|
||||
[[ "$(cd "$target" 2>/dev/null && pwd -P)" == "$real" ]] || continue
|
||||
list+=" $cand"
|
||||
done
|
||||
done
|
||||
if [[ -n $list ]]; then
|
||||
say "not rebuilt, also using $dir:$list; each picks up the new framework on its next summer build"
|
||||
fi
|
||||
}
|
||||
|
||||
# env_line_state tells whether the .env line $2 sets the key $1, read the
|
||||
# way the framework's .env loader reads it (trimmed, # comments skipped, an
|
||||
# optional "export " prefix, the key trimmed before the first =, matching
|
||||
# surrounding quotes removed from the value): 0 with a value, 2 with an
|
||||
# empty value, 1 when the line does not set the key.
|
||||
env_line_state() {
|
||||
local key="$1" line k v
|
||||
line="$(trim "$2")"
|
||||
[[ -n $line && $line != '#'* ]] || return 1
|
||||
if [[ $line == 'export '* ]]; then
|
||||
line="$(trim "${line#export }")"
|
||||
fi
|
||||
[[ $line == *=* ]] || return 1
|
||||
k="$(trim "${line%%=*}")"
|
||||
[[ $k == "$key" ]] || return 1
|
||||
v="$(trim "${line#*=}")"
|
||||
if ((${#v} >= 2)) && [[ $v == \"*\" || $v == \'*\' ]]; then
|
||||
v="${v:1:${#v}-2}"
|
||||
fi
|
||||
[[ -n $v ]] || return 2
|
||||
return 0
|
||||
}
|
||||
|
||||
# generate_secret prints 32 random bytes as standard base64 (44 characters),
|
||||
# from openssl when it is installed, else from head and base64 over
|
||||
# /dev/urandom. It returns 1 when neither produces a valid value. The value
|
||||
# only travels through command substitution and printf.
|
||||
generate_secret() {
|
||||
local s="" re='^[A-Za-z0-9+/]{43}=$'
|
||||
if command -v openssl >/dev/null 2>&1; then
|
||||
s="$(openssl rand -base64 32 </dev/null 2>/dev/null)" || s=""
|
||||
s="${s//$'\n'/}"
|
||||
s="${s//$'\r'/}"
|
||||
fi
|
||||
if [[ ! $s =~ $re ]] && command -v head >/dev/null 2>&1 &&
|
||||
command -v base64 >/dev/null 2>&1 && [[ -r /dev/urandom ]]; then
|
||||
s="$(head -c 32 /dev/urandom | base64 2>/dev/null)" || s=""
|
||||
s="${s//$'\n'/}"
|
||||
s="${s//$'\r'/}"
|
||||
fi
|
||||
[[ $s =~ $re ]] || return 1
|
||||
printf '%s\n' "$s"
|
||||
}
|
||||
|
||||
# ensure_admin_secret appends the admin token secret to the .env of the
|
||||
# application $1 when no active line sets it (applications created before
|
||||
# the admin was wired in lack it). The append keeps the file mode, adds a
|
||||
# newline first when the file lacks a final one, and never prints the value.
|
||||
ensure_admin_secret() {
|
||||
local app="$1" key=SUMMER_ADMIN__JWT__SECRET env line state found=0 empty=0 newline="" secret
|
||||
env="$app/.env"
|
||||
if [[ ! -f $env ]]; then
|
||||
say "no .env in $app; skipped the admin token secret"
|
||||
return 0
|
||||
fi
|
||||
[[ -r $env && -w $env ]] || die "$env is not readable and writable; add a $key line with any long random value, then run the update again"
|
||||
while IFS= read -r line; do
|
||||
state=0
|
||||
env_line_state "$key" "$line" || state=$?
|
||||
if ((state != 1)); then
|
||||
found=1
|
||||
empty=$((state == 2 ? 1 : 0))
|
||||
fi
|
||||
done <"$env"
|
||||
# A non-empty $line here is a final line without a newline.
|
||||
if [[ -n $line ]]; then
|
||||
newline=$'\n'
|
||||
state=0
|
||||
env_line_state "$key" "$line" || state=$?
|
||||
if ((state != 1)); then
|
||||
found=1
|
||||
empty=$((state == 2 ? 1 : 0))
|
||||
fi
|
||||
fi
|
||||
if ((found)); then
|
||||
if ((empty)); then
|
||||
warn "$key in $env is empty; the admin refuses to start until it has a value"
|
||||
fi
|
||||
return 0
|
||||
fi
|
||||
secret="$(generate_secret)" ||
|
||||
die "could not generate $key for $env (needs openssl, or head and base64); add a $key line with any long random value, then run the update again"
|
||||
if ! printf '%s%s=%s\n' "$newline" "$key" "$secret" >>"$env"; then
|
||||
die "could not append $key to $env"
|
||||
fi
|
||||
say "added $key to $env"
|
||||
}
|
||||
|
||||
# update_app updates the application in the current directory: the
|
||||
# framework checkout its go.mod replace names, summer, the admin token
|
||||
# secret in its .env and its build. Nothing else is built or written.
|
||||
update_app() {
|
||||
local app="$PWD" fw rhs target dir ref="${SUMMERCMS_REF:-}" branch gobin
|
||||
fw="$(framework_module)"
|
||||
[[ -f $app/go.mod ]] || die "run this from inside an sm-<name>-app directory: $app has no go.mod"
|
||||
[[ -f $app/summer.yaml ]] || die "run this from inside an sm-<name>-app directory: $app has no summer.yaml"
|
||||
rhs="$(framework_replace "$app/go.mod")" ||
|
||||
die "$app/go.mod has no replace for $fw; --update works on applications whose go.mod points the framework at a local checkout, as summer new writes it"
|
||||
target="${rhs%%$'\n'*}"
|
||||
is_local_path "$target" ||
|
||||
die "$app/go.mod replaces $fw with ${rhs//$'\n'/ }, not a local checkout; update that requirement with go get instead"
|
||||
if [[ $target == /* ]]; then
|
||||
dir="$(abspath "$target")"
|
||||
else
|
||||
dir="$(abspath "$app/$target")"
|
||||
fi
|
||||
[[ $dir != / ]] || die "the framework replace in $app/go.mod must not point at /"
|
||||
[[ -e $dir || -L $dir ]] || die "the framework replace in $app/go.mod points at $dir, which does not exist"
|
||||
if [[ -n ${SUMMERCMS_DIR:-} ]]; then
|
||||
warn "SUMMERCMS_DIR is ignored by --update; the framework checkout comes from go.mod ($dir)"
|
||||
fi
|
||||
|
||||
command -v git >/dev/null 2>&1 || die "git is not installed; install git and run again"
|
||||
check_go
|
||||
|
||||
check_framework_dir "$dir" "point the framework replace in $app/go.mod at a SummerCMS framework checkout"
|
||||
if branch="$(git -C "$dir" symbolic-ref -q --short HEAD </dev/null)"; then
|
||||
pull_branch "$dir" "$branch" "$ref"
|
||||
else
|
||||
move_detached "$dir" "$ref"
|
||||
fi
|
||||
note_shared_apps "$dir" "$app"
|
||||
|
||||
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
|
||||
|
||||
ensure_admin_secret "$app"
|
||||
|
||||
say "building $app"
|
||||
"$gobin/summer" build </dev/null || die "summer build failed in $app; fix the error above and run the update again"
|
||||
|
||||
printf '\n==> Done. Run summer migrate, then restart summer serve.\n'
|
||||
path_hint "$gobin"
|
||||
}
|
||||
|
||||
main() {
|
||||
set -euo pipefail
|
||||
export GIT_TERMINAL_PROMPT=0
|
||||
@@ -207,6 +554,25 @@ main() {
|
||||
usage
|
||||
exit 0
|
||||
fi
|
||||
local arg update=0 others=0
|
||||
if (($#)); then
|
||||
for arg in "$@"; do
|
||||
if [[ $arg == --update ]]; then
|
||||
update=1
|
||||
else
|
||||
others=$((others + 1))
|
||||
fi
|
||||
done
|
||||
fi
|
||||
if ((update)); then
|
||||
if ((others)); then
|
||||
printf 'error: --update takes no other argument\n\n' >&2
|
||||
usage >&2
|
||||
exit 2
|
||||
fi
|
||||
update_app
|
||||
return 0
|
||||
fi
|
||||
if (($# != 1)); then
|
||||
usage >&2
|
||||
exit 2
|
||||
@@ -246,12 +612,7 @@ main() {
|
||||
(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
|
||||
path_hint "$gobin"
|
||||
}
|
||||
|
||||
main "$@"
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
@@ -16,20 +17,22 @@ import (
|
||||
// `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
|
||||
t *testing.T
|
||||
bash string
|
||||
script string
|
||||
updateFile string // UpdateScript() bytes
|
||||
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
|
||||
unsetShell bool // run the script with SHELL unset instead of the empty default
|
||||
env []string // extra environment
|
||||
}
|
||||
|
||||
@@ -175,14 +178,33 @@ func newScriptHarness(t *testing.T) *scriptHarness {
|
||||
if err := os.WriteFile(h.script, Script(), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h.updateFile = filepath.Join(root, "update.sh")
|
||||
if err := os.WriteFile(h.updateFile, UpdateScript(), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(gitBin, filepath.Join(h.tools, "git")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Update mode makes the admin token secret with head and base64 when
|
||||
// openssl is missing; a case adds an openssl stub with stubOpenssl.
|
||||
for _, tool := range []string{"head", "base64"} {
|
||||
p, err := exec.LookPath(tool)
|
||||
if err != nil {
|
||||
t.Skip(tool + " not installed")
|
||||
}
|
||||
if p, err = filepath.Abs(p); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(p, filepath.Join(h.tools, tool)); 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"
|
||||
[[ -z ${STUB_SUMMER_FAIL:-} ]] || exit 1
|
||||
`
|
||||
goStub := "#!" + bash + `
|
||||
printf '%s\n' "$*" >>"$STUB_LOG/go.calls"
|
||||
@@ -225,6 +247,24 @@ func (h *scriptHarness) without(tool string) {
|
||||
|
||||
// run executes the script with args in h.work.
|
||||
func (h *scriptHarness) run(args ...string) scriptResult {
|
||||
h.t.Helper()
|
||||
return h.runFile(h.script, h.work, args...)
|
||||
}
|
||||
|
||||
// runIn executes the script with args in dir.
|
||||
func (h *scriptHarness) runIn(dir string, args ...string) scriptResult {
|
||||
h.t.Helper()
|
||||
return h.runFile(h.script, dir, args...)
|
||||
}
|
||||
|
||||
// runUpdate executes UpdateScript() with args in dir.
|
||||
func (h *scriptHarness) runUpdate(dir string, args ...string) scriptResult {
|
||||
h.t.Helper()
|
||||
return h.runFile(h.updateFile, dir, args...)
|
||||
}
|
||||
|
||||
// runFile executes file with args in dir; cmd.Dir and PWD both name dir.
|
||||
func (h *scriptHarness) runFile(file, dir string, args ...string) scriptResult {
|
||||
h.t.Helper()
|
||||
path := h.tools
|
||||
if h.gobinOnPath {
|
||||
@@ -236,13 +276,17 @@ func (h *scriptHarness) run(args ...string) scriptResult {
|
||||
}
|
||||
env := append(gitTestEnv(filepath.Join(h.root, "home")),
|
||||
"PATH="+path,
|
||||
"PWD="+h.work,
|
||||
"PWD="+dir,
|
||||
"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,
|
||||
// bash fills an unset SHELL from the passwd entry, so this default
|
||||
// keeps cases independent of the developer's login shell; a case
|
||||
// sets its own SHELL through h.env, which comes later and wins.
|
||||
"SHELL=",
|
||||
)
|
||||
// The script must not depend on the harness identity: drop it.
|
||||
filtered := env[:0]
|
||||
@@ -253,8 +297,13 @@ func (h *scriptHarness) run(args ...string) scriptResult {
|
||||
filtered = append(filtered, kv)
|
||||
}
|
||||
env = append(filtered, h.env...)
|
||||
cmd := exec.Command(h.bash, append([]string{h.script}, args...)...)
|
||||
cmd.Dir = h.work
|
||||
cmd := exec.Command(h.bash, append([]string{file}, args...)...)
|
||||
if h.unsetShell {
|
||||
// Unset SHELL inside bash, after startup filled it, then source the
|
||||
// script ($0) with the case arguments so it runs with SHELL unset.
|
||||
cmd = exec.Command(h.bash, append([]string{"-c", `unset SHELL; . "$0" "$@"`, file}, args...)...)
|
||||
}
|
||||
cmd.Dir = dir
|
||||
cmd.Env = env
|
||||
cmd.Stdin = strings.NewReader(stdinProbe)
|
||||
var stdout, stderr bytes.Buffer
|
||||
@@ -319,6 +368,57 @@ func expectContains(t *testing.T, what, got string, wants ...string) {
|
||||
}
|
||||
}
|
||||
|
||||
func expectLacks(t *testing.T, what, got string, unwanted ...string) {
|
||||
t.Helper()
|
||||
for _, u := range unwanted {
|
||||
if strings.Contains(got, u) {
|
||||
t.Fatalf("%s contains %q:\n%s", what, u, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// hintDir returns dir escaped the way new.sh escapes it inside the double
|
||||
// quotes of the printed PATH hint: backslash, double quote and dollar for
|
||||
// every shell, plus backtick and a single-quoted ! for POSIX shells.
|
||||
func hintDir(dir string, fish bool) string {
|
||||
if fish {
|
||||
return strings.NewReplacer(`\`, `\\`, `"`, `\"`, `$`, `\$`).Replace(dir)
|
||||
}
|
||||
return strings.NewReplacer(`\`, `\\`, `"`, `\"`, `$`, `\$`, "`", "\\`", `!`, `"'!'"`).Replace(dir)
|
||||
}
|
||||
|
||||
// hint is the PATH hint new.sh prints at the end of stdout.
|
||||
func hint(gobin, note, line string) string {
|
||||
return "\n==> summer is installed in " + gobin + ", which is not on your PATH. Run this (" + note + "):\n" + line + "\n"
|
||||
}
|
||||
|
||||
// exportHint and fishHint are the two hint forms for gobin.
|
||||
func exportHint(gobin, note string) string {
|
||||
return hint(gobin, note, ` export PATH="`+hintDir(gobin, false)+`:$PATH"`)
|
||||
}
|
||||
|
||||
func fishHint(gobin string) string {
|
||||
return hint(gobin, "fish_add_path also keeps it for new fish sessions", ` fish_add_path "`+hintDir(gobin, true)+`"`)
|
||||
}
|
||||
|
||||
// expectHintSuffix asserts stdout ends with the hint.
|
||||
func expectHintSuffix(t *testing.T, stdout, want string) {
|
||||
t.Helper()
|
||||
if !strings.HasSuffix(stdout, want) {
|
||||
t.Fatalf("stdout does not end with the hint\ngot:\n%q\nwant suffix:\n%q", stdout, want)
|
||||
}
|
||||
}
|
||||
|
||||
// metaGobin creates and selects a Go bin directory whose name holds a
|
||||
// space, backslash, double quote, dollar, backtick and !.
|
||||
func metaGobin(t *testing.T, h *scriptHarness) {
|
||||
t.Helper()
|
||||
h.gobin = filepath.Join(h.root, "go bin\\x\"y$HOME`id`!z")
|
||||
if err := os.MkdirAll(h.gobin, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// expectNothingStarted asserts no clone and no go or summer call happened.
|
||||
func expectNothingStarted(t *testing.T, h *scriptHarness) {
|
||||
t.Helper()
|
||||
@@ -350,11 +450,159 @@ func expectRefusedUnchanged(t *testing.T, h *scriptHarness, res scriptResult, be
|
||||
// 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"} {
|
||||
for _, n := range []string{"go.calls", "go.install", "summer.call", "summer.stdin", "docker.calls", "openssl.calls"} {
|
||||
_ = os.Remove(filepath.Join(h.logs, n))
|
||||
}
|
||||
}
|
||||
|
||||
// replaceLine is a single-line go.mod replace of the framework.
|
||||
func replaceLine(target string) string {
|
||||
return "replace " + fwModule + " => " + target
|
||||
}
|
||||
|
||||
// appAt writes an application fixture into dir: go.mod and summer.yaml.
|
||||
func (h *scriptHarness) appAt(dir, gomod string) string {
|
||||
h.t.Helper()
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
h.t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "go.mod"), []byte(gomod), 0o644); err != nil {
|
||||
h.t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "summer.yaml"), []byte("name: x\n"), 0o644); err != nil {
|
||||
h.t.Fatal(err)
|
||||
}
|
||||
return dir
|
||||
}
|
||||
|
||||
// app writes an application fixture at <work>/sm-<name>-app.
|
||||
func (h *scriptHarness) app(name, gomod string) string {
|
||||
h.t.Helper()
|
||||
return h.appAt(filepath.Join(h.work, "sm-"+name+"-app"), gomod)
|
||||
}
|
||||
|
||||
// appGoMod is a go.mod whose framework replace points at target.
|
||||
func appGoMod(name, target string) string {
|
||||
return "module example.com/" + name + "\n\n" + replaceLine(target) + "\n"
|
||||
}
|
||||
|
||||
// updateReady clones the framework with a create run, writes sm-blog-app
|
||||
// pointing at it and clears the stub records; it returns the app directory.
|
||||
func (h *scriptHarness) updateReady() string {
|
||||
h.t.Helper()
|
||||
h.mustRun("demo")
|
||||
app := h.app("blog", appGoMod("blog", "../summercms"))
|
||||
h.resetLogs()
|
||||
return app
|
||||
}
|
||||
|
||||
// mustUpdate runs --update in dir and requires success.
|
||||
func (h *scriptHarness) mustUpdate(dir string) scriptResult {
|
||||
h.t.Helper()
|
||||
res := h.runIn(dir, "--update")
|
||||
if res.code != 0 {
|
||||
h.t.Fatalf("update exit %d\nstdout:\n%s\nstderr:\n%s", res.code, res.stdout, res.stderr)
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
// stubOpenssl puts an openssl stub on PATH that records its arguments and
|
||||
// prints output.
|
||||
func (h *scriptHarness) stubOpenssl(output string) {
|
||||
h.t.Helper()
|
||||
body := "#!" + h.bash + "\nprintf '%s\\n' \"$*\" >>\"$STUB_LOG/openssl.calls\"\nprintf '%s\\n' '" + output + "'\n"
|
||||
if err := os.WriteFile(filepath.Join(h.tools, "openssl"), []byte(body), 0o755); err != nil {
|
||||
h.t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// expectBuilt asserts summer build ran once, in app, with no stdin.
|
||||
func expectBuilt(t *testing.T, h *scriptHarness, app string) {
|
||||
t.Helper()
|
||||
if got, want := h.log("summer.call"), "cwd="+app+"\narg=build\n"; got != want {
|
||||
t.Fatalf("summer call = %q, want %q", got, want)
|
||||
}
|
||||
if got := h.log("summer.stdin"); got != "" {
|
||||
t.Fatalf("summer read stdin %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// expectNoInstallOrBuild asserts neither go install nor summer ran.
|
||||
func expectNoInstallOrBuild(t *testing.T, h *scriptHarness) {
|
||||
t.Helper()
|
||||
if c := h.log("go.install"); c != "" {
|
||||
t.Fatalf("go install ran:\n%s", c)
|
||||
}
|
||||
if c := h.log("summer.call"); c != "" {
|
||||
t.Fatalf("summer was called:\n%s", c)
|
||||
}
|
||||
}
|
||||
|
||||
// branch prints the branch checked out in the framework checkout.
|
||||
func (h *scriptHarness) branch() string {
|
||||
h.t.Helper()
|
||||
return h.remote.git(h.checkout(), "symbolic-ref", "--short", "HEAD")
|
||||
}
|
||||
|
||||
// writeEnv writes a .env file with exactly mode, whatever the umask.
|
||||
func writeEnv(t *testing.T, path, data string, mode os.FileMode) {
|
||||
t.Helper()
|
||||
if err := os.WriteFile(path, []byte(data), mode); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Chmod(path, mode); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// readFile returns the bytes of path as a string.
|
||||
func readFile(t *testing.T, path string) string {
|
||||
t.Helper()
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return string(data)
|
||||
}
|
||||
|
||||
// secretRe is the shape of a generated admin token secret.
|
||||
var secretRe = regexp.MustCompile(`^[A-Za-z0-9+/]{43}=$`)
|
||||
|
||||
const adminKey = "SUMMER_ADMIN__JWT__SECRET"
|
||||
|
||||
// expectAppended asserts the .env at path is before plus one generated
|
||||
// admin secret line, that the secret is well-formed and that neither output
|
||||
// stream holds it. It returns the secret. Failure messages name only the key
|
||||
// and the value length, never .env bytes.
|
||||
func expectAppended(t *testing.T, res scriptResult, path, before string) string {
|
||||
t.Helper()
|
||||
got := readFile(t, path)
|
||||
prefix := before + adminKey + "="
|
||||
if !strings.HasPrefix(got, prefix) || !strings.HasSuffix(got, "\n") {
|
||||
t.Fatalf(".env is not the original bytes plus one %s line (length %d, original %d)", adminKey, len(got), len(before))
|
||||
}
|
||||
v := strings.TrimSuffix(strings.TrimPrefix(got, prefix), "\n")
|
||||
if !secretRe.MatchString(v) {
|
||||
t.Fatalf("%s value of length %d is not 44-character base64", adminKey, len(v))
|
||||
}
|
||||
if strings.Contains(res.stdout, v) || strings.Contains(res.stderr, v) {
|
||||
t.Fatalf("the output contains the generated %s value", adminKey)
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// expectMode asserts the permission bits of path.
|
||||
func expectMode(t *testing.T, path string, mode os.FileMode) {
|
||||
t.Helper()
|
||||
fi, err := os.Stat(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := fi.Mode().Perm(); got != mode {
|
||||
t.Fatalf("mode of %s = %v, want %v", path, got, mode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScriptBehavior(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
@@ -583,16 +831,61 @@ func TestScriptBehavior(t *testing.T) {
|
||||
t.Fatalf("the script created .git: %v", err)
|
||||
}
|
||||
}},
|
||||
{"GOBIN off PATH prints the hint", func(t *testing.T, h *scriptHarness) {
|
||||
{"empty SHELL prints the generic 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"`)
|
||||
expectHintSuffix(t, res.stdout, "\n==> summer is installed in "+h.gobin+", which is not on your PATH. Run this (and add it to your shell profile):\n export PATH=\""+h.gobin+":$PATH\"\n")
|
||||
}},
|
||||
{"unset SHELL prints the generic hint", func(t *testing.T, h *scriptHarness) {
|
||||
h.unsetShell = true
|
||||
res := h.mustRun("demo")
|
||||
expectLacks(t, "stderr", res.stderr, "unbound variable")
|
||||
expectHintSuffix(t, res.stdout, exportHint(h.gobin, "and add it to your shell profile"))
|
||||
}},
|
||||
{"unknown SHELL prints the generic hint", func(t *testing.T, h *scriptHarness) {
|
||||
h.env = append(h.env, "SHELL=/bin/tcsh")
|
||||
res := h.mustRun("demo")
|
||||
expectHintSuffix(t, res.stdout, exportHint(h.gobin, "and add it to your shell profile"))
|
||||
}},
|
||||
{"fish SHELL prints fish_add_path", func(t *testing.T, h *scriptHarness) {
|
||||
h.env = append(h.env, "SHELL=/usr/bin/fish")
|
||||
res := h.mustRun("demo")
|
||||
expectHintSuffix(t, res.stdout, fishHint(h.gobin))
|
||||
expectContains(t, "stdout", res.stdout, "fish_add_path also keeps it for new fish sessions", ` fish_add_path "`+h.gobin+`"`)
|
||||
expectLacks(t, "stdout", res.stdout, "export PATH", "shell profile")
|
||||
}},
|
||||
{"zsh SHELL names ~/.zshrc", func(t *testing.T, h *scriptHarness) {
|
||||
h.env = append(h.env, "SHELL=/bin/zsh")
|
||||
res := h.mustRun("demo")
|
||||
expectHintSuffix(t, res.stdout, exportHint(h.gobin, "and add it to ~/.zshrc"))
|
||||
expectLacks(t, "stdout", res.stdout, "fish_add_path")
|
||||
}},
|
||||
{"bash SHELL names ~/.bashrc", func(t *testing.T, h *scriptHarness) {
|
||||
h.env = append(h.env, "SHELL=/usr/local/bin/bash")
|
||||
res := h.mustRun("demo")
|
||||
expectHintSuffix(t, res.stdout, exportHint(h.gobin, "and add it to ~/.bashrc"))
|
||||
expectLacks(t, "stdout", res.stdout, "fish_add_path")
|
||||
}},
|
||||
{"metacharacters in the dir are escaped for zsh", func(t *testing.T, h *scriptHarness) {
|
||||
metaGobin(t, h)
|
||||
h.env = append(h.env, "SHELL=/bin/zsh")
|
||||
res := h.mustRun("demo")
|
||||
want := " export PATH=\"" + hintDir(h.root, false) + "/" + (`go bin\\x\"y\$HOME\` + "`" + `id\` + "`" + `"'!'"z`) + ":$PATH\"\n"
|
||||
expectContains(t, "stdout", res.stdout, want)
|
||||
expectHintSuffix(t, res.stdout, exportHint(h.gobin, "and add it to ~/.zshrc"))
|
||||
}},
|
||||
{"metacharacters in the dir are escaped for fish", func(t *testing.T, h *scriptHarness) {
|
||||
metaGobin(t, h)
|
||||
h.env = append(h.env, "SHELL=/usr/bin/fish")
|
||||
res := h.mustRun("demo")
|
||||
want := " fish_add_path \"" + hintDir(h.root, true) + "/" + (`go bin\\x\"y\$HOME` + "`id`!z") + "\"\n"
|
||||
expectContains(t, "stdout", res.stdout, want)
|
||||
expectHintSuffix(t, res.stdout, fishHint(h.gobin))
|
||||
}},
|
||||
{"GOBIN on PATH prints no hint", func(t *testing.T, h *scriptHarness) {
|
||||
h.gobinOnPath = true
|
||||
h.env = append(h.env, "SHELL=/usr/bin/fish")
|
||||
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)
|
||||
}
|
||||
expectLacks(t, "stdout", res.stdout, "export PATH", "fish_add_path", "not on your PATH")
|
||||
}},
|
||||
{"empty GOBIN installs into GOPATH/bin", func(t *testing.T, h *scriptHarness) {
|
||||
h.useGOBIN = false
|
||||
@@ -604,8 +897,407 @@ func TestScriptBehavior(t *testing.T) {
|
||||
expectContains(t, "stderr", res.stderr, "installing summer into "+bin)
|
||||
expectContains(t, "stdout", res.stdout, `export PATH="`+bin+`:$PATH"`)
|
||||
}},
|
||||
{"update: help names the update forms", 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",
|
||||
"https://summercms.io/update | bash", "bash -s -- --update")
|
||||
// The rejected flag name, split so a grep of the sources finds none.
|
||||
expectLacks(t, "stdout", res.stdout, "--"+"upgrade")
|
||||
expectNothingStarted(t, h)
|
||||
}},
|
||||
{"update: --update with a name is a usage error", func(t *testing.T, h *scriptHarness) {
|
||||
for _, args := range [][]string{{"--update", "demo"}, {"demo", "--update"}} {
|
||||
res := h.run(args...)
|
||||
expectCode(t, res, 2)
|
||||
expectContains(t, "stderr", res.stderr, "--update takes no other argument", "Usage:")
|
||||
expectNothingStarted(t, h)
|
||||
}
|
||||
}},
|
||||
{"update: a repeated --update is accepted", func(t *testing.T, h *scriptHarness) {
|
||||
app := h.updateReady()
|
||||
res := h.runIn(app, "--update", "--update")
|
||||
expectCode(t, res, 0)
|
||||
expectBuilt(t, h, app)
|
||||
}},
|
||||
{"update: outside an application refuses", func(t *testing.T, h *scriptHarness) {
|
||||
res := h.run("--update")
|
||||
expectCode(t, res, 1)
|
||||
expectContains(t, "stderr", res.stderr, "run this from inside an sm-<name>-app directory")
|
||||
expectNothingStarted(t, h)
|
||||
}},
|
||||
{"update: an app without summer.yaml refuses", func(t *testing.T, h *scriptHarness) {
|
||||
app := filepath.Join(h.work, "sm-blog-app")
|
||||
if err := os.MkdirAll(app, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(app, "go.mod"), []byte(appGoMod("blog", "../summercms")), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
res := h.runIn(app, "--update")
|
||||
expectCode(t, res, 1)
|
||||
expectContains(t, "stderr", res.stderr, "run this from inside an sm-<name>-app directory", "summer.yaml")
|
||||
if c := h.log("go.calls"); c != "" {
|
||||
t.Fatalf("go was called:\n%s", c)
|
||||
}
|
||||
}},
|
||||
{"update: go.mod without a framework replace refuses", func(t *testing.T, h *scriptHarness) {
|
||||
app := h.app("blog", "module example.com/blog\n\nrequire "+fwModule+" v0.1.13\n\nreplace example.com/x => ../x\n")
|
||||
res := h.runIn(app, "--update")
|
||||
expectCode(t, res, 1)
|
||||
expectContains(t, "stderr", res.stderr, "has no replace for "+fwModule)
|
||||
if c := h.log("go.calls"); c != "" {
|
||||
t.Fatalf("go was called:\n%s", c)
|
||||
}
|
||||
}},
|
||||
{"update: a module-version replace refuses", func(t *testing.T, h *scriptHarness) {
|
||||
app := h.app("blog", "module example.com/blog\n\n"+replaceLine(fwModule+" v0.1.13")+"\n")
|
||||
res := h.runIn(app, "--update")
|
||||
expectCode(t, res, 1)
|
||||
expectContains(t, "stderr", res.stderr, "not a local checkout", fwModule+" v0.1.13")
|
||||
if c := h.log("go.calls"); c != "" {
|
||||
t.Fatalf("go was called:\n%s", c)
|
||||
}
|
||||
}},
|
||||
{"update: a missing replace target refuses", func(t *testing.T, h *scriptHarness) {
|
||||
app := h.app("blog", appGoMod("blog", "../nowhere"))
|
||||
res := h.runIn(app, "--update")
|
||||
expectCode(t, res, 1)
|
||||
expectContains(t, "stderr", res.stderr, filepath.Join(h.work, "nowhere")+", which does not exist")
|
||||
if c := h.log("go.calls"); c != "" {
|
||||
t.Fatalf("go was called:\n%s", c)
|
||||
}
|
||||
}},
|
||||
{"update: a replace target that is another module refuses", func(t *testing.T, h *scriptHarness) {
|
||||
other := filepath.Join(h.work, "other")
|
||||
if err := os.Mkdir(other, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(other, "go.mod"), []byte("module example.com/other\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
app := h.app("blog", appGoMod("blog", "../other"))
|
||||
res := h.runIn(app, "--update")
|
||||
expectCode(t, res, 1)
|
||||
expectContains(t, "stderr", res.stderr, "is not a SummerCMS framework checkout", "example.com/other")
|
||||
expectNoInstallOrBuild(t, h)
|
||||
}},
|
||||
{"update: a detached checkout moves to the new release and the app is rebuilt", func(t *testing.T, h *scriptHarness) {
|
||||
app := h.updateReady()
|
||||
next := h.remote.tag("v0.11.0")
|
||||
res := h.mustUpdate(app)
|
||||
if got := h.head(); got != next {
|
||||
t.Fatalf("HEAD = %s, want v0.11.0 %s", got, next)
|
||||
}
|
||||
if err := exec.Command("git", "-C", h.checkout(), "symbolic-ref", "-q", "HEAD").Run(); err == nil {
|
||||
t.Fatal("checkout is on a branch, want detached")
|
||||
}
|
||||
expectContains(t, "stderr", res.stderr, "updating the framework checkout", "framework target: v0.11.0")
|
||||
if got, want := h.log("go.install"), "GOWORK=off dir="+h.checkout()+"\n"; got != want {
|
||||
t.Fatalf("go install = %q, want %q", got, want)
|
||||
}
|
||||
expectBuilt(t, h, app)
|
||||
expectHintSuffix(t, res.stdout, "\n==> Done. Run summer migrate, then restart summer serve.\n"+exportHint(h.gobin, "and add it to your shell profile"))
|
||||
}},
|
||||
{"update: at the newest release it still reinstalls and rebuilds", func(t *testing.T, h *scriptHarness) {
|
||||
app := h.updateReady()
|
||||
before := h.head()
|
||||
res := h.mustUpdate(app)
|
||||
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")
|
||||
if h.log("go.install") == "" {
|
||||
t.Fatal("go install did not run")
|
||||
}
|
||||
expectBuilt(t, h, app)
|
||||
}},
|
||||
{"update: SUMMERCMS_REF selects the tag of a detached checkout", func(t *testing.T, h *scriptHarness) {
|
||||
app := h.updateReady()
|
||||
want := h.remote.tag("v0.11.0")
|
||||
h.remote.tag("v0.12.0")
|
||||
h.env = append(h.env, "SUMMERCMS_REF=v0.11.0")
|
||||
h.mustUpdate(app)
|
||||
if got := h.head(); got != want {
|
||||
t.Fatalf("HEAD = %s, want v0.11.0 %s", got, want)
|
||||
}
|
||||
}},
|
||||
{"update: a block replace with an absolute path", func(t *testing.T, h *scriptHarness) {
|
||||
h.mustRun("demo")
|
||||
app := h.app("blog", "module example.com/blog\n\nreplace (\n\texample.com/x => ./x\n\t"+fwModule+" v0.0.0 => "+h.checkout()+" // local\n)\n")
|
||||
h.resetLogs()
|
||||
next := h.remote.tag("v0.11.0")
|
||||
h.mustUpdate(app)
|
||||
if got := h.head(); got != next {
|
||||
t.Fatalf("HEAD = %s, want v0.11.0 %s", got, next)
|
||||
}
|
||||
expectBuilt(t, h, app)
|
||||
}},
|
||||
{"update: a failed build exits 1 naming the app", func(t *testing.T, h *scriptHarness) {
|
||||
app := h.updateReady()
|
||||
h.env = append(h.env, "STUB_SUMMER_FAIL=1")
|
||||
res := h.runIn(app, "--update")
|
||||
expectCode(t, res, 1)
|
||||
expectContains(t, "stderr", res.stderr, "summer build failed in "+app)
|
||||
expectLacks(t, "stdout", res.stdout, "==> Done.")
|
||||
}},
|
||||
{"update: fish SHELL ends with fish_add_path", func(t *testing.T, h *scriptHarness) {
|
||||
app := h.updateReady()
|
||||
h.env = append(h.env, "SHELL=/usr/bin/fish")
|
||||
res := h.mustUpdate(app)
|
||||
expectHintSuffix(t, res.stdout, "\n==> Done. Run summer migrate, then restart summer serve.\n"+fishHint(h.gobin))
|
||||
}},
|
||||
{"update: GOBIN on PATH prints no hint", func(t *testing.T, h *scriptHarness) {
|
||||
app := h.updateReady()
|
||||
h.gobinOnPath = true
|
||||
res := h.mustUpdate(app)
|
||||
if res.stdout != "\n==> Done. Run summer migrate, then restart summer serve.\n" {
|
||||
t.Fatalf("stdout = %q", res.stdout)
|
||||
}
|
||||
}},
|
||||
{"update: create-mode stdout is unchanged", func(t *testing.T, h *scriptHarness) {
|
||||
res := h.mustRun("demo")
|
||||
want := "\n==> Done. Start with:\n cd " + filepath.Join(h.work, "sm-demo-app") + "\n" + exportHint(h.gobin, "and add it to your shell profile")
|
||||
if res.stdout != want {
|
||||
t.Fatalf("stdout = %q\nwant %q", res.stdout, want)
|
||||
}
|
||||
}},
|
||||
{"update: UpdateScript runs update mode with no arguments", func(t *testing.T, h *scriptHarness) {
|
||||
app := h.updateReady()
|
||||
res := h.runUpdate(app)
|
||||
expectCode(t, res, 0)
|
||||
expectBuilt(t, h, app)
|
||||
h.resetLogs()
|
||||
res = h.runUpdate(app, "demo")
|
||||
expectCode(t, res, 2)
|
||||
expectContains(t, "stderr", res.stderr, "--update takes no other argument")
|
||||
expectNoInstallOrBuild(t, h)
|
||||
}},
|
||||
{"update: a branch checkout is pulled fast-forward", func(t *testing.T, h *scriptHarness) {
|
||||
app := h.updateReady()
|
||||
h.remote.git(h.checkout(), "switch", "-q", "main")
|
||||
next := h.remote.commit()
|
||||
res := h.mustUpdate(app)
|
||||
if got := h.head(); got != next {
|
||||
t.Fatalf("HEAD = %s, want the new main %s", got, next)
|
||||
}
|
||||
if got := h.branch(); got != "main" {
|
||||
t.Fatalf("branch = %s", got)
|
||||
}
|
||||
expectContains(t, "stderr", res.stderr, "pulling main from origin/main (fast-forward only)", "framework checkout moved from")
|
||||
if h.log("go.install") == "" {
|
||||
t.Fatal("go install did not run")
|
||||
}
|
||||
expectBuilt(t, h, app)
|
||||
|
||||
h.resetLogs()
|
||||
res = h.mustUpdate(app)
|
||||
expectContains(t, "stderr", res.stderr, "framework checkout is already up to date")
|
||||
if got := h.head(); got != next {
|
||||
t.Fatalf("HEAD moved to %s", got)
|
||||
}
|
||||
}},
|
||||
{"update: a dirty branch refuses and is kept", func(t *testing.T, h *scriptHarness) {
|
||||
app := h.updateReady()
|
||||
h.remote.git(h.checkout(), "switch", "-q", "main")
|
||||
version := filepath.Join(h.checkout(), "VERSION")
|
||||
if err := os.WriteFile(version, []byte("local edit\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h.remote.commit()
|
||||
before := h.head()
|
||||
res := h.runIn(app, "--update")
|
||||
expectRefusedUnchanged(t, h, res, before, "uncommitted changes to tracked files")
|
||||
if got := h.branch(); got != "main" {
|
||||
t.Fatalf("branch = %s", got)
|
||||
}
|
||||
if got := readFile(t, version); got != "local edit\n" {
|
||||
t.Fatalf("VERSION = %q", got)
|
||||
}
|
||||
expectNoInstallOrBuild(t, h)
|
||||
}},
|
||||
{"update: a branch without an upstream refuses", func(t *testing.T, h *scriptHarness) {
|
||||
app := h.updateReady()
|
||||
h.remote.git(h.checkout(), "switch", "-q", "-c", "mine")
|
||||
before := h.head()
|
||||
res := h.runIn(app, "--update")
|
||||
expectRefusedUnchanged(t, h, res, before, "has no upstream")
|
||||
if got := h.branch(); got != "mine" {
|
||||
t.Fatalf("branch = %s", got)
|
||||
}
|
||||
expectNoInstallOrBuild(t, h)
|
||||
}},
|
||||
{"update: a diverged branch refuses", func(t *testing.T, h *scriptHarness) {
|
||||
app := h.updateReady()
|
||||
h.remote.git(h.checkout(), "switch", "-q", "main")
|
||||
h.remote.git(h.checkout(), "commit", "-q", "--allow-empty", "-m", "local work")
|
||||
h.remote.commit()
|
||||
before := h.head()
|
||||
// With an identity a plain pull could merge; only --ff-only refuses.
|
||||
h.env = append(h.env, "GIT_AUTHOR_NAME=U", "GIT_AUTHOR_EMAIL=u@example.com", "GIT_COMMITTER_NAME=U", "GIT_COMMITTER_EMAIL=u@example.com")
|
||||
res := h.runIn(app, "--update")
|
||||
expectRefusedUnchanged(t, h, res, before, "not a fast-forward")
|
||||
expectNoInstallOrBuild(t, h)
|
||||
}},
|
||||
{"update: SUMMERCMS_REF is ignored on a branch", func(t *testing.T, h *scriptHarness) {
|
||||
app := h.updateReady()
|
||||
h.remote.git(h.checkout(), "switch", "-q", "main")
|
||||
next := h.remote.commit()
|
||||
h.env = append(h.env, "SUMMERCMS_REF=v0.2.0")
|
||||
res := h.mustUpdate(app)
|
||||
expectContains(t, "stderr", res.stderr, "SUMMERCMS_REF is ignored for a branch checkout")
|
||||
if got := h.head(); got != next {
|
||||
t.Fatalf("HEAD = %s, want the new main %s (not v0.2.0)", got, next)
|
||||
}
|
||||
}},
|
||||
{"update: SUMMERCMS_DIR is ignored", func(t *testing.T, h *scriptHarness) {
|
||||
app := h.updateReady()
|
||||
h.env = append(h.env, "SUMMERCMS_DIR=elsewhere")
|
||||
res := h.mustUpdate(app)
|
||||
expectContains(t, "stderr", res.stderr, "SUMMERCMS_DIR is ignored by --update")
|
||||
if got, want := h.log("go.install"), "GOWORK=off dir="+h.checkout()+"\n"; got != want {
|
||||
t.Fatalf("go install = %q, want %q", got, want)
|
||||
}
|
||||
}},
|
||||
{"update: other apps on the same checkout are named, not rebuilt", func(t *testing.T, h *scriptHarness) {
|
||||
app := h.updateReady()
|
||||
alpha := h.app("alpha", appGoMod("alpha", "../summercms"))
|
||||
beta := h.app("beta", "module example.com/beta\n\nreplace (\n\t"+fwModule+" => "+h.checkout()+"\n)\n")
|
||||
other := h.app("other", appGoMod("other", "../elsewhere"))
|
||||
for _, d := range []string{alpha, beta, other} {
|
||||
writeEnv(t, filepath.Join(d, ".env"), "SUMMER_DATABASE__DSN=x\n", 0o600)
|
||||
}
|
||||
res := h.mustUpdate(app)
|
||||
var lines []string
|
||||
for _, l := range strings.Split(res.stderr, "\n") {
|
||||
if strings.Contains(l, "not rebuilt") {
|
||||
lines = append(lines, l)
|
||||
}
|
||||
}
|
||||
if len(lines) != 1 {
|
||||
t.Fatalf("want one not rebuilt line, got %d:\n%s", len(lines), res.stderr)
|
||||
}
|
||||
expectContains(t, "not rebuilt line", lines[0], alpha, beta)
|
||||
expectLacks(t, "not rebuilt line", lines[0], "sm-other-app", "sm-blog-app")
|
||||
expectBuilt(t, h, app)
|
||||
for _, d := range []string{alpha, beta} {
|
||||
if got := readFile(t, filepath.Join(d, ".env")); got != "SUMMER_DATABASE__DSN=x\n" {
|
||||
t.Fatalf("%s/.env changed (length %d)", d, len(got))
|
||||
}
|
||||
}
|
||||
}},
|
||||
{"update: an app elsewhere still names apps next to the checkout", func(t *testing.T, h *scriptHarness) {
|
||||
h.mustRun("demo")
|
||||
alpha := h.app("alpha", appGoMod("alpha", "../summercms"))
|
||||
far := h.appAt(filepath.Join(h.root, "apps", "sm-far-app"), appGoMod("far", h.checkout()))
|
||||
h.resetLogs()
|
||||
res := h.mustUpdate(far)
|
||||
expectContains(t, "stderr", res.stderr, "not rebuilt", alpha)
|
||||
expectBuilt(t, h, far)
|
||||
}},
|
||||
{"update: no other apps prints no not rebuilt line", func(t *testing.T, h *scriptHarness) {
|
||||
app := h.updateReady()
|
||||
res := h.mustUpdate(app)
|
||||
expectLacks(t, "stderr", res.stderr, "not rebuilt")
|
||||
}},
|
||||
{"update: a missing admin secret is appended with head and base64", func(t *testing.T, h *scriptHarness) {
|
||||
app := h.updateReady()
|
||||
env := filepath.Join(app, ".env")
|
||||
writeEnv(t, env, "SUMMER_DATABASE__DSN=x\n", 0o640)
|
||||
res := h.mustUpdate(app)
|
||||
expectAppended(t, res, env, "SUMMER_DATABASE__DSN=x\n")
|
||||
expectMode(t, env, 0o640)
|
||||
expectContains(t, "stderr", res.stderr, "added "+adminKey+" to "+env)
|
||||
expectBuilt(t, h, app)
|
||||
|
||||
after := readFile(t, env)
|
||||
h.resetLogs()
|
||||
res = h.mustUpdate(app)
|
||||
if readFile(t, env) != after {
|
||||
t.Fatal(".env changed on the second run")
|
||||
}
|
||||
expectLacks(t, "stderr", res.stderr, "added "+adminKey)
|
||||
}},
|
||||
{"update: a .env without a final newline gets one first", func(t *testing.T, h *scriptHarness) {
|
||||
app := h.updateReady()
|
||||
env := filepath.Join(app, ".env")
|
||||
writeEnv(t, env, "SUMMER_DATABASE__DSN=x", 0o600)
|
||||
res := h.mustUpdate(app)
|
||||
expectAppended(t, res, env, "SUMMER_DATABASE__DSN=x\n")
|
||||
}},
|
||||
{"update: an export-form admin secret counts as present", func(t *testing.T, h *scriptHarness) {
|
||||
app := h.updateReady()
|
||||
env := filepath.Join(app, ".env")
|
||||
data := " export " + adminKey + " = abc\n"
|
||||
writeEnv(t, env, data, 0o600)
|
||||
res := h.mustUpdate(app)
|
||||
if readFile(t, env) != data {
|
||||
t.Fatal(".env changed")
|
||||
}
|
||||
expectLacks(t, "stderr", res.stderr, "added "+adminKey, "is empty")
|
||||
}},
|
||||
{"update: a commented admin secret does not count", func(t *testing.T, h *scriptHarness) {
|
||||
app := h.updateReady()
|
||||
env := filepath.Join(app, ".env")
|
||||
data := "# " + adminKey + "=old\n"
|
||||
writeEnv(t, env, data, 0o600)
|
||||
res := h.mustUpdate(app)
|
||||
expectAppended(t, res, env, data)
|
||||
}},
|
||||
{"update: an empty admin secret is kept with a warning", func(t *testing.T, h *scriptHarness) {
|
||||
app := h.updateReady()
|
||||
env := filepath.Join(app, ".env")
|
||||
data := adminKey + "=\n"
|
||||
writeEnv(t, env, data, 0o600)
|
||||
res := h.mustUpdate(app)
|
||||
if readFile(t, env) != data {
|
||||
t.Fatal(".env changed")
|
||||
}
|
||||
expectContains(t, "stderr", res.stderr, adminKey+" in "+env+" is empty")
|
||||
expectBuilt(t, h, app)
|
||||
}},
|
||||
{"update: openssl is preferred for the admin secret", func(t *testing.T, h *scriptHarness) {
|
||||
app := h.updateReady()
|
||||
const value = "QUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVphYmNkZWY="
|
||||
h.stubOpenssl(value)
|
||||
env := filepath.Join(app, ".env")
|
||||
writeEnv(t, env, "SUMMER_DATABASE__DSN=x\n", 0o600)
|
||||
res := h.mustUpdate(app)
|
||||
if got := h.log("openssl.calls"); got != "rand -base64 32\n" {
|
||||
t.Fatalf("openssl calls = %q", got)
|
||||
}
|
||||
if got := expectAppended(t, res, env, "SUMMER_DATABASE__DSN=x\n"); got != value {
|
||||
t.Fatal("the appended value is not the openssl output")
|
||||
}
|
||||
}},
|
||||
{"update: a failed secret generator exits 1 and keeps .env", func(t *testing.T, h *scriptHarness) {
|
||||
app := h.updateReady()
|
||||
h.stubOpenssl("short")
|
||||
h.without("head")
|
||||
env := filepath.Join(app, ".env")
|
||||
data := "SUMMER_DATABASE__DSN=x\n"
|
||||
writeEnv(t, env, data, 0o600)
|
||||
res := h.runIn(app, "--update")
|
||||
expectCode(t, res, 1)
|
||||
if readFile(t, env) != data {
|
||||
t.Fatal(".env changed")
|
||||
}
|
||||
expectContains(t, "stderr", res.stderr, "add a "+adminKey+" line")
|
||||
if c := h.log("summer.call"); c != "" {
|
||||
t.Fatalf("summer was called:\n%s", c)
|
||||
}
|
||||
}},
|
||||
{"update: no .env is skipped", func(t *testing.T, h *scriptHarness) {
|
||||
app := h.updateReady()
|
||||
res := h.mustUpdate(app)
|
||||
if _, err := os.Lstat(filepath.Join(app, ".env")); !os.IsNotExist(err) {
|
||||
t.Fatalf(".env was created: %v", err)
|
||||
}
|
||||
expectContains(t, "stderr", res.stderr, "no .env in "+app)
|
||||
expectBuilt(t, h, app)
|
||||
}},
|
||||
}
|
||||
if len(cases) < 18 {
|
||||
if len(cases) < 67 {
|
||||
t.Fatalf("only %d cases", len(cases))
|
||||
}
|
||||
for _, tc := range cases {
|
||||
|
||||
@@ -6,6 +6,12 @@
|
||||
// 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.
|
||||
//
|
||||
// The same script has an update mode, which
|
||||
// `curl -fsSL https://summercms.io/update | bash` runs inside one
|
||||
// application: it moves the framework checkout named by the application's
|
||||
// go.mod forward, reinstalls summer and rebuilds that application.
|
||||
// UpdateScript() is the script with update mode preset, for the /update URL.
|
||||
package sunrise
|
||||
|
||||
import (
|
||||
@@ -21,3 +27,31 @@ var script []byte
|
||||
func Script() []byte {
|
||||
return bytes.Clone(script)
|
||||
}
|
||||
|
||||
const (
|
||||
mainCall = "main \"$@\"\n"
|
||||
updateCall = "main \"$@\" --update\n"
|
||||
)
|
||||
|
||||
var updateScript = deriveUpdateScript(script)
|
||||
|
||||
// deriveUpdateScript replaces the final `main "$@"` line of s with
|
||||
// `main "$@" --update`. It panics when s does not end with that line, so a
|
||||
// changed script fails every test instead of serving create mode at /update.
|
||||
func deriveUpdateScript(s []byte) []byte {
|
||||
if !bytes.HasSuffix(s, []byte(mainCall)) {
|
||||
panic(`sunrise: new.sh must end with the line main "$@"`)
|
||||
}
|
||||
out := bytes.Clone(s[:len(s)-len(mainCall)])
|
||||
return append(out, updateCall...)
|
||||
}
|
||||
|
||||
// UpdateScript returns the bootstrap script with update mode preset: Script()
|
||||
// with its final line `main "$@"` replaced by `main "$@" --update`, so both
|
||||
// URLs serve the same logic. The flag follows "$@" so that every proper
|
||||
// prefix of the last line, as a cut-short download delivers it, calls main
|
||||
// without a valid --update or with no arguments, and main exits with the
|
||||
// usage instead of running anything. Each call returns a fresh copy.
|
||||
func UpdateScript() []byte {
|
||||
return bytes.Clone(updateScript)
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -48,15 +49,56 @@ func TestScriptShape(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestScriptTruncationRunsNothing runs every prefix of the script that ends
|
||||
func TestUpdateScript(t *testing.T) {
|
||||
s := Script()
|
||||
want := append(bytes.Clone(bytes.TrimSuffix(s, []byte("main \"$@\"\n"))), "main \"$@\" --update\n"...)
|
||||
if bytes.Equal(want, s) {
|
||||
t.Fatal("Script() does not end with main \"$@\"")
|
||||
}
|
||||
got := UpdateScript()
|
||||
if !bytes.Equal(got, want) {
|
||||
t.Fatal("UpdateScript() is not Script() with the last line replaced by main \"$@\" --update")
|
||||
}
|
||||
lines := strings.Split(strings.TrimRight(string(got), "\n"), "\n")
|
||||
if last := lines[len(lines)-1]; last != `main "$@" --update` {
|
||||
t.Fatalf("last line = %q", last)
|
||||
}
|
||||
for i := range got {
|
||||
got[i] = 'x'
|
||||
}
|
||||
if !bytes.Equal(UpdateScript(), want) {
|
||||
t.Fatal("modifying a returned slice changed the next UpdateScript() call")
|
||||
}
|
||||
bash := lookBash(t)
|
||||
file := filepath.Join(t.TempDir(), "update.sh")
|
||||
if err := os.WriteFile(file, want, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if out, err := exec.Command(bash, "-n", file).CombinedOutput(); err != nil {
|
||||
t.Fatalf("bash -n on UpdateScript(): %v\n%s", err, out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeriveUpdateScriptPanicsWithoutMainCall(t *testing.T) {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("deriveUpdateScript accepted a script without the final main call")
|
||||
}
|
||||
}()
|
||||
deriveUpdateScript([]byte("#!/usr/bin/env bash\necho hi\n"))
|
||||
}
|
||||
|
||||
// TestScriptTruncationRunsNothing runs every prefix of a 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.
|
||||
// changes a file. It covers Script() creating an application, Script() with
|
||||
// --update inside an application and UpdateScript() with no arguments. A
|
||||
// positive control per variant 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"} {
|
||||
for _, name := range []string{"git", "go", "docker", "curl", "summer", "openssl", "head", "base64"} {
|
||||
body := "#!/bin/sh\necho \"" + name + " $*\" >>'" + marker + "'\n"
|
||||
if err := os.WriteFile(filepath.Join(stubs, name), []byte(body), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -64,49 +106,113 @@ func TestScriptTruncationRunsNothing(t *testing.T) {
|
||||
}
|
||||
home := t.TempDir()
|
||||
scripts := t.TempDir()
|
||||
run := func(t *testing.T, prefix []byte) (work string) {
|
||||
|
||||
// emptyDir runs in an empty directory; appTree runs inside an
|
||||
// application whose go.mod points at a framework directory next to it.
|
||||
emptyDir := func(t *testing.T) (root, cwd string) {
|
||||
d := t.TempDir()
|
||||
return d, d
|
||||
}
|
||||
appTree := func(t *testing.T) (root, cwd string) {
|
||||
root = t.TempDir()
|
||||
app := filepath.Join(root, "sm-demo-app")
|
||||
fw := filepath.Join(root, "summercms")
|
||||
for _, d := range []string{app, fw} {
|
||||
if err := os.Mkdir(d, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
files := map[string]string{
|
||||
filepath.Join(app, "go.mod"): "module example.com/demo\n\n" + replaceLine("../summercms") + "\n",
|
||||
filepath.Join(app, "summer.yaml"): "name: demo\n",
|
||||
filepath.Join(fw, "go.mod"): "module " + fwModule + "\n",
|
||||
}
|
||||
for p, data := range files {
|
||||
if err := os.WriteFile(p, []byte(data), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
return root, app
|
||||
}
|
||||
listing := func(t *testing.T, root string) string {
|
||||
t.Helper()
|
||||
file := filepath.Join(scripts, "new.sh")
|
||||
if err := os.WriteFile(file, prefix, 0o644); err != nil {
|
||||
var b strings.Builder
|
||||
err := filepath.WalkDir(root, func(p string, d os.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
info, err := d.Info()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rel, _ := filepath.Rel(root, p)
|
||||
b.WriteString(rel + " " + info.Mode().String() + " " + strconv.FormatInt(info.Size(), 10) + "\n")
|
||||
return nil
|
||||
})
|
||||
if 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
|
||||
return b.String()
|
||||
}
|
||||
|
||||
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))
|
||||
variants := []struct {
|
||||
name string
|
||||
script []byte
|
||||
args []string
|
||||
tree func(t *testing.T) (root, cwd string)
|
||||
}{
|
||||
{"Script demo", Script(), []string{"demo"}, emptyDir},
|
||||
{"Script --update", Script(), []string{"--update"}, appTree},
|
||||
{"UpdateScript", UpdateScript(), nil, appTree},
|
||||
}
|
||||
for _, v := range variants {
|
||||
t.Run(v.name, func(t *testing.T) {
|
||||
_ = os.Remove(marker)
|
||||
run := func(t *testing.T, prefix []byte) {
|
||||
t.Helper()
|
||||
file := filepath.Join(scripts, "script.sh")
|
||||
if err := os.WriteFile(file, prefix, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
root, cwd := v.tree(t)
|
||||
before := listing(t, root)
|
||||
cmd := exec.Command(bash, append([]string{file}, v.args...)...)
|
||||
cmd.Dir = cwd
|
||||
cmd.Env = []string{"PATH=" + stubs, "HOME=" + home, "PWD=" + cwd, "LC_ALL=C"}
|
||||
_ = cmd.Run()
|
||||
if after := listing(t, root); after != before {
|
||||
t.Fatalf("prefix of %d bytes (ending %q) changed the tree:\nbefore:\n%s\nafter:\n%s", len(prefix), tail(prefix), before, after)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
s := v.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 {
|
||||
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 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -129,8 +235,10 @@ func lookBash(t *testing.T) string {
|
||||
// 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.
|
||||
// then drops the admin token secret from the app's .env and runs
|
||||
// UpdateScript() from /update inside the app, which must restore the secret
|
||||
// without printing it and rebuild. 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")
|
||||
@@ -146,20 +254,25 @@ func TestBootstrapEndToEnd(t *testing.T) {
|
||||
}
|
||||
head := gitOut(t, root, "rev-parse", "HEAD")
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
if r.URL.Path == "/update" {
|
||||
_, _ = w.Write(UpdateScript())
|
||||
return
|
||||
}
|
||||
_, _ = w.Write(Script())
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
work := t.TempDir()
|
||||
gobin := t.TempDir()
|
||||
env := []string{"SUMMERCMS_REPO=" + root, "SUMMERCMS_REF=" + head, "GOBIN=" + gobin}
|
||||
// The PATH hint form follows SHELL: pin bash so the export line is checked.
|
||||
env := []string{"SUMMERCMS_REPO=" + root, "SUMMERCMS_REF=" + head, "GOBIN=" + gobin, "SHELL=/bin/bash"}
|
||||
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":
|
||||
key == "GOWORK", key == "GOBIN", key == "PWD", key == "GOFLAGS", key == "SHELL":
|
||||
continue
|
||||
}
|
||||
env = append(env, kv)
|
||||
@@ -197,6 +310,9 @@ func TestBootstrapEndToEnd(t *testing.T) {
|
||||
if !strings.Contains(string(out), `export PATH="`+gobin+`:$PATH"`) {
|
||||
t.Fatalf("output lacks the PATH hint for %s", gobin)
|
||||
}
|
||||
if !strings.Contains(string(out), "(and add it to ~/.bashrc):") {
|
||||
t.Fatal("output lacks the ~/.bashrc note of the bash PATH hint")
|
||||
}
|
||||
|
||||
b := exec.CommandContext(ctx, filepath.Join(gobin, "summer"), "build")
|
||||
b.Dir = app
|
||||
@@ -204,6 +320,60 @@ func TestBootstrapEndToEnd(t *testing.T) {
|
||||
if out, err := b.CombinedOutput(); err != nil {
|
||||
t.Fatalf("summer build in the generated app: %v\n%s", err, out)
|
||||
}
|
||||
|
||||
// Update: drop the admin secret, as in an app created before the admin
|
||||
// was wired in, then run /update inside the app.
|
||||
envPath := filepath.Join(app, ".env")
|
||||
envData, err := os.ReadFile(envPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var kept []string
|
||||
for _, line := range strings.SplitAfter(string(envData), "\n") {
|
||||
if !strings.HasPrefix(line, "SUMMER_ADMIN__JWT__SECRET=") {
|
||||
kept = append(kept, line)
|
||||
}
|
||||
}
|
||||
if err := os.WriteFile(envPath, []byte(strings.Join(kept, "")), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Remove(filepath.Join(app, "bin", "demo")); err != nil && !os.IsNotExist(err) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
up := exec.CommandContext(ctx, "bash", "-c", `set -o pipefail; curl -fsSL "$1" | bash`, "bash", srv.URL+"/update")
|
||||
up.Dir = app
|
||||
up.Env = append(env[:len(env):len(env)], "PWD="+app)
|
||||
upOut, err := up.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("curl /update | bash: %v\n%s", err, upOut)
|
||||
}
|
||||
for _, want := range []string{"already at the target", "added SUMMER_ADMIN__JWT__SECRET to " + envPath, "Run summer migrate, then restart summer serve"} {
|
||||
if !strings.Contains(string(upOut), want) {
|
||||
t.Fatalf("update output lacks %q:\n%s", want, upOut)
|
||||
}
|
||||
}
|
||||
envData, err = os.ReadFile(envPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var secrets []string
|
||||
for _, line := range strings.Split(string(envData), "\n") {
|
||||
if v, ok := strings.CutPrefix(line, "SUMMER_ADMIN__JWT__SECRET="); ok {
|
||||
secrets = append(secrets, v)
|
||||
}
|
||||
}
|
||||
if len(secrets) != 1 || len(secrets[0]) != 44 {
|
||||
t.Fatalf("want one SUMMER_ADMIN__JWT__SECRET line with a 44-character value, got %d lines", len(secrets))
|
||||
}
|
||||
if strings.Contains(string(upOut), secrets[0]) {
|
||||
t.Fatal("the update output contains the generated secret")
|
||||
}
|
||||
if fi, err := os.Stat(envPath); err != nil || fi.Mode().Perm() != 0o600 {
|
||||
t.Fatalf(".env mode: %v %v", fi, err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(app, "bin", "demo")); err != nil {
|
||||
t.Fatalf("the update did not rebuild bin/demo: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func gitOut(t *testing.T, dir string, args ...string) string {
|
||||
|
||||
@@ -22,7 +22,7 @@ surf turns the routes that plugins declare through `pact.HasRoutes` into one `ht
|
||||
- Path-scoped CORS configured with the same keys as Laravel's `config/cors.php` (`surf.CORSConfig`), including preflight handling. Every `OPTIONS` request on a CORS path is answered with 204 before routing, with the headers Laravel's `HandleCors` sends: `Cache-Control: no-cache, private` always and, on a preflight (an `Origin` and an `Access-Control-Request-Method`), the requested method (upper-cased) and headers echoed in `Access-Control-Allow-Methods` and `Access-Control-Allow-Headers` when `*` allows any, `Vary` on the request headers and PHP's default `Content-Type: text/html; charset=UTF-8`.
|
||||
- Optional `surf.LocaleResolver`: a compiled plugin publishes the interface through backpack; surf looks it up without importing the plugin. When present, it may strip an enabled locale prefix before routing and writes a validated code with `towel.WithLocale`. When absent, hosts keep the raw `Accept-Language` header and `surf.LocaleFromPrincipal` overlay.
|
||||
- `surf.LocaleFromPrincipal` switches the request locale to the signed-in user's preferred locale.
|
||||
- A read-only route table (`surf.Router.Routes`) and the `serve` and `route:list` commands.
|
||||
- A read-only route table (`surf.Router.Routes`), the admin mount status (`surf.Router.AdminPrefix`) and the `serve` and `route:list` commands.
|
||||
|
||||
## Usage
|
||||
|
||||
@@ -108,6 +108,7 @@ The generated application `main` wires surf in with `surf.ServeCommand` and `sur
|
||||
| `surf.Router.RegisterMiddleware` | Stores a named middleware; duplicates fail. |
|
||||
| `surf.Router.RegisterMiddlewareFactory` | Stores a parameterized middleware used as `name:param`. |
|
||||
| `surf.Router.Routes` | Returns a copy of the registered routes as `surf.RouteInfo` values. |
|
||||
| `surf.Router.AdminPrefix` | The admin mount path and true when `surf.BuildRouter` mounted the cabana admin; false when no plugin registers an admin controller. |
|
||||
| `surf.RouteInfo` | Method, pattern, owning plugin, middleware and raw flag of one route. |
|
||||
| `surf.Use` | Builds a middleware name list for a group. |
|
||||
| `surf.Constraint` | A compiled path-parameter restriction built by `surf.Regex` or `surf.Enum`. |
|
||||
@@ -162,8 +163,8 @@ The `serve` command also opens the database through [lagoon](../lagoon/README.md
|
||||
|
||||
| Command | Flags | Description |
|
||||
|---------|-------|-------------|
|
||||
| `serve` | `--addr` (default `:8080`) | Opens the database and uploads bucket, assembles the router, starts the in-process job worker (see `queue.work_in_serve`) and serves HTTP until SIGINT or SIGTERM, then shuts the server and the worker down gracefully within 10 seconds. |
|
||||
| `route:list` | none | Builds the router the same way `serve` does, without opening the database or listening, and prints a table of method, pattern, plugin, middleware and raw flag for every route. |
|
||||
| `serve` | `--addr` (default `:8080`) | Opens the database and uploads bucket, assembles the router, binds the address, starts the in-process job worker (see `queue.work_in_serve`) and serves HTTP until SIGINT or SIGTERM, then shuts the server and the worker down gracefully within 10 seconds. After binding it prints the listen address with a browser URL (an unspecified host shows as 127.0.0.1) and an admin status line: the admin URL, with a warning naming `summer admin:create` when no administrator exists yet, or `admin: off` with the steps to add an admin controller. |
|
||||
| `route:list` | none | Builds the router the same way `serve` does, without opening the database or listening, and prints a table of method, pattern, plugin, middleware and raw flag for every route, followed by the admin status line (`admin: mounted at <prefix>` or `admin: off`). |
|
||||
|
||||
## Dependencies
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package surf
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"os"
|
||||
@@ -10,6 +11,7 @@ import (
|
||||
"testing/fstest"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||
"git.golem15.com/golem15/summercms/modules/bonfire"
|
||||
"git.golem15.com/golem15/summercms/modules/compass"
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
"git.golem15.com/golem15/summercms/modules/party"
|
||||
@@ -177,3 +179,57 @@ func (p prefixRoutePlugin) Routes(r pact.Router) error {
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// TestRouterAdminPrefix reports the admin mount path BuildRouter used.
|
||||
func TestRouterAdminPrefix(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
app *backpack.App
|
||||
plugins []party.Plugin
|
||||
prefix string
|
||||
mounted bool
|
||||
}{
|
||||
{"configured prefix", adminPrefixApp(t), []party.Plugin{adminPrefixPlugin{}}, "/acme-admin", true},
|
||||
{"default prefix", adminPrefixAppWith(t, ""), []party.Plugin{adminPrefixPlugin{}}, "/backend", true},
|
||||
{"no admin controllers", adminPrefixApp(t), []party.Plugin{prefixRoutePlugin{id: "acme.site", method: http.MethodGet, path: "/hello"}}, "", false},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
r, err := BuildRouter(tc.app, tc.plugins)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
prefix, mounted := r.AdminPrefix()
|
||||
if prefix != tc.prefix || mounted != tc.mounted {
|
||||
t.Fatalf("AdminPrefix() = (%q, %v), want (%q, %v)", prefix, mounted, tc.prefix, tc.mounted)
|
||||
}
|
||||
})
|
||||
}
|
||||
if prefix, mounted := (*Router)(nil).AdminPrefix(); prefix != "" || mounted {
|
||||
t.Fatalf("nil router AdminPrefix() = (%q, %v)", prefix, mounted)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRouteListAdminStatus ends the route table with the admin status line.
|
||||
func TestRouteListAdminStatus(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
app *backpack.App
|
||||
plugins []party.Plugin
|
||||
want string
|
||||
}{
|
||||
{"mounted", adminPrefixApp(t), []party.Plugin{adminPrefixPlugin{}}, "admin: mounted at /acme-admin"},
|
||||
{"off", adminPrefixApp(t), []party.Plugin{prefixRoutePlugin{id: "acme.site", method: http.MethodGet, path: "/hello"}}, "admin: off — no plugin registers an admin controller"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
out := bonfire.NewOutput(strings.NewReader(""), &buf, &buf)
|
||||
if err := RouteListCommand(tc.app, tc.plugins).Run(t.Context(), nil, out); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := strings.TrimRight(buf.String(), "\n")
|
||||
if !strings.HasSuffix(got, tc.want) {
|
||||
t.Fatalf("route:list output does not end with %q:\n%s", tc.want, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,8 @@ import (
|
||||
)
|
||||
|
||||
// RouteListCommand builds the router the same way ServeCommand does, then
|
||||
// renders Routes() as a table instead of serving.
|
||||
// renders Routes() as a table instead of serving, followed by the admin
|
||||
// status line.
|
||||
func RouteListCommand(app *backpack.App, plugins []party.Plugin) bonfire.Command {
|
||||
return bonfire.Command{
|
||||
Name: "route:list",
|
||||
@@ -33,6 +34,11 @@ func RouteListCommand(app *backpack.App, plugins []party.Plugin) bonfire.Command
|
||||
})
|
||||
}
|
||||
out.Table([]string{"Method", "Pattern", "Plugin", "Middleware", "Raw"}, rows)
|
||||
if prefix, mounted := r.AdminPrefix(); mounted {
|
||||
out.Info("admin: mounted at " + prefix)
|
||||
} else {
|
||||
out.Info(adminOffText)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
@@ -60,6 +60,8 @@ type Router struct {
|
||||
uploadBytes int64
|
||||
built map[string]pact.Middleware
|
||||
app *backpack.App
|
||||
adminPrefix string
|
||||
adminMounted bool
|
||||
}
|
||||
|
||||
var (
|
||||
@@ -556,6 +558,8 @@ func BuildRouter(app *backpack.App, plugins []party.Plugin) (*Router, error) {
|
||||
if err := r.checkAdminPrefix(admin.Prefix); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r.adminPrefix = admin.Prefix
|
||||
r.adminMounted = true
|
||||
}
|
||||
for _, rt := range r.routes {
|
||||
if _, err := r.wrap(rt); err != nil {
|
||||
|
||||
@@ -10,6 +10,15 @@ type RouteInfo struct {
|
||||
Raw bool
|
||||
}
|
||||
|
||||
// AdminPrefix reports the cabana admin mount path set by BuildRouter. False
|
||||
// means no plugin registers an admin controller, so no admin routes exist.
|
||||
func (r *Router) AdminPrefix() (string, bool) {
|
||||
if r == nil || !r.adminMounted {
|
||||
return "", false
|
||||
}
|
||||
return r.adminPrefix, true
|
||||
}
|
||||
|
||||
// Routes returns a defensive copy of every registered route.
|
||||
func (r *Router) Routes() []RouteInfo {
|
||||
if r == nil {
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strings"
|
||||
@@ -13,11 +14,13 @@ import (
|
||||
|
||||
"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/conga"
|
||||
"git.golem15.com/golem15/summercms/modules/lagoon"
|
||||
"git.golem15.com/golem15/summercms/modules/lagoon/attach"
|
||||
"git.golem15.com/golem15/summercms/modules/party"
|
||||
"gocloud.dev/blob"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// ServeCommand starts a signal-aware HTTP server on the assembled router.
|
||||
@@ -50,22 +53,36 @@ func ServeCommand(app *backpack.App, plugins []party.Plugin) bonfire.Command {
|
||||
return err
|
||||
}
|
||||
defer bucket.Close()
|
||||
h, err := Assemble(app, plugins)
|
||||
r, err := BuildRouter(app, plugins)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
h, err := r.compile()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ln, err := net.Listen("tcp", addr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
worker, err := conga.StartServeWorker(ctx, app, plugins)
|
||||
if err != nil {
|
||||
_ = ln.Close()
|
||||
return err
|
||||
}
|
||||
if worker != nil {
|
||||
out.Info(fmt.Sprintf("job worker started on queues: %s", strings.Join(worker.Queues(), ", ")))
|
||||
}
|
||||
prefix, mounted := r.AdminPrefix()
|
||||
admins := adminsUnknown
|
||||
if mounted {
|
||||
admins = countAdmins(ctx, gdb)
|
||||
}
|
||||
printBanner(out, serveBanner(addr, prefix, mounted, admins))
|
||||
srv := &http.Server{Addr: addr, Handler: h, BaseContext: func(net.Listener) context.Context { return ctx }}
|
||||
errCh := make(chan error, 1)
|
||||
go func() {
|
||||
out.Info(fmt.Sprintf("listening on %s", addr))
|
||||
errCh <- srv.ListenAndServe()
|
||||
errCh <- srv.Serve(ln)
|
||||
}()
|
||||
shutdownCtx := func() (context.Context, context.CancelFunc) {
|
||||
return context.WithTimeout(context.Background(), 10*time.Second)
|
||||
@@ -121,3 +138,119 @@ func publishUploads(ctx context.Context, app *backpack.App) (*blob.Bucket, error
|
||||
}
|
||||
return bucket, nil
|
||||
}
|
||||
|
||||
// adminCreateCommand is the command the startup hints name for creating the
|
||||
// first backend administrator.
|
||||
const adminCreateCommand = "summer admin:create --email you@example.com --superuser"
|
||||
|
||||
// adminOffText is the admin status line serve and route:list print when no
|
||||
// plugin registers an admin controller.
|
||||
const adminOffText = "admin: off — no plugin registers an admin controller"
|
||||
|
||||
// serveURL turns a listen address into a URL a browser can open. An empty or
|
||||
// unspecified host (0.0.0.0, ::) becomes 127.0.0.1, port 80 is left out and
|
||||
// an IPv6 host is bracketed. An address that does not parse gives "".
|
||||
func serveURL(addr string) string {
|
||||
host, port, err := net.SplitHostPort(addr)
|
||||
if err != nil || port == "" {
|
||||
return ""
|
||||
}
|
||||
if host == "" {
|
||||
host = "127.0.0.1"
|
||||
} else if ip, err := netip.ParseAddr(host); err == nil && ip.IsUnspecified() {
|
||||
host = "127.0.0.1"
|
||||
}
|
||||
hostport := net.JoinHostPort(host, port)
|
||||
if port == "80" {
|
||||
hostport = host
|
||||
if strings.Contains(host, ":") {
|
||||
hostport = "[" + host + "]"
|
||||
}
|
||||
}
|
||||
return "http://" + hostport + "/"
|
||||
}
|
||||
|
||||
// adminPresence is what serve could learn about backend administrators.
|
||||
type adminPresence int
|
||||
|
||||
const (
|
||||
adminsUnknown adminPresence = iota
|
||||
adminsMissingTable
|
||||
adminsNone
|
||||
adminsPresent
|
||||
)
|
||||
|
||||
// countAdmins checks whether backend_users exists and holds a row. It is
|
||||
// bounded by a 2 second timeout and maps every failure to adminsUnknown, so
|
||||
// it never fails or blocks boot. HasTable queries information_schema, so a
|
||||
// missing table does not reach the GORM error log.
|
||||
func countAdmins(ctx context.Context, gdb *gorm.DB) adminPresence {
|
||||
if gdb == nil {
|
||||
return adminsUnknown
|
||||
}
|
||||
c, cancel := context.WithTimeout(ctx, 2*time.Second)
|
||||
defer cancel()
|
||||
db := gdb.WithContext(c)
|
||||
if !db.Migrator().HasTable(&cabana.BackendUser{}) {
|
||||
if c.Err() != nil {
|
||||
return adminsUnknown
|
||||
}
|
||||
return adminsMissingTable
|
||||
}
|
||||
var n int64
|
||||
if err := db.Model(&cabana.BackendUser{}).Count(&n).Error; err != nil {
|
||||
return adminsUnknown
|
||||
}
|
||||
if n == 0 {
|
||||
return adminsNone
|
||||
}
|
||||
return adminsPresent
|
||||
}
|
||||
|
||||
// bannerLine is one startup line; warn lines print with the warning symbol.
|
||||
type bannerLine struct {
|
||||
warn bool
|
||||
text string
|
||||
}
|
||||
|
||||
// serveBanner builds the lines serve prints once the port is bound: the
|
||||
// listen address with a browser URL, then the admin status.
|
||||
func serveBanner(addr, adminPrefix string, mounted bool, admins adminPresence) []bannerLine {
|
||||
url := serveURL(addr)
|
||||
listen := "listening on " + addr
|
||||
if url != "" {
|
||||
listen += ", open " + url
|
||||
}
|
||||
lines := []bannerLine{{text: listen}}
|
||||
if !mounted {
|
||||
return append(lines,
|
||||
bannerLine{text: adminOffText},
|
||||
bannerLine{text: "to add one, run summer make:admin-controller <vendor.plugin> <Name>, then summer build, summer migrate and " + adminCreateCommand + "; the admin needs SUMMER_ADMIN__JWT__SECRET, which summer new writes to .env"},
|
||||
)
|
||||
}
|
||||
if url != "" {
|
||||
lines = append(lines, bannerLine{text: "admin: " + strings.TrimSuffix(url, "/") + adminPrefix})
|
||||
} else {
|
||||
lines = append(lines, bannerLine{text: "admin: mounted at " + adminPrefix})
|
||||
}
|
||||
switch admins {
|
||||
case adminsNone:
|
||||
lines = append(lines, bannerLine{warn: true, text: "no administrators yet; create one with " + adminCreateCommand})
|
||||
case adminsMissingTable:
|
||||
lines = append(lines, bannerLine{warn: true, text: "the admin tables do not exist yet; run summer migrate, then " + adminCreateCommand})
|
||||
case adminsUnknown:
|
||||
lines = append(lines, bannerLine{text: "sign in with an administrator; " + adminCreateCommand + " creates one"})
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
// printBanner writes banner lines as info or warning output.
|
||||
func printBanner(out bonfire.Output, lines []bannerLine) {
|
||||
for _, l := range lines {
|
||||
if l.warn {
|
||||
out.Warning(l.text)
|
||||
continue
|
||||
}
|
||||
out.Info(l.text)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
package surf
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||
"git.golem15.com/golem15/summercms/modules/bonfire"
|
||||
"git.golem15.com/golem15/summercms/modules/compass"
|
||||
"gocloud.dev/blob"
|
||||
)
|
||||
@@ -51,3 +54,71 @@ func serveTestApp(t *testing.T, storageYAML string) *backpack.App {
|
||||
}
|
||||
return backpack.New(cfg)
|
||||
}
|
||||
|
||||
func TestServeURL(t *testing.T) {
|
||||
for _, tc := range []struct{ addr, want string }{
|
||||
{":8123", "http://127.0.0.1:8123/"},
|
||||
{"127.0.0.1:9000", "http://127.0.0.1:9000/"},
|
||||
{"[::1]:8080", "http://[::1]:8080/"},
|
||||
{"0.0.0.0:80", "http://127.0.0.1/"},
|
||||
{"[::]:8080", "http://127.0.0.1:8080/"},
|
||||
{"localhost:3000", "http://localhost:3000/"},
|
||||
{"8080", ""},
|
||||
{"", ""},
|
||||
{"127.0.0.1:", ""},
|
||||
} {
|
||||
if got := serveURL(tc.addr); got != tc.want {
|
||||
t.Errorf("serveURL(%q) = %q, want %q", tc.addr, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeBanner(t *testing.T) {
|
||||
listen := bannerLine{text: "listening on :8123, open http://127.0.0.1:8123/"}
|
||||
mounted := bannerLine{text: "admin: http://127.0.0.1:8123/backend"}
|
||||
create := "summer admin:create --email you@example.com --superuser"
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
addr string
|
||||
prefix string
|
||||
mounted bool
|
||||
admins adminPresence
|
||||
want []bannerLine
|
||||
}{
|
||||
{"present", ":8123", "/backend", true, adminsPresent, []bannerLine{listen, mounted}},
|
||||
{"none", ":8123", "/backend", true, adminsNone, []bannerLine{listen, mounted,
|
||||
{warn: true, text: "no administrators yet; create one with " + create}}},
|
||||
{"missing table", ":8123", "/backend", true, adminsMissingTable, []bannerLine{listen, mounted,
|
||||
{warn: true, text: "the admin tables do not exist yet; run summer migrate, then " + create}}},
|
||||
{"unknown", ":8123", "/backend", true, adminsUnknown, []bannerLine{listen, mounted,
|
||||
{text: "sign in with an administrator; " + create + " creates one"}}},
|
||||
{"off", ":8123", "", false, adminsUnknown, []bannerLine{listen,
|
||||
{text: "admin: off — no plugin registers an admin controller"},
|
||||
{text: "to add one, run summer make:admin-controller <vendor.plugin> <Name>, then summer build, summer migrate and " + create + "; the admin needs SUMMER_ADMIN__JWT__SECRET, which summer new writes to .env"}}},
|
||||
{"unparsable address", "8080", "/backend", true, adminsPresent, []bannerLine{
|
||||
{text: "listening on 8080"}, {text: "admin: mounted at /backend"}}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := serveBanner(tc.addr, tc.prefix, tc.mounted, tc.admins)
|
||||
if !slices.Equal(got, tc.want) {
|
||||
t.Fatalf("serveBanner:\n got %#v\nwant %#v", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintBanner(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
out := bonfire.NewOutput(strings.NewReader(""), &buf, &buf)
|
||||
printBanner(out, []bannerLine{{text: "plain"}, {warn: true, text: "careful"}})
|
||||
got := buf.String()
|
||||
if !strings.Contains(got, "ℹ plain") || !strings.Contains(got, "⚠ careful") {
|
||||
t.Fatalf("printBanner output = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCountAdminsNilDB(t *testing.T) {
|
||||
if got := countAdmins(t.Context(), nil); got != adminsUnknown {
|
||||
t.Fatalf("countAdmins(nil) = %v, want adminsUnknown", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,11 @@
|
||||
# generate and tests, and --forbidden.
|
||||
#
|
||||
# --forbidden scans for surfaces the phase must not add: a framework config
|
||||
# variable (SUMMER_ prefix) in new.sh, a new require in go.mod since v0.1.12,
|
||||
# variable (SUMMER_ prefix) in new.sh other than SUMMER_ADMIN__JWT__SECRET,
|
||||
# which update mode appends to an application's .env, or any read
|
||||
# ($SUMMER_..., ${SUMMER_...}) or export of a SUMMER_ variable in new.sh
|
||||
# (D-26: the script's own overrides are SUMMERCMS_*), a new require in go.mod
|
||||
# since v0.1.12,
|
||||
# the stdlib plugin package or yaegi in the dependency graph, an absolute
|
||||
# path in a golden go.mod/go.work, or a golden ignored by .gitignore.
|
||||
#
|
||||
@@ -92,11 +96,12 @@ REQUIRED_TESTS=(
|
||||
TestScriptTruncationRunsNothing
|
||||
TestScriptBehavior
|
||||
TestScriptShellcheck
|
||||
TestUpdateScript
|
||||
TestDocsTree
|
||||
TestDocsAIOutputsInSync
|
||||
)
|
||||
# Site tests that pin GET /new and the landing page commands.
|
||||
SITE_PLUGIN_TESTS=(TestNewScriptRoute TestRoutesAssemble)
|
||||
SITE_PLUGIN_TESTS=(TestNewScriptRoute TestUpdateScriptRoute TestRoutesAssemble)
|
||||
SITE_APP_TESTS=(TestLoadTerminal TestTerminalScript TestTerminalEnv TestTerminalRunScript)
|
||||
|
||||
fail() {
|
||||
@@ -385,8 +390,14 @@ stage_acceptance() {
|
||||
stage_forbidden() {
|
||||
cd "$ROOT"
|
||||
echo "== forbidden surfaces"
|
||||
if grep -nE 'SUMMER_' modules/sunrise/new.sh; then
|
||||
fail "new.sh names a framework config variable (SUMMER_ prefix)"
|
||||
local names
|
||||
names="$(grep -noE 'SUMMER_[A-Z0-9_]*' modules/sunrise/new.sh | grep -v ':SUMMER_ADMIN__JWT__SECRET$' || true)"
|
||||
if [ -n "$names" ]; then
|
||||
printf '%s\n' "$names"
|
||||
fail "new.sh names a framework config variable other than SUMMER_ADMIN__JWT__SECRET"
|
||||
fi
|
||||
if grep -nE '\$\{?SUMMER_|export[[:space:]]+SUMMER_' modules/sunrise/new.sh; then
|
||||
fail "new.sh reads or exports a framework config variable"
|
||||
fi
|
||||
git rev-parse -q --verify 'refs/tags/v0.1.12^{commit}' >/dev/null || fail "tag v0.1.12 is missing; cannot compare go.mod"
|
||||
local added
|
||||
|
||||
Reference in New Issue
Block a user