@remcp/remcp 0.2.9 → 0.2.11
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/package.json +1 -1
- package/src/agent.mjs +130 -17
- package/src/cli.mjs +73 -14
- package/src/runtime.mjs +13 -2
package/package.json
CHANGED
package/src/agent.mjs
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import os from 'node:os';
|
|
2
|
-
import { existsSync } from 'node:fs';
|
|
2
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import process from 'node:process';
|
|
5
5
|
import { spawn, spawnSync } from 'node:child_process';
|
|
6
6
|
import WebSocket from 'ws';
|
|
7
7
|
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
|
8
8
|
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
|
|
9
|
-
import { normalizeRuntime } from './runtime.mjs';
|
|
9
|
+
import { isRuntimeSpecFor, normalizeRuntime } from './runtime.mjs';
|
|
10
10
|
import { VERSION } from './version.mjs';
|
|
11
11
|
|
|
12
12
|
const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm';
|
|
@@ -82,18 +82,77 @@ function isNewer(candidate, current) {
|
|
|
82
82
|
// What the agent should install, if anything. The client version alone is not enough: a machine
|
|
83
83
|
// that already runs the newest client but an older local runtime would otherwise never catch up,
|
|
84
84
|
// because its runtime is what executes the tools.
|
|
85
|
-
export function updateDecision({ advertised, cliVersion, runtimeVersion }) {
|
|
85
|
+
export function updateDecision({ advertised, cliVersion, runtimeVersion, runtimePackageName, runtimeDown = false }) {
|
|
86
86
|
const cliSpec = String(advertised?.cli || '');
|
|
87
|
-
const
|
|
87
|
+
const advertisedRuntime = String(advertised?.runtime || '');
|
|
88
|
+
// A spec that is not a plain version of the configured runtime package is ignored rather than
|
|
89
|
+
// installed: this is the only place a server-chosen string reaches npm.
|
|
90
|
+
const runtimeSpec = runtimePackageName && advertisedRuntime && !isRuntimeSpecFor(runtimePackageName, advertisedRuntime) ? '' : advertisedRuntime;
|
|
88
91
|
const installedRuntime = String(runtimeVersion || '');
|
|
89
92
|
const runtimeKnown = Boolean(installedRuntime) && !/^unknown$/i.test(installedRuntime);
|
|
90
93
|
if (isNewer(cliSpec, cliVersion)) return { needed: true, target: cliSpec, runtime: runtimeSpec, reason: 'client' };
|
|
91
|
-
if (
|
|
94
|
+
if (runtimeSpec && runtimeKnown && isNewer(runtimeSpec, installedRuntime)) {
|
|
92
95
|
return { needed: true, target: cliSpec || `@remcp/remcp@${cliVersion}`, runtime: runtimeSpec, reason: 'runtime' };
|
|
93
96
|
}
|
|
97
|
+
// A runtime that never reported a version cannot be compared, so a device whose runtime is down
|
|
98
|
+
// (or was never installed) would never repair itself. The cooldown in checkForUpdate keeps this
|
|
99
|
+
// from becoming an install loop.
|
|
100
|
+
if (runtimeSpec && !runtimeKnown && runtimeDown) {
|
|
101
|
+
return { needed: true, target: cliSpec || `@remcp/remcp@${cliVersion}`, runtime: runtimeSpec, reason: 'runtime-repair' };
|
|
102
|
+
}
|
|
94
103
|
return { needed: false, target: cliSpec, runtime: runtimeSpec, reason: 'current' };
|
|
95
104
|
}
|
|
96
105
|
|
|
106
|
+
// Applies a freshly installed version. Exiting is what a supervisor needs; without one the new CLI is
|
|
107
|
+
// started in this process' place. Either way the agent stops holding a stale runtime, which is what
|
|
108
|
+
// makes an update actually take effect on a machine that no service manager watches.
|
|
109
|
+
async function restartToApplyUpdate(cli, stopAgent, markStopping, onRuntimeRepaired) {
|
|
110
|
+
try {
|
|
111
|
+
const installed = globalInstalledVersion();
|
|
112
|
+
if (installed && !isNewer(installed, VERSION)) {
|
|
113
|
+
// The client is current, so the update was a runtime repair: restart the runtime rather than
|
|
114
|
+
// the whole agent, or a device with no usable runtime would stay broken.
|
|
115
|
+
console.log(`ReMCP ${VERSION} is already the installed version; restarting the local runtime.`);
|
|
116
|
+
await onRuntimeRepaired?.();
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
console.log(`ReMCP ${installed || 'a newer version'} installed; restarting to apply it.`);
|
|
120
|
+
if (!supervisorRestart()) {
|
|
121
|
+
spawn(process.execPath, [cli, 'start'], { detached: true, stdio: 'ignore', env: { ...process.env } }).unref();
|
|
122
|
+
}
|
|
123
|
+
markStopping();
|
|
124
|
+
await stopAgent().catch(() => {});
|
|
125
|
+
setTimeout(() => process.exit(0), 100);
|
|
126
|
+
} catch (error) {
|
|
127
|
+
console.error(`Could not restart after the update: ${error instanceof Error ? error.message : String(error)}`);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// The version of the globally installed client, read from the package the CLI resolves to.
|
|
132
|
+
function globalInstalledVersion() {
|
|
133
|
+
try {
|
|
134
|
+
const cli = globalCliEntry();
|
|
135
|
+
if (!cli) return null;
|
|
136
|
+
const base = path.dirname(cli);
|
|
137
|
+
const candidates = [
|
|
138
|
+
path.join(base, '..', 'lib', 'node_modules', '@remcp', 'remcp', 'package.json'),
|
|
139
|
+
path.join(base, '..', 'node_modules', '@remcp', 'remcp', 'package.json'),
|
|
140
|
+
];
|
|
141
|
+
for (const manifest of candidates) {
|
|
142
|
+
try { return JSON.parse(readFileSync(manifest, 'utf8')).version || null; } catch {}
|
|
143
|
+
}
|
|
144
|
+
return null;
|
|
145
|
+
} catch {
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function supervisorRestart() {
|
|
151
|
+
if (process.env.INVOCATION_ID || process.env.JOURNAL_STREAM) return 'systemd';
|
|
152
|
+
try { if (existsSync('/.dockerenv')) return 'docker'; } catch {}
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
|
|
97
156
|
function globalCliEntry() {
|
|
98
157
|
const prefix = spawnSync(npmCommand, ['prefix', '--global'], { encoding: 'utf8' });
|
|
99
158
|
if (prefix.error || prefix.status !== 0) return null;
|
|
@@ -144,9 +203,21 @@ export async function runAgent(options) {
|
|
|
144
203
|
let runtimeVersion = 'unknown';
|
|
145
204
|
let runtimeRestarts = 0;
|
|
146
205
|
let runtimeDown = false;
|
|
206
|
+
// The reason the runtime is not running, sent to the server so the workspace can show something
|
|
207
|
+
// actionable instead of a machine that merely looks connected.
|
|
208
|
+
let runtimeError = '';
|
|
147
209
|
const telemetryQueue = [];
|
|
148
210
|
let telemetryTimer = null;
|
|
149
|
-
|
|
211
|
+
// A device whose runtime is missing or unreadable must still run the agent: the agent is what
|
|
212
|
+
// installs and repairs the runtime, so failing here would remove the only path back.
|
|
213
|
+
let runtimeEntry = '';
|
|
214
|
+
try {
|
|
215
|
+
runtimeEntry = localRuntimeEntry(options.runtime);
|
|
216
|
+
} catch (error) {
|
|
217
|
+
runtimeDown = true;
|
|
218
|
+
runtimeError = error instanceof Error ? error.message : String(error);
|
|
219
|
+
console.error(`${runtimeError} The agent keeps running and retries; remcp update reinstalls the runtime.`);
|
|
220
|
+
}
|
|
150
221
|
|
|
151
222
|
// --- local runtime supervision ------------------------------------------------------
|
|
152
223
|
// If the runtime dies (a bad shell, a broken pipe, an OOM) the agent used to stay
|
|
@@ -190,11 +261,15 @@ export async function runAgent(options) {
|
|
|
190
261
|
runtimeVersion = client.getServerVersion()?.version || runtimeVersion;
|
|
191
262
|
runtimeRestarts += 1;
|
|
192
263
|
runtimeRestartDelay = RUNTIME_RESTART_BASE_MS;
|
|
264
|
+
runtimeDown = false;
|
|
265
|
+
runtimeError = '';
|
|
193
266
|
console.log(`ReMCP local runtime ready (${runtimeVersion})`);
|
|
194
267
|
send({ type: 'metrics', metrics: deviceMetrics({ reconnects, pendingRequests, runtimeVersion, runtimeRestarts, runtimeDown: false }) });
|
|
195
268
|
if (runtimeRestarts > 1) queueEvent({ event: 'runtime_restart', at: Date.now(), count: runtimeRestarts, success: true });
|
|
196
269
|
} catch (error) {
|
|
197
|
-
|
|
270
|
+
runtimeDown = true;
|
|
271
|
+
runtimeError = error instanceof Error ? error.message : String(error);
|
|
272
|
+
console.error(`ReMCP local runtime failed to start: ${runtimeError}`);
|
|
198
273
|
handleRuntimeExit('failed');
|
|
199
274
|
}
|
|
200
275
|
}
|
|
@@ -241,6 +316,7 @@ export async function runAgent(options) {
|
|
|
241
316
|
platform: process.platform,
|
|
242
317
|
arch: process.arch,
|
|
243
318
|
installSpec: String(options.installSpec || ''),
|
|
319
|
+
runtimeState: runtimeDown ? 'down' : 'ready',
|
|
244
320
|
})) {
|
|
245
321
|
persistState({ installReported: true });
|
|
246
322
|
console.log('ReMCP reported this installation to your own workspace (disable with `remcp telemetry off`).');
|
|
@@ -297,11 +373,13 @@ export async function runAgent(options) {
|
|
|
297
373
|
arch: process.arch,
|
|
298
374
|
agentVersion: VERSION,
|
|
299
375
|
runtimeVersion,
|
|
376
|
+
runtimeState: runtimeDown ? 'down' : 'ready',
|
|
377
|
+
runtimeError,
|
|
300
378
|
telemetryEnabled,
|
|
301
379
|
reconnects,
|
|
302
380
|
}));
|
|
303
381
|
console.log(`Connected to ${serverUrl} as ${deviceName}`);
|
|
304
|
-
send({ type: 'metrics', metrics: deviceMetrics({ reconnects, pendingRequests, runtimeVersion, runtimeRestarts, runtimeDown }) });
|
|
382
|
+
send({ type: 'metrics', metrics: deviceMetrics({ reconnects, pendingRequests, runtimeVersion, runtimeRestarts, runtimeDown }), runtimeState: runtimeDown ? 'down' : 'ready', runtimeError });
|
|
305
383
|
reportInstallOnce();
|
|
306
384
|
flushTelemetry();
|
|
307
385
|
void checkForUpdate();
|
|
@@ -324,10 +402,12 @@ export async function runAgent(options) {
|
|
|
324
402
|
return;
|
|
325
403
|
}
|
|
326
404
|
if (code === 1012) {
|
|
327
|
-
// 1012 ('service restart') is what the relay sends when another agent process took over
|
|
328
|
-
//
|
|
329
|
-
//
|
|
330
|
-
console.error('Another ReMCP agent connected for this device; this process will stop. Run one agent per machine (
|
|
405
|
+
// 1012 ('service restart') is what the relay sends when another agent process took over this
|
|
406
|
+
// device. Staying alive would keep a second runtime and a reconnect loop, so this process
|
|
407
|
+
// stops and leaves the device to the agent that owns the connection.
|
|
408
|
+
console.error('Another ReMCP agent connected for this device; this process will stop. Run one agent per machine (a service manager or the remcp start command).');
|
|
409
|
+
stopping = true;
|
|
410
|
+
void stop().finally(() => setTimeout(() => process.exit(0), 100));
|
|
331
411
|
return;
|
|
332
412
|
}
|
|
333
413
|
reconnects += 1;
|
|
@@ -358,7 +438,7 @@ export async function runAgent(options) {
|
|
|
358
438
|
if (minimum && isNewer(minimum, VERSION)) {
|
|
359
439
|
console.error(`ReMCP ${VERSION} is older than the minimum supported agent ${minimum}; update with: remcp update`);
|
|
360
440
|
}
|
|
361
|
-
const decision = updateDecision({ advertised, cliVersion: VERSION, runtimeVersion });
|
|
441
|
+
const decision = updateDecision({ advertised, cliVersion: VERSION, runtimeVersion, runtimePackageName: options.runtime?.packageName, runtimeDown });
|
|
362
442
|
if (!decision.needed) return;
|
|
363
443
|
const target = decision.target;
|
|
364
444
|
queueEvent({ event: 'agent_update', at: Date.now(), reason: `${decision.reason}:${target}`.slice(0, 32), success: true });
|
|
@@ -375,13 +455,46 @@ export async function runAgent(options) {
|
|
|
375
455
|
lastAttemptAt = Date.now();
|
|
376
456
|
updateInFlight = true;
|
|
377
457
|
console.log(`Updating ReMCP to ${target}${decision.runtime ? ` with ${decision.runtime}` : ''} (${decision.reason})…`);
|
|
378
|
-
|
|
458
|
+
// The trust flag is only forwarded when this machine's owner trusted the server at pairing
|
|
459
|
+
// time; otherwise the update stops at the server's own version and asks the user.
|
|
460
|
+
const trustFlag = options.trustRuntime === true ? ['--trust-runtime'] : [];
|
|
461
|
+
const child = spawn(process.execPath, [cli, 'update', ...trustFlag, ...(decision.runtime ? ['--runtime', decision.runtime] : [])], {
|
|
379
462
|
detached: true,
|
|
380
|
-
|
|
463
|
+
// The updater's own output is the only record of why an install failed, so it is piped back
|
|
464
|
+
// into this agent's log instead of being discarded.
|
|
465
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
381
466
|
env: { ...process.env },
|
|
382
467
|
});
|
|
383
|
-
|
|
384
|
-
|
|
468
|
+
const forward = chunk => {
|
|
469
|
+
const line = String(chunk).trim();
|
|
470
|
+
if (line) console.error(`remcp update: ${line}`);
|
|
471
|
+
};
|
|
472
|
+
child.stdout?.on('data', forward);
|
|
473
|
+
child.stderr?.on('data', forward);
|
|
474
|
+
child.on('exit', code => {
|
|
475
|
+
updateInFlight = false;
|
|
476
|
+
if (code !== 0) {
|
|
477
|
+
console.error(`remcp update exited with ${code}; keeping ${VERSION} and retrying after the cooldown.`);
|
|
478
|
+
return;
|
|
479
|
+
}
|
|
480
|
+
void restartToApplyUpdate(cli, stop, () => { stopping = true; }, async () => {
|
|
481
|
+
// The packages are installed now: clear the failure, reset the backoff and start again.
|
|
482
|
+
runtimeRestartDelay = RUNTIME_RESTART_BASE_MS;
|
|
483
|
+
runtimeDown = false;
|
|
484
|
+
runtimeError = '';
|
|
485
|
+
try {
|
|
486
|
+
runtimeEntry = localRuntimeEntry(options.runtime);
|
|
487
|
+
} catch (error) {
|
|
488
|
+
runtimeDown = true;
|
|
489
|
+
runtimeError = error instanceof Error ? error.message : String(error);
|
|
490
|
+
}
|
|
491
|
+
if (!runtimeDown && !stopping) await startRuntime();
|
|
492
|
+
});
|
|
493
|
+
});
|
|
494
|
+
child.on('error', error => {
|
|
495
|
+
updateInFlight = false;
|
|
496
|
+
console.error(`remcp update could not start: ${error.message}`);
|
|
497
|
+
});
|
|
385
498
|
child.unref();
|
|
386
499
|
} catch {
|
|
387
500
|
// Offline, DNS failure, older server without the endpoint: keep running as-is.
|
package/src/cli.mjs
CHANGED
|
@@ -5,7 +5,7 @@ import process from 'node:process';
|
|
|
5
5
|
import { spawnSync } from 'node:child_process';
|
|
6
6
|
import { randomUUID } from 'node:crypto';
|
|
7
7
|
import { runAgent } from './agent.mjs';
|
|
8
|
-
import { normalizeRuntime } from './runtime.mjs';
|
|
8
|
+
import { isRuntimeSpecFor, normalizeRuntime } from './runtime.mjs';
|
|
9
9
|
import { PACKAGE_NAME, VERSION } from './version.mjs';
|
|
10
10
|
|
|
11
11
|
const home = os.homedir();
|
|
@@ -200,12 +200,41 @@ function restartPersistentServiceIfInstalled() {
|
|
|
200
200
|
if (platform === 'linux' && fs.existsSync(linuxServiceFile)) {
|
|
201
201
|
run('systemctl', ['--user', 'daemon-reload']);
|
|
202
202
|
run('systemctl', ['--user', 'restart', 'remcp-agent.service']);
|
|
203
|
-
|
|
203
|
+
return 'remcp-agent.service';
|
|
204
|
+
}
|
|
205
|
+
if (platform === 'darwin' && fs.existsSync(macServiceFile)) {
|
|
204
206
|
run('launchctl', ['kickstart', '-k', `${macLaunchDomain()}/${macServiceLabel}`]);
|
|
205
|
-
|
|
207
|
+
return macServiceLabel;
|
|
208
|
+
}
|
|
209
|
+
if (platform === 'win32') {
|
|
206
210
|
const result = spawnSync('schtasks.exe', ['/Query', '/TN', windowsTaskName], { stdio: 'ignore' });
|
|
207
|
-
if (result.status === 0)
|
|
211
|
+
if (result.status === 0) {
|
|
212
|
+
run('schtasks.exe', ['/Run', '/TN', windowsTaskName]);
|
|
213
|
+
return windowsTaskName;
|
|
214
|
+
}
|
|
208
215
|
}
|
|
216
|
+
return null;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// The agent the user installed with `remcp install` is the one this CLI manages. A machine can also
|
|
220
|
+
// be supervised by its own systemd unit, by Docker, or by a terminal, and in those cases installing
|
|
221
|
+
// a new version is not enough: the running process keeps the old code until something restarts it.
|
|
222
|
+
// systemd marks every unit process with INVOCATION_ID and Docker leaves /.dockerenv, so those two
|
|
223
|
+
// cases can be handed over by exiting (the supervisor starts the new build); anything else gets an
|
|
224
|
+
// explicit instruction instead of a silent exit that would take the device offline.
|
|
225
|
+
function supervisorRestart() {
|
|
226
|
+
if (process.env.INVOCATION_ID || process.env.JOURNAL_STREAM) return 'systemd';
|
|
227
|
+
try { if (fs.existsSync('/.dockerenv')) return 'docker'; } catch {}
|
|
228
|
+
return null;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Reads the version a freshly installed global package reports, so an update that installed
|
|
232
|
+
// nothing (wrong prefix, npm cache, permissions) is reported instead of assumed successful.
|
|
233
|
+
function installedVersion(packageName) {
|
|
234
|
+
const prefix = spawnSync(npmCommand, ['prefix', '--global'], { encoding: 'utf8' });
|
|
235
|
+
if (prefix.error || prefix.status !== 0) return null;
|
|
236
|
+
const manifest = path.join(String(prefix.stdout || '').trim(), 'lib', 'node_modules', ...packageName.split('/'), 'package.json');
|
|
237
|
+
try { return JSON.parse(fs.readFileSync(manifest, 'utf8')).version || null; } catch { return null; }
|
|
209
238
|
}
|
|
210
239
|
|
|
211
240
|
function uninstallPersistentService() {
|
|
@@ -262,6 +291,9 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
262
291
|
deviceName: String(flags.name || os.hostname()),
|
|
263
292
|
runtime: normalizeRuntime(paired.runtime),
|
|
264
293
|
machineId: ensureMachineId(),
|
|
294
|
+
// Remember whether this machine's owner trusted the server to name a runtime version. The
|
|
295
|
+
// auto-updater must not widen that decision on its own later.
|
|
296
|
+
trustRuntime: Boolean(flags['trust-runtime']) || new URL(server).origin === officialOrigin,
|
|
265
297
|
};
|
|
266
298
|
saveConfig(config);
|
|
267
299
|
console.log(`Paired ${os.hostname()} with ${server}`);
|
|
@@ -275,6 +307,9 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
275
307
|
await runAgent({
|
|
276
308
|
...cfg,
|
|
277
309
|
autoUpdate: cfg.autoUpdate !== false,
|
|
310
|
+
trustRuntime: cfg.trustRuntime === true
|
|
311
|
+
|| process.env.REMCP_TRUST_RUNTIME === '1'
|
|
312
|
+
|| new URL(cfg.serverUrl).origin === officialOrigin,
|
|
278
313
|
telemetryEnabled: telemetry.enabled,
|
|
279
314
|
installReported: telemetry.installReported,
|
|
280
315
|
installSpec: `${PACKAGE_NAME}@${VERSION}`,
|
|
@@ -338,26 +373,50 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
338
373
|
// parses, and an explicit --trust-runtime before a custom server may change it.
|
|
339
374
|
let runtimeSpec = cfg.runtime.packageSpec;
|
|
340
375
|
if (requested) {
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
if (
|
|
344
|
-
throw new Error(`--runtime must
|
|
376
|
+
// Only `<configured package>@<semver>` is installable: an alias, a git/URL/file spec, a tag or
|
|
377
|
+
// a range would run code the user never agreed to.
|
|
378
|
+
if (!isRuntimeSpecFor(cfg.runtime.packageName, requested)) {
|
|
379
|
+
throw new Error(`--runtime must be ${cfg.runtime.packageName}@<version>`);
|
|
345
380
|
}
|
|
346
|
-
|
|
347
|
-
|
|
381
|
+
const trusted = cfg.trustRuntime === true
|
|
382
|
+
|| Boolean(flags['trust-runtime'])
|
|
383
|
+
|| process.env.REMCP_TRUST_RUNTIME === '1'
|
|
384
|
+
// A configuration written before the field existed paired with the official server, which is
|
|
385
|
+
// trusted by definition; refusing it would silently stop every existing device updating.
|
|
386
|
+
|| new URL(cfg.serverUrl).origin === officialOrigin;
|
|
387
|
+
if (!trusted) {
|
|
388
|
+
throw new Error(`This machine was paired without trusting ${cfg.serverUrl} to choose a runtime version. Re-run with --trust-runtime if you trust that server.`);
|
|
348
389
|
}
|
|
349
|
-
runtimeSpec = normalizeRuntime({ kind: 'npm', packageName:
|
|
390
|
+
runtimeSpec = normalizeRuntime({ kind: 'npm', packageName: cfg.runtime.packageName, packageSpec: requested, entry: cfg.runtime.entry }).packageSpec;
|
|
350
391
|
}
|
|
351
392
|
if (flags.check) {
|
|
352
|
-
console.log(JSON.stringify({
|
|
393
|
+
console.log(JSON.stringify({
|
|
394
|
+
current: VERSION,
|
|
395
|
+
installedRuntime: installedVersion(cfg.runtime.packageName),
|
|
396
|
+
runtimeSpec: runtimeSpec,
|
|
397
|
+
available: `${PACKAGE_NAME}@latest`,
|
|
398
|
+
managedService: fs.existsSync(linuxServiceFile) || fs.existsSync(macServiceFile),
|
|
399
|
+
supervisor: supervisorRestart() ?? 'none',
|
|
400
|
+
}, null, 2));
|
|
353
401
|
return;
|
|
354
402
|
}
|
|
403
|
+
const before = { cli: VERSION, runtime: installedVersion(cfg.runtime.packageName) };
|
|
355
404
|
console.log(`Updating ReMCP to the latest published version (${runtimeSpec})…`);
|
|
356
405
|
npmGlobalInstall(`${PACKAGE_NAME}@latest`, runtimeSpec);
|
|
357
406
|
// Only a validated spec is persisted, so a failed update cannot leave the install unable to start.
|
|
358
407
|
if (requested && runtimeSpec !== cfg.runtime.packageSpec) saveConfig({ ...cfg, runtime: { ...cfg.runtime, packageSpec: runtimeSpec } });
|
|
359
|
-
|
|
360
|
-
|
|
408
|
+
const after = { cli: installedVersion(PACKAGE_NAME), runtime: installedVersion(cfg.runtime.packageName) };
|
|
409
|
+
const restarted = restartPersistentServiceIfInstalled();
|
|
410
|
+
if (restarted) {
|
|
411
|
+
console.log(`ReMCP updated and ${restarted} restarted (client ${before.cli} → ${after.cli ?? '?'}, runtime ${before.runtime ?? '?'} → ${after.runtime ?? '?'}).`);
|
|
412
|
+
return;
|
|
413
|
+
}
|
|
414
|
+
// This process is the updater, not the agent: exiting here would restart nothing. The agent sees
|
|
415
|
+
// the exit status, verifies the installed version, and restarts itself.
|
|
416
|
+
console.log(`ReMCP updated (client ${before.cli} → ${after.cli ?? '?'}, runtime ${before.runtime ?? '?'} → ${after.runtime ?? '?'}). The running agent restarts itself to apply it.`);
|
|
417
|
+
if (after.cli === before.cli && after.runtime === before.runtime) {
|
|
418
|
+
console.log('Nothing changed: the installed versions already match the requested ones.');
|
|
419
|
+
}
|
|
361
420
|
return;
|
|
362
421
|
}
|
|
363
422
|
|
package/src/runtime.mjs
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
2
|
|
|
3
3
|
const PACKAGE_NAME = /^(?:@[a-z0-9._-]+\/)?[a-z0-9._-]+$/i;
|
|
4
|
-
|
|
4
|
+
// A runtime spec is a plain published version of one package. Anything else — an npm alias
|
|
5
|
+
// (`npm:@other/pkg`), a git/https/file spec, a tag, or a version range — is a way to make a device
|
|
6
|
+
// install and execute code the user never agreed to, so only `name@x.y.z[-pre][+build]` is accepted.
|
|
7
|
+
const SEMVER = '\\d+\\.\\d+\\.\\d+(?:-[0-9A-Za-z.-]+)?(?:\\+[0-9A-Za-z.-]+)?';
|
|
5
8
|
|
|
6
9
|
export function normalizeRuntime(value) {
|
|
7
10
|
if (!value || value.kind !== 'npm') throw new Error('Pairing server did not provide a supported local runtime');
|
|
@@ -9,7 +12,15 @@ export function normalizeRuntime(value) {
|
|
|
9
12
|
const packageSpec = String(value.packageSpec || '');
|
|
10
13
|
const entry = String(value.entry || '');
|
|
11
14
|
if (!PACKAGE_NAME.test(packageName)) throw new Error('Pairing server returned an invalid runtime package name');
|
|
12
|
-
if (!
|
|
15
|
+
if (!isRuntimeSpecFor(packageName, packageSpec)) throw new Error(`Runtime package spec must be ${packageName}@<version>`);
|
|
13
16
|
if (!entry || path.isAbsolute(entry) || entry.split(/[\\/]+/).includes('..')) throw new Error('Pairing server returned an invalid runtime entry');
|
|
14
17
|
return { kind: 'npm', packageName, packageSpec, entry };
|
|
15
18
|
}
|
|
19
|
+
|
|
20
|
+
// True only for `<packageName>@<semver>` of exactly that package. Exported so the CLI and the agent
|
|
21
|
+
// can validate a server-advertised version without repeating the rule.
|
|
22
|
+
export function isRuntimeSpecFor(packageName, packageSpec) {
|
|
23
|
+
if (!PACKAGE_NAME.test(String(packageName || ''))) return false;
|
|
24
|
+
const escaped = String(packageName).replace(/[.*+?^${}()|[\]\\/]/g, '\\$&');
|
|
25
|
+
return new RegExp(`^${escaped}@(${SEMVER})$`).test(String(packageSpec || ''));
|
|
26
|
+
}
|