From cfd110eddb2af449aae0a2a420008c4368dfd9c9 Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Thu, 8 Oct 2026 11:51:39 +0200 Subject: [PATCH] feat(14.3-02): add the sunrise bootstrap script module - modules/sunrise/new.sh: curl | bash installer wrapped in main(), checks Go 1.27+/git/Docker, clones or fast-forwards the framework at the newest release tag (SUMMERCMS_REPO/DIR/REF overrides), installs summer and runs summer new next to the checkout, prints the cd line and a PATH hint - sunrise.Script() returns a fresh copy of the embedded script - smoke, shape, truncation and gated end-to-end tests; README and modules row --- README.md | 1 + modules/sunrise/README.md | 91 +++++++++++ modules/sunrise/new.sh | 257 ++++++++++++++++++++++++++++++++ modules/sunrise/sunrise.go | 23 +++ modules/sunrise/sunrise_test.go | 216 +++++++++++++++++++++++++++ 5 files changed, 588 insertions(+) create mode 100644 modules/sunrise/README.md create mode 100755 modules/sunrise/new.sh create mode 100644 modules/sunrise/sunrise.go create mode 100644 modules/sunrise/sunrise_test.go diff --git a/README.md b/README.md index 5aee89d..a4664fd 100644 --- a/README.md +++ b/README.md @@ -90,6 +90,7 @@ The `migrate`, `migrate:status`, `migrate:rollback`, `serve` and admin commands | [party](modules/party/README.md) | Compiled plugin registry that orders plugins by their dependencies and runs their Register and Boot lifecycle. | | [phrasebook](modules/phrasebook/README.md) | Namespaced translation catalogs loaded from plugin YAML, with locale fallback, placeholder interpolation and CLDR pluralization. | | [postcard](modules/postcard/README.md) | Transactional mail from plugin-owned Markdown templates and layouts, delivered through a memory, log or SMTP driver. | +| [sunrise](modules/sunrise/README.md) | Bootstrap script that installs the summer CLI and creates a new SummerCMS application in one command, embedded so it is versioned with the framework. | | [sunscreen](modules/sunscreen/README.md) | Credential-redacting slog handler that keeps API keys, tokens and passwords out of application logs. | | [surf](modules/surf/README.md) | HTTP routing for SummerCMS: collects plugin routes and named middleware into a `net/http` ServeMux with constraints, rate limiting, body limits, CORS and panic recovery, and provides the `serve` and `route:list` commands. | | [tide](modules/tide/README.md) | HTTP parity toolkit that records request and response fixtures from a reference backend, replays them against a new one and reports normalized differences. | diff --git a/modules/sunrise/README.md b/modules/sunrise/README.md new file mode 100644 index 0000000..c88c90f --- /dev/null +++ b/modules/sunrise/README.md @@ -0,0 +1,91 @@ +# sunrise + +Bootstrap script that installs the summer CLI and creates a new SummerCMS application in one command, embedded so it is versioned with the framework. + +`import "git.golem15.com/golem15/summercms/modules/sunrise"` + +## Overview + +`sunrise` holds `new.sh`, the script behind the one-liner served at https://summercms.io/new: + +```sh +curl -fsSL https://summercms.io/new | bash -s -- blog +``` + +The script checks for Go 1.27 or newer, git and Docker, clones the framework into `./summercms` at its newest release tag (or moves an existing release checkout forward to it), installs the `summer` CLI with `go install ./cmd/summer` and runs `summer new blog --framework ` next to the checkout, which writes `./sm-blog-app`. `summer new` prints the next steps; the script adds the `cd` line and, when the Go install directory is not on `PATH`, an `export PATH=...` hint. + +The script lives inside this package so `//go:embed` can reach it, and a web server serves the bytes of the framework release it was built against. `sunrise.Script` returns them. + +## Features + +- Truncation-safe: everything is a function definition and the last line is `main "$@"`, so a download cut short runs nothing. +- Prerequisites first: Go older than 1.27 or missing, or a missing git, stops the script with an install hint before anything is cloned; a missing Docker or compose plugin is only a warning. +- Release tags: a fresh clone is checked out detached at the newest `vX.Y.Z` tag, or at `SUMMERCMS_REF` when set. +- Fast-forward-only updates: an existing checkout is fetched (tags are never force-updated) and moved only when its `HEAD` is an ancestor of the target. +- Refusals that protect local work: an existing directory that is not a git repository, whose `go.mod` is not the framework module, that has changes to tracked files, or that is on a branch is left untouched and the script stops with a message. +- Early name check: a name that is not lower-case letters and digits starting with a letter, or an existing `sm--app`, stops the script before any clone or install. +- No privilege escalation and no dynamic code; only bash builtins plus `git`, `go` and `docker` are run, with stdin closed and `GIT_TERMINAL_PROMPT=0` so nothing waits for input. + +## Usage + +Run the script from the directory that should hold the framework checkout and the application: + +```sh +curl -fsSL https://summercms.io/new | bash -s -- blog +``` + +`bash -s -- ` passes the application name to the piped script; `-h` or `--help` prints the usage. A local copy runs the same way with `bash new.sh blog`. + +Serving the script from a Go handler: + +```go +package acme + +import ( + "net/http" + + "git.golem15.com/golem15/summercms/modules/sunrise" +) + +func newScript(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + w.Header().Set("X-Content-Type-Options", "nosniff") + _, _ = w.Write(sunrise.Script()) +} +``` + +## API reference + +| Identifier | Description | +|------------|-------------| +| `sunrise.Script` | Returns a fresh copy of the embedded `new.sh` bytes on every call. | + +## Configuration + +The script reads three environment variables. It never reads or sets `SUMMER_`-prefixed variables, which the framework reads as configuration. + +| Variable | Default | Purpose | +|----------|---------|---------| +| `SUMMERCMS_REPO` | `https://git.golem15.com/golem15/summercms` | Repository to clone. | +| `SUMMERCMS_DIR` | `summercms` (relative to the current directory) | Framework checkout directory; the application is created next to it. | +| `SUMMERCMS_REF` | newest `vX.Y.Z` tag | Tag or commit to check out instead of the newest release. | + +## Dependencies + +- SummerCMS modules: none. +- Third-party: none. +- Standard library: `bytes`, `embed`. + +## Testing + +```sh +go test ./modules/sunrise/... +``` + +The default run checks the embedded bytes, the script's shape and syntax (`bash -n`), and runs every line-boundary prefix and every byte prefix of the final line against stub commands to prove a truncated download invokes nothing. + +```sh +SUMMERCMS_BOOTSTRAP_CHECK=1 go test ./modules/sunrise/ -run TestBootstrapEndToEnd -timeout 20m +``` + +The gated run serves the script from a local HTTP server, pipes it through `curl | bash` with `SUMMERCMS_REPO` set to this repository and `SUMMERCMS_REF` to its `HEAD`, and builds the generated application. It needs bash, curl, git, Go and access to the Go module proxy. diff --git a/modules/sunrise/new.sh b/modules/sunrise/new.sh new file mode 100755 index 0000000..450a9f0 --- /dev/null +++ b/modules/sunrise/new.sh @@ -0,0 +1,257 @@ +#!/usr/bin/env bash +# SummerCMS bootstrap: create a new SummerCMS application in one command. +# +# curl -fsSL https://summercms.io/new | bash -s -- +# +# The script checks for Go 1.27 or newer, git and (optionally) Docker, clones +# the framework into ./summercms at its newest release tag (or moves an +# existing release checkout forward to it), installs the summer CLI with +# `go install` and runs `summer new ` next to the checkout, which +# writes ./sm--app. +# +# Environment overrides: +# SUMMERCMS_REPO framework repository to clone +# (default https://git.golem15.com/golem15/summercms) +# SUMMERCMS_DIR framework checkout directory (default ./summercms); the +# application is created next to it +# SUMMERCMS_REF tag or commit to check out instead of the newest release +# +# Everything below is a function definition and the last line calls main, so +# a download cut short runs nothing. Only bash builtins and the git, go and +# docker commands are used. + +say() { + printf '==> %s\n' "$*" >&2 +} + +warn() { + printf 'warning: %s\n' "$*" >&2 +} + +die() { + printf 'error: %s\n' "$*" >&2 + exit 1 +} + +usage() { + printf '%s\n' \ + 'Usage: curl -fsSL https://summercms.io/new | bash -s -- ' \ + ' bash new.sh ' \ + '' \ + 'Creates sm--app next to a SummerCMS framework checkout. is' \ + 'lower-case letters and digits, starting with a letter.' \ + '' \ + 'Steps: check Go 1.27+, git and Docker; clone the framework (or move an' \ + 'existing release checkout forward) at its newest release tag; install' \ + 'the summer CLI with go install; run summer new .' \ + '' \ + 'Environment:' \ + ' SUMMERCMS_REPO repository to clone (default https://git.golem15.com/golem15/summercms)' \ + ' SUMMERCMS_DIR framework checkout directory (default ./summercms)' \ + ' SUMMERCMS_REF tag or commit to check out instead of the newest release' +} + +# abspath prints $1 as an absolute path against $PWD with ".", ".." and +# repeated slashes removed lexically. +abspath() { + local p="$1" out="" seg + local -a parts + [[ $p == /* ]] || p="$PWD/$p" + IFS=/ read -r -a parts <<<"$p" + for seg in "${parts[@]}"; do + case "$seg" in + '' | .) ;; + ..) out="${out%/*}" ;; + *) out="$out/$seg" ;; + esac + done + printf '%s\n' "${out:-/}" +} + +# check_go requires go 1.27 or newer. +check_go() { + command -v go >/dev/null 2>&1 || + die "Go is not installed. SummerCMS needs Go 1.27 or newer: https://go.dev/dl/" + local v + # Read the version from / so a go.mod in the current directory cannot + # make the go command switch toolchains for the check. + v="$(cd / && go env GOVERSION /dev/null)" || v="" + if [[ $v =~ ^go([0-9]+)\.([0-9]+) ]]; then + local major="${BASH_REMATCH[1]}" minor="${BASH_REMATCH[2]}" + if ((major < 1 || (major == 1 && minor < 27))); then + die "SummerCMS needs Go 1.27 or newer, found $v. Install a newer Go from https://go.dev/dl/" + fi + else + warn "could not read the Go version (go env GOVERSION printed '${v}'); SummerCMS needs Go 1.27 or newer" + fi +} + +# check_docker only warns: the application builds without Docker, the +# generated compose.yaml just needs it for the development database. +check_docker() { + if ! command -v docker >/dev/null 2>&1; then + warn "Docker is not installed; the generated compose.yaml needs it to run PostgreSQL (https://docs.docker.com/engine/install/)" + elif ! docker compose version /dev/null 2>&1; then + warn "the Docker compose plugin is missing; the generated compose.yaml needs 'docker compose'" + fi +} + +# latest_tag prints the newest vX.Y.Z tag of the checkout in $1. +latest_tag() { + local dir="$1" tags tag + # Capture the whole list first: piping into head under pipefail can + # fail with SIGPIPE. + tags="$(git -C "$dir" tag -l 'v*' --sort=-v:refname /dev/null)" || + die "$dir is not a git repository; $hint" + real="$(cd "$dir" && pwd -P)" + [[ $top == "$real" ]] || die "$dir is not the root of a git repository; $hint" + status="$(git -C "$dir" status --porcelain --untracked-files=no &2 + exit 2 + fi + local name="$1" + if [[ ! $name =~ ^[a-z][a-z0-9]*$ ]]; then + printf 'error: invalid application name %q: use lower-case letters and digits, starting with a letter\n\n' "$name" >&2 + usage >&2 + exit 2 + fi + + local repo="${SUMMERCMS_REPO:-https://git.golem15.com/golem15/summercms}" + local ref="${SUMMERCMS_REF:-}" + local dir parent app + dir="$(abspath "${SUMMERCMS_DIR:-summercms}")" + [[ $dir != / ]] || die "SUMMERCMS_DIR must not be /" + parent="${dir%/*}" + parent="${parent:-/}" + app="${parent%/}/sm-$name-app" + [[ -d $parent ]] || die "$parent does not exist" + if [[ -e $app || -L $app ]]; then + die "$app already exists; choose another name or remove it" + fi + + command -v git >/dev/null 2>&1 || die "git is not installed; install git and run again" + check_go + check_docker + + checkout_framework "$repo" "$dir" "$ref" + + local gobin + gobin="$(go_bin_dir)" || die "could not determine the Go install directory (go env GOBIN / GOPATH)" + say "installing summer into $gobin" + GOWORK=off go -C "$dir" install ./cmd/summer Done. Start with:\n cd %s\n' "$app" + if [[ :${PATH:-}: != *":$gobin:"* ]]; then + printf '\n==> summer is installed in %s, which is not on your PATH. Run this (and add it to your shell profile):\n' "$gobin" + # $PATH stays literal: the user's shell expands it. + # shellcheck disable=SC2016 + printf ' export PATH="%s:$PATH"\n' "$gobin" + fi +} + +main "$@" diff --git a/modules/sunrise/sunrise.go b/modules/sunrise/sunrise.go new file mode 100644 index 0000000..35c506f --- /dev/null +++ b/modules/sunrise/sunrise.go @@ -0,0 +1,23 @@ +// Package sunrise embeds the SummerCMS bootstrap script, new.sh, so the +// script is versioned with the framework release that ships it. +// +// The script is what `curl -fsSL https://summercms.io/new | bash -s -- ` +// runs: it checks for Go 1.27 or newer, git and Docker, clones the framework +// at its newest release tag (or moves an existing release checkout forward), +// installs the summer CLI and runs `summer new ` next to the checkout. +// A web server hands out Script() as text/plain. +package sunrise + +import ( + "bytes" + _ "embed" +) + +//go:embed new.sh +var script []byte + +// Script returns the bootstrap script. Each call returns a fresh copy, so a +// caller may modify the slice without affecting later calls. +func Script() []byte { + return bytes.Clone(script) +} diff --git a/modules/sunrise/sunrise_test.go b/modules/sunrise/sunrise_test.go new file mode 100644 index 0000000..9fef821 --- /dev/null +++ b/modules/sunrise/sunrise_test.go @@ -0,0 +1,216 @@ +package sunrise + +import ( + "bytes" + "context" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestScriptEmbedded(t *testing.T) { + want, err := os.ReadFile("new.sh") + if err != nil { + t.Fatal(err) + } + got := Script() + if !bytes.Equal(got, want) { + t.Fatal("Script() differs from new.sh") + } + for i := range got { + got[i] = 'x' + } + if !bytes.Equal(Script(), want) { + t.Fatal("modifying a returned slice changed the next Script() call") + } +} + +func TestScriptShape(t *testing.T) { + s := string(Script()) + lines := strings.Split(strings.TrimRight(s, "\n"), "\n") + if lines[0] != "#!/usr/bin/env bash" { + t.Fatalf("first line = %q, want the bash shebang", lines[0]) + } + if last := lines[len(lines)-1]; last != `main "$@"` { + t.Fatalf("last non-empty line = %q, want %q", last, `main "$@"`) + } + if strings.Count(s, "\nmain \"$@\"") != 1 { + t.Fatal("main must be called exactly once, on the last line") + } + bash := lookBash(t) + if out, err := exec.Command(bash, "-n", "new.sh").CombinedOutput(); err != nil { + t.Fatalf("bash -n new.sh: %v\n%s", err, out) + } +} + +// TestScriptTruncationRunsNothing runs every prefix of the script that ends +// on a line boundary and every byte prefix of its final line, as a cut-short +// download would arrive, and asserts none of them invokes a command or +// creates a file. A positive control proves the stubs do record calls. +func TestScriptTruncationRunsNothing(t *testing.T) { + bash := lookBash(t) + stubs := t.TempDir() + marker := filepath.Join(t.TempDir(), "invoked") + for _, name := range []string{"git", "go", "docker", "curl", "summer"} { + body := "#!/bin/sh\necho \"" + name + " $*\" >>'" + marker + "'\n" + if err := os.WriteFile(filepath.Join(stubs, name), []byte(body), 0o755); err != nil { + t.Fatal(err) + } + } + home := t.TempDir() + scripts := t.TempDir() + run := func(t *testing.T, prefix []byte) (work string) { + t.Helper() + file := filepath.Join(scripts, "new.sh") + if err := os.WriteFile(file, prefix, 0o644); err != nil { + t.Fatal(err) + } + work = t.TempDir() + cmd := exec.Command(bash, file, "demo") + cmd.Dir = work + cmd.Env = []string{"PATH=" + stubs, "HOME=" + home, "PWD=" + work, "LC_ALL=C"} + _ = cmd.Run() + return work + } + + s := Script() + body := bytes.TrimRight(s, "\n") + lastStart := bytes.LastIndexByte(body, '\n') + 1 + var prefixes []int + for i, c := range s[:lastStart] { + if c == '\n' { + prefixes = append(prefixes, i+1) + } + } + prefixes = append([]int{0}, prefixes...) + for k := 1; k < len(body)-lastStart; k++ { + prefixes = append(prefixes, lastStart+k) + } + for _, n := range prefixes { + work := run(t, s[:n]) + if out, err := os.ReadFile(marker); err == nil { + t.Fatalf("prefix of %d bytes (ending %q) invoked: %s", n, tail(s[:n]), out) + } + if entries, err := os.ReadDir(work); err != nil || len(entries) != 0 { + t.Fatalf("prefix of %d bytes (ending %q) changed the working directory: %v %v", n, tail(s[:n]), entries, err) + } + } + if len(prefixes) < 50 { + t.Fatalf("only %d prefixes tested", len(prefixes)) + } + + run(t, s) + if _, err := os.Stat(marker); err != nil { + t.Fatalf("the complete script did not reach a stub, so the harness proves nothing: %v", err) + } +} + +func tail(b []byte) string { + if len(b) > 30 { + b = b[len(b)-30:] + } + return string(b) +} + +func lookBash(t *testing.T) string { + t.Helper() + bash, err := exec.LookPath("bash") + if err != nil { + t.Skip("bash not installed") + } + return bash +} + +// TestBootstrapEndToEnd serves Script() over HTTP, pipes it into bash with +// curl the way the one-liner does, clones this repository at HEAD, installs +// summer into a GOBIN that is not on PATH and builds the generated app. It +// needs network access for Go modules and runs only with +// SUMMERCMS_BOOTSTRAP_CHECK=1. +func TestBootstrapEndToEnd(t *testing.T) { + if os.Getenv("SUMMERCMS_BOOTSTRAP_CHECK") != "1" { + t.Skip("set SUMMERCMS_BOOTSTRAP_CHECK=1 to run the bootstrap script end to end") + } + for _, tool := range []string{"bash", "curl", "git", "go"} { + if _, err := exec.LookPath(tool); err != nil { + t.Fatalf("%s is required: %v", tool, err) + } + } + root, err := filepath.Abs(filepath.Join("..", "..")) + if err != nil { + t.Fatal(err) + } + head := gitOut(t, root, "rev-parse", "HEAD") + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + _, _ = w.Write(Script()) + })) + defer srv.Close() + + work := t.TempDir() + gobin := t.TempDir() + env := []string{"SUMMERCMS_REPO=" + root, "SUMMERCMS_REF=" + head, "GOBIN=" + gobin} + for _, kv := range os.Environ() { + key, _, _ := strings.Cut(kv, "=") + switch { + case strings.HasPrefix(key, "SUMMER_"), strings.HasPrefix(key, "SUMMERCMS_"), + key == "GOWORK", key == "GOBIN", key == "PWD", key == "GOFLAGS": + continue + } + env = append(env, kv) + } + + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute) + defer cancel() + cmd := exec.CommandContext(ctx, "bash", "-c", `set -o pipefail; curl -fsSL "$1" | bash -s -- demo`, "bash", srv.URL+"/new") + cmd.Dir = work + cmd.Env = append(env[:len(env):len(env)], "PWD="+work) + out, err := cmd.CombinedOutput() + t.Logf("bootstrap output:\n%s", out) + if err != nil { + t.Fatalf("curl | bash: %v", err) + } + + checkout := filepath.Join(work, "summercms") + if got := gitOut(t, checkout, "rev-parse", "HEAD"); got != head { + t.Fatalf("checkout HEAD = %s, want %s", got, head) + } + if err := exec.Command("git", "-C", checkout, "symbolic-ref", "-q", "HEAD").Run(); err == nil { + t.Fatal("checkout is on a branch, want a detached HEAD") + } + app := filepath.Join(work, "sm-demo-app") + gomod, err := os.ReadFile(filepath.Join(app, "go.mod")) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(gomod), "replace git.golem15.com/golem15/summercms => ../summercms\n") { + t.Fatalf("go.mod lacks the relative framework replace:\n%s", gomod) + } + if !strings.Contains(string(out), " cd "+app+"\n") { + t.Fatalf("output lacks the cd line for %s", app) + } + if !strings.Contains(string(out), `export PATH="`+gobin+`:$PATH"`) { + t.Fatalf("output lacks the PATH hint for %s", gobin) + } + + b := exec.CommandContext(ctx, filepath.Join(gobin, "summer"), "build") + b.Dir = app + b.Env = env + if out, err := b.CombinedOutput(); err != nil { + t.Fatalf("summer build in the generated app: %v\n%s", err, out) + } +} + +func gitOut(t *testing.T, dir string, args ...string) string { + t.Helper() + out, err := exec.Command("git", append([]string{"-C", dir}, args...)...).Output() + if err != nil { + t.Fatalf("git %v in %s: %v", args, dir, err) + } + return strings.TrimSpace(string(out)) +}