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:
parent
cdd1030d8d
commit
2fa27412ea
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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."),
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
Loading…
Reference in New Issue