@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
|
@@ -4,7 +4,8 @@ const { execFileSync } = require('child_process');
|
|
|
4
4
|
const path = require('path');
|
|
5
5
|
const fs = require('fs');
|
|
6
6
|
const os = require('os');
|
|
7
|
-
const { readBinaryVersion } = require('./version-utils');
|
|
7
|
+
const { readBinaryVersion, compareVersions } = require('./version-utils');
|
|
8
|
+
const { npmSpawnOpts } = require('./npm-exec');
|
|
8
9
|
|
|
9
10
|
const PLATFORM = os.platform();
|
|
10
11
|
const ARCH = os.arch();
|
|
@@ -51,29 +52,22 @@ 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
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
* Assumes plain numeric releases (the project's tag scheme); a pre-release tag
|
|
63
|
-
* (e.g. "1.2.3-rc1") is NOT semver-ordered — `parseInt("3-rc1", 10)` keeps the
|
|
64
|
-
* leading 3 and drops the suffix, so "1.2.3-rc1" compares EQUAL to "1.2.3".
|
|
65
|
-
* Revisit only if releases adopt pre-release tags.
|
|
66
|
-
*/
|
|
67
|
-
function compareVersions(a, b) {
|
|
68
|
-
const pa = String(a).split('.').map(s => parseInt(s, 10));
|
|
69
|
-
const pb = String(b).split('.').map(s => parseInt(s, 10));
|
|
70
|
-
for (let i = 0; i < 3; i++) {
|
|
71
|
-
const x = Number.isFinite(pa[i]) ? pa[i] : 0;
|
|
72
|
-
const y = Number.isFinite(pb[i]) ? pb[i] : 0;
|
|
73
|
-
if (x !== y) return x < y ? -1 : 1;
|
|
74
|
-
}
|
|
75
|
-
return 0;
|
|
76
|
-
}
|
|
69
|
+
// compareVersions lives in version-utils.js (single canonical implementation,
|
|
70
|
+
// pre-release-aware); re-exported below for existing consumers.
|
|
77
71
|
|
|
78
72
|
/**
|
|
79
73
|
* Candidate paths for npm global `node_modules`.
|
|
@@ -87,7 +81,19 @@ function globalNodeModulesCandidates() {
|
|
|
87
81
|
const out = [];
|
|
88
82
|
const nodeBinDir = path.dirname(process.execPath);
|
|
89
83
|
|
|
90
|
-
// 1.
|
|
84
|
+
// 1. NPM_CONFIG_PREFIX env override (users with `~/.npm-global` etc.) FIRST:
|
|
85
|
+
// when set, `npm install -g` actually installs THERE, so it is the most
|
|
86
|
+
// authoritative location — matching npm's own prefix-resolution order.
|
|
87
|
+
// (It also used to rank below the execPath derivation, which let a stale
|
|
88
|
+
// relic in the nvm prefix shadow the user's real prefix.)
|
|
89
|
+
const envPrefix = process.env.NPM_CONFIG_PREFIX || process.env.npm_config_prefix;
|
|
90
|
+
if (envPrefix) {
|
|
91
|
+
out.push(PLATFORM === 'win32'
|
|
92
|
+
? path.join(envPrefix, 'node_modules')
|
|
93
|
+
: path.join(envPrefix, 'lib', 'node_modules'));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// 2. Derive from process.execPath. Works for nvm + standard Unix prefixes
|
|
91
97
|
// (`<prefix>/bin/node` → globals at `<prefix>/lib/node_modules`); on
|
|
92
98
|
// Windows globals sit next to `node.exe`.
|
|
93
99
|
if (PLATFORM === 'win32') {
|
|
@@ -96,25 +102,17 @@ function globalNodeModulesCandidates() {
|
|
|
96
102
|
out.push(path.resolve(nodeBinDir, '..', 'lib', 'node_modules'));
|
|
97
103
|
}
|
|
98
104
|
|
|
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
105
|
// 3. Common no-sudo user prefix
|
|
108
106
|
out.push(path.join(os.homedir(), '.npm-global', 'lib', 'node_modules'));
|
|
109
107
|
|
|
110
108
|
// 4. Last resort: ask npm directly. Slow (~50-200ms) but most accurate when
|
|
111
109
|
// user has a non-standard prefix. Cached at the disk-cache layer above.
|
|
112
110
|
try {
|
|
113
|
-
const root = execFileSync('npm', ['root', '-g'], {
|
|
111
|
+
const root = execFileSync('npm', ['root', '-g'], npmSpawnOpts({
|
|
114
112
|
timeout: 2000,
|
|
115
113
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
116
114
|
encoding: 'utf8',
|
|
117
|
-
}).trim();
|
|
115
|
+
})).trim();
|
|
118
116
|
if (root) out.push(root);
|
|
119
117
|
} catch { /* npm not on PATH or timed out */ }
|
|
120
118
|
|
|
@@ -160,6 +158,10 @@ function isCachedBinaryFresh(cachedPath, pkgVersion) {
|
|
|
160
158
|
* auto-update cache → platform npm pkg → bundled (bin/) →
|
|
161
159
|
* cargo install → PATH → npx cache
|
|
162
160
|
*
|
|
161
|
+
* Every tier after dev-mode is version-gated (createVersionGate): the first
|
|
162
|
+
* candidate at/above the pkg version wins; when NONE is current, the newest
|
|
163
|
+
* stale candidate is returned rather than null.
|
|
164
|
+
*
|
|
163
165
|
* Returns the absolute path or null if not found.
|
|
164
166
|
*/
|
|
165
167
|
function findBinary() {
|
|
@@ -200,21 +202,67 @@ function isDevRepo(rootDir) {
|
|
|
200
202
|
* nvm/standard setups), so a working `npm install -g @sdsrs/code-graph` can
|
|
201
203
|
* still be invisible without the fallback.
|
|
202
204
|
*/
|
|
203
|
-
|
|
205
|
+
// Truncation gate for the npm platform-package tier ONLY: an interrupted npm
|
|
206
|
+
// install can leave a partial binary with the right name, and unlike the
|
|
207
|
+
// GitHub-download path (size + sha256 sidecar + version-exec before promote)
|
|
208
|
+
// nothing else checks this tier. Real release binaries are ~40MB; 1MB matches
|
|
209
|
+
// promoteVerifiedBinary's floor. Deliberately NOT inside isNativeBinary —
|
|
210
|
+
// dev builds, cargo installs, and test fixtures go through other tiers.
|
|
211
|
+
function isPlausibleReleaseBinary(candidate) {
|
|
212
|
+
try { return fs.statSync(candidate).size > 1_000_000; } catch { return false; }
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function platformBinaryCandidates() {
|
|
216
|
+
const out = [];
|
|
204
217
|
// Fast path: standard module resolution.
|
|
205
218
|
try {
|
|
206
219
|
const pkgPath = require.resolve(`${PLATFORM_PKG}/package.json`);
|
|
207
220
|
const bin = path.join(path.dirname(pkgPath), BINARY_NAME);
|
|
208
|
-
if (isNativeBinary(bin)
|
|
221
|
+
if (isNativeBinary(bin) && isPlausibleReleaseBinary(bin)) out.push(bin);
|
|
209
222
|
} catch { /* not in node_modules walk-up */ }
|
|
210
223
|
|
|
211
224
|
// Slow path: explicit global node_modules probe.
|
|
212
225
|
for (const globalRoot of globalNodeModulesCandidates()) {
|
|
213
226
|
const bin = path.join(globalRoot, '@sdsrs', `code-graph-${PLATFORM}-${ARCH}`, BINARY_NAME);
|
|
214
|
-
if (isNativeBinary(bin)
|
|
227
|
+
if (isNativeBinary(bin) && isPlausibleReleaseBinary(bin)) out.push(bin);
|
|
215
228
|
}
|
|
216
229
|
|
|
217
|
-
return
|
|
230
|
+
return out;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function findPlatformBinary() {
|
|
234
|
+
return platformBinaryCandidates()[0] || null;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Version gate for discovery candidates. `consider(bin)` accepts a candidate
|
|
239
|
+
* outright when it is current (version >= pkgVersion) or unverifiable (no pkg
|
|
240
|
+
* version / binary won't report one — don't refuse the only path we may have);
|
|
241
|
+
* a candidate OLDER than the package is recorded as a stale fallback instead of
|
|
242
|
+
* being returned, and `best()` yields the NEWEST of those.
|
|
243
|
+
*
|
|
244
|
+
* Why: candidates below the auto-update cache had no version check at all, so
|
|
245
|
+
* a years-old relic (a 0.16.6 `npm install -g` leftover in the nvm global
|
|
246
|
+
* node_modules) was returned VERBATIM during every post-release window in
|
|
247
|
+
* which the cache binary was one version behind — an ancient server on a
|
|
248
|
+
* modern schema, presenting as the MCP 30s connect-timeout. Newest-stale
|
|
249
|
+
* beats null because every consumer (statusline, hooks, CLI) degrades more
|
|
250
|
+
* gracefully on a slightly-old binary than on "offline", and the stale-binary
|
|
251
|
+
* self-heal in auto-update.js re-downloads shortly anyway.
|
|
252
|
+
*/
|
|
253
|
+
function createVersionGate(pkgVersion, { readVersion = readBinaryVersion } = {}) {
|
|
254
|
+
let bestStale = null;
|
|
255
|
+
return {
|
|
256
|
+
consider(bin) {
|
|
257
|
+
if (!isNativeBinary(bin)) return null;
|
|
258
|
+
if (!pkgVersion) return bin;
|
|
259
|
+
const ver = readVersion(bin);
|
|
260
|
+
if (!ver || compareVersions(ver, pkgVersion) >= 0) return bin;
|
|
261
|
+
if (!bestStale || compareVersions(ver, bestStale.ver) > 0) bestStale = { bin, ver };
|
|
262
|
+
return null;
|
|
263
|
+
},
|
|
264
|
+
best() { return bestStale ? bestStale.bin : null; },
|
|
265
|
+
};
|
|
218
266
|
}
|
|
219
267
|
|
|
220
268
|
function findBinaryUncached() {
|
|
@@ -240,24 +288,31 @@ function findBinaryUncached() {
|
|
|
240
288
|
}
|
|
241
289
|
}
|
|
242
290
|
|
|
291
|
+
// Every tier below runs through the version gate: a candidate at or above
|
|
292
|
+
// the npm pkg version is returned on the spot (tier order = priority, same
|
|
293
|
+
// as before); an OLDER candidate is only remembered as a fallback. Without
|
|
294
|
+
// the gate, tiers below the auto-update cache accepted any binary verbatim —
|
|
295
|
+
// so when the cache was one release behind (every post-release window), an
|
|
296
|
+
// ancient global-npm relic could shadow it (the 0.16.6-serves-a-modern-DB
|
|
297
|
+
// incident behind the MCP 30s connect-timeout).
|
|
298
|
+
const gate = createVersionGate(getPackageVersion());
|
|
299
|
+
|
|
243
300
|
// --- Auto-update cache (binary downloaded directly from GitHub release) ---
|
|
244
301
|
// Cache wins when its version >= the npm pkg version. After `npm update`
|
|
245
302
|
// refreshes the platform-pkg, an older auto-update cache binary must NOT
|
|
246
303
|
// shadow the freshly-installed one; this version check prevents the
|
|
247
304
|
// upgrade-race where users keep running stale binary until auto-update fires.
|
|
248
305
|
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.
|
|
306
|
+
{
|
|
307
|
+
const hit = gate.consider(autoUpdateBin);
|
|
308
|
+
if (hit) return hit;
|
|
256
309
|
}
|
|
257
310
|
|
|
258
311
|
// --- Platform-specific npm package (@sdsrs/code-graph-{os}-{arch}) ---
|
|
259
|
-
const platformBin
|
|
260
|
-
|
|
312
|
+
for (const platformBin of platformBinaryCandidates()) {
|
|
313
|
+
const hit = gate.consider(platformBin);
|
|
314
|
+
if (hit) return hit;
|
|
315
|
+
}
|
|
261
316
|
|
|
262
317
|
// --- Bundled binary (in same directory as cli.js or plugin scripts) ---
|
|
263
318
|
// Check bin/ directory of the npm package
|
|
@@ -267,20 +322,23 @@ function findBinaryUncached() {
|
|
|
267
322
|
}
|
|
268
323
|
binDirs.add(path.resolve(__dirname, '..', '..', 'bin'));
|
|
269
324
|
for (const dir of binDirs) {
|
|
270
|
-
const
|
|
271
|
-
if (
|
|
325
|
+
const hit = gate.consider(path.join(dir, BINARY_NAME));
|
|
326
|
+
if (hit) return hit;
|
|
272
327
|
}
|
|
273
328
|
|
|
274
329
|
// --- Cargo install (~/.cargo/bin) ---
|
|
275
|
-
|
|
276
|
-
|
|
330
|
+
{
|
|
331
|
+
const hit = gate.consider(path.join(os.homedir(), '.cargo', 'bin', BINARY_NAME));
|
|
332
|
+
if (hit) return hit;
|
|
333
|
+
}
|
|
277
334
|
|
|
278
335
|
// --- PATH lookup (last resort for intentionally installed binaries) ---
|
|
279
336
|
try {
|
|
280
337
|
const which = PLATFORM === 'win32' ? 'where' : 'which';
|
|
281
338
|
const found = execFileSync(which, [BINARY_NAME], { stdio: ['pipe', 'pipe', 'pipe'] })
|
|
282
339
|
.toString().trim().split('\n')[0];
|
|
283
|
-
|
|
340
|
+
const hit = gate.consider(found);
|
|
341
|
+
if (hit) return hit;
|
|
284
342
|
} catch { /* not in PATH */ }
|
|
285
343
|
|
|
286
344
|
// --- npx cache (very last resort — may be outdated) ---
|
|
@@ -288,12 +346,15 @@ function findBinaryUncached() {
|
|
|
288
346
|
try {
|
|
289
347
|
for (const entry of fs.readdirSync(npxDir)) {
|
|
290
348
|
const platDir = path.join(npxDir, entry, 'node_modules', '@sdsrs', `code-graph-${PLATFORM}-${ARCH}`);
|
|
291
|
-
const
|
|
292
|
-
if (
|
|
349
|
+
const hit = gate.consider(path.join(platDir, BINARY_NAME));
|
|
350
|
+
if (hit) return hit;
|
|
293
351
|
}
|
|
294
352
|
} catch { /* no npx cache */ }
|
|
295
353
|
|
|
296
|
-
|
|
354
|
+
// Nothing current anywhere: the newest stale candidate (if any) beats null —
|
|
355
|
+
// consumers degrade better on a slightly-old binary than on "offline", and
|
|
356
|
+
// auto-update's stale-binary self-heal replaces it shortly.
|
|
357
|
+
return gate.best();
|
|
297
358
|
}
|
|
298
359
|
|
|
299
360
|
/**
|
|
@@ -306,7 +367,7 @@ function clearCache() {
|
|
|
306
367
|
|
|
307
368
|
module.exports = {
|
|
308
369
|
findBinary, findBinaryUncached, clearCache,
|
|
309
|
-
globalNodeModulesCandidates, findPlatformBinary,
|
|
370
|
+
globalNodeModulesCandidates, findPlatformBinary, platformBinaryCandidates, createVersionGate,
|
|
310
371
|
getPackageVersion, compareVersions, isCachedBinaryFresh,
|
|
311
372
|
detectLibc, unsupportedPlatformHint,
|
|
312
373
|
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 };
|