mirror of
https://github.com/jakejarvis/stanza.git
synced 2026-07-16 18:05:58 -04:00
refactor: dissolve packages/internal into scripts/, reorganize CLI into src/lib/, and wire stanza remove codemod revert
- Delete `packages/internal` workspace package — `registry-build.ts` and `module-new.ts` move to top-level `scripts/` (no `package.json`, not shipped); `pnpm registry:build` and `pnpm module:new` scripts updated in root `package.json` and `turbo.json`; CLAUDE.md / TODO.md references updated - Relocate CLI internals from `apps/cli/src/` → `apps/cli/src/lib/` — `codemod-runner.ts`, `manifest.ts`, `region-tracker.ts`, `registry-loader.ts`, `wizard.ts`; all intra-CLI imports updated to `@/lib/*` path alias via tsconfig `paths`; add `@types/bun` to CLI devDependencies - `stanza remove` now dispatches `revertCodemods()` as step 1 (before declarative cleanup) — re-loads the module adapter from the registry, calls imperative reverts while framework files are still intact, then sweeps remaining region claims; fixes `wrap-root-layout` / `re-export` regions previously left as "needs manual cleanup" despite shipping working reverts
This commit is contained in:
@@ -21,7 +21,7 @@ Three things differentiate stanza from other scaffolders:
|
||||
- `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/internal/` — Internal maintenance scripts: `registry-build.ts` (emits static CDN JSON), `module-new.ts` (scaffolds a new module)
|
||||
- `scripts/` — repo-root maintainer helpers (not shipped): `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).
|
||||
@@ -29,7 +29,7 @@ In a **generated project**, `auth`, `db`, and `orm` modules install into their o
|
||||
## Commands
|
||||
|
||||
- `bun apps/cli/src/bin.ts <verb>` — run CLI directly without build
|
||||
- `pnpm registry:build` (or `bun packages/internal/src/registry-build.ts`) — regenerate `dist/registry/{index,modules/*}.json`
|
||||
- `pnpm registry:build` (or `bun scripts/registry-build.ts`) — regenerate `dist/registry/{index,modules/*}.json`
|
||||
- `pnpm module:new [slot] [id]` — scaffold a new module under `registry/modules/`
|
||||
- `pnpm --filter @stanza/web dev` — TanStack Start dev server; `predev` hook auto-copies `dist/registry/` → `apps/web/public/registry/` so the same-domain registry path resolves
|
||||
- `pnpm lint` / `pnpm lint:fix` — Oxlint across the whole repo (config: `.oxlintrc.json`)
|
||||
@@ -42,8 +42,8 @@ In a **generated project**, `auth`, `db`, and `orm` modules install into their o
|
||||
## 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
|
||||
- TypeScript 6 — `allowImportingTsExtensions: true` + `noEmit: true` is set in `tsconfig.json`; build with `bun build`, not `tsc`
|
||||
- `tsconfig.json` excludes `**/templates/**` globally — template files target user projects, not this repo
|
||||
- Zod 4: use `z.partialRecord(K, V)` for finite-key partial records (`z.record(z.enum, V)` requires exhaustive keys)
|
||||
- TanStack Start: `verbatimModuleSyntax: false` in `apps/web/tsconfig.json` (server bundles leak otherwise); `tanstackStart()` MUST precede `react()` in vite plugins
|
||||
- Bun runs the CLI directly from `.ts`; don't add a TS compile step
|
||||
@@ -51,7 +51,7 @@ In a **generated project**, `auth`, `db`, and `orm` modules install into their o
|
||||
## Architecture rules
|
||||
|
||||
- **Modules are vendored**: their templates land in the user's repo verbatim; no `@stanza/runtime` dep
|
||||
- **Template distribution**: `packages/internal`'s `registry-build.ts` inlines each template file's contents into the per-module JSON's `templates[].content` field so HTTP-loaded manifests are self-contained. The runner prefers `tpl.content` and only reads from `registry/modules/<x>/templates/` when it's absent (local dev). New templates need no build wiring — they're picked up automatically
|
||||
- **Template distribution**: `scripts/registry-build.ts` inlines each template file's contents into the per-module JSON's `templates[].content` field so HTTP-loaded manifests are self-contained. The runner prefers `tpl.content` and only reads from `registry/modules/<x>/templates/` when it's absent (local dev). New templates need no build wiring — they're picked up automatically
|
||||
- **Module logos**: drop `logo.svg` (theme-agnostic) or `logo-light.svg` + `logo-dark.svg` (theme pair) in a module's directory. The registry build auto-detects and inlines as `mod.logo` (string or `{ light, dark }`) — module authors don't declare anything in `module.ts`. First-party logos come from [svgl.app](https://svgl.app). The web builder renders inline via `dangerouslySetInnerHTML`
|
||||
- **Registry is data; CLI is the runtime**: the per-module JSON ships templates (text), deps (strings), env (strings), scripts (strings), logos (SVG markup), and codemod **invocations** (`{ id, args }`). It does NOT ship codemod _code_. The catalog of generic codemods lives in [packages/codemods/src/builtins/](packages/codemods/src/builtins/) and is exposed via the `@stanza/codemods/builtins` subpath export — each codemod is parameterized by `TArgs` and reusable across modules (`wrap-root-layout` serves both Clerk and any future provider-style auth/state library). The catalog is statically imported into the CLI binary at build time, so distribution shape (single binary, pnpm-isolated, npm-hoisted, `npx`, `bun --compile`) doesn't matter — implementations always travel with the runtime
|
||||
- **Adding a generic codemod**: drop `<id>.ts` under [packages/codemods/src/builtins/](packages/codemods/src/builtins/), default-export a `Codemod<TArgs>`, and register it in [packages/codemods/src/builtins/index.ts](packages/codemods/src/builtins/index.ts). Codemods that bake in module-specific identifiers don't belong — factor them into args
|
||||
@@ -60,7 +60,7 @@ In a **generated project**, `auth`, `db`, and `orm` modules install into their o
|
||||
- **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
|
||||
- **Generated projects don't share a tsconfig base**: every `apps/*/tsconfig.json` and `packages/*/tsconfig.json` is self-contained. The framework module ships the app's tsconfig; the runner's `ensureSlotPackage` writes a matching self-contained config when bootstrapping a slot package. `tsconfig.json` lives in the stanza repo only; do not emit it in generated trees
|
||||
- **Cross-package wiring**: when 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
|
||||
|
||||
@@ -19,7 +19,7 @@ The builder is functionally wired but visually unfinished. It's inline-styled ra
|
||||
- [ ] 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/internal`'s registry-build output to `apps/web/public/registry/` and serve statically, or via a Vercel route handler
|
||||
- [ ] Host the registry on the same domain — wire the registry-build output (`scripts/registry-build.ts` → `dist/registry/`) 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
|
||||
|
||||
@@ -30,10 +30,9 @@ 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. Also: the root `pnpm-workspace.yaml` must cover `packages/*` so the runner's bootstrapped slot packages link correctly
|
||||
- [ ] `stanza init`: today's `bootstrapShell` doesn't emit `turbo.json`. Decide whether stanza ships that or a tooling module does. 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
|
||||
|
||||
@@ -33,6 +33,7 @@
|
||||
"semver": "^7.8.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "^1.3.14",
|
||||
"@types/node": "^25.9.1",
|
||||
"@types/semver": "^7.7.1",
|
||||
"typescript": "^6.0.3",
|
||||
|
||||
@@ -6,9 +6,9 @@ import { resolveAdapter, type SlotId, KNOWN_SLOTS } from "@stanza/registry";
|
||||
import kleur from "kleur";
|
||||
import type { Argv } from "mri";
|
||||
|
||||
import { applyModule } from "../codemod-runner";
|
||||
import { findProjectRoot, readManifest } from "../manifest";
|
||||
import { loadRegistry } from "../registry-loader";
|
||||
import { applyModule } from "@/lib/codemod-runner";
|
||||
import { findProjectRoot, readManifest } from "@/lib/manifest";
|
||||
import { loadRegistry } from "@/lib/registry-loader";
|
||||
|
||||
export async function cmdAdd(args: {
|
||||
slot?: string;
|
||||
|
||||
@@ -6,10 +6,10 @@ import { resolveAdapter, slotOrder } from "@stanza/registry";
|
||||
import kleur from "kleur";
|
||||
import type { Argv } from "mri";
|
||||
|
||||
import { applyModule } from "../codemod-runner";
|
||||
import { initManifest } from "../manifest";
|
||||
import { loadRegistry } from "../registry-loader";
|
||||
import { runInitWizard } from "../wizard";
|
||||
import { applyModule } from "@/lib/codemod-runner";
|
||||
import { initManifest } from "@/lib/manifest";
|
||||
import { loadRegistry } from "@/lib/registry-loader";
|
||||
import { runInitWizard } from "@/lib/wizard";
|
||||
|
||||
export async function cmdInit(args: { name?: string; argv: Argv }): Promise<void> {
|
||||
const registry = await loadRegistry();
|
||||
|
||||
@@ -3,7 +3,7 @@ import { slotOrder, type SlotId } from "@stanza/registry";
|
||||
import kleur from "kleur";
|
||||
import type { Argv } from "mri";
|
||||
|
||||
import { findProjectRoot, readManifest } from "../manifest";
|
||||
import { findProjectRoot, readManifest } from "@/lib/manifest";
|
||||
|
||||
export async function cmdList(_args: { argv: Argv }): Promise<void> {
|
||||
const projectRoot = findProjectRoot();
|
||||
|
||||
@@ -7,8 +7,10 @@ import { KNOWN_SLOTS, SLOT_PACKAGE_DIR, type SlotId } from "@stanza/registry";
|
||||
import kleur from "kleur";
|
||||
import type { Argv } from "mri";
|
||||
|
||||
import { findProjectRoot, readManifest, writeManifest } from "../manifest";
|
||||
import { regionsOwnedBy } from "../region-tracker";
|
||||
import { revertCodemods } from "@/lib/codemod-runner";
|
||||
import { findProjectRoot, readManifest, writeManifest } from "@/lib/manifest";
|
||||
import { regionsOwnedBy } from "@/lib/region-tracker";
|
||||
import { loadRegistry } from "@/lib/registry-loader";
|
||||
|
||||
export async function cmdRemove(args: { slot?: string; argv: Argv }): Promise<void> {
|
||||
if (!args.slot) {
|
||||
@@ -30,21 +32,43 @@ export async function cmdRemove(args: { slot?: string; argv: Argv }): Promise<vo
|
||||
return;
|
||||
}
|
||||
|
||||
const manifest = readManifest(projectRoot);
|
||||
let 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[] = [];
|
||||
|
||||
// Step 1: revert imperative codemods first. They modify framework- or
|
||||
// peer-owned files (root layout, schema barrels, vite config) — reverts
|
||||
// need to see those files intact, before any template deletions below.
|
||||
const registry = await loadRegistry();
|
||||
const mod = await registry.loadModule(slot, installed.id).catch(() => null);
|
||||
const adapter = mod?.adapters.find((a) => a.key === installed.adapter);
|
||||
if (mod && adapter) {
|
||||
const revertResult = await revertCodemods({
|
||||
projectRoot,
|
||||
manifest,
|
||||
module: mod,
|
||||
adapter,
|
||||
dryRun,
|
||||
});
|
||||
manifest = revertResult.manifest;
|
||||
for (const id of revertResult.manualCleanup) {
|
||||
manualCleanup.push(`codemod:${id} (no revert or revert threw)`);
|
||||
}
|
||||
} else {
|
||||
p.log.warn(
|
||||
`Couldn't re-load ${installed.id}@${installed.adapter} from the registry — skipping imperative codemod reversal.`,
|
||||
);
|
||||
}
|
||||
|
||||
// Step 2: reverse the declarative side — files, deps, scripts, env vars —
|
||||
// driven by whatever region claims remain after the codemod reverts.
|
||||
const owned = regionsOwnedBy(manifest, installed.id);
|
||||
for (const { file, region } of owned) {
|
||||
const abs = path.join(projectRoot, file);
|
||||
if (file === ".env.example") {
|
||||
@@ -67,14 +91,19 @@ export async function cmdRemove(args: { slot?: string; argv: Argv }): Promise<vo
|
||||
}
|
||||
}
|
||||
if (region === "file") {
|
||||
// Whole-file template — safe to delete since stanza wrote it.
|
||||
if (!dryRun && fs.existsSync(abs)) fs.unlinkSync(abs);
|
||||
continue;
|
||||
}
|
||||
// Anything still here is a codemod-claimed region whose revert wasn't
|
||||
// dispatched (no revert defined, or it threw). Surface it for manual
|
||||
// cleanup — but don't drop the claim, since the artifact is still in
|
||||
// the file and an operator may still want to find it.
|
||||
manualCleanup.push(`${file}:${region}`);
|
||||
}
|
||||
|
||||
// Update manifest: drop module + its regions.
|
||||
// Strip the module record + its remaining region claims (the codemod
|
||||
// reverts already released their own; this picks up the declarative ones
|
||||
// we just processed above).
|
||||
const nextRegions = { ...manifest.regions };
|
||||
for (const { file, region } of owned) {
|
||||
if (nextRegions[file]) {
|
||||
@@ -86,19 +115,20 @@ export async function cmdRemove(args: { slot?: string; argv: Argv }): Promise<vo
|
||||
}
|
||||
const nextModules = { ...manifest.modules };
|
||||
delete nextModules[slot];
|
||||
manifest = { ...manifest, modules: nextModules, regions: nextRegions };
|
||||
|
||||
// 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.
|
||||
// Step 3: sweep any internal package whose claims have all been released.
|
||||
// The bootstrap files (package.json, tsconfig.json, the workspace dep on
|
||||
// the app) are system-owned — not tracked in regions, so they'd otherwise
|
||||
// linger forever.
|
||||
const sweptPackages: string[] = [];
|
||||
const appPkgRel = `${manifest.appDir}/package.json`;
|
||||
const appPkgAbs = path.join(projectRoot, appPkgRel);
|
||||
const appPkgAbs = path.join(projectRoot, manifest.appDir, "package.json");
|
||||
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}/`));
|
||||
const stillUsed = Object.keys(manifest.regions).some((file) =>
|
||||
file.startsWith(`packages/${dir}/`),
|
||||
);
|
||||
if (stillUsed) continue;
|
||||
const pkgRoot = path.join(projectRoot, "packages", dir);
|
||||
if (!fs.existsSync(pkgRoot)) continue;
|
||||
@@ -111,9 +141,7 @@ export async function cmdRemove(args: { slot?: string; argv: Argv }): Promise<vo
|
||||
sweptPackages.push(dir);
|
||||
}
|
||||
|
||||
if (!dryRun) {
|
||||
writeManifest(projectRoot, { ...manifest, modules: nextModules, regions: nextRegions });
|
||||
}
|
||||
if (!dryRun) writeManifest(projectRoot, manifest);
|
||||
|
||||
p.log.success(`${kleur.green("✓")} Removed ${installed.id} from ${slot}`);
|
||||
if (sweptPackages.length > 0) {
|
||||
@@ -121,7 +149,7 @@ export async function cmdRemove(args: { slot?: string; argv: Argv }): Promise<vo
|
||||
}
|
||||
if (manualCleanup.length > 0) {
|
||||
p.log.warn(
|
||||
`${manualCleanup.length} region(s) need manual cleanup:\n` +
|
||||
`${manualCleanup.length} item(s) need manual cleanup:\n` +
|
||||
manualCleanup.map((r) => ` • ${r}`).join("\n"),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import kleur from "kleur";
|
||||
import type { Argv } from "mri";
|
||||
|
||||
import { loadRegistry } from "../registry-loader";
|
||||
import { loadRegistry } from "@/lib/registry-loader";
|
||||
|
||||
export async function cmdSearch(args: { query?: string; argv: Argv }): Promise<void> {
|
||||
const registry = await loadRegistry();
|
||||
|
||||
@@ -21,7 +21,7 @@ import type {
|
||||
import { SLOT_PACKAGE_DIR } from "@stanza/registry";
|
||||
|
||||
import { writeManifest } from "./manifest";
|
||||
import { claim, RegionConflictError } from "./region-tracker";
|
||||
import { claim, release, RegionConflictError } from "./region-tracker";
|
||||
|
||||
export type RunResult = {
|
||||
manifest: StanzaManifest;
|
||||
@@ -86,20 +86,7 @@ export async function applyModule(args: {
|
||||
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}`;
|
||||
}
|
||||
const renderContext = buildRenderContext(manifest, packageName);
|
||||
|
||||
// 1. Templates (claim regions per-template-file).
|
||||
for (const tpl of adapter.templates ?? []) {
|
||||
@@ -413,7 +400,8 @@ function buildContext(args: {
|
||||
project: () => Project;
|
||||
touchedFiles: Set<string>;
|
||||
dryRun: boolean;
|
||||
onClaim: (file: string, region: string) => void;
|
||||
onClaim?: (file: string, region: string) => void;
|
||||
onRelease?: (file: string, region: string) => void;
|
||||
}): CodemodContext {
|
||||
return {
|
||||
projectRoot: args.projectRoot,
|
||||
@@ -423,12 +411,118 @@ function buildContext(args: {
|
||||
owner: { slot: args.module.slot, module: args.module.id },
|
||||
adapter: args.adapter.key,
|
||||
claimRegion(file, region) {
|
||||
args.onClaim(file, region);
|
||||
args.onClaim?.(file, region);
|
||||
},
|
||||
releaseRegion() {
|
||||
// No-op during `add`. The remove path uses regionsOwnedBy() to reverse.
|
||||
releaseRegion(file, region) {
|
||||
args.onRelease?.(file, region);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the render context used by both template-body substitution and
|
||||
* codemod-args substitution. Provides:
|
||||
* - `{{appDir}}`, `{{projectName}}` — project-level
|
||||
* - `{{packageName}}` — the active module's own package (empty for slots
|
||||
* without a package mapping)
|
||||
* - `{{<dir>PackageName}}` (e.g. `{{dbPackageName}}`) — shorthand for every
|
||||
* slot package, so cross-package imports stay declarative
|
||||
*/
|
||||
function buildRenderContext(manifest: StanzaManifest, packageName: string): Record<string, string> {
|
||||
const ctx: 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),
|
||||
)) {
|
||||
ctx[`${dir}PackageName`] = `@${manifest.name}/${dir}`;
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
|
||||
export type RevertResult = {
|
||||
manifest: StanzaManifest;
|
||||
touchedFiles: string[];
|
||||
dryRun: boolean;
|
||||
/**
|
||||
* Codemods that couldn't be reverted automatically — either no `revert()`
|
||||
* is defined or one threw. Each entry is the codemod id; the caller
|
||||
* surfaces this as "needs manual cleanup".
|
||||
*/
|
||||
manualCleanup: string[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Replay an installed module's imperative codemods in reverse via their
|
||||
* `revert()` functions. Each revert releases the regions it claimed, which
|
||||
* we propagate into the manifest. Declarative reversals (files, deps, env)
|
||||
* stay in `commands/remove.ts` — they're region-driven and don't need the
|
||||
* module to be re-loaded.
|
||||
*
|
||||
* Args are reconstructed by running the original `args` template strings
|
||||
* through the same `renderTemplate` substitution used on apply, so reverts
|
||||
* see the same concrete values (e.g. `providerImport: "@my-app/auth"`).
|
||||
*/
|
||||
export async function revertCodemods(args: {
|
||||
projectRoot: string;
|
||||
manifest: StanzaManifest;
|
||||
module: Module;
|
||||
adapter: ModuleAdapter;
|
||||
dryRun: boolean;
|
||||
}): Promise<RevertResult> {
|
||||
const { projectRoot, module, adapter, dryRun } = args;
|
||||
let manifest = args.manifest;
|
||||
const touchedFiles = new Set<string>();
|
||||
const manualCleanup: string[] = [];
|
||||
const appRoot = path.join(projectRoot, manifest.appDir);
|
||||
|
||||
const codemods = adapter.codemods ?? [];
|
||||
if (codemods.length === 0) return { manifest, touchedFiles: [], dryRun, manualCleanup };
|
||||
|
||||
const packageDir = SLOT_PACKAGE_DIR[module.slot];
|
||||
const packageName = packageDir ? `@${manifest.name}/${packageDir}` : "";
|
||||
const renderContext = buildRenderContext(manifest, packageName);
|
||||
|
||||
const project = lazyProject(appRoot);
|
||||
const ctx = buildContext({
|
||||
projectRoot,
|
||||
appRoot,
|
||||
manifest,
|
||||
module,
|
||||
adapter,
|
||||
project,
|
||||
touchedFiles,
|
||||
dryRun,
|
||||
onRelease: (file, region) => {
|
||||
manifest = release(manifest, file, region);
|
||||
},
|
||||
});
|
||||
|
||||
// Reverse order: if codemod B layered on top of A's output, undo B first.
|
||||
for (const invocation of codemods.toReversed()) {
|
||||
const fn = CODEMOD_CATALOG[invocation.id];
|
||||
if (!fn || !fn.revert) {
|
||||
manualCleanup.push(invocation.id);
|
||||
continue;
|
||||
}
|
||||
if (dryRun) continue;
|
||||
try {
|
||||
const renderedArgs = renderArgs(invocation.args ?? {}, renderContext);
|
||||
const result = await fn.revert(ctx, renderedArgs);
|
||||
result.touchedFiles.forEach((f) => touchedFiles.add(f));
|
||||
} catch {
|
||||
// Don't surface the exception body — the caller already prints a
|
||||
// "manual cleanup" warning with the codemod id; the user can re-run
|
||||
// with stack traces if they need details. Keep going so other codemods
|
||||
// still get a chance to revert.
|
||||
manualCleanup.push(invocation.id);
|
||||
}
|
||||
}
|
||||
if (!dryRun) await project.save?.();
|
||||
|
||||
return { manifest, touchedFiles: [...touchedFiles], dryRun, manualCleanup };
|
||||
}
|
||||
|
||||
export { RegionConflictError };
|
||||
@@ -1,7 +1,10 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"types": ["node"]
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
},
|
||||
"types": ["node", "bun"]
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"jsx": "preserve",
|
||||
"jsxImportSource": "react",
|
||||
|
||||
+6
-2
@@ -14,11 +14,15 @@
|
||||
"lint:fix": "oxlint --fix",
|
||||
"fmt": "oxfmt",
|
||||
"fmt:check": "oxfmt --check",
|
||||
"registry:build": "turbo run registry:build --filter=@stanza/internal",
|
||||
"module:new": "pnpm --filter=@stanza/internal run module:new"
|
||||
"registry:build": "bun scripts/registry-build.ts",
|
||||
"module:new": "bun scripts/module-new.ts"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@clack/prompts": "^1.4.0",
|
||||
"@stanza/registry": "workspace:*",
|
||||
"@types/node": "^25.9.1",
|
||||
"kleur": "^4.1.5",
|
||||
"mri": "^1.2.0",
|
||||
"oxfmt": "^0.51.0",
|
||||
"oxlint": "^1.66.0",
|
||||
"turbo": "^2.9.14",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
#!/usr/bin/env bun
|
||||
import { run } from "@stanza/cli";
|
||||
import mri from "mri";
|
||||
|
||||
/**
|
||||
* `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.
|
||||
@@ -7,8 +9,6 @@ import { run } from "@stanza/cli";
|
||||
* 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";
|
||||
|
||||
const argv = mri(process.argv.slice(2), {
|
||||
alias: { h: "help", v: "version" },
|
||||
boolean: ["help", "version", "yes", "dry-run", "no-telemetry"],
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"types": ["node"]
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
{
|
||||
"name": "@stanza/internal",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"description": "Internal maintenance scripts for the stanza monorepo.",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"registry:build": "bun run ./src/registry-build.ts",
|
||||
"module:new": "bun run ./src/module-new.ts",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.4.0",
|
||||
"@stanza/registry": "workspace:*",
|
||||
"kleur": "^4.1.5",
|
||||
"mri": "^1.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "^1.3.14",
|
||||
"@types/node": "^25.9.1",
|
||||
"typescript": "^6.0.3"
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"types": ["node", "bun"]
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src"
|
||||
|
||||
Generated
+15
-25
@@ -8,9 +8,21 @@ importers:
|
||||
|
||||
.:
|
||||
devDependencies:
|
||||
'@clack/prompts':
|
||||
specifier: ^1.4.0
|
||||
version: 1.4.0
|
||||
'@stanza/registry':
|
||||
specifier: workspace:*
|
||||
version: link:packages/registry
|
||||
'@types/node':
|
||||
specifier: ^25.9.1
|
||||
version: 25.9.1
|
||||
kleur:
|
||||
specifier: ^4.1.5
|
||||
version: 4.1.5
|
||||
mri:
|
||||
specifier: ^1.2.0
|
||||
version: 1.2.0
|
||||
oxfmt:
|
||||
specifier: ^0.51.0
|
||||
version: 0.51.0
|
||||
@@ -48,6 +60,9 @@ importers:
|
||||
specifier: ^7.8.0
|
||||
version: 7.8.0
|
||||
devDependencies:
|
||||
'@types/bun':
|
||||
specifier: ^1.3.14
|
||||
version: 1.3.14
|
||||
'@types/node':
|
||||
specifier: ^25.9.1
|
||||
version: 25.9.1
|
||||
@@ -208,31 +223,6 @@ importers:
|
||||
specifier: ^6.0.3
|
||||
version: 6.0.3
|
||||
|
||||
packages/internal:
|
||||
dependencies:
|
||||
'@clack/prompts':
|
||||
specifier: ^1.4.0
|
||||
version: 1.4.0
|
||||
'@stanza/registry':
|
||||
specifier: workspace:*
|
||||
version: link:../registry
|
||||
kleur:
|
||||
specifier: ^4.1.5
|
||||
version: 4.1.5
|
||||
mri:
|
||||
specifier: ^1.2.0
|
||||
version: 1.2.0
|
||||
devDependencies:
|
||||
'@types/bun':
|
||||
specifier: ^1.3.14
|
||||
version: 1.3.14
|
||||
'@types/node':
|
||||
specifier: ^25.9.1
|
||||
version: 25.9.1
|
||||
typescript:
|
||||
specifier: ^6.0.3
|
||||
version: 6.0.3
|
||||
|
||||
packages/registry:
|
||||
dependencies:
|
||||
zod:
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"extends": "../../../tsconfig.json",
|
||||
"include": ["module.ts"],
|
||||
"exclude": ["templates"]
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"extends": "../../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"types": ["node"]
|
||||
},
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"extends": "../../../tsconfig.json",
|
||||
"include": ["module.ts"],
|
||||
"exclude": ["templates"]
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"extends": "../../../tsconfig.json",
|
||||
"include": ["module.ts"],
|
||||
"exclude": ["templates"]
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"extends": "../../../tsconfig.json",
|
||||
"include": ["module.ts"],
|
||||
"exclude": ["templates"]
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"extends": "../../../tsconfig.json",
|
||||
"include": ["module.ts"],
|
||||
"exclude": ["templates"]
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"extends": "../../../tsconfig.json",
|
||||
"include": ["module.ts"],
|
||||
"exclude": ["templates"]
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"extends": "../../../tsconfig.json",
|
||||
"include": ["module.ts"],
|
||||
"exclude": ["templates"]
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"extends": "../../../tsconfig.json",
|
||||
"include": ["module.ts"],
|
||||
"exclude": ["templates"]
|
||||
}
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
* Scaffold a new first-party module under registry/modules/<slot>-<id>/.
|
||||
*
|
||||
* Usage:
|
||||
* bun module:new — fully interactive
|
||||
* bun module:new <slot> <id> — positional, prompts for the rest
|
||||
* bun module:new <slot> <id> [flags] — non-interactive
|
||||
* bun scripts/module-new.ts — fully interactive
|
||||
* bun scripts/module-new.ts <slot> <id> — positional, prompts for the rest
|
||||
* bun scripts/module-new.ts <slot> <id> [flags] — non-interactive
|
||||
*
|
||||
* Flags:
|
||||
* --label "Display Name" Default: title-cased id
|
||||
@@ -195,7 +195,7 @@ function packageJson(a: Args): string {
|
||||
|
||||
function tsconfig(withCodemod: boolean): string {
|
||||
const cfg = {
|
||||
extends: "../../../tsconfig.base.json",
|
||||
extends: "../../../tsconfig.json",
|
||||
...(withCodemod ? { compilerOptions: { types: ["node"] } } : {}),
|
||||
include: withCodemod ? ["module.ts", "codemods/**/*.ts"] : ["module.ts"],
|
||||
exclude: ["templates"],
|
||||
@@ -286,7 +286,7 @@ function printHelp() {
|
||||
${kleur.bold("module:new")} — scaffold a new first-party stanza module
|
||||
|
||||
${kleur.bold("Usage")}
|
||||
bun module:new [slot] [id] [flags]
|
||||
bun scripts/module-new.ts [slot] [id] [flags]
|
||||
|
||||
${kleur.bold("Flags")}
|
||||
--label "Display Name" Default: title-cased id
|
||||
@@ -297,9 +297,9 @@ ${kleur.bold("Flags")}
|
||||
-h, --help Show this help
|
||||
|
||||
${kleur.bold("Examples")}
|
||||
bun module:new # fully interactive
|
||||
bun module:new email resend # prompt only for label/description
|
||||
bun module:new ui shadcn-radix --yes # no prompts
|
||||
bun module:new auth workos --with-codemod # include codemods/index.ts stub
|
||||
bun scripts/module-new.ts # fully interactive
|
||||
bun scripts/module-new.ts email resend # prompt only for label/description
|
||||
bun scripts/module-new.ts ui shadcn-radix --yes # no prompts
|
||||
bun scripts/module-new.ts auth workos --with-codemod # include codemods/index.ts stub
|
||||
`);
|
||||
}
|
||||
@@ -20,10 +20,6 @@
|
||||
"typecheck": {
|
||||
"dependsOn": ["^build"],
|
||||
"outputs": []
|
||||
},
|
||||
"registry:build": {
|
||||
"dependsOn": ["^build"],
|
||||
"outputs": ["dist/registry/**"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user