@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 +21 -0
- package/bin/cli.js +18 -5
- package/claude-plugin/.claude-plugin/plugin.json +1 -1
- package/claude-plugin/scripts/adopt.js +46 -3
- package/claude-plugin/scripts/auto-update.js +139 -8
- package/claude-plugin/scripts/doctor.js +24 -0
- package/claude-plugin/scripts/find-binary.js +101 -35
- package/claude-plugin/scripts/install-lock.js +48 -0
- package/claude-plugin/scripts/launcher-install.js +146 -0
- package/claude-plugin/scripts/lifecycle.js +80 -6
- package/claude-plugin/scripts/mcp-launcher.js +71 -62
- package/claude-plugin/scripts/npm-exec.js +15 -0
- package/claude-plugin/scripts/session-init.js +7 -4
- package/claude-plugin/scripts/statusline.js +29 -5
- package/claude-plugin/scripts/version-utils.js +10 -2
- package/package.json +6 -6
|
@@ -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
|
-
/**
|
|
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.
|
|
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
|
|
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))
|
|
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))
|
|
232
|
+
if (isNativeBinary(bin)) out.push(bin);
|
|
215
233
|
}
|
|
216
234
|
|
|
217
|
-
return
|
|
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
|
-
|
|
250
|
-
const
|
|
251
|
-
|
|
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
|
|
260
|
-
|
|
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
|
|
271
|
-
if (
|
|
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
|
-
|
|
276
|
-
|
|
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
|
-
|
|
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
|
|
292
|
-
if (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
969
|
-
|
|
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}`);
|