Files
agentic-dev/turbo/generators/config.ts
Danijel Martinek a17b984675 fix(generators): run UI tests, guard optional bus, harden e2e clones
Three generator fixes:
- templates/feature/vitest.config.ts.hbs lacked an include for
  .test.{ts,tsx}; the node base only includes .test.ts, so scaffolded
  UI component tests never executed
- gen event consume emitted an unguarded bus.subscribe although
  ctx.bus is optional in BindContext — now wrapped in if (bus) {}
- e2e repo clones now exclude /dist and /.next build outputs, and
  every dep-stripping e2e strips the scaffolded package from EVERY
  workspace package.json via globSync instead of a hardcoded dependent
  list that drifts as packages gain or drop the dependency

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 16:42:46 +02:00

1858 lines
71 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { existsSync, readFileSync, writeFileSync } 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";
import { registerFeatureInReleasePlease } from "./lib/release-please-utils.js";
/**
* Turbo generator: `feature`
*
* Scaffolds a feature package under `packages/<name>/`
* matching the shape of the existing `navigation` reference feature.
*
* Scope (intentionally limited):
* - Single entity, single use case (`getX`)
* - Skips Payload CMS collection/global templates — emits only the
* integrations/cms/index.ts barrel (with the `<gen:job-tasks>` anchor)
* - 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 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}}/stryker.config.json",
templateFile: "templates/feature/stryker.config.json.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",
},
// Integrations: cms barrel — carries no collections (the generator skips
// Payload templates) but provides the `<gen:job-tasks>` anchor that
// `gen job` and `gen event consume` splice into.
{
type: "add",
path: "packages/{{kebabCase name}}/src/integrations/cms/index.ts",
templateFile: "templates/feature/src/integrations/cms/index.ts.hbs",
},
// Seeds + factories + contracts (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 (./ui subpath kept reserved)
{
type: "add",
path: "packages/{{kebabCase name}}/src/ui/index.ts",
templateFile: "templates/feature/src/ui/index.ts.hbs",
},
// CHANGELOG (release-please will append future versions; v0.1.0 seed)
{
type: "add",
path: "packages/{{kebabCase name}}/CHANGELOG.md",
templateFile: "templates/feature/CHANGELOG.md.hbs",
},
// Register the new feature in release-please tracking (ADR-021).
// Adds an entry to .release-please-manifest.json and a per-package
// block to release-please-config.json. Idempotent + sorted so reruns
// are stable. Throws if either file is missing.
function registerInReleasePlease(
answers: Record<string, unknown>,
): string {
const a = answers as { name: string };
const repoRoot = process.cwd();
const { manifestChanged, configChanged } =
registerFeatureInReleasePlease(repoRoot, a.name);
if (manifestChanged || configChanged) {
return `Registered @repo/${a.name} in release-please tracking (manifest + config)`;
}
return `@repo/${a.name} already tracked by release-please (no changes)`;
},
// 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: `reader`
*
* Scaffolds a cross-feature reader under
* `packages/<feature>/src/integrations/readers/` and adds the `./reader`
* export subpath to the feature's `package.json`. The reader interface is
* the public contract; the implementation and test are internal.
*
* See ADR-026 for the full design (rules Q0Q3).
*/
plop.setGenerator("reader", {
description:
"Scaffold a cross-feature reader interface + implementation (ADR-026)",
prompts: [
{
type: "input",
name: "feature",
message: "Feature that will EXPOSE the reader (kebab-case):",
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`;
}
const readersDir = join(
process.cwd(),
"packages",
input,
"src",
"integrations",
"readers",
);
if (existsSync(readersDir)) {
return `packages/${input}/src/integrations/readers/ already exists — this feature already has a reader`;
}
return true;
},
},
],
actions(answers) {
const a = answers as { feature: string };
return readerActions(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/docs/library-decisions",
"docs/library-decisions",
{ force: true },
),
...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"),
...emitTemplateTree(
"core-package/events/docs/library-decisions",
"docs/library-decisions",
{ force: true },
),
() => {
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"),
...emitTemplateTree(
"core-package/trpc/docs/library-decisions",
"docs/library-decisions",
{ force: true },
),
() => {
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"),
...emitTemplateTree(
"core-package/ui/docs/library-decisions",
"docs/library-decisions",
{ force: true },
),
() => {
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"),
...emitTemplateTree(
"core-package/audit/docs/library-decisions",
"docs/library-decisions",
{ force: true },
),
() => {
addToTranspilePackages(
"apps/web-next/next.config.mjs",
"@repo/core-audit",
);
return "Added @repo/core-audit to transpilePackages.";
},
printAuditNextSteps,
],
analytics: () => [
() => {
assertOptionalPackageNotPresent("core-analytics");
return "Guard passed — packages/core-analytics does not exist yet.";
},
...emitTemplateTree(
"core-package/analytics",
"packages/core-analytics",
),
() => {
addToTranspilePackages(
"apps/web-next/next.config.mjs",
"@repo/core-analytics",
);
return "Added @repo/core-analytics to transpilePackages.";
},
printAnalyticsNextSteps,
],
consent: () => [
() => {
assertOptionalPackageNotPresent("core-consent");
return "Guard passed — packages/core-consent does not exist yet.";
},
...emitTemplateTree("core-package/consent", "packages/core-consent"),
() => {
addToTranspilePackages(
"apps/web-next/next.config.mjs",
"@repo/core-consent",
);
return "Added @repo/core-consent to transpilePackages.";
},
printConsentNextSteps,
],
dsr: () => [
() => {
assertOptionalPackageNotPresent("core-dsr");
return "Guard passed — packages/core-dsr does not exist yet.";
},
...emitTemplateTree("core-package/dsr", "packages/core-dsr"),
() => {
addToTranspilePackages(
"apps/web-next/next.config.mjs",
"@repo/core-dsr",
);
return "Added @repo/core-dsr to transpilePackages.";
},
printDsrNextSteps,
],
};
plop.setGenerator("core-package", {
description:
"Scaffold an optional core package (realtime, events, trpc, ui, audit, analytics, consent, dsr)",
prompts: [
{
type: "list",
name: "name",
message: "Which optional core package?",
choices: [
"analytics",
"audit",
"consent",
"dsr",
"events",
"realtime",
"trpc",
"ui",
],
},
],
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 readerActions(a: { feature: string }): PlopTypes.ActionType[] {
const base = `packages/${a.feature}/src/integrations/readers`;
const pkgJsonPath = `packages/${a.feature}/package.json`;
return [
{
type: "add",
path: `${base}/${a.feature}.reader.interface.ts`,
templateFile: "templates/reader/reader.interface.ts.hbs",
data: a,
},
{
type: "add",
path: `${base}/${a.feature}.reader.ts`,
templateFile: "templates/reader/reader.ts.hbs",
data: a,
},
{
type: "add",
path: `${base}/${a.feature}.reader.test.ts`,
templateFile: "templates/reader/reader.test.ts.hbs",
data: a,
},
{
type: "add",
path: `${base}/index.ts`,
templateFile: "templates/reader/index.ts.hbs",
data: a,
},
// Add ./reader subpath to package.json exports map
() => {
const pkgPath = join(process.cwd(), pkgJsonPath);
const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
if (!pkg.exports) pkg.exports = {};
pkg.exports["./reader"] = "./src/integrations/readers/index.ts";
writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + "\n");
return `Added "./reader" export to ${pkgJsonPath}`;
},
() => printReaderNextSteps(a),
];
}
function printReaderNextSteps(a: { feature: string }): string {
const pascal = pascalCase(a.feature);
return [
"",
"─────────────────────────────────────────────────────────────",
` Reader scaffolded for @repo/${a.feature}`,
"─────────────────────────────────────────────────────────────",
"",
" Next steps:",
"",
` 1. Add methods to I${pascal}Reader interface:`,
` packages/${a.feature}/src/integrations/readers/${a.feature}.reader.interface.ts`,
"",
` 2. Implement methods in ${pascal}Reader (delegate to use cases):`,
` packages/${a.feature}/src/integrations/readers/${a.feature}.reader.ts`,
"",
` 3. Construct the reader in bind-production.ts and return it:`,
` const reader = new ${pascal}Reader(/* injected use cases */);`,
` return { reader };`,
"",
` 4. In bindAll(), pass the reader to consuming features:`,
` const ${a.feature}Result = bindProduction${pascal}(ctx);`,
` bindProductionConsumer(ctx, { ${a.feature}Reader: ${a.feature}Result.reader });`,
"",
` 5. In consuming features' manifest, declare: reads: ["${a.feature}"]`,
"",
" Rules: Q0 (cross-feature only), Q1 (interface public, impl private),",
" Q2 (read-only — wraps only mutates:false use cases),",
" Q3 (reader cycles are a design error)",
"",
"─────────────────────────────────────────────────────────────",
].join("\n");
}
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});
// ctx.bus is an OPTIONAL core (guard per CLAUDE.md binder conventions).
if (bus) {
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 printAnalyticsNextSteps(): string {
return [
"─────────────────────────────────────────────────────────────",
"@repo/core-analytics scaffolded into packages/core-analytics/.",
"",
"Next steps:",
"",
" 1. pnpm install # link the new workspace package",
"",
" 2. Implement IAnalytics + NoopAnalytics in packages/core-analytics/src/",
" (see story 01-scaffold-core-analytics-package bullet 2)",
"",
" 3. Add @repo/core-analytics to feature package.json files that need analytics",
"",
" 4. Wire IAnalytics into apps/web-next/src/server/bind-production.ts:",
' - import { NoopAnalytics, type IAnalytics } from "@repo/core-analytics";',
" - add analytics field to BindAllDeps and BindProductionContext",
" - pass NoopAnalytics (or a vendor adapter) into each feature binder",
"",
" 5. pnpm typecheck && pnpm lint && pnpm test",
"─────────────────────────────────────────────────────────────",
].join("\n");
}
function printConsentNextSteps(): string {
return [
"─────────────────────────────────────────────────────────────",
"@repo/core-consent scaffolded into packages/core-consent/.",
"",
"Next steps:",
"",
" 1. pnpm install # link the new workspace package",
"",
" 2. Implement consent-types.ts, consent.interface.ts, and withConsent",
" in packages/core-consent/src/ (see story 03-core-consent-foundation)",
"",
" 3. Add @repo/core-consent to feature package.json files that need consent",
"",
" 4. Wire IConsent into apps/web-next/src/server/bind-production.ts:",
' - import { type IConsent } from "@repo/core-consent";',
" - add consent field to BindAllDeps and BindProductionContext",
" - pass the consent instance into each feature binder",
"",
" 5. pnpm typecheck && pnpm lint && pnpm test",
"─────────────────────────────────────────────────────────────",
].join("\n");
}
function printDsrNextSteps(): string {
return [
"─────────────────────────────────────────────────────────────",
"@repo/core-dsr scaffolded into packages/core-dsr/.",
"",
"Next steps:",
"",
" 1. pnpm install # link the new workspace package",
"",
" 2. Implement DSR interfaces and types in packages/core-dsr/src/",
" (see story 06-core-dsr)",
"",
" 3. Add @repo/core-dsr to feature package.json files that need DSR",
"",
" 4. pnpm typecheck && pnpm lint && pnpm test",
"─────────────────────────────────────────────────────────────",
].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, "_");
}