feat(fleet): add reviewed v1-to-v2 migration preview
All checks were successful
ci/woodpecker/pr/ci Pipeline was successful
All checks were successful
ci/woodpecker/pr/ci Pipeline was successful
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
153
packages/mosaic/src/commands/fleet-migration-command.ts
Normal file
153
packages/mosaic/src/commands/fleet-migration-command.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
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<string>;
|
||||
readonly printJson?: (value: unknown) => void;
|
||||
readonly setExitCode?: (code: number) => void;
|
||||
}
|
||||
|
||||
interface PreviewOptions {
|
||||
readonly source?: string;
|
||||
readonly decisions?: string;
|
||||
readonly observations?: string;
|
||||
}
|
||||
|
||||
/** 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<void> => {
|
||||
const readText = deps.readText ?? ((path: string): Promise<string> => 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 emptyOption = requiredOptionNames.find((name) => 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 (
|
||||
sourcePath === undefined ||
|
||||
decisionsPath === undefined ||
|
||||
observationsPath === undefined
|
||||
) {
|
||||
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,
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user