@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.
@@ -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
- // The package's integrity is checked against the pin (or the registry-verified update target)
14
- // when it is installed. Every start re-checks the installed files against the record; if the
15
- // copy is missing, changed or its node is gone, the same entrypoint runs through npx exactly as
16
- // before, so a hook keeps its fail-closed behaviour. Hook commands reach the runtime through
17
- // $HOME, so project hook files stay the same for every user and name no local path.
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 { spawnSync } = require('node:child_process');
29
+ const zlib = require('node:zlib');
30
+ const PINS = require('./pins');
31
+ const { runtimeCore } = require('./runtime-core');
23
32
 
24
- const RUNTIME_SCHEMA = 'marrow-mcp-runtime.v1';
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 INTEGRITY_RE = /^sha512-[A-Za-z0-9+/]{86}==$/;
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
- // A directory Marrow owns: a real directory (not a link) owned by this user, not writable by
45
- // others; created 0700 when missing.
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 privateDirectoryOk(directory) {
57
- try {
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
- function listFiles(base, relative = '') {
67
- const directory = path.join(base, relative);
68
- const out = [];
69
- for (const entry of fs.readdirSync(directory, { withFileTypes: true }).sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))) {
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
- // The installed package files, as the launcher hashes them on every start (npm's own lock copy
80
- // and any .bin links are not package files; the runtime is never started through .bin).
81
- function packageFiles(directory) {
82
- return listFiles(directory, 'node_modules').filter((file) => file !== 'node_modules/.package-lock.json');
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 manifestDigest(directory, files) {
86
- const hash = crypto.createHash('sha256');
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
- const LAUNCHER_SOURCE = `'use strict';
97
- // Written by @getmarrow/install. Runs the verified local @getmarrow/mcp. When the copy is
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
- function verifiedEntry() {
118
- try {
119
- const record = JSON.parse(fs.readFileSync(path.join(__dirname, '.verified'), 'utf8'));
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
- function readRecord(directory) {
162
- try {
163
- const stat = fs.lstatSync(path.join(directory, '.verified'));
164
- if (!stat.isFile() || stat.isSymbolicLink() || (stat.mode & 0o077) !== 0) return null;
165
- const record = JSON.parse(fs.readFileSync(path.join(directory, '.verified'), 'utf8'));
166
- return record && record.schema === RUNTIME_SCHEMA ? record : null;
167
- } catch {
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
- // The runtime for one version, when its record matches the expected integrity and its files
173
- // still hash to the record. Returns null otherwise.
174
- function verifyMcpRuntime(home, version, integrity) {
175
- if (!home || !VERSION_RE.test(String(version || ''))) return null;
176
- const directory = runtimeDir(home, version);
177
- if (![path.join(home, '.marrow'), path.dirname(runtimeRoot(home)), runtimeRoot(home), directory].every(privateDirectoryOk)) return null;
178
- const record = readRecord(directory);
179
- if (!record || record.version !== version) return null;
180
- if (integrity && record.integrity !== integrity) return null;
181
- try {
182
- const files = packageFiles(directory);
183
- if (JSON.stringify(files) !== JSON.stringify(record.files)) return null;
184
- if (manifestDigest(directory, files) !== record.manifest_sha256) return null;
185
- if (fs.readFileSync(path.join(directory, 'launch.cjs'), 'utf8') !== LAUNCHER_SOURCE) return null;
186
- const runStat = fs.statSync(path.join(directory, 'run'));
187
- if (!runStat.isFile() || (runStat.mode & 0o100) === 0) return null;
188
- const nodeTarget = fs.readlinkSync(path.join(directory, 'node'));
189
- if (fs.readFileSync(path.join(directory, 'run'), 'utf8') !== runScript(nodeTarget, path.join(directory, 'launch.cjs'))) return null;
190
- } catch {
191
- return null;
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 defaultNpmInstall(stage, specs) {
197
- const npm = process.platform === 'win32' ? 'npm.cmd' : 'npm';
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
- function removeQuietly(target) {
208
- try {
209
- fs.rmSync(target, { recursive: true, force: true });
210
- } catch {
211
- // Best effort: a leftover directory is cleaned on the next install or update.
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
- // Removes every runtime version except `keep` (and leftover staging directories).
216
- function removeOtherRuntimes(home, keep) {
217
- const root = runtimeRoot(home);
218
- const removed = [];
219
- let entries = [];
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
- entries = fs.readdirSync(root);
139
+ source = zlib.brotliDecompressSync(Buffer.from(match[1], 'base64url'), { maxOutputLength: 1 << 17 }).toString('utf8');
222
140
  } catch {
223
- return removed;
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
- let stage = null;
143
+ const line = source.split('\n')[1] || '';
144
+ if (!line.startsWith('const CFG = ') || !line.endsWith(';')) return null;
145
+ let parsed;
251
146
  try {
252
- ensurePrivateDirectory(path.join(home, '.marrow'));
253
- ensurePrivateDirectory(path.dirname(runtimeRoot(home)));
254
- ensurePrivateDirectory(runtimeRoot(home));
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
- function removeMcpRuntime(home, { dryRun = false } = {}) {
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 <-> the local runtime 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 ARGS = '--package=@getmarrow/mcp@(\\d+\\.\\d+\\.\\d+) marrow-mcp ([a-z][a-z-]{0,63})';
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
- // 1. A plain entrypoint command.
330
- function localPlain(version, sub) {
331
- const args = `--package=@getmarrow/mcp@${version} marrow-mcp ${sub}`;
332
- return `/bin/sh -c 'M="${RUN(version)}"; if [ -x "$M" ]; then exec "$M" ${args}; fi; exec npx -y ${args}'`;
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 LOCAL_PLAIN_RE = new RegExp(`^/bin/sh -c 'M="\\$HOME/\\.marrow/runtime/mcp/(\\d+\\.\\d+\\.\\d+)/run"; if \\[ -x "\\$M" \\]; then exec "\\$M" ${ARGS}; fi; exec npx -y --package=@getmarrow/mcp@\\2 marrow-mcp \\3'$`);
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
- function localInner(version, sub) {
339
- const args = `--package=@getmarrow/mcp@${version} marrow-mcp ${sub}`;
340
- return `{ M="${RUN(version)}"; if [ -x "$M" ]; then "$M" ${args}; else npx -y ${args}; fi; }`;
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 LOCAL_INNER_RE = new RegExp(`\\{ M="\\$HOME/\\.marrow/runtime/mcp/(\\d+\\.\\d+\\.\\d+)/run"; if \\[ -x "\\$M" \\]; then "\\$M" ${ARGS}; else npx -y --package=@getmarrow/mcp@\\2 marrow-mcp \\3; fi; \\}`, 'g');
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 through npx.
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, argsJson) {
348
- return `spawn(...((f,m,a)=>f.existsSync(m)?[m,a.slice(1)]:[process.platform==="win32"?"npx.cmd":"npx",a])(require("node:fs"),(process.env.HOME||"")+${JSON.stringify(`/.marrow/runtime/mcp/${version}/run`)},${argsJson}),`;
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 LOCAL_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;
351
- const CANONICAL_SPAWN_RE = /spawn\(process\.platform==="win32"\?"npx\.cmd":"npx",(\["-y","--package=@getmarrow\/mcp@(\d+\.\d+\.\d+)","marrow-mcp","[a-z-]+"\]),/g;
352
- const localGuardPrefix = (version) => `N="${NODE(version)}"; [ -x "$N" ] || N=node; exec "$N" -e '`;
353
- const LOCAL_GUARD_PREFIX_RE = /^N="\$HOME\/\.marrow\/runtime\/mcp\/\d+\.\d+\.\d+\/node"; \[ -x "\$N" \] \|\| N=node; exec "\$N" -e '/;
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 canonical (npx) form of a hook command; any other command is returned unchanged.
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 plain = command.match(LOCAL_PLAIN_RE);
359
- if (plain && plain[1] === plain[2]) return `npx -y --package=@getmarrow/mcp@${plain[2]} marrow-mcp ${plain[3]}`;
360
- if (LOCAL_GUARD_PREFIX_RE.test(command)) {
361
- return command.replace(LOCAL_GUARD_PREFIX_RE, 'node -e \'').replace(LOCAL_SPAWN_RE, `${SPAWN_NPX}$1,`);
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 \'') && LOCAL_INNER_RE.test(command)) {
364
- LOCAL_INNER_RE.lastIndex = 0;
365
- const inner = command.replace(LOCAL_INNER_RE, (match, runVersion, version, sub) => (
366
- runVersion === version ? `npx -y --package=@getmarrow/mcp@${version} marrow-mcp ${sub}` : match
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
- function localizeHookCommand(command, version) {
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, argsJson, specVersion) => {
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, argsJson);
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 delocalizeHookCommand(local) === command ? local : command;
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
- function localizeHookSettingsText(text, version) {
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
- LAUNCHER_SOURCE,
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,