22 KiB
Stanza
Shadcn-style CLI for assembling modular full-stack TS monorepos. Currently ships init, add, remove, list, search against these categories: framework, styling, db, orm, auth, tooling, testing. swap + update verbs and more categories (api, ai, ui, payments, deploy, email, monorepo) are planned — the manifest already reserves the fields they'll need (modules[category][].version, regions). See the module registry 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, built todist/bin.mjsfor publish viavp pack)apps/web/—@stanza/web, TanStack Start visual builder (Vite-native, no Vinxi)packages/registry/— shared schema, category/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/<category>-<id>/— first-party modules:module.ts+templates/(modules don't ship codemod code; see Architecture rules)
In a generated project, a module's output lands per its category's home (in the canonical CATEGORIES array): auth/db/orm (home: package) install into an internal workspace package at packages/<dir>/ (named @<manifest.name>/<dir>, consumed via workspace:*; db + orm share packages/db/); framework/styling/testing (home: app) wire the app shell; tooling (home: repo) writes config at the repo root + scripts in the root package.json. categoryHome(id), PACKAGE_DIRS, and categoryLabel(id) are all derived from the array.
The repo runs on the Vite+ toolchain (vp) — one CLI for dev/build/test/lint/fmt/check/pack, all configured by the root vite.config.ts (test/lint/fmt/staged blocks). There is no turbo.json, .oxlintrc.json, .oxfmtrc.json, or per-package vitest.config.ts — that config now lives in vite.config.ts. vp run <script> runs a package.json script; vp run -r <task> fans out across the workspace (topological); vp run <pkg>#<task> targets one package. vp install/add/remove delegate to pnpm (the declared packageManager). vite/vitest are catalog-aliased to @voidzero-dev/vite-plus-core/@voidzero-dev/vite-plus-test in pnpm-workspace.yaml — keep those aliases; never add standalone vite/vitest. Source imports use vite-plus and vite-plus/test (template files under registry/modules/*/templates/ stay on stock vite/vitest — they target generated user projects). Git hooks are managed by vp config → .vite-hooks/; CI bootstraps via voidzero-dev/setup-vp@v1.
tsx apps/cli/src/bin.ts <verb>— run the CLI directly in dev (no build step; usetsx watchfor a watch loop)vp run stanza-cli#build— build the publishable CLI viavp pack(wraps tsdown; config inapps/cli/tsdown.config.ts, referenced fromapps/cli/vite.config.ts'spackblock — externalizes npm deps, inlines workspace packages). Same forcreate-stanza.vp run -r buildbuilds everything (both CLIs + the web app)vp run registry:build— regeneratedist/registry/{index,modules/*}.json(script body isbun scripts/registry-build.ts; portable,tsx scripts/registry-build.tsworks too)vp run @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 fsvp check— format + lint + type-check across the repo (oxfmt + oxlint + type-awaretsgolint;lint.options.typeAware/typeCheckare on).vp check --fixautofixes formatting + fixable lint.vp lint/vp fmtrun those passes alone. Prefervp checkfor validation loops — it replaces the oldpnpm lint/fmt:check/check-typestriovp test— run the workspace test suites once (Vitest 4 viavite-plus/test; selection viatest.projectsinvite.config.ts).apps/webis intentionally excluded fromprojects(a vite-plus alpha bug breaks its plugin-heavy SSR config under the workspace loader); its suite passes standalone viacd apps/web && vp test. Re-add it toprojectsonce the upstream loader bug is fixedsrc/routeTree.gen.tsis generated by the web build (vp run @stanza/web#build) — run it before the firstvp checkif the file is missing- E2E smoke: seed
$TMPDIR/xwithstanza.json+apps/web/package.json, thentsx apps/cli/src/bin.ts add <slot> <module> pnpm changeset(orvp exec changeset) — create a new changeset describing what changed (run after a substantive PR)vp run release— build CLI/create-stanza and publish to npm (only runs in CI; locally usevp pack/pnpm packto inspect tarballs)
Browser Automation
Use agent-browser for web automation. Run agent-browser --help for all commands.
Core workflow:
agent-browser open <url>- Navigate to pageagent-browser snapshot -i- Get interactive elements with refs (@e1, @e2)agent-browser click @e1/fill @e2 "text"- Interact using refs- Re-snapshot after page changes
Distributed Skill
skills/stanza-cli/SKILL.mdis the installable agent skill for using Stanza through the npm-distributed CLI. Keep it accurate whenever CLI flags, command behavior, slot/add-on names, registry resolution, or published package names change; agents using the skill may not have access to this source repo and will rely on the public CLI contract alone.
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:
vp pack(which wrapstsdown) compiles each publishable package to ESM JS indist/. The tsdown options live in each package'stsdown.config.tsand are passed throughvite.config.ts'spackblock. 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 it 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 runvp run 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 — verify aftervp run @stanza/web#buildby grepping.output/public/assets/*.jsfor the shiki runtime (createHighlighter/codeToHtml/loadWasm), which should be absent. A baregrep shikinow yields false positives: the MDX docs pages ship statically-rendered code blocks withclass="shiki"+--shiki-*CSS vars, which is fine — only the runtime must stay server-side - Category taxonomy (
framework | styling | db | orm | auth | tooling | testing, more planned). One unified concept: aModulecarries a singlecategoryfield (nokind/slotdiscriminator). A category has two orthogonal, explicit properties —cardinality("one"single-choice /"many"coexisting) andhome(app/repo/package). Both live on theCategoryentry inCATEGORIES;CategoryId,KNOWN_CATEGORIES,PEER_CATEGORIES,PACKAGE_DIRSall derive from it, so adding a category is a one-line edit ({ id, label, description, cardinality, home }). Constraint-bearing is emergent: a category is a peer candidate only if some module declarespeers/matchagainst it, and the resolver iteratesPEER_CATEGORIES(thecardinality: "one"ids) only — so amanycategory liketestingnever participates in others' dispatch. A module can declare a one-waypeers(e.g.{ framework: [...] }) + framework-varying adapters regardless of its own cardinality.CATEGORIESorder is topological (a category appears after everything it peers on;manycategories last) - Manifest stores selections in one
modulesrecord keyed byCategoryId, each an array (Partial<Record<CategoryId, StanzaModuleRecord[]>>);cardinality: "one"categories are kept to ≤ 1 record byadd/initrejecting a second pick (the runner write itself is uniform). Read viaselectedOne(m, cat)/selectedAll(m, cat). Region ownership keys onmodule.id, so two modules writing the same file at disjoint dot-paths (vitestscripts.testvs playwrightscripts.test:e2e) never conflict - 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) - Install home routing: a module's templates/deps/scripts route by its category's
home(the single decision point —categoryHome(id), read by both the CLI runner and the web'ssynthesizePackageJsons).home: package(auth/db/orm) →packages/<dir>/workspace package (named@<manifest.name>/<dir>), templatesscope: "package", app gets aworkspace:*dep;home: repo(tooling) →scope: "repo"config at the repo root + deps/scripts in the rootpackage.json;home: app(framework/styling/testing) → the active app. Package bootstrap files (the package'spackage.json+tsconfig.jsonand the host app's workspace dep) are system-owned — not tracked inregions.stanza remove's sweep (overPACKAGE_DIRS) 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[category][].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 a singlecategory(one ofKNOWN_CATEGORIES) and at least one adapter (usematch: {}for "default / no peer"). Modules live inregistry/modules/<category>-<id>/(e.g.testing-vitest/). Formany-cardinality categories, give co-existing modules 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/<dir>/where<dir>is the active category'shome.dir— only valid for categories withhome.kind === "package"(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/oxfmt now run through
vp(bundled invite-plus); their config lives in thelint/fmtblocks ofvite.config.ts, not standalone rc files. Oxlint has most but not all ESLint plugin rules —prevent-abbreviations,react/jsx-uses-react, etc. don't exist; a bad rule name fails the lint run vp checkis type-aware (lint.options.typeAware/typeCheck: true), so it runstsgolinttype checks too.tsgolintdoes not supportcompilerOptions.baseUrl— if a tsconfig sets it, vite-plus silently skips type-aware checks. Genuinely-neededasassertions at untyped boundaries (CSS custom props inReact.CSSProperties,Object.fromEntries→ exhaustiveRecord, first-party SSR asset casts) carry a scoped// oxlint-disable-next-line typescript/no-unsafe-type-assertion; test files relaxno-unsafe-type-assertion/no-floating-promisesvia thevite.config.tstest override