#!/usr/bin/env bash # Phase 14.3 gate (one-command app scaffolding). # # --all is the authoritative, fail-closed phase gate. It runs each stage # below in its own process and prints the final gate marker only after # all of --unit, --generator, --bootstrap, --terminal, --acceptance and # --security pass. # # --unit runs go vet ./..., the full framework test suite (-count=1 -v, never # -short or -update) and requires a "--- PASS:" line for every test in # REQUIRED_TESTS; a skipped required test, "no tests to run" or a golden file # changed by the run fails the stage. It also runs the examples/hello tests, # docs:build --check, the site plugin and app tests, the landing page # generate and tests, and --forbidden. # # --forbidden scans for surfaces the phase must not add: a framework config # 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. # # --bootstrap runs the gated TestBootstrapEndToEnd; --terminal runs the # site's gated TestTerminalCommands against the committed new.sh and this # checkout at HEAD (SUMMERCMS_NEW_URL/REPO/REF overrides). Each requires its # named PASS. # # --security requires every threat T-14.3-01..26 and T-14.3-SC in # 14.3-SECURITY-REVIEW.md, no high row with status open, and a named test or # gate stage in the "Pinned by" column of every high row. # # --generator proves D-12 end to end: `summer new demo` run next to a # symlinked framework checkout, with no --framework flag and no git # identity, writes an app that builds, migrates against PostgreSQL 16 from # its own compose.yaml and serves the starter route with no edits. It ends # with "Phase 14.3 generator check passed". # # --acceptance proves D-20 before the release tag exists: in a bare # node:24-bookworm container it installs jq, the Docker CLI and compose # plugin from Docker's signed apt repository and Go 1.27.0 from go.dev # (sha256 checked), pipes the committed modules/sunrise/new.sh into # `bash -s -- demo` with SUMMERCMS_REPO= and SUMMERCMS_REF=HEAD, # then runs `docker compose up -d --wait && summer build && summer migrate && # summer serve` in sm-demo-app and requires the starter route. The container # drives the host Docker daemon through its socket, so it runs with # --network host under a unique compose project that is removed afterwards. # It ends with "Phase 14.3 acceptance passed". # # --acceptance-production runs the same chain with # `curl -fsSL https://summercms.io/new | bash -s -- demo` and no SUMMERCMS_ # overrides, after the release is tagged and the site redeployed. It ends # with "Phase 14.3 production acceptance passed". # # Needs Docker, the postgres:16 image (pulled when missing) and free host # ports 54320 and 8080. Every failure exits non-zero. # # Overrides: PHASE143_ROOT (framework checkout), PHASE143_KEEP=1 (keep the # temporary directory), PHASE143_TIMEOUT (seconds to wait for serve, 60). set -euo pipefail unset FORCE_COLOR ROOT="${PHASE143_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}" SERVE_TIMEOUT="${PHASE143_TIMEOUT:-60}" SITE="${PHASE143_SITE:-$ROOT/../sm-summercmsio-app}" PHASE_DIR="${PHASE143_PHASE_DIR:-$ROOT/.planning/phases/14.3-one-command-app-scaffolding-summer-new-and-summercms-io-new}" REVIEW="$PHASE_DIR/14.3-SECURITY-REVIEW.md" # Framework tests that must each print "--- PASS:" in the --unit run. REQUIRED_TESTS=( TestNewGolden TestNewGoldenNoPlugin TestNewAppSmoke TestNewNoPluginBuilds TestNewValidateAppName TestNewRefusesExistingTarget TestNewFrameworkResolution TestNewRefusesTargetInsideFramework TestNewCleansUpOnFailure TestNewRequiresValidAppKey TestNewEnvFileMode TestNewAgentsSymlink TestNewGitFirstCommit TestNewGitUsesConfiguredIdentity TestNewGitMissingIsWarning TestNewNoGit TestMakeAndAddPluginOnHelloCopy TestMakePluginRoutesUnchanged TestGenerateAppKey TestNewCommandViaCLI TestToolCommandNames TestScriptEmbedded TestScriptShape TestScriptTruncationRunsNothing TestScriptBehavior TestScriptShellcheck TestUpdateScript TestDocsTree TestDocsAIOutputsInSync ) # Site tests that pin GET /new and the landing page commands. SITE_PLUGIN_TESTS=(TestNewScriptRoute TestUpdateScriptRoute TestRoutesAssemble) SITE_APP_TESTS=(TestLoadTerminal TestTerminalScript TestTerminalEnv TestTerminalRunScript) fail() { echo "FAIL: $*" >&2 exit 1 } # require_named_pass LOG NAME...: the go test -v log must contain no # "no tests to run", no failure, no skip of a named test and a top-level # "--- PASS: NAME (" line for every name. require_named_pass() { local log="$1" name shift if grep -q 'no tests to run' "$log"; then fail "$log: a test pattern matched no tests" fi if grep -qE '^[[:space:]]*--- FAIL|^FAIL' "$log"; then grep -E '^[[:space:]]*--- FAIL|^FAIL' "$log" >&2 fail "$log: a test failed" fi for name in "$@"; do if grep -qE "^[[:space:]]*--- SKIP: ${name}( |/|\$)" "$log"; then fail "required test $name was skipped" fi grep -qE "^--- PASS: ${name} \(" "$log" || fail "required test $name has no --- PASS line" echo "PASS $name" done } port_free() { # bash /dev/tcp connects when something listens; free means it fails. if (exec 3<>"/dev/tcp/127.0.0.1/$1") 2>/dev/null; then return 1 fi return 0 } stage_generator() { command -v docker >/dev/null || fail "docker is not installed" docker info >/dev/null 2>&1 || fail "the Docker daemon is not reachable (docker info failed)" docker compose version >/dev/null 2>&1 || fail "docker compose is not available" command -v git >/dev/null || fail "git is not installed" command -v curl >/dev/null || fail "curl is not installed" port_free 54320 || fail "127.0.0.1:54320 is in use; the generated compose.yaml publishes PostgreSQL there" port_free 8080 || fail "127.0.0.1:8080 is in use; summer serve listens there" # Globals, not locals: the EXIT trap runs after this function returns. tmp="$(mktemp -d)" app="$tmp/parent/sm-demo-app" project="phase143-$$-$(date +%s)" server_pid="" cleanup() { local rc=$? if [ -n "$server_pid" ]; then kill "$server_pid" 2>/dev/null || true wait "$server_pid" 2>/dev/null || true fi if [ -f "$app/compose.yaml" ]; then (cd "$app" && COMPOSE_PROJECT_NAME="$project" docker compose down -v --remove-orphans >/dev/null 2>&1) || true fi if [ "${PHASE143_KEEP:-0}" = "1" ]; then echo "kept $tmp" >&2 else chmod -R u+w "$tmp" 2>/dev/null || true rm -rf "$tmp" fi exit "$rc" } trap cleanup EXIT echo "== build the summer CLI" mkdir -p "$tmp/bin" "$tmp/parent" "$tmp/home" go -C "$ROOT" build -o "$tmp/bin/summer" ./cmd/summer export PATH="$tmp/bin:$PATH" # Reuse the module and build caches under the empty HOME below. GOMODCACHE="$(go env GOMODCACHE)" GOCACHE="$(go env GOCACHE)" GOPATH="$(go env GOPATH)" export GOMODCACHE GOCACHE GOPATH echo "== summer new demo (sibling detection, no git identity)" ln -s "$ROOT" "$tmp/parent/summercms" ( cd "$tmp/parent" env -u GIT_AUTHOR_NAME -u GIT_AUTHOR_EMAIL -u GIT_COMMITTER_NAME -u GIT_COMMITTER_EMAIL \ HOME="$tmp/home" XDG_CONFIG_HOME="$tmp/home/.config" GIT_CONFIG_NOSYSTEM=1 \ summer new demo ) | tee "$tmp/new.out" grep -q "SummerCMS " "$tmp/new.out" || fail "summer new did not announce the fallback git identity" grep -q "docker compose up -d --wait" "$tmp/new.out" || fail "next steps lack docker compose up -d --wait" for step in "summer build" "summer migrate" "summer serve"; do grep -q "$step" "$tmp/new.out" || fail "next steps lack $step" done echo "== layout checks" [ -d "$app" ] || fail "sm-demo-app was not created" grep -q "replace git.golem15.com/golem15/summercms => ../summercms" "$app/go.mod" || fail "go.mod lacks the relative sibling replace" [ "$(stat -c %a "$app/.env")" = "600" ] || fail ".env mode is $(stat -c %a "$app/.env"), want 600" grep -q '^SUMMER_APP__KEY=.\{44\}$' "$app/.env" || fail ".env lacks a 44-character SUMMER_APP__KEY" [ "$(git -C "$app" rev-list --count HEAD)" = "1" ] || fail "want exactly one commit" [ "$(git -C "$app" rev-parse --abbrev-ref HEAD)" = "main" ] || fail "want branch main" [ "$(git -C "$app" log -1 --format='%an <%ae>')" = "SummerCMS " ] || fail "first commit author is not the fallback identity" [ -z "$(git -C "$app" ls-files .env)" ] || fail ".env is committed" for f in .env.example go.work main.go plugins.gen.go .gitignore compose.yaml CLAUDE.md AGENTS.md summer.yaml go.mod; do [ -n "$(git -C "$app" ls-files "$f")" ] || fail "$f is not committed" done [ -L "$app/AGENTS.md" ] && [ "$(readlink "$app/AGENTS.md")" = "CLAUDE.md" ] || fail "AGENTS.md is not a symlink to CLAUDE.md" [ ! -e "$app/go.sum" ] || fail "the app root has a go.sum" if grep -Eq '^[[:space:]]*/' "$app/go.work"; then fail "go.work has an absolute use path" fi git -C "$app" log -1 --format=%B | grep -qi "co-authored-by" && fail "first commit carries a trailer" echo "== docker compose up -d --wait, summer build, summer migrate" cd "$app" export COMPOSE_PROJECT_NAME="$project" docker compose up -d --wait summer build summer migrate echo "== summer serve" summer serve >"$tmp/serve.log" 2>&1 & server_pid=$! local body="" i for ((i = 0; i < SERVE_TIMEOUT; i++)); do if body="$(curl -fsS http://127.0.0.1:8080/ 2>/dev/null)"; then break fi kill -0 "$server_pid" 2>/dev/null || { cat "$tmp/serve.log" >&2; fail "summer serve exited"; } sleep 1 done [ -n "$body" ] || { cat "$tmp/serve.log" >&2; fail "GET / did not answer within ${SERVE_TIMEOUT}s"; } echo "GET / -> $body" case "$body" in *'"plugin":"golem15.demo"'*) ;; *) fail "GET / body lacks \"plugin\":\"golem15.demo\"" ;; esac echo "Phase 14.3 generator check passed" } # acceptance_inner runs inside node:24-bookworm as root. ACC_MODE is local # or production; ACC_REF is the framework commit for local runs. read -r -d '' acceptance_inner <<'INNER' || true export DEBIAN_FRONTEND=noninteractive run() { echo "+ $*" "$@" } echo "== apt: ca-certificates curl gnupg jq" apt-get update -qq apt-get install -y -qq ca-certificates curl gnupg jq >/dev/null echo "== Docker CLI and compose plugin from Docker's apt repository" install -m 0755 -d /etc/apt/keyrings curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc chmod a+r /etc/apt/keyrings/docker.asc echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/debian bookworm stable" >/etc/apt/sources.list.d/docker.list apt-get update -qq apt-get install -y -qq docker-ce-cli docker-compose-plugin >/dev/null docker --version docker compose version echo "== Go 1.27.0 from go.dev, sha256 checked" tarball=go1.27.0.linux-amd64.tar.gz sha="$(curl -fsSL 'https://go.dev/dl/?mode=json&include=all' | jq -r --arg f "$tarball" '.[] | select(.version == "go1.27.0") | .files[] | select(.filename == $f) | .sha256')" [[ $sha =~ ^[0-9a-f]{64}$ ]] || { echo "no sha256 for $tarball on go.dev" >&2; exit 1; } curl -fsSL -o "/tmp/$tarball" "https://go.dev/dl/$tarball" (cd /tmp && echo "$sha $tarball" | sha256sum -c -) tar -C /usr/local -xzf "/tmp/$tarball" export PATH="/usr/local/go/bin:$PATH" go version # Throwaway root container cloning a host-owned checkout. git config --global --add safe.directory '*' mkdir /work cd /work server_pid="" cleanup() { local rc=$? if [ -n "$server_pid" ]; then kill "$server_pid" 2>/dev/null || true wait "$server_pid" 2>/dev/null || true fi if [ -f /work/sm-demo-app/compose.yaml ]; then (cd /work/sm-demo-app && docker compose down -v --remove-orphans >/dev/null 2>&1) || true fi exit "$rc" } trap cleanup EXIT if [ "$ACC_MODE" = production ]; then echo "+ curl -fsSL https://summercms.io/new | bash -s -- demo" curl -fsSL https://summercms.io/new | bash -s -- demo 2>&1 | tee /tmp/new.out else echo "+ curl -fsSL file:///fw/modules/sunrise/new.sh | SUMMERCMS_REPO=/fw SUMMERCMS_REF=$ACC_REF bash -s -- demo" curl -fsSL file:///fw/modules/sunrise/new.sh | SUMMERCMS_REPO=/fw SUMMERCMS_REF="$ACC_REF" bash -s -- demo 2>&1 | tee /tmp/new.out fi grep -qx ' cd /work/sm-demo-app' /tmp/new.out || { echo "the script did not print cd /work/sm-demo-app" >&2; exit 1; } grep -qF 'export PATH="/root/go/bin:$PATH"' /tmp/new.out || { echo "the script did not print the PATH hint" >&2; exit 1; } export PATH="$(go env GOPATH)/bin:$PATH" echo "== D-20 chain" cd sm-demo-app run docker compose up -d --wait run summer build run summer migrate echo "+ summer serve &" summer serve >/tmp/serve.log 2>&1 & server_pid=$! body="" for ((i = 0; i < 60; i++)); do if body="$(curl -fsS http://127.0.0.1:8080/ 2>/dev/null)"; then break fi kill -0 "$server_pid" 2>/dev/null || { cat /tmp/serve.log >&2; echo "summer serve exited" >&2; exit 1; } sleep 1 done [ -n "$body" ] || { cat /tmp/serve.log >&2; echo "GET / did not answer within 60s" >&2; exit 1; } echo "GET http://127.0.0.1:8080/ -> $body" case "$body" in *'"plugin":"golem15.demo"'*) ;; *) echo "GET / body lacks \"plugin\":\"golem15.demo\"" >&2; exit 1 ;; esac INNER # stage_acceptance runs acceptance_inner in node:24-bookworm. $1 is local # (committed script and repository at HEAD) or production. stage_acceptance() { local mode="$1" command -v docker >/dev/null || fail "docker is not installed" docker info >/dev/null 2>&1 || fail "the Docker daemon is not reachable (docker info failed)" port_free 54320 || fail "127.0.0.1:54320 is in use; the generated compose.yaml publishes PostgreSQL there" port_free 8080 || fail "127.0.0.1:8080 is in use; summer serve listens there" local ref="" mounts=() if [ "$mode" = local ]; then git -C "$ROOT" ls-files --error-unmatch modules/sunrise/new.sh >/dev/null 2>&1 || fail "modules/sunrise/new.sh is not committed; the acceptance run clones HEAD" git -C "$ROOT" diff --quiet HEAD -- modules/sunrise/new.sh || fail "modules/sunrise/new.sh differs from HEAD; commit it first (what is tested must be committed)" ref="$(git -C "$ROOT" rev-parse HEAD)" mounts=(-v "$ROOT:/fw:ro") fi echo "== docker pull node:24-bookworm" docker pull -q node:24-bookworm >/dev/null # Globals: the EXIT trap runs after this function returns. acc_project="phase143acc-$$-$(date +%s)" acc_runner="$acc_project-runner" acc_cleanup() { local rc=$? docker rm -f "$acc_runner" >/dev/null 2>&1 || true local ids ids="$(docker ps -aq --filter "label=com.docker.compose.project=$acc_project")" [ -z "$ids" ] || docker rm -f $ids >/dev/null 2>&1 || true ids="$(docker volume ls -q --filter "label=com.docker.compose.project=$acc_project")" [ -z "$ids" ] || docker volume rm -f $ids >/dev/null 2>&1 || true ids="$(docker network ls -q --filter "label=com.docker.compose.project=$acc_project")" [ -z "$ids" ] || docker network rm $ids >/dev/null 2>&1 || true exit "$rc" } trap acc_cleanup EXIT echo "== node:24-bookworm ($mode, compose project $acc_project)" docker run --rm --name "$acc_runner" --network host \ -v /var/run/docker.sock:/var/run/docker.sock "${mounts[@]}" \ -e COMPOSE_PROJECT_NAME="$acc_project" -e ACC_MODE="$mode" -e ACC_REF="$ref" \ node:24-bookworm bash -euo pipefail -c "$acceptance_inner" if [ "$mode" = production ]; then echo "Phase 14.3 production acceptance passed" else echo "Phase 14.3 acceptance passed" fi } stage_forbidden() { cd "$ROOT" echo "== forbidden surfaces" 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 added="$(git diff v0.1.12 -- go.mod | grep -E '^\+[[:space:]]*(require[[:space:]]|[^+[:space:]]+[[:space:]]+v[0-9])' || true)" [ -z "$added" ] || fail "go.mod gained a requirement since v0.1.12: $added" local deps deps="$(go list -deps ./...)" if grep -xE 'plugin|.*yaegi.*' <<<"$deps"; then fail "the dependency graph contains runtime plugin loading (plugin or yaegi)" fi local golden for golden in internal/build/testdata/new/go.mod.golden internal/build/testdata/new/go.work.golden \ internal/build/testdata/new-no-plugin/go.mod.golden internal/build/testdata/new-no-plugin/go.work.golden; do [ -f "$golden" ] || fail "$golden is missing" if grep -nE '^[[:space:]]*/|=> /' "$golden"; then fail "$golden contains an absolute path" fi done local ignored ignored="$(git ls-files internal/build/testdata | git check-ignore --no-index --stdin || true)" [ -z "$ignored" ] || fail "the framework .gitignore ignores goldens: $ignored" [ "$(git ls-files internal/build/testdata | wc -l)" -ge 23 ] || fail "fewer than 23 goldens are tracked" echo "Phase 14.3 forbidden-surface scan passed" } stage_unit() { cd "$ROOT" for tool in go git pnpm python3 shellcheck; do command -v "$tool" >/dev/null || fail "$tool is not installed" done unit_tmp="$(mktemp -d)" trap 'rm -rf "$unit_tmp"' EXIT echo "== go vet ./..." go vet ./... echo "== go test ./... -count=1 -v" if ! go test ./... -count=1 -v >"$unit_tmp/framework.log" 2>&1; then grep -E '^[[:space:]]*--- FAIL|^FAIL|panic:' "$unit_tmp/framework.log" >&2 || tail -50 "$unit_tmp/framework.log" >&2 fail "go test ./... failed" fi require_named_pass "$unit_tmp/framework.log" "${REQUIRED_TESTS[@]}" # Goldens change only with an explicit -update, never during the gate. git diff --quiet -- internal/build/testdata || fail "golden files differ from the index" [ -z "$(git status --porcelain -- internal/build/testdata)" ] || fail "untracked or changed golden files" echo "== examples/hello" go -C examples/hello test ./... -count=1 echo "== docs:build --check" go run ./cmd/summer docs:build --check echo "== site plugin" [ -d "$SITE" ] || fail "site checkout $SITE is missing (set PHASE143_SITE)" if ! go -C "$SITE/plugins/golem15/summercms" test ./... -count=1 -v >"$unit_tmp/site-plugin.log" 2>&1; then tail -50 "$unit_tmp/site-plugin.log" >&2 fail "site plugin tests failed" fi require_named_pass "$unit_tmp/site-plugin.log" "${SITE_PLUGIN_TESTS[@]}" echo "== site app" if ! go -C "$SITE" test ./... -count=1 -v >"$unit_tmp/site-app.log" 2>&1; then tail -50 "$unit_tmp/site-app.log" >&2 fail "site app tests failed" fi require_named_pass "$unit_tmp/site-app.log" "${SITE_APP_TESTS[@]}" echo "== landing page: pnpm generate, pnpm test" pnpm -C "$SITE/vue-summercmsio-app" generate >"$unit_tmp/generate.log" 2>&1 || { tail -50 "$unit_tmp/generate.log" >&2 fail "pnpm generate failed" } pnpm -C "$SITE/vue-summercmsio-app" test stage_forbidden echo "Phase 14.3 unit stage passed" } stage_bootstrap() { cd "$ROOT" bootstrap_tmp="$(mktemp -d)" trap 'rm -rf "$bootstrap_tmp"' EXIT echo "== SUMMERCMS_BOOTSTRAP_CHECK=1 TestBootstrapEndToEnd" if ! SUMMERCMS_BOOTSTRAP_CHECK=1 go test ./modules/sunrise/ -run '^TestBootstrapEndToEnd$' -count=1 -v >"$bootstrap_tmp/log" 2>&1; then tail -60 "$bootstrap_tmp/log" >&2 fail "TestBootstrapEndToEnd failed" fi require_named_pass "$bootstrap_tmp/log" TestBootstrapEndToEnd echo "Phase 14.3 bootstrap check passed" } stage_terminal() { cd "$ROOT" command -v docker >/dev/null || fail "docker is not installed" port_free 54320 || fail "127.0.0.1:54320 is in use" port_free 8080 || fail "127.0.0.1:8080 is in use" git diff --quiet HEAD -- modules/sunrise/new.sh || fail "modules/sunrise/new.sh differs from HEAD; commit it first (the check clones HEAD)" terminal_tmp="$(mktemp -d)" trap 'rm -rf "$terminal_tmp"' EXIT local head head="$(git rev-parse HEAD)" echo "== site TestTerminalCommands (local script, framework at $head)" if ! SUMMERCMS_TERMINAL_CHECK=1 SUMMERCMS_NEW_URL="file://$ROOT/modules/sunrise/new.sh" \ SUMMERCMS_REPO="$ROOT" SUMMERCMS_REF="$head" \ go -C "$SITE" test -run '^TestTerminalCommands$' -count=1 -v . >"$terminal_tmp/log" 2>&1; then tail -60 "$terminal_tmp/log" >&2 fail "TestTerminalCommands failed" fi require_named_pass "$terminal_tmp/log" TestTerminalCommands echo "Phase 14.3 terminal check passed" } stage_security() { [ -f "$REVIEW" ] || fail "missing $REVIEW" python3 - "$REVIEW" <<'PY' || fail "security review check failed" import re, sys text = open(sys.argv[1], encoding="utf-8").read() want = [f"T-14.3-{i:02d}" for i in range(1, 27)] + ["T-14.3-SC"] rows = {} for line in text.splitlines(): if not line.startswith("| T-14.3-"): continue cells = [c.strip() for c in re.split(r"(?&2 exit 1 fi done echo "Phase 14.3 gate passed" } case "${1:-}" in --all) stage_all ;; --unit) stage_unit ;; --forbidden) stage_forbidden ;; --generator) stage_generator ;; --bootstrap) stage_bootstrap ;; --terminal) stage_terminal ;; --acceptance) stage_acceptance local ;; --acceptance-production) stage_acceptance production ;; --security) stage_security ;; *) echo "usage: $0 --all | --unit | --forbidden | --generator | --bootstrap | --terminal | --acceptance | --acceptance-production | --security" >&2 exit 2 ;; esac