diff --git a/src/daemon/runtime-paths.test.ts b/src/daemon/runtime-paths.test.ts index cd76d2da016..c3c71e4713c 100644 --- a/src/daemon/runtime-paths.test.ts +++ b/src/daemon/runtime-paths.test.ts @@ -12,6 +12,7 @@ vi.mock("node:fs/promises", () => ({ import { renderSystemNodeWarning, resolvePreferredNodePath, + resolveStableNodePath, resolveSystemNodeInfo, } from "./runtime-paths.js"; @@ -19,9 +20,9 @@ afterEach(() => { vi.resetAllMocks(); }); -function mockNodePathPresent(nodePath: string) { +function mockNodePathPresent(...nodePaths: string[]) { fsMocks.access.mockImplementation(async (target: string) => { - if (target === nodePath) { + if (nodePaths.includes(target)) { return; } throw new Error("missing"); @@ -142,6 +143,61 @@ describe("resolvePreferredNodePath", () => { }); }); +describe("resolveStableNodePath", () => { + it("resolves Homebrew Cellar path to stable symlink", async () => { + mockNodePathPresent("/opt/homebrew/bin/node"); + + const result = await resolveStableNodePath("/opt/homebrew/Cellar/node/25.7.0/bin/node"); + expect(result).toBe("/opt/homebrew/bin/node"); + }); + + it("resolves Intel Mac Cellar path to stable symlink", async () => { + mockNodePathPresent("/usr/local/bin/node"); + + const result = await resolveStableNodePath("/usr/local/Cellar/node/25.7.0/bin/node"); + expect(result).toBe("/usr/local/bin/node"); + }); + + it("returns original path when symlink does not exist", async () => { + fsMocks.access.mockRejectedValue(new Error("missing")); + + const cellarPath = "/opt/homebrew/Cellar/node/25.7.0/bin/node"; + const result = await resolveStableNodePath(cellarPath); + expect(result).toBe(cellarPath); + }); + + it("returns non-Cellar paths unchanged", async () => { + const fnmPath = "/Users/test/.fnm/node-versions/v24.11.1/installation/bin/node"; + const result = await resolveStableNodePath(fnmPath); + expect(result).toBe(fnmPath); + }); + + it("returns system paths unchanged", async () => { + const result = await resolveStableNodePath("/opt/homebrew/bin/node"); + expect(result).toBe("/opt/homebrew/bin/node"); + }); +}); + +describe("resolvePreferredNodePath — Homebrew Cellar", () => { + it("resolves Cellar execPath to stable Homebrew symlink", async () => { + const cellarNode = "/opt/homebrew/Cellar/node/25.7.0/bin/node"; + const stableNode = "/opt/homebrew/bin/node"; + mockNodePathPresent(stableNode); + + const execFile = vi.fn().mockResolvedValue({ stdout: "25.7.0\n", stderr: "" }); + + const result = await resolvePreferredNodePath({ + env: {}, + runtime: "node", + platform: "darwin", + execFile, + execPath: cellarNode, + }); + + expect(result).toBe(stableNode); + }); +}); + describe("resolveSystemNodeInfo", () => { const darwinNode = "/opt/homebrew/bin/node"; diff --git a/src/daemon/runtime-paths.ts b/src/daemon/runtime-paths.ts index 5730c24efae..780bd721b30 100644 --- a/src/daemon/runtime-paths.ts +++ b/src/daemon/runtime-paths.ts @@ -153,6 +153,26 @@ export function renderSystemNodeWarning( return `System Node ${versionLabel} at ${systemNode.path} is below the required Node 22+.${selectedLabel} Install Node 22+ from nodejs.org or Homebrew.`; } +/** + * Homebrew Cellar paths (e.g. /opt/homebrew/Cellar/node/25.7.0/bin/node) + * break when Homebrew upgrades Node and removes the old version directory. + * Resolve these to the stable Homebrew symlink path (/opt/homebrew/bin/node) + * which Homebrew updates automatically during upgrades. + */ +export async function resolveStableNodePath(nodePath: string): Promise { + const cellarMatch = nodePath.match(/^(.+?)\/Cellar\/[^/]+\/[^/]+\/bin\/node$/); + if (!cellarMatch) { + return nodePath; + } + const stablePath = `${cellarMatch[1]}/bin/node`; + try { + await fs.access(stablePath); + return stablePath; + } catch { + return nodePath; + } +} + export async function resolvePreferredNodePath(params: { env?: Record; runtime?: string; @@ -172,7 +192,7 @@ export async function resolvePreferredNodePath(params: { const execFileImpl = params.execFile ?? execFileAsync; const version = await resolveNodeVersion(currentExecPath, execFileImpl); if (isSupportedNodeVersion(version)) { - return currentExecPath; + return resolveStableNodePath(currentExecPath); } }