Some checks failed
ci/woodpecker/pr/ci Pipeline failed
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
171 lines
6.0 KiB
TypeScript
171 lines
6.0 KiB
TypeScript
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 | 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<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 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,
|
|
);
|
|
}
|