chore: trim AGENTS.md, expand stanza-cli skill docs, and tidy stats page

- Condense `AGENTS.md` by moving CLI-specific guidance into `skills/stanza-cli/SKILL.md` where it is co-located with the skill it describes
- Expand `SKILL.md` with the relocated context so agent runs against the CLI skill have the full picture without loading root-level agent instructions
- Minor cleanup to `stats.tsx` layout/typography
This commit is contained in:
2026-05-27 19:13:03 -04:00
parent 77de7bcbc9
commit 1e45429ea3
3 changed files with 98 additions and 114 deletions
+49 -91
View File
@@ -1,109 +1,67 @@
# Stanza
Shadcn-style CLI for assembling modular full-stack TS monorepos. Currently ships `init`, `add`, `remove`, `list`, `search` against these **categories**: `framework`, `ui`, `db`, `orm`, `auth`, `tooling`, `testing`. `swap` + `update` verbs and more categories are planned — the manifest already reserves the fields they'll need (`modules[category][].version`, `regions`). See the [module registry](apps/web/content/docs/registry.mdx) for the module roadmap.
Three things differentiate Stanza from other scaffolders:
1. Post-init `stanza add` works on existing projects (manifest-driven, peer-aware).
2. Generated code is vendored verbatim — no `@stanza/runtime` dep.
3. Open registry spec — third parties can host their own static JSON.
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](packages/registry/src/module.ts) (`CATEGORIES`); module roadmap in [registry.mdx](apps/web/content/docs/registry.mdx).
## Layout
- `apps/cli/``stanza-cli`, node entrypoint at `src/bin.ts` (run via tsx in dev, built to `dist/bin.mjs` for publish via `vp pack`)
- `apps/web/``@stanza/web`, TanStack Start visual builder (Vite-native, no Vinxi)
- `packages/registry/` — shared schema, category/peer resolver, Zod manifest validator
- `packages/codemods/` — ts-morph helpers (idempotent + reversible)
- `packages/create-stanza/``pnpm create stanza` shim
- `registry/modules/<category>-<id>/` — first-party modules: `module.ts` + `templates/` (modules don't ship codemod code; see Architecture rules)
- `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
A generated project has one or more **apps** declared in `stanza.json` as `apps: [{ id, dir, kind }]` (e.g. `{ id: "web", dir: "apps/web", kind: "web" }`). `AppKind = "web" | "native"` is a closed enum, and framework modules declare an `appKind` so the runner can validate that the framework matches its target app. `stanza init` scaffolds a single web app today (`defaultWebApp()`); multi-app init is a planned follow-up, but schema and runtime are already multi-app-shaped — `applyModule` threads a `targetApps: AppSpec[]` per call, and the web builder's `synthesize*` functions all take an `apps` array.
## Commands
In a **generated project**, a module's output lands per its category's `home` (in the canonical [`CATEGORIES`](packages/registry/src/module.ts) array): `auth`/`db`/`orm`/`ui` (`home: package`) install into an internal workspace package at `packages/<dir>/` (named `@<manifest.name>/<dir>`, consumed via `workspace:*` by every consuming app; `db` + `orm` share `packages/db/`); `framework`/`testing` (`home: app`) wire the chosen app's shell; `tooling` (`home: repo`) writes config at the repo root + scripts in the root `package.json`. The app a record targets is encoded as `apps: ["<id>", …]` on `StanzaModuleRecord`**required** for `home: app`, **optional** for `home: package` (omitted means "ship app-scoped shims into every app"; an explicit list restricts), and **forbidden** for `home: repo`. The app `id` doubles as the workspace package suffix: `@<manifest.name>/<app.id>` (so `id: "web"``@acme/web`, regardless of `dir`). `categoryHome(id)`, `PACKAGE_DIRS`, and `categoryLabel(id)` are all derived from the `CATEGORIES` array.
The repo runs on the **Vite+ toolchain** (`vp`). All config lives in the root [`vite.config.ts`](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).
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`](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 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. `prebuild` runs [`prepare-registry.sh`](apps/web/scripts/prepare-registry.sh) → `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: seed `$TMPDIR/x` with `stanza.json` + `apps/web/package.json`, then `tsx apps/cli/src/bin.ts add <category> <module>`
- `pnpm changeset` — drop a markdown file after a substantive PR; the release workflow handles versioning + publish. Only `stanza-cli` + `create-stanza` ship to npm
- `tsx apps/cli/src/bin.ts <verb>` — run the CLI directly in dev (no build step; use `tsx watch` for a watch loop)
- `vp run stanza-cli#build` — build the publishable CLI via `vp pack` (wraps tsdown; config in [`apps/cli/tsdown.config.ts`](apps/cli/tsdown.config.ts), referenced from `apps/cli/vite.config.ts`'s `pack` block — externalizes npm deps, inlines workspace packages). Same for `create-stanza`. `vp run -r build` builds everything (both CLIs + the web app)
- `vp run @stanza/web#dev` — TanStack Start dev server. `prebuild` invokes [`apps/web/scripts/prepare-registry.sh`](apps/web/scripts/prepare-registry.sh) which runs `jiti packages/registry/src/build.ts apps/web/public` — the build emits directly into `apps/web/public/registry/{index,modules/*}.json` + `apps/web/public/schema.json` (the builder accepts a positional output-base arg; if you want a `dist/`-scoped build for inspection, run `jiti packages/registry/src/build.ts` with no arg). The deployed site ships this directory as static assets, so CLI and web consume the same JSON. `public/registry/` is also registered as a Nitro `serverAssets` dir (vite.config.ts), so its contents are embedded into the server bundle at build time — SSR reads it via `useStorage("assets:registry")` (not the CDN), which is why it works on Vercel where `public/` is absent from the function fs
- `vp check` — format + lint + type-check across the repo (oxfmt + oxlint + type-aware `tsgolint`; `lint.options.typeAware`/`typeCheck` are **on**). `vp check --fix` autofixes formatting + fixable lint. `vp lint` / `vp fmt` run those passes alone. **Prefer `vp check` for validation loops** — it replaces the old `pnpm lint`/`fmt:check`/`check-types` trio
- `vp test` — run the workspace test suites once (Vitest 4 via `vite-plus/test`; selection via `test.projects` in `vite.config.ts`)
- `src/routeTree.gen.ts` is generated by the web build (`vp run @stanza/web#build`) — run it before the first `vp check` if the file is missing
- E2E smoke: seed `$TMPDIR/x` with `stanza.json` + `apps/web/package.json`, then `tsx apps/cli/src/bin.ts add <slot> <module>`
- `pnpm changeset` (or `vp 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 use `vp pack` / `pnpm pack` to inspect tarballs)
## Browser Automation
Use `agent-browser` for web automation. Run `agent-browser --help` for all commands.
Core workflow:
1. `agent-browser open <url>` - Navigate to page
2. `agent-browser snapshot -i` - Get interactive elements with refs (@e1, @e2)
3. `agent-browser click @e1` / `fill @e2 "text"` - Interact using refs
4. Re-snapshot after page changes
## Distributed Skill
- `skills/stanza-cli/SKILL.md` is 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.
Use `agent-browser` for web automation (`agent-browser --help`); prefer it over built-in browser tools.
## 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 any `Bun.*` APIs and run fine under tsx/node
- **Build pipeline**: `vp pack` (which wraps `tsdown`) compiles each publishable package to ESM JS in `dist/`. The tsdown options live in each package's `tsdown.config.ts` and are passed through `vite.config.ts`'s `pack` block. External npm deps are _not_ bundled (users install them via the normal dep chain); workspace deps are _inlined_ (we don't publish `@stanza/registry` and `@stanza/codemods` separately). Transitive runtime deps (`ts-morph`, `zod`) MUST be declared as direct `dependencies` of the publishable package or it will inline them into the bundle
- **Per-workspace `dist/` paths**: `main`/`types` in the source `package.json` still point at `./src/` so other workspaces resolve `.ts` directly during dev. The published tarball overrides via `publishConfig` to point at `./dist/<x>.mjs`/`.d.mts`
- **Publishing**: only `stanza-cli` and `create-stanza` ship to npm. `@stanza/codemods` + `@stanza/registry` are marked `private: true` (inlined into the CLI bundle by tsdown); `@stanza/web` is private (deployed as a Vercel site, not an npm package); the `registry/modules/*` packages are also private (they're registry data, not npm packages). Releases go through **Changesets**: drop a markdown file via `pnpm changeset`, push to main → the [release workflow](.github/workflows/release.yml) opens a "Version Packages" PR; merging that PR triggers the same workflow to run `vp run release` (build CLI + create-stanza, then `changeset publish` to npm). Requires `NPM_TOKEN` in repo secrets; provenance attestations are emitted via `id-token: write` + `NPM_CONFIG_PROVENANCE=true`
- pnpm 10 + `node-linker: isolated` — each workspace MUST declare `@types/node` in its own devDeps and set `types: ["node"]` in tsconfig (auto-discovery doesn't reach into the isolated `node_modules/@types`)
- TypeScript 6 — `allowImportingTsExtensions: true` + `noEmit: true` is set in `tsconfig.json`; the CLI/create-stanza emit JS via tsdown, the registry/codemods packages stay source-only and never emit
- `tsconfig.json` excludes `**/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: false` in `apps/web/tsconfig.json` (server bundles leak otherwise); `tanstackStart()` MUST precede `react()` in vite plugins
- **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 6** — `allowImportingTsExtensions: 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**: their templates land in the user's repo verbatim; no `@stanza/runtime` dep
- **Template distribution**: `packages/registry/src/build.ts` inlines each template file's contents into the per-module JSON's `templates[].content` field so HTTP-loaded manifests are self-contained. The runner prefers `tpl.content` and only reads from `registry/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) or `logo-light.svg` + `logo-dark.svg` (theme pair) in a module's directory. The registry build auto-detects and inlines as `mod.logo` (string or `{ light, dark }`) — module authors don't declare anything in `module.ts`. First-party logos come from [svgl.app](https://svgl.app). The web builder renders inline via `dangerouslySetInnerHTML`
- **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/](packages/codemods/src/builtins/) and is exposed via the `@stanza/codemods/builtins` subpath export — each codemod is parameterized by `TArgs` and reusable across modules (`wrap-root-layout` serves 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>.ts` under [packages/codemods/src/builtins/](packages/codemods/src/builtins/), default-export a `Codemod<TArgs>`, and register it in [packages/codemods/src/builtins/index.ts](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 (`createServerFn` in `apps/web/src/server/builder-state.ts`) computes selected files from URL search params, pre-renders Shiki HTML for each, and ships `Record<path, { light, dark }>` to the client. `shiki` must NEVER be imported from a client component — verify after `vp run @stanza/web#build` by grepping `.output/public/assets/*.js` for the shiki **runtime** (`createHighlighter`/`codeToHtml`/`loadWasm`), which should be absent. A bare `grep shiki` now yields false positives: the MDX docs pages ship statically-rendered code blocks with `class="shiki"` + `--shiki-*` CSS vars, which is fine — only the runtime must stay server-side
- **Category taxonomy** (`framework | ui | db | orm | auth | tooling | testing`, more planned). One unified concept: a `Module` carries a single `category` field (no `kind`/`slot` discriminator). A category has two orthogonal, explicit properties — **`cardinality`** (`"one"` single-choice / `"many"` coexisting) and **`home`** (`app`/`repo`/`package`). Both live on the `Category` entry in `CATEGORIES`; `CategoryId`, `KNOWN_CATEGORIES`, `PEER_CATEGORIES`, `PACKAGE_DIRS` all 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 declares `peers`/`match` against it, and the resolver iterates **`PEER_CATEGORIES`** (the `cardinality: "one"` ids) only — so a `many` category like `testing` never participates in others' dispatch. A module _can_ declare a one-way `peers` (e.g. `{ framework: [...] }`) + framework-varying adapters regardless of its own cardinality. `CATEGORIES` order is topological (a category appears after everything it peers on; `many` categories last)
- **Manifest stores selections in one `modules` record** keyed by `CategoryId`, each an array (`Partial<Record<CategoryId, StanzaModuleRecord[]>>`); records carry an optional `apps?: string[]` field (see the apps-routing paragraph above). `cardinality: "one"` enforcement is **per-app** for `home: app` categories (`selectedOne(m, cat, appId)` filters; `add` rejects a second pick _for that app_) and per-project for the rest. Read via `selectedOne(m, cat, appId?)` / `selectedAll(m, cat, appId?)`. Region ownership keys on `module.id`, so two modules writing the same file at disjoint dot-paths (vitest `scripts.test` vs playwright `scripts.test:e2e`) never conflict; per-app writes use distinct file paths (`apps/web/...` vs `apps/native/...`), so app-scoped region claims naturally disambiguate
- **Adapter keys** encode peer choices (e.g., `next+drizzle`); the resolver picks the most specific match
- **Module-level install fields**: `dependencies`, `devDependencies`, `env`, `scripts`, and `consumesPackages` are declared at the module level when shared across adapters. Adapter-level fields override per-key (`env` merges by `name`). This avoids re-declaring the same `dependencies` and `env` block in every adapter of a multi-framework module (cf. Better Auth — 6 adapters, but `better-auth` dep 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's `synthesizePackageJsons`). `home: package` (`auth`/`db`/`orm`/`ui`) → `packages/<dir>/` workspace package (named `@<manifest.name>/<dir>`), templates `scope: "package"`; every _consuming_ app (per the record's `apps` field, or all apps when omitted) gets a `workspace:*` dep. `home: repo` (`tooling`) → `scope: "repo"` config at the repo root + deps/scripts in the root `package.json`. `home: app` (`framework`/`testing`) → the app the record targets; `scope: "app"` templates emit per-target-app (so a package-home module that ships shims into both web and native fans out cleanly). Package bootstrap files (the package's `package.json` + `tsconfig.json` and each consuming app's workspace dep) are **system-owned** not tracked in `regions`. `stanza remove`'s sweep (over `PACKAGE_DIRS`) deletes them when no claims remain under `packages/<dir>/` and strips the workspace dep from _every_ app's `package.json`
- **Generated projects don't share a tsconfig base**: every `apps/*/tsconfig.json` and `packages/*/tsconfig.json` is self-contained. The framework module ships the app's tsconfig; the runner's `ensureSlotPackage` writes a matching self-contained config when bootstrapping a slot package. `tsconfig.json` lives 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`'s `auth.ts` reads `db` from the orm package), declare `consumesPackages: ["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 `{{packages.<dir>.name}}` substitution (e.g. `{{packages.db.name}}``@my-app/db`) — substitution runs over both template bodies (when `template: true`) and codemod-invocation `args` string values. The full template context is `{ project: { name, appDir }, app: { id, dir, kind }, package: { name }, packages: Record<dir, { name }> }`; `app.*` and the legacy `project.appDir` alias are bound to the _currently-applying app_ (the runner re-builds context per target app in the loop). `renderTemplate` + `buildRenderContext` live in `@stanza/registry` so the CLI's apply path and the web preview's `synthesizeTemplates` produce byte-identical output
- **Region ownership** in `stanza.json` is the source of truth for `stanza remove`. Two modules claiming the same region is a hard error (`RegionConflictError`). Note: regions are _not yet sufficient for `swap`_ — 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 `version` field**: every module manifest declares a `version` string, pinned into `stanza.json` at install time. The upcoming `swap`/`update` verbs 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`/`scripts` over imperative codemods; the runner applies declarative fields generically
- **Reserved manifest fields**: `modules[category][].version` and `regions` are written today but only fully consumed by the upcoming `swap`/`update` verbs — do not drop them
- **Web app hosts the canonical registry**: `pnpm --filter @stanza/web build` runs `prebuild` which builds the registry (if missing) and copies it into `apps/web/public/registry/`. The deployed Vercel output therefore ships `index.json` + `modules/*.json` at the same origin. The published CLI's `DEFAULT_REGISTRY_URL` points at this same URL, so users get a working CLI with zero configuration. Self-hosters and CI override via `STANZA_REGISTRY` (URL or filesystem path) — this overrides the bundled `@stanza` default namespace's URL only
- **Third-party registries**: namespaces declared in `stanza.json` under `registries` (string URL prefix or `{ url, indexUrl?, headers?, params? }` object). Modules from them are addressed as `<category> @<ns>/<id>` on the CLI. Resolved by [packages/registry/src/registry-config.ts](packages/registry/src/registry-config.ts) (Zod schema, `parseModuleSpec`, `expandEnv`) and the [Registries](apps/cli/src/lib/registry-loader.ts) loader. Codemods still come from the first-party catalog only — third-party modules can invoke catalog ids but can't ship new ones
- **Third-party id privacy**: `telemetry.captureModule` redacts the `module` field for non-`@stanza` namespaces (`"<redacted>"`) so per-namespace install counts aggregate without leaking private ids to first-party analytics. Stderr and progress messages still print the full `@ns/id` — CI pipelines that forward stderr to external log aggregators will see those ids
- **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/](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`](packages/codemods/src/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`](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: package``packages/<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`](apps/web/src/server/highlighter.server.ts); the client-safe `Preview` type lives in [`highlighter.ts`](apps/web/src/server/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.** `prebuild` 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`](apps/web/src/server/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
- `module.ts` exports `defineModule({...})` with a single `category` (one of `KNOWN_CATEGORIES`) and at least one adapter (use `match: {}` for "default / no peer"). Modules live in `registry/modules/<category>-<id>/` (e.g. `testing-vitest/`). For `many`-cardinality categories, give co-existing modules disjoint `scripts`/file paths so their region claims don't collide
- Templates go in `templates/`, referenced by `src` path. `scope` decides where `dest` lands:
- `"app"` (default) → each targeted app's `dir` (the runner emits once per consuming app, so the same `dest` lands in `apps/web/...` and `apps/native/...` when both target the module)
- `"repo"` → repo root
- `"package"``packages/<dir>/` where `<dir>` is the active category's `home.dir` — only valid for categories with `home.kind === "package"` (`auth`, `db`, `orm`, `ui`)
- For `auth`/`db`/`orm`/`ui` modules, default to `scope: "package"` for everything that can live inside the package boundary. Reach for `scope: "app"` only when a framework convention forces the file to sit at the app root (e.g. Next's `middleware.ts`, App Router API routes). App-scoped files should be thin shims that `import` from `{{package.name}}`; set `template: true` and the runner runs mustache substitution
- For cross-package imports (e.g. `auth` reading `db`), declare `consumesPackages: ["<dir>"]` **at the module level** (not on each adapter). The runner adds the workspace dep on first apply. Write the import as `{{packages.<dir>.name}}` (e.g. `import { db } from "{{packages.db.name}}";`)
- **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's `dependencies: { "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 with `addPackageDependency`. Let the runner merge deps into the host's package.json. The same rule applies to **package-scoped** modules: don't ship a `packages/<dir>/package.json` template — the runner's `ensureSlotPackage` bootstraps 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 in `args` go 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-export` and `append-to-file` both honor it
## Gotchas
- Clack spinners (`p.spinner()`) don't auto-stop on promise rejection — wrap awaits in try/catch and call `spinner.stop(...failed)` in the catch
- LSP diagnostics on template files (e.g. "Cannot find module 'react'") are noise; the global exclude works for `tsc` but tsserver still indexes them
- The dev registry is found by walking up from `import.meta.url` looking for `registry/modules/`; `STANZA_REGISTRY` env var overrides (FS path or HTTP URL)
- `pnpm install` says "Already up to date" when only workspace `package.json` files changed without lockfile-affecting bumps — use `pnpm install --force` to re-link
- Oxlint/oxfmt now run **through `vp`** (bundled in `vite-plus`); their config lives in the `lint`/`fmt` blocks of `vite.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 check` is type-aware (`lint.options.typeAware`/`typeCheck: true`), so it runs `tsgolint` type checks too. **`tsgolint` does not support `compilerOptions.baseUrl`** — if a tsconfig sets it, vite-plus silently skips type-aware checks. Genuinely-needed `as` assertions at untyped boundaries (CSS custom props in `React.CSSProperties`, `Object.fromEntries` → exhaustive `Record`, first-party SSR asset casts) carry a scoped `// oxlint-disable-next-line typescript/no-unsafe-type-assertion`; test files relax `no-unsafe-type-assertion`/`no-floating-promises` via the `vite.config.ts` test override
- 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 `middleware.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](https://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`
+11 -18
View File
@@ -44,17 +44,12 @@ function formatGeneratedAt(iso: string): string {
function LastRefreshed({ iso }: { iso: string }) {
const ago = useTimeAgo(iso);
return (
<p className="text-xs leading-4">
<p className="font-mono text-xs leading-4 text-muted-foreground/70">
Last refreshed{" "}
<Tooltip>
<TooltipTrigger
nativeButton={false}
render={
<time
dateTime={iso}
className="cursor-help font-mono text-muted-foreground/70 tabular-nums"
/>
}
render={<time dateTime={iso} className="cursor-help tabular-nums" />}
>
{ago}
</TooltipTrigger>
@@ -87,8 +82,6 @@ function StatsPage() {
};
}, []);
const isLoading = stats === null;
const findModule = (category: CategoryId, id: string): ModuleMetadata | undefined =>
registry.modules.find((m) => m.category === category && m.id === id);
@@ -119,9 +112,9 @@ function StatsPage() {
</CardTitle>
</CardHeader>
<CardContent>
<p className="font-mono text-4xl font-medium tracking-tight tabular-nums">
{isLoading ? "—" : <NumberFlow value={stats.projectsScaffolded} locales="en-US" />}
</p>
<div className="font-mono text-4xl font-medium tracking-tight tabular-nums">
<NumberFlow value={stats?.projectsScaffolded ?? 0} locales="en-US" />
</div>
</CardContent>
</Card>
<Card>
@@ -131,9 +124,9 @@ function StatsPage() {
</CardTitle>
</CardHeader>
<CardContent>
<p className="font-mono text-4xl font-medium tracking-tight tabular-nums">
{isLoading ? "—" : <NumberFlow value={stats.modulesInstalled} locales="en-US" />}
</p>
<div className="font-mono text-4xl font-medium tracking-tight tabular-nums">
<NumberFlow value={stats?.modulesInstalled ?? 0} locales="en-US" />
</div>
</CardContent>
</Card>
</section>
@@ -145,7 +138,7 @@ function StatsPage() {
<CardTitle className="text-xs font-medium tracking-wider text-muted-foreground uppercase">
CLI runs &middot; last 30 days
</CardTitle>
{!isLoading ? (
{stats ? (
<span className="font-mono text-xs text-muted-foreground tabular-nums">
{activitySum > 0
? `${numberFormatter.format(activitySum)} total`
@@ -156,7 +149,7 @@ function StatsPage() {
</CardHeader>
<CardContent className="pb-1">
<Suspense fallback={<div aria-hidden className="h-24 w-full" />}>
<ActivityChart activity30d={stats?.activity30d ?? []} isLoading={isLoading} />
<ActivityChart activity30d={stats?.activity30d ?? []} isLoading={stats === null} />
</Suspense>
</CardContent>
</Card>
@@ -184,7 +177,7 @@ function StatsPage() {
</CardHeader>
<CardContent>
<BarList
emptyMessage={isLoading ? "Loading…" : "No data yet."}
emptyMessage={stats ? "No data yet." : "Loading…"}
data={entries.map((entry) => {
const meta = findModule(category, entry.id);
const label = meta?.label ?? entry.id;
+38 -5
View File
@@ -72,8 +72,8 @@ Run `add`, `remove`, and `list` from the project root or any child directory con
Every module belongs to exactly one **category**, and each category is either single-choice or multi-choice:
- **Single-choice** categories: `framework`, `ui`, `db`, `orm`, `auth`, `tooling`. Adding a module to a filled single-choice category fails until the existing one is removed.
- **Multi-choice** categories: `testing` (with `deploy`, `email`, `monorepo` planned). Multiple modules coexist in one category.
- **Single-choice** categories: `framework`, `ui`, `db`, `orm`, `auth`, `payments`, `email`, `ai`, `tooling`, `monorepo`. Adding a module to a filled single-choice category fails until the existing one is removed.
- **Multi-choice** categories: `testing`, `deploy`. Multiple modules coexist in one category.
For `init --yes`, pass each category's module ids as a comma-separated value; single-choice categories take exactly one, multi-choice take several — for example `--framework=next` and `--testing=vitest,playwright`.
@@ -88,8 +88,41 @@ For `init --yes`, pass each category's module ids as a comma-separated value; si
- Use `--dry-run` before a mutating command when the user wants a preview. It writes nothing.
- Mutating commands refuse to run in a dirty git worktree. Ask the user before using `--dangerously-allow-dirty`; it intentionally allows Stanza edits to mix with existing changes.
- Use `STANZA_REGISTRY=<url-or-path>` only when the user asks for a custom/self-hosted registry or test fixture. Otherwise let the published CLI use its default registry.
- For third-party modules from another publisher, declare the registry in `stanza.json` under `registries` (e.g. `"registries": { "@acme": "https://reg.acme.dev" }`) and install with `stanza add <category> @acme/<module>`. Unknown namespaces fail fast — there is no implicit fallback to the default registry.
## Registries
Stanza ships modules from the first-party `@stanza` namespace by default. To install modules from another publisher, declare the registry in `stanza.json#registries` and address modules with `@<scope>/<id>` syntax:
```json
{
"registries": {
"@acme": "https://reg.acme.dev"
}
}
```
```sh
stanza add testing @acme/cosmos
```
Editing `stanza.json#registries` is an expected user-driven edit (the rest of the manifest stays runner-managed). For non-canonical layouts, use the object form:
```json
{
"registries": {
"@acme": {
"url": "https://api.acme.dev/r/{category}/{id}.json",
"indexUrl": "https://api.acme.dev/r/index.json",
"headers": { "Authorization": "Bearer ${ACME_TOKEN}" },
"params": { "channel": "stable" }
}
}
}
```
`{category}` and `{id}` are required placeholders. `indexUrl` is optional — without it, `stanza search` skips that namespace but install-by-name still works. `${ENV_VAR}` expansion runs against `process.env`: unset vars in `headers` silently drop the header; unset vars in `params` are a hard error. Unknown namespaces fail fast — no implicit fallback to `@stanza`. `@stanza` itself is reserved and rejected if redeclared under `registries`.
`STANZA_REGISTRY=<url-or-path>` overrides the `@stanza` namespace's URL only — for a self-hosted mirror, air-gapped install, or CI fixture. It does NOT enable third-party modules.
## Telemetry
@@ -106,6 +139,6 @@ For `init --yes`, pass each category's module ids as a comma-separated value; si
## Agent Rules
- Treat generated files as user-owned project code after Stanza writes them.
- Do not edit `stanza.json` by hand unless the user explicitly asks for manual repair.
- Don't synthesize `modules`, `regions`, or `readmeChecksum` in `stanza.json` — those are runner-managed. `registries`, `apps`, and `packageManager` are user config and may be edited.
- Prefer CLI commands over reconstructing Stanza's template output yourself.
- When reporting results, include the exact command run, whether it wrote files, and any follow-up package-manager command needed.