#!/usr/bin/env node /** * cli:scaffold-api-client — Generate TypeScript API clients + React Query hooks */ import { parseArgsOrFail as parseArgs } from '../../../../../lib/cli-args.js' import { mkdirSync, writeFileSync } from 'node:fs' import { dirname, resolve } from 'node:path' import { validate } from './validate.js' import { generate, legacyPaths } from './generate.js' import { generateEnvelope, failGenerate, printEnvelope } from '../../../../../lib/output.js' import { readSpecArg } from '../../../../../lib/spec-arg.js' import { assertWebProjectRoot, safeJoinPath } from '../../../../../lib/fs.js' import { guardedRm } from '../../../../../lib/guarded-rm.js' import { customisedWarnings, writeGeneratedFiles } from '../../../../../lib/generated-writes.js' const COMMAND = 'scaffold-api-client' 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 { printEnvelope(failGenerate(COMMAND, ['Invalid JSON'])); process.exit(1) } const validation = validate(raw) if (!validation.valid || validation.data === undefined) { printEnvelope(failGenerate(COMMAND, validation.errors)); process.exit(1) } // The PARSED spec (Zod defaults applied) — never the raw JSON: `scope: 'row'`, // `responseType: 'void'`, `fields: []`, `required: true` only exist after // parse; on the raw object a header URL lost its `/${id}` and an entity // without `fields` crashed generate(). const spec = validation.data const files = generate(spec) const outdir = values.outdir ?? spec.projectPath // Idempotent relocate: pre-classification feature files (no segment) // are swept so re-running MOVES the client into src/features//…. The // sweep is guarded (lib/guarded-rm): a legacy path identical to an emitted // path is never listed, an @customised file is preserved with a warning, and // every deletion is traced in the envelope. const emittedPaths = new Set(files.map((f: { path: string }) => f.path)) const legacySweep = legacyPaths(spec).filter((lp: string) => !emittedPaths.has(lp)) if (values.dry_run) { let wouldRemoveLegacy: string[] = [] try { wouldRemoveLegacy = guardedRm(legacySweep, { outdir, dryRun: true }).removed } catch { /* outdir invalid — the real run fails on assertWebProjectRoot */ } printEnvelope(generateEnvelope(COMMAND, { data: { dryRun: true, files: files.map((f: { path: string }) => f.path), wouldRemoveLegacy } })) process.exit(0) } try { assertWebProjectRoot(outdir) } catch (e) { printEnvelope(failGenerate(COMMAND, [e instanceof Error ? e.message : String(e)])); process.exit(1) } const sweep = guardedRm(legacySweep, { outdir }) // Guarded write (lib/generated-writes): a `// @customised` module (a custom onConflict in // outbox/{entity}Outbox.ts, the hand-edits this SKILL asks for) is skipped + warned, never // overwritten — the write path used to honour the marker only on the legacy sweep (B-CRIT-04). const writes = writeGeneratedFiles(files, { outdir }) const written = writes.written printEnvelope(generateEnvelope(COMMAND, { data: { module: spec.module, fileCount: written.length, filesRemoved: sweep.removed, skipped: writes.skipped }, filesCreated: written, warnings: [...sweep.preserved.map((p) => `legacy path ${p} kept: marked @customised — delete manually if truly superseded.`), ...customisedWarnings(writes)], nextSteps: ['Run npm run build to verify'], })) } main()