mirror of
https://github.com/mksglu/context-mode.git
synced 2026-10-03 04:38:25 +08:00
fix(install): npm postinstall self-heal for poisoned installed_plugins.json (v1.0.114)
v1.0.113's /ctx-upgrade poisoned ~/.claude/plugins/installed_plugins.json
in two ways: (a) per-entry version drifted from the cache directory's
plugin.json version, and (b) the top-level enabledPlugins[<key>] was
emptied. Claude Code's plugin loader then refuses to load context-mode,
killing MCP — and with MCP gone the user can no longer run /ctx-upgrade
to recover. The escape hatch is `npm install -g context-mode@1.0.114`,
which executes regardless of plugin-loader state.
Adds a shared heal module (scripts/heal-installed-plugins.mjs) that:
- HEAL 3: rewrites entry.version from each cache dir's plugin.json
- HEAL 4: ensures enabledPlugins[<key>] is set when missing/empty
- returns a result object — never throws, best-effort posture
Wires it into scripts/postinstall.mjs behind an isGlobalInstall() guard
(npm_config_global=true AND no nearby .git) so contributor `npm install`
runs do not rewrite their HOME registry. Emits exactly one ASCII stderr
summary line per run: healed / no-heal-needed / no-Claude-Code-registry.
Coordination: this module is the single source of truth; start.mjs HEAL
3+4 should import from `./scripts/heal-installed-plugins.mjs` so install-
time and runtime heals stay aligned.
Tests:
- tests/util/heal-installed-plugins.test.ts (9): HEAL 3 sync, HEAL 4
create/rewrite/idempotent, no-registry skip, healthy no-op, path-
traversal guard, native sep, package.json files[] guard.
- tests/util/postinstall-heal.test.ts (4): integration via spawnSync
against a staged npm-install layout — non-global skip, poisoned
registry repair, no-Claude-Code silent OK, already-healthy no-op.
Full suite: 2824 tests, 8 failed (pre-existing opencode baseline),
2787 passed, 24 skipped. +13 tests, 0 regressions.
This commit is contained in:
@@ -79,6 +79,7 @@
|
||||
"start.mjs",
|
||||
"scripts/postinstall.mjs",
|
||||
"scripts/heal-better-sqlite3.mjs",
|
||||
"scripts/heal-installed-plugins.mjs",
|
||||
"README.md",
|
||||
"LICENSE"
|
||||
],
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* Self-heal `~/.claude/plugins/installed_plugins.json` (#46915 follow-up).
|
||||
*
|
||||
* v1.0.113's `/ctx-upgrade` poisoned this file in two ways:
|
||||
* 1. Per-entry `version` drifted from the actual cache directory's
|
||||
* `plugin.json` version.
|
||||
* 2. The top-level `enabledPlugins[<key>]` was emptied (or never set)
|
||||
* so Claude Code's plugin loader skipped context-mode → MCP died.
|
||||
*
|
||||
* Single source of truth shared by:
|
||||
* - `start.mjs` HEAL 3+4 (every MCP boot)
|
||||
* - `scripts/postinstall.mjs` (every `npm install -g context-mode`)
|
||||
*
|
||||
* Pure Node.js (built-ins only). Best-effort: never throws, always
|
||||
* returns a plain result object so callers can log a one-liner.
|
||||
*
|
||||
* @see https://github.com/anthropics/claude-code/issues/46915
|
||||
*/
|
||||
|
||||
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { resolve, sep } from "node:path";
|
||||
|
||||
/**
|
||||
* @typedef {Object} HealResult
|
||||
* @property {string[]} healed - one of: "entry-version", "enabled-plugins"
|
||||
* @property {string} [skipped] - reason if no work performed
|
||||
* @property {string} [error] - error message if heal aborted
|
||||
*/
|
||||
|
||||
/**
|
||||
* Heal a single plugin entry inside installed_plugins.json.
|
||||
*
|
||||
* @param {{
|
||||
* registryPath: string,
|
||||
* pluginCacheRoot: string,
|
||||
* pluginKey: string,
|
||||
* }} opts
|
||||
* @returns {HealResult}
|
||||
*/
|
||||
export function healInstalledPlugins({ registryPath, pluginCacheRoot, pluginKey }) {
|
||||
if (!registryPath || !existsSync(registryPath)) {
|
||||
return { healed: [], skipped: "no-registry" };
|
||||
}
|
||||
|
||||
let raw;
|
||||
try {
|
||||
raw = readFileSync(registryPath, "utf-8");
|
||||
} catch (err) {
|
||||
return { healed: [], error: `read-failed: ${(err && err.message) || err}` };
|
||||
}
|
||||
|
||||
let ip;
|
||||
try {
|
||||
ip = JSON.parse(raw);
|
||||
} catch (err) {
|
||||
return { healed: [], error: `parse-failed: ${(err && err.message) || err}` };
|
||||
}
|
||||
if (!ip || typeof ip !== "object") {
|
||||
return { healed: [], error: "bad-shape" };
|
||||
}
|
||||
|
||||
const entries = (ip.plugins && ip.plugins[pluginKey]) || [];
|
||||
if (!Array.isArray(entries) || entries.length === 0) {
|
||||
return { healed: [], skipped: "no-entry" };
|
||||
}
|
||||
|
||||
/** @type {string[]} */
|
||||
const healed = [];
|
||||
let syncedVersion = null;
|
||||
|
||||
// ── HEAL 3: per-entry version <- cache plugin.json version ──
|
||||
// We trust the cache directory because that's what start.mjs actually
|
||||
// boots from; the registry is just a stale label.
|
||||
for (const entry of entries) {
|
||||
if (!entry || typeof entry !== "object") continue;
|
||||
const installPath = entry.installPath;
|
||||
if (!installPath || typeof installPath !== "string") continue;
|
||||
|
||||
// Path-traversal guard: only consult plugin.json files inside the
|
||||
// declared plugin cache root.
|
||||
const resolvedInstall = resolve(installPath);
|
||||
const cacheRootWithSep = resolve(pluginCacheRoot) + sep;
|
||||
if (!resolvedInstall.startsWith(cacheRootWithSep)) continue;
|
||||
|
||||
const cachePluginJson = resolve(installPath, ".claude-plugin", "plugin.json");
|
||||
if (!existsSync(cachePluginJson)) continue;
|
||||
let actualVersion = null;
|
||||
try {
|
||||
const pj = JSON.parse(readFileSync(cachePluginJson, "utf-8"));
|
||||
if (pj && typeof pj.version === "string" && pj.version) {
|
||||
actualVersion = pj.version;
|
||||
}
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (!actualVersion) continue;
|
||||
|
||||
syncedVersion = actualVersion;
|
||||
if (entry.version !== actualVersion) {
|
||||
entry.version = actualVersion;
|
||||
if (!healed.includes("entry-version")) healed.push("entry-version");
|
||||
}
|
||||
}
|
||||
|
||||
// ── HEAL 4: top-level enabledPlugins[key] presence ──
|
||||
// Claude Code's plugin loader checks enabledPlugins. When /ctx-upgrade
|
||||
// emptied it, our plugin was silently disabled. Set it to `true` (the
|
||||
// simplest enabled-flag form) when missing or falsy.
|
||||
if (syncedVersion) {
|
||||
if (!ip.enabledPlugins || typeof ip.enabledPlugins !== "object" || Array.isArray(ip.enabledPlugins)) {
|
||||
ip.enabledPlugins = {};
|
||||
}
|
||||
const current = ip.enabledPlugins[pluginKey];
|
||||
if (current === undefined || current === null || current === false || current === "") {
|
||||
ip.enabledPlugins[pluginKey] = true;
|
||||
healed.push("enabled-plugins");
|
||||
}
|
||||
}
|
||||
|
||||
if (healed.length > 0) {
|
||||
try {
|
||||
writeFileSync(registryPath, JSON.stringify(ip, null, 2) + "\n", "utf-8");
|
||||
} catch (err) {
|
||||
return { healed: [], error: `write-failed: ${(err && err.message) || err}` };
|
||||
}
|
||||
}
|
||||
|
||||
return { healed };
|
||||
}
|
||||
@@ -14,10 +14,33 @@ import { dirname, resolve, join, sep } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { homedir } from "node:os";
|
||||
import { healBetterSqlite3Binding } from "./heal-better-sqlite3.mjs";
|
||||
import { healInstalledPlugins } from "./heal-installed-plugins.mjs";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const pkgRoot = resolve(__dirname, "..");
|
||||
|
||||
/**
|
||||
* True when running as a real `npm install -g context-mode`. We use this
|
||||
* to keep contributors' local `npm install` runs from rewriting their HOME's
|
||||
* Claude Code registry (would be very surprising during dev).
|
||||
*
|
||||
* Heuristic: npm sets `npm_config_global=true` for global installs AND the
|
||||
* package directory has no nearby `.git` (a contributor's clone always
|
||||
* does). Both signals must agree.
|
||||
*/
|
||||
function isGlobalInstall() {
|
||||
if (process.env.npm_config_global !== "true") return false;
|
||||
// Walk up a few levels looking for .git — contributors always have one.
|
||||
let dir = pkgRoot;
|
||||
for (let i = 0; i < 4; i++) {
|
||||
if (existsSync(join(dir, ".git"))) return false;
|
||||
const parent = dirname(dir);
|
||||
if (parent === dir) break;
|
||||
dir = parent;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that a path is safe to interpolate into a cmd.exe command.
|
||||
* Rejects characters that could enable command injection via cmd.exe.
|
||||
@@ -26,6 +49,41 @@ function isSafeWindowsPath(p) {
|
||||
return !/[&|<>"^%\r\n]/.test(p);
|
||||
}
|
||||
|
||||
// ── -1. v1.0.114 hotfix — installed_plugins.json registry repair ─────
|
||||
// /ctx-upgrade in v1.0.113 poisoned the registry (entry.version drifted
|
||||
// + enabledPlugins emptied), making Claude Code's plugin loader skip
|
||||
// context-mode entirely. start.mjs HEAL 3+4 fix this on every MCP boot,
|
||||
// but already-broken users have no MCP to boot — they need the heal to
|
||||
// run from npm postinstall. Shared module so both call sites stay in
|
||||
// sync. Only runs in real `npm install -g` to avoid surprising
|
||||
// contributors. Best effort, never blocks install. (#46915 follow-up.)
|
||||
if (isGlobalInstall()) {
|
||||
try {
|
||||
const registryPath = resolve(homedir(), ".claude", "plugins", "installed_plugins.json");
|
||||
const pluginCacheRoot = resolve(homedir(), ".claude", "plugins", "cache");
|
||||
const result = healInstalledPlugins({
|
||||
registryPath,
|
||||
pluginCacheRoot,
|
||||
pluginKey: "context-mode@context-mode",
|
||||
});
|
||||
if (result.skipped === "no-registry") {
|
||||
// Standalone npm user (no Claude Code) — silent success.
|
||||
process.stderr.write("context-mode: install OK, no Claude Code registry found\n");
|
||||
} else if (result.error) {
|
||||
process.stderr.write(`context-mode: install OK, registry heal skipped (${result.error})\n`);
|
||||
} else if (result.healed && result.healed.length > 0) {
|
||||
process.stderr.write(`context-mode: healed installed_plugins.json (${result.healed.join(", ")})\n`);
|
||||
} else {
|
||||
process.stderr.write("context-mode: install OK, no heal needed\n");
|
||||
}
|
||||
} catch (err) {
|
||||
// Never block install on a heal failure.
|
||||
try {
|
||||
process.stderr.write(`context-mode: install OK, heal aborted (${(err && err.message) || err})\n`);
|
||||
} catch { /* truly best effort */ }
|
||||
}
|
||||
}
|
||||
|
||||
// ── 0. Self-heal Layer 3: Backward symlink for stale registry (anthropics/claude-code#46915) ──
|
||||
// When this install completes, installed_plugins.json may still point to an old
|
||||
// non-existent path. Create a symlink from that old path → our new directory.
|
||||
|
||||
@@ -0,0 +1,286 @@
|
||||
/**
|
||||
* heal-installed-plugins — shared HEAL 3 + HEAL 4 logic.
|
||||
*
|
||||
* v1.0.113's /ctx-upgrade poisoned ~/.claude/plugins/installed_plugins.json by
|
||||
* (a) writing a stale per-entry `version` and (b) emptying `enabledPlugins`.
|
||||
* Claude Code's plugin loader then refuses to load context-mode and the user
|
||||
* loses MCP entirely — including the /ctx-upgrade escape hatch. This module
|
||||
* is the single source of truth used by BOTH `start.mjs` (runtime) and
|
||||
* `scripts/postinstall.mjs` (npm install) to repair the registry.
|
||||
*
|
||||
* HEAL 3: per-plugin entry.version <- cache dir's plugin.json version
|
||||
* HEAL 4: top-level enabledPlugins[pluginKey] <- the synced version
|
||||
*
|
||||
* MUST stay in sync between start.mjs and scripts/postinstall.mjs callers.
|
||||
* Both import from this module.
|
||||
*/
|
||||
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
|
||||
import { healInstalledPlugins } from "../../scripts/heal-installed-plugins.mjs";
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Shared helpers
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
const cleanups: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
while (cleanups.length) {
|
||||
const dir = cleanups.pop();
|
||||
if (dir) {
|
||||
try {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
} catch {
|
||||
/* best effort */
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function makeTmp(prefix = "ctx-heal-ip-"): string {
|
||||
const dir = mkdtempSync(join(tmpdir(), prefix));
|
||||
cleanups.push(dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
interface FakeRegistry {
|
||||
registryPath: string;
|
||||
cacheRoot: string;
|
||||
cacheDir: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a fake `~/.claude/plugins/` layout under `root`:
|
||||
* <root>/installed_plugins.json
|
||||
* <root>/cache/<owner>/<plugin>/<version>/.claude-plugin/plugin.json
|
||||
*
|
||||
* Returns paths the heal module needs.
|
||||
*/
|
||||
function buildFakeRegistry(opts: {
|
||||
registryVersionField?: number;
|
||||
entryVersion: string; // what installed_plugins.json says
|
||||
cacheVersion: string; // actual cache dir name + plugin.json version
|
||||
enabledPlugins?: unknown; // top-level enabledPlugins to seed
|
||||
ownerSlug?: string;
|
||||
pluginSlug?: string;
|
||||
}): FakeRegistry {
|
||||
const root = makeTmp();
|
||||
const owner = opts.ownerSlug ?? "context-mode";
|
||||
const plugin = opts.pluginSlug ?? "context-mode";
|
||||
const cacheRoot = resolve(root, "cache");
|
||||
const cacheDir = resolve(cacheRoot, owner, plugin, opts.cacheVersion);
|
||||
const claudePluginDir = resolve(cacheDir, ".claude-plugin");
|
||||
mkdirSync(claudePluginDir, { recursive: true });
|
||||
writeFileSync(
|
||||
resolve(claudePluginDir, "plugin.json"),
|
||||
JSON.stringify({ name: "context-mode", version: opts.cacheVersion }, null, 2),
|
||||
);
|
||||
const registry: Record<string, unknown> = {
|
||||
version: opts.registryVersionField ?? 2,
|
||||
plugins: {
|
||||
[`${plugin}@${owner}`]: [
|
||||
{
|
||||
scope: "user",
|
||||
installPath: cacheDir,
|
||||
version: opts.entryVersion,
|
||||
installedAt: "2025-01-01T00:00:00.000Z",
|
||||
lastUpdated: "2025-01-01T00:00:00.000Z",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
if (opts.enabledPlugins !== undefined) {
|
||||
registry.enabledPlugins = opts.enabledPlugins;
|
||||
}
|
||||
const registryPath = resolve(root, "installed_plugins.json");
|
||||
writeFileSync(registryPath, JSON.stringify(registry, null, 2) + "\n");
|
||||
return { registryPath, cacheRoot, cacheDir };
|
||||
}
|
||||
|
||||
function readRegistry(p: string): Record<string, unknown> {
|
||||
return JSON.parse(readFileSync(p, "utf-8"));
|
||||
}
|
||||
|
||||
const KEY = "context-mode@context-mode";
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Slice 1 — HEAL 3: per-plugin entry.version syncs from cache plugin.json
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("healInstalledPlugins — HEAL 3 (entry.version sync)", () => {
|
||||
it("rewrites entry.version when it disagrees with cache plugin.json", () => {
|
||||
const fake = buildFakeRegistry({
|
||||
entryVersion: "1.0.99", // poisoned/stale
|
||||
cacheVersion: "1.0.113", // actual
|
||||
});
|
||||
|
||||
const result = healInstalledPlugins({
|
||||
registryPath: fake.registryPath,
|
||||
pluginCacheRoot: fake.cacheRoot,
|
||||
pluginKey: KEY,
|
||||
});
|
||||
|
||||
expect(result.skipped).toBeUndefined();
|
||||
expect(result.healed).toContain("entry-version");
|
||||
|
||||
const after = readRegistry(fake.registryPath) as {
|
||||
plugins: Record<string, Array<{ version: string }>>;
|
||||
};
|
||||
expect(after.plugins[KEY][0].version).toBe("1.0.113");
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Slice 2 — HEAL 4: top-level enabledPlugins[key] is set to synced version
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("healInstalledPlugins — HEAL 4 (enabledPlugins)", () => {
|
||||
it("creates enabledPlugins[key] when missing entirely", () => {
|
||||
const fake = buildFakeRegistry({
|
||||
entryVersion: "1.0.113",
|
||||
cacheVersion: "1.0.113",
|
||||
// enabledPlugins omitted entirely (the user's actual broken state)
|
||||
});
|
||||
|
||||
const result = healInstalledPlugins({
|
||||
registryPath: fake.registryPath,
|
||||
pluginCacheRoot: fake.cacheRoot,
|
||||
pluginKey: KEY,
|
||||
});
|
||||
|
||||
expect(result.healed).toContain("enabled-plugins");
|
||||
const after = readRegistry(fake.registryPath) as {
|
||||
enabledPlugins?: Record<string, unknown>;
|
||||
};
|
||||
expect(after.enabledPlugins).toBeDefined();
|
||||
expect(after.enabledPlugins?.[KEY]).toBeDefined();
|
||||
});
|
||||
|
||||
it("rewrites enabledPlugins[key] when present but emptied", () => {
|
||||
const fake = buildFakeRegistry({
|
||||
entryVersion: "1.0.113",
|
||||
cacheVersion: "1.0.113",
|
||||
enabledPlugins: {}, // /ctx-upgrade poisoned shape
|
||||
});
|
||||
|
||||
const result = healInstalledPlugins({
|
||||
registryPath: fake.registryPath,
|
||||
pluginCacheRoot: fake.cacheRoot,
|
||||
pluginKey: KEY,
|
||||
});
|
||||
|
||||
expect(result.healed).toContain("enabled-plugins");
|
||||
const after = readRegistry(fake.registryPath) as {
|
||||
enabledPlugins?: Record<string, unknown>;
|
||||
};
|
||||
expect(after.enabledPlugins?.[KEY]).toBeDefined();
|
||||
});
|
||||
|
||||
it("leaves enabledPlugins untouched when already set", () => {
|
||||
const fake = buildFakeRegistry({
|
||||
entryVersion: "1.0.113",
|
||||
cacheVersion: "1.0.113",
|
||||
enabledPlugins: { [KEY]: true, "other@vendor": true },
|
||||
});
|
||||
|
||||
const result = healInstalledPlugins({
|
||||
registryPath: fake.registryPath,
|
||||
pluginCacheRoot: fake.cacheRoot,
|
||||
pluginKey: KEY,
|
||||
});
|
||||
|
||||
expect(result.healed).not.toContain("enabled-plugins");
|
||||
const after = readRegistry(fake.registryPath) as {
|
||||
enabledPlugins: Record<string, unknown>;
|
||||
};
|
||||
expect(after.enabledPlugins["other@vendor"]).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Slice 3 — defensive: no-registry, no-entry, idempotent healthy
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("healInstalledPlugins — defensive paths", () => {
|
||||
it("returns skipped:'no-registry' when registry file is missing", () => {
|
||||
const root = makeTmp();
|
||||
const result = healInstalledPlugins({
|
||||
registryPath: resolve(root, "does-not-exist.json"),
|
||||
pluginCacheRoot: resolve(root, "cache"),
|
||||
pluginKey: KEY,
|
||||
});
|
||||
expect(result.healed).toEqual([]);
|
||||
expect(result.skipped).toBe("no-registry");
|
||||
expect(result.error).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns healed:[] (no-op) when registry already healthy", () => {
|
||||
const fake = buildFakeRegistry({
|
||||
entryVersion: "1.0.113",
|
||||
cacheVersion: "1.0.113",
|
||||
enabledPlugins: { [KEY]: true },
|
||||
});
|
||||
|
||||
const before = readFileSync(fake.registryPath, "utf-8");
|
||||
const result = healInstalledPlugins({
|
||||
registryPath: fake.registryPath,
|
||||
pluginCacheRoot: fake.cacheRoot,
|
||||
pluginKey: KEY,
|
||||
});
|
||||
|
||||
expect(result.healed).toEqual([]);
|
||||
// Idempotent: bytes on disk are unchanged.
|
||||
expect(readFileSync(fake.registryPath, "utf-8")).toBe(before);
|
||||
});
|
||||
|
||||
it("ignores entries whose installPath escapes pluginCacheRoot", () => {
|
||||
const fake = buildFakeRegistry({
|
||||
entryVersion: "1.0.99",
|
||||
cacheVersion: "1.0.113",
|
||||
});
|
||||
// Tamper: point registry at /tmp/<rand> outside the declared cacheRoot.
|
||||
const ip = readRegistry(fake.registryPath) as {
|
||||
plugins: Record<string, Array<{ installPath: string }>>;
|
||||
};
|
||||
ip.plugins[KEY][0].installPath = makeTmp("ctx-escape-");
|
||||
writeFileSync(fake.registryPath, JSON.stringify(ip, null, 2) + "\n");
|
||||
|
||||
const result = healInstalledPlugins({
|
||||
registryPath: fake.registryPath,
|
||||
pluginCacheRoot: fake.cacheRoot,
|
||||
pluginKey: KEY,
|
||||
});
|
||||
// The install path was outside the cache root → skipped silently;
|
||||
// entry version stays "1.0.99" because we never trusted the foreign dir.
|
||||
expect(result.healed).not.toContain("entry-version");
|
||||
});
|
||||
|
||||
it("uses native path separators (no hardcoded '/' or '\\\\')", async () => {
|
||||
// Static guard: the module must rely on `node:path` for separators so
|
||||
// Windows installs work. Read its source and assert it doesn't bake in
|
||||
// a single hardcoded separator for path traversal.
|
||||
const src = readFileSync(
|
||||
resolve(__dirname, "../../scripts/heal-installed-plugins.mjs"),
|
||||
"utf-8",
|
||||
);
|
||||
expect(src).toMatch(/from "node:path"/);
|
||||
expect(src).toMatch(/\bsep\b/);
|
||||
});
|
||||
|
||||
it("is shipped in package.json `files` (so npm postinstall can find it)", () => {
|
||||
const pkg = JSON.parse(
|
||||
readFileSync(resolve(__dirname, "../../package.json"), "utf-8"),
|
||||
) as { files: string[] };
|
||||
expect(pkg.files).toContain("scripts/heal-installed-plugins.mjs");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,256 @@
|
||||
/**
|
||||
* scripts/postinstall.mjs — installed_plugins.json self-heal contract.
|
||||
*
|
||||
* v1.0.114 hotfix for users broken by v1.0.113's `/ctx-upgrade`. Their
|
||||
* Claude Code plugin loader rejects context-mode → MCP gone → they
|
||||
* can't run `/ctx-upgrade` to recover. The escape hatch is `npm install
|
||||
* -g context-mode@1.0.114` whose postinstall MUST repair their registry.
|
||||
*
|
||||
* These integration tests spawn `node scripts/postinstall.mjs` in a
|
||||
* subprocess with isolated HOME and assert end-to-end behavior:
|
||||
* - Heals when run as a true `npm install -g` (npm_config_global=true).
|
||||
* - Skips silently when run as a contributor's local `npm install`.
|
||||
* - One-line stderr summary; no walls of text, no scary noise.
|
||||
* - No-op when registry already healthy.
|
||||
*/
|
||||
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import {
|
||||
copyFileSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const REPO_ROOT = resolve(__dirname, "../..");
|
||||
const REPO_POSTINSTALL = resolve(REPO_ROOT, "scripts", "postinstall.mjs");
|
||||
const REPO_HEAL_IP = resolve(REPO_ROOT, "scripts", "heal-installed-plugins.mjs");
|
||||
const REPO_HEAL_SQLITE3 = resolve(REPO_ROOT, "scripts", "heal-better-sqlite3.mjs");
|
||||
const KEY = "context-mode@context-mode";
|
||||
|
||||
/**
|
||||
* Simulate an `npm install -g` package layout: copy postinstall + its
|
||||
* sibling helper modules into a tmpdir that has NO `.git` ancestor. The
|
||||
* `isGlobalInstall()` heuristic in postinstall.mjs walks up looking for
|
||||
* `.git` and skips heal if found — exactly what we want during contributor
|
||||
* `npm install` runs but exactly what we have to *bypass* in vitest, since
|
||||
* the test always lives inside a git checkout.
|
||||
*/
|
||||
function stagePostinstallPackage(): {
|
||||
scriptPath: string;
|
||||
packageDir: string;
|
||||
} {
|
||||
const root = mkdtempSync(join(tmpdir(), "ctx-postinstall-pkg-"));
|
||||
cleanups.push(root);
|
||||
const scriptsDir = join(root, "scripts");
|
||||
const hooksDir = join(root, "hooks");
|
||||
mkdirSync(scriptsDir, { recursive: true });
|
||||
mkdirSync(hooksDir, { recursive: true });
|
||||
copyFileSync(REPO_POSTINSTALL, join(scriptsDir, "postinstall.mjs"));
|
||||
copyFileSync(REPO_HEAL_IP, join(scriptsDir, "heal-installed-plugins.mjs"));
|
||||
copyFileSync(REPO_HEAL_SQLITE3, join(scriptsDir, "heal-better-sqlite3.mjs"));
|
||||
// postinstall imports ../hooks/normalize-hooks.mjs — provide a no-op stub
|
||||
// so the import does not crash. Real postinstall wraps the import in
|
||||
// try/catch so even a missing file is fine, but copying a stub keeps the
|
||||
// test focused on the heal contract, not on Windows hook normalization.
|
||||
writeFileSync(
|
||||
join(hooksDir, "normalize-hooks.mjs"),
|
||||
"export function normalizeHooksOnStartup() {}\n",
|
||||
);
|
||||
return { scriptPath: join(scriptsDir, "postinstall.mjs"), packageDir: root };
|
||||
}
|
||||
|
||||
const cleanups: string[] = [];
|
||||
afterEach(() => {
|
||||
while (cleanups.length) {
|
||||
const dir = cleanups.pop();
|
||||
if (dir) {
|
||||
try { rmSync(dir, { recursive: true, force: true }); } catch { /* best effort */ }
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function makeTmp(prefix = "ctx-postinstall-"): string {
|
||||
const dir = mkdtempSync(join(tmpdir(), prefix));
|
||||
cleanups.push(dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
interface FakeHome {
|
||||
home: string;
|
||||
registryPath: string;
|
||||
cacheDir: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lay out a fake HOME with `~/.claude/plugins/installed_plugins.json`
|
||||
* + a context-mode cache dir whose plugin.json declares `cacheVersion`.
|
||||
*/
|
||||
function buildFakeHome(opts: {
|
||||
entryVersion: string;
|
||||
cacheVersion: string;
|
||||
enabledPlugins?: unknown;
|
||||
}): FakeHome {
|
||||
const home = makeTmp("ctx-postinstall-home-");
|
||||
const pluginsRoot = resolve(home, ".claude", "plugins");
|
||||
const cacheDir = resolve(pluginsRoot, "cache", "context-mode", "context-mode", opts.cacheVersion);
|
||||
mkdirSync(resolve(cacheDir, ".claude-plugin"), { recursive: true });
|
||||
writeFileSync(
|
||||
resolve(cacheDir, ".claude-plugin", "plugin.json"),
|
||||
JSON.stringify({ name: "context-mode", version: opts.cacheVersion }, null, 2),
|
||||
);
|
||||
const registry: Record<string, unknown> = {
|
||||
version: 2,
|
||||
plugins: {
|
||||
[KEY]: [
|
||||
{
|
||||
scope: "user",
|
||||
installPath: cacheDir,
|
||||
version: opts.entryVersion,
|
||||
installedAt: "2025-01-01T00:00:00.000Z",
|
||||
lastUpdated: "2025-01-01T00:00:00.000Z",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
if (opts.enabledPlugins !== undefined) registry.enabledPlugins = opts.enabledPlugins;
|
||||
const registryPath = resolve(pluginsRoot, "installed_plugins.json");
|
||||
writeFileSync(registryPath, JSON.stringify(registry, null, 2) + "\n");
|
||||
return { home, registryPath, cacheDir };
|
||||
}
|
||||
|
||||
/**
|
||||
* Spawn a staged copy of postinstall (in a no-`.git` package layout) with
|
||||
* isolated HOME and chosen `npm_config_global` value. Returns
|
||||
* { stdout, stderr, status }.
|
||||
*/
|
||||
function runPostinstall(opts: {
|
||||
home: string;
|
||||
global: boolean;
|
||||
}): { stdout: string; stderr: string; status: number | null } {
|
||||
const staged = stagePostinstallPackage();
|
||||
const env: Record<string, string> = {
|
||||
PATH: process.env.PATH ?? "",
|
||||
HOME: opts.home,
|
||||
USERPROFILE: opts.home,
|
||||
};
|
||||
if (opts.global) env.npm_config_global = "true";
|
||||
const r = spawnSync(process.execPath, [staged.scriptPath], {
|
||||
cwd: staged.packageDir,
|
||||
env,
|
||||
encoding: "utf-8",
|
||||
timeout: 30_000,
|
||||
});
|
||||
return { stdout: r.stdout ?? "", stderr: r.stderr ?? "", status: r.status };
|
||||
}
|
||||
|
||||
function readRegistry(p: string): Record<string, unknown> {
|
||||
return JSON.parse(readFileSync(p, "utf-8"));
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Slice 5 — non-global install must NOT mutate registry
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("postinstall — non-global install (contributor `npm install`)", () => {
|
||||
it("does NOT heal installed_plugins.json when npm_config_global is unset", () => {
|
||||
const fake = buildFakeHome({
|
||||
entryVersion: "1.0.99", // poisoned
|
||||
cacheVersion: "1.0.113", // would be healed if we ran
|
||||
enabledPlugins: {},
|
||||
});
|
||||
|
||||
const before = readFileSync(fake.registryPath, "utf-8");
|
||||
const r = runPostinstall({ home: fake.home, global: false });
|
||||
// Best-effort posture — postinstall must never crash.
|
||||
expect(r.status === 0 || r.status === null).toBe(true);
|
||||
|
||||
// Registry MUST be untouched.
|
||||
expect(readFileSync(fake.registryPath, "utf-8")).toBe(before);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Slice 6 — global install with poisoned registry: heal happens
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("postinstall — global install with poisoned registry", () => {
|
||||
it("repairs entry.version + enabledPlugins and emits one stderr line", () => {
|
||||
const fake = buildFakeHome({
|
||||
entryVersion: "1.0.99", // poisoned
|
||||
cacheVersion: "1.0.113", // truth
|
||||
enabledPlugins: {}, // /ctx-upgrade emptied this
|
||||
});
|
||||
|
||||
const r = runPostinstall({ home: fake.home, global: true });
|
||||
expect(r.status === 0 || r.status === null).toBe(true);
|
||||
|
||||
const after = readRegistry(fake.registryPath) as {
|
||||
plugins: Record<string, Array<{ version: string }>>;
|
||||
enabledPlugins: Record<string, unknown>;
|
||||
};
|
||||
expect(after.plugins[KEY][0].version).toBe("1.0.113");
|
||||
expect(after.enabledPlugins[KEY]).toBeDefined();
|
||||
|
||||
// Concise stderr summary: a single human-readable line mentioning
|
||||
// context-mode + heal verb. NOT a wall of text.
|
||||
const healLines = r.stderr
|
||||
.split(/\r?\n/)
|
||||
.filter((l) => /context-mode/i.test(l) && /heal|sync|repair/i.test(l));
|
||||
expect(healLines.length).toBe(1);
|
||||
// No emoji / ANSI noise — line should be plain ASCII summary.
|
||||
expect(healLines[0]).toMatch(/^context-mode:/);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Slice 7 — global install but no Claude Code registry: silent OK
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("postinstall — global install, user not on Claude Code", () => {
|
||||
it("emits a single benign one-liner and never crashes", () => {
|
||||
const home = makeTmp("ctx-postinstall-home-bare-");
|
||||
const r = runPostinstall({ home, global: true });
|
||||
expect(r.status === 0 || r.status === null).toBe(true);
|
||||
|
||||
// No "scary" stderr noise — no stack traces, no ENOENT, no JSON parse.
|
||||
expect(r.stderr).not.toMatch(/stack/i);
|
||||
expect(r.stderr).not.toMatch(/throw/i);
|
||||
expect(r.stderr).not.toMatch(/ENOENT/);
|
||||
expect(r.stderr).not.toMatch(/SyntaxError/);
|
||||
|
||||
// Exactly one summary line that mentions context-mode.
|
||||
const ctxLines = r.stderr.split(/\r?\n/).filter((l) => /context-mode:/.test(l));
|
||||
expect(ctxLines.length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Slice 8 — registry already healthy: "no heal needed" line
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("postinstall — global install, registry already healthy", () => {
|
||||
it("emits 'no heal needed' and leaves registry bytes unchanged", () => {
|
||||
const fake = buildFakeHome({
|
||||
entryVersion: "1.0.114",
|
||||
cacheVersion: "1.0.114",
|
||||
enabledPlugins: { [KEY]: true },
|
||||
});
|
||||
const before = readFileSync(fake.registryPath, "utf-8");
|
||||
|
||||
const r = runPostinstall({ home: fake.home, global: true });
|
||||
expect(r.status === 0 || r.status === null).toBe(true);
|
||||
expect(readFileSync(fake.registryPath, "utf-8")).toBe(before);
|
||||
|
||||
const ctxLines = r.stderr.split(/\r?\n/).filter((l) => /context-mode:/.test(l));
|
||||
expect(ctxLines.length).toBe(1);
|
||||
expect(ctxLines[0]).toMatch(/no heal needed/i);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user