@sdsrs/code-graph 0.101.0 → 0.103.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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 sdsrss
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/bin/cli.js CHANGED
@@ -45,24 +45,47 @@ if (sub === "uninstall") {
45
45
  if (process.argv.slice(3).some((a) => a === "--help" || a === "-h")) {
46
46
  process.stdout.write(
47
47
  "code-graph-mcp uninstall — remove code-graph config + cache from this machine\n\n" +
48
- "USAGE:\n code-graph-mcp uninstall\n\n" +
48
+ "USAGE:\n code-graph-mcp uninstall [--unadopt-all] [--purge-global]\n\n" +
49
49
  "Restores your prior statusline, strips code-graph hooks from settings.json,\n" +
50
50
  "deletes ~/.cache/code-graph, and removes this project's CLAUDE.md adoption\n" +
51
- "block. Also run `/plugin uninstall code-graph-mcp` in Claude Code to sync its\n" +
52
- "UI, and `code-graph-mcp unadopt` in any OTHER adopted project.\n");
51
+ "block. --unadopt-all also removes the managed block + detail file from every\n" +
52
+ "registered adopted project; --purge-global removes the globally-installed\n" +
53
+ "@sdsrs npm packages even without the plugin-install marker. Also run\n" +
54
+ "`/plugin uninstall code-graph-mcp` in Claude Code to sync its UI.\n");
53
55
  process.exit(0);
54
56
  }
55
57
  const lifecycle = require("../claude-plugin/scripts/lifecycle");
56
58
  const { unadopt } = require("../claude-plugin/scripts/adopt");
57
- const r = lifecycle.uninstall();
59
+ const r = lifecycle.uninstall({
60
+ purgeGlobal: process.argv.slice(3).includes("--purge-global"),
61
+ unadoptAll: process.argv.slice(3).includes("--unadopt-all"),
62
+ });
58
63
  let ua = { ok: false };
59
64
  try { ua = unadopt(); } catch { /* best-effort — settings/cache already cleaned */ }
60
65
  const projectUnadopted = !!(ua && (ua.blockPruned || ua.fileRemoved || ua.claudeMdRemoved));
61
- process.stdout.write(
66
+ let out =
62
67
  `Uninstalled code-graph-mcp | settings cleaned=${r.settingsChanged}` +
63
- ` | this project unadopted=${projectUnadopted}\n` +
64
- " Also run `/plugin uninstall code-graph-mcp` in Claude Code, and\n" +
65
- " `code-graph-mcp unadopt` in any other adopted project.\n");
68
+ ` | this project unadopted=${projectUnadopted}\n`;
69
+ if (r.globalPkgsRemoved.length) {
70
+ out += ` Removed global npm package(s): ${r.globalPkgsRemoved.join(", ")}\n`;
71
+ }
72
+ if (r.globalPkgsRemaining.length) {
73
+ out += ` Global npm package(s) still installed: ${r.globalPkgsRemaining.join(", ")}\n` +
74
+ ` Remove with: npm uninstall -g ${r.globalPkgsRemaining.join(" ")}` +
75
+ (r.pluginInstalledGlobals ? "\n" : " (or re-run with --purge-global)\n");
76
+ }
77
+ if (r.unadopted.length) {
78
+ const cleaned = r.unadopted.filter((u) => u.cleaned).length;
79
+ out += ` Unadopted ${cleaned}/${r.unadopted.length} registered project(s) (--unadopt-all).\n`;
80
+ }
81
+ const otherAdopted = r.adoptedProjects.filter((p) => p !== process.cwd());
82
+ if (otherAdopted.length) {
83
+ out += " Other adopted project(s) — re-run with --unadopt-all, or in each:" +
84
+ " `code-graph-mcp unadopt` + `rm -rf .code-graph`\n" +
85
+ otherAdopted.map((p) => ` ${p}\n`).join("");
86
+ }
87
+ out += " Also run `/plugin uninstall code-graph-mcp` in Claude Code to sync its UI state.\n";
88
+ process.stdout.write(out);
66
89
  process.exit(0);
67
90
  }
68
91
 
@@ -4,7 +4,7 @@
4
4
  "author": {
5
5
  "name": "sdsrs"
6
6
  },
7
- "version": "0.101.0",
7
+ "version": "0.103.0",
8
8
  "keywords": [
9
9
  "code-graph",
10
10
  "ast",
@@ -339,7 +339,46 @@ function platformGuard() {
339
339
  // now lives in project-detect.js — the single activation gate shared with
340
340
  // mcp-launcher.js and session-init.js. Imported above and re-exported below.
341
341
 
342
- function adopt({ cwd, templatePath } = {}) {
342
+ // ── Adopted-projects registry ───────────────────────────────
343
+ // ~/.cache/code-graph/adopted-projects.json — every project adopt() has touched.
344
+ // Sole consumer is lifecycle.js uninstall(): without this list it cannot tell
345
+ // the user WHICH projects still carry a managed CLAUDE.md block + .code-graph/
346
+ // index dir (adoption state is otherwise only discoverable per-project).
347
+ // Best-effort: registry loss only degrades uninstall guidance, never adoption.
348
+
349
+ function adoptedRegistryFile(home) {
350
+ return path.join(home || os.homedir(), '.cache', 'code-graph', 'adopted-projects.json');
351
+ }
352
+
353
+ function readAdoptedProjects(home) {
354
+ try {
355
+ const list = JSON.parse(fs.readFileSync(adoptedRegistryFile(home), 'utf8'));
356
+ return Array.isArray(list) ? list.filter((p) => typeof p === 'string') : [];
357
+ } catch { return []; }
358
+ }
359
+
360
+ function recordAdopted(projectDir, home) {
361
+ try {
362
+ const file = adoptedRegistryFile(home);
363
+ const list = readAdoptedProjects(home);
364
+ const abs = path.resolve(projectDir);
365
+ if (list.includes(abs)) return;
366
+ fs.mkdirSync(path.dirname(file), { recursive: true });
367
+ writeFileAtomic(file, JSON.stringify([...list, abs], null, 2) + '\n');
368
+ } catch { /* best-effort */ }
369
+ }
370
+
371
+ function removeAdopted(projectDir, home) {
372
+ try {
373
+ const list = readAdoptedProjects(home);
374
+ const abs = path.resolve(projectDir);
375
+ const next = list.filter((p) => p !== abs);
376
+ if (next.length === list.length) return;
377
+ writeFileAtomic(adoptedRegistryFile(home), JSON.stringify(next, null, 2) + '\n');
378
+ } catch { /* best-effort */ }
379
+ }
380
+
381
+ function adopt({ cwd, templatePath, home } = {}) {
343
382
  const blocked = platformGuard();
344
383
  if (blocked) return blocked;
345
384
 
@@ -376,6 +415,7 @@ function adopt({ cwd, templatePath } = {}) {
376
415
  const exists = fs.existsSync(cPath);
377
416
  const current = exists ? fs.readFileSync(cPath, 'utf8') : '';
378
417
  if (current.includes(block)) {
418
+ recordAdopted(effectiveCwd, home);
379
419
  return { ok: true, detailPath: dPath, claudeMdPath: cPath, detailWritten, claudeMdWritten: false, created: false, healed: false };
380
420
  }
381
421
  const cleaned = exists ? stripSentinelBlock(current) : '';
@@ -383,6 +423,7 @@ function adopt({ cwd, templatePath } = {}) {
383
423
  const base = cleaned.replace(/\n+$/, '');
384
424
  const prefix = base ? base + '\n\n' : '';
385
425
  writeFileAtomic(cPath, prefix + block + '\n');
426
+ recordAdopted(effectiveCwd, home);
386
427
  return { ok: true, detailPath: dPath, claudeMdPath: cPath, detailWritten, claudeMdWritten: true, created: !exists, healed };
387
428
  }
388
429
 
@@ -495,12 +536,12 @@ function maybeAutoAdopt({ cwd, home, env, scriptPath } = {}) {
495
536
  // shipped template / 管理块 漂移时重跑 adopt 对齐。
496
537
  // opt-out: CODE_GRAPH_NO_TEMPLATE_REFRESH=1(锁定手动编辑)。
497
538
  if (env.CODE_GRAPH_NO_TEMPLATE_REFRESH !== '1' && needsRefresh({ cwd })) {
498
- const result = adopt({ cwd });
539
+ const result = adopt({ cwd, home });
499
540
  return { attempted: true, reason: 'refreshed', result, migrated };
500
541
  }
501
542
  return { attempted: false, reason: 'already-adopted', migrated };
502
543
  }
503
- const result = adopt({ cwd });
544
+ const result = adopt({ cwd, home });
504
545
  return { attempted: true, reason: 'adopted', result, migrated };
505
546
  }
506
547
 
@@ -544,6 +585,7 @@ function unadopt({ cwd, home } = {}) {
544
585
  // Also sweep any legacy memory-dir remnants (uninstall before auto-migration ran).
545
586
  const migrated = migrateLegacyMemoryDir({ cwd, home });
546
587
 
588
+ removeAdopted(effectiveCwd, home);
547
589
  return { ok: true, fileRemoved, blockPruned, claudeMdRemoved, target: dPath, claudeMdPath: cPath, migrated };
548
590
  }
549
591
 
@@ -603,6 +645,7 @@ if (require.main === module) {
603
645
 
604
646
  module.exports = {
605
647
  adopt, unadopt, memoryDir, formatResult, stripSentinelBlock,
648
+ readAdoptedProjects, recordAdopted, removeAdopted, adoptedRegistryFile,
606
649
  isAdopted, isPluginModeInstall, maybeAutoAdopt, needsRefresh, isProjectRoot,
607
650
  detectProjectType, buildBlock, buildTriggerRows, migrateLegacyMemoryDir,
608
651
  claudeMdPath, detailDir, detailPath,
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  'use strict';
3
- const { execFileSync } = require('child_process');
3
+ const { execFileSync, spawn } = require('child_process');
4
4
  const fs = require('fs');
5
5
  const https = require('https');
6
6
  const http = require('http');
@@ -9,9 +9,11 @@ const path = require('path');
9
9
  const os = require('os');
10
10
  const { CACHE_DIR, PLUGIN_ID, MARKETPLACE_NAME, readManifest, readJson, writeJsonAtomic, installedPluginsPath, pluginsCacheDir } = require('./lifecycle');
11
11
  const { claudeHome } = require('./claude-config');
12
- const { clearCache: clearBinaryCache } = require('./find-binary');
13
- const { readBinaryVersion, isDevMode } = require('./version-utils');
12
+ const { clearCache: clearBinaryCache, globalNodeModulesCandidates, PLATFORM_PKG, detectLibc } = require('./find-binary');
13
+ const { readBinaryVersion, compareVersions, isDevMode } = require('./version-utils');
14
14
  const { cgTmpDir } = require('./tmp-dir');
15
+ const { npmSpawnOpts } = require('./npm-exec');
16
+ const { acquireLock } = require('./install-lock');
15
17
 
16
18
  // ── Environment Checks ────────────────────────────────────
17
19
 
@@ -56,9 +58,13 @@ function isForceMode(argv = process.argv.slice(2)) {
56
58
  }
57
59
 
58
60
  // ── Platform → GitHub release asset name mapping ──────────
59
- function getPlatformAssetName() {
60
- const platform = os.platform();
61
- const arch = os.arch();
61
+ function getPlatformAssetName({ platform = os.platform(), arch = os.arch(), libc = null } = {}) {
62
+ // No musl asset is published: the glibc linux build downloads fine but cannot
63
+ // exec on Alpine, so promoteVerifiedBinary always rejected it and — with the
64
+ // binary still missing — every SessionStart bypassed the throttle and pulled
65
+ // the same futile ~40MB again. Null stops the download path entirely; the
66
+ // launcher surfaces unsupportedPlatformHint (cargo install / glibc image).
67
+ if (platform === 'linux' && (libc || detectLibc()) === 'musl') return null;
62
68
  const key = `${platform}-${arch}`;
63
69
  const map = {
64
70
  'linux-x64': 'code-graph-mcp-linux-x64',
@@ -106,21 +112,9 @@ function shouldCheck(state, { force = false } = {}) {
106
112
  return elapsed >= interval;
107
113
  }
108
114
 
109
- // ── Version Comparison (semver) ────────────────────────────
110
-
111
- // Assumes plain numeric "M.m.p" releases (the project's tag scheme). A pre-release
112
- // tag (e.g. "1.2.4-rc1") is NOT semver-ordered: `Number("4-rc1")` is NaN → coerced
113
- // to 0, dropping that segment's number (so "1.2.4-rc1" wrongly sorts below "1.2.3").
114
- // Revisit with a real semver compare only if the release process adopts pre-releases.
115
- function compareVersions(a, b) {
116
- const pa = a.split('.').map(Number);
117
- const pb = b.split('.').map(Number);
118
- for (let i = 0; i < 3; i++) {
119
- if ((pa[i] || 0) > (pb[i] || 0)) return 1;
120
- if ((pa[i] || 0) < (pb[i] || 0)) return -1;
121
- }
122
- return 0;
123
- }
115
+ // ── Version Comparison ─────────────────────────────────────
116
+ // compareVersions is imported from version-utils.js (single canonical,
117
+ // pre-release-aware implementation) and re-exported below.
124
118
 
125
119
  // ── GitHub API ─────────────────────────────────────────────
126
120
 
@@ -284,7 +278,11 @@ function cachedBinaryPath() {
284
278
  function cachedBinaryNeedsUpdate(latest, { binaryPath = cachedBinaryPath(), readVersion = readBinaryVersion } = {}) {
285
279
  if (!latest || !latest.binaryUrl) return false;
286
280
  if (!fs.existsSync(binaryPath)) return true;
287
- return readVersion(binaryPath) !== latest.version;
281
+ const current = readVersion(binaryPath);
282
+ if (!current) return true; // unreadable/broken binary — let the heal replace it
283
+ // Ordered compare, not string inequality: a binary NEWER than releases/latest
284
+ // (dev build, or the API momentarily lagging a publish) must not be downgraded.
285
+ return compareVersions(current, latest.version) < 0;
288
286
  }
289
287
 
290
288
  /**
@@ -298,7 +296,10 @@ function cachedBinaryNeedsUpdate(latest, { binaryPath = cachedBinaryPath(), read
298
296
  function cachedBinaryStaleVsState(state, { binaryPath = cachedBinaryPath(), readVersion = readBinaryVersion } = {}) {
299
297
  if (!state || !state.latestVersion) return false;
300
298
  if (!fs.existsSync(binaryPath)) return false;
301
- return readVersion(binaryPath) !== state.latestVersion;
299
+ const current = readVersion(binaryPath);
300
+ if (!current) return true; // unreadable/broken — bypass throttle so the heal runs
301
+ // Ordered compare (see cachedBinaryNeedsUpdate): newer-than-state is not stale.
302
+ return compareVersions(current, state.latestVersion) < 0;
302
303
  }
303
304
 
304
305
  /**
@@ -556,7 +557,98 @@ async function selfHealStaleBinary(latest, { needsUpdate = cachedBinaryNeedsUpda
556
557
  return await download(latest);
557
558
  }
558
559
 
560
+ // ── Global npm package self-heal ───────────────────────────
561
+ // The `code-graph-mcp` CLI on the user's PATH is the GLOBAL npm shell package
562
+ // (@sdsrs/code-graph) — a delivery surface entirely outside the marketplace
563
+ // plugin, so /plugin update and the binary self-heal above never touch it. In
564
+ // the field it drifts for months (a 0.46.0 wrapper delegating to a 0.101.0
565
+ // binary) and users were expected to run `npm update -g` by hand — which also
566
+ // breaks on unrelated npm-config quirks (EALLOWGIT). Same story for a platform
567
+ // package installed EXPLICITLY at the global top level (the old launcher's
568
+ // manual-install hint suggested exactly that): that relic was the 0.16.6
569
+ // landmine behind the MCP connect-timeout incident.
570
+ //
571
+ // Heal contract: refresh ONLY what the user already installed globally (never
572
+ // introduce a global install they didn't ask for), one bounded npm run per
573
+ // release target, silent failure (an unhealable npm env must not block or spam).
574
+
575
+ const SHELL_PKG = '@sdsrs/code-graph';
576
+ const GLOBAL_PKG_HEAL_MAX_ATTEMPTS = 3;
577
+ const GLOBAL_PKG_HEAL_TIMEOUT_MS = 180000; // npm resolves + downloads the platform optionalDependency (~40MB)
578
+
579
+ /** Installed version of a top-level GLOBAL npm package, or null when absent. */
580
+ function globalPkgVersion(name, roots = null) {
581
+ for (const root of (roots || globalNodeModulesCandidates())) {
582
+ try {
583
+ const pkg = readJson(path.join(root, name, 'package.json'));
584
+ if (pkg && pkg.version) return pkg.version;
585
+ } catch { /* not installed under this root */ }
586
+ }
587
+ return null;
588
+ }
589
+
590
+ /** Globally-installed packages of ours whose version lags `latestVersion`. */
591
+ function staleGlobalPkgs(latestVersion, roots = null) {
592
+ const out = [];
593
+ for (const name of [SHELL_PKG, PLATFORM_PKG]) {
594
+ const ver = globalPkgVersion(name, roots);
595
+ if (ver && compareVersions(ver, latestVersion) < 0) out.push({ name, version: ver });
596
+ }
597
+ return out;
598
+ }
599
+
600
+ /** One targeted `npm install -g` for the given specs. Resolves true on exit 0. */
601
+ function npmInstallGlobal(specs) {
602
+ return new Promise((resolve) => {
603
+ if (!commandExists('npm')) { resolve(false); return; }
604
+ const child = spawn('npm', ['install', '-g', ...specs], npmSpawnOpts({
605
+ timeout: GLOBAL_PKG_HEAL_TIMEOUT_MS,
606
+ stdio: ['ignore', 'ignore', 'pipe'],
607
+ }));
608
+ let stderr = '';
609
+ child.stderr.on('data', (d) => { stderr += d.toString(); });
610
+ child.on('error', () => resolve(false));
611
+ child.on('exit', (code) => {
612
+ if (code === 0) {
613
+ console.error(`[code-graph] global npm package(s) refreshed: ${specs.join(' ')}`);
614
+ resolve(true);
615
+ } else {
616
+ const tail = stderr.trim().split('\n').slice(-2).join(' | ');
617
+ console.error(`[code-graph] global npm refresh failed (exit ${code}): ${tail}`);
618
+ resolve(false);
619
+ }
620
+ });
621
+ });
622
+ }
623
+
624
+ /**
625
+ * Self-heal globally-installed shell/platform packages to `latest.version`.
626
+ * Returns a state patch (spread into the update-state save): attempts are
627
+ * counted PER target version so a persistently-failing npm env stops being
628
+ * retried after GLOBAL_PKG_HEAL_MAX_ATTEMPTS, and the counter re-arms when the
629
+ * next release moves the target.
630
+ */
631
+ async function selfHealGlobalPkgs(latest, state, {
632
+ readStale = staleGlobalPkgs,
633
+ install = npmInstallGlobal,
634
+ } = {}) {
635
+ if (!latest || !latest.version) return {};
636
+ const stale = readStale(latest.version);
637
+ if (stale.length === 0) {
638
+ // Healthy (or nothing installed globally) — clear any leftover counter.
639
+ return state.globalPkgHealAttempts ? { globalPkgHealAttempts: 0, globalPkgHealVersion: null } : {};
640
+ }
641
+ const attempts = state.globalPkgHealVersion === latest.version ? (state.globalPkgHealAttempts || 0) : 0;
642
+ if (attempts >= GLOBAL_PKG_HEAL_MAX_ATTEMPTS) return {};
643
+ const ok = await install(stale.map((s) => `${s.name}@${latest.version}`));
644
+ return {
645
+ globalPkgHealVersion: latest.version,
646
+ globalPkgHealAttempts: ok ? 0 : attempts + 1,
647
+ };
648
+ }
649
+
559
650
  async function checkForUpdate({ installMissing = false, force = false } = {}) {
651
+ let installLock = null;
560
652
  try {
561
653
  // Skip in dev mode — unless the launcher explicitly requested a missing-
562
654
  // binary install, in which case we MUST proceed regardless of mode (the
@@ -596,6 +688,19 @@ async function checkForUpdate({ installMissing = false, force = false } = {}) {
596
688
  // Compare versions
597
689
  const hasUpdate = compareVersions(latest.version, installedVersion) > 0;
598
690
 
691
+ // Inter-process gate for every mutating path below (plugin-cache copy,
692
+ // binary download, global npm heals): concurrent sessions racing here ran
693
+ // parallel `npm install -g` against one global prefix and clobbered each
694
+ // other's state-file counters (rateLimited, heal attempts). Skip-if-held:
695
+ // the holder does the work and its state outcome wins. The launcher's
696
+ // install chain already holds this lock across its spawn of this script —
697
+ // it marks that with CODE_GRAPH_INSTALL_LOCK_HELD so we don't deadlock
698
+ // against our own parent.
699
+ if (process.env.CODE_GRAPH_INSTALL_LOCK_HELD !== '1') {
700
+ installLock = acquireLock(path.join(CACHE_DIR, 'install.lock'));
701
+ if (!installLock) return null;
702
+ }
703
+
599
704
  if (hasUpdate) {
600
705
  const result = await downloadAndInstall(latest);
601
706
  const success = result.pluginUpdated;
@@ -615,7 +720,10 @@ async function checkForUpdate({ installMissing = false, force = false } = {}) {
615
720
  binaryUpdated: result.binaryUpdated,
616
721
  marketplaceRefreshed: result.marketplaceRefreshed,
617
722
  };
618
- saveState(newState);
723
+ // Keep any globally-installed shell/platform npm packages in step with
724
+ // the release the plugin just moved to (see selfHealGlobalPkgs).
725
+ const globalHeal = await selfHealGlobalPkgs(latest, state);
726
+ saveState({ ...newState, ...globalHeal });
619
727
 
620
728
  return {
621
729
  updateAvailable: !success,
@@ -632,6 +740,12 @@ async function checkForUpdate({ installMissing = false, force = false } = {}) {
632
740
  // failure observed in the field (shell at v0.45, binary pinned at v0.16.6).
633
741
  const selfHealedBinary = await selfHealStaleBinary(latest);
634
742
 
743
+ // Same for the GLOBAL npm delivery surface (the `code-graph-mcp` CLI on
744
+ // PATH + any explicitly-installed platform package): nothing else ever
745
+ // updates it, and stale copies drift for months (0.46.0 wrapper) or years
746
+ // (the 0.16.6 platform relic).
747
+ const globalHeal = await selfHealGlobalPkgs(latest, state);
748
+
635
749
  saveState({
636
750
  ...state,
637
751
  installedVersion,
@@ -640,6 +754,7 @@ async function checkForUpdate({ installMissing = false, force = false } = {}) {
640
754
  updateAvailable: false,
641
755
  rateLimited: false,
642
756
  binaryUpdated: selfHealedBinary || state.binaryUpdated,
757
+ ...globalHeal,
643
758
  });
644
759
  return selfHealedBinary
645
760
  ? { updated: false, binaryUpdated: true, from: installedVersion, to: installedVersion }
@@ -647,6 +762,8 @@ async function checkForUpdate({ installMissing = false, force = false } = {}) {
647
762
  } catch {
648
763
  // Silent failure — never block session
649
764
  return null;
765
+ } finally {
766
+ if (installLock) installLock.release();
650
767
  }
651
768
  }
652
769
 
@@ -656,7 +773,9 @@ module.exports = {
656
773
  isSilentMode, isInstallMissingMode, isForceMode,
657
774
  requestJson, resolveProxy, parseLatestRelease, fetchLatestRelease,
658
775
  downloadBinary, cachedBinaryPath, cachedBinaryNeedsUpdate, cachedBinaryStaleVsState,
776
+ getPlatformAssetName,
659
777
  selfHealStaleBinary,
778
+ selfHealGlobalPkgs, staleGlobalPkgs, globalPkgVersion, npmInstallGlobal,
660
779
  downloadAndInstall, refreshMarketplaceClone, marketplaceCloneDir,
661
780
  };
662
781
 
@@ -8,6 +8,7 @@ const { readBinaryVersion, isDevMode, getNewestMtime } = require('./version-util
8
8
  const {
9
9
  getPluginVersion, readJson, healthCheck, CACHE_DIR,
10
10
  settingsPath, surveyHookCoverage,
11
+ installedGlobalPkgs, GLOBAL_INSTALL_MARKER, SHELL_PKG,
11
12
  } = require('./lifecycle');
12
13
  const { findBinary, clearCache: clearBinaryCache } = require('./find-binary');
13
14
 
@@ -278,6 +279,37 @@ function runDiagnostics() {
278
279
  }
279
280
  } catch { /* probe failed — skip */ }
280
281
 
282
+ // 9. Global npm residue — the launcher's background install (or the user)
283
+ // may have `npm install -g`'d the shell + platform packages. Surface what
284
+ // exists and who owns cleanup: with the plugin-install marker,
285
+ // `lifecycle.js uninstall` removes them; without it they are treated as
286
+ // user-installed and a plugin uninstall leaves them on PATH.
287
+ try {
288
+ const { globalPkgVersion } = require('./auto-update');
289
+ const { PLATFORM_PKG } = require('./find-binary');
290
+ const found = [SHELL_PKG, PLATFORM_PKG]
291
+ .map((name) => ({ name, version: globalPkgVersion(name) }))
292
+ .filter((p) => p.version);
293
+ if (found.length) {
294
+ const marker = !!readJson(GLOBAL_INSTALL_MARKER);
295
+ // Heal-exhausted is otherwise invisible: selfHealGlobalPkgs stops after
296
+ // 3 failed npm runs per target version and stays silent until the next
297
+ // release re-arms the counter — a drifted CLI shim just sits there.
298
+ const state = readJson(path.join(CACHE_DIR, 'update-state.json')) || {};
299
+ const healGaveUp = (state.globalPkgHealAttempts || 0) >= 3;
300
+ results.push({
301
+ name: 'Global npm packages',
302
+ status: healGaveUp ? 'warn' : 'ok',
303
+ detail: found.map((p) => `${p.name}@${p.version}`).join(', ') + (healGaveUp
304
+ ? ` — self-heal gave up after ${state.globalPkgHealAttempts} failed npm runs targeting v${state.globalPkgHealVersion}; ` +
305
+ `your npm env likely can't install globally (EACCES/system node). Run manually: npm install -g ${found.map((p) => `${p.name}@${state.globalPkgHealVersion}`).join(' ')}`
306
+ : (marker
307
+ ? ' — plugin-installed; `node lifecycle.js uninstall` removes them'
308
+ : ` — no plugin-install marker; uninstall leaves them (remove: npm uninstall -g ${found.map((p) => p.name).join(' ')})`)),
309
+ });
310
+ }
311
+ } catch { /* probe failed — skip */ }
312
+
281
313
  return results;
282
314
  }
283
315