Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ with no production counterpart in a self-hosted stack.

## Documentation

- [Start here](docs/README.md) — what the toolbox is, map, commands, glossary
- [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
Expand Down
107 changes: 107 additions & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
# scaffold documentation

## What scaffold is

- A bash toolbox: one `scaffold` script plus the libraries in `lib/`.
- It generates a client monorepo from `common/`, one adapter per application, and optional services.
- Every generated application implements the nine-task contract in `lib/contract.sh`.
- A generated project's CI calls this account's reusable workflows by the `@v1` tag and runs the contract per config root, so CI never learns the language.

![System context](diagrams/system-context.svg)

## Repository map

| Path | Role |
| --- | --- |
| `scaffold` | Entry point: `main()` dispatches to one `cmd_*` function per command |
| `lib/` | Libraries `scaffold` sources into one shell, listed below |
| `adapters/` | One directory per framework: `adapter.env`, overlay files, a `lefthook.fragment.yml` |
| `services/` | One directory per database or cache: `service.env`, compose fragments, `drivers/`; `services/shared` is not a service but the driver bodies those `drivers/` source |
| `common/` | Copied into every new project, then rendered |
| `docs/` | This documentation: tour, decisions, runbooks, provenance, diagrams |
| `tests/` | bats suites and their fixtures |
| `scripts/` | CI helpers: adapter matrix, provenance check, deploy check |
| `.github/` | This toolbox's own workflows and pull request template |
| `.vscode/` | Editor settings and recommended extensions |
| `lib/log.sh` | `log`, `warn`, `die`, `step`, `run_quietly` |
| `lib/contract.sh` | The nine contract task names |
| `lib/lint.sh` | `scaffold lint`: adapters and services against the contract |
| `lib/adapter.sh` | Load an adapter, run its generator, overlay its files, wire its services |
| `lib/service.sh` | Compose services, host ports, service drivers |
| `lib/pnpm.sh` | The pnpm workspace and its supply-chain policy |
| `lib/manifest.sh` | `config_roots` and image targets, recorded once and derived everywhere else |
| `lib/project.sh` | Project skeleton, `.scaffold.toml`, the first commit |
| `lib/update.sh` | `scaffold update`: the patch from the recorded commit to this one |
| `lib/publish.sh` | `scaffold publish`: GitHub repository and settings |
| `lib/wizard.sh` | What the wizard asks and the command it builds |
| `lib/tui.sh` | The wizard's terminal screens |

![Code layers](diagrams/code-layers.svg)

## How `scaffold new` works

![scaffold new](diagrams/scaffold-new.svg)

- `scaffold:parse_new_flags` and `scaffold:resolve_service_selection` read `--web`, `--api`, `--app`, `--db`, `--cache` and refuse a service with no `--api` or `--app`.
- `lib/project.sh:init_project` copies `common/`, renders `you/` and `@PROJECT_NAME@`, and arms the cleanup trap; `init_scaffold_manifest` writes `.scaffold.toml`.
- `lib/service.sh:assemble_compose` and `assemble_example_env` merge each service into the compose lanes and `example.env`.
- `scaffold:install_adapters` runs, per app, `add_app_service`, `lib/adapter.sh:apply_adapter` and `record_scaffold_app`.
- `scaffold:settle_workspace_shape` joins one pnpm workspace when every app is TypeScript, keeps apps standalone otherwise, then runs `register_image_target`.
- `lib/project.sh:finalize_project` syncs the CI roots, runs `mise lock` and commits `feat: scaffold project`.

## What a generated project contains

![Generated project](diagrams/generated-project.svg)

Measured on `scaffold new demo --api nestjs --web nextjs --db postgres`: 101 tracked files, one commit.

## Commands

| Command | Flags | Does | Decision |
| --- | --- | --- | --- |
| `scaffold` | none | On a terminal, a wizard for `new`, `update` or `publish`; elsewhere, prints usage and exits 1 | [09-wizard](tour/09-wizard.md) |
| `scaffold new <name>` | `--web`, `--api`, `--app <adapter>`; `--db`, `--cache <service>` | Generates and commits a project | [ADR-0020](decisions/0020-database-default-is-derived-from-requested-adapters.md) |
| `scaffold add <dir>` | `--adapter <adapter>` | Adds an app to an existing project and stages it | [ADR-0018](decisions/0018-add-does-not-recompute-the-typescript-workspace.md) |
| `scaffold update [dir]` | `--dry-run` | Applies toolbox changes since the commit in `.scaffold.toml`; never commits | [ADR-0023](decisions/0023-a-project-records-what-generated-it.md) |
| `scaffold publish [dir]` | `--public`, `--private` (default), `--no-protect`, `--dry-run` | Creates the GitHub repository and applies its settings | [ADR-0024](decisions/0024-publishing-a-project-is-part-of-generating-it.md) |
| `scaffold list` | `--adapters`, `--services` | Prints adapters with role and tier, services with kind | [ADR-0012](decisions/0012-tiered-adapter-support.md) |
| `scaffold lint` | none | Checks every adapter and service against the contract | [ADR-0011](decisions/0011-task-contract-names-follow-immich.md) |
| `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 |

## Glossary

| Term | Meaning | Where |
| --- | --- | --- |
| adapter | A framework's generator command plus the files overlaid on its output | `adapters/nestjs` |
| family | `ADAPTER_FAMILY`: which service driver an adapter uses (`laravel`, `nest`, `next`, `flask`) | `adapters/nestjs/adapter.env` |
| tier | `ADAPTER_TIER`: A, B or C, how often CI verifies the adapter | [ADR-0012](decisions/0012-tiered-adapter-support.md) |
| service | A database or cache: `SERVICE_KIND`, a pinned image, compose fragments | `services/postgres` |
| driver | Per-family script that wires a service into an app's code, Dockerfile and compose entry | `services/postgres/drivers` |
| config root | A directory with its own `mise.toml` that CI runs the contract in, listed in `config_roots` | [ADR-0013](decisions/0013-config-roots-is-the-manifest.md) |
| task contract | The nine tasks every app implements: `install` … `checklist` | `lib/contract.sh` |
| overlay | Copying an adapter's files over the generator's output | [ADR-0003](decisions/0003-adapter-overlay-instead-of-vendored-presets.md) |
| splice anchor | The `# @SERVICE_SETUP@` line in an adapter Dockerfile, replaced by the drivers' setup block | `adapters/nestjs/Dockerfile` |
| manifest | `config_roots` in the project's root `mise.toml`: recorded once, the CI matrix is derived from it | `lib/manifest.sh`, [ADR-0013](decisions/0013-config-roots-is-the-manifest.md) |
| `.scaffold.toml` | What generated the project: the toolbox commit and the adapter behind each app, read by `scaffold update` | [ADR-0023](decisions/0023-a-project-records-what-generated-it.md) |

## Reading path

| When | Read |
| --- | --- |
| Day one | This page, then [01-toolchain](tour/01-toolchain.md) through [03-ci](tour/03-ci.md) |
| First week | [04-guardrails](tour/04-guardrails.md) through [09-wizard](tour/09-wizard.md); ADR-0001, ADR-0003, ADR-0011 |
| On demand | The [runbook](runbook/) that names the situation |

## Diagrams

Each `.svg` is exported from the `.html` beside it.

| Diagram | Shows |
| --- | --- |
| [system-context](diagrams/system-context.svg) | Toolbox, generated project, reusable workflows, images, releases, client host |
| [code-layers](diagrams/code-layers.svg) | `scaffold`, `lib/*.sh` and the directories they read |
| [scaffold-new](diagrams/scaffold-new.svg) | The `scaffold new` call order |
| [generated-project](diagrams/generated-project.svg) | The tree `scaffold new` produces |
| [release-flow](diagrams/release-flow.svg) | Continuous builds and cut releases of a generated project |
| [scaffold-update](diagrams/scaffold-update.svg) | How `scaffold update` patches an existing project |
68 changes: 68 additions & 0 deletions docs/diagrams/code-layers.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>How the toolbox code is layered</title>
<link href="https://fonts.googleapis.com/css2?family=Instrument+Serif:ital@0;1&family=Geist:wght@400;500;600&family=Geist+Mono:wght@400;500;600&display=swap" rel="stylesheet">
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: 'Geist', system-ui, sans-serif; background: #f5f5f5; color: #2d3142; min-height: 100vh; display: flex; align-items: center; justify-content: center; padding: 3rem 2rem; }
.frame { max-width: 1000px; width: 100%; }
.eyebrow { font-family: 'Geist Mono', ui-monospace, monospace; font-size: 0.66rem; font-weight: 500; letter-spacing: 0.18em; text-transform: uppercase; color: #4f5d75; margin-bottom: 0.5rem; }
h1 { font-family: 'Instrument Serif', serif; font-size: clamp(1.5rem, 2.4vw + 0.75rem, 2rem); font-weight: 400; letter-spacing: -0.02em; line-height: 1.15; margin-bottom: 1.5rem; }
svg { width: 100%; display: block; }
</style>
</head>
<body>
<div class="frame">
<p class="eyebrow">Layer stack · scaffold</p>
<h1>How the toolbox code is layered</h1>
<svg viewBox="0 0 1000 560" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="code-layers-title code-layers-desc">
<title id="code-layers-title">How the toolbox code is layered</title>
<desc id="code-layers-desc">Layer stack showing the scaffold entry point over the wizard, the project lifecycle libraries, the app and service assembly libraries, shared primitives, and the adapters, services and common directories they read.</desc>
<defs>
<marker id="arrow" markerWidth="8" markerHeight="6" refX="7" refY="3" orient="auto"><polygon points="0 0, 8 3, 0 6" fill="#4f5d75"/></marker>
<marker id="arrow-accent" markerWidth="8" markerHeight="6" refX="7" refY="3" orient="auto"><polygon points="0 0, 8 3, 0 6" fill="#eb6c36"/></marker>
</defs>
<rect width="100%" height="100%" fill="#f5f5f5"/>
<text x="24" y="64" fill="#4f5d75" font-size="10" font-family="'Geist Mono', monospace" letter-spacing="0.14em">CALLS</text>
<line x1="56" y1="80" x2="56" y2="464" stroke="rgba(45,49,66,0.30)" stroke-width="1"/>
<polygon points="52,464 60,464 56,472" fill="#4f5d75"/>
<text x="24" y="492" fill="#4f5d75" font-size="10" font-family="'Geist Mono', monospace" letter-spacing="0.14em">READS</text>
<rect x="128" y="80" width="848" height="64" fill="#ffffff"/>
<line x1="128" y1="144" x2="976" y2="144" stroke="rgba(45,49,66,0.12)" stroke-width="1"/>
<text x="144" y="117" fill="#4f5d75" font-size="10" font-family="'Geist Mono', monospace" letter-spacing="0.08em">L1</text>
<text x="184" y="118" fill="#2d3142" font-size="16" font-weight="600" font-family="'Geist', sans-serif">Entry point</text>
<text x="960" y="117" fill="#4f5d75" font-size="10" font-family="'Geist Mono', monospace" text-anchor="end">scaffold · main() → cmd_new, cmd_add, cmd_update, cmd_publish, cmd_list, cmd_lint</text>
<rect x="128" y="144" width="848" height="64" fill="#f5f5f5"/>
<line x1="128" y1="208" x2="976" y2="208" stroke="rgba(45,49,66,0.12)" stroke-width="1"/>
<text x="144" y="181" fill="#4f5d75" font-size="10" font-family="'Geist Mono', monospace" letter-spacing="0.08em">L2</text>
<text x="184" y="182" fill="#2d3142" font-size="16" font-weight="600" font-family="'Geist', sans-serif">Wizard</text>
<text x="960" y="181" fill="#4f5d75" font-size="10" font-family="'Geist Mono', monospace" text-anchor="end">lib/wizard.sh · lib/tui.sh (no arguments, on a terminal)</text>
<rect x="128" y="208" width="848" height="64" fill="#ffffff"/>
<line x1="128" y1="272" x2="976" y2="272" stroke="rgba(45,49,66,0.12)" stroke-width="1"/>
<text x="144" y="245" fill="#4f5d75" font-size="10" font-family="'Geist Mono', monospace" letter-spacing="0.08em">L3</text>
<text x="184" y="246" fill="#2d3142" font-size="16" font-weight="600" font-family="'Geist', sans-serif">Project lifecycle</text>
<text x="960" y="245" fill="#4f5d75" font-size="10" font-family="'Geist Mono', monospace" text-anchor="end">lib/project.sh · lib/update.sh · lib/publish.sh · lib/lint.sh</text>
<rect x="128" y="272" width="848" height="64" fill="rgba(235,108,54,0.08)"/>
<rect x="128" y="272" width="848" height="64" fill="none" stroke="#eb6c36" stroke-width="1"/>
<text x="144" y="309" fill="#eb6c36" font-size="10" font-family="'Geist Mono', monospace" letter-spacing="0.08em">L4</text>
<text x="184" y="310" fill="#2d3142" font-size="16" font-weight="600" font-family="'Geist', sans-serif">Apps and services</text>
<text x="960" y="309" fill="#eb6c36" font-size="10" font-family="'Geist Mono', monospace" text-anchor="end">lib/adapter.sh · lib/service.sh · lib/pnpm.sh · lib/manifest.sh</text>
<rect x="128" y="336" width="848" height="64" fill="#ffffff"/>
<line x1="128" y1="400" x2="976" y2="400" stroke="rgba(45,49,66,0.12)" stroke-width="1"/>
<text x="144" y="373" fill="#4f5d75" font-size="10" font-family="'Geist Mono', monospace" letter-spacing="0.08em">L5</text>
<text x="184" y="374" fill="#2d3142" font-size="16" font-weight="600" font-family="'Geist', sans-serif">Shared primitives</text>
<text x="960" y="373" fill="#4f5d75" font-size="10" font-family="'Geist Mono', monospace" text-anchor="end">lib/log.sh · lib/contract.sh</text>
<rect x="128" y="400" width="848" height="64" fill="#ececec"/>
<line x1="128" y1="464" x2="976" y2="464" stroke="rgba(45,49,66,0.12)" stroke-width="1"/>
<text x="144" y="437" fill="#4f5d75" font-size="10" font-family="'Geist Mono', monospace" letter-spacing="0.08em">L6</text>
<text x="184" y="438" fill="#2d3142" font-size="16" font-weight="600" font-family="'Geist', sans-serif">Data it reads</text>
<text x="960" y="437" fill="#4f5d75" font-size="10" font-family="'Geist Mono', monospace" text-anchor="end">adapters/&lt;name&gt;/ · services/&lt;name&gt;/ · common/</text>
<line x1="128" y1="80" x2="976" y2="80" stroke="rgba(45,49,66,0.22)" stroke-width="1"/>
<text x="128" y="516" fill="#2d3142" font-size="14" font-style="italic" font-family="'Instrument Serif', serif">Every lib/*.sh is sourced into one shell; L4 turns an adapter or a service into files in the project.</text>
</svg>
</div>
</body>
</html>
47 changes: 47 additions & 0 deletions docs/diagrams/code-layers.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading