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:
Mert Koseoglu
2026-05-10 18:31:53 +03:00
parent ce5dca22cc
commit 8c045f96ed
5 changed files with 730 additions and 0 deletions
+1
View File
@@ -79,6 +79,7 @@
"start.mjs",
"scripts/postinstall.mjs",
"scripts/heal-better-sqlite3.mjs",
"scripts/heal-installed-plugins.mjs",
"README.md",
"LICENSE"
],
+129
View File
@@ -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 };
}
+58
View File
@@ -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.
+286
View File
@@ -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");
});
});
+256
View File
@@ -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);
});
});