From cc860d3bfb7373a77faca6db8f381bab17117618 Mon Sep 17 00:00:00 2001 From: harshitsinghbhandari <24b4506@iitb.ac.in> Date: Tue, 16 Jun 2026 18:10:00 +0530 Subject: [PATCH] feat(cli): add ao migrate to port legacy projects+settings to rewrite daemon Implements the legacy half of the legacy->rewrite migration spec (aoagents/ReverbCode#247 sections 1 + 3): a new `ao migrate` command that ports the registered project registry and per-project settings into the rewrite (Go/Electron) daemon over its loopback REST API, mirroring the rewrite's own `ao project add` flow (POST /api/v1/projects then PUT /api/v1/projects/{id}/config). Sessions are out of scope for this cut. Respects the rewrite invariant that the daemon is the sole writer of ~/.ao/data/ao.db (the CLI never opens the DB). Ref: aoagents/ReverbCode#247 Co-Authored-By: Claude Opus 4.8 --- packages/cli/src/commands/migrate.ts | 141 ++++++++ packages/cli/src/lib/migrate.ts | 480 +++++++++++++++++++++++++++ packages/cli/src/program.ts | 2 + 3 files changed, 623 insertions(+) create mode 100644 packages/cli/src/commands/migrate.ts create mode 100644 packages/cli/src/lib/migrate.ts diff --git a/packages/cli/src/commands/migrate.ts b/packages/cli/src/commands/migrate.ts new file mode 100644 index 000000000..5e29394b9 --- /dev/null +++ b/packages/cli/src/commands/migrate.ts @@ -0,0 +1,141 @@ +import type { Command } from "commander"; +import chalk from "chalk"; +import { recordActivityEvent } from "@aoagents/ao-core"; +import { + DaemonUnreachableError, + resolveDaemonUrl, + runMigrate, + type MigrateProjectResult, + type MigrateSummary, +} from "../lib/migrate.js"; + +/** + * `ao migrate` — port the legacy project registry + per-project settings into + * the rewrite (Go/Electron) daemon. Projects + settings only; sessions are not + * migrated (yet). See lib/migrate.ts for the cross-repo contract and mapping. + */ +export function registerMigrate(program: Command): void { + program + .command("migrate") + .description("Port legacy projects and their settings into the new AO (rewrite) daemon") + .option("--dry-run", "Parse and map the legacy registry, print the plan, write nothing") + .option( + "--daemon-url ", + "New AO daemon base URL (default http://127.0.0.1:3001; env AO_DAEMON_URL / AO_PORT)", + ) + .action(async (opts: { dryRun?: boolean; daemonUrl?: string }) => { + const dryRun = opts.dryRun === true; + const daemonUrl = resolveDaemonUrl(opts.daemonUrl); + + recordActivityEvent({ + source: "cli", + kind: "cli.migrate_invoked", + level: "info", + summary: `ao migrate invoked${dryRun ? " (dry-run)" : ""}`, + data: { dryRun, daemonUrl }, + }); + + let summary: MigrateSummary; + try { + summary = await runMigrate({ dryRun, daemonUrl }); + } catch (error) { + if (error instanceof DaemonUnreachableError) { + recordActivityEvent({ + source: "cli", + kind: "cli.migrate_failed", + level: "error", + summary: "ao migrate failed: daemon unreachable", + data: { daemonUrl, reason: "daemon_unreachable" }, + }); + console.error(chalk.red(`\nCould not reach the new AO daemon at ${daemonUrl}.`)); + console.error( + chalk.dim( + "Start the new AO (the rewrite) first so its daemon is listening, then run `ao migrate`.\n" + + "Override the address with --daemon-url or the AO_DAEMON_URL / AO_PORT env vars.", + ), + ); + process.exit(1); + } + recordActivityEvent({ + source: "cli", + kind: "cli.migrate_failed", + level: "error", + summary: "ao migrate failed", + data: { daemonUrl, errorMessage: error instanceof Error ? error.message : String(error) }, + }); + console.error(chalk.red(error instanceof Error ? error.message : String(error))); + process.exit(1); + } + + printSummary(summary); + + const hadError = summary.results.some((r) => r.outcome === "error"); + recordActivityEvent({ + source: "cli", + kind: hadError ? "cli.migrate_failed" : "cli.migrate_completed", + level: hadError ? "error" : "info", + summary: `ao migrate ${dryRun ? "dry-run " : ""}finished`, + data: { daemonUrl, dryRun, counts: countByOutcome(summary.results) }, + }); + + if (hadError) process.exit(1); + }); +} + +function countByOutcome(results: MigrateProjectResult[]): Record { + const counts: Record = {}; + for (const r of results) counts[r.outcome] = (counts[r.outcome] ?? 0) + 1; + return counts; +} + +function printSummary(summary: MigrateSummary): void { + const { results, dryRun, daemonUrl } = summary; + + if (results.length === 0) { + console.log(chalk.dim("No registered projects found in the legacy config. Nothing to migrate.")); + return; + } + + console.log( + dryRun + ? chalk.cyan(`\nPlan (dry-run) — target: ${daemonUrl}\n`) + : chalk.cyan(`\nMigration — target: ${daemonUrl}\n`), + ); + + for (const r of results) { + console.log(`${outcomeBadge(r)} ${chalk.bold(r.id)} ${chalk.dim(r.path)}`); + if (r.configApplied) console.log(chalk.dim(` settings: ${dryRun ? "to apply" : "applied"}`)); + if (r.error) console.log(chalk.red(` error: ${r.error}`)); + for (const note of r.notes) console.log(chalk.yellow(` note: ${note}`)); + } + + const counts = countByOutcome(results); + const parts: string[] = []; + if (counts["created"]) parts.push(`${counts["created"]} created`); + if (counts["planned"]) parts.push(`${counts["planned"]} planned`); + if (counts["skipped-conflict"]) parts.push(`${counts["skipped-conflict"]} already present`); + if (counts["skipped-degraded"]) parts.push(`${counts["skipped-degraded"]} unresolved`); + if (counts["skipped-invalid-id"]) parts.push(`${counts["skipped-invalid-id"]} invalid id`); + if (counts["error"]) parts.push(chalk.red(`${counts["error"]} failed`)); + + console.log(`\n${parts.join(", ") || "nothing to do"}.`); + if (dryRun) { + console.log(chalk.dim("Re-run without --dry-run to apply.")); + } +} + +function outcomeBadge(r: MigrateProjectResult): string { + switch (r.outcome) { + case "created": + return chalk.green("✓"); + case "planned": + return chalk.cyan("•"); + case "skipped-conflict": + return chalk.dim("="); + case "skipped-degraded": + case "skipped-invalid-id": + return chalk.yellow("⊘"); + case "error": + return chalk.red("✗"); + } +} diff --git a/packages/cli/src/lib/migrate.ts b/packages/cli/src/lib/migrate.ts new file mode 100644 index 000000000..46f24ea4c --- /dev/null +++ b/packages/cli/src/lib/migrate.ts @@ -0,0 +1,480 @@ +import { + getGlobalConfigPath, + loadConfig, + type LoadedConfig, + type ProjectConfig, +} from "@aoagents/ao-core"; + +/** + * `ao migrate` — port the legacy flat-file project registry + per-project + * settings into the rewrite (Go/Electron) daemon's SQLite store. + * + * Scope (deliberately narrow for this first cut): PROJECTS and their SETTINGS + * only. Sessions, PRs, notifiers, and global config are NOT migrated here. + * + * Write path: the rewrite daemon is the SOLE writer of `~/.ao/data/ao.db` + * (the CLI/doctor are forbidden from opening it). So we never touch the DB — + * we mirror the rewrite's own `ao project add` flow over its loopback REST API: + * + * POST /api/v1/projects { path, projectId?, name? } + * PUT /api/v1/projects/{id}/config { config } + * + * Cross-repo contract verified against aoagents/ReverbCode: + * - controllers/projects.go (routes, DisallowUnknownFields on the body) + * - service/project/{dto,service}.go (AddInput, server-set repo_origin_url / + * registered_at / kind, 409 on duplicate id/path, 400 on non-git path) + * - domain/{projectconfig,agentconfig,harness}.go (config JSON shape + enums) + * + * Mapping is spec'd by aoagents/ReverbCode#247 §1 + §3. + */ + +// --------------------------------------------------------------------------- +// Rewrite vocabulary (domain enums, mirrored as literals so core stays free of +// any rewrite dependency) +// --------------------------------------------------------------------------- + +/** `domain.PermissionMode` (agentconfig.go). `""` (unset) is also valid. */ +export type RewritePermissionMode = "default" | "accept-edits" | "auto" | "bypass-permissions"; + +/** `domain.AgentHarness` (harness.go) — the set the rewrite `RoleOverride.agent` accepts. */ +const KNOWN_REWRITE_HARNESSES = new Set([ + "claude-code", + "codex", + "aider", + "opencode", + "grok", + "droid", + "amp", + "agy", + "crush", + "cursor", + "qwen", + "copilot", + "goose", + "auggie", + "continue", + "devin", + "cline", + "kimi", + "kiro", + "kilocode", + "vibe", + "pi", + "autohand", +]); + +/** Default bind of the rewrite daemon: loopback-only, port 3001 (config.go). */ +export const DEFAULT_DAEMON_URL = "http://127.0.0.1:3001"; + +/** + * Resolve the rewrite daemon base URL. Precedence: explicit flag → AO_DAEMON_URL + * → AO_PORT (loopback) → the rewrite's hardcoded default. + */ +export function resolveDaemonUrl(explicit?: string): string { + if (explicit && explicit.trim().length > 0) return stripTrailingSlash(explicit.trim()); + const url = process.env.AO_DAEMON_URL; + if (url && url.trim().length > 0) return stripTrailingSlash(url.trim()); + const port = process.env.AO_PORT; + if (port && /^\d+$/.test(port.trim())) return `http://127.0.0.1:${port.trim()}`; + return DEFAULT_DAEMON_URL; +} + +function stripTrailingSlash(url: string): string { + return url.replace(/\/+$/, ""); +} + +// --------------------------------------------------------------------------- +// Field mapping (pure — fully unit tested) +// --------------------------------------------------------------------------- + +/** Rewrite project-id gate (`validateProjectID`, service.go). */ +const REWRITE_PROJECT_ID = /^[A-Za-z0-9][A-Za-z0-9._-]*$/; + +export function isValidRewriteProjectId(id: string): boolean { + return ( + id.length > 0 && + id !== "." && + !id.includes("..") && + !/[/\\]/.test(id) && + REWRITE_PROJECT_ID.test(id) + ); +} + +/** + * Legacy `AgentPermissionMode` → rewrite `PermissionMode` (#247 §3 table). + * `lossy` flags a remap that drops a distinction the rewrite cannot represent. + * + * Note: legacy `skip` is already collapsed to `permissionless` by the config + * schema, but a hand-edited config could still carry the raw value, so we map + * it explicitly. + */ +export function mapPermission(legacy: string | undefined): { + mode: RewritePermissionMode; + lossy: boolean; +} | null { + switch (legacy) { + case undefined: + case "": + return null; + case "permissionless": + case "skip": + return { mode: "bypass-permissions", lossy: false }; + case "auto-edit": + return { mode: "accept-edits", lossy: false }; + case "default": + return { mode: "default", lossy: false }; + case "suggest": + // The rewrite has no suggest/plan mode (#247 G8). + return { mode: "default", lossy: true }; + default: + return { mode: "default", lossy: true }; + } +} + +/** Legacy agent plugin id → rewrite harness, or null if the rewrite has no such harness. */ +export function mapHarness(agent: string | undefined): string | null { + if (!agent) return null; + return KNOWN_REWRITE_HARNESSES.has(agent) ? agent : null; +} + +/** Rewrite `domain.AgentConfig` JSON shape. */ +interface RewriteAgentConfig { + model?: string; + permissions?: RewritePermissionMode; +} + +/** Rewrite `domain.RoleOverride` JSON shape (note: harness key is `agent`). */ +interface RewriteRoleOverride { + agent?: string; + agentConfig?: RewriteAgentConfig; +} + +/** Rewrite `domain.ProjectConfig` JSON shape (the `config` column). */ +export interface RewriteProjectConfig { + defaultBranch?: string; + sessionPrefix?: string; + env?: Record; + symlinks?: string[]; + postCreate?: string[]; + agentConfig?: RewriteAgentConfig; + worker?: RewriteRoleOverride; + orchestrator?: RewriteRoleOverride; +} + +function buildAgentConfig( + source: { model?: string; permissions?: string } | undefined, + notes: string[], + label: string, +): RewriteAgentConfig | undefined { + if (!source) return undefined; + const out: RewriteAgentConfig = {}; + if (typeof source.model === "string" && source.model.length > 0) out.model = source.model; + const perm = mapPermission(source.permissions); + if (perm) { + out.permissions = perm.mode; + if (perm.lossy) { + notes.push(`${label} permission "${source.permissions}" mapped lossily to "${perm.mode}"`); + } + } + return Object.keys(out).length > 0 ? out : undefined; +} + +function buildRoleOverride( + role: { agent?: string; agentConfig?: { model?: string; permissions?: string } } | undefined, + notes: string[], + label: string, +): RewriteRoleOverride | undefined { + if (!role) return undefined; + const out: RewriteRoleOverride = {}; + if (role.agent) { + const harness = mapHarness(role.agent); + if (harness) { + out.agent = harness; + } else { + notes.push(`${label} agent "${role.agent}" has no rewrite harness — dropped`); + } + } + const agentConfig = buildAgentConfig(role.agentConfig, notes, `${label} agent`); + if (agentConfig) out.agentConfig = agentConfig; + return Object.keys(out).length > 0 ? out : undefined; +} + +/** + * Build the rewrite `config` blob from a legacy effective ProjectConfig (#247 §3). + * Returns null when nothing worth persisting remains (the rewrite stores NULL + * for a zero config). `notes` accumulates lossy/dropped-field warnings. + */ +export function buildRewriteConfig(pc: ProjectConfig, notes: string[]): RewriteProjectConfig | null { + const config: RewriteProjectConfig = {}; + + // defaultBranch: omit "main" so the common case keeps config NULL (#247 §3). + if (typeof pc.defaultBranch === "string" && pc.defaultBranch && pc.defaultBranch !== "main") { + config.defaultBranch = pc.defaultBranch; + } + if (typeof pc.sessionPrefix === "string" && pc.sessionPrefix.length > 0) { + config.sessionPrefix = pc.sessionPrefix; + } + if (pc.env && Object.keys(pc.env).length > 0) { + config.env = { ...pc.env }; + } + if (Array.isArray(pc.symlinks) && pc.symlinks.length > 0) { + config.symlinks = [...pc.symlinks]; + } + if (Array.isArray(pc.postCreate) && pc.postCreate.length > 0) { + config.postCreate = [...pc.postCreate]; + } + + const agentConfig = buildAgentConfig(pc.agentConfig, notes, "agentConfig"); + if (agentConfig) config.agentConfig = agentConfig; + + const worker = buildRoleOverride(pc.worker, notes, "worker"); + if (worker) config.worker = worker; + + const orchestrator = buildRoleOverride(pc.orchestrator, notes, "orchestrator"); + if (orchestrator) config.orchestrator = orchestrator; + + // Surface project-level fields the rewrite has no home for (#247 §4). + const dropped: string[] = []; + if (pc.tracker) dropped.push("tracker"); + if (pc.scm) dropped.push("scm"); + if (pc.agentRules || pc.agentRulesFile || pc.orchestratorRules) dropped.push("rules"); + if (pc.runtime) dropped.push("runtime"); + if (pc.workspace) dropped.push("workspace"); + if (pc.reactions && Object.keys(pc.reactions).length > 0) dropped.push("reactions"); + if (dropped.length > 0) { + notes.push(`project-level fields with no rewrite home dropped: ${dropped.join(", ")}`); + } + + return Object.keys(config).length > 0 ? config : null; +} + +// --------------------------------------------------------------------------- +// Per-project plan +// --------------------------------------------------------------------------- + +/** Rewrite `POST /api/v1/projects` body (`service.AddInput`). */ +export interface ProjectAddInput { + path: string; + projectId?: string; + name?: string; +} + +export interface ProjectPlan { + id: string; + add: ProjectAddInput; + config: RewriteProjectConfig | null; + notes: string[]; +} + +/** Build the full create+config plan for one legacy project. Pure. */ +export function buildProjectPlan(id: string, pc: ProjectConfig): ProjectPlan { + const notes: string[] = []; + const add: ProjectAddInput = { path: pc.path, projectId: id }; + // displayName falls back to id on the rewrite read side; only send a real name. + if (typeof pc.name === "string" && pc.name.length > 0 && pc.name !== id) { + add.name = pc.name; + } + const config = buildRewriteConfig(pc, notes); + return { id, add, config, notes }; +} + +// --------------------------------------------------------------------------- +// HTTP execution +// --------------------------------------------------------------------------- + +export class DaemonUnreachableError extends Error { + constructor( + readonly daemonUrl: string, + cause?: unknown, + ) { + super(`Could not reach the AO daemon at ${daemonUrl}`, { cause }); + this.name = "DaemonUnreachableError"; + } +} + +type FetchLike = typeof fetch; + +export type ProjectOutcome = + | "created" + | "skipped-conflict" + | "skipped-degraded" + | "skipped-invalid-id" + | "error" + | "planned"; + +export interface MigrateProjectResult { + id: string; + path: string; + outcome: ProjectOutcome; + configApplied: boolean; + notes: string[]; + error?: string; +} + +export interface MigrateSummary { + daemonUrl: string; + dryRun: boolean; + results: MigrateProjectResult[]; +} + +export interface MigrateOptions { + daemonUrl: string; + dryRun: boolean; + /** Override the config source (tests). Defaults to the global config. */ + config?: LoadedConfig; + /** Override fetch (tests). */ + fetchImpl?: FetchLike; +} + +interface ApiResult { + status: number; + body: string; +} + +async function apiRequest( + fetchImpl: FetchLike, + daemonUrl: string, + method: "GET" | "POST" | "PUT", + path: string, + body?: unknown, +): Promise { + let res: Response; + try { + res = await fetchImpl(`${daemonUrl}${path}`, { + method, + // No `Origin` header — the daemon's CORS gate lets origin-less (CLI) + // requests through; a browser origin would be rejected. + headers: body === undefined ? undefined : { "content-type": "application/json" }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + } catch (err) { + throw new DaemonUnreachableError(daemonUrl, err); + } + return { status: res.status, body: await res.text() }; +} + +/** Pull a stable error code out of the rewrite's JSON error envelope, best-effort. */ +function describeApiError(result: ApiResult): string { + try { + const parsed = JSON.parse(result.body) as { error?: { code?: string; message?: string } }; + const code = parsed.error?.code; + const message = parsed.error?.message; + if (code || message) return [code, message].filter(Boolean).join(": "); + } catch { + // not JSON — fall through + } + return result.body.trim().slice(0, 200) || `HTTP ${result.status}`; +} + +/** + * Run the projects+settings migration. Reads the legacy global config, then + * for each registered project POSTs the create and PUTs the config blob. + * + * Idempotent: a project whose id/path is already registered comes back 409 and + * is reported as skipped (re-running is safe). + */ +export async function runMigrate(opts: MigrateOptions): Promise { + const fetchImpl = opts.fetchImpl ?? fetch; + const config = opts.config ?? loadConfig(getGlobalConfigPath()); + const results: MigrateProjectResult[] = []; + + // Projects whose local config failed to resolve — cannot map faithfully. + for (const [id, entry] of Object.entries(config.degradedProjects)) { + results.push({ + id, + path: entry.path, + outcome: "skipped-degraded", + configApplied: false, + notes: [`local config could not be resolved: ${entry.resolveError}`], + }); + } + + const ids = Object.keys(config.projects); + + // Fail fast if the daemon is down (skip the probe on dry runs — they never hit it). + if (!opts.dryRun && ids.length > 0) { + await apiRequest(fetchImpl, opts.daemonUrl, "GET", "/api/v1/projects"); + } + + for (const id of ids) { + const pc = config.projects[id]!; + const plan = buildProjectPlan(id, pc); + + if (!isValidRewriteProjectId(id)) { + results.push({ + id, + path: pc.path, + outcome: "skipped-invalid-id", + configApplied: false, + notes: [...plan.notes, "project id fails rewrite validation — rename before migrating"], + }); + continue; + } + + if (opts.dryRun) { + results.push({ + id, + path: pc.path, + outcome: "planned", + configApplied: plan.config !== null, + notes: plan.notes, + }); + continue; + } + + const result = await migrateOneProject(fetchImpl, opts.daemonUrl, plan, pc.path); + results.push(result); + } + + return { daemonUrl: opts.daemonUrl, dryRun: opts.dryRun, results }; +} + +async function migrateOneProject( + fetchImpl: FetchLike, + daemonUrl: string, + plan: ProjectPlan, + path: string, +): Promise { + const notes = [...plan.notes]; + + const addRes = await apiRequest(fetchImpl, daemonUrl, "POST", "/api/v1/projects", plan.add); + if (addRes.status === 409) { + return { + id: plan.id, + path, + outcome: "skipped-conflict", + configApplied: false, + notes: [...notes, "already registered in the new system — skipped"], + }; + } + if (addRes.status !== 201) { + return { + id: plan.id, + path, + outcome: "error", + configApplied: false, + notes, + error: describeApiError(addRes), + }; + } + + let configApplied = false; + if (plan.config) { + const cfgRes = await apiRequest( + fetchImpl, + daemonUrl, + "PUT", + `/api/v1/projects/${encodeURIComponent(plan.id)}/config`, + { config: plan.config }, + ); + if (cfgRes.status >= 200 && cfgRes.status < 300) { + configApplied = true; + } else { + // The project was created; only the config write failed. Keep the + // project, surface the config error. + notes.push(`project created but config write failed: ${describeApiError(cfgRes)}`); + } + } + + return { id: plan.id, path, outcome: "created", configApplied, notes }; +} diff --git a/packages/cli/src/program.ts b/packages/cli/src/program.ts index 108118d5a..6091e7d95 100644 --- a/packages/cli/src/program.ts +++ b/packages/cli/src/program.ts @@ -17,6 +17,7 @@ import { registerPlugin } from "./commands/plugin.js"; import { registerNotify } from "./commands/notify.js"; import { registerProjectCommand } from "./commands/project.js"; import { registerMigrateStorage } from "./commands/migrate-storage.js"; +import { registerMigrate } from "./commands/migrate.js"; import { registerCompletion } from "./commands/completion.js"; import { registerEvents } from "./commands/events.js"; import { registerConfig } from "./commands/config.js"; @@ -52,6 +53,7 @@ export function createProgram(): Command { registerNotify(program); registerProjectCommand(program); registerMigrateStorage(program); + registerMigrate(program); registerCompletion(program); registerEvents(program); registerConfig(program);