From d5f230e07fe19bf745db583067e74c2b961f71c4 Mon Sep 17 00:00:00 2001 From: Danijel Martinek Date: Fri, 8 May 2026 16:20:33 +0200 Subject: [PATCH] feat(turbo-gen): job generator Adds `pnpm turbo gen job ` which scaffolds the factory + test + Payload TaskConfig, then modifies the feature's job-symbol, job-bind, and cms job-task anchors to wire the wrapped job into the per-feature container. Also registers a custom Handlebars `eq` helper used by the void/typed branch in the job templates. --- turbo/generators/config.ts | 157 +++++++++++++++++++++++++++++++++++++ 1 file changed, 157 insertions(+) diff --git a/turbo/generators/config.ts b/turbo/generators/config.ts index 39b8850..fe7f827 100644 --- a/turbo/generators/config.ts +++ b/turbo/generators/config.ts @@ -25,6 +25,10 @@ import { assertAnchors } from "./lib/anchor-validate.js"; * 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)", @@ -410,6 +414,159 @@ export default function generator(plop: PlopTypes.NodePlopAPI): void { return consumeActions(a as Required); }, }); + + /** + * 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, ["// "]); + 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 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: {