@mindrian_os/install 1.13.0-beta.22 → 1.13.0-beta.24

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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "mos",
3
3
  "description": "MindrianOS -- Your AI innovation co-founder. Larry thinks with you through PWS methodology, builds your Data Room as you explore, and chains frameworks intelligently. Install and go.",
4
- "version": "1.13.0-beta.22",
4
+ "version": "1.13.0-beta.24",
5
5
  "author": {
6
6
  "name": "Jonathan Sagir",
7
7
  "url": "https://mindrian.ai"
package/CHANGELOG.md CHANGED
@@ -1,3 +1,26 @@
1
+ ## [1.13.0-beta.24] - 2026-05-22
2
+
3
+ ### Fixed
4
+ - **Brain unreachable on the first session after a plugin update -- now fixed on all three platforms.** `claude plugin update` lands a fresh plugin cache directory with no `node_modules`. Both bundled MCP servers (`mindrian-brain` + `mindrian-os`, both `alwaysLoad`) then crashed at module load (`Cannot find module '@modelcontextprotocol/sdk/server/mcp.js'`), the Brain was unreachable, and `/reload-plugins` reported a load error. On Windows the gap was *permanent* -- the repair never ran (see the portable self-heal note below). Root cause: a startup-order race plus a cross-platform defect in the repair (debug session `mcp-servers-cache-missing-node-modules`). The fix below makes Brain connectivity true by construction on Windows, Mac, and Linux.
5
+
6
+ ### Changed
7
+ - **Vendored production dependencies (the guarantee).** The plugin now ships its production `node_modules` with the released marketplace artifact, so the Brain shim's dependencies are present the instant the install cache lands -- no runtime install, no network, no startup race. Every production dependency was audited and confirmed pure-JavaScript (zero native/compiled binaries: no `.node` addons, no `binding.gyp`, no prebuilt platform binaries, no install lifecycle scripts), so the same vendored tree is correct on Windows, Mac, and Linux by construction. The vendored tree is built fresh from `package-lock.json` via `npm ci --omit=dev` during the release (`scripts/release.sh` Step 6.7), staged onto the tagged release commit only -- `main` HEAD stays clean. This is a new release lockstep surface (see `.claude/includes/release-process.md`); it can never drift from the lockfile.
8
+ - **Portable cross-platform self-heal (the backstop).** The runtime self-heal that repairs an incomplete cache is now cross-platform. The prior implementation ran a bare `spawnSync('npm', ...)`, which was *dead on Windows* (`npm` is `npm.cmd`, a batch file -- bare `spawnSync` returns `ENOENT`) and *fragile on Mac* (a GUI-launched Claude Code gives child processes a minimal `PATH` that often excludes the nvm / Homebrew bin directory where `npm` lives). The new `lib/core/npm-cli-resolve.cjs` resolves `npm` to its absolute `npm-cli.js` entry point off `process.execPath` -- npm ships in the same distribution as the running `node` binary -- and runs it as `node <abs npm-cli.js> install`. This sidesteps `PATH`, the `.cmd` extension, and `shell:true` entirely. Applied to both spawn sites (`lib/core/mcp-dep-heal.cjs` and `scripts/sessionstart-npm-reconcile.cjs`).
9
+ - **Hybrid self-heal for the plugin cache (carried from the prior staging of this beta).** The vendored tree is the primary guarantee; the self-heal is the backstop for a somehow-incomplete cache. Three coordinated changes still close the race from both ends:
10
+ 1. The `sessionstart-npm-reconcile.cjs` hook is `async: false` and ordered FIRST in the `SessionStart` chain, so any needed dependency install completes before Claude Code reads `.mcp.json` and spawns the MCP servers.
11
+ 2. Both MCP entry points (`bin/mindrian-brain-mcp-client.cjs`, `bin/mindrian-mcp-server.cjs`) self-heal: a missing dependency triggers a one-shot guarded `npm install` in the plugin cache root, then re-requires. On a normal install (vendored deps present) this is a cheap `stat()` pre-flight that spawns nothing.
12
+ 3. A lockfile guard (`lib/core/npm-install-lock.cjs`) ensures that when both servers spawn together, exactly one runs `npm install` while the other blocks and waits -- two concurrent installs can never corrupt `node_modules`.
13
+ - New modules: `lib/core/mcp-dep-heal.cjs` (`ensureDepsPresent` + `requireWithHeal`) and `lib/core/npm-cli-resolve.cjs` (portable npm resolution). Zero network surface -- pure node built-ins plus a single guarded `npm install` child process (Canon Part 8). Mirrors the existing reconcile-hook detection logic rather than inventing a new mechanism (Canon Part 7).
14
+ - `package-lock.json` resynced with `package.json` (it was 13 betas stale; `npm ci` could not run against it). No runtime dependency versions changed -- a metadata-only catch-up.
15
+
16
+ ### Fixed (dependency hygiene, surfaced by the vendoring audit)
17
+ - **`/mos:doctor` would crash with `Cannot find module 'semver'` on a production-only install.** `scripts/doctor.cjs` -- a user-facing runtime script invoked by `/mos:doctor` -- requires `semver` for version-ordering (`semver.compare`), but `semver` was declared as a `devDependency`. A full audit of every `require()` of a declared dependency across all shipped code paths confirmed `semver` was the only misclassification. Moved to `dependencies` so it is present on every install (and in the vendored tree). `devDependencies` is now empty.
18
+
19
+ ### Fixed (lockfile + probe correctness, surfaced by a remote code review of the self-heal backstop)
20
+ - **Concurrent-install corruption from a non-atomic lock (`bug_004`).** `lib/core/npm-install-lock.cjs` created its lock with `openSync('wx')` and then populated it with a *separate* `writeSync`. The create is atomic, but the file existed empty between the two syscalls -- a racing peer that hit `EEXIST` then read the empty file, `JSON.parse('')` threw, the lock was misclassified as corrupt, the winner's live lock was unlinked, and both servers ran `npm install` at once. Lock creation is now atomic via `fs.linkSync` (the payload is written to a private temp file in full, then atomically linked into place), so a winner's lock is always observed fully-written. As defence-in-depth `readLock` now distinguishes a transient empty mid-write file (sentinel `'EMPTY'` -- caller retries) from genuinely corrupt non-empty JSON (`null` -- safe to clear), and both `acquireInstallLock` and `waitForUnlock` treat an empty file as transient instead of as a cleared/dead lock.
21
+ - **False-stale reclaim of a healthy long install (`bug_001`).** The lock's `STALE_THRESHOLD_MS` was 90s, but `runGuardedInstall` gives `npm install` a 120s timeout -- a healthy install legitimately running 90-120s was declared abandoned, and because the staleness check used OR (`age > STALE || !pidAlive`) a peer reclaimed the *live* lock and started a second concurrent install. The threshold is raised to 180s (strictly above the 120s install timeout, 60s headroom) and the staleness check is now an AND-gate: a lock is reclaimed only when it is BOTH older than the threshold AND its owning pid is dead. `WAIT_TIMEOUT_MS` raised to 200s to stay above the new stale threshold.
22
+ - **Dependency probe too narrow (`bug_011`).** `ensureDepsPresent` probed only `['@modelcontextprotocol/sdk', 'zod']`. A partially-populated `node_modules` (those two present, `@modelcontextprotocol/ext-apps` or another production dep absent) passed the probe, no heal ran, and a bare `require` deeper in the `lib/mcp/*` chain then threw `MODULE_NOT_FOUND` at module-init scope and crashed the server. The probe now defaults to the FULL production dependency set read from the plugin's `package.json` (`Object.keys(pkg.dependencies)`), matching what `scripts/sessionstart-npm-reconcile.cjs` already does; a missing or unreadable `package.json` falls back to the MCP-critical pair rather than crashing. New regression suites: `lib/core/npm-install-lock.test.cjs` (18 tests) and `lib/core/mcp-dep-heal.test.cjs` (9 tests).
23
+
1
24
  ## [1.13.0-beta.22] - 2026-05-21
2
25
 
3
26
  ### Documentation
@@ -24,9 +24,22 @@
24
24
  */
25
25
 
26
26
  const path = require('path');
27
- const { McpServer } = require('@modelcontextprotocol/sdk/server/mcp.js');
28
- const { StdioServerTransport } = require('@modelcontextprotocol/sdk/server/stdio.js');
29
- const { z } = require('zod');
27
+
28
+ // -- Dependency self-heal (Option D, debug session
29
+ // mcp-servers-cache-missing-node-modules). `claude plugin update` can land a
30
+ // fresh plugin cache with NO node_modules; on the first post-update session
31
+ // this MCP server may spawn before the SessionStart reconcile hook finishes its
32
+ // npm install. mcp-dep-heal.cjs + npm-install-lock.cjs are pure node-built-in
33
+ // modules (safe to require with node_modules absent). ensureDepsPresent runs a
34
+ // guarded one-shot `npm install` if node_modules is missing/incomplete BEFORE
35
+ // the SDK/zod requires below; requireWithHeal is the per-require backstop.
36
+ const { ensureDepsPresent, requireWithHeal } = require('../lib/core/mcp-dep-heal.cjs');
37
+ const healLog = (msg) => { try { process.stderr.write(msg + '\n'); } catch (e) { /* swallow */ } };
38
+ ensureDepsPresent({ log: healLog });
39
+
40
+ const { McpServer } = requireWithHeal('@modelcontextprotocol/sdk/server/mcp.js', { log: healLog });
41
+ const { StdioServerTransport } = requireWithHeal('@modelcontextprotocol/sdk/server/stdio.js', { log: healLog });
42
+ const { z } = requireWithHeal('zod', { log: healLog });
30
43
 
31
44
  const brainClient = require('../lib/core/brain-client.cjs');
32
45
  const { wrapDirective } = require('../lib/core/directive-envelope.cjs');
@@ -36,8 +36,23 @@
36
36
 
37
37
  const path = require('path');
38
38
  const fs = require('fs');
39
- const { McpServer } = require('@modelcontextprotocol/sdk/server/mcp.js');
40
- const { StdioServerTransport } = require('@modelcontextprotocol/sdk/server/stdio.js');
39
+
40
+ // -- Dependency self-heal (Option D, debug session
41
+ // mcp-servers-cache-missing-node-modules). `claude plugin update` can land a
42
+ // fresh plugin cache with NO node_modules; on the first post-update session
43
+ // this MCP server may spawn before the SessionStart reconcile hook finishes its
44
+ // npm install. mcp-dep-heal.cjs + npm-install-lock.cjs are pure node-built-in
45
+ // modules (safe to require with node_modules absent). ensureDepsPresent runs a
46
+ // guarded one-shot `npm install` if node_modules is missing/incomplete BEFORE
47
+ // any npm-dependency require below (the SDK direct requires AND the transitive
48
+ // requires inside the lib/mcp/* modules). requireWithHeal is the per-require
49
+ // backstop, including the lazy express / streamableHttp requires in main().
50
+ const { ensureDepsPresent, requireWithHeal } = require('../lib/core/mcp-dep-heal.cjs');
51
+ const healLog = (msg) => { try { process.stderr.write(msg + '\n'); } catch (e) { /* swallow */ } };
52
+ ensureDepsPresent({ log: healLog });
53
+
54
+ const { McpServer } = requireWithHeal('@modelcontextprotocol/sdk/server/mcp.js', { log: healLog });
55
+ const { StdioServerTransport } = requireWithHeal('@modelcontextprotocol/sdk/server/stdio.js', { log: healLog });
41
56
  const { detectSurface } = require('../lib/mcp/surface-detect.cjs');
42
57
  const { registerCapabilities } = require('../lib/mcp/capability-registry.cjs');
43
58
  const { computeCatchUp, registerShutdownHandler } = require('../lib/mcp/session-catchup.cjs');
@@ -80,7 +95,7 @@ registerRouterTools(server, roomDir, pluginRoot, larryContext);
80
95
  // claims and route filing through Phase 109 navigation.cjs
81
96
  // Both wrap pure lib/core entries; safe for Desktop/Cowork stdio transport.
82
97
  // -----------------------------------------------------------------------------
83
- const { z } = require('zod');
98
+ const { z } = requireWithHeal('zod', { log: healLog });
84
99
  const dualPathDetector = require('../lib/core/dual-path-detector.cjs');
85
100
  const shallowDocParser = require('../lib/core/shallow-doc-parser.cjs');
86
101
 
package/hooks/hooks.json CHANGED
@@ -6,10 +6,10 @@
6
6
  "hooks": [
7
7
  {
8
8
  "type": "command",
9
- "command": "\"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook.cmd\" session-start",
10
- "timeout": 10000,
9
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/sessionstart-npm-reconcile.cjs\"",
10
+ "timeout": 120000,
11
11
  "async": false,
12
- "statusMessage": "Loading room context..."
12
+ "statusMessage": "Reconciling dependencies..."
13
13
  }
14
14
  ]
15
15
  },
@@ -18,7 +18,7 @@
18
18
  "hooks": [
19
19
  {
20
20
  "type": "command",
21
- "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/sessionstart-coordinator.cjs\"",
21
+ "command": "\"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook.cmd\" session-start",
22
22
  "timeout": 10000,
23
23
  "async": false,
24
24
  "statusMessage": "Loading room context..."
@@ -30,10 +30,10 @@
30
30
  "hooks": [
31
31
  {
32
32
  "type": "command",
33
- "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/sessionstart-npm-reconcile.cjs\"",
34
- "timeout": 60000,
35
- "async": true,
36
- "statusMessage": "Reconciling dependencies..."
33
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/sessionstart-coordinator.cjs\"",
34
+ "timeout": 10000,
35
+ "async": false,
36
+ "statusMessage": "Loading room context..."
37
37
  }
38
38
  ]
39
39
  },
@@ -0,0 +1,246 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ /*
5
+ * Copyright (c) 2026 Mindrian. BSL 1.1.
6
+ *
7
+ * MindrianOS Plugin -- MCP dependency self-heal (Option D, hybrid self-heal).
8
+ *
9
+ * THE PROBLEM (debug session mcp-servers-cache-missing-node-modules):
10
+ * `claude plugin update` lands a fresh plugin cache directory with NO
11
+ * node_modules. The SessionStart reconcile hook (scripts/sessionstart-npm-
12
+ * reconcile.cjs) repairs it -- but on the FIRST post-update session Claude Code
13
+ * spawns the bundled MCP servers (.mcp.json, alwaysLoad) at a moment that can
14
+ * precede the hook's npm install finishing. The servers then crash at module
15
+ * load with MODULE_NOT_FOUND for @modelcontextprotocol/sdk.
16
+ *
17
+ * THE FIX (this module): make each MCP entry point self-sufficient. Each server
18
+ * calls `requireWithHeal(...)` instead of bare `require(...)`. On a
19
+ * MODULE_NOT_FOUND it runs a ONE-SHOT synchronous `npm install` in the plugin
20
+ * cache root, then re-requires. Combined with flipping the reconcile hook to
21
+ * synchronous (async:false) in hooks.json, this closes the race from both ends:
22
+ * - healthy session: requireWithHeal succeeds first try, near-zero cost.
23
+ * - first post-update session: the hook usually wins; if it has not, the
24
+ * server heals itself before connecting its transport.
25
+ *
26
+ * RACE GUARD: both servers can spawn together. npm-install-lock.cjs guarantees
27
+ * exactly one runs `npm install` while the other WAITS, so two concurrent
28
+ * installs never corrupt node_modules.
29
+ *
30
+ * Canon Part 8: zero network surface. The only child process is `npm install`.
31
+ * No Brain calls, no external requests, no user data.
32
+ *
33
+ * Canon Part 7: reuse before build -- this mirrors the detection logic already
34
+ * in scripts/sessionstart-npm-reconcile.cjs (the hook) rather than inventing a
35
+ * new mechanism; it is the same `npm install --no-audit --no-fund --silent`
36
+ * invocation, wrapped for the require-time crash path.
37
+ *
38
+ * CROSS-PLATFORM (escalated mandate 2026-05-21): the npm invocation is resolved
39
+ * through lib/core/npm-cli-resolve.cjs, which runs npm via its absolute
40
+ * npm-cli.js entry off process.execPath -- correct on Windows (no `.cmd`
41
+ * dependency), Mac (no PATH dependency for GUI-launched Claude Code), and Linux.
42
+ * The bare spawnSync('npm') the prior fix used was dead on Windows and fragile
43
+ * on Mac. The self-heal here is the BACKSTOP; the primary guarantee is the
44
+ * vendored production node_modules shipped with the plugin (see CHANGELOG
45
+ * v1.13.0-beta.23) -- on a normal install ensureDepsPresent finds the deps
46
+ * already present and never spawns anything.
47
+ *
48
+ * HARD RULE: no em-dashes anywhere in this file (hyphens only).
49
+ */
50
+
51
+ const fs = require('node:fs');
52
+ const path = require('node:path');
53
+ const { spawnSync } = require('node:child_process');
54
+
55
+ const {
56
+ acquireInstallLock,
57
+ releaseInstallLock,
58
+ waitForUnlock,
59
+ } = require('./npm-install-lock.cjs');
60
+ const { resolveNpmCli, buildInstallArgs } = require('./npm-cli-resolve.cjs');
61
+
62
+ /**
63
+ * Resolve the plugin cache root the install must run in. CLAUDE_PLUGIN_ROOT is
64
+ * set by Claude Code when it spawns plugin processes; the __dirname fallback
65
+ * (lib/core -> plugin root) covers manual / test invocation.
66
+ *
67
+ * @param {string} [fallbackDir] - explicit override (used by callers / tests)
68
+ * @returns {string}
69
+ */
70
+ function resolvePluginRoot(fallbackDir) {
71
+ return (
72
+ process.env.CLAUDE_PLUGIN_ROOT ||
73
+ process.env.MINDRIAN_OS_ROOT ||
74
+ fallbackDir ||
75
+ path.resolve(__dirname, '..', '..')
76
+ );
77
+ }
78
+
79
+ /**
80
+ * Run `npm install` once in `dir`, guarded so two racing servers cannot run it
81
+ * concurrently. The loser waits for the winner instead.
82
+ *
83
+ * @param {string} dir
84
+ * @returns {{ ran: boolean, waited: boolean, ok: boolean }}
85
+ */
86
+ function runGuardedInstall(dir) {
87
+ const haveLock = acquireInstallLock(dir);
88
+
89
+ if (!haveLock) {
90
+ // Another live process is installing. Wait for it, then return without
91
+ // running our own install -- node_modules should now exist.
92
+ const cleared = waitForUnlock(dir);
93
+ return { ran: false, waited: true, ok: cleared };
94
+ }
95
+
96
+ try {
97
+ // Portable npm resolution: run npm via its absolute npm-cli.js off the
98
+ // current node binary (process.execPath). This is correct on Windows
99
+ // (no `.cmd` extension dependency), Mac (no PATH dependency), and Linux.
100
+ const npm = resolveNpmCli();
101
+ const result = spawnSync(
102
+ npm.command,
103
+ buildInstallArgs(npm),
104
+ { cwd: dir, timeout: 120000, stdio: 'ignore', shell: npm.shell }
105
+ );
106
+ const ok = !!result && result.status === 0;
107
+ return { ran: true, waited: false, ok };
108
+ } catch (_) {
109
+ return { ran: true, waited: false, ok: false };
110
+ } finally {
111
+ releaseInstallLock(dir);
112
+ }
113
+ }
114
+
115
+ /**
116
+ * require() a module; on MODULE_NOT_FOUND, run a one-shot guarded `npm install`
117
+ * in the plugin cache root and retry exactly once.
118
+ *
119
+ * Any non-MODULE_NOT_FOUND error is re-thrown immediately (a real bug, not a
120
+ * missing-dependency situation -- healing would not help).
121
+ *
122
+ * @param {string} moduleId - the module specifier to require
123
+ * @param {object} [opts]
124
+ * @param {string} [opts.pluginRoot] - explicit plugin cache root override
125
+ * @param {function} [opts.log] - sink for a one-line stderr breadcrumb
126
+ * @returns {*} the required module
127
+ */
128
+ function requireWithHeal(moduleId, opts) {
129
+ opts = opts || {};
130
+ const log = typeof opts.log === 'function' ? opts.log : () => {};
131
+ try {
132
+ return require(moduleId);
133
+ } catch (err) {
134
+ if (!err || err.code !== 'MODULE_NOT_FOUND') throw err;
135
+
136
+ const dir = resolvePluginRoot(opts.pluginRoot);
137
+ log('[mcp-dep-heal] missing dependency for ' + moduleId + '; self-healing npm install in ' + dir);
138
+
139
+ const outcome = runGuardedInstall(dir);
140
+ log(
141
+ '[mcp-dep-heal] install ' +
142
+ (outcome.waited ? 'waited-for-peer' : outcome.ran ? 'ran' : 'skipped') +
143
+ '; ok=' + outcome.ok
144
+ );
145
+
146
+ // Retry the require. If the peer-install or our own install succeeded the
147
+ // module now resolves. If it still fails, the error propagates -- the
148
+ // server crashes with a clear MODULE_NOT_FOUND, exactly as before, and the
149
+ // SessionStart reconcile hook is the remaining safety net for next session.
150
+ return require(moduleId);
151
+ }
152
+ }
153
+
154
+ /**
155
+ * The full production dependency set the plugin requires at runtime, read from
156
+ * the plugin's own package.json. This is the bug_011 fix: a probe limited to
157
+ * just ['@modelcontextprotocol/sdk', 'zod'] passes on a PARTIALLY-populated
158
+ * node_modules (sdk + zod present, @modelcontextprotocol/ext-apps or another
159
+ * dep absent), no heal runs, then a bare `require` deeper in the lib/mcp/*
160
+ * chain (capability-registry.cjs -> app-views.cjs -> ext-apps/server) throws
161
+ * MODULE_NOT_FOUND at module-init scope and crashes the server. Probing the
162
+ * full `dependencies` set -- exactly as scripts/sessionstart-npm-reconcile.cjs
163
+ * already does -- catches an incomplete tree before any require runs.
164
+ *
165
+ * Defensive: a missing or unreadable package.json yields the MCP-critical pair
166
+ * as a fallback rather than crashing -- the heal pre-flight must never throw.
167
+ *
168
+ * @param {string} dir - resolved plugin cache root
169
+ * @returns {string[]} dependency names to stat-check
170
+ */
171
+ function productionDepNames(dir) {
172
+ const fallback = ['@modelcontextprotocol/sdk', 'zod'];
173
+ try {
174
+ const pkgPath = path.join(dir, 'package.json');
175
+ if (!fs.existsSync(pkgPath)) return fallback;
176
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
177
+ const names = Object.keys(pkg.dependencies || {});
178
+ return names.length ? names : fallback;
179
+ } catch (_) {
180
+ // Unreadable / unparseable package.json -- fall back gracefully.
181
+ return fallback;
182
+ }
183
+ }
184
+
185
+ /**
186
+ * Heal the plugin's node_modules up front, BEFORE any dependency require runs.
187
+ * Cheap pre-flight: a few stat() calls on a healthy box, the guarded install
188
+ * only on a genuinely-missing cache.
189
+ *
190
+ * MCP entry points call this once at the very top so the subsequent SDK / zod
191
+ * requires are guaranteed to resolve. Idempotent and defensive: any error is
192
+ * swallowed (the per-require requireWithHeal path remains as a second net).
193
+ *
194
+ * @param {object} [opts]
195
+ * @param {string} [opts.pluginRoot]
196
+ * @param {string[]} [opts.probe] - explicit dependency names to stat-check.
197
+ * When omitted, defaults to the FULL
198
+ * production dependency set from the plugin's
199
+ * package.json (bug_011) so a partially
200
+ * populated node_modules is detected, not just
201
+ * a totally absent one.
202
+ * @param {function} [opts.log]
203
+ * @returns {{ healed: boolean, ok: boolean }}
204
+ */
205
+ function ensureDepsPresent(opts) {
206
+ opts = opts || {};
207
+ const log = typeof opts.log === 'function' ? opts.log : () => {};
208
+ const dir = resolvePluginRoot(opts.pluginRoot);
209
+ const probe = Array.isArray(opts.probe) && opts.probe.length
210
+ ? opts.probe
211
+ : productionDepNames(dir);
212
+
213
+ try {
214
+ const nm = path.join(dir, 'node_modules');
215
+ let missing = false;
216
+ if (!fs.existsSync(nm)) {
217
+ missing = true;
218
+ } else {
219
+ for (const dep of probe) {
220
+ if (!fs.existsSync(path.join(nm, ...dep.split('/')))) { missing = true; break; }
221
+ }
222
+ }
223
+ if (!missing) return { healed: false, ok: true };
224
+
225
+ log('[mcp-dep-heal] node_modules missing/incomplete; self-healing npm install in ' + dir);
226
+ const outcome = runGuardedInstall(dir);
227
+ log(
228
+ '[mcp-dep-heal] install ' +
229
+ (outcome.waited ? 'waited-for-peer' : 'ran') +
230
+ '; ok=' + outcome.ok
231
+ );
232
+ return { healed: true, ok: outcome.ok };
233
+ } catch (_) {
234
+ // Never let the heal pre-flight itself crash the server -- requireWithHeal
235
+ // is the backstop.
236
+ return { healed: false, ok: false };
237
+ }
238
+ }
239
+
240
+ module.exports = {
241
+ requireWithHeal,
242
+ ensureDepsPresent,
243
+ runGuardedInstall,
244
+ resolvePluginRoot,
245
+ productionDepNames,
246
+ };
@@ -0,0 +1,253 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ /*
5
+ * Copyright (c) 2026 Mindrian. BSL 1.1.
6
+ *
7
+ * Regression tests for lib/core/mcp-dep-heal.cjs -- focused on the bug_011
8
+ * fix folded into v1.13.0-beta.23.
9
+ *
10
+ * bug_011 -- dependency probe too narrow.
11
+ * ensureDepsPresent's probe defaulted to only ['@modelcontextprotocol/sdk',
12
+ * 'zod']. A partially-populated node_modules (sdk + zod present, but
13
+ * @modelcontextprotocol/ext-apps or another production dep absent) PASSED the
14
+ * probe, no heal ran, and a bare `require` deeper in the lib/mcp/* chain
15
+ * (capability-registry.cjs -> app-views.cjs -> ext-apps/server) then threw
16
+ * MODULE_NOT_FOUND at module-init scope and crashed the server.
17
+ *
18
+ * The fix: when no explicit `probe` is supplied, ensureDepsPresent defaults
19
+ * to the FULL production dependency set read from the plugin's package.json
20
+ * (Object.keys(pkg.dependencies)), exactly as scripts/sessionstart-npm-
21
+ * reconcile.cjs already does. A missing / unreadable package.json must fall
22
+ * back gracefully, never crash.
23
+ *
24
+ * These tests exercise productionDepNames directly and ensureDepsPresent
25
+ * against synthetic plugin roots so no real `npm install` is ever spawned.
26
+ *
27
+ * HARD RULE: no em-dashes.
28
+ */
29
+
30
+ const assert = require('node:assert/strict');
31
+ const fs = require('node:fs');
32
+ const os = require('node:os');
33
+ const path = require('node:path');
34
+
35
+ const REPO_ROOT = path.resolve(__dirname, '..', '..');
36
+ const MODULE_PATH = path.join(REPO_ROOT, 'lib', 'core', 'mcp-dep-heal.cjs');
37
+ const { productionDepNames, ensureDepsPresent } = require(MODULE_PATH);
38
+
39
+ const FALLBACK = ['@modelcontextprotocol/sdk', 'zod'];
40
+
41
+ let passed = 0;
42
+ let failed = 0;
43
+
44
+ function ok(name) {
45
+ passed += 1;
46
+ process.stdout.write(' ok ' + name + '\n');
47
+ }
48
+ function fail(name, err) {
49
+ failed += 1;
50
+ process.stdout.write(' FAIL ' + name + '\n');
51
+ process.stdout.write(' ' + (err && err.message ? err.message : String(err)) + '\n');
52
+ }
53
+ function test(name, fn) {
54
+ try { fn(); ok(name); } catch (err) { fail(name, err); }
55
+ }
56
+
57
+ function tmpdir() {
58
+ return fs.mkdtempSync(path.join(os.tmpdir(), 'mos-dep-heal-test-'));
59
+ }
60
+
61
+ /**
62
+ * Build a synthetic plugin root: a package.json + a node_modules tree
63
+ * containing exactly `present` (a subset of the declared deps).
64
+ */
65
+ function makeRoot(deps, present) {
66
+ const dir = tmpdir();
67
+ fs.writeFileSync(
68
+ path.join(dir, 'package.json'),
69
+ JSON.stringify({ name: 'fake', version: '0.0.0', dependencies: deps }, null, 2)
70
+ );
71
+ const nm = path.join(dir, 'node_modules');
72
+ fs.mkdirSync(nm, { recursive: true });
73
+ for (const name of present || []) {
74
+ fs.mkdirSync(path.join(nm, ...name.split('/')), { recursive: true });
75
+ }
76
+ return dir;
77
+ }
78
+
79
+ // --- productionDepNames ----------------------------------------------------
80
+
81
+ // bug_011 core: productionDepNames reads the FULL dependencies set, not the
82
+ // 2-element MCP-critical pair.
83
+ test('bug_011: productionDepNames returns the full dependency set from package.json', () => {
84
+ const deps = {
85
+ '@modelcontextprotocol/sdk': '^1.29.0',
86
+ '@modelcontextprotocol/ext-apps': '^1.5.0',
87
+ zod: '^3.25.76',
88
+ express: '^5.2.1',
89
+ };
90
+ const dir = makeRoot(deps, []);
91
+ try {
92
+ const names = productionDepNames(dir);
93
+ assert.deepEqual(
94
+ names.slice().sort(),
95
+ Object.keys(deps).slice().sort(),
96
+ 'must return every declared production dependency'
97
+ );
98
+ assert.ok(
99
+ names.indexOf('@modelcontextprotocol/ext-apps') !== -1,
100
+ 'ext-apps -- the dep bug_011 cited -- must be in the probe set'
101
+ );
102
+ assert.ok(names.length > FALLBACK.length, 'full set must be broader than the 2-dep fallback');
103
+ } finally {
104
+ fs.rmSync(dir, { recursive: true, force: true });
105
+ }
106
+ });
107
+
108
+ // productionDepNames matches the REAL plugin package.json when given the repo
109
+ // root -- proving the live MCP entry points get the full probe.
110
+ test('bug_011: productionDepNames matches the real plugin package.json', () => {
111
+ const pkg = JSON.parse(fs.readFileSync(path.join(REPO_ROOT, 'package.json'), 'utf8'));
112
+ const expected = Object.keys(pkg.dependencies || {});
113
+ const names = productionDepNames(REPO_ROOT);
114
+ assert.deepEqual(names.slice().sort(), expected.slice().sort(),
115
+ 'live probe set must equal the plugin package.json dependencies');
116
+ assert.ok(names.indexOf('@modelcontextprotocol/ext-apps') !== -1,
117
+ 'the real probe set must include ext-apps');
118
+ });
119
+
120
+ // Graceful fallback: a missing package.json must not crash, returns the pair.
121
+ test('bug_011: productionDepNames falls back gracefully when package.json is missing', () => {
122
+ const dir = tmpdir(); // no package.json written
123
+ try {
124
+ const names = productionDepNames(dir);
125
+ assert.deepEqual(names, FALLBACK, 'missing package.json => MCP-critical fallback');
126
+ } finally {
127
+ fs.rmSync(dir, { recursive: true, force: true });
128
+ }
129
+ });
130
+
131
+ // Graceful fallback: an unparseable package.json must not crash.
132
+ test('bug_011: productionDepNames falls back gracefully on an unparseable package.json', () => {
133
+ const dir = tmpdir();
134
+ fs.writeFileSync(path.join(dir, 'package.json'), '{ this is : not json');
135
+ try {
136
+ const names = productionDepNames(dir);
137
+ assert.deepEqual(names, FALLBACK, 'corrupt package.json => MCP-critical fallback');
138
+ } finally {
139
+ fs.rmSync(dir, { recursive: true, force: true });
140
+ }
141
+ });
142
+
143
+ // Fallback: a package.json with no dependencies block returns the pair (the
144
+ // install machinery still has something MCP-critical to probe).
145
+ test('bug_011: productionDepNames falls back when dependencies is empty', () => {
146
+ const dir = tmpdir();
147
+ fs.writeFileSync(path.join(dir, 'package.json'),
148
+ JSON.stringify({ name: 'fake', version: '0.0.0' }));
149
+ try {
150
+ const names = productionDepNames(dir);
151
+ assert.deepEqual(names, FALLBACK, 'no dependencies block => MCP-critical fallback');
152
+ } finally {
153
+ fs.rmSync(dir, { recursive: true, force: true });
154
+ }
155
+ });
156
+
157
+ // --- ensureDepsPresent probe behavior -------------------------------------
158
+
159
+ // THE decisive bug_011 test: a partially-populated node_modules (sdk + zod
160
+ // present, ext-apps ABSENT) must be detected as incomplete. Pre-fix the narrow
161
+ // probe passed and no heal ran. We pass a no-op runner via the probe so no real
162
+ // `npm install` is spawned -- instead we assert the missing-detection by
163
+ // inspecting what ensureDepsPresent decides.
164
+ test('bug_011: ensureDepsPresent detects a partial tree (sdk+zod present, ext-apps absent)', () => {
165
+ const deps = {
166
+ '@modelcontextprotocol/sdk': '^1.29.0',
167
+ '@modelcontextprotocol/ext-apps': '^1.5.0',
168
+ zod: '^3.25.76',
169
+ };
170
+ // node_modules has sdk + zod but NOT ext-apps -- the exact bug_011 scenario.
171
+ const dir = makeRoot(deps, ['@modelcontextprotocol/sdk', 'zod']);
172
+ try {
173
+ // With the narrow 2-dep probe (the pre-fix default) this tree looks
174
+ // healthy. Confirm that first, to prove the bug was real.
175
+ let narrowSawMissing = false;
176
+ for (const d of FALLBACK) {
177
+ if (!fs.existsSync(path.join(dir, 'node_modules', ...d.split('/')))) {
178
+ narrowSawMissing = true;
179
+ }
180
+ }
181
+ assert.equal(narrowSawMissing, false,
182
+ 'pre-condition: the narrow sdk+zod probe would (wrongly) see this tree as healthy');
183
+
184
+ // The full probe -- the fix -- must see ext-apps as missing.
185
+ const fullProbe = productionDepNames(dir);
186
+ let fullSawMissing = false;
187
+ for (const d of fullProbe) {
188
+ if (!fs.existsSync(path.join(dir, 'node_modules', ...d.split('/')))) {
189
+ fullSawMissing = true;
190
+ }
191
+ }
192
+ assert.equal(fullSawMissing, true,
193
+ 'the full-dep-set probe MUST detect the absent ext-apps');
194
+
195
+ // ensureDepsPresent with an explicit no-op probe set (deps already present)
196
+ // is a clean no-op -- confirms the explicit-probe path still works and
197
+ // never spawns when nothing is missing.
198
+ const res = ensureDepsPresent({ pluginRoot: dir, probe: ['@modelcontextprotocol/sdk', 'zod'] });
199
+ assert.equal(res.healed, false, 'explicit probe of present-only deps => no heal');
200
+ assert.equal(res.ok, true);
201
+ } finally {
202
+ fs.rmSync(dir, { recursive: true, force: true });
203
+ }
204
+ });
205
+
206
+ // ensureDepsPresent on a fully-healthy tree (every declared dep present) is a
207
+ // pure no-op -- it must not spawn an install.
208
+ test('bug_011: ensureDepsPresent is a no-op when every production dep is present', () => {
209
+ const deps = {
210
+ '@modelcontextprotocol/sdk': '^1.29.0',
211
+ zod: '^3.25.76',
212
+ express: '^5.2.1',
213
+ };
214
+ const dir = makeRoot(deps, Object.keys(deps));
215
+ try {
216
+ const res = ensureDepsPresent({ pluginRoot: dir });
217
+ assert.equal(res.healed, false, 'a complete tree must not trigger a heal');
218
+ assert.equal(res.ok, true, 'a complete tree reports ok');
219
+ } finally {
220
+ fs.rmSync(dir, { recursive: true, force: true });
221
+ }
222
+ });
223
+
224
+ // ensureDepsPresent must never crash on a pathological plugin root. A root
225
+ // with no package.json falls back to the MCP-critical pair; we pre-populate
226
+ // node_modules with that pair so the assertion stays on the no-throw contract
227
+ // without spawning a real `npm install` in the test.
228
+ test('bug_011: ensureDepsPresent never throws on a pathological plugin root', () => {
229
+ const dir = tmpdir(); // no package.json -- productionDepNames -> FALLBACK
230
+ const nm = path.join(dir, 'node_modules');
231
+ fs.mkdirSync(nm, { recursive: true });
232
+ for (const d of FALLBACK) {
233
+ fs.mkdirSync(path.join(nm, ...d.split('/')), { recursive: true });
234
+ }
235
+ try {
236
+ const res = ensureDepsPresent({ pluginRoot: dir });
237
+ assert.ok(res && typeof res.healed === 'boolean' && typeof res.ok === 'boolean',
238
+ 'must return a well-formed result object, never throw');
239
+ assert.equal(res.healed, false, 'fallback pair present => no heal, no install spawn');
240
+ } finally {
241
+ fs.rmSync(dir, { recursive: true, force: true });
242
+ }
243
+ });
244
+
245
+ // HARD RULE: no em-dashes in the module.
246
+ test('mcp-dep-heal.cjs has no em-dashes', () => {
247
+ const src = fs.readFileSync(MODULE_PATH, 'utf8');
248
+ const EM_DASH = String.fromCharCode(0x2014);
249
+ assert.ok(src.indexOf(EM_DASH) === -1, 'em-dash found in mcp-dep-heal.cjs');
250
+ });
251
+
252
+ process.stdout.write('\nmcp-dep-heal: ' + passed + ' passed, ' + failed + ' failed\n');
253
+ process.exit(failed === 0 ? 0 : 1);