diff --git a/packages/cli/__tests__/commands/start.test.ts b/packages/cli/__tests__/commands/start.test.ts index 964f0a674..1b7a3f593 100644 --- a/packages/cli/__tests__/commands/start.test.ts +++ b/packages/cli/__tests__/commands/start.test.ts @@ -15,11 +15,15 @@ import { rmSync, writeFileSync, } from "node:fs"; -import { join } from "node:path"; +import { basename, join } from "node:path"; import { tmpdir } from "node:os"; import { parse as parseYaml } from "yaml"; import { EventEmitter } from "node:events"; -import { recordActivityEvent, type SessionManager } from "@aoagents/ao-core"; +import { + getDefaultRuntime, + recordActivityEvent, + type SessionManager, +} from "@aoagents/ao-core"; // --------------------------------------------------------------------------- // Hoisted mocks @@ -2362,7 +2366,7 @@ describe("start command — platform-aware runtime fallback", () => { // --------------------------------------------------------------------------- describe("start command — autoCreateConfig", () => { - it("generates config with empty notifiers array (no desktop notifier added by default)", async () => { + it("writes a flat local config and returns the global project identity", async () => { const { detectEnvironment } = await import("../../src/lib/detect-env.js"); vi.mocked(detectEnvironment).mockResolvedValue({ isGitRepo: true, @@ -2385,9 +2389,6 @@ describe("start command — autoCreateConfig", () => { vi.mocked(detectAvailableAgents).mockResolvedValue([]); vi.mocked(detectAgentRuntime).mockResolvedValue("claude-code"); - const { findFreePort } = await import("../../src/lib/web-dir.js"); - vi.mocked(findFreePort).mockResolvedValue(3000); - // start.ts uses `import { cwd } from "node:process"` which is intercepted // by the node:process mock defined at the top of this file. mockProcessCwd.mockReturnValue(tmpDir); @@ -2396,20 +2397,79 @@ describe("start command — autoCreateConfig", () => { const callerContext = await import("../../src/lib/caller-context.js"); vi.spyOn(callerContext, "isHumanCaller").mockReturnValue(false); - await autoCreateConfig(tmpDir); + const config = await autoCreateConfig(tmpDir); const configPath = join(tmpDir, "agent-orchestrator.yaml"); expect(existsSync(configPath)).toBe(true); const content = readFileSync(configPath, "utf-8"); const parsed = parseYaml(content) as { - $schema?: string; - defaults?: { notifiers?: unknown[] }; + projects?: unknown; + runtime?: string; + agent?: string; + workspace?: string; }; - expect(parsed["$schema"]).toBe( - "https://raw.githubusercontent.com/ComposioHQ/agent-orchestrator/main/schema/config.schema.json", + expect(parsed.projects).toBeUndefined(); + expect(parsed.runtime).toBe(getDefaultRuntime()); + expect(parsed.agent).toBe("claude-code"); + expect(parsed.workspace).toBe("worktree"); + + const globalConfigPath = process.env["AO_GLOBAL_CONFIG"]!; + const globalContent = readFileSync(globalConfigPath, "utf-8"); + const globalParsed = parseYaml(globalContent) as { + defaults?: { notifiers?: unknown[] }; + projects?: Record; + }; + expect(globalParsed.defaults?.notifiers).toEqual(["composio", "desktop"]); + + const projectIds = Object.keys(globalParsed.projects ?? {}); + expect(projectIds).toHaveLength(1); + expect(config.configPath).toBe(configPath); + expect(Object.keys(config.projects)).toEqual(projectIds); + expect(config.projects[projectIds[0]!]!.path).toBe(realpathSync(tmpDir)); + }); + + it("removes the flat local config when global registration fails", async () => { + const { detectEnvironment } = await import("../../src/lib/detect-env.js"); + vi.mocked(detectEnvironment).mockResolvedValue({ + isGitRepo: true, + gitRemote: null, + ownerRepo: null, + currentBranch: "main", + defaultBranch: "main", + hasTmux: true, + hasGh: false, + ghAuthed: false, + hasLinearKey: false, + hasSlackWebhook: false, + }); + + const { detectProjectType } = await import("../../src/lib/project-detection.js"); + vi.mocked(detectProjectType).mockReturnValue({ languages: [], frameworks: [], tools: [] }); + + const { detectAvailableAgents, detectAgentRuntime } = + await import("../../src/lib/detect-agent.js"); + vi.mocked(detectAvailableAgents).mockResolvedValue([]); + vi.mocked(detectAgentRuntime).mockResolvedValue("claude-code"); + + mockProcessCwd.mockReturnValue(tmpDir); + + const callerContext = await import("../../src/lib/caller-context.js"); + vi.spyOn(callerContext, "isHumanCaller").mockReturnValue(false); + + writeFileSync( + process.env["AO_GLOBAL_CONFIG"]!, + [ + "projects:", + ` ${basename(tmpDir)}:`, + ` path: ${join(tmpDir, "other-repo")}`, + "", + ].join("\n"), ); - expect(parsed.defaults?.notifiers).toEqual([]); + + await expect(autoCreateConfig(tmpDir)).rejects.toThrow("already registered"); + + expect(existsSync(join(tmpDir, "agent-orchestrator.yaml"))).toBe(false); }); }); diff --git a/packages/cli/src/commands/start.ts b/packages/cli/src/commands/start.ts index 79850587c..4f5d86eb0 100644 --- a/packages/cli/src/commands/start.ts +++ b/packages/cli/src/commands/start.ts @@ -10,7 +10,7 @@ */ import { type ChildProcess } from "node:child_process"; -import { existsSync, readFileSync, writeFileSync } from "node:fs"; +import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { resolve, basename, dirname } from "node:path"; import { cwd } from "node:process"; import chalk from "chalk"; @@ -566,29 +566,11 @@ export async function autoCreateConfig(workingDir: string): Promise = { - port: port ?? DEFAULT_PORT, - defaults: { - runtime: getDefaultRuntime(), - agent, - workspace: "worktree", - notifiers: [], - }, - projects: { - [projectId]: { - name: projectId, - sessionPrefix: generateSessionPrefix(projectId), - ...(repo ? { repo } : {}), - path, - defaultBranch, - ...(agentRules ? { agentRules } : {}), - }, - }, + const localConfig: LocalProjectConfig = { + runtime: getDefaultRuntime(), + agent, + workspace: "worktree", + ...(agentRules ? { agentRules } : {}), }; const outputPath = resolve(workingDir, "agent-orchestrator.yaml"); @@ -597,10 +579,7 @@ export async function autoCreateConfig(workingDir: string): Promise { + let tmpHome: string; + let repoPath: string; + let globalConfigPath: string; + let originalHome: string | undefined; + let originalAoGlobalConfig: string | undefined; + let originalAoConfigPath: string | undefined; + + beforeEach(async () => { + tmpHome = await realpath(await mkdtemp(join(tmpdir(), "ao-first-run-int-"))); + repoPath = join(tmpHome, "first-run-repo"); + globalConfigPath = join(tmpHome, "global-agent-orchestrator.yaml"); + + originalHome = process.env["HOME"]; + originalAoGlobalConfig = process.env["AO_GLOBAL_CONFIG"]; + originalAoConfigPath = process.env["AO_CONFIG_PATH"]; + process.env["HOME"] = tmpHome; + process.env["AO_GLOBAL_CONFIG"] = globalConfigPath; + delete process.env["AO_CONFIG_PATH"]; + + mkdirSync(repoPath, { recursive: true }); + await execFileAsync("git", ["init"], { cwd: repoPath }); + await execFileAsync("git", ["config", "user.email", "test@example.com"], { cwd: repoPath }); + await execFileAsync("git", ["config", "user.name", "Test User"], { cwd: repoPath }); + await execFileAsync("git", ["branch", "-M", "main"], { cwd: repoPath }); + await execFileAsync( + "git", + ["remote", "add", "origin", "https://github.com/ComposioHQ/ao-first-run-fixture.git"], + { cwd: repoPath }, + ); + writeFileSync(join(repoPath, "README.md"), "# first-run integration\n"); + await execFileAsync("git", ["add", "."], { cwd: repoPath }); + await execFileAsync("git", ["commit", "-m", "Initial commit"], { cwd: repoPath }); + }, 30_000); + + afterEach(async () => { + if (originalHome === undefined) delete process.env["HOME"]; + else process.env["HOME"] = originalHome; + if (originalAoGlobalConfig === undefined) delete process.env["AO_GLOBAL_CONFIG"]; + else process.env["AO_GLOBAL_CONFIG"] = originalAoGlobalConfig; + if (originalAoConfigPath === undefined) delete process.env["AO_CONFIG_PATH"]; + else process.env["AO_CONFIG_PATH"] = originalAoConfigPath; + await rm(tmpHome, { recursive: true, force: true }).catch(() => {}); + }); + + it("generates flat local config that resolves to the global project identity", async () => { + const env: NodeJS.ProcessEnv = { + ...process.env, + HOME: tmpHome, + AO_GLOBAL_CONFIG: globalConfigPath, + }; + delete env["AO_CONFIG_PATH"]; + env["AO_CALLER_TYPE"] = "agent"; + + await execFileAsync( + tsxBin, + [cliEntry, "start", "--no-dashboard", "--no-orchestrator", "--no-restore"], + { cwd: repoPath, env, timeout: 60_000 }, + ); + + const localConfigPath = join(repoPath, "agent-orchestrator.yaml"); + expect(existsSync(localConfigPath)).toBe(true); + expect(existsSync(globalConfigPath)).toBe(true); + + const localConfig = readFileSync(localConfigPath, "utf-8"); + expect(localConfig).not.toMatch(/^projects:/m); + expect(localConfig).toMatch(/^runtime:/m); + expect(localConfig).toMatch(/^agent:/m); + expect(localConfig).toMatch(/^workspace: worktree$/m); + + const fromLocal = loadConfig(localConfigPath); + const fromGlobal = loadConfig(globalConfigPath); + const projectIds = Object.keys(fromGlobal.projects); + expect(projectIds).toHaveLength(1); + + const projectId = projectIds[0]!; + expect(projectId).toMatch(/^first-run-repo_[a-f0-9]{10}$/); + expect(projectId).not.toBe("first-run-repo"); + expect(Object.keys(fromLocal.projects)).toEqual([projectId]); + expect(fromLocal.projects[projectId].path).toBe(repoPath); + expect(fromGlobal.projects[projectId].path).toBe(repoPath); + }, 90_000); +});