From 56a907fc5ea77349cbc08e4f48d24b35be5b05d1 Mon Sep 17 00:00:00 2001 From: Adrian Cockcroft Date: Tue, 11 Aug 2026 19:32:45 -0700 Subject: [PATCH 1/2] fix: resolve npm's global root on versioned (kegged) node layouts `globalRoot()` falls back to an execPath-derived path whenever `npm` is not spawnable. That fallback only tried `/../lib/node_modules` and `/node_modules`, which assumes the executable sits directly under its prefix. Homebrew does not: `process.execPath` is symlink-resolved to `/Cellar/node//bin/node`, so both candidates miss and `globalRoot()` throws "cannot determine npm global root (is npm installed?)" on a machine where npm is installed and working. mise, asdf, and nvm place their versioned trees at a similar depth. This is not a theoretical path: the test sandbox points PATH at a directory that does not exist by design (tests/kit/helpers/home-sandbox.mjs), so every sandboxed run takes the fallback. Six tests in tests/kit/provider-cli.test.mjs fail on any Homebrew-node macOS checkout, all surfacing as an opencode host-pick failure rather than as a path bug. At runtime the same throw reaches `ak host pick` whenever npm is off PATH. Walk the executable's ancestors (bounded to 5, never probing the filesystem root) so a linked prefix is recovered, and honour npm's own documented `npm_config_prefix` override ahead of any derivation. Nearest-first ordering is preserved, so a keg-local tree still wins where one exists, and the sibling layout stays the last resort. The walk is split into two exported, injectable functions so the layouts can be asserted as data without installing node five different ways, and without touching the process-wide cache. --- src/lib/paths.mjs | 53 ++++++++++++++--- tests/kit/paths-global-root.test.mjs | 87 ++++++++++++++++++++++++++++ 2 files changed, 132 insertions(+), 8 deletions(-) create mode 100644 tests/kit/paths-global-root.test.mjs diff --git a/src/lib/paths.mjs b/src/lib/paths.mjs index 9dddf60..0cefe2c 100644 --- a/src/lib/paths.mjs +++ b/src/lib/paths.mjs @@ -58,10 +58,53 @@ export const projectAgentDbMemoryDb = (root) => path.join(root, '.swarm', 'agent export const projectClaudeFlowDir = (root) => path.join(root, '.claude-flow'); export const projectAqeDir = (root) => path.join(root, '.agentic-qe'); +/** How many ancestors of the executable's bin/ dir may host a global tree. + * Homebrew's kegged layout needs four (`/Cellar/node//bin` + * → ``); the bound keeps the walk away from the filesystem root. */ +const GLOBAL_ROOT_MAX_ASCENT = 5; + +/** Spawn-free candidates for npm's global node_modules, nearest-first. + * Exported for tests: the layouts this must cover are host-specific, so they + * are asserted as data rather than reproduced by installing node five ways. + * + * `npm_config_prefix` is npm's own documented override and wins when set. + * Otherwise the executable's location is the only evidence available. A plain + * POSIX install keeps the tree one level above `bin/`, but a *versioned* + * layout does not: Homebrew resolves `/bin/node` to + * `/Cellar/node//bin/node`, and mise/asdf/nvm place their + * shims similarly deep. `process.execPath` is already symlink-resolved by + * node, so the `/bin/node` view is not observable here — walking the + * ancestors is how the linked prefix is recovered. */ +export function globalRootCandidates(execPath = process.execPath, env = process.env) { + const out = []; + const prefix = env.npm_config_prefix; + if (prefix) out.push(path.join(prefix, 'lib', 'node_modules'), path.join(prefix, 'node_modules')); + const binDir = path.dirname(execPath); + let dir = binDir; + for (let ascent = 0; ascent < GLOBAL_ROOT_MAX_ASCENT; ascent += 1) { + const parent = path.dirname(dir); + if (parent === dir) break; // filesystem root: `/lib/node_modules` is not a prefix + out.push(path.join(parent, 'lib', 'node_modules')); + dir = parent; + } + out.push(path.join(binDir, 'node_modules')); // Windows / some managers + return out; +} + +/** First existing candidate, or null. Split out so the walk is testable + * against a fixture tree without touching the process-wide cache. */ +export function resolveGlobalRoot(execPath = process.execPath, env = process.env, exists = fs.existsSync) { + for (const cand of globalRootCandidates(execPath, env)) { + if (exists(cand)) return path.resolve(cand); + } + return null; +} + let _globalRoot = null; /** npm's global node_modules. Cached per process. Derivation order mirrors * upstream #2221: `npm root -g` is authoritative; execPath-derived candidates - * cover environments where npm itself is missing from PATH (rare). */ + * cover environments where npm itself is missing from PATH — which is not as + * rare as it reads, since every sandboxed test and hook runs that way. */ export function globalRoot() { if (_globalRoot) return _globalRoot; try { @@ -71,13 +114,7 @@ export function globalRoot() { shell: isWindows, // npm is npm.cmd on Windows }).trim(); } catch { - const binDir = path.dirname(process.execPath); - for (const cand of [ - path.join(binDir, '..', 'lib', 'node_modules'), // POSIX layout - path.join(binDir, 'node_modules'), // Windows / some managers - ]) { - if (fs.existsSync(cand)) { _globalRoot = path.resolve(cand); break; } - } + _globalRoot = resolveGlobalRoot(); } if (!_globalRoot) throw new Error('cannot determine npm global root (is npm installed?)'); return _globalRoot; diff --git a/tests/kit/paths-global-root.test.mjs b/tests/kit/paths-global-root.test.mjs new file mode 100644 index 0000000..9c49780 --- /dev/null +++ b/tests/kit/paths-global-root.test.mjs @@ -0,0 +1,87 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { globalRootCandidates, resolveGlobalRoot } from '../../src/lib/paths.mjs'; + +// The spawn-free fallback is only exercised when `npm` is unreachable — which +// is exactly what every sandboxed test does (helpers/home-sandbox.mjs points +// PATH at a directory that does not exist). These fixtures assert the layouts +// it has to cover as data, rather than installing node five different ways. + +const HOMEBREW_EXEC = '/opt/homebrew/Cellar/node/26.4.0/bin/node'; +const LINUXBREW_EXEC = '/home/linuxbrew/.linuxbrew/Cellar/node/24.8.0/bin/node'; +const POSIX_EXEC = '/usr/bin/node'; +const NVM_EXEC = '/home/dev/.nvm/versions/node/v22.14.0/bin/node'; + +test('a versioned (kegged) layout offers the linked prefix, not just the keg', () => { + const candidates = globalRootCandidates(HOMEBREW_EXEC, {}); + // The regression: only the two keg-local paths used to be tried, and neither + // exists on a Homebrew install, so globalRoot() threw "is npm installed?" + // on a machine where npm was installed all along. + assert.ok(candidates.includes('/opt/homebrew/lib/node_modules'), + `linked prefix missing from candidates: ${candidates.join(', ')}`); + assert.ok(candidates.indexOf('/opt/homebrew/Cellar/node/26.4.0/lib/node_modules') + < candidates.indexOf('/opt/homebrew/lib/node_modules'), + 'nearest-first ordering must still prefer the keg-local tree when it exists'); +}); + +test('the ascent bound reaches a linuxbrew prefix and stops short of the filesystem root', () => { + const candidates = globalRootCandidates(LINUXBREW_EXEC, {}); + assert.ok(candidates.includes('/home/linuxbrew/.linuxbrew/lib/node_modules')); + assert.ok(!candidates.includes(path.join(path.sep, 'lib', 'node_modules')), + 'the filesystem root is not a prefix and must never be probed'); +}); + +test('the plain POSIX layout is still the first candidate', () => { + assert.equal(globalRootCandidates(POSIX_EXEC, {})[0], '/usr/lib/node_modules'); +}); + +test('a version-manager layout resolves to its own prefix', () => { + const candidates = globalRootCandidates(NVM_EXEC, {}); + assert.equal(candidates[0], '/home/dev/.nvm/versions/node/v22.14.0/lib/node_modules'); +}); + +test("npm's own prefix override wins over any execPath derivation", () => { + const candidates = globalRootCandidates(HOMEBREW_EXEC, { npm_config_prefix: '/custom/prefix' }); + assert.equal(candidates[0], path.join('/custom/prefix', 'lib', 'node_modules')); + assert.ok(candidates.indexOf(path.join('/custom/prefix', 'lib', 'node_modules')) + < candidates.indexOf('/opt/homebrew/lib/node_modules')); +}); + +// Asserted with a platform-neutral path: the invariant is the sibling tree's +// presence and its last-resort ordering, not win32 path parsing (paths.mjs uses +// the ambient `path`, which is already `path.win32` when running on Windows). +test('the sibling layout (Windows / some managers) remains the last candidate', () => { + const candidates = globalRootCandidates(POSIX_EXEC, {}); + assert.equal(candidates.at(-1), path.join('/usr/bin', 'node_modules')); +}); + +test('resolveGlobalRoot finds a kegged prefix on a real fixture tree', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ak-global-root-')); + try { + // /Cellar/node/26.4.0/bin/node, with the tree only at /lib. + const bin = path.join(root, 'Cellar', 'node', '26.4.0', 'bin'); + fs.mkdirSync(bin, { recursive: true }); + fs.mkdirSync(path.join(root, 'lib', 'node_modules'), { recursive: true }); + // path.resolve, not realpath: the walk normalizes but never dereferences. + assert.equal( + resolveGlobalRoot(path.join(bin, 'node'), {}), + path.join(root, 'lib', 'node_modules'), + ); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test('resolveGlobalRoot returns null rather than guessing when nothing exists', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ak-global-root-')); + try { + const bin = path.join(root, 'bin'); + fs.mkdirSync(bin, { recursive: true }); + assert.equal(resolveGlobalRoot(path.join(bin, 'node'), {}, () => false), null); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); From 368f44ca7fabf95e6d9cf449cfc9bede22e3777f Mon Sep 17 00:00:00 2001 From: Adrian Cockcroft Date: Wed, 12 Aug 2026 12:09:16 -0700 Subject: [PATCH 2/2] fix(test): exercise the win32 path branch instead of assuming POSIX separators MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Windows CI legs failed on the new suite. The production walk was correct there — the candidate list contained `\opt\homebrew\lib\node_modules`, the linked prefix this change exists to recover — but the assertions compared it against hand-written '/opt/homebrew/lib/node_modules' literals, which only match on a host where path.sep is '/'. Rewritten to follow the convention in footprint-windows.test.mjs: run the REAL win32 code path through an injected `path` implementation, so separator handling is verified from any host rather than in whichever flavour the runner happens to use. Expected values are now composed with the same implementation under test, which is what makes a literal-vs-join mismatch impossible to reintroduce. Three win32 cases are added — separator emission, the sibling layout beside node.exe, and drive-root exclusion. Also fixes a real discrepancy the Windows run exposed. The code claimed it never probes the filesystem root, and the linuxbrew fixture appeared to confirm it — but that path is deep enough that the ascent never reaches `/`, so the assertion passed vacuously. A shallow executable such as /usr/bin/node does reach it, and `/lib/node_modules` (or `C:\lib\node_modules`) was being emitted as a candidate. The root is now genuinely skipped, matching the stated contract, with both a POSIX and a win32 test that fail if it regresses. --- src/lib/paths.mjs | 18 +++--- tests/kit/paths-global-root.test.mjs | 86 +++++++++++++++++++--------- 2 files changed, 70 insertions(+), 34 deletions(-) diff --git a/src/lib/paths.mjs b/src/lib/paths.mjs index 0cefe2c..f955a35 100644 --- a/src/lib/paths.mjs +++ b/src/lib/paths.mjs @@ -75,19 +75,23 @@ const GLOBAL_ROOT_MAX_ASCENT = 5; * shims similarly deep. `process.execPath` is already symlink-resolved by * node, so the `/bin/node` view is not observable here — walking the * ancestors is how the linked prefix is recovered. */ -export function globalRootCandidates(execPath = process.execPath, env = process.env) { +export function globalRootCandidates(execPath = process.execPath, env = process.env, p = path) { + const isRoot = (dir) => p.dirname(dir) === dir; const out = []; const prefix = env.npm_config_prefix; - if (prefix) out.push(path.join(prefix, 'lib', 'node_modules'), path.join(prefix, 'node_modules')); - const binDir = path.dirname(execPath); + if (prefix) out.push(p.join(prefix, 'lib', 'node_modules'), p.join(prefix, 'node_modules')); + const binDir = p.dirname(execPath); let dir = binDir; for (let ascent = 0; ascent < GLOBAL_ROOT_MAX_ASCENT; ascent += 1) { - const parent = path.dirname(dir); - if (parent === dir) break; // filesystem root: `/lib/node_modules` is not a prefix - out.push(path.join(parent, 'lib', 'node_modules')); + const parent = p.dirname(dir); + if (parent === dir) break; + // The filesystem root is not a prefix: `/lib/node_modules` (or `C:\lib\…`) + // belongs to no install, so it is skipped rather than probed. The walk + // still ascends past it in case an intermediate level qualifies. + if (!isRoot(parent)) out.push(p.join(parent, 'lib', 'node_modules')); dir = parent; } - out.push(path.join(binDir, 'node_modules')); // Windows / some managers + out.push(p.join(binDir, 'node_modules')); // Windows / some managers return out; } diff --git a/tests/kit/paths-global-root.test.mjs b/tests/kit/paths-global-root.test.mjs index 9c49780..d968253 100644 --- a/tests/kit/paths-global-root.test.mjs +++ b/tests/kit/paths-global-root.test.mjs @@ -1,3 +1,16 @@ +// The spawn-free half of `globalRoot()`. It only runs when `npm` is +// unreachable — which is what every sandboxed test does by design +// (helpers/home-sandbox.mjs points PATH at a directory that does not exist), +// so this walk is on the hot path for the suite itself, not just for exotic +// machines. +// +// Following the convention in footprint-windows.test.mjs: the win32 layouts +// run the REAL win32 code path through an injected `path` implementation, so +// separator handling is verified from any host rather than asserted in +// whichever flavour the runner happens to use. Expected values are composed +// with the same implementation under test — comparing against a hand-written +// '/usr/lib/node_modules' literal is what made the first revision pass on +// POSIX and fail on Windows. import { test } from 'node:test'; import assert from 'node:assert/strict'; import fs from 'node:fs'; @@ -5,59 +18,78 @@ import os from 'node:os'; import path from 'node:path'; import { globalRootCandidates, resolveGlobalRoot } from '../../src/lib/paths.mjs'; -// The spawn-free fallback is only exercised when `npm` is unreachable — which -// is exactly what every sandboxed test does (helpers/home-sandbox.mjs points -// PATH at a directory that does not exist). These fixtures assert the layouts -// it has to cover as data, rather than installing node five different ways. +const posix = path.posix; +const win32 = path.win32; const HOMEBREW_EXEC = '/opt/homebrew/Cellar/node/26.4.0/bin/node'; const LINUXBREW_EXEC = '/home/linuxbrew/.linuxbrew/Cellar/node/24.8.0/bin/node'; const POSIX_EXEC = '/usr/bin/node'; const NVM_EXEC = '/home/dev/.nvm/versions/node/v22.14.0/bin/node'; +const WIN_EXEC = 'C:\\Users\\dev\\scoop\\apps\\nodejs\\24.8.0\\node.exe'; test('a versioned (kegged) layout offers the linked prefix, not just the keg', () => { - const candidates = globalRootCandidates(HOMEBREW_EXEC, {}); + const candidates = globalRootCandidates(HOMEBREW_EXEC, {}, posix); // The regression: only the two keg-local paths used to be tried, and neither // exists on a Homebrew install, so globalRoot() threw "is npm installed?" // on a machine where npm was installed all along. - assert.ok(candidates.includes('/opt/homebrew/lib/node_modules'), - `linked prefix missing from candidates: ${candidates.join(', ')}`); - assert.ok(candidates.indexOf('/opt/homebrew/Cellar/node/26.4.0/lib/node_modules') - < candidates.indexOf('/opt/homebrew/lib/node_modules'), + const linked = posix.join('/opt/homebrew', 'lib', 'node_modules'); + const keg = posix.join('/opt/homebrew/Cellar/node/26.4.0', 'lib', 'node_modules'); + assert.ok(candidates.includes(linked), `linked prefix missing: ${candidates.join(', ')}`); + assert.ok(candidates.indexOf(keg) < candidates.indexOf(linked), 'nearest-first ordering must still prefer the keg-local tree when it exists'); }); -test('the ascent bound reaches a linuxbrew prefix and stops short of the filesystem root', () => { - const candidates = globalRootCandidates(LINUXBREW_EXEC, {}); - assert.ok(candidates.includes('/home/linuxbrew/.linuxbrew/lib/node_modules')); - assert.ok(!candidates.includes(path.join(path.sep, 'lib', 'node_modules')), - 'the filesystem root is not a prefix and must never be probed'); +test('the ascent reaches a linuxbrew prefix', () => { + const candidates = globalRootCandidates(LINUXBREW_EXEC, {}, posix); + assert.ok(candidates.includes(posix.join('/home/linuxbrew/.linuxbrew', 'lib', 'node_modules'))); }); test('the plain POSIX layout is still the first candidate', () => { - assert.equal(globalRootCandidates(POSIX_EXEC, {})[0], '/usr/lib/node_modules'); + assert.equal(globalRootCandidates(POSIX_EXEC, {}, posix)[0], + posix.join('/usr', 'lib', 'node_modules')); +}); + +test('the filesystem root is never probed — `/lib/node_modules` belongs to no install', () => { + // /usr/bin/node ascends into `/` within the bound, so this is the shallow + // case that proves the skip rather than passing vacuously on a deep path. + const candidates = globalRootCandidates(POSIX_EXEC, {}, posix); + assert.ok(!candidates.includes(posix.join('/', 'lib', 'node_modules')), + `root probed: ${candidates.join(', ')}`); }); test('a version-manager layout resolves to its own prefix', () => { - const candidates = globalRootCandidates(NVM_EXEC, {}); - assert.equal(candidates[0], '/home/dev/.nvm/versions/node/v22.14.0/lib/node_modules'); + assert.equal(globalRootCandidates(NVM_EXEC, {}, posix)[0], + posix.join('/home/dev/.nvm/versions/node/v22.14.0', 'lib', 'node_modules')); }); test("npm's own prefix override wins over any execPath derivation", () => { - const candidates = globalRootCandidates(HOMEBREW_EXEC, { npm_config_prefix: '/custom/prefix' }); - assert.equal(candidates[0], path.join('/custom/prefix', 'lib', 'node_modules')); - assert.ok(candidates.indexOf(path.join('/custom/prefix', 'lib', 'node_modules')) - < candidates.indexOf('/opt/homebrew/lib/node_modules')); + const candidates = globalRootCandidates(HOMEBREW_EXEC, { npm_config_prefix: '/custom/prefix' }, posix); + assert.equal(candidates[0], posix.join('/custom/prefix', 'lib', 'node_modules')); + assert.ok(candidates.indexOf(posix.join('/custom/prefix', 'lib', 'node_modules')) + < candidates.indexOf(posix.join('/opt/homebrew', 'lib', 'node_modules'))); +}); + +// ── win32, exercised from any host ─────────────────────────────────────────── + +test('win32: candidates are emitted with backslash separators', () => { + const candidates = globalRootCandidates(WIN_EXEC, {}, win32); + assert.ok(candidates.every((c) => !c.includes('/')), `forward slash leaked: ${candidates.join(', ')}`); + assert.ok(candidates.includes(win32.join('C:\\Users\\dev\\scoop\\apps\\nodejs', 'lib', 'node_modules'))); +}); + +test('win32: the sibling layout (npm/nodejs ship node_modules beside node.exe) is a candidate', () => { + const candidates = globalRootCandidates('C:\\Program Files\\nodejs\\node.exe', {}, win32); + assert.equal(candidates.at(-1), win32.join('C:\\Program Files\\nodejs', 'node_modules')); }); -// Asserted with a platform-neutral path: the invariant is the sibling tree's -// presence and its last-resort ordering, not win32 path parsing (paths.mjs uses -// the ambient `path`, which is already `path.win32` when running on Windows). -test('the sibling layout (Windows / some managers) remains the last candidate', () => { - const candidates = globalRootCandidates(POSIX_EXEC, {}); - assert.equal(candidates.at(-1), path.join('/usr/bin', 'node_modules')); +test('win32: a drive root is never probed', () => { + const candidates = globalRootCandidates('C:\\node.exe', {}, win32); + assert.ok(!candidates.includes(win32.join('C:\\', 'lib', 'node_modules')), + `drive root probed: ${candidates.join(', ')}`); }); +// ── against a real fixture tree, on whatever host is running ───────────────── + test('resolveGlobalRoot finds a kegged prefix on a real fixture tree', () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ak-global-root-')); try {