@matthew-cochran/crossmatrix 0.0.0-stage → 0.3.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/lib/install.js ADDED
@@ -0,0 +1,571 @@
1
+ 'use strict';
2
+ // Resolves, downloads, verifies and caches the crossmatrix-mcp release binary.
3
+ //
4
+ // Only Node built-ins are used. Everything is written to stderr (via `log`),
5
+ // never to stdout: stdout is the MCP stdio channel of the server we launch.
6
+
7
+ const crypto = require('node:crypto');
8
+ const fs = require('node:fs');
9
+ const http = require('node:http');
10
+ const https = require('node:https');
11
+ const os = require('node:os');
12
+ const path = require('node:path');
13
+ const { execFileSync } = require('node:child_process');
14
+
15
+ const PKG_VERSION = require('../package.json').version;
16
+ const BIN = 'crossmatrix-mcp';
17
+ const REPO = 'praxec/crossmatrix';
18
+ const DEFAULT_BASE = `https://github.com/${REPO}/releases`;
19
+ const RELEASES_PAGE = `https://github.com/${REPO}/releases`;
20
+ const INSTALL_DOCS = `https://github.com/${REPO}#install`;
21
+ // GitHub serves release downloads from github.com and redirects to its asset
22
+ // CDN. Current releases redirect to release-assets.githubusercontent.com;
23
+ // objects.githubusercontent.com is the older CDN host.
24
+ const REDIRECT_HOSTS = ['github.com', 'objects.githubusercontent.com', 'release-assets.githubusercontent.com'];
25
+ const VERIFIED_FILE = '.verified';
26
+ const LOCK_FILE = '.lock';
27
+
28
+ // Download limits. ensureBinary({ limits }) overrides them (tests only).
29
+ const DEFAULT_LIMITS = {
30
+ maxBytes: 134217728, // same cap as scripts/install.sh (PRAXEC_MAX_BYTES default)
31
+ maxRedirects: 5,
32
+ idleTimeoutMs: 30000,
33
+ totalTimeoutMs: 300000,
34
+ };
35
+ // Lock timing. The holder refreshes the lock's mtime every heartbeatMs, so a
36
+ // lock not touched for staleMs is abandoned whatever pid it names (pids are
37
+ // reused, e.g. pid 1 in containers). A lock naming a dead pid on this host is
38
+ // taken over at once. Waiting outlasts both the stale threshold and a full
39
+ // download (DEFAULT_LIMITS.totalTimeoutMs).
40
+ const DEFAULT_LOCK = { staleMs: 2 * 60 * 1000, heartbeatMs: 30 * 1000, waitMs: 6 * 60 * 1000, pollMs: 100 };
41
+ // Tokens of the locks this process holds right now.
42
+ const heldTokens = new Set();
43
+
44
+ class LauncherError extends Error {}
45
+
46
+ // The six release targets, keyed by Node's process.platform/process.arch.
47
+ const TARGETS = {
48
+ 'linux-x64': 'x86_64-unknown-linux-gnu',
49
+ 'linux-arm64': 'aarch64-unknown-linux-gnu',
50
+ 'darwin-x64': 'x86_64-apple-darwin',
51
+ 'darwin-arm64': 'aarch64-apple-darwin',
52
+ 'win32-x64': 'x86_64-pc-windows-msvc',
53
+ 'win32-arm64': 'aarch64-pc-windows-msvc',
54
+ };
55
+
56
+ // Maps a platform/arch to the release target, asset name and binary name,
57
+ // matching .github/workflows/release.yml and scripts/release-manifest.sh.
58
+ // `translated` is true when an x64 Node runs under Rosetta on Apple Silicon;
59
+ // like install.sh we then prefer the native arm64 binary.
60
+ function resolveTarget(platform, arch, { translated = false } = {}) {
61
+ const effectiveArch = platform === 'darwin' && arch === 'x64' && translated ? 'arm64' : arch;
62
+ const target = TARGETS[`${platform}-${effectiveArch}`];
63
+ if (!target) {
64
+ throw new LauncherError(
65
+ `unsupported platform ${platform}/${arch}; release binaries exist for linux, macOS and Windows on x64 and arm64`,
66
+ );
67
+ }
68
+ const windows = platform === 'win32';
69
+ const ext = windows ? 'zip' : 'tar.gz';
70
+ return { target, ext, asset: `${BIN}-${target}.${ext}`, binary: windows ? `${BIN}.exe` : BIN };
71
+ }
72
+
73
+ function runningUnderRosetta() {
74
+ try {
75
+ return execFileSync('sysctl', ['-n', 'sysctl.proc_translated'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim() === '1';
76
+ } catch {
77
+ return false;
78
+ }
79
+ }
80
+
81
+ function hostTarget(platform = process.platform, arch = process.arch) {
82
+ const translated = platform === 'darwin' && arch === 'x64' && runningUnderRosetta();
83
+ return resolveTarget(platform, arch, { translated });
84
+ }
85
+
86
+ // Per-user cache root: CROSSMATRIX_MCP_CACHE_DIR, else %LOCALAPPDATA% on Windows,
87
+ // else $XDG_CACHE_HOME, else ~/.cache. Binaries live in <root>/crossmatrix-mcp/<version>/.
88
+ function cacheRoot(env = process.env, platform = process.platform) {
89
+ if (env.CROSSMATRIX_MCP_CACHE_DIR) return env.CROSSMATRIX_MCP_CACHE_DIR;
90
+ if (platform === 'win32' && env.LOCALAPPDATA) return env.LOCALAPPDATA;
91
+ if (env.XDG_CACHE_HOME) return env.XDG_CACHE_HOME;
92
+ return path.join(os.homedir(), '.cache');
93
+ }
94
+
95
+ function insecureAllowed(env) {
96
+ return env.PRAXEC_ALLOW_INSECURE === '1';
97
+ }
98
+
99
+ // Returns `url` without any user:password part, for logs and errors.
100
+ function redact(url) {
101
+ try {
102
+ const u = new URL(String(url));
103
+ if (!u.username && !u.password) return u.href;
104
+ u.username = '';
105
+ u.password = '';
106
+ return u.href;
107
+ } catch {
108
+ return String(url).replace(/\/\/[^/@\s]*@/, '//');
109
+ }
110
+ }
111
+
112
+ // Validates the release base URL. https only unless PRAXEC_ALLOW_INSECURE=1,
113
+ // the same rule as scripts/install.sh and scripts/install.ps1.
114
+ function parseBase(base, env) {
115
+ let url;
116
+ try {
117
+ url = new URL(base);
118
+ } catch {
119
+ throw new LauncherError(`invalid download base URL '${redact(base)}'`);
120
+ }
121
+ const ok = url.protocol === 'https:' || (insecureAllowed(env) && url.protocol === 'http:');
122
+ if (!ok) {
123
+ throw new LauncherError(
124
+ `refusing non-https base URL '${redact(base)}' (set PRAXEC_ALLOW_INSECURE=1 to override for local testing)`,
125
+ );
126
+ }
127
+ return url;
128
+ }
129
+
130
+ function checkRedirect(location, allowedHosts, env) {
131
+ const httpsOk = location.protocol === 'https:' || (insecureAllowed(env) && location.protocol === 'http:');
132
+ if (!httpsOk) throw new LauncherError(`refusing redirect to non-https URL ${location.origin}`);
133
+ if (!allowedHosts.includes(location.host)) {
134
+ throw new LauncherError(`refusing redirect to host '${location.host}' (allowed: ${allowedHosts.join(', ')})`);
135
+ }
136
+ }
137
+
138
+ // Streams `url` into a new file at `dest`, following only allowlisted redirects.
139
+ function download(url, dest, { allowedHosts, env, version, limits, maxBytes = limits.maxBytes }) {
140
+ return new Promise((resolve, reject) => {
141
+ let settled = false;
142
+ let req;
143
+ const finish = (err) => {
144
+ if (settled) return;
145
+ settled = true;
146
+ clearTimeout(deadline);
147
+ if (err) {
148
+ if (req) req.destroy();
149
+ reject(err);
150
+ } else {
151
+ resolve();
152
+ }
153
+ };
154
+ const deadline = setTimeout(
155
+ () => finish(new LauncherError(`download timed out after ${limits.totalTimeoutMs / 1000}s: ${redact(url)}`)),
156
+ limits.totalTimeoutMs,
157
+ );
158
+
159
+ const get = (current, redirectsLeft) => {
160
+ const shown = redact(current);
161
+ const mod = current.protocol === 'https:' ? https : http;
162
+ req = mod.get(current, { headers: { 'user-agent': `crossmatrix-mcp-npm/${version}` }, timeout: limits.idleTimeoutMs }, (res) => {
163
+ const status = res.statusCode || 0;
164
+ if (status >= 300 && status < 400 && res.headers.location) {
165
+ res.resume();
166
+ if (redirectsLeft <= 0) return finish(new LauncherError(`too many redirects (more than ${limits.maxRedirects}): ${redact(url)}`));
167
+ let next;
168
+ try {
169
+ next = new URL(res.headers.location, current);
170
+ checkRedirect(next, allowedHosts, env);
171
+ } catch (err) {
172
+ return finish(err instanceof LauncherError ? err : new LauncherError(`invalid redirect from ${shown}`));
173
+ }
174
+ return get(next, redirectsLeft - 1);
175
+ }
176
+ if (status !== 200) {
177
+ res.resume();
178
+ const hint = status === 404 ? ` (is release v${version} published with this asset?)` : '';
179
+ return finish(new LauncherError(`download failed with HTTP ${status}: ${shown}${hint}`));
180
+ }
181
+ let bytes = 0;
182
+ const out = fs.createWriteStream(dest, { flags: 'wx', mode: 0o600 });
183
+ res.on('data', (chunk) => {
184
+ bytes += chunk.length;
185
+ if (bytes > maxBytes) {
186
+ res.destroy();
187
+ out.destroy();
188
+ finish(new LauncherError(`download exceeds ${maxBytes} bytes: ${shown}`));
189
+ }
190
+ });
191
+ res.on('error', (err) => finish(new LauncherError(`download interrupted: ${shown}: ${err.message}`)));
192
+ out.on('error', (err) => finish(new LauncherError(`cannot write ${dest}: ${err.message}`)));
193
+ out.on('close', () => {
194
+ if (bytes === 0) return finish(new LauncherError(`downloaded file is empty: ${shown}`));
195
+ finish();
196
+ });
197
+ res.pipe(out);
198
+ });
199
+ req.on('timeout', () => req.destroy(new Error(`no data for ${limits.idleTimeoutMs / 1000}s`)));
200
+ req.on('error', (err) => finish(err instanceof LauncherError ? err : new LauncherError(`download failed: ${shown}: ${err.message}`)));
201
+ };
202
+ get(new URL(url), limits.maxRedirects);
203
+ });
204
+ }
205
+
206
+ // Streams the file through the hash so a large binary is never fully in memory.
207
+ function sha256File(file) {
208
+ return new Promise((resolve, reject) => {
209
+ const hash = crypto.createHash('sha256');
210
+ fs.createReadStream(file)
211
+ .on('error', reject)
212
+ .on('data', (chunk) => hash.update(chunk))
213
+ .on('end', () => resolve(hash.digest('hex')));
214
+ });
215
+ }
216
+
217
+ // Reads "<hex> <asset>" (or "<hex> *<asset>") lines, the sha256sum format
218
+ // written by scripts/release-manifest.sh.
219
+ function expectedDigest(checksumsText, asset) {
220
+ for (const line of checksumsText.split(/\r?\n/)) {
221
+ const m = /^([0-9a-fA-F]{64})\s+\*?(.+?)\s*$/.exec(line);
222
+ if (m && m[2] === asset) return m[1].toLowerCase();
223
+ }
224
+ return null;
225
+ }
226
+
227
+ // True for archive entry names that could land outside the extract dir.
228
+ function unsafeEntry(name) {
229
+ const n = name.replace(/\\/g, '/');
230
+ return n.startsWith('/') || /^[A-Za-z]:/.test(n) || n.split('/').includes('..');
231
+ }
232
+
233
+ // True for a `tar -tv` listing line describing a link or special file.
234
+ function specialEntry(listingLine) {
235
+ return /^[lhbcp]/.test(listingLine);
236
+ }
237
+
238
+ // Extracts a .tar.gz with the system `tar` (GNU tar or bsdtar; Windows 10+
239
+ // ships bsdtar). Paths are relative to `cwd` so GNU tar never reads "C:" as a host.
240
+ function extractTarGz(workDir, archiveName, extractName) {
241
+ const run = (args) => execFileSync('tar', args, { cwd: workDir, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], maxBuffer: 16 * 1024 * 1024 });
242
+ let listing;
243
+ let verbose;
244
+ try {
245
+ listing = run(['-tzf', archiveName]);
246
+ verbose = run(['-tvzf', archiveName]);
247
+ } catch (err) {
248
+ throw new LauncherError(`not a readable .tar.gz archive (is \`tar\` installed?): ${err.message}`);
249
+ }
250
+ const entries = listing.split(/\r?\n/).filter(Boolean);
251
+ if (entries.length === 0) throw new LauncherError('archive is empty');
252
+ const bad = entries.find(unsafeEntry);
253
+ if (bad) throw new LauncherError(`refusing unsafe path in archive: ${bad}`);
254
+ if (verbose.split(/\r?\n/).filter(Boolean).some(specialEntry)) {
255
+ throw new LauncherError('refusing archive containing links or special files');
256
+ }
257
+ fs.mkdirSync(path.join(workDir, extractName));
258
+ try {
259
+ run(['-xzf', archiveName, '-C', extractName, '--no-same-owner']);
260
+ } catch {
261
+ run(['-xzf', archiveName, '-C', extractName]);
262
+ }
263
+ }
264
+
265
+ // Extracts a .zip on Windows with PowerShell's Expand-Archive after the same
266
+ // entry checks as scripts/install.ps1. Paths go through env vars, not quoting.
267
+ function extractZip(workDir, archiveName, extractName) {
268
+ if (process.platform !== 'win32') throw new LauncherError('.zip assets are only extracted on Windows');
269
+ const script = [
270
+ "$ErrorActionPreference = 'Stop'",
271
+ 'Add-Type -AssemblyName System.IO.Compression.FileSystem',
272
+ '$zip = [System.IO.Compression.ZipFile]::OpenRead($env:CROSSMATRIX_ZIP)',
273
+ 'try { foreach ($e in $zip.Entries) {',
274
+ " if ([System.IO.Path]::IsPathRooted($e.FullName) -or $e.FullName -match '(^|[\\\\/])\\.\\.([\\\\/]|$)') { throw \"unsafe path in archive: $($e.FullName)\" }",
275
+ " if ((($e.ExternalAttributes -shr 16) -band 0xF000) -eq 0xA000) { throw \"symlink in archive: $($e.FullName)\" }",
276
+ '} } finally { $zip.Dispose() }',
277
+ 'Expand-Archive -LiteralPath $env:CROSSMATRIX_ZIP -DestinationPath $env:CROSSMATRIX_DEST -Force',
278
+ ].join('\n');
279
+ try {
280
+ execFileSync('powershell.exe', ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', script], {
281
+ cwd: workDir,
282
+ env: { ...process.env, CROSSMATRIX_ZIP: path.join(workDir, archiveName), CROSSMATRIX_DEST: path.join(workDir, extractName) },
283
+ stdio: ['ignore', 'pipe', 'pipe'],
284
+ });
285
+ } catch (err) {
286
+ const detail = err.stderr ? String(err.stderr).trim() : err.message;
287
+ throw new LauncherError(`cannot extract .zip archive: ${detail}`);
288
+ }
289
+ }
290
+
291
+ // A regular file (not a link or directory) that is executable on POSIX.
292
+ function isRunnableFile(file) {
293
+ const st = fs.lstatSync(file);
294
+ if (!st.isFile()) return false;
295
+ return process.platform === 'win32' || (st.mode & 0o111) !== 0;
296
+ }
297
+
298
+ // Finds the binary at the archive root, or anywhere below it as install.sh does.
299
+ function findBinary(dir, name) {
300
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
301
+ for (const entry of entries) {
302
+ if (entry.name === name && isRunnableFile(path.join(dir, name))) return path.join(dir, name);
303
+ }
304
+ for (const entry of entries) {
305
+ if (entry.isDirectory()) {
306
+ const found = findBinary(path.join(dir, entry.name), name);
307
+ if (found) return found;
308
+ }
309
+ }
310
+ return null;
311
+ }
312
+
313
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
314
+
315
+ // process.kill(pid, 0) probes a pid without signalling it: ESRCH means no such
316
+ // process, EPERM means it exists but belongs to someone else.
317
+ function pidAlive(pid) {
318
+ try {
319
+ process.kill(pid, 0);
320
+ return true;
321
+ } catch (err) {
322
+ return err.code !== 'ESRCH';
323
+ }
324
+ }
325
+
326
+ function readLock(lockPath) {
327
+ try {
328
+ const raw = fs.readFileSync(lockPath, 'utf8');
329
+ let owner = null;
330
+ try {
331
+ owner = JSON.parse(raw);
332
+ } catch {
333
+ // being written right now, or garbage: judged by age only
334
+ }
335
+ return { raw, owner, mtimeMs: fs.statSync(lockPath).mtimeMs };
336
+ } catch {
337
+ return null; // vanished
338
+ }
339
+ }
340
+
341
+ function lockIsStale(lock, staleMs) {
342
+ if (Date.now() - lock.mtimeMs > staleMs) return true; // no heartbeat: abandoned
343
+ const { owner } = lock;
344
+ if (!owner || !Number.isInteger(owner.pid) || owner.host !== os.hostname()) return false;
345
+ // Our own pid on a lock we do not hold: a previous process with this pid died.
346
+ if (owner.pid === process.pid) return !heldTokens.has(owner.token);
347
+ return !pidAlive(owner.pid);
348
+ }
349
+
350
+ // Cross-process lock: an exclusively created file holding the owner's pid,
351
+ // host and a random token, kept fresh by a heartbeat while held. Returns a
352
+ // release function that stops the heartbeat and deletes the lock only while it
353
+ // still carries our token, or null when `isDone()` became true while waiting
354
+ // (another process finished the download).
355
+ async function acquireLock(lockPath, isDone, opts = {}) {
356
+ const { staleMs, heartbeatMs, waitMs, pollMs } = { ...DEFAULT_LOCK, ...opts };
357
+ const token = crypto.randomBytes(16).toString('hex');
358
+ const ours = () => {
359
+ const lock = readLock(lockPath);
360
+ return Boolean(lock && lock.owner && lock.owner.token === token);
361
+ };
362
+ const started = Date.now();
363
+ for (;;) {
364
+ try {
365
+ const fd = fs.openSync(lockPath, 'wx', 0o600);
366
+ fs.writeSync(fd, JSON.stringify({ pid: process.pid, host: os.hostname(), token, at: new Date().toISOString() }));
367
+ fs.closeSync(fd);
368
+ heldTokens.add(token);
369
+ const heartbeat = setInterval(() => {
370
+ try {
371
+ if (ours()) fs.utimesSync(lockPath, new Date(), new Date());
372
+ } catch {
373
+ // lock gone or unwritable: the next waiter will judge it
374
+ }
375
+ }, heartbeatMs);
376
+ heartbeat.unref();
377
+ return () => {
378
+ clearInterval(heartbeat);
379
+ heldTokens.delete(token);
380
+ if (ours()) fs.rmSync(lockPath, { force: true });
381
+ };
382
+ } catch (err) {
383
+ if (err.code !== 'EEXIST') throw new LauncherError(`cannot create lock ${lockPath}: ${err.message}`);
384
+ }
385
+ if (isDone()) return null;
386
+ const lock = readLock(lockPath);
387
+ if (!lock) continue; // released between open and read: retry at once
388
+ if (lockIsStale(lock, staleMs)) {
389
+ // Only remove the lock we judged; a fresh one written meanwhile stays.
390
+ const again = readLock(lockPath);
391
+ if (again && again.raw === lock.raw) fs.rmSync(lockPath, { force: true });
392
+ continue;
393
+ }
394
+ if (Date.now() - started > waitMs) {
395
+ throw new LauncherError(`timed out waiting for another download to finish (lock ${lockPath}; delete it if no download is running)`);
396
+ }
397
+ await sleep(pollMs);
398
+ }
399
+ }
400
+
401
+ // Removes work dirs left by a holder that died without cleaning up (SIGKILL,
402
+ // power loss). Called only while holding the lock, so none of them is live.
403
+ function removeStaleWorkDirs(versionDir) {
404
+ for (const name of fs.readdirSync(versionDir)) {
405
+ if (name.startsWith('.download-')) fs.rmSync(path.join(versionDir, name), { recursive: true, force: true });
406
+ }
407
+ }
408
+
409
+ // Refuses a cache directory another user could have written to (POSIX).
410
+ function checkCacheDirTrust(versionDir) {
411
+ if (process.platform === 'win32' || typeof process.getuid !== 'function') return;
412
+ const st = fs.statSync(versionDir);
413
+ const remedy = `remove ${versionDir} so it is downloaded again, or set CROSSMATRIX_MCP_CACHE_DIR to a private directory`;
414
+ if (st.uid !== process.getuid()) {
415
+ throw new LauncherError(`refusing cached binary: ${versionDir} is owned by uid ${st.uid}, not ${process.getuid()}; ${remedy}`);
416
+ }
417
+ if ((st.mode & 0o022) !== 0) {
418
+ throw new LauncherError(`refusing cached binary: ${versionDir} is writable by group or others (mode ${(st.mode & 0o777).toString(8)}); ${remedy}`);
419
+ }
420
+ }
421
+
422
+ // Re-checks a cached binary against the digest recorded when it was installed.
423
+ async function checkCachedBinary(versionDir, finalPath) {
424
+ checkCacheDirTrust(versionDir);
425
+ const remedy = `remove ${versionDir} to download it again`;
426
+ let recorded;
427
+ try {
428
+ recorded = fs.readFileSync(path.join(versionDir, VERIFIED_FILE), 'utf8').trim();
429
+ } catch {
430
+ throw new LauncherError(`refusing cached binary ${finalPath}: no ${VERIFIED_FILE} digest; ${remedy}`);
431
+ }
432
+ const actual = await sha256File(finalPath);
433
+ if (actual !== recorded) {
434
+ throw new LauncherError(`refusing cached binary ${finalPath}: sha256 ${actual} does not match the verified ${recorded}; ${remedy}`);
435
+ }
436
+ }
437
+
438
+ async function downloadAndInstall({ version, t, base, allowedHosts, env, versionDir, finalPath, log, limits, state }) {
439
+ const releaseUrl = `${base.href.replace(/\/+$/, '')}/download/v${version}`;
440
+ removeStaleWorkDirs(versionDir);
441
+ const workDir = fs.mkdtempSync(path.join(versionDir, '.download-'));
442
+ state.workDir = workDir;
443
+ try {
444
+ const archive = path.join(workDir, t.asset);
445
+ const sums = path.join(workDir, 'checksums.sha256');
446
+ log(`downloading ${BIN} v${version} for ${t.target} from ${redact(`${releaseUrl}/${t.asset}`)}`);
447
+ await download(`${releaseUrl}/${t.asset}`, archive, { allowedHosts, env, version, limits });
448
+ await download(`${releaseUrl}/checksums.sha256`, sums, { allowedHosts, env, version, limits, maxBytes: 1024 * 1024 });
449
+ const expected = expectedDigest(fs.readFileSync(sums, 'utf8'), t.asset);
450
+ if (!expected) throw new LauncherError(`no checksum entry for ${t.asset} in checksums.sha256`);
451
+ const actual = await sha256File(archive);
452
+ if (actual !== expected) {
453
+ throw new LauncherError(`checksum mismatch for ${t.asset} (expected ${expected}, got ${actual})`);
454
+ }
455
+ log(`checksum verified: ${actual}`);
456
+ if (t.ext === 'zip') extractZip(workDir, t.asset, 'extract');
457
+ else extractTarGz(workDir, t.asset, 'extract');
458
+ const src = findBinary(path.join(workDir, 'extract'), t.binary);
459
+ if (!src) throw new LauncherError(`no executable file '${t.binary}' inside ${t.asset}`);
460
+ if (process.platform !== 'win32') fs.chmodSync(src, 0o755);
461
+ // Record the digest first: a binary without a matching .verified is refused.
462
+ const verifiedTmp = path.join(workDir, VERIFIED_FILE);
463
+ fs.writeFileSync(verifiedTmp, `${await sha256File(src)}\n`, { mode: 0o600 });
464
+ fs.renameSync(verifiedTmp, path.join(versionDir, VERIFIED_FILE));
465
+ fs.renameSync(src, finalPath);
466
+ log(`installed ${finalPath}`);
467
+ } finally {
468
+ fs.rmSync(workDir, { recursive: true, force: true });
469
+ state.workDir = null;
470
+ }
471
+ }
472
+
473
+ // While the lock is held, a terminating signal removes the work dir and the
474
+ // lock, then re-raises the signal so the process still dies by it.
475
+ function cleanupOnSignals(cleanup) {
476
+ const signals = process.platform === 'win32' ? ['SIGINT', 'SIGTERM'] : ['SIGINT', 'SIGTERM', 'SIGHUP'];
477
+ const handlers = signals.map((sig) => {
478
+ const handler = () => {
479
+ remove();
480
+ try {
481
+ cleanup();
482
+ } finally {
483
+ process.kill(process.pid, sig);
484
+ }
485
+ };
486
+ process.on(sig, handler);
487
+ return [sig, handler];
488
+ });
489
+ function remove() {
490
+ for (const [sig, handler] of handlers) process.removeListener(sig, handler);
491
+ }
492
+ return remove;
493
+ }
494
+
495
+ function checkLocalBinary(file) {
496
+ if (/\.(cmd|bat)$/i.test(file)) {
497
+ throw new LauncherError(`CROSSMATRIX_MCP_BINARY must be a native executable (.exe on Windows), not a .cmd/.bat script: ${file}`);
498
+ }
499
+ if (!fs.existsSync(file)) {
500
+ throw new LauncherError(`CROSSMATRIX_MCP_BINARY points to a missing file: ${file}`);
501
+ }
502
+ return file;
503
+ }
504
+
505
+ // Returns the path of a runnable crossmatrix-mcp binary, downloading and verifying
506
+ // it on first use. Options other than the defaults exist for tests.
507
+ async function ensureBinary(opts = {}) {
508
+ const env = opts.env || process.env;
509
+ const log = opts.log || ((msg) => process.stderr.write(`crossmatrix-mcp (npm): ${msg}\n`));
510
+ if (env.CROSSMATRIX_MCP_BINARY) return checkLocalBinary(env.CROSSMATRIX_MCP_BINARY);
511
+ const limits = { ...DEFAULT_LIMITS, ...opts.limits };
512
+ const version = opts.version || PKG_VERSION;
513
+ const t = opts.target || hostTarget(opts.platform, opts.arch);
514
+ const versionDir = path.join(opts.cacheRoot || cacheRoot(env), BIN, version);
515
+ const finalPath = path.join(versionDir, t.binary);
516
+ if (fs.existsSync(finalPath)) {
517
+ await checkCachedBinary(versionDir, finalPath);
518
+ return finalPath;
519
+ }
520
+
521
+ const base = parseBase(env.CROSSMATRIX_MCP_DOWNLOAD_BASE || DEFAULT_BASE, env);
522
+ const allowedHosts = [...new Set([base.host, ...REDIRECT_HOSTS])];
523
+ fs.mkdirSync(versionDir, { recursive: true, mode: 0o700 });
524
+ checkCacheDirTrust(versionDir);
525
+ // Handlers go in before the lock exists, so no signal can orphan it.
526
+ const state = { workDir: null, release: null };
527
+ const removeSignalHandlers = cleanupOnSignals(() => {
528
+ if (state.workDir) fs.rmSync(state.workDir, { recursive: true, force: true });
529
+ if (state.release) state.release();
530
+ });
531
+ try {
532
+ state.release = await acquireLock(path.join(versionDir, LOCK_FILE), () => fs.existsSync(finalPath), opts.lock);
533
+ if (state.release && !fs.existsSync(finalPath)) {
534
+ await downloadAndInstall({ version, t, base, allowedHosts, env, versionDir, finalPath, log, limits, state });
535
+ } else {
536
+ await checkCachedBinary(versionDir, finalPath);
537
+ }
538
+ return finalPath;
539
+ } finally {
540
+ removeSignalHandlers();
541
+ if (state.release) state.release();
542
+ }
543
+ }
544
+
545
+ function formatError(err) {
546
+ const msg = err instanceof LauncherError ? err.message : (err && err.stack) || String(err);
547
+ return [
548
+ `crossmatrix-mcp (npm): error: ${msg}`,
549
+ ' remedy: set CROSSMATRIX_MCP_BINARY to a crossmatrix-mcp binary you installed yourself, or install one manually:',
550
+ ` ${INSTALL_DOCS}`,
551
+ ` ${RELEASES_PAGE}`,
552
+ '',
553
+ ].join('\n');
554
+ }
555
+
556
+ module.exports = {
557
+ LauncherError,
558
+ TARGETS,
559
+ resolveTarget,
560
+ hostTarget,
561
+ cacheRoot,
562
+ parseBase,
563
+ checkRedirect,
564
+ redact,
565
+ expectedDigest,
566
+ unsafeEntry,
567
+ specialEntry,
568
+ acquireLock,
569
+ ensureBinary,
570
+ formatError,
571
+ };
@@ -0,0 +1,46 @@
1
+ 'use strict';
2
+ // Optional pre-fetch of the release binary at install time, so the first MCP
3
+ // start does not wait for a download. It never fails the install: every error
4
+ // becomes a warning on stderr and the launcher retries on first run.
5
+ //
6
+ // Skipped when scripts are disabled (npm_config_ignore_scripts), in CI (CI is
7
+ // set), when CROSSMATRIX_MCP_BINARY names a local binary, or when
8
+ // CROSSMATRIX_MCP_SKIP_DOWNLOAD=1.
9
+
10
+ function skipReason(env) {
11
+ if (env.npm_config_ignore_scripts === 'true' || env.npm_config_ignore_scripts === '1') return 'npm_config_ignore_scripts is set';
12
+ if (env.CI && env.CI !== 'false' && env.CI !== '0') return 'CI is set';
13
+ if (env.CROSSMATRIX_MCP_BINARY) return 'CROSSMATRIX_MCP_BINARY is set';
14
+ if (env.CROSSMATRIX_MCP_SKIP_DOWNLOAD === '1') return 'CROSSMATRIX_MCP_SKIP_DOWNLOAD=1';
15
+ return null;
16
+ }
17
+
18
+ function warn(msg) {
19
+ try {
20
+ process.stderr.write(`crossmatrix-mcp (npm postinstall): ${msg}\n`);
21
+ } catch {
22
+ // stderr closed; nothing else to do
23
+ }
24
+ }
25
+
26
+ // Runs the pre-fetch unless skipReason() says not to. `ensure` is injectable
27
+ // for tests. Resolves to the skip reason, or null when a pre-fetch was tried.
28
+ async function main(env = process.env, ensure = null) {
29
+ const reason = skipReason(env);
30
+ if (reason) return reason;
31
+ try {
32
+ const run = ensure || require('./install.js').ensureBinary;
33
+ await run({ env, log: warn });
34
+ } catch (err) {
35
+ warn(`pre-fetch skipped (${err && err.message ? err.message : err}); the binary will be downloaded on first run`);
36
+ }
37
+ return null;
38
+ }
39
+
40
+ if (require.main === module) {
41
+ main().finally(() => {
42
+ process.exitCode = 0;
43
+ });
44
+ }
45
+
46
+ module.exports = { skipReason, main };
@@ -0,0 +1,50 @@
1
+ 'use strict';
2
+ // Copies the repository's ../skills tree into npm/skills/ so the bundled
3
+ // skills ship inside the published package. Runs on `prepack`; the copy is a
4
+ // build product and is git-ignored.
5
+ //
6
+ // Only SKILL.md and any *.md/*.json/*.toml files beside them are copied, and
7
+ // symlinks are never followed (a symlinked directory is skipped rather than
8
+ // walked). Stale copies are removed first so a deleted skill does not linger.
9
+
10
+ const fs = require('node:fs');
11
+ const path = require('node:path');
12
+
13
+ const SRC = path.resolve(__dirname, '..', '..', 'skills');
14
+ const DEST = path.resolve(__dirname, '..', 'skills');
15
+
16
+ function isCopyable(name) {
17
+ return /\.(md|json|toml)$/i.test(name);
18
+ }
19
+
20
+ function copyTree(rel, copied) {
21
+ const abs = path.join(SRC, rel);
22
+ for (const entry of fs.readdirSync(abs, { withFileTypes: true })) {
23
+ const entryRel = rel ? path.join(rel, entry.name) : entry.name;
24
+ const entryAbs = path.join(SRC, entryRel);
25
+ const st = fs.lstatSync(entryAbs);
26
+ if (st.isSymbolicLink()) continue; // never follow links
27
+ if (st.isDirectory()) {
28
+ copyTree(entryRel, copied);
29
+ continue;
30
+ }
31
+ if (!st.isFile() || !isCopyable(entry.name)) continue;
32
+ const destFile = path.join(DEST, entryRel);
33
+ fs.mkdirSync(path.dirname(destFile), { recursive: true });
34
+ fs.copyFileSync(entryAbs, destFile);
35
+ copied.push(entryRel);
36
+ }
37
+ }
38
+
39
+ // Rebuilds npm/skills/ from ../skills. Returns the relative paths copied.
40
+ function sync() {
41
+ fs.rmSync(DEST, { recursive: true, force: true });
42
+ fs.mkdirSync(DEST, { recursive: true });
43
+ const copied = [];
44
+ if (fs.existsSync(SRC)) copyTree('', copied);
45
+ return copied;
46
+ }
47
+
48
+ if (require.main === module) sync();
49
+
50
+ module.exports = { sync };