@sdsrs/code-graph 0.5.27 → 0.5.29

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.
@@ -6,25 +6,43 @@ const fs = require('fs');
6
6
  const os = require('os');
7
7
 
8
8
  const PLATFORM = os.platform();
9
+ const ARCH = os.arch();
9
10
  const CACHE_FILE = path.join(os.homedir(), '.cache', 'code-graph', 'binary-path');
11
+ const BINARY_NAME = PLATFORM === 'win32' ? 'code-graph-mcp.exe' : 'code-graph-mcp';
12
+
13
+ function isNativeBinary(candidate) {
14
+ if (!candidate) return false;
15
+ try {
16
+ if (!fs.existsSync(candidate)) return false;
17
+ const realPath = fs.realpathSync(candidate);
18
+ return path.basename(realPath) === BINARY_NAME;
19
+ } catch {
20
+ return false;
21
+ }
22
+ }
10
23
 
11
24
  /**
12
25
  * Locate the code-graph-mcp binary using multiple strategies.
13
26
  * Results are cached to disk so repeated calls (e.g. per-hook) are fast.
14
- * Priority: cache > PATH > local dev build > cargo install > npm platform pkg > npx cache
27
+ *
28
+ * Priority:
29
+ * cache (if valid) → dev-mode (target/release) → auto-update cache
30
+ * → platform npm pkg → bundled (bin/) → cargo install → PATH → npx cache
31
+ *
15
32
  * Returns the absolute path or null if not found.
16
33
  */
17
34
  function findBinary() {
18
35
  // Try disk cache first (avoids spawning `which` on hot paths)
19
36
  try {
20
37
  const cached = fs.readFileSync(CACHE_FILE, 'utf8').trim();
21
- if (cached && fs.existsSync(cached)) return cached;
38
+ if (isNativeBinary(cached)) return cached;
39
+ if (cached) clearCache();
22
40
  } catch { /* no cache or stale */ }
23
41
 
24
42
  const result = findBinaryUncached();
25
43
 
26
44
  // Write cache for subsequent calls
27
- if (result) {
45
+ if (isNativeBinary(result)) {
28
46
  try {
29
47
  fs.mkdirSync(path.dirname(CACHE_FILE), { recursive: true });
30
48
  fs.writeFileSync(CACHE_FILE, result);
@@ -34,50 +52,95 @@ function findBinary() {
34
52
  return result;
35
53
  }
36
54
 
55
+ /**
56
+ * Detect if we're running from the source repo (e.g. npm link).
57
+ * Checks relative to a given root directory for Cargo.toml.
58
+ */
59
+ function isDevRepo(rootDir) {
60
+ return fs.existsSync(path.join(rootDir, 'Cargo.toml'));
61
+ }
62
+
37
63
  function findBinaryUncached() {
38
- const name = PLATFORM === 'win32' ? 'code-graph-mcp.exe' : 'code-graph-mcp';
64
+ // --- Dev mode: always prefer cargo build output when running from source repo ---
65
+ // This covers: npm link, direct invocation from repo, CLAUDE_PROJECT_DIR set to repo
66
+ const possibleRoots = new Set();
39
67
 
40
- // 1. PATH lookup (user has intentionally installed it)
41
- try {
42
- const which = PLATFORM === 'win32' ? 'where' : 'which';
43
- const found = execFileSync(which, [name], { stdio: ['pipe', 'pipe', 'pipe'] })
44
- .toString().trim().split('\n')[0];
45
- if (found && fs.existsSync(found)) return found;
46
- } catch { /* not in PATH */ }
68
+ // From plugin scripts context (claude-plugin/scripts/ repo root is ../..)
69
+ possibleRoots.add(path.resolve(__dirname, '..', '..'));
70
+ // From bin/ context (cli.js sets FIND_BINARY_ROOT)
71
+ if (process.env._FIND_BINARY_ROOT) {
72
+ possibleRoots.add(path.resolve(process.env._FIND_BINARY_ROOT));
73
+ }
74
+ // From CLAUDE_PROJECT_DIR
75
+ if (process.env.CLAUDE_PROJECT_DIR) {
76
+ possibleRoots.add(path.resolve(process.env.CLAUDE_PROJECT_DIR));
77
+ }
47
78
 
48
- // 2. Local dev build (target/release in project directory)
49
- const projectRoot = process.env.CLAUDE_PROJECT_DIR || process.cwd();
50
- const devBin = path.join(projectRoot, 'target', 'release', name);
51
- if (fs.existsSync(devBin)) return devBin;
79
+ for (const root of possibleRoots) {
80
+ if (isDevRepo(root)) {
81
+ const devBin = path.join(root, 'target', 'release', BINARY_NAME);
82
+ if (isNativeBinary(devBin)) return devBin;
83
+ }
84
+ }
52
85
 
53
- // 3. Cargo install (~/.cargo/bin)
54
- const cargoBin = path.join(os.homedir(), '.cargo', 'bin', name);
55
- if (fs.existsSync(cargoBin)) return cargoBin;
86
+ // --- Auto-update cache (binary downloaded directly from GitHub release) ---
87
+ const autoUpdateBin = path.join(os.homedir(), '.cache', 'code-graph', 'bin', BINARY_NAME);
88
+ if (isNativeBinary(autoUpdateBin)) return autoUpdateBin;
56
89
 
57
- // 4. npm platform package (installed via @sdsrs/code-graph)
58
- const platformPkg = `@sdsrs/code-graph-${PLATFORM}-${os.arch()}`;
90
+ // --- Platform-specific npm package (@sdsrs/code-graph-{os}-{arch}) ---
91
+ const platformPkg = `@sdsrs/code-graph-${PLATFORM}-${ARCH}`;
59
92
  try {
60
93
  const pkgPath = require.resolve(`${platformPkg}/package.json`);
61
- const bin = path.join(path.dirname(pkgPath), name);
62
- if (fs.existsSync(bin)) return bin;
94
+ const bin = path.join(path.dirname(pkgPath), BINARY_NAME);
95
+ if (isNativeBinary(bin)) return bin;
63
96
  } catch { /* not installed via npm */ }
64
97
 
65
- // 5. npx cache (last resort may be outdated)
98
+ // --- Bundled binary (in same directory as cli.js or plugin scripts) ---
99
+ // Check bin/ directory of the npm package
100
+ const binDirs = new Set();
101
+ if (process.env._FIND_BINARY_ROOT) {
102
+ binDirs.add(path.join(process.env._FIND_BINARY_ROOT, 'bin'));
103
+ }
104
+ binDirs.add(path.resolve(__dirname, '..', '..', 'bin'));
105
+ for (const dir of binDirs) {
106
+ const bundled = path.join(dir, BINARY_NAME);
107
+ if (isNativeBinary(bundled)) return bundled;
108
+ }
109
+
110
+ // --- Cargo install (~/.cargo/bin) ---
111
+ const cargoBin = path.join(os.homedir(), '.cargo', 'bin', BINARY_NAME);
112
+ if (isNativeBinary(cargoBin)) return cargoBin;
113
+
114
+ // --- PATH lookup (last resort for intentionally installed binaries) ---
115
+ try {
116
+ const which = PLATFORM === 'win32' ? 'where' : 'which';
117
+ const found = execFileSync(which, [BINARY_NAME], { stdio: ['pipe', 'pipe', 'pipe'] })
118
+ .toString().trim().split('\n')[0];
119
+ if (isNativeBinary(found)) return found;
120
+ } catch { /* not in PATH */ }
121
+
122
+ // --- npx cache (very last resort — may be outdated) ---
66
123
  const npxDir = path.join(os.homedir(), '.npm', '_npx');
67
124
  try {
68
125
  for (const entry of fs.readdirSync(npxDir)) {
69
- const pkgJsonPath = path.join(npxDir, entry, 'node_modules', '@sdsrs', 'code-graph', 'package.json');
70
- if (!fs.existsSync(pkgJsonPath)) continue;
71
- const platDir = path.join(npxDir, entry, 'node_modules', '@sdsrs', `code-graph-${PLATFORM}-${os.arch()}`);
72
- const platBin = path.join(platDir, name);
73
- if (fs.existsSync(platBin)) return platBin;
126
+ const platDir = path.join(npxDir, entry, 'node_modules', '@sdsrs', `code-graph-${PLATFORM}-${ARCH}`);
127
+ const platBin = path.join(platDir, BINARY_NAME);
128
+ if (isNativeBinary(platBin)) return platBin;
74
129
  }
75
130
  } catch { /* no npx cache */ }
76
131
 
77
132
  return null;
78
133
  }
79
134
 
80
- module.exports = { findBinary };
135
+ /**
136
+ * Clear the disk cache. Call this after binary updates so the next
137
+ * findBinary() picks up the new location.
138
+ */
139
+ function clearCache() {
140
+ try { fs.unlinkSync(CACHE_FILE); } catch { /* ok */ }
141
+ }
142
+
143
+ module.exports = { findBinary, findBinaryUncached, clearCache, CACHE_FILE, BINARY_NAME };
81
144
 
82
145
  // Allow direct invocation for testing
83
146
  if (require.main === module) {
@@ -0,0 +1,91 @@
1
+ 'use strict';
2
+ const test = require('node:test');
3
+ const assert = require('node:assert/strict');
4
+ const fs = require('fs');
5
+ const os = require('os');
6
+ const path = require('path');
7
+ const { execFileSync } = require('child_process');
8
+
9
+ const repoRoot = path.resolve(__dirname, '..', '..');
10
+ const pluginRoot = path.resolve(__dirname, '..');
11
+ const lifecycleCli = path.join(__dirname, 'lifecycle.js');
12
+ const compositeCli = path.join(__dirname, 'statusline-composite.js');
13
+ const currentVersion = JSON.parse(fs.readFileSync(path.join(repoRoot, 'package.json'), 'utf8')).version;
14
+
15
+ function mkHome() {
16
+ return fs.mkdtempSync(path.join(os.tmpdir(), 'code-graph-e2e-'));
17
+ }
18
+
19
+ function writeJson(filePath, value) {
20
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
21
+ fs.writeFileSync(filePath, JSON.stringify(value, null, 2) + '\n');
22
+ }
23
+
24
+ function readJson(filePath) {
25
+ return JSON.parse(fs.readFileSync(filePath, 'utf8'));
26
+ }
27
+
28
+ function runScript(homeDir, scriptPath, args = [], options = {}) {
29
+ return execFileSync(process.execPath, [scriptPath, ...args], {
30
+ cwd: options.cwd || repoRoot,
31
+ env: { ...process.env, HOME: homeDir, CLAUDE_PLUGIN_ROOT: pluginRoot },
32
+ input: options.input,
33
+ stdio: ['pipe', 'pipe', 'pipe'],
34
+ }).toString();
35
+ }
36
+
37
+ test('lifecycle CLI handles install, disable self-heal, re-enable, and uninstall', () => {
38
+ const homeDir = mkHome();
39
+ const settingsPath = path.join(homeDir, '.claude', 'settings.json');
40
+ const installedPath = path.join(homeDir, '.claude', 'plugins', 'installed_plugins.json');
41
+ const registryPath = path.join(homeDir, '.cache', 'code-graph', 'statusline-registry.json');
42
+ const manifestPath = path.join(homeDir, '.cache', 'code-graph', 'install-manifest.json');
43
+ const cacheDir = path.join(homeDir, '.cache', 'code-graph');
44
+
45
+ writeJson(settingsPath, {
46
+ statusLine: { type: 'command', command: 'echo previous-status' },
47
+ enabledPlugins: { 'code-graph-mcp@code-graph-mcp': true },
48
+ });
49
+ writeJson(installedPath, {
50
+ plugins: {
51
+ 'code-graph-mcp@code-graph-mcp': [{
52
+ installPath: pluginRoot,
53
+ version: currentVersion,
54
+ scope: 'user',
55
+ }],
56
+ },
57
+ });
58
+
59
+ runScript(homeDir, lifecycleCli, ['install']);
60
+ let settings = readJson(settingsPath);
61
+ let registry = readJson(registryPath);
62
+ let manifest = readJson(manifestPath);
63
+ assert.match(settings.statusLine.command, /statusline-composite\.js/);
64
+ assert.equal(registry[0].id, '_previous');
65
+ assert.equal(registry[1].id, 'code-graph');
66
+ assert.equal(manifest.version, currentVersion);
67
+
68
+ settings.enabledPlugins['code-graph-mcp@code-graph-mcp'] = false;
69
+ writeJson(settingsPath, settings);
70
+ runScript(homeDir, compositeCli, [], { input: '{}' });
71
+ settings = readJson(settingsPath);
72
+ assert.equal(settings.statusLine.command, 'echo previous-status');
73
+ assert.equal(fs.existsSync(registryPath), false);
74
+
75
+ settings.enabledPlugins['code-graph-mcp@code-graph-mcp'] = true;
76
+ writeJson(settingsPath, settings);
77
+ runScript(homeDir, lifecycleCli, ['install']);
78
+ settings = readJson(settingsPath);
79
+ registry = readJson(registryPath);
80
+ assert.match(settings.statusLine.command, /statusline-composite\.js/);
81
+ assert.equal(registry.length, 2);
82
+
83
+ runScript(homeDir, lifecycleCli, ['uninstall']);
84
+ settings = readJson(settingsPath);
85
+ const installed = readJson(installedPath);
86
+ assert.equal(settings.statusLine.command, 'echo previous-status');
87
+ assert.deepEqual(settings.enabledPlugins, {});
88
+ assert.deepEqual(installed.plugins, {});
89
+ assert.equal(fs.existsSync(cacheDir), false);
90
+ });
91
+
@@ -53,6 +53,15 @@ function codeGraphStatuslineCommand() {
53
53
  return `node ${JSON.stringify(path.join(PLUGIN_ROOT, 'scripts', 'statusline.js'))}`;
54
54
  }
55
55
 
56
+ function hasOwn(obj, key) {
57
+ return !!obj && Object.prototype.hasOwnProperty.call(obj, key);
58
+ }
59
+
60
+ function hasInstalledPluginRecord() {
61
+ const installed = readJson(INSTALLED_PLUGINS_PATH);
62
+ return !!(installed && installed.plugins && Array.isArray(installed.plugins[PLUGIN_ID]) && installed.plugins[PLUGIN_ID].length > 0);
63
+ }
64
+
56
65
  function isOurComposite(settings) {
57
66
  return settings.statusLine &&
58
67
  settings.statusLine.command &&
@@ -67,6 +76,10 @@ function readRegistry() {
67
76
  }
68
77
 
69
78
  function writeRegistry(registry) {
79
+ if (!registry || registry.length === 0) {
80
+ try { fs.unlinkSync(REGISTRY_FILE); } catch { /* ok */ }
81
+ return;
82
+ }
70
83
  writeJsonAtomic(REGISTRY_FILE, registry);
71
84
  }
72
85
 
@@ -93,6 +106,58 @@ function unregisterStatuslineProvider(id) {
93
106
  return true;
94
107
  }
95
108
 
109
+ function isPluginExplicitlyDisabled(settings = readJson(SETTINGS_PATH) || {}) {
110
+ return hasOwn(settings.enabledPlugins, PLUGIN_ID) && settings.enabledPlugins[PLUGIN_ID] === false;
111
+ }
112
+
113
+ function isPluginInactive(settings = readJson(SETTINGS_PATH) || {}) {
114
+ if (isPluginExplicitlyDisabled(settings)) return true;
115
+
116
+ const hasComposite = isOurComposite(settings);
117
+ const hasCodeGraphRegistry = readRegistry().some((provider) => provider.id === 'code-graph');
118
+ if (!hasComposite && !hasCodeGraphRegistry) return false;
119
+
120
+ const installed = readJson(INSTALLED_PLUGINS_PATH);
121
+ if (!installed || !installed.plugins) return false;
122
+ return !hasInstalledPluginRecord();
123
+ }
124
+
125
+ function detachStatuslineIntegration(settings) {
126
+ let settingsChanged = false;
127
+
128
+ unregisterStatuslineProvider('code-graph');
129
+ const previous = readRegistry().find(p => p.id === '_previous' && p.command);
130
+
131
+ // If our composite is still configured while the plugin is disabled/uninstalled,
132
+ // prefer restoring the prior statusline (or removing ours entirely) so the plugin
133
+ // truly stops affecting Claude Code.
134
+ if (isOurComposite(settings)) {
135
+ if (previous) {
136
+ settings.statusLine = { type: 'command', command: previous.command };
137
+ } else {
138
+ delete settings.statusLine;
139
+ }
140
+ settingsChanged = true;
141
+ }
142
+
143
+ unregisterStatuslineProvider('_previous');
144
+ return settingsChanged;
145
+ }
146
+
147
+ function cleanupDisabledStatusline() {
148
+ const settings = readJson(SETTINGS_PATH);
149
+ if (!settings || !isPluginInactive(settings)) {
150
+ return { cleaned: false, settingsChanged: false };
151
+ }
152
+
153
+ const settingsChanged = detachStatuslineIntegration(settings);
154
+ if (settingsChanged) {
155
+ writeJsonAtomic(SETTINGS_PATH, settings);
156
+ }
157
+
158
+ return { cleaned: true, settingsChanged };
159
+ }
160
+
96
161
  // --- Scope Conflict Detection ---
97
162
 
98
163
  function checkScopeConflict() {
@@ -207,22 +272,9 @@ function uninstall() {
207
272
  let settingsChanged = false;
208
273
 
209
274
  if (settings) {
210
- // 1. StatusLine: remove code-graph from registry
211
- unregisterStatuslineProvider('code-graph');
212
- const remaining = readRegistry();
213
-
214
- if (isOurComposite(settings)) {
215
- if (remaining.length === 1 && remaining[0].id === '_previous') {
216
- // Only the previous provider remains — restore it directly
217
- settings.statusLine = { type: 'command', command: remaining[0].command };
218
- unregisterStatuslineProvider('_previous');
219
- settingsChanged = true;
220
- } else if (remaining.length === 0) {
221
- // No providers left — remove statusLine entirely
222
- delete settings.statusLine;
223
- settingsChanged = true;
224
- }
225
- // else: other providers still using composite — leave it
275
+ // 1. StatusLine: remove code-graph integration and restore prior statusline.
276
+ if (detachStatuslineIntegration(settings)) {
277
+ settingsChanged = true;
226
278
  }
227
279
 
228
280
  // 2. Remove all known IDs from enabledPlugins
@@ -317,6 +369,7 @@ function update() {
317
369
 
318
370
  module.exports = {
319
371
  install, uninstall, update, checkScopeConflict,
372
+ isPluginExplicitlyDisabled, isPluginInactive, cleanupDisabledStatusline,
320
373
  readManifest, readJson, writeJsonAtomic,
321
374
  readRegistry, writeRegistry,
322
375
  getPluginVersion,
@@ -0,0 +1,97 @@
1
+ 'use strict';
2
+ const test = require('node:test');
3
+ const assert = require('node:assert/strict');
4
+ const fs = require('fs');
5
+ const os = require('os');
6
+ const path = require('path');
7
+ const { execFileSync } = require('child_process');
8
+
9
+ const lifecyclePath = path.join(__dirname, 'lifecycle.js');
10
+ const statuslinePath = path.join(__dirname, 'statusline.js');
11
+
12
+ function mkHome() {
13
+ return fs.mkdtempSync(path.join(os.tmpdir(), 'code-graph-home-'));
14
+ }
15
+
16
+ function writeJson(filePath, value) {
17
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
18
+ fs.writeFileSync(filePath, JSON.stringify(value, null, 2) + '\n');
19
+ }
20
+
21
+ function seedDisabledComposite(homeDir) {
22
+ const settingsPath = path.join(homeDir, '.claude', 'settings.json');
23
+ const registryPath = path.join(homeDir, '.cache', 'code-graph', 'statusline-registry.json');
24
+ writeJson(settingsPath, {
25
+ statusLine: { type: 'command', command: 'node "/plugin/statusline-composite.js"' },
26
+ enabledPlugins: { 'code-graph-mcp@code-graph-mcp': false },
27
+ });
28
+ writeJson(registryPath, [
29
+ { id: '_previous', command: 'echo previous-status', needsStdin: true },
30
+ { id: 'code-graph', command: 'node "/plugin/statusline.js"', needsStdin: false },
31
+ ]);
32
+ return { settingsPath, registryPath };
33
+ }
34
+
35
+ function seedOrphanedComposite(homeDir) {
36
+ const settingsPath = path.join(homeDir, '.claude', 'settings.json');
37
+ const registryPath = path.join(homeDir, '.cache', 'code-graph', 'statusline-registry.json');
38
+ const installedPath = path.join(homeDir, '.claude', 'plugins', 'installed_plugins.json');
39
+ writeJson(settingsPath, {
40
+ statusLine: { type: 'command', command: 'node "/plugin/statusline-composite.js"' },
41
+ enabledPlugins: {},
42
+ });
43
+ writeJson(installedPath, { plugins: {} });
44
+ writeJson(registryPath, [
45
+ { id: '_previous', command: 'echo previous-status', needsStdin: true },
46
+ { id: 'code-graph', command: 'node "/plugin/statusline.js"', needsStdin: false },
47
+ ]);
48
+ return { settingsPath, registryPath };
49
+ }
50
+
51
+ test('cleanupDisabledStatusline restores previous statusline and removes registry', () => {
52
+ const homeDir = mkHome();
53
+ const { settingsPath, registryPath } = seedDisabledComposite(homeDir);
54
+
55
+ const out = execFileSync(process.execPath, ['-e', `
56
+ const { cleanupDisabledStatusline } = require(${JSON.stringify(lifecyclePath)});
57
+ process.stdout.write(JSON.stringify(cleanupDisabledStatusline()));
58
+ `], { env: { ...process.env, HOME: homeDir } }).toString();
59
+
60
+ assert.deepEqual(JSON.parse(out), { cleaned: true, settingsChanged: true });
61
+ const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
62
+ assert.equal(settings.statusLine.command, 'echo previous-status');
63
+ assert.equal(fs.existsSync(registryPath), false);
64
+ });
65
+
66
+ test('statusline exits cleanly and self-heals when plugin is disabled', () => {
67
+ const homeDir = mkHome();
68
+ const { settingsPath, registryPath } = seedDisabledComposite(homeDir);
69
+ const projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'code-graph-project-'));
70
+ fs.mkdirSync(path.join(projectDir, '.code-graph'), { recursive: true });
71
+ fs.writeFileSync(path.join(projectDir, '.code-graph', 'index.db'), '');
72
+
73
+ const stdout = execFileSync(process.execPath, [statuslinePath], {
74
+ env: { ...process.env, HOME: homeDir },
75
+ cwd: projectDir,
76
+ }).toString();
77
+
78
+ assert.equal(stdout, '');
79
+ const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
80
+ assert.equal(settings.statusLine.command, 'echo previous-status');
81
+ assert.equal(fs.existsSync(registryPath), false);
82
+ });
83
+
84
+ test('cleanupDisabledStatusline also heals orphaned statusline after uninstall', () => {
85
+ const homeDir = mkHome();
86
+ const { settingsPath, registryPath } = seedOrphanedComposite(homeDir);
87
+
88
+ const out = execFileSync(process.execPath, ['-e', `
89
+ const { cleanupDisabledStatusline } = require(${JSON.stringify(lifecyclePath)});
90
+ process.stdout.write(JSON.stringify(cleanupDisabledStatusline()));
91
+ `], { env: { ...process.env, HOME: homeDir } }).toString();
92
+
93
+ assert.deepEqual(JSON.parse(out), { cleaned: true, settingsChanged: true });
94
+ const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
95
+ assert.equal(settings.statusLine.command, 'echo previous-status');
96
+ assert.equal(fs.existsSync(registryPath), false);
97
+ });