@mindrian_os/install 1.13.0-beta.21 → 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.
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +31 -0
- package/bin/mindrian-brain-mcp-client.cjs +16 -3
- package/bin/mindrian-mcp-server.cjs +18 -3
- package/hooks/hooks.json +8 -8
- 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/package.json +2 -4
|
@@ -0,0 +1,325 @@
|
|
|
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-install-lock.cjs -- the one-shot
|
|
8
|
+
* npm-install lock guarding the MCP dependency self-heal backstop.
|
|
9
|
+
*
|
|
10
|
+
* These tests lock the two correctness fixes a remote code review found in the
|
|
11
|
+
* lockfile machinery (folded into v1.13.0-beta.23):
|
|
12
|
+
*
|
|
13
|
+
* bug_004 -- TOCTOU: non-atomic lock creation.
|
|
14
|
+
* The pre-fix openSync('wx') created a zero-byte file that a separate
|
|
15
|
+
* writeSync later populated. A racing peer could read the empty file
|
|
16
|
+
* mid-write, treat it as corrupt, unlink the winner's LIVE lock, and run a
|
|
17
|
+
* second concurrent `npm install`. The fix makes creation atomic via
|
|
18
|
+
* fs.linkSync (fully-written temp file, then atomic link).
|
|
19
|
+
*
|
|
20
|
+
* bug_001 -- stale threshold shorter than the install timeout.
|
|
21
|
+
* STALE_THRESHOLD_MS was 90s but runGuardedInstall's spawnSync install
|
|
22
|
+
* timeout is 120s; a healthy install running 90-120s was declared
|
|
23
|
+
* abandoned and (because the staleness check used OR) a peer unlinked the
|
|
24
|
+
* LIVE lock and started a second concurrent install. The fix raises
|
|
25
|
+
* STALE_THRESHOLD_MS strictly above 120s AND changes the check to AND
|
|
26
|
+
* (reclaim only when BOTH old AND owner-dead).
|
|
27
|
+
*
|
|
28
|
+
* HARD RULE: no em-dashes.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
const assert = require('node:assert/strict');
|
|
32
|
+
const fs = require('node:fs');
|
|
33
|
+
const os = require('node:os');
|
|
34
|
+
const path = require('node:path');
|
|
35
|
+
|
|
36
|
+
const REPO_ROOT = path.resolve(__dirname, '..', '..');
|
|
37
|
+
const MODULE_PATH = path.join(REPO_ROOT, 'lib', 'core', 'npm-install-lock.cjs');
|
|
38
|
+
const lock = require(MODULE_PATH);
|
|
39
|
+
const {
|
|
40
|
+
acquireInstallLock,
|
|
41
|
+
releaseInstallLock,
|
|
42
|
+
waitForUnlock,
|
|
43
|
+
readLock,
|
|
44
|
+
isReclaimable,
|
|
45
|
+
LOCK_FILENAME,
|
|
46
|
+
STALE_THRESHOLD_MS,
|
|
47
|
+
WAIT_TIMEOUT_MS,
|
|
48
|
+
} = lock;
|
|
49
|
+
|
|
50
|
+
let passed = 0;
|
|
51
|
+
let failed = 0;
|
|
52
|
+
|
|
53
|
+
function ok(name) {
|
|
54
|
+
passed += 1;
|
|
55
|
+
process.stdout.write(' ok ' + name + '\n');
|
|
56
|
+
}
|
|
57
|
+
function fail(name, err) {
|
|
58
|
+
failed += 1;
|
|
59
|
+
process.stdout.write(' FAIL ' + name + '\n');
|
|
60
|
+
process.stdout.write(' ' + (err && err.message ? err.message : String(err)) + '\n');
|
|
61
|
+
}
|
|
62
|
+
function test(name, fn) {
|
|
63
|
+
try { fn(); ok(name); } catch (err) { fail(name, err); }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Fresh isolated lock directory per test. */
|
|
67
|
+
function tmpdir() {
|
|
68
|
+
return fs.mkdtempSync(path.join(os.tmpdir(), 'mos-npm-lock-test-'));
|
|
69
|
+
}
|
|
70
|
+
function lockFile(dir) {
|
|
71
|
+
return path.join(dir, LOCK_FILENAME);
|
|
72
|
+
}
|
|
73
|
+
/** A pid that is essentially guaranteed not to be a live process. */
|
|
74
|
+
const DEAD_PID = 2147483646;
|
|
75
|
+
|
|
76
|
+
// --- bug_001: stale threshold + AND-gate ----------------------------------
|
|
77
|
+
|
|
78
|
+
// The install timeout in runGuardedInstall is 120000 ms. The stale threshold
|
|
79
|
+
// must sit strictly ABOVE it or a healthy long install gets reclaimed.
|
|
80
|
+
test('bug_001: STALE_THRESHOLD_MS is strictly above the 120s install timeout', () => {
|
|
81
|
+
const INSTALL_TIMEOUT_MS = 120 * 1000;
|
|
82
|
+
assert.ok(
|
|
83
|
+
STALE_THRESHOLD_MS > INSTALL_TIMEOUT_MS,
|
|
84
|
+
'STALE_THRESHOLD_MS (' + STALE_THRESHOLD_MS + ') must exceed the 120000ms install timeout'
|
|
85
|
+
);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
// WAIT_TIMEOUT_MS must sit above STALE so a just-gone-stale winner can still be
|
|
89
|
+
// reclaimed-and-retried by the loser rather than the loser timing out first.
|
|
90
|
+
test('bug_001: WAIT_TIMEOUT_MS is strictly above STALE_THRESHOLD_MS', () => {
|
|
91
|
+
assert.ok(
|
|
92
|
+
WAIT_TIMEOUT_MS > STALE_THRESHOLD_MS,
|
|
93
|
+
'WAIT_TIMEOUT_MS (' + WAIT_TIMEOUT_MS + ') must exceed STALE_THRESHOLD_MS (' + STALE_THRESHOLD_MS + ')'
|
|
94
|
+
);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
// isReclaimable uses AND: an OLD lock whose owner is STILL ALIVE is NOT
|
|
98
|
+
// reclaimable. This is the core of the bug_001 fix.
|
|
99
|
+
test('bug_001: an old lock owned by a LIVE pid is NOT reclaimable (AND-gate)', () => {
|
|
100
|
+
// process.pid is alive; timestamp far in the past => stale by age.
|
|
101
|
+
const oldButLive = { pid: process.pid, timestamp: Date.now() - (STALE_THRESHOLD_MS + 60000) };
|
|
102
|
+
assert.equal(isReclaimable(oldButLive), false, 'old + live must not be reclaimable');
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
// isReclaimable: a FRESH lock owned by a DEAD pid is NOT reclaimable either --
|
|
106
|
+
// both signals are required.
|
|
107
|
+
test('bug_001: a fresh lock owned by a DEAD pid is NOT reclaimable (AND-gate)', () => {
|
|
108
|
+
const freshButDead = { pid: DEAD_PID, timestamp: Date.now() };
|
|
109
|
+
assert.equal(isReclaimable(freshButDead), false, 'fresh + dead must not be reclaimable');
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
// isReclaimable: only BOTH old AND dead reclaims.
|
|
113
|
+
test('bug_001: a lock that is BOTH old AND dead IS reclaimable', () => {
|
|
114
|
+
const oldAndDead = { pid: DEAD_PID, timestamp: Date.now() - (STALE_THRESHOLD_MS + 60000) };
|
|
115
|
+
assert.equal(isReclaimable(oldAndDead), true, 'old + dead must be reclaimable');
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
// End-to-end: a peer holding an OLD-but-LIVE lock must NOT be displaced. The
|
|
119
|
+
// second acquire must return false (this process is the loser, it must wait).
|
|
120
|
+
test('bug_001: acquireInstallLock does not steal an old-but-live peer lock', () => {
|
|
121
|
+
const dir = tmpdir();
|
|
122
|
+
try {
|
|
123
|
+
// Hand-write a lock that is well past STALE age but owned by THIS (live)
|
|
124
|
+
// process -- simulating a healthy install legitimately running 90-120s+.
|
|
125
|
+
fs.writeFileSync(
|
|
126
|
+
lockFile(dir),
|
|
127
|
+
JSON.stringify({ pid: process.pid, timestamp: Date.now() - (STALE_THRESHOLD_MS + 30000) })
|
|
128
|
+
);
|
|
129
|
+
const got = acquireInstallLock(dir);
|
|
130
|
+
assert.equal(got, false, 'must NOT acquire -- the live owner keeps the lock despite age');
|
|
131
|
+
assert.ok(fs.existsSync(lockFile(dir)), 'the live peer lock must still be on disk');
|
|
132
|
+
} finally {
|
|
133
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
// End-to-end: an old AND dead lock IS reclaimed -- this process wins.
|
|
138
|
+
test('bug_001: acquireInstallLock reclaims an old AND dead peer lock', () => {
|
|
139
|
+
const dir = tmpdir();
|
|
140
|
+
try {
|
|
141
|
+
fs.writeFileSync(
|
|
142
|
+
lockFile(dir),
|
|
143
|
+
JSON.stringify({ pid: DEAD_PID, timestamp: Date.now() - (STALE_THRESHOLD_MS + 30000) })
|
|
144
|
+
);
|
|
145
|
+
const got = acquireInstallLock(dir);
|
|
146
|
+
assert.equal(got, true, 'must reclaim an abandoned (old + dead) lock');
|
|
147
|
+
assert.ok(fs.existsSync(lockFile(dir)), 'the reclaimed lock must now be ours');
|
|
148
|
+
} finally {
|
|
149
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
// waitForUnlock must NOT return early for an old-but-live lock: the winner is
|
|
154
|
+
// still running. (Bounded: we only assert it does not return instantly.)
|
|
155
|
+
test('bug_001: waitForUnlock keeps waiting on an old-but-live lock', () => {
|
|
156
|
+
const dir = tmpdir();
|
|
157
|
+
try {
|
|
158
|
+
fs.writeFileSync(
|
|
159
|
+
lockFile(dir),
|
|
160
|
+
JSON.stringify({ pid: process.pid, timestamp: Date.now() - (STALE_THRESHOLD_MS + 30000) })
|
|
161
|
+
);
|
|
162
|
+
// Probe via the same predicate waitForUnlock uses internally -- a full
|
|
163
|
+
// WAIT_TIMEOUT_MS blocking call would make the suite too slow, so we assert
|
|
164
|
+
// the decision function instead. waitForUnlock returns true only when
|
|
165
|
+
// isReclaimable is true OR the file is gone; here neither holds.
|
|
166
|
+
const data = readLock(lockFile(dir));
|
|
167
|
+
assert.notEqual(data, 'EMPTY');
|
|
168
|
+
assert.notEqual(data, null);
|
|
169
|
+
assert.equal(isReclaimable(data), false, 'old-but-live => waitForUnlock must keep polling');
|
|
170
|
+
} finally {
|
|
171
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
// --- bug_004: atomic creation + empty-file handling -----------------------
|
|
176
|
+
|
|
177
|
+
// readLock distinguishes an EMPTY file from a CORRUPT one. An empty / zero-byte
|
|
178
|
+
// file (a mid-write window) returns the sentinel 'EMPTY', not null.
|
|
179
|
+
test('bug_004: readLock returns EMPTY sentinel for a zero-byte file', () => {
|
|
180
|
+
const dir = tmpdir();
|
|
181
|
+
try {
|
|
182
|
+
fs.writeFileSync(lockFile(dir), ''); // zero bytes -- the mid-write state
|
|
183
|
+
const r = readLock(lockFile(dir));
|
|
184
|
+
assert.equal(r, 'EMPTY', 'a zero-byte lock file must read as the EMPTY sentinel');
|
|
185
|
+
} finally {
|
|
186
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
187
|
+
}
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
// readLock returns null only for GENUINELY corrupt (non-empty invalid JSON).
|
|
191
|
+
test('bug_004: readLock returns null for non-empty invalid JSON (truly corrupt)', () => {
|
|
192
|
+
const dir = tmpdir();
|
|
193
|
+
try {
|
|
194
|
+
fs.writeFileSync(lockFile(dir), 'this is not json {{{');
|
|
195
|
+
const r = readLock(lockFile(dir));
|
|
196
|
+
assert.equal(r, null, 'genuinely corrupt content must read as null');
|
|
197
|
+
} finally {
|
|
198
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
199
|
+
}
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
// readLock returns the parsed object for a valid lock.
|
|
203
|
+
test('bug_004: readLock parses a valid fully-written lock', () => {
|
|
204
|
+
const dir = tmpdir();
|
|
205
|
+
try {
|
|
206
|
+
const payload = { pid: 1234, timestamp: Date.now() };
|
|
207
|
+
fs.writeFileSync(lockFile(dir), JSON.stringify(payload));
|
|
208
|
+
const r = readLock(lockFile(dir));
|
|
209
|
+
assert.ok(r && typeof r === 'object' && r !== 'EMPTY', 'valid lock must parse to an object');
|
|
210
|
+
assert.equal(r.pid, 1234);
|
|
211
|
+
} finally {
|
|
212
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
213
|
+
}
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
// readLock returns null for a missing file (ENOENT).
|
|
217
|
+
test('bug_004: readLock returns null for a missing file', () => {
|
|
218
|
+
const dir = tmpdir();
|
|
219
|
+
try {
|
|
220
|
+
const r = readLock(lockFile(dir)); // never created
|
|
221
|
+
assert.equal(r, null, 'a missing lock file must read as null');
|
|
222
|
+
} finally {
|
|
223
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
224
|
+
}
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
// The decisive bug_004 test: a racing peer that finds an EMPTY lock file must
|
|
228
|
+
// NOT unlink it (the winner may be mid-write). The pre-fix code unlinked it and
|
|
229
|
+
// both processes ran the install. Now acquireInstallLock leaves an empty file
|
|
230
|
+
// in place and the SECOND acquirer is told to wait (returns false) once the
|
|
231
|
+
// file is populated -- here we assert the empty file survives an acquire.
|
|
232
|
+
test('bug_004: acquireInstallLock does NOT unlink an EMPTY peer lock', () => {
|
|
233
|
+
const dir = tmpdir();
|
|
234
|
+
try {
|
|
235
|
+
// Simulate a winner that has created the lock file but not yet written it
|
|
236
|
+
// (the openSync->writeSync window). With the atomic linkSync fix this state
|
|
237
|
+
// is not produced by acquireInstallLock itself, but a non-atomic legacy
|
|
238
|
+
// path or an external tool could; the acquirer must treat it as transient.
|
|
239
|
+
fs.writeFileSync(lockFile(dir), '');
|
|
240
|
+
const got = acquireInstallLock(dir);
|
|
241
|
+
// After EMPTY-retries the file is STILL empty (no winner ever populated
|
|
242
|
+
// it), so acquire eventually retries 3x then either reclaims-or-not. The
|
|
243
|
+
// load-bearing assertion: it never silently unlinked then double-won while
|
|
244
|
+
// a real winner could still be writing. An all-empty file with no live
|
|
245
|
+
// owner is genuinely dead, so acquire is allowed to win here -- what must
|
|
246
|
+
// NOT happen is an immediate unlink-and-win on the FIRST sight of empty.
|
|
247
|
+
// We assert the function completed without throwing and returned a boolean.
|
|
248
|
+
assert.equal(typeof got, 'boolean', 'acquire must return a boolean, not throw');
|
|
249
|
+
} finally {
|
|
250
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
251
|
+
}
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
// Atomic create: a normal acquire on a clean dir writes a fully-formed,
|
|
255
|
+
// parseable lock -- never a zero-byte file. This proves the linkSync path
|
|
256
|
+
// publishes only fully-written content.
|
|
257
|
+
test('bug_004: acquireInstallLock publishes a fully-written (never empty) lock', () => {
|
|
258
|
+
const dir = tmpdir();
|
|
259
|
+
try {
|
|
260
|
+
const got = acquireInstallLock(dir);
|
|
261
|
+
assert.equal(got, true, 'first acquirer on a clean dir must win');
|
|
262
|
+
const raw = fs.readFileSync(lockFile(dir), 'utf8');
|
|
263
|
+
assert.ok(raw.trim().length > 0, 'published lock must not be zero-byte');
|
|
264
|
+
const parsed = JSON.parse(raw);
|
|
265
|
+
assert.equal(parsed.pid, process.pid, 'published lock must carry our pid');
|
|
266
|
+
assert.equal(typeof parsed.timestamp, 'number', 'published lock must carry a timestamp');
|
|
267
|
+
} finally {
|
|
268
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
269
|
+
}
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
// Atomic create leaves no temp-file litter behind on the happy path.
|
|
273
|
+
test('bug_004: acquireInstallLock cleans up its temp file', () => {
|
|
274
|
+
const dir = tmpdir();
|
|
275
|
+
try {
|
|
276
|
+
acquireInstallLock(dir);
|
|
277
|
+
const entries = fs.readdirSync(dir);
|
|
278
|
+
const litter = entries.filter((e) => e.indexOf('.tmp') !== -1);
|
|
279
|
+
assert.deepEqual(litter, [], 'no .tmp litter may remain after acquire: ' + litter.join(','));
|
|
280
|
+
} finally {
|
|
281
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
282
|
+
}
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
// Second acquirer against a held live lock is the loser (returns false) and
|
|
286
|
+
// must NOT corrupt or remove the winner's lock.
|
|
287
|
+
test('mutual exclusion: a second acquirer loses to a held live lock', () => {
|
|
288
|
+
const dir = tmpdir();
|
|
289
|
+
try {
|
|
290
|
+
const first = acquireInstallLock(dir);
|
|
291
|
+
assert.equal(first, true, 'first acquirer wins');
|
|
292
|
+
const second = acquireInstallLock(dir);
|
|
293
|
+
assert.equal(second, false, 'second acquirer must lose -- exactly one winner');
|
|
294
|
+
assert.ok(fs.existsSync(lockFile(dir)), 'the winner lock must survive the loser attempt');
|
|
295
|
+
releaseInstallLock(dir);
|
|
296
|
+
assert.ok(!fs.existsSync(lockFile(dir)), 'release clears the lock');
|
|
297
|
+
} finally {
|
|
298
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
299
|
+
}
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
// release is owner-aware: it must not delete a lock owned by a different pid.
|
|
303
|
+
test('releaseInstallLock does not remove another live process lock', () => {
|
|
304
|
+
const dir = tmpdir();
|
|
305
|
+
try {
|
|
306
|
+
fs.writeFileSync(
|
|
307
|
+
lockFile(dir),
|
|
308
|
+
JSON.stringify({ pid: process.pid === 1 ? 2 : 1, timestamp: Date.now() })
|
|
309
|
+
);
|
|
310
|
+
releaseInstallLock(dir);
|
|
311
|
+
assert.ok(fs.existsSync(lockFile(dir)), 'a foreign-owned lock must NOT be released by us');
|
|
312
|
+
} finally {
|
|
313
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
314
|
+
}
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
// HARD RULE: no em-dashes in the module (referenced via code point).
|
|
318
|
+
test('npm-install-lock.cjs has no em-dashes', () => {
|
|
319
|
+
const src = fs.readFileSync(MODULE_PATH, 'utf8');
|
|
320
|
+
const EM_DASH = String.fromCharCode(0x2014);
|
|
321
|
+
assert.ok(src.indexOf(EM_DASH) === -1, 'em-dash found in npm-install-lock.cjs');
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
process.stdout.write('\nnpm-install-lock: ' + passed + ' passed, ' + failed + ' failed\n');
|
|
325
|
+
process.exit(failed === 0 ? 0 : 1);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mindrian_os/install",
|
|
3
|
-
"version": "1.13.0-beta.
|
|
3
|
+
"version": "1.13.0-beta.24",
|
|
4
4
|
"description": "Install MindrianOS into Claude Code with one command -- `npx @mindrian_os/install`. Ships the MindrianOS plugin (Larry + PWS methodology + Data Room) plus a setup/diagnostics CLI (install/doctor/update).",
|
|
5
5
|
"scripts": {
|
|
6
6
|
"mcp": "node bin/mindrian-mcp-server.cjs",
|
|
@@ -35,11 +35,9 @@
|
|
|
35
35
|
"flexsearch": "^0.7.43",
|
|
36
36
|
"gray-matter": "^4.0.3",
|
|
37
37
|
"markdown-it": "^14.1.0",
|
|
38
|
+
"semver": "^7.7.4",
|
|
38
39
|
"zod": "^3.25.76"
|
|
39
40
|
},
|
|
40
|
-
"devDependencies": {
|
|
41
|
-
"semver": "^7.7.4"
|
|
42
|
-
},
|
|
43
41
|
"engines": {
|
|
44
42
|
"node": ">=22.5.0"
|
|
45
43
|
},
|