#!/usr/bin/env node /** * cli:split-component-registry — Entry point. * * Migration step (a) for apps generated under the legacy MCP flow: split the * monolithic componentRegistry.generated.ts (every page registered INLINE) * into canonical per-module `{app}-{module}Registry.ts` files, VERBATIM * (routing-identical), then re-aggregate in-process. Fail-closed: any key or * statement the split cannot attribute aborts with ZERO writes. * * Invocation : * npx --prefer-offline tsx skills/development/frontend/routes/cli/split-component-registry/index.ts \ * --spec '{"projectPath":""}' * * Spec fields : projectPath (web root), registryFile?, appCode?, dry_run?, * backup? (default true), aggregate? (default true). * * Sequencing : validate → plan (pure) → errors ⇒ exit 1 zero writes → dry_run * ⇒ plan report → backup the monolith → atomic write of the module files → * re-aggregation in-process (aggregate-component-registry with * `overwriteLegacyAggregate: true` — the monolith is still on disk at that * moment, its own guard would otherwise refuse). If the re-aggregation fails, * the monolith is STILL on disk untouched — the app keeps routing; fix the * reported issue then re-run aggregate-component-registry manually. */ import { parseArgs } from 'node:util' import fs from 'node:fs' import path from 'node:path' import { validate } from './validate.js' import { generate } from './generate.js' import { generate as aggregateGenerate } from '../aggregate-component-registry/generate.js' import { writeGeneratedFilesAtomic } from '../aggregate-component-registry/write.js' import { readSpecArg } from '../../../../../lib/spec-arg.js' import { generateEnvelope, failGenerate, printEnvelope } from '../../../../../lib/output.js' const COMMAND = 'split-component-registry' async function main(): Promise { const { values } = parseArgs({ options: { spec: { type: 'string' }, 'spec-file': { type: 'string' }, }, strict: true, }) const specArg = readSpecArg(values) if ('error' in specArg) { printEnvelope(failGenerate(COMMAND, [specArg.error])) process.exit(1) } let raw: unknown try { raw = JSON.parse(specArg.raw) } catch (err) { printEnvelope(failGenerate(COMMAND, [`--spec is not valid JSON: ${err instanceof Error ? err.message : String(err)}`])) process.exit(1) } const validation = await validate(raw) if (!validation.valid || !validation.spec) { printEnvelope(failGenerate(COMMAND, validation.errors)) process.exit(1) } const spec = validation.spec const plan = generate(spec) if (plan.errors.length > 0) { printEnvelope(failGenerate(COMMAND, plan.errors)) process.exit(1) } if (plan.noop) { printEnvelope( generateEnvelope(COMMAND, { data: { noop: true, keysMigrated: 0 }, warnings: [`${spec.registryFile} registers nothing of its own — layout already migrated, nothing to do.`], }), ) process.exit(0) } const moduleFilesData = plan.moduleFiles.map((f) => ({ file: f.path, keys: f.keys })) if (spec.dry_run) { printEnvelope( generateEnvelope(COMMAND, { data: { dryRun: true, keysMigrated: plan.keysMigrated, moduleFiles: moduleFilesData, droppedDuplicates: plan.droppedDuplicates, droppedSideEffects: plan.droppedSideEffects, }, warnings: plan.warnings, }), ) process.exit(0) } // Backup the monolith (`.bak` — outside the *Registry.ts contract and the build). const registryAbs = path.join(spec.projectPath, spec.registryFile) let backupFile: string | null = null if (spec.backup) { const stamp = new Date().toISOString().replace(/[-:T]/g, '').slice(0, 14) backupFile = spec.registryFile.replace(/[^/]+$/, `componentRegistry.legacy-${stamp}.bak`) fs.copyFileSync(registryAbs, path.join(spec.projectPath, backupFile)) } const written = writeGeneratedFilesAtomic( spec.projectPath, plan.moduleFiles.map((f) => ({ path: f.path, content: f.content })), ) // Re-aggregation in-process — replaces the monolith with a clean aggregate // importing the freshly split module files. let aggregateData: { rewritten: boolean; registeredKeys: number } = { rewritten: false, registeredKeys: 0 } if (spec.aggregate) { const agg = aggregateGenerate({ projectPath: spec.projectPath, exclude: [], overwriteLegacyAggregate: true }) const aggErrors: string[] = [ ...agg.unresolved.map((u) => `Phantom import in ${u.module}Registry.ts: '${u.importPath}' does not resolve on disk.`), ...agg.collisions.map((c) => `componentKey collision: '${c.key}' registered by ${c.modules.join(' + ')}.`), ...agg.errors, ] if (aggErrors.length > 0) { printEnvelope( failGenerate(COMMAND, [ `Split written (${written.length} module file(s)) but the re-aggregation FAILED — the monolith ` + `${spec.registryFile} is STILL on disk untouched, the app keeps routing. Fix the issue(s) below, then ` + `re-run aggregate-component-registry manually.`, ...aggErrors, ]), ) process.exit(1) } const aggWritten = writeGeneratedFilesAtomic(spec.projectPath, agg.files) written.push(...aggWritten) aggregateData = { rewritten: true, registeredKeys: agg.parsed.reduce((acc, p) => acc + p.keys.length, 0), } } printEnvelope( generateEnvelope(COMMAND, { data: { keysMigrated: plan.keysMigrated, moduleFiles: moduleFilesData, droppedDuplicates: plan.droppedDuplicates, droppedSideEffects: plan.droppedSideEffects, backupFile, aggregate: aggregateData, }, filesCreated: written, warnings: plan.warnings, nextSteps: [ 'Run `npm run build` in the web app — every lazy chunk must resolve (routing-identical migration).', ...(spec.aggregate ? [] : ['Re-run aggregate-component-registry to replace the monolith with the clean aggregate.']), 'Optionally regenerate each module canonically (pages + registry) — see the frontend-routes SKILL, ' + 'section "Migrating a legacy monolithic registry", step (b).', ...(backupFile ? [`Delete ${backupFile} once the migration is verified.`] : []), ], }), ) } main().catch((err) => { printEnvelope(failGenerate(COMMAND, [err instanceof Error ? (err.stack ?? err.message) : String(err)])) process.exit(1) })