Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
115 changes: 115 additions & 0 deletions packages/cli/__tests__/commands/start.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -482,6 +482,68 @@ function createFakeRepo(dir: string, remoteUrl: string, files?: Record<string, s
}
}

describe("start command — auto config creation", () => {
it("warns instead of failing when first-run global registration collides", async () => {
createFakeRepo(tmpDir, "https://github.com/org/first-run.git");

const detectEnv = await import("../../src/lib/detect-env.js");
vi.mocked(detectEnv.detectEnvironment).mockResolvedValueOnce({
isGitRepo: true,
gitRemote: "https://github.com/org/first-run.git",
ownerRepo: "org/first-run",
currentBranch: "main",
defaultBranch: "main",
hasTmux: true,
hasGh: true,
ghAuthed: true,
hasLinearKey: false,
hasSlackWebhook: false,
});

const globalConfigDir = join(tmpDir, "global");
const globalConfigPath = join(globalConfigDir, "config.yaml");
mkdirSync(globalConfigDir, { recursive: true });
const { stringify: yamlStringify } = await import("yaml");
writeFileSync(
globalConfigPath,
yamlStringify(
{
defaults: { runtime: "process", agent: "claude-code", workspace: "worktree", notifiers: [] },
projects: {
existing: {
projectId: "existing",
path: realpathSync(tmpDir),
defaultBranch: "main",
source: "local",
registeredAt: 1,
displayName: "Existing",
sessionPrefix: "existing",
},
},
},
{ indent: 2 },
),
);

const originalGlobalConfig = process.env["AO_GLOBAL_CONFIG"];
process.env["AO_GLOBAL_CONFIG"] = globalConfigPath;
try {
await autoCreateConfig(tmpDir);

expect(existsSync(join(tmpDir, "agent-orchestrator.yaml"))).toBe(true);
const output = vi
.mocked(console.log)
.mock.calls.map((call) => call.join(" "))
.join("\n");
expect(output).toContain("Could not register project in global config");
expect(output).toContain("Project \"existing\" is already registered");
} finally {
if (originalGlobalConfig === undefined) delete process.env["AO_GLOBAL_CONFIG"];
else process.env["AO_GLOBAL_CONFIG"] = originalGlobalConfig;
}
});
});

// ---------------------------------------------------------------------------
// resolveProject (tested through `ao start` with --no-dashboard --no-orchestrator)
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -2260,6 +2322,59 @@ describe("start command — autoCreateConfig", () => {
);
expect(parsed.defaults?.notifiers).toEqual([]);
});

it("registers a first-run project in the global config", async () => {
createFakeRepo(tmpDir, "https://github.com/org/first-run.git");

const { detectEnvironment } = await import("../../src/lib/detect-env.js");
vi.mocked(detectEnvironment).mockResolvedValue({
isGitRepo: true,
gitRemote: "https://github.com/org/first-run.git",
ownerRepo: "org/first-run",
currentBranch: "main",
defaultBranch: "main",
hasTmux: true,
hasGh: true,
ghAuthed: true,
hasLinearKey: false,
hasSlackWebhook: false,
});

const { detectAvailableAgents, detectAgentRuntime } =
await import("../../src/lib/detect-agent.js");
vi.mocked(detectAvailableAgents).mockResolvedValue([]);
vi.mocked(detectAgentRuntime).mockResolvedValue("claude-code");

const { findFreePort } = await import("../../src/lib/web-dir.js");
vi.mocked(findFreePort).mockResolvedValue(3000);

mockProcessCwd.mockReturnValue(tmpDir);
mockIsHumanCaller.mockReturnValue(false);

const originalGlobalConfig = process.env["AO_GLOBAL_CONFIG"];
const globalConfigPath = join(tmpDir, "global", "config.yaml");
process.env["AO_GLOBAL_CONFIG"] = globalConfigPath;

try {
await autoCreateConfig(tmpDir);

expect(existsSync(join(tmpDir, "agent-orchestrator.yaml"))).toBe(true);
expect(existsSync(globalConfigPath)).toBe(true);

const parsed = parseYaml(readFileSync(globalConfigPath, "utf-8")) as {
projects: Record<string, { path: string; repo?: { originUrl: string } }>;
};
const projects = Object.values(parsed.projects);
expect(projects).toHaveLength(1);
expect(projects[0]).toMatchObject({
path: realpathSync(tmpDir),
repo: { originUrl: "https://github.com/org/first-run" },
});
} finally {
if (originalGlobalConfig === undefined) delete process.env["AO_GLOBAL_CONFIG"];
else process.env["AO_GLOBAL_CONFIG"] = originalGlobalConfig;
}
});
});

// ---------------------------------------------------------------------------
Expand Down
21 changes: 17 additions & 4 deletions packages/cli/__tests__/lib/path-equality.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { mkdtempSync, rmSync, mkdirSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { homedir, tmpdir } from "node:os";
import { join, resolve } from "node:path";

import { pathsEqual, canonicalCompareKey } from "../../src/lib/path-equality.js";

Expand Down Expand Up @@ -77,13 +77,26 @@ describe("pathsEqual", () => {
});

describe("canonicalCompareKey", () => {
it("expands ~ to HOME", () => {
it("expands ~ to the OS home directory", () => {
const originalHome = process.env["HOME"];
process.env["HOME"] = tmpDir;
try {
const key = canonicalCompareKey("~");
// On Windows the result is lowercased; on POSIX it's case-preserved.
expect(key.toLowerCase()).toBe(tmpDir.toLowerCase());
expect(key.toLowerCase()).toBe(canonicalCompareKey(homedir()).toLowerCase());
} finally {
if (originalHome === undefined) delete process.env["HOME"];
else process.env["HOME"] = originalHome;
}
});

it("does not collapse ~ to the current directory when HOME is missing", () => {
const originalHome = process.env["HOME"];
delete process.env["HOME"];

try {
expect(canonicalCompareKey("~")).toBe(canonicalCompareKey(homedir()));
expect(canonicalCompareKey("~")).not.toBe(canonicalCompareKey(resolve("")));
} finally {
if (originalHome === undefined) delete process.env["HOME"];
else process.env["HOME"] = originalHome;
Expand Down
16 changes: 15 additions & 1 deletion packages/cli/src/commands/start.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

import { spawn, type ChildProcess } from "node:child_process";
import { existsSync, readFileSync, writeFileSync } from "node:fs";
import { homedir } from "node:os";
import { resolve, basename, dirname } from "node:path";
import { cwd } from "node:process";
import chalk from "chalk";
Expand Down Expand Up @@ -571,6 +572,19 @@ export async function autoCreateConfig(workingDir: string): Promise<Orchestrator

console.log(chalk.green(`✓ Config created: ${outputPath}\n`));

try {
const registeredProjectId = registerProjectInGlobalConfig(projectId, projectId, path, {
...(repo ? { repo } : {}),
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`));
}

if (!repo) {
console.log(
chalk.yellow("⚠ No repo configured — issue tracking and PR features will be unavailable."),
Expand Down Expand Up @@ -615,7 +629,7 @@ async function addProjectToConfig(
config: OrchestratorConfig,
projectPath: string,
): Promise<string> {
const resolvedPath = resolve(projectPath.replace(/^~/, process.env["HOME"] || ""));
const resolvedPath = resolve(projectPath.replace(/^~/, homedir()));

// Check if this path is already registered under any project name.
// pathsEqual canonicalizes via realpathSync and lowercases on Windows so
Expand Down
3 changes: 2 additions & 1 deletion packages/cli/src/lib/path-equality.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
*/

import { realpathSync } from "node:fs";
import { homedir } from "node:os";
import { resolve } from "node:path";
import { isWindows } from "@aoagents/ao-core";

Expand All @@ -39,7 +40,7 @@ function canonicalize(p: string): string {
* caller needs a stable key for `Map`/`Set` lookups across many paths.
*/
export function canonicalCompareKey(input: string): string {
const expanded = input.replace(/^~/, process.env["HOME"] ?? "");
const expanded = input.replace(/^~/, homedir());
const canonical = canonicalize(resolve(expanded));
return isWindows() ? canonical.toLowerCase() : canonical;
}
Expand Down
3 changes: 2 additions & 1 deletion packages/cli/src/lib/resolve-project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
*/

import { existsSync, writeFileSync } from "node:fs";
import { homedir } from "node:os";
import { resolve } from "node:path";
import { pathsEqual } from "./path-equality.js";
import { cwd } from "node:process";
Expand Down Expand Up @@ -333,7 +334,7 @@ async function fromUrl(arg: string, deps: ResolveDeps, opts: ResolveOptions): Pr
}

async function fromPath(arg: string, deps: ResolveDeps, opts: ResolveOptions): Promise<Resolved> {
const resolvedPath = resolve(arg.replace(/^~/, process.env["HOME"] || ""));
const resolvedPath = resolve(arg.replace(/^~/, homedir()));

// When a daemon is already running, register against the global config
// (the daemon's source of truth) instead of whatever cwd-local config
Expand Down
4 changes: 4 additions & 0 deletions packages/cli/vitest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,10 @@ export default defineConfig({
find: "@aoagents/ao-plugin-scm-github",
replacement: resolve(__dirname, "../plugins/scm-github/src/index.ts"),
},
{
find: "@aoagents/ao-plugin-runtime-process",
replacement: resolve(__dirname, "../plugins/runtime-process/src/index.ts"),
},
],
},
});
21 changes: 21 additions & 0 deletions packages/core/src/__tests__/global-config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { join } from "node:path";
import { parse as parseYaml } from "yaml";
import {
generateExternalId,
isCanonicalGlobalConfigPath,
loadGlobalConfig,
migrateToGlobalConfig,
repairWrappedLocalProjectConfig,
Expand All @@ -17,6 +18,7 @@ describe("global-config storage identity", () => {
let configPath: string;
let originalHome: string | undefined;
let originalUserProfile: string | undefined;
let originalPlatform: PropertyDescriptor | undefined;

beforeEach(() => {
tempRoot = join(
Expand All @@ -27,13 +29,17 @@ describe("global-config storage identity", () => {
configPath = join(tempRoot, "config.yaml");
originalHome = process.env["HOME"];
originalUserProfile = process.env["USERPROFILE"];
originalPlatform = Object.getOwnPropertyDescriptor(process, "platform");
process.env["HOME"] = tempRoot;
process.env["USERPROFILE"] = tempRoot;
});

afterEach(() => {
process.env["HOME"] = originalHome;
process.env["USERPROFILE"] = originalUserProfile;
if (originalPlatform) {
Object.defineProperty(process, "platform", originalPlatform);
}
rmSync(tempRoot, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
});

Expand Down Expand Up @@ -501,4 +507,19 @@ describe("global-config storage identity", () => {
const expected = process.platform === "win32" ? "process" : "tmux";
expect(config?.defaults?.runtime).toBe(expected);
});

it("matches canonical global config paths case-insensitively on Windows", () => {
const originalGlobalConfig = process.env["AO_GLOBAL_CONFIG"];
Object.defineProperty(process, "platform", { value: "win32", configurable: true });
process.env["AO_GLOBAL_CONFIG"] = "C:\\Users\\Priya\\.agent-orchestrator\\config.yaml";

try {
expect(
isCanonicalGlobalConfigPath("c:\\Users\\Priya\\.agent-orchestrator\\config.yaml"),
).toBe(true);
} finally {
if (originalGlobalConfig === undefined) delete process.env["AO_GLOBAL_CONFIG"];
else process.env["AO_GLOBAL_CONFIG"] = originalGlobalConfig;
}
});
});
26 changes: 21 additions & 5 deletions packages/core/src/global-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { withFileLockSync } from "./file-lock.js";
import { ProjectResolveError } from "./types.js";
import { generateSessionPrefix } from "./paths.js";
import { normalizeOriginUrl } from "./storage-key.js";
import { getDefaultRuntime } from "./platform.js";
import { getDefaultRuntime, isWindows } from "./platform.js";

function globalConfigLockPath(configPath: string): string {
return `${configPath}.lock`;
Expand Down Expand Up @@ -99,7 +99,23 @@ export function getGlobalConfigPath(): string {

export function isCanonicalGlobalConfigPath(configPath: string | undefined): boolean {
if (!configPath) return false;
return resolve(configPath) === resolve(getGlobalConfigPath());
return registryPathCompareKey(configPath) === registryPathCompareKey(getGlobalConfigPath());
}

function registryPathCompareKey(path: string): string {
let resolved = resolve(path);
try {
resolved = realpathSync(resolved);
} catch {
// The global config file itself, or a hand-edited registry path, may not
// exist yet. Fall back to the resolved path while preserving Windows casing
// behavior below.
}
return isWindows() ? resolved.toLowerCase() : resolved;
}
Comment thread
greptile-apps[bot] marked this conversation as resolved.

function registryPathsEqual(a: string, b: string): boolean {
return registryPathCompareKey(a) === registryPathCompareKey(b);
}

// =============================================================================
Expand Down Expand Up @@ -672,7 +688,7 @@ export function registerProjectInGlobalConfig(
| (GlobalProjectEntry & Record<string, unknown>)
| undefined;

if (hashedExisting?.path && resolve(hashedExisting.path) === normalizedProjectPath) {
if (hashedExisting?.path && registryPathsEqual(hashedExisting.path, normalizedProjectPath)) {
effectiveProjectId = hashedId;
existing = hashedExisting;
} else if (!hashedExisting) {
Expand All @@ -685,7 +701,7 @@ export function registerProjectInGlobalConfig(
}
}

if (existing?.path && resolve(existing.path) !== normalizedProjectPath) {
if (existing?.path && !registryPathsEqual(existing.path, normalizedProjectPath)) {
throw new Error(
`Project id "${effectiveProjectId}" is already registered for "${existing.path}". ` +
`Choose a different configProjectKey to add "${normalizedProjectPath}" as a separate project.`,
Expand All @@ -694,7 +710,7 @@ export function registerProjectInGlobalConfig(

for (const [existingProjectId, entry] of Object.entries(globalConfig.projects)) {
if (existingProjectId === effectiveProjectId) continue;
if (resolve(entry.path) === normalizedProjectPath) {
if (registryPathsEqual(entry.path, normalizedProjectPath)) {
throw new Error(
`Project "${existingProjectId}" is already registered at "${normalizedProjectPath}". ` +
`Choose a different project ID or path.`,
Expand Down
Loading