diff --git a/.changeset/tidy-finches-dance.md b/.changeset/tidy-finches-dance.md new file mode 100644 index 000000000..07cbb101b --- /dev/null +++ b/.changeset/tidy-finches-dance.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 3856 +--- +**Managed hooks no longer bake a prunable fnm version path on macOS and Linux** — `normalizeNodePath` matched only fnm's shim, but Node resolves `process.execPath` through that symlink to the concrete `node-versions//installation/bin/node` directory, so the branch never fired on POSIX and every managed hook was pinned to one Node version. `fnm uninstall` or fnm's own pruning then broke all of them. The versioned path now rewrites to the stable `aliases/default` path, matching how the Homebrew, mise and volta branches already behave. (#3704) diff --git a/src/runtime-hooks-surface.cts b/src/runtime-hooks-surface.cts index 44a345c08..3287e18c9 100644 --- a/src/runtime-hooks-surface.cts +++ b/src/runtime-hooks-surface.cts @@ -390,20 +390,41 @@ interface NodeNormOpts { execPath?: string; } +/** + * Normalize a directory that will be joined with `/…` — posix separators, no + * trailing slash. `FNM_DIR=/custom/fnm/` would otherwise bake + * `/custom/fnm//aliases/default/bin/node` (#3704 review). Cosmetic — `existsSync` + * resolves the doubled separator and every shell collapses it — but the value is + * written into a user's settings.json and read by humans. + * + * Shared by both fnm branches deliberately: they build the same alias paths from + * different roots, and a trim applied to only one is a difference with no reason + * behind it. + */ +function normalizeRootDir(dir: string): string { + return shellCmdProjection.posixNormalize(dir).replace(/\/+$/, ''); +} + function normalizeNodePath(execPath: string, opts?: NodeNormOpts): string { if (!execPath) return execPath; const env = (opts && opts.env) || process.env; const existsSync = (opts && opts.existsSync) || fs.existsSync; const normalizedForMatch = shellCmdProjection.posixNormalize(execPath); - if (/\/fnm_multishells\/[0-9]+_[0-9]+\/node(\.exe)?$/i.test(normalizedForMatch)) { + // #977: fnm's Windows shim IS `process.execPath` there — Windows does not + // realpath through it — so this branch stays. #3704 adds `(?:bin\/)?`: fnm's + // POSIX shim is `/bin/node`, so the original pattern could not match + // it even when a caller hands one in explicitly (#3662's `execPath` option + // exists to do exactly that, normalizing a path from another environment). + if (/\/fnm_multishells\/[0-9]+_[0-9]+\/(?:bin\/)?node(\.exe)?$/i.test(normalizedForMatch)) { const candidates: string[] = []; if (env.FNM_DIR) { - candidates.push(`${env.FNM_DIR}/aliases/default/node.exe`); - candidates.push(`${env.FNM_DIR}/aliases/default/bin/node`); + const fnmRoot = normalizeRootDir(env.FNM_DIR); + candidates.push(`${fnmRoot}/aliases/default/node.exe`); + candidates.push(`${fnmRoot}/aliases/default/bin/node`); } if (env.APPDATA) { - candidates.push(`${env.APPDATA}/fnm/aliases/default/node.exe`); + candidates.push(`${normalizeRootDir(env.APPDATA)}/fnm/aliases/default/node.exe`); } for (const candidate of candidates) { if (candidate && existsSync(candidate)) return candidate; @@ -411,6 +432,42 @@ function normalizeNodePath(execPath: string, opts?: NodeNormOpts): string { return execPath; } + // #3704: fnm pins a concrete version at + // /node-versions//installation/bin/node (Windows: + // .../installation/node.exe). On macOS/Linux this — not the shim above — is what + // `process.execPath` reports, because Node realpaths through + // `fnm_multishells`. So the shim branch above is unreachable on POSIX and the + // raw versioned path was baked into every managed hook: `fnm uninstall `, + // or fnm's own pruning, then 404s every hook — the ephemeral-path failure #977 + // exists to prevent, and the same one #1619 (mise) and #2185 (Homebrew) fixed + // for their managers by matching the VERSIONED path rather than a shim. + // + // The stable alias is /aliases/default/..., which fnm repoints on + // `fnm default`. Derive from execPath first (#2185's rule — the path + // IS the install location, and FNM_DIR may be unset or point at a different + // install), keeping the env as a secondary candidate. Rewrite only when the + // alias exists; otherwise fall through to the raw execPath, exactly like the + // mise and volta branches, so a rewrite never turns a stale-but-working pin + // into an immediately broken one. + const fnmVersioned = normalizedForMatch.match( + /^(.*)\/node-versions\/[^/]+\/installation\/(?:bin\/)?node(\.exe)?$/i, + ); + if (fnmVersioned) { + const isExe = Boolean(fnmVersioned[2]); + const roots = [fnmVersioned[1]]; + if (env.FNM_DIR) roots.push(normalizeRootDir(env.FNM_DIR)); + const fnmAliasCandidates: string[] = []; + for (const root of roots) { + // Probe the spelling matching the input first, then the other — a layout + // is one or the other, and guessing wrong would skip a real alias. + const leaf = isExe ? ['node.exe', 'bin/node'] : ['bin/node', 'node.exe']; + for (const tail of leaf) fnmAliasCandidates.push(`${root}/aliases/default/${tail}`); + } + for (const candidate of fnmAliasCandidates) { + if (existsSync(candidate)) return candidate; + } + } + // Homebrew (macOS Intel /usr/local, Apple Silicon /opt/homebrew, Linuxbrew // /home/linuxbrew/.linuxbrew, and any custom HOMEBREW_PREFIX) pins node at // /Cellar/node(<@ver>)?//bin/node, then deletes prior versions on diff --git a/tests/install.test.cjs b/tests/install.test.cjs index be48050c8..515b89a69 100644 --- a/tests/install.test.cjs +++ b/tests/install.test.cjs @@ -1925,6 +1925,167 @@ describe('normalizeNodePath — mise versioned path → sibling shim (#1619)', ( }); }); +// ─── normalizeNodePath — fnm versioned path → alias (#3704) ────────────────── +// +// Bug #3704: the POSIX half of #977. fnm's branch matched only the SHIM +// (`/node`), but on macOS/Linux `process.execPath` is never that — +// Node realpaths through `fnm_multishells` to +// `/node-versions//installation/bin/node`. Two independent reasons +// the old pattern missed: the realpath above, and the `bin/` segment (fnm's POSIX +// shim is `/bin/node`, the pattern required `/node`). +// +// Consequence: the raw versioned path was baked into every managed hook, so +// `fnm uninstall ` — or fnm's own pruning — 404s all of them. That is the +// ephemeral-path failure #977 exists to prevent, already fixed for mise (#1619), +// Homebrew (#2185) and volta (#2335) by matching the VERSIONED path. +// +// NOTE ON THE STUBS: each test grants existence to exactly ONE candidate, the one +// its platform would really have. A stub that grants both `aliases/default/node.exe` +// and `aliases/default/bin/node` does not test candidate ORDER, it hides it — no +// real machine has both. +describe('normalizeNodePath — fnm versioned path → alias (#3704)', () => { + const FNM = '/Users/u/.local/share/fnm'; + const FNM_POSIX_PINNED = `${FNM}/node-versions/v22.22.0/installation/bin/node`; + const FNM_POSIX_ALIAS = `${FNM}/aliases/default/bin/node`; + const FNM_POSIX_SHIM = '/Users/u/.local/state/fnm_multishells/55303_1787205798690/bin/node'; + + const FNM_WIN = 'C:/Users/u/AppData/Roaming/fnm'; + const FNM_WIN_PINNED = `${FNM_WIN}/node-versions/v22.22.0/installation/node.exe`; + const FNM_WIN_ALIAS = `${FNM_WIN}/aliases/default/node.exe`; + const FNM_WIN_SHIM = 'C:/Users/u/AppData/Local/fnm_multishells/1234_5678/node.exe'; + + test('POSIX versioned install path + alias exists → alias (the reported bug)', () => { + assert.equal( + normalizeNodePath(FNM_POSIX_PINNED, { env: {}, existsSync: p => p === FNM_POSIX_ALIAS }), + FNM_POSIX_ALIAS); + }); + + test('Windows versioned install path → aliases/default/node.exe (.exe preserved)', () => { + assert.equal( + normalizeNodePath(FNM_WIN_PINNED, { env: {}, existsSync: p => p === FNM_WIN_ALIAS }), + FNM_WIN_ALIAS); + }); + + test('backslash Windows path normalizes the same as forward-slash', () => { + assert.equal( + normalizeNodePath(FNM_WIN_PINNED.replace(/\//g, '\\'), + { env: {}, existsSync: p => p === FNM_WIN_ALIAS }), + FNM_WIN_ALIAS); + }); + + test('POSIX shim carries a bin/ segment and is now matched too', () => { + // Reason 2. This path never arrives as process.execPath on POSIX, but #3662's + // `execPath` option exists so a caller can normalize a path from another + // environment — and it could not match before. + assert.equal( + normalizeNodePath(FNM_POSIX_SHIM, { env: { FNM_DIR: FNM }, existsSync: p => p === FNM_POSIX_ALIAS }), + FNM_POSIX_ALIAS); + }); + + test('no regression (#977): the Windows shim still resolves through FNM_DIR', () => { + // The one fnm shape that already worked, and the one that is genuinely + // process.execPath on Windows — Windows does not realpath the shim away. + assert.equal( + normalizeNodePath(FNM_WIN_SHIM, { env: { FNM_DIR: FNM_WIN }, existsSync: p => p === FNM_WIN_ALIAS }), + FNM_WIN_ALIAS); + }); + + test('FNM_DIR unset → root derived from execPath (#2185 rule)', () => { + assert.equal( + normalizeNodePath(FNM_POSIX_PINNED, { env: {}, existsSync: p => p === FNM_POSIX_ALIAS }), + FNM_POSIX_ALIAS); + }); + + test('FNM_DIR pointing at a different install → the path-derived root wins', () => { + const other = '/opt/other-fnm'; + assert.equal( + normalizeNodePath(FNM_POSIX_PINNED, { + env: { FNM_DIR: other }, + // BOTH aliases exist; the one belonging to the path must be chosen. + existsSync: p => p === FNM_POSIX_ALIAS || p === `${other}/aliases/default/bin/node`, + }), + FNM_POSIX_ALIAS); + }); + + test('no regression: alias absent → falls back to raw execPath unchanged', () => { + // Rewriting to an alias that is not there would turn a stale-but-working pin + // into an immediately broken one — worse than the bug. + assert.equal( + normalizeNodePath(FNM_POSIX_PINNED, { env: {}, existsSync: () => false }), + FNM_POSIX_PINNED); + assert.equal( + normalizeNodePath(FNM_POSIX_SHIM, { env: { FNM_DIR: FNM }, existsSync: () => false }), + FNM_POSIX_SHIM); + }); + + test('a non-fnm path containing node-versions is left unchanged', () => { + for (const foreign of [ + '/opt/custom/node-versions/v1/bin/node', + '/srv/node-versions/v22/installation-notes/bin/node', // `installation` must be a whole segment + ]) { + assert.equal( + normalizeNodePath(foreign, { env: {}, existsSync: () => true }), + foreign, + `${foreign} is not an fnm layout`); + } + }); + + test('sibling managers are untouched by the fnm branch', () => { + const cases = [ + ['/Users/u/.local/share/mise/installs/node/22.1.0/bin/node', '/Users/u/.local/share/mise/shims/node'], + ['/Users/u/.volta/tools/image/node/22.1.0/bin/node', '/Users/u/.volta/bin/node'], + ['/opt/homebrew/Cellar/node/26.7.0/bin/node', '/opt/homebrew/bin/node'], + ]; + for (const [input, expected] of cases) { + assert.equal( + normalizeNodePath(input, { env: { FNM_DIR: FNM }, existsSync: p => p === expected }), + expected, + `${input} must still resolve through its own manager's branch`); + } + }); + + test('the rewrite is always an absolute path, never a bare node (#3022/#3002)', () => { + const out = normalizeNodePath(FNM_POSIX_PINNED, { env: {}, existsSync: () => true }); + assert.ok(out.includes('/'), `expected an absolute path, got ${out}`); + assert.notEqual(out, 'node'); + }); + + test('a trailing slash on FNM_DIR does not bake a doubled separator', () => { + // Review finding (minor). Cosmetic only — existsSync resolves `//` and shells + // collapse it — but the value is written into a user's settings.json. + const alias = `${FNM}/aliases/default/bin/node`; + assert.equal( + normalizeNodePath(FNM_POSIX_SHIM, { + env: { FNM_DIR: `${FNM}/` }, + // Exact match, deliberately: a stub that collapses a doubled separator + // before comparing would pass whether or not the trim happened. + existsSync: p => p === alias, + }), + alias); + }); + + test('a trailing slash is trimmed on every fnm root, not just one branch', () => { + // One shared normalizeRootDir, three call sites — the versioned branch, the + // shim branch's FNM_DIR, and its APPDATA fallback. + const aliasBin = `${FNM}/aliases/default/bin/node`; + assert.equal( + normalizeNodePath('/nowhere/node-versions/v1/installation/bin/node', + { env: { FNM_DIR: `${FNM}//` }, existsSync: p => p === aliasBin }), + aliasBin, + 'versioned branch'); + const winAlias = 'C:/Users/u/AppData/Roaming/fnm/aliases/default/node.exe'; + assert.equal( + normalizeNodePath('C:/x/fnm_multishells/1_2/node.exe', + { env: { APPDATA: 'C:/Users/u/AppData/Roaming/' }, existsSync: p => p === winAlias }), + winAlias, + 'APPDATA fallback'); + }); + + test('empty execPath is returned unchanged without throwing', () => { + assert.equal(normalizeNodePath('', { env: {}, existsSync: () => true }), ''); + }); +}); + // ─── normalizeNodePath — volta versioned image path → stable shim (#2335) ──── // // Bug #2335: the volta analog of #977 (fnm) / #1619 (mise) / #2185 (Homebrew).