feat: initial public release (MAESTRO)

This commit is contained in:
oss-sync
2026-06-03 05:08:00 +00:00
commit f5c7666f6b
823 changed files with 184150 additions and 0 deletions
+290
View File
@@ -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);