mirror of
https://github.com/thedotmack/claude-mem.git
synced 2026-07-03 20:59:42 +08:00
* 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>
194 lines
7.3 KiB
JavaScript
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);
|