#!/usr/bin/env node /** * cli:scaffold-external-api * * Emits the PUBLIC (third-party, machine-to-machine) stratum of a client * extension: one controller per catalogue code, the catalogue seed provider, * and its DI registration. * * Usage: * npx --prefer-offline tsx skills/external-api/cli/scaffold-external-api/index.ts \ * --spec-file /path/to/external-api-spec.json [--outdir /path/to/target-app] [--dry-run] */ import { parseArgs } from 'node:util' import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' import { dirname, join, resolve } from 'node:path' import { failGenerate, generateEnvelope, printEnvelope } from '../../../lib/output.js' import { readSpecArg } from '../../../lib/spec-arg.js' import { spliceDiMarkerBlock } from '../../../lib/di-markers.js' import { guardedRm } from '../../../lib/guarded-rm.js' import { generate, diMarkers, legacyPaths } from './generate.js' import { validate } from './validate.js' import { ScaffoldExternalApiInputSchema } from './types.js' const COMMAND = 'scaffold-external-api' function main(): void { const { values } = parseArgs({ options: { spec: { type: 'string' }, 'spec-file': { type: 'string' }, outdir: { type: 'string' }, 'dry-run': { type: 'boolean', default: false }, }, strict: true, }) const specSrc = readSpecArg(values) if ('error' in specSrc) { printEnvelope(failGenerate(COMMAND, [specSrc.error])) process.exit(1) } let raw: unknown try { raw = JSON.parse(specSrc.raw) } catch (err) { printEnvelope(failGenerate(COMMAND, [`--spec is not valid JSON: ${(err as Error).message}`])) process.exit(1) } const validation = validate(raw) if (!validation.valid) { printEnvelope(failGenerate(COMMAND, validation.errors)) process.exit(1) } // Parse (not cast): Zod applies the schema defaults a hand-authored spec omits. const spec = ScaffoldExternalApiInputSchema.parse(raw) const result = generate(spec) const appPascal = spec.applicationPascal ?? spec.applicationCode if (values['dry-run']) { printEnvelope( generateEnvelope(COMMAND, { data: { dryRun: true, files: result.files.map(f => f.path), catalogue: result.catalogue, diBlock: result.diRegistration.markerBlock, diCandidates: result.diRegistration.candidatePaths, }, warnings: validation.warnings, }), ) process.exit(0) } const outdir = values.outdir ?? spec.projectPath const warnings = [...validation.warnings] // Controllers for operations that are no longer published. A file marked // `@customised` is kept — the bespoke seam outranks the sweep. const sweep = guardedRm(legacyPaths(spec), { outdir }) for (const kept of sweep.preserved) { warnings.push(`legacy path ${kept} kept: marked @customised. Its catalogue code is no longer seeded, so it now answers 404 endpoint_not_found — delete it or restore the operation.`) } const filesCreated: string[] = [] for (const file of result.files) { const fullPath = resolve(join(outdir, file.path)) mkdirSync(dirname(fullPath), { recursive: true }) writeFileSync(fullPath, file.content, 'utf-8') filesCreated.push(fullPath) } // DI registration of the catalogue seed provider. Without it the provider // never runs, no catalogue row exists, and every public route answers 404 // endpoint_not_found to an external app while looking perfectly healthy to a // signed-in user — the failure DEV-XAPI-012 exists to catch early. const filesModified: string[] = [] let diHostPath: string | null = null for (const candidate of result.diRegistration.candidatePaths) { const fullPath = resolve(join(outdir, candidate)) if (existsSync(fullPath)) { diHostPath = fullPath break } } if (diHostPath !== null) { const markers = diMarkers(appPascal) const source = readFileSync(diHostPath, 'utf-8') const next = spliceDiMarkerBlock(source, markers.begin, markers.end, result.diRegistration.markerBlock) if (next !== null && next !== source) { writeFileSync(diHostPath, next, 'utf-8') filesModified.push(diHostPath) } } else { warnings.push( `No DependencyInjection.cs / ServiceCollectionExtensions.cs found under src/${spec.namespace ?? spec.appCode}.Infrastructure/. The catalogue seed provider will never run and every public endpoint will answer 404 endpoint_not_found. Register it manually:\n${result.diRegistration.markerBlock}`, ) } printEnvelope( generateEnvelope(COMMAND, { data: { applicationCode: spec.applicationCode, resourceCount: spec.resources.length, catalogue: result.catalogue, diPatched: diHostPath !== null, diHostPath, filesRemoved: sweep.removed, }, filesCreated, filesModified, warnings, nextSteps: [ 'Run `dotnet build` — the public controllers reuse the {Mod}Permissions.{Section} constants emitted by scaffold-controller; a missing constant means the operation was never declared there.', 'Boot the API once: the catalogue seed provider upserts the DataApiEndpoints rows (it self-heals at every boot — never wire it into derive-seed-delta).', 'Grant an external application access to the new codes — that stays an explicit ADMIN act, no scaffolder creates a grant.', 'Run audit-dev-external-api (DEV-XAPI-001..014) before publishing the contract.', ], }), ) } main()