diff --git a/.changeset/ao-migrate-projects.md b/.changeset/ao-migrate-projects.md new file mode 100644 index 0000000000..3f8fa3ee44 --- /dev/null +++ b/.changeset/ao-migrate-projects.md @@ -0,0 +1,10 @@ +--- +"@aoagents/ao-cli": minor +--- + +Add `ao migrate`: port the legacy project registry and per-project settings into +the new AO (rewrite) daemon. It mirrors the rewrite's own `ao project add` flow +over the daemon's loopback REST API (so the daemon stays the sole writer of its +store) and maps project config per the migration spec (aoagents/ReverbCode#247 +sections 1 and 3). Supports `--dry-run` and `--daemon-url`. Sessions are not +migrated by this command. diff --git a/packages/cli/__tests__/lib/migrate.test.ts b/packages/cli/__tests__/lib/migrate.test.ts new file mode 100644 index 0000000000..b7a1a3a186 --- /dev/null +++ b/packages/cli/__tests__/lib/migrate.test.ts @@ -0,0 +1,354 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import type { LoadedConfig, ProjectConfig } from "@aoagents/ao-core"; +import { + DEFAULT_DAEMON_URL, + DaemonUnreachableError, + buildProjectPlan, + buildRewriteConfig, + isValidRewriteProjectId, + mapHarness, + mapPermission, + resolveDaemonUrl, + runMigrate, +} from "../../src/lib/migrate.js"; + +// --------------------------------------------------------------------------- +// fixtures +// --------------------------------------------------------------------------- + +function project(overrides: Partial = {}): ProjectConfig { + return { + name: "My Project", + path: "/repos/my-project", + defaultBranch: "main", + // Empty by default so per-field assertions stay focused; a dedicated test + // covers sessionPrefix carry-over. + sessionPrefix: "", + ...overrides, + } as ProjectConfig; +} + +function loaded( + projects: Record, + degraded: LoadedConfig["degradedProjects"] = {}, +): LoadedConfig { + return { projects, degradedProjects: degraded } as unknown as LoadedConfig; +} + +interface FakeCall { + method: string; + path: string; + body: unknown; +} + +/** Build a fetch stub from a (path, method) → {status, body} responder. */ +function fakeFetch( + responder: (path: string, method: string, body: unknown) => { status: number; body?: string }, + calls: FakeCall[] = [], +): typeof fetch { + return (async (input: string, init?: RequestInit) => { + const url = new URL(input); + const path = url.pathname; + const method = init?.method ?? "GET"; + const body = init?.body ? JSON.parse(init.body as string) : undefined; + calls.push({ method, path, body }); + const { status, body: respBody } = responder(path, method, body); + return { + status, + text: async () => respBody ?? "", + } as Response; + }) as unknown as typeof fetch; +} + +// --------------------------------------------------------------------------- +// resolveDaemonUrl +// --------------------------------------------------------------------------- + +describe("resolveDaemonUrl", () => { + const saved = { ...process.env }; + beforeEach(() => { + delete process.env.AO_DAEMON_URL; + delete process.env.AO_PORT; + }); + afterEach(() => { + process.env = { ...saved }; + }); + + it("prefers the explicit flag and strips a trailing slash", () => { + expect(resolveDaemonUrl("http://127.0.0.1:9/")).toBe("http://127.0.0.1:9"); + }); + it("falls back to AO_DAEMON_URL", () => { + process.env.AO_DAEMON_URL = "http://host:1234"; + expect(resolveDaemonUrl()).toBe("http://host:1234"); + }); + it("ignores AO_PORT (overloaded with the legacy dashboard) and uses the default", () => { + process.env.AO_PORT = "3000"; + expect(resolveDaemonUrl()).toBe(DEFAULT_DAEMON_URL); + }); + it("uses the rewrite default when nothing is set", () => { + expect(resolveDaemonUrl()).toBe(DEFAULT_DAEMON_URL); + }); +}); + +// --------------------------------------------------------------------------- +// isValidRewriteProjectId +// --------------------------------------------------------------------------- + +describe("isValidRewriteProjectId", () => { + it("accepts legacy-style ids (a strict subset of the rewrite grammar)", () => { + expect(isValidRewriteProjectId("agent-orchestrator")).toBe(true); + expect(isValidRewriteProjectId("repo_1")).toBe(true); + }); + it("rejects empty, dot-dot, and path separators", () => { + expect(isValidRewriteProjectId("")).toBe(false); + expect(isValidRewriteProjectId(".")).toBe(false); + expect(isValidRewriteProjectId("a..b")).toBe(false); + expect(isValidRewriteProjectId("a/b")).toBe(false); + expect(isValidRewriteProjectId("a\\b")).toBe(false); + expect(isValidRewriteProjectId(".hidden")).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// mapPermission / mapHarness +// --------------------------------------------------------------------------- + +describe("mapPermission", () => { + it("maps each legacy mode per #247 §3", () => { + expect(mapPermission("permissionless")).toEqual({ mode: "bypass-permissions", lossy: false }); + expect(mapPermission("skip")).toEqual({ mode: "bypass-permissions", lossy: false }); + expect(mapPermission("auto-edit")).toEqual({ mode: "accept-edits", lossy: false }); + expect(mapPermission("default")).toEqual({ mode: "default", lossy: false }); + }); + it("flags suggest and unknown values as lossy", () => { + expect(mapPermission("suggest")).toEqual({ mode: "default", lossy: true }); + expect(mapPermission("wat")).toEqual({ mode: "default", lossy: true }); + }); + it("returns null for unset", () => { + expect(mapPermission(undefined)).toBeNull(); + expect(mapPermission("")).toBeNull(); + }); +}); + +describe("mapHarness", () => { + it("passes through harnesses the rewrite knows", () => { + expect(mapHarness("claude-code")).toBe("claude-code"); + expect(mapHarness("codex")).toBe("codex"); + expect(mapHarness("opencode")).toBe("opencode"); + }); + it("returns null for unknown or unset", () => { + expect(mapHarness("frobnicator")).toBeNull(); + expect(mapHarness(undefined)).toBeNull(); + }); +}); + +// --------------------------------------------------------------------------- +// buildRewriteConfig +// --------------------------------------------------------------------------- + +describe("buildRewriteConfig", () => { + it("omits a 'main' default branch and keeps a non-main one", () => { + const notes: string[] = []; + expect(buildRewriteConfig(project({ defaultBranch: "main" }), notes)).toBeNull(); + expect(buildRewriteConfig(project({ defaultBranch: "develop" }), [])).toEqual({ + defaultBranch: "develop", + }); + }); + + it("carries a non-empty sessionPrefix", () => { + expect(buildRewriteConfig(project({ sessionPrefix: "app" }), [])).toEqual({ + sessionPrefix: "app", + }); + }); + + it("carries env, symlinks, and postCreate verbatim", () => { + const config = buildRewriteConfig( + project({ + defaultBranch: "main", + env: { FOO: "bar" }, + symlinks: [".env"], + postCreate: ["pnpm i"], + }), + [], + ); + expect(config).toEqual({ + env: { FOO: "bar" }, + symlinks: [".env"], + postCreate: ["pnpm i"], + }); + }); + + it("remaps the agent permission and notes a lossy suggest", () => { + const notes: string[] = []; + const config = buildRewriteConfig( + project({ agentConfig: { model: "opus", permissions: "suggest" } }), + notes, + ); + expect(config).toEqual({ agentConfig: { model: "opus", permissions: "default" } }); + expect(notes.join()).toMatch(/lossily/); + }); + + it("maps worker/orchestrator harness and drops unknown ones with a note", () => { + const notes: string[] = []; + const config = buildRewriteConfig( + project({ + worker: { agent: "codex", agentConfig: { permissions: "auto-edit" } }, + orchestrator: { agent: "frobnicator" }, + }), + notes, + ); + expect(config).toEqual({ + worker: { agent: "codex", agentConfig: { permissions: "accept-edits" } }, + }); + expect(notes.join()).toMatch(/frobnicator.*dropped/); + }); + + it("notes project-level fields with no rewrite home", () => { + const notes: string[] = []; + buildRewriteConfig( + project({ + tracker: { provider: "github" } as ProjectConfig["tracker"], + agentRules: "be nice", + }), + notes, + ); + expect(notes.join()).toMatch(/no rewrite home dropped: tracker, rules/); + }); +}); + +// --------------------------------------------------------------------------- +// buildProjectPlan +// --------------------------------------------------------------------------- + +describe("buildProjectPlan", () => { + it("uses the legacy id and path, and only sends a name that differs from the id", () => { + const withName = buildProjectPlan("my-project", project({ name: "Pretty Name" })); + expect(withName.add).toEqual({ + path: "/repos/my-project", + projectId: "my-project", + name: "Pretty Name", + }); + + const nameEqualsId = buildProjectPlan("my-project", project({ name: "my-project" })); + expect(nameEqualsId.add).toEqual({ path: "/repos/my-project", projectId: "my-project" }); + }); +}); + +// --------------------------------------------------------------------------- +// runMigrate +// --------------------------------------------------------------------------- + +describe("runMigrate", () => { + it("plans without any network calls on a dry run", async () => { + const calls: FakeCall[] = []; + const summary = await runMigrate({ + daemonUrl: "http://d", + dryRun: true, + config: loaded({ a: project({ defaultBranch: "develop" }) }), + fetchImpl: fakeFetch(() => ({ status: 500 }), calls), + }); + expect(calls).toHaveLength(0); + expect(summary.results[0]).toMatchObject({ outcome: "planned", configApplied: true }); + }); + + it("creates a project and applies its config", async () => { + const calls: FakeCall[] = []; + const summary = await runMigrate({ + daemonUrl: "http://d", + dryRun: false, + config: loaded({ a: project({ defaultBranch: "develop" }) }), + fetchImpl: fakeFetch((path, method) => { + if (method === "GET") return { status: 200, body: "[]" }; + if (method === "POST") return { status: 201, body: "{}" }; + if (method === "PUT") return { status: 200, body: "{}" }; + return { status: 500 }; + }, calls), + }); + expect(summary.results[0]).toMatchObject({ outcome: "created", configApplied: true }); + expect(calls.map((c) => `${c.method} ${c.path}`)).toEqual([ + "GET /api/v1/projects", + "POST /api/v1/projects", + "PUT /api/v1/projects/a/config", + ]); + }); + + it("skips a project already present in the new system (409)", async () => { + const summary = await runMigrate({ + daemonUrl: "http://d", + dryRun: false, + config: loaded({ a: project() }), + fetchImpl: fakeFetch((_path, method) => + method === "GET" ? { status: 200, body: "[]" } : { status: 409, body: "{}" }, + ), + }); + expect(summary.results[0]).toMatchObject({ outcome: "skipped-conflict" }); + }); + + it("reports the rewrite error envelope on a failed create", async () => { + const summary = await runMigrate({ + daemonUrl: "http://d", + dryRun: false, + config: loaded({ a: project() }), + fetchImpl: fakeFetch((_path, method) => + method === "GET" + ? { status: 200, body: "[]" } + : { status: 400, body: JSON.stringify({ error: { code: "NOT_A_GIT_REPO", message: "nope" } }) }, + ), + }); + expect(summary.results[0]).toMatchObject({ outcome: "error", error: "NOT_A_GIT_REPO: nope" }); + }); + + it("keeps a created project even when its config write fails", async () => { + const summary = await runMigrate({ + daemonUrl: "http://d", + dryRun: false, + config: loaded({ a: project({ defaultBranch: "develop" }) }), + fetchImpl: fakeFetch((_path, method) => { + if (method === "GET") return { status: 200, body: "[]" }; + if (method === "POST") return { status: 201, body: "{}" }; + return { status: 400, body: JSON.stringify({ error: { code: "INVALID_PROJECT_CONFIG" } }) }; + }), + }); + expect(summary.results[0]).toMatchObject({ outcome: "created", configApplied: false }); + expect(summary.results[0]!.notes.join()).toMatch(/config write failed/); + }); + + it("reports degraded projects as skipped without calling the daemon", async () => { + const calls: FakeCall[] = []; + const summary = await runMigrate({ + daemonUrl: "http://d", + dryRun: false, + config: loaded({}, { broken: { projectId: "broken", path: "/x", resolveError: "gone" } }), + fetchImpl: fakeFetch(() => ({ status: 200, body: "[]" }), calls), + }); + expect(calls).toHaveLength(0); + expect(summary.results[0]).toMatchObject({ outcome: "skipped-degraded" }); + }); + + it("skips a project whose id fails rewrite validation, without a create call", async () => { + const calls: FakeCall[] = []; + const summary = await runMigrate({ + daemonUrl: "http://d", + dryRun: false, + config: loaded({ "bad/id": project({ path: "/x" }) }), + fetchImpl: fakeFetch(() => ({ status: 200, body: "[]" }), calls), + }); + // Only the liveness probe; no POST for the invalid id. + expect(calls.map((c) => c.method)).toEqual(["GET"]); + expect(summary.results[0]).toMatchObject({ outcome: "skipped-invalid-id" }); + }); + + it("raises DaemonUnreachableError when the probe cannot connect", async () => { + const throwing = (async () => { + throw new Error("ECONNREFUSED"); + }) as unknown as typeof fetch; + await expect( + runMigrate({ + daemonUrl: "http://d", + dryRun: false, + config: loaded({ a: project() }), + fetchImpl: throwing, + }), + ).rejects.toBeInstanceOf(DaemonUnreachableError); + }); +}); diff --git a/packages/cli/src/commands/migrate.ts b/packages/cli/src/commands/migrate.ts new file mode 100644 index 0000000000..0d5c3aa1d6 --- /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)", + ) + .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 env var.", + ), + ); + 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 0000000000..92721c1d7a --- /dev/null +++ b/packages/cli/src/lib/migrate.ts @@ -0,0 +1,482 @@ +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 + * → the rewrite's hardcoded default. + * + * We deliberately do NOT fall back to `AO_PORT`: that variable is overloaded + * (the legacy dashboard and the rewrite daemon both use it for "the port"), so + * in a legacy environment it usually points at the legacy Next.js dashboard. + * Targeting the rewrite daemon must be explicit and unambiguous. + */ +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()); + 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 entries = Object.entries(config.projects); + + // Fail fast if the daemon is down (skip the probe on dry runs — they never hit it). + if (!opts.dryRun && entries.length > 0) { + await apiRequest(fetchImpl, opts.daemonUrl, "GET", "/api/v1/projects"); + } + + for (const [id, pc] of entries) { + 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 108118d5a7..6091e7d95e 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);