@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.
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
@@ -54,15 +54,28 @@ if (sub === "uninstall") {
54
54
  }
55
55
  const lifecycle = require("../claude-plugin/scripts/lifecycle");
56
56
  const { unadopt } = require("../claude-plugin/scripts/adopt");
57
- const r = lifecycle.uninstall();
57
+ const r = lifecycle.uninstall({ purgeGlobal: process.argv.slice(3).includes("--purge-global") });
58
58
  let ua = { ok: false };
59
59
  try { ua = unadopt(); } catch { /* best-effort — settings/cache already cleaned */ }
60
60
  const projectUnadopted = !!(ua && (ua.blockPruned || ua.fileRemoved || ua.claudeMdRemoved));
61
- process.stdout.write(
61
+ let out =
62
62
  `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");
63
+ ` | this project unadopted=${projectUnadopted}\n`;
64
+ if (r.globalPkgsRemoved.length) {
65
+ out += ` Removed global npm package(s): ${r.globalPkgsRemoved.join(", ")}\n`;
66
+ }
67
+ if (r.globalPkgsRemaining.length) {
68
+ out += ` Global npm package(s) still installed: ${r.globalPkgsRemaining.join(", ")}\n` +
69
+ ` Remove with: npm uninstall -g ${r.globalPkgsRemaining.join(" ")}` +
70
+ (r.pluginInstalledGlobals ? "\n" : " (or re-run with --purge-global)\n");
71
+ }
72
+ const otherAdopted = r.adoptedProjects.filter((p) => p !== process.cwd());
73
+ if (otherAdopted.length) {
74
+ out += " Other adopted project(s) — run `code-graph-mcp unadopt` + `rm -rf .code-graph` in each:\n" +
75
+ otherAdopted.map((p) => ` ${p}\n`).join("");
76
+ }
77
+ out += " Also run `/plugin uninstall code-graph-mcp` in Claude Code to sync its UI state.\n";
78
+ process.stdout.write(out);
66
79
  process.exit(0);
67
80
  }
68
81
 
@@ -4,7 +4,7 @@
4
4
  "author": {
5
5
  "name": "sdsrs"
6
6
  },
7
- "version": "0.101.0",
7
+ "version": "0.102.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');
12
+ const { clearCache: clearBinaryCache, globalNodeModulesCandidates, PLATFORM_PKG, detectLibc } = require('./find-binary');
13
13
  const { readBinaryVersion, 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',
@@ -284,7 +290,11 @@ function cachedBinaryPath() {
284
290
  function cachedBinaryNeedsUpdate(latest, { binaryPath = cachedBinaryPath(), readVersion = readBinaryVersion } = {}) {
285
291
  if (!latest || !latest.binaryUrl) return false;
286
292
  if (!fs.existsSync(binaryPath)) return true;
287
- return readVersion(binaryPath) !== latest.version;
293
+ const current = readVersion(binaryPath);
294
+ if (!current) return true; // unreadable/broken binary — let the heal replace it
295
+ // Ordered compare, not string inequality: a binary NEWER than releases/latest
296
+ // (dev build, or the API momentarily lagging a publish) must not be downgraded.
297
+ return compareVersions(current, latest.version) < 0;
288
298
  }
289
299
 
290
300
  /**
@@ -298,7 +308,10 @@ function cachedBinaryNeedsUpdate(latest, { binaryPath = cachedBinaryPath(), read
298
308
  function cachedBinaryStaleVsState(state, { binaryPath = cachedBinaryPath(), readVersion = readBinaryVersion } = {}) {
299
309
  if (!state || !state.latestVersion) return false;
300
310
  if (!fs.existsSync(binaryPath)) return false;
301
- return readVersion(binaryPath) !== state.latestVersion;
311
+ const current = readVersion(binaryPath);
312
+ if (!current) return true; // unreadable/broken — bypass throttle so the heal runs
313
+ // Ordered compare (see cachedBinaryNeedsUpdate): newer-than-state is not stale.
314
+ return compareVersions(current, state.latestVersion) < 0;
302
315
  }
303
316
 
304
317
  /**
@@ -556,7 +569,98 @@ async function selfHealStaleBinary(latest, { needsUpdate = cachedBinaryNeedsUpda
556
569
  return await download(latest);
557
570
  }
558
571
 
572
+ // ── Global npm package self-heal ───────────────────────────
573
+ // The `code-graph-mcp` CLI on the user's PATH is the GLOBAL npm shell package
574
+ // (@sdsrs/code-graph) — a delivery surface entirely outside the marketplace
575
+ // plugin, so /plugin update and the binary self-heal above never touch it. In
576
+ // the field it drifts for months (a 0.46.0 wrapper delegating to a 0.101.0
577
+ // binary) and users were expected to run `npm update -g` by hand — which also
578
+ // breaks on unrelated npm-config quirks (EALLOWGIT). Same story for a platform
579
+ // package installed EXPLICITLY at the global top level (the old launcher's
580
+ // manual-install hint suggested exactly that): that relic was the 0.16.6
581
+ // landmine behind the MCP connect-timeout incident.
582
+ //
583
+ // Heal contract: refresh ONLY what the user already installed globally (never
584
+ // introduce a global install they didn't ask for), one bounded npm run per
585
+ // release target, silent failure (an unhealable npm env must not block or spam).
586
+
587
+ const SHELL_PKG = '@sdsrs/code-graph';
588
+ const GLOBAL_PKG_HEAL_MAX_ATTEMPTS = 3;
589
+ const GLOBAL_PKG_HEAL_TIMEOUT_MS = 180000; // npm resolves + downloads the platform optionalDependency (~40MB)
590
+
591
+ /** Installed version of a top-level GLOBAL npm package, or null when absent. */
592
+ function globalPkgVersion(name, roots = null) {
593
+ for (const root of (roots || globalNodeModulesCandidates())) {
594
+ try {
595
+ const pkg = readJson(path.join(root, name, 'package.json'));
596
+ if (pkg && pkg.version) return pkg.version;
597
+ } catch { /* not installed under this root */ }
598
+ }
599
+ return null;
600
+ }
601
+
602
+ /** Globally-installed packages of ours whose version lags `latestVersion`. */
603
+ function staleGlobalPkgs(latestVersion, roots = null) {
604
+ const out = [];
605
+ for (const name of [SHELL_PKG, PLATFORM_PKG]) {
606
+ const ver = globalPkgVersion(name, roots);
607
+ if (ver && compareVersions(ver, latestVersion) < 0) out.push({ name, version: ver });
608
+ }
609
+ return out;
610
+ }
611
+
612
+ /** One targeted `npm install -g` for the given specs. Resolves true on exit 0. */
613
+ function npmInstallGlobal(specs) {
614
+ return new Promise((resolve) => {
615
+ if (!commandExists('npm')) { resolve(false); return; }
616
+ const child = spawn('npm', ['install', '-g', ...specs], npmSpawnOpts({
617
+ timeout: GLOBAL_PKG_HEAL_TIMEOUT_MS,
618
+ stdio: ['ignore', 'ignore', 'pipe'],
619
+ }));
620
+ let stderr = '';
621
+ child.stderr.on('data', (d) => { stderr += d.toString(); });
622
+ child.on('error', () => resolve(false));
623
+ child.on('exit', (code) => {
624
+ if (code === 0) {
625
+ console.error(`[code-graph] global npm package(s) refreshed: ${specs.join(' ')}`);
626
+ resolve(true);
627
+ } else {
628
+ const tail = stderr.trim().split('\n').slice(-2).join(' | ');
629
+ console.error(`[code-graph] global npm refresh failed (exit ${code}): ${tail}`);
630
+ resolve(false);
631
+ }
632
+ });
633
+ });
634
+ }
635
+
636
+ /**
637
+ * Self-heal globally-installed shell/platform packages to `latest.version`.
638
+ * Returns a state patch (spread into the update-state save): attempts are
639
+ * counted PER target version so a persistently-failing npm env stops being
640
+ * retried after GLOBAL_PKG_HEAL_MAX_ATTEMPTS, and the counter re-arms when the
641
+ * next release moves the target.
642
+ */
643
+ async function selfHealGlobalPkgs(latest, state, {
644
+ readStale = staleGlobalPkgs,
645
+ install = npmInstallGlobal,
646
+ } = {}) {
647
+ if (!latest || !latest.version) return {};
648
+ const stale = readStale(latest.version);
649
+ if (stale.length === 0) {
650
+ // Healthy (or nothing installed globally) — clear any leftover counter.
651
+ return state.globalPkgHealAttempts ? { globalPkgHealAttempts: 0, globalPkgHealVersion: null } : {};
652
+ }
653
+ const attempts = state.globalPkgHealVersion === latest.version ? (state.globalPkgHealAttempts || 0) : 0;
654
+ if (attempts >= GLOBAL_PKG_HEAL_MAX_ATTEMPTS) return {};
655
+ const ok = await install(stale.map((s) => `${s.name}@${latest.version}`));
656
+ return {
657
+ globalPkgHealVersion: latest.version,
658
+ globalPkgHealAttempts: ok ? 0 : attempts + 1,
659
+ };
660
+ }
661
+
559
662
  async function checkForUpdate({ installMissing = false, force = false } = {}) {
663
+ let installLock = null;
560
664
  try {
561
665
  // Skip in dev mode — unless the launcher explicitly requested a missing-
562
666
  // binary install, in which case we MUST proceed regardless of mode (the
@@ -596,6 +700,19 @@ async function checkForUpdate({ installMissing = false, force = false } = {}) {
596
700
  // Compare versions
597
701
  const hasUpdate = compareVersions(latest.version, installedVersion) > 0;
598
702
 
703
+ // Inter-process gate for every mutating path below (plugin-cache copy,
704
+ // binary download, global npm heals): concurrent sessions racing here ran
705
+ // parallel `npm install -g` against one global prefix and clobbered each
706
+ // other's state-file counters (rateLimited, heal attempts). Skip-if-held:
707
+ // the holder does the work and its state outcome wins. The launcher's
708
+ // install chain already holds this lock across its spawn of this script —
709
+ // it marks that with CODE_GRAPH_INSTALL_LOCK_HELD so we don't deadlock
710
+ // against our own parent.
711
+ if (process.env.CODE_GRAPH_INSTALL_LOCK_HELD !== '1') {
712
+ installLock = acquireLock(path.join(CACHE_DIR, 'install.lock'));
713
+ if (!installLock) return null;
714
+ }
715
+
599
716
  if (hasUpdate) {
600
717
  const result = await downloadAndInstall(latest);
601
718
  const success = result.pluginUpdated;
@@ -615,7 +732,10 @@ async function checkForUpdate({ installMissing = false, force = false } = {}) {
615
732
  binaryUpdated: result.binaryUpdated,
616
733
  marketplaceRefreshed: result.marketplaceRefreshed,
617
734
  };
618
- saveState(newState);
735
+ // Keep any globally-installed shell/platform npm packages in step with
736
+ // the release the plugin just moved to (see selfHealGlobalPkgs).
737
+ const globalHeal = await selfHealGlobalPkgs(latest, state);
738
+ saveState({ ...newState, ...globalHeal });
619
739
 
620
740
  return {
621
741
  updateAvailable: !success,
@@ -632,6 +752,12 @@ async function checkForUpdate({ installMissing = false, force = false } = {}) {
632
752
  // failure observed in the field (shell at v0.45, binary pinned at v0.16.6).
633
753
  const selfHealedBinary = await selfHealStaleBinary(latest);
634
754
 
755
+ // Same for the GLOBAL npm delivery surface (the `code-graph-mcp` CLI on
756
+ // PATH + any explicitly-installed platform package): nothing else ever
757
+ // updates it, and stale copies drift for months (0.46.0 wrapper) or years
758
+ // (the 0.16.6 platform relic).
759
+ const globalHeal = await selfHealGlobalPkgs(latest, state);
760
+
635
761
  saveState({
636
762
  ...state,
637
763
  installedVersion,
@@ -640,6 +766,7 @@ async function checkForUpdate({ installMissing = false, force = false } = {}) {
640
766
  updateAvailable: false,
641
767
  rateLimited: false,
642
768
  binaryUpdated: selfHealedBinary || state.binaryUpdated,
769
+ ...globalHeal,
643
770
  });
644
771
  return selfHealedBinary
645
772
  ? { updated: false, binaryUpdated: true, from: installedVersion, to: installedVersion }
@@ -647,6 +774,8 @@ async function checkForUpdate({ installMissing = false, force = false } = {}) {
647
774
  } catch {
648
775
  // Silent failure — never block session
649
776
  return null;
777
+ } finally {
778
+ if (installLock) installLock.release();
650
779
  }
651
780
  }
652
781
 
@@ -656,7 +785,9 @@ module.exports = {
656
785
  isSilentMode, isInstallMissingMode, isForceMode,
657
786
  requestJson, resolveProxy, parseLatestRelease, fetchLatestRelease,
658
787
  downloadBinary, cachedBinaryPath, cachedBinaryNeedsUpdate, cachedBinaryStaleVsState,
788
+ getPlatformAssetName,
659
789
  selfHealStaleBinary,
790
+ selfHealGlobalPkgs, staleGlobalPkgs, globalPkgVersion, npmInstallGlobal,
660
791
  downloadAndInstall, refreshMarketplaceClone, marketplaceCloneDir,
661
792
  };
662
793
 
@@ -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,29 @@ 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
+ results.push({
296
+ name: 'Global npm packages',
297
+ status: 'ok',
298
+ detail: found.map((p) => `${p.name}@${p.version}`).join(', ') + (marker
299
+ ? ' — plugin-installed; `node lifecycle.js uninstall` removes them'
300
+ : ` — no plugin-install marker; uninstall leaves them (remove: npm uninstall -g ${found.map((p) => p.name).join(' ')})`),
301
+ });
302
+ }
303
+ } catch { /* probe failed — skip */ }
304
+
281
305
  return results;
282
306
  }
283
307