#!/usr/bin/env node /** * cli:scaffold-business * Responsabilité : Generate CQRS pattern for an entity */ import { parseArgsOrFail as parseArgs } from '../../../../../lib/cli-args.js' import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' import { dirname, resolve, join } from 'node:path' import { validate } from './validate.js' import { generate, legacyPaths, diPatches } from './generate.js' import { ScaffoldBusinessInputSchema } from './types.js' import { applicationNs } from '../../../../../lib/app-classification.js' import { generateEnvelope, failGenerate, printEnvelope } from '../../../../../lib/output.js' import { customisedWarnings, writeGeneratedFiles } from '../../../../../lib/generated-writes.js' import { mergeLineIntoMarkerBlock, spliceDiMarkerBlock } from '../../../../../lib/di-markers.js' import { guardedRm } from '../../../../../lib/guarded-rm.js' const COMMAND = 'scaffold-business' function main(): void { const { values } = parseArgs({ options: { spec: { type: 'string' }, outdir: { type: 'string' }, dry_run: { type: 'boolean', default: false } }, strict: true }) if (!values.spec) { printEnvelope(failGenerate(COMMAND, ['--spec is required'])); process.exit(1) } let raw: unknown try { raw = JSON.parse(values.spec) } catch { printEnvelope(failGenerate(COMMAND, ['Invalid JSON'])); process.exit(1) } const validation = validate(raw) if (!validation.valid) { printEnvelope(failGenerate(COMMAND, validation.errors)); process.exit(1) } // Parse (not cast) so Zod defaults (businessRules: [], required: true, …) // are materialized — generate() relies on them. const spec = ScaffoldBusinessInputSchema.parse(raw) // generate() FAILS CLOSED on an unresolvable display field (§26). That error // carries the actionable fix ("declare **Affichage** : ") and must // reach the orchestrator through the JSON envelope it parses — an uncaught // throw would exit with a Node stack trace on stderr and EMPTY stdout, so // ba-develop's heal loop would read no `errors[]` and never learn what to fix. let files: ReturnType try { files = generate(spec) } catch (err) { printEnvelope(failGenerate(COMMAND, [err instanceof Error ? err.message : String(err)])) process.exit(1) } if (values.dry_run) { printEnvelope(generateEnvelope(COMMAND, { data: { dryRun: true, files: files.map(f => f.path), diPatches: diPatches(spec).map(p => ({ hosts: p.hostCandidates, registration: p.line ?? p.block })), }, warnings: validation.warnings, })) process.exit(0) } const outdir = values.outdir ?? spec.projectPath // Idempotent relocate: delete the pre-classification copies before writing the // new /-classified files (re-run MOVES the slice — no duplicate // handler/validator left in the assembly for MediatR to reject at boot). // Guarded sweep (lib/guarded-rm): @customised files preserved, deletions traced. const legacySweep = guardedRm(legacyPaths(spec), { outdir }) // Guarded write (lib/generated-writes): an `@customised` output is skipped + warned, never overwritten. const writes = writeGeneratedFiles(files, { outdir }) const written = writes.written // ── DI registrations — the generator LANDS them, it never asks (the manual // nextSteps era shipped modules whose every endpoint answered 500: build, // audits and unit tests were green, only the container knew). Idempotent // through lib/di-markers: a re-run never duplicates a line, and a line the // project registered by hand outside the block is detected and skipped. const filesModified: string[] = [] const warnings = [ ...validation.warnings, ...legacySweep.preserved.map(p => `legacy path ${p} kept: marked @customised — delete manually if truly superseded.`), ...customisedWarnings(writes), ] for (const patch of diPatches(spec)) { const host = patch.hostCandidates.map(c => resolve(join(outdir, c))).find(p => existsSync(p)) if (!host) { warnings.push( `No DI host found (${patch.hostCandidates.join(' / ')}) — the registration below is NOT landed; ` + `add it manually or the first request 500s:\n${patch.line ?? patch.block}`, ) continue } const source = readFileSync(host, 'utf-8') if (patch.line !== undefined) { if (patch.skipIfPresent && source.includes(patch.skipIfPresent)) continue const r = mergeLineIntoMarkerBlock(source, patch.begin, patch.end, patch.line) if (r.status === 'patched') { writeFileSync(host, r.source, 'utf-8'); filesModified.push(host) } else if (r.status === 'failed') { warnings.push(`DI host ${host} has no insertion point (no 'return services;') — add manually:\n${patch.line}`) } } else if (patch.block !== undefined) { const beginIdx = source.indexOf(patch.begin) if (beginIdx < 0 && patch.skipIfPresent && source.includes(patch.skipIfPresent)) continue const next = spliceDiMarkerBlock(source, patch.begin, patch.end, patch.block) if (next !== null) { writeFileSync(host, next, 'utf-8'); filesModified.push(host) } } } // ── Scheduled jobs → Program.cs RECURRING-JOBS marker ───────────────────── // One AddSmartStackRecurringJob per scheduled UC, through the SmartStack // seam (NEVER Hangfire's static API — boot-order sensitive). Pre-marker // Program.cs: insert right after AddSmartStackHangfire; nothing to anchor // → warning with the lines to add by hand. if ((spec.scheduledJobs ?? []).length > 0) { const appNsForJobs = applicationNs(spec.namespace ?? spec.appCode, spec.applicationCode, spec.module) const programHost = resolve(join(outdir, `src/${spec.namespace ?? spec.appCode}.Api/Program.cs`)) const RJ_BEGIN = '// <<< RECURRING-JOBS BEGIN >>>' const RJ_END = '// <<< RECURRING-JOBS END >>>' if (!existsSync(programHost)) { warnings.push(`Program.cs not found at ${programHost} — register the recurring jobs manually (RECURRING-JOBS marker).`) } else { let programSource = readFileSync(programHost, 'utf-8') let programDirty = false for (const job of spec.scheduledJobs ?? []) { const line = `builder.Services.AddSmartStackRecurringJob("${job.jobId}", s => s.${job.methodName}(default, CancellationToken.None), "${job.cron}");` const beginIdx = programSource.indexOf(RJ_BEGIN) const hasBlock = beginIdx >= 0 && programSource.indexOf(RJ_END) > beginIdx if (hasBlock) { const r = mergeLineIntoMarkerBlock(programSource, RJ_BEGIN, RJ_END, line, ' ') if (r.status === 'patched') { programSource = r.source; programDirty = true } continue } // Pre-marker template: the block is anchored on the Hangfire registration line ONLY — // never through mergeLineIntoMarkerBlock's DI-host fallback, which splices before the // file's LAST `}`: on a top-level-statements Program.cs that is the tail of some // `if (...) { }` after builder.Build(), where a builder.Services.Add… throws at boot // (found by the lot 12 tests, 2026-09-24). if (programSource.includes(line)) continue // registered by hand outside any block const hangfire = programSource.indexOf('AddSmartStackHangfire(') if (hangfire >= 0) { const lineEnd = programSource.indexOf('\n', hangfire) programSource = `${programSource.slice(0, lineEnd + 1)}\n ${RJ_BEGIN}\n ${line}\n ${RJ_END}\n${programSource.slice(lineEnd + 1)}` programDirty = true } else { warnings.push(`Program.cs has no RECURRING-JOBS marker nor AddSmartStackHangfire — add manually:\n${line}`) } } if (programDirty) { writeFileSync(programHost, programSource, 'utf-8'); filesModified.push(programHost) } } } printEnvelope(generateEnvelope(COMMAND, { data: { entity: spec.name, fileCount: written.length, filesRemoved: legacySweep.removed }, filesCreated: written, filesModified, warnings, nextSteps: ['Run dotnet build'], })) } main()