baldart 4.37.0 → 4.39.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 +24 -0
- package/README.md +8 -0
- package/VERSION +1 -1
- package/bin/baldart.js +10 -0
- package/framework/.claude/skills/new/references/merge-cleanup.md +15 -0
- package/framework/.claude/skills/new2/SKILL.md +11 -0
- package/framework/.claude/skills/prd/references/validation-phase.md +14 -0
- package/package.json +1 -1
- package/src/commands/doctor.js +44 -0
- package/src/commands/reap-orphans.js +90 -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,30 @@ 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.39.0] - 2026-06-15
|
|
9
|
+
|
|
10
|
+
**`/new`, `/new2`, and `/prd` now auto-reap orphaned Codex MCP servers at their workspace-hygiene finalizers — the v4.37.0 doctor reaper, made automatic.** v4.37.0 added an on-demand reaper to `baldart doctor`, but the leak compounds *per skill run*: every batch's Codex finder calls (`/new`/`new2` per-card review + final review, `/prd` discovery-completeness + plan audit) drive `codex app-server`, whose detached broker spawns the `~/.codex/config.toml` MCP servers (Playwright, …) as children that orphan to init (ppid 1) when the broker dies and keep burning CPU. Waiting for a manual `baldart doctor` let them accumulate between runs. Now each batch ends by sweeping them. A new focused, non-interactive CLI command (`baldart reap-orphans`) is the SSOT the three finalizers call; it shares the v4.37.0 `codex-orphans.js` detection/reaping logic and the same hard safety invariant — it reaps ONLY orphaned MCP servers (ppid 1 ⇒ broker dead ⇒ stdio broken), and NEVER kills a live `codex app-server` broker (a shared, detached runtime that may still serve the user's interactive session). Because an MCP child of a still-warm broker is not yet orphaned, this is a cumulative orphan sweep (catches this run's debris once its broker dies, plus any prior runs'), not a per-run broker teardown. **MINOR** (new CLI command + skill-finalizer wiring; backwards-compatible — non-blocking hygiene step, no-op when nothing is orphaned, no install/layout change, no `baldart.config.yml` key ⇒ schema-propagation rule N/A).
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- **`src/commands/reap-orphans.js`** + **`bin/baldart.js`** — new `baldart reap-orphans` command: detects orphaned MCP servers (ppid 1 + MCP signature) and reaps each process tree via syscall, then prints a one-line summary. `--dry-run` reports without killing; `--json` emits a machine-readable result (`schema:"baldart.reap-orphans/1"`). Always exits 0 (hygiene, never a blocker). Reuses `src/utils/codex-orphans.js` (the v4.37.0 SSOT); live `codex app-server` brokers are detected and reported but never killed.
|
|
15
|
+
|
|
16
|
+
### Changed
|
|
17
|
+
|
|
18
|
+
- **`framework/.claude/skills/new/references/merge-cleanup.md`** (Phase 6c, new step 5b), **`framework/.claude/skills/prd/references/validation-phase.md`** (Step 7.5, new non-blocking closer), **`framework/.claude/skills/new2/SKILL.md`** (Step 5, new item 6) — each workspace-hygiene finalizer now runs `npx baldart reap-orphans` as a NON-BLOCKING step and folds its summary into the phase log. `new2` runs it in the main context after the workflow returns (the workflow sandbox cannot run Bash). All three carry the explicit "reaps orphans only, never the broker" note so a future maintainer does not escalate it into a broker kill.
|
|
19
|
+
|
|
20
|
+
## [4.38.0] - 2026-06-15
|
|
21
|
+
|
|
22
|
+
**`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).
|
|
23
|
+
|
|
24
|
+
### Added
|
|
25
|
+
|
|
26
|
+
- **`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`).
|
|
27
|
+
- **`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.
|
|
28
|
+
- **`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`.
|
|
29
|
+
- **`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).
|
|
30
|
+
- **`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.
|
|
31
|
+
|
|
8
32
|
## [4.37.0] - 2026-06-15
|
|
9
33
|
|
|
10
34
|
**`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).
|
package/README.md
CHANGED
|
@@ -508,6 +508,14 @@ MCP servers from `~/.codex/config.toml` (Playwright, …) and leaks them to init
|
|
|
508
508
|
orphaned MCP servers (and their browser children) directly via syscall; the live
|
|
509
509
|
`codex app-server` broker is never touched.
|
|
510
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.
|
|
518
|
+
|
|
511
519
|
```bash
|
|
512
520
|
npx baldart # diagnostic + interactive prompts
|
|
513
521
|
npx baldart --auto # CI-friendly: skip y/n; error out on ambiguity
|
package/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
4.
|
|
1
|
+
4.39.0
|
package/bin/baldart.js
CHANGED
|
@@ -154,6 +154,16 @@ program
|
|
|
154
154
|
await doctorCommand({ auto: !!options.auto, offline: !!options.offline });
|
|
155
155
|
});
|
|
156
156
|
|
|
157
|
+
program
|
|
158
|
+
.command('reap-orphans')
|
|
159
|
+
.description('Sweep orphaned MCP-server processes left by Codex calls (ppid 1 — their broker is dead). Used by the /new and /prd finalizers; safe to run anytime. Never touches a live codex app-server broker.')
|
|
160
|
+
.option('--json', 'Machine-readable output: emit a single JSON result object on stdout')
|
|
161
|
+
.option('--dry-run', 'Detect and report orphans without killing anything')
|
|
162
|
+
.action(async (options) => {
|
|
163
|
+
const reapCommand = require('../src/commands/reap-orphans');
|
|
164
|
+
await reapCommand({ json: !!options.json, dryRun: !!options.dryRun });
|
|
165
|
+
});
|
|
166
|
+
|
|
157
167
|
const overlayGroup = program
|
|
158
168
|
.command('overlay')
|
|
159
169
|
.description('Author and check .baldart/overlays/ — scaffolds, validates, and detects drift on skill/agent/command overlays');
|
|
@@ -177,6 +177,20 @@ The most common failure mode is leaving cards IN_PROGRESS after merge. This crea
|
|
|
177
177
|
- Question: `"Restore dello stash di Phase 0 ha generato conflitti. Lo stash è ancora presente (NON eliminato). Come procedo?"`
|
|
178
178
|
- Options: `[Lascia lo stash + apri istruzioni per merge manuale]` / `[Mostrami il conflitto inline]` / `[Halt]`.
|
|
179
179
|
|
|
180
|
+
5b. **Process hygiene — reap orphaned Codex MCP servers (NON-BLOCKING)**. This
|
|
181
|
+
batch's per-card / final-review Codex calls drive `codex app-server`, whose
|
|
182
|
+
broker spawns the MCP servers declared in `~/.codex/config.toml` (Playwright,
|
|
183
|
+
…) as children; when a broker dies the OS reparents those MCP servers to init
|
|
184
|
+
(ppid 1) and they keep burning CPU. Sweep them now so the batch ends clean:
|
|
185
|
+
```bash
|
|
186
|
+
npx baldart reap-orphans 2>/dev/null || true
|
|
187
|
+
```
|
|
188
|
+
This reaps ONLY orphaned MCP servers (ppid 1 ⇒ their broker is already dead ⇒
|
|
189
|
+
stdio is broken ⇒ dead weight). It deliberately NEVER kills a live
|
|
190
|
+
`codex app-server` broker (a shared, detached runtime that may still serve the
|
|
191
|
+
user's interactive session). Never gate the close on this — any error or a
|
|
192
|
+
"nothing to reap" result is fine; capture its one-line summary for the log.
|
|
193
|
+
|
|
180
194
|
6. **Log and exit**:
|
|
181
195
|
```
|
|
182
196
|
## Phase 6c — Workspace Hygiene Post-merge
|
|
@@ -185,6 +199,7 @@ The most common failure mode is leaving cards IN_PROGRESS after merge. This crea
|
|
|
185
199
|
Divergence (local…origin/$TRUNK): <0\t0 | resolved: pushed/cherry-picked/ff-pulled/rebased>
|
|
186
200
|
Sync-deferred markers: <none | reconciled | user-retained>
|
|
187
201
|
Phase 0 snapshot restore: <n/a | popped clean | conflict-deferred-to-user>
|
|
202
|
+
Codex MCP hygiene: <reaped N/M | nothing to reap | skipped (error)>
|
|
188
203
|
Completed: <timestamp>
|
|
189
204
|
```
|
|
190
205
|
If any step ended in HALT, set `Status: HALT` and report — Phase 7 must NOT start with an unclean main repo unless the user explicitly chose `[Lascia così]`.
|
|
@@ -265,3 +265,14 @@ returns when the batch is done. It returns:
|
|
|
265
265
|
deferrals resolving too late — order the dependent card earlier), and `owner_gated_deduped` > 0
|
|
266
266
|
means N defers were collapsed to one external action.
|
|
267
267
|
Do NOT re-summarise the cards — the workflow already did.
|
|
268
|
+
6. **Process hygiene — reap orphaned Codex MCP servers (NON-BLOCKING).** The batch's per-card Codex
|
|
269
|
+
finder calls drive `codex app-server`, whose broker spawns the `~/.codex/config.toml` MCP servers
|
|
270
|
+
(Playwright, …) as children; when a broker dies they leak to init (ppid 1) and keep burning CPU.
|
|
271
|
+
Sweep them in the main context (the workflow sandbox cannot run Bash, so this MUST run here, after
|
|
272
|
+
the workflow returns) so the run ends clean:
|
|
273
|
+
```bash
|
|
274
|
+
npx baldart reap-orphans 2>/dev/null || true
|
|
275
|
+
```
|
|
276
|
+
Reaps ONLY orphaned MCP servers (ppid 1 ⇒ broker dead); NEVER kills a live `codex app-server`
|
|
277
|
+
broker. Non-blocking — any error / "nothing to reap" is fine; fold its one-line summary into the
|
|
278
|
+
record (`codex_mcp_reaped`).
|
|
@@ -247,6 +247,20 @@ markers it can emit, then act:
|
|
|
247
247
|
empty, no `[SYNC-NEEDS-DECISION]` marker is left unhandled, and the merged remote
|
|
248
248
|
branch is gone (or its deletion is explicitly user-deferred).
|
|
249
249
|
|
|
250
|
+
**Process hygiene — reap orphaned Codex MCP servers (NON-BLOCKING).** This run's
|
|
251
|
+
Codex calls (discovery-completeness check, plan audit) drive `codex app-server`,
|
|
252
|
+
whose broker spawns the MCP servers from `~/.codex/config.toml` (Playwright, …)
|
|
253
|
+
as children; when a broker dies the OS reparents them to init (ppid 1) and they
|
|
254
|
+
keep burning CPU. Sweep them now so the run ends clean:
|
|
255
|
+
```bash
|
|
256
|
+
npx baldart reap-orphans 2>/dev/null || true
|
|
257
|
+
```
|
|
258
|
+
This reaps ONLY orphaned MCP servers (ppid 1 ⇒ broker dead ⇒ stdio broken ⇒ dead
|
|
259
|
+
weight); it NEVER kills a live `codex app-server` broker (a shared, detached
|
|
260
|
+
runtime that may still serve the user's interactive session). This is NOT part
|
|
261
|
+
of the blocking gate — any error or "nothing to reap" is fine; include its
|
|
262
|
+
one-line summary in the final summary's hygiene line.
|
|
263
|
+
|
|
250
264
|
### Step 7.6 — Obsidian back-reference (NON-BLOCKING — runs only when a spec note was given)
|
|
251
265
|
|
|
252
266
|
**Why this exists.** When the user kicked off the PRD from an Obsidian note (state file
|
package/package.json
CHANGED
package/src/commands/doctor.js
CHANGED
|
@@ -33,6 +33,7 @@ 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');
|
|
36
37
|
const CodexOrphans = require('../utils/codex-orphans');
|
|
37
38
|
const UpdateNotifier = require('../utils/update-notifier');
|
|
38
39
|
const cliPackageJson = require('../../package.json');
|
|
@@ -390,6 +391,24 @@ async function detectState(cwd, opts = {}) {
|
|
|
390
391
|
}
|
|
391
392
|
} catch (_) { /* never block doctor on graph probe */ }
|
|
392
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
|
+
|
|
393
412
|
// ---- Orphaned MCP servers from Codex calls (since v4.37.0) ---------
|
|
394
413
|
// BALDART's Codex finder calls (/new, new2, /codexreview, cron engine)
|
|
395
414
|
// drive `codex app-server` via the companion plugin. That broker spawns the
|
|
@@ -799,6 +818,31 @@ function planActions(state) {
|
|
|
799
818
|
});
|
|
800
819
|
}
|
|
801
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
|
+
|
|
802
846
|
// Orphaned MCP servers from Codex calls (since v4.37.0). BALDART's Codex
|
|
803
847
|
// finder calls leave behind MCP-server processes (Playwright, obsidian-mcp, …)
|
|
804
848
|
// reparented to init when their `codex app-server` broker dies. They keep
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `baldart reap-orphans` — sweep orphaned MCP-server processes left by Codex
|
|
3
|
+
* calls (since v4.38.0).
|
|
4
|
+
*
|
|
5
|
+
* Non-interactive, focused companion to the `baldart doctor` reap action: it
|
|
6
|
+
* detects MCP servers that have been orphaned to init (ppid 1 — their parent
|
|
7
|
+
* `codex app-server` broker is dead) and kills each process tree. Designed to be
|
|
8
|
+
* called from the `/new` (Phase 6c) and `/prd` (Step 7.5) workspace-hygiene
|
|
9
|
+
* finalizers so each batch ends by clearing the MCP debris its Codex finder
|
|
10
|
+
* calls (and any prior runs) left behind.
|
|
11
|
+
*
|
|
12
|
+
* SCOPE & SAFETY (do not "improve" this into killing the broker):
|
|
13
|
+
* - Reaps ONLY orphaned MCP servers (ppid 1 + MCP signature). A live, in-use
|
|
14
|
+
* `codex app-server` broker is `detached + unref'd` BY DESIGN and also shows
|
|
15
|
+
* ppid 1, so we never touch brokers — killing one could break the user's
|
|
16
|
+
* concurrent interactive Codex session. Brokers are reported, never killed.
|
|
17
|
+
* - Because MCP children of a *still-alive* broker have ppid = broker (not 1),
|
|
18
|
+
* a run whose broker is still warm may leave its own MCP non-orphaned at
|
|
19
|
+
* finalizer time; those get swept by the next run's finalizer (or `doctor`).
|
|
20
|
+
* This command is a cumulative orphan sweep, not a per-run broker teardown.
|
|
21
|
+
*
|
|
22
|
+
* Always exits 0 — this is hygiene, never a blocker. The SSOT for detection /
|
|
23
|
+
* reaping logic is `src/utils/codex-orphans.js`; this command only frames it.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
const UI = require('../utils/ui');
|
|
27
|
+
const CodexOrphans = require('../utils/codex-orphans');
|
|
28
|
+
|
|
29
|
+
async function reapOrphans(opts = {}) {
|
|
30
|
+
const json = !!opts.json;
|
|
31
|
+
const dryRun = !!opts.dryRun;
|
|
32
|
+
|
|
33
|
+
const result = {
|
|
34
|
+
schema: 'baldart.reap-orphans/1',
|
|
35
|
+
found: 0,
|
|
36
|
+
reaped: 0,
|
|
37
|
+
failed: 0,
|
|
38
|
+
runtimeBrokers: 0,
|
|
39
|
+
dryRun,
|
|
40
|
+
orphans: [],
|
|
41
|
+
failures: [],
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
try {
|
|
45
|
+
const procs = CodexOrphans.listProcesses();
|
|
46
|
+
const { mcp, runtime } = CodexOrphans.detectOrphans(procs);
|
|
47
|
+
result.found = mcp.length;
|
|
48
|
+
result.runtimeBrokers = runtime.length;
|
|
49
|
+
result.orphans = mcp.map((p) => ({ pid: p.pid, etime: p.etime, command: p.command }));
|
|
50
|
+
|
|
51
|
+
if (!dryRun && mcp.length > 0) {
|
|
52
|
+
const { killed, failed } = CodexOrphans.reapOrphans(mcp, procs);
|
|
53
|
+
result.reaped = killed.length;
|
|
54
|
+
result.failed = failed.length;
|
|
55
|
+
result.failures = failed;
|
|
56
|
+
}
|
|
57
|
+
} catch (err) {
|
|
58
|
+
result.error = (err && err.message) || String(err);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (json) {
|
|
62
|
+
process.stdout.write(JSON.stringify(result) + '\n');
|
|
63
|
+
return result;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Human output — single concise summary line + optional detail.
|
|
67
|
+
if (result.error) {
|
|
68
|
+
UI.warning(`Codex MCP reap skipped (probe error: ${result.error}).`);
|
|
69
|
+
} else if (result.found === 0) {
|
|
70
|
+
UI.success('Codex MCP hygiene: no orphaned MCP servers — nothing to reap.');
|
|
71
|
+
} else if (dryRun) {
|
|
72
|
+
UI.warning(`Codex MCP hygiene: ${result.found} orphaned MCP server(s) found (dry-run, not killed):`);
|
|
73
|
+
result.orphans.slice(0, 8).forEach((o) =>
|
|
74
|
+
console.log(` • pid ${o.pid} (up ${o.etime}): ${o.command.slice(0, 70)}`));
|
|
75
|
+
if (result.orphans.length > 8) console.log(` • … and ${result.orphans.length - 8} more`);
|
|
76
|
+
} else {
|
|
77
|
+
UI.success(`Codex MCP hygiene: reaped ${result.reaped}/${result.found} orphaned MCP server(s) (incl. descendants).`);
|
|
78
|
+
if (result.failed > 0) {
|
|
79
|
+
UI.warning(`${result.failed} could not be killed:`);
|
|
80
|
+
result.failures.forEach((f) => console.log(` pid ${f.pid}: ${f.error}`));
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
if (result.runtimeBrokers > 0) {
|
|
84
|
+
UI.info(`(${result.runtimeBrokers} codex app-server broker(s) detected — left untouched by design.)`);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return result;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
module.exports = reapOrphans;
|
|
@@ -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 };
|