Skip to content
Open
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
53 changes: 45 additions & 8 deletions src/lib/paths.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 (`<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) {
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 {
Expand All @@ -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;
Expand Down
87 changes: 87 additions & 0 deletions tests/kit/paths-global-root.test.mjs
Original file line number Diff line number Diff line change
@@ -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 {
// <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