mirror of
https://github.com/jakejarvis/stanza.git
synced 2026-07-16 18:05:58 -04:00
feat: initial commit
This commit is contained in:
+31
@@ -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/
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
auto-install-peers=true
|
||||||
|
strict-peer-dependencies=false
|
||||||
|
node-linker=isolated
|
||||||
|
prefer-workspace-packages=true
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"$schema": "./node_modules/oxfmt/configuration_schema.json",
|
||||||
|
"ignorePatterns": []
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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.
|
||||||
@@ -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
@@ -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`).
|
||||||
@@ -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
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
Executable
+16
@@ -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;
|
||||||
|
});
|
||||||
@@ -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 };
|
||||||
@@ -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.");
|
||||||
|
}
|
||||||
@@ -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.");
|
||||||
|
}
|
||||||
@@ -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"));
|
||||||
|
}
|
||||||
@@ -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"));
|
||||||
|
}
|
||||||
@@ -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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { run } from "./run.ts";
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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." },
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"rootDir": "./src",
|
||||||
|
"types": ["node"]
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -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>>;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -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(),
|
||||||
|
],
|
||||||
|
});
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
};
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "./dist",
|
||||||
|
"rootDir": "./src",
|
||||||
|
"types": ["node"]
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
});
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"rootDir": "./src",
|
||||||
|
"types": ["node"]
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"rootDir": "./src",
|
||||||
|
"types": ["node"]
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
@@ -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: {},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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>;
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "./dist",
|
||||||
|
"rootDir": "./src"
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
Generated
+2774
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,10 @@
|
|||||||
|
packages:
|
||||||
|
- "apps/*"
|
||||||
|
- "packages/*"
|
||||||
|
- "registry/modules/*"
|
||||||
|
|
||||||
|
onlyBuiltDependencies:
|
||||||
|
- "esbuild"
|
||||||
|
- "better-sqlite3"
|
||||||
|
|
||||||
|
ignoredOptionalDependencies: []
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -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" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -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.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -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.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
@@ -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:*"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../../tsconfig.base.json",
|
||||||
|
"include": ["module.ts"],
|
||||||
|
"exclude": ["templates"]
|
||||||
|
}
|
||||||
@@ -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
Reference in New Issue
Block a user