diff --git a/src/daemon/runtime-paths.test.ts b/src/daemon/runtime-paths.test.ts index 432bb55a6..95849ca2f 100644 --- a/src/daemon/runtime-paths.test.ts +++ b/src/daemon/runtime-paths.test.ts @@ -21,6 +21,53 @@ afterEach(() => { describe("resolvePreferredNodePath", () => { const darwinNode = "/opt/homebrew/bin/node"; + const fnmNode = "/Users/test/.fnm/node-versions/v24.11.1/installation/bin/node"; + + it("prefers execPath (version manager node) over system node", async () => { + fsMocks.access.mockImplementation(async (target: string) => { + if (target === darwinNode) { + return; + } + throw new Error("missing"); + }); + + const execFile = vi.fn().mockResolvedValue({ stdout: "24.11.1\n", stderr: "" }); + + const result = await resolvePreferredNodePath({ + env: {}, + runtime: "node", + platform: "darwin", + execFile, + execPath: fnmNode, + }); + + expect(result).toBe(fnmNode); + expect(execFile).toHaveBeenCalledTimes(1); + }); + + it("falls back to system node when execPath version is unsupported", async () => { + fsMocks.access.mockImplementation(async (target: string) => { + if (target === darwinNode) { + return; + } + throw new Error("missing"); + }); + + const execFile = vi.fn() + .mockResolvedValueOnce({ stdout: "18.0.0\n", stderr: "" }) // execPath too old + .mockResolvedValueOnce({ stdout: "22.12.0\n", stderr: "" }); // system node ok + + const result = await resolvePreferredNodePath({ + env: {}, + runtime: "node", + platform: "darwin", + execFile, + execPath: "/some/old/node", + }); + + expect(result).toBe(darwinNode); + expect(execFile).toHaveBeenCalledTimes(2); + }); it("uses system node when it meets the minimum version", async () => { fsMocks.access.mockImplementation(async (target: string) => { @@ -38,6 +85,7 @@ describe("resolvePreferredNodePath", () => { runtime: "node", platform: "darwin", execFile, + execPath: darwinNode, }); expect(result).toBe(darwinNode); @@ -60,6 +108,7 @@ describe("resolvePreferredNodePath", () => { runtime: "node", platform: "darwin", execFile, + execPath: "", }); expect(result).toBeUndefined(); @@ -69,17 +118,17 @@ describe("resolvePreferredNodePath", () => { it("returns undefined when no system node is found", async () => { fsMocks.access.mockRejectedValue(new Error("missing")); - const execFile = vi.fn(); + const execFile = vi.fn().mockRejectedValue(new Error("not found")); const result = await resolvePreferredNodePath({ env: {}, runtime: "node", platform: "darwin", execFile, + execPath: "", }); expect(result).toBeUndefined(); - expect(execFile).not.toHaveBeenCalled(); }); }); diff --git a/src/daemon/runtime-paths.ts b/src/daemon/runtime-paths.ts index c2c74b82f..eb00841cc 100644 --- a/src/daemon/runtime-paths.ts +++ b/src/daemon/runtime-paths.ts @@ -152,10 +152,24 @@ export async function resolvePreferredNodePath(params: { runtime?: string; platform?: NodeJS.Platform; execFile?: ExecFileAsync; + execPath?: string; }): Promise { if (params.runtime !== "node") { return undefined; } + + // Prefer the node that is currently running `openclaw gateway install`. + // This respects the user's active version manager (fnm/nvm/volta/etc.). + const currentExecPath = params.execPath ?? process.execPath; + if (currentExecPath) { + const execFileImpl = params.execFile ?? execFileAsync; + const version = await resolveNodeVersion(currentExecPath, execFileImpl); + if (isSupportedNodeVersion(version)) { + return currentExecPath; + } + } + + // Fall back to system node. const systemNode = await resolveSystemNodeInfo(params); if (!systemNode?.supported) { return undefined; diff --git a/src/daemon/service-env.test.ts b/src/daemon/service-env.test.ts index 6a3b6a939..591096096 100644 --- a/src/daemon/service-env.test.ts +++ b/src/daemon/service-env.test.ts @@ -96,21 +96,66 @@ describe("getMinimalServicePathParts - Linux user directories", () => { expect(result).toContain("/opt/fnm/current/bin"); }); - it("does not include Linux user directories on macOS", () => { + it("includes version manager directories on macOS when HOME is set", () => { const result = getMinimalServicePathParts({ platform: "darwin", home: "/Users/testuser", }); - // Should not include Linux-specific user dirs even with HOME set - expect(result.some((p) => p.includes(".npm-global"))).toBe(false); - expect(result.some((p) => p.includes(".nvm"))).toBe(false); + // Should include common user bin directories + expect(result).toContain("/Users/testuser/.local/bin"); + expect(result).toContain("/Users/testuser/.npm-global/bin"); + expect(result).toContain("/Users/testuser/bin"); - // Should only include macOS system directories + // Should include version manager paths (macOS specific) + // Note: nvm has no stable default path, relies on user's shell config + expect(result).toContain("/Users/testuser/Library/Application Support/fnm/aliases/default/bin"); // fnm default on macOS + expect(result).toContain("/Users/testuser/.fnm/aliases/default/bin"); // fnm if customized to ~/.fnm + expect(result).toContain("/Users/testuser/.volta/bin"); + expect(result).toContain("/Users/testuser/.asdf/shims"); + expect(result).toContain("/Users/testuser/Library/pnpm"); // pnpm default on macOS + expect(result).toContain("/Users/testuser/.local/share/pnpm"); // pnpm XDG fallback + expect(result).toContain("/Users/testuser/.bun/bin"); + + // Should also include macOS system directories expect(result).toContain("/opt/homebrew/bin"); expect(result).toContain("/usr/local/bin"); }); + it("includes env-configured version manager dirs on macOS", () => { + const result = getMinimalServicePathPartsFromEnv({ + platform: "darwin", + env: { + HOME: "/Users/testuser", + FNM_DIR: "/Users/testuser/Library/Application Support/fnm", + NVM_DIR: "/Users/testuser/.nvm", + PNPM_HOME: "/Users/testuser/Library/pnpm", + }, + }); + + // fnm uses aliases/default/bin (not current) + expect(result).toContain("/Users/testuser/Library/Application Support/fnm/aliases/default/bin"); + // nvm: relies on NVM_DIR env var (no stable default path) + expect(result).toContain("/Users/testuser/.nvm"); + // pnpm: binary is directly in PNPM_HOME + expect(result).toContain("/Users/testuser/Library/pnpm"); + }); + + it("places version manager dirs before system dirs on macOS", () => { + const result = getMinimalServicePathParts({ + platform: "darwin", + home: "/Users/testuser", + }); + + // fnm on macOS defaults to ~/Library/Application Support/fnm + const fnmIndex = result.indexOf("/Users/testuser/Library/Application Support/fnm/aliases/default/bin"); + const homebrewIndex = result.indexOf("/opt/homebrew/bin"); + + expect(fnmIndex).toBeGreaterThan(-1); + expect(homebrewIndex).toBeGreaterThan(-1); + expect(fnmIndex).toBeLessThan(homebrewIndex); + }); + it("does not include Linux user directories on Windows", () => { const result = getMinimalServicePathParts({ platform: "win32", diff --git a/src/daemon/service-env.ts b/src/daemon/service-env.ts index d340b599c..e16e1d702 100644 --- a/src/daemon/service-env.ts +++ b/src/daemon/service-env.ts @@ -34,6 +34,71 @@ function resolveSystemPathDirs(platform: NodeJS.Platform): string[] { return []; } +/** + * Resolve common user bin directories for macOS. + * These are paths where npm global installs and node version managers typically place binaries. + * + * Key differences from Linux: + * - fnm: macOS uses ~/Library/Application Support/fnm (not ~/.local/share/fnm) + * - pnpm: macOS uses ~/Library/pnpm (not ~/.local/share/pnpm) + */ +export function resolveDarwinUserBinDirs( + home: string | undefined, + env?: Record, +): string[] { + if (!home) { + return []; + } + + const dirs: string[] = []; + + const add = (dir: string | undefined) => { + if (dir) { + dirs.push(dir); + } + }; + const appendSubdir = (base: string | undefined, subdir: string) => { + if (!base) { + return undefined; + } + return base.endsWith(`/${subdir}`) ? base : path.posix.join(base, subdir); + }; + + // Env-configured bin roots (override defaults when present). + // Note: FNM_DIR on macOS defaults to ~/Library/Application Support/fnm + // Note: PNPM_HOME on macOS defaults to ~/Library/pnpm + add(env?.PNPM_HOME); + add(appendSubdir(env?.NPM_CONFIG_PREFIX, "bin")); + add(appendSubdir(env?.BUN_INSTALL, "bin")); + add(appendSubdir(env?.VOLTA_HOME, "bin")); + add(appendSubdir(env?.ASDF_DATA_DIR, "shims")); + // nvm: no stable default path, relies on env or user's shell config + // User must set NVM_DIR and source nvm.sh for it to work + add(env?.NVM_DIR); + // fnm: use aliases/default (not current) + add(appendSubdir(env?.FNM_DIR, "aliases/default/bin")); + // pnpm: binary is directly in PNPM_HOME (not in bin subdirectory) + + // Common user bin directories + dirs.push(`${home}/.local/bin`); // XDG standard, pip, etc. + dirs.push(`${home}/.npm-global/bin`); // npm custom prefix + dirs.push(`${home}/bin`); // User's personal bin + + // Node version managers - macOS specific paths + // nvm: no stable default path, depends on user's shell configuration + // fnm: macOS default is ~/Library/Application Support/fnm, not ~/.fnm + dirs.push(`${home}/Library/Application Support/fnm/aliases/default/bin`); // fnm default + dirs.push(`${home}/.fnm/aliases/default/bin`); // fnm if customized to ~/.fnm + dirs.push(`${home}/.volta/bin`); // Volta (same on all platforms) + dirs.push(`${home}/.asdf/shims`); // asdf (same on all platforms) + // pnpm: macOS default is ~/Library/pnpm, not ~/.local/share/pnpm + dirs.push(`${home}/Library/pnpm`); // pnpm default + dirs.push(`${home}/.local/share/pnpm`); // pnpm XDG fallback + dirs.push(`${home}/.bun/bin`); // Bun (same on all platforms) + + return dirs; +} + /** * Resolve common user bin directories for Linux. * These are paths where npm global installs and node version managers typically place binaries. @@ -95,9 +160,13 @@ export function getMinimalServicePathParts(options: MinimalServicePathOptions = const extraDirs = options.extraDirs ?? []; const systemDirs = resolveSystemPathDirs(platform); - // Add Linux user bin directories (npm global, nvm, fnm, volta, etc.) - const linuxUserDirs = - platform === "linux" ? resolveLinuxUserBinDirs(options.home, options.env) : []; + // Add user bin directories for version managers (npm global, nvm, fnm, volta, etc.) + const userDirs = + platform === "linux" + ? resolveLinuxUserBinDirs(options.home, options.env) + : platform === "darwin" + ? resolveDarwinUserBinDirs(options.home, options.env) + : []; const add = (dir: string) => { if (!dir) { @@ -112,7 +181,7 @@ export function getMinimalServicePathParts(options: MinimalServicePathOptions = add(dir); } // User dirs first so user-installed binaries take precedence - for (const dir of linuxUserDirs) { + for (const dir of userDirs) { add(dir); } for (const dir of systemDirs) {