14 KiB
Stanza
Shadcn-style CLI that assembles modular full-stack TS monorepos. Two unusual properties: (1) generated code is vendored verbatim — no @stanza/runtime dep — and (2) stanza add works on existing projects, not just at init. Verbs today: init, add, remove, list, search (swap + update planned — manifest already reserves modules[category][].version and regions). The canonical category list lives in packages/schema/src/category.ts (CATEGORIES); module roadmap in registry.mdx.
Layout
apps/cli/—stanza-cli, entrysrc/bin.ts(publishable, ESM via tsdown)apps/web/—@withstanza/web, TanStack Start visual builder (private, deployed to Vercel)packages/schema/—@withstanza/schema: thestanza.json+ module schemas, contract types,CATEGORIESlist, package-manager + registry-config schemas (publishable; also inlined into CLI bundle). The single Zod source of truth —StanzaManifestSchemabacks the published JSON Schemapackages/registry/—@withstanza/registry: peer/adapter resolver +package.json/env/README/template synthesis (private; inlined into CLI bundle). Depends on@withstanza/schema+@withstanza/utilspackages/codemods/— ts-morph helpers, idempotent + reversible (private; inlined)packages/utils/—@withstanza/utils: path-safety (safeRelativePath) + env-file (appendEnvVar) helpers shared by schema/registry/codemods (private; inlined)packages/create-stanza/—pnpm create stanzashim (publishable)registry/modules/<category>-<id>/— first-party modules:module.ts+templates/(private; data, not code)scripts/compile-registry.ts— standalone static-registry build (not part of any package; imports@withstanza/schema). Run viajiti; exportscompileRegistry()for in-process callersskills/stanza-cli/SKILL.md— agent skill for the published CLI. Update whenever the public CLI contract changes; agents using this skill won't have the source repo
Commands
The repo runs on the Vite+ toolchain (vp). All config lives in the root vite.config.ts — there is no turbo.json, .oxlintrc.json, .oxfmtrc.json, or per-package vitest.config.ts. vite/vitest are catalog-aliased to @voidzero-dev/vite-plus-core / @voidzero-dev/vite-plus-test in pnpm-workspace.yaml — never add standalone vite/vitest. Source imports use vite-plus / vite-plus/test; template files under registry/modules/*/templates/ stay on stock vite/vitest (they target user projects).
tsx apps/cli/src/bin.ts <verb>— run CLI in dev (no build step;tsx watchfor a loop)vp check— format + lint + type-check (oxfmt + oxlint + type-awaretsgolint). Use this for validation loops.--fixautofixesvp test— Vitest 4 viavite-plus/test; project selection viatest.projectsin root configvp run -r build— build everything (vp packper package, wraps tsdown)vp run @withstanza/web#dev— TanStack Start dev server. Itsdev/buildscripts first run thecompile-registryvp task (defined underrun.tasksinapps/web/vite.config.ts) →jiti scripts/compile-registry.ts apps/web/public/registry, emittingapps/web/public/registry/{index.json,modules/*.json}. The manifest JSON Schema is no longer a build artifact — it's served on request by the/schema.jsonroute (apps/web/src/routes/schema[.]json.ts)src/routeTree.gen.tsis generated byvp run @withstanza/web#build— run it before the firstvp checkif missing- E2E smoke: build the registry (
jiti scripts/compile-registry.ts $TMPDIR/reg→ writes$TMPDIR/reg/index.json+$TMPDIR/reg/modules/*.jsondirectly under the out dir, noregistry/wrapper), seed$TMPDIR/xwithstanza.json+apps/web/package.json, thenSTANZA_REGISTRY=$TMPDIR/reg/index.json tsx apps/cli/src/bin.ts add <category> <module>. The CLI reads a built registry main file (full path/URL, any filename) — there is no source-tree loader;STANZA_REGISTRYunset hits the production registry pnpm changeset— drop a markdown file after a substantive PR; the release workflow handles versioning + publish.stanza-cli,create-stanza, and@withstanza/schemaship to npm; every other workspace is private
Use agent-browser for web automation (agent-browser --help); prefer it over built-in browser tools.
Toolchain invariants
- Node-only at runtime. CLI is dev-run via
tsx, published as plain ESM JS (#!/usr/bin/env node). Bun shebangs onscripts/*.tsare convenience only — those scripts use noBun.*APIs - Build pipeline:
vp pack→ tsdown → ESMdist/. Sourcemain/typespoint at./src/for dev resolution across workspaces;publishConfigoverrides for the tarball. External npm deps are NOT bundled; workspace deps ARE inlined — transitive runtime deps (ts-morph,zod) MUST be directdependenciesof the publishable package - pnpm 10 +
node-linker: isolated— every workspace MUST declare@types/nodein its own devDeps and settypes: ["node"]in tsconfig - TypeScript 6 —
allowImportingTsExtensions: true+noEmit: truerepo-wide;tsconfig.jsonexcludes**/templates/**(those target user projects) - Zod 4: use
z.partialRecord(K, V)for finite-key partial records - TanStack Start:
verbatimModuleSyntax: falseinapps/web/tsconfig.json;tanstackStart()MUST precedereact()in vite plugins
Architecture rules
- Modules are vendored, not runtime-linked. Templates land in the user's repo verbatim
- Registry is data; CLI is the runtime. Per-module JSON ships templates (text), deps, env, scripts, logos (SVG markup), and codemod invocations (
{ id, args }) — never codemod code. The catalog of generic codemods lives in packages/codemods/src/builtins/ and is statically imported into the CLI bundle at build time, so distribution shape (single binary, pnpm-isolated, npx, etc.) doesn't matter - Adding a generic codemod: drop
<id>.tsunderbuiltins/, default-export aCodemod<TArgs>, register inbuiltins/index.ts. Module-specific identifiers don't belong — factor them into args - Codemod catalog ids are part of the public contract. Once published, renaming an id breaks third-party manifests. Treat ids like npm package names — additions are free, renames need a deprecation cycle, removals need a manifest schema version bump
- Third-party codemods are deferred until a sandboxed-execution + signing model lands. Third-party modules can invoke existing catalog ids but can't add new ones
- Template distribution:
scripts/compile-registry.ts(the standalone registry build) inlines each template file's contents intotemplates[].contentso HTTP-loaded manifests are self-contained. The runner preferstpl.contentand only reads fromtemplates/when absent (local dev). New templates need no build wiring - Categories carry orthogonal
cardinality(one/many) andhome(app/repo/package); both are declared on each entry inCATEGORIES. Adding a category is a one-line edit there —KNOWN_CATEGORIES,PEER_CATEGORIES,PACKAGE_DIRS, andcategoryHome()/categoryLabel()all derive. Peer-resolution iteratesPEER_CATEGORIES(theone-cardinality ids) only — amanycategory never participates in others' dispatch.CATEGORIESorder is topological (each category appears after everything it peers on;manycategories last) - Manifest layout: selections live in one
modulesrecord keyed byCategoryId, each an array ofStanzaModuleRecord. Records carry optionalapps?: string[]— required forhome: app, optional forhome: package(omitted means "ship app-scoped shims into every app"; an explicit list restricts), forbidden forhome: repo. The appiddoubles as the workspace package suffix (id: "web"→@<project>/web, regardless ofdir).cardinality: "one"enforcement is per-app forhome: appcategories (selectedOne(m, cat, appId)), per-project otherwise - Install home routing:
categoryHome(id)is the single decision point, read by both the CLI runner and the web'ssynthesizePackageJsons.home: package→packages/<dir>/workspace package named@<manifest.name>/<dir>(auto-bootstrapped byensureSlotPackage); every consuming app gets aworkspace:*dep.home: repo→ repo-root config + scripts in rootpackage.json.home: app→ the targeted app - Generated projects don't share a tsconfig base. Every
apps/*/tsconfig.jsonandpackages/*/tsconfig.jsonis self-contained. The Stanza repo's roottsconfig.jsonexists only for this repo — never emit one into generated trees - Cross-package wiring: declare
consumesPackages: ["<dir>"]at the module level (not per-adapter) when source imports another internal package. Reference other packages in templates / codemodargsvia{{packages.<dir>.name}}(e.g.{{packages.db.name}}→@my-app/db). Full template context:{ project, app, package, packages };app.*is rebound per target app in the apply loop.renderTemplatelives in@withstanza/registryso CLI apply and web preview produce byte-identical output - Region ownership in
stanza.jsonis the source of truth forstanza remove. Two modules claiming the same region throwsRegionConflictError. Regions key onmodule.id, so disjoint dot-paths (vitestscripts.testvs playwrightscripts.test:e2e) coexist. Package bootstrap files (package.json,tsconfig.json, workspace deps) are system-owned, not tracked in regions —stanza remove's sweep overPACKAGE_DIRScleans them when no claims remain. Regions are not yet sufficient forswap— that verb needs adapter-region remapping; design pending - Reserved manifest fields:
modules[category][].versionandregionsare written today but only fully consumed by upcomingswap/update. Don't drop them - Web previews are SSR. Shiki runs in
highlighter.server.ts; the client-safePreviewtype lives inhighlighter.ts. Never importshikifrom a client component. Aftervp run @withstanza/web#build, grep.output/public/assets/*.jsfor the runtime (createHighlighter/codeToHtml/loadWasm) — must be absent. A baregrep shikiyields false positives because MDX docs ship statically-rendered code blocks withclass="shiki"+ CSS vars (fine) - Web hosts the canonical registry. The
compile-registrytask writes the registry intoapps/web/public/registry/; the deployed site serves it at the published CLI's defaultDEFAULT_REGISTRY_URL.STANZA_REGISTRY(URL or FS path) overrides for self-hosters / CI.public/registry/is also a NitroserverAssetsdir (vite.config.ts) — SSR reads viauseStorage("assets:registry")(seeregistry-base.server.ts), which is why Vercel works wherepublic/is absent from the function fs. The deployed site also serves the manifest JSON Schema at/schema.json(routeapps/web/src/routes/schema[.]json.ts), compiled from@withstanza/schema'sStanzaManifestSchemaon request rather than emitted topublic/— this is the URLMANIFEST_SCHEMA_URL/ a manifest's$schemaresolves to - Third-party registries: namespaces declared under
stanza.json#registries. Modules addressed as<category> @<ns>/<id>.telemetry.captureModuleredacts non-@stanzaids to<redacted>; stderr still prints full ids (CI log forwarders will see them)
Module authoring
- Modules live in
registry/modules/<category>-<id>/.module.tsexportsdefineModule({...})with onecategoryand ≥1 adapter (match: {}for "default / no peer"). Formany-cardinality categories, give co-existing modules disjointscriptskeys / file paths so region claims don't collide - Template
scope:"app"(default) → each targeted app'sdir;"repo"→ repo root;"package"→packages/<dir>/(only valid forhome: packagecategories). For package-home modules, default toscope: "package"for everything that fits inside the package; reach for"app"only when framework convention forces it (e.g. Nextproxy.ts). App-scoped files should be thin shims that import from{{package.name}}withtemplate: true - Hoist shared install fields (
dependencies,devDependencies,env,scripts,consumesPackages) to the module level. Adapter-level values still override per-key (envmerges byname) - Never ship
package.jsonortsconfig.jsontemplates — they collide with the runner'sensureSlotPackage/addPackageDependency. Framework modules must not shippackage.json.tpl; package-home modules must not shippackages/<dir>/package.json - Codemod invocations:
codemods: [{ id, args }]on the adapter. String values inargsgo through mustache (same template context). For codemods operating on files inside a slot's package, passbase: "package:<dir>"(honored byre-exportandappend-to-file) - Adapter
matchkeys encode peer choices (e.g.next+drizzle); the resolver picks the most specific match. A module can declare a one-waypeers(e.g.{ framework: [...] }) and framework-varying adapters regardless of its own cardinality - Logos: drop
logo.svg(theme-agnostic) orlogo-light.svg+logo-dark.svg(theme pair) in the module dir. The registry build auto-detects and inlines asmod.logo. First-party logos come from svgl.app versionfield: declare on every manifest; pinned intostanza.jsonat install. Bump per semver on schema-affecting changes (templates, dep upgrades). Consumed by upcomingswap/update