@sdsrs/code-graph 0.17.3 → 0.18.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,7 @@
4
4
  "author": {
5
5
  "name": "sdsrs"
6
6
  },
7
- "version": "0.17.3",
7
+ "version": "0.18.0",
8
8
  "keywords": [
9
9
  "code-graph",
10
10
  "ast",
@@ -38,6 +38,10 @@ function isSilentMode(argv = process.argv.slice(2), env = process.env) {
38
38
  return argv.includes('--silent') || env.CODE_GRAPH_AUTO_UPDATE_SILENT === '1';
39
39
  }
40
40
 
41
+ function isInstallMissingMode(argv = process.argv.slice(2)) {
42
+ return argv.includes('--install-missing');
43
+ }
44
+
41
45
  // ── Platform → GitHub release asset name mapping ──────────
42
46
  function getPlatformAssetName() {
43
47
  const platform = os.platform();
@@ -327,10 +331,12 @@ async function downloadAndInstall(latest) {
327
331
 
328
332
  // ── Main Entry ─────────────────────────────────────────────
329
333
 
330
- async function checkForUpdate() {
334
+ async function checkForUpdate({ installMissing = false } = {}) {
331
335
  try {
332
- // Skip in dev mode
333
- if (isDevMode()) return null;
336
+ // Skip in dev mode — unless the launcher explicitly requested a missing-
337
+ // binary install, in which case we MUST proceed regardless of mode (the
338
+ // alternative is wedging the MCP server with no binary on disk).
339
+ if (!installMissing && isDevMode()) return null;
334
340
 
335
341
  const state = readState();
336
342
  // manifest.version is authoritative — /plugin update writes it directly and
@@ -414,23 +420,25 @@ async function checkForUpdate() {
414
420
 
415
421
  module.exports = {
416
422
  checkForUpdate, commandExists, isDevMode, readState, compareVersions,
417
- getExtractedPluginVersion, readBinaryVersion, promoteVerifiedBinary, isSilentMode,
423
+ getExtractedPluginVersion, readBinaryVersion, promoteVerifiedBinary,
424
+ isSilentMode, isInstallMissingMode,
418
425
  requestJson, parseLatestRelease, fetchLatestRelease,
419
426
  downloadBinary, cachedBinaryPath,
420
427
  };
421
428
 
422
- // CLI: node auto-update.js [check|status]
429
+ // CLI: node auto-update.js [check|status] [--silent] [--install-missing]
423
430
  if (require.main === module) {
424
431
  (async () => {
425
432
  const argv = process.argv.slice(2);
426
433
  const cmd = argv.find(arg => !arg.startsWith('--')) || 'check';
427
434
  const silent = isSilentMode(argv);
435
+ const installMissing = isInstallMissingMode(argv);
428
436
  if (cmd === 'status') {
429
437
  const state = readState();
430
438
  console.log(JSON.stringify(state, null, 2));
431
439
  } else {
432
440
  if (!silent) console.log('Checking for updates...');
433
- const result = await checkForUpdate();
441
+ const result = await checkForUpdate({ installMissing });
434
442
  if (silent) return;
435
443
  if (result && result.updated) {
436
444
  console.log(`Updated: v${result.from} → v${result.to} (binary: ${result.binaryUpdated ? 'yes' : 'no'})`);
@@ -438,7 +446,7 @@ if (require.main === module) {
438
446
  console.log(`Update available: v${result.to} (auto-install failed)`);
439
447
  } else if (result && result.binaryUpdated) {
440
448
  console.log(`Repaired binary cache (v${result.to})`);
441
- } else if (isDevMode()) {
449
+ } else if (!installMissing && isDevMode()) {
442
450
  console.log('Dev mode — auto-update skipped');
443
451
  } else {
444
452
  const manifest = readManifest();
@@ -14,6 +14,8 @@ const {
14
14
  promoteVerifiedBinary,
15
15
  cachedBinaryPath,
16
16
  downloadBinary,
17
+ isInstallMissingMode,
18
+ isSilentMode,
17
19
  } = require('./auto-update');
18
20
 
19
21
  function mkDir(t, prefix) {
@@ -113,6 +115,21 @@ test('downloadBinary returns false when latest is null', async () => {
113
115
  assert.equal(result, false);
114
116
  });
115
117
 
118
+ // ── Flag parsing ───────────────────────────────────────────
119
+
120
+ test('isInstallMissingMode detects --install-missing in argv', () => {
121
+ assert.equal(isInstallMissingMode(['--install-missing']), true);
122
+ assert.equal(isInstallMissingMode(['check', '--install-missing']), true);
123
+ assert.equal(isInstallMissingMode(['check']), false);
124
+ assert.equal(isInstallMissingMode([]), false);
125
+ });
126
+
127
+ test('isSilentMode honors --silent flag and CODE_GRAPH_AUTO_UPDATE_SILENT env', () => {
128
+ assert.equal(isSilentMode(['--silent'], {}), true);
129
+ assert.equal(isSilentMode([], { CODE_GRAPH_AUTO_UPDATE_SILENT: '1' }), true);
130
+ assert.equal(isSilentMode([], {}), false);
131
+ });
132
+
116
133
  test('fetchLatestRelease parses JSON without relying on global fetch', async () => {
117
134
  const latest = await fetchLatestRelease(async () => ({
118
135
  statusCode: 200,
@@ -7,7 +7,7 @@
7
7
  * Used by .mcp.json so the plugin controls binary discovery instead of
8
8
  * relying on the binary being in PATH.
9
9
  */
10
- const { spawn, execFileSync } = require('child_process');
10
+ const { spawn, spawnSync } = require('child_process');
11
11
  const path = require('path');
12
12
  const fs = require('fs');
13
13
 
@@ -28,42 +28,69 @@ if (!binary) {
28
28
  } catch { /* use latest */ }
29
29
 
30
30
  process.stderr.write(`[code-graph] Binary not found, installing @sdsrs/code-graph@${version}...\n`);
31
- try {
32
- execFileSync('npm', ['install', '-g', `@sdsrs/code-graph@${version}`], {
33
- timeout: 60000, stdio: 'pipe',
34
- });
31
+ const npmResult = spawnSync('npm', ['install', '-g', `@sdsrs/code-graph@${version}`], {
32
+ timeout: 60000, stdio: ['ignore', 'pipe', 'pipe'], encoding: 'utf8',
33
+ });
34
+ if (npmResult.error || npmResult.status !== 0) {
35
+ process.stderr.write('[code-graph] npm install failed.\n');
36
+ if (npmResult.stderr) {
37
+ process.stderr.write(npmResult.stderr.trim().split('\n').map(l => `[code-graph][npm] ${l}\n`).join(''));
38
+ }
39
+ } else {
35
40
  clearCache();
36
41
  binary = findBinary();
37
42
  if (binary) {
38
43
  process.stderr.write(`[code-graph] Installed at ${binary}\n`);
39
44
  }
40
- } catch {
41
- process.stderr.write('[code-graph] npm install failed.\n');
42
45
  }
43
46
  }
44
47
 
45
48
  // Fallback: npm install may have succeeded but optionalDependencies for the
46
49
  // platform binary can fail silently (npm tolerates OS-mismatch + flaky
47
50
  // registry). Pull the platform binary directly from the GitHub release.
51
+ //
52
+ // --install-missing bypasses auto-update.js's isDevMode() short-circuit. The
53
+ // marketplace ships the full repo (including Cargo.toml at the workspace root),
54
+ // so dev-mode heuristics that look for Cargo.toml were misclassifying every
55
+ // marketplace install as dev mode and skipping this fallback (issue #12).
48
56
  if (!binary) {
49
57
  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 */ }
58
+ const result = spawnSync(
59
+ process.execPath,
60
+ [path.join(__dirname, 'auto-update.js'), '--silent', '--install-missing'],
61
+ { timeout: 90000, stdio: ['ignore', 'pipe', 'pipe'], encoding: 'utf8' }
62
+ );
63
+ if (result.stderr && result.stderr.trim()) {
64
+ process.stderr.write(result.stderr.trim().split('\n').map(l => `[code-graph][auto-update] ${l}\n`).join(''));
65
+ }
66
+ if (result.error) {
67
+ process.stderr.write(`[code-graph] auto-update spawn failed: ${result.error.message}\n`);
68
+ } else if (result.status !== 0) {
69
+ process.stderr.write(`[code-graph] auto-update exited with status ${result.status}\n`);
70
+ }
71
+ clearCache();
72
+ binary = findBinary();
73
+ if (binary) {
74
+ process.stderr.write(`[code-graph] Installed at ${binary}\n`);
75
+ }
60
76
  }
61
77
 
62
78
  if (!binary) {
79
+ const installedViaMarketplace = fs.existsSync(
80
+ path.join(__dirname, '..', '.claude-plugin', 'plugin.json')
81
+ );
82
+ process.stderr.write('[code-graph] Binary not found. Install manually:\n');
83
+ if (installedViaMarketplace) {
84
+ process.stderr.write(
85
+ ' # Re-install the plugin via Claude Code marketplace:\n' +
86
+ ' /plugin uninstall code-graph-mcp && /plugin install code-graph-mcp@code-graph-mcp\n' +
87
+ ' # Or install the binary directly via npm:\n'
88
+ );
89
+ }
63
90
  process.stderr.write(
64
- '[code-graph] Binary not found. Install manually:\n' +
91
+ ' npm install -g @sdsrs/code-graph @sdsrs/code-graph-' + process.platform + '-' + process.arch + '\n' +
92
+ ' # or, equivalent split form:\n' +
65
93
  ' npm install -g @sdsrs/code-graph\n' +
66
- ' # or\n' +
67
94
  ' npm install -g @sdsrs/code-graph-' + process.platform + '-' + process.arch + '\n'
68
95
  );
69
96
  process.exit(1);
@@ -18,13 +18,22 @@ function readBinaryVersion(binaryPath) {
18
18
  }
19
19
  }
20
20
 
21
- function isDevMode() {
22
- // Always derive from __dirname CLAUDE_PLUGIN_ROOT can leak from other plugins
23
- const pluginRoot = path.resolve(__dirname, '..');
24
- // Dev mode: running from source repo (has Cargo.toml nearby)
25
- if (fs.existsSync(path.join(pluginRoot, '..', 'Cargo.toml'))) return true;
26
- // Dev mode: plugin root is a symlink
21
+ function isDevMode(pluginRoot = path.resolve(__dirname, '..')) {
22
+ // Explicit opt-in always wins (also lets users force dev mode in any layout)
23
+ if (process.env.CODE_GRAPH_DEV === '1') return true;
24
+ // Plugin root is a symlink (e.g. `npm link`)
27
25
  try { if (fs.lstatSync(pluginRoot).isSymbolicLink()) return true; } catch { /* ok */ }
26
+ // Source repo: Cargo.toml AND target/ at parent. Marketplace installs ship
27
+ // Cargo.toml (git-tracked) but NOT target/ (gitignored), so target/ is the
28
+ // discriminator — without it, a marketplace clone was being misclassified as
29
+ // dev mode and the launcher's GitHub-release fallback was unreachable
30
+ // (see GitHub issue #12). If a dev hasn't built yet, they fall through to
31
+ // the user-mode auto-install path, which still produces a working binary.
32
+ const parent = path.dirname(pluginRoot);
33
+ if (fs.existsSync(path.join(parent, 'Cargo.toml')) &&
34
+ fs.existsSync(path.join(parent, 'target'))) {
35
+ return true;
36
+ }
28
37
  return false;
29
38
  }
30
39
 
@@ -45,10 +45,66 @@ test('readBinaryVersion returns null for binary with unexpected output', (t) =>
45
45
 
46
46
  // ── isDevMode ──
47
47
 
48
- test('isDevMode returns true in source repo (Cargo.toml nearby)', () => {
48
+ function makeFakePluginRoot(t, { withCargo = false, withTarget = false, asSymlink = false } = {}) {
49
+ const parent = mkDir(t, 'vu-dev-');
50
+ const pluginRoot = path.join(parent, 'claude-plugin');
51
+ fs.mkdirSync(pluginRoot, { recursive: true });
52
+ if (withCargo) fs.writeFileSync(path.join(parent, 'Cargo.toml'), '[package]\nname = "x"\n');
53
+ if (withTarget) fs.mkdirSync(path.join(parent, 'target'), { recursive: true });
54
+
55
+ if (asSymlink) {
56
+ const real = path.join(parent, 'real-plugin');
57
+ fs.mkdirSync(real, { recursive: true });
58
+ fs.rmSync(pluginRoot, { recursive: true, force: true });
59
+ fs.symlinkSync(real, pluginRoot);
60
+ }
61
+ return pluginRoot;
62
+ }
63
+
64
+ function withEnv(t, key, value) {
65
+ const original = process.env[key];
66
+ if (value === undefined) delete process.env[key]; else process.env[key] = value;
67
+ t.after(() => {
68
+ if (original === undefined) delete process.env[key]; else process.env[key] = original;
69
+ });
70
+ }
71
+
72
+ test('isDevMode returns true in source repo when Cargo.toml AND target/ both exist', (t) => {
73
+ const { isDevMode } = require('./version-utils');
74
+ withEnv(t, 'CODE_GRAPH_DEV', undefined);
75
+ const pluginRoot = makeFakePluginRoot(t, { withCargo: true, withTarget: true });
76
+ assert.equal(isDevMode(pluginRoot), true);
77
+ });
78
+
79
+ test('isDevMode returns false for marketplace clone (Cargo.toml without target/)', (t) => {
80
+ const { isDevMode } = require('./version-utils');
81
+ withEnv(t, 'CODE_GRAPH_DEV', undefined);
82
+ // Marketplace clones the full repo (Cargo.toml is git-tracked) but `target/`
83
+ // is gitignored, so a fresh marketplace install has no target/. This is the
84
+ // exact misclassification fixed for issue #12.
85
+ const pluginRoot = makeFakePluginRoot(t, { withCargo: true, withTarget: false });
86
+ assert.equal(isDevMode(pluginRoot), false);
87
+ });
88
+
89
+ test('isDevMode returns false for fully unrelated install (no Cargo.toml, no target/)', (t) => {
90
+ const { isDevMode } = require('./version-utils');
91
+ withEnv(t, 'CODE_GRAPH_DEV', undefined);
92
+ const pluginRoot = makeFakePluginRoot(t);
93
+ assert.equal(isDevMode(pluginRoot), false);
94
+ });
95
+
96
+ test('isDevMode honors CODE_GRAPH_DEV=1 even when neither Cargo.toml nor target/ exist', (t) => {
97
+ const { isDevMode } = require('./version-utils');
98
+ withEnv(t, 'CODE_GRAPH_DEV', '1');
99
+ const pluginRoot = makeFakePluginRoot(t);
100
+ assert.equal(isDevMode(pluginRoot), true);
101
+ });
102
+
103
+ test('isDevMode returns true when plugin root is a symlink', (t) => {
49
104
  const { isDevMode } = require('./version-utils');
50
- // Running from source repo: __dirname/../.. has Cargo.toml → true
51
- assert.equal(isDevMode(), true);
105
+ withEnv(t, 'CODE_GRAPH_DEV', undefined);
106
+ const pluginRoot = makeFakePluginRoot(t, { asSymlink: true });
107
+ assert.equal(isDevMode(pluginRoot), true);
52
108
  });
53
109
 
54
110
  // ── getNewestMtime ──
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sdsrs/code-graph",
3
- "version": "0.17.3",
3
+ "version": "0.18.0",
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": {
@@ -35,10 +35,10 @@
35
35
  "node": ">=16"
36
36
  },
37
37
  "optionalDependencies": {
38
- "@sdsrs/code-graph-linux-x64": "0.17.3",
39
- "@sdsrs/code-graph-linux-arm64": "0.17.3",
40
- "@sdsrs/code-graph-darwin-x64": "0.17.3",
41
- "@sdsrs/code-graph-darwin-arm64": "0.17.3",
42
- "@sdsrs/code-graph-win32-x64": "0.17.3"
38
+ "@sdsrs/code-graph-linux-x64": "0.18.0",
39
+ "@sdsrs/code-graph-linux-arm64": "0.18.0",
40
+ "@sdsrs/code-graph-darwin-x64": "0.18.0",
41
+ "@sdsrs/code-graph-darwin-arm64": "0.18.0",
42
+ "@sdsrs/code-graph-win32-x64": "0.18.0"
43
43
  }
44
44
  }