From 39bbc08fbf9c502defc86eb5b3dfd3f62dd120e0 Mon Sep 17 00:00:00 2001 From: iam-truongtrungnghia Date: Wed, 16 Sep 2026 18:09:30 +0700 Subject: [PATCH 01/10] chore: skip the shfmt reformat in git blame --- .git-blame-ignore-revs | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 .git-blame-ignore-revs diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs new file mode 100644 index 0000000..aef1503 --- /dev/null +++ b/.git-blame-ignore-revs @@ -0,0 +1,2 @@ +# style: shfmt -i 2 -ci every shell file +74b123c1d903de65c358e90d5c62b3e2f9477545 From 034137665ce4580f2d2a0569d1ba8ddcddd4e5c1 Mon Sep 17 00:00:00 2001 From: iam-truongtrungnghia Date: Wed, 16 Sep 2026 18:25:36 +0700 Subject: [PATCH 02/10] refactor(lib): trim comments and split long functions in the adapter and lint layer --- lib/adapter.sh | 147 ++++++++++++++++++++++-------------------------- lib/contract.sh | 21 +++---- lib/lint.sh | 129 ++++++++++++++++++++++-------------------- lib/log.sh | 28 ++++----- lib/manifest.sh | 116 ++++++++++++++++++++------------------ 5 files changed, 217 insertions(+), 224 deletions(-) diff --git a/lib/adapter.sh b/lib/adapter.sh index 4fc7b70..3e3fa66 100644 --- a/lib/adapter.sh +++ b/lib/adapter.sh @@ -1,8 +1,4 @@ -# ═══════════════════════════════════════════════════════════════════════════ -# Script : lib/adapter.sh -# Description : Load an adapter and install the application it generates. -# Author : ttncode -# ═══════════════════════════════════════════════════════════════════════════ +# Load an adapter and install the application it generates. # shellcheck shell=bash APPS_DIR="apps" @@ -16,49 +12,50 @@ ADAPTER_OPTIONAL_VARS=( ADAPTER_FAMILY ADAPTER_LIVENESS_PATH ADAPTER_READINESS_PATH ) +# Runs a command in a directory through the app's pinned toolchain. composer +# stays ambient on purpose (ADR-0016). +# shellcheck disable=SC2016 # $1 and $2 are the child's to expand +ADAPTER_TOOLCHAIN_SCRIPT='cd "$1" && mise exec -- bash -c "$2"' + load_adapter() { local -r name="$1" - # `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. + # `source` below executes what it reads, so the name must not leave adapters/. case "$name" in '' | *[!a-z0-9-]* | -*) die "not a usable adapter name: ${name} (run: scaffold list)" ;; esac local -r dir="${SCAFFOLD_ROOT}/adapters/${name}" - [ -d "$dir" ] || die "unknown adapter: ${name} (run: scaffold list)" + [[ -d "$dir" ]] || die "unknown adapter: ${name} (run: scaffold list)" ADAPTER_DIR="$dir" unset -v "${ADAPTER_OPTIONAL_VARS[@]}" # shellcheck source=/dev/null - # `|| 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 - # 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. + # Left to the linter, so a fixture missing them still loads. : "${ADAPTER_TIER:=}" : "${ADAPTER_FAMILY:=}" - # 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 + # Under `set -u` the caller would die mid-loop reading an unset name, hiding + # every good adapter after it. + [[ -n "${ADAPTER_NAME:-}" ]] && [[ -n "${ADAPTER_ROLE:-}" ]] || return 1 } adapter_is_typescript() { ( load_adapter "$1" - [ "${ADAPTER_LANGUAGE:-}" = "typescript" ] + [[ "${ADAPTER_LANGUAGE:-}" == "typescript" ]] ) } role_path() { - case "$1" in - web | api | app) printf '%s/%s\n' "$APPS_DIR" "$1" ;; - *) die "unknown adapter role: ${1}" ;; + local -r role="$1" + + case "$role" in + web | api | app) printf '%s/%s\n' "$APPS_DIR" "$role" ;; + *) die "unknown adapter role: ${role}" ;; esac } @@ -66,23 +63,18 @@ merge_lefthook_fragment() { local -r fragment="$1" project="$2" rel="$3" local rendered - [ -f "$fragment" ] || return 0 + [[ -f "$fragment" ]] || return 0 rendered="$(mktemp)" sed "s|@APP_ROOT@|${rel}/|g" "$fragment" >"$rendered" - # 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. + # The merge is key-wise: without the suffix, two laravel apps' `pint` collapse + # into one and the other app goes unformatted. yq --inplace "(.. | select(has(\"commands\")) | .commands) |= with_entries(.key |= . + \"-${rel//\//-}\")" "$rendered" - # -P (block style) because yq propagates the *fragment's* style to the whole - # 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. + # -P: yq propagates the fragment's style, and an empty `{}` fragment would + # collapse lefthook.yml onto one line and drop its comments. if ! yq eval-all --inplace -P 'select(fileIndex==0) * select(fileIndex==1)' \ "${project}/lefthook.yml" "$rendered"; then rm -f "$rendered" @@ -91,39 +83,32 @@ merge_lefthook_fragment() { rm -f "$rendered" } -# 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. +# pnpm exits 0 on a --filter that matches nothing, so a misnamed package.json +# would surface steps later as a COPY of a node_modules never created. assert_workspace_filter_name() { local -r dest="$1" - [ -f "${ADAPTER_DIR}/Dockerfile.workspace" ] || return 0 + [[ -f "${ADAPTER_DIR}/Dockerfile.workspace" ]] || return 0 local found expected found="$(jq -r '.name' "${dest}/package.json")" expected="$(basename "$dest")" - [ "$found" = "$expected" ] || + [[ "$found" == "$expected" ]] || die "${dest}/package.json is named '${found}', not '${expected}' — Dockerfile.workspace's 'pnpm --filter ${expected}' would match nothing" } -# 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 -r dest="$1" local -r file="${dest}/Dockerfile.workspace" - [ -f "$file" ] || return 0 + [[ -f "$file" ]] || return 0 sed -i.bak "s|@APP_FILTER@|$(basename "$dest")|g" "$file" rm -f "${file}.bak" } -# 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. +# Directories merge rather than replace: the generator already created `src/`, +# and `cp -R src dest/src` would nest it. copy_adapter_files() { local -r dest="$1" local file base dir had_dotglob=0 @@ -131,17 +116,17 @@ copy_adapter_files() { shopt -q dotglob && had_dotglob=1 shopt -s dotglob for file in "${ADAPTER_DIR}"/*; do - [ -f "$file" ] || continue + [[ -f "$file" ]] || continue base="$(basename "$file")" case " ${ADAPTER_INTERNAL_FILES[*]} " in *" ${base} "*) continue ;; esac cp "$file" "${dest}/${base}" done - [ "$had_dotglob" -eq 1 ] || shopt -u dotglob + ((had_dotglob == 1)) || shopt -u dotglob for dir in "${ADAPTER_DIR}"/*/; do - [ -d "$dir" ] || continue + [[ -d "$dir" ]] || continue mkdir -p "${dest}/$(basename "$dir")" cp -R "${dir}." "${dest}/$(basename "$dir")/" done @@ -153,49 +138,53 @@ apply_adapter() { load_adapter "$name" local -r dest="${project}/${rel}" + generate_adapter_app "$name" "$dest" "$rel" + + assert_workspace_filter_name "$dest" + copy_adapter_files "$dest" + substitute_workspace_filter "$dest" + configure_adapter_app "$dest" "$rel" + wire_adapter_services "$dest" "$project" + + register_config_root "$project" "$rel" + merge_lefthook_fragment "${ADAPTER_DIR}/lefthook.fragment.yml" "$project" "$rel" +} + +# CI=true stays, so pnpm replaces node_modules without a TTY; its frozen +# lockfile must not, or `pnpm add` reports success and installs nothing. +generate_adapter_app() { + local -r name="$1" dest="$2" rel="$3" 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" + bash -c "$ADAPTER_TOOLCHAIN_SCRIPT" _ "$parent" "$ADAPTER_GENERATOR" +} - assert_workspace_filter_name "$dest" - copy_adapter_files "$dest" - substitute_workspace_filter "$dest" +# verify-deps off: node_modules is meant to disagree with the lockfile here, +# and `pnpm exec` would run its own install and fail opaquely. +configure_adapter_app() { + local -r dest="$1" rel="$2" - if [ -n "${ADAPTER_POST_GENERATE:-}" ]; then - # 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 + [[ -n "${ADAPTER_POST_GENERATE:-}" ]] || return 0 - # 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 + 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 "$ADAPTER_TOOLCHAIN_SCRIPT" _ "$dest" "$ADAPTER_POST_GENERATE" +} + +# After post-generate, which copies in the .env.example a driver edits. +wire_adapter_services() { + local -r dest="$1" project="$2" + + if [[ "${ADAPTER_ROLE}" != "web" ]] && ((${#SCAFFOLD_SERVICES[@]} > 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. + # Even with no block: a Dockerfile shipping the anchor verbatim fails to build. apply_service_dockerfile "$dest" "" fi - - register_config_root "$project" "$rel" - merge_lefthook_fragment "${ADAPTER_DIR}/lefthook.fragment.yml" "$project" "$rel" } diff --git a/lib/contract.sh b/lib/contract.sh index 62b840e..17cd2a9 100644 --- a/lib/contract.sh +++ b/lib/contract.sh @@ -1,8 +1,4 @@ -# ═══════════════════════════════════════════════════════════════════════════ -# Script : lib/contract.sh -# Description : The contract every adapter and service satisfies (ADR-0011). -# Author : ttncode -# ═══════════════════════════════════════════════════════════════════════════ +# The contract every adapter and service satisfies (ADR-0011). # shellcheck shell=bash # shellcheck disable=SC2034 # all read by lib/lint.sh once sourced @@ -11,13 +7,13 @@ CONTRACT_TASKS=(install format format-fix lint check test build ci-unit checklis READ_ONLY_TASKS=(format lint check) # Catches a read-only task copied from its -fix sibling, not a tool that writes -# by default with no flag saying so. +# by default. WRITING_FLAGS=(--write --fix -w --in-place --overwrite) REQUIRED_ADAPTER_FILES=(adapter.env mise.toml Dockerfile .env.example) -# ADAPTER_GENERATOR and ADAPTER_FAMILY are read mid-generation, not at -# `scaffold lint`; missing, they fail there with `unbound variable`. +# ADAPTER_GENERATOR and ADAPTER_FAMILY are read mid-generation, where a missing +# one dies as `unbound variable`. REQUIRED_ADAPTER_VARS=(ADAPTER_NAME ADAPTER_ROLE ADAPTER_FAMILY ADAPTER_GENERATOR ADAPTER_LIVENESS_PATH) REQUIRED_SERVICE_FILES=( @@ -29,18 +25,17 @@ REQUIRED_SERVICE_FILES=( env.fragment ) -# SERVICE_IMAGE is the one place a service's digest is written; the compose fragments carry no image line. +# The compose fragments carry no image line: the digest lives only in SERVICE_IMAGE. REQUIRED_SERVICE_VARS=(SERVICE_NAME SERVICE_KIND SERVICE_IMAGE) -# Holds the parameterised driver bodies every service sources, not a service. +# 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. +# A cache implements compose_migrate too, printing nothing. REQUIRED_DRIVER_FUNCTIONS=(service_driver_apply service_driver_dockerfile service_driver_compose_env service_driver_compose_migrate) # The web tier opens no connection, so it takes no driver. DRIVEN_ROLES=(api app) -# What cmd_new picks when --db is omitted (ADR-0020); the wizard's default -# ordering reads this too, so a plain Enter cannot drift from it. +# ADR-0020. The wizard orders its default from this too. DEFAULT_DATABASE_SERVICE=mysql diff --git a/lib/lint.sh b/lib/lint.sh index 9cde835..f4f2d37 100644 --- a/lib/lint.sh +++ b/lib/lint.sh @@ -1,68 +1,60 @@ -# ═══════════════════════════════════════════════════════════════════════════ -# Script : lib/lint.sh -# Description : Check every adapter and service against the contract. -# Author : ttncode -# ═══════════════════════════════════════════════════════════════════════════ +# Check every adapter and service against the contract. # shellcheck shell=bash +# +# Every lint_* function prints one line per problem and returns 1 when it found +# any, so a caller runs them all and fails once at the end. -# adapter_env_value — the value of one quoted assignment. adapter_env_value() { - sed -n "s/^${2}=\"\(.*\)\"\$/\1/p" "$1" + local -r file="$1" var="$2" + + sed -n "s/^${var}=\"\(.*\)\"\$/\1/p" "$file" } -# 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. +# Prints the whole table, so a `run` string and a multi-line array both work. task_body() { - awk -v task="$2" ' + local -r file="$1" task="$2" + + awk -v task="$task" ' $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 + # a trailing comment belongs to the next table inside && /^[[:space:]]*#/ { next } inside { print } - ' "$1" + ' "$file" } -# 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. +# Read from the adapters, not listed here, so it cannot go stale. driver_families() { + local -r adapters="$1" local adapter role family local -a families=() - for adapter in "$1"/*/; do - [ -f "${adapter}adapter.env" ] || continue + for adapter in "$adapters"/*/; 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 + [[ -n "$family" ]] || continue case " ${families[*]-} " in *" ${family} "*) ;; *) families+=("$family") ;; esac done - [ "${#families[@]}" -gt 0 ] || return 0 + ((${#families[@]} > 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 -r name="$1" dir="$2" shift 2 local file status=0 for file in "$@"; do - if [ ! -f "${dir}${file}" ]; then + if [[ ! -f "${dir}${file}" ]]; then printf '%s: missing file %s\n' "$name" "$file" status=1 fi @@ -72,7 +64,7 @@ lint_required_files() { lint_adapter_env() { local -r name="$1" file="$2" - local var role value status=0 + local var status=0 for var in "${REQUIRED_ADAPTER_VARS[@]}"; do grep -Eq "^${var}=" "$file" || { @@ -81,23 +73,35 @@ lint_adapter_env() { } 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. + lint_readiness_path_declared "$name" "$file" || status=1 + lint_route_paths "$name" "$file" || status=1 + + return "$status" +} + +# Only a driven role: a web adapter has no connection, and a required readiness +# path would only produce one that returns 200 doing nothing. +lint_readiness_path_declared() { + local -r name="$1" file="$2" + local role + 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 - } - ;; + *" ${role} "*) ;; + *) return 0 ;; 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. + grep -Eq '^ADAPTER_READINESS_PATH=' "$file" && return 0 + printf '%s: adapter.env does not set ADAPTER_READINESS_PATH (required for role %s)\n' "$name" "$role" + return 1 +} + +# An empty path would make the HEALTHCHECK assertion and the deploy gate's curl +# match any probe on localhost:8080. +lint_route_paths() { + local -r name="$1" file="$2" + local var value status=0 + for var in ADAPTER_LIVENESS_PATH ADAPTER_READINESS_PATH; do grep -Eq "^${var}=" "$file" || continue value="$(adapter_env_value "$file" "$var")" @@ -115,16 +119,24 @@ lint_adapter_env() { lint_adapter_tasks() { local -r name="$1" file="$2" - local task body flag status=0 + local task 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 + lint_read_only_tasks "$name" "$file" || status=1 + + return "$status" +} + +lint_read_only_tasks() { + local -r name="$1" file="$2" + local task body flag status=0 + for task in "${READ_ONLY_TASKS[@]}"; do body="$(task_body "$file" "$task")" for flag in "${WRITING_FLAGS[@]}"; do @@ -146,12 +158,12 @@ lint_adapters() { local adapter name status=0 for adapter in "$dir"/*/; do - [ -d "$adapter" ] || continue + [[ -d "$adapter" ]] || continue name="$(basename "$adapter")" 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; } + [[ -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 return "$status" @@ -175,12 +187,10 @@ lint_service_env() { 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. +# A subshell per function, or one family's LARAVEL_* parameters leak into the +# next driver checked. SERVICE_DIR is set first, as load_service sets it: a +# driver reading it unbound would die under `set -u` and report as a missing +# function. lint_driver_functions() { local -r name="$1" family="$2" driver="$3" service_dir="$4" local fn fault status=0 @@ -193,7 +203,7 @@ lint_driver_functions() { . "$driver" declare -F "$fn" >/dev/null } 2>&1)"; then - if [ -n "$fault" ]; 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" @@ -212,7 +222,7 @@ lint_service_drivers() { for family in "$@"; do driver="${service}drivers/${family}.sh" - if [ ! -f "$driver" ]; then + if [[ ! -f "$driver" ]]; then printf '%s: no driver for %s\n' "$name" "$family" status=1 continue @@ -223,9 +233,6 @@ lint_service_drivers() { return "$status" } -# lint_services -# Fails when any service is incomplete, or when a family that takes a driver has -# no driver in some service. lint_services() { local -r dir="$1" adapters="$2" local service name status=0 @@ -234,12 +241,12 @@ lint_services() { mapfile -t families < <(driver_families "$adapters") for service in "$dir"/*/; do - [ -d "$service" ] || continue + [[ -d "$service" ]] || continue name="$(basename "$service")" - [ "$name" = "$SHARED_DRIVERS_DIR" ] && continue + [[ "$name" == "$SHARED_DRIVERS_DIR" ]] && continue lint_required_files "$name" "$service" "${REQUIRED_SERVICE_FILES[@]}" || status=1 - [ -f "${service}service.env" ] && { lint_service_env "$name" "${service}service.env" || 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 diff --git a/lib/log.sh b/lib/log.sh index e36a8ad..4056449 100644 --- a/lib/log.sh +++ b/lib/log.sh @@ -1,8 +1,4 @@ -# ═══════════════════════════════════════════════════════════════════════════ -# Script : lib/log.sh -# Description : Terminal output: messages, step markers and quiet command runs. -# Author : ttncode -# ═══════════════════════════════════════════════════════════════════════════ +# Terminal output: messages, step markers and quiet command runs. # shellcheck shell=bash log() { printf '%s\n' "$*" >&2; } @@ -12,29 +8,29 @@ die() { exit 1 } -# 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 ... -# Captures output and prints it only on failure; SCAFFOLD_VERBOSE=1 passes it -# straight through, for a run that hangs rather than fails. +# Output is shown only on failure; SCAFFOLD_VERBOSE=1 streams it, for a hang. run_quietly() { local -r what="$1" shift local log status=0 - if [ "${SCAFFOLD_VERBOSE:-0}" = 1 ]; then + if [[ "${SCAFFOLD_VERBOSE:-0}" == "1" ]]; then "$@" || die "failed while ${what}" return 0 fi log="$(mktemp)" "$@" >"$log" 2>&1 || status=$? - if [ "$status" -ne 0 ]; then - cat "$log" >&2 - rm -f "$log" - die "failed while ${what}" - fi + ((status == 0)) || die_with_log "$log" "failed while ${what}" + rm -f "$log" +} + +die_with_log() { + local -r log="$1" message="$2" + + cat "$log" >&2 rm -f "$log" + die "$message" } diff --git a/lib/manifest.sh b/lib/manifest.sh index 700e0cd..fb9e2d1 100644 --- a/lib/manifest.sh +++ b/lib/manifest.sh @@ -1,54 +1,56 @@ -# ═══════════════════════════════════════════════════════════════════════════ -# Script : lib/manifest.sh -# Description : One list of config roots and image targets, derived not copied. -# Author : ttncode -# ═══════════════════════════════════════════════════════════════════════════ +# The config roots and image targets, each recorded in one place and derived +# everywhere else. # shellcheck shell=bash -# -# `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") +# ADR-0013: `config_roots` in mise.toml is the manifest. register_config_root() { local -r project="$1" root="$2" local -r file="${project}/${MISE_CONFIG_FILE}" - # 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 } - /^config_roots = \[$/ { printf " \"%s\",\n", root } - ' "$file" >"${file}.tmp" - mv "${file}.tmp" "$file" - grep -q "^ \"${root}\",\$" "$file" || - die "could not register ${root}: no 'config_roots = [' line in ${file} — has it been reformatted?" - fi + add_config_roots_entry "$file" "$root" + add_root_checklist_task "$file" "$root" +} - # 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 } - in_checklist && /^run = \[/ { - sub(/\]$/, ", { task = \"//" root ":checklist\" }]") - in_checklist = 0 - } - { print } - ' "$file" >"${file}.tmp" - mv "${file}.tmp" "$file" - grep -q "\"//${root}:checklist\"" "$file" || - die "could not add ${root} to the root checklist in ${file} — has [tasks.checklist] been reformatted?" - fi +# Anchored on the formatting mise.root.toml ships, and verified afterwards: a +# silent no-op here ships a CI matrix of [] that passes green running nothing. +add_config_roots_entry() { + local -r file="$1" root="$2" + + grep -q "^ \"${root}\",\$" "$file" && return 0 + awk -v root="$root" ' + { print } + /^config_roots = \[$/ { printf " \"%s\",\n", root } + ' "$file" >"${file}.tmp" + mv "${file}.tmp" "$file" + grep -q "^ \"${root}\",\$" "$file" || + die "could not register ${root}: no 'config_roots = [' line in ${file} — has it been reformatted?" +} + +add_root_checklist_task() { + local -r file="$1" root="$2" + + grep -q "\"//${root}:checklist\"" "$file" && return 0 + awk -v root="$root" ' + /^\[tasks\.checklist\]$/ { in_checklist = 1 } + in_checklist && /^run = \[/ { + sub(/\]$/, ", { task = \"//" root ":checklist\" }]") + in_checklist = 0 + } + { print } + ' "$file" >"${file}.tmp" + mv "${file}.tmp" "$file" + grep -q "\"//${root}:checklist\"" "$file" || + die "could not add ${root} to the root checklist in ${file} — has [tasks.checklist] been reformatted?" } config_roots() { - sed -n '/^config_roots = \[$/,/^\]$/p' "${1}/${MISE_CONFIG_FILE}" | + local -r project="$1" + + sed -n '/^config_roots = \[$/,/^\]$/p' "${project}/${MISE_CONFIG_FILE}" | sed -n 's/^ "\(.*\)",$/\1/p' } @@ -61,39 +63,43 @@ sync_ci_roots() { rm -f "${project}/${CI_WORKFLOW}.bak" } -# 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. +# ADR-0022. Call once the workspace shape is settled: the build context depends +# on it. register_image_target() { local -r project="$1" rel="$2" - local name context dockerfile image file current updated + local name context dockerfile image file name="$(app_service_key "$rel")" image="$(project_image_base "$project")-${name}" dockerfile="${rel}/Dockerfile" - # A workspace member has no package.json or lockfile of its own — they live - # at the root — so its Dockerfile's first COPY only resolves from there. + # A workspace member's manifests live at the root. if app_is_workspace_member "$project" "$rel"; then context="." else context="$rel" fi - [ -f "${project}/${dockerfile}" ] || + [[ -f "${project}/${dockerfile}" ]] || die "no Dockerfile at ${dockerfile} to build ${name} from" 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" \ - '. + [{image: $image, context: $context, dockerfile: $dockerfile}]' \ - <<<"$current")" || - die "could not read the images array out of ${file}" - - IMAGES="$updated" yq --inplace \ - '(.jobs[] | select(has("with")) | .with.images) = strenv(IMAGES)' "$file" || - die "could not record ${name}'s image in ${file}" + append_image_target "${project}/${file}" "$name" "$image" "$context" "$dockerfile" done } + +append_image_target() { + local -r file="$1" name="$2" image="$3" context="$4" dockerfile="$5" + local current updated + + current="$(yq -r '[.jobs[] | select(has("with")) | .with.images] | .[0] // "[]"' "$file")" + updated="$(jq -c --arg image "$image" --arg context "$context" \ + --arg dockerfile "$dockerfile" \ + '. + [{image: $image, context: $context, dockerfile: $dockerfile}]' \ + <<<"$current")" || + die "could not read the images array out of ${file}" + + IMAGES="$updated" yq --inplace \ + '(.jobs[] | select(has("with")) | .with.images) = strenv(IMAGES)' "$file" || + die "could not record ${name}'s image in ${file}" +} From 92189333dd40181ff8a1720c26bfb75cbbe528e2 Mon Sep 17 00:00:00 2001 From: iam-truongtrungnghia Date: Wed, 16 Sep 2026 18:25:53 +0700 Subject: [PATCH 03/10] refactor(lib): trim comments and split long functions in pnpm and project --- lib/pnpm.sh | 194 ++++++++++++++++++------------------------------- lib/project.sh | 103 +++++++++++--------------- 2 files changed, 113 insertions(+), 184 deletions(-) diff --git a/lib/pnpm.sh b/lib/pnpm.sh index 3fcf9fe..bf5193e 100644 --- a/lib/pnpm.sh +++ b/lib/pnpm.sh @@ -1,70 +1,54 @@ -# ═══════════════════════════════════════════════════════════════════════════ -# Script : lib/pnpm.sh -# Description : The pnpm workspace and the supply-chain policy over it. -# Author : ttncode -# ═══════════════════════════════════════════════════════════════════════════ +# The pnpm workspace and the supply-chain policy over it (ADR-0017). # shellcheck shell=bash -# -# 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. +# Recording one batch of too-fresh dependencies can reveal another; the cap +# stops a different failure spinning 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. -# -# In the workspace file rather than the environment because a generator spawns -# pnpm through several processes and npm_config_* does not survive the trip. +# Relaxed while a generator runs inside an installed workspace, and stripped on +# both of cmd_add's exit paths. In the file, not the environment: npm_config_* +# does not survive a generator's nested pnpm processes. +# confirmModulesPurge relinking the shared node_modules otherwise wants a TTY +# frozenLockfile CI=true turns it on, and the generator adds dependencies +# minimumReleaseAge a day-old dependency fails before scaffold can record it PNPM_RELAXATIONS=('confirmModulesPurge: false' 'frozenLockfile: false' 'minimumReleaseAge: 0') -# 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. +# Whenever the file exists, not only for a shared workspace: a generator writes +# the root lockfile either way. relax_pnpm_workspace() { - [ -f "$1" ] || return 0 - printf '%s\n' "${PNPM_RELAXATIONS[@]}" >>"$1" + local -r file="$1" + + [[ -f "$file" ]] || return 0 + printf '%s\n' "${PNPM_RELAXATIONS[@]}" >>"$file" } restore_pnpm_workspace() { + local -r file="$1" local line - [ -f "$1" ] || return 0 + + [[ -f "$file" ]] || return 0 for line in "${PNPM_RELAXATIONS[@]}"; do - sed -i "/^${line}\$/d" "$1" + sed -i "/^${line}\$/d" "$file" done } -# 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. +# Membership, not whether the command was `new` or `add`, decides an app's +# Dockerfile variant and build context. app_is_workspace_member() { local -r project="$1" rel="$2" local -r workspace_file="${project}/${WORKSPACE_FILE}" local glob - [ -f "$workspace_file" ] || return 1 + [[ -f "$workspace_file" ]] || return 1 while IFS= read -r glob; do - [ -n "$glob" ] || continue + [[ -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") @@ -72,9 +56,7 @@ app_is_workspace_member() { return 1 } -# pnpm_install -# pnpm reports its failures on stdout, so silencing the install leaves a `die` -# that names the step and proves nothing. Shown only on failure. +# pnpm reports failures on stdout, so the captured log is shown on failure. pnpm_install() { local -r dir="$1" what="$2" local log status=0 @@ -83,26 +65,19 @@ 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. + # This install exists to rewrite the lockfile a generator just produced. mise exec -- pnpm install \ --no-frozen-lockfile \ --config.confirm-modules-purge=false \ --config.minimum-release-age=0 ) >"$log" 2>&1 || status=$? - if [ "$status" -ne 0 ]; then - cat "$log" >&2 - rm -f "$log" - die "pnpm install failed while ${what}" - fi + ((status == 0)) || die_with_log "$log" "pnpm install failed while ${what}" rm -f "$log" } -# 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. +# create-next-app writes its own nested pair, which pnpm's upward search finds +# before the outer workspace. sync_workspace_lockfile() { local -r project="$1" @@ -113,24 +88,18 @@ sync_workspace_lockfile() { } # record_release_age_exceptions [settings-dir] -# Runs the frozen install from and records the exclusions in -# '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. +# An app outside a workspace installs from its own directory, the only place +# pnpm resolves its dependencies, and records into the root's workspace file. # # 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. +# frozen install, and the policy stays live for everything added later. record_release_age_exceptions() { local -r project="$1" local -r settings="${2:-$1}" step "checking $(basename "$project")'s lockfile against the supply-chain policy" local -r 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}/${LOCKFILE}" ] || [ -f "${settings}/${LOCKFILE}" ] || return 0 + [[ -f "${project}/${LOCKFILE}" ]] || [[ -f "${settings}/${LOCKFILE}" ]] || return 0 local round=0 log entries all_entries="" log="$(mktemp)" @@ -141,48 +110,40 @@ record_release_age_exceptions() { return 0 fi - grep -q ERR_PNPM_MINIMUM_RELEASE_AGE_VIOLATION "$log" || { - cat "$log" >&2 - rm -f "$log" - die "pnpm install failed for a reason other than minimum-release-age (see above)" - } + grep -q ERR_PNPM_MINIMUM_RELEASE_AGE_VIOLATION "$log" || + die_with_log "$log" "pnpm install failed for a reason other than minimum-release-age (see above)" round=$((round + 1)) - [ "$round" -le "$MAX_RELEASE_AGE_ROUNDS" ] || { - cat "$log" >&2 - rm -f "$log" - die "pnpm install still hits new minimum-release-age violations after ${MAX_RELEASE_AGE_ROUNDS} rounds of recording exceptions" - } + ((round <= MAX_RELEASE_AGE_ROUNDS)) || + die_with_log "$log" "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')" - [ -n "$entries" ] || { - cat "$log" >&2 - rm -f "$log" - die "pnpm reported a minimum-release-age failure but no entries could be parsed from it (see above)" - } + [[ -n "$entries" ]] || + die_with_log "$log" "pnpm reported a minimum-release-age failure but no entries could be parsed from it (see above)" 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 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 "/^${RELEASE_AGE_BLOCK_START}/,/^${RELEASE_AGE_BLOCK_END}\$/d" "$workspace_file" - { - 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 '%s\n' "$RELEASE_AGE_BLOCK_END" - } >>"$workspace_file" + write_release_age_block "$workspace_file" "$all_entries" done } -# enable_typescript_workspace -# Only called when every application is typescript; sharing types across a -# language boundary is a different problem, solved by openapi. +# Bounded by both markers, not deleted to EOF, so anything appended after the +# block survives a rewrite. +write_release_age_block() { + local -r workspace_file="$1" entries="$2" + + [[ -f "$workspace_file" ]] || : >"$workspace_file" + sed -i "/^${RELEASE_AGE_BLOCK_START}/,/^${RELEASE_AGE_BLOCK_END}\$/d" "$workspace_file" + { + 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' "$entries" | while IFS= read -r entry; do printf ' - "%s"\n' "$entry"; done + printf '%s\n' "$RELEASE_AGE_BLOCK_END" + } >>"$workspace_file" +} + +# Types are shared across a language boundary through openapi instead. enable_typescript_workspace() { local -r project="$1" @@ -191,30 +152,26 @@ enable_typescript_workspace() { register_config_root "$project" "packages/types" } -# 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. +# pnpm's upward search and the docker build context both stop at the app's own +# workspace file, so a standalone app needs allowBuilds merged in. common wins +# on a shared key. sync_standalone_build_policy() { local -r app="$1" project="$2" local -r file="${app}/${WORKSPACE_FILE}" - [ -f "$file" ] || printf '{}\n' >"$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; exactly one may survive, whichever -# app_is_workspace_member matches. +# apply_adapter copies both Dockerfile variants; exactly one survives. finalize_app_dockerfile() { local -r project="$1" rel="$2" local -r dir="${project}/${rel}" - [ -f "${dir}/Dockerfile.workspace" ] || return 0 + [[ -f "${dir}/Dockerfile.workspace" ]] || return 0 if app_is_workspace_member "$project" "$rel"; then mv -f "${dir}/Dockerfile.workspace" "${dir}/Dockerfile" @@ -224,23 +181,18 @@ finalize_app_dockerfile() { } # join_typescript_workspace :... -# Every application is TypeScript, so they share one lockfile and one -# node_modules at the root, and a packages/types can exist between them. join_typescript_workspace() { local -r project="$1" shift enable_typescript_workspace "$project" - # 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. + # docs' standalone pair would shadow the root workspace file for its tasks. rm -f "${project}/docs/${WORKSPACE_FILE}" "${project}/docs/${LOCKFILE}" sync_workspace_lockfile "$project" record_release_age_exceptions "$project" - # Every application just lost its own package.json and lockfile to the - # workspace, which apply_adapter's standalone Dockerfile assumed it had. local pair for pair in "$@"; do finalize_app_dockerfile "$project" "${pair%%:*}" @@ -248,36 +200,28 @@ join_typescript_workspace() { } # keep_apps_standalone :... -# Not every application is TypeScript — or there are none — so each owns its -# manifests and its own lockfile, and there is no shared workspace to join. keep_apps_standalone() { local -r project="$1" shift rm -rf "${project}/packages-types" - # 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. + # The file stays: its allowBuilds covers the root package.json that even a + # php-only project installs, for commitlint (ADR-0007). 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 - # (ADR-0007). That install runs at minimum-release-age=0, so the root's own - # violations surface only in the call after it. + # That install runs at minimum-release-age=0, so the root's own violations + # surface only in the check after it. pnpm_install "$project" "installing the project root's own tooling dependencies" 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%%:*}" + # By adapter, not by package.json: laravel-inertia has one and is not typescript. adapter_is_typescript "${pair#*:}" && sync_standalone_build_policy "${project}/${app}" "$project" finalize_app_dockerfile "$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 e47d5d0..626dd9e 100644 --- a/lib/project.sh +++ b/lib/project.sh @@ -1,72 +1,59 @@ -# ═══════════════════════════════════════════════════════════════════════════ -# Script : lib/project.sh -# Description : Create a project's skeleton and record what generated it. -# Author : ttncode -# ═══════════════════════════════════════════════════════════════════════════ +# Create a project's skeleton and record what generated it. # shellcheck shell=bash -# Its own file rather than a `[vars]` entry: the apps table is a mapping, and -# mise's vars are flat strings. +# Its own file, not `[vars]`: the apps table is a mapping, and mise vars are flat. 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; mise.toml alone is not proof, since any repository -# can carry one. +# mise.toml alone is not proof: any repository can carry one. PROJECT_MARKER="monorepo_root = true" -# Not an ambient git config, which a CI runner does not have. +# A CI runner has no ambient git identity. 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 and must be substituted before it becomes mise.toml. +# Carry the `you/` placeholder, alongside every workflow. 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. 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. +# Display text, which wants the capital project_name_is_usable forbids. PROJECT_TITLE_FILES=(docs/.vitepress/config.ts docs/index.md README.md) -# `gh api user`, not `gh auth status`: the former reports who the token belongs -# to, the latter what login recorded — seen disagreeing after a rename. +# `gh api user`, not `gh auth status`: after a rename, only the former reports +# who the token belongs to. resolve_github_owner() { local owner="${SCAFFOLD_GITHUB_OWNER:-}" source="" - if [ -z "$owner" ] && command -v gh >/dev/null 2>&1; then + if [[ -z "$owner" ]] && command -v gh >/dev/null 2>&1; then owner="$(timeout 10 gh api user --jq .login 2>/dev/null || true)" - [ -n "$owner" ] && source="gh" + [[ -n "$owner" ]] && source="gh" fi - if [ -z "$owner" ]; then + if [[ -z "$owner" ]]; then owner="$(git config --get github.user || true)" - [ -n "$owner" ] && source="git config github.user" + [[ -n "$owner" ]] && source="git config github.user" fi - [ -n "$owner" ] || die "no GitHub account to substitute for 'you/' in the generated workflows — set SCAFFOLD_GITHUB_OWNER, sign in with 'gh auth login', or 'git config --global github.user '" + [[ -n "$owner" ]] || die "no GitHub account to substitute for 'you/' in the generated workflows — set SCAFFOLD_GITHUB_OWNER, sign in with 'gh auth login', or 'git config --global github.user '" - # Interpolated into `sed s|you/|...|`; GNU sed's s///e flag runs the pattern - # space as a shell command, so an owner containing `|` is remote code execution. + # Interpolated into `sed s|you/|...|`, where GNU sed's `e` flag makes a `|` in + # the owner remote code execution. case "$owner" in *[!A-Za-z0-9-]* | -* | *-) die "not a usable GitHub account name: ${owner}" ;; esac - [ -z "$source" ] || warn "using GitHub owner '${owner}' (detected from ${source}) — set SCAFFOLD_GITHUB_OWNER to override" + [[ -z "$source" ]] || warn "using GitHub owner '${owner}' (detected from ${source}) — set SCAFFOLD_GITHUB_OWNER to override" printf '%s' "$owner" } -# The name goes into `sed s|@PROJECT_NAME@|...|`, where `|` closes the -# expression early and `&` expands to the whole match; the same characters are -# illegal in an OCI image name, so one rule covers both. +# The name goes into `sed s|@PROJECT_NAME@|...|`, where `|` and `&` are syntax; +# an OCI image name forbids both anyway. project_name_is_usable() { local -r name="$1" @@ -79,9 +66,8 @@ project_name_is_usable() { esac } -# `git describe`, not a VERSION file: a file goes stale the first time someone -# forgets to bump it. `--dirty` matters as much as the tag — a project generated -# from uncommitted edits cannot be reproduced from any commit. +# `--dirty`: a project generated from uncommitted edits cannot be reproduced +# from any commit. scaffold_version() { local version version="$(git -C "$SCAFFOLD_ROOT" describe --tags --always --dirty 2>/dev/null)" || @@ -90,14 +76,14 @@ scaffold_version() { } is_scaffold_project() { - [ -f "${1}/mise.toml" ] && grep -q "^${PROJECT_MARKER}\$" "${1}/mise.toml" + local -r project="$1" + + [[ -f "${project}/mise.toml" ]] && grep -q "^${PROJECT_MARKER}\$" "${project}/mise.toml" } init_scaffold_manifest() { local -r project="$1" - # 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}" <"${dir}/mise.toml" @@ -172,18 +163,15 @@ init_project() { 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" } -# `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. +# `mise install` from a warm cache writes a lockfile with no URLs, which CI's +# `mise install --locked` rejects. A mise.toml above the project can fail this +# without breaking the project, so it warns. lock_toolchains() { - # A mise.toml above the new project, read before ours, can make this fail - # without breaking the project — so warn and leave it to whoever owns it. - mise lock --quiet -C "$1" >/dev/null || + local -r project="$1" + + mise lock --quiet -C "$project" >/dev/null || warn "could not lock the toolchain — run 'mise lock' before committing mise.lock, or CI's 'mise install --locked' will reject it" } @@ -194,11 +182,8 @@ finalize_project() { lock_toolchains "$project" git -C "$project" add -A - # `feat:`, not `chore:`: Release Please hides chore from the changelog and - # cuts nothing for it, leaving install.sh with no release to download. - # - # GIT_AUTHOR_*/GIT_COMMITTER_* rather than `-c user.name=`: these env vars - # outrank `-c` config, so a caller that exports one would otherwise leak through. + # `feat:`: Release Please cuts no release for `chore:`, and install.sh needs + # one. Env vars, not `-c user.name=`: exported GIT_* would outrank `-c`. 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" From c4cb1edd95c46de00d9275c235f6490d6b2e2dd4 Mon Sep 17 00:00:00 2001 From: iam-truongtrungnghia Date: Wed, 16 Sep 2026 18:51:45 +0700 Subject: [PATCH 04/10] refactor(lib): trim comments and split long functions in services and publish --- lib/publish.sh | 85 ++++------ lib/service.sh | 410 ++++++++++++++++++++++--------------------------- 2 files changed, 212 insertions(+), 283 deletions(-) diff --git a/lib/publish.sh b/lib/publish.sh index 12eb80f..4246f8c 100644 --- a/lib/publish.sh +++ b/lib/publish.sh @@ -1,33 +1,16 @@ -# ═══════════════════════════════════════════════════════════════════════════ -# Script : lib/publish.sh -# Description : Create the GitHub repository a generated project assumes. -# Author : ttncode -# ═══════════════════════════════════════════════════════════════════════════ +# Create the GitHub repository a generated project assumes, with the settings +# no file in the project records (ADR-0024). # shellcheck shell=bash -# -# Each step was a hand step in docs/runbook/first-project-walkthrough.md, 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. -# -# 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. + +# A setting this account's plan does not allow: the caller warns and carries on. PUBLISH_UNSUPPORTED=2 -# repo_slug — `/`, taken from the registry path the -# 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. +# Derived from the registry path compose.yaml, install.sh and the build +# workflows already carry, so their assumption about the repository holds. repo_slug() { + local -r project="$1" local image - image="$(project_image_base "$1")" + image="$(project_image_base "$project")" printf '%s' "${image#ghcr.io/}" } @@ -38,40 +21,33 @@ gh_repo_exists() { create_repo() { local -r project="$1" slug="$2" visibility="$3" - # 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." } -# allow_actions_to_open_pull_requests -# `default_workflow_permissions=read` alongside it, deliberately: the reusable -# workflows each request exactly what they need at the job level, so the -# repository default has no reason to be write. +# Without this, Release Please fails far from the cause: "GitHub Actions is not +# permitted to create or approve pull requests". The default stays read: each +# reusable workflow requests its own permissions. allow_actions_to_open_pull_requests() { - gh api -X PUT "repos/${1}/actions/permissions/workflow" \ + local -r slug="$1" + + gh api -X PUT "repos/${slug}/actions/permissions/workflow" \ -f default_workflow_permissions=read \ -F can_approve_pull_request_reviews=true >/dev/null || - die "could not allow Actions to open pull requests on ${1} — Release Please will not be able to open its release pull request" + die "could not allow Actions to open pull requests on ${slug} — Release Please will not be able to open its release pull request" } main_is_protected() { + local -r slug="$1" local rulesets - rulesets="$(gh api "repos/${1}/rulesets" --jq '.[].name' 2>/dev/null)" || return 1 + rulesets="$(gh api "repos/${slug}/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". -# -# 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. +# ADR-0004. No required status checks: their names (`ci (apps/api)`) differ per +# project and change with every application added. A free account's private +# repository answers 403 "Upgrade to GitHub Pro". protect_main() { local -r slug="$1" local response status=0 @@ -103,7 +79,7 @@ protect_main() { EOF )" || status=$? - [ "$status" -eq 0 ] && return 0 + ((status == 0)) && return 0 case "$response" in *"Upgrade to GitHub Pro"*) return "$PUBLISH_UNSUPPORTED" ;; esac @@ -111,15 +87,13 @@ EOF return 1 } -# 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 -# protect_main handles, and reported the same way. +# A private repository without Advanced Security answers 422. enable_secret_scanning() { + local -r slug="$1" local response status=0 response="$( - gh api -X PATCH "repos/${1}" --input - 2>&1 <<'EOF' + gh api -X PATCH "repos/${slug}" --input - 2>&1 <<'EOF' { "security_and_analysis": { "secret_scanning": { "status": "enabled" }, @@ -129,7 +103,7 @@ enable_secret_scanning() { EOF )" || status=$? - [ "$status" -eq 0 ] && return 0 + ((status == 0)) && return 0 case "$response" in *"Advanced Security"* | *"not available"* | *"upgrade"* | *"Upgrade"*) return "$PUBLISH_UNSUPPORTED" ;; esac @@ -137,17 +111,12 @@ EOF return 1 } -# 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. +# Optional: the release workflow falls back to GITHUB_TOKEN. set_release_secrets() { local -r slug="$1" - [ -n "${RELEASE_APP_ID:-}" ] && [ -n "${RELEASE_APP_PRIVATE_KEY:-}" ] || return 1 + [[ -n "${RELEASE_APP_ID:-}" ]] && [[ -n "${RELEASE_APP_PRIVATE_KEY:-}" ]] || return 1 - # --body reads from the environment rather than argv: a private key in a - # process's arguments is readable by every other user on the host. gh secret set RELEASE_APP_ID --repo "$slug" --body "$RELEASE_APP_ID" >/dev/null || die "could not set RELEASE_APP_ID on ${slug}" gh secret set RELEASE_APP_PRIVATE_KEY --repo "$slug" --body "$RELEASE_APP_PRIVATE_KEY" >/dev/null || diff --git a/lib/service.sh b/lib/service.sh index 6a0609e..5342548 100644 --- a/lib/service.sh +++ b/lib/service.sh @@ -1,8 +1,4 @@ -# ═══════════════════════════════════════════════════════════════════════════ -# Script : lib/service.sh -# Description : Compose services, host ports, and per-framework service drivers. -# Author : ttncode -# ═══════════════════════════════════════════════════════════════════════════ +# Compose services, host ports, and per-framework service drivers. # shellcheck shell=bash COMPOSE_FILE="compose.yaml" @@ -10,18 +6,13 @@ 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). +# ADR-0022: one container port, host ports allocated upward per application. 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/. +# `source` below executes what it reads, so the name must not leave services/. load_service() { local -r name="$1" @@ -30,7 +21,7 @@ load_service() { esac local -r dir="${SCAFFOLD_ROOT}/services/${name}" - [ -d "$dir" ] || die "unknown service: ${name} (run: scaffold list)" + [[ -d "$dir" ]] || die "unknown service: ${name} (run: scaffold list)" # shellcheck disable=SC2034 # read by the caller SERVICE_DIR="$dir" @@ -38,18 +29,19 @@ load_service() { # shellcheck source=/dev/null source "${dir}/service.env" || return 1 - [ -n "${SERVICE_NAME:-}" ] && [ -n "${SERVICE_KIND:-}" ] && - [ -n "${SERVICE_IMAGE:-}" ] || return 1 + [[ -n "${SERVICE_NAME:-}" ]] && [[ -n "${SERVICE_KIND:-}" ]] && + [[ -n "${SERVICE_IMAGE:-}" ]] || return 1 } -# 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. +# A function, so an unknown kind fails here instead of writing a service +# nothing depends on. service_compose_key() { - case "$1" in + local -r kind="$1" + + case "$kind" in database) printf 'database\n' ;; cache) printf 'cache\n' ;; - *) die "unknown service kind: ${1}" ;; + *) die "unknown service kind: ${kind}" ;; esac } @@ -65,42 +57,34 @@ record_services() { return 0 } -# project_service -# Prints nothing for `none`, so a caller can test the value rather than compare -# it to a word. +# Prints nothing for `none`. project_service() { local -r project="$1" key="$2" local value value="$(yq -p toml -oy -r ".vars.${key} // \"\"" "${project}/mise.toml" 2>/dev/null || true)" - [ "$value" = "none" ] || [ "$value" = "null" ] && return 0 + [[ "$value" == "none" ]] || [[ "$value" == "null" ]] && return 0 printf '%s' "$value" } -# ─── 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. +# The directory name, not the role: `scaffold add` can place an app at any path. app_service_key() { basename "$1" } -# 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. +# WEB_PORT for apps/web; derived, so example.env and compose.yaml agree. app_port_variable() { + local -r rel="$1" local key - key="$(app_service_key "$1")" + key="$(app_service_key "$rel")" key="${key//-/_}" key="${key//./_}" printf '%s_PORT' "$(printf '%s' "$key" | tr '[:lower:]' '[:upper:]')" } -# 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. +# Read off compose.yaml, so `scaffold add` continues where `scaffold new` left +# off. Seeded one below the first port: yq's `max` over an empty sequence prints +# nothing, which `// default` does not catch. next_app_port() { local -r project="$1" local highest @@ -110,39 +94,34 @@ next_app_port() { printf '%s' "$((highest + 1))" } -# 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. +# Read back out of the project, so a later `scaffold add` lands where the first +# application did. build.yml is the fallback for projects that predate +# [vars] image. project_image_base() { local -r project="$1" local value value="$(yq -p toml -oy -r '.vars.image // ""' "${project}/mise.toml" 2>/dev/null || true)" - if [ -z "$value" ] || [ "$value" = null ]; then + if [[ -z "$value" ]] || [[ "$value" == "null" ]]; then value="$(grep -oE 'ghcr\.io/[A-Za-z0-9._-]+/[A-Za-z0-9._-]+' \ "${project}/.github/workflows/build.yml" 2>/dev/null | head -1 || true)" fi - [ -n "$value" ] || + [[ -n "$value" ]] || die "cannot tell which registry path ${project} publishes under — neither [vars] image in mise.toml nor a ghcr.io reference in .github/workflows/build.yml" 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 + local -r lane="$1" + + case "$lane" in prod) printf '%s\n' "$COMPOSE_FILE" ;; - dev | test) printf 'compose.%s.yaml\n' "$1" ;; - *) die "unknown compose lane: ${1}" ;; + dev | test) printf 'compose.%s.yaml\n' "$lane" ;; + *) die "unknown compose lane: ${lane}" ;; 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. +# Removes the fragment on both paths, since `die` leaves immediately. merge_compose_fragment() { local -r file="$1" fragment="$2" what="$3" @@ -154,50 +133,49 @@ merge_compose_fragment() { 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 -r project="$1" shift - local service lane file key merged + local service lane key 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. + # add_app_service's depends_on names the key the kind implies. 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" + assemble_service_lane "${project}/$(compose_lane_file "$lane")" "$service" "$key" "$lane" 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. +# The image is injected here, so a service's digest lives only in service.env. +assemble_service_lane() { + local -r file="$1" service="$2" key="$3" lane="$4" + local merged + + 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" +} + +# The infrastructure side only: each driver writes what its framework calls the +# connection into the app's own .env.example. assemble_example_env() { local -r project="$1" shift @@ -205,22 +183,20 @@ assemble_example_env() { for service in "$@"; do load_service "$service" - [ -f "${SERVICE_DIR}/env.fragment" ] || continue + [[ -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). 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. +# ADR-0022: one compose service per application, its image named from the base +# the build workflows push to. add_app_service() { local -r project="$1" rel="$2" role="$3" local -r file="${project}/${COMPOSE_FILE}" - local key port_var port image fragment kind recorded + local key port_var port image fragment - [ -f "$file" ] || die "no ${COMPOSE_FILE} in ${project}" + [[ -f "$file" ]] || die "no ${COMPOSE_FILE} in ${project}" key="$(app_service_key "$rel")" port_var="$(app_port_variable "$rel")" @@ -228,38 +204,42 @@ add_app_service() { image="$(project_image_base "$project")-${key}" fragment="$(mktemp)" - { - printf 'services:\n' - printf ' %s:\n' "$key" - # shellcheck disable=SC2016 # ${IMAGE_TAG} and ${_PORT} are compose's - # 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. - 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}:%s'\n" "$port_var" "$port" "$APP_CONTAINER_PORT" - } >"$fragment" - + app_service_fragment "$key" "$image" "$port_var" "$port" >"$fragment" merge_compose_fragment "$file" "$fragment" "the ${key} service" 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 - # making it wait would only delay it behind a service it never reaches. + depend_on_project_services "$project" "$key" "$role" +} + +app_service_fragment() { + local -r key="$1" image="$2" port_var="$3" port="$4" + + printf 'services:\n' + printf ' %s:\n' "$key" + # shellcheck disable=SC2016 # ${IMAGE_TAG} and ${_PORT} are compose's own interpolation + printf ' image: %s:${IMAGE_TAG:-latest}\n' "$image" + # required: false validates before a .env exists; install.sh writes one first. + 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}:%s'\n" "$port_var" "$port" "$APP_CONTAINER_PORT" +} + +# Only a driven role waits: a web app never connects to the services. +depend_on_project_services() { + local -r project="$1" key="$2" role="$3" + local -r file="${project}/${COMPOSE_FILE}" + local kind recorded dependency + case " ${DRIVEN_ROLES[*]} " in *" ${role} "*) ;; *) return 0 ;; esac - local dependency for kind in database cache; do recorded="$(project_service "$project" "$kind")" - [ -n "$recorded" ] || continue + [[ -n "$recorded" ]] || continue dependency="$(service_compose_key "$kind")" yq --inplace \ ".services.\"${key}\".depends_on.${dependency}.condition = \"service_healthy\"" \ @@ -267,38 +247,21 @@ add_app_service() { done } -# ─── drivers ─────────────────────────────────────────────────────────────── - -# 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. +# Replaces an existing key rather than appending: an adapter's .env.example may +# already set it, and two values let the reader pick the loser. write_env_lines() { local -r file="$1" shift - local line key rendered + local line key - [ -f "$file" ] || : >"$file" + [[ -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" + replace_env_line "$file" "$key" "$line" 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 + # else the key lands on the end of a last line with no newline + if [[ -s "$file" ]] && [[ -n "$(tail -c1 "$file")" ]]; then printf '\n' >>"$file" fi printf '%s\n' "$line" >>"$file" @@ -306,48 +269,64 @@ write_env_lines() { done } -# 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. +# awk, not sed: a value can carry `&` or `|` (a MongoDB DATABASE_URL does). +# ENVIRON, not -v, which would read a backslash as an escape. +replace_env_line() { + local -r file="$1" key="$2" line="$3" + local rendered + + rendered="$(mktemp)" + 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" +} + +# Blocks concatenate, so a database and a cache both land. Both Dockerfile +# variants are resolved: which one survives is decided after this runs. apply_service_dockerfile() { local -r app="$1" local block="$2" local file found=0 for file in "${app}/Dockerfile" "${app}/Dockerfile.workspace"; do - [ -f "$file" ] || continue + [[ -f "$file" ]] || continue found=1 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. - 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" + splice_service_setup "$file" "$block" done - [ "$found" -eq 1 ] || return 0 + ((found == 1)) || return 0 +} + +# ENVIRON, not -v: -v would consume a backslash in the block as an escape. +splice_service_setup() { + local -r file="$1" + local block="$2" rendered + + rendered="$(mktemp)" + 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" } -# 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. +# No -P, unlike merge_lefthook_fragment: this fragment is always block style, +# and -P rewrites nodes the merge never touched. apply_service_compose_env() { local -r project="$1" service="$2" block="$3" local -r file="${project}/${COMPOSE_FILE}" local fragment - [ -n "$block" ] || return 0 - [ -f "$file" ] || die "no ${COMPOSE_FILE} in ${project}" + [[ -n "$block" ]] || return 0 + [[ -f "$file" ]] || die "no ${COMPOSE_FILE} in ${project}" fragment="$(mktemp)" { @@ -360,16 +339,13 @@ apply_service_compose_env() { merge_compose_fragment "$file" "$fragment" "the service environment" } -# apply_service_compose_service -# 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 -r project="$1" block="$2" local -r file="${project}/${COMPOSE_FILE}" local fragment - [ -n "$block" ] || return 0 - [ -f "$file" ] || die "no ${COMPOSE_FILE} in ${project}" + [[ -n "$block" ]] || return 0 + [[ -f "$file" ]] || die "no ${COMPOSE_FILE} in ${project}" fragment="$(mktemp)" printf '%s\n' "$block" >"$fragment" @@ -377,64 +353,55 @@ apply_service_compose_service() { merge_compose_fragment "$file" "$fragment" "the service" } -# 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. +# Behind a profile, so it never starts with the stack; install.sh runs it once, +# after the stack is up. apply_service_compose_migrate() { local -r project="$1" service="$2" env_block="$3" command="$4" local -r file="${project}/${COMPOSE_FILE}" local image block - [ -n "$command" ] || return 0 - [ -f "$file" ] || die "no ${COMPOSE_FILE} in ${project}" + [[ -n "$command" ]] || return 0 + [[ -f "$file" ]] || die "no ${COMPOSE_FILE} in ${project}" image="$(yq ".services.\"${service}\".image" "$file")" || die "could not read ${service}'s image out of ${file}" - [ -n "$image" ] && [ "$image" != null ] || + [[ -n "$image" ]] && [[ "$image" != "null" ]] || die "${file} has no ${service} service to migrate from" - block="$( - printf 'services:\n migrate:\n' - printf ' image: %s\n' "$image" - printf ' env_file:\n - path: .env\n required: false\n' - printf ' profiles:\n - migrate\n' - printf ' %s\n' "$command" - if [ -n "$env_block" ]; then - printf ' environment:\n' - printf '%s\n' "$env_block" | sed 's/^/ /' - fi - )" + block="$(migrate_service_block "$image" "$env_block" "$command")" apply_service_compose_service "$project" "$block" } -# 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. +migrate_service_block() { + local -r image="$1" env_block="$2" command="$3" + + printf 'services:\n migrate:\n' + printf ' image: %s\n' "$image" + printf ' env_file:\n - path: .env\n required: false\n' + printf ' profiles:\n - migrate\n' + printf ' %s\n' "$command" + if [[ -n "$env_block" ]]; then + printf ' environment:\n' + printf '%s\n' "$env_block" | sed 's/^/ /' + fi +} + +# Its own `bash -e` process, not a subshell: `( ... ) || die` disables `set -e` +# inside, and a driver's unchecked failure would vanish. # -# pnpm/node/uv 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). +# Tools go in by PATH, not `mise exec -C`, which resolves PATH from scratch and +# loses yq. The npm_config_* pair is apply_adapter's. run_driver_apply() { local -r app="$1" project="$2" family="$3" service="$4" driver="$5" local pnpm_bin node_bin uv_bin="" pnpm_bin="$(dirname "$(mise which pnpm -C "$app")")" node_bin="$(dirname "$(mise which node -C "$app")")" - # uv is declared only in adapters/flask/mise.toml, not the project root's, so - # resolving it for every family would fail a laravel/nest/nextjs app outright. - [ "$family" = flask ] && uv_bin="$(dirname "$(mise which uv -C "$app")")" + # Only flask pins uv; resolving it elsewhere fails outright. + [[ "$family" == "flask" ]] && uv_bin="$(dirname "$(mise which uv -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 + # shellcheck disable=SC2016 # the child expands $1, $2 and SCAFFOLD_ROOT local -r driver_script=' cd "$1" . "${SCAFFOLD_ROOT}/lib/log.sh" @@ -451,41 +418,36 @@ run_driver_apply() { 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. +# In a subshell, so one driver's parameters do not leak into the next. driver_output() { + local -r driver="$1" hook="$2" + # shellcheck source=/dev/null # family varies, so the path isn't constant ( - . "$1" - "$2" + . "$driver" + "$hook" ) } -# resolve_driver — the driver file, by name, or die. resolve_driver() { - load_service "$2" - local -r driver="${SERVICE_DIR}/drivers/${1}.sh" - [ -f "$driver" ] || die "${2} has no driver for ${1} — run 'scaffold lint'" + local -r family="$1" service="$2" + + load_service "$service" + local -r driver="${SERVICE_DIR}/drivers/${family}.sh" + [[ -f "$driver" ]] || die "${service} has no driver for ${family} — 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. -# -# 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. +# project-root is passed, not counted in `..`: `new` and `add` nest apps at +# different depths. apply_service_drivers() { local -r app="$1" project="$2" family="$3" shift 3 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 + # The caller skips web; an empty family here is a wiring mistake. + if (($# > 0)) && [[ -z "$family" ]]; then die "${app} has services selected but no driver family — run 'scaffold lint'" fi @@ -493,24 +455,22 @@ apply_service_drivers() { driver="$(resolve_driver "$family" "$service")" run_driver_apply "$app" "$project" "$family" "$service" "$driver" - # A driver with nothing to contribute returns an empty string; appending it - # anyway splices a blank line into the client's Dockerfile. + # An empty contribution would splice a blank line into the Dockerfile. rendered="$(driver_output "$driver" service_driver_dockerfile)" - [ -n "$rendered" ] && block+="${rendered}"$'\n' + [[ -n "$rendered" ]] && block+="${rendered}"$'\n' rendered="$(driver_output "$driver" service_driver_compose_env)" - [ -n "$rendered" ] && env_block+="${rendered}"$'\n' + [[ -n "$rendered" ]] && env_block+="${rendered}"$'\n' rendered="$(driver_output "$driver" service_driver_compose_migrate)" - [ -n "$rendered" ] && migrate_block+="${rendered}"$'\n' + [[ -n "$rendered" ]] && migrate_block+="${rendered}"$'\n' done local key key="$(app_service_key "$app")" 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 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). + # Runs the first driven application's image; a second backend would need its + # own migrate service, and nothing generates that shape (ADR-0022). apply_service_compose_migrate "$project" "$key" "${env_block%$'\n'}" "${migrate_block%$'\n'}" } From c3876b4c7891061735e9066254f52f56bc6c3fba Mon Sep 17 00:00:00 2001 From: iam-truongtrungnghia Date: Wed, 16 Sep 2026 18:51:59 +0700 Subject: [PATCH 05/10] refactor(lib): trim comments in the wizard, tui and update layers --- lib/tui.sh | 193 +++++++++++++++++--------------------------------- lib/update.sh | 110 +++++++++------------------- lib/wizard.sh | 92 +++++++++--------------- 3 files changed, 131 insertions(+), 264 deletions(-) diff --git a/lib/tui.sh b/lib/tui.sh index ec4920b..a9c1ec9 100644 --- a/lib/tui.sh +++ b/lib/tui.sh @@ -1,14 +1,8 @@ -# ═══════════════════════════════════════════════════════════════════════════ -# Script : lib/tui.sh -# Description : The interactive wizard's terminal layer: header, prompt, menu. -# Author : ttncode -# ═══════════════════════════════════════════════════════════════════════════ +# The interactive wizard's terminal layer: header, prompt, menu. # shellcheck shell=bash # -# 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 and lib/banner.sh, cut down to +# one choice per screen. BOLD="\033[1m" DIM="\033[2m" @@ -20,59 +14,45 @@ RESET="\033[0m" 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. +# Shorter than this, the header scrolls away and breaks _tui_render's +# cursor-up overwrite, so it collapses to one line. 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. +# 50ms, not 10: under autorepeat an arrow key's tail can arrive late, and a +# truncated sequence reads as a bare Esc that cancels the wizard. 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. +# The terminal is taken for the whole run, not per screen: `read -s` silences +# only its own read, and keys held during a redraw would echo into the menu. _TUI_STTY_SAVED="" tui_begin() { - [ -t 0 ] || return 0 + [[ -t 0 ]] || return 0 _TUI_STTY_SAVED="$(stty -g 2>/dev/null || true)" stty -echo 2>/dev/null || true tput civis 2>/dev/null || true - # Esc and Ctrl-C both have to leave the terminal as they found it; a trap is - # the only thing that fires on both a normal return and a signal. trap 'tui_end' EXIT trap 'tui_end; exit 130' INT TERM tui_header } tui_end() { - [ -t 0 ] || return 0 - # Drained here rather than left to spill into whatever the caller reads or - # prints next. + [[ -t 0 ]] || return 0 local junk # shellcheck disable=SC2034 # junk is the read target, not read back while read -rsn1 -t "$KEY_DRAIN_TIMEOUT" junk 2>/dev/null; do :; done - if [ -n "$_TUI_STTY_SAVED" ]; then + if [[ -n "$_TUI_STTY_SAVED" ]]; then stty "$_TUI_STTY_SAVED" 2>/dev/null || true _TUI_STTY_SAVED="" fi tput cnorm 2>/dev/null || true } -# ─── 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. +# ANSI Shadow, written out: figlet does not ship the font, and a client machine +# has no figlet at all. _TUI_LOGO=( ' ███████╗ ██████╗ █████╗ ███████╗███████╗ ██████╗ ██╗ ██████╗ ' ' ██╔════╝██╔════╝██╔══██╗██╔════╝██╔════╝██╔═══██╗██║ ██╔══██╗' @@ -81,20 +61,18 @@ _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. +# Printed once and never repainted: nothing below may clear it. The wordmark is +# dropped rather than drawn as four ellipsised fragments when it does not fit. tui_header() { local term_lines - term_lines="$(tput lines 2>/dev/null || echo "$DEFAULT_TERM_LINES")" + term_lines="$(tput lines 2>/dev/null || printf '%s\n' "$DEFAULT_TERM_LINES")" if ((term_lines < MIN_TERM_LINES_FOR_HEADER)); then - echo -e "${BOLD}${GREEN}scaffold — project generator${RESET}" + printf '%b\n' "${BOLD}${GREEN}scaffold — project generator${RESET}" return fi local cols width - cols="$(tput cols 2>/dev/null || echo "$DEFAULT_TERM_COLS")" + cols="$(tput cols 2>/dev/null || printf '%s\n' "$DEFAULT_TERM_COLS")" width=$((cols - 1)) _tui_header_edge '╭' '╮' 'Scaffold' "$width" @@ -116,9 +94,6 @@ tui_header() { echo } -# _tui_header_edge