291 lines
10 KiB
TypeScript
291 lines
10 KiB
TypeScript
/**
|
|
* 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);
|