@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.
@@ -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
- /** Read the npm pkg version from this script's package.json (claude-plugin/../package.json). */
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
- * Compare semver-ish "M.m.p" strings; returns -1, 0, or 1. Non-numeric parts → 0.
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. Derive from process.execPath. Works for nvm + standard Unix prefixes
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
- function findPlatformBinary() {
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)) return 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)) return bin;
227
+ if (isNativeBinary(bin) && isPlausibleReleaseBinary(bin)) out.push(bin);
215
228
  }
216
229
 
217
- return null;
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
- if (isNativeBinary(autoUpdateBin)) {
250
- const cacheVer = readBinaryVersion(autoUpdateBin);
251
- const pkgVer = getPackageVersion();
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 = findPlatformBinary();
260
- if (platformBin) return platformBin;
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 bundled = path.join(dir, BINARY_NAME);
271
- if (isNativeBinary(bundled)) return bundled;
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
- const cargoBin = path.join(os.homedir(), '.cargo', 'bin', BINARY_NAME);
276
- if (isNativeBinary(cargoBin)) return cargoBin;
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
- if (isNativeBinary(found)) return found;
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 platBin = path.join(platDir, BINARY_NAME);
292
- if (isNativeBinary(platBin)) return platBin;
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
- return null;
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 };