#!/usr/bin/env node /** * cli:scaffold-time-entry-refs * * Patches the client's `{appCode}.Infrastructure/DependencyInjection.cs` with the * HR time-entry imputation registration of its extension entities (between the * `<<< TIME-ENTRY-REFS-DI BEGIN/END >>>` markers). Idempotent. * * Two modes: * • --spec / --spec-file : register the imputation dimensions from an explicit spec. * (No BA-derived mode: imputation is a business decision, NOT * derivable from the BA pagespecs — a list screen does not imply * an entity should receive time bookings.) * • --discover --project --app : DISCOVER candidate entities from an EXISTING project and * back-fill the COMMENTED seam wrapper (used by `ss upgrade`). * NEVER auto-registers a dimension — every entity is reported as * a candidate for a human to promote. * * Usage: * npx --prefer-offline tsx .../scaffold-time-entry-refs/index.ts --spec-file spec.json --outdir * npx --prefer-offline tsx .../scaffold-time-entry-refs/index.ts --discover --project --app Test */ import { parseArgs } from 'node:util'; import { readFileSync, writeFileSync, existsSync } from 'node:fs'; import { resolve, join } from 'node:path'; import { validate } from './validate.js'; import { patchTimeEntryRefs, backfillSeamScaffold } from './generate.js'; import { discoverFromProject } from './discover.js'; import { generateEnvelope, failGenerate, printEnvelope } from '../../../../../lib/output.js'; import type { TimeEntryRefsSpec } from './types.js'; const COMMAND = 'scaffold-time-entry-refs'; function resolveDiHost(outdir: string, appCode: string): string | null { const candidatePaths = [ `src/${appCode}.Infrastructure/DependencyInjection.cs`, `src/${appCode}.Infrastructure/ServiceCollectionExtensions.cs`, `src/${appCode}.Infrastructure/InfrastructureModule.cs`, ]; for (const candidate of candidatePaths) { const fullPath = resolve(join(outdir, candidate)); if (existsSync(fullPath)) return fullPath; } return null; } function main(): void { const { values } = parseArgs({ options: { spec: { type: 'string' }, 'spec-file': { type: 'string' }, outdir: { type: 'string' }, 'dry-run': { type: 'boolean', default: false }, discover: { type: 'boolean', default: false }, project: { type: 'string' }, app: { type: 'string' }, }, strict: true, }); if (values.discover) { runDiscover(values); return; } runSpec(values); } /** Explicit-spec mode: register the declared imputation dimensions (idempotent marker patch). */ function runSpec(values: Record): void { const rawJson = values['spec-file'] ? safeRead(values['spec-file'] as string) : ((values.spec as string) ?? null); if (rawJson === null) { printEnvelope(failGenerate(COMMAND, ['Either --spec, --spec-file, or --discover is required'])); process.exit(1); } let raw: unknown; try { raw = JSON.parse(rawJson); } catch { printEnvelope(failGenerate(COMMAND, ['Invalid JSON in spec'])); process.exit(1); } const validation = validate(raw); if (!validation.valid || !validation.data) { printEnvelope(failGenerate(COMMAND, validation.errors)); process.exit(1); } const spec: TimeEntryRefsSpec = validation.data; const outdir = (values.outdir as string) ?? spec.projectPath; const dryRun = !!values['dry-run']; const warnings: string[] = []; const diHostPath = resolveDiHost(outdir, spec.appCode); const filesModified: string[] = []; const willPatch = diHostPath !== null && !dryRun; if (willPatch) { const source = readFileSync(diHostPath!, 'utf-8'); const next = patchTimeEntryRefs(source, spec); if (next !== null) { writeFileSync(diHostPath!, next, 'utf-8'); filesModified.push(diHostPath!); } } else if (diHostPath === null) { warnings.push(`No DependencyInjection.cs found under src/${spec.appCode}.Infrastructure/.`); } printEnvelope( generateEnvelope(COMMAND, { data: { mode: 'spec', dryRun, registered: spec.entities.map((e) => e.refType), diPatched: filesModified.length > 0, diHostPath, }, filesModified, warnings, nextSteps: [ dryRun ? `Would register ${spec.entities.length} dimension${spec.entities.length === 1 ? '' : 's'}: ${spec.entities.map((e) => e.refType).join(', ')}.` : 'Run `dotnet build` to verify the time-entry-refs registration compiles.', 'Enable the feature per tenant: HR → Time settings → external refs (default off).', ...(diHostPath === null ? ['DI host not found — register manually.'] : []), ], }), ); } /** Discovery mode (ss upgrade): back-fill the commented seam + surface candidate entities. Never registers. */ function runDiscover(values: Record): void { if (!values.project || !values.app) { printEnvelope(failGenerate(COMMAND, ['--discover requires --project and --app '])); process.exit(1); } const projectDir = values.project as string; const appCode = values.app as string; const outdir = (values.outdir as string) ?? projectDir; const dryRun = !!values['dry-run']; const warnings: string[] = []; const result = discoverFromProject(projectDir, appCode); const diHostPath = resolveDiHost(outdir, appCode); const filesModified: string[] = []; let seamBackfilled = false; if (diHostPath !== null && !result.seamPresent && !dryRun) { const source = readFileSync(diHostPath, 'utf-8'); const next = backfillSeamScaffold(source, 'ExtensionsDbContext'); if (next !== null) { writeFileSync(diHostPath, next, 'utf-8'); filesModified.push(diHostPath); seamBackfilled = true; } } else if (diHostPath === null && !result.seamPresent) { warnings.push(`No DependencyInjection.cs found under src/${appCode}.Infrastructure/ — the time-entry-refs seam was not back-filled.`); } const nextSteps: string[] = []; if (result.candidates.length > 0) { nextSteps.push( `${result.candidates.length} extension entit${result.candidates.length === 1 ? 'y is a' : 'ies are'} candidate imputation dimension${result.candidates.length === 1 ? '' : 's'} — none registered (opt-in). Promote the ones time should be booked against: see data.review[].suggestion.`, ); } else { nextSteps.push('No extension entity found to suggest as an imputation dimension.'); } if (result.seamPresent) nextSteps.push('Seam already present — nothing to back-fill.'); else if (seamBackfilled) nextSteps.push('Commented time-entry-refs seam back-filled — register a dimension inside the markers to activate it.'); nextSteps.push('Enable the feature per tenant: HR → Time settings → external refs (default off).'); printEnvelope( generateEnvelope(COMMAND, { data: { mode: 'discover', dryRun, registered: [], // discovery NEVER auto-registers — imputation is opt-in. seamPresent: result.seamPresent, seamBackfilled, diPatched: filesModified.length > 0, diHostPath, candidates: result.candidates.map((c) => c.simpleName), review: result.review.map((r) => ({ kind: r.kind, entity: r.entity, detail: r.detail, suggestion: r.suggestion })), }, filesModified, warnings, nextSteps, }), ); } function safeRead(path: string): string { try { return readFileSync(path, 'utf-8'); } catch (err) { printEnvelope(failGenerate(COMMAND, [`Failed to read --spec-file ${path}: ${err instanceof Error ? err.message : String(err)}`])); process.exit(1); } } main();