The prune model
How the generator turns the kitchen-sink app into just your app.
Pruning is what makes one reference app serve every feature combination. The
generator starts from the full kitchen-sink apps/web and removes everything
your selection doesn't include — it never assembles from fragments. This means
the code you get is the exact code that's tested in the reference app, minus the
parts you left out.
features.json — the single source of truth
Every feature is declared once, in features.json:
{
"features": {
"billing": {
"title": "Billing (Stripe)",
"description": "Subscriptions, trials, customer portal, webhooks",
"core": false, // core features are non-deselectable
"requires": ["orgs", "email", "jobs"],
"paths": [ // deleted outright when pruned
"apps/web/src/features/billing",
"apps/web/src/app/api/webhooks/stripe",
"apps/web/src/config/plans.ts"
],
"dependencies": ["stripe"], // package.json deps removed
"scripts": ["stripe:sync"], // package.json scripts removed
"env": ["STRIPE_SECRET_KEY", "..."], // .env.example blocks removed
"composeServices": [] // docker-compose services removed
}
// … one entry per feature
}
}The CLI, the marker lint, and the ESLint feature-boundary rule all read this one file, so the graph and the code can't drift apart.
Step 1 — resolve the feature DAG
Your requested features are expanded into the full selected set: core features
are always added, and each selection's requires closure is pulled in. The
complement — every declared feature not selected — is the deselected set that
pruning removes. Unknown feature names and dependency cycles are hard errors. See
the feature picker for the full graph.
Step 2 — delete paths
Each deselected feature's paths are deleted outright. Paths can overlap between
features, so a path already removed is a harmless no-op — deletion is
order-independent. A manifest path that points outside the template is a loud
error, not a silent skip.
Step 3 — strip @feature markers
Code that crosses feature boundaries in the reference app is wrapped in @feature
marker comments so it can be conditionally kept or removed:
// @feature:billing:start
import { billingRouter } from "@/features/billing/router";
// @feature:billing:endThe marker parser is comment-style agnostic — it recognizes the same tokens in
//, {/* */}, #, and <!-- --> comments (TypeScript, JSX, env/YAML,
Markdown/HTML).
- A region is removed — marker lines included — when any feature it names is
deselected. Multi-key markers (
@feature:billing,usage:start) strip if any listed feature is off. - A region that's kept loses only its marker lines.
Either way, generated code contains no markers at all — you'd never know they were there.
Step 4 — structured rewrites
Some files need edits, not deletion:
package.json— the two-package workspace (saas-starterroot +apps/web) collapses into one standalone manifest: renamed to your project, workspace config stripped, root tooling (prettier,knip,tsx) merged in, delegating scripts flattened (pnpm --filter web exec X→X), and every deselected feature's packages removed from bothdependenciesanddevDependencies..env.exampleanddocker-compose.yml— deselected features' variable blocks and services are removed. This is manifest-keyed belt-and-braces over the marker stripping (both the vars and services are fully marker-wrapped in the reference app).features.jsonis deleted, along with the repo-development tooling that reads it:scripts/lint-markers.mjs,apps/web/eslint-rules/, and the boundary-rule block ineslint.config.mjs(delimited by a distinct@cli-stripgrammar so it prunes on tooling removal, not a feature deselection).
Fail loud, never silently skip
Every prune step throws on the unexpected — an unknown deselected feature, a path outside the template, unbalanced markers. A silently mis-pruned app would be a broken app; the engine refuses to produce one (GUIDELINES §5).
Why this design
Because the CLI reads apps/web and features.json directly and only ever
subtracts, the output for an all-features selection is byte-identical to the
reference app (modulo the intended deltas — the standalone package.json, the
generated README, the removed marker lines). That equivalence is pinned by a CI
test, so a template change that would break generation fails this repo's CI the
same day.