@sdsrs/code-graph 0.74.3 → 0.74.4

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/bin/cli.js CHANGED
@@ -36,6 +36,36 @@ if (sub === "adopt" || sub === "unadopt") {
36
36
  process.exit(result.ok === false ? 1 : 0);
37
37
  }
38
38
 
39
+ // Intercept `uninstall` — full local teardown (restore prior statusline, strip
40
+ // code-graph hooks from settings.json, delete ~/.cache/code-graph, and unadopt the
41
+ // CURRENT project). Node-only; no Rust counterpart. CC's `/plugin uninstall` fires
42
+ // no uninstall hook, so this is the user's one-shot CLI teardown. Guard `--help`
43
+ // before the destructive work (same discipline as adopt/unadopt above).
44
+ if (sub === "uninstall") {
45
+ if (process.argv.slice(3).some((a) => a === "--help" || a === "-h")) {
46
+ process.stdout.write(
47
+ "code-graph-mcp uninstall — remove code-graph config + cache from this machine\n\n" +
48
+ "USAGE:\n code-graph-mcp uninstall\n\n" +
49
+ "Restores your prior statusline, strips code-graph hooks from settings.json,\n" +
50
+ "deletes ~/.cache/code-graph, and removes this project's CLAUDE.md adoption\n" +
51
+ "block. Also run `/plugin uninstall code-graph-mcp` in Claude Code to sync its\n" +
52
+ "UI, and `code-graph-mcp unadopt` in any OTHER adopted project.\n");
53
+ process.exit(0);
54
+ }
55
+ const lifecycle = require("../claude-plugin/scripts/lifecycle");
56
+ const { unadopt } = require("../claude-plugin/scripts/adopt");
57
+ const r = lifecycle.uninstall();
58
+ let ua = { ok: false };
59
+ try { ua = unadopt(); } catch { /* best-effort — settings/cache already cleaned */ }
60
+ const projectUnadopted = !!(ua && (ua.blockPruned || ua.fileRemoved || ua.claudeMdRemoved));
61
+ process.stdout.write(
62
+ `Uninstalled code-graph-mcp | settings cleaned=${r.settingsChanged}` +
63
+ ` | this project unadopted=${projectUnadopted}\n` +
64
+ " Also run `/plugin uninstall code-graph-mcp` in Claude Code, and\n" +
65
+ " `code-graph-mcp unadopt` in any other adopted project.\n");
66
+ process.exit(0);
67
+ }
68
+
39
69
  const { findBinary, unsupportedPlatformHint } = require("../claude-plugin/scripts/find-binary");
40
70
 
41
71
  const binary = findBinary();
@@ -4,7 +4,7 @@
4
4
  "author": {
5
5
  "name": "sdsrs"
6
6
  },
7
- "version": "0.74.3",
7
+ "version": "0.74.4",
8
8
  "keywords": [
9
9
  "code-graph",
10
10
  "ast",
@@ -871,9 +871,31 @@ function healthCheck() {
871
871
  };
872
872
  }
873
873
 
874
+ // True when the plugin has been UNINSTALLED (removed from installed_plugins.json),
875
+ // as opposed to merely toggled OFF (isPluginExplicitlyDisabled — the user may
876
+ // re-enable). The distinction matters because the uninstall teardown below is
877
+ // destructive (deletes the cached binary, unwinds project adoption); doing that
878
+ // on a temporary disable would force a re-download + re-adopt on re-enable.
879
+ function isPluginUninstalled(settings = readJson(settingsPath()) || {}) {
880
+ if (isPluginExplicitlyDisabled(settings)) return false;
881
+ return isPluginInactive(settings);
882
+ }
883
+
884
+ // Remove the ~/.cache/code-graph residue (the ~40MB binary, update-state,
885
+ // statusline-registry, install-manifest). The settings-only self-heal
886
+ // (cleanupDisabledStatusline) leaves this behind; the SessionStart teardown calls
887
+ // this so a CC `/plugin uninstall` (which fires no uninstall hook) still reclaims
888
+ // the disk. Idempotent: rm is force, so repeat SessionStarts are no-ops. Does NOT
889
+ // touch the plugin-cache script dirs — those are CC-managed and may be executing.
890
+ function removeCacheResidue() {
891
+ try { fs.rmSync(CACHE_DIR, { recursive: true, force: true }); return true; }
892
+ catch { return false; }
893
+ }
894
+
874
895
  module.exports = {
875
896
  install, uninstall, update, healthCheck, scanForBrokenPaths, checkScopeConflict,
876
- isPluginExplicitlyDisabled, isPluginInactive, cleanupDisabledStatusline,
897
+ isPluginExplicitlyDisabled, isPluginInactive, isPluginUninstalled, removeCacheResidue,
898
+ cleanupDisabledStatusline,
877
899
  readManifest, readJson, writeJsonAtomic,
878
900
  readRegistry, writeRegistry,
879
901
  getPluginVersion, cleanupOldCacheVersions,
@@ -898,7 +920,8 @@ if (require.main === module) {
898
920
  } else if (cmd === 'uninstall') {
899
921
  const r = uninstall();
900
922
  console.log(`Uninstalled | settings cleaned=${r.settingsChanged}`);
901
- console.log(' Note: also run `/plugin uninstall code-graph-mcp` inside Claude Code to sync its UI state.');
923
+ console.log(' Note: also run `/plugin uninstall code-graph-mcp` inside Claude Code to sync its UI state,');
924
+ console.log(' and `code-graph-mcp unadopt` in each adopted project to remove its CLAUDE.md block.');
902
925
  } else if (cmd === 'update') {
903
926
  const r = update();
904
927
  console.log(`Updated ${r.oldVersion} → ${r.version} | settings=${r.settingsChanged}`);
@@ -99,6 +99,39 @@ test('cleanupDisabledStatusline also heals orphaned statusline after uninstall',
99
99
  assert.equal(fs.existsSync(registryPath), false);
100
100
  });
101
101
 
102
+ test('isPluginUninstalled distinguishes a genuine uninstall from a temporary disable', (t) => {
103
+ // Orphaned composite (installed_plugins exists, no code-graph record) = uninstalled.
104
+ const uninstalledHome = mkHome(t);
105
+ seedOrphanedComposite(uninstalledHome);
106
+ // enabledPlugins[id]=false = user toggled it off; may re-enable → NOT uninstalled.
107
+ const disabledHome = mkHome(t);
108
+ seedDisabledComposite(disabledHome);
109
+
110
+ const probe = (home) => JSON.parse(execFileSync(process.execPath, ['-e', `
111
+ const { isPluginUninstalled } = require(${JSON.stringify(lifecyclePath)});
112
+ process.stdout.write(JSON.stringify(isPluginUninstalled()));
113
+ `], { env: { ...process.env, HOME: home } }).toString());
114
+
115
+ assert.equal(probe(uninstalledHome), true, 'orphaned/no-record → uninstalled');
116
+ assert.equal(probe(disabledHome), false, 'explicit disable → not uninstalled (re-enable safe)');
117
+ });
118
+
119
+ test('removeCacheResidue deletes ~/.cache/code-graph and is idempotent', (t) => {
120
+ const homeDir = mkHome(t);
121
+ const cacheDir = path.join(homeDir, '.cache', 'code-graph');
122
+ writeJson(path.join(cacheDir, 'bin', 'marker.json'), { v: 1 });
123
+ fs.writeFileSync(path.join(cacheDir, 'update-state.json'), '{}');
124
+
125
+ const run = () => execFileSync(process.execPath, ['-e', `
126
+ const { removeCacheResidue } = require(${JSON.stringify(lifecyclePath)});
127
+ process.stdout.write(JSON.stringify(removeCacheResidue()));
128
+ `], { env: { ...process.env, HOME: homeDir } }).toString();
129
+
130
+ assert.equal(run(), 'true');
131
+ assert.equal(fs.existsSync(cacheDir), false, 'cache dir removed');
132
+ assert.equal(run(), 'true', 'second call is a no-op success (idempotent force-rm)');
133
+ });
134
+
102
135
  function legacyHooksFromPlugin() {
103
136
  return {
104
137
  SessionStart: [{
@@ -5,11 +5,11 @@ const path = require('path');
5
5
  const fs = require('fs');
6
6
  const {
7
7
  install, update, readManifest, getPluginVersion, checkScopeConflict,
8
- cleanupDisabledStatusline, isPluginInactive, readJson, CACHE_DIR,
9
- settingsPath, isStaleRelicContext,
8
+ cleanupDisabledStatusline, isPluginInactive, isPluginUninstalled, removeCacheResidue,
9
+ readJson, CACHE_DIR, settingsPath, isStaleRelicContext,
10
10
  } = require('./lifecycle');
11
11
  const { readBinaryVersion, isDevMode, getNewestMtime } = require('./version-utils');
12
- const { maybeAutoAdopt, isAdopted } = require('./adopt');
12
+ const { maybeAutoAdopt, isAdopted, unadopt } = require('./adopt');
13
13
  const { isNonProjectCwd } = require('./project-detect');
14
14
 
15
15
  // v0.17.0 — quietHooks: unconditional quiet 默认。
@@ -420,8 +420,31 @@ function consistencyCheck(binary) {
420
420
 
421
421
  function runSessionInit({ source } = {}) {
422
422
  if (isPluginInactive()) {
423
+ // Capture the uninstalled-vs-disabled verdict BEFORE cleanupDisabledStatusline()
424
+ // runs — it removes our composite + registry entry, which are the very signals
425
+ // isPluginUninstalled()/isPluginInactive() read, so calling it afterwards would
426
+ // always see "no composite/registry" and report not-uninstalled (teardown skipped).
427
+ const uninstalled = isPluginUninstalled();
423
428
  cleanupDisabledStatusline();
424
- return { inactive: true, lifecycle: 'noop', autoUpdateLaunched: false };
429
+ // Genuine uninstall (not a temporary disable) leaves residue the settings-only
430
+ // self-heal can't reach: ~/.cache/code-graph (the ~40MB binary + state) and the
431
+ // current project's CLAUDE.md adoption block. CC fires no uninstall hook, so this
432
+ // SessionStart is the only automated teardown — symmetric to install's auto-adopt.
433
+ // Per-project: only the cwd we're in; other adopted projects self-clean when next
434
+ // opened, or via `code-graph-mcp unadopt`.
435
+ let teardown = null;
436
+ if (uninstalled) {
437
+ const cacheRemoved = removeCacheResidue();
438
+ let unadopted = false;
439
+ try {
440
+ if (!isNonProjectCwd(process.cwd())) {
441
+ const r = unadopt({ cwd: process.cwd() });
442
+ unadopted = !!(r && (r.blockPruned || r.fileRemoved || r.claudeMdRemoved));
443
+ }
444
+ } catch { /* best-effort — never let teardown break SessionStart */ }
445
+ teardown = { cacheRemoved, unadopted };
446
+ }
447
+ return { inactive: true, lifecycle: 'noop', autoUpdateLaunched: false, teardown };
425
448
  }
426
449
 
427
450
  // Non-project cwd (no .git/manifest — e.g. /tmp, where claude-mem-lite
@@ -99,6 +99,53 @@ test('runSessionInit no-ops (nonProject) in a non-project cwd', (t) => {
99
99
  }
100
100
  });
101
101
 
102
+ test('runSessionInit tears down cache + adoption on a genuine uninstall (order regression)', (t) => {
103
+ // Subprocess isolation: lifecycle.js evaluates CACHE_DIR from os.homedir() at
104
+ // MODULE LOAD, so HOME/CLAUDE_CONFIG_DIR must be set before require — only a
105
+ // fresh child honors them. This locks the order bug: isPluginUninstalled() MUST be
106
+ // read BEFORE cleanupDisabledStatusline() wipes the composite/registry signals it
107
+ // depends on — otherwise teardown is skipped (was null pre-fix).
108
+ const os = require('os');
109
+ const { execFileSync } = require('child_process');
110
+ const sb = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-si-teardown-'));
111
+ t.after(() => fs.rmSync(sb, { recursive: true, force: true }));
112
+ const home = sb, cfg = path.join(sb, '.claude'), proj = path.join(sb, 'proj');
113
+ fs.mkdirSync(path.join(cfg, 'plugins'), { recursive: true });
114
+ fs.mkdirSync(proj, { recursive: true });
115
+ fs.writeFileSync(path.join(proj, 'package.json'), '{"name":"p","version":"1.0.0"}');
116
+ fs.writeFileSync(path.join(proj, 'CLAUDE.md'), '# P\n\nKEEP THIS USER LINE.\n');
117
+ fs.writeFileSync(path.join(cfg, 'settings.json'), '{"statusLine":{"type":"command","command":"/bin/prior.sh"}}');
118
+ const env = { ...process.env, HOME: home, CLAUDE_CONFIG_DIR: cfg };
119
+ const lc = path.join(__dirname, 'lifecycle.js'), ad = path.join(__dirname, 'adopt.js');
120
+ const si = path.join(__dirname, 'session-init.js');
121
+
122
+ // install + adopt, then simulate a downloaded binary + a post-/plugin-uninstall
123
+ // installed_plugins.json (record for some OTHER plugin, none for code-graph).
124
+ execFileSync(process.execPath, [lc, 'install'], { env, cwd: proj, stdio: 'ignore' });
125
+ execFileSync(process.execPath, ['-e',
126
+ `require(${JSON.stringify(ad)}).adopt({cwd:process.cwd()})`], { env, cwd: proj, stdio: 'ignore' });
127
+ fs.mkdirSync(path.join(home, '.cache', 'code-graph', 'bin'), { recursive: true });
128
+ fs.writeFileSync(path.join(home, '.cache', 'code-graph', 'bin', 'code-graph-mcp'), 'x');
129
+ fs.writeFileSync(path.join(cfg, 'plugins', 'installed_plugins.json'),
130
+ JSON.stringify({ plugins: { 'other@mkt': [{ version: '1.0.0', installPath: '/x' }] } }));
131
+ assert.ok(fs.readFileSync(path.join(proj, 'CLAUDE.md'), 'utf8').includes('code-graph'), 'adopt injected block');
132
+
133
+ const res = JSON.parse(execFileSync(process.execPath, ['-e',
134
+ `process.stdout.write(JSON.stringify(require(${JSON.stringify(si)}).runSessionInit({source:'startup'})))`],
135
+ { env, cwd: proj }).toString());
136
+
137
+ assert.equal(res.inactive, true);
138
+ assert.ok(res.teardown, 'teardown ran (null pre-fix = order bug)');
139
+ assert.equal(res.teardown.cacheRemoved, true);
140
+ assert.equal(res.teardown.unadopted, true);
141
+ assert.equal(fs.existsSync(path.join(home, '.cache', 'code-graph')), false, 'cache residue gone');
142
+ const md = fs.readFileSync(path.join(proj, 'CLAUDE.md'), 'utf8');
143
+ assert.ok(!md.includes('code-graph'), 'adopt block removed');
144
+ assert.ok(md.includes('KEEP THIS USER LINE'), 'user content preserved');
145
+ const settings = JSON.parse(fs.readFileSync(path.join(cfg, 'settings.json'), 'utf8'));
146
+ assert.equal(settings.statusLine.command, '/bin/prior.sh', 'prior statusline restored');
147
+ });
148
+
102
149
  test('consistencyCheck returns empty array when binary version matches plugin', () => {
103
150
  const result = consistencyCheck('/tmp/nonexistent-binary');
104
151
  assert.ok(Array.isArray(result));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sdsrs/code-graph",
3
- "version": "0.74.3",
3
+ "version": "0.74.4",
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": {
@@ -35,10 +35,10 @@
35
35
  "node": ">=16"
36
36
  },
37
37
  "optionalDependencies": {
38
- "@sdsrs/code-graph-linux-x64": "0.74.3",
39
- "@sdsrs/code-graph-linux-arm64": "0.74.3",
40
- "@sdsrs/code-graph-darwin-x64": "0.74.3",
41
- "@sdsrs/code-graph-darwin-arm64": "0.74.3",
42
- "@sdsrs/code-graph-win32-x64": "0.74.3"
38
+ "@sdsrs/code-graph-linux-x64": "0.74.4",
39
+ "@sdsrs/code-graph-linux-arm64": "0.74.4",
40
+ "@sdsrs/code-graph-darwin-x64": "0.74.4",
41
+ "@sdsrs/code-graph-darwin-arm64": "0.74.4",
42
+ "@sdsrs/code-graph-win32-x64": "0.74.4"
43
43
  }
44
44
  }