diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 04bff16..8ab18f3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -74,31 +74,3 @@ jobs: # tests/workflows.bats asserts sha-pinned actions, closed permission # sets and shared-repository-only `uses:`. No audit runs on them. - run: mise exec -- zizmor .github/workflows/ - - # The checklist is only worth having if something reads it. This parses the - # headings out of the template itself rather than holding a second copy of - # them, so editing the template changes what is enforced — the convention and - # its enforcement cannot drift apart. Borrowed from immich's auto-close.yml. - pull-request-body: - if: github.event_name == 'pull_request' - runs-on: ubuntu-latest - permissions: - contents: read - timeout-minutes: 5 - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - - env: - BODY: ${{ github.event.pull_request.body }} - run: | - missing="" - while IFS= read -r heading; do - printf '%s\n' "$BODY" | grep -qF "$heading" || missing="${missing}\n ${heading}" - done < <(grep '^## ' .github/pull_request_template.md) - - if [ -n "$missing" ]; then - printf 'pull request body is missing:%b\n' "$missing" >&2 - echo "Keep the template's sections; delete the comments, not the headings." >&2 - exit 1 - fi diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml new file mode 100644 index 0000000..dcb125f --- /dev/null +++ b/.github/workflows/pull-request.yml @@ -0,0 +1,52 @@ +name: Pull request +# Its own workflow, not a job in ci.yml, because of `edited`: a body that fails +# this check is fixed by editing the body, and ci.yml does not run on that — +# so the check stayed red for a fix that had already been made. Putting +# `edited` in ci.yml instead would re-run the integration lane, twenty +# minutes, every time somebody touched a description. +on: + pull_request: + types: [opened, edited, synchronize, reopened] + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: {} + +jobs: + # The checklist is only worth having if something reads it. This parses the + # headings out of the template itself rather than holding a second copy of + # them, so editing the template changes what is enforced — the convention and + # its enforcement cannot drift apart. Borrowed from immich's auto-close.yml. + # + # The job's name is load-bearing: `main`'s branch protection requires a + # check called `pull-request-body`, and a required check that never reports + # blocks every merge forever. Moving the job to this workflow was safe; + # renaming it to `body` at the same time was not, and blocked the pull + # request that made the change. Renaming it means updating the repository + # setting first — there is no trace of that setting in this repository to + # remind anyone, which is ADR-0004's point about the guardrail that is not + # a file. + pull-request-body: + runs-on: ubuntu-latest + permissions: + contents: read + timeout-minutes: 5 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - env: + BODY: ${{ github.event.pull_request.body }} + run: | + missing="" + while IFS= read -r heading; do + printf '%s\n' "$BODY" | grep -qF "$heading" || missing="${missing}\n ${heading}" + done < <(grep '^## ' .github/pull_request_template.md) + + if [ -n "$missing" ]; then + printf 'pull request body is missing:%b\n' "$missing" >&2 + echo "Keep the template's sections; delete the comments, not the headings." >&2 + exit 1 + fi diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c531335..4a5ec67 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -100,6 +100,25 @@ writing decides whether anything has to be done afterwards. Conventional Commits, enforced by lefthook at `commit-msg`. `feat:` and `fix:` move the version of a generated project; `chore:` and `docs:` do not. +## Versions + +This toolbox is versioned by git tag and nothing else — there is no package to +publish, and the tag is the artefact. `scaffold --version` is `git describe` +against the checkout, and a generated project records that same string in its +`.scaffold.toml`, which is what `scaffold update` later diffs from. + +Cut one from `main` after a change worth telling somebody about: + +```sh +git tag v0.2.0 +git push origin v0.2.0 +``` + +Tagging is deliberately manual. Release Please is not set up here the way it is +in a generated project, because nothing downstream installs this by version: +what a tag buys is a readable answer in `--version` and in every +`.scaffold.toml` written after it, not a distribution channel. + ## Before opening a pull request ```sh diff --git a/README.md b/README.md index 3878c83..b6c0427 100644 --- a/README.md +++ b/README.md @@ -65,6 +65,10 @@ against the contract. `--version` reports which commit of this toolbox is installed — `git describe`, so a working tree with uncommitted edits says `-dirty`. +`new` prints one line per step rather than a package manager's output, and the +commands to run next when it finishes. `SCAFFOLD_VERBOSE=1` passes everything +through instead; a failing step prints its whole output either way. + `--db` and `--cache` select a database and a cache; each defaults to `none` except `--db`, which defaults to `mysql` for a project with an `--api` or `--app` adapter. Requesting either on a project with neither is refused — diff --git a/lib/adapter.sh b/lib/adapter.sh index 2b0a29d..f80089f 100644 --- a/lib/adapter.sh +++ b/lib/adapter.sh @@ -135,9 +135,15 @@ apply_adapter() { # 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). - ( cd "$parent" && APP_DIR="$(basename "$dest")" \ - npm_config_frozen_lockfile=false \ - mise exec -- bash -c "$ADAPTER_GENERATOR" ) + # 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" @@ -173,10 +179,10 @@ apply_adapter() { # 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. - ( cd "$dest" \ - && npm_config_frozen_lockfile=false \ - npm_config_verify_deps_before_run=false \ - mise exec -- bash -c "$ADAPTER_POST_GENERATE" ) + 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 diff --git a/lib/log.sh b/lib/log.sh index 3f6efe9..92ce361 100644 --- a/lib/log.sh +++ b/lib/log.sh @@ -2,3 +2,38 @@ 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. +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. +run_quietly() { + local what="$1"; shift + local log status=0 + + 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 + rm -f "$log" +} diff --git a/lib/manifest.sh b/lib/manifest.sh new file mode 100644 index 0000000..319a29a --- /dev/null +++ b/lib/manifest.sh @@ -0,0 +1,108 @@ +# 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). + +# register_config_root +register_config_root() { + local project="$1" root="$2" + local file="${project}/mise.toml" + + # 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. + 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 + + # 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. + 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 +} +# collect_config_roots +collect_config_roots() { + sed -n '/^config_roots = \[$/,/^\]$/p' "${1}/mise.toml" \ + | 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 .)" + sed -i.bak "s|^ roots: .*| roots: '${json}'|" \ + "${project}/.github/workflows/ci.yml" + rm -f "${project}/.github/workflows/ci.yml.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() { + local project="$1" rel="$2" + local name context dockerfile image file current updated + + 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. + if app_is_workspace_member "$project" "$rel"; then + context="." + else + context="$rel" + fi + + [ -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 + 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}" + done +} diff --git a/lib/pnpm.sh b/lib/pnpm.sh new file mode 100644 index 0000000..42abd49 --- /dev/null +++ b/lib/pnpm.sh @@ -0,0 +1,266 @@ +# shellcheck shell=bash +# +# The pnpm workspace, and the supply-chain policy that governs what may be +# installed into it (ADR-0017). +# +# 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`. + +# 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" + + mkdir -p "${project}/packages" + mv "${project}/packages-types" "${project}/packages/types" + register_config_root "$project" "packages/types" +} +# 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 + + 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 "$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}/pnpm-workspace.yaml" +} +# 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. +pnpm_install() { + local dir="$1" what="$2" log status=0 + step "$what" + log="$(mktemp)" + + ( + 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. + 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 + 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] +# Runs the frozen install from and records the exclusions in +# 's pnpm-workspace.yaml, 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, +# so running there found no violation and the app still could not install. +resolve_minimum_release_age() { + 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" + + # 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 + + local max_rounds=10 round=0 log entries all_entries="" + log="$(mktemp)" + + while true; do + if ( cd "$project" && mise exec -- pnpm install --frozen-lockfile --config.confirm-modules-purge=false >"$log" 2>&1 ); then + rm -f "$log" + 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)" + } + + round=$((round + 1)) + [ "$round" -le "$max_rounds" ] || { + cat "$log" >&2 + rm -f "$log" + die "pnpm install still hits new minimum-release-age violations after ${max_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)" + } + + 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 + # 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" + { + printf '# too fresh at generation time; pnpm re-checks this on every frozen\n' + 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' + } >> "$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 + + 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") + + return 1 +} +# 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() { + local project="$1" rel="$2" + local dir="${project}/${rel}" + + [ -f "${dir}/Dockerfile.workspace" ] || return 0 + + if app_is_workspace_member "$project" "$rel"; then + mv -f "${dir}/Dockerfile.workspace" "${dir}/Dockerfile" + else + rm -f "${dir}/Dockerfile.workspace" + fi +} + +# 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. +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" + + sync_workspace_lockfile "$project" + resolve_minimum_release_age "$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. + local pair + for pair in "$@"; do + finalize_app_dockerfile "$project" "${pair%%:*}" + done +} + +# 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 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. + # Deleting it left that project with no recorded build-script decision. + yq --inplace 'del(.packages)' "${project}/pnpm-workspace.yaml" + + # 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). + 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). + 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}" + done +} diff --git a/lib/project.sh b/lib/project.sh index 44c3d0f..d8309b3 100644 --- a/lib/project.sh +++ b/lib/project.sh @@ -207,297 +207,16 @@ init_project() { mise trust -y --quiet -C "$dir" } -# 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() { - local project="$1" rel="$2" - local name context dockerfile image file current updated - - 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. - if app_is_workspace_member "$project" "$rel"; then - context="." - else - context="$rel" - fi - [ -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 - 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}" - 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 - - 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") - - return 1 -} -# 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() { - local project="$1" rel="$2" - local dir="${project}/${rel}" - - [ -f "${dir}/Dockerfile.workspace" ] || return 0 - - if app_is_workspace_member "$project" "$rel"; then - mv -f "${dir}/Dockerfile.workspace" "${dir}/Dockerfile" - else - rm -f "${dir}/Dockerfile.workspace" - fi -} -# 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" - mkdir -p "${project}/packages" - mv "${project}/packages-types" "${project}/packages/types" - register_config_root "$project" "packages/types" -} -# 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 - 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 "$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}/pnpm-workspace.yaml" -} -# 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. -pnpm_install() { - local dir="$1" what="$2" log status=0 - log="$(mktemp)" - - ( - 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. - 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 - 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] -# Runs the frozen install from and records the exclusions in -# 's pnpm-workspace.yaml, 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, -# so running there found no violation and the app still could not install. -resolve_minimum_release_age() { - local project="$1" - local settings="${2:-$1}" - local workspace_file="${settings}/pnpm-workspace.yaml" - - # 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 - - local max_rounds=10 round=0 log entries all_entries="" - log="$(mktemp)" - - while true; do - if ( cd "$project" && mise exec -- pnpm install --frozen-lockfile --config.confirm-modules-purge=false >"$log" 2>&1 ); then - rm -f "$log" - 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)" - } - - round=$((round + 1)) - [ "$round" -le "$max_rounds" ] || { - cat "$log" >&2 - rm -f "$log" - die "pnpm install still hits new minimum-release-age violations after ${max_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)" - } - - 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 - # 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" - { - printf '# too fresh at generation time; pnpm re-checks this on every frozen\n' - 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' - } >> "$workspace_file" - done -} - -# register_config_root -register_config_root() { - local project="$1" root="$2" - local file="${project}/mise.toml" - - # 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. - 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 - - # 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. - 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 -} - -# collect_config_roots -collect_config_roots() { - sed -n '/^config_roots = \[$/,/^\]$/p' "${1}/mise.toml" \ - | 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 .)" - sed -i.bak "s|^ roots: .*| roots: '${json}'|" \ - "${project}/.github/workflows/ci.yml" - rm -f "${project}/.github/workflows/ci.yml.bak" -} # lock_toolchains # `mise install` writes a lockfile naming versions but no download URLs when the diff --git a/lib/service.sh b/lib/service.sh index a447817..0ce8afd 100644 --- a/lib/service.sh +++ b/lib/service.sh @@ -462,20 +462,25 @@ apply_service_drivers() { pnpm_bin="$(dirname "$(mise which pnpm -C "$app")")" node_bin="$(dirname "$(mise which node -C "$app")")" - 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 ' + # 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" - # shellcheck source=/dev/null . "${SCAFFOLD_ROOT}/lib/log.sh" - # shellcheck source=/dev/null . "${SCAFFOLD_ROOT}/lib/service.sh" - # shellcheck source=/dev/null . "$2" service_driver_apply - ' _ "$app" "$driver" \ - || die "the ${service} driver failed for ${family}" + ' + + 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 diff --git a/scaffold b/scaffold index f55d178..5793c61 100755 --- a/scaffold +++ b/scaffold @@ -22,6 +22,10 @@ source "${SCAFFOLD_ROOT}/lib/wizard.sh" source "${SCAFFOLD_ROOT}/lib/tui.sh" # shellcheck source=lib/project.sh source "${SCAFFOLD_ROOT}/lib/project.sh" +# shellcheck source=lib/pnpm.sh +source "${SCAFFOLD_ROOT}/lib/pnpm.sh" +# shellcheck source=lib/manifest.sh +source "${SCAFFOLD_ROOT}/lib/manifest.sh" # shellcheck source=lib/update.sh source "${SCAFFOLD_ROOT}/lib/update.sh" # shellcheck source=lib/publish.sh @@ -278,68 +282,27 @@ cmd_new() { done fi - # This decides one thing only: whether a shared packages/types is possible, - # which needs every app to be TypeScript. It used to decide whether the root - # pnpm-workspace.yaml survived as well — so one PHP app deleted the file - # carrying ADR-0017's allowBuilds, and with it the policy protecting the - # TypeScript app beside it. The file now stays either way. + # One question, not two: a shared packages/types needs every application to + # be TypeScript. It used to decide whether the root pnpm-workspace.yaml + # survived as well — so one PHP app deleted the file carrying ADR-0017's + # allowBuilds, and with it the policy protecting the TypeScript app beside + # it. The file now stays either way. local all_typescript=1 for entry in "${requested[@]}"; do adapter_is_typescript "${entry#*:}" || all_typescript=0 done + # :, the shape both branches below want: one needs the path, + # the other needs to ask the adapter whether it is typescript. + local -a apps=() + for entry in ${requested[@]+"${requested[@]}"}; do + apps+=("$(role_path "${entry%%:*}"):${entry#*:}") + done + if [ ${#requested[@]} -gt 0 ] && [ "$all_typescript" -eq 1 ]; then - enable_typescript_workspace "$target" - # 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 "${target}/docs/pnpm-workspace.yaml" "${target}/docs/pnpm-lock.yaml" - sync_workspace_lockfile "$target" - resolve_minimum_release_age "$target" - - # Every requested role just lost its own package.json/lockfile to the - # workspace above — apply_adapter's standalone Dockerfile assumed it had - # one. finalize_app_dockerfile swaps in the workspace-flavored Dockerfile - # each typescript adapter ships beside it; point the image build at the - # workspace root too, the only place those apps' manifests are reachable - # from now. - for entry in "${requested[@]}"; do - finalize_app_dockerfile "$target" "$(role_path "${entry%%:*}")" - done + join_typescript_workspace "$target" "${apps[@]}" else - rm -rf "${target}/packages-types" - - # No shared workspace without a fully TypeScript project, so 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 at all. - yq --inplace 'del(.packages)' "${target}/pnpm-workspace.yaml" - # 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). - # - pnpm_install "$target" "installing the project root's own tooling dependencies" - # pnpm_install above installed at full strength (minimum-release-age=0), - # so a violation among commitlint's own dependencies never surfaced — - # the loop below resolves each app's lockfile but never the root's, and - # the root's is the one the commit-msg hook installs from. - resolve_minimum_release_age "$target" - - # Each app here stands alone rather than joining a workspace, so each owns - # a lockfile the policy will re-check forever. Same reason as cmd_add's - # else branch. - local generated - for generated in "${requested[@]}"; do - adapter_is_typescript "${generated#*:}" \ - && sync_standalone_build_policy "${target}/$(role_path "${generated%%:*}")" "$target" - # the standalone Dockerfile above 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 "$target" "$(role_path "${generated%%:*}")" - resolve_minimum_release_age "${target}/$(role_path "${generated%%:*}")" - done + keep_apps_standalone "$target" ${apps[@]+"${apps[@]}"} fi # Last, because a build context depends on the workspace decision both @@ -350,9 +313,18 @@ cmd_new() { register_image_target "$target" "$(role_path "${entry%%:*}")" done + step "locking the toolchain and committing" finalize_project "$target" trap - EXIT + + log "" log "created ${target}" + log "" + log "next:" + log " cd ${target}" + log " mise install && mise exec -- lefthook install" + log " scaffold publish # create its GitHub repository" + log " mise run checklist # what CI will run" } # cmd_add_cleanup — same EXIT-trap diff --git a/tests/cli.bats b/tests/cli.bats index 1701054..21bd33c 100755 --- a/tests/cli.bats +++ b/tests/cli.bats @@ -85,11 +85,15 @@ setup() { # ambient pnpm that disagrees with the project's pin; asserting the # invocation shape here costs nothing. Same reasoning as service.bats' "the # nest driver decides allowBuilds before it installs anything". - run grep -c 'mise exec -- bash -c "\$ADAPTER_GENERATOR"' "${SCAFFOLD_ROOT}/lib/adapter.sh" - assert_ok - [ "$output" -eq 1 ] - - run grep -c 'mise exec -- bash -c "\$ADAPTER_POST_GENERATE"' "${SCAFFOLD_ROOT}/lib/adapter.sh" + # Asserted as a negative and a positive, so the shape can be rearranged — + # it has been once, when the output started being captured — without the + # guarantee quietly going with it. Neither value may be handed to a shell + # directly, and the only script that runs either goes through mise exec. + run grep -nE '(eval|bash -c) "\$ADAPTER_(GENERATOR|POST_GENERATE)"' "${SCAFFOLD_ROOT}/lib/adapter.sh" + [ -z "$output" ] \ + || { echo "a generator is run outside the project's toolchain:"; echo "$output"; false; } + + run grep -c 'mise exec -- bash -c "\$2"' "${SCAFFOLD_ROOT}/lib/adapter.sh" assert_ok [ "$output" -eq 1 ] } diff --git a/tests/new-laravel-inertia.bats b/tests/new-laravel-inertia.bats index ba59732..9ed1507 100644 --- a/tests/new-laravel-inertia.bats +++ b/tests/new-laravel-inertia.bats @@ -20,7 +20,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/manifest.sh'; collect_config_roots '${PROJECT}' | sort | tr '\n' ' '" [ "$output" = "apps/app docs " ] } diff --git a/tests/new-nestjs.bats b/tests/new-nestjs.bats index 76ce7df..3722987 100644 --- a/tests/new-nestjs.bats +++ b/tests/new-nestjs.bats @@ -31,6 +31,7 @@ teardown() { @test "config roots land in the order the calls actually produce" { source "${SCAFFOLD_ROOT}/lib/log.sh" source "${SCAFFOLD_ROOT}/lib/project.sh" + source "${SCAFFOLD_ROOT}/lib/manifest.sh" scaffold new "$PROJECT" --api nestjs --web nextjs run collect_config_roots "$PROJECT" diff --git a/tests/new-project.bats b/tests/new-project.bats index a9b5cee..056e46a 100644 --- a/tests/new-project.bats +++ b/tests/new-project.bats @@ -47,6 +47,38 @@ teardown() { [ ! -e "${SCAFFOLD_ROOT}/demo-relative" ] } +@test "new reports its steps instead of a package manager's output" { + # It used to hand the terminal several minutes of progress bars, through + # which the one line that mattered — which application is being generated — + # never appeared at all. + run scaffold new "$PROJECT" + assert_ok + [[ "$output" == *"→ "* ]] || { echo "no step lines:"; echo "$output"; false; } + [[ "$output" != *"Progress: resolved"* ]] \ + || { echo "package manager output reached the terminal"; false; } + # And says what to do with what it just made. + [[ "$output" == *"next:"* ]] + [[ "$output" == *"scaffold publish"* ]] +} + +@test "a captured step still shows everything when it fails" { + # The whole point of hiding output is that a failure prints all of it. + run bash -c "source '${SCAFFOLD_ROOT}/lib/log.sh' + run_quietly 'the probe' bash -c 'echo the-only-clue; exit 3'" + [ "$status" -ne 0 ] + [[ "$output" == *"the-only-clue"* ]] + [[ "$output" == *"failed while the probe"* ]] +} + +@test "SCAFFOLD_VERBOSE passes a step's output straight through" { + # For a run that hangs rather than fails, where there is otherwise nothing + # to look at. + run bash -c "source '${SCAFFOLD_ROOT}/lib/log.sh' + SCAFFOLD_VERBOSE=1 run_quietly 'the probe' bash -c 'echo live-output'" + assert_ok + [[ "$output" == *"live-output"* ]] +} + @test "new copies the common layer" { scaffold new "$PROJECT" [ -f "${PROJECT}/lefthook.yml" ] @@ -73,6 +105,7 @@ teardown() { @test "register_config_root is idempotent" { source "${SCAFFOLD_ROOT}/lib/log.sh" source "${SCAFFOLD_ROOT}/lib/project.sh" + source "${SCAFFOLD_ROOT}/lib/manifest.sh" scaffold new "$PROJECT" register_config_root "$PROJECT" "apps/api" register_config_root "$PROJECT" "apps/api" @@ -83,6 +116,7 @@ teardown() { @test "sync_ci_roots writes the roots as a JSON array" { source "${SCAFFOLD_ROOT}/lib/log.sh" source "${SCAFFOLD_ROOT}/lib/project.sh" + source "${SCAFFOLD_ROOT}/lib/manifest.sh" scaffold new "$PROJECT" register_config_root "$PROJECT" "apps/api" sync_ci_roots "$PROJECT" @@ -103,6 +137,7 @@ teardown() { @test "register, collect and sync agree on two roots" { source "${SCAFFOLD_ROOT}/lib/log.sh" source "${SCAFFOLD_ROOT}/lib/project.sh" + source "${SCAFFOLD_ROOT}/lib/manifest.sh" scaffold new "$PROJECT" register_config_root "$PROJECT" "apps/api" register_config_root "$PROJECT" "apps/web" @@ -174,6 +209,7 @@ teardown() { collect_roots() { source "${SCAFFOLD_ROOT}/lib/log.sh" source "${SCAFFOLD_ROOT}/lib/project.sh" + source "${SCAFFOLD_ROOT}/lib/manifest.sh" collect_config_roots "$1" } @@ -235,7 +271,7 @@ collect_roots() { printf 'monorepo_root = true\n\n[monorepo]\nconfig_roots = ["docs"]\n\n[tasks.checklist]\nrun = [{ task = "//docs:checklist" }]\n' \ > "${p}/mise.toml" - run bash -c "source '${SCAFFOLD_ROOT}/lib/log.sh'; source '${SCAFFOLD_ROOT}/lib/project.sh'; register_config_root '$p' apps/web" + run bash -c "source '${SCAFFOLD_ROOT}/lib/log.sh'; source '${SCAFFOLD_ROOT}/lib/manifest.sh'; register_config_root '$p' apps/web" [ "$status" -ne 0 ] [[ "$output" == *"config_roots"* ]] } diff --git a/tests/service.bats b/tests/service.bats index 411090f..75b5de4 100644 --- a/tests/service.bats +++ b/tests/service.bats @@ -303,7 +303,12 @@ EOF SCAFFOLD_ROOT="$toolbox" run apply_service_drivers "$app" "$app" fixture broken [ "$status" -eq 1 ] - [[ "$output" == *"the broken driver failed for fixture"* ]] + # The service and the family both have to be named: a bare "a driver failed" + # sends the reader to the wrong one of eight. + [[ "$output" == *"wiring broken into"* ]] \ + || { echo "the failure does not name the service:"; echo "$output"; false; } + [[ "$output" == *"fixture driver"* ]] \ + || { echo "the failure does not name the driver family:"; echo "$output"; false; } [ ! -e "${app}/installed" ] } @@ -330,7 +335,12 @@ EOF SCAFFOLD_ROOT="$toolbox" run apply_service_drivers "$app" "$app" fixture careless [ "$status" -eq 1 ] - [[ "$output" == *"the careless driver failed for fixture"* ]] + # The service and the family both have to be named: a bare "a driver failed" + # sends the reader to the wrong one of eight. + [[ "$output" == *"wiring careless into"* ]] \ + || { echo "the failure does not name the service:"; echo "$output"; false; } + [[ "$output" == *"fixture driver"* ]] \ + || { echo "the failure does not name the driver family:"; echo "$output"; false; } [ ! -e "${app}/installed" ] } diff --git a/tests/update.bats b/tests/update.bats index 75901b5..9b555c0 100644 --- a/tests/update.bats +++ b/tests/update.bats @@ -7,6 +7,8 @@ setup() { source "${SCAFFOLD_ROOT}/lib/adapter.sh" source "${SCAFFOLD_ROOT}/lib/service.sh" source "${SCAFFOLD_ROOT}/lib/project.sh" + source "${SCAFFOLD_ROOT}/lib/pnpm.sh" + source "${SCAFFOLD_ROOT}/lib/manifest.sh" source "${SCAFFOLD_ROOT}/lib/update.sh" WORKDIR="$(mktemp -d)" }