#!/usr/bin/env node /** * cli:scaffold-extension-search * * Patches the client's `{appCode}.Infrastructure/DependencyInjection.cs` with the * global-search registration of its extension entities (between the * `<<< EXTENSION-SEARCH-DI BEGIN/END >>>` markers). Idempotent. * * Two modes: * • --spec / --spec-file : register from an explicit spec (post-screens in /ba-develop). * • --discover --project --app : DISCOVER searchable entities from an EXISTING project * (used by `ss upgrade`). Fail-closed on row-scope: a * row-scope-suspect entity is NOT registered, it is reported * for human review. * * Usage: * npx --prefer-offline tsx .../scaffold-extension-search/index.ts --spec-file spec.json --outdir * npx --prefer-offline tsx .../scaffold-extension-search/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 { patchExtensionSearch } from './generate.js'; import { discoverFromProject, type ReviewItem } from './discover.js'; import { generateEnvelope, failGenerate, printEnvelope } from '../../../../../lib/output.js'; import type { ExtensionSearchSpec } from './types.js'; const COMMAND = 'scaffold-extension-search'; 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, }); let spec: ExtensionSearchSpec; let review: ReviewItem[] = []; let outdir: string; const warnings: string[] = []; if (values.discover) { if (!values.project || !values.app) { printEnvelope(failGenerate(COMMAND, ['--discover requires --project and --app '])); process.exit(1); } const result = discoverFromProject(values.project, values.app); spec = result.spec; // already typed + complete; may have 0 entities (all withheld) review = result.review; outdir = values.outdir ?? values.project; } else { const rawJson = values['spec-file'] ? safeRead(values['spec-file']) : (values.spec ?? 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); } spec = validation.data; outdir = values.outdir ?? spec.projectPath; } // Surface row-scope-suspect items prominently — these are the security-relevant ones. for (const item of review) { if (item.kind === 'row-scope-suspect') { warnings.push(`REVIEW (row-scope): ${item.entity} NOT registered — ${item.detail}`); } } const candidatePaths = [ `src/${spec.appCode}.Infrastructure/DependencyInjection.cs`, `src/${spec.appCode}.Infrastructure/ServiceCollectionExtensions.cs`, `src/${spec.appCode}.Infrastructure/InfrastructureModule.cs`, ]; let diHostPath: string | null = null; for (const candidate of candidatePaths) { const fullPath = resolve(join(outdir, candidate)); if (existsSync(fullPath)) { diHostPath = fullPath; break; } } const filesModified: string[] = []; const willPatch = spec.entities.length > 0 && diHostPath !== null && !values['dry-run']; if (willPatch) { const source = readFileSync(diHostPath!, 'utf-8'); const next = patchExtensionSearch(source, spec); if (next !== null) { writeFileSync(diHostPath!, next, 'utf-8'); filesModified.push(diHostPath!); } } else if (spec.entities.length > 0 && diHostPath === null) { warnings.push(`No DependencyInjection.cs found under src/${spec.appCode}.Infrastructure/.`); } printEnvelope( generateEnvelope(COMMAND, { data: { mode: values.discover ? 'discover' : 'spec', dryRun: !!values['dry-run'], registered: spec.entities.map((e) => e.categoryKey), diPatched: filesModified.length > 0, diHostPath, review: review.map((r) => ({ kind: r.kind, entity: r.entity, section: r.section, detail: r.detail, suggestion: r.suggestion })), }, filesModified, warnings, nextSteps: buildNextSteps(spec, review, diHostPath, !!values['dry-run']), }), ); } function buildNextSteps(spec: ExtensionSearchSpec, review: ReviewItem[], diHostPath: string | null, dryRun: boolean): string[] { const steps: string[] = []; if (spec.entities.length > 0) { steps.push(dryRun ? `Would register ${spec.entities.length} categor${spec.entities.length === 1 ? 'y' : 'ies'}: ${spec.entities.map((e) => e.categoryKey).join(', ')}.` : 'Run `dotnet build` to verify the search registration compiles.'); } else { steps.push('No entity was auto-registered (all withheld for review or unbound).'); } const suspects = review.filter((r) => r.kind === 'row-scope-suspect'); if (suspects.length > 0) { steps.push(`${suspects.length} entit${suspects.length === 1 ? 'y' : 'ies'} need a manual .RestrictTo(...) before registering (row-scope) — see data.review[].suggestion.`); } if (diHostPath === null) steps.push('DI host not found — register manually.'); return steps; } 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();