feat(sunrise): update an application in place with summercms.io/update

- new.sh gains --update, run inside one application directory (go.mod and
  summer.yaml required); it changes only that application
- the framework checkout is read from the app's go.mod replace (single-line
  or block form); module-version replaces, missing targets and foreign or
  dirty checkouts are refused through the shared check_framework_dir
- a detached checkout moves fast-forward only to the newest release (or
  SUMMERCMS_REF) through the shared move_detached; a branch checkout is
  pulled with git pull --no-rebase --ff-only, refusing a missing upstream
  or a diverged branch with the checkout unchanged
- other sm-*-app directories on the same checkout are named on one
  "not rebuilt" line and never built or written
- summer is reinstalled, a missing SUMMER_ADMIN__JWT__SECRET is appended to
  this app's .env (openssl, else head and base64; mode kept, never printed),
  then summer build runs and the script ends with the migrate and restart
  reminder and the shared path_hint
- sunrise.UpdateScript() serves the same script with its last line changed
  to main "$@" --update, so truncated downloads still run nothing
- check-phase14.3.sh --forbidden now allows only the literal
  SUMMER_ADMIN__JWT__SECRET in new.sh and still fails on any other
  SUMMER_ name or on a read or export of one; TestUpdateScript and
  TestUpdateScriptRoute are required tests
This commit is contained in:
Jakub Zych
2026-10-08 20:15:50 +02:00
parent 27f75308d7
commit 9b3803c098
8 changed files with 1292 additions and 108 deletions

View File

@@ -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>`:
@@ -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. |

View File

@@ -29,7 +29,7 @@ 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.
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.
@@ -41,7 +41,7 @@ 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
@@ -76,6 +76,43 @@ 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:

View File

@@ -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"`
@@ -14,18 +14,29 @@ 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`, 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.
- 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.
- 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.
- 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
@@ -37,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
@@ -55,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, plus `SHELL`, which only picks the form of the PATH hint. 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
@@ -83,10 +106,10 @@ The script reads three environment variables, plus `SHELL`, which only picks the
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.

View File

@@ -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,19 +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
@@ -39,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' \
@@ -48,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() {
@@ -132,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
@@ -145,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"
@@ -154,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
@@ -162,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"
@@ -172,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.
@@ -219,6 +272,280 @@ dq_escape() {
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
@@ -227,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
@@ -266,26 +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
# 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
path_hint "$gobin"
}
main "$@"

View File

@@ -6,6 +6,7 @@ import (
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
"testing"
)
@@ -16,16 +17,17 @@ 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
@@ -176,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"
@@ -226,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 {
@@ -237,7 +276,7 @@ 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,
@@ -258,13 +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 := 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" "$@"`, h.script}, args...)...)
cmd = exec.Command(h.bash, append([]string{"-c", `unset SHELL; . "$0" "$@"`, file}, args...)...)
}
cmd.Dir = h.work
cmd.Dir = dir
cmd.Env = env
cmd.Stdin = strings.NewReader(stdinProbe)
var stdout, stderr bytes.Buffer
@@ -411,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
@@ -710,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) < 32 {
if len(cases) < 67 {
t.Fatalf("only %d cases", len(cases))
}
for _, tc := range cases {

View File

@@ -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)
}

View File

@@ -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,8 +254,12 @@ 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()
@@ -208,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 {

View File

@@ -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