From 19f2ddcf17dd52f1192de710b8530691fa116af4 Mon Sep 17 00:00:00 2001 From: iam-truongtrungnghia Date: Thu, 17 Sep 2026 14:50:55 +0700 Subject: [PATCH 1/2] docs: rewrite the tour and README as verified, terse reference --- CONTRIBUTING.md | 145 ++++++++++++---------------------- README.md | 104 +++++------------------- SECURITY.md | 4 +- docs/README.md | 5 ++ docs/runbook/ci-is-red.md | 2 + docs/tour/01-toolchain.md | 42 +++------- docs/tour/02-task-contract.md | 37 +++------ docs/tour/03-ci.md | 53 ++++--------- docs/tour/04-guardrails.md | 53 ++++--------- docs/tour/05-release.md | 45 ++++------- docs/tour/06-docs-site.md | 36 +++------ docs/tour/07-containers.md | 93 +++++----------------- docs/tour/08-adapters.md | 98 ++++------------------- docs/tour/09-wizard.md | 91 ++++----------------- 14 files changed, 207 insertions(+), 601 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e14a73d..1057631 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -2,122 +2,76 @@ ## Setup -```sh -mise install # every tool this repository uses, pinned in mise.toml -mise exec -- lefthook install # shellcheck, gitleaks, commit-message check -mise run lint # shellcheck + shfmt over every tracked shell file -mise run test-unit # the offline suites -``` +1. `mise install` — every tool this repository uses, pinned in `mise.toml`. +2. `mise exec -- lefthook install` — the git hooks (`lefthook` is pinned, not on `PATH`). +3. `mise run lint` +4. `mise run test-unit` -`lefthook` is pinned here, not on your PATH, so the bare command works only if -your shell already runs `mise activate`. The hooks are deliberately cheap — -`lint` and a secret scan at `pre-commit`, a Conventional Commit check at -`commit-msg`, and no `pre-push` gate: the suites run in this repository's CI on -every push, and paying two minutes locally to learn the same thing is a tax. - -`./scaffold` loads this repository's own pinned `jq` and `yq` before it does -anything else, so it needs no wrapper — from the clone, a symlink, or `PATH`. +| Hook | Runs | +| --- | --- | +| `pre-commit` | `mise run lint`, gitleaks on staged changes | +| `commit-msg` | Conventional Commit check (a grep; this repository has no node) | +| `pre-push` | nothing: CI runs the suites on every push | ## Tasks -| Task | What it runs | +| Task | Runs | | --- | --- | -| `lint` | shellcheck + shfmt (`-i 2 -ci`) over every shell file the repository tracks | -| `test-unit` | the suites that never invoke an adapter's generator — offline and quick | -| `test-integration` | the suites that generate a real project as a fixture | -| `test` | every suite, including the per-adapter smoke tests | -| `test-runner` | `test-unit` and `test-integration` under the environment a GitHub runner has | -| `ci-unit` | `lint` + `test-unit` — what CI runs on a pull request | -| `checklist` | `lint` + `test` — the pre-push gate | - -`mise run test-runner` matters more than its name suggests. `pnpm` turns on -`--frozen-lockfile` when `CI` is set, and `mise` trusts every config it finds -for the same reason; a suite that passes without those variables says nothing -about the runner. Several failures have only ever appeared there. +| `lint` | shellcheck + shfmt (`-i 2 -ci`) over every tracked shell file | +| `test-unit` | The offline suites, listed by name in `mise.toml` | +| `test-integration` | The suites that generate a real project as a fixture | +| `test` | Every suite in `tests/`, including the per-adapter smoke tests | +| `test-runner` | `test-unit` and `test-integration` with `CI`, `GITHUB_ACTIONS` and `MISE_YES` set, as on a runner | +| `ci-unit` | `lint` + `test-unit`: the `unit` job in `.github/workflows/ci.yml` | +| `checklist` | `lint` + `test` | -## Writing a test +`test-runner` matters: with `CI` set, pnpm uses `--frozen-lockfile`, so a suite can pass locally and fail on a runner. -Assert with `assert_ok` rather than `[ "$status" -eq 0 ]`. bats captures the -command's output into `$output` and prints none of it, so a bare status check -reports the line that failed and nothing about why — every diagnosis in this -repository has started by adding that output back by hand. +## Test lanes -Each test generates its own project. That is slow, and deliberately so: a -shared fixture makes one test's mess into the next test's failure, and a suite -whose result depends on execution order cannot say what broke. The suites run -with `--jobs` instead, which overlaps independent work without sharing any. +| Suite | Lane | Where CI runs it | +| --- | --- | --- | +| Listed in `test-unit` | unit | `.github/workflows/ci.yml`, `unit` job | +| Listed in `test-integration` | integration | `.github/workflows/ci.yml`, `integration` job | +| `tests/new-.bats` | smoke, per adapter tier | `.github/workflows/adapters.yml` | +| `tests/provenance.bats` | provenance | `.github/workflows/provenance.yml` | -When a test cannot hold its own precondition — `mise` pre-trusts every config -on a runner, `gh` has no credentials there — `skip` with the reason rather than -failing. A red that is about the environment teaches nothing. +`tests/contract.bats` fails when a suite is in no lane, or when a `test-unit` suite runs an adapter generator. -## Adding an adapter +## Writing a test -See [docs/runbook/add-an-adapter.md](docs/runbook/add-an-adapter.md). In short: -an adapter invokes a framework's own generator and overlays its own files on -the result. It must ship `adapter.env`, `mise.toml`, `Dockerfile` and -`.env.example`, implement all nine contract tasks, and pass `scaffold lint`. +- Assert with `assert_ok` (`tests/helpers/setup.bash`), not `[ "$status" -eq 0 ]`: it prints the captured output on failure. +- Each test builds its own fixtures; no suite uses `setup_file`. Suites run in parallel with `--jobs`. +- When the environment cannot hold a precondition, `skip` with the reason instead of failing. -`format`, `lint` and `check` must report without repairing. The linter enforces -this by rejecting a writing flag in their `run` — see -[ADR-0011](docs/decisions/0011-task-contract-names-follow-immich.md). +## Adding an adapter -Nothing needs doing for the wizard: it builds its questions from `scaffold -list`, so a new adapter appears there as soon as `scaffold lint` passes — -see [09-wizard](docs/tour/09-wizard.md). +Follow [docs/runbook/add-an-adapter.md](docs/runbook/add-an-adapter.md). The wizard needs no change: it reads `scaffold list`. ## Adding a service -A service is a directory under `services/`, not an adapter — see -[ADR-0019](docs/decisions/0019-services-are-not-adapters.md). It must ship -six files: `service.env` (`SERVICE_NAME`, `SERVICE_KIND`, a digest-pinned -`SERVICE_IMAGE`), a shared `compose.fragment.yaml`, a `compose.prod.fragment.yaml` -/ `compose.dev.fragment.yaml` / `compose.test.fragment.yaml` delta per lane, -and an `env.fragment`. None of the compose fragments may carry their own -`image:` line — `assemble_compose` writes the digest in from `service.env`. - -It must also ship a driver, `drivers/.sh`, for every adapter family -whose role is `api` or `app` — `laravel` and `nest` today; `next` takes none, -because `nextjs`'s role is `web` and the presentation tier opens no -connection. `scaffold lint` derives that family list from the adapters -themselves and fails a service that is missing any one of them, by name. A -new adapter family is the same problem from the other direction: it cannot -merge until every existing service has a `drivers/.sh`, which is -why the lint requires the full matrix rather than checking each service in -isolation. +A service is a directory under `services/` ([ADR-0019](docs/decisions/0019-services-are-not-adapters.md)). -## What a change reaches - -A change to `common/` or to an adapter reaches a project that already exists -only when somebody runs `scaffold update` in it — see -[ADR-0023](docs/decisions/0023-a-project-records-what-generated-it.md). A -change to a reusable workflow in *you/.github* reaches every project the next -time it runs, once `v1` moves (ADR-0005). Knowing which of the two you are -writing decides whether anything has to be done afterwards. - -## Commits - -Conventional Commits, enforced by lefthook at `commit-msg`. `feat:` and `fix:` -move the version of a generated project; `chore:` and `docs:` do not. +1. Add `service.env` with `SERVICE_NAME`, `SERVICE_KIND` and a digest-pinned `SERVICE_IMAGE`. +2. Add `compose.fragment.yaml` and the per-lane `compose.prod.fragment.yaml`, `compose.dev.fragment.yaml`, `compose.test.fragment.yaml`, none with an `image:` line. +3. Add `env.fragment`. +4. Add `drivers/.sh` for every family of an `api` or `app` adapter — today `laravel`, `nest`, `flask`. +5. Run `./scaffold lint`: it derives the families from the adapters and names any missing driver. -## Versions +A new adapter family is the same check from the other side: every service needs its driver before it merges. -This toolbox is versioned by git tag and nothing else — there is no package to -publish, and the tag is the artefact. `scaffold --version` is `git describe` -against the checkout, and a generated project records that same string in its -`.scaffold.toml`, which is what `scaffold update` later diffs from. +## What a change reaches -Cut one from `main` after a change worth telling somebody about: +| Change | Reaches an existing project | +| --- | --- | +| `common/` or an adapter | When someone runs `scaffold update` in it ([ADR-0023](docs/decisions/0023-a-project-records-what-generated-it.md)) | +| A reusable workflow in *you/.github* | On its next run, once `v1` moves (ADR-0005) | -```sh -git tag v0.2.0 -git push origin v0.2.0 -``` +## Commits and versions -Tagging is deliberately manual. Release Please is not set up here the way it is -in a generated project, because nothing downstream installs this by version: -what a tag buys is a readable answer in `--version` and in every -`.scaffold.toml` written after it, not a distribution channel. +- Conventional Commits, checked at `commit-msg`. +- The toolbox is versioned by git tag only. `scaffold --version` is `git describe`, and `.scaffold.toml` records it. +- Tagging is manual: `git tag v0.2.0 && git push origin v0.2.0`. ## Before opening a pull request @@ -126,5 +80,4 @@ mise run lint mise run test-runner ``` -`mise run test` additionally covers the per-adapter smoke tests, including the -tier B adapter, which takes about 25 minutes. +`mise run test` adds the per-adapter smoke tests; tier B's five tests take about five minutes each. diff --git a/README.md b/README.md index fa247b9..c873ba6 100644 --- a/README.md +++ b/README.md @@ -4,34 +4,23 @@ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE) A bash toolbox that generates fully configured client projects. Every generated -application implements the same nine-task contract — `install`, `format`, -`format-fix`, `lint`, `check`, `test`, `build`, `ci-unit`, `checklist` — so CI -runs one command per config root and never learns the language. +application implements the same nine-task contract, so CI runs one command per +config root and never learns the language. ## Install -The toolbox needs `git`, `mise`, `jq` and `yq`. `mise` supplies the last two, -so install it first ([instructions](https://mise.jdx.dev/getting-started.html)), -then: +Needs `git` and [`mise`](https://mise.jdx.dev/getting-started.html); `mise install` supplies `jq`, `yq` and the rest. ```sh git clone https://github.com/ttncode/scaffold.git cd scaffold -mise install # jq, yq, bats, shellcheck, zizmor, rush — pinned in mise.toml +mise install ./scaffold list +export PATH="$PWD:$PATH" # optional: run `scaffold` from anywhere ``` -`scaffold` loads its own pinned `jq` and `yq` from this toolbox's `mise.toml` -before it does anything else, so it runs the same from a clone, from a symlink, -or from `PATH`: - -```sh -ln -s "$PWD/scaffold" ~/.local/bin/scaffold -``` - -It reads that environment without changing directory, so a relative target is -always created where the command was run, not inside the toolbox. It still -refuses to run when `git` or `mise` itself is missing, and names which. +`scaffold` loads its pinned `jq` and `yq` itself, and creates a relative target where you run it. +Call it by its path or through `PATH`; a symlink to it does not work. ## Usage @@ -41,99 +30,42 @@ scaffold new [--web ] [--api ] [--app ] [--db ] [--cache ] scaffold add --adapter scaffold update [dir] [--dry-run] -scaffold publish [dir] [--public] [--no-protect] [--dry-run] -scaffold list +scaffold publish [dir] [--public | --private] [--no-protect] [--dry-run] +scaffold list [--adapters] [--services] scaffold lint scaffold --version ``` -Run with no arguments in a terminal, `scaffold` walks you to a complete -`new` command instead of printing usage — see -[09-wizard](docs/tour/09-wizard.md). Anywhere else — a script, CI, no -terminal attached — it keeps exactly the behaviour below. - -`new` creates a project. `add` installs another application into one that -already exists. `update` brings a project that already exists up to this -toolbox — it diffs `common/` and each adapter between the commit the project -records in its own `.scaffold.toml` and this one, and applies the result to -the project's own paths; `--dry-run` prints that patch instead. `publish` creates the GitHub -repository the project already names and applies the settings a generated -project needs but cannot carry in a file — see -[ADR-0024](docs/decisions/0024-publishing-a-project-is-part-of-generating-it.md). -`list` reports the adapters and their tiers. `lint` checks every adapter and every service -against the contract. `--version` reports -which commit of this toolbox is installed — `git describe`, so a working tree -with uncommitted edits says `-dirty`. - -`new` prints one line per step rather than a package manager's output, and the -commands to run next when it finishes. `SCAFFOLD_VERBOSE=1` passes everything -through instead; a failing step prints its whole output either way. - -`--db` and `--cache` select a database and a cache; each defaults to `none` -except `--db`, which defaults to `mysql` for a project with an `--api` or -`--app` adapter. Requesting either on a project with neither is refused — -the `web` tier has no driver, so nothing in the project could reach it. See -[ADR-0020](docs/decisions/0020-database-default-is-derived-from-requested-adapters.md). - -The generated workflows call this account's reusable CI (`dot-github`) and -publish to its `ghcr.io` namespace. `scaffold new` resolves the account from -`SCAFFOLD_GITHUB_OWNER`, then `gh api user`, then `git config github.user`, -and refuses to generate if none of the three resolves. +What each command does, its defaults and its decision: [Commands](docs/README.md#commands). ## Adapter support tiers -"Supported" and "guaranteed" are different words. Tier membership is read -from each adapter's own `ADAPTER_TIER` (`adapters/*/adapter.env`) — see -[ADR-0012](docs/decisions/0012-tiered-adapter-support.md). +Each adapter's tier is `ADAPTER_TIER` in its `adapter.env` ([ADR-0012](docs/decisions/0012-tiered-adapter-support.md)). | Tier | Adapters | CI runs it | Guarantee | | --- | --- | --- | --- | | A | `nextjs`, `nestjs`, `laravel-api`, `flask` | every pull request, and nightly | stays green through every dependency bump | -| B | `laravel-inertia` | when `adapters/laravel-inertia/**` changes, and weekly | verified regularly, not on every push — a full generation measures ~5 minutes per test | -| C | none currently | not automatically verified | may rot; no guarantee at all | +| B | `laravel-inertia` | a pull request that changes `adapters/laravel-inertia/`, weekly, or on manual dispatch | verified regularly, not on every push | +| C | none currently | not automatically verified | none | ## Services -A database or cache is a directory under `services/`, not an adapter — see -[ADR-0019](docs/decisions/0019-services-are-not-adapters.md). Each ships a -driver per adapter family (`laravel`, `nest`, `next`, `flask`); `scaffold lint` -requires the full matrix before an adapter in a new family can merge. +A database or cache is a directory under `services/`, not an adapter ([ADR-0019](docs/decisions/0019-services-are-not-adapters.md)). | Slot | Services | Default | | --- | --- | --- | -| `--db` | `mysql`, `postgres`, `mongodb`, `none` | `mysql` (with `--api` or `--app`), otherwise `none` | +| `--db` | `mysql`, `postgres`, `mongodb`, `none` | `mysql` with `--api` or `--app`, otherwise `none` ([ADR-0020](docs/decisions/0020-database-default-is-derived-from-requested-adapters.md)) | | `--cache` | `redis`, `none` | `none` | -No DynamoDB: `compose.yaml` is attached to every release for a client to run -(ADR-0014), and the only DynamoDB that fits a compose file is an emulator -with no production counterpart in a self-hosted stack. - ## Documentation -- [Start here](docs/README.md) — what the toolbox is, map, commands, glossary +- [Start here](docs/README.md) — what the toolbox is, map, commands, glossary, [reading path](docs/README.md#reading-path) - [Tour](docs/tour/) — how the pieces fit, nine pages - [Decisions](docs/decisions/) — why they fit that way - [Runbooks](docs/runbook/) — what to do when something specific happens - [Provenance](docs/PROVENANCE.md) — what is copied from immich, and where it drifted -- [Contributing](CONTRIBUTING.md) — the tasks, the tests, and how to add an adapter - -### Reading path - -New to this toolbox: [01-toolchain](docs/tour/01-toolchain.md) through -[03-ci](docs/tour/03-ci.md). That is day one — enough to generate a project and -understand what CI does with it. - -Owning it for real, over the first week: the rest of the tour -([04-guardrails](docs/tour/04-guardrails.md) through -[09-wizard](docs/tour/09-wizard.md)), plus ADR-0001, ADR-0003 and ADR-0011. - -`docs/runbook/` is not reading material — consult it when the situation that -names it actually arises. +- [Contributing](CONTRIBUTING.md) — tasks, tests, adding an adapter or a service ## Licence -MIT — see [LICENSE](LICENSE). - -That covers this toolbox. A project it generates carries no licence of its own: -the toolbox does not write one, because who owns generated work and on what -terms is a question for the engagement it was generated for, not a default. +MIT — see [LICENSE](LICENSE). A generated project gets no licence file: its terms belong to the engagement it was generated for. diff --git a/SECURITY.md b/SECURITY.md index d29158d..b9280c7 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,5 +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. +Report vulnerabilities privately, not in a public issue. Contact the owner in +`CODEOWNERS` (`@ttncode`) with a description and reproduction steps. Expect an initial response within a few business days. diff --git a/docs/README.md b/docs/README.md index c24ce1b..960961f 100644 --- a/docs/README.md +++ b/docs/README.md @@ -69,6 +69,11 @@ Measured on `scaffold new demo --api nestjs --web nextjs --db postgres`: 101 tra | `scaffold --version` | also `-v` | `git describe` of this toolbox, `-dirty` for uncommitted edits | [ADR-0023](decisions/0023-a-project-records-what-generated-it.md) | | `scaffold --help` | also `-h` | Prints usage | none | +| Variable | Effect | +| --- | --- | +| `SCAFFOLD_GITHUB_OWNER` | The account substituted for `you/`; otherwise `gh api user`, then `git config github.user`, else `scaffold new` refuses | +| `SCAFFOLD_VERBOSE=1` | Streams every step's output; by default a step's output is shown only when it fails | + ## Glossary | Term | Meaning | Where | diff --git a/docs/runbook/ci-is-red.md b/docs/runbook/ci-is-red.md index 0a420b5..59792e2 100644 --- a/docs/runbook/ci-is-red.md +++ b/docs/runbook/ci-is-red.md @@ -23,6 +23,8 @@ means `scripts/check-provenance.sh` itself regressed, not that upstream drifted; its `check` job means upstream actually moved — see `docs/runbook/sync-with-upstream-immich.md`. +- The toolbox's own `unit` job runs long: a `test-unit` suite now runs an adapter generator. `tests/contract.bats` names it; move it to `test-integration` in `mise.toml`. + ## Before you call a fix done This project's own history has four separate fixes that each stopped the diff --git a/docs/tour/01-toolchain.md b/docs/tour/01-toolchain.md index 0a45af4..e447690 100644 --- a/docs/tour/01-toolchain.md +++ b/docs/tour/01-toolchain.md @@ -2,42 +2,24 @@ ## What it does -`mise.toml` pins every language and tool the project uses, and `mise.lock` -records the resolved versions. `mise install` reproduces them exactly on any -machine — this repository's own toolchain (`bats`, `shellcheck`, `yq`, `jq`) -and, in a generated project, `common/mise.root.toml`'s (`node`, `pnpm`, -`lefthook`, `gitleaks`). +- `mise.toml` pins every tool; `[settings] lockfile = true` makes `mise.lock` record the resolved versions. +- `mise install` reproduces them on any machine. +- The toolbox pins its own tools (`bats`, `shellcheck`, `shfmt`, `jq`, `yq`, …). +- A generated project's root pins `node`, `pnpm`, `lefthook`, `gitleaks`; an app's own `mise.toml` adds only what it needs beyond those. ## Read this -- `mise.toml` — this toolbox's own tools, plus `[settings] lockfile = true`. -- `common/mise.root.toml` — the template rendered into a generated project's - root `mise.toml`. Notice what it does *not* pin: no language runtime for - any adapter. -- `adapters/laravel-api/mise.toml` — a language declared in a local - `[tools]` block so it never reaches the project root (see 08 — Adapters). -- `adapters/flask/mise.toml` — the same rule with the pin somewhere else - again: it declares `uv` and no python, because uv resolves its own managed - interpreter and would install a mise-pinned one only to ignore it. - `adapters/flask/.python-version` is the file uv actually reads. -- Upstream for comparison: immich's own root - `https://github.com/immich-app/immich/blob/351be95/mise.toml`, which pins - every service's language in one place — the opposite of this project's - per-adapter split, and the reason ADR-0011 exists. +| File | Why | +| --- | --- | +| `mise.toml` | The toolbox's tools and its `lockfile = true` | +| `common/mise.root.toml` | Rendered into a generated project's root `mise.toml` | +| `adapters/laravel-inertia/mise.toml` | App-local `[tools]`: composer and node; php comes from the system (ADR-0016) | +| `adapters/flask/mise.toml` | Pins `uv`, not python: uv installs the version in `adapters/flask/.python-version` | ## Delete test -`mise.lock` is tracked here (added in `08468e7`, task 1 — confirm -yourself with `git ls-files --error-unmatch mise.lock`) and in every -generated project (`common/mise.root.toml` sets `lockfile = true` too, and -`common/.gitignore` does not exclude it). Delete it and nothing breaks -today: `mise install` still resolves something. Three months later a -client's machine resolves a newer Node than the one this project was built -and tested against, `pnpm install` behaves slightly differently, and the -build fails with nothing in the error pointing at a version mismatch. -`[settings] lockfile = true` is what turns the pin into something real -rather than decorative: without it, "pinned" only means "pinned until the -next machine resolves it differently." +Delete `mise.lock` and `mise install` still succeeds. +What is lost is each tool's recorded backend, download URL and checksum per platform, so nothing checks a download against the one tested. ## Try it diff --git a/docs/tour/02-task-contract.md b/docs/tour/02-task-contract.md index 5585570..4a56b6d 100644 --- a/docs/tour/02-task-contract.md +++ b/docs/tour/02-task-contract.md @@ -2,38 +2,23 @@ ## What it does -Every adapter implements the same nine task names — `install`, `format`, -`format-fix`, `lint`, `check`, `test`, `build`, `ci-unit`, `checklist` — so -CI can run the identical command against a Laravel API, a Next.js app, or a -NestJS service without ever knowing which one it is. `format`, `lint`, and -`check` are read-only by contract; only the `-fix` variant is allowed to -write. That split exists so a checking task that quietly repairs its own -input can't pass locally and then fail in CI against a clean checkout. +- Every adapter implements nine `mise` tasks: `install`, `format`, `format-fix`, `lint`, `check`, `test`, `build`, `ci-unit`, `checklist`. +- CI runs the same task names in every config root, whatever the language. +- `format`, `lint` and `check` only report; `scaffold lint` rejects a writing flag (`--write`, `--fix`, …) in them. +- Names follow immich (ADR-0011). ## Read this -- `lib/contract.sh` — `CONTRACT_TASKS`, the exact nine names, and - `REQUIRED_ADAPTER_FILES`, the four files every adapter must ship. -- `lib/lint.sh` — `lint_adapters`, which checks both of those against every - directory in `adapters/`. -- `adapters/nestjs/mise.toml` — one adapter's full implementation of the - contract; compare `check` (`tsc --noEmit`, read-only) against - `format-fix` (writes). -- ADR-0011 for why the names are immich's own rather than something - invented for this project. -- Upstream for comparison: - `https://github.com/immich-app/immich/blob/351be95/server/mise.toml`. +| File | Why | +| --- | --- | +| `lib/contract.sh` | `CONTRACT_TASKS`, `READ_ONLY_TASKS`, `WRITING_FLAGS`, `REQUIRED_ADAPTER_FILES` | +| `lib/lint.sh` | `lint_adapters`: required files, `adapter.env` variables, the nine tasks, read-only tasks | +| `adapters/nestjs/mise.toml` | One full implementation: `check` runs `tsc --noEmit`, `format-fix` writes | ## Delete test -Delete `lib/contract.sh` and `scaffold` fails immediately — it's sourced by -name at the top of the script, and `set -euo pipefail` means a missing -source file stops execution before any command runs. That's the good case: -unlike `mise.lock`, there's no silent window where this looks fine. The -quieter failure is deleting one *entry* from `CONTRACT_TASKS` instead: an -adapter that already implements the removed task keeps working, CI stops -calling it, and nobody notices until the day someone assumes the contract -still calls a task it stopped calling months ago. +Remove one name from `CONTRACT_TASKS` and `tests/contract.bats` fails: +"the contract has exactly nine task names". ## Try it diff --git a/docs/tour/03-ci.md b/docs/tour/03-ci.md index 77854b0..303e8ac 100644 --- a/docs/tour/03-ci.md +++ b/docs/tour/03-ci.md @@ -2,52 +2,29 @@ ## What it does -A generated project's own `.github/workflows/` carries almost no logic. Each -file is a thin call site — ten or so lines naming a reusable workflow in a -second repository, *you/.github*, pinned to the moving tag `v1` — plus -whatever inputs that project needs (which config roots to build, which image -to publish). The pipeline itself lives in one place, not copied into every -project scaffold ever generates. +- Each generated workflow is a thin call site: it names a reusable workflow in *you/.github* at `@v1` (ADR-0005). +- `ci.yml` passes one input, `roots:`, the project's config roots. +- `sync_ci_roots` rewrites that line from `config_roots` (ADR-0013), so the two cannot disagree. +- The toolbox's own `.github/workflows/ci.yml` is not a call site: it runs `mise run ci-unit`, `mise run test-integration` and `zizmor`. + +![Release flow](../diagrams/release-flow.svg) ## Read this -- `common/.github/workflows/ci.yml` — the entire call site: *uses: - you/.github/.github/workflows/app-ci.yml@v1* plus a `roots:` input. -- `lib/manifest.sh`'s `sync_ci_roots` — rewrites that one `roots:` line from - `config_roots` (ADR-0013) every time `scaffold new` or `scaffold add` - runs, so the two can never disagree. -- ADR-0005 for why the pipeline lives in a second repository at all, and - ADR-0010 for why it authenticates with the ambient `GITHUB_TOKEN` instead - of a minted GitHub App token. -- This toolbox's own CI is a different, unrelated case: `.github/workflows/ci.yml` - at the repository root tests *this* repository directly — it is not a - call site, because there is no *you/.github* for the toolbox itself to - call into. Its `unit` job runs `mise run test-unit`, the fast, - supposedly-offline lane — supposedly, because for a while it wasn't: a - filename convention (`! -name 'new-*.bats'`) was true when it was - written and drifted silently from what the test files actually did, - so the "offline" job was really running about thirty-five real project - generations per run, timing out around twenty minutes. Nothing checked - the convention against reality; only a stopwatch caught it. `test-unit` - is now an explicit file list instead of a filename guess, and - `tests/contract.bats` guards against that same list drifting again. See - `docs/runbook/ci-is-red.md` for the pattern that same task's review - found across four unrelated fixes in this project — the single most - transferable lesson this codebase produced. +| File | Why | +| --- | --- | +| `common/.github/workflows/ci.yml` | The whole call site: `uses:` plus `roots:` | +| `lib/manifest.sh` | `config_roots` and `sync_ci_roots` | +| `.github/workflows/ci.yml` | The toolbox's own CI | +| `docs/runbook/ci-is-red.md` | What a red job means | ## Delete test -Delete a generated project's `common/.github/workflows/ci.yml` (as -`.github/workflows/ci.yml` in that project) and the next push to `main` -simply runs no CI at all — GitHub silently has nothing to trigger. That's -loud in the sense that a pull request shows no checks, but easy to miss if -nobody is looking at the checks list, since nothing turns red. Compare: a -bad edit to the `roots:` line (say, a typo'd config root) fails the very -next CI run with a clear "no such directory," because `app-ci.yml` iterates -that list directly. +Delete `.github/workflows/ci.yml` from a generated project and no contract task runs on a pull request. +The security workflow still reports, and nothing turns red, so the gap is easy to miss. ## Try it ```bash -grep -A2 'uses: you/.github' common/.github/workflows/ci.yml +grep -n -A2 'uses:' common/.github/workflows/ci.yml ``` diff --git a/docs/tour/04-guardrails.md b/docs/tour/04-guardrails.md index b520bf5..24a6ca6 100644 --- a/docs/tour/04-guardrails.md +++ b/docs/tour/04-guardrails.md @@ -2,51 +2,30 @@ ## What it does -A generated project ships four independent layers that all have to agree -before code lands on `main`: lefthook runs formatting, a secret scan, and a -commit-message check locally, before a commit is even made; CI (via -*you/.github*'s security workflow) repeats the secret scan and adds -`zizmor` (workflow-injection scanning) and CodeQL, because a local hook can -always be bypassed with `--no-verify`; Renovate opens the dependency-bump -pull requests those checks then have to pass; and branch protection on -`main` is the one piece that isn't a file at all — a repository setting -that makes the required checks actually block a merge instead of just -turning red. +| Layer | Where | Checks | +| --- | --- | --- | +| Git hooks | `common/lefthook.yml` | prettier and gitleaks at `pre-commit`, commitlint at `commit-msg`, `checklist` at `pre-push` | +| CI | `common/.github/workflows/security.yml` | Calls *you/.github*'s `app-security.yml`; hooks can be skipped with `--no-verify`, CI cannot | +| Renovate | `common/renovate.json` | Opens dependency-bump pull requests that the checks above must pass | +| Ruleset | `lib/publish.sh` | `scaffold publish` protects `main`: pull request required, no deletion, no force push | ## Read this -- `common/lefthook.yml` — `pre-commit` (prettier, gitleaks), `commit-msg` - (commitlint), `pre-push` (the full `checklist`). -- `common/commitlint.config.js` — Conventional Commits, nothing custom. -- `common/renovate.json` — deliberately minimal: `$schema` plus the public - `config:recommended` preset. -- ADR-0007 for lefthook over husky, ADR-0006 for why every commit has to be - conventional in the first place (Release Please parses them). -- Upstream for comparison: immich runs no git hooks at all. ADR-0007's - Context ("Git hooks must work in a PHP-only project") is why this - project couldn't just reuse immich's tooling wholesale; its Alternatives - considered section is where husky specifically gets rejected, for being - Node-only. +| File | Why | +| --- | --- | +| `common/lefthook.yml` | The three hook stages | +| `common/commitlint.config.js` | `@commitlint/config-conventional`, nothing custom (ADR-0006) | +| `common/renovate.json` | `config:recommended`, digest pinning, `minimumReleaseAge` of 3 days | +| `lib/publish.sh` | `protect_main`: no required status checks, because their names differ per project | +| `docs/decisions/0007-lefthook-over-husky.md` | lefthook over husky: hooks must work in a PHP-only project | ## Delete test -`common/renovate.json` is the sharpest lesson in this project. Delete it -and a generated project simply gets no automated dependency updates — -visible, if you go looking. Far worse happened when the file was merely -*wrong* instead of missing: for nine tasks and every review in between, -this repository shipped a byte-for-byte copy of immich's own -`renovate.json` — invalid JSON (a trailing comma), extending an -organisation-internal preset (`local>immich-app/.github:renovate-config`) -no client's Renovate installation could ever resolve, and full of rules -for `mobile/**` and `machine-learning/**` paths that don't exist in a -generated project. It passed every check because the check being asked was -"is this identical to upstream," and the answer was truthfully yes. Nobody -asked "is this right for this project" until `docs/PROVENANCE.md` gave that -question a place to be asked. A file can be verbatim and broken at the same -time; those are different claims. +Delete `common/renovate.json` and a generated project gets no dependency-bump pull requests. +No check fails; dependencies just stop moving. ## Try it ```bash -node -e "import('./common/commitlint.config.js').then(c => console.log(c.default))" +yq '.pre-commit.commands | keys' common/lefthook.yml ``` diff --git a/docs/tour/05-release.md b/docs/tour/05-release.md index c267a32..0e0f70a 100644 --- a/docs/tour/05-release.md +++ b/docs/tour/05-release.md @@ -2,43 +2,30 @@ ## What it does -Every commit in a generated project must be a Conventional Commit — enforced -locally by commitlint and again by parsing history, because a local hook can -be bypassed. Release Please reads that history and maintains a standing -release pull request; merging it cuts a version, a changelog entry, and a -GitHub Release. Publishing an image is deliberately a separate concern from -cutting a release, so an ordinary bug-fix merge never has to wait behind -someone else's decision to bump a version. +- Commits are Conventional Commits; Release Please reads them and keeps a standing release pull request (ADR-0006). +- `build.yml` runs on every push to `main` and publishes `main` and `sha-` image tags. +- `release.yml` also runs on every push to `main`; merging the release pull request publishes the version tags (`1.4.0`, `1.4`) and `latest`. +- Build and release are separate workflows, so an ordinary merge never waits on a release (ADR-0015). + +![Release flow](../diagrams/release-flow.svg) ## Read this -- `common/release-please-config.json` and `common/.release-please-manifest.json` - — Release Please's own config and the version it currently believes it is - at. -- `common/.github/workflows/build.yml` — runs on every push to `main`, - publishes `main` and `sha-` image tags, no dependency on Release - Please at all. -- `common/.github/workflows/release.yml` — runs on every push to `main` - too, but its image and asset jobs only fire on the merge that actually - closes a release PR, publishing `` (e.g. `1.4.0` and `1.4`) and - `latest`. -- ADR-0006 for Release Please over changesets, ADR-0015 for why build and - release are two separate workflows rather than two jobs in one. +| File | Why | +| --- | --- | +| `common/release-please-config.json` | `release-type: simple`, changelog sections | +| `common/.release-please-manifest.json` | The version Release Please last released | +| `common/.github/workflows/build.yml` | Continuous images; `images:` is written by `scaffold` (ADR-0022) | +| `common/.github/workflows/release.yml` | Release call site; passes `RELEASE_APP_ID` and `RELEASE_APP_PRIVATE_KEY` | +| `docs/runbook/cut-a-release.md` | The steps to cut one | ## Delete test -Delete `common/.release-please-manifest.json` and the next release run -doesn't fail — Release Please just loses track of what version it already -issued and may propose a version lower than what's already tagged, which -only surfaces the first time someone tries to cut a release after the -file's been gone for a while, as a confusing diff in the release PR rather -than an error. Delete `common/.github/workflows/build.yml` instead and the -break is immediate: no image tag ever gets published for an ordinary merge -again, and a client running `IMAGE_TAG=main` stops getting updates the very -next push. +Delete `common/.github/workflows/build.yml` and an ordinary merge publishes no image. +A client on `IMAGE_TAG=main` stops getting updates. ## Try it ```bash -node -e "console.log(require('./common/.release-please-manifest.json'))" +jq . common/.release-please-manifest.json common/release-please-config.json ``` diff --git a/docs/tour/06-docs-site.md b/docs/tour/06-docs-site.md index b7c5c8e..e89c183 100644 --- a/docs/tour/06-docs-site.md +++ b/docs/tour/06-docs-site.md @@ -2,38 +2,26 @@ ## What it does -`common/docs/` is not a side project — it's a config root (ADR-0013), -registered in `common/mise.root.toml`'s `config_roots` alongside every app. -That means its `check` and `build` tasks run in CI exactly like an app's -`test` and `build` do: a broken VitePress build, a dead internal link, or a -malformed ADR fails the pipeline the same way a failing unit test would. +- `common/docs/` is a VitePress site and a config root: `docs` is listed in `config_roots` in `common/mise.root.toml` (ADR-0013). +- CI runs its contract tasks like any app's, so a broken path, a malformed ADR or a failed build fails the pipeline. +- One docs workflow, not immich's three (ADR-0009). ## Read this -- `common/docs/mise.toml` — `check` runs two structural scripts before - `build` ever runs VitePress. -- `common/docs/scripts/check-paths.mjs` — every backticked path-looking - string in the generated project's own Markdown must resolve to a real - file. -- `common/docs/scripts/check-adrs.mjs` — every ADR under - `common/docs/decisions/` needs `Context`, `Decision`, `Consequences`, - and `Alternatives considered`, a valid `Status`, and a non-duplicate - number. -- ADR-0009 for one docs workflow instead of immich's three, ADR-0001 for - why `mise` tasks are the mechanism at all. +| File | Why | +| --- | --- | +| `common/docs/mise.toml` | `lint` runs `check-paths.mjs`; `check` runs `check-paths.mjs` and `check-adrs.mjs`; `build` runs VitePress | +| `common/docs/scripts/check-paths.mjs` | Every backticked path in the project's Markdown must exist | +| `common/docs/scripts/check-adrs.mjs` | Every ADR has `Context`, `Decision`, `Consequences`, `Alternatives considered`, a valid `Status`, a unique number | +| `common/.github/workflows/docs.yml` | The docs call site | ## Delete test -Delete `common/docs/scripts/check-paths.mjs` and nothing breaks today — -`check` still runs `check-adrs.mjs` and reports success. Months later, -someone renames a directory the docs reference, the reference goes stale, -and the only signal is a reader hitting a dead link in the published site — -exactly the "documentation that CI does not verify is wrong within six -months" comment in `common/docs/mise.toml` describes. The check exists -specifically because that failure mode has no other detector. +Remove `node scripts/check-adrs.mjs` from `check` in `common/docs/mise.toml`. +An ADR missing a required section then passes CI. ## Try it ```bash -node common/docs/scripts/check-adrs.mjs && echo "all ADRs valid" +grep -n -A1 '^\[tasks' common/docs/mise.toml ``` diff --git a/docs/tour/07-containers.md b/docs/tour/07-containers.md index bfe732a..e989f83 100644 --- a/docs/tour/07-containers.md +++ b/docs/tour/07-containers.md @@ -2,90 +2,33 @@ ## What it does -Every adapter ships a multi-stage Dockerfile that builds a client's -deployable image without ever containing that client's real configuration. -Three Compose files exist alongside it for three different purposes: the -production-like stack a client actually runs, a throwaway database for -local development, and a tmpfs database for CI and test runs — same shape, -different lifetimes. +- Every adapter ships a multi-stage `Dockerfile` that exposes 8080 and has a `HEALTHCHECK` on its `ADAPTER_LIVENESS_PATH`. +- `compose.yaml` is the released stack (ADR-0014); `compose.dev.yaml` runs throwaway services for an app outside docker; `compose.test.yaml` runs them on tmpfs. +- Each app gets one compose service and one image, named after its directory (ADR-0022). +- Each selected service merges `compose.fragment.yaml` plus a per-lane delta into all three files; an api or app service `depends_on` it as `service_healthy` in `compose.yaml` (ADR-0019). +- A service's image digest lives only in its `service.env`; `assemble_compose` writes it in. -`common/compose.yaml`, `common/compose.dev.yaml` and `common/compose.test.yaml` -ship the `app` service alone; a database or cache is not written into any of -them. Each service selected with `--db` or `--cache` merges in a shared body -(`services//compose.fragment.yaml`) plus its own per-lane delta -(`compose.prod.fragment.yaml`, `.dev.`, `.test.`) into all three files, and -`app`'s `depends_on` gets a `service_healthy` entry for it, in the -production lane only. A project that asked for neither ships neither — no -service nothing opens a connection to (ADR-0019). The image digest lives in -exactly one place, that service's own `service.env`: no compose fragment -carries an `image:` line, `assemble_compose` writes it in during the merge, -and `tests/service.bats` fails a fragment that pins its own. +![Generated project](../diagrams/generated-project.svg) ## Read this -- `adapters/laravel-api/Dockerfile` — vendor stage (`composer install`) - separate from the FrankenPHP runtime stage, and the comment explaining - why FrankenPHP replaced php-fpm: php-fpm speaks FastCGI, this stack has - no reverse proxy in front of it, and the check that used to ship here — - `php -r 'exit(0);'` — only proved the PHP binary starts, never failed a - review or CI because it could not fail *at all*, and was removed outright - rather than kept. That argument still holds: a check that can never fail - is worse than no check — an orchestrator with none at least knows it - doesn't know a container's state; one with an always-green check believes - it does, and routes real traffic to a dead container on that false - confidence. What changed is the premise underneath it, not the argument - (ADR-0014, ADR-0021): FrankenPHP serves real HTTP, so - `HEALTHCHECK … CMD wget -qO- http://localhost:8080/up` is a check that can - actually fail. -- `adapters/nestjs/Dockerfile` — the same shape, for contrast: its - `HEALTHCHECK` probes `/health/live`, the route - `adapters/nestjs/src/health/health.controller.ts` ships. -- `lib/service.sh`'s `assemble_compose` — the merge described above, and - `service_compose_key` for why a fragment must publish under `database` or - `cache`, not its own service name: `depends_on` names the key, not - `mysql` or `redis`, so an adapter's driver never has to know which one was - picked. -- `services/mysql/` for one full service: `service.env` (the pinned digest), - the four compose fragments, `env.fragment`, and - `services/mysql/drivers/laravel.sh` / `services/mysql/drivers/nest.sh`. -- ADR-0019 for why services are a category of their own, not a kind of - adapter, and ADR-0014 for the seven seams a real deploy target plugs into - later (published image, environment-only configuration, parameterised - `IMAGE_TAG`, health checks, and more). -- `lib/service.sh`'s `add_app_service` and `lib/manifest.sh`'s - `register_image_target`: one compose service and one image per application, - named after the application's own directory (ADR-0022). Until that ADR the - build and release workflows named one `apps/` directory per project, - so `--web nextjs --api nestjs` published only whichever adapter was applied - last and the other was never built at all. +| File | Why | +| --- | --- | +| `adapters/laravel-api/Dockerfile` | composer vendor stage, FrankenPHP runtime, `HEALTHCHECK` on `/up` | +| `adapters/nestjs/Dockerfile` | Same shape; probes `/health/live` | +| `lib/service.sh` | `assemble_compose`, `service_compose_key` (`database` or `cache`), `add_app_service` | +| `lib/manifest.sh` | `register_image_target`: one image per app in the build and release workflows | +| `services/mysql/` | One full service: `service.env`, four compose fragments, `env.fragment`, `drivers/` | +| `scripts/deploy-check.sh` | The only gate that starts containers and calls the app over HTTP (ADR-0021) | ## Delete test -Delete the `HEALTHCHECK` line from `adapters/nestjs/Dockerfile` (or any -other adapter's) and something notices now: `tests/compose.bats` asserts -every adapter Dockerfile has `EXPOSE 8080`, a `HEALTHCHECK`, and that the -`HEALTHCHECK` probes the exact path the adapter's own `adapter.env` -declares. Point it at a path nothing serves instead of deleting it, and the -same assertion still catches it — that is the `nestjs` defect ADR-0021 -records: it probed `/health`, which no adapter has ever served, for as long -as this repository existed, and nothing here noticed until this test was -written to compare the two values. - -What that test still cannot catch: whether the process behind the probe -ever answers for real. It reads Dockerfile text; it never builds an image -or starts a container. ADR-0021 records two defects invisible to every -static check in this repository, found only once something actually ran -the image — FrankenPHP's `CMD` silently dropping the base image's default -arguments (nothing listened on 8080 while `EXPOSE`/`HEALTHCHECK` both still -read correctly), and `nextjs` binding to an address its own `HEALTHCHECK` -could never dial. Only `scripts/deploy-check.sh`, the deploy gate, starts a -container, which is what closes that gap. If you're adding a `HEALTHCHECK` -to a new adapter, delete-test it the way that gate does: stop the process -the check is supposed to detect, and confirm the check actually goes -unhealthy — the laravel lesson above is what happens when nobody does. +Delete the `HEALTHCHECK` line from `adapters/nestjs/Dockerfile` and `tests/compose.bats` fails: +"every adapter Dockerfile probes the liveness path its adapter declares". +That test reads text only; whether the app really answers is checked by `scripts/deploy-check.sh`. ## Try it ```bash -docker compose -f common/compose.dev.yaml config --quiet && echo "compose.dev.yaml is valid" +docker compose -f common/compose.dev.yaml config --quiet && echo valid ``` diff --git a/docs/tour/08-adapters.md b/docs/tour/08-adapters.md index 8d2ba3f..f0cbb02 100644 --- a/docs/tour/08-adapters.md +++ b/docs/tour/08-adapters.md @@ -2,96 +2,32 @@ ## What it does -An adapter is an overlay, not a vendored application: `scaffold` invokes a -framework's own generator (`create-next-app`, `nest new`, `composer -create-project`) and then copies its own files on top of the result. Where a -framework ships no generator, the package manager's project init stands in — -`flask` runs `uv init --bare`, which writes a `pyproject.toml` and nothing -else, and the overlay supplies the application itself. -`lib/lint.sh` requires four of them — `adapter.env`, `mise.toml`, -`Dockerfile`, `.env.example` — and an adapter may ship more: `nextjs` adds -`next.config.ts` and `.prettierignore`, `laravel-api` adds `phpstan.neon` -and a `docker/` directory. `adapter.env` is the one exception to the copy: -it is sourced, never written into the app. +- An adapter runs a framework's own generator (`ADAPTER_GENERATOR`), then overlays its files on the output (ADR-0003). +- Required files: `adapter.env`, `mise.toml`, `Dockerfile`, `.env.example`; `adapter.env` and `lefthook.fragment.yml` are read, never copied. +- `ADAPTER_ROLE` (`web`, `api`, `app`) picks the directory: `apps/web`, `apps/api`, `apps/app`. +- For an `api` or `app` adapter, each selected service's `drivers/.sh` runs, keyed on `ADAPTER_FAMILY`, and its Dockerfile block replaces the `# @SERVICE_SETUP@` anchor. +- A `web` adapter takes no driver; the anchor is removed. -The overlay is small by construction — tens of lines per adapter, not a -generated application kept in sync by hand. Run `scaffold list` for what -ships today rather than trusting a figure written here, which goes stale -the first time an adapter gains a file. - -Every Dockerfile that can take a database or cache ships a -`# @SERVICE_SETUP@` anchor comment. Once the generator and any -`ADAPTER_POST_GENERATE` have settled the package manager's state, -`apply_adapter` calls `apply_service_drivers`, which runs each selected -service's `drivers/.sh` — keyed on `ADAPTER_FAMILY`, not the -adapter's own name, because the two Laravel adapters need identical wiring -— and concatenates every driver's `service_driver_dockerfile` output in -place of the anchor. A driver is expected to do two things: install -whatever the framework needs to reach the service (a composer package, a -pnpm package, a Prisma schema) and write the connection variables into -`.env.example` with `write_env_lines`, never a bare append, since a driver -runs against an `.env.example` the adapter already shipped. `nextjs`'s -Dockerfile ships the anchor like every other adapter's — `tests/service.bats` -requires it on all of them — but `ADAPTER_ROLE=web` takes no driver at all, -so `apply_adapter` calls `apply_service_dockerfile` with an empty block, which -removes the anchor outright rather than replacing it. A Dockerfile that -ships the anchor unreplaced fails to build. +![Code layers](../diagrams/code-layers.svg) ## Read this -- `adapters/nestjs/adapter.env` — `ADAPTER_NAME`, `ADAPTER_ROLE`, - `ADAPTER_TIER`, `ADAPTER_LANGUAGE`, `ADAPTER_FAMILY`, `ADAPTER_GENERATOR`, - and the optional `ADAPTER_POST_GENERATE` for one-time fixups the - generator itself gets wrong. -- `lib/adapter.sh` — `load_adapter` (reads `adapter.env` into the shell), - `role_path` (the only place a role maps to a directory — web, api, and - app roles land under apps/web, apps/api, and apps/app in a *generated* - project), `apply_adapter` (runs the generator, overlays every file the adapter - ships except `adapter.env`, merges the lefthook fragment, then runs the - service drivers described above). -- `services/shared/nest.sh` — the Prisma driver body every `nest`-family - database service sources. Pinned to Prisma major 6, not `@latest`: - major 7 drops the datasource `url` field this driver writes, in favor of - a `prisma.config.ts` adapter — a bigger change than a driver that only - ever writes `datasource` and `generator` blocks should force on every - service. The same file sets `allowBuilds` for `prisma`, `@prisma/engines` - and `@prisma/client` in the project's `pnpm-workspace.yaml`: none of the - three ships a pure-JS fallback for its install-time binary fetch, and - pnpm blocks an unapproved postinstall build by default - (`ERR_PNPM_IGNORED_BUILDS`) — the same guard ADR-0017 already names for - `unrs-resolver` and `esbuild`. -- `services/shared/laravel.sh` writes `DB_HOST=localhost`, not the compose - service name `database`: `.env.example` describes `mise run dev` (the app - running on the host), which reaches the database through - `compose.dev.yaml`'s published port, not the compose network. -- `services/mongodb/drivers/laravel.sh` — `composer config platform.ext-mongodb - 1.21.0` has to stay the version `service_driver_dockerfile`'s `pecl install - mongodb` actually builds a few lines below; pecl carries no version pin of - its own, so a newer extension release moves the image out from under this - number with nothing here to notice. -- `services/redis/drivers/nest.sh` installs the cache packages and writes - `REDIS_URL`; it registers no NestJS module. Wiring `CacheModule` to it is - left to the developer, the same way every adapter's own generator leaves - the rest of the framework's setup. -- ADR-0003 for the overlay decision itself, ADR-0012 for how tiers decide - what CI actually runs, ADR-0018 for why adding a second adapter later - never retroactively rewires the shared TypeScript workspace, and - ADR-0019 for why `services/` is a category of its own rather than a kind - of adapter. +| File | Why | +| --- | --- | +| `adapters/nestjs/adapter.env` | Every field: name, role, tier, language, family, generator, post-generate, health paths | +| `lib/adapter.sh` | `load_adapter`, `role_path`, `apply_adapter`: generate, overlay, post-generate, drivers, config root, lefthook fragment | +| `lib/service.sh` | `apply_service_drivers`, `apply_service_dockerfile`, `write_env_lines` | +| `services/shared/nest.sh` | The Prisma driver body: Prisma 6, `allowBuilds` for its install scripts (ADR-0017) | +| `scripts/adapter-matrix.sh` | Tier A and B CI matrices from `scaffold list --adapters` (ADR-0012) | +| `docs/runbook/add-an-adapter.md` | The steps to add one | ## Delete test -Delete an adapter's `adapter.env` and `scaffold lint` catches it -immediately — `lint_adapters` checks for all four required files on every -run. The quieter failure is a typo inside a field that still parses: set -`ADAPTER_TIER` to an unrecognised value and, before the fix this project -had to make, the adapter simply vanished from every CI matrix with exit 0 -— no adapters.yml job ever mentioned it again, and nothing pointed at -`adapter.env` as the place to look. `scripts/adapter-matrix.sh`'s -`assert_known_tiers` now fails loudly, by name, on exactly that case. +Delete an adapter's `adapter.env` and `scaffold lint` reports `missing file adapter.env`. +Set `ADAPTER_TIER` to an unknown value and `scripts/adapter-matrix.sh` fails, naming the adapter. ## Try it ```bash -./scaffold list +./scaffold list --adapters ``` diff --git a/docs/tour/09-wizard.md b/docs/tour/09-wizard.md index d3431e6..bae63b2 100644 --- a/docs/tour/09-wizard.md +++ b/docs/tour/09-wizard.md @@ -2,91 +2,28 @@ ## What it does -`scaffold` run with no arguments walks a user to a complete `new` command -instead of printing usage and exiting. It only does this in a terminal: -`main` checks `[ -t 0 ]` before calling `cmd_wizard`, and every other -invocation — a script, CI, `./scaffold list` inside this -repository's own tests — keeps exactly today's behaviour. `scaffold` -appears in all three; a bare call that opened a menu there would hang them -as a timeout, not fail as an error, so the TTY check is the whole safety -argument. - -The first question is what to do: `new`, and — only when the wizard was run -from inside a project scaffold generated — `update` and `publish`. Both of -those act on a project that already exists, so outside one they are not -offered at all, for the same reason a `web` project is never asked about a -database. With only one action to choose from the screen is skipped, because a -menu with one answer asks nothing. - -For `new`, the next question is shape — `web+api`, `app`, `api`, or `web` — -not frontend and backend separately. `laravel-inertia` sets -`ADAPTER_ROLE=app`: it is one application serving both tiers, so asking -"frontend?" then "backend?" has no honest answer for it on a two-question -flow. Asking shape first means that combination is never assembled to -begin with, rather than offered and then rejected at a summary screen. The -remaining questions follow from the answer — `web` never asks about a -database, because the web tier has no driver and `scaffold new` refuses -`--db` there (ADR-0020); the refusal becomes an absence instead of -something the user can walk into. - -Every option the wizard offers comes from `scaffold list`, grouped by the -role and kind columns that command already prints. `wizard_options` in -`lib/wizard.sh` hardcodes no adapter name, no service name, no tier. This -repository has twice shipped a second copy of a list that then drifted from -`adapter.env` — `scripts/adapter-matrix.sh` exists because of the first -drift, and the service-adapter branch broke that script by adding rows to -`scaffold list` without checking who parsed it. A wizard with its own list -would have been the third copy. Instead, a fifth service costs one -directory, and it appears in the wizard because the wizard asks `scaffold -list`, the same promise ADR-0019 makes for everything else that reads that -listing. - -Before generating anything, the wizard prints the `scaffold new` command -its answers mean — `wizard_command` renders it from `wizard_new_args`' -argv, the same argv `cmd_wizard` passes to `cmd_new` — and asks to confirm. -The second project from the same answers is scripted rather than clicked. +1. `scaffold` with no arguments opens the wizard only when stdin is a terminal (`[[ -t 0 ]]` in `main`); otherwise it prints usage and exits 1, so scripts and CI never hang. +2. Action: `new`; `update` and `publish` are offered only inside a scaffold project. A single option skips the screen. +3. For `new`: name, then shape (`web+api`, `app`, `api`, `web`), then one question per role and service. `web` asks no database (ADR-0020). +4. Options come from `scaffold list`, so a new adapter or service appears without editing the wizard. +5. It prints the equivalent `scaffold new` command and asks `[y/N]` before running it. No back navigation: Esc or Ctrl-C exits. ## Read this -- `lib/wizard.sh` — `wizard_actions`, `wizard_options`, `wizard_questions`, - `wizard_prompt_for`, `wizard_new_args`, `wizard_command`: pure, - string-in/string-out functions - that hold everything that could be wrong about the wizard's logic, tested - without a terminal at all. -- `lib/tui.sh` — the terminal machinery: hiding and restoring the cursor, - turning off terminal echo for the whole menu, draining the autorepeat - backlog, and `tui_name_is_usable`, which calls `project_name_is_usable` - (`lib/project.sh`) — the same rule `init_project` enforces for the flags - and `scaffold add`. -- `scaffold`'s `cmd_wizard` — name, shape, then one to four more screens - depending on the shape, from `wizard_questions`; - `SCAFFOLD_WIZARD_DRY_RUN=1` stops it just before `cmd_new` would run. -- `tests/wizard.bats` — the pure functions get direct tests; one pty test - drives the real screens with a scripted key sequence and asserts only on - the command line it prints at the end, not on frames. -- The wizard's known limit: no back navigation (Ctrl-C and re-run). The other - limit it once had — a `web+api` project building only one image — is gone; - see ADR-0022. +| File | Why | +| --- | --- | +| `lib/wizard.sh` | Pure functions: `wizard_actions`, `wizard_shapes`, `wizard_questions`, `wizard_options`, `wizard_new_args`, `wizard_command` | +| `lib/tui.sh` | Terminal screens; `tui_name_is_usable` calls `project_name_is_usable` from `lib/project.sh` | +| `scaffold` | `cmd_wizard`, `ask_shape_questions`; `SCAFFOLD_WIZARD_DRY_RUN=1` stops before anything changes | +| `tests/wizard.bats` | Direct tests of the pure functions, plus pty tests that drive the real screens | ## Delete test -Delete the `[ -t 0 ] ||` guard from `scaffold`'s `main` and nothing in -`mise run test-unit` catches it in the way you'd expect: `tests/wizard.bats` -deliberately drives the wizard through a real pty, and `tests/cli.bats`'s -"scaffold with no arguments prints usage and fails" is what actually -depends on the guard — bats gives that test's `run scaffold` a closed -stdin. With the guard gone, that closed stdin reaches `tui_prompt_name`'s -first `read`, which now hits EOF and exits 130 instead of looping on an -empty name forever (confirmed by removing the guard in a scratch copy) — -so the test still fails, on the wrong status and no `usage:` text, just -without needing CI's five-minute timeout to say so. +Delete the `[[ -t 0 ]] ||` guard in `scaffold`'s `main` and `tests/cli.bats` fails: +"scaffold with no arguments prints usage and fails". ## Try it ```bash -./scaffold +SCAFFOLD_WIZARD_DRY_RUN=1 ./scaffold # in a terminal; answer y at the end, nothing is generated ``` - -Run this in an actual terminal — it opens the wizard. Piped or redirected, -the same command prints usage and exits 1 instead, which is what -`tests/cli.bats` checks under bats. From 665948c6c43dce7e426dd21c52e21865198e40e0 Mon Sep 17 00:00:00 2001 From: iam-truongtrungnghia Date: Thu, 17 Sep 2026 16:26:58 +0700 Subject: [PATCH 2/2] docs: restore dropped facts and fix symptoms in the tour and README --- README.md | 2 ++ docs/runbook/ci-is-red.md | 2 +- docs/tour/03-ci.md | 4 +--- docs/tour/08-adapters.md | 1 + docs/tour/09-wizard.md | 2 +- 5 files changed, 6 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index c873ba6..da49bc8 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,8 @@ A database or cache is a directory under `services/`, not an adapter ([ADR-0019] | `--db` | `mysql`, `postgres`, `mongodb`, `none` | `mysql` with `--api` or `--app`, otherwise `none` ([ADR-0020](docs/decisions/0020-database-default-is-derived-from-requested-adapters.md)) | | `--cache` | `redis`, `none` | `none` | +No DynamoDB: `compose.yaml` ships with every release for a client to run (ADR-0014), and the only DynamoDB that fits a compose file is an emulator with no production counterpart. + ## Documentation - [Start here](docs/README.md) — what the toolbox is, map, commands, glossary, [reading path](docs/README.md#reading-path) diff --git a/docs/runbook/ci-is-red.md b/docs/runbook/ci-is-red.md index 59792e2..52b9238 100644 --- a/docs/runbook/ci-is-red.md +++ b/docs/runbook/ci-is-red.md @@ -23,7 +23,7 @@ means `scripts/check-provenance.sh` itself regressed, not that upstream drifted; its `check` job means upstream actually moved — see `docs/runbook/sync-with-upstream-immich.md`. -- The toolbox's own `unit` job runs long: a `test-unit` suite now runs an adapter generator. `tests/contract.bats` names it; move it to `test-integration` in `mise.toml`. +- `tests/contract.bats` fails with " runs an adapter generator to completion", or the toolbox's `unit` job hits its 5-minute timeout: that `test-unit` suite runs an adapter generator. Move it to `test-integration` in `mise.toml`. ## Before you call a fix done diff --git a/docs/tour/03-ci.md b/docs/tour/03-ci.md index 303e8ac..8c8c932 100644 --- a/docs/tour/03-ci.md +++ b/docs/tour/03-ci.md @@ -7,8 +7,6 @@ - `sync_ci_roots` rewrites that line from `config_roots` (ADR-0013), so the two cannot disagree. - The toolbox's own `.github/workflows/ci.yml` is not a call site: it runs `mise run ci-unit`, `mise run test-integration` and `zizmor`. -![Release flow](../diagrams/release-flow.svg) - ## Read this | File | Why | @@ -21,7 +19,7 @@ ## Delete test Delete `.github/workflows/ci.yml` from a generated project and no contract task runs on a pull request. -The security workflow still reports, and nothing turns red, so the gap is easy to miss. +The security and docs workflows still report, and nothing turns red, so the gap is easy to miss. ## Try it diff --git a/docs/tour/08-adapters.md b/docs/tour/08-adapters.md index f0cbb02..3920eed 100644 --- a/docs/tour/08-adapters.md +++ b/docs/tour/08-adapters.md @@ -17,6 +17,7 @@ | `adapters/nestjs/adapter.env` | Every field: name, role, tier, language, family, generator, post-generate, health paths | | `lib/adapter.sh` | `load_adapter`, `role_path`, `apply_adapter`: generate, overlay, post-generate, drivers, config root, lefthook fragment | | `lib/service.sh` | `apply_service_drivers`, `apply_service_dockerfile`, `write_env_lines` | +| `services/redis/drivers/nest.sh` | Installs the cache packages and writes `REDIS_URL`; registering `CacheModule` is left to the developer | | `services/shared/nest.sh` | The Prisma driver body: Prisma 6, `allowBuilds` for its install scripts (ADR-0017) | | `scripts/adapter-matrix.sh` | Tier A and B CI matrices from `scaffold list --adapters` (ADR-0012) | | `docs/runbook/add-an-adapter.md` | The steps to add one | diff --git a/docs/tour/09-wizard.md b/docs/tour/09-wizard.md index bae63b2..29df385 100644 --- a/docs/tour/09-wizard.md +++ b/docs/tour/09-wizard.md @@ -4,7 +4,7 @@ 1. `scaffold` with no arguments opens the wizard only when stdin is a terminal (`[[ -t 0 ]]` in `main`); otherwise it prints usage and exits 1, so scripts and CI never hang. 2. Action: `new`; `update` and `publish` are offered only inside a scaffold project. A single option skips the screen. -3. For `new`: name, then shape (`web+api`, `app`, `api`, `web`), then one question per role and service. `web` asks no database (ADR-0020). +3. For `new`: name, then shape (`web+api`, `app`, `api`, `web`), then one question per role and service. `web` asks only for its adapter: no database or cache (ADR-0020). 4. Options come from `scaffold list`, so a new adapter or service appears without editing the wizard. 5. It prints the equivalent `scaffold new` command and asks `[y/N]` before running it. No back navigation: Esc or Ctrl-C exits.