mirror of
https://github.com/ruvnet/ruflo.git
synced 2026-10-23 03:47:00 +09:00
* 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:
4 files changed
+853
-25
No files matched your search
@@ -24,9 +24,10 @@ const helpersDir = __dirname;
|
|||||||
const helperRoot = path.resolve(helpersDir, '..', '..');
|
const helperRoot = path.resolve(helpersDir, '..', '..');
|
||||||
|
|
||||||
// Resolve an installed @claude-flow/cli (or ruflo) bin — mirrors
|
// Resolve an installed @claude-flow/cli (or ruflo) bin — mirrors
|
||||||
// statusline-generator.ts's resolveCliBin() candidate list. Used only to
|
// statusline-generator.ts's resolveCliBin() candidate list, then looks for a
|
||||||
// spawn the detached funnel-refresh helper below; failures are silent (no
|
// global install (resolveGlobalCliBin() below, #3368). Used only to spawn the
|
||||||
// candidate found just means the refresh never fires this session).
|
// 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
|
// Verifies dist/src/index.js exists alongside bin/cli.js, not just the bin
|
||||||
// itself — Claude Code's own plugin marketplace mechanism installs by
|
// 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,
|
// first every time and spawnDetachedFunnelRefresh() below had no fallback,
|
||||||
// so the promo/disclosure row could never populate for any marketplace
|
// so the promo/disclosure row could never populate for any marketplace
|
||||||
// install, on any OS.
|
// 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() {
|
function resolveCliBinForHook() {
|
||||||
|
if (cliBinCache === undefined) cliBinCache = resolveCliBinUncached();
|
||||||
|
return cliBinCache;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveCliBinUncached() {
|
||||||
try {
|
try {
|
||||||
const home = os.homedir();
|
const home = os.homedir();
|
||||||
const candidates = [
|
const candidates = [
|
||||||
@@ -53,16 +64,219 @@ function resolveCliBinForHook() {
|
|||||||
path.join(helpersDir, '..', '..', 'bin', 'cli.js'),
|
path.join(helpersDir, '..', '..', 'bin', 'cli.js'),
|
||||||
];
|
];
|
||||||
for (const p of candidates) {
|
for (const p of candidates) {
|
||||||
try {
|
if (isRunnableCli(p)) return p;
|
||||||
if (fs.existsSync(p) && fs.existsSync(path.join(path.dirname(p), '..', 'dist', 'src', 'index.js'))) {
|
|
||||||
return p;
|
|
||||||
}
|
|
||||||
} catch (e) { /* try next candidate */ }
|
|
||||||
}
|
}
|
||||||
|
return resolveGlobalCliBin();
|
||||||
} catch (e) { /* ignore */ }
|
} catch (e) { /* ignore */ }
|
||||||
return null;
|
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
|
// Fire-and-forget doesn't work when the CALLER is itself a short-lived
|
||||||
// subprocess (confirmed live: two consecutive statusline renders 5s apart
|
// subprocess (confirmed live: two consecutive statusline renders 5s apart
|
||||||
// both saw an empty funnel-messages-cache, because the async HTTPS fetch
|
// 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
|
// chance to write the cache. Never awaited here — must not add to
|
||||||
// SessionStart's own timeout budget.
|
// SessionStart's own timeout budget.
|
||||||
//
|
//
|
||||||
// No usable local candidate (resolveCliBinForHook() returned null) falls
|
// No usable local or global candidate (resolveCliBinForHook() returned null)
|
||||||
// back to npx: this call is detached/unref'd, so a slower npx cold-start
|
// 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
|
// costs nothing perceptible — unlike the statusline's own synchronous
|
||||||
// render path, where local-first exists purely for per-render latency.
|
// render path, where local-first exists purely for per-render latency.
|
||||||
// `--prefer-offline` avoids a registry round trip for the tarball when
|
// `--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).
|
// Which sibling helpers loaded (all CommonJS, shipped as .cjs — #3555).
|
||||||
const loadedHelpers = { router: !!router, session: !!session, memory: !!memory, intelligence: !!intelligence };
|
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": {
|
"manifest": {
|
||||||
"version": "3.57.1",
|
"version": "3.59.12",
|
||||||
"files": {
|
"files": {
|
||||||
"auto-memory-hook.mjs": "631fac9d518835e2852a406888316bbbddddb49e7ed8e406d2e971f46833df9a",
|
"auto-memory-hook.mjs": "631fac9d518835e2852a406888316bbbddddb49e7ed8e406d2e971f46833df9a",
|
||||||
"hook-handler.cjs": "bc12b3cae92d5a7c8deef699fa00d0ef07b57625c18dbeac4387f15725b8e09f",
|
"hook-handler.cjs": "24502a3bcb78770971147940515c992e35b6736ae14ee2dc54e07d4cdef6a1d8",
|
||||||
"intelligence.cjs": "1d981d6c6a35d70a66450e96458e26169e0cede11523ea246a2c129c197a5b4e",
|
"intelligence.cjs": "1d981d6c6a35d70a66450e96458e26169e0cede11523ea246a2c129c197a5b4e",
|
||||||
"statusline.cjs": "d72417ecb38634011a19f095a30e07b9929e6f97663520172ece9be7491b2eaa",
|
"statusline.cjs": "d72417ecb38634011a19f095a30e07b9929e6f97663520172ece9be7491b2eaa",
|
||||||
"router.cjs": "16648b3c0d73b6681ac2d8d13def6a68286c57f5a11004de5b19a0fa088c833d"
|
"router.cjs": "16648b3c0d73b6681ac2d8d13def6a68286c57f5a11004de5b19a0fa088c833d"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"signature": "6ArQqTEyowza1Ku34wtreG6UuyiY7XPdn99FyFK4LVAN8xvgFr+LzlfnP/Gk3wLd2ZhG0oBAELAUS00TuFOLBA==",
|
"signature": "3W2msAbKXT6nAWSCviEGsLg0KdwT5VcWkBTndXg9RgKY56YkGiwjE8sL0eBI/m5iaGbdHqUOQVbibaBNJkdQAg==",
|
||||||
"algorithm": "ed25519"
|
"algorithm": "ed25519"
|
||||||
}
|
}
|
||||||
@@ -24,9 +24,10 @@ const helpersDir = __dirname;
|
|||||||
const helperRoot = path.resolve(helpersDir, '..', '..');
|
const helperRoot = path.resolve(helpersDir, '..', '..');
|
||||||
|
|
||||||
// Resolve an installed @claude-flow/cli (or ruflo) bin — mirrors
|
// Resolve an installed @claude-flow/cli (or ruflo) bin — mirrors
|
||||||
// statusline-generator.ts's resolveCliBin() candidate list. Used only to
|
// statusline-generator.ts's resolveCliBin() candidate list, then looks for a
|
||||||
// spawn the detached funnel-refresh helper below; failures are silent (no
|
// global install (resolveGlobalCliBin() below, #3368). Used only to spawn the
|
||||||
// candidate found just means the refresh never fires this session).
|
// 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
|
// Verifies dist/src/index.js exists alongside bin/cli.js, not just the bin
|
||||||
// itself — Claude Code's own plugin marketplace mechanism installs by
|
// 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,
|
// first every time and spawnDetachedFunnelRefresh() below had no fallback,
|
||||||
// so the promo/disclosure row could never populate for any marketplace
|
// so the promo/disclosure row could never populate for any marketplace
|
||||||
// install, on any OS.
|
// 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() {
|
function resolveCliBinForHook() {
|
||||||
|
if (cliBinCache === undefined) cliBinCache = resolveCliBinUncached();
|
||||||
|
return cliBinCache;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveCliBinUncached() {
|
||||||
try {
|
try {
|
||||||
const home = os.homedir();
|
const home = os.homedir();
|
||||||
const candidates = [
|
const candidates = [
|
||||||
@@ -53,16 +64,219 @@ function resolveCliBinForHook() {
|
|||||||
path.join(helpersDir, '..', '..', 'bin', 'cli.js'),
|
path.join(helpersDir, '..', '..', 'bin', 'cli.js'),
|
||||||
];
|
];
|
||||||
for (const p of candidates) {
|
for (const p of candidates) {
|
||||||
try {
|
if (isRunnableCli(p)) return p;
|
||||||
if (fs.existsSync(p) && fs.existsSync(path.join(path.dirname(p), '..', 'dist', 'src', 'index.js'))) {
|
|
||||||
return p;
|
|
||||||
}
|
|
||||||
} catch (e) { /* try next candidate */ }
|
|
||||||
}
|
}
|
||||||
|
return resolveGlobalCliBin();
|
||||||
} catch (e) { /* ignore */ }
|
} catch (e) { /* ignore */ }
|
||||||
return null;
|
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
|
// Fire-and-forget doesn't work when the CALLER is itself a short-lived
|
||||||
// subprocess (confirmed live: two consecutive statusline renders 5s apart
|
// subprocess (confirmed live: two consecutive statusline renders 5s apart
|
||||||
// both saw an empty funnel-messages-cache, because the async HTTPS fetch
|
// 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
|
// chance to write the cache. Never awaited here — must not add to
|
||||||
// SessionStart's own timeout budget.
|
// SessionStart's own timeout budget.
|
||||||
//
|
//
|
||||||
// No usable local candidate (resolveCliBinForHook() returned null) falls
|
// No usable local or global candidate (resolveCliBinForHook() returned null)
|
||||||
// back to npx: this call is detached/unref'd, so a slower npx cold-start
|
// 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
|
// costs nothing perceptible — unlike the statusline's own synchronous
|
||||||
// render path, where local-first exists purely for per-render latency.
|
// render path, where local-first exists purely for per-render latency.
|
||||||
// `--prefer-offline` avoids a registry round trip for the tarball when
|
// `--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).
|
// Which sibling helpers loaded (all CommonJS, shipped as .cjs — #3555).
|
||||||
const loadedHelpers = { router: !!router, session: !!session, memory: !!memory, intelligence: !!intelligence };
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in new issue
Block a user