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:
@@ -25,6 +25,10 @@ import { assertAnchors } from "./lib/anchor-validate.js";
|
|||||||
* The generator prints these manual steps when it finishes.
|
* The generator prints these manual steps when it finishes.
|
||||||
*/
|
*/
|
||||||
export default function generator(plop: PlopTypes.NodePlopAPI): void {
|
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", {
|
plop.setGenerator("feature", {
|
||||||
description:
|
description:
|
||||||
"Scaffold a Lazar-conformant feature package (single entity / single use case)",
|
"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>);
|
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: {
|
function publishActions(a: {
|
||||||
|
|||||||
Reference in New Issue
Block a user