diff --git a/src/infra/openclaw-root.test.ts b/src/infra/openclaw-root.test.ts index efdad7c43..bcf6bc9b6 100644 --- a/src/infra/openclaw-root.test.ts +++ b/src/infra/openclaw-root.test.ts @@ -10,6 +10,7 @@ const FIXTURE_BASE = path.join(VITEST_FS_BASE, "openclaw-root"); const state = vi.hoisted(() => ({ entries: new Map(), realpaths: new Map(), + realpathErrors: new Set(), })); const abs = (p: string) => path.resolve(p); @@ -56,7 +57,15 @@ vi.mock("node:fs", async (importOriginal) => { }; }, realpathSync: (p: string) => - isFixturePath(p) ? (state.realpaths.get(abs(p)) ?? abs(p)) : actual.realpathSync(p), + isFixturePath(p) + ? (() => { + const resolved = abs(p); + if (state.realpathErrors.has(resolved)) { + throw new Error(`ENOENT: no such file or directory, realpath '${p}'`); + } + return state.realpaths.get(resolved) ?? resolved; + })() + : actual.realpathSync(p), }; return { ...wrapped, default: wrapped }; }); @@ -84,6 +93,7 @@ describe("resolveOpenClawPackageRoot", () => { beforeEach(() => { state.entries.clear(); state.realpaths.clear(); + state.realpathErrors.clear(); }); it("resolves package root from .bin argv1", async () => { @@ -109,6 +119,18 @@ describe("resolveOpenClawPackageRoot", () => { expect(resolveOpenClawPackageRootSync({ argv1: bin })).toBe(realPkg); }); + it("falls back when argv1 realpath throws", async () => { + const { resolveOpenClawPackageRootSync } = await import("./openclaw-root.js"); + + const project = fx("realpath-throw-scenario"); + const argv1 = path.join(project, "node_modules", ".bin", "openclaw"); + const pkgRoot = path.join(project, "node_modules", "openclaw"); + state.realpathErrors.add(abs(argv1)); + setFile(path.join(pkgRoot, "package.json"), JSON.stringify({ name: "openclaw" })); + + expect(resolveOpenClawPackageRootSync({ argv1 })).toBe(pkgRoot); + }); + it("prefers moduleUrl candidates", async () => { const { resolveOpenClawPackageRootSync } = await import("./openclaw-root.js"); @@ -136,4 +158,10 @@ describe("resolveOpenClawPackageRoot", () => { await expect(resolveOpenClawPackageRoot({ cwd: pkgRoot })).resolves.toBe(pkgRoot); }); + + it("async resolver returns null when no package roots exist", async () => { + const { resolveOpenClawPackageRoot } = await import("./openclaw-root.js"); + + await expect(resolveOpenClawPackageRoot({ cwd: fx("missing") })).resolves.toBeNull(); + }); }); diff --git a/src/infra/openclaw-root.ts b/src/infra/openclaw-root.ts index 257b547f1..5d48c6cb0 100644 --- a/src/infra/openclaw-root.ts +++ b/src/infra/openclaw-root.ts @@ -26,35 +26,35 @@ function readPackageNameSync(dir: string): string | null { } async function findPackageRoot(startDir: string, maxDepth = 12): Promise { - let current = path.resolve(startDir); - for (let i = 0; i < maxDepth; i += 1) { + for (const current of iterAncestorDirs(startDir, maxDepth)) { const name = await readPackageName(current); if (name && CORE_PACKAGE_NAMES.has(name)) { return current; } - const parent = path.dirname(current); - if (parent === current) { - break; - } - current = parent; } return null; } function findPackageRootSync(startDir: string, maxDepth = 12): string | null { - let current = path.resolve(startDir); - for (let i = 0; i < maxDepth; i += 1) { + for (const current of iterAncestorDirs(startDir, maxDepth)) { const name = readPackageNameSync(current); if (name && CORE_PACKAGE_NAMES.has(name)) { return current; } + } + return null; +} + +function* iterAncestorDirs(startDir: string, maxDepth: number): Generator { + let current = path.resolve(startDir); + for (let i = 0; i < maxDepth; i += 1) { + yield current; const parent = path.dirname(current); if (parent === current) { break; } current = parent; } - return null; } function candidateDirsFromArgv1(argv1: string): string[] {