fix(hooks): resolve globally-installed ruflo before falling back to npx (#3368) (#4368)

* fix(hooks): resolve globally-installed ruflo before falling back to npx (#3368)

hook-handler.cjs's resolveCliBinForHook() only probed project-relative
locations, so on a machine with `npm install -g ruflo` every SessionStart
refresh (refresh-funnel, refresh-advisor) and the first-run spinner enable
fell back to `npx --prefer-offline @claude-flow/cli`: a second copy of the
CLI that follows the registry's latest instead of the pinned global, and
that cannot start offline on a cold npx cache.

After the existing candidates miss, look for a global install:
1. npm's global prefix, resolved in npm's own precedence (npm_config_prefix
   env, user npmrc, npm's builtin npmrc, default) without spawning npm. It
   matches `npm prefix -g` in 12 of 14 configurations compared; the other
   two are an npmrc npm itself rejects and an undefined ${VAR}, which this
   keeps literal so the relative value is refused rather than followed.
2. `ruflo` / `claude-flow` on PATH, which covers yarn and bun globals. Only
   a symlink into a package that declares one of the three published names
   counts, so a wrapper script or a Volta shim is not mistaken for an
   install, and npm's Windows folder layout is only probed on win32 — on
   POSIX it would follow a Windows install through WSL's appended PATH.

Each package root maps to its CLI the way ruflo/bin/ruflo.js does, stopping
at the enclosing node_modules so a stray ~/node_modules is never reached,
and every hit must still pass the existing dist/src/index.js guard.
Project-local installs keep priority, and npx stays the last resort. The
lookup runs once per hook process (session-restore asks up to three times):
about 0.6 ms for that call, 0.000 ms after, against 70-80 ms for a
`npm prefix -g` spawn.

Both committed copies stay byte-identical (hook-handler-artifact-parity).
helpers.manifest.json is deliberately untouched: the new hook-handler.cjs
hash needs the maintainer's re-sign, and helper-signing.test.ts reports the
drift until then.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KTedGmPKpEdArVBMMn3JaP

* fix(hooks): also look in ~/.npm-global after the configured prefix and PATH (#3368)

resolveGlobalCliBin() still returned null on a real install: ruflo lived in
~/.npm-global (npm's documented no-sudo prefix), but the setting pointing npm
there sat where the hook's node never reads it, and the only `ruflo` on PATH
was a wrapper script, which the PATH lookup skips by design. Every hook then
fell back to `npx @claude-flow/cli@latest`.

~/.npm-global is now a third, last-resort lookup, checked only after the
configured prefix and PATH both miss, so it can never override either. The
prefix loop moves into cliUnderPrefix() so both lookups share it.

Tests: an end-to-end case for that exact setup (no npmrc prefix, wrapper
script on PATH), a unit pinning the order (configured prefix wins), and a
unit that an unbuilt ~/.npm-global install is ignored. The first two fail
on the previous helper; 35/35 pass with the fix. Both committed copies stay
byte-identical.

Co-Authored-By: ruflo <ruv@ruv.net>

* test(hooks): expect the npx fallback to carry --prefix <helper root> (#3368)

Since e625b7104 (3.56.4) cliSpawnArgs() resolves the npx fallback from the
helper's own install root: `npx --prefer-offline --prefix <helperRoot>
@claude-flow/cli hooks refresh-*`. The three "still falls back to npx" cases
asserted the older argv without --prefix and failed after rebasing onto
v3.56.9. For the sandbox's project-level helper the root is the project, so
expectFellBackToNpx() now takes the sandbox and checks that exact prefix.
21/21 pass.

Co-Authored-By: ruflo <ruv@ruv.net>

* chore(helpers): re-sign manifest for the global-CLI hook fix (#3368)

hook-handler.cjs changed in the prior commits; re-sign
v3/@claude-flow/cli/.claude/helpers/helpers.manifest.json so the
shipped-helpers integrity gate (helper-signing.test.ts) matches the new
content hash.

Co-authored-by: Mr. Mak <218673441+HF-teamdev@users.noreply.github.com>
Co-Authored-By: RuFlo <ruv@ruv.net>

---------

Co-authored-by: Mr. Mak <218673441+HF-teamdev@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Mak Allen <mak@heroforge.ai>
Co-authored-by: r <r@x>
This commit is contained in:
authored and GitHub committed 2026-10-21 17:34:24 -04:00
1 parent 59dd30c60e
commit 3da60761f9
4 files changed
+853 -25

No files matched your search

+228 -11
View File
@@ -24,9 +24,10 @@ const helpersDir = __dirname;
const helperRoot = path.resolve(helpersDir, '..', '..');
// Resolve an installed @claude-flow/cli (or ruflo) bin — mirrors
// statusline-generator.ts's resolveCliBin() candidate list. Used only to
// spawn the detached funnel-refresh helper below; failures are silent (no
// candidate found just means the refresh never fires this session).
// statusline-generator.ts's resolveCliBin() candidate list, then looks for a
// global install (resolveGlobalCliBin() below, #3368). Used only to spawn the
// detached funnel-refresh helper below; failures are silent (no candidate
// found just means the refresh falls back to npx).
//
// Verifies dist/src/index.js exists alongside bin/cli.js, not just the bin
// itself — Claude Code's own plugin marketplace mechanism installs by
@@ -39,7 +40,17 @@ const helperRoot = path.resolve(helpersDir, '..', '..');
// first every time and spawnDetachedFunnelRefresh() below had no fallback,
// so the promo/disclosure row could never populate for any marketplace
// install, on any OS.
// session-restore asks up to three times (first-run enable, then the two
// refreshes). The answer cannot change inside one short-lived hook process,
// so resolve once: `undefined` means "not looked up yet", null means "no
// local or global CLI — use npx".
let cliBinCache;
function resolveCliBinForHook() {
if (cliBinCache === undefined) cliBinCache = resolveCliBinUncached();
return cliBinCache;
}
function resolveCliBinUncached() {
try {
const home = os.homedir();
const candidates = [
@@ -53,16 +64,219 @@ function resolveCliBinForHook() {
path.join(helpersDir, '..', '..', 'bin', 'cli.js'),
];
for (const p of candidates) {
try {
if (fs.existsSync(p) && fs.existsSync(path.join(path.dirname(p), '..', 'dist', 'src', 'index.js'))) {
return p;
}
} catch (e) { /* try next candidate */ }
if (isRunnableCli(p)) return p;
}
return resolveGlobalCliBin();
} catch (e) { /* ignore */ }
return null;
}
function isRunnableCli(p) {
try {
return fs.existsSync(p) && fs.existsSync(path.join(path.dirname(p), '..', 'dist', 'src', 'index.js'));
} catch (e) { return false; }
}
// #3368: a global install — the README's `npm install -g ruflo@latest` — keeps
// the CLI at <prefix>/lib/node_modules/ruflo/node_modules/@claude-flow/cli,
// which none of the project-relative candidates above cover, so on those
// machines every refresh fell back to npx: a second, separately cached copy
// of the CLI that follows the registry's latest rather than the version the
// user pinned (the same npx-cache skew as #3306), and that cannot start
// offline on a cold cache.
//
// Three lookups, fs calls only (no child process), reached only after every
// candidate above missed:
// 1. npm's global prefix, resolved the way npm resolves it
// (npmGlobalPrefix() below). First because it does not depend on PATH:
// a hook inherits the host process's PATH, which need not contain the
// prefix's bin/ even when the user's interactive shell does (observed:
// the Claude Code process spawning hooks lacked ~/.npm-global/bin while
// a login zsh on the same machine had it).
// 2. `ruflo`, then `claude-flow`, on PATH — the order plugins/ruflo-core/
// scripts/ruflo-hook.cjs uses — for globals npm did not install (yarn
// classic, bun). Only a symlinked bin counts, and only when it lands in
// a package that declares one of the three known names, so a wrapper
// script or a version-manager shim (Volta) is never mistaken for an
// install. The <dir>/node_modules/<name> layout npm uses on Windows,
// where ruflo.cmd sits beside node_modules\ruflo (what resolveNpmShim()
// in ruflo-hook.cjs maps), is only consulted on win32: on POSIX every
// manager symlinks, and probing it there would follow a Windows install
// exposed to WSL through the appended Windows PATH.
// Relative PATH entries (`.` or an empty entry) are skipped, so a bare
// `./ruflo` in the working directory is never picked up.
// 3. ~/.npm-global, npm's documented no-sudo prefix, when 1 and 2 both
// missed: the prefix setting may live where this hook's node never
// reads it, and a wrapper script on PATH is skipped by 2.
// `env`/`platform`/`execPath` are parameters so any layout is testable
// from any OS.
function resolveGlobalCliBin(env = process.env, platform = process.platform, execPath = process.execPath) {
const prefix = npmGlobalPrefix(env, platform, execPath);
if (prefix) {
const hit = cliUnderPrefix(prefix, platform);
if (hit) return hit;
}
const dirs = String(env.PATH || env.Path || '').split(platform === 'win32' ? ';' : ':');
const exts = platform === 'win32' ? ['.cmd', ''] : [''];
for (const name of ['ruflo', 'claude-flow']) {
for (const dir of dirs) {
if (!dir || !path.isAbsolute(dir)) continue;
for (const ext of exts) {
const shim = path.join(dir, name + ext);
if (!fs.existsSync(shim)) continue;
const roots = [];
try {
if (fs.lstatSync(shim).isSymbolicLink()) {
const root = path.dirname(path.dirname(fs.realpathSync(shim)));
if (isKnownPackageRoot(root)) roots.push(root);
}
} catch (e) { /* unreadable link — fall back to the layout roots */ }
if (platform === 'win32') {
roots.push(path.join(dir, 'node_modules', name));
if (name === 'claude-flow') roots.push(path.join(dir, 'node_modules', '@claude-flow', 'cli'));
}
for (const root of roots) {
const hit = cliInPackage(root);
if (hit) return hit;
}
}
}
}
// Last resort: ~/.npm-global, the prefix npm's own docs recommend for
// global installs without sudo ("Resolving EACCES permissions errors").
// The setting that points npm there can live where this hook's node never
// looks (another node's builtin npmrc, or a wrapper script that sets it
// per call), and a `ruflo` wrapper script on PATH is skipped above by
// design. Without this, such a machine fell back to npx @latest on every
// hook. Checked only after the configured prefix and PATH both miss, so it
// can never override either.
const home = env.HOME || env.USERPROFILE;
if (home && path.isAbsolute(home)) {
const hit = cliUnderPrefix(path.join(home, '.npm-global'), platform);
if (hit) return hit;
}
return null;
}
// The CLI of a global install under an npm prefix: <prefix>/lib/node_modules
// on POSIX, <prefix>\node_modules on Windows, trying the three published
// package names in order.
function cliUnderPrefix(prefix, platform) {
const modules = platform === 'win32'
? path.join(prefix, 'node_modules')
: path.join(prefix, 'lib', 'node_modules');
for (const pkg of ['ruflo', 'claude-flow', path.join('@claude-flow', 'cli')]) {
const hit = cliInPackage(path.join(modules, pkg));
if (hit) return hit;
}
return null;
}
// A bin on PATH only counts as an install when the package it points into
// declares one of the three published names. This is a narrower check than
// resolveNpmShim() in ruflo-hook.cjs, which never reads `name`: that one takes
// the package name from the shim's own filename, then validates package.json's
// `bin` entry and that its realpath stays inside the package. Here the bin has
// already been resolved to a package root, so the manifest `name` is what
// identifies it.
function isKnownPackageRoot(root) {
try {
const name = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf-8')).name;
return name === 'ruflo' || name === 'claude-flow' || name === '@claude-flow/cli';
} catch (e) { return false; }
}
// Map an installed ruflo / claude-flow / @claude-flow/cli package root to the
// CLI it runs: <root>/bin/cli.js (@claude-flow/cli itself),
// <root>/v3/@claude-flow/cli/bin/cli.js (the claude-flow umbrella bundles
// it), or @claude-flow/cli in the node_modules npm nests inside <root> —
// or, for the flat layout yarn classic and bun use, in the node_modules
// folder that holds <root> itself. That is the dependency edge
// ruflo/bin/ruflo.js's findCliPath() follows. The search stops at that one
// enclosing node_modules: walking further up would reach unrelated trees,
// such as a stray ~/node_modules left by an `npm i` in a home directory.
// Every hit must still pass isRunnableCli().
function cliInPackage(root) {
if (!fs.existsSync(root)) return null;
const candidates = [
path.join(root, 'bin', 'cli.js'),
path.join(root, 'v3', '@claude-flow', 'cli', 'bin', 'cli.js'),
path.join(root, 'node_modules', '@claude-flow', 'cli', 'bin', 'cli.js'),
];
for (let dir = path.dirname(root), i = 0; i < 4 && dir !== path.dirname(dir); i++, dir = path.dirname(dir)) {
if (path.basename(dir) === 'node_modules') {
candidates.push(path.join(dir, '@claude-flow', 'cli', 'bin', 'cli.js'));
break;
}
}
return candidates.find(isRunnableCli) || null;
}
// npm's global prefix without spawning npm (`npm prefix -g` measured 70-80 ms
// per call, paid on every SessionStart that gets this far). Same precedence
// npm applies:
// 1. npm_config_prefix in the environment (npm accepts either case);
// 2. `prefix=` in the user npmrc ($npm_config_userconfig, else ~/.npmrc),
// where `npm config set prefix ~/.npm-global` (the npm docs' fix for
// EACCES on global installs) writes it;
// 3. `prefix=` in npm's builtin npmrc (<npm package>/npmrc), found through
// the `npm` link beside node: Homebrew writes `prefix = /opt/homebrew`
// there (its process.execPath is the Cellar path, so step 4 alone would
// be wrong), and the Node.js Windows installer ships
// `prefix=${APPDATA}\npm`;
// 4. npm's default: $PREFIX, else the directory above node's bin/ (POSIX)
// or node.exe's own directory (Windows).
// Values follow npm's ini handling for the cases that decide a path: a
// quoted value is taken literally, an unquoted `;`/`#` starts a comment,
// keys below a `[section]` header are ignored, `${VAR}` expands when the
// variable is set (and `${VAR?}` to empty when it is not) but otherwise
// stays literal like npm — which leaves the value relative, so the
// isAbsolute() check below rejects it instead of pointing at the drive root.
// Not emulated: escaped characters and npm's other ini corner cases; each of
// those produces a miss, never a wrong CLI. The npmrc is read into memory,
// but only the value of its last top-level `prefix =` line is kept: no other
// line (an `_authToken`, say) is returned, stored or logged.
function npmGlobalPrefix(env = process.env, platform = process.platform, execPath = process.execPath) {
const home = (platform === 'win32' ? env.USERPROFILE : env.HOME) || os.homedir();
const readPrefix = (file) => {
let value = null;
try {
for (const line of fs.readFileSync(file, 'utf-8').split(/\r?\n/)) {
if (/^\s*\[/.test(line)) break; // only the top-level section applies
const m = /^\s*prefix\s*=\s*(.*?)\s*$/.exec(line);
if (m) value = m[1]; // last one wins, as in npm's ini parser
}
} catch (e) { /* no such npmrc */ }
return value;
};
const nodeDir = path.dirname(execPath);
// npm's builtin npmrc sits in npm's own package dir. On Windows that is
// node_modules\npm beside node.exe; elsewhere follow the `npm` link beside
// node (<npm>/bin/npm-cli.js) — on Homebrew it points out of the node keg
// to /opt/homebrew/lib/node_modules/npm.
let builtinRc = path.join(nodeDir, 'node_modules', 'npm', 'npmrc');
if (platform !== 'win32') {
try {
builtinRc = path.join(path.dirname(path.dirname(fs.realpathSync(path.join(nodeDir, 'npm')))), 'npmrc');
} catch (e) { /* no npm beside node */ }
}
const raw = env.npm_config_prefix || env.NPM_CONFIG_PREFIX
|| readPrefix(env.npm_config_userconfig || env.NPM_CONFIG_USERCONFIG || path.join(home, '.npmrc'))
|| readPrefix(builtinRc)
|| env.PREFIX
|| (platform === 'win32' ? nodeDir : path.dirname(nodeDir));
let value = String(raw).trim();
const quoted = /^(["'])([\s\S]*)\1$/.exec(value);
if (quoted) value = quoted[2];
else value = value.replace(/(^|[^\\])[;#].*$/, '$1').trim();
value = value
.replace(/\$\{([^}?]+)(\?)?\}/g, (whole, name, optional) => (
env[name] !== undefined ? env[name] : (optional ? '' : whole)
))
.replace(/^~(?=$|[\\/])/, home);
return path.isAbsolute(value) ? path.resolve(value) : null;
}
// Fire-and-forget doesn't work when the CALLER is itself a short-lived
// subprocess (confirmed live: two consecutive statusline renders 5s apart
// both saw an empty funnel-messages-cache, because the async HTTPS fetch
@@ -74,8 +288,8 @@ function resolveCliBinForHook() {
// chance to write the cache. Never awaited here — must not add to
// SessionStart's own timeout budget.
//
// No usable local candidate (resolveCliBinForHook() returned null) falls
// back to npx: this call is detached/unref'd, so a slower npx cold-start
// No usable local or global candidate (resolveCliBinForHook() returned null)
// falls back to npx: this call is detached/unref'd, so a slower npx cold-start
// costs nothing perceptible — unlike the statusline's own synchronous
// render path, where local-first exists purely for per-render latency.
// `--prefer-offline` avoids a registry round trip for the tarball when
@@ -1060,4 +1274,7 @@ if (require.main === module) {
// Which sibling helpers loaded (all CommonJS, shipped as .cjs — #3555).
const loadedHelpers = { router: !!router, session: !!session, memory: !!memory, intelligence: !!intelligence };
module.exports = { runWithTimeout, INTELLIGENCE_TIMEOUT_MS, loadedHelpers, ownedByMod, MOD_OWNABLE_EVENTS, resolveCliBinForHook, cliSpawnArgs, helperRoot, scanSessionStartSettingsForRisk, srFormatRiskFindingsForAutomaticBoot, srScanSettingsForRisk, srScanCommandStringForRisk, srScanAllowRuleForRisk, SR_DANGEROUS_COMMAND_WORDS, SR_SHELL_EXEC_WORDS, SR_DOWNLOADER_WORDS, SR_STANDALONE_RISK_PATTERNS };
// resolveGlobalCliBin/npmGlobalPrefix are exported for
// hook-handler-global-cli.test.ts; resolveCliBinForHook for anyone checking
// what a given machine resolves to without running a hook (#3368).
module.exports = { runWithTimeout, INTELLIGENCE_TIMEOUT_MS, loadedHelpers, ownedByMod, MOD_OWNABLE_EVENTS, resolveCliBinForHook, resolveGlobalCliBin, npmGlobalPrefix, cliSpawnArgs, helperRoot, scanSessionStartSettingsForRisk, srFormatRiskFindingsForAutomaticBoot, srScanSettingsForRisk, srScanCommandStringForRisk, srScanAllowRuleForRisk, SR_DANGEROUS_COMMAND_WORDS, SR_SHELL_EXEC_WORDS, SR_DOWNLOADER_WORDS, SR_STANDALONE_RISK_PATTERNS };
@@ -1,14 +1,14 @@
{
"manifest": {
"version": "3.57.1",
"version": "3.59.12",
"files": {
"auto-memory-hook.mjs": "631fac9d518835e2852a406888316bbbddddb49e7ed8e406d2e971f46833df9a",
"hook-handler.cjs": "bc12b3cae92d5a7c8deef699fa00d0ef07b57625c18dbeac4387f15725b8e09f",
"hook-handler.cjs": "24502a3bcb78770971147940515c992e35b6736ae14ee2dc54e07d4cdef6a1d8",
"intelligence.cjs": "1d981d6c6a35d70a66450e96458e26169e0cede11523ea246a2c129c197a5b4e",
"statusline.cjs": "d72417ecb38634011a19f095a30e07b9929e6f97663520172ece9be7491b2eaa",
"router.cjs": "16648b3c0d73b6681ac2d8d13def6a68286c57f5a11004de5b19a0fa088c833d"
}
},
"signature": "6ArQqTEyowza1Ku34wtreG6UuyiY7XPdn99FyFK4LVAN8xvgFr+LzlfnP/Gk3wLd2ZhG0oBAELAUS00TuFOLBA==",
"signature": "3W2msAbKXT6nAWSCviEGsLg0KdwT5VcWkBTndXg9RgKY56YkGiwjE8sL0eBI/m5iaGbdHqUOQVbibaBNJkdQAg==",
"algorithm": "ed25519"
}
@@ -24,9 +24,10 @@ const helpersDir = __dirname;
const helperRoot = path.resolve(helpersDir, '..', '..');
// Resolve an installed @claude-flow/cli (or ruflo) bin — mirrors
// statusline-generator.ts's resolveCliBin() candidate list. Used only to
// spawn the detached funnel-refresh helper below; failures are silent (no
// candidate found just means the refresh never fires this session).
// statusline-generator.ts's resolveCliBin() candidate list, then looks for a
// global install (resolveGlobalCliBin() below, #3368). Used only to spawn the
// detached funnel-refresh helper below; failures are silent (no candidate
// found just means the refresh falls back to npx).
//
// Verifies dist/src/index.js exists alongside bin/cli.js, not just the bin
// itself — Claude Code's own plugin marketplace mechanism installs by
@@ -39,7 +40,17 @@ const helperRoot = path.resolve(helpersDir, '..', '..');
// first every time and spawnDetachedFunnelRefresh() below had no fallback,
// so the promo/disclosure row could never populate for any marketplace
// install, on any OS.
// session-restore asks up to three times (first-run enable, then the two
// refreshes). The answer cannot change inside one short-lived hook process,
// so resolve once: `undefined` means "not looked up yet", null means "no
// local or global CLI — use npx".
let cliBinCache;
function resolveCliBinForHook() {
if (cliBinCache === undefined) cliBinCache = resolveCliBinUncached();
return cliBinCache;
}
function resolveCliBinUncached() {
try {
const home = os.homedir();
const candidates = [
@@ -53,16 +64,219 @@ function resolveCliBinForHook() {
path.join(helpersDir, '..', '..', 'bin', 'cli.js'),
];
for (const p of candidates) {
try {
if (fs.existsSync(p) && fs.existsSync(path.join(path.dirname(p), '..', 'dist', 'src', 'index.js'))) {
return p;
}
} catch (e) { /* try next candidate */ }
if (isRunnableCli(p)) return p;
}
return resolveGlobalCliBin();
} catch (e) { /* ignore */ }
return null;
}
function isRunnableCli(p) {
try {
return fs.existsSync(p) && fs.existsSync(path.join(path.dirname(p), '..', 'dist', 'src', 'index.js'));
} catch (e) { return false; }
}
// #3368: a global install — the README's `npm install -g ruflo@latest` — keeps
// the CLI at <prefix>/lib/node_modules/ruflo/node_modules/@claude-flow/cli,
// which none of the project-relative candidates above cover, so on those
// machines every refresh fell back to npx: a second, separately cached copy
// of the CLI that follows the registry's latest rather than the version the
// user pinned (the same npx-cache skew as #3306), and that cannot start
// offline on a cold cache.
//
// Three lookups, fs calls only (no child process), reached only after every
// candidate above missed:
// 1. npm's global prefix, resolved the way npm resolves it
// (npmGlobalPrefix() below). First because it does not depend on PATH:
// a hook inherits the host process's PATH, which need not contain the
// prefix's bin/ even when the user's interactive shell does (observed:
// the Claude Code process spawning hooks lacked ~/.npm-global/bin while
// a login zsh on the same machine had it).
// 2. `ruflo`, then `claude-flow`, on PATH — the order plugins/ruflo-core/
// scripts/ruflo-hook.cjs uses — for globals npm did not install (yarn
// classic, bun). Only a symlinked bin counts, and only when it lands in
// a package that declares one of the three known names, so a wrapper
// script or a version-manager shim (Volta) is never mistaken for an
// install. The <dir>/node_modules/<name> layout npm uses on Windows,
// where ruflo.cmd sits beside node_modules\ruflo (what resolveNpmShim()
// in ruflo-hook.cjs maps), is only consulted on win32: on POSIX every
// manager symlinks, and probing it there would follow a Windows install
// exposed to WSL through the appended Windows PATH.
// Relative PATH entries (`.` or an empty entry) are skipped, so a bare
// `./ruflo` in the working directory is never picked up.
// 3. ~/.npm-global, npm's documented no-sudo prefix, when 1 and 2 both
// missed: the prefix setting may live where this hook's node never
// reads it, and a wrapper script on PATH is skipped by 2.
// `env`/`platform`/`execPath` are parameters so any layout is testable
// from any OS.
function resolveGlobalCliBin(env = process.env, platform = process.platform, execPath = process.execPath) {
const prefix = npmGlobalPrefix(env, platform, execPath);
if (prefix) {
const hit = cliUnderPrefix(prefix, platform);
if (hit) return hit;
}
const dirs = String(env.PATH || env.Path || '').split(platform === 'win32' ? ';' : ':');
const exts = platform === 'win32' ? ['.cmd', ''] : [''];
for (const name of ['ruflo', 'claude-flow']) {
for (const dir of dirs) {
if (!dir || !path.isAbsolute(dir)) continue;
for (const ext of exts) {
const shim = path.join(dir, name + ext);
if (!fs.existsSync(shim)) continue;
const roots = [];
try {
if (fs.lstatSync(shim).isSymbolicLink()) {
const root = path.dirname(path.dirname(fs.realpathSync(shim)));
if (isKnownPackageRoot(root)) roots.push(root);
}
} catch (e) { /* unreadable link — fall back to the layout roots */ }
if (platform === 'win32') {
roots.push(path.join(dir, 'node_modules', name));
if (name === 'claude-flow') roots.push(path.join(dir, 'node_modules', '@claude-flow', 'cli'));
}
for (const root of roots) {
const hit = cliInPackage(root);
if (hit) return hit;
}
}
}
}
// Last resort: ~/.npm-global, the prefix npm's own docs recommend for
// global installs without sudo ("Resolving EACCES permissions errors").
// The setting that points npm there can live where this hook's node never
// looks (another node's builtin npmrc, or a wrapper script that sets it
// per call), and a `ruflo` wrapper script on PATH is skipped above by
// design. Without this, such a machine fell back to npx @latest on every
// hook. Checked only after the configured prefix and PATH both miss, so it
// can never override either.
const home = env.HOME || env.USERPROFILE;
if (home && path.isAbsolute(home)) {
const hit = cliUnderPrefix(path.join(home, '.npm-global'), platform);
if (hit) return hit;
}
return null;
}
// The CLI of a global install under an npm prefix: <prefix>/lib/node_modules
// on POSIX, <prefix>\node_modules on Windows, trying the three published
// package names in order.
function cliUnderPrefix(prefix, platform) {
const modules = platform === 'win32'
? path.join(prefix, 'node_modules')
: path.join(prefix, 'lib', 'node_modules');
for (const pkg of ['ruflo', 'claude-flow', path.join('@claude-flow', 'cli')]) {
const hit = cliInPackage(path.join(modules, pkg));
if (hit) return hit;
}
return null;
}
// A bin on PATH only counts as an install when the package it points into
// declares one of the three published names. This is a narrower check than
// resolveNpmShim() in ruflo-hook.cjs, which never reads `name`: that one takes
// the package name from the shim's own filename, then validates package.json's
// `bin` entry and that its realpath stays inside the package. Here the bin has
// already been resolved to a package root, so the manifest `name` is what
// identifies it.
function isKnownPackageRoot(root) {
try {
const name = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf-8')).name;
return name === 'ruflo' || name === 'claude-flow' || name === '@claude-flow/cli';
} catch (e) { return false; }
}
// Map an installed ruflo / claude-flow / @claude-flow/cli package root to the
// CLI it runs: <root>/bin/cli.js (@claude-flow/cli itself),
// <root>/v3/@claude-flow/cli/bin/cli.js (the claude-flow umbrella bundles
// it), or @claude-flow/cli in the node_modules npm nests inside <root> —
// or, for the flat layout yarn classic and bun use, in the node_modules
// folder that holds <root> itself. That is the dependency edge
// ruflo/bin/ruflo.js's findCliPath() follows. The search stops at that one
// enclosing node_modules: walking further up would reach unrelated trees,
// such as a stray ~/node_modules left by an `npm i` in a home directory.
// Every hit must still pass isRunnableCli().
function cliInPackage(root) {
if (!fs.existsSync(root)) return null;
const candidates = [
path.join(root, 'bin', 'cli.js'),
path.join(root, 'v3', '@claude-flow', 'cli', 'bin', 'cli.js'),
path.join(root, 'node_modules', '@claude-flow', 'cli', 'bin', 'cli.js'),
];
for (let dir = path.dirname(root), i = 0; i < 4 && dir !== path.dirname(dir); i++, dir = path.dirname(dir)) {
if (path.basename(dir) === 'node_modules') {
candidates.push(path.join(dir, '@claude-flow', 'cli', 'bin', 'cli.js'));
break;
}
}
return candidates.find(isRunnableCli) || null;
}
// npm's global prefix without spawning npm (`npm prefix -g` measured 70-80 ms
// per call, paid on every SessionStart that gets this far). Same precedence
// npm applies:
// 1. npm_config_prefix in the environment (npm accepts either case);
// 2. `prefix=` in the user npmrc ($npm_config_userconfig, else ~/.npmrc),
// where `npm config set prefix ~/.npm-global` (the npm docs' fix for
// EACCES on global installs) writes it;
// 3. `prefix=` in npm's builtin npmrc (<npm package>/npmrc), found through
// the `npm` link beside node: Homebrew writes `prefix = /opt/homebrew`
// there (its process.execPath is the Cellar path, so step 4 alone would
// be wrong), and the Node.js Windows installer ships
// `prefix=${APPDATA}\npm`;
// 4. npm's default: $PREFIX, else the directory above node's bin/ (POSIX)
// or node.exe's own directory (Windows).
// Values follow npm's ini handling for the cases that decide a path: a
// quoted value is taken literally, an unquoted `;`/`#` starts a comment,
// keys below a `[section]` header are ignored, `${VAR}` expands when the
// variable is set (and `${VAR?}` to empty when it is not) but otherwise
// stays literal like npm — which leaves the value relative, so the
// isAbsolute() check below rejects it instead of pointing at the drive root.
// Not emulated: escaped characters and npm's other ini corner cases; each of
// those produces a miss, never a wrong CLI. The npmrc is read into memory,
// but only the value of its last top-level `prefix =` line is kept: no other
// line (an `_authToken`, say) is returned, stored or logged.
function npmGlobalPrefix(env = process.env, platform = process.platform, execPath = process.execPath) {
const home = (platform === 'win32' ? env.USERPROFILE : env.HOME) || os.homedir();
const readPrefix = (file) => {
let value = null;
try {
for (const line of fs.readFileSync(file, 'utf-8').split(/\r?\n/)) {
if (/^\s*\[/.test(line)) break; // only the top-level section applies
const m = /^\s*prefix\s*=\s*(.*?)\s*$/.exec(line);
if (m) value = m[1]; // last one wins, as in npm's ini parser
}
} catch (e) { /* no such npmrc */ }
return value;
};
const nodeDir = path.dirname(execPath);
// npm's builtin npmrc sits in npm's own package dir. On Windows that is
// node_modules\npm beside node.exe; elsewhere follow the `npm` link beside
// node (<npm>/bin/npm-cli.js) — on Homebrew it points out of the node keg
// to /opt/homebrew/lib/node_modules/npm.
let builtinRc = path.join(nodeDir, 'node_modules', 'npm', 'npmrc');
if (platform !== 'win32') {
try {
builtinRc = path.join(path.dirname(path.dirname(fs.realpathSync(path.join(nodeDir, 'npm')))), 'npmrc');
} catch (e) { /* no npm beside node */ }
}
const raw = env.npm_config_prefix || env.NPM_CONFIG_PREFIX
|| readPrefix(env.npm_config_userconfig || env.NPM_CONFIG_USERCONFIG || path.join(home, '.npmrc'))
|| readPrefix(builtinRc)
|| env.PREFIX
|| (platform === 'win32' ? nodeDir : path.dirname(nodeDir));
let value = String(raw).trim();
const quoted = /^(["'])([\s\S]*)\1$/.exec(value);
if (quoted) value = quoted[2];
else value = value.replace(/(^|[^\\])[;#].*$/, '$1').trim();
value = value
.replace(/\$\{([^}?]+)(\?)?\}/g, (whole, name, optional) => (
env[name] !== undefined ? env[name] : (optional ? '' : whole)
))
.replace(/^~(?=$|[\\/])/, home);
return path.isAbsolute(value) ? path.resolve(value) : null;
}
// Fire-and-forget doesn't work when the CALLER is itself a short-lived
// subprocess (confirmed live: two consecutive statusline renders 5s apart
// both saw an empty funnel-messages-cache, because the async HTTPS fetch
@@ -74,8 +288,8 @@ function resolveCliBinForHook() {
// chance to write the cache. Never awaited here — must not add to
// SessionStart's own timeout budget.
//
// No usable local candidate (resolveCliBinForHook() returned null) falls
// back to npx: this call is detached/unref'd, so a slower npx cold-start
// No usable local or global candidate (resolveCliBinForHook() returned null)
// falls back to npx: this call is detached/unref'd, so a slower npx cold-start
// costs nothing perceptible — unlike the statusline's own synchronous
// render path, where local-first exists purely for per-render latency.
// `--prefer-offline` avoids a registry round trip for the tarball when
@@ -1060,4 +1274,7 @@ if (require.main === module) {
// Which sibling helpers loaded (all CommonJS, shipped as .cjs — #3555).
const loadedHelpers = { router: !!router, session: !!session, memory: !!memory, intelligence: !!intelligence };
module.exports = { runWithTimeout, INTELLIGENCE_TIMEOUT_MS, loadedHelpers, ownedByMod, MOD_OWNABLE_EVENTS, resolveCliBinForHook, cliSpawnArgs, helperRoot, scanSessionStartSettingsForRisk, srFormatRiskFindingsForAutomaticBoot, srScanSettingsForRisk, srScanCommandStringForRisk, srScanAllowRuleForRisk, SR_DANGEROUS_COMMAND_WORDS, SR_SHELL_EXEC_WORDS, SR_DOWNLOADER_WORDS, SR_STANDALONE_RISK_PATTERNS };
// resolveGlobalCliBin/npmGlobalPrefix are exported for
// hook-handler-global-cli.test.ts; resolveCliBinForHook for anyone checking
// what a given machine resolves to without running a hook (#3368).
module.exports = { runWithTimeout, INTELLIGENCE_TIMEOUT_MS, loadedHelpers, ownedByMod, MOD_OWNABLE_EVENTS, resolveCliBinForHook, resolveGlobalCliBin, npmGlobalPrefix, cliSpawnArgs, helperRoot, scanSessionStartSettingsForRisk, srFormatRiskFindingsForAutomaticBoot, srScanSettingsForRisk, srScanCommandStringForRisk, srScanAllowRuleForRisk, SR_DANGEROUS_COMMAND_WORDS, SR_SHELL_EXEC_WORDS, SR_DOWNLOADER_WORDS, SR_STANDALONE_RISK_PATTERNS };
@@ -0,0 +1,394 @@
/**
* hook-handler.cjs must find a globally-installed CLI before falling back to
* npx (#3368).
*
* `npm install -g ruflo` (the README's global install) puts the CLI at
* <prefix>/lib/node_modules/ruflo/node_modules/@claude-flow/cli, which none
* of resolveCliBinForHook()'s project-relative candidates cover. Every
* SessionStart on such a machine therefore spawned
* `npx --prefer-offline --prefix <helper root> @claude-flow/cli hooks refresh-*`: a second copy of
* the CLI that follows the registry's latest instead of the pinned version,
* and cannot start offline on a cold npx cache.
*
* Each end-to-end case builds a fake install layout in a temp dir, puts an
* `npx` trap first on PATH (it only logs its argv; the real npx is never
* reachable), runs the hook exactly as Claude Code's SessionStart does, and
* asserts which binary the two detached refreshes actually ran. The
* "resolves" cases fail on the unfixed helper (both refreshes hit the npx
* trap); the "still" cases pin behaviour the fix must not change.
*
* Hermetic: every case points HOME at an empty dir and sets npm's prefix
* explicitly (npm_config_prefix, or a sandbox ~/.npmrc), so the host's own
* npm config and global installs can never leak into a result.
*/
import { describe, it, expect, afterAll } from 'vitest';
import { spawnSync } from 'node:child_process';
import { createRequire } from 'node:module';
import {
chmodSync, copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync,
realpathSync, rmSync, symlinkSync, writeFileSync,
} from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const here = path.dirname(fileURLToPath(import.meta.url));
// The shipped copy; hook-handler-artifact-parity.test.ts keeps the repo's
// dogfood copy byte-identical to it.
const HOOK_HANDLER = path.resolve(here, '../.claude/helpers/hook-handler.cjs');
const sandboxes: string[] = [];
afterAll(() => {
for (const dir of sandboxes) rmSync(dir, { recursive: true, force: true });
});
interface Sandbox { root: string; home: string; project: string; hook: string; trap: string; noPrefix: string }
function sandbox(): Sandbox {
// realpath: macOS tmpdir() is a symlink (/var -> /private/var), and the
// resolver reports realpath'd locations.
const root = realpathSync(mkdtempSync(path.join(tmpdir(), 'hook-global-cli-')));
sandboxes.push(root);
const home = path.join(root, 'home'); // empty: no ~/.npmrc, no ~/.claude/plugins checkout
const project = path.join(root, 'project');
const helpers = path.join(project, '.claude', 'helpers');
const trap = path.join(root, 'trap');
const noPrefix = path.join(root, 'empty-npm-prefix');
for (const d of [home, helpers, trap, noPrefix]) mkdirSync(d, { recursive: true });
const hook = path.join(helpers, 'hook-handler.cjs');
copyFileSync(HOOK_HANDLER, hook);
writeFileSync(path.join(trap, 'npx'), '#!/bin/sh\nprintf "%s\\n" "$*" >> "$NPX_LOG"\n');
chmodSync(path.join(trap, 'npx'), 0o755);
return { root, home, project, hook, trap, noPrefix };
}
function write(file: string, content: string, mode?: number) {
mkdirSync(path.dirname(file), { recursive: true });
writeFileSync(file, content);
if (mode) chmodSync(file, mode);
}
/** A built @claude-flow/cli whose bin/cli.js records how it was invoked. */
function fakeCli(dir: string, { built = true } = {}): string {
const bin = path.join(dir, 'bin', 'cli.js');
write(path.join(dir, 'package.json'), '{"name":"@claude-flow/cli","version":"3.42.4"}');
write(bin,
"require('fs').appendFileSync(process.env.CLI_LOG, " +
"JSON.stringify({ bin: __filename, args: process.argv.slice(2) }) + '\\n');\n", 0o755);
if (built) write(path.join(dir, 'dist', 'src', 'index.js'), 'export {};\n');
return bin;
}
/** A package with a bin entry, linked from binDir the way npm/yarn/bun link it. */
function linkedPackage(pkgDir: string, name: string, entry: string, binDir: string) {
write(path.join(pkgDir, 'package.json'), JSON.stringify({ name }));
if (!existsSync(path.join(pkgDir, entry))) write(path.join(pkgDir, entry), '// entry\n', 0o755);
mkdirSync(binDir, { recursive: true });
const command = name === 'ruflo' ? 'ruflo' : 'claude-flow';
symlinkSync(path.relative(binDir, path.join(pkgDir, entry)), path.join(binDir, command));
}
/** `npm install -g ruflo` under `prefix`: bin/ruflo -> lib/node_modules/ruflo, CLI nested. */
function npmGlobalRuflo(prefix: string, opts?: { built?: boolean }): string {
const pkg = path.join(prefix, 'lib', 'node_modules', 'ruflo');
const cli = fakeCli(path.join(pkg, 'node_modules', '@claude-flow', 'cli'), opts);
linkedPackage(pkg, 'ruflo', 'bin/ruflo.js', path.join(prefix, 'bin'));
return cli;
}
type Env = Record<string, string | undefined>;
async function runSessionRestore(sb: Sandbox, pathDirs: string[], env: Env = {}) {
const npxLog = path.join(sb.root, 'npx.log');
const cliLog = path.join(sb.root, 'cli.log');
const fullEnv: Env = {
HOME: sb.home,
USERPROFILE: sb.home,
PATH: [sb.trap, ...pathDirs].join(path.delimiter),
npm_config_prefix: sb.noPrefix, // overridden (or removed) per case
RUFLO_NO_AUTO_ENABLE: '1',
NPX_LOG: npxLog,
CLI_LOG: cliLog,
...env,
};
for (const k of Object.keys(fullEnv)) if (fullEnv[k] === undefined) delete fullEnv[k];
const run = spawnSync(process.execPath, [sb.hook, 'session-restore'], {
cwd: sb.project, input: '', encoding: 'utf8', timeout: 15_000, env: fullEnv as NodeJS.ProcessEnv,
});
expect(run.status).toBe(0);
const read = (f: string) => (existsSync(f) ? readFileSync(f, 'utf8').trim().split('\n').filter(Boolean) : []);
// Both refreshes are detached + unref'd; wait for them to record.
for (let waited = 0; waited < 10_000; waited += 50) {
if (read(npxLog).length + read(cliLog).length >= 2) break;
await new Promise((r) => setTimeout(r, 50));
}
return { npx: read(npxLog), cli: read(cliLog).map((l) => JSON.parse(l) as { bin: string; args: string[] }) };
}
type Result = Awaited<ReturnType<typeof runSessionRestore>>;
function expectRanCli(result: Result, bin: string) {
expect(result.npx, 'no refresh may fall back to npx').toEqual([]);
expect(result.cli.map((c) => c.bin)).toEqual([bin, bin]);
expect(result.cli.map((c) => c.args)).toEqual(expect.arrayContaining([
['hooks', 'refresh-funnel', '--quiet'],
['hooks', 'refresh-advisor', '--quiet'],
]));
}
// The npx fallback resolves from the helper's own install root, which for
// the sandbox's project-level helper is the project (cliSpawnArgs()).
function expectFellBackToNpx(result: Result, sb: Sandbox) {
expect(result.cli).toEqual([]);
expect(result.npx.sort()).toEqual([
`--prefer-offline --prefix ${sb.project} @claude-flow/cli hooks refresh-advisor --quiet`,
`--prefer-offline --prefix ${sb.project} @claude-flow/cli hooks refresh-funnel --quiet`,
]);
}
// Symlinks and a /bin/sh trap: these are the POSIX layouts. The Windows npm
// layout is covered below through resolveGlobalCliBin() directly.
describe.skipIf(process.platform === 'win32')('hook-handler.cjs — global CLI before npx (#3368)', () => {
describe('via npm\'s global prefix', () => {
it('resolves `npm i -g ruflo` with the prefix set in ~/.npmrc and its bin/ NOT on PATH (the reported setup)', { timeout: 30_000 }, async () => {
const sb = sandbox();
const prefix = path.join(sb.root, 'npm-global');
const cli = npmGlobalRuflo(prefix);
write(path.join(sb.home, '.npmrc'), `prefix=${prefix}\n`);
expectRanCli(await runSessionRestore(sb, [], { npm_config_prefix: undefined }), cli);
});
it('resolves `npm i -g claude-flow` (umbrella bundles v3/@claude-flow/cli; its own bin/cli.js has no dist)', { timeout: 30_000 }, async () => {
const sb = sandbox();
const prefix = path.join(sb.root, 'npm-global');
const pkg = path.join(prefix, 'lib', 'node_modules', 'claude-flow');
const cli = fakeCli(path.join(pkg, 'v3', '@claude-flow', 'cli'));
write(path.join(pkg, 'bin', 'cli.js'), '// umbrella proxy; must not be picked (no dist beside it)\n', 0o755);
expectRanCli(await runSessionRestore(sb, [], { npm_config_prefix: prefix }), cli);
});
it('resolves `npm i -g @claude-flow/cli` (the package is the CLI itself)', { timeout: 30_000 }, async () => {
const sb = sandbox();
const prefix = path.join(sb.root, 'npm-global');
const cli = fakeCli(path.join(prefix, 'lib', 'node_modules', '@claude-flow', 'cli'));
expectRanCli(await runSessionRestore(sb, [], { NPM_CONFIG_PREFIX: prefix, npm_config_prefix: undefined }), cli);
});
});
describe('via PATH', () => {
it('resolves a `ruflo` on PATH installed under a prefix npm is no longer configured for', { timeout: 30_000 }, async () => {
const sb = sandbox();
const other = path.join(sb.root, 'other-prefix');
const cli = npmGlobalRuflo(other);
expectRanCli(await runSessionRestore(sb, [path.join(other, 'bin')]), cli);
});
it('resolves a yarn/bun global (dependency hoisted beside ruflo, not nested)', { timeout: 30_000 }, async () => {
const sb = sandbox();
const globalDir = path.join(sb.root, 'bun', 'install', 'global', 'node_modules');
const cli = fakeCli(path.join(globalDir, '@claude-flow', 'cli'));
linkedPackage(path.join(globalDir, 'ruflo'), 'ruflo', 'bin/ruflo.js', path.join(sb.root, 'bun', 'bin'));
expectRanCli(await runSessionRestore(sb, [path.join(sb.root, 'bun', 'bin')]), cli);
});
});
describe('via ~/.npm-global (npm\'s documented no-sudo prefix)', () => {
it('resolves ~/.npm-global when no npmrc points there and the only `ruflo` on PATH is a wrapper script', { timeout: 30_000 }, async () => {
const sb = sandbox();
const cli = npmGlobalRuflo(path.join(sb.home, '.npm-global'));
// A wrapper that pins node and runs the install directly: a regular
// file, so the PATH lookup skips it by design.
const wrapBin = path.join(sb.root, 'wrapper-bin');
write(path.join(wrapBin, 'ruflo'), '#!/bin/sh\nexec node "$HOME/.npm-global/lib/node_modules/ruflo/bin/ruflo.js" "$@"\n', 0o755);
expectRanCli(await runSessionRestore(sb, [wrapBin]), cli);
});
});
describe('unchanged behaviour', () => {
it('still prefers a project-local install over a global one', { timeout: 30_000 }, async () => {
const sb = sandbox();
const local = fakeCli(path.join(sb.project, 'node_modules', '@claude-flow', 'cli'));
const prefix = path.join(sb.root, 'npm-global');
npmGlobalRuflo(prefix);
expectRanCli(await runSessionRestore(sb, [path.join(prefix, 'bin')], { npm_config_prefix: prefix }), local);
});
it('still falls back to npx when the global CLI has no compiled dist', { timeout: 30_000 }, async () => {
const sb = sandbox();
const prefix = path.join(sb.root, 'npm-global');
npmGlobalRuflo(prefix, { built: false });
expectFellBackToNpx(await runSessionRestore(sb, [path.join(prefix, 'bin')], { npm_config_prefix: prefix }), sb);
});
it('still falls back to npx when there is no global install at all', { timeout: 30_000 }, async () => {
const sb = sandbox();
expectFellBackToNpx(await runSessionRestore(sb, []), sb);
});
it('never trusts a relative PATH entry, so a bare `./ruflo` is not picked up', { timeout: 30_000 }, async () => {
const sb = sandbox();
const pkg = path.join(sb.project, 'vendor', 'ruflo');
fakeCli(path.join(pkg, 'node_modules', '@claude-flow', 'cli'));
linkedPackage(pkg, 'ruflo', 'bin/ruflo.js', sb.project); // ./ruflo in the project root
expectFellBackToNpx(await runSessionRestore(sb, ['.']), sb);
});
});
});
describe('hook-handler.cjs — resolveGlobalCliBin() / npmGlobalPrefix() units (#3368)', () => {
// Load a sandbox copy: it has no router/session/intelligence siblings to
// side-effect on require, and require() never runs main() (require.main guard).
function load(sb: Sandbox) {
return createRequire(import.meta.url)(sb.hook) as {
resolveGlobalCliBin: (env: Env, platform?: string, execPath?: string) => string | null;
npmGlobalPrefix: (env: Env, platform?: string, execPath?: string) => string | null;
};
}
// npm on Windows puts ruflo.cmd (plus sh/ps1 shims) straight into the
// prefix, beside node_modules\ruflo, with no lib/ level and nothing to
// realpath. Same layout resolveNpmShim() in ruflo-hook.cjs maps.
it('maps the Windows npm layout via the prefix and via ruflo.cmd on PATH', () => {
const sb = sandbox();
const { resolveGlobalCliBin } = load(sb);
const prefix = path.join(sb.root, 'AppData', 'Roaming', 'npm');
write(path.join(prefix, 'ruflo.cmd'), '@ECHO off\r\n');
const cli = fakeCli(path.join(prefix, 'node_modules', 'ruflo', 'node_modules', '@claude-flow', 'cli'));
const base = { USERPROFILE: sb.home };
expect(resolveGlobalCliBin({ ...base, npm_config_prefix: prefix }, 'win32')).toBe(cli);
expect(resolveGlobalCliBin({ ...base, npm_config_prefix: sb.noPrefix, Path: prefix }, 'win32')).toBe(cli);
expect(resolveGlobalCliBin({ ...base, npm_config_prefix: sb.noPrefix, PATH: sb.home }, 'win32')).toBeNull();
});
it('reads the prefix the way npm does: env over ~/.npmrc, last line wins, ~ and ${VAR} expanded', () => {
const sb = sandbox();
const { npmGlobalPrefix } = load(sb);
const npmrc = path.join(sb.home, '.npmrc');
const env = { HOME: sb.home, BASE: '/opt/base' };
write(npmrc, '; comment\n//registry.npmjs.org/:_authToken=secret\nprefix=/first\nprefix = ~/.npm-global\n');
expect(npmGlobalPrefix(env, 'darwin')).toBe(path.join(sb.home, '.npm-global'));
expect(npmGlobalPrefix({ ...env, npm_config_prefix: '/from-env' }, 'darwin')).toBe(path.resolve('/from-env'));
expect(npmGlobalPrefix({ ...env, NPM_CONFIG_PREFIX: '/from-env-upper' }, 'darwin')).toBe(path.resolve('/from-env-upper'));
write(npmrc, 'prefix="${BASE}/npm"\r\n');
expect(npmGlobalPrefix(env, 'darwin')).toBe(path.resolve('/opt/base/npm'));
const custom = path.join(sb.root, 'custom-npmrc');
write(custom, 'prefix=/via-userconfig\n');
expect(npmGlobalPrefix({ ...env, npm_config_userconfig: custom }, 'darwin')).toBe(path.resolve('/via-userconfig'));
expect(npmGlobalPrefix({ ...env, npm_config_prefix: 'relative/dir' }, 'darwin')).toBeNull();
});
it('keeps an undefined ${VAR} literal, as npm does, instead of anchoring the path at the root', () => {
const sb = sandbox();
const { npmGlobalPrefix } = load(sb);
const env = { HOME: sb.home };
write(path.join(sb.home, '.npmrc'), 'prefix=${NOT_SET}/npm-global\n');
// Left literal the value is relative, so it is rejected — not turned into
// "/npm-global" next to the drive root.
expect(npmGlobalPrefix(env, 'darwin')).not.toBe(path.resolve('/npm-global'));
expect(npmGlobalPrefix(env, 'darwin')).toBeNull();
// `${VAR?}` is npm's "empty if unset" form.
write(path.join(sb.home, '.npmrc'), 'prefix=/opt${NOT_SET?}/npm\n');
expect(npmGlobalPrefix(env, 'darwin')).toBe(path.resolve('/opt/npm'));
});
it('ignores an inline comment and any key below a [section] header', () => {
const sb = sandbox();
const { npmGlobalPrefix } = load(sb);
const env = { HOME: sb.home };
write(path.join(sb.home, '.npmrc'), 'prefix=/opt/one ; trailing comment\n');
expect(npmGlobalPrefix(env, 'darwin')).toBe(path.resolve('/opt/one'));
write(path.join(sb.home, '.npmrc'), 'prefix=/opt/one\n[scope]\nprefix=/opt/sectioned\n');
expect(npmGlobalPrefix(env, 'darwin')).toBe(path.resolve('/opt/one'));
});
// Steps 3 and 4 of npmGlobalPrefix(): the builtin npmrc in npm's own
// package (Homebrew keeps npm outside the node keg, which is why
// process.execPath alone is not enough) and npm's default prefix.
it('follows the `npm` link beside node to npm\'s builtin npmrc (the Homebrew keg case)', () => {
const sb = sandbox();
const { npmGlobalPrefix, resolveGlobalCliBin } = load(sb);
const keg = path.join(sb.root, 'Cellar', 'node', '26.8.2');
const prefix = path.join(sb.root, 'brew-prefix');
const npmPkg = path.join(prefix, 'lib', 'node_modules', 'npm');
write(path.join(npmPkg, 'bin', 'npm-cli.js'), '// npm\n');
write(path.join(keg, 'bin', 'node'), '// node\n', 0o755);
symlinkSync(path.join(npmPkg, 'bin', 'npm-cli.js'), path.join(keg, 'bin', 'npm'));
write(path.join(npmPkg, 'npmrc'), `prefix = ${prefix}\n`);
const execPath = path.join(keg, 'bin', 'node');
const env = { HOME: sb.home };
expect(npmGlobalPrefix(env, 'darwin', execPath)).toBe(prefix);
// …and the whole lookup finds a global install with no env hint and no PATH.
const cli = npmGlobalRuflo(prefix);
expect(resolveGlobalCliBin(env, 'darwin', execPath)).toBe(cli);
});
it('falls back to $PREFIX, then to the directory above node/bin, when no npmrc sets one', () => {
const sb = sandbox();
const { npmGlobalPrefix } = load(sb);
const execPath = path.join(sb.root, 'nvm', 'v26', 'bin', 'node');
write(execPath, '// node\n', 0o755);
expect(npmGlobalPrefix({ HOME: sb.home }, 'darwin', execPath)).toBe(path.join(sb.root, 'nvm', 'v26'));
expect(npmGlobalPrefix({ HOME: sb.home, PREFIX: path.join(sb.root, 'from-prefix-env') }, 'darwin', execPath))
.toBe(path.join(sb.root, 'from-prefix-env'));
});
it('reads the Windows builtin npmrc beside node.exe and expands ${APPDATA}', () => {
const sb = sandbox();
const { npmGlobalPrefix } = load(sb);
const nodeDir = path.join(sb.root, 'Program Files', 'nodejs');
write(path.join(nodeDir, 'node_modules', 'npm', 'npmrc'), 'prefix=${APPDATA}\\npm\n');
const appData = path.join(sb.root, 'AppData', 'Roaming');
const execPath = path.join(nodeDir, 'node.exe');
// Windows path separators only resolve on win32, so assert that the
// ${APPDATA} expansion happened rather than the exact joined form.
expect(npmGlobalPrefix({ USERPROFILE: sb.home, APPDATA: appData }, 'win32', execPath))
.toContain(appData);
// With APPDATA unset the value stays literal, so it is relative and rejected.
expect(npmGlobalPrefix({ USERPROFILE: sb.home }, 'win32', execPath)).toBeNull();
});
// The PATH lookup only trusts a symlink into a package that names itself.
it('ignores a `ruflo` on PATH that is a wrapper script or a version-manager shim', () => {
const sb = sandbox();
const { resolveGlobalCliBin } = load(sb);
const env = { HOME: sb.home, npm_config_prefix: sb.noPrefix };
// A built CLI sitting in a stray node_modules above the shim's target,
// e.g. left by an `npm i @claude-flow/cli` in a home directory.
fakeCli(path.join(sb.root, 'stray', 'node_modules', '@claude-flow', 'cli'));
const binDir = path.join(sb.root, 'stray', 'volta', 'bin');
mkdirSync(binDir, { recursive: true });
write(path.join(sb.root, 'stray', 'volta', 'volta-shim'), '#!/bin/sh\n', 0o755);
symlinkSync(path.join(sb.root, 'stray', 'volta', 'volta-shim'), path.join(binDir, 'ruflo'));
expect(resolveGlobalCliBin({ ...env, PATH: binDir }, 'darwin')).toBeNull();
});
// WSL appends the Windows PATH, which includes %APPDATA%\npm — where npm
// writes an extensionless `ruflo` sh shim beside node_modules\ruflo.
it('does not apply the Windows folder layout on POSIX (WSL keeps its own CLI)', () => {
const sb = sandbox();
const { resolveGlobalCliBin } = load(sb);
const winPrefix = path.join(sb.root, 'mnt', 'c', 'Users', 'me', 'AppData', 'Roaming', 'npm');
write(path.join(winPrefix, 'ruflo'), '#!/bin/sh\n', 0o755); // npm's sh shim, not a symlink
fakeCli(path.join(winPrefix, 'node_modules', 'ruflo', 'node_modules', '@claude-flow', 'cli'));
const env = { HOME: sb.home, npm_config_prefix: sb.noPrefix, PATH: winPrefix };
expect(resolveGlobalCliBin(env, 'linux')).toBeNull();
expect(resolveGlobalCliBin(env, 'win32')).not.toBeNull();
});
it('uses ~/.npm-global only after the configured prefix and PATH both miss', () => {
const sb = sandbox();
const { resolveGlobalCliBin } = load(sb);
const fallback = npmGlobalRuflo(path.join(sb.home, '.npm-global'));
const configured = path.join(sb.root, 'configured-prefix');
const preferred = npmGlobalRuflo(configured);
expect(resolveGlobalCliBin({ HOME: sb.home, npm_config_prefix: configured, PATH: '' }, 'darwin')).toBe(preferred);
expect(resolveGlobalCliBin({ HOME: sb.home, npm_config_prefix: sb.noPrefix, PATH: '' }, 'darwin')).toBe(fallback);
});
it('ignores a ~/.npm-global install whose CLI has no compiled dist', () => {
const sb = sandbox();
const { resolveGlobalCliBin } = load(sb);
npmGlobalRuflo(path.join(sb.home, '.npm-global'), { built: false });
expect(resolveGlobalCliBin({ HOME: sb.home, npm_config_prefix: sb.noPrefix, PATH: '' }, 'darwin')).toBeNull();
});
});