mirror of
https://github.com/mksglu/context-mode.git
synced 2026-10-04 05:08:24 +08:00
Claude Code's native plugin manager auto-update copies the previous
version's *already-normalized* `hooks/hooks.json` and
`.claude-plugin/plugin.json` forward into the new version's cache dir.
The `${CLAUDE_PLUGIN_ROOT}` placeholder is gone, so the existing
`normalizeHooksJson` / `normalizePluginJson` flow short-circuits — and
the stale `…/<old-version>/hooks/<file>.mjs` command paths persist.
The old version dir was cleaned up by the plugin manager, so every hook
fires `MODULE_NOT_FOUND`, while `ctx-doctor` stays green (it only
verifies the *current* version's files exist and that hooks are
*configured* — never that command paths point at the current version).
Break the ratchet:
* New `CACHE_VERSION_RE` matches `context-mode/context-mode/<X.Y.Z>`
segments inside arbitrary content.
* New `pluginRootVersion(pluginRoot)` extracts the current version from
the pluginRoot. Returns null for npm-global / dev-checkout layouts —
callers treat null as "no stale-path check possible", which is the
correct behavior outside the Claude Code cache layout (no regression
in those scenarios).
* New `hasStaleCacheVersionSegment(content, currentVersion)` returns
true when content references any cache version that is not the
current one.
* `needsHookNormalization(content, pluginRoot)` now also returns true
for already-resolved content carrying a stale cache version. The
second arg is optional for backwards compatibility with single-arg
callers.
* `normalizeHooksJson` / `normalizePluginJson` per-command loops add a
parallel branch alongside the existing placeholder branch: when a
command (or plugin.json arg string) holds a stale version segment,
rewrite `context-mode/context-mode/<old>` → `…/<current>` via the
same regex. Forward-slash form so MSYS-mangled paths heal as well.
* `normalizeHooksOnStartup` passes `pluginRoot` to the detection check
so the new path actually engages at boot.
Same class as #411 (.mcp.json) and #523 (.claude-plugin/plugin.json).
#528 fixed the `/ctx-upgrade` tmpdir variant — this covers the
previous-version-directory variant from Claude Code's auto-update.
Test plan:
* `tests/hooks/windows-hooks-normalization.test.ts` — 3 new
regression tests under `describe("normalize-hooks survives a
version bump (#604)")`. Each fails on `main`, all green after the
fix:
- `re-points an already-normalized hooks.json to the new version`
(the failing case from the issue body verbatim)
- `needsHookNormalization detects stale cache-root version segment`
- `normalizeHooksOnStartup self-heals stale hooks.json on next
boot` (end-to-end with two temp dirs mirroring the auto-update)
* `npm run typecheck` — clean.
* `npm test` — 145/145 files pass, 3302/3345 tests pass (43 skipped,
0 fail). Same baseline as `origin/next` before this patch — no
regression introduced.
Repro log preserved at /tmp/see-real-bug_mksglu-context-mode_604.log
(invoked via /see-real-bug).
This commit is contained in:
+101
-19
@@ -18,18 +18,70 @@ import { resolve } from "node:path";
|
||||
|
||||
const PLACEHOLDER = "${CLAUDE_PLUGIN_ROOT}";
|
||||
|
||||
// #604: matches a cache path segment `context-mode/context-mode/<version>`.
|
||||
// Capture group is the X.Y.Z version. Used to detect command paths frozen on a
|
||||
// previous-version dir that Claude Code's native plugin manager has since
|
||||
// cleaned up. `/g` so a single content blob with multiple stale references is
|
||||
// fully covered. Forward-slash only — callers convert beforehand.
|
||||
const CACHE_VERSION_RE =
|
||||
/context-mode\/context-mode\/([0-9]+\.[0-9]+\.[0-9]+)(?=\/)/g;
|
||||
|
||||
/** Convert any path string to forward slashes (MSYS-safe). */
|
||||
function fwd(p) {
|
||||
return String(p).replace(/\\/g, "/");
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure detection: does this content contain an unresolved CLAUDE_PLUGIN_ROOT
|
||||
* placeholder that should be normalized?
|
||||
* Extract the X.Y.Z version segment from a pluginRoot under the context-mode
|
||||
* cache layout. Returns null when running from npm-global, a dev checkout, or
|
||||
* any layout that does not match the `<…>/context-mode/context-mode/<v>(/…)?`
|
||||
* pattern — callers must treat null as "no stale-path check is possible".
|
||||
*/
|
||||
export function needsHookNormalization(content) {
|
||||
function pluginRootVersion(pluginRoot) {
|
||||
if (!pluginRoot) return null;
|
||||
const m =
|
||||
/context-mode\/context-mode\/([0-9]+\.[0-9]+\.[0-9]+)(?:\/|$)/.exec(
|
||||
fwd(pluginRoot),
|
||||
);
|
||||
return m ? m[1] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Does `content` reference any context-mode cache version segment that differs
|
||||
* from `currentVersion`? Detects the #604 ratchet: already-normalized hooks.json
|
||||
* / plugin.json carrying a previous version's absolute paths forward into a
|
||||
* newer version's cache directory after Claude Code's auto-update.
|
||||
*/
|
||||
function hasStaleCacheVersionSegment(content, currentVersion) {
|
||||
if (!currentVersion || !content || typeof content !== "string") return false;
|
||||
const safe = fwd(content);
|
||||
CACHE_VERSION_RE.lastIndex = 0;
|
||||
let m;
|
||||
while ((m = CACHE_VERSION_RE.exec(safe)) !== null) {
|
||||
if (m[1] !== currentVersion) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure detection: does this content need to be (re-)normalized?
|
||||
*
|
||||
* Two triggers:
|
||||
* 1. Fresh content still containing the `${CLAUDE_PLUGIN_ROOT}` placeholder
|
||||
* — the original #378 first-boot path on any host.
|
||||
* 2. (#604) Already-resolved content whose absolute paths point at a
|
||||
* different version of the context-mode cache than the current
|
||||
* `pluginRoot`. Breaks the ratchet that previously froze stale paths
|
||||
* after Claude Code's native plugin manager copied a previous version's
|
||||
* hooks.json forward.
|
||||
*
|
||||
* `pluginRoot` is optional for backwards compatibility with single-arg
|
||||
* callers; without it, only the placeholder check runs.
|
||||
*/
|
||||
export function needsHookNormalization(content, pluginRoot) {
|
||||
if (!content || typeof content !== "string") return false;
|
||||
return content.includes(PLACEHOLDER);
|
||||
if (content.includes(PLACEHOLDER)) return true;
|
||||
return hasStaleCacheVersionSegment(content, pluginRootVersion(pluginRoot));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -41,10 +93,11 @@ export function needsHookNormalization(content) {
|
||||
* Idempotent — leaves already-normalized content unchanged.
|
||||
*/
|
||||
export function normalizeHooksJson(content, nodePath, pluginRoot) {
|
||||
if (!needsHookNormalization(content)) return content;
|
||||
if (!needsHookNormalization(content, pluginRoot)) return content;
|
||||
|
||||
const safeNode = fwd(nodePath);
|
||||
const safeRoot = fwd(pluginRoot);
|
||||
const currentVersion = pluginRootVersion(pluginRoot);
|
||||
|
||||
let parsed;
|
||||
try {
|
||||
@@ -65,12 +118,30 @@ export function normalizeHooksJson(content, nodePath, pluginRoot) {
|
||||
if (!Array.isArray(inner)) continue;
|
||||
for (const h of inner) {
|
||||
if (typeof h?.command !== "string") continue;
|
||||
if (!h.command.includes(PLACEHOLDER)) continue;
|
||||
// Replace placeholder with absolute root (forward-slash).
|
||||
let next = h.command.replaceAll(PLACEHOLDER, safeRoot);
|
||||
// Replace bare `node ` prefix with quoted execPath. Match both
|
||||
// `node ` and `node\t` at start, with optional surrounding whitespace.
|
||||
next = next.replace(/^\s*node\s+/, `"${safeNode}" `);
|
||||
|
||||
const hasPlaceholder = h.command.includes(PLACEHOLDER);
|
||||
// #604: also rewrite when the command holds a stale absolute path under
|
||||
// a previous-version cache dir (Claude Code's auto-update ratchet).
|
||||
const hasStale = hasStaleCacheVersionSegment(h.command, currentVersion);
|
||||
if (!hasPlaceholder && !hasStale) continue;
|
||||
|
||||
let next = h.command;
|
||||
if (hasPlaceholder) {
|
||||
// Replace placeholder with absolute root (forward-slash).
|
||||
next = next.replaceAll(PLACEHOLDER, safeRoot);
|
||||
// Replace bare `node ` prefix with quoted execPath. Match both
|
||||
// `node ` and `node\t` at start, with optional surrounding whitespace.
|
||||
next = next.replace(/^\s*node\s+/, `"${safeNode}" `);
|
||||
}
|
||||
if (hasStale) {
|
||||
// Re-point every `context-mode/context-mode/<old-version>/…` segment
|
||||
// to the current pluginRoot's version. Operates on the forward-slash
|
||||
// form so MSYS-mangled paths heal as well.
|
||||
next = fwd(next).replace(
|
||||
CACHE_VERSION_RE,
|
||||
`context-mode/context-mode/${currentVersion}`,
|
||||
);
|
||||
}
|
||||
h.command = next;
|
||||
mutated = true;
|
||||
}
|
||||
@@ -92,10 +163,11 @@ export function normalizeHooksJson(content, nodePath, pluginRoot) {
|
||||
* Idempotent.
|
||||
*/
|
||||
export function normalizePluginJson(content, nodePath, pluginRoot) {
|
||||
if (!needsHookNormalization(content)) return content;
|
||||
if (!needsHookNormalization(content, pluginRoot)) return content;
|
||||
|
||||
const safeNode = fwd(nodePath);
|
||||
const safeRoot = fwd(pluginRoot);
|
||||
const currentVersion = pluginRootVersion(pluginRoot);
|
||||
|
||||
let parsed;
|
||||
try {
|
||||
@@ -114,11 +186,21 @@ export function normalizePluginJson(content, nodePath, pluginRoot) {
|
||||
|
||||
if (Array.isArray(srv.args)) {
|
||||
const before = srv.args;
|
||||
const after = before.map((a) =>
|
||||
typeof a === "string" && a.includes(PLACEHOLDER)
|
||||
? a.replaceAll(PLACEHOLDER, safeRoot)
|
||||
: a,
|
||||
);
|
||||
const after = before.map((a) => {
|
||||
if (typeof a !== "string") return a;
|
||||
let next = a;
|
||||
if (next.includes(PLACEHOLDER)) {
|
||||
next = next.replaceAll(PLACEHOLDER, safeRoot);
|
||||
}
|
||||
// #604: same auto-update ratchet hits plugin.json args (see #523).
|
||||
if (hasStaleCacheVersionSegment(next, currentVersion)) {
|
||||
next = fwd(next).replace(
|
||||
CACHE_VERSION_RE,
|
||||
`context-mode/context-mode/${currentVersion}`,
|
||||
);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
if (after.some((v, i) => v !== before[i])) {
|
||||
srv.args = after;
|
||||
mutated = true;
|
||||
@@ -158,7 +240,7 @@ export function normalizeHooksOnStartup({ pluginRoot, nodePath, platform }) {
|
||||
const hooksPath = resolve(pluginRoot, "hooks", "hooks.json");
|
||||
if (existsSync(hooksPath)) {
|
||||
const original = readFileSync(hooksPath, "utf-8");
|
||||
if (needsHookNormalization(original)) {
|
||||
if (needsHookNormalization(original, pluginRoot)) {
|
||||
const next = normalizeHooksJson(original, nodePath, pluginRoot);
|
||||
if (next !== original) {
|
||||
writeFileSync(hooksPath, next, "utf-8");
|
||||
@@ -174,7 +256,7 @@ export function normalizeHooksOnStartup({ pluginRoot, nodePath, platform }) {
|
||||
const pluginPath = resolve(pluginRoot, ".claude-plugin", "plugin.json");
|
||||
if (existsSync(pluginPath)) {
|
||||
const original = readFileSync(pluginPath, "utf-8");
|
||||
if (needsHookNormalization(original)) {
|
||||
if (needsHookNormalization(original, pluginRoot)) {
|
||||
const next = normalizePluginJson(original, nodePath, pluginRoot);
|
||||
if (next !== original) {
|
||||
writeFileSync(pluginPath, next, "utf-8");
|
||||
|
||||
@@ -402,3 +402,109 @@ describe("normalizeHooksOnStartup", () => {
|
||||
).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────
|
||||
// Slice 5: version-bump regression (#604)
|
||||
//
|
||||
// Claude Code's native plugin manager auto-update carries the previous
|
||||
// version's *already-normalized* hooks.json forward into the new version
|
||||
// directory. The placeholder is gone, so normalize-hooks short-circuits
|
||||
// and the stale `…/<old-version>/hooks/<file>.mjs` command paths persist.
|
||||
// The old version dir has been cleaned up → every hook fires MODULE_NOT_FOUND.
|
||||
// `ctx-doctor` stays green because it only checks the current dir exists
|
||||
// and that hooks are *configured*, not that command paths point at it.
|
||||
//
|
||||
// Fix: detection + rewrite must also handle stale absolute paths whose
|
||||
// `context-mode/context-mode/<version>` segment differs from the current
|
||||
// pluginRoot. See `hooks/cache-heal-utils.mjs` `isStaleNodePath` for the
|
||||
// precedent on stale-absolute-path repair.
|
||||
// ─────────────────────────────────────────────────────────
|
||||
|
||||
describe("normalize-hooks survives a version bump (#604)", () => {
|
||||
const NODE = "/usr/bin/node";
|
||||
const ROOT_V135 = "/cache/context-mode/context-mode/1.0.135";
|
||||
const ROOT_V136 = "/cache/context-mode/context-mode/1.0.136";
|
||||
const PLACEHOLDER_SOURCE = JSON.stringify({
|
||||
hooks: {
|
||||
SessionStart: [
|
||||
{
|
||||
matcher: "",
|
||||
hooks: [
|
||||
{
|
||||
type: "command",
|
||||
command: 'node "${CLAUDE_PLUGIN_ROOT}/hooks/sessionstart.mjs"',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
test("re-points an already-normalized hooks.json to the new version (#604)", () => {
|
||||
// v135 boot: placeholder → absolute 1.0.135 path
|
||||
const v135 = normalizeHooksJson(PLACEHOLDER_SOURCE, NODE, ROOT_V135);
|
||||
expect(v135).toContain("/1.0.135/");
|
||||
|
||||
// Auto-update carries v135's normalized hooks.json into the 1.0.136 dir;
|
||||
// the 1.0.136 MCP server boots and normalize runs again with the new root.
|
||||
const v136 = normalizeHooksJson(v135, NODE, ROOT_V136);
|
||||
|
||||
// After the fix: stale `/1.0.135/` segment must be re-pointed to `/1.0.136/`.
|
||||
// Pre-fix this fails because needsHookNormalization(v135) === false
|
||||
// (placeholder gone) → normalizeHooksJson short-circuits → v136 === v135.
|
||||
expect(v136).toContain("/1.0.136/");
|
||||
expect(v136).not.toContain("/1.0.135/");
|
||||
});
|
||||
|
||||
test("needsHookNormalization detects stale cache-root version segment", () => {
|
||||
// Already-normalized content with the OLD version segment must still be
|
||||
// flagged for normalization when the current pluginRoot has a NEW segment.
|
||||
const v135 = normalizeHooksJson(PLACEHOLDER_SOURCE, NODE, ROOT_V135);
|
||||
expect(needsHookNormalization(v135, ROOT_V136)).toBe(true);
|
||||
|
||||
// Same content + same pluginRoot → no work needed.
|
||||
expect(needsHookNormalization(v135, ROOT_V135)).toBe(false);
|
||||
|
||||
// Placeholder always wins.
|
||||
expect(needsHookNormalization(PLACEHOLDER_SOURCE, ROOT_V136)).toBe(true);
|
||||
});
|
||||
|
||||
test("normalizeHooksOnStartup self-heals stale hooks.json on next boot (end-to-end)", () => {
|
||||
const cacheBase = makeTmp();
|
||||
const v135Dir = join(cacheBase, "context-mode", "context-mode", "1.0.135");
|
||||
const v136Dir = join(cacheBase, "context-mode", "context-mode", "1.0.136");
|
||||
mkdirSync(join(v135Dir, "hooks"), { recursive: true });
|
||||
mkdirSync(join(v136Dir, "hooks"), { recursive: true });
|
||||
|
||||
// v135 boot: write fresh placeholder hooks.json + normalize.
|
||||
writeFileSync(join(v135Dir, "hooks", "hooks.json"), PLACEHOLDER_SOURCE);
|
||||
normalizeHooksOnStartup({
|
||||
pluginRoot: v135Dir,
|
||||
nodePath: NODE,
|
||||
platform: "linux",
|
||||
});
|
||||
const normalizedV135 = readFileSync(
|
||||
join(v135Dir, "hooks", "hooks.json"),
|
||||
"utf-8",
|
||||
);
|
||||
expect(normalizedV135).toContain("/1.0.135/");
|
||||
|
||||
// Claude Code's native auto-update: copy v135's normalized hooks.json
|
||||
// forward into v136 dir, then clean up v135.
|
||||
writeFileSync(join(v136Dir, "hooks", "hooks.json"), normalizedV135);
|
||||
rmSync(v135Dir, { recursive: true, force: true });
|
||||
|
||||
// v136 boot: normalize must re-point the stale absolute paths.
|
||||
normalizeHooksOnStartup({
|
||||
pluginRoot: v136Dir,
|
||||
nodePath: NODE,
|
||||
platform: "linux",
|
||||
});
|
||||
const healedV136 = readFileSync(
|
||||
join(v136Dir, "hooks", "hooks.json"),
|
||||
"utf-8",
|
||||
);
|
||||
expect(healedV136).toContain("/1.0.136/");
|
||||
expect(healedV136).not.toContain("/1.0.135/");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user