diff --git a/CLAUDE.md b/CLAUDE.md index 26f0efc..8205bd5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -21,13 +21,14 @@ 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/registry-build/` — Bun script that emits the static CDN JSON +- `packages/internal/` — Internal maintenance scripts: `registry-build.ts` (emits static CDN JSON), `module-new.ts` (scaffolds a new module) - `registry/modules/-/` — first-party modules: `module.ts` + `templates/` (+ optional `codemods/`) ## Commands - `bun apps/cli/src/bin.ts ` — run CLI directly without build -- `bun packages/registry-build/src/build.ts` — regenerate `dist/registry/{index,modules/*}.json` +- `pnpm registry:build` (or `bun packages/internal/src/registry-build.ts`) — regenerate `dist/registry/{index,modules/*}.json` +- `pnpm module:new [slot] [id]` — scaffold a new module under `registry/modules/` - `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/ && node_modules/.bin/vitest run` — unit tests (per workspace; no root `vitest` binary) diff --git a/TODO.md b/TODO.md index e121e70..0840d94 100644 --- a/TODO.md +++ b/TODO.md @@ -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/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 `packages/internal`'s 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 diff --git a/package.json b/package.json index 3627bdb..5b27945 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,8 @@ "lint:fix": "oxlint --fix", "fmt": "oxfmt", "fmt:check": "oxfmt --check", - "registry:build": "turbo run registry:build --filter=@stanza/registry-build" + "registry:build": "turbo run registry:build --filter=@stanza/internal", + "module:new": "pnpm --filter=@stanza/internal run module:new" }, "devDependencies": { "@types/node": "^25.9.1", diff --git a/packages/internal/package.json b/packages/internal/package.json new file mode 100644 index 0000000..0b1a51e --- /dev/null +++ b/packages/internal/package.json @@ -0,0 +1,24 @@ +{ + "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" + } +} diff --git a/packages/internal/src/module-new.ts b/packages/internal/src/module-new.ts new file mode 100644 index 0000000..0effd4c --- /dev/null +++ b/packages/internal/src/module-new.ts @@ -0,0 +1,303 @@ +#!/usr/bin/env bun +/** + * Scaffold a new first-party module under registry/modules/-/. + * + * Usage: + * bun module:new — fully interactive + * bun module:new — positional, prompts for the rest + * bun module:new [flags] — non-interactive + * + * Flags: + * --label "Display Name" Default: title-cased id + * --description "..." Default: empty + * --homepage Optional + * --with-codemod Scaffold an empty codemods/ directory + * --yes Skip all prompts (use defaults for everything) + */ +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import * as p from "@clack/prompts"; +import kleur from "kleur"; +import mri from "mri"; +import { KNOWN_SLOTS, type SlotId } from "@stanza/registry"; + +type Args = { + slot?: SlotId; + id?: string; + label?: string; + description?: string; + homepage?: string; + withCodemod: boolean; +}; + +await main(); + +async function main() { + const argv = mri(process.argv.slice(2), { + boolean: ["with-codemod", "yes", "help"], + alias: { h: "help" }, + }); + + if (argv.help) { + printHelp(); + return; + } + + const args = await gatherArgs(argv); + if (!args) return; + + const repoRoot = findRepoRoot(); + const moduleDir = path.join(repoRoot, "registry", "modules", `${args.slot}-${args.id}`); + + if (fs.existsSync(moduleDir)) { + p.log.error(`Module already exists: ${path.relative(repoRoot, moduleDir)}`); + process.exitCode = 1; + return; + } + + writeModule({ moduleDir, repoRoot, args }); + + p.outro( + [ + kleur.green("✓ Module scaffolded:"), + ` ${kleur.dim(path.relative(repoRoot, moduleDir))}`, + "", + "Next steps:", + ` ${kleur.dim("1.")} Edit ${kleur.cyan(`registry/modules/${args.slot}-${args.id}/module.ts`)}`, + ` ${kleur.dim("2.")} Add templates under ${kleur.cyan("templates/")}`, + ` ${kleur.dim("3.")} ${kleur.cyan("pnpm install --force")} to link the workspace`, + ` ${kleur.dim("4.")} ${kleur.cyan("pnpm registry:build")} to verify the manifest`, + ].join("\n"), + ); +} + +async function gatherArgs(argv: mri.Argv): Promise { + const positional = argv._; + const yes = Boolean(argv.yes); + + let slot = (positional[0] as SlotId | undefined) ?? (argv.slot as SlotId | undefined); + let id = (positional[1] as string | undefined) ?? (argv.id as string | undefined); + + if (!slot || !id) p.intro(kleur.bold().cyan("new stanza module")); + + if (!slot) { + const choice = await p.select({ + message: "Slot?", + options: KNOWN_SLOTS.map((s) => ({ value: s, label: s })), + }); + if (p.isCancel(choice)) return cancel(); + slot = choice; + } else if (!KNOWN_SLOTS.includes(slot)) { + p.log.error(`Unknown slot: ${slot}. Known: ${KNOWN_SLOTS.join(", ")}`); + process.exitCode = 1; + return null; + } + + if (!id) { + const v = await p.text({ + message: "Module id?", + placeholder: "e.g. resend, polar, vitest", + validate: (s) => + s && /^[a-z][a-z0-9-]*$/.test(s) + ? undefined + : "Lowercase letters, digits, dashes; must start with a letter.", + }); + if (p.isCancel(v)) return cancel(); + id = String(v); + } else if (!/^[a-z][a-z0-9-]*$/.test(id)) { + p.log.error(`Invalid id: ${id}. Lowercase letters, digits, dashes only.`); + process.exitCode = 1; + return null; + } + + const label = (argv.label as string | undefined) ?? (yes ? titleCase(id) : await askLabel(id)); + if (label === null) return cancel(); + + const description = + (argv.description as string | undefined) ?? (yes ? "" : ((await askDescription()) ?? "")); + if (description === null) return cancel(); + + const homepage = argv.homepage as string | undefined; + + const withCodemod = Boolean(argv["with-codemod"]) || (!yes ? await askWithCodemod() : false); + if (withCodemod === null) return cancel(); + + return { slot, id, label, description, homepage, withCodemod }; +} + +async function askLabel(id: string): Promise { + const v = await p.text({ + message: "Display label?", + initialValue: titleCase(id), + }); + if (p.isCancel(v)) return null; + return String(v); +} + +async function askDescription(): Promise { + const v = await p.text({ + message: "Description (optional)?", + placeholder: "One-line summary shown in `stanza search`", + }); + if (p.isCancel(v)) return null; + return String(v ?? ""); +} + +async function askWithCodemod(): Promise { + const v = await p.confirm({ + message: "Scaffold a codemods/ directory? (most modules don't need this)", + initialValue: false, + }); + if (p.isCancel(v)) return false; + return v; +} + +function writeModule(args: { moduleDir: string; repoRoot: string; args: Args }) { + const { moduleDir, args: a } = args; + fs.mkdirSync(moduleDir, { recursive: true }); + fs.mkdirSync(path.join(moduleDir, "templates"), { recursive: true }); + + fs.writeFileSync(path.join(moduleDir, "package.json"), packageJson(a)); + fs.writeFileSync(path.join(moduleDir, "tsconfig.json"), tsconfig(a.withCodemod)); + fs.writeFileSync(path.join(moduleDir, "module.ts"), moduleTs(a)); + + // Placeholder so the templates/ dir survives a fresh git clone. + fs.writeFileSync( + path.join(moduleDir, "templates", ".gitkeep"), + "# Templates go here. Reference them from module.ts via `templates: [{ src, dest, scope }]`.\n", + ); + + if (a.withCodemod) { + fs.mkdirSync(path.join(moduleDir, "codemods"), { recursive: true }); + fs.writeFileSync(path.join(moduleDir, "codemods", "index.ts"), codemodTs(a)); + } +} + +function packageJson(a: Args): string { + const pkg = { + name: `@stanza-modules/${a.slot}-${a.id}`, + version: "0.1.0", + private: true, + license: "MIT", + type: "module", + main: "./module.ts", + dependencies: { + "@stanza/codemods": "workspace:*", + "@stanza/registry": "workspace:*", + }, + ...(a.withCodemod ? { devDependencies: { "@types/node": "^25.9.1" } } : {}), + }; + return JSON.stringify(pkg, null, 2) + "\n"; +} + +function tsconfig(withCodemod: boolean): string { + const cfg = { + extends: "../../../tsconfig.base.json", + ...(withCodemod ? { compilerOptions: { types: ["node"] } } : {}), + include: withCodemod ? ["module.ts", "codemods/**/*.ts"] : ["module.ts"], + exclude: ["templates"], + }; + return JSON.stringify(cfg, null, 2) + "\n"; +} + +function moduleTs(a: Args): string { + const lines = [ + `import { defineModule } from "@stanza/registry";`, + ``, + `export default defineModule({`, + ` id: ${JSON.stringify(a.id)},`, + ` slot: ${JSON.stringify(a.slot)},`, + ` label: ${JSON.stringify(a.label)},`, + ` description: ${JSON.stringify(a.description)},`, + ` version: "0.1.0",`, + ...(a.homepage ? [` homepage: ${JSON.stringify(a.homepage)},`] : []), + ` // peers: { framework: ["next", "tanstack-start"] },`, + ` adapters: [`, + ` {`, + ` // "default" matches any stack. Add framework/orm/db keys to \`match\``, + ` // when this adapter only applies to a specific peer combination.`, + ` key: "default",`, + ` match: {},`, + ` // dependencies: { "some-pkg": "^1.0.0" },`, + ` // devDependencies: { "some-dev-pkg": "^1.0.0" },`, + ` // env: [{ name: "FOO", example: "bar", required: true }],`, + ` // scripts: { "some:script": "command" },`, + ` // templates: [{ src: "config.ts", dest: "config.ts", scope: "app" }],`, + ...(a.withCodemod ? [` codemods: [/* "my-codemod" */],`] : []), + ` },`, + ` ],`, + `});`, + ``, + ]; + return lines.join("\n"); +} + +function codemodTs(a: Args): string { + const codemodId = `${a.id}-setup`; + return [ + `import type { Codemod } from "@stanza/codemods";`, + ``, + `const setup: Codemod = {`, + ` id: ${JSON.stringify(codemodId)},`, + ` description: "Imperative setup for ${a.label}.",`, + ` apply(ctx) {`, + ` // Open files via \`ctx.project().addSourceFileAtPath(...)\`,`, + ` // claim regions via \`ctx.claimRegion(file, region)\`,`, + ` // return the relative paths you touched.`, + ` return { touchedFiles: [] };`, + ` },`, + ` // Optional inverse for \`stanza remove\`.`, + ` // revert(ctx) { return { touchedFiles: [] }; },`, + `};`, + ``, + `export default {`, + ` ${JSON.stringify(codemodId)}: setup,`, + `};`, + ``, + ].join("\n"); +} + +function cancel(): null { + p.cancel("Cancelled."); + return null; +} + +function titleCase(s: string): string { + return s + .split(/[-_]/) + .map((w) => (w[0]?.toUpperCase() ?? "") + w.slice(1)) + .join(" "); +} + +function findRepoRoot(): string { + let dir = path.dirname(fileURLToPath(import.meta.url)); + 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."); +} + +function printHelp() { + console.log(` +${kleur.bold("module:new")} — scaffold a new first-party stanza module + +${kleur.bold("Usage")} + bun module:new [slot] [id] [flags] + +${kleur.bold("Flags")} + --label "Display Name" Default: title-cased id + --description "..." Default: empty + --homepage Optional + --with-codemod Scaffold an empty codemods/ directory + --yes Skip all prompts (use defaults) + -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 +`); +} diff --git a/packages/registry-build/src/build.ts b/packages/internal/src/registry-build.ts similarity index 100% rename from packages/registry-build/src/build.ts rename to packages/internal/src/registry-build.ts diff --git a/packages/registry-build/tsconfig.json b/packages/internal/tsconfig.json similarity index 100% rename from packages/registry-build/tsconfig.json rename to packages/internal/tsconfig.json diff --git a/packages/registry-build/package.json b/packages/registry-build/package.json deleted file mode 100644 index 3e16eb5..0000000 --- a/packages/registry-build/package.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "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" - } -} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e47c88f..f7a1724 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -130,6 +130,31 @@ 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: @@ -143,22 +168,6 @@ importers: specifier: ^4.1.7 version: 4.1.7(@types/node@25.9.1)(vite@8.0.13(@types/node@25.9.1)(jiti@2.7.0)) - packages/registry-build: - dependencies: - '@stanza/registry': - specifier: workspace:* - version: link:../registry - 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 - registry/modules/auth-better-auth: dependencies: '@stanza/codemods':