@sdsrs/code-graph 0.109.0 → 0.111.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -0
- package/claude-plugin/.claude-plugin/plugin.json +1 -1
- package/claude-plugin/scripts/auto-update.js +84 -19
- package/claude-plugin/scripts/cg-answer.js +9 -8
- package/claude-plugin/scripts/doctor.js +28 -13
- package/claude-plugin/scripts/find-binary.js +6 -4
- package/claude-plugin/scripts/incremental-index.js +3 -2
- package/claude-plugin/scripts/launcher-install.js +11 -5
- package/claude-plugin/scripts/lifecycle.js +8 -8
- package/claude-plugin/scripts/mcp-launcher.js +5 -4
- package/claude-plugin/scripts/npm-exec.js +52 -6
- package/claude-plugin/scripts/pr-impact-comment.js +5 -4
- package/claude-plugin/scripts/pre-edit-guide.js +5 -4
- package/claude-plugin/scripts/proc-opts.js +27 -0
- package/claude-plugin/scripts/session-init.js +19 -14
- package/claude-plugin/scripts/statusline-composite.js +3 -2
- package/claude-plugin/scripts/statusline.js +3 -2
- package/claude-plugin/scripts/user-prompt-context.js +3 -2
- package/claude-plugin/scripts/version-utils.js +3 -2
- package/package.json +6 -6
package/README.md
CHANGED
|
@@ -151,6 +151,19 @@ Then reconnect the MCP server in Claude Code with `/mcp`.
|
|
|
151
151
|
|
|
152
152
|
> **Note:** Auto-update is disabled in the source repo directory (dev mode). Use manual update when developing the plugin itself.
|
|
153
153
|
|
|
154
|
+
#### Turning auto-update off
|
|
155
|
+
|
|
156
|
+
| Environment variable | Effect |
|
|
157
|
+
|----------------------|--------|
|
|
158
|
+
| `CODE_GRAPH_NO_AUTO_UPDATE=1` | Skips the update check entirely — no version check, no download, and no updater process is started. A **missing** binary is still installed on first run, so the MCP server can't be left with no engine. Update manually with the command above. |
|
|
159
|
+
|
|
160
|
+
Set it in the `env` block of `~/.claude/settings.json` (the environment of the
|
|
161
|
+
process hosting the MCP server), then reconnect with `/mcp`.
|
|
162
|
+
|
|
163
|
+
An update that keeps failing also stops retrying on its own: after 5 failed
|
|
164
|
+
attempts at the *same* release, the updater goes check-only until a newer
|
|
165
|
+
release is published. Run `code-graph-mcp doctor` to see the state.
|
|
166
|
+
|
|
154
167
|
#### Invited-memory mode (quieter prompts)
|
|
155
168
|
|
|
156
169
|
By default, every user prompt the plugin deems code-related gets a small context injection from `code-graph` CLI output. If you'd rather rely on MEMORY.md + explicit tool calls, opt into invited-memory mode:
|
|
@@ -12,7 +12,8 @@ const { claudeHome } = require('./claude-config');
|
|
|
12
12
|
const { clearCache: clearBinaryCache, globalNodeModulesCandidates, nvmNodeModulesDirs, PLATFORM_PKG, detectLibc } = require('./find-binary');
|
|
13
13
|
const { readBinaryVersion, compareVersions, isDevMode } = require('./version-utils');
|
|
14
14
|
const { cgTmpDir } = require('./tmp-dir');
|
|
15
|
-
const {
|
|
15
|
+
const { npmInvocation } = require('./npm-exec');
|
|
16
|
+
const { hidden } = require('./proc-opts');
|
|
16
17
|
const { acquireLock } = require('./install-lock');
|
|
17
18
|
|
|
18
19
|
// ── Environment Checks ────────────────────────────────────
|
|
@@ -25,7 +26,7 @@ const { acquireLock } = require('./install-lock');
|
|
|
25
26
|
function commandExists(cmd) {
|
|
26
27
|
try {
|
|
27
28
|
const whichCmd = process.platform === 'win32' ? 'where' : 'which';
|
|
28
|
-
execFileSync(whichCmd, [cmd], { stdio: 'ignore' });
|
|
29
|
+
execFileSync(whichCmd, [cmd], hidden({ stdio: 'ignore' }));
|
|
29
30
|
return true;
|
|
30
31
|
} catch {
|
|
31
32
|
return false;
|
|
@@ -47,11 +48,30 @@ const SESSION_START_MIN_GAP_MS = 2 * 60 * 1000; // 2min — anti-hammer flo
|
|
|
47
48
|
// `--force` included, because the backoff arm sits above the force arm below.
|
|
48
49
|
const RATE_LIMIT_INTERVAL_MS = 60 * 60 * 1000; // 1h — GitHub's reset window
|
|
49
50
|
const FETCH_TIMEOUT_MS = 3000;
|
|
51
|
+
// After this many consecutive FAILED install cycles for the SAME target version,
|
|
52
|
+
// stop re-running the download chain and go check-only until a new release moves
|
|
53
|
+
// the target. Some failures are permanent for a given machine (a GNU tar that
|
|
54
|
+
// cannot open `C:\...`, a locked plugin cache, a full disk) and every retry was
|
|
55
|
+
// a fresh ~MB download plus — before the windowsHide sweep — a burst of console
|
|
56
|
+
// windows, once per session, forever (issue #40). Kept equal to statusline.js's
|
|
57
|
+
// STUCK_UPDATE_ATTEMPTS (drift-guarded in statusline.test.js) so the moment the
|
|
58
|
+
// updater gives up is the moment the statusline stops promising "↻ updating".
|
|
59
|
+
const MAX_UPDATE_ATTEMPTS = 5;
|
|
50
60
|
|
|
51
61
|
function isSilentMode(argv = process.argv.slice(2), env = process.env) {
|
|
52
62
|
return argv.includes('--silent') || env.CODE_GRAPH_AUTO_UPDATE_SILENT === '1';
|
|
53
63
|
}
|
|
54
64
|
|
|
65
|
+
// Documented opt-out. Until now the only way to stop auto-update was the
|
|
66
|
+
// accidental one — CODE_GRAPH_DEV=1, which also changes binary resolution and
|
|
67
|
+
// several unrelated code paths (issue #40). This one does exactly what it says.
|
|
68
|
+
// It does NOT block --install-missing: that path exists to put a binary on disk
|
|
69
|
+
// for a server that has none, and disabling *updates* must not wedge a fresh
|
|
70
|
+
// install with no engine.
|
|
71
|
+
function isAutoUpdateDisabled(env = process.env) {
|
|
72
|
+
return env.CODE_GRAPH_NO_AUTO_UPDATE === '1';
|
|
73
|
+
}
|
|
74
|
+
|
|
55
75
|
function isInstallMissingMode(argv = process.argv.slice(2)) {
|
|
56
76
|
return argv.includes('--install-missing');
|
|
57
77
|
}
|
|
@@ -342,7 +362,7 @@ async function downloadBinary(latest) {
|
|
|
342
362
|
execFileSync('curl', [
|
|
343
363
|
'-sL', '-o', binaryTmp,
|
|
344
364
|
latest.binaryUrl,
|
|
345
|
-
], { timeout: 60000, stdio: 'pipe' });
|
|
365
|
+
], hidden({ timeout: 60000, stdio: 'pipe' }));
|
|
346
366
|
|
|
347
367
|
// Integrity sidecar (<asset>.sha256), fail-CLOSED. `curl -f` turns a 404 into
|
|
348
368
|
// a throw. One retry, because the alternative to a transient network blip is
|
|
@@ -363,7 +383,7 @@ async function downloadBinary(latest) {
|
|
|
363
383
|
for (let attempt = 0; attempt < 2 && !expectedSha; attempt++) {
|
|
364
384
|
try {
|
|
365
385
|
execFileSync('curl', ['-sfL', '-o', shaTmp, latest.binaryUrl + '.sha256'],
|
|
366
|
-
{ timeout: 30000, stdio: 'pipe' });
|
|
386
|
+
hidden({ timeout: 30000, stdio: 'pipe' }));
|
|
367
387
|
expectedSha = (fs.readFileSync(shaTmp, 'utf8').trim().split(/\s+/)[0]) || null;
|
|
368
388
|
} catch { /* retry once, then refuse below */ } finally {
|
|
369
389
|
try { if (fs.existsSync(shaTmp)) fs.unlinkSync(shaTmp); } catch { /* ok */ }
|
|
@@ -464,7 +484,7 @@ function refreshMarketplaceClone({ dir = marketplaceCloneDir(), exec = execFileS
|
|
|
464
484
|
try {
|
|
465
485
|
if (!fs.existsSync(path.join(dir, '.git'))) return false;
|
|
466
486
|
if (!commandExists('git')) return false;
|
|
467
|
-
exec('git', ['-C', dir, 'pull', '--ff-only', '--quiet'], { timeout: timeoutMs, stdio: 'pipe' });
|
|
487
|
+
exec('git', ['-C', dir, 'pull', '--ff-only', '--quiet'], hidden({ timeout: timeoutMs, stdio: 'pipe' }));
|
|
468
488
|
return true;
|
|
469
489
|
} catch {
|
|
470
490
|
return false;
|
|
@@ -517,7 +537,7 @@ async function downloadAndInstall(latest, {
|
|
|
517
537
|
'-sL', '-o', tarballPath,
|
|
518
538
|
'-H', 'Accept: application/octet-stream',
|
|
519
539
|
latest.pluginTarballUrl,
|
|
520
|
-
], { timeout: 30000, stdio: 'pipe' });
|
|
540
|
+
], hidden({ timeout: 30000, stdio: 'pipe' }));
|
|
521
541
|
|
|
522
542
|
// One retry, matching the binary sidecar at :355 — same failure mode, same
|
|
523
543
|
// argument: a transient blip should cost an update cycle, not force a
|
|
@@ -528,7 +548,7 @@ async function downloadAndInstall(latest, {
|
|
|
528
548
|
for (let attempt = 0; attempt < 2 && !expectedSha; attempt++) {
|
|
529
549
|
try {
|
|
530
550
|
exec('curl', ['-sfL', '-o', shaPath, latest.pluginTarballUrl + '.sha256'],
|
|
531
|
-
{ timeout: 30000, stdio: 'pipe' });
|
|
551
|
+
hidden({ timeout: 30000, stdio: 'pipe' }));
|
|
532
552
|
expectedSha = (fs.readFileSync(shaPath, 'utf8').trim().split(/\s+/)[0]) || null;
|
|
533
553
|
} catch { /* retried once, then refused just below */ }
|
|
534
554
|
}
|
|
@@ -540,9 +560,18 @@ async function downloadAndInstall(latest, {
|
|
|
540
560
|
|
|
541
561
|
// No --strip-components: the asset archives `claude-plugin/` itself, while
|
|
542
562
|
// GitHub's source tarball wraps everything in `<owner>-<repo>-<sha>/`.
|
|
563
|
+
//
|
|
564
|
+
// Relative archive name + `cwd`, never an absolute path with `-C`: GNU tar
|
|
565
|
+
// (git-for-Windows / MSYS, first on PATH for many Windows users) reads the
|
|
566
|
+
// drive letter in `C:\Users\...\claude-plugin.tar.gz` as a REMOTE HOST and
|
|
567
|
+
// fails with "Cannot connect to C: resolve failed" — the same colon-parsing
|
|
568
|
+
// family as issues #34/#35. Windows' built-in bsdtar accepts both spellings,
|
|
569
|
+
// so this form is the portable one. On a failing GNU tar this was the step
|
|
570
|
+
// that made plugin updates unachievable, which is what put the whole
|
|
571
|
+
// download chain on a per-session repeat loop (issue #40).
|
|
543
572
|
exec('tar', [
|
|
544
|
-
'xzf',
|
|
545
|
-
], { timeout: 15000, stdio: 'pipe' });
|
|
573
|
+
'xzf', PLUGIN_ASSET_NAME,
|
|
574
|
+
], hidden({ cwd: tmpDir, timeout: 15000, stdio: 'pipe' }));
|
|
546
575
|
|
|
547
576
|
const pluginSrc = path.join(tmpDir, 'claude-plugin');
|
|
548
577
|
const pluginDst = path.join(
|
|
@@ -587,9 +616,9 @@ async function downloadAndInstall(latest, {
|
|
|
587
616
|
try {
|
|
588
617
|
const newLifecycle = path.join(pluginDst, 'scripts', 'lifecycle.js');
|
|
589
618
|
if (fs.existsSync(newLifecycle)) {
|
|
590
|
-
exec(process.execPath, [newLifecycle, 'update'], {
|
|
619
|
+
exec(process.execPath, [newLifecycle, 'update'], hidden({
|
|
591
620
|
timeout: 5000, stdio: 'pipe',
|
|
592
|
-
});
|
|
621
|
+
}));
|
|
593
622
|
}
|
|
594
623
|
} catch { /* not fatal — syncLifecycleConfig will self-heal on next session */ }
|
|
595
624
|
}
|
|
@@ -698,10 +727,11 @@ function inactiveNodeGlobalRelics({ dirs = null, activeDir = null } = {}) {
|
|
|
698
727
|
function npmInstallGlobal(specs) {
|
|
699
728
|
return new Promise((resolve) => {
|
|
700
729
|
if (!commandExists('npm')) { resolve(false); return; }
|
|
701
|
-
const
|
|
730
|
+
const npm = npmInvocation(['install', '-g', ...specs], {
|
|
702
731
|
timeout: GLOBAL_PKG_HEAL_TIMEOUT_MS,
|
|
703
732
|
stdio: ['ignore', 'ignore', 'pipe'],
|
|
704
|
-
})
|
|
733
|
+
});
|
|
734
|
+
const child = spawn(npm.file, npm.args, npm.opts);
|
|
705
735
|
let stderr = '';
|
|
706
736
|
child.stderr.on('data', (d) => { stderr += d.toString(); });
|
|
707
737
|
child.on('error', () => resolve(false));
|
|
@@ -775,10 +805,11 @@ function shouldHealGlobalsOnThrottle(state, { readStale = staleGlobalPkgs } = {}
|
|
|
775
805
|
async function checkForUpdate({ installMissing = false, force = false, requestJsonFn } = {}) {
|
|
776
806
|
let installLock = null;
|
|
777
807
|
try {
|
|
778
|
-
// Skip in dev mode
|
|
779
|
-
// binary install, in which case we MUST
|
|
780
|
-
// alternative is wedging the MCP server
|
|
781
|
-
|
|
808
|
+
// Skip in dev mode / when the user opted out — unless the launcher
|
|
809
|
+
// explicitly requested a missing-binary install, in which case we MUST
|
|
810
|
+
// proceed regardless of mode (the alternative is wedging the MCP server
|
|
811
|
+
// with no binary on disk).
|
|
812
|
+
if (!installMissing && (isDevMode() || isAutoUpdateDisabled())) return null;
|
|
782
813
|
|
|
783
814
|
const state = readState();
|
|
784
815
|
// manifest.version is authoritative — /plugin update writes it directly and
|
|
@@ -845,6 +876,35 @@ async function checkForUpdate({ installMissing = false, force = false, requestJs
|
|
|
845
876
|
}
|
|
846
877
|
|
|
847
878
|
if (hasUpdate) {
|
|
879
|
+
// Attempts are counted PER target version, so a newly published release
|
|
880
|
+
// always starts with a full budget. The counter used to be unscoped, which
|
|
881
|
+
// only mattered because nothing ever read it: the download chain re-ran on
|
|
882
|
+
// every single session no matter how many times it had already failed.
|
|
883
|
+
const attempts = state.latestVersion === latest.version ? (state.updateAttempts || 0) : 0;
|
|
884
|
+
if (attempts >= MAX_UPDATE_ATTEMPTS) {
|
|
885
|
+
// Suspended — check-only from here until a newer release moves the
|
|
886
|
+
// target. The one thing still worth attempting is a MISSING cached
|
|
887
|
+
// binary: without it the MCP server has no engine at all, so that
|
|
888
|
+
// self-heal stays reachable while the (separately failing) plugin
|
|
889
|
+
// tarball chain and the global-npm heal stay parked.
|
|
890
|
+
const healedMissing = !fs.existsSync(cachedBinaryPath()) && await downloadBinary(latest);
|
|
891
|
+
saveState({
|
|
892
|
+
...state,
|
|
893
|
+
installedVersion,
|
|
894
|
+
lastCheck: new Date().toISOString(),
|
|
895
|
+
latestVersion: latest.version,
|
|
896
|
+
updateAvailable: true,
|
|
897
|
+
updateAttempts: attempts,
|
|
898
|
+
rateLimited: false,
|
|
899
|
+
binaryUpdated: healedMissing || state.binaryUpdated,
|
|
900
|
+
});
|
|
901
|
+
console.error(
|
|
902
|
+
`[code-graph] Auto-update to v${latest.version} suspended after ${attempts} failed attempts on this machine. ` +
|
|
903
|
+
'Update manually (`/plugin update code-graph-mcp`, or `npm install -g @sdsrs/code-graph`) or run `code-graph-mcp doctor`. ' +
|
|
904
|
+
'Retries resume automatically when a newer release is published.'
|
|
905
|
+
);
|
|
906
|
+
return { updateAvailable: true, suspended: true, from: installedVersion, to: latest.version };
|
|
907
|
+
}
|
|
848
908
|
const result = await downloadAndInstall(latest);
|
|
849
909
|
const success = result.pluginUpdated;
|
|
850
910
|
const newState = {
|
|
@@ -857,7 +917,7 @@ async function checkForUpdate({ installMissing = false, force = false, requestJs
|
|
|
857
917
|
// update (missing tar/curl, full disk, blocked network) pins "updating"
|
|
858
918
|
// forever, asserting a self-heal that never happens. The statusline stops
|
|
859
919
|
// trusting it past STUCK_UPDATE_ATTEMPTS; success resets to 0.
|
|
860
|
-
updateAttempts: success ? 0 :
|
|
920
|
+
updateAttempts: success ? 0 : attempts + 1,
|
|
861
921
|
lastUpdate: success ? new Date().toISOString() : state.lastUpdate,
|
|
862
922
|
rateLimited: false,
|
|
863
923
|
binaryUpdated: result.binaryUpdated,
|
|
@@ -913,7 +973,8 @@ async function checkForUpdate({ installMissing = false, force = false, requestJs
|
|
|
913
973
|
module.exports = {
|
|
914
974
|
checkForUpdate, commandExists, isDevMode, readState, compareVersions, shouldCheck,
|
|
915
975
|
getExtractedPluginVersion, readBinaryVersion, promoteVerifiedBinary,
|
|
916
|
-
isSilentMode, isInstallMissingMode, isForceMode,
|
|
976
|
+
isSilentMode, isInstallMissingMode, isForceMode, isAutoUpdateDisabled,
|
|
977
|
+
MAX_UPDATE_ATTEMPTS,
|
|
917
978
|
requestJson, resolveProxy, parseLatestRelease, fetchLatestRelease,
|
|
918
979
|
PLUGIN_ASSET_NAME,
|
|
919
980
|
downloadBinary, cachedBinaryPath, cachedBinaryNeedsUpdate, cachedBinaryStaleVsState,
|
|
@@ -941,10 +1002,14 @@ if (require.main === module) {
|
|
|
941
1002
|
if (silent) return;
|
|
942
1003
|
if (result && result.updated) {
|
|
943
1004
|
console.log(`Updated: v${result.from} → v${result.to} (binary: ${result.binaryUpdated ? 'yes' : 'no'})`);
|
|
1005
|
+
} else if (result && result.suspended) {
|
|
1006
|
+
console.log(`Update available: v${result.to} — auto-install SUSPENDED after ${MAX_UPDATE_ATTEMPTS} failed attempts. Update manually; retries resume on the next release.`);
|
|
944
1007
|
} else if (result && result.updateAvailable) {
|
|
945
1008
|
console.log(`Update available: v${result.to} (auto-install failed)`);
|
|
946
1009
|
} else if (result && result.binaryUpdated) {
|
|
947
1010
|
console.log(`Repaired binary cache (v${result.to})`);
|
|
1011
|
+
} else if (!installMissing && isAutoUpdateDisabled()) {
|
|
1012
|
+
console.log('CODE_GRAPH_NO_AUTO_UPDATE=1 — auto-update skipped');
|
|
948
1013
|
} else if (!installMissing && isDevMode()) {
|
|
949
1014
|
console.log('Dev mode — auto-update skipped');
|
|
950
1015
|
} else {
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
// runs cannot inflate the deny→use conversion funnel.
|
|
20
20
|
|
|
21
21
|
const { spawnSync } = require('child_process');
|
|
22
|
+
const { hidden } = require('./proc-opts');
|
|
22
23
|
|
|
23
24
|
// 2000 ms is a product decision, not a tuning knob: a PreToolUse hook that
|
|
24
25
|
// stalls longer than this costs the user more than the answer is worth, so the
|
|
@@ -124,7 +125,7 @@ function runGrepAnswer(opts = {}) {
|
|
|
124
125
|
const scope = sanitizeSearchPath(searchPath);
|
|
125
126
|
const args = ['grep', pattern];
|
|
126
127
|
if (scope) args.push(scope);
|
|
127
|
-
const res = spawnSync(binary, args, {
|
|
128
|
+
const res = spawnSync(binary, args, hidden({
|
|
128
129
|
cwd,
|
|
129
130
|
timeout: timeoutMs,
|
|
130
131
|
encoding: 'utf8',
|
|
@@ -133,7 +134,7 @@ function runGrepAnswer(opts = {}) {
|
|
|
133
134
|
// Hook-internal run: a delivered answer, not a model-initiated conversion.
|
|
134
135
|
// The CLI skips its recommendations.jsonl `use` record when this is set.
|
|
135
136
|
env: { ...process.env, CODE_GRAPH_INTERNAL: '1' },
|
|
136
|
-
});
|
|
137
|
+
}));
|
|
137
138
|
if (res.error || res.signal) {
|
|
138
139
|
return { status: 'unavailable' };
|
|
139
140
|
}
|
|
@@ -190,14 +191,14 @@ function runShowAnswer(opts = {}) {
|
|
|
190
191
|
const parts = [];
|
|
191
192
|
for (const sym of symbols.slice(0, 3)) {
|
|
192
193
|
if (typeof sym !== 'string' || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(sym)) continue;
|
|
193
|
-
const res = spawnSync(binary, ['show', sym], {
|
|
194
|
+
const res = spawnSync(binary, ['show', sym], hidden({
|
|
194
195
|
cwd,
|
|
195
196
|
timeout: timeoutMs,
|
|
196
197
|
encoding: 'utf8',
|
|
197
198
|
maxBuffer: 4 * 1024 * 1024,
|
|
198
199
|
stdio: ['ignore', 'pipe', 'ignore'],
|
|
199
200
|
env: { ...process.env, CODE_GRAPH_INTERNAL: '1' },
|
|
200
|
-
});
|
|
201
|
+
}));
|
|
201
202
|
if (res.error || res.signal || res.status !== 0) continue;
|
|
202
203
|
const out = (res.stdout || '').trim();
|
|
203
204
|
if (!out || out.startsWith(NO_MATCH_PREFIX)) continue;
|
|
@@ -238,14 +239,14 @@ function runOverviewAnswer(opts = {}) {
|
|
|
238
239
|
binary = process.env._CG_ANSWER_BINARY || require('./find-binary').findBinary();
|
|
239
240
|
}
|
|
240
241
|
if (!binary) return { status: 'no-binary' };
|
|
241
|
-
const res = spawnSync(binary, ['overview', dir], {
|
|
242
|
+
const res = spawnSync(binary, ['overview', dir], hidden({
|
|
242
243
|
cwd,
|
|
243
244
|
timeout: timeoutMs,
|
|
244
245
|
encoding: 'utf8',
|
|
245
246
|
maxBuffer: 4 * 1024 * 1024,
|
|
246
247
|
stdio: ['ignore', 'pipe', 'ignore'],
|
|
247
248
|
env: { ...process.env, CODE_GRAPH_INTERNAL: '1' },
|
|
248
|
-
});
|
|
249
|
+
}));
|
|
249
250
|
if (res.error || res.signal || res.status !== 0) {
|
|
250
251
|
return { status: 'unavailable' };
|
|
251
252
|
}
|
|
@@ -293,14 +294,14 @@ function runCallgraphAnswer(opts = {}) {
|
|
|
293
294
|
}
|
|
294
295
|
if (!binary) return { status: 'no-binary' };
|
|
295
296
|
|
|
296
|
-
const res = spawnSync(binary, ['callgraph', symbol], {
|
|
297
|
+
const res = spawnSync(binary, ['callgraph', symbol], hidden({
|
|
297
298
|
cwd,
|
|
298
299
|
timeout: timeoutMs,
|
|
299
300
|
encoding: 'utf8',
|
|
300
301
|
maxBuffer: 4 * 1024 * 1024,
|
|
301
302
|
stdio: ['ignore', 'pipe', 'ignore'],
|
|
302
303
|
env: { ...process.env, CODE_GRAPH_INTERNAL: '1' },
|
|
303
|
-
});
|
|
304
|
+
}));
|
|
304
305
|
if (res.error || res.signal) return { status: 'unavailable' };
|
|
305
306
|
// grep-parity exit codes: 1 = symbol not found (no graph node).
|
|
306
307
|
if (res.status === 1) return { status: 'no-hits' };
|
|
@@ -11,6 +11,8 @@ const {
|
|
|
11
11
|
installedGlobalPkgs, GLOBAL_INSTALL_MARKER, SHELL_PKG,
|
|
12
12
|
} = require('./lifecycle');
|
|
13
13
|
const { findBinary, clearCache: clearBinaryCache } = require('./find-binary');
|
|
14
|
+
const { hidden } = require('./proc-opts');
|
|
15
|
+
const { MAX_UPDATE_ATTEMPTS } = require('./auto-update');
|
|
14
16
|
|
|
15
17
|
// ── Diagnostics ───────────────────────────────────────────
|
|
16
18
|
|
|
@@ -136,12 +138,12 @@ function runDiagnostics({ checkOnly = false } = {}) {
|
|
|
136
138
|
if (execOk) {
|
|
137
139
|
try {
|
|
138
140
|
const cwd = process.cwd();
|
|
139
|
-
const hcOutput = execFileSync(binary, ['health-check', '--json'], {
|
|
141
|
+
const hcOutput = execFileSync(binary, ['health-check', '--json'], hidden({
|
|
140
142
|
cwd,
|
|
141
143
|
timeout: 5000,
|
|
142
144
|
encoding: 'utf8',
|
|
143
145
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
144
|
-
}).trim();
|
|
146
|
+
})).trim();
|
|
145
147
|
const hc = JSON.parse(hcOutput);
|
|
146
148
|
|
|
147
149
|
// No-index short-circuit — binary deliberately returns a structured
|
|
@@ -202,7 +204,20 @@ function runDiagnostics({ checkOnly = false } = {}) {
|
|
|
202
204
|
// 5. Auto-update state
|
|
203
205
|
try {
|
|
204
206
|
const state = readJson(path.join(CACHE_DIR, 'update-state.json'));
|
|
205
|
-
|
|
207
|
+
const attempts = (state && state.updateAttempts) || 0;
|
|
208
|
+
if (state && state.updateAvailable && attempts >= MAX_UPDATE_ATTEMPTS) {
|
|
209
|
+
// The updater has given up on this release (issue #40). Deliberately NO
|
|
210
|
+
// fixId: re-running `auto-update.js check` is precisely the thing that was
|
|
211
|
+
// suspended, so offering it as a repair would print "✅ Update check
|
|
212
|
+
// complete" and count a fix that cannot happen. Say what is true and hand
|
|
213
|
+
// the user the manual route.
|
|
214
|
+
results.push({
|
|
215
|
+
name: 'Auto-update',
|
|
216
|
+
status: 'warn',
|
|
217
|
+
detail: `v${state.latestVersion} failed to install ${attempts}× — auto-retry suspended until a newer release. `
|
|
218
|
+
+ 'Update manually: `npm install -g @sdsrs/code-graph` (or `/plugin update code-graph-mcp`)',
|
|
219
|
+
});
|
|
220
|
+
} else if (state && state.updateAvailable && state.binaryUpdated === false) {
|
|
206
221
|
results.push({
|
|
207
222
|
name: 'Auto-update',
|
|
208
223
|
status: 'warn',
|
|
@@ -492,10 +507,10 @@ function runRepairs(results) {
|
|
|
492
507
|
if (!isDevMode()) {
|
|
493
508
|
console.log('\n Triggering binary update...');
|
|
494
509
|
try {
|
|
495
|
-
execFileSync(process.execPath, [path.join(__dirname, 'auto-update.js'), 'check'], {
|
|
510
|
+
execFileSync(process.execPath, [path.join(__dirname, 'auto-update.js'), 'check'], hidden({
|
|
496
511
|
timeout: 60000,
|
|
497
512
|
stdio: 'inherit',
|
|
498
|
-
});
|
|
513
|
+
}));
|
|
499
514
|
console.log(' \u2705 Update check complete');
|
|
500
515
|
fixed++;
|
|
501
516
|
} catch {
|
|
@@ -516,11 +531,11 @@ function runRepairs(results) {
|
|
|
516
531
|
console.log(` \u2192 ${buildCmd}`);
|
|
517
532
|
try {
|
|
518
533
|
const projectRoot = path.resolve(__dirname, '..', '..');
|
|
519
|
-
execSync(buildCmd, {
|
|
534
|
+
execSync(buildCmd, hidden({
|
|
520
535
|
cwd: projectRoot,
|
|
521
536
|
stdio: 'inherit',
|
|
522
537
|
timeout: 600000, // embed-model (Candle) builds exceed the old 5min
|
|
523
|
-
});
|
|
538
|
+
}));
|
|
524
539
|
clearBinaryCache();
|
|
525
540
|
console.log(' \u2705 Build complete');
|
|
526
541
|
fixed++;
|
|
@@ -539,11 +554,11 @@ function runRepairs(results) {
|
|
|
539
554
|
console.log(' (for semantic search: cargo build --release --features embed-model)');
|
|
540
555
|
try {
|
|
541
556
|
const projectRoot = path.resolve(__dirname, '..', '..');
|
|
542
|
-
execSync('cargo build --release --no-default-features', {
|
|
557
|
+
execSync('cargo build --release --no-default-features', hidden({
|
|
543
558
|
cwd: projectRoot,
|
|
544
559
|
stdio: 'inherit',
|
|
545
560
|
timeout: 600000,
|
|
546
|
-
});
|
|
561
|
+
}));
|
|
547
562
|
clearBinaryCache();
|
|
548
563
|
console.log(' \u2705 Build complete');
|
|
549
564
|
fixed++;
|
|
@@ -580,11 +595,11 @@ function runRepairs(results) {
|
|
|
580
595
|
console.log('\n Rebuilding index...');
|
|
581
596
|
console.log(' \u2192 code-graph-mcp incremental-index');
|
|
582
597
|
try {
|
|
583
|
-
execFileSync(binary, ['incremental-index'], {
|
|
598
|
+
execFileSync(binary, ['incremental-index'], hidden({
|
|
584
599
|
cwd: process.cwd(),
|
|
585
600
|
stdio: 'inherit',
|
|
586
601
|
timeout: 120000,
|
|
587
|
-
});
|
|
602
|
+
}));
|
|
588
603
|
console.log(' \u2705 Index rebuilt');
|
|
589
604
|
fixed++;
|
|
590
605
|
} catch {
|
|
@@ -597,10 +612,10 @@ function runRepairs(results) {
|
|
|
597
612
|
case 'update-incomplete': {
|
|
598
613
|
console.log('\n Completing auto-update...');
|
|
599
614
|
try {
|
|
600
|
-
execFileSync(process.execPath, [path.join(__dirname, 'auto-update.js'), 'check'], {
|
|
615
|
+
execFileSync(process.execPath, [path.join(__dirname, 'auto-update.js'), 'check'], hidden({
|
|
601
616
|
timeout: 60000,
|
|
602
617
|
stdio: 'inherit',
|
|
603
|
-
});
|
|
618
|
+
}));
|
|
604
619
|
console.log(' \u2705 Update check complete');
|
|
605
620
|
fixed++;
|
|
606
621
|
} catch {
|
|
@@ -5,7 +5,8 @@ const path = require('path');
|
|
|
5
5
|
const fs = require('fs');
|
|
6
6
|
const os = require('os');
|
|
7
7
|
const { readBinaryVersion, compareVersions } = require('./version-utils');
|
|
8
|
-
const {
|
|
8
|
+
const { npmInvocation } = require('./npm-exec');
|
|
9
|
+
const { hidden } = require('./proc-opts');
|
|
9
10
|
|
|
10
11
|
const PLATFORM = os.platform();
|
|
11
12
|
const ARCH = os.arch();
|
|
@@ -108,11 +109,12 @@ function globalNodeModulesCandidates() {
|
|
|
108
109
|
// 4. Last resort: ask npm directly. Slow (~50-200ms) but most accurate when
|
|
109
110
|
// user has a non-standard prefix. Cached at the disk-cache layer above.
|
|
110
111
|
try {
|
|
111
|
-
const
|
|
112
|
+
const npm = npmInvocation(['root', '-g'], {
|
|
112
113
|
timeout: 2000,
|
|
113
114
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
114
115
|
encoding: 'utf8',
|
|
115
|
-
})
|
|
116
|
+
});
|
|
117
|
+
const root = execFileSync(npm.file, npm.args, npm.opts).trim();
|
|
116
118
|
if (root) out.push(root);
|
|
117
119
|
} catch { /* npm not on PATH or timed out */ }
|
|
118
120
|
|
|
@@ -351,7 +353,7 @@ function findBinaryUncached() {
|
|
|
351
353
|
// --- PATH lookup (last resort for intentionally installed binaries) ---
|
|
352
354
|
try {
|
|
353
355
|
const which = PLATFORM === 'win32' ? 'where' : 'which';
|
|
354
|
-
const found = execFileSync(which, [BINARY_NAME], { stdio: ['pipe', 'pipe', 'pipe'] })
|
|
356
|
+
const found = execFileSync(which, [BINARY_NAME], hidden({ stdio: ['pipe', 'pipe', 'pipe'] }))
|
|
355
357
|
.toString().trim().split('\n')[0];
|
|
356
358
|
const hit = gate.consider(found);
|
|
357
359
|
if (hit) return hit;
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
'use strict';
|
|
3
3
|
const { execFileSync } = require('child_process');
|
|
4
4
|
const { findBinary } = require('./find-binary');
|
|
5
|
+
const { hidden } = require('./proc-opts');
|
|
5
6
|
|
|
6
7
|
// v0.21 — gated default-off. v0.18.0 added query-time freshness
|
|
7
8
|
// (ensure_file_indexed) inside MCP tools that take a file_path arg, so a
|
|
@@ -29,10 +30,10 @@ function runMain() {
|
|
|
29
30
|
if (!bin) return; // silent — binary not installed yet
|
|
30
31
|
|
|
31
32
|
try {
|
|
32
|
-
execFileSync(bin, ['incremental-index', '--quiet'], {
|
|
33
|
+
execFileSync(bin, ['incremental-index', '--quiet'], hidden({
|
|
33
34
|
timeout: 8000,
|
|
34
35
|
stdio: ['pipe', 'pipe', 'pipe']
|
|
35
|
-
});
|
|
36
|
+
}));
|
|
36
37
|
} catch { /* timeout or error — silent for hook */ }
|
|
37
38
|
}
|
|
38
39
|
|
|
@@ -22,7 +22,8 @@
|
|
|
22
22
|
*/
|
|
23
23
|
const { spawn } = require('child_process');
|
|
24
24
|
const path = require('path');
|
|
25
|
-
const {
|
|
25
|
+
const { npmInvocation } = require('./npm-exec');
|
|
26
|
+
const { hidden } = require('./proc-opts');
|
|
26
27
|
const { acquireLock } = require('./install-lock');
|
|
27
28
|
|
|
28
29
|
const NPM_TIMEOUT_MS = 60000;
|
|
@@ -48,11 +49,11 @@ function runStep(cmd, args, timeoutMs, prefix, spawnFn, cb, spawnOpts = {}) {
|
|
|
48
49
|
|
|
49
50
|
let child;
|
|
50
51
|
try {
|
|
51
|
-
child = spawnFn(cmd, args, {
|
|
52
|
+
child = spawnFn(cmd, args, hidden({
|
|
52
53
|
timeout: timeoutMs,
|
|
53
54
|
stdio: ['ignore', 'ignore', 'pipe'],
|
|
54
55
|
...spawnOpts,
|
|
55
|
-
});
|
|
56
|
+
}));
|
|
56
57
|
} catch (e) {
|
|
57
58
|
process.stderr.write(`[code-graph] install step ${cmd} failed to start: ${e.message}\n`);
|
|
58
59
|
done();
|
|
@@ -124,7 +125,12 @@ function installBinaryInBackground({
|
|
|
124
125
|
return findBinary();
|
|
125
126
|
};
|
|
126
127
|
|
|
127
|
-
|
|
128
|
+
// npmInvocation, not a bare ('npm', args, { shell }) pair: on Windows npm is
|
|
129
|
+
// `npm.cmd` (needs a shell) and passing `args` alongside `shell: true` is
|
|
130
|
+
// DEP0190 — runtime-deprecated in Node 24, and unescaped. It pre-quotes the
|
|
131
|
+
// whole command into `file` with empty `args`, and carries windowsHide.
|
|
132
|
+
const npm = npmInvocation(['install', '-g', `@sdsrs/code-graph@${version}`]);
|
|
133
|
+
runStep(npm.file, npm.args, npmTimeoutMs, '[code-graph][npm]', spawnFn, (npmExit) => {
|
|
128
134
|
if (resolved()) {
|
|
129
135
|
if (npmExit === 0 && recordGlobalInstall) {
|
|
130
136
|
try { recordGlobalInstall(); } catch { /* marker is best-effort */ }
|
|
@@ -140,7 +146,7 @@ function installBinaryInBackground({
|
|
|
140
146
|
// The child would otherwise try to take the same install lock we hold.
|
|
141
147
|
env: { ...process.env, CODE_GRAPH_INSTALL_LOCK_HELD: '1' },
|
|
142
148
|
});
|
|
143
|
-
},
|
|
149
|
+
}, npm.opts);
|
|
144
150
|
}
|
|
145
151
|
|
|
146
152
|
module.exports = { installBinaryInBackground, runStep, NPM_TIMEOUT_MS, GITHUB_TIMEOUT_MS };
|
|
@@ -4,6 +4,7 @@ const fs = require('fs');
|
|
|
4
4
|
const path = require('path');
|
|
5
5
|
const os = require('os');
|
|
6
6
|
const { claudeHome } = require('./claude-config');
|
|
7
|
+
const { hidden } = require('./proc-opts');
|
|
7
8
|
|
|
8
9
|
const PLUGIN_ID = 'code-graph-mcp@code-graph-mcp';
|
|
9
10
|
const OLD_PLUGIN_IDS = [
|
|
@@ -879,13 +880,13 @@ function verifyHooksFire({ hooks, env, timeoutMs = 4000, tmpBase } = {}) {
|
|
|
879
880
|
for (const h of probes) {
|
|
880
881
|
let error = null, ok = false, emitted = false, code = null, signal = null;
|
|
881
882
|
try {
|
|
882
|
-
const r = spawnSync(process.execPath, [h.script], {
|
|
883
|
+
const r = spawnSync(process.execPath, [h.script], hidden({
|
|
883
884
|
input: JSON.stringify(h.payload || {}),
|
|
884
885
|
cwd: fixture,
|
|
885
886
|
env: baseEnv,
|
|
886
887
|
timeout: timeoutMs,
|
|
887
888
|
encoding: 'utf8',
|
|
888
|
-
});
|
|
889
|
+
}));
|
|
889
890
|
code = r.status;
|
|
890
891
|
signal = r.signal;
|
|
891
892
|
ok = !r.error && r.status === 0;
|
|
@@ -1061,11 +1062,10 @@ function installedGlobalPkgs() {
|
|
|
1061
1062
|
|
|
1062
1063
|
function defaultRunNpm(args) {
|
|
1063
1064
|
const { spawnSync } = require('child_process');
|
|
1064
|
-
const {
|
|
1065
|
+
const { npmInvocation } = require('./npm-exec');
|
|
1065
1066
|
try {
|
|
1066
|
-
const
|
|
1067
|
-
|
|
1068
|
-
}));
|
|
1067
|
+
const npm = npmInvocation(args, { timeout: 120000, stdio: 'pipe', encoding: 'utf8' });
|
|
1068
|
+
const r = spawnSync(npm.file, npm.args, npm.opts);
|
|
1069
1069
|
return !r.error && r.status === 0;
|
|
1070
1070
|
} catch { return false; }
|
|
1071
1071
|
}
|
|
@@ -1311,9 +1311,9 @@ function readActiveProcessCmdlines() {
|
|
|
1311
1311
|
} catch { /* fall through to ps */ }
|
|
1312
1312
|
try {
|
|
1313
1313
|
const { execFileSync } = require('child_process');
|
|
1314
|
-
return execFileSync('ps', ['-axww', '-o', 'command='], {
|
|
1314
|
+
return execFileSync('ps', ['-axww', '-o', 'command='], hidden({
|
|
1315
1315
|
encoding: 'utf8', maxBuffer: 8 * 1024 * 1024,
|
|
1316
|
-
}).split('\n').filter(Boolean);
|
|
1316
|
+
})).split('\n').filter(Boolean);
|
|
1317
1317
|
} catch { /* unsupported platform — caller falls back to recency-only */ }
|
|
1318
1318
|
return [];
|
|
1319
1319
|
}
|
|
@@ -12,6 +12,7 @@ const path = require('path');
|
|
|
12
12
|
const fs = require('fs');
|
|
13
13
|
const { isNonProjectCwd } = require('./project-detect');
|
|
14
14
|
const { serveEmptyMcpStub } = require('./mcp-stub');
|
|
15
|
+
const { hidden } = require('./proc-opts');
|
|
15
16
|
|
|
16
17
|
// Set plugin root so find-binary.js can locate bundled/dev binaries
|
|
17
18
|
// Always derive from __dirname — CLAUDE_PLUGIN_ROOT can leak from other plugins
|
|
@@ -78,7 +79,7 @@ if (process.env.CODE_GRAPH_FORCE_PLUGIN_MCP !== '1' && isNonProjectCwd(process.c
|
|
|
78
79
|
const bin = findBinary();
|
|
79
80
|
if (!bin) return null;
|
|
80
81
|
process.stderr.write(`[code-graph] cwd became a project — upgrading plugin MCP to real tools via ${bin} (restart Claude Code for full tool steering)\n`);
|
|
81
|
-
return spawn(bin, ['serve'], { stdio: ['pipe', 'pipe', 'inherit'], env: process.env });
|
|
82
|
+
return spawn(bin, ['serve'], hidden({ stdio: ['pipe', 'pipe', 'inherit'], env: process.env }));
|
|
82
83
|
},
|
|
83
84
|
},
|
|
84
85
|
});
|
|
@@ -159,7 +160,7 @@ if (!binary) {
|
|
|
159
160
|
const bin = findBinary();
|
|
160
161
|
if (!bin) return null;
|
|
161
162
|
process.stderr.write(`[code-graph] binary ready at ${bin} — upgrading plugin MCP to real tools (restart Claude Code for full tool steering)\n`);
|
|
162
|
-
return spawn(bin, ['serve'], { stdio: ['pipe', 'pipe', 'inherit'], env: process.env });
|
|
163
|
+
return spawn(bin, ['serve'], hidden({ stdio: ['pipe', 'pipe', 'inherit'], env: process.env }));
|
|
163
164
|
},
|
|
164
165
|
},
|
|
165
166
|
});
|
|
@@ -205,10 +206,10 @@ try {
|
|
|
205
206
|
}
|
|
206
207
|
|
|
207
208
|
// Spawn binary with stdio inheritance for MCP JSON-RPC
|
|
208
|
-
const child = spawn(binary, ['serve'], {
|
|
209
|
+
const child = spawn(binary, ['serve'], hidden({
|
|
209
210
|
stdio: 'inherit',
|
|
210
211
|
env: process.env,
|
|
211
|
-
});
|
|
212
|
+
}));
|
|
212
213
|
|
|
213
214
|
child.on('error', (err) => {
|
|
214
215
|
process.stderr.write(`[code-graph] Failed to start: ${err.message}\n`);
|
|
@@ -3,13 +3,59 @@
|
|
|
3
3
|
// .cmd without a shell (and Node >= 18.20 throws EINVAL spawning .cmd directly
|
|
4
4
|
// as a CVE-2024-27980 mitigation). Every bare `spawn('npm', ...)` in the
|
|
5
5
|
// install/update flow therefore silently ENOENT'd on Windows while
|
|
6
|
-
// commandExists('npm') (via `where`) said npm was present.
|
|
7
|
-
|
|
6
|
+
// commandExists('npm') (via `where`) said npm was present.
|
|
7
|
+
const { hidden } = require('./proc-opts');
|
|
8
|
+
|
|
8
9
|
const NPM_NEEDS_SHELL = process.platform === 'win32';
|
|
9
10
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
11
|
+
// Args we are willing to hand to cmd.exe unquoted: flags, package specs,
|
|
12
|
+
// versions, paths. Everything else gets double-quoted; anything that cannot be
|
|
13
|
+
// made safe inside double quotes throws rather than being passed through.
|
|
14
|
+
const SAFE_CMD_ARG = /^[A-Za-z0-9_@./:\\+=-]+$/;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Quote one argument for the `cmd.exe /d /s /c "..."` line Node builds when
|
|
18
|
+
* `shell: true`. Node itself only space-joins `args` in that mode — the reason
|
|
19
|
+
* DEP0190 runtime-deprecated the combination in Node 24 — so the joining (and
|
|
20
|
+
* the quoting) has to happen here.
|
|
21
|
+
* @param {string} arg
|
|
22
|
+
* @returns {string}
|
|
23
|
+
*/
|
|
24
|
+
function quoteCmdArg(arg) {
|
|
25
|
+
const s = String(arg);
|
|
26
|
+
if (SAFE_CMD_ARG.test(s)) return s;
|
|
27
|
+
// `"` ends our quoting; `%` and `!` are expanded inside double quotes; CR/LF
|
|
28
|
+
// splits the command line. None of our call sites produce these — a throw is
|
|
29
|
+
// a loud bug report, not a user-facing failure path.
|
|
30
|
+
if (/["%!\r\n]/.test(s)) {
|
|
31
|
+
throw new Error(`npm argument cannot be safely quoted for cmd.exe: ${JSON.stringify(s)}`);
|
|
32
|
+
}
|
|
33
|
+
return `"${s}"`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Build a child_process invocation for `npm <args>` that is correct on both
|
|
38
|
+
* platforms. Spread into spawn/spawnSync/execFileSync:
|
|
39
|
+
*
|
|
40
|
+
* const { file, args, opts } = npmInvocation(['root', '-g'], { timeout: 2000 });
|
|
41
|
+
* execFileSync(file, args, opts);
|
|
42
|
+
*
|
|
43
|
+
* On Windows the whole command is pre-quoted into `file` with an EMPTY `args`
|
|
44
|
+
* array: passing `args` alongside `shell: true` is DEP0190 (runtime-deprecated
|
|
45
|
+
* in Node 24 — it space-joins without escaping, which is a shell-injection
|
|
46
|
+
* hole) and printed a deprecation warning on every npm call.
|
|
47
|
+
*
|
|
48
|
+
* @param {string[]} args
|
|
49
|
+
* @param {object} [opts] - extra child_process options
|
|
50
|
+
* @returns {{file: string, args: string[], opts: object}}
|
|
51
|
+
*/
|
|
52
|
+
function npmInvocation(args, opts = {}) {
|
|
53
|
+
if (!NPM_NEEDS_SHELL) return { file: 'npm', args: [...args], opts: hidden(opts) };
|
|
54
|
+
return {
|
|
55
|
+
file: ['npm', ...args.map(quoteCmdArg)].join(' '),
|
|
56
|
+
args: [],
|
|
57
|
+
opts: hidden({ ...opts, shell: true }),
|
|
58
|
+
};
|
|
13
59
|
}
|
|
14
60
|
|
|
15
|
-
module.exports = {
|
|
61
|
+
module.exports = { npmInvocation, quoteCmdArg, NPM_NEEDS_SHELL };
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
// cg-answer.js).
|
|
17
17
|
|
|
18
18
|
const { spawnSync } = require('child_process');
|
|
19
|
+
const { hidden } = require('./proc-opts');
|
|
19
20
|
|
|
20
21
|
const MARKER = '<!-- code-graph-impact-review -->';
|
|
21
22
|
const SPAWN_TIMEOUT_MS = 60_000;
|
|
@@ -88,7 +89,7 @@ function resolveBinary() {
|
|
|
88
89
|
}
|
|
89
90
|
|
|
90
91
|
function runAffected(binary, args, cwd, stdin) {
|
|
91
|
-
const res = spawnSync(binary, args, {
|
|
92
|
+
const res = spawnSync(binary, args, hidden({
|
|
92
93
|
cwd,
|
|
93
94
|
input: stdin,
|
|
94
95
|
timeout: SPAWN_TIMEOUT_MS,
|
|
@@ -96,7 +97,7 @@ function runAffected(binary, args, cwd, stdin) {
|
|
|
96
97
|
maxBuffer: 16 * 1024 * 1024,
|
|
97
98
|
stdio: ['pipe', 'pipe', 'ignore'],
|
|
98
99
|
env: { ...process.env, CODE_GRAPH_INTERNAL: '1' },
|
|
99
|
-
});
|
|
100
|
+
}));
|
|
100
101
|
if (res.error || res.signal || res.status !== 0) return null;
|
|
101
102
|
try {
|
|
102
103
|
return JSON.parse((res.stdout || '').trim());
|
|
@@ -201,10 +202,10 @@ function renderMarkdown(review) {
|
|
|
201
202
|
/// Upsert a sticky comment: find an existing comment containing MARKER and PATCH
|
|
202
203
|
/// it, else POST a new one. Uses `gh api` (preinstalled on GitHub runners).
|
|
203
204
|
function upsertComment(repo, prNumber, body) {
|
|
204
|
-
const gh = (args, input) => spawnSync('gh', args, {
|
|
205
|
+
const gh = (args, input) => spawnSync('gh', args, hidden({
|
|
205
206
|
encoding: 'utf8', input, timeout: SPAWN_TIMEOUT_MS,
|
|
206
207
|
env: { ...process.env },
|
|
207
|
-
});
|
|
208
|
+
}));
|
|
208
209
|
|
|
209
210
|
const list = gh(['api', '--paginate', `repos/${repo}/issues/${prNumber}/comments`]);
|
|
210
211
|
let existingId = null;
|
|
@@ -15,6 +15,7 @@ const { resolveProjectRoot } = require('./project-root');
|
|
|
15
15
|
const { recordRecommendation } = require('./recommendation-log');
|
|
16
16
|
const { formatCoveringTests } = require('./covering-tests');
|
|
17
17
|
const { emitPreToolAllowContext } = require('./hook-emit');
|
|
18
|
+
const { hidden } = require('./proc-opts');
|
|
18
19
|
|
|
19
20
|
// v0.49 — walk up from the shell cwd (subdir-cwd fix). The per-cwd index.db
|
|
20
21
|
// gate kept this hook dark for entire sessions after `cd backend/` — daagu
|
|
@@ -81,10 +82,10 @@ if (!symbol || symbol.length < 3) {
|
|
|
81
82
|
.sort((a, b) => b.length - a.length);
|
|
82
83
|
for (const candidate of candidates.slice(0, 5)) {
|
|
83
84
|
try {
|
|
84
|
-
const raw = execFileSync(binary, ['grep', candidate, filePath, '--json'], {
|
|
85
|
+
const raw = execFileSync(binary, ['grep', candidate, filePath, '--json'], hidden({
|
|
85
86
|
cwd, timeout: 2000, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'],
|
|
86
87
|
env: internalEnv,
|
|
87
|
-
});
|
|
88
|
+
}));
|
|
88
89
|
const grepResult = JSON.parse(raw);
|
|
89
90
|
// Pick this candidate if it has few matches (precise location)
|
|
90
91
|
const withContainer = (grepResult || []).filter(m => m.container && m.container.name);
|
|
@@ -136,13 +137,13 @@ try {
|
|
|
136
137
|
if (relFile && !relFile.startsWith('..')) args.push('--file', relFile);
|
|
137
138
|
// v0.49 — use the resolved binary (bare 'code-graph-mcp' was PATH-dependent,
|
|
138
139
|
// diverging from the findBinary() result the rest of the hook trusts).
|
|
139
|
-
const raw = execFileSync(binary, args, {
|
|
140
|
+
const raw = execFileSync(binary, args, hidden({
|
|
140
141
|
cwd,
|
|
141
142
|
timeout: 2500,
|
|
142
143
|
encoding: 'utf8',
|
|
143
144
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
144
145
|
env: internalEnv,
|
|
145
|
-
});
|
|
146
|
+
}));
|
|
146
147
|
jsonResult = JSON.parse(raw);
|
|
147
148
|
} catch {
|
|
148
149
|
// Symbol not found, timeout, or parse error — exit silently
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/**
|
|
3
|
+
* Child-process option defaults shared by every spawn/exec in this plugin.
|
|
4
|
+
*
|
|
5
|
+
* Windows creates a NEW visible console window for every console-subsystem
|
|
6
|
+
* child whose parent has no console of its own — and none of our parents do:
|
|
7
|
+
* the MCP server (`node mcp-launcher.js`), the hooks and the statusline are all
|
|
8
|
+
* launched hidden by Claude Code. Node's `windowsHide` defaults to `false` on
|
|
9
|
+
* EVERY child_process API (spawn/spawnSync/exec/execSync/execFile/execFileSync),
|
|
10
|
+
* so each `where` / `curl` / `tar` / `npm` child flashed a console window for
|
|
11
|
+
* ~1s and stole keyboard focus. Reported as 5–7 flashes per session start
|
|
12
|
+
* (issue #40); the auto-update treadmill fixed alongside it made that per
|
|
13
|
+
* session, forever.
|
|
14
|
+
*
|
|
15
|
+
* `windowsHide: true` maps to CREATE_NO_WINDOW, which only stops the child from
|
|
16
|
+
* ALLOCATING a console — inherited stdio handles still work, so an interactive
|
|
17
|
+
* `doctor` run in a real terminal is unaffected. No-op on non-Windows.
|
|
18
|
+
*
|
|
19
|
+
* Every child_process call site under claude-plugin/scripts/ must route through
|
|
20
|
+
* here (or set windowsHide itself); `windows-hide.test.js` fails the build on a
|
|
21
|
+
* new call site that doesn't.
|
|
22
|
+
*/
|
|
23
|
+
function hidden(opts = {}) {
|
|
24
|
+
return { windowsHide: true, ...opts };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
module.exports = { hidden };
|
|
@@ -11,6 +11,7 @@ const {
|
|
|
11
11
|
const { readBinaryVersion, isDevMode, getNewestMtime } = require('./version-utils');
|
|
12
12
|
const { maybeAutoAdopt, isAdopted, unadopt } = require('./adopt');
|
|
13
13
|
const { isNonProjectCwd } = require('./project-detect');
|
|
14
|
+
const { hidden } = require('./proc-opts');
|
|
14
15
|
|
|
15
16
|
// v0.17.0 — quietHooks: unconditional quiet 默认。
|
|
16
17
|
// 项目地图与 MEMORY.md plugin contract + on-demand `project_map` 工具高度重叠,
|
|
@@ -168,16 +169,20 @@ function formatRecentImpact(changed, affected, dependentCap = 6) {
|
|
|
168
169
|
|
|
169
170
|
function launchBackgroundAutoUpdate(spawnFn = spawn, env = process.env, { force = false } = {}) {
|
|
170
171
|
try {
|
|
172
|
+
// Documented opt-out (issue #40). Checked HERE as well as inside
|
|
173
|
+
// auto-update.js so an opted-out user doesn't pay for a node process per
|
|
174
|
+
// session just to have it exit immediately.
|
|
175
|
+
if (env.CODE_GRAPH_NO_AUTO_UPDATE === '1') return false;
|
|
171
176
|
const args = [path.join(__dirname, 'auto-update.js'), 'check', '--silent'];
|
|
172
177
|
// A session start / reload forces an immediate check (bypasses the soft
|
|
173
178
|
// throttle down to auto-update.js's short anti-hammer floor + rate-limit
|
|
174
179
|
// backoff), so an available update is picked up now rather than on the next tick.
|
|
175
180
|
if (force) args.push('--force');
|
|
176
|
-
const child = spawnFn(process.execPath, args, {
|
|
181
|
+
const child = spawnFn(process.execPath, args, hidden({
|
|
177
182
|
detached: true,
|
|
178
183
|
stdio: 'ignore',
|
|
179
184
|
env: { ...env, CODE_GRAPH_AUTO_UPDATE_SILENT: '1' },
|
|
180
|
-
});
|
|
185
|
+
}));
|
|
181
186
|
if (child && typeof child.unref === 'function') child.unref();
|
|
182
187
|
return true;
|
|
183
188
|
} catch {
|
|
@@ -322,7 +327,7 @@ function indexNeedsRevalidation(bin, cwd) {
|
|
|
322
327
|
let out;
|
|
323
328
|
try {
|
|
324
329
|
out = execFileSync(bin, ['health-check', '--format', 'json'],
|
|
325
|
-
{ cwd, timeout: 3000, stdio: ['pipe', 'pipe', 'pipe'] }).toString();
|
|
330
|
+
hidden({ cwd, timeout: 3000, stdio: ['pipe', 'pipe', 'pipe'] })).toString();
|
|
326
331
|
} catch (e) {
|
|
327
332
|
// health-check exits non-zero on an unhealthy index but still writes JSON.
|
|
328
333
|
out = ((e && e.stdout) || '').toString();
|
|
@@ -363,7 +368,7 @@ function ensureIndexFresh() {
|
|
|
363
368
|
try {
|
|
364
369
|
const dbMtime = fs.statSync(dbPath).mtimeMs;
|
|
365
370
|
const gitTs = parseInt(
|
|
366
|
-
execSync('git log -1 --format=%ct', { cwd, timeout: 2000, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }).trim()
|
|
371
|
+
execSync('git log -1 --format=%ct', hidden({ cwd, timeout: 2000, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] })).trim()
|
|
367
372
|
) * 1000;
|
|
368
373
|
if (gitTs > dbMtime) needsRefresh = true;
|
|
369
374
|
} catch { /* no git / not a repo — fall through to the version probe */ }
|
|
@@ -373,11 +378,11 @@ function ensureIndexFresh() {
|
|
|
373
378
|
|
|
374
379
|
if (!needsRefresh) return 'fresh';
|
|
375
380
|
|
|
376
|
-
const child = spawn(bin, ['incremental-index', '--quiet'], {
|
|
381
|
+
const child = spawn(bin, ['incremental-index', '--quiet'], hidden({
|
|
377
382
|
cwd,
|
|
378
383
|
detached: true,
|
|
379
384
|
stdio: 'ignore',
|
|
380
|
-
});
|
|
385
|
+
}));
|
|
381
386
|
if (child && typeof child.unref === 'function') child.unref();
|
|
382
387
|
return 'refreshing';
|
|
383
388
|
}
|
|
@@ -415,7 +420,7 @@ function verifyBinary() {
|
|
|
415
420
|
// On macOS, verify the binary can actually run (Gatekeeper may block it)
|
|
416
421
|
if (process.platform === 'darwin') {
|
|
417
422
|
try {
|
|
418
|
-
execFileSync(binary, ['--version'], { timeout: 3000, stdio: 'pipe' });
|
|
423
|
+
execFileSync(binary, ['--version'], hidden({ timeout: 3000, stdio: 'pipe' }));
|
|
419
424
|
} catch (err) {
|
|
420
425
|
const msg = (err.message || '') + (err.stderr ? err.stderr.toString() : '');
|
|
421
426
|
if (msg.includes('quarantine') || msg.includes('not permitted') ||
|
|
@@ -674,7 +679,7 @@ function injectProjectMap() {
|
|
|
674
679
|
const bin = findBinary();
|
|
675
680
|
if (!bin) return false;
|
|
676
681
|
|
|
677
|
-
const output = execFileSync(bin, ['map', '--compact'], {
|
|
682
|
+
const output = execFileSync(bin, ['map', '--compact'], hidden({
|
|
678
683
|
cwd,
|
|
679
684
|
timeout: 5000,
|
|
680
685
|
encoding: 'utf8',
|
|
@@ -682,7 +687,7 @@ function injectProjectMap() {
|
|
|
682
687
|
// Hook-internal delivery, not a model conversion — keep record_cli_use from
|
|
683
688
|
// logging this `map` run as a phantom `use` (mirror injectRecentImpact's affected call).
|
|
684
689
|
env: { ...process.env, CODE_GRAPH_INTERNAL: '1' },
|
|
685
|
-
});
|
|
690
|
+
}));
|
|
686
691
|
|
|
687
692
|
if (output && output.trim()) {
|
|
688
693
|
process.stdout.write(
|
|
@@ -723,7 +728,7 @@ function injectRecentImpact({ source } = {}) {
|
|
|
723
728
|
// last commit. Timeouts tightened (finding #1): worst-case cap sum is now
|
|
724
729
|
// status(1s) + HEAD~1(1s) + affected(1.5s) = 3.5s, comfortably under the 5s
|
|
725
730
|
// SessionStart hook budget; the old 2+2+3=7s could get the whole hook killed.
|
|
726
|
-
const gitOpts = { cwd: sessionDir, timeout: 1000, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] };
|
|
731
|
+
const gitOpts = hidden({ cwd: sessionDir, timeout: 1000, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] });
|
|
727
732
|
let changed = [];
|
|
728
733
|
let isWip = false;
|
|
729
734
|
try {
|
|
@@ -749,10 +754,10 @@ function injectRecentImpact({ source } = {}) {
|
|
|
749
754
|
|
|
750
755
|
let affected;
|
|
751
756
|
try {
|
|
752
|
-
const raw = execFileSync(bin, ['affected', ...changed, '--json'], {
|
|
757
|
+
const raw = execFileSync(bin, ['affected', ...changed, '--json'], hidden({
|
|
753
758
|
cwd, timeout: 1500, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'],
|
|
754
759
|
env: { ...process.env, CODE_GRAPH_INTERNAL: '1' },
|
|
755
|
-
});
|
|
760
|
+
}));
|
|
756
761
|
affected = JSON.parse(raw);
|
|
757
762
|
} catch {
|
|
758
763
|
return false;
|
|
@@ -803,9 +808,9 @@ function checkHookFiring({ now = Date.now() } = {}) {
|
|
|
803
808
|
// surface from the next start. Re-checks daily (catches post-install drift,
|
|
804
809
|
// e.g. a node upgrade that breaks a hook).
|
|
805
810
|
try {
|
|
806
|
-
const child = spawn(process.execPath, [path.join(__dirname, 'lifecycle.js'), 'verify-hooks-fire'], {
|
|
811
|
+
const child = spawn(process.execPath, [path.join(__dirname, 'lifecycle.js'), 'verify-hooks-fire'], hidden({
|
|
807
812
|
detached: true, stdio: 'ignore',
|
|
808
|
-
});
|
|
813
|
+
}));
|
|
809
814
|
if (child && typeof child.unref === 'function') child.unref();
|
|
810
815
|
} catch { /* ok */ }
|
|
811
816
|
}
|
|
@@ -9,6 +9,7 @@ const { execFileSync } = require('child_process');
|
|
|
9
9
|
const path = require('path');
|
|
10
10
|
const os = require('os');
|
|
11
11
|
const lifecycle = require('./lifecycle');
|
|
12
|
+
const { hidden } = require('./proc-opts');
|
|
12
13
|
const { readRegistry } = lifecycle;
|
|
13
14
|
const cleanupDisabledStatusline = lifecycle.cleanupDisabledStatusline || (() => ({ cleaned: false }));
|
|
14
15
|
|
|
@@ -79,12 +80,12 @@ function runProvider(command, needsStdin, stdin) {
|
|
|
79
80
|
const cwd = cwdFromStdin(stdin);
|
|
80
81
|
const env = cwd ? { ...process.env, CODE_GRAPH_STATUSLINE_CWD: cwd } : process.env;
|
|
81
82
|
|
|
82
|
-
const out = execFileSync(argv[0], argv.slice(1), {
|
|
83
|
+
const out = execFileSync(argv[0], argv.slice(1), hidden({
|
|
83
84
|
timeout: 3000,
|
|
84
85
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
85
86
|
input: needsStdin ? stdin : '',
|
|
86
87
|
env,
|
|
87
|
-
}).toString().trim();
|
|
88
|
+
})).toString().trim();
|
|
88
89
|
|
|
89
90
|
return out || null;
|
|
90
91
|
} catch { return null; }
|
|
@@ -7,6 +7,7 @@ const path = require('path');
|
|
|
7
7
|
const { findBinary } = require('./find-binary');
|
|
8
8
|
const { resolveProjectRoot } = require('./project-root');
|
|
9
9
|
const lifecycle = require('./lifecycle');
|
|
10
|
+
const { hidden } = require('./proc-opts');
|
|
10
11
|
const cleanupDisabledStatusline = lifecycle.cleanupDisabledStatusline || (() => ({ cleaned: false }));
|
|
11
12
|
|
|
12
13
|
// True when auto-update has a newer release queued or in flight (the background
|
|
@@ -155,14 +156,14 @@ try {
|
|
|
155
156
|
// health-check (e.g. CPU saturated by the embedding backfill) and the segment
|
|
156
157
|
// silently vanished. Keeping the inner budget well under the outer one turns
|
|
157
158
|
// "slow health-check" into a rendered "offline"/"updating" instead of a blank.
|
|
158
|
-
report = parseReport(execFileSync(bin, ['health-check', '--format', 'json'], {
|
|
159
|
+
report = parseReport(execFileSync(bin, ['health-check', '--format', 'json'], hidden({
|
|
159
160
|
timeout: 1500,
|
|
160
161
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
161
162
|
// Run the binary FROM the resolved root so its own project-root resolution
|
|
162
163
|
// lands on the same DB the gate above picked (a subdir cwd would otherwise
|
|
163
164
|
// re-resolve to a stray nested index inside the binary).
|
|
164
165
|
cwd: root
|
|
165
|
-
}).toString());
|
|
166
|
+
})).toString());
|
|
166
167
|
} catch (e) {
|
|
167
168
|
// health-check exits NON-ZERO on an unhealthy/empty index but still writes the
|
|
168
169
|
// full JSON report to stdout. The binary ran fine \u2014 recover the report from the
|
|
@@ -13,6 +13,7 @@ const os = require('os');
|
|
|
13
13
|
// diagnostic blindness + the §8 recursive-grep footgun (see tmp-dir.js). The
|
|
14
14
|
// other hook scripts already route through here; this one was the lone holdout.
|
|
15
15
|
const { cgTmpDir } = require('./tmp-dir');
|
|
16
|
+
const { hidden } = require('./proc-opts');
|
|
16
17
|
|
|
17
18
|
// Mid-session install detection: hook fires but no manifest yet.
|
|
18
19
|
const MANIFEST_PATH = path.join(os.homedir(), '.cache', 'code-graph', 'install-manifest.json');
|
|
@@ -467,13 +468,13 @@ function runMain() {
|
|
|
467
468
|
};
|
|
468
469
|
|
|
469
470
|
function run(cmd, args) {
|
|
470
|
-
return execFileSync(cmd, args, {
|
|
471
|
+
return execFileSync(cmd, args, hidden({
|
|
471
472
|
cwd,
|
|
472
473
|
timeout: 3000,
|
|
473
474
|
encoding: 'utf8',
|
|
474
475
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
475
476
|
env: buildRunEnv(),
|
|
476
|
-
});
|
|
477
|
+
}));
|
|
477
478
|
}
|
|
478
479
|
|
|
479
480
|
try {
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
const { execFileSync } = require('child_process');
|
|
3
3
|
const fs = require('fs');
|
|
4
4
|
const path = require('path');
|
|
5
|
+
const { hidden } = require('./proc-opts');
|
|
5
6
|
|
|
6
7
|
// Tolerant match: the version line anywhere in the output (m flag), optional
|
|
7
8
|
// "v" prefix, and anything after the numeric triple (build-metadata suffixes
|
|
@@ -13,12 +14,12 @@ const VERSION_OUTPUT_RE = /^code-graph-mcp\s+v?(\d+\.\d+\.\d+)/m;
|
|
|
13
14
|
|
|
14
15
|
function readBinaryVersion(binaryPath) {
|
|
15
16
|
try {
|
|
16
|
-
const out = execFileSync(binaryPath, ['--version'], {
|
|
17
|
+
const out = execFileSync(binaryPath, ['--version'], hidden({
|
|
17
18
|
// 5s: a cold exec of a freshly-written ~40MB binary (page-in, Windows AV
|
|
18
19
|
// scan) regularly exceeded the old 2s, misclassifying a good binary.
|
|
19
20
|
timeout: 5000,
|
|
20
21
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
21
|
-
}).toString().trim();
|
|
22
|
+
})).toString().trim();
|
|
22
23
|
const match = out.match(VERSION_OUTPUT_RE);
|
|
23
24
|
return match ? match[1] : null;
|
|
24
25
|
} catch {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sdsrs/code-graph",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.111.0",
|
|
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.
|
|
39
|
-
"@sdsrs/code-graph-linux-arm64": "0.
|
|
40
|
-
"@sdsrs/code-graph-darwin-x64": "0.
|
|
41
|
-
"@sdsrs/code-graph-darwin-arm64": "0.
|
|
42
|
-
"@sdsrs/code-graph-win32-x64": "0.
|
|
38
|
+
"@sdsrs/code-graph-linux-x64": "0.111.0",
|
|
39
|
+
"@sdsrs/code-graph-linux-arm64": "0.111.0",
|
|
40
|
+
"@sdsrs/code-graph-darwin-x64": "0.111.0",
|
|
41
|
+
"@sdsrs/code-graph-darwin-arm64": "0.111.0",
|
|
42
|
+
"@sdsrs/code-graph-win32-x64": "0.111.0"
|
|
43
43
|
}
|
|
44
44
|
}
|