fix: align first-run project identity (#2039)

* fix: align first-run project identity

* test(cli): canonicalize tmpDir with realpathSync for macOS

The new "writes a flat local config and returns the global project
identity" test failed on macOS because os.tmpdir() returns /var/... while
the project path stored in the global registry is canonicalized to
/private/var/... by registerProjectInGlobalConfig and loadConfig. Wrap
the comparison operand in realpathSync to match the prior art in the
PR's own integration test (cli-first-run-config.integration.test.ts:31).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(cli): address PR review feedback

Three small follow-ups raised by reviewers on #2039:

- Remove stale findFreePort mock in autoCreateConfig test — the function
  no longer calls findFreePort, so the mock setup was dead code
- Drop misleading "⚠ Could not register project in global config" warning
  printed immediately before rethrowing. The yellow warning visually
  signalled a recoverable-and-continuing flow, but the function now
  throws fatally. Let the thrown error propagate to the caller for
  presentation; the registerProjectInGlobalConfig error message is
  descriptive enough on its own
- Add trailing newline to cli-first-run-config.integration.test.ts

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: suraj-markup <sk9261712674@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Madhav Kumar 2026-05-23 21:58:04 +05:30 committed by GitHub
parent cdd1030d8d
commit 2fa27412ea
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 186 additions and 43 deletions

View File

@ -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<string, { path?: string; sessionPrefix?: string }>;
};
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);
});
});

View File

@ -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<Orchestrator
const agent = await detectAgentRuntime(detectedAgents);
console.log(chalk.green(` ✓ Agent runtime: ${agent}`));
const port = await findFreePort(DEFAULT_PORT);
if (port !== null && port !== DEFAULT_PORT) {
console.log(chalk.yellow(` ⚠ Port ${DEFAULT_PORT} is busy — using ${port} instead.`));
}
const config: Record<string, unknown> = {
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<Orchestrator
console.log(chalk.dim(" Use 'ao start' to start with the existing config.\n"));
return loadConfig(outputPath);
}
const yamlContent = configToYaml(config);
writeFileSync(outputPath, yamlContent);
console.log(chalk.green(`✓ Config created: ${outputPath}\n`));
writeLocalProjectConfig(workingDir, localConfig, outputPath);
try {
const registeredProjectId = registerProjectInGlobalConfig(projectId, projectId, path, {
@ -608,13 +587,15 @@ export async function autoCreateConfig(workingDir: string): Promise<Orchestrator
defaultBranch,
sessionPrefix: generateSessionPrefix(projectId),
});
console.log(chalk.green(`✓ Registered "${registeredProjectId}" in global config\n`));
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
console.log(chalk.yellow("⚠ Could not register project in global config."));
console.log(chalk.dim(` ${message}\n`));
rmSync(outputPath, { force: true });
throw err;
}
console.log(chalk.green(`✓ Config created: ${outputPath}\n`));
if (!repo) {
console.log(
chalk.yellow("⚠ No repo configured — issue tracking and PR features will be unavailable."),

View File

@ -0,0 +1,102 @@
import { execFile } from "node:child_process";
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { mkdtemp, realpath, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { promisify } from "node:util";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { isWindows, loadConfig } from "@aoagents/ao-core";
import { isTmuxAvailable } from "./helpers/tmux.js";
const execFileAsync = promisify(execFile);
const __dirname = dirname(fileURLToPath(import.meta.url));
const repoRoot = resolve(__dirname, "../../..");
const cliEntry = join(repoRoot, "packages/cli/src/index.ts");
const tsxBin = join(repoRoot, "packages/cli/node_modules/.bin/tsx");
const tmuxOk = await isTmuxAvailable();
const canRun = !isWindows() && existsSync(tsxBin) && tmuxOk;
describe.skipIf(!canRun)("CLI first-run config generation (integration)", () => {
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);
});