@mindrian_os/install 1.13.0-beta.22 → 1.13.0-beta.26
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/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +39 -0
- package/agents/brain-query.md +12 -15
- package/agents/grading.md +14 -26
- package/agents/investor.md +6 -7
- package/agents/research.md +1 -2
- package/bin/mindrian-brain-mcp-client.cjs +16 -3
- package/bin/mindrian-mcp-server.cjs +18 -3
- package/commands/act.md +8 -8
- package/commands/rs-experts.md +3 -1
- package/commands/rs-explain.md +2 -2
- package/commands/rs-thesis.md +3 -1
- package/hooks/hooks.json +8 -8
- package/lib/agents/mva/brain-classic-traps.cjs +29 -51
- package/lib/brain/chain-recommender.cjs +14 -8
- package/lib/brain/framework-chain-slice.cjs +89 -70
- package/lib/core/brain-client.cjs +54 -0
- package/lib/core/brain-derivation-prompts.cjs +15 -10
- package/lib/core/brain-derivation.cjs +16 -2
- package/lib/core/mcp-dep-heal.cjs +246 -0
- package/lib/core/mcp-dep-heal.test.cjs +253 -0
- package/lib/core/npm-cli-resolve.cjs +151 -0
- package/lib/core/npm-cli-resolve.test.cjs +153 -0
- package/lib/core/npm-install-lock.cjs +302 -0
- package/lib/core/npm-install-lock.test.cjs +325 -0
- package/lib/core/rs-chain-feeder.cjs +62 -30
- package/lib/core/rs-nl-to-query.cjs +16 -6
- package/lib/hmi/cross-room-memory.cjs +72 -29
- package/lib/mcp/brain-router.cjs +69 -55
- package/lib/memory/brain-cypher-chain-slice.test.cjs +143 -143
- package/lib/memory/brain-derivation.test.cjs +10 -5
- package/package.json +2 -4
- package/references/brain/query-patterns.md +29 -17
|
@@ -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);
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/*
|
|
5
|
+
* Copyright (c) 2026 Mindrian. BSL 1.1.
|
|
6
|
+
*
|
|
7
|
+
* MindrianOS Plugin -- portable npm CLI resolution (debug session
|
|
8
|
+
* mcp-servers-cache-missing-node-modules, escalated mandate 2026-05-21).
|
|
9
|
+
*
|
|
10
|
+
* THE PROBLEM (code review of the prior Option D fix, commit f6cafe74):
|
|
11
|
+
* The self-heal and the SessionStart reconcile hook both ran
|
|
12
|
+
* `spawnSync('npm', ['install', ...])`. That bare invocation is NOT
|
|
13
|
+
* cross-platform:
|
|
14
|
+
* - WINDOWS: `npm` is `npm.cmd` (a batch file). spawnSync('npm') with no
|
|
15
|
+
* shell:true and no .cmd suffix returns ENOENT -- the heal silently does
|
|
16
|
+
* nothing. On Windows the node_modules gap then NEVER heals.
|
|
17
|
+
* - MAC: even with the .cmd issue aside, spawnSync('npm') depends on `npm`
|
|
18
|
+
* being on the child process PATH. A GUI-launched (Dock/Finder) Claude
|
|
19
|
+
* Code gives child processes a minimal PATH that frequently excludes the
|
|
20
|
+
* nvm / Homebrew bin directory where `npm` lives -- same ENOENT, different
|
|
21
|
+
* cause. `shell:true` does NOT fix this (it does not add the missing dir
|
|
22
|
+
* to PATH).
|
|
23
|
+
*
|
|
24
|
+
* THE FIX (this module): resolve npm to an ABSOLUTE path, independent of PATH
|
|
25
|
+
* and independent of the platform file extension.
|
|
26
|
+
*
|
|
27
|
+
* The key insight: npm ships in the SAME distribution as the `node` binary
|
|
28
|
+
* already executing this code. `process.execPath` is the absolute path to
|
|
29
|
+
* that node binary. npm's real entry point is a plain JavaScript file --
|
|
30
|
+
* `npm-cli.js` -- which lives at a fixed location relative to the node binary:
|
|
31
|
+
* - POSIX (Linux, Mac): <nodeBinDir>/../lib/node_modules/npm/bin/npm-cli.js
|
|
32
|
+
* - WINDOWS: <nodeBinDir>/node_modules/npm/bin/npm-cli.js
|
|
33
|
+
* Running `node <absolute npm-cli.js> install ...` invokes npm directly with
|
|
34
|
+
* the SAME node binary, sidestepping PATH, the .cmd extension, and shell:true
|
|
35
|
+
* entirely. This is correct by construction on Windows, Mac, and Linux.
|
|
36
|
+
*
|
|
37
|
+
* Fallback: if npm-cli.js cannot be located off process.execPath (an unusual
|
|
38
|
+
* layout -- a system-package node, a relocated install), the resolver returns
|
|
39
|
+
* a PATH-based spawn descriptor that DOES carry the Windows .cmd handling
|
|
40
|
+
* (shell:true on win32) so the backstop is still better than the bare
|
|
41
|
+
* pre-fix invocation.
|
|
42
|
+
*
|
|
43
|
+
* Canon Part 8: zero network surface. Pure node built-ins. This module only
|
|
44
|
+
* computes a spawn descriptor; the caller runs `npm install`.
|
|
45
|
+
*
|
|
46
|
+
* HARD RULE: no em-dashes anywhere in this file (hyphens only).
|
|
47
|
+
*/
|
|
48
|
+
|
|
49
|
+
const fs = require('node:fs');
|
|
50
|
+
const path = require('node:path');
|
|
51
|
+
|
|
52
|
+
const IS_WINDOWS = process.platform === 'win32';
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Candidate absolute locations of npm's JavaScript entry point (npm-cli.js),
|
|
56
|
+
* derived from the directory of the currently-running node binary.
|
|
57
|
+
*
|
|
58
|
+
* Node distributions place npm consistently:
|
|
59
|
+
* - POSIX tarball / nvm / Homebrew / Volta:
|
|
60
|
+
* bin/node + lib/node_modules/npm/bin/npm-cli.js
|
|
61
|
+
* - Windows zip / installer:
|
|
62
|
+
* node.exe + node_modules/npm/bin/npm-cli.js (same dir as node.exe)
|
|
63
|
+
*
|
|
64
|
+
* Both layouts are probed on every platform (a defensive superset) so a
|
|
65
|
+
* non-standard packaging still resolves if npm is present anywhere npm
|
|
66
|
+
* normally ships.
|
|
67
|
+
*
|
|
68
|
+
* @param {string} [execPath] - override for process.execPath (tests)
|
|
69
|
+
* @returns {string[]} absolute candidate paths, most-likely first
|
|
70
|
+
*/
|
|
71
|
+
function npmCliCandidates(execPath) {
|
|
72
|
+
const nodeBin = path.dirname(execPath || process.execPath);
|
|
73
|
+
return [
|
|
74
|
+
// Windows-style: npm sits beside node.exe.
|
|
75
|
+
path.join(nodeBin, 'node_modules', 'npm', 'bin', 'npm-cli.js'),
|
|
76
|
+
// POSIX-style: npm sits one level up under lib/.
|
|
77
|
+
path.join(nodeBin, '..', 'lib', 'node_modules', 'npm', 'bin', 'npm-cli.js'),
|
|
78
|
+
// Some Windows installs nest under a node_modules/npm with a lib prefix.
|
|
79
|
+
path.join(nodeBin, '..', 'node_modules', 'npm', 'bin', 'npm-cli.js'),
|
|
80
|
+
];
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Resolve a portable, absolute spawn descriptor for `npm install`.
|
|
85
|
+
*
|
|
86
|
+
* Preferred result (strategy 'node-npm-cli'):
|
|
87
|
+
* { command: process.execPath, baseArgs: [<abs npm-cli.js>], shell: false }
|
|
88
|
+
* Run npm by feeding its JS entry point to the current node binary. No PATH
|
|
89
|
+
* dependency, no .cmd extension, no shell. Correct on Windows, Mac, Linux.
|
|
90
|
+
*
|
|
91
|
+
* Fallback result (strategy 'path-npm'):
|
|
92
|
+
* { command: 'npm', baseArgs: [], shell: true on win32 else false }
|
|
93
|
+
* Used only when npm-cli.js is not found off process.execPath. shell:true is
|
|
94
|
+
* set on Windows so the OS resolves `npm` -> `npm.cmd` (still better than the
|
|
95
|
+
* pre-fix bare spawn, though it remains PATH-dependent).
|
|
96
|
+
*
|
|
97
|
+
* @param {object} [opts]
|
|
98
|
+
* @param {string} [opts.execPath] - override process.execPath (tests)
|
|
99
|
+
* @returns {{command:string, baseArgs:string[], shell:boolean, strategy:string, npmCli:(string|null)}}
|
|
100
|
+
*/
|
|
101
|
+
function resolveNpmCli(opts) {
|
|
102
|
+
opts = opts || {};
|
|
103
|
+
const candidates = npmCliCandidates(opts.execPath);
|
|
104
|
+
for (const candidate of candidates) {
|
|
105
|
+
try {
|
|
106
|
+
if (fs.existsSync(candidate)) {
|
|
107
|
+
return {
|
|
108
|
+
command: opts.execPath || process.execPath,
|
|
109
|
+
baseArgs: [candidate],
|
|
110
|
+
shell: false,
|
|
111
|
+
strategy: 'node-npm-cli',
|
|
112
|
+
npmCli: candidate,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
} catch (_) {
|
|
116
|
+
// stat failure on a candidate -- try the next one.
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
// Fallback: npm-cli.js not locatable. PATH-based spawn, with Windows .cmd
|
|
120
|
+
// handling via shell:true. Still an improvement over the bare pre-fix call.
|
|
121
|
+
return {
|
|
122
|
+
command: 'npm',
|
|
123
|
+
baseArgs: [],
|
|
124
|
+
shell: IS_WINDOWS,
|
|
125
|
+
strategy: 'path-npm',
|
|
126
|
+
npmCli: null,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Build the full argv for `npm install` (production-safe, quiet, no scripts
|
|
132
|
+
* surprises) given a resolved descriptor from resolveNpmCli().
|
|
133
|
+
*
|
|
134
|
+
* @param {{baseArgs:string[]}} descriptor
|
|
135
|
+
* @param {string[]} [installArgs] - npm args after `install`; defaults to the
|
|
136
|
+
* quiet production set used by the heal path.
|
|
137
|
+
* @returns {string[]} argv to pass as the second arg of spawnSync(command, argv)
|
|
138
|
+
*/
|
|
139
|
+
function buildInstallArgs(descriptor, installArgs) {
|
|
140
|
+
const tail = Array.isArray(installArgs) && installArgs.length
|
|
141
|
+
? installArgs
|
|
142
|
+
: ['--no-audit', '--no-fund', '--silent'];
|
|
143
|
+
return descriptor.baseArgs.concat(['install'], tail);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
module.exports = {
|
|
147
|
+
resolveNpmCli,
|
|
148
|
+
buildInstallArgs,
|
|
149
|
+
npmCliCandidates,
|
|
150
|
+
IS_WINDOWS,
|
|
151
|
+
};
|
|
@@ -0,0 +1,153 @@
|
|
|
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/npm-cli-resolve.cjs -- portable npm CLI
|
|
8
|
+
* resolution (debug session mcp-servers-cache-missing-node-modules, escalated
|
|
9
|
+
* mandate 2026-05-21).
|
|
10
|
+
*
|
|
11
|
+
* The prior Option D fix (commit f6cafe74) ran a bare `spawnSync('npm', ...)`
|
|
12
|
+
* that was DEAD on Windows (npm is npm.cmd) and FRAGILE on Mac (PATH gap for
|
|
13
|
+
* GUI-launched Claude Code). These tests lock the cross-platform contract:
|
|
14
|
+
*
|
|
15
|
+
* Test 1: resolveNpmCli on the test host returns the 'node-npm-cli' strategy
|
|
16
|
+
* and the resolved npm-cli.js actually exists.
|
|
17
|
+
* Test 2: the resolved command equals process.execPath (the current node
|
|
18
|
+
* binary) -- NOT the literal string 'npm'. This is what makes it
|
|
19
|
+
* PATH-independent.
|
|
20
|
+
* Test 3: WINDOWS layout -- npmCliCandidates for a win32-style execPath puts
|
|
21
|
+
* `<nodeBinDir>/node_modules/npm/bin/npm-cli.js` first (where the
|
|
22
|
+
* Windows node installer ships npm). No `.cmd` anywhere in the argv.
|
|
23
|
+
* Test 4: buildInstallArgs produces `[<npm-cli.js>] install --no-audit
|
|
24
|
+
* --no-fund --silent` for the node-npm-cli strategy.
|
|
25
|
+
* Test 5: the fallback descriptor (path-npm) sets shell:true ONLY on win32,
|
|
26
|
+
* so even the fallback carries Windows .cmd handling.
|
|
27
|
+
* Test 6: no em-dashes in npm-cli-resolve.cjs (HARD RULE).
|
|
28
|
+
* Test 7: the argv never contains the bare token 'npm' as command on the
|
|
29
|
+
* node-npm-cli strategy -- proves PATH is not relied upon.
|
|
30
|
+
*
|
|
31
|
+
* HARD RULE: no em-dashes.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
const assert = require('node:assert/strict');
|
|
35
|
+
const fs = require('node:fs');
|
|
36
|
+
const path = require('node:path');
|
|
37
|
+
|
|
38
|
+
const REPO_ROOT = path.resolve(__dirname, '..', '..');
|
|
39
|
+
const MODULE_PATH = path.join(REPO_ROOT, 'lib', 'core', 'npm-cli-resolve.cjs');
|
|
40
|
+
const { resolveNpmCli, buildInstallArgs, npmCliCandidates } = require(MODULE_PATH);
|
|
41
|
+
|
|
42
|
+
let passed = 0;
|
|
43
|
+
let failed = 0;
|
|
44
|
+
|
|
45
|
+
function ok(name) {
|
|
46
|
+
passed += 1;
|
|
47
|
+
process.stdout.write(' ok ' + name + '\n');
|
|
48
|
+
}
|
|
49
|
+
function fail(name, err) {
|
|
50
|
+
failed += 1;
|
|
51
|
+
process.stdout.write(' FAIL ' + name + '\n');
|
|
52
|
+
process.stdout.write(' ' + (err && err.message ? err.message : String(err)) + '\n');
|
|
53
|
+
}
|
|
54
|
+
function test(name, fn) {
|
|
55
|
+
try { fn(); ok(name); } catch (err) { fail(name, err); }
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Test 1 -- node-npm-cli strategy on the test host, npm-cli.js exists.
|
|
59
|
+
test('resolveNpmCli returns node-npm-cli strategy with a real npm-cli.js', () => {
|
|
60
|
+
const r = resolveNpmCli();
|
|
61
|
+
assert.equal(r.strategy, 'node-npm-cli', 'expected node-npm-cli strategy on a normal node install');
|
|
62
|
+
assert.ok(r.npmCli, 'npmCli path should be set');
|
|
63
|
+
assert.ok(fs.existsSync(r.npmCli), 'resolved npm-cli.js must exist on disk: ' + r.npmCli);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
// Test 2 -- command is the node binary, not the literal 'npm'.
|
|
67
|
+
test('resolveNpmCli command is process.execPath (PATH-independent)', () => {
|
|
68
|
+
const r = resolveNpmCli();
|
|
69
|
+
assert.equal(r.command, process.execPath, 'command must be the current node binary');
|
|
70
|
+
assert.notEqual(r.command, 'npm', 'command must NOT be the bare PATH-dependent string npm');
|
|
71
|
+
assert.equal(r.shell, false, 'node-npm-cli strategy needs no shell');
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
// Test 3 -- Windows layout: npm-cli.js beside node.exe is the first candidate.
|
|
75
|
+
test('npmCliCandidates puts the Windows-layout path first', () => {
|
|
76
|
+
// path.win32 mirrors what require('path') resolves to on a real Windows host.
|
|
77
|
+
const winExec = 'C:\\Program Files\\nodejs\\node.exe';
|
|
78
|
+
const cands = npmCliCandidates(winExec);
|
|
79
|
+
assert.ok(Array.isArray(cands) && cands.length >= 3, 'expected >= 3 candidates');
|
|
80
|
+
// The first candidate is the <nodeBinDir>/node_modules/npm/bin/npm-cli.js
|
|
81
|
+
// layout -- exactly where the Windows node installer ships npm.
|
|
82
|
+
assert.ok(
|
|
83
|
+
cands[0].indexOf('node_modules') !== -1 && cands[0].indexOf('npm-cli.js') !== -1,
|
|
84
|
+
'first candidate must target node_modules/npm/bin/npm-cli.js'
|
|
85
|
+
);
|
|
86
|
+
// No candidate is a .cmd file -- we always run the JS entry point directly.
|
|
87
|
+
for (const c of cands) {
|
|
88
|
+
assert.ok(c.indexOf('.cmd') === -1, 'no candidate may be a .cmd batch file: ' + c);
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
// Test 4 -- buildInstallArgs argv shape for the node-npm-cli strategy.
|
|
93
|
+
test('buildInstallArgs produces [npm-cli.js] install ...quiet-flags', () => {
|
|
94
|
+
const r = resolveNpmCli();
|
|
95
|
+
const argv = buildInstallArgs(r);
|
|
96
|
+
assert.equal(argv[0], r.npmCli, 'first arg must be the npm-cli.js path');
|
|
97
|
+
assert.equal(argv[1], 'install', 'second arg must be install');
|
|
98
|
+
assert.deepEqual(
|
|
99
|
+
argv.slice(2),
|
|
100
|
+
['--no-audit', '--no-fund', '--silent'],
|
|
101
|
+
'default install args must be the quiet production set'
|
|
102
|
+
);
|
|
103
|
+
// A custom install-args override is honored.
|
|
104
|
+
const custom = buildInstallArgs(r, ['--omit=dev']);
|
|
105
|
+
assert.deepEqual(custom.slice(1), ['install', '--omit=dev']);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
// Test 5 -- fallback descriptor: shell:true only on Windows.
|
|
109
|
+
test('path-npm fallback sets shell:true only on win32', () => {
|
|
110
|
+
// Force the fallback by pointing execPath at a freshly-created EMPTY temp
|
|
111
|
+
// directory tree deep enough that none of the candidate paths (including the
|
|
112
|
+
// `../lib/node_modules/...` POSIX-layout candidate) can escape onto a real
|
|
113
|
+
// system npm. The temp dir is isolated and known to contain no node_modules.
|
|
114
|
+
const os = require('node:os');
|
|
115
|
+
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'mos-npm-resolve-test-'));
|
|
116
|
+
try {
|
|
117
|
+
// <tmpRoot>/x/y/z/bin/node -- candidates resolve under <tmpRoot>/x/y/z,
|
|
118
|
+
// which is empty, so resolveNpmCli must fall through to path-npm.
|
|
119
|
+
const fakeBin = path.join(tmpRoot, 'x', 'y', 'z', 'bin');
|
|
120
|
+
fs.mkdirSync(fakeBin, { recursive: true });
|
|
121
|
+
const r = resolveNpmCli({ execPath: path.join(fakeBin, 'node') });
|
|
122
|
+
assert.equal(r.strategy, 'path-npm', 'expected the path-npm fallback in an isolated empty tree');
|
|
123
|
+
assert.equal(r.command, 'npm', 'fallback command is the bare npm');
|
|
124
|
+
assert.equal(r.npmCli, null, 'fallback has no npmCli path');
|
|
125
|
+
assert.equal(
|
|
126
|
+
r.shell,
|
|
127
|
+
process.platform === 'win32',
|
|
128
|
+
'fallback shell must be true on Windows (npm.cmd handling) and false elsewhere'
|
|
129
|
+
);
|
|
130
|
+
} finally {
|
|
131
|
+
fs.rmSync(tmpRoot, { recursive: true, force: true });
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
// Test 6 -- HARD RULE: no em-dashes. The em-dash is referenced via its Unicode
|
|
136
|
+
// code point (U+2014) so this test file itself stays em-dash-clean.
|
|
137
|
+
test('npm-cli-resolve.cjs has no em-dashes', () => {
|
|
138
|
+
const src = fs.readFileSync(MODULE_PATH, 'utf8');
|
|
139
|
+
const EM_DASH = String.fromCharCode(0x2014);
|
|
140
|
+
assert.ok(src.indexOf(EM_DASH) === -1, 'em-dash found in npm-cli-resolve.cjs');
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
// Test 7 -- the node-npm-cli argv never relies on a bare 'npm' token.
|
|
144
|
+
test('node-npm-cli argv does not depend on PATH resolution of npm', () => {
|
|
145
|
+
const r = resolveNpmCli();
|
|
146
|
+
const fullArgv = [r.command].concat(buildInstallArgs(r));
|
|
147
|
+
assert.notEqual(fullArgv[0], 'npm', 'command position must not be the bare npm token');
|
|
148
|
+
// The npm-cli.js path is absolute -- not resolved against PATH or cwd.
|
|
149
|
+
assert.ok(path.isAbsolute(r.npmCli), 'npm-cli.js path must be absolute');
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
process.stdout.write('\nnpm-cli-resolve: ' + passed + ' passed, ' + failed + ' failed\n');
|
|
153
|
+
process.exit(failed === 0 ? 0 : 1);
|