feat: initial public release (MAESTRO)
This commit is contained in:
@@ -0,0 +1,290 @@
|
||||
/**
|
||||
* CLI entry point for `scripts/migrate-config.sh`.
|
||||
*
|
||||
* Reads `config.yaml`, runs it through `normalizeConfig`, and writes the
|
||||
* v2-shaped result back. Always backs up the original to
|
||||
* `config.yaml.bak-<timestamp>` before rewriting. Dry-run mode prints the
|
||||
* normalized YAML to stdout + a (rough) diff to stderr and never touches
|
||||
* the source file.
|
||||
*
|
||||
* Exit codes:
|
||||
* 0 success (incl. "already v2 → no-op")
|
||||
* 1 IO failure (read / write / backup)
|
||||
* 2 parse / validation error
|
||||
* 3 invalid CLI usage
|
||||
*
|
||||
* Design notes:
|
||||
* - We deliberately re-read the file AS YAML rather than going through
|
||||
* loadConfig() — loadConfig applies defaults + env overrides that we
|
||||
* don't want to persist back to disk. The migration must be a pure
|
||||
* syntactic transform of what the operator wrote.
|
||||
* - We DO run `transformKeys` (snake→camel → normalize → snake) to reuse
|
||||
* the same normalizer the runtime uses. This guarantees the dry-run
|
||||
* output matches what loadConfig would produce internally.
|
||||
* - Comments + key ordering are NOT preserved (yaml.parse loses them).
|
||||
* Users keep the .bak file as their source of truth for any custom
|
||||
* comments they want to re-introduce.
|
||||
*/
|
||||
import { existsSync, readFileSync, writeFileSync, copyFileSync } from 'fs';
|
||||
import { resolve } from 'path';
|
||||
import { parse as parseYaml, stringify as stringifyYaml } from 'yaml';
|
||||
import { normalizeConfig, UnsupportedConfigVersionError } from '../config-normalize.js';
|
||||
import { toSnakeKeys } from '../config.js';
|
||||
|
||||
interface Cli {
|
||||
dryRun: boolean;
|
||||
configPath: string;
|
||||
help: boolean;
|
||||
}
|
||||
|
||||
function parseCli(argv: string[]): Cli {
|
||||
const out: Cli = { dryRun: false, configPath: 'config.yaml', help: false };
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const a = argv[i];
|
||||
if (a === '--dry-run') out.dryRun = true;
|
||||
else if (a === '--help' || a === '-h') out.help = true;
|
||||
else if (a === '--config' || a === '-c') {
|
||||
const v = argv[++i];
|
||||
if (!v) {
|
||||
process.stderr.write('error: --config requires a path argument\n');
|
||||
process.exit(3);
|
||||
}
|
||||
out.configPath = v;
|
||||
} else if (a && a.startsWith('-')) {
|
||||
process.stderr.write(`error: unknown flag ${a}\n`);
|
||||
process.exit(3);
|
||||
} else if (a) {
|
||||
// bare positional → treat as config path (for `migrate-config.sh path/to/config.yaml`)
|
||||
out.configPath = a;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function printHelp(): void {
|
||||
process.stdout.write([
|
||||
'Usage: migrate-config [--dry-run] [--config PATH]',
|
||||
'',
|
||||
'Convert a legacy (v1) config.yaml to the v2 layout in-place.',
|
||||
'',
|
||||
'Options:',
|
||||
' --dry-run Print the normalized YAML to stdout + diff to stderr.',
|
||||
' Does not modify the source file.',
|
||||
' --config PATH Path to config.yaml (default: ./config.yaml).',
|
||||
' -h, --help Show this help and exit.',
|
||||
'',
|
||||
'Exit codes:',
|
||||
' 0 success (incl. already-v2 no-op)',
|
||||
' 1 IO failure',
|
||||
' 2 parse / validation error',
|
||||
' 3 invalid CLI usage',
|
||||
'',
|
||||
].join('\n'));
|
||||
}
|
||||
|
||||
/** snake_case → camelCase recursive (matches src/config.ts transformKeys). */
|
||||
function toCamel(s: string): string {
|
||||
return s.replace(/_([a-z])/g, (_, c: string) => c.toUpperCase());
|
||||
}
|
||||
function transformCamel(obj: unknown): unknown {
|
||||
if (Array.isArray(obj)) return obj.map(transformCamel);
|
||||
if (obj !== null && typeof obj === 'object') {
|
||||
return Object.fromEntries(
|
||||
Object.entries(obj as Record<string, unknown>).map(([k, v]) => [toCamel(k), transformCamel(v)]),
|
||||
);
|
||||
}
|
||||
return obj;
|
||||
}
|
||||
|
||||
/**
|
||||
* Produce a tiny line-level diff suitable for stderr. We deliberately avoid
|
||||
* an extra dep (diff/jsdiff) — operators just need a "what changed" hint.
|
||||
*/
|
||||
function lineDiff(oldText: string, newText: string): string {
|
||||
const oldLines = oldText.split('\n');
|
||||
const newLines = newText.split('\n');
|
||||
const oldSet = new Set(oldLines);
|
||||
const newSet = new Set(newLines);
|
||||
const lines: string[] = [];
|
||||
for (const ln of oldLines) {
|
||||
if (!newSet.has(ln)) lines.push(`- ${ln}`);
|
||||
}
|
||||
for (const ln of newLines) {
|
||||
if (!oldSet.has(ln)) lines.push(`+ ${ln}`);
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function main(argv: string[]): number {
|
||||
const cli = parseCli(argv);
|
||||
if (cli.help) {
|
||||
printHelp();
|
||||
return 0;
|
||||
}
|
||||
|
||||
const absPath = resolve(cli.configPath);
|
||||
if (!existsSync(absPath)) {
|
||||
process.stderr.write(`error: config file not found: ${absPath}\n`);
|
||||
return 1;
|
||||
}
|
||||
|
||||
let rawText: string;
|
||||
try {
|
||||
rawText = readFileSync(absPath, 'utf-8');
|
||||
} catch (e) {
|
||||
process.stderr.write(`error: could not read ${absPath}: ${(e as Error).message}\n`);
|
||||
return 1;
|
||||
}
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = parseYaml(rawText);
|
||||
} catch (e) {
|
||||
process.stderr.write(`error: YAML parse failed: ${(e as Error).message}\n`);
|
||||
return 2;
|
||||
}
|
||||
|
||||
// Early-out: already v2? Don't rewrite (would shuffle key order
|
||||
// needlessly + drop comments). We still allow --dry-run to confirm.
|
||||
const camelInput = transformCamel(parsed) as Record<string, unknown> | null;
|
||||
const inputVersion =
|
||||
camelInput && typeof camelInput === 'object' ? camelInput.configVersion : undefined;
|
||||
|
||||
let normalized: ReturnType<typeof normalizeConfig>;
|
||||
try {
|
||||
normalized = normalizeConfig(camelInput);
|
||||
} catch (e) {
|
||||
if (e instanceof UnsupportedConfigVersionError) {
|
||||
process.stderr.write(`error: ${e.message}\n`);
|
||||
return 2;
|
||||
}
|
||||
process.stderr.write(`error: normalization failed: ${(e as Error).message}\n`);
|
||||
return 2;
|
||||
}
|
||||
|
||||
if (inputVersion === 2 && !cli.dryRun) {
|
||||
process.stdout.write(`already up to date: config_version=2 at ${absPath}\n`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Compose the v2 output. We strip the legacy `provider.*` block because
|
||||
// that's the entire point of the migration — but preserve any other
|
||||
// top-level keys verbatim (gateway, auth, mcp, ssh, etc.).
|
||||
const out: Record<string, unknown> = {};
|
||||
out.config_version = 2;
|
||||
if (normalized.llm) out.llm = normalized.llm;
|
||||
// Skip empty storage blocks — happens when the source file used only
|
||||
// defaults (no worktree_dir / custom_pieces_dir / etc. overrides).
|
||||
if (normalized.storage && Object.keys(normalized.storage).length > 0) {
|
||||
out.storage = normalized.storage;
|
||||
}
|
||||
|
||||
// Pass-through: every top-level key from input *except* the ones the v2
|
||||
// layout supersedes. We deliberately keep `gateway`, `auth`, `branding`,
|
||||
// `mcp`, `ssh`, `tools`, `reflection`, `notes`, `safety`, `context`,
|
||||
// `subtasks`, `ask`, `retry`, `concurrency`, `maxMovements`, `secrets`,
|
||||
// `searchFilter`, `browser`, `customPiecesDir`, `userFolderRoot`,
|
||||
// `worktreeDir`.
|
||||
//
|
||||
// The supersession list intentionally drops the *individual* legacy
|
||||
// keys that are now under storage.* so users get a clean v2 file. They
|
||||
// can re-add them as overrides if needed (storage.* always wins).
|
||||
const SUPERSEDED = new Set([
|
||||
'provider', // → llm.*
|
||||
'configVersion', // already written as snake above
|
||||
'llm', // already written from normalizer
|
||||
'storage', // already written from normalizer
|
||||
'worktreeDir', // → storage.worktreeDir
|
||||
'customPiecesDir', // → storage.customPiecesDir
|
||||
'userFolderRoot', // → storage.userFolderRoot
|
||||
]);
|
||||
const inputObj = (camelInput ?? {}) as Record<string, unknown>;
|
||||
for (const [k, v] of Object.entries(inputObj)) {
|
||||
if (SUPERSEDED.has(k)) continue;
|
||||
if (k === 'tools' && v && typeof v === 'object') {
|
||||
// Strip tools.task_upload_max_size_mb and tools.trash_retention_days
|
||||
// since they migrated into storage.*. Keep every other tools.* key.
|
||||
const tools = { ...(v as Record<string, unknown>) };
|
||||
delete tools.taskUploadMaxSizeMb;
|
||||
delete tools.trashRetentionDays;
|
||||
if (Object.keys(tools).length > 0) out.tools = tools;
|
||||
continue;
|
||||
}
|
||||
out[k] = v;
|
||||
}
|
||||
|
||||
// Convert camelCase → snake_case for the on-disk YAML.
|
||||
// toSnakeKeys handles nested objects + arrays. The keys we wrote directly
|
||||
// (config_version, llm, storage, tools) get re-snake'd too — idempotent.
|
||||
const snakeOut = toSnakeKeys(out) as Record<string, unknown>;
|
||||
|
||||
// Render. lineWidth: 120 matches ConfigManager.updateConfig's writer.
|
||||
let yamlOut: string;
|
||||
try {
|
||||
yamlOut = stringifyYaml(snakeOut, { lineWidth: 120 });
|
||||
} catch (e) {
|
||||
process.stderr.write(`error: YAML render failed: ${(e as Error).message}\n`);
|
||||
return 2;
|
||||
}
|
||||
|
||||
// Re-validate by round-tripping through normalizeConfig once more — if
|
||||
// we produced something the loader can't read, we want to know now,
|
||||
// not at server restart time.
|
||||
try {
|
||||
normalizeConfig(transformCamel(parseYaml(yamlOut)));
|
||||
} catch (e) {
|
||||
process.stderr.write(
|
||||
`error: produced YAML does not re-normalize cleanly: ${(e as Error).message}\n`,
|
||||
);
|
||||
return 2;
|
||||
}
|
||||
|
||||
if (cli.dryRun) {
|
||||
process.stdout.write(yamlOut);
|
||||
if (!yamlOut.endsWith('\n')) process.stdout.write('\n');
|
||||
const diff = lineDiff(rawText, yamlOut);
|
||||
if (diff) {
|
||||
process.stderr.write('--- diff (input vs migrated) ---\n');
|
||||
process.stderr.write(diff + '\n');
|
||||
process.stderr.write('--- end diff ---\n');
|
||||
} else {
|
||||
process.stderr.write('(no textual diff)\n');
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Real write path: backup → write → done.
|
||||
const ts = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
const backupPath = `${absPath}.bak-${ts}`;
|
||||
try {
|
||||
copyFileSync(absPath, backupPath);
|
||||
} catch (e) {
|
||||
process.stderr.write(`error: backup failed (${backupPath}): ${(e as Error).message}\n`);
|
||||
return 1;
|
||||
}
|
||||
|
||||
try {
|
||||
writeFileSync(absPath, yamlOut, 'utf-8');
|
||||
} catch (e) {
|
||||
// Roll back from backup. If even the rollback fails, leave both files
|
||||
// on disk and surface a loud error.
|
||||
try {
|
||||
copyFileSync(backupPath, absPath);
|
||||
process.stderr.write(
|
||||
`error: write failed, rolled back from ${backupPath}: ${(e as Error).message}\n`,
|
||||
);
|
||||
} catch (rb) {
|
||||
process.stderr.write(
|
||||
`error: write AND rollback failed. Original at ${backupPath}, ` +
|
||||
`current state of ${absPath} unknown: ${(rb as Error).message}\n`,
|
||||
);
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
process.stdout.write(`migrated ${absPath} → v2; backup at ${backupPath}\n`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
const code = main(process.argv.slice(2));
|
||||
process.exit(code);
|
||||
Reference in New Issue
Block a user