From a12a7de25029a683bad5709f19327979509fd693 Mon Sep 17 00:00:00 2001 From: Farnood Massoudi Date: Mon, 23 Mar 2026 23:02:32 -0400 Subject: [PATCH] fix(sync): keep sync canonical-only Require initialized canonical state for sync, keep provider import behavior on init, and bump the CLI to 0.1.12. Co-authored-by: Codex --- apps/web/src/app/docs/page.tsx | 7 + packages/cli/README.md | 2 + packages/cli/package.json | 2 +- packages/cli/src/commands/init.ts | 1 + packages/cli/src/commands/sync.ts | 56 ++++-- packages/cli/src/core/copy.ts | 10 +- packages/cli/src/core/scope.ts | 113 +++++++++++- .../tests/integration/sync-migration.test.ts | 171 +++++++++++++++++- packages/cli/tests/unit/cli-help.test.ts | 12 ++ packages/cli/tests/unit/copy.test.ts | 14 ++ packages/cli/tests/unit/init-command.test.ts | 1 + packages/cli/tests/unit/scope.test.ts | 147 +++++++++++++++ .../cli/tests/unit/sync-migration.test.ts | 122 ++++++++++--- 13 files changed, 600 insertions(+), 58 deletions(-) diff --git a/apps/web/src/app/docs/page.tsx b/apps/web/src/app/docs/page.tsx index 2655381..e73e838 100644 --- a/apps/web/src/app/docs/page.tsx +++ b/apps/web/src/app/docs/page.tsx @@ -153,6 +153,13 @@ export default function DocsPage() { provider configs, migrates them into the canonical format, and syncs everything back out. + + After initialization, agentloom sync is + one-way: it reads from .agents/ and + writes provider-native outputs. Rerun{" "} + agentloom init only when you want to + re-import provider state into canonical config. +

diff --git a/packages/cli/README.md b/packages/cli/README.md index 14a6ae4..7f1fda8 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -12,6 +12,8 @@ npx agentloom init That's all you need. Agentloom picks up your existing provider configs, migrates them into a unified `.agents/` directory, and syncs everything back out to all your tools. From here on, manage your agents, commands, rules, skills, and MCP servers in one place and run `agentloom sync` whenever you make changes. +`agentloom init` is the provider-to-canonical bootstrap step. After that, `agentloom sync` is one-way: it reads from `.agents/` and writes provider-native outputs. If you intentionally want to pull provider state back into canonical `.agents/`, rerun `agentloom init`. + ## Install ```bash diff --git a/packages/cli/package.json b/packages/cli/package.json index a5ce4cd..68cae7a 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "agentloom", - "version": "0.1.11", + "version": "0.1.12", "description": "Unified agent and MCP sync CLI for multi-provider AI tooling", "type": "module", "bin": { diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index 0d96565..1f1bab5 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -16,5 +16,6 @@ export async function runInitCommand( cwd, target: "all", skipSync: Boolean(argv["no-sync"]), + migrateProviderState: true, }); } diff --git a/packages/cli/src/commands/sync.ts b/packages/cli/src/commands/sync.ts index 8f64eec..8b804d6 100644 --- a/packages/cli/src/commands/sync.ts +++ b/packages/cli/src/commands/sync.ts @@ -10,6 +10,10 @@ import { migrateProviderStateToCanonical, MigrationConflictError, } from "../core/migration.js"; +import { + hasInitializedCanonicalLayout, + resolveScopeForSync, +} from "../core/scope.js"; import type { EntityType, ScopePaths } from "../types.js"; import { getNonInteractiveMode, @@ -42,12 +46,21 @@ export async function runScopedSyncCommand(options: { cwd: string; target: EntityType | "all"; skipSync?: boolean; + migrateProviderState?: boolean; }): Promise { const nonInteractive = getNonInteractiveMode(options.argv); let cleanupDryRunPaths: (() => void) | undefined; try { - const paths = await resolvePathsForCommand(options.argv, options.cwd); + const shouldMigrateProviderState = Boolean(options.migrateProviderState); + const paths = shouldMigrateProviderState + ? await resolvePathsForCommand(options.argv, options.cwd) + : await resolveScopeForSync({ + cwd: options.cwd, + global: Boolean(options.argv.global), + local: Boolean(options.argv.local), + interactive: !nonInteractive, + }); const explicitProviders = parseProvidersFlag(options.argv.providers); const providers = await resolveProvidersForSync({ paths, @@ -55,6 +68,10 @@ export async function runScopedSyncCommand(options: { nonInteractive, }); + if (!shouldMigrateProviderState) { + assertInitializedCanonicalStateExists(paths); + } + const dryRun = Boolean(options.argv["dry-run"]); const effectivePaths = dryRun ? createDryRunCanonicalPaths(paths) @@ -63,17 +80,19 @@ export async function runScopedSyncCommand(options: { initializeCanonicalLayout(effectivePaths.paths, providers); - const migrationSummary = await migrateProviderStateToCanonical({ - paths: effectivePaths.paths, - providers, - target: options.target, - yes: Boolean(options.argv.yes), - nonInteractive, - dryRun, - materializeCanonical: dryRun, - }); + if (shouldMigrateProviderState) { + const migrationSummary = await migrateProviderStateToCanonical({ + paths: effectivePaths.paths, + providers, + target: options.target, + yes: Boolean(options.argv.yes), + nonInteractive, + dryRun, + materializeCanonical: dryRun, + }); - console.log(formatMigrationSummary(migrationSummary)); + console.log(formatMigrationSummary(migrationSummary)); + } if (options.skipSync) { return; @@ -101,6 +120,21 @@ export async function runScopedSyncCommand(options: { } } +function assertInitializedCanonicalStateExists(paths: ScopePaths): void { + if (hasInitializedCanonicalLayout(paths)) { + return; + } + + const initCommand = + paths.scope === "global" + ? "agentloom init --global" + : "agentloom init --local"; + + throw new Error( + `No initialized canonical .agents state found at ${paths.agentsRoot}.\nRun \`${initCommand}\` to bootstrap from provider configs first, or use \`agentloom add\` to create canonical content before syncing.`, + ); +} + function createDryRunCanonicalPaths(paths: ScopePaths): { paths: ScopePaths; cleanup: () => void; diff --git a/packages/cli/src/core/copy.ts b/packages/cli/src/core/copy.ts index 5480df3..81084ca 100644 --- a/packages/cli/src/core/copy.ts +++ b/packages/cli/src/core/copy.ts @@ -17,11 +17,11 @@ Usage: Aggregate commands: add Import agents/commands/mcp/rules/skills from a source - init Bootstrap canonical files, migrate providers, then sync + init Bootstrap canonical files from provider configs, then sync find Search remote + local entities update [source] Refresh lockfile-managed imports upgrade Install the latest CLI release - sync Migrate provider configs then generate provider outputs + sync Generate provider outputs from canonical .agents delete Delete imported entities by source or name(s) Entity commands: @@ -147,7 +147,9 @@ Behavior: } export function getSyncHelpText(): string { - return `Migrate provider configs into canonical .agents data, then generate provider-specific outputs. + return `Generate provider-specific outputs from canonical .agents data. + +Use \`agentloom init\` when you want to bootstrap or re-import provider configs into canonical state. Usage: agentloom sync [options] @@ -162,7 +164,7 @@ Options: } export function getInitHelpText(): string { - return `Bootstrap canonical .agents files, migrate provider configs into canonical state, and sync providers. + return `Bootstrap canonical .agents files from existing provider configs, then sync providers. Usage: agentloom init [options] diff --git a/packages/cli/src/core/scope.ts b/packages/cli/src/core/scope.ts index 6085b78..8eda11c 100644 --- a/packages/cli/src/core/scope.ts +++ b/packages/cli/src/core/scope.ts @@ -12,6 +12,46 @@ export interface ScopeResolutionOptions { interactive?: boolean; } +function directoryHasEntries(dirPath: string): boolean { + return fs.existsSync(dirPath) && fs.readdirSync(dirPath).length > 0; +} + +export function hasInitializedCanonicalLayout( + paths: Pick< + ScopePaths, + | "agentsRoot" + | "agentsDir" + | "commandsDir" + | "rulesDir" + | "skillsDir" + | "mcpPath" + | "lockPath" + | "manifestPath" + >, +): boolean { + if ( + !fs.existsSync(paths.agentsRoot) || + !fs.statSync(paths.agentsRoot).isDirectory() + ) { + return false; + } + + if ( + fs.existsSync(paths.mcpPath) || + fs.existsSync(paths.lockPath) || + fs.existsSync(paths.manifestPath) + ) { + return true; + } + + return ( + directoryHasEntries(paths.agentsDir) || + directoryHasEntries(paths.commandsDir) || + directoryHasEntries(paths.rulesDir) || + directoryHasEntries(paths.skillsDir) + ); +} + export function buildScopePaths( cwd: string, scope: Scope, @@ -59,31 +99,86 @@ export async function resolveScope( return buildScopePaths(cwd, hasLocalAgents ? "local" : "global"); } - const globalSettings = readSettings(getGlobalSettingsPath()); - const defaultScope = - globalSettings.lastScope === "local" ? "local" : "global"; + const defaultScope = getDefaultScope(); + const selected = await promptForScopeSelection({ + hasLocalAgents, + defaultScope, + }); + + return buildScopePaths(cwd, selected); +} + +export async function resolveScopeForSync( + options: ScopeResolutionOptions, +): Promise { + const { cwd } = options; + + if (options.global && options.local) { + throw new Error("Use either --global or --local, not both."); + } + + if (options.global) return buildScopePaths(cwd, "global"); + if (options.local) return buildScopePaths(cwd, "local"); + + const localPaths = buildScopePaths(cwd, "local"); + const globalPaths = buildScopePaths(cwd, "global"); + const hasLocalAgents = fs.existsSync(localPaths.agentsRoot); + const hasLocalCanonical = hasInitializedCanonicalLayout(localPaths); + const hasGlobalCanonical = hasInitializedCanonicalLayout(globalPaths); + + const interactive = + options.interactive ?? (process.stdin.isTTY && process.stdout.isTTY); + if (!interactive) { + return hasLocalAgents ? localPaths : globalPaths; + } + + if (hasLocalAgents && hasGlobalCanonical) { + const selected = await promptForScopeSelection({ + hasLocalAgents: true, + defaultScope: getDefaultScope(globalPaths.homeDir), + }); + return buildScopePaths(cwd, selected, globalPaths.homeDir); + } + + if (hasLocalCanonical) return localPaths; + if (hasGlobalCanonical) return globalPaths; + if (hasLocalAgents) return localPaths; + + throw new Error( + `No initialized canonical .agents state found at ${localPaths.agentsRoot} or ${globalPaths.agentsRoot}.\nRun \`agentloom init --local\` or \`agentloom init --global\` to bootstrap from provider configs first, or use \`agentloom add\` to create canonical content before syncing.`, + ); +} + +function getDefaultScope(homeDir = os.homedir()): Scope { + const globalSettings = readSettings(getGlobalSettingsPath(homeDir)); + return globalSettings.lastScope === "local" ? "local" : "global"; +} +async function promptForScopeSelection(options: { + hasLocalAgents: boolean; + defaultScope: Scope; +}): Promise { const selected = await select({ message: "Choose scope for this command", options: [ { value: "local", label: ".agents in this repository", - hint: hasLocalAgents - ? defaultScope === "local" + hint: options.hasLocalAgents + ? options.defaultScope === "local" ? "default" : undefined - : defaultScope === "local" + : options.defaultScope === "local" ? "default (creates .agents)" : "creates .agents", }, { value: "global", label: "~/.agents shared config", - hint: defaultScope === "global" ? "default" : undefined, + hint: options.defaultScope === "global" ? "default" : undefined, }, ], - initialValue: defaultScope, + initialValue: options.defaultScope, }); if (isCancel(selected)) { @@ -91,5 +186,5 @@ export async function resolveScope( process.exit(1); } - return buildScopePaths(cwd, selected as Scope); + return selected as Scope; } diff --git a/packages/cli/tests/integration/sync-migration.test.ts b/packages/cli/tests/integration/sync-migration.test.ts index 852f1da..f4f102b 100644 --- a/packages/cli/tests/integration/sync-migration.test.ts +++ b/packages/cli/tests/integration/sync-migration.test.ts @@ -12,15 +12,160 @@ afterEach(() => { for (const dir of tempDirs.splice(0, tempDirs.length)) { fs.rmSync(dir, { recursive: true, force: true }); } + vi.restoreAllMocks(); }); -describe("sync migration pre-step", () => { - it("migrates provider commands into canonical before sync generation", async () => { +describe("sync canonical output flow", () => { + it("rejects settings-only global preference state", async () => { + const workspaceRoot = fs.mkdtempSync( + path.join(os.tmpdir(), "agentloom-workspace-"), + ); + const homeRoot = fs.mkdtempSync(path.join(os.tmpdir(), "agentloom-home-")); + tempDirs.push(workspaceRoot, homeRoot); + vi.spyOn(os, "homedir").mockReturnValue(homeRoot); + + writeTextAtomic( + path.join(homeRoot, ".agents", "settings.local.json"), + JSON.stringify({ version: 1, lastScope: "global" }, null, 2), + ); + + await expect( + runSyncCommand( + parseArgs(["sync", "--global", "--providers", "cursor", "--yes"]), + workspaceRoot, + ), + ).rejects.toThrow( + `No initialized canonical .agents state found at ${path.join(homeRoot, ".agents")}.`, + ); + + expect(fs.existsSync(path.join(homeRoot, ".agents", "mcp.json"))).toBe( + false, + ); + }); + + it("requires canonical .agents before syncing", async () => { + const workspaceRoot = fs.mkdtempSync( + path.join(os.tmpdir(), "agentloom-workspace-"), + ); + tempDirs.push(workspaceRoot); + + ensureDir(path.join(workspaceRoot, ".cursor", "commands")); + writeTextAtomic( + path.join(workspaceRoot, ".cursor", "commands", "review.prompt.md"), + "# /review\n\nReview this pull request.\n", + ); + + await expect( + runSyncCommand( + parseArgs(["sync", "--local", "--providers", "cursor", "--yes"]), + workspaceRoot, + ), + ).rejects.toThrow( + `No initialized canonical .agents state found at ${path.join(workspaceRoot, ".agents")}.`, + ); + await expect( + runSyncCommand( + parseArgs(["sync", "--local", "--providers", "cursor", "--yes"]), + workspaceRoot, + ), + ).rejects.toThrow("agentloom init --local"); + + expect( + fs.existsSync( + path.join(workspaceRoot, ".agents", "commands", "review.md"), + ), + ).toBe(false); + }); + + it("rejects empty placeholder .agents directories", async () => { + const workspaceRoot = fs.mkdtempSync( + path.join(os.tmpdir(), "agentloom-workspace-"), + ); + tempDirs.push(workspaceRoot); + + ensureDir(path.join(workspaceRoot, ".agents")); + ensureDir(path.join(workspaceRoot, ".cursor")); + writeTextAtomic( + path.join(workspaceRoot, ".cursor", "mcp.json"), + JSON.stringify( + { + mcpServers: { + browser: { + command: "npx", + args: ["browser-tools-mcp"], + }, + }, + }, + null, + 2, + ), + ); + + await expect( + runSyncCommand( + parseArgs(["sync", "--local", "--providers", "cursor", "--yes"]), + workspaceRoot, + ), + ).rejects.toThrow( + `No initialized canonical .agents state found at ${path.join(workspaceRoot, ".agents")}.`, + ); + + const cursorMcp = JSON.parse( + fs.readFileSync(path.join(workspaceRoot, ".cursor", "mcp.json"), "utf8"), + ); + expect(cursorMcp.mcpServers).toEqual({ + browser: { + command: "npx", + args: ["browser-tools-mcp"], + }, + }); + }); + + it("does not fall back to global sync when the repo already has .agents", async () => { + const workspaceRoot = fs.mkdtempSync( + path.join(os.tmpdir(), "agentloom-workspace-"), + ); + const homeRoot = fs.mkdtempSync(path.join(os.tmpdir(), "agentloom-home-")); + tempDirs.push(workspaceRoot, homeRoot); + vi.spyOn(os, "homedir").mockReturnValue(homeRoot); + + ensureDir(path.join(workspaceRoot, ".agents")); + + writeTextAtomic( + path.join(homeRoot, ".agents", "mcp.json"), + JSON.stringify({ version: 1, mcpServers: {} }, null, 2), + ); + writeTextAtomic( + path.join(homeRoot, ".agents", "commands", "write.md"), + "# /write\n\nWrite the change.\n", + ); + + await expect( + runSyncCommand( + parseArgs(["sync", "--providers", "cursor", "--yes"]), + workspaceRoot, + ), + ).rejects.toThrow( + `No initialized canonical .agents state found at ${path.join(workspaceRoot, ".agents")}.`, + ); + + expect( + fs.existsSync(path.join(homeRoot, ".cursor", "commands", "write.md")), + ).toBe(false); + }); + + it("syncs from canonical state without importing provider-only files", async () => { const workspaceRoot = fs.mkdtempSync( path.join(os.tmpdir(), "agentloom-workspace-"), ); tempDirs.push(workspaceRoot); + ensureDir(path.join(workspaceRoot, ".agents", "commands")); + writeTextAtomic( + path.join(workspaceRoot, ".agents", "commands", "write.md"), + "# /write\n\nWrite the change.\n", + ); + ensureDir(path.join(workspaceRoot, ".cursor", "commands")); writeTextAtomic( path.join(workspaceRoot, ".cursor", "commands", "review.prompt.md"), @@ -34,22 +179,28 @@ describe("sync migration pre-step", () => { expect( fs.existsSync( - path.join(workspaceRoot, ".agents", "commands", "review.md"), + path.join(workspaceRoot, ".cursor", "commands", "write.md"), ), ).toBe(true); expect( fs.existsSync( - path.join(workspaceRoot, ".cursor", "commands", "review.md"), + path.join(workspaceRoot, ".agents", "commands", "review.md"), ), - ).toBe(true); + ).toBe(false); }); - it("includes migrated commands in dry-run sync preview", async () => { + it("keeps dry-run previews one-way from canonical state", async () => { const workspaceRoot = fs.mkdtempSync( path.join(os.tmpdir(), "agentloom-workspace-"), ); tempDirs.push(workspaceRoot); + ensureDir(path.join(workspaceRoot, ".agents", "commands")); + writeTextAtomic( + path.join(workspaceRoot, ".agents", "commands", "write.md"), + "# /write\n\nWrite the change.\n", + ); + ensureDir(path.join(workspaceRoot, ".cursor", "commands")); writeTextAtomic( path.join(workspaceRoot, ".cursor", "commands", "review.prompt.md"), @@ -71,17 +222,19 @@ describe("sync migration pre-step", () => { ); const output = logSpy.mock.calls.map((call) => String(call[0])).join("\n"); - logSpy.mockRestore(); expect(output).toContain("Generated/updated files: 2"); expect( fs.existsSync( - path.join(workspaceRoot, ".agents", "commands", "review.md"), + path.join(workspaceRoot, ".cursor", "commands", "write.md"), ), ).toBe(false); + expect(fs.existsSync(path.join(workspaceRoot, ".cursor", "mcp.json"))).toBe( + false, + ); expect( fs.existsSync( - path.join(workspaceRoot, ".cursor", "commands", "review.md"), + path.join(workspaceRoot, ".agents", "commands", "review.md"), ), ).toBe(false); }); diff --git a/packages/cli/tests/unit/cli-help.test.ts b/packages/cli/tests/unit/cli-help.test.ts index 5e90528..91dbff4 100644 --- a/packages/cli/tests/unit/cli-help.test.ts +++ b/packages/cli/tests/unit/cli-help.test.ts @@ -26,9 +26,21 @@ describe("cli help routing", () => { const output = logSpy.mock.calls.map((call) => String(call[0])).join("\n"); expect(output).toContain("agentloom init [options]"); + expect(output).toContain("existing provider configs"); expect(output).toContain("--no-sync"); }); + it("prints sync help", async () => { + const logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined); + + await runCli(["sync", "--help"]); + + const output = logSpy.mock.calls.map((call) => String(call[0])).join("\n"); + expect(output).toContain("agentloom sync [options]"); + expect(output).toContain("canonical .agents data"); + expect(output).toContain("Use `agentloom init`"); + }); + it("prints upgrade help", async () => { const logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined); diff --git a/packages/cli/tests/unit/copy.test.ts b/packages/cli/tests/unit/copy.test.ts index ccf3382..2ed8245 100644 --- a/packages/cli/tests/unit/copy.test.ts +++ b/packages/cli/tests/unit/copy.test.ts @@ -6,9 +6,11 @@ import { formatUnknownCommandError, formatUsageError, getAddHelpText, + getInitHelpText, getMcpAddHelpText, getMcpHelpText, getRootHelpText, + getSyncHelpText, } from "../../src/core/copy.js"; describe("copy helpers", () => { @@ -18,7 +20,9 @@ describe("copy helpers", () => { expect(help).toContain("command "); expect(help).toContain("find "); expect(help).toContain("init"); + expect(help).toContain("Bootstrap canonical files from provider configs"); expect(help).toContain("upgrade"); + expect(help).toContain("Generate provider outputs from canonical .agents"); expect(help).toContain("delete "); expect(help).toContain("mcp "); expect(help).toContain("rule "); @@ -79,4 +83,14 @@ describe("copy helpers", () => { expect(help).toContain("--selection-mode "); expect(help).toContain("agentloom add farnoodma/agents --providers"); }); + + it("separates init migration from sync generation help", () => { + expect(getSyncHelpText()).toContain( + "Generate provider-specific outputs from canonical .agents data.", + ); + expect(getSyncHelpText()).toContain("Use `agentloom init`"); + expect(getInitHelpText()).toContain( + "Bootstrap canonical .agents files from existing provider configs", + ); + }); }); diff --git a/packages/cli/tests/unit/init-command.test.ts b/packages/cli/tests/unit/init-command.test.ts index a25dc13..f9a7c9e 100644 --- a/packages/cli/tests/unit/init-command.test.ts +++ b/packages/cli/tests/unit/init-command.test.ts @@ -41,6 +41,7 @@ describe("runInitCommand", () => { cwd: "/workspace", target: "all", skipSync: true, + migrateProviderState: true, }); }); }); diff --git a/packages/cli/tests/unit/scope.test.ts b/packages/cli/tests/unit/scope.test.ts index e710e5e..3e8ab0c 100644 --- a/packages/cli/tests/unit/scope.test.ts +++ b/packages/cli/tests/unit/scope.test.ts @@ -27,8 +27,30 @@ vi.mock("../../src/core/settings.js", () => ({ })); import { resolveScope } from "../../src/core/scope.js"; +import { resolveScopeForSync } from "../../src/core/scope.js"; const tempDirs: string[] = []; +let homedirSpy: ReturnType; + +function writeInitializedCanonicalMarker(root: string): void { + const agentsRoot = path.join(root, ".agents"); + fs.mkdirSync(agentsRoot, { recursive: true }); + fs.writeFileSync( + path.join(agentsRoot, "mcp.json"), + JSON.stringify({ version: 1, mcpServers: {} }, null, 2), + "utf8", + ); +} + +function writeSettingsOnlyMarker(root: string): void { + const agentsRoot = path.join(root, ".agents"); + fs.mkdirSync(agentsRoot, { recursive: true }); + fs.writeFileSync( + path.join(agentsRoot, "settings.local.json"), + JSON.stringify({ version: 1, lastScope: "global" }, null, 2), + "utf8", + ); +} beforeEach(() => { promptMocks.cancel.mockReset(); @@ -54,12 +76,15 @@ beforeEach(() => { ], telemetry: { enabled: true }, } satisfies AgentloomSettings); + + homedirSpy = vi.spyOn(os, "homedir"); }); afterEach(() => { for (const dir of tempDirs.splice(0, tempDirs.length)) { fs.rmSync(dir, { recursive: true, force: true }); } + homedirSpy.mockRestore(); }); describe("resolveScope", () => { @@ -152,3 +177,125 @@ describe("resolveScope", () => { ); }); }); + +describe("resolveScopeForSync", () => { + it("fails before prompting when no canonical scope exists", async () => { + const workspaceRoot = fs.mkdtempSync( + path.join(os.tmpdir(), "agentloom-workspace-"), + ); + const homeRoot = fs.mkdtempSync(path.join(os.tmpdir(), "agentloom-home-")); + tempDirs.push(workspaceRoot, homeRoot); + homedirSpy.mockReturnValue(homeRoot); + + await expect( + resolveScopeForSync({ + cwd: workspaceRoot, + interactive: true, + }), + ).rejects.toThrow( + `No initialized canonical .agents state found at ${path.join(workspaceRoot, ".agents")} or ${path.join(homeRoot, ".agents")}.`, + ); + + expect(promptMocks.select).not.toHaveBeenCalled(); + }); + + it("skips the scope prompt when only one canonical scope exists", async () => { + const workspaceRoot = fs.mkdtempSync( + path.join(os.tmpdir(), "agentloom-workspace-"), + ); + const homeRoot = fs.mkdtempSync(path.join(os.tmpdir(), "agentloom-home-")); + tempDirs.push(workspaceRoot, homeRoot); + homedirSpy.mockReturnValue(homeRoot); + + writeInitializedCanonicalMarker(homeRoot); + + const paths = await resolveScopeForSync({ + cwd: workspaceRoot, + interactive: true, + }); + + expect(paths.scope).toBe("global"); + expect(promptMocks.select).not.toHaveBeenCalled(); + }); + + it("ignores settings-only scope markers", async () => { + const workspaceRoot = fs.mkdtempSync( + path.join(os.tmpdir(), "agentloom-workspace-"), + ); + const homeRoot = fs.mkdtempSync(path.join(os.tmpdir(), "agentloom-home-")); + tempDirs.push(workspaceRoot, homeRoot); + homedirSpy.mockReturnValue(homeRoot); + + writeSettingsOnlyMarker(homeRoot); + + await expect( + resolveScopeForSync({ + cwd: workspaceRoot, + interactive: true, + }), + ).rejects.toThrow( + `No initialized canonical .agents state found at ${path.join(workspaceRoot, ".agents")} or ${path.join(homeRoot, ".agents")}.`, + ); + }); + + it("keeps placeholder .agents directories on local scope in non-interactive mode", async () => { + const workspaceRoot = fs.mkdtempSync( + path.join(os.tmpdir(), "agentloom-workspace-"), + ); + const homeRoot = fs.mkdtempSync(path.join(os.tmpdir(), "agentloom-home-")); + tempDirs.push(workspaceRoot, homeRoot); + homedirSpy.mockReturnValue(homeRoot); + + fs.mkdirSync(path.join(workspaceRoot, ".agents"), { recursive: true }); + writeInitializedCanonicalMarker(homeRoot); + + const paths = await resolveScopeForSync({ + cwd: workspaceRoot, + interactive: false, + }); + + expect(paths.scope).toBe("local"); + }); + + it("prompts when a placeholder local .agents exists alongside global canonical state", async () => { + const workspaceRoot = fs.mkdtempSync( + path.join(os.tmpdir(), "agentloom-workspace-"), + ); + const homeRoot = fs.mkdtempSync(path.join(os.tmpdir(), "agentloom-home-")); + tempDirs.push(workspaceRoot, homeRoot); + homedirSpy.mockReturnValue(homeRoot); + + fs.mkdirSync(path.join(workspaceRoot, ".agents"), { recursive: true }); + writeInitializedCanonicalMarker(homeRoot); + promptMocks.select.mockResolvedValueOnce("global"); + + const paths = await resolveScopeForSync({ + cwd: workspaceRoot, + interactive: true, + }); + + expect(paths.scope).toBe("global"); + expect(promptMocks.select).toHaveBeenCalledTimes(1); + }); + + it("prompts when both canonical scopes exist", async () => { + const workspaceRoot = fs.mkdtempSync( + path.join(os.tmpdir(), "agentloom-workspace-"), + ); + const homeRoot = fs.mkdtempSync(path.join(os.tmpdir(), "agentloom-home-")); + tempDirs.push(workspaceRoot, homeRoot); + homedirSpy.mockReturnValue(homeRoot); + + writeInitializedCanonicalMarker(workspaceRoot); + writeInitializedCanonicalMarker(homeRoot); + promptMocks.select.mockResolvedValueOnce("local"); + + const paths = await resolveScopeForSync({ + cwd: workspaceRoot, + interactive: true, + }); + + expect(paths.scope).toBe("local"); + expect(promptMocks.select).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/cli/tests/unit/sync-migration.test.ts b/packages/cli/tests/unit/sync-migration.test.ts index 224465e..0afbcc8 100644 --- a/packages/cli/tests/unit/sync-migration.test.ts +++ b/packages/cli/tests/unit/sync-migration.test.ts @@ -1,9 +1,14 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { ParsedArgs } from "minimist"; import type { ScopePaths } from "../../src/types.js"; const commandMocks = vi.hoisted(() => ({ resolvePathsForCommand: vi.fn(), + resolveScopeForSync: vi.fn(), + hasInitializedCanonicalLayout: vi.fn(), resolveProvidersForSync: vi.fn(), initializeCanonicalLayout: vi.fn(), migrateProviderStateToCanonical: vi.fn(), @@ -17,6 +22,11 @@ vi.mock("../../src/commands/entity-utils.js", () => ({ getNonInteractiveMode: vi.fn(() => true), })); +vi.mock("../../src/core/scope.js", () => ({ + resolveScopeForSync: commandMocks.resolveScopeForSync, + hasInitializedCanonicalLayout: commandMocks.hasInitializedCanonicalLayout, +})); + vi.mock("../../src/core/migration.js", () => ({ initializeCanonicalLayout: commandMocks.initializeCanonicalLayout, migrateProviderStateToCanonical: commandMocks.migrateProviderStateToCanonical, @@ -32,7 +42,7 @@ vi.mock("../../src/sync/index.js", () => ({ const { runScopedSyncCommand } = await import("../../src/commands/sync.js"); -function createScopePaths(root = "/tmp/agentloom"): ScopePaths { +function createScopePaths(root: string): ScopePaths { return { scope: "local", workspaceRoot: root, @@ -49,10 +59,17 @@ function createScopePaths(root = "/tmp/agentloom"): ScopePaths { }; } -const paths = createScopePaths(); +let tempRoot = ""; +let paths: ScopePaths; beforeEach(() => { + tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "agentloom-sync-test-")); + paths = createScopePaths(tempRoot); + fs.mkdirSync(paths.agentsRoot, { recursive: true }); + commandMocks.resolvePathsForCommand.mockReset(); + commandMocks.resolveScopeForSync.mockReset(); + commandMocks.hasInitializedCanonicalLayout.mockReset(); commandMocks.resolveProvidersForSync.mockReset(); commandMocks.initializeCanonicalLayout.mockReset(); commandMocks.migrateProviderStateToCanonical.mockReset(); @@ -61,6 +78,8 @@ beforeEach(() => { commandMocks.formatSyncSummary.mockReset(); commandMocks.resolvePathsForCommand.mockResolvedValue(paths); + commandMocks.resolveScopeForSync.mockResolvedValue(paths); + commandMocks.hasInitializedCanonicalLayout.mockReturnValue(true); commandMocks.resolveProvidersForSync.mockResolvedValue(["cursor"]); commandMocks.migrateProviderStateToCanonical.mockResolvedValue({ providers: ["cursor"], @@ -82,8 +101,13 @@ beforeEach(() => { commandMocks.formatSyncSummary.mockReturnValue("sync summary"); }); -describe("sync migration pipeline", () => { - it("runs migration before sync", async () => { +afterEach(() => { + fs.rmSync(tempRoot, { recursive: true, force: true }); + vi.restoreAllMocks(); +}); + +describe("sync command pipeline", () => { + it("runs provider sync without migration when canonical config exists", async () => { const logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined); await runScopedSyncCommand({ @@ -92,33 +116,50 @@ describe("sync migration pipeline", () => { target: "all", }); + expect(commandMocks.resolveScopeForSync).toHaveBeenCalledWith({ + cwd: "/workspace", + global: false, + local: false, + interactive: false, + }); expect(commandMocks.initializeCanonicalLayout).toHaveBeenCalledWith(paths, [ "cursor", ]); - expect(commandMocks.migrateProviderStateToCanonical).toHaveBeenCalledWith({ + expect(commandMocks.migrateProviderStateToCanonical).not.toHaveBeenCalled(); + expect(commandMocks.syncFromCanonical).toHaveBeenCalledWith({ paths, providers: ["cursor"], - target: "all", yes: true, nonInteractive: true, dryRun: false, - materializeCanonical: false, + target: "all", }); - expect(commandMocks.syncFromCanonical).toHaveBeenCalled(); - expect(logSpy).toHaveBeenCalledWith("migration summary"); expect(logSpy).toHaveBeenCalledWith("sync summary"); + expect(logSpy).not.toHaveBeenCalledWith("migration summary"); }); - it("supports skipSync for init --no-sync", async () => { + it("supports migration for init --no-sync", async () => { + const logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined); + await runScopedSyncCommand({ argv: { _: ["init"], yes: true } as ParsedArgs, cwd: "/workspace", target: "all", skipSync: true, + migrateProviderState: true, }); - expect(commandMocks.migrateProviderStateToCanonical).toHaveBeenCalled(); + expect(commandMocks.migrateProviderStateToCanonical).toHaveBeenCalledWith({ + paths, + providers: ["cursor"], + target: "all", + yes: true, + nonInteractive: true, + dryRun: false, + materializeCanonical: false, + }); expect(commandMocks.syncFromCanonical).not.toHaveBeenCalled(); + expect(logSpy).toHaveBeenCalledWith("migration summary"); }); it("uses an ephemeral canonical path for dry-run previews", async () => { @@ -128,20 +169,53 @@ describe("sync migration pipeline", () => { target: "all", }); - expect(commandMocks.migrateProviderStateToCanonical).toHaveBeenCalledWith( - expect.objectContaining({ - providers: ["cursor"], - dryRun: true, - materializeCanonical: true, - }), - ); - - const migrationCall = - commandMocks.migrateProviderStateToCanonical.mock.calls.at(-1)?.[0]; + const initializeCall = + commandMocks.initializeCanonicalLayout.mock.calls.at(-1)?.[0]; const syncCall = commandMocks.syncFromCanonical.mock.calls.at(-1)?.[0]; - expect(migrationCall?.paths.agentsRoot).not.toBe(paths.agentsRoot); - expect(syncCall?.paths.agentsRoot).toBe(migrationCall?.paths.agentsRoot); + expect(commandMocks.migrateProviderStateToCanonical).not.toHaveBeenCalled(); + expect(initializeCall?.agentsRoot).not.toBe(paths.agentsRoot); + expect(syncCall?.paths.agentsRoot).toBe(initializeCall?.agentsRoot); expect(syncCall?.dryRun).toBe(true); }); + + it("fails before provider resolution when sync scope cannot be resolved", async () => { + commandMocks.resolveScopeForSync.mockRejectedValueOnce( + new Error("No initialized canonical .agents state found."), + ); + + await expect( + runScopedSyncCommand({ + argv: { _: ["sync"] } as ParsedArgs, + cwd: "/workspace", + target: "all", + }), + ).rejects.toThrow("No initialized canonical .agents state found."); + + expect(commandMocks.resolveProvidersForSync).not.toHaveBeenCalled(); + }); + + it("fails sync when canonical .agents does not exist yet", async () => { + commandMocks.hasInitializedCanonicalLayout.mockReturnValue(false); + + await expect( + runScopedSyncCommand({ + argv: { _: ["sync"], yes: true } as ParsedArgs, + cwd: "/workspace", + target: "all", + }), + ).rejects.toThrow( + `No initialized canonical .agents state found at ${paths.agentsRoot}.`, + ); + await expect( + runScopedSyncCommand({ + argv: { _: ["sync"], yes: true } as ParsedArgs, + cwd: "/workspace", + target: "all", + }), + ).rejects.toThrow("agentloom init --local"); + + expect(commandMocks.initializeCanonicalLayout).not.toHaveBeenCalled(); + expect(commandMocks.syncFromCanonical).not.toHaveBeenCalled(); + }); });