303 lines
9.3 KiB
TypeScript
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);
|
|
}
|
|
},
|
|
});
|