diff --git a/claude-code/bundle/plugin-cache-gc.js b/claude-code/bundle/plugin-cache-gc.js new file mode 100755 index 0000000..0e2e910 --- /dev/null +++ b/claude-code/bundle/plugin-cache-gc.js @@ -0,0 +1,179 @@ +#!/usr/bin/env node + +// dist/src/hooks/plugin-cache-gc.js +import { fileURLToPath, pathToFileURL } from "node:url"; +import { dirname as dirname2 } from "node:path"; + +// dist/src/utils/debug.js +import { appendFileSync } from "node:fs"; +import { join } from "node:path"; +import { homedir } from "node:os"; +var DEBUG = process.env.HIVEMIND_DEBUG === "1"; +var LOG = join(homedir(), ".deeplake", "hook-debug.log"); +function log(tag, msg) { + if (!DEBUG) + return; + appendFileSync(LOG, `${(/* @__PURE__ */ new Date()).toISOString()} [${tag}] ${msg} +`); +} + +// dist/src/utils/plugin-cache.js +import { cpSync, existsSync, readdirSync, readFileSync, renameSync, rmSync, statSync } from "node:fs"; +import { basename, dirname, join as join2, resolve, sep } from "node:path"; +import { homedir as homedir2 } from "node:os"; +var SEMVER_RE = /^\d+\.\d+\.\d+$/; +var KEEP_RE = /\.keep-(\d+)$/; +function isSemver(name) { + return SEMVER_RE.test(name); +} +function compareSemverDesc(a, b) { + const pa = a.split(".").map(Number); + const pb = b.split(".").map(Number); + for (let i = 0; i < 3; i++) { + if (pa[i] !== pb[i]) + return pb[i] - pa[i]; + } + return 0; +} +function resolveVersionedPluginDir(bundleDir) { + const pluginDir = dirname(bundleDir); + const versionsRoot = dirname(pluginDir); + const version = basename(pluginDir); + if (!isSemver(version)) + return null; + if (basename(versionsRoot) !== "hivemind") + return null; + const expectedPrefix = resolve(homedir2(), ".claude", "plugins", "cache") + sep; + if (!resolve(versionsRoot).startsWith(expectedPrefix)) + return null; + return { pluginDir, versionsRoot, version }; +} +function isPidAlive(pid) { + try { + process.kill(pid, 0); + return true; + } catch (e) { + return e?.code === "EPERM"; + } +} +function readCurrentVersionFromManifest(manifestPath) { + try { + const raw = readFileSync(manifestPath, "utf-8"); + const parsed = JSON.parse(raw); + const entries = parsed?.plugins?.["hivemind@hivemind"]; + if (!Array.isArray(entries)) + return null; + for (const e of entries) { + if (typeof e?.version === "string" && isSemver(e.version)) + return e.version; + } + return null; + } catch { + return null; + } +} +function planGc(versionsRoot, currentVersion, keepCount, isAlive = isPidAlive) { + const entries = safeReaddir(versionsRoot); + const versions = entries.filter(isSemver); + const snapshots = entries.filter((e) => KEEP_RE.test(e)); + const sorted = [...versions].sort(compareSemverDesc); + const keep = /* @__PURE__ */ new Set(); + if (currentVersion && versions.includes(currentVersion)) + keep.add(currentVersion); + for (const v of sorted) { + if (keep.size >= keepCount) + break; + keep.add(v); + } + const deleteVersions = []; + if (currentVersion && versions.includes(currentVersion)) { + for (const v of versions) { + if (!keep.has(v)) + deleteVersions.push(v); + } + } + const deleteSnapshots = []; + for (const s of snapshots) { + const m = s.match(KEEP_RE); + if (!m) + continue; + const pid = Number(m[1]); + if (!Number.isFinite(pid) || !isAlive(pid)) + deleteSnapshots.push(s); + } + return { keep: [...keep], deleteVersions, deleteSnapshots }; +} +function executeGc(versionsRoot, plan) { + const errors = []; + const deletedVersions = []; + const deletedSnapshots = []; + for (const v of plan.deleteVersions) { + const target = join2(versionsRoot, v); + try { + rmSync(target, { recursive: true, force: true }); + deletedVersions.push(v); + } catch (e) { + errors.push(`${v}: ${e.message}`); + } + } + for (const s of plan.deleteSnapshots) { + const target = join2(versionsRoot, s); + try { + rmSync(target, { recursive: true, force: true }); + deletedSnapshots.push(s); + } catch (e) { + errors.push(`${s}: ${e.message}`); + } + } + return { kept: plan.keep, deletedVersions, deletedSnapshots, errors }; +} +function safeReaddir(dir) { + try { + return readdirSync(dir).filter((name) => { + try { + return statSync(join2(dir, name)).isDirectory(); + } catch { + return false; + } + }); + } catch { + return []; + } +} +var DEFAULT_MANIFEST_PATH = join2(homedir2(), ".claude", "plugins", "installed_plugins.json"); +var DEFAULT_KEEP_COUNT = 3; + +// dist/src/hooks/plugin-cache-gc.js +var defaultLog = (msg) => log("plugin-cache-gc", msg); +function runGc(bundleDir, opts = {}) { + const log2 = opts.log ?? defaultLog; + if (process.env.HIVEMIND_WIKI_WORKER === "1") + return; + const resolved = resolveVersionedPluginDir(bundleDir); + if (!resolved) { + log2("not a versioned install, skipping"); + return; + } + const manifestPath = opts.manifestPath ?? DEFAULT_MANIFEST_PATH; + const keepCount = opts.keepCount ?? DEFAULT_KEEP_COUNT; + const currentVersion = readCurrentVersionFromManifest(manifestPath); + const plan = planGc(resolved.versionsRoot, currentVersion, keepCount); + if (plan.deleteVersions.length === 0 && plan.deleteSnapshots.length === 0) { + log2(`nothing to gc (kept: ${plan.keep.join(", ")})`); + return; + } + const result = executeGc(resolved.versionsRoot, plan); + log2(`gc kept=${result.kept.join(",")} deletedVersions=${result.deletedVersions.join(",")} deletedSnapshots=${result.deletedSnapshots.join(",")} errors=${result.errors.length}`); +} +var __bundleDir = dirname2(fileURLToPath(import.meta.url)); +var __entryUrl = process.argv[1] ? pathToFileURL(process.argv[1]).href : ""; +if (import.meta.url === __entryUrl) { + try { + runGc(__bundleDir); + } catch (e) { + defaultLog(`fatal: ${e.message}`); + } +} +export { + runGc +}; diff --git a/claude-code/bundle/session-start-setup.js b/claude-code/bundle/session-start-setup.js index e184bce..5eff08e 100755 --- a/claude-code/bundle/session-start-setup.js +++ b/claude-code/bundle/session-start-setup.js @@ -2,9 +2,9 @@ // dist/src/hooks/session-start-setup.js import { fileURLToPath } from "node:url"; -import { dirname as dirname2, join as join7 } from "node:path"; +import { dirname as dirname3, join as join8 } from "node:path"; import { execSync as execSync2 } from "node:child_process"; -import { homedir as homedir4 } from "node:os"; +import { homedir as homedir5 } from "node:os"; // dist/src/commands/auth.js import { readFileSync, writeFileSync, existsSync, mkdirSync, unlinkSync } from "node:fs"; @@ -109,7 +109,7 @@ var MAX_CONCURRENCY = 5; var QUERY_TIMEOUT_MS = Number(process.env.HIVEMIND_QUERY_TIMEOUT_MS ?? 1e4); var INDEX_MARKER_TTL_MS = Number(process.env.HIVEMIND_INDEX_MARKER_TTL_MS ?? 6 * 60 * 6e4); function sleep(ms) { - return new Promise((resolve) => setTimeout(resolve, ms)); + return new Promise((resolve2) => setTimeout(resolve2, ms)); } function isTimeoutError(error) { const name = error instanceof Error ? error.name.toLowerCase() : ""; @@ -142,7 +142,7 @@ var Semaphore = class { this.active++; return; } - await new Promise((resolve) => this.waiting.push(resolve)); + await new Promise((resolve2) => this.waiting.push(resolve2)); } release() { this.active--; @@ -403,13 +403,13 @@ var DeeplakeApi = class { // dist/src/utils/stdin.js function readStdin() { - return new Promise((resolve, reject) => { + return new Promise((resolve2, reject) => { let data = ""; process.stdin.setEncoding("utf-8"); process.stdin.on("data", (chunk) => data += chunk); process.stdin.on("end", () => { try { - resolve(JSON.parse(data)); + resolve2(JSON.parse(data)); } catch (err) { reject(new Error(`Failed to parse hook input: ${err}`)); } @@ -482,10 +482,71 @@ function makeWikiLogger(hooksDir, filename = "deeplake-wiki.log") { }; } +// dist/src/utils/plugin-cache.js +import { cpSync, existsSync as existsSync4, readdirSync, readFileSync as readFileSync5, renameSync, rmSync, statSync } from "node:fs"; +import { basename, dirname as dirname2, join as join7, resolve, sep } from "node:path"; +import { homedir as homedir4 } from "node:os"; +var SEMVER_RE = /^\d+\.\d+\.\d+$/; +function isSemver(name) { + return SEMVER_RE.test(name); +} +function resolveVersionedPluginDir(bundleDir) { + const pluginDir = dirname2(bundleDir); + const versionsRoot = dirname2(pluginDir); + const version = basename(pluginDir); + if (!isSemver(version)) + return null; + if (basename(versionsRoot) !== "hivemind") + return null; + const expectedPrefix = resolve(homedir4(), ".claude", "plugins", "cache") + sep; + if (!resolve(versionsRoot).startsWith(expectedPrefix)) + return null; + return { pluginDir, versionsRoot, version }; +} +function snapshotPath(pluginDir, pid) { + return `${pluginDir}.keep-${pid}`; +} +function snapshotPluginDir(pluginDir, pid = process.pid) { + if (!existsSync4(pluginDir)) + return null; + const snapshot = snapshotPath(pluginDir, pid); + try { + rmSync(snapshot, { recursive: true, force: true }); + cpSync(pluginDir, snapshot, { recursive: true, dereference: false }); + return { pluginDir, snapshot }; + } catch { + return null; + } +} +function restoreOrCleanup(handle) { + if (!handle) + return "noop"; + const { pluginDir, snapshot } = handle; + try { + if (!existsSync4(pluginDir)) { + if (existsSync4(snapshot)) { + renameSync(snapshot, pluginDir); + return "restored"; + } + return "noop"; + } + rmSync(snapshot, { recursive: true, force: true }); + return "cleaned"; + } catch (e) { + try { + process.stderr.write(`[plugin-cache] restoreOrCleanup failed for ${pluginDir}: ${e?.message} +`); + } catch { + } + return "restore-failed"; + } +} +var DEFAULT_MANIFEST_PATH = join7(homedir4(), ".claude", "plugins", "installed_plugins.json"); + // dist/src/hooks/session-start-setup.js var log3 = (msg) => log("session-setup", msg); -var __bundleDir = dirname2(fileURLToPath(import.meta.url)); -var { log: wikiLog } = makeWikiLogger(join7(homedir4(), ".claude", "hooks")); +var __bundleDir = dirname3(fileURLToPath(import.meta.url)); +var { log: wikiLog } = makeWikiLogger(join8(homedir5(), ".claude", "hooks")); async function main() { if (process.env.HIVEMIND_WIKI_WORKER === "1") return; @@ -526,14 +587,19 @@ async function main() { if (latest && isNewer(latest, current)) { if (autoupdate) { log3(`autoupdate: updating ${current} \u2192 ${latest}`); + const resolved = resolveVersionedPluginDir(__bundleDir); + const handle = resolved ? snapshotPluginDir(resolved.pluginDir) : null; try { const scopes = ["user", "project", "local", "managed"]; - const cmd = scopes.map((s) => `claude plugin update hivemind@hivemind --scope ${s} 2>/dev/null`).join("; "); + const cmd = scopes.map((s) => `claude plugin update hivemind@hivemind --scope ${s} 2>/dev/null || true`).join("; "); execSync2(cmd, { stdio: "ignore", timeout: 6e4 }); + const outcome = restoreOrCleanup(handle); + log3(`autoupdate snapshot outcome: ${outcome}`); process.stderr.write(`\u2705 Hivemind auto-updated: ${current} \u2192 ${latest}. Run /reload-plugins to apply. `); log3(`autoupdate succeeded: ${current} \u2192 ${latest}`); } catch (e) { + restoreOrCleanup(handle); process.stderr.write(`\u2B06\uFE0F Hivemind update available: ${current} \u2192 ${latest}. Auto-update failed \u2014 run /hivemind:update to upgrade manually. `); log3(`autoupdate failed: ${e.message}`); diff --git a/claude-code/bundle/session-start.js b/claude-code/bundle/session-start.js index f5b51fc..909bf88 100755 --- a/claude-code/bundle/session-start.js +++ b/claude-code/bundle/session-start.js @@ -2,10 +2,9 @@ // dist/src/hooks/session-start.js import { fileURLToPath } from "node:url"; -import { dirname as dirname2, join as join7 } from "node:path"; -import { readdirSync, rmSync } from "node:fs"; +import { dirname as dirname3, join as join8 } from "node:path"; import { execSync as execSync2 } from "node:child_process"; -import { homedir as homedir4 } from "node:os"; +import { homedir as homedir5 } from "node:os"; // dist/src/commands/auth.js import { readFileSync, writeFileSync, existsSync, mkdirSync, unlinkSync } from "node:fs"; @@ -110,7 +109,7 @@ var MAX_CONCURRENCY = 5; var QUERY_TIMEOUT_MS = Number(process.env.HIVEMIND_QUERY_TIMEOUT_MS ?? 1e4); var INDEX_MARKER_TTL_MS = Number(process.env.HIVEMIND_INDEX_MARKER_TTL_MS ?? 6 * 60 * 6e4); function sleep(ms) { - return new Promise((resolve) => setTimeout(resolve, ms)); + return new Promise((resolve2) => setTimeout(resolve2, ms)); } function isTimeoutError(error) { const name = error instanceof Error ? error.name.toLowerCase() : ""; @@ -143,7 +142,7 @@ var Semaphore = class { this.active++; return; } - await new Promise((resolve) => this.waiting.push(resolve)); + await new Promise((resolve2) => this.waiting.push(resolve2)); } release() { this.active--; @@ -404,13 +403,13 @@ var DeeplakeApi = class { // dist/src/utils/stdin.js function readStdin() { - return new Promise((resolve, reject) => { + return new Promise((resolve2, reject) => { let data = ""; process.stdin.setEncoding("utf-8"); process.stdin.on("data", (chunk) => data += chunk); process.stdin.on("end", () => { try { - resolve(JSON.parse(data)); + resolve2(JSON.parse(data)); } catch (err) { reject(new Error(`Failed to parse hook input: ${err}`)); } @@ -483,10 +482,71 @@ function makeWikiLogger(hooksDir, filename = "deeplake-wiki.log") { }; } +// dist/src/utils/plugin-cache.js +import { cpSync, existsSync as existsSync4, readdirSync, readFileSync as readFileSync5, renameSync, rmSync, statSync } from "node:fs"; +import { basename, dirname as dirname2, join as join7, resolve, sep } from "node:path"; +import { homedir as homedir4 } from "node:os"; +var SEMVER_RE = /^\d+\.\d+\.\d+$/; +function isSemver(name) { + return SEMVER_RE.test(name); +} +function resolveVersionedPluginDir(bundleDir) { + const pluginDir = dirname2(bundleDir); + const versionsRoot = dirname2(pluginDir); + const version = basename(pluginDir); + if (!isSemver(version)) + return null; + if (basename(versionsRoot) !== "hivemind") + return null; + const expectedPrefix = resolve(homedir4(), ".claude", "plugins", "cache") + sep; + if (!resolve(versionsRoot).startsWith(expectedPrefix)) + return null; + return { pluginDir, versionsRoot, version }; +} +function snapshotPath(pluginDir, pid) { + return `${pluginDir}.keep-${pid}`; +} +function snapshotPluginDir(pluginDir, pid = process.pid) { + if (!existsSync4(pluginDir)) + return null; + const snapshot = snapshotPath(pluginDir, pid); + try { + rmSync(snapshot, { recursive: true, force: true }); + cpSync(pluginDir, snapshot, { recursive: true, dereference: false }); + return { pluginDir, snapshot }; + } catch { + return null; + } +} +function restoreOrCleanup(handle) { + if (!handle) + return "noop"; + const { pluginDir, snapshot } = handle; + try { + if (!existsSync4(pluginDir)) { + if (existsSync4(snapshot)) { + renameSync(snapshot, pluginDir); + return "restored"; + } + return "noop"; + } + rmSync(snapshot, { recursive: true, force: true }); + return "cleaned"; + } catch (e) { + try { + process.stderr.write(`[plugin-cache] restoreOrCleanup failed for ${pluginDir}: ${e?.message} +`); + } catch { + } + return "restore-failed"; + } +} +var DEFAULT_MANIFEST_PATH = join7(homedir4(), ".claude", "plugins", "installed_plugins.json"); + // dist/src/hooks/session-start.js var log3 = (msg) => log("session-start", msg); -var __bundleDir = dirname2(fileURLToPath(import.meta.url)); -var AUTH_CMD = join7(__bundleDir, "commands", "auth-login.js"); +var __bundleDir = dirname3(fileURLToPath(import.meta.url)); +var AUTH_CMD = join8(__bundleDir, "commands", "auth-login.js"); var context = `DEEPLAKE MEMORY: You have TWO memory sources. ALWAYS check BOTH when the user asks you to recall, remember, or look up ANY information: 1. Your built-in memory (~/.claude/) \u2014 personal per-project notes @@ -517,8 +577,8 @@ IMPORTANT: Only use bash commands (cat, ls, grep, echo, jq, head, tail, etc.) to LIMITS: Do NOT spawn subagents to read deeplake memory. If a file returns empty after 2 attempts, skip it and move on. Report what you found rather than exhaustively retrying. Debugging: Set HIVEMIND_DEBUG=1 to enable verbose logging to ~/.deeplake/hook-debug.log`; -var HOME = homedir4(); -var { log: wikiLog } = makeWikiLogger(join7(HOME, ".claude", "hooks")); +var HOME = homedir5(); +var { log: wikiLog } = makeWikiLogger(join8(HOME, ".claude", "hooks")); async function createPlaceholder(api, table, sessionId, cwd, userName, orgName, workspaceId) { const summaryPath = `/summaries/${userName}/${sessionId}.md`; const existing = await api.query(`SELECT path FROM "${table}" WHERE path = '${sqlStr(summaryPath)}' LIMIT 1`); @@ -591,22 +651,14 @@ async function main() { if (latest && isNewer(latest, current)) { if (autoupdate) { log3(`autoupdate: updating ${current} \u2192 ${latest}`); + const resolved = resolveVersionedPluginDir(__bundleDir); + const handle = resolved ? snapshotPluginDir(resolved.pluginDir) : null; try { const scopes = ["user", "project", "local", "managed"]; const cmd = scopes.map((s) => `claude plugin update hivemind@hivemind --scope ${s} 2>/dev/null || true`).join("; "); execSync2(cmd, { stdio: "ignore", timeout: 6e4 }); - try { - const cacheParent = join7(homedir4(), ".claude", "plugins", "cache", "hivemind", "hivemind"); - const entries = readdirSync(cacheParent, { withFileTypes: true }); - for (const e of entries) { - if (e.isDirectory() && e.name !== latest) { - rmSync(join7(cacheParent, e.name), { recursive: true, force: true }); - log3(`cache cleanup: removed old version ${e.name}`); - } - } - } catch (e) { - log3(`cache cleanup failed: ${e.message}`); - } + const outcome = restoreOrCleanup(handle); + log3(`autoupdate snapshot outcome: ${outcome}`); updateNotice = ` \u2705 Hivemind auto-updated: ${current} \u2192 ${latest}. Run /reload-plugins to apply.`; @@ -614,6 +666,7 @@ async function main() { `); log3(`autoupdate succeeded: ${current} \u2192 ${latest}`); } catch (e) { + restoreOrCleanup(handle); updateNotice = ` \u2B06\uFE0F Hivemind update available: ${current} \u2192 ${latest}. Auto-update failed \u2014 run /hivemind:update to upgrade manually.`; diff --git a/claude-code/hooks/hooks.json b/claude-code/hooks/hooks.json index 7801e25..ef82672 100644 --- a/claude-code/hooks/hooks.json +++ b/claude-code/hooks/hooks.json @@ -84,6 +84,12 @@ "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/bundle/session-end.js\"", "timeout": 60 + }, + { + "type": "command", + "command": "node \"${CLAUDE_PLUGIN_ROOT}/bundle/plugin-cache-gc.js\"", + "timeout": 15, + "async": true } ] } diff --git a/claude-code/tests/plugin-cache-bundles.test.ts b/claude-code/tests/plugin-cache-bundles.test.ts new file mode 100644 index 0000000..0a1865c --- /dev/null +++ b/claude-code/tests/plugin-cache-bundles.test.ts @@ -0,0 +1,36 @@ +import { describe, it, expect } from "vitest"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +/** + * Bundle-level guard: the snapshot/restore dance must survive the + * esbuild pass — if it doesn't, SessionStart updates will wipe the + * live plugin dir again and break in-flight sessions (the bug we just + * fixed). Scan the shipped bundles. + */ + +const claudeCodeBundleDir = join(__dirname, "..", "bundle"); + +describe("shipped bundles contain plugin-cache safety", () => { + it("session-start-setup.js calls snapshotPluginDir and restoreOrCleanup", () => { + const src = readFileSync(join(claudeCodeBundleDir, "session-start-setup.js"), "utf-8"); + expect(src).toMatch(/snapshotPluginDir/); + expect(src).toMatch(/restoreOrCleanup/); + expect(src).toMatch(/resolveVersionedPluginDir/); + }); + + it("plugin-cache-gc.js bundle exists and calls planGc + executeGc", () => { + const src = readFileSync(join(claudeCodeBundleDir, "plugin-cache-gc.js"), "utf-8"); + expect(src).toMatch(/planGc/); + expect(src).toMatch(/executeGc/); + expect(src).toMatch(/readCurrentVersionFromManifest/); + }); + + it("hooks.json wires plugin-cache-gc into SessionEnd", () => { + const hooks = JSON.parse(readFileSync(join(__dirname, "..", "hooks", "hooks.json"), "utf-8")); + const sessionEnd = hooks.hooks.SessionEnd?.[0]?.hooks ?? []; + const gcEntry = sessionEnd.find((h: any) => typeof h.command === "string" && h.command.includes("plugin-cache-gc.js")); + expect(gcEntry).toBeTruthy(); + expect(gcEntry.async).toBe(true); + }); +}); diff --git a/claude-code/tests/plugin-cache-gc-bundle.integration.test.ts b/claude-code/tests/plugin-cache-gc-bundle.integration.test.ts new file mode 100644 index 0000000..e14cb68 --- /dev/null +++ b/claude-code/tests/plugin-cache-gc-bundle.integration.test.ts @@ -0,0 +1,148 @@ +import { describe, it, expect, beforeAll } from "vitest"; +import { mkdirSync, writeFileSync, existsSync, readdirSync, rmSync, cpSync, statSync } from "node:fs"; +import { execFileSync } from "node:child_process"; +import { join, resolve } from "node:path"; +import { tmpdir } from "node:os"; + +/** + * Integration test: runs the *shipped* plugin-cache-gc.js bundle as a + * node subprocess against a fake HOME. This is the closest we get to + * what happens inside a real Claude session without touching the user's + * real plugin cache. + * + * Requires the bundle to be built (`npm run build`). Skipped if missing. + */ + +const bundlePath = resolve(__dirname, "..", "bundle", "plugin-cache-gc.js"); +const bundleExists = existsSync(bundlePath); + +function makeFakeHome(): string { + const home = join(tmpdir(), `hivemind-gc-it-${Date.now()}-${Math.random().toString(36).slice(2)}`); + mkdirSync(join(home, ".claude", "plugins", "cache", "hivemind", "hivemind"), { recursive: true }); + mkdirSync(join(home, ".deeplake"), { recursive: true }); + return home; +} + +function mkVersion(home: string, version: string): string { + const v = join(home, ".claude", "plugins", "cache", "hivemind", "hivemind", version); + mkdirSync(join(v, "bundle"), { recursive: true }); + writeFileSync(join(v, "bundle", "marker.txt"), version); + return v; +} + +function writeManifest(home: string, version: string): void { + const manifest = { + version: 2, + plugins: { + "hivemind@hivemind": [{ + scope: "user", + installPath: join(home, ".claude", "plugins", "cache", "hivemind", "hivemind", version), + version, + }], + }, + }; + writeFileSync(join(home, ".claude", "plugins", "installed_plugins.json"), JSON.stringify(manifest)); +} + +function runGcBundle(home: string, bundleInsideHome: string): { stdout: string; stderr: string } { + try { + const stdout = execFileSync("node", [bundleInsideHome], { + env: { ...process.env, HOME: home, HIVEMIND_DEBUG: "0" }, + input: "", + encoding: "utf-8", + stdio: ["pipe", "pipe", "pipe"], + timeout: 10_000, + }); + return { stdout, stderr: "" }; + } catch (e: any) { + return { stdout: e.stdout?.toString() ?? "", stderr: e.stderr?.toString() ?? "" }; + } +} + +describe.skipIf(!bundleExists)("plugin-cache-gc shipped bundle", () => { + beforeAll(() => { + // Fail loud if someone tries to run this without a build. + if (!bundleExists) throw new Error(`missing bundle at ${bundlePath} — run npm run build`); + }); + + it("keeps current + two previous, deletes older versions and dead-PID snapshots", () => { + const home = makeFakeHome(); + try { + const versionsRoot = join(home, ".claude", "plugins", "cache", "hivemind", "hivemind"); + mkVersion(home, "0.6.36"); + mkVersion(home, "0.6.37"); + mkVersion(home, "0.6.38"); + const current = mkVersion(home, "0.6.39"); + // A stale snapshot belonging to a PID that is not alive (9999999 is virtually + // guaranteed to be above the kernel PID max on Linux). + mkdirSync(`${join(versionsRoot, "0.6.37")}.keep-9999999`, { recursive: true }); + writeManifest(home, "0.6.39"); + + // Copy the built GC bundle into the fake cache so __bundleDir resolves inside it. + cpSync(bundlePath, join(current, "bundle", "plugin-cache-gc.js")); + const gcInsideHome = join(current, "bundle", "plugin-cache-gc.js"); + + runGcBundle(home, gcInsideHome); + + const remaining = readdirSync(versionsRoot).sort(); + // DEFAULT_KEEP_COUNT=3: current + two newest predecessors survive. + expect(remaining).toEqual(["0.6.37", "0.6.38", "0.6.39"]); + } finally { + rmSync(home, { recursive: true, force: true }); + } + }); + + it("preserves live-PID snapshots", () => { + const home = makeFakeHome(); + try { + const versionsRoot = join(home, ".claude", "plugins", "cache", "hivemind", "hivemind"); + mkVersion(home, "0.6.38"); + const current = mkVersion(home, "0.6.39"); + // Our own PID is alive; the GC must not touch this snapshot. + const liveSnapshot = `${join(versionsRoot, "0.6.38")}.keep-${process.pid}`; + mkdirSync(liveSnapshot, { recursive: true }); + writeManifest(home, "0.6.39"); + + cpSync(bundlePath, join(current, "bundle", "plugin-cache-gc.js")); + runGcBundle(home, join(current, "bundle", "plugin-cache-gc.js")); + + expect(existsSync(liveSnapshot)).toBe(true); + } finally { + rmSync(home, { recursive: true, force: true }); + } + }); + + it("deletes nothing when manifest is missing (bail-safe)", () => { + const home = makeFakeHome(); + try { + const versionsRoot = join(home, ".claude", "plugins", "cache", "hivemind", "hivemind"); + mkVersion(home, "0.6.37"); + mkVersion(home, "0.6.38"); + const current = mkVersion(home, "0.6.39"); + // No installed_plugins.json written. + cpSync(bundlePath, join(current, "bundle", "plugin-cache-gc.js")); + runGcBundle(home, join(current, "bundle", "plugin-cache-gc.js")); + + const remaining = readdirSync(versionsRoot).sort(); + expect(remaining).toEqual(["0.6.37", "0.6.38", "0.6.39"]); + } finally { + rmSync(home, { recursive: true, force: true }); + } + }); + + it("skips silently when bundle is in a local --plugin-dir layout (not under ~/.claude/plugins/cache)", () => { + const sandbox = join(tmpdir(), `hivemind-dev-it-${Date.now()}-${Math.random().toString(36).slice(2)}`); + mkdirSync(join(sandbox, "claude-code", "bundle"), { recursive: true }); + try { + cpSync(bundlePath, join(sandbox, "claude-code", "bundle", "plugin-cache-gc.js")); + const { stdout, stderr } = runGcBundle(sandbox, join(sandbox, "claude-code", "bundle", "plugin-cache-gc.js")); + // Must not crash; output is fine to be empty. + expect(stderr).not.toMatch(/TypeError|ReferenceError|Cannot find module/); + // The sandbox dir should be unchanged. + expect(statSync(join(sandbox, "claude-code")).isDirectory()).toBe(true); + expect(stdout).toBe(""); + } finally { + rmSync(sandbox, { recursive: true, force: true }); + } + }); +}); diff --git a/claude-code/tests/plugin-cache-gc.test.ts b/claude-code/tests/plugin-cache-gc.test.ts new file mode 100644 index 0000000..e8a6a19 --- /dev/null +++ b/claude-code/tests/plugin-cache-gc.test.ts @@ -0,0 +1,172 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { mkdirSync, writeFileSync, rmSync, readdirSync, existsSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir, homedir } from "node:os"; +import { runGc } from "../../src/hooks/plugin-cache-gc.js"; + +/** + * Unit tests for the runGc orchestrator in plugin-cache-gc.ts. The + * `resolveVersionedPluginDir` helper only returns a non-null value when + * the bundleDir sits under `~/.claude/plugins/cache/hivemind/hivemind//bundle/`. + * We can construct that real-looking path under a tmp root by pointing + * into the current user's home — no files are written until runGc calls + * a helper that uses it, and we pass an explicit manifestPath so we + * never touch the real installed_plugins.json. + */ + +function mkRoot(): string { + const root = join(tmpdir(), `pcgc-unit-${Date.now()}-${Math.random().toString(36).slice(2)}`); + mkdirSync(root, { recursive: true }); + return root; +} + +// Build a real versioned plugin layout under ~/.claude/plugins/cache/hivemind/hivemind//. +// We use a uniquely-named top-level cache dir inside ~/.claude/plugins/cache/ so we don't +// clobber any real hivemind install, then fabricate versions beneath it. +function mkFakeVersionedLayout(): { + bundleDir: string; + versionsRoot: string; + cleanup: () => void; +} { + const unique = `hivemind-test-${Date.now()}-${Math.random().toString(36).slice(2)}`; + // Inside the hivemind org-dir, siblings of versions are dirs. Make a sandbox + // org-dir to avoid touching the real one. + const sandboxOrg = join(homedir(), ".claude", "plugins", "cache", unique); + // resolveVersionedPluginDir looks for parent dir named "hivemind" (the inner + // one), so mirror that name inside our sandbox org. + const versionsRoot = join(sandboxOrg, "hivemind"); + mkdirSync(versionsRoot, { recursive: true }); + const bundleDir = join(versionsRoot, "0.6.39", "bundle"); + return { + bundleDir, + versionsRoot, + cleanup: () => rmSync(sandboxOrg, { recursive: true, force: true }), + }; +} + +describe("runGc", () => { + let root: string; + let logs: string[]; + let log: (m: string) => void; + + beforeEach(() => { + root = mkRoot(); + logs = []; + log = (m: string) => { logs.push(m); }; + }); + + afterEach(() => { + rmSync(root, { recursive: true, force: true }); + }); + + it("returns early when HIVEMIND_WIKI_WORKER=1 (no log of skip)", () => { + const prev = process.env.HIVEMIND_WIKI_WORKER; + process.env.HIVEMIND_WIKI_WORKER = "1"; + try { + runGc("/anything", { log }); + expect(logs).toEqual([]); + } finally { + if (prev === undefined) delete process.env.HIVEMIND_WIKI_WORKER; + else process.env.HIVEMIND_WIKI_WORKER = prev; + } + }); + + it("logs and returns when bundleDir is not a versioned install", () => { + runGc(join(root, "claude-code", "bundle"), { log }); + expect(logs).toContain("not a versioned install, skipping"); + }); + + it("logs 'nothing to gc' when plan has no deletions", () => { + // Resolvable layout with a single version that is also the manifest's current. + const layout = mkFakeVersionedLayout(); + try { + mkdirSync(join(layout.versionsRoot, "0.6.39"), { recursive: true }); + const manifestPath = join(root, "installed_plugins.json"); + writeFileSync(manifestPath, JSON.stringify({ + plugins: { "hivemind@hivemind": [{ version: "0.6.39" }] }, + })); + runGc(layout.bundleDir, { log, manifestPath }); + expect(logs.some(l => l.startsWith("nothing to gc"))).toBe(true); + } finally { + layout.cleanup(); + } + }); + + it("deletes older versions and logs the gc summary", () => { + const layout = mkFakeVersionedLayout(); + try { + for (const v of ["0.6.36", "0.6.37", "0.6.38", "0.6.39"]) { + mkdirSync(join(layout.versionsRoot, v), { recursive: true }); + } + const manifestPath = join(root, "installed_plugins.json"); + writeFileSync(manifestPath, JSON.stringify({ + plugins: { "hivemind@hivemind": [{ version: "0.6.39" }] }, + })); + runGc(layout.bundleDir, { log, manifestPath }); + + // DEFAULT_KEEP_COUNT = 3 → keep 0.6.39 + 0.6.38 + 0.6.37; delete 0.6.36 + const remaining = readdirSync(layout.versionsRoot).sort(); + expect(remaining).toEqual(["0.6.37", "0.6.38", "0.6.39"]); + expect(logs.some(l => l.startsWith("gc kept=") && l.includes("deletedVersions=0.6.36"))).toBe(true); + } finally { + layout.cleanup(); + } + }); + + it("respects an explicit keepCount override", () => { + const layout = mkFakeVersionedLayout(); + try { + for (const v of ["0.6.37", "0.6.38", "0.6.39"]) { + mkdirSync(join(layout.versionsRoot, v), { recursive: true }); + } + const manifestPath = join(root, "installed_plugins.json"); + writeFileSync(manifestPath, JSON.stringify({ + plugins: { "hivemind@hivemind": [{ version: "0.6.39" }] }, + })); + runGc(layout.bundleDir, { log, manifestPath, keepCount: 1 }); + + const remaining = readdirSync(layout.versionsRoot).sort(); + expect(remaining).toEqual(["0.6.39"]); + } finally { + layout.cleanup(); + } + }); + + it("uses default log + manifest path when opts are omitted", () => { + const layout = mkFakeVersionedLayout(); + try { + // Put a non-matching version on disk: the real manifest's current + // version (whatever is installed) won't be in this fake root, so + // planGc bails without any deletions. This exercises the `??` + // default branches for opts.log and opts.manifestPath without + // altering the user's actual install. + mkdirSync(join(layout.versionsRoot, "0.0.1"), { recursive: true }); + expect(() => runGc(layout.bundleDir)).not.toThrow(); + expect(existsSync(join(layout.versionsRoot, "0.0.1"))).toBe(true); + } finally { + layout.cleanup(); + } + }); + + it("deletes dead-PID snapshot dirs but preserves live-PID snapshots", () => { + const layout = mkFakeVersionedLayout(); + try { + for (const v of ["0.6.38", "0.6.39"]) { + mkdirSync(join(layout.versionsRoot, v), { recursive: true }); + } + const deadPidSnapshot = join(layout.versionsRoot, "0.6.38.keep-9999999"); + const livePidSnapshot = join(layout.versionsRoot, `0.6.38.keep-${process.pid}`); + mkdirSync(deadPidSnapshot, { recursive: true }); + mkdirSync(livePidSnapshot, { recursive: true }); + const manifestPath = join(root, "installed_plugins.json"); + writeFileSync(manifestPath, JSON.stringify({ + plugins: { "hivemind@hivemind": [{ version: "0.6.39" }] }, + })); + runGc(layout.bundleDir, { log, manifestPath }); + expect(existsSync(deadPidSnapshot)).toBe(false); + expect(existsSync(livePidSnapshot)).toBe(true); + } finally { + layout.cleanup(); + } + }); +}); diff --git a/claude-code/tests/plugin-cache.test.ts b/claude-code/tests/plugin-cache.test.ts new file mode 100644 index 0000000..f6d7059 --- /dev/null +++ b/claude-code/tests/plugin-cache.test.ts @@ -0,0 +1,327 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { chmodSync, mkdirSync, writeFileSync, rmSync, existsSync, readdirSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir, homedir } from "node:os"; +import { + compareSemverDesc, + executeGc, + isSemver, + planGc, + readCurrentVersionFromManifest, + resolveVersionedPluginDir, + restoreOrCleanup, + snapshotPluginDir, +} from "../../src/utils/plugin-cache.js"; + +function mkRoot(): string { + const root = join(tmpdir(), `hivemind-cache-test-${Date.now()}-${Math.random().toString(36).slice(2)}`); + mkdirSync(root, { recursive: true }); + return root; +} + +describe("isSemver", () => { + it("accepts three-segment numeric", () => { + expect(isSemver("0.6.39")).toBe(true); + expect(isSemver("10.0.1")).toBe(true); + }); + it("rejects non-semver", () => { + expect(isSemver("0.6")).toBe(false); + expect(isSemver("0.6.39-rc1")).toBe(false); + expect(isSemver("latest")).toBe(false); + expect(isSemver(".keep-1234")).toBe(false); + }); +}); + +describe("compareSemverDesc", () => { + it("sorts newest first", () => { + const vs = ["0.6.38", "0.6.40", "0.5.9", "0.6.39"]; + expect([...vs].sort(compareSemverDesc)).toEqual(["0.6.40", "0.6.39", "0.6.38", "0.5.9"]); + }); + it("handles multi-digit segments", () => { + const vs = ["0.6.9", "0.6.10"]; + expect([...vs].sort(compareSemverDesc)).toEqual(["0.6.10", "0.6.9"]); + }); +}); + +describe("resolveVersionedPluginDir", () => { + // We can't write to ~/.claude safely in tests, so the positive case uses a + // manual path-assembly and the validator only checks the shape. Confirm + // that anything outside the real cache prefix returns null. + it("rejects a local --plugin-dir layout", () => { + const root = mkRoot(); + try { + const bundle = join(root, "claude-code", "bundle"); + mkdirSync(bundle, { recursive: true }); + expect(resolveVersionedPluginDir(bundle)).toBeNull(); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + it("rejects when version segment isn't semver", () => { + const root = mkRoot(); + try { + const bundle = join(root, "plugins", "cache", "hivemind", "hivemind", "latest", "bundle"); + mkdirSync(bundle, { recursive: true }); + expect(resolveVersionedPluginDir(bundle)).toBeNull(); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + it("accepts the real cache layout", () => { + const fakeHome = join(homedir(), ".claude", "plugins", "cache", "hivemind", "hivemind", "9.99.99", "bundle"); + const resolved = resolveVersionedPluginDir(fakeHome); + expect(resolved).not.toBeNull(); + expect(resolved?.version).toBe("9.99.99"); + }); +}); + +describe("snapshotPluginDir + restoreOrCleanup", () => { + let root: string; + beforeEach(() => { root = mkRoot(); }); + afterEach(() => { rmSync(root, { recursive: true, force: true }); }); + + it("returns null when the plugin dir doesn't exist", () => { + const handle = snapshotPluginDir(join(root, "missing"), 1234); + expect(handle).toBeNull(); + }); + + it("creates a PID-scoped snapshot with same contents", () => { + const plugin = join(root, "0.6.38"); + mkdirSync(join(plugin, "bundle"), { recursive: true }); + writeFileSync(join(plugin, "bundle", "capture.js"), "console.log('v38');"); + const handle = snapshotPluginDir(plugin, 1234); + expect(handle).not.toBeNull(); + expect(handle!.snapshot).toBe(`${plugin}.keep-1234`); + expect(readFileSync(join(handle!.snapshot, "bundle", "capture.js"), "utf-8")).toBe("console.log('v38');"); + }); + + it("overwrites a stale same-PID snapshot", () => { + const plugin = join(root, "0.6.38"); + mkdirSync(plugin, { recursive: true }); + writeFileSync(join(plugin, "marker"), "new"); + const stale = `${plugin}.keep-1234`; + mkdirSync(stale, { recursive: true }); + writeFileSync(join(stale, "old-marker"), "old"); + const handle = snapshotPluginDir(plugin, 1234); + expect(handle).not.toBeNull(); + expect(existsSync(join(handle!.snapshot, "old-marker"))).toBe(false); + expect(readFileSync(join(handle!.snapshot, "marker"), "utf-8")).toBe("new"); + }); + + it("restores the snapshot when the installer wiped the plugin dir", () => { + const plugin = join(root, "0.6.38"); + mkdirSync(plugin, { recursive: true }); + writeFileSync(join(plugin, "marker"), "preserved"); + const handle = snapshotPluginDir(plugin, 1234); + rmSync(plugin, { recursive: true, force: true }); + const outcome = restoreOrCleanup(handle); + expect(outcome).toBe("restored"); + expect(readFileSync(join(plugin, "marker"), "utf-8")).toBe("preserved"); + expect(existsSync(handle!.snapshot)).toBe(false); + }); + + it("removes the snapshot when the plugin dir still exists", () => { + const plugin = join(root, "0.6.38"); + mkdirSync(plugin, { recursive: true }); + writeFileSync(join(plugin, "marker"), "x"); + const handle = snapshotPluginDir(plugin, 1234); + const outcome = restoreOrCleanup(handle); + expect(outcome).toBe("cleaned"); + expect(existsSync(handle!.snapshot)).toBe(false); + expect(existsSync(plugin)).toBe(true); + }); + + it("restoreOrCleanup is a no-op when handle is null", () => { + expect(restoreOrCleanup(null)).toBe("noop"); + }); + + it("returns 'restore-failed' and writes to stderr when rename throws", () => { + const root = mkRoot(); + const stderrChunks: string[] = []; + const stderrSpy = vi.spyOn(process.stderr, "write").mockImplementation(((chunk: any) => { + stderrChunks.push(String(chunk)); + return true; + }) as any); + try { + const plugin = join(root, "0.6.38"); + mkdirSync(plugin, { recursive: true }); + writeFileSync(join(plugin, "marker"), "x"); + const handle = snapshotPluginDir(plugin, 1234)!; + // Remove the live plugin dir so restoreOrCleanup goes through the + // rename path. Then chmod the parent so rename fails with EACCES — + // exercising the catch branch in restoreOrCleanup. The new contract + // returns "restore-failed" (not "noop") so the caller / log line can + // tell a genuine fs failure apart from the no-op cases. + rmSync(plugin, { recursive: true, force: true }); + chmodSync(root, 0o500); + try { + expect(restoreOrCleanup(handle)).toBe("restore-failed"); + expect(stderrChunks.join("")).toMatch(/restoreOrCleanup failed/); + } finally { + chmodSync(root, 0o700); + } + } finally { + stderrSpy.mockRestore(); + rmSync(root, { recursive: true, force: true }); + } + }); +}); + +describe("readCurrentVersionFromManifest", () => { + let root: string; + beforeEach(() => { root = mkRoot(); }); + afterEach(() => { rmSync(root, { recursive: true, force: true }); }); + + it("returns null when the file doesn't exist", () => { + expect(readCurrentVersionFromManifest(join(root, "nope.json"))).toBeNull(); + }); + + it("returns null when JSON is malformed", () => { + const p = join(root, "installed_plugins.json"); + writeFileSync(p, "{ not json"); + expect(readCurrentVersionFromManifest(p)).toBeNull(); + }); + + it("returns null when hivemind@hivemind entry is missing", () => { + const p = join(root, "installed_plugins.json"); + writeFileSync(p, JSON.stringify({ plugins: { "other@scope": [{ version: "1.0.0" }] } })); + expect(readCurrentVersionFromManifest(p)).toBeNull(); + }); + + it("returns the first valid semver version", () => { + const p = join(root, "installed_plugins.json"); + writeFileSync(p, JSON.stringify({ + plugins: { "hivemind@hivemind": [{ version: "0.6.39", installPath: "/x" }] }, + })); + expect(readCurrentVersionFromManifest(p)).toBe("0.6.39"); + }); + + it("skips entries with non-semver versions", () => { + const p = join(root, "installed_plugins.json"); + writeFileSync(p, JSON.stringify({ + plugins: { "hivemind@hivemind": [{ version: "latest" }, { version: "0.6.40" }] }, + })); + expect(readCurrentVersionFromManifest(p)).toBe("0.6.40"); + }); + + it("returns null when every entry has a non-semver version", () => { + const p = join(root, "installed_plugins.json"); + writeFileSync(p, JSON.stringify({ + plugins: { "hivemind@hivemind": [{ version: "latest" }, { version: "unknown" }, {}] }, + })); + expect(readCurrentVersionFromManifest(p)).toBeNull(); + }); +}); + +describe("planGc", () => { + let root: string; + beforeEach(() => { root = mkRoot(); }); + afterEach(() => { rmSync(root, { recursive: true, force: true }); }); + + const mk = (...names: string[]) => names.forEach(n => mkdirSync(join(root, n), { recursive: true })); + + it("keeps current + N-1 newest, deletes the rest", () => { + mk("0.6.37", "0.6.38", "0.6.39", "0.6.40"); + const plan = planGc(root, "0.6.40", 2, () => false); + expect(new Set(plan.keep)).toEqual(new Set(["0.6.40", "0.6.39"])); + expect(new Set(plan.deleteVersions)).toEqual(new Set(["0.6.37", "0.6.38"])); + }); + + it("deletes nothing when current version is not on disk (bail-safe)", () => { + // current version missing from disk means installer state is weird; + // we shouldn't GC in that case. + mk("0.6.37", "0.6.38"); + const plan = planGc(root, "0.6.40", 2, () => false); + expect(plan.deleteVersions).toEqual([]); + }); + + it("deletes nothing when manifest version is null", () => { + mk("0.6.37", "0.6.38", "0.6.39"); + const plan = planGc(root, null, 2, () => false); + expect(plan.deleteVersions).toEqual([]); + }); + + it("leaves unknown entries untouched (non-semver, non-.keep)", () => { + mk("0.6.39", "0.6.38", "tmp", "node_modules"); + const plan = planGc(root, "0.6.39", 1, () => false); + expect(plan.deleteVersions).toEqual(["0.6.38"]); + // "tmp" and "node_modules" must not appear anywhere in the plan. + expect(plan.deleteSnapshots).toEqual([]); + expect(plan.keep.some(k => k === "tmp" || k === "node_modules")).toBe(false); + }); + + it("schedules dead-PID snapshots for deletion", () => { + mk("0.6.39", "0.6.38.keep-1111", "0.6.39.keep-2222"); + const plan = planGc(root, "0.6.39", 2, (pid) => pid === 2222); + expect(new Set(plan.deleteSnapshots)).toEqual(new Set(["0.6.38.keep-1111"])); + }); + + it("preserves live-PID snapshots", () => { + mk("0.6.39", "0.6.39.keep-2222"); + const plan = planGc(root, "0.6.39", 2, () => true); + expect(plan.deleteSnapshots).toEqual([]); + }); + + it("handles missing versionsRoot gracefully", () => { + const plan = planGc(join(root, "does-not-exist"), "0.6.39", 2, () => false); + expect(plan).toEqual({ keep: [], deleteVersions: [], deleteSnapshots: [] }); + }); +}); + +describe("executeGc", () => { + let root: string; + beforeEach(() => { root = mkRoot(); }); + afterEach(() => { rmSync(root, { recursive: true, force: true }); }); + + it("deletes the planned version dirs and snapshots", () => { + mkdirSync(join(root, "0.6.37"), { recursive: true }); + mkdirSync(join(root, "0.6.38.keep-1111"), { recursive: true }); + const result = executeGc(root, { + keep: ["0.6.39"], + deleteVersions: ["0.6.37"], + deleteSnapshots: ["0.6.38.keep-1111"], + }); + expect(result.deletedVersions).toEqual(["0.6.37"]); + expect(result.deletedSnapshots).toEqual(["0.6.38.keep-1111"]); + expect(result.errors).toEqual([]); + expect(readdirSync(root)).toEqual([]); + }); + + it("reports but swallows rm errors", () => { + // Passing a path that doesn't exist with `force: true` won't error, + // so exercise the normal happy path and assert the empty-errors + // contract the hook relies on. + const result = executeGc(root, { + keep: [], + deleteVersions: ["nonexistent-version"], + deleteSnapshots: [], + }); + expect(result.errors).toEqual([]); + expect(result.deletedVersions).toEqual(["nonexistent-version"]); + }); + + it("collects errors from both rmSync catch blocks without throwing", () => { + const versionDir = join(root, "0.6.38"); + mkdirSync(versionDir, { recursive: true }); + const snapshotDir = join(root, "0.6.38.keep-9999"); + mkdirSync(snapshotDir, { recursive: true }); + // chmod 0500 on the parent makes unlink of its children fail EACCES. + chmodSync(root, 0o500); + try { + const result = executeGc(root, { + keep: ["0.6.39"], + deleteVersions: ["0.6.38"], + deleteSnapshots: ["0.6.38.keep-9999"], + }); + expect(result.deletedVersions).toEqual([]); + expect(result.deletedSnapshots).toEqual([]); + expect(result.errors.length).toBe(2); + expect(result.errors[0]).toContain("0.6.38"); + expect(result.errors[1]).toContain("0.6.38.keep-9999"); + } finally { + chmodSync(root, 0o700); + } + }); +}); diff --git a/claude-code/tests/session-start-hook.test.ts b/claude-code/tests/session-start-hook.test.ts index 27b15c8..0be52e5 100644 --- a/claude-code/tests/session-start-hook.test.ts +++ b/claude-code/tests/session-start-hook.test.ts @@ -227,20 +227,30 @@ describe("session-start hook — placeholder branching", () => { // ═══ Version check + autoupdate ═════════════════════════════════════════════ describe("session-start hook — version check", () => { - it("runs execSync and cleans old cache entries when a newer version is available", async () => { + it("runs execSync when a newer version is available and does NOT rm cache directories in-session", async () => { + // Regression guard: the old code explicitly rm -rf'd every + // non-latest version directory from inside the running session, + // which invalidated hook bundle paths and produced + // "Plugin directory does not exist" errors on later hooks in the + // same session. Cleanup now lives in the SessionEnd GC hook. fetchMock.mockResolvedValue({ ok: true, json: async () => ({ version: "999.0.0" }), }); readdirSyncMock.mockReturnValue([ { name: "0.0.1", isDirectory: () => true }, - { name: "999.0.0", isDirectory: () => true }, // latest, must NOT be removed + { name: "999.0.0", isDirectory: () => true }, ]); const stderrSpy = vi.spyOn(process.stderr, "write").mockReturnValue(true); const out = await runHook(); expect(execSyncMock).toHaveBeenCalled(); - expect(rmSyncMock).toHaveBeenCalledTimes(1); - expect(rmSyncMock.mock.calls[0][0]).toContain("0.0.1"); + // No in-session rm of version dirs. The only rmSync calls allowed + // are inside the snapshot helper (which is guarded and only runs + // under a real versioned install layout — not in test env). + for (const call of rmSyncMock.mock.calls) { + expect(String(call[0])).not.toMatch(/\/0\.0\.1(?:$|\/)/); + expect(String(call[0])).not.toMatch(/\/999\.0\.0(?:$|\/)/); + } expect(stderrSpy).toHaveBeenCalledWith(expect.stringContaining("auto-updated")); const parsed = JSON.parse(out!); expect(parsed.hookSpecificOutput.additionalContext).toContain("auto-updated"); @@ -276,12 +286,15 @@ describe("session-start hook — version check", () => { expect(execSyncMock).not.toHaveBeenCalled(); }); - it("tolerates readdirSync throw during cache cleanup", async () => { + it("logs the snapshot outcome after a successful autoupdate", async () => { + // The snapshot helper is guarded by resolveVersionedPluginDir, + // which returns null outside a real ~/.claude/plugins/cache + // layout — so in this test the outcome is "noop". The log line + // proves the new code path ran end-to-end. fetchMock.mockResolvedValue({ ok: true, json: async () => ({ version: "999.0.0" }) }); - readdirSyncMock.mockImplementation(() => { throw new Error("readdir boom"); }); await runHook(); expect(debugLogMock).toHaveBeenCalledWith( - expect.stringContaining("cache cleanup failed: readdir boom"), + expect.stringContaining("autoupdate snapshot outcome:"), ); }); diff --git a/claude-code/tests/session-start-setup-branches.test.ts b/claude-code/tests/session-start-setup-branches.test.ts new file mode 100644 index 0000000..8c28370 --- /dev/null +++ b/claude-code/tests/session-start-setup-branches.test.ts @@ -0,0 +1,178 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +/** + * Branch-coverage tests for src/hooks/session-start-setup.ts. These + * mock version-check and plugin-cache so we can hit specific branches + * the main source-level test can't reach against the real filesystem: + * + * - userName fallback to "unknown" when node:os userInfo().username + * is nullish. + * - `if (current)` false branch — getInstalledVersion returns null. + * - `resolved ? snapshotPluginDir(...) : null` truthy branch — a real + * versioned install layout is detected, snapshot is taken, and + * restoreOrCleanup is called after the update completes. + * - Outer `try/catch (version check failed)` — getLatestVersion throws. + */ + +const stdinMock = vi.fn(); +const loadCredsMock = vi.fn(); +const saveCredsMock = vi.fn(); +const loadConfigMock = vi.fn(); +const debugLogMock = vi.fn(); +const ensureTableMock = vi.fn(); +const ensureSessionsTableMock = vi.fn(); +const execSyncMock = vi.fn(); +const userInfoMock = vi.fn(); +const getInstalledVersionMock = vi.fn(); +const getLatestVersionMock = vi.fn(); +const isNewerMock = vi.fn(); +const resolveVersionedPluginDirMock = vi.fn(); +const snapshotPluginDirMock = vi.fn(); +const restoreOrCleanupMock = vi.fn(); + +vi.mock("../../src/utils/stdin.js", () => ({ readStdin: (...a: any[]) => stdinMock(...a) })); +vi.mock("../../src/commands/auth.js", () => ({ + loadCredentials: (...a: any[]) => loadCredsMock(...a), + saveCredentials: (...a: any[]) => saveCredsMock(...a), +})); +vi.mock("../../src/config.js", () => ({ loadConfig: (...a: any[]) => loadConfigMock(...a) })); +vi.mock("../../src/utils/debug.js", () => ({ + log: (_t: string, msg: string) => debugLogMock(msg), + utcTimestamp: () => "2026-04-17 00:00:00 UTC", +})); +vi.mock("../../src/deeplake-api.js", () => ({ + DeeplakeApi: class { + ensureTable() { return ensureTableMock(); } + ensureSessionsTable(t: string) { return ensureSessionsTableMock(t); } + }, +})); +vi.mock("node:child_process", async () => { + const actual = await vi.importActual("node:child_process"); + return { ...actual, execSync: (...a: any[]) => execSyncMock(...a) }; +}); +vi.mock("node:os", async () => { + const actual = await vi.importActual("node:os"); + return { ...actual, userInfo: (...a: any[]) => userInfoMock(...a) }; +}); +vi.mock("../../src/utils/version-check.js", () => ({ + getInstalledVersion: (...a: any[]) => getInstalledVersionMock(...a), + getLatestVersion: (...a: any[]) => getLatestVersionMock(...a), + isNewer: (...a: any[]) => isNewerMock(...a), +})); +vi.mock("../../src/utils/plugin-cache.js", () => ({ + resolveVersionedPluginDir: (...a: any[]) => resolveVersionedPluginDirMock(...a), + snapshotPluginDir: (...a: any[]) => snapshotPluginDirMock(...a), + restoreOrCleanup: (...a: any[]) => restoreOrCleanupMock(...a), +})); + +async function runHook(): Promise { + delete process.env.HIVEMIND_WIKI_WORKER; + vi.resetModules(); + await import("../../src/hooks/session-start-setup.js"); + await new Promise(r => setImmediate(r)); + await new Promise(r => setImmediate(r)); +} + +const validConfig = { + token: "t", orgId: "o", orgName: "acme", workspaceId: "default", + userName: "alice", apiUrl: "http://example", tableName: "memory", + sessionsTableName: "sessions", +}; + +beforeEach(() => { + stdinMock.mockReset().mockResolvedValue({ session_id: "sid-1", cwd: "/x" }); + loadCredsMock.mockReset().mockReturnValue({ + token: "tok", orgId: "o", orgName: "acme", userName: "alice", + }); + saveCredsMock.mockReset(); + loadConfigMock.mockReset().mockReturnValue(validConfig); + debugLogMock.mockReset(); + ensureTableMock.mockReset().mockResolvedValue(undefined); + ensureSessionsTableMock.mockReset().mockResolvedValue(undefined); + execSyncMock.mockReset(); + userInfoMock.mockReset().mockReturnValue({ username: "alice" }); + getInstalledVersionMock.mockReset().mockReturnValue("0.6.38"); + getLatestVersionMock.mockReset().mockResolvedValue("0.6.38"); + isNewerMock.mockReset().mockReturnValue(false); + resolveVersionedPluginDirMock.mockReset().mockReturnValue(null); + snapshotPluginDirMock.mockReset(); + restoreOrCleanupMock.mockReset().mockReturnValue("noop"); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("session-start-setup — branch coverage", () => { + it("falls back to 'unknown' when userInfo().username is nullish", async () => { + loadCredsMock.mockReturnValue({ token: "t", orgId: "o", orgName: "acme" }); + userInfoMock.mockReturnValue({ username: undefined }); + await runHook(); + expect(debugLogMock).toHaveBeenCalledWith("backfilled userName: unknown"); + expect(saveCredsMock).toHaveBeenCalledWith( + expect.objectContaining({ userName: "unknown" }), + ); + }); + + it("skips autoupdate entirely when getInstalledVersion returns null", async () => { + getInstalledVersionMock.mockReturnValue(null); + await runHook(); + expect(getLatestVersionMock).not.toHaveBeenCalled(); + expect(execSyncMock).not.toHaveBeenCalled(); + }); + + it("takes the snapshot when resolveVersionedPluginDir returns a real install", async () => { + getLatestVersionMock.mockResolvedValue("0.6.39"); + isNewerMock.mockReturnValue(true); + resolveVersionedPluginDirMock.mockReturnValue({ + pluginDir: "/fake/plugin/dir", + versionsRoot: "/fake/plugin", + version: "0.6.38", + }); + snapshotPluginDirMock.mockReturnValue({ + pluginDir: "/fake/plugin/dir", + snapshot: "/fake/plugin/dir.keep-1234", + }); + restoreOrCleanupMock.mockReturnValue("cleaned"); + vi.spyOn(process.stderr, "write").mockReturnValue(true); + + await runHook(); + + expect(snapshotPluginDirMock).toHaveBeenCalledWith("/fake/plugin/dir"); + expect(restoreOrCleanupMock).toHaveBeenCalledWith(expect.objectContaining({ + pluginDir: "/fake/plugin/dir", + })); + expect(debugLogMock).toHaveBeenCalledWith( + expect.stringContaining("autoupdate snapshot outcome: cleaned"), + ); + }); + + it("restores the snapshot on the failure path when execSync throws", async () => { + getLatestVersionMock.mockResolvedValue("0.6.39"); + isNewerMock.mockReturnValue(true); + resolveVersionedPluginDirMock.mockReturnValue({ + pluginDir: "/fake/plugin/dir", + versionsRoot: "/fake/plugin", + version: "0.6.38", + }); + const handle = { pluginDir: "/fake/plugin/dir", snapshot: "/fake/snap" }; + snapshotPluginDirMock.mockReturnValue(handle); + execSyncMock.mockImplementation(() => { throw new Error("network"); }); + vi.spyOn(process.stderr, "write").mockReturnValue(true); + + await runHook(); + + // Called once in the catch block (not the try path, since execSync threw) + expect(restoreOrCleanupMock).toHaveBeenCalledWith(handle); + }); + + it("catches getLatestVersion throws and logs 'version check failed'", async () => { + // getLatestVersion throwing is the only thing that can reach the + // outer catch, since getInstalledVersion handles its own errors. + getLatestVersionMock.mockRejectedValue(new Error("dns boom")); + await runHook(); + expect(debugLogMock).toHaveBeenCalledWith( + expect.stringContaining("version check failed: dns boom"), + ); + }); +}); diff --git a/esbuild.config.mjs b/esbuild.config.mjs index 54d5fed..7b05a62 100644 --- a/esbuild.config.mjs +++ b/esbuild.config.mjs @@ -11,6 +11,7 @@ const ccHooks = [ { entry: "dist/src/hooks/capture.js", out: "capture" }, { entry: "dist/src/hooks/pre-tool-use.js", out: "pre-tool-use" }, { entry: "dist/src/hooks/session-end.js", out: "session-end" }, + { entry: "dist/src/hooks/plugin-cache-gc.js", out: "plugin-cache-gc" }, { entry: "dist/src/hooks/wiki-worker.js", out: "wiki-worker" }, ]; diff --git a/src/hooks/plugin-cache-gc.ts b/src/hooks/plugin-cache-gc.ts new file mode 100644 index 0000000..cec652a --- /dev/null +++ b/src/hooks/plugin-cache-gc.ts @@ -0,0 +1,69 @@ +#!/usr/bin/env node + +/** + * SessionEnd hook — garbage-collects old plugin version directories + * under ~/.claude/plugins/cache/hivemind/hivemind/. + * + * Keeps the current version plus the two next-newest + * (DEFAULT_KEEP_COUNT = 3), so sessions that started on a previous + * version still find their bundle until they exit — covers a session + * pinned through two further updates. Anything older is deleted. + * + * Stale `.keep-` snapshots from crashed SessionStart updates are + * also cleaned up. + */ + +import { fileURLToPath, pathToFileURL } from "node:url"; +import { dirname } from "node:path"; +import { log as _log } from "../utils/debug.js"; +import { + DEFAULT_KEEP_COUNT, + DEFAULT_MANIFEST_PATH, + executeGc, + planGc, + readCurrentVersionFromManifest, + resolveVersionedPluginDir, +} from "../utils/plugin-cache.js"; + +const defaultLog = (msg: string) => _log("plugin-cache-gc", msg); + +export interface RunGcOptions { + manifestPath?: string; + keepCount?: number; + log?: (msg: string) => void; +} + +export function runGc(bundleDir: string, opts: RunGcOptions = {}): void { + const log = opts.log ?? defaultLog; + if (process.env.HIVEMIND_WIKI_WORKER === "1") return; + + const resolved = resolveVersionedPluginDir(bundleDir); + if (!resolved) { log("not a versioned install, skipping"); return; } + + const manifestPath = opts.manifestPath ?? DEFAULT_MANIFEST_PATH; + const keepCount = opts.keepCount ?? DEFAULT_KEEP_COUNT; + const currentVersion = readCurrentVersionFromManifest(manifestPath); + const plan = planGc(resolved.versionsRoot, currentVersion, keepCount); + if (plan.deleteVersions.length === 0 && plan.deleteSnapshots.length === 0) { + log(`nothing to gc (kept: ${plan.keep.join(", ")})`); + return; + } + const result = executeGc(resolved.versionsRoot, plan); + log( + `gc kept=${result.kept.join(",")} ` + + `deletedVersions=${result.deletedVersions.join(",")} ` + + `deletedSnapshots=${result.deletedSnapshots.join(",")} ` + + `errors=${result.errors.length}`, + ); +} + +// Only auto-run when invoked as a script (bundled entrypoint). +// Imports from tests take the `runGc` export directly and skip this. +/* c8 ignore start — script-mode bootstrap, covered by bundle integration test */ +const __bundleDir = dirname(fileURLToPath(import.meta.url)); +const __entryUrl = process.argv[1] ? pathToFileURL(process.argv[1]).href : ""; +if (import.meta.url === __entryUrl) { + try { runGc(__bundleDir); } + catch (e: any) { defaultLog(`fatal: ${e.message}`); } +} +/* c8 ignore stop */ diff --git a/src/hooks/session-start-setup.ts b/src/hooks/session-start-setup.ts index f78ceb0..d55ef93 100644 --- a/src/hooks/session-start-setup.ts +++ b/src/hooks/session-start-setup.ts @@ -17,6 +17,7 @@ import { readStdin } from "../utils/stdin.js"; import { log as _log } from "../utils/debug.js"; import { getInstalledVersion, getLatestVersion, isNewer } from "../utils/version-check.js"; import { makeWikiLogger } from "../utils/wiki-log.js"; +import { resolveVersionedPluginDir, snapshotPluginDir, restoreOrCleanup } from "../utils/plugin-cache.js"; const log = (msg: string) => _log("session-setup", msg); const __bundleDir = dirname(fileURLToPath(import.meta.url)); @@ -68,15 +69,26 @@ async function main(): Promise { if (latest && isNewer(latest, current)) { if (autoupdate) { log(`autoupdate: updating ${current} → ${latest}`); + // Claude's installer deletes the old version directory, which + // invalidates the bundle paths baked into the *current* session's + // hook registry. Snapshot the dir first, restore it if the + // installer wiped it — so already-loaded hooks keep working + // until the session exits. Only applies to a real versioned + // install layout; a local --plugin-dir dev run is skipped. + const resolved = resolveVersionedPluginDir(__bundleDir); + const handle = resolved ? snapshotPluginDir(resolved.pluginDir) : null; try { const scopes = ["user", "project", "local", "managed"]; const cmd = scopes - .map(s => `claude plugin update hivemind@hivemind --scope ${s} 2>/dev/null`) + .map(s => `claude plugin update hivemind@hivemind --scope ${s} 2>/dev/null || true`) .join("; "); execSync(cmd, { stdio: "ignore", timeout: 60_000 }); + const outcome = restoreOrCleanup(handle); + log(`autoupdate snapshot outcome: ${outcome}`); process.stderr.write(`✅ Hivemind auto-updated: ${current} → ${latest}. Run /reload-plugins to apply.\n`); log(`autoupdate succeeded: ${current} → ${latest}`); } catch (e: any) { + restoreOrCleanup(handle); process.stderr.write(`⬆️ Hivemind update available: ${current} → ${latest}. Auto-update failed — run /hivemind:update to upgrade manually.\n`); log(`autoupdate failed: ${e.message}`); } diff --git a/src/hooks/session-start.ts b/src/hooks/session-start.ts index 60e402b..9bcc4e8 100644 --- a/src/hooks/session-start.ts +++ b/src/hooks/session-start.ts @@ -8,7 +8,6 @@ import { fileURLToPath } from "node:url"; import { dirname, join } from "node:path"; -import { readdirSync, rmSync } from "node:fs"; import { execSync } from "node:child_process"; import { homedir } from "node:os"; import { loadCredentials, saveCredentials, login } from "../commands/auth.js"; @@ -19,6 +18,7 @@ import { readStdin } from "../utils/stdin.js"; import { log as _log } from "../utils/debug.js"; import { getInstalledVersion, getLatestVersion, isNewer } from "../utils/version-check.js"; import { makeWikiLogger } from "../utils/wiki-log.js"; +import { resolveVersionedPluginDir, snapshotPluginDir, restoreOrCleanup } from "../utils/plugin-cache.js"; const log = (msg: string) => _log("session-start", msg); const __bundleDir = dirname(fileURLToPath(import.meta.url)); @@ -158,29 +158,25 @@ async function main(): Promise { if (latest && isNewer(latest, current)) { if (autoupdate) { log(`autoupdate: updating ${current} → ${latest}`); + // Snapshot the versioned plugin dir before the installer runs so + // this live session keeps finding its bundle paths. Old-version + // cleanup is handled by the SessionEnd GC hook (keeps last 2), + // which is safe across concurrent sessions. + const resolved = resolveVersionedPluginDir(__bundleDir); + const handle = resolved ? snapshotPluginDir(resolved.pluginDir) : null; try { const scopes = ["user", "project", "local", "managed"]; const cmd = scopes .map(s => `claude plugin update hivemind@hivemind --scope ${s} 2>/dev/null || true`) .join("; "); execSync(cmd, { stdio: "ignore", timeout: 60_000 }); - // Clean up old cached versions, keep only the latest - try { - const cacheParent = join(homedir(), ".claude", "plugins", "cache", "hivemind", "hivemind"); - const entries = readdirSync(cacheParent, { withFileTypes: true }); - for (const e of entries) { - if (e.isDirectory() && e.name !== latest) { - rmSync(join(cacheParent, e.name), { recursive: true, force: true }); - log(`cache cleanup: removed old version ${e.name}`); - } - } - } catch (e: any) { - log(`cache cleanup failed: ${e.message}`); - } + const outcome = restoreOrCleanup(handle); + log(`autoupdate snapshot outcome: ${outcome}`); updateNotice = `\n\n✅ Hivemind auto-updated: ${current} → ${latest}. Run /reload-plugins to apply.`; process.stderr.write(`✅ Hivemind auto-updated: ${current} → ${latest}. Run /reload-plugins to apply.\n`); log(`autoupdate succeeded: ${current} → ${latest}`); } catch (e: any) { + restoreOrCleanup(handle); updateNotice = `\n\n⬆️ Hivemind update available: ${current} → ${latest}. Auto-update failed — run /hivemind:update to upgrade manually.`; process.stderr.write(`⬆️ Hivemind update available: ${current} → ${latest}. Auto-update failed — run /hivemind:update to upgrade manually.\n`); log(`autoupdate failed: ${e.message}`); diff --git a/src/utils/plugin-cache.ts b/src/utils/plugin-cache.ts new file mode 100644 index 0000000..41a9313 --- /dev/null +++ b/src/utils/plugin-cache.ts @@ -0,0 +1,226 @@ +import { cpSync, existsSync, readdirSync, readFileSync, renameSync, rmSync, statSync } from "node:fs"; +import { basename, dirname, join, resolve, sep } from "node:path"; +import { homedir } from "node:os"; + +const SEMVER_RE = /^\d+\.\d+\.\d+$/; +const KEEP_RE = /\.keep-(\d+)$/; + +export function isSemver(name: string): boolean { + return SEMVER_RE.test(name); +} + +export function compareSemverDesc(a: string, b: string): number { + const pa = a.split(".").map(Number); + const pb = b.split(".").map(Number); + for (let i = 0; i < 3; i++) { + if (pa[i] !== pb[i]) return pb[i] - pa[i]; + } + return 0; +} + +/** + * Resolve the versioned plugin directory from the hook's bundle dir. + * + * Expected layout: `/plugins/cache/hivemind/hivemind//bundle/`. + * Returns null when we're not running from that layout — e.g. a local + * `--plugin-dir` dev run — so callers skip snapshot/restore/GC entirely. + */ +export function resolveVersionedPluginDir(bundleDir: string): { + pluginDir: string; + versionsRoot: string; + version: string; +} | null { + const pluginDir = dirname(bundleDir); + const versionsRoot = dirname(pluginDir); + const version = basename(pluginDir); + if (!isSemver(version)) return null; + if (basename(versionsRoot) !== "hivemind") return null; + const expectedPrefix = resolve(homedir(), ".claude", "plugins", "cache") + sep; + if (!resolve(versionsRoot).startsWith(expectedPrefix)) return null; + return { pluginDir, versionsRoot, version }; +} + +function snapshotPath(pluginDir: string, pid: number): string { + return `${pluginDir}.keep-${pid}`; +} + +function isPidAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch (e: any) { + return e?.code === "EPERM"; + } +} + +export interface SnapshotHandle { + pluginDir: string; + snapshot: string; +} + +/** + * Copy `pluginDir` to `.keep-` before the installer runs. + * Returns null when the dir doesn't exist or the copy fails — callers + * should still run the installer; the worst case is the existing bug + * (installer wipes the dir, we can't restore). + */ +export function snapshotPluginDir(pluginDir: string, pid = process.pid): SnapshotHandle | null { + if (!existsSync(pluginDir)) return null; + const snapshot = snapshotPath(pluginDir, pid); + try { + rmSync(snapshot, { recursive: true, force: true }); + cpSync(pluginDir, snapshot, { recursive: true, dereference: false }); + return { pluginDir, snapshot }; + } catch { + return null; + } +} + +export type RestoreOutcome = "restored" | "cleaned" | "noop" | "restore-failed"; + +/** + * After the installer runs, restore the snapshot if the installer wiped + * the versioned directory; otherwise remove the snapshot. + * + * Returns: + * - "restored" snapshot renamed back into place + * - "cleaned" plugin dir survived; snapshot removed + * - "noop" nothing to do (dev layout or both pluginDir and snapshot already absent) + * - "restore-failed" fs operation threw — pluginDir may still be absent, + * caller should treat this as a real failure. Also + * writes to stderr so the broken state is observable + * even if the log sink is unavailable. + */ +export function restoreOrCleanup(handle: SnapshotHandle | null): RestoreOutcome { + if (!handle) return "noop"; + const { pluginDir, snapshot } = handle; + try { + if (!existsSync(pluginDir)) { + if (existsSync(snapshot)) { + renameSync(snapshot, pluginDir); + return "restored"; + } + return "noop"; + } + rmSync(snapshot, { recursive: true, force: true }); + return "cleaned"; + } catch (e: any) { + try { process.stderr.write(`[plugin-cache] restoreOrCleanup failed for ${pluginDir}: ${e?.message}\n`); } catch { /* ignore */ } + return "restore-failed"; + } +} + +/** + * Read the currently-installed hivemind version from Claude's plugin + * manifest. Null when the manifest is missing or malformed. + */ +export function readCurrentVersionFromManifest(manifestPath: string): string | null { + try { + const raw = readFileSync(manifestPath, "utf-8"); + const parsed = JSON.parse(raw); + const entries = parsed?.plugins?.["hivemind@hivemind"]; + if (!Array.isArray(entries)) return null; + for (const e of entries) { + if (typeof e?.version === "string" && isSemver(e.version)) return e.version; + } + return null; + } catch { + return null; + } +} + +export interface GcPlan { + keep: string[]; + deleteVersions: string[]; + deleteSnapshots: string[]; +} + +/** + * Decide which entries to keep vs delete under the versions root. + * + * - Keeps the current version (from the manifest) plus the next-newest + * versions up to `keepCount` total. + * - Marks stale `.keep-` snapshots (dead PID) for deletion. + * - Leaves unknown entries (non-semver, non-`.keep-*`) alone so we + * never touch files the installer or user put there for other reasons. + */ +export function planGc( + versionsRoot: string, + currentVersion: string | null, + keepCount: number, + isAlive: (pid: number) => boolean = isPidAlive, +): GcPlan { + const entries = safeReaddir(versionsRoot); + const versions = entries.filter(isSemver); + const snapshots = entries.filter(e => KEEP_RE.test(e)); + + const sorted = [...versions].sort(compareSemverDesc); + const keep = new Set(); + if (currentVersion && versions.includes(currentVersion)) keep.add(currentVersion); + for (const v of sorted) { + if (keep.size >= keepCount) break; + keep.add(v); + } + + const deleteVersions: string[] = []; + if (currentVersion && versions.includes(currentVersion)) { + for (const v of versions) { + if (!keep.has(v)) deleteVersions.push(v); + } + } + + const deleteSnapshots: string[] = []; + for (const s of snapshots) { + const m = s.match(KEEP_RE); + if (!m) continue; + const pid = Number(m[1]); + if (!Number.isFinite(pid) || !isAlive(pid)) deleteSnapshots.push(s); + } + + return { keep: [...keep], deleteVersions, deleteSnapshots }; +} + +export interface GcResult { + kept: string[]; + deletedVersions: string[]; + deletedSnapshots: string[]; + errors: string[]; +} + +export function executeGc(versionsRoot: string, plan: GcPlan): GcResult { + const errors: string[] = []; + const deletedVersions: string[] = []; + const deletedSnapshots: string[] = []; + for (const v of plan.deleteVersions) { + const target = join(versionsRoot, v); + try { + rmSync(target, { recursive: true, force: true }); + deletedVersions.push(v); + } catch (e: any) { + errors.push(`${v}: ${e.message}`); + } + } + for (const s of plan.deleteSnapshots) { + const target = join(versionsRoot, s); + try { + rmSync(target, { recursive: true, force: true }); + deletedSnapshots.push(s); + } catch (e: any) { + errors.push(`${s}: ${e.message}`); + } + } + return { kept: plan.keep, deletedVersions, deletedSnapshots, errors }; +} + +function safeReaddir(dir: string): string[] { + try { + return readdirSync(dir).filter(name => { + try { return statSync(join(dir, name)).isDirectory(); } catch { return false; } + }); + } catch { + return []; + } +} + +export const DEFAULT_MANIFEST_PATH = join(homedir(), ".claude", "plugins", "installed_plugins.json"); +export const DEFAULT_KEEP_COUNT = 3; diff --git a/vitest.config.ts b/vitest.config.ts index c4fb754..375fd1f 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -98,6 +98,32 @@ export default defineConfig({ functions: 90, lines: 90, }, + // fix/plugin-autoupdate-session-safety — snapshot-restore around + // claude-plugin update + SessionEnd GC. All four files at 90+. + "src/utils/plugin-cache.ts": { + statements: 90, + branches: 90, + functions: 90, + lines: 90, + }, + "src/hooks/plugin-cache-gc.ts": { + statements: 90, + branches: 90, + functions: 90, + lines: 90, + }, + "src/hooks/session-start.ts": { + statements: 90, + branches: 90, + functions: 90, + lines: 90, + }, + "src/hooks/session-start-setup.ts": { + statements: 90, + branches: 90, + functions: 90, + lines: 90, + }, }, }, },