- Delete `packages/internal` workspace package — `registry-build.ts` and `module-new.ts` move to top-level `scripts/` (no `package.json`, not shipped); `pnpm registry:build` and `pnpm module:new` scripts updated in root `package.json` and `turbo.json`; CLAUDE.md / TODO.md references updated - Relocate CLI internals from `apps/cli/src/` → `apps/cli/src/lib/` — `codemod-runner.ts`, `manifest.ts`, `region-tracker.ts`, `registry-loader.ts`, `wizard.ts`; all intra-CLI imports updated to `@/lib/*` path alias via tsconfig `paths`; add `@types/bun` to CLI devDependencies - `stanza remove` now dispatches `revertCodemods()` as step 1 (before declarative cleanup) — re-loads the module adapter from the registry, calls imperative reverts while framework files are still intact, then sweeps remaining region claims; fixes `wrap-root-layout` / `re-export` regions previously left as "needs manual cleanup" despite shipping working reverts
12 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 slots (api/ai/ui/payments/email/tooling/testing/deploy) 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, Bun entrypoint atsrc/bin.tsapps/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 JSON,module-new.tsscaffolds a new moduleregistry/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 is hardcoded in SLOT_PACKAGE_DIR — auth → "auth", both db and orm → "db" (they share a single packages/db/ package).
Commands
bun apps/cli/src/bin.ts <verb>— run CLI directly without buildpnpm registry:build(orbun scripts/registry-build.ts) — regeneratedist/registry/{index,modules/*}.jsonpnpm module:new [slot] [id]— scaffold a new module underregistry/modules/pnpm --filter @stanza/web dev— TanStack Start dev server;predevhook auto-copiesdist/registry/→apps/web/public/registry/so the same-domain registry path resolvespnpm 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)cd packages/<x> && node_modules/.bin/vitest run— unit tests (per workspace; no rootvitestbinary)cd <pkg> && node_modules/.bin/tsc --noEmit— typecheck (per workspace)cd 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, thenbun .../bin.ts add <slot> <module>
Toolchain invariants
- 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; build withbun build, nottsc 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 - Bun runs the CLI directly from
.ts; don't add a TS compile step
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(seeKNOWN_SLOTS); adding a slot is a manifest schema bump — updateKNOWN_SLOTS,slotOrder, the Zod manifest schema, andSLOT_PACKAGE_DIRtogether (decide whether the new slot extracts into its own package or stays app-scoped) - Adapter keys encode peer choices (e.g.,
next+drizzle); the resolver picks the most specific match - 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 (theirSLOT_PACKAGE_DIRentry 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 an adapter's source code imports from another internal package (e.g.
better-auth'sauth.tsreadsdbfrom the orm package), declarepeerPackages: ["db"]on the adapter. The runner adds@<project>/db: workspace:*to the current package'spackage.json. 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 forremove/future-swap; two modules claiming the same region is a hard error (RegionConflictError) - 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
Module authoring
module.tsexportsdefineModule({...})with at least one adapter (usematch: {}for "default / no peer")- Templates go in
templates/, referenced bysrcpath.scopedecides wheredestlands:"app"(default) →manifest.appDir(e.g.apps/web/)"repo"→ repo root"package"→packages/<SLOT_PACKAGE_DIR[slot]>/— 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), declarepeerPackages: ["<dir>"]on the adapter so the runner adds the workspace dep, and write the import as{{<dir>PackageName}}(e.g.import { db } from "{{dbPackageName}}";) - 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 adapter 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