Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions src/commands/status.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import path from 'node:path';
import { glyph, dim, bold } from '../lib/output.mjs';
import * as paths from '../lib/paths.mjs';
import { nativesStatus, aidefencePresent, securityPresent } from '../lib/natives.mjs';
import { scanNpxStale } from '../lib/npx.mjs';
import { registrationStatus } from '../lib/mcp.mjs';
import { listDaemons, staleDaemons } from '../lib/daemons.mjs';
import { scanRvf } from '../lib/rvf.mjs';
Expand Down Expand Up @@ -118,6 +119,22 @@ export async function collect({ pkgRoot, cwd = process.cwd() }) {
rows.push(row('natives', 'warn', `native check unavailable: ${e.message}`));
}

// npx (stale ruflo-family cache envs — `npx --prefer-offline` fallbacks in the
// statusline/hooks execute these verbatim, keeping retired defects alive)
try {
const stale = scanNpxStale();
if (stale.length) {
const what = stale.flatMap((e) => e.stale.map((s) => `${s.pkg}@${s.cached}`)).join(', ');
rows.push(row('npx', 'warn',
`${stale.length} stale npx env(s) serve outdated code (${what})`,
'sync prunes them (npx re-fetches on demand)'));
} else {
rows.push(row('npx', 'ok', 'npx cache holds no stale ruflo-family envs'));
}
} catch (e) {
rows.push(row('npx', 'warn', `npx cache check unavailable: ${e.message}`));
}

// security surface
if (securityPresent()) {
if (aidefencePresent()) {
Expand Down
9 changes: 9 additions & 0 deletions src/commands/sync.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { listDaemons, staleDaemons, reap } from '../lib/daemons.mjs';
import { loadKitConfig } from '../lib/config.mjs';
import { HOSTS, applyHosts, applyProviders, hostInstallState, installHost, applyAqeRouter } from '../lib/providers.mjs';
import { driftReport, selfDrift } from '../lib/versions.mjs';
import { pruneNpxStale } from '../lib/npx.mjs';
import * as paths from '../lib/paths.mjs';
import { ok, warn, fail, bold, dim } from '../lib/output.mjs';

Expand Down Expand Up @@ -82,6 +83,14 @@ export async function run({ flags, pkgRoot }) {
if (subsystems.has('natives') || subsystems.has('versions') || subsystems.has('security')) {
report('natives', await heal.healNatives());
}
// npx: prune cached envs serving outdated ruflo-family code — the statusline/
// hook `npx --prefer-offline` fallbacks execute these verbatim, so a stale env
// keeps retired defects (the fabricated CVE counter) alive on an upgraded
// machine. Runs on `versions` too: an upgrade is precisely what turns a
// previously-current cache stale.
if (subsystems.has('npx') || subsystems.has('versions')) {
report('npx', pruneNpxStale());
}
if (subsystems.has('aqe')) {
report('rvf', heal.healRvf(paths.projectAqeDir(cwd)));
}
Expand Down
76 changes: 76 additions & 0 deletions src/lib/npx.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
// Stale npx-cache detection for the ruflo family. 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. That is how a machine running a fixed ruflo
// 3.32.2 kept executing a cached 3.28.0 (statusline npx fallback) and rendering
// its fabricated CVE counter; six such envs held ~6.4 GB of retired code.
//
// Prune rule — conservative by construction, a miss only means "not pruned":
// · every package the env is keyed to (its package.json dependencies) must be
// kit-managed — an env we can't fully judge is left alone;
// · each managed package needs an installed global baseline to compare against
// — no baseline, no judgement, no prune;
// · at least one cached copy must be STRICTLY older than its baseline
// (equal-or-newer stays: a current cache is what a pre-install machine's
// npx fallback runs).
// Envs are pure caches; npx re-fetches on demand, so removal is always safe.
import fs from 'node:fs';
import path from 'node:path';
import { npxCacheDir, rufloNodeModules } from './paths.mjs';
import { installedVersion, cmpVersions } from './versions.mjs';

const readPkg = (dir) => {
try { return JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8')); } catch { return null; }
};

/** Installed baseline per managed package. @claude-flow/cli is ruflo's NESTED
* dependency (never a top-level global), so it can't go through
* installedVersion — the same layout fact behind the statusline bin fix. */
export function managedBaseline(pkg) {
if (pkg === '@claude-flow/cli') {
return readPkg(path.join(rufloNodeModules(), '@claude-flow', 'cli'))?.version ?? null;
}
if (pkg === 'ruflo' || pkg === 'agentic-qe') return installedVersion(pkg);
return null; // not a package the kit manages — never judged, never pruned
}

/** Stale envs under the npx cache. Returns [{dir, stale: [{pkg, cached, installed}]}].
* `root`/`baseline` are injectable so tests run against fixtures, no real cache. */
export function scanNpxStale({ root = npxCacheDir(), baseline = managedBaseline } = {}) {
let entries;
try { entries = fs.readdirSync(root); } catch { return []; } // no cache dir: nothing to do
const out = [];
for (const name of entries) {
const dir = path.join(root, name);
const keyed = readPkg(dir)?.dependencies;
const pkgs = keyed ? Object.keys(keyed) : [];
if (!pkgs.length) continue;
const judged = pkgs.map((pkg) => ({
pkg,
installed: baseline(pkg),
cached: readPkg(path.join(dir, 'node_modules', pkg))?.version ?? null,
}));
// One unjudgeable package (unmanaged, no baseline, unreadable copy) exempts
// the whole env — partial verdicts are how wrong prunes happen.
if (judged.some((j) => !j.installed || !j.cached)) continue;
const stale = judged.filter((j) => cmpVersions(j.cached, j.installed) < 0);
if (stale.length) out.push({ dir, stale });
}
return out;
}

/** Remove stale envs. Returns {ok, detail}; ok=false only on a failed removal.
* @param {{ root?: string, baseline?: (pkg: string) => string | null }} [opts] */
export function pruneNpxStale({ root, baseline } = {}) {
const found = scanNpxStale({ ...(root && { root }), ...(baseline && { baseline }) });
if (!found.length) return { ok: true, detail: 'no stale envs' };
const removed = []; const failed = [];
for (const e of found) {
const label = e.stale.map((s) => `${s.pkg}@${s.cached}`).join('+');
try { fs.rmSync(e.dir, { recursive: true, force: true }); removed.push(label); } catch { failed.push(label); }
}
const parts = [];
if (removed.length) parts.push(`pruned ${removed.length} env(s): ${removed.join(', ')} (npx re-fetches on demand)`);
if (failed.length) parts.push(`FAILED to remove: ${failed.join(', ')}`);
return { ok: !failed.length, detail: parts.join('; ') };
}
13 changes: 13 additions & 0 deletions src/lib/paths.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,19 @@ export function globalRoot() {
/** For tests: override the cached global root. */
export function _setGlobalRootForTest(p) { _globalRoot = p; }

/** npm's npx cache (`<npm-cache>/_npx`). Resolved from npm_config_cache or the
* platform default (~/.npm on POSIX, %LocalAppData%\npm-cache on npm>=7
* Windows) WITHOUT spawning npm: a `npm config set cache` userconfig custom
* path would be missed, but a miss only means an empty scan — the stale-env
* prune quietly does nothing, it never prunes the wrong directory. */
export const npxCacheDir = () => {
const cache = process.env.npm_config_cache
|| (isWindows
? path.join(process.env.LOCALAPPDATA || path.join(home, 'AppData', 'Local'), 'npm-cache')
: path.join(home, '.npm'));
return path.join(cache, '_npx');
};

export const rufloRoot = () => path.join(globalRoot(), 'ruflo');
export const rufloNodeModules = () => path.join(rufloRoot(), 'node_modules');
export const rufloCliDist = () =>
Expand Down
117 changes: 117 additions & 0 deletions tests/kit/npx.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
// scanNpxStale / pruneNpxStale — the stale npx-env prune behind `ak sync`.
// Uses a synthetic _npx fixture and an injected baseline, so the test is
// hermetic (no npm, no network, never the machine's real cache).
//
// The invariant under test is the conservative prune rule: a stale env is
// removed ONLY when every package it is keyed to is judgeable (managed +
// installed baseline + readable cached copy) and at least one cached copy is
// strictly older. Anything unjudgeable is left alone — misses must fail safe
// as "not pruned", never as a wrong prune.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { scanNpxStale, pruneNpxStale, managedBaseline } from '../../src/lib/npx.mjs';
import { _setGlobalRootForTest } from '../../src/lib/paths.mjs';

// One npx env: <root>/<name>/package.json (keyed spec) + node_modules/<pkg> copies.
function env(root, name, keyed, copies = {}) {
const dir = path.join(root, name);
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({ dependencies: keyed }));
for (const [pkg, version] of Object.entries(copies)) {
const p = path.join(dir, 'node_modules', pkg);
fs.mkdirSync(p, { recursive: true });
fs.writeFileSync(path.join(p, 'package.json'), JSON.stringify({ name: pkg, version }));
}
return dir;
}

const INSTALLED = { ruflo: '3.32.2', '@claude-flow/cli': '3.32.2', 'agentic-qe': '3.12.2' };
const baseline = (pkg) => INSTALLED[pkg] ?? null;
const mkroot = () => fs.mkdtempSync(path.join(os.tmpdir(), 'ak-npx-'));

test('scan flags an env whose cached copy is strictly older than the baseline', () => {
const root = mkroot();
const dir = env(root, 'aaa', { ruflo: '^3.21.1' }, { ruflo: '3.21.1' });
const found = scanNpxStale({ root, baseline });
assert.deepEqual(found, [{ dir, stale: [{ pkg: 'ruflo', installed: '3.32.2', cached: '3.21.1' }] }]);
fs.rmSync(root, { recursive: true, force: true });
});

test('scan keeps an env whose cached copy matches the installed version', () => {
const root = mkroot();
env(root, 'bbb', { '@claude-flow/cli': '^3.32.0' }, { '@claude-flow/cli': '3.32.2' });
assert.deepEqual(scanNpxStale({ root, baseline }), []);
fs.rmSync(root, { recursive: true, force: true });
});

test('scan keeps an env cached NEWER than the install — only strictly older is stale', () => {
const root = mkroot();
env(root, 'ccc', { ruflo: '^3.33.0' }, { ruflo: '3.33.0' });
assert.deepEqual(scanNpxStale({ root, baseline }), []);
fs.rmSync(root, { recursive: true, force: true });
});

test('scan keeps an env keyed to any unmanaged package — partial verdicts never prune', () => {
const root = mkroot();
// ruflo copy is stale, but the pnpm key is unjudgeable → whole env exempt.
env(root, 'ddd', { ruflo: '^3.21.1', pnpm: '^9' }, { ruflo: '3.21.1', pnpm: '9.0.0' });
assert.deepEqual(scanNpxStale({ root, baseline }), []);
fs.rmSync(root, { recursive: true, force: true });
});

test('scan keeps a managed env when no installed baseline exists to judge against', () => {
const root = mkroot();
env(root, 'eee', { 'agentic-qe': '^3.11.5' }, { 'agentic-qe': '3.11.5' });
assert.deepEqual(scanNpxStale({ root, baseline: () => null }), []);
fs.rmSync(root, { recursive: true, force: true });
});

test('scan keeps an env whose cached copy is unreadable — no version, no verdict', () => {
const root = mkroot();
env(root, 'fff', { ruflo: '^3.21.1' }, {}); // keyed but node_modules copy missing
assert.deepEqual(scanNpxStale({ root, baseline }), []);
fs.rmSync(root, { recursive: true, force: true });
});

test('scan of a missing cache dir returns empty, never throws', () => {
assert.deepEqual(scanNpxStale({ root: path.join(os.tmpdir(), 'ak-npx-does-not-exist'), baseline }), []);
});

test('prune removes exactly the stale envs and reports what it removed', () => {
const root = mkroot();
const stale = env(root, 'stale', { '@claude-flow/cli': '^3.28.0' }, { '@claude-flow/cli': '3.28.0' });
const current = env(root, 'current', { ruflo: '^3.32.2' }, { ruflo: '3.32.2' });
const foreign = env(root, 'foreign', { typescript: '^5' }, { typescript: '5.5.0' });

const r = pruneNpxStale({ root, baseline });

assert.equal(r.ok, true);
assert.match(r.detail, /@claude-flow\/cli@3\.28\.0/);
assert.equal(fs.existsSync(stale), false, 'stale env removed');
assert.equal(fs.existsSync(current), true, 'current env kept');
assert.equal(fs.existsSync(foreign), true, 'foreign env kept');
fs.rmSync(root, { recursive: true, force: true });
});

test('prune reports "no stale envs" on a clean cache without touching anything', () => {
const root = mkroot();
const kept = env(root, 'ok', { ruflo: '^3.32.2' }, { ruflo: '3.32.2' });
const r = pruneNpxStale({ root, baseline });
assert.deepEqual(r, { ok: true, detail: 'no stale envs' });
assert.equal(fs.existsSync(kept), true);
fs.rmSync(root, { recursive: true, force: true });
});

test('managedBaseline resolves @claude-flow/cli from its NESTED location under ruflo', () => {
const groot = fs.mkdtempSync(path.join(os.tmpdir(), 'ak-npx-groot-'));
const nested = path.join(groot, 'ruflo', 'node_modules', '@claude-flow', 'cli');
fs.mkdirSync(nested, { recursive: true });
fs.writeFileSync(path.join(nested, 'package.json'), JSON.stringify({ version: '3.32.2' }));
_setGlobalRootForTest(groot);
assert.equal(managedBaseline('@claude-flow/cli'), '3.32.2');
assert.equal(managedBaseline('left-pad'), null, 'unmanaged packages are never judged');
fs.rmSync(groot, { recursive: true, force: true });
});
Loading