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
This commit is contained in:
91
modules/sunrise/README.md
Normal file
91
modules/sunrise/README.md
Normal file
@@ -0,0 +1,91 @@
|
||||
# sunrise
|
||||
|
||||
Bootstrap script that installs the summer CLI and creates a new SummerCMS application in one command, embedded so it is versioned with the framework.
|
||||
|
||||
`import "git.golem15.com/golem15/summercms/modules/sunrise"`
|
||||
|
||||
## Overview
|
||||
|
||||
`sunrise` holds `new.sh`, the script behind the one-liner served at https://summercms.io/new:
|
||||
|
||||
```sh
|
||||
curl -fsSL https://summercms.io/new | bash -s -- blog
|
||||
```
|
||||
|
||||
The script checks for Go 1.27 or newer, git and Docker, clones the framework into `./summercms` at its newest release tag (or moves an existing release checkout forward to it), installs the `summer` CLI with `go install ./cmd/summer` and runs `summer new blog --framework <checkout>` next to the checkout, which writes `./sm-blog-app`. `summer new` prints the next steps; the script adds the `cd` line and, when the Go install directory is not on `PATH`, an `export PATH=...` hint.
|
||||
|
||||
The script lives inside this package so `//go:embed` can reach it, and a web server serves the bytes of the framework release it was built against. `sunrise.Script` returns them.
|
||||
|
||||
## Features
|
||||
|
||||
- Truncation-safe: everything is a function definition and the last line is `main "$@"`, so a download cut short runs nothing.
|
||||
- Prerequisites first: Go older than 1.27 or missing, or a missing git, stops the script with an install hint before anything is cloned; a missing Docker or compose plugin is only a warning.
|
||||
- Release tags: a fresh clone is checked out detached at the newest `vX.Y.Z` tag, or at `SUMMERCMS_REF` when set.
|
||||
- Fast-forward-only updates: an existing checkout is fetched (tags are never force-updated) and moved only when its `HEAD` is an ancestor of the target.
|
||||
- Refusals that protect local work: an existing directory that is not a git repository, whose `go.mod` is not the framework module, that has changes to tracked files, or that is on a branch is left untouched and the script stops with a message.
|
||||
- Early name check: a name that is not lower-case letters and digits starting with a letter, or an existing `sm-<name>-app`, stops the script before any clone or install.
|
||||
- No privilege escalation and no dynamic code; only bash builtins plus `git`, `go` and `docker` are run, with stdin closed and `GIT_TERMINAL_PROMPT=0` so nothing waits for input.
|
||||
|
||||
## Usage
|
||||
|
||||
Run the script from the directory that should hold the framework checkout and the application:
|
||||
|
||||
```sh
|
||||
curl -fsSL https://summercms.io/new | bash -s -- blog
|
||||
```
|
||||
|
||||
`bash -s -- <name>` passes the application name to the piped script; `-h` or `--help` prints the usage. A local copy runs the same way with `bash new.sh blog`.
|
||||
|
||||
Serving the script from a Go handler:
|
||||
|
||||
```go
|
||||
package acme
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/sunrise"
|
||||
)
|
||||
|
||||
func newScript(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
_, _ = w.Write(sunrise.Script())
|
||||
}
|
||||
```
|
||||
|
||||
## API reference
|
||||
|
||||
| Identifier | Description |
|
||||
|------------|-------------|
|
||||
| `sunrise.Script` | Returns a fresh copy of the embedded `new.sh` bytes on every call. |
|
||||
|
||||
## Configuration
|
||||
|
||||
The script reads three environment variables. It never reads or sets `SUMMER_`-prefixed variables, which the framework reads as configuration.
|
||||
|
||||
| Variable | Default | Purpose |
|
||||
|----------|---------|---------|
|
||||
| `SUMMERCMS_REPO` | `https://git.golem15.com/golem15/summercms` | Repository to clone. |
|
||||
| `SUMMERCMS_DIR` | `summercms` (relative to the current directory) | Framework checkout directory; the application is created next to it. |
|
||||
| `SUMMERCMS_REF` | newest `vX.Y.Z` tag | Tag or commit to check out instead of the newest release. |
|
||||
|
||||
## Dependencies
|
||||
|
||||
- SummerCMS modules: none.
|
||||
- Third-party: none.
|
||||
- Standard library: `bytes`, `embed`.
|
||||
|
||||
## Testing
|
||||
|
||||
```sh
|
||||
go test ./modules/sunrise/...
|
||||
```
|
||||
|
||||
The default run checks the embedded bytes, the script's shape and syntax (`bash -n`), and runs every line-boundary prefix and every byte prefix of the final line against stub commands to prove a truncated download invokes nothing.
|
||||
|
||||
```sh
|
||||
SUMMERCMS_BOOTSTRAP_CHECK=1 go test ./modules/sunrise/ -run TestBootstrapEndToEnd -timeout 20m
|
||||
```
|
||||
|
||||
The gated run serves the script from a local HTTP server, pipes it through `curl | bash` with `SUMMERCMS_REPO` set to this repository and `SUMMERCMS_REF` to its `HEAD`, and builds the generated application. It needs bash, curl, git, Go and access to the Go module proxy.
|
||||
257
modules/sunrise/new.sh
Executable file
257
modules/sunrise/new.sh
Executable file
@@ -0,0 +1,257 @@
|
||||
#!/usr/bin/env bash
|
||||
# SummerCMS bootstrap: create a new SummerCMS application in one command.
|
||||
#
|
||||
# curl -fsSL https://summercms.io/new | bash -s -- <name>
|
||||
#
|
||||
# The script checks for Go 1.27 or newer, git and (optionally) Docker, clones
|
||||
# the framework into ./summercms at its newest release tag (or moves an
|
||||
# existing release checkout forward to it), installs the summer CLI with
|
||||
# `go install` and runs `summer new <name>` next to the checkout, which
|
||||
# writes ./sm-<name>-app.
|
||||
#
|
||||
# Environment overrides:
|
||||
# SUMMERCMS_REPO framework repository to clone
|
||||
# (default https://git.golem15.com/golem15/summercms)
|
||||
# SUMMERCMS_DIR framework checkout directory (default ./summercms); the
|
||||
# application is created next to it
|
||||
# SUMMERCMS_REF tag or commit to check out instead of the newest release
|
||||
#
|
||||
# Everything below is a function definition and the last line calls main, so
|
||||
# a download cut short runs nothing. Only bash builtins and the git, go and
|
||||
# docker commands are used.
|
||||
|
||||
say() {
|
||||
printf '==> %s\n' "$*" >&2
|
||||
}
|
||||
|
||||
warn() {
|
||||
printf 'warning: %s\n' "$*" >&2
|
||||
}
|
||||
|
||||
die() {
|
||||
printf 'error: %s\n' "$*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
usage() {
|
||||
printf '%s\n' \
|
||||
'Usage: curl -fsSL https://summercms.io/new | bash -s -- <name>' \
|
||||
' bash new.sh <name>' \
|
||||
'' \
|
||||
'Creates sm-<name>-app next to a SummerCMS framework checkout. <name> is' \
|
||||
'lower-case letters and digits, starting with a letter.' \
|
||||
'' \
|
||||
'Steps: check Go 1.27+, git and Docker; clone the framework (or move an' \
|
||||
'existing release checkout forward) at its newest release tag; install' \
|
||||
'the summer CLI with go install; run summer new <name>.' \
|
||||
'' \
|
||||
'Environment:' \
|
||||
' SUMMERCMS_REPO repository to clone (default https://git.golem15.com/golem15/summercms)' \
|
||||
' SUMMERCMS_DIR framework checkout directory (default ./summercms)' \
|
||||
' SUMMERCMS_REF tag or commit to check out instead of the newest release'
|
||||
}
|
||||
|
||||
# abspath prints $1 as an absolute path against $PWD with ".", ".." and
|
||||
# repeated slashes removed lexically.
|
||||
abspath() {
|
||||
local p="$1" out="" seg
|
||||
local -a parts
|
||||
[[ $p == /* ]] || p="$PWD/$p"
|
||||
IFS=/ read -r -a parts <<<"$p"
|
||||
for seg in "${parts[@]}"; do
|
||||
case "$seg" in
|
||||
'' | .) ;;
|
||||
..) out="${out%/*}" ;;
|
||||
*) out="$out/$seg" ;;
|
||||
esac
|
||||
done
|
||||
printf '%s\n' "${out:-/}"
|
||||
}
|
||||
|
||||
# check_go requires go 1.27 or newer.
|
||||
check_go() {
|
||||
command -v go >/dev/null 2>&1 ||
|
||||
die "Go is not installed. SummerCMS needs Go 1.27 or newer: https://go.dev/dl/"
|
||||
local v
|
||||
# Read the version from / so a go.mod in the current directory cannot
|
||||
# make the go command switch toolchains for the check.
|
||||
v="$(cd / && go env GOVERSION </dev/null 2>/dev/null)" || v=""
|
||||
if [[ $v =~ ^go([0-9]+)\.([0-9]+) ]]; then
|
||||
local major="${BASH_REMATCH[1]}" minor="${BASH_REMATCH[2]}"
|
||||
if ((major < 1 || (major == 1 && minor < 27))); then
|
||||
die "SummerCMS needs Go 1.27 or newer, found $v. Install a newer Go from https://go.dev/dl/"
|
||||
fi
|
||||
else
|
||||
warn "could not read the Go version (go env GOVERSION printed '${v}'); SummerCMS needs Go 1.27 or newer"
|
||||
fi
|
||||
}
|
||||
|
||||
# check_docker only warns: the application builds without Docker, the
|
||||
# generated compose.yaml just needs it for the development database.
|
||||
check_docker() {
|
||||
if ! command -v docker >/dev/null 2>&1; then
|
||||
warn "Docker is not installed; the generated compose.yaml needs it to run PostgreSQL (https://docs.docker.com/engine/install/)"
|
||||
elif ! docker compose version </dev/null >/dev/null 2>&1; then
|
||||
warn "the Docker compose plugin is missing; the generated compose.yaml needs 'docker compose'"
|
||||
fi
|
||||
}
|
||||
|
||||
# latest_tag prints the newest vX.Y.Z tag of the checkout in $1.
|
||||
latest_tag() {
|
||||
local dir="$1" tags tag
|
||||
# Capture the whole list first: piping into head under pipefail can
|
||||
# fail with SIGPIPE.
|
||||
tags="$(git -C "$dir" tag -l 'v*' --sort=-v:refname </dev/null)" || return 1
|
||||
while IFS= read -r tag; do
|
||||
if [[ $tag =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
printf '%s\n' "$tag"
|
||||
return 0
|
||||
fi
|
||||
done <<<"$tags"
|
||||
return 1
|
||||
}
|
||||
|
||||
# target_commit prints the commit to check out in $1: SUMMERCMS_REF ($2)
|
||||
# when set, otherwise the newest release tag.
|
||||
target_commit() {
|
||||
local dir="$1" ref="$2" label commit
|
||||
if [[ -n $ref ]]; then
|
||||
[[ $ref != -* ]] || die "SUMMERCMS_REF must be a tag or commit, not '$ref'"
|
||||
label="$ref"
|
||||
else
|
||||
label="$(latest_tag "$dir")" ||
|
||||
die "no release tag (vX.Y.Z) found in $dir; set SUMMERCMS_REF to a tag or commit"
|
||||
ref="refs/tags/$label"
|
||||
fi
|
||||
commit="$(git -C "$dir" rev-parse --verify --quiet "${ref}^{commit}" </dev/null)" ||
|
||||
die "$label is not a tag or commit in $dir"
|
||||
say "framework target: $label (${commit:0:12})"
|
||||
printf '%s\n' "$commit"
|
||||
}
|
||||
|
||||
# check_checkout refuses an existing directory that is not a clean,
|
||||
# detached SummerCMS framework checkout. It never changes the directory.
|
||||
check_checkout() {
|
||||
local dir="$1" hint top real line module="" status branch
|
||||
hint="use SUMMERCMS_DIR for a separate clone, or run 'summer new --framework <checkout>' yourself"
|
||||
[[ -d $dir ]] || die "$dir exists and is not a directory; $hint"
|
||||
[[ -f $dir/go.mod ]] || die "$dir is not a SummerCMS framework checkout (no go.mod); $hint"
|
||||
while IFS= read -r line || [[ -n $line ]]; do
|
||||
if [[ $line =~ ^[[:space:]]*module[[:space:]]+\"?([^[:space:]\"]+) ]]; then
|
||||
module="${BASH_REMATCH[1]}"
|
||||
break
|
||||
fi
|
||||
done <"$dir/go.mod"
|
||||
[[ $module == git.golem15.com/golem15/summercms ]] ||
|
||||
die "$dir is not a SummerCMS framework checkout (go.mod module is '${module}'); $hint"
|
||||
top="$(git -C "$dir" rev-parse --show-toplevel </dev/null 2>/dev/null)" ||
|
||||
die "$dir is not a git repository; $hint"
|
||||
real="$(cd "$dir" && pwd -P)"
|
||||
[[ $top == "$real" ]] || die "$dir is not the root of a git repository; $hint"
|
||||
status="$(git -C "$dir" status --porcelain --untracked-files=no </dev/null)" ||
|
||||
die "git status failed in $dir"
|
||||
[[ -z $status ]] || die "$dir has uncommitted changes to tracked files; commit or stash them, or $hint"
|
||||
if branch="$(git -C "$dir" symbolic-ref -q --short HEAD </dev/null)"; then
|
||||
die "$dir is on branch $branch; this installer only moves a detached release checkout. To keep your branch, $hint"
|
||||
fi
|
||||
}
|
||||
|
||||
# checkout_framework clones the framework into $2 or moves an existing
|
||||
# detached checkout forward, fast-forward only.
|
||||
checkout_framework() {
|
||||
local repo="$1" dir="$2" ref="$3" target head
|
||||
if [[ ! -e $dir && ! -L $dir ]]; then
|
||||
say "cloning $repo into $dir"
|
||||
git clone --quiet -- "$repo" "$dir" </dev/null || die "git clone of $repo failed"
|
||||
target="$(target_commit "$dir" "$ref")" ||
|
||||
die "the new clone in $dir has no usable target; remove $dir and run again"
|
||||
git -C "$dir" -c advice.detachedHead=false checkout --quiet --detach "$target" </dev/null
|
||||
return 0
|
||||
fi
|
||||
check_checkout "$dir"
|
||||
say "updating the framework checkout in $dir"
|
||||
# No --force: a tag that moved upstream is never overwritten locally,
|
||||
# git refuses and the script stops.
|
||||
git -C "$dir" fetch --quiet --tags origin </dev/null ||
|
||||
die "git fetch in $dir failed (a release tag may have moved upstream); the checkout was not changed"
|
||||
target="$(target_commit "$dir" "$ref")" || exit 1
|
||||
head="$(git -C "$dir" rev-parse HEAD </dev/null)"
|
||||
if [[ $head == "$target" ]]; then
|
||||
say "framework checkout is already at the target"
|
||||
elif git -C "$dir" merge-base --is-ancestor "$head" "$target" </dev/null; then
|
||||
git -C "$dir" -c advice.detachedHead=false checkout --quiet --detach "$target" </dev/null
|
||||
else
|
||||
die "moving $dir from ${head:0:12} to ${target:0:12} is not a fast-forward; the checkout was not changed. $dir must be an ancestor of the target; use SUMMERCMS_DIR for a fresh clone"
|
||||
fi
|
||||
}
|
||||
|
||||
# go_bin_dir prints the directory go install writes to: GOBIN, else the
|
||||
# first GOPATH entry plus /bin.
|
||||
go_bin_dir() {
|
||||
local gobin gopath
|
||||
gobin="$(cd / && go env GOBIN </dev/null)" || return 1
|
||||
if [[ -z $gobin ]]; then
|
||||
gopath="$(cd / && go env GOPATH </dev/null)" || return 1
|
||||
gopath="${gopath%%:*}"
|
||||
[[ -n $gopath ]] || return 1
|
||||
gobin="$gopath/bin"
|
||||
fi
|
||||
printf '%s\n' "$gobin"
|
||||
}
|
||||
|
||||
main() {
|
||||
set -euo pipefail
|
||||
export GIT_TERMINAL_PROMPT=0
|
||||
|
||||
if (($# == 1)) && [[ $1 == -h || $1 == --help ]]; then
|
||||
usage
|
||||
exit 0
|
||||
fi
|
||||
if (($# != 1)); then
|
||||
usage >&2
|
||||
exit 2
|
||||
fi
|
||||
local name="$1"
|
||||
if [[ ! $name =~ ^[a-z][a-z0-9]*$ ]]; then
|
||||
printf 'error: invalid application name %q: use lower-case letters and digits, starting with a letter\n\n' "$name" >&2
|
||||
usage >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
local repo="${SUMMERCMS_REPO:-https://git.golem15.com/golem15/summercms}"
|
||||
local ref="${SUMMERCMS_REF:-}"
|
||||
local dir parent app
|
||||
dir="$(abspath "${SUMMERCMS_DIR:-summercms}")"
|
||||
[[ $dir != / ]] || die "SUMMERCMS_DIR must not be /"
|
||||
parent="${dir%/*}"
|
||||
parent="${parent:-/}"
|
||||
app="${parent%/}/sm-$name-app"
|
||||
[[ -d $parent ]] || die "$parent does not exist"
|
||||
if [[ -e $app || -L $app ]]; then
|
||||
die "$app already exists; choose another name or remove it"
|
||||
fi
|
||||
|
||||
command -v git >/dev/null 2>&1 || die "git is not installed; install git and run again"
|
||||
check_go
|
||||
check_docker
|
||||
|
||||
checkout_framework "$repo" "$dir" "$ref"
|
||||
|
||||
local gobin
|
||||
gobin="$(go_bin_dir)" || die "could not determine the Go install directory (go env GOBIN / GOPATH)"
|
||||
say "installing summer into $gobin"
|
||||
GOWORK=off go -C "$dir" install ./cmd/summer </dev/null
|
||||
|
||||
say "creating $app"
|
||||
(cd "$parent" && "$gobin/summer" new "$name" --framework "$dir" </dev/null)
|
||||
|
||||
printf '\n==> Done. Start with:\n cd %s\n' "$app"
|
||||
if [[ :${PATH:-}: != *":$gobin:"* ]]; then
|
||||
printf '\n==> summer is installed in %s, which is not on your PATH. Run this (and add it to your shell profile):\n' "$gobin"
|
||||
# $PATH stays literal: the user's shell expands it.
|
||||
# shellcheck disable=SC2016
|
||||
printf ' export PATH="%s:$PATH"\n' "$gobin"
|
||||
fi
|
||||
}
|
||||
|
||||
main "$@"
|
||||
23
modules/sunrise/sunrise.go
Normal file
23
modules/sunrise/sunrise.go
Normal file
@@ -0,0 +1,23 @@
|
||||
// Package sunrise embeds the SummerCMS bootstrap script, new.sh, so the
|
||||
// script is versioned with the framework release that ships it.
|
||||
//
|
||||
// The script is what `curl -fsSL https://summercms.io/new | bash -s -- <name>`
|
||||
// runs: it checks for Go 1.27 or newer, git and Docker, clones the framework
|
||||
// at its newest release tag (or moves an existing release checkout forward),
|
||||
// installs the summer CLI and runs `summer new <name>` next to the checkout.
|
||||
// A web server hands out Script() as text/plain.
|
||||
package sunrise
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
_ "embed"
|
||||
)
|
||||
|
||||
//go:embed new.sh
|
||||
var script []byte
|
||||
|
||||
// Script returns the bootstrap script. Each call returns a fresh copy, so a
|
||||
// caller may modify the slice without affecting later calls.
|
||||
func Script() []byte {
|
||||
return bytes.Clone(script)
|
||||
}
|
||||
216
modules/sunrise/sunrise_test.go
Normal file
216
modules/sunrise/sunrise_test.go
Normal file
@@ -0,0 +1,216 @@
|
||||
package sunrise
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestScriptEmbedded(t *testing.T) {
|
||||
want, err := os.ReadFile("new.sh")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := Script()
|
||||
if !bytes.Equal(got, want) {
|
||||
t.Fatal("Script() differs from new.sh")
|
||||
}
|
||||
for i := range got {
|
||||
got[i] = 'x'
|
||||
}
|
||||
if !bytes.Equal(Script(), want) {
|
||||
t.Fatal("modifying a returned slice changed the next Script() call")
|
||||
}
|
||||
}
|
||||
|
||||
func TestScriptShape(t *testing.T) {
|
||||
s := string(Script())
|
||||
lines := strings.Split(strings.TrimRight(s, "\n"), "\n")
|
||||
if lines[0] != "#!/usr/bin/env bash" {
|
||||
t.Fatalf("first line = %q, want the bash shebang", lines[0])
|
||||
}
|
||||
if last := lines[len(lines)-1]; last != `main "$@"` {
|
||||
t.Fatalf("last non-empty line = %q, want %q", last, `main "$@"`)
|
||||
}
|
||||
if strings.Count(s, "\nmain \"$@\"") != 1 {
|
||||
t.Fatal("main must be called exactly once, on the last line")
|
||||
}
|
||||
bash := lookBash(t)
|
||||
if out, err := exec.Command(bash, "-n", "new.sh").CombinedOutput(); err != nil {
|
||||
t.Fatalf("bash -n new.sh: %v\n%s", err, out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestScriptTruncationRunsNothing runs every prefix of the script that ends
|
||||
// on a line boundary and every byte prefix of its final line, as a cut-short
|
||||
// download would arrive, and asserts none of them invokes a command or
|
||||
// creates a file. A positive control proves the stubs do record calls.
|
||||
func TestScriptTruncationRunsNothing(t *testing.T) {
|
||||
bash := lookBash(t)
|
||||
stubs := t.TempDir()
|
||||
marker := filepath.Join(t.TempDir(), "invoked")
|
||||
for _, name := range []string{"git", "go", "docker", "curl", "summer"} {
|
||||
body := "#!/bin/sh\necho \"" + name + " $*\" >>'" + marker + "'\n"
|
||||
if err := os.WriteFile(filepath.Join(stubs, name), []byte(body), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
home := t.TempDir()
|
||||
scripts := t.TempDir()
|
||||
run := func(t *testing.T, prefix []byte) (work string) {
|
||||
t.Helper()
|
||||
file := filepath.Join(scripts, "new.sh")
|
||||
if err := os.WriteFile(file, prefix, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
work = t.TempDir()
|
||||
cmd := exec.Command(bash, file, "demo")
|
||||
cmd.Dir = work
|
||||
cmd.Env = []string{"PATH=" + stubs, "HOME=" + home, "PWD=" + work, "LC_ALL=C"}
|
||||
_ = cmd.Run()
|
||||
return work
|
||||
}
|
||||
|
||||
s := Script()
|
||||
body := bytes.TrimRight(s, "\n")
|
||||
lastStart := bytes.LastIndexByte(body, '\n') + 1
|
||||
var prefixes []int
|
||||
for i, c := range s[:lastStart] {
|
||||
if c == '\n' {
|
||||
prefixes = append(prefixes, i+1)
|
||||
}
|
||||
}
|
||||
prefixes = append([]int{0}, prefixes...)
|
||||
for k := 1; k < len(body)-lastStart; k++ {
|
||||
prefixes = append(prefixes, lastStart+k)
|
||||
}
|
||||
for _, n := range prefixes {
|
||||
work := run(t, s[:n])
|
||||
if out, err := os.ReadFile(marker); err == nil {
|
||||
t.Fatalf("prefix of %d bytes (ending %q) invoked: %s", n, tail(s[:n]), out)
|
||||
}
|
||||
if entries, err := os.ReadDir(work); err != nil || len(entries) != 0 {
|
||||
t.Fatalf("prefix of %d bytes (ending %q) changed the working directory: %v %v", n, tail(s[:n]), entries, err)
|
||||
}
|
||||
}
|
||||
if len(prefixes) < 50 {
|
||||
t.Fatalf("only %d prefixes tested", len(prefixes))
|
||||
}
|
||||
|
||||
run(t, s)
|
||||
if _, err := os.Stat(marker); err != nil {
|
||||
t.Fatalf("the complete script did not reach a stub, so the harness proves nothing: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func tail(b []byte) string {
|
||||
if len(b) > 30 {
|
||||
b = b[len(b)-30:]
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func lookBash(t *testing.T) string {
|
||||
t.Helper()
|
||||
bash, err := exec.LookPath("bash")
|
||||
if err != nil {
|
||||
t.Skip("bash not installed")
|
||||
}
|
||||
return bash
|
||||
}
|
||||
|
||||
// TestBootstrapEndToEnd serves Script() over HTTP, pipes it into bash with
|
||||
// curl the way the one-liner does, clones this repository at HEAD, installs
|
||||
// summer into a GOBIN that is not on PATH and builds the generated app. It
|
||||
// needs network access for Go modules and runs only with
|
||||
// SUMMERCMS_BOOTSTRAP_CHECK=1.
|
||||
func TestBootstrapEndToEnd(t *testing.T) {
|
||||
if os.Getenv("SUMMERCMS_BOOTSTRAP_CHECK") != "1" {
|
||||
t.Skip("set SUMMERCMS_BOOTSTRAP_CHECK=1 to run the bootstrap script end to end")
|
||||
}
|
||||
for _, tool := range []string{"bash", "curl", "git", "go"} {
|
||||
if _, err := exec.LookPath(tool); err != nil {
|
||||
t.Fatalf("%s is required: %v", tool, err)
|
||||
}
|
||||
}
|
||||
root, err := filepath.Abs(filepath.Join("..", ".."))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
head := gitOut(t, root, "rev-parse", "HEAD")
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
_, _ = w.Write(Script())
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
work := t.TempDir()
|
||||
gobin := t.TempDir()
|
||||
env := []string{"SUMMERCMS_REPO=" + root, "SUMMERCMS_REF=" + head, "GOBIN=" + gobin}
|
||||
for _, kv := range os.Environ() {
|
||||
key, _, _ := strings.Cut(kv, "=")
|
||||
switch {
|
||||
case strings.HasPrefix(key, "SUMMER_"), strings.HasPrefix(key, "SUMMERCMS_"),
|
||||
key == "GOWORK", key == "GOBIN", key == "PWD", key == "GOFLAGS":
|
||||
continue
|
||||
}
|
||||
env = append(env, kv)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(ctx, "bash", "-c", `set -o pipefail; curl -fsSL "$1" | bash -s -- demo`, "bash", srv.URL+"/new")
|
||||
cmd.Dir = work
|
||||
cmd.Env = append(env[:len(env):len(env)], "PWD="+work)
|
||||
out, err := cmd.CombinedOutput()
|
||||
t.Logf("bootstrap output:\n%s", out)
|
||||
if err != nil {
|
||||
t.Fatalf("curl | bash: %v", err)
|
||||
}
|
||||
|
||||
checkout := filepath.Join(work, "summercms")
|
||||
if got := gitOut(t, checkout, "rev-parse", "HEAD"); got != head {
|
||||
t.Fatalf("checkout HEAD = %s, want %s", got, head)
|
||||
}
|
||||
if err := exec.Command("git", "-C", checkout, "symbolic-ref", "-q", "HEAD").Run(); err == nil {
|
||||
t.Fatal("checkout is on a branch, want a detached HEAD")
|
||||
}
|
||||
app := filepath.Join(work, "sm-demo-app")
|
||||
gomod, err := os.ReadFile(filepath.Join(app, "go.mod"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(gomod), "replace git.golem15.com/golem15/summercms => ../summercms\n") {
|
||||
t.Fatalf("go.mod lacks the relative framework replace:\n%s", gomod)
|
||||
}
|
||||
if !strings.Contains(string(out), " cd "+app+"\n") {
|
||||
t.Fatalf("output lacks the cd line for %s", app)
|
||||
}
|
||||
if !strings.Contains(string(out), `export PATH="`+gobin+`:$PATH"`) {
|
||||
t.Fatalf("output lacks the PATH hint for %s", gobin)
|
||||
}
|
||||
|
||||
b := exec.CommandContext(ctx, filepath.Join(gobin, "summer"), "build")
|
||||
b.Dir = app
|
||||
b.Env = env
|
||||
if out, err := b.CombinedOutput(); err != nil {
|
||||
t.Fatalf("summer build in the generated app: %v\n%s", err, out)
|
||||
}
|
||||
}
|
||||
|
||||
func gitOut(t *testing.T, dir string, args ...string) string {
|
||||
t.Helper()
|
||||
out, err := exec.Command("git", append([]string{"-C", dir}, args...)...).Output()
|
||||
if err != nil {
|
||||
t.Fatalf("git %v in %s: %v", args, dir, err)
|
||||
}
|
||||
return strings.TrimSpace(string(out))
|
||||
}
|
||||
Reference in New Issue
Block a user