Files
thedotmack-claude-mem/plugin/scripts/version-check.js
Ben Younes eae20a410a fix(setup): auto-install plugin dependencies at Setup phase (2649) (#2650)
* fix(setup): auto-install plugin dependencies at Setup phase (2649)

The plugin marketplace extracts files into ~/.claude/plugins/cache/...
but does not run `bun install`. On fresh installs the worker crashes
with `Cannot find module 'zod/v3'` on the first hook invocation
(gh #2640, #2637).

PR #2644 fixed this by auto-installing dependencies inside
`bun-runner.js`, but that runs on the SessionStart / UserPromptSubmit
critical path. Review on #2644 (YOMXXX, #2649) flagged this as the
wrong architectural home: corporate proxies, offline machines,
permissions, registry timeouts — every install failure lands on the
user's first prompt instead of at install time.

Move the auto-install to `version-check.js` (Setup phase), the only
standalone hook script and the natural place to materialise plugin
runtime state. Setup has a 300s timeout (vs 60s for SessionStart),
runs once per Claude Code launch, and the node_modules guard short-
circuits subsequent invocations.

Failure modes surfaced explicitly (spawn exception, non-zero exit,
signal-killed including OOM SIGKILL — where spawnSync leaves
status=null and error=undefined).

RED → GREEN proof:
- /tmp/issue-2649-proof/RED.log  (unpatched: 1 pass, 1 fail)
- /tmp/issue-2649-proof/GREEN.log (patched: 2 pass, 0 fail)
- Full-suite baseline: 1803 pass / 55 fail (unchanged after fix:
  1805 pass / 55 fail — +2 = new tests, 0 regressions).

Existing `plugin-version-check.test.ts` updated to pre-create
node_modules in beforeEach so its marker-compat assertions
(`stderr === ''`) are unaffected by the new Setup-phase install path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(setup): cleanup partial node_modules + success log (#2650 review)

Address Greptile review findings on PR #2650:

P1 — failed install permanently blocks retry. `bun install` often
creates the node_modules directory before it terminates under failure
(network timeout mid-fetch, registry 5xx, OOM kill). The
existsSync(node_modules) guard would then permanently skip every
subsequent Setup run, leaving the plugin broken with no recovery short
of manual `rm -rf node_modules`. Remove the partial dir in the failure
branch so the next Setup invocation can retry automatically.

P2 — no completion confirmation. A Setup hook that can block for up to
120s needs an explicit success line so users can distinguish a hung
install from one that finished silently. Emit a success diagnostic in
the no-error else branch.

New test `cleans up partial node_modules after a failed install` uses
a `partial-then-fail` fake-bun behavior that creates node_modules then
exits non-zero (mirrors real bun under mid-fetch failure). Asserts the
failure diagnostic surfaces AND node_modules is gone after, proving
the retry path is unblocked.

RED -> GREEN proof:
- /tmp/issue-2650-proof/RED-review-fixes.log
  (without fix: 1 pass / 2 fail — success diagnostic missing AND
   partial node_modules NOT cleaned up)
- /tmp/issue-2650-proof/GREEN-review-fixes.log
  (with fix: 3 pass / 0 fail)

Full-suite baseline preserved:
- /tmp/issue-2650-proof/full-suite-GREEN.log
  (1806 pass / 19 skip / 55 fail — same 55 pre-existing failures,
   +1 new test vs prior commit, 0 regressions)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-05 23:04:54 -07:00

194 lines
7.3 KiB
JavaScript

#!/usr/bin/env node
import { spawnSync } from 'child_process';
import { existsSync, readFileSync, rmSync } from 'fs';
import { homedir } from 'os';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
const IS_WINDOWS = process.platform === 'win32';
const VERSION_CHECK_LOG_PREFIX = '[version-check]';
const BUN_INSTALL_ARGS = Object.freeze(['install', '--production']);
const BUN_INSTALL_TIMEOUT_MS = 120_000;
const NODE_MODULES_DIRNAME = 'node_modules';
function findBun() {
const pathCheck = IS_WINDOWS
? spawnSync('where bun', { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'], shell: true })
: spawnSync('which', ['bun'], { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] });
if (pathCheck.status === 0 && pathCheck.stdout.trim()) {
if (IS_WINDOWS) {
const bunCmdPath = pathCheck.stdout.split('\n').find((line) => line.trim().endsWith('bun.cmd'));
if (bunCmdPath) return bunCmdPath.trim();
}
return 'bun';
}
const bunPaths = IS_WINDOWS
? [join(homedir(), '.bun', 'bin', 'bun.exe')]
: [
join(homedir(), '.bun', 'bin', 'bun'),
'/usr/local/bin/bun',
'/opt/homebrew/bin/bun',
'/home/linuxbrew/.linuxbrew/bin/bun',
];
for (const bunPath of bunPaths) {
if (existsSync(bunPath)) return bunPath;
}
return null;
}
// Setup-phase auto-install of plugin runtime dependencies.
//
// The plugin marketplace extracts files into ~/.claude/plugins/cache/...
// but does not run `bun install`. On fresh installs the worker crashes
// with `Cannot find module 'zod/v3'` on the very first hook invocation
// (gh #2640, #2637). The previous defense-in-depth fix (gh #2644) ran
// the install on the SessionStart / UserPromptSubmit hot path; review
// (gh #2649 — YOMXXX) flagged that as the wrong architectural home
// because it makes proxy / offline / OOM failures land on the user's
// first prompt instead of at install time.
//
// Running it here at Setup keeps the install off the hot path: Setup
// has a 300s timeout (vs 60s for SessionStart), runs once per Claude
// Code launch, and is the only standalone hook script — the natural
// place to materialise plugin runtime state.
function ensurePluginDependencies(pluginRoot) {
if (!existsSync(join(pluginRoot, 'package.json'))) return;
// Guard on node_modules (package-manager marker) rather than a specific
// package, so the check stays correct if dependencies are later renamed.
if (existsSync(join(pluginRoot, NODE_MODULES_DIRNAME))) return;
const bunPath = findBun();
if (!bunPath) {
console.error(`${VERSION_CHECK_LOG_PREFIX} bun not found on PATH; cannot auto-install plugin dependencies`);
return;
}
// Progress diagnostic so users understand the (one-time) Setup hang.
console.error(`${VERSION_CHECK_LOG_PREFIX} installing plugin dependencies (first run, one-time)...`);
let result;
try {
result = spawnSync(bunPath, BUN_INSTALL_ARGS, {
cwd: pluginRoot,
encoding: 'utf-8',
stdio: ['pipe', 'pipe', 'pipe'],
timeout: BUN_INSTALL_TIMEOUT_MS,
windowsHide: true,
});
} catch (err) {
const reason = err && err.message ? err.message : String(err);
console.error(`${VERSION_CHECK_LOG_PREFIX} bun install threw (${reason}); worker may crash with missing module errors`);
return;
}
// spawnSync does NOT throw on a failed child. Three distinct failure
// modes must be surfaced explicitly:
// 1. result.error set (ENOENT / ETIMEDOUT / ...)
// 2. non-zero exit code
// 3. signal-killed (OOM SIGKILL, SIGTERM, ...) where result.status is
// null AND result.error is undefined — only result.signal is set.
const killedBySignal = result.status === null && !!result.signal;
const nonZeroExit = result.status !== null && result.status !== 0;
if (result.error || nonZeroExit || killedBySignal) {
let reason;
if (result.error) {
reason = result.error.message;
} else if (killedBySignal) {
reason = `killed by ${result.signal}`;
} else {
reason = `exit ${result.status}`;
}
console.error(`${VERSION_CHECK_LOG_PREFIX} bun install failed (${reason}); worker may crash with missing module errors`);
// `bun install` often creates `node_modules/` BEFORE the failure point
// (network timeout mid-fetch, OOM kill, registry 5xx after partial
// resolution). The existence guard above would then permanently skip
// retry on every subsequent Setup run, leaving the plugin broken with
// no recovery path short of manual `rm -rf node_modules`. Remove the
// partial dir so the next Setup invocation can retry automatically
// (gh #2650 review).
try {
rmSync(join(pluginRoot, NODE_MODULES_DIRNAME), { recursive: true, force: true });
} catch (rmErr) {
const rmReason = rmErr && rmErr.message ? rmErr.message : String(rmErr);
console.error(`${VERSION_CHECK_LOG_PREFIX} failed to clean up partial node_modules (${rmReason}); next Setup run may skip retry`);
}
} else {
// Close the diagnostic loop: a Setup hook that can block for up to
// 120s needs an explicit completion line so users can distinguish a
// hung install from one that finished silently (gh #2650 review).
console.error(`${VERSION_CHECK_LOG_PREFIX} plugin dependencies installed successfully`);
}
}
function resolveRoot() {
if (process.env.CLAUDE_PLUGIN_ROOT) {
const root = process.env.CLAUDE_PLUGIN_ROOT;
if (existsSync(join(root, 'package.json'))) return root;
}
try {
const scriptDir = dirname(fileURLToPath(import.meta.url));
const candidate = dirname(scriptDir);
if (existsSync(join(candidate, 'package.json'))) return candidate;
} catch {}
return null;
}
const ROOT = resolveRoot();
if (!ROOT) process.exit(0);
ensurePluginDependencies(ROOT);
function emitUpgradeHint(message) {
if (process.env.CLAUDE_MEM_CODEX_HOOK === '1') {
console.log(JSON.stringify({
hookSpecificOutput: {
hookEventName: 'SessionStart',
additionalContext: message,
},
}));
} else {
console.error(message);
}
}
const LEGACY_VERSION_MARKER_RE =
/^v?\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
function readInstallMarkerVersion(markerPath) {
const content = readFileSync(markerPath, 'utf-8');
try {
const marker = JSON.parse(content);
return marker && typeof marker === 'object' && typeof marker.version === 'string'
? marker.version
: null;
} catch {
const legacyVersion = content.trim();
return LEGACY_VERSION_MARKER_RE.test(legacyVersion)
? legacyVersion.replace(/^v/i, '')
: null;
}
}
try {
const pkg = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf-8'));
const markerPath = join(ROOT, '.install-version');
if (!existsSync(markerPath)) {
emitUpgradeHint('claude-mem: runtime not yet set up - run: npx claude-mem@latest install');
process.exit(0);
}
const markerVersion = readInstallMarkerVersion(markerPath);
if (!markerVersion) {
emitUpgradeHint('claude-mem: install marker unreadable - run: npx claude-mem@latest install');
} else if (markerVersion !== pkg.version) {
emitUpgradeHint(`claude-mem: upgraded to v${pkg.version} - run: npx claude-mem@latest install`);
}
} catch {
emitUpgradeHint('claude-mem: install marker unreadable - run: npx claude-mem@latest install');
}
process.exit(0);