#!/usr/bin/env node /** * cli:scaffold-ui-primitives — Scaffold theme-compliant UI primitives. * * Idempotent: skips writes if existing file content matches; honors * `/* @customised *\/` (or `// @customised`) marker at the top of the file. * For JSON locale files, deep-merges the `entityLookup.*` namespace into * existing content so siblings (auth.*, nav.*, …) are preserved. */ import { parseArgs } from 'node:util'; import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; import { dirname, resolve } from 'node:path'; import { validate } from './validate.js'; import { generate } 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'; const COMMAND = 'scaffold-ui-primitives'; function isCustomised(source: string): boolean { const head = source.trimStart().slice(0, 100); return head.startsWith('/* @customised') || head.startsWith('// @customised'); } /** * Recursively merge `patch` on top of `base`. Objects are merged key-by-key, * arrays and primitives in `patch` replace those in `base`. Used for locale * JSON so we override only the `entityLookup.*` namespace and never wipe * sibling namespaces (auth.*, nav.*, …). */ function deepMerge(base: unknown, patch: unknown): unknown { if ( base !== null && typeof base === 'object' && !Array.isArray(base) && patch !== null && typeof patch === 'object' && !Array.isArray(patch) ) { const out: Record = { ...(base as Record) }; for (const [k, v] of Object.entries(patch as Record)) { out[k] = deepMerge(out[k], v); } return out; } return patch; } function main(): void { const { values } = parseArgs({ options: { spec: { type: 'string' }, 'spec-file': { 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 in --spec'])); process.exit(1); } const validation = validate(raw); if (!validation.valid || !validation.spec) { printEnvelope(failGenerate(COMMAND, validation.errors)); process.exit(1); } const spec = validation.spec; const files = generate(spec); if (values.dry_run) { printEnvelope(generateEnvelope(COMMAND, { data: { dryRun: true, files: files.map((f) => f.path) }, warnings: validation.warnings, })); process.exit(0); } // Fail-closed guard: refuse to write into a .NET backend / repo root. Frontend // scaffolds write to /src/… — a wrong projectPath (the repo root) // would land files in the backend's src/ and trigger destructive "cleanup". try { assertWebProjectRoot(spec.projectPath); } catch (e) { printEnvelope(failGenerate(COMMAND, [e instanceof Error ? e.message : String(e)])); process.exit(1); } const filesCreated: string[] = []; const filesModified: string[] = []; const skipped: string[] = []; for (const file of files) { const abs = resolve(safeJoinPath(spec.projectPath, file.path)); const exists = existsSync(abs); if (exists && file.strategy === 'overwrite') { const existing = readFileSync(abs, 'utf-8'); if (isCustomised(existing)) { skipped.push(file.path); continue; } if (!spec.force && existing === file.content) continue; mkdirSync(dirname(abs), { recursive: true }); writeFileSync(abs, file.content, 'utf-8'); filesModified.push(abs); } else if (exists && file.strategy === 'deep-merge-json') { let existingJson: unknown = {}; try { existingJson = JSON.parse(readFileSync(abs, 'utf-8')); } catch { // Malformed JSON — overwrite with the canonical patch rather than fail. existingJson = {}; } const patch = JSON.parse(file.content); const merged = deepMerge(existingJson, patch); const out = JSON.stringify(merged, null, 2) + '\n'; const current = readFileSync(abs, 'utf-8'); if (!spec.force && current === out) continue; writeFileSync(abs, out, 'utf-8'); filesModified.push(abs); } else { mkdirSync(dirname(abs), { recursive: true }); // For new locale files we still write the patch verbatim (it's already // a complete JSON document with just our namespace). writeFileSync(abs, file.content, 'utf-8'); filesCreated.push(abs); } } const nextSteps: string[] = []; if (skipped.length > 0) { nextSteps.push(`Skipped ${skipped.length} customised file(s): ${skipped.join(', ')}. Remove the @customised marker to allow overwrites.`); } if (filesCreated.length === 0 && filesModified.length === 0 && skipped.length === 0) { nextSteps.push('UI primitives already up-to-date — no changes needed.'); } if (filesCreated.length > 0) { nextSteps.push('scaffold-component now resolves @/components/ui/EntityLookup for FK fields. Re-run audit-dev-frontend to clear DEV-UI-022.'); } printEnvelope(generateEnvelope(COMMAND, { data: { fileCount: filesCreated.length + filesModified.length, skipped: skipped.length }, filesCreated, filesModified, warnings: validation.warnings, nextSteps, })); } main();