/** * `toolctrl create entity ` — emit a Prisma model snippet plus a * timestamped migration directory. * * The generator does NOT modify `prisma/schema.prisma` automatically — * we drop the model snippet under a sibling `prisma/_generated/.prisma` * file plus a manual-include note in the README, because patching * `schema.prisma` reliably from a CLI requires parsing prisma's own * grammar (no off-the-shelf TS lib does that cleanly). The migration * directory follows prisma's standard layout so `prisma migrate dev` * picks it up. * * 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 { createScaffoldResult, kebabCase, pascalCase, resolveRepoRoot, snakeCase, validateIdentifier, writeScaffoldFile, type ScaffoldResult, } from "../_lib/scaffold.js"; export type GenerateEntityOptions = { name: string; repoRoot?: string; dryRun?: boolean; /** Override the migration timestamp prefix (test seam). */ timestamp?: string; }; export async function generateEntity(options: GenerateEntityOptions): Promise { const baseName = validateIdentifier(options.name, "entity name"); const dryRun = options.dryRun ?? false; const repoRoot = resolveRepoRoot(options.repoRoot); const pascal = pascalCase(baseName); const snake = snakeCase(baseName); const kebab = kebabCase(baseName); const timestamp = options.timestamp ?? defaultTimestamp(); const migrationDir = `${timestamp}_create_entity_${snake}`; const result = createScaffoldResult(dryRun); await writeScaffoldFile({ targetPath: path.join(repoRoot, "prisma/_generated", `${kebab}.prisma`), contents: renderPrismaModel({ pascal, snake }), dryRun, result, }); await writeScaffoldFile({ targetPath: path.join(repoRoot, "prisma/migrations", migrationDir, "migration.sql"), contents: renderMigrationSql({ pascal, snake }), dryRun, result, }); await writeScaffoldFile({ targetPath: path.join(repoRoot, "prisma/migrations", migrationDir, "README.md"), contents: renderMigrationReadme({ pascal, snake, kebab }), dryRun, result, }); result.notes.push( `Next steps:`, ` 1. Copy the model from prisma/_generated/${kebab}.prisma into prisma/schema.prisma`, ` 2. Run \`pnpm prisma migrate dev --name create_entity_${snake}\``, ` 3. Regenerate the prisma client: \`pnpm prisma generate\``, ); return result; } function defaultTimestamp(): string { // Prisma migration timestamp format: YYYYMMDDHHMMSS. const now = new Date(); const pad = (n: number) => n.toString().padStart(2, "0"); return ( now.getUTCFullYear().toString() + pad(now.getUTCMonth() + 1) + pad(now.getUTCDate()) + pad(now.getUTCHours()) + pad(now.getUTCMinutes()) + pad(now.getUTCSeconds()) ); } function renderPrismaModel({ pascal, snake }: { pascal: string; snake: string }): string { return `// Generated by toolctrl-cli — paste this block into prisma/schema.prisma. model ${pascal} { id String @id @default(cuid()) tenantId String name String createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@index([tenantId]) @@map("${snake}") } `; } function renderMigrationSql({ pascal, snake }: { pascal: string; snake: string }): string { return `-- Generated by toolctrl-cli for entity ${pascal}. -- Replace columns with actual schema before running \`prisma migrate dev\`. CREATE TABLE "${snake}" ( "id" TEXT NOT NULL, "tenantId" TEXT NOT NULL, "name" TEXT NOT NULL, "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "updatedAt" TIMESTAMP(3) NOT NULL, CONSTRAINT "${snake}_pkey" PRIMARY KEY ("id") ); CREATE INDEX "${snake}_tenantId_idx" ON "${snake}"("tenantId"); `; } function renderMigrationReadme({ pascal, snake, kebab, }: { pascal: string; snake: string; kebab: string; }): string { return `# Migration: create entity ${pascal} Generated by \`toolctrl create entity ${kebab}\`. ## How to apply 1. Confirm the prisma model in \`prisma/_generated/${kebab}.prisma\` matches your intended schema. Adjust columns / indexes / relations as needed. 2. Append the model to \`prisma/schema.prisma\`. 3. Run: \`\`\`bash pnpm prisma migrate dev --name create_entity_${snake} \`\`\` 4. Prisma will rewrite the SQL in \`migration.sql\` to match the schema diff — this file is only a placeholder so the directory exists. `; } export const createEntityCommand = defineCommand({ meta: { name: "entity", description: "Scaffold a Prisma model + migration directory for a new entity.", }, args: { name: { type: "positional", required: true, description: "Entity name (will be PascalCased for the model)." }, "dry-run": { type: "boolean", required: false, description: "Print the would-be writes without touching the filesystem.", }, }, async run({ args }) { const result = await generateEntity({ name: args.name, dryRun: args["dry-run"] === true }); consola.success( `${result.dryRun ? "[dry-run] " : ""}Generated entity "${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); } }, });