@sdsrs/code-graph 0.7.15 → 0.7.18
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/claude-plugin/.claude-plugin/plugin.json +1 -1
- package/claude-plugin/hooks/hooks.json +2 -1
- package/claude-plugin/scripts/auto-update.js +15 -25
- package/claude-plugin/scripts/doctor.js +387 -0
- package/claude-plugin/scripts/doctor.test.js +47 -0
- package/claude-plugin/scripts/lifecycle.e2e.test.js +5 -1
- package/claude-plugin/scripts/lifecycle.js +193 -16
- package/claude-plugin/scripts/lifecycle.test.js +78 -0
- package/claude-plugin/scripts/mcp-launcher.js +2 -1
- package/claude-plugin/scripts/session-init.js +129 -4
- package/claude-plugin/scripts/session-init.test.js +33 -0
- package/claude-plugin/scripts/statusline-composite.js +2 -2
- package/claude-plugin/scripts/version-utils.js +49 -0
- package/claude-plugin/scripts/version-utils.test.js +83 -0
- package/package.json +6 -6
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"
|
|
2
|
+
"description": "code-graph-mcp hooks",
|
|
3
|
+
"_note": "Hooks are registered to ~/.claude/settings.json by scripts/lifecycle.js. This file is intentionally empty to prevent double-firing — Claude Code would otherwise load hooks from both the plugin cache copy AND settings.json, causing each hook to run twice per event.",
|
|
3
4
|
"hooks": {}
|
|
4
5
|
}
|
|
@@ -7,6 +7,7 @@ const path = require('path');
|
|
|
7
7
|
const os = require('os');
|
|
8
8
|
const { CACHE_DIR, PLUGIN_ID, MARKETPLACE_NAME, readManifest, readJson, writeJsonAtomic } = require('./lifecycle');
|
|
9
9
|
const { clearCache: clearBinaryCache } = require('./find-binary');
|
|
10
|
+
const { readBinaryVersion, isDevMode } = require('./version-utils');
|
|
10
11
|
|
|
11
12
|
// ── Environment Checks ────────────────────────────────────
|
|
12
13
|
|
|
@@ -32,7 +33,6 @@ const BINARY_CACHE_DIR = path.join(CACHE_DIR, 'bin');
|
|
|
32
33
|
const CHECK_INTERVAL_MS = 6 * 60 * 60 * 1000; // 6h
|
|
33
34
|
const RATE_LIMIT_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24h if rate-limited
|
|
34
35
|
const FETCH_TIMEOUT_MS = 3000;
|
|
35
|
-
const VERSION_OUTPUT_RE = /^code-graph-mcp\s+(\d+\.\d+\.\d+)$/;
|
|
36
36
|
|
|
37
37
|
function isSilentMode(argv = process.argv.slice(2), env = process.env) {
|
|
38
38
|
return argv.includes('--silent') || env.CODE_GRAPH_AUTO_UPDATE_SILENT === '1';
|
|
@@ -65,17 +65,6 @@ function saveState(state) {
|
|
|
65
65
|
} catch { /* ok */ }
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
-
// ── Dev Mode Detection ─────────────────────────────────────
|
|
69
|
-
|
|
70
|
-
function isDevMode() {
|
|
71
|
-
const pluginRoot = process.env.CLAUDE_PLUGIN_ROOT || path.resolve(__dirname, '..');
|
|
72
|
-
// Dev mode: running from source repo (has Cargo.toml nearby)
|
|
73
|
-
if (fs.existsSync(path.join(pluginRoot, '..', 'Cargo.toml'))) return true;
|
|
74
|
-
// Dev mode: plugin root is a symlink
|
|
75
|
-
try { if (fs.lstatSync(pluginRoot).isSymbolicLink()) return true; } catch { /* ok */ }
|
|
76
|
-
return false;
|
|
77
|
-
}
|
|
78
|
-
|
|
79
68
|
// ── Throttle ───────────────────────────────────────────────
|
|
80
69
|
|
|
81
70
|
function shouldCheck(state) {
|
|
@@ -183,19 +172,6 @@ function getExtractedPluginVersion(pluginSrc) {
|
|
|
183
172
|
return manifest && typeof manifest.version === 'string' ? manifest.version : null;
|
|
184
173
|
}
|
|
185
174
|
|
|
186
|
-
function readBinaryVersion(binaryPath) {
|
|
187
|
-
try {
|
|
188
|
-
const out = execFileSync(binaryPath, ['--version'], {
|
|
189
|
-
timeout: 2000,
|
|
190
|
-
stdio: ['pipe', 'pipe', 'pipe'],
|
|
191
|
-
}).toString().trim();
|
|
192
|
-
const match = out.match(VERSION_OUTPUT_RE);
|
|
193
|
-
return match ? match[1] : null;
|
|
194
|
-
} catch {
|
|
195
|
-
return null;
|
|
196
|
-
}
|
|
197
|
-
}
|
|
198
|
-
|
|
199
175
|
function promoteVerifiedBinary(binaryTmp, binaryDst, expectedVersion) {
|
|
200
176
|
try {
|
|
201
177
|
const stat = fs.statSync(binaryTmp);
|
|
@@ -281,6 +257,20 @@ async function downloadAndInstall(latest) {
|
|
|
281
257
|
writeJsonAtomic(path.join(CACHE_DIR, 'install-manifest.json'), manifest);
|
|
282
258
|
} catch { /* not fatal */ }
|
|
283
259
|
|
|
260
|
+
// Run the NEW lifecycle.js to update settings.json hooks with new paths.
|
|
261
|
+
// Without this, settings.json hooks still point to the old version directory
|
|
262
|
+
// until the next session's self-heal corrects them.
|
|
263
|
+
if (pluginUpdated) {
|
|
264
|
+
try {
|
|
265
|
+
const newLifecycle = path.join(pluginDst, 'scripts', 'lifecycle.js');
|
|
266
|
+
if (fs.existsSync(newLifecycle)) {
|
|
267
|
+
execFileSync(process.execPath, [newLifecycle, 'update'], {
|
|
268
|
+
timeout: 5000, stdio: 'pipe',
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
} catch { /* not fatal — syncLifecycleConfig will self-heal on next session */ }
|
|
272
|
+
}
|
|
273
|
+
|
|
284
274
|
// ── Step 2: Download platform binary directly from GitHub release ──
|
|
285
275
|
if (latest.binaryUrl) {
|
|
286
276
|
try {
|
|
@@ -0,0 +1,387 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
const { execFileSync, execSync } = require('child_process');
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
const os = require('os');
|
|
7
|
+
const { readBinaryVersion, isDevMode, getNewestMtime } = require('./version-utils');
|
|
8
|
+
const {
|
|
9
|
+
getPluginVersion, readJson, healthCheck, CACHE_DIR,
|
|
10
|
+
findStalePluginHooksJson, clearStalePluginCacheHooks,
|
|
11
|
+
} = require('./lifecycle');
|
|
12
|
+
const { findBinary, clearCache: clearBinaryCache } = require('./find-binary');
|
|
13
|
+
|
|
14
|
+
// ── Diagnostics ───────────────────────────────────────────
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Run all diagnostic checks. Returns an array of:
|
|
18
|
+
* { name: string, status: 'ok'|'warn'|'error'|'skip', detail: string, fixId?: string }
|
|
19
|
+
*/
|
|
20
|
+
function runDiagnostics() {
|
|
21
|
+
const results = [];
|
|
22
|
+
const binary = findBinary();
|
|
23
|
+
|
|
24
|
+
// 1. Binary executable
|
|
25
|
+
if (!binary) {
|
|
26
|
+
results.push({ name: 'Binary', status: 'error', detail: 'not found', fixId: 'binary-missing' });
|
|
27
|
+
results.push({ name: 'Binary version', status: 'skip', detail: 'binary not found' });
|
|
28
|
+
results.push({ name: 'Source fresh', status: 'skip', detail: 'binary not found' });
|
|
29
|
+
results.push({ name: 'Schema', status: 'skip', detail: 'binary not found' });
|
|
30
|
+
results.push({ name: 'Index', status: 'skip', detail: 'binary not found' });
|
|
31
|
+
results.push({ name: 'Embeddings', status: 'skip', detail: 'binary not found' });
|
|
32
|
+
} else {
|
|
33
|
+
let execOk = true;
|
|
34
|
+
try {
|
|
35
|
+
fs.accessSync(binary, fs.constants.X_OK);
|
|
36
|
+
results.push({ name: 'Binary exec', status: 'ok', detail: binary });
|
|
37
|
+
} catch {
|
|
38
|
+
results.push({ name: 'Binary exec', status: 'error', detail: `not executable: ${binary}`, fixId: 'binary-not-exec' });
|
|
39
|
+
execOk = false;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// 2. Binary version vs plugin version
|
|
43
|
+
const pluginVersion = getPluginVersion();
|
|
44
|
+
const binaryVersion = execOk ? readBinaryVersion(binary) : null;
|
|
45
|
+
if (!binaryVersion) {
|
|
46
|
+
results.push({ name: 'Binary version', status: 'error', detail: 'failed to read version', fixId: 'binary-broken' });
|
|
47
|
+
} else if (binaryVersion !== pluginVersion) {
|
|
48
|
+
results.push({
|
|
49
|
+
name: 'Binary version',
|
|
50
|
+
status: 'warn',
|
|
51
|
+
detail: `v${binaryVersion} (plugin expects v${pluginVersion})`,
|
|
52
|
+
fixId: 'version-mismatch',
|
|
53
|
+
});
|
|
54
|
+
} else {
|
|
55
|
+
results.push({ name: 'Binary version', status: 'ok', detail: `v${binaryVersion}` });
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// 3. Source freshness (dev mode only)
|
|
59
|
+
if (isDevMode()) {
|
|
60
|
+
const srcDir = path.resolve(__dirname, '..', '..', 'src');
|
|
61
|
+
try {
|
|
62
|
+
const binaryMtime = fs.statSync(binary).mtimeMs;
|
|
63
|
+
const latestSrcMtime = getNewestMtime(srcDir, '.rs');
|
|
64
|
+
if (latestSrcMtime > binaryMtime) {
|
|
65
|
+
const deltaMin = Math.round((latestSrcMtime - binaryMtime) / 60000);
|
|
66
|
+
results.push({
|
|
67
|
+
name: 'Source fresh',
|
|
68
|
+
status: 'warn',
|
|
69
|
+
detail: `src/ modified ${deltaMin}min after binary`,
|
|
70
|
+
fixId: 'binary-stale',
|
|
71
|
+
});
|
|
72
|
+
} else {
|
|
73
|
+
results.push({ name: 'Source fresh', status: 'ok', detail: 'binary up-to-date' });
|
|
74
|
+
}
|
|
75
|
+
} catch {
|
|
76
|
+
results.push({ name: 'Source fresh', status: 'skip', detail: 'could not stat files' });
|
|
77
|
+
}
|
|
78
|
+
} else {
|
|
79
|
+
results.push({ name: 'Source fresh', status: 'skip', detail: 'not dev mode' });
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// 4. health-check (schema, index, embeddings) via binary --json
|
|
83
|
+
if (execOk) {
|
|
84
|
+
try {
|
|
85
|
+
const cwd = process.cwd();
|
|
86
|
+
const hcOutput = execFileSync(binary, ['health-check', '--json'], {
|
|
87
|
+
cwd,
|
|
88
|
+
timeout: 5000,
|
|
89
|
+
encoding: 'utf8',
|
|
90
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
91
|
+
}).trim();
|
|
92
|
+
const hc = JSON.parse(hcOutput);
|
|
93
|
+
|
|
94
|
+
// Schema
|
|
95
|
+
if (hc.issue && hc.issue.includes('schema')) {
|
|
96
|
+
results.push({ name: 'Schema', status: 'warn', detail: hc.issue, fixId: 'schema-mismatch' });
|
|
97
|
+
} else {
|
|
98
|
+
results.push({ name: 'Schema', status: 'ok', detail: `v${hc.schema_version}` });
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Index
|
|
102
|
+
if (hc.nodes === 0) {
|
|
103
|
+
results.push({ name: 'Index', status: 'warn', detail: 'empty', fixId: 'index-empty' });
|
|
104
|
+
} else {
|
|
105
|
+
const age = hc.index_age ? ` (${hc.index_age})` : '';
|
|
106
|
+
results.push({
|
|
107
|
+
name: 'Index',
|
|
108
|
+
status: 'ok',
|
|
109
|
+
detail: `${hc.nodes} nodes, ${hc.edges} edges, ${hc.files} files${age}`,
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Embeddings
|
|
114
|
+
const ep = hc.embedding_progress || '0/0';
|
|
115
|
+
const [done, total] = ep.split('/').map(Number);
|
|
116
|
+
if (total > 0 && done < total) {
|
|
117
|
+
const pct = Math.round((done / total) * 100);
|
|
118
|
+
results.push({ name: 'Embeddings', status: 'ok', detail: `${pct}% (${done}/${total})` });
|
|
119
|
+
} else if (total === 0) {
|
|
120
|
+
results.push({ name: 'Embeddings', status: 'ok', detail: 'no embeddable nodes' });
|
|
121
|
+
} else {
|
|
122
|
+
results.push({ name: 'Embeddings', status: 'ok', detail: `100% (${done}/${total})` });
|
|
123
|
+
}
|
|
124
|
+
} catch (e) {
|
|
125
|
+
const msg = e.stderr ? e.stderr.toString().trim().slice(0, 100) : e.message.slice(0, 100);
|
|
126
|
+
results.push({ name: 'Schema', status: 'error', detail: `health-check failed: ${msg}`, fixId: 'binary-broken' });
|
|
127
|
+
results.push({ name: 'Index', status: 'skip', detail: 'health-check failed' });
|
|
128
|
+
results.push({ name: 'Embeddings', status: 'skip', detail: 'health-check failed' });
|
|
129
|
+
}
|
|
130
|
+
} else {
|
|
131
|
+
results.push({ name: 'Schema', status: 'skip', detail: 'binary not executable' });
|
|
132
|
+
results.push({ name: 'Index', status: 'skip', detail: 'binary not executable' });
|
|
133
|
+
results.push({ name: 'Embeddings', status: 'skip', detail: 'binary not executable' });
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// 5. Auto-update state
|
|
138
|
+
try {
|
|
139
|
+
const state = readJson(path.join(CACHE_DIR, 'update-state.json'));
|
|
140
|
+
if (state && state.updateAvailable && state.binaryUpdated === false) {
|
|
141
|
+
results.push({
|
|
142
|
+
name: 'Auto-update',
|
|
143
|
+
status: 'warn',
|
|
144
|
+
detail: `plugin v${state.latestVersion}, binary download incomplete`,
|
|
145
|
+
fixId: 'update-incomplete',
|
|
146
|
+
});
|
|
147
|
+
} else {
|
|
148
|
+
results.push({ name: 'Auto-update', status: 'ok', detail: 'up-to-date' });
|
|
149
|
+
}
|
|
150
|
+
} catch {
|
|
151
|
+
results.push({ name: 'Auto-update', status: 'ok', detail: 'no update state' });
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// 6. Hook paths validity
|
|
155
|
+
const hookResult = healthCheck();
|
|
156
|
+
if (hookResult.healthy) {
|
|
157
|
+
results.push({ name: 'Hooks', status: 'ok', detail: 'all paths valid' });
|
|
158
|
+
} else {
|
|
159
|
+
results.push({
|
|
160
|
+
name: 'Hooks',
|
|
161
|
+
status: hookResult.repaired ? 'ok' : 'warn',
|
|
162
|
+
detail: hookResult.repaired
|
|
163
|
+
? `${hookResult.issues.length} issue(s) auto-repaired`
|
|
164
|
+
: `${hookResult.issues.length} invalid path(s)`,
|
|
165
|
+
fixId: hookResult.repaired ? undefined : 'hooks-invalid',
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// 7. Plugin cache hooks.json sanity — non-empty copies cause every hook to fire twice
|
|
170
|
+
try {
|
|
171
|
+
const stale = findStalePluginHooksJson();
|
|
172
|
+
if (stale.length === 0) {
|
|
173
|
+
results.push({ name: 'Plugin cache', status: 'ok', detail: 'no stale hooks.json' });
|
|
174
|
+
} else {
|
|
175
|
+
results.push({
|
|
176
|
+
name: 'Plugin cache',
|
|
177
|
+
status: 'warn',
|
|
178
|
+
detail: `${stale.length} stale hooks.json (hooks fire twice per event)`,
|
|
179
|
+
fixId: 'hooks-cache-stale',
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
} catch { /* lifecycle probe failed — skip */ }
|
|
183
|
+
|
|
184
|
+
return results;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// ── Report Formatting ─────────────────────────────────────
|
|
188
|
+
|
|
189
|
+
const STATUS_ICONS = { ok: '\u2705', warn: '\u26a0\ufe0f', error: '\u274c', skip: '\u2796' };
|
|
190
|
+
|
|
191
|
+
function formatReport(results) {
|
|
192
|
+
const pluginVersion = getPluginVersion();
|
|
193
|
+
const lines = [`\ud83d\udd0d code-graph doctor v${pluginVersion}`, ''];
|
|
194
|
+
|
|
195
|
+
const maxName = Math.max(...results.map(r => r.name.length));
|
|
196
|
+
for (const r of results) {
|
|
197
|
+
const icon = STATUS_ICONS[r.status] || '?';
|
|
198
|
+
const pad = ' '.repeat(maxName - r.name.length + 2);
|
|
199
|
+
lines.push(` ${r.name}${pad}${icon} ${r.detail}`);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const issues = results.filter(r => r.status === 'warn' || r.status === 'error');
|
|
203
|
+
lines.push('');
|
|
204
|
+
if (issues.length === 0) {
|
|
205
|
+
lines.push(' All checks passed.');
|
|
206
|
+
} else {
|
|
207
|
+
const fixable = issues.filter(r => r.fixId);
|
|
208
|
+
lines.push(` ${issues.length} issue(s) found.${fixable.length > 0 ? ' Fixing...' : ''}`);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
return lines.join('\n');
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// ── Repair Actions ────────────────────────────────────────
|
|
215
|
+
|
|
216
|
+
function runRepairs(results) {
|
|
217
|
+
const fixable = results.filter(r => r.fixId);
|
|
218
|
+
if (fixable.length === 0) return 0;
|
|
219
|
+
|
|
220
|
+
let fixed = 0;
|
|
221
|
+
for (const issue of fixable) {
|
|
222
|
+
switch (issue.fixId) {
|
|
223
|
+
case 'binary-stale':
|
|
224
|
+
case 'version-mismatch': {
|
|
225
|
+
if (!isDevMode()) {
|
|
226
|
+
console.log('\n Triggering binary update...');
|
|
227
|
+
try {
|
|
228
|
+
execFileSync(process.execPath, [path.join(__dirname, 'auto-update.js'), 'check'], {
|
|
229
|
+
timeout: 60000,
|
|
230
|
+
stdio: 'inherit',
|
|
231
|
+
});
|
|
232
|
+
console.log(' \u2705 Update check complete');
|
|
233
|
+
fixed++;
|
|
234
|
+
} catch {
|
|
235
|
+
console.log(' \u274c Update check failed — install manually');
|
|
236
|
+
}
|
|
237
|
+
break;
|
|
238
|
+
}
|
|
239
|
+
console.log('\n Building binary...');
|
|
240
|
+
console.log(' \u2192 cargo build --release --no-default-features');
|
|
241
|
+
try {
|
|
242
|
+
const projectRoot = path.resolve(__dirname, '..', '..');
|
|
243
|
+
execSync('cargo build --release --no-default-features', {
|
|
244
|
+
cwd: projectRoot,
|
|
245
|
+
stdio: 'inherit',
|
|
246
|
+
timeout: 300000,
|
|
247
|
+
});
|
|
248
|
+
clearBinaryCache();
|
|
249
|
+
console.log(' \u2705 Build complete');
|
|
250
|
+
fixed++;
|
|
251
|
+
} catch {
|
|
252
|
+
console.log(' \u274c Build failed');
|
|
253
|
+
}
|
|
254
|
+
break;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
case 'binary-missing': {
|
|
258
|
+
console.log('\n Installing binary...');
|
|
259
|
+
if (isDevMode()) {
|
|
260
|
+
console.log(' \u2192 cargo build --release --no-default-features');
|
|
261
|
+
try {
|
|
262
|
+
const projectRoot = path.resolve(__dirname, '..', '..');
|
|
263
|
+
execSync('cargo build --release --no-default-features', {
|
|
264
|
+
cwd: projectRoot,
|
|
265
|
+
stdio: 'inherit',
|
|
266
|
+
timeout: 300000,
|
|
267
|
+
});
|
|
268
|
+
clearBinaryCache();
|
|
269
|
+
console.log(' \u2705 Build complete');
|
|
270
|
+
fixed++;
|
|
271
|
+
} catch {
|
|
272
|
+
console.log(' \u274c Build failed');
|
|
273
|
+
}
|
|
274
|
+
} else {
|
|
275
|
+
console.log(' Install: npm install -g @sdsrs/code-graph');
|
|
276
|
+
console.log(' Or download from: https://github.com/sdsrss/code-graph-mcp/releases');
|
|
277
|
+
}
|
|
278
|
+
break;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
case 'binary-not-exec': {
|
|
282
|
+
const binary = findBinary();
|
|
283
|
+
if (binary) {
|
|
284
|
+
try {
|
|
285
|
+
fs.chmodSync(binary, 0o755);
|
|
286
|
+
console.log(`\n \u2705 Fixed permissions: chmod +x ${binary}`);
|
|
287
|
+
fixed++;
|
|
288
|
+
} catch {
|
|
289
|
+
console.log(`\n \u274c Could not fix permissions: ${binary}`);
|
|
290
|
+
}
|
|
291
|
+
if (os.platform() === 'darwin') {
|
|
292
|
+
console.log(` Also try: xattr -d com.apple.quarantine "${binary}"`);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
break;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
case 'index-empty': {
|
|
299
|
+
const binary = findBinary();
|
|
300
|
+
if (binary) {
|
|
301
|
+
console.log('\n Rebuilding index...');
|
|
302
|
+
console.log(' \u2192 code-graph-mcp incremental-index');
|
|
303
|
+
try {
|
|
304
|
+
execFileSync(binary, ['incremental-index'], {
|
|
305
|
+
cwd: process.cwd(),
|
|
306
|
+
stdio: 'inherit',
|
|
307
|
+
timeout: 120000,
|
|
308
|
+
});
|
|
309
|
+
console.log(' \u2705 Index rebuilt');
|
|
310
|
+
fixed++;
|
|
311
|
+
} catch {
|
|
312
|
+
console.log(' \u274c Index rebuild failed');
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
break;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
case 'update-incomplete': {
|
|
319
|
+
console.log('\n Completing auto-update...');
|
|
320
|
+
try {
|
|
321
|
+
execFileSync(process.execPath, [path.join(__dirname, 'auto-update.js'), 'check'], {
|
|
322
|
+
timeout: 60000,
|
|
323
|
+
stdio: 'inherit',
|
|
324
|
+
});
|
|
325
|
+
console.log(' \u2705 Update check complete');
|
|
326
|
+
fixed++;
|
|
327
|
+
} catch {
|
|
328
|
+
console.log(' \u274c Update check failed');
|
|
329
|
+
}
|
|
330
|
+
break;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
case 'hooks-invalid': {
|
|
334
|
+
console.log('\n Repairing hooks...');
|
|
335
|
+
const { install } = require('./lifecycle');
|
|
336
|
+
install();
|
|
337
|
+
console.log(' \u2705 Hooks repaired');
|
|
338
|
+
fixed++;
|
|
339
|
+
break;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
case 'hooks-cache-stale': {
|
|
343
|
+
console.log('\n Clearing stale plugin cache hooks.json...');
|
|
344
|
+
const cleared = clearStalePluginCacheHooks();
|
|
345
|
+
console.log(` \u2705 Cleared ${cleared.length} file(s) — restart Claude Code to apply`);
|
|
346
|
+
for (const p of cleared) console.log(` - ${p}`);
|
|
347
|
+
fixed++;
|
|
348
|
+
break;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
case 'schema-mismatch': {
|
|
352
|
+
console.log('\n Schema migration happens automatically when the binary runs.');
|
|
353
|
+
console.log(' If binary is older than DB, update the binary first.');
|
|
354
|
+
break;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
default:
|
|
358
|
+
break;
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
return fixed;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// ── Main ──────────────────────────────────────────────────
|
|
365
|
+
|
|
366
|
+
function runDoctor(opts = {}) {
|
|
367
|
+
const results = runDiagnostics();
|
|
368
|
+
console.log(formatReport(results));
|
|
369
|
+
|
|
370
|
+
const issues = results.filter(r => r.status === 'warn' || r.status === 'error');
|
|
371
|
+
|
|
372
|
+
if (issues.length > 0 && !opts.checkOnly) {
|
|
373
|
+
const fixed = runRepairs(results);
|
|
374
|
+
console.log(`\n ${fixed}/${issues.length} issue(s) addressed.`);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
return { results, issueCount: issues.length };
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
module.exports = { runDiagnostics, formatReport, runRepairs, runDoctor };
|
|
381
|
+
|
|
382
|
+
if (require.main === module) {
|
|
383
|
+
const args = process.argv.slice(2);
|
|
384
|
+
const checkOnly = args.includes('--check-only');
|
|
385
|
+
const { issueCount } = runDoctor({ checkOnly });
|
|
386
|
+
process.exit(issueCount > 0 ? 1 : 0);
|
|
387
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const test = require('node:test');
|
|
3
|
+
const assert = require('node:assert/strict');
|
|
4
|
+
|
|
5
|
+
const { runDiagnostics, formatReport } = require('./doctor');
|
|
6
|
+
|
|
7
|
+
test('runDiagnostics returns an array of check results', () => {
|
|
8
|
+
const results = runDiagnostics();
|
|
9
|
+
assert.ok(Array.isArray(results));
|
|
10
|
+
assert.ok(results.length > 0, 'should have at least one check result');
|
|
11
|
+
for (const r of results) {
|
|
12
|
+
assert.equal(typeof r.name, 'string');
|
|
13
|
+
assert.ok(['ok', 'warn', 'error', 'skip'].includes(r.status));
|
|
14
|
+
assert.equal(typeof r.detail, 'string');
|
|
15
|
+
}
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test('formatReport produces readable output', () => {
|
|
19
|
+
const results = [
|
|
20
|
+
{ name: 'Binary version', status: 'ok', detail: 'v0.7.16' },
|
|
21
|
+
{ name: 'Source fresh', status: 'warn', detail: 'src/ modified 3min after binary', fixId: 'binary-stale' },
|
|
22
|
+
{ name: 'Schema', status: 'ok', detail: 'v6' },
|
|
23
|
+
];
|
|
24
|
+
const output = formatReport(results);
|
|
25
|
+
assert.ok(output.includes('Binary version'));
|
|
26
|
+
assert.ok(output.includes('v0.7.16'));
|
|
27
|
+
assert.ok(output.includes('Source fresh'));
|
|
28
|
+
assert.ok(output.includes('3min'));
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test('formatReport shows issue count when problems exist', () => {
|
|
32
|
+
const results = [
|
|
33
|
+
{ name: 'Test', status: 'warn', detail: 'problem', fixId: 'test-fix' },
|
|
34
|
+
];
|
|
35
|
+
const output = formatReport(results);
|
|
36
|
+
assert.ok(output.includes('1'));
|
|
37
|
+
assert.ok(output.includes('issue'));
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test('formatReport shows all-clear when no problems', () => {
|
|
41
|
+
const results = [
|
|
42
|
+
{ name: 'Binary version', status: 'ok', detail: 'v0.7.16' },
|
|
43
|
+
{ name: 'Schema', status: 'ok', detail: 'v6' },
|
|
44
|
+
];
|
|
45
|
+
const output = formatReport(results);
|
|
46
|
+
assert.ok(output.includes('All checks passed') || output.includes('0 issues'));
|
|
47
|
+
});
|
|
@@ -26,9 +26,13 @@ function readJson(filePath) {
|
|
|
26
26
|
}
|
|
27
27
|
|
|
28
28
|
function runScript(homeDir, scriptPath, args = [], options = {}) {
|
|
29
|
+
const env = { ...process.env, HOME: homeDir };
|
|
30
|
+
// Do NOT set CLAUDE_PLUGIN_ROOT — lifecycle.js derives PLUGIN_ROOT from __dirname
|
|
31
|
+
// to avoid env var leakage from other plugins in shared hook execution context.
|
|
32
|
+
delete env.CLAUDE_PLUGIN_ROOT;
|
|
29
33
|
return execFileSync(process.execPath, [scriptPath, ...args], {
|
|
30
34
|
cwd: options.cwd || repoRoot,
|
|
31
|
-
env
|
|
35
|
+
env,
|
|
32
36
|
input: options.input,
|
|
33
37
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
34
38
|
}).toString();
|
|
@@ -11,7 +11,10 @@ const OLD_PLUGIN_IDS = [
|
|
|
11
11
|
];
|
|
12
12
|
const MARKETPLACE_NAME = 'code-graph-mcp';
|
|
13
13
|
const CACHE_DIR = path.join(os.homedir(), '.cache', 'code-graph');
|
|
14
|
-
|
|
14
|
+
// Always derive from __dirname — CLAUDE_PLUGIN_ROOT env var can leak from other
|
|
15
|
+
// plugins when hooks run in shared process context (e.g. claude-mem-lite sets it
|
|
16
|
+
// to its own marketplace path, polluting all subsequent settings.json hook processes).
|
|
17
|
+
const PLUGIN_ROOT = path.resolve(__dirname, '..');
|
|
15
18
|
const MANIFEST_FILE = path.join(CACHE_DIR, 'install-manifest.json');
|
|
16
19
|
const SETTINGS_PATH = path.join(os.homedir(), '.claude', 'settings.json');
|
|
17
20
|
const INSTALLED_PLUGINS_PATH = path.join(os.homedir(), '.claude', 'plugins', 'installed_plugins.json');
|
|
@@ -217,15 +220,106 @@ function migrateOldPluginIds(settings) {
|
|
|
217
220
|
return changed;
|
|
218
221
|
}
|
|
219
222
|
|
|
223
|
+
// --- Plugin-cache hooks.json guard ---
|
|
224
|
+
// Claude Code loads hooks from TWO places: settings.json AND the plugin cache
|
|
225
|
+
// at ~/.claude/plugins/cache/<mp>/<plugin>/<ver>/hooks/hooks.json. If both have
|
|
226
|
+
// our hooks, every event fires twice. We register to settings.json (reliable),
|
|
227
|
+
// so cache copies must stay empty. Auto-updates can re-populate cache hooks.json
|
|
228
|
+
// from the marketplace source — this scan+clear runs on every install/update and
|
|
229
|
+
// every SessionStart (via session-init.js) as a second layer of defense.
|
|
230
|
+
|
|
231
|
+
const EMPTY_HOOKS_STUB = Object.freeze({
|
|
232
|
+
description: 'code-graph-mcp hooks',
|
|
233
|
+
_note: 'Hooks are registered to ~/.claude/settings.json by lifecycle.js. Cleared automatically to prevent double-firing.',
|
|
234
|
+
hooks: {},
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
function isOurPluginMarketplace(mpDir) {
|
|
238
|
+
try {
|
|
239
|
+
const meta = readJson(path.join(mpDir, '.claude-plugin', 'marketplace.json'));
|
|
240
|
+
if (meta && meta.name === MARKETPLACE_NAME) return true;
|
|
241
|
+
} catch { /* fallthrough */ }
|
|
242
|
+
return path.basename(mpDir) === MARKETPLACE_NAME;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function scanPluginHooksJsonCopies() {
|
|
246
|
+
const HOME = os.homedir();
|
|
247
|
+
const paths = [];
|
|
248
|
+
|
|
249
|
+
// Marketplace source (git-cloned by Claude Code on install)
|
|
250
|
+
const mpRoot = path.join(HOME, '.claude', 'plugins', 'marketplaces');
|
|
251
|
+
try {
|
|
252
|
+
for (const name of fs.readdirSync(mpRoot)) {
|
|
253
|
+
const mpDir = path.join(mpRoot, name);
|
|
254
|
+
try { if (!fs.statSync(mpDir).isDirectory()) continue; } catch { continue; }
|
|
255
|
+
if (!isOurPluginMarketplace(mpDir)) continue;
|
|
256
|
+
const p = path.join(mpDir, 'claude-plugin', 'hooks', 'hooks.json');
|
|
257
|
+
if (fs.existsSync(p)) paths.push(p);
|
|
258
|
+
}
|
|
259
|
+
} catch { /* no marketplaces dir */ }
|
|
260
|
+
|
|
261
|
+
// Cache (what Claude Code actually loads at runtime), per plugin + per version
|
|
262
|
+
const cacheRoot = path.join(HOME, '.claude', 'plugins', 'cache', MARKETPLACE_NAME);
|
|
263
|
+
try {
|
|
264
|
+
for (const pluginName of fs.readdirSync(cacheRoot)) {
|
|
265
|
+
const pluginDir = path.join(cacheRoot, pluginName);
|
|
266
|
+
try { if (!fs.statSync(pluginDir).isDirectory()) continue; } catch { continue; }
|
|
267
|
+
for (const ver of fs.readdirSync(pluginDir)) {
|
|
268
|
+
const verDir = path.join(pluginDir, ver);
|
|
269
|
+
try { if (!fs.statSync(verDir).isDirectory()) continue; } catch { continue; }
|
|
270
|
+
const p = path.join(verDir, 'hooks', 'hooks.json');
|
|
271
|
+
if (fs.existsSync(p)) paths.push(p);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
} catch { /* no cache dir */ }
|
|
275
|
+
|
|
276
|
+
return paths;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function findStalePluginHooksJson() {
|
|
280
|
+
const stale = [];
|
|
281
|
+
for (const p of scanPluginHooksJsonCopies()) {
|
|
282
|
+
try {
|
|
283
|
+
const cur = readJson(p);
|
|
284
|
+
if (cur && cur.hooks && typeof cur.hooks === 'object' && Object.keys(cur.hooks).length > 0) {
|
|
285
|
+
stale.push(p);
|
|
286
|
+
}
|
|
287
|
+
} catch { /* unreadable — skip */ }
|
|
288
|
+
}
|
|
289
|
+
return stale;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function clearStalePluginCacheHooks() {
|
|
293
|
+
const cleared = [];
|
|
294
|
+
const stamp = new Date().toISOString();
|
|
295
|
+
for (const p of findStalePluginHooksJson()) {
|
|
296
|
+
try {
|
|
297
|
+
const stub = { ...EMPTY_HOOKS_STUB, _note: `${EMPTY_HOOKS_STUB._note} (cleared ${stamp})` };
|
|
298
|
+
writeJsonAtomic(p, stub);
|
|
299
|
+
cleared.push(p);
|
|
300
|
+
} catch { /* write failure — skip */ }
|
|
301
|
+
}
|
|
302
|
+
return cleared;
|
|
303
|
+
}
|
|
304
|
+
|
|
220
305
|
// --- Hook Registration ---
|
|
221
306
|
// Plugin system's hooks.json auto-loading is unreliable (observed across GSD,
|
|
222
307
|
// superpowers, code-graph-mcp). Write hooks directly to settings.json instead.
|
|
223
308
|
// Same strategy as claude-mem-lite. hooks.json is kept empty to prevent double-firing.
|
|
224
309
|
|
|
225
310
|
const OUR_HOOK_SCRIPTS = ['session-init.js', 'incremental-index.js', 'user-prompt-context.js', 'pre-edit-guide.js'];
|
|
311
|
+
const OUR_DESCRIPTIONS = [
|
|
312
|
+
'StatusLine self-heal, lifecycle sync, project map injection',
|
|
313
|
+
'Auto-inject impact analysis when editing functions with 2+ callers',
|
|
314
|
+
'Auto-update code graph index after file edits',
|
|
315
|
+
'Inject code-graph structural context based on user intent',
|
|
316
|
+
];
|
|
226
317
|
|
|
227
318
|
function isOurHookEntry(entry) {
|
|
228
319
|
if (!entry || !entry.hooks) return false;
|
|
320
|
+
// Primary: match by description (immune to path pollution)
|
|
321
|
+
if (entry.description && OUR_DESCRIPTIONS.includes(entry.description)) return true;
|
|
322
|
+
// Fallback: match by script name + 'code-graph' in path
|
|
229
323
|
return entry.hooks.some(h =>
|
|
230
324
|
h.command && OUR_HOOK_SCRIPTS.some(s => h.command.includes(s)) &&
|
|
231
325
|
h.command.includes('code-graph')
|
|
@@ -269,17 +363,17 @@ function registerHooksToSettings(settings) {
|
|
|
269
363
|
for (const [event, newEntries] of Object.entries(defs)) {
|
|
270
364
|
if (!settings.hooks[event]) settings.hooks[event] = [];
|
|
271
365
|
|
|
366
|
+
// First, remove ALL existing entries that match ours (cleans up duplicates
|
|
367
|
+
// from prior PLUGIN_ROOT pollution where isOurHookEntry couldn't match,
|
|
368
|
+
// causing infinite re-adds each session).
|
|
369
|
+
const beforeLen = settings.hooks[event].length;
|
|
370
|
+
settings.hooks[event] = settings.hooks[event].filter(e => !isOurHookEntry(e));
|
|
371
|
+
if (settings.hooks[event].length !== beforeLen) changed = true;
|
|
372
|
+
|
|
373
|
+
// Then add our entries fresh with correct paths
|
|
272
374
|
for (const newEntry of newEntries) {
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
if (JSON.stringify(settings.hooks[event][existingIdx]) !== JSON.stringify(newEntry)) {
|
|
276
|
-
settings.hooks[event][existingIdx] = newEntry;
|
|
277
|
-
changed = true;
|
|
278
|
-
}
|
|
279
|
-
} else {
|
|
280
|
-
settings.hooks[event].push(newEntry);
|
|
281
|
-
changed = true;
|
|
282
|
-
}
|
|
375
|
+
settings.hooks[event].push(newEntry);
|
|
376
|
+
changed = true;
|
|
283
377
|
}
|
|
284
378
|
}
|
|
285
379
|
|
|
@@ -328,6 +422,13 @@ function install() {
|
|
|
328
422
|
settings.statusLine = { type: 'command', command: compositeCommand() };
|
|
329
423
|
settingsChanged = true;
|
|
330
424
|
manifest.config.statusLine = true;
|
|
425
|
+
} else {
|
|
426
|
+
// Composite exists — ensure path is correct (may have been polluted by env leak)
|
|
427
|
+
const cmd = compositeCommand();
|
|
428
|
+
if (settings.statusLine.command !== cmd) {
|
|
429
|
+
settings.statusLine.command = cmd;
|
|
430
|
+
settingsChanged = true;
|
|
431
|
+
}
|
|
331
432
|
}
|
|
332
433
|
|
|
333
434
|
// Register code-graph provider
|
|
@@ -347,13 +448,17 @@ function install() {
|
|
|
347
448
|
writeJsonAtomic(SETTINGS_PATH, settings);
|
|
348
449
|
}
|
|
349
450
|
|
|
451
|
+
// 3b. Clear cache/marketplace hooks.json copies after settings.json is authoritative,
|
|
452
|
+
// so next session only fires hooks from settings.json (no double-firing).
|
|
453
|
+
const clearedHookCopies = clearStalePluginCacheHooks();
|
|
454
|
+
|
|
350
455
|
// 4. Write manifest with version
|
|
351
456
|
manifest.version = version;
|
|
352
457
|
manifest.installedAt = manifest.installedAt || new Date().toISOString();
|
|
353
458
|
manifest.updatedAt = new Date().toISOString();
|
|
354
459
|
writeManifest(manifest);
|
|
355
460
|
|
|
356
|
-
return { version, settingsChanged, statusLineClaimed: manifest.config.statusLine };
|
|
461
|
+
return { version, settingsChanged, statusLineClaimed: manifest.config.statusLine, clearedHookCopies };
|
|
357
462
|
}
|
|
358
463
|
|
|
359
464
|
// --- Uninstall (clean all config) ---
|
|
@@ -456,6 +561,10 @@ function update() {
|
|
|
456
561
|
writeJsonAtomic(SETTINGS_PATH, settings);
|
|
457
562
|
}
|
|
458
563
|
|
|
564
|
+
// 4b. Clear cache/marketplace hooks.json copies after settings.json is updated.
|
|
565
|
+
// Auto-update can re-populate cache from marketplace source; stamp it out.
|
|
566
|
+
const clearedHookCopies = clearStalePluginCacheHooks();
|
|
567
|
+
|
|
459
568
|
// 5. Clear update-check cache (force re-check after update)
|
|
460
569
|
const updateCache = path.join(CACHE_DIR, 'update-check');
|
|
461
570
|
try { fs.unlinkSync(updateCache); } catch { /* ok */ }
|
|
@@ -468,7 +577,7 @@ function update() {
|
|
|
468
577
|
// 7. Clean up old cached versions (keep latest 3)
|
|
469
578
|
cleanupOldCacheVersions(3);
|
|
470
579
|
|
|
471
|
-
return { oldVersion, version, settingsChanged };
|
|
580
|
+
return { oldVersion, version, settingsChanged, clearedHookCopies };
|
|
472
581
|
}
|
|
473
582
|
|
|
474
583
|
/**
|
|
@@ -506,17 +615,70 @@ function cleanupOldCacheVersions(keep = 3) {
|
|
|
506
615
|
} catch { /* cache dir doesn't exist — nothing to clean */ }
|
|
507
616
|
}
|
|
508
617
|
|
|
618
|
+
// --- Health Check ---
|
|
619
|
+
// Validates all registered paths in settings.json point to existing scripts.
|
|
620
|
+
// Returns { healthy, issues, repaired }.
|
|
621
|
+
|
|
622
|
+
function healthCheck() {
|
|
623
|
+
const settings = readJson(SETTINGS_PATH) || {};
|
|
624
|
+
const issues = [];
|
|
625
|
+
|
|
626
|
+
// Check statusLine path
|
|
627
|
+
if (isOurComposite(settings)) {
|
|
628
|
+
const m = settings.statusLine.command.match(/node\s+"([^"]+)"/);
|
|
629
|
+
if (m && m[1] && !fs.existsSync(m[1])) {
|
|
630
|
+
issues.push({ type: 'statusLine', path: m[1] });
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
// Check hook paths
|
|
635
|
+
if (settings.hooks) {
|
|
636
|
+
for (const [event, entries] of Object.entries(settings.hooks)) {
|
|
637
|
+
if (!Array.isArray(entries)) continue;
|
|
638
|
+
for (const entry of entries) {
|
|
639
|
+
if (!isOurHookEntry(entry) || !entry.hooks) continue;
|
|
640
|
+
for (const h of entry.hooks) {
|
|
641
|
+
const m = h.command && h.command.match(/node\s+"([^"]+)"/);
|
|
642
|
+
if (m && m[1] && !fs.existsSync(m[1])) {
|
|
643
|
+
issues.push({ type: 'hook', event, path: m[1] });
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
// Check registry paths
|
|
651
|
+
const registry = readRegistry();
|
|
652
|
+
for (const provider of registry) {
|
|
653
|
+
if (provider.id === '_previous') continue;
|
|
654
|
+
const m = provider.command && provider.command.match(/node\s+"([^"]+)"/);
|
|
655
|
+
if (m && m[1] && !fs.existsSync(m[1])) {
|
|
656
|
+
issues.push({ type: 'registry', id: provider.id, path: m[1] });
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
// Auto-repair if issues found
|
|
661
|
+
let repaired = false;
|
|
662
|
+
if (issues.length > 0) {
|
|
663
|
+
install();
|
|
664
|
+
repaired = true;
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
return { healthy: issues.length === 0, issues, repaired };
|
|
668
|
+
}
|
|
669
|
+
|
|
509
670
|
module.exports = {
|
|
510
|
-
install, uninstall, update, checkScopeConflict,
|
|
671
|
+
install, uninstall, update, healthCheck, checkScopeConflict,
|
|
511
672
|
isPluginExplicitlyDisabled, isPluginInactive, cleanupDisabledStatusline,
|
|
512
673
|
readManifest, readJson, writeJsonAtomic,
|
|
513
674
|
readRegistry, writeRegistry,
|
|
514
675
|
getPluginVersion, cleanupOldCacheVersions,
|
|
515
676
|
registerHooksToSettings, removeHooksFromSettings, getHookDefinitions,
|
|
677
|
+
scanPluginHooksJsonCopies, findStalePluginHooksJson, clearStalePluginCacheHooks,
|
|
516
678
|
PLUGIN_ID, OLD_PLUGIN_IDS, MARKETPLACE_NAME, CACHE_DIR, REGISTRY_FILE,
|
|
517
679
|
};
|
|
518
680
|
|
|
519
|
-
// CLI: node lifecycle.js <install|uninstall|update>
|
|
681
|
+
// CLI: node lifecycle.js <install|uninstall|update|health>
|
|
520
682
|
if (require.main === module) {
|
|
521
683
|
const cmd = process.argv[2];
|
|
522
684
|
if (cmd === 'install') {
|
|
@@ -528,8 +690,23 @@ if (require.main === module) {
|
|
|
528
690
|
} else if (cmd === 'update') {
|
|
529
691
|
const r = update();
|
|
530
692
|
console.log(`Updated ${r.oldVersion} → ${r.version} | settings=${r.settingsChanged}`);
|
|
693
|
+
} else if (cmd === 'health') {
|
|
694
|
+
const r = healthCheck();
|
|
695
|
+
if (r.healthy) {
|
|
696
|
+
console.log('Health: OK — all paths valid');
|
|
697
|
+
} else {
|
|
698
|
+
console.log(`Health: ${r.issues.length} issue(s) found${r.repaired ? ' — repaired' : ''}`);
|
|
699
|
+
for (const issue of r.issues) {
|
|
700
|
+
console.log(` ${issue.type}: ${issue.path || issue.id}`);
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
} else if (cmd === 'doctor') {
|
|
704
|
+
const { runDoctor } = require('./doctor');
|
|
705
|
+
const checkOnly = process.argv.includes('--check-only');
|
|
706
|
+
const { issueCount } = runDoctor({ checkOnly });
|
|
707
|
+
process.exit(issueCount > 0 ? 1 : 0);
|
|
531
708
|
} else {
|
|
532
|
-
console.error('Usage: lifecycle.js <install|uninstall|update>');
|
|
709
|
+
console.error('Usage: lifecycle.js <install|uninstall|update|health|doctor>');
|
|
533
710
|
process.exit(1);
|
|
534
711
|
}
|
|
535
712
|
}
|
|
@@ -94,4 +94,82 @@ test('cleanupDisabledStatusline also heals orphaned statusline after uninstall',
|
|
|
94
94
|
const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
|
|
95
95
|
assert.equal(settings.statusLine.command, 'echo previous-status');
|
|
96
96
|
assert.equal(fs.existsSync(registryPath), false);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
function nonEmptyHooksJson() {
|
|
100
|
+
return {
|
|
101
|
+
hooks: {
|
|
102
|
+
SessionStart: [{
|
|
103
|
+
matcher: 'startup',
|
|
104
|
+
hooks: [{ type: 'command', command: 'node "/plugin/session-init.js"' }],
|
|
105
|
+
}],
|
|
106
|
+
},
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
test('findStalePluginHooksJson detects non-empty cache and marketplace copies', () => {
|
|
111
|
+
const homeDir = mkHome();
|
|
112
|
+
const mpHooks = path.join(homeDir, '.claude', 'plugins', 'marketplaces', 'code-graph-mcp', 'claude-plugin', 'hooks', 'hooks.json');
|
|
113
|
+
const mpManifest = path.join(homeDir, '.claude', 'plugins', 'marketplaces', 'code-graph-mcp', '.claude-plugin', 'marketplace.json');
|
|
114
|
+
const cacheHooks = path.join(homeDir, '.claude', 'plugins', 'cache', 'code-graph-mcp', 'code-graph-mcp', '0.7.17', 'hooks', 'hooks.json');
|
|
115
|
+
|
|
116
|
+
writeJson(mpManifest, { name: 'code-graph-mcp' });
|
|
117
|
+
writeJson(mpHooks, nonEmptyHooksJson());
|
|
118
|
+
writeJson(cacheHooks, nonEmptyHooksJson());
|
|
119
|
+
|
|
120
|
+
const out = execFileSync(process.execPath, ['-e', `
|
|
121
|
+
const { findStalePluginHooksJson } = require(${JSON.stringify(lifecyclePath)});
|
|
122
|
+
process.stdout.write(JSON.stringify(findStalePluginHooksJson()));
|
|
123
|
+
`], { env: { ...process.env, HOME: homeDir } }).toString();
|
|
124
|
+
|
|
125
|
+
const stale = JSON.parse(out).sort();
|
|
126
|
+
assert.equal(stale.length, 2);
|
|
127
|
+
assert.ok(stale.some(p => p === mpHooks));
|
|
128
|
+
assert.ok(stale.some(p => p === cacheHooks));
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test('clearStalePluginCacheHooks empties non-empty hooks.json copies', () => {
|
|
132
|
+
const homeDir = mkHome();
|
|
133
|
+
const cacheHooks = path.join(homeDir, '.claude', 'plugins', 'cache', 'code-graph-mcp', 'code-graph-mcp', '0.7.17', 'hooks', 'hooks.json');
|
|
134
|
+
writeJson(cacheHooks, nonEmptyHooksJson());
|
|
135
|
+
|
|
136
|
+
const out = execFileSync(process.execPath, ['-e', `
|
|
137
|
+
const { clearStalePluginCacheHooks } = require(${JSON.stringify(lifecyclePath)});
|
|
138
|
+
process.stdout.write(JSON.stringify(clearStalePluginCacheHooks()));
|
|
139
|
+
`], { env: { ...process.env, HOME: homeDir } }).toString();
|
|
140
|
+
|
|
141
|
+
const cleared = JSON.parse(out);
|
|
142
|
+
assert.deepEqual(cleared, [cacheHooks]);
|
|
143
|
+
|
|
144
|
+
const payload = JSON.parse(fs.readFileSync(cacheHooks, 'utf8'));
|
|
145
|
+
assert.deepEqual(payload.hooks, {});
|
|
146
|
+
assert.ok(payload._note && payload._note.includes('cleared'));
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test('clearStalePluginCacheHooks is idempotent and skips already-empty copies', () => {
|
|
150
|
+
const homeDir = mkHome();
|
|
151
|
+
const cacheHooks = path.join(homeDir, '.claude', 'plugins', 'cache', 'code-graph-mcp', 'code-graph-mcp', '0.7.17', 'hooks', 'hooks.json');
|
|
152
|
+
writeJson(cacheHooks, { hooks: {} });
|
|
153
|
+
|
|
154
|
+
const out = execFileSync(process.execPath, ['-e', `
|
|
155
|
+
const { clearStalePluginCacheHooks } = require(${JSON.stringify(lifecyclePath)});
|
|
156
|
+
process.stdout.write(JSON.stringify(clearStalePluginCacheHooks()));
|
|
157
|
+
`], { env: { ...process.env, HOME: homeDir } }).toString();
|
|
158
|
+
|
|
159
|
+
assert.deepEqual(JSON.parse(out), []);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test('scanPluginHooksJsonCopies ignores unrelated marketplaces', () => {
|
|
163
|
+
const homeDir = mkHome();
|
|
164
|
+
const otherMp = path.join(homeDir, '.claude', 'plugins', 'marketplaces', 'some-other-plugin', 'claude-plugin', 'hooks', 'hooks.json');
|
|
165
|
+
const otherManifest = path.join(homeDir, '.claude', 'plugins', 'marketplaces', 'some-other-plugin', '.claude-plugin', 'marketplace.json');
|
|
166
|
+
writeJson(otherManifest, { name: 'some-other-plugin' });
|
|
167
|
+
writeJson(otherMp, nonEmptyHooksJson());
|
|
168
|
+
|
|
169
|
+
const out = execFileSync(process.execPath, ['-e', `
|
|
170
|
+
const { scanPluginHooksJsonCopies } = require(${JSON.stringify(lifecyclePath)});
|
|
171
|
+
process.stdout.write(JSON.stringify(scanPluginHooksJsonCopies()));
|
|
172
|
+
`], { env: { ...process.env, HOME: homeDir } }).toString();
|
|
173
|
+
|
|
174
|
+
assert.deepEqual(JSON.parse(out), []);
|
|
97
175
|
});
|
|
@@ -12,7 +12,8 @@ const path = require('path');
|
|
|
12
12
|
const fs = require('fs');
|
|
13
13
|
|
|
14
14
|
// Set plugin root so find-binary.js can locate bundled/dev binaries
|
|
15
|
-
|
|
15
|
+
// Always derive from __dirname — CLAUDE_PLUGIN_ROOT can leak from other plugins
|
|
16
|
+
process.env._FIND_BINARY_ROOT = path.resolve(__dirname, '..');
|
|
16
17
|
|
|
17
18
|
const { findBinary, clearCache } = require('./find-binary');
|
|
18
19
|
|
|
@@ -6,8 +6,10 @@ const os = require('os');
|
|
|
6
6
|
const fs = require('fs');
|
|
7
7
|
const {
|
|
8
8
|
install, update, readManifest, getPluginVersion, checkScopeConflict,
|
|
9
|
-
cleanupDisabledStatusline, isPluginInactive, readJson,
|
|
9
|
+
cleanupDisabledStatusline, isPluginInactive, readJson, CACHE_DIR,
|
|
10
|
+
clearStalePluginCacheHooks, findStalePluginHooksJson,
|
|
10
11
|
} = require('./lifecycle');
|
|
12
|
+
const { readBinaryVersion, isDevMode, getNewestMtime } = require('./version-utils');
|
|
11
13
|
|
|
12
14
|
function launchBackgroundAutoUpdate(spawnFn = spawn, env = process.env) {
|
|
13
15
|
try {
|
|
@@ -35,8 +37,8 @@ function syncLifecycleConfig() {
|
|
|
35
37
|
update();
|
|
36
38
|
return 'updated';
|
|
37
39
|
}
|
|
38
|
-
// Self-heal: version matches but statusLine may have been lost
|
|
39
|
-
// (e.g. plugin removed and reinstalled
|
|
40
|
+
// Self-heal: version matches but statusLine may have been lost or path corrupted
|
|
41
|
+
// (e.g. plugin removed and reinstalled, or CLAUDE_PLUGIN_ROOT leaked from another plugin).
|
|
40
42
|
// install() is idempotent — isOurComposite guard prevents duplicate work.
|
|
41
43
|
const settings = readJson(path.join(os.homedir(), '.claude', 'settings.json')) || {};
|
|
42
44
|
if (!settings.statusLine || !settings.statusLine.command ||
|
|
@@ -44,6 +46,28 @@ function syncLifecycleConfig() {
|
|
|
44
46
|
install();
|
|
45
47
|
return 'self-healed';
|
|
46
48
|
}
|
|
49
|
+
// Also self-heal if composite path points to a non-existent script (path pollution)
|
|
50
|
+
const scriptMatch = settings.statusLine.command.match(/node\s+"([^"]+)"/);
|
|
51
|
+
if (scriptMatch && scriptMatch[1] && !fs.existsSync(scriptMatch[1])) {
|
|
52
|
+
install();
|
|
53
|
+
return 'self-healed-bad-path';
|
|
54
|
+
}
|
|
55
|
+
// Self-heal if any hook command points to a non-existent script (path pollution)
|
|
56
|
+
if (settings.hooks) {
|
|
57
|
+
for (const entries of Object.values(settings.hooks)) {
|
|
58
|
+
if (!Array.isArray(entries)) continue;
|
|
59
|
+
for (const entry of entries) {
|
|
60
|
+
if (!entry.hooks) continue;
|
|
61
|
+
for (const h of entry.hooks) {
|
|
62
|
+
const m = h.command && h.command.match(/node\s+"([^"]+)"/);
|
|
63
|
+
if (m && m[1] && m[1].includes('code-graph') && !fs.existsSync(m[1])) {
|
|
64
|
+
install();
|
|
65
|
+
return 'self-healed-bad-hook';
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
47
71
|
return 'noop';
|
|
48
72
|
}
|
|
49
73
|
|
|
@@ -139,6 +163,94 @@ function verifyBinary() {
|
|
|
139
163
|
return { available: true, binary };
|
|
140
164
|
}
|
|
141
165
|
|
|
166
|
+
/**
|
|
167
|
+
* Lightweight consistency checks — called from runSessionInit().
|
|
168
|
+
* Returns an array of issue objects: { id, msg, fix }.
|
|
169
|
+
* Empty array = all consistent (silent).
|
|
170
|
+
*/
|
|
171
|
+
function consistencyCheck(binary) {
|
|
172
|
+
const issues = [];
|
|
173
|
+
|
|
174
|
+
// Check 1: Binary version vs plugin version
|
|
175
|
+
try {
|
|
176
|
+
const pluginVersion = getPluginVersion();
|
|
177
|
+
const binaryVersion = readBinaryVersion(binary);
|
|
178
|
+
if (binaryVersion && binaryVersion !== pluginVersion) {
|
|
179
|
+
issues.push({
|
|
180
|
+
id: 'version-mismatch',
|
|
181
|
+
msg: `Binary v${binaryVersion}, plugin expects v${pluginVersion}`,
|
|
182
|
+
fix: isDevMode() ? 'cargo build --release' : 'code-graph-mcp doctor',
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
} catch { /* skip check on error */ }
|
|
186
|
+
|
|
187
|
+
// Check 2: Source freshness (dev mode only)
|
|
188
|
+
try {
|
|
189
|
+
if (isDevMode()) {
|
|
190
|
+
const srcDir = path.resolve(__dirname, '..', '..', 'src');
|
|
191
|
+
const binaryMtime = fs.statSync(binary).mtimeMs;
|
|
192
|
+
const latestSrcMtime = getNewestMtime(srcDir, '.rs');
|
|
193
|
+
if (latestSrcMtime > binaryMtime) {
|
|
194
|
+
const deltaMin = Math.round((latestSrcMtime - binaryMtime) / 60000);
|
|
195
|
+
issues.push({
|
|
196
|
+
id: 'binary-stale',
|
|
197
|
+
msg: `src/ modified ${deltaMin}min after last build`,
|
|
198
|
+
fix: 'cargo build --release',
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
} catch { /* skip check on error */ }
|
|
203
|
+
|
|
204
|
+
// Check 3: Auto-update incomplete
|
|
205
|
+
try {
|
|
206
|
+
const statePath = path.join(CACHE_DIR, 'update-state.json');
|
|
207
|
+
const state = readJson(statePath);
|
|
208
|
+
if (state && state.updateAvailable && state.binaryUpdated === false) {
|
|
209
|
+
issues.push({
|
|
210
|
+
id: 'update-incomplete',
|
|
211
|
+
msg: `Plugin updated to v${state.latestVersion}, binary not updated`,
|
|
212
|
+
fix: 'code-graph-mcp doctor',
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
} catch { /* skip check on error */ }
|
|
216
|
+
|
|
217
|
+
// Output warnings to stderr
|
|
218
|
+
if (issues.length > 0) {
|
|
219
|
+
const lines = [`[code-graph] ${issues.length} consistency issue(s):`];
|
|
220
|
+
issues.forEach((issue, i) => {
|
|
221
|
+
lines.push(` ${i + 1}. ${issue.msg}`);
|
|
222
|
+
lines.push(` → ${issue.fix}`);
|
|
223
|
+
});
|
|
224
|
+
process.stderr.write(lines.join('\n') + '\n');
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
return issues;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Self-heal: Claude Code auto-update can re-populate cache hooks.json from the
|
|
232
|
+
* marketplace source, which would double-fire every hook we registered to
|
|
233
|
+
* settings.json. If our hooks are already in settings.json (install has run),
|
|
234
|
+
* any non-empty cache/marketplace hooks.json is stale — clear it.
|
|
235
|
+
* Gated on settings.json registration so pure plugin-only users (no install
|
|
236
|
+
* script run; cache hooks.json is their only registration) are not broken.
|
|
237
|
+
*/
|
|
238
|
+
function healStaleCacheHooks() {
|
|
239
|
+
try {
|
|
240
|
+
const settings = readJson(path.join(os.homedir(), '.claude', 'settings.json')) || {};
|
|
241
|
+
const manifest = readManifest();
|
|
242
|
+
if (!manifest || !manifest.version) return { checked: false, cleared: 0 };
|
|
243
|
+
const serialized = JSON.stringify(settings.hooks || {});
|
|
244
|
+
if (!serialized.includes('code-graph')) return { checked: false, cleared: 0 };
|
|
245
|
+
const stale = findStalePluginHooksJson();
|
|
246
|
+
if (stale.length === 0) return { checked: true, cleared: 0 };
|
|
247
|
+
const cleared = clearStalePluginCacheHooks();
|
|
248
|
+
return { checked: true, cleared: cleared.length };
|
|
249
|
+
} catch {
|
|
250
|
+
return { checked: false, cleared: 0 };
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
142
254
|
function runSessionInit() {
|
|
143
255
|
if (isPluginInactive()) {
|
|
144
256
|
cleanupDisabledStatusline();
|
|
@@ -155,13 +267,24 @@ function runSessionInit() {
|
|
|
155
267
|
|
|
156
268
|
const lifecycle = syncLifecycleConfig();
|
|
157
269
|
|
|
270
|
+
// Self-heal stale plugin cache hooks.json (prevents double-firing after auto-update).
|
|
271
|
+
// syncLifecycleConfig's install/update path already clears; this catches the
|
|
272
|
+
// 'noop' case where version matches but cache was re-populated externally.
|
|
273
|
+
const cacheHookHeal = healStaleCacheHooks();
|
|
274
|
+
|
|
158
275
|
// Verify binary availability — catch issues early with actionable diagnostics
|
|
159
276
|
const binaryCheck = verifyBinary();
|
|
160
277
|
|
|
161
278
|
const autoUpdateLaunched = launchBackgroundAutoUpdate();
|
|
162
279
|
const indexFreshness = binaryCheck.available ? ensureIndexFresh() : 'skipped';
|
|
163
280
|
const mapInjected = binaryCheck.available ? injectProjectMap() : false;
|
|
164
|
-
|
|
281
|
+
const consistencyIssues = binaryCheck.available
|
|
282
|
+
? consistencyCheck(binaryCheck.binary)
|
|
283
|
+
: [];
|
|
284
|
+
return {
|
|
285
|
+
inactive: false, lifecycle, cacheHookHeal,
|
|
286
|
+
autoUpdateLaunched, indexFreshness, mapInjected, binaryCheck, consistencyIssues,
|
|
287
|
+
};
|
|
165
288
|
}
|
|
166
289
|
|
|
167
290
|
/**
|
|
@@ -199,6 +322,8 @@ module.exports = {
|
|
|
199
322
|
ensureIndexFresh,
|
|
200
323
|
injectProjectMap,
|
|
201
324
|
verifyBinary,
|
|
325
|
+
consistencyCheck,
|
|
326
|
+
healStaleCacheHooks,
|
|
202
327
|
runSessionInit,
|
|
203
328
|
};
|
|
204
329
|
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
const test = require('node:test');
|
|
3
3
|
const assert = require('node:assert/strict');
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const path = require('path');
|
|
4
6
|
|
|
5
7
|
const { launchBackgroundAutoUpdate, syncLifecycleConfig, ensureIndexFresh, verifyBinary } = require('./session-init');
|
|
6
8
|
|
|
@@ -70,3 +72,34 @@ test('launchBackgroundAutoUpdate spawns detached silent updater', () => {
|
|
|
70
72
|
assert.equal(calls[0].unrefCalled, true);
|
|
71
73
|
});
|
|
72
74
|
|
|
75
|
+
const { consistencyCheck } = require('./session-init');
|
|
76
|
+
|
|
77
|
+
test('consistencyCheck is exported as a function', () => {
|
|
78
|
+
assert.equal(typeof consistencyCheck, 'function');
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test('consistencyCheck returns empty array when binary version matches plugin', () => {
|
|
82
|
+
const result = consistencyCheck('/tmp/nonexistent-binary');
|
|
83
|
+
assert.ok(Array.isArray(result));
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test('consistencyCheck returns version-mismatch when versions differ', () => {
|
|
87
|
+
const os = require('os');
|
|
88
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cc-'));
|
|
89
|
+
const bin = path.join(dir, 'code-graph-mcp');
|
|
90
|
+
fs.writeFileSync(bin, [
|
|
91
|
+
'#!/usr/bin/env bash',
|
|
92
|
+
'if [ "$1" = "--version" ]; then',
|
|
93
|
+
' echo "code-graph-mcp 0.0.1"',
|
|
94
|
+
' exit 0',
|
|
95
|
+
'fi',
|
|
96
|
+
'exit 0',
|
|
97
|
+
].join('\n'));
|
|
98
|
+
fs.chmodSync(bin, 0o755);
|
|
99
|
+
|
|
100
|
+
const issues = consistencyCheck(bin);
|
|
101
|
+
const versionIssue = issues.find(i => i.id === 'version-mismatch');
|
|
102
|
+
assert.ok(versionIssue, 'should detect version mismatch');
|
|
103
|
+
assert.ok(versionIssue.msg.includes('0.0.1'));
|
|
104
|
+
});
|
|
105
|
+
|
|
@@ -82,8 +82,8 @@ function parseCommand(cmd) {
|
|
|
82
82
|
}
|
|
83
83
|
|
|
84
84
|
function codeGraphCommand() {
|
|
85
|
-
|
|
86
|
-
return `node "${path.join(
|
|
85
|
+
// Always derive from __dirname — CLAUDE_PLUGIN_ROOT can leak from other plugins
|
|
86
|
+
return `node "${path.join(__dirname, 'statusline.js')}"`;
|
|
87
87
|
}
|
|
88
88
|
|
|
89
89
|
module.exports = { run };
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const { execFileSync } = require('child_process');
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
|
|
6
|
+
const VERSION_OUTPUT_RE = /^code-graph-mcp\s+(\d+\.\d+\.\d+)$/;
|
|
7
|
+
|
|
8
|
+
function readBinaryVersion(binaryPath) {
|
|
9
|
+
try {
|
|
10
|
+
const out = execFileSync(binaryPath, ['--version'], {
|
|
11
|
+
timeout: 2000,
|
|
12
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
13
|
+
}).toString().trim();
|
|
14
|
+
const match = out.match(VERSION_OUTPUT_RE);
|
|
15
|
+
return match ? match[1] : null;
|
|
16
|
+
} catch {
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function isDevMode() {
|
|
22
|
+
// Always derive from __dirname — CLAUDE_PLUGIN_ROOT can leak from other plugins
|
|
23
|
+
const pluginRoot = path.resolve(__dirname, '..');
|
|
24
|
+
// Dev mode: running from source repo (has Cargo.toml nearby)
|
|
25
|
+
if (fs.existsSync(path.join(pluginRoot, '..', 'Cargo.toml'))) return true;
|
|
26
|
+
// Dev mode: plugin root is a symlink
|
|
27
|
+
try { if (fs.lstatSync(pluginRoot).isSymbolicLink()) return true; } catch { /* ok */ }
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function getNewestMtime(dir, ext = '.rs') {
|
|
32
|
+
let newest = 0;
|
|
33
|
+
try {
|
|
34
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
35
|
+
for (const entry of entries) {
|
|
36
|
+
const full = path.join(dir, entry.name);
|
|
37
|
+
if (entry.isDirectory()) {
|
|
38
|
+
const sub = getNewestMtime(full, ext);
|
|
39
|
+
if (sub > newest) newest = sub;
|
|
40
|
+
} else if (entry.name.endsWith(ext)) {
|
|
41
|
+
const mt = fs.statSync(full).mtimeMs;
|
|
42
|
+
if (mt > newest) newest = mt;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
} catch { /* dir doesn't exist or not readable */ }
|
|
46
|
+
return newest;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
module.exports = { readBinaryVersion, isDevMode, getNewestMtime, VERSION_OUTPUT_RE };
|
|
@@ -0,0 +1,83 @@
|
|
|
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
|
+
function mkDir(prefix) {
|
|
9
|
+
return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// ── readBinaryVersion ──
|
|
13
|
+
|
|
14
|
+
test('readBinaryVersion returns version from valid binary', () => {
|
|
15
|
+
const { readBinaryVersion } = require('./version-utils');
|
|
16
|
+
const dir = mkDir('vu-');
|
|
17
|
+
const bin = path.join(dir, 'code-graph-mcp');
|
|
18
|
+
fs.writeFileSync(bin, [
|
|
19
|
+
'#!/usr/bin/env bash',
|
|
20
|
+
'if [ "$1" = "--version" ]; then',
|
|
21
|
+
' echo "code-graph-mcp 1.2.3"',
|
|
22
|
+
' exit 0',
|
|
23
|
+
'fi',
|
|
24
|
+
'exit 0',
|
|
25
|
+
].join('\n'));
|
|
26
|
+
fs.chmodSync(bin, 0o755);
|
|
27
|
+
assert.equal(readBinaryVersion(bin), '1.2.3');
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test('readBinaryVersion returns null for non-existent binary', () => {
|
|
31
|
+
const { readBinaryVersion } = require('./version-utils');
|
|
32
|
+
assert.equal(readBinaryVersion('/tmp/does-not-exist-binary'), null);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test('readBinaryVersion returns null for binary with unexpected output', () => {
|
|
36
|
+
const { readBinaryVersion } = require('./version-utils');
|
|
37
|
+
const dir = mkDir('vu-');
|
|
38
|
+
const bin = path.join(dir, 'code-graph-mcp');
|
|
39
|
+
fs.writeFileSync(bin, '#!/usr/bin/env bash\necho "something else"');
|
|
40
|
+
fs.chmodSync(bin, 0o755);
|
|
41
|
+
assert.equal(readBinaryVersion(bin), null);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
// ── isDevMode ──
|
|
45
|
+
|
|
46
|
+
test('isDevMode returns true in source repo (Cargo.toml nearby)', () => {
|
|
47
|
+
const { isDevMode } = require('./version-utils');
|
|
48
|
+
// Running from source repo: __dirname/../.. has Cargo.toml → true
|
|
49
|
+
assert.equal(isDevMode(), true);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
// ── getNewestMtime ──
|
|
53
|
+
|
|
54
|
+
test('getNewestMtime returns 0 for non-existent directory', () => {
|
|
55
|
+
const { getNewestMtime } = require('./version-utils');
|
|
56
|
+
assert.equal(getNewestMtime('/tmp/no-such-dir-xyz'), 0);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test('getNewestMtime finds newest .rs file mtime', () => {
|
|
60
|
+
const { getNewestMtime } = require('./version-utils');
|
|
61
|
+
const dir = mkDir('vu-mtime-');
|
|
62
|
+
const sub = path.join(dir, 'sub');
|
|
63
|
+
fs.mkdirSync(sub);
|
|
64
|
+
|
|
65
|
+
const older = path.join(dir, 'old.rs');
|
|
66
|
+
const newer = path.join(sub, 'new.rs');
|
|
67
|
+
fs.writeFileSync(older, 'fn old() {}');
|
|
68
|
+
|
|
69
|
+
fs.writeFileSync(newer, 'fn new() {}');
|
|
70
|
+
const futureMs = Date.now() + 1000;
|
|
71
|
+
fs.utimesSync(newer, futureMs / 1000, futureMs / 1000);
|
|
72
|
+
|
|
73
|
+
const newerMtime = fs.statSync(newer).mtimeMs;
|
|
74
|
+
const result = getNewestMtime(dir, '.rs');
|
|
75
|
+
assert.equal(result, newerMtime, 'should return exactly the newest file mtime');
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test('getNewestMtime ignores non-matching extensions', () => {
|
|
79
|
+
const { getNewestMtime } = require('./version-utils');
|
|
80
|
+
const dir = mkDir('vu-ext-');
|
|
81
|
+
fs.writeFileSync(path.join(dir, 'file.js'), 'hello');
|
|
82
|
+
assert.equal(getNewestMtime(dir, '.rs'), 0);
|
|
83
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sdsrs/code-graph",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.18",
|
|
4
4
|
"description": "MCP server that indexes codebases into an AST knowledge graph with semantic search, call graph traversal, and HTTP route tracing",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -34,10 +34,10 @@
|
|
|
34
34
|
"node": ">=16"
|
|
35
35
|
},
|
|
36
36
|
"optionalDependencies": {
|
|
37
|
-
"@sdsrs/code-graph-linux-x64": "0.7.
|
|
38
|
-
"@sdsrs/code-graph-linux-arm64": "0.7.
|
|
39
|
-
"@sdsrs/code-graph-darwin-x64": "0.7.
|
|
40
|
-
"@sdsrs/code-graph-darwin-arm64": "0.7.
|
|
41
|
-
"@sdsrs/code-graph-win32-x64": "0.7.
|
|
37
|
+
"@sdsrs/code-graph-linux-x64": "0.7.18",
|
|
38
|
+
"@sdsrs/code-graph-linux-arm64": "0.7.18",
|
|
39
|
+
"@sdsrs/code-graph-darwin-x64": "0.7.18",
|
|
40
|
+
"@sdsrs/code-graph-darwin-arm64": "0.7.18",
|
|
41
|
+
"@sdsrs/code-graph-win32-x64": "0.7.18"
|
|
42
42
|
}
|
|
43
43
|
}
|