- Adds ui.snapshot.json (28 entries) computed from packages/core-ui - Wires CORE_PACKAGE_GENERATORS["ui"]: guard + emitTemplateTree + addToTranspilePackages + printUiNextSteps (web-tanstack/storybook wiring is printed, not generated — resists templating) - Adds "ui" to the core-package generator choices list - Adds core-package-ui.e2e.test.ts (byte-identical reconstruction) — passes in 14.6 s Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1308 lines
50 KiB
TypeScript
1308 lines
50 KiB
TypeScript
import { existsSync } from "node:fs";
|
|
import { join } from "node:path";
|
|
import type { PlopTypes } from "@turbo/gen";
|
|
import { assertAnchors } from "./lib/anchor-validate.js";
|
|
import {
|
|
assertOptionalPackageNotPresent,
|
|
addToTranspilePackages,
|
|
splicePluginRulesAt,
|
|
splicePluginImportsAt,
|
|
addBoundariesEntry,
|
|
emitTemplateTree,
|
|
} from "./lib/core-package-utils.js";
|
|
|
|
/**
|
|
* Turbo generator: `feature`
|
|
*
|
|
* Scaffolds a Lazar-conformant feature package under `packages/<name>/`
|
|
* matching the shape of the existing `navigation` reference feature.
|
|
*
|
|
* Phase 1 scope (intentionally limited):
|
|
* - Single entity, single use case (`getX`)
|
|
* - Skips Payload CMS collection/global templates (integrations/cms/**)
|
|
* - Skips UI query helpers (ui/query.ts) — emits an empty barrel
|
|
* - Skips faker-driven factories — emits empty stubs
|
|
* - Skips multi-entity / multi-use-case
|
|
* - Skips aggregator wiring (core-api/root.ts, core-cms/**, apps/web-next/server/bind-production.ts)
|
|
*
|
|
* After running, the developer must hand-wire the new feature into:
|
|
* - apps/web-next/src/server/bind-production.ts (bindAll dispatcher)
|
|
* - packages/core-api/src/root.ts (mount on app router)
|
|
* - packages/core-cms/... (when CMS templates are added later)
|
|
*
|
|
* The generator prints these manual steps when it finishes.
|
|
*/
|
|
export default function generator(plop: PlopTypes.NodePlopAPI): void {
|
|
// Handlebars helper used by templates that branch on prompt values
|
|
// (e.g. `gen job`'s inputShape void/typed split).
|
|
plop.setHelper("eq", (a: unknown, b: unknown) => a === b);
|
|
|
|
plop.setGenerator("feature", {
|
|
description:
|
|
"Scaffold a Lazar-conformant feature package (single entity / single use case)",
|
|
prompts: [
|
|
{
|
|
type: "input",
|
|
name: "name",
|
|
message:
|
|
"Feature package name (kebab-case, becomes @repo/<name> and packages/<name>/):",
|
|
validate: (input: string) => {
|
|
if (!input) return "Required";
|
|
if (!/^[a-z][a-z0-9-]*$/.test(input)) {
|
|
return "Must be kebab-case (lowercase letters, digits, hyphens; must start with a letter)";
|
|
}
|
|
return true;
|
|
},
|
|
},
|
|
{
|
|
type: "input",
|
|
name: "entity",
|
|
message:
|
|
"Entity name (PascalCase singular, e.g. 'Widget' for a getWidget use case):",
|
|
validate: (input: string) => {
|
|
if (!input) return "Required";
|
|
if (!/^[A-Z][A-Za-z0-9]*$/.test(input)) {
|
|
return "Must be PascalCase (e.g. Widget, BlogPost)";
|
|
}
|
|
return true;
|
|
},
|
|
},
|
|
{
|
|
type: "input",
|
|
name: "entityPlural",
|
|
message:
|
|
"Entity plural slug (kebab-case, used for Payload collection slug; e.g. 'widgets'):",
|
|
validate: (input: string) => {
|
|
if (!input) return "Required";
|
|
if (!/^[a-z][a-z0-9-]*$/.test(input)) {
|
|
return "Must be kebab-case";
|
|
}
|
|
return true;
|
|
},
|
|
},
|
|
],
|
|
actions: [
|
|
// Top-level package files
|
|
{
|
|
type: "add",
|
|
path: "packages/{{kebabCase name}}/package.json",
|
|
templateFile: "templates/feature/package.json.hbs",
|
|
},
|
|
{
|
|
type: "add",
|
|
path: "packages/{{kebabCase name}}/tsconfig.json",
|
|
templateFile: "templates/feature/tsconfig.json.hbs",
|
|
},
|
|
{
|
|
type: "add",
|
|
path: "packages/{{kebabCase name}}/vitest.config.ts",
|
|
templateFile: "templates/feature/vitest.config.ts.hbs",
|
|
},
|
|
{
|
|
type: "add",
|
|
path: "packages/{{kebabCase name}}/eslint.config.js",
|
|
templateFile: "templates/feature/eslint.config.js.hbs",
|
|
},
|
|
{
|
|
type: "add",
|
|
path: "packages/{{kebabCase name}}/turbo.json",
|
|
templateFile: "templates/feature/turbo.json.hbs",
|
|
},
|
|
{
|
|
type: "add",
|
|
path: "packages/{{kebabCase name}}/AGENTS.md",
|
|
templateFile: "templates/feature/AGENTS.md.hbs",
|
|
},
|
|
|
|
// Public surface
|
|
{
|
|
type: "add",
|
|
path: "packages/{{kebabCase name}}/src/index.ts",
|
|
templateFile: "templates/feature/src/index.ts.hbs",
|
|
},
|
|
|
|
// Entities — models + errors
|
|
{
|
|
type: "add",
|
|
path: "packages/{{kebabCase name}}/src/entities/models/{{kebabCase entity}}.ts",
|
|
templateFile: "templates/feature/src/entities/models/entity.ts.hbs",
|
|
},
|
|
{
|
|
type: "add",
|
|
path: "packages/{{kebabCase name}}/src/entities/models/{{kebabCase entity}}.test.ts",
|
|
templateFile: "templates/feature/src/entities/models/entity.test.ts.hbs",
|
|
},
|
|
{
|
|
type: "add",
|
|
path: "packages/{{kebabCase name}}/src/entities/errors/common.ts",
|
|
templateFile: "templates/feature/src/entities/errors/common.ts.hbs",
|
|
},
|
|
{
|
|
type: "add",
|
|
path: "packages/{{kebabCase name}}/src/entities/errors/{{kebabCase entity}}.ts",
|
|
templateFile: "templates/feature/src/entities/errors/entity.ts.hbs",
|
|
},
|
|
|
|
// Application layer
|
|
{
|
|
type: "add",
|
|
path: "packages/{{kebabCase name}}/src/application/repositories/{{kebabCase entity}}.repository.interface.ts",
|
|
templateFile:
|
|
"templates/feature/src/application/repositories/entity.repository.interface.ts.hbs",
|
|
},
|
|
{
|
|
type: "add",
|
|
path: "packages/{{kebabCase name}}/src/application/use-cases/get-{{kebabCase entity}}.use-case.ts",
|
|
templateFile:
|
|
"templates/feature/src/application/use-cases/get-entity.use-case.ts.hbs",
|
|
},
|
|
{
|
|
type: "add",
|
|
path: "packages/{{kebabCase name}}/src/application/use-cases/get-{{kebabCase entity}}.use-case.test.ts",
|
|
templateFile:
|
|
"templates/feature/src/application/use-cases/get-entity.use-case.test.ts.hbs",
|
|
},
|
|
|
|
// Infrastructure
|
|
{
|
|
type: "add",
|
|
path: "packages/{{kebabCase name}}/src/infrastructure/repositories/{{kebabCase entity}}.repository.ts",
|
|
templateFile:
|
|
"templates/feature/src/infrastructure/repositories/entity.repository.ts.hbs",
|
|
},
|
|
{
|
|
type: "add",
|
|
path: "packages/{{kebabCase name}}/src/infrastructure/repositories/{{kebabCase entity}}.repository.test.ts",
|
|
templateFile:
|
|
"templates/feature/src/infrastructure/repositories/entity.repository.test.ts.hbs",
|
|
},
|
|
{
|
|
type: "add",
|
|
path: "packages/{{kebabCase name}}/src/infrastructure/repositories/{{kebabCase entity}}.repository.mock.ts",
|
|
templateFile:
|
|
"templates/feature/src/infrastructure/repositories/entity.repository.mock.ts.hbs",
|
|
},
|
|
{
|
|
type: "add",
|
|
path: "packages/{{kebabCase name}}/src/infrastructure/repositories/{{kebabCase entity}}.repository.mock.test.ts",
|
|
templateFile:
|
|
"templates/feature/src/infrastructure/repositories/entity.repository.mock.test.ts.hbs",
|
|
},
|
|
{
|
|
type: "add",
|
|
path: "packages/{{kebabCase name}}/src/infrastructure/repositories/{{kebabCase entity}}.repository.span.test.ts",
|
|
templateFile:
|
|
"templates/feature/src/infrastructure/repositories/entity.repository.span.test.ts.hbs",
|
|
},
|
|
|
|
// Interface adapters
|
|
{
|
|
type: "add",
|
|
path: "packages/{{kebabCase name}}/src/interface-adapters/controllers/get-{{kebabCase entity}}.controller.ts",
|
|
templateFile:
|
|
"templates/feature/src/interface-adapters/controllers/get-entity.controller.ts.hbs",
|
|
},
|
|
{
|
|
type: "add",
|
|
path: "packages/{{kebabCase name}}/src/interface-adapters/controllers/get-{{kebabCase entity}}.controller.test.ts",
|
|
templateFile:
|
|
"templates/feature/src/interface-adapters/controllers/get-entity.controller.test.ts.hbs",
|
|
},
|
|
|
|
// DI
|
|
{
|
|
type: "add",
|
|
path: "packages/{{kebabCase name}}/src/di/symbols.ts",
|
|
templateFile: "templates/feature/src/di/symbols.ts.hbs",
|
|
},
|
|
{
|
|
type: "add",
|
|
path: "packages/{{kebabCase name}}/src/di/module.ts",
|
|
templateFile: "templates/feature/src/di/module.ts.hbs",
|
|
},
|
|
{
|
|
type: "add",
|
|
path: "packages/{{kebabCase name}}/src/di/container.ts",
|
|
templateFile: "templates/feature/src/di/container.ts.hbs",
|
|
},
|
|
{
|
|
type: "add",
|
|
path: "packages/{{kebabCase name}}/src/di/container.test.ts",
|
|
templateFile: "templates/feature/src/di/container.test.ts.hbs",
|
|
},
|
|
{
|
|
type: "add",
|
|
path: "packages/{{kebabCase name}}/src/di/bind-production.ts",
|
|
templateFile: "templates/feature/src/di/bind-production.ts.hbs",
|
|
},
|
|
{
|
|
type: "add",
|
|
path: "packages/{{kebabCase name}}/src/di/bind-dev-seed.ts",
|
|
templateFile: "templates/feature/src/di/bind-dev-seed.ts.hbs",
|
|
},
|
|
{
|
|
type: "add",
|
|
path: "packages/{{kebabCase name}}/src/di/bind-dev-seed.test.ts",
|
|
templateFile: "templates/feature/src/di/bind-dev-seed.test.ts.hbs",
|
|
},
|
|
|
|
// Integrations: api
|
|
{
|
|
type: "add",
|
|
path: "packages/{{kebabCase name}}/src/integrations/api/procedures.ts",
|
|
templateFile: "templates/feature/src/integrations/api/procedures.ts.hbs",
|
|
},
|
|
{
|
|
type: "add",
|
|
path: "packages/{{kebabCase name}}/src/integrations/api/router.ts",
|
|
templateFile: "templates/feature/src/integrations/api/router.ts.hbs",
|
|
},
|
|
{
|
|
type: "add",
|
|
path: "packages/{{kebabCase name}}/src/integrations/api/router.test.ts",
|
|
templateFile: "templates/feature/src/integrations/api/router.test.ts.hbs",
|
|
},
|
|
|
|
// Seeds + factories + contracts (Phase 1: minimal stubs that still typecheck/test)
|
|
{
|
|
type: "add",
|
|
path: "packages/{{kebabCase name}}/src/__seeds__/dev.ts",
|
|
templateFile: "templates/feature/src/__seeds__/dev.ts.hbs",
|
|
},
|
|
{
|
|
type: "add",
|
|
path: "packages/{{kebabCase name}}/src/__factories__/index.ts",
|
|
templateFile: "templates/feature/src/__factories__/index.ts.hbs",
|
|
},
|
|
{
|
|
type: "add",
|
|
path: "packages/{{kebabCase name}}/src/__factories__/{{kebabCase entity}}.factory.ts",
|
|
templateFile: "templates/feature/src/__factories__/entity.factory.ts.hbs",
|
|
},
|
|
{
|
|
type: "add",
|
|
path: "packages/{{kebabCase name}}/src/__contracts__/{{kebabCase entity}}-repository.contract.ts",
|
|
templateFile:
|
|
"templates/feature/src/__contracts__/entity-repository.contract.ts.hbs",
|
|
},
|
|
|
|
// ui — empty barrel for Phase 1 (./ui subpath kept reserved)
|
|
{
|
|
type: "add",
|
|
path: "packages/{{kebabCase name}}/src/ui/index.ts",
|
|
templateFile: "templates/feature/src/ui/index.ts.hbs",
|
|
},
|
|
|
|
// Final manual-wiring instructions printed to the user
|
|
function printNextSteps(answers: Record<string, unknown>): string {
|
|
const a = answers as { name: string; entity: string; entityPlural: string };
|
|
const kebab = a.name;
|
|
const constSym = a.name.toUpperCase().replace(/-/g, "_");
|
|
const pkg = `@repo/${kebab}`;
|
|
return [
|
|
"",
|
|
"─────────────────────────────────────────────────────────────",
|
|
`Feature package ${pkg} scaffolded.`,
|
|
"",
|
|
"Next steps (manual aggregator wiring — not generated):",
|
|
"",
|
|
` 1. pnpm install # link the new workspace package`,
|
|
"",
|
|
` 2. apps/web-next/src/server/bind-production.ts:`,
|
|
` - import { bindProduction${cap(a.name)} } from "${pkg}/di/bind-production";`,
|
|
` - import { bindDevSeed${cap(a.name)} } from "${pkg}/di/bind-dev-seed";`,
|
|
` - call bindProduction${cap(a.name)}(ctx) (production branch, ctx: BindProductionContext)`,
|
|
` - call await bindDevSeed${cap(a.name)}(ctx) (dev-seed branch, ctx: BindContext)`,
|
|
"",
|
|
` 3. packages/core-api/src/root.ts:`,
|
|
` - import { ${camel(a.name)}Router } from "${pkg}/api";`,
|
|
` - mount it on the app router (e.g. ${camel(a.name)}: ${camel(a.name)}Router)`,
|
|
"",
|
|
` 4. packages/core-api/package.json:`,
|
|
` - add "${pkg}": "workspace:*" to dependencies`,
|
|
"",
|
|
` 5. apps/web-next/package.json:`,
|
|
` - add "${pkg}": "workspace:*" to dependencies`,
|
|
"",
|
|
` 6. (Later) Add Payload CMS collection/global at:`,
|
|
` packages/${kebab}/src/integrations/cms/collections/${a.entityPlural}.ts`,
|
|
` and register in packages/core-cms/...`,
|
|
"",
|
|
` 7. Verify: pnpm --filter ${pkg} lint typecheck test`,
|
|
"",
|
|
`Reference symbols exported by this package:`,
|
|
` - ${constSym}_SYMBOLS (from ${pkg}/* internal)`,
|
|
` - bindProduction${cap(a.name)}, bindDevSeed${cap(a.name)}`,
|
|
` - ${camel(a.name)}Router (tRPC)`,
|
|
"─────────────────────────────────────────────────────────────",
|
|
"",
|
|
].join("\n");
|
|
},
|
|
],
|
|
});
|
|
|
|
/**
|
|
* Turbo generator: `event`
|
|
*
|
|
* Mode `publish` scaffolds an event contract under
|
|
* `packages/<feature>/src/events/<event-kebab>.event.ts` (+ test) and
|
|
* re-exports it from the feature's public surface at the `// <gen:events>`
|
|
* anchor. Mode `consume` (Task 40) scaffolds a handler in the consumer
|
|
* feature.
|
|
*/
|
|
plop.setGenerator("event", {
|
|
description: "Scaffold an event contract (publish) or handler (consume)",
|
|
prompts: [
|
|
{
|
|
type: "list",
|
|
name: "mode",
|
|
message: "Mode:",
|
|
choices: ["publish", "consume"],
|
|
},
|
|
{
|
|
type: "input",
|
|
name: "feature",
|
|
message:
|
|
"Owning feature (kebab-case; for publish: contract package; for consume: consumer package):",
|
|
validate(input: string) {
|
|
if (!/^[a-z][a-z0-9-]*$/.test(input)) return "Must be kebab-case";
|
|
if (!existsSync(join(process.cwd(), "packages", input, "src"))) {
|
|
return `packages/${input}/src does not exist`;
|
|
}
|
|
return true;
|
|
},
|
|
},
|
|
{
|
|
type: "input",
|
|
name: "event",
|
|
message: "Event slug (dotted-kebab past tense, e.g. 'user.signed-up'):",
|
|
validate(input: string) {
|
|
if (!/^[a-z][a-z0-9-]*(\.[a-z][a-z0-9-]*)+$/.test(input)) {
|
|
return "Must be dotted-kebab past tense (e.g. user.signed-up)";
|
|
}
|
|
return true;
|
|
},
|
|
},
|
|
{
|
|
type: "input",
|
|
name: "publisher",
|
|
message:
|
|
"Publisher feature (kebab-case; required for consume, ignored for publish):",
|
|
// Always shown — `--args` cannot bypass conditional prompts (Plop limitation).
|
|
// Publish mode accepts any value (it's ignored by publishActions).
|
|
validate(input: string, answers: { mode: string; event: string }) {
|
|
if (answers.mode === "publish") return true;
|
|
if (!/^[a-z][a-z0-9-]*$/.test(input)) return "Must be kebab-case";
|
|
const eventKebab = answers.event.replace(/\./g, "-");
|
|
const path = join(
|
|
process.cwd(),
|
|
"packages",
|
|
input,
|
|
"src",
|
|
"events",
|
|
`${eventKebab}.event.ts`,
|
|
);
|
|
if (!existsSync(path)) {
|
|
return `Publisher contract not found: ${path}`;
|
|
}
|
|
return true;
|
|
},
|
|
},
|
|
],
|
|
actions(answers) {
|
|
const a = answers as {
|
|
mode: "publish" | "consume";
|
|
feature: string;
|
|
event: string;
|
|
publisher?: string;
|
|
};
|
|
if (a.mode === "publish") return publishActions(a);
|
|
if (!a.publisher) {
|
|
throw new Error("consume mode requires a publisher feature");
|
|
}
|
|
return consumeActions(a as Required<typeof a>);
|
|
},
|
|
});
|
|
|
|
/**
|
|
* Turbo generator: `realtime`
|
|
*
|
|
* Mode `channel` scaffolds a realtime channel descriptor under
|
|
* `packages/<feature>/src/realtime/<slug>.channel.ts` (+ test) and
|
|
* re-exports it from the feature's public surface at the
|
|
* `// <gen:realtime-channels>` anchor.
|
|
* Mode `handler` scaffolds an inbound handler + DI binding at the
|
|
* `// <gen:realtime-handlers>` anchor.
|
|
*/
|
|
plop.setGenerator("realtime", {
|
|
description: "Scaffold a realtime channel descriptor or inbound handler",
|
|
prompts: [
|
|
{
|
|
type: "input",
|
|
name: "mode",
|
|
message: "Mode: channel | handler",
|
|
validate(input: string) {
|
|
if (!["channel", "handler"].includes(input))
|
|
return "Must be 'channel' or 'handler'";
|
|
return true;
|
|
},
|
|
},
|
|
{
|
|
type: "input",
|
|
name: "feature",
|
|
message: "Feature (kebab-case, must exist):",
|
|
validate(input: string) {
|
|
if (!/^[a-z][a-z0-9-]*$/.test(input)) return "Must be kebab-case";
|
|
if (!existsSync(join(process.cwd(), "packages", input, "src"))) {
|
|
return `packages/${input}/src does not exist`;
|
|
}
|
|
return true;
|
|
},
|
|
},
|
|
{
|
|
type: "input",
|
|
name: "channelSlug",
|
|
message: "Channel slug (kebab-case, e.g. 'presence-ping'):",
|
|
validate(input: string) {
|
|
if (!/^[a-z][a-z0-9-]+$/.test(input)) return "Must be kebab-case";
|
|
return true;
|
|
},
|
|
},
|
|
{
|
|
type: "input",
|
|
name: "scope",
|
|
message:
|
|
"Scope (channel mode only): public | authenticated | role:NAME | user-scoped (ignored for handler mode):",
|
|
validate(input: string, answers: { mode: string }) {
|
|
if (answers.mode === "handler") return true;
|
|
if (
|
|
input === "public" ||
|
|
input === "authenticated" ||
|
|
input === "user-scoped"
|
|
)
|
|
return true;
|
|
if (/^role:[a-z][a-z0-9_-]*$/.test(input)) return true;
|
|
return "Must be public | authenticated | role:NAME | user-scoped";
|
|
},
|
|
},
|
|
],
|
|
actions(answers) {
|
|
const a = answers as {
|
|
mode: string;
|
|
feature: string;
|
|
channelSlug: string;
|
|
scope: string;
|
|
};
|
|
if (a.mode === "channel") return realtimeChannelActions(a);
|
|
if (a.mode === "handler") return realtimeHandlerActions(a);
|
|
throw new Error(`Unknown mode: ${a.mode}`);
|
|
},
|
|
});
|
|
|
|
/**
|
|
* Turbo generator: `core-package`
|
|
*
|
|
* Scaffolds an optional core package back into a slimmed template. Each
|
|
* name maps to a verbatim snapshot of the package captured at generator-add
|
|
* time. Phases 3-6 each register one entry in CORE_PACKAGE_GENERATORS.
|
|
*/
|
|
const REALTIME_RULE_IMPORTS = `import noDirectSocketIO from "./rules/no-direct-socket-io.js";
|
|
import noRealtimeHandlerReexport from "./rules/no-realtime-handler-reexport.js";`;
|
|
|
|
const REALTIME_RULE_BLOCK = ` {
|
|
files: ["**/*.{ts,tsx,mjs,cjs,js}"],
|
|
plugins: {
|
|
"repo-rules": {
|
|
rules: {
|
|
"no-direct-socket-io": noDirectSocketIO,
|
|
"no-realtime-handler-reexport": noRealtimeHandlerReexport,
|
|
},
|
|
},
|
|
},
|
|
rules: {
|
|
"repo-rules/no-direct-socket-io": "error",
|
|
"repo-rules/no-realtime-handler-reexport": "error",
|
|
},
|
|
},`;
|
|
|
|
const EVENTS_RULE_BLOCK = ` {
|
|
files: ["**/*.{ts,tsx,mjs,cjs,js}"],
|
|
rules: {
|
|
"no-restricted-syntax": [
|
|
"error",
|
|
{
|
|
selector:
|
|
"ExportNamedDeclaration[source.value=/\\/events\\/handlers\\//]",
|
|
message:
|
|
"Event handlers (events/handlers/*.handler.ts) must not be re-exported. Wire them only inside the consumer feature's bind-production / bind-dev-seed (Rule E1).",
|
|
},
|
|
{
|
|
selector:
|
|
"ExportAllDeclaration[source.value=/\\/events\\/handlers\\//]",
|
|
message:
|
|
"Event handlers (events/handlers/*.handler.ts) must not be re-exported. Wire them only inside the consumer feature's bind-production / bind-dev-seed (Rule E1).",
|
|
},
|
|
{
|
|
selector:
|
|
"MemberExpression[object.type='MemberExpression'][object.object.type='Identifier'][object.object.name='payload'][object.property.type='Identifier'][object.property.name='jobs']",
|
|
message:
|
|
"Direct \`payload.jobs.*\` access is not allowed here. Use IJobQueue (from @repo/core-shared/jobs) instead. Allowed only in **/integrations/cms/jobs/** and **/core-shared/src/jobs/**.",
|
|
},
|
|
],
|
|
},
|
|
},
|
|
// J — \`payload.jobs.*\` is allowed only in the integration layer.
|
|
// In these paths, no-restricted-syntax is narrowed to keep E1 active but
|
|
// drop the payload.jobs check.
|
|
// Note: "**/core-shared/src/jobs/**" does not match from within a package-local
|
|
// ESLint run because ESLint resolves globs relative to the config file location.
|
|
// The pattern is kept for documentation; in practice, the PayloadJobQueue class
|
|
// uses \`this.payload.jobs.*\` which the selector already ignores (it only catches
|
|
// bare \`payload.jobs.*\`). Any new file added there that does use bare \`payload.jobs.*\`
|
|
// would need this allowlist to be expressed as "**/jobs/payload-*" or similar.
|
|
{
|
|
files: [
|
|
"**/integrations/cms/jobs/**",
|
|
"**/core-shared/src/jobs/**",
|
|
],
|
|
rules: {
|
|
"no-restricted-syntax": [
|
|
"error",
|
|
{
|
|
selector:
|
|
"ExportNamedDeclaration[source.value=/\\/events\\/handlers\\//]",
|
|
message:
|
|
"Event handlers (events/handlers/*.handler.ts) must not be re-exported. Wire them only inside the consumer feature's bind-production / bind-dev-seed (Rule E1).",
|
|
},
|
|
{
|
|
selector:
|
|
"ExportAllDeclaration[source.value=/\\/events\\/handlers\\//]",
|
|
message:
|
|
"Event handlers (events/handlers/*.handler.ts) must not be re-exported. Wire them only inside the consumer feature's bind-production / bind-dev-seed (Rule E1).",
|
|
},
|
|
],
|
|
},
|
|
},`;
|
|
|
|
const CORE_PACKAGE_GENERATORS: Record<string, () => PlopTypes.ActionType[]> =
|
|
{
|
|
realtime: () => [
|
|
() => {
|
|
assertOptionalPackageNotPresent("core-realtime");
|
|
return "Guard passed — packages/core-realtime does not exist yet.";
|
|
},
|
|
...emitTemplateTree("core-package/realtime", "packages/core-realtime"),
|
|
...emitTemplateTree(
|
|
"core-package/realtime-eslint-rules",
|
|
"packages/core-eslint/rules",
|
|
),
|
|
() => {
|
|
addToTranspilePackages("apps/web-next/next.config.mjs", "@repo/core-realtime");
|
|
return "Added @repo/core-realtime to transpilePackages.";
|
|
},
|
|
() => {
|
|
addBoundariesEntry("packages/core-eslint/base.js", "packages/core-realtime", { mode: "folder" });
|
|
return "Added core-realtime boundaries entry.";
|
|
},
|
|
() => {
|
|
splicePluginImportsAt(
|
|
"packages/core-eslint/base.js",
|
|
"realtime-rules-imports",
|
|
REALTIME_RULE_IMPORTS,
|
|
);
|
|
return "Added realtime rule imports to base.js.";
|
|
},
|
|
() => {
|
|
splicePluginRulesAt(
|
|
"packages/core-eslint/base.js",
|
|
"realtime-rules",
|
|
REALTIME_RULE_BLOCK,
|
|
);
|
|
return "Added realtime rule block to base.js.";
|
|
},
|
|
printRealtimeNextSteps,
|
|
],
|
|
events: () => [
|
|
() => {
|
|
assertOptionalPackageNotPresent("core-events");
|
|
return "Guard passed — packages/core-events does not exist yet.";
|
|
},
|
|
...emitTemplateTree("core-package/events", "packages/core-events"),
|
|
() => {
|
|
addToTranspilePackages("apps/web-next/next.config.mjs", "@repo/core-events");
|
|
return "Added @repo/core-events to transpilePackages.";
|
|
},
|
|
() => {
|
|
splicePluginRulesAt(
|
|
"packages/core-eslint/base.js",
|
|
"events-rules",
|
|
EVENTS_RULE_BLOCK,
|
|
);
|
|
return "Added events rule block to base.js.";
|
|
},
|
|
printEventsNextSteps,
|
|
],
|
|
trpc: () => [
|
|
() => {
|
|
assertOptionalPackageNotPresent("core-trpc");
|
|
return "Guard passed — packages/core-trpc does not exist yet.";
|
|
},
|
|
...emitTemplateTree("core-package/trpc", "packages/core-trpc"),
|
|
() => {
|
|
addToTranspilePackages("apps/web-next/next.config.mjs", "@repo/core-trpc");
|
|
return "Added @repo/core-trpc to transpilePackages.";
|
|
},
|
|
printTrpcNextSteps,
|
|
],
|
|
ui: () => [
|
|
() => {
|
|
assertOptionalPackageNotPresent("core-ui");
|
|
return "Guard passed — packages/core-ui does not exist yet.";
|
|
},
|
|
...emitTemplateTree("core-package/ui", "packages/core-ui"),
|
|
() => {
|
|
addToTranspilePackages("apps/web-next/next.config.mjs", "@repo/core-ui");
|
|
return "Added @repo/core-ui to transpilePackages.";
|
|
},
|
|
printUiNextSteps,
|
|
],
|
|
};
|
|
|
|
plop.setGenerator("core-package", {
|
|
description: "Scaffold an optional core package (realtime, events, trpc, ui)",
|
|
prompts: [
|
|
{
|
|
type: "list",
|
|
name: "name",
|
|
message: "Which optional core package?",
|
|
choices: ["realtime", "events", "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);
|
|
},
|
|
});
|
|
}
|
|
|
|
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");
|
|
}
|
|
|
|
// 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, "_");
|
|
}
|