@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 +1 -1
- package/bin/cli.js +8 -47
- package/claude-plugin/.claude-plugin/plugin.json +8 -2
- package/claude-plugin/hooks/hooks.json +2 -2
- package/claude-plugin/scripts/auto-update.js +167 -44
- package/claude-plugin/scripts/auto-update.test.js +97 -0
- package/claude-plugin/scripts/find-binary.js +92 -29
- package/claude-plugin/scripts/lifecycle.e2e.test.js +91 -0
- package/claude-plugin/scripts/lifecycle.js +69 -16
- package/claude-plugin/scripts/lifecycle.test.js +97 -0
- package/claude-plugin/scripts/session-init.js +48 -98
- package/claude-plugin/scripts/session-init.test.js +35 -0
- package/claude-plugin/scripts/statusline-composite.js +4 -1
- package/claude-plugin/scripts/statusline.js +4 -0
- package/package.json +6 -6
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
|
|
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 {
|
|
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
|
-
|
|
9
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
8
|
-
"keywords": [
|
|
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":
|
|
69
|
+
"timeout": 5
|
|
70
70
|
}
|
|
71
71
|
],
|
|
72
|
-
"description": "
|
|
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
|
|
14
|
-
const
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
|
|
75
|
-
|
|
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 (
|
|
142
|
+
if (res.statusCode < 200 || res.statusCode >= 300) return null;
|
|
81
143
|
|
|
82
|
-
const data =
|
|
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
|
|
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
|
-
//
|
|
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 { /*
|
|
250
|
+
} catch { /* not fatal */ }
|
|
147
251
|
|
|
148
|
-
//
|
|
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 { /*
|
|
155
|
-
|
|
156
|
-
//
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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
|
|
168
|
-
} catch {
|
|
169
|
-
|
|
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
|
-
|
|
206
|
-
const success =
|
|
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 = {
|
|
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
|
|
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
|
-
*
|
|
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 (
|
|
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
|
-
|
|
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
|
-
//
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
//
|
|
54
|
-
const
|
|
55
|
-
if (
|
|
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
|
-
//
|
|
58
|
-
const platformPkg = `@sdsrs/code-graph-${PLATFORM}-${
|
|
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),
|
|
62
|
-
if (
|
|
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
|
-
//
|
|
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
|
|
70
|
-
|
|
71
|
-
|
|
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
|
-
|
|
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
|
|
211
|
-
|
|
212
|
-
|
|
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 {
|
|
4
|
-
const fs = require('fs');
|
|
3
|
+
const { spawn } = require('child_process');
|
|
5
4
|
const path = require('path');
|
|
6
|
-
const
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
5
|
+
const {
|
|
6
|
+
install, update, readManifest, getPluginVersion, checkScopeConflict,
|
|
7
|
+
cleanupDisabledStatusline, isPluginInactive,
|
|
8
|
+
} = require('./lifecycle');
|
|
10
9
|
|
|
11
|
-
|
|
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
|
-
|
|
19
|
-
|
|
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
|
-
|
|
22
|
-
|
|
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
|
-
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
|
|
56
|
-
if (
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
|
|
87
|
-
const
|
|
88
|
-
|
|
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
|
-
|
|
96
|
-
|
|
97
|
-
|
|
58
|
+
module.exports = {
|
|
59
|
+
launchBackgroundAutoUpdate,
|
|
60
|
+
syncLifecycleConfig,
|
|
61
|
+
runSessionInit,
|
|
62
|
+
};
|
|
98
63
|
|
|
99
|
-
if (
|
|
100
|
-
|
|
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.
|
|
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.
|
|
37
|
-
"@sdsrs/code-graph-linux-arm64": "0.5.
|
|
38
|
-
"@sdsrs/code-graph-darwin-x64": "0.5.
|
|
39
|
-
"@sdsrs/code-graph-darwin-arm64": "0.5.
|
|
40
|
-
"@sdsrs/code-graph-win32-x64": "0.5.
|
|
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
|
}
|