feat: slot-package extraction — auth/db/orm install into their own workspace packages

- Add `SLOT_PACKAGE_DIR` map to `packages/registry/src/module.ts`: `auth → "auth"`, `db`+`orm → "db"`, `framework`+`styling → null` (app-scoped); drives where package-scoped templates and deps land
- Runner gains `ensureSlotPackage` — bootstraps `packages/<dir>/package.json` + `tsconfig.json` on first install and merges adapter `dependencies`/`devDeps`/`scripts` there; wires the app to the slot package via a `workspace:*` dep
- Add `scope: "package"` to `TemplateRef` — resolves dest against `packages/<SLOT_PACKAGE_DIR[slot]>/` instead of `appDir` or repo root; runner writes package-scoped files to the right home
- Add `peerPackages` to `ModuleAdapter` — declares cross-package deps (e.g. `auth` needing `db`); runner injects `@<project>/<dir>: workspace:*` into the current package's `package.json`
- Add `{{<dir>PackageName}}` mustache substitution (e.g. `{{dbPackageName}}` → `@my-app/db`) — runs over template bodies (`template: true`) and codemod `args` string values; enables auth templates to import from the db package without hardcoding names
- Add `base: "package:<dir>"` support to `re-export` and `append-to-file` builtins — codemods can target files inside another slot's package (e.g. extending the orm schema barrel from the auth module)
- Update `auth-better-auth`, `auth-clerk`, `orm-drizzle`, `orm-prisma` modules to use `scope: "package"` templates and `peerPackages` cross-wiring; rename `layout-wrapper.tsx` → `provider.tsx` in auth-clerk
- `stanza remove` updated to sweep `packages/<dir>/` when no region claims remain under it
- Update `selection.ts` in apps/web to account for package-scoped file paths
- Document extraction model, `SLOT_PACKAGE_DIR`, authoring rules, and `base:` codemod targeting in `CLAUDE.md`, `README.md`, and `REGISTRY.md`
This commit is contained in:
2026-05-20 15:05:11 -04:00
parent 227b625ea1
commit f83e84eb85
30 changed files with 583 additions and 122 deletions
+15 -4
View File
@@ -24,6 +24,8 @@ Three things differentiate stanza from other scaffolders:
- `packages/internal/` — Internal maintenance scripts: `registry-build.ts` (emits static CDN JSON), `module-new.ts` (scaffolds a new module)
- `registry/modules/<slot>-<id>/` — first-party modules: `module.ts` + `templates/` (modules don't ship codemod code; see Architecture rules)
In a **generated project**, `auth`, `db`, and `orm` modules install into their own internal workspace package at `packages/<dir>/` (named `@<manifest.name>/<dir>`); the app consumes them via `workspace:*` deps. `framework` and `styling` stay app-scoped because they wire the app shell itself. The mapping is hardcoded in [`SLOT_PACKAGE_DIR`](packages/registry/src/module.ts) — `auth → "auth"`, both `db` and `orm → "db"` (they share a single `packages/db/` package).
## Commands
- `bun apps/cli/src/bin.ts <verb>` — run CLI directly without build
@@ -55,8 +57,11 @@ Three things differentiate stanza from other scaffolders:
- **Adding a generic codemod**: drop `<id>.ts` under [packages/codemods/src/builtins/](packages/codemods/src/builtins/), default-export a `Codemod<TArgs>`, and register it in [packages/codemods/src/builtins/index.ts](packages/codemods/src/builtins/index.ts). Codemods that bake in module-specific identifiers don't belong — factor them into args
- **Third-party codemods**: deferred. Third-party HTTP-loaded modules can use the existing catalog codemods (pass `{ id, args }` from their manifest) but can't add new ones until we land a proper sandboxed-execution + signing model
- **apps/web previews are server-rendered**: Shiki runs in `apps/web/src/server/highlighter.ts` (module-singleton, kept warm). The builder loader (`createServerFn` in `apps/web/src/server/builder-state.ts`) computes selected files from URL search params, pre-renders Shiki HTML for each, and ships `Record<path, { light, dark }>` to the client. `shiki` must NEVER be imported from a client component — verified by `vite build` followed by `grep shiki .output/public/assets/*.js` (should return nothing)
- **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
- **Slot taxonomy** is currently `framework | styling | db | orm | auth` (see `KNOWN_SLOTS`); adding a slot is a manifest schema bump — update `KNOWN_SLOTS`, `slotOrder`, the Zod manifest schema, and `SLOT_PACKAGE_DIR` together (decide whether the new slot extracts into its own package or stays app-scoped)
- **Adapter keys** encode peer choices (e.g., `next+drizzle`); the resolver picks the most specific match
- **Slot-package extraction**: `auth`/`db`/`orm` modules install into `packages/<dir>/` workspace packages (named `@<manifest.name>/<dir>`); templates `scope: "package"`, deps/devDeps/scripts route there, and the app gets a `workspace:*` dep wired by the runner. `framework`/`styling` stay app-scoped (their `SLOT_PACKAGE_DIR` entry is `null`). The bootstrap files (the package's `package.json` + `tsconfig.json` and the host app's workspace dep) are **system-owned** — not tracked in `regions`. `stanza remove`'s sweep deletes them when no claims remain under `packages/<dir>/`
- **Generated projects don't share a tsconfig base**: every `apps/*/tsconfig.json` and `packages/*/tsconfig.json` is self-contained. The framework module ships the app's tsconfig; the runner's `ensureSlotPackage` writes a matching self-contained config when bootstrapping a slot package. `tsconfig.base.json` lives in the stanza repo only; do not emit it in generated trees
- **Cross-package wiring**: when an adapter's source code imports from another internal package (e.g. `better-auth`'s `auth.ts` reads `db` from the orm package), declare `peerPackages: ["db"]` on the adapter. The runner adds `@<project>/db: workspace:*` to the current package's `package.json`. Templates can reference other packages via `{{<dir>PackageName}}` substitution (e.g. `{{dbPackageName}}``@my-app/db`) — substitution runs over both template bodies (when `template: true`) and codemod-invocation `args` string values
- **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
@@ -64,9 +69,15 @@ Three things differentiate stanza from other scaffolders:
## 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
- To invoke an imperative codemod from a module, add `codemods: [{ id: "<catalog-id>", args: {...} }]` to the adapter. Modules never ship code — if no catalog entry matches the need, design a new generic codemod with the right args
- Templates go in `templates/`, referenced by `src` path. `scope` decides where `dest` lands:
- `"app"` (default) → `manifest.appDir` (e.g. `apps/web/`)
- `"repo"` → repo root
- `"package"``packages/<SLOT_PACKAGE_DIR[slot]>/` — only valid for slots with a non-null entry (`auth`, `db`, `orm`)
- For `auth`/`db`/`orm` modules, default to `scope: "package"` for everything that can live inside the package boundary. Reach for `scope: "app"` only when a framework convention forces the file to sit at the app root (e.g. Next's `middleware.ts`, App Router API routes). App-scoped files should be thin shims that `import` from `{{packageName}}`; set `template: true` and the runner runs mustache substitution
- For cross-package imports (e.g. `auth` reading `db`), declare `peerPackages: ["<dir>"]` on the adapter so the runner adds the workspace dep, and write the import as `{{<dir>PackageName}}` (e.g. `import { db } from "{{dbPackageName}}";`)
- Framework modules MUST NOT ship a `package.json.tpl` — it collides with `addPackageDependency`. Let the runner merge deps into the host's package.json. The same rule applies to **package-scoped** modules: don't ship a `packages/<dir>/package.json` template — the runner's `ensureSlotPackage` bootstraps one and merges adapter deps in
- To invoke an imperative codemod from a module, add `codemods: [{ id: "<catalog-id>", args: {...} }]` to the adapter. Modules never ship code — if no catalog entry matches the need, design a new generic codemod with the right args. String values in `args` go through mustache substitution (same context as template bodies)
- For codemods that operate on files inside a slot's package (e.g. extending the orm's schema barrel from the auth module), pass `base: "package:<dir>"` to the catalog codemod — `re-export` and `append-to-file` both honor it
## Gotchas
+8 -5
View File
@@ -8,18 +8,21 @@ 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`.
Generated projects keep slot boundaries explicit: `auth`, `db`, and `orm` install into their own internal workspace packages (`packages/auth/`, `packages/db/`, named `@<your-app>/auth`, `@<your-app>/db`), and your app consumes them via `workspace:*` deps. Swapping an auth provider replaces the contents of `packages/auth/` without touching your app's imports.
## What's inside
```
apps/
cli/ # @stanza/cli — the CLI binary
web/ # builder.stanza.dev (TanStack Start)
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
registry/ # shared schema, slot/peer/capability resolver
codemods/ # ts-morph helpers for region-aware patching
create-stanza/ # `pnpm create stanza` shim
internal/ # maintenance scripts (registry-build, module-new)
registry/
modules/ # first-party modules (framework, orm, db, auth, styling)
modules/ # first-party modules (framework, orm, db, auth, styling)
```
## Status
+10 -1
View File
@@ -107,8 +107,17 @@ _Not a slot — a top-level field in `stanza.json` (`packageManager: "pnpm" | "b
- [x] npm
- [ ] yarn — needs lockfile/workspace handling that differs from the others
## Slot-package extraction
Generated projects place each slot's output in one of two homes:
- **App-scoped** (`framework`, `styling`) — files land in `manifest.appDir` (e.g. `apps/web/`). These slots wire the app shell itself, so there's no useful extraction boundary.
- **Package-scoped** (`auth`, `db`, `orm`) — files land in `packages/<dir>/`, named `@<manifest.name>/<dir>`, and the app gets a `workspace:*` dep. `db` and `orm` share a single `packages/db/` package so the ORM client sits next to the schema it queries.
The mapping is hardcoded in [`SLOT_PACKAGE_DIR`](packages/registry/src/module.ts) and applies to every adapter in those slots — module authors don't opt in per-adapter. When you add a new slot, decide upfront whether it extracts (data layer, observability, payments) or wires the shell (router, UI primitives that mount in `<html>`) and add the entry alongside `KNOWN_SLOTS`.
## 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.
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`, plus a `SLOT_PACKAGE_DIR` entry (`null` for shell-wiring slots, a directory name for extracted ones). 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`).
+7 -7
View File
@@ -1,6 +1,6 @@
# 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.
State at end of last session: declarative slot-package extraction landed — `auth`/`db`/`orm` modules now install into their own internal workspace packages (`packages/auth/`, `packages/db/` named `@<project>/<dir>`); the app consumes via `workspace:*`. Cross-package wiring is declared via `peerPackages` and templated via `{{<dir>PackageName}}` substitution. `stanza add` composes modules across slots (verified: `framework next``db postgres``orm drizzle``auth better-auth` produces a working tree with the right adapter selection and the auth package depending on the db package). 49 unit tests pass. apps/web is a minimal Vite-native TanStack Start scaffold — needs the actual builder UI.
## Web app (apps/web) — priority
@@ -30,9 +30,10 @@ 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
- [ ] `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. Also: the root `pnpm-workspace.yaml` must cover `packages/*` so the runner's bootstrapped slot packages link correctly
- [ ] 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
- [ ] `stanza remove` doesn't dispatch to the inverse `revert()` on imperative codemods — regions touched by `wrap-root-layout` and `re-export` get flagged as "needs manual cleanup" even though the codemod ships a working revert. Fix: look up `CODEMOD_CATALOG[id].revert` for region keys that match a codemod-claim pattern (`imports.*`, `providers.*`, `re-exports.*`, `append.*`) and call it
- [ ] Tests for command handlers (`init`, `add`, `remove`) — current coverage is just codemods + resolver
## Modules
@@ -40,11 +41,10 @@ The wizard and verbs work but a few things from the plan are stubbed.
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
- [ ] `auth-better-auth` drizzle `auth.ts` hardcodes `provider: "pg"` in the `drizzleAdapter` call — wrong for the sqlite peer. Split the template per-db or pass the provider through a substitution var
- [ ] `auth-better-auth` sqlite schema variant exists (`shared/auth-schema.drizzle-sqlite.ts`); confirm it matches what better-auth actually expects on SQLite end-to-end
- [ ] `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
- [ ] Authoring guide: docs page covering `defineModule`, slot/peer/capability semantics, template vs. codemod choice, region ownership, and the `scope: "package"` + `peerPackages` story
## Registry expansion
@@ -76,7 +76,7 @@ The full first-party module roadmap lives in [REGISTRY.md](REGISTRY.md). These a
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 swap <slot> <to>` — manifest already records `version` + `regions` to support it; slot-package extraction now means swap can replace the contents of `packages/<dir>/` without touching app imports
- `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
+250 -19
View File
@@ -10,7 +10,15 @@ import {
} from "@stanza/codemods";
import type { CodemodContext, Project } from "@stanza/codemods";
import { CODEMOD_CATALOG } from "@stanza/codemods/builtins";
import type { Module, ModuleAdapter, StanzaManifest, TemplateRef } from "@stanza/registry";
import type {
JsonValue,
Module,
ModuleAdapter,
SlotId,
StanzaManifest,
TemplateRef,
} from "@stanza/registry";
import { SLOT_PACKAGE_DIR } from "@stanza/registry";
import { writeManifest } from "./manifest";
import { claim, RegionConflictError } from "./region-tracker";
@@ -19,6 +27,11 @@ export type RunResult = {
manifest: StanzaManifest;
touchedFiles: string[];
dryRun: boolean;
/**
* Non-null when this add caused a new `packages/<dir>/` package to be
* bootstrapped — used by `add.ts` to print a `pnpm install` hint.
*/
bootstrappedPackage?: { dir: string; name: string };
};
/**
@@ -45,48 +58,96 @@ export async function applyModule(args: {
const owner = module.id;
const moduleDir = path.join(registryRoot, "modules", `${module.slot}-${module.id}`);
// Slot → package mapping. When non-null, this slot's templates/deps/scripts
// are routed into `packages/<packageDir>/` instead of into the active app.
const packageDir = SLOT_PACKAGE_DIR[module.slot];
const packageRoot = packageDir ? path.join(projectRoot, "packages", packageDir) : null;
const packageName = packageDir ? `@${manifest.name}/${packageDir}` : "";
const needsPackage =
packageDir !== null &&
(Boolean(adapter.templates?.some((t) => t.scope === "package")) ||
Boolean(adapter.dependencies && Object.keys(adapter.dependencies).length > 0) ||
Boolean(adapter.devDependencies && Object.keys(adapter.devDependencies).length > 0) ||
Boolean(adapter.scripts && Object.keys(adapter.scripts).length > 0));
let bootstrappedPackage: { dir: string; name: string } | undefined;
if (needsPackage && packageDir && packageRoot) {
const created = ensureSlotPackage({
projectRoot,
appRoot,
manifest,
packageDir,
packageName,
packageRoot,
peerPackages: adapter.peerPackages ?? [],
dryRun,
});
if (created) bootstrappedPackage = { dir: packageDir, name: packageName };
}
// Render context provides `{{packageName}}` for the active module's own
// package, plus shorthand `{{<dir>PackageName}}` keys for every slot package
// so cross-package imports (e.g. better-auth's auth.ts importing `db` from
// `@<project>/db`) can be templated declaratively.
const renderContext: Record<string, string> = {
appDir: manifest.appDir,
projectName: manifest.name,
packageName,
};
for (const dir of new Set(
Object.values(SLOT_PACKAGE_DIR).filter((d): d is string => d !== null),
)) {
renderContext[`${dir}PackageName`] = `@${manifest.name}/${dir}`;
}
// 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 dest = resolveTemplateDest({ tpl, projectRoot, appRoot, packageRoot, slot: module.slot });
const rel = path.relative(projectRoot, dest);
manifest = claim(manifest, rel, "file", owner);
if (!dryRun) {
const source = readTemplateSource(tpl, moduleDir);
const rendered = tpl.template
? renderTemplate(source, { appDir: manifest.appDir, projectName: manifest.name })
: source;
const rendered = tpl.template ? renderTemplate(source, renderContext) : source;
fs.mkdirSync(path.dirname(dest), { recursive: true });
fs.writeFileSync(dest, rendered, "utf8");
}
touchedFiles.add(rel);
}
// 2. Dependencies on the host package.json (in the active app).
const appPkg = path.join(appRoot, "package.json");
// 2. Dependencies + scripts on package.json. Routes to the slot's package
// when `SLOT_PACKAGE_DIR[slot]` is non-null; otherwise the active app.
const pkgJsonPath = packageRoot
? path.join(packageRoot, "package.json")
: path.join(appRoot, "package.json");
if (
(adapter.dependencies || adapter.devDependencies || adapter.scripts) &&
fs.existsSync(appPkg)
fs.existsSync(pkgJsonPath)
) {
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);
manifest = claim(
manifest,
path.relative(projectRoot, pkgJsonPath),
`dependencies.${name}`,
owner,
);
if (!dryRun) addPackageDependency(pkgJsonPath, name, range);
}
for (const [name, range] of Object.entries(adapter.devDependencies ?? {})) {
manifest = claim(
manifest,
path.relative(projectRoot, appPkg),
path.relative(projectRoot, pkgJsonPath),
`devDependencies.${name}`,
owner,
);
if (!dryRun) addPackageDependency(appPkg, name, range, { dev: true });
if (!dryRun) addPackageDependency(pkgJsonPath, 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);
manifest = claim(manifest, path.relative(projectRoot, pkgJsonPath), `scripts.${name}`, owner);
if (!dryRun) addPackageScript(pkgJsonPath, name, command);
}
touchedFiles.add(path.relative(projectRoot, appPkg));
touchedFiles.add(path.relative(projectRoot, pkgJsonPath));
}
// 3. Env vars in .env.example at repo root.
@@ -101,7 +162,9 @@ export async function applyModule(args: {
// 4. Imperative codemods — dispatched through the CLI's generic catalog.
// Modules don't ship code; they reference catalog entries by id and pass
// the per-invocation args from their manifest.
// the per-invocation args from their manifest. Args are rendered through
// the same template substitution as file bodies so modules can declare
// e.g. `providerImport: "{{packageName}}"`.
if (adapter.codemods?.length) {
const project = lazyProject(appRoot);
const ctx = buildContext({
@@ -125,7 +188,8 @@ export async function applyModule(args: {
);
}
if (!dryRun) {
const result = await fn.apply(ctx, invocation.args ?? {});
const renderedArgs = renderArgs(invocation.args ?? {}, renderContext);
const result = await fn.apply(ctx, renderedArgs);
result.touchedFiles.forEach((f) => touchedFiles.add(f));
}
}
@@ -147,7 +211,174 @@ export async function applyModule(args: {
writeManifest(projectRoot, manifest);
}
return { manifest, touchedFiles: [...touchedFiles], dryRun };
return { manifest, touchedFiles: [...touchedFiles], dryRun, bootstrappedPackage };
}
function resolveTemplateDest(args: {
tpl: TemplateRef;
projectRoot: string;
appRoot: string;
packageRoot: string | null;
slot: SlotId;
}): string {
const { tpl, projectRoot, appRoot, packageRoot, slot } = args;
if (tpl.scope === "repo") return path.join(projectRoot, tpl.dest);
if (tpl.scope === "package") {
if (!packageRoot) {
throw new Error(
`Template scope "package" is not valid for slot "${slot}" — SLOT_PACKAGE_DIR has no entry for it. ` +
`Use scope "app" for framework/styling modules.`,
);
}
return path.join(packageRoot, tpl.dest);
}
return path.join(appRoot, tpl.dest);
}
/**
* Create the slot's workspace package on first need. Idempotent: if the
* package.json/tsconfig.json/workspace dep already exist, this is a no-op.
* Returns true when at least one file was newly created (the signal `add.ts`
* uses to print a `pnpm install` hint).
*
* These files are NOT claimed as regions: they are shared by every module
* that lives in the package (e.g. db + orm both write into packages/db/).
* The remove path's package-dir sweep deletes them only after every module
* has released its claims under packages/<dir>/.
*/
function ensureSlotPackage(args: {
projectRoot: string;
appRoot: string;
manifest: StanzaManifest;
packageDir: string;
packageName: string;
packageRoot: string;
peerPackages: string[];
dryRun: boolean;
}): boolean {
const { appRoot, packageDir, packageName, packageRoot, peerPackages, dryRun } = args;
const pkgPath = path.join(packageRoot, "package.json");
const tsconfigPath = path.join(packageRoot, "tsconfig.json");
const appPkgPath = path.join(appRoot, "package.json");
let created = false;
if (!fs.existsSync(pkgPath)) {
created = true;
if (!dryRun) {
fs.mkdirSync(packageRoot, { recursive: true });
fs.writeFileSync(
pkgPath,
JSON.stringify(
{
name: packageName,
version: "0.0.0",
private: true,
type: "module",
main: "./src/index.ts",
types: "./src/index.ts",
exports: { ".": "./src/index.ts" },
},
null,
2,
) + "\n",
"utf8",
);
}
}
if (!fs.existsSync(tsconfigPath)) {
created = true;
if (!dryRun) {
// Self-contained tsconfig — generated projects don't share a base, so
// each app and package stands on its own. Mirrors the shape framework
// modules ship for `apps/web/tsconfig.json`.
fs.writeFileSync(
tsconfigPath,
JSON.stringify(
{
compilerOptions: {
target: "ES2022",
lib: ["dom", "dom.iterable", "esnext"],
module: "esnext",
moduleResolution: "bundler",
strict: true,
noUncheckedIndexedAccess: true,
skipLibCheck: true,
esModuleInterop: true,
resolveJsonModule: true,
isolatedModules: true,
noEmit: true,
types: ["node"],
},
include: ["src"],
exclude: ["node_modules"],
},
null,
2,
) + "\n",
"utf8",
);
}
}
// Wire the workspace dep into the host app's package.json on first
// bootstrap. Not region-tracked — sweep cleans it up.
if (fs.existsSync(appPkgPath)) {
const appPkg = JSON.parse(fs.readFileSync(appPkgPath, "utf8")) as {
dependencies?: Record<string, string>;
};
if (appPkg.dependencies?.[packageName] !== "workspace:*") {
created = true;
if (!dryRun) addPackageDependency(appPkgPath, packageName, "workspace:*");
}
}
// Wire cross-package workspace deps so this package can import from its
// peers (e.g. better-auth's auth.ts importing `db` from `@<project>/db`).
// Skip self-references and unknown package dirs.
if (peerPackages.length > 0) {
const allowed = new Set(Object.values(SLOT_PACKAGE_DIR).filter((d): d is string => d !== null));
const ownPkgJson = path.join(packageRoot, "package.json");
for (const peer of peerPackages) {
if (peer === packageDir) continue;
if (!allowed.has(peer)) continue;
const peerName = `@${args.manifest.name}/${peer}`;
if (!fs.existsSync(ownPkgJson)) continue;
const pkg = JSON.parse(fs.readFileSync(ownPkgJson, "utf8")) as {
dependencies?: Record<string, string>;
};
if (pkg.dependencies?.[peerName] !== "workspace:*") {
created = true;
if (!dryRun) addPackageDependency(ownPkgJson, peerName, "workspace:*");
}
}
}
return created;
}
/**
* Walk a codemod args object and run renderTemplate over every string leaf.
* Non-string values pass through untouched. The contract for catalog
* codemods is that string args may contain `{{projectName}}`, `{{appDir}}`,
* `{{packageName}}` — anything else should be passed in raw.
*/
function renderArgs(
args: Record<string, JsonValue>,
context: Record<string, string>,
): Record<string, JsonValue> {
const visit = (value: JsonValue): JsonValue => {
if (typeof value === "string") return renderTemplate(value, context);
if (Array.isArray(value)) return value.map(visit);
if (value && typeof value === "object") {
const out: Record<string, JsonValue> = {};
for (const [k, v] of Object.entries(value)) out[k] = visit(v);
return out;
}
return value;
};
return visit(args) as Record<string, JsonValue>;
}
/**
+6 -1
View File
@@ -64,8 +64,9 @@ export async function cmdAdd(args: {
spinner.start(`Adding ${mod.label}`);
const registryRoot = pickRegistryRoot();
let result;
try {
await applyModule({
result = await applyModule({
projectRoot,
manifest,
module: mod,
@@ -79,6 +80,10 @@ export async function cmdAdd(args: {
}
spinner.stop(`${kleur.green("✓")} ${mod.label} added`);
if (result.bootstrappedPackage) {
const { name } = result.bootstrappedPackage;
p.log.info(`Run ${kleur.cyan("pnpm install")} to link ${kleur.cyan(name)}.`);
}
if (dryRun) p.log.info(kleur.yellow("[dry-run] no files were written"));
}
+28 -1
View File
@@ -3,7 +3,7 @@ import path from "node:path";
import * as p from "@clack/prompts";
import { removePackageDependency, removeEnvVar } from "@stanza/codemods";
import { KNOWN_SLOTS, type SlotId } from "@stanza/registry";
import { KNOWN_SLOTS, SLOT_PACKAGE_DIR, type SlotId } from "@stanza/registry";
import kleur from "kleur";
import type { Argv } from "mri";
@@ -87,11 +87,38 @@ export async function cmdRemove(args: { slot?: string; argv: Argv }): Promise<vo
const nextModules = { ...manifest.modules };
delete nextModules[slot];
// Sweep: for each slot that maps to an internal package, if nothing remains
// claimed under packages/<dir>/, tear down the bootstrap (package.json,
// tsconfig.json, the dir itself) and drop the workspace dep from the app.
// The system-owned bootstrap files aren't tracked as regions, so they'd
// otherwise linger forever.
const sweptPackages: string[] = [];
const appPkgRel = `${manifest.appDir}/package.json`;
const appPkgAbs = path.join(projectRoot, appPkgRel);
for (const dir of new Set(
Object.values(SLOT_PACKAGE_DIR).filter((d): d is string => d !== null),
)) {
const stillUsed = Object.keys(nextRegions).some((file) => file.startsWith(`packages/${dir}/`));
if (stillUsed) continue;
const pkgRoot = path.join(projectRoot, "packages", dir);
if (!fs.existsSync(pkgRoot)) continue;
if (!dryRun) {
fs.rmSync(pkgRoot, { recursive: true, force: true });
if (fs.existsSync(appPkgAbs)) {
removePackageDependency(appPkgAbs, `@${manifest.name}/${dir}`);
}
}
sweptPackages.push(dir);
}
if (!dryRun) {
writeManifest(projectRoot, { ...manifest, modules: nextModules, regions: nextRegions });
}
p.log.success(`${kleur.green("✓")} Removed ${installed.id} from ${slot}`);
if (sweptPackages.length > 0) {
p.log.info(`Swept packages/${sweptPackages.join(", packages/")} (no remaining slot owns it).`);
}
if (manualCleanup.length > 0) {
p.log.warn(
`${manualCleanup.length} region(s) need manual cleanup:\n` +
+25 -5
View File
@@ -1,5 +1,11 @@
import type { Module, ModuleAdapter, SlotId, TemplateRef } from "@stanza/registry";
import { KNOWN_SLOTS, emptyManifest, resolveAdapter, slotOrder } from "@stanza/registry";
import {
KNOWN_SLOTS,
SLOT_PACKAGE_DIR,
emptyManifest,
resolveAdapter,
slotOrder,
} from "@stanza/registry";
export type Selections = Partial<Record<SlotId, string>>;
@@ -80,9 +86,12 @@ export type SelectedFile = {
/**
* Derive the full file list stanza will write for the current selection.
* Mirrors codemod-runner's resolution: `scope: "app"` files are prefixed by
* the active app dir (we default to `apps/web/`); `scope: "repo"` files land
* at the repo root. Returns a deterministic order grouped by slot order.
* Mirrors codemod-runner's resolution:
* - `scope: "repo"` → repo root
* - `scope: "app"` → the active app dir (defaults to `apps/web/`)
* - `scope: "package"` → `packages/<SLOT_PACKAGE_DIR[slot]>/`
*
* Returns a deterministic order grouped by slot order.
*/
export function selectedFiles(
resolved: Partial<Record<SlotId, { module: Module; adapter: ModuleAdapter }>>,
@@ -93,13 +102,24 @@ export function selectedFiles(
const entry = resolved[slot];
if (!entry) continue;
for (const tpl of entry.adapter.templates ?? []) {
const path = tpl.scope === "repo" ? tpl.dest : `${appDir.replace(/\/$/, "")}/${tpl.dest}`;
const path = resolveTemplatePath(tpl, slot, appDir);
out.push({ path, template: tpl, owner: { slot, module: entry.module.id } });
}
}
return out;
}
function resolveTemplatePath(tpl: TemplateRef, slot: SlotId, appDir: string): string {
if (tpl.scope === "repo") return tpl.dest;
if (tpl.scope === "package") {
const dir = SLOT_PACKAGE_DIR[slot];
// Defensive: if a module declares `scope: "package"` for a slot with no
// package dir, the CLI runner would error; the preview just hides it.
return dir ? `packages/${dir}/${tpl.dest}` : tpl.dest;
}
return `${appDir.replace(/\/$/, "")}/${tpl.dest}`;
}
/**
* Build the `pnpm create stanza` command from the current state. Used both by
* the command-bar display and by the copy-to-clipboard action.
@@ -17,9 +17,22 @@ export type CommentStyle = "line" | "hash" | "block";
* gives us robust idempotency without parsing the host format.
*/
export type AppendToFileArgs = {
/** Path relative to `projectRoot` or `appRoot` per `scope`. */
/** Path relative to the base directory (see `scope` and `base`). */
file: string;
/**
* Where `file` resolves against:
* - `"repo"`: `ctx.projectRoot`
* - `"app"` (default): `ctx.appRoot`
*
* Mutually exclusive with `base` — set one or the other.
*/
scope?: "repo" | "app";
/**
* Alternative to `scope` for targeting an internal workspace package, e.g.
* `"package:db"` resolves against `<projectRoot>/packages/db/`. Lets auth
* modules append to `packages/db/prisma/schema.prisma` without owning it.
*/
base?: `package:${string}`;
/** Text content to append. Multi-line strings are common. */
content: string;
/**
@@ -106,14 +119,24 @@ const appendToFile: Codemod<AppendToFileArgs> = {
};
function resolve(ctx: Parameters<Codemod<AppendToFileArgs>["apply"]>[0], args: AppendToFileArgs) {
const scope = args.scope ?? "app";
const base = scope === "repo" ? ctx.projectRoot : ctx.appRoot;
const fileAbs = path.join(base, args.file);
const baseAbs = baseDir(ctx, args);
const fileAbs = path.join(baseAbs, args.file);
const fileRel = path.relative(ctx.projectRoot, fileAbs);
const comment = args.commentStyle ?? inferCommentStyle(args.file);
return { fileAbs, fileRel, comment };
}
function baseDir(
ctx: Parameters<Codemod<AppendToFileArgs>["apply"]>[0],
args: AppendToFileArgs,
): string {
if (args.base && args.base.startsWith("package:")) {
return path.join(ctx.projectRoot, "packages", args.base.slice("package:".length));
}
const scope = args.scope ?? "app";
return scope === "repo" ? ctx.projectRoot : ctx.appRoot;
}
/**
* Inferred comment syntax per file extension. Returning `undefined` here
* means the caller has to pass `commentStyle` explicitly.
@@ -11,12 +11,12 @@ export const user = sqliteTable("user", {
});
`;
function setup(initial: string = BARREL) {
function setup(initial: string = BARREL, opts: { barrelPath?: string } = {}) {
const seed = openProject("/repo/apps/web");
const inMem: Project = new (seed.constructor as new (opts: Record<string, unknown>) => Project)({
useInMemoryFileSystem: true,
});
const sf = inMem.createSourceFile("/repo/apps/web/src/db/schema.ts", initial);
const sf = inMem.createSourceFile(opts.barrelPath ?? "/repo/apps/web/src/db/schema.ts", initial);
const claimed: Array<{ file: string; region: string }> = [];
const released: Array<{ file: string; region: string }> = [];
@@ -141,6 +141,23 @@ describe("re-export", () => {
);
});
it('resolves against packages/<dir>/ when base is "package:<dir>"', () => {
const { ctx, sf, claimed } = setup(BARREL, {
barrelPath: "/repo/packages/db/src/schema.ts",
});
const result = reExport.apply(ctx, {
file: "src/schema.ts",
from: "@t/auth/auth-schema",
base: "package:db",
}) as { touchedFiles: string[] };
expect(result.touchedFiles).toEqual(["packages/db/src/schema.ts"]);
expect(sf.getFullText()).toContain(`export * from "@t/auth/auth-schema"`);
expect(claimed).toEqual([
{ file: "packages/db/src/schema.ts", region: "re-exports.@t/auth/auth-schema" },
]);
});
it("lets two modules re-export from different sources without colliding", () => {
const { ctx, sf, claimed } = setup();
reExport.apply(ctx, ARGS_STAR);
+22 -3
View File
@@ -20,7 +20,7 @@ import {
* so two modules don't silently disagree on how to re-export a peer.
*/
export type ReExportArgs = {
/** Barrel file path, appRoot-relative (e.g. `"src/db/schema.ts"`). */
/** Barrel file path, resolved against `base` (default: the app root). */
file: string;
/** Module specifier to re-export from (e.g. `"./auth-schema"`). */
from: string;
@@ -29,6 +29,14 @@ export type ReExportArgs = {
* named exports emits `export { a, b } from "<from>";`.
*/
names?: string[] | "all";
/**
* Where `file` resolves against:
* - `"app"` (default): `ctx.appRoot/<file>` — the active framework app.
* - `"package:<dir>"`: `<projectRoot>/packages/<dir>/<file>` — an internal
* workspace package. Used when auth needs to wire its schema barrel into
* `packages/db/`, which lives outside the app.
*/
base?: "app" | `package:${string}`;
};
const reExport: Codemod<ReExportArgs> = {
@@ -36,7 +44,7 @@ const reExport: Codemod<ReExportArgs> = {
description: 'Append an `export ... from "X"` statement to a barrel file.',
apply(ctx, args) {
const fileAbs = path.join(ctx.appRoot, args.file);
const fileAbs = resolveBase(ctx, args);
const fileRel = path.relative(ctx.projectRoot, fileAbs);
const sf = openOrThrow(ctx, fileAbs);
@@ -83,7 +91,7 @@ const reExport: Codemod<ReExportArgs> = {
},
revert(ctx, args) {
const fileAbs = path.join(ctx.appRoot, args.file);
const fileAbs = resolveBase(ctx, args);
const fileRel = path.relative(ctx.projectRoot, fileAbs);
const sf = openOrThrow(ctx, fileAbs);
@@ -101,6 +109,17 @@ function regionKey(from: string): string {
return `re-exports.${from}`;
}
function resolveBase(
ctx: Parameters<Codemod<ReExportArgs>["apply"]>[0],
args: ReExportArgs,
): string {
if (args.base && args.base.startsWith("package:")) {
const dir = args.base.slice("package:".length);
return path.join(ctx.projectRoot, "packages", dir, args.file);
}
return path.join(ctx.appRoot, args.file);
}
function openOrThrow(
ctx: Parameters<Codemod<ReExportArgs>["apply"]>[0],
fileAbs: string,
+1 -1
View File
@@ -14,7 +14,7 @@ export type {
CodemodInvocation,
JsonValue,
} from "./module";
export { defineModule, KNOWN_SLOTS } from "./module";
export { defineModule, KNOWN_SLOTS, SLOT_PACKAGE_DIR } from "./module";
export type { StanzaManifest, StanzaModuleRecord, RegionMap, RegionOwnership } from "./manifest";
export { StanzaManifestSchema, CURRENT_MANIFEST_VERSION, emptyManifest } from "./manifest";
+34 -2
View File
@@ -4,6 +4,26 @@ export const KNOWN_SLOTS = ["framework", "orm", "db", "auth", "styling"] as cons
export type SlotId = (typeof KNOWN_SLOTS)[number];
/**
* Slot → internal-package directory under `packages/`. Modules whose slot has
* a non-null entry are extracted into their own workspace package
* (`packages/<dir>/`, named `@<manifest.name>/<dir>`); the app consumes them
* via a `workspace:*` dep. Many-to-one is intentional: `db` and `orm` share a
* single `packages/db/` package so the ORM client can sit next to the schema
* it queries.
*
* `null` means "no package — `scope: "package"` is invalid for this slot".
* Framework and styling stay app-scoped because they wire the app shell
* itself (root layout, global CSS) and have no meaningful separation point.
*/
export const SLOT_PACKAGE_DIR: Record<SlotId, string | null> = {
framework: null,
styling: null,
auth: "auth",
db: "db",
orm: "db",
};
export type Slot = {
id: SlotId;
label: string;
@@ -39,6 +59,14 @@ export type ModuleAdapter = {
env?: EnvVar[];
/** package.json `scripts` to merge into the host app. */
scripts?: Record<string, string>;
/**
* Other internal-package directories this adapter consumes — referenced by
* the same name used in `SLOT_PACKAGE_DIR` values (e.g. `"db"`). The runner
* adds `@<manifest.name>/<dir>: workspace:*` to this adapter's package so
* cross-package imports resolve. Only meaningful when this adapter's own
* slot maps to a package dir.
*/
peerPackages?: string[];
};
export type TemplateRef = {
@@ -50,8 +78,11 @@ export type TemplateRef = {
* Where the dest is resolved against:
* - "repo": repo root
* - "app": the active framework app dir (apps/web by default)
* - "package": the slot's internal package dir under packages/<dir>, where
* `<dir>` is `SLOT_PACKAGE_DIR[module.slot]`. Invalid for slots whose
* entry is `null` (framework, styling).
*/
scope?: "repo" | "app";
scope?: "repo" | "app" | "package";
/** If true, run as a template (mustache-style) with the manifest as context. */
template?: boolean;
/**
@@ -188,7 +219,7 @@ export const ModuleSchema = z.object({
z.object({
src: z.string(),
dest: z.string(),
scope: z.enum(["repo", "app"]).optional(),
scope: z.enum(["repo", "app", "package"]).optional(),
template: z.boolean().optional(),
content: z.string().optional(),
}),
@@ -215,6 +246,7 @@ export const ModuleSchema = z.object({
)
.optional(),
scripts: z.record(z.string(), z.string()).optional(),
peerPackages: z.array(z.string()).optional(),
}),
),
homepage: z.string().optional(),
+97 -45
View File
@@ -18,20 +18,27 @@ const env = [
const deps = { "better-auth": "^1.6.11" };
// All four drizzle adapters share the same barrel-re-export step — the auth
// tables ship as `src/db/auth-schema.ts` and need to be visible through the
// existing Drizzle barrel at `src/db/schema.ts` for drizzle-kit to see them.
// All drizzle adapters wire their schema barrel inside the shared db package.
// The auth tables live in `@<project>/auth/auth-schema` (shipped from the
// auth package's own templates) and the orm-owned barrel at
// `packages/db/src/schema.ts` re-exports them so drizzle-kit introspects
// both schemas as one.
const drizzleBarrelExport = [
{
id: "re-export",
args: { file: "src/db/schema.ts", from: "./auth-schema" },
args: {
file: "src/schema.ts",
from: "{{packageName}}/auth-schema",
base: "package:db",
},
},
] as const;
// Both prisma adapters append the same auth models to the orm-prisma-owned
// `prisma/schema.prisma`. The models match Better Auth's canonical Prisma
// schema (user / session / account / verification); the marker wraps the
// block so re-apply is a no-op and `stanza remove auth` cleans it out.
// `prisma/schema.prisma` inside `packages/db/`. The models match Better
// Auth's canonical Prisma schema (user / session / account / verification);
// the marker wraps the block so re-apply is a no-op and `stanza remove auth`
// cleans it out.
const prismaAuthModels = `model User {
id String @id
name String
@@ -97,10 +104,59 @@ const prismaAuthModelsAppend = [
file: "prisma/schema.prisma",
content: prismaAuthModels,
marker: "better-auth-models",
base: "package:db",
},
},
] as const;
// Files that go into the auth package itself (`packages/auth/`). The auth
// barrel is shared across every adapter; the auth.ts implementation varies
// by ORM/framework.
const authPackageBarrel = {
src: "shared/index.ts",
dest: "src/index.ts",
scope: "package",
} as const;
const authClientPackage = (src: string) =>
({
src,
dest: "src/auth-client.ts",
scope: "package",
}) as const;
const authImplPackage = (src: string) =>
({
src,
dest: "src/auth.ts",
scope: "package",
template: true,
}) as const;
const authSchemaPackage = (src: string) =>
({
src,
dest: "src/auth-schema.ts",
scope: "package",
}) as const;
// API route files stay app-scoped (framework routing conventions) but they
// import from the auth package via `{{packageName}}` so the better-auth dep
// lives only in `packages/auth/`.
const nextApiRoute = {
src: "next/route.ts",
dest: "app/api/auth/[...all]/route.ts",
scope: "app",
template: true,
} as const;
const tanstackApiRoute = {
src: "tanstack/api.ts",
dest: "src/routes/api/auth/$.ts",
scope: "app",
template: true,
} as const;
export default defineModule({
id: "better-auth",
slot: "auth",
@@ -119,15 +175,13 @@ export default defineModule({
match: { framework: "next", orm: "drizzle", db: "postgres" },
dependencies: deps,
env,
peerPackages: ["db"],
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-postgres.ts",
dest: "src/db/auth-schema.ts",
scope: "app",
},
authImplPackage("next/auth.drizzle.ts"),
authClientPackage("next/auth-client.ts"),
authSchemaPackage("shared/auth-schema.drizzle-postgres.ts"),
authPackageBarrel,
nextApiRoute,
],
codemods: [...drizzleBarrelExport],
},
@@ -136,15 +190,13 @@ export default defineModule({
match: { framework: "next", orm: "drizzle", db: "sqlite" },
dependencies: deps,
env,
peerPackages: ["db"],
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-sqlite.ts",
dest: "src/db/auth-schema.ts",
scope: "app",
},
authImplPackage("next/auth.drizzle.ts"),
authClientPackage("next/auth-client.ts"),
authSchemaPackage("shared/auth-schema.drizzle-sqlite.ts"),
authPackageBarrel,
nextApiRoute,
],
codemods: [...drizzleBarrelExport],
},
@@ -153,10 +205,12 @@ export default defineModule({
match: { framework: "next", orm: "prisma" },
dependencies: deps,
env,
peerPackages: ["db"],
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" },
authImplPackage("next/auth.prisma.ts"),
authClientPackage("next/auth-client.ts"),
{ src: "shared/index.prisma.ts", dest: "src/index.ts", scope: "package" },
nextApiRoute,
],
codemods: [...prismaAuthModelsAppend],
},
@@ -165,15 +219,13 @@ export default defineModule({
match: { framework: "tanstack-start", orm: "drizzle", db: "postgres" },
dependencies: deps,
env,
peerPackages: ["db"],
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-postgres.ts",
dest: "src/db/auth-schema.ts",
scope: "app",
},
authImplPackage("tanstack/auth.drizzle.ts"),
authClientPackage("tanstack/auth-client.ts"),
authSchemaPackage("shared/auth-schema.drizzle-postgres.ts"),
authPackageBarrel,
tanstackApiRoute,
],
codemods: [...drizzleBarrelExport],
},
@@ -182,15 +234,13 @@ export default defineModule({
match: { framework: "tanstack-start", orm: "drizzle", db: "sqlite" },
dependencies: deps,
env,
peerPackages: ["db"],
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-sqlite.ts",
dest: "src/db/auth-schema.ts",
scope: "app",
},
authImplPackage("tanstack/auth.drizzle.ts"),
authClientPackage("tanstack/auth-client.ts"),
authSchemaPackage("shared/auth-schema.drizzle-sqlite.ts"),
authPackageBarrel,
tanstackApiRoute,
],
codemods: [...drizzleBarrelExport],
},
@@ -199,10 +249,12 @@ export default defineModule({
match: { framework: "tanstack-start", orm: "prisma" },
dependencies: deps,
env,
peerPackages: ["db"],
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" },
authImplPackage("tanstack/auth.prisma.ts"),
authClientPackage("tanstack/auth-client.ts"),
{ src: "shared/index.prisma.ts", dest: "src/index.ts", scope: "package" },
tanstackApiRoute,
],
codemods: [...prismaAuthModelsAppend],
},
@@ -1,7 +1,7 @@
import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { db } from "@/db";
import { db } from "{{dbPackageName}}";
export const auth = betterAuth({
database: drizzleAdapter(db, { provider: "pg" }),
@@ -1,7 +1,7 @@
import { betterAuth } from "better-auth";
import { prismaAdapter } from "better-auth/adapters/prisma";
import { db } from "@/db";
import { db } from "{{dbPackageName}}";
export const auth = betterAuth({
database: prismaAdapter(db, { provider: "postgresql" }),
@@ -1,5 +1,5 @@
import { toNextJsHandler } from "better-auth/next-js";
import { auth } from "@/lib/auth";
import { auth } from "{{packageName}}";
export const { GET, POST } = toNextJsHandler(auth);
@@ -0,0 +1,2 @@
export { auth } from "./auth";
export { authClient } from "./auth-client";
@@ -0,0 +1,3 @@
export { auth } from "./auth";
export { authClient } from "./auth-client";
export * from "./auth-schema";
@@ -1,6 +1,6 @@
import { createAPIFileRoute } from "@tanstack/react-start/api";
import { auth } from "~/lib/auth";
import { auth } from "{{packageName}}";
export const APIRoute = createAPIFileRoute("/api/auth/$")({
GET: ({ request }) => auth.handler(request),
@@ -1,7 +1,7 @@
import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { db } from "~/db";
import { db } from "{{dbPackageName}}";
export const auth = betterAuth({
database: drizzleAdapter(db, { provider: "pg" }),
@@ -1,7 +1,7 @@
import { betterAuth } from "better-auth";
import { prismaAdapter } from "better-auth/adapters/prisma";
import { db } from "~/db";
import { db } from "{{dbPackageName}}";
export const auth = betterAuth({
database: prismaAdapter(db, { provider: "postgresql" }),
+8 -3
View File
@@ -34,15 +34,20 @@ export default defineModule({
},
],
templates: [
{ src: "middleware.ts", dest: "middleware.ts", scope: "app" },
{ src: "layout-wrapper.tsx", dest: "app/_clerk-provider.tsx", scope: "app" },
// Package-scoped: provider + barrel land in packages/auth/.
{ src: "provider.tsx", dest: "src/provider.tsx", scope: "package" },
{ src: "index.ts", dest: "src/index.ts", scope: "package" },
// App-scoped: Next requires middleware.ts at the app root. We ship a
// thin shim that re-exports clerkMiddleware from the auth package, so
// the @clerk/nextjs dep stays in packages/auth/.
{ src: "middleware.ts", dest: "middleware.ts", scope: "app", template: true },
],
codemods: [
{
id: "wrap-root-layout",
args: {
providerName: "ClerkRootProvider",
providerImport: "./_clerk-provider",
providerImport: "{{packageName}}",
},
},
],
@@ -0,0 +1,2 @@
export { ClerkRootProvider } from "./provider";
export { clerkMiddleware } from "@clerk/nextjs/server";
@@ -1,4 +1,4 @@
import { clerkMiddleware } from "@clerk/nextjs/server";
import { clerkMiddleware } from "{{packageName}}";
export default clerkMiddleware();
+6 -6
View File
@@ -20,9 +20,9 @@ export default defineModule({
"db:studio": "drizzle-kit studio",
},
templates: [
{ src: "drizzle.config.postgres.ts", dest: "drizzle.config.ts", scope: "app" },
{ src: "src/db/index.postgres.ts", dest: "src/db/index.ts", scope: "app" },
{ src: "src/db/schema.postgres.ts", dest: "src/db/schema.ts", scope: "app" },
{ src: "drizzle.config.postgres.ts", dest: "drizzle.config.ts", scope: "package" },
{ src: "src/db/index.postgres.ts", dest: "src/index.ts", scope: "package" },
{ src: "src/db/schema.postgres.ts", dest: "src/schema.ts", scope: "package" },
],
},
{
@@ -36,9 +36,9 @@ export default defineModule({
"db:studio": "drizzle-kit studio",
},
templates: [
{ src: "drizzle.config.sqlite.ts", dest: "drizzle.config.ts", scope: "app" },
{ src: "src/db/index.sqlite.ts", dest: "src/db/index.ts", scope: "app" },
{ src: "src/db/schema.sqlite.ts", dest: "src/db/schema.ts", scope: "app" },
{ src: "drizzle.config.sqlite.ts", dest: "drizzle.config.ts", scope: "package" },
{ src: "src/db/index.sqlite.ts", dest: "src/index.ts", scope: "package" },
{ src: "src/db/schema.sqlite.ts", dest: "src/schema.ts", scope: "package" },
],
},
],
@@ -1,7 +1,7 @@
import { defineConfig } from "drizzle-kit";
export default defineConfig({
schema: "./src/db/schema.ts",
schema: "./src/schema.ts",
out: "./drizzle",
dialect: "postgresql",
dbCredentials: {
@@ -1,7 +1,7 @@
import { defineConfig } from "drizzle-kit";
export default defineConfig({
schema: "./src/db/schema.ts",
schema: "./src/schema.ts",
out: "./drizzle",
dialect: "sqlite",
dbCredentials: {
+4 -4
View File
@@ -20,8 +20,8 @@ export default defineModule({
"db:studio": "prisma studio",
},
templates: [
{ src: "prisma/schema.postgres.prisma", dest: "prisma/schema.prisma", scope: "app" },
{ src: "src/db.ts", dest: "src/db.ts", scope: "app" },
{ src: "prisma/schema.postgres.prisma", dest: "prisma/schema.prisma", scope: "package" },
{ src: "src/db.ts", dest: "src/index.ts", scope: "package" },
],
},
{
@@ -35,8 +35,8 @@ export default defineModule({
"db:studio": "prisma studio",
},
templates: [
{ src: "prisma/schema.sqlite.prisma", dest: "prisma/schema.prisma", scope: "app" },
{ src: "src/db.ts", dest: "src/db.ts", scope: "app" },
{ src: "prisma/schema.sqlite.prisma", dest: "prisma/schema.prisma", scope: "package" },
{ src: "src/db.ts", dest: "src/index.ts", scope: "package" },
],
},
],