feat(turbo-gen): job generator

Adds `pnpm turbo gen job <feature> <slug> <void|typed>` 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.
This commit is contained in:
2026-05-08 16:20:33 +02:00
parent 8e081c2e97
commit d5f230e07f

View File

@@ -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<typeof a>);
},
});
/**
* 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: {