@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 +21 -0
- package/bin/cli.js +31 -8
- package/claude-plugin/.claude-plugin/plugin.json +1 -1
- package/claude-plugin/scripts/adopt.js +46 -3
- package/claude-plugin/scripts/auto-update.js +143 -24
- package/claude-plugin/scripts/doctor.js +32 -0
- package/claude-plugin/scripts/find-binary.js +114 -53
- package/claude-plugin/scripts/install-lock.js +48 -0
- package/claude-plugin/scripts/launcher-install.js +146 -0
- package/claude-plugin/scripts/lifecycle.js +189 -28
- package/claude-plugin/scripts/mcp-launcher.js +75 -62
- package/claude-plugin/scripts/mcp-stub.js +25 -3
- package/claude-plugin/scripts/npm-exec.js +15 -0
- package/claude-plugin/scripts/session-init.js +15 -6
- package/claude-plugin/scripts/statusline.js +29 -5
- package/claude-plugin/scripts/version-utils.js +42 -3
- package/package.json +6 -6
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.
|
|
52
|
-
"
|
|
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
|
-
|
|
66
|
+
let out =
|
|
62
67
|
`Uninstalled code-graph-mcp | settings cleaned=${r.settingsChanged}` +
|
|
63
|
-
` | this project unadopted=${projectUnadopted}\n
|
|
64
|
-
|
|
65
|
-
|
|
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
|
|
|
@@ -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
|
-
|
|
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
|
-
|
|
61
|
-
|
|
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
|
|
110
|
-
|
|
111
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|