mirror of
https://github.com/ruvnet/ruflo.git
synced 2026-10-23 03:47:00 +09:00
fix(helpers): resolve the ruflo CLI from the helper root, not the project
A home-level helper or a user-level plugin hook serves every opened project.
hook-handler.cjs and statusline.cjs executed a CLI found under the project
(<cwd>/node_modules/{@claude-flow/cli,ruflo}, <cwd>/v3/...), and every npx
fallback ran with the project as cwd, where npx prefers the project's own
install. The plugin shims also resolved `ruflo` through relative PATH
entries, which resolve against the cwd.
- hook-handler.cjs / statusline.cjs (package and repo copies): candidates
are built from the helper's install root; for a project-local helper that
is the project, so its behaviour is unchanged.
- npx fallbacks pass `--prefix <helper root>` (generated hook-handler and
ruflo-hook.cjs too) or, in the plugin shims, `--prefix <plugin data dir or
home>`. The spawned CLI still runs with the project as its cwd.
- ruflo-hook.cjs (all four copies): relative/empty PATH entries are skipped.
Test: helper-cli-resolution-trust.test.ts (11 cases, 10 fail on main);
hook-handler-artifact-parity follows the npx args into cliSpawnArgs().
The signed helpers changed, so the helpers manifest needs re-signing.
This commit is contained in:
1 parent
24774b9087
commit
e625b7104a
10 files changed
+1600
-1370
No files matched your search
@@ -79,6 +79,15 @@ function envValue(env, name) {
|
||||
return key ? env[key] : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the npx fallback resolves ruflo from: the plugin's data dir or the
|
||||
* user's home, never the cwd (the opened project). `--prefix` only changes
|
||||
* package resolution; the CLI still runs with the project as its cwd.
|
||||
*/
|
||||
function npxPrefix(env = process.env) {
|
||||
return envValue(env, 'CLAUDE_PLUGIN_DATA') || envValue(env, 'PLUGIN_DATA') || require('os').homedir();
|
||||
}
|
||||
|
||||
/**
|
||||
* Locate a command on PATH using fs only.
|
||||
*
|
||||
@@ -98,6 +107,9 @@ function resolveCommandPath(command, env = process.env, platform = process.platf
|
||||
? (envValue(env, 'PATHEXT') || '.COM;.EXE;.BAT;.CMD').split(';')
|
||||
: [''];
|
||||
for (const dir of dirs) {
|
||||
// A relative or empty PATH entry resolves against the cwd, i.e. the
|
||||
// opened project; never resolve the CLI from there.
|
||||
if (!hasSeparator && !(platform === 'win32' ? path.win32 : path.posix).isAbsolute(dir)) continue;
|
||||
for (const ext of extensions) {
|
||||
const base = path.resolve(dir || '.', command);
|
||||
const candidates = ext
|
||||
@@ -289,7 +301,7 @@ function main() {
|
||||
// a spurious failure even though the shim itself works correctly.
|
||||
// The bash version doesn't hit this because it backgrounded the work.
|
||||
if (process.env.RUFLO_HOOK_SKIP_NPX !== '1') {
|
||||
invokeHook('npx', ['--prefer-offline', '--yes', 'ruflo@latest'], hookArgs, stdinData);
|
||||
invokeHook('npx', ['--prefer-offline', '--yes', '--prefix', npxPrefix(), 'ruflo@latest'], hookArgs, stdinData);
|
||||
}
|
||||
|
||||
done();
|
||||
@@ -301,4 +313,4 @@ function main() {
|
||||
// invokes this file directly, so main() runs unconditionally otherwise.
|
||||
if (!globalThis.__RUFLO_HOOK_IMPORT_ONLY__) main();
|
||||
|
||||
module.exports = { invokeHook, resolveCommandPath, resolveInvocation, resolveNpmShim, escapeCmdArg };
|
||||
module.exports = { invokeHook, npxPrefix, resolveCommandPath, resolveInvocation, resolveNpmShim, escapeCmdArg };
|
||||
@@ -18,6 +18,10 @@ const fs = require('fs');
|
||||
const os = require('os');
|
||||
|
||||
const helpersDir = __dirname;
|
||||
// The helper's own install root: the project for a project-local helper,
|
||||
// $HOME for a home-level one. CLI resolution starts here, never at the opened
|
||||
// project, so a home-level helper does not run code the project supplies.
|
||||
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
|
||||
@@ -38,12 +42,11 @@ const helpersDir = __dirname;
|
||||
function resolveCliBinForHook() {
|
||||
try {
|
||||
const home = os.homedir();
|
||||
const cwd = process.cwd();
|
||||
const candidates = [
|
||||
path.join(home, '.claude', 'plugins', 'marketplaces', 'ruflo', 'bin', 'cli.js'),
|
||||
path.join(cwd, 'node_modules', '@claude-flow', 'cli', 'bin', 'cli.js'),
|
||||
path.join(cwd, 'node_modules', 'ruflo', 'bin', 'cli.js'),
|
||||
path.join(cwd, 'v3', '@claude-flow', 'cli', 'bin', 'cli.js'),
|
||||
path.join(helperRoot, 'node_modules', '@claude-flow', 'cli', 'bin', 'cli.js'),
|
||||
path.join(helperRoot, 'node_modules', 'ruflo', 'bin', 'cli.js'),
|
||||
path.join(helperRoot, 'v3', '@claude-flow', 'cli', 'bin', 'cli.js'),
|
||||
// helpersDir is .claude/helpers/ inside the package itself when this
|
||||
// file is running from a real @claude-flow/cli install (not a project
|
||||
// that merely copied the helper) — its bin/ is two levels up.
|
||||
@@ -77,14 +80,19 @@ function resolveCliBinForHook() {
|
||||
// render path, where local-first exists purely for per-render latency.
|
||||
// `--prefer-offline` avoids a registry round trip for the tarball when
|
||||
// already cached while still resolving the current `@latest` version.
|
||||
//
|
||||
// `--prefix helperRoot` makes npx resolve @claude-flow/cli from the helper's
|
||||
// install root instead of the cwd (the opened project); the spawned CLI still
|
||||
// runs with the project as its cwd.
|
||||
function cliSpawnArgs(cliBin, args, platform = process.platform) {
|
||||
if (cliBin) return [process.execPath, [cliBin, ...args]];
|
||||
return [platform === 'win32' ? 'npx.cmd' : 'npx', ['--prefer-offline', '--prefix', helperRoot, '@claude-flow/cli', ...args]];
|
||||
}
|
||||
|
||||
function spawnDetachedHookRefresh(subcommand) {
|
||||
try {
|
||||
const { spawn } = require('child_process');
|
||||
const cliBin = resolveCliBinForHook();
|
||||
const cmd = process.platform === 'win32' ? 'npx.cmd' : 'npx';
|
||||
const spawnArgs = cliBin
|
||||
? [process.execPath, [cliBin, 'hooks', subcommand, '--quiet']]
|
||||
: [cmd, ['--prefer-offline', '@claude-flow/cli', 'hooks', subcommand, '--quiet']];
|
||||
const spawnArgs = cliSpawnArgs(resolveCliBinForHook(), ['hooks', subcommand, '--quiet']);
|
||||
const child = spawn(spawnArgs[0], spawnArgs[1], {
|
||||
detached: true,
|
||||
stdio: 'ignore',
|
||||
@@ -151,10 +159,7 @@ function firstRunAutoEnableIfEligible() {
|
||||
const cliBin = resolveCliBinForHook();
|
||||
const runDetached = (args) => {
|
||||
try {
|
||||
const spawnArgs = cliBin
|
||||
? [process.execPath, [cliBin, ...args]]
|
||||
: [process.platform === 'win32' ? 'npx.cmd' : 'npx',
|
||||
['--prefer-offline', '@claude-flow/cli', ...args]];
|
||||
const spawnArgs = cliSpawnArgs(cliBin, args);
|
||||
const child = spawn(spawnArgs[0], spawnArgs[1], {
|
||||
detached: true, stdio: 'ignore', env: process.env, windowsHide: true,
|
||||
});
|
||||
@@ -772,4 +777,4 @@ 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 };
|
||||
module.exports = { runWithTimeout, INTELLIGENCE_TIMEOUT_MS, loadedHelpers, ownedByMod, MOD_OWNABLE_EVENTS, resolveCliBinForHook, cliSpawnArgs, helperRoot };
|
||||
+1333
-1328
File diff suppressed because it is too large.
Load diff
@@ -79,6 +79,15 @@ function envValue(env, name) {
|
||||
return key ? env[key] : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the npx fallback resolves ruflo from: the plugin's data dir or the
|
||||
* user's home, never the cwd (the opened project). `--prefix` only changes
|
||||
* package resolution; the CLI still runs with the project as its cwd.
|
||||
*/
|
||||
function npxPrefix(env = process.env) {
|
||||
return envValue(env, 'CLAUDE_PLUGIN_DATA') || envValue(env, 'PLUGIN_DATA') || require('os').homedir();
|
||||
}
|
||||
|
||||
/**
|
||||
* Locate a command on PATH using fs only.
|
||||
*
|
||||
@@ -98,6 +107,9 @@ function resolveCommandPath(command, env = process.env, platform = process.platf
|
||||
? (envValue(env, 'PATHEXT') || '.COM;.EXE;.BAT;.CMD').split(';')
|
||||
: [''];
|
||||
for (const dir of dirs) {
|
||||
// A relative or empty PATH entry resolves against the cwd, i.e. the
|
||||
// opened project; never resolve the CLI from there.
|
||||
if (!hasSeparator && !(platform === 'win32' ? path.win32 : path.posix).isAbsolute(dir)) continue;
|
||||
for (const ext of extensions) {
|
||||
const base = path.resolve(dir || '.', command);
|
||||
const candidates = ext
|
||||
@@ -289,7 +301,7 @@ function main() {
|
||||
// a spurious failure even though the shim itself works correctly.
|
||||
// The bash version doesn't hit this because it backgrounded the work.
|
||||
if (process.env.RUFLO_HOOK_SKIP_NPX !== '1') {
|
||||
invokeHook('npx', ['--prefer-offline', '--yes', 'ruflo@latest'], hookArgs, stdinData);
|
||||
invokeHook('npx', ['--prefer-offline', '--yes', '--prefix', npxPrefix(), 'ruflo@latest'], hookArgs, stdinData);
|
||||
}
|
||||
|
||||
done();
|
||||
@@ -301,4 +313,4 @@ function main() {
|
||||
// invokes this file directly, so main() runs unconditionally otherwise.
|
||||
if (!globalThis.__RUFLO_HOOK_IMPORT_ONLY__) main();
|
||||
|
||||
module.exports = { invokeHook, resolveCommandPath, resolveInvocation, resolveNpmShim, escapeCmdArg };
|
||||
module.exports = { invokeHook, npxPrefix, resolveCommandPath, resolveInvocation, resolveNpmShim, escapeCmdArg };
|
||||
@@ -57,6 +57,15 @@ function envValue(env, name) {
|
||||
return key ? env[key] : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the npx fallback resolves ruflo from: the plugin's data dir or the
|
||||
* user's home, never the cwd (the opened project). `--prefix` only changes
|
||||
* package resolution; the CLI still runs with the project as its cwd.
|
||||
*/
|
||||
function npxPrefix(env = process.env) {
|
||||
return envValue(env, 'CLAUDE_PLUGIN_DATA') || envValue(env, 'PLUGIN_DATA') || require('os').homedir();
|
||||
}
|
||||
|
||||
/**
|
||||
* Locate a command on PATH using fs only.
|
||||
*
|
||||
@@ -76,6 +85,9 @@ function resolveCommandPath(command, env = process.env, platform = process.platf
|
||||
? (envValue(env, 'PATHEXT') || '.COM;.EXE;.BAT;.CMD').split(';')
|
||||
: [''];
|
||||
for (const dir of dirs) {
|
||||
// A relative or empty PATH entry resolves against the cwd, i.e. the
|
||||
// opened project; never resolve the CLI from there.
|
||||
if (!hasSeparator && !(platform === 'win32' ? path.win32 : path.posix).isAbsolute(dir)) continue;
|
||||
for (const ext of extensions) {
|
||||
const base = path.resolve(dir || '.', command);
|
||||
const candidates = ext
|
||||
@@ -271,7 +283,7 @@ function invokeCli(hookSubcommand, hookArgs, stdinData) {
|
||||
// itself works correctly. The bash version doesn't hit this because it
|
||||
// backgrounded the work.
|
||||
if (process.env.RUFLO_HOOK_SKIP_NPX !== '1') {
|
||||
invokeHook('npx', ['--prefer-offline', '--yes', 'ruflo@latest'], hookSubcommand, hookArgs, stdinData);
|
||||
invokeHook('npx', ['--prefer-offline', '--yes', '--prefix', npxPrefix(), 'ruflo@latest'], hookSubcommand, hookArgs, stdinData);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -465,4 +477,4 @@ if (process.env.RUFLO_HOOK_UNIT_TEST !== '1') {
|
||||
main();
|
||||
}
|
||||
|
||||
module.exports = { escapeCmdArg, invokeHook, resolveCommandPath, resolveInvocation, resolveNpmShim };
|
||||
module.exports = { escapeCmdArg, invokeHook, npxPrefix, resolveCommandPath, resolveInvocation, resolveNpmShim };
|
||||
@@ -18,6 +18,10 @@ const fs = require('fs');
|
||||
const os = require('os');
|
||||
|
||||
const helpersDir = __dirname;
|
||||
// The helper's own install root: the project for a project-local helper,
|
||||
// $HOME for a home-level one. CLI resolution starts here, never at the opened
|
||||
// project, so a home-level helper does not run code the project supplies.
|
||||
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
|
||||
@@ -38,12 +42,11 @@ const helpersDir = __dirname;
|
||||
function resolveCliBinForHook() {
|
||||
try {
|
||||
const home = os.homedir();
|
||||
const cwd = process.cwd();
|
||||
const candidates = [
|
||||
path.join(home, '.claude', 'plugins', 'marketplaces', 'ruflo', 'bin', 'cli.js'),
|
||||
path.join(cwd, 'node_modules', '@claude-flow', 'cli', 'bin', 'cli.js'),
|
||||
path.join(cwd, 'node_modules', 'ruflo', 'bin', 'cli.js'),
|
||||
path.join(cwd, 'v3', '@claude-flow', 'cli', 'bin', 'cli.js'),
|
||||
path.join(helperRoot, 'node_modules', '@claude-flow', 'cli', 'bin', 'cli.js'),
|
||||
path.join(helperRoot, 'node_modules', 'ruflo', 'bin', 'cli.js'),
|
||||
path.join(helperRoot, 'v3', '@claude-flow', 'cli', 'bin', 'cli.js'),
|
||||
// helpersDir is .claude/helpers/ inside the package itself when this
|
||||
// file is running from a real @claude-flow/cli install (not a project
|
||||
// that merely copied the helper) — its bin/ is two levels up.
|
||||
@@ -77,14 +80,19 @@ function resolveCliBinForHook() {
|
||||
// render path, where local-first exists purely for per-render latency.
|
||||
// `--prefer-offline` avoids a registry round trip for the tarball when
|
||||
// already cached while still resolving the current `@latest` version.
|
||||
//
|
||||
// `--prefix helperRoot` makes npx resolve @claude-flow/cli from the helper's
|
||||
// install root instead of the cwd (the opened project); the spawned CLI still
|
||||
// runs with the project as its cwd.
|
||||
function cliSpawnArgs(cliBin, args, platform = process.platform) {
|
||||
if (cliBin) return [process.execPath, [cliBin, ...args]];
|
||||
return [platform === 'win32' ? 'npx.cmd' : 'npx', ['--prefer-offline', '--prefix', helperRoot, '@claude-flow/cli', ...args]];
|
||||
}
|
||||
|
||||
function spawnDetachedHookRefresh(subcommand) {
|
||||
try {
|
||||
const { spawn } = require('child_process');
|
||||
const cliBin = resolveCliBinForHook();
|
||||
const cmd = process.platform === 'win32' ? 'npx.cmd' : 'npx';
|
||||
const spawnArgs = cliBin
|
||||
? [process.execPath, [cliBin, 'hooks', subcommand, '--quiet']]
|
||||
: [cmd, ['--prefer-offline', '@claude-flow/cli', 'hooks', subcommand, '--quiet']];
|
||||
const spawnArgs = cliSpawnArgs(resolveCliBinForHook(), ['hooks', subcommand, '--quiet']);
|
||||
const child = spawn(spawnArgs[0], spawnArgs[1], {
|
||||
detached: true,
|
||||
stdio: 'ignore',
|
||||
@@ -151,10 +159,7 @@ function firstRunAutoEnableIfEligible() {
|
||||
const cliBin = resolveCliBinForHook();
|
||||
const runDetached = (args) => {
|
||||
try {
|
||||
const spawnArgs = cliBin
|
||||
? [process.execPath, [cliBin, ...args]]
|
||||
: [process.platform === 'win32' ? 'npx.cmd' : 'npx',
|
||||
['--prefer-offline', '@claude-flow/cli', ...args]];
|
||||
const spawnArgs = cliSpawnArgs(cliBin, args);
|
||||
const child = spawn(spawnArgs[0], spawnArgs[1], {
|
||||
detached: true, stdio: 'ignore', env: process.env, windowsHide: true,
|
||||
});
|
||||
@@ -772,4 +777,4 @@ 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 };
|
||||
module.exports = { runWithTimeout, INTELLIGENCE_TIMEOUT_MS, loadedHelpers, ownedByMod, MOD_OWNABLE_EVENTS, resolveCliBinForHook, cliSpawnArgs, helperRoot };
|
||||
@@ -43,6 +43,10 @@ const CONFIG = {
|
||||
};
|
||||
|
||||
const CWD = process.env.CLAUDE_PROJECT_DIR || process.cwd();
|
||||
// The helper's own install root: the project for a project-local helper,
|
||||
// $HOME for a home-level one. The CLI is resolved from here, never from CWD,
|
||||
// so a home-level statusline does not run code the opened project supplies.
|
||||
const HELPER_ROOT = path.resolve(__dirname, '..', '..');
|
||||
// Replaced by statusline-generator with the package root of the CLI that
|
||||
// installed this helper. This survives custom npm prefixes and bundled Node
|
||||
// runtimes whose process.execPath belongs to a different tree (#2811).
|
||||
@@ -111,9 +115,9 @@ function resolveCliBinCandidates() {
|
||||
const home = os.homedir();
|
||||
candidates.push(
|
||||
path.join(home, '.claude', 'plugins', 'marketplaces', 'ruflo', 'bin', 'cli.js'),
|
||||
path.join(CWD, 'node_modules', '@claude-flow', 'cli', 'bin', 'cli.js'),
|
||||
path.join(CWD, 'node_modules', 'ruflo', 'bin', 'cli.js'),
|
||||
path.join(CWD, 'v3', '@claude-flow', 'cli', 'bin', 'cli.js'),
|
||||
path.join(HELPER_ROOT, 'node_modules', '@claude-flow', 'cli', 'bin', 'cli.js'),
|
||||
path.join(HELPER_ROOT, 'node_modules', 'ruflo', 'bin', 'cli.js'),
|
||||
path.join(HELPER_ROOT, 'v3', '@claude-flow', 'cli', 'bin', 'cli.js'),
|
||||
);
|
||||
try {
|
||||
const binDir = path.dirname(process.execPath);
|
||||
@@ -239,7 +243,8 @@ function getStatuslineData() {
|
||||
// delegation, so it could never get seeded on Windows either).
|
||||
const cmds = resolveCliBinCandidates()
|
||||
.map((bin) => '"' + process.execPath + '" "' + bin + '" hooks statusline --json')
|
||||
.concat(['npx --prefer-offline @claude-flow/cli hooks statusline --json']);
|
||||
// --prefix: npx resolves the package from HELPER_ROOT, not the project.
|
||||
.concat(['npx --prefer-offline --prefix "' + HELPER_ROOT + '" @claude-flow/cli hooks statusline --json']);
|
||||
for (const cmd of cmds) {
|
||||
try {
|
||||
const raw = execSync(
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
/**
|
||||
* Hook helpers and plugin hook shims launch the ruflo CLI. A home-level
|
||||
* helper or a user-level plugin serves every opened project, so the CLI must
|
||||
* be resolved from the helper's own install root (or the plugin data / home
|
||||
* dir), never from the project: not from <project>/node_modules, not from a
|
||||
* relative PATH entry, and not through npx's project-local lookup.
|
||||
*/
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { chmodSync, copyFileSync, existsSync, mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { createRequire } from 'node:module';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { dirname, join, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { generateHookHandler, generateRufloHookCjs } from '../src/init/helpers-generator.js';
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const REPO = resolve(here, '../../../..');
|
||||
const require_ = createRequire(import.meta.url);
|
||||
const tempRoots: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const root of tempRoots.splice(0)) rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
/** A built-looking CLI install whose bin leaves a marker when it runs. */
|
||||
function plantCli(pkgDir: string, marker: string) {
|
||||
mkdirSync(join(pkgDir, 'bin'), { recursive: true });
|
||||
mkdirSync(join(pkgDir, 'dist', 'src'), { recursive: true });
|
||||
writeFileSync(join(pkgDir, 'bin', 'cli.js'), `require('fs').writeFileSync(${JSON.stringify(marker)}, '1');\n`);
|
||||
writeFileSync(join(pkgDir, 'dist', 'src', 'index.js'), '');
|
||||
}
|
||||
|
||||
function fixture() {
|
||||
const root = realpathSync(mkdtempSync(join(tmpdir(), 'ruflo-cli-trust-')));
|
||||
tempRoots.push(root);
|
||||
const home = join(root, 'home');
|
||||
const project = join(root, 'project');
|
||||
const marker = join(root, 'project-cli-ran');
|
||||
mkdirSync(join(home, '.claude', 'helpers'), { recursive: true });
|
||||
plantCli(join(project, 'node_modules', '@claude-flow', 'cli'), marker);
|
||||
return { root, home, project, marker };
|
||||
}
|
||||
|
||||
function nodeEval(code: string, cwd: string, env: NodeJS.ProcessEnv) {
|
||||
const result = spawnSync(process.execPath, ['-e', code], { cwd, env: { ...process.env, ...env }, encoding: 'utf8', timeout: 15_000 });
|
||||
expect(result.error).toBeUndefined();
|
||||
return result;
|
||||
}
|
||||
|
||||
describe('hook-handler.cjs resolves the CLI from its install root', () => {
|
||||
for (const [name, source] of [
|
||||
['package', resolve(here, '../.claude/helpers/hook-handler.cjs')],
|
||||
['repository', resolve(REPO, '.claude/helpers/hook-handler.cjs')],
|
||||
] as const) {
|
||||
it(`${name}: a home-level helper ignores the project's CLI and pins npx --prefix`, () => {
|
||||
const { home, project } = fixture();
|
||||
const hook = join(home, '.claude', 'helpers', 'hook-handler.cjs');
|
||||
copyFileSync(source, hook);
|
||||
const out = nodeEval(
|
||||
`const h = require(${JSON.stringify(hook)});` +
|
||||
`console.log(JSON.stringify({ bin: h.resolveCliBinForHook(), posix: h.cliSpawnArgs(null, ['hooks', 'x'], 'linux'), win: h.cliSpawnArgs(null, ['hooks', 'x'], 'win32') }));`,
|
||||
project, { HOME: home, USERPROFILE: home, CLAUDE_PROJECT_DIR: project },
|
||||
);
|
||||
const r = JSON.parse(out.stdout.trim().split('\n').pop() as string);
|
||||
expect(r.bin).toBeNull();
|
||||
expect(r.posix).toEqual(['npx', ['--prefer-offline', '--prefix', home, '@claude-flow/cli', 'hooks', 'x']]);
|
||||
expect(r.win).toEqual(['npx.cmd', ['--prefer-offline', '--prefix', home, '@claude-flow/cli', 'hooks', 'x']]);
|
||||
});
|
||||
|
||||
it(`${name}: a project-local helper still uses the project's CLI`, () => {
|
||||
const { home, project } = fixture();
|
||||
const hook = join(project, '.claude', 'helpers', 'hook-handler.cjs');
|
||||
mkdirSync(dirname(hook), { recursive: true });
|
||||
copyFileSync(source, hook);
|
||||
const out = nodeEval(`console.log(require(${JSON.stringify(hook)}).resolveCliBinForHook())`, project, { HOME: home, USERPROFILE: home });
|
||||
expect(out.stdout.trim().split('\n').pop()).toBe(join(project, 'node_modules', '@claude-flow', 'cli', 'bin', 'cli.js'));
|
||||
});
|
||||
}
|
||||
|
||||
it('the generated hook-handler pins its npx refresh to the helper root', () => {
|
||||
expect(generateHookHandler()).toContain("'--prefer-offline', '--prefix', path.resolve(helpersDir, '..', '..'), '@claude-flow/cli'");
|
||||
});
|
||||
});
|
||||
|
||||
describe('statusline.cjs resolves the CLI from its install root', () => {
|
||||
const source = resolve(here, '../.claude/helpers/statusline.cjs');
|
||||
// An empty PATH keeps the npx fallback from running; the bin candidates are
|
||||
// launched through process.execPath, so they still run if selected.
|
||||
const render = (helper: string, home: string, project: string, emptyBin: string) =>
|
||||
spawnSync(process.execPath, [helper], {
|
||||
cwd: project,
|
||||
env: { HOME: home, USERPROFILE: home, CLAUDE_PROJECT_DIR: project, PATH: emptyBin },
|
||||
input: '{}',
|
||||
encoding: 'utf8',
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
it("a home-level statusline never runs the project's CLI", () => {
|
||||
const { root, home, project, marker } = fixture();
|
||||
const emptyBin = join(root, 'empty-bin');
|
||||
mkdirSync(emptyBin);
|
||||
const helper = join(home, '.claude', 'helpers', 'statusline.cjs');
|
||||
copyFileSync(source, helper);
|
||||
render(helper, home, project, emptyBin);
|
||||
expect(existsSync(marker)).toBe(false);
|
||||
});
|
||||
|
||||
it("a project-local statusline still uses the project's CLI (control)", () => {
|
||||
const { root, home, project, marker } = fixture();
|
||||
const emptyBin = join(root, 'empty-bin');
|
||||
mkdirSync(emptyBin);
|
||||
const helper = join(project, '.claude', 'helpers', 'statusline.cjs');
|
||||
mkdirSync(dirname(helper), { recursive: true });
|
||||
copyFileSync(source, helper);
|
||||
render(helper, home, project, emptyBin);
|
||||
expect(existsSync(marker)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
type Shim = {
|
||||
npxPrefix?: (env?: NodeJS.ProcessEnv) => string;
|
||||
resolveCommandPath: (cmd: string, env?: NodeJS.ProcessEnv, platform?: string) => string | null;
|
||||
};
|
||||
|
||||
describe('ruflo-hook.cjs plugin shims', () => {
|
||||
const shims = ['plugins/ruflo-core/scripts/ruflo-hook.cjs', 'plugin/scripts/ruflo-hook.cjs', '.claude-plugin/scripts/ruflo-hook.cjs'];
|
||||
|
||||
for (const rel of shims) {
|
||||
it(`${rel}: ignores relative PATH entries and pins the npx prefix`, () => {
|
||||
// The shims run main() on load unless told they are imported for a test
|
||||
// (ruflo-core reads RUFLO_HOOK_UNIT_TEST, the others a global flag).
|
||||
const unitTest = process.env.RUFLO_HOOK_UNIT_TEST;
|
||||
process.env.RUFLO_HOOK_UNIT_TEST = '1';
|
||||
(globalThis as Record<string, unknown>).__RUFLO_HOOK_IMPORT_ONLY__ = true;
|
||||
const shim = require_(resolve(REPO, rel)) as Shim;
|
||||
delete (globalThis as Record<string, unknown>).__RUFLO_HOOK_IMPORT_ONLY__;
|
||||
if (unitTest === undefined) delete process.env.RUFLO_HOOK_UNIT_TEST;
|
||||
else process.env.RUFLO_HOOK_UNIT_TEST = unitTest;
|
||||
const { root, home } = fixture();
|
||||
const absBin = join(root, 'abs-bin');
|
||||
mkdirSync(absBin);
|
||||
const rufloAbs = join(absBin, 'ruflo');
|
||||
writeFileSync(rufloAbs, '#!/bin/sh\n');
|
||||
chmodSync(rufloAbs, 0o755);
|
||||
|
||||
// A relative entry that would resolve against the cwd is skipped
|
||||
// (posix and win32 forms); an absolute entry still resolves.
|
||||
expect(shim.resolveCommandPath('ruflo', { PATH: `.${':'}relative-dir` }, 'linux')).toBeNull();
|
||||
expect(shim.resolveCommandPath('ruflo', { PATH: `.;relative-dir`, PATHEXT: '.CMD' }, 'win32')).toBeNull();
|
||||
expect(shim.resolveCommandPath('ruflo', { PATH: `.:${absBin}` }, 'linux')).toBe(rufloAbs);
|
||||
|
||||
expect(shim.npxPrefix?.({ CLAUDE_PLUGIN_DATA: '/plugin/data' })).toBe('/plugin/data');
|
||||
expect(shim.npxPrefix?.({ HOME: home })).toBeTypeOf('string');
|
||||
});
|
||||
}
|
||||
|
||||
it('the generated ruflo-hook.cjs pins its npx fallback to the helper root', () => {
|
||||
expect(generateRufloHookCjs()).toContain("'--prefer-offline', '--yes', '--prefix', path.resolve(__dirname, '..', '..'), 'ruflo@latest'");
|
||||
});
|
||||
});
|
||||
@@ -56,9 +56,14 @@ describe('hook-handler.cjs — resolveCliBinForHook validates a real dist, not j
|
||||
const idx = source.indexOf('function spawnDetachedHookRefresh');
|
||||
expect(idx).toBeGreaterThan(-1);
|
||||
const body = source.slice(idx, idx + 700);
|
||||
expect(body).toContain('@claude-flow/cli');
|
||||
expect(body).toContain('--prefer-offline');
|
||||
expect(body).toContain('cliSpawnArgs(resolveCliBinForHook()');
|
||||
expect(body).not.toContain('if (!cliBin) return;');
|
||||
// cliSpawnArgs() builds the npx fallback when no local candidate resolves.
|
||||
const argsIdx = source.indexOf('function cliSpawnArgs');
|
||||
expect(argsIdx).toBeGreaterThan(-1);
|
||||
const argsBody = source.slice(argsIdx, argsIdx + 400);
|
||||
expect(argsBody).toContain('@claude-flow/cli');
|
||||
expect(argsBody).toContain('--prefer-offline');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -615,7 +615,9 @@ export function generateHookHandler(): string {
|
||||
'function spawnFunnelRefresh() {',
|
||||
' try {',
|
||||
" var cmd = process.platform === 'win32' ? 'npx.cmd' : 'npx';",
|
||||
" var args = ['--prefer-offline', '@claude-flow/cli', 'hooks', 'refresh-funnel', '--quiet'];",
|
||||
// --prefix: resolve the package from this helper's install root, never
|
||||
// from the cwd (the opened project); the CLI still runs in the project.
|
||||
" var args = ['--prefer-offline', '--prefix', path.resolve(helpersDir, '..', '..'), '@claude-flow/cli', 'hooks', 'refresh-funnel', '--quiet'];",
|
||||
' var child = spawn(cmd, args, {',
|
||||
" detached: true, stdio: 'ignore', env: Object.assign({}, process.env),",
|
||||
' });',
|
||||
@@ -1637,6 +1639,9 @@ function resolveCommandPath(command, env = process.env, platform = process.platf
|
||||
? (envValue(env, 'PATHEXT') || '.COM;.EXE;.BAT;.CMD').split(';')
|
||||
: [''];
|
||||
for (const dir of dirs) {
|
||||
// A relative or empty PATH entry resolves against the cwd, i.e. the
|
||||
// opened project; never resolve the CLI from there.
|
||||
if (!hasSeparator && !(platform === 'win32' ? path.win32 : path.posix).isAbsolute(dir)) continue;
|
||||
for (const ext of extensions) {
|
||||
const base = path.resolve(dir || '.', command);
|
||||
const candidates = ext
|
||||
@@ -1789,7 +1794,9 @@ function main() {
|
||||
// exists but cannot be resolved to an entrypoint still runs, via layer 2.
|
||||
if (resolveCommandPath('ruflo')) { invokeHook('ruflo', [], hookArgs, stdinData); done(); }
|
||||
if (resolveCommandPath('claude-flow')) { invokeHook('claude-flow', [], hookArgs, stdinData); done(); }
|
||||
invokeHook('npx', ['--prefer-offline', '--yes', 'ruflo@latest'], hookArgs, stdinData);
|
||||
// --prefix: npx resolves ruflo from this helper's install root, never from
|
||||
// the cwd (the opened project); the CLI still runs in the project.
|
||||
invokeHook('npx', ['--prefer-offline', '--yes', '--prefix', path.resolve(__dirname, '..', '..'), 'ruflo@latest'], hookArgs, stdinData);
|
||||
done();
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user