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>
This commit is contained in:
Tom Boucher
2026-06-10 11:16:13 -04:00
committed by GitHub
parent 972a41a528
commit 36b68ac81d
4 changed files with 233 additions and 3 deletions

View File

@@ -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/<id>/node.exe`, a per-shell-session path fnm later deletes, breaking every managed hook until reinstall. (#977)

View File

@@ -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/<u>/AppData/Local/fnm_multishells/<pid>_<ts>/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/<ver>/bin/node) and
// nvm-windows (AppData/Roaming/nvm/<ver>/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/<version>/bin/node
// or /usr/local/Cellar/node@20/<version>/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, '/'));

View File

@@ -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"
]

View File

@@ -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/<pid>_<ts>/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/<id>/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 });
}
});
});