Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 49 additions & 8 deletions src/lib/paths.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -58,10 +58,57 @@ 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 (`<prefix>/Cellar/node/<version>/bin`
* → `<prefix>`); 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 `<prefix>/bin/node` to
* `<prefix>/Cellar/node/<version>/bin/node`, and mise/asdf/nvm place their
* shims similarly deep. `process.execPath` is already symlink-resolved by
* node, so the `<prefix>/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, p = path) {
const isRoot = (dir) => p.dirname(dir) === dir;
const out = [];
const prefix = env.npm_config_prefix;
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 = 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(p.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 {
Expand All @@ -71,13 +118,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;
Expand Down
119 changes: 119 additions & 0 deletions tests/kit/paths-global-root.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
// 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';
import os from 'node:os';
import path from 'node:path';
import { globalRootCandidates, resolveGlobalRoot } from '../../src/lib/paths.mjs';

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, {}, 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.
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 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, {}, 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', () => {
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' }, 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'));
});

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 {
// <root>/Cellar/node/26.4.0/bin/node, with the tree only at <root>/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 });
}
});
Loading