From 36b68ac81dd7b77c77d2b96df9db8a1f43c726b3 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Wed, 10 Jun 2026 11:16:13 -0400 Subject: [PATCH] fix(#977): map ephemeral fnm multishell execPath to a stable fnm alias in normalizeNodePath (#992) * fix(#977): map ephemeral fnm multishell execPath to a stable fnm alias in normalizeNodePath Closes #977 * chore(#977): backfill changeset pr number (992) --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- .changeset/977-fnm-multishell-node-path.md | 5 + bin/install.js | 40 +++- .../lint-regression-test-names.allowlist.json | 1 + tests/bug-977-fnm-multishell-path.test.cjs | 190 ++++++++++++++++++ 4 files changed, 233 insertions(+), 3 deletions(-) create mode 100644 .changeset/977-fnm-multishell-node-path.md create mode 100644 tests/bug-977-fnm-multishell-path.test.cjs diff --git a/.changeset/977-fnm-multishell-node-path.md b/.changeset/977-fnm-multishell-node-path.md new file mode 100644 index 000000000..e183c250f --- /dev/null +++ b/.changeset/977-fnm-multishell-node-path.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 992 +--- +**The installer now resolves a stable fnm node path instead of the ephemeral multishell shim on Windows** — managed `.js` hooks were pinned to `fnm_multishells//node.exe`, a per-shell-session path fnm later deletes, breaking every managed hook until reinstall. (#977) diff --git a/bin/install.js b/bin/install.js index aaff9d53d..ea212a84f 100755 --- a/bin/install.js +++ b/bin/install.js @@ -637,8 +637,37 @@ function computePathPrefix({ isGlobal, isOpencode, isWindowsHost: _isWindowsHost * * Non-Homebrew installs (NVM, system node, Windows, etc.) are returned as-is. */ -function normalizeNodePath(execPath) { +function normalizeNodePath(execPath, opts) { if (!execPath) return execPath; + const env = (opts && opts.env) || process.env; + const existsSync = (opts && opts.existsSync) || fs.existsSync; + + // fnm multishell shim: C:/Users//AppData/Local/fnm_multishells/_/node.exe + // These are per-shell-session ephemeral directories that fnm cleans up on shell exit. + // Probe the stable fnm alias paths instead so baked hook commands survive shell restarts. + // (#977) + // TODO: Volta (~/.volta/bin/node → ~/.volta/tools/image/node//bin/node) and + // nvm-windows (AppData/Roaming/nvm//node.exe) have analogous issues — future work. + const normalizedForMatch = execPath.replace(/\\/g, '/'); + if (/\/fnm_multishells\/[0-9]+_[0-9]+\/node(\.exe)?$/i.test(normalizedForMatch)) { + const candidates = []; + if (env.FNM_DIR) { + // Preferred: alias installed directly under FNM_DIR/aliases/default/ + candidates.push(`${env.FNM_DIR}/aliases/default/node.exe`); + // POSIX layout (no .exe) + candidates.push(`${env.FNM_DIR}/aliases/default/bin/node`); + } + if (env.APPDATA) { + // Fallback: fnm default location on Windows when FNM_DIR is not set + candidates.push(`${env.APPDATA}/fnm/aliases/default/node.exe`); + } + for (const candidate of candidates) { + if (existsSync(candidate)) return candidate; + } + // No stable alias found — return raw path unchanged (graceful fallback) + return execPath; + } + // Intel Homebrew: /usr/local/Cellar/node//bin/node // or /usr/local/Cellar/node@20//bin/node if (/^\/usr\/local\/Cellar\/node(@\d+)?\/[^/]+\/bin\/node(\.exe)?$/.test(execPath)) { @@ -667,11 +696,16 @@ function normalizeNodePath(execPath) { * * When `process.execPath` is a versioned Homebrew Cellar path, the stable * Homebrew symlink is returned instead to survive `brew upgrade node` (#3181). + * + * When `process.execPath` is an ephemeral fnm multishell shim, the stable fnm + * alias path is returned instead so managed hook commands survive shell restarts + * (#977). An optional `opts` bag (`{ env, existsSync }`) is accepted for + * testability; production callers omit it to get real env / fs. */ -function resolveNodeRunner() { +function resolveNodeRunner(opts) { const execPath = typeof process.execPath === 'string' ? process.execPath : ''; if (!execPath) return null; - const stablePath = normalizeNodePath(execPath); + const stablePath = normalizeNodePath(execPath, opts); // JSON.stringify produces a properly escaped double-quoted shell token, // safe for paths containing spaces or unusual characters. return JSON.stringify(stablePath.replace(/\\/g, '/')); diff --git a/scripts/lint-regression-test-names.allowlist.json b/scripts/lint-regression-test-names.allowlist.json index 941ff4681..53afc839d 100644 --- a/scripts/lint-regression-test-names.allowlist.json +++ b/scripts/lint-regression-test-names.allowlist.json @@ -262,5 +262,6 @@ "bug-950-quick-summary-status-complete.test.cjs", "bug-967-verify-key-links-strict-paths.test.cjs", "bug-974-graphify-budget-missing-value.test.cjs", + "bug-977-fnm-multishell-path.test.cjs", "bug-978-milestone-complete-force.test.cjs" ] diff --git a/tests/bug-977-fnm-multishell-path.test.cjs b/tests/bug-977-fnm-multishell-path.test.cjs new file mode 100644 index 000000000..65ea2331a --- /dev/null +++ b/tests/bug-977-fnm-multishell-path.test.cjs @@ -0,0 +1,190 @@ +'use strict'; + +process.env.GSD_TEST_MODE = '1'; + +/** + * Bug #977: `resolveNodeRunner()` bakes an ephemeral fnm multishell shim path + * (e.g. `C:/Users/u/AppData/Local/fnm_multishells/_/node.exe`) into + * managed `.js` hook commands. fnm cleans up these per-shell-session directories + * when the shell exits, so the captured path later points at nothing — every + * managed hook fails to spawn until reinstall. + * + * Fix: when `normalizeNodePath` detects a path matching the fnm multishell + * directory pattern (`fnm_multishells//node(\.exe)?$`), it probes a stable + * alias path derived from `FNM_DIR` or `APPDATA` env vars (with injected + * `existsSync` for testability) and returns the first that exists. Falls back to + * the raw execPath if no stable alias is found. + * + * All assertions go against exported function return values — no source-grep. + */ + +const { test, describe } = require('node:test'); +const assert = require('node:assert/strict'); +const path = require('node:path'); + +const INSTALL = require(path.join(__dirname, '..', 'bin', 'install.js')); +const { normalizeNodePath, resolveNodeRunner } = INSTALL; + +// ─── Synthetic paths used across tests ─────────────────────────────────────── + +const EPHEMERAL_FNM_WIN = 'C:/Users/u/AppData/Local/fnm_multishells/15600_1781041703752/node.exe'; +const EPHEMERAL_FNM_WIN_BACKSLASH = 'C:\\Users\\u\\AppData\\Local\\fnm_multishells\\15600_1781041703752\\node.exe'; +const FNM_DIR_WIN = 'C:/Users/u/AppData/Roaming/fnm'; +const APPDATA_WIN = 'C:/Users/u/AppData/Roaming'; +const STABLE_FNM_DIR_NODE = `${FNM_DIR_WIN}/aliases/default/node.exe`; +const STABLE_APPDATA_NODE = `${APPDATA_WIN}/fnm/aliases/default/node.exe`; + +// ─── normalizeNodePath — fnm multishell ephemeral path → stable alias ──────── + +describe('Bug #977: normalizeNodePath — fnm multishell path with FNM_DIR → stable alias', () => { + test('forward-slash Windows ephemeral path + FNM_DIR set + alias exists → stable FNM_DIR alias', () => { + const result = normalizeNodePath(EPHEMERAL_FNM_WIN, { + env: { FNM_DIR: FNM_DIR_WIN }, + existsSync: p => p === STABLE_FNM_DIR_NODE, + }); + assert.equal( + result, + STABLE_FNM_DIR_NODE, + `expected stable FNM_DIR alias, got: ${result}`, + ); + }); + + test('backslash Windows ephemeral path + FNM_DIR set + alias exists → stable FNM_DIR alias', () => { + const result = normalizeNodePath(EPHEMERAL_FNM_WIN_BACKSLASH, { + env: { FNM_DIR: FNM_DIR_WIN }, + existsSync: p => p === STABLE_FNM_DIR_NODE, + }); + assert.equal( + result, + STABLE_FNM_DIR_NODE, + `expected stable FNM_DIR alias, got: ${result}`, + ); + }); + + test('FNM_DIR alias does not exist → falls through to APPDATA alias → returns APPDATA alias', () => { + const result = normalizeNodePath(EPHEMERAL_FNM_WIN, { + env: { FNM_DIR: FNM_DIR_WIN, APPDATA: APPDATA_WIN }, + existsSync: p => p === STABLE_APPDATA_NODE, // FNM_DIR alias absent, APPDATA alias present + }); + assert.equal( + result, + STABLE_APPDATA_NODE, + `expected stable APPDATA alias, got: ${result}`, + ); + }); + + test('no alias exists → returns raw execPath unchanged (graceful fallback)', () => { + const result = normalizeNodePath(EPHEMERAL_FNM_WIN, { + env: { FNM_DIR: FNM_DIR_WIN, APPDATA: APPDATA_WIN }, + existsSync: () => false, // nothing exists + }); + assert.equal( + result, + EPHEMERAL_FNM_WIN, + `expected raw execPath fallback, got: ${result}`, + ); + }); + + test('no FNM_DIR or APPDATA in env → returns raw execPath unchanged', () => { + const result = normalizeNodePath(EPHEMERAL_FNM_WIN, { + env: {}, + existsSync: () => false, + }); + assert.equal( + result, + EPHEMERAL_FNM_WIN, + `expected raw execPath fallback, got: ${result}`, + ); + }); +}); + +// ─── normalizeNodePath — non-fnm paths are NOT affected by the new branch ──── + +describe('Bug #977: normalizeNodePath — non-fnm paths are unaffected (no regression to existing behavior)', () => { + test('NVM path is unchanged', () => { + const nvm = '/Users/dev/.nvm/versions/node/v20.11.0/bin/node'; + assert.equal(normalizeNodePath(nvm), nvm); + }); + + test('Intel Homebrew Cellar path still maps to stable symlink', () => { + assert.equal( + normalizeNodePath('/usr/local/Cellar/node/25.8.1/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'), + '/opt/homebrew/bin/node', + ); + }); + + test('regular Windows nodejs path is unchanged', () => { + const win = 'C:\\Program Files\\nodejs\\node.exe'; + assert.equal(normalizeNodePath(win), win); + }); + + test('empty string is returned as-is', () => { + assert.equal(normalizeNodePath(''), ''); + }); + + test('null is returned as-is', () => { + assert.equal(normalizeNodePath(null), null); + }); +}); + +// ─── normalizeNodePath — already-stable fnm alias path is not re-processed ─── + +describe('Bug #977: normalizeNodePath — already-stable fnm alias path passes through unchanged', () => { + test('stable FNM_DIR alias path is returned as-is', () => { + assert.equal( + normalizeNodePath(STABLE_FNM_DIR_NODE), + STABLE_FNM_DIR_NODE, + ); + }); +}); + +// ─── normalizeNodePath — false-positive guard: non-numeric id must NOT remap ── + +describe('Bug #977: normalizeNodePath — non-ephemeral fnm_multishells path is not remapped', () => { + test('non-numeric id segment (e.g. custom-dir) returns raw execPath unchanged even when alias exists', () => { + const nonEphemeral = 'C:/Users/u/AppData/Local/fnm_multishells/custom-dir/node.exe'; + const stableAlias = 'C:/Users/u/AppData/Roaming/fnm/aliases/default/node.exe'; + const result = normalizeNodePath(nonEphemeral, { + env: { FNM_DIR: 'C:/Users/u/AppData/Roaming/fnm' }, + // existsSync returns true for the alias to prove the regex — not the existsSync — is the guard + existsSync: p => p === stableAlias, + }); + assert.equal( + result, + nonEphemeral, + `expected raw execPath (non-ephemeral id must not be remapped), got: ${result}`, + ); + }); +}); + +// ─── resolveNodeRunner — opts pass-through ──────────────────────────────────── + +describe('Bug #977: resolveNodeRunner — passes opts through to normalizeNodePath', () => { + test('fnm multishell execPath is resolved to stable alias via injected opts', () => { + const orig = process.execPath; + try { + Object.defineProperty(process, 'execPath', { + value: EPHEMERAL_FNM_WIN, + configurable: true, + }); + const runner = resolveNodeRunner({ + env: { FNM_DIR: FNM_DIR_WIN }, + existsSync: p => p === STABLE_FNM_DIR_NODE, + }); + assert.equal( + runner, + JSON.stringify(STABLE_FNM_DIR_NODE), + `expected stable FNM_DIR alias quoted, got: ${runner}`, + ); + } finally { + Object.defineProperty(process, 'execPath', { value: orig, configurable: true }); + } + }); +});