/** * `toolctrl create module ` — generate a new feature-module skeleton * under `apps/web/app/modules//`. * * The generator emits the minimum set of files needed for a tenant-scoped * CRUD module: a db-layer file, a service-layer file, a React-Router-v7 * tenant route, a List/Form component pair, a tiny utils file, and a vitest * stub. Files are written to NEW paths only — re-running against an * existing module name throws. * * Auto-wiring (sidebar entry, `routes.ts` patch, `seedPermissions`) is left * for future iterations to keep the generator dependency-free; the README * file produced by the generator lists the manual follow-up steps so the * caller has a single checklist. * * Refs: docs/improvement-plan/08-mobile-cli-storybook-sdk/cli-scaffolding.md */ import { defineCommand } from "citty"; import consola from "consola"; import path from "node:path"; import { camelCase, createScaffoldResult, kebabCase, pascalCase, resolveRepoRoot, validateIdentifier, writeScaffoldFile, type ScaffoldResult, } from "../_lib/scaffold.js"; export type GenerateModuleOptions = { /** Raw user-provided name (e.g. "inventory" or "Help Desk"). */ name: string; /** Repo root the module is written under. Defaults to `process.cwd()`. */ repoRoot?: string; /** When `true` no files are written; the result.files array still records every would-be write. */ dryRun?: boolean; /** Permission key prefix. Defaults to the kebab-cased name. */ permissionPrefix?: string; }; export async function generateModule(options: GenerateModuleOptions): Promise { const baseName = validateIdentifier(options.name, "module name"); const dryRun = options.dryRun ?? false; const repoRoot = resolveRepoRoot(options.repoRoot); const camel = camelCase(baseName); const pascal = pascalCase(baseName); const kebab = kebabCase(baseName); const permissionPrefix = options.permissionPrefix?.trim() || kebab; const moduleDir = path.join(repoRoot, "apps/web/app/modules", camel); const result = createScaffoldResult(dryRun); await writeScaffoldFile({ targetPath: path.join(moduleDir, "db", `${camel}.db.server.ts`), contents: renderDbFile({ camel, pascal }), dryRun, result, }); await writeScaffoldFile({ targetPath: path.join(moduleDir, "services", `${pascal}Service.ts`), contents: renderServiceFile({ camel, pascal }), dryRun, result, }); await writeScaffoldFile({ targetPath: path.join(moduleDir, "components", `${pascal}List.tsx`), contents: renderListComponent({ camel, pascal }), dryRun, result, }); await writeScaffoldFile({ targetPath: path.join(repoRoot, "apps/web/app/routes", `app.$tenant.${camel}.tsx`), contents: renderRouteFile({ camel, pascal, permissionPrefix }), dryRun, result, }); await writeScaffoldFile({ targetPath: path.join(moduleDir, `${camel}.db.server.test.ts`), contents: renderDbTestFile({ camel, pascal }), dryRun, result, }); await writeScaffoldFile({ targetPath: path.join(moduleDir, "README.md"), contents: renderReadme({ pascal, kebab, camel, permissionPrefix }), dryRun, result, }); result.notes.push( `Next steps:`, ` 1. Add permission keys "${permissionPrefix}.view|create|update|delete" to packages/db/src/permissions-seed.server.ts`, ` 2. Register module in apps/web/app/sidebar (manual — sidebar layout differs per app)`, ` 3. Run \`pnpm typecheck\` to generate route types for app.$tenant.${camel}.tsx`, ); return result; } function renderDbFile({ camel, pascal }: { camel: string; pascal: string }): string { return `/** * Tenant-scoped CRUD helpers for the ${pascal} module. * Generated by toolctrl-cli — fill in the model field references after * adding the Prisma model and running \`prisma migrate dev\`. */ import { db } from "~/utils/db.server"; export type ${pascal}WhereScope = { tenantId: string; }; export async function list${pascal}(scope: ${pascal}WhereScope) { // TODO: replace \`/* model */\` with the actual prisma model name. return db.$queryRaw\`SELECT 1 WHERE \${scope.tenantId} = \${scope.tenantId}\`; } export async function get${pascal}ById(scope: ${pascal}WhereScope, id: string) { void id; void scope; // TODO: \`return db.${camel}.findFirst({ where: { id, tenantId: scope.tenantId } });\` return null; } `; } function renderServiceFile({ camel, pascal }: { camel: string; pascal: string }): string { return `/** * Service-layer for the ${pascal} module — wraps the db helpers with * tenant-access guards so route handlers don't have to repeat the * permission boilerplate. */ import { list${pascal}, get${pascal}ById } from "../db/${camel}.db.server"; export type ${pascal}ServiceContext = { tenantId: string; userId: string; }; export async function getAll${pascal}(ctx: ${pascal}ServiceContext) { // TODO: call assertTenantHasAccess(ctx) when the helper is available. return list${pascal}({ tenantId: ctx.tenantId }); } export async function get${pascal}(ctx: ${pascal}ServiceContext, id: string) { return get${pascal}ById({ tenantId: ctx.tenantId }, id); } `; } function renderListComponent({ pascal }: { camel: string; pascal: string }): string { return `import type { ReactNode } from "react"; export type ${pascal}ListProps = { items: ReadonlyArray<{ id: string; name: string }>; empty?: ReactNode; }; export function ${pascal}List({ items, empty }: ${pascal}ListProps): ReactNode { if (items.length === 0) { return empty ??

No ${pascal} yet.

; } return (
    {items.map((item) => (
  • {item.name}
  • ))}
); } `; } function renderRouteFile({ camel, pascal, permissionPrefix, }: { camel: string; pascal: string; permissionPrefix: string; }): string { return `import type { Route } from "./+types/app.$tenant.${camel}"; import { getAll${pascal} } from "~/modules/${camel}/services/${pascal}Service"; import { ${pascal}List } from "~/modules/${camel}/components/${pascal}List"; /** * Tenant route for the ${pascal} module. * Generated by toolctrl-cli. Wire in \`requireAuth\` and the * "${permissionPrefix}.view" permission guard once you connect the module * to your auth helpers. */ export async function loader({ params }: Route.LoaderArgs) { // TODO: call requireAuth(request) + assertPermission("${permissionPrefix}.view") const tenantId = params.tenant ?? ""; const items = await getAll${pascal}({ tenantId, userId: "" }); return { items: (items as Array<{ id: string; name: string }>) ?? [] }; } export default function ${pascal}Route({ loaderData }: Route.ComponentProps) { return (

${pascal}

<${pascal}List items={loaderData.items} />
); } `; } function renderDbTestFile({ camel, pascal }: { camel: string; pascal: string }): string { return `import { describe, expect, it } from "vitest"; import { list${pascal} } from "./db/${camel}.db.server"; describe("${pascal} db layer", () => { it("returns a list (smoke test — implement once prisma model exists)", async () => { const result = await list${pascal}({ tenantId: "test-tenant" }); expect(Array.isArray(result)).toBe(true); }); }); `; } function renderReadme({ pascal, kebab, camel, permissionPrefix, }: { pascal: string; kebab: string; camel: string; permissionPrefix: string; }): string { return `# ${pascal} module Generated by \`toolctrl create module ${kebab}\`. ## Layout - \`db/${camel}.db.server.ts\` — tenant-scoped CRUD helpers - \`services/${pascal}Service.ts\` — tenant-aware service layer - \`components/${pascal}List.tsx\` — list component - \`../../routes/app.$tenant.${camel}.tsx\` — tenant route ## Manual follow-up 1. Add the prisma model and run \`pnpm prisma migrate dev\`. 2. Replace the \`TODO\` markers in \`db/${camel}.db.server.ts\` with the real prisma model calls. 3. Add permission keys to \`packages/db/src/permissions-seed.server.ts\`: - \`${permissionPrefix}.view\` - \`${permissionPrefix}.create\` - \`${permissionPrefix}.update\` - \`${permissionPrefix}.delete\` 4. Register a sidebar entry in \`apps/web/app/sidebar\`. 5. Run \`pnpm typecheck\` to generate the React Router v7 \`./+types/\` files. `; } export const createModuleCommand = defineCommand({ meta: { name: "module", description: "Scaffold a new feature-module under apps/web/app/modules/.", }, args: { name: { type: "positional", required: true, description: "Module name (camel/kebab/pascal — normalised)." }, "permission-prefix": { type: "string", required: false, description: "Permission key prefix (defaults to kebab-cased name).", }, "dry-run": { type: "boolean", required: false, description: "Print the would-be writes without touching the filesystem.", }, }, async run({ args }) { const result = await generateModule({ name: args.name, permissionPrefix: args["permission-prefix"], dryRun: args["dry-run"] === true, }); consola.success( `${result.dryRun ? "[dry-run] " : ""}Generated module "${args.name}" — ${result.files.length} file(s).`, ); for (const file of result.files) { consola.info(` ${result.dryRun ? "would write" : "wrote"} ${file.path}`); } for (const note of result.notes) { consola.info(note); } }, });