From 163f5184b367861785e3bb2520f11b795548d644 Mon Sep 17 00:00:00 2001 From: scoootscooob Date: Mon, 2 Mar 2026 14:13:20 -0800 Subject: [PATCH] fix(daemon): handle versioned node@XX Homebrew formulas in Cellar resolution Address review feedback: versioned Homebrew formulas (node@22, node@20) use keg-only paths where the stable symlink is at /opt//bin/node, not /bin/node. Updated resolveStableNodePath to: 1. Try /opt//bin/node first (works for both default + versioned) 2. Fall back to /bin/node for the default "node" formula 3. Return the original Cellar path if neither stable path exists Co-Authored-By: Claude Opus 4.6 --- src/daemon/runtime-paths.test.ts | 26 ++++++++++++++++++++------ src/daemon/runtime-paths.ts | 32 +++++++++++++++++++++++++------- 2 files changed, 45 insertions(+), 13 deletions(-) diff --git a/src/daemon/runtime-paths.test.ts b/src/daemon/runtime-paths.test.ts index c3c71e4713c..3b502193a33 100644 --- a/src/daemon/runtime-paths.test.ts +++ b/src/daemon/runtime-paths.test.ts @@ -144,21 +144,35 @@ describe("resolvePreferredNodePath", () => { }); describe("resolveStableNodePath", () => { - it("resolves Homebrew Cellar path to stable symlink", async () => { + it("resolves Homebrew Cellar path to opt symlink", async () => { + mockNodePathPresent("/opt/homebrew/opt/node/bin/node"); + + const result = await resolveStableNodePath("/opt/homebrew/Cellar/node/25.7.0/bin/node"); + expect(result).toBe("/opt/homebrew/opt/node/bin/node"); + }); + + it("falls back to bin symlink for default node formula", 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"); + it("resolves Intel Mac Cellar path to opt symlink", async () => { + mockNodePathPresent("/usr/local/opt/node/bin/node"); const result = await resolveStableNodePath("/usr/local/Cellar/node/25.7.0/bin/node"); - expect(result).toBe("/usr/local/bin/node"); + expect(result).toBe("/usr/local/opt/node/bin/node"); }); - it("returns original path when symlink does not exist", async () => { + it("resolves versioned node@22 formula to opt symlink", async () => { + mockNodePathPresent("/opt/homebrew/opt/node@22/bin/node"); + + const result = await resolveStableNodePath("/opt/homebrew/Cellar/node@22/22.12.0/bin/node"); + expect(result).toBe("/opt/homebrew/opt/node@22/bin/node"); + }); + + it("returns original path when no stable symlink exists", async () => { fsMocks.access.mockRejectedValue(new Error("missing")); const cellarPath = "/opt/homebrew/Cellar/node/25.7.0/bin/node"; @@ -181,7 +195,7 @@ describe("resolveStableNodePath", () => { 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"; + const stableNode = "/opt/homebrew/opt/node/bin/node"; mockNodePathPresent(stableNode); const execFile = vi.fn().mockResolvedValue({ stdout: "25.7.0\n", stderr: "" }); diff --git a/src/daemon/runtime-paths.ts b/src/daemon/runtime-paths.ts index 780bd721b30..8cea5792bb4 100644 --- a/src/daemon/runtime-paths.ts +++ b/src/daemon/runtime-paths.ts @@ -156,21 +156,39 @@ export function renderSystemNodeWarning( /** * 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. + * Resolve these to a stable Homebrew-managed path that survives upgrades: + * - Default formula "node": /opt/node/bin/node or /bin/node + * - Versioned formula "node@22": /opt/node@22/bin/node (keg-only) */ export async function resolveStableNodePath(nodePath: string): Promise { - const cellarMatch = nodePath.match(/^(.+?)\/Cellar\/[^/]+\/[^/]+\/bin\/node$/); + const cellarMatch = nodePath.match(/^(.+?)\/Cellar\/([^/]+)\/[^/]+\/bin\/node$/); if (!cellarMatch) { return nodePath; } - const stablePath = `${cellarMatch[1]}/bin/node`; + const prefix = cellarMatch[1]; // e.g. /opt/homebrew + const formula = cellarMatch[2]; // e.g. "node" or "node@22" + + // Try the Homebrew opt symlink first — works for both default and versioned formulas. + const optPath = `${prefix}/opt/${formula}/bin/node`; try { - await fs.access(stablePath); - return stablePath; + await fs.access(optPath); + return optPath; } catch { - return nodePath; + // fall through } + + // For the default "node" formula, also try the direct bin symlink. + if (formula === "node") { + const binPath = `${prefix}/bin/node`; + try { + await fs.access(binPath); + return binPath; + } catch { + // fall through + } + } + + return nodePath; } export async function resolvePreferredNodePath(params: {