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//` * 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 `` 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/ and packages//):", 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 `` 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 { 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 { 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//src/events/.event.ts` (+ test) and * re-exports it from the feature's public surface at the `// ` * 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); }, }); /** * Turbo generator: `reader` * * Scaffolds a cross-feature reader under * `packages//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 Q0–Q3). */ 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//src/realtime/.channel.ts` (+ test) and * re-exports it from the feature's public surface at the * `// ` anchor. * Mode `handler` scaffolds an inbound handler + DI binding at the * `// ` 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 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: `.tsx`, `.stories.tsx`, * `.test.tsx`, `index.ts`. Splices the new export into the matching * tier barrel via the `// 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, ["// "]); assertAnchors(process.cwd(), bindProdFile, ["// "]); assertAnchors(process.cwd(), bindDevFile, ["// "]); assertAnchors(process.cwd(), cmsIndexFile, ["// "]); 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: /\/\/ /, template: `// \nexport { ${camel(a.job)}Task } from "./jobs/${a.job}.task";`, }, { type: "modify", path: symbolFile, pattern: /\/\/ /, template: `// \n I${pascalCase(a.job)}Job: Symbol.for("@repo/${a.feature}/${camel(a.job)}Job"),`, }, { type: "modify", path: bindProdFile, pattern: /\/\/ /, template: jobBindBlock(a), }, { type: "modify", path: bindDevFile, pattern: /\/\/ /, 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 `// 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 fs = require("node:fs"); const pkgPath = join(process.cwd(), pkgJsonPath); const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8")); if (!pkg.exports) pkg.exports = {}; pkg.exports["./reader"] = "./src/integrations/readers/index.ts"; fs.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, ["// "]); 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: /\/\/ /, template: `// \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, [ "// ", ]); assertAnchors(process.cwd(), bindProdFile, ["// "]); assertAnchors(process.cwd(), bindDevFile, ["// "]); assertAnchors(process.cwd(), cmsIndexFile, ["// "]); 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: /\/\/ /, template: `// \n IOn${pascalCase(a.publisher)}${pascalCase(a.event)}Handler: Symbol.for("@repo/${a.feature}/${handlerName}"),`, }, { type: "modify", path: bindProdFile, pattern: /\/\/ /, template: handlerBindBlock(a), }, { type: "modify", path: bindDevFile, pattern: /\/\/ /, template: handlerBindBlock(a), }, { type: "modify", path: cmsIndexFile, pattern: /\/\/ /, template: `// \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 `// // ${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, ["// "]); 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: /\/\/ /, template: `// \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, [ "// ", ]); assertAnchors(process.cwd(), bindProdFile, [ "// ", ]); assertAnchors(process.cwd(), bindDevFile, ["// "]); 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: /\/\/ /, template: `// \n IOn${pascalCase(a.channelSlug)}Handler: Symbol.for("@repo/${a.feature}/on${pascalCase(a.channelSlug)}Handler"),`, }, { type: "modify", path: bindProdFile, pattern: /\/\/ /, template: realtimeHandlerBindBlock(a), }, { type: "modify", path: bindDevFile, pattern: /\/\/ /, 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 `// // ${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: {children}', "", " 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 with ', "", " 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, [`// `]); return `Anchor // 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(`// `), template: `// \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, "_"); }