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 <noreply@anthropic.com>
This commit is contained in:
harshitsinghbhandari 2026-06-16 18:10:00 +05:30
parent 5897b4e8d8
commit cc860d3bfb
No known key found for this signature in database
3 changed files with 623 additions and 0 deletions

View File

@ -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 <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<string, number> {
const counts: Record<string, number> = {};
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("✗");
}
}

View File

@ -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<string>([
"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<string, string>;
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<ApiResult> {
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<MigrateSummary> {
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<MigrateProjectResult> {
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 };
}

View File

@ -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);