@sdsrs/code-graph 0.101.0 → 0.102.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -5,6 +5,7 @@ const path = require('path');
5
5
  const fs = require('fs');
6
6
  const os = require('os');
7
7
  const { readBinaryVersion } = require('./version-utils');
8
+ const { npmSpawnOpts } = require('./npm-exec');
8
9
 
9
10
  const PLATFORM = os.platform();
10
11
  const ARCH = os.arch();
@@ -51,9 +52,17 @@ function unsupportedPlatformHint(platform = PLATFORM, arch = ARCH, libc = null)
51
52
  return null;
52
53
  }
53
54
 
54
- /** Read the npm pkg version from this script's package.json (claude-plugin/../package.json). */
55
+ /**
56
+ * Version that arms the gates below. Two shipped layouts resolve differently:
57
+ * npm install has `<pkg>/package.json` two levels up; the marketplace/plugin-cache
58
+ * copy ships ONLY the claude-plugin subtree, so `../.claude-plugin/plugin.json`
59
+ * is the sole version source there. Without the fallback, every marketplace
60
+ * install ran with a null version and each gate degraded to
61
+ * first-candidate-wins (the pre-d578d99 relic-shadowing behavior).
62
+ */
55
63
  function getPackageVersion() {
56
- try { return require('../../package.json').version; }
64
+ try { return require('../../package.json').version; } catch { /* not npm layout */ }
65
+ try { return require('../.claude-plugin/plugin.json').version; }
57
66
  catch { return null; }
58
67
  }
59
68
 
@@ -87,7 +96,19 @@ function globalNodeModulesCandidates() {
87
96
  const out = [];
88
97
  const nodeBinDir = path.dirname(process.execPath);
89
98
 
90
- // 1. Derive from process.execPath. Works for nvm + standard Unix prefixes
99
+ // 1. NPM_CONFIG_PREFIX env override (users with `~/.npm-global` etc.) FIRST:
100
+ // when set, `npm install -g` actually installs THERE, so it is the most
101
+ // authoritative location — matching npm's own prefix-resolution order.
102
+ // (It also used to rank below the execPath derivation, which let a stale
103
+ // relic in the nvm prefix shadow the user's real prefix.)
104
+ const envPrefix = process.env.NPM_CONFIG_PREFIX || process.env.npm_config_prefix;
105
+ if (envPrefix) {
106
+ out.push(PLATFORM === 'win32'
107
+ ? path.join(envPrefix, 'node_modules')
108
+ : path.join(envPrefix, 'lib', 'node_modules'));
109
+ }
110
+
111
+ // 2. Derive from process.execPath. Works for nvm + standard Unix prefixes
91
112
  // (`<prefix>/bin/node` → globals at `<prefix>/lib/node_modules`); on
92
113
  // Windows globals sit next to `node.exe`.
93
114
  if (PLATFORM === 'win32') {
@@ -96,25 +117,17 @@ function globalNodeModulesCandidates() {
96
117
  out.push(path.resolve(nodeBinDir, '..', 'lib', 'node_modules'));
97
118
  }
98
119
 
99
- // 2. NPM_CONFIG_PREFIX env override (set by users using `~/.npm-global` etc.)
100
- const envPrefix = process.env.NPM_CONFIG_PREFIX || process.env.npm_config_prefix;
101
- if (envPrefix) {
102
- out.push(PLATFORM === 'win32'
103
- ? path.join(envPrefix, 'node_modules')
104
- : path.join(envPrefix, 'lib', 'node_modules'));
105
- }
106
-
107
120
  // 3. Common no-sudo user prefix
108
121
  out.push(path.join(os.homedir(), '.npm-global', 'lib', 'node_modules'));
109
122
 
110
123
  // 4. Last resort: ask npm directly. Slow (~50-200ms) but most accurate when
111
124
  // user has a non-standard prefix. Cached at the disk-cache layer above.
112
125
  try {
113
- const root = execFileSync('npm', ['root', '-g'], {
126
+ const root = execFileSync('npm', ['root', '-g'], npmSpawnOpts({
114
127
  timeout: 2000,
115
128
  stdio: ['pipe', 'pipe', 'pipe'],
116
129
  encoding: 'utf8',
117
- }).trim();
130
+ })).trim();
118
131
  if (root) out.push(root);
119
132
  } catch { /* npm not on PATH or timed out */ }
120
133
 
@@ -160,6 +173,10 @@ function isCachedBinaryFresh(cachedPath, pkgVersion) {
160
173
  * auto-update cache → platform npm pkg → bundled (bin/) →
161
174
  * cargo install → PATH → npx cache
162
175
  *
176
+ * Every tier after dev-mode is version-gated (createVersionGate): the first
177
+ * candidate at/above the pkg version wins; when NONE is current, the newest
178
+ * stale candidate is returned rather than null.
179
+ *
163
180
  * Returns the absolute path or null if not found.
164
181
  */
165
182
  function findBinary() {
@@ -200,21 +217,57 @@ function isDevRepo(rootDir) {
200
217
  * nvm/standard setups), so a working `npm install -g @sdsrs/code-graph` can
201
218
  * still be invisible without the fallback.
202
219
  */
203
- function findPlatformBinary() {
220
+ function platformBinaryCandidates() {
221
+ const out = [];
204
222
  // Fast path: standard module resolution.
205
223
  try {
206
224
  const pkgPath = require.resolve(`${PLATFORM_PKG}/package.json`);
207
225
  const bin = path.join(path.dirname(pkgPath), BINARY_NAME);
208
- if (isNativeBinary(bin)) return bin;
226
+ if (isNativeBinary(bin)) out.push(bin);
209
227
  } catch { /* not in node_modules walk-up */ }
210
228
 
211
229
  // Slow path: explicit global node_modules probe.
212
230
  for (const globalRoot of globalNodeModulesCandidates()) {
213
231
  const bin = path.join(globalRoot, '@sdsrs', `code-graph-${PLATFORM}-${ARCH}`, BINARY_NAME);
214
- if (isNativeBinary(bin)) return bin;
232
+ if (isNativeBinary(bin)) out.push(bin);
215
233
  }
216
234
 
217
- return null;
235
+ return out;
236
+ }
237
+
238
+ function findPlatformBinary() {
239
+ return platformBinaryCandidates()[0] || null;
240
+ }
241
+
242
+ /**
243
+ * Version gate for discovery candidates. `consider(bin)` accepts a candidate
244
+ * outright when it is current (version >= pkgVersion) or unverifiable (no pkg
245
+ * version / binary won't report one — don't refuse the only path we may have);
246
+ * a candidate OLDER than the package is recorded as a stale fallback instead of
247
+ * being returned, and `best()` yields the NEWEST of those.
248
+ *
249
+ * Why: candidates below the auto-update cache had no version check at all, so
250
+ * a years-old relic (a 0.16.6 `npm install -g` leftover in the nvm global
251
+ * node_modules) was returned VERBATIM during every post-release window in
252
+ * which the cache binary was one version behind — an ancient server on a
253
+ * modern schema, presenting as the MCP 30s connect-timeout. Newest-stale
254
+ * beats null because every consumer (statusline, hooks, CLI) degrades more
255
+ * gracefully on a slightly-old binary than on "offline", and the stale-binary
256
+ * self-heal in auto-update.js re-downloads shortly anyway.
257
+ */
258
+ function createVersionGate(pkgVersion, { readVersion = readBinaryVersion } = {}) {
259
+ let bestStale = null;
260
+ return {
261
+ consider(bin) {
262
+ if (!isNativeBinary(bin)) return null;
263
+ if (!pkgVersion) return bin;
264
+ const ver = readVersion(bin);
265
+ if (!ver || compareVersions(ver, pkgVersion) >= 0) return bin;
266
+ if (!bestStale || compareVersions(ver, bestStale.ver) > 0) bestStale = { bin, ver };
267
+ return null;
268
+ },
269
+ best() { return bestStale ? bestStale.bin : null; },
270
+ };
218
271
  }
219
272
 
220
273
  function findBinaryUncached() {
@@ -240,24 +293,31 @@ function findBinaryUncached() {
240
293
  }
241
294
  }
242
295
 
296
+ // Every tier below runs through the version gate: a candidate at or above
297
+ // the npm pkg version is returned on the spot (tier order = priority, same
298
+ // as before); an OLDER candidate is only remembered as a fallback. Without
299
+ // the gate, tiers below the auto-update cache accepted any binary verbatim —
300
+ // so when the cache was one release behind (every post-release window), an
301
+ // ancient global-npm relic could shadow it (the 0.16.6-serves-a-modern-DB
302
+ // incident behind the MCP 30s connect-timeout).
303
+ const gate = createVersionGate(getPackageVersion());
304
+
243
305
  // --- Auto-update cache (binary downloaded directly from GitHub release) ---
244
306
  // Cache wins when its version >= the npm pkg version. After `npm update`
245
307
  // refreshes the platform-pkg, an older auto-update cache binary must NOT
246
308
  // shadow the freshly-installed one; this version check prevents the
247
309
  // upgrade-race where users keep running stale binary until auto-update fires.
248
310
  const autoUpdateBin = path.join(os.homedir(), '.cache', 'code-graph', 'bin', BINARY_NAME);
249
- if (isNativeBinary(autoUpdateBin)) {
250
- const cacheVer = readBinaryVersion(autoUpdateBin);
251
- const pkgVer = getPackageVersion();
252
- if (!pkgVer || !cacheVer || compareVersions(cacheVer, pkgVer) >= 0) {
253
- return autoUpdateBin;
254
- }
255
- // Cache is older than npm pkg — fall through to platform-pkg.
311
+ {
312
+ const hit = gate.consider(autoUpdateBin);
313
+ if (hit) return hit;
256
314
  }
257
315
 
258
316
  // --- Platform-specific npm package (@sdsrs/code-graph-{os}-{arch}) ---
259
- const platformBin = findPlatformBinary();
260
- if (platformBin) return platformBin;
317
+ for (const platformBin of platformBinaryCandidates()) {
318
+ const hit = gate.consider(platformBin);
319
+ if (hit) return hit;
320
+ }
261
321
 
262
322
  // --- Bundled binary (in same directory as cli.js or plugin scripts) ---
263
323
  // Check bin/ directory of the npm package
@@ -267,20 +327,23 @@ function findBinaryUncached() {
267
327
  }
268
328
  binDirs.add(path.resolve(__dirname, '..', '..', 'bin'));
269
329
  for (const dir of binDirs) {
270
- const bundled = path.join(dir, BINARY_NAME);
271
- if (isNativeBinary(bundled)) return bundled;
330
+ const hit = gate.consider(path.join(dir, BINARY_NAME));
331
+ if (hit) return hit;
272
332
  }
273
333
 
274
334
  // --- Cargo install (~/.cargo/bin) ---
275
- const cargoBin = path.join(os.homedir(), '.cargo', 'bin', BINARY_NAME);
276
- if (isNativeBinary(cargoBin)) return cargoBin;
335
+ {
336
+ const hit = gate.consider(path.join(os.homedir(), '.cargo', 'bin', BINARY_NAME));
337
+ if (hit) return hit;
338
+ }
277
339
 
278
340
  // --- PATH lookup (last resort for intentionally installed binaries) ---
279
341
  try {
280
342
  const which = PLATFORM === 'win32' ? 'where' : 'which';
281
343
  const found = execFileSync(which, [BINARY_NAME], { stdio: ['pipe', 'pipe', 'pipe'] })
282
344
  .toString().trim().split('\n')[0];
283
- if (isNativeBinary(found)) return found;
345
+ const hit = gate.consider(found);
346
+ if (hit) return hit;
284
347
  } catch { /* not in PATH */ }
285
348
 
286
349
  // --- npx cache (very last resort — may be outdated) ---
@@ -288,12 +351,15 @@ function findBinaryUncached() {
288
351
  try {
289
352
  for (const entry of fs.readdirSync(npxDir)) {
290
353
  const platDir = path.join(npxDir, entry, 'node_modules', '@sdsrs', `code-graph-${PLATFORM}-${ARCH}`);
291
- const platBin = path.join(platDir, BINARY_NAME);
292
- if (isNativeBinary(platBin)) return platBin;
354
+ const hit = gate.consider(path.join(platDir, BINARY_NAME));
355
+ if (hit) return hit;
293
356
  }
294
357
  } catch { /* no npx cache */ }
295
358
 
296
- return null;
359
+ // Nothing current anywhere: the newest stale candidate (if any) beats null
360
+ // consumers degrade better on a slightly-old binary than on "offline", and
361
+ // auto-update's stale-binary self-heal replaces it shortly.
362
+ return gate.best();
297
363
  }
298
364
 
299
365
  /**
@@ -306,7 +372,7 @@ function clearCache() {
306
372
 
307
373
  module.exports = {
308
374
  findBinary, findBinaryUncached, clearCache,
309
- globalNodeModulesCandidates, findPlatformBinary,
375
+ globalNodeModulesCandidates, findPlatformBinary, createVersionGate,
310
376
  getPackageVersion, compareVersions, isCachedBinaryFresh,
311
377
  detectLibc, unsupportedPlatformHint,
312
378
  CACHE_FILE, BINARY_NAME, PLATFORM_PKG,
@@ -0,0 +1,48 @@
1
+ 'use strict';
2
+ // Inter-process install lock. N concurrently-opened sessions each spawn a
3
+ // launcher / auto-update process; without a lock they ran parallel
4
+ // `npm install -g` against one global prefix (npm's staging dir is not
5
+ // concurrency-safe → EEXIST/ENOTEMPTY tree corruption) and clobbered each
6
+ // other's update-state counters. O_EXCL create is the atomic primitive; a lock
7
+ // whose owner pid is dead or whose file is older than staleMs is reclaimed
8
+ // (crashed installer must not wedge every future session).
9
+ const fs = require('fs');
10
+ const path = require('path');
11
+
12
+ const STALE_MS = 10 * 60 * 1000; // > the longest install step (npm 180s heal timeout)
13
+
14
+ function lockIsStale(lockPath, staleMs) {
15
+ try {
16
+ const age = Date.now() - fs.statSync(lockPath).mtimeMs;
17
+ if (age > staleMs) return true;
18
+ const info = JSON.parse(fs.readFileSync(lockPath, 'utf8'));
19
+ if (!info || !Number.isInteger(info.pid)) return false; // unreadable → trust age only
20
+ try { process.kill(info.pid, 0); return false; } // owner alive
21
+ catch (e) { return e.code !== 'EPERM'; } // EPERM = alive, not ours
22
+ } catch {
23
+ return false; // raced away / unreadable — treat as held; age check re-runs next attempt
24
+ }
25
+ }
26
+
27
+ /**
28
+ * Try to take the lock. Returns `{ release() }` on success, null when another
29
+ * live process holds it. Never throws, never blocks.
30
+ */
31
+ function acquireLock(lockPath, { staleMs = STALE_MS } = {}) {
32
+ try { fs.mkdirSync(path.dirname(lockPath), { recursive: true }); } catch { return null; }
33
+ for (let attempt = 0; attempt < 2; attempt++) {
34
+ try {
35
+ const fd = fs.openSync(lockPath, 'wx');
36
+ fs.writeSync(fd, JSON.stringify({ pid: process.pid, at: new Date().toISOString() }));
37
+ fs.closeSync(fd);
38
+ return { release: () => { try { fs.unlinkSync(lockPath); } catch { /* ok */ } } };
39
+ } catch (e) {
40
+ if (!e || e.code !== 'EEXIST') return null;
41
+ if (!lockIsStale(lockPath, staleMs)) return null;
42
+ try { fs.unlinkSync(lockPath); } catch { /* another reclaimer won — retry loop */ }
43
+ }
44
+ }
45
+ return null;
46
+ }
47
+
48
+ module.exports = { acquireLock, STALE_MS };
@@ -0,0 +1,146 @@
1
+ 'use strict';
2
+ /**
3
+ * Background binary installer for mcp-launcher.js.
4
+ *
5
+ * Replaces the launcher's old SYNCHRONOUS missing-binary chain — `npm install
6
+ * -g` (60s timeout) then the GitHub-release fallback (90s) — which ran BEFORE
7
+ * any MCP JSON-RPC was answered. Claude Code's connect timeout is 30s, so a
8
+ * cold install always presented as "connection timed out after 30000ms" and
9
+ * the tools only appeared on a later reconnect. The launcher now answers the
10
+ * handshake from an upgradeable 0-tool stub immediately and runs this chain in
11
+ * the background; `onInstalled` fires as soon as a step yields a resolvable
12
+ * binary so the caller can hand the live connection over to it.
13
+ *
14
+ * Steps (same order + timeouts as the old sync chain):
15
+ * 1. npm install -g @sdsrs/code-graph@<version> — the normal package path
16
+ * 2. auto-update.js --silent --install-missing — direct GitHub release
17
+ * download, for when npm succeeds but the platform optionalDependency
18
+ * fails silently (OS-mismatch tolerance, flaky registry — issue #12)
19
+ *
20
+ * `spawnFn` is injectable so the chain is unit-testable without touching npm
21
+ * or the network (launcher-install.test.js).
22
+ */
23
+ const { spawn } = require('child_process');
24
+ const path = require('path');
25
+ const { NPM_NEEDS_SHELL } = require('./npm-exec');
26
+ const { acquireLock } = require('./install-lock');
27
+
28
+ const NPM_TIMEOUT_MS = 60000;
29
+ const GITHUB_TIMEOUT_MS = 90000;
30
+
31
+ /**
32
+ * Run one install step, capture its stderr, and invoke `cb` exactly once when
33
+ * the step is over — whether it exited, timed out (spawn's `timeout` option
34
+ * SIGTERMs and still emits 'exit'), or failed to start at all ('error' without
35
+ * 'exit', e.g. npm missing from PATH). Never throws: a failed step just means
36
+ * the chain moves on. `cb` receives the step's exit code (null when it never
37
+ * exited cleanly); `spawnOpts` merges extras into the spawn options (shell for
38
+ * npm on Windows, env for the auto-update child).
39
+ */
40
+ function runStep(cmd, args, timeoutMs, prefix, spawnFn, cb, spawnOpts = {}) {
41
+ let settled = false;
42
+ let exitCode = null;
43
+ const done = () => {
44
+ if (settled) return;
45
+ settled = true;
46
+ cb(exitCode);
47
+ };
48
+
49
+ let child;
50
+ try {
51
+ child = spawnFn(cmd, args, {
52
+ timeout: timeoutMs,
53
+ stdio: ['ignore', 'ignore', 'pipe'],
54
+ ...spawnOpts,
55
+ });
56
+ } catch (e) {
57
+ process.stderr.write(`[code-graph] install step ${cmd} failed to start: ${e.message}\n`);
58
+ done();
59
+ return;
60
+ }
61
+
62
+ let stderr = '';
63
+ if (child.stderr) child.stderr.on('data', (d) => { stderr += d.toString(); });
64
+ child.on('error', (err) => {
65
+ process.stderr.write(`[code-graph] install step ${cmd} failed to start: ${err.message}\n`);
66
+ done();
67
+ });
68
+ child.on('exit', (code) => {
69
+ exitCode = code;
70
+ if (stderr.trim()) {
71
+ process.stderr.write(stderr.trim().split('\n').map((l) => `${prefix} ${l}\n`).join(''));
72
+ }
73
+ done();
74
+ });
75
+ }
76
+
77
+ /**
78
+ * Kick off the background install chain. Fire-and-forget: exactly one of
79
+ * `onInstalled` / `onFailed` is eventually called — EXCEPT when `lockPath` is
80
+ * given and another live session already holds the lock, in which case the
81
+ * chain is skipped entirely (neither callback fires; that session's install
82
+ * lands and our stub's poller picks the binary up).
83
+ *
84
+ * - findBinary / clearCache: injected from find-binary.js (clear the disk
85
+ * cache before each re-resolve so a pre-install negative result can't mask a
86
+ * freshly landed binary).
87
+ * - onInstalled: a step produced a resolvable binary — attempt the stub→real
88
+ * handover now instead of waiting for the stub's next 4s poll.
89
+ * - onFailed: both steps ran and no binary resolved — surface manual hints.
90
+ * - recordGlobalInstall: called when the npm step itself exited 0 AND yielded a
91
+ * binary — i.e. the plugin (not the user) introduced the global packages.
92
+ * lifecycle.js uninstall uses that marker to know it owns their removal.
93
+ * - lockPath: opt-in inter-process lock — N cold sessions otherwise run
94
+ * parallel `npm install -g` against one global prefix (npm staging is not
95
+ * concurrency-safe).
96
+ */
97
+ function installBinaryInBackground({
98
+ version,
99
+ findBinary,
100
+ clearCache,
101
+ onInstalled,
102
+ onFailed,
103
+ spawnFn = spawn,
104
+ autoUpdateScript = path.join(__dirname, 'auto-update.js'),
105
+ npmTimeoutMs = NPM_TIMEOUT_MS,
106
+ githubTimeoutMs = GITHUB_TIMEOUT_MS,
107
+ recordGlobalInstall = null,
108
+ lockPath = null,
109
+ }) {
110
+ let lock = null;
111
+ if (lockPath) {
112
+ lock = acquireLock(lockPath);
113
+ if (!lock) {
114
+ process.stderr.write('[code-graph] another session is already installing the binary; this stub will pick it up when it lands\n');
115
+ return;
116
+ }
117
+ }
118
+ const finish = (fn) => {
119
+ if (lock) { lock.release(); lock = null; }
120
+ fn();
121
+ };
122
+ const resolved = () => {
123
+ clearCache();
124
+ return findBinary();
125
+ };
126
+
127
+ runStep('npm', ['install', '-g', `@sdsrs/code-graph@${version}`], npmTimeoutMs, '[code-graph][npm]', spawnFn, (npmExit) => {
128
+ if (resolved()) {
129
+ if (npmExit === 0 && recordGlobalInstall) {
130
+ try { recordGlobalInstall(); } catch { /* marker is best-effort */ }
131
+ }
132
+ finish(onInstalled);
133
+ return;
134
+ }
135
+ process.stderr.write('[code-graph] npm install did not yield a binary; falling back to GitHub release download...\n');
136
+ runStep(process.execPath, [autoUpdateScript, '--silent', '--install-missing'], githubTimeoutMs, '[code-graph][auto-update]', spawnFn, () => {
137
+ if (resolved()) { finish(onInstalled); return; }
138
+ finish(onFailed);
139
+ }, {
140
+ // The child would otherwise try to take the same install lock we hold.
141
+ env: { ...process.env, CODE_GRAPH_INSTALL_LOCK_HELD: '1' },
142
+ });
143
+ }, NPM_NEEDS_SHELL ? { shell: true } : {});
144
+ }
145
+
146
+ module.exports = { installBinaryInBackground, runStep, NPM_TIMEOUT_MS, GITHUB_TIMEOUT_MS };
@@ -18,6 +18,13 @@ const CACHE_DIR = path.join(os.homedir(), '.cache', 'code-graph');
18
18
  const PLUGIN_ROOT = path.resolve(__dirname, '..');
19
19
  const MANIFEST_FILE = path.join(CACHE_DIR, 'install-manifest.json');
20
20
  const REGISTRY_FILE = path.join(CACHE_DIR, 'statusline-registry.json');
21
+ // Written by the launcher's background install when ITS `npm install -g` step
22
+ // introduced the global shell + platform packages. Uninstall only removes
23
+ // global packages it can prove the plugin installed (marker present) or when
24
+ // the user passes --purge-global — a deliberate user install is never yanked.
25
+ const GLOBAL_INSTALL_MARKER = path.join(CACHE_DIR, 'global-install-marker.json');
26
+ const INSTALL_LOCK_FILE = path.join(CACHE_DIR, 'install.lock');
27
+ const SHELL_PKG = '@sdsrs/code-graph';
21
28
 
22
29
  // Lazy resolvers — Claude Code's config dir can be overridden by CLAUDE_CONFIG_DIR
23
30
  // (multi-account isolation). Re-read every call so test subprocesses with a
@@ -218,13 +225,25 @@ function cleanupDisabledStatusline() {
218
225
  return { cleaned: false, settingsChanged: false };
219
226
  }
220
227
 
228
+ // Decide BEFORE mutating: isPluginUninstalled reads the same composite/
229
+ // registry markers detachStatuslineIntegration is about to remove.
230
+ const uninstalled = isPluginUninstalled(settings);
231
+
221
232
  let settingsChanged = detachStatuslineIntegration(settings);
222
233
  if (removeHooksFromSettings(settings)) settingsChanged = true;
223
234
  if (settingsChanged) {
224
235
  writeJsonAtomic(settingsPath(), settings);
225
236
  }
226
237
 
227
- return { cleaned: true, settingsChanged };
238
+ // Genuine uninstall (not a temporary disable): reclaim ~/.cache/code-graph
239
+ // too. This statusline-render path is the ONLY plugin code guaranteed to
240
+ // still run after `/plugin uninstall` — Claude Code stops loading the
241
+ // plugin's hooks.json, so the SessionStart teardown in session-init.js never
242
+ // fires post-uninstall. Without this, the ~40MB cached binary leaked forever.
243
+ let cacheRemoved = false;
244
+ if (uninstalled) cacheRemoved = removeCacheResidue();
245
+
246
+ return { cleaned: true, settingsChanged, cacheRemoved };
228
247
  }
229
248
 
230
249
  // --- Scope Conflict Detection ---
@@ -648,7 +667,30 @@ function install() {
648
667
 
649
668
  // --- Uninstall (clean all config) ---
650
669
 
651
- function uninstall() {
670
+ /** Which of our npm packages exist at a global top level right now. */
671
+ function installedGlobalPkgs() {
672
+ const { globalNodeModulesCandidates, PLATFORM_PKG } = require('./find-binary');
673
+ const found = [];
674
+ for (const name of [SHELL_PKG, PLATFORM_PKG]) {
675
+ for (const root of globalNodeModulesCandidates()) {
676
+ if (fs.existsSync(path.join(root, name, 'package.json'))) { found.push(name); break; }
677
+ }
678
+ }
679
+ return found;
680
+ }
681
+
682
+ function defaultRunNpm(args) {
683
+ const { spawnSync } = require('child_process');
684
+ const { npmSpawnOpts } = require('./npm-exec');
685
+ try {
686
+ const r = spawnSync('npm', args, npmSpawnOpts({
687
+ timeout: 120000, stdio: 'pipe', encoding: 'utf8',
688
+ }));
689
+ return !r.error && r.status === 0;
690
+ } catch { return false; }
691
+ }
692
+
693
+ function uninstall({ purgeGlobal = false, runNpm = defaultRunNpm, scanGlobalPkgs = installedGlobalPkgs } = {}) {
652
694
  const settings = readJson(settingsPath());
653
695
  let settingsChanged = false;
654
696
 
@@ -692,6 +734,23 @@ function uninstall() {
692
734
  if (ipChanged) writeJsonAtomic(installedPluginsPath(), installedPlugins);
693
735
  }
694
736
 
737
+ // 5.5. Global npm packages + adoption inventory — read BEFORE step 6 wipes
738
+ // CACHE_DIR (both the install marker and the adopted-projects registry live
739
+ // there). The launcher's background install runs `npm install -g` on the
740
+ // user's behalf; nothing on the Claude Code uninstall path ever removes those
741
+ // packages (~40MB platform binary + CLI shim left on PATH forever).
742
+ const pluginInstalledGlobals = !!readJson(GLOBAL_INSTALL_MARKER);
743
+ let adoptedProjects = [];
744
+ try { adoptedProjects = require('./adopt').readAdoptedProjects(); } catch { /* POSIX-only helper — ok */ }
745
+ let globalPkgsRemoved = [];
746
+ let globalPkgsRemaining = scanGlobalPkgs();
747
+ if (globalPkgsRemaining.length && (pluginInstalledGlobals || purgeGlobal)) {
748
+ if (runNpm(['uninstall', '-g', ...globalPkgsRemaining])) {
749
+ globalPkgsRemoved = globalPkgsRemaining;
750
+ globalPkgsRemaining = scanGlobalPkgs(); // re-scan: report only what actually survived
751
+ }
752
+ }
753
+
695
754
  // 6. Remove cache directory
696
755
  try { fs.rmSync(CACHE_DIR, { recursive: true, force: true }); } catch { /* ok */ }
697
756
 
@@ -706,7 +765,7 @@ function uninstall() {
706
765
  try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* ok */ }
707
766
  }
708
767
 
709
- return { settingsChanged };
768
+ return { settingsChanged, pluginInstalledGlobals, globalPkgsRemoved, globalPkgsRemaining, adoptedProjects };
710
769
  }
711
770
 
712
771
  // --- Update (refresh config points) ---
@@ -952,6 +1011,7 @@ module.exports = {
952
1011
  SETTINGS_HOOK_DESC, OUR_HOOK_SCRIPTS, OUR_DESCRIPTIONS, // v0.32.0 — for tests
953
1012
  PLUGIN_ROOT, // v0.32.1 — for tests / consumers
954
1013
  registerStatuslineProvider, unregisterStatuslineProvider,
1014
+ installedGlobalPkgs, GLOBAL_INSTALL_MARKER, INSTALL_LOCK_FILE, SHELL_PKG, // uninstall residue
955
1015
  PLUGIN_ID, OLD_PLUGIN_IDS, MARKETPLACE_NAME, CACHE_DIR, REGISTRY_FILE,
956
1016
  settingsPath, installedPluginsPath, providersBackupFile, pluginsCacheDir,
957
1017
  };
@@ -963,10 +1023,24 @@ if (require.main === module) {
963
1023
  const r = install();
964
1024
  console.log(`Installed v${r.version} | settings=${r.settingsChanged} | statusLine=${r.statusLineClaimed}`);
965
1025
  } else if (cmd === 'uninstall') {
966
- const r = uninstall();
1026
+ const r = uninstall({ purgeGlobal: process.argv.includes('--purge-global') });
967
1027
  console.log(`Uninstalled | settings cleaned=${r.settingsChanged}`);
968
- console.log(' Note: also run `/plugin uninstall code-graph-mcp` inside Claude Code to sync its UI state,');
969
- console.log(' and `code-graph-mcp unadopt` in each adopted project to remove its CLAUDE.md block.');
1028
+ if (r.globalPkgsRemoved.length) {
1029
+ console.log(` Removed global npm package(s): ${r.globalPkgsRemoved.join(', ')}`);
1030
+ }
1031
+ if (r.globalPkgsRemaining.length) {
1032
+ console.log(` Global npm package(s) still installed: ${r.globalPkgsRemaining.join(', ')}`);
1033
+ console.log(` Remove with: npm uninstall -g ${r.globalPkgsRemaining.join(' ')}`);
1034
+ if (!r.pluginInstalledGlobals) {
1035
+ console.log(' (left in place: no plugin-install marker, so they may be your own install; --purge-global forces removal)');
1036
+ }
1037
+ }
1038
+ if (r.adoptedProjects.length) {
1039
+ console.log(' Adopted project(s) still carrying a managed CLAUDE.md block + .code-graph/ index:');
1040
+ for (const p of r.adoptedProjects) console.log(` ${p}`);
1041
+ console.log(' In each: run `code-graph-mcp unadopt` and `rm -rf .code-graph` to clean up.');
1042
+ }
1043
+ console.log(' Note: also run `/plugin uninstall code-graph-mcp` inside Claude Code to sync its UI state.');
970
1044
  } else if (cmd === 'update') {
971
1045
  const r = update();
972
1046
  console.log(`Updated ${r.oldVersion} → ${r.version} | settings=${r.settingsChanged}`);