180 lines
5.4 KiB
TypeScript
180 lines
5.4 KiB
TypeScript
/**
|
|
* `toolctrl create entity <name>` — 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/<name>.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<ScaffoldResult> {
|
|
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);
|
|
}
|
|
},
|
|
});
|