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
181 changes: 118 additions & 63 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,114 +1,169 @@
# scaffold

**Set up client projects with CI, containers, hooks, and releases in one command.**

scaffold is a bash toolbox for engineers who start client projects often. One command
generates a monorepo that is ready for its first pull request, and every project it makes is
built, checked and released the same way.

[![CI](https://github.com/ttncode/scaffold/actions/workflows/ci.yml/badge.svg)](https://github.com/ttncode/scaffold/actions/workflows/ci.yml)
[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)

Generate a client project that is ready for its first pull request: a monorepo
of apps, an optional database and cache in Docker Compose, git hooks, CI,
releases and a docs site, all wired together and committed.
<img src="docs/diagrams/scaffold-lifecycle.png" alt="A client project is generated, published, developed, checked by shared CI, released and run from install.sh. scaffold update brings later toolbox changes in, and every app answers one nine-task contract.">

scaffold is a bash toolbox for engineers who start client projects often and
want every one of them built, checked and released the same way. It is
pre-1.0; versions are git tags.
scaffold is pre-1.0, and its versions are git tags.

## What a generated project gets
---

- **Apps** from the adapters you pick: `--web`, `--api` or `--app`, each in its own directory with its own `mise.toml`.
- **One task contract.** Every app answers the same nine `mise` tasks (`install`, `format`, `lint`, `test`, `build`, `checklist`, …), so CI runs one command per app and never learns the language.
- **CI** as five thin workflows that call shared reusable workflows at `@v1` ([ADR-0005](docs/decisions/0005-share-ci-through-reusable-workflows.md)).
- **Guardrails:** lefthook runs prettier, gitleaks and commitlint locally; Renovate opens dependency bumps.
- **Releases:** Release Please from Conventional Commits, container images, and an `install.sh` that runs the released stack with Docker Compose.
- **A VitePress docs site** checked in CI like any app.
- **`.scaffold.toml`**, recording the toolbox commit that generated it, so `scaffold update` can bring later toolbox changes in.
## Commands

## Requirements
| What you're doing | Command | Key principle |
|---|---|---|
| Start a project | `scaffold new <name>` | One command, one commit |
| Add an app to it | `scaffold add <dir> --adapter <adapter>` | Staged, never committed for you |
| Bring in toolbox changes | `scaffold update [dir]` | Applied as a patch you review |
| Put it on GitHub | `scaffold publish [dir]` | Private by default, `main` protected |
| See what's available | `scaffold list` | The wizard reads the same list |
| Check adapters and services | `scaffold lint` | Every one meets the contract |

| Needed for | Requirement |
| --- | --- |
| Everything | `git` and [`mise`](https://mise.jdx.dev/getting-started.html); `mise install` supplies `jq`, `yq` and the rest |
| `laravel-api`, `laravel-inertia` | PHP 8.3 or later on the host; mise cannot pin it ([ADR-0016](docs/decisions/0016-php-is-not-pinned-through-mise.md)) |
| `scaffold new` | A GitHub owner for the project: `SCAFFOLD_GITHUB_OWNER`, else the signed-in `gh` user, else `git config github.user` |
| `scaffold publish` | [`gh`](https://cli.github.com/), signed in with `gh auth login` |
| CI in a generated project | A `.github` repository under the project's GitHub owner holding the reusable workflows ([ADR-0005](docs/decisions/0005-share-ci-through-reusable-workflows.md)) |
| Running a release | Docker |
Run `scaffold` with no arguments for a wizard that builds the command for you. Flags and
defaults are in [Commands](docs/README.md#commands).

---

## Quick start

You need `git` and [`mise`](https://mise.jdx.dev/getting-started.html). `mise install`
supplies the rest.

```sh
git clone https://github.com/ttncode/scaffold.git
cd scaffold
mise install
export PATH="$PWD:$PATH"
scaffold list
```

`scaffold list` prints one row per adapter and service. `scaffold` loads its pinned `jq` and `yq` itself. Call `scaffold` by its path or through `PATH`; a symlink to it does not work.

Generate a project outside the toolbox. `scaffold new` creates it where you run it:
Then generate a project somewhere outside the toolbox:

```sh
cd ~/playground
scaffold new demo-app --web nextjs --api nestjs --db postgres
```

The framework generators take several minutes. The result is a directory with one commit, `feat: scaffold project`.
The framework generators take a few minutes. You get a directory with one commit,
`feat: scaffold project`. The full run, from generation to a running release, is in
[Walk through a first project](docs/runbook/first-project-walkthrough.md).

Run `scaffold` with no arguments in a terminal for a wizard that builds the same command. The full end-to-end run, from generation to a running release, is [Walk through a first project](docs/runbook/first-project-walkthrough.md).
<details>
<summary><b>Other requirements</b></summary>

## Commands
| Needed for | Requirement |
|---|---|
| `laravel-api`, `laravel-inertia` | PHP 8.3 or later on the host ([ADR-0016](docs/decisions/0016-php-is-not-pinned-through-mise.md)) |
| `scaffold new` | A GitHub owner: `SCAFFOLD_GITHUB_OWNER`, the signed-in `gh` user, or `git config github.user` |
| `scaffold publish` | [`gh`](https://cli.github.com/), signed in |
| CI in a generated project | A `.github` repository under that owner, holding the reusable workflows ([ADR-0005](docs/decisions/0005-share-ci-through-reusable-workflows.md)) |
| Running a release | Docker |

| Command | Does |
| --- | --- |
| `scaffold new <name>` | Generates and commits a project |
| `scaffold add <dir> --adapter <adapter>` | Adds an app to an existing project and stages it |
| `scaffold update [dir]` | Applies toolbox changes since the project was generated; never commits |
| `scaffold publish [dir]` | Creates the GitHub repository (private by default) and protects `main` |
| `scaffold list` | Prints adapters with role and tier, services with kind |
| `scaffold lint` | Checks every adapter and service against the contract |
</details>

Flags, defaults, environment variables and the decision behind each command: [Commands](docs/README.md#commands).
---

## Adapters
## What a generated project gets

Each adapter's tier is `ADAPTER_TIER` in its `adapter.env` ([ADR-0012](docs/decisions/0012-tiered-adapter-support.md)).
| Piece | What It Does |
|---|---|
| **Apps** | One directory per adapter you pick, each with its own `mise.toml` |
| **Task contract** | Every app answers the same nine `mise` tasks, so CI runs one command per app |
| **CI** | Five thin workflows that call shared reusable workflows at `@v1` |
| **Guardrails** | lefthook runs prettier, gitleaks and commitlint; Renovate opens dependency bumps |
| **Releases** | Release Please, container images, and an `install.sh` that runs the stack with Docker Compose |
| **Docs site** | VitePress, checked in CI like any app |
| **`.scaffold.toml`** | Records the toolbox commit that generated it, for `scaffold update` |

| Adapter | Role | Tier |
| --- | --- | --- |
| `nextjs` | web | A |
| `nestjs` | api | A |
| `laravel-api` | api | A |
| `flask` | api | A |
| `laravel-inertia` | app | B |
---

| Tier | CI runs it | Guarantee |
| --- | --- | --- |
| A | every pull request, and nightly | stays green through every dependency bump |
| B | a pull request that changes `adapters/laravel-inertia/`, weekly, or on manual dispatch | verified regularly, not on every push |
| C | not automatically verified | none; no adapter is tier C today |
## Adapters and services

## Services
| Adapter | Flag | Tier |
|---|---|---|
| `nextjs` | `--web` | A |
| `nestjs` | `--api` | A |
| `laravel-api` | `--api` | A |
| `flask` | `--api` | A |
| `laravel-inertia` | `--app` | B |

A database or cache is a directory under `services/`, not an adapter ([ADR-0019](docs/decisions/0019-services-are-not-adapters.md)).
Tier A runs on every pull request and stays green through every dependency bump. Tier B is
verified weekly and whenever its adapter changes
([ADR-0012](docs/decisions/0012-tiered-adapter-support.md)).

| Flag | Services | Default |
| --- | --- | --- |
| `--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)) |
|---|---|---|
| `--db` | `mysql`, `postgres`, `mongodb`, `none` | `mysql` with `--api` or `--app`, otherwise `none` |
| `--cache` | `redis`, `none` | `none` |

There is no DynamoDB: every release ships a `compose.yaml` for the client to run ([ADR-0014](docs/decisions/0014-deployment-deferred-with-seams.md)), and the only DynamoDB that fits a compose file is an emulator with no production counterpart.
---

## How it works

- **Overlay, not presets.** Each adapter runs the framework's own generator, then lays
scaffold's files on top ([ADR-0003](docs/decisions/0003-adapter-overlay-instead-of-vendored-presets.md)).
- **Only what you picked.** A project gets `common/` and the adapters you chose, nothing
else ([ADR-0004](docs/decisions/0004-keep-the-toolbox-out-of-generated-projects.md)).
- **No build orchestrator.** `mise` tasks are the only task runner
([ADR-0001](docs/decisions/0001-use-mise-tasks-as-the-task-runner.md),
[ADR-0002](docs/decisions/0002-no-monorepo-build-orchestrator.md)).
- **The released stack must run.** A release has to start and serve, not just build
([ADR-0021](docs/decisions/0021-the-released-stack-must-run.md)).

---

## Project structure

| Path | Purpose |
|---|---|
| `scaffold` | The entry point, one function per command |
| `lib/` | The libraries it sources |
| `adapters/` | One directory per framework |
| `services/` | One directory per database or cache |
| `common/` | Copied into every new project |
| `tests/` | bats suites and fixtures |
| `docs/` | Tour, decisions, runbooks and diagrams |

---

## Why scaffold?

Client projects start the same way every time, and each hand-made setup drifts a little from
the last. scaffold turns the setup into one command and keeps it consistent: every app speaks
the same task contract, every project shares the same CI, and `scaffold update` carries later
fixes into projects that already exist.

---

## Documentation

- [Start here](docs/README.md): repository map, commands, glossary and a [reading path](docs/README.md#reading-path)
- [Start here](docs/README.md): repository map, commands and a reading path
- [Tour](docs/tour/): how the pieces fit, in 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](https://github.com/immich-app/immich), and where it drifted
- [Provenance](docs/PROVENANCE.md): what is copied from [immich](https://github.com/immich-app/immich)

---

## Contributing

Setup, test lanes, and how to add an adapter or a service are in
[CONTRIBUTING.md](CONTRIBUTING.md). Report vulnerabilities privately as described in
[SECURITY.md](SECURITY.md).

## Contributing and security
## Team

Setup, test lanes and how to add an adapter or a service: [CONTRIBUTING.md](CONTRIBUTING.md). Report vulnerabilities privately as described in [SECURITY.md](SECURITY.md).
| | Name | GitHub | Role |
|---|------|--------|------|
| <img src="https://github.com/ttncode.png?size=120" width="60" height="60" alt="Truong Trung Nghia"> | **Truong Trung Nghia** | [@ttncode](https://github.com/ttncode) | Creator |

## License

MIT, see [LICENSE](LICENSE). A generated project gets no license file: its terms belong to the engagement it was generated for.
MIT, see [LICENSE](LICENSE). A generated project gets no license file, because its terms
belong to the engagement it was generated for.
3 changes: 2 additions & 1 deletion docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ Measured on `scaffold new demo --api nestjs --web nextjs --db postgres`: 101 tra

## Diagrams

Each `.svg` is exported from the `.html` beside it.
Each `.svg` or `.png` is exported from the `.html` beside it. The README's hero is a `.png`, because GitHub shows README images without their web fonts.

| Diagram | Shows |
| --- | --- |
Expand All @@ -113,3 +113,4 @@ Each `.svg` is exported from the `.html` beside it.
| [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 |
| [scaffold-lifecycle](diagrams/scaffold-lifecycle.png) | A client project from generation to a running release (the README hero) |
60 changes: 60 additions & 0 deletions docs/diagrams/scaffold-lifecycle.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>A client project, end to end</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>*{margin:0;padding:0}body{background:#f5f5f5}svg{display:block}</style>
</head>
<body>
<svg viewBox="0 0 1200 284" width="1200" height="284" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="scaffold-lifecycle-title scaffold-lifecycle-desc">
<title id="scaffold-lifecycle-title">A client project, end to end</title>
<desc id="scaffold-lifecycle-desc">A client project is generated, published, developed, checked by shared CI, released and run from install.sh, with scaffold update bringing later toolbox changes and every app answering one nine-task contract.</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"/>
<line x1="192" y1="96" x2="240" y2="96" stroke="#4f5d75" stroke-width="1.2" marker-end="url(#arrow)"/>
<line x1="384" y1="96" x2="432" y2="96" stroke="#4f5d75" stroke-width="1.2" marker-end="url(#arrow)"/>
<line x1="576" y1="96" x2="624" y2="96" stroke="#4f5d75" stroke-width="1.2" marker-end="url(#arrow)"/>
<line x1="768" y1="96" x2="816" y2="96" stroke="#4f5d75" stroke-width="1.2" marker-end="url(#arrow)"/>
<line x1="960" y1="96" x2="1008" y2="96" stroke="#4f5d75" stroke-width="1.2" marker-end="url(#arrow)"/>
<path d="M120,64 V44 Q120,36 128,36 H496 Q504,36 504,44 V64" fill="none" stroke="#4f5d75" stroke-width="1" stroke-dasharray="5,4" marker-end="url(#arrow)"/>
<rect x="263" y="17" width="98" height="12" rx="2" fill="#f5f5f5"/>
<text x="312" y="26" fill="#7a8399" font-size="8" font-weight="400" font-family="'Geist Mono', ui-monospace, monospace" text-anchor="middle" letter-spacing="0.06em">SCAFFOLD UPDATE</text>
<rect x="48" y="64" width="144" height="64" rx="6" fill="#f5f5f5"/>
<rect x="48" y="64" width="144" height="64" rx="6" fill="#ffffff" stroke="#2d3142" stroke-width="1"/>
<text x="120" y="94" fill="#2d3142" font-size="12" font-weight="600" font-family="'Geist', system-ui, sans-serif" text-anchor="middle">Generate</text>
<text x="120" y="110" fill="#4f5d75" font-size="9" font-weight="400" font-family="'Geist Mono', ui-monospace, monospace" text-anchor="middle">scaffold new</text>
<rect x="240" y="64" width="144" height="64" rx="6" fill="#f5f5f5"/>
<rect x="240" y="64" width="144" height="64" rx="6" fill="#ffffff" stroke="#2d3142" stroke-width="1"/>
<text x="312" y="94" fill="#2d3142" font-size="12" font-weight="600" font-family="'Geist', system-ui, sans-serif" text-anchor="middle">Publish</text>
<text x="312" y="110" fill="#4f5d75" font-size="9" font-weight="400" font-family="'Geist Mono', ui-monospace, monospace" text-anchor="middle">scaffold publish</text>
<rect x="432" y="64" width="144" height="64" rx="6" fill="#f5f5f5"/>
<rect x="432" y="64" width="144" height="64" rx="6" fill="#ffffff" stroke="#2d3142" stroke-width="1"/>
<text x="504" y="94" fill="#2d3142" font-size="12" font-weight="600" font-family="'Geist', system-ui, sans-serif" text-anchor="middle">Develop</text>
<text x="504" y="110" fill="#4f5d75" font-size="9" font-weight="400" font-family="'Geist Mono', ui-monospace, monospace" text-anchor="middle">lefthook · mise</text>
<rect x="624" y="64" width="144" height="64" rx="6" fill="#f5f5f5"/>
<rect x="624" y="64" width="144" height="64" rx="6" fill="#ffffff" stroke="#2d3142" stroke-width="1"/>
<text x="696" y="94" fill="#2d3142" font-size="12" font-weight="600" font-family="'Geist', system-ui, sans-serif" text-anchor="middle">Check</text>
<text x="696" y="110" fill="#4f5d75" font-size="9" font-weight="400" font-family="'Geist Mono', ui-monospace, monospace" text-anchor="middle">reusable CI @v1</text>
<rect x="816" y="64" width="144" height="64" rx="6" fill="#f5f5f5"/>
<rect x="816" y="64" width="144" height="64" rx="6" fill="#ffffff" stroke="#2d3142" stroke-width="1"/>
<text x="888" y="94" fill="#2d3142" font-size="12" font-weight="600" font-family="'Geist', system-ui, sans-serif" text-anchor="middle">Release</text>
<text x="888" y="110" fill="#4f5d75" font-size="9" font-weight="400" font-family="'Geist Mono', ui-monospace, monospace" text-anchor="middle">release-please</text>
<rect x="1008" y="64" width="144" height="64" rx="6" fill="#f5f5f5"/>
<rect x="1008" y="64" width="144" height="64" rx="6" fill="#ffffff" stroke="#2d3142" stroke-width="1"/>
<text x="1080" y="94" fill="#2d3142" font-size="12" font-weight="600" font-family="'Geist', system-ui, sans-serif" text-anchor="middle">Run</text>
<text x="1080" y="110" fill="#4f5d75" font-size="9" font-weight="400" font-family="'Geist Mono', ui-monospace, monospace" text-anchor="middle">install.sh</text>
<rect x="48" y="160" width="1104" height="52" rx="8" fill="rgba(235,108,54,0.05)" stroke="#eb6c36" stroke-opacity="0.8" stroke-width="1" stroke-dasharray="4,4"/>
<text x="600" y="182" fill="#2d3142" font-size="12" font-weight="600" font-family="'Geist', system-ui, sans-serif" text-anchor="middle">One task contract: every app answers the same nine mise tasks</text>
<text x="600" y="200" fill="#eb6c36" font-size="9" font-weight="400" font-family="'Geist Mono', ui-monospace, monospace" text-anchor="middle">install · format · format-fix · lint · check · test · build · ci-unit · checklist — CI never learns the language</text>
<line x1="36" y1="236" x2="1164" y2="236" stroke="rgba(45,49,66,0.10)" stroke-width="0.8"/>
<text x="36" y="260" fill="#4f5d75" font-size="8" font-weight="400" font-family="'Geist Mono', ui-monospace, monospace" text-anchor="start" letter-spacing="0.14em">LEGEND</text>
<rect x="120" y="252" width="24" height="16" rx="3" fill="#ffffff" stroke="#2d3142" stroke-width="1"/>
<text x="152" y="264" fill="#4f5d75" font-size="9" font-weight="400" font-family="'Geist Mono', ui-monospace, monospace" text-anchor="start">stage of a client project</text>
<line x1="360" y1="260" x2="392" y2="260" stroke="#4f5d75" stroke-width="1" stroke-dasharray="5,4" marker-end="url(#arrow)"/>
<text x="404" y="264" fill="#4f5d75" font-size="9" font-weight="400" font-family="'Geist Mono', ui-monospace, monospace" text-anchor="start">later toolbox changes, applied but never committed</text>
<rect x="764" y="252" width="24" height="16" rx="3" fill="rgba(235,108,54,0.05)" stroke="#eb6c36" stroke-dasharray="4,4" stroke-width="1"/>
<text x="796" y="264" fill="#4f5d75" font-size="9" font-weight="400" font-family="'Geist Mono', ui-monospace, monospace" text-anchor="start">what every app implements</text>
</svg>
</body>
</html>
Binary file added docs/diagrams/scaffold-lifecycle.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading