@getmarrow/install 0.1.69 → 0.1.70
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/README.md +16 -8
- package/package.json +1 -1
- package/src/governed-runner.js +3 -3
- package/src/installer.js +121 -63
- package/src/marrow-own-tools.js +121 -0
- package/src/mcp-runtime.js +361 -309
- package/src/pins.js +3 -3
- package/src/runtime-core.js +594 -0
package/src/mcp-runtime.js
CHANGED
|
@@ -1,37 +1,43 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
// A private local copy of the pinned @getmarrow/mcp, so hooks start it directly instead of
|
|
4
|
-
// through npx (npx costs 0.6 to 1.3 s per gated tool call before Marrow is contacted
|
|
4
|
+
// through npx (npx costs 0.6 to 1.3 s per gated tool call before Marrow is contacted, and it
|
|
5
|
+
// resolves the package from the project directory first: a copy planted in the project's
|
|
6
|
+
// node_modules, or a project npm config naming another registry, would decide every hook).
|
|
5
7
|
//
|
|
6
8
|
// Layout (all owner-only, directories 0700):
|
|
7
9
|
// ~/.marrow/runtime/mcp/<version>/node_modules/... the package, installed with --ignore-scripts
|
|
8
10
|
// ~/.marrow/runtime/mcp/<version>/.verified integrity record (0600)
|
|
9
|
-
// ~/.marrow/runtime/mcp/<version>/launch.cjs checks the files, then runs the entrypoint
|
|
10
|
-
// ~/.marrow/runtime/mcp/<version>/run /bin/sh script: absolute node + launch.cjs
|
|
11
11
|
// ~/.marrow/runtime/mcp/<version>/node link to the node binary captured at install
|
|
12
|
+
// ~/.marrow/runtime/mcp/.heal-<version>.lock a hook's self-heal in progress (one per user)
|
|
13
|
+
// ~/.marrow/runtime/mcp/.heal-<version>-<sha256 of the project's real path>.failed|.tried
|
|
14
|
+
// that project's last failed heal / last heal start
|
|
15
|
+
// (On Windows there is no node link.)
|
|
12
16
|
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
17
|
+
// Every hook command carries the runtime loader inline (runtime-core.js, compressed): it checks
|
|
18
|
+
// the installed files against the record and runs the entry. When the copy is missing or changed,
|
|
19
|
+
// it installs the pinned version itself, with the installer's own rules and the pinned integrity
|
|
20
|
+
// it carries. When that fails for an outage (offline, registry down, the wait ran out) a
|
|
21
|
+
// pre-action hook warns and lets the call run (exit 1, as npx failing did); any other failure,
|
|
22
|
+
// such as an integrity mismatch, holds it (exit 2 with a fixed repair message; every other
|
|
23
|
+
// entrypoint exits 1). Nothing ever reads the project or starts npx.
|
|
24
|
+
// Hook commands reach the runtime through the home directory, so project hook files stay the same
|
|
25
|
+
// for every user and name no local path.
|
|
18
26
|
|
|
19
|
-
const crypto = require('node:crypto');
|
|
20
27
|
const fs = require('node:fs');
|
|
21
28
|
const path = require('node:path');
|
|
22
|
-
const
|
|
29
|
+
const zlib = require('node:zlib');
|
|
30
|
+
const PINS = require('./pins');
|
|
31
|
+
const { runtimeCore } = require('./runtime-core');
|
|
23
32
|
|
|
24
|
-
const
|
|
33
|
+
const core = runtimeCore();
|
|
34
|
+
const RUNTIME_SCHEMA = core.SCHEMA;
|
|
35
|
+
const HOLD_TEXT = core.HOLD;
|
|
36
|
+
const UNAVAILABLE_TEXT = core.UNAVAILABLE;
|
|
37
|
+
const WARN_TEXT = core.WARN;
|
|
38
|
+
const NPM_INSTALL_TIMEOUT_MS = core.NPM_TIMEOUT_MS;
|
|
25
39
|
const VERSION_RE = /^\d+\.\d+\.\d+$/;
|
|
26
|
-
const
|
|
27
|
-
// Paths written into the run script, quoted with single quotes: no quote, backslash or newline.
|
|
28
|
-
const SAFE_PATH_RE = /^[^'\\\n\r\0]+$/;
|
|
29
|
-
// A black-holed registry must not hold an install for minutes: past this, hooks stay on npx.
|
|
30
|
-
const NPM_INSTALL_TIMEOUT_MS = 30_000;
|
|
31
|
-
|
|
32
|
-
function currentUid() {
|
|
33
|
-
return typeof process.getuid === 'function' ? process.getuid() : null;
|
|
34
|
-
}
|
|
40
|
+
const PRE_ACTION_SUB_RE = /-pre-action-hook$/;
|
|
35
41
|
|
|
36
42
|
function runtimeRoot(home) {
|
|
37
43
|
return path.join(home, '.marrow', 'runtime', 'mcp');
|
|
@@ -41,330 +47,256 @@ function runtimeDir(home, version) {
|
|
|
41
47
|
return path.join(runtimeRoot(home), version);
|
|
42
48
|
}
|
|
43
49
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
function ensurePrivateDirectory(directory) {
|
|
47
|
-
if (!fs.existsSync(directory)) fs.mkdirSync(directory, { mode: 0o700 });
|
|
48
|
-
const stat = fs.lstatSync(directory);
|
|
49
|
-
const uid = currentUid();
|
|
50
|
-
if (!stat.isDirectory() || stat.isSymbolicLink() || (uid !== null && stat.uid !== uid)) {
|
|
51
|
-
throw new Error(`unsafe runtime directory ${directory}`);
|
|
52
|
-
}
|
|
53
|
-
if ((stat.mode & 0o077) !== 0) fs.chmodSync(directory, 0o700);
|
|
50
|
+
function failureText(sub) {
|
|
51
|
+
return PRE_ACTION_SUB_RE.test(String(sub || '')) ? HOLD_TEXT : UNAVAILABLE_TEXT;
|
|
54
52
|
}
|
|
55
53
|
|
|
56
|
-
function
|
|
57
|
-
|
|
58
|
-
const stat = fs.lstatSync(directory);
|
|
59
|
-
const uid = currentUid();
|
|
60
|
-
return stat.isDirectory() && !stat.isSymbolicLink() && (uid === null || stat.uid === uid) && (stat.mode & 0o077) === 0;
|
|
61
|
-
} catch {
|
|
62
|
-
return false;
|
|
63
|
-
}
|
|
54
|
+
function failureStatus(sub) {
|
|
55
|
+
return PRE_ACTION_SUB_RE.test(String(sub || '')) ? 2 : 1;
|
|
64
56
|
}
|
|
65
57
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
const rel = relative ? `${relative}/${entry.name}` : entry.name;
|
|
71
|
-
if (rel === 'node_modules/.bin') continue;
|
|
72
|
-
if (entry.isSymbolicLink()) throw new Error(`unexpected link in runtime package: ${rel}`);
|
|
73
|
-
if (entry.isDirectory()) out.push(...listFiles(base, rel));
|
|
74
|
-
else if (entry.isFile()) out.push(rel);
|
|
75
|
-
}
|
|
76
|
-
return out;
|
|
58
|
+
// The runtime for one version, when its record matches the expected integrity and its files
|
|
59
|
+
// still hash to the record. Returns null otherwise (also when no integrity is given).
|
|
60
|
+
function verifyMcpRuntime(home, version, integrity, { platform = process.platform } = {}) {
|
|
61
|
+
return core.verify(home, version, integrity, { platform });
|
|
77
62
|
}
|
|
78
63
|
|
|
79
|
-
//
|
|
80
|
-
//
|
|
81
|
-
|
|
82
|
-
|
|
64
|
+
// Installs (or keeps) the runtime for one MCP version whose tarball integrity is known.
|
|
65
|
+
// `sdk` is the pinned SDK { version, integrity }; when the installed SDK has that version, its
|
|
66
|
+
// integrity must match too. Never throws: a failure leaves hooks to heal it or hold.
|
|
67
|
+
function ensureMcpRuntime(options = {}) {
|
|
68
|
+
return core.ensure({ ...options, prune: true });
|
|
83
69
|
}
|
|
84
70
|
|
|
85
|
-
function
|
|
86
|
-
|
|
87
|
-
for (const file of files) {
|
|
88
|
-
hash.update(file);
|
|
89
|
-
hash.update('\0');
|
|
90
|
-
hash.update(fs.readFileSync(path.join(directory, file)));
|
|
91
|
-
hash.update('\0');
|
|
92
|
-
}
|
|
93
|
-
return hash.digest('hex');
|
|
71
|
+
function defaultNpmInstall(stage, specs, options = {}) {
|
|
72
|
+
return core.npmInstall(stage, specs, options);
|
|
94
73
|
}
|
|
95
74
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
// missing or any of its files changed, the same entrypoint runs through npx instead.
|
|
99
|
-
const fs = require('fs');
|
|
100
|
-
const path = require('path');
|
|
101
|
-
const crypto = require('crypto');
|
|
102
|
-
const args = process.argv.slice(2);
|
|
103
|
-
// Every package file now present, listed as at install: an added file is a change too.
|
|
104
|
-
function listFiles(relative) {
|
|
105
|
-
const out = [];
|
|
106
|
-
const entries = fs.readdirSync(path.join(__dirname, relative), { withFileTypes: true })
|
|
107
|
-
.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
108
|
-
for (const entry of entries) {
|
|
109
|
-
const rel = relative + '/' + entry.name;
|
|
110
|
-
if (rel === 'node_modules/.bin') continue;
|
|
111
|
-
if (entry.isSymbolicLink()) throw new Error('link');
|
|
112
|
-
if (entry.isDirectory()) out.push(...listFiles(rel));
|
|
113
|
-
else if (entry.isFile()) out.push(rel);
|
|
114
|
-
}
|
|
115
|
-
return out;
|
|
75
|
+
function npmInvocation(options = {}) {
|
|
76
|
+
return core.npmInvocation(options);
|
|
116
77
|
}
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
if (record.schema !== ${JSON.stringify(RUNTIME_SCHEMA)}) return null;
|
|
121
|
-
if (args[0] !== '--package=@getmarrow/mcp@' + record.version || args[1] !== 'marrow-mcp') return null;
|
|
122
|
-
const present = listFiles('node_modules').filter((file) => file !== 'node_modules/.package-lock.json');
|
|
123
|
-
if (JSON.stringify(present) !== JSON.stringify(record.files)) return null;
|
|
124
|
-
const hash = crypto.createHash('sha256');
|
|
125
|
-
for (const file of record.files) {
|
|
126
|
-
hash.update(file);
|
|
127
|
-
hash.update('\\0');
|
|
128
|
-
hash.update(fs.readFileSync(path.join(__dirname, file)));
|
|
129
|
-
hash.update('\\0');
|
|
130
|
-
}
|
|
131
|
-
return hash.digest('hex') === record.manifest_sha256 ? path.join(__dirname, record.bin) : null;
|
|
132
|
-
} catch {
|
|
133
|
-
return null;
|
|
134
|
-
}
|
|
135
|
-
}
|
|
136
|
-
const entry = verifiedEntry();
|
|
137
|
-
if (entry) {
|
|
138
|
-
process.argv = [process.argv[0], entry, ...args.slice(2)];
|
|
139
|
-
require(entry);
|
|
140
|
-
} else {
|
|
141
|
-
const { spawn } = require('child_process');
|
|
142
|
-
const child = spawn(process.platform === 'win32' ? 'npx.cmd' : 'npx', ['-y', ...args], { stdio: 'inherit' });
|
|
143
|
-
child.on('error', () => process.exit(1));
|
|
144
|
-
child.on('exit', (code, signal) => {
|
|
145
|
-
if (signal) process.kill(process.pid, signal);
|
|
146
|
-
else process.exit(code === null ? 1 : code);
|
|
147
|
-
});
|
|
148
|
-
}
|
|
149
|
-
`;
|
|
150
|
-
|
|
151
|
-
function runScript(nodePath, launcherPath) {
|
|
152
|
-
return [
|
|
153
|
-
'#!/bin/sh',
|
|
154
|
-
'# Written by @getmarrow/install: the verified local @getmarrow/mcp, or npx when it is gone.',
|
|
155
|
-
`if [ -x '${nodePath}' ] && [ -f '${launcherPath}' ]; then exec '${nodePath}' '${launcherPath}' "$@"; fi`,
|
|
156
|
-
'exec npx -y "$@"',
|
|
157
|
-
'',
|
|
158
|
-
].join('\n');
|
|
78
|
+
|
|
79
|
+
function npmEnvironment(source = process.env, network = null) {
|
|
80
|
+
return core.npmEnvironment(source, network);
|
|
159
81
|
}
|
|
160
82
|
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
return null;
|
|
169
|
-
}
|
|
83
|
+
// The shared runtime serves every project on this machine, so a project uninstall keeps it;
|
|
84
|
+
// only an explicit full uninstall (`uninstall --all`) removes it.
|
|
85
|
+
function removeMcpRuntime(home, { dryRun = false } = {}) {
|
|
86
|
+
const root = path.dirname(runtimeRoot(home));
|
|
87
|
+
if (!fs.existsSync(root)) return { removed: false, path: root };
|
|
88
|
+
if (!dryRun) fs.rmSync(root, { recursive: true, force: true });
|
|
89
|
+
return { removed: !dryRun, would_remove: dryRun, path: root };
|
|
170
90
|
}
|
|
171
91
|
|
|
172
|
-
//
|
|
173
|
-
//
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
}
|
|
193
|
-
return { state: 'verified', version, directory, run: path.join(directory, 'run'), node: path.join(directory, 'node') };
|
|
92
|
+
// ---------------------------------------------------------------------------
|
|
93
|
+
// The inline loader: the core's text with the pinned version, integrity and SDK pin, compressed
|
|
94
|
+
// and base64url-encoded inside regex literals, so it contains no quote, $, %, !, backslash or
|
|
95
|
+
// backtick and every hook form (sh single or double quotes, cmd, PowerShell, a JS string) can
|
|
96
|
+
// carry it literally.
|
|
97
|
+
// ---------------------------------------------------------------------------
|
|
98
|
+
|
|
99
|
+
const CORE_SOURCE = runtimeCore.toString().split('\n').map((line) => line.trim()).filter(Boolean).join('\n');
|
|
100
|
+
const LOADER_RE = /eval\(global\.MARROW_L=require\(\/node:zlib\/\.source\)\.brotliDecompressSync\(Buffer\.from\(\/([A-Za-z0-9_-]{16,16000})\/\.source,\/base64url\/\.source\)\)\+\[\]\)/;
|
|
101
|
+
const LOADER_PATTERN = LOADER_RE.source;
|
|
102
|
+
// The same pattern with no capture group, for use inside the command patterns below.
|
|
103
|
+
const LOADER_NC = LOADER_PATTERN.replace('([A-Za-z0-9_-]', '(?:[A-Za-z0-9_-]');
|
|
104
|
+
|
|
105
|
+
function runtimeConfig(version, { integrity, sdk } = {}) {
|
|
106
|
+
const pinned = version === PINS.MCP_ADAPTER_VERSION;
|
|
107
|
+
return core.configOf(
|
|
108
|
+
version,
|
|
109
|
+
integrity === undefined ? (pinned ? PINS.MCP_ADAPTER_INTEGRITY : null) : integrity,
|
|
110
|
+
sdk === undefined ? { version: PINS.SDK_ADAPTER_VERSION, integrity: PINS.SDK_ADAPTER_INTEGRITY } : sdk,
|
|
111
|
+
);
|
|
194
112
|
}
|
|
195
113
|
|
|
196
|
-
function
|
|
197
|
-
|
|
198
|
-
const result = spawnSync(npm, ['install', '--no-audit', '--no-fund', '--ignore-scripts', '--no-bin-links', '--loglevel=error', ...specs], {
|
|
199
|
-
cwd: stage,
|
|
200
|
-
env: process.env,
|
|
201
|
-
stdio: 'ignore',
|
|
202
|
-
timeout: NPM_INSTALL_TIMEOUT_MS,
|
|
203
|
-
});
|
|
204
|
-
return result.status === 0;
|
|
114
|
+
function launcherSource(cfg) {
|
|
115
|
+
return `'use strict';\nconst CFG = ${JSON.stringify(cfg)};\n(${CORE_SOURCE})().main(CFG, global.MARROW_L);\n`;
|
|
205
116
|
}
|
|
206
117
|
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
118
|
+
const loaderCache = new Map();
|
|
119
|
+
function loaderFor(cfg) {
|
|
120
|
+
const source = launcherSource(cfg);
|
|
121
|
+
if (!loaderCache.has(source)) {
|
|
122
|
+
const packed = zlib.brotliCompressSync(Buffer.from(source), {
|
|
123
|
+
params: { [zlib.constants.BROTLI_PARAM_MODE]: zlib.constants.BROTLI_MODE_TEXT, [zlib.constants.BROTLI_PARAM_QUALITY]: 11 },
|
|
124
|
+
}).toString('base64url');
|
|
125
|
+
loaderCache.set(source, `eval(global.MARROW_L=require(/node:zlib/.source).brotliDecompressSync(Buffer.from(/${packed}/.source,/base64url/.source))+[])`);
|
|
212
126
|
}
|
|
127
|
+
return loaderCache.get(source);
|
|
213
128
|
}
|
|
214
129
|
|
|
215
|
-
//
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
130
|
+
// The configuration a loader carries, only when it decodes to exactly the launcher this module
|
|
131
|
+
// writes for that configuration (compressed bytes may differ between zlib builds; the code that
|
|
132
|
+
// runs may not). A write with another configuration (integrity) than the current target's
|
|
133
|
+
// replaces the command.
|
|
134
|
+
function loaderConfig(loader) {
|
|
135
|
+
const match = String(loader || '').match(new RegExp(`^${LOADER_PATTERN}$`));
|
|
136
|
+
if (!match) return null;
|
|
137
|
+
let source;
|
|
220
138
|
try {
|
|
221
|
-
|
|
139
|
+
source = zlib.brotliDecompressSync(Buffer.from(match[1], 'base64url'), { maxOutputLength: 1 << 17 }).toString('utf8');
|
|
222
140
|
} catch {
|
|
223
|
-
return
|
|
224
|
-
}
|
|
225
|
-
for (const entry of entries) {
|
|
226
|
-
if (entry === keep) continue;
|
|
227
|
-
removeQuietly(path.join(root, entry));
|
|
228
|
-
removed.push(entry);
|
|
229
|
-
}
|
|
230
|
-
return removed;
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
// Installs (or keeps) the runtime for one MCP version whose tarball integrity is known.
|
|
234
|
-
// `sdk` is the pinned SDK { version, integrity }; when the installed SDK has that version, its
|
|
235
|
-
// integrity must match too. Never throws: a failure leaves hooks on npx.
|
|
236
|
-
function ensureMcpRuntime({ home, version, integrity, sdk = null, nodePath = process.execPath, npmInstall = defaultNpmInstall, platform = process.platform, onProgress = null } = {}) {
|
|
237
|
-
if (platform === 'win32') return { state: 'unsupported', reason: 'windows' };
|
|
238
|
-
if (!home || !VERSION_RE.test(String(version || '')) || !INTEGRITY_RE.test(String(integrity || ''))) {
|
|
239
|
-
return { state: 'skipped', reason: 'no_verified_integrity' };
|
|
240
|
-
}
|
|
241
|
-
const existing = verifyMcpRuntime(home, version, integrity);
|
|
242
|
-
if (existing) {
|
|
243
|
-
const removed = removeOtherRuntimes(home, version);
|
|
244
|
-
return { ...existing, state: 'present', removed_versions: removed };
|
|
245
|
-
}
|
|
246
|
-
const directory = runtimeDir(home, version);
|
|
247
|
-
if (!SAFE_PATH_RE.test(directory) || !SAFE_PATH_RE.test(String(nodePath || '')) || !path.isAbsolute(String(nodePath || ''))) {
|
|
248
|
-
return { state: 'skipped', reason: 'unsafe_path' };
|
|
141
|
+
return null;
|
|
249
142
|
}
|
|
250
|
-
|
|
143
|
+
const line = source.split('\n')[1] || '';
|
|
144
|
+
if (!line.startsWith('const CFG = ') || !line.endsWith(';')) return null;
|
|
145
|
+
let parsed;
|
|
251
146
|
try {
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
stage = path.join(runtimeRoot(home), `.stage-${version}-${process.pid}-${crypto.randomBytes(4).toString('hex')}`);
|
|
256
|
-
fs.mkdirSync(stage, { mode: 0o700 });
|
|
257
|
-
const dependencies = { '@getmarrow/mcp': version };
|
|
258
|
-
if (sdk && VERSION_RE.test(String(sdk.version || ''))) dependencies['@getmarrow/sdk'] = sdk.version;
|
|
259
|
-
fs.writeFileSync(path.join(stage, 'package.json'), `${JSON.stringify({ name: 'marrow-mcp-runtime', private: true, dependencies }, null, 2)}\n`, { mode: 0o600 });
|
|
260
|
-
const specs = Object.entries(dependencies).map(([name, spec]) => `${name}@${spec}`);
|
|
261
|
-
if (typeof onProgress === 'function') {
|
|
262
|
-
onProgress(`Installing a local copy of @getmarrow/mcp@${version} so hooks start faster (up to ${NPM_INSTALL_TIMEOUT_MS / 1000} s; hooks keep using npx if it does not finish)...\n`);
|
|
263
|
-
}
|
|
264
|
-
if (!npmInstall(stage, specs)) return { state: 'failed', reason: 'npm_install_failed' };
|
|
265
|
-
const lock = JSON.parse(fs.readFileSync(path.join(stage, 'package-lock.json'), 'utf8'));
|
|
266
|
-
const packages = lock && typeof lock.packages === 'object' && lock.packages ? lock.packages : {};
|
|
267
|
-
const mcp = packages['node_modules/@getmarrow/mcp'] || {};
|
|
268
|
-
if (mcp.version !== version || mcp.integrity !== integrity) return { state: 'failed', reason: 'integrity_mismatch' };
|
|
269
|
-
const sdkEntry = packages['node_modules/@getmarrow/sdk'];
|
|
270
|
-
if (sdk && sdkEntry && sdkEntry.version === sdk.version && sdkEntry.integrity !== sdk.integrity) {
|
|
271
|
-
return { state: 'failed', reason: 'sdk_integrity_mismatch' };
|
|
272
|
-
}
|
|
273
|
-
for (const [key, entry] of Object.entries(packages)) {
|
|
274
|
-
if (key === '') continue;
|
|
275
|
-
if (!entry || typeof entry.integrity !== 'string' || !/^sha512-/.test(entry.integrity)) return { state: 'failed', reason: 'unverified_dependency' };
|
|
276
|
-
}
|
|
277
|
-
const installed = JSON.parse(fs.readFileSync(path.join(stage, 'node_modules', '@getmarrow', 'mcp', 'package.json'), 'utf8'));
|
|
278
|
-
if (installed.name !== '@getmarrow/mcp' || installed.version !== version) return { state: 'failed', reason: 'package_mismatch' };
|
|
279
|
-
const binRelative = typeof installed.bin === 'object' && installed.bin ? installed.bin['marrow-mcp'] : null;
|
|
280
|
-
if (typeof binRelative !== 'string' || !/^[A-Za-z0-9._/-]+$/.test(binRelative) || binRelative.includes('..')) {
|
|
281
|
-
return { state: 'failed', reason: 'package_entry_missing' };
|
|
282
|
-
}
|
|
283
|
-
const bin = `node_modules/@getmarrow/mcp/${binRelative.replace(/^\.\//, '')}`;
|
|
284
|
-
const files = packageFiles(stage);
|
|
285
|
-
if (!files.includes(bin)) return { state: 'failed', reason: 'package_entry_missing' };
|
|
286
|
-
const launcher = path.join(directory, 'launch.cjs');
|
|
287
|
-
fs.writeFileSync(path.join(stage, 'launch.cjs'), LAUNCHER_SOURCE, { mode: 0o600 });
|
|
288
|
-
fs.writeFileSync(path.join(stage, 'run'), runScript(nodePath, launcher), { mode: 0o700 });
|
|
289
|
-
fs.symlinkSync(nodePath, path.join(stage, 'node'));
|
|
290
|
-
fs.writeFileSync(path.join(stage, '.verified'), `${JSON.stringify({
|
|
291
|
-
schema: RUNTIME_SCHEMA,
|
|
292
|
-
version,
|
|
293
|
-
integrity,
|
|
294
|
-
sdk_version: sdkEntry ? sdkEntry.version : null,
|
|
295
|
-
bin,
|
|
296
|
-
files,
|
|
297
|
-
manifest_sha256: manifestDigest(stage, files),
|
|
298
|
-
installed_at: new Date().toISOString(),
|
|
299
|
-
}, null, 2)}\n`, { mode: 0o600 });
|
|
300
|
-
removeQuietly(directory);
|
|
301
|
-
fs.renameSync(stage, directory);
|
|
302
|
-
stage = null;
|
|
303
|
-
const verified = verifyMcpRuntime(home, version, integrity);
|
|
304
|
-
if (!verified) return { state: 'failed', reason: 'verification_failed' };
|
|
305
|
-
const removed = removeOtherRuntimes(home, version);
|
|
306
|
-
return { ...verified, state: 'installed', removed_versions: removed };
|
|
307
|
-
} catch (error) {
|
|
308
|
-
return { state: 'failed', reason: error && /unsafe runtime directory/.test(error.message) ? 'unsafe_directory' : 'install_error' };
|
|
309
|
-
} finally {
|
|
310
|
-
if (stage) removeQuietly(stage);
|
|
147
|
+
parsed = JSON.parse(line.slice('const CFG = '.length, -1));
|
|
148
|
+
} catch {
|
|
149
|
+
return null;
|
|
311
150
|
}
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
const root = path.dirname(runtimeRoot(home));
|
|
316
|
-
if (!fs.existsSync(root)) return { removed: false, path: root };
|
|
317
|
-
if (!dryRun) removeQuietly(root);
|
|
318
|
-
return { removed: !dryRun, would_remove: dryRun, path: root };
|
|
151
|
+
if (!parsed || typeof parsed !== 'object' || !VERSION_RE.test(String(parsed.version || ''))) return null;
|
|
152
|
+
const cfg = core.configOf(parsed.version, parsed.integrity, parsed.sdk);
|
|
153
|
+
return launcherSource(cfg) === source ? cfg : null;
|
|
319
154
|
}
|
|
320
155
|
|
|
321
156
|
// ---------------------------------------------------------------------------
|
|
322
|
-
// Hook commands: the canonical npx form
|
|
157
|
+
// Hook commands: the canonical npx form (the one spelling every check and merge reads) <-> the
|
|
158
|
+
// written local-runtime form. Written hooks never start npx: the canonical form exists only in
|
|
159
|
+
// memory. Earlier local forms (0.1.66-0.1.68, which fell back to npx) still read as Marrow's own
|
|
160
|
+
// so that repair rewrites them.
|
|
323
161
|
// ---------------------------------------------------------------------------
|
|
324
162
|
|
|
325
|
-
const
|
|
163
|
+
const V = '(\\d+\\.\\d+\\.\\d+)';
|
|
164
|
+
const SUB = '([a-z][a-z-]{0,63})';
|
|
165
|
+
const ARGS = `--package=@getmarrow/mcp@${V} marrow-mcp ${SUB}`;
|
|
166
|
+
const canonicalEntrypoint = (version, sub) => `npx -y --package=@getmarrow/mcp@${version} marrow-mcp ${sub}`;
|
|
326
167
|
const RUN = (version) => `$HOME/.marrow/runtime/mcp/${version}/run`;
|
|
327
|
-
const NODE = (version) => `$HOME/.marrow/runtime/mcp/${version}/node`;
|
|
328
168
|
|
|
329
|
-
//
|
|
330
|
-
function
|
|
331
|
-
|
|
332
|
-
|
|
169
|
+
// Each written form is built from its parts by one function, and the pattern that recognizes it
|
|
170
|
+
// is generated from the same function (named groups for the version, entrypoint and loader; a
|
|
171
|
+
// repeated part must repeat exactly), so the writer and the reader cannot drift apart.
|
|
172
|
+
const SENTINEL = { version: '\u0001V\u0001', sub: '\u0001S\u0001', loader: '\u0001L\u0001', text: '\u0001T\u0001', status: '\u0001X\u0001' };
|
|
173
|
+
function formPattern(build) {
|
|
174
|
+
const seen = new Set();
|
|
175
|
+
const parts = { V: ['v', '\\d+\\.\\d+\\.\\d+'], S: ['s', '[a-z][a-z-]{0,63}'], L: ['l', LOADER_NC] };
|
|
176
|
+
return build(SENTINEL).replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(/\u0001([VSLTX])\u0001/g, (match, key) => {
|
|
177
|
+
if (key === 'T') return '[^"\']*';
|
|
178
|
+
if (key === 'X') return '[12]';
|
|
179
|
+
const [name, pattern] = parts[key];
|
|
180
|
+
if (seen.has(name)) return `\\k<${name}>`;
|
|
181
|
+
seen.add(name);
|
|
182
|
+
return `(?<${name}>${pattern})`;
|
|
183
|
+
});
|
|
333
184
|
}
|
|
334
|
-
const
|
|
185
|
+
const partsFor = (version, sub, cfg) => ({ version, sub, loader: loaderFor(cfg), text: failureText(sub), status: failureStatus(sub) });
|
|
186
|
+
const argsOf = (p) => `--package=@getmarrow/mcp@${p.version} marrow-mcp ${p.sub}`;
|
|
187
|
+
// Which node runs the loader (POSIX): the one captured in the private runtime when it exists, else
|
|
188
|
+
// node on PATH or in the usual absolute places, but never a relative path or one inside the
|
|
189
|
+
// current (project) directory, so a project cannot plant the node that checks it.
|
|
190
|
+
const nodeSearch = (p, found) => `N="$HOME/.marrow/runtime/mcp/${p.version}/node"; if [ ! -x "$N" ]; then N=; P="$PATH:/usr/local/bin:/opt/homebrew/bin"; while [ -n "$P" ]; do D="\${P%%:*}"; case "$P" in (*:*) P="\${P#*:}" ;; (*) P= ;; esac; case "$D" in ("$PWD"|"$PWD"/*) ;; (/*) if [ -f "$D/node" ] && [ -x "$D/node" ]; then N="$D/node"; break; fi ;; esac; done; fi; ${found}`;
|
|
191
|
+
|
|
192
|
+
// 1. A plain entrypoint command. POSIX: /bin/sh picks node as above and runs the loader, passed as
|
|
193
|
+
// $0. Windows: node on the loader, quoted so cmd, PowerShell and sh all read it the same way. No
|
|
194
|
+
// usable node: the fixed text with the hold (2) or failure (1) status.
|
|
195
|
+
const plainPosix = (p) => `/bin/sh -c '${nodeSearch(p, `if [ -n "$N" ]; then exec "$N" -e "$0" -- ${argsOf(p)}; fi;`)} printf "%s\\n" "${p.text}" >&2; exit ${p.status}' "${p.loader}"`;
|
|
196
|
+
const plainWindows = (p) => `node -e "${p.loader}" -- ${argsOf(p)}`;
|
|
197
|
+
function localPlain(version, sub, platform, cfg = runtimeConfig(version)) {
|
|
198
|
+
return (platform === 'win32' ? plainWindows : plainPosix)(partsFor(version, sub, cfg));
|
|
199
|
+
}
|
|
200
|
+
const PLAIN_RE = new RegExp(`^${formPattern(plainPosix)}$`);
|
|
201
|
+
const WINDOWS_PLAIN_RE = new RegExp(`^${formPattern(plainWindows)}$`);
|
|
202
|
+
const LEGACY_PLAIN_RE = new RegExp(`^/bin/sh -c 'M="\\$HOME/\\.marrow/runtime/mcp/${V}/run"; if \\[ -x "\\$M" \\]; then exec "\\$M" ${ARGS}; fi; exec npx -y --package=@getmarrow/mcp@\\2 marrow-mcp \\3'$`);
|
|
335
203
|
const CANONICAL_PLAIN_RE = new RegExp(`^npx -y ${ARGS}$`);
|
|
336
204
|
|
|
337
|
-
// 2. An entrypoint inside an `sh -c '...'` wrapper (Windsurf, Gemini)
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
205
|
+
// 2. An entrypoint inside an `sh -c '...'` wrapper (Windsurf, Gemini, every platform) or a Cline
|
|
206
|
+
// hook script: the same node search and loader, or the fixed text with the hold or failure status.
|
|
207
|
+
const innerForm = (p) => `{ ${nodeSearch(p, `if [ -n "$N" ]; then "$N" -e "${p.loader}" -- ${argsOf(p)}; else printf "%s\\n" "${p.text}" >&2; (exit ${p.status}); fi; }`)}`;
|
|
208
|
+
function localInner(version, sub, cfg = runtimeConfig(version)) {
|
|
209
|
+
return innerForm(partsFor(version, sub, cfg));
|
|
341
210
|
}
|
|
342
|
-
const
|
|
211
|
+
const INNER_RE = new RegExp(formPattern(innerForm), 'g');
|
|
212
|
+
const LEGACY_INNER_RE = new RegExp(`\\{ M="\\$HOME/\\.marrow/runtime/mcp/${V}/run"; if \\[ -x "\\$M" \\]; then "\\$M" ${ARGS}; else npx -y --package=@getmarrow/mcp@\\2 marrow-mcp \\3; fi; \\}`, 'g');
|
|
343
213
|
const CANONICAL_INNER_RE = new RegExp(`npx -y ${ARGS}`, 'g');
|
|
344
214
|
|
|
345
|
-
// 3. A node guard (`node -e '...'`) that spawns the entrypoint
|
|
215
|
+
// 3. A node guard (`node -e '...'`) that spawns the entrypoint: the guard's own node on the
|
|
216
|
+
// loader. A failed child is what every guard already treats as its own launch failure (it
|
|
217
|
+
// blocks, or stays neutral).
|
|
346
218
|
const SPAWN_NPX = 'spawn(process.platform==="win32"?"npx.cmd":"npx",';
|
|
347
|
-
function localSpawn(version,
|
|
348
|
-
return `spawn(
|
|
219
|
+
function localSpawn(version, sub, cfg = runtimeConfig(version)) {
|
|
220
|
+
return `spawn(process.execPath,["-e","${loaderFor(cfg)}","--","--package=@getmarrow/mcp@${version}","marrow-mcp","${sub}"],`;
|
|
349
221
|
}
|
|
350
|
-
const
|
|
351
|
-
const
|
|
352
|
-
const
|
|
353
|
-
const
|
|
222
|
+
const SPAWN_RE = new RegExp(`spawn\\(process\\.execPath,\\["-e","(${LOADER_NC})","--","--package=@getmarrow/mcp@(\\d+\\.\\d+\\.\\d+)","marrow-mcp","([a-z-]+)"\\],`, 'g');
|
|
223
|
+
const LEGACY_SPAWN_RE = /spawn\(\.\.\.\(\(f,m,a\)=>f\.existsSync\(m\)\?\[m,a\.slice\(1\)\]:\[process\.platform==="win32"\?"npx\.cmd":"npx",a\]\)\(require\("node:fs"\),\(process\.env\.HOME\|\|""\)\+"\/\.marrow\/runtime\/mcp\/\d+\.\d+\.\d+\/run",(\["-y","--package=@getmarrow\/mcp@\d+\.\d+\.\d+","marrow-mcp","[a-z-]+"\])\),/g;
|
|
224
|
+
const LEGACY_SPAWN_ONE_RE = new RegExp(LEGACY_SPAWN_RE.source);
|
|
225
|
+
const CANONICAL_SPAWN_RE = /spawn\(process\.platform==="win32"\?"npx\.cmd":"npx",\["-y","--package=@getmarrow\/mcp@(\d+\.\d+\.\d+)","marrow-mcp","([a-z-]+)"\],/g;
|
|
226
|
+
// The POSIX guard runs on node found as above (none: a path that fails, as a missing node did).
|
|
227
|
+
const guardPrefixForm = (p) => nodeSearch(p, `[ -n "$N" ] || N=/nonexistent/marrow-node; exec "$N" -e '`);
|
|
228
|
+
const localGuardPrefix = (version) => guardPrefixForm({ version });
|
|
229
|
+
const LOCAL_GUARD_PREFIX_RE = new RegExp(`^${formPattern(guardPrefixForm)}`);
|
|
230
|
+
const LEGACY_GUARD_SPAWN_HINT = 'spawn(...((f,m,a)=>f.existsSync(m)';
|
|
354
231
|
|
|
355
|
-
// The
|
|
232
|
+
// The command with each valid loader in it spelled as this module compresses it, so a command
|
|
233
|
+
// written by another zlib build compares equal to its exact rebuild.
|
|
234
|
+
function withCanonicalLoaders(command, cfg) {
|
|
235
|
+
return command.replace(new RegExp(LOADER_PATTERN, 'g'), (loader) => (loaderConfig(loader) ? loaderFor(cfg) : loader));
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// The configuration of every loader in a command, when all are valid and carry the same one.
|
|
239
|
+
function commandConfig(loaders) {
|
|
240
|
+
let cfg = null;
|
|
241
|
+
for (const loader of loaders) {
|
|
242
|
+
const found = loaderConfig(loader);
|
|
243
|
+
if (!found || (cfg && JSON.stringify(found) !== JSON.stringify(cfg))) return null;
|
|
244
|
+
cfg = found;
|
|
245
|
+
}
|
|
246
|
+
return cfg;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// The canonical (npx) form of a hook command; any other command is returned unchanged. Only a
|
|
250
|
+
// command that the current writer would produce again exactly (or an earlier writer's local
|
|
251
|
+
// form) maps back, so an edited command never reads as Marrow's own.
|
|
356
252
|
function delocalizeHookCommand(command) {
|
|
357
253
|
if (typeof command !== 'string') return command;
|
|
358
|
-
const
|
|
359
|
-
if (
|
|
360
|
-
|
|
361
|
-
|
|
254
|
+
const legacyPlain = command.match(LEGACY_PLAIN_RE);
|
|
255
|
+
if (legacyPlain && legacyPlain[1] === legacyPlain[2]) return canonicalEntrypoint(legacyPlain[2], legacyPlain[3]);
|
|
256
|
+
for (const [pattern, platform] of [[PLAIN_RE, 'posix'], [WINDOWS_PLAIN_RE, 'win32']]) {
|
|
257
|
+
const found = command.match(pattern);
|
|
258
|
+
if (!found) continue;
|
|
259
|
+
const { v, s, l } = found.groups;
|
|
260
|
+
const cfg = commandConfig([l]);
|
|
261
|
+
return cfg && cfg.version === v && localPlain(v, s, platform, cfg) === withCanonicalLoaders(command, cfg) ? canonicalEntrypoint(v, s) : command;
|
|
262
|
+
}
|
|
263
|
+
const prefixed = LOCAL_GUARD_PREFIX_RE.test(command);
|
|
264
|
+
if (prefixed || (command.startsWith('node -e \'') && (command.includes('spawn(process.execPath,["-e","') || command.includes(LEGACY_GUARD_SPAWN_HINT)))) {
|
|
265
|
+
const body = prefixed ? command.replace(LOCAL_GUARD_PREFIX_RE, 'node -e \'') : command;
|
|
266
|
+
if (LEGACY_SPAWN_ONE_RE.test(body)) {
|
|
267
|
+
const canonical = body.replace(LEGACY_SPAWN_RE, `${SPAWN_NPX}$1,`);
|
|
268
|
+
return canonical === body ? command : canonical;
|
|
269
|
+
}
|
|
270
|
+
const loaders = [];
|
|
271
|
+
const canonical = body.replace(SPAWN_RE, (match, loader, version, sub) => {
|
|
272
|
+
loaders.push(loader);
|
|
273
|
+
return `${SPAWN_NPX}${JSON.stringify(['-y', `--package=@getmarrow/mcp@${version}`, 'marrow-mcp', sub])},`;
|
|
274
|
+
});
|
|
275
|
+
if (canonical === body) return command;
|
|
276
|
+
const cfg = commandConfig(loaders);
|
|
277
|
+
const version = (canonical.match(/--package=@getmarrow\/mcp@(\d+\.\d+\.\d+)/) || [])[1];
|
|
278
|
+
// The current guard forms must map back exactly.
|
|
279
|
+
if (!cfg || !version || localizeHookCommand(canonical, version, { platform: prefixed ? 'posix' : 'win32', ...cfg }) !== withCanonicalLoaders(command, cfg)) return command;
|
|
280
|
+
return canonical;
|
|
362
281
|
}
|
|
363
|
-
if (command.startsWith('/bin/sh -c \'') &&
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
282
|
+
if (command.startsWith('/bin/sh -c \'') && (LEGACY_INNER_RE.test(command) || INNER_RE.test(command))) {
|
|
283
|
+
LEGACY_INNER_RE.lastIndex = 0;
|
|
284
|
+
INNER_RE.lastIndex = 0;
|
|
285
|
+
const loaders = [];
|
|
286
|
+
let mismatch = false;
|
|
287
|
+
const inner = command
|
|
288
|
+
.replace(LEGACY_INNER_RE, (match, runVersion, version, sub) => (runVersion === version ? canonicalEntrypoint(version, sub) : match))
|
|
289
|
+
.replace(INNER_RE, (match, ...rest) => {
|
|
290
|
+
const { v: version, s: sub, l: loader } = rest[rest.length - 1];
|
|
291
|
+
const cfg = commandConfig([loader]);
|
|
292
|
+
if (!cfg || cfg.version !== version || localInner(version, sub, cfg) !== withCanonicalLoaders(match, cfg)) {
|
|
293
|
+
mismatch = true;
|
|
294
|
+
return match;
|
|
295
|
+
}
|
|
296
|
+
loaders.push(loader);
|
|
297
|
+
return canonicalEntrypoint(version, sub);
|
|
298
|
+
});
|
|
299
|
+
if (mismatch || (loaders.length && !commandConfig(loaders))) return command;
|
|
368
300
|
return inner === command ? command : `sh -c '${inner.slice('/bin/sh -c \''.length)}`;
|
|
369
301
|
}
|
|
370
302
|
return command;
|
|
@@ -372,35 +304,49 @@ function delocalizeHookCommand(command) {
|
|
|
372
304
|
|
|
373
305
|
// The local runtime form of a canonical hook command for `version`, or the command unchanged
|
|
374
306
|
// when it is not a Marrow entrypoint for that version. The result always maps back exactly.
|
|
375
|
-
|
|
307
|
+
// `integrity` and `sdk` are what the loader installs when it heals (defaults: the pins).
|
|
308
|
+
function localizeHookCommand(command, version, { platform = process.platform, integrity, sdk } = {}) {
|
|
376
309
|
if (typeof command !== 'string' || !VERSION_RE.test(String(version || ''))) return command;
|
|
310
|
+
const windows = platform === 'win32';
|
|
311
|
+
const cfg = runtimeConfig(version, { integrity, sdk });
|
|
377
312
|
let local = command;
|
|
378
313
|
const plain = command.match(CANONICAL_PLAIN_RE);
|
|
379
314
|
if (plain) {
|
|
380
315
|
if (plain[1] !== version) return command;
|
|
381
|
-
local = localPlain(version, plain[2]);
|
|
316
|
+
local = localPlain(version, plain[2], windows ? 'win32' : 'posix', cfg);
|
|
382
317
|
} else if (command.startsWith('node -e \'') && command.includes(SPAWN_NPX)) {
|
|
383
318
|
let matched = false;
|
|
384
|
-
const body = command.slice('node -e \''.length).replace(CANONICAL_SPAWN_RE, (match,
|
|
319
|
+
const body = command.slice('node -e \''.length).replace(CANONICAL_SPAWN_RE, (match, specVersion, sub) => {
|
|
385
320
|
if (specVersion !== version) return match;
|
|
386
321
|
matched = true;
|
|
387
|
-
return localSpawn(version,
|
|
322
|
+
return localSpawn(version, sub, cfg);
|
|
388
323
|
});
|
|
389
324
|
if (!matched) return command;
|
|
390
|
-
local = `${localGuardPrefix(version)}${body}`;
|
|
325
|
+
local = windows ? `node -e '${body}` : `${localGuardPrefix(version)}${body}`;
|
|
391
326
|
} else if (command.startsWith('sh -c \'')) {
|
|
392
327
|
let matched = false;
|
|
393
328
|
const body = command.slice('sh -c \''.length).replace(CANONICAL_INNER_RE, (match, specVersion, sub) => {
|
|
394
329
|
if (specVersion !== version) return match;
|
|
395
330
|
matched = true;
|
|
396
|
-
return localInner(version, sub);
|
|
331
|
+
return localInner(version, sub, cfg);
|
|
397
332
|
});
|
|
398
333
|
if (!matched) return command;
|
|
399
334
|
local = `/bin/sh -c '${body}`;
|
|
400
335
|
} else {
|
|
401
336
|
return command;
|
|
402
337
|
}
|
|
403
|
-
return
|
|
338
|
+
return mapsBack(local, command) ? local : command;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function mapsBack(local, command) {
|
|
342
|
+
// delocalize re-checks guard forms through localize; compare without recursing forever.
|
|
343
|
+
if (local.match(PLAIN_RE) || local.match(WINDOWS_PLAIN_RE) || local.startsWith('/bin/sh -c \'')) return delocalizeHookCommand(local) === command;
|
|
344
|
+
const prefixed = LOCAL_GUARD_PREFIX_RE.test(local);
|
|
345
|
+
const body = prefixed ? local.replace(LOCAL_GUARD_PREFIX_RE, 'node -e \'') : local;
|
|
346
|
+
const canonical = body.replace(SPAWN_RE, (match, loader, version, sub) => (loaderConfig(loader)
|
|
347
|
+
? `${SPAWN_NPX}${JSON.stringify(['-y', `--package=@getmarrow/mcp@${version}`, 'marrow-mcp', sub])},`
|
|
348
|
+
: match));
|
|
349
|
+
return canonical === command;
|
|
404
350
|
}
|
|
405
351
|
|
|
406
352
|
// Every `command` string in a parsed hook settings object.
|
|
@@ -413,8 +359,11 @@ function mapHookCommands(value, map) {
|
|
|
413
359
|
]));
|
|
414
360
|
}
|
|
415
361
|
|
|
416
|
-
// Hook settings text with Marrow's entrypoints for `version` switched to the local runtime.
|
|
417
|
-
|
|
362
|
+
// Hook settings text with Marrow's entrypoints for `version` switched to the local runtime. A
|
|
363
|
+
// command already in `previousText` that maps to the same canonical form and carries the same
|
|
364
|
+
// configuration is kept byte for byte, so a file written on another machine (another zlib
|
|
365
|
+
// build) is not rewritten.
|
|
366
|
+
function localizeHookSettingsText(text, version, { platform = process.platform, integrity, sdk, previousText = null } = {}) {
|
|
418
367
|
if (typeof text !== 'string' || !text.trim()) return text;
|
|
419
368
|
let parsed;
|
|
420
369
|
try {
|
|
@@ -422,25 +371,128 @@ function localizeHookSettingsText(text, version) {
|
|
|
422
371
|
} catch {
|
|
423
372
|
return text;
|
|
424
373
|
}
|
|
374
|
+
const expected = JSON.stringify(runtimeConfig(version, { integrity, sdk }));
|
|
375
|
+
const keep = new Map();
|
|
376
|
+
if (typeof previousText === 'string' && previousText.trim()) {
|
|
377
|
+
try {
|
|
378
|
+
mapHookCommands(JSON.parse(previousText), (command) => {
|
|
379
|
+
const canonical = delocalizeHookCommand(command);
|
|
380
|
+
const loaders = [...command.matchAll(new RegExp(LOADER_PATTERN, 'g'))].map((match) => match[0]);
|
|
381
|
+
const cfg = loaders.length ? commandConfig(loaders) : null;
|
|
382
|
+
if (canonical !== command && cfg && JSON.stringify(cfg) === expected
|
|
383
|
+
&& (platform === 'win32') === command.startsWith('node -e "')) keep.set(canonical, command);
|
|
384
|
+
return command;
|
|
385
|
+
});
|
|
386
|
+
} catch {
|
|
387
|
+
keep.clear();
|
|
388
|
+
}
|
|
389
|
+
}
|
|
425
390
|
let changed = false;
|
|
426
391
|
const mapped = mapHookCommands(parsed, (command) => {
|
|
427
|
-
const local = localizeHookCommand(command, version);
|
|
392
|
+
const local = keep.get(command) || localizeHookCommand(command, version, { platform, integrity, sdk });
|
|
428
393
|
if (local !== command) changed = true;
|
|
429
394
|
return local;
|
|
430
395
|
});
|
|
431
396
|
return changed ? `${JSON.stringify(mapped, null, 2)}\n` : text;
|
|
432
397
|
}
|
|
433
398
|
|
|
399
|
+
// A hook script (Cline) with each canonical entrypoint switched to the inline loader for
|
|
400
|
+
// `version`. Without node the line fails, and each script already treats that as no decision
|
|
401
|
+
// (the pre-action script then cancels the call).
|
|
402
|
+
// The Cline pre-action script checks the runtime's answer with node: written scripts run that
|
|
403
|
+
// check on the node the same search picks (never a node on PATH inside the project); no node
|
|
404
|
+
// gives no decision, so the script cancels.
|
|
405
|
+
const CANONICAL_VALIDATOR_RE = /\| NODE_OPTIONS= node -e '([^'\n]*)' 2>\/dev\/null\)"/g;
|
|
406
|
+
const localValidator = (version, body) => `| { ${nodeSearch({ version }, `[ -n "$N" ] || exit 1; NODE_OPTIONS= "$N" -e '${body}'; }`)} 2>/dev/null)"`;
|
|
407
|
+
const VALIDATOR_RE = new RegExp(localValidator('0.0.0', '\u0000').replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
408
|
+
.replace('0\\.0\\.0', '(\\d+\\.\\d+\\.\\d+)').replace('\u0000', "([^'\\n]*)"), 'g');
|
|
409
|
+
|
|
410
|
+
function localizeHookScriptText(text, version, { integrity, sdk } = {}) {
|
|
411
|
+
if (typeof text !== 'string' || !VERSION_RE.test(String(version || ''))) return text;
|
|
412
|
+
const cfg = runtimeConfig(version, { integrity, sdk });
|
|
413
|
+
return text.replace(CANONICAL_INNER_RE, (match, specVersion, sub) => (
|
|
414
|
+
specVersion === version ? localInner(version, sub, cfg) : match
|
|
415
|
+
)).replace(CANONICAL_VALIDATOR_RE, (match, body) => localValidator(version, body));
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// Earlier Cline script lines ("$HOME/.marrow/runtime/mcp/<v>/run" ...) back to the canonical form.
|
|
419
|
+
function delocalizeHookScriptText(text) {
|
|
420
|
+
if (typeof text !== 'string') return text;
|
|
421
|
+
return text.replace(VALIDATOR_RE, (match, version, body) => `| NODE_OPTIONS= node -e '${body}' 2>/dev/null)"`).replace(INNER_RE, (match, ...rest) => {
|
|
422
|
+
const { v: version, s: sub, l: loader } = rest[rest.length - 1];
|
|
423
|
+
const cfg = commandConfig([loader]);
|
|
424
|
+
return cfg && cfg.version === version && localInner(version, sub, cfg) === withCanonicalLoaders(match, cfg) ? canonicalEntrypoint(version, sub) : match;
|
|
425
|
+
}).replace(/"\$HOME\/\.marrow\/runtime\/mcp\/(\d+\.\d+\.\d+)\/run" --package=@getmarrow\/mcp@(\d+\.\d+\.\d+) marrow-mcp ([a-z][a-z-]{0,63})/g,
|
|
426
|
+
(match, runVersion, version, sub) => (runVersion === version ? canonicalEntrypoint(version, sub) : match));
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// The MCP server entry (.mcp.json, .cursor/mcp.json), started by the host with no shell. POSIX:
|
|
430
|
+
// /bin/sh with the same node search as the hooks, on the loader. Windows: node on the loader.
|
|
431
|
+
// Base and 0.1.68 wrote `npx -y --package=... marrow-mcp`, started from the project directory.
|
|
432
|
+
function mcpServerLaunch(version, { integrity, sdk, platform = process.platform } = {}) {
|
|
433
|
+
const loader = loaderFor(runtimeConfig(version, { integrity, sdk }));
|
|
434
|
+
const tail = [`--package=@getmarrow/mcp@${version}`, 'marrow-mcp'];
|
|
435
|
+
if (platform === 'win32') return { command: 'node', args: ['-e', loader, '--', ...tail] };
|
|
436
|
+
const script = nodeSearch({ version }, `if [ -n "$N" ]; then exec "$N" -e "$0" -- "$@"; fi; printf "%s\\n" "${UNAVAILABLE_TEXT}" >&2; exit 1`);
|
|
437
|
+
return { command: '/bin/sh', args: ['-c', script, loader, ...tail] };
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// The version of a server entry this module wrote (exactly, with a valid loader), else null.
|
|
441
|
+
function mcpServerLaunchVersion(entry) {
|
|
442
|
+
if (!entry || typeof entry !== 'object' || !Array.isArray(entry.args)) return null;
|
|
443
|
+
const args = entry.args.map(String);
|
|
444
|
+
const spec = args.find((arg) => /^--package=@getmarrow\/mcp@\d+\.\d+\.\d+$/.test(arg));
|
|
445
|
+
const loader = args.find((arg) => arg.startsWith('eval(global.MARROW_L='));
|
|
446
|
+
if (!spec || !loader) return null;
|
|
447
|
+
const cfg = loaderConfig(loader);
|
|
448
|
+
const version = spec.slice('--package=@getmarrow/mcp@'.length);
|
|
449
|
+
if (!cfg || cfg.version !== version) return null;
|
|
450
|
+
const written = JSON.stringify({ command: entry.command, args: args.map((arg) => (arg === loader ? loaderFor(cfg) : arg)) });
|
|
451
|
+
return ['linux', 'win32'].some((platform) => JSON.stringify(mcpServerLaunch(version, { ...cfg, platform })) === written) ? version : null;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
// Whether a hook file's text still starts @getmarrow/mcp through npx (the canonical form, an
|
|
455
|
+
// earlier local form with its npx fallback, or a guard that spawns npx): such a file needs repair.
|
|
456
|
+
function hookTextUsesNpx(text) {
|
|
457
|
+
if (typeof text !== 'string' || !text) return false;
|
|
458
|
+
return /npx -y --package=@getmarrow\/mcp@/.test(text) || text.includes('"npx.cmd":"npx"') || text.includes('\\"npx.cmd\\":\\"npx\\"');
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// Whether an MCP server config still starts the Marrow server through npx.
|
|
462
|
+
function mcpServerUsesNpx(config) {
|
|
463
|
+
const entry = config && typeof config === 'object' && config.mcpServers && typeof config.mcpServers === 'object' ? config.mcpServers.marrow : null;
|
|
464
|
+
if (!entry || typeof entry !== 'object') return false;
|
|
465
|
+
const args = Array.isArray(entry.args) ? entry.args.map(String) : [];
|
|
466
|
+
return /(?:^|[\\/])npx(?:\.cmd)?$/.test(String(entry.command || '')) && args.some((arg) => /^--package=@getmarrow\/mcp(?:@|$)/.test(arg));
|
|
467
|
+
}
|
|
468
|
+
|
|
434
469
|
module.exports = {
|
|
470
|
+
HOLD_TEXT,
|
|
435
471
|
NPM_INSTALL_TIMEOUT_MS,
|
|
436
472
|
RUNTIME_SCHEMA,
|
|
437
|
-
|
|
473
|
+
UNAVAILABLE_TEXT,
|
|
474
|
+
WARN_TEXT,
|
|
475
|
+
RUN,
|
|
476
|
+
defaultNpmInstall,
|
|
438
477
|
delocalizeHookCommand,
|
|
478
|
+
delocalizeHookScriptText,
|
|
439
479
|
ensureMcpRuntime,
|
|
480
|
+
hookTextUsesNpx,
|
|
481
|
+
launcherSource,
|
|
482
|
+
loaderConfig,
|
|
483
|
+
loaderFor,
|
|
440
484
|
localizeHookCommand,
|
|
485
|
+
localizeHookScriptText,
|
|
441
486
|
localizeHookSettingsText,
|
|
442
487
|
mapHookCommands,
|
|
488
|
+
mcpServerLaunch,
|
|
489
|
+
mcpServerLaunchVersion,
|
|
490
|
+
mcpServerUsesNpx,
|
|
491
|
+
npmEnvironment,
|
|
492
|
+
npmInvocation,
|
|
443
493
|
removeMcpRuntime,
|
|
494
|
+
runtimeConfig,
|
|
495
|
+
runtimeCore: core,
|
|
444
496
|
runtimeDir,
|
|
445
497
|
runtimeRoot,
|
|
446
498
|
verifyMcpRuntime,
|