frontier-mcp 0.3.1 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +19 -14
- package/dist/bin.js +62 -0
- package/dist/bin.js.map +1 -1
- package/dist/domain.d.ts +3 -0
- package/dist/domain.d.ts.map +1 -1
- package/dist/domain.js.map +1 -1
- package/dist/frontier.js +2 -0
- package/dist/frontier.js.map +1 -1
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +16 -2
- package/dist/server.js.map +1 -1
- package/dist/storage/markdown/create.d.ts.map +1 -1
- package/dist/storage/markdown/create.js +3 -0
- package/dist/storage/markdown/create.js.map +1 -1
- package/dist/storage/markdown/driver.d.ts.map +1 -1
- package/dist/storage/markdown/driver.js +12 -9
- package/dist/storage/markdown/driver.js.map +1 -1
- package/dist/storage/markdown/serialize.d.ts.map +1 -1
- package/dist/storage/markdown/serialize.js +6 -0
- package/dist/storage/markdown/serialize.js.map +1 -1
- package/dist/storage/markdown/ticket.d.ts.map +1 -1
- package/dist/storage/markdown/ticket.js +29 -3
- package/dist/storage/markdown/ticket.js.map +1 -1
- package/dist/storage/markdown/write.d.ts +5 -1
- package/dist/storage/markdown/write.d.ts.map +1 -1
- package/dist/storage/markdown/write.js +32 -2
- package/dist/storage/markdown/write.js.map +1 -1
- package/dist/tools/get-board.js +1 -1
- package/dist/tools/get-board.js.map +1 -1
- package/dist/tools/update-ticket.d.ts +18 -1
- package/dist/tools/update-ticket.d.ts.map +1 -1
- package/dist/tools/update-ticket.js +98 -15
- package/dist/tools/update-ticket.js.map +1 -1
- package/docs/agents/issue-tracker.md +7 -4
- package/package.json +7 -3
- package/scripts/frontier-setup.cjs +1517 -0
|
@@ -0,0 +1,1517 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
// This file deliberately has no dependencies and uses syntax Node 16 accepts.
|
|
5
|
+
// It must choose a runtime before FrontierMCP itself is installed or imported.
|
|
6
|
+
// FrontierMCP setup bootstrap revision 2.
|
|
7
|
+
|
|
8
|
+
const { spawn } = require('node:child_process');
|
|
9
|
+
const { createHash } = require('node:crypto');
|
|
10
|
+
const {
|
|
11
|
+
access,
|
|
12
|
+
chmod,
|
|
13
|
+
copyFile,
|
|
14
|
+
mkdtemp,
|
|
15
|
+
mkdir,
|
|
16
|
+
open,
|
|
17
|
+
readdir,
|
|
18
|
+
readFile,
|
|
19
|
+
realpath,
|
|
20
|
+
rename,
|
|
21
|
+
rm,
|
|
22
|
+
stat,
|
|
23
|
+
writeFile,
|
|
24
|
+
} = require('node:fs/promises');
|
|
25
|
+
const https = require('node:https');
|
|
26
|
+
const os = require('node:os');
|
|
27
|
+
const path = require('node:path');
|
|
28
|
+
|
|
29
|
+
const PACKAGE = 'frontier-mcp';
|
|
30
|
+
const SERVER = 'frontier';
|
|
31
|
+
const REGISTRY = 'https://registry.npmjs.org';
|
|
32
|
+
const TOOL_NAMES = [
|
|
33
|
+
'create_tickets',
|
|
34
|
+
'edit_map',
|
|
35
|
+
'get_board',
|
|
36
|
+
'get_tickets',
|
|
37
|
+
'list_efforts',
|
|
38
|
+
'migrate_effort',
|
|
39
|
+
'spec',
|
|
40
|
+
'update_ticket',
|
|
41
|
+
];
|
|
42
|
+
const REQUEST_TIMEOUT_MS = 15_000;
|
|
43
|
+
|
|
44
|
+
if (require.main === module) {
|
|
45
|
+
main(process.argv.slice(2)).catch(error => {
|
|
46
|
+
process.stderr.write(`frontier setup failed: ${error.message}\n`);
|
|
47
|
+
process.exitCode = 1;
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function main(args) {
|
|
52
|
+
const options = parseArguments(args);
|
|
53
|
+
if (options.help) {
|
|
54
|
+
printUsage();
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const release = await readRelease(options.version);
|
|
59
|
+
const runtime = await selectRuntime(options.node, release.engines.node);
|
|
60
|
+
const installation = await findOrInstall(runtime, options.version);
|
|
61
|
+
const alternatives = await resolveAlternativeRuntimes(options);
|
|
62
|
+
const launch = await durableLaunch(runtime, installation, {
|
|
63
|
+
alternatives,
|
|
64
|
+
version: options.version,
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
// The preflight starts in a disposable empty directory. It proves the exact
|
|
68
|
+
// saved executable and entry work without creating or changing a tracker in
|
|
69
|
+
// the repository where setup was invoked.
|
|
70
|
+
await verifyLaunch(launch);
|
|
71
|
+
|
|
72
|
+
const target =
|
|
73
|
+
options.client === 'cursor' ? cursorConfigPath() : 'your MCP client user configuration';
|
|
74
|
+
const preview =
|
|
75
|
+
options.client === 'cursor'
|
|
76
|
+
? await prepareConfig(target, launch)
|
|
77
|
+
: { desired: { command: launch.command, args: launch.args } };
|
|
78
|
+
printPreview({ runtime, release, installation, target, preview, client: options.client });
|
|
79
|
+
|
|
80
|
+
if (!options.apply || options.client === 'manual') return;
|
|
81
|
+
await applyConfig(target, preview, options.replace);
|
|
82
|
+
process.stdout.write(
|
|
83
|
+
`Applied Cursor user configuration at ${target}. Restart Cursor to use it.\n`,
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function parseArguments(args) {
|
|
88
|
+
const options = {
|
|
89
|
+
apply: false,
|
|
90
|
+
bun: undefined,
|
|
91
|
+
client: 'cursor',
|
|
92
|
+
deno: undefined,
|
|
93
|
+
help: false,
|
|
94
|
+
node: undefined,
|
|
95
|
+
replace: false,
|
|
96
|
+
};
|
|
97
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
98
|
+
const argument = args[index];
|
|
99
|
+
const value = args[index + 1];
|
|
100
|
+
if (argument === '--apply') {
|
|
101
|
+
options.apply = true;
|
|
102
|
+
} else if (argument === '--replace') {
|
|
103
|
+
options.replace = true;
|
|
104
|
+
} else if (argument === '--help' || argument === '-h') {
|
|
105
|
+
options.help = true;
|
|
106
|
+
} else if (argument === '--version' && value !== undefined) {
|
|
107
|
+
options.version = value;
|
|
108
|
+
index += 1;
|
|
109
|
+
} else if (argument === '--node' && value !== undefined) {
|
|
110
|
+
options.node = value;
|
|
111
|
+
index += 1;
|
|
112
|
+
} else if (argument === '--bun' && value !== undefined) {
|
|
113
|
+
options.bun = value;
|
|
114
|
+
index += 1;
|
|
115
|
+
} else if (argument === '--deno' && value !== undefined) {
|
|
116
|
+
options.deno = value;
|
|
117
|
+
index += 1;
|
|
118
|
+
} else if (argument === '--client' && value !== undefined) {
|
|
119
|
+
options.client = value;
|
|
120
|
+
index += 1;
|
|
121
|
+
} else {
|
|
122
|
+
throw new Error(`Unknown or incomplete argument: ${argument}`);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
if (
|
|
126
|
+
!options.help &&
|
|
127
|
+
(options.version === undefined || !/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(options.version))
|
|
128
|
+
) {
|
|
129
|
+
throw new Error('Pass an exact released version, for example --version 0.3.1.');
|
|
130
|
+
}
|
|
131
|
+
if (options.client !== 'cursor' && options.client !== 'manual') {
|
|
132
|
+
throw new Error('--client must be cursor or manual.');
|
|
133
|
+
}
|
|
134
|
+
if (options.client === 'manual' && options.apply) {
|
|
135
|
+
throw new Error('--apply is available only for Cursor user configuration.');
|
|
136
|
+
}
|
|
137
|
+
return options;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function printUsage() {
|
|
141
|
+
process.stdout.write(`FrontierMCP setup bootstrap revision 2
|
|
142
|
+
|
|
143
|
+
Usage: node scripts/frontier-setup.cjs --version X.Y.Z [options]
|
|
144
|
+
|
|
145
|
+
Options:
|
|
146
|
+
--node PATH Use this Node executable instead of the current one.
|
|
147
|
+
--bun PATH Save this Bun candidate for verified project launches.
|
|
148
|
+
--deno PATH Save this Deno candidate for verified project launches.
|
|
149
|
+
--client NAME cursor (default) writes Cursor user scope; manual prints an entry.
|
|
150
|
+
--apply Write the previewed Cursor entry.
|
|
151
|
+
--replace Replace an existing different frontier entry, with a backup.
|
|
152
|
+
`);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function readRelease(version) {
|
|
156
|
+
const url = `${REGISTRY}/${PACKAGE}/${encodeURIComponent(version)}`;
|
|
157
|
+
return readJson(url).then(release => {
|
|
158
|
+
if (release.name !== PACKAGE || release.version !== version || !release.engines?.node) {
|
|
159
|
+
throw new Error(`Registry metadata for ${PACKAGE}@${version} has no usable Node engine.`);
|
|
160
|
+
}
|
|
161
|
+
return release;
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function readJson(url) {
|
|
166
|
+
return new Promise((resolve, reject) => {
|
|
167
|
+
const request = https.get(url, { timeout: REQUEST_TIMEOUT_MS }, response => {
|
|
168
|
+
let body = '';
|
|
169
|
+
response.setEncoding('utf8');
|
|
170
|
+
response.on('data', chunk => {
|
|
171
|
+
body += chunk;
|
|
172
|
+
});
|
|
173
|
+
response.on('end', () => {
|
|
174
|
+
if (response.statusCode !== 200) {
|
|
175
|
+
reject(new Error(`Could not read ${url}: HTTP ${response.statusCode}.`));
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
try {
|
|
179
|
+
resolve(JSON.parse(body));
|
|
180
|
+
} catch (error) {
|
|
181
|
+
reject(new Error(`Could not parse registry metadata from ${url}: ${error.message}`));
|
|
182
|
+
}
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
request.on('timeout', () => request.destroy(new Error(`Timed out reading ${url}.`)));
|
|
186
|
+
request.on('error', error => reject(new Error(`Could not read ${url}: ${error.message}`)));
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
async function selectRuntime(override, range, options = {}) {
|
|
191
|
+
const environment = options.environment ?? process.env;
|
|
192
|
+
const currentExecutable = options.currentExecutable ?? process.execPath;
|
|
193
|
+
if (!isKnownEngineRange(range)) {
|
|
194
|
+
throw new Error(
|
|
195
|
+
`${PACKAGE} declares ${range}. This bootstrap understands only >=N and ^N.N.N clauses joined with ||; use a bootstrap released for that package version.`,
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
if (override !== undefined) {
|
|
199
|
+
const runtime = await resolvedRuntime(path.resolve(override), environment, 'override');
|
|
200
|
+
if (supportsRange(runtime.version, range)) return runtime;
|
|
201
|
+
throw new Error(
|
|
202
|
+
`${runtime.executable} is Node ${formatVersion(runtime.version)}, but ${PACKAGE} requires ${range}.`,
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const current = await resolvedRuntime(path.resolve(currentExecutable), environment, 'current');
|
|
207
|
+
if (supportsRange(current.version, range)) return current;
|
|
208
|
+
|
|
209
|
+
const discovery = await discoverManagedRuntimes(
|
|
210
|
+
environment,
|
|
211
|
+
options.platform ?? process.platform,
|
|
212
|
+
);
|
|
213
|
+
const selected = selectManagedRuntime(discovery.runtimes, range);
|
|
214
|
+
if (selected !== undefined) return selected;
|
|
215
|
+
|
|
216
|
+
throw new Error(
|
|
217
|
+
`${current.executable} is Node ${formatVersion(current.version)}, but ${PACKAGE} requires ${range}. ` +
|
|
218
|
+
missingRuntimeGuidance(discovery.managers),
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
async function resolvedRuntime(requested, environment, source, manager, repairCommand) {
|
|
223
|
+
const executable = await stableRuntimePath(requested, environment);
|
|
224
|
+
const version = await nodeVersion(executable, environment);
|
|
225
|
+
return { executable, version, source, manager, repairCommand };
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
async function stableRuntimePath(requested, environment) {
|
|
229
|
+
try {
|
|
230
|
+
const { stdout } = await run(requested, ['-p', 'process.execPath'], {
|
|
231
|
+
capture: true,
|
|
232
|
+
env: environment,
|
|
233
|
+
});
|
|
234
|
+
const selected = stdout.trim();
|
|
235
|
+
if (!path.isAbsolute(selected))
|
|
236
|
+
throw new Error('The selected Node did not report an absolute path.');
|
|
237
|
+
const resolved = await realpath(selected);
|
|
238
|
+
// A manager shim can choose an executable from the setup project's pin.
|
|
239
|
+
// Save the process it actually selected, never the shim itself.
|
|
240
|
+
await nodeVersion(resolved, environment);
|
|
241
|
+
return resolved;
|
|
242
|
+
} catch (error) {
|
|
243
|
+
throw new Error(
|
|
244
|
+
`Could not resolve a stable Node executable from ${requested}: ${error.message}`,
|
|
245
|
+
{
|
|
246
|
+
cause: error,
|
|
247
|
+
},
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
async function nodeVersion(executable, environment) {
|
|
253
|
+
const { stdout } = await run(executable, ['--version'], { capture: true, env: environment });
|
|
254
|
+
const match = /^v(\d+)\.(\d+)\.(\d+)\s*$/.exec(stdout);
|
|
255
|
+
if (match === null) throw new Error(`${executable} did not report a Node version.`);
|
|
256
|
+
return { major: Number(match[1]), minor: Number(match[2]), patch: Number(match[3]) };
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// This bootstrap intentionally recognizes the engine forms FrontierMCP has
|
|
260
|
+
// released. A new range needs a new bootstrap; refusing is safer than silently
|
|
261
|
+
// accepting a runtime with an incomplete semver implementation.
|
|
262
|
+
function supportsRange(version, range) {
|
|
263
|
+
return range.split('||').some(part => supportsComparator(version, part.trim()));
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function isKnownEngineRange(range) {
|
|
267
|
+
return range.split('||').every(part => isKnownRange(part.trim()));
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function isKnownRange(range) {
|
|
271
|
+
return /^(?:>=|\^)(?:0|[1-9]\d*)\.\d+\.\d+$/.test(range) || /^(?:>=)(?:0|[1-9]\d*)$/.test(range);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function supportsComparator(version, comparator) {
|
|
275
|
+
const match = /^(>=|\^)(\d+)(?:\.(\d+)\.(\d+))?$/.exec(comparator);
|
|
276
|
+
if (match === null) return false;
|
|
277
|
+
const floor = {
|
|
278
|
+
major: Number(match[2]),
|
|
279
|
+
minor: Number(match[3] ?? 0),
|
|
280
|
+
patch: Number(match[4] ?? 0),
|
|
281
|
+
};
|
|
282
|
+
if (match[1] === '^' && version.major !== floor.major) return false;
|
|
283
|
+
return compareVersions(version, floor) >= 0;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function compareVersions(left, right) {
|
|
287
|
+
for (const part of ['major', 'minor', 'patch']) {
|
|
288
|
+
if (left[part] !== right[part]) return left[part] - right[part];
|
|
289
|
+
}
|
|
290
|
+
return 0;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function formatVersion(version) {
|
|
294
|
+
return `${version.major}.${version.minor}.${version.patch}`;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
const MANAGER_ORDER = ['fnm', 'nvm', 'nvm-windows', 'volta', 'asdf', 'mise'];
|
|
298
|
+
|
|
299
|
+
async function discoverManagedRuntimes(environment, platform) {
|
|
300
|
+
const discoveries = await Promise.all([
|
|
301
|
+
discoverFnm(environment, platform),
|
|
302
|
+
discoverNvm(environment, platform),
|
|
303
|
+
discoverNvmWindows(environment, platform),
|
|
304
|
+
discoverVolta(environment, platform),
|
|
305
|
+
discoverAsdf(environment, platform),
|
|
306
|
+
discoverMise(environment, platform),
|
|
307
|
+
]);
|
|
308
|
+
const managers = discoveries.filter(discovery => discovery.found);
|
|
309
|
+
const runtimes = deduplicateRuntimes(discoveries.flatMap(discovery => discovery.runtimes));
|
|
310
|
+
return { managers, runtimes };
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
async function discoverFnm(environment, platform) {
|
|
314
|
+
const repairCommand = 'fnm install 24';
|
|
315
|
+
const roots = uniquePaths([
|
|
316
|
+
environment.FNM_DIR,
|
|
317
|
+
environment.XDG_DATA_HOME && path.join(environment.XDG_DATA_HOME, 'fnm'),
|
|
318
|
+
platform === 'darwin' &&
|
|
319
|
+
path.join(homeDirectory(environment), 'Library', 'Application Support', 'fnm'),
|
|
320
|
+
platform === 'win32' && environment.APPDATA && path.join(environment.APPDATA, 'fnm'),
|
|
321
|
+
path.join(homeDirectory(environment), '.local', 'share', 'fnm'),
|
|
322
|
+
path.join(homeDirectory(environment), '.fnm'),
|
|
323
|
+
]);
|
|
324
|
+
const command = await managerCommand('fnm', roots, environment, platform);
|
|
325
|
+
const listed = await managerListedVersions(command, ['list'], environment);
|
|
326
|
+
const runtimes = await runtimesFromLayouts(
|
|
327
|
+
roots.map(root => ({
|
|
328
|
+
root: path.join(root, 'node-versions'),
|
|
329
|
+
suffix: ['installation', ...nodeLayout(platform)],
|
|
330
|
+
})),
|
|
331
|
+
'fnm',
|
|
332
|
+
repairCommand,
|
|
333
|
+
environment,
|
|
334
|
+
listed,
|
|
335
|
+
);
|
|
336
|
+
return managerResult(
|
|
337
|
+
'fnm',
|
|
338
|
+
repairCommand,
|
|
339
|
+
command !== undefined || runtimes.length > 0,
|
|
340
|
+
runtimes,
|
|
341
|
+
);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
async function discoverNvm(environment, platform) {
|
|
345
|
+
if (platform === 'win32') return emptyDiscovery('nvm', 'nvm install 24');
|
|
346
|
+
const script = await nvmScript(environment);
|
|
347
|
+
const repairCommand = 'nvm install 24';
|
|
348
|
+
const roots = uniquePaths([
|
|
349
|
+
environment.NVM_DIR,
|
|
350
|
+
environment.XDG_CONFIG_HOME === undefined
|
|
351
|
+
? undefined
|
|
352
|
+
: path.join(environment.XDG_CONFIG_HOME, 'nvm'),
|
|
353
|
+
path.join(homeDirectory(environment), '.nvm'),
|
|
354
|
+
]);
|
|
355
|
+
const listed =
|
|
356
|
+
script === undefined
|
|
357
|
+
? undefined
|
|
358
|
+
: await managerListedVersions(
|
|
359
|
+
'/bin/bash',
|
|
360
|
+
[
|
|
361
|
+
'--noprofile',
|
|
362
|
+
'--norc',
|
|
363
|
+
'-c',
|
|
364
|
+
'. "$1" --no-use; nvm ls --no-colors',
|
|
365
|
+
'frontier-setup',
|
|
366
|
+
script,
|
|
367
|
+
],
|
|
368
|
+
{
|
|
369
|
+
...environment,
|
|
370
|
+
NVM_DIR: path.dirname(script),
|
|
371
|
+
PATH: `${environment.PATH ?? ''}:/usr/bin:/bin`,
|
|
372
|
+
},
|
|
373
|
+
);
|
|
374
|
+
const runtimes = await runtimesFromLayouts(
|
|
375
|
+
roots.map(root => ({ root: path.join(root, 'versions', 'node'), suffix: ['bin', 'node'] })),
|
|
376
|
+
'nvm',
|
|
377
|
+
repairCommand,
|
|
378
|
+
environment,
|
|
379
|
+
listed,
|
|
380
|
+
);
|
|
381
|
+
return managerResult('nvm', repairCommand, script !== undefined || runtimes.length > 0, runtimes);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
async function nvmScript(environment) {
|
|
385
|
+
const directories = [
|
|
386
|
+
environment.NVM_DIR,
|
|
387
|
+
environment.XDG_CONFIG_HOME === undefined
|
|
388
|
+
? undefined
|
|
389
|
+
: path.join(environment.XDG_CONFIG_HOME, 'nvm'),
|
|
390
|
+
path.join(homeDirectory(environment), '.nvm'),
|
|
391
|
+
].filter(Boolean);
|
|
392
|
+
return firstAccessible(directories.map(directory => path.join(directory, 'nvm.sh')));
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
async function discoverNvmWindows(environment, platform) {
|
|
396
|
+
const repairCommand = 'nvm install 24';
|
|
397
|
+
if (platform !== 'win32') return emptyDiscovery('nvm-windows', repairCommand);
|
|
398
|
+
const directRoots = uniquePaths([environment.NVM_HOME, nvmWindowsDefaultRoot(environment)]);
|
|
399
|
+
const command = await managerCommand('nvm', directRoots, environment, platform);
|
|
400
|
+
const root = await nvmWindowsRoot(command, environment);
|
|
401
|
+
const roots = uniquePaths([root, ...directRoots]);
|
|
402
|
+
const listed = await managerListedVersions(command, ['list'], environment);
|
|
403
|
+
const runtimes = await runtimesFromLayouts(
|
|
404
|
+
roots.map(candidate => ({ root: candidate, suffix: ['node.exe'] })),
|
|
405
|
+
'nvm-windows',
|
|
406
|
+
repairCommand,
|
|
407
|
+
environment,
|
|
408
|
+
listed,
|
|
409
|
+
);
|
|
410
|
+
const evidence = await Promise.all(roots.map(nvmWindowsRootEvidence));
|
|
411
|
+
return managerResult(
|
|
412
|
+
'nvm-windows',
|
|
413
|
+
repairCommand,
|
|
414
|
+
command !== undefined || evidence.some(Boolean) || runtimes.length > 0,
|
|
415
|
+
runtimes,
|
|
416
|
+
);
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
function nvmWindowsDefaultRoot(environment) {
|
|
420
|
+
return environment.APPDATA === undefined ? undefined : path.join(environment.APPDATA, 'nvm');
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
async function nvmWindowsRootEvidence(root) {
|
|
424
|
+
try {
|
|
425
|
+
const entries = await readdir(root, { withFileTypes: true });
|
|
426
|
+
return entries.some(
|
|
427
|
+
entry =>
|
|
428
|
+
entry.name === 'settings.txt' ||
|
|
429
|
+
entry.name === 'nvm.exe' ||
|
|
430
|
+
(entry.isDirectory() && /^v\d+\.\d+\.\d+$/.test(entry.name)),
|
|
431
|
+
);
|
|
432
|
+
} catch {
|
|
433
|
+
return false;
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
async function nvmWindowsRoot(command, environment) {
|
|
438
|
+
if (environment.NVM_HOME !== undefined) return environment.NVM_HOME;
|
|
439
|
+
if (command === undefined) return undefined;
|
|
440
|
+
try {
|
|
441
|
+
const { stdout } = await run(command, ['root'], {
|
|
442
|
+
env: managerQueryEnvironment(environment),
|
|
443
|
+
label: 'nvm root',
|
|
444
|
+
cwd: os.tmpdir(),
|
|
445
|
+
timeout: 5000,
|
|
446
|
+
});
|
|
447
|
+
return stdout.trim().replace(/^Current Root:\s*/i, '');
|
|
448
|
+
} catch {
|
|
449
|
+
return undefined;
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
async function discoverVolta(environment, platform) {
|
|
454
|
+
const repairCommand = 'volta fetch node@24';
|
|
455
|
+
const roots = uniquePaths([
|
|
456
|
+
environment.VOLTA_HOME,
|
|
457
|
+
platform === 'win32' &&
|
|
458
|
+
environment.LOCALAPPDATA &&
|
|
459
|
+
path.join(environment.LOCALAPPDATA, 'Volta'),
|
|
460
|
+
path.join(homeDirectory(environment), '.volta'),
|
|
461
|
+
]);
|
|
462
|
+
const command = await managerCommand('volta', roots, environment, platform);
|
|
463
|
+
const listed = command === undefined ? [] : await voltaListedVersions(command, environment);
|
|
464
|
+
const runtimes = await runtimesFromLayouts(
|
|
465
|
+
roots.map(root => ({
|
|
466
|
+
root: path.join(root, 'tools', 'image', 'node'),
|
|
467
|
+
suffix: nodeLayout(platform),
|
|
468
|
+
})),
|
|
469
|
+
'volta',
|
|
470
|
+
repairCommand,
|
|
471
|
+
environment,
|
|
472
|
+
listed,
|
|
473
|
+
);
|
|
474
|
+
return managerResult(
|
|
475
|
+
'volta',
|
|
476
|
+
repairCommand,
|
|
477
|
+
command !== undefined || runtimes.length > 0,
|
|
478
|
+
runtimes,
|
|
479
|
+
);
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
async function discoverAsdf(environment, platform) {
|
|
483
|
+
const repairCommand = 'asdf install nodejs latest:24';
|
|
484
|
+
if (platform === 'win32') return emptyDiscovery('asdf', repairCommand);
|
|
485
|
+
const roots = uniquePaths([
|
|
486
|
+
environment.ASDF_DATA_DIR,
|
|
487
|
+
environment.ASDF_DIR,
|
|
488
|
+
path.join(homeDirectory(environment), '.asdf'),
|
|
489
|
+
]);
|
|
490
|
+
const command = await managerCommand('asdf', roots, environment, platform);
|
|
491
|
+
const listed = await managerListedVersions(command, ['list', 'nodejs'], environment);
|
|
492
|
+
const runtimes = await runtimesFromLayouts(
|
|
493
|
+
roots.map(root => ({ root: path.join(root, 'installs', 'nodejs'), suffix: ['bin', 'node'] })),
|
|
494
|
+
'asdf',
|
|
495
|
+
repairCommand,
|
|
496
|
+
environment,
|
|
497
|
+
listed,
|
|
498
|
+
);
|
|
499
|
+
return managerResult(
|
|
500
|
+
'asdf',
|
|
501
|
+
repairCommand,
|
|
502
|
+
command !== undefined || runtimes.length > 0,
|
|
503
|
+
runtimes,
|
|
504
|
+
);
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
async function discoverMise(environment, platform) {
|
|
508
|
+
const repairCommand = 'mise install node@24';
|
|
509
|
+
const roots = uniquePaths([
|
|
510
|
+
environment.MISE_DATA_DIR,
|
|
511
|
+
environment.XDG_DATA_HOME && path.join(environment.XDG_DATA_HOME, 'mise'),
|
|
512
|
+
platform === 'win32' && environment.LOCALAPPDATA && path.join(environment.LOCALAPPDATA, 'mise'),
|
|
513
|
+
path.join(homeDirectory(environment), '.local', 'share', 'mise'),
|
|
514
|
+
]);
|
|
515
|
+
const command = await managerCommand('mise', roots, environment, platform);
|
|
516
|
+
const listed = await managerListedVersions(
|
|
517
|
+
command,
|
|
518
|
+
['ls', '--installed', '--no-header', 'node'],
|
|
519
|
+
environment,
|
|
520
|
+
);
|
|
521
|
+
const runtimes = await runtimesFromLayouts(
|
|
522
|
+
roots.map(root => ({
|
|
523
|
+
root: path.join(root, 'installs', 'node'),
|
|
524
|
+
suffix: nodeLayout(platform),
|
|
525
|
+
})),
|
|
526
|
+
'mise',
|
|
527
|
+
repairCommand,
|
|
528
|
+
environment,
|
|
529
|
+
listed,
|
|
530
|
+
);
|
|
531
|
+
return managerResult(
|
|
532
|
+
'mise',
|
|
533
|
+
repairCommand,
|
|
534
|
+
command !== undefined || runtimes.length > 0,
|
|
535
|
+
runtimes,
|
|
536
|
+
);
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
function emptyDiscovery(manager, repairCommand) {
|
|
540
|
+
return { found: false, manager, repairCommand, runtimes: [] };
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
function nodeLayout(platform) {
|
|
544
|
+
return platform === 'win32' ? ['node.exe'] : ['bin', 'node'];
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
function managerResult(manager, repairCommand, found, runtimes) {
|
|
548
|
+
return { found, manager, repairCommand, runtimes };
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
async function runtimesFromLayouts(layouts, manager, repairCommand, environment, listed) {
|
|
552
|
+
const candidates = (await Promise.all(layouts.map(runtimeCandidatesFromLayout))).flat();
|
|
553
|
+
const allowed =
|
|
554
|
+
listed === undefined || listed.length === 0
|
|
555
|
+
? candidates
|
|
556
|
+
: candidates.filter(candidate =>
|
|
557
|
+
listed.some(version => compareVersions(version, candidate.version) === 0),
|
|
558
|
+
);
|
|
559
|
+
const outcomes = await Promise.allSettled(
|
|
560
|
+
allowed.map(async candidate => {
|
|
561
|
+
const runtime = await resolvedRuntime(
|
|
562
|
+
candidate.executable,
|
|
563
|
+
environment,
|
|
564
|
+
'manager',
|
|
565
|
+
manager,
|
|
566
|
+
repairCommand,
|
|
567
|
+
);
|
|
568
|
+
if (compareVersions(runtime.version, candidate.version) !== 0)
|
|
569
|
+
throw new Error('Runtime version does not match its manager directory.');
|
|
570
|
+
return runtime;
|
|
571
|
+
}),
|
|
572
|
+
);
|
|
573
|
+
return outcomes.filter(outcome => outcome.status === 'fulfilled').map(outcome => outcome.value);
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
async function runtimeCandidatesFromLayout(layout) {
|
|
577
|
+
try {
|
|
578
|
+
return (await readdir(layout.root, { withFileTypes: true }))
|
|
579
|
+
.filter(entry => entry.isDirectory())
|
|
580
|
+
.flatMap(entry => {
|
|
581
|
+
const version = /^v?\d+\.\d+\.\d+$/.test(entry.name)
|
|
582
|
+
? listedVersions(entry.name)[0]
|
|
583
|
+
: undefined;
|
|
584
|
+
return version === undefined
|
|
585
|
+
? []
|
|
586
|
+
: [{ executable: path.join(layout.root, entry.name, ...layout.suffix), version }];
|
|
587
|
+
});
|
|
588
|
+
} catch {
|
|
589
|
+
return [];
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
function listedVersions(output) {
|
|
594
|
+
const versions = [];
|
|
595
|
+
const expression = /(?:v|node@)?(\d+)\.(\d+)\.(\d+)/g;
|
|
596
|
+
let match;
|
|
597
|
+
while ((match = expression.exec(output)) !== null) {
|
|
598
|
+
const version = { major: Number(match[1]), minor: Number(match[2]), patch: Number(match[3]) };
|
|
599
|
+
if (!versions.some(candidate => compareVersions(candidate, version) === 0))
|
|
600
|
+
versions.push(version);
|
|
601
|
+
}
|
|
602
|
+
return versions;
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
function deduplicateVersions(versions) {
|
|
606
|
+
return versions.filter(
|
|
607
|
+
(version, index) =>
|
|
608
|
+
versions.findIndex(candidate => compareVersions(candidate, version) === 0) === index,
|
|
609
|
+
);
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
function voltaInstalledNodeVersions(output) {
|
|
613
|
+
let inNodeRuntimes = false;
|
|
614
|
+
const versions = [];
|
|
615
|
+
for (const line of output.split(/\r?\n/)) {
|
|
616
|
+
if (/^\s*Node runtimes:\s*$/i.test(line)) {
|
|
617
|
+
inNodeRuntimes = true;
|
|
618
|
+
} else if (/^\S.*:\s*$/.test(line)) {
|
|
619
|
+
inNodeRuntimes = false;
|
|
620
|
+
} else if (inNodeRuntimes) {
|
|
621
|
+
versions.push(...listedVersions(line));
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
return deduplicateVersions(versions);
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
async function voltaListedVersions(command, environment) {
|
|
628
|
+
try {
|
|
629
|
+
const { stdout } = await run(command, ['list', 'all', '--format', 'plain'], {
|
|
630
|
+
env: managerQueryEnvironment(environment),
|
|
631
|
+
label: 'volta list',
|
|
632
|
+
cwd: os.tmpdir(),
|
|
633
|
+
timeout: 5000,
|
|
634
|
+
});
|
|
635
|
+
return voltaInstalledNodeVersions(stdout);
|
|
636
|
+
} catch {
|
|
637
|
+
return [];
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
// Commands are read-only installed-version queries. Missing commands, older CLI
|
|
642
|
+
// syntax, and unrecognized output fall back to probing the known local layouts.
|
|
643
|
+
async function managerListedVersions(command, args, environment) {
|
|
644
|
+
if (command === undefined) return undefined;
|
|
645
|
+
try {
|
|
646
|
+
const { stdout } = await run(command, args, {
|
|
647
|
+
cwd: os.tmpdir(),
|
|
648
|
+
env: managerQueryEnvironment(environment),
|
|
649
|
+
timeout: 5000,
|
|
650
|
+
});
|
|
651
|
+
return listedVersions(stdout);
|
|
652
|
+
} catch {
|
|
653
|
+
return undefined;
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
function managerQueryEnvironment(environment) {
|
|
658
|
+
// A manager executable may itself be a Bash script. Noninteractive Bash
|
|
659
|
+
// sources BASH_ENV even when no shell profile was requested.
|
|
660
|
+
return { ...environment, BASH_ENV: '', NO_COLOR: '1' };
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
function deduplicateRuntimes(runtimes) {
|
|
664
|
+
const byPath = new Map();
|
|
665
|
+
for (const runtime of runtimes) {
|
|
666
|
+
if (!byPath.has(runtime.executable)) byPath.set(runtime.executable, runtime);
|
|
667
|
+
}
|
|
668
|
+
return [...byPath.values()];
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
function selectManagedRuntime(runtimes, range) {
|
|
672
|
+
return runtimes
|
|
673
|
+
.filter(runtime => supportsRange(runtime.version, range))
|
|
674
|
+
.reduce(
|
|
675
|
+
(selected, candidate) =>
|
|
676
|
+
selected === undefined || compareManagedRuntimes(candidate, selected) < 0
|
|
677
|
+
? candidate
|
|
678
|
+
: selected,
|
|
679
|
+
undefined,
|
|
680
|
+
);
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
function compareManagedRuntimes(left, right) {
|
|
684
|
+
const lts = Number(right.version.major === 24) - Number(left.version.major === 24);
|
|
685
|
+
if (lts !== 0) return lts;
|
|
686
|
+
const version = compareVersions(right.version, left.version);
|
|
687
|
+
if (version !== 0) return version;
|
|
688
|
+
const manager = MANAGER_ORDER.indexOf(left.manager) - MANAGER_ORDER.indexOf(right.manager);
|
|
689
|
+
if (manager !== 0) return manager;
|
|
690
|
+
return left.executable.localeCompare(right.executable);
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
function missingRuntimeGuidance(managers) {
|
|
694
|
+
if (managers.length === 0)
|
|
695
|
+
return 'No supported Node version manager was found. Install fnm, then run `fnm install 24` and rerun setup. Setup will not alter project pins or shell profiles.';
|
|
696
|
+
const commands = managers
|
|
697
|
+
.map(manager => `${manager.manager}: ${manager.repairCommand}`)
|
|
698
|
+
.join('; ');
|
|
699
|
+
return `No compatible installed Node was found. Install Node 24 LTS with one detected manager (${commands}), then rerun setup. Setup will not alter project pins or shell profiles.`;
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
async function managerCommand(name, roots, environment, platform) {
|
|
703
|
+
const extensions = platform === 'win32' ? ['.exe', '.cmd', '.bat', ''] : [''];
|
|
704
|
+
const rooted = roots.flatMap(root => [
|
|
705
|
+
...extensions.map(extension => path.join(root, 'bin', `${name}${extension}`)),
|
|
706
|
+
...extensions.map(extension => path.join(root, `${name}${extension}`)),
|
|
707
|
+
]);
|
|
708
|
+
return firstAccessible([...rooted, ...commandCandidatesOnPath(name, environment, platform)]);
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
function commandCandidatesOnPath(name, environment, platform) {
|
|
712
|
+
const directories = (environment.PATH ?? '').split(path.delimiter).filter(Boolean);
|
|
713
|
+
const extensions = platform === 'win32' ? ['.exe', '.cmd', '.bat', ''] : [''];
|
|
714
|
+
return directories.flatMap(directory =>
|
|
715
|
+
extensions.map(extension => path.join(directory, `${name}${extension}`)),
|
|
716
|
+
);
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
function uniquePaths(paths) {
|
|
720
|
+
return [...new Set(paths.filter(Boolean))];
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
async function firstAccessible(candidates) {
|
|
724
|
+
const checks = await Promise.all(
|
|
725
|
+
candidates.map(async candidate => {
|
|
726
|
+
try {
|
|
727
|
+
await access(candidate);
|
|
728
|
+
return candidate;
|
|
729
|
+
} catch {
|
|
730
|
+
return undefined;
|
|
731
|
+
}
|
|
732
|
+
}),
|
|
733
|
+
);
|
|
734
|
+
return checks.find(Boolean);
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
async function resolveAlternativeRuntimes(options, environment = process.env) {
|
|
738
|
+
return {
|
|
739
|
+
bun: await resolveAlternativeRuntime('bun', options.bun, environment),
|
|
740
|
+
deno: await resolveAlternativeRuntime('deno', options.deno, environment),
|
|
741
|
+
};
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
async function resolveAlternativeRuntime(name, override, environment) {
|
|
745
|
+
const requested =
|
|
746
|
+
override === undefined
|
|
747
|
+
? await firstAccessible(commandCandidatesOnPath(name, environment, process.platform))
|
|
748
|
+
: path.resolve(override);
|
|
749
|
+
if (requested === undefined) return undefined;
|
|
750
|
+
try {
|
|
751
|
+
return await realpath(requested);
|
|
752
|
+
} catch (error) {
|
|
753
|
+
if (override === undefined) return undefined;
|
|
754
|
+
throw new Error(`Could not resolve the requested ${name} executable ${requested}.`, {
|
|
755
|
+
cause: error,
|
|
756
|
+
});
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
async function durableLaunch(runtime, installation, options = {}) {
|
|
761
|
+
const selector = launcherSelectorSource({
|
|
762
|
+
alternatives: options.alternatives ?? {},
|
|
763
|
+
entry: installation.entry,
|
|
764
|
+
node: runtime.executable,
|
|
765
|
+
packageVersion: options.version,
|
|
766
|
+
repairCommand: runtime.repairCommand ?? 'fnm install 24',
|
|
767
|
+
});
|
|
768
|
+
const selectorDigest = createHash('sha256').update(selector).digest('hex').slice(0, 24);
|
|
769
|
+
const selectorPath = path.join(installation.directory, `frontier-select-${selectorDigest}.cjs`);
|
|
770
|
+
await writeImmutable(selectorPath, selector);
|
|
771
|
+
const contents =
|
|
772
|
+
process.platform === 'win32'
|
|
773
|
+
? windowsLaunchWrapper(runtime, selectorPath)
|
|
774
|
+
: posixLaunchWrapper(runtime, selectorPath);
|
|
775
|
+
const digest = createHash('sha256').update(contents).digest('hex').slice(0, 24);
|
|
776
|
+
const wrapper = path.join(
|
|
777
|
+
installation.directory,
|
|
778
|
+
`frontier-launch-${digest}${process.platform === 'win32' ? '.cmd' : ''}`,
|
|
779
|
+
);
|
|
780
|
+
// A preview must never redirect a launcher already named by saved configuration.
|
|
781
|
+
await writeImmutable(wrapper, contents);
|
|
782
|
+
if (process.platform !== 'win32') await chmod(wrapper, 0o755);
|
|
783
|
+
return { command: wrapper, args: [] };
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
async function writeImmutable(target, contents) {
|
|
787
|
+
try {
|
|
788
|
+
await writeFile(target, contents, { encoding: 'utf8', flag: 'wx' });
|
|
789
|
+
} catch (error) {
|
|
790
|
+
if (error.code !== 'EEXIST' || (await readFile(target, 'utf8')) !== contents) throw error;
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
function posixLaunchWrapper(runtime, selector) {
|
|
795
|
+
const repair = runtime.repairCommand ?? 'fnm install 24';
|
|
796
|
+
return `#!/bin/sh
|
|
797
|
+
if [ ! -x ${shellQuote(runtime.executable)} ]; then
|
|
798
|
+
printf '%s\\n' ${shellQuote(`FrontierMCP cannot find its configured Node runtime: ${runtime.executable}`)} >&2
|
|
799
|
+
printf '%s\\n' ${shellQuote(`Repair it with ${repair}, then rerun FrontierMCP setup.`)} >&2
|
|
800
|
+
exit 127
|
|
801
|
+
fi
|
|
802
|
+
exec ${shellQuote(runtime.executable)} ${shellQuote(selector)} "$@"
|
|
803
|
+
`;
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
function shellQuote(value) {
|
|
807
|
+
return `'${String(value).replace(/'/g, `'"'"'`)}'`;
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
function windowsLaunchWrapper(runtime, selector) {
|
|
811
|
+
const node = windowsBatchQuote(runtime.executable);
|
|
812
|
+
const quotedSelector = windowsBatchQuote(selector);
|
|
813
|
+
const repair = runtime.repairCommand ?? 'fnm install 24';
|
|
814
|
+
return `@echo off
|
|
815
|
+
setlocal DisableDelayedExpansion
|
|
816
|
+
if not exist ${node} (
|
|
817
|
+
>&2 echo FrontierMCP cannot find its configured Node runtime: ${windowsBatchText(runtime.executable)}
|
|
818
|
+
>&2 echo Repair it with ${windowsBatchText(repair)}, then rerun FrontierMCP setup.
|
|
819
|
+
exit /b 127
|
|
820
|
+
)
|
|
821
|
+
${node} ${quotedSelector} %*
|
|
822
|
+
exit /b %ERRORLEVEL%
|
|
823
|
+
`;
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
function launcherSelectorSource(configuration) {
|
|
827
|
+
return `'use strict';
|
|
828
|
+
const { spawn, spawnSync } = require('node:child_process');
|
|
829
|
+
const { existsSync, readFileSync, realpathSync, statSync } = require('node:fs');
|
|
830
|
+
const path = require('node:path');
|
|
831
|
+
const configuration = ${JSON.stringify(configuration)};
|
|
832
|
+
|
|
833
|
+
const choice = chooseRuntime();
|
|
834
|
+
if (choice.message) process.stderr.write(\`FrontierMCP: \${choice.message}\\n\`);
|
|
835
|
+
const child = spawn(choice.command, choice.args.concat(process.argv.slice(2)), {
|
|
836
|
+
cwd: process.cwd(),
|
|
837
|
+
env: process.env,
|
|
838
|
+
stdio: 'inherit',
|
|
839
|
+
});
|
|
840
|
+
for (const signal of ['SIGINT', 'SIGTERM']) {
|
|
841
|
+
process.on(signal, () => child.kill(signal));
|
|
842
|
+
}
|
|
843
|
+
child.on('error', error => {
|
|
844
|
+
process.stderr.write(\`FrontierMCP could not start \${choice.runtime}: \${error.message}\\n\`);
|
|
845
|
+
process.exitCode = 127;
|
|
846
|
+
});
|
|
847
|
+
child.on('exit', (code, signal) => {
|
|
848
|
+
if (code !== null && code !== 0 && choice.runtime !== 'configured Node') {
|
|
849
|
+
process.stderr.write(\`FrontierMCP: the \${choice.runtime} npm launcher exited \${code}; check that the pinned package is cached or the registry is reachable\\n\`);
|
|
850
|
+
}
|
|
851
|
+
process.exitCode = code ?? (signal ? 1 : 0);
|
|
852
|
+
});
|
|
853
|
+
|
|
854
|
+
function chooseRuntime() {
|
|
855
|
+
const node = {
|
|
856
|
+
runtime: 'configured Node',
|
|
857
|
+
command: configuration.node,
|
|
858
|
+
args: [configuration.entry],
|
|
859
|
+
};
|
|
860
|
+
const requested = process.env.FRONTIER_RUNTIME?.toLowerCase();
|
|
861
|
+
if (requested !== undefined && !['node', 'bun', 'deno'].includes(requested)) {
|
|
862
|
+
return fallback(node, \`FRONTIER_RUNTIME=\${requested} is invalid; expected node, bun, or deno; using configured Node\`);
|
|
863
|
+
}
|
|
864
|
+
if (requested === 'node') return node;
|
|
865
|
+
|
|
866
|
+
const workspace = resolveLaunchWorkspace(process.cwd());
|
|
867
|
+
if (workspace === undefined && requested === undefined) {
|
|
868
|
+
return fallback(node, 'no launch workspace marker was found; using configured Node');
|
|
869
|
+
}
|
|
870
|
+
const markers = workspace === undefined ? { bun: false, deno: false } : findMarkers(process.cwd(), workspace);
|
|
871
|
+
let selected = requested;
|
|
872
|
+
if (selected === undefined) {
|
|
873
|
+
if (markers.bun && markers.deno) {
|
|
874
|
+
return fallback(node, 'both Bun and Deno markers were found; using configured Node unless FRONTIER_RUNTIME chooses one');
|
|
875
|
+
}
|
|
876
|
+
if (markers.bun) selected = 'bun';
|
|
877
|
+
else if (markers.deno) selected = 'deno';
|
|
878
|
+
else return node;
|
|
879
|
+
}
|
|
880
|
+
return alternative(selected, node);
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
function alternative(runtime, node) {
|
|
884
|
+
if (configuration.packageVersion !== '0.3.1' || process.platform !== 'darwin' || process.arch !== 'arm64') {
|
|
885
|
+
return fallback(node, \`\${runtimeName(runtime)} is not verified for frontier-mcp@\${configuration.packageVersion} on \${process.platform}/\${process.arch}; using configured Node\`);
|
|
886
|
+
}
|
|
887
|
+
const executable = configuration.alternatives[runtime];
|
|
888
|
+
if (!executable || !existsSync(executable)) {
|
|
889
|
+
return fallback(node, \`\${runtimeName(runtime)} was selected but no saved executable is available; using configured Node\`);
|
|
890
|
+
}
|
|
891
|
+
const probe = spawnSync(executable, ['--version'], { encoding: 'utf8', timeout: 3000 });
|
|
892
|
+
const output = \`\${probe.stdout ?? ''} \${probe.stderr ?? ''}\`;
|
|
893
|
+
const version = runtime === 'bun'
|
|
894
|
+
? /(?:^|\\s)(\\d+\\.\\d+\\.\\d+)(?:\\s|$)/.exec(output)?.[1]
|
|
895
|
+
: /(?:^|\\s)deno\\s+(\\d+\\.\\d+\\.\\d+)(?:\\s|$)/i.exec(output)?.[1];
|
|
896
|
+
const expected = runtime === 'bun' ? '1.3.14' : '2.9.6';
|
|
897
|
+
if (probe.status !== 0 || version !== expected) {
|
|
898
|
+
return fallback(node, \`\${runtimeName(runtime)} \${version ?? 'with an unreadable version'} is not verified (expected \${expected}); using configured Node\`);
|
|
899
|
+
}
|
|
900
|
+
if (runtime === 'bun') {
|
|
901
|
+
return {
|
|
902
|
+
runtime: 'Bun',
|
|
903
|
+
command: executable,
|
|
904
|
+
args: ['x', '--bun', \`frontier-mcp@\${configuration.packageVersion}\`],
|
|
905
|
+
};
|
|
906
|
+
}
|
|
907
|
+
return {
|
|
908
|
+
runtime: 'Deno',
|
|
909
|
+
command: executable,
|
|
910
|
+
args: [
|
|
911
|
+
'run',
|
|
912
|
+
'--no-config',
|
|
913
|
+
'--no-lock',
|
|
914
|
+
'--node-modules-dir=none',
|
|
915
|
+
'--no-prompt',
|
|
916
|
+
'--allow-read',
|
|
917
|
+
'--allow-write',
|
|
918
|
+
'--allow-env',
|
|
919
|
+
\`npm:frontier-mcp@\${configuration.packageVersion}\`,
|
|
920
|
+
],
|
|
921
|
+
};
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
function resolveLaunchWorkspace(start) {
|
|
925
|
+
let current;
|
|
926
|
+
try {
|
|
927
|
+
current = realpathSync(start);
|
|
928
|
+
} catch {
|
|
929
|
+
return undefined;
|
|
930
|
+
}
|
|
931
|
+
while (true) {
|
|
932
|
+
if (isDirectory(path.join(current, '.scratch')) || existsSync(path.join(current, '.git'))) return current;
|
|
933
|
+
const parent = path.dirname(current);
|
|
934
|
+
if (parent === current) return undefined;
|
|
935
|
+
current = parent;
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
function findMarkers(start, workspace) {
|
|
940
|
+
let current = realpathSync(start);
|
|
941
|
+
const found = { bun: false, deno: false };
|
|
942
|
+
while (true) {
|
|
943
|
+
found.bun ||= existsSync(path.join(current, 'bun.lock')) || existsSync(path.join(current, 'bun.lockb')) || hasBunPackageManager(current);
|
|
944
|
+
found.deno ||= ['deno.json', 'deno.jsonc', 'deno.lock'].some(name => existsSync(path.join(current, name)));
|
|
945
|
+
if (current === workspace) return found;
|
|
946
|
+
const parent = path.dirname(current);
|
|
947
|
+
if (parent === current || !isWithin(parent, workspace)) return found;
|
|
948
|
+
current = parent;
|
|
949
|
+
}
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
function hasBunPackageManager(directory) {
|
|
953
|
+
try {
|
|
954
|
+
const manifest = JSON.parse(readFileSync(path.join(directory, 'package.json'), 'utf8'));
|
|
955
|
+
return typeof manifest.packageManager === 'string' && manifest.packageManager.startsWith('bun@');
|
|
956
|
+
} catch {
|
|
957
|
+
return false;
|
|
958
|
+
}
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
function isWithin(candidate, workspace) {
|
|
962
|
+
const relative = path.relative(workspace, candidate);
|
|
963
|
+
return relative === '' || (!relative.startsWith('..' + path.sep) && relative !== '..' && !path.isAbsolute(relative));
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
function isDirectory(target) {
|
|
967
|
+
try {
|
|
968
|
+
return statSync(target).isDirectory();
|
|
969
|
+
} catch {
|
|
970
|
+
return false;
|
|
971
|
+
}
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
function runtimeName(runtime) {
|
|
975
|
+
return runtime === 'bun' ? 'Bun' : 'Deno';
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
function fallback(node, message) {
|
|
979
|
+
return { ...node, message };
|
|
980
|
+
}
|
|
981
|
+
`;
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
function windowsBatchQuote(value) {
|
|
985
|
+
// Metacharacters are literal within quotes; carets here would change the path.
|
|
986
|
+
// Percent expansion still occurs in batch files, even within quotes.
|
|
987
|
+
if (/["\r\n]/.test(value)) throw new Error('Invalid Windows launcher path.');
|
|
988
|
+
return `"${String(value).replace(/%/g, '%%')}"`;
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
function windowsBatchText(value) {
|
|
992
|
+
return String(value)
|
|
993
|
+
.replace(/\^/g, '^^')
|
|
994
|
+
.replace(/%/g, '%%')
|
|
995
|
+
.replace(/&/g, '^&')
|
|
996
|
+
.replace(/\|/g, '^|')
|
|
997
|
+
.replace(/</g, '^<')
|
|
998
|
+
.replace(/>/g, '^>')
|
|
999
|
+
.replace(/\(/g, '^(')
|
|
1000
|
+
.replace(/\)/g, '^)');
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
async function findOrInstall(runtime, version) {
|
|
1004
|
+
const root = path.join(dataRoot(), 'frontier-mcp', version);
|
|
1005
|
+
await mkdir(root, { recursive: true });
|
|
1006
|
+
const existing = await verifiedInstallation(root, runtime);
|
|
1007
|
+
if (existing !== undefined) return existing;
|
|
1008
|
+
|
|
1009
|
+
// pnpm uses absolute directory junctions on Windows. Installing under one
|
|
1010
|
+
// parent and renaming that parent leaves those junctions aimed at the old
|
|
1011
|
+
// path, so create the unique immutable destination before pnpm runs.
|
|
1012
|
+
const destination = await mkdtemp(path.join(root, 'installed-'));
|
|
1013
|
+
try {
|
|
1014
|
+
await installPackage(destination, runtime, version);
|
|
1015
|
+
const entry = packageEntry(destination);
|
|
1016
|
+
await verifyLaunch({ command: runtime.executable, args: [entry] });
|
|
1017
|
+
return { directory: destination, entry, reused: false };
|
|
1018
|
+
} catch (error) {
|
|
1019
|
+
await rm(destination, { recursive: true, force: true });
|
|
1020
|
+
throw error;
|
|
1021
|
+
}
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
async function verifiedInstallation(root, runtime) {
|
|
1025
|
+
const entries = await readdir(root, { withFileTypes: true });
|
|
1026
|
+
const candidates = entries
|
|
1027
|
+
.filter(candidate => candidate.isDirectory() && candidate.name.startsWith('installed-'))
|
|
1028
|
+
.map(candidate => path.join(root, candidate.name));
|
|
1029
|
+
return findVerifiedInstallation(candidates, runtime);
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
async function findVerifiedInstallation(candidates, runtime, index = 0) {
|
|
1033
|
+
const directory = candidates[index];
|
|
1034
|
+
if (directory === undefined) return undefined;
|
|
1035
|
+
const entry = packageEntry(directory);
|
|
1036
|
+
try {
|
|
1037
|
+
const manifest = JSON.parse(
|
|
1038
|
+
await readFile(path.join(directory, 'node_modules', PACKAGE, 'package.json'), 'utf8'),
|
|
1039
|
+
);
|
|
1040
|
+
const expectedVersion = path.basename(path.dirname(directory));
|
|
1041
|
+
if (manifest.name !== PACKAGE || manifest.version !== expectedVersion) {
|
|
1042
|
+
throw new Error('The installed package does not match its pinned directory.');
|
|
1043
|
+
}
|
|
1044
|
+
await stat(entry);
|
|
1045
|
+
await verifyLaunch({ command: runtime.executable, args: [entry] });
|
|
1046
|
+
return { directory, entry, reused: true };
|
|
1047
|
+
} catch {
|
|
1048
|
+
// An interrupted or manually altered installation stays untouched. A new
|
|
1049
|
+
// immutable installation will be staged beside it instead.
|
|
1050
|
+
return findVerifiedInstallation(candidates, runtime, index + 1);
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
function packageEntry(directory) {
|
|
1055
|
+
return path.join(directory, 'node_modules', PACKAGE, 'dist', 'bin.js');
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
async function installPackage(directory, runtime, version) {
|
|
1059
|
+
const pnpm = await selectedPnpm(runtime);
|
|
1060
|
+
await writeFile(path.join(directory, 'package.json'), '{"private":true}\n', 'utf8');
|
|
1061
|
+
await run(
|
|
1062
|
+
pnpm.command,
|
|
1063
|
+
[
|
|
1064
|
+
...pnpm.args,
|
|
1065
|
+
'--dir',
|
|
1066
|
+
directory,
|
|
1067
|
+
'add',
|
|
1068
|
+
'--prod',
|
|
1069
|
+
'--ignore-scripts',
|
|
1070
|
+
'--save-exact',
|
|
1071
|
+
`${PACKAGE}@${version}`,
|
|
1072
|
+
],
|
|
1073
|
+
{ cwd: directory, env: selectedRuntimeEnvironment(runtime), label: 'pnpm install' },
|
|
1074
|
+
);
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
async function selectedPnpm(runtime) {
|
|
1078
|
+
const nodeDirectory = path.dirname(runtime.executable);
|
|
1079
|
+
const moduleDirectories = [
|
|
1080
|
+
path.resolve(nodeDirectory, '..', 'lib', 'node_modules'),
|
|
1081
|
+
path.join(nodeDirectory, 'node_modules'),
|
|
1082
|
+
];
|
|
1083
|
+
const candidates = moduleDirectories.flatMap(moduleDirectory => [
|
|
1084
|
+
{
|
|
1085
|
+
args: [path.join(moduleDirectory, 'pnpm', 'bin', 'pnpm.cjs')],
|
|
1086
|
+
script: path.join(moduleDirectory, 'pnpm', 'bin', 'pnpm.cjs'),
|
|
1087
|
+
},
|
|
1088
|
+
{
|
|
1089
|
+
args: [path.join(moduleDirectory, 'corepack', 'dist', 'corepack.js'), 'pnpm@10'],
|
|
1090
|
+
script: path.join(moduleDirectory, 'corepack', 'dist', 'corepack.js'),
|
|
1091
|
+
},
|
|
1092
|
+
]);
|
|
1093
|
+
return firstWorkingPnpm(candidates, runtime);
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
async function firstWorkingPnpm(candidates, runtime, index = 0) {
|
|
1097
|
+
const candidate = candidates[index];
|
|
1098
|
+
if (candidate === undefined) {
|
|
1099
|
+
throw new Error(
|
|
1100
|
+
`No usable pnpm or Corepack installation accompanies ${runtime.executable}. Install pnpm with that Node's Corepack, then rerun setup. See https://pnpm.io/installation.`,
|
|
1101
|
+
);
|
|
1102
|
+
}
|
|
1103
|
+
try {
|
|
1104
|
+
await access(candidate.script);
|
|
1105
|
+
await run(runtime.executable, [...candidate.args, '--version'], {
|
|
1106
|
+
cwd: os.tmpdir(),
|
|
1107
|
+
env: selectedRuntimeEnvironment(runtime),
|
|
1108
|
+
capture: true,
|
|
1109
|
+
});
|
|
1110
|
+
return { command: runtime.executable, args: candidate.args };
|
|
1111
|
+
} catch {
|
|
1112
|
+
return firstWorkingPnpm(candidates, runtime, index + 1);
|
|
1113
|
+
}
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
function selectedRuntimeEnvironment(runtime) {
|
|
1117
|
+
const nodeDirectory = path.dirname(runtime.executable);
|
|
1118
|
+
return {
|
|
1119
|
+
...process.env,
|
|
1120
|
+
COREPACK_ENABLE_AUTO_PIN: '0',
|
|
1121
|
+
PATH: `${nodeDirectory}${path.delimiter}${process.env.PATH ?? ''}`,
|
|
1122
|
+
};
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
function dataRoot() {
|
|
1126
|
+
const home = homeDirectory();
|
|
1127
|
+
if (process.platform === 'win32')
|
|
1128
|
+
return process.env.LOCALAPPDATA ?? path.join(home, 'AppData', 'Local');
|
|
1129
|
+
if (process.platform === 'darwin') return path.join(home, 'Library', 'Application Support');
|
|
1130
|
+
return process.env.XDG_DATA_HOME ?? path.join(home, '.local', 'share');
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
function homeDirectory(environment = process.env) {
|
|
1134
|
+
return environment.HOME ?? environment.USERPROFILE ?? os.homedir();
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
async function verifyLaunch(launch, options = {}) {
|
|
1138
|
+
const fixture = await mkdtemp(path.join(os.tmpdir(), 'frontier-setup-check-'));
|
|
1139
|
+
let verificationError;
|
|
1140
|
+
try {
|
|
1141
|
+
await protocolCheck(launch, fixture, undefined, options);
|
|
1142
|
+
} catch (error) {
|
|
1143
|
+
verificationError = error;
|
|
1144
|
+
}
|
|
1145
|
+
try {
|
|
1146
|
+
// Windows may release the launcher's working-directory handle just after
|
|
1147
|
+
// the process closes. This directory is disposable, so let Node's recursive
|
|
1148
|
+
// remover retry only its documented transient filesystem errors.
|
|
1149
|
+
await rm(fixture, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
|
|
1150
|
+
} catch (cleanupError) {
|
|
1151
|
+
if (verificationError === undefined) throw cleanupError;
|
|
1152
|
+
verificationError.message += `\nPreflight cleanup also failed: ${cleanupError.message}`;
|
|
1153
|
+
}
|
|
1154
|
+
if (verificationError !== undefined) throw verificationError;
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
function protocolCheck(
|
|
1158
|
+
launch,
|
|
1159
|
+
cwd,
|
|
1160
|
+
environment = minimalLaunchEnvironment(launch.command),
|
|
1161
|
+
options = {},
|
|
1162
|
+
) {
|
|
1163
|
+
return new Promise((resolve, reject) => {
|
|
1164
|
+
const batch = process.platform === 'win32' && launch.command.endsWith('.cmd');
|
|
1165
|
+
if (batch && launch.args.length !== 0)
|
|
1166
|
+
throw new Error('Setup batch launch takes no arguments.');
|
|
1167
|
+
const command = batch
|
|
1168
|
+
? path.join(environment.SystemRoot ?? environment.SYSTEMROOT, 'System32', 'cmd.exe')
|
|
1169
|
+
: launch.command;
|
|
1170
|
+
// Match the batch launch used by MCP clients with cross-spawn. The setup
|
|
1171
|
+
// wrapper takes no arguments, so only its literal filename needs escaping.
|
|
1172
|
+
const args = batch
|
|
1173
|
+
? ['/d', '/s', '/c', `"${launch.command.replace(/([()\][%!^"`<>&|;, *?])/g, '^$1')}"`]
|
|
1174
|
+
: launch.args;
|
|
1175
|
+
const child = spawn(command, args, {
|
|
1176
|
+
cwd,
|
|
1177
|
+
env: environment,
|
|
1178
|
+
windowsVerbatimArguments: batch,
|
|
1179
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
1180
|
+
});
|
|
1181
|
+
let stderr = '';
|
|
1182
|
+
let stdout = '';
|
|
1183
|
+
let settled = false;
|
|
1184
|
+
let initialized = false;
|
|
1185
|
+
const timeout = setTimeout(
|
|
1186
|
+
() =>
|
|
1187
|
+
finish(new Error(`${options.label ?? 'MCP launch'} timed out waiting for the handshake.`)),
|
|
1188
|
+
options.timeout ?? REQUEST_TIMEOUT_MS,
|
|
1189
|
+
);
|
|
1190
|
+
|
|
1191
|
+
child.stderr.setEncoding('utf8');
|
|
1192
|
+
child.stderr.on('data', chunk => {
|
|
1193
|
+
stderr += chunk;
|
|
1194
|
+
});
|
|
1195
|
+
child.stdout.setEncoding('utf8');
|
|
1196
|
+
child.stdout.on('data', chunk => {
|
|
1197
|
+
stdout += chunk;
|
|
1198
|
+
let newline;
|
|
1199
|
+
while ((newline = stdout.indexOf('\n')) !== -1) {
|
|
1200
|
+
const line = stdout.slice(0, newline).trim();
|
|
1201
|
+
stdout = stdout.slice(newline + 1);
|
|
1202
|
+
if (line.length === 0) continue;
|
|
1203
|
+
let message;
|
|
1204
|
+
try {
|
|
1205
|
+
message = JSON.parse(line);
|
|
1206
|
+
} catch {
|
|
1207
|
+
finish(new Error(`Server wrote non-protocol output to stdout: ${line.slice(0, 200)}`));
|
|
1208
|
+
return;
|
|
1209
|
+
}
|
|
1210
|
+
if (message.id === 1) {
|
|
1211
|
+
if (message.error !== undefined) {
|
|
1212
|
+
finish(new Error(`MCP initialization failed: ${JSON.stringify(message.error)}`));
|
|
1213
|
+
return;
|
|
1214
|
+
}
|
|
1215
|
+
initialized = true;
|
|
1216
|
+
send(child, { jsonrpc: '2.0', method: 'notifications/initialized' });
|
|
1217
|
+
send(child, { jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} });
|
|
1218
|
+
} else if (message.id === 2) {
|
|
1219
|
+
const names = (message.result?.tools ?? []).map(tool => tool.name);
|
|
1220
|
+
if (
|
|
1221
|
+
!initialized ||
|
|
1222
|
+
names.length !== TOOL_NAMES.length ||
|
|
1223
|
+
!TOOL_NAMES.every(name => names.includes(name))
|
|
1224
|
+
) {
|
|
1225
|
+
finish(new Error('MCP tools/list did not return FrontierMCP’s eight tools.'));
|
|
1226
|
+
return;
|
|
1227
|
+
}
|
|
1228
|
+
finish();
|
|
1229
|
+
}
|
|
1230
|
+
}
|
|
1231
|
+
});
|
|
1232
|
+
child.on('error', error =>
|
|
1233
|
+
finish(new Error(`Could not launch ${launch.command}: ${error.message}`, { cause: error })),
|
|
1234
|
+
);
|
|
1235
|
+
child.stdin.on('error', error => {
|
|
1236
|
+
if (!settled)
|
|
1237
|
+
finish(
|
|
1238
|
+
new Error(`Could not write to ${launch.command}: ${error.message}`, { cause: error }),
|
|
1239
|
+
);
|
|
1240
|
+
});
|
|
1241
|
+
child.on('exit', code => {
|
|
1242
|
+
if (!settled) finish(new Error(`Server exited before verification (code ${code}).`));
|
|
1243
|
+
});
|
|
1244
|
+
send(child, {
|
|
1245
|
+
jsonrpc: '2.0',
|
|
1246
|
+
id: 1,
|
|
1247
|
+
method: 'initialize',
|
|
1248
|
+
params: {
|
|
1249
|
+
protocolVersion: '2025-11-25',
|
|
1250
|
+
capabilities: {},
|
|
1251
|
+
clientInfo: { name: 'frontier-setup', version: '1' },
|
|
1252
|
+
},
|
|
1253
|
+
});
|
|
1254
|
+
|
|
1255
|
+
function finish(error) {
|
|
1256
|
+
if (settled) return;
|
|
1257
|
+
settled = true;
|
|
1258
|
+
clearTimeout(timeout);
|
|
1259
|
+
let finishError = error;
|
|
1260
|
+
let completed = false;
|
|
1261
|
+
let forceClose;
|
|
1262
|
+
child.stdin.end();
|
|
1263
|
+
const shutdown = setTimeout(() => {
|
|
1264
|
+
void forceProcessTree(child, environment, options.forceTimeout ?? 2000).then(() => {
|
|
1265
|
+
if (completed) return;
|
|
1266
|
+
forceClose = setTimeout(
|
|
1267
|
+
() =>
|
|
1268
|
+
complete(
|
|
1269
|
+
finishError ??
|
|
1270
|
+
new Error(
|
|
1271
|
+
`${options.label ?? 'MCP launch'} did not exit after bounded termination.`,
|
|
1272
|
+
),
|
|
1273
|
+
),
|
|
1274
|
+
250,
|
|
1275
|
+
);
|
|
1276
|
+
});
|
|
1277
|
+
}, options.shutdownTimeout ?? 2000);
|
|
1278
|
+
child.once('close', () => {
|
|
1279
|
+
complete(finishError);
|
|
1280
|
+
});
|
|
1281
|
+
|
|
1282
|
+
function complete(resultError) {
|
|
1283
|
+
if (completed) return;
|
|
1284
|
+
completed = true;
|
|
1285
|
+
clearTimeout(shutdown);
|
|
1286
|
+
clearTimeout(forceClose);
|
|
1287
|
+
const detail = stderr.trim();
|
|
1288
|
+
if (resultError !== undefined) {
|
|
1289
|
+
reject(
|
|
1290
|
+
new Error(
|
|
1291
|
+
detail ? `${resultError.message}\nServer stderr: ${detail}` : resultError.message,
|
|
1292
|
+
),
|
|
1293
|
+
);
|
|
1294
|
+
} else resolve({ stderr: detail });
|
|
1295
|
+
}
|
|
1296
|
+
}
|
|
1297
|
+
});
|
|
1298
|
+
}
|
|
1299
|
+
|
|
1300
|
+
function forceProcessTree(child, environment, timeout) {
|
|
1301
|
+
if (process.platform !== 'win32') {
|
|
1302
|
+
return new Promise(resolve => {
|
|
1303
|
+
let done = false;
|
|
1304
|
+
let timer;
|
|
1305
|
+
child.once('close', complete);
|
|
1306
|
+
child.kill('SIGTERM');
|
|
1307
|
+
timer = setTimeout(() => {
|
|
1308
|
+
if (child.exitCode === null && child.signalCode === null) child.kill('SIGKILL');
|
|
1309
|
+
complete();
|
|
1310
|
+
}, timeout);
|
|
1311
|
+
|
|
1312
|
+
function complete() {
|
|
1313
|
+
if (done) return;
|
|
1314
|
+
done = true;
|
|
1315
|
+
clearTimeout(timer);
|
|
1316
|
+
child.off('close', complete);
|
|
1317
|
+
resolve();
|
|
1318
|
+
}
|
|
1319
|
+
});
|
|
1320
|
+
}
|
|
1321
|
+
if (child.pid === undefined) return Promise.resolve();
|
|
1322
|
+
|
|
1323
|
+
return new Promise(resolve => {
|
|
1324
|
+
const systemRoot = environment.SystemRoot ?? environment.SYSTEMROOT ?? 'C:\\Windows';
|
|
1325
|
+
const killer = spawn(
|
|
1326
|
+
path.join(systemRoot, 'System32', 'taskkill.exe'),
|
|
1327
|
+
['/pid', String(child.pid), '/t', '/f'],
|
|
1328
|
+
{ stdio: 'ignore', windowsHide: true },
|
|
1329
|
+
);
|
|
1330
|
+
let done = false;
|
|
1331
|
+
const timer = setTimeout(() => {
|
|
1332
|
+
killer.kill();
|
|
1333
|
+
complete();
|
|
1334
|
+
}, timeout);
|
|
1335
|
+
killer.on('error', complete);
|
|
1336
|
+
killer.on('close', complete);
|
|
1337
|
+
|
|
1338
|
+
function complete() {
|
|
1339
|
+
if (done) return;
|
|
1340
|
+
done = true;
|
|
1341
|
+
clearTimeout(timer);
|
|
1342
|
+
resolve();
|
|
1343
|
+
}
|
|
1344
|
+
});
|
|
1345
|
+
}
|
|
1346
|
+
|
|
1347
|
+
function minimalLaunchEnvironment(command) {
|
|
1348
|
+
const environment = { PATH: path.dirname(command) };
|
|
1349
|
+
for (const name of ['HOME', 'USERPROFILE', 'SystemRoot', 'SYSTEMROOT', 'TEMP', 'TMP']) {
|
|
1350
|
+
if (process.env[name] !== undefined) environment[name] = process.env[name];
|
|
1351
|
+
}
|
|
1352
|
+
return environment;
|
|
1353
|
+
}
|
|
1354
|
+
|
|
1355
|
+
function send(child, message) {
|
|
1356
|
+
child.stdin.write(`${JSON.stringify(message)}\n`);
|
|
1357
|
+
}
|
|
1358
|
+
|
|
1359
|
+
function cursorConfigPath() {
|
|
1360
|
+
return path.join(homeDirectory(), '.cursor', 'mcp.json');
|
|
1361
|
+
}
|
|
1362
|
+
|
|
1363
|
+
async function prepareConfig(target, launch) {
|
|
1364
|
+
let source = '';
|
|
1365
|
+
let config = {};
|
|
1366
|
+
let hasExistingFile = false;
|
|
1367
|
+
try {
|
|
1368
|
+
source = await readFile(target, 'utf8');
|
|
1369
|
+
hasExistingFile = true;
|
|
1370
|
+
config = JSON.parse(source);
|
|
1371
|
+
} catch (error) {
|
|
1372
|
+
if (error.code !== 'ENOENT') {
|
|
1373
|
+
throw new Error(`Refusing to replace invalid Cursor JSON at ${target}: ${error.message}`, {
|
|
1374
|
+
cause: error,
|
|
1375
|
+
});
|
|
1376
|
+
}
|
|
1377
|
+
}
|
|
1378
|
+
if (!isObject(config) || (config.mcpServers !== undefined && !isObject(config.mcpServers))) {
|
|
1379
|
+
throw new Error(`Refusing to replace invalid Cursor configuration at ${target}.`);
|
|
1380
|
+
}
|
|
1381
|
+
const servers = config.mcpServers ?? {};
|
|
1382
|
+
const existing = servers[SERVER];
|
|
1383
|
+
const desired = { command: launch.command, args: launch.args };
|
|
1384
|
+
const identical = JSON.stringify(existing) === JSON.stringify(desired);
|
|
1385
|
+
const next = { ...config, mcpServers: { ...servers, [SERVER]: desired } };
|
|
1386
|
+
return { existing, fingerprint: source, hasExistingFile, identical, next, desired };
|
|
1387
|
+
}
|
|
1388
|
+
|
|
1389
|
+
function isObject(value) {
|
|
1390
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
1391
|
+
}
|
|
1392
|
+
|
|
1393
|
+
function printPreview({ runtime, release, installation, target, preview, client }) {
|
|
1394
|
+
process.stdout.write(`Runtime: ${runtime.executable} (Node ${formatVersion(runtime.version)})\n`);
|
|
1395
|
+
process.stdout.write(
|
|
1396
|
+
`Package: ${PACKAGE}@${release.version} (requires ${release.engines.node})\n`,
|
|
1397
|
+
);
|
|
1398
|
+
process.stdout.write(
|
|
1399
|
+
`Installation: ${installation.directory}${installation.reused ? ' (reused after verification)' : ''}\n`,
|
|
1400
|
+
);
|
|
1401
|
+
process.stdout.write(
|
|
1402
|
+
`${client === 'cursor' ? 'Cursor user config' : 'Manual client entry'}: ${target}\n`,
|
|
1403
|
+
);
|
|
1404
|
+
process.stdout.write(
|
|
1405
|
+
`${JSON.stringify({ mcpServers: { [SERVER]: preview.desired } }, null, 2)}\n`,
|
|
1406
|
+
);
|
|
1407
|
+
if (client === 'cursor' && !preview.identical && preview.existing !== undefined) {
|
|
1408
|
+
process.stdout.write('An existing frontier entry needs --replace before --apply.\n');
|
|
1409
|
+
}
|
|
1410
|
+
if (client === 'cursor')
|
|
1411
|
+
process.stdout.write('Preview only. Add --apply to write this Cursor entry.\n');
|
|
1412
|
+
}
|
|
1413
|
+
|
|
1414
|
+
async function applyConfig(target, preview, replace, afterPreparation) {
|
|
1415
|
+
const guard = `${target}.frontier-setup.lock`;
|
|
1416
|
+
await mkdir(path.dirname(target), { recursive: true });
|
|
1417
|
+
let handle;
|
|
1418
|
+
try {
|
|
1419
|
+
handle = await open(guard, 'wx');
|
|
1420
|
+
} catch (error) {
|
|
1421
|
+
if (error.code === 'EEXIST')
|
|
1422
|
+
throw new Error(
|
|
1423
|
+
`Another FrontierMCP setup is applying ${target}. Try again when it finishes.`,
|
|
1424
|
+
{ cause: error },
|
|
1425
|
+
);
|
|
1426
|
+
throw error;
|
|
1427
|
+
}
|
|
1428
|
+
let temporary;
|
|
1429
|
+
try {
|
|
1430
|
+
if (preview.existing !== undefined && !preview.identical && !replace) {
|
|
1431
|
+
throw new Error(
|
|
1432
|
+
'Cursor already has a different frontier entry. Review the preview and rerun with --replace.',
|
|
1433
|
+
);
|
|
1434
|
+
}
|
|
1435
|
+
if (preview.identical) {
|
|
1436
|
+
await assertConfigUnchanged(target, preview.fingerprint);
|
|
1437
|
+
return;
|
|
1438
|
+
}
|
|
1439
|
+
if (preview.hasExistingFile) {
|
|
1440
|
+
const backup = `${target}.frontier-backup-${new Date().toISOString().replace(/[:.]/g, '-')}`;
|
|
1441
|
+
await copyFile(target, backup);
|
|
1442
|
+
process.stdout.write(`Backup: ${backup}\n`);
|
|
1443
|
+
}
|
|
1444
|
+
temporary = `${target}.frontier-setup-${process.pid}-${Date.now()}`;
|
|
1445
|
+
await writeFile(temporary, `${JSON.stringify(preview.next, null, 2)}\n`, 'utf8');
|
|
1446
|
+
if (afterPreparation !== undefined) await afterPreparation();
|
|
1447
|
+
await assertConfigUnchanged(target, preview.fingerprint);
|
|
1448
|
+
await rename(temporary, target);
|
|
1449
|
+
} finally {
|
|
1450
|
+
if (temporary !== undefined) await rm(temporary, { force: true });
|
|
1451
|
+
await handle.close();
|
|
1452
|
+
await rm(guard, { force: true });
|
|
1453
|
+
}
|
|
1454
|
+
}
|
|
1455
|
+
|
|
1456
|
+
async function assertConfigUnchanged(target, fingerprint) {
|
|
1457
|
+
let current = '';
|
|
1458
|
+
try {
|
|
1459
|
+
current = await readFile(target, 'utf8');
|
|
1460
|
+
} catch (error) {
|
|
1461
|
+
if (error.code !== 'ENOENT') throw error;
|
|
1462
|
+
}
|
|
1463
|
+
if (current !== fingerprint) {
|
|
1464
|
+
throw new Error(`Cursor configuration changed since preview; refusing to overwrite ${target}.`);
|
|
1465
|
+
}
|
|
1466
|
+
}
|
|
1467
|
+
|
|
1468
|
+
function run(command, args, options = {}) {
|
|
1469
|
+
return new Promise((resolve, reject) => {
|
|
1470
|
+
const child = spawn(command, args, {
|
|
1471
|
+
cwd: options.cwd,
|
|
1472
|
+
env: options.env,
|
|
1473
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
1474
|
+
timeout: options.timeout,
|
|
1475
|
+
});
|
|
1476
|
+
let stdout = '';
|
|
1477
|
+
let stderr = '';
|
|
1478
|
+
child.stdout.setEncoding('utf8');
|
|
1479
|
+
child.stderr.setEncoding('utf8');
|
|
1480
|
+
child.stdout.on('data', chunk => {
|
|
1481
|
+
stdout += chunk;
|
|
1482
|
+
});
|
|
1483
|
+
child.stderr.on('data', chunk => {
|
|
1484
|
+
stderr += chunk;
|
|
1485
|
+
});
|
|
1486
|
+
child.on('error', error => reject(error));
|
|
1487
|
+
child.on('close', code => {
|
|
1488
|
+
if (code === 0) resolve({ stdout, stderr });
|
|
1489
|
+
else
|
|
1490
|
+
reject(
|
|
1491
|
+
new Error(
|
|
1492
|
+
`${options.label ?? command} exited ${code}: ${stderr.trim() || stdout.trim()}`,
|
|
1493
|
+
),
|
|
1494
|
+
);
|
|
1495
|
+
});
|
|
1496
|
+
});
|
|
1497
|
+
}
|
|
1498
|
+
|
|
1499
|
+
// The check script imports these private helpers to force a fingerprint race
|
|
1500
|
+
// and a broken protocol without adding command-line switches for test cases.
|
|
1501
|
+
module.exports = {
|
|
1502
|
+
testing: {
|
|
1503
|
+
applyConfig,
|
|
1504
|
+
cursorConfigPath,
|
|
1505
|
+
discoverManagedRuntimes,
|
|
1506
|
+
durableLaunch,
|
|
1507
|
+
prepareConfig,
|
|
1508
|
+
protocolCheck,
|
|
1509
|
+
resolveAlternativeRuntimes,
|
|
1510
|
+
selectManagedRuntime,
|
|
1511
|
+
selectRuntime,
|
|
1512
|
+
stableRuntimePath,
|
|
1513
|
+
supportsRange,
|
|
1514
|
+
verifyLaunch,
|
|
1515
|
+
windowsLaunchWrapper,
|
|
1516
|
+
},
|
|
1517
|
+
};
|