@sdsrs/code-graph 0.5.26 → 0.5.28

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/README.md CHANGED
@@ -118,7 +118,7 @@ What you get:
118
118
  - **Code Explorer Agent** — Deep code understanding expert via `code-explorer`
119
119
  - **Auto-indexing Hook** — Incremental index on every file edit (PostToolUse)
120
120
  - **StatusLine** — Real-time health display (nodes, files, watch status) — compatible with other plugins' StatusLine via composite multiplexer
121
- - **Auto-update** — Checks for new versions every 24h, updates silently
121
+ - **Auto-update** — Checks for new versions every 6h, updates silently
122
122
 
123
123
  #### Manual Update
124
124
 
package/bin/cli.js CHANGED
@@ -1,62 +1,23 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- const { execFileSync, spawn } = require("child_process");
3
+ const { spawn } = require("child_process");
4
4
  const path = require("path");
5
- const fs = require("fs");
6
- const os = require("os");
7
5
 
8
- function getBinaryName() {
9
- return os.platform() === "win32" ? "code-graph-mcp.exe" : "code-graph-mcp";
10
- }
11
-
12
- function findBinary() {
13
- const binaryName = getBinaryName();
14
-
15
- // 1. Check platform-specific npm package (code-graph-<os>-<arch>)
16
- const platformPkg = `@sdsrs/code-graph-${os.platform()}-${os.arch()}`;
17
- try {
18
- const pkgPath = require.resolve(`${platformPkg}/package.json`);
19
- const pkgDir = path.dirname(pkgPath);
20
- const platBinary = path.join(pkgDir, binaryName);
21
- if (fs.existsSync(platBinary)) {
22
- return platBinary;
23
- }
24
- } catch {
25
- // Platform package not installed
26
- }
27
-
28
- // 2. Check bundled binary in the same directory
29
- const bundled = path.join(__dirname, binaryName);
30
- if (fs.existsSync(bundled)) {
31
- return bundled;
32
- }
6
+ // Tell find-binary.js our package root so it can locate bundled binaries
7
+ // and detect dev mode from bin/ → repo root (one level up)
8
+ process.env._FIND_BINARY_ROOT = path.resolve(__dirname, "..");
33
9
 
34
- // 3. Check cargo build output (for development)
35
- const cargoRelease = path.join(__dirname, "..", "target", "release", binaryName);
36
- if (fs.existsSync(cargoRelease)) {
37
- return cargoRelease;
38
- }
39
-
40
- // 4. Check if available in PATH
41
- try {
42
- const which = os.platform() === "win32" ? "where" : "which";
43
- const result = execFileSync(which, [binaryName], { encoding: "utf8" }).trim();
44
- if (result) return result;
45
- } catch {
46
- // not in PATH
47
- }
48
-
49
- return null;
50
- }
10
+ const { findBinary } = require("../claude-plugin/scripts/find-binary");
51
11
 
52
12
  const binary = findBinary();
53
13
 
54
14
  if (!binary) {
55
15
  console.error(
56
16
  "Error: code-graph-mcp binary not found.\n\n" +
17
+ "To install:\n" +
18
+ " npm install -g @sdsrs/code-graph\n\n" +
57
19
  "To build from source:\n" +
58
- " cargo build --release --no-default-features\n\n" +
59
- "Or install the platform-specific binary."
20
+ " cargo build --release\n"
60
21
  );
61
22
  process.exit(1);
62
23
  }
@@ -4,6 +4,12 @@
4
4
  "author": {
5
5
  "name": "sdsrs"
6
6
  },
7
- "version": "0.5.26",
8
- "keywords": ["code-graph", "ast", "navigation", "mcp", "knowledge-graph"]
7
+ "version": "0.5.28",
8
+ "keywords": [
9
+ "code-graph",
10
+ "ast",
11
+ "navigation",
12
+ "mcp",
13
+ "knowledge-graph"
14
+ ]
9
15
  }
@@ -66,10 +66,10 @@
66
66
  {
67
67
  "type": "command",
68
68
  "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/session-init.js\"",
69
- "timeout": 20
69
+ "timeout": 5
70
70
  }
71
71
  ],
72
- "description": "Health check, StatusLine registration, and update check at session start"
72
+ "description": "StatusLine self-heal, lifecycle sync, and background update launch at session start"
73
73
  }
74
74
  ]
75
75
  }
@@ -2,17 +2,39 @@
2
2
  'use strict';
3
3
  const { execFileSync } = require('child_process');
4
4
  const fs = require('fs');
5
+ const https = require('https');
5
6
  const path = require('path');
6
7
  const os = require('os');
7
8
  const { CACHE_DIR, PLUGIN_ID, MARKETPLACE_NAME, readManifest, readJson, writeJsonAtomic } = require('./lifecycle');
9
+ const { clearCache: clearBinaryCache } = require('./find-binary');
8
10
 
9
11
  // ── Configuration ──────────────────────────────────────────
10
12
  const GITHUB_REPO = 'sdsrss/code-graph-mcp';
11
- const NPM_PACKAGE = '@sdsrs/code-graph';
12
13
  const STATE_FILE = path.join(CACHE_DIR, 'update-state.json');
13
- const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24h
14
- const RATE_LIMIT_INTERVAL_MS = 6 * 60 * 60 * 1000; // 6h if rate-limited
14
+ const BINARY_CACHE_DIR = path.join(CACHE_DIR, 'bin');
15
+ const CHECK_INTERVAL_MS = 6 * 60 * 60 * 1000; // 6h
16
+ const RATE_LIMIT_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24h if rate-limited
15
17
  const FETCH_TIMEOUT_MS = 3000;
18
+ const VERSION_OUTPUT_RE = /^code-graph-mcp\s+(\d+\.\d+\.\d+)$/;
19
+
20
+ function isSilentMode(argv = process.argv.slice(2), env = process.env) {
21
+ return argv.includes('--silent') || env.CODE_GRAPH_AUTO_UPDATE_SILENT === '1';
22
+ }
23
+
24
+ // ── Platform → GitHub release asset name mapping ──────────
25
+ function getPlatformAssetName() {
26
+ const platform = os.platform();
27
+ const arch = os.arch();
28
+ const key = `${platform}-${arch}`;
29
+ const map = {
30
+ 'linux-x64': 'code-graph-mcp-linux-x64',
31
+ 'linux-arm64': 'code-graph-mcp-linux-arm64',
32
+ 'darwin-x64': 'code-graph-mcp-darwin-x64',
33
+ 'darwin-arm64': 'code-graph-mcp-darwin-arm64',
34
+ 'win32-x64': 'code-graph-mcp-win32-x64.exe',
35
+ };
36
+ return map[key] || null;
37
+ }
16
38
 
17
39
  // ── State Persistence ──────────────────────────────────────
18
40
 
@@ -60,30 +82,67 @@ function compareVersions(a, b) {
60
82
 
61
83
  // ── GitHub API ─────────────────────────────────────────────
62
84
 
63
- async function fetchLatestRelease() {
64
- const url = `https://api.github.com/repos/${GITHUB_REPO}/releases/latest`;
65
- try {
66
- const res = await fetch(url, {
67
- signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
85
+ function requestJson(url, timeoutMs = FETCH_TIMEOUT_MS) {
86
+ return new Promise((resolve, reject) => {
87
+ const req = https.request(url, {
88
+ method: 'GET',
68
89
  headers: {
69
90
  'Accept': 'application/vnd.github+json',
70
91
  'User-Agent': 'code-graph-auto-update/1.0',
71
92
  },
93
+ }, (res) => {
94
+ let body = '';
95
+ res.setEncoding('utf8');
96
+ res.on('data', (chunk) => { body += chunk; });
97
+ res.on('end', () => {
98
+ if (!res.statusCode) {
99
+ reject(new Error('missing status code'));
100
+ return;
101
+ }
102
+ resolve({ statusCode: res.statusCode, body });
103
+ });
72
104
  });
73
105
 
74
- if (res.status === 403) {
75
- // Rate limited
106
+ req.setTimeout(timeoutMs, () => req.destroy(new Error('request timeout')));
107
+ req.on('error', reject);
108
+ req.end();
109
+ });
110
+ }
111
+
112
+ function parseLatestRelease(data, assetName = getPlatformAssetName()) {
113
+ if (!data || typeof data.tag_name !== 'string' || typeof data.tarball_url !== 'string') {
114
+ return null;
115
+ }
116
+
117
+ let binaryUrl = null;
118
+ if (assetName && Array.isArray(data.assets)) {
119
+ const asset = data.assets.find((entry) => entry && entry.name === assetName);
120
+ if (asset && typeof asset.browser_download_url === 'string') {
121
+ binaryUrl = asset.browser_download_url;
122
+ }
123
+ }
124
+
125
+ return {
126
+ version: data.tag_name.replace(/^v/, ''),
127
+ tarballUrl: data.tarball_url,
128
+ binaryUrl,
129
+ };
130
+ }
131
+
132
+ async function fetchLatestRelease(requestJsonFn = requestJson) {
133
+ const url = `https://api.github.com/repos/${GITHUB_REPO}/releases/latest`;
134
+ try {
135
+ const res = await requestJsonFn(url, FETCH_TIMEOUT_MS);
136
+
137
+ if (res.statusCode === 403) {
76
138
  const state = readState();
77
139
  saveState({ ...state, rateLimited: true });
78
140
  return null;
79
141
  }
80
- if (!res.ok) return null;
142
+ if (res.statusCode < 200 || res.statusCode >= 300) return null;
81
143
 
82
- const data = await res.json();
83
- return {
84
- version: data.tag_name.replace(/^v/, ''),
85
- tarballUrl: data.tarball_url,
86
- };
144
+ const data = JSON.parse(res.body);
145
+ return parseLatestRelease(data);
87
146
  } catch { return null; }
88
147
  }
89
148
 
@@ -102,14 +161,60 @@ function copyDirSync(src, dst) {
102
161
  }
103
162
  }
104
163
 
164
+ function getExtractedPluginVersion(pluginSrc) {
165
+ const manifest = readJson(path.join(pluginSrc, '.claude-plugin', 'plugin.json'));
166
+ return manifest && typeof manifest.version === 'string' ? manifest.version : null;
167
+ }
168
+
169
+ function readBinaryVersion(binaryPath) {
170
+ try {
171
+ const out = execFileSync(binaryPath, ['--version'], {
172
+ timeout: 2000,
173
+ stdio: ['pipe', 'pipe', 'pipe'],
174
+ }).toString().trim();
175
+ const match = out.match(VERSION_OUTPUT_RE);
176
+ return match ? match[1] : null;
177
+ } catch {
178
+ return null;
179
+ }
180
+ }
181
+
182
+ function promoteVerifiedBinary(binaryTmp, binaryDst, expectedVersion) {
183
+ try {
184
+ const stat = fs.statSync(binaryTmp);
185
+ if (stat.size <= 1_000_000) return false;
186
+
187
+ const actualVersion = readBinaryVersion(binaryTmp);
188
+ if (!actualVersion || (expectedVersion && actualVersion !== expectedVersion)) {
189
+ return false;
190
+ }
191
+
192
+ fs.renameSync(binaryTmp, binaryDst);
193
+ if (os.platform() !== 'win32') {
194
+ fs.chmodSync(binaryDst, 0o755);
195
+ }
196
+ clearBinaryCache();
197
+ return true;
198
+ } catch {
199
+ return false;
200
+ } finally {
201
+ try {
202
+ if (fs.existsSync(binaryTmp)) fs.unlinkSync(binaryTmp);
203
+ } catch { /* ok */ }
204
+ }
205
+ }
206
+
105
207
  // ── Download & Install ─────────────────────────────────────
106
208
 
107
209
  async function downloadAndInstall(latest) {
108
210
  const tmpDir = path.join(os.tmpdir(), `code-graph-update-${Date.now()}`);
211
+ let pluginUpdated = false;
212
+ let binaryUpdated = false;
213
+
109
214
  try {
110
215
  fs.mkdirSync(tmpDir, { recursive: true });
111
216
 
112
- // 1. Download tarball (safe: no shell interpolation)
217
+ // ── Step 1: Download and install plugin files from tarball ──
113
218
  const tarballPath = path.join(tmpDir, 'release.tar.gz');
114
219
  execFileSync('curl', [
115
220
  '-sL', '-o', tarballPath,
@@ -117,23 +222,22 @@ async function downloadAndInstall(latest) {
117
222
  latest.tarballUrl,
118
223
  ], { timeout: 30000, stdio: 'pipe' });
119
224
 
120
- // 2. Extract tarball
121
225
  execFileSync('tar', [
122
226
  'xzf', tarballPath, '-C', tmpDir, '--strip-components=1',
123
227
  ], { timeout: 15000, stdio: 'pipe' });
124
228
 
125
- // 3. Copy plugin files to cache (cross-platform)
126
229
  const pluginSrc = path.join(tmpDir, 'claude-plugin');
127
230
  const pluginDst = path.join(
128
231
  os.homedir(), '.claude', 'plugins', 'cache', MARKETPLACE_NAME, 'code-graph-mcp', latest.version
129
232
  );
130
233
 
131
- if (fs.existsSync(pluginSrc)) {
234
+ if (fs.existsSync(pluginSrc) && getExtractedPluginVersion(pluginSrc) === latest.version) {
132
235
  fs.mkdirSync(pluginDst, { recursive: true });
133
236
  copyDirSync(pluginSrc, pluginDst);
237
+ pluginUpdated = true;
134
238
  }
135
239
 
136
- // 4. Update installed_plugins.json to point to new version
240
+ // Update installed_plugins.json to point to new version
137
241
  const installedPath = path.join(os.homedir(), '.claude', 'plugins', 'installed_plugins.json');
138
242
  try {
139
243
  const installed = readJson(installedPath);
@@ -143,30 +247,41 @@ async function downloadAndInstall(latest) {
143
247
  installed.plugins[PLUGIN_ID][0].lastUpdated = new Date().toISOString();
144
248
  writeJsonAtomic(installedPath, installed);
145
249
  }
146
- } catch { /* installed_plugins update failed — not fatal */ }
250
+ } catch { /* not fatal */ }
147
251
 
148
- // 5. Update install manifest with tag version
252
+ // Update install manifest
149
253
  try {
150
254
  const manifest = readManifest();
151
255
  manifest.version = latest.version;
152
256
  manifest.updatedAt = new Date().toISOString();
153
257
  writeJsonAtomic(path.join(CACHE_DIR, 'install-manifest.json'), manifest);
154
- } catch { /* manifest update failed — not fatal */ }
155
-
156
- // 6. Update npm binary (non-blocking, best-effort)
157
- try {
158
- execFileSync('npm', ['install', '-g', `${NPM_PACKAGE}@${latest.version}`], {
159
- timeout: 60000,
160
- stdio: 'pipe',
161
- });
162
- } catch {
163
- // npm install failed — plugin files still updated
164
- // User can manually update binary later
258
+ } catch { /* not fatal */ }
259
+
260
+ // ── Step 2: Download platform binary directly from GitHub release ──
261
+ if (latest.binaryUrl) {
262
+ try {
263
+ const binaryName = os.platform() === 'win32' ? 'code-graph-mcp.exe' : 'code-graph-mcp';
264
+ const binaryDst = path.join(BINARY_CACHE_DIR, binaryName);
265
+ const binaryTmp = binaryDst + '.tmp.' + process.pid;
266
+
267
+ fs.mkdirSync(BINARY_CACHE_DIR, { recursive: true });
268
+ execFileSync('curl', [
269
+ '-sL', '-o', binaryTmp,
270
+ latest.binaryUrl,
271
+ ], { timeout: 60000, stdio: 'pipe' });
272
+
273
+ if (promoteVerifiedBinary(binaryTmp, binaryDst, latest.version)) {
274
+ binaryUpdated = true;
275
+ }
276
+ } catch {
277
+ // Binary download failed — plugin update still counts as success
278
+ }
165
279
  }
166
280
 
167
- return true;
168
- } catch { return false; }
169
- finally {
281
+ return { pluginUpdated, binaryUpdated };
282
+ } catch {
283
+ return { pluginUpdated: false, binaryUpdated: false };
284
+ } finally {
170
285
  try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch { /* ok */ }
171
286
  }
172
287
  }
@@ -182,7 +297,6 @@ async function checkForUpdate() {
182
297
 
183
298
  // Time-based throttle
184
299
  if (!shouldCheck(state)) {
185
- // Report pending update from previous check
186
300
  if (state.updateAvailable && state.latestVersion) {
187
301
  return { updateAvailable: true, from: state.installedVersion, to: state.latestVersion };
188
302
  }
@@ -202,8 +316,8 @@ async function checkForUpdate() {
202
316
  const hasUpdate = compareVersions(latest.version, currentVersion) > 0;
203
317
 
204
318
  if (hasUpdate) {
205
- // Auto-update
206
- const success = await downloadAndInstall(latest);
319
+ const result = await downloadAndInstall(latest);
320
+ const success = result.pluginUpdated;
207
321
  const newState = {
208
322
  lastCheck: new Date().toISOString(),
209
323
  installedVersion: success ? latest.version : currentVersion,
@@ -211,12 +325,14 @@ async function checkForUpdate() {
211
325
  updateAvailable: !success,
212
326
  lastUpdate: success ? new Date().toISOString() : state.lastUpdate,
213
327
  rateLimited: false,
328
+ binaryUpdated: result.binaryUpdated,
214
329
  };
215
330
  saveState(newState);
216
331
 
217
332
  return {
218
333
  updateAvailable: !success,
219
334
  updated: success,
335
+ binaryUpdated: result.binaryUpdated,
220
336
  from: currentVersion,
221
337
  to: latest.version,
222
338
  };
@@ -237,20 +353,27 @@ async function checkForUpdate() {
237
353
  }
238
354
  }
239
355
 
240
- module.exports = { checkForUpdate, isDevMode, readState };
356
+ module.exports = {
357
+ checkForUpdate, isDevMode, readState, compareVersions,
358
+ getExtractedPluginVersion, readBinaryVersion, promoteVerifiedBinary, isSilentMode,
359
+ requestJson, parseLatestRelease, fetchLatestRelease,
360
+ };
241
361
 
242
362
  // CLI: node auto-update.js [check|status]
243
363
  if (require.main === module) {
244
364
  (async () => {
245
- const cmd = process.argv[2] || 'check';
365
+ const argv = process.argv.slice(2);
366
+ const cmd = argv.find(arg => !arg.startsWith('--')) || 'check';
367
+ const silent = isSilentMode(argv);
246
368
  if (cmd === 'status') {
247
369
  const state = readState();
248
370
  console.log(JSON.stringify(state, null, 2));
249
371
  } else {
250
- console.log('Checking for updates...');
372
+ if (!silent) console.log('Checking for updates...');
251
373
  const result = await checkForUpdate();
374
+ if (silent) return;
252
375
  if (result && result.updated) {
253
- console.log(`Updated: v${result.from} → v${result.to}`);
376
+ console.log(`Updated: v${result.from} → v${result.to} (binary: ${result.binaryUpdated ? 'yes' : 'no'})`);
254
377
  } else if (result && result.updateAvailable) {
255
378
  console.log(`Update available: v${result.to} (auto-install failed)`);
256
379
  } else if (isDevMode()) {
@@ -0,0 +1,97 @@
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 {
9
+ fetchLatestRelease,
10
+ getExtractedPluginVersion,
11
+ parseLatestRelease,
12
+ readBinaryVersion,
13
+ promoteVerifiedBinary,
14
+ } = require('./auto-update');
15
+
16
+ function mkDir(prefix) {
17
+ return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
18
+ }
19
+
20
+ test('getExtractedPluginVersion reads extracted plugin manifest version', () => {
21
+ const root = mkDir('code-graph-plugin-');
22
+ const manifest = path.join(root, '.claude-plugin', 'plugin.json');
23
+ fs.mkdirSync(path.dirname(manifest), { recursive: true });
24
+ fs.writeFileSync(manifest, JSON.stringify({ version: '1.2.3' }, null, 2));
25
+ assert.equal(getExtractedPluginVersion(root), '1.2.3');
26
+ });
27
+
28
+ function writeFakeBinary(filePath, version) {
29
+ const script = [
30
+ '#!/usr/bin/env bash',
31
+ 'if [ "$1" = "--version" ]; then',
32
+ ` echo "code-graph-mcp ${version}"`,
33
+ ' exit 0',
34
+ 'fi',
35
+ 'exit 0',
36
+ `# ${'x'.repeat(1_100_000)}`,
37
+ '',
38
+ ].join('\n');
39
+ fs.writeFileSync(filePath, script);
40
+ fs.chmodSync(filePath, 0o755);
41
+ }
42
+
43
+ test('promoteVerifiedBinary accepts a runnable binary with the expected version', () => {
44
+ const dir = mkDir('code-graph-bin-');
45
+ const tmp = path.join(dir, 'code-graph-mcp.tmp');
46
+ const dst = path.join(dir, 'code-graph-mcp');
47
+ writeFakeBinary(tmp, '1.2.3');
48
+
49
+ assert.equal(readBinaryVersion(tmp), '1.2.3');
50
+ assert.equal(promoteVerifiedBinary(tmp, dst, '1.2.3'), true);
51
+ assert.equal(fs.existsSync(tmp), false);
52
+ assert.equal(fs.existsSync(dst), true);
53
+ });
54
+
55
+ test('promoteVerifiedBinary rejects binaries with mismatched version', () => {
56
+ const dir = mkDir('code-graph-bin-');
57
+ const tmp = path.join(dir, 'code-graph-mcp.tmp');
58
+ const dst = path.join(dir, 'code-graph-mcp');
59
+ writeFakeBinary(tmp, '1.2.2');
60
+
61
+ assert.equal(promoteVerifiedBinary(tmp, dst, '1.2.3'), false);
62
+ assert.equal(fs.existsSync(tmp), false);
63
+ assert.equal(fs.existsSync(dst), false);
64
+ });
65
+
66
+ test('parseLatestRelease selects the matching platform asset', () => {
67
+ const latest = parseLatestRelease({
68
+ tag_name: 'v1.2.3',
69
+ tarball_url: 'https://example.com/tarball.tgz',
70
+ assets: [
71
+ { name: 'code-graph-mcp-linux-x64', browser_download_url: 'https://example.com/linux-x64' },
72
+ { name: 'other', browser_download_url: 'https://example.com/other' },
73
+ ],
74
+ }, 'code-graph-mcp-linux-x64');
75
+
76
+ assert.deepEqual(latest, {
77
+ version: '1.2.3',
78
+ tarballUrl: 'https://example.com/tarball.tgz',
79
+ binaryUrl: 'https://example.com/linux-x64',
80
+ });
81
+ });
82
+
83
+ test('fetchLatestRelease parses JSON without relying on global fetch', async () => {
84
+ const latest = await fetchLatestRelease(async () => ({
85
+ statusCode: 200,
86
+ body: JSON.stringify({
87
+ tag_name: 'v2.0.0',
88
+ tarball_url: 'https://example.com/release.tgz',
89
+ assets: [
90
+ { name: 'code-graph-mcp-linux-x64', browser_download_url: 'https://example.com/bin' },
91
+ ],
92
+ }),
93
+ }));
94
+
95
+ assert.equal(latest.version, '2.0.0');
96
+ assert.equal(latest.tarballUrl, 'https://example.com/release.tgz');
97
+ });
@@ -6,25 +6,43 @@ const fs = require('fs');
6
6
  const os = require('os');
7
7
 
8
8
  const PLATFORM = os.platform();
9
+ const ARCH = os.arch();
9
10
  const CACHE_FILE = path.join(os.homedir(), '.cache', 'code-graph', 'binary-path');
11
+ const BINARY_NAME = PLATFORM === 'win32' ? 'code-graph-mcp.exe' : 'code-graph-mcp';
12
+
13
+ function isNativeBinary(candidate) {
14
+ if (!candidate) return false;
15
+ try {
16
+ if (!fs.existsSync(candidate)) return false;
17
+ const realPath = fs.realpathSync(candidate);
18
+ return path.basename(realPath) === BINARY_NAME;
19
+ } catch {
20
+ return false;
21
+ }
22
+ }
10
23
 
11
24
  /**
12
25
  * Locate the code-graph-mcp binary using multiple strategies.
13
26
  * Results are cached to disk so repeated calls (e.g. per-hook) are fast.
14
- * Priority: cache > PATH > local dev build > cargo install > npm platform pkg > npx cache
27
+ *
28
+ * Priority:
29
+ * cache (if valid) → dev-mode (target/release) → auto-update cache
30
+ * → platform npm pkg → bundled (bin/) → cargo install → PATH → npx cache
31
+ *
15
32
  * Returns the absolute path or null if not found.
16
33
  */
17
34
  function findBinary() {
18
35
  // Try disk cache first (avoids spawning `which` on hot paths)
19
36
  try {
20
37
  const cached = fs.readFileSync(CACHE_FILE, 'utf8').trim();
21
- if (cached && fs.existsSync(cached)) return cached;
38
+ if (isNativeBinary(cached)) return cached;
39
+ if (cached) clearCache();
22
40
  } catch { /* no cache or stale */ }
23
41
 
24
42
  const result = findBinaryUncached();
25
43
 
26
44
  // Write cache for subsequent calls
27
- if (result) {
45
+ if (isNativeBinary(result)) {
28
46
  try {
29
47
  fs.mkdirSync(path.dirname(CACHE_FILE), { recursive: true });
30
48
  fs.writeFileSync(CACHE_FILE, result);
@@ -34,50 +52,95 @@ function findBinary() {
34
52
  return result;
35
53
  }
36
54
 
55
+ /**
56
+ * Detect if we're running from the source repo (e.g. npm link).
57
+ * Checks relative to a given root directory for Cargo.toml.
58
+ */
59
+ function isDevRepo(rootDir) {
60
+ return fs.existsSync(path.join(rootDir, 'Cargo.toml'));
61
+ }
62
+
37
63
  function findBinaryUncached() {
38
- const name = PLATFORM === 'win32' ? 'code-graph-mcp.exe' : 'code-graph-mcp';
64
+ // --- Dev mode: always prefer cargo build output when running from source repo ---
65
+ // This covers: npm link, direct invocation from repo, CLAUDE_PROJECT_DIR set to repo
66
+ const possibleRoots = new Set();
39
67
 
40
- // 1. PATH lookup (user has intentionally installed it)
41
- try {
42
- const which = PLATFORM === 'win32' ? 'where' : 'which';
43
- const found = execFileSync(which, [name], { stdio: ['pipe', 'pipe', 'pipe'] })
44
- .toString().trim().split('\n')[0];
45
- if (found && fs.existsSync(found)) return found;
46
- } catch { /* not in PATH */ }
68
+ // From plugin scripts context (claude-plugin/scripts/ repo root is ../..)
69
+ possibleRoots.add(path.resolve(__dirname, '..', '..'));
70
+ // From bin/ context (cli.js sets FIND_BINARY_ROOT)
71
+ if (process.env._FIND_BINARY_ROOT) {
72
+ possibleRoots.add(path.resolve(process.env._FIND_BINARY_ROOT));
73
+ }
74
+ // From CLAUDE_PROJECT_DIR
75
+ if (process.env.CLAUDE_PROJECT_DIR) {
76
+ possibleRoots.add(path.resolve(process.env.CLAUDE_PROJECT_DIR));
77
+ }
47
78
 
48
- // 2. Local dev build (target/release in project directory)
49
- const projectRoot = process.env.CLAUDE_PROJECT_DIR || process.cwd();
50
- const devBin = path.join(projectRoot, 'target', 'release', name);
51
- if (fs.existsSync(devBin)) return devBin;
79
+ for (const root of possibleRoots) {
80
+ if (isDevRepo(root)) {
81
+ const devBin = path.join(root, 'target', 'release', BINARY_NAME);
82
+ if (isNativeBinary(devBin)) return devBin;
83
+ }
84
+ }
52
85
 
53
- // 3. Cargo install (~/.cargo/bin)
54
- const cargoBin = path.join(os.homedir(), '.cargo', 'bin', name);
55
- if (fs.existsSync(cargoBin)) return cargoBin;
86
+ // --- Auto-update cache (binary downloaded directly from GitHub release) ---
87
+ const autoUpdateBin = path.join(os.homedir(), '.cache', 'code-graph', 'bin', BINARY_NAME);
88
+ if (isNativeBinary(autoUpdateBin)) return autoUpdateBin;
56
89
 
57
- // 4. npm platform package (installed via @sdsrs/code-graph)
58
- const platformPkg = `@sdsrs/code-graph-${PLATFORM}-${os.arch()}`;
90
+ // --- Platform-specific npm package (@sdsrs/code-graph-{os}-{arch}) ---
91
+ const platformPkg = `@sdsrs/code-graph-${PLATFORM}-${ARCH}`;
59
92
  try {
60
93
  const pkgPath = require.resolve(`${platformPkg}/package.json`);
61
- const bin = path.join(path.dirname(pkgPath), name);
62
- if (fs.existsSync(bin)) return bin;
94
+ const bin = path.join(path.dirname(pkgPath), BINARY_NAME);
95
+ if (isNativeBinary(bin)) return bin;
63
96
  } catch { /* not installed via npm */ }
64
97
 
65
- // 5. npx cache (last resort may be outdated)
98
+ // --- Bundled binary (in same directory as cli.js or plugin scripts) ---
99
+ // Check bin/ directory of the npm package
100
+ const binDirs = new Set();
101
+ if (process.env._FIND_BINARY_ROOT) {
102
+ binDirs.add(path.join(process.env._FIND_BINARY_ROOT, 'bin'));
103
+ }
104
+ binDirs.add(path.resolve(__dirname, '..', '..', 'bin'));
105
+ for (const dir of binDirs) {
106
+ const bundled = path.join(dir, BINARY_NAME);
107
+ if (isNativeBinary(bundled)) return bundled;
108
+ }
109
+
110
+ // --- Cargo install (~/.cargo/bin) ---
111
+ const cargoBin = path.join(os.homedir(), '.cargo', 'bin', BINARY_NAME);
112
+ if (isNativeBinary(cargoBin)) return cargoBin;
113
+
114
+ // --- PATH lookup (last resort for intentionally installed binaries) ---
115
+ try {
116
+ const which = PLATFORM === 'win32' ? 'where' : 'which';
117
+ const found = execFileSync(which, [BINARY_NAME], { stdio: ['pipe', 'pipe', 'pipe'] })
118
+ .toString().trim().split('\n')[0];
119
+ if (isNativeBinary(found)) return found;
120
+ } catch { /* not in PATH */ }
121
+
122
+ // --- npx cache (very last resort — may be outdated) ---
66
123
  const npxDir = path.join(os.homedir(), '.npm', '_npx');
67
124
  try {
68
125
  for (const entry of fs.readdirSync(npxDir)) {
69
- const pkgJsonPath = path.join(npxDir, entry, 'node_modules', '@sdsrs', 'code-graph', 'package.json');
70
- if (!fs.existsSync(pkgJsonPath)) continue;
71
- const platDir = path.join(npxDir, entry, 'node_modules', '@sdsrs', `code-graph-${PLATFORM}-${os.arch()}`);
72
- const platBin = path.join(platDir, name);
73
- if (fs.existsSync(platBin)) return platBin;
126
+ const platDir = path.join(npxDir, entry, 'node_modules', '@sdsrs', `code-graph-${PLATFORM}-${ARCH}`);
127
+ const platBin = path.join(platDir, BINARY_NAME);
128
+ if (isNativeBinary(platBin)) return platBin;
74
129
  }
75
130
  } catch { /* no npx cache */ }
76
131
 
77
132
  return null;
78
133
  }
79
134
 
80
- module.exports = { findBinary };
135
+ /**
136
+ * Clear the disk cache. Call this after binary updates so the next
137
+ * findBinary() picks up the new location.
138
+ */
139
+ function clearCache() {
140
+ try { fs.unlinkSync(CACHE_FILE); } catch { /* ok */ }
141
+ }
142
+
143
+ module.exports = { findBinary, findBinaryUncached, clearCache, CACHE_FILE, BINARY_NAME };
81
144
 
82
145
  // Allow direct invocation for testing
83
146
  if (require.main === module) {
@@ -0,0 +1,91 @@
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
+ const { execFileSync } = require('child_process');
8
+
9
+ const repoRoot = path.resolve(__dirname, '..', '..');
10
+ const pluginRoot = path.resolve(__dirname, '..');
11
+ const lifecycleCli = path.join(__dirname, 'lifecycle.js');
12
+ const compositeCli = path.join(__dirname, 'statusline-composite.js');
13
+ const currentVersion = JSON.parse(fs.readFileSync(path.join(repoRoot, 'package.json'), 'utf8')).version;
14
+
15
+ function mkHome() {
16
+ return fs.mkdtempSync(path.join(os.tmpdir(), 'code-graph-e2e-'));
17
+ }
18
+
19
+ function writeJson(filePath, value) {
20
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
21
+ fs.writeFileSync(filePath, JSON.stringify(value, null, 2) + '\n');
22
+ }
23
+
24
+ function readJson(filePath) {
25
+ return JSON.parse(fs.readFileSync(filePath, 'utf8'));
26
+ }
27
+
28
+ function runScript(homeDir, scriptPath, args = [], options = {}) {
29
+ return execFileSync(process.execPath, [scriptPath, ...args], {
30
+ cwd: options.cwd || repoRoot,
31
+ env: { ...process.env, HOME: homeDir, CLAUDE_PLUGIN_ROOT: pluginRoot },
32
+ input: options.input,
33
+ stdio: ['pipe', 'pipe', 'pipe'],
34
+ }).toString();
35
+ }
36
+
37
+ test('lifecycle CLI handles install, disable self-heal, re-enable, and uninstall', () => {
38
+ const homeDir = mkHome();
39
+ const settingsPath = path.join(homeDir, '.claude', 'settings.json');
40
+ const installedPath = path.join(homeDir, '.claude', 'plugins', 'installed_plugins.json');
41
+ const registryPath = path.join(homeDir, '.cache', 'code-graph', 'statusline-registry.json');
42
+ const manifestPath = path.join(homeDir, '.cache', 'code-graph', 'install-manifest.json');
43
+ const cacheDir = path.join(homeDir, '.cache', 'code-graph');
44
+
45
+ writeJson(settingsPath, {
46
+ statusLine: { type: 'command', command: 'echo previous-status' },
47
+ enabledPlugins: { 'code-graph-mcp@code-graph-mcp': true },
48
+ });
49
+ writeJson(installedPath, {
50
+ plugins: {
51
+ 'code-graph-mcp@code-graph-mcp': [{
52
+ installPath: pluginRoot,
53
+ version: currentVersion,
54
+ scope: 'user',
55
+ }],
56
+ },
57
+ });
58
+
59
+ runScript(homeDir, lifecycleCli, ['install']);
60
+ let settings = readJson(settingsPath);
61
+ let registry = readJson(registryPath);
62
+ let manifest = readJson(manifestPath);
63
+ assert.match(settings.statusLine.command, /statusline-composite\.js/);
64
+ assert.equal(registry[0].id, '_previous');
65
+ assert.equal(registry[1].id, 'code-graph');
66
+ assert.equal(manifest.version, currentVersion);
67
+
68
+ settings.enabledPlugins['code-graph-mcp@code-graph-mcp'] = false;
69
+ writeJson(settingsPath, settings);
70
+ runScript(homeDir, compositeCli, [], { input: '{}' });
71
+ settings = readJson(settingsPath);
72
+ assert.equal(settings.statusLine.command, 'echo previous-status');
73
+ assert.equal(fs.existsSync(registryPath), false);
74
+
75
+ settings.enabledPlugins['code-graph-mcp@code-graph-mcp'] = true;
76
+ writeJson(settingsPath, settings);
77
+ runScript(homeDir, lifecycleCli, ['install']);
78
+ settings = readJson(settingsPath);
79
+ registry = readJson(registryPath);
80
+ assert.match(settings.statusLine.command, /statusline-composite\.js/);
81
+ assert.equal(registry.length, 2);
82
+
83
+ runScript(homeDir, lifecycleCli, ['uninstall']);
84
+ settings = readJson(settingsPath);
85
+ const installed = readJson(installedPath);
86
+ assert.equal(settings.statusLine.command, 'echo previous-status');
87
+ assert.deepEqual(settings.enabledPlugins, {});
88
+ assert.deepEqual(installed.plugins, {});
89
+ assert.equal(fs.existsSync(cacheDir), false);
90
+ });
91
+
@@ -53,6 +53,15 @@ function codeGraphStatuslineCommand() {
53
53
  return `node ${JSON.stringify(path.join(PLUGIN_ROOT, 'scripts', 'statusline.js'))}`;
54
54
  }
55
55
 
56
+ function hasOwn(obj, key) {
57
+ return !!obj && Object.prototype.hasOwnProperty.call(obj, key);
58
+ }
59
+
60
+ function hasInstalledPluginRecord() {
61
+ const installed = readJson(INSTALLED_PLUGINS_PATH);
62
+ return !!(installed && installed.plugins && Array.isArray(installed.plugins[PLUGIN_ID]) && installed.plugins[PLUGIN_ID].length > 0);
63
+ }
64
+
56
65
  function isOurComposite(settings) {
57
66
  return settings.statusLine &&
58
67
  settings.statusLine.command &&
@@ -67,6 +76,10 @@ function readRegistry() {
67
76
  }
68
77
 
69
78
  function writeRegistry(registry) {
79
+ if (!registry || registry.length === 0) {
80
+ try { fs.unlinkSync(REGISTRY_FILE); } catch { /* ok */ }
81
+ return;
82
+ }
70
83
  writeJsonAtomic(REGISTRY_FILE, registry);
71
84
  }
72
85
 
@@ -93,6 +106,58 @@ function unregisterStatuslineProvider(id) {
93
106
  return true;
94
107
  }
95
108
 
109
+ function isPluginExplicitlyDisabled(settings = readJson(SETTINGS_PATH) || {}) {
110
+ return hasOwn(settings.enabledPlugins, PLUGIN_ID) && settings.enabledPlugins[PLUGIN_ID] === false;
111
+ }
112
+
113
+ function isPluginInactive(settings = readJson(SETTINGS_PATH) || {}) {
114
+ if (isPluginExplicitlyDisabled(settings)) return true;
115
+
116
+ const hasComposite = isOurComposite(settings);
117
+ const hasCodeGraphRegistry = readRegistry().some((provider) => provider.id === 'code-graph');
118
+ if (!hasComposite && !hasCodeGraphRegistry) return false;
119
+
120
+ const installed = readJson(INSTALLED_PLUGINS_PATH);
121
+ if (!installed || !installed.plugins) return false;
122
+ return !hasInstalledPluginRecord();
123
+ }
124
+
125
+ function detachStatuslineIntegration(settings) {
126
+ let settingsChanged = false;
127
+
128
+ unregisterStatuslineProvider('code-graph');
129
+ const previous = readRegistry().find(p => p.id === '_previous' && p.command);
130
+
131
+ // If our composite is still configured while the plugin is disabled/uninstalled,
132
+ // prefer restoring the prior statusline (or removing ours entirely) so the plugin
133
+ // truly stops affecting Claude Code.
134
+ if (isOurComposite(settings)) {
135
+ if (previous) {
136
+ settings.statusLine = { type: 'command', command: previous.command };
137
+ } else {
138
+ delete settings.statusLine;
139
+ }
140
+ settingsChanged = true;
141
+ }
142
+
143
+ unregisterStatuslineProvider('_previous');
144
+ return settingsChanged;
145
+ }
146
+
147
+ function cleanupDisabledStatusline() {
148
+ const settings = readJson(SETTINGS_PATH);
149
+ if (!settings || !isPluginInactive(settings)) {
150
+ return { cleaned: false, settingsChanged: false };
151
+ }
152
+
153
+ const settingsChanged = detachStatuslineIntegration(settings);
154
+ if (settingsChanged) {
155
+ writeJsonAtomic(SETTINGS_PATH, settings);
156
+ }
157
+
158
+ return { cleaned: true, settingsChanged };
159
+ }
160
+
96
161
  // --- Scope Conflict Detection ---
97
162
 
98
163
  function checkScopeConflict() {
@@ -207,22 +272,9 @@ function uninstall() {
207
272
  let settingsChanged = false;
208
273
 
209
274
  if (settings) {
210
- // 1. StatusLine: remove code-graph from registry
211
- unregisterStatuslineProvider('code-graph');
212
- const remaining = readRegistry();
213
-
214
- if (isOurComposite(settings)) {
215
- if (remaining.length === 1 && remaining[0].id === '_previous') {
216
- // Only the previous provider remains — restore it directly
217
- settings.statusLine = { type: 'command', command: remaining[0].command };
218
- unregisterStatuslineProvider('_previous');
219
- settingsChanged = true;
220
- } else if (remaining.length === 0) {
221
- // No providers left — remove statusLine entirely
222
- delete settings.statusLine;
223
- settingsChanged = true;
224
- }
225
- // else: other providers still using composite — leave it
275
+ // 1. StatusLine: remove code-graph integration and restore prior statusline.
276
+ if (detachStatuslineIntegration(settings)) {
277
+ settingsChanged = true;
226
278
  }
227
279
 
228
280
  // 2. Remove all known IDs from enabledPlugins
@@ -317,6 +369,7 @@ function update() {
317
369
 
318
370
  module.exports = {
319
371
  install, uninstall, update, checkScopeConflict,
372
+ isPluginExplicitlyDisabled, isPluginInactive, cleanupDisabledStatusline,
320
373
  readManifest, readJson, writeJsonAtomic,
321
374
  readRegistry, writeRegistry,
322
375
  getPluginVersion,
@@ -0,0 +1,97 @@
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
+ const { execFileSync } = require('child_process');
8
+
9
+ const lifecyclePath = path.join(__dirname, 'lifecycle.js');
10
+ const statuslinePath = path.join(__dirname, 'statusline.js');
11
+
12
+ function mkHome() {
13
+ return fs.mkdtempSync(path.join(os.tmpdir(), 'code-graph-home-'));
14
+ }
15
+
16
+ function writeJson(filePath, value) {
17
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
18
+ fs.writeFileSync(filePath, JSON.stringify(value, null, 2) + '\n');
19
+ }
20
+
21
+ function seedDisabledComposite(homeDir) {
22
+ const settingsPath = path.join(homeDir, '.claude', 'settings.json');
23
+ const registryPath = path.join(homeDir, '.cache', 'code-graph', 'statusline-registry.json');
24
+ writeJson(settingsPath, {
25
+ statusLine: { type: 'command', command: 'node "/plugin/statusline-composite.js"' },
26
+ enabledPlugins: { 'code-graph-mcp@code-graph-mcp': false },
27
+ });
28
+ writeJson(registryPath, [
29
+ { id: '_previous', command: 'echo previous-status', needsStdin: true },
30
+ { id: 'code-graph', command: 'node "/plugin/statusline.js"', needsStdin: false },
31
+ ]);
32
+ return { settingsPath, registryPath };
33
+ }
34
+
35
+ function seedOrphanedComposite(homeDir) {
36
+ const settingsPath = path.join(homeDir, '.claude', 'settings.json');
37
+ const registryPath = path.join(homeDir, '.cache', 'code-graph', 'statusline-registry.json');
38
+ const installedPath = path.join(homeDir, '.claude', 'plugins', 'installed_plugins.json');
39
+ writeJson(settingsPath, {
40
+ statusLine: { type: 'command', command: 'node "/plugin/statusline-composite.js"' },
41
+ enabledPlugins: {},
42
+ });
43
+ writeJson(installedPath, { plugins: {} });
44
+ writeJson(registryPath, [
45
+ { id: '_previous', command: 'echo previous-status', needsStdin: true },
46
+ { id: 'code-graph', command: 'node "/plugin/statusline.js"', needsStdin: false },
47
+ ]);
48
+ return { settingsPath, registryPath };
49
+ }
50
+
51
+ test('cleanupDisabledStatusline restores previous statusline and removes registry', () => {
52
+ const homeDir = mkHome();
53
+ const { settingsPath, registryPath } = seedDisabledComposite(homeDir);
54
+
55
+ const out = execFileSync(process.execPath, ['-e', `
56
+ const { cleanupDisabledStatusline } = require(${JSON.stringify(lifecyclePath)});
57
+ process.stdout.write(JSON.stringify(cleanupDisabledStatusline()));
58
+ `], { env: { ...process.env, HOME: homeDir } }).toString();
59
+
60
+ assert.deepEqual(JSON.parse(out), { cleaned: true, settingsChanged: true });
61
+ const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
62
+ assert.equal(settings.statusLine.command, 'echo previous-status');
63
+ assert.equal(fs.existsSync(registryPath), false);
64
+ });
65
+
66
+ test('statusline exits cleanly and self-heals when plugin is disabled', () => {
67
+ const homeDir = mkHome();
68
+ const { settingsPath, registryPath } = seedDisabledComposite(homeDir);
69
+ const projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'code-graph-project-'));
70
+ fs.mkdirSync(path.join(projectDir, '.code-graph'), { recursive: true });
71
+ fs.writeFileSync(path.join(projectDir, '.code-graph', 'index.db'), '');
72
+
73
+ const stdout = execFileSync(process.execPath, [statuslinePath], {
74
+ env: { ...process.env, HOME: homeDir },
75
+ cwd: projectDir,
76
+ }).toString();
77
+
78
+ assert.equal(stdout, '');
79
+ const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
80
+ assert.equal(settings.statusLine.command, 'echo previous-status');
81
+ assert.equal(fs.existsSync(registryPath), false);
82
+ });
83
+
84
+ test('cleanupDisabledStatusline also heals orphaned statusline after uninstall', () => {
85
+ const homeDir = mkHome();
86
+ const { settingsPath, registryPath } = seedOrphanedComposite(homeDir);
87
+
88
+ const out = execFileSync(process.execPath, ['-e', `
89
+ const { cleanupDisabledStatusline } = require(${JSON.stringify(lifecyclePath)});
90
+ process.stdout.write(JSON.stringify(cleanupDisabledStatusline()));
91
+ `], { env: { ...process.env, HOME: homeDir } }).toString();
92
+
93
+ assert.deepEqual(JSON.parse(out), { cleaned: true, settingsChanged: true });
94
+ const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
95
+ assert.equal(settings.statusLine.command, 'echo previous-status');
96
+ assert.equal(fs.existsSync(registryPath), false);
97
+ });
@@ -1,116 +1,66 @@
1
1
  #!/usr/bin/env node
2
2
  'use strict';
3
- const { execFileSync } = require('child_process');
4
- const fs = require('fs');
3
+ const { spawn } = require('child_process');
5
4
  const path = require('path');
6
- const os = require('os');
7
- const { findBinary } = require('./find-binary');
8
- const { install, update, readManifest, getPluginVersion, checkScopeConflict } = require('./lifecycle');
9
- const { checkForUpdate } = require('./auto-update');
5
+ const {
6
+ install, update, readManifest, getPluginVersion, checkScopeConflict,
7
+ cleanupDisabledStatusline, isPluginInactive,
8
+ } = require('./lifecycle');
10
9
 
11
- let BIN = findBinary();
12
-
13
- // --- 0. Auto-install binary if missing ---
14
- if (!BIN) {
15
- const version = getPluginVersion();
16
- process.stderr.write(`[code-graph] Binary not found, installing @sdsrs/code-graph@${version}...\n`);
10
+ function launchBackgroundAutoUpdate(spawnFn = spawn, env = process.env) {
17
11
  try {
18
- execFileSync('npm', ['install', '-g', `@sdsrs/code-graph@${version}`], {
19
- timeout: 60000, stdio: 'pipe'
12
+ const child = spawnFn(process.execPath, [path.join(__dirname, 'auto-update.js'), 'check', '--silent'], {
13
+ detached: true,
14
+ stdio: 'ignore',
15
+ env: { ...env, CODE_GRAPH_AUTO_UPDATE_SILENT: '1' },
20
16
  });
21
- // Clear cached path so findBinary picks up the new install
22
- try { fs.unlinkSync(path.join(os.homedir(), '.cache', 'code-graph', 'binary-path')); } catch {}
23
- BIN = findBinary();
24
- if (BIN) {
25
- process.stderr.write(`[code-graph] Installed v${version} at ${BIN}\n`);
26
- } else {
27
- process.stderr.write('[code-graph] Install succeeded but binary not found in PATH. Try: npx @sdsrs/code-graph@latest\n');
28
- }
17
+ if (child && typeof child.unref === 'function') child.unref();
18
+ return true;
29
19
  } catch {
30
- process.stderr.write(
31
- `[code-graph] Auto-install failed. Run manually: npm install -g @sdsrs/code-graph@${version}\n`
32
- );
20
+ return false;
33
21
  }
34
22
  }
35
23
 
36
- // --- 1. Health check (always runs) ---
37
- if (BIN) {
38
- try {
39
- const out = execFileSync(BIN, ['health-check', '--format', 'oneline'], {
40
- timeout: 2000,
41
- stdio: ['pipe', 'pipe', 'pipe']
42
- }).toString().trim();
43
- if (out) process.stdout.write(out);
44
- } catch { /* timeout — silent */ }
45
- }
24
+ function syncLifecycleConfig() {
25
+ const manifest = readManifest();
26
+ const currentVersion = getPluginVersion();
46
27
 
47
- // --- 1b. Suggest project_map as first action ---
48
- if (BIN) {
49
- process.stdout.write(
50
- '\n[code-graph] TIP: Call project_map first to get a full architecture overview ' +
51
- '(modules, dependencies, hot functions, entry points) in one call.\n'
52
- );
28
+ if (!manifest.version) {
29
+ install();
30
+ return 'installed';
31
+ }
32
+ if (manifest.version !== currentVersion) {
33
+ update();
34
+ return 'updated';
35
+ }
36
+ return 'noop';
53
37
  }
54
38
 
55
- // --- 1c. Binary version sync (plugin may update before npm binary) ---
56
- if (BIN) {
57
- try {
58
- const binOut = execFileSync(BIN, ['--version'], { timeout: 2000, stdio: 'pipe' }).toString().trim();
59
- const binVersion = binOut.replace(/^code-graph-mcp\s+/, '');
60
- const pluginVersion = getPluginVersion();
61
- if (binVersion && pluginVersion && /^\d+\.\d+\.\d+$/.test(binVersion)) {
62
- const bv = binVersion.split('.').map(Number);
63
- const pv = pluginVersion.split('.').map(Number);
64
- const pluginNewer = (pv[0] > bv[0]) ||
65
- (pv[0] === bv[0] && pv[1] > bv[1]) ||
66
- (pv[0] === bv[0] && pv[1] === bv[1] && pv[2] > bv[2]);
67
- if (pluginNewer) {
68
- process.stderr.write(`[code-graph] Binary v${binVersion} < plugin v${pluginVersion}, updating...\n`);
69
- try {
70
- execFileSync('npm', ['install', '-g', `@sdsrs/code-graph@${pluginVersion}`], {
71
- timeout: 30000, stdio: 'pipe'
72
- });
73
- // Clear cached binary path so next lookup finds the new binary
74
- try { fs.unlinkSync(path.join(os.homedir(), '.cache', 'code-graph', 'binary-path')); } catch {}
75
- process.stderr.write(`[code-graph] Binary updated to v${pluginVersion}\n`);
76
- } catch {
77
- process.stderr.write(
78
- `[code-graph] Auto-update failed. Run: npm install -g @sdsrs/code-graph@${pluginVersion}\n`
79
- );
80
- }
81
- }
82
- }
83
- } catch { /* version check failed — not critical */ }
84
- }
39
+ function runSessionInit() {
40
+ if (isPluginInactive()) {
41
+ cleanupDisabledStatusline();
42
+ return { inactive: true, lifecycle: 'noop', autoUpdateLaunched: false };
43
+ }
44
+
45
+ const conflict = checkScopeConflict();
46
+ if (conflict) {
47
+ process.stderr.write(
48
+ `[code-graph] Warning: conflicting install detected — ${conflict.existingId} (${conflict.scope || 'unknown'} scope). ` +
49
+ `Use /plugin to remove one to avoid config conflicts.\n`
50
+ );
51
+ }
85
52
 
86
- // --- 2. Scope conflict warning ---
87
- const conflict = checkScopeConflict();
88
- if (conflict) {
89
- process.stderr.write(
90
- `[code-graph] Warning: conflicting install detected — ${conflict.existingId} (${conflict.scope || 'unknown'} scope). ` +
91
- `Use /plugin to remove one to avoid config conflicts.\n`
92
- );
53
+ const lifecycle = syncLifecycleConfig();
54
+ const autoUpdateLaunched = launchBackgroundAutoUpdate();
55
+ return { inactive: false, lifecycle, autoUpdateLaunched };
93
56
  }
94
57
 
95
- // --- 3. Lifecycle: install or update config (idempotent) ---
96
- const manifest = readManifest();
97
- const currentVersion = getPluginVersion();
58
+ module.exports = {
59
+ launchBackgroundAutoUpdate,
60
+ syncLifecycleConfig,
61
+ runSessionInit,
62
+ };
98
63
 
99
- if (!manifest.version) {
100
- install();
101
- } else if (manifest.version !== currentVersion) {
102
- update();
64
+ if (require.main === module) {
65
+ runSessionInit();
103
66
  }
104
-
105
- // --- 4. Auto-update (throttled, non-blocking) ---
106
- (async () => {
107
- const result = await checkForUpdate();
108
- if (result && result.updated) {
109
- process.stderr.write(`[code-graph] Updated: v${result.from} \u2192 v${result.to}\n`);
110
- } else if (result && result.updateAvailable) {
111
- process.stderr.write(
112
- `[code-graph] Update available: v${result.from} \u2192 v${result.to}. ` +
113
- `Run: npx @sdsrs/code-graph@latest\n`
114
- );
115
- }
116
- })();
@@ -0,0 +1,35 @@
1
+ 'use strict';
2
+ const test = require('node:test');
3
+ const assert = require('node:assert/strict');
4
+
5
+ const { launchBackgroundAutoUpdate, syncLifecycleConfig } = require('./session-init');
6
+
7
+ test('syncLifecycleConfig is exported as a callable helper', () => {
8
+ assert.equal(typeof syncLifecycleConfig, 'function');
9
+ });
10
+
11
+ test('launchBackgroundAutoUpdate spawns detached silent updater', () => {
12
+ const calls = [];
13
+
14
+ const ok = launchBackgroundAutoUpdate((command, args, options) => {
15
+ const record = { command, args, options, unrefCalled: false };
16
+ calls.push(record);
17
+ return {
18
+ unref() {
19
+ record.unrefCalled = true;
20
+ },
21
+ };
22
+ }, { HOME: '/tmp/fake-home' });
23
+
24
+ assert.equal(ok, true);
25
+ assert.equal(calls.length, 1);
26
+ assert.equal(calls[0].command, process.execPath);
27
+ assert.match(calls[0].args[0], /auto-update\.js$/);
28
+ assert.equal(calls[0].args[1], 'check');
29
+ assert.equal(calls[0].args[2], '--silent');
30
+ assert.equal(calls[0].options.detached, true);
31
+ assert.equal(calls[0].options.stdio, 'ignore');
32
+ assert.equal(calls[0].options.env.CODE_GRAPH_AUTO_UPDATE_SILENT, '1');
33
+ assert.equal(calls[0].unrefCalled, true);
34
+ });
35
+
@@ -7,10 +7,13 @@
7
7
  */
8
8
  const { execFileSync } = require('child_process');
9
9
  const path = require('path');
10
- const { readRegistry } = require('./lifecycle');
10
+ const { cleanupDisabledStatusline, readRegistry } = require('./lifecycle');
11
11
 
12
12
  const SEPARATOR = ' \x1b[2m|\x1b[0m ';
13
13
 
14
+ const disabledCleanup = cleanupDisabledStatusline();
15
+ if (disabledCleanup.cleaned) process.exit(0);
16
+
14
17
  // Collect stdin (Claude Code pipes JSON context)
15
18
  let stdinData = '';
16
19
  let ran = false;
@@ -4,6 +4,10 @@ const { execFileSync } = require('child_process');
4
4
  const fs = require('fs');
5
5
  const path = require('path');
6
6
  const { findBinary } = require('./find-binary');
7
+ const { cleanupDisabledStatusline } = require('./lifecycle');
8
+
9
+ const disabledCleanup = cleanupDisabledStatusline();
10
+ if (disabledCleanup.cleaned) process.exit(0);
7
11
 
8
12
  // Only show status in projects that have a code-graph index.
9
13
  // The statusLine config is global, so we must exit silently for
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sdsrs/code-graph",
3
- "version": "0.5.26",
3
+ "version": "0.5.28",
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": {
@@ -33,10 +33,10 @@
33
33
  "node": ">=16"
34
34
  },
35
35
  "optionalDependencies": {
36
- "@sdsrs/code-graph-linux-x64": "0.5.26",
37
- "@sdsrs/code-graph-linux-arm64": "0.5.26",
38
- "@sdsrs/code-graph-darwin-x64": "0.5.26",
39
- "@sdsrs/code-graph-darwin-arm64": "0.5.26",
40
- "@sdsrs/code-graph-win32-x64": "0.5.26"
36
+ "@sdsrs/code-graph-linux-x64": "0.5.28",
37
+ "@sdsrs/code-graph-linux-arm64": "0.5.28",
38
+ "@sdsrs/code-graph-darwin-x64": "0.5.28",
39
+ "@sdsrs/code-graph-darwin-arm64": "0.5.28",
40
+ "@sdsrs/code-graph-win32-x64": "0.5.28"
41
41
  }
42
42
  }