@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.
@@ -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, spawnSync } = require('child_process');
10
+ const { spawn } = require('child_process');
11
11
  const path = require('path');
12
12
  const fs = require('fs');
13
13
  const { isNonProjectCwd } = require('./project-detect');
@@ -86,66 +86,15 @@ if (process.env.CODE_GRAPH_FORCE_PLUGIN_MCP !== '1' && isNonProjectCwd(process.c
86
86
  }
87
87
 
88
88
  const { findBinary, clearCache, unsupportedPlatformHint } = require('./find-binary');
89
+ const { installBinaryInBackground } = require('./launcher-install');
89
90
 
90
- let binary = findBinary();
91
+ const binary = findBinary();
91
92
 
92
- // Auto-install binary if not found (first-time install)
93
- if (!binary) {
94
- let version = 'latest';
95
- try {
96
- const pj = path.join(__dirname, '..', '.claude-plugin', 'plugin.json');
97
- version = JSON.parse(fs.readFileSync(pj, 'utf8')).version || 'latest';
98
- } catch { /* use latest */ }
99
-
100
- process.stderr.write(`[code-graph] Binary not found, installing @sdsrs/code-graph@${version}...\n`);
101
- const npmResult = spawnSync('npm', ['install', '-g', `@sdsrs/code-graph@${version}`], {
102
- timeout: 60000, stdio: ['ignore', 'pipe', 'pipe'], encoding: 'utf8',
103
- });
104
- if (npmResult.error || npmResult.status !== 0) {
105
- process.stderr.write('[code-graph] npm install failed.\n');
106
- if (npmResult.stderr) {
107
- process.stderr.write(npmResult.stderr.trim().split('\n').map(l => `[code-graph][npm] ${l}\n`).join(''));
108
- }
109
- } else {
110
- clearCache();
111
- binary = findBinary();
112
- if (binary) {
113
- process.stderr.write(`[code-graph] Installed at ${binary}\n`);
114
- }
115
- }
116
- }
117
-
118
- // Fallback: npm install may have succeeded but optionalDependencies for the
119
- // platform binary can fail silently (npm tolerates OS-mismatch + flaky
120
- // registry). Pull the platform binary directly from the GitHub release.
121
- //
122
- // --install-missing bypasses auto-update.js's isDevMode() short-circuit. The
123
- // marketplace ships the full repo (including Cargo.toml at the workspace root),
124
- // so dev-mode heuristics that look for Cargo.toml were misclassifying every
125
- // marketplace install as dev mode and skipping this fallback (issue #12).
126
- if (!binary) {
127
- process.stderr.write('[code-graph] Falling back to GitHub release download...\n');
128
- const result = spawnSync(
129
- process.execPath,
130
- [path.join(__dirname, 'auto-update.js'), '--silent', '--install-missing'],
131
- { timeout: 90000, stdio: ['ignore', 'pipe', 'pipe'], encoding: 'utf8' }
132
- );
133
- if (result.stderr && result.stderr.trim()) {
134
- process.stderr.write(result.stderr.trim().split('\n').map(l => `[code-graph][auto-update] ${l}\n`).join(''));
135
- }
136
- if (result.error) {
137
- process.stderr.write(`[code-graph] auto-update spawn failed: ${result.error.message}\n`);
138
- } else if (result.status !== 0) {
139
- process.stderr.write(`[code-graph] auto-update exited with status ${result.status}\n`);
140
- }
141
- clearCache();
142
- binary = findBinary();
143
- if (binary) {
144
- process.stderr.write(`[code-graph] Installed at ${binary}\n`);
145
- }
146
- }
147
-
148
- if (!binary) {
93
+ // Manual-install guidance, printed when the background install chain exhausts
94
+ // both steps without producing a binary. Unlike the old sync path this does NOT
95
+ // exit: the upgradeable stub stays connected (0 tools), so a manual
96
+ // `npm install -g` mid-session still upgrades the live connection.
97
+ function printManualInstallHints() {
149
98
  const installedViaMarketplace = fs.existsSync(
150
99
  path.join(__dirname, '..', '.claude-plugin', 'plugin.json')
151
100
  );
@@ -155,9 +104,9 @@ if (!binary) {
155
104
  // npm package does not exist, so the generic "npm install @sdsrs/code-graph-<plat>-<arch>"
156
105
  // suggestion below would point at a nonexistent package. Show the source/emulation hint.
157
106
  process.stderr.write('[code-graph] Binary not found.\n' + platformHint + '\n');
158
- process.exit(1);
107
+ return;
159
108
  }
160
- process.stderr.write('[code-graph] Binary not found. Install manually:\n');
109
+ process.stderr.write('[code-graph] Binary install failed. Install manually:\n');
161
110
  if (installedViaMarketplace) {
162
111
  process.stderr.write(
163
112
  ' # Re-install the plugin via Claude Code marketplace:\n' +
@@ -171,7 +120,67 @@ if (!binary) {
171
120
  ' npm install -g @sdsrs/code-graph\n' +
172
121
  ' npm install -g @sdsrs/code-graph-' + process.platform + '-' + process.arch + '\n'
173
122
  );
174
- process.exit(1);
123
+ }
124
+
125
+ // --- Missing binary: answer the handshake NOW, install in the background ----
126
+ // The old chain ran `npm install -g` (60s timeout) and the GitHub-release
127
+ // fallback (90s) SYNCHRONOUSLY before answering any MCP JSON-RPC. Claude
128
+ // Code's connect timeout is 30s, so a cold install always presented as
129
+ // "MCP server connection timed out after 30000ms" and the tools only appeared
130
+ // on a later reconnect. Serve the upgradeable 0-tool stub first (initialize is
131
+ // answered instantly), run the same install chain in the background, and hand
132
+ // the live connection to the real binary via the same upgrade mechanism the
133
+ // non-project gate uses — no reconnect, no restart.
134
+ //
135
+ // --install-missing bypasses auto-update.js's isDevMode() short-circuit. The
136
+ // marketplace ships the full repo (including Cargo.toml at the workspace root),
137
+ // so dev-mode heuristics that look for Cargo.toml were misclassifying every
138
+ // marketplace install as dev mode and skipping this fallback (issue #12).
139
+ if (!binary) {
140
+ let version = 'latest';
141
+ try {
142
+ const pj = path.join(__dirname, '..', '.claude-plugin', 'plugin.json');
143
+ version = JSON.parse(fs.readFileSync(pj, 'utf8')).version || 'latest';
144
+ } catch { /* use latest */ }
145
+
146
+ process.stderr.write(
147
+ `[code-graph] Binary not found — serving 0-tool stub while installing ` +
148
+ `@sdsrs/code-graph@${version} in the background (tools appear when it lands)...\n`
149
+ );
150
+
151
+ const stub = serveEmptyMcpStub({
152
+ upgrade: {
153
+ shouldUpgrade: () => !!findBinary(),
154
+ spawnReal: () => {
155
+ const bin = findBinary();
156
+ if (!bin) return null;
157
+ process.stderr.write(`[code-graph] binary ready at ${bin} — upgrading plugin MCP to real tools (restart Claude Code for full tool steering)\n`);
158
+ return spawn(bin, ['serve'], { stdio: ['pipe', 'pipe', 'inherit'], env: process.env });
159
+ },
160
+ },
161
+ });
162
+
163
+ const { GLOBAL_INSTALL_MARKER, INSTALL_LOCK_FILE } = require('./lifecycle');
164
+ installBinaryInBackground({
165
+ version,
166
+ findBinary,
167
+ clearCache,
168
+ // Nudge the handover immediately instead of waiting for the stub's next poll.
169
+ onInstalled: () => stub.attemptUpgrade(),
170
+ onFailed: () => printManualInstallHints(),
171
+ // Marker: this npm install was OURS, so lifecycle.js uninstall knows it
172
+ // owns removing the global packages (never yanks a user's own install).
173
+ recordGlobalInstall: () => {
174
+ fs.mkdirSync(path.dirname(GLOBAL_INSTALL_MARKER), { recursive: true });
175
+ fs.writeFileSync(GLOBAL_INSTALL_MARKER, JSON.stringify({
176
+ installedBy: 'code-graph-mcp launcher', version, at: new Date().toISOString(),
177
+ }, null, 2) + '\n');
178
+ },
179
+ // Serialize against other cold sessions + auto-update (parallel global npm
180
+ // installs corrupt the shared prefix).
181
+ lockPath: INSTALL_LOCK_FILE,
182
+ });
183
+ return; // top-level function scope of mcp-launcher.js
175
184
  }
176
185
 
177
186
  // Pre-spawn: verify binary is executable (catches macOS quarantine, permission issues)
@@ -0,0 +1,15 @@
1
+ 'use strict';
2
+ // npm is `npm.cmd` on Windows: child_process spawn/execFileSync cannot exec a
3
+ // .cmd without a shell (and Node >= 18.20 throws EINVAL spawning .cmd directly
4
+ // as a CVE-2024-27980 mitigation). Every bare `spawn('npm', ...)` in the
5
+ // install/update flow therefore silently ENOENT'd on Windows while
6
+ // commandExists('npm') (via `where`) said npm was present. All args routed
7
+ // through here are fixed flags / package specs — shell-quoting-safe.
8
+ const NPM_NEEDS_SHELL = process.platform === 'win32';
9
+
10
+ /** Merge shell:true into spawn/exec options when the platform needs it. */
11
+ function npmSpawnOpts(opts = {}) {
12
+ return NPM_NEEDS_SHELL ? { ...opts, shell: true } : opts;
13
+ }
14
+
15
+ module.exports = { npmSpawnOpts, NPM_NEEDS_SHELL };
@@ -480,10 +480,13 @@ function runSessionInit({ source } = {}) {
480
480
  cleanupDisabledStatusline();
481
481
  // Genuine uninstall (not a temporary disable) leaves residue the settings-only
482
482
  // self-heal can't reach: ~/.cache/code-graph (the ~40MB binary + state) and the
483
- // current project's CLAUDE.md adoption block. CC fires no uninstall hook, so this
484
- // SessionStart is the only automated teardown symmetric to install's auto-adopt.
485
- // Per-project: only the cwd we're in; other adopted projects self-clean when next
486
- // opened, or via `code-graph-mcp unadopt`.
483
+ // current project's CLAUDE.md adoption block. CC fires no uninstall hook, AND it
484
+ // stops loading this plugin's hooks.json the moment the install record is gone
485
+ // so after a real `/plugin uninstall` this SessionStart usually never runs again.
486
+ // The reachable teardown is cleanupDisabledStatusline() via the composite
487
+ // statusline (still wired in settings.json); it removes the cache residue too.
488
+ // This branch remains for the disable→uninstall-while-running edge and as the
489
+ // only place project unadoption can happen automatically.
487
490
  let teardown = null;
488
491
  if (uninstalled) {
489
492
  const cacheRemoved = removeCacheResidue();
@@ -57,13 +57,31 @@ const codeGraphDir = path.join(root, '.code-graph');
57
57
  // Check for background indexing progress file first
58
58
  const progressFile = path.join(codeGraphDir, 'indexing-status.json');
59
59
  try {
60
- const raw = fs.readFileSync(progressFile, 'utf8');
61
- const p = JSON.parse(raw);
62
- if (p.s === 'indexing' && p.t > 0) {
63
- const pct = Math.round((p.d / p.t) * 100);
60
+ // Staleness gate: the file is normally deleted by the server's IndexGuard, but
61
+ // a killed process (session exit, SIGKILL, the 30s MCP connect-timeout kill)
62
+ // skips Drop, and the orphan would pin "indexing N/M" here forever. A LIVE
63
+ // indexer heartbeats the file at least once per batch and per finalize phase,
64
+ // so an old mtime proves no indexer is writing it: ignore the file and fall
65
+ // through to the health check. (Mirrors INDEXING_STATUS_STALE_SECS in
66
+ // src/indexer/pipeline/mod.rs, which drives server/CLI-side stale cleanup.)
67
+ const INDEXING_STALE_MS = 120000;
68
+ const fresh = (Date.now() - fs.statSync(progressFile).mtimeMs) < INDEXING_STALE_MS;
69
+ const p = fresh ? JSON.parse(fs.readFileSync(progressFile, 'utf8')) : null;
70
+ if (p && p.s === 'indexing' && p.t > 0) {
71
+ // floor, not round: skipped files (parse errors, oversized) keep d below t
72
+ // even in the terminal progress write, and rounding displayed that state as
73
+ // a confusing stuck "100%".
74
+ const pct = Math.floor((p.d / p.t) * 100);
64
75
  process.stdout.write(`code-graph: \u21BB indexing ${p.d}/${p.t} (${pct}%)`);
65
76
  process.exit(0);
66
77
  }
78
+ if (p && p.s === 'finalizing' && p.t > 0) {
79
+ // Post-batch full-graph phases (context strings, import bind/prune, ANALYZE):
80
+ // the file count no longer moves, so show an explicit phase label instead of
81
+ // a frozen-looking counter.
82
+ process.stdout.write(`code-graph: ↻ finalizing ${p.d}/${p.t}`);
83
+ process.exit(0);
84
+ }
67
85
  } catch { /* no progress file or parse error — continue to health check */ }
68
86
 
69
87
  // No indexing in progress — show normal health status
@@ -131,8 +149,14 @@ function statusUnavailable(errText) {
131
149
  let report = null;
132
150
  let errText = '';
133
151
  try {
152
+ // 1500ms, NOT 3000ms: the composite wrapper kills this whole provider at
153
+ // 3000ms (statusline-composite.js runProvider), so an inner budget equal to
154
+ // the outer one guaranteed the OUTER timeout fired first on a slow
155
+ // health-check (e.g. CPU saturated by the embedding backfill) and the segment
156
+ // silently vanished. Keeping the inner budget well under the outer one turns
157
+ // "slow health-check" into a rendered "offline"/"updating" instead of a blank.
134
158
  report = parseReport(execFileSync(bin, ['health-check', '--format', 'json'], {
135
- timeout: 3000,
159
+ timeout: 1500,
136
160
  stdio: ['pipe', 'pipe', 'pipe'],
137
161
  // Run the binary FROM the resolved root so its own project-root resolution
138
162
  // lands on the same DB the gate above picked (a subdir cwd would otherwise
@@ -3,12 +3,20 @@ const { execFileSync } = require('child_process');
3
3
  const fs = require('fs');
4
4
  const path = require('path');
5
5
 
6
- const VERSION_OUTPUT_RE = /^code-graph-mcp\s+(\d+\.\d+\.\d+)$/;
6
+ // Tolerant match: the version line anywhere in the output (m flag), optional
7
+ // "v" prefix, and anything after the numeric triple (build-metadata suffixes
8
+ // like "1.2.3 (abc123)" or "-dev"). The old fully-anchored /^...$/ turned ANY
9
+ // deviation into null, which upstream reads as "broken binary" → judged
10
+ // permanently stale → re-download every session, with the fresh download
11
+ // rejected by the same parse: a self-sustaining loop.
12
+ const VERSION_OUTPUT_RE = /^code-graph-mcp\s+v?(\d+\.\d+\.\d+)/m;
7
13
 
8
14
  function readBinaryVersion(binaryPath) {
9
15
  try {
10
16
  const out = execFileSync(binaryPath, ['--version'], {
11
- timeout: 2000,
17
+ // 5s: a cold exec of a freshly-written ~40MB binary (page-in, Windows AV
18
+ // scan) regularly exceeded the old 2s, misclassifying a good binary.
19
+ timeout: 5000,
12
20
  stdio: ['pipe', 'pipe', 'pipe'],
13
21
  }).toString().trim();
14
22
  const match = out.match(VERSION_OUTPUT_RE);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sdsrs/code-graph",
3
- "version": "0.101.0",
3
+ "version": "0.102.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": {
@@ -36,10 +36,10 @@
36
36
  "node": ">=16"
37
37
  },
38
38
  "optionalDependencies": {
39
- "@sdsrs/code-graph-linux-x64": "0.101.0",
40
- "@sdsrs/code-graph-linux-arm64": "0.101.0",
41
- "@sdsrs/code-graph-darwin-x64": "0.101.0",
42
- "@sdsrs/code-graph-darwin-arm64": "0.101.0",
43
- "@sdsrs/code-graph-win32-x64": "0.101.0"
39
+ "@sdsrs/code-graph-linux-x64": "0.102.0",
40
+ "@sdsrs/code-graph-linux-arm64": "0.102.0",
41
+ "@sdsrs/code-graph-darwin-x64": "0.102.0",
42
+ "@sdsrs/code-graph-darwin-arm64": "0.102.0",
43
+ "@sdsrs/code-graph-win32-x64": "0.102.0"
44
44
  }
45
45
  }