Files
agentic-dev/turbo/generators/config.ts
Danijel Martinek e378c950a9 feat(core-dsr): scaffold package + GDPR DSR interfaces and types
- Add pnpm turbo gen core-package dsr generator template and register
  dsr in CORE_PACKAGE_GENERATORS / choices list
- Run generator to produce packages/core-dsr/ shell
- Define IDataExport (Art. 15/20), IDataDelete (Art. 17),
  IDataRectify (Art. 16), IProcessingRestriction (Art. 18) interfaces
- Add UserDataBundle and DeletionCertificate types in dsr-types.ts
- Ship core-dsr/contexts/user-data.jsonld schema.org JSON-LD @context
- Wire @repo/core-dsr into transpilePackages (web-next)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 19:32:03 +00:00

1726 lines
66 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";
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 (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 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",
},
// 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: `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 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 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, "_");
}