1516 lines
58 KiB
TypeScript
1516 lines
58 KiB
TypeScript
import { existsSync } from "node:fs";
|
|
import { join } from "node:path";
|
|
import type { PlopTypes } from "@turbo/gen";
|
|
import { assertAnchors } from "./lib/anchor-validate.js";
|
|
import {
|
|
assertOptionalPackageNotPresent,
|
|
addToTranspilePackages,
|
|
splicePluginRulesAt,
|
|
splicePluginImportsAt,
|
|
addBoundariesEntry,
|
|
emitTemplateTree,
|
|
} from "./lib/core-package-utils.js";
|
|
|
|
/**
|
|
* Turbo generator: `feature`
|
|
*
|
|
* Scaffolds a Lazar-conformant feature package under `packages/<name>/`
|
|
* matching the shape of the existing `navigation` reference feature.
|
|
*
|
|
* Phase 1 scope (intentionally limited):
|
|
* - Single entity, single use case (`getX`)
|
|
* - Skips Payload CMS collection/global templates (integrations/cms/**)
|
|
* - Skips UI query helpers (ui/query.ts) — emits an empty barrel
|
|
* - Skips faker-driven factories — emits empty stubs
|
|
* - Skips multi-entity / multi-use-case
|
|
* - Skips aggregator wiring (core-api/root.ts, core-cms/**, apps/web-next/server/bind-production.ts)
|
|
*
|
|
* After running, the developer must hand-wire the new feature into:
|
|
* - apps/web-next/src/server/bind-production.ts (bindAll dispatcher)
|
|
* - packages/core-api/src/root.ts (mount on app router)
|
|
* - packages/core-cms/... (when CMS templates are added later)
|
|
*
|
|
* The generator prints these manual steps when it finishes.
|
|
*/
|
|
export default function generator(plop: PlopTypes.NodePlopAPI): void {
|
|
// Handlebars helper used by templates that branch on prompt values
|
|
// (e.g. `gen job`'s inputShape void/typed split).
|
|
plop.setHelper("eq", (a: unknown, b: unknown) => a === b);
|
|
|
|
plop.setGenerator("feature", {
|
|
description:
|
|
"Scaffold a Lazar-conformant feature package (single entity / single use case)",
|
|
prompts: [
|
|
{
|
|
type: "input",
|
|
name: "name",
|
|
message:
|
|
"Feature package name (kebab-case, becomes @repo/<name> and packages/<name>/):",
|
|
validate: (input: string) => {
|
|
if (!input) return "Required";
|
|
if (!/^[a-z][a-z0-9-]*$/.test(input)) {
|
|
return "Must be kebab-case (lowercase letters, digits, hyphens; must start with a letter)";
|
|
}
|
|
return true;
|
|
},
|
|
},
|
|
{
|
|
type: "input",
|
|
name: "entity",
|
|
message:
|
|
"Entity name (PascalCase singular, e.g. 'Widget' for a getWidget use case):",
|
|
validate: (input: string) => {
|
|
if (!input) return "Required";
|
|
if (!/^[A-Z][A-Za-z0-9]*$/.test(input)) {
|
|
return "Must be PascalCase (e.g. Widget, BlogPost)";
|
|
}
|
|
return true;
|
|
},
|
|
},
|
|
{
|
|
type: "input",
|
|
name: "entityPlural",
|
|
message:
|
|
"Entity plural slug (kebab-case, used for Payload collection slug; e.g. 'widgets'):",
|
|
validate: (input: string) => {
|
|
if (!input) return "Required";
|
|
if (!/^[a-z][a-z0-9-]*$/.test(input)) {
|
|
return "Must be kebab-case";
|
|
}
|
|
return true;
|
|
},
|
|
},
|
|
],
|
|
actions: [
|
|
// Top-level package files
|
|
{
|
|
type: "add",
|
|
path: "packages/{{kebabCase name}}/package.json",
|
|
templateFile: "templates/feature/package.json.hbs",
|
|
},
|
|
{
|
|
type: "add",
|
|
path: "packages/{{kebabCase name}}/tsconfig.json",
|
|
templateFile: "templates/feature/tsconfig.json.hbs",
|
|
},
|
|
{
|
|
type: "add",
|
|
path: "packages/{{kebabCase name}}/vitest.config.ts",
|
|
templateFile: "templates/feature/vitest.config.ts.hbs",
|
|
},
|
|
{
|
|
type: "add",
|
|
path: "packages/{{kebabCase name}}/eslint.config.js",
|
|
templateFile: "templates/feature/eslint.config.js.hbs",
|
|
},
|
|
{
|
|
type: "add",
|
|
path: "packages/{{kebabCase name}}/turbo.json",
|
|
templateFile: "templates/feature/turbo.json.hbs",
|
|
},
|
|
{
|
|
type: "add",
|
|
path: "packages/{{kebabCase name}}/AGENTS.md",
|
|
templateFile: "templates/feature/AGENTS.md.hbs",
|
|
},
|
|
|
|
// Public surface
|
|
{
|
|
type: "add",
|
|
path: "packages/{{kebabCase name}}/src/index.ts",
|
|
templateFile: "templates/feature/src/index.ts.hbs",
|
|
},
|
|
{
|
|
type: "add",
|
|
path: "packages/{{kebabCase name}}/src/feature.manifest.ts",
|
|
templateFile: "templates/feature/src/feature.manifest.ts.hbs",
|
|
},
|
|
|
|
// Entities — models + errors
|
|
{
|
|
type: "add",
|
|
path: "packages/{{kebabCase name}}/src/entities/models/{{kebabCase entity}}.ts",
|
|
templateFile: "templates/feature/src/entities/models/entity.ts.hbs",
|
|
},
|
|
{
|
|
type: "add",
|
|
path: "packages/{{kebabCase name}}/src/entities/models/{{kebabCase entity}}.test.ts",
|
|
templateFile: "templates/feature/src/entities/models/entity.test.ts.hbs",
|
|
},
|
|
{
|
|
type: "add",
|
|
path: "packages/{{kebabCase name}}/src/entities/errors/common.ts",
|
|
templateFile: "templates/feature/src/entities/errors/common.ts.hbs",
|
|
},
|
|
{
|
|
type: "add",
|
|
path: "packages/{{kebabCase name}}/src/entities/errors/{{kebabCase entity}}.ts",
|
|
templateFile: "templates/feature/src/entities/errors/entity.ts.hbs",
|
|
},
|
|
|
|
// Application layer
|
|
{
|
|
type: "add",
|
|
path: "packages/{{kebabCase name}}/src/application/repositories/{{kebabCase entity}}.repository.interface.ts",
|
|
templateFile:
|
|
"templates/feature/src/application/repositories/entity.repository.interface.ts.hbs",
|
|
},
|
|
{
|
|
type: "add",
|
|
path: "packages/{{kebabCase name}}/src/application/use-cases/get-{{kebabCase entity}}.use-case.ts",
|
|
templateFile:
|
|
"templates/feature/src/application/use-cases/get-entity.use-case.ts.hbs",
|
|
},
|
|
{
|
|
type: "add",
|
|
path: "packages/{{kebabCase name}}/src/application/use-cases/get-{{kebabCase entity}}.use-case.test.ts",
|
|
templateFile:
|
|
"templates/feature/src/application/use-cases/get-entity.use-case.test.ts.hbs",
|
|
},
|
|
|
|
// Infrastructure
|
|
{
|
|
type: "add",
|
|
path: "packages/{{kebabCase name}}/src/infrastructure/repositories/{{kebabCase entity}}.repository.ts",
|
|
templateFile:
|
|
"templates/feature/src/infrastructure/repositories/entity.repository.ts.hbs",
|
|
},
|
|
{
|
|
type: "add",
|
|
path: "packages/{{kebabCase name}}/src/infrastructure/repositories/{{kebabCase entity}}.repository.test.ts",
|
|
templateFile:
|
|
"templates/feature/src/infrastructure/repositories/entity.repository.test.ts.hbs",
|
|
},
|
|
{
|
|
type: "add",
|
|
path: "packages/{{kebabCase name}}/src/infrastructure/repositories/{{kebabCase entity}}.repository.mock.ts",
|
|
templateFile:
|
|
"templates/feature/src/infrastructure/repositories/entity.repository.mock.ts.hbs",
|
|
},
|
|
{
|
|
type: "add",
|
|
path: "packages/{{kebabCase name}}/src/infrastructure/repositories/{{kebabCase entity}}.repository.mock.test.ts",
|
|
templateFile:
|
|
"templates/feature/src/infrastructure/repositories/entity.repository.mock.test.ts.hbs",
|
|
},
|
|
{
|
|
type: "add",
|
|
path: "packages/{{kebabCase name}}/src/infrastructure/repositories/{{kebabCase entity}}.repository.span.test.ts",
|
|
templateFile:
|
|
"templates/feature/src/infrastructure/repositories/entity.repository.span.test.ts.hbs",
|
|
},
|
|
|
|
// Interface adapters
|
|
{
|
|
type: "add",
|
|
path: "packages/{{kebabCase name}}/src/interface-adapters/controllers/get-{{kebabCase entity}}.controller.ts",
|
|
templateFile:
|
|
"templates/feature/src/interface-adapters/controllers/get-entity.controller.ts.hbs",
|
|
},
|
|
{
|
|
type: "add",
|
|
path: "packages/{{kebabCase name}}/src/interface-adapters/controllers/get-{{kebabCase entity}}.controller.test.ts",
|
|
templateFile:
|
|
"templates/feature/src/interface-adapters/controllers/get-entity.controller.test.ts.hbs",
|
|
},
|
|
|
|
// DI
|
|
{
|
|
type: "add",
|
|
path: "packages/{{kebabCase name}}/src/di/symbols.ts",
|
|
templateFile: "templates/feature/src/di/symbols.ts.hbs",
|
|
},
|
|
{
|
|
type: "add",
|
|
path: "packages/{{kebabCase name}}/src/di/module.ts",
|
|
templateFile: "templates/feature/src/di/module.ts.hbs",
|
|
},
|
|
{
|
|
type: "add",
|
|
path: "packages/{{kebabCase name}}/src/di/container.ts",
|
|
templateFile: "templates/feature/src/di/container.ts.hbs",
|
|
},
|
|
{
|
|
type: "add",
|
|
path: "packages/{{kebabCase name}}/src/di/container.test.ts",
|
|
templateFile: "templates/feature/src/di/container.test.ts.hbs",
|
|
},
|
|
{
|
|
type: "add",
|
|
path: "packages/{{kebabCase name}}/src/di/bind-production.ts",
|
|
templateFile: "templates/feature/src/di/bind-production.ts.hbs",
|
|
},
|
|
{
|
|
type: "add",
|
|
path: "packages/{{kebabCase name}}/src/di/bind-dev-seed.ts",
|
|
templateFile: "templates/feature/src/di/bind-dev-seed.ts.hbs",
|
|
},
|
|
{
|
|
type: "add",
|
|
path: "packages/{{kebabCase name}}/src/di/bind-dev-seed.test.ts",
|
|
templateFile: "templates/feature/src/di/bind-dev-seed.test.ts.hbs",
|
|
},
|
|
|
|
// Integrations: api
|
|
{
|
|
type: "add",
|
|
path: "packages/{{kebabCase name}}/src/integrations/api/procedures.ts",
|
|
templateFile: "templates/feature/src/integrations/api/procedures.ts.hbs",
|
|
},
|
|
{
|
|
type: "add",
|
|
path: "packages/{{kebabCase name}}/src/integrations/api/router.ts",
|
|
templateFile: "templates/feature/src/integrations/api/router.ts.hbs",
|
|
},
|
|
{
|
|
type: "add",
|
|
path: "packages/{{kebabCase name}}/src/integrations/api/router.test.ts",
|
|
templateFile: "templates/feature/src/integrations/api/router.test.ts.hbs",
|
|
},
|
|
|
|
// Seeds + factories + contracts (Phase 1: minimal stubs that still typecheck/test)
|
|
{
|
|
type: "add",
|
|
path: "packages/{{kebabCase name}}/src/__seeds__/dev.ts",
|
|
templateFile: "templates/feature/src/__seeds__/dev.ts.hbs",
|
|
},
|
|
{
|
|
type: "add",
|
|
path: "packages/{{kebabCase name}}/src/__factories__/index.ts",
|
|
templateFile: "templates/feature/src/__factories__/index.ts.hbs",
|
|
},
|
|
{
|
|
type: "add",
|
|
path: "packages/{{kebabCase name}}/src/__factories__/{{kebabCase entity}}.factory.ts",
|
|
templateFile: "templates/feature/src/__factories__/entity.factory.ts.hbs",
|
|
},
|
|
{
|
|
type: "add",
|
|
path: "packages/{{kebabCase name}}/src/__contracts__/{{kebabCase entity}}-repository.contract.ts",
|
|
templateFile:
|
|
"templates/feature/src/__contracts__/entity-repository.contract.ts.hbs",
|
|
},
|
|
|
|
// ui — empty barrel for Phase 1 (./ui subpath kept reserved)
|
|
{
|
|
type: "add",
|
|
path: "packages/{{kebabCase name}}/src/ui/index.ts",
|
|
templateFile: "templates/feature/src/ui/index.ts.hbs",
|
|
},
|
|
|
|
// Final manual-wiring instructions printed to the user
|
|
function printNextSteps(answers: Record<string, unknown>): string {
|
|
const a = answers as { name: string; entity: string; entityPlural: string };
|
|
const kebab = a.name;
|
|
const constSym = a.name.toUpperCase().replace(/-/g, "_");
|
|
const pkg = `@repo/${kebab}`;
|
|
return [
|
|
"",
|
|
"─────────────────────────────────────────────────────────────",
|
|
`Feature package ${pkg} scaffolded.`,
|
|
"",
|
|
"Next steps (manual aggregator wiring — not generated):",
|
|
"",
|
|
` 1. pnpm install # link the new workspace package`,
|
|
"",
|
|
` 2. apps/web-next/src/server/bind-production.ts:`,
|
|
` - import { bindProduction${cap(a.name)} } from "${pkg}/di/bind-production";`,
|
|
` - import { bindDevSeed${cap(a.name)} } from "${pkg}/di/bind-dev-seed";`,
|
|
` - call bindProduction${cap(a.name)}(ctx) (production branch, ctx: BindProductionContext)`,
|
|
` - call await bindDevSeed${cap(a.name)}(ctx) (dev-seed branch, ctx: BindContext)`,
|
|
"",
|
|
` 3. packages/core-api/src/root.ts:`,
|
|
` - import { ${camel(a.name)}Router } from "${pkg}/api";`,
|
|
` - mount it on the app router (e.g. ${camel(a.name)}: ${camel(a.name)}Router)`,
|
|
"",
|
|
` 4. packages/core-api/package.json:`,
|
|
` - add "${pkg}": "workspace:*" to dependencies`,
|
|
"",
|
|
` 5. apps/web-next/package.json:`,
|
|
` - add "${pkg}": "workspace:*" to dependencies`,
|
|
"",
|
|
` 6. (Later) Add Payload CMS collection/global at:`,
|
|
` packages/${kebab}/src/integrations/cms/collections/${a.entityPlural}.ts`,
|
|
` and register in packages/core-cms/...`,
|
|
"",
|
|
` 7. Verify: pnpm --filter ${pkg} lint typecheck test`,
|
|
"",
|
|
`Reference symbols exported by this package:`,
|
|
` - ${constSym}_SYMBOLS (from ${pkg}/* internal)`,
|
|
` - bindProduction${cap(a.name)}, bindDevSeed${cap(a.name)}`,
|
|
` - ${camel(a.name)}Router (tRPC)`,
|
|
"─────────────────────────────────────────────────────────────",
|
|
"",
|
|
].join("\n");
|
|
},
|
|
],
|
|
});
|
|
|
|
/**
|
|
* Turbo generator: `event`
|
|
*
|
|
* Mode `publish` scaffolds an event contract under
|
|
* `packages/<feature>/src/events/<event-kebab>.event.ts` (+ test) and
|
|
* re-exports it from the feature's public surface at the `// <gen:events>`
|
|
* anchor. Mode `consume` (Task 40) scaffolds a handler in the consumer
|
|
* feature.
|
|
*/
|
|
plop.setGenerator("event", {
|
|
description: "Scaffold an event contract (publish) or handler (consume)",
|
|
prompts: [
|
|
{
|
|
type: "list",
|
|
name: "mode",
|
|
message: "Mode:",
|
|
choices: ["publish", "consume"],
|
|
},
|
|
{
|
|
type: "input",
|
|
name: "feature",
|
|
message:
|
|
"Owning feature (kebab-case; for publish: contract package; for consume: consumer package):",
|
|
validate(input: string) {
|
|
if (!/^[a-z][a-z0-9-]*$/.test(input)) return "Must be kebab-case";
|
|
if (!existsSync(join(process.cwd(), "packages", input, "src"))) {
|
|
return `packages/${input}/src does not exist`;
|
|
}
|
|
return true;
|
|
},
|
|
},
|
|
{
|
|
type: "input",
|
|
name: "event",
|
|
message: "Event slug (dotted-kebab past tense, e.g. 'user.signed-up'):",
|
|
validate(input: string) {
|
|
if (!/^[a-z][a-z0-9-]*(\.[a-z][a-z0-9-]*)+$/.test(input)) {
|
|
return "Must be dotted-kebab past tense (e.g. user.signed-up)";
|
|
}
|
|
return true;
|
|
},
|
|
},
|
|
{
|
|
type: "input",
|
|
name: "publisher",
|
|
message:
|
|
"Publisher feature (kebab-case; required for consume, ignored for publish):",
|
|
// Always shown — `--args` cannot bypass conditional prompts (Plop limitation).
|
|
// Publish mode accepts any value (it's ignored by publishActions).
|
|
validate(input: string, answers: { mode: string; event: string }) {
|
|
if (answers.mode === "publish") return true;
|
|
if (!/^[a-z][a-z0-9-]*$/.test(input)) return "Must be kebab-case";
|
|
const eventKebab = answers.event.replace(/\./g, "-");
|
|
const path = join(
|
|
process.cwd(),
|
|
"packages",
|
|
input,
|
|
"src",
|
|
"events",
|
|
`${eventKebab}.event.ts`,
|
|
);
|
|
if (!existsSync(path)) {
|
|
return `Publisher contract not found: ${path}`;
|
|
}
|
|
return true;
|
|
},
|
|
},
|
|
],
|
|
actions(answers) {
|
|
const a = answers as {
|
|
mode: "publish" | "consume";
|
|
feature: string;
|
|
event: string;
|
|
publisher?: string;
|
|
};
|
|
if (a.mode === "publish") return publishActions(a);
|
|
if (!a.publisher) {
|
|
throw new Error("consume mode requires a publisher feature");
|
|
}
|
|
return consumeActions(a as Required<typeof a>);
|
|
},
|
|
});
|
|
|
|
/**
|
|
* Turbo generator: `realtime`
|
|
*
|
|
* Mode `channel` scaffolds a realtime channel descriptor under
|
|
* `packages/<feature>/src/realtime/<slug>.channel.ts` (+ test) and
|
|
* re-exports it from the feature's public surface at the
|
|
* `// <gen:realtime-channels>` anchor.
|
|
* Mode `handler` scaffolds an inbound handler + DI binding at the
|
|
* `// <gen:realtime-handlers>` anchor.
|
|
*/
|
|
plop.setGenerator("realtime", {
|
|
description: "Scaffold a realtime channel descriptor or inbound handler",
|
|
prompts: [
|
|
{
|
|
type: "input",
|
|
name: "mode",
|
|
message: "Mode: channel | handler",
|
|
validate(input: string) {
|
|
if (!["channel", "handler"].includes(input))
|
|
return "Must be 'channel' or 'handler'";
|
|
return true;
|
|
},
|
|
},
|
|
{
|
|
type: "input",
|
|
name: "feature",
|
|
message: "Feature (kebab-case, must exist):",
|
|
validate(input: string) {
|
|
if (!/^[a-z][a-z0-9-]*$/.test(input)) return "Must be kebab-case";
|
|
if (!existsSync(join(process.cwd(), "packages", input, "src"))) {
|
|
return `packages/${input}/src does not exist`;
|
|
}
|
|
return true;
|
|
},
|
|
},
|
|
{
|
|
type: "input",
|
|
name: "channelSlug",
|
|
message: "Channel slug (kebab-case, e.g. 'presence-ping'):",
|
|
validate(input: string) {
|
|
if (!/^[a-z][a-z0-9-]+$/.test(input)) return "Must be kebab-case";
|
|
return true;
|
|
},
|
|
},
|
|
{
|
|
type: "input",
|
|
name: "scope",
|
|
message:
|
|
"Scope (channel mode only): public | authenticated | role:NAME | user-scoped (ignored for handler mode):",
|
|
validate(input: string, answers: { mode: string }) {
|
|
if (answers.mode === "handler") return true;
|
|
if (
|
|
input === "public" ||
|
|
input === "authenticated" ||
|
|
input === "user-scoped"
|
|
)
|
|
return true;
|
|
if (/^role:[a-z][a-z0-9_-]*$/.test(input)) return true;
|
|
return "Must be public | authenticated | role:NAME | user-scoped";
|
|
},
|
|
},
|
|
],
|
|
actions(answers) {
|
|
const a = answers as {
|
|
mode: string;
|
|
feature: string;
|
|
channelSlug: string;
|
|
scope: string;
|
|
};
|
|
if (a.mode === "channel") return realtimeChannelActions(a);
|
|
if (a.mode === "handler") return realtimeHandlerActions(a);
|
|
throw new Error(`Unknown mode: ${a.mode}`);
|
|
},
|
|
});
|
|
|
|
/**
|
|
* Turbo generator: `core-package`
|
|
*
|
|
* Scaffolds an optional core package back into a slimmed template. Each
|
|
* name maps to a verbatim snapshot of the package captured at generator-add
|
|
* time. Phases 3-6 each register one entry in CORE_PACKAGE_GENERATORS.
|
|
*/
|
|
const REALTIME_RULE_IMPORTS = `import noDirectSocketIO from "./rules/no-direct-socket-io.js";
|
|
import noRealtimeHandlerReexport from "./rules/no-realtime-handler-reexport.js";`;
|
|
|
|
const REALTIME_RULE_BLOCK = ` {
|
|
files: ["**/*.{ts,tsx,mjs,cjs,js}"],
|
|
plugins: {
|
|
"repo-rules": {
|
|
rules: {
|
|
"no-direct-socket-io": noDirectSocketIO,
|
|
"no-realtime-handler-reexport": noRealtimeHandlerReexport,
|
|
},
|
|
},
|
|
},
|
|
rules: {
|
|
"repo-rules/no-direct-socket-io": "error",
|
|
"repo-rules/no-realtime-handler-reexport": "error",
|
|
},
|
|
},`;
|
|
|
|
const EVENTS_RULE_BLOCK = ` {
|
|
files: ["**/*.{ts,tsx,mjs,cjs,js}"],
|
|
rules: {
|
|
"no-restricted-syntax": [
|
|
"error",
|
|
{
|
|
selector:
|
|
"ExportNamedDeclaration[source.value=/\\/events\\/handlers\\//]",
|
|
message:
|
|
"Event handlers (events/handlers/*.handler.ts) must not be re-exported. Wire them only inside the consumer feature's bind-production / bind-dev-seed (Rule E1).",
|
|
},
|
|
{
|
|
selector:
|
|
"ExportAllDeclaration[source.value=/\\/events\\/handlers\\//]",
|
|
message:
|
|
"Event handlers (events/handlers/*.handler.ts) must not be re-exported. Wire them only inside the consumer feature's bind-production / bind-dev-seed (Rule E1).",
|
|
},
|
|
{
|
|
selector:
|
|
"MemberExpression[object.type='MemberExpression'][object.object.type='Identifier'][object.object.name='payload'][object.property.type='Identifier'][object.property.name='jobs']",
|
|
message:
|
|
"Direct \`payload.jobs.*\` access is not allowed here. Use IJobQueue (from @repo/core-shared/jobs) instead. Allowed only in **/integrations/cms/jobs/** and **/core-shared/src/jobs/**.",
|
|
},
|
|
],
|
|
},
|
|
},
|
|
// J — \`payload.jobs.*\` is allowed only in the integration layer.
|
|
// In these paths, no-restricted-syntax is narrowed to keep E1 active but
|
|
// drop the payload.jobs check.
|
|
// Note: "**/core-shared/src/jobs/**" does not match from within a package-local
|
|
// ESLint run because ESLint resolves globs relative to the config file location.
|
|
// The pattern is kept for documentation; in practice, the PayloadJobQueue class
|
|
// uses \`this.payload.jobs.*\` which the selector already ignores (it only catches
|
|
// bare \`payload.jobs.*\`). Any new file added there that does use bare \`payload.jobs.*\`
|
|
// would need this allowlist to be expressed as "**/jobs/payload-*" or similar.
|
|
{
|
|
files: [
|
|
"**/integrations/cms/jobs/**",
|
|
"**/core-shared/src/jobs/**",
|
|
],
|
|
rules: {
|
|
"no-restricted-syntax": [
|
|
"error",
|
|
{
|
|
selector:
|
|
"ExportNamedDeclaration[source.value=/\\/events\\/handlers\\//]",
|
|
message:
|
|
"Event handlers (events/handlers/*.handler.ts) must not be re-exported. Wire them only inside the consumer feature's bind-production / bind-dev-seed (Rule E1).",
|
|
},
|
|
{
|
|
selector:
|
|
"ExportAllDeclaration[source.value=/\\/events\\/handlers\\//]",
|
|
message:
|
|
"Event handlers (events/handlers/*.handler.ts) must not be re-exported. Wire them only inside the consumer feature's bind-production / bind-dev-seed (Rule E1).",
|
|
},
|
|
],
|
|
},
|
|
},`;
|
|
|
|
const CORE_PACKAGE_GENERATORS: Record<string, () => PlopTypes.ActionType[]> =
|
|
{
|
|
realtime: () => [
|
|
() => {
|
|
assertOptionalPackageNotPresent("core-realtime");
|
|
return "Guard passed — packages/core-realtime does not exist yet.";
|
|
},
|
|
...emitTemplateTree("core-package/realtime", "packages/core-realtime"),
|
|
...emitTemplateTree(
|
|
"core-package/realtime-eslint-rules",
|
|
"packages/core-eslint/rules",
|
|
),
|
|
() => {
|
|
addToTranspilePackages("apps/web-next/next.config.mjs", "@repo/core-realtime");
|
|
return "Added @repo/core-realtime to transpilePackages.";
|
|
},
|
|
() => {
|
|
addBoundariesEntry("packages/core-eslint/base.js", "packages/core-realtime", { mode: "folder" });
|
|
return "Added core-realtime boundaries entry.";
|
|
},
|
|
() => {
|
|
splicePluginImportsAt(
|
|
"packages/core-eslint/base.js",
|
|
"realtime-rules-imports",
|
|
REALTIME_RULE_IMPORTS,
|
|
);
|
|
return "Added realtime rule imports to base.js.";
|
|
},
|
|
() => {
|
|
splicePluginRulesAt(
|
|
"packages/core-eslint/base.js",
|
|
"realtime-rules",
|
|
REALTIME_RULE_BLOCK,
|
|
);
|
|
return "Added realtime rule block to base.js.";
|
|
},
|
|
printRealtimeNextSteps,
|
|
],
|
|
events: () => [
|
|
() => {
|
|
assertOptionalPackageNotPresent("core-events");
|
|
return "Guard passed — packages/core-events does not exist yet.";
|
|
},
|
|
...emitTemplateTree("core-package/events", "packages/core-events"),
|
|
() => {
|
|
addToTranspilePackages("apps/web-next/next.config.mjs", "@repo/core-events");
|
|
return "Added @repo/core-events to transpilePackages.";
|
|
},
|
|
() => {
|
|
splicePluginRulesAt(
|
|
"packages/core-eslint/base.js",
|
|
"events-rules",
|
|
EVENTS_RULE_BLOCK,
|
|
);
|
|
return "Added events rule block to base.js.";
|
|
},
|
|
printEventsNextSteps,
|
|
],
|
|
trpc: () => [
|
|
() => {
|
|
assertOptionalPackageNotPresent("core-trpc");
|
|
return "Guard passed — packages/core-trpc does not exist yet.";
|
|
},
|
|
...emitTemplateTree("core-package/trpc", "packages/core-trpc"),
|
|
() => {
|
|
addToTranspilePackages("apps/web-next/next.config.mjs", "@repo/core-trpc");
|
|
return "Added @repo/core-trpc to transpilePackages.";
|
|
},
|
|
printTrpcNextSteps,
|
|
],
|
|
ui: () => [
|
|
() => {
|
|
assertOptionalPackageNotPresent("core-ui");
|
|
return "Guard passed — packages/core-ui does not exist yet.";
|
|
},
|
|
...emitTemplateTree("core-package/ui", "packages/core-ui"),
|
|
() => {
|
|
addToTranspilePackages("apps/web-next/next.config.mjs", "@repo/core-ui");
|
|
return "Added @repo/core-ui to transpilePackages.";
|
|
},
|
|
printUiNextSteps,
|
|
],
|
|
audit: () => [
|
|
() => {
|
|
assertOptionalPackageNotPresent("core-audit");
|
|
return "Guard passed — packages/core-audit does not exist yet.";
|
|
},
|
|
...emitTemplateTree("core-package/audit", "packages/core-audit"),
|
|
() => {
|
|
addToTranspilePackages("apps/web-next/next.config.mjs", "@repo/core-audit");
|
|
return "Added @repo/core-audit to transpilePackages.";
|
|
},
|
|
printAuditNextSteps,
|
|
],
|
|
};
|
|
|
|
plop.setGenerator("core-package", {
|
|
description: "Scaffold an optional core package (realtime, events, trpc, ui, audit)",
|
|
prompts: [
|
|
{
|
|
type: "list",
|
|
name: "name",
|
|
message: "Which optional core package?",
|
|
choices: ["realtime", "events", "trpc", "ui", "audit"],
|
|
},
|
|
],
|
|
actions: (answers) => {
|
|
const a = answers as { name: string };
|
|
const handler = CORE_PACKAGE_GENERATORS[a.name];
|
|
if (!handler) {
|
|
throw new Error(`No generator for core-package '${a.name}'`);
|
|
}
|
|
return handler();
|
|
},
|
|
});
|
|
|
|
/**
|
|
* Turbo generator: `job`
|
|
*
|
|
* Scaffolds a background job inside an existing feature: factory + test +
|
|
* Payload TaskConfig, plus the per-feature DI binding (span+capture wrap)
|
|
* and re-export from `integrations/cms/index.ts`.
|
|
*/
|
|
plop.setGenerator("job", {
|
|
description: "Scaffold a background job in an existing feature",
|
|
prompts: [
|
|
{
|
|
type: "input",
|
|
name: "feature",
|
|
message: "Feature (kebab-case, must exist):",
|
|
validate(input: string) {
|
|
if (!/^[a-z][a-z0-9-]*$/.test(input)) return "Must be kebab-case";
|
|
if (!existsSync(join(process.cwd(), "packages", input, "src"))) {
|
|
return `packages/${input}/src does not exist`;
|
|
}
|
|
return true;
|
|
},
|
|
},
|
|
{
|
|
type: "input",
|
|
name: "job",
|
|
message: "Job slug (verb-noun kebab, e.g. 'send-welcome-email'):",
|
|
validate(input: string) {
|
|
if (!/^[a-z][a-z0-9-]+$/.test(input)) return "Must be kebab-case";
|
|
return true;
|
|
},
|
|
},
|
|
{
|
|
type: "list",
|
|
name: "inputShape",
|
|
message: "Input shape:",
|
|
choices: ["void", "typed"],
|
|
default: "void",
|
|
},
|
|
],
|
|
actions(answers) {
|
|
const a = answers as {
|
|
feature: string;
|
|
job: string;
|
|
inputShape: "void" | "typed";
|
|
};
|
|
return jobActions(a);
|
|
},
|
|
});
|
|
|
|
/**
|
|
* Turbo generator: `core-ui-component`
|
|
*
|
|
* Scaffolds a core-ui atomic-design component (atom / molecule / organism)
|
|
* using the established 4-file pattern: `<name>.tsx`, `<name>.stories.tsx`,
|
|
* `<name>.test.tsx`, `index.ts`. Splices the new export into the matching
|
|
* tier barrel via the `// <gen:<tier>s>` anchor.
|
|
*
|
|
* Refuses to run unless `packages/core-ui/` exists (see
|
|
* `pnpm turbo gen core-package ui`).
|
|
*/
|
|
plop.setGenerator("core-ui-component", {
|
|
description:
|
|
"Scaffold a core-ui atomic-design component (atom / molecule / organism)",
|
|
prompts: [
|
|
{
|
|
type: "list",
|
|
name: "tier",
|
|
message: "Tier:",
|
|
choices: ["atom", "molecule", "organism"],
|
|
},
|
|
{
|
|
type: "input",
|
|
name: "name",
|
|
message: "Component name (PascalCase, e.g. Spinner):",
|
|
validate: (input: string) => {
|
|
if (!input) return "Required";
|
|
if (!/^[A-Z][A-Za-z0-9]*$/.test(input)) {
|
|
return "Must be PascalCase (e.g. Spinner, IconButton)";
|
|
}
|
|
return true;
|
|
},
|
|
},
|
|
],
|
|
actions: (answers) => {
|
|
const a = answers as {
|
|
tier: "atom" | "molecule" | "organism";
|
|
name: string;
|
|
};
|
|
return coreUiComponentActions(a);
|
|
},
|
|
});
|
|
}
|
|
|
|
function jobActions(a: {
|
|
feature: string;
|
|
job: string;
|
|
inputShape: "void" | "typed";
|
|
}): PlopTypes.ActionType[] {
|
|
const symbolFile = `packages/${a.feature}/src/di/symbols.ts`;
|
|
const bindProdFile = `packages/${a.feature}/src/di/bind-production.ts`;
|
|
const bindDevFile = `packages/${a.feature}/src/di/bind-dev-seed.ts`;
|
|
const cmsIndexFile = `packages/${a.feature}/src/integrations/cms/index.ts`;
|
|
return [
|
|
() => {
|
|
assertAnchors(process.cwd(), symbolFile, ["// <gen:job-symbols>"]);
|
|
assertAnchors(process.cwd(), bindProdFile, ["// <gen:jobs>"]);
|
|
assertAnchors(process.cwd(), bindDevFile, ["// <gen:jobs>"]);
|
|
assertAnchors(process.cwd(), cmsIndexFile, ["// <gen:job-tasks>"]);
|
|
return "All required anchors present";
|
|
},
|
|
{
|
|
type: "add",
|
|
path: `packages/${a.feature}/src/jobs/${a.job}.job.ts`,
|
|
templateFile: "templates/job/job.ts.hbs",
|
|
data: a,
|
|
},
|
|
{
|
|
type: "add",
|
|
path: `packages/${a.feature}/src/jobs/${a.job}.job.test.ts`,
|
|
templateFile: "templates/job/job.test.ts.hbs",
|
|
data: a,
|
|
},
|
|
{
|
|
type: "add",
|
|
path: `packages/${a.feature}/src/integrations/cms/jobs/${a.job}.task.ts`,
|
|
templateFile: "templates/job/task.ts.hbs",
|
|
data: a,
|
|
},
|
|
{
|
|
type: "modify",
|
|
path: cmsIndexFile,
|
|
pattern: /\/\/ <gen:job-tasks>/,
|
|
template: `// <gen:job-tasks>\nexport { ${camel(a.job)}Task } from "./jobs/${a.job}.task";`,
|
|
},
|
|
{
|
|
type: "modify",
|
|
path: symbolFile,
|
|
pattern: /\/\/ <gen:job-symbols>/,
|
|
template: `// <gen:job-symbols>\n I${pascalCase(a.job)}Job: Symbol.for("@repo/${a.feature}/${camel(a.job)}Job"),`,
|
|
},
|
|
{
|
|
type: "modify",
|
|
path: bindProdFile,
|
|
pattern: /\/\/ <gen:jobs>/,
|
|
template: jobBindBlock(a),
|
|
},
|
|
{
|
|
type: "modify",
|
|
path: bindDevFile,
|
|
pattern: /\/\/ <gen:jobs>/,
|
|
template: jobBindBlock(a),
|
|
},
|
|
() => printJobNextSteps(a),
|
|
];
|
|
}
|
|
|
|
function jobBindBlock(a: { feature: string; job: string }): string {
|
|
const factoryFn = `${camel(a.job)}Job`;
|
|
const symbol = `${constantCase(a.feature)}_SYMBOLS.I${pascalCase(a.job)}Job`;
|
|
const containerVar = `${camel(a.feature)}Container`;
|
|
return `// <gen:jobs>
|
|
const wrapped${pascalCase(a.job)} = withSpan(
|
|
tracer,
|
|
{ name: "${a.feature}.${camel(a.job)}", op: "job" },
|
|
withCapture(
|
|
logger,
|
|
{
|
|
feature: "${a.feature}",
|
|
layer: "job",
|
|
name: "${a.feature}.${camel(a.job)}",
|
|
},
|
|
${factoryFn}(),
|
|
),
|
|
);
|
|
if (${containerVar}.isBound(${symbol})) {
|
|
${containerVar}.unbind(${symbol});
|
|
}
|
|
${containerVar}.bind(${symbol}).toConstantValue(wrapped${pascalCase(a.job)});`;
|
|
}
|
|
|
|
function printJobNextSteps(a: { feature: string; job: string }): string {
|
|
return [
|
|
"",
|
|
"─────────────────────────────────────────────────────────────",
|
|
`Job ${a.feature}.${a.job} scaffolded.`,
|
|
"",
|
|
"Next steps (manual):",
|
|
` 1. Fill in the job body in packages/${a.feature}/src/jobs/${a.job}.job.ts`,
|
|
` 2. Add Payload field config to inputSchema in ${a.job}.task.ts (matches your Zod schema).`,
|
|
` 3. Add the import for ${camel(a.job)}Job at the top of bind-production.ts and bind-dev-seed.ts.`,
|
|
` 4. (Optional) Add a cron schedule in core-cms's buildConfig if this job runs periodically.`,
|
|
` 5. Verify: pnpm --filter @repo/${a.feature} lint typecheck test`,
|
|
"─────────────────────────────────────────────────────────────",
|
|
"",
|
|
].join("\n");
|
|
}
|
|
|
|
function publishActions(a: {
|
|
feature: string;
|
|
event: string;
|
|
}): PlopTypes.ActionType[] {
|
|
const eventKebab = a.event.replace(/\./g, "-");
|
|
const indexPath = `packages/${a.feature}/src/index.ts`;
|
|
return [
|
|
() => {
|
|
assertAnchors(process.cwd(), indexPath, ["// <gen:events>"]);
|
|
return `Anchors verified in ${indexPath}`;
|
|
},
|
|
{
|
|
type: "add",
|
|
path: `packages/${a.feature}/src/events/${eventKebab}.event.ts`,
|
|
templateFile: "templates/event/publish/event.ts.hbs",
|
|
data: { event: a.event, feature: a.feature, eventKebab },
|
|
},
|
|
{
|
|
type: "add",
|
|
path: `packages/${a.feature}/src/events/${eventKebab}.event.test.ts`,
|
|
templateFile: "templates/event/publish/event.test.ts.hbs",
|
|
data: { event: a.event, feature: a.feature, eventKebab },
|
|
},
|
|
{
|
|
type: "modify",
|
|
path: indexPath,
|
|
pattern: /\/\/ <gen:events>/,
|
|
template: `// <gen:events>\nexport {\n {{camelCase event}}Event,\n {{camelCase event}}EventSchema,\n type {{pascalCase event}}Event,\n} from "./events/{{kebabCase event}}.event";`,
|
|
data: { event: a.event },
|
|
},
|
|
() => printPublishNextSteps(a),
|
|
];
|
|
}
|
|
|
|
function consumeActions(a: {
|
|
feature: string;
|
|
event: string;
|
|
publisher: string;
|
|
}): PlopTypes.ActionType[] {
|
|
const eventKebab = a.event.replace(/\./g, "-");
|
|
const handlerName = `on${pascalCase(a.publisher)}${pascalCase(a.event)}`;
|
|
const symbolFile = `packages/${a.feature}/src/di/symbols.ts`;
|
|
const bindProdFile = `packages/${a.feature}/src/di/bind-production.ts`;
|
|
const bindDevFile = `packages/${a.feature}/src/di/bind-dev-seed.ts`;
|
|
const cmsIndexFile = `packages/${a.feature}/src/integrations/cms/index.ts`;
|
|
return [
|
|
() => {
|
|
assertAnchors(process.cwd(), symbolFile, ["// <gen:event-handler-symbols>"]);
|
|
assertAnchors(process.cwd(), bindProdFile, ["// <gen:event-handlers>"]);
|
|
assertAnchors(process.cwd(), bindDevFile, ["// <gen:event-handlers>"]);
|
|
assertAnchors(process.cwd(), cmsIndexFile, ["// <gen:job-tasks>"]);
|
|
return "All required anchors present";
|
|
},
|
|
{
|
|
type: "add",
|
|
path: `packages/${a.feature}/src/events/handlers/on-${a.publisher}-${eventKebab}.handler.ts`,
|
|
templateFile: "templates/event/consume/handler.ts.hbs",
|
|
data: a,
|
|
},
|
|
{
|
|
type: "add",
|
|
path: `packages/${a.feature}/src/events/handlers/on-${a.publisher}-${eventKebab}.handler.test.ts`,
|
|
templateFile: "templates/event/consume/handler.test.ts.hbs",
|
|
data: a,
|
|
},
|
|
{
|
|
type: "add",
|
|
path: `packages/${a.feature}/src/integrations/cms/jobs/__events-${a.publisher}-${eventKebab}.task.ts`,
|
|
templateFile: "templates/event/consume/event-task.ts.hbs",
|
|
data: a,
|
|
},
|
|
{
|
|
type: "modify",
|
|
path: symbolFile,
|
|
pattern: /\/\/ <gen:event-handler-symbols>/,
|
|
template: `// <gen:event-handler-symbols>\n IOn${pascalCase(a.publisher)}${pascalCase(a.event)}Handler: Symbol.for("@repo/${a.feature}/${handlerName}"),`,
|
|
},
|
|
{
|
|
type: "modify",
|
|
path: bindProdFile,
|
|
pattern: /\/\/ <gen:event-handlers>/,
|
|
template: handlerBindBlock(a),
|
|
},
|
|
{
|
|
type: "modify",
|
|
path: bindDevFile,
|
|
pattern: /\/\/ <gen:event-handlers>/,
|
|
template: handlerBindBlock(a),
|
|
},
|
|
{
|
|
type: "modify",
|
|
path: cmsIndexFile,
|
|
pattern: /\/\/ <gen:job-tasks>/,
|
|
template: `// <gen:job-tasks>\nexport { on${pascalCase(a.publisher)}${pascalCase(a.event)}EventTask } from "./jobs/__events-${a.publisher}-${eventKebab}.task";`,
|
|
},
|
|
() => printConsumeNextSteps(a),
|
|
];
|
|
}
|
|
|
|
function handlerBindBlock(a: {
|
|
feature: string;
|
|
event: string;
|
|
publisher: string;
|
|
}): string {
|
|
const handlerFn = `on${pascalCase(a.publisher)}${pascalCase(a.event)}Handler`;
|
|
const eventConst = `${camel(a.event.replace(/\./g, "-"))}Event`;
|
|
const handlerSymbol = `${constantCase(a.feature)}_SYMBOLS.IOn${pascalCase(a.publisher)}${pascalCase(a.event)}Handler`;
|
|
const wrappedVar = `wrapped${pascalCase(a.publisher)}${pascalCase(a.event)}`;
|
|
const containerVar = `${camel(a.feature)}Container`;
|
|
return `// <gen:event-handlers>
|
|
// ${handlerFn} subscription — generated, edit the handler file (not this block) for behavior.
|
|
const ${wrappedVar} = withSpan(
|
|
tracer,
|
|
{ name: "${a.feature}.${handlerFn}", op: "event-handler" },
|
|
withCapture(
|
|
logger,
|
|
{
|
|
feature: "${a.feature}",
|
|
layer: "event-handler",
|
|
name: "${a.feature}.${handlerFn}",
|
|
},
|
|
${handlerFn}(),
|
|
),
|
|
);
|
|
if (${containerVar}.isBound(${handlerSymbol})) {
|
|
${containerVar}.unbind(${handlerSymbol});
|
|
}
|
|
${containerVar}.bind(${handlerSymbol}).toConstantValue(${wrappedVar});
|
|
bus.subscribe(${eventConst}, "${a.feature}", ${wrappedVar});`;
|
|
}
|
|
|
|
function printConsumeNextSteps(a: {
|
|
feature: string;
|
|
event: string;
|
|
publisher: string;
|
|
}): string {
|
|
const eventKebab = a.event.replace(/\./g, "-");
|
|
const eventConst = `${camel(a.event.replace(/\./g, "-"))}Event`;
|
|
return [
|
|
"",
|
|
"─────────────────────────────────────────────────────────────",
|
|
`Handler on-${a.publisher}-${eventKebab} scaffolded in ${a.feature}.`,
|
|
"",
|
|
"Next steps (manual):",
|
|
` 1. Implement the handler body in packages/${a.feature}/src/events/handlers/on-${a.publisher}-${eventKebab}.handler.ts`,
|
|
` 2. Add the import at the top of bind-production.ts AND bind-dev-seed.ts:`,
|
|
` import { ${eventConst} } from "@repo/${a.publisher}";`,
|
|
` import { ${camel(`on-${a.publisher}-${a.event.replace(/\./g, "-")}-handler`)} } from "../events/handlers/on-${a.publisher}-${eventKebab}.handler";`,
|
|
` 3. If your handler needs deps, extend the factory signature and pass them in the generated bind block.`,
|
|
` 4. Add "@repo/${a.publisher}": "workspace:*" to packages/${a.feature}/package.json dependencies (if not already present).`,
|
|
` 5. Verify: pnpm --filter @repo/${a.feature} lint typecheck test`,
|
|
"─────────────────────────────────────────────────────────────",
|
|
"",
|
|
].join("\n");
|
|
}
|
|
|
|
function printPublishNextSteps(a: { feature: string; event: string }): string {
|
|
const eventKebab = a.event.replace(/\./g, "-");
|
|
return [
|
|
"",
|
|
"─────────────────────────────────────────────────────────────",
|
|
`Event ${a.feature}.${a.event} contract scaffolded.`,
|
|
"",
|
|
"Next steps (manual):",
|
|
` 1. Fill in the schema in packages/${a.feature}/src/events/${eventKebab}.event.ts`,
|
|
` 2. Pick a use case in packages/${a.feature}/src/application/use-cases/ that should publish.`,
|
|
` - Add 'bus: IEventBus' to the factory signature.`,
|
|
` - Call 'await bus.publish(${camel(a.event.replace(/\./g, "-"))}Event, payload)' after success.`,
|
|
` - Update the use case's DI binding to inject the bus.`,
|
|
` 3. Verify: pnpm --filter @repo/${a.feature} lint typecheck test`,
|
|
"─────────────────────────────────────────────────────────────",
|
|
"",
|
|
].join("\n");
|
|
}
|
|
|
|
function realtimeChannelActions(a: {
|
|
feature: string;
|
|
channelSlug: string;
|
|
scope: string;
|
|
}): PlopTypes.ActionType[] {
|
|
const indexPath = `packages/${a.feature}/src/index.ts`;
|
|
const scopeLiteral = renderScopeLiteral(a.scope);
|
|
return [
|
|
() => {
|
|
assertAnchors(process.cwd(), indexPath, ["// <gen:realtime-channels>"]);
|
|
return `Anchors verified in ${indexPath}`;
|
|
},
|
|
{
|
|
type: "add",
|
|
path: `packages/${a.feature}/src/realtime/${a.channelSlug}.channel.ts`,
|
|
templateFile: "templates/realtime/channel/channel.ts.hbs",
|
|
data: { ...a, scopeLiteral },
|
|
},
|
|
{
|
|
type: "add",
|
|
path: `packages/${a.feature}/src/realtime/${a.channelSlug}.channel.test.ts`,
|
|
templateFile: "templates/realtime/channel/channel.test.ts.hbs",
|
|
data: { ...a, scopeLiteral },
|
|
},
|
|
{
|
|
type: "modify",
|
|
path: indexPath,
|
|
pattern: /\/\/ <gen:realtime-channels>/,
|
|
template: `// <gen:realtime-channels>\nexport {\n {{camelCase channelSlug}}Channel,\n {{camelCase channelSlug}}Schema,\n type {{pascalCase channelSlug}}Payload,\n} from "./realtime/{{kebabCase channelSlug}}.channel";`,
|
|
data: a,
|
|
},
|
|
() => printChannelNextSteps(a),
|
|
];
|
|
}
|
|
|
|
function renderScopeLiteral(scope: string): string {
|
|
if (scope === "public") return `"public"`;
|
|
if (scope === "authenticated") return `"authenticated"`;
|
|
if (scope.startsWith("role:"))
|
|
return `{ role: "${scope.slice("role:".length)}" }`;
|
|
if (scope === "user-scoped")
|
|
return `{ userScoped: true, template: "TODO_TEMPLATE" }`;
|
|
throw new Error(`unknown scope: ${scope}`);
|
|
}
|
|
|
|
function printChannelNextSteps(a: {
|
|
feature: string;
|
|
channelSlug: string;
|
|
}): string {
|
|
return [
|
|
"─────────────────────────────────────────────────────────────",
|
|
`Realtime channel ${a.feature}.${a.channelSlug} scaffolded.`,
|
|
"",
|
|
"Next steps (manual):",
|
|
` 1. Fill in the Zod schema in packages/${a.feature}/src/realtime/${a.channelSlug}.channel.ts`,
|
|
` 2. To broadcast: inject 'realtime: IRealtimeBroadcaster' into a use case factory and call`,
|
|
` realtime.broadcast(${a.channelSlug}Channel, payload)`,
|
|
` 3. To receive client-emitted messages on this channel: pnpm turbo gen realtime`,
|
|
` and pick "handler" mode with the same channel slug.`,
|
|
` 4. Verify: pnpm --filter @repo/${a.feature} lint typecheck test`,
|
|
"─────────────────────────────────────────────────────────────",
|
|
].join("\n");
|
|
}
|
|
|
|
function realtimeHandlerActions(a: {
|
|
feature: string;
|
|
channelSlug: string;
|
|
}): PlopTypes.ActionType[] {
|
|
const symbolFile = `packages/${a.feature}/src/di/symbols.ts`;
|
|
const bindProdFile = `packages/${a.feature}/src/di/bind-production.ts`;
|
|
const bindDevFile = `packages/${a.feature}/src/di/bind-dev-seed.ts`;
|
|
return [
|
|
() => {
|
|
assertAnchors(process.cwd(), symbolFile, [
|
|
"// <gen:realtime-handler-symbols>",
|
|
]);
|
|
assertAnchors(process.cwd(), bindProdFile, ["// <gen:realtime-handlers>"]);
|
|
assertAnchors(process.cwd(), bindDevFile, ["// <gen:realtime-handlers>"]);
|
|
return "All required anchors present";
|
|
},
|
|
{
|
|
type: "add",
|
|
path: `packages/${a.feature}/src/realtime/handlers/on-${a.channelSlug}.handler.ts`,
|
|
templateFile: "templates/realtime/handler/handler.ts.hbs",
|
|
data: a,
|
|
},
|
|
{
|
|
type: "add",
|
|
path: `packages/${a.feature}/src/realtime/handlers/on-${a.channelSlug}.handler.test.ts`,
|
|
templateFile: "templates/realtime/handler/handler.test.ts.hbs",
|
|
data: a,
|
|
},
|
|
{
|
|
type: "modify",
|
|
path: symbolFile,
|
|
pattern: /\/\/ <gen:realtime-handler-symbols>/,
|
|
template: `// <gen:realtime-handler-symbols>\n IOn${pascalCase(a.channelSlug)}Handler: Symbol.for("@repo/${a.feature}/on${pascalCase(a.channelSlug)}Handler"),`,
|
|
},
|
|
{
|
|
type: "modify",
|
|
path: bindProdFile,
|
|
pattern: /\/\/ <gen:realtime-handlers>/,
|
|
template: realtimeHandlerBindBlock(a),
|
|
},
|
|
{
|
|
type: "modify",
|
|
path: bindDevFile,
|
|
pattern: /\/\/ <gen:realtime-handlers>/,
|
|
template: realtimeHandlerBindBlock(a),
|
|
},
|
|
() => printHandlerNextSteps(a),
|
|
];
|
|
}
|
|
|
|
function realtimeHandlerBindBlock(a: {
|
|
feature: string;
|
|
channelSlug: string;
|
|
}): string {
|
|
const handlerFn = `on${pascalCase(a.channelSlug)}Handler`;
|
|
const channelConst = `${camel(a.channelSlug)}Channel`;
|
|
return `// <gen:realtime-handlers>
|
|
// ${handlerFn} — generated by gen realtime handler. Edit the handler file (not this block).
|
|
const wrapped${pascalCase(a.channelSlug)} = withSpan(
|
|
tracer,
|
|
{ name: "${a.feature}.${handlerFn}", op: "realtime-handler" },
|
|
withCapture(
|
|
logger,
|
|
{ feature: "${a.feature}", layer: "realtime-handler", name: "${a.feature}.${handlerFn}" },
|
|
${handlerFn}(),
|
|
),
|
|
);
|
|
realtimeRegistry.register({ descriptor: ${channelConst}, handler: wrapped${pascalCase(a.channelSlug)} });`;
|
|
}
|
|
|
|
function printHandlerNextSteps(a: {
|
|
feature: string;
|
|
channelSlug: string;
|
|
}): string {
|
|
return [
|
|
"─────────────────────────────────────────────────────────────",
|
|
`Realtime handler on-${a.channelSlug} scaffolded in ${a.feature}.`,
|
|
"",
|
|
"Next steps (manual):",
|
|
` 1. Implement the handler body in packages/${a.feature}/src/realtime/handlers/on-${a.channelSlug}.handler.ts`,
|
|
` 2. Add the imports to bind-production.ts AND bind-dev-seed.ts:`,
|
|
` import { ${camel(a.channelSlug)}Channel } from "../realtime/${a.channelSlug}.channel";`,
|
|
` import { on${pascalCase(a.channelSlug)}Handler } from "../realtime/handlers/on-${a.channelSlug}.handler";`,
|
|
` 3. If the handler needs deps, extend the factory signature and pass them in the bind block.`,
|
|
` 4. Verify: pnpm --filter @repo/${a.feature} lint typecheck test`,
|
|
"─────────────────────────────────────────────────────────────",
|
|
].join("\n");
|
|
}
|
|
|
|
function printTrpcNextSteps(): string {
|
|
return [
|
|
"─────────────────────────────────────────────────────────────",
|
|
"@repo/core-trpc scaffolded.",
|
|
"",
|
|
"Next steps (manual wiring — not generated):",
|
|
"",
|
|
" 1. pnpm install # link the new workspace package",
|
|
"",
|
|
" 2. apps/web-next/src/app/providers.tsx:",
|
|
' - import { NextTrpcProvider } from "@repo/core-trpc/next";',
|
|
" - wrap children: <NextTrpcProvider trpcUrl=\"/api/trpc\">{children}</NextTrpcProvider>",
|
|
"",
|
|
" 3. apps/web-next/src/app/api/trpc/[trpc]/route.ts:",
|
|
' - import { fetchRequestHandler } from "@trpc/server/adapters/fetch";',
|
|
' - import { appRouter } from "@repo/core-api";',
|
|
" - export GET and POST handlers using fetchRequestHandler",
|
|
"",
|
|
" 4. apps/web-tanstack/src/routes/__root.tsx:",
|
|
' - import { TanstackTrpcProvider } from "@repo/core-trpc/tanstack";',
|
|
" - wrap <Outlet /> with <TanstackTrpcProvider trpcUrl=\"http://localhost:3000/api/trpc\">",
|
|
"",
|
|
" 5. Add @repo/core-trpc to apps/web-next/package.json and apps/web-tanstack/package.json dependencies",
|
|
"",
|
|
" 6. pnpm typecheck && pnpm lint && pnpm test",
|
|
"─────────────────────────────────────────────────────────────",
|
|
].join("\n");
|
|
}
|
|
|
|
function printUiNextSteps(): string {
|
|
return [
|
|
"─────────────────────────────────────────────────────────────",
|
|
"@repo/core-ui scaffolded.",
|
|
"",
|
|
"Next steps (manual wiring — not generated):",
|
|
"",
|
|
" 1. pnpm install # link the new workspace package",
|
|
"",
|
|
" 2. apps/web-next/package.json:",
|
|
' - add "@repo/core-ui": "workspace:*" to dependencies',
|
|
"",
|
|
" 3. apps/storybook/.storybook/main.ts:",
|
|
' - add "../../../packages/core-ui/src/**/*.stories.@(ts|tsx)" to the stories array',
|
|
"",
|
|
" 4. apps/storybook/.storybook/preview.ts:",
|
|
' - add: import "@repo/core-ui/styles/globals.css";',
|
|
"",
|
|
" 5. apps/storybook/package.json:",
|
|
' - add "@repo/core-ui": "workspace:*" to dependencies',
|
|
"",
|
|
" 6. apps/web-tanstack/package.json:",
|
|
' - add "@repo/core-ui": "workspace:*" to dependencies',
|
|
" - when TanStack Start gains a real vite.config.ts, add @repo/core-ui",
|
|
" to optimizeDeps.include",
|
|
"",
|
|
" 7. pnpm typecheck && pnpm lint && pnpm test",
|
|
"─────────────────────────────────────────────────────────────",
|
|
].join("\n");
|
|
}
|
|
|
|
function printEventsNextSteps(): string {
|
|
return [
|
|
"─────────────────────────────────────────────────────────────",
|
|
"@repo/core-events scaffolded.",
|
|
"",
|
|
"Next steps (manual wiring — not generated):",
|
|
"",
|
|
" 1. pnpm install # link the new workspace package",
|
|
"",
|
|
" 2. apps/web-next/src/server/bind-production.ts:",
|
|
' - import { InMemoryEventBus, PayloadJobsEventBus, type IEventBus } from "@repo/core-events";',
|
|
" - construct bus in resolveEventsAndJobsProduction + resolveEventsAndJobsDevSeed",
|
|
" - add bus to BindProductionContext generic arg and ctx object",
|
|
" - update BindAllDeps to include bus field",
|
|
"",
|
|
" 3. Add @repo/core-events to each feature package.json that publishes or subscribes events",
|
|
"",
|
|
" 4. pnpm typecheck && pnpm lint && pnpm test",
|
|
"─────────────────────────────────────────────────────────────",
|
|
].join("\n");
|
|
}
|
|
|
|
function printRealtimeNextSteps(): string {
|
|
return [
|
|
"─────────────────────────────────────────────────────────────",
|
|
"@repo/core-realtime scaffolded.",
|
|
"",
|
|
"Next steps (manual wiring — not generated):",
|
|
"",
|
|
" 1. pnpm install # link the new workspace package",
|
|
"",
|
|
" 2. apps/web-next/server.ts:",
|
|
' - import { Server as IOServer } from "socket.io";',
|
|
' - import { RealtimeHandlerRegistry, SocketIORealtimeBroadcaster, SocketIORealtimeServer } from "@repo/core-realtime";',
|
|
" - create IOServer on the http server, construct broadcaster + registry",
|
|
" - call bindAll({ realtime: broadcaster, realtimeRegistry: registry })",
|
|
" - start SocketIORealtimeServer",
|
|
"",
|
|
" 3. apps/web-next/src/server/bind-production.ts:",
|
|
' - import { IRealtimeBroadcaster, IRealtimeHandlerRegistry, InMemoryRealtimeBroadcaster, RealtimeHandlerRegistry } from "@repo/core-realtime";',
|
|
" - add realtime + realtimeRegistry to BindAllDeps",
|
|
" - update BindProductionContext generic args",
|
|
" - add maybeRegisterRealtimePing + bindRealtimeBridge functions",
|
|
"",
|
|
" 4. Add @repo/core-realtime to each feature package.json that uses realtime",
|
|
"",
|
|
" 5. pnpm typecheck && pnpm lint && pnpm test",
|
|
"─────────────────────────────────────────────────────────────",
|
|
].join("\n");
|
|
}
|
|
|
|
function printAuditNextSteps(): string {
|
|
return [
|
|
"─────────────────────────────────────────────────────────────",
|
|
"@repo/core-audit scaffolded into packages/core-audit/.",
|
|
"",
|
|
"Manual wiring required (compliance-critical):",
|
|
"",
|
|
"1. Set AUDIT_PSEUDONYM_SALT env var (production REQUIRED):",
|
|
' export AUDIT_PSEUDONYM_SALT="$(openssl rand -hex 32)"',
|
|
" Add to your deployment secrets manager.",
|
|
"",
|
|
"2. Mount the audit-logs Payload collection in packages/core-cms/src/payload.config.ts:",
|
|
' import { auditLogsCollection } from "@repo/core-audit/collection";',
|
|
" // collections: [..., auditLogsCollection],",
|
|
"",
|
|
"3. Mount the admin tRPC router in packages/core-api/src/root.ts:",
|
|
' import { createAuditRouter } from "@repo/core-audit/api";',
|
|
" // const { auditLog } = bindAudit(container, { payloadConfig, sinks: [\"payload\", \"stdout\"] });",
|
|
" // routers: { ..., audit: createAuditRouter(auditLog) },",
|
|
"",
|
|
"4. Bind audit in apps/web-next/src/server/bind-production.ts:",
|
|
' const { bindAudit } = await import("@repo/core-audit/di");',
|
|
" const { auditLog } = bindAudit(sharedContainer, {",
|
|
" payloadConfig: resolvedConfig,",
|
|
' sinks: ["payload", "stdout"],',
|
|
" });",
|
|
"",
|
|
"5. Install user-collection hooks (recommended for DPA compliance):",
|
|
" In packages/auth/src/di/bind-production.ts, gate on ctx.auditLog:",
|
|
" if (ctx.auditLog) {",
|
|
' const { createAuditErasureHook, createAuditAfterReadHook } =',
|
|
' await import("@repo/core-audit/hooks");',
|
|
" // wire onto users collection — see docs/guides/audit-and-compliance.md",
|
|
" }",
|
|
"",
|
|
"6. Set up a log shipper (Vector / Fluent Bit) to forward stdout JSON to",
|
|
" your aggregator. See docs/guides/audit-and-compliance.md for configs.",
|
|
"",
|
|
"7. Verify:",
|
|
" pnpm install",
|
|
" pnpm lint && pnpm typecheck && pnpm test",
|
|
" pnpm turbo boundaries",
|
|
"",
|
|
"See docs/guides/audit-and-compliance.md for the full guide.",
|
|
"─────────────────────────────────────────────────────────────",
|
|
].join("\n");
|
|
}
|
|
|
|
function coreUiComponentActions(a: {
|
|
tier: "atom" | "molecule" | "organism";
|
|
name: string;
|
|
}): PlopTypes.ActionType[] {
|
|
const tierPlural = `${a.tier}s` as const;
|
|
const tierTitle = tierPlural[0]!.toUpperCase() + tierPlural.slice(1);
|
|
const tierBarrel = `packages/core-ui/src/${tierPlural}/index.ts`;
|
|
const componentDir = `packages/core-ui/src/${tierPlural}/{{kebabCase name}}`;
|
|
|
|
return [
|
|
// 1. Guard: core-ui must be scaffolded
|
|
() => {
|
|
const pkgRoot = join(process.cwd(), "packages", "core-ui");
|
|
if (!existsSync(pkgRoot)) {
|
|
throw new Error(
|
|
`packages/core-ui/ does not exist. Run \`pnpm turbo gen core-package ui\` first.`,
|
|
);
|
|
}
|
|
return "Guard passed — packages/core-ui exists.";
|
|
},
|
|
|
|
// 2. Guard: tier barrel must have its anchor
|
|
() => {
|
|
assertAnchors(process.cwd(), tierBarrel, [`// <gen:${tierPlural}>`]);
|
|
return `Anchor // <gen:${tierPlural}> present in ${tierBarrel}.`;
|
|
},
|
|
|
|
// 3. Emit the 4 component files
|
|
{
|
|
type: "add",
|
|
path: `${componentDir}/{{kebabCase name}}.tsx`,
|
|
templateFile: "templates/core-ui-component/component.tsx.hbs",
|
|
data: { name: a.name },
|
|
},
|
|
{
|
|
type: "add",
|
|
path: `${componentDir}/{{kebabCase name}}.stories.tsx`,
|
|
templateFile: "templates/core-ui-component/component.stories.tsx.hbs",
|
|
data: { name: a.name, tierTitle },
|
|
},
|
|
{
|
|
type: "add",
|
|
path: `${componentDir}/{{kebabCase name}}.test.tsx`,
|
|
templateFile: "templates/core-ui-component/component.test.tsx.hbs",
|
|
data: { name: a.name },
|
|
},
|
|
{
|
|
type: "add",
|
|
path: `${componentDir}/index.ts`,
|
|
templateFile: "templates/core-ui-component/index.ts.hbs",
|
|
data: { name: a.name },
|
|
},
|
|
|
|
// 4. Splice export into tier barrel.
|
|
// The `template` string is itself Handlebars-rendered by plop, so
|
|
// {{pascalCase name}} and {{kebabCase name}} are resolved at apply-
|
|
// time. This matches the pattern used by the `realtime` and `event`
|
|
// generators (no kebabCase JS helper exists in config.ts).
|
|
{
|
|
type: "modify",
|
|
path: tierBarrel,
|
|
pattern: new RegExp(`// <gen:${tierPlural}>`),
|
|
template: `// <gen:${tierPlural}>\nexport { {{pascalCase name}}, type {{pascalCase name}}Props } from "./{{kebabCase name}}/index";`,
|
|
},
|
|
|
|
// 5. Print next-steps
|
|
() => printCoreUiComponentNextSteps(a),
|
|
];
|
|
}
|
|
|
|
function printCoreUiComponentNextSteps(a: {
|
|
tier: "atom" | "molecule" | "organism";
|
|
name: string;
|
|
}): string {
|
|
const tierPlural = `${a.tier}s`;
|
|
const kebab = a.name
|
|
.replace(/([a-z0-9])([A-Z])/g, "$1-$2")
|
|
.replace(/([A-Z]+)([A-Z][a-z])/g, "$1-$2")
|
|
.toLowerCase();
|
|
const pascal = a.name;
|
|
return [
|
|
"─────────────────────────────────────────────────────────────",
|
|
`${pascal} scaffolded into packages/core-ui/src/${tierPlural}/${kebab}/.`,
|
|
"",
|
|
"Next steps (manual):",
|
|
` 1. Implement the component body in ${kebab}.tsx (currently a div`,
|
|
` passthrough — change the element/type to fit, add variants/sizes`,
|
|
` if needed; see button.tsx for the canonical rich pattern).`,
|
|
` 2. Flesh out the Default story in ${kebab}.stories.tsx; add variant`,
|
|
` stories if your component takes a variant prop.`,
|
|
` 3. Tighten the tests in ${kebab}.test.tsx beyond the smoke-test`,
|
|
` baseline.`,
|
|
"",
|
|
"Verify:",
|
|
" pnpm --filter @repo/core-ui lint typecheck test",
|
|
" pnpm dev --filter @repo/storybook # view in Storybook",
|
|
"─────────────────────────────────────────────────────────────",
|
|
].join("\n");
|
|
}
|
|
|
|
// Local helpers used inside the printNextSteps action — Plop's helpers aren't
|
|
// available outside template strings, so we replicate the bits we need.
|
|
function cap(input: string): string {
|
|
return input
|
|
.split(/[-_\s]+/)
|
|
.filter(Boolean)
|
|
.map((s) => s[0]!.toUpperCase() + s.slice(1))
|
|
.join("");
|
|
}
|
|
function camel(input: string): string {
|
|
const p = cap(input);
|
|
return p ? p[0]!.toLowerCase() + p.slice(1) : "";
|
|
}
|
|
function pascalCase(input: string): string {
|
|
// Same as `cap` but also splits on dots, so "user.signed-up" → "UserSignedUp".
|
|
return input
|
|
.split(/[-_.\s]+/)
|
|
.filter(Boolean)
|
|
.map((s) => s[0]!.toUpperCase() + s.slice(1))
|
|
.join("");
|
|
}
|
|
function constantCase(input: string): string {
|
|
return input.toUpperCase().replace(/[-.\s]/g, "_");
|
|
}
|