Files
stanza/AGENTS.md
T
jake e89fb63c31 feat: unify registry loading behind a main file, add apply rollback, and add stanza doctor
- **Registry main file.** The CLI now addresses any registry (first-party, third-party, or `STANZA_REGISTRY` override) by the full URL/path to its JSON index file. The `modules/<category>-<id>.json` convention, the in-repo source-tree loader, the directory-based auto-detect, and the inline-template disk fallback are all removed — one loader, no heuristics. `STANZA_REGISTRY` must be the full path/URL to the main file (not a directory); `registries` entries now carry `url` pointing at the main file instead of an `indexUrl`+template. **Breaking** (pre-release, clean break): registry index bumped to `schemaVersion: 2`; every module entry carries a `path` field resolved relative to the main file.
- **Auto-rollback.** `stanza add` (and each module in `stanza init`) now wraps its file writes in a transaction; a mid-apply failure restores touched files and `stanza.json` to their pre-apply state. `reportApplyFailure` updated to say "The change was rolled back" and demotes the `stanza remove` / `git restore` steps to a backstop note.
- **`stanza doctor`.** New read-only command that checks `stanza.json` against the filesystem (tracked files, deps, scripts, env vars, internal packages) and reports drift, exiting non-zero when found.
- Update integration tests to build the first-party registry once into a temp dir and point `STANZA_REGISTRY` at its main file; update the third-party HTTP fixture server to serve `/index.json` as the main file alongside per-module paths.
- Update `AGENTS.md` E2E smoke instructions and `apps/cli/README.md` to reflect the new loader and `doctor` command.
2026-05-30 12:49:11 -04:00

12 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/registry/src/module.ts (CATEGORIES); module roadmap in registry.mdx.

Layout

  • apps/cli/stanza-cli, entry src/bin.ts (publishable, ESM via tsdown)
  • apps/web/@stanza/web, TanStack Start visual builder (private, deployed to Vercel)
  • packages/registry/ — schema, resolver, manifest validator (private; inlined into CLI bundle)
  • packages/codemods/ — ts-morph helpers, idempotent + reversible (private; inlined)
  • packages/create-stanza/pnpm create stanza shim (publishable)
  • registry/modules/<category>-<id>/ — first-party modules: module.ts + templates/ (private; data, not code)
  • skills/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.yamlnever 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 watch for a loop)
  • vp check — format + lint + type-check (oxfmt + oxlint + type-aware tsgolint). Use this for validation loops. --fix autofixes
  • vp test — Vitest 4 via vite-plus/test; project selection via test.projects in root config
  • vp run -r build — build everything (vp pack per package, wraps tsdown)
  • vp run @stanza/web#dev — TanStack Start dev server. Its dev/build scripts first run the compile-registry vp task (defined under run.tasks in apps/web/vite.config.ts) → jiti packages/registry/src/build.ts apps/web/public, emitting apps/web/public/registry/{index,modules/*}.json + apps/web/public/schema.json
  • src/routeTree.gen.ts is generated by vp run @stanza/web#build — run it before the first vp check if missing
  • E2E smoke: build the registry (jiti packages/registry/src/build.ts $TMPDIR/reg), seed $TMPDIR/x with stanza.json + apps/web/package.json, then STANZA_REGISTRY=$TMPDIR/reg/registry/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_REGISTRY unset hits the production registry
  • pnpm changeset — drop a markdown file after a substantive PR; the release workflow handles versioning + publish. Only stanza-cli + create-stanza ship to npm

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 on scripts/*.ts are convenience only — those scripts use no Bun.* APIs
  • Build pipeline: vp pack → tsdown → ESM dist/. Source main/types point at ./src/ for dev resolution across workspaces; publishConfig overrides for the tarball. External npm deps are NOT bundled; workspace deps ARE inlined — transitive runtime deps (ts-morph, zod) MUST be direct dependencies of the publishable package
  • pnpm 10 + node-linker: isolated — every workspace MUST declare @types/node in its own devDeps and set types: ["node"] in tsconfig
  • TypeScript 6allowImportingTsExtensions: true + noEmit: true repo-wide; tsconfig.json excludes **/templates/** (those target user projects)
  • Zod 4: use z.partialRecord(K, V) for finite-key partial records
  • TanStack Start: verbatimModuleSyntax: false in apps/web/tsconfig.json; tanstackStart() MUST precede react() 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>.ts under builtins/, default-export a Codemod<TArgs>, register in builtins/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: packages/registry/src/build.ts inlines each template file's contents into templates[].content so HTTP-loaded manifests are self-contained. The runner prefers tpl.content and only reads from templates/ when absent (local dev). New templates need no build wiring
  • Categories carry orthogonal cardinality (one / many) and home (app / repo / package); both are declared on each entry in CATEGORIES. Adding a category is a one-line edit there — KNOWN_CATEGORIES, PEER_CATEGORIES, PACKAGE_DIRS, and categoryHome()/categoryLabel() all derive. Peer-resolution iterates PEER_CATEGORIES (the one-cardinality ids) only — a many category never participates in others' dispatch. CATEGORIES order is topological (each category appears after everything it peers on; many categories last)
  • Manifest layout: selections live in one modules record keyed by CategoryId, each an array of StanzaModuleRecord. Records carry optional apps?: string[]required for home: app, optional for home: package (omitted means "ship app-scoped shims into every app"; an explicit list restricts), forbidden for home: repo. The app id doubles as the workspace package suffix (id: "web"@<project>/web, regardless of dir). cardinality: "one" enforcement is per-app for home: app categories (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's synthesizePackageJsons. home: packagepackages/<dir>/ workspace package named @<manifest.name>/<dir> (auto-bootstrapped by ensureSlotPackage); every consuming app gets a workspace:* dep. home: repo → repo-root config + scripts in root package.json. home: app → the targeted app
  • Generated projects don't share a tsconfig base. Every apps/*/tsconfig.json and packages/*/tsconfig.json is self-contained. The Stanza repo's root tsconfig.json exists 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 / codemod args via {{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. renderTemplate lives in @stanza/registry so CLI apply and web preview produce byte-identical output
  • Region ownership in stanza.json is the source of truth for stanza remove. Two modules claiming the same region throws RegionConflictError. Regions key on module.id, so disjoint dot-paths (vitest scripts.test vs playwright scripts.test:e2e) coexist. Package bootstrap files (package.json, tsconfig.json, workspace deps) are system-owned, not tracked in regions — stanza remove's sweep over PACKAGE_DIRS cleans them when no claims remain. Regions are not yet sufficient for swap — that verb needs adapter-region remapping; design pending
  • Reserved manifest fields: modules[category][].version and regions are written today but only fully consumed by upcoming swap/update. Don't drop them
  • Web previews are SSR. Shiki runs in highlighter.server.ts; the client-safe Preview type lives in highlighter.ts. Never import shiki from a client component. After vp run @stanza/web#build, grep .output/public/assets/*.js for the runtime (createHighlighter/codeToHtml/loadWasm) — must be absent. A bare grep shiki yields false positives because MDX docs ship statically-rendered code blocks with class="shiki" + CSS vars (fine)
  • Web hosts the canonical registry. The compile-registry task writes the registry into apps/web/public/registry/; the deployed site serves it at the published CLI's default DEFAULT_REGISTRY_URL. STANZA_REGISTRY (URL or FS path) overrides for self-hosters / CI. public/registry/ is also a Nitro serverAssets dir (vite.config.ts) — SSR reads via useStorage("assets:registry") (see registry-base.server.ts), which is why Vercel works where public/ is absent from the function fs
  • Third-party registries: namespaces declared under stanza.json#registries. Modules addressed as <category> @<ns>/<id>. telemetry.captureModule redacts non-@stanza ids to <redacted>; stderr still prints full ids (CI log forwarders will see them)

Module authoring

  • Modules live in registry/modules/<category>-<id>/. module.ts exports defineModule({...}) with one category and ≥1 adapter (match: {} for "default / no peer"). For many-cardinality categories, give co-existing modules disjoint scripts keys / file paths so region claims don't collide
  • Template scope: "app" (default) → each targeted app's dir; "repo" → repo root; "package"packages/<dir>/ (only valid for home: package categories). For package-home modules, default to scope: "package" for everything that fits inside the package; reach for "app" only when framework convention forces it (e.g. Next proxy.ts). App-scoped files should be thin shims that import from {{package.name}} with template: true
  • Hoist shared install fields (dependencies, devDependencies, env, scripts, consumesPackages) to the module level. Adapter-level values still override per-key (env merges by name)
  • Never ship package.json or tsconfig.json templates — they collide with the runner's ensureSlotPackage / addPackageDependency. Framework modules must not ship package.json.tpl; package-home modules must not ship packages/<dir>/package.json
  • Codemod invocations: codemods: [{ id, args }] on the adapter. String values in args go through mustache (same template context). For codemods operating on files inside a slot's package, pass base: "package:<dir>" (honored by re-export and append-to-file)
  • Adapter match keys encode peer choices (e.g. next+drizzle); the resolver picks the most specific match. A module can declare a one-way peers (e.g. { framework: [...] }) and framework-varying adapters regardless of its own cardinality
  • Logos: drop logo.svg (theme-agnostic) or logo-light.svg + logo-dark.svg (theme pair) in the module dir. The registry build auto-detects and inlines as mod.logo. First-party logos come from svgl.app
  • version field: declare on every manifest; pinned into stanza.json at install. Bump per semver on schema-affecting changes (templates, dep upgrades). Consumed by upcoming swap/update