Track HiveGrid Cloud planning reference
This commit is contained in:
1 parent
b3d5ea6764
commit
e341c5938a
5784 files changed
+1104929
-3
No files matched your search
@@ -0,0 +1,302 @@
|
||||
/**
|
||||
* `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);
|
||||
}
|
||||
},
|
||||
});
|
||||
Reference in new issue
Block a user