#!/usr/bin/env node /** * cli:scaffold-business * Responsabilité : Generate CQRS pattern for an entity */ import { parseArgs } from 'node:util' 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 { 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 }) const written: string[] = [] for (const file of files) { const fullPath = resolve(join(outdir, file.path)) mkdirSync(dirname(fullPath), { recursive: true }) writeFileSync(fullPath, file.content, 'utf-8') written.push(fullPath) } // ── 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.`), ] 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 r = mergeLineIntoMarkerBlock(programSource, RJ_BEGIN, RJ_END, line, ' ') if (r.status === 'patched') { programSource = r.source; programDirty = true } else if (r.status === 'failed') { // Pre-marker template: anchor on the Hangfire registration line. const hangfire = programSource.indexOf('AddSmartStackHangfire(') if (hangfire >= 0 && !programSource.includes(line)) { 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 if (hangfire < 0) { 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()