Files
ruvnet__ruflo/plugins/ruflo-cost-tracker/scripts/test-hooks.mjs
ruvnet b68ad4ccba fix(plugins): make ruflo-core/ruflo-cost-tracker hooks Windows-native (#2721)
Both plugins' hooks.json wrapped every command in `/bin/bash -c '...'`,
which fails outright on native Windows (no such path) -- Codex/Claude
Code report "PreToolUse hook (failed) -- exit code 1" on every tool
call. The `_platform: posix` / "ruflo init overrides this on Windows"
claim in both files was never actually true: Claude Code merges
plugin-declared hooks additively with any init-generated
.claude/settings.json, it doesn't replace them, and there's no `ruflo
init` step at all in the reported Codex marketplace install flow.

Fix: every hook command is now a `node -e` bootstrap that resolves
plugins/*/scripts/ruflo-hook.cjs from process.env.CLAUDE_PLUGIN_ROOT
inside Node -- no shell env-var expansion (${VAR} vs %VAR%), so the
exact same command string runs unchanged on Windows/macOS/Linux.

ruflo-core's ruflo-hook.cjs (previously a full port of ruflo-hook.sh
that existed on disk but was never referenced by hooks.json) gained:
  - JSON parsing of the hook event from stdin (replaces jq) for
    post-command/post-edit, deriving the same CLI flags the bash
    version computed
  - the PreToolUse permission-allow stdout echo Cursor's stricter
    contract requires (previously only the bash wrapper's trailing
    printf did this)
  - precompact-manual/precompact-auto guidance text (previously plain
    bash echoes, no CLI call)
  - a real Windows shell-quoting fix: shell:true with an args array
    does NOT quote array elements, so "echo hi" silently truncated to
    "echo" and a heredoc's `<<` errored as unexpected -- skip the
    shell entirely for `node` invocations (never a .cmd shim, so
    CreateProcess gets the argv array byte-for-byte)

cost-tracker's existing ruflo-hook.cjs (already correct, just
orphaned) needed no logic changes, only wiring.

Also:
  - corrected the false "_platform_note" claims about ruflo init
    overriding plugin hooks
  - hardened scripts/audit-plugin-hooks-cross-platform.mjs: a
    POSIX-exempt hooks.json now must actually reference its sibling
    .cjs shim, not just have one sitting on disk unreferenced (which
    is exactly the shape cost-tracker shipped in undetected)
  - added windows-latest to the plugin-hooks-smoke CI matrix (it was
    ubuntu/macos-only because the old bash-based hooks.json couldn't
    run on Windows at all) and rewrote test-hooks.mjs to drive hooks.json's
    literal command strings via `shell: true` -- exactly how Claude
    Code/Codex invoke them -- instead of wrapping everything in an
    explicit `bash -c` that could never have caught this bug
  - flagged (not fixed) a separate, currently-published, actively
    maintained plugin package (.claude-plugin/ + plugin/, the older
    "claude-flow" plugin, not listed in the ruflo marketplace) with
    the same underlying bug via jq/xargs pipes instead of bash --
    explicitly marked _legacy_unaudited_shim so the hardened audit
    doesn't silently regress on out-of-scope work

Verified locally on native Windows (this fix's actual target
platform): all 17 ruflo-core hook cases pass, all 3 cost-tracker
cases pass, the existing 12-case smoke-ruflo-hook-cjs.mjs passes
unchanged, both hook-command audits pass clean.

Fixes #2721
2026-07-18 19:05:06 -04:00

79 lines
2.7 KiB
JavaScript

#!/usr/bin/env node
/**
* Regression guard for ruvnet/ruflo#2721.
*
* Drives the Stop hook command from `hooks/hooks.json` exactly as Claude
* Code/Codex would — `spawnSync(command, { shell: true, ... })`, no bash
* wrapper of our own — asserting it exits 0 on every OS without requiring
* bash. Before #2721 the command hard-coded `/bin/bash -c '...'`, which
* fails outright on native Windows.
*
* TRACK_CWD points at a throwaway directory with no session jsonl files,
* so track.mjs takes its fast no-op path instead of scanning/touching a
* real ~/.claude/projects session — this is a hook-wiring smoke, not a
* cost-tracking behavior test.
*
* Usage (from repo root):
* node plugins/ruflo-cost-tracker/scripts/test-hooks.mjs
*
* Wired into .github/workflows/v3-ci.yml as part of the `plugin-hooks-smoke`
* job (windows-latest, macos-latest, ubuntu-latest).
*/
import { readFileSync, mkdtempSync } from 'node:fs';
import { spawnSync } from 'node:child_process';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { tmpdir } from 'node:os';
const __dirname = dirname(fileURLToPath(import.meta.url));
const PLUGIN_ROOT = join(__dirname, '..');
const HOOKS_JSON = join(PLUGIN_ROOT, 'hooks', 'hooks.json');
const hooks = JSON.parse(readFileSync(HOOKS_JSON, 'utf8'));
const cmdStop = hooks.hooks?.Stop?.[0]?.hooks?.[0]?.command;
if (!cmdStop) throw new Error('No Stop hook found in hooks.json');
const scratchCwd = mkdtempSync(join(tmpdir(), 'ruflo-cost-tracker-smoke-'));
let failed = 0;
const cases = [];
const run = (name, stdin) => {
const r = spawnSync(cmdStop, {
shell: true,
input: stdin,
encoding: 'utf8',
env: {
...process.env,
CLAUDE_PLUGIN_ROOT: PLUGIN_ROOT,
TRACK_CWD: scratchCwd,
TRACK_DRY_RUN: '1',
},
timeout: 15_000,
});
const errors = [];
if (r.error) errors.push(`spawn error: ${r.error.message}`);
if (r.status !== 0) errors.push(`exit ${r.status} (expected 0)`);
if (errors.length === 0) {
console.log(`ok: ${name}`);
} else {
console.error(`FAIL: ${name}`);
for (const e of errors) console.error(` - ${e}`);
const combined = (r.stdout ?? '') + (r.stderr ?? '');
if (combined.trim()) {
console.error(' output:');
for (const line of combined.split('\n').slice(0, 8)) console.error(` ${line}`);
}
failed++;
}
cases.push(name);
};
run('Stop hook exits 0 with valid stdin', '{"session_id":"test"}');
run('Stop hook exits 0 with empty stdin', '');
run('Stop hook exits 0 with malformed stdin', '{not json');
console.log(`\n${cases.length - failed}/${cases.length} passed`);
process.exit(failed === 0 ? 0 : 1);