From f09e7ed08c41ab88b51fa5861594dfef0e7d4070 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Sun, 6 Sep 2026 16:04:21 -0400 Subject: [PATCH] fix(#4137): existsSync-guard the Homebrew Cellar rewrite in normalizeNodePath (#4375) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * test(#4137): keg-only Homebrew Cellar path falls back to raw execPath Regression tests for the Homebrew branch of normalizeNodePath: the rewrite to /bin/node must be existsSync-guarded like the mise/volta branches, falling through to the raw execPath when the keg-only formula was never linked into /bin. Also makes the existing #3181/#2185 Cellar assertions hermetic by injecting existsSync stubs (granting existence to exactly the one candidate each asserts) so they no longer depend on the runner machine's real /usr/local/bin/node. * fix(#4137): existsSync-guard the Homebrew Cellar rewrite in normalizeNodePath The Homebrew branch of normalizeNodePath returned /bin/node unconditionally — the only one of five runtime branches that never probed its rewrite candidate. On a keg-only or versioned Homebrew install (node@24 never brew-linked) that path does not exist, so every managed hook command baked by resolveNodeRunner/buildBakedNodeToken/ buildNodeRunnerChainToken failed at invocation with exit 127, /bin/sh: /bin/node: No such file or directory. Guard the rewrite with the already-injected existsSync exactly like the mise and volta branches: when /bin/node exists (linked formula) the rewrite is byte-identical to today; when it does not, fall through to the raw execPath — a working keg path instead of an immediately broken one. Also drops two now-unused constants from the regression tests. * test(#4137): make the #977 non-fnm Cellar assertions hermetic too The Bug #977 folded block's two 'still maps to stable symlink' assertions called normalizeNodePath without an existsSync stub, silently depending on the runner machine's real /usr/local/bin/node (present on the Linux bench image, absent for /opt/homebrew). With the #4137 guard these become environment-dependent; grant each exactly the one candidate it asserts. * chore(#4137): add changeset fragment * chore(#4137): backfill changeset pr number --------- Co-authored-by: sim --- .changeset/rapid-finches-roar.md | 5 ++ src/runtime-hooks-surface.cts | 10 ++- tests/install.test.cjs | 147 +++++++++++++++++++++++++++---- 3 files changed, 146 insertions(+), 16 deletions(-) create mode 100644 .changeset/rapid-finches-roar.md diff --git a/.changeset/rapid-finches-roar.md b/.changeset/rapid-finches-roar.md new file mode 100644 index 000000000..ce6a46393 --- /dev/null +++ b/.changeset/rapid-finches-roar.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 4375 +--- +**Managed hooks no longer break on keg-only Homebrew node** — on a Homebrew Node installed as a versioned, unlinked formula (e.g. node@24), every managed hook failed at invocation with `/bin/sh: /bin/node: No such file or directory`; the Homebrew path rewrite now verifies the stable symlink exists before using it and keeps the working install path otherwise. (#4137) diff --git a/src/runtime-hooks-surface.cts b/src/runtime-hooks-surface.cts index 50d0f6c84..4311ff9b6 100644 --- a/src/runtime-hooks-surface.cts +++ b/src/runtime-hooks-surface.cts @@ -512,11 +512,19 @@ function normalizeNodePath(execPath: string, opts?: NodeNormOpts): string { // survives the upgrade. Derive from the path itself (more reliable // than HOMEBREW_PREFIX env — the path IS the install location) so every layout // is covered by one branch instead of one per known prefix (#2185). + // + // #4137: rewrite only when the symlink exists; otherwise fall through to the + // raw execPath, exactly like the mise and volta branches. A keg-only/versioned + // formula (node@24 installed but never `brew link`ed) has no /bin/node + // at all, so the unconditional rewrite handed every managed hook a path that + // fails at invocation — a rewrite must never turn a working keg path into an + // immediately broken one. const homebrewMatch = normalizedForMatch.match( /^(.+)\/Cellar\/node(@\d+)?\/[^/]+\/bin\/node(\.exe)?$/i, ); if (homebrewMatch) { - return `${homebrewMatch[1]}/bin/node${homebrewMatch[3] || ''}`; + const homebrewStable = `${homebrewMatch[1]}/bin/node${homebrewMatch[3] || ''}`; + if (existsSync(homebrewStable)) return homebrewStable; } // mise pins a concrete node version at /installs/node//bin/node diff --git a/tests/install.test.cjs b/tests/install.test.cjs index 3b356f8ae..4dfa07d04 100644 --- a/tests/install.test.cjs +++ b/tests/install.test.cjs @@ -2420,39 +2420,46 @@ describe('Bug #3181: normalizeNodePath — exported as a function', () => { describe('Bug #3181: normalizeNodePath — Intel Homebrew Cellar paths → /usr/local/bin/node', () => { test('simple versioned Intel Cellar path', () => { - const result = normalizeNodePath('/usr/local/Cellar/node/25.8.1/bin/node'); + const result = normalizeNodePath('/usr/local/Cellar/node/25.8.1/bin/node', + { existsSync: p => p === '/usr/local/bin/node' }); assert.equal(result, '/usr/local/bin/node'); }); test('Intel Cellar path with long semver', () => { - const result = normalizeNodePath('/usr/local/Cellar/node/20.11.0/bin/node'); + const result = normalizeNodePath('/usr/local/Cellar/node/20.11.0/bin/node', + { existsSync: p => p === '/usr/local/bin/node' }); assert.equal(result, '/usr/local/bin/node'); }); test('Intel Cellar path with prerelease version segment', () => { - const result = normalizeNodePath('/usr/local/Cellar/node/22.0.0-rc.1/bin/node'); + const result = normalizeNodePath('/usr/local/Cellar/node/22.0.0-rc.1/bin/node', + { existsSync: p => p === '/usr/local/bin/node' }); assert.equal(result, '/usr/local/bin/node'); }); test('Intel versioned formula Cellar path (node@20) maps to stable symlink', () => { - const result = normalizeNodePath('/usr/local/Cellar/node@20/20.11.0/bin/node'); + const result = normalizeNodePath('/usr/local/Cellar/node@20/20.11.0/bin/node', + { existsSync: p => p === '/usr/local/bin/node' }); assert.equal(result, '/usr/local/bin/node'); }); }); describe('Bug #3181: normalizeNodePath — Apple Silicon Homebrew Cellar paths → /opt/homebrew/bin/node', () => { test('simple versioned Apple Silicon Cellar path', () => { - const result = normalizeNodePath('/opt/homebrew/Cellar/node/25.8.1/bin/node'); + const result = normalizeNodePath('/opt/homebrew/Cellar/node/25.8.1/bin/node', + { existsSync: p => p === '/opt/homebrew/bin/node' }); assert.equal(result, '/opt/homebrew/bin/node'); }); test('Apple Silicon Cellar path with another version', () => { - const result = normalizeNodePath('/opt/homebrew/Cellar/node/18.20.4/bin/node'); + const result = normalizeNodePath('/opt/homebrew/Cellar/node/18.20.4/bin/node', + { existsSync: p => p === '/opt/homebrew/bin/node' }); assert.equal(result, '/opt/homebrew/bin/node'); }); test('Apple Silicon versioned formula Cellar path (node@18) maps to stable symlink', () => { - const result = normalizeNodePath('/opt/homebrew/Cellar/node@18/18.20.4/bin/node'); + const result = normalizeNodePath('/opt/homebrew/Cellar/node@18/18.20.4/bin/node', + { existsSync: p => p === '/opt/homebrew/bin/node' }); assert.equal(result, '/opt/homebrew/bin/node'); }); }); @@ -2461,26 +2468,134 @@ describe('Bug #3181: normalizeNodePath — Apple Silicon Homebrew Cellar paths // from the path itself, so one branch covers every Homebrew layout. describe('Bug #2185: normalizeNodePath — Linuxbrew + custom-prefix Cellar paths → /bin/node', () => { test('Linuxbrew Cellar path maps to the stable linuxbrew symlink', () => { - const result = normalizeNodePath('/home/linuxbrew/.linuxbrew/Cellar/node/26.0.0/bin/node'); + const result = normalizeNodePath('/home/linuxbrew/.linuxbrew/Cellar/node/26.0.0/bin/node', + { existsSync: p => p === '/home/linuxbrew/.linuxbrew/bin/node' }); assert.equal(result, '/home/linuxbrew/.linuxbrew/bin/node'); }); test('Linuxbrew Cellar path after a version bump (26.5.0) maps to stable symlink', () => { - const result = normalizeNodePath('/home/linuxbrew/.linuxbrew/Cellar/node/26.5.0/bin/node'); + const result = normalizeNodePath('/home/linuxbrew/.linuxbrew/Cellar/node/26.5.0/bin/node', + { existsSync: p => p === '/home/linuxbrew/.linuxbrew/bin/node' }); assert.equal(result, '/home/linuxbrew/.linuxbrew/bin/node'); }); test('Linuxbrew versioned formula Cellar path (node@22) maps to stable symlink', () => { - const result = normalizeNodePath('/home/linuxbrew/.linuxbrew/Cellar/node@22/22.11.0/bin/node'); + const result = normalizeNodePath('/home/linuxbrew/.linuxbrew/Cellar/node@22/22.11.0/bin/node', + { existsSync: p => p === '/home/linuxbrew/.linuxbrew/bin/node' }); assert.equal(result, '/home/linuxbrew/.linuxbrew/bin/node'); }); test('custom HOMEBREW_PREFIX Cellar path maps to its stable symlink', () => { - const result = normalizeNodePath('/custom/brew/Cellar/node/25.8.1/bin/node'); + const result = normalizeNodePath('/custom/brew/Cellar/node/25.8.1/bin/node', + { existsSync: p => p === '/custom/brew/bin/node' }); assert.equal(result, '/custom/brew/bin/node'); }); }); +// ─── normalizeNodePath — Homebrew keg-only Cellar path keeps execPath (#4137) ── +// +// Bug #4137: every other runtime branch in normalizeNodePath (fnm shim, fnm +// versioned, mise, volta) probes its rewrite candidate with the injected +// existsSync and falls back to the raw execPath on a miss. The Homebrew branch +// alone returned `/bin/node` unconditionally — a path that does not +// exist when the formula is keg-only/versioned and not `brew link`ed (e.g. +// `node@24` resolving through `/opt/node@24/bin/node`). Every managed +// hook command then failed at invocation with exit 127, +// `/bin/sh: /bin/node: No such file or directory`. +// +// Fix: existsSync-guard the Homebrew rewrite exactly as mise and volta do; on a +// miss fall through to the unmodified execPath. A rewrite must never convert a +// working path into a broken one. +// +// Hermetic: every case injects existsSync and grants existence to exactly ONE +// candidate — the one its layout would really have (the fnm #3704 stub rule). +describe('Bug #4137: normalizeNodePath — keg-only Homebrew Cellar path falls back to raw execPath', () => { + const ARM_KEG = '/opt/homebrew/Cellar/node@24/24.11.0/bin/node'; + const INTEL_KEG = '/usr/local/Cellar/node@20/20.11.0/bin/node'; + const LINUXBREW_KEG = '/home/linuxbrew/.linuxbrew/Cellar/node@22/22.11.0/bin/node'; + const CUSTOM_KEG = '/custom/brew/Cellar/node@18/18.20.4/bin/node'; + + test('keg-only versioned Cellar path (the reported bug) → raw execPath unchanged', () => { + // Apple Silicon, node@24 keg-only: /bin/node is absent. + assert.equal(normalizeNodePath(ARM_KEG, { existsSync: () => false }), ARM_KEG); + }); + + test('Intel versioned keg-only Cellar path → raw execPath unchanged', () => { + assert.equal(normalizeNodePath(INTEL_KEG, { existsSync: () => false }), INTEL_KEG); + }); + + test('Linuxbrew versioned keg-only Cellar path → raw execPath unchanged', () => { + assert.equal(normalizeNodePath(LINUXBREW_KEG, { existsSync: () => false }), LINUXBREW_KEG); + }); + + test('unversioned keg-only Cellar path (brew unlink node) → raw execPath unchanged', () => { + const keg = '/opt/homebrew/Cellar/node/24.11.0/bin/node'; + assert.equal(normalizeNodePath(keg, { existsSync: () => false }), keg); + }); + + test('custom HOMEBREW_PREFIX keg-only Cellar path → raw execPath unchanged', () => { + assert.equal(normalizeNodePath(CUSTOM_KEG, { existsSync: () => false }), CUSTOM_KEG); + }); + + test('Windows-spelled keg-only Cellar path → raw execPath unchanged, .exe intact', () => { + const keg = 'C:/Program Files/brew/Cellar/node@24/24.11.0/bin/node.exe'; + assert.equal(normalizeNodePath(keg, { existsSync: () => false }), keg); + }); + + test('guard miss must not leak into the mise/volta branches', () => { + // The decoy shim belongs to a sibling branch's candidate space; a Cellar + // path can never claim it, and the fallback must be the raw execPath. + const decoyShim = '/opt/homebrew/shims/node'; + assert.equal( + normalizeNodePath(ARM_KEG, { existsSync: p => p === decoyShim }), + ARM_KEG); + }); +}); + +// Negative space: the guard must be a no-op when /bin/node DOES exist +// (linked formula) — the #2185/#3181 rewrite survives exactly as before. +describe('Bug #4137: normalizeNodePath — linked Homebrew Cellar path still rewrites (guard is a no-op)', () => { + test('linked versioned formula keg + stable symlink present → stable symlink', () => { + assert.equal( + normalizeNodePath('/opt/homebrew/Cellar/node@24/24.11.0/bin/node', + { existsSync: p => p === '/opt/homebrew/bin/node' }), + '/opt/homebrew/bin/node'); + }); + + test('linked unversioned formula keg + stable symlink present → stable symlink', () => { + assert.equal( + normalizeNodePath('/usr/local/Cellar/node/25.8.1/bin/node', + { existsSync: p => p === '/usr/local/bin/node' }), + '/usr/local/bin/node'); + }); + + test('Linuxbrew linked keg + stable symlink present → stable symlink', () => { + assert.equal( + normalizeNodePath('/home/linuxbrew/.linuxbrew/Cellar/node@22/22.11.0/bin/node', + { existsSync: p => p === '/home/linuxbrew/.linuxbrew/bin/node' }), + '/home/linuxbrew/.linuxbrew/bin/node'); + }); +}); + +// The seam callers bake into hook commands — the reported breakage surface. +describe('Bug #4137: resolveNodeRunner — keg-only Cellar execPath stays raw when symlink absent', () => { + test('resolveNodeRunner keeps the keg path (quoted token) instead of the broken symlink', () => { + const keg = '/opt/homebrew/Cellar/node@24/24.11.0/bin/node'; + assert.equal( + resolveNodeRunner({ execPath: keg, existsSync: () => false }), + JSON.stringify(keg)); + }); + + test('resolveNodeRunner still maps a linked Cellar execPath to the stable symlink', () => { + assert.equal( + resolveNodeRunner({ + execPath: '/opt/homebrew/Cellar/node@24/24.11.0/bin/node', + existsSync: p => p === '/opt/homebrew/bin/node', + }), + '"/opt/homebrew/bin/node"'); + }); +}); + describe('Bug #3181: normalizeNodePath — non-Homebrew paths are returned unchanged', () => { test('NVM path is unchanged', () => { const nvm = '/Users/dev/.nvm/versions/node/v20.11.0/bin/node'; @@ -2523,7 +2638,7 @@ describe('Bug #3181: resolveNodeRunner — maps Cellar execPath to stable symlin value: '/usr/local/Cellar/node/25.8.1/bin/node', configurable: true, }); - const runner = resolveNodeRunner(); + const runner = resolveNodeRunner({ existsSync: p => p === '/usr/local/bin/node' }); assert.equal(runner, '"/usr/local/bin/node"', `expected stable Intel symlink, got: ${runner}`); } finally { @@ -2538,7 +2653,7 @@ describe('Bug #3181: resolveNodeRunner — maps Cellar execPath to stable symlin value: '/opt/homebrew/Cellar/node/25.8.1/bin/node', configurable: true, }); - const runner = resolveNodeRunner(); + const runner = resolveNodeRunner({ existsSync: p => p === '/opt/homebrew/bin/node' }); assert.equal(runner, '"/opt/homebrew/bin/node"', `expected stable Apple Silicon symlink, got: ${runner}`); } finally { @@ -2788,14 +2903,16 @@ describe('Bug #977: normalizeNodePath — non-fnm paths are unaffected (no regre test('Intel Homebrew Cellar path still maps to stable symlink', () => { assert.equal( - normalizeNodePath('/usr/local/Cellar/node/25.8.1/bin/node'), + normalizeNodePath('/usr/local/Cellar/node/25.8.1/bin/node', + { existsSync: p => p === '/usr/local/bin/node' }), '/usr/local/bin/node', ); }); test('Apple Silicon Homebrew Cellar path still maps to stable symlink', () => { assert.equal( - normalizeNodePath('/opt/homebrew/Cellar/node/25.8.1/bin/node'), + normalizeNodePath('/opt/homebrew/Cellar/node/25.8.1/bin/node', + { existsSync: p => p === '/opt/homebrew/bin/node' }), '/opt/homebrew/bin/node', ); });