From 9d12725e4e3142a133deae56777ec0f5550bcbbf Mon Sep 17 00:00:00 2001 From: Joe Seymour Date: Tue, 23 Jun 2026 16:17:03 -0700 Subject: [PATCH] fix(#1619): normalize pruned mise node execPath to the stable shim in normalizeNodePath (#1621) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(#1619): normalize pruned mise node execPath to the stable shim resolveNodeRunner() bakes process.execPath into managed .js hook commands. Node realpaths execPath, so under mise it resolves to a concrete /installs/node//bin/node that mise prunes on `mise up`, after which every managed hook 404s — the same ephemeral-path failure #977 fixed for fnm and #3181 for Homebrew. normalizeNodePath now rewrites a mise versioned install path to the stable sibling shim /shims/node when it exists (deriving from execPath so a custom MISE_DATA_DIR works), falling back to the raw execPath otherwise. Tests folded into install.test.cjs per the regression test-name lint. Co-Authored-By: Claude Opus 4.8 (1M context) * chore(changeset): set pr number to 1621 Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Joe Seymour Co-authored-by: Claude Opus 4.8 (1M context) Co-authored-by: Tom Boucher --- .changeset/1619-mise-shim-node-path.md | 5 +++ src/runtime-hooks-surface.cts | 17 +++++++ tests/install.test.cjs | 61 ++++++++++++++++++++++++++ 3 files changed, 83 insertions(+) create mode 100644 .changeset/1619-mise-shim-node-path.md diff --git a/.changeset/1619-mise-shim-node-path.md b/.changeset/1619-mise-shim-node-path.md new file mode 100644 index 000000000..ccba5b8cc --- /dev/null +++ b/.changeset/1619-mise-shim-node-path.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 1621 +--- +**`normalizeNodePath` now maps pruned mise node paths to the stable shim (#1619)** — `resolveNodeRunner()` bakes `process.execPath` into managed `.js` hook commands. Node realpaths execPath, so under mise it resolves to `/installs/node//bin/node` — a concrete version mise prunes on `mise up`, after which every managed hook fails to spawn (`No such file or directory` on every SessionStart and tool event), the same ephemeral-path failure #977 fixed for fnm and #3181 for Homebrew. `normalizeNodePath` now rewrites a mise versioned install path to the stable sibling shim `/shims/node` (`.exe` preserved on Windows) when that shim exists, deriving `` from execPath so a custom `MISE_DATA_DIR` works, and falling back to the raw execPath unchanged otherwise. (#1619) diff --git a/src/runtime-hooks-surface.cts b/src/runtime-hooks-surface.cts index 62275893e..3497f18ed 100644 --- a/src/runtime-hooks-surface.cts +++ b/src/runtime-hooks-surface.cts @@ -253,6 +253,23 @@ function normalizeNodePath(execPath: string, opts?: NodeNormOpts): string { if (/^\/opt\/homebrew\/Cellar\/node(@\d+)?\/[^/]+\/bin\/node(\.exe)?$/.test(execPath)) { return '/opt/homebrew/bin/node'; } + + // mise pins a concrete node version at /installs/node//bin/node + // (Windows: /installs/node//node.exe). Node realpaths + // process.execPath to that versioned path, and `mise up` prunes old versions, + // so a baked hook command 404s after any node bump — the same ephemeral-path + // failure #977 fixed for fnm. The stable alias is the sibling shim + // (/shims/node), which always resolves to the active version, like the + // Homebrew symlink survives `brew upgrade node`. Derive from execPath + // so a custom MISE_DATA_DIR layout still works, and only rewrite when the shim + // exists — otherwise fall back to the raw execPath unchanged. + const miseMatch = normalizedForMatch.match( + /^(.*)\/installs\/node\/[^/]+\/(?:bin\/)?node(\.exe)?$/, + ); + if (miseMatch) { + const shim = `${miseMatch[1]}/shims/node${miseMatch[2] || ''}`; + if (existsSync(shim)) return shim; + } return execPath; } diff --git a/tests/install.test.cjs b/tests/install.test.cjs index 66adf95bc..53a4c38eb 100644 --- a/tests/install.test.cjs +++ b/tests/install.test.cjs @@ -44,6 +44,7 @@ const { resolveKiloConfigPath, configureKiloPermissions, selectRuntimesFromArgs, + normalizeNodePath, } = require('../bin/install.js'); const { getGlobalConfigDir } = require('../gsd-core/bin/lib/runtime-homes.cjs'); @@ -1769,3 +1770,63 @@ describe('#767 Parity: docs/AGENTS.md "Disallowed Tools" rows match READONLY_AGE }); } }); + +// ─── normalizeNodePath — mise versioned install path → stable shim (#1619) ──── +// +// Bug #1619: `resolveNodeRunner()` bakes process.execPath into managed hook +// commands. Node realpaths execPath, so under mise it resolves to +// `/installs/node//bin/node` — a concrete version mise prunes on +// `mise up`, after which every managed hook 404s (same class as #977 fnm / +// #3181 Homebrew). normalizeNodePath now rewrites it to the stable sibling +// shim `/shims/node` when that shim exists, deriving from the +// path so a custom MISE_DATA_DIR works, and falling back to execPath otherwise. +// Folded into install.test.cjs (not a new bug-NNNN file) per the regression +// test-name lint. Assertions go against the exported function's return values. +describe('normalizeNodePath — mise versioned path → sibling shim (#1619)', () => { + const MISE_DATA = '/Users/u/.local/share/mise'; + const MISE_NODE_PINNED = `${MISE_DATA}/installs/node/26.3.0/bin/node`; + const MISE_SHIM = `${MISE_DATA}/shims/node`; + const MISE_WIN_DATA = 'C:/Users/u/AppData/Local/mise'; + const MISE_WIN_NODE = `${MISE_WIN_DATA}/installs/node/22.1.0/node.exe`; // no bin/ on Windows + const MISE_WIN_SHIM = `${MISE_WIN_DATA}/shims/node.exe`; + const MISE_CUSTOM_DATA = '/opt/mise-data'; + const MISE_CUSTOM_NODE = `${MISE_CUSTOM_DATA}/installs/node/20.0.0/bin/node`; + const MISE_CUSTOM_SHIM = `${MISE_CUSTOM_DATA}/shims/node`; + + test('POSIX pinned install path + shim exists → sibling shim', () => { + assert.equal( + normalizeNodePath(MISE_NODE_PINNED, { existsSync: p => p === MISE_SHIM }), + MISE_SHIM); + }); + + test('Windows node.exe + shim exists → shims/node.exe (.exe preserved)', () => { + assert.equal( + normalizeNodePath(MISE_WIN_NODE, { existsSync: p => p === MISE_WIN_SHIM }), + MISE_WIN_SHIM); + }); + + test('backslash Windows path normalizes the same as forward-slash', () => { + assert.equal( + normalizeNodePath(MISE_WIN_NODE.replace(/\//g, '\\'), + { existsSync: p => p === MISE_WIN_SHIM }), + MISE_WIN_SHIM); + }); + + test('custom MISE_DATA_DIR layout → shim derived from execPath, not env', () => { + assert.equal( + normalizeNodePath(MISE_CUSTOM_NODE, { existsSync: p => p === MISE_CUSTOM_SHIM }), + MISE_CUSTOM_SHIM); + }); + + test('no regression: shim absent → falls back to raw execPath unchanged', () => { + assert.equal( + normalizeNodePath(MISE_NODE_PINNED, { existsSync: () => false }), + MISE_NODE_PINNED); + }); + + test('non-mise path (Homebrew symlink) is left unchanged here', () => { + assert.equal( + normalizeNodePath('/opt/homebrew/bin/node', { existsSync: () => true }), + '/opt/homebrew/bin/node'); + }); +});