Chassis gives you NestJS-style controller ergonomics on plain Express 5 — in a handful of small files you can actually read. Pick a database, an auth provider and an optional Next.js front end; the scaffolder ships only what you chose.
Chassis gives you NestJS-style controller ergonomics on plain Express 5 — in a handful of small files you can actually read. Zero configuration required: the server boots standalone, and every integration switches on only when you add its environment variable. Scaffold with a preset or pick à la carte — a database (Mongo, Postgres, or SQLite, ORM included), an auth provider (Auth0, JWT, or Clerk), an optional Next.js front end, Sentry, an MCP server, and x402 payments — and the CLI ships only what you chose.
Every path is non-interactive: --yes and --bare never prompt, and the CLI
skips prompts automatically whenever stdin isn't a TTY. One command produces a
project that already typechecks, lints and tests green.
llms.txt — the project, its
conventions and its docs index, in one fetch
llms-full.txt — every
documentation page, concatenated
AGENTS.md — the conventions to follow when writing code in a
Chassis project, and the definition of done
Generated projects carry AGENTS.md, CLAUDE.md, llms.txt and an
add-resource skill, so whichever agent opens one writes code that matches the
rest of the codebase rather than fighting it.
Pick-your-stack scaffolder — presets or à la carte: database + ORM (Mongo/Postgres/SQLite), auth (Auth0/JWT/Clerk), a Next.js front end, Sentry, MCP, x402 — the CLI prunes everything else so package.json carries only what you chose
Opt-in integrations — every module enables by env var, never required
Payment-gated routes — @paidRoute('get', '/report', '$0.01') via the x402 protocol (opt-in)
Optional Next.js front end — --web adds an App Router app and makes the project an npm-workspaces monorepo (apps/api + apps/web); the auth provider you picked is wired on both sides
MCP server — expose your API to AI agents as MCP tools (npm run mcp, opt-in)
Health endpoints — /healthz (liveness) and /readyz (readiness, checks enabled integrations)
Graceful shutdown — drains connections and closes integrations on SIGTERM/SIGINT
Vitest + supertest — fast tests against the pure app factory, no server or DB needed
DB-aware code generator — npm run gen user scaffolds a controller + test wired to your ORM (Drizzle or Mongoose)
Production Docker — multi-stage build, non-root user, plus docker-compose with your database for dev
CI + Renovate — GitHub Actions verify pipeline and automated dependency updates
AI-agent ready — ships AGENTS.md, CLAUDE.md, llms.txt, and an add-resource skill so agents write code that matches the conventions (see below)
Most people scaffolding a backend today have an AI agent in the loop. Chassis is built so that agent-written code reads like hand-written code — because the framework gives agents rails and a verifiable finish line:
AGENTS.md + CLAUDE.md ship in every project — Claude Code, Cursor, Copilot, and Codex pick them up automatically and follow the conventions (thin controllers, resHandler responses, throw AppError, config in one place).
One obvious place for everything means agent output converges on the same shape a maintainer would write — that's what keeps it readable.
npm run verify (strict TypeScript + ESLint + tests) is a deterministic quality gate agents iterate against until green.
.claude/skills/add-resource turns "add a books resource" into one consistent, checklisted operation.
llms.txt gives doc-fetching tools a compact map of the conventions.
Nothing to install — it's all in the scaffold. See AGENTS.md.
Read them at dvd90.github.io/chassis —
searchable, one page. The source lives in docs/ and the site
is generated from it, so the two can never disagree:
This creates src/controllers/Todo.controller.ts (a CRUD skeleton), a
matching test, and wires the export into src/controllers/index.ts.
The dev server picks it up on save:
That's the entire wiring. Notice what you did not write: no router
file, no try/catch (thrown errors — including Mongoose failures — land
in the central error handler), no manual 400 handling (the validate
middleware answers with a structured issue list).
Controllers are testable without a running server. For DB-backed tests,
either point MONGODB_URI at a throwaway database, use
mongodb-memory-server,
or keep controller logic thin and unit-test your services instead.
A pattern that needs no database — mount a throwaway controller through
the factory's test seam (extraRoutables) — is demonstrated in
src/__tests__/app.test.ts.
Chassis ships one database at a time — you pick it when scaffolding
(--db mongo|postgres|sqlite, or a preset). The ORM comes with the choice:
Mongoose for Mongo, Drizzle for Postgres and SQLite. Like every
integration it stays off until its env var is set, so the app boots
standalone in tests.
# .env
DATABASE_URL=postgres://postgres:postgres@localhost:5432/chassis # postgres# or
SQLITE_PATH=chassis.db # sqlite
src/db/<engine>/ holds the Drizzle client (db) and your schema.ts. Add
tables there, or let the generator do it:
bash
npm run gen Widget
This appends a widgets table to schema.ts and generates a controller
wired to Drizzle (db.select().from(widgets), insert().values().returning(),
…). Then create and run the migration:
/readyz pings the database so orchestrators only route traffic once it's
reachable. With Docker enabled, docker compose up -d starts a matching
Postgres.
Mongoose has no shared db handle — models register themselves on import.
npm run gen Widget writes src/db/mongo/widget.model.ts and a controller
using WidgetModel. No migration step.
The three are parallel integrations. To swap after scaffolding, follow the
module contract: add src/integrations/<db>.ts, an env var +
features.<db> flag in src/config, and a guarded init in
src/integrations/index.ts. Prisma isn't shipped (its codegen fights the
everything-installed template), but drops in the same way if you prefer it.
@protectedRoute marks an endpoint as requiring authentication. Who
verifies the request is pluggable: Chassis ships an Auth0 integration,
and swapping in any other provider is one function call.
Until a provider is configured, protected routes answer 501 with a
message explaining exactly what to set — the route is never silently
open.
Missing/invalid tokens get a structured 401 from the central error
handler. To read the token's claims in a handler, use the auth
property that express-oauth2-jwt-bearer sets on the request
(req.auth?.payload.sub, etc.).
Pick --auth jwt for self-issued Bearer tokens with no third party. Set a
secret:
bash
# .env
JWT_SECRET=a-long-random-string
Unlike the hosted providers, this one has no user directory behind it — so
Chassis ships the missing half: src/controllers/Auth.controller.ts mints
tokens that src/integrations/jwt.ts then verifies (HS256, via
jose).
Passwords are hashed with scrypt from Node's node:crypto — a
memory-hard KDF in the standard library, so there is no argon2/bcrypt
dependency and no native build (src/utils/password.ts).
src/db/users.ts resolves the store the same way integrations resolve
themselves — by feature flag, at call time:
Configured database
Store
SQLite / Postgres
Drizzle users table (src/db/<db>/users.ts)
MongoDB
Mongoose User model (src/db/mongo/users.ts)
none
in-memory, seeded from AUTH_DEV_EMAIL / AUTH_DEV_PASSWORD
Pick a database and the store follows it — no code change. The in-memory
fallback exists so --auth jwt --db none still boots and logs in during
development; it is process-local and forgets everything on restart. Add a
database before putting local JWT in front of real users.
With Drizzle, generate the migration for the users table before first
use:
Tokens are access tokens only, valid for one hour — there is no refresh
rotation. Clients re-authenticate when a token expires; add a refresh
endpoint alongside /auth/login if you need sessions to outlive that
without a password prompt.
@protectedRoute delegates to whatever middleware chain was registered
with setAuthProvider() (see src/core/auth.ts). To use your own IdP,
API keys, sessions — anything — register a chain at boot.
Then register it in src/integrations/index.ts behind a feature flag,
exactly like the built-ins (the pattern is documented in
Modules). Every @protectedRoute in the app now uses
your provider — controllers don't change at all.
If you scaffolded the Next.js front end (--web), the same three
providers have a matching web half in apps/web/auth/providers/. All of
them do one job — hand apiFetch a bearer token for the API — behind one
function, getAccessToken():
Provider
Web package
How the token is obtained
jwt
(none)
sign-in form → /api/session → API /auth/login → httpOnly cookie
auth0
@auth0/nextjs-auth0
hosted login → SDK session → getAccessToken()
clerk
@clerk/nextjs
<SignIn/> → auth().getToken()
Two notes that cost people hours:
Auth0 needs an audience.apps/web/auth/providers/auth0.shared.ts
passes authorizationParameters.audience. Without it Auth0 returns an
ID token rather than an API access token, and every @protectedRoute
call is rejected. It must match AUTH0_AUDIENCE on the API exactly.
The local-JWT token never touches client JavaScript. The form posts
to a Next route handler, which calls the API server-side and returns the
token only as an httpOnly cookie — so an injected script cannot read it.
Route decorators run at class-definition time, before any integration
has initialized. requireAuth() therefore resolves the provider per
request, not at decoration time. If nothing registered a provider, the
request is answered with 501 Not Implemented and a hint — a loud,
obvious failure instead of an accidentally-public endpoint.
--web adds a Next.js 15 App Router front end and turns the project into
an npm-workspaces monorepo. Without the flag nothing changes: the
project stays a single package, exactly as it is today.
bash
npm create chassis my-app ----preset fullstack # Postgres + JWT + web
npm create chassis my-app ----web--auth clerk # à la carte
my-app/
package.json # workspaces root: dev / build / verify / format
apps/api/ # the Chassis backend — src, scripts, Dockerfile
apps/web/ # the Next.js app
docs/ README.md docker-compose.yml .github/
npm run dev at the root starts both (API on :8000, web on :3000).
npm run verify runs each workspace's own verify. npm run gen <Name>
still scaffolds a resource — it forwards to apps/api.
Nothing forces you into npm workspaces beyond the root package.json:
there is no monorepo tool, no build graph, no plugin versions to keep in
step. Two apps do not need a task orchestrator; if the repo grows to the
point where one earns its keep, adding it later is a package.json edit.
It runs server-side only — it reads the session to attach
Authorization: Bearer <token>, and a token must never be exposed to the
browser. Call it from server components and route handlers. Point
API_URL at the backend (.env.local, defaults to http://localhost:8000).
Every provider lives in apps/web/auth/providers/ and exports the same
five things. The app never names one directly — it imports from
auth/active.ts, which is a single re-export line:
That line (and its twin in active-middleware.ts, kept separate so the
middleware bundle stays free of React and next/headers) is what
create-chassis rewrites for --auth. Changing provider afterwards means
editing it, installing the new SDK, and swapping the env vars.
The generated project keeps only the provider you chose. In the Chassis
repo itself all four coexist, and
apps/web/auth/providers/conformance.ts typechecks each one against the
AuthModule contract — so a provider that drifts out of shape fails CI
here rather than in someone's generated project. That file is
template-only and never ships.
docker-compose.yml builds the API from apps/api. The Dockerfile there
uses npm install rather than npm ci, because the workspace lockfile
lives at the repo root and is not part of that build context. The web app
has no compose service — deploy it wherever you deploy Next.
Chassis itself — an agent calls it to create a project
If you want an agent to scaffold Chassis projects for you, that's the second
one; point your client at npx -y chassis-mcp and it gains
list_chassis_options, create_chassis_project and chassis_conventions.
The rest of this page is about the first.
The MCP module (--mcp) exposes your API to AI agents as
Model Context Protocol tools. It runs as a
separate stdio process — the way agent clients (Claude Desktop, etc.)
launch tool servers — not mounted in the HTTP app. That keeps the ESM-only MCP
SDK out of the compiled dist build; it runs via tsx.
The x402 module (--x402) gates routes behind an HTTP 402 payment using the
x402 protocol — pay-per-request in stablecoins,
settled by a facilitator. It mirrors auth: a @paidRoute decorator plus a
pluggable gate, so payment verification never lives in your controllers.
A caller without a valid payment gets 402 Payment Required with the payment
details; the x402 client library on their side handles the settlement and
retries. The price string ('$0.01') is USDC.
@paidRoute uses requirePayment(price), which resolves the registered gate
per request (see src/core/payments.ts) — so decorators can run before
the integration boots. The x402 integration registers a gate backed by
x402-express's paymentMiddleware, keyed to each route's path and price.
Swap in any other provider by calling setPaymentGate() from your own
integration; @paidRoute doesn't care who settles the payment.
On SIGTERM/SIGINT the server stops accepting connections, drains
in-flight requests, disconnects integrations, then exits (with a 10s
force-exit failsafe). This is exactly what rolling deploys on
Kubernetes, ECS, Fly, Railway, Render, etc. need — no special handling
required on your side.
Chassis is deliberately small: the "framework" is ~7 files in src/core
that you can read in one sitting. This page explains how a request flows
through it and why the pieces are shaped the way they are.
Class definition time — @route('get', '/:id') runs and pushes a
RouteDefinition (method, path, handler name, middlewares) onto a
symbol-keyed array on the controller's prototype. No router exists yet.
Boot time — createApp() iterates every class exported from
src/controllers, instantiates it, and calls registerToRouter(app).
That builds a fresh express.Router, binds each handler to the
instance, and mounts it at the controller's base path.
Because decorators only record metadata, ordering problems disappear:
@protectedRoute can be evaluated long before the auth integration
initializes — requireAuth() resolves the provider per request.
Expected failures — return them: req.resHandler.notFound('...'),
or throw new AppError(ERROR_CODES.CONFLICT, '...') from anywhere
(controller, service, model hook). The central handler maps it.
Unexpected failures — just let them throw. Express 5 catches
rejected promises; the central handler logs the stack (with callId),
reports to Sentry when enabled, and returns a sanitized 500 (stack
traces are only included outside production).
createApp() performs no I/O — integrations boot separately in
server.ts. Tests build the app synchronously and hit it with supertest;
no port, no database, no mocks of the framework itself. The
extraRoutables option lets tests (or plugins) mount throwaway
controllers to exercise any code path — see src/__tests__/app.test.ts.
src/config is the only file that touches process.env. The zod schema
validates at import time and the process exits with a readable message on
bad config. Feature flags (config.features.*) are derived from which
variables are present — that's the entire opt-in mechanism.
Every integration in Chassis follows the same contract, which is what
makes the template safe to strip down (the create-chassis CLI does
exactly that) and easy to extend.
One file in src/integrations/<name>.ts exporting init<Name>()
(plus optional close<Name>() / readiness helpers).
One feature flag in src/config derived from its env vars:
features.<name> = Boolean(env.SOME_VAR).
Single-line hooks where it touches shared code — registration in
src/integrations/index.ts, optional lines in the health controller,
error handler, .env.example, and docker-compose.yml.
Nothing else in the codebase may import an integration directly
(the health controller's readiness check is the one sanctioned
exception).
Two concerns are pick-exactly-one groups rather than independent toggles,
because their options are mutually exclusive:
Database — none / mongo / postgres / sqlite. The ORM follows the
choice (Mongoose or Drizzle). See Database.
Auth — none / auth0 / jwt / clerk, all sharing the
setAuthProvider() seam. See Authentication.
Each variant also has a web half (web/auth/providers/<name>) behind
the equivalent front-end seam — one re-export line in web/auth/active.ts.
Local JWT additionally ships the piece the hosted providers don't need: a
register/login controller and a user store that follows the database
choice. See Web front end.
Mechanically a group variant is just a module in the chassis:<name>
namespace: choosing Postgres declines mongo and sqlite, which prune
exactly like a declined toggle. The template ships every variant installed
together; the CLI keeps only the one you pick.
@protectedRoute (auth) and @paidRoute (x402) live in src/core and are
always present — with no provider configured they answer 501, never open.
The create-chassis CLI uses these markers to prune declined modules:
it deletes the module's file, drops every marked line, and removes the
dependency from package.json — leaving a project that compiles and
tests green as if the module never existed.
Rule of thumb: keep every marked construct on a single line.
Create src/integrations/redis.ts with initRedis() /
closeRedis().
Add REDIS_URL to the schema in src/config and
features.redis: Boolean(env.REDIS_URL).
Register it in src/integrations/index.ts:
if (config.features.redis) await initRedis();
Optionally add a /readyz check and an .env.example entry.
Swap rather than add: to replace Auth0 with any other IdP, call
setAuthProvider([...yourMiddleware]) from your own integration —
@protectedRoute doesn't know or care who verifies the token.
npm create chassis my-api # interactive — pick a preset
npm create chassis my-api ----preset lite # SQLite + JWT, no infrastructure
npm create chassis my-api ----yes# Recommended API preset, no prompts
npm create chassis my-api ----bare# nothing — standalone build
npm create chassis my-api ----preset fullstack # + Next.js front end
npm create chassis my-api ----db postgres --auth jwt --mcp# à la carte
The interactive flow asks for one preset, then (only if you pick
Custom) walks you through each choice. Every choice is also a flag, so
nothing is interactive-only. Run npm create chassis -- --help for the
generated list of presets, choices, and toggles.
Downloads the template (GitHub tarball; requires tar on PATH)
Resolves your selection (preset → prompts → flags, flags win) and shows
a confirmation summary
Prunes everything not chosen — deletes each declined module's files
and directories, strips every line carrying its chassis:<name> marker,
and removes its dependencies, devDependencies, and npm scripts from
package.json. A dependency shared by a kept module (e.g. drizzle-orm,
used by both Postgres and SQLite) is never removed.
Renames the package, resets the version, git-inits (optional), installs
(optional)
Makes the project the user's own — rewrites the LICENSE copyright and
removes the maintainer-only docs/maintainers.md
The generated project passes npm run verify and npm run build, and its package.json carries
only the dependencies the chosen modules need — no dead weight.
For developing the CLI itself, scaffold from a local checkout instead of the
network:
npm run gen user # → UserController at /users
npm run gen BlogPost # → BlogPostController at /blog-posts
Creates src/controllers/<Name>.controller.ts, a smoke test in
src/__tests__/, and appends the export to src/controllers/index.ts. It is
database-aware: it detects the installed DB and generates matching
persistence code —
Postgres / SQLite — a controller wired to Drizzle, plus a table
appended to src/db/<engine>/schema.ts. Generate the migration with
npx drizzle-kit generate --config src/db/<engine>/drizzle.config.ts.
Mongo — a Mongoose model in src/db/mongo/ and a controller using it.
typecheck + lint + test — what CI and the pre-commit hook run
npm run gen <Name>
Generate a controller (above)
npm run mcp
Start the MCP server over stdio (only with the MCP module)
With the Next.js front end (--web) the project is a workspaces monorepo,
so these live in apps/api and the root gains orchestration instead:
npm run dev starts both apps, npm run build and npm run verify fan out
to every workspace, and npm run gen <Name> forwards to apps/api. See
Web front end.
All configuration is environment variables, declared and validated with
zod in src/config/index.ts — the only file that reads
process.env. On invalid config the process prints each problem and
exits before binding a port.
.env files are loaded automatically in development (via dotenv);
in production, inject real environment variables instead.
class UserController extends Routable {
constructor() {
super('/users'); // base path (default '/')
}
}
registerToRouter(app, basePath?) — builds an express.Router from
the class's decorated methods and mounts it. Called for you by the app
factory for every class exported from src/controllers/index.ts.
Like @route, but the configured auth provider runs first; your
middlewares run after it. Answers 501 when no provider is
configured. See Authentication.
new AppError(code, message?, details?) — details is included in
the response body when provided.
ERROR_CODES entries carry { id, statusCode, statusReason }; id
is a stable application-level code clients can switch on. Add your own
domain codes to src/core/errors.ts.
The pluggable-auth seam. Integrations call setAuthProvider([...]) at
boot; @protectedRoute runs the registered chain per request via
requireAuth(). See src/core/auth.ts.
extraRoutables — extra Routable instances mounted after the
auto-registered controllers and before the error handlers. Built for
tests; see src/__tests__/app.test.ts.
Instructions for AI coding agents (Claude Code, Cursor, Copilot, Codex, …)
working in a Chassis project. Follow these conventions and the code you
produce will match what a human maintainer would write — that's the whole
point of this file.
Golden rule: finish every change by running npm run verify and only
stop when it passes. It runs typecheck + lint + tests — the same gate CI
uses. Green means done; anything else means keep going.
Endpoints are controller methods. A controller is a class extending
Routable; each method is decorated with @route(method, path).
Exported controllers auto-mount. Everything exported from
src/controllers/index.ts is registered at boot. There are no router
files to edit.
Responses go through req.resHandler — ok, created, notFound,
validation, … Never call res.status().json() directly.
Errors are thrown, not handled.throw new AppError(ERROR_CODES.X, msg)
from anywhere; a central handler maps it. Do not write try/catch in
controllers — Express 5 forwards rejected promises for you.
Config is one validated file.src/config is the only place that
reads process.env.
Add an endpoint: run npm run gen <Name> to scaffold a controller +
test, then edit the generated methods. It's DB-aware — it wires the
controller to the installed ORM (Drizzle or Mongoose) and, for Drizzle,
appends a table to src/db/<engine>/schema.ts. (Or copy an existing
*.controller.ts and export it from src/controllers/index.ts.)
Protect or charge for a route with @protectedRoute(...) (auth) or
@paidRoute(method, path, price) (x402 payments). Both answer 501 until
their module is configured — never silently open or free.
Validate input with validate({ body|query|params: zodSchema }) in
the route's middleware array. Define the schema next to the controller
or in src/schemas.
Return via req.resHandler and match the semantic helper to the
outcome (created for 201, noContent for 204, notFound, conflict,
…). See docs/reference/core-api.md for the full list.
Signal errors by throwing AppError with the right ERROR_CODES
entry. Add new domain codes to src/core/errors.ts rather than
inventing ad-hoc status numbers.
Add a config value by extending the zod schema in src/config,
exposing it on the config object, and documenting it in .env.example.
Keep controllers thin. Push real logic into src/services/* and
data access into src/models/*. Controllers translate HTTP ↔ domain.
❌ Don't hand-write route registration or express.Router() — the
decorators and the app factory do it.
❌ Don't edit src/core/** to build a feature. That's the framework;
features live in controllers, services, models, schemas, integrations.
❌ Don't add a dependency when a listed one already covers it (zod for
validation, winston for logging, your installed ORM — Drizzle or Mongoose —
for data access).
❌ Don't disable lint rules or loosen tsconfig to make verify pass —
fix the actual issue.
npm run verify passes — typecheck, lint, and tests all green. When a
web app is present, verify covers it too; adding an auth provider there
means adding it to web/auth/providers/conformance.ts.
npm run build passes. In a monorepo that also runs next build, which
is the only thing that exercises front-end middleware and bundling.
No new process.env reads, no res.status().json(), no try/catch in
controllers, no edits to src/core for feature work.
cd cli
# sanity check first — scaffold from the local template and verify it
CHASSIS_TEMPLATE=.. node index.mjs /tmp/chassis-smoke --bare
cd /tmp/chassis-smoke && npm install && npm run verify && cd -
# and once with the front end, which exercises the monorepo restructure
CHASSIS_TEMPLATE=.. node index.mjs /tmp/chassis-full --preset fullstack
cd /tmp/chassis-full && npm install && npm run verify && cd -
npm login
npm publish # unscoped packages are public by default
Verify the flow end-to-end from a clean directory:
Install the Renovate GitHub app on
the repo — renovate.json is already configured (non-major updates are
grouped into a single PR). This is the single most important step for a
template: templates rot silently, and automated dependency PRs +
green CI are what keep it trustworthy for strangers.
Alternative: GitHub's own Dependabot (Settings → Code security).
web/ is a self-contained package with its own package.json, lockfile,
tsconfig and eslint config — it is not an npm workspace of the
template, because a project scaffolded without --web must stay a plain
single package. Install it separately:
bash
npm install && npm ci --prefix web
Root npm run verify then delegates to it, and CI installs both. That
delegation is what keeps web/ from rotting: it is typechecked, linted and
tested on every PR even though no generated project ever ships it in this
shape.
Its tests are deliberately narrow — apiFetch, the /api/session route
handler and the local-JWT middleware. Those hold the parts that would fail
silently and expensively: whether the bearer token is attached, and whether
the session token stays in an httpOnly cookie instead of reaching client
JavaScript. Rendering is left to next build in the scaffold suite.
Two files there exist only for this repo and never reach a generated
project — web/auth/types.ts and web/auth/providers/conformance.ts. The
latter typechecks all four auth providers against the same contract, which
is the only place that check can happen: a generated project keeps exactly
one provider, so a provider that drifts out of shape would otherwise break
only for whoever picked it. Adding an auth provider means adding it
there too.
site/ builds the published docs from the markdown in this repo — one
self-contained dist/index.html with sidebar nav, client-side search and
highlighted code, no server required.
bash
npm ci --prefix site
npm run build --prefix site # then open site/dist/index.html
npm run check --prefix site # build without writing (what CI runs)
It holds no prose of its own: every page is rendered from docs/**,
README.md and AGENTS.md, so the site cannot drift from the repo. Adding
a doc means adding it to site/pages.mjs — the build fails on any markdown
file under docs/ that no section lists, so a page can never be published
without a way to reach it.
Like cli/, site/ is template-only: it is in the CLI's ignore list and
never reaches a generated project, and a scaffold test asserts that. Its
marked dependency therefore never lands in anyone's package.json.
Pushing to master publishes it via .github/workflows/docs.yml. That
needs Settings → Pages → Source: GitHub Actions enabled once.
The stylesheet in site/theme.mjs duplicates the tokens from
web/app/globals.css on purpose — the web app is a template that gets
copied into user projects, so it must not import from this build.
mcp-server/ is published separately as
chassis-mcp: an MCP server that
scaffolds Chassis projects, so an agent can create one without knowing the
CLI's flags.
bash
npm ci --prefix mcp-server
npm test --prefix mcp-server # drives it over stdio, like a real client
It imports the option catalog from create-chassis rather than restating
it, so it cannot advertise a stack the CLI does not support — and its tests run
against the CLI in this checkout via CHASSIS_CLI, so a change to
cli/modules.mjs fails here rather than in someone's agent.
Like cli/ and site/, it is template-only and never reaches a generated
project. Note the directory is mcp-server, not mcp: the CLI's ignore list
matches by basename, so a top-level mcp/ would also have excluded
src/mcp and silently broken the --mcp module.
Releasing it follows the same shape as the CLI — bump mcp-server/package.json,
npm publish from that directory, tag mcp-v<version>.
Keep the module contract intact: anything specific to an
optional integration stays on single lines tagged
// chassis:<name>, or the CLI's pruning breaks.
Two marker rules the test suite enforces, both learned the hard way:
The marker must be last on the line. A marker after an opening
brace (sqliteTable('users', { // chassis:jwt) gets moved onto its own
line by Prettier, and pruning then deletes the body but keeps the
declaration. Put the construct in its own file and mark the import.
A module name must not appear as chassis:<name> anywhere else in the
template. Pruning drops any line containing the string, so naming a
module routes would delete Symbol('chassis:routes') from
src/core/routable.ts.
After touching integrations or markers, run the scaffold suite:
The fast layers take seconds and cover the catalog, the CLI's failure
modes, and every prune permutation. The build layer is the only one that
runs next build, so it is the only thing that catches a front-end
bundling break in a generated project — run it before releasing the CLI.
A pruned project must pass verifyandbuild with zero warnings —
that's the contract the CLI advertises.
Coverage is the thing to watch, not depth. --auth jwt --db none shipped
broken (an import left unused once every database line was pruned) purely
because no build case ever compiled that combination; any layer would have
caught it. When you add a module, add the combination that empties out
something else.
Express, Mongoose, ESLint, and the Sentry SDK occasionally ship breaking
majors. For each: read the migration guide, upgrade in a branch, run
npm run verify plus both CLI smoke tests (--yes and --bare), and
check the boot log still shows a clean standalone start.