chore: migrate to vite+

This commit is contained in:
2026-05-22 18:37:24 -04:00
parent 0db48d5dbc
commit 78f8e44721
74 changed files with 1373 additions and 1559 deletions
+8 -26
View File
@@ -17,45 +17,27 @@ jobs:
steps:
- uses: actions/checkout@v6
- name: Setup pnpm
uses: pnpm/action-setup@v6
with:
version: 10
- name: Setup Node.js
uses: actions/setup-node@v6
- uses: voidzero-dev/setup-vp@v1
with:
node-version: 24
cache: pnpm
cache: true
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: 1.3.14
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build registry
run: vp run registry:build
- name: Lint
run: pnpm lint
- name: Format check
run: pnpm fmt:check
- name: Build registry
run: pnpm registry:build
- name: Build web app
run: pnpm --filter @stanza/web build
- name: Typecheck
run: pnpm check-types
run: vp check
- name: Tests
run: pnpm test
run: vp test
- name: Build CLI bundle
run: pnpm --filter stanza-cli build
- name: Build
run: vp run build
- name: Smoke-test built CLI
run: |
+13 -20
View File
@@ -24,39 +24,32 @@ jobs:
# Changesets needs full history to compute the changelog.
fetch-depth: 0
- name: Setup pnpm
uses: pnpm/action-setup@v6
with:
version: 10
- name: Setup Node.js
uses: actions/setup-node@v6
- uses: voidzero-dev/setup-vp@v1
with:
node-version: 24
cache: pnpm
cache: true
- name: Setup Bun
uses: oven-sh/setup-bun@v2
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build registry
run: pnpm registry:build
run: vp run registry:build
- name: Lint + typecheck + test
run: |
pnpm lint
pnpm fmt:check
pnpm check-types
pnpm test
- name: Lint
run: vp check
- name: Tests
run: vp test
- name: Build
run: vp run build:cli
- name: Create release PR or publish
id: changesets
uses: changesets/action@v1
with:
version: pnpm changeset version
publish: pnpm release
version: vp exec changeset version
publish: vp run release
title: "chore: release"
commit: "chore: release"
env:
-23
View File
@@ -1,23 +0,0 @@
{
"$schema": "./node_modules/oxfmt/configuration_schema.json",
"sortImports": {},
"sortTailwindcss": {
"stylesheet": "apps/web/src/styles.css",
"functions": ["clsx", "cn", "cva", "tw"],
"preserveDuplicates": false,
"preserveWhitespace": false
},
"sortPackageJson": true,
"ignorePatterns": [
"**/node_modules/**",
"**/dist/**",
"**/.output/**",
"**/.turbo/**",
"**/.vercel/**",
"**/routeTree.gen.ts",
"**/coverage/**",
"apps/web/public/registry/**",
"registry/modules/*/logo*.svg",
"registry/modules/*/templates/**"
]
}
-53
View File
@@ -1,53 +0,0 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["oxc", "eslint", "typescript", "unicorn", "import", "promise", "vitest"],
"categories": {
"correctness": "error",
"suspicious": "warn",
"perf": "warn"
},
"rules": {
"no-console": "off",
"no-empty": ["error", { "allowEmptyCatch": true }],
"no-await-in-loop": "off",
"unicorn/no-null": "off",
"unicorn/filename-case": "off",
"unicorn/no-array-reduce": "off"
},
"ignorePatterns": [
"**/node_modules/**",
"**/dist/**",
"**/.output/**",
"**/.turbo/**",
"**/.vercel/**",
"**/*.gen.ts",
"**/coverage/**",
"registry/modules/*/templates/**"
],
"overrides": [
{
"files": ["**/*.test.ts", "**/*.test.tsx", "**/tests/**"],
"rules": {
"typescript/no-explicit-any": "off"
}
},
{
"files": ["apps/web/**/*.tsx"],
"plugins": [
"oxc",
"eslint",
"typescript",
"react",
"react-perf",
"promise",
"jsx-a11y",
"unicorn",
"import",
"vitest"
],
"rules": {
"react/react-in-jsx-scope": "off"
}
}
]
}
+1
View File
@@ -0,0 +1 @@
vp staged
+1 -6
View File
@@ -1,8 +1,3 @@
{
"recommendations": [
"bradlc.vscode-tailwindcss",
"oxc.oxc-vscode",
"vercel.turbo-vsc",
"vitest.explorer"
]
"recommendations": ["VoidZero.vite-plus-extension-pack"]
}
+2 -1
View File
@@ -35,5 +35,6 @@
},
"[css]": {
"editor.defaultFormatter": "oxc.oxc-vscode"
}
},
"oxc.fmt.configPath": "./vite.config.ts"
}
+17 -18
View File
@@ -1,6 +1,6 @@
# 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 [REGISTRY.md](REGISTRY.md) for the module roadmap and [TODO.md](TODO.md) for active work.
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](apps/web/content/docs/registry.mdx) for the module roadmap and [TODO.md](TODO.md) for active work.
Three things differentiate stanza from other scaffolders:
@@ -10,7 +10,7 @@ Three things differentiate stanza from other scaffolders:
## Layout
- `apps/cli/``stanza-cli`, node entrypoint at `src/bin.ts` (run via tsx in dev, tsdown-built to `dist/bin.mjs` for publish)
- `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)
@@ -20,19 +20,18 @@ Three things differentiate stanza from other scaffolders:
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` (`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.
## Commands
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`.
- `pnpm --filter stanza-cli dev -- <verb>` — run CLI directly via `tsx watch ./src/bin.ts`; no build step. Or `tsx apps/cli/src/bin.ts <verb>` for a one-shot run
- `pnpm --filter stanza-cli build` — build the publishable CLI via tsdown (compiles to ESM JS at `apps/cli/dist/`, externalizes npm deps, inlines workspace packages). Same for `create-stanza`
- `pnpm registry:build` — regenerate `dist/registry/{index,modules/*}.json`. Uses bun for maintainer convenience (the script body is portable; `tsx scripts/registry-build.ts` works too)
- `pnpm --filter @stanza/web dev` — TanStack Start dev server. `prebuild` invokes [`apps/web/scripts/prepare-registry.sh`](apps/web/scripts/prepare-registry.sh) which copies the built `dist/registry/` into `apps/web/public/registry/`. Since `dist/registry/` is gitignored, the script builds it first when it's missing (e.g. on Vercel's clean checkout) — preferring `bun` locally and falling back to `pnpm exec tsx` on 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 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
- `pnpm 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 turbo
- `cd apps/web && node_modules/.bin/vite build` — generates `src/routeTree.gen.ts` (required before first typecheck)
- `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 registry:build` — regenerate `dist/registry/{index,modules/*}.json` (script body is `bun scripts/registry-build.ts`; portable, `tsx scripts/registry-build.ts` works too)
- `vp run @stanza/web#dev` — TanStack Start dev server. `prebuild` invokes [`apps/web/scripts/prepare-registry.sh`](apps/web/scripts/prepare-registry.sh) which copies the built `dist/registry/` into `apps/web/public/registry/`. Since `dist/registry/` is gitignored, the script builds it first when it's missing (e.g. on Vercel's clean checkout) — preferring `bun` locally and falling back to `pnpm exec tsx` on 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 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`). **`apps/web` is intentionally excluded** from `projects` (a vite-plus alpha bug breaks its plugin-heavy SSR config under the workspace loader); its suite passes standalone via `cd apps/web && vp test`. Re-add it to `projects` once the upstream loader bug is fixed
- `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` — 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 use `pnpm pack` to inspect tarballs)
- `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
@@ -52,9 +51,9 @@ Core workflow:
## 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**: `tsdown` compiles each publishable package to ESM JS in `dist/`. 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 tsdown will inline them into the bundle
- **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 `pnpm 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`
- **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
@@ -69,7 +68,7 @@ Core workflow:
- **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 — verified by `vite build` followed by `grep shiki .output/public/assets/*.js` (should return nothing)
- **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 | styling | 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[]>>`); `cardinality: "one"` categories are kept to ≤ 1 record by `add`/`init` rejecting a second pick (the runner write itself is uniform). Read via `selectedOne(m, cat)` / `selectedAll(m, cat)`. 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
- **Adapter keys** encode peer choices (e.g., `next+drizzle`); the resolver picks the most specific match
@@ -104,5 +103,5 @@ Core workflow:
- 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 has _most_ but not all ESLint plugin rules — `prevent-abbreviations`, `react/jsx-uses-react`, etc. don't exist. Check `node_modules/oxlint/configuration_schema.json` if a rule name fails
- oxfmt auto-loads `.gitignore` and `.prettierignore`; we use `.oxfmtrc.json`'s `ignorePatterns` instead so we don't masquerade as a Prettier project
- 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
+6 -7
View File
@@ -3,11 +3,14 @@
Modular monorepo template CLI — aka shadcn for full-stack TypeScript projects.
```sh
npm init stanza my-app
npm init stanza my-revolutionary-app
```
Pick a framework, ORM, database, auth provider, and styling — get a clean monorepo with idiomatic code, vendored into your repo. Layer in more modules later with `stanza-cli add`.
> [!WARNING]
> 🚧 **Major work in progress!** See the [module registry](https://stanza.tools/docs/registry) for the module roadmap, and [TODO.md](./TODO.md) for active work.
## Why Stanza?
- **`add` works after `init`.** Run `stanza add` on an existing project — it's manifest-driven and peer-aware, so it picks the right adapter for your stack and wires deps, env, and templates into the correct workspace package.
@@ -44,15 +47,11 @@ apps/
packages/
registry/ # shared schema, slot/peer/capability resolver
codemods/ # ts-morph helpers for region-aware patching
create-stanza/ # `pnpm create stanza` shim
create-stanza/ # `npm init stanza` template shim
registry/
modules/ # first-party modules (framework, orm, db, auth, styling, tooling, testing)
```
## Contributing
Work in progress. See [CLAUDE.md](./CLAUDE.md) for architecture and toolchain invariants, [REGISTRY.md](./REGISTRY.md) for the module roadmap, and [TODO.md](./TODO.md) for active work.
## License
MIT — see [LICENSE](./LICENSE).
[MIT](./LICENSE)
-145
View File
@@ -1,145 +0,0 @@
# First-party module registry
This is the canonical roadmap for the first-party modules stanza ships. Each
entry maps to a `registry/modules/<category>-<id>/` directory. Update this file
when a module lands, gets renamed, or is dropped.
Legend: `[x]` added · `[ ]` planned
## Categories
Every module fills exactly one **category**. A category has two independent,
explicit properties (`CATEGORIES` in [`module.ts`](packages/registry/src/module.ts)):
- **`cardinality`** — `"one"` (single-choice: framework, auth) or `"many"`
(coexisting: testing, deploy). This is the only thing that decides single- vs
multi-select in the wizard/web and length in the manifest.
- **`home`** — where the module's output lands: `app` (manifest.appDir), `repo`
(monorepo root), or `package` (`packages/<dir>/`).
Constraint-bearing is **emergent**, not a category property: a category is a
peer candidate only if some module declares `peers`/`match` against it, and the
resolver only treats `cardinality: "one"` categories as peers (`PEER_CATEGORIES`).
| Cardinality | Categories | Examples |
| ------------------ | ------------------------------------------------------------------------------------- | ----------------------------------------- |
| **one** (single) | `framework`, `styling`, `db`, `orm`, `auth`, `tooling`, `api`, `ai`, `ui`, `payments` | next vs tanstack-start; drizzle vs prisma |
| **many** (coexist) | `testing`, `deploy`, `email`, `monorepo` | vitest + playwright together |
A `Module` carries a single `category` field (no `kind`/`slot`/`category`
discriminator). The manifest stores everything in one `modules` record keyed by
category, each holding an array (`Partial<Record<CategoryId,
StanzaModuleRecord[]>>`); `cardinality: "one"` categories are kept to ≤ 1 record
at install time. `selectedOne`/`selectedAll` read it ergonomically.
## framework
- [x] **tanstack-start** — TanStack Start on Vite (no Vinxi). Provides `web`, `react`, `ssr`, `node`
- [x] **next** — Next.js 16 (App Router). Provides `web`, `react`, `ssr`, `rsc`, `node`, `edge`
- [ ] **nuxt** — Vue. Will require relaxing the React-implicit assumption in many peer modules (capability tag `vue`)
- [ ] **svelte** — SvelteKit. Capability `svelte`
- [ ] **solid** — SolidStart. Capability `solid`
## api
_New **slot** (single-choice, constraint-bearing)._ Optional layer between the framework and the database/services.
- [ ] **trpc** — tRPC v11; per-framework adapters (next, tanstack-start, nuxt, svelte, solid)
- [ ] **orpc** — oRPC
## ai
_New **slot** (single-choice, constraint-bearing)._
- [ ] **vercel-ai-sdk**`ai` package + provider sub-recipes
- [ ] **tanstack-ai** — TanStack AI
## auth
- [x] **better-auth** — headless, peers `orm: [drizzle, prisma]`, `framework: [next, tanstack-start]`
- [x] **clerk** — hosted UI, peers `framework: [next]` (TanStack Start adapter planned)
- [ ] **workos** — WorkOS AuthKit
## orm
- [x] **drizzle** — Drizzle ORM 0.45, peers `db: [postgres, sqlite]`
- [x] **prisma** — Prisma 7, peers `db: [postgres, sqlite]`
## db
- [x] **postgres**`postgres` driver 3.4.x
- [x] **sqlite**`better-sqlite3` 12.x
## styling
- [x] **tailwind** — Vanilla Tailwind v4, adapters per framework
- [ ] **shadcn-radix** — classic shadcn/ui (Radix primitives)
- [ ] **shadcn-base** — shadcn on react-base-ui
## payments
_New **slot** (single-choice, constraint-bearing)._ In addition to adding example files to the framework app, each of these have a plugin for better-auth -- add those too if better-auth is selected, either before or after this selection.
- [ ] **stripe** — Checkout Sessions + webhooks
- [ ] **polar** — Polar SDK
- [ ] **autumn**
- [ ] **dodo payments**
## email
_`cardinality: many`, app-scoped._
- [ ] **resend** — Resend SDK + React Email templates
## tooling
_`cardinality: one`, repo-scoped._ Lint/format toolchain — single-choice because the three toolchains are mutually exclusive substitutes. Bears no _outbound_ dispatch constraints (nothing peers on `tooling`) but consumes a `framework` peer where the config varies.
- [x] **eslint-prettier** — ESLint flat config + Prettier; per-framework adapters (next, tanstack-start)
- [x] **biome** — Biome (lint + format), framework-agnostic
- [x] **oxlint-oxfmt** — Oxlint + oxfmt, framework-agnostic
## testing
_`cardinality: many`, app-scoped._ Vitest and Playwright are independent and routinely coexist.
- [x] **vitest** — unit + integration; per-framework adapters (next, tanstack-start), `jsdom` + RTL, `test`/`test:watch` scripts
- [x] **playwright** — e2e; per-framework `webServer` (`next dev` / `vite dev`), `test:e2e`/`test:e2e:ui` scripts (disjoint from vitest's)
## deploy
_`cardinality: many`, repo-scoped._
- [ ] **vercel**`vercel.json` + framework-specific output, add the nitro vite plugin for tanstack start
- [ ] **cloudflare** — Workers / Pages adapter per framework
- [ ] **railway**`railway.toml` + Dockerfile
- [ ] **docker** — generic `Dockerfile` + compose for self-host
## monorepo
_`cardinality: many`, repo-scoped. Currently hardcoded in `bootstrapShell` as Turborepo; becomes a real category when a second option (Nx, Moonrepo) lands._
- [x] **turborepo** — Turbo 2.x (current default; not yet a configurable choice)
## packageManager
_Not a slot — a top-level field in `stanza.json` (`packageManager: "pnpm" | "bun" | "npm"`). Wizard prompts; codemods only touch `package.json`, never lockfiles._
- [x] pnpm (default)
- [x] bun
- [x] npm
- [ ] yarn — needs lockfile/workspace handling that differs from the others
## Install homes (package extraction)
A category's `home` places its modules' output in one of three places:
- **`app`** (`framework`, `styling`, `testing`, `email`) — files land in `manifest.appDir` (e.g. `apps/web/`). For categories that wire the app shell or test it.
- **`package`** (`auth`, `db`, `orm`) — files land in `packages/<dir>/`, named `@<manifest.name>/<dir>`, and the app gets a `workspace:*` dep. `db` and `orm` share a single `packages/db/` package so the ORM client sits next to the schema it queries.
- **`repo`** (`tooling`, `deploy`, `monorepo`) — config files land at the repo root and scripts/devDeps merge into the root `package.json`, because one config governs every workspace.
The mapping lives in the canonical [`CATEGORIES`](packages/registry/src/module.ts) array as the `home` tagged union. `categoryHome(id)` and `PACKAGE_DIRS` are derived views. When adding a category, pick the home that matches: data layer/payments → `package`; app shell/router → `app`; repo-wide tooling → `repo`.
## Adding a category
A **one-line edit**: append a `Category` entry to `CATEGORIES` with `{ id, label, description, cardinality, home }`. `CategoryId`, `KNOWN_CATEGORIES`, `PEER_CATEGORIES`, and `PACKAGE_DIRS` all derive from it — nothing else to keep in sync. Order is topological: a category must appear after every category it can peer on, and `many` (leaf) categories come last. Authoring a **module** sets a single `category` field on `defineModule`; the runner/CLI/web handle single- vs multi-choice off the category's `cardinality`. Existing `stanza.json` files don't break (new categories are optional).
+3 -3
View File
@@ -28,7 +28,7 @@ Session before that: pre-1.0 architectural cleanup landed.
- **A3**: deleted `Module.provides`/`Module.requires`/`Capability` — they were declared but never read by the resolver. Peer constraints already encode the same info more precisely.
- **A1**: hoisted module-level install fields (`dependencies`, `devDependencies`, `env`, `scripts`, `consumesPackages`). Adapters override per-key when needed. Better Auth's 6 adapters no longer duplicate `dependencies`, `env`, or workspace deps.
- **A4**: web app is now the canonical registry host. `prebuild` builds + copies the registry into `apps/web/public/registry/`; deployed Vercel output ships it; the CLI's default URL points at the same path.
- **A2 deferred**: the add-on schema (`manifest.addons[]` + `kind` discriminator) is intentionally postponed until the first real add-on module forces concrete design decisions. Categorization documented in [REGISTRY.md](REGISTRY.md).
- **A2 deferred**: the add-on schema (`manifest.addons[]` + `kind` discriminator) is intentionally postponed until the first real add-on module forces concrete design decisions. Categorization documented in the [module registry](apps/web/content/docs/registry.mdx).
- Cross-package wiring is now declared via module-level `consumesPackages` (was per-adapter `peerPackages`). `stanza add` composes modules across slots (verified: `framework next``db postgres``orm drizzle``auth better-auth` produces a working tree with the right adapter selection and the auth package depending on the db package). 49 unit tests pass. apps/web is a minimal Vite-native TanStack Start scaffold — needs the actual builder UI.
## Web app (apps/web) — priority
@@ -111,7 +111,7 @@ Functional but a few real issues to fix.
## Registry expansion
The full first-party module roadmap lives in [REGISTRY.md](REGISTRY.md). These are the schema/resolver changes needed before most of those modules can land.
The full first-party module roadmap lives in the [module registry](apps/web/content/docs/registry.mdx). These are the schema/resolver changes needed before most of those modules can land.
- [x] **Unified `Category` taxonomy** — collapsed the slot/add-on split into one `Category` concept with two orthogonal, explicit axes: `cardinality` (`"one" | "many"`) and `home` (`app | repo | package`, replacing `packageDir`+`repoScoped`). `Module` is no longer a discriminated union — it carries a single `category` field. The manifest unified to one `modules: Partial<Record<CategoryId, StanzaModuleRecord[]>>` (arrays everywhere; `"one"` enforced ≤1 by `add`/`init`), bumping `CURRENT_MANIFEST_VERSION` 0.1→0.2 (clean break, no migration). Constraint-bearing is now emergent — the resolver iterates `PEER_CATEGORIES` (the `"one"` ids) only. Deleted `isAddon`/`moduleGroup`/`SlotModule`/`AddonModule`/`slotLabel`/`addonLabel`/`groupLabel`/`SLOT_PACKAGE_DIR`/`SLOT_REPO_SCOPED`/`ADDON_PACKAGE_DIR`/`KNOWN_SLOTS`/`KNOWN_ADDONS`; added `CATEGORIES`/`KNOWN_CATEGORIES`/`PEER_CATEGORIES`/`PACKAGE_DIRS`/`categoryHome`/`categoryCardinality`/`isMulti`/`categoryLabel`/`selectedOne`/`selectedAll`. Routing decided once in `categoryHome`, shared by the CLI runner + web `synthesizePackageJsons`. Wired through resolver/runner/CLI (add/remove/init/list/wizard, unified `--<category>` flags) + web builder (cards render single- vs multi-select off `isMulti`). All 14 modules migrated `slot``category`
- [ ] Add new categories to `CATEGORIES`: `api`, `ai`, `ui`, `payments` (all `cardinality: one`). Decide topological order for the wizard prompts (api/ai after framework)
@@ -143,4 +143,4 @@ These are real future work but consciously deferred — don't pull them in oppor
- `stanza update` — pinned-version 3-way merge
- Third-party registry hosting — the spec exists implicitly; publish it formally later
- React Native / Expo modules — needs the `native` capability + cross-platform framework slot
- Additional first-party modules — full catalog tracked in [REGISTRY.md](REGISTRY.md); land the slot taxonomy changes above first
- Additional first-party modules — full catalog tracked in the [module registry](apps/web/content/docs/registry.mdx); land the slot taxonomy changes above first
+6 -8
View File
@@ -29,23 +29,21 @@
".": "./src/index.ts"
},
"publishConfig": {
"access": "public",
"bin": {
"stanza": "./dist/bin.mjs"
},
"main": "./dist/index.mjs",
"types": "./dist/index.d.mts",
"exports": {
".": {
"types": "./dist/index.d.mts",
"default": "./dist/index.mjs"
}
},
"access": "public"
"main": "./dist/index.mjs",
"types": "./dist/index.d.mts"
},
"scripts": {
"build": "tsdown",
"check-types": "tsc --noEmit",
"test": "vitest run"
"build": "vp pack"
},
"dependencies": {
"@clack/prompts": "^1.4.0",
@@ -60,8 +58,8 @@
"@stanza/registry": "workspace:*",
"@types/node": "^25.9.1",
"@types/semver": "^7.7.1",
"tsdown": "^0.22.0",
"typescript": "^6.0.3",
"vitest": "^4.1.7"
"vite-plus": "catalog:",
"vitest": "catalog:"
}
}
+9 -4
View File
@@ -1,6 +1,11 @@
import * as p from "@clack/prompts";
import type { CategoryId } from "@stanza/registry";
import { isMulti, KNOWN_CATEGORIES, resolveAdapter, selectedAll } from "@stanza/registry";
import {
isCategoryId,
isMulti,
KNOWN_CATEGORIES,
resolveAdapter,
selectedAll,
} from "@stanza/registry";
import { defineCommand } from "citty";
import pc from "picocolors";
@@ -30,12 +35,12 @@ export async function cmdAdd(args: CliArgs): Promise<void> {
return;
}
if (!(KNOWN_CATEGORIES as readonly string[]).includes(slot)) {
if (!isCategoryId(slot)) {
p.log.error(`Unknown category: ${slot}. Categories: ${KNOWN_CATEGORIES.join(", ")}`);
process.exitCode = 1;
return;
}
const category = slot as CategoryId;
const category = slot;
const group = category;
const projectRoot = findProjectRoot();
+1 -1
View File
@@ -2,7 +2,7 @@ import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { afterEach, beforeEach, describe, expect, it } from "vite-plus/test";
import { cmdAdd } from "./add";
import { cmdInit } from "./init";
+4 -4
View File
@@ -3,8 +3,8 @@ import path from "node:path";
import * as p from "@clack/prompts";
import { removePackageDependency, removeEnvVar } from "@stanza/codemods";
import type { CategoryId, StanzaModuleRecord } from "@stanza/registry";
import { isMulti, KNOWN_CATEGORIES, PACKAGE_DIRS, selectedAll } from "@stanza/registry";
import type { StanzaModuleRecord } from "@stanza/registry";
import { isCategoryId, isMulti, PACKAGE_DIRS, selectedAll } from "@stanza/registry";
import { defineCommand } from "citty";
import pc from "picocolors";
@@ -41,12 +41,12 @@ export async function cmdRemove(args: CliArgs): Promise<void> {
process.exitCode = 1;
return;
}
if (!(KNOWN_CATEGORIES as readonly string[]).includes(slot)) {
if (!isCategoryId(slot)) {
p.log.error(`Unknown category: ${slot}`);
process.exitCode = 1;
return;
}
const category = slot as CategoryId;
const category = slot;
const group = category;
const projectRoot = findProjectRoot();
+9 -7
View File
@@ -344,9 +344,9 @@ function ensureSlotPackage(args: {
// Wire the workspace dep into the host app's package.json on first
// bootstrap. Not region-tracked — sweep cleans it up.
if (fs.existsSync(appPkgPath)) {
const appPkg = JSON.parse(fs.readFileSync(appPkgPath, "utf8")) as {
dependencies?: Record<string, string>;
};
const appPkg: { dependencies?: Record<string, string> } = JSON.parse(
fs.readFileSync(appPkgPath, "utf8"),
);
if (appPkg.dependencies?.[packageName] !== "workspace:*") {
created = true;
if (!dryRun) addPackageDependency(appPkgPath, packageName, "workspace:*");
@@ -363,9 +363,9 @@ function ensureSlotPackage(args: {
if (!PACKAGE_DIRS.has(peer)) continue;
const peerName = `@${args.manifest.name}/${peer}`;
if (!fs.existsSync(ownPkgJson)) continue;
const pkg = JSON.parse(fs.readFileSync(ownPkgJson, "utf8")) as {
dependencies?: Record<string, string>;
};
const pkg: { dependencies?: Record<string, string> } = JSON.parse(
fs.readFileSync(ownPkgJson, "utf8"),
);
if (pkg.dependencies?.[peerName] !== "workspace:*") {
created = true;
if (!dryRun) addPackageDependency(ownPkgJson, peerName, "workspace:*");
@@ -396,7 +396,9 @@ function renderArgs(
}
return value;
};
return visit(args) as Record<string, JsonValue>;
const out: Record<string, JsonValue> = {};
for (const [k, v] of Object.entries(args)) out[k] = visit(v);
return out;
}
/**
+1 -1
View File
@@ -1,4 +1,4 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
import { clearVersionCacheForTests, resolveRange, resolveRanges } from "./npm-version";
+7 -2
View File
@@ -6,6 +6,10 @@ const NPM_REGISTRY = process.env.STANZA_NPM_REGISTRY ?? "https://registry.npmjs.
// the CLI process so a multi-module `init` hits npm at most once per package.
const cache = new Map<string, string[] | null>();
function isObject(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
/** `"^"`/`"~"` for shapes we re-resolve; `null` means leave the range verbatim. */
function rangeModifier(range: string): "^" | "~" | null {
const t = range.trim();
@@ -32,8 +36,9 @@ async function fetchVersions(name: string): Promise<string[] | null> {
cache.set(name, null);
return null;
}
const body = (await res.json()) as { versions?: Record<string, unknown> };
const versions = Object.keys(body.versions ?? {});
const body: unknown = await res.json();
const versionsMap = isObject(body) && isObject(body.versions) ? body.versions : {};
const versions = Object.keys(versionsMap);
cache.set(name, versions);
return versions;
} catch {
+4 -4
View File
@@ -3,7 +3,7 @@ import path from "node:path";
import { fileURLToPath } from "node:url";
import type { Module, RegistryIndex } from "@stanza/registry";
import { CATEGORIES } from "@stanza/registry";
import { CATEGORIES, ModuleSchema, RegistryIndexSchema } from "@stanza/registry";
/**
* In dev (when running from the stanza monorepo), modules are imported
@@ -111,7 +111,7 @@ async function loadHttpRegistry(baseUrl: string): Promise<Registry> {
if (!indexRes.ok) {
throw new Error(`Failed to load stanza registry from ${baseUrl}: ${indexRes.status}`);
}
const index = (await indexRes.json()) as RegistryIndex;
const index = RegistryIndexSchema.parse(await indexRes.json());
return {
index,
@@ -119,14 +119,14 @@ async function loadHttpRegistry(baseUrl: string): Promise<Registry> {
const url = `${baseUrl}/modules/${slot}-${id}.json`;
const res = await fetch(url);
if (!res.ok) throw new Error(`Module fetch failed: ${url} (${res.status})`);
return (await res.json()) as Module;
return ModuleSchema.parse(await res.json());
},
};
}
async function importModule(modulesDir: string, dirName: string): Promise<Module> {
const entry = path.join(modulesDir, dirName, "module.ts");
const mod = (await import(entry)) as { default: Module };
const mod: { default: Module } = await import(entry);
if (!mod.default) {
throw new Error(`Module ${dirName} has no default export at ${entry}`);
}
+13 -14
View File
@@ -1,5 +1,5 @@
import * as p from "@clack/prompts";
import type { CategoryId, Module, RegistryIndex } from "@stanza/registry";
import type { CategoryId, Module, PackageManager, RegistryIndex } from "@stanza/registry";
import {
categoryLabel,
categoryOrder,
@@ -81,7 +81,7 @@ export async function runInitWizard(args: {
p.cancel("Cancelled.");
return null;
}
const ids = picks as string[];
const ids = picks;
if (ids.length === 0) continue;
selections[category] = await Promise.all(ids.map((id) => registry.loadModule(category, id)));
} else {
@@ -95,13 +95,13 @@ export async function runInitWizard(args: {
return null;
}
if (choice === "__skip__") continue;
const full = await registry.loadModule(category, choice as string);
const full = await registry.loadModule(category, choice);
selections[category] = [full];
pending[category] = full;
}
}
const pmChoice = await p.select({
const pmChoice = await p.select<PackageManager>({
message: "Package manager?",
options: [
{ value: "pnpm", label: "pnpm", hint: "Recommended" },
@@ -116,16 +116,15 @@ export async function runInitWizard(args: {
}
// Summary screen — what we're about to write.
const rows = (Object.entries(selections) as [CategoryId, Module[]][]).flatMap(
([category, mods]) =>
mods.map(
(mod) =>
`${pc.bold(categoryLabel(category).padEnd(16))} ${mod.label} ${pc.dim(`(${mod.id})`)}`,
),
const rows = KNOWN_CATEGORIES.flatMap((category) =>
(selections[category] ?? []).map(
(mod) =>
`${pc.bold(categoryLabel(category).padEnd(16))} ${mod.label} ${pc.dim(`(${mod.id})`)}`,
),
);
const summary = [
`${pc.bold("Name:")} ${String(name)}`,
`${pc.bold("Package manager:")} ${String(pmChoice)}`,
`${pc.bold("Name:")} ${name}`,
`${pc.bold("Package manager:")} ${pmChoice}`,
"",
...rows,
].join("\n");
@@ -138,9 +137,9 @@ export async function runInitWizard(args: {
}
return {
name: String(name),
name,
appDir: "apps/web",
packageManager: pmChoice as "pnpm" | "bun" | "npm",
packageManager: pmChoice,
selections,
};
}
-11
View File
@@ -1,11 +0,0 @@
import { defineConfig } from "tsdown";
export default defineConfig({
entry: ["./src/bin.ts", "./src/index.ts"],
format: "esm",
target: "node22",
platform: "node",
deps: { alwaysBundle: [/^@stanza\//] },
dts: true,
clean: true,
});
+13
View File
@@ -0,0 +1,13 @@
import { defineConfig } from "vite-plus";
export default defineConfig({
pack: {
entry: ["./src/bin.ts", "./src/index.ts"],
format: "esm",
target: "node22",
platform: "node",
deps: { alwaysBundle: [/^@stanza\//] },
dts: true,
clean: true,
},
});
-3
View File
@@ -1,3 +0,0 @@
import { defineConfig } from "vitest/config";
export default defineConfig({});
@@ -70,9 +70,9 @@ stanza search [query]
Use the printed **id** (not the display label) when passing a module to `add`.
<Callout type="info">
**Planned:** `swap` (replace a module with another in the same category) and
`update` (re-pull a module at a newer version) are on the roadmap. The manifest
already reserves the fields they need; the verbs aren't implemented yet.
**Planned:** `swap` (replace a module with another in the same category) and `update` (re-pull a
module at a newer version) are on the roadmap. The manifest already reserves the fields they need;
the verbs aren't implemented yet.
</Callout>
## Global flags
+15 -14
View File
@@ -14,15 +14,15 @@ properties that govern how its modules behave:
- **Cardinality** — `one` (single-choice) or `many` (coexisting).
- **Home** — where a module's output lands: `app`, `repo`, or `package`.
| Category | Cardinality | Home |
| ----------- | ----------- | --------- |
| `framework` | one | app |
| `styling` | one | app |
| `db` | one | package |
| `orm` | one | package |
| `auth` | one | package |
| `tooling` | one | repo |
| `testing` | many | app |
| Category | Cardinality | Home |
| ----------- | ----------- | ------- |
| `framework` | one | app |
| `styling` | one | app |
| `db` | one | package |
| `orm` | one | package |
| `auth` | one | package |
| `tooling` | one | repo |
| `testing` | many | app |
A `one` category holds at most one module — adding a second fails until you
remove the first. A `many` category lets modules coexist (e.g. Vitest and
@@ -36,10 +36,10 @@ share a single `packages/db/` so the ORM client sits next to the schema it
queries.
<Callout type="info">
**Planned:** more categories are on the roadmap — `api`, `ai`, `payments`
(single-choice) and `email`, `deploy` (coexisting). They aren't shipping yet,
but the taxonomy is designed to absorb them without breaking existing
projects.
**Planned:** more categories are on the roadmap — `api`, `ai`, `payments`, `email` (single-choice)
and `deploy` (coexisting). They aren't shipping yet, but the taxonomy is designed to absorb them
without breaking existing projects. See the [module registry](/docs/registry) for the full
roadmap.
</Callout>
## Vendoring
@@ -55,7 +55,8 @@ Modules declare **peers** — the other categories they care about. When you `ad
a module, stanza reads your current selections and picks the **adapter** that
matches. Better Auth, for instance, peers on both `framework` and `orm`, so
adding it to a Next + Drizzle app produces different wiring than a TanStack Start
+ Prisma app. You pick the module; stanza picks the right variant.
- Prisma app. You pick the module; stanza picks the right variant.
## The manifest (`stanza.json`)
+1 -1
View File
@@ -34,6 +34,6 @@ and out without you hand-editing the wiring.
- **[Getting started](/docs/getting-started)** — create your first project and add a module.
- **[Concepts](/docs/concepts)** — the mental model: categories, vendoring, peers, and the manifest.
- **[CLI reference](/docs/cli-reference)** — every verb, flag, and environment variable.
- **[CLI reference](/docs/cli)** — every verb, flag, and environment variable.
Or skip ahead and assemble a stack visually with the [builder](/).
+1 -1
View File
@@ -1,3 +1,3 @@
{
"pages": ["index", "getting-started", "concepts", "cli-reference"]
"pages": ["index", "getting-started", "concepts", "cli", "registry"]
}
+175
View File
@@ -0,0 +1,175 @@
---
title: Module registry
description: The roadmap of first-party modules stanza ships — what's available today and what's planned.
---
Every module fills exactly one **category**. This page is the canonical roadmap
of the first-party modules stanza ships: what's available today, and what's on
the way. Self-hosted registries can add their own modules — see
[the open registry](/docs/concepts#open-registry).
## Categories
A category has two independent properties: its **cardinality** — `one`
(single-choice, like `framework` or `auth`) or `many` (coexisting, like
`testing`) — and its **home**, where a module's output lands (`app`, `repo`, or
`package`). See [Concepts](/docs/concepts#categories) for the full mental model.
| Category | Cardinality | Home | What it covers |
| ----------- | ----------- | ------- | ---------------------------------------------- |
| `framework` | one | app | App shell + router (Next, TanStack Start, …) |
| `api` | one | package | Typed RPC layer between framework and services |
| `ai` | one | package | AI SDK + provider wiring |
| `auth` | one | package | Authentication (Better Auth, Clerk, …) |
| `orm` | one | package | Schema + query layer (Drizzle, Prisma) |
| `db` | one | package | Database driver (Postgres, SQLite) |
| `styling` | one | app | CSS toolchain (Tailwind, shadcn, …) |
| `payments` | one | package | Checkout + webhooks (Stripe, Polar, …) |
| `tooling` | one | repo | Lint/format toolchain |
| `email` | one | package | Transactional email (Resend) |
| `testing` | many | app | Unit + e2e (Vitest, Playwright) |
| `deploy` | many | repo | Deploy targets (Vercel, Cloudflare, …) |
| `monorepo` | many | repo | Monorepo task runner (Turborepo) |
## framework
Single-choice. Provides the app shell, router, and React (or future) runtime.
| Module | Status | Notes |
| ---------------- | --------- | ---------------------------------------------------------------------------------------- |
| `tanstack-start` | Available | TanStack Start on Vite (no Vinxi). Provides `web`, `react`, `ssr`, `node`. |
| `next` | Available | Next.js 16 (App Router). Provides `web`, `react`, `ssr`, `rsc`, `node`, `edge`. |
| `nuxt` | Planned | Vue. Requires relaxing the React-implicit assumption in peer modules (capability `vue`). |
| `svelte` | Planned | SvelteKit (capability `svelte`). |
| `solid` | Planned | SolidStart (capability `solid`). |
## api
Single-choice, constraint-bearing. An optional layer between the framework and
the database or services, installed as its own workspace package.
| Module | Status | Notes |
| ------ | ------- | ---------------------------------------------------------------------- |
| `trpc` | Planned | tRPC v11; per-framework adapters (next, tanstack-start, nuxt, svelte). |
| `orpc` | Planned | oRPC. |
## ai
Single-choice, constraint-bearing. Installs into its own workspace package.
| Module | Status | Notes |
| --------------- | ------- | ------------------------------------ |
| `vercel-ai-sdk` | Planned | `ai` package + provider sub-recipes. |
| `tanstack-ai` | Planned | TanStack AI. |
## auth
Single-choice, installs into `packages/auth/`.
| Module | Status | Notes |
| ------------- | --------- | ---------------------------------------------------------------------------------- |
| `better-auth` | Available | Headless. Peers on `orm` (drizzle, prisma) and `framework` (next, tanstack-start). |
| `clerk` | Available | Hosted UI. Peers on `framework` (next); TanStack Start adapter planned. |
| `workos` | Planned | WorkOS AuthKit. |
## orm
Single-choice, shares `packages/db/` with the database driver.
| Module | Status | Notes |
| --------- | --------- | --------------------------------------------------- |
| `drizzle` | Available | Drizzle ORM 0.45. Peers on `db` (postgres, sqlite). |
| `prisma` | Available | Prisma 7. Peers on `db` (postgres, sqlite). |
## db
Single-choice, installs into `packages/db/`.
| Module | Status | Notes |
| ---------- | --------- | ------------------------ |
| `postgres` | Available | `postgres` driver 3.4.x. |
| `sqlite` | Available | `better-sqlite3` 12.x. |
## styling
Single-choice.
| Module | Status | Notes |
| -------------- | --------- | -------------------------------------------- |
| `tailwind` | Available | Vanilla Tailwind v4, adapters per framework. |
| `shadcn-radix` | Planned | Classic shadcn/ui (Radix primitives). |
| `shadcn-base` | Planned | shadcn on react-base-ui. |
## payments
Single-choice, constraint-bearing. Installs into its own workspace package, plus
a Better Auth plugin when auth is selected.
| Module | Status | Notes |
| -------- | ------- | ----------------------------- |
| `stripe` | Planned | Checkout Sessions + webhooks. |
| `polar` | Planned | Polar SDK. |
| `autumn` | Planned | Autumn. |
| `dodo` | Planned | Dodo Payments. |
## email
Single-choice, installs into its own workspace package.
| Module | Status | Notes |
| -------- | ------- | ----------------------------------- |
| `resend` | Planned | Resend SDK + React Email templates. |
## tooling
Single-choice, repo-scoped. The three toolchains are mutually exclusive
substitutes.
| Module | Status | Notes |
| ----------------- | --------- | ------------------------------------------------------ |
| `eslint-prettier` | Available | ESLint flat config + Prettier; per-framework adapters. |
| `biome` | Available | Biome (lint + format), framework-agnostic. |
| `oxlint-oxfmt` | Available | Oxlint + oxfmt, framework-agnostic. |
## testing
Coexisting, app-scoped. Vitest and Playwright are independent and routinely run
side by side.
| Module | Status | Notes |
| ------------ | --------- | ----------------------------------------------------------------- |
| `vitest` | Available | Unit + integration; `jsdom` + RTL; `test`/`test:watch` scripts. |
| `playwright` | Available | e2e; per-framework `webServer`; `test:e2e`/`test:e2e:ui` scripts. |
## deploy
Coexisting, repo-scoped.
| Module | Status | Notes |
| ------------ | ------- | --------------------------------------------- |
| `vercel` | Planned | `vercel.json` + framework-specific output. |
| `cloudflare` | Planned | Workers / Pages adapter per framework. |
| `railway` | Planned | `railway.toml` + Dockerfile. |
| `docker` | Planned | Generic `Dockerfile` + compose for self-host. |
## monorepo
Coexisting, repo-scoped. Currently hardcoded as Turborepo; becomes a real
configurable category when a second option (Nx, Moonrepo) lands.
| Module | Status | Notes |
| ----------- | --------- | ---------------------------------------------- |
| `turborepo` | Available | Turbo 2.x (current default; not yet a choice). |
## Package manager
Not a category — a top-level field in `stanza.json`
(`packageManager: "pnpm" | "bun" | "npm"`). The wizard prompts for it; codemods
only ever touch `package.json`, never lockfiles.
| Manager | Status |
| ------- | ---------------------------------------------------- |
| pnpm | Available (default) |
| bun | Available |
| npm | Available |
| yarn | Planned (needs distinct lockfile/workspace handling) |
+7 -8
View File
@@ -5,13 +5,11 @@
"license": "MIT",
"type": "module",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"predev": "./scripts/prepare-registry.sh",
"build": "vp build",
"dev": "vp dev",
"prebuild": "./scripts/prepare-registry.sh",
"start": "node .output/server/index.mjs",
"check-types": "tsc --noEmit",
"test": "vitest run"
"predev": "./scripts/prepare-registry.sh",
"start": "node .output/server/index.mjs"
},
"dependencies": {
"@base-ui/react": "^1.5.0",
@@ -61,8 +59,9 @@
"@vitejs/plugin-react": "^6.0.2",
"jsdom": "^29.1.1",
"typescript": "^6.0.3",
"vite": "^8.0.14",
"vitest": "^4.1.7",
"vite": "catalog:",
"vite-plus": "catalog:",
"vitest": "catalog:",
"web-vitals": "^5.2.0"
}
}
@@ -27,7 +27,7 @@ export function ProjectSetup({
}, [name]);
useEffect(() => {
if (draft === name) return;
if (draft === name) return undefined;
const timer = setTimeout(() => onNameChangeRef.current(draft), 300);
return () => clearTimeout(timer);
}, [draft, name]);
@@ -175,5 +175,8 @@ function describeError(error: ResolveError): string {
return `Doesn't pair with ${error.peer} (your ${categoryLabel(error.category)} pick).`;
case "no-adapter":
return "No adapter matches your current stack.";
default:
error satisfies never;
throw new Error(`Unknown resolve error: ${String(error)}`);
}
}
+1 -1
View File
@@ -44,7 +44,7 @@ export function CommandPreview({
value={command}
showLabel={false}
copyLabel="Copy command"
className="flex-1 [font-variant-ligatures:none]"
className="flex-1"
onCopy={onCopy}
/>
</div>
@@ -1,5 +1,5 @@
import type { CategoryId, ModuleSummary, RegistryIndex } from "@stanza/registry";
import { categoryLabel } from "@stanza/registry";
import { categoryLabel, KNOWN_CATEGORIES } from "@stanza/registry";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
@@ -19,9 +19,10 @@ export function AdapterSwitcher({
resolvedPeers: Partial<Record<CategoryId, string>>;
onChange: (category: CategoryId, id: string) => void;
}) {
const switchable = (Object.entries(peerOptions) as [CategoryId, string[]][]).filter(
([, opts]) => opts.length > 1,
);
const switchable = KNOWN_CATEGORIES.flatMap((category): [CategoryId, string[]][] => {
const opts = peerOptions[category];
return opts && opts.length > 1 ? [[category, opts]] : [];
});
if (switchable.length === 0) return null;
return (
@@ -9,7 +9,7 @@ import {
DropdownMenuRadioItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { PACKAGE_MANAGERS, type PackageManager } from "@/lib/package-manager";
import { isPackageManager, PACKAGE_MANAGERS, type PackageManager } from "@/lib/package-manager";
function NpmLogo(props: ComponentProps<"svg">) {
return (
@@ -84,7 +84,9 @@ export function PackageManagerSelect({
<DropdownMenuContent align="start">
<DropdownMenuRadioGroup
value={value}
onValueChange={(next) => onValueChange(next as PackageManager)}
onValueChange={(next) => {
if (isPackageManager(next)) onValueChange(next);
}}
>
{PACKAGE_MANAGERS.map((pm) => (
<DropdownMenuRadioItem
+1 -1
View File
@@ -1,4 +1,4 @@
import posthog from "posthog-js";
import { posthog } from "posthog-js";
import { PostHogProvider as PostHogReactProvider } from "posthog-js/react";
/**
+1 -1
View File
@@ -69,7 +69,7 @@ export function ThemeProvider({
}, [theme, mounted]);
useEffect(() => {
if (!mounted || theme !== "system") return;
if (!mounted || theme !== "system") return undefined;
const media = window.matchMedia("(prefers-color-scheme: dark)");
const onChange = () => setResolvedTheme(applyTheme("system"));
media.addEventListener("change", onChange);
+1 -1
View File
@@ -59,7 +59,7 @@ function InputGroupAddon({
data-align={align}
className={cn(inputGroupAddonVariants({ align }), className)}
onClick={(e) => {
if ((e.target as HTMLElement).closest("button")) {
if (e.target instanceof HTMLElement && e.target.closest("button")) {
return;
}
e.currentTarget.parentElement?.querySelector("input")?.focus();
+4 -1
View File
@@ -17,6 +17,9 @@ const ICONS = {
loading: <IconLoader className="size-4 animate-spin" />,
};
// CSS custom properties aren't representable in React.CSSProperties (csstype
// has no `--*` index signature), so the cast is the standard escape hatch.
// oxlint-disable-next-line typescript/no-unsafe-type-assertion
const STYLE = {
"--normal-bg": "var(--popover)",
"--normal-text": "var(--popover-foreground)",
@@ -31,7 +34,7 @@ const TOAST_OPTIONS = {
};
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme();
const { resolvedTheme: theme } = useTheme();
return (
<Sonner
@@ -38,6 +38,8 @@ function ToggleGroup({
data-size={size}
data-spacing={spacing}
data-orientation={orientation}
// CSS custom property — not representable in React.CSSProperties (csstype).
// oxlint-disable-next-line typescript/no-unsafe-type-assertion
style={{ "--gap": spacing } as React.CSSProperties}
className={cn(
"group/toggle-group flex w-fit flex-row items-center gap-[--spacing(var(--gap))] rounded-none data-[size=sm]:rounded-none data-vertical:flex-col data-vertical:items-stretch",
-313
View File
@@ -1,313 +0,0 @@
import { defineModule, type Module } from "@stanza/registry";
import { describe, expect, it } from "vitest";
import {
buildCommand,
DEFAULT_NAME,
parseSelections,
pruneUnresolved,
resolveSelected,
selectedFiles,
toSearchParams,
} from "./selection";
describe("parseSelections", () => {
it("returns defaults when search is empty", () => {
const { name, pm, selections } = parseSelections({});
expect(name).toBe(DEFAULT_NAME);
expect(pm).toBe("pnpm");
expect(selections).toEqual({});
});
it("parses a recognized package manager and falls back to pnpm otherwise", () => {
expect(parseSelections({ pm: "npm" }).pm).toBe("npm");
expect(parseSelections({ pm: "bun" }).pm).toBe("bun");
expect(parseSelections({ pm: "yarn" }).pm).toBe("pnpm");
expect(parseSelections({ pm: "" }).pm).toBe("pnpm");
});
it("preserves recognized category keys and ignores unknown keys", () => {
const { name, selections } = parseSelections({
name: "my-thing",
framework: "next",
orm: "drizzle",
// @ts-expect-error unknown category — should be silently dropped
flooble: "ignored",
});
expect(name).toBe("my-thing");
expect(selections).toEqual({ framework: ["next"], orm: ["drizzle"] });
});
it("splits comma-joined multi-choice categories into id lists", () => {
const { selections } = parseSelections({ testing: "vitest,playwright" });
expect(selections.testing).toEqual(["vitest", "playwright"]);
});
it("drops empty category values", () => {
const { selections } = parseSelections({ framework: "" });
expect(selections.framework).toBeUndefined();
});
});
describe("toSearchParams", () => {
it("round-trips a populated state", () => {
const input = {
name: "my-app",
pm: "pnpm" as const,
selections: { framework: ["next"], orm: ["drizzle"] },
};
const search = toSearchParams(input);
expect(parseSelections(search)).toEqual(input);
});
it("round-trips multi-choice selections as comma-joined params", () => {
const input = {
name: DEFAULT_NAME,
pm: "pnpm" as const,
selections: { framework: ["next"], testing: ["vitest", "playwright"] },
};
const search = toSearchParams(input);
expect(search.testing).toBe("vitest,playwright");
expect(parseSelections(search)).toEqual(input);
});
it("omits the default name and package manager to keep the URL terse", () => {
expect(toSearchParams({ name: DEFAULT_NAME, pm: "pnpm", selections: {} })).toEqual({});
});
it("emits a non-default package manager", () => {
expect(toSearchParams({ name: DEFAULT_NAME, pm: "npm", selections: {} })).toEqual({
pm: "npm",
});
});
});
describe("buildCommand", () => {
it("emits only the selected flags", () => {
expect(
buildCommand({
name: "my-app",
selections: { framework: ["next"], db: ["sqlite"] },
}),
).toBe("pnpm create stanza my-app --framework=next --db=sqlite");
});
it("emits multi-choice flags as comma-joined ids", () => {
expect(
buildCommand({
name: "my-app",
selections: { framework: ["next"], testing: ["vitest", "playwright"] },
}),
).toBe("pnpm create stanza my-app --framework=next --testing=vitest,playwright");
});
it("keeps the bare command when nothing is selected", () => {
expect(buildCommand({ name: "my-app", selections: {} })).toBe("pnpm create stanza my-app");
});
it("uses the chosen package manager as the prefix", () => {
expect(buildCommand({ name: "my-app", selections: { framework: ["next"] }, pm: "bun" })).toBe(
"bun create stanza my-app --framework=next",
);
});
it("inserts a -- separator before flags for npm", () => {
expect(buildCommand({ name: "my-app", selections: { framework: ["next"] }, pm: "npm" })).toBe(
"npm create stanza my-app -- --framework=next",
);
});
it("omits the -- separator for npm when there are no flags", () => {
expect(buildCommand({ name: "my-app", selections: {}, pm: "npm" })).toBe(
"npm create stanza my-app",
);
});
});
describe("selectedFiles", () => {
const drizzle: Module = defineModule({
id: "drizzle",
category: "orm",
label: "Drizzle",
description: "",
version: "0.1.0",
adapters: [
{
key: "default",
match: {},
templates: [
{ src: "schema.ts", dest: "src/index.ts", scope: "package" },
{ src: "drizzle.config.ts", dest: "drizzle.config.ts", scope: "package" },
],
},
],
});
it("routes scope:package to packages/<dir> and scope:repo bare", () => {
const adapter = drizzle.adapters[0]!;
const adapterWithRepo = {
...adapter,
templates: [
...(adapter.templates ?? []),
{ src: "turbo.json", dest: "turbo.json", scope: "repo" as const },
],
};
const files = selectedFiles({
orm: [{ module: drizzle, adapter: adapterWithRepo }],
});
expect(files.map((f) => f.path)).toEqual([
"packages/db/src/index.ts",
"packages/db/drizzle.config.ts",
"turbo.json",
]);
});
it("groups files by category order", () => {
const framework: Module = defineModule({
id: "next",
category: "framework",
label: "Next.js",
description: "",
version: "0.1.0",
adapters: [
{
key: "default",
match: {},
templates: [{ src: "layout.tsx", dest: "app/layout.tsx", scope: "app" }],
},
],
});
const files = selectedFiles({
orm: [{ module: drizzle, adapter: drizzle.adapters[0]! }],
framework: [{ module: framework, adapter: framework.adapters[0]! }],
});
// framework comes before orm in categoryOrder, so its files appear first.
expect(files[0]!.path).toBe("apps/web/app/layout.tsx");
});
it("emits multi-choice templates after one-choice templates", () => {
const vitest: Module = defineModule({
id: "vitest",
category: "testing",
label: "Vitest",
description: "",
version: "0.1.0",
adapters: [
{
key: "default",
match: {},
templates: [{ src: "vitest.config.ts", dest: "vitest.config.ts", scope: "app" }],
},
],
});
const files = selectedFiles({
orm: [{ module: drizzle, adapter: drizzle.adapters[0]! }],
testing: [{ module: vitest, adapter: vitest.adapters[0]! }],
});
expect(files.at(-1)!.path).toBe("apps/web/vitest.config.ts");
expect(files.at(-1)!.owner.category).toBe("testing");
});
});
describe("resolveSelected", () => {
it("picks the adapter whose match aligns with active peers", () => {
const drizzleMod: Module = defineModule({
id: "drizzle",
category: "orm",
label: "Drizzle",
description: "",
version: "0.1.0",
peers: { db: ["postgres", "sqlite"] },
adapters: [
{ key: "postgres", match: { db: "postgres" } },
{ key: "sqlite", match: { db: "sqlite" } },
],
});
const postgresMod: Module = defineModule({
id: "postgres",
category: "db",
label: "Postgres",
description: "",
version: "0.1.0",
adapters: [{ key: "default", match: {} }],
});
const modules = {
"orm:drizzle": drizzleMod,
"db:postgres": postgresMod,
};
const out = resolveSelected(modules, { orm: ["drizzle"], db: ["postgres"] });
expect(out.orm?.[0]?.adapter.key).toBe("postgres");
expect(out.db?.[0]?.adapter.key).toBe("default");
});
});
describe("pruneUnresolved", () => {
const postgres: Module = defineModule({
id: "postgres",
category: "db",
label: "Postgres",
description: "",
version: "0.1.0",
adapters: [{ key: "default", match: {} }],
});
const drizzle: Module = defineModule({
id: "drizzle",
category: "orm",
label: "Drizzle",
description: "",
version: "0.1.0",
peers: { db: ["postgres"] },
adapters: [{ key: "postgres", match: { db: "postgres" } }],
});
const next: Module = defineModule({
id: "next",
category: "framework",
label: "Next.js",
description: "",
version: "0.1.0",
adapters: [{ key: "default", match: {} }],
});
const vitest: Module = defineModule({
id: "vitest",
category: "testing",
label: "Vitest",
description: "",
version: "0.1.0",
peers: { framework: ["next"] },
adapters: [{ key: "next", match: { framework: "next" } }],
});
const modules: Record<string, Module> = {
"db:postgres": postgres,
"orm:drizzle": drizzle,
"framework:next": next,
"testing:vitest": vitest,
};
it("drops a category whose peer is missing", () => {
expect(pruneUnresolved(modules, { orm: ["drizzle"] })).toEqual({});
});
it("keeps a category once its peer is present", () => {
expect(pruneUnresolved(modules, { orm: ["drizzle"], db: ["postgres"] })).toEqual({
orm: ["drizzle"],
db: ["postgres"],
});
});
it("drops a multi-choice module whose framework peer is missing", () => {
expect(pruneUnresolved(modules, { testing: ["vitest"] })).toEqual({});
});
it("keeps a multi-choice module once its framework peer is present", () => {
expect(pruneUnresolved(modules, { framework: ["next"], testing: ["vitest"] })).toEqual({
framework: ["next"],
testing: ["vitest"],
});
});
it("cascades: dropping db strands orm and its dependents in one call", () => {
expect(pruneUnresolved(modules, { framework: ["next"], orm: ["drizzle"] })).toEqual({
framework: ["next"],
});
});
});
+7 -10
View File
@@ -13,14 +13,8 @@ import { getRegistryIndex } from "@/server/registry-index.functions";
import appCss from "../styles.css?url";
const BACK_LINK = <Link to="/" />;
const DEVTOOLS_CONFIG = { position: "bottom-right" } as const;
const DEVTOOLS_PLUGINS = [{ name: "Tanstack Router", render: <TanStackRouterDevtoolsPanel /> }];
export const Route = createRootRoute({
loader: () => getRegistryIndex(),
// Per-route `head()` overrides supply title / OG / canonical. The root just
// sets the always-present basics and ships the global stylesheet.
head: () => ({
meta: [
{ charSet: "utf-8" },
@@ -57,7 +51,7 @@ function NotFound() {
title="Page not found"
description="That page doesnt exist. It may have moved, or the URL might be wrong."
>
<Button render={BACK_LINK} nativeButton={false} variant="outline" size="sm">
<Button render={<Link to="/" />} nativeButton={false} variant="outline" size="sm">
Back to builder
</Button>
</CenteredMessage>
@@ -70,7 +64,7 @@ function ErrorState({ error }: { error: Error }) {
title="Something went wrong"
description={error?.message || "An unexpected error occurred while rendering this page."}
>
<Button render={BACK_LINK} nativeButton={false} variant="outline" size="sm">
<Button render={<Link to="/" />} nativeButton={false} variant="outline" size="sm">
Back to builder
</Button>
</CenteredMessage>
@@ -83,7 +77,7 @@ function RootComponent() {
<head>
<HeadContent />
</head>
<body className="min-h-svh bg-background font-sans text-foreground antialiased">
<body className="min-h-svh bg-background font-sans text-foreground tabular-nums antialiased">
<PostHogProvider>
<ThemeProvider defaultTheme="system" storageKey="stanza-theme">
<div className="flex min-h-svh flex-col">
@@ -96,7 +90,10 @@ function RootComponent() {
<Toaster />
</ThemeProvider>
</PostHogProvider>
<TanStackDevtools config={DEVTOOLS_CONFIG} plugins={DEVTOOLS_PLUGINS} />
<TanStackDevtools
config={{ position: "bottom-right" }}
plugins={[{ name: "TanStack Router", render: <TanStackRouterDevtoolsPanel /> }]}
/>
<Analytics />
<Scripts />
</body>
+15 -5
View File
@@ -30,16 +30,26 @@ type Payload = {
events: IncomingEvent[];
};
function isObject(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
function parsePayload(body: unknown): Payload | null {
if (typeof body !== "object" || body === null) return null;
const { distinctId, events } = body as Record<string, unknown>;
if (!isObject(body)) return null;
const { distinctId, events } = body;
if (typeof distinctId !== "string" || distinctId.length === 0) return null;
if (!Array.isArray(events) || events.length === 0 || events.length > MAX_EVENTS) return null;
const parsed: IncomingEvent[] = [];
for (const e of events) {
if (typeof e !== "object" || e === null) return null;
if (typeof (e as IncomingEvent).event !== "string") return null;
if (!isObject(e)) return null;
if (typeof e.event !== "string") return null;
parsed.push({
event: e.event,
properties: isObject(e.properties) ? e.properties : undefined,
timestamp: typeof e.timestamp === "string" ? e.timestamp : undefined,
});
}
return { distinctId, events: events as IncomingEvent[] };
return { distinctId, events: parsed };
}
export const Route = createFileRoute("/api/events")({
+3 -2
View File
@@ -82,8 +82,9 @@ function ModuleDetailPage() {
// arrays (the unified selection shape). CommandPreview turns this into the
// package-manager-specific command string (`--framework=next --testing=vitest`).
const selections: Selections = {};
for (const [category, id] of Object.entries(resolvedPeers)) {
selections[category as CategoryId] = [id];
for (const category of KNOWN_CATEGORIES) {
const id = resolvedPeers[category];
if (id !== undefined) selections[category] = [id];
}
selections[module.category] = [module.id];
const tryItParts = { selections };
@@ -7,13 +7,18 @@ import type {
PeerRequirement,
RegistryIndex,
} from "@stanza/registry";
import { emptyManifest, PEER_CATEGORIES, resolveAdapter } from "@stanza/registry";
import { emptyManifest, KNOWN_CATEGORIES, PEER_CATEGORIES, resolveAdapter } from "@stanza/registry";
import { createServerFn } from "@tanstack/react-start";
import type { Preview } from "@/server/highlighter";
import { renderPreview } from "@/server/highlighter.server";
import { loadRegistryFile } from "@/server/registry-base.server";
/** Typed `Object.keys` for a partial category-record (avoids a key-widening cast). */
function categoryKeys(record: Partial<Record<CategoryId, unknown>>): CategoryId[] {
return KNOWN_CATEGORIES.filter((category) => record[category] !== undefined);
}
export type ModuleDetailInput = {
category: CategoryId;
id: string;
@@ -98,9 +103,9 @@ function computePeerOptions(
const out: Partial<Record<CategoryId, string[]>> = {};
const declared = module.peers ?? ({} as PeerRequirement);
const referenced = new Set<CategoryId>();
for (const slot of Object.keys(declared) as CategoryId[]) referenced.add(slot);
for (const slot of categoryKeys(declared)) referenced.add(slot);
for (const adapter of module.adapters) {
for (const slot of Object.keys(adapter.match) as CategoryId[]) referenced.add(slot);
for (const slot of categoryKeys(adapter.match)) referenced.add(slot);
}
for (const slot of referenced) {
@@ -144,7 +149,7 @@ function applyAutoDefaults(
peerOptions: Partial<Record<CategoryId, string[]>>,
): Partial<Record<CategoryId, string>> {
const out: Partial<Record<CategoryId, string>> = { ...peers };
for (const slot of Object.keys(peerOptions) as CategoryId[]) {
for (const slot of categoryKeys(peerOptions)) {
if (out[slot]) continue;
const opts = peerOptions[slot];
if (opts && opts.length > 0) out[slot] = opts[0];
+6 -2
View File
@@ -25,7 +25,8 @@ export async function loadRegistryFile<T>(relativePath: string): Promise<T> {
const { resolve } = await import("node:path");
const filePath = resolve(process.cwd(), "public/registry", relativePath);
try {
return JSON.parse(await readFile(filePath, "utf8")) as T;
const parsed: T = JSON.parse(await readFile(filePath, "utf8"));
return parsed;
} catch {
throw new Error(
`Registry asset not found: ${filePath} (run \`pnpm --filter @stanza/web prebuild\` to populate public/registry/)`,
@@ -38,5 +39,8 @@ export async function loadRegistryFile<T>(relativePath: string): Promise<T> {
throw new Error(`Registry asset not found: assets:registry:${relativePath}`);
}
// unstorage destr-parses JSON, but be explicit if a driver returns raw text.
return (typeof data === "string" ? JSON.parse(data) : data) as T;
const value: unknown = typeof data === "string" ? JSON.parse(data) : data;
// Registry assets are first-party build output; the caller declares the shape.
// oxlint-disable-next-line typescript/no-unsafe-type-assertion
return value as T;
}
+7
View File
@@ -182,6 +182,13 @@
scrollbar-color: var(--scrollbar-thumb-current) transparent;
}
}
pre,
code,
.font-mono {
/* TODO: temporary fix for https://github.com/vercel/geist-font/pull/217 */
font-variant-ligatures: none !important;
}
}
@utility no-scrollbar {
-9
View File
@@ -1,9 +0,0 @@
{
"$schema": "https://turborepo.com/schema.json",
"extends": ["//"],
"tasks": {
"build": {
"env": ["VITE_PUBLIC_POSTHOG_KEY", "VITE_PUBLIC_POSTHOG_HOST"]
}
}
}
+4 -4
View File
@@ -4,17 +4,17 @@ import { tanstackStart } from "@tanstack/react-start/plugin/vite";
import react from "@vitejs/plugin-react";
import mdx from "fumadocs-mdx/vite";
import { nitro } from "nitro/vite";
import { defineConfig } from "vite";
import { defineConfig, lazyPlugins } from "vite-plus";
export default defineConfig({
plugins: [
plugins: lazyPlugins(() => [
mdx(),
nitro({ serverAssets: [{ baseName: "registry", dir: "public/registry" }] }),
devtools(),
tailwindcss(),
tanstackStart(),
react(),
],
nitro({ serverAssets: [{ baseName: "registry", dir: "public/registry" }] }),
]),
resolve: {
tsconfigPaths: true,
alias: {
+15 -14
View File
@@ -5,29 +5,30 @@
"description": "Modular monorepo template CLI — shadcn for stacks.",
"license": "MIT",
"author": "Jake Jarvis <jakejarvis@gmail.com>",
"type": "module",
"scripts": {
"build": "turbo run build",
"dev": "turbo run dev",
"test": "turbo run test",
"check-types": "turbo run check-types",
"lint": "oxlint",
"lint:fix": "oxlint --fix",
"fmt": "oxfmt",
"fmt:check": "oxfmt --check",
"build": "vp run -r build",
"build:cli": "vp run stanza-cli#build && vp run create-stanza#build",
"build:web": "vp run @stanza/web#build",
"dev": "vp run -r dev",
"test": "vp test",
"lint": "vp lint",
"lint:fix": "vp lint --fix",
"fmt": "vp fmt",
"fmt:check": "vp fmt --check",
"registry:build": "bun scripts/registry-build.ts",
"changeset": "changeset",
"release": "pnpm --filter stanza-cli --filter create-stanza build && changeset publish"
"changeset": "vp exec changeset",
"release": "vp run build:cli && changeset publish",
"prepare": "vp config"
},
"devDependencies": {
"@changesets/changelog-github": "^0.7.0",
"@changesets/cli": "^2.31.0",
"@stanza/registry": "workspace:*",
"@types/node": "^25.9.1",
"oxfmt": "^0.51.0",
"oxlint": "^1.66.0",
"tsx": "^4.22.3",
"turbo": "^2.9.14",
"typescript": "^6.0.3"
"typescript": "^6.0.3",
"vite-plus": "catalog:"
},
"engines": {
"node": ">=22",
+2 -5
View File
@@ -10,10 +10,6 @@
".": "./src/index.ts",
"./builtins": "./src/builtins/index.ts"
},
"scripts": {
"check-types": "tsc --noEmit",
"test": "vitest run"
},
"dependencies": {
"@stanza/registry": "workspace:*",
"ts-morph": "^28.0.0"
@@ -21,6 +17,7 @@
"devDependencies": {
"@types/node": "^25.9.1",
"typescript": "^6.0.3",
"vitest": "^4.1.7"
"vite-plus": "catalog:",
"vitest": "catalog:"
}
}
@@ -1,5 +1,5 @@
import { emptyManifest } from "@stanza/registry";
import { describe, expect, it, vi } from "vitest";
import { describe, expect, it, vi } from "vite-plus/test";
import { openProject, type CodemodContext, type Project } from "../index";
import addVitePlugin from "./add-vite-plugin";
@@ -3,7 +3,7 @@ import os from "node:os";
import path from "node:path";
import { emptyManifest } from "@stanza/registry";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { afterEach, beforeEach, describe, expect, it } from "vite-plus/test";
import { openProject, type CodemodContext, type Project } from "../index";
import appendToFile from "./append-to-file";
@@ -204,6 +204,9 @@ function commentLine(style: CommentStyle, body: string): string {
return `# ${body}`;
case "block":
return `/* ${body} */`;
default:
style satisfies never;
throw new Error(`append-to-file: unknown comment style ${String(style)}`);
}
}
+4 -4
View File
@@ -26,10 +26,10 @@ import reExport from "./re-export";
import wrapRootLayout from "./wrap-root-layout";
export const CODEMOD_CATALOG: Record<string, Codemod> = {
[addVitePlugin.id]: addVitePlugin as unknown as Codemod,
[appendToFile.id]: appendToFile as unknown as Codemod,
[reExport.id]: reExport as unknown as Codemod,
[wrapRootLayout.id]: wrapRootLayout as unknown as Codemod,
[addVitePlugin.id]: addVitePlugin,
[appendToFile.id]: appendToFile,
[reExport.id]: reExport,
[wrapRootLayout.id]: wrapRootLayout,
};
export { addVitePlugin, appendToFile, reExport, wrapRootLayout };
@@ -1,5 +1,5 @@
import { emptyManifest } from "@stanza/registry";
import { describe, expect, it } from "vitest";
import { describe, expect, it } from "vite-plus/test";
import { openProject, type CodemodContext, type Project } from "../index";
import reExport from "./re-export";
+3 -2
View File
@@ -48,8 +48,9 @@ const reExport: Codemod<ReExportArgs> = {
const fileRel = path.relative(ctx.projectRoot, fileAbs);
const sf = openOrThrow(ctx, fileAbs);
const wantsStar = args.names === undefined || args.names === "all";
const wantNames = wantsStar ? [] : (args.names as string[]);
const names = args.names ?? "all";
const wantsStar = names === "all";
const wantNames = names === "all" ? [] : names;
const existing = sf
.getExportDeclarations()
+1 -1
View File
@@ -2,7 +2,7 @@ import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { describe, expect, it, beforeEach } from "vitest";
import { describe, expect, it, beforeEach } from "vite-plus/test";
import { addEnvVar, removeEnvVar } from "./env";
+1 -1
View File
@@ -1,5 +1,5 @@
import { Project } from "ts-morph";
import { describe, expect, it } from "vitest";
import { describe, expect, it } from "vite-plus/test";
import { addNamedImport, addDefaultImport, removeImport } from "./imports";
+1 -1
View File
@@ -2,7 +2,7 @@ import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { describe, expect, it, beforeEach } from "vitest";
import { describe, expect, it, beforeEach } from "vite-plus/test";
import { addPackageDependency, addPackageScript, mergeJson, removePackageDependency } from "./json";
+37 -29
View File
@@ -1,7 +1,18 @@
import fs from "node:fs";
export function readJson<T = unknown>(path: string): T {
return JSON.parse(fs.readFileSync(path, "utf8")) as T;
/** Narrow an unknown JSON value to a plain object (excludes arrays and null). */
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
export function readJson(path: string): unknown {
return JSON.parse(fs.readFileSync(path, "utf8"));
}
/** Read a JSON file expected to hold an object, falling back to `{}` otherwise. */
function readRecord(path: string): Record<string, unknown> {
const value = readJson(path);
return isRecord(value) ? value : {};
}
export function writeJson(path: string, value: unknown): void {
@@ -13,9 +24,8 @@ export function writeJson(path: string, value: unknown): void {
* Returns the dot-paths that were created or changed (for region claiming).
*/
export function mergeJson(path: string, patch: Record<string, unknown>): string[] {
const original = readJson<Record<string, unknown>>(path);
const touched: string[] = [];
const merged = mergeRecurse(original, patch, "", touched);
const merged = mergeRecurse(readRecord(path), patch, "", touched);
writeJson(path, merged);
return touched;
}
@@ -29,20 +39,9 @@ function mergeRecurse(
const out: Record<string, unknown> = { ...target };
for (const [k, v] of Object.entries(source)) {
const path = prefix ? `${prefix}.${k}` : k;
if (
v !== null &&
typeof v === "object" &&
!Array.isArray(v) &&
typeof out[k] === "object" &&
out[k] !== null &&
!Array.isArray(out[k])
) {
out[k] = mergeRecurse(
out[k] as Record<string, unknown>,
v as Record<string, unknown>,
path,
touched,
);
const existing = out[k];
if (isRecord(v) && isRecord(existing)) {
out[k] = mergeRecurse(existing, v, path, touched);
} else {
if (out[k] !== v) touched.push(path);
out[k] = v;
@@ -52,36 +51,45 @@ function mergeRecurse(
}
export function setJsonPath(file: string, dotPath: string, value: unknown): void {
const root = readJson<Record<string, unknown>>(file);
const root = readRecord(file);
setPath(root, dotPath, value);
writeJson(file, root);
}
export function unsetJsonPath(file: string, dotPath: string): void {
const root = readJson<Record<string, unknown>>(file);
const root = readRecord(file);
unsetPath(root, dotPath);
writeJson(file, root);
}
function setPath(root: Record<string, unknown>, dotPath: string, value: unknown): void {
const parts = dotPath.split(".");
// parts is non-empty by construction (dotPath is non-empty caller contract)
const last = parts.pop() as string;
let node: Record<string, unknown> = root;
const last = parts.pop();
// dotPath is non-empty by caller contract, so `parts` was non-empty.
if (last === undefined) return;
let node = root;
for (const p of parts) {
if (typeof node[p] !== "object" || node[p] === null) node[p] = {};
node = node[p] as Record<string, unknown>;
const next = node[p];
if (isRecord(next)) {
node = next;
} else {
const created: Record<string, unknown> = {};
node[p] = created;
node = created;
}
}
node[last] = value;
}
function unsetPath(root: Record<string, unknown>, dotPath: string): void {
const parts = dotPath.split(".");
const last = parts.pop() as string;
let node: Record<string, unknown> = root;
const last = parts.pop();
if (last === undefined) return;
let node = root;
for (const p of parts) {
if (typeof node[p] !== "object" || node[p] === null) return;
node = node[p] as Record<string, unknown>;
const next = node[p];
if (!isRecord(next)) return;
node = next;
}
delete node[last];
}
+5 -6
View File
@@ -23,21 +23,20 @@
],
"type": "module",
"publishConfig": {
"access": "public",
"bin": {
"create-stanza": "./dist/bin.mjs"
},
"access": "public"
}
},
"scripts": {
"build": "tsdown",
"check-types": "tsc --noEmit"
"build": "vp pack"
},
"dependencies": {
"stanza-cli": "workspace:*"
},
"devDependencies": {
"@types/node": "^25.9.1",
"tsdown": "^0.22.0",
"typescript": "^6.0.3"
"typescript": "^6.0.3",
"vite-plus": "catalog:"
}
}
-16
View File
@@ -1,16 +0,0 @@
import { defineConfig } from "tsdown";
/**
* `create-stanza` is a thin shim over `stanza-cli`. We compile its TS to
* ESM but externalize EVERYTHING — including `stanza-cli` itself, which
* the user installs alongside `create-stanza` via npm's normal dep chain
* (it's in `dependencies`). Inlining `stanza-cli` here would also pull in
* its transitive 12 MB of ts-morph and friends.
*/
export default defineConfig({
entry: ["./src/bin.ts"],
format: "esm",
target: "node22",
platform: "node",
clean: true,
});
+18
View File
@@ -0,0 +1,18 @@
import { defineConfig } from "vite-plus";
export default defineConfig({
/**
* `create-stanza` is a thin shim over `stanza-cli`. We compile its TS to
* ESM but externalize EVERYTHING — including `stanza-cli` itself, which
* the user installs alongside `create-stanza` via npm's normal dep chain
* (it's in `dependencies`). Inlining `stanza-cli` here would also pull in
* its transitive 12 MB of ts-morph and friends.
*/
pack: {
entry: ["./src/bin.ts"],
format: "esm",
target: "node22",
platform: "node",
clean: true,
},
});
+2 -5
View File
@@ -12,15 +12,12 @@
"./module": "./src/module.ts",
"./resolver": "./src/resolver.ts"
},
"scripts": {
"check-types": "tsc --noEmit",
"test": "vitest run"
},
"dependencies": {
"zod": "^4.4.3"
},
"devDependencies": {
"typescript": "^6.0.3",
"vitest": "^4.1.7"
"vite-plus": "catalog:",
"vitest": "catalog:"
}
}
+4
View File
@@ -19,12 +19,16 @@ export {
defineModule,
CATEGORIES,
KNOWN_CATEGORIES,
isCategoryId,
PEER_CATEGORIES,
PACKAGE_DIRS,
categoryLabel,
categoryHome,
categoryCardinality,
isMulti,
ModuleSchema,
ModuleSummarySchema,
RegistryIndexSchema,
} from "./module";
export type { StanzaManifest, StanzaModuleRecord, RegionMap, RegionOwnership } from "./manifest";
+1 -1
View File
@@ -1,4 +1,4 @@
import { assert, describe, expect, it } from "vitest";
import { assert, describe, expect, it } from "vite-plus/test";
import { CURRENT_MANIFEST_VERSION, emptyManifest, StanzaManifestSchema } from "./manifest";
+47 -3
View File
@@ -113,8 +113,20 @@ export const CATEGORIES = [
export type CategoryId = (typeof CATEGORIES)[number]["id"];
/** Category ids as a non-empty tuple — the shape `z.enum` needs. Also the processing order. */
export const KNOWN_CATEGORIES = CATEGORIES.map((c) => c.id) as [CategoryId, ...CategoryId[]];
const [firstCategory, ...restCategories] = CATEGORIES;
export const KNOWN_CATEGORIES: [CategoryId, ...CategoryId[]] = [
firstCategory.id,
...restCategories.map((c) => c.id),
];
/** Runtime guard narrowing an arbitrary string to a known `CategoryId`. */
export function isCategoryId(value: string): value is CategoryId {
return KNOWN_CATEGORIES.some((id) => id === value);
}
// `Object.fromEntries` widens keys to `string`; CATEGORIES covers every
// CategoryId by construction, so narrowing to the exhaustive record is sound.
// oxlint-disable-next-line typescript/no-unsafe-type-assertion
const CATEGORY_BY_ID = Object.fromEntries(CATEGORIES.map((c) => [c.id, c])) as Record<
CategoryId,
Category
@@ -346,7 +358,8 @@ const adapterSchema = z.object({
...installFieldsSchema,
});
export const ModuleSchema = z.object({
/** Shape shared by a full `Module` and its index `ModuleSummary` (all but `adapters`). */
const moduleBaseShape = {
category: z.enum(KNOWN_CATEGORIES),
id: z.string(),
label: z.string(),
@@ -358,8 +371,39 @@ export const ModuleSchema = z.object({
.optional(),
consumesPackages: z.array(z.string()).optional(),
...installFieldsSchema,
adapters: z.array(adapterSchema),
homepage: z.string().optional(),
author: z.string().optional(),
logo: z.union([z.string(), z.object({ light: z.string(), dark: z.string() })]).optional(),
};
export const ModuleSchema = z.object({
...moduleBaseShape,
adapters: z.array(adapterSchema),
}) satisfies z.ZodType<Module>;
/** Category metadata as served in the registry `index.json`. */
const categorySchema = z.object({
id: z.enum(KNOWN_CATEGORIES),
label: z.string(),
description: z.string(),
cardinality: z.enum(["one", "many"]),
home: z.union([
z.object({ kind: z.literal("app") }),
z.object({ kind: z.literal("repo") }),
z.object({ kind: z.literal("package"), dir: z.string() }),
]),
}) satisfies z.ZodType<Category>;
/** Index summary: full module metadata with adapters reduced to `key` + `match`. */
export const ModuleSummarySchema = z.object({
...moduleBaseShape,
adapters: z.array(z.object({ key: z.string(), match: z.record(z.string(), z.string()) })),
}) satisfies z.ZodType<ModuleSummary>;
/** Runtime-validatable schema for the registry `index.json` payload. */
export const RegistryIndexSchema = z.object({
generatedAt: z.string(),
schemaVersion: z.literal(1),
categories: z.array(categorySchema),
modules: z.array(ModuleSummarySchema),
}) satisfies z.ZodType<RegistryIndex>;
+1 -1
View File
@@ -1,4 +1,4 @@
import { assert, describe, expect, it } from "vitest";
import { assert, describe, expect, it } from "vite-plus/test";
import { emptyManifest } from "./manifest";
import { defineModule, type Module } from "./module";
+4 -3
View File
@@ -103,9 +103,10 @@ function matchSpecificity(
peers: Partial<Record<CategoryId, string>>,
): number {
let score = 0;
for (const [category, required] of Object.entries(adapter.match) as [CategoryId, string][]) {
const actual = peers[category];
if (actual !== required) return -1;
for (const category of KNOWN_CATEGORIES) {
const required = adapter.match[category];
if (required === undefined) continue;
if (peers[category] !== required) return -1;
score += 1;
}
return score;
+705 -686
View File
File diff suppressed because it is too large Load Diff
+14 -3
View File
@@ -2,14 +2,25 @@ packages:
- apps/*
- packages/*
- registry/modules/*
catalog:
vite: npm:@voidzero-dev/vite-plus-core@^0.1.22
vite-plus: ^0.1.22
vitest: npm:@voidzero-dev/vite-plus-test@^0.1.22
ignoredBuiltDependencies:
- sharp
ignoredOptionalDependencies: []
onlyBuiltDependencies:
- core-js
- esbuild
- msw
- protobufjs
overrides:
vite: "catalog:"
vitest: "catalog:"
peerDependencyRules:
allowAny:
- vite
- vitest
allowedVersions:
vite: "*"
vitest: "*"
+2 -1
View File
@@ -34,7 +34,8 @@ async function main() {
const summaries = [];
for (const dir of dirs) {
const entry = path.join(modulesDir, dir, "module.ts");
const mod = ((await import(entry)) as { default: Module }).default;
const imported: { default: Module } = await import(entry);
const mod = imported.default;
if (!mod) throw new Error(`Module ${dir} has no default export.`);
// Inline each template's file contents. The per-module JSON is the
-25
View File
@@ -1,25 +0,0 @@
{
"$schema": "https://turborepo.com/schema.json",
"ui": "stream",
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**", ".output/**", ".vercel/output/**"]
},
"dev": {
"cache": false,
"persistent": true
},
"test": {
"dependsOn": ["^build"],
"outputs": []
},
"lint": {
"outputs": []
},
"check-types": {
"dependsOn": ["^build"],
"outputs": []
}
}
}
+106
View File
@@ -0,0 +1,106 @@
import { defineConfig } from "vite-plus";
export default defineConfig({
test: {
// apps/web is intentionally omitted: its plugin-heavy SSR config trips a
// vite-plus alpha bug in the workspace project loader (`runner.config` is
// undefined during suite collection — same class as voidzero-dev/vite-plus#1076).
// Re-add it here once that's fixed upstream; it passes when run on its own.
projects: ["apps/*", "packages/*", "!apps/web"],
},
staged: {
"*": "vp check --fix",
},
fmt: {
sortImports: {},
sortTailwindcss: {
stylesheet: "apps/web/src/styles.css",
functions: ["clsx", "cn", "cva", "tw"],
preserveDuplicates: false,
preserveWhitespace: false,
},
sortPackageJson: true,
ignorePatterns: [
"**/node_modules/**",
"**/dist/**",
"**/.output/**",
"**/routeTree.gen.ts",
"**/coverage/**",
"apps/web/public/registry/**",
"registry/modules/*/logo*.svg",
"registry/modules/*/templates/**",
],
},
lint: {
plugins: ["oxc", "eslint", "typescript", "unicorn", "import", "promise", "vitest"],
categories: {
correctness: "error",
suspicious: "warn",
perf: "warn",
},
rules: {
"no-console": "off",
"no-empty": [
"error",
{
allowEmptyCatch: true,
},
],
"no-await-in-loop": "off",
"unicorn/no-null": "off",
"unicorn/filename-case": "off",
"unicorn/no-array-reduce": "off",
"vite-plus/prefer-vite-plus-imports": "error",
},
ignorePatterns: [
"**/node_modules/**",
"**/dist/**",
"**/.output/**",
"**/*.gen.ts",
"**/coverage/**",
"registry/modules/*/templates/**",
],
overrides: [
{
files: ["**/*.test.ts", "**/*.test.tsx"],
rules: {
"typescript/no-explicit-any": "off",
// Codemod apply/revert are typed `Promise<R> | R` so authors can write
// async codemods, but the builtins under test are synchronous and the
// suites call them for their side effects — no promise to await.
"typescript/no-floating-promises": "off",
// Test setup constructs mock objects/results and casts them into shape.
"typescript/no-unsafe-type-assertion": "off",
},
},
{
files: ["apps/web/**/*.tsx"],
plugins: [
"oxc",
"eslint",
"typescript",
"react",
"react-perf",
"promise",
"jsx-a11y",
"unicorn",
"import",
"vitest",
],
rules: {
"react/react-in-jsx-scope": "off",
},
},
],
options: {
typeAware: true,
typeCheck: true,
},
jsPlugins: [
{
name: "vite-plus",
specifier: "vite-plus/oxlint-plugin",
},
],
},
});