fix(#4137): existsSync-guard the Homebrew Cellar rewrite in normalizeNodePath (#4375)

* test(#4137): keg-only Homebrew Cellar path falls back to raw execPath

Regression tests for the Homebrew branch of normalizeNodePath: the rewrite
to <prefix>/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 <prefix>/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 <prefix>/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:
<prefix>/bin/node: No such file or directory.

Guard the rewrite with the already-injected existsSync exactly like the
mise and volta branches: when <prefix>/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 <sim@local>
This commit is contained in:
Tom Boucher
2026-09-06 16:04:21 -04:00
committed by GitHub
parent 2920bbc022
commit f09e7ed08c
3 changed files with 146 additions and 16 deletions

View File

@@ -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: <prefix>/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)

View File

@@ -512,11 +512,19 @@ function normalizeNodePath(execPath: string, opts?: NodeNormOpts): string {
// survives the upgrade. Derive <prefix> 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 <prefix>/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 <data>/installs/node/<ver>/bin/node

View File

@@ -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 → <prefix>/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 `<prefix>/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 `<prefix>/opt/node@24/bin/node`). Every managed
// hook command then failed at invocation with exit 127,
// `/bin/sh: <prefix>/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: <prefix>/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 <prefix>/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',
);
});