import { readFile } from 'node:fs/promises'; import { join } from 'node:path'; import type { Command } from 'commander'; import { parseV1MigrationObservations, parseV1ToV2MigrationDecisions, previewV1ToV2Migration, } from '../fleet/v1-v2-migration.js'; export interface FleetMigrationCommandDeps { readonly mosaicHome?: string; readonly rolesDir?: string; readonly overrideDir?: string; readonly readText?: (path: string) => Promise; readonly printJson?: (value: unknown) => void; readonly setExitCode?: (code: number) => void; } interface PreviewOptions { readonly source?: string | boolean; readonly decisions?: string | boolean; readonly observations?: string | boolean; } /** Registers preview-only v1 migration. This command has no mutation verbs or runners. */ export function registerFleetMigrationCommand( fleetCommand: Command, deps: FleetMigrationCommandDeps = {}, ): void { fleetCommand .command('migrate-v1') .description('Preview a field-complete v1-to-v2 roster migration') .command('preview') .description('Compile migration evidence without writing files or changing runtimes') .option('--source [path]', 'v1 roster YAML or JSON') .option('--decisions [path]', 'explicit migration decisions JSON') .option('--observations [path]', 'reviewed lifecycle observations JSON') .action(async (options: PreviewOptions): Promise => { const readText = deps.readText ?? ((path: string): Promise => readFile(path, 'utf8')); const printJson = deps.printJson ?? ((value: unknown): void => console.log(JSON.stringify(value))); const setExitCode = deps.setExitCode ?? ((code: number): void => void (process.exitCode = code)); try { const requiredOptionNames = ['source', 'decisions', 'observations'] as const; const missingOption = requiredOptionNames.find((name) => options[name] === undefined); if (missingOption !== undefined) { printJson({ status: 'blocked', blockers: [ { code: 'missing-migration-preview-option', path: `request.${missingOption}`, detail: 'Required migration preview option is missing.', }, ], }); setExitCode(1); return; } const missingValueOption = requiredOptionNames.find((name) => options[name] === true); if (missingValueOption !== undefined) { printJson({ status: 'blocked', blockers: [ { code: 'missing-migration-preview-option-value', path: `request.${missingValueOption}`, detail: 'Required migration preview option value is missing.', }, ], }); setExitCode(1); return; } const emptyOption = requiredOptionNames.find( (name) => typeof options[name] === 'string' && options[name].trim() === '', ); if (emptyOption !== undefined) { printJson({ status: 'blocked', blockers: [ { code: 'empty-migration-preview-option', path: `request.${emptyOption}`, detail: 'Required migration preview option must be a non-empty path.', }, ], }); setExitCode(1); return; } const sourcePath = options.source; const decisionsPath = options.decisions; const observationsPath = options.observations; if ( typeof sourcePath !== 'string' || typeof decisionsPath !== 'string' || typeof observationsPath !== 'string' ) { throw new Error('Validated migration preview options became unavailable.'); } const [source, decisionsSource, observationsSource] = await Promise.all([ readText(sourcePath), readText(decisionsPath), readText(observationsPath), ]); const decisions = parseV1ToV2MigrationDecisions( parseJsonObject(decisionsSource, 'migration decisions'), ); const observations = parseV1MigrationObservations( parseJsonObject(observationsSource, 'lifecycle observations'), ); const mosaicHome = deps.mosaicHome ?? fleetCommand.opts<{ mosaicHome: string }>().mosaicHome; const preview = await previewV1ToV2Migration({ source, sourcePath, decisions, observations, personaDirs: { rolesDir: deps.rolesDir ?? join(mosaicHome, 'fleet', 'roles'), overrideDir: deps.overrideDir ?? join(mosaicHome, 'fleet', 'roles.local'), }, environment: { mosaicHome, agentEnvDir: join(mosaicHome, 'fleet', 'agents'), }, }); printJson(preview); if (preview.status === 'blocked') setExitCode(1); } catch (error: unknown) { printJson({ status: 'blocked', blockers: [ { code: 'migration-preview-failed', path: 'request', detail: safeErrorDetail(error), }, ], }); setExitCode(1); } }); } function parseJsonObject(source: string, label: string): unknown { let value: unknown; try { value = JSON.parse(source) as unknown; } catch { throw new Error(`${label} must be valid JSON.`); } if (typeof value !== 'object' || value === null || Array.isArray(value)) { throw new Error(`${label} must be a JSON object.`); } return value; } function safeErrorDetail(error: unknown): string { if (error instanceof Error && isPublishableValidationMessage(error.message)) return error.message; return 'Migration preview failed without publishable detail.'; } function isPublishableValidationMessage(message: string): boolean { return /^(migration decisions|lifecycle observations) must be (valid JSON|a JSON object)\.$/.test( message, ); }