Skip to content

Commit 035e07a

Browse files
authored
feat(sync): prune npx cache envs serving outdated ruflo-family code (#29)
npx envs (<npm-cache>/_npx/<hash>/) are snapshots keyed by requested spec: once `npx @claude-flow/cli` caches a version, --prefer-offline serves that copy forever — upgrading the global install never touches it. The statusline/hook npx fallbacks execute these verbatim, which is how a machine running a fixed ruflo 3.32.2 kept rendering the fabricated CVE counter from a cached 3.28.0 (#28): six such envs held ~6.4 GB of retired code spanning ruflo 3.10-3.28 and agentic-qe 3.11.5. New `npx` status row + sync heal automates what #28 remediated by hand. The prune rule is conservative by construction — a miss fails safe as "not pruned", never a wrong prune: - every package the env is keyed to must be kit-managed (ruflo, @claude-flow/cli, agentic-qe); an env we can't fully judge is exempt - each needs an installed global baseline (@claude-flow/cli resolves from its NESTED location under ruflo — the same layout fact behind the #28 bin fix; there is never a top-level global copy) - only a cached copy STRICTLY older than its baseline counts; equal or newer stays, since a current cache is what a pre-install machine's npx fallback runs Runs on the `npx` row or after `versions` upgrades — an upgrade is precisely what turns a previously-current cache stale. The cache dir is resolved from npm_config_cache or platform defaults without spawning npm; a custom userconfig cache path is missed, which only means an empty scan. Verified end-to-end with a synthetic stale env planted in the real cache: status detects (warn row), sync --dry-run plans it, the heal prunes exactly that env (current and foreign envs untouched), and status converges to ok.
1 parent 6692116 commit 035e07a

5 files changed

Lines changed: 232 additions & 0 deletions

File tree

‎src/commands/status.mjs‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import path from 'node:path';
66
import { glyph, dim, bold } from '../lib/output.mjs';
77
import * as paths from '../lib/paths.mjs';
88
import { nativesStatus, aidefencePresent, securityPresent } from '../lib/natives.mjs';
9+
import { scanNpxStale } from '../lib/npx.mjs';
910
import { registrationStatus } from '../lib/mcp.mjs';
1011
import { listDaemons, staleDaemons } from '../lib/daemons.mjs';
1112
import { scanRvf } from '../lib/rvf.mjs';
@@ -118,6 +119,22 @@ export async function collect({ pkgRoot, cwd = process.cwd() }) {
118119
rows.push(row('natives', 'warn', `native check unavailable: ${e.message}`));
119120
}
120121

122+
// npx (stale ruflo-family cache envs — `npx --prefer-offline` fallbacks in the
123+
// statusline/hooks execute these verbatim, keeping retired defects alive)
124+
try {
125+
const stale = scanNpxStale();
126+
if (stale.length) {
127+
const what = stale.flatMap((e) => e.stale.map((s) => `${s.pkg}@${s.cached}`)).join(', ');
128+
rows.push(row('npx', 'warn',
129+
`${stale.length} stale npx env(s) serve outdated code (${what})`,
130+
'sync prunes them (npx re-fetches on demand)'));
131+
} else {
132+
rows.push(row('npx', 'ok', 'npx cache holds no stale ruflo-family envs'));
133+
}
134+
} catch (e) {
135+
rows.push(row('npx', 'warn', `npx cache check unavailable: ${e.message}`));
136+
}
137+
121138
// security surface
122139
if (securityPresent()) {
123140
if (aidefencePresent()) {

‎src/commands/sync.mjs‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { listDaemons, staleDaemons, reap } from '../lib/daemons.mjs';
1111
import { loadKitConfig } from '../lib/config.mjs';
1212
import { HOSTS, applyHosts, applyProviders, hostInstallState, installHost, applyAqeRouter } from '../lib/providers.mjs';
1313
import { driftReport, selfDrift } from '../lib/versions.mjs';
14+
import { pruneNpxStale } from '../lib/npx.mjs';
1415
import * as paths from '../lib/paths.mjs';
1516
import { ok, warn, fail, bold, dim } from '../lib/output.mjs';
1617

@@ -82,6 +83,14 @@ export async function run({ flags, pkgRoot }) {
8283
if (subsystems.has('natives') || subsystems.has('versions') || subsystems.has('security')) {
8384
report('natives', await heal.healNatives());
8485
}
86+
// npx: prune cached envs serving outdated ruflo-family code — the statusline/
87+
// hook `npx --prefer-offline` fallbacks execute these verbatim, so a stale env
88+
// keeps retired defects (the fabricated CVE counter) alive on an upgraded
89+
// machine. Runs on `versions` too: an upgrade is precisely what turns a
90+
// previously-current cache stale.
91+
if (subsystems.has('npx') || subsystems.has('versions')) {
92+
report('npx', pruneNpxStale());
93+
}
8594
if (subsystems.has('aqe')) {
8695
report('rvf', heal.healRvf(paths.projectAqeDir(cwd)));
8796
}

‎src/lib/npx.mjs‎

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
// Stale npx-cache detection for the ruflo family. npx envs (`<npm-cache>/_npx/
2+
// <hash>/`) are snapshots keyed by requested spec: once `npx @claude-flow/cli`
3+
// caches a version, `--prefer-offline` serves that copy forever — upgrading the
4+
// global install never touches it. That is how a machine running a fixed ruflo
5+
// 3.32.2 kept executing a cached 3.28.0 (statusline npx fallback) and rendering
6+
// its fabricated CVE counter; six such envs held ~6.4 GB of retired code.
7+
//
8+
// Prune rule — conservative by construction, a miss only means "not pruned":
9+
// · every package the env is keyed to (its package.json dependencies) must be
10+
// kit-managed — an env we can't fully judge is left alone;
11+
// · each managed package needs an installed global baseline to compare against
12+
// — no baseline, no judgement, no prune;
13+
// · at least one cached copy must be STRICTLY older than its baseline
14+
// (equal-or-newer stays: a current cache is what a pre-install machine's
15+
// npx fallback runs).
16+
// Envs are pure caches; npx re-fetches on demand, so removal is always safe.
17+
import fs from 'node:fs';
18+
import path from 'node:path';
19+
import { npxCacheDir, rufloNodeModules } from './paths.mjs';
20+
import { installedVersion, cmpVersions } from './versions.mjs';
21+
22+
const readPkg = (dir) => {
23+
try { return JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8')); } catch { return null; }
24+
};
25+
26+
/** Installed baseline per managed package. @claude-flow/cli is ruflo's NESTED
27+
* dependency (never a top-level global), so it can't go through
28+
* installedVersion — the same layout fact behind the statusline bin fix. */
29+
export function managedBaseline(pkg) {
30+
if (pkg === '@claude-flow/cli') {
31+
return readPkg(path.join(rufloNodeModules(), '@claude-flow', 'cli'))?.version ?? null;
32+
}
33+
if (pkg === 'ruflo' || pkg === 'agentic-qe') return installedVersion(pkg);
34+
return null; // not a package the kit manages — never judged, never pruned
35+
}
36+
37+
/** Stale envs under the npx cache. Returns [{dir, stale: [{pkg, cached, installed}]}].
38+
* `root`/`baseline` are injectable so tests run against fixtures, no real cache. */
39+
export function scanNpxStale({ root = npxCacheDir(), baseline = managedBaseline } = {}) {
40+
let entries;
41+
try { entries = fs.readdirSync(root); } catch { return []; } // no cache dir: nothing to do
42+
const out = [];
43+
for (const name of entries) {
44+
const dir = path.join(root, name);
45+
const keyed = readPkg(dir)?.dependencies;
46+
const pkgs = keyed ? Object.keys(keyed) : [];
47+
if (!pkgs.length) continue;
48+
const judged = pkgs.map((pkg) => ({
49+
pkg,
50+
installed: baseline(pkg),
51+
cached: readPkg(path.join(dir, 'node_modules', pkg))?.version ?? null,
52+
}));
53+
// One unjudgeable package (unmanaged, no baseline, unreadable copy) exempts
54+
// the whole env — partial verdicts are how wrong prunes happen.
55+
if (judged.some((j) => !j.installed || !j.cached)) continue;
56+
const stale = judged.filter((j) => cmpVersions(j.cached, j.installed) < 0);
57+
if (stale.length) out.push({ dir, stale });
58+
}
59+
return out;
60+
}
61+
62+
/** Remove stale envs. Returns {ok, detail}; ok=false only on a failed removal.
63+
* @param {{ root?: string, baseline?: (pkg: string) => string | null }} [opts] */
64+
export function pruneNpxStale({ root, baseline } = {}) {
65+
const found = scanNpxStale({ ...(root && { root }), ...(baseline && { baseline }) });
66+
if (!found.length) return { ok: true, detail: 'no stale envs' };
67+
const removed = []; const failed = [];
68+
for (const e of found) {
69+
const label = e.stale.map((s) => `${s.pkg}@${s.cached}`).join('+');
70+
try { fs.rmSync(e.dir, { recursive: true, force: true }); removed.push(label); } catch { failed.push(label); }
71+
}
72+
const parts = [];
73+
if (removed.length) parts.push(`pruned ${removed.length} env(s): ${removed.join(', ')} (npx re-fetches on demand)`);
74+
if (failed.length) parts.push(`FAILED to remove: ${failed.join(', ')}`);
75+
return { ok: !failed.length, detail: parts.join('; ') };
76+
}

‎src/lib/paths.mjs‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,19 @@ export function globalRoot() {
6464
/** For tests: override the cached global root. */
6565
export function _setGlobalRootForTest(p) { _globalRoot = p; }
6666

67+
/** npm's npx cache (`<npm-cache>/_npx`). Resolved from npm_config_cache or the
68+
* platform default (~/.npm on POSIX, %LocalAppData%\npm-cache on npm>=7
69+
* Windows) WITHOUT spawning npm: a `npm config set cache` userconfig custom
70+
* path would be missed, but a miss only means an empty scan — the stale-env
71+
* prune quietly does nothing, it never prunes the wrong directory. */
72+
export const npxCacheDir = () => {
73+
const cache = process.env.npm_config_cache
74+
|| (isWindows
75+
? path.join(process.env.LOCALAPPDATA || path.join(home, 'AppData', 'Local'), 'npm-cache')
76+
: path.join(home, '.npm'));
77+
return path.join(cache, '_npx');
78+
};
79+
6780
export const rufloRoot = () => path.join(globalRoot(), 'ruflo');
6881
export const rufloNodeModules = () => path.join(rufloRoot(), 'node_modules');
6982
export const rufloCliDist = () =>

‎tests/kit/npx.test.mjs‎

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
// scanNpxStale / pruneNpxStale — the stale npx-env prune behind `ak sync`.
2+
// Uses a synthetic _npx fixture and an injected baseline, so the test is
3+
// hermetic (no npm, no network, never the machine's real cache).
4+
//
5+
// The invariant under test is the conservative prune rule: a stale env is
6+
// removed ONLY when every package it is keyed to is judgeable (managed +
7+
// installed baseline + readable cached copy) and at least one cached copy is
8+
// strictly older. Anything unjudgeable is left alone — misses must fail safe
9+
// as "not pruned", never as a wrong prune.
10+
import { test } from 'node:test';
11+
import assert from 'node:assert/strict';
12+
import fs from 'node:fs';
13+
import os from 'node:os';
14+
import path from 'node:path';
15+
import { scanNpxStale, pruneNpxStale, managedBaseline } from '../../src/lib/npx.mjs';
16+
import { _setGlobalRootForTest } from '../../src/lib/paths.mjs';
17+
18+
// One npx env: <root>/<name>/package.json (keyed spec) + node_modules/<pkg> copies.
19+
function env(root, name, keyed, copies = {}) {
20+
const dir = path.join(root, name);
21+
fs.mkdirSync(dir, { recursive: true });
22+
fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({ dependencies: keyed }));
23+
for (const [pkg, version] of Object.entries(copies)) {
24+
const p = path.join(dir, 'node_modules', pkg);
25+
fs.mkdirSync(p, { recursive: true });
26+
fs.writeFileSync(path.join(p, 'package.json'), JSON.stringify({ name: pkg, version }));
27+
}
28+
return dir;
29+
}
30+
31+
const INSTALLED = { ruflo: '3.32.2', '@claude-flow/cli': '3.32.2', 'agentic-qe': '3.12.2' };
32+
const baseline = (pkg) => INSTALLED[pkg] ?? null;
33+
const mkroot = () => fs.mkdtempSync(path.join(os.tmpdir(), 'ak-npx-'));
34+
35+
test('scan flags an env whose cached copy is strictly older than the baseline', () => {
36+
const root = mkroot();
37+
const dir = env(root, 'aaa', { ruflo: '^3.21.1' }, { ruflo: '3.21.1' });
38+
const found = scanNpxStale({ root, baseline });
39+
assert.deepEqual(found, [{ dir, stale: [{ pkg: 'ruflo', installed: '3.32.2', cached: '3.21.1' }] }]);
40+
fs.rmSync(root, { recursive: true, force: true });
41+
});
42+
43+
test('scan keeps an env whose cached copy matches the installed version', () => {
44+
const root = mkroot();
45+
env(root, 'bbb', { '@claude-flow/cli': '^3.32.0' }, { '@claude-flow/cli': '3.32.2' });
46+
assert.deepEqual(scanNpxStale({ root, baseline }), []);
47+
fs.rmSync(root, { recursive: true, force: true });
48+
});
49+
50+
test('scan keeps an env cached NEWER than the install — only strictly older is stale', () => {
51+
const root = mkroot();
52+
env(root, 'ccc', { ruflo: '^3.33.0' }, { ruflo: '3.33.0' });
53+
assert.deepEqual(scanNpxStale({ root, baseline }), []);
54+
fs.rmSync(root, { recursive: true, force: true });
55+
});
56+
57+
test('scan keeps an env keyed to any unmanaged package — partial verdicts never prune', () => {
58+
const root = mkroot();
59+
// ruflo copy is stale, but the pnpm key is unjudgeable → whole env exempt.
60+
env(root, 'ddd', { ruflo: '^3.21.1', pnpm: '^9' }, { ruflo: '3.21.1', pnpm: '9.0.0' });
61+
assert.deepEqual(scanNpxStale({ root, baseline }), []);
62+
fs.rmSync(root, { recursive: true, force: true });
63+
});
64+
65+
test('scan keeps a managed env when no installed baseline exists to judge against', () => {
66+
const root = mkroot();
67+
env(root, 'eee', { 'agentic-qe': '^3.11.5' }, { 'agentic-qe': '3.11.5' });
68+
assert.deepEqual(scanNpxStale({ root, baseline: () => null }), []);
69+
fs.rmSync(root, { recursive: true, force: true });
70+
});
71+
72+
test('scan keeps an env whose cached copy is unreadable — no version, no verdict', () => {
73+
const root = mkroot();
74+
env(root, 'fff', { ruflo: '^3.21.1' }, {}); // keyed but node_modules copy missing
75+
assert.deepEqual(scanNpxStale({ root, baseline }), []);
76+
fs.rmSync(root, { recursive: true, force: true });
77+
});
78+
79+
test('scan of a missing cache dir returns empty, never throws', () => {
80+
assert.deepEqual(scanNpxStale({ root: path.join(os.tmpdir(), 'ak-npx-does-not-exist'), baseline }), []);
81+
});
82+
83+
test('prune removes exactly the stale envs and reports what it removed', () => {
84+
const root = mkroot();
85+
const stale = env(root, 'stale', { '@claude-flow/cli': '^3.28.0' }, { '@claude-flow/cli': '3.28.0' });
86+
const current = env(root, 'current', { ruflo: '^3.32.2' }, { ruflo: '3.32.2' });
87+
const foreign = env(root, 'foreign', { typescript: '^5' }, { typescript: '5.5.0' });
88+
89+
const r = pruneNpxStale({ root, baseline });
90+
91+
assert.equal(r.ok, true);
92+
assert.match(r.detail, /@claude-flow\/cli@3\.28\.0/);
93+
assert.equal(fs.existsSync(stale), false, 'stale env removed');
94+
assert.equal(fs.existsSync(current), true, 'current env kept');
95+
assert.equal(fs.existsSync(foreign), true, 'foreign env kept');
96+
fs.rmSync(root, { recursive: true, force: true });
97+
});
98+
99+
test('prune reports "no stale envs" on a clean cache without touching anything', () => {
100+
const root = mkroot();
101+
const kept = env(root, 'ok', { ruflo: '^3.32.2' }, { ruflo: '3.32.2' });
102+
const r = pruneNpxStale({ root, baseline });
103+
assert.deepEqual(r, { ok: true, detail: 'no stale envs' });
104+
assert.equal(fs.existsSync(kept), true);
105+
fs.rmSync(root, { recursive: true, force: true });
106+
});
107+
108+
test('managedBaseline resolves @claude-flow/cli from its NESTED location under ruflo', () => {
109+
const groot = fs.mkdtempSync(path.join(os.tmpdir(), 'ak-npx-groot-'));
110+
const nested = path.join(groot, 'ruflo', 'node_modules', '@claude-flow', 'cli');
111+
fs.mkdirSync(nested, { recursive: true });
112+
fs.writeFileSync(path.join(nested, 'package.json'), JSON.stringify({ version: '3.32.2' }));
113+
_setGlobalRootForTest(groot);
114+
assert.equal(managedBaseline('@claude-flow/cli'), '3.32.2');
115+
assert.equal(managedBaseline('left-pad'), null, 'unmanaged packages are never judged');
116+
fs.rmSync(groot, { recursive: true, force: true });
117+
});

0 commit comments

Comments
 (0)