19 KiB
Stanza
Shadcn-style CLI for assembling modular full-stack TS monorepos. Currently
ships init, add, remove, list, search against five slots:
framework, styling, db, orm, auth. swap + update verbs and
additional categories (api, ai, ui, payments, plus add-ons for testing,
tooling, deploy, email) are planned — the manifest already reserves the
fields they'll need (modules[slot].version, regions). See
REGISTRY.md for the module roadmap and TODO.md for
active work.
Three things differentiate stanza from other scaffolders:
- Post-init
stanza addworks on existing projects (manifest-driven, peer-aware). - Generated code is vendored verbatim — no
@stanza/runtimedep. - Open registry spec — third parties can host their own static JSON.
Layout
apps/cli/—@stanza/cli, node entrypoint atsrc/bin.ts(run via tsx in dev, tsdown-built todist/bin.mjsfor publish)apps/web/—@stanza/web, TanStack Start visual builder (Vite-native, no Vinxi)packages/registry/— shared schema, slot/peer resolver, Zod manifest validatorpackages/codemods/— ts-morph helpers (idempotent + reversible)packages/create-stanza/—pnpm create stanzashimscripts/— repo-root maintainer helpers (not shipped):registry-build.tsemits static CDN JSONregistry/modules/<slot>-<id>/— first-party modules:module.ts+templates/(modules don't ship codemod code; see Architecture rules)
In a generated project, auth, db, and orm modules install into their own internal workspace package at packages/<dir>/ (named @<manifest.name>/<dir>); the app consumes them via workspace:* deps. framework and styling stay app-scoped because they wire the app shell itself. The mapping lives in the canonical SLOTS array — auth → "auth", both db and orm → "db" (they share a single packages/db/ package). The SLOT_PACKAGE_DIR lookup and slotLabel helper are both derived from this array.
Commands
pnpm --filter @stanza/cli dev -- <verb>— run CLI directly viatsx watch ./src/bin.ts; no build step. Ortsx apps/cli/src/bin.ts <verb>for a one-shot runpnpm --filter @stanza/cli build— build the publishable CLI via tsdown (compiles to ESM JS atapps/cli/dist/, externalizes npm deps, inlines workspace packages). Same forcreate-stanzapnpm registry:build— regeneratedist/registry/{index,modules/*}.json. Uses bun for maintainer convenience (the script body is portable;tsx scripts/registry-build.tsworks too)pnpm --filter @stanza/web dev— TanStack Start dev server.prebuildinvokesapps/web/scripts/prepare-registry.shwhich copies the builtdist/registry/intoapps/web/public/registry/. Sincedist/registry/is gitignored, the script builds it first when it's missing (e.g. on Vercel's clean checkout) — preferringbunlocally and falling back topnpm exec tsxon node-only deploy targets. The deployed site ships this directory as static assets, so CLI and web consume the same JSON.public/registry/is also registered as a NitroserverAssetsdir (vite.config.ts), so its contents are embedded into the server bundle at build time — SSR reads it viauseStorage("assets:registry")(not the CDN), which is why it works on Vercel wherepublic/is absent from the function fspnpm lint/pnpm lint:fix— Oxlint across the whole repo (config:.oxlintrc.json)pnpm fmt/pnpm fmt:check— oxfmt across the whole repo (config:.oxfmtrc.json)pnpm test/pnpm check-types— fan out to every workspace via turbocd apps/web && node_modules/.bin/vite build— generatessrc/routeTree.gen.ts(required before first typecheck)- E2E smoke: seed
$TMPDIR/xwithstanza.json+apps/web/package.json, thentsx apps/cli/src/bin.ts add <slot> <module> pnpm changeset— create a new changeset describing what changed (run after a substantive PR)pnpm release— build CLI/create-stanza and publish to npm (only runs in CI; locally usepnpm packto inspect tarballs)
Toolchain invariants
- Node-only at runtime. The CLI source uses node APIs and is dev-run via
tsx; the published binary is plain ESM JS (#!/usr/bin/env node). The only place bun appears is the shebang on root maintainer scripts (scripts/*.ts) for our own convenience — those scripts don't use anyBun.*APIs and run fine under tsx/node - Build pipeline:
tsdowncompiles each publishable package to ESM JS indist/. External npm deps are not bundled (users install them via the normal dep chain); workspace deps are inlined (we don't publish@stanza/registryand@stanza/codemodsseparately). Transitive runtime deps (ts-morph,zod) MUST be declared as directdependenciesof the publishable package or tsdown will inline them into the bundle - Per-workspace
dist/paths:main/typesin the sourcepackage.jsonstill point at./src/so other workspaces resolve.tsdirectly during dev. The published tarball overrides viapublishConfigto point at./dist/<x>.mjs/.d.mts - Publishing: only
@stanza/cliandcreate-stanzaship to npm.@stanza/codemods+@stanza/registryare markedprivate: true(inlined into the CLI bundle by tsdown);@stanza/webis private (deployed as a Vercel site, not an npm package); theregistry/modules/*packages are also private (they're registry data, not npm packages). Releases go through Changesets: drop a markdown file viapnpm changeset, push to main → the release workflow opens a "Version Packages" PR; merging that PR triggers the same workflow to runpnpm release(build CLI + create-stanza, thenchangeset publishto npm). RequiresNPM_TOKENin repo secrets; provenance attestations are emitted viaid-token: write+NPM_CONFIG_PROVENANCE=true - pnpm 10 +
node-linker: isolated— each workspace MUST declare@types/nodein its own devDeps and settypes: ["node"]in tsconfig (auto-discovery doesn't reach into the isolatednode_modules/@types) - TypeScript 6 —
allowImportingTsExtensions: true+noEmit: trueis set intsconfig.json; the CLI/create-stanza emit JS via tsdown, the registry/codemods packages stay source-only and never emit tsconfig.jsonexcludes**/templates/**globally — template files target user projects, not this repo- Zod 4: use
z.partialRecord(K, V)for finite-key partial records (z.record(z.enum, V)requires exhaustive keys) - TanStack Start:
verbatimModuleSyntax: falseinapps/web/tsconfig.json(server bundles leak otherwise);tanstackStart()MUST precedereact()in vite plugins
Architecture rules
- Modules are vendored: their templates land in the user's repo verbatim; no
@stanza/runtimedep - Template distribution:
scripts/registry-build.tsinlines each template file's contents into the per-module JSON'stemplates[].contentfield so HTTP-loaded manifests are self-contained. The runner preferstpl.contentand only reads fromregistry/modules/<x>/templates/when it's absent (local dev). New templates need no build wiring — they're picked up automatically - Module logos: drop
logo.svg(theme-agnostic) orlogo-light.svg+logo-dark.svg(theme pair) in a module's directory. The registry build auto-detects and inlines asmod.logo(string or{ light, dark }) — module authors don't declare anything inmodule.ts. First-party logos come from svgl.app. The web builder renders inline viadangerouslySetInnerHTML - Registry is data; CLI is the runtime: the per-module JSON ships templates (text), deps (strings), env (strings), scripts (strings), logos (SVG markup), and codemod invocations (
{ id, args }). It does NOT ship codemod code. The catalog of generic codemods lives in packages/codemods/src/builtins/ and is exposed via the@stanza/codemods/builtinssubpath export — each codemod is parameterized byTArgsand reusable across modules (wrap-root-layoutserves both Clerk and any future provider-style auth/state library). The catalog is statically imported into the CLI binary at build time, so distribution shape (single binary, pnpm-isolated, npm-hoisted,npx,bun --compile) doesn't matter — implementations always travel with the runtime - Adding a generic codemod: drop
<id>.tsunder packages/codemods/src/builtins/, default-export aCodemod<TArgs>, and register it in packages/codemods/src/builtins/index.ts. Codemods that bake in module-specific identifiers don't belong — factor them into args - Third-party codemods: deferred. Third-party HTTP-loaded modules can use the existing catalog codemods (pass
{ id, args }from their manifest) but can't add new ones until we land a proper sandboxed-execution + signing model - apps/web previews are server-rendered: Shiki runs in
apps/web/src/server/highlighter.ts(module-singleton, kept warm). The builder loader (createServerFninapps/web/src/server/builder-state.ts) computes selected files from URL search params, pre-renders Shiki HTML for each, and shipsRecord<path, { light, dark }>to the client.shikimust NEVER be imported from a client component — verified byvite buildfollowed bygrep shiki .output/public/assets/*.js(should return nothing) - Slot taxonomy is currently
framework | styling | db | orm | auth. Adding a slot is now a two-line edit: append the id toKNOWN_SLOTS(theas consttuple Zod needs for literal inference) and append aSlotentry toSLOTSwith{ id, label, description, packageDir }. Decide upfront whether the new slot extracts (data layer, observability) or wires the app shell (router, global CSS) and setpackageDiraccordingly - Slots vs. add-ons: the 5 slots are one-choice, peer-constraint-bearing. Add-ons are multi-choice, no outbound peer constraints (
testing,tooling,deploy,email,monorepo) — several coexist in a category. The add-on schema is live (landed with thetestingmodules): aModuleis a discriminated union onkind("slot"default, carryingslot; or"addon", carryingcategory);isAddon()narrows it andmoduleGroup()returns the slot-or-category key. Add-on categories live inKNOWN_ADDONS/ADDON_CATEGORIES(parallel toKNOWN_SLOTS/SLOTS), deliberately disjoint fromKNOWN_SLOTSso the resolver — which iteratesKNOWN_SLOTSfor peer checks andactivePeerIds— never lets add-ons participate in others' dispatch. An add-on can declare a one-waypeers(e.g.{ framework: [...] }) and framework-varying adapters;resolveAdapterhandles it unchanged. The manifest stores them inaddons: Partial<Record<AddonCategoryId, StanzaAddonRecord[]>>(.default({})keeps oldstanza.jsonvalid). Region ownership keys onmodule.id, so two add-ons writing the same file at disjoint dot-paths (vitestscripts.testvs playwrightscripts.test:e2e) never conflict. Future single-choice categories (api,ai,ui,payments) are still slots, added viaKNOWN_SLOTS/SLOTS - Adapter keys encode peer choices (e.g.,
next+drizzle); the resolver picks the most specific match - Module-level install fields:
dependencies,devDependencies,env,scripts, andconsumesPackagesare declared at the module level when shared across adapters. Adapter-level fields override per-key (envmerges byname). This avoids re-declaring the samedependenciesandenvblock in every adapter of a multi-framework module (cf. Better Auth — 6 adapters, butbetter-authdep and the two env vars are declared once) - Slot-package extraction:
auth/db/ormmodules install intopackages/<dir>/workspace packages (named@<manifest.name>/<dir>); templatesscope: "package", deps/devDeps/scripts route there, and the app gets aworkspace:*dep wired by the runner.framework/stylingstay app-scoped (theirpackageDirentry isnull). The bootstrap files (the package'spackage.json+tsconfig.jsonand the host app's workspace dep) are system-owned — not tracked inregions.stanza remove's sweep deletes them when no claims remain underpackages/<dir>/ - Generated projects don't share a tsconfig base: every
apps/*/tsconfig.jsonandpackages/*/tsconfig.jsonis self-contained. The framework module ships the app's tsconfig; the runner'sensureSlotPackagewrites a matching self-contained config when bootstrapping a slot package.tsconfig.jsonlives in the stanza repo only; do not emit it in generated trees - Cross-package wiring: when a module's source code imports from another internal package (e.g.
better-auth'sauth.tsreadsdbfrom the orm package), declareconsumesPackages: ["db"]at the module level. The runner adds@<project>/db: workspace:*to this module's own package. Module-level (not adapter-level) because the source code is shared infrastructure — adapters vary in templates/codemods, not in what they import. Templates can reference other packages via{{<dir>PackageName}}substitution (e.g.{{dbPackageName}}→@my-app/db) — substitution runs over both template bodies (whentemplate: true) and codemod-invocationargsstring values - Region ownership in
stanza.jsonis the source of truth forstanza remove. Two modules claiming the same region is a hard error (RegionConflictError). Note: regions are not yet sufficient forswap— that verb additionally needs old-adapter-region → new-adapter-region mapping; design pending - Codemod catalog ids are part of the public contract: once stanza is published, renaming a builtin codemod id breaks every third-party manifest that references it. Treat ids as you would npm package names — additions are free, renames require a deprecation cycle, removals require a manifest schema version bump
- Module
versionfield: every module manifest declares aversionstring, pinned intostanza.jsonat install time. The upcomingswap/updateverbs read it; today it's stored for forward compatibility. Bump it on schema-affecting changes (template additions, dep upgrades) per semver - Declarative beats imperative: prefer
templates/dependencies/env/scriptsover imperative codemods; the runner applies declarative fields generically - Reserved manifest fields:
modules[slot].versionandregionsare written today but only fully consumed by the upcomingswap/updateverbs — do not drop them - Web app hosts the canonical registry:
pnpm --filter @stanza/web buildrunsprebuildwhich builds the registry (if missing) and copies it intoapps/web/public/registry/. The deployed Vercel output therefore shipsindex.json+modules/*.jsonat the same origin. The published CLI'sDEFAULT_REGISTRY_URLpoints at this same URL, so users get a working CLI with zero configuration. Self-hosters and CI override viaSTANZA_REGISTRY(URL or filesystem path)
Module authoring
module.tsexportsdefineModule({...})with at least one adapter (usematch: {}for "default / no peer"). Slot modules carryslot; add-on modules setkind: "addon"+category(one ofKNOWN_ADDONS) and live inregistry/modules/<category>-<id>/(e.g.testing-vitest/). Add-ons are app/repo-scoped (nopackageDir); give co-existing add-ons in a category disjointscripts/file paths so their region claims don't collide- Templates go in
templates/, referenced bysrcpath.scopedecides wheredestlands:"app"(default) →manifest.appDir(e.g.apps/web/)"repo"→ repo root"package"→packages/<packageDir>/wherepackageDiris the active module's slot entry inSLOTS— only valid for slots with a non-null entry (auth,db,orm)
- For
auth/db/ormmodules, default toscope: "package"for everything that can live inside the package boundary. Reach forscope: "app"only when a framework convention forces the file to sit at the app root (e.g. Next'smiddleware.ts, App Router API routes). App-scoped files should be thin shims thatimportfrom{{packageName}}; settemplate: trueand the runner runs mustache substitution - For cross-package imports (e.g.
authreadingdb), declareconsumesPackages: ["<dir>"]at the module level (not on each adapter). The runner adds the workspace dep on first apply. Write the import as{{<dir>PackageName}}(e.g.import { db } from "{{dbPackageName}}";) - Hoist shared install fields (
dependencies,devDependencies,env,scripts) to the module level when they don't vary across adapters. Adapter-level values still work for true variations and override per-key. Example: Better Auth'sdependencies: { "better-auth": "^1.6.11" }and the two env vars are declared once at module level; only the per-(framework, orm) templates and codemods sit in the adapter blocks - Framework modules MUST NOT ship a
package.json.tpl— it collides withaddPackageDependency. Let the runner merge deps into the host's package.json. The same rule applies to package-scoped modules: don't ship apackages/<dir>/package.jsontemplate — the runner'sensureSlotPackagebootstraps one and merges deps in - To invoke an imperative codemod from a module, add
codemods: [{ id: "<catalog-id>", args: {...} }]to the adapter. Modules never ship code — if no catalog entry matches the need, design a new generic codemod with the right args. String values inargsgo through mustache substitution (same context as template bodies) - For codemods that operate on files inside a slot's package (e.g. extending the orm's schema barrel from the auth module), pass
base: "package:<dir>"to the catalog codemod —re-exportandappend-to-fileboth honor it
Gotchas
- Clack spinners (
p.spinner()) don't auto-stop on promise rejection — wrap awaits in try/catch and callspinner.stop(...failed)in the catch - LSP diagnostics on template files (e.g. "Cannot find module 'react'") are noise; the global exclude works for
tscbut tsserver still indexes them - The dev registry is found by walking up from
import.meta.urllooking forregistry/modules/;STANZA_REGISTRYenv var overrides (FS path or HTTP URL) pnpm installsays "Already up to date" when only workspacepackage.jsonfiles changed without lockfile-affecting bumps — usepnpm install --forceto re-link- Oxlint has most but not all ESLint plugin rules —
prevent-abbreviations,react/jsx-uses-react, etc. don't exist. Checknode_modules/oxlint/configuration_schema.jsonif a rule name fails - oxfmt auto-loads
.gitignoreand.prettierignore; we use.oxfmtrc.json'signorePatternsinstead so we don't masquerade as a Prettier project