Files
HiveGrid-Wireframes/hivegrid-cloud/packages/cli/src/commands/create/module.ts
T

303 lines
9.3 KiB
TypeScript

/**
* `toolctrl create module <name>` — generate a new feature-module skeleton
* under `apps/web/app/modules/<name>/`.
*
* 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<ScaffoldResult> {
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 ?? <p>No ${pascal} yet.</p>;
}
return (
<ul>
{items.map((item) => (
<li key={item.id}>{item.name}</li>
))}
</ul>
);
}
`;
}
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 (
<div>
<h1>${pascal}</h1>
<${pascal}List items={loaderData.items} />
</div>
);
}
`;
}
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/<name>.",
},
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);
}
},
});