* test(#3704): failing-first coverage for fnm versioned-path normalization on POSIX * fix(#3704): rewrite fnm versioned node paths to the stable alias on POSIX * fix(#3704): share one root normalizer across both fnm branches so no baked path carries a doubled separator * chore(#3704): backfill changeset PR number --------- Co-authored-by: sim <sim@local>
This commit is contained in:
5
.changeset/tidy-finches-dance.md
Normal file
5
.changeset/tidy-finches-dance.md
Normal file
@@ -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/<ver>/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)
|
||||
@@ -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 `<multishell>/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
|
||||
// <FNM_DIR>/node-versions/<ver>/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 <ver>`,
|
||||
// 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 <FNM_DIR>/aliases/default/..., which fnm repoints on
|
||||
// `fnm default`. Derive <FNM_DIR> 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
|
||||
// <prefix>/Cellar/node(<@ver>)?/<ver>/bin/node, then deletes prior versions on
|
||||
|
||||
@@ -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
|
||||
// (`<multishell>/node`), but on macOS/Linux `process.execPath` is never that —
|
||||
// Node realpaths through `fnm_multishells` to
|
||||
// `<FNM_DIR>/node-versions/<ver>/installation/bin/node`. Two independent reasons
|
||||
// the old pattern missed: the realpath above, and the `bin/` segment (fnm's POSIX
|
||||
// shim is `<multishell>/bin/node`, the pattern required `<multishell>/node`).
|
||||
//
|
||||
// Consequence: the raw versioned path was baked into every managed hook, so
|
||||
// `fnm uninstall <ver>` — 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).
|
||||
|
||||
Reference in New Issue
Block a user