mirror of
https://github.com/Yeachan-Heo/oh-my-claudecode.git
synced 2026-09-03 06:25:33 +08:00
fix(hooks): symlink old plugin cache versions instead of deleting them
Stop hooks fail with "Cannot find module" when a plugin is updated mid-session because session-start.mjs aggressively deletes old cache directories while running sessions still reference them via the resolved CLAUDE_PLUGIN_ROOT environment variable. Changes: - session-start.mjs: Replace rmSync with symlinkSync for old versions (beyond latest 2), so stale CLAUDE_PLUGIN_ROOT paths follow the symlink to the current version's scripts - paths.ts: Increase purgeStalePluginCacheVersions grace period from 1 hour to 24 hours to avoid premature deletion during long sessions - Add integration tests for the symlink cleanup behavior - Update existing purge-stale-cache test staleStats default to match the new 24-hour threshold Closes #970
This commit is contained in:
@@ -6,7 +6,7 @@
|
||||
* Cross-platform: Windows, macOS, Linux
|
||||
*/
|
||||
|
||||
import { existsSync, readFileSync, readdirSync, rmSync, mkdirSync, writeFileSync } from 'fs';
|
||||
import { existsSync, readFileSync, readdirSync, rmSync, mkdirSync, writeFileSync, symlinkSync, lstatSync, readlinkSync, unlinkSync } from 'fs';
|
||||
import { join, dirname } from 'path';
|
||||
import { homedir } from 'os';
|
||||
import { fileURLToPath, pathToFileURL } from 'url';
|
||||
@@ -414,7 +414,10 @@ ${cleanContent}
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup old plugin cache versions (keep latest 2)
|
||||
// Cleanup old plugin cache versions (keep latest 2, symlink the rest)
|
||||
// Instead of deleting old versions, replace them with symlinks to the latest.
|
||||
// This prevents "Cannot find module" errors for sessions started before a
|
||||
// plugin update whose CLAUDE_PLUGIN_ROOT still points to the old version.
|
||||
try {
|
||||
const cacheBase = join(configDir, 'plugins', 'cache', 'omc', 'oh-my-claudecode');
|
||||
if (existsSync(cacheBase)) {
|
||||
@@ -422,11 +425,31 @@ ${cleanContent}
|
||||
.filter(v => /^\d+\.\d+\.\d+/.test(v))
|
||||
.sort(semverCompare)
|
||||
.reverse();
|
||||
const toRemove = versions.slice(2);
|
||||
for (const version of toRemove) {
|
||||
try {
|
||||
rmSync(join(cacheBase, version), { recursive: true, force: true });
|
||||
} catch {}
|
||||
|
||||
if (versions.length > 2) {
|
||||
const latest = versions[0];
|
||||
const toSymlink = versions.slice(2);
|
||||
for (const version of toSymlink) {
|
||||
try {
|
||||
const versionPath = join(cacheBase, version);
|
||||
const stat = lstatSync(versionPath);
|
||||
|
||||
if (stat.isSymbolicLink()) {
|
||||
// Already a symlink — update only if pointing to wrong target
|
||||
const target = readlinkSync(versionPath);
|
||||
if (target === latest) continue;
|
||||
unlinkSync(versionPath);
|
||||
} else if (stat.isDirectory()) {
|
||||
rmSync(versionPath, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
// Create relative symlink: e.g. 4.4.1 -> 4.4.3
|
||||
symlinkSync(latest, versionPath);
|
||||
} catch {
|
||||
// If symlink creation fails (e.g. Windows without dev mode),
|
||||
// leave the old directory in place — safer than deleting it.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
|
||||
@@ -32,7 +32,7 @@ function dirent(name: string): { name: string; isDirectory: () => boolean } {
|
||||
}
|
||||
|
||||
/** Return a stat result with mtime N ms ago */
|
||||
function staleStats(ageMs: number = 2 * 60 * 60 * 1000) {
|
||||
function staleStats(ageMs: number = 25 * 60 * 60 * 1000) {
|
||||
return { mtimeMs: Date.now() - ageMs } as ReturnType<typeof statSync>;
|
||||
}
|
||||
|
||||
|
||||
163
src/__tests__/session-start-cache-cleanup.test.ts
Normal file
163
src/__tests__/session-start-cache-cleanup.test.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, rmSync, existsSync, lstatSync, readlinkSync, readdirSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { tmpdir } from 'os';
|
||||
import { execFileSync } from 'child_process';
|
||||
|
||||
const SCRIPT_PATH = join(__dirname, '..', '..', 'scripts', 'session-start.mjs');
|
||||
const NODE = process.execPath;
|
||||
|
||||
/**
|
||||
* Integration tests for the plugin cache cleanup logic in session-start.mjs.
|
||||
*
|
||||
* The script's cleanup block scans ~/.claude/plugins/cache/omc/oh-my-claudecode/
|
||||
* for version directories, keeps the latest 2 real directories, and replaces
|
||||
* older versions with symlinks pointing to the latest version. This prevents
|
||||
* "Cannot find module" errors when a running session's CLAUDE_PLUGIN_ROOT
|
||||
* still points to an old (now-removed) version directory.
|
||||
*/
|
||||
describe('session-start.mjs — plugin cache cleanup uses symlinks', () => {
|
||||
let tmpDir: string;
|
||||
let fakeHome: string;
|
||||
let fakeCacheBase: string;
|
||||
let fakeProject: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = mkdtempSync(join(tmpdir(), 'omc-cache-test-'));
|
||||
fakeHome = join(tmpDir, 'home');
|
||||
fakeCacheBase = join(fakeHome, '.claude', 'plugins', 'cache', 'omc', 'oh-my-claudecode');
|
||||
fakeProject = join(tmpDir, 'project');
|
||||
|
||||
// Create fake project directory with .omc
|
||||
mkdirSync(join(fakeProject, '.omc', 'state'), { recursive: true });
|
||||
|
||||
// Create fake cache base
|
||||
mkdirSync(fakeCacheBase, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function createFakeVersion(version: string) {
|
||||
const versionDir = join(fakeCacheBase, version);
|
||||
mkdirSync(join(versionDir, 'scripts'), { recursive: true });
|
||||
writeFileSync(join(versionDir, 'scripts', 'run.cjs'), '// stub');
|
||||
writeFileSync(join(versionDir, 'scripts', 'session-start.mjs'), '// stub');
|
||||
return versionDir;
|
||||
}
|
||||
|
||||
function runSessionStart(env: Record<string, string> = {}) {
|
||||
// We can't easily run the full session-start.mjs because it reads stdin
|
||||
// and relies on many env vars. Instead, we test the cleanup logic by
|
||||
// providing the minimal input it needs.
|
||||
try {
|
||||
const result = execFileSync(NODE, [SCRIPT_PATH], {
|
||||
input: JSON.stringify({
|
||||
hook_event_name: 'SessionStart',
|
||||
session_id: 'test-session',
|
||||
cwd: fakeProject,
|
||||
}),
|
||||
encoding: 'utf-8',
|
||||
env: {
|
||||
...process.env,
|
||||
HOME: fakeHome,
|
||||
USERPROFILE: fakeHome, // Windows compat
|
||||
CLAUDE_PLUGIN_ROOT: join(fakeCacheBase, '4.4.3'),
|
||||
...env,
|
||||
},
|
||||
timeout: 15000,
|
||||
});
|
||||
return result.trim();
|
||||
} catch (err: any) {
|
||||
// The script may exit with non-zero but we still want its stdout
|
||||
return err.stdout?.trim() || '';
|
||||
}
|
||||
}
|
||||
|
||||
it('replaces old versions (beyond latest 2) with symlinks to the latest', () => {
|
||||
createFakeVersion('4.4.1');
|
||||
createFakeVersion('4.4.2');
|
||||
createFakeVersion('4.4.3');
|
||||
|
||||
runSessionStart();
|
||||
|
||||
// 4.4.3 (latest) and 4.4.2 (2nd latest) should remain as real directories
|
||||
const v3Stat = lstatSync(join(fakeCacheBase, '4.4.3'));
|
||||
expect(v3Stat.isDirectory()).toBe(true);
|
||||
expect(v3Stat.isSymbolicLink()).toBe(false);
|
||||
|
||||
const v2Stat = lstatSync(join(fakeCacheBase, '4.4.2'));
|
||||
expect(v2Stat.isDirectory()).toBe(true);
|
||||
expect(v2Stat.isSymbolicLink()).toBe(false);
|
||||
|
||||
// 4.4.1 (oldest) should be a symlink to 4.4.3
|
||||
const v1Stat = lstatSync(join(fakeCacheBase, '4.4.1'));
|
||||
expect(v1Stat.isSymbolicLink()).toBe(true);
|
||||
|
||||
const target = readlinkSync(join(fakeCacheBase, '4.4.1'));
|
||||
expect(target).toBe('4.4.3');
|
||||
});
|
||||
|
||||
it('with only 2 versions, no symlinks are created', () => {
|
||||
createFakeVersion('4.4.2');
|
||||
createFakeVersion('4.4.3');
|
||||
|
||||
runSessionStart();
|
||||
|
||||
// Both should remain as real directories
|
||||
const v3Stat = lstatSync(join(fakeCacheBase, '4.4.3'));
|
||||
expect(v3Stat.isDirectory()).toBe(true);
|
||||
expect(v3Stat.isSymbolicLink()).toBe(false);
|
||||
|
||||
const v2Stat = lstatSync(join(fakeCacheBase, '4.4.2'));
|
||||
expect(v2Stat.isDirectory()).toBe(true);
|
||||
expect(v2Stat.isSymbolicLink()).toBe(false);
|
||||
});
|
||||
|
||||
it('symlinked old version still resolves scripts correctly', () => {
|
||||
createFakeVersion('4.4.1');
|
||||
createFakeVersion('4.4.2');
|
||||
createFakeVersion('4.4.3');
|
||||
|
||||
runSessionStart();
|
||||
|
||||
// Verify that accessing a script through the symlinked old version works
|
||||
const scriptPath = join(fakeCacheBase, '4.4.1', 'scripts', 'run.cjs');
|
||||
expect(existsSync(scriptPath)).toBe(true);
|
||||
});
|
||||
|
||||
it('handles 4+ versions, symlinking all but latest 2', () => {
|
||||
createFakeVersion('4.4.0');
|
||||
createFakeVersion('4.4.1');
|
||||
createFakeVersion('4.4.2');
|
||||
createFakeVersion('4.4.3');
|
||||
|
||||
runSessionStart();
|
||||
|
||||
// 4.4.3 and 4.4.2: real directories
|
||||
expect(lstatSync(join(fakeCacheBase, '4.4.3')).isSymbolicLink()).toBe(false);
|
||||
expect(lstatSync(join(fakeCacheBase, '4.4.2')).isSymbolicLink()).toBe(false);
|
||||
|
||||
// 4.4.1 and 4.4.0: symlinks to 4.4.3
|
||||
expect(lstatSync(join(fakeCacheBase, '4.4.1')).isSymbolicLink()).toBe(true);
|
||||
expect(readlinkSync(join(fakeCacheBase, '4.4.1'))).toBe('4.4.3');
|
||||
|
||||
expect(lstatSync(join(fakeCacheBase, '4.4.0')).isSymbolicLink()).toBe(true);
|
||||
expect(readlinkSync(join(fakeCacheBase, '4.4.0'))).toBe('4.4.3');
|
||||
});
|
||||
|
||||
it('with only 1 version, no cleanup is needed', () => {
|
||||
createFakeVersion('4.4.3');
|
||||
|
||||
runSessionStart();
|
||||
|
||||
// Single version should remain as a real directory
|
||||
const entries = readdirSync(fakeCacheBase);
|
||||
expect(entries).toEqual(['4.4.3']);
|
||||
|
||||
const v3Stat = lstatSync(join(fakeCacheBase, '4.4.3'));
|
||||
expect(v3Stat.isDirectory()).toBe(true);
|
||||
expect(v3Stat.isSymbolicLink()).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -133,8 +133,10 @@ function stripTrailing(p: string): string {
|
||||
return toForwardSlash(p).replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
/** Default grace period: skip directories modified within the last hour. */
|
||||
const STALE_THRESHOLD_MS = 60 * 60 * 1000;
|
||||
/** Default grace period: skip directories modified within the last 24 hours.
|
||||
* Extended from 1 hour to 24 hours to avoid deleting cache directories that
|
||||
* are still referenced by long-running sessions via CLAUDE_PLUGIN_ROOT. */
|
||||
const STALE_THRESHOLD_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
export function purgeStalePluginCacheVersions(): PurgeCacheResult {
|
||||
const result: PurgeCacheResult = { removed: 0, removedPaths: [], errors: [] };
|
||||
|
||||
Reference in New Issue
Block a user