From 70ca3a98c6357424d8ff9838e713408c3006ea1f Mon Sep 17 00:00:00 2001 From: iam-truongtrungnghia Date: Sat, 12 Sep 2026 23:40:40 +0700 Subject: [PATCH 1/2] fix(install): read compose services without piping into grep -q MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `docker compose --profile migrate config --services | grep -qx migrate` looks correct and fails about one run in seven. grep -q exits on its first match and closes the pipe; docker compose is still writing, dies of SIGPIPE, and this script's own `set -o pipefail` reports the whole pipeline as failed. The effect on a client is a stack that refuses to migrate, at random, with "a database service exists but no migrate service was found — refusing to start with unapplied schema" — about a service that is sitting right there. Measured 6 failures in 40 runs against a generated nextjs+nestjs+postgres project; 0 in 40 after. compose_has_service captures the list first and matches against it, so nothing closes a pipe early. The test stub writes past the match by more than one pipe buffer, which makes the old shape fail deterministically. --- common/install.sh | 17 +++++++++++++++-- tests/install.bats | 31 +++++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 2 deletions(-) diff --git a/common/install.sh b/common/install.sh index 8e1f28f..28a371c 100755 --- a/common/install.sh +++ b/common/install.sh @@ -220,12 +220,25 @@ start_stack() { # the one deploy mechanism that exists today. A project with no database # ships no migrate service, and `--profile` on a service that is not there # is not an error. +# compose_has_service [--profile ] +# Captured, never piped into `grep -q`: grep closes the pipe on its first match, +# `docker compose` then dies of SIGPIPE, and `set -o pipefail` reports the whole +# pipeline as failed. Measured at roughly one run in seven — a stack that +# refused to migrate, at random, with a message about a service that was there. +compose_has_service() { + local service="$1"; shift + local services + + services="$(docker compose "$@" config --services)" || return 1 + grep -qx "$service" <<<"$services" +} + run_migrations() { # `docker compose config --services` (no --profile) never lists a service # gated behind a profile, so that guard alone always skipped the migration # silently — measured: plain `config --services` prints only `app`, and # `--profile migrate config --services` prints `migrate app`. - if docker compose --profile migrate config --services | grep -qx migrate; then + if compose_has_service migrate --profile migrate; then echo "running migrations..." docker compose --profile migrate run --rm migrate return @@ -236,7 +249,7 @@ run_migrations() { # itself silently vanished. Returning 0 here is exactly the hole that let # a stack go green with unapplied schema; a project with no database at # all is the only case this falls through to. - if docker compose config --services | grep -qx database; then + if compose_has_service database; then echo "a database service exists but no migrate service was found — refusing to start with unapplied schema" >&2 return 1 fi diff --git a/tests/install.bats b/tests/install.bats index a138dbc..1fc41ac 100644 --- a/tests/install.bats +++ b/tests/install.bats @@ -181,3 +181,34 @@ INNER_EOF [[ "$output" == *"read:packages"* ]] [[ "$output" != *"if this project is private"* ]] } + +@test "run_migrations survives a compose that is still writing when the match is found" { + # `docker compose ... | grep -qx migrate` reads correctly and fails about one + # run in seven: grep closes the pipe on its first match, compose dies of + # SIGPIPE, and install.sh's own `set -o pipefail` reports the pipeline as + # failed — so the stack refused to migrate, at random, naming a service that + # was there. The stub keeps writing past the match to make that deterministic. + mkdir -p stub6 + cat > stub6/docker <<'INNER_EOF' +#!/usr/bin/env bash +case "$*" in + *"config --services"*) + printf 'database\nmigrate\napi\n' + # more than one pipe buffer past the match, so a reader that closes early + # leaves this write to fail + head -c 200000 /dev/zero | tr '\0' 'x' + ;; + *) printf 'ran: %s\n' "$*" >> "${DOCKER_LOG}" ;; +esac +INNER_EOF + chmod +x stub6/docker + + DOCKER_LOG="${PWD}/d6.log" PATH="${PWD}/stub6:${PATH}" run run_migrations + assert_ok + [[ "$output" == *"running migrations"* ]] + + [ -f d6.log ] \ + || { echo "the docker stub never ran the migration"; false; } + run cat d6.log + [[ "$output" == *"run --rm migrate"* ]] +} From 22fc6b21c90d2abaa0f0cf6c162a3ba2c516d5b9 Mon Sep 17 00:00:00 2001 From: iam-truongtrungnghia Date: Sat, 12 Sep 2026 23:41:02 +0700 Subject: [PATCH 2/2] refactor: name things once, and say the rest in code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit No behaviour change. Everything below is naming, shape and comments. Headers: one format on every script, giving the file, what it does and the author; executables also carry a usage and an example. Vocabulary: one verb per idea, so a reader can guess. assert_ dies on a bad value, require_ is an environment precondition, resolve_ works a value out, record_ writes it down, load_ reads a definition into variables, print_ only prints, and a bare noun is a getter. Twelve functions were renamed to fit, three of which said the wrong thing outright: resolve_minimum_release_age -> record_release_age_exceptions verify_workspace_filter_name -> assert_workspace_filter_name resolve_workspace_filter_name -> substitute_workspace_filter The last two were near-identical names for opposite actions. Shape: long functions became named steps. cmd_new went from 143 lines to 27, and cmd_add, cmd_update, cmd_publish, lint_adapters, lint_services and apply_service_drivers followed. scripts/deploy-check.sh and scripts/check-provenance.sh were straight-line scripts and are now a main calling named steps. 126 functions became 187; the longest went from 143 lines to 69. Duplication: merge_compose_fragment replaces four copies of the same yq-merge-or-die block, adapter_env_value four copies of the same sed, relax_pnpm_workspace/restore_pnpm_workspace three copies of the same three-line sed, resolve_project two copies of a ten-line block. Constants: FIRST_APP_PORT and APP_CONTAINER_PORT (which removed the magic 8079 and the paragraph explaining it), PNPM_RELAXATIONS, PUBLISH_UNSUPPORTED, SHARED_DRIVERS_DIR, PasswordPlaceholder, ESC_SEQUENCE_TIMEOUT and others. Comments: 18,505 words to 13,166, 36% of lines to 27%. Fourteen were pure signature echoes and are gone; the rest were compressed. What remains is third-party landmines — mise exec trusting a parent config, pnpm turning on frozen lockfiles under CI, yq collapsing a document without -P — which is why the density stays above immich's 14%. Not done: readonly on the constants. These libraries are re-sourced into child processes by design, and a second readonly is an error that set -e turns into a dead script. --- .github/workflows/ci.yml | 10 +- .github/workflows/provenance.yml | 12 +- adapters/nextjs/Dockerfile | 20 +- adapters/nextjs/Dockerfile.workspace | 20 +- common/compose.yaml | 17 +- common/install.sh | 187 ++- .../0014-deployment-deferred-with-seams.md | 2 +- ...ly-chain-defaults-in-generated-projects.md | 12 +- ...-not-recompute-the-typescript-workspace.md | 2 +- .../0021-the-released-stack-must-run.md | 2 +- docs/runbook/add-an-adapter.md | 2 +- docs/runbook/first-project-walkthrough.md | 2 +- .../plans/2026-08-25-scaffold-toolbox.md | 18 +- docs/tour/03-ci.md | 2 +- docs/tour/07-containers.md | 2 +- docs/tour/08-adapters.md | 4 +- lefthook.yml | 14 +- lib/adapter.sh | 185 ++- lib/contract.sh | 43 +- lib/lint.sh | 370 +++--- lib/log.sh | 23 +- lib/manifest.sh | 72 +- lib/pnpm.sh | 269 +++-- lib/project.sh | 223 ++-- lib/publish.sh | 95 +- lib/service.sh | 615 +++++----- lib/tui.sh | 337 +++--- lib/update.sh | 144 +-- lib/wizard.sh | 234 ++-- scaffold | 1061 +++++++++-------- scripts/adapter-matrix.sh | 71 +- scripts/check-provenance.sh | 160 ++- scripts/deploy-check.sh | 535 +++++---- services/mongodb/drivers/laravel.sh | 103 +- services/mongodb/drivers/nest.sh | 15 +- services/mysql/drivers/laravel.sh | 8 +- services/mysql/drivers/nest.sh | 5 + services/postgres/drivers/laravel.sh | 8 +- services/postgres/drivers/nest.sh | 5 + services/redis/drivers/laravel.sh | 25 +- services/redis/drivers/nest.sh | 14 +- services/shared/laravel.sh | 84 +- services/shared/nest.sh | 164 +-- tests/cli.bats | 6 +- tests/helpers/setup.bash | 5 + tests/new-laravel-api.bats | 4 +- tests/new-laravel-inertia.bats | 2 +- tests/new-nestjs.bats | 2 +- tests/new-project.bats | 6 +- tests/service.bats | 16 +- tests/update.bats | 2 +- 51 files changed, 2623 insertions(+), 2616 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8ab18f3..f85748e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,13 +15,11 @@ jobs: runs-on: ubuntu-latest permissions: contents: read - # ci-unit is lint plus test-unit, and test-unit is genuinely offline — - # no adapter generator anywhere in its setup(), asserted by + # ci-unit is lint plus test-unit, and test-unit is genuinely offline — no + # adapter generator anywhere in its setup(), asserted by # tests/contract.bats rather than promised by this comment — so no - # pnpm/php provisioning is needed here. The lane has grown from 30 tests - # to 152 since it was written; measured 79 seconds for the whole job on - # a runner (checkout, mise install, lint and the suites), which is what - # the timeout below leaves room around. + # pnpm/php provisioning is needed here. The whole job measured 79 seconds + # on a runner, which is what the timeout below leaves room around. timeout-minutes: 5 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 diff --git a/.github/workflows/provenance.yml b/.github/workflows/provenance.yml index 5f0721f..bd7b175 100644 --- a/.github/workflows/provenance.yml +++ b/.github/workflows/provenance.yml @@ -14,14 +14,10 @@ permissions: {} jobs: self-test: - # "does the checker work" belongs on every change (and on a manual - # run of this workflow); "has upstream moved" (the check job below) - # belongs on a schedule — different questions. before this, - # tests/provenance.bats only ran monthly alongside check, so a - # regression in the script itself could go unnoticed for weeks. - # workflow_dispatch is included here (rather than duplicating this - # job's bats step inside check) so a manual run exercises both jobs, - # not just the drift check. + # "does the checker work" belongs on every change; "has upstream moved" + # (the check job below) belongs on a schedule — different questions. + # workflow_dispatch is included here, rather than duplicating this job's + # bats step inside check, so a manual run exercises both jobs. if: ${{ github.event_name == 'pull_request' || github.event_name == 'workflow_dispatch' }} runs-on: ubuntu-latest permissions: diff --git a/adapters/nextjs/Dockerfile b/adapters/nextjs/Dockerfile index a250e36..39f2c75 100644 --- a/adapters/nextjs/Dockerfile +++ b/adapters/nextjs/Dockerfile @@ -29,22 +29,16 @@ USER node # nothing rewrites it — an adapter listening anywhere else publishes a dead # port. next's standalone server.js reads PORT itself. ENV PORT=8080 -# Docker sets HOSTNAME to the container's own id for every container, and -# the standalone server.js binds to `process.env.HOSTNAME || '0.0.0.0'` — so -# without this, it listens on that id-derived address, not 0.0.0.0. Traffic -# from outside (compose's published port) still reaches it, since that's -# routed to the container's real interface regardless; the HEALTHCHECK below -# runs inside the container and dials localhost, which nothing is listening -# on, so it fails forever while the app answers everyone else. Measured with -# `docker run`: `ss -tlnp` showed the server bound to the bridge IP, and -# HEALTHCHECK logged "connection refused" on every attempt, until this line. +# Docker sets HOSTNAME to the container's own id, and the standalone +# server.js binds to `process.env.HOSTNAME || '0.0.0.0'` — so without this it +# listens on that id-derived address. Traffic from outside still reaches it, +# but the HEALTHCHECK below dials localhost from inside the container and +# fails forever while the app answers everyone else. ENV HOSTNAME="0.0.0.0" EXPOSE 8080 # 127.0.0.1, not localhost: "0.0.0.0" above is an IPv4-only bind, but this -# image's resolver hands wget the ::1 (IPv6) address for "localhost" first, -# and busybox wget does not fall back to the IPv4 result — measured, this -# still failed with "connection refused" after the HOSTNAME fix alone, even -# though the server was listening and answering every other caller. +# image's resolver hands wget the ::1 address for "localhost" first, and +# busybox wget does not fall back to the IPv4 result. HEALTHCHECK --interval=30s --timeout=3s \ CMD wget -qO- http://127.0.0.1:8080/api/health/live || exit 1 CMD ["node", "server.js"] diff --git a/adapters/nextjs/Dockerfile.workspace b/adapters/nextjs/Dockerfile.workspace index 418a37c..a33f69c 100644 --- a/adapters/nextjs/Dockerfile.workspace +++ b/adapters/nextjs/Dockerfile.workspace @@ -35,22 +35,16 @@ USER node # nothing rewrites it — an adapter listening anywhere else publishes a dead # port. next's standalone server.js reads PORT itself. ENV PORT=8080 -# Docker sets HOSTNAME to the container's own id for every container, and -# the standalone server.js binds to `process.env.HOSTNAME || '0.0.0.0'` — so -# without this, it listens on that id-derived address, not 0.0.0.0. Traffic -# from outside (compose's published port) still reaches it, since that's -# routed to the container's real interface regardless; the HEALTHCHECK below -# runs inside the container and dials localhost, which nothing is listening -# on, so it fails forever while the app answers everyone else. Measured with -# `docker run`: `ss -tlnp` showed the server bound to the bridge IP, and -# HEALTHCHECK logged "connection refused" on every attempt, until this line. +# Docker sets HOSTNAME to the container's own id, and the standalone +# server.js binds to `process.env.HOSTNAME || '0.0.0.0'` — so without this it +# listens on that id-derived address. Traffic from outside still reaches it, +# but the HEALTHCHECK below dials localhost from inside the container and +# fails forever while the app answers everyone else. ENV HOSTNAME="0.0.0.0" EXPOSE 8080 # 127.0.0.1, not localhost: "0.0.0.0" above is an IPv4-only bind, but this -# image's resolver hands wget the ::1 (IPv6) address for "localhost" first, -# and busybox wget does not fall back to the IPv4 result — measured, this -# still failed with "connection refused" after the HOSTNAME fix alone, even -# though the server was listening and answering every other caller. +# image's resolver hands wget the ::1 address for "localhost" first, and +# busybox wget does not fall back to the IPv4 result. HEALTHCHECK --interval=30s --timeout=3s \ CMD wget -qO- http://127.0.0.1:8080/api/health/live || exit 1 CMD ["node", "apps/@APP_FILTER@/server.js"] diff --git a/common/compose.yaml b/common/compose.yaml index 164da5e..42ad3d7 100644 --- a/common/compose.yaml +++ b/common/compose.yaml @@ -1,14 +1,11 @@ -# production-like stack. clients run this file; it is attached to every -# release so the compose file and the image always match (see the scaffold -# toolbox's ADR-0014, not shipped here). every variable below has a default -# so this file validates in a freshly generated project, before a .env -# exists at all — the real values live in .env, written by install.sh from -# example.env. +# The production-like stack clients run. Attached to every release so the +# compose file and the image always match (ADR-0014). Every variable below has +# a default so this file validates in a freshly generated project, before a +# .env exists — the real values live in .env, written by install.sh. # -# nothing is here yet on purpose. scaffold merges in one service per -# application (ADR-0022) and whichever database and cache were selected at -# generation time (ADR-0019), so a project ships exactly what it asked for -# rather than a service nothing opens a connection to. +# Empty on purpose: scaffold merges in one service per application (ADR-0022) +# and whichever database and cache were selected (ADR-0019), so a project ships +# exactly what it asked for rather than a service nothing connects to. name: app services: {} diff --git a/common/install.sh b/common/install.sh index 28a371c..b106d99 100755 --- a/common/install.sh +++ b/common/install.sh @@ -1,9 +1,19 @@ #!/usr/bin/env bash -# install.sh — download the latest release's compose.yaml and example.env, -# then start the stack. adapted from immich's install.sh (see -# https://github.com/immich-app/immich/blob/main/install.sh); this project -# has one image, not several, so its compose stack is simpler, and it never -# overwrites an existing .env (see the comment on download_release_assets). +# ═══════════════════════════════════════════════════════════════════════════ +# Script : install.sh +# Description : Download the latest release's compose files and start the stack. +# Author : ttncode +# +# Usage: +# ./install.sh +# +# Example: +# curl -fsSL https://github.com/you/@PROJECT_NAME@/releases/latest/download/install.sh | bash +# ═══════════════════════════════════════════════════════════════════════════ +# +# Adapted from immich's install.sh +# (https://github.com/immich-app/immich/blob/main/install.sh); this project +# never overwrites an existing .env (see download_release_assets). set -o nounset set -o pipefail @@ -19,14 +29,22 @@ TargetDir='./app' RepoSlug="${RepoUrl#https://github.com/}" RepoSlug="${RepoSlug%/releases/latest/download}" +# The literal every password in the assembled example.env carries, and the +# contract example.env's own header states. Matched on the value rather than a +# *_PASSWORD name pattern: a service naming its variable differently (e.g. +# RABBITMQ_DEFAULT_PASS) still needs a real value generated for it. +PasswordPlaceholder='changeme' +PasswordBytes=32 +PasswordLength=24 + +# ─── downloading the release ─────────────────────────────────────────────── + # release_asset_id — reads a release's JSON on stdin. # -# jq, not grep: measured against a real release, an asset's own id precedes -# its name while the uploader's id follows it, so "find the name, take the -# next id" returns the uploader's for every asset. That request does not -# fail — it fetches a different valid object and writes it to the file the -# caller asked for. Only the token path needs this, so jq stays off the -# public path's dependency list. +# jq, not grep: an asset's own id precedes its name while the uploader's follows +# it, so "find the name, take the next id" returns the uploader's for every +# asset — and that request succeeds, fetching a different valid object. Only the +# token path needs jq, so it stays off the public path's dependencies. release_asset_id() { local name="$1" id id="$(jq -r --arg name "$name" \ @@ -40,18 +58,16 @@ release_asset_id() { # fetch_release_asset # -# Two endpoints, because a private release is not reachable from the public -# one: measured against a real private repository, the browser URL returns -# 404 both anonymously and with a Bearer token, while the API asset endpoint -# returns 200. So a token alone does not fix the public URL — the URL is what -# has to change. +# Two endpoints: a private release's browser URL returns 404 both anonymously +# and with a Bearer token, while the API asset endpoint returns 200. A token +# alone does not fix the public URL — the URL is what has to change. fetch_release_asset() { local name="$1" dest="$2" id if [ -z "${GITHUB_TOKEN:-}" ]; then curl -fsSL "${RepoUrl}/${name}" -o "$dest" && return 0 - # A private release answers 404 to an anonymous request, which reads as - # "no such release" rather than "you are not signed in". + # A private release answers 404 to an anonymous request, which reads as "no + # such release" rather than "you are not signed in". echo "could not download ${name}; if this project is private, set GITHUB_TOKEN to a token with repo and read:packages" >&2 return 1 fi @@ -66,8 +82,8 @@ fetch_release_asset() { -H "Authorization: Bearer ${GITHUB_TOKEN}" \ -H 'Accept: application/octet-stream' \ "https://api.github.com/repos/${RepoSlug}/releases/assets/${id}" -o "$dest" && return 0 - # A token given but rejected by this endpoint is the token's problem, not - # its absence — this message must not repeat the no-token hint above. + # A token given but rejected by this endpoint is the token's problem, not its + # absence — this message must not repeat the no-token hint above. echo "could not download ${name} with the token given; it needs repo and read:packages" >&2 return 1 } @@ -92,24 +108,22 @@ create_directory() { cd "$TargetDir" || return 1 } -# compose.yaml is always overwritten so it never drifts from the image it -# names. .env never is: it holds this installation's real password and the -# operator's edits, and re-running to pick up a release must not lose either. -# A kept .env is still checked for any password left at changeme, so an -# upgrade cannot leave a production service on the literal default. +# compose.yaml is always overwritten so it never drifts from the image it names. +# .env never is: it holds this installation's real password and the operator's +# edits. A kept .env is still checked for a password left at the placeholder. # -# Two cleanup mechanisms, both needed: the explicit `rm -f` before each -# `return 1`, since an EXIT trap would not fire until the script ends; and the -# trap itself, for a signal landing mid-download. Neither leaves a temp file -# holding a plaintext password. +# Two cleanup mechanisms, both needed so no temp file is left holding a +# plaintext password: the explicit `rm -f` before each `return 1`, since an EXIT +# trap fires only at the end of the script; and the trap, for a signal landing +# mid-download. download_release_assets() { echo "downloading compose.yaml..." fetch_release_asset compose.yaml ./compose.yaml || return 1 if [[ -f .env ]]; then echo "found existing .env, leaving it alone" - if grep -qE '^[A-Za-z_][A-Za-z0-9_]*=changeme$' .env; then - echo ".env still has a password set to changeme; set real values in .env before running this again" + if grep -qE "^[A-Za-z_][A-Za-z0-9_]*=${PasswordPlaceholder}\$" .env; then + echo ".env still has a password set to ${PasswordPlaceholder}; set real values in .env before running this again" return 1 fi return 0 @@ -118,11 +132,10 @@ download_release_assets() { echo "downloading example.env..." local tmp_env tmp_env="$(mktemp ./.env.XXXXXX)" || return 1 - # Two changes from the obvious `trap 'rm -f "$tmp_env"' EXIT`, both needed - # before a Ctrl-C stopped leaving the generated password on disk: the path is - # baked in with printf %q, because bash unwinds function locals before - # running the trap; and the signals are named, because a plain EXIT trap does - # not run when one kills the shell. + # Two changes from the obvious `trap 'rm -f "$tmp_env"' EXIT`, or a Ctrl-C + # leaves the generated password on disk: the path is baked in with printf %q, + # because bash unwinds function locals before running the trap; and the + # signals are named, because a plain EXIT trap does not run on a kill. # shellcheck disable=SC2064 # expanding now is the point trap "rm -f $(printf '%q' "$tmp_env")" EXIT INT TERM HUP if ! fetch_release_asset example.env "$tmp_env"; then @@ -135,8 +148,8 @@ download_release_assets() { rm -f "$tmp_env" return 1 fi - # checked, like every other step here: an unchecked mv returns 0 through the - # trap below, so a failure reported success and left the password file behind. + # Checked, like every other step here: an unchecked mv returns 0 through the + # trap below, reporting success and leaving the password file behind. if ! mv "$tmp_env" ./.env; then rm -f "$tmp_env" trap - EXIT INT TERM HUP @@ -146,15 +159,10 @@ download_release_assets() { trap - EXIT INT TERM HUP } -# Every variable still at the literal `changeme` the assembled .env carries, -# not a *_PASSWORD name match: example.env's own header names the literal as -# the contract, and a service naming its variable differently (e.g. -# RABBITMQ_DEFAULT_PASS) still needs a real value generated for it — a name -# pattern is a convention nothing enforces, the literal is what's actually -# checked below. -# -# Fails hard if a substitution misses: a password staying "changeme" because -# example.env's text drifted is a credential defaulting to a known value. +# ─── configuring it ──────────────────────────────────────────────────────── + +# Fails hard if a substitution misses: a password left at the placeholder is a +# credential defaulting to a known value. # # Known, not fixed: each password is briefly visible in sed's argv to other # local users. Pre-existing in the immich script this came from. @@ -162,48 +170,44 @@ generate_service_passwords() { local file="$1" name password while IFS= read -r name; do # APP_KEY is not a password: laravel decrypts with it and rejects anything - # that is not base64: plus exactly 32 bytes. Handled inside this loop - # rather than beside it so example.env keeps one placeholder, and the - # existing-.env guard that greps for a remaining `=changeme` still covers - # it. + # that is not `base64:` plus exactly 32 bytes. Inside this loop so + # example.env keeps one placeholder and the existing-.env guard covers it. if [ "$name" = APP_KEY ]; then - password="base64:$(head -c 32 /dev/urandom | base64)" + password="base64:$(head -c "$PasswordBytes" /dev/urandom | base64)" else - password="$(head -c 32 /dev/urandom | base64 | tr -dc 'A-Za-z0-9' | head -c 24)" + password="$(head -c "$PasswordBytes" /dev/urandom | base64 | tr -dc 'A-Za-z0-9' | head -c "$PasswordLength")" fi - # `|`, not `/`: a base64 value can itself contain `/`, which would end - # sed's s/// early and leave the line unmatched instead of substituted. - sed -i.bak "s|^${name}=changeme\$|${name}=${password}|" "$file" + # `|`, not `/`: a base64 value can itself contain `/`, which would end sed's + # s/// early and leave the line unmatched instead of substituted. + sed -i.bak "s|^${name}=${PasswordPlaceholder}\$|${name}=${password}|" "$file" rm -f "${file}.bak" grep -qF "${name}=${password}" "$file" || { echo "could not set ${name} in ${file}; refusing to start with an unconfirmed password" return 1 } - done < <(sed -n 's/^\([A-Za-z_][A-Za-z0-9_]*\)=changeme$/\1/p' "$file") + done < <(sed -n "s/^\([A-Za-z_][A-Za-z0-9_]*\)=${PasswordPlaceholder}\$/\1/p" "$file") } -# A generated project no longer ships a placeholder here — scaffold fills the -# image in. This stays for the copy that was hand-edited back to one, or -# carried over from a project generated before that was true: docker rejects -# it on its own, but with "invalid reference format" rather than anything -# actionable. Matched case-insensitively so a half-edit trips it too. -check_image_configured() { - if grep 'image:' compose.yaml | grep -qi 'CHANGEME'; then +# scaffold fills the image in, so this only catches a copy hand-edited back to a +# placeholder: docker rejects that itself, but with "invalid reference format" +# rather than anything actionable. +require_configured_image() { + if grep -i 'image:.*CHANGEME' compose.yaml >/dev/null; then echo "compose.yaml's image line still has a CHANGEME placeholder; edit it to this project's real registry path, then re-run this script" return 1 fi } +# ─── running it ──────────────────────────────────────────────────────────── + start_stack() { - # A package's ghcr visibility is separate from its repository's, and a - # private package refuses an anonymous pull with `unauthorized` — measured - # 2026-09-07. The username is not checked for a token login; RepoSlug's - # owner just makes a failure name something the operator recognises. + # A package's ghcr visibility is separate from its repository's, and a private + # package refuses an anonymous pull with `unauthorized`. The username is not + # checked for a token login; RepoSlug's owner just names something the + # operator recognises. # - # --password-stdin, not an argument: an argument would put the token in - # this process's argv, visible to every other user on the host through the - # process list — the same exposure generate_service_passwords already - # carries for sed's argv, and this must not add a second instance of it. + # --password-stdin, not an argument: argv is visible to every other user on + # the host through the process list. if [ -n "${GITHUB_TOKEN:-}" ]; then printf '%s' "${GITHUB_TOKEN}" \ | docker login ghcr.io -u "${RepoSlug%%/*}" --password-stdin >/dev/null || { @@ -215,11 +219,8 @@ start_stack() { } # ADR-0014 seam 5 forbids migrations from an *entrypoint* — a container that -# migrates every time it starts cannot be scaled or rolled back. This is a -# human running one command on the target host, which is what that ADR calls -# the one deploy mechanism that exists today. A project with no database -# ships no migrate service, and `--profile` on a service that is not there -# is not an error. +# migrates every time it starts cannot be scaled or rolled back. This is a human +# running one command on the target host. # compose_has_service [--profile ] # Captured, never piped into `grep -q`: grep closes the pipe on its first match, # `docker compose` then dies of SIGPIPE, and `set -o pipefail` reports the whole @@ -243,12 +244,10 @@ run_migrations() { docker compose --profile migrate run --rm migrate return fi - # A database service with no migrate service beside it is not "nothing to - # migrate" — every database driver ships a migrate command, so this - # combination only happens if the service, its profile, or the command - # itself silently vanished. Returning 0 here is exactly the hole that let - # a stack go green with unapplied schema; a project with no database at - # all is the only case this falls through to. + # A database with no migrate service beside it is not "nothing to migrate": + # every database driver ships a migrate command, so this only happens if the + # service, its profile or the command silently vanished. A project with no + # database is the only case that falls through. if compose_has_service database; then echo "a database service exists but no migrate service was found — refusing to start with unapplied schema" >&2 return 1 @@ -262,15 +261,12 @@ main() { create_directory || { echo 'could not create the target directory'; return 1; } download_release_assets || { echo 'could not download the release assets'; return 1; } - check_image_configured || return 1 + require_configured_image || return 1 start_stack || { echo 'could not start the stack; check the output above'; return 1; } run_migrations || { echo 'could not run migrations; check the output above'; return 1; } - # One line per application, not one for the project: a project publishes an - # image per application now, and compose gives each its own host port - # (see the scaffold toolbox's ADR-0022). Read out of .env rather than - # compose.yaml so it reports the ports actually in effect, including any the - # operator changed. + # One line per application (ADR-0022), read out of .env so it reports the + # ports actually in effect, including any the operator changed. local name port while IFS='=' read -r name port; do [ -n "$port" ] || continue @@ -279,14 +275,11 @@ main() { done < <(grep -E '^[A-Z][A-Z0-9_]*_PORT=' .env || true) } -# sourced by the toolbox's tests to exercise one function at a time; running -# main on source would try to download a release from the toolbox's own -# unsubstituted `you/@PROJECT_NAME@` url. -# `${BASH_SOURCE[0]:-$0}`, not a bare `${BASH_SOURCE[0]}`: this is documented -# as curl-piped (`curl ... | bash`, same as the immich script it's adapted -# from), and piped in there is no BASH_SOURCE at all — `set -o nounset` above -# killed the script before this line under the bare form, silently, in the -# one way this project is actually run. +# Sourced by the toolbox's tests to exercise one function at a time. +# +# `${BASH_SOURCE[0]:-$0}`, not a bare `${BASH_SOURCE[0]}`: piped through curl +# there is no BASH_SOURCE at all, and `set -o nounset` above kills the script +# right here — silently, in the one way this project is actually run. if [ "${BASH_SOURCE[0]:-$0}" = "${0}" ]; then main fi diff --git a/docs/decisions/0014-deployment-deferred-with-seams.md b/docs/decisions/0014-deployment-deferred-with-seams.md index 1508040..79759a2 100644 --- a/docs/decisions/0014-deployment-deferred-with-seams.md +++ b/docs/decisions/0014-deployment-deferred-with-seams.md @@ -139,7 +139,7 @@ constrains them: the placeholder this bullet used to describe, it never was: the first release shipped a `compose.yaml` naming an image nothing had pushed, so every project needed a hand-edit and a second release before - `install.sh` could work at all. `check_image_configured` remains as a + `install.sh` could work at all. `require_configured_image` remains as a safety net for a copy edited back to a placeholder, or carried over from a project generated before this changed: it greps the downloaded `compose.yaml`'s image line for a bare `CHANGEME` before starting the diff --git a/docs/decisions/0017-supply-chain-defaults-in-generated-projects.md b/docs/decisions/0017-supply-chain-defaults-in-generated-projects.md index 099d04b..57613ac 100644 --- a/docs/decisions/0017-supply-chain-defaults-in-generated-projects.md +++ b/docs/decisions/0017-supply-chain-defaults-in-generated-projects.md @@ -88,7 +88,7 @@ whether it clears the immediate failure: no TTY-abort) and does *not* propagate through a bare `mise exec --` wrapper (confirmed independently; `mise exec` does not reliably forward ambient environment variables to the tool it launches — a fact this - decision had to design around, not rely on, for `resolve_minimum_release_age` + decision had to design around, not rely on, for `record_release_age_exceptions` below). - **`minimumReleaseAge: 0` does not ship anywhere, ever.** Lowering it permanently, silently, for every client project this toolbox will ever @@ -104,7 +104,7 @@ whether it clears the immediate failure: and release-age both relaxed only for this one in-process resolution, neither persisted — to produce a single, correct, root-level lockfile covering every workspace member) and then by - `resolve_minimum_release_age`, which repeatedly runs a real, default + `record_release_age_exceptions`, which repeatedly runs a real, default frozen install (via `mise exec`, so it is checked against the exact pnpm version the contract tasks themselves will use — a bare `pnpm` call from `scaffold`'s own process resolves a different, unpinned system @@ -135,9 +135,9 @@ whether it clears the immediate failure: different exclude lists for the same adapters. - `scaffold new` for an all-typescript project takes longer and needs the network more than before: `sync_workspace_lockfile` and - `resolve_minimum_release_age` each run at least one more real `pnpm + `record_release_age_exceptions` each run at least one more real `pnpm install` beyond what the adapters' own generators already did. -- `resolve_minimum_release_age`'s `mise exec` calls require `mise` +- `record_release_age_exceptions`'s `mise exec` calls require `mise` to be able to install the project's pinned pnpm version on demand if it is not already cached locally — a cost every other pnpm invocation in this pipeline already pays. @@ -161,7 +161,7 @@ whether it clears the immediate failure: relink the existing shared `node_modules`, which pnpm treats as a purge and refuses non-interactively (`ERR_PNPM_ABORTED_REMOVE_MODULES_DIR_NO_TTY`). `cmd_add` relaxes - `confirmModulesPurge` the same way `resolve_minimum_release_age` relaxes + `confirmModulesPurge` the same way `record_release_age_exceptions` relaxes minimum-release-age: appended to `pnpm-workspace.yaml` immediately before the adapter's generator runs, never left in the file handed to the caller. Removal happens at **two separate sites**, not one shared @@ -200,7 +200,7 @@ whether it clears the immediate failure: append-then-strip unconditionally. `cmd_add` then runs `sync_workspace_lockfile` and - `resolve_minimum_release_age` again itself, so a second (or third, ...) + `record_release_age_exceptions` again itself, so a second (or third, ...) app joining the workspace gets the same lockfile reconciliation and minimum-release-age recording the first round of apps got from `scaffold new`. diff --git a/docs/decisions/0018-add-does-not-recompute-the-typescript-workspace.md b/docs/decisions/0018-add-does-not-recompute-the-typescript-workspace.md index ab933ba..5a3a1b7 100644 --- a/docs/decisions/0018-add-does-not-recompute-the-typescript-workspace.md +++ b/docs/decisions/0018-add-does-not-recompute-the-typescript-workspace.md @@ -51,7 +51,7 @@ retroactively when an added adapter happens to be typescript. asked for.** Creating `packages/types` after the fact needs the `packages-types` template `scaffold new` already deleted for a mixed project (there is nothing to `mv`), a fresh `pnpm-workspace.yaml`, - `sync_workspace_lockfile`, and `resolve_minimum_release_age` — most of + `sync_workspace_lockfile`, and `record_release_age_exceptions` — most of `cmd_new`'s typescript-specific machinery, run again, to retrofit a feature (shared types) the caller did not request. `scaffold add`'s contract is "install one adapter, re-sync CI" (task 8's brief); turning diff --git a/docs/decisions/0021-the-released-stack-must-run.md b/docs/decisions/0021-the-released-stack-must-run.md index 4516164..36064e2 100644 --- a/docs/decisions/0021-the-released-stack-must-run.md +++ b/docs/decisions/0021-the-released-stack-must-run.md @@ -31,7 +31,7 @@ the existing `smoke` lane:** declares one. `scripts/deploy-check.sh` is gate 2's implementation. It builds the image locally and starts the stack directly rather than running `common/install.sh` end to end — it does not download a - release, call `create_directory`, or check `check_image_configured` — + release, call `create_directory`, or check `require_configured_image` — but it does call `install.sh`'s own `generate_service_passwords` on the copied `.env`, so the password loop and the `APP_KEY` branch run under the same substitution a client's install would perform, not against diff --git a/docs/runbook/add-an-adapter.md b/docs/runbook/add-an-adapter.md index e043b69..ae6bdfe 100644 --- a/docs/runbook/add-an-adapter.md +++ b/docs/runbook/add-an-adapter.md @@ -48,7 +48,7 @@ cd /tmp/probe && mise run "//apps/api:checklist" ``` (the project root's own `mise run checklist` also runs this once -`register_config_root` picks it up — see `lib/project.sh` — but the +`register_config_root` picks it up — see `lib/manifest.sh` — but the `//apps/api:` prefix runs only the new app, without waiting on every other config root along with it.) diff --git a/docs/runbook/first-project-walkthrough.md b/docs/runbook/first-project-walkthrough.md index 02f37c3..af9cdeb 100644 --- a/docs/runbook/first-project-walkthrough.md +++ b/docs/runbook/first-project-walkthrough.md @@ -81,7 +81,7 @@ scaffold list ``` Expect: the same output as step 2. A report of a missing tool means -`hoist_toolchain` could not read this toolbox's mise environment — check +`load_toolchain_env` could not read this toolbox's mise environment — check `mise env -C ` by hand. A later `scaffold new relative-name` that lands inside the toolbox rather than in the current directory is a finding: nothing in `scaffold` may change directory before resolving the target. diff --git a/docs/superpowers/plans/2026-08-25-scaffold-toolbox.md b/docs/superpowers/plans/2026-08-25-scaffold-toolbox.md index 1c67f64..4cacfbc 100644 --- a/docs/superpowers/plans/2026-08-25-scaffold-toolbox.md +++ b/docs/superpowers/plans/2026-08-25-scaffold-toolbox.md @@ -37,7 +37,7 @@ | `lib/log.sh` | `log`, `warn`, `die`. | | `lib/contract.sh` | `CONTRACT_TASKS`, `REQUIRED_ADAPTER_FILES`. The single source of truth both the linter and tests read. | | `lib/lint.sh` | `lint_adapters` — validates adapters against the contract. | -| `lib/project.sh` | `init_project`, `register_config_root`, `collect_config_roots`, `sync_ci_roots`, `finalize_project`. | +| `lib/project.sh` | `init_project`, `register_config_root`, `config_roots`, `sync_ci_roots`, `finalize_project`. | | `lib/adapter.sh` | `load_adapter`, `apply_adapter`, `merge_lefthook_fragment`. | | `adapters//` | Five files: `adapter.env`, `mise.toml`, `Dockerfile`, `.env.example`, `lefthook.fragment.yml`. | | `common/` | Everything copied verbatim into a generated project. | @@ -568,7 +568,7 @@ git commit -m "feat: add command dispatch and adapter listing" - Produces: - `init_project ` — creates the directory, `git init` on `main`, copies `common/`, renders the root `mise.toml` with `config_roots = ["docs"]`. - `register_config_root ` — inserts the path into the `config_roots` array, idempotent. - - `collect_config_roots ` — echoes the roots, one per line, in file order. + - `config_roots ` — echoes the roots, one per line, in file order. - `sync_ci_roots ` — rewrites the `roots:` input in `.github/workflows/ci.yml` from the current roots. - `finalize_project ` — runs `sync_ci_roots`, then stages everything and makes the initial commit. @@ -647,7 +647,7 @@ teardown() { collect_roots() { source "${SCAFFOLD_ROOT}/lib/log.sh" source "${SCAFFOLD_ROOT}/lib/project.sh" - collect_config_roots "$1" + config_roots "$1" } ``` @@ -772,8 +772,8 @@ register_config_root() { mv "${file}.tmp" "$file" } -# collect_config_roots -collect_config_roots() { +# config_roots +config_roots() { sed -n '/^config_roots = \[$/,/^\]$/p' "${1}/mise.toml" \ | sed -n 's/^ "\(.*\)",$/\1/p' } @@ -782,7 +782,7 @@ collect_config_roots() { # manifest so the two can never disagree. sync_ci_roots() { local project="$1" json - json="$(collect_config_roots "$project" | jq -R . | jq -sc .)" + json="$(config_roots "$project" | jq -R . | jq -sc .)" sed -i.bak "s|^ roots: .*| roots: '${json}'|" \ "${project}/.github/workflows/ci.yml" rm -f "${project}/.github/workflows/ci.yml.bak" @@ -1722,7 +1722,7 @@ teardown() { @test "the fullstack project has exactly two config roots" { scaffold new "$PROJECT" --app laravel-inertia - run bash -c "source '${SCAFFOLD_ROOT}/lib/log.sh'; source '${SCAFFOLD_ROOT}/lib/project.sh'; collect_config_roots '${PROJECT}' | sort | tr '\n' ' '" + run bash -c "source '${SCAFFOLD_ROOT}/lib/log.sh'; source '${SCAFFOLD_ROOT}/lib/project.sh'; config_roots '${PROJECT}' | sort | tr '\n' ' '" [ "$output" = "apps/app docs " ] } @@ -2285,7 +2285,7 @@ git commit -m "feat: add the compose stack and the client installer" - Create: `docs/decisions/0005-share-ci-through-reusable-workflows.md`, `0006-release-please-over-changesets.md`, `0010-github-token-over-a-github-app.md`, `0015-continuous-builds-separate-from-cut-releases.md` **Interfaces:** -- Consumes: `collect_config_roots`, `sync_ci_roots`. +- Consumes: `config_roots`, `sync_ci_roots`. - Produces: five `workflow_call` entrypoints. `app-ci.yml` takes one input, `roots` (a JSON array as a string). `app-build.yml` and `app-release.yml` take `image` (the `ghcr.io/...` repository, no tag). - [ ] **Step 1: Write the failing test** @@ -3681,6 +3681,6 @@ required structure is fixed by the four headings and enforced by **Type consistency.** `SCAFFOLD_ROOT` is set by `scaffold` and by `tests/helpers/setup.bash`. `ADAPTER_*` variables are written in `adapter.env` and read by `load_adapter`. `apply_adapter` takes `(name, project, relative -path)` in that order everywhere. `register_config_root`, `collect_config_roots`, +path)` in that order everywhere. `register_config_root`, `config_roots`, `sync_ci_roots`, and `finalize_project` all take the project directory as their first argument. `role_path` is the only place a role maps to a directory. diff --git a/docs/tour/03-ci.md b/docs/tour/03-ci.md index 7dd1760..77854b0 100644 --- a/docs/tour/03-ci.md +++ b/docs/tour/03-ci.md @@ -13,7 +13,7 @@ project scaffold ever generates. - `common/.github/workflows/ci.yml` — the entire call site: *uses: you/.github/.github/workflows/app-ci.yml@v1* plus a `roots:` input. -- `lib/project.sh`'s `sync_ci_roots` — rewrites that one `roots:` line from +- `lib/manifest.sh`'s `sync_ci_roots` — rewrites that one `roots:` line from `config_roots` (ADR-0013) every time `scaffold new` or `scaffold add` runs, so the two can never disagree. - ADR-0005 for why the pipeline lives in a second repository at all, and diff --git a/docs/tour/07-containers.md b/docs/tour/07-containers.md index 132b8e2..bfe732a 100644 --- a/docs/tour/07-containers.md +++ b/docs/tour/07-containers.md @@ -52,7 +52,7 @@ and `tests/service.bats` fails a fragment that pins its own. adapter, and ADR-0014 for the seven seams a real deploy target plugs into later (published image, environment-only configuration, parameterised `IMAGE_TAG`, health checks, and more). -- `lib/service.sh`'s `add_app_service` and `lib/project.sh`'s +- `lib/service.sh`'s `add_app_service` and `lib/manifest.sh`'s `register_image_target`: one compose service and one image per application, named after the application's own directory (ADR-0022). Until that ADR the build and release workflows named one `apps/` directory per project, diff --git a/docs/tour/08-adapters.md b/docs/tour/08-adapters.md index d5729c0..2653e17 100644 --- a/docs/tour/08-adapters.md +++ b/docs/tour/08-adapters.md @@ -30,7 +30,7 @@ pnpm package, a Prisma schema) and write the connection variables into runs against an `.env.example` the adapter already shipped. `nextjs`'s Dockerfile ships the anchor like every other adapter's — `tests/service.bats` requires it on all of them — but `ADAPTER_ROLE=web` takes no driver at all, -so `apply_adapter` calls `apply_service_setup` with an empty block, which +so `apply_adapter` calls `apply_service_dockerfile` with an empty block, which removes the anchor outright rather than replacing it. A Dockerfile that ships the anchor unreplaced fails to build. @@ -85,7 +85,7 @@ run. The quieter failure is a typo inside a field that still parses: set had to make, the adapter simply vanished from every CI matrix with exit 0 — no adapters.yml job ever mentioned it again, and nothing pointed at `adapter.env` as the place to look. `scripts/adapter-matrix.sh`'s -`validate_tiers` now fails loudly, by name, on exactly that case. +`assert_known_tiers` now fails loudly, by name, on exactly that case. ## Try it diff --git a/lefthook.yml b/lefthook.yml index 91f6857..5cd80d0 100644 --- a/lefthook.yml +++ b/lefthook.yml @@ -1,6 +1,5 @@ -# The toolbox ships lefthook and gitleaks into every generated project -# (common/lefthook.yml) and ran neither on itself, and nothing in this -# repository's own CI scanned for secrets at all. +# The same two hooks the toolbox ships into every generated project +# (common/lefthook.yml), run here on itself. # # Through `mise exec --` so only mise has to be on PATH: both tools exist only # in this repository's mise.toml, so calling them bare fails a plain @@ -10,11 +9,10 @@ pre-commit: parallel: true commands: lint: - # `mise run lint`, not a staged-files glob: shellcheck's subjects here - # are `scaffold` and `scripts/*.sh` as much as `lib/*.sh`, and a glob - # that has to name an extensionless file is a second copy of the list - # mise.toml's lint task already discovers with `git ls-files`. It costs - # a third of a second over the whole repository. + # `mise run lint`, not a staged-files glob: a glob that has to name an + # extensionless file (`scaffold`) is a second copy of the list mise.toml's + # lint task already discovers with `git ls-files`, for a third of a second + # over the whole repository. run: mise run lint gitleaks: run: mise exec -- gitleaks protect --staged --redact --no-banner diff --git a/lib/adapter.sh b/lib/adapter.sh index f80089f..9196670 100644 --- a/lib/adapter.sh +++ b/lib/adapter.sh @@ -1,39 +1,50 @@ +# ═══════════════════════════════════════════════════════════════════════════ +# Script : lib/adapter.sh +# Description : Load an adapter and install the application it generates. +# Author : ttncode +# ═══════════════════════════════════════════════════════════════════════════ # shellcheck shell=bash +APPS_DIR="apps" + +# Sourced or merged by name, so never copied verbatim into the application. +ADAPTER_INTERNAL_FILES=(adapter.env lefthook.fragment.yml) + +# Cleared on every load, or a stale value is read as the next adapter's own. +ADAPTER_OPTIONAL_VARS=( + ADAPTER_POST_GENERATE ADAPTER_LANGUAGE ADAPTER_ROLE ADAPTER_TIER + ADAPTER_FAMILY ADAPTER_LIVENESS_PATH ADAPTER_READINESS_PATH +) + load_adapter() { local name="$1" - # `source` below executes whatever it reads, so the name must not be able to - # leave adapters/ — `--api ../../../tmp/evil` would otherwise run an - # arbitrary file. Checked before the path is built, not after. + # `source` below executes whatever it reads, so the name must not leave + # adapters/ — `--api ../../../tmp/evil` runs an arbitrary file. Checked before + # the path is built. case "$name" in ''|*[!a-z0-9-]*|-*) die "not a usable adapter name: ${name} (run: scaffold list)" ;; esac local dir="${SCAFFOLD_ROOT}/adapters/${name}" - [ -d "$dir" ] || die "unknown adapter: ${name} (run: scaffold list)" ADAPTER_DIR="$dir" - # every optional value, not just one: a stale ADAPTER_LANGUAGE or ROLE from - # the previous load would otherwise be read as this adapter's own - unset -v ADAPTER_POST_GENERATE ADAPTER_LANGUAGE ADAPTER_ROLE ADAPTER_TIER ADAPTER_FAMILY \ - ADAPTER_LIVENESS_PATH ADAPTER_READINESS_PATH + unset -v "${ADAPTER_OPTIONAL_VARS[@]}" + # shellcheck source=/dev/null - # `|| return 1` so an unreadable adapter.env fails here, rather than letting - # the default below become this function's last, always-successful command + # `|| return 1` so an unreadable adapter.env fails here rather than letting + # the defaults below become this function's always-successful last command. source "${dir}/adapter.env" || return 1 - # not contract-required yet, and validate_tiers reports a bad value better - # than `set -u` reports an unbound one + + # The linter is the gate for both, so a fixture adapter missing them still + # loads and assert_known_tiers reports a bad tier by name. : "${ADAPTER_TIER:=}" - # not contract-required yet either — the linter is the gate — so a fixture - # adapter with no family still loads : "${ADAPTER_FAMILY:=}" - # An adapter.env that parses but omits a name used to reach the caller, where - # reading $ADAPTER_NAME under `set -u` killed the shell mid-loop — so one - # incomplete adapter suppressed the listing of every good one. Failing here - # keeps that a per-adapter error, which is what cmd_list already handles. + # An adapter.env that parses but omits a name would reach the caller, where + # reading $ADAPTER_NAME under `set -u` kills the shell mid-loop — one + # incomplete adapter suppressing the listing of every good one. [ -n "${ADAPTER_NAME:-}" ] && [ -n "${ADAPTER_ROLE:-}" ] || return 1 } @@ -43,9 +54,7 @@ adapter_is_typescript() { role_path() { case "$1" in - web) printf 'apps/web\n' ;; - api) printf 'apps/api\n' ;; - app) printf 'apps/app\n' ;; + web|api|app) printf '%s/%s\n' "$APPS_DIR" "$1" ;; *) die "unknown adapter role: ${1}" ;; esac } @@ -58,21 +67,18 @@ merge_lefthook_fragment() { rendered="$(mktemp)" sed "s|@APP_ROOT@|${rel}/|g" "$fragment" > "$rendered" - # Suffix every command name with the app it came from. The merge below is - # key-wise, so two apps of the same language — both laravel adapters define - # `pint` — would otherwise leave one hook scoped to whichever was applied - # last, and the other app's code unformatted on commit, silently. + # Suffix every command with the app it came from: the merge below is key-wise, + # so two apps of the same language — both laravel adapters define `pint` — + # leave one app's code unformatted on commit, silently. yq --inplace "(.. | select(has(\"commands\")) | .commands) |= with_entries(.key |= . + \"-${rel//\//-}\")" "$rendered" - # cleaned up on both paths: under `set -e` a yq failure leaves immediately - # and the file survives the run. Not a RETURN trap — that fires again in - # callers, where $rendered is out of scope and the shell aborts. # -P (block style) because yq propagates the *fragment's* style to the whole - # merged document, and an adapter contributing no hook ships `{}` — one flow - # mapping is enough to collapse all 26 lines of lefthook.yml onto a single - # line and replace every comment in it. The hooks still run; the file a - # human has to read and review does not survive. + # document, and an adapter contributing no hook ships `{}` — one flow mapping + # collapses lefthook.yml onto a single line and drops every comment in it. + # + # Cleaned up on both paths, and not by a RETURN trap: that fires again in + # callers, where $rendered is out of scope. if ! yq eval-all --inplace -P 'select(fileIndex==0) * select(fileIndex==1)' \ "${project}/lefthook.yml" "$rendered"; then rm -f "$rendered" @@ -81,16 +87,12 @@ merge_lefthook_fragment() { rm -f "$rendered" } -# verify_workspace_filter_name — only for an adapter shipping -# Dockerfile.workspace, whose deps stage runs `pnpm --filter install` (resolve_workspace_filter_name bakes that value -# in below): a name create-next-app/@nestjs/cli choose by convention, not one -# this toolbox enforces upstream. A filter matching no project does not fail -# there — pnpm reports "No projects matched the filters" and exits 0 — so the -# build proceeds with nothing installed and dies steps later on a COPY of a -# node_modules that was never created, several layers from the real cause. -# Skipped for the Laravel adapters: composer has no --filter to miss. -verify_workspace_filter_name() { +# Dockerfile.workspace's deps stage runs `pnpm --filter +# install`, and a filter matching no project does not fail: pnpm reports "No +# projects matched the filters" and exits 0, so the build proceeds with nothing +# installed and dies steps later on a COPY of a node_modules that was never +# created. Skipped for Laravel: composer has no --filter to miss. +assert_workspace_filter_name() { local dest="$1" [ -f "${ADAPTER_DIR}/Dockerfile.workspace" ] || return 0 @@ -102,13 +104,10 @@ verify_workspace_filter_name() { || die "${dest}/package.json is named '${found}', not '${expected}' — Dockerfile.workspace's 'pnpm --filter ${expected}' would match nothing" } -# resolve_workspace_filter_name — Dockerfile.workspace ships -# @APP_FILTER@ where it needs the app's own directory name: `scaffold add` -# can place an adapter at any path (apps/worker, not just apps/), so -# the filter can't be baked to the role at adapter-authoring time the way -# role_path's apps/ can. verify_workspace_filter_name already proved -# package.json carries this same value. -resolve_workspace_filter_name() { +# Dockerfile.workspace ships @APP_FILTER@ where it needs the app's own directory +# name: `scaffold add` can place an adapter at any path, so the filter cannot be +# baked to the role at adapter-authoring time. +substitute_workspace_filter() { local dest="$1" local file="${dest}/Dockerfile.workspace" @@ -118,82 +117,78 @@ resolve_workspace_filter_name() { rm -f "${file}.bak" } -apply_adapter() { - local name="$1" project="$2" rel="$3" - - load_adapter "$name" - - local dest="${project}/${rel}" - local parent; parent="$(dirname "$dest")" - mkdir -p "$parent" - - # CI=true stays — it is what lets pnpm replace node_modules with no TTY to - # confirm on. The frozen lockfile it also switches on must not: a generator - # cannot install what it is adding, so `pnpm add -D prettier` reports - # success, leaves no binary, and the next `pnpm exec prettier` is not found. - # - # Through mise exec, not a bare eval: without it, node and pnpm resolve from - # whatever is ambient on the caller's PATH instead of the project's own - # pin — composer stays ambient too, on purpose (docs/decisions/0016). - # The child's own script, held in a variable so it survives the trip through - # `env` intact. Its $1 and $2 are the child's to expand. - # shellcheck disable=SC2016 - local in_the_app_toolchain='cd "$1" && mise exec -- bash -c "$2"' - - step "generating ${rel} with ${name} (a framework generator, this takes a few minutes)" - run_quietly "generating ${rel} with ${name}" \ - env APP_DIR="$(basename "$dest")" npm_config_frozen_lockfile=false \ - bash -c "$in_the_app_toolchain" _ "$parent" "$ADAPTER_GENERATOR" - - verify_workspace_filter_name "$dest" +# Everything the adapter ships except the files it keeps to itself. dotglob so +# .env.example is not skipped; directories merge rather than replace, since +# `src/` exists after the generator ran and `cp -R src dest/src` nests it. +copy_adapter_files() { + local dest="$1" + local file base dir had_dotglob=0 - # everything the adapter ships except adapter.env (sourced) and - # lefthook.fragment.yml (merged). dotglob so .env.example is not skipped. - local file base had_dotglob=0 shopt -q dotglob && had_dotglob=1 shopt -s dotglob for file in "${ADAPTER_DIR}"/*; do [ -f "$file" ] || continue base="$(basename "$file")" - case "$base" in - adapter.env|lefthook.fragment.yml) continue ;; + case " ${ADAPTER_INTERNAL_FILES[*]} " in + *" ${base} "*) continue ;; esac cp "$file" "${dest}/${base}" done [ "$had_dotglob" -eq 1 ] || shopt -u dotglob - # Every directory the adapter ships, merged into the generated tree rather - # than replacing what is there: `src/` already exists after the generator - # ran, and `cp -R src dest/src` would nest it as dest/src/src. - local dir for dir in "${ADAPTER_DIR}"/*/; do [ -d "$dir" ] || continue mkdir -p "${dest}/$(basename "$dir")" cp -R "${dir}." "${dest}/$(basename "$dir")/" done +} + +apply_adapter() { + local name="$1" project="$2" rel="$3" + + load_adapter "$name" + + local dest="${project}/${rel}" + local parent; parent="$(dirname "$dest")" + mkdir -p "$parent" + + # CI=true stays — it lets pnpm replace node_modules with no TTY to confirm on. + # The frozen lockfile it also switches on must not: a generator cannot install + # what it is adding, so `pnpm add -D prettier` reports success, leaves no + # binary, and the next `pnpm exec prettier` is not found. + # + # Through mise exec, not a bare eval, or node and pnpm resolve ambient instead + # of from the project's pin. composer stays ambient on purpose (ADR-0016). + # shellcheck disable=SC2016 # $1 and $2 are the child's to expand + local in_the_app_toolchain='cd "$1" && mise exec -- bash -c "$2"' + + step "generating ${rel} with ${name} (a framework generator, this takes a few minutes)" + run_quietly "generating ${rel} with ${name}" \ + env APP_DIR="$(basename "$dest")" npm_config_frozen_lockfile=false \ + bash -c "$in_the_app_toolchain" _ "$parent" "$ADAPTER_GENERATOR" - resolve_workspace_filter_name "$dest" + assert_workspace_filter_name "$dest" + copy_adapter_files "$dest" + substitute_workspace_filter "$dest" if [ -n "${ADAPTER_POST_GENERATE:-}" ]; then - # verify-deps off too: this runs between the generator and - # sync_workspace_lockfile, the one window where node_modules is meant to - # disagree with the lockfile. Left on, `pnpm exec` runs its own install - # first and reports only `Command failed with exit code 1` when it fails. + # verify-deps off too: this is the one window where node_modules is meant + # to disagree with the lockfile, and left on `pnpm exec` runs its own + # install and reports only `Command failed with exit code 1`. step "configuring ${rel}" run_quietly "configuring ${rel} after its generator ran" \ env npm_config_frozen_lockfile=false npm_config_verify_deps_before_run=false \ bash -c "$in_the_app_toolchain" _ "$dest" "$ADAPTER_POST_GENERATE" fi - # After post-generate: the generator and its own follow-up have settled the - # package manager's state by here, and .env.example has been copied in from - # the adapter, which is the file the driver edits. + # After post-generate, which settles the package manager's state and copies in + # .env.example — the file the driver edits. if [ "${ADAPTER_ROLE}" != "web" ] && [ "${#SCAFFOLD_SERVICES[@]}" -gt 0 ]; then apply_service_drivers "$dest" "$project" "$ADAPTER_FAMILY" "${SCAFFOLD_SERVICES[@]}" else # The anchor is not optional: a Dockerfile shipping it verbatim would fail # to build. - apply_service_setup "$dest" "" + apply_service_dockerfile "$dest" "" fi register_config_root "$project" "$rel" diff --git a/lib/contract.sh b/lib/contract.sh index 5a792f9..81178cf 100644 --- a/lib/contract.sh +++ b/lib/contract.sh @@ -1,25 +1,26 @@ +# ═══════════════════════════════════════════════════════════════════════════ +# Script : lib/contract.sh +# Description : The contract every adapter and service satisfies (ADR-0011). +# Author : ttncode +# ═══════════════════════════════════════════════════════════════════════════ # shellcheck shell=bash -# The contract every adapter satisfies — see docs/decisions/0011. # shellcheck disable=SC2034 # all read by lib/lint.sh once sourced CONTRACT_TASKS=(install format format-fix lint check test build ci-unit checklist) -REQUIRED_ADAPTER_FILES=(adapter.env mise.toml Dockerfile .env.example) - -# apply_adapter evals ADAPTER_GENERATOR, so a missing one dies mid-generation -# with `unbound variable` instead of failing at `scaffold lint`. ADAPTER_FAMILY -# is the same story one step later: apply_service_drivers looks up -# drivers/${family}.sh only once generation is already underway. -REQUIRED_ADAPTER_VARS=(ADAPTER_NAME ADAPTER_ROLE ADAPTER_FAMILY ADAPTER_GENERATOR ADAPTER_LIVENESS_PATH) - READ_ONLY_TASKS=(format lint check) # Catches a read-only task copied from its own -fix sibling. Cannot catch a # tool that writes by default with no flag saying so. WRITING_FLAGS=(--write --fix -w --in-place --overwrite) -# apply_service_drivers sources these and calls both, so a service shipping -# neither fails at generation rather than at lint. +REQUIRED_ADAPTER_FILES=(adapter.env mise.toml Dockerfile .env.example) + +# Both are read mid-generation — ADAPTER_GENERATOR by apply_adapter's eval, +# ADAPTER_FAMILY by the drivers/ lookup — so missing, they fail there with +# `unbound variable` instead of at `scaffold lint`. +REQUIRED_ADAPTER_VARS=(ADAPTER_NAME ADAPTER_ROLE ADAPTER_FAMILY ADAPTER_GENERATOR ADAPTER_LIVENESS_PATH) + REQUIRED_SERVICE_FILES=( service.env compose.fragment.yaml @@ -31,19 +32,17 @@ REQUIRED_SERVICE_FILES=( REQUIRED_SERVICE_VARS=(SERVICE_NAME SERVICE_KIND SERVICE_IMAGE) -# apply_service_drivers calls all four, so a driver shipping fewer fails at -# generation rather than at lint. service_driver_compose_migrate is the -# fourth: every driver implements it, including a cache's, which has no -# schema and prints nothing. +# Holds the parameterised driver bodies every service sources, not a service. +SHARED_DRIVERS_DIR=shared + +# A cache implements compose_migrate too: it has no schema and prints nothing. REQUIRED_DRIVER_FUNCTIONS=(service_driver_apply service_driver_dockerfile service_driver_compose_env service_driver_compose_migrate) -# The web tier is the presentation layer and opens no connection, so it takes -# no driver — stated once, about the role, rather than as a "not applicable" -# entry repeated in every service. +# The web tier opens no connection, so it takes no driver. Stated once about the +# role rather than as a "not applicable" entry in every service. DRIVEN_ROLES=(api app) -# The --db value cmd_new picks when a project has an api or app adapter and -# --db was not given (docs/decisions/0020). The wizard's default ordering -# reads this too, so a plain Enter can't drift from what an omitted flag -# would have picked. +# What cmd_new picks when a project has a backend and --db was not given +# (ADR-0020). The wizard's default ordering reads this too, so a plain Enter +# cannot drift from what an omitted flag would pick. DEFAULT_DATABASE_SERVICE=mysql diff --git a/lib/lint.sh b/lib/lint.sh index c86c3ff..2973757 100644 --- a/lib/lint.sh +++ b/lib/lint.sh @@ -1,197 +1,245 @@ +# ═══════════════════════════════════════════════════════════════════════════ +# Script : lib/lint.sh +# Description : Check every adapter and service against the contract. +# Author : ttncode +# ═══════════════════════════════════════════════════════════════════════════ # shellcheck shell=bash +# adapter_env_value — the value of one quoted assignment. +adapter_env_value() { + sed -n "s/^${2}=\"\(.*\)\"\$/\1/p" "$1" +} + +# task_body — every line of one task's table. A `run` value +# can be a string or an array spanning several lines, and printing the whole +# table covers both without parsing either. +task_body() { + awk -v task="$2" ' + $0 ~ "^\\[tasks\\.\"?" task "\"?\\]$" { inside = 1; next } + inside && /^\[/ { exit } + # a comment is not what the task runs, and a trailing one belongs to the + # next table: a note above [tasks.format-fix] otherwise reads as the + # previous task writing + inside && /^[[:space:]]*#/ { next } + inside { print } + ' "$1" +} + +# driver_families — the families that take a driver, read from +# the adapters themselves rather than listed here: a list would be a second +# copy of the same fact, and the copy is what goes stale. +driver_families() { + local adapter role family + local -a families=() + + for adapter in "$1"/*/; do + [ -f "${adapter}adapter.env" ] || continue + role="$(adapter_env_value "${adapter}adapter.env" ADAPTER_ROLE)" + case " ${DRIVEN_ROLES[*]} " in + *" ${role} "*) ;; + *) continue ;; + esac + family="$(adapter_env_value "${adapter}adapter.env" ADAPTER_FAMILY)" + [ -n "$family" ] || continue + case " ${families[*]-} " in + *" ${family} "*) ;; + *) families+=("$family") ;; + esac + done + + [ "${#families[@]}" -gt 0 ] || return 0 + printf '%s\n' "${families[@]}" +} + # lint_adapters # prints one line per problem and returns 1 when any adapter is incomplete. +# Every lint_* function prints one line per problem and returns 1 when it found +# any, so a caller can run them all and still fail once at the end. + +lint_required_files() { + local name="$1" dir="$2"; shift 2 + local file status=0 + + for file in "$@"; do + if [ ! -f "${dir}${file}" ]; then + printf '%s: missing file %s\n' "$name" "$file" + status=1 + fi + done + return "$status" +} + +lint_adapter_env() { + local name="$1" file="$2" + local var role value status=0 + + for var in "${REQUIRED_ADAPTER_VARS[@]}"; do + grep -Eq "^${var}=" "$file" || { + printf '%s: adapter.env does not set %s\n' "$name" "$var" + status=1 + } + done + + # Conditional on the role rather than required outright: a web adapter has no + # connection to probe, and demanding a readiness path from it would only + # produce one that returns 200 without doing anything. + role="$(adapter_env_value "$file" ADAPTER_ROLE)" + case " ${DRIVEN_ROLES[*]} " in + *" ${role} "*) + grep -Eq '^ADAPTER_READINESS_PATH=' "$file" || { + printf '%s: adapter.env does not set ADAPTER_READINESS_PATH (required for role %s)\n' "$name" "$role" + status=1 + } + ;; + esac + + # A path variable that merely exists is not a route: an empty value satisfies + # every check above, then collapses compose.bats' HEALTHCHECK assertion and + # the deploy gate's readiness curl into matching any probe on localhost:8080 — + # the defect these exist to stop. + for var in ADAPTER_LIVENESS_PATH ADAPTER_READINESS_PATH; do + grep -Eq "^${var}=" "$file" || continue + value="$(adapter_env_value "$file" "$var")" + case "$value" in + /*) ;; + *) + printf '%s: adapter.env sets %s to "%s", not a path starting with /\n' "$name" "$var" "$value" + status=1 + ;; + esac + done + + return "$status" +} + +lint_adapter_tasks() { + local name="$1" file="$2" + local task body flag status=0 + + for task in "${CONTRACT_TASKS[@]}"; do + # both the bare and quoted spelling are valid toml, so tolerate either + if ! grep -Eq "^\[tasks\.\"?${task}\"?\]" "$file"; then + printf '%s: missing task %s\n' "$name" "$task" + status=1 + fi + done + + for task in "${READ_ONLY_TASKS[@]}"; do + body="$(task_body "$file" "$task")" + for flag in "${WRITING_FLAGS[@]}"; do + case " $body " in + *" ${flag} "*|*" ${flag}="*) + printf '%s: %s writes (%s) — %s must report, not repair; see docs/decisions/0011\n' \ + "$name" "$task" "$flag" "$task" + status=1 + ;; + esac + done + done + + return "$status" +} + lint_adapters() { local dir="$1" - local adapter name file task task_body flag var role value status=0 + local adapter name status=0 for adapter in "$dir"/*/; do [ -d "$adapter" ] || continue name="$(basename "$adapter")" - for file in "${REQUIRED_ADAPTER_FILES[@]}"; do - if [ ! -f "${adapter}${file}" ]; then - printf '%s: missing file %s\n' "$name" "$file" - status=1 - fi - done + lint_required_files "$name" "$adapter" "${REQUIRED_ADAPTER_FILES[@]}" || status=1 + [ -f "${adapter}adapter.env" ] && { lint_adapter_env "$name" "${adapter}adapter.env" || status=1; } + [ -f "${adapter}mise.toml" ] && { lint_adapter_tasks "$name" "${adapter}mise.toml" || status=1; } + done - if [ -f "${adapter}adapter.env" ]; then - for var in "${REQUIRED_ADAPTER_VARS[@]}"; do - grep -Eq "^${var}=" "${adapter}adapter.env" || { - printf '%s: adapter.env does not set %s\n' "$name" "$var" - status=1 - } - done - - # Conditional on the role rather than required outright: a web adapter has - # no connection to probe, and demanding a readiness path from it would only - # produce one that returns 200 without doing anything. - role="$(sed -n 's/^ADAPTER_ROLE="\(.*\)"$/\1/p' "${adapter}adapter.env")" - case " ${DRIVEN_ROLES[*]} " in - *" ${role} "*) - grep -Eq '^ADAPTER_READINESS_PATH=' "${adapter}adapter.env" || { - printf '%s: adapter.env does not set ADAPTER_READINESS_PATH (required for role %s)\n' "$name" "$role" - status=1 - } - ;; - esac + return "$status" +} - # A path variable that merely exists is not a route: an empty value - # satisfies every check above, and downstream that same empty value - # collapses tests/compose.bats' HEALTHCHECK assertion and the deploy - # gate's readiness curl into matching any localhost probe on 8080 — - # exactly the Dockerfile-probing-nothing defect these exist to stop. - # Only checked when the variable is declared at all: an undeclared - # ADAPTER_READINESS_PATH on a non-driven role is handled above, not - # here. - for var in ADAPTER_LIVENESS_PATH ADAPTER_READINESS_PATH; do - grep -Eq "^${var}=" "${adapter}adapter.env" || continue - value="$(sed -n "s/^${var}=\"\(.*\)\"\$/\1/p" "${adapter}adapter.env")" - case "$value" in - /*) ;; - *) - printf '%s: adapter.env sets %s to "%s", not a path starting with /\n' "$name" "$var" "$value" - status=1 - ;; - esac - done - fi +lint_service_env() { + local name="$1" file="$2" + local var status=0 - [ -f "${adapter}mise.toml" ] || continue + for var in "${REQUIRED_SERVICE_VARS[@]}"; do + grep -Eq "^${var}=" "$file" || { + printf '%s: service.env does not set %s\n' "$name" "$var" + status=1 + } + done + grep -q '@sha256:' "$file" || { + printf '%s: SERVICE_IMAGE is not pinned by digest\n' "$name" + status=1 + } - for task in "${CONTRACT_TASKS[@]}"; do - # both the bare and quoted spelling are valid toml, so tolerate either - if ! grep -Eq "^\[tasks\.\"?${task}\"?\]" "${adapter}mise.toml"; then - printf '%s: missing task %s\n' "$name" "$task" - status=1 + return "$status" +} + +# A subshell per function, or one family's LARAVEL_* parameters (read unqualified +# in services/shared/laravel.sh) leak into the next driver checked. +# +# SERVICE_DIR set before sourcing, as load_service sets it: a driver that reads +# it at sourcing time and finds it unbound dies under the inherited `set -u`, +# which is not the same problem as a missing function. +lint_driver_functions() { + local name="$1" family="$2" driver="$3" service_dir="$4" + local fn fault status=0 + + for fn in "${REQUIRED_DRIVER_FUNCTIONS[@]}"; do + if ! fault="$( { + # shellcheck disable=SC2034 # read by the driver, not by this loop + SERVICE_DIR="$service_dir" + # shellcheck source=/dev/null # family varies, so the path isn't constant + . "$driver" + declare -F "$fn" >/dev/null + } 2>&1 )"; then + if [ -n "$fault" ]; then + printf '%s: %s driver failed to source: %s\n' "$name" "$family" "$fault" + else + printf '%s: %s driver does not define %s\n' "$name" "$family" "$fn" fi - done + status=1 + fi + done - for task in "${READ_ONLY_TASKS[@]}"; do - task_body="$(task_body "${adapter}mise.toml" "$task")" - for flag in "${WRITING_FLAGS[@]}"; do - case " $task_body " in - *" ${flag} "*|*" ${flag}="*) - printf '%s: %s writes (%s) — %s must report, not repair; see docs/decisions/0011\n' \ - "$name" "$task" "$flag" "$task" - status=1 - ;; - esac - done - done + return "$status" +} + +lint_service_drivers() { + local name="$1" service="$2"; shift 2 + local family driver status=0 + + for family in "$@"; do + driver="${service}drivers/${family}.sh" + if [ ! -f "$driver" ]; then + printf '%s: no driver for %s\n' "$name" "$family" + status=1 + continue + fi + lint_driver_functions "$name" "$family" "$driver" "${service%/}" || status=1 done return "$status" } # lint_services -# prints one line per problem and returns 1 when any service is incomplete or -# any family that takes a driver has no driver in some service. +# Fails when any service is incomplete, or when a family that takes a driver has +# no driver in some service. lint_services() { local dir="$1" adapters="$2" - local service name file var family driver fn fault status=0 + local service name status=0 local -a families=() - # The families to require, read from the adapters themselves rather than - # listed here: a list would be a second copy of the same fact, and the copy - # is what goes stale. - local adapter role - for adapter in "$adapters"/*/; do - [ -f "${adapter}adapter.env" ] || continue - role="$(sed -n 's/^ADAPTER_ROLE="\(.*\)"$/\1/p' "${adapter}adapter.env")" - case " ${DRIVEN_ROLES[*]} " in - *" ${role} "*) ;; - *) continue ;; - esac - family="$(sed -n 's/^ADAPTER_FAMILY="\(.*\)"$/\1/p' "${adapter}adapter.env")" - [ -n "$family" ] || continue - case " ${families[*]-} " in - *" ${family} "*) ;; - *) families+=("$family") ;; - esac - done + mapfile -t families < <(driver_families "$adapters") for service in "$dir"/*/; do [ -d "$service" ] || continue name="$(basename "$service")" - # services/shared holds the parameterised driver bodies, not a service - [ "$name" = shared ] && continue - - for file in "${REQUIRED_SERVICE_FILES[@]}"; do - if [ ! -f "${service}${file}" ]; then - printf '%s: missing file %s\n' "$name" "$file" - status=1 - fi - done - - if [ -f "${service}service.env" ]; then - for var in "${REQUIRED_SERVICE_VARS[@]}"; do - grep -Eq "^${var}=" "${service}service.env" || { - printf '%s: service.env does not set %s\n' "$name" "$var" - status=1 - } - done - grep -q '@sha256:' "${service}service.env" || { - printf '%s: SERVICE_IMAGE is not pinned by digest\n' "$name" - status=1 - } - fi - - for family in "${families[@]}"; do - driver="${service}drivers/${family}.sh" - if [ ! -f "$driver" ]; then - printf '%s: no driver for %s\n' "$name" "$family" - status=1 - continue - fi + [ "$name" = "$SHARED_DRIVERS_DIR" ] && continue - # A subshell, not the current one: sourcing eight drivers in sequence - # here would let one family's LARAVEL_* parameters (services/shared/ - # laravel.sh reads them unqualified) leak into the next driver checked. - # - # SERVICE_DIR set the same way load_service sets it, before sourcing: - # every other call site that sources a driver (apply_service_drivers, - # via load_service; the compose-env test in service.bats, by hand) has - # it set first. A driver that reads it at sourcing time and finds it - # unbound would die under the inherited `set -u` before `declare -F` - # ever ran, and that death is not the same problem as a missing - # function — captured below instead of folded into that message. - for fn in "${REQUIRED_DRIVER_FUNCTIONS[@]}"; do - if ! fault="$( { - # shellcheck disable=SC2034 # read by the driver, not by this loop - SERVICE_DIR="${service%/}" - # shellcheck source=/dev/null # family varies, so the path isn't constant - . "$driver" - declare -F "$fn" >/dev/null - } 2>&1 )"; then - if [ -n "$fault" ]; then - printf '%s: %s driver failed to source: %s\n' "$name" "$family" "$fault" - else - printf '%s: %s driver does not define %s\n' "$name" "$family" "$fn" - fi - status=1 - fi - done - done + lint_required_files "$name" "$service" "${REQUIRED_SERVICE_FILES[@]}" || status=1 + [ -f "${service}service.env" ] && { lint_service_env "$name" "${service}service.env" || status=1; } + lint_service_drivers "$name" "$service" ${families[@]+"${families[@]}"} || status=1 done return "$status" } - -# task_body -# prints every line of one task's table, which is enough to see what it runs: -# a `run` value can be a single string or an array spanning several lines, and -# both are covered by printing the whole table rather than parsing the value. -task_body() { - awk -v task="$2" ' - $0 ~ "^\\[tasks\\.\"?" task "\"?\\]$" { inside = 1; next } - inside && /^\[/ { exit } - # a comment is not what the task runs, and a trailing one belongs to the - # next table: a note above [tasks.format-fix] otherwise reads as the - # previous task writing - inside && /^[[:space:]]*#/ { next } - inside { print } - ' "$1" -} diff --git a/lib/log.sh b/lib/log.sh index 92ce361..7e5ab31 100644 --- a/lib/log.sh +++ b/lib/log.sh @@ -1,24 +1,21 @@ +# ═══════════════════════════════════════════════════════════════════════════ +# Script : lib/log.sh +# Description : Terminal output: messages, step markers and quiet command runs. +# Author : ttncode +# ═══════════════════════════════════════════════════════════════════════════ # shellcheck shell=bash + log() { printf '%s\n' "$*" >&2; } warn() { printf 'warning: %s\n' "$*" >&2; } die() { printf 'error: %s\n' "$*" >&2; exit 1; } -# step -# A line before a step that takes minutes, so a captured command does not look -# like a hang. Numbered nothing and totalled nothing: the number of steps -# depends on the adapters requested, and a "3 of 7" that is wrong is worse -# than no count. +# Marks a step that takes minutes, so a captured command does not read as a +# hang. Unnumbered: the number of steps depends on the adapters requested. step() { printf '→ %s\n' "$*" >&2; } # run_quietly ... -# Runs a command with its output captured, and prints that output only if it -# fails. `scaffold new` used to hand the terminal several minutes of a package -# manager's progress bars, through which the one line that mattered — which -# application is being generated — never appeared at all. -# -# SCAFFOLD_VERBOSE=1 passes the output straight through. The failure path -# already prints everything, so this is for a run that hangs rather than -# fails, where there is otherwise nothing to look at. +# Captures output and prints it only on failure; SCAFFOLD_VERBOSE=1 passes it +# straight through, for a run that hangs rather than fails. run_quietly() { local what="$1"; shift local log status=0 diff --git a/lib/manifest.sh b/lib/manifest.sh index 319a29a..5e63f49 100644 --- a/lib/manifest.sh +++ b/lib/manifest.sh @@ -1,23 +1,25 @@ +# ═══════════════════════════════════════════════════════════════════════════ +# Script : lib/manifest.sh +# Description : One list of config roots and image targets, derived not copied. +# Author : ttncode +# ═══════════════════════════════════════════════════════════════════════════ # shellcheck shell=bash # -# What a project publishes and what CI runs over, both derived from one list -# rather than written twice. -# -# `config_roots` in mise.toml is the manifest (ADR-0013): register_config_root -# is the single place a root enters it, and sync_ci_roots copies it into the CI -# workflow so the two cannot disagree. register_image_target does the same job -# for the applications a project builds images from (ADR-0022). +# `config_roots` in mise.toml is the manifest (ADR-0013): register_config_root is +# the single place a root enters it, and sync_ci_roots copies it into the CI +# workflow. register_image_target does the same for images (ADR-0022). + +MISE_CONFIG_FILE="mise.toml" +CI_WORKFLOW=".github/workflows/ci.yml" +BUILD_WORKFLOWS=(".github/workflows/build.yml" ".github/workflows/release.yml") -# register_config_root register_config_root() { local project="$1" root="$2" - local file="${project}/mise.toml" + local file="${project}/${MISE_CONFIG_FILE}" - # Both halves below are anchored on the exact formatting mise.root.toml - # ships, and both used to no-op silently when it did not match — an inline - # `config_roots = ["docs"]` left the roots half untouched while the checklist - # half succeeded, and the project shipped a CI matrix of [] that passed green - # while running nothing. Verified rather than assumed, on each half. + # Anchored on the exact formatting mise.root.toml ships, and verified: an + # inline `config_roots = ["docs"]` matches neither awk, and a silent no-op + # here ships a CI matrix of [] that passes green while running nothing. if ! grep -q "^ \"${root}\",\$" "$file"; then awk -v root="$root" ' { print } @@ -28,11 +30,8 @@ register_config_root() { || die "could not register ${root}: no 'config_roots = [' line in ${file} — has it been reformatted?" fi - # the root [tasks.checklist] (pre-push's own gate) must run every config - # root's own checklist, not just docs' — register_config_root is the one - # place every config root passes through, so this stays in lockstep with - # config_roots itself instead of being a second list a later task forgets - # to update. + # The root [tasks.checklist] must run every config root's checklist. Kept + # here, the one place every root passes through, not as a second list. if ! grep -q "\"//${root}:checklist\"" "$file"; then awk -v root="$root" ' /^\[tasks\.checklist\]$/ { in_checklist = 1 } @@ -47,32 +46,23 @@ register_config_root() { || die "could not add ${root} to the root checklist in ${file} — has [tasks.checklist] been reformatted?" fi } -# collect_config_roots -collect_config_roots() { - sed -n '/^config_roots = \[$/,/^\]$/p' "${1}/mise.toml" \ + +config_roots() { + sed -n '/^config_roots = \[$/,/^\]$/p' "${1}/${MISE_CONFIG_FILE}" \ | sed -n 's/^ "\(.*\)",$/\1/p' } -# sync_ci_roots — the ci workflow's matrix input is derived from the -# manifest so the two can never disagree. + sync_ci_roots() { local project="$1" json - json="$(collect_config_roots "$project" | jq -R . | jq -sc .)" + json="$(config_roots "$project" | jq -R . | jq -sc .)" sed -i.bak "s|^ roots: .*| roots: '${json}'|" \ - "${project}/.github/workflows/ci.yml" - rm -f "${project}/.github/workflows/ci.yml.bak" + "${project}/${CI_WORKFLOW}" + rm -f "${project}/${CI_WORKFLOW}.bak" } -# register_image_target — add one entry to the `images` array -# build.yml and release.yml pass to the reusable workflow (ADR-0022). -# -# This replaced a pair of functions that wrote one context/dockerfile pair per -# project: every applied adapter overwrote the previous one, so a project with -# a web and an api application published only whichever was applied last, and -# the other passed CI and was never built at all. -# -# Called after the workspace decision is settled, not during it: an -# application's build context depends on whether it resolves through the -# shared pnpm workspace or owns its manifests, which cmd_new decides only once -# every adapter has been applied. + +# register_image_target — one entry in the `images` array the +# build workflows pass on (ADR-0022). Called after the workspace decision is +# settled, since the build context depends on it. register_image_target() { local project="$1" rel="$2" local name context dockerfile image file current updated @@ -92,8 +82,8 @@ register_image_target() { [ -f "${project}/${dockerfile}" ] \ || die "no Dockerfile at ${dockerfile} to build ${name} from" - for file in "${project}/.github/workflows/build.yml" \ - "${project}/.github/workflows/release.yml"; do + for file in "${BUILD_WORKFLOWS[@]}"; do + file="${project}/${file}" current="$(yq -r '[.jobs[] | select(has("with")) | .with.images] | .[0] // "[]"' "$file")" updated="$(jq -c --arg image "$image" --arg context "$context" \ --arg dockerfile "$dockerfile" \ diff --git a/lib/pnpm.sh b/lib/pnpm.sh index 42abd49..6d564c8 100644 --- a/lib/pnpm.sh +++ b/lib/pnpm.sh @@ -1,63 +1,79 @@ +# ═══════════════════════════════════════════════════════════════════════════ +# Script : lib/pnpm.sh +# Description : The pnpm workspace and the supply-chain policy over it. +# Author : ttncode +# ═══════════════════════════════════════════════════════════════════════════ # shellcheck shell=bash # -# The pnpm workspace, and the supply-chain policy that governs what may be -# installed into it (ADR-0017). +# ADR-0017 governs what may be installed into the workspace. + +WORKSPACE_FILE="pnpm-workspace.yaml" +LOCKFILE="pnpm-lock.yaml" + +# Excluding one batch of too-fresh dependencies can reveal another, so +# record_release_age_exceptions loops — capped, so a different failure cannot +# spin forever. +MAX_RELEASE_AGE_ROUNDS=10 + +RELEASE_AGE_BLOCK_START="# too fresh at generation time" +RELEASE_AGE_BLOCK_END="# end minimumReleaseAgeExclude" + +# What a generator needs relaxed while it runs inside a workspace that is +# already installed, and that cmd_add must strip again on both the success and +# the failure path — a relaxation left behind is the caller's file, permanently +# weakened. +# +# confirmModulesPurge linking a new member makes pnpm purge and relink the +# shared node_modules, which it refuses without a TTY. +# frozenLockfile the generator's own `pnpm install` sees dependencies +# the workspace lockfile has never heard of, and CI=true +# turns frozen on by itself. +# minimumReleaseAge the generator verifies the lockfile it is extending, +# and a dependency published in the last day fails that +# check before scaffold can record it. # -# Split out of lib/project.sh, which had grown to carry this, the config_roots -# manifest, the GitHub account, the project name rule and the git commit — five -# subjects whose only relation was being needed by `scaffold new`. +# In the workspace file rather than the environment because a generator spawns +# pnpm through several processes and npm_config_* does not survive the trip. +PNPM_RELAXATIONS=('confirmModulesPurge: false' 'frozenLockfile: false' 'minimumReleaseAge: 0') -# enable_typescript_workspace -# only called when every application in the project is typescript; sharing -# types across a language boundary is a different problem, solved by openapi. -enable_typescript_workspace() { - local project="$1" +# relax_pnpm_workspace / restore_pnpm_workspace +# Whenever the file exists, not only when a shared workspace does: a generator +# writes into the root lockfile either way. +relax_pnpm_workspace() { + [ -f "$1" ] || return 0 + printf '%s\n' "${PNPM_RELAXATIONS[@]}" >> "$1" +} - mkdir -p "${project}/packages" - mv "${project}/packages-types" "${project}/packages/types" - register_config_root "$project" "packages/types" +restore_pnpm_workspace() { + local line + [ -f "$1" ] || return 0 + for line in "${PNPM_RELAXATIONS[@]}"; do + sed -i "/^${line}\$/d" "$1" + done } -# Not every generator notices the pnpm-workspace.yaml init_project already -# wrote. create-next-app writes its own apps/web/pnpm-lock.yaml and -# pnpm-workspace.yaml, and pnpm's upward search finds the nested one first — -# so the app never resolves as part of the outer workspace, which is fatal for -# a multi-app project and harmless for a standalone one. Drop the strays and -# rebuild one root lockfile. The minimum-release-age relaxation covers this -# pass only; resolve_minimum_release_age still enforces the real default. -sync_workspace_lockfile() { - local project="$1" - find "$project" -mindepth 3 -maxdepth 3 \ - \( -name pnpm-lock.yaml -o -name pnpm-workspace.yaml \) -delete +# app_is_workspace_member — true when rel resolves through the +# shared root install rather than owning a package.json/lockfile. This, not +# whether the command was `new` or `add`, decides which Dockerfile variant an +# app needs and what its build context has to be. +app_is_workspace_member() { + local project="$1" rel="$2" + local workspace_file="${project}/${WORKSPACE_FILE}" glob - pnpm_install "$project" "reconciling the workspace lockfile" -} -# sync_standalone_build_policy -# An app that stands alone rather than joining the workspace (mixed-language) -# resolves its own generator's pnpm-workspace.yaml, if any — the root one, -# carrying ADR-0017's allowBuilds, is never reached: pnpm's upward search -# stops at the first workspace file it finds, and so does the docker build -# context (apps/ only, never the root). Without this, common's baseline -# (unrs-resolver, esbuild, @parcel/watcher) is simply absent for this app, and -# any of it this app needs fails ERR_PNPM_IGNORED_BUILDS the moment nothing -# outside apps/ is there to answer for it. Merge: common wins on a key -# both name, the app's own generator (e.g. create-next-app denying sharp) -# keeps any key only it names. -sync_standalone_build_policy() { - local app="$1" project="$2" - local file="${app}/pnpm-workspace.yaml" + [ -f "$workspace_file" ] || return 1 - [ -f "$file" ] || printf '{}\n' > "$file" + while IFS= read -r glob; do + [ -n "$glob" ] || continue + # shellcheck disable=SC2254 # glob is a pattern by design, not a literal + case "$rel" in $glob) return 0 ;; esac + done < <(yq -r '.packages[]? // ""' "$workspace_file") - yq eval-all --inplace \ - 'select(fileIndex==0).allowBuilds = ((select(fileIndex==0).allowBuilds // {}) * select(fileIndex==1).allowBuilds) | select(fileIndex==0)' \ - "$file" "${project}/pnpm-workspace.yaml" + return 1 } + # pnpm_install # pnpm reports its failures on stdout, so silencing the install leaves a `die` -# that names the step and proves nothing — a CI failure here was unreadable -# until this kept the output. Shown only on failure; a successful install is -# still quiet. +# that names the step and proves nothing. Shown only on failure. pnpm_install() { local dir="$1" what="$2" log status=0 step "$what" @@ -66,9 +82,8 @@ pnpm_install() { ( cd "$dir" # --no-frozen-lockfile because pnpm turns frozen on by itself when CI=true, - # and this install exists precisely to rewrite the lockfile a generator just - # produced. Without it the step is a contradiction that only fails on a - # runner: reconcile the lockfile, but you may not change the lockfile. + # and this install exists precisely to rewrite the lockfile a generator + # just produced. mise exec -- pnpm install \ --no-frozen-lockfile \ --config.confirm-modules-purge=false \ @@ -82,30 +97,40 @@ pnpm_install() { fi rm -f "$log" } -# pnpm re-checks minimum-release-age on every frozen install, not just the -# first, so relaxing it for one call would not hold. Record the too-fresh -# entries in the project's own file instead, leaving the policy live for -# everything it adds later. Excluding one batch can reveal another, so this -# loops — capped, so a different failure cannot spin forever. -# resolve_minimum_release_age [settings-dir] + +# Not every generator notices the workspace file init_project already wrote. +# create-next-app writes its own nested pair, and pnpm's upward search finds +# those first — so the app never resolves as part of the outer workspace. +sync_workspace_lockfile() { + local project="$1" + + find "$project" -mindepth 3 -maxdepth 3 \ + \( -name "$LOCKFILE" -o -name "$WORKSPACE_FILE" \) -delete + + pnpm_install "$project" "reconciling the workspace lockfile" +} + +# record_release_age_exceptions [settings-dir] # Runs the frozen install from and records the exclusions in -# 's pnpm-workspace.yaml, defaulting to the same place. +# 's workspace file, defaulting to the same place. The two differ +# for an app outside a workspace: its contract tasks install from the app, +# which is the only place pnpm resolves its dependencies — the project root +# holds the lockfile but its own package.json names none of them. # -# The two differ for an app outside a workspace: its contract tasks install -# from the app, which is the only place pnpm resolves its dependencies — the -# project root holds the lockfile but its own package.json names none of them, -# so running there found no violation and the app still could not install. -resolve_minimum_release_age() { +# Recorded rather than relaxed: pnpm re-checks minimum-release-age on every +# frozen install, not just the first, so relaxing it for one call would not +# hold. The policy stays live for everything the project adds later. +record_release_age_exceptions() { local project="$1" local settings="${2:-$1}" step "checking $(basename "$project")'s lockfile against the supply-chain policy" - local workspace_file="${settings}/pnpm-workspace.yaml" + local workspace_file="${settings}/${WORKSPACE_FILE}" # Keyed on the lockfile pnpm will actually verify — which for an app outside # a workspace is the root's, found by walking up. - [ -f "${project}/pnpm-lock.yaml" ] || [ -f "${settings}/pnpm-lock.yaml" ] || return 0 + [ -f "${project}/${LOCKFILE}" ] || [ -f "${settings}/${LOCKFILE}" ] || return 0 - local max_rounds=10 round=0 log entries all_entries="" + local round=0 log entries all_entries="" log="$(mktemp)" while true; do @@ -121,10 +146,10 @@ resolve_minimum_release_age() { } round=$((round + 1)) - [ "$round" -le "$max_rounds" ] || { + [ "$round" -le "$MAX_RELEASE_AGE_ROUNDS" ] || { cat "$log" >&2 rm -f "$log" - die "pnpm install still hits new minimum-release-age violations after ${max_rounds} rounds of recording exceptions" + die "pnpm install still hits new minimum-release-age violations after ${MAX_RELEASE_AGE_ROUNDS} rounds of recording exceptions" } entries="$(sed -E 's/\x1b\[[0-9;]*m//g' "$log" | sed -n 's/^ \(.*\) was published.*/\1/p')" @@ -136,50 +161,53 @@ resolve_minimum_release_age() { all_entries="$(printf '%s\n%s\n' "$all_entries" "$entries" | sed '/^$/d' | sort -u)" - # bounded by an explicit start AND end marker, not a delete-to-eof: a - # range open on the end (,$d) would silently swallow anything appended - # after this block by a later step or caller, with nothing printed. - # both markers are always written together below, so the range is + # Bounded by an explicit start AND end marker, not a delete-to-eof: a range + # open on the end (,$d) would silently swallow anything a later step + # appended. Both markers are always written together, so the range is # always well-formed by the time this runs a second time. [ -f "$workspace_file" ] || : > "$workspace_file" - sed -i '/^# too fresh at generation time/,/^# end minimumReleaseAgeExclude$/d' "$workspace_file" + sed -i "/^${RELEASE_AGE_BLOCK_START}/,/^${RELEASE_AGE_BLOCK_END}\$/d" "$workspace_file" { - printf '# too fresh at generation time; pnpm re-checks this on every frozen\n' + printf '%s; pnpm re-checks this on every frozen\n' "$RELEASE_AGE_BLOCK_START" printf '# install forever, not just this one, so it is recorded once here\n' printf '# instead of turned off for every dependency this project adds later.\n' printf 'minimumReleaseAgeExclude:\n' printf '%s\n' "$all_entries" | while IFS= read -r entry; do printf ' - "%s"\n' "$entry"; done - printf '# end minimumReleaseAgeExclude\n' + printf '%s\n' "$RELEASE_AGE_BLOCK_END" } >> "$workspace_file" done } -# app_is_workspace_member — true when rel falls inside -# pnpm-workspace.yaml's packages: globs, i.e. rel resolves through the shared -# root install rather than owning a package.json/lockfile of its own. This is -# what actually decides which Dockerfile variant an app needs (finalize_app_ -# dockerfile) and what its build context has to be — not whether the command -# generating it was `new` or `add`, and not whether every adapter requested -# at `new` time happened to be typescript (cmd_new's all_typescript is just -# how that project arrived at this same state). -app_is_workspace_member() { - local project="$1" rel="$2" - local workspace_file="${project}/pnpm-workspace.yaml" glob - [ -f "$workspace_file" ] || return 1 +# enable_typescript_workspace +# Only called when every application is typescript; sharing types across a +# language boundary is a different problem, solved by openapi. +enable_typescript_workspace() { + local project="$1" - while IFS= read -r glob; do - [ -n "$glob" ] || continue - # shellcheck disable=SC2254 # glob is a pattern by design, not a literal - case "$rel" in $glob) return 0 ;; esac - done < <(yq -r '.packages[]? // ""' "$workspace_file") + mkdir -p "${project}/packages" + mv "${project}/packages-types" "${project}/packages/types" + register_config_root "$project" "packages/types" +} - return 1 +# sync_standalone_build_policy +# pnpm's upward search stops at the first workspace file it finds, and so does +# the docker build context — so a standalone app never reaches the root file +# carrying ADR-0017's allowBuilds. Merged, not copied: common wins on a key both +# name, the app's own generator keeps any key only it names. +sync_standalone_build_policy() { + local app="$1" project="$2" + local file="${app}/${WORKSPACE_FILE}" + + [ -f "$file" ] || printf '{}\n' > "$file" + + yq eval-all --inplace \ + 'select(fileIndex==0).allowBuilds = ((select(fileIndex==0).allowBuilds // {}) * select(fileIndex==1).allowBuilds) | select(fileIndex==0)' \ + "$file" "${project}/${WORKSPACE_FILE}" } -# finalize_app_dockerfile — apply_adapter's flat copy lands -# both Dockerfile and Dockerfile.workspace for any adapter that ships one -# (nestjs, nextjs); exactly one may survive, whichever matches -# app_is_workspace_member, since that's what the standalone Dockerfile's -# assumption of its own lockfile actually depends on. + +# finalize_app_dockerfile — apply_adapter's flat copy lands both +# Dockerfile and Dockerfile.workspace; exactly one may survive, whichever +# app_is_workspace_member matches. finalize_app_dockerfile() { local project="$1" rel="$2" local dir="${project}/${rel}" @@ -195,27 +223,21 @@ finalize_app_dockerfile() { # join_typescript_workspace :... # Every application is TypeScript, so they share one lockfile and one -# node_modules at the project root, and a packages/types can exist between -# them. Lifted out of cmd_new, where it was one arm of an if/else long enough -# that the condition and the consequence never appeared on screen together. +# node_modules at the root, and a packages/types can exist between them. join_typescript_workspace() { local project="$1"; shift enable_typescript_workspace "$project" - # docs ships its own standalone pnpm-workspace.yaml/pnpm-lock.yaml for a - # project with no typescript adapter; here it joins the real workspace - # instead, so its own copies would only sit there unused at best, and shadow - # the root pnpm-workspace.yaml for docs' own tasks at worst. - rm -f "${project}/docs/pnpm-workspace.yaml" "${project}/docs/pnpm-lock.yaml" + # docs ships its own standalone pair for a project with no typescript adapter; + # here they would shadow the root workspace file for docs' own tasks. + rm -f "${project}/docs/${WORKSPACE_FILE}" "${project}/docs/${LOCKFILE}" sync_workspace_lockfile "$project" - resolve_minimum_release_age "$project" + record_release_age_exceptions "$project" # Every application just lost its own package.json and lockfile to the - # workspace above, and apply_adapter's standalone Dockerfile assumed it had - # one. finalize_app_dockerfile swaps in the workspace-flavored Dockerfile - # each typescript adapter ships beside it. + # workspace, which apply_adapter's standalone Dockerfile assumed it had. local pair for pair in "$@"; do finalize_app_dockerfile "$project" "${pair%%:*}" @@ -233,34 +255,25 @@ keep_apps_standalone() { # The packages list goes, but the file stays either way: it carries # ADR-0017's allowBuilds, which applies to any node install here, including # the root package.json a php-only project still needs for commitlint. - # Deleting it left that project with no recorded build-script decision. - yq --inplace 'del(.packages)' "${project}/pnpm-workspace.yaml" + yq --inplace 'del(.packages)' "${project}/${WORKSPACE_FILE}" # The root package.json still needs installing on its own — commitlint backs # lefthook's commit-msg hook, which must work in a php-only project - # (docs/decisions/0007). + # (ADR-0007). That install runs at minimum-release-age=0, so the root's own + # violations surface only in the call after it. pnpm_install "$project" "installing the project root's own tooling dependencies" - # That install ran at full strength (minimum-release-age=0), so a violation - # among commitlint's own dependencies never surfaced — the loop below - # resolves each application's lockfile but never the root's, and the root's - # is the one the commit-msg hook installs from. - resolve_minimum_release_age "$project" - - # The adapter travels with the path because this branch asks about it: only - # a typescript application resolves through a pnpm workspace file, and - # "does it have a package.json" is a different question with a different - # answer (laravel-inertia has one, for vite, and is not typescript). + record_release_age_exceptions "$project" + + # The adapter travels with the path because this branch asks about it. "Does + # it have a package.json" is a different question with a different answer: + # laravel-inertia has one, for vite, and is not typescript. local pair app for pair in "$@"; do app="${pair%%:*}" - # Each application here stands alone, so each owns a lockfile the policy - # will re-check forever. adapter_is_typescript "${pair#*:}" \ && sync_standalone_build_policy "${project}/${app}" "$project" - # The standalone Dockerfile is the one this shape builds from; a - # typescript adapter's workspace-flavored sibling never applies here and - # would otherwise ship unused. finalize_app_dockerfile "$project" "$app" - resolve_minimum_release_age "${project}/${app}" + # Each application here owns a lockfile the policy will re-check forever. + record_release_age_exceptions "${project}/${app}" done } diff --git a/lib/project.sh b/lib/project.sh index d8309b3..26245e0 100644 --- a/lib/project.sh +++ b/lib/project.sh @@ -1,14 +1,50 @@ +# ═══════════════════════════════════════════════════════════════════════════ +# Script : lib/project.sh +# Description : Create a project's skeleton and record what generated it. +# Author : ttncode +# ═══════════════════════════════════════════════════════════════════════════ # shellcheck shell=bash +# Where a generated project records its own origin. Its own file rather than a +# `[vars]` entry: the apps table is a mapping, and mise's vars are flat strings. +SCAFFOLD_MANIFEST=".scaffold.toml" + +# Shared by init_project's die() and the wizard's prompt, so a rejected name +# gets the same sentence either way. +PROJECT_NAME_RULE="a project name must start with a lowercase letter or digit, and may contain only lowercase letters, digits, '.', '_' and '-'" + +# init_project writes this and nothing else has a reason to; mise.toml alone is +# not proof, since any repository can carry one. +PROJECT_MARKER="monorepo_root = true" + +# The first commit is boilerplate, not authored by a person, so it must not +# depend on an ambient git config a CI runner does not have. +PROJECT_COMMIT_NAME="scaffold" +PROJECT_COMMIT_EMAIL="scaffold@scaffold.invalid" + +# Files carrying the `you/` placeholder, alongside every workflow. mise.root.toml +# carries the registry path ([vars] image) and must be substituted before it +# becomes mise.toml. +PROJECT_OWNER_FILES=(compose.yaml install.sh README.md mise.root.toml) + +# Files carrying @PROJECT_NAME@. The image build.yml pushes to and the image +# compose.yaml pulls have to be one string. migrate inherits it later, from +# assemble_compose copying the app image across. +PROJECT_NAME_FILES=( + .github/workflows/build.yml .github/workflows/release.yml + docs/.vitepress/config.ts docs/index.md compose.yaml install.sh README.md +) + +# The one place the name is read rather than resolved: a browser tab and a page +# heading, which want the capital project_name_is_usable forbids. +PROJECT_TITLE_FILES=(docs/.vitepress/config.ts docs/index.md README.md) + # The account owning the generated workflows' `uses:` and image refs. Dies -# rather than shipping `you/`, which fails only on the first push. Detection is -# announced on stderr for the same reason: a wrong account produces workflows -# that look fine until GitHub rejects them. +# rather than shipping `you/`, which fails only on the first push. # # `gh api user`, not `gh auth status`: the former reports who the token belongs -# to, the latter reports what login recorded and goes stale after a rename. -# Observed disagreeing here — status said `ttndevfullstack`, the token -# resolved to `ttncode`. +# to, the latter what login recorded, which goes stale after a rename. Seen +# disagreeing here. resolve_github_owner() { local owner="${SCAFFOLD_GITHUB_OWNER:-}" source="" @@ -35,19 +71,11 @@ resolve_github_owner() { printf '%s' "$owner" } -# PROJECT_NAME_RULE — what a usable project name has to satisfy, in the -# user's terms. Shared by init_project's die() and the wizard's prompt -# (lib/tui.sh) so a rejected name gets the same sentence either way. -PROJECT_NAME_RULE="a project name must start with a lowercase letter or digit, and may contain only lowercase letters, digits, '.', '_' and '-'" - # project_name_is_usable -# The name is substituted into `sed s|@PROJECT_NAME@|...|` and into the image -# reference in the generated workflows. A `|` would close the sed expression -# early and a `&` would expand to the whole match, so an unchecked name can -# rewrite the file it is being written into. The same characters are illegal -# in an OCI image name, so one rule covers both: lowercase, digits, and -# separators, starting alphanumeric. Called from init_project (the flags and -# `scaffold add`) and from the wizard's name prompt, so the two cannot drift. +# The name goes into `sed s|@PROJECT_NAME@|...|`, where a `|` closes the +# expression early and a `&` expands to the whole match — an unchecked name can +# rewrite the file it is written into. The same characters are illegal in an OCI +# image name, so one rule covers both. project_name_is_usable() { local name="$1" @@ -61,14 +89,10 @@ project_name_is_usable() { } # scaffold_version — which toolbox produced a given project, in one string. -# -# `git describe` against this checkout rather than a VERSION file: every -# install of this toolbox is a clone, and a file is a second copy of the same -# fact that goes stale the first time someone forgets to bump it. `--dirty` -# is the point as much as the tag is — a project generated from uncommitted -# edits cannot be reproduced from any commit, and the string has to say so. -# Printed without a trailing newline so a caller that records it into a file -# decides its own framing. +# `git describe`, not a VERSION file: every install of this toolbox is a clone, +# and a file goes stale the first time someone forgets to bump it. `--dirty` is +# the point as much as the tag — a project generated from uncommitted edits +# cannot be reproduced from any commit, and the string has to say so. scaffold_version() { local version version="$(git -C "$SCAFFOLD_ROOT" describe --tags --always --dirty 2>/dev/null)" \ @@ -76,21 +100,18 @@ scaffold_version() { printf '%s' "$version" } -# SCAFFOLD_MANIFEST — the file a generated project records its own origin in. -# A file of its own rather than another `[vars]` entry in mise.toml: the apps -# table is a mapping, and mise's vars are flat strings. -SCAFFOLD_MANIFEST=".scaffold.toml" +is_scaffold_project() { + [ -f "${1}/mise.toml" ] && grep -q "^${PROJECT_MARKER}\$" "${1}/mise.toml" +} # init_scaffold_manifest # Without this a generated project has no record of what produced it, and -# `scaffold update` has no "since when" to diff against — which is the state -# every project generated before this one is stuck in. +# `scaffold update` has no "since when" to diff against. init_scaffold_manifest() { local project="$1" - # A heredoc, not a run of printf: the prose is full of backticks, which the - # linter reads inside single quotes as a command substitution somebody - # forgot to escape. + # A heredoc, not printf: the prose is full of backticks, which shellcheck + # reads inside single quotes as an unescaped command substitution. cat > "${project}/${SCAFFOLD_MANIFEST}" < record_scaffold_app() { local project="$1" rel="$2" adapter="$3" local file="${project}/${SCAFFOLD_MANIFEST}" @@ -120,15 +140,16 @@ record_scaffold_app() { printf '"%s" = "%s"\n' "$rel" "$adapter" >> "$file" } -# is_scaffold_project -# The marker init_project writes and nothing else has a reason to. mise.toml -# alone is not proof — any repository can carry one. Was written out three -# times in `scaffold` before the wizard needed a fourth. -is_scaffold_project() { - [ -f "${1}/mise.toml" ] && grep -q '^monorepo_root = true$' "${1}/mise.toml" +substitute_in_files() { + local expression="$1"; shift + local file + + for file in "$@"; do + sed -i.bak "$expression" "$file" + rm -f "${file}.bak" + done } -# init_project init_project() { local dir="$1" name="$2" @@ -140,89 +161,42 @@ init_project() { owner="$(resolve_github_owner)" mkdir -p "$dir" - # from here on this run owns $dir; a later step failing must remove it, not - # leave debris behind the overwrite guard above. $dir's value is fixed now - # and baked into the trap command so it survives after this function - # returns and its own local goes away; $? stays deferred to fire time. + # From here on this run owns $dir; a later step failing must remove it, not + # leave debris behind the overwrite guard above. $dir is baked into the trap + # command so it survives this function's locals going away; $? stays deferred. # shellcheck disable=SC2064 # $dir expanding now is intentional; $? is escaped and still deferred trap "cmd_new_cleanup $(printf '%q' "$dir") \"\$?\"" EXIT git -C "$dir" init --initial-branch=main --quiet cp -R "${SCAFFOLD_ROOT}/common/." "${dir}/" - # cp -R preserves common/install.sh's committed executable bit, but that - # depends on the source checkout's own mode surviving clone/checkout - # (e.g. core.fileMode); set it explicitly so a generated project's - # install.sh runs regardless of how this toolbox itself was checked out. + # cp -R preserves the committed executable bit, but that depends on the + # source checkout's own mode surviving clone/checkout (e.g. core.fileMode). chmod +x "${dir}/install.sh" - # The workflows and image ref carry the placeholder account as `you/`; - # CODEOWNERS carries it as `@you`, which the first pattern does not match — - # so it used to ship untouched, and SECURITY.md points vulnerability reports - # at whoever CODEOWNERS names. GitHub treats an unresolvable owner as a - # syntax error, making the security contact unreachable. - # mise.root.toml is in this list because it carries the registry path every - # application publishes under ([vars] image) — and it has to be substituted - # here, before it becomes mise.toml a few lines below. - local wf - for wf in "${dir}/.github/workflows/"*.yml "${dir}/compose.yaml" "${dir}/install.sh" \ - "${dir}/README.md" "${dir}/mise.root.toml"; do - sed -i.bak "s|you/|${owner}/|g" "$wf" - rm -f "${wf}.bak" - done + local -a owner_files=("${dir}/.github/workflows/"*.yml) + owner_files+=("${PROJECT_OWNER_FILES[@]/#/${dir}/}") + substitute_in_files "s|you/|${owner}/|g" "${owner_files[@]}" - sed -i.bak "s|@you\b|@${owner}|g" "${dir}/CODEOWNERS" - rm -f "${dir}/CODEOWNERS.bak" + # CODEOWNERS carries the placeholder as `@you`, which the pattern above does + # not match. SECURITY.md points vulnerability reports at whoever CODEOWNERS + # names, and GitHub treats an unresolvable owner as a syntax error — an + # untouched file here makes the security contact unreachable. + substitute_in_files "s|@you\b|@${owner}|g" "${dir}/CODEOWNERS" sed "s|@PROJECT_NAME@|${name}|g" "${dir}/mise.root.toml" > "${dir}/mise.toml" rm -f "${dir}/mise.root.toml" - # compose.yaml and install.sh are in this list for the same reason the - # workflows are: the image build.yml pushes to and the image compose.yaml - # pulls have to be one string. They used to ship `CHANGEME/CHANGEME`, which - # made every project's first release unusable — its compose.yaml named an - # image nothing had pushed, so install.sh had to be given a second release - # after a hand-edit. Both values were already known here. - # - # assemble_compose runs after this and copies the app image onto the migrate - # service, so migrate inherits the substitution rather than needing its own. - local file - for file in "${dir}/.github/workflows/build.yml" "${dir}/.github/workflows/release.yml" \ - "${dir}/docs/.vitepress/config.ts" "${dir}/docs/index.md" \ - "${dir}/compose.yaml" "${dir}/install.sh" "${dir}/README.md"; do - sed -i.bak "s|@PROJECT_NAME@|${name}|g" "$file" - rm -f "${file}.bak" - done - - # The docs site is the one place the name is read rather than resolved: a - # browser tab and a page heading. project_name_is_usable forces a lowercase - # first character, because a registry path and a directory name need one — - # so a title taken straight from it reads as a shell argument, not a title. - for file in "${dir}/docs/.vitepress/config.ts" "${dir}/docs/index.md" \ - "${dir}/README.md"; do - sed -i.bak "s|@PROJECT_TITLE@|${name^}|g" "$file" - rm -f "${file}.bak" - done + substitute_in_files "s|@PROJECT_NAME@|${name}|g" "${PROJECT_NAME_FILES[@]/#/${dir}/}" + substitute_in_files "s|@PROJECT_TITLE@|${name^}|g" "${PROJECT_TITLE_FILES[@]/#/${dir}/}" # a config not yet trusted makes mise prompt or refuse instead of working. mise trust -y --quiet -C "$dir" } - - - - - - - - - - - # lock_toolchains -# `mise install` writes a lockfile naming versions but no download URLs when the -# tools were already in the local cache, and CI's `mise install --locked` rejects -# exactly that file. `mise lock` fills in the URLs and checksums. It covers one -# config root, and the root is the only one CI installs from. +# `mise install` writes a lockfile naming versions but no download URLs when +# the tools were already in the local cache, and CI's `mise install --locked` +# rejects exactly that file. `mise lock` fills in the URLs and checksums. lock_toolchains() { # a mise.toml above the new project is neither trusted nor necessarily # parseable, and mise reads it before ours. That breaks locking but not the @@ -233,32 +207,21 @@ lock_toolchains() { finalize_project() { local project="$1" + sync_ci_roots "$project" lock_toolchains "$project" git -C "$project" add -A + # `feat:`, not `chore:`. Release Please hides chore from the changelog and - # cuts nothing for it, so the first push of a new project ran the release - # workflow, found no releasable commit, and finished green with no release — - # leaving install.sh with nothing to download until somebody hand-wrote a - # feat or fix commit. Measured on a real repository: Release succeeded in - # 10 seconds and published nothing. This commit really is the project's - # first feature, and common/.release-please-manifest.json starts at 0.0.0 - # because nothing has been released yet — measured on a real repository, the - # release it then cuts is v1.0.0: release-please treats a feat on a 0.x - # version as the 1.0.0 it was building towards unless told otherwise, and a - # client project's first shipped version being 1.0.0 is the right answer - # anyway. + # cuts nothing for it, so a new project's first push ran the release + # workflow, found no releasable commit and finished green with no release — + # leaving install.sh with nothing to download. This commit really is the + # project's first feature, and the release it cuts from 0.0.0 is v1.0.0. # - # GIT_AUTHOR_*/GIT_COMMITTER_* rather than relying on the caller's git - # config: this commit is boilerplate, not authored by a person, so it has no - # business depending on an ambient identity that a developer machine has and - # a CI runner does not — every caller outside the test suite - # (deploy-check.sh, the adapters workflow) had to work around that gap on - # its own, repeatedly. `-c user.name=` alone isn't enough: these env vars - # outrank `-c` config in git's own precedence, so a caller that happens to - # export one (as this sandbox's shell does) would otherwise still leak - # through. - GIT_AUTHOR_NAME="scaffold" GIT_AUTHOR_EMAIL="scaffold@scaffold.invalid" \ - GIT_COMMITTER_NAME="scaffold" GIT_COMMITTER_EMAIL="scaffold@scaffold.invalid" \ + # GIT_AUTHOR_*/GIT_COMMITTER_* rather than `-c user.name=`: these env vars + # outrank `-c` config in git's own precedence, so a caller that exports one + # would otherwise still leak through. + GIT_AUTHOR_NAME="$PROJECT_COMMIT_NAME" GIT_AUTHOR_EMAIL="$PROJECT_COMMIT_EMAIL" \ + GIT_COMMITTER_NAME="$PROJECT_COMMIT_NAME" GIT_COMMITTER_EMAIL="$PROJECT_COMMIT_EMAIL" \ git -C "$project" commit --quiet -m "feat: scaffold project" } diff --git a/lib/publish.sh b/lib/publish.sh index 9061957..078003a 100644 --- a/lib/publish.sh +++ b/lib/publish.sh @@ -1,50 +1,45 @@ +# ═══════════════════════════════════════════════════════════════════════════ +# Script : lib/publish.sh +# Description : Create the GitHub repository a generated project assumes. +# Author : ttncode +# ═══════════════════════════════════════════════════════════════════════════ # shellcheck shell=bash # -# Creating the GitHub repository a generated project already assumes it has. +# Each step was a hand step in docs/runbook/first-project-walkthrough.md, and +# two of them fail in ways that point somewhere else: # -# Everything here was a step in docs/runbook/first-project-walkthrough.md that -# a person had to get right by hand, and two of them fail in ways that point -# somewhere else: +# - `gh repo create --push` pushes whatever branch is checked out and makes it +# the default, so from a feature branch `main` never reaches the remote and +# CI's `changes` job fails fetching a branch that is not there. +# - Without `can_approve_pull_request_reviews`, Release Please cannot open its +# pull request: "GitHub Actions is not permitted to create or approve pull +# requests", several steps away from the setting that caused it. # -# - `gh repo create --push` pushes whatever branch is checked out and makes -# it the default. Run from a feature branch, `main` never reaches the -# remote, `gh pr create` then refuses with "head branch is the same as the -# base branch", and CI's `changes` job fails fetching a `main` that is not -# there. Two red runs, none of it about the project. -# - Without `can_approve_pull_request_reviews`, Release Please cannot open -# its pull request. Measured on a real repository: the release job failed -# with "GitHub Actions is not permitted to create or approve pull -# requests" — several steps away from the setting that caused it. The -# runbook calls this one "required, not optional". -# -# Not here: GitHub Pages. `app-docs.yml` builds the site and does not deploy -# it, so there is nothing for a Pages setting to serve. +# Not here: GitHub Pages. `app-docs.yml` builds the site and does not deploy it. + +# A repository setting this account's plan does not allow is not a failed +# publish — the caller says what is missing and carries on. +PUBLISH_UNSUPPORTED=2 # repo_slug — `/`, taken from the registry path the -# project already publishes under. The whole project assumes its repository is -# named after its own directory: compose.yaml's image, install.sh's RepoUrl and -# the build workflows all carry that pair. Deriving the repository from the same -# value is what makes that assumption true instead of hopeful. +# project already publishes under. compose.yaml's image, install.sh's RepoUrl +# and the build workflows all carry that same pair, so deriving the repository +# from it is what makes their assumption true rather than hopeful. repo_slug() { local image; image="$(project_image_base "$1")" printf '%s' "${image#ghcr.io/}" } -# gh_repo_exists gh_repo_exists() { gh repo view "$1" --json name >/dev/null 2>&1 } -# create_repo create_repo() { local project="$1" slug="$2" visibility="$3" - # One `gh` call doing three things — create, add the remote, push — and a - # failure in the second or third leaves the first behind. Observed: adding - # the remote failed and the repository existed anyway, so the next run - # reported "already exists" about a repository this command had just made. - # That path is now idempotent rather than surprising, but the message has to - # say what may be out there. + # One `gh` call doing three things — create, add the remote, push — so a + # failure in the second or third leaves the first behind. Everything here is + # idempotent, but the message has to say what may already be out there. gh repo create "$slug" "--${visibility}" --source "$project" \ --remote origin --push >/dev/null \ || die "could not finish creating ${slug} — it may exist on GitHub already, with no remote or no branch pushed. Check it, then run this again: everything here is idempotent." @@ -61,23 +56,21 @@ allow_actions_to_open_pull_requests() { || die "could not allow Actions to open pull requests on ${1} — Release Please will not be able to open its release pull request" } -# protect_main -# ADR-0004's fourth guardrail, and the only one that is a repository setting -# rather than a file: without it the other three turn red without blocking -# anything. -# -# No required status checks. A ruleset names them literally, and this -# project's are `ci (apps/api)`, one per config root — a list that differs per -# project and changes whenever an application is added. Requiring a pull -# request and refusing force-pushes is the part that generalises; naming -# checks is left to whoever knows the project. +main_is_protected() { + local rulesets + rulesets="$(gh api "repos/${1}/rulesets" --jq '.[].name' 2>/dev/null)" || return 1 + grep -qx main <<<"$rulesets" +} + +# protect_main — ADR-0004's fourth guardrail, and the only one that is a +# repository setting rather than a file: without it the other three turn red +# without blocking anything. Returns PUBLISH_UNSUPPORTED on a free account's +# private repository, which answers 403 "Upgrade to GitHub Pro". # -# Three outcomes, not two. Measured against a real repository: a private -# repository on a free account answers 403 "Upgrade to GitHub Pro or make this -# repository public to enable this feature". Treating that as fatal would make -# the whole command unusable for exactly the accounts that most need the steps -# before it, so it comes back as 2 and the caller says what is missing and -# carries on. Anything else is a real failure. +# No required status checks. A ruleset names them literally, and this project's +# are `ci (apps/api)`, one per config root — a list that differs per project and +# changes whenever an application is added. Requiring a pull request and +# refusing force-pushes is the part that generalises. protect_main() { local slug="$1" response status=0 @@ -109,17 +102,12 @@ EOF [ "$status" -eq 0 ] && return 0 case "$response" in - *"Upgrade to GitHub Pro"*) return 2 ;; + *"Upgrade to GitHub Pro"*) return "$PUBLISH_UNSUPPORTED" ;; esac printf '%s\n' "$response" >&2 return 1 } -# main_is_protected -main_is_protected() { - gh api "repos/${1}/rulesets" --jq '.[].name' 2>/dev/null | grep -qx main -} - # enable_secret_scanning # GitHub scans and blocks the push itself. Free on a public repository; on a # private one it needs Advanced Security, which answers 422 — the same shape @@ -139,7 +127,7 @@ EOF [ "$status" -eq 0 ] && return 0 case "$response" in - *"Advanced Security"*|*"not available"*|*"upgrade"*|*"Upgrade"*) return 2 ;; + *"Advanced Security"*|*"not available"*|*"upgrade"*|*"Upgrade"*) return "$PUBLISH_UNSUPPORTED" ;; esac printf '%s\n' "$response" >&2 return 1 @@ -148,8 +136,7 @@ EOF # set_release_secrets # Optional on both sides: the release workflow declares them optional and falls # back to GITHUB_TOKEN. What the fallback costs is a release pull request whose -# checks sit at "Action required" and then expire red — three runs in the -# history that say nothing true about the project. +# checks sit at "Action required" and then expire red. set_release_secrets() { local slug="$1" diff --git a/lib/service.sh b/lib/service.sh index 0ce8afd..2261581 100644 --- a/lib/service.sh +++ b/lib/service.sh @@ -1,5 +1,24 @@ +# ═══════════════════════════════════════════════════════════════════════════ +# Script : lib/service.sh +# Description : Compose services, host ports, and per-framework service drivers. +# Author : ttncode +# ═══════════════════════════════════════════════════════════════════════════ # shellcheck shell=bash +COMPOSE_FILE="compose.yaml" +EXAMPLE_ENV_FILE="example.env" + +COMPOSE_LANES=(prod dev test) + +# Every application listens on this port inside its container; the host port is +# allocated per application from FIRST_APP_PORT upward (ADR-0022). +APP_CONTAINER_PORT=8080 +FIRST_APP_PORT=8080 + +SERVICE_SETUP_ANCHOR="# @SERVICE_SETUP@" + +# ─── services ────────────────────────────────────────────────────────────── + # load_service # Same guard as load_adapter, for the same reason: `source` below executes # whatever it reads, so the name must not be able to leave services/. @@ -23,11 +42,9 @@ load_service() { && [ -n "${SERVICE_IMAGE:-}" ] || return 1 } -# service_compose_key -# The compose service name a kind is published under. An identity mapping -# today, and a function rather than a bare expansion so an unrecognised kind -# fails here instead of writing a service that nothing depends on and nothing -# reports missing. +# service_compose_key — the compose service name a kind publishes under. +# A function, not a bare expansion, so an unrecognised kind fails here instead +# of writing a service nothing depends on and nothing reports missing. service_compose_key() { case "$1" in database) printf 'database\n' ;; @@ -36,70 +53,40 @@ service_compose_key() { esac } -# assemble_compose ... -# The common compose files ship no services at all; each selected service's -# block is merged in per lane, and each application's own service is added by -# add_app_service below. The image is injected here rather than written in a -# fragment so a service's digest lives only in its service.env. -assemble_compose() { - local project="$1"; shift - local service lane file key merged - - for service in "$@"; do - load_service "$service" - key="$(service_compose_key "$SERVICE_KIND")" - - # The fragment has to publish under the key its kind implies, or the - # depends_on below would name a service that is not there. - yq -e ".services.${key} != null" "${SERVICE_DIR}/compose.fragment.yaml" >/dev/null \ - || die "${service}'s compose fragment does not define services.${key}" - - for lane in prod dev test; do - case "$lane" in - prod) file="${project}/compose.yaml" ;; - dev) file="${project}/compose.dev.yaml" ;; - test) file="${project}/compose.test.yaml" ;; - esac +record_services() { + local project="$1" database="$2" cache="$3" + local file="${project}/mise.toml" - merged="$(mktemp)" - # cleaned up on both paths: under `set -e` a yq failure leaves - # immediately and the temporary file survives the run. - if ! yq eval-all 'select(fileIndex==0) * select(fileIndex==1)' \ - "${SERVICE_DIR}/compose.fragment.yaml" \ - "${SERVICE_DIR}/compose.${lane}.fragment.yaml" > "$merged"; then - rm -f "$merged" - die "could not assemble ${service}'s ${lane} block" - fi + sed -i.bak -e "s|@DATABASE@|${database}|" -e "s|@CACHE@|${cache}|" "$file" + rm -f "${file}.bak" - if ! SERVICE_IMAGE="$SERVICE_IMAGE" yq --inplace \ - ".services.${key}.image = strenv(SERVICE_IMAGE)" "$merged"; then - rm -f "$merged" - die "could not set ${service}'s image" - fi + grep -Eq '@DATABASE@|@CACHE@' "$file" \ + && die "could not record the selected services in ${file} — has [vars] been reformatted?" + return 0 +} - if ! yq eval-all --inplace 'select(fileIndex==0) * select(fileIndex==1)' \ - "$file" "$merged"; then - rm -f "$merged" - die "could not merge ${service} into ${file}" - fi - rm -f "$merged" - done +# project_service +# Prints nothing for `none`, so a caller can test the value rather than compare +# it to a word. +project_service() { + local project="$1" key="$2" value - done + value="$(yq -p toml -oy -r ".vars.${key} // \"\"" "${project}/mise.toml" 2>/dev/null || true)" + [ "$value" = "none" ] || [ "$value" = "null" ] && return 0 + printf '%s' "$value" } -# app_service_key — the compose service name for an application, and the -# suffix on its image. The application's own directory name: `scaffold add` -# places an application at any path the caller likes, so a role would not -# answer for apps/worker, and a workspace Dockerfile's `pnpm --filter` -# already binds to this same name. +# ─── applications ────────────────────────────────────────────────────────── + +# app_service_key — the compose service name and image suffix for an +# application. Its own directory name, because `scaffold add` can place one at +# any path and a role would not answer for apps/worker. app_service_key() { basename "$1" } -# app_port_variable — WEB_PORT for apps/web. Same name in example.env -# and in compose.yaml, derived rather than recorded, so the two cannot -# disagree. +# app_port_variable — WEB_PORT for apps/web. The same name in example.env +# and in compose.yaml, derived rather than recorded, so the two cannot disagree. app_port_variable() { local key; key="$(app_service_key "$1")" key="${key//-/_}" @@ -107,34 +94,23 @@ app_port_variable() { printf '%s_PORT' "$(printf '%s' "$key" | tr '[:lower:]' '[:upper:]')" } -# next_app_port — 8080 for the first application, then one more per -# application already published. Allocated rather than fixed per role, because -# `scaffold add` can place any number of applications at any path and a table -# keyed on role runs out at three entries (ADR-0022). -# -# Read off compose.yaml rather than counted in a variable, so `scaffold add` -# months later allocates from the same state `scaffold new` left behind. -# 8079 seeds the list rather than guarding the empty case afterwards: yq's -# `max` over an empty sequence prints nothing at all, which `// 8079` does not -# catch and `$(( + 1 ))` would then read as 1. +# next_app_port — FIRST_APP_PORT, then one more per application +# already published (ADR-0022). Read off compose.yaml, so `scaffold add` months +# later allocates from the state `scaffold new` left behind. Seeded one below +# the first port because yq's `max` over an empty sequence prints nothing at +# all, which `// default` does not catch. next_app_port() { local project="$1" highest - highest="$(yq -r '[8079, (.services[].ports[]? + highest="$(SEED="$((FIRST_APP_PORT - 1))" yq -r '[(env(SEED) | tonumber), (.services[].ports[]? | capture("\{[A-Za-z0-9_]+:-(?P[0-9]+)\}").port | tonumber)] | max' \ - "${project}/compose.yaml")" + "${project}/${COMPOSE_FILE}")" printf '%s' "$((highest + 1))" } -# project_image_base — the registry path this project publishes -# under, read back out of the project rather than recomputed from the owner -# and name, so `scaffold add` in month six lands under the same path as the -# first application did. -# -# mise.toml's [vars] image first, then the build workflow. The fallback is -# what lets `scaffold update` work on a project generated before that variable -# existed — exactly the projects with the most to receive, so refusing them -# would make that command useless on its best cases. build.yml has carried the -# same two values since long before the manifest did. +# project_image_base — the registry path this project publishes under, +# read back out of it so `scaffold add` in month six lands where the first +# application did. The build.yml fallback is what lets `scaffold update` work on +# a project generated before [vars] image existed. project_image_base() { local project="$1" value @@ -148,20 +124,97 @@ project_image_base() { printf '%s' "$value" } +# ─── compose ─────────────────────────────────────────────────────────────── + +# compose_lane_file — compose.yaml is the prod lane; dev and test are +# overlays beside it. +compose_lane_file() { + case "$1" in + prod) printf '%s\n' "$COMPOSE_FILE" ;; + dev|test) printf 'compose.%s.yaml\n' "$1" ;; + *) die "unknown compose lane: ${1}" ;; + esac +} + +# merge_compose_fragment +# Removes the fragment on both paths: under `set -e` a yq failure leaves +# immediately and the temporary file would survive the run. +merge_compose_fragment() { + local file="$1" fragment="$2" what="$3" + + if ! yq eval-all --inplace 'select(fileIndex==0) * select(fileIndex==1)' \ + "$file" "$fragment"; then + rm -f "$fragment" + die "could not merge ${what} into ${file}" + fi + rm -f "$fragment" +} + +# assemble_compose ... +# The common compose files ship no services; each selected service's block is +# merged in per lane. The image is injected here rather than written in a +# fragment so a service's digest lives only in its service.env. +assemble_compose() { + local project="$1"; shift + local service lane file key merged + + for service in "$@"; do + load_service "$service" + key="$(service_compose_key "$SERVICE_KIND")" + + # The fragment has to publish under the key its kind implies, or the + # depends_on in add_app_service would name a service that is not there. + yq -e ".services.${key} != null" "${SERVICE_DIR}/compose.fragment.yaml" >/dev/null \ + || die "${service}'s compose fragment does not define services.${key}" + + for lane in "${COMPOSE_LANES[@]}"; do + file="${project}/$(compose_lane_file "$lane")" + + merged="$(mktemp)" + if ! yq eval-all 'select(fileIndex==0) * select(fileIndex==1)' \ + "${SERVICE_DIR}/compose.fragment.yaml" \ + "${SERVICE_DIR}/compose.${lane}.fragment.yaml" > "$merged"; then + rm -f "$merged" + die "could not assemble ${service}'s ${lane} block" + fi + + if ! SERVICE_IMAGE="$SERVICE_IMAGE" yq --inplace \ + ".services.${key}.image = strenv(SERVICE_IMAGE)" "$merged"; then + rm -f "$merged" + die "could not set ${service}'s image" + fi + + merge_compose_fragment "$file" "$merged" "$service" + done + done +} + +# assemble_example_env ... +# The infrastructure side only. What the application needs is written by that +# service's driver, into the app's own .env.example: DB_CONNECTION is Laravel's +# phrasing and DATABASE_URL is Prisma's for the same server. +assemble_example_env() { + local project="$1"; shift + local service + + for service in "$@"; do + load_service "$service" + [ -f "${SERVICE_DIR}/env.fragment" ] || continue + printf '\n' >> "${project}/${EXAMPLE_ENV_FILE}" + cat "${SERVICE_DIR}/env.fragment" >> "${project}/${EXAMPLE_ENV_FILE}" + done +} + # add_app_service -# One compose service per application (ADR-0022). Before this, compose.yaml -# carried a single `app` service and a project generated with both a web and -# an api application ran only whichever adapter was applied last. -# -# The image is written from the same base build.yml and release.yml get, -# because this is the path they push to: the two cannot be written -# independently without drifting apart. +# One compose service per application (ADR-0022). The image is written from the +# same base the build workflows get, because this is the path they push to: the +# two cannot be written independently without drifting apart. add_app_service() { local project="$1" rel="$2" role="$3" - local file="${project}/compose.yaml" + local file="${project}/${COMPOSE_FILE}" local key port_var port image fragment kind recorded - [ -f "$file" ] || die "no compose.yaml in ${project}" + [ -f "$file" ] || die "no ${COMPOSE_FILE} in ${project}" key="$(app_service_key "$rel")" port_var="$(app_port_variable "$rel")" @@ -173,27 +226,21 @@ add_app_service() { printf 'services:\n' printf ' %s:\n' "$key" # shellcheck disable=SC2016 # ${IMAGE_TAG} and ${_PORT} are compose's - # own interpolation, resolved from .env when the stack starts — expanding - # them here would bake this machine's environment into a client's file. - # Quoted the way the service fragments quote theirs, because yq keeps the - # style it is given and a client reads the merged result. + # own interpolation; expanding them here bakes this machine's environment + # into a client's file. Quoted as the service fragments quote theirs, + # because yq keeps the style it is given. printf ' image: %s:${IMAGE_TAG:-latest}\n' "$image" - # required: false so this validates before a .env exists; install.sh - # always writes one before starting the stack. + # required: false so this validates before a .env exists; install.sh always + # writes one before starting the stack. printf ' env_file:\n - path: .env\n required: false\n' printf ' restart: always\n' # shellcheck disable=SC2016 # same as the image line above - printf " ports:\n - '\${%s:-%s}:8080'\n" "$port_var" "$port" + printf " ports:\n - '\${%s:-%s}:%s'\n" "$port_var" "$port" "$APP_CONTAINER_PORT" } > "$fragment" - if ! yq eval-all --inplace 'select(fileIndex==0) * select(fileIndex==1)' \ - "$file" "$fragment"; then - rm -f "$fragment" - die "could not add the ${key} service to ${file}" - fi - rm -f "$fragment" + merge_compose_fragment "$file" "$fragment" "the ${key} service" - printf '\n%s=%s\n' "$port_var" "$port" >> "${project}/example.env" + printf '\n%s=%s\n' "$port_var" "$port" >> "${project}/${EXAMPLE_ENV_FILE}" # Only an application that opens a connection waits for one. A web # application in a project with a database has no driver and no client, so @@ -214,51 +261,64 @@ add_app_service() { done } -# assemble_example_env ... -# The infrastructure side of a service's configuration. What the application -# itself needs is written by that service's driver, in the application's own -# .env.example, because DB_CONNECTION is Laravel's phrasing and DATABASE_URL -# is Prisma's for the same server. -assemble_example_env() { - local project="$1"; shift - local service +# ─── drivers ─────────────────────────────────────────────────────────────── - for service in "$@"; do - load_service "$service" - [ -f "${SERVICE_DIR}/env.fragment" ] || continue - printf '\n' >> "${project}/example.env" - cat "${SERVICE_DIR}/env.fragment" >> "${project}/example.env" +# write_env_lines ... +# Sets each KEY=value, replacing the key if it is already there. A driver runs +# against an .env.example the adapter shipped, so appending blindly would leave +# two values for one key and let the loser win depending on the reader. +write_env_lines() { + local file="$1"; shift + local line key rendered + + [ -f "$file" ] || : > "$file" + for line in "$@"; do + key="${line%%=*}" + if grep -q "^${key}=" "$file"; then + rendered="$(mktemp)" + # awk, not sed: a value can carry sed's own replacement syntax (&, |) — a + # MongoDB DATABASE_URL's query string does. ENVIRON, not -v, so a + # backslash in the value survives instead of being read as an escape. + if ! KEY="$key" LINE="$line" awk ' + BEGIN { prefix = ENVIRON["KEY"] "=" } + substr($0, 1, length(prefix)) == prefix { print ENVIRON["LINE"]; next } + { print } + ' "$file" > "$rendered"; then + rm -f "$rendered" + die "could not set ${key} in ${file}" + fi + mv "$rendered" "$file" + else + # a file with no trailing newline would otherwise get this key + # concatenated onto the end of the last line + if [ -s "$file" ] && [ -n "$(tail -c1 "$file")" ]; then + printf '\n' >> "$file" + fi + printf '%s\n' "$line" >> "$file" + fi done } -# apply_service_setup -# Replaces the Dockerfile's anchor comment with the concatenated output of -# every selected service's driver. Concatenated rather than substituted per -# service, so `--db mongodb --cache redis` produces two blocks instead of one -# overwriting the other. -# -# Runs against every Dockerfile the adapter shipped, not just one: a -# typescript adapter ships a second, workspace-flavored Dockerfile -# (Dockerfile.workspace) alongside the standalone one, and cmd_new decides -# which of the two survives only after this has already run — both need the -# anchor resolved now, or whichever one is kept later ships the literal -# anchor comment. -apply_service_setup() { +# apply_service_dockerfile +# Concatenated, so `--db mongodb --cache redis` produces two blocks rather than +# one overwriting the other. Both Dockerfile variants get the anchor resolved: +# cmd_new decides which survives only after this runs. +apply_service_dockerfile() { local app="$1" block="$2" local file found=0 for file in "${app}/Dockerfile" "${app}/Dockerfile.workspace"; do [ -f "$file" ] || continue found=1 - grep -q '^# @SERVICE_SETUP@$' "$file" \ + grep -q "^${SERVICE_SETUP_ANCHOR}\$" "$file" \ || die "no @SERVICE_SETUP@ anchor in ${file}" local rendered; rendered="$(mktemp)" # ENVIRON, not -v: awk's -v does C-style escape processing on the assigned # value, so a literal backslash in the block (e.g. \t, \") is consumed - # instead of passed through. ENVIRON does none of that. - block="$block" awk ' - /^# @SERVICE_SETUP@$/ { if (ENVIRON["block"] != "") printf "%s\n", ENVIRON["block"]; next } + # instead of passed through. + block="$block" anchor="$SERVICE_SETUP_ANCHOR" awk ' + $0 == ENVIRON["anchor"] { if (ENVIRON["block"] != "") printf "%s\n", ENVIRON["block"]; next } { print } ' "$file" > "$rendered" mv "$rendered" "$file" @@ -267,30 +327,17 @@ apply_service_setup() { [ "$found" -eq 1 ] || return 0 } -# apply_service_compose_env -# Adds the block to compose.yaml's app service. yq rather than an anchor: the -# app service is generated by assemble_compose from common/compose.yaml, so -# there is a real document to merge into by the time this runs, and a text -# anchor would only be a second way to write YAML. -# -# No -P, unlike merge_lefthook_fragment: that merge takes a fragment file it -# does not control the style of, so an author who wrote it in flow style -# would collapse the whole target document without -P forcing everything back -# to block. The fragment built below is never that — it is this function's -# own printf, always one block-style `KEY: value` line per driver, never a -# flow mapping — so there is nothing here for -P to guard against. Measured -# instead of assumed: merging it in without -P left every byte outside the -# two inserted lines untouched, while -P rewrote nodes this merge never -# touched (unquoted compose.yaml's `- '${APP_PORT:-8080}:8080'`, and expanded -# the postgres healthcheck's flow-style `test: [...]` to block) — a client's -# `prettier --check` happened to accept both spellings, but a merge with no -# business editing those lines should not still be reshaping them. +# apply_service_compose_env +# No -P, unlike merge_lefthook_fragment: that merge takes a fragment file whose +# style it does not control. This one is built below by printf, always one +# block-style `KEY: value` line per driver — and -P rewrites nodes the merge +# never touched. apply_service_compose_env() { local project="$1" service="$2" block="$3" - local file="${project}/compose.yaml" fragment + local file="${project}/${COMPOSE_FILE}" fragment [ -n "$block" ] || return 0 - [ -f "$file" ] || die "no compose.yaml in ${project}" + [ -f "$file" ] || die "no ${COMPOSE_FILE} in ${project}" fragment="$(mktemp)" { @@ -300,55 +347,35 @@ apply_service_compose_env() { printf '%s\n' "$block" | sed 's/^/ /' } > "$fragment" - if ! yq eval-all --inplace 'select(fileIndex==0) * select(fileIndex==1)' \ - "$file" "$fragment"; then - rm -f "$fragment" - die "could not merge the service environment into ${file}" - fi - rm -f "$fragment" + merge_compose_fragment "$file" "$fragment" "the service environment" } # apply_service_compose_service -# Merges a complete `services:` fragment into compose.yaml. The counterpart to -# apply_service_compose_env above for a driver that needs to add an entire -# sibling service (the migrate runner below), not another line under one -# application's own environment: apply_service_compose_env cannot be reused -# for this, it writes under an application's `environment`, and overloading it -# with a second, unrelated merge target does not belong in the same function. +# For a driver needing a whole sibling service (the migrate runner below) rather +# than another line under one application's environment. apply_service_compose_service() { local project="$1" block="$2" - local file="${project}/compose.yaml" fragment + local file="${project}/${COMPOSE_FILE}" fragment [ -n "$block" ] || return 0 - [ -f "$file" ] || die "no compose.yaml in ${project}" + [ -f "$file" ] || die "no ${COMPOSE_FILE} in ${project}" fragment="$(mktemp)" printf '%s\n' "$block" > "$fragment" - if ! yq eval-all --inplace 'select(fileIndex==0) * select(fileIndex==1)' \ - "$file" "$fragment"; then - rm -f "$fragment" - die "could not merge the service into ${file}" - fi - rm -f "$fragment" + merge_compose_fragment "$file" "$fragment" "the service" } -# apply_service_compose_migrate -# Writes compose.yaml's migrate service, behind a profile so it never starts -# with the stack (install.sh runs it explicitly, once, after the stack is -# up). The image is read back off compose.yaml rather than hardcoded, so it -# stays correct however the app's own image line is written; the environment -# is the same block apply_service_compose_env just merged into app, since a -# migration needs the same DB_CONNECTION/DATABASE_URL the application does, -# not a second copy of that decision. An empty command (a project with no -# database, or a cache-only driver) merges nothing — no migrate service is -# not an error. +# apply_service_compose_migrate +# Behind a profile, so it never starts with the stack — install.sh runs it +# explicitly, once, after the stack is up. An empty command (no database, or a +# cache-only driver) merges nothing. apply_service_compose_migrate() { local project="$1" service="$2" env_block="$3" command="$4" - local file="${project}/compose.yaml" image block + local file="${project}/${COMPOSE_FILE}" image block [ -n "$command" ] || return 0 - [ -f "$file" ] || die "no compose.yaml in ${project}" + [ -f "$file" ] || die "no ${COMPOSE_FILE} in ${project}" image="$(yq ".services.\"${service}\".image" "$file")" \ || die "could not read ${service}'s image out of ${file}" @@ -370,170 +397,100 @@ apply_service_compose_migrate() { apply_service_compose_service "$project" "$block" } -# write_env_lines ... -# Sets each KEY=value, replacing the key if it is already there. A driver runs -# against an .env.example the adapter shipped, so appending blindly would -# leave two values for one key and let the loser win depending on the reader. -write_env_lines() { - local file="$1"; shift - local line key rendered +# run_driver_apply +# service_driver_apply runs in its own `bash -e` process, not a subshell: +# `( ... ) || die` makes the subshell the left operand of `||`, and bash disables +# `set -e` inside it, so a driver's unchecked failure would vanish. +# +# die and write_env_lines are shell functions, not exported, so the child needs +# its own copies. The npm_config_* pair is apply_adapter's, for the same reason: +# a driver runs pnpm add, and pnpm turns the frozen lockfile on whenever CI is +# set. +# +# pnpm/node go in by PATH, not `mise exec -C`: this script also calls yq, which +# the project's mise.toml does not pin, and `mise exec` resolves PATH from +# scratch. composer stays ambient either way (ADR-0016). +run_driver_apply() { + local app="$1" project="$2" family="$3" service="$4" driver="$5" + local pnpm_bin node_bin + + pnpm_bin="$(dirname "$(mise which pnpm -C "$app")")" + node_bin="$(dirname "$(mise which node -C "$app")")" + + # Held in a variable so it reaches `bash -c` through `env` intact. Its + # `$1`/`$2` and ${SCAFFOLD_ROOT} are the child's to expand. + # shellcheck disable=SC2016 + local driver_script=' + cd "$1" + . "${SCAFFOLD_ROOT}/lib/log.sh" + . "${SCAFFOLD_ROOT}/lib/service.sh" + . "$2" + service_driver_apply + ' - [ -f "$file" ] || : > "$file" - for line in "$@"; do - key="${line%%=*}" - if grep -q "^${key}=" "$file"; then - rendered="$(mktemp)" - # awk, not sed: a value can carry sed's own replacement syntax (&, |) - # — a MongoDB DATABASE_URL's query string does. ENVIRON, not -v, so a - # backslash in the value survives instead of being read as an escape. - if ! KEY="$key" LINE="$line" awk ' - BEGIN { prefix = ENVIRON["KEY"] "=" } - substr($0, 1, length(prefix)) == prefix { print ENVIRON["LINE"]; next } - { print } - ' "$file" > "$rendered"; then - rm -f "$rendered" - die "could not set ${key} in ${file}" - fi - mv "$rendered" "$file" - else - # a file with no trailing newline would otherwise get this key - # concatenated onto the end of the last line - if [ -s "$file" ] && [ -n "$(tail -c1 "$file")" ]; then - printf '\n' >> "$file" - fi - printf '%s\n' "$line" >> "$file" - fi - done + step "wiring ${service} into $(app_service_key "$app")" + run_quietly "wiring ${service} into $(app_service_key "$app") (the ${family} driver)" \ + env PATH="${pnpm_bin}:${node_bin}:${PATH}" \ + npm_config_frozen_lockfile=false npm_config_verify_deps_before_run=false \ + SCAFFOLD_PROJECT_ROOT="$project" \ + bash -euo pipefail -c "$driver_script" _ "$app" "$driver" +} + +# driver_output — one hook's stdout, sourced in a subshell so a +# driver's parameters do not leak into the next one. +driver_output() { + # shellcheck source=/dev/null # family varies, so the path isn't constant + ( . "$1"; "$2" ) +} + +# resolve_driver — the driver file, by name, or die. +resolve_driver() { + load_service "$2" + local driver="${SERVICE_DIR}/drivers/${1}.sh" + [ -f "$driver" ] || die "${2} has no driver for ${1} — run 'scaffold lint'" + printf '%s' "$driver" } # apply_service_drivers ... -# A service knows how to run a container; a driver knows how one framework -# talks to it. service_driver_apply runs in its own `bash -e` process, not a -# subshell, and not per driver's own parameters leaking into the next one for -# the same reason: `( ... ) || die` makes the subshell the left operand of -# `||`, and bash disables `set -e` for everything inside that — a fallible -# command a driver forgot to check would keep running and its failure would -# vanish. A separate process keeps its own `-e` no matter how this function is -# invoked; a subshell's suppression is enforced only by every driver body -# remembering `|| return 1`, which is what this replaces. +# A service knows how to run a container; a driver knows how one framework talks +# to it. # -# project-root is a caller-supplied argument, not `app`'s ancestor derived by -# counting `..`: cmd_new's apps/ and cmd_add's caller-chosen directory -# nest at different depths, so a driver that needs the project root (a -# pnpm-workspace.yaml edit) cannot recover it from its own cwd. +# project-root is an argument, not `app`'s ancestor counted in `..`: cmd_new's +# apps/ and cmd_add's caller-chosen directory nest at different depths. apply_service_drivers() { local app="$1" project="$2" family="$3"; shift 3 - local service driver block="" env_block="" migrate_block="" rendered - - # web is the presentation tier and takes no driver — the caller decides - # that from ADAPTER_ROLE, so reaching here with a family that has none is a - # wiring mistake, not a supported case. An api/app adapter with no - # ADAPTER_FAMILY set is the same mistake one step later: caught here, by - # name, instead of interpolating a blank into every driver-not-found message - # below. + local service driver rendered + local block="" env_block="" migrate_block="" + + # web is the presentation tier and takes no driver — the caller decides that + # from ADAPTER_ROLE, so reaching here with a family that has none is a wiring + # mistake. Named here instead of interpolating a blank into every + # driver-not-found message below. if [ $# -gt 0 ] && [ -z "$family" ]; then die "${app} has services selected but no driver family — run 'scaffold lint'" fi for service in "$@"; do - load_service "$service" - driver="${SERVICE_DIR}/drivers/${family}.sh" - [ -f "$driver" ] \ - || die "${service} has no driver for ${family} — run 'scaffold lint'" - - # die and write_env_lines are shell functions, not exported, so the new - # process needs its own copies — lib/log.sh and lib/service.sh do nothing - # but define functions when sourced, so re-sourcing them here re-runs - # nothing. SCAFFOLD_ROOT reaches the child because `scaffold` exports it; - # same two npm_config_* variables as apply_adapter's ADAPTER_POST_GENERATE - # call, and for the same reason: service_driver_apply runs pnpm add / - # composer require, and pnpm turns the frozen lockfile on by itself - # whenever CI is set — a driver cannot install what it is adding. - # - # pnpm/node go in by PATH, not `mise exec -C`: this script also calls yq - # (the allowBuilds edit above `pnpm add` in services/shared/nest.sh), - # which the project's own mise.toml does not pin — `mise exec` resolves - # PATH from scratch for the directory it is given, so wrapping the whole - # call in it would satisfy pnpm and lose yq. A bare `pnpm add` here - # resolves ambient, which just linked node_modules against whatever store - # the generator's mise-pinned pnpm used, the same skew apply_adapter fixes. - # composer is left alone either way — not mise-managed (ADR-0016). - local pnpm_bin node_bin - pnpm_bin="$(dirname "$(mise which pnpm -C "$app")")" - node_bin="$(dirname "$(mise which node -C "$app")")" - - # The child's own script, held in a variable so it reaches `bash -c` - # through `env` intact. Its `$1`/`$2` and ${SCAFFOLD_ROOT} are the - # child's to expand, not this shell's, and the two `.` lines source paths - # that vary per service and family. - # shellcheck disable=SC2016 - local driver_script=' - cd "$1" - . "${SCAFFOLD_ROOT}/lib/log.sh" - . "${SCAFFOLD_ROOT}/lib/service.sh" - . "$2" - service_driver_apply - ' + driver="$(resolve_driver "$family" "$service")" + run_driver_apply "$app" "$project" "$family" "$service" "$driver" - step "wiring ${service} into $(app_service_key "$app")" - run_quietly "wiring ${service} into $(app_service_key "$app") (the ${family} driver)" \ - env PATH="${pnpm_bin}:${node_bin}:${PATH}" \ - npm_config_frozen_lockfile=false npm_config_verify_deps_before_run=false \ - SCAFFOLD_PROJECT_ROOT="$project" \ - bash -euo pipefail -c "$driver_script" _ "$app" "$driver" - - # A driver with nothing to add to the Dockerfile (redis's drivers, on - # both families) returns an empty string; appending it anyway spliced a - # blank line into the client's Dockerfile whenever it ran alongside one - # that does have output. - # shellcheck source=/dev/null # family varies, so the path isn't constant - rendered="$( . "$driver"; service_driver_dockerfile )" + # A driver with nothing to contribute returns an empty string; appending it + # anyway splices a blank line into the client's Dockerfile. + rendered="$(driver_output "$driver" service_driver_dockerfile)" [ -n "$rendered" ] && block+="${rendered}"$'\n' - # shellcheck source=/dev/null # family varies, so the path isn't constant - rendered="$( . "$driver"; service_driver_compose_env )" + rendered="$(driver_output "$driver" service_driver_compose_env)" [ -n "$rendered" ] && env_block+="${rendered}"$'\n' - # Only a database driver prints a command here — a cache's returns - # nothing (see services/redis/drivers/*.sh) — so this stays empty for a - # cache-only project and carries the one migration command otherwise. - # shellcheck source=/dev/null # family varies, so the path isn't constant - rendered="$( . "$driver"; service_driver_compose_migrate )" + rendered="$(driver_output "$driver" service_driver_compose_migrate)" [ -n "$rendered" ] && migrate_block+="${rendered}"$'\n' done local key; key="$(app_service_key "$app")" - apply_service_setup "$app" "${block%$'\n'}" + apply_service_dockerfile "$app" "${block%$'\n'}" apply_service_compose_env "$project" "$key" "${env_block%$'\n'}" - # The migrate service runs the first driven application's image — it is the - # one carrying the schema and the migration tool. A project with a second - # backend would need a migrate service per backend; nothing generates that - # shape today (ADR-0022). + # The migrate service runs the first driven application's image — it carries + # the schema and the migration tool. A project with a second backend would + # need a migrate service per backend; nothing generates that shape (ADR-0022). apply_service_compose_migrate "$project" "$key" "${env_block%$'\n'}" "${migrate_block%$'\n'}" } - -# record_services -# mise.root.toml ships the two placeholders; substituting them is the same -# technique as @PROJECT_NAME@ rather than a second way of writing toml. -record_services() { - local project="$1" database="$2" cache="$3" - local file="${project}/mise.toml" - - sed -i.bak -e "s|@DATABASE@|${database}|" -e "s|@CACHE@|${cache}|" "$file" - rm -f "${file}.bak" - - grep -Eq '@DATABASE@|@CACHE@' "$file" \ - && die "could not record the selected services in ${file} — has [vars] been reformatted?" - return 0 -} - -# project_service -# Prints nothing for `none`, and nothing for a project generated before this -# existed, so a caller can test the value rather than compare it to a word. -project_service() { - local project="$1" key="$2" value - - value="$(yq -p toml -oy -r ".vars.${key} // \"\"" "${project}/mise.toml" 2>/dev/null || true)" - [ "$value" = "none" ] || [ "$value" = "null" ] && return 0 - printf '%s' "$value" -} diff --git a/lib/tui.sh b/lib/tui.sh index 2e685b2..e6232fa 100644 --- a/lib/tui.sh +++ b/lib/tui.sh @@ -1,9 +1,14 @@ +# ═══════════════════════════════════════════════════════════════════════════ +# Script : lib/tui.sh +# Description : The interactive wizard's terminal layer: header, prompt, menu. +# Author : ttncode +# ═══════════════════════════════════════════════════════════════════════════ # shellcheck shell=bash -# The interactive wizard's terminal layer. Adapted from -# ~/.dotfiles/scripts/lib/menu.sh (the select loop, the echo/cursor handling) -# and lib/banner.sh (the one-column-short row width), with that menu's -# boolean-per-row selection removed: this is one choice per screen, so -# SELECTED[] becomes a single cursor index and space is not a key. +# +# Adapted from ~/.dotfiles/scripts/lib/menu.sh (the select loop, the +# echo/cursor handling) and lib/banner.sh (the one-column-short row width), +# with that menu's boolean-per-row selection removed: this is one choice per +# screen, so SELECTED[] becomes a single cursor index and space is not a key. BOLD="\033[1m" DIM="\033[2m" @@ -12,13 +17,29 @@ CYAN="\033[36m" RED="\033[31m" RESET="\033[0m" -# tui_begin / tui_end — take and restore the terminal for the wizard's whole -# run, not per screen. -# -# `read -s` only silences the one read it wraps; a key held down keeps sending -# bytes while a redraw is in flight, and the tty echoes them into the middle -# of the menu. Turning echo off once, for the session, is what menu.sh does -# instead. +DEFAULT_TERM_COLS=80 +DEFAULT_TERM_LINES=24 + +# Below this the header collapses to one line: a terminal that short scrolls +# once the header and a question's screen don't both fit, and a scroll breaks +# _tui_render's cursor-up overwrite math. menu.sh's own threshold. +MIN_TERM_LINES_FOR_HEADER=23 + +# An escape sequence (an arrow key) arrives as Esc plus more bytes; a bare Esc +# arrives alone. 50ms, not 10: under autorepeat the rest of a sequence can +# arrive late, and a truncated read reads as a bare Esc — which would cancel +# the wizard mid-scroll. +ESC_SEQUENCE_TIMEOUT=0.05 + +# Autorepeat outruns the redraw loop, so a held key can leave a backlog. +KEY_DRAIN_TIMEOUT=0.001 + +# ─── the terminal session ────────────────────────────────────────────────── + +# tui_begin / tui_end take and restore the terminal for the wizard's whole run, +# not per screen: `read -s` only silences the one read it wraps, and a key held +# down keeps sending bytes while a redraw is in flight, which the tty echoes +# into the middle of the menu. _TUI_STTY_SAVED="" tui_begin() { @@ -35,12 +56,11 @@ tui_begin() { tui_end() { [ -t 0 ] || return 0 - # Autorepeat outruns the redraw loop, so a held key can leave a backlog. - # Drain it here rather than let it spill into whatever the caller reads or + # Drained here rather than left to spill into whatever the caller reads or # prints next. local junk # shellcheck disable=SC2034 # junk is the read target, not read back - while read -rsn1 -t 0.001 junk 2>/dev/null; do :; done + while read -rsn1 -t "$KEY_DRAIN_TIMEOUT" junk 2>/dev/null; do :; done if [ -n "$_TUI_STTY_SAVED" ]; then stty "$_TUI_STTY_SAVED" 2>/dev/null || true _TUI_STTY_SAVED="" @@ -48,31 +68,33 @@ tui_end() { tput cnorm 2>/dev/null || true } -# tui_header — the wizard's title box, printed once by tui_begin and never -# repainted: this is a transcript, not a screen, so nothing below it may ever -# clear or scroll it away. -# -# Box shape, edge colour and the one-column-short width are banner.sh's, -# reimplemented rather than sourced — scaffold has to run standalone on a -# client machine, the same reason the select loop reimplements menu.sh's. -# -# Collapses to one line under menu.sh's own threshold (TERM_LINES < 23): a -# terminal that short scrolls once the header and a question's screen don't -# both fit, and a scroll breaks tui_select's cursor-up overwrite math. +# ─── the header ──────────────────────────────────────────────────────────── + +# The wordmark, in menu.sh's font: ANSI Shadow with its duplicated fourth row +# and trailing shadow row dropped. Written out rather than generated — figlet +# does not ship this font, and a client machine has no figlet at all. +_TUI_LOGO=( + ' ███████╗ ██████╗ █████╗ ███████╗███████╗ ██████╗ ██╗ ██████╗ ' + ' ██╔════╝██╔════╝██╔══██╗██╔════╝██╔════╝██╔═══██╗██║ ██╔══██╗' + ' ███████╗██║ ███████║█████╗ █████╗ ██║ ██║██║ ██║ ██║' + ' ███████║╚██████╗██║ ██║██║ ██║ ╚██████╔╝███████╗██████╔╝' +) +_TUI_LOGO_WIDTH=${#_TUI_LOGO[0]} + +# tui_header — printed once by tui_begin and never repainted: this is a +# transcript, not a screen, so nothing below it may clear or scroll it away. +# The wordmark is width-checked before it is drawn, because below that _tui_fit +# hands back four ellipsised fragments, which reads as damage, not a logo. tui_header() { - local term_lines; term_lines="$(tput lines 2>/dev/null || echo 24)" - if (( term_lines < 23 )); then + local term_lines; term_lines="$(tput lines 2>/dev/null || echo "$DEFAULT_TERM_LINES")" + if (( term_lines < MIN_TERM_LINES_FOR_HEADER )); then echo -e "${BOLD}${GREEN}scaffold — project generator${RESET}" return fi - local cols width; cols="$(tput cols 2>/dev/null || echo 80)" + local cols width; cols="$(tput cols 2>/dev/null || echo "$DEFAULT_TERM_COLS")" width=$(( cols - 1 )) - # Blank row, the wordmark, two-space indent, one hint per line, blank row, - # then a blank line under the box: banner.sh's own layout, followed exactly - # rather than approximated, because the two are meant to be recognisably - # one family. _tui_header_edge '╭' '╮' 'Scaffold' "$width" _tui_header_row '' '' "$width" if (( width - 2 >= _TUI_LOGO_WIDTH )); then @@ -92,28 +114,9 @@ tui_header() { echo } -# The wordmark, in menu.sh's font: ANSI Shadow with its duplicated fourth row -# and its trailing shadow row dropped, which is the same four-row compression -# menu.sh applies to DOTFILE. Written out rather than generated — figlet -# does not ship this font, and a client machine has no figlet at all. -# -# _TUI_LOGO_WIDTH is checked before the rows are drawn because this wordmark -# is wider than DOTFILE's. banner.sh guarantees DOTFILE fits its own minimum -# width; nothing guarantees that here, and _tui_fit would otherwise hand back -# four separate ellipsised fragments, which reads as damage rather than as a -# logo. Below the threshold the box simply carries no wordmark. -_TUI_LOGO=( - ' ███████╗ ██████╗ █████╗ ███████╗███████╗ ██████╗ ██╗ ██████╗ ' - ' ██╔════╝██╔════╝██╔══██╗██╔════╝██╔════╝██╔═══██╗██║ ██╔══██╗' - ' ███████╗██║ ███████║█████╗ █████╗ ██║ ██║██║ ██║ ██║' - ' ███████║╚██████╗██║ ██║██║ ██║ ╚██████╔╝███████╗██████╔╝' -) -_TUI_LOGO_WIDTH=${#_TUI_LOGO[0]} - -# _tui_header_edge