- Initialize Changesets: `.changeset/config.json` with `@changesets/changelog-github` for PR-linked changelog entries, `access: "public"`, `baseBranch: "main"`, `updateInternalDependencies: "patch"` - Add `.github/workflows/release.yml` — on push to main, `changesets/action@v1` opens a "Version Packages" PR when changesets are queued; merging that PR re-runs the workflow and calls `pnpm release` (build CLI + create-stanza, then `changeset publish`); provenance attestations enabled via `id-token: write` + `NPM_CONFIG_PROVENANCE=true`; requires `NPM_TOKEN` in repo secrets - Add root `pnpm changeset` and `pnpm release` scripts; add `@changesets/cli` + `@changesets/changelog-github` to root devDependencies - `@stanza/codemods` and `@stanza/registry` marked `private: true` — they are inlined into the CLI bundle by tsdown and never published independently; drop the now-redundant `files` field from codemods `package.json` - `apps/cli/package.json` and `packages/create-stanza/package.json` gain `keywords`, `homepage`, `bugs`, `author`, `repository`, and `publishConfig.access: "public"` — npm metadata required for a clean first publish - Fix `add-vite-plugin` multiline-array indent regression — when inserting into a `plugins: [...]` array that already spans multiple lines, ts-morph was reformatting the inserted and following elements to 8-space indent while leaving the first element at 4; fix preserves the existing element indent; add a dedicated test case covering the regression - Drop stale bun shebang comment from CI workflow; remove `packages/internal` from README; mark npm publish TODO done; update CLAUDE.md with Changesets workflow, `private: true` invariant, and `pnpm release` entry
17 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/copy-registry.shwhich copies the prebuiltdist/registry/intoapps/web/public/registry/. Pure bash — no node/tsx prerequisite on the deploy target. The deployed site ships this directory as static assets, so CLI and web consume the same JSON. Runpnpm registry:buildbefore the first web buildpnpm 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: today's 5 slots are all one-choice, peer-constraint-bearing. Future categories planned in REGISTRY.md fall into two buckets: slots (single-choice, constrain other modules' adapter dispatch —
api,ai,ui,payments) and add-ons (multi-choice, no peer constraints —testing,tooling,deploy,email,monorepo). The add-on schema is intentionally deferred: it should be designed around real add-on modules, not in a vacuum. When the first add-on lands, expect to introducemanifest.addons[]and akinddiscriminator (or equivalent) onModule - 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")- 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