#!/usr/bin/env node /** * cli:scaffold-component — Generate React page components from the * customisation-ui baseline templates. * * Output location options (precedence order): * 1. --pages-dir Absolute location of the pages folder. * Overrides the default layout entirely. * Example: --pages-dir src/pages/crm/prospection/leads * → pages land at src/pages/crm/prospection/leads/LeadListPage.tsx * (i18n files still land at src/i18n/… under the * validated web root) * 2. --outdir Project root to write into. Pages go to the * default feature layout * (src/features/{module}/{entity}/pages/). * 3. (spec.projectPath) Fallback when neither flag is set. * * The --pages-dir flag is the regeneration-mode input: ui-polish's regenerate * pass passes the existing page's directory so the new conformant file * OVERWRITES the old one in-place, regardless of project layout convention. */ import { parseArgs } from 'node:util' import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { dirname, resolve, join, isAbsolute } from 'node:path' import { validate } from './validate.js' import { generate } from './generate.js' import { checkPrimitivePreflight } from './preflight.js' import { readExistingI18n, writeFileAtomic } from './i18n-io.js' import { loadRoutesFamilies } from '../../../../../lib/routes-registry.js' import type { GenerateContext } from './types.js' import { generateEnvelope, failGenerate, printEnvelope } from '../../../../../lib/output.js' import { readSpecArg } from '../../../../../lib/spec-arg.js' import { assertWebProjectRoot, safeJoinPath, validatePathSecurity } from '../../../../../lib/fs.js' import { parseRelatedTabs, relatedAppOf } from '../../../../../lib/page-spec-related-tabs.js' import { deepMerge } from '../../../../../lib/json-merge.js' import { pluralize } from '../../../../../lib/string-utils.js' import { buildRegistryIndex } from '../../../../../lib/registry-index.js' import { checkRegistryCollisions } from './registry-guard.js' /** * A generated page can opt OUT of regeneration by starting with a `@customised` * marker (architecture C bespoke seam) — same contract as scaffold-layout / * scaffold-ui-primitives. When present, scaffold-component PRESERVES the file * (an LLM ui-design pass owns it) instead of overwriting the judgment. */ function isCustomised(source: string): boolean { const head = source.trimStart().slice(0, 100) return head.startsWith('/* @customised') || head.startsWith('// @customised') } const COMMAND = 'scaffold-component' function main(): void { const { values } = parseArgs({ options: { spec: { type: 'string' }, 'spec-file': { type: 'string' }, outdir: { type: 'string' }, 'pages-dir': { type: 'string' }, overwrite: { type: 'boolean', default: false }, dry_run: { type: 'boolean', default: false }, // Reset ONE entity's i18n subtree to current floor + this call's PRD keys // (sibling entities untouched). The sanctioned path for shipping a NEW // floor wording to an existing catalogue — under the normal precedence // (floor < existing < PRD) a floor change never reaches an existing file. // Pass ONLY on the FIRST view call of a full re-scaffold: later per-view // calls must run without it or they stomp the previous view's PRD keys. 'reset-i18n-entity': { 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) { printEnvelope(failGenerate(COMMAND, validation.errors)) process.exit(1) } const spec = raw as any // Resolved BEFORE generate(): the same root feeds the existing-i18n context // read (below) and the write loop, so both resolve identical paths. const outdir = values.outdir ?? spec.projectPath // 360 related tabs: load the ROUTE FAMILIES the target modules' generated // *Routes.ts actually declare, so generate() can verify each tab's family // against reality (unknown family = hard error) and resolve the create/ // row-open emission from the create()/detail() helpers. A missing Routes.ts // (first run — scaffold-routes not run yet) degrades to warnings. const ctx: GenerateContext = { warnings: [] } const { tabs: specRelatedTabs } = parseRelatedTabs(spec.pageSpec?.relatedTabs) if (specRelatedTabs.length > 0 && spec.projectPath && spec.appCode) { // Keyed by `{app}-{module}` — the identity of the generated *Routes.ts file — because a // tab may target another application, and two applications may ship the same module code. ctx.routesFamilies = {} for (const t of specRelatedTabs) { const app = relatedAppOf(t, spec.appCode) Object.assign( ctx.routesFamilies, loadRoutesFamilies(spec.projectPath, app, [t.relatedModule]), ) } } // Existing i18n module catalogues → generate() emits the already-merged full // file (sibling entities + precedence floor < existing < PRD). Fail-closed: // a malformed existing catalogue used to be silently OVERWRITTEN, wiping the // whole module's manual translations — now the call refuses instead. const resetI18nEntity = values['reset-i18n-entity'] === true if (outdir) { const { existingI18n, malformed } = readExistingI18n(outdir, spec.module) if (malformed.length > 0) { printEnvelope(failGenerate(COMMAND, malformed.map(p => `Existing i18n catalogue is not a valid JSON object: ${p} — refusing to merge or overwrite. Fix or delete the file, then re-run.`))) process.exit(1) } if (resetI18nEntity) { const eLower = spec.entity.charAt(0).toLowerCase() + spec.entity.slice(1) for (const loc of Object.keys(existingI18n)) delete existingI18n[loc]![eLower] } if (Object.keys(existingI18n).length > 0) ctx.existingI18n = existingI18n } let files: ReturnType try { files = generate(spec, ctx) } catch (e) { printEnvelope(failGenerate(COMMAND, [e instanceof Error ? e.message : String(e)])) process.exit(1) return } // --pages-dir rewrites page output paths (relative to spec.projectPath). // i18n files keep their default layout. const pagesDirArg = values['pages-dir'] as string | undefined const effectiveFiles = pagesDirArg ? files.map((f) => { if (/\.tsx$/i.test(f.path) && f.path.includes('/pages/')) { const basename = f.path.split('/').pop()! const absTarget = isAbsolute(pagesDirArg) ? join(pagesDirArg, basename) : join(pagesDirArg, basename) return { ...f, path: absTarget } } return f }) : files if (values.dry_run) { printEnvelope(generateEnvelope(COMMAND, { data: { dryRun: true, files: effectiveFiles.map(f => f.path), pagesDirArg: pagesDirArg ?? null }, })) 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(outdir) } catch (e) { printEnvelope(failGenerate(COMMAND, [e instanceof Error ? e.message : String(e)])) process.exit(1) } const outdirAbs = resolve(outdir) // Registry-takeover guard (fail-closed) — the LIVE registry may already // serve this invocation's componentKeys from DIFFERENT page files (legacy // app, pages outside the canonical convention). Writing the canonical page // would leave two pages for one key, the registry still serving the old one // (the PickEBike dead-duplicate class). `allowTakeover: true` is the // explicit consent of the canonical-regeneration flow. const effectivePlural = spec.pluralName ?? pluralize(spec.entity) const takeoverWarnings: string[] = [] let takeovers: Array<{ key: string; from: string; to: string }> = [] if (spec.appCode && spec.module && spec.section) { const emittedByView = new Map() for (const file of effectiveFiles) { if (!/\.tsx$/i.test(file.path)) continue const base = file.path.replace(/\\/g, '/').split('/').pop()! const abs = isAbsolute(file.path) ? resolve(file.path) : resolve(safeJoinPath(outdir, file.path)) if (base === `${effectivePlural}ListPage.tsx`) emittedByView.set('list', abs) else if (base === `${spec.entity}DetailPage.tsx`) emittedByView.set('detail', abs) else if (base === `${spec.entity}FormPage.tsx`) { emittedByView.set('create', abs) emittedByView.set('edit', abs) } // hub/kanban/dashboard views are out of the guard's v1 scope: their keys // are representations of the list key, never independently re-pointed. } if (emittedByView.size > 0) { const index = buildRegistryIndex(outdirAbs) const byKey = new Map( [...index.byKey.entries()].map(([k, v]) => [ k, { file: v.file, resolvedAbsPath: v.registration.resolvedAbsPath }, ]), ) const collisions = checkRegistryCollisions({ byKey, appCode: spec.appCode, module: spec.module, section: spec.section, emittedByView, }) if (collisions.length > 0 && spec.allowTakeover !== true) { printEnvelope(failGenerate(COMMAND, collisions.map(c => `Component key '${c.key}' is already served by the live registry from '${c.registeredFile}' but this ` + `invocation would emit '${c.emittedFile}' — writing it would leave TWO pages for one key (the registry ` + `keeps serving the old one). Either (a) regenerate IN PLACE: pass pageSpec.filePath = the registered ` + `page's path, or (b) pass allowTakeover: true to intentionally re-point the key to the canonical path — ` + `then re-run scaffold-routes + aggregate-component-registry and remove the de-routed legacy file.`))) process.exit(1) } takeovers = collisions.map(c => ({ key: c.key, from: c.registeredFile, to: c.emittedFile })) takeoverWarnings.push(...collisions.map(c => `takeover: key '${c.key}' re-pointed from ${c.registeredFile} to ${c.emittedFile} — legacy file left on ` + `disk; re-run scaffold-routes + aggregate-component-registry, then remove it.`)) } } // Fail-closed primitive preflight — every '@/components/ui/

' import of // the output must resolve on disk (or be written by THIS invocation). // `ss upgrade` never re-runs scaffold-ui-primitives: a page importing a // newer primitive (SectionCard…) against a stale project compiled against // nothing, and the gate's auto-heal then improvised the markup by hand (the // stacked read+edit shape DEV-UI-045 flags). Refuse with the remediation // named instead of shipping the improvisation. const primitiveMisses = checkPrimitivePreflight(outdirAbs, effectiveFiles, existsSync, join) if (primitiveMisses.length > 0) { printEnvelope(failGenerate(COMMAND, primitiveMisses.map(m => `Missing UI primitive on disk: ${m.expected} (the generated pages import '@/components/ui/${m.primitive}'). ` + `Run scaffold-ui-primitives (and scaffold-layout for PageTemplate) BEFORE scaffold-component: ` + `npx --prefer-offline tsx skills/development/frontend/ui-primitives/cli/scaffold-ui-primitives/index.ts ` + `--spec '{"projectPath":""}'`))) process.exit(1) } const written: string[] = [] const skipped: string[] = [] // Naive-plural rename cleanup (mirror of scaffold-business.legacyPaths): // before 2026-08-25 the plural fallback was `entity + 's'` // (CategorysListPage.tsx). When the effective plural now differs, delete the // previous run's naive-named pages so tsc and the routes registry no longer // see two copies. `@customised` pages are preserved (bespoke seam). const naivePlural = `${spec.entity}s` if (effectivePlural !== naivePlural) { const emitted = new Set(effectiveFiles.map(f => f.path)) for (const file of effectiveFiles) { if (!/\.tsx$/i.test(file.path)) continue // Rename only the BASENAME (the directory may legitimately contain the // entity name — e.g. a kebab section folder). const norm = file.path.replace(/\\/g, '/') const cut = norm.lastIndexOf('/') + 1 const base = norm.slice(cut) if (!base.includes(effectivePlural)) continue const legacy = file.path.slice(0, cut) + base.split(effectivePlural).join(naivePlural) if (emitted.has(legacy)) continue const p = isAbsolute(legacy) ? resolve(legacy) : resolve(safeJoinPath(outdir, legacy)) validatePathSecurity(p, outdirAbs) if (existsSync(p) && !isCustomised(readFileSync(p, 'utf-8'))) { rmSync(p, { force: true }) } } } for (const file of effectiveFiles) { // When --pages-dir is ABSOLUTE, skip the outdir join — but keep the write // inside the validated web app root. let p: string if (isAbsolute(file.path)) { p = resolve(file.path) validatePathSecurity(p, outdirAbs) } else { p = resolve(safeJoinPath(outdir, file.path)) } // Bespoke seam (architecture C): a page marked `@customised` is owned by an // LLM ui-design pass — never overwrite it. (i18n JSON is merged below, not a // page file, so it is unaffected by this guard.) if (/\.tsx$/i.test(p) && existsSync(p) && isCustomised(readFileSync(p, 'utf-8'))) { skipped.push(p) continue } mkdirSync(dirname(p), { recursive: true }) // i18n locale files at /src/i18n/locales//.json are // module-level — multiple entities under the same module share the file. // The emitted content is ALREADY the merged full module catalogue // (generate() folds ctx.existingI18n in), so the write-time deep-merge is // belt-and-suspenders: it only matters for a sibling entity ADDED between // the context read and this write (same-module scaffolds stay serialized — // atomic rename fixes torn writes, not lost updates). Under // --reset-i18n-entity the merge is SKIPPED on purpose: merging against the // on-disk base would resurrect the very keys the reset dropped. const isI18nLocaleJson = /[\\\/]i18n[\\\/]locales[\\\/][^\\\/]+[\\\/][^\\\/]+\.json$/i.test(p) if (isI18nLocaleJson) { let content = file.content if (!resetI18nEntity && existsSync(p)) { let existing: unknown try { existing = JSON.parse(readFileSync(p, 'utf-8')) as unknown } catch { // Fail-closed: NEVER silently overwrite a malformed catalogue (the // historical fallback wiped whole modules' manual translations). // Only reachable when the corruption appeared AFTER the context read. printEnvelope(failGenerate(COMMAND, [ `Existing i18n catalogue is not valid JSON: ${p} — refusing to merge or overwrite. Fix or delete the file, then re-run. Files written before this failure: ${written.length}.`, ])) process.exit(1) } content = JSON.stringify(deepMerge(existing, JSON.parse(file.content)), null, 2) + '\n' } writeFileAtomic(p, content) written.push(p) continue } writeFileSync(p, file.content, 'utf-8') written.push(p) } printEnvelope(generateEnvelope(COMMAND, { data: { entity: spec.entity, views: spec.views, fileCount: written.length, overwrite: values.overwrite ?? false, resetI18nEntity, pagesDir: pagesDirArg ?? null, skipped, takeovers, // Related-tab routing notices (family unverified, legacy defaults, // recomposed sub-view create permission) — non-fatal but actionable. relatedTabWarnings: ctx.warnings ?? [], }, filesCreated: written, warnings: takeoverWarnings, nextSteps: ['Run npm run build'], })) } main()