baldart 4.36.0 → 4.38.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/CHANGELOG.md +21 -0
- package/README.md +19 -2
- package/VERSION +1 -1
- package/package.json +1 -1
- package/src/commands/doctor.js +106 -0
- package/src/utils/codex-orphans.js +182 -0
- package/src/utils/graphify-installer.js +47 -0
- package/src/utils/http.js +35 -0
- package/src/utils/semver-lite.js +38 -0
- package/src/utils/tool-currency.js +148 -0
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,27 @@ All notable changes to BALDART will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [4.38.0] - 2026-06-15
|
|
9
|
+
|
|
10
|
+
**`baldart doctor` now checks whether the external tools BALDART installs are out of date upstream, and offers a one-command upgrade.** BALDART installs external tools into consumer machines but **pins none of them** — `pipx install graphifyy`, `npm install -g typescript-language-server`, … all grab "latest" at install time, and neither pipx nor npm ever auto-upgrades. So a consumer who installed months ago is frozen on whatever version they got, and never receives upstream security/correctness fixes; the `add`/`update`/`configure` flows can't help because they only run at install time. Concrete trigger: Graphify shipped `0.8.37` (SSRF guard thread-safety + prompt-injection mitigation + a macOS NFC/NFD re-extraction loop fix), `0.8.38` (`calls` edge-direction + JS/TS default import/export + tsconfig `paths` correctness) and `0.8.39` (a `graphify affected` `KeyError` crash fix — a command BALDART agents actually invoke) — all invisible to a frozen install. This release adds the continuous currency check that was missing: the tool-dependency analogue of the `baldart` CLI's own `UpdateNotifier`. **MINOR** (new doctor diagnostic + self-heal action; backwards-compatible — network-gated and skipped under `--offline`, zero output when every tool is current, no install/layout change, no `baldart.config.yml` key ⇒ schema-propagation rule N/A).
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- **`src/utils/tool-currency.js`** — external-tool currency prober. `probeAll()` returns one record per managed tool BALDART can query against its upstream registry, comparing the installed version to the latest published one. Probes today: **Graphify** (`graphifyy` on PyPI, gated on `features.has_code_graph` + binary present) and **LSP language servers** (gated on `features.has_lsp_layer`; `npm-global` adapters queried via the npm registry, `system` adapters — gopls, rust-analyzer — reported `unknown` with their canonical `@latest` reinstall command rather than a probe we can't cheaply run). Invariants mirror the rest of doctor's probes: **best-effort** (a failed probe is omitted, never throws, never blocks), **honest** (`status: 'outdated'` only with BOTH versions known and installed < latest — `unknown`/`current` are never surfaced as a nag), **offline-aware** (the caller skips it entirely under `--offline`).
|
|
15
|
+
- **`src/utils/http.js`** — dependency-free `getJson(url, {timeoutMs})` over `https` core (no reliance on the Node 18 `fetch` flag), resolving `null` on any failure. Used by the currency layer to read registry metadata.
|
|
16
|
+
- **`src/utils/semver-lite.js`** — tolerant dependency-free version comparison (`cmpVersions`) for heterogeneous registry strings (`0.8.39`, `v1.2`, `4.3.3`, `1.2.3-rc1`); returns `0` (no nag) on unparseable input. Shared by `graphify-installer` and `tool-currency`.
|
|
17
|
+
- **`src/utils/graphify-installer.js`** — `checkUpgrade()` (installed CLI vs latest `graphifyy` on PyPI; best-effort, async, never nags on uncertainty) and `upgrade()` (`pipx upgrade graphifyy`, falling back to `pip --user --upgrade`; never throws).
|
|
18
|
+
- **`src/commands/doctor.js`** — new probe (`state.toolCurrency`, network-gated on `!--offline`) and one non-blocking upgrade action per tool **confirmed** behind upstream (`tool-upgrade:<tool>`, `autoOk: false` — upgrading a system-level tool warrants explicit intent). Auto-upgradable tools (pipx/npm) run the upgrade in place; non-auto ones (system toolchains) print the canonical `@latest` command. `unknown`/`current` tools produce no action — no nag without proof.
|
|
19
|
+
|
|
20
|
+
## [4.37.0] - 2026-06-15
|
|
21
|
+
|
|
22
|
+
**`baldart doctor` now detects and reaps orphaned MCP-server processes left behind by BALDART's Codex calls.** A real machine hit ~100% CPU from ~45 orphaned `@playwright/mcp` processes (plus stray `obsidian-mcp-server` instances), all children of OpenAI Codex CLI sessions that had since died. Root cause traced through the Codex companion plugin: every BALDART Codex finder call (`/new`, `new2`, `/codexreview`, the cron review engine) drives `codex app-server` via `codex-companion.mjs`, which attaches to a **shared, `detached + unref'd` broker** (`broker-lifecycle.mjs`). That broker spawns every MCP server declared in the user's `~/.codex/config.toml` (Playwright, Figma, …) as its own children; when the broker dies the OS reparents those MCP servers to init (ppid 1) and they keep running — an `@playwright/mcp` can peg a core for days. The leak compounds across sessions. We cannot suppress the MCP spawn per-call (the companion attaches to a broker it does not control, and exposes no shutdown verb), so the fix is a **safe reaper** owned by the doctor. **MINOR** (new doctor diagnostic + self-heal action; backwards-compatible — zero output on a clean machine, no install/layout change, not a `baldart.config.yml` key ⇒ schema-propagation rule N/A).
|
|
23
|
+
|
|
24
|
+
### Added
|
|
25
|
+
|
|
26
|
+
- **`src/utils/codex-orphans.js`** — orphaned-MCP-server detector + reaper. `detectOrphans()` snapshots `ps -axo` and returns MCP servers that are **orphaned (ppid 1) AND match an MCP-server command signature** (`@playwright/mcp`, `playwright-mcp`, `*-mcp-server`, `@modelcontextprotocol/*`, `obsidian-mcp`, npx `*-mcp@*`). `reapOrphans()` kills each orphan's full process tree (so a Playwright MCP's browser children go too) via `process.kill(pid, 'SIGKILL')` — a direct syscall, immune to sandboxed shells that silently swallow multi-arg `kill`/for-loops. **Safety invariant**: ppid 1 means the parent is dead, so an MCP server's stdio pipe is broken and the process is unreconnectable dead weight — safe to reap. The `codex app-server` broker is deliberately NOT reaped: it is `detached + unref'd` by design, so a *live, in-use* shared runtime also shows ppid 1 and ppid 1 cannot tell a leaked broker from a healthy one. Broker processes are detected for visibility only. Fully fail-safe (Windows / any error → "no orphans"); no age threshold (an orphan is dead weight at any age).
|
|
27
|
+
- **`src/commands/doctor.js`** — new probe (`state.mcpOrphans`), diagnostic line (`Codex MCP leak — N orphaned MCP server(s) running`, shown only when present so a clean machine prints nothing), and self-heal action `reap-mcp-orphans` (`autoOk: false` — killing processes warrants explicit intent; re-detects against a fresh snapshot at run time so it never acts on a stale list).
|
|
28
|
+
|
|
8
29
|
## [4.36.0] - 2026-06-13
|
|
9
30
|
|
|
10
31
|
**`/new` security-domain fixes are now applied by `security-reviewer`, not `coder` — the v4.26.1 canonical writer map, finally propagated from `new2` to `/new`.** Auditing the `new2` lessons for guards/logic missing on `/new` surfaced one real gap (the others — args-string guard, JS router clamp, no-self-judge + specialist-owned lane, relevance-gated fan-out — were already present on `/new`). `new2-resolve.js` routes security fixes to `security-reviewer` (`fixerAgent = {doc:'doc-reviewer', ui:'ui-expert', security:'security-reviewer'}[domain] || 'coder'`), but the canonical writer map was never propagated to `/new`'s SSOT: the `Domain-Override Domains` table (SKILL.md) and every fix-routing site still sent `security` → `coder`. A coder applying a one-line RLS/permission/auth fix lacks the security-invariant contract that lives in `security-reviewer`'s system prompt — the same class of error as "wrong agent for the card", and a direct violation of the user's standing strict-specialization principle. **MINOR** (changes which agent applies security fixes across `/new`; backwards-compatible — `migration` stays `coder`, no install/layout change, no `baldart.config.yml` key ⇒ schema-propagation rule N/A).
|
package/README.md
CHANGED
|
@@ -496,8 +496,25 @@ still exist for power users, but the seamless default makes them unnecessary.
|
|
|
496
496
|
|
|
497
497
|
Smart diagnostic that detects the install state and proposes the next sensible
|
|
498
498
|
action (install, migrate legacy layout, configure, refresh config schema,
|
|
499
|
-
update, push,
|
|
500
|
-
proposed actions with confirmation per
|
|
499
|
+
update, push, repair symlinks, reap orphaned Codex MCP servers, or "nothing to
|
|
500
|
+
do"). Prints a status table then runs the proposed actions with confirmation per
|
|
501
|
+
step.
|
|
502
|
+
|
|
503
|
+
Since v4.37.0 it also surfaces **orphaned MCP-server processes left by Codex
|
|
504
|
+
calls** — every BALDART Codex finder call (`/new`, `new2`, `/codexreview`, the
|
|
505
|
+
cron review engine) drives `codex app-server`, whose detached broker spawns the
|
|
506
|
+
MCP servers from `~/.codex/config.toml` (Playwright, …) and leaks them to init
|
|
507
|
+
(ppid 1) when it dies, where they keep burning CPU. The doctor reaps the
|
|
508
|
+
orphaned MCP servers (and their browser children) directly via syscall; the live
|
|
509
|
+
`codex app-server` broker is never touched.
|
|
510
|
+
|
|
511
|
+
Since v4.38.0 it also checks **external-tool version currency** — BALDART pins
|
|
512
|
+
none of the tools it installs (`graphifyy` via pipx, language servers via npm),
|
|
513
|
+
and pipx/npm never auto-upgrade, so an old install silently misses upstream
|
|
514
|
+
security/correctness fixes. The doctor probes the managed tools against their
|
|
515
|
+
registries (PyPI / npm) and surfaces a non-blocking one-command upgrade for any
|
|
516
|
+
confirmed behind upstream (e.g. `Upgrade Graphify 0.8.36 → 0.8.39`). Network-gated
|
|
517
|
+
— skipped under `--offline`, silent when everything is current.
|
|
501
518
|
|
|
502
519
|
```bash
|
|
503
520
|
npx baldart # diagnostic + interactive prompts
|
package/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
4.
|
|
1
|
+
4.38.0
|
package/package.json
CHANGED
package/src/commands/doctor.js
CHANGED
|
@@ -33,6 +33,8 @@ const Hooks = require('../utils/hooks');
|
|
|
33
33
|
const GitHooks = require('../utils/githooks');
|
|
34
34
|
const LspInstaller = require('../utils/lsp-installer');
|
|
35
35
|
const GraphifyInstaller = require('../utils/graphify-installer');
|
|
36
|
+
const ToolCurrency = require('../utils/tool-currency');
|
|
37
|
+
const CodexOrphans = require('../utils/codex-orphans');
|
|
36
38
|
const UpdateNotifier = require('../utils/update-notifier');
|
|
37
39
|
const cliPackageJson = require('../../package.json');
|
|
38
40
|
|
|
@@ -388,6 +390,41 @@ async function detectState(cwd, opts = {}) {
|
|
|
388
390
|
}
|
|
389
391
|
}
|
|
390
392
|
} catch (_) { /* never block doctor on graph probe */ }
|
|
393
|
+
|
|
394
|
+
// ---- External-tool version currency (since v4.38.0) ----------------
|
|
395
|
+
// BALDART pins none of the external tools it installs (graphifyy via pipx,
|
|
396
|
+
// language servers via npm/system) — pipx/npm never auto-upgrade, so a
|
|
397
|
+
// consumer installed months ago is frozen and never receives upstream
|
|
398
|
+
// security/correctness fixes. Probe the managed tools against their
|
|
399
|
+
// registries and let the planner surface a non-blocking upgrade. Network;
|
|
400
|
+
// skipped entirely under --offline. Best-effort — never blocks doctor.
|
|
401
|
+
state.toolCurrency = [];
|
|
402
|
+
if (!opts.offline) {
|
|
403
|
+
try {
|
|
404
|
+
state.toolCurrency = await ToolCurrency.probeAll({
|
|
405
|
+
cwd,
|
|
406
|
+
config: (config && !config.__malformed) ? config : null,
|
|
407
|
+
lspInstalled: state.lspInstalled,
|
|
408
|
+
});
|
|
409
|
+
} catch (_) { /* never block doctor on currency probe */ }
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// ---- Orphaned MCP servers from Codex calls (since v4.37.0) ---------
|
|
413
|
+
// BALDART's Codex finder calls (/new, new2, /codexreview, cron engine)
|
|
414
|
+
// drive `codex app-server` via the companion plugin. That broker spawns the
|
|
415
|
+
// MCP servers from ~/.codex/config.toml (Playwright, …) as children and,
|
|
416
|
+
// being detached, leaks them to init (ppid 1) when it dies — they keep
|
|
417
|
+
// running (an @playwright/mcp can peg a core for days). Surface the orphans
|
|
418
|
+
// so the planner can offer a safe reap (orphaned MCP servers only; never the
|
|
419
|
+
// broker — see codex-orphans.js for the ppid-1 safety invariant). Fully
|
|
420
|
+
// fail-safe: any error → no orphans reported.
|
|
421
|
+
state.mcpOrphans = [];
|
|
422
|
+
state.codexRuntimeOrphans = [];
|
|
423
|
+
try {
|
|
424
|
+
const { mcp, runtime } = CodexOrphans.detectOrphans();
|
|
425
|
+
state.mcpOrphans = mcp;
|
|
426
|
+
state.codexRuntimeOrphans = runtime;
|
|
427
|
+
} catch (_) { /* never block doctor on the process probe */ }
|
|
391
428
|
}
|
|
392
429
|
|
|
393
430
|
return state;
|
|
@@ -781,6 +818,62 @@ function planActions(state) {
|
|
|
781
818
|
});
|
|
782
819
|
}
|
|
783
820
|
|
|
821
|
+
// External-tool version currency (since v4.38.0). One non-blocking upgrade
|
|
822
|
+
// action per managed tool confirmed behind upstream — the tool-dependency
|
|
823
|
+
// analogue of the `baldart` CLI's own UpdateNotifier. `unknown`/`current`
|
|
824
|
+
// records are NOT surfaced (no nag without proof). Auto-upgradable records
|
|
825
|
+
// (pipx/npm) get a runnable action; non-auto ones (system toolchains) print
|
|
826
|
+
// the canonical `@latest` command without auto-running it.
|
|
827
|
+
for (const rec of ToolCurrency.outdated(state.toolCurrency)) {
|
|
828
|
+
actions.push({
|
|
829
|
+
key: `tool-upgrade:${rec.tool}`,
|
|
830
|
+
label: `Upgrade ${rec.label} ${rec.installed} → ${rec.latest}`,
|
|
831
|
+
why: `${rec.label} is installed at ${rec.installed} but ${rec.latest} is published (${rec.source}). BALDART pins no external-tool versions and ${rec.autoUpgradable ? 'pipx/npm' : 'the system package manager'} never auto-upgrades, so this install is missing every upstream fix since ${rec.installed}. Upgrade with: \`${rec.upgradeCommand}\`.`,
|
|
832
|
+
autoOk: false, // upgrading a system-level tool warrants explicit intent
|
|
833
|
+
run: async () => {
|
|
834
|
+
if (rec.autoUpgradable && typeof rec.upgrade === 'function') {
|
|
835
|
+
const r = rec.upgrade();
|
|
836
|
+
if (r && r.ok) UI.success(`${rec.label} upgraded${r.method ? ` via ${r.method}` : ''}.`);
|
|
837
|
+
else UI.warning(`Upgrade failed: ${(r && r.error) || 'unknown error'}. Run \`${rec.upgradeCommand}\` manually.`);
|
|
838
|
+
} else {
|
|
839
|
+
UI.info(`Run to upgrade ${rec.label}:`);
|
|
840
|
+
console.log(` ${rec.upgradeCommand}`);
|
|
841
|
+
}
|
|
842
|
+
},
|
|
843
|
+
});
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
// Orphaned MCP servers from Codex calls (since v4.37.0). BALDART's Codex
|
|
847
|
+
// finder calls leave behind MCP-server processes (Playwright, obsidian-mcp, …)
|
|
848
|
+
// reparented to init when their `codex app-server` broker dies. They keep
|
|
849
|
+
// burning CPU. Offer a safe reap — orphaned MCP servers only (ppid 1 ⇒ parent
|
|
850
|
+
// dead ⇒ stdio broken ⇒ dead weight). The action is NOT autoOk: killing
|
|
851
|
+
// processes warrants explicit intent.
|
|
852
|
+
if (state.mcpOrphans && state.mcpOrphans.length > 0) {
|
|
853
|
+
const n = state.mcpOrphans.length;
|
|
854
|
+
actions.push({
|
|
855
|
+
key: 'reap-mcp-orphans',
|
|
856
|
+
label: `Reap ${n} orphaned MCP server process(es) left by Codex`,
|
|
857
|
+
why: `${n} MCP server(s) are orphaned (ppid 1 — their parent Codex session/broker is dead) and still running. They cannot be reconnected to (their stdio pipe is broken) and waste CPU. Reaping kills each process tree directly via syscall. The codex app-server broker itself is never touched.`,
|
|
858
|
+
autoOk: false, // kills processes — require explicit intent
|
|
859
|
+
run: async () => {
|
|
860
|
+
const procs = CodexOrphans.listProcesses();
|
|
861
|
+
// Re-detect against a fresh snapshot so we never act on a stale list.
|
|
862
|
+
const { mcp } = CodexOrphans.detectOrphans(procs);
|
|
863
|
+
if (mcp.length === 0) {
|
|
864
|
+
UI.info('No orphaned MCP servers remain — nothing to reap.');
|
|
865
|
+
return;
|
|
866
|
+
}
|
|
867
|
+
const { killed, failed } = CodexOrphans.reapOrphans(mcp, procs);
|
|
868
|
+
if (killed.length) UI.success(`Reaped ${killed.length} orphaned process(es) (incl. descendants).`);
|
|
869
|
+
if (failed.length) {
|
|
870
|
+
UI.warning(`${failed.length} could not be killed:`);
|
|
871
|
+
failed.forEach((f) => console.log(` pid ${f.pid}: ${f.error}`));
|
|
872
|
+
}
|
|
873
|
+
},
|
|
874
|
+
});
|
|
875
|
+
}
|
|
876
|
+
|
|
784
877
|
// v3.25.0+: drift detection is authoritative via VERSION compare (isAligned).
|
|
785
878
|
// The HEAD...FETCH_HEAD commit count is subtree-merge noise and never reaches
|
|
786
879
|
// 0, so we MUST NOT use it as the "needs update" signal.
|
|
@@ -1037,6 +1130,19 @@ function renderDiagnostic(state) {
|
|
|
1037
1130
|
console.log(statusLine('Code graph', 'disabled', 'ok'));
|
|
1038
1131
|
}
|
|
1039
1132
|
|
|
1133
|
+
// Orphaned MCP servers left by Codex calls (v4.37.0). Only shown when present
|
|
1134
|
+
// — a clean machine prints nothing here (zero noise).
|
|
1135
|
+
if (state.mcpOrphans && state.mcpOrphans.length > 0) {
|
|
1136
|
+
console.log(statusLine(
|
|
1137
|
+
'Codex MCP leak',
|
|
1138
|
+
`${state.mcpOrphans.length} orphaned MCP server(s) running — will be reaped`,
|
|
1139
|
+
'warn'
|
|
1140
|
+
));
|
|
1141
|
+
state.mcpOrphans.slice(0, 6).forEach((p) =>
|
|
1142
|
+
console.log(` • pid ${p.pid} (up ${p.etime}): ${p.command.slice(0, 70)}`));
|
|
1143
|
+
if (state.mcpOrphans.length > 6) console.log(` • … and ${state.mcpOrphans.length - 6} more`);
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1040
1146
|
console.log();
|
|
1041
1147
|
}
|
|
1042
1148
|
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Orphaned-MCP-server reaper (since v4.37.0).
|
|
3
|
+
*
|
|
4
|
+
* WHY THIS EXISTS
|
|
5
|
+
* ---------------
|
|
6
|
+
* BALDART's Codex integration (the `/new`, `new2`, `/codexreview` finder calls
|
|
7
|
+
* and the cron review engine) drives the OpenAI Codex CLI through the
|
|
8
|
+
* `codex-companion.mjs` plugin. That companion attaches to a SHARED, persistent
|
|
9
|
+
* `codex app-server` broker which is spawned `detached + unref'd`
|
|
10
|
+
* (broker-lifecycle.mjs) and which, in turn, spawns every MCP server declared in
|
|
11
|
+
* the user's `~/.codex/config.toml` (Playwright, Figma, …) as its own children.
|
|
12
|
+
*
|
|
13
|
+
* When that broker eventually dies, its MCP children are NOT reaped: the OS
|
|
14
|
+
* reparents them to init (ppid 1) and they keep running — an `@playwright/mcp`
|
|
15
|
+
* server can sit at ~100% CPU for days. Over many Codex sessions these
|
|
16
|
+
* accumulate (the symptom that motivated this utility: ~45 orphaned Playwright
|
|
17
|
+
* MCP processes pegging the machine).
|
|
18
|
+
*
|
|
19
|
+
* SAFETY INVARIANT (read before touching the matchers)
|
|
20
|
+
* ----------------------------------------------------
|
|
21
|
+
* We reap a process ONLY when BOTH hold:
|
|
22
|
+
* 1. ppid === 1 → the process was reparented to init, i.e. its controlling
|
|
23
|
+
* parent is DEAD. An MCP server is a stdio child of whatever launched it;
|
|
24
|
+
* once that parent dies the stdio pipe is broken and the server is dead
|
|
25
|
+
* weight that can never be reconnected to. Reaping it is safe.
|
|
26
|
+
* 2. the command matches a known MCP-server signature (below).
|
|
27
|
+
*
|
|
28
|
+
* We deliberately DO NOT reap the `codex app-server` broker itself. The broker
|
|
29
|
+
* is `detached + unref'd` BY DESIGN, so a perfectly healthy, in-use shared
|
|
30
|
+
* runtime ALSO shows ppid 1 — ppid 1 cannot distinguish a leaked broker from a
|
|
31
|
+
* live one. Killing it could interrupt an in-flight Codex turn. We only report
|
|
32
|
+
* broker processes for visibility; we never auto-kill them.
|
|
33
|
+
*
|
|
34
|
+
* We use Node's `process.kill(pid)` (a direct syscall) rather than shelling out
|
|
35
|
+
* to `kill` — some sandboxed shells silently swallow multi-arg `kill`/for-loops,
|
|
36
|
+
* and the syscall path is immune to that.
|
|
37
|
+
*
|
|
38
|
+
* Fully fail-safe: any internal error degrades to "no orphans found" / "nothing
|
|
39
|
+
* reaped". This is hygiene, never a blocker.
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
const { execSync } = require('child_process');
|
|
43
|
+
|
|
44
|
+
// Command signatures that identify an MCP server. When such a process is
|
|
45
|
+
// orphaned (ppid 1) it is safe to reap (its stdio parent is gone).
|
|
46
|
+
const MCP_SIGNATURES = [
|
|
47
|
+
/@playwright\/mcp/,
|
|
48
|
+
/\bplaywright-mcp\b/,
|
|
49
|
+
/@modelcontextprotocol\//,
|
|
50
|
+
/-mcp-server\b/,
|
|
51
|
+
/\bmcp-server\b/,
|
|
52
|
+
/\bobsidian-mcp/,
|
|
53
|
+
/[\w@/.-]+-mcp@/, // npx-launched `<pkg>-mcp@<version>`
|
|
54
|
+
];
|
|
55
|
+
|
|
56
|
+
// Codex runtime processes — DETECTED for visibility, never auto-reaped (see the
|
|
57
|
+
// safety note above: a detached broker at ppid 1 may still be the live runtime).
|
|
58
|
+
const CODEX_RUNTIME_SIGNATURES = [
|
|
59
|
+
/codex\s+app-server/,
|
|
60
|
+
/codex-companion\.mjs/,
|
|
61
|
+
];
|
|
62
|
+
|
|
63
|
+
function matchesAny(signatures, command) {
|
|
64
|
+
return signatures.some((re) => re.test(command));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Snapshot every process as { pid, ppid, etime, command }.
|
|
69
|
+
* `ps -axo` works on both macOS and Linux. Returns [] on any failure or on
|
|
70
|
+
* Windows (the orphan-reparent-to-init leak is a POSIX phenomenon).
|
|
71
|
+
*/
|
|
72
|
+
function listProcesses() {
|
|
73
|
+
if (process.platform === 'win32') return [];
|
|
74
|
+
let raw;
|
|
75
|
+
try {
|
|
76
|
+
raw = execSync('ps -axo pid=,ppid=,etime=,command=', {
|
|
77
|
+
encoding: 'utf8',
|
|
78
|
+
maxBuffer: 16 * 1024 * 1024,
|
|
79
|
+
timeout: 5000,
|
|
80
|
+
});
|
|
81
|
+
} catch (_) {
|
|
82
|
+
return [];
|
|
83
|
+
}
|
|
84
|
+
const procs = [];
|
|
85
|
+
for (const line of raw.split('\n')) {
|
|
86
|
+
const m = line.trim().match(/^(\d+)\s+(\d+)\s+(\S+)\s+(.*)$/);
|
|
87
|
+
if (!m) continue;
|
|
88
|
+
procs.push({
|
|
89
|
+
pid: Number(m[1]),
|
|
90
|
+
ppid: Number(m[2]),
|
|
91
|
+
etime: m[3],
|
|
92
|
+
command: m[4],
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
return procs;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Detect orphaned MCP servers (reapable) and Codex runtime processes (info only).
|
|
100
|
+
*
|
|
101
|
+
* @returns {{ mcp: Array, runtime: Array }}
|
|
102
|
+
* mcp — orphaned MCP servers (ppid 1 + MCP signature) safe to reap
|
|
103
|
+
* runtime — codex app-server / companion processes (reported, NOT reaped)
|
|
104
|
+
*/
|
|
105
|
+
function detectOrphans(procs = listProcesses()) {
|
|
106
|
+
const self = process.pid;
|
|
107
|
+
const mcp = [];
|
|
108
|
+
const runtime = [];
|
|
109
|
+
for (const p of procs) {
|
|
110
|
+
if (p.pid === self) continue;
|
|
111
|
+
if (p.ppid !== 1) continue; // only true orphans — parent is dead
|
|
112
|
+
if (matchesAny(MCP_SIGNATURES, p.command)) mcp.push(p);
|
|
113
|
+
else if (matchesAny(CODEX_RUNTIME_SIGNATURES, p.command)) runtime.push(p);
|
|
114
|
+
}
|
|
115
|
+
return { mcp, runtime };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Collect a pid plus all of its descendants (so killing an orphaned MCP server
|
|
120
|
+
* also takes down the browser/worker subprocesses it spawned).
|
|
121
|
+
*/
|
|
122
|
+
function collectTree(rootPid, procs) {
|
|
123
|
+
const childrenOf = new Map();
|
|
124
|
+
for (const p of procs) {
|
|
125
|
+
if (!childrenOf.has(p.ppid)) childrenOf.set(p.ppid, []);
|
|
126
|
+
childrenOf.get(p.ppid).push(p.pid);
|
|
127
|
+
}
|
|
128
|
+
const tree = [];
|
|
129
|
+
const seen = new Set();
|
|
130
|
+
const stack = [rootPid];
|
|
131
|
+
while (stack.length) {
|
|
132
|
+
const pid = stack.pop();
|
|
133
|
+
if (seen.has(pid)) continue;
|
|
134
|
+
seen.add(pid);
|
|
135
|
+
tree.push(pid);
|
|
136
|
+
for (const child of childrenOf.get(pid) || []) stack.push(child);
|
|
137
|
+
}
|
|
138
|
+
return tree;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Reap the given orphaned MCP-server processes (and their descendant trees).
|
|
143
|
+
* Uses process.kill(pid, 'SIGKILL') per-pid — immune to shells that swallow
|
|
144
|
+
* multi-arg kills. Never throws.
|
|
145
|
+
*
|
|
146
|
+
* @param {Array} orphans the `mcp` array from detectOrphans()
|
|
147
|
+
* @param {Array} procs full process snapshot (for descendant resolution)
|
|
148
|
+
* @returns {{ killed: number[], failed: Array<{pid:number,error:string}> }}
|
|
149
|
+
*/
|
|
150
|
+
function reapOrphans(orphans = [], procs = listProcesses()) {
|
|
151
|
+
const self = process.pid;
|
|
152
|
+
const targets = new Set();
|
|
153
|
+
for (const o of orphans) {
|
|
154
|
+
for (const pid of collectTree(o.pid, procs)) {
|
|
155
|
+
if (pid !== self && Number.isInteger(pid) && pid > 1) targets.add(pid);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
const killed = [];
|
|
159
|
+
const failed = [];
|
|
160
|
+
// Kill descendants before roots so a parent can't immediately re-fork: sort
|
|
161
|
+
// by depth is overkill — SIGKILL is unconditional — so a single pass suffices.
|
|
162
|
+
for (const pid of targets) {
|
|
163
|
+
try {
|
|
164
|
+
process.kill(pid, 'SIGKILL');
|
|
165
|
+
killed.push(pid);
|
|
166
|
+
} catch (err) {
|
|
167
|
+
// ESRCH = already gone (e.g. died with its parent tree) → treat as success.
|
|
168
|
+
if (err && err.code === 'ESRCH') killed.push(pid);
|
|
169
|
+
else failed.push({ pid, error: (err && err.message) || String(err) });
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return { killed, failed };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
module.exports = {
|
|
176
|
+
MCP_SIGNATURES,
|
|
177
|
+
CODEX_RUNTIME_SIGNATURES,
|
|
178
|
+
listProcesses,
|
|
179
|
+
detectOrphans,
|
|
180
|
+
collectTree,
|
|
181
|
+
reapOrphans,
|
|
182
|
+
};
|
|
@@ -2,6 +2,8 @@ const { execSync } = require('child_process');
|
|
|
2
2
|
const fs = require('fs');
|
|
3
3
|
const path = require('path');
|
|
4
4
|
const UI = require('./ui');
|
|
5
|
+
const { getJson } = require('./http');
|
|
6
|
+
const { cmpVersions } = require('./semver-lite');
|
|
5
7
|
|
|
6
8
|
/**
|
|
7
9
|
* High-level installer/wrapper for the Graphify code-knowledge-graph layer,
|
|
@@ -47,6 +49,51 @@ class GraphifyInstaller {
|
|
|
47
49
|
}
|
|
48
50
|
}
|
|
49
51
|
|
|
52
|
+
// ── Version currency (since v4.38.0) ──────────────────────────────────────
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Compare the installed `graphify` CLI against the latest `graphifyy` on
|
|
56
|
+
* PyPI. BEST-EFFORT: returns `{ installed, latest, outdated, source }` where
|
|
57
|
+
* any field may be `null` when it cannot be determined (offline, CLI absent,
|
|
58
|
+
* PyPI unreachable). `outdated` is only `true` with BOTH versions known and
|
|
59
|
+
* installed < latest — never nags on uncertainty. Async (network); never
|
|
60
|
+
* throws. The upgrade command is `pipx upgrade graphifyy` (see `upgrade()`).
|
|
61
|
+
*/
|
|
62
|
+
async checkUpgrade({ timeoutMs = 4000 } = {}) {
|
|
63
|
+
const installed = this.verify().version || null;
|
|
64
|
+
let latest = null;
|
|
65
|
+
try {
|
|
66
|
+
const data = await getJson('https://pypi.org/pypi/graphifyy/json', { timeoutMs });
|
|
67
|
+
latest = (data && data.info && data.info.version) || null;
|
|
68
|
+
} catch { latest = null; }
|
|
69
|
+
const outdated = !!(installed && latest && cmpVersions(installed, latest) < 0);
|
|
70
|
+
return { tool: 'graphify', installed, latest, outdated, source: 'pypi:graphifyy' };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Upgrade the Graphify CLI in place via the same package manager that can
|
|
75
|
+
* install it (pipx preferred, then pip --user). Returns `{ ok, method?,
|
|
76
|
+
* error? }`. Never throws. Safe to run unattended (no sudo); the command
|
|
77
|
+
* layer still asks for explicit intent before invoking it.
|
|
78
|
+
*/
|
|
79
|
+
upgrade({ spinner = true } = {}) {
|
|
80
|
+
const attempts = [];
|
|
81
|
+
if (this._hasCmd('pipx')) attempts.push({ method: 'pipx', cmd: 'pipx upgrade graphifyy' });
|
|
82
|
+
if (this._hasCmd('pip3')) attempts.push({ method: 'pip3 --user', cmd: 'pip3 install --user --upgrade graphifyy' });
|
|
83
|
+
if (this._hasCmd('pip')) attempts.push({ method: 'pip --user', cmd: 'pip install --user --upgrade graphifyy' });
|
|
84
|
+
if (!attempts.length) return { ok: false, error: 'no pipx/pip found on PATH' };
|
|
85
|
+
const sp = spinner ? UI.spinner('Upgrading Graphify (graphifyy)…').start() : null;
|
|
86
|
+
for (const a of attempts) {
|
|
87
|
+
try {
|
|
88
|
+
execSync(a.cmd, { stdio: 'pipe', timeout: 300000 });
|
|
89
|
+
if (sp) sp.succeed(`Upgraded Graphify via ${a.method}`);
|
|
90
|
+
return { ok: true, method: a.method };
|
|
91
|
+
} catch (_) { /* try next */ }
|
|
92
|
+
}
|
|
93
|
+
if (sp) sp.fail('Graphify upgrade failed');
|
|
94
|
+
return { ok: false, error: `upgrade failed (tried: ${attempts.map(a => a.method).join(', ')}). Run \`pipx upgrade graphifyy\` manually.` };
|
|
95
|
+
}
|
|
96
|
+
|
|
50
97
|
/** Absolute path to the graph artifact for a graphed root. */
|
|
51
98
|
graphPath(root = this.cwd, outputDir = 'graphify-out') {
|
|
52
99
|
return path.join(root, outputDir, 'graph.json');
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
const https = require('https');
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Minimal dependency-free JSON GET with a hard timeout.
|
|
5
|
+
*
|
|
6
|
+
* Used by the external-tool currency layer (`tool-currency.js`,
|
|
7
|
+
* `graphify-installer.checkUpgrade`) to ask a package registry "what is the
|
|
8
|
+
* latest published version?". It is deliberately tiny and BEST-EFFORT:
|
|
9
|
+
* - resolves `null` on ANY failure (DNS, TLS, non-200, bad JSON, timeout),
|
|
10
|
+
* - never throws, never rejects — currency checks must never break a flow.
|
|
11
|
+
*
|
|
12
|
+
* `https` core only (no `node-fetch`, no relying on global `fetch` which is
|
|
13
|
+
* still flagged on the Node 18 floor). HTTP is not supported on purpose:
|
|
14
|
+
* registry metadata is always HTTPS.
|
|
15
|
+
*/
|
|
16
|
+
function getJson(url, { timeoutMs = 4000, headers = {} } = {}) {
|
|
17
|
+
return new Promise((resolve) => {
|
|
18
|
+
let settled = false;
|
|
19
|
+
const done = (val) => { if (!settled) { settled = true; resolve(val); } };
|
|
20
|
+
let req;
|
|
21
|
+
try {
|
|
22
|
+
req = https.get(url, { headers: { 'User-Agent': 'baldart-doctor', Accept: 'application/json', ...headers } }, (res) => {
|
|
23
|
+
if (res.statusCode !== 200) { res.resume(); return done(null); }
|
|
24
|
+
let body = '';
|
|
25
|
+
res.setEncoding('utf8');
|
|
26
|
+
res.on('data', (c) => { body += c; if (body.length > 5_000_000) { req.destroy(); done(null); } });
|
|
27
|
+
res.on('end', () => { try { done(JSON.parse(body)); } catch { done(null); } });
|
|
28
|
+
});
|
|
29
|
+
} catch { return done(null); }
|
|
30
|
+
req.on('error', () => done(null));
|
|
31
|
+
req.setTimeout(timeoutMs, () => { req.destroy(); done(null); });
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
module.exports = { getJson };
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tolerant, dependency-free version comparison for the external-tool currency
|
|
3
|
+
* layer. NOT a full semver implementation — registry version strings from
|
|
4
|
+
* heterogeneous tools (PyPI `0.8.39`, a language server's `4.3.3`, a `v1.2`
|
|
5
|
+
* prefix, an `1.2.3-rc1` suffix) only need a stable "is A behind B?" ordering.
|
|
6
|
+
*
|
|
7
|
+
* Rules:
|
|
8
|
+
* - leading `v`/`graphify ` style noise is stripped, numeric dotted core is
|
|
9
|
+
* compared field by field (missing fields treated as 0, so `1.2` < `1.2.1`),
|
|
10
|
+
* - any non-numeric suffix (`-rc1`, `+build`) is ignored for ordering — a
|
|
11
|
+
* pre-release is treated as equal to its release core, which is acceptable
|
|
12
|
+
* for a "should I upgrade?" hint (we never auto-act on a tie),
|
|
13
|
+
* - unparseable input yields `null` from `parse`, and `cmpVersions` returns
|
|
14
|
+
* `0` (treat as equal → no nag) when either side is unparseable.
|
|
15
|
+
*/
|
|
16
|
+
function parse(v) {
|
|
17
|
+
if (v == null) return null;
|
|
18
|
+
const m = String(v).match(/(\d+(?:\.\d+)*)/);
|
|
19
|
+
if (!m) return null;
|
|
20
|
+
return m[1].split('.').map((n) => parseInt(n, 10));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** -1 if a<b, 1 if a>b, 0 if equal/unknown. Never throws. */
|
|
24
|
+
function cmpVersions(a, b) {
|
|
25
|
+
const pa = parse(a);
|
|
26
|
+
const pb = parse(b);
|
|
27
|
+
if (!pa || !pb) return 0;
|
|
28
|
+
const len = Math.max(pa.length, pb.length);
|
|
29
|
+
for (let i = 0; i < len; i++) {
|
|
30
|
+
const x = pa[i] || 0;
|
|
31
|
+
const y = pb[i] || 0;
|
|
32
|
+
if (x < y) return -1;
|
|
33
|
+
if (x > y) return 1;
|
|
34
|
+
}
|
|
35
|
+
return 0;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
module.exports = { parse, cmpVersions };
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
const { execSync } = require('child_process');
|
|
2
|
+
const { cmpVersions } = require('./semver-lite');
|
|
3
|
+
const GraphifyInstaller = require('./graphify-installer');
|
|
4
|
+
const { REGISTRY } = require('./lsp-adapters');
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* External-tool currency layer (since v4.38.0).
|
|
8
|
+
*
|
|
9
|
+
* BALDART installs external tools into consumer machines but pins NONE of them
|
|
10
|
+
* (`pipx install graphifyy`, `npm install -g typescript-language-server`, …
|
|
11
|
+
* all grab "latest" at install time). pipx/npm never auto-upgrade, so a
|
|
12
|
+
* consumer that installed months ago is frozen — security/correctness fixes
|
|
13
|
+
* never reach them. The `add`/`update`/`configure` flows can't help: they run
|
|
14
|
+
* at install time, not continuously.
|
|
15
|
+
*
|
|
16
|
+
* This module is the missing continuous check. `baldart doctor` probes the
|
|
17
|
+
* tools it manages against their upstream registries and surfaces a
|
|
18
|
+
* non-blocking "X is N versions behind — upgrade with: …" action. It is the
|
|
19
|
+
* tool-dependency analogue of `UpdateNotifier` (which already does this for the
|
|
20
|
+
* `baldart` npm package itself).
|
|
21
|
+
*
|
|
22
|
+
* Invariants (mirroring the rest of doctor's probes):
|
|
23
|
+
* - BEST-EFFORT: every probe resolves to a record or is omitted; a failed
|
|
24
|
+
* probe never throws and never blocks doctor.
|
|
25
|
+
* - HONEST: `status` is `'outdated'` only when BOTH installed and latest are
|
|
26
|
+
* known and installed < latest. When latest can't be fetched (offline,
|
|
27
|
+
* registry down, system package manager we can't query) the record is
|
|
28
|
+
* `'unknown'` — we never claim up-to-date we can't prove, and never nag.
|
|
29
|
+
* - OFFLINE-AWARE: the caller skips `probeAll` entirely under `--offline`.
|
|
30
|
+
*
|
|
31
|
+
* Record shape:
|
|
32
|
+
* { tool, label, installed, latest, status: 'outdated'|'current'|'unknown',
|
|
33
|
+
* upgradeCommand, autoUpgradable, source, upgrade? }
|
|
34
|
+
* `upgrade` (when present) is a thunk doctor can run to perform the upgrade.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
/** Run a command and pull the first dotted-numeric version token from stdout. */
|
|
38
|
+
function _probeBinaryVersion(cmd, timeoutMs = 8000) {
|
|
39
|
+
try {
|
|
40
|
+
const out = execSync(cmd, { stdio: ['ignore', 'pipe', 'pipe'], timeout: timeoutMs }).toString();
|
|
41
|
+
const m = out.match(/(\d+\.\d+(?:\.\d+)?)/);
|
|
42
|
+
return m ? m[1] : null;
|
|
43
|
+
} catch { return null; }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** `npm view <pkg> version` → latest published version, or null. Network. */
|
|
47
|
+
function _npmLatest(pkg, timeoutMs = 8000) {
|
|
48
|
+
try {
|
|
49
|
+
return execSync(`npm view ${pkg} version`, { stdio: ['ignore', 'pipe', 'ignore'], timeout: timeoutMs })
|
|
50
|
+
.toString().trim() || null;
|
|
51
|
+
} catch { return null; }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Build the Graphify currency record (only when the layer is on + present). */
|
|
55
|
+
async function _graphifyRecord(cwd, config, timeoutMs) {
|
|
56
|
+
if (!(config && config.features && config.features.has_code_graph === true)) return null;
|
|
57
|
+
const gi = new GraphifyInstaller(cwd);
|
|
58
|
+
if (!gi.detect()) return null; // a missing binary is the install action's job, not currency
|
|
59
|
+
const r = await gi.checkUpgrade({ timeoutMs });
|
|
60
|
+
return {
|
|
61
|
+
tool: 'graphify',
|
|
62
|
+
label: 'Graphify (graphifyy)',
|
|
63
|
+
installed: r.installed,
|
|
64
|
+
latest: r.latest,
|
|
65
|
+
status: r.outdated ? 'outdated' : (r.installed && r.latest ? 'current' : 'unknown'),
|
|
66
|
+
upgradeCommand: 'pipx upgrade graphifyy',
|
|
67
|
+
autoUpgradable: true,
|
|
68
|
+
source: 'pypi:graphifyy',
|
|
69
|
+
upgrade: () => gi.upgrade({ spinner: false }),
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Build LSP language-server currency records. Only `npm-global` adapters can
|
|
75
|
+
* be queried generically (npm registry + `<binary> --version`). `system`-mode
|
|
76
|
+
* servers (gopls, rust-analyzer) live under per-language toolchains we don't
|
|
77
|
+
* version-probe — we emit an `'unknown'` record carrying the canonical
|
|
78
|
+
* `@latest` reinstall command so the user can refresh on their terms.
|
|
79
|
+
*/
|
|
80
|
+
function _lspRecords(cwd, config, lspInstalled, timeoutMs) {
|
|
81
|
+
if (!(config && config.features && config.features.has_lsp_layer === true)) return [];
|
|
82
|
+
const names = Array.isArray(lspInstalled) ? lspInstalled : [];
|
|
83
|
+
const records = [];
|
|
84
|
+
for (const name of names) {
|
|
85
|
+
const Cls = REGISTRY[name];
|
|
86
|
+
if (!Cls) continue;
|
|
87
|
+
let adapter;
|
|
88
|
+
try { adapter = new Cls(cwd); } catch { continue; }
|
|
89
|
+
const installed = _probeBinaryVersion(adapter.verifyCommand(), timeoutMs);
|
|
90
|
+
if (!installed) continue; // broken/absent server is the LSP-repair action's job
|
|
91
|
+
if (adapter.installMode === 'npm-global') {
|
|
92
|
+
// First whitespace-separated token of npmPackage is the versioned one.
|
|
93
|
+
const pkg = String(adapter.npmPackage || adapter.binary).split(/\s+/)[0];
|
|
94
|
+
const latest = _npmLatest(pkg, timeoutMs);
|
|
95
|
+
const outdated = !!(latest && cmpVersions(installed, latest) < 0);
|
|
96
|
+
records.push({
|
|
97
|
+
tool: `lsp:${name}`,
|
|
98
|
+
label: `${adapter.label} LSP (${pkg})`,
|
|
99
|
+
installed,
|
|
100
|
+
latest,
|
|
101
|
+
status: outdated ? 'outdated' : (latest ? 'current' : 'unknown'),
|
|
102
|
+
upgradeCommand: adapter.installCommand(), // already pins @latest
|
|
103
|
+
autoUpgradable: true,
|
|
104
|
+
source: `npm:${pkg}`,
|
|
105
|
+
upgrade: () => {
|
|
106
|
+
try { execSync(adapter.installCommand(), { stdio: 'pipe', cwd, timeout: 300000 }); return { ok: true, method: 'npm -g' }; }
|
|
107
|
+
catch (e) { return { ok: false, error: (e.message || '').split('\n')[0] }; }
|
|
108
|
+
},
|
|
109
|
+
});
|
|
110
|
+
} else {
|
|
111
|
+
records.push({
|
|
112
|
+
tool: `lsp:${name}`,
|
|
113
|
+
label: `${adapter.label} LSP (${adapter.binary})`,
|
|
114
|
+
installed,
|
|
115
|
+
latest: null,
|
|
116
|
+
status: 'unknown', // system toolchain — can't cheaply ask "latest"
|
|
117
|
+
upgradeCommand: adapter.installCommand(), // e.g. `... @latest`
|
|
118
|
+
autoUpgradable: false,
|
|
119
|
+
source: 'system',
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return records;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Probe every external tool BALDART manages for version currency.
|
|
128
|
+
* Returns an array of records (possibly empty). Async (network). Never throws.
|
|
129
|
+
* The caller is responsible for skipping this under `--offline`.
|
|
130
|
+
*/
|
|
131
|
+
async function probeAll({ cwd = process.cwd(), config = null, lspInstalled = [], timeoutMs = 6000 } = {}) {
|
|
132
|
+
const records = [];
|
|
133
|
+
try {
|
|
134
|
+
const g = await _graphifyRecord(cwd, config, timeoutMs);
|
|
135
|
+
if (g) records.push(g);
|
|
136
|
+
} catch { /* best-effort */ }
|
|
137
|
+
try {
|
|
138
|
+
records.push(..._lspRecords(cwd, config, lspInstalled, timeoutMs));
|
|
139
|
+
} catch { /* best-effort */ }
|
|
140
|
+
return records;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Convenience: only the records that are confirmed behind upstream. */
|
|
144
|
+
function outdated(records) {
|
|
145
|
+
return (records || []).filter((r) => r.status === 'outdated');
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
module.exports = { probeAll, outdated, _probeBinaryVersion, _npmLatest };
|