@sdsrs/code-graph 0.16.6 → 0.16.7

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,7 @@
4
4
  "author": {
5
5
  "name": "sdsrs"
6
6
  },
7
- "version": "0.16.6",
7
+ "version": "0.16.7",
8
8
  "keywords": [
9
9
  "code-graph",
10
10
  "ast",
@@ -172,6 +172,46 @@ function getExtractedPluginVersion(pluginSrc) {
172
172
  return manifest && typeof manifest.version === 'string' ? manifest.version : null;
173
173
  }
174
174
 
175
+ function cachedBinaryPath() {
176
+ const name = os.platform() === 'win32' ? 'code-graph-mcp.exe' : 'code-graph-mcp';
177
+ return path.join(BINARY_CACHE_DIR, name);
178
+ }
179
+
180
+ /**
181
+ * Download just the platform binary from a GitHub release into the cache.
182
+ * Used in two paths:
183
+ * 1. As part of `downloadAndInstall` after a plugin tarball update.
184
+ * 2. As a standalone self-heal when the cached binary is missing but the
185
+ * installed plugin version already matches `latest` (e.g. previous
186
+ * download failed silently, cache was wiped, optionalDependency
187
+ * install dropped the platform package).
188
+ *
189
+ * Returns true on successful promote, false otherwise. Never throws.
190
+ */
191
+ async function downloadBinary(latest) {
192
+ if (!latest || !latest.binaryUrl) return false;
193
+ if (!commandExists('curl')) {
194
+ console.error('[code-graph] Binary download skipped: curl not on PATH.');
195
+ return false;
196
+ }
197
+
198
+ const binaryDst = cachedBinaryPath();
199
+ const binaryTmp = binaryDst + '.tmp.' + process.pid;
200
+
201
+ try {
202
+ fs.mkdirSync(BINARY_CACHE_DIR, { recursive: true });
203
+ execFileSync('curl', [
204
+ '-sL', '-o', binaryTmp,
205
+ latest.binaryUrl,
206
+ ], { timeout: 60000, stdio: 'pipe' });
207
+
208
+ return promoteVerifiedBinary(binaryTmp, binaryDst, latest.version);
209
+ } catch (e) {
210
+ console.error(`[code-graph] Binary download failed: ${e.message}`);
211
+ return false;
212
+ }
213
+ }
214
+
175
215
  function promoteVerifiedBinary(binaryTmp, binaryDst, expectedVersion) {
176
216
  try {
177
217
  const stat = fs.statSync(binaryTmp);
@@ -272,25 +312,8 @@ async function downloadAndInstall(latest) {
272
312
  }
273
313
 
274
314
  // ── Step 2: Download platform binary directly from GitHub release ──
275
- if (latest.binaryUrl) {
276
- try {
277
- const binaryName = os.platform() === 'win32' ? 'code-graph-mcp.exe' : 'code-graph-mcp';
278
- const binaryDst = path.join(BINARY_CACHE_DIR, binaryName);
279
- const binaryTmp = binaryDst + '.tmp.' + process.pid;
280
-
281
- fs.mkdirSync(BINARY_CACHE_DIR, { recursive: true });
282
- execFileSync('curl', [
283
- '-sL', '-o', binaryTmp,
284
- latest.binaryUrl,
285
- ], { timeout: 60000, stdio: 'pipe' });
286
-
287
- if (promoteVerifiedBinary(binaryTmp, binaryDst, latest.version)) {
288
- binaryUpdated = true;
289
- }
290
- } catch (e) {
291
- // Binary download failed — plugin update still counts as success
292
- console.error(`[code-graph] Binary download failed: ${e.message}`);
293
- }
315
+ if (await downloadBinary(latest)) {
316
+ binaryUpdated = true;
294
317
  }
295
318
 
296
319
  return { pluginUpdated, binaryUpdated };
@@ -314,8 +337,11 @@ async function checkForUpdate() {
314
337
  // bypasses auto-update.js, so re-sync state.installedVersion every call.
315
338
  const installedVersion = readManifest().version || '0.0.0';
316
339
 
317
- // Time-based throttle
318
- if (!shouldCheck(state)) {
340
+ // Time-based throttle. A missing cache binary is a hard failure (launcher
341
+ // cannot start) so it overrides the throttle — without this bypass the
342
+ // session wedges for up to 6h waiting for the next check window.
343
+ const binaryMissing = !fs.existsSync(cachedBinaryPath());
344
+ if (!binaryMissing && !shouldCheck(state)) {
319
345
  if (state.installedVersion !== installedVersion) {
320
346
  saveState({ ...state, installedVersion });
321
347
  }
@@ -359,7 +385,15 @@ async function checkForUpdate() {
359
385
  };
360
386
  }
361
387
 
362
- // No update needed
388
+ // No update needed — but self-heal if cache binary is missing.
389
+ // State file alone is not authoritative; previous download may have failed
390
+ // silently, cache may have been wiped, or `npm install -g` optionalDependency
391
+ // may have dropped the platform package.
392
+ let selfHealedBinary = false;
393
+ if (latest.binaryUrl && !fs.existsSync(cachedBinaryPath())) {
394
+ selfHealedBinary = await downloadBinary(latest);
395
+ }
396
+
363
397
  saveState({
364
398
  ...state,
365
399
  installedVersion,
@@ -367,8 +401,11 @@ async function checkForUpdate() {
367
401
  latestVersion: latest.version,
368
402
  updateAvailable: false,
369
403
  rateLimited: false,
404
+ binaryUpdated: selfHealedBinary || state.binaryUpdated,
370
405
  });
371
- return null;
406
+ return selfHealedBinary
407
+ ? { updated: false, binaryUpdated: true, from: installedVersion, to: installedVersion }
408
+ : null;
372
409
  } catch {
373
410
  // Silent failure — never block session
374
411
  return null;
@@ -379,6 +416,7 @@ module.exports = {
379
416
  checkForUpdate, commandExists, isDevMode, readState, compareVersions,
380
417
  getExtractedPluginVersion, readBinaryVersion, promoteVerifiedBinary, isSilentMode,
381
418
  requestJson, parseLatestRelease, fetchLatestRelease,
419
+ downloadBinary, cachedBinaryPath,
382
420
  };
383
421
 
384
422
  // CLI: node auto-update.js [check|status]
@@ -398,6 +436,8 @@ if (require.main === module) {
398
436
  console.log(`Updated: v${result.from} → v${result.to} (binary: ${result.binaryUpdated ? 'yes' : 'no'})`);
399
437
  } else if (result && result.updateAvailable) {
400
438
  console.log(`Update available: v${result.to} (auto-install failed)`);
439
+ } else if (result && result.binaryUpdated) {
440
+ console.log(`Repaired binary cache (v${result.to})`);
401
441
  } else if (isDevMode()) {
402
442
  console.log('Dev mode — auto-update skipped');
403
443
  } else {
@@ -12,6 +12,8 @@ const {
12
12
  parseLatestRelease,
13
13
  readBinaryVersion,
14
14
  promoteVerifiedBinary,
15
+ cachedBinaryPath,
16
+ downloadBinary,
15
17
  } = require('./auto-update');
16
18
 
17
19
  function mkDir(t, prefix) {
@@ -93,6 +95,24 @@ test('commandExists returns false for a non-existent command', () => {
93
95
  assert.equal(commandExists('__nonexistent_cmd_xyz_12345__'), false);
94
96
  });
95
97
 
98
+ test('cachedBinaryPath returns expected platform binary path', () => {
99
+ const p = cachedBinaryPath();
100
+ const expectedName = process.platform === 'win32' ? 'code-graph-mcp.exe' : 'code-graph-mcp';
101
+ assert.equal(path.basename(p), expectedName);
102
+ assert.ok(p.includes('.cache') && p.includes('code-graph'),
103
+ `expected cache path to live under ~/.cache/code-graph: ${p}`);
104
+ });
105
+
106
+ test('downloadBinary returns false for missing binaryUrl (no-op safety)', async () => {
107
+ const result = await downloadBinary({ version: '1.0.0', binaryUrl: null });
108
+ assert.equal(result, false);
109
+ });
110
+
111
+ test('downloadBinary returns false when latest is null', async () => {
112
+ const result = await downloadBinary(null);
113
+ assert.equal(result, false);
114
+ });
115
+
96
116
  test('fetchLatestRelease parses JSON without relying on global fetch', async () => {
97
117
  const latest = await fetchLatestRelease(async () => ({
98
118
  statusCode: 200,
@@ -9,6 +9,53 @@ const PLATFORM = os.platform();
9
9
  const ARCH = os.arch();
10
10
  const CACHE_FILE = path.join(os.homedir(), '.cache', 'code-graph', 'binary-path');
11
11
  const BINARY_NAME = PLATFORM === 'win32' ? 'code-graph-mcp.exe' : 'code-graph-mcp';
12
+ const PLATFORM_PKG = `@sdsrs/code-graph-${PLATFORM}-${ARCH}`;
13
+
14
+ /**
15
+ * Candidate paths for npm global `node_modules`.
16
+ *
17
+ * `require.resolve` only searches `node_modules` walking up from the requiring
18
+ * file — it does NOT search global installations (no NODE_PATH set in default
19
+ * Node setups, including nvm). When a user runs `npm install -g`, the platform
20
+ * package lands somewhere we have to discover ourselves.
21
+ */
22
+ function globalNodeModulesCandidates() {
23
+ const out = [];
24
+ const nodeBinDir = path.dirname(process.execPath);
25
+
26
+ // 1. Derive from process.execPath. Works for nvm + standard Unix prefixes
27
+ // (`<prefix>/bin/node` → globals at `<prefix>/lib/node_modules`); on
28
+ // Windows globals sit next to `node.exe`.
29
+ if (PLATFORM === 'win32') {
30
+ out.push(path.join(nodeBinDir, 'node_modules'));
31
+ } else {
32
+ out.push(path.resolve(nodeBinDir, '..', 'lib', 'node_modules'));
33
+ }
34
+
35
+ // 2. NPM_CONFIG_PREFIX env override (set by users using `~/.npm-global` etc.)
36
+ const envPrefix = process.env.NPM_CONFIG_PREFIX || process.env.npm_config_prefix;
37
+ if (envPrefix) {
38
+ out.push(PLATFORM === 'win32'
39
+ ? path.join(envPrefix, 'node_modules')
40
+ : path.join(envPrefix, 'lib', 'node_modules'));
41
+ }
42
+
43
+ // 3. Common no-sudo user prefix
44
+ out.push(path.join(os.homedir(), '.npm-global', 'lib', 'node_modules'));
45
+
46
+ // 4. Last resort: ask npm directly. Slow (~50-200ms) but most accurate when
47
+ // user has a non-standard prefix. Cached at the disk-cache layer above.
48
+ try {
49
+ const root = execFileSync('npm', ['root', '-g'], {
50
+ timeout: 2000,
51
+ stdio: ['pipe', 'pipe', 'pipe'],
52
+ encoding: 'utf8',
53
+ }).trim();
54
+ if (root) out.push(root);
55
+ } catch { /* npm not on PATH or timed out */ }
56
+
57
+ return [...new Set(out)];
58
+ }
12
59
 
13
60
  function isNativeBinary(candidate) {
14
61
  if (!candidate) return false;
@@ -60,6 +107,32 @@ function isDevRepo(rootDir) {
60
107
  return fs.existsSync(path.join(rootDir, 'Cargo.toml'));
61
108
  }
62
109
 
110
+ /**
111
+ * Locate the platform-specific binary in npm package layouts.
112
+ * - First via `require.resolve` (parent-walk node_modules / linked / npx).
113
+ * - Then by explicit probe of npm global `node_modules` candidates.
114
+ *
115
+ * `require.resolve` does NOT search global installs (no NODE_PATH on
116
+ * nvm/standard setups), so a working `npm install -g @sdsrs/code-graph` can
117
+ * still be invisible without the fallback.
118
+ */
119
+ function findPlatformBinary() {
120
+ // Fast path: standard module resolution.
121
+ try {
122
+ const pkgPath = require.resolve(`${PLATFORM_PKG}/package.json`);
123
+ const bin = path.join(path.dirname(pkgPath), BINARY_NAME);
124
+ if (isNativeBinary(bin)) return bin;
125
+ } catch { /* not in node_modules walk-up */ }
126
+
127
+ // Slow path: explicit global node_modules probe.
128
+ for (const globalRoot of globalNodeModulesCandidates()) {
129
+ const bin = path.join(globalRoot, '@sdsrs', `code-graph-${PLATFORM}-${ARCH}`, BINARY_NAME);
130
+ if (isNativeBinary(bin)) return bin;
131
+ }
132
+
133
+ return null;
134
+ }
135
+
63
136
  function findBinaryUncached() {
64
137
  // --- Dev mode: always prefer cargo build output when running from source repo ---
65
138
  // This covers: npm link, direct invocation from repo, CLAUDE_PROJECT_DIR set to repo
@@ -88,12 +161,8 @@ function findBinaryUncached() {
88
161
  if (isNativeBinary(autoUpdateBin)) return autoUpdateBin;
89
162
 
90
163
  // --- Platform-specific npm package (@sdsrs/code-graph-{os}-{arch}) ---
91
- const platformPkg = `@sdsrs/code-graph-${PLATFORM}-${ARCH}`;
92
- try {
93
- const pkgPath = require.resolve(`${platformPkg}/package.json`);
94
- const bin = path.join(path.dirname(pkgPath), BINARY_NAME);
95
- if (isNativeBinary(bin)) return bin;
96
- } catch { /* not installed via npm */ }
164
+ const platformBin = findPlatformBinary();
165
+ if (platformBin) return platformBin;
97
166
 
98
167
  // --- Bundled binary (in same directory as cli.js or plugin scripts) ---
99
168
  // Check bin/ directory of the npm package
@@ -140,7 +209,11 @@ function clearCache() {
140
209
  try { fs.unlinkSync(CACHE_FILE); } catch { /* ok */ }
141
210
  }
142
211
 
143
- module.exports = { findBinary, findBinaryUncached, clearCache, CACHE_FILE, BINARY_NAME };
212
+ module.exports = {
213
+ findBinary, findBinaryUncached, clearCache,
214
+ globalNodeModulesCandidates, findPlatformBinary,
215
+ CACHE_FILE, BINARY_NAME, PLATFORM_PKG,
216
+ };
144
217
 
145
218
  // Allow direct invocation for testing
146
219
  if (require.main === module) {
@@ -0,0 +1,110 @@
1
+ 'use strict';
2
+ const test = require('node:test');
3
+ const assert = require('node:assert/strict');
4
+ const fs = require('fs');
5
+ const os = require('os');
6
+ const path = require('path');
7
+
8
+ const { globalNodeModulesCandidates, findPlatformBinary, BINARY_NAME } = require('./find-binary');
9
+
10
+ function mkDir(t, prefix) {
11
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
12
+ t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
13
+ return dir;
14
+ }
15
+
16
+ test('globalNodeModulesCandidates includes derivation from process.execPath', () => {
17
+ const candidates = globalNodeModulesCandidates();
18
+ assert.ok(candidates.length > 0, 'at least one candidate path');
19
+
20
+ const nodeBinDir = path.dirname(process.execPath);
21
+ const expected = process.platform === 'win32'
22
+ ? path.join(nodeBinDir, 'node_modules')
23
+ : path.resolve(nodeBinDir, '..', 'lib', 'node_modules');
24
+ assert.ok(candidates.includes(expected), `expected ${expected} in ${JSON.stringify(candidates)}`);
25
+ });
26
+
27
+ test('globalNodeModulesCandidates honors NPM_CONFIG_PREFIX', (t) => {
28
+ const original = process.env.NPM_CONFIG_PREFIX;
29
+ process.env.NPM_CONFIG_PREFIX = '/tmp/fake-npm-prefix';
30
+ t.after(() => {
31
+ if (original === undefined) delete process.env.NPM_CONFIG_PREFIX;
32
+ else process.env.NPM_CONFIG_PREFIX = original;
33
+ });
34
+
35
+ const candidates = globalNodeModulesCandidates();
36
+ const expected = process.platform === 'win32'
37
+ ? path.join('/tmp/fake-npm-prefix', 'node_modules')
38
+ : path.join('/tmp/fake-npm-prefix', 'lib', 'node_modules');
39
+ assert.ok(candidates.includes(expected),
40
+ `expected NPM_CONFIG_PREFIX-derived path in candidates: ${JSON.stringify(candidates)}`);
41
+ });
42
+
43
+ test('globalNodeModulesCandidates dedupes overlapping paths', (t) => {
44
+ const original = process.env.NPM_CONFIG_PREFIX;
45
+ // Force NPM_CONFIG_PREFIX to match the execPath-derived prefix
46
+ const nodeBinDir = path.dirname(process.execPath);
47
+ const matchedPrefix = process.platform === 'win32'
48
+ ? nodeBinDir
49
+ : path.resolve(nodeBinDir, '..');
50
+ process.env.NPM_CONFIG_PREFIX = matchedPrefix;
51
+ t.after(() => {
52
+ if (original === undefined) delete process.env.NPM_CONFIG_PREFIX;
53
+ else process.env.NPM_CONFIG_PREFIX = original;
54
+ });
55
+
56
+ const candidates = globalNodeModulesCandidates();
57
+ const seen = new Set();
58
+ for (const c of candidates) {
59
+ assert.ok(!seen.has(c), `duplicate candidate: ${c}`);
60
+ seen.add(c);
61
+ }
62
+ });
63
+
64
+ test('findPlatformBinary locates platform pkg in NPM_CONFIG_PREFIX-derived global node_modules', (t) => {
65
+ // Mirror what `npm install -g` produces for @sdsrs/code-graph-{platform}-{arch}.
66
+ const fakePrefix = mkDir(t, 'find-binary-test-');
67
+ const platDir = process.platform === 'win32'
68
+ ? path.join(fakePrefix, 'node_modules', '@sdsrs', `code-graph-${process.platform}-${process.arch}`)
69
+ : path.join(fakePrefix, 'lib', 'node_modules', '@sdsrs', `code-graph-${process.platform}-${process.arch}`);
70
+ fs.mkdirSync(platDir, { recursive: true });
71
+
72
+ // Copy node executable so realpathSync(candidate)'s basename === BINARY_NAME
73
+ // (isNativeBinary check). Plain copy, not symlink, so basename matches.
74
+ const fakeBinary = path.join(platDir, BINARY_NAME);
75
+ fs.copyFileSync(process.execPath, fakeBinary);
76
+ if (process.platform !== 'win32') fs.chmodSync(fakeBinary, 0o755);
77
+
78
+ const original = process.env.NPM_CONFIG_PREFIX;
79
+ process.env.NPM_CONFIG_PREFIX = fakePrefix;
80
+ t.after(() => {
81
+ if (original === undefined) delete process.env.NPM_CONFIG_PREFIX;
82
+ else process.env.NPM_CONFIG_PREFIX = original;
83
+ });
84
+
85
+ const found = findPlatformBinary();
86
+ assert.equal(found, fakeBinary, `expected ${fakeBinary}, got ${found}`);
87
+ });
88
+
89
+ test('findPlatformBinary returns null when no platform pkg installed anywhere reachable', (t) => {
90
+ // Point NPM_CONFIG_PREFIX at an empty dir so global probe cannot match.
91
+ const fakePrefix = mkDir(t, 'find-binary-empty-');
92
+ const original = process.env.NPM_CONFIG_PREFIX;
93
+ process.env.NPM_CONFIG_PREFIX = fakePrefix;
94
+ t.after(() => {
95
+ if (original === undefined) delete process.env.NPM_CONFIG_PREFIX;
96
+ else process.env.NPM_CONFIG_PREFIX = original;
97
+ });
98
+
99
+ // Note: this test only proves the negative if no real install of the platform
100
+ // package is reachable via require.resolve OR any other candidate path. On a
101
+ // dev machine that has `@sdsrs/code-graph-linux-x64` installed globally, this
102
+ // assertion will fail — that's not a defect of the helper but of test setup.
103
+ // Skip if a real install is detected.
104
+ const real = findPlatformBinary();
105
+ if (real && !real.startsWith(fakePrefix)) {
106
+ t.skip(`real platform pkg installed at ${real}, cannot test the null path here`);
107
+ return;
108
+ }
109
+ assert.equal(real, null);
110
+ });
@@ -42,10 +42,29 @@ if (!binary) {
42
42
  }
43
43
  }
44
44
 
45
+ // Fallback: npm install may have succeeded but optionalDependencies for the
46
+ // platform binary can fail silently (npm tolerates OS-mismatch + flaky
47
+ // registry). Pull the platform binary directly from the GitHub release.
48
+ if (!binary) {
49
+ process.stderr.write('[code-graph] Falling back to GitHub release download...\n');
50
+ try {
51
+ execFileSync(process.execPath, [path.join(__dirname, 'auto-update.js'), '--silent'], {
52
+ timeout: 90000, stdio: 'pipe',
53
+ });
54
+ clearCache();
55
+ binary = findBinary();
56
+ if (binary) {
57
+ process.stderr.write(`[code-graph] Installed at ${binary}\n`);
58
+ }
59
+ } catch { /* fall through to manual-install message */ }
60
+ }
61
+
45
62
  if (!binary) {
46
63
  process.stderr.write(
47
64
  '[code-graph] Binary not found. Install manually:\n' +
48
- ' npm install -g @sdsrs/code-graph\n'
65
+ ' npm install -g @sdsrs/code-graph\n' +
66
+ ' # or\n' +
67
+ ' npm install -g @sdsrs/code-graph-' + process.platform + '-' + process.arch + '\n'
49
68
  );
50
69
  process.exit(1);
51
70
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sdsrs/code-graph",
3
- "version": "0.16.6",
3
+ "version": "0.16.7",
4
4
  "description": "MCP server that indexes codebases into an AST knowledge graph with semantic search, call graph traversal, and HTTP route tracing",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -34,10 +34,10 @@
34
34
  "node": ">=16"
35
35
  },
36
36
  "optionalDependencies": {
37
- "@sdsrs/code-graph-linux-x64": "0.16.6",
38
- "@sdsrs/code-graph-linux-arm64": "0.16.6",
39
- "@sdsrs/code-graph-darwin-x64": "0.16.6",
40
- "@sdsrs/code-graph-darwin-arm64": "0.16.6",
41
- "@sdsrs/code-graph-win32-x64": "0.16.6"
37
+ "@sdsrs/code-graph-linux-x64": "0.16.7",
38
+ "@sdsrs/code-graph-linux-arm64": "0.16.7",
39
+ "@sdsrs/code-graph-darwin-x64": "0.16.7",
40
+ "@sdsrs/code-graph-darwin-arm64": "0.16.7",
41
+ "@sdsrs/code-graph-win32-x64": "0.16.7"
42
42
  }
43
43
  }