#!/usr/bin/env bash # SummerCMS bootstrap: create a new SummerCMS application in one command, or # update an existing one. # # curl -fsSL https://summercms.io/new | bash -s -- # cd sm--app && curl -fsSL https://summercms.io/update | bash # cd sm--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 # 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. # # 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); # used only when cloning # SUMMERCMS_DIR framework checkout directory (default ./summercms); the # 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, plus, in update mode, openssl (or head and # base64) to make the admin token secret and the installed summer. 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 -- ' \ ' cd sm--app && curl -fsSL https://summercms.io/update | bash' \ ' curl -fsSL https://summercms.io/new | bash -s -- --update' \ ' 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 .' \ '' \ '--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); 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() { 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 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)" || 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 && 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)" || 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--app directory: $app has no go.mod" [[ -f $app/summer.yaml ]] || die "run this from inside an sm--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 Done. Run summer migrate, then restart summer serve.\n' path_hint "$gobin" } main() { set -euo pipefail export GIT_TERMINAL_PROMPT=0 if (($# == 1)) && [[ $1 == -h || $1 == --help ]]; then 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 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" path_hint "$gobin" } main "$@" --update