zazzy
CLI

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:end

The 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-starter root + 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 XX), and every deselected feature's packages removed from both dependencies and devDependencies.
  • .env.example and docker-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.json is deleted, along with the repo-development tooling that reads it: scripts/lint-markers.mjs, apps/web/eslint-rules/, and the boundary-rule block in eslint.config.mjs (delimited by a distinct @cli-strip grammar 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.

On this page