feat: initial commit

This commit is contained in:
2026-05-20 11:52:53 -04:00
commit b74ffb9d1a
124 changed files with 6925 additions and 0 deletions
+31
View File
@@ -0,0 +1,31 @@
node_modules/
dist/
.output/
.vercel/
.turbo/
*.tsbuildinfo
.tanstack/
# IDE
.vscode/*
!.vscode/extensions.json
!.vscode/settings.json
.idea/
# Logs
*.log
npm-debug.log*
pnpm-debug.log*
# OS
.DS_Store
Thumbs.db
# Env
.env
.env.local
.env.*.local
# Test artifacts
coverage/
__snapshots__/.tmp/
+4
View File
@@ -0,0 +1,4 @@
auto-install-peers=true
strict-peer-dependencies=false
node-linker=isolated
prefer-workspace-packages=true
+4
View File
@@ -0,0 +1,4 @@
{
"$schema": "./node_modules/oxfmt/configuration_schema.json",
"ignorePatterns": []
}
+42
View File
@@ -0,0 +1,42 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["typescript", "unicorn", "import", "promise"],
"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": ["typescript", "react", "react-hooks", "jsx-a11y", "unicorn", "import"],
"rules": {
"react/react-in-jsx-scope": "off"
}
}
]
}
+70
View File
@@ -0,0 +1,70 @@
# Stanza
Shadcn-style CLI for assembling modular full-stack TS monorepos. Currently
ships `init`, `add`, `remove`, `list`, `search` against five slots:
`framework`, `styling`, `db`, `orm`, `auth`. `swap` + `update` verbs and
additional slots (api/ai/ui/payments/email/tooling/testing/deploy) are
planned — the manifest already reserves the fields they'll need
(`modules[slot].version`, `regions`). See [REGISTRY.md](REGISTRY.md) for the
module roadmap and [TODO.md](TODO.md) for active work.
Three things differentiate stanza from other scaffolders:
1. Post-init `stanza add` works on existing projects (manifest-driven, peer-aware).
2. Generated code is vendored verbatim — no `@stanza/runtime` dep.
3. Open registry spec — third parties can host their own static JSON.
## Layout
- `apps/cli/``@stanza/cli`, Bun entrypoint at `src/bin.ts`
- `apps/web/``@stanza/web`, TanStack Start visual builder (Vite-native, no Vinxi)
- `packages/registry/` — shared schema, slot/peer resolver, Zod manifest validator
- `packages/codemods/` — ts-morph helpers (idempotent + reversible)
- `packages/create-stanza/``pnpm create stanza` shim
- `packages/registry-build/` — Bun script that emits the static CDN JSON
- `registry/modules/<slot>-<id>/` — first-party modules: `module.ts` + `templates/` (+ optional `codemods/`)
## Commands
- `bun apps/cli/src/bin.ts <verb>` — run CLI directly without build
- `bun packages/registry-build/src/build.ts` — regenerate `dist/registry/{index,modules/*}.json`
- `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`)
- `cd packages/<x> && node_modules/.bin/vitest run` — unit tests (per workspace; no root `vitest` binary)
- `cd <pkg> && node_modules/.bin/tsc --noEmit` — typecheck (per workspace)
- `cd apps/web && node_modules/.bin/vite build` — generates `src/routeTree.gen.ts` (required before first typecheck)
- E2E smoke: seed `$TMPDIR/x` with `stanza.json` + `apps/web/package.json`, then `bun .../bin.ts add <slot> <module>`
## Toolchain invariants
- 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.base.json`; build with `bun build`, not `tsc`
- `tsconfig.base.json` excludes `**/templates/**` globally — template files target user projects, not this repo
- Zod 4: use `z.partialRecord(K, V)` for finite-key partial records (`z.record(z.enum, V)` requires exhaustive keys)
- TanStack Start: `verbatimModuleSyntax: false` in `apps/web/tsconfig.json` (server bundles leak otherwise); `tanstackStart()` MUST precede `react()` in vite plugins
- Bun runs the CLI directly from `.ts`; don't add a TS compile step
## Architecture rules
- **Modules are vendored**: their templates land in the user's repo verbatim; no `@stanza/runtime` dep
- **Slot taxonomy** is currently `framework | styling | db | orm | auth` (see `KNOWN_SLOTS`); adding a slot is a manifest schema bump — update `KNOWN_SLOTS`, `slotOrder`, and the Zod manifest schema together
- **Adapter keys** encode peer choices (e.g., `next+drizzle`); the resolver picks the most specific match
- **Region ownership** in `stanza.json` is the source of truth for `remove`/future-`swap`; two modules claiming the same region is a hard error (`RegionConflictError`)
- **Declarative beats imperative**: prefer `templates`/`dependencies`/`env`/`scripts` over imperative codemods; the runner applies declarative fields generically
- **Reserved manifest fields**: `modules[slot].version` and `regions` are written today but only fully consumed by the upcoming `swap`/`update` verbs — do not drop them
## Module authoring
- `module.ts` exports `defineModule({...})` with at least one adapter (use `match: {}` for "default / no peer")
- Templates go in `templates/`, referenced by `src` path; `scope: "app"` resolves against `manifest.appDir`, `scope: "repo"` against the repo root
- Framework modules MUST NOT ship a `package.json.tpl` — it collides with `addPackageDependency`. Let the runner merge deps into the host's package.json
- Codemod functions (when needed) live in `<module>/codemods/index.ts` as a default-exported map of `{ id: Codemod }`
## Gotchas
- Clack spinners (`p.spinner()`) don't auto-stop on promise rejection — wrap awaits in try/catch and call `spinner.stop(...failed)` in the catch
- LSP diagnostics on template files (e.g. "Cannot find module 'react'") are noise; the global exclude works for `tsc` but tsserver still indexes them
- The dev registry is found by walking up from `import.meta.url` looking for `registry/modules/`; `STANZA_REGISTRY` env var overrides (FS path or HTTP URL)
- `pnpm install` says "Already up to date" when only workspace `package.json` files changed without lockfile-affecting bumps — use `pnpm install --force` to re-link
- Oxlint 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
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Jake Jarvis
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+31
View File
@@ -0,0 +1,31 @@
# Stanza
Modular monorepo template CLI — shadcn for full-stack TypeScript stacks.
```sh
pnpm create stanza my-app
```
Pick a framework, ORM, database, auth provider, styling — get a clean monorepo with idiomatic code, vendored into your repo. Add modules later with `stanza add`.
## What's inside
```
apps/
cli/ # @stanza/cli — the CLI binary
web/ # builder.stanza.dev (TanStack Start)
packages/
registry/ # shared schema, slot/peer/capability resolver
codemods/ # ts-morph helpers for region-aware patching
create-stanza/ # `pnpm create stanza` shim
registry/
modules/ # first-party modules (framework, orm, db, auth, styling)
```
## Status
Work in progress. See [CLAUDE.md](./CLAUDE.md), [TODO.md](./TODO.md), and [REGISTRY.md](./REGISTRY.md) for current state.
## License
MIT — see [LICENSE](./LICENSE).
+114
View File
@@ -0,0 +1,114 @@
# First-party module registry
This is the canonical roadmap for the first-party modules stanza ships. Each
entry maps to a `registry/modules/<slot>-<id>/` directory. Update this file
when a module lands, gets renamed, or is dropped.
Legend: `[x]` added· `[ ]` planned
## 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._ 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._
- [ ] **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** — Tailwind v4, adapters per framework
## ui
_New slot._ Layered on top of `styling`; provides component primitives.
- [ ] **shadcn-radix** — classic shadcn/ui (Radix primitives)
- [ ] **shadcn-base** — shadcn on react-base-ui
## payments
_New slot._
- [ ] **stripe** — Checkout Sessions + webhooks
- [ ] **polar** — Polar SDK
## email
_New slot._
- [ ] **resend** — Resend SDK + React Email templates
## tooling
_New slot._ Lint/format toolchain. Single-choice slot.
- [ ] **eslint-prettier** — ESLint + Prettier
- [ ] **biome** — Biome (lint + format)
- [ ] **oxlint-oxfmt** — Oxlint + oxfmt
## testing
_New slot._ Multi-choice (unit + e2e are independent).
- [ ] **vitest** — unit + integration
- [ ] **playwright** — e2e
## deploy
_New slot._
- [ ] **vercel**`vercel.json` + framework-specific output
- [ ] **cloudflare** — Workers / Pages adapter per framework
- [ ] **railway**`railway.toml` + Dockerfile
- [ ] **docker** — generic `Dockerfile` + compose for self-host
## monorepo
_Currently hardcoded in `bootstrapShell` as Turborepo. Will become a slot if we ever add a Nx/Moonrepo alternative._
- [x] **turborepo** — Turbo 2.x (current default; not yet a configurable slot)
## 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
## Slot taxonomy changes required
The slots `api`, `ai`, `ui`, `payments`, `email`, `tooling`, `testing`, `deploy` don't exist in `KNOWN_SLOTS` yet. Adding them is a manifest-schema bump and a `slotOrder` update in `packages/registry/src/resolver.ts`. Plan the rollout so existing `stanza.json` files don't break — new slots are optional, so adding them is additive.
`tooling` and `monorepo` are single-choice within their slot; `testing` is multi-choice (a project can have both vitest and playwright). The current resolver assumes single-choice — `testing` will need an array-valued slot or two sub-slots (`testing-unit`, `testing-e2e`).
+83
View File
@@ -0,0 +1,83 @@
# TODO
State at end of last session: the core architecture is end-to-end functional. `stanza add` composes modules across slots (verified: `framework next``db sqlite``orm drizzle``auth better-auth` produces a working tree with the right adapter selection). 20 unit tests pass. apps/web is a minimal Vite-native TanStack Start scaffold — needs the actual builder UI.
## Web app (apps/web) — priority
The builder is functionally wired but visually unfinished. It's inline-styled radios + a code block.
- [ ] Add Tailwind 4 to `apps/web` — use the `@tailwindcss/vite` plugin (mirror what the `styling-tailwind` module emits for the `tanstack-start` adapter)
- [ ] Hand-roll a small component layer or pull in shadcn (web variant) for cards, buttons, inputs, code blocks
- [ ] Replace the radio-list `Builder` with a card grid per slot, showing module label + description + homepage link
- [ ] Show _why_ a module is filtered out (e.g. "needs `framework: next`, you picked tanstack-start") instead of just dropping it
- [ ] Deep linking: encode selections in URL search params (`?framework=next&orm=drizzle&...`) so users can share configurations
- [ ] Copy-to-clipboard for the generated `pnpm create stanza ...` command
- [ ] Preview pane: list of files stanza will write (derived from `module.adapters[].templates[].dest` for the selected adapter combo)
- [ ] Show pinned npm versions per selected module (from `adapter.dependencies` / `adapter.devDependencies`)
- [ ] Module detail route at `/m/$slot/$id` — full description, all adapters, deps, env vars, templates list
- [ ] Search route at `/search` backed by the same registry index
- [ ] Layout: header (logo, GitHub link, docs link), footer
- [ ] Dark mode (Tailwind 4 `light-dark()` + system pref)
- [ ] SEO: meta tags via `head()` on routes, OG image, sitemap
- [ ] Host the registry on the same domain — wire `packages/registry-build` output to `apps/web/public/registry/` and serve statically, or via a Vercel route handler
- [ ] Vercel deploy config — `vercel.json` if needed, env vars for `STANZA_REGISTRY` (point dev at local FS, prod at the deployed CDN path)
- [ ] Docs section (could be MDX routes): overview, authoring guide, registry spec
## CLI (apps/cli)
The wizard and verbs work but a few things from the plan are stubbed.
- [ ] Implement opt-out PostHog telemetry — wire `posthog-node` (already a dep), prompt on first run, store a `telemetryId` in `stanza.json`, respect `--no-telemetry` and `DO_NOT_TRACK=1`
- [ ] `--yes` flag for non-interactive `init` — pick defaults via flags (`--framework=next` etc.); essential for CI tests
- [ ] HTTP registry loader path is implemented but unverified — smoke test against the static JSON output
- [ ] `stanza init`: today's `bootstrapShell` doesn't include `tsconfig.json` at the repo root for the generated project, and doesn't emit `turbo.json`. Decide whether stanza ships those or modules do
- [ ] Better error messages for `RegionConflictError` (current message is technical; should suggest `stanza remove <slot>` or manual cleanup)
- [ ] `bun build` the CLI for publish — script exists, not yet exercised
- [ ] Tests for command handlers (`init`, `add`, `remove`) — current coverage is just codemods + resolver
## Modules
Functional but a few real issues to fix.
- [ ] `auth-better-auth` tanstack-start adapter references `import.meta.env.VITE_BETTER_AUTH_URL` but the env var is declared as `BETTER_AUTH_URL` — either add `VITE_` prefix or read it server-side
- [ ] `auth-better-auth` auth schema (`shared/auth-schema.drizzle.ts`) is Postgres-only — needs a SQLite variant (different timestamp/boolean handling)
- [ ] `auth-clerk`: ships `layout-wrapper.tsx` (a `ClerkRootProvider`) but doesn't actually wire it into `app/layout.tsx` — needs an imperative codemod to wrap `<body>` children
- [ ] `styling-tailwind` + `framework-next` adapter writes `app/globals.css` over the framework's own — confirm the merge order produces the right import (`@import "tailwindcss"`)
- [ ] First imperative codemod example to validate the codemod-runner's `loadCodemods()` path — try ClerkProvider wrapping
- [ ] Authoring guide: docs page covering `defineModule`, slot/peer/capability semantics, template vs. codemod choice, region ownership
## 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.
- [ ] Add new slots to `KNOWN_SLOTS` + `slotOrder`: `api`, `ai`, `ui`, `payments`, `email`, `tooling`, `testing`, `deploy`. Decide ordering for the wizard's topological prompts (deploy last; testing/tooling near the end; api/ai after framework)
- [ ] Multi-choice slot support — `testing` is the first slot where two modules co-exist (vitest + playwright). Either (a) make the slot value `string[]` in `stanza.json` and update the resolver/UI, or (b) split into `testing-unit` + `testing-e2e` sub-slots. Pick before implementing the testing modules
- [ ] Capability tag expansion — current set is `web | native | react | node | edge | ssr | rsc`. Adding Nuxt/Svelte/Solid needs `vue`, `svelte`, `solid` capabilities; existing React-only modules must add `requires: ['react']` so the resolver filters correctly
- [ ] `monorepo` is hardcoded to Turborepo in `bootstrapShell` — promote to a real slot only when a second option lands
- [ ] `packageManager` Yarn support — different workspace/lockfile semantics than pnpm/bun/npm; needs its own bootstrap branch
- [ ] Cross-framework adapter explosion — Better Auth, tRPC, oRPC, the AI SDKs, etc. each need a sub-adapter per framework (next/tanstack-start/nuxt/svelte/solid). Decide whether to ship them all in one module with many adapters, or split per framework. Lean toward many-adapters-per-module to keep the slot count sane
## Infrastructure
- [ ] GitHub Actions CI: typecheck (every workspace), unit tests, registry build, lint (add Biome or ESLint)
- [ ] Golden snapshot tests per module combination (per the plan's verification section) — for each valid `(framework, orm, db, auth, styling, pm)` tuple, run `stanza init` headless, snapshot the tree, compare against fixture
- [ ] Integration test for the canonical stack — Docker Postgres + Playwright sign-up flow
- [ ] Registry deploy pipeline — on push to main, build `dist/registry/` and push to Vercel/CF
- [ ] npm publish workflow for `@stanza/cli` + `create-stanza` (Changesets recommended; no changesets config yet)
- [ ] `.env.example` at repo root listing `STANZA_REGISTRY`, PostHog key, etc.
## Open items from the plan
- [ ] Domain clearance — confirm `stanza.dev` / `stanza.sh` availability + decide
- [ ] npm scope clearance — `@stanza` scope availability (the CLI assumes `@stanza/cli`); fall back to unscoped `stanza` if taken
- [ ] Better Auth vs Clerk feature parity — Clerk wraps its own UI, Better Auth is headless; document the difference or ship shared UI stubs (`SignInForm`, callback page) that each adapter fills in
## Out of scope for now
These are real future work but consciously deferred — don't pull them in opportunistically.
- `stanza swap <slot> <to>` — manifest already records `version` + `regions` to support it
- `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
+41
View File
@@ -0,0 +1,41 @@
{
"name": "@stanza/cli",
"version": "0.1.0",
"description": "Modular monorepo template CLI — shadcn for stacks.",
"license": "MIT",
"bin": {
"stanza": "./src/bin.ts"
},
"files": [
"src",
"dist"
],
"type": "module",
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": "./src/index.ts",
"./bin": "./src/bin.ts"
},
"scripts": {
"dev": "bun run --watch ./src/bin.ts",
"build": "bun build ./src/bin.ts --target=bun --outdir=./dist --minify",
"typecheck": "tsc --noEmit",
"test": "vitest run"
},
"dependencies": {
"@clack/prompts": "^1.4.0",
"@stanza/codemods": "workspace:*",
"@stanza/registry": "workspace:*",
"kleur": "^4.1.5",
"mri": "^1.2.0",
"posthog-node": "^5.34.7",
"semver": "^7.8.0"
},
"devDependencies": {
"@types/node": "^25.9.1",
"@types/semver": "^7.7.1",
"typescript": "^6.0.3",
"vitest": "^4.1.7"
}
}
+16
View File
@@ -0,0 +1,16 @@
#!/usr/bin/env bun
import mri from "mri";
import { run } from "./run.ts";
const argv = mri(process.argv.slice(2), {
alias: { h: "help", v: "version" },
boolean: ["help", "version", "yes", "dry-run", "no-telemetry"],
});
run(argv).catch((err: unknown) => {
// Top-level catch — every command should handle its own errors, but this is
// the safety net. We use process.exitCode rather than process.exit so any
// outstanding async work (telemetry flush) can finish.
console.error(err instanceof Error ? err.message : String(err));
process.exitCode = 1;
});
+191
View File
@@ -0,0 +1,191 @@
import path from "node:path";
import fs from "node:fs";
import { openProject } from "@stanza/codemods";
import {
addPackageDependency,
addPackageScript,
addEnvVar,
writeTemplateFile,
} from "@stanza/codemods";
import type { Module, ModuleAdapter, StanzaManifest } from "@stanza/registry";
import type { CodemodContext, Project } from "@stanza/codemods";
import { claim, RegionConflictError } from "./region-tracker.ts";
import { writeManifest } from "./manifest.ts";
export type RunResult = {
manifest: StanzaManifest;
touchedFiles: string[];
dryRun: boolean;
};
/**
* Apply a module's chosen adapter to the project at projectRoot. Handles
* the declarative parts (templates, env vars, deps, scripts) directly;
* defers anything in `adapter.codemods` to the codemod registry.
*
* This is the only place that writes to disk on `add`. Both the manifest
* update and the file writes share the same dry-run gate.
*/
export async function applyModule(args: {
projectRoot: string;
manifest: StanzaManifest;
module: Module;
adapter: ModuleAdapter;
registryRoot: string;
dryRun: boolean;
}): Promise<RunResult> {
const { projectRoot, module, adapter, registryRoot, dryRun } = args;
let manifest = args.manifest;
const touchedFiles = new Set<string>();
const appRoot = path.join(projectRoot, manifest.appDir);
const owner = module.id;
const moduleDir = path.join(registryRoot, "modules", `${module.slot}-${module.id}`);
// 1. Templates (claim regions per-template-file).
for (const tpl of adapter.templates ?? []) {
const dest =
tpl.scope === "repo" ? path.join(projectRoot, tpl.dest) : path.join(appRoot, tpl.dest);
const rel = path.relative(projectRoot, dest);
manifest = claim(manifest, rel, "file", owner);
if (!dryRun) {
writeTemplateFile(path.join(moduleDir, "templates", tpl.src), dest, {
appDir: manifest.appDir,
projectName: manifest.name,
});
}
touchedFiles.add(rel);
}
// 2. Dependencies on the host package.json (in the active app).
const appPkg = path.join(appRoot, "package.json");
if (
(adapter.dependencies || adapter.devDependencies || adapter.scripts) &&
fs.existsSync(appPkg)
) {
for (const [name, range] of Object.entries(adapter.dependencies ?? {})) {
manifest = claim(manifest, path.relative(projectRoot, appPkg), `dependencies.${name}`, owner);
if (!dryRun) addPackageDependency(appPkg, name, range);
}
for (const [name, range] of Object.entries(adapter.devDependencies ?? {})) {
manifest = claim(
manifest,
path.relative(projectRoot, appPkg),
`devDependencies.${name}`,
owner,
);
if (!dryRun) addPackageDependency(appPkg, name, range, { dev: true });
}
for (const [name, command] of Object.entries(adapter.scripts ?? {})) {
manifest = claim(manifest, path.relative(projectRoot, appPkg), `scripts.${name}`, owner);
if (!dryRun) addPackageScript(appPkg, name, command);
}
touchedFiles.add(path.relative(projectRoot, appPkg));
}
// 3. Env vars in .env.example at repo root.
if (adapter.env && adapter.env.length > 0) {
const envFile = path.join(projectRoot, ".env.example");
for (const v of adapter.env) {
manifest = claim(manifest, ".env.example", v.name, owner);
if (!dryRun) addEnvVar(envFile, v.name, v.example, v.description);
}
touchedFiles.add(".env.example");
}
// 4. Imperative codemods — resolved against the module's local codemod registry.
if (adapter.codemods?.length) {
const codemods = await loadCodemods(moduleDir);
const project = lazyProject(appRoot);
const ctx = buildContext({
projectRoot,
appRoot,
manifest,
module,
adapter,
project,
touchedFiles,
dryRun,
onClaim: (file, region) => {
manifest = claim(manifest, file, region, owner);
},
});
for (const id of adapter.codemods) {
const fn = codemods[id];
if (!fn) {
throw new Error(`Codemod "${id}" not found in module ${module.id}`);
}
const result = await fn.apply(ctx);
result.touchedFiles.forEach((f) => touchedFiles.add(f));
}
if (!dryRun) await project.save?.();
}
if (!dryRun) {
manifest = {
...manifest,
modules: {
...manifest.modules,
[module.slot]: {
id: module.id,
version: module.version,
adapter: adapter.key,
},
},
};
writeManifest(projectRoot, manifest);
}
return { manifest, touchedFiles: [...touchedFiles], dryRun };
}
type CodemodMap = Record<string, import("@stanza/codemods").Codemod>;
async function loadCodemods(moduleDir: string): Promise<CodemodMap> {
const entry = path.join(moduleDir, "codemods", "index.ts");
if (!fs.existsSync(entry)) return {};
const mod = (await import(entry)) as { default?: CodemodMap; codemods?: CodemodMap };
return mod.default ?? mod.codemods ?? {};
}
function lazyProject(appRoot: string): { (): Project; save?: () => Promise<void> } {
let project: Project | undefined;
const fn = (() => {
if (!project) project = openProject(appRoot);
return project;
}) as { (): Project; save?: () => Promise<void> };
fn.save = async () => {
if (project) await project.save();
};
return fn;
}
function buildContext(args: {
projectRoot: string;
appRoot: string;
manifest: StanzaManifest;
module: Module;
adapter: ModuleAdapter;
project: () => Project;
touchedFiles: Set<string>;
dryRun: boolean;
onClaim: (file: string, region: string) => void;
}): CodemodContext {
return {
projectRoot: args.projectRoot,
appRoot: args.appRoot,
project: args.project,
manifest: args.manifest,
owner: { slot: args.module.slot, module: args.module.id },
adapter: args.adapter.key,
claimRegion(file, region) {
args.onClaim(file, region);
},
releaseRegion() {
// No-op during `add`. The remove path uses regionsOwnedBy() to reverse.
},
};
}
export { RegionConflictError };
+108
View File
@@ -0,0 +1,108 @@
import type { Argv } from "mri";
import kleur from "kleur";
import * as p from "@clack/prompts";
import path from "node:path";
import fs from "node:fs";
import { findProjectRoot, readManifest } from "../manifest.ts";
import { loadRegistry } from "../registry-loader.ts";
import { applyModule } from "../codemod-runner.ts";
import { resolveAdapter, type SlotId, KNOWN_SLOTS } from "@stanza/registry";
export async function cmdAdd(args: {
slot?: string;
moduleId?: string;
argv: Argv;
}): Promise<void> {
if (!args.slot || !args.moduleId) {
p.log.error("Usage: stanza add <slot> <module>");
process.exitCode = 1;
return;
}
if (!(KNOWN_SLOTS as readonly string[]).includes(args.slot)) {
p.log.error(`Unknown slot: ${args.slot}. Known: ${KNOWN_SLOTS.join(", ")}`);
process.exitCode = 1;
return;
}
const slot = args.slot as SlotId;
const projectRoot = findProjectRoot();
if (!projectRoot) {
p.log.error("No stanza.json found in this or any parent directory.");
process.exitCode = 1;
return;
}
const manifest = readManifest(projectRoot);
if (manifest.modules[slot]) {
p.log.error(
`Slot "${slot}" is already filled by "${manifest.modules[slot]!.id}". Run \`stanza remove ${slot}\` first.`,
);
process.exitCode = 1;
return;
}
const registry = await loadRegistry();
const mod = await registry.loadModule(slot, args.moduleId).catch(() => null);
if (!mod) {
p.log.error(`Module not found: ${slot}/${args.moduleId}`);
process.exitCode = 1;
return;
}
const resolved = resolveAdapter(mod, { manifest, pending: {} });
if (!resolved.ok) {
p.log.error(`Cannot add ${mod.label}: ${describeResolveError(resolved.error.kind)}`);
process.exitCode = 1;
return;
}
const dryRun = Boolean(args.argv["dry-run"]);
const spinner = p.spinner();
spinner.start(`Adding ${mod.label}`);
const registryRoot = pickRegistryRoot();
try {
await applyModule({
projectRoot,
manifest,
module: mod,
adapter: resolved.adapter,
registryRoot,
dryRun,
});
} catch (err) {
spinner.stop(`${mod.label} ${kleur.red("failed")}`);
throw err;
}
spinner.stop(`${kleur.green("✓")} ${mod.label} added`);
if (dryRun) p.log.info(kleur.yellow("[dry-run] no files were written"));
}
function describeResolveError(kind: string): string {
switch (kind) {
case "missing-peer":
return "a required peer module isn't installed.";
case "incompatible-peer":
return "the installed peer module isn't supported.";
case "no-adapter":
return "no adapter matches your current stack.";
default:
return kind;
}
}
function pickRegistryRoot(): string {
const override = process.env.STANZA_REGISTRY;
if (override && !override.startsWith("http")) return override;
const here = path.dirname(new URL(import.meta.url).pathname);
let dir = here;
for (let i = 0; i < 6; i++) {
if (fs.existsSync(path.join(dir, "registry", "modules"))) {
return path.join(dir, "registry");
}
dir = path.dirname(dir);
}
throw new Error("Could not locate stanza registry root.");
}
+159
View File
@@ -0,0 +1,159 @@
import path from "node:path";
import fs from "node:fs";
import type { Argv } from "mri";
import kleur from "kleur";
import * as p from "@clack/prompts";
import { initManifest } from "../manifest.ts";
import { loadRegistry } from "../registry-loader.ts";
import { runInitWizard } from "../wizard.ts";
import { applyModule } from "../codemod-runner.ts";
import { resolveAdapter, slotOrder } from "@stanza/registry";
export async function cmdInit(args: { name?: string; argv: Argv }): Promise<void> {
const registry = await loadRegistry();
const defaultName = args.name ?? path.basename(process.cwd());
const result = await runInitWizard({ registry, defaultName });
if (!result) return;
const projectRoot = path.resolve(process.cwd(), result.name);
if (fs.existsSync(projectRoot)) {
p.log.error(`Directory already exists: ${projectRoot}`);
process.exitCode = 1;
return;
}
fs.mkdirSync(projectRoot, { recursive: true });
// Bootstrap the empty monorepo shell.
bootstrapShell(projectRoot, {
name: result.name,
packageManager: result.packageManager,
appDir: result.appDir,
});
let manifest = initManifest({
projectRoot,
name: result.name,
appDir: result.appDir,
packageManager: result.packageManager,
});
const registryRoot = pickRegistryRoot();
const dryRun = Boolean(args.argv["dry-run"]);
if (dryRun) p.log.info(kleur.yellow("[dry-run] no files will be written"));
const spinner = p.spinner();
for (const slot of slotOrder) {
const mod = result.modules[slot];
if (!mod) continue;
spinner.start(`Installing ${mod.label}`);
const adapter = resolveAdapter(mod, { manifest, pending: {} });
if (!adapter.ok) {
spinner.stop(`${mod.label} ${kleur.red("failed")}`);
throw new Error(`Could not resolve adapter for ${slot}/${mod.id}: ${adapter.error.kind}`);
}
const r = await applyModule({
projectRoot,
manifest,
module: mod,
adapter: adapter.adapter,
registryRoot,
dryRun,
});
manifest = r.manifest;
spinner.stop(`${kleur.green("✓")} ${mod.label}`);
}
p.outro(
[
kleur.green("Done."),
"",
` ${kleur.dim("$")} cd ${result.name}`,
` ${kleur.dim("$")} ${result.packageManager} install`,
` ${kleur.dim("$")} ${result.packageManager} dev`,
].join("\n"),
);
}
function bootstrapShell(
projectRoot: string,
opts: { name: string; packageManager: "pnpm" | "bun" | "npm"; appDir: string },
) {
// Root package.json
fs.writeFileSync(
path.join(projectRoot, "package.json"),
JSON.stringify(
{
name: opts.name,
private: true,
version: "0.1.0",
packageManager: defaultPmVersion(opts.packageManager),
scripts: {
dev: `${opts.packageManager} -r run dev`,
build: `${opts.packageManager} -r run build`,
test: `${opts.packageManager} -r run test`,
},
},
null,
2,
) + "\n",
);
if (opts.packageManager === "pnpm") {
fs.writeFileSync(
path.join(projectRoot, "pnpm-workspace.yaml"),
`packages:\n - "apps/*"\n - "packages/*"\n`,
);
} else if (opts.packageManager === "bun") {
// Bun reads workspaces from package.json.
const pkgPath = path.join(projectRoot, "package.json");
const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8"));
pkg.workspaces = ["apps/*", "packages/*"];
fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + "\n");
} else {
const pkgPath = path.join(projectRoot, "package.json");
const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8"));
pkg.workspaces = ["apps/*", "packages/*"];
fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + "\n");
}
fs.writeFileSync(
path.join(projectRoot, ".gitignore"),
"node_modules/\ndist/\n.output/\n.vercel/\n.turbo/\n.env\n.env.local\n.env.*.local\n*.log\n",
);
fs.writeFileSync(
path.join(projectRoot, ".env.example"),
`# Stanza-managed environment variables.\n`,
);
// App shell — empty but layout-correct. The framework module fills it in.
fs.mkdirSync(path.join(projectRoot, opts.appDir), { recursive: true });
fs.mkdirSync(path.join(projectRoot, "packages"), { recursive: true });
}
function defaultPmVersion(pm: "pnpm" | "bun" | "npm"): string {
return { pnpm: "pnpm@9.12.0", bun: "bun@1.1.34", npm: "npm@10.9.0" }[pm];
}
function pickRegistryRoot(): string {
// Same resolution as registry-loader's local path lookup. Kept here so the
// template + codemod files can be sourced from the same place we read the
// index from.
const override = process.env.STANZA_REGISTRY;
if (override && !override.startsWith("http")) return override;
// Walk up from this file.
const here = path.dirname(new URL(import.meta.url).pathname);
let dir = here;
for (let i = 0; i < 6; i++) {
if (fs.existsSync(path.join(dir, "registry", "modules"))) {
return path.join(dir, "registry");
}
dir = path.dirname(dir);
}
throw new Error("Could not locate stanza registry root.");
}
+24
View File
@@ -0,0 +1,24 @@
import type { Argv } from "mri";
import * as p from "@clack/prompts";
import kleur from "kleur";
import { findProjectRoot, readManifest } from "../manifest.ts";
import { slotOrder, type SlotId } from "@stanza/registry";
export async function cmdList(_args: { argv: Argv }): Promise<void> {
const projectRoot = findProjectRoot();
if (!projectRoot) {
p.log.error("No stanza.json found.");
process.exitCode = 1;
return;
}
const manifest = readManifest(projectRoot);
const rows = slotOrder.map((slot: SlotId) => {
const m = manifest.modules[slot];
return m
? `${kleur.cyan(slot.padEnd(10))} ${m.id} ${kleur.dim(`@${m.version}`)} ${kleur.dim(`[${m.adapter}]`)}`
: `${kleur.cyan(slot.padEnd(10))} ${kleur.dim("(empty)")}`;
});
console.log(rows.join("\n"));
}
+100
View File
@@ -0,0 +1,100 @@
import type { Argv } from "mri";
import * as p from "@clack/prompts";
import kleur from "kleur";
import path from "node:path";
import fs from "node:fs";
import { findProjectRoot, readManifest, writeManifest } from "../manifest.ts";
import { regionsOwnedBy } from "../region-tracker.ts";
import { removePackageDependency, removeEnvVar } from "@stanza/codemods";
import { KNOWN_SLOTS, type SlotId } from "@stanza/registry";
export async function cmdRemove(args: { slot?: string; argv: Argv }): Promise<void> {
if (!args.slot) {
p.log.error("Usage: stanza remove <slot>");
process.exitCode = 1;
return;
}
if (!(KNOWN_SLOTS as readonly string[]).includes(args.slot)) {
p.log.error(`Unknown slot: ${args.slot}`);
process.exitCode = 1;
return;
}
const slot = args.slot as SlotId;
const projectRoot = findProjectRoot();
if (!projectRoot) {
p.log.error("No stanza.json found.");
process.exitCode = 1;
return;
}
const manifest = readManifest(projectRoot);
const installed = manifest.modules[slot];
if (!installed) {
p.log.warn(`Slot "${slot}" is not filled.`);
return;
}
const owned = regionsOwnedBy(manifest, installed.id);
const dryRun = Boolean(args.argv["dry-run"]);
// Best-effort reversal: deps, env vars, and whole-file templates revert
// cleanly. Regions touched by imperative codemods get reported as "needs
// manual cleanup" until proper inverse codemods land.
const manualCleanup: string[] = [];
for (const { file, region } of owned) {
const abs = path.join(projectRoot, file);
if (file === ".env.example") {
if (!dryRun) removeEnvVar(abs, region);
continue;
}
if (file.endsWith("package.json")) {
if (region.startsWith("dependencies.") || region.startsWith("devDependencies.")) {
const name = region.split(".").slice(1).join(".");
if (!dryRun) removePackageDependency(abs, name);
continue;
}
if (region.startsWith("scripts.")) {
if (!dryRun) {
const pkg = JSON.parse(fs.readFileSync(abs, "utf8"));
delete pkg.scripts?.[region.slice("scripts.".length)];
fs.writeFileSync(abs, JSON.stringify(pkg, null, 2) + "\n");
}
continue;
}
}
if (region === "file") {
// Whole-file template — safe to delete since stanza wrote it.
if (!dryRun && fs.existsSync(abs)) fs.unlinkSync(abs);
continue;
}
manualCleanup.push(`${file}:${region}`);
}
// Update manifest: drop module + its regions.
const nextRegions = { ...manifest.regions };
for (const { file, region } of owned) {
if (nextRegions[file]) {
const copy = { ...nextRegions[file] };
delete copy[region];
if (Object.keys(copy).length === 0) delete nextRegions[file];
else nextRegions[file] = copy;
}
}
const nextModules = { ...manifest.modules };
delete nextModules[slot];
if (!dryRun) {
writeManifest(projectRoot, { ...manifest, modules: nextModules, regions: nextRegions });
}
p.log.success(`${kleur.green("✓")} Removed ${installed.id} from ${slot}`);
if (manualCleanup.length > 0) {
p.log.warn(
`${manualCleanup.length} region(s) need manual cleanup:\n` +
manualCleanup.map((r) => `${r}`).join("\n"),
);
}
if (dryRun) p.log.info(kleur.yellow("[dry-run] no files were written"));
}
+29
View File
@@ -0,0 +1,29 @@
import type { Argv } from "mri";
import kleur from "kleur";
import { loadRegistry } from "../registry-loader.ts";
export async function cmdSearch(args: { query?: string; argv: Argv }): Promise<void> {
const registry = await loadRegistry();
const q = (args.query ?? "").toLowerCase().trim();
const results = registry.index.modules.filter((m) => {
if (!q) return true;
return (
m.id.toLowerCase().includes(q) ||
m.label.toLowerCase().includes(q) ||
m.description.toLowerCase().includes(q) ||
m.slot.includes(q)
);
});
if (results.length === 0) {
console.log(kleur.dim("No modules found."));
return;
}
for (const m of results) {
const head = `${kleur.bold(m.label)} ${kleur.dim(`(${m.slot}/${m.id})`)}`;
const desc = m.description ? ` ${kleur.dim(m.description)}` : "";
console.log(`${head}\n${desc}`);
}
}
+1
View File
@@ -0,0 +1 @@
export { run } from "./run.ts";
+54
View File
@@ -0,0 +1,54 @@
import fs from "node:fs";
import path from "node:path";
import { StanzaManifestSchema, type StanzaManifest, emptyManifest } from "@stanza/registry";
const MANIFEST_FILENAME = "stanza.json";
export function manifestPath(projectRoot: string): string {
return path.join(projectRoot, MANIFEST_FILENAME);
}
export function readManifest(projectRoot: string): StanzaManifest {
const file = manifestPath(projectRoot);
if (!fs.existsSync(file)) {
throw new Error(`No stanza.json found in ${projectRoot}. Run \`stanza init\` first.`);
}
const parsed = StanzaManifestSchema.safeParse(JSON.parse(fs.readFileSync(file, "utf8")));
if (!parsed.success) {
throw new Error(
`Malformed stanza.json:\n${parsed.error.issues
.map((i) => `${i.path.join(".")}: ${i.message}`)
.join("\n")}`,
);
}
return parsed.data;
}
export function writeManifest(projectRoot: string, manifest: StanzaManifest): void {
const file = manifestPath(projectRoot);
fs.writeFileSync(file, JSON.stringify(manifest, null, 2) + "\n", "utf8");
}
export function findProjectRoot(cwd: string = process.cwd()): string | undefined {
let dir = path.resolve(cwd);
while (dir !== path.dirname(dir)) {
if (fs.existsSync(path.join(dir, MANIFEST_FILENAME))) return dir;
dir = path.dirname(dir);
}
return undefined;
}
export function initManifest(input: {
projectRoot: string;
name: string;
appDir?: string;
packageManager?: StanzaManifest["packageManager"];
}): StanzaManifest {
const m = emptyManifest({
name: input.name,
appDir: input.appDir,
packageManager: input.packageManager,
});
writeManifest(input.projectRoot, m);
return m;
}
+69
View File
@@ -0,0 +1,69 @@
import type { StanzaManifest } from "@stanza/registry";
/**
* Region ownership operations on a manifest. Pure functions that return a
* new manifest — call sites are responsible for persisting via writeManifest.
*/
export class RegionConflictError extends Error {
constructor(
public readonly file: string,
public readonly region: string,
public readonly existingOwner: string,
public readonly newOwner: string,
) {
super(
`Region conflict in ${file}/${region}: already owned by "${existingOwner}", "${newOwner}" tried to claim`,
);
this.name = "RegionConflictError";
}
}
export function claim(
manifest: StanzaManifest,
file: string,
region: string,
owner: string,
): StanzaManifest {
const current = manifest.regions[file]?.[region];
if (current && current !== owner) {
throw new RegionConflictError(file, region, current, owner);
}
return {
...manifest,
regions: {
...manifest.regions,
[file]: { ...manifest.regions[file], [region]: owner },
},
};
}
export function release(manifest: StanzaManifest, file: string, region: string): StanzaManifest {
const fileRegions = manifest.regions[file];
if (!fileRegions) return manifest;
const next: Record<string, string> = { ...fileRegions };
delete next[region];
const regions = { ...manifest.regions };
if (Object.keys(next).length === 0) {
delete regions[file];
} else {
regions[file] = next;
}
return { ...manifest, regions };
}
export function regionsOwnedBy(
manifest: StanzaManifest,
owner: string,
): { file: string; region: string }[] {
const out: { file: string; region: string }[] = [];
for (const [file, regions] of Object.entries(manifest.regions)) {
for (const [region, value] of Object.entries(regions)) {
if (value === owner) out.push({ file, region });
}
}
return out;
}
+134
View File
@@ -0,0 +1,134 @@
import type { Module, RegistryIndex } from "@stanza/registry";
import path from "node:path";
import fs from "node:fs";
import { fileURLToPath } from "node:url";
/**
* In dev (when running from the stanza monorepo), modules are imported
* directly from `registry/modules/<id>/module.ts`. In production (published
* CLI), the registry is a static JSON endpoint plus per-module bundles.
*
* Resolution order:
* 1. `STANZA_REGISTRY` env var — explicit URL or filesystem path.
* 2. Local workspace at `../../registry` (the stanza monorepo dev case).
* 3. The default CDN URL `https://stanza.dev/registry`.
*/
const DEFAULT_REGISTRY_URL = "https://stanza.dev/registry";
export type Registry = {
index: RegistryIndex;
loadModule(slot: string, id: string): Promise<Module>;
};
export async function loadRegistry(): Promise<Registry> {
const envOverride = process.env.STANZA_REGISTRY;
if (envOverride) {
return envOverride.startsWith("http")
? loadHttpRegistry(envOverride)
: loadFsRegistry(envOverride);
}
const localPath = resolveLocalRegistry();
if (localPath) return loadFsRegistry(localPath);
return loadHttpRegistry(DEFAULT_REGISTRY_URL);
}
function resolveLocalRegistry(): string | undefined {
// When running from `apps/cli/src/bin.ts`, walk up until we find a sibling
// `registry/modules/` dir — the dev-time monorepo layout.
const here = path.dirname(fileURLToPath(import.meta.url));
let dir = here;
for (let i = 0; i < 6; i++) {
const candidate = path.join(dir, "registry", "modules");
if (fs.existsSync(candidate)) return path.join(dir, "registry");
dir = path.dirname(dir);
}
return undefined;
}
async function loadFsRegistry(rootDir: string): Promise<Registry> {
const modulesDir = path.join(rootDir, "modules");
const ids = fs
.readdirSync(modulesDir, { withFileTypes: true })
.filter((d) => d.isDirectory())
.map((d) => d.name);
// Lazily import each module manifest. We don't keep them all in memory;
// we keep summaries for the index and re-import full modules on demand.
const summaries = await Promise.all(
ids.map(async (name) => {
const mod = await importModule(modulesDir, name);
return summarize(mod);
}),
);
const index: RegistryIndex = {
generatedAt: new Date().toISOString(),
schemaVersion: 1,
slots: slotMetadata(),
modules: summaries,
};
return {
index,
async loadModule(_slot, id) {
// Modules are stored as `<slot>-<id>` directories. We accept either the
// bare id (preferred) or the slot-prefixed dir name.
const dirName = ids.find((d) => d === id || d.endsWith(`-${id}`) || d === `${_slot}-${id}`);
if (!dirName) {
throw new Error(`Module not found in local registry: ${_slot}/${id}`);
}
return importModule(modulesDir, dirName);
},
};
}
async function loadHttpRegistry(baseUrl: string): Promise<Registry> {
const indexRes = await fetch(`${baseUrl}/index.json`);
if (!indexRes.ok) {
throw new Error(`Failed to load stanza registry from ${baseUrl}: ${indexRes.status}`);
}
const index = (await indexRes.json()) as RegistryIndex;
return {
index,
async loadModule(slot, id) {
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;
// NOTE: HTTP-loaded modules can't ship codemod *functions* — they
// contain only the manifest + codemod ids. The CLI ships a bundled
// registry of codemod implementations keyed by id, populated at build
// time from the first-party modules. Custom codemod bundles for
// third-party modules are a future addition (signed JS payloads).
},
};
}
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 };
if (!mod.default) {
throw new Error(`Module ${dirName} has no default export at ${entry}`);
}
return mod.default;
}
function summarize(mod: Module) {
return {
...mod,
adapters: mod.adapters.map((a) => ({ key: a.key, match: a.match })),
};
}
function slotMetadata() {
return [
{ id: "framework" as const, label: "Framework", description: "Web/native app framework." },
{ id: "styling" as const, label: "Styling", description: "CSS / styling system." },
{ id: "db" as const, label: "Database", description: "Database engine." },
{ id: "orm" as const, label: "ORM", description: "Database query layer." },
{ id: "auth" as const, label: "Auth", description: "Authentication provider." },
];
}
+66
View File
@@ -0,0 +1,66 @@
import type { Argv } from "mri";
import kleur from "kleur";
import { cmdInit } from "./commands/init.ts";
import { cmdAdd } from "./commands/add.ts";
import { cmdRemove } from "./commands/remove.ts";
import { cmdList } from "./commands/list.ts";
import { cmdSearch } from "./commands/search.ts";
const VERSION = "0.1.0";
const HELP = `${kleur.bold("stanza")} — modular monorepo template CLI
${kleur.bold("Usage")}
stanza <command> [options]
${kleur.bold("Commands")}
init [name] Scaffold a new monorepo via the interactive wizard.
add <slot> <module> Add a module to the current project.
remove <slot> Remove the module currently filling a slot.
list List installed modules.
search [query] Search the registry.
${kleur.bold("Options")}
-h, --help Show this help.
-v, --version Print the CLI version.
--yes Accept all defaults; suppress prompts.
--dry-run Print the actions that would be taken; write nothing.
--no-telemetry Disable telemetry for this invocation.
${kleur.dim("Docs: https://stanza.dev")}
`;
export async function run(argv: Argv): Promise<void> {
if (argv.version) {
console.log(VERSION);
return;
}
const [command, ...rest] = argv._;
if (!command || argv.help) {
console.log(HELP);
return;
}
switch (command) {
case "init":
await cmdInit({ name: rest[0], argv });
return;
case "add":
await cmdAdd({ slot: rest[0], moduleId: rest[1], argv });
return;
case "remove":
await cmdRemove({ slot: rest[0], argv });
return;
case "list":
await cmdList({ argv });
return;
case "search":
await cmdSearch({ query: rest.join(" "), argv });
return;
default:
console.error(kleur.red(`Unknown command: ${command}`));
console.error(HELP);
process.exitCode = 1;
}
}
+122
View File
@@ -0,0 +1,122 @@
import * as p from "@clack/prompts";
import kleur from "kleur";
import type { Module, RegistryIndex, SlotId } from "@stanza/registry";
import { resolveAdapter, slotOrder } from "@stanza/registry";
import type { Registry } from "./registry-loader.ts";
import { emptyManifest } from "@stanza/registry";
export type WizardResult = {
name: string;
appDir: string;
packageManager: "pnpm" | "bun" | "npm";
modules: Partial<Record<SlotId, Module>>;
};
/**
* Run the interactive `stanza init` wizard. Topological prompt order (defined
* by slotOrder); slots that don't have any compatible modules given prior
* picks are skipped. Returns the user's selections.
*/
export async function runInitWizard(args: {
registry: Registry;
defaultName: string;
}): Promise<WizardResult | null> {
const { registry, defaultName } = args;
p.intro(kleur.bold().cyan("stanza"));
const name = await p.text({
message: "Project name",
initialValue: defaultName,
validate: (v) =>
v && /^[a-z0-9][a-z0-9-]*$/i.test(v) ? undefined : "Use letters, digits, dashes.",
});
if (p.isCancel(name)) return cancel();
const modules: Partial<Record<SlotId, Module>> = {};
for (const slot of slotOrder) {
const candidates = candidatesForSlot(registry.index, slot, modules);
if (candidates.length === 0) {
continue;
}
const choices = candidates.map((m) => ({
value: m.id,
label: m.label,
hint: m.description,
}));
const choice = await p.select({
message: `${slotLabel(slot)}?`,
options: [...choices, { value: "__skip__", label: kleur.dim("Skip this slot") }],
});
if (p.isCancel(choice)) return cancel();
if (choice === "__skip__") continue;
const full = await registry.loadModule(slot, choice as string);
modules[slot] = full;
}
const pmChoice = await p.select({
message: "Package manager?",
options: [
{ value: "pnpm", label: "pnpm", hint: "Recommended" },
{ value: "bun", label: "bun" },
{ value: "npm", label: "npm" },
],
initialValue: "pnpm",
});
if (p.isCancel(pmChoice)) return cancel();
// Summary screen — what we're about to write.
const summary = [
`${kleur.bold("Name:")} ${String(name)}`,
`${kleur.bold("Package manager:")} ${String(pmChoice)}`,
"",
...Object.entries(modules).map(
([slot, mod]) =>
`${kleur.bold(slotLabel(slot as SlotId).padEnd(16))} ${mod!.label} ${kleur.dim(`(${mod!.id})`)}`,
),
].join("\n");
p.note(summary, "Summary");
const confirm = await p.confirm({ message: "Scaffold this project?" });
if (p.isCancel(confirm) || !confirm) return cancel();
return {
name: String(name),
appDir: "apps/web",
packageManager: pmChoice as "pnpm" | "bun" | "npm",
modules,
};
}
function candidatesForSlot(
index: RegistryIndex,
slot: SlotId,
picked: Partial<Record<SlotId, Module>>,
): RegistryIndex["modules"] {
const manifest = emptyManifest({ name: "tmp" });
return index.modules
.filter((m) => m.slot === slot)
.filter((m) => {
// Use the resolver with summary adapters — we only need the peer check,
// not the full adapter selection. Build a temp Module with empty adapter
// bodies just to validate peers.
const synthetic: Module = { ...m, adapters: m.adapters.map((a) => ({ ...a })) };
const result = resolveAdapter(synthetic, { manifest, pending: picked });
return result.ok;
});
}
function slotLabel(slot: SlotId): string {
return { framework: "Framework", styling: "Styling", db: "Database", orm: "ORM", auth: "Auth" }[
slot
];
}
function cancel(): null {
p.cancel("Cancelled.");
return null;
}
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"types": ["node"]
},
"include": ["src"]
}
+27
View File
@@ -0,0 +1,27 @@
{
"name": "@stanza/web",
"version": "0.1.0",
"private": true,
"license": "MIT",
"type": "module",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"start": "node .output/server/index.mjs",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@stanza/registry": "workspace:*",
"@tanstack/react-router": "^1.170.5",
"@tanstack/react-start": "^1.168.7",
"react": "^19.2.6",
"react-dom": "^19.2.6"
},
"devDependencies": {
"@types/react": "^19.2.15",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.2",
"typescript": "^6.0.3",
"vite": "^8.0.13"
}
}
+110
View File
@@ -0,0 +1,110 @@
import { useMemo, useState } from "react";
import type { ModuleSummary, RegistryIndex, SlotId } from "@stanza/registry";
import { resolveAdapter, slotOrder, emptyManifest } from "@stanza/registry";
type Selections = Partial<Record<SlotId, string>>;
export function Builder({ index }: { index: RegistryIndex }) {
const [selections, setSelections] = useState<Selections>({});
const [name, setName] = useState("my-app");
const moduleById = useMemo(() => {
const m = new Map<string, ModuleSummary>();
for (const mod of index.modules) m.set(`${mod.slot}:${mod.id}`, mod);
return m;
}, [index]);
const compatibleBySlot = useMemo(() => {
const out: Record<SlotId, ModuleSummary[]> = {
framework: [],
styling: [],
db: [],
orm: [],
auth: [],
};
const pending = pendingFromSelections(selections, moduleById);
for (const slot of slotOrder) {
out[slot] = index.modules
.filter((m) => m.slot === slot)
.filter((m) => {
const synthetic = { ...m, adapters: m.adapters.map((a) => ({ ...a })) };
return resolveAdapter(synthetic, {
manifest: emptyManifest({ name: "t" }),
pending,
}).ok;
});
}
return out;
}, [index, selections, moduleById]);
const command = useMemo(() => buildCommand(name, selections), [name, selections]);
return (
<div>
<label style={{ display: "block", marginBottom: "1rem" }}>
<div>Project name</div>
<input
aria-label="Project name"
value={name}
onChange={(e) => setName(e.target.value)}
style={{ padding: "0.5rem", width: "100%" }}
/>
</label>
{slotOrder.map((slot) => (
<fieldset
key={slot}
style={{ margin: "0 0 1rem 0", padding: "0.75rem", border: "1px solid #ddd" }}
>
<legend>{slotLabel(slot)}</legend>
{compatibleBySlot[slot].length === 0 ? (
<div style={{ opacity: 0.6 }}>No compatible modules with current picks.</div>
) : (
compatibleBySlot[slot].map((m) => (
<label key={m.id} style={{ display: "block" }}>
<input
type="radio"
name={slot}
aria-label={m.label}
checked={selections[slot] === m.id}
onChange={() => setSelections((s) => ({ ...s, [slot]: m.id }))}
/>{" "}
<strong>{m.label}</strong> <span style={{ opacity: 0.7 }}>{m.description}</span>
</label>
))
)}
</fieldset>
))}
<pre style={{ background: "#111", color: "#eee", padding: "1rem", borderRadius: "0.25rem" }}>
{command}
</pre>
</div>
);
}
function pendingFromSelections(selections: Selections, moduleById: Map<string, ModuleSummary>) {
const pending: Record<string, ModuleSummary> = {};
for (const [slot, id] of Object.entries(selections)) {
if (!id) continue;
const mod = moduleById.get(`${slot}:${id}`);
if (mod) pending[slot] = mod;
}
// The resolver expects full Modules; the empty `adapters` slot bodies don't
// matter for peer-validation, so cast is safe.
return pending as Parameters<typeof resolveAdapter>[1]["pending"];
}
function buildCommand(name: string, selections: Selections): string {
const flags = slotOrder
.map((s) => (selections[s] ? `--${s}=${selections[s]}` : null))
.filter(Boolean)
.join(" ");
return `pnpm create stanza ${name} ${flags}`.trim();
}
function slotLabel(slot: SlotId): string {
return { framework: "Framework", styling: "Styling", db: "Database", orm: "ORM", auth: "Auth" }[
slot
];
}
+68
View File
@@ -0,0 +1,68 @@
/* eslint-disable */
// @ts-nocheck
// noinspection JSUnusedGlobalSymbols
// This file was automatically generated by TanStack Router.
// You should NOT make any changes in this file as it will be overwritten.
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
import { Route as rootRouteImport } from "./routes/__root";
import { Route as IndexRouteImport } from "./routes/index";
const IndexRoute = IndexRouteImport.update({
id: "/",
path: "/",
getParentRoute: () => rootRouteImport,
} as any);
export interface FileRoutesByFullPath {
"/": typeof IndexRoute;
}
export interface FileRoutesByTo {
"/": typeof IndexRoute;
}
export interface FileRoutesById {
__root__: typeof rootRouteImport;
"/": typeof IndexRoute;
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath;
fullPaths: "/";
fileRoutesByTo: FileRoutesByTo;
to: "/";
id: "__root__" | "/";
fileRoutesById: FileRoutesById;
}
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute;
}
declare module "@tanstack/react-router" {
interface FileRoutesByPath {
"/": {
id: "/";
path: "/";
fullPath: "/";
preLoaderRoute: typeof IndexRouteImport;
parentRoute: typeof rootRouteImport;
};
}
}
const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
};
export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren)
._addFileTypes<FileRouteTypes>();
import type { getRouter } from "./router.tsx";
import type { createStart } from "@tanstack/react-start";
declare module "@tanstack/react-start" {
interface Register {
ssr: true;
router: Awaited<ReturnType<typeof getRouter>>;
}
}
+16
View File
@@ -0,0 +1,16 @@
import { createRouter } from "@tanstack/react-router";
import { routeTree } from "./routeTree.gen";
export function getRouter() {
return createRouter({
routeTree,
scrollRestoration: true,
defaultPreload: "intent",
});
}
declare module "@tanstack/react-router" {
interface Register {
router: ReturnType<typeof getRouter>;
}
}
+27
View File
@@ -0,0 +1,27 @@
import { HeadContent, Outlet, Scripts, createRootRoute } from "@tanstack/react-router";
export const Route = createRootRoute({
head: () => ({
meta: [
{ charSet: "utf-8" },
{ name: "viewport", content: "width=device-width, initial-scale=1" },
{ title: "stanza" },
{ name: "description", content: "Modular monorepo template builder." },
],
}),
component: RootComponent,
});
function RootComponent() {
return (
<html lang="en">
<head>
<HeadContent />
</head>
<body>
<Outlet />
<Scripts />
</body>
</html>
);
}
+29
View File
@@ -0,0 +1,29 @@
import { createFileRoute } from "@tanstack/react-router";
import { createServerFn } from "@tanstack/react-start";
import type { RegistryIndex } from "@stanza/registry";
import { Builder } from "~/components/builder.tsx";
const getRegistryIndex = createServerFn({ method: "GET" }).handler(async () => {
const url = process.env.STANZA_REGISTRY ?? "https://stanza.dev/registry";
const res = await fetch(`${url}/index.json`);
if (!res.ok) {
throw new Error(`Failed to load stanza registry: ${res.status}`);
}
return (await res.json()) as RegistryIndex;
});
export const Route = createFileRoute("/")({
loader: () => getRegistryIndex(),
component: Page,
});
function Page() {
const index = Route.useLoaderData();
return (
<main style={{ maxWidth: "48rem", margin: "4rem auto", padding: "0 1rem" }}>
<h1>stanza</h1>
<p>Pick your stack. Get an idiomatic monorepo.</p>
<Builder index={index} />
</main>
);
}
+16
View File
@@ -0,0 +1,16 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"jsx": "preserve",
"jsxImportSource": "react",
"lib": ["DOM", "DOM.Iterable", "ES2023"],
"moduleResolution": "Bundler",
"verbatimModuleSyntax": false,
"noEmit": true,
"allowImportingTsExtensions": true,
"paths": {
"~/*": ["./src/*"]
}
},
"include": ["src/**/*.ts", "src/**/*.tsx", "vite.config.ts"]
}
+13
View File
@@ -0,0 +1,13 @@
import { defineConfig } from "vite";
import { tanstackStart } from "@tanstack/react-start/plugin/vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [
// tanstackStart() MUST come before react() — the Start plugin generates
// route trees and transforms server functions; the React plugin reads
// the transformed output.
tanstackStart(),
react(),
],
});
+31
View File
@@ -0,0 +1,31 @@
{
"name": "stanza",
"version": "0.1.0",
"private": true,
"description": "Modular monorepo template CLI — shadcn for stacks.",
"license": "MIT",
"author": "Jake Jarvis <jakejarvis@gmail.com>",
"scripts": {
"build": "turbo run build",
"dev": "turbo run dev",
"test": "turbo run test",
"typecheck": "turbo run typecheck",
"lint": "oxlint",
"lint:fix": "oxlint --fix",
"fmt": "oxfmt",
"fmt:check": "oxfmt --check",
"registry:build": "turbo run registry:build --filter=@stanza/registry-build"
},
"devDependencies": {
"@types/node": "^25.9.1",
"oxfmt": "^0.51.0",
"oxlint": "^1.66.0",
"turbo": "^2.9.14",
"typescript": "^6.0.3"
},
"engines": {
"node": ">=22",
"pnpm": ">=10"
},
"packageManager": "pnpm@10.20.0"
}
+29
View File
@@ -0,0 +1,29 @@
{
"name": "@stanza/codemods",
"version": "0.1.0",
"license": "MIT",
"files": [
"src",
"dist"
],
"type": "module",
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": "./src/index.ts"
},
"scripts": {
"build": "tsc",
"typecheck": "tsc --noEmit",
"test": "vitest run"
},
"dependencies": {
"@stanza/registry": "workspace:*",
"ts-morph": "^28.0.0"
},
"devDependencies": {
"@types/node": "^25.9.1",
"typescript": "^6.0.3",
"vitest": "^4.1.7"
}
}
+63
View File
@@ -0,0 +1,63 @@
import { type SourceFile, SyntaxKind, type ArrayLiteralExpression } from "ts-morph";
/**
* Add an element to an array literal exported (or assigned to) under a
* specific name. Idempotent — won't duplicate identical text.
*/
export function addArrayElement(
sourceFile: SourceFile,
exportedName: string,
elementText: string,
): void {
const arr = findArrayLiteral(sourceFile, exportedName);
if (!arr) {
throw new Error(
`addArrayElement: no array literal named "${exportedName}" found in ${sourceFile.getFilePath()}`,
);
}
const existing = arr.getElements().map((e) => e.getText().trim());
if (existing.includes(elementText.trim())) return;
arr.addElement(elementText);
}
export function removeArrayElement(
sourceFile: SourceFile,
exportedName: string,
elementText: string,
): void {
const arr = findArrayLiteral(sourceFile, exportedName);
if (!arr) return;
const target = elementText.trim();
arr
.getElements()
.filter((e) => e.getText().trim() === target)
.forEach((e) => arr.removeElement(e));
}
function findArrayLiteral(
sourceFile: SourceFile,
name: string,
): ArrayLiteralExpression | undefined {
// export const X = [...]
const v = sourceFile.getVariableDeclaration(name);
if (v) {
const init = v.getInitializer();
if (init?.isKind(SyntaxKind.ArrayLiteralExpression)) {
return init.asKindOrThrow(SyntaxKind.ArrayLiteralExpression);
}
}
// export default [...]
if (name === "default") {
const def = sourceFile.getExportAssignment(() => true);
const init = def?.getExpression();
if (init?.isKind(SyntaxKind.ArrayLiteralExpression)) {
return init.asKindOrThrow(SyntaxKind.ArrayLiteralExpression);
}
}
return undefined;
}
+50
View File
@@ -0,0 +1,50 @@
import { describe, expect, it, beforeEach } from "vitest";
import fs from "node:fs";
import path from "node:path";
import os from "node:os";
import { addEnvVar, removeEnvVar } from "./env.ts";
let tmp: string;
beforeEach(() => {
tmp = fs.mkdtempSync(path.join(os.tmpdir(), "stanza-test-"));
});
function envFile(initial: string): string {
const p = path.join(tmp, ".env.example");
fs.writeFileSync(p, initial);
return p;
}
describe("addEnvVar", () => {
it("creates the file if missing", () => {
const p = path.join(tmp, ".env.example");
addEnvVar(p, "DATABASE_URL", "postgres://localhost/db");
expect(fs.readFileSync(p, "utf8")).toMatch(/DATABASE_URL=postgres/);
});
it("appends to an existing file", () => {
const p = envFile("FOO=bar\n");
addEnvVar(p, "BAZ", "qux");
const out = fs.readFileSync(p, "utf8");
expect(out).toContain("FOO=bar");
expect(out).toContain("BAZ=qux");
});
it("is idempotent (updates in place rather than appending)", () => {
const p = envFile("FOO=bar\n");
addEnvVar(p, "FOO", "baz");
const out = fs.readFileSync(p, "utf8");
expect(out).toBe("FOO=baz\n");
});
});
describe("removeEnvVar", () => {
it("removes the entry and an attached preceding comment", () => {
const p = envFile("# the foo\nFOO=bar\nKEEP=me\n");
removeEnvVar(p, "FOO");
const out = fs.readFileSync(p, "utf8");
expect(out).not.toContain("FOO");
expect(out).not.toContain("# the foo");
expect(out).toContain("KEEP=me");
});
});
+53
View File
@@ -0,0 +1,53 @@
import fs from "node:fs";
/**
* Idempotently append an env var to a .env.example-style file. Preserves
* existing entries; updates the example value in-place if the var already
* exists; adds a leading comment if `description` is supplied.
*/
export function addEnvVar(
envFile: string,
name: string,
example: string,
description?: string,
): void {
const contents = fs.existsSync(envFile) ? fs.readFileSync(envFile, "utf8") : "";
const lines = contents.split("\n");
const existingIdx = lines.findIndex((line) => line.replace(/^#\s*/, "").startsWith(`${name}=`));
const entry = description ? `# ${description}\n${name}=${example}` : `${name}=${example}`;
if (existingIdx >= 0) {
// Replace existing line (and a preceding comment if present and matches description).
const prev = lines[existingIdx - 1];
if (description && prev?.startsWith("#")) {
lines.splice(existingIdx - 1, 2, ...entry.split("\n"));
} else {
lines.splice(existingIdx, 1, ...entry.split("\n"));
}
} else {
if (contents.length > 0 && !contents.endsWith("\n")) lines.push("");
if (lines.length > 0 && lines[lines.length - 1] !== "") lines.push("");
lines.push(...entry.split("\n"));
}
fs.writeFileSync(envFile, lines.join("\n").replace(/\n+$/, "\n"), "utf8");
}
export function removeEnvVar(envFile: string, name: string): void {
if (!fs.existsSync(envFile)) return;
const lines = fs.readFileSync(envFile, "utf8").split("\n");
const idx = lines.findIndex((line) => line.replace(/^#\s*/, "").startsWith(`${name}=`));
if (idx < 0) return;
// Remove the var line and a preceding standalone comment if it looks attached.
const prev = lines[idx - 1];
if (prev?.startsWith("#") && (lines[idx - 2] === "" || idx - 2 < 0)) {
lines.splice(idx - 1, 2);
} else {
lines.splice(idx, 1);
}
fs.writeFileSync(envFile, lines.join("\n"), "utf8");
}
+62
View File
@@ -0,0 +1,62 @@
import { describe, expect, it } from "vitest";
import { Project } from "ts-morph";
import { addNamedImport, addDefaultImport, removeImport } from "./imports.ts";
function file(src: string) {
const project = new Project({ useInMemoryFileSystem: true });
return project.createSourceFile("x.ts", src);
}
describe("addNamedImport", () => {
it("inserts when no import for the module exists", () => {
const sf = file("");
addNamedImport(sf, "react", "useState");
expect(sf.getText()).toContain(`import { useState } from "react"`);
});
it("merges named imports into an existing declaration", () => {
const sf = file(`import { useState } from "react";\n`);
addNamedImport(sf, "react", ["useEffect", "useMemo"]);
const text = sf.getText();
expect(text).toContain("useState");
expect(text).toContain("useEffect");
expect(text).toContain("useMemo");
expect(text.match(/from "react"/g)?.length).toBe(1);
});
it("is idempotent on the same named import", () => {
const sf = file(`import { useState } from "react";\n`);
addNamedImport(sf, "react", "useState");
expect(sf.getText().match(/useState/g)?.length).toBe(1);
});
});
describe("addDefaultImport", () => {
it("adds a default import alongside named imports if present", () => {
const sf = file(`import { useState } from "react";\n`);
addDefaultImport(sf, "react", "React");
expect(sf.getText()).toMatch(/import React, \{ useState \} from "react"/);
});
});
describe("removeImport", () => {
it("removes the whole declaration when named is omitted", () => {
const sf = file(`import { useState } from "react";\n`);
removeImport(sf, "react");
expect(sf.getText()).not.toContain("react");
});
it("removes only the listed named imports, keeping the rest", () => {
const sf = file(`import { useState, useEffect } from "react";\n`);
removeImport(sf, "react", ["useState"]);
const text = sf.getText();
expect(text).toContain("useEffect");
expect(text).not.toContain("useState");
});
it("drops the declaration when all named imports are removed and no default", () => {
const sf = file(`import { useState } from "react";\n`);
removeImport(sf, "react", ["useState"]);
expect(sf.getText()).not.toContain("react");
});
});
+84
View File
@@ -0,0 +1,84 @@
import type { SourceFile } from "ts-morph";
/**
* Idempotently add a named import. If the module specifier is already
* imported, merges the named entries; otherwise inserts a new import after
* the last existing import statement.
*/
export function addNamedImport(
sourceFile: SourceFile,
moduleSpecifier: string,
named: string | string[],
): void {
const names = Array.isArray(named) ? named : [named];
const existing = sourceFile.getImportDeclaration(
(decl) => decl.getModuleSpecifierValue() === moduleSpecifier,
);
if (existing) {
const already = new Set(existing.getNamedImports().map((n) => n.getName()));
const toAdd = names.filter((n) => !already.has(n));
if (toAdd.length > 0) {
existing.addNamedImports(toAdd.map((name) => ({ name })));
}
return;
}
sourceFile.addImportDeclaration({
moduleSpecifier,
namedImports: names.map((name) => ({ name })),
});
}
export function addDefaultImport(
sourceFile: SourceFile,
moduleSpecifier: string,
defaultName: string,
): void {
const existing = sourceFile.getImportDeclaration(
(decl) => decl.getModuleSpecifierValue() === moduleSpecifier,
);
if (existing) {
if (!existing.getDefaultImport()) {
existing.setDefaultImport(defaultName);
}
return;
}
sourceFile.addImportDeclaration({
moduleSpecifier,
defaultImport: defaultName,
});
}
export function removeImport(
sourceFile: SourceFile,
moduleSpecifier: string,
named?: string[],
): void {
const existing = sourceFile.getImportDeclaration(
(decl) => decl.getModuleSpecifierValue() === moduleSpecifier,
);
if (!existing) return;
if (!named) {
existing.remove();
return;
}
const toRemove = new Set(named);
existing
.getNamedImports()
.filter((n) => toRemove.has(n.getName()))
.forEach((n) => n.remove());
// If we removed everything and no default, drop the declaration.
if (
existing.getNamedImports().length === 0 &&
!existing.getDefaultImport() &&
!existing.getNamespaceImport()
) {
existing.remove();
}
}
+26
View File
@@ -0,0 +1,26 @@
export type { CodemodContext, Codemod, CodemodResult } from "./types.ts";
// Re-export the ts-morph Project type so consumers don't need a direct
// ts-morph dep just to type a parameter.
export type { Project, SourceFile } from "ts-morph";
export { openProject } from "./project.ts";
export { addNamedImport, addDefaultImport, removeImport } from "./imports.ts";
export { addArrayElement, removeArrayElement } from "./arrays.ts";
export {
readJson,
writeJson,
mergeJson,
setJsonPath,
unsetJsonPath,
addPackageDependency,
removePackageDependency,
addPackageScript,
} from "./json.ts";
export { addEnvVar, removeEnvVar } from "./env.ts";
export { renderTemplate, writeTemplateFile } from "./template.ts";
+75
View File
@@ -0,0 +1,75 @@
import { describe, expect, it, beforeEach } from "vitest";
import fs from "node:fs";
import path from "node:path";
import os from "node:os";
import {
addPackageDependency,
addPackageScript,
mergeJson,
removePackageDependency,
} from "./json.ts";
let tmp: string;
beforeEach(() => {
tmp = fs.mkdtempSync(path.join(os.tmpdir(), "stanza-test-"));
});
function writePkg(contents: object): string {
const p = path.join(tmp, "package.json");
fs.writeFileSync(p, JSON.stringify(contents, null, 2));
return p;
}
describe("addPackageDependency", () => {
it("adds to dependencies by default", () => {
const p = writePkg({ name: "x", dependencies: { react: "^18" } });
addPackageDependency(p, "better-auth", "^1.0.0");
const out = JSON.parse(fs.readFileSync(p, "utf8"));
expect(out.dependencies["better-auth"]).toBe("^1.0.0");
expect(out.dependencies.react).toBe("^18");
});
it("creates devDependencies when dev: true", () => {
const p = writePkg({ name: "x" });
addPackageDependency(p, "vitest", "^2", { dev: true });
const out = JSON.parse(fs.readFileSync(p, "utf8"));
expect(out.devDependencies.vitest).toBe("^2");
});
});
describe("removePackageDependency", () => {
it("removes from both dependencies and devDependencies", () => {
const p = writePkg({
name: "x",
dependencies: { foo: "1" },
devDependencies: { foo: "1" },
});
removePackageDependency(p, "foo");
const out = JSON.parse(fs.readFileSync(p, "utf8"));
expect(out.dependencies.foo).toBeUndefined();
expect(out.devDependencies.foo).toBeUndefined();
});
});
describe("mergeJson", () => {
it("deep-merges objects and reports touched paths", () => {
const p = writePkg({ name: "x", scripts: { dev: "vite" } });
const touched = mergeJson(p, {
scripts: { dev: "vite --host", db: "drizzle" },
});
expect(touched).toContain("scripts.dev");
expect(touched).toContain("scripts.db");
const out = JSON.parse(fs.readFileSync(p, "utf8"));
expect(out.scripts.dev).toBe("vite --host");
expect(out.scripts.db).toBe("drizzle");
});
});
describe("addPackageScript", () => {
it("adds a script entry", () => {
const p = writePkg({ name: "x" });
addPackageScript(p, "db:migrate", "drizzle-kit migrate");
const out = JSON.parse(fs.readFileSync(p, "utf8"));
expect(out.scripts["db:migrate"]).toBe("drizzle-kit migrate");
});
});
+106
View File
@@ -0,0 +1,106 @@
import fs from "node:fs";
export function readJson<T = unknown>(path: string): T {
return JSON.parse(fs.readFileSync(path, "utf8")) as T;
}
export function writeJson(path: string, value: unknown): void {
fs.writeFileSync(path, JSON.stringify(value, null, 2) + "\n", "utf8");
}
/**
* Deep-merge an object into the JSON at `path`. Arrays are replaced, not merged.
* 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);
writeJson(path, merged);
return touched;
}
function mergeRecurse(
target: Record<string, unknown>,
source: Record<string, unknown>,
prefix: string,
touched: string[],
): Record<string, unknown> {
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,
);
} else {
if (out[k] !== v) touched.push(path);
out[k] = v;
}
}
return out;
}
export function setJsonPath(file: string, dotPath: string, value: unknown): void {
const root = readJson<Record<string, unknown>>(file);
setPath(root, dotPath, value);
writeJson(file, root);
}
export function unsetJsonPath(file: string, dotPath: string): void {
const root = readJson<Record<string, unknown>>(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;
for (const p of parts) {
if (typeof node[p] !== "object" || node[p] === null) node[p] = {};
node = node[p] as Record<string, unknown>;
}
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;
for (const p of parts) {
if (typeof node[p] !== "object" || node[p] === null) return;
node = node[p] as Record<string, unknown>;
}
delete node[last];
}
export function addPackageDependency(
packageJsonPath: string,
name: string,
range: string,
options: { dev?: boolean } = {},
): void {
const key = options.dev ? "devDependencies" : "dependencies";
setJsonPath(packageJsonPath, `${key}.${name}`, range);
}
export function removePackageDependency(packageJsonPath: string, name: string): void {
unsetJsonPath(packageJsonPath, `dependencies.${name}`);
unsetJsonPath(packageJsonPath, `devDependencies.${name}`);
}
export function addPackageScript(packageJsonPath: string, name: string, command: string): void {
setJsonPath(packageJsonPath, `scripts.${name}`, command);
}
+31
View File
@@ -0,0 +1,31 @@
import { Project, ScriptTarget, ModuleKind } from "ts-morph";
import path from "node:path";
import fs from "node:fs";
/**
* Open (or lazily load) a ts-morph Project rooted at the given directory.
* Prefers the project's tsconfig if present; otherwise falls back to a
* minimal in-memory config sufficient for adding imports / modifying
* exports without resolving the whole graph.
*/
export function openProject(rootDir: string): Project {
const tsconfigPath = path.join(rootDir, "tsconfig.json");
if (fs.existsSync(tsconfigPath)) {
return new Project({
tsConfigFilePath: tsconfigPath,
skipAddingFilesFromTsConfig: true,
skipFileDependencyResolution: true,
});
}
return new Project({
compilerOptions: {
target: ScriptTarget.ES2022,
module: ModuleKind.ESNext,
moduleResolution: 100, // Bundler
allowJs: true,
jsx: 4, // ReactJSX
},
});
}
+33
View File
@@ -0,0 +1,33 @@
import fs from "node:fs";
import path from "node:path";
/**
* Minimal mustache-style template renderer — `{{ name }}` only. Intentionally
* no logic helpers; templates that need branching should live as separate
* files and the codemod picks which one to render.
*/
export function renderTemplate(source: string, context: Record<string, string>): string {
return source.replace(/\{\{\s*([\w.-]+)\s*\}\}/g, (_, key: string) => {
const value = context[key];
if (value === undefined) {
throw new Error(`renderTemplate: missing key "${key}"`);
}
return value;
});
}
export function writeTemplateFile(
sourcePath: string,
destPath: string,
context: Record<string, string> | undefined,
options: { overwrite?: boolean } = {},
): void {
if (!options.overwrite && fs.existsSync(destPath)) {
throw new Error(`writeTemplateFile: refusing to overwrite existing file: ${destPath}`);
}
fs.mkdirSync(path.dirname(destPath), { recursive: true });
const raw = fs.readFileSync(sourcePath, "utf8");
const rendered = context ? renderTemplate(raw, context) : raw;
fs.writeFileSync(destPath, rendered, "utf8");
}
+34
View File
@@ -0,0 +1,34 @@
import type { Project } from "ts-morph";
import type { ModuleId, SlotId, StanzaManifest } from "@stanza/registry";
export type CodemodContext = {
/** Absolute path to the project root (where stanza.json lives). */
projectRoot: string;
/** Absolute path to the active app dir (manifest.appDir resolved). */
appRoot: string;
/** ts-morph Project, lazily opened on first AST-touching codemod. */
project: () => Project;
/** Current manifest snapshot (read-only inside a codemod). */
manifest: StanzaManifest;
/** The slot/module this codemod is acting on behalf of. */
owner: { slot: SlotId; module: ModuleId };
/** Adapter key the resolver selected — useful for adapter-specific branches. */
adapter: string;
/** Claim a region in stanza.json. Throws if a different owner already holds it. */
claimRegion(filePath: string, region: string): void;
/** Release a region (used by remove/inverse codemods). */
releaseRegion(filePath: string, region: string): void;
};
export type CodemodResult = {
/** Files this codemod touched (relative to projectRoot). For dry-run output. */
touchedFiles: string[];
};
export type Codemod = {
id: string;
description?: string;
apply(ctx: CodemodContext): Promise<CodemodResult> | CodemodResult;
/** Inverse for `stanza remove`. Ship these where cheap. */
revert?(ctx: CodemodContext): Promise<CodemodResult> | CodemodResult;
};
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src",
"types": ["node"]
},
"include": ["src"]
}
+26
View File
@@ -0,0 +1,26 @@
{
"name": "create-stanza",
"version": "0.1.0",
"description": "`pnpm create stanza` — the canonical entry point to the stanza wizard.",
"license": "MIT",
"bin": {
"create-stanza": "./src/bin.ts"
},
"files": [
"src",
"dist"
],
"type": "module",
"scripts": {
"build": "bun build ./src/bin.ts --target=bun --outdir=./dist --minify",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@stanza/cli": "workspace:*",
"mri": "^1.2.0"
},
"devDependencies": {
"@types/node": "^25.9.1",
"typescript": "^6.0.3"
}
}
+23
View File
@@ -0,0 +1,23 @@
#!/usr/bin/env bun
/**
* `pnpm create stanza my-app` lands here. We forward straight to the CLI's
* init command — no extra logic, since the wizard wants to live in one place.
*
* The argv shape from npm's `create-` convention is the same as a normal CLI
* invocation, with the project name as the first positional arg.
*/
import mri from "mri";
import { run } from "@stanza/cli";
const argv = mri(process.argv.slice(2), {
alias: { h: "help", v: "version" },
boolean: ["help", "version", "yes", "dry-run", "no-telemetry"],
});
// Inject the `init` verb so the user-facing command stays terse.
argv._ = ["init", ...argv._];
run(argv).catch((err: unknown) => {
console.error(err instanceof Error ? err.message : String(err));
process.exitCode = 1;
});
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"types": ["node"]
},
"include": ["src"]
}
+19
View File
@@ -0,0 +1,19 @@
{
"name": "@stanza/registry-build",
"version": "0.1.0",
"private": true,
"license": "MIT",
"type": "module",
"scripts": {
"registry:build": "bun run ./src/build.ts",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@stanza/registry": "workspace:*"
},
"devDependencies": {
"@types/bun": "^1.3.14",
"@types/node": "^25.9.1",
"typescript": "^6.0.3"
}
}
+73
View File
@@ -0,0 +1,73 @@
#!/usr/bin/env bun
/**
* Static registry build. Scans `registry/modules/*`, imports each module's
* default export, writes:
* - dist/registry/index.json — registry index (slot/module summaries)
* - dist/registry/modules/<slot>-<id>.json — per-module full manifests
*
* The output directory is what gets uploaded to the CDN (Vercel) and what
* the CLI's HTTP loader hits at runtime.
*/
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import type { Module, RegistryIndex } from "@stanza/registry";
const here = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = findRepoRoot(here);
const modulesDir = path.join(repoRoot, "registry", "modules");
const outDir = path.join(repoRoot, "dist", "registry");
await main();
async function main() {
fs.mkdirSync(path.join(outDir, "modules"), { recursive: true });
const dirs = fs
.readdirSync(modulesDir, { withFileTypes: true })
.filter((d) => d.isDirectory())
.map((d) => d.name);
const summaries = [];
for (const dir of dirs) {
const entry = path.join(modulesDir, dir, "module.ts");
const mod = ((await import(entry)) as { default: Module }).default;
if (!mod) throw new Error(`Module ${dir} has no default export.`);
fs.writeFileSync(
path.join(outDir, "modules", `${mod.slot}-${mod.id}.json`),
JSON.stringify(mod, null, 2),
);
summaries.push({
...mod,
adapters: mod.adapters.map((a) => ({ key: a.key, match: a.match })),
});
}
const index: RegistryIndex = {
generatedAt: new Date().toISOString(),
schemaVersion: 1,
slots: [
{ id: "framework", label: "Framework", description: "Web/native app framework." },
{ id: "styling", label: "Styling", description: "CSS / styling system." },
{ id: "db", label: "Database", description: "Database engine." },
{ id: "orm", label: "ORM", description: "Database query layer." },
{ id: "auth", label: "Auth", description: "Authentication provider." },
],
modules: summaries,
};
fs.writeFileSync(path.join(outDir, "index.json"), JSON.stringify(index, null, 2));
console.log(`Wrote ${summaries.length} modules to ${outDir}`);
}
function findRepoRoot(start: string): string {
let dir = start;
for (let i = 0; i < 8; i++) {
if (fs.existsSync(path.join(dir, "pnpm-workspace.yaml"))) return dir;
dir = path.dirname(dir);
}
throw new Error("Could not locate repo root from " + start);
}
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"types": ["node"]
},
"include": ["src"]
}
+30
View File
@@ -0,0 +1,30 @@
{
"name": "@stanza/registry",
"version": "0.1.0",
"license": "MIT",
"files": [
"src",
"dist"
],
"type": "module",
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": "./src/index.ts",
"./manifest": "./src/manifest.ts",
"./module": "./src/module.ts",
"./resolver": "./src/resolver.ts"
},
"scripts": {
"build": "tsc",
"typecheck": "tsc --noEmit",
"test": "vitest run"
},
"dependencies": {
"zod": "^4.4.3"
},
"devDependencies": {
"typescript": "^6.0.3",
"vitest": "^4.1.7"
}
}
+18
View File
@@ -0,0 +1,18 @@
export type {
Slot,
SlotId,
ModuleId,
Capability,
PeerRequirement,
Module,
ModuleAdapter,
ModuleSummary,
RegistryIndex,
} from "./module.ts";
export { defineModule, KNOWN_SLOTS } from "./module.ts";
export type { StanzaManifest, StanzaModuleRecord, RegionMap, RegionOwnership } from "./manifest.ts";
export { StanzaManifestSchema, CURRENT_MANIFEST_VERSION, emptyManifest } from "./manifest.ts";
export type { ResolveContext, ResolveResult, ResolveError } from "./resolver.ts";
export { resolveAdapter, isCompatible, slotOrder } from "./resolver.ts";
+75
View File
@@ -0,0 +1,75 @@
import { z } from "zod";
import { KNOWN_SLOTS, type ModuleId, type SlotId } from "./module.ts";
export const CURRENT_MANIFEST_VERSION = "0.1" as const;
export type StanzaModuleRecord = {
id: ModuleId;
/**
* Pinned module version at install time. Recorded now so the upcoming
* `swap` and `update` verbs can read it; not consumed yet.
*/
version: string;
/** Adapter key chosen at install time (function of peer slots). */
adapter: string;
};
/**
* Per-file region ownership. Keys are dot-paths inside the file
* (e.g. "imports", "providers", "dependencies.better-auth"). Values are
* the owning module id. Written today; the `swap`/`update` verbs (and the
* deeper `remove` reversal) will use it to scope codemods back to the
* regions a single module owns.
*/
export type RegionMap = Record<string, ModuleId>;
export type RegionOwnership = Record<string, RegionMap>;
export type StanzaManifest = {
version: typeof CURRENT_MANIFEST_VERSION;
projectShape: "monorepo";
packageManager: "pnpm" | "bun" | "npm";
/** Display name; usually the repo root name. */
name: string;
/** Path of the primary web/native app inside the monorepo. */
appDir: string;
modules: Partial<Record<SlotId, StanzaModuleRecord>>;
regions: RegionOwnership;
/** Anonymous client id used by opt-out telemetry. Stored to dedupe events. */
telemetryId?: string;
};
export const StanzaManifestSchema = z.object({
version: z.literal(CURRENT_MANIFEST_VERSION),
projectShape: z.literal("monorepo"),
packageManager: z.enum(["pnpm", "bun", "npm"]),
name: z.string(),
appDir: z.string(),
// Zod 4: `z.record` over a finite key type requires all keys to be present.
// We want partial — not every slot needs to be filled — so use partialRecord.
modules: z.partialRecord(
z.enum(KNOWN_SLOTS),
z.object({
id: z.string(),
version: z.string(),
adapter: z.string(),
}),
),
regions: z.record(z.string(), z.record(z.string(), z.string())),
telemetryId: z.string().optional(),
}) satisfies z.ZodType<StanzaManifest>;
export function emptyManifest(input: {
name: string;
appDir?: string;
packageManager?: StanzaManifest["packageManager"];
}): StanzaManifest {
return {
version: CURRENT_MANIFEST_VERSION,
projectShape: "monorepo",
packageManager: input.packageManager ?? "pnpm",
name: input.name,
appDir: input.appDir ?? "apps/web",
modules: {},
regions: {},
};
}
+162
View File
@@ -0,0 +1,162 @@
import { z } from "zod";
export const KNOWN_SLOTS = ["framework", "orm", "db", "auth", "styling"] as const;
export type SlotId = (typeof KNOWN_SLOTS)[number];
export type Slot = {
id: SlotId;
label: string;
description: string;
};
export type ModuleId = string;
export type Capability = "web" | "native" | "react" | "node" | "edge" | "ssr" | "rsc";
export type PeerRequirement = {
[K in SlotId]?: ModuleId[] | "any";
};
export type ModuleAdapter = {
/** Stable key — composite of peer choices that selected this adapter. */
key: string;
/** Which peer module(s) this adapter handles. Empty record means "default / no peer". */
match: Partial<Record<SlotId, ModuleId>>;
/** Templates copied verbatim into the project (relative dest path → registry-relative source). */
templates?: TemplateRef[];
/**
* Codemod IDs to run, in order. The CLI looks these up in the module's
* codemod registry (loaded lazily so the registry index stays JSON-serializable).
*/
codemods?: string[];
/** npm dependencies this adapter adds (name → semver range). */
dependencies?: Record<string, string>;
devDependencies?: Record<string, string>;
/** Environment variables to add to `.env.example`. */
env?: EnvVar[];
/** package.json `scripts` to merge into the host app. */
scripts?: Record<string, string>;
};
export type TemplateRef = {
/** Path inside the module's templates/ folder. */
src: string;
/** Path in the generated project (relative to repo root or app root). */
dest: string;
/**
* Where the dest is resolved against:
* - "repo": repo root
* - "app": the active framework app dir (apps/web by default)
*/
scope?: "repo" | "app";
/** If true, run as a template (mustache-style) with the manifest as context. */
template?: boolean;
};
export type EnvVar = {
name: string;
/** Example value placed in `.env.example`. */
example: string;
/** Required for the module to work, vs. nice-to-have. */
required: boolean;
description?: string;
};
export type Module = {
id: ModuleId;
slot: SlotId;
/** Display name in the wizard / web builder. */
label: string;
description: string;
/** Module schema version — pinned in stanza.json. */
version: string;
/** Tags this module provides to peers downstream. */
provides?: Capability[];
/** Capabilities this module needs from the project. */
requires?: Capability[];
/** Peer module slots this module needs filled, with optional allow-list. */
peers?: PeerRequirement;
/**
* Concrete install recipes keyed by adapter. The resolver picks one based on
* the project's other modules.
*/
adapters: ModuleAdapter[];
/** Optional homepage / docs URL surfaced in `stanza search`. */
homepage?: string;
/** Optional maintainer attribution. */
author?: string;
};
/**
* Lightweight summary suitable for the registry index — strips the codemod
* implementations but keeps everything needed for the wizard / search UI.
*/
export type ModuleSummary = Omit<Module, "adapters"> & {
adapters: Pick<ModuleAdapter, "key" | "match">[];
};
export type RegistryIndex = {
generatedAt: string;
schemaVersion: 1;
slots: Slot[];
modules: ModuleSummary[];
};
/**
* Identity helper for module manifests — gives full inference and IDE
* autocomplete without forcing a class hierarchy. The runtime cost is zero.
*/
export function defineModule(module: Module): Module {
return module;
}
// Runtime-validatable schema for third-party / fetched manifests.
//
// Zod 4 requires `z.record(K, V)` — the 1-arg form is gone. We use
// `z.string()` for keys everywhere except `peers`, which keys on SlotId.
export const ModuleSchema = z.object({
id: z.string(),
slot: z.enum(KNOWN_SLOTS),
label: z.string(),
description: z.string(),
version: z.string(),
provides: z.array(z.enum(["web", "native", "react", "node", "edge", "ssr", "rsc"])).optional(),
requires: z.array(z.enum(["web", "native", "react", "node", "edge", "ssr", "rsc"])).optional(),
// Zod 4: partialRecord because not every slot is constrained by a module.
peers: z
.partialRecord(z.enum(KNOWN_SLOTS), z.union([z.literal("any"), z.array(z.string())]))
.optional(),
adapters: z.array(
z.object({
key: z.string(),
match: z.record(z.string(), z.string()),
templates: z
.array(
z.object({
src: z.string(),
dest: z.string(),
scope: z.enum(["repo", "app"]).optional(),
template: z.boolean().optional(),
}),
)
.optional(),
codemods: z.array(z.string()).optional(),
dependencies: z.record(z.string(), z.string()).optional(),
devDependencies: z.record(z.string(), z.string()).optional(),
env: z
.array(
z.object({
name: z.string(),
example: z.string(),
required: z.boolean(),
description: z.string().optional(),
}),
)
.optional(),
scripts: z.record(z.string(), z.string()).optional(),
}),
),
homepage: z.string().optional(),
author: z.string().optional(),
}) satisfies z.ZodType<Module>;
+94
View File
@@ -0,0 +1,94 @@
import { describe, expect, it } from "vitest";
import { defineModule, type Module } from "./module.ts";
import { emptyManifest } from "./manifest.ts";
import { resolveAdapter } from "./resolver.ts";
const drizzle: Module = defineModule({
id: "drizzle",
slot: "orm",
label: "Drizzle",
description: "",
version: "0.1.0",
peers: { db: ["postgres", "sqlite"] },
adapters: [
{ key: "postgres", match: { db: "postgres" } },
{ key: "sqlite", match: { db: "sqlite" } },
],
});
const betterAuth: Module = defineModule({
id: "better-auth",
slot: "auth",
label: "Better Auth",
description: "",
version: "0.1.0",
peers: { orm: ["drizzle", "prisma"] },
adapters: [
{ key: "drizzle", match: { orm: "drizzle" } },
{ key: "prisma", match: { orm: "prisma" } },
],
});
describe("resolveAdapter", () => {
it("picks the adapter whose match aligns with active peers", () => {
const result = resolveAdapter(drizzle, {
manifest: emptyManifest({ name: "t" }),
pending: {
db: defineModule({
id: "postgres",
slot: "db",
label: "",
description: "",
version: "0.1.0",
adapters: [{ key: "default", match: {} }],
}),
},
});
expect(result.ok).toBe(true);
if (result.ok) expect(result.adapter.key).toBe("postgres");
});
it("fails fast when a required peer is missing", () => {
const result = resolveAdapter(betterAuth, {
manifest: emptyManifest({ name: "t" }),
pending: {},
});
expect(result.ok).toBe(false);
if (!result.ok) expect(result.error.kind).toBe("missing-peer");
});
it("rejects a peer not on the allow-list", () => {
const result = resolveAdapter(betterAuth, {
manifest: emptyManifest({ name: "t" }),
pending: {
orm: defineModule({
id: "typeorm",
slot: "orm",
label: "",
description: "",
version: "0.1.0",
adapters: [{ key: "default", match: {} }],
}),
},
});
expect(result.ok).toBe(false);
if (!result.ok) expect(result.error.kind).toBe("incompatible-peer");
});
it("falls back to a default (empty-match) adapter when no peers are required", () => {
const tailwind: Module = defineModule({
id: "tailwind",
slot: "styling",
label: "",
description: "",
version: "0.1.0",
adapters: [{ key: "default", match: {} }],
});
const result = resolveAdapter(tailwind, {
manifest: emptyManifest({ name: "t" }),
pending: {},
});
expect(result.ok).toBe(true);
if (result.ok) expect(result.adapter.key).toBe("default");
});
});
+99
View File
@@ -0,0 +1,99 @@
import { KNOWN_SLOTS, type Module, type ModuleAdapter, type SlotId } from "./module.ts";
import type { StanzaManifest } from "./manifest.ts";
/**
* Topological order slots are processed in. Earlier slots become peer
* candidates for later ones. Hardcoded for now since the dependency graph
* is small and fixed; can be derived from peer declarations later.
*/
export const slotOrder: readonly SlotId[] = ["framework", "styling", "db", "orm", "auth"];
export type ResolveContext = {
/** Manifest state at the moment of resolution (post any pending picks). */
manifest: StanzaManifest;
/** Modules the user has already chosen this run but not yet committed. */
pending: Partial<Record<SlotId, Module>>;
};
export type ResolveError =
| { kind: "no-adapter"; module: Module; peers: Partial<Record<SlotId, string>> }
| { kind: "missing-peer"; module: Module; slot: SlotId }
| { kind: "incompatible-peer"; module: Module; slot: SlotId; peer: string };
export type ResolveResult =
| { ok: true; adapter: ModuleAdapter }
| { ok: false; error: ResolveError };
/**
* Pick the most-specific adapter that matches the active peer choices.
*
* Specificity = number of `match` keys satisfied. The default (empty `match`)
* always wins on tiebreak when no peers are required.
*/
export function resolveAdapter(module: Module, context: ResolveContext): ResolveResult {
const activePeers = activePeerIds(context);
// Check declared peers are satisfied (id present + on allow-list if specified).
for (const slot of KNOWN_SLOTS) {
const allowed = module.peers?.[slot];
if (allowed === undefined) continue;
const chosen = activePeers[slot];
if (!chosen) {
return { ok: false, error: { kind: "missing-peer", module, slot } };
}
if (allowed !== "any" && !allowed.includes(chosen)) {
return {
ok: false,
error: { kind: "incompatible-peer", module, slot, peer: chosen },
};
}
}
const candidates = module.adapters
.map((adapter) => ({
adapter,
specificity: matchSpecificity(adapter, activePeers),
}))
.filter((c) => c.specificity >= 0);
if (candidates.length === 0) {
return {
ok: false,
error: { kind: "no-adapter", module, peers: activePeers },
};
}
candidates.sort((a, b) => b.specificity - a.specificity);
// Non-null asserted: candidates.length > 0 was just checked above.
return { ok: true, adapter: candidates[0]!.adapter };
}
export function isCompatible(module: Module, context: ResolveContext): boolean {
return resolveAdapter(module, context).ok;
}
function activePeerIds(context: ResolveContext): Partial<Record<SlotId, string>> {
const out: Partial<Record<SlotId, string>> = {};
for (const slot of KNOWN_SLOTS) {
const pending = context.pending[slot]?.id;
const installed = context.manifest.modules[slot]?.id;
const chosen = pending ?? installed;
if (chosen) out[slot] = chosen;
}
return out;
}
/**
* Returns -1 if the adapter is impossible (declares a peer match the active
* peers contradict). Otherwise returns the number of matched constraints.
* Adapters with an empty `match` map are universally applicable (specificity 0).
*/
function matchSpecificity(adapter: ModuleAdapter, peers: Partial<Record<SlotId, string>>): number {
let score = 0;
for (const [slot, required] of Object.entries(adapter.match) as [SlotId, string][]) {
const actual = peers[slot];
if (actual !== required) return -1;
score += 1;
}
return score;
}
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src"]
}
+2774
View File
File diff suppressed because it is too large Load Diff
+10
View File
@@ -0,0 +1,10 @@
packages:
- "apps/*"
- "packages/*"
- "registry/modules/*"
onlyBuiltDependencies:
- "esbuild"
- "better-sqlite3"
ignoredOptionalDependencies: []
+111
View File
@@ -0,0 +1,111 @@
import { defineModule } from "@stanza/registry";
export default defineModule({
id: "better-auth",
slot: "auth",
label: "Better Auth",
description: "Framework-agnostic, headless TypeScript auth library.",
version: "0.1.0",
peers: { orm: ["drizzle", "prisma"], framework: ["next", "tanstack-start"] },
homepage: "https://better-auth.com",
adapters: [
{
key: "next+drizzle",
match: { framework: "next", orm: "drizzle" },
dependencies: { "better-auth": "^1.6.11" },
env: [
{
name: "BETTER_AUTH_SECRET",
example: "change-me-in-prod",
required: true,
description: "Better Auth signing secret.",
},
{
name: "BETTER_AUTH_URL",
example: "http://localhost:3000",
required: true,
description: "Public URL of the app.",
},
],
templates: [
{ src: "next/auth.drizzle.ts", dest: "src/lib/auth.ts", scope: "app" },
{ src: "next/auth-client.ts", dest: "src/lib/auth-client.ts", scope: "app" },
{ src: "next/route.ts", dest: "app/api/auth/[...all]/route.ts", scope: "app" },
{ src: "shared/auth-schema.drizzle.ts", dest: "src/db/auth-schema.ts", scope: "app" },
],
},
{
key: "next+prisma",
match: { framework: "next", orm: "prisma" },
dependencies: { "better-auth": "^1.6.11" },
env: [
{
name: "BETTER_AUTH_SECRET",
example: "change-me-in-prod",
required: true,
description: "Better Auth signing secret.",
},
{
name: "BETTER_AUTH_URL",
example: "http://localhost:3000",
required: true,
description: "Public URL of the app.",
},
],
templates: [
{ src: "next/auth.prisma.ts", dest: "src/lib/auth.ts", scope: "app" },
{ src: "next/auth-client.ts", dest: "src/lib/auth-client.ts", scope: "app" },
{ src: "next/route.ts", dest: "app/api/auth/[...all]/route.ts", scope: "app" },
],
},
{
key: "tanstack-start+drizzle",
match: { framework: "tanstack-start", orm: "drizzle" },
dependencies: { "better-auth": "^1.6.11" },
env: [
{
name: "BETTER_AUTH_SECRET",
example: "change-me-in-prod",
required: true,
description: "Better Auth signing secret.",
},
{
name: "BETTER_AUTH_URL",
example: "http://localhost:3000",
required: true,
description: "Public URL of the app.",
},
],
templates: [
{ src: "tanstack/auth.drizzle.ts", dest: "src/lib/auth.ts", scope: "app" },
{ src: "tanstack/auth-client.ts", dest: "src/lib/auth-client.ts", scope: "app" },
{ src: "tanstack/api.ts", dest: "src/routes/api/auth/$.ts", scope: "app" },
{ src: "shared/auth-schema.drizzle.ts", dest: "src/db/auth-schema.ts", scope: "app" },
],
},
{
key: "tanstack-start+prisma",
match: { framework: "tanstack-start", orm: "prisma" },
dependencies: { "better-auth": "^1.6.11" },
env: [
{
name: "BETTER_AUTH_SECRET",
example: "change-me-in-prod",
required: true,
description: "Better Auth signing secret.",
},
{
name: "BETTER_AUTH_URL",
example: "http://localhost:3000",
required: true,
description: "Public URL of the app.",
},
],
templates: [
{ src: "tanstack/auth.prisma.ts", dest: "src/lib/auth.ts", scope: "app" },
{ src: "tanstack/auth-client.ts", dest: "src/lib/auth-client.ts", scope: "app" },
{ src: "tanstack/api.ts", dest: "src/routes/api/auth/$.ts", scope: "app" },
],
},
],
});
@@ -0,0 +1,12 @@
{
"name": "@stanza-modules/auth-better-auth",
"version": "0.1.0",
"private": true,
"license": "MIT",
"type": "module",
"main": "./module.ts",
"dependencies": {
"@stanza/codemods": "workspace:*",
"@stanza/registry": "workspace:*"
}
}
@@ -0,0 +1,5 @@
import { createAuthClient } from "better-auth/react";
export const authClient = createAuthClient({
baseURL: process.env.BETTER_AUTH_URL,
});
@@ -0,0 +1,8 @@
import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { db } from "@/db";
export const auth = betterAuth({
database: drizzleAdapter(db, { provider: "pg" }),
emailAndPassword: { enabled: true },
});
@@ -0,0 +1,8 @@
import { betterAuth } from "better-auth";
import { prismaAdapter } from "better-auth/adapters/prisma";
import { db } from "@/db";
export const auth = betterAuth({
database: prismaAdapter(db, { provider: "postgresql" }),
emailAndPassword: { enabled: true },
});
@@ -0,0 +1,4 @@
import { auth } from "@/lib/auth";
import { toNextJsHandler } from "better-auth/next-js";
export const { GET, POST } = toNextJsHandler(auth);
@@ -0,0 +1,51 @@
import { pgTable, text, timestamp, boolean } from "drizzle-orm/pg-core";
export const user = pgTable("user", {
id: text("id").primaryKey(),
name: text("name").notNull(),
email: text("email").notNull().unique(),
emailVerified: boolean("email_verified").notNull().default(false),
image: text("image"),
createdAt: timestamp("created_at").notNull().defaultNow(),
updatedAt: timestamp("updated_at").notNull().defaultNow(),
});
export const session = pgTable("session", {
id: text("id").primaryKey(),
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
expiresAt: timestamp("expires_at").notNull(),
token: text("token").notNull(),
ipAddress: text("ip_address"),
userAgent: text("user_agent"),
createdAt: timestamp("created_at").notNull().defaultNow(),
updatedAt: timestamp("updated_at").notNull().defaultNow(),
});
export const account = pgTable("account", {
id: text("id").primaryKey(),
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
accountId: text("account_id").notNull(),
providerId: text("provider_id").notNull(),
accessToken: text("access_token"),
refreshToken: text("refresh_token"),
idToken: text("id_token"),
accessTokenExpiresAt: timestamp("access_token_expires_at"),
refreshTokenExpiresAt: timestamp("refresh_token_expires_at"),
scope: text("scope"),
password: text("password"),
createdAt: timestamp("created_at").notNull().defaultNow(),
updatedAt: timestamp("updated_at").notNull().defaultNow(),
});
export const verification = pgTable("verification", {
id: text("id").primaryKey(),
identifier: text("identifier").notNull(),
value: text("value").notNull(),
expiresAt: timestamp("expires_at").notNull(),
createdAt: timestamp("created_at").notNull().defaultNow(),
updatedAt: timestamp("updated_at").notNull().defaultNow(),
});
@@ -0,0 +1,7 @@
import { createAPIFileRoute } from "@tanstack/react-start/api";
import { auth } from "~/lib/auth";
export const APIRoute = createAPIFileRoute("/api/auth/$")({
GET: ({ request }) => auth.handler(request),
POST: ({ request }) => auth.handler(request),
});
@@ -0,0 +1,5 @@
import { createAuthClient } from "better-auth/react";
export const authClient = createAuthClient({
baseURL: import.meta.env.VITE_BETTER_AUTH_URL,
});
@@ -0,0 +1,8 @@
import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { db } from "~/db";
export const auth = betterAuth({
database: drizzleAdapter(db, { provider: "pg" }),
emailAndPassword: { enabled: true },
});
@@ -0,0 +1,8 @@
import { betterAuth } from "better-auth";
import { prismaAdapter } from "better-auth/adapters/prisma";
import { db } from "~/db";
export const auth = betterAuth({
database: prismaAdapter(db, { provider: "postgresql" }),
emailAndPassword: { enabled: true },
});
@@ -0,0 +1,5 @@
{
"extends": "../../../tsconfig.base.json",
"include": ["module.ts"],
"exclude": ["templates"]
}
+42
View File
@@ -0,0 +1,42 @@
import { defineModule } from "@stanza/registry";
/**
* Clerk ships its own hosted UI and session store; it bypasses the user-owned
* DB. That means it has no peer requirement on `orm`/`db` — those slots can be
* unfilled and Clerk still works. Only the Next.js adapter is wired up so
* far; TanStack Start integration is planned.
*/
export default defineModule({
id: "clerk",
slot: "auth",
label: "Clerk",
description: "Hosted user management with pre-built UI components.",
version: "0.1.0",
peers: { framework: ["next"] },
homepage: "https://clerk.com",
adapters: [
{
key: "next",
match: { framework: "next" },
dependencies: { "@clerk/nextjs": "^7.3.7" },
env: [
{
name: "NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY",
example: "pk_test_...",
required: true,
description: "Clerk publishable key.",
},
{
name: "CLERK_SECRET_KEY",
example: "sk_test_...",
required: true,
description: "Clerk secret key.",
},
],
templates: [
{ src: "middleware.ts", dest: "middleware.ts", scope: "app" },
{ src: "layout-wrapper.tsx", dest: "app/_clerk-provider.tsx", scope: "app" },
],
},
],
});
+12
View File
@@ -0,0 +1,12 @@
{
"name": "@stanza-modules/auth-clerk",
"version": "0.1.0",
"private": true,
"license": "MIT",
"type": "module",
"main": "./module.ts",
"dependencies": {
"@stanza/codemods": "workspace:*",
"@stanza/registry": "workspace:*"
}
}
@@ -0,0 +1,6 @@
import type { ReactNode } from "react";
import { ClerkProvider } from "@clerk/nextjs";
export function ClerkRootProvider({ children }: { children: ReactNode }) {
return <ClerkProvider>{children}</ClerkProvider>;
}
@@ -0,0 +1,10 @@
import { clerkMiddleware } from "@clerk/nextjs/server";
export default clerkMiddleware();
export const config = {
matcher: [
"/((?!_next|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)",
"/(api|trpc)(.*)",
],
};
@@ -0,0 +1,5 @@
{
"extends": "../../../tsconfig.base.json",
"include": ["module.ts"],
"exclude": ["templates"]
}
+25
View File
@@ -0,0 +1,25 @@
import { defineModule } from "@stanza/registry";
export default defineModule({
id: "postgres",
slot: "db",
label: "PostgreSQL",
description: "Postgres via the `postgres` driver. Works locally or via Neon/Supabase.",
version: "0.1.0",
homepage: "https://www.postgresql.org",
adapters: [
{
key: "default",
match: {},
dependencies: { postgres: "^3.4.9" },
env: [
{
name: "DATABASE_URL",
example: "postgres://postgres:postgres@localhost:5432/stanza",
required: true,
description: "Postgres connection string.",
},
],
},
],
});
+12
View File
@@ -0,0 +1,12 @@
{
"name": "@stanza-modules/db-postgres",
"version": "0.1.0",
"private": true,
"license": "MIT",
"type": "module",
"main": "./module.ts",
"dependencies": {
"@stanza/codemods": "workspace:*",
"@stanza/registry": "workspace:*"
}
}
@@ -0,0 +1,5 @@
{
"extends": "../../../tsconfig.base.json",
"include": ["module.ts"],
"exclude": ["templates"]
}
+26
View File
@@ -0,0 +1,26 @@
import { defineModule } from "@stanza/registry";
export default defineModule({
id: "sqlite",
slot: "db",
label: "SQLite",
description: "Local SQLite via better-sqlite3. Zero-config for development.",
version: "0.1.0",
homepage: "https://www.sqlite.org",
adapters: [
{
key: "default",
match: {},
dependencies: { "better-sqlite3": "^12.10.0" },
devDependencies: { "@types/better-sqlite3": "^7.6.13" },
env: [
{
name: "DATABASE_URL",
example: "file:./data/dev.db",
required: true,
description: "SQLite database file path.",
},
],
},
],
});
+12
View File
@@ -0,0 +1,12 @@
{
"name": "@stanza-modules/db-sqlite",
"version": "0.1.0",
"private": true,
"license": "MIT",
"type": "module",
"main": "./module.ts",
"dependencies": {
"@stanza/codemods": "workspace:*",
"@stanza/registry": "workspace:*"
}
}
+5
View File
@@ -0,0 +1,5 @@
{
"extends": "../../../tsconfig.base.json",
"include": ["module.ts"],
"exclude": ["templates"]
}
+41
View File
@@ -0,0 +1,41 @@
import { defineModule } from "@stanza/registry";
export default defineModule({
id: "next",
slot: "framework",
label: "Next.js",
description: "React framework with App Router, RSC, and edge runtime.",
version: "0.1.0",
provides: ["web", "react", "ssr", "rsc", "node", "edge"],
homepage: "https://nextjs.org",
adapters: [
{
key: "default",
match: {},
dependencies: {
next: "^16.2.6",
react: "^19.2.6",
"react-dom": "^19.2.6",
},
devDependencies: {
"@types/react": "^19.2.15",
"@types/react-dom": "^19.2.3",
typescript: "^6.0.3",
},
scripts: {
dev: "next dev",
build: "next build",
start: "next start",
lint: "next lint",
},
templates: [
{ src: "tsconfig.json", dest: "tsconfig.json", scope: "app" },
{ src: "next.config.ts", dest: "next.config.ts", scope: "app" },
{ src: "next-env.d.ts", dest: "next-env.d.ts", scope: "app" },
{ src: "app/layout.tsx", dest: "app/layout.tsx", scope: "app" },
{ src: "app/page.tsx", dest: "app/page.tsx", scope: "app" },
{ src: "app/globals.css", dest: "app/globals.css", scope: "app" },
],
},
],
});
@@ -0,0 +1,12 @@
{
"name": "@stanza-modules/framework-next",
"version": "0.1.0",
"private": true,
"license": "MIT",
"type": "module",
"main": "./module.ts",
"dependencies": {
"@stanza/codemods": "workspace:*",
"@stanza/registry": "workspace:*"
}
}
@@ -0,0 +1,15 @@
html,
body {
margin: 0;
padding: 0;
font-family:
system-ui,
-apple-system,
sans-serif;
}
main {
max-width: 48rem;
margin: 4rem auto;
padding: 0 1rem;
}
@@ -0,0 +1,15 @@
import type { ReactNode } from "react";
import "./globals.css";
export const metadata = {
title: "stanza app",
description: "Generated by stanza",
};
export default function RootLayout({ children }: { children: ReactNode }) {
return (
<html lang="en">
<body>{children}</body>
</html>
);
}
@@ -0,0 +1,10 @@
export default function Page() {
return (
<main>
<h1>Welcome to stanza</h1>
<p>
Edit <code>app/page.tsx</code> to get started.
</p>
</main>
);
}
@@ -0,0 +1,2 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
@@ -0,0 +1,7 @@
import type { NextConfig } from "next";
const config: NextConfig = {
reactStrictMode: true,
};
export default config;
@@ -0,0 +1,24 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["dom", "dom.iterable", "esnext"],
"module": "esnext",
"moduleResolution": "bundler",
"jsx": "preserve",
"strict": true,
"noUncheckedIndexedAccess": true,
"allowJs": true,
"skipLibCheck": true,
"esModuleInterop": true,
"resolveJsonModule": true,
"isolatedModules": true,
"incremental": true,
"noEmit": true,
"plugins": [{ "name": "next" }],
"paths": {
"@/*": ["./*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}
@@ -0,0 +1,5 @@
{
"extends": "../../../tsconfig.base.json",
"include": ["module.ts"],
"exclude": ["templates"]
}
@@ -0,0 +1,42 @@
import { defineModule } from "@stanza/registry";
export default defineModule({
id: "tanstack-start",
slot: "framework",
label: "TanStack Start",
description: "Full-stack React framework on Vite + TanStack Router.",
version: "0.1.0",
provides: ["web", "react", "ssr", "node"],
homepage: "https://tanstack.com/start",
adapters: [
{
key: "default",
match: {},
dependencies: {
"@tanstack/react-router": "^1.170.5",
"@tanstack/react-start": "^1.168.7",
react: "^19.2.6",
"react-dom": "^19.2.6",
},
devDependencies: {
"@types/react": "^19.2.15",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.2",
typescript: "^6.0.3",
vite: "^8.0.13",
},
scripts: {
dev: "vite dev",
build: "vite build",
start: "node .output/server/index.mjs",
},
templates: [
{ src: "vite.config.ts", dest: "vite.config.ts", scope: "app" },
{ src: "tsconfig.json", dest: "tsconfig.json", scope: "app" },
{ src: "src/router.tsx", dest: "src/router.tsx", scope: "app" },
{ src: "src/routes/__root.tsx", dest: "src/routes/__root.tsx", scope: "app" },
{ src: "src/routes/index.tsx", dest: "src/routes/index.tsx", scope: "app" },
],
},
],
});
@@ -0,0 +1,12 @@
{
"name": "@stanza-modules/framework-tanstack-start",
"version": "0.1.0",
"private": true,
"license": "MIT",
"type": "module",
"main": "./module.ts",
"dependencies": {
"@stanza/codemods": "workspace:*",
"@stanza/registry": "workspace:*"
}
}
@@ -0,0 +1,12 @@
import { createRouter as createTanStackRouter } from "@tanstack/react-router";
import { routeTree } from "./routeTree.gen";
export function createRouter() {
return createTanStackRouter({ routeTree });
}
declare module "@tanstack/react-router" {
interface Register {
router: ReturnType<typeof createRouter>;
}
}
@@ -0,0 +1,11 @@
import { Outlet, createRootRoute } from "@tanstack/react-router";
export const Route = createRootRoute({
component: () => (
<html lang="en">
<body>
<Outlet />
</body>
</html>
),
});
@@ -0,0 +1,12 @@
import { createFileRoute } from "@tanstack/react-router";
export const Route = createFileRoute("/")({
component: () => (
<main>
<h1>Welcome to stanza</h1>
<p>
Edit <code>src/routes/index.tsx</code> to get started.
</p>
</main>
),
});
@@ -0,0 +1,22 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["dom", "dom.iterable", "esnext"],
"module": "esnext",
"moduleResolution": "bundler",
"jsx": "preserve",
"strict": true,
"noUncheckedIndexedAccess": true,
"skipLibCheck": true,
"esModuleInterop": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"allowJs": true,
"paths": {
"~/*": ["./src/*"]
}
},
"include": ["src/**/*.ts", "src/**/*.tsx", "app.config.ts"],
"exclude": ["node_modules"]
}
@@ -0,0 +1,12 @@
import { defineConfig } from "vite";
import { tanstackStart } from "@tanstack/react-start/plugin/vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [
// tanstackStart() must precede react() — Start transforms server functions
// and generates the route tree first.
tanstackStart(),
react(),
],
});

Some files were not shown because too many files have changed in this diff Show More