/** * 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-` 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).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 | null; const inputVersion = camelInput && typeof camelInput === 'object' ? camelInput.configVersion : undefined; let normalized: ReturnType; 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 = {}; 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; 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) }; 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; // 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);