From 18913b5d933b2d69406040ad782dcfcec5b2268c Mon Sep 17 00:00:00 2001 From: iam-truongtrungnghia Date: Sun, 13 Sep 2026 13:01:16 +0700 Subject: [PATCH 1/9] docs: design for the remaining immich parity gaps --- .../specs/2026-09-13-immich-parity-design.md | 162 ++++++++++++++++++ 1 file changed, 162 insertions(+) create mode 100644 docs/superpowers/specs/2026-09-13-immich-parity-design.md diff --git a/docs/superpowers/specs/2026-09-13-immich-parity-design.md b/docs/superpowers/specs/2026-09-13-immich-parity-design.md new file mode 100644 index 0000000..7ff2119 --- /dev/null +++ b/docs/superpowers/specs/2026-09-13-immich-parity-design.md @@ -0,0 +1,162 @@ +# Immich Parity Design + +**Goal:** close the remaining gaps between this toolbox and the project it was +derived from, in the two places a survey found them — files immich has that we +never added, and a presentation style we adopted in prose but not in mechanism. + +**Scope:** every tracked file in this repository was measured. Four work items +fall out. Nothing here changes behaviour except one test, which is strengthened +so it can fail for the reason it was written. + +## What the survey found + +Comment density, measured over every tracked file with at least five lines: + +| Area | scaffold | immich equivalent | +| --- | --- | --- | +| `tests/*.bats` + helpers (4,345 lines) | 19% | 0.9% (`e2e/src/*.ts`) | +| `.github/workflows/*` | 17.5% | 3.8% | +| `adapters/*/adapter.env` | 53–76% | — | +| `common/.dockerignore` | 79% | 0% | +| `common/example.env` | 80% | — | +| `common/.prettierignore` | 73% | — | +| `common/install.sh` | 36% | 3% (`install.sh`) | +| Steps carrying `name:` | 0 of 49 | 297 | + +The gap is not that immich explains less. It is that immich explains through +mechanisms a reader already has to look at: + +- **`name:` on a workflow step.** The name appears in the GitHub log while the + job runs. A comment above the step appears only to someone reading the file. + Same words, better placement, and it cannot go stale unnoticed because it is + on screen every run. +- **Function names that narrate.** immich's `install.sh` is 107 lines with three + comments, because `main()` reads as prose: `create_immich_directory`, + `download_docker_compose_file`, `generate_random_password`. Ours already has + this shape; what it still carries is a paragraph above each function. +- **`local -r`.** immich uses it for locals that never change (`local -r + Tgt='./immich-app'`). This is not the file-level `readonly` this repository + correctly rejected — that breaks re-sourcing into child processes, which this + codebase does by design. Function scope has no such problem. + +## Decisions + +**Adopt the mechanisms, keep the facts.** The comments that remain after the two +previous passes are largely third-party landmines: `mise exec` trusting and +executing a parent config, pnpm turning on frozen lockfiles whenever `CI` is set, +yq collapsing a document without `-P`, prisma 2.x changing `bsonSerialize()`, +`gh repo create --push` setting the default branch. immich sits at 3–4% because +it has no such layer — it calls its own tools. Those facts stay. What goes is +the narration around them. + +**Community assets go to both the toolbox and `common/`, rewritten for each +audience.** This repository ships `SECURITY.md` and `CODEOWNERS` to every +generated project and carries neither itself. It enforces a pull request body on +its own pull requests, with a CI job, and ships no template to clients. + +**No issue or discussion templates.** immich's are a triage funnel for thousands +of strangers, with a `config.yml` of Discord links. A client project is one to +three developers who sit together. Copying them produces ceremony, not +discipline. + +## Work items + +### A — Files immich has and we do not + +Toolbox root: + +- `SECURITY.md` — the text `common/SECURITY.md` already ships, unchanged. +- `CODEOWNERS` — `* @ttncode`. Hardcoded is correct here; this repository has + one owner and no substitution step. +- `.vscode/extensions.json` — `timonwong.shellcheck`, + `foxundermoon.shell-format`, `editorconfig.editorconfig`. Exactly the tools + `mise.toml` already pins and `.editorconfig` already configures. +- `.vscode/settings.json` — minimal. `files.associations` so an editor + recognises `scaffold`, an extensionless bash file, as shell. + +Shipped into every generated project: + +- `common/.github/pull_request_template.md` — the three headings this repository + enforces on itself, with a checklist written for a client project's own + commands. +- `common/.vscode/extensions.json` — the language-agnostic set. Per-adapter + extensions would need a fragment-merge mechanism like `lefthook.fragment.yml`; + that is not built, and nothing yet needs it. + +Any file added under `common/` that carries the owner or the project name must +join `PROJECT_OWNER_FILES` or `PROJECT_NAME_FILES` in `lib/project.sh`, or it +ships with the placeholder intact. + +One test changes. `tests/new-project.bats` asserts that no `@you` or `you/` +survives generation. That proves a placeholder is gone, not that the owner is +right: a hardcoded or mistyped account passes it. It becomes an assertion that +`CODEOWNERS` names the account the run resolved. + +### B — A name on every workflow step + +49 steps across the four workflows in `.github/workflows/`; none has a `name:`. +The five files in `common/.github/workflows/` are pure `uses:` call sites with no +steps, so they are untouched. + +Name every step, then delete the comments the name now carries. What stays is +GitHub's own behaviour: SARIF upload refused on a private repository without +Advanced Security, CodeQL needing `actions: read` to read its own run, the job +name `pull-request-body` being load-bearing for branch protection. + +Target: 17.5% to 6–8%. + +### C — Scripts in immich's shape + +- `local -r` for every local that is assigned once. +- `common/install.sh` from 36% to about 15%: each function's comment block down + to the sentence carrying the fact. The landmines stay — jq rather than grep for + a release asset's id, two endpoints because a private release's browser URL + answers 404, the trap baking its path with `printf %q` and naming its signals, + `sed` delimited on `|` because a base64 value contains `/`, `BASH_SOURCE[0]:-$0` + because a curl-piped script has no `BASH_SOURCE`. +- The same pass over `lib/*.sh` and `services/shared/*.sh`, which the previous + two rounds left at 37–52%. + +### D — Data files and tests + +Not in the original three-part split; the survey found it. + +- `tests/*.bats` and `tests/helpers/` — 833 comment lines in 4,345. A bats test + name is already a sentence; a comment above it that restates the name is the + dominant pattern here. Keep the ones recording why a test exists at all — + those are regression notes, and several name a defect that shipped. +- `adapters/*/adapter.env` — 53–76%. The laravel-inertia file is the extreme: a + seven-line block on `ADAPTER_GENERATOR` and a ten-line block on + `ADAPTER_POST_GENERATE`. Both record real traps (`rm -rf .github` because the + starter kit's inert dependabot config fails zizmor; the sed that wires + `routes/health.php` into `bootstrap/app.php` because Laravel auto-loads + neither). Compress, do not delete. +- `common/.dockerignore`, `common/.prettierignore`, `common/example.env`, + `adapters/*/.dockerignore`, `adapters/*/.env.example` — 42–80%. immich's + `.dockerignore` carries no comment at all and is perfectly legible. + +## Not doing + +| | Why | +| --- | --- | +| `ISSUE_TEMPLATE`, `DISCUSSION_TEMPLATE` | A triage funnel for a public product; a client project has three developers. | +| `labeler.yml`, `pr-labeler.yml` | One maintainer. | +| `.github/release.yml` changelog categories | Generated projects use Release Please, which writes its own changelog from conventional commits. | +| `.devcontainer`, `.pnpmfile.cjs`, `.prettierrc`, `.nvmrc` | No JS/TS in the toolbox; mise already pins node. | +| Root `.dockerignore` | The toolbox builds no image. | +| Path-filtered CI | Worth doing, deferred by choice. A `paths:` trigger makes a required check never report, which blocks merges forever — the failure this repository already hit with `pull-request-body`. If taken up, gate with `if:` at the job, which still reports. | +| `docker rmi` in `deploy-check.sh` | immich removes no image it builds, anywhere. Reclamation is documented as the operator's `docker image prune`. | + +## Verification + +Each work item is a pull request, verified the same way: + +- `mise run lint` +- `mise run test-runner` — both lanes under the runner's environment +- `mise exec -- zizmor --min-severity medium .github/workflows/` +- every YAML and TOML touched re-parsed with `yq` +- the pull request's own CI, which runs `deploy` and `smoke` against three + adapters on a real runner + +A adds one more: generate a project with an explicit owner and confirm +`CODEOWNERS` names it. From 53358a5aa78b95bfa8da7241a5733987317b8a14 Mon Sep 17 00:00:00 2001 From: iam-truongtrungnghia Date: Sun, 13 Sep 2026 13:03:47 +0700 Subject: [PATCH 2/9] docs: implementation plan for the immich parity gaps --- .../plans/2026-09-13-immich-parity.md | 603 ++++++++++++++++++ 1 file changed, 603 insertions(+) create mode 100644 docs/superpowers/plans/2026-09-13-immich-parity.md diff --git a/docs/superpowers/plans/2026-09-13-immich-parity.md b/docs/superpowers/plans/2026-09-13-immich-parity.md new file mode 100644 index 0000000..403b3df --- /dev/null +++ b/docs/superpowers/plans/2026-09-13-immich-parity.md @@ -0,0 +1,603 @@ +# Immich Parity Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +> **Delete this file when every task is checked off.** PR #18 removed five plans +> that outlived their execution and still claimed 305 undone tasks. A plan is a +> work order, not a record; the record is the ADRs, the tour, and the code. + +**Goal:** close the gaps a full-repository survey found between this toolbox and +immich — files immich has that we never added, and a presentation style we +adopted in prose but not in mechanism. + +**Architecture:** four independent pull requests, each verified the same way and +each revertable on its own. Nothing changes behaviour except one test, which is +strengthened so it can fail for the reason it was written. + +**Tech Stack:** bash, YAML, bats, mise, shellcheck, yq, zizmor. + +**Spec:** `docs/superpowers/specs/2026-09-13-immich-parity-design.md` + +## Global Constraints + +- Chat is Vietnamese; **every file, comment, commit message and document is + English**. +- Comment rule: a comment earns its place only when it is not obvious in ten + seconds AND sits at a different abstraction level than the line below it. + Third-party landmines stay; narration around them goes. +- `mise run lint` must pass before every commit — lefthook's pre-commit hook + runs it and will block the commit otherwise. +- Commit messages are Conventional Commits; lefthook's commit-msg hook greps for + the prefix. +- Work on `chore/immich-parity`, already cut from `main` at `277d8a8`. The spec + is already committed there as `18913b5`. +- Every task ends green on: `mise run lint`, `mise run test-runner`, + `mise exec -- zizmor --min-severity medium .github/workflows/`. +- `mise run test-runner` takes about 25 minutes. Run it once per task, at the + end, not per step. + +--- + +## File Structure + +| Path | Responsibility | Task | +| --- | --- | --- | +| `SECURITY.md` | the toolbox's own vulnerability contact | A | +| `CODEOWNERS` | the toolbox's own owner | A | +| `.vscode/extensions.json` | recommend the tools `mise.toml` pins | A | +| `.vscode/settings.json` | teach an editor that `scaffold` is bash | A | +| `common/.github/pull_request_template.md` | ships a PR template to clients | A | +| `common/.vscode/extensions.json` | ships editor hints to clients | A | +| `tests/new-project.bats` | assert the owner is right, not merely non-placeholder | A | +| `.github/workflows/*.yml` | a `name:` on all 49 steps | B | +| `common/install.sh`, `lib/*.sh`, `services/shared/*.sh` | `local -r`, comments to their facts | C | +| `tests/*.bats`, `tests/helpers/setup.bash` | comments to their facts | D | +| `adapters/*/adapter.env`, `common/*ignore`, `common/example.env` | comments to their facts | D | + +--- + +## Task A: Files immich has and we do not + +**Files:** +- Create: `SECURITY.md`, `CODEOWNERS`, `.vscode/extensions.json`, + `.vscode/settings.json`, `common/.github/pull_request_template.md`, + `common/.vscode/extensions.json` +- Modify: `tests/new-project.bats` + +**Interfaces:** +- Consumes: nothing from other tasks. +- Produces: nothing other tasks rely on. + +- [ ] **Step 1: Write the failing assertion** + +`tests/new-project.bats` has a test named `no placeholder account survives into +the generated project`. Its body currently ends with a `grep` for `@you\b|you/` +asserting the output is empty. That proves a placeholder is gone, not that the +owner is right — `* @ttncode` passes it. Add this to the end of that test body, +after the existing `grep` assertion: + +```bash + # Absence of the placeholder is not presence of the owner: a hardcoded or + # mistyped account passes the grep above. tests/helpers/setup.bash exports + # SCAFFOLD_GITHUB_OWNER=test-owner. + run cat "${PROJECT}/CODEOWNERS" + [ "$output" = "* @test-owner" ] \ + || { echo "CODEOWNERS says '${output}', not the account this run resolved"; false; } +``` + +- [ ] **Step 2: Prove the new assertion can fail** + +```bash +sed -i 's|^\* @you$|* @someone-else|' common/CODEOWNERS +mise exec -- bats tests/new-project.bats -f "no placeholder account" +``` + +Expected: FAIL, with `CODEOWNERS says '* @someone-else', not the account this run resolved`. + +Then restore: + +```bash +git checkout -- common/CODEOWNERS +mise exec -- bats tests/new-project.bats -f "no placeholder account" +``` + +Expected: PASS. + +- [ ] **Step 3: Create the toolbox's own SECURITY.md** + +Same text `common/SECURITY.md` already ships. Create `SECURITY.md`: + +```markdown +# Security policy + +Report vulnerabilities privately rather than opening a public issue. Email the +maintainer listed in `CODEOWNERS` with a description and reproduction steps. +Expect an initial response within a few business days. +``` + +- [ ] **Step 4: Create the toolbox's own CODEOWNERS** + +Create `CODEOWNERS`: + +``` +* @ttncode +``` + +Hardcoded is correct here: this repository has one owner and no substitution +step. `common/CODEOWNERS` keeps its `@you` placeholder — do not touch it. + +- [ ] **Step 5: Create the editor hints** + +Create `.vscode/extensions.json`: + +```json +{ + "recommendations": [ + "timonwong.shellcheck", + "foxundermoon.shell-format", + "editorconfig.editorconfig" + ] +} +``` + +Create `.vscode/settings.json`: + +```json +{ + "files.associations": { + "scaffold": "shellscript" + } +} +``` + +`scaffold` has no extension, so an editor treats it as plain text and offers no +shell diagnostics for the largest file in the repository. + +- [ ] **Step 6: Ship a pull request template to generated projects** + +Create `common/.github/pull_request_template.md`: + +```markdown +## What this changes + + + +## How it was verified + + + +## Checklist + +- [ ] `mise run checklist` passes +- [ ] New behaviour has a test that fails without the change +- [ ] Docs that describe changed behaviour were updated in the same commit +- [ ] No unrelated changes +``` + +The three headings match the ones this repository enforces on itself. The +checklist names the generated project's own command, `mise run checklist`, not +this repository's `mise run test-runner`. + +- [ ] **Step 7: Ship editor hints to generated projects** + +Create `common/.vscode/extensions.json`: + +```json +{ + "recommendations": [ + "editorconfig.editorconfig", + "esbenp.prettier-vscode", + "timonwong.shellcheck" + ] +} +``` + +Language-agnostic only. A generated project can be TypeScript, PHP, or both; +per-adapter extensions would need a fragment-merge mechanism like +`lefthook.fragment.yml`, which does not exist and nothing yet needs. + +- [ ] **Step 8: Verify a generated project carries the new files** + +```bash +T=$(mktemp -d) +SCAFFOLD_GITHUB_OWNER=acme-corp MISE_STATE_DIR="$T/s" GIT_CONFIG_GLOBAL="$T/g" \ + ./scaffold new "$T/demo" --db none +ls "$T/demo/.vscode/extensions.json" "$T/demo/.github/pull_request_template.md" +cat "$T/demo/CODEOWNERS" +rm -rf "$T" +``` + +Expected: both files listed, `CODEOWNERS` reads `* @acme-corp`. + +Neither new file carries `you/`, `@you`, or `@PROJECT_NAME@`, so neither needs +adding to `PROJECT_OWNER_FILES` or `PROJECT_NAME_FILES` in `lib/project.sh`. If +a future template does carry one, it must be added there or it ships with the +placeholder intact. + +- [ ] **Step 9: Run the full suite** + +```bash +mise run lint +mise run test-runner +mise exec -- zizmor --min-severity medium .github/workflows/ +``` + +Expected: lint clean, 262 tests passing with none failing, zizmor reporting no +findings. + +- [ ] **Step 10: Commit** + +```bash +git add SECURITY.md CODEOWNERS .vscode common/.vscode \ + common/.github/pull_request_template.md tests/new-project.bats +git commit -m "feat: carry the files this toolbox already asks of its projects + +SECURITY.md and CODEOWNERS ship to every generated project and were missing +here. A pull request template is enforced on this repository's own pull +requests by a CI job and was shipped to nobody. .vscode recommends the tools +mise.toml already pins, and tells an editor that the extensionless scaffold +file is bash. + +The placeholder test asserted that no @you survived generation, which a +hardcoded account passes. It now asserts CODEOWNERS names the account the run +resolved." +``` + +--- + +## Task B: A name on every workflow step + +**Files:** +- Modify: `.github/workflows/adapters.yml`, `.github/workflows/ci.yml`, + `.github/workflows/provenance.yml`, `.github/workflows/pull-request.yml` + +**Interfaces:** +- Consumes: nothing. +- Produces: nothing. + +`common/.github/workflows/*.yml` are pure `uses:` call sites with no steps. Do +not touch them. + +- [ ] **Step 1: Count the steps that need a name** + +```bash +grep -c '^\s*- uses:\|^\s*- run:\|^\s*- id:' .github/workflows/*.yml +``` + +Expected: 31 in adapters.yml, 11 in ci.yml, 6 in provenance.yml, 1 in +pull-request.yml — 49 with no `name:` between them. + +- [ ] **Step 2: Name every step** + +For each step, add `name:` as its first key. The name is a short imperative +phrase describing what the step does, in the same voice immich uses: `Checkout +code`, `Setup Mise`, `Publish`, `Build and push image`. + +Where a step has an `id:`, `name:` goes first and `id:` second. + +Example, from `.github/workflows/ci.yml`: + +```yaml + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false +``` + +becomes: + +```yaml + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false +``` + +A step whose purpose was explained by a comment above it takes that explanation +into its name where the name can carry it. Example, from +`.github/workflows/adapters.yml`: + +```yaml + # a broken adapter must not hide the state of the others + fail-fast: false +``` + +stays a comment — it annotates `fail-fast:`, not a step. + +- [ ] **Step 3: Delete the comments the names now carry** + +Remove a comment only when the step's new `name:` says the same thing. Keep +every comment recording GitHub's own behaviour, including: + +- SARIF upload refused on a private repository without Advanced Security +- CodeQL needing `actions: read` to read its own workflow run +- the job name `pull-request-body` being load-bearing for branch protection +- the weekly cron string also living in `scripts/adapter-matrix.sh` +- timeout values and the measurement behind them + +- [ ] **Step 4: Verify the YAML still parses and the audit is clean** + +```bash +for f in .github/workflows/*.yml; do mise exec -- yq -e '.jobs' "$f" >/dev/null && echo "ok $f"; done +mise exec -- zizmor --min-severity medium .github/workflows/ +grep -c '^\s*#' .github/workflows/*.yml +``` + +Expected: four `ok` lines, `No findings to report`, and a comment count of +roughly 35–45 across the four files, down from 100. + +- [ ] **Step 5: Run the full suite** + +```bash +mise run lint +mise run test-runner +``` + +Expected: lint clean, 262 tests passing. + +`tests/workflows.bats` asserts that every job in these files has a timeout, +starts from a closed permission set, pins every action by sha, and disables +credential persistence on checkout. Adding `name:` changes none of that; if any +of those tests fails, a key was moved or deleted by mistake. + +- [ ] **Step 6: Commit** + +```bash +git add .github/workflows +git commit -m "refactor(ci): name every step instead of commenting it + +49 steps across four workflows, none with a name. immich names 297 of them and +sits at 3.8% comments against our 17.5% — the difference is not that they +explain less, it is where. A step's name appears in the GitHub log while the +job runs; a comment above it appears only to someone reading the file. + +What stays is GitHub's own behaviour: SARIF refused without Advanced Security, +CodeQL needing actions: read, the pull-request-body job name being load-bearing +for branch protection." +``` + +--- + +## Task C: Scripts in immich's shape + +**Files:** +- Modify: `common/install.sh`, `lib/*.sh`, `services/shared/laravel.sh`, + `services/shared/nest.sh` + +**Interfaces:** +- Consumes: nothing. +- Produces: nothing. + +- [ ] **Step 1: Apply `local -r` where a local is assigned once** + +immich writes `local -r Tgt='./immich-app'` and `local -r RepoUrl=...`. This is +function scope, not the file-level `readonly` this repository correctly rejects: +these libraries are re-sourced into child processes by design, and a second +file-level `readonly` is an error that `set -e` turns into a dead script. A +`local -r` has no such problem. + +Change `local x="$1"` to `local -r x="$1"` only where `x` is never reassigned in +the function. Leave accumulators (`status`, `block`, `env_block`), loop +variables, and anything reassigned. + +Do not change the file-level constants (`COMPOSE_FILE`, `GATE_NAME`, +`PROJECT_NAME_RULE`, and the rest). They stay plain assignments. + +- [ ] **Step 2: Verify shellcheck is still clean** + +```bash +mise run lint +``` + +Expected: clean. `local -r` on a variable that is later assigned is a shellcheck +error (SC2155 family) and will surface here. + +- [ ] **Step 3: Cut `common/install.sh` to its facts** + +104 comment lines in 285. immich's equivalent is 3 in 107, because `main()` +reads as prose and the function names narrate. Ours already has that shape. + +For each function, reduce its comment block to the sentence carrying the fact. +Keep, in full or close to it: + +- `release_asset_id` — jq rather than grep, because an asset's own id precedes + its name while the uploader's follows it, and the wrong request succeeds +- `fetch_release_asset` — two endpoints, because a private release's browser URL + answers 404 both anonymously and with a token +- `download_release_assets` — two cleanup mechanisms, and why a plain EXIT trap + is not enough +- `generate_service_passwords` — the `|` delimiter, because a base64 value + contains `/`; and the known argv exposure +- `compose_has_service` — grep closing the pipe, SIGPIPE, pipefail +- the `BASH_SOURCE[0]:-$0` guard at the foot of the file + +Delete narration that restates the function name or walks through the body. + +Target: about 15%, roughly 40 comment lines. + +- [ ] **Step 4: Cut `lib/*.sh` and `services/shared/*.sh` the same way** + +`lib/project.sh` is at 37%, `lib/contract.sh` at 42%, `services/shared/nest.sh` +at 52%, `services/shared/laravel.sh` at 47%, `services/mongodb/drivers/laravel.sh` +at 42%. Same rule: the fact stays, the narration goes. + +The small per-service drivers (`services/{mysql,postgres,mongodb}/drivers/nest.sh`) +read at 67–75%, but that is the six-line header box over a twelve-line file. +Leave them. + +- [ ] **Step 5: Run the full suite** + +```bash +mise run lint +mise run test-runner +``` + +Expected: lint clean, 262 tests passing. + +`tests/install.bats` exercises `release_asset_id`, `fetch_release_asset`, +`require_private_tools`, `start_stack`, `generate_service_passwords` and +`run_migrations` directly. `tests/cli.bats` greps `lib/adapter.sh` for the exact +string `mise exec -- bash -c "$2"` and asserts it appears once — do not reword +that line. `tests/publish.bats` parses the heredocs in `lib/publish.sh` with +`sed` and `awk` anchored on a line that is exactly `{` and a line that is +exactly `}` — do not indent them. + +- [ ] **Step 6: Commit** + +```bash +git add common/install.sh lib services/shared +git commit -m "refactor: local -r, and comments down to their facts + +immich's install.sh is 107 lines with three comments because main() reads as +prose and the function names narrate. Ours has that shape already and still +carried a paragraph above each function. + +local -r for locals assigned once, which is function scope and unlike the +file-level readonly this repository rejects — these libraries are re-sourced +into child processes by design. + +What stays is the third-party landmines: jq rather than grep for an asset id, +two endpoints for a private release, the trap baking its path and naming its +signals, sed delimited on | because a base64 value contains /, and +BASH_SOURCE[0]:-\$0 because a curl-piped script has none." +``` + +--- + +## Task D: Data files and tests + +**Files:** +- Modify: `tests/*.bats`, `tests/helpers/setup.bash`, + `adapters/*/adapter.env`, `adapters/*/.dockerignore`, + `adapters/*/.env.example`, `adapters/*/.prettierignore`, + `common/.dockerignore`, `common/.prettierignore`, `common/example.env` + +**Interfaces:** +- Consumes: nothing. +- Produces: nothing. + +- [ ] **Step 1: Cut the test suites** + +833 comment lines in 4,345 — 19%, against immich's 0.9% in `e2e/src`. A bats +test name is already a sentence; the dominant pattern here is a comment above a +test that restates its name. + +Delete a comment when the test name says it. Keep one when it records why the +test exists at all — several name a defect that shipped, and those are the +reason the test is not deleted as redundant. Example of one to keep, from +`tests/install.bats`: + +```bash + # `docker compose ... | grep -qx migrate` reads correctly and fails about one + # run in seven: grep closes the pipe on its first match, compose dies of + # SIGPIPE, and install.sh's own `set -o pipefail` reports the pipeline as + # failed. +``` + +Example of one to delete — a comment restating the test name immediately above +it. + +- [ ] **Step 2: Cut the adapter.env files** + +`adapters/laravel-inertia/adapter.env` is 29 comment lines in 38. Two blocks +carry real traps and must survive, compressed: + +- `ADAPTER_GENERATOR` — the commit pin, `SHELL_VERBOSITY=-1` because + `--no-interaction` does not reach `install:features`, and + `COMPOSER_PROCESS_TIMEOUT=900` because a cold cache exceeds the 300s default +- `ADAPTER_POST_GENERATE` — `rm -rf .github` because the starter kit's inert + dependabot config fails zizmor with exit 13, and the sed that wires + `routes/health.php` into `bootstrap/app.php` because Laravel auto-loads + neither, plus the grep pair that turns a silent no-op into a failure + +Delete the rest, including the `ADAPTER_TIER` block that restates ADR-0012 and +the `/up` note that restates what `ADAPTER_READINESS_PATH` beside it already +shows. + +Apply the same rule to `adapters/nextjs`, `adapters/nestjs`, +`adapters/laravel-api`. + +- [ ] **Step 3: Cut the ignore and env templates** + +immich's `.dockerignore` carries no comment at all and is perfectly legible. +Ours is 11 comment lines in 14. + +- `common/.dockerignore` — keep one line on why `node_modules` is ignored + despite being copied from a build stage; delete the rest +- `common/.prettierignore` — keep one line: prettier rewrites `pnpm-lock.yaml` + and fights Release Please for `CHANGELOG.md`; neither is written by hand +- `common/example.env` — keep one line: copy to `.env`, install.sh does it and + replaces every `changeme` +- `adapters/*/.dockerignore`, `adapters/*/.env.example`, + `adapters/*/.prettierignore` — same rule + +- [ ] **Step 4: Run the full suite** + +```bash +mise run lint +mise run test-runner +``` + +Expected: lint clean, 262 tests passing. + +`tests/compose.bats` asserts `common/example.env` still carries the literal +`changeme` for every password, and that `.dockerignore` exists for every +adapter. `tests/contract.bats` reads `adapter.env` through +`adapter_env_value` — a comment line cannot break it, but a deleted assignment +can. + +- [ ] **Step 5: Commit** + +```bash +git add tests adapters common/.dockerignore common/.prettierignore common/example.env +git commit -m "refactor: comments in the data files and the test suites + +19% of the test suites were comments against immich's 0.9%, and the dominant +pattern was a comment restating the test name below it. The adapter.env files +ran to 76%, and common/.dockerignore to 79% where immich's carries none. + +What stays is the traps: the laravel starter kit's inert .github failing zizmor +with exit 13, the sed wiring routes/health.php into bootstrap/app.php because +Laravel auto-loads neither, and the regression notes on tests that exist +because a defect shipped." +``` + +--- + +## Task E: Open the pull requests + +**Files:** none. + +- [ ] **Step 1: Push and open one pull request per task** + +One pull request carrying all four commits. They are independent and each +reviewable on its own, and the branch is already cut; splitting into four +branches buys separate revert granularity that `git revert ` already gives. + +```bash +git push -u origin chore/immich-parity +gh pr create --title "chore: close the remaining immich parity gaps" --base main +``` + +The body must carry the three headings `.github/pull_request_template.md` +declares, or the `pull-request-body` check fails. + +- [ ] **Step 2: Wait for CI and merge** + +```bash +gh pr checks +``` + +Expected: `unit`, `integration`, `zizmor`, `self-test`, `pull-request-body`, +`discover`, `smoke` for three adapters and `deploy` for three adapters, all +passing. `deploy-tier-b` and `smoke-tier-b` run too, because Task D changes +`adapters/laravel-inertia/`. + +- [ ] **Step 3: Delete this plan** + +```bash +git rm docs/superpowers/plans/2026-09-13-immich-parity.md +git commit -m "chore: remove the executed parity plan" +``` + +A plan is a work order. Leaving it behind is what produced the 8,703 lines PR +#18 deleted. From b2798d874238da6f8b94ae55b32583810d184b62 Mon Sep 17 00:00:00 2001 From: iam-truongtrungnghia Date: Sun, 13 Sep 2026 13:04:55 +0700 Subject: [PATCH 3/9] docs: number the plan's tasks --- .../plans/2026-09-13-immich-parity.md | 32 +++++++++---------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/docs/superpowers/plans/2026-09-13-immich-parity.md b/docs/superpowers/plans/2026-09-13-immich-parity.md index 403b3df..8cdd18c 100644 --- a/docs/superpowers/plans/2026-09-13-immich-parity.md +++ b/docs/superpowers/plans/2026-09-13-immich-parity.md @@ -42,21 +42,21 @@ strengthened so it can fail for the reason it was written. | Path | Responsibility | Task | | --- | --- | --- | -| `SECURITY.md` | the toolbox's own vulnerability contact | A | -| `CODEOWNERS` | the toolbox's own owner | A | -| `.vscode/extensions.json` | recommend the tools `mise.toml` pins | A | -| `.vscode/settings.json` | teach an editor that `scaffold` is bash | A | -| `common/.github/pull_request_template.md` | ships a PR template to clients | A | -| `common/.vscode/extensions.json` | ships editor hints to clients | A | -| `tests/new-project.bats` | assert the owner is right, not merely non-placeholder | A | -| `.github/workflows/*.yml` | a `name:` on all 49 steps | B | -| `common/install.sh`, `lib/*.sh`, `services/shared/*.sh` | `local -r`, comments to their facts | C | -| `tests/*.bats`, `tests/helpers/setup.bash` | comments to their facts | D | -| `adapters/*/adapter.env`, `common/*ignore`, `common/example.env` | comments to their facts | D | +| `SECURITY.md` | the toolbox's own vulnerability contact | 1 | +| `CODEOWNERS` | the toolbox's own owner | 1 | +| `.vscode/extensions.json` | recommend the tools `mise.toml` pins | 1 | +| `.vscode/settings.json` | teach an editor that `scaffold` is bash | 1 | +| `common/.github/pull_request_template.md` | ships a PR template to clients | 1 | +| `common/.vscode/extensions.json` | ships editor hints to clients | 1 | +| `tests/new-project.bats` | assert the owner is right, not merely non-placeholder | 1 | +| `.github/workflows/*.yml` | a `name:` on all 49 steps | 2 | +| `common/install.sh`, `lib/*.sh`, `services/shared/*.sh` | `local -r`, comments to their facts | 3 | +| `tests/*.bats`, `tests/helpers/setup.bash` | comments to their facts | 4 | +| `adapters/*/adapter.env`, `common/*ignore`, `common/example.env` | comments to their facts | 4 | --- -## Task A: Files immich has and we do not +## Task 1: Files immich has and we do not **Files:** - Create: `SECURITY.md`, `CODEOWNERS`, `.vscode/extensions.json`, @@ -245,7 +245,7 @@ resolved." --- -## Task B: A name on every workflow step +## Task 2: A name on every workflow step **Files:** - Modify: `.github/workflows/adapters.yml`, `.github/workflows/ci.yml`, @@ -357,7 +357,7 @@ for branch protection." --- -## Task C: Scripts in immich's shape +## Task 3: Scripts in immich's shape **Files:** - Modify: `common/install.sh`, `lib/*.sh`, `services/shared/laravel.sh`, @@ -463,7 +463,7 @@ BASH_SOURCE[0]:-\$0 because a curl-piped script has none." --- -## Task D: Data files and tests +## Task 4: Data files and tests **Files:** - Modify: `tests/*.bats`, `tests/helpers/setup.bash`, @@ -563,7 +563,7 @@ because a defect shipped." --- -## Task E: Open the pull requests +## Task 5: Open the pull requests **Files:** none. From ebde016096e0caaebf470ea9176fd47d09f8fa55 Mon Sep 17 00:00:00 2001 From: iam-truongtrungnghia Date: Sun, 13 Sep 2026 13:35:43 +0700 Subject: [PATCH 4/9] feat: carry the files this toolbox already asks of its projects SECURITY.md and CODEOWNERS ship to every generated project and were missing here. A pull request template is enforced on this repository's own pull requests by a CI job and was shipped to nobody. .vscode recommends the tools mise.toml already pins, and tells an editor that the extensionless scaffold file is bash. The placeholder test asserted that no @you survived generation, which a hardcoded account passes. It now asserts CODEOWNERS names the account the run resolved. --- .vscode/extensions.json | 7 +++++++ .vscode/settings.json | 5 +++++ CODEOWNERS | 1 + SECURITY.md | 5 +++++ common/.github/pull_request_template.md | 14 ++++++++++++++ common/.vscode/extensions.json | 7 +++++++ tests/new-project.bats | 7 +++++++ 7 files changed, 46 insertions(+) create mode 100644 .vscode/extensions.json create mode 100644 .vscode/settings.json create mode 100644 CODEOWNERS create mode 100644 SECURITY.md create mode 100644 common/.github/pull_request_template.md create mode 100644 common/.vscode/extensions.json diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..4a3f1c2 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,7 @@ +{ + "recommendations": [ + "timonwong.shellcheck", + "foxundermoon.shell-format", + "editorconfig.editorconfig" + ] +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..2230113 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,5 @@ +{ + "files.associations": { + "scaffold": "shellscript" + } +} diff --git a/CODEOWNERS b/CODEOWNERS new file mode 100644 index 0000000..e49d0f0 --- /dev/null +++ b/CODEOWNERS @@ -0,0 +1 @@ +* @ttncode diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..d29158d --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,5 @@ +# Security policy + +Report vulnerabilities privately rather than opening a public issue. Email the +maintainer listed in `CODEOWNERS` with a description and reproduction steps. +Expect an initial response within a few business days. diff --git a/common/.github/pull_request_template.md b/common/.github/pull_request_template.md new file mode 100644 index 0000000..91c680a --- /dev/null +++ b/common/.github/pull_request_template.md @@ -0,0 +1,14 @@ +## What this changes + + + +## How it was verified + + + +## Checklist + +- [ ] `mise run checklist` passes +- [ ] New behaviour has a test that fails without the change +- [ ] Docs that describe changed behaviour were updated in the same commit +- [ ] No unrelated changes diff --git a/common/.vscode/extensions.json b/common/.vscode/extensions.json new file mode 100644 index 0000000..6815bc8 --- /dev/null +++ b/common/.vscode/extensions.json @@ -0,0 +1,7 @@ +{ + "recommendations": [ + "editorconfig.editorconfig", + "esbenp.prettier-vscode", + "timonwong.shellcheck" + ] +} diff --git a/tests/new-project.bats b/tests/new-project.bats index 15a2bc2..ac57077 100644 --- a/tests/new-project.bats +++ b/tests/new-project.bats @@ -246,6 +246,13 @@ collect_roots() { # error, so the security contact was a name that cannot receive anything. run grep -rn '@you\b\|you/' "$PROJECT" --include='*.yml' --include='*.md' --include='CODEOWNERS' [ -z "$output" ] || { echo "placeholder left in:"; echo "$output"; false; } + + # Absence of the placeholder is not presence of the owner: a hardcoded or + # mistyped account passes the grep above. tests/helpers/setup.bash exports + # SCAFFOLD_GITHUB_OWNER=test-owner. + run cat "${PROJECT}/CODEOWNERS" + [ "$output" = "* @test-owner" ] \ + || { echo "CODEOWNERS says '${output}', not the account this run resolved"; false; } } @test "no @PROJECT_ placeholder survives into the generated project" { From 703580117a61e2ff818d9faecd848d4d31832ac5 Mon Sep 17 00:00:00 2001 From: iam-truongtrungnghia Date: Sun, 13 Sep 2026 14:27:34 +0700 Subject: [PATCH 5/9] refactor(ci): name every step instead of commenting it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 63 steps across four workflows, none with a name. immich names 297 of its own and sits at 3.8% comments against our 17.5% — the difference is not that they explain less, it is where. A step's name appears in the GitHub log while the job runs; a comment above it appears only to someone reading the file. No comment was deleted. Every one that survives annotates a key that is not a step — a timeout and the measurement behind it, fetch-depth: 0 for a test that walks this repository's history, the zizmor path scope, the `edited` trigger, and the pull-request-body job name being load-bearing for branch protection. None of those is a fact a step's name could carry. --- .github/workflows/adapters.yml | 126 +++++++++++++++++++---------- .github/workflows/ci.yml | 33 +++++--- .github/workflows/provenance.yml | 24 ++++-- .github/workflows/pull-request.yml | 6 +- 4 files changed, 126 insertions(+), 63 deletions(-) diff --git a/.github/workflows/adapters.yml b/.github/workflows/adapters.yml index 04f3a65..262edbc 100644 --- a/.github/workflows/adapters.yml +++ b/.github/workflows/adapters.yml @@ -25,12 +25,15 @@ jobs: tier-a: ${{ steps.tiers.outputs.tier-a }} tier-b: ${{ steps.tiers.outputs.tier-b }} steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 persist-credentials: false - - uses: jdx/mise-action@c2a87611a18de5b3828c5652fe268e992400cb5c # v4.3.0 - - id: tiers + - name: Setup mise + uses: jdx/mise-action@c2a87611a18de5b3828c5652fe268e992400cb5c # v4.3.0 + - name: Discover the adapter tiers + id: tiers env: EVENT_NAME: ${{ github.event_name }} SCHEDULE_CRON: ${{ github.event.schedule }} @@ -54,22 +57,28 @@ jobs: matrix: adapter: ${{ fromJson(needs.discover.outputs.tier-a) }} steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - - uses: jdx/mise-action@c2a87611a18de5b3828c5652fe268e992400cb5c # v4.3.0 - - run: corepack enable - - id: language + - name: Setup mise + uses: jdx/mise-action@c2a87611a18de5b3828c5652fe268e992400cb5c # v4.3.0 + - name: Enable corepack + run: corepack enable + - name: Detect the adapter language + id: language env: ADAPTER: ${{ matrix.adapter }} run: | lang="$(grep '^ADAPTER_LANGUAGE=' "adapters/${ADAPTER}/adapter.env" | cut -d'"' -f2)" echo "value=${lang}" >> "$GITHUB_OUTPUT" - - if: steps.language.outputs.value == 'php' + - name: Setup PHP + if: steps.language.outputs.value == 'php' uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # 2.37.2 with: php-version: "8.3" - - env: + - name: Run the tier-a smoke test + env: ADAPTER: ${{ matrix.adapter }} run: bats "tests/new-${ADAPTER}.bats" @@ -87,22 +96,28 @@ jobs: matrix: adapter: ${{ fromJson(needs.discover.outputs.tier-b) }} steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - - uses: jdx/mise-action@c2a87611a18de5b3828c5652fe268e992400cb5c # v4.3.0 - - run: corepack enable - - id: language + - name: Setup mise + uses: jdx/mise-action@c2a87611a18de5b3828c5652fe268e992400cb5c # v4.3.0 + - name: Enable corepack + run: corepack enable + - name: Detect the adapter language + id: language env: ADAPTER: ${{ matrix.adapter }} run: | lang="$(grep '^ADAPTER_LANGUAGE=' "adapters/${ADAPTER}/adapter.env" | cut -d'"' -f2)" echo "value=${lang}" >> "$GITHUB_OUTPUT" - - if: steps.language.outputs.value == 'php' + - name: Setup PHP + if: steps.language.outputs.value == 'php' uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # 2.37.2 with: php-version: "8.3" - - env: + - name: Run the tier-b smoke test + env: ADAPTER: ${{ matrix.adapter }} run: bats "tests/new-${ADAPTER}.bats" @@ -119,22 +134,28 @@ jobs: matrix: adapter: ${{ fromJson(needs.discover.outputs.tier-a) }} steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - - uses: jdx/mise-action@c2a87611a18de5b3828c5652fe268e992400cb5c # v4.3.0 - - run: corepack enable - - id: language + - name: Setup mise + uses: jdx/mise-action@c2a87611a18de5b3828c5652fe268e992400cb5c # v4.3.0 + - name: Enable corepack + run: corepack enable + - name: Detect the adapter language + id: language env: ADAPTER: ${{ matrix.adapter }} run: | lang="$(grep '^ADAPTER_LANGUAGE=' "adapters/${ADAPTER}/adapter.env" | cut -d'"' -f2)" echo "value=${lang}" >> "$GITHUB_OUTPUT" - - if: steps.language.outputs.value == 'php' + - name: Setup PHP + if: steps.language.outputs.value == 'php' uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # 2.37.2 with: php-version: "8.3" - - env: + - name: Run the deploy check + env: ADAPTER: ${{ matrix.adapter }} run: ./scripts/deploy-check.sh "$ADAPTER" @@ -151,22 +172,28 @@ jobs: matrix: adapter: ${{ fromJson(needs.discover.outputs.tier-b) }} steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - - uses: jdx/mise-action@c2a87611a18de5b3828c5652fe268e992400cb5c # v4.3.0 - - run: corepack enable - - id: language + - name: Setup mise + uses: jdx/mise-action@c2a87611a18de5b3828c5652fe268e992400cb5c # v4.3.0 + - name: Enable corepack + run: corepack enable + - name: Detect the adapter language + id: language env: ADAPTER: ${{ matrix.adapter }} run: | lang="$(grep '^ADAPTER_LANGUAGE=' "adapters/${ADAPTER}/adapter.env" | cut -d'"' -f2)" echo "value=${lang}" >> "$GITHUB_OUTPUT" - - if: steps.language.outputs.value == 'php' + - name: Setup PHP + if: steps.language.outputs.value == 'php' uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # 2.37.2 with: php-version: "8.3" - - env: + - name: Run the deploy check + env: ADAPTER: ${{ matrix.adapter }} run: ./scripts/deploy-check.sh "$ADAPTER" @@ -180,12 +207,16 @@ jobs: contents: read timeout-minutes: 30 steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - - uses: jdx/mise-action@c2a87611a18de5b3828c5652fe268e992400cb5c # v4.3.0 - - run: corepack enable - - run: ./scripts/deploy-check.sh nextjs nestjs --db postgres + - name: Setup mise + uses: jdx/mise-action@c2a87611a18de5b3828c5652fe268e992400cb5c # v4.3.0 + - name: Enable corepack + run: corepack enable + - name: Run the deploy check for two apps + run: ./scripts/deploy-check.sh nextjs nestjs --db postgres compose: # expensive relative to the other checks here, so gated on the weekly @@ -196,14 +227,18 @@ jobs: contents: read timeout-minutes: 10 steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - - uses: jdx/mise-action@c2a87611a18de5b3828c5652fe268e992400cb5c # v4.3.0 - - uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # 2.37.2 + - name: Setup mise + uses: jdx/mise-action@c2a87611a18de5b3828c5652fe268e992400cb5c # v4.3.0 + - name: Setup PHP + uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # 2.37.2 with: php-version: "8.3" - - run: bats tests/compose.bats + - name: Run the compose tests + run: bats tests/compose.bats services: # Eight combinations per adapter at minutes each, so the full grid is @@ -220,22 +255,28 @@ jobs: db: [mysql, postgres, mongodb, none] cache: [none, redis] steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - - uses: jdx/mise-action@c2a87611a18de5b3828c5652fe268e992400cb5c # v4.3.0 - - run: corepack enable - - id: language + - name: Setup mise + uses: jdx/mise-action@c2a87611a18de5b3828c5652fe268e992400cb5c # v4.3.0 + - name: Enable corepack + run: corepack enable + - name: Detect the adapter language + id: language env: ADAPTER: ${{ matrix.adapter }} run: | lang="$(grep '^ADAPTER_LANGUAGE=' "adapters/${ADAPTER}/adapter.env" | cut -d'"' -f2)" echo "value=${lang}" >> "$GITHUB_OUTPUT" - - if: steps.language.outputs.value == 'php' + - name: Setup PHP + if: steps.language.outputs.value == 'php' uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # 2.37.2 with: php-version: "8.3" - - env: + - name: Generate the project and run its checklist + env: ADAPTER: ${{ matrix.adapter }} DB: ${{ matrix.db }} CACHE: ${{ matrix.cache }} @@ -266,7 +307,8 @@ jobs: issues: write timeout-minutes: 5 steps: - - env: + - name: Open an issue for the failed run + env: GH_TOKEN: ${{ github.token }} run: >- gh issue create --repo "${{ github.repository }}" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f85748e..20a0bea 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,11 +22,14 @@ jobs: # on a runner, which is what the timeout below leaves room around. timeout-minutes: 5 steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - - uses: jdx/mise-action@c2a87611a18de5b3828c5652fe268e992400cb5c # v4.3.0 - - run: mise run ci-unit + - name: Setup mise + uses: jdx/mise-action@c2a87611a18de5b3828c5652fe268e992400cb5c # v4.3.0 + - name: Run the unit lane + run: mise run ci-unit integration: runs-on: ubuntu-latest @@ -39,7 +42,8 @@ jobs: # tests, not these cross-cutting mechanics suites. timeout-minutes: 25 steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: # workflows.bats walks this repository's own history to find the # commit that introduced an adapter, then diffs against its parent. @@ -47,12 +51,16 @@ jobs: # on `unknown revision` rather than on anything it is testing. fetch-depth: 0 persist-credentials: false - - uses: jdx/mise-action@c2a87611a18de5b3828c5652fe268e992400cb5c # v4.3.0 - - run: corepack enable - - uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # 2.37.2 + - name: Setup mise + uses: jdx/mise-action@c2a87611a18de5b3828c5652fe268e992400cb5c # v4.3.0 + - name: Enable corepack + run: corepack enable + - name: Setup PHP + uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # 2.37.2 with: php-version: "8.3" - - run: mise run test-integration + - name: Run the integration lane + run: mise run test-integration zizmor: runs-on: ubuntu-latest @@ -60,10 +68,12 @@ jobs: contents: read timeout-minutes: 5 steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - - uses: jdx/mise-action@c2a87611a18de5b3828c5652fe268e992400cb5c # v4.3.0 + - name: Setup mise + uses: jdx/mise-action@c2a87611a18de5b3828c5652fe268e992400cb5c # v4.3.0 # Only this repository's own workflows. common/ holds templates whose # `uses: you/.github/...` names no real repository, so ref-confusion # cannot resolve it and the audit errors out rather than reporting a @@ -71,4 +81,5 @@ jobs: # What a generated project's call sites get instead is structural: # 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/ + - name: Run zizmor + run: mise exec -- zizmor .github/workflows/ diff --git a/.github/workflows/provenance.yml b/.github/workflows/provenance.yml index bd7b175..2d104e3 100644 --- a/.github/workflows/provenance.yml +++ b/.github/workflows/provenance.yml @@ -24,15 +24,19 @@ jobs: contents: read timeout-minutes: 15 steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - - uses: jdx/mise-action@c2a87611a18de5b3828c5652fe268e992400cb5c # v4.3.0 + - name: Setup mise + uses: jdx/mise-action@c2a87611a18de5b3828c5652fe268e992400cb5c # v4.3.0 # tests/provenance.bats needs a local upstream clone the same as # check does below — its own copy here, the way adapters.yml's # tier-b matrix legs each do their own generation independently. - - run: git clone --filter=blob:none --no-checkout https://github.com/immich-app/immich.git "${RUNNER_TEMP}/immich" - - env: + - name: Clone upstream immich + run: git clone --filter=blob:none --no-checkout https://github.com/immich-app/immich.git "${RUNNER_TEMP}/immich" + - name: Run the provenance self-test + env: SCAFFOLD_UPSTREAM_CLONE: ${{ runner.temp }}/immich run: bats tests/provenance.bats @@ -44,14 +48,17 @@ jobs: issues: write timeout-minutes: 15 steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false # check-provenance.sh has no network fallback of its own (a denied # fetch must fail loudly, not read as "everything drifted"); a # partial clone gets the full commit graph without every blob. - - run: git clone --filter=blob:none --no-checkout https://github.com/immich-app/immich.git "${RUNNER_TEMP}/immich" - - id: check + - name: Clone upstream immich + run: git clone --filter=blob:none --no-checkout https://github.com/immich-app/immich.git "${RUNNER_TEMP}/immich" + - name: Check for upstream drift + id: check env: SCAFFOLD_UPSTREAM_CLONE: ${{ runner.temp }}/immich run: ./scripts/check-provenance.sh | tee "${RUNNER_TEMP}/report.txt" @@ -60,7 +67,8 @@ jobs: # this step would silently never run. failure() first makes the # step eligible; steps.check.outcome narrows it to the real check # specifically, not some other step failing. - - if: failure() && steps.check.outcome == 'failure' + - name: Open an issue for upstream drift + if: failure() && steps.check.outcome == 'failure' env: GH_TOKEN: ${{ github.token }} run: | diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml index ada49a1..8bb7c19 100644 --- a/.github/workflows/pull-request.yml +++ b/.github/workflows/pull-request.yml @@ -30,10 +30,12 @@ jobs: contents: read timeout-minutes: 5 steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - - env: + - name: Check the pull request body against the template + env: BODY: ${{ github.event.pull_request.body }} run: | missing="" From c1c5af5ca152a0d446ed045fab8499635a2de27d Mon Sep 17 00:00:00 2001 From: iam-truongtrungnghia Date: Sun, 13 Sep 2026 15:43:38 +0700 Subject: [PATCH 6/9] refactor: local -r, and comments down to their facts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit immich's install.sh is 107 lines with three comments because main() reads as prose and the function names narrate. Ours has that shape already and still carried a paragraph above each function. local -r for locals assigned once, which is function scope and unlike the file-level readonly this repository rejects — these libraries are re-sourced into child processes by design. What stays is the third-party landmines: jq rather than grep for an asset id, two endpoints for a private release, the trap baking its path and naming its signals, sed delimited on | because a base64 value contains /, and BASH_SOURCE[0]:-$0 because a curl-piped script has none. --- common/install.sh | 98 ++++++++++------------------- lib/adapter.sh | 19 +++--- lib/contract.sh | 20 +++--- lib/lint.sh | 16 ++--- lib/log.sh | 2 +- lib/manifest.sh | 9 +-- lib/pnpm.sh | 30 ++++----- lib/project.sh | 93 ++++++++++----------------- lib/publish.sh | 7 ++- lib/service.sh | 55 +++++++++------- lib/tui.sh | 20 +++--- lib/update.sh | 19 +++--- lib/wizard.sh | 9 +-- services/mongodb/drivers/laravel.sh | 55 +++++++--------- services/shared/laravel.sh | 32 +++++----- services/shared/nest.sh | 78 +++++++++-------------- 16 files changed, 247 insertions(+), 315 deletions(-) diff --git a/common/install.sh b/common/install.sh index b106d99..b4b2d3c 100755 --- a/common/install.sh +++ b/common/install.sh @@ -11,28 +11,22 @@ # curl -fsSL https://github.com/you/@PROJECT_NAME@/releases/latest/download/install.sh | bash # ═══════════════════════════════════════════════════════════════════════════ # -# Adapted from immich's install.sh -# (https://github.com/immich-app/immich/blob/main/install.sh); this project -# never overwrites an existing .env (see download_release_assets). +# Adapted from immich's install.sh; unlike immich, never overwrites an existing +# .env (see download_release_assets). set -o nounset set -o pipefail -# Substituted at generation time from the GitHub owner scaffold resolved and -# this project's own name, the same pair compose.yaml's image and the build -# workflows carry. It assumes the repository is named after the project -# directory; rename it and this line needs the new name too. +# Substituted at generation time; assumes the repo is named after the project +# directory. RepoUrl='https://github.com/you/@PROJECT_NAME@/releases/latest/download' TargetDir='./app' -# The owner/repo pair, taken from RepoUrl so a project still edits one line. RepoSlug="${RepoUrl#https://github.com/}" RepoSlug="${RepoSlug%/releases/latest/download}" -# The literal every password in the assembled example.env carries, and the -# contract example.env's own header states. Matched on the value rather than a -# *_PASSWORD name pattern: a service naming its variable differently (e.g. -# RABBITMQ_DEFAULT_PASS) still needs a real value generated for it. +# Matched on the placeholder value, not a *_PASSWORD name pattern, so a +# differently-named variable (e.g. RABBITMQ_DEFAULT_PASS) still gets a real value. PasswordPlaceholder='changeme' PasswordBytes=32 PasswordLength=24 @@ -41,12 +35,12 @@ PasswordLength=24 # release_asset_id — reads a release's JSON on stdin. # -# jq, not grep: an asset's own id precedes its name while the uploader's follows -# it, so "find the name, take the next id" returns the uploader's for every -# asset — and that request succeeds, fetching a different valid object. Only the -# token path needs jq, so it stays off the public path's dependencies. +# jq, not grep: an asset's own id precedes its name while the uploader's +# follows it, so "find the name, take the next id" returns the uploader's id — +# and that request succeeds, fetching a different valid object. release_asset_id() { - local name="$1" id + local -r name="$1" + local id id="$(jq -r --arg name "$name" \ 'first(.assets[] | select(.name == $name) | .id) // empty')" || return 1 if [ -z "$id" ]; then @@ -59,15 +53,13 @@ release_asset_id() { # fetch_release_asset # # Two endpoints: a private release's browser URL returns 404 both anonymously -# and with a Bearer token, while the API asset endpoint returns 200. A token -# alone does not fix the public URL — the URL is what has to change. +# and with a Bearer token, while the API asset endpoint returns 200. fetch_release_asset() { - local name="$1" dest="$2" id + local -r name="$1" dest="$2" + local id if [ -z "${GITHUB_TOKEN:-}" ]; then curl -fsSL "${RepoUrl}/${name}" -o "$dest" && return 0 - # A private release answers 404 to an anonymous request, which reads as "no - # such release" rather than "you are not signed in". echo "could not download ${name}; if this project is private, set GITHUB_TOKEN to a token with repo and read:packages" >&2 return 1 fi @@ -82,15 +74,11 @@ fetch_release_asset() { -H "Authorization: Bearer ${GITHUB_TOKEN}" \ -H 'Accept: application/octet-stream' \ "https://api.github.com/repos/${RepoSlug}/releases/assets/${id}" -o "$dest" && return 0 - # A token given but rejected by this endpoint is the token's problem, not its - # absence — this message must not repeat the no-token hint above. echo "could not download ${name} with the token given; it needs repo and read:packages" >&2 return 1 } -# jq is needed only to read a release's JSON, which only the token path does. -# Checked separately from main's curl/docker checks so a public install never -# learns about a dependency it does not use. +# Checked separately from main's other checks, so a public install never needs jq. require_private_tools() { [ -n "${GITHUB_TOKEN:-}" ] || return 0 command -v jq >/dev/null || { @@ -108,9 +96,8 @@ create_directory() { cd "$TargetDir" || return 1 } -# compose.yaml is always overwritten so it never drifts from the image it names. -# .env never is: it holds this installation's real password and the operator's -# edits. A kept .env is still checked for a password left at the placeholder. +# compose.yaml is always overwritten; a kept .env is only checked for a +# password left at the placeholder. # # Two cleanup mechanisms, both needed so no temp file is left holding a # plaintext password: the explicit `rm -f` before each `return 1`, since an EXIT @@ -148,8 +135,6 @@ download_release_assets() { rm -f "$tmp_env" return 1 fi - # Checked, like every other step here: an unchecked mv returns 0 through the - # trap below, reporting success and leaving the password file behind. if ! mv "$tmp_env" ./.env; then rm -f "$tmp_env" trap - EXIT INT TERM HUP @@ -161,17 +146,14 @@ download_release_assets() { # ─── configuring it ──────────────────────────────────────────────────────── -# Fails hard if a substitution misses: a password left at the placeholder is a -# credential defaulting to a known value. -# # Known, not fixed: each password is briefly visible in sed's argv to other -# local users. Pre-existing in the immich script this came from. +# local users. generate_service_passwords() { - local file="$1" name password + local -r file="$1" + local name password while IFS= read -r name; do # APP_KEY is not a password: laravel decrypts with it and rejects anything - # that is not `base64:` plus exactly 32 bytes. Inside this loop so - # example.env keeps one placeholder and the existing-.env guard covers it. + # that is not `base64:` plus exactly 32 bytes. if [ "$name" = APP_KEY ]; then password="base64:$(head -c "$PasswordBytes" /dev/urandom | base64)" else @@ -188,9 +170,8 @@ generate_service_passwords() { done < <(sed -n "s/^\([A-Za-z_][A-Za-z0-9_]*\)=${PasswordPlaceholder}\$/\1/p" "$file") } -# scaffold fills the image in, so this only catches a copy hand-edited back to a -# placeholder: docker rejects that itself, but with "invalid reference format" -# rather than anything actionable. +# Catches a copy hand-edited back to the placeholder; docker itself would only +# report an unhelpful "invalid reference format". require_configured_image() { if grep -i 'image:.*CHANGEME' compose.yaml >/dev/null; then echo "compose.yaml's image line still has a CHANGEME placeholder; edit it to this project's real registry path, then re-run this script" @@ -201,13 +182,9 @@ require_configured_image() { # ─── running it ──────────────────────────────────────────────────────────── start_stack() { - # A package's ghcr visibility is separate from its repository's, and a private - # package refuses an anonymous pull with `unauthorized`. The username is not - # checked for a token login; RepoSlug's owner just names something the - # operator recognises. - # - # --password-stdin, not an argument: argv is visible to every other user on - # the host through the process list. + # ghcr package visibility is separate from repository visibility; a private + # package refuses an anonymous pull with `unauthorized`. + # --password-stdin, not an argument: argv is visible to every other user on the host. if [ -n "${GITHUB_TOKEN:-}" ]; then printf '%s' "${GITHUB_TOKEN}" \ | docker login ghcr.io -u "${RepoSlug%%/*}" --password-stdin >/dev/null || { @@ -218,36 +195,29 @@ start_stack() { docker compose up --remove-orphans -d || return 1 } -# ADR-0014 seam 5 forbids migrations from an *entrypoint* — a container that -# migrates every time it starts cannot be scaled or rolled back. This is a human -# running one command on the target host. -# compose_has_service [--profile ] # Captured, never piped into `grep -q`: grep closes the pipe on its first match, # `docker compose` then dies of SIGPIPE, and `set -o pipefail` reports the whole # pipeline as failed. Measured at roughly one run in seven — a stack that # refused to migrate, at random, with a message about a service that was there. compose_has_service() { - local service="$1"; shift + local -r service="$1"; shift local services services="$(docker compose "$@" config --services)" || return 1 grep -qx "$service" <<<"$services" } +# ADR-0014 seam 5 forbids migrations from an entrypoint, so this is a human +# running one command on the target host. run_migrations() { - # `docker compose config --services` (no --profile) never lists a service - # gated behind a profile, so that guard alone always skipped the migration - # silently — measured: plain `config --services` prints only `app`, and - # `--profile migrate config --services` prints `migrate app`. + # `config --services` with no --profile never lists a service gated behind one. if compose_has_service migrate --profile migrate; then echo "running migrations..." docker compose --profile migrate run --rm migrate return fi - # A database with no migrate service beside it is not "nothing to migrate": - # every database driver ships a migrate command, so this only happens if the - # service, its profile or the command silently vanished. A project with no - # database is the only case that falls through. + # Every database driver ships a migrate command, so a database with none means + # the service, its profile or the command vanished — not "nothing to migrate". if compose_has_service database; then echo "a database service exists but no migrate service was found — refusing to start with unapplied schema" >&2 return 1 @@ -265,8 +235,8 @@ main() { start_stack || { echo 'could not start the stack; check the output above'; return 1; } run_migrations || { echo 'could not run migrations; check the output above'; return 1; } - # One line per application (ADR-0022), read out of .env so it reports the - # ports actually in effect, including any the operator changed. + # One line per application (ADR-0022), read out of .env so it reflects any + # port the operator changed. local name port while IFS='=' read -r name port; do [ -n "$port" ] || continue diff --git a/lib/adapter.sh b/lib/adapter.sh index 9196670..2126169 100644 --- a/lib/adapter.sh +++ b/lib/adapter.sh @@ -17,7 +17,7 @@ ADAPTER_OPTIONAL_VARS=( ) load_adapter() { - local name="$1" + 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 @@ -26,7 +26,7 @@ load_adapter() { ''|*[!a-z0-9-]*|-*) die "not a usable adapter name: ${name} (run: scaffold list)" ;; esac - local dir="${SCAFFOLD_ROOT}/adapters/${name}" + local -r dir="${SCAFFOLD_ROOT}/adapters/${name}" [ -d "$dir" ] || die "unknown adapter: ${name} (run: scaffold list)" ADAPTER_DIR="$dir" @@ -60,7 +60,8 @@ role_path() { } merge_lefthook_fragment() { - local fragment="$1" project="$2" rel="$3" rendered + local -r fragment="$1" project="$2" rel="$3" + local rendered [ -f "$fragment" ] || return 0 @@ -93,7 +94,7 @@ merge_lefthook_fragment() { # installed and dies steps later on a COPY of a node_modules that was never # created. Skipped for Laravel: composer has no --filter to miss. assert_workspace_filter_name() { - local dest="$1" + local -r dest="$1" [ -f "${ADAPTER_DIR}/Dockerfile.workspace" ] || return 0 @@ -108,8 +109,8 @@ assert_workspace_filter_name() { # name: `scaffold add` can place an adapter at any path, so the filter cannot be # baked to the role at adapter-authoring time. substitute_workspace_filter() { - local dest="$1" - local file="${dest}/Dockerfile.workspace" + local -r dest="$1" + local -r file="${dest}/Dockerfile.workspace" [ -f "$file" ] || return 0 @@ -121,7 +122,7 @@ substitute_workspace_filter() { # .env.example is not skipped; directories merge rather than replace, since # `src/` exists after the generator ran and `cp -R src dest/src` nests it. copy_adapter_files() { - local dest="$1" + local -r dest="$1" local file base dir had_dotglob=0 shopt -q dotglob && had_dotglob=1 @@ -144,11 +145,11 @@ copy_adapter_files() { } apply_adapter() { - local name="$1" project="$2" rel="$3" + local -r name="$1" project="$2" rel="$3" load_adapter "$name" - local dest="${project}/${rel}" + local -r dest="${project}/${rel}" local parent; parent="$(dirname "$dest")" mkdir -p "$parent" diff --git a/lib/contract.sh b/lib/contract.sh index 579dccc..62b840e 100644 --- a/lib/contract.sh +++ b/lib/contract.sh @@ -10,15 +10,14 @@ 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 own -fix sibling. Cannot catch a -# tool that writes by default with no flag saying so. +# Catches a read-only task copied from its -fix sibling, not a tool that writes +# by default with no flag saying so. WRITING_FLAGS=(--write --fix -w --in-place --overwrite) REQUIRED_ADAPTER_FILES=(adapter.env mise.toml Dockerfile .env.example) -# Both are read mid-generation — ADAPTER_GENERATOR by apply_adapter's eval, -# ADAPTER_FAMILY by the drivers/ lookup — so missing, they fail there with -# `unbound variable` instead of at `scaffold lint`. +# ADAPTER_GENERATOR and ADAPTER_FAMILY are read mid-generation, not at +# `scaffold lint`; missing, they fail there with `unbound variable`. REQUIRED_ADAPTER_VARS=(ADAPTER_NAME ADAPTER_ROLE ADAPTER_FAMILY ADAPTER_GENERATOR ADAPTER_LIVENESS_PATH) REQUIRED_SERVICE_FILES=( @@ -30,8 +29,7 @@ REQUIRED_SERVICE_FILES=( env.fragment ) -# SERVICE_IMAGE is the one place a service's digest is written — the compose -# fragments carry no image line, so a bump here reaches all three lanes at once. +# SERVICE_IMAGE is the one place a service's digest is written; the compose fragments carry no image line. REQUIRED_SERVICE_VARS=(SERVICE_NAME SERVICE_KIND SERVICE_IMAGE) # Holds the parameterised driver bodies every service sources, not a service. @@ -40,11 +38,9 @@ SHARED_DRIVERS_DIR=shared # A cache implements compose_migrate too: it has no schema and prints nothing. REQUIRED_DRIVER_FUNCTIONS=(service_driver_apply service_driver_dockerfile service_driver_compose_env service_driver_compose_migrate) -# The web tier opens no connection, so it takes no driver. Stated once about the -# role rather than as a "not applicable" entry in every service. +# The web tier opens no connection, so it takes no driver. DRIVEN_ROLES=(api app) -# What cmd_new picks when a project has a backend and --db was not given -# (ADR-0020). The wizard's default ordering reads this too, so a plain Enter -# cannot drift from what an omitted flag would pick. +# 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. DEFAULT_DATABASE_SERVICE=mysql diff --git a/lib/lint.sh b/lib/lint.sh index 2973757..d37047f 100644 --- a/lib/lint.sh +++ b/lib/lint.sh @@ -57,7 +57,7 @@ driver_families() { # any, so a caller can run them all and still fail once at the end. lint_required_files() { - local name="$1" dir="$2"; shift 2 + local -r name="$1" dir="$2"; shift 2 local file status=0 for file in "$@"; do @@ -70,7 +70,7 @@ lint_required_files() { } lint_adapter_env() { - local name="$1" file="$2" + local -r name="$1" file="$2" local var role value status=0 for var in "${REQUIRED_ADAPTER_VARS[@]}"; do @@ -113,7 +113,7 @@ lint_adapter_env() { } lint_adapter_tasks() { - local name="$1" file="$2" + local -r name="$1" file="$2" local task body flag status=0 for task in "${CONTRACT_TASKS[@]}"; do @@ -141,7 +141,7 @@ lint_adapter_tasks() { } lint_adapters() { - local dir="$1" + local -r dir="$1" local adapter name status=0 for adapter in "$dir"/*/; do @@ -157,7 +157,7 @@ lint_adapters() { } lint_service_env() { - local name="$1" file="$2" + local -r name="$1" file="$2" local var status=0 for var in "${REQUIRED_SERVICE_VARS[@]}"; do @@ -181,7 +181,7 @@ lint_service_env() { # it at sourcing time and finds it unbound dies under the inherited `set -u`, # which is not the same problem as a missing function. lint_driver_functions() { - local name="$1" family="$2" driver="$3" service_dir="$4" + local -r name="$1" family="$2" driver="$3" service_dir="$4" local fn fault status=0 for fn in "${REQUIRED_DRIVER_FUNCTIONS[@]}"; do @@ -205,7 +205,7 @@ lint_driver_functions() { } lint_service_drivers() { - local name="$1" service="$2"; shift 2 + local -r name="$1" service="$2"; shift 2 local family driver status=0 for family in "$@"; do @@ -225,7 +225,7 @@ lint_service_drivers() { # Fails when any service is incomplete, or when a family that takes a driver has # no driver in some service. lint_services() { - local dir="$1" adapters="$2" + local -r dir="$1" adapters="$2" local service name status=0 local -a families=() diff --git a/lib/log.sh b/lib/log.sh index 7e5ab31..86d4d2b 100644 --- a/lib/log.sh +++ b/lib/log.sh @@ -17,7 +17,7 @@ step() { printf '→ %s\n' "$*" >&2; } # Captures output and prints it only on failure; SCAFFOLD_VERBOSE=1 passes it # straight through, for a run that hangs rather than fails. run_quietly() { - local what="$1"; shift + local -r what="$1"; shift local log status=0 if [ "${SCAFFOLD_VERBOSE:-0}" = 1 ]; then diff --git a/lib/manifest.sh b/lib/manifest.sh index 5e63f49..69a88de 100644 --- a/lib/manifest.sh +++ b/lib/manifest.sh @@ -14,8 +14,8 @@ CI_WORKFLOW=".github/workflows/ci.yml" BUILD_WORKFLOWS=(".github/workflows/build.yml" ".github/workflows/release.yml") register_config_root() { - local project="$1" root="$2" - local file="${project}/${MISE_CONFIG_FILE}" + 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 @@ -53,7 +53,8 @@ config_roots() { } sync_ci_roots() { - local project="$1" json + local -r project="$1" + local json json="$(config_roots "$project" | jq -R . | jq -sc .)" sed -i.bak "s|^ roots: .*| roots: '${json}'|" \ "${project}/${CI_WORKFLOW}" @@ -64,7 +65,7 @@ sync_ci_roots() { # build workflows pass on (ADR-0022). Called after the workspace decision is # settled, since the build context depends on it. register_image_target() { - local project="$1" rel="$2" + local -r project="$1" rel="$2" local name context dockerfile image file current updated name="$(app_service_key "$rel")" diff --git a/lib/pnpm.sh b/lib/pnpm.sh index 6d564c8..f8fc9ef 100644 --- a/lib/pnpm.sh +++ b/lib/pnpm.sh @@ -57,8 +57,9 @@ restore_pnpm_workspace() { # whether the command was `new` or `add`, decides which Dockerfile variant an # app needs and what its build context has to be. app_is_workspace_member() { - local project="$1" rel="$2" - local workspace_file="${project}/${WORKSPACE_FILE}" glob + local -r project="$1" rel="$2" + local -r workspace_file="${project}/${WORKSPACE_FILE}" + local glob [ -f "$workspace_file" ] || return 1 @@ -75,7 +76,8 @@ app_is_workspace_member() { # 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_install() { - local dir="$1" what="$2" log status=0 + local -r dir="$1" what="$2" + local log status=0 step "$what" log="$(mktemp)" @@ -102,7 +104,7 @@ pnpm_install() { # create-next-app writes its own nested pair, and pnpm's upward search finds # those first — so the app never resolves as part of the outer workspace. sync_workspace_lockfile() { - local project="$1" + local -r project="$1" find "$project" -mindepth 3 -maxdepth 3 \ \( -name "$LOCKFILE" -o -name "$WORKSPACE_FILE" \) -delete @@ -121,10 +123,10 @@ sync_workspace_lockfile() { # frozen install, not just the first, so relaxing it for one call would not # hold. The policy stays live for everything the project adds later. record_release_age_exceptions() { - local project="$1" - local settings="${2:-$1}" + local -r project="$1" + local -r settings="${2:-$1}" step "checking $(basename "$project")'s lockfile against the supply-chain policy" - local workspace_file="${settings}/${WORKSPACE_FILE}" + 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. @@ -182,7 +184,7 @@ record_release_age_exceptions() { # Only called when every application is typescript; sharing types across a # language boundary is a different problem, solved by openapi. enable_typescript_workspace() { - local project="$1" + local -r project="$1" mkdir -p "${project}/packages" mv "${project}/packages-types" "${project}/packages/types" @@ -195,8 +197,8 @@ enable_typescript_workspace() { # carrying ADR-0017's allowBuilds. Merged, not copied: common wins on a key both # name, the app's own generator keeps any key only it names. sync_standalone_build_policy() { - local app="$1" project="$2" - local file="${app}/${WORKSPACE_FILE}" + local -r app="$1" project="$2" + local -r file="${app}/${WORKSPACE_FILE}" [ -f "$file" ] || printf '{}\n' > "$file" @@ -209,8 +211,8 @@ sync_standalone_build_policy() { # Dockerfile and Dockerfile.workspace; exactly one may survive, whichever # app_is_workspace_member matches. finalize_app_dockerfile() { - local project="$1" rel="$2" - local dir="${project}/${rel}" + local -r project="$1" rel="$2" + local -r dir="${project}/${rel}" [ -f "${dir}/Dockerfile.workspace" ] || return 0 @@ -225,7 +227,7 @@ finalize_app_dockerfile() { # 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 project="$1"; shift + local -r project="$1"; shift enable_typescript_workspace "$project" @@ -248,7 +250,7 @@ join_typescript_workspace() { # 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 + local -r project="$1"; shift rm -rf "${project}/packages-types" diff --git a/lib/project.sh b/lib/project.sh index 26245e0..91346a5 100644 --- a/lib/project.sh +++ b/lib/project.sh @@ -5,31 +5,27 @@ # ═══════════════════════════════════════════════════════════════════════════ # shellcheck shell=bash -# Where a generated project records its own origin. Its own file rather than a -# `[vars]` entry: the apps table is a mapping, and mise's vars are flat strings. +# Its own file rather than a `[vars]` entry: the apps table is a mapping, and +# mise's vars are flat strings. SCAFFOLD_MANIFEST=".scaffold.toml" # Shared by init_project's die() and the wizard's prompt, so a rejected name # gets the same sentence either way. PROJECT_NAME_RULE="a project name must start with a lowercase letter or digit, and may contain only lowercase letters, digits, '.', '_' and '-'" -# init_project writes this and nothing else has a reason to; mise.toml alone is -# not proof, since any repository can carry one. +# init_project writes this; mise.toml alone is not proof, since any repository +# can carry one. PROJECT_MARKER="monorepo_root = true" -# The first commit is boilerplate, not authored by a person, so it must not -# depend on an ambient git config a CI runner does not have. +# Not an ambient git config, which a CI runner does not have. PROJECT_COMMIT_NAME="scaffold" PROJECT_COMMIT_EMAIL="scaffold@scaffold.invalid" -# Files carrying the `you/` placeholder, alongside every workflow. mise.root.toml -# carries the registry path ([vars] image) and must be substituted before it -# becomes mise.toml. +# Files carrying 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. migrate inherits it later, from -# assemble_compose copying the app image across. +# 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 @@ -39,12 +35,8 @@ PROJECT_NAME_FILES=( # heading, which want the capital project_name_is_usable forbids. PROJECT_TITLE_FILES=(docs/.vitepress/config.ts docs/index.md README.md) -# The account owning the generated workflows' `uses:` and image refs. Dies -# rather than shipping `you/`, which fails only on the first push. -# # `gh api user`, not `gh auth status`: the former reports who the token belongs -# to, the latter what login recorded, which goes stale after a rename. Seen -# disagreeing here. +# to, the latter what login recorded — seen disagreeing after a rename. resolve_github_owner() { local owner="${SCAFFOLD_GITHUB_OWNER:-}" source="" @@ -60,9 +52,8 @@ resolve_github_owner() { [ -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 '" - # This is interpolated into `sed s|you/|...|`, and GNU sed's s///e flag runs - # the pattern space as a shell command — an owner containing `|` is remote - # code execution. GitHub's own rule is alphanumerics and single hyphens. + # 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. case "$owner" in *[!A-Za-z0-9-]*|-*|*-) die "not a usable GitHub account name: ${owner}" ;; @@ -71,13 +62,11 @@ resolve_github_owner() { printf '%s' "$owner" } -# project_name_is_usable -# The name goes into `sed s|@PROJECT_NAME@|...|`, where a `|` closes the -# expression early and a `&` expands to the whole match — an unchecked name can -# rewrite the file it is written into. The same characters are illegal in an OCI -# image name, so one rule covers both. +# 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. project_name_is_usable() { - local name="$1" + local -r name="$1" case "$name" in [a-z0-9]*) ;; @@ -88,11 +77,9 @@ project_name_is_usable() { esac } -# scaffold_version — which toolbox produced a given project, in one string. -# `git describe`, not a VERSION file: every install of this toolbox is a clone, -# and a file goes stale the first time someone forgets to bump it. `--dirty` is -# the point as much as the tag — a project generated from uncommitted edits -# cannot be reproduced from any commit, and the string has to say so. +# `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. scaffold_version() { local version version="$(git -C "$SCAFFOLD_ROOT" describe --tags --always --dirty 2>/dev/null)" \ @@ -104,11 +91,8 @@ is_scaffold_project() { [ -f "${1}/mise.toml" ] && grep -q "^${PROJECT_MARKER}\$" "${1}/mise.toml" } -# init_scaffold_manifest -# Without this a generated project has no record of what produced it, and -# `scaffold update` has no "since when" to diff against. init_scaffold_manifest() { - local project="$1" + 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. @@ -128,8 +112,8 @@ EOF } record_scaffold_app() { - local project="$1" rel="$2" adapter="$3" - local file="${project}/${SCAFFOLD_MANIFEST}" + local -r project="$1" rel="$2" adapter="$3" + local -r file="${project}/${SCAFFOLD_MANIFEST}" [ -f "$file" ] \ || die "no ${SCAFFOLD_MANIFEST} in ${project} — this project predates it; see 'scaffold update'" @@ -141,7 +125,7 @@ record_scaffold_app() { } substitute_in_files() { - local expression="$1"; shift + local -r expression="$1"; shift local file for file in "$@"; do @@ -151,7 +135,7 @@ substitute_in_files() { } init_project() { - local dir="$1" name="$2" + local -r dir="$1" name="$2" project_name_is_usable "$name" || die "${PROJECT_NAME_RULE}: ${name}" @@ -161,16 +145,15 @@ init_project() { owner="$(resolve_github_owner)" mkdir -p "$dir" - # From here on this run owns $dir; a later step failing must remove it, not - # leave debris behind the overwrite guard above. $dir is baked into the trap - # command so it survives this function's locals going away; $? stays deferred. + # $dir is baked into the trap command with printf %q so it survives this + # function's locals going away; a later step failing must clean up $dir. # shellcheck disable=SC2064 # $dir expanding now is intentional; $? is escaped and still deferred trap "cmd_new_cleanup $(printf '%q' "$dir") \"\$?\"" EXIT git -C "$dir" init --initial-branch=main --quiet cp -R "${SCAFFOLD_ROOT}/common/." "${dir}/" - # cp -R preserves the committed executable bit, but that depends on the - # source checkout's own mode surviving clone/checkout (e.g. core.fileMode). + # cp -R's preserved executable bit depends on the source checkout's own mode + # surviving clone/checkout (e.g. core.fileMode). chmod +x "${dir}/install.sh" local -a owner_files=("${dir}/.github/workflows/"*.yml) @@ -178,9 +161,7 @@ init_project() { substitute_in_files "s|you/|${owner}/|g" "${owner_files[@]}" # CODEOWNERS carries the placeholder as `@you`, which the pattern above does - # not match. SECURITY.md points vulnerability reports at whoever CODEOWNERS - # names, and GitHub treats an unresolvable owner as a syntax error — an - # untouched file here makes the security contact unreachable. + # not match; GitHub treats an unresolvable owner in it as a syntax error. substitute_in_files "s|@you\b|@${owner}|g" "${dir}/CODEOWNERS" sed "s|@PROJECT_NAME@|${name}|g" "${dir}/mise.root.toml" > "${dir}/mise.toml" @@ -193,34 +174,28 @@ init_project() { mise trust -y --quiet -C "$dir" } -# lock_toolchains # `mise install` writes a lockfile naming versions but no download URLs when # the tools were already in the local cache, and CI's `mise install --locked` # rejects exactly that file. `mise lock` fills in the URLs and checksums. lock_toolchains() { - # a mise.toml above the new project is neither trusted nor necessarily - # parseable, and mise reads it before ours. That breaks locking but not the - # project, so say so and leave the environment to whoever owns it. + # 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 \ || warn "could not lock the toolchain — run 'mise lock' before committing mise.lock, or CI's 'mise install --locked' will reject it" } finalize_project() { - local project="$1" + local -r project="$1" sync_ci_roots "$project" lock_toolchains "$project" git -C "$project" add -A - # `feat:`, not `chore:`. Release Please hides chore from the changelog and - # cuts nothing for it, so a new project's first push ran the release - # workflow, found no releasable commit and finished green with no release — - # leaving install.sh with nothing to download. This commit really is the - # project's first feature, and the release it cuts from 0.0.0 is v1.0.0. + # `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 in git's own precedence, so a caller that exports one - # would otherwise still leak through. + # outrank `-c` config, so a caller that exports one would otherwise leak through. GIT_AUTHOR_NAME="$PROJECT_COMMIT_NAME" GIT_AUTHOR_EMAIL="$PROJECT_COMMIT_EMAIL" \ GIT_COMMITTER_NAME="$PROJECT_COMMIT_NAME" GIT_COMMITTER_EMAIL="$PROJECT_COMMIT_EMAIL" \ git -C "$project" commit --quiet -m "feat: scaffold project" diff --git a/lib/publish.sh b/lib/publish.sh index 078003a..df7156d 100644 --- a/lib/publish.sh +++ b/lib/publish.sh @@ -35,7 +35,7 @@ gh_repo_exists() { } create_repo() { - local project="$1" slug="$2" visibility="$3" + 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 @@ -72,7 +72,8 @@ main_is_protected() { # changes whenever an application is added. Requiring a pull request and # refusing force-pushes is the part that generalises. protect_main() { - local slug="$1" response status=0 + local -r slug="$1" + local response status=0 response="$(gh api -X POST "repos/${slug}/rulesets" --input - 2>&1 <<'EOF' { @@ -138,7 +139,7 @@ EOF # back to GITHUB_TOKEN. What the fallback costs is a release pull request whose # checks sit at "Action required" and then expire red. set_release_secrets() { - local slug="$1" + local -r slug="$1" [ -n "${RELEASE_APP_ID:-}" ] && [ -n "${RELEASE_APP_PRIVATE_KEY:-}" ] || return 1 diff --git a/lib/service.sh b/lib/service.sh index 2261581..9ab525b 100644 --- a/lib/service.sh +++ b/lib/service.sh @@ -23,13 +23,13 @@ SERVICE_SETUP_ANCHOR="# @SERVICE_SETUP@" # 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/. load_service() { - local name="$1" + local -r name="$1" case "$name" in ''|*[!a-z0-9-]*|-*) die "not a usable service name: ${name} (run: scaffold list)" ;; esac - local dir="${SCAFFOLD_ROOT}/services/${name}" + local -r dir="${SCAFFOLD_ROOT}/services/${name}" [ -d "$dir" ] || die "unknown service: ${name} (run: scaffold list)" # shellcheck disable=SC2034 # read by the caller @@ -54,8 +54,8 @@ service_compose_key() { } record_services() { - local project="$1" database="$2" cache="$3" - local file="${project}/mise.toml" + local -r project="$1" database="$2" cache="$3" + local -r file="${project}/mise.toml" sed -i.bak -e "s|@DATABASE@|${database}|" -e "s|@CACHE@|${cache}|" "$file" rm -f "${file}.bak" @@ -69,7 +69,8 @@ record_services() { # Prints nothing for `none`, so a caller can test the value rather than compare # it to a word. project_service() { - local project="$1" key="$2" value + 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 @@ -100,7 +101,8 @@ app_port_variable() { # the first port because yq's `max` over an empty sequence prints nothing at # all, which `// default` does not catch. next_app_port() { - local project="$1" highest + local -r project="$1" + local highest highest="$(SEED="$((FIRST_APP_PORT - 1))" yq -r '[(env(SEED) | tonumber), (.services[].ports[]? | capture("\{[A-Za-z0-9_]+:-(?P[0-9]+)\}").port | tonumber)] | max' \ "${project}/${COMPOSE_FILE}")" @@ -112,7 +114,8 @@ next_app_port() { # application did. The build.yml fallback is what lets `scaffold update` work on # a project generated before [vars] image existed. project_image_base() { - local project="$1" value + 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 @@ -140,7 +143,7 @@ compose_lane_file() { # Removes the fragment on both paths: under `set -e` a yq failure leaves # immediately and the temporary file would survive the run. merge_compose_fragment() { - local file="$1" fragment="$2" what="$3" + local -r file="$1" fragment="$2" what="$3" if ! yq eval-all --inplace 'select(fileIndex==0) * select(fileIndex==1)' \ "$file" "$fragment"; then @@ -155,7 +158,7 @@ merge_compose_fragment() { # merged in per lane. The image is injected here rather than written in a # fragment so a service's digest lives only in its service.env. assemble_compose() { - local project="$1"; shift + local -r project="$1"; shift local service lane file key merged for service in "$@"; do @@ -194,7 +197,7 @@ assemble_compose() { # service's driver, into the app's own .env.example: DB_CONNECTION is Laravel's # phrasing and DATABASE_URL is Prisma's for the same server. assemble_example_env() { - local project="$1"; shift + local -r project="$1"; shift local service for service in "$@"; do @@ -210,8 +213,8 @@ assemble_example_env() { # same base the build workflows get, because this is the path they push to: the # two cannot be written independently without drifting apart. add_app_service() { - local project="$1" rel="$2" role="$3" - local file="${project}/${COMPOSE_FILE}" + local -r project="$1" rel="$2" role="$3" + local -r file="${project}/${COMPOSE_FILE}" local key port_var port image fragment kind recorded [ -f "$file" ] || die "no ${COMPOSE_FILE} in ${project}" @@ -268,7 +271,7 @@ add_app_service() { # against an .env.example the adapter shipped, so appending blindly would leave # two values for one key and let the loser win depending on the reader. write_env_lines() { - local file="$1"; shift + local -r file="$1"; shift local line key rendered [ -f "$file" ] || : > "$file" @@ -304,7 +307,8 @@ write_env_lines() { # one overwriting the other. Both Dockerfile variants get the anchor resolved: # cmd_new decides which survives only after this runs. apply_service_dockerfile() { - local app="$1" block="$2" + local -r app="$1" + local block="$2" local file found=0 for file in "${app}/Dockerfile" "${app}/Dockerfile.workspace"; do @@ -333,8 +337,9 @@ apply_service_dockerfile() { # block-style `KEY: value` line per driver — and -P rewrites nodes the merge # never touched. apply_service_compose_env() { - local project="$1" service="$2" block="$3" - local file="${project}/${COMPOSE_FILE}" fragment + 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}" @@ -354,8 +359,9 @@ apply_service_compose_env() { # For a driver needing a whole sibling service (the migrate runner below) rather # than another line under one application's environment. apply_service_compose_service() { - local project="$1" block="$2" - local file="${project}/${COMPOSE_FILE}" fragment + 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}" @@ -371,8 +377,9 @@ apply_service_compose_service() { # explicitly, once, after the stack is up. An empty command (no database, or a # cache-only driver) merges nothing. apply_service_compose_migrate() { - local project="$1" service="$2" env_block="$3" command="$4" - local file="${project}/${COMPOSE_FILE}" image block + 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}" @@ -411,7 +418,7 @@ apply_service_compose_migrate() { # the project's mise.toml does not pin, and `mise exec` resolves PATH from # scratch. composer stays ambient either way (ADR-0016). run_driver_apply() { - local app="$1" project="$2" family="$3" service="$4" driver="$5" + local -r app="$1" project="$2" family="$3" service="$4" driver="$5" local pnpm_bin node_bin pnpm_bin="$(dirname "$(mise which pnpm -C "$app")")" @@ -420,7 +427,7 @@ run_driver_apply() { # Held in a variable so it reaches `bash -c` through `env` intact. Its # `$1`/`$2` and ${SCAFFOLD_ROOT} are the child's to expand. # shellcheck disable=SC2016 - local driver_script=' + local -r driver_script=' cd "$1" . "${SCAFFOLD_ROOT}/lib/log.sh" . "${SCAFFOLD_ROOT}/lib/service.sh" @@ -446,7 +453,7 @@ driver_output() { # resolve_driver — the driver file, by name, or die. resolve_driver() { load_service "$2" - local driver="${SERVICE_DIR}/drivers/${1}.sh" + local -r driver="${SERVICE_DIR}/drivers/${1}.sh" [ -f "$driver" ] || die "${2} has no driver for ${1} — run 'scaffold lint'" printf '%s' "$driver" } @@ -458,7 +465,7 @@ resolve_driver() { # project-root is an argument, not `app`'s ancestor counted in `..`: cmd_new's # apps/ and cmd_add's caller-chosen directory nest at different depths. apply_service_drivers() { - local app="$1" project="$2" family="$3"; shift 3 + local -r app="$1" project="$2" family="$3"; shift 3 local service driver rendered local block="" env_block="" migrate_block="" diff --git a/lib/tui.sh b/lib/tui.sh index e6232fa..a4dbd90 100644 --- a/lib/tui.sh +++ b/lib/tui.sh @@ -118,13 +118,14 @@ tui_header() { # banner.sh's _banner_edge, cut down to a label centred in a horizontal rule, # drawn once so it carries none of that file's rebuild-on-resize bookkeeping. _tui_header_edge() { - local left="$1" right="$2" label="$3" width="$4" - local inner=$(( width - 2 )) + local -r left="$1" right="$2" width="$4" + local label="$3" + local -r inner=$(( width - 2 )) _tui_fit " ${label} " "$inner" label="$REPLY" - local side=$(( (inner - ${#label}) / 2 )) - local extra=$(( inner - ${#label} - side * 2 )) + local -r side=$(( (inner - ${#label}) / 2 )) + local -r extra=$(( inner - ${#label} - side * 2 )) local l r printf -v l '%*s' "$side" ''; l="${l// /─}" printf -v r '%*s' "$(( side + extra ))" ''; r="${r// /─}" @@ -135,8 +136,9 @@ _tui_header_edge() { # _tui_header_row [dim|bold] — banner.sh's _banner_row, minus # the styles it never uses here. _tui_header_row() { - local style="$1" text="$2" width="$3" - local inner=$(( width - 2 )) + local -r style="$1" width="$3" + local text="$2" + local -r inner=$(( width - 2 )) _tui_fit "$text" "$inner" text="$REPLY" @@ -238,7 +240,7 @@ _tui_read_line() { # value in TUI_CHOICE; returns 1 on Esc rather than dying, so the caller decides # what cancelling the wizard means. tui_select() { - local prompt="$1"; shift + local -r prompt="$1"; shift local -a options=("$@") local cursor=0 key i value @@ -305,7 +307,7 @@ tui_select() { _TUI_RENDER_HEIGHT=0 _tui_render() { - local prompt="$1" cursor="$2"; shift 2 + local -r prompt="$1" cursor="$2"; shift 2 local -a options=("$@") local cols limit cols="$(tput cols 2>/dev/null || echo "$DEFAULT_TERM_COLS")" @@ -350,7 +352,7 @@ _tui_render() { # per row per keypress without forking a subshell while a held key is still # sending bytes at the (echo-disabled) tty. _tui_fit() { - local text="$1" limit="$2" + local -r text="$1" limit="$2" if (( ${#text} <= limit )); then REPLY="$text" elif (( limit <= 1 )); then diff --git a/lib/update.sh b/lib/update.sh index 05357eb..8753ff9 100644 --- a/lib/update.sh +++ b/lib/update.sh @@ -28,7 +28,8 @@ COMMON_PATCH_EXCLUDES=(':(exclude)common/mise.root.toml') # manifest_version — the toolbox commit a project was generated from. manifest_version() { - local file="${1}/${SCAFFOLD_MANIFEST}" version + local -r file="${1}/${SCAFFOLD_MANIFEST}" + local version [ -f "$file" ] || return 1 version="$(yq -p toml -oy -r '.version // ""' "$file" 2>/dev/null || true)" @@ -70,7 +71,7 @@ project_image_name() { # with `|` delimiting the expression sed reads that alternation as the end of # the pattern. rewrite_patch_paths() { - local from="$1" to="$2" + local -r from="$1" to="$2" sed -E \ -e "s#^diff --git a/${from}#diff --git a/${to}#" \ -e "s#^(diff --git a/[^ ]+) b/${from}#\1 b/${to}#" \ @@ -85,7 +86,7 @@ rewrite_patch_paths() { # about to become the project's content, and the context lines because # otherwise no hunk matches anything. substitute_placeholders() { - local project="$1" rel="${2:-}" + local -r project="$1" rel="${2:-}" local owner name filter owner="$(project_image_owner "$project")" name="$(project_image_name "$project")" @@ -106,7 +107,7 @@ substitute_placeholders() { # ─── building the patch ──────────────────────────────────────────────────── common_patch() { - local project="$1" + local -r project="$1" git -C "$SCAFFOLD_ROOT" diff "${SCAFFOLD_UPDATE_FROM}..HEAD" -- \ common/ "${COMMON_PATCH_EXCLUDES[@]}" \ @@ -124,8 +125,8 @@ common_patch() { # mapped onto `Dockerfile` and the other is dropped, rather than emitting a # patch against a path that is not there. adapter_patch() { - local project="$1" rel="$2" adapter="$3" - local dir="adapters/${adapter}" + local -r project="$1" rel="$2" adapter="$3" + local -r dir="adapters/${adapter}" local kept dropped internal local -a excludes=() @@ -156,7 +157,8 @@ adapter_patch() { # update_patch — everything the project has not received, as one # patch against its own paths. update_patch() { - local project="$1" rel adapter + local -r project="$1" + local rel adapter common_patch "$project" while IFS=$'\t' read -r rel adapter; do @@ -178,7 +180,8 @@ update_patch() { # Only when the value came back empty, so this never rewrites targets that were # already right. resync_derived_files() { - local project="$1" rel + local -r project="$1" + local rel sync_ci_roots "$project" diff --git a/lib/wizard.sh b/lib/wizard.sh index 72a0ff7..06c668d 100644 --- a/lib/wizard.sh +++ b/lib/wizard.sh @@ -47,7 +47,7 @@ wizard_shapes() { # The order the answers constrain each other in. `web` asks nothing about a # database because `scaffold new` refuses --db without an api or app adapter. wizard_questions() { - local shape="$1" + local -r shape="$1" grep -qx "$shape" <<<"$(wizard_shapes | cut -f1)" || die "unknown project shape: ${shape}" @@ -93,7 +93,7 @@ wizard_prompt_width() { # is cmd_list's tab-separated output; every option comes from there # rather than a second copy of what the adapters and services already declare. wizard_options() { - local listing="$1" kind="$2" + local -r listing="$1" kind="$2" case "$kind" in web|api|app) @@ -126,7 +126,8 @@ wizard_options() { # cmd_new's own unset-flag default moved first, so a plain Enter picks what the # flags would have picked unset. wizard_order_options() { - local kind="$1" listing="$2" default="" line + local -r kind="$1" listing="$2" + local default="" line case "$kind" in database) default="$DEFAULT_DATABASE_SERVICE" ;; @@ -179,7 +180,7 @@ wizard_new_args() { # What the answers would have been typed as. Printed before the run so the # second project is scripted rather than clicked. wizard_command() { - local name="$1"; shift + local -r name="$1"; shift local -a args; mapfile -t args < <(wizard_new_args "$@") local out="scaffold new ${name}" [ "${#args[@]}" -eq 0 ] || out+=" ${args[*]}" diff --git a/services/mongodb/drivers/laravel.sh b/services/mongodb/drivers/laravel.sh index c7bdc56..6fa72cc 100644 --- a/services/mongodb/drivers/laravel.sh +++ b/services/mongodb/drivers/laravel.sh @@ -8,11 +8,10 @@ # a DSN and a config/database.php connection instead of the decomposed # DB_HOST/DB_PORT/DB_USERNAME/DB_PASSWORD every relational driver shares. service_driver_apply() { - # Recorded in composer.lock BEFORE `composer require`, never after: it is what - # lets both this call and the Docker vendor stage's from-scratch `composer - # install` resolve laravel-mongodb with no mongodb extension on the host. - # - # 1.21.0 must stay the version service_driver_dockerfile builds below. + # Recorded in composer.lock before `composer require`: it is what lets both + # this call and the Docker vendor stage's from-scratch `composer install` + # resolve laravel-mongodb with no mongodb extension on the host. 1.21.0 must + # stay the version service_driver_dockerfile builds below. composer config platform.ext-mongodb 1.21.0 --no-interaction || return 1 composer require mongodb/laravel-mongodb --no-interaction || return 1 @@ -25,24 +24,21 @@ service_driver_apply() { register_mongodb_connection config/database.php # APP_KEY is per-family, not per-service, so no env.fragment can carry it into - # the project's example.env. Without a value here, compose.yaml's - # `APP_KEY: ${APP_KEY}` interpolates to empty and laravel refuses to boot. + # example.env; unset, compose.yaml's `APP_KEY: ${APP_KEY}` interpolates to + # empty and laravel refuses to boot. write_env_lines "${SCAFFOLD_PROJECT_ROOT}/example.env" "APP_KEY=changeme" || return 1 # mongodb has no SQL to run a `select 1` against; ping is what - # laravel-mongodb exposes. - # - # The throw is replaced in place rather than left below the probe: pint - # rejects dead code after a path that always returns. The class arrives as a - # short name with its own `use`, because pint's fully_qualified_strict_types - # rejects an inline FQCN once the file has imports — and a --db none project - # runs neither substitution, so it keeps both the throw and the FQCN. + # laravel-mongodb exposes. The class arrives as a short name with its own + # `use`: pint's fully_qualified_strict_types rejects an inline FQCN once the + # file has imports. sed -i.bak 's|use Illuminate\\Support\\Facades\\Route;|use Illuminate\\Support\\Facades\\DB;\nuse Illuminate\\Support\\Facades\\Route;|' \ routes/health.php || return 1 sed -i.bak 's|// @DB_PROBE@|DB::connection(\x27mongodb\x27)->getMongoDB()->command([\x27ping\x27 => 1]);|' \ routes/health.php || return 1 - # Matched with its leading indentation so the replacement's `\n` opens a bare - # blank line, which is what pint's blank_line_before_statement wants here. + # Replaced in place, not left below the probe: pint rejects dead code after an + # always-returning path. Matched with its leading indentation so the + # replacement's `\n` opens a bare blank line, which blank_line_before_statement wants. sed -i.bak "s| throw new RuntimeException('no database is configured for this project');|\\n return response()->json(['status' => 'ok']);|" \ routes/health.php || return 1 rm -f routes/health.php.bak @@ -53,14 +49,13 @@ service_driver_apply() { } service_driver_dockerfile() { - # pecl, not apk: the extension is not in alpine's repositories, which is why - # this block installs build dependencies and nothing else does. + # pecl, not apk: the extension is not in alpine's repositories. # - # Pinned to 1.21.0, matching platform.ext-mongodb above. mongodb/mongodb's + # Pinned to 1.21.0, matching platform.ext-mongodb above: mongodb/mongodb's # BSONArray/BSONDocument declare bsonSerialize() against the 1.x signature and - # the 2.x extension changed it, so any code path loading those classes is a - # PHP fatal error, not an exception this project's try/catch can see — a 500 - # on /health/ready before this pin. + # the 2.x extension changed it, so loading those classes is a PHP fatal error, + # not an exception this project's try/catch can see — a 500 on /health/ready + # before this pin. printf '%s\n' \ 'RUN apk add --no-cache --virtual .build-deps $PHPIZE_DEPS openssl-dev \' \ ' && pecl install mongodb-1.21.0 \' \ @@ -68,9 +63,9 @@ service_driver_dockerfile() { ' && apk del .build-deps' } -# DB_CONNECTION first and always: config/database.php defaults to sqlite, so its -# absence is a silent wrong answer. DB_URI is assembled here because -# laravel-mongodb reads one DSN string, not decomposed credentials. +# config/database.php defaults to sqlite, so DB_CONNECTION's absence is a +# silent wrong answer. DB_URI is assembled here: laravel-mongodb reads one DSN +# string, not decomposed credentials. service_driver_compose_env() { printf 'DB_CONNECTION: mongodb\n' printf 'DB_URI: ${DB_URI:-mongodb://${DB_USERNAME:-app}:${DB_PASSWORD}@database:27017/${DB_DATABASE:-app}?authSource=admin}\n' @@ -83,15 +78,13 @@ service_driver_compose_migrate() { printf 'command: ["php", "artisan", "migrate", "--force"]\n' } -# register_mongodb_connection # laravel-mongodb needs a 'mongodb' entry in the connections array; the Laravel # skeleton ships none. Insert-then-verify, like register_config_root: an anchor -# that stops matching after a skeleton upgrade must fail loudly here, not ship -# an app whose DB_CONNECTION names a connection that does not exist. +# that stops matching after a skeleton upgrade must fail loudly here. register_mongodb_connection() { - local file="$1" - local anchor=" 'connections' => [" - local block=" 'mongodb' => [ + local -r file="$1" + local -r anchor=" 'connections' => [" + local -r block=" 'mongodb' => [ 'driver' => 'mongodb', 'dsn' => env('DB_URI', 'mongodb://localhost:27017'), 'database' => env('DB_DATABASE', 'app'), diff --git a/services/shared/laravel.sh b/services/shared/laravel.sh index 6c92934..ce928f7 100644 --- a/services/shared/laravel.sh +++ b/services/shared/laravel.sh @@ -4,9 +4,9 @@ # Description : The shared Laravel SQL driver body. # Author : ttncode # ═══════════════════════════════════════════════════════════════════════════ -# A service's drivers/laravel.sh sets the parameters below and sources this. -# mysql and postgres only — mongodb is self-contained: a DSN and a -# config/database.php edit differ in kind from these decomposed credentials. +# A service's drivers/laravel.sh sets these and sources this. mysql and +# postgres only — mongodb is self-contained: a DSN and a config/database.php +# edit differ in kind from these decomposed credentials. # # LARAVEL_CONNECTION the DB_CONNECTION value # LARAVEL_PORT the default port for .env.example @@ -21,8 +21,7 @@ service_driver_apply() { fi # localhost, not the compose service name: .env.example describes host-side - # `mise run dev`, which reaches the database through compose.dev.yaml's - # published port, not the compose network. + # `mise run dev`, reached through compose.dev.yaml's published port. write_env_lines .env.example \ "DB_CONNECTION=${LARAVEL_CONNECTION}" \ "DB_HOST=localhost" \ @@ -33,24 +32,22 @@ service_driver_apply() { || return 1 # APP_KEY is per-family, not per-service, so no env.fragment can carry it into - # the project's example.env. Without a value here, compose.yaml's - # `APP_KEY: ${APP_KEY}` interpolates to empty and laravel refuses to boot. + # example.env; unset, compose.yaml's `APP_KEY: ${APP_KEY}` interpolates to + # empty and laravel refuses to boot. write_env_lines "${SCAFFOLD_PROJECT_ROOT}/example.env" "APP_KEY=changeme" || return 1 # Spliced here rather than shipped in the route, so the file carries exactly # one probe, for the connection this project actually has. # - # The throw is replaced in place rather than left below the probe: pint - # rejects dead code after a path that always returns. The class arrives as a - # short name with its own `use`, because pint's fully_qualified_strict_types - # rejects an inline FQCN once the file has imports — and a --db none project - # runs neither substitution, so it keeps both the throw and the FQCN. + # The class arrives as a short name with its own `use`: pint's + # fully_qualified_strict_types rejects an inline FQCN once the file has imports. sed -i.bak 's|use Illuminate\\Support\\Facades\\Route;|use Illuminate\\Support\\Facades\\DB;\nuse Illuminate\\Support\\Facades\\Route;|' \ routes/health.php || return 1 sed -i.bak 's|// @DB_PROBE@|DB::connection()->select(\x27select 1\x27);|' \ routes/health.php || return 1 - # Matched with its leading indentation so the replacement's `\n` opens a bare - # blank line, which is what pint's blank_line_before_statement wants here. + # Replaced in place, not left below the probe: pint rejects dead code after an + # always-returning path. Matched with its leading indentation so the + # replacement's `\n` opens a bare blank line, which blank_line_before_statement wants. sed -i.bak "s| throw new RuntimeException('no database is configured for this project');|\\n return response()->json(['status' => 'ok']);|" \ routes/health.php || return 1 rm -f routes/health.php.bak @@ -64,10 +61,9 @@ service_driver_dockerfile() { [ -z "$LARAVEL_SETUP" ] || printf '%s\n' "$LARAVEL_SETUP" } -# DB_CONNECTION first and always: config/database.php defaults to sqlite, so its -# absence is a silent wrong answer, not an error. The credentials already reach -# the container through compose.yaml's env_file, so only what laravel cannot -# otherwise know — the connection name, the host, the key — is added here. +# config/database.php defaults to sqlite, so DB_CONNECTION's absence is a +# silent wrong answer, not an error. Credentials reach the container through +# compose.yaml's env_file already; only what laravel cannot otherwise know goes here. service_driver_compose_env() { printf 'DB_CONNECTION: %s\n' "$LARAVEL_CONNECTION" printf '%s\n' "$LARAVEL_COMPOSE_ENV" diff --git a/services/shared/nest.sh b/services/shared/nest.sh index 9c062b1..cf7f7bd 100644 --- a/services/shared/nest.sh +++ b/services/shared/nest.sh @@ -15,13 +15,13 @@ # credentials left as compose interpolations service_driver_apply() { - # Before the installs, not after: all three packages place the query engine - # binary through an install-time script with no pure-js fallback, and - # undecided the first `pnpm add` below is refused with + # Before the installs, not after: prisma, its engines and its client all place + # the query engine binary through an install-time script with no pure-js + # fallback, and undecided the first `pnpm add` below is refused with # ERR_PNPM_IGNORED_BUILDS wherever CI=true leaves pnpm no prompt. # - # SCAFFOLD_PROJECT_ROOT, exported by apply_service_drivers: cmd_add's app - # directory is caller-chosen, so a fixed `../..` reaches outside the project. + # SCAFFOLD_PROJECT_ROOT, not a fixed `../..`: cmd_add's app directory is + # caller-chosen. if ! yq --inplace \ '.allowBuilds.prisma = true | .allowBuilds."@prisma/engines" = true @@ -32,16 +32,15 @@ service_driver_apply() { # major-pinned, not @latest: 7 dropped the datasource `url` this driver writes # below for a prisma.config.ts adapter, and latest resolves to an 8.x release - # candidate. 6 is the newest stable major that still reads `url`. + # candidate. pnpm add @prisma/client@6 || return 1 # A regular dependency, not -D: `pnpm prune --prod` drops devDependencies, and - # the published image is what runs `migrate deploy`. The engines cost image - # size (ADR-0021). + # the published image is what runs `migrate deploy`. pnpm add prisma@6 || return 1 mkdir -p prisma || return 1 - # datasource and generator only. models describe the client's domain, which - # this toolbox does not know — see the spec's non-goals. + # datasource and generator only; models describe the client's domain, which + # this toolbox does not know. cat > prisma/schema.prisma </node_modules/.bin for the workspace shape, node_modules/.bin -# at the container root for the standalone one. The command tries both rather -# than guessing, and `cd`s into whichever matched: WORKDIR stays the container -# root either way, and prisma resolves `./prisma/schema.prisma` from its own -# working directory, which is nested under the app directory in the workspace -# shape (measured: `Could not find Prisma Schema` before this `cd`). +# not in the built image. prisma's own bin survives `pnpm prune --prod`, but at +# one of two locations depending on which Dockerfile shape wins, a decision made +# after this driver runs — so the command tries both and `cd`s into whichever +# matched, since prisma resolves `./prisma/schema.prisma` from its own working +# directory (measured: `Could not find Prisma Schema` before this `cd`). service_driver_compose_migrate() { local args case "$PRISMA_PROVIDER" in mongodb) args='db push --skip-generate' ;; *) args='migrate deploy' ;; esac - # `$${d}`/`$$d`, not `${d}`/`$d`: compose interpolates `$var` in compose.yaml - # before the command reaches the container, and a single `$` resolves to an - # unset variable that blanks the loop out entirely. `$$` is compose's escape - # for a literal `$`. + # `$${d}`/`$$d`, not `${d}`/`$d`: compose interpolates `$var` before the + # command reaches the container; `$$` is compose's escape for a literal `$`. printf 'command: ["sh", "-c", "for d in apps/*/ ./; do [ -x $${d}node_modules/.bin/prisma ] && cd $$d && exec node_modules/.bin/prisma %s; done; echo prisma binary not found >&2; exit 1"]\n' "$args" } From 51a32b47d2996be7be06e7640000450fc14e3d3a Mon Sep 17 00:00:00 2001 From: iam-truongtrungnghia Date: Sun, 13 Sep 2026 17:01:00 +0700 Subject: [PATCH 7/9] refactor: cut adapter.env, dockerignore and prettierignore to their facts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit adapter.env ran to 53-76% comments, common/.dockerignore to 79%, where immich's equivalents carry none at all. What stays is the traps: the laravel starter kit's inert .github failing zizmor with exit 13, the sed wiring routes/health.php into bootstrap/app.php because Laravel auto-loads neither, the nest v11/v12 jest-vitest regression, and the generator env vars (SHELL_VERBOSITY, COMPOSER_PROCESS_TIMEOUT) with their reasons. Deleted: comments restating what the assignment beside them already says (ADAPTER_TIER citing ADR-0012, the /up note beside ADAPTER_READINESS_PATH) and boilerplate ("never belongs in an image"). tests/*.bats were audited against the same rule — delete a comment the test name already says, keep one that records why the test exists — and found already compliant: every comment explains a shipped defect, an ADR, or a measured behavior no test name conveys. None restated a name, so no test file changed. Co-authored-by: Claude --- adapters/laravel-api/.dockerignore | 10 ++----- adapters/laravel-api/.env.example | 4 +-- adapters/laravel-api/adapter.env | 17 ++++------- adapters/laravel-inertia/.dockerignore | 17 ++++------- adapters/laravel-inertia/.env.example | 4 +-- adapters/laravel-inertia/adapter.env | 39 +++++++------------------- adapters/nestjs/.dockerignore | 4 +-- adapters/nestjs/.prettierignore | 6 ++-- adapters/nestjs/adapter.env | 22 +++++---------- adapters/nextjs/.dockerignore | 4 +-- adapters/nextjs/adapter.env | 6 ++-- common/.dockerignore | 6 +--- common/.prettierignore | 8 ++---- common/example.env | 5 +--- 14 files changed, 43 insertions(+), 109 deletions(-) diff --git a/adapters/laravel-api/.dockerignore b/adapters/laravel-api/.dockerignore index 8103920..acffaad 100644 --- a/adapters/laravel-api/.dockerignore +++ b/adapters/laravel-api/.dockerignore @@ -2,7 +2,6 @@ vendor node_modules -# .env holds this installation's real APP_KEY and database password .env .env.* !.env.example @@ -10,10 +9,7 @@ node_modules storage/logs storage/framework/cache -# laravel's own .gitignore excludes the generated manifests but keeps the -# directory, and both halves matter here. `composer create-project` writes -# them locally, and they name dev providers a --no-dev image does not install; -# copied in, artisan cannot boot. Excluding the directory itself fails the -# other way — laravel refuses to start without it. For laravel-inertia either -# failure surfaces in vite's wayfinder plugin, several stages from the cause. +# both the directory and its manifests must survive: composer writes them +# locally, naming dev providers a --no-dev image lacks, but artisan also +# refuses to boot without the directory itself present bootstrap/cache/*.php diff --git a/adapters/laravel-api/.env.example b/adapters/laravel-api/.env.example index 56efee2..cf699ce 100644 --- a/adapters/laravel-api/.env.example +++ b/adapters/laravel-api/.env.example @@ -1,8 +1,6 @@ APP_ENV=local APP_KEY= -# off by default: `cp .env.example .env` is how most deploys start, and -# Laravel's debug page renders the whole environment — APP_KEY and -# DB_PASSWORD included — to anyone who triggers a 500. Turn it on locally. +# off by default: Laravel's debug page leaks APP_KEY and DB_PASSWORD on a 500 APP_DEBUG=false APP_URL=http://localhost:8000 # the database variables are written by the selected service's driver diff --git a/adapters/laravel-api/adapter.env b/adapters/laravel-api/adapter.env index 297f7ad..3df312c 100644 --- a/adapters/laravel-api/adapter.env +++ b/adapters/laravel-api/adapter.env @@ -10,18 +10,11 @@ ADAPTER_FAMILY="laravel" # (13.x needs ^8.3, 12.x needs ^8.2). Pinned, with mise.toml's install guard # holding the same floor. See docs/decisions/0016. ADAPTER_GENERATOR='composer create-project laravel/laravel:^13.0 "$APP_DIR" --no-interaction --prefer-dist' -# the skeleton ships phpunit and pint, but nothing for the check task -# -# The sed call wires routes/health.php (copied in by apply_adapter's -# directory loop, not this adapter's flat file list) into bootstrap/app.php's -# routing: laravel only auto-loads routes/web.php and routes/console.php, so -# a file dropped at routes/health.php with nothing pointing at it 404s -# forever. The grep pair after it is not optional — `s|health: '/up',|...|` -# silently no-ops if the skeleton ever reformats that line, leaving the -# route unregistered with no build failure, only a 404 discovered in -# production. +# the skeleton ships phpunit and pint, but nothing for the check task. The sed +# wires routes/health.php (copied in separately by apply_adapter) into +# bootstrap/app.php, since laravel auto-loads neither; the grep pair after it +# turns a skeleton reformat that breaks the sed into a build failure instead +# of a silent 404. ADAPTER_POST_GENERATE='composer require --dev larastan/larastan phpstan/phpstan --no-interaction && sed -i "s|health: '"'"'/up'"'"',|health: '"'"'/up'"'"',\n then: function (): void {\n require __DIR__.'"'"'/../routes/health.php'"'"';\n },|" bootstrap/app.php && { grep -q "then: function (): void {" bootstrap/app.php && grep -q "require __DIR__.'"'"'/../routes/health.php'"'"';" bootstrap/app.php; } || { echo "post-generate: health route wiring missing from bootstrap/app.php — the laravel skeleton likely changed its withRouting shape; update the sed pattern in ADAPTER_POST_GENERATE to match" >&2; exit 1; }' -# /up ships with laravel since 11.x and deliberately touches nothing, which -# is why it cannot stand alone: a project with a wrong DATABASE_URL passes it. ADAPTER_LIVENESS_PATH="/up" ADAPTER_READINESS_PATH="/health/ready" diff --git a/adapters/laravel-inertia/.dockerignore b/adapters/laravel-inertia/.dockerignore index 48bbc59..2a52b6a 100644 --- a/adapters/laravel-inertia/.dockerignore +++ b/adapters/laravel-inertia/.dockerignore @@ -2,7 +2,6 @@ vendor node_modules -# .env holds this installation's real APP_KEY and database password .env .env.* !.env.example @@ -10,16 +9,12 @@ node_modules storage/logs storage/framework/cache -# laravel's own .gitignore excludes the generated manifests but keeps the -# directory, and both halves matter here. `composer create-project` writes -# them locally, and they name dev providers a --no-dev image does not install; -# copied in, artisan cannot boot. Excluding the directory itself fails the -# other way — laravel refuses to start without it. For laravel-inertia either -# failure surfaces in vite's wayfinder plugin, several stages from the cause. +# both the directory and its manifests must survive: composer writes them +# locally, naming dev providers a --no-dev image lacks, but artisan also +# refuses to boot without the directory itself present — for this adapter the +# failure surfaces in vite's wayfinder plugin, several stages from the cause bootstrap/cache/*.php -# vite writes this and .gitignore excludes it, but a build context is not a -# git tree: without this line a developer who has run `npm run build` ships -# their host copy over the one the assets stage just built. Same shape as the -# bootstrap/cache manifests two lines up. +# a build context is not a git tree: without this, a host `npm run build` +# ships over what the assets stage just built public/build diff --git a/adapters/laravel-inertia/.env.example b/adapters/laravel-inertia/.env.example index 71e8e3e..5f403cd 100644 --- a/adapters/laravel-inertia/.env.example +++ b/adapters/laravel-inertia/.env.example @@ -1,8 +1,6 @@ APP_ENV=local APP_KEY= -# off by default: `cp .env.example .env` is how most deploys start, and -# Laravel's debug page renders the whole environment — APP_KEY and -# DB_PASSWORD included — to anyone who triggers a 500. Turn it on locally. +# off by default: Laravel's debug page leaks APP_KEY and DB_PASSWORD on a 500 APP_DEBUG=false APP_URL=http://localhost:8000 # the database variables are written by the selected service's driver diff --git a/adapters/laravel-inertia/adapter.env b/adapters/laravel-inertia/adapter.env index be593e5..92801fd 100644 --- a/adapters/laravel-inertia/adapter.env +++ b/adapters/laravel-inertia/adapter.env @@ -1,38 +1,19 @@ ADAPTER_NAME="laravel-inertia" ADAPTER_ROLE="app" -# ~5 minutes per test; the five in tests/new-laravel-inertia.bats cost ~25 -# minutes together. See docs/decisions/0012. ADAPTER_TIER="B" ADAPTER_LANGUAGE="php" ADAPTER_FAMILY="laravel" -# Pinned to a commit, not the branch, so create-project resolves the same tree -# every run. Move to a tag once laravel/vue-starter-kit cuts one targeting -# laravel 13 — its releases still pin ^12.0. -# -# The starter kit runs `artisan install:features --ansi` on install, and -# composer's --no-interaction does not reach that script line. SHELL_VERBOSITY=-1 -# satisfies Symfony Console's own check instead, so the prompt takes defaults. -# -# COMPOSER_PROCESS_TIMEOUT because install:features exceeds the 300s default on -# a cold cache — it reads as a hang. Widened, not disabled, so a real hang still -# ends the test. +# pinned to a commit so create-project resolves the same tree every run; move +# to a tag once laravel/vue-starter-kit targets laravel 13. SHELL_VERBOSITY=-1 +# satisfies the starter kit's install:features prompt, which --no-interaction +# does not reach; COMPOSER_PROCESS_TIMEOUT=900 because a cold cache exceeds +# the 300s default — widened, not disabled, so a real hang still ends the run. ADAPTER_GENERATOR='SHELL_VERBOSITY=-1 COMPOSER_PROCESS_TIMEOUT=900 composer create-project "laravel/vue-starter-kit:dev-main#11a7368240d7d9146415aa6bfc858a90b6ce8490" "$APP_DIR" --no-interaction --prefer-dist' -# The vue starter kit ships its own .github/, and composer create-project -# brings it along into apps/app/. At that path github ignores both files in -# it, so they configure nothing — but zizmor still audits every workflow and -# dependabot file in the tree, and the kit's dependabot.yml sets -# `cooldown.default-days: 5`, which fails the security gate with exit 13 on -# the first pull request. Inert config whose only effect is a red check. -# The sed call wires routes/health.php (copied in by apply_adapter's -# directory loop, not this adapter's flat file list) into bootstrap/app.php's -# routing: laravel only auto-loads routes/web.php and routes/console.php, so -# a file dropped at routes/health.php with nothing pointing at it 404s -# forever. The grep pair after it is not optional — `s|health: '/up',|...|` -# silently no-ops if the skeleton ever reformats that line, leaving the -# route unregistered with no build failure, only a 404 discovered in -# production. +# rm -rf .github: the starter kit's own inert dependabot.yml still fails +# zizmor's audit with exit 13. The sed wires routes/health.php (copied in +# separately by apply_adapter) into bootstrap/app.php, since laravel +# auto-loads neither; the grep pair after it turns a skeleton reformat that +# breaks the sed into a build failure instead of a silent 404. ADAPTER_POST_GENERATE='rm -rf .github && sed -i "s|health: '"'"'/up'"'"',|health: '"'"'/up'"'"',\n then: function (): void {\n require __DIR__.'"'"'/../routes/health.php'"'"';\n },|" bootstrap/app.php && { grep -q "then: function (): void {" bootstrap/app.php && grep -q "require __DIR__.'"'"'/../routes/health.php'"'"';" bootstrap/app.php; } || { echo "post-generate: health route wiring missing from bootstrap/app.php — the laravel skeleton likely changed its withRouting shape; update the sed pattern in ADAPTER_POST_GENERATE to match" >&2; exit 1; }' -# /up ships with laravel since 11.x and deliberately touches nothing, which -# is why it cannot stand alone: a project with a wrong DATABASE_URL passes it. ADAPTER_LIVENESS_PATH="/up" ADAPTER_READINESS_PATH="/health/ready" diff --git a/adapters/nestjs/.dockerignore b/adapters/nestjs/.dockerignore index 1ed51ea..06bd904 100644 --- a/adapters/nestjs/.dockerignore +++ b/adapters/nestjs/.dockerignore @@ -1,11 +1,9 @@ -# node_modules is copied from a pinned build stage; a host copy landing after -# it would overwrite the pinned one with whatever the developer has locally. +# copied from a pinned build stage; a host copy would overwrite it node_modules .next dist coverage -# never belongs in an image .env .env.* !.env.example diff --git a/adapters/nestjs/.prettierignore b/adapters/nestjs/.prettierignore index 94e72f3..0245695 100644 --- a/adapters/nestjs/.prettierignore +++ b/adapters/nestjs/.prettierignore @@ -1,6 +1,4 @@ pnpm-lock.yaml -# `nest build` writes here, and format is `prettier --check .`. Within one -# checklist run format precedes build, so the first run is clean and every -# run after it fails on the previous run's output — a task list that poisons -# itself. CI never saw it: a fresh checkout has no dist. +# nest build writes here; unignored, format fails on the previous run's own +# output once build has run once dist diff --git a/adapters/nestjs/adapter.env b/adapters/nestjs/adapter.env index 00ebce0..0628777 100644 --- a/adapters/nestjs/adapter.env +++ b/adapters/nestjs/adapter.env @@ -5,22 +5,14 @@ ADAPTER_LANGUAGE="typescript" ADAPTER_FAMILY="nest" # v11, not @latest: v12's template swaps jest for vitest with no # decorator-metadata transform, so Nest's DI never injects AppService and the -# generated test fails on every fresh project. Before moving off this pin, -# generate with the new major and confirm `//apps/api:ci-unit` passes clean. +# generated test fails on every fresh project. ADAPTER_GENERATOR='pnpm dlx @nestjs/cli@11 new "$APP_DIR" --package-manager pnpm --skip-git' -# main.ts's un-awaited bootstrap() trips --max-warnings 0, and the generator's -# own output is not prettier-formatted. Wiring HealthModule here too: the -# module lives in src/health/ (copied in by apply_adapter's directory loop, -# not this adapter's flat file list), and app.module.ts is the generator's -# own file, so it can only be edited after the generator has produced it. -# The grep pair after the two sed calls is not optional: `1i` always -# succeeds, and `s/imports: \[\]/…/` silently no-ops if the generator ever -# reformats that line, leaving HealthModule unregistered with no build or -# lint failure — only a 404 on /health/live, discovered by the HEALTHCHECK -# that quietly starts failing on every image. +# main.ts's un-awaited bootstrap() trips --max-warnings 0. HealthModule is +# wired here rather than shipped in app.module.ts because that file is the +# generator's own output; the grep pair after the sed calls turns a generator +# reformat that breaks the sed into a build failure instead of a silent 404 +# on /health/live. ADAPTER_POST_GENERATE='sed -i "s/^bootstrap();$/void bootstrap();/" src/main.ts && sed -i "1i import { HealthModule } from '"'"'./health/health.module'"'"';" src/app.module.ts && sed -i "s/imports: \[\]/imports: [HealthModule]/" src/app.module.ts && { grep -q "import { HealthModule } from '"'"'./health/health.module'"'"';" src/app.module.ts && grep -q "imports: \[HealthModule\]" src/app.module.ts || { echo "post-generate: HealthModule wiring missing from src/app.module.ts; the nest generator likely changed its output format — update the sed patterns in ADAPTER_POST_GENERATE to match" >&2; exit 1; }; } && pnpm exec prettier --write .' -# The generator produces `/` returning Hello World and nothing else. Both of -# these are routes this adapter ships itself (src/health/), because the -# Dockerfile's HEALTHCHECK has been probing a /health that never existed. +# the generator produces only `/`; both health routes are this adapter's own ADAPTER_LIVENESS_PATH="/health/live" ADAPTER_READINESS_PATH="/health/ready" diff --git a/adapters/nextjs/.dockerignore b/adapters/nextjs/.dockerignore index 1ed51ea..06bd904 100644 --- a/adapters/nextjs/.dockerignore +++ b/adapters/nextjs/.dockerignore @@ -1,11 +1,9 @@ -# node_modules is copied from a pinned build stage; a host copy landing after -# it would overwrite the pinned one with whatever the developer has locally. +# copied from a pinned build stage; a host copy would overwrite it node_modules .next dist coverage -# never belongs in an image .env .env.* !.env.example diff --git a/adapters/nextjs/adapter.env b/adapters/nextjs/adapter.env index a9fbf7c..8458582 100644 --- a/adapters/nextjs/adapter.env +++ b/adapters/nextjs/adapter.env @@ -10,8 +10,6 @@ ADAPTER_GENERATOR='pnpm create next-app@16 "$APP_DIR" --ts --app --eslint --tail # create-next-app ships neither prettier nor vitest, and its own output is not # formatted — the contract's format and test tasks need both. ADAPTER_POST_GENERATE='pnpm add -D prettier vitest && pnpm exec prettier --write .' -# A route handler answers without rendering the home page, so the probe does -# not depend on whatever the client later puts on `/`. The route lives at -# src/app/api/health/live/route.ts (copied in by apply_adapter's directory -# loop, not this adapter's flat file list). +# a route handler answers without rendering the home page, so the probe does +# not depend on whatever the client later puts on `/` ADAPTER_LIVENESS_PATH="/api/health/live" diff --git a/common/.dockerignore b/common/.dockerignore index 409be4f..5e468f5 100644 --- a/common/.dockerignore +++ b/common/.dockerignore @@ -1,13 +1,9 @@ -# only read when the build context is the project root — an all-typescript -# project's apps/*/Dockerfile.workspace (see docs/PROVENANCE.md). node_modules -# is copied from a pinned build stage; a host copy landing after it would -# overwrite the pinned one with whatever the developer has locally. +# copied from a pinned build stage; a host copy would overwrite it **/node_modules **/.next **/dist **/coverage -# never belongs in an image **/.env **/.env.* !**/.env.example diff --git a/common/.prettierignore b/common/.prettierignore index ad156aa..8f4781a 100644 --- a/common/.prettierignore +++ b/common/.prettierignore @@ -1,9 +1,5 @@ -# The pre-commit hook runs `prettier --write` over every staged file matching -# its glob, and `yaml` is in that glob. Left to itself prettier reformats -# pnpm-lock.yaml — it grew by 2-4 KB the one time it happened — and rewrites -# CHANGELOG.md's bullets from `*` to `-` on every release, fighting Release -# Please for a file Release Please owns. Neither file is written by hand, so -# neither is prettier's to format. +# prettier rewrites pnpm-lock.yaml and fights Release Please for CHANGELOG.md; +# neither is written by hand pnpm-lock.yaml **/pnpm-lock.yaml package-lock.json diff --git a/common/example.env b/common/example.env index d413a29..5ee79b8 100644 --- a/common/example.env +++ b/common/example.env @@ -1,5 +1,2 @@ -# copy to .env and edit. install.sh does this for you and generates a random -# value for every password below in place of the literal `changeme`. the -# per-application port variables, and the database and cache variables, are -# appended by scaffold from whatever the project was generated with. +# copy to .env; install.sh does this and replaces every `changeme` IMAGE_TAG=latest From 4123f088f51ff8cc9a6eed534c24b1216f40f1dd Mon Sep 17 00:00:00 2001 From: iam-truongtrungnghia Date: Sun, 13 Sep 2026 17:34:33 +0700 Subject: [PATCH 8/9] fix: restore four facts the comment-density cut dropped Final review on chore/immich-parity found one Important and three Minor findings where compression lost a fact with nowhere else to land in the shipped repo: .dockerignore's build-context scope, example.env's appended variables, project.sh's mise.root.toml substitution order, and adapter.env's Nest major-bump validation step. Folded each back in as a clause on the surviving comment. --- adapters/nestjs/adapter.env | 3 ++- common/.dockerignore | 4 +++- common/example.env | 3 ++- lib/project.sh | 3 ++- 4 files changed, 9 insertions(+), 4 deletions(-) diff --git a/adapters/nestjs/adapter.env b/adapters/nestjs/adapter.env index 0628777..5d55c19 100644 --- a/adapters/nestjs/adapter.env +++ b/adapters/nestjs/adapter.env @@ -5,7 +5,8 @@ ADAPTER_LANGUAGE="typescript" ADAPTER_FAMILY="nest" # v11, not @latest: v12's template swaps jest for vitest with no # decorator-metadata transform, so Nest's DI never injects AppService and the -# generated test fails on every fresh project. +# generated test fails on every fresh project. Before moving off this pin, +# generate with the new major and confirm `//apps/api:ci-unit` passes clean. ADAPTER_GENERATOR='pnpm dlx @nestjs/cli@11 new "$APP_DIR" --package-manager pnpm --skip-git' # main.ts's un-awaited bootstrap() trips --max-warnings 0. HealthModule is # wired here rather than shipped in app.module.ts because that file is the diff --git a/common/.dockerignore b/common/.dockerignore index 5e468f5..688adc7 100644 --- a/common/.dockerignore +++ b/common/.dockerignore @@ -1,4 +1,6 @@ -# copied from a pinned build stage; a host copy would overwrite it +# only read when the build context is the project root — an all-typescript +# project's apps/*/Dockerfile.workspace; node_modules is copied from a pinned +# build stage, so a host copy would overwrite it **/node_modules **/.next **/dist diff --git a/common/example.env b/common/example.env index 5ee79b8..7ea91e5 100644 --- a/common/example.env +++ b/common/example.env @@ -1,2 +1,3 @@ -# copy to .env; install.sh does this and replaces every `changeme` +# copy to .env; install.sh does this and replaces every `changeme`. Scaffold +# appends the per-app port, database and cache variables at generation time. IMAGE_TAG=latest diff --git a/lib/project.sh b/lib/project.sh index 91346a5..9b33708 100644 --- a/lib/project.sh +++ b/lib/project.sh @@ -21,7 +21,8 @@ PROJECT_MARKER="monorepo_root = true" PROJECT_COMMIT_NAME="scaffold" PROJECT_COMMIT_EMAIL="scaffold@scaffold.invalid" -# Files carrying the `you/` placeholder, alongside every workflow. +# Files carrying the `you/` placeholder, alongside every workflow; mise.root.toml +# carries the registry path and must be substituted before it becomes mise.toml. PROJECT_OWNER_FILES=(compose.yaml install.sh README.md mise.root.toml) # Files carrying @PROJECT_NAME@; the image build.yml pushes to and the image From 71655c2e10016e680c8dd1672cde1ea975900022 Mon Sep 17 00:00:00 2001 From: iam-truongtrungnghia Date: Sun, 13 Sep 2026 17:36:05 +0700 Subject: [PATCH 9/9] chore: remove the executed parity plan A plan is a work order. The five left behind before this one claimed 305 undone tasks in a repository where all of it had shipped; PR #18 deleted 8,703 lines of them. The design stays in docs/superpowers/specs/, which is the record. --- .../plans/2026-09-13-immich-parity.md | 603 ------------------ 1 file changed, 603 deletions(-) delete mode 100644 docs/superpowers/plans/2026-09-13-immich-parity.md diff --git a/docs/superpowers/plans/2026-09-13-immich-parity.md b/docs/superpowers/plans/2026-09-13-immich-parity.md deleted file mode 100644 index 8cdd18c..0000000 --- a/docs/superpowers/plans/2026-09-13-immich-parity.md +++ /dev/null @@ -1,603 +0,0 @@ -# Immich Parity Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -> **Delete this file when every task is checked off.** PR #18 removed five plans -> that outlived their execution and still claimed 305 undone tasks. A plan is a -> work order, not a record; the record is the ADRs, the tour, and the code. - -**Goal:** close the gaps a full-repository survey found between this toolbox and -immich — files immich has that we never added, and a presentation style we -adopted in prose but not in mechanism. - -**Architecture:** four independent pull requests, each verified the same way and -each revertable on its own. Nothing changes behaviour except one test, which is -strengthened so it can fail for the reason it was written. - -**Tech Stack:** bash, YAML, bats, mise, shellcheck, yq, zizmor. - -**Spec:** `docs/superpowers/specs/2026-09-13-immich-parity-design.md` - -## Global Constraints - -- Chat is Vietnamese; **every file, comment, commit message and document is - English**. -- Comment rule: a comment earns its place only when it is not obvious in ten - seconds AND sits at a different abstraction level than the line below it. - Third-party landmines stay; narration around them goes. -- `mise run lint` must pass before every commit — lefthook's pre-commit hook - runs it and will block the commit otherwise. -- Commit messages are Conventional Commits; lefthook's commit-msg hook greps for - the prefix. -- Work on `chore/immich-parity`, already cut from `main` at `277d8a8`. The spec - is already committed there as `18913b5`. -- Every task ends green on: `mise run lint`, `mise run test-runner`, - `mise exec -- zizmor --min-severity medium .github/workflows/`. -- `mise run test-runner` takes about 25 minutes. Run it once per task, at the - end, not per step. - ---- - -## File Structure - -| Path | Responsibility | Task | -| --- | --- | --- | -| `SECURITY.md` | the toolbox's own vulnerability contact | 1 | -| `CODEOWNERS` | the toolbox's own owner | 1 | -| `.vscode/extensions.json` | recommend the tools `mise.toml` pins | 1 | -| `.vscode/settings.json` | teach an editor that `scaffold` is bash | 1 | -| `common/.github/pull_request_template.md` | ships a PR template to clients | 1 | -| `common/.vscode/extensions.json` | ships editor hints to clients | 1 | -| `tests/new-project.bats` | assert the owner is right, not merely non-placeholder | 1 | -| `.github/workflows/*.yml` | a `name:` on all 49 steps | 2 | -| `common/install.sh`, `lib/*.sh`, `services/shared/*.sh` | `local -r`, comments to their facts | 3 | -| `tests/*.bats`, `tests/helpers/setup.bash` | comments to their facts | 4 | -| `adapters/*/adapter.env`, `common/*ignore`, `common/example.env` | comments to their facts | 4 | - ---- - -## Task 1: Files immich has and we do not - -**Files:** -- Create: `SECURITY.md`, `CODEOWNERS`, `.vscode/extensions.json`, - `.vscode/settings.json`, `common/.github/pull_request_template.md`, - `common/.vscode/extensions.json` -- Modify: `tests/new-project.bats` - -**Interfaces:** -- Consumes: nothing from other tasks. -- Produces: nothing other tasks rely on. - -- [ ] **Step 1: Write the failing assertion** - -`tests/new-project.bats` has a test named `no placeholder account survives into -the generated project`. Its body currently ends with a `grep` for `@you\b|you/` -asserting the output is empty. That proves a placeholder is gone, not that the -owner is right — `* @ttncode` passes it. Add this to the end of that test body, -after the existing `grep` assertion: - -```bash - # Absence of the placeholder is not presence of the owner: a hardcoded or - # mistyped account passes the grep above. tests/helpers/setup.bash exports - # SCAFFOLD_GITHUB_OWNER=test-owner. - run cat "${PROJECT}/CODEOWNERS" - [ "$output" = "* @test-owner" ] \ - || { echo "CODEOWNERS says '${output}', not the account this run resolved"; false; } -``` - -- [ ] **Step 2: Prove the new assertion can fail** - -```bash -sed -i 's|^\* @you$|* @someone-else|' common/CODEOWNERS -mise exec -- bats tests/new-project.bats -f "no placeholder account" -``` - -Expected: FAIL, with `CODEOWNERS says '* @someone-else', not the account this run resolved`. - -Then restore: - -```bash -git checkout -- common/CODEOWNERS -mise exec -- bats tests/new-project.bats -f "no placeholder account" -``` - -Expected: PASS. - -- [ ] **Step 3: Create the toolbox's own SECURITY.md** - -Same text `common/SECURITY.md` already ships. Create `SECURITY.md`: - -```markdown -# Security policy - -Report vulnerabilities privately rather than opening a public issue. Email the -maintainer listed in `CODEOWNERS` with a description and reproduction steps. -Expect an initial response within a few business days. -``` - -- [ ] **Step 4: Create the toolbox's own CODEOWNERS** - -Create `CODEOWNERS`: - -``` -* @ttncode -``` - -Hardcoded is correct here: this repository has one owner and no substitution -step. `common/CODEOWNERS` keeps its `@you` placeholder — do not touch it. - -- [ ] **Step 5: Create the editor hints** - -Create `.vscode/extensions.json`: - -```json -{ - "recommendations": [ - "timonwong.shellcheck", - "foxundermoon.shell-format", - "editorconfig.editorconfig" - ] -} -``` - -Create `.vscode/settings.json`: - -```json -{ - "files.associations": { - "scaffold": "shellscript" - } -} -``` - -`scaffold` has no extension, so an editor treats it as plain text and offers no -shell diagnostics for the largest file in the repository. - -- [ ] **Step 6: Ship a pull request template to generated projects** - -Create `common/.github/pull_request_template.md`: - -```markdown -## What this changes - - - -## How it was verified - - - -## Checklist - -- [ ] `mise run checklist` passes -- [ ] New behaviour has a test that fails without the change -- [ ] Docs that describe changed behaviour were updated in the same commit -- [ ] No unrelated changes -``` - -The three headings match the ones this repository enforces on itself. The -checklist names the generated project's own command, `mise run checklist`, not -this repository's `mise run test-runner`. - -- [ ] **Step 7: Ship editor hints to generated projects** - -Create `common/.vscode/extensions.json`: - -```json -{ - "recommendations": [ - "editorconfig.editorconfig", - "esbenp.prettier-vscode", - "timonwong.shellcheck" - ] -} -``` - -Language-agnostic only. A generated project can be TypeScript, PHP, or both; -per-adapter extensions would need a fragment-merge mechanism like -`lefthook.fragment.yml`, which does not exist and nothing yet needs. - -- [ ] **Step 8: Verify a generated project carries the new files** - -```bash -T=$(mktemp -d) -SCAFFOLD_GITHUB_OWNER=acme-corp MISE_STATE_DIR="$T/s" GIT_CONFIG_GLOBAL="$T/g" \ - ./scaffold new "$T/demo" --db none -ls "$T/demo/.vscode/extensions.json" "$T/demo/.github/pull_request_template.md" -cat "$T/demo/CODEOWNERS" -rm -rf "$T" -``` - -Expected: both files listed, `CODEOWNERS` reads `* @acme-corp`. - -Neither new file carries `you/`, `@you`, or `@PROJECT_NAME@`, so neither needs -adding to `PROJECT_OWNER_FILES` or `PROJECT_NAME_FILES` in `lib/project.sh`. If -a future template does carry one, it must be added there or it ships with the -placeholder intact. - -- [ ] **Step 9: Run the full suite** - -```bash -mise run lint -mise run test-runner -mise exec -- zizmor --min-severity medium .github/workflows/ -``` - -Expected: lint clean, 262 tests passing with none failing, zizmor reporting no -findings. - -- [ ] **Step 10: Commit** - -```bash -git add SECURITY.md CODEOWNERS .vscode common/.vscode \ - common/.github/pull_request_template.md tests/new-project.bats -git commit -m "feat: carry the files this toolbox already asks of its projects - -SECURITY.md and CODEOWNERS ship to every generated project and were missing -here. A pull request template is enforced on this repository's own pull -requests by a CI job and was shipped to nobody. .vscode recommends the tools -mise.toml already pins, and tells an editor that the extensionless scaffold -file is bash. - -The placeholder test asserted that no @you survived generation, which a -hardcoded account passes. It now asserts CODEOWNERS names the account the run -resolved." -``` - ---- - -## Task 2: A name on every workflow step - -**Files:** -- Modify: `.github/workflows/adapters.yml`, `.github/workflows/ci.yml`, - `.github/workflows/provenance.yml`, `.github/workflows/pull-request.yml` - -**Interfaces:** -- Consumes: nothing. -- Produces: nothing. - -`common/.github/workflows/*.yml` are pure `uses:` call sites with no steps. Do -not touch them. - -- [ ] **Step 1: Count the steps that need a name** - -```bash -grep -c '^\s*- uses:\|^\s*- run:\|^\s*- id:' .github/workflows/*.yml -``` - -Expected: 31 in adapters.yml, 11 in ci.yml, 6 in provenance.yml, 1 in -pull-request.yml — 49 with no `name:` between them. - -- [ ] **Step 2: Name every step** - -For each step, add `name:` as its first key. The name is a short imperative -phrase describing what the step does, in the same voice immich uses: `Checkout -code`, `Setup Mise`, `Publish`, `Build and push image`. - -Where a step has an `id:`, `name:` goes first and `id:` second. - -Example, from `.github/workflows/ci.yml`: - -```yaml - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false -``` - -becomes: - -```yaml - - name: Checkout - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false -``` - -A step whose purpose was explained by a comment above it takes that explanation -into its name where the name can carry it. Example, from -`.github/workflows/adapters.yml`: - -```yaml - # a broken adapter must not hide the state of the others - fail-fast: false -``` - -stays a comment — it annotates `fail-fast:`, not a step. - -- [ ] **Step 3: Delete the comments the names now carry** - -Remove a comment only when the step's new `name:` says the same thing. Keep -every comment recording GitHub's own behaviour, including: - -- SARIF upload refused on a private repository without Advanced Security -- CodeQL needing `actions: read` to read its own workflow run -- the job name `pull-request-body` being load-bearing for branch protection -- the weekly cron string also living in `scripts/adapter-matrix.sh` -- timeout values and the measurement behind them - -- [ ] **Step 4: Verify the YAML still parses and the audit is clean** - -```bash -for f in .github/workflows/*.yml; do mise exec -- yq -e '.jobs' "$f" >/dev/null && echo "ok $f"; done -mise exec -- zizmor --min-severity medium .github/workflows/ -grep -c '^\s*#' .github/workflows/*.yml -``` - -Expected: four `ok` lines, `No findings to report`, and a comment count of -roughly 35–45 across the four files, down from 100. - -- [ ] **Step 5: Run the full suite** - -```bash -mise run lint -mise run test-runner -``` - -Expected: lint clean, 262 tests passing. - -`tests/workflows.bats` asserts that every job in these files has a timeout, -starts from a closed permission set, pins every action by sha, and disables -credential persistence on checkout. Adding `name:` changes none of that; if any -of those tests fails, a key was moved or deleted by mistake. - -- [ ] **Step 6: Commit** - -```bash -git add .github/workflows -git commit -m "refactor(ci): name every step instead of commenting it - -49 steps across four workflows, none with a name. immich names 297 of them and -sits at 3.8% comments against our 17.5% — the difference is not that they -explain less, it is where. A step's name appears in the GitHub log while the -job runs; a comment above it appears only to someone reading the file. - -What stays is GitHub's own behaviour: SARIF refused without Advanced Security, -CodeQL needing actions: read, the pull-request-body job name being load-bearing -for branch protection." -``` - ---- - -## Task 3: Scripts in immich's shape - -**Files:** -- Modify: `common/install.sh`, `lib/*.sh`, `services/shared/laravel.sh`, - `services/shared/nest.sh` - -**Interfaces:** -- Consumes: nothing. -- Produces: nothing. - -- [ ] **Step 1: Apply `local -r` where a local is assigned once** - -immich writes `local -r Tgt='./immich-app'` and `local -r RepoUrl=...`. This is -function scope, not the file-level `readonly` this repository correctly rejects: -these libraries are re-sourced into child processes by design, and a second -file-level `readonly` is an error that `set -e` turns into a dead script. A -`local -r` has no such problem. - -Change `local x="$1"` to `local -r x="$1"` only where `x` is never reassigned in -the function. Leave accumulators (`status`, `block`, `env_block`), loop -variables, and anything reassigned. - -Do not change the file-level constants (`COMPOSE_FILE`, `GATE_NAME`, -`PROJECT_NAME_RULE`, and the rest). They stay plain assignments. - -- [ ] **Step 2: Verify shellcheck is still clean** - -```bash -mise run lint -``` - -Expected: clean. `local -r` on a variable that is later assigned is a shellcheck -error (SC2155 family) and will surface here. - -- [ ] **Step 3: Cut `common/install.sh` to its facts** - -104 comment lines in 285. immich's equivalent is 3 in 107, because `main()` -reads as prose and the function names narrate. Ours already has that shape. - -For each function, reduce its comment block to the sentence carrying the fact. -Keep, in full or close to it: - -- `release_asset_id` — jq rather than grep, because an asset's own id precedes - its name while the uploader's follows it, and the wrong request succeeds -- `fetch_release_asset` — two endpoints, because a private release's browser URL - answers 404 both anonymously and with a token -- `download_release_assets` — two cleanup mechanisms, and why a plain EXIT trap - is not enough -- `generate_service_passwords` — the `|` delimiter, because a base64 value - contains `/`; and the known argv exposure -- `compose_has_service` — grep closing the pipe, SIGPIPE, pipefail -- the `BASH_SOURCE[0]:-$0` guard at the foot of the file - -Delete narration that restates the function name or walks through the body. - -Target: about 15%, roughly 40 comment lines. - -- [ ] **Step 4: Cut `lib/*.sh` and `services/shared/*.sh` the same way** - -`lib/project.sh` is at 37%, `lib/contract.sh` at 42%, `services/shared/nest.sh` -at 52%, `services/shared/laravel.sh` at 47%, `services/mongodb/drivers/laravel.sh` -at 42%. Same rule: the fact stays, the narration goes. - -The small per-service drivers (`services/{mysql,postgres,mongodb}/drivers/nest.sh`) -read at 67–75%, but that is the six-line header box over a twelve-line file. -Leave them. - -- [ ] **Step 5: Run the full suite** - -```bash -mise run lint -mise run test-runner -``` - -Expected: lint clean, 262 tests passing. - -`tests/install.bats` exercises `release_asset_id`, `fetch_release_asset`, -`require_private_tools`, `start_stack`, `generate_service_passwords` and -`run_migrations` directly. `tests/cli.bats` greps `lib/adapter.sh` for the exact -string `mise exec -- bash -c "$2"` and asserts it appears once — do not reword -that line. `tests/publish.bats` parses the heredocs in `lib/publish.sh` with -`sed` and `awk` anchored on a line that is exactly `{` and a line that is -exactly `}` — do not indent them. - -- [ ] **Step 6: Commit** - -```bash -git add common/install.sh lib services/shared -git commit -m "refactor: local -r, and comments down to their facts - -immich's install.sh is 107 lines with three comments because main() reads as -prose and the function names narrate. Ours has that shape already and still -carried a paragraph above each function. - -local -r for locals assigned once, which is function scope and unlike the -file-level readonly this repository rejects — these libraries are re-sourced -into child processes by design. - -What stays is the third-party landmines: jq rather than grep for an asset id, -two endpoints for a private release, the trap baking its path and naming its -signals, sed delimited on | because a base64 value contains /, and -BASH_SOURCE[0]:-\$0 because a curl-piped script has none." -``` - ---- - -## Task 4: Data files and tests - -**Files:** -- Modify: `tests/*.bats`, `tests/helpers/setup.bash`, - `adapters/*/adapter.env`, `adapters/*/.dockerignore`, - `adapters/*/.env.example`, `adapters/*/.prettierignore`, - `common/.dockerignore`, `common/.prettierignore`, `common/example.env` - -**Interfaces:** -- Consumes: nothing. -- Produces: nothing. - -- [ ] **Step 1: Cut the test suites** - -833 comment lines in 4,345 — 19%, against immich's 0.9% in `e2e/src`. A bats -test name is already a sentence; the dominant pattern here is a comment above a -test that restates its name. - -Delete a comment when the test name says it. Keep one when it records why the -test exists at all — several name a defect that shipped, and those are the -reason the test is not deleted as redundant. Example of one to keep, from -`tests/install.bats`: - -```bash - # `docker compose ... | grep -qx migrate` reads correctly and fails about one - # run in seven: grep closes the pipe on its first match, compose dies of - # SIGPIPE, and install.sh's own `set -o pipefail` reports the pipeline as - # failed. -``` - -Example of one to delete — a comment restating the test name immediately above -it. - -- [ ] **Step 2: Cut the adapter.env files** - -`adapters/laravel-inertia/adapter.env` is 29 comment lines in 38. Two blocks -carry real traps and must survive, compressed: - -- `ADAPTER_GENERATOR` — the commit pin, `SHELL_VERBOSITY=-1` because - `--no-interaction` does not reach `install:features`, and - `COMPOSER_PROCESS_TIMEOUT=900` because a cold cache exceeds the 300s default -- `ADAPTER_POST_GENERATE` — `rm -rf .github` because the starter kit's inert - dependabot config fails zizmor with exit 13, and the sed that wires - `routes/health.php` into `bootstrap/app.php` because Laravel auto-loads - neither, plus the grep pair that turns a silent no-op into a failure - -Delete the rest, including the `ADAPTER_TIER` block that restates ADR-0012 and -the `/up` note that restates what `ADAPTER_READINESS_PATH` beside it already -shows. - -Apply the same rule to `adapters/nextjs`, `adapters/nestjs`, -`adapters/laravel-api`. - -- [ ] **Step 3: Cut the ignore and env templates** - -immich's `.dockerignore` carries no comment at all and is perfectly legible. -Ours is 11 comment lines in 14. - -- `common/.dockerignore` — keep one line on why `node_modules` is ignored - despite being copied from a build stage; delete the rest -- `common/.prettierignore` — keep one line: prettier rewrites `pnpm-lock.yaml` - and fights Release Please for `CHANGELOG.md`; neither is written by hand -- `common/example.env` — keep one line: copy to `.env`, install.sh does it and - replaces every `changeme` -- `adapters/*/.dockerignore`, `adapters/*/.env.example`, - `adapters/*/.prettierignore` — same rule - -- [ ] **Step 4: Run the full suite** - -```bash -mise run lint -mise run test-runner -``` - -Expected: lint clean, 262 tests passing. - -`tests/compose.bats` asserts `common/example.env` still carries the literal -`changeme` for every password, and that `.dockerignore` exists for every -adapter. `tests/contract.bats` reads `adapter.env` through -`adapter_env_value` — a comment line cannot break it, but a deleted assignment -can. - -- [ ] **Step 5: Commit** - -```bash -git add tests adapters common/.dockerignore common/.prettierignore common/example.env -git commit -m "refactor: comments in the data files and the test suites - -19% of the test suites were comments against immich's 0.9%, and the dominant -pattern was a comment restating the test name below it. The adapter.env files -ran to 76%, and common/.dockerignore to 79% where immich's carries none. - -What stays is the traps: the laravel starter kit's inert .github failing zizmor -with exit 13, the sed wiring routes/health.php into bootstrap/app.php because -Laravel auto-loads neither, and the regression notes on tests that exist -because a defect shipped." -``` - ---- - -## Task 5: Open the pull requests - -**Files:** none. - -- [ ] **Step 1: Push and open one pull request per task** - -One pull request carrying all four commits. They are independent and each -reviewable on its own, and the branch is already cut; splitting into four -branches buys separate revert granularity that `git revert ` already gives. - -```bash -git push -u origin chore/immich-parity -gh pr create --title "chore: close the remaining immich parity gaps" --base main -``` - -The body must carry the three headings `.github/pull_request_template.md` -declares, or the `pull-request-body` check fails. - -- [ ] **Step 2: Wait for CI and merge** - -```bash -gh pr checks -``` - -Expected: `unit`, `integration`, `zizmor`, `self-test`, `pull-request-body`, -`discover`, `smoke` for three adapters and `deploy` for three adapters, all -passing. `deploy-tier-b` and `smoke-tier-b` run too, because Task D changes -`adapters/laravel-inertia/`. - -- [ ] **Step 3: Delete this plan** - -```bash -git rm docs/superpowers/plans/2026-09-13-immich-parity.md -git commit -m "chore: remove the executed parity plan" -``` - -A plan is a work order. Leaving it behind is what produced the 8,703 lines PR -#18 deleted.