@remcp/remcp 0.2.33 → 0.2.35

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remcp/remcp",
3
- "version": "0.2.33",
3
+ "version": "0.2.35",
4
4
  "description": "ReMCP device client: pair a computer with ReMCP and run the outbound-only agent that hosts the local MCP runtime.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -18,7 +18,7 @@
18
18
  "README.md"
19
19
  ],
20
20
  "scripts": {
21
- "check": "node --check bin/remcp.mjs && node --check src/agent.mjs && node --check src/cli.mjs && node --check src/cli/config.mjs && node --check src/cli/connect.mjs && node --check src/cli/doctor.mjs && node --check src/cli/env.mjs && node --check src/cli/service.mjs && node --check src/cli/shell.mjs && node --check src/fs-access.mjs && node --check src/npm.mjs && node --check src/runtime.mjs && node --check src/version.mjs",
21
+ "check": "node --check bin/remcp.mjs && node --check src/agent-update.mjs && node --check src/agent.mjs && node --check src/cli.mjs && node --check src/cli/config.mjs && node --check src/cli/connect.mjs && node --check src/cli/doctor.mjs && node --check src/cli/env.mjs && node --check src/cli/service.mjs && node --check src/cli/shell.mjs && node --check src/cli/update.mjs && node --check src/fs-access.mjs && node --check src/npm.mjs && node --check src/runtime.mjs && node --check src/version.mjs",
22
22
  "test": "node --test test/*.test.mjs"
23
23
  },
24
24
  "dependencies": {
@@ -0,0 +1,180 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import process from 'node:process';
4
+ import { spawn, spawnSync } from 'node:child_process';
5
+ import { resolveNpm } from './npm.mjs';
6
+ import { isRuntimeSpecFor, normalizeRuntime } from './runtime.mjs';
7
+ import { VERSION } from './version.mjs';
8
+
9
+ const npm = resolveNpm();
10
+ // How long a handing-over agent waits for its replacement to take the device over before it keeps
11
+ // running itself. Long enough for a fresh process to connect and be registered.
12
+ const REPLACEMENT_HANDOVER_TIMEOUT_MS = 20_000;
13
+
14
+ function globalNodeModules() {
15
+ const result = spawnSync(npm.command, [...npm.args, 'root', '--global'], { encoding: 'utf8' });
16
+ if (result.error || result.status !== 0) throw new Error('Could not locate the global npm modules directory');
17
+ return String(result.stdout || '').trim();
18
+ }
19
+
20
+ export function localRuntimeEntry(runtimeValue) {
21
+ const runtime = normalizeRuntime(runtimeValue);
22
+ const candidate = path.join(globalNodeModules(), ...runtime.packageName.split('/'), ...runtime.entry.split(/[\\/]+/));
23
+ if (!existsSync(candidate)) throw new Error('ReMCP local runtime is not installed. Run `remcp install`.');
24
+ return candidate;
25
+ }
26
+
27
+ function parseVersion(value) {
28
+ // Prerelease and build metadata are kept, because comparing only the numeric core made
29
+ // 1.0.0 look newer than 1.0.0-beta.2 and left a machine stuck on the prerelease forever.
30
+ const match = String(value || '').match(/(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?/);
31
+ return match ? { parts: [Number(match[1]), Number(match[2]), Number(match[3])], prerelease: match[4] || '' } : null;
32
+ }
33
+
34
+ function isNewer(candidate, current) {
35
+ const a = parseVersion(candidate);
36
+ const b = parseVersion(current);
37
+ if (!a || !b) return false;
38
+ for (let index = 0; index < 3; index += 1) {
39
+ if (a.parts[index] > b.parts[index]) return true;
40
+ if (a.parts[index] < b.parts[index]) return false;
41
+ }
42
+ // Same numeric core: a release is newer than a prerelease, and two prereleases compare by
43
+ // identifier (numeric identifiers order numerically, as semver requires).
44
+ if (!a.prerelease && b.prerelease) return true;
45
+ if (a.prerelease && !b.prerelease) return false;
46
+ if (!a.prerelease && !b.prerelease) return false;
47
+ const left = a.prerelease.split('.');
48
+ const right = b.prerelease.split('.');
49
+ for (let index = 0; index < Math.max(left.length, right.length); index += 1) {
50
+ const one = left[index];
51
+ const two = right[index];
52
+ if (one === undefined) return false;
53
+ if (two === undefined) return true;
54
+ if (one === two) continue;
55
+ const oneNumeric = /^\d+$/.test(one);
56
+ const twoNumeric = /^\d+$/.test(two);
57
+ if (oneNumeric && twoNumeric) return Number(one) > Number(two);
58
+ if (oneNumeric !== twoNumeric) return oneNumeric;
59
+ return one > two;
60
+ }
61
+ return false;
62
+ }
63
+
64
+ // What the agent should install, if anything. The client version alone is not enough: a machine
65
+ // that already runs the newest client but an older local runtime would otherwise never catch up,
66
+ // because its runtime is what executes the tools.
67
+ export function updateDecision({ advertised, cliVersion, runtimeVersion, runtimePackageName, runtimeDown = false }) {
68
+ const cliSpec = String(advertised?.cli || '');
69
+ const advertisedRuntime = String(advertised?.runtime || '');
70
+ // A spec that is not a plain version of the configured runtime package is ignored rather than
71
+ // installed: this is the only place a server-chosen string reaches npm.
72
+ const runtimeSpec = runtimePackageName && advertisedRuntime && !isRuntimeSpecFor(runtimePackageName, advertisedRuntime) ? '' : advertisedRuntime;
73
+ const installedRuntime = String(runtimeVersion || '');
74
+ const runtimeKnown = Boolean(installedRuntime) && !/^unknown$/i.test(installedRuntime);
75
+ if (isNewer(cliSpec, cliVersion)) return { needed: true, target: cliSpec, runtime: runtimeSpec, reason: 'client' };
76
+ if (runtimeSpec && runtimeKnown && isNewer(runtimeSpec, installedRuntime)) {
77
+ return { needed: true, target: cliSpec || `@remcp/remcp@${cliVersion}`, runtime: runtimeSpec, reason: 'runtime' };
78
+ }
79
+ // A runtime that never reported a version cannot be compared, so a device whose runtime is down
80
+ // (or was never installed) would never repair itself. The cooldown in checkForUpdate keeps this
81
+ // from becoming an install loop.
82
+ if (runtimeSpec && !runtimeKnown && runtimeDown) {
83
+ return { needed: true, target: cliSpec || `@remcp/remcp@${cliVersion}`, runtime: runtimeSpec, reason: 'runtime-repair' };
84
+ }
85
+ return { needed: false, target: cliSpec, runtime: runtimeSpec, reason: 'current' };
86
+ }
87
+
88
+ // Applies a freshly installed version. Exiting is what a supervisor needs; without one the new CLI is
89
+ // started in this process' place. Either way the agent stops holding a stale runtime, which is what
90
+ // makes an update actually take effect on a machine that no service manager watches.
91
+ export async function restartToApplyUpdate(cli, stopAgent, markStopping, onRuntimeRepaired, isStopping) {
92
+ try {
93
+ const installed = globalInstalledVersion();
94
+ if (installed && !isNewer(installed, VERSION)) {
95
+ // The client is current, so the update was a runtime repair: restart the runtime rather than
96
+ // the whole agent, or a device with no usable runtime would stay broken.
97
+ console.log(`ReMCP ${VERSION} is already the installed version; restarting the local runtime.`);
98
+ await onRuntimeRepaired?.();
99
+ return;
100
+ }
101
+ // Handing over to a version that cannot start would take the machine offline with nobody left to
102
+ // retry. The new CLI has to answer `--version` before this process steps aside.
103
+ const probe = spawnSync(process.execPath, [cli, '--version'], { encoding: 'utf8', timeout: 30000 });
104
+ const reported = String(probe.stdout || '').trim();
105
+ if (probe.error || probe.status !== 0 || !/^\d+\.\d+\.\d+/.test(reported)) {
106
+ console.error(`The installed ReMCP ${installed || 'update'} did not run (${probe.error?.message || `exit ${probe.status}`}${reported ? `: ${reported}` : ''}). Keeping ${VERSION} running; retry with: remcp update`);
107
+ return;
108
+ }
109
+ console.log(`ReMCP ${installed || 'a newer version'} installed and verified (${reported}); restarting to apply it.`);
110
+ if (!supervisorRestart()) {
111
+ spawn(process.execPath, [cli, 'start'], { detached: true, stdio: 'ignore', env: { ...process.env } }).unref();
112
+ // Stepping aside is only safe once the replacement really holds the device: the relay closes
113
+ // this socket with 1012 ('replaced') the moment another agent takes the machine over, and that
114
+ // close is what stops this process. Without the wait, a replacement that cannot start left the
115
+ // machine connected in `/health` and offline everywhere else, with nobody left to retry.
116
+ await new Promise(resolve => setTimeout(resolve, REPLACEMENT_HANDOVER_TIMEOUT_MS));
117
+ if (!isStopping()) {
118
+ console.error(`The replacement agent did not take over within ${Math.round(REPLACEMENT_HANDOVER_TIMEOUT_MS / 1000)}s; keeping ${VERSION} running. Retry with: remcp update`);
119
+ return;
120
+ }
121
+ }
122
+ markStopping();
123
+ await stopAgent().catch(() => {});
124
+ setTimeout(() => process.exit(0), 100);
125
+ } catch (error) {
126
+ console.error(`Could not restart after the update: ${error instanceof Error ? error.message : String(error)}`);
127
+ }
128
+ }
129
+
130
+ // The version of the globally installed client, read from the package the CLI resolves to.
131
+ export function globalInstalledVersion() {
132
+ try {
133
+ const cli = globalCliEntry();
134
+ if (!cli) return null;
135
+ const base = path.dirname(cli);
136
+ const candidates = [
137
+ path.join(base, '..', 'lib', 'node_modules', '@remcp', 'remcp', 'package.json'),
138
+ path.join(base, '..', 'node_modules', '@remcp', 'remcp', 'package.json'),
139
+ ];
140
+ for (const manifest of candidates) {
141
+ try { return JSON.parse(readFileSync(manifest, 'utf8')).version || null; } catch {}
142
+ }
143
+ return null;
144
+ } catch {
145
+ return null;
146
+ }
147
+ }
148
+
149
+ // True when this process is the one a service manager owns: launchd and systemd's system manager run
150
+ // a unit's main process as a child of PID 1, and `systemd --user` runs it as a child of the user
151
+ // manager. Anything else — a terminal, a shell inside another unit, a CI runner job — has nobody
152
+ // waiting to start the agent again.
153
+ function parentIsServiceManager() {
154
+ if (process.ppid === 1) return true;
155
+ if (process.platform === 'win32') return false;
156
+ try { return readFileSync(`/proc/${process.ppid}/comm`, 'utf8').trim() === 'systemd'; } catch { return false; }
157
+ }
158
+
159
+ // What starts the agent again after it exits to apply an update, or null when it has to start its own
160
+ // replacement. systemd sets INVOCATION_ID and JOURNAL_STREAM for a unit and every child of that unit
161
+ // inherits them, so a `remcp start` run from a shell inside a service (a CI runner, a systemd-run
162
+ // scope, another agent) believed a supervisor would bring it back: the update exited into nothing and
163
+ // the workspace showed the machine offline until someone started the agent by hand. Only the unit's
164
+ // own main process is restarted, so that is what the check requires.
165
+ //
166
+ // Injectable for tests: the verdict must not depend on the machine that runs them.
167
+ export function supervisorRestart({ platform = process.platform, dockerenv = existsSync('/.dockerenv'), parentOurs = parentIsServiceManager() } = {}) {
168
+ if (parentOurs) return platform === 'darwin' ? 'launchd' : 'systemd';
169
+ if (dockerenv) return 'docker';
170
+ return null;
171
+ }
172
+
173
+ export function globalCliEntry() {
174
+ const prefix = spawnSync(npm.command, [...npm.args, 'prefix', '--global'], { encoding: 'utf8' });
175
+ if (prefix.error || prefix.status !== 0) return null;
176
+ const base = String(prefix.stdout || '').trim();
177
+ return process.platform === 'win32'
178
+ ? path.join(base, 'remcp.cmd')
179
+ : path.join(base, 'bin', 'remcp');
180
+ }
package/src/agent.mjs CHANGED
@@ -1,25 +1,26 @@
1
1
  import os from 'node:os';
2
- import { existsSync, readFileSync } from 'node:fs';
3
- import path from 'node:path';
2
+ import { existsSync } from 'node:fs';
4
3
  import process from 'node:process';
5
- import { spawn, spawnSync } from 'node:child_process';
4
+ import { spawn } from 'node:child_process';
6
5
  import WebSocket from 'ws';
7
6
  import { Client } from '@modelcontextprotocol/sdk/client/index.js';
8
7
  import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
9
- import { resolveNpm } from './npm.mjs';
10
- import { isRuntimeSpecFor, normalizeRuntime } from './runtime.mjs';
11
8
  import { VERSION } from './version.mjs';
9
+ import {
10
+ globalCliEntry,
11
+ globalInstalledVersion,
12
+ localRuntimeEntry,
13
+ restartToApplyUpdate,
14
+ supervisorRestart,
15
+ updateDecision,
16
+ } from './agent-update.mjs';
17
+
18
+ export { localRuntimeEntry, supervisorRestart, updateDecision } from './agent-update.mjs';
12
19
 
13
- // See src/npm.mjs: a service started by launchd or systemd has a minimal PATH, so npm is resolved
14
- // from the running node instead of being looked up on PATH.
15
- const npm = resolveNpm();
16
20
  const UPDATE_CHECK_INTERVAL_MS = 6 * 60 * 60 * 1000;
17
21
  const UPDATE_CHECK_TIMEOUT_MS = 5000;
18
22
  // A version that failed to install is retried after this cooldown instead of on every reconnect.
19
23
  const UPDATE_RETRY_COOLDOWN_MS = 30 * 60 * 1000;
20
- // How long a handing-over agent waits for its replacement to take the device over before it keeps
21
- // running itself. Long enough for a fresh process to install nothing, connect and be registered.
22
- const REPLACEMENT_HANDOVER_TIMEOUT_MS = 20_000;
23
24
  const METRICS_INTERVAL_MS = 60_000;
24
25
  const TELEMETRY_QUEUE_LIMIT = 500;
25
26
  const TELEMETRY_BATCH_LIMIT = 100;
@@ -38,174 +39,6 @@ const RUNTIME_RESTART_MAX_MS = 30_000;
38
39
  // timeout while the device keeps working invisibly.
39
40
  const CALL_TIMEOUT_MARGIN_MS = 10_000;
40
41
 
41
- function globalNodeModules() {
42
- const result = spawnSync(npm.command, [...npm.args, 'root', '--global'], { encoding: 'utf8' });
43
- if (result.error || result.status !== 0) throw new Error('Could not locate the global npm modules directory');
44
- return String(result.stdout || '').trim();
45
- }
46
-
47
- export function localRuntimeEntry(runtimeValue) {
48
- const runtime = normalizeRuntime(runtimeValue);
49
- const candidate = path.join(globalNodeModules(), ...runtime.packageName.split('/'), ...runtime.entry.split(/[\\/]+/));
50
- if (!existsSync(candidate)) throw new Error('ReMCP local runtime is not installed. Run `remcp install`.');
51
- return candidate;
52
- }
53
-
54
- function parseVersion(value) {
55
- // Prerelease and build metadata are kept, because comparing only the numeric core made
56
- // 1.0.0 look newer than 1.0.0-beta.2 and left a machine stuck on the prerelease forever.
57
- const match = String(value || '').match(/(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?/);
58
- return match ? { parts: [Number(match[1]), Number(match[2]), Number(match[3])], prerelease: match[4] || '' } : null;
59
- }
60
-
61
- function isNewer(candidate, current) {
62
- const a = parseVersion(candidate);
63
- const b = parseVersion(current);
64
- if (!a || !b) return false;
65
- for (let index = 0; index < 3; index += 1) {
66
- if (a.parts[index] > b.parts[index]) return true;
67
- if (a.parts[index] < b.parts[index]) return false;
68
- }
69
- // Same numeric core: a release is newer than a prerelease, and two prereleases compare by
70
- // identifier (numeric identifiers order numerically, as semver requires).
71
- if (!a.prerelease && b.prerelease) return true;
72
- if (a.prerelease && !b.prerelease) return false;
73
- if (!a.prerelease && !b.prerelease) return false;
74
- const left = a.prerelease.split('.');
75
- const right = b.prerelease.split('.');
76
- for (let index = 0; index < Math.max(left.length, right.length); index += 1) {
77
- const one = left[index];
78
- const two = right[index];
79
- if (one === undefined) return false;
80
- if (two === undefined) return true;
81
- if (one === two) continue;
82
- const oneNumeric = /^\d+$/.test(one);
83
- const twoNumeric = /^\d+$/.test(two);
84
- if (oneNumeric && twoNumeric) return Number(one) > Number(two);
85
- if (oneNumeric !== twoNumeric) return oneNumeric;
86
- return one > two;
87
- }
88
- return false;
89
- }
90
-
91
- // What the agent should install, if anything. The client version alone is not enough: a machine
92
- // that already runs the newest client but an older local runtime would otherwise never catch up,
93
- // because its runtime is what executes the tools.
94
- export function updateDecision({ advertised, cliVersion, runtimeVersion, runtimePackageName, runtimeDown = false }) {
95
- const cliSpec = String(advertised?.cli || '');
96
- const advertisedRuntime = String(advertised?.runtime || '');
97
- // A spec that is not a plain version of the configured runtime package is ignored rather than
98
- // installed: this is the only place a server-chosen string reaches npm.
99
- const runtimeSpec = runtimePackageName && advertisedRuntime && !isRuntimeSpecFor(runtimePackageName, advertisedRuntime) ? '' : advertisedRuntime;
100
- const installedRuntime = String(runtimeVersion || '');
101
- const runtimeKnown = Boolean(installedRuntime) && !/^unknown$/i.test(installedRuntime);
102
- if (isNewer(cliSpec, cliVersion)) return { needed: true, target: cliSpec, runtime: runtimeSpec, reason: 'client' };
103
- if (runtimeSpec && runtimeKnown && isNewer(runtimeSpec, installedRuntime)) {
104
- return { needed: true, target: cliSpec || `@remcp/remcp@${cliVersion}`, runtime: runtimeSpec, reason: 'runtime' };
105
- }
106
- // A runtime that never reported a version cannot be compared, so a device whose runtime is down
107
- // (or was never installed) would never repair itself. The cooldown in checkForUpdate keeps this
108
- // from becoming an install loop.
109
- if (runtimeSpec && !runtimeKnown && runtimeDown) {
110
- return { needed: true, target: cliSpec || `@remcp/remcp@${cliVersion}`, runtime: runtimeSpec, reason: 'runtime-repair' };
111
- }
112
- return { needed: false, target: cliSpec, runtime: runtimeSpec, reason: 'current' };
113
- }
114
-
115
- // Applies a freshly installed version. Exiting is what a supervisor needs; without one the new CLI is
116
- // started in this process' place. Either way the agent stops holding a stale runtime, which is what
117
- // makes an update actually take effect on a machine that no service manager watches.
118
- async function restartToApplyUpdate(cli, stopAgent, markStopping, onRuntimeRepaired, isStopping) {
119
- try {
120
- const installed = globalInstalledVersion();
121
- if (installed && !isNewer(installed, VERSION)) {
122
- // The client is current, so the update was a runtime repair: restart the runtime rather than
123
- // the whole agent, or a device with no usable runtime would stay broken.
124
- console.log(`ReMCP ${VERSION} is already the installed version; restarting the local runtime.`);
125
- await onRuntimeRepaired?.();
126
- return;
127
- }
128
- // Handing over to a version that cannot start would take the machine offline with nobody left to
129
- // retry. The new CLI has to answer `--version` before this process steps aside.
130
- const probe = spawnSync(process.execPath, [cli, '--version'], { encoding: 'utf8', timeout: 30000 });
131
- const reported = String(probe.stdout || '').trim();
132
- if (probe.error || probe.status !== 0 || !/^\d+\.\d+\.\d+/.test(reported)) {
133
- console.error(`The installed ReMCP ${installed || 'update'} did not run (${probe.error?.message || `exit ${probe.status}`}${reported ? `: ${reported}` : ''}). Keeping ${VERSION} running; retry with: remcp update`);
134
- return;
135
- }
136
- console.log(`ReMCP ${installed || 'a newer version'} installed and verified (${reported}); restarting to apply it.`);
137
- if (!supervisorRestart()) {
138
- spawn(process.execPath, [cli, 'start'], { detached: true, stdio: 'ignore', env: { ...process.env } }).unref();
139
- // Stepping aside is only safe once the replacement really holds the device: the relay closes
140
- // this socket with 1012 ('replaced') the moment another agent takes the machine over, and that
141
- // close is what stops this process. Without the wait, a replacement that cannot start left the
142
- // machine connected in `/health` and offline everywhere else, with nobody left to retry.
143
- await new Promise(resolve => setTimeout(resolve, REPLACEMENT_HANDOVER_TIMEOUT_MS));
144
- if (!isStopping()) {
145
- console.error(`The replacement agent did not take over within ${Math.round(REPLACEMENT_HANDOVER_TIMEOUT_MS / 1000)}s; keeping ${VERSION} running. Retry with: remcp update`);
146
- return;
147
- }
148
- }
149
- markStopping();
150
- await stopAgent().catch(() => {});
151
- setTimeout(() => process.exit(0), 100);
152
- } catch (error) {
153
- console.error(`Could not restart after the update: ${error instanceof Error ? error.message : String(error)}`);
154
- }
155
- }
156
-
157
- // The version of the globally installed client, read from the package the CLI resolves to.
158
- function globalInstalledVersion() {
159
- try {
160
- const cli = globalCliEntry();
161
- if (!cli) return null;
162
- const base = path.dirname(cli);
163
- const candidates = [
164
- path.join(base, '..', 'lib', 'node_modules', '@remcp', 'remcp', 'package.json'),
165
- path.join(base, '..', 'node_modules', '@remcp', 'remcp', 'package.json'),
166
- ];
167
- for (const manifest of candidates) {
168
- try { return JSON.parse(readFileSync(manifest, 'utf8')).version || null; } catch {}
169
- }
170
- return null;
171
- } catch {
172
- return null;
173
- }
174
- }
175
-
176
- // True when this process is the one a service manager owns: launchd and systemd's system manager run
177
- // a unit's main process as a child of PID 1, and `systemd --user` runs it as a child of the user
178
- // manager. Anything else — a terminal, a shell inside another unit, a CI runner job — has nobody
179
- // waiting to start the agent again.
180
- function parentIsServiceManager() {
181
- if (process.ppid === 1) return true;
182
- if (process.platform === 'win32') return false;
183
- try { return readFileSync(`/proc/${process.ppid}/comm`, 'utf8').trim() === 'systemd'; } catch { return false; }
184
- }
185
-
186
- // What starts the agent again after it exits to apply an update, or null when it has to start its own
187
- // replacement. systemd sets INVOCATION_ID and JOURNAL_STREAM for a unit and every child of that unit
188
- // inherits them, so a `remcp start` run from a shell inside a service (a CI runner, a systemd-run
189
- // scope, another agent) believed a supervisor would bring it back: the update exited into nothing and
190
- // the workspace showed the machine offline until someone started the agent by hand. Only the unit's
191
- // own main process is restarted, so that is what the check requires.
192
- //
193
- // Injectable for tests: the verdict must not depend on the machine that runs them.
194
- export function supervisorRestart({ platform = process.platform, dockerenv = existsSync('/.dockerenv'), parentOurs = parentIsServiceManager() } = {}) {
195
- if (parentOurs) return platform === 'darwin' ? 'launchd' : 'systemd';
196
- if (dockerenv) return 'docker';
197
- return null;
198
- }
199
-
200
- function globalCliEntry() {
201
- const prefix = spawnSync(npm.command, [...npm.args, 'prefix', '--global'], { encoding: 'utf8' });
202
- if (prefix.error || prefix.status !== 0) return null;
203
- const base = String(prefix.stdout || '').trim();
204
- return process.platform === 'win32'
205
- ? path.join(base, 'remcp.cmd')
206
- : path.join(base, 'bin', 'remcp');
207
- }
208
-
209
42
  function jitter(ms) {
210
43
  return Math.round(ms * (0.75 + Math.random() * 0.5));
211
44
  }
@@ -8,13 +8,22 @@ import { ensureMachineId } from './config.mjs';
8
8
  import { officialOrigin } from './env.mjs';
9
9
  import { sleep } from './shell.mjs';
10
10
 
11
+ // Keep the server-provided approval URL as data all the way to the OS. In particular, do not route it
12
+ // through `cmd /c start` on Windows: cmd reparses metacharacters such as `&`, so a custom pairing
13
+ // server could otherwise turn a verification URL into a local shell command.
14
+ export function browserLaunchCommand(url, platform = process.platform) {
15
+ const target = String(url);
16
+ if (platform === 'darwin') return { command: 'open', args: [target] };
17
+ if (platform === 'win32') return { command: 'explorer.exe', args: [target] };
18
+ return { command: 'xdg-open', args: [target] };
19
+ }
20
+
11
21
  // Opens the approval page in the person's browser. A machine that nobody is looking at only gets the
12
22
  // printed URL, so every failure here is silent and non-fatal.
13
23
  export function openInBrowser(url) {
14
24
  try {
15
- const command = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'cmd' : 'xdg-open';
16
- const args = process.platform === 'win32' ? ['/c', 'start', '', url] : [url];
17
- const child = spawn(command, args, { stdio: 'ignore', detached: true });
25
+ const { command, args } = browserLaunchCommand(url);
26
+ const child = spawn(command, args, { stdio: 'ignore', detached: true, shell: false });
18
27
  child.on('error', () => {});
19
28
  child.unref();
20
29
  } catch {}
@@ -0,0 +1,74 @@
1
+ // `remcp update`: install what the server advertises, then let whoever supervises the agent restart
2
+ // it. Only a spec the configured package can satisfy is installed, so a pairing response from a
3
+ // custom server cannot point this machine at a different package or run a git/URL spec.
4
+ import fs from 'node:fs';
5
+ import process from 'node:process';
6
+
7
+ import { supervisorRestart } from '../agent.mjs';
8
+ import { isRuntimeSpecFor, normalizeRuntime } from '../runtime.mjs';
9
+ import { PACKAGE_NAME, VERSION } from '../version.mjs';
10
+
11
+ import { loadConfig, saveConfig } from './config.mjs';
12
+ import { installedVersion } from './doctor.mjs';
13
+ import { linuxServiceFile, macServiceFile, officialOrigin } from './env.mjs';
14
+ import { ensureServiceIfRecorded, npmGlobalInstall, restartPersistentServiceIfInstalled } from './service.mjs';
15
+
16
+ export async function updateCommand(flags) {
17
+ const cfg = loadConfig();
18
+ // The server advertises which runtime version it expects; an agent that is updating
19
+ // itself passes it through so client and runtime move together.
20
+ const requested = typeof flags.runtime === 'string' ? flags.runtime.trim() : '';
21
+ // Whatever the server advertises is installed globally, so it is validated exactly like the
22
+ // metadata from a pairing response: same package as the configured runtime, a spec that
23
+ // parses, and an explicit --trust-runtime before a custom server may change it.
24
+ let runtimeSpec = cfg.runtime.packageSpec;
25
+ if (requested) {
26
+ // Only `<configured package>@<semver>` is installable: an alias, a git/URL/file spec, a tag or
27
+ // a range would run code the user never agreed to.
28
+ if (!isRuntimeSpecFor(cfg.runtime.packageName, requested)) {
29
+ throw new Error(`--runtime must be ${cfg.runtime.packageName}@<version>`);
30
+ }
31
+ const trusted = cfg.trustRuntime === true
32
+ || Boolean(flags['trust-runtime'])
33
+ || process.env.REMCP_TRUST_RUNTIME === '1'
34
+ // A configuration written before the field existed paired with the official server, which is
35
+ // trusted by definition; refusing it would silently stop every existing device updating.
36
+ || new URL(cfg.serverUrl).origin === officialOrigin;
37
+ if (!trusted) {
38
+ 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.`);
39
+ }
40
+ runtimeSpec = normalizeRuntime({ kind: 'npm', packageName: cfg.runtime.packageName, packageSpec: requested, entry: cfg.runtime.entry }).packageSpec;
41
+ }
42
+ if (flags.check) {
43
+ console.log(JSON.stringify({
44
+ current: VERSION,
45
+ installedRuntime: installedVersion(cfg.runtime.packageName),
46
+ runtimeSpec: runtimeSpec,
47
+ available: `${PACKAGE_NAME}@latest`,
48
+ managedService: fs.existsSync(linuxServiceFile) || fs.existsSync(macServiceFile),
49
+ supervisor: supervisorRestart() ?? 'none',
50
+ }, null, 2));
51
+ return;
52
+ }
53
+ const before = { cli: VERSION, runtime: installedVersion(cfg.runtime.packageName) };
54
+ console.log(`Updating ReMCP to the latest published version (${runtimeSpec})…`);
55
+ npmGlobalInstall(`${PACKAGE_NAME}@latest`, runtimeSpec);
56
+ // The npm prefix can change across Node-manager upgrades. Repair an existing persistent-service
57
+ // launcher only after the install, when globalCliPath() points at the CLI we just installed.
58
+ ensureServiceIfRecorded(cfg);
59
+ // Only a validated spec is persisted, so a failed update cannot leave the install unable to start.
60
+ if (requested && runtimeSpec !== cfg.runtime.packageSpec) saveConfig({ ...cfg, runtime: { ...cfg.runtime, packageSpec: runtimeSpec } });
61
+ const after = { cli: installedVersion(PACKAGE_NAME), runtime: installedVersion(cfg.runtime.packageName) };
62
+ const restarted = restartPersistentServiceIfInstalled();
63
+ if (restarted) {
64
+ console.log(`ReMCP updated and ${restarted} restarted (client ${before.cli} → ${after.cli ?? '?'}, runtime ${before.runtime ?? '?'} → ${after.runtime ?? '?'}).`);
65
+ return;
66
+ }
67
+ // This process is the updater, not the agent: exiting here would restart nothing. The agent sees
68
+ // the exit status, verifies the installed version, and restarts itself.
69
+ console.log(`ReMCP updated (client ${before.cli} → ${after.cli ?? '?'}, runtime ${before.runtime ?? '?'} → ${after.runtime ?? '?'}). The running agent restarts itself to apply it.`);
70
+ if (after.cli === before.cli && after.runtime === before.runtime) {
71
+ console.log('Nothing changed: the installed versions already match the requested ones.');
72
+ }
73
+ return;
74
+ }
package/src/cli.mjs CHANGED
@@ -1,18 +1,18 @@
1
- import fs from 'node:fs';
2
1
  import os from 'node:os';
3
2
  import path from 'node:path';
4
3
  import process from 'node:process';
5
- import { runAgent, supervisorRestart } from './agent.mjs';
6
- import { isRuntimeSpecFor, normalizeRuntime } from './runtime.mjs';
4
+ import { runAgent } from './agent.mjs';
5
+ import { normalizeRuntime } from './runtime.mjs';
7
6
  import { PACKAGE_NAME, VERSION } from './version.mjs';
8
7
  import { probeFilesystemAccess } from './fs-access.mjs';
9
8
 
10
9
  import { ensureMachineId, loadConfig, readJsonFile, saveConfig, setTelemetry, telemetryState, writeJsonFile } from './cli/config.mjs';
11
10
  import { assertRuntimeTrust, pairWithDeviceCode } from './cli/connect.mjs';
12
- import { diagnoseLocalRuntime, installedVersion, runtimeAllowedRoots } from './cli/doctor.mjs';
13
- import { configFile, linuxServiceFile, macServiceFile, npm, officialOrigin, runtimeConfigFile } from './cli/env.mjs';
14
- import { ensureServiceIfRecorded, globalCliPath, installPersistentAgent, npmGlobalInstall, restartPersistentServiceIfInstalled, uninstallPersistentService } from './cli/service.mjs';
11
+ import { diagnoseLocalRuntime, runtimeAllowedRoots } from './cli/doctor.mjs';
12
+ import { configFile, npm, officialOrigin, runtimeConfigFile } from './cli/env.mjs';
13
+ import { installPersistentAgent, restartPersistentServiceIfInstalled, uninstallPersistentService } from './cli/service.mjs';
15
14
  import { run } from './cli/shell.mjs';
15
+ import { updateCommand } from './cli/update.mjs';
16
16
  function parse(argv) {
17
17
  const [command = 'help', ...rest] = argv;
18
18
  const flags = {};
@@ -208,62 +208,7 @@ export async function main(argv = process.argv.slice(2)) {
208
208
  }
209
209
 
210
210
  if (command === 'update') {
211
- const cfg = loadConfig();
212
- // The server advertises which runtime version it expects; an agent that is updating
213
- // itself passes it through so client and runtime move together.
214
- const requested = typeof flags.runtime === 'string' ? flags.runtime.trim() : '';
215
- // Whatever the server advertises is installed globally, so it is validated exactly like the
216
- // metadata from a pairing response: same package as the configured runtime, a spec that
217
- // parses, and an explicit --trust-runtime before a custom server may change it.
218
- let runtimeSpec = cfg.runtime.packageSpec;
219
- if (requested) {
220
- // Only `<configured package>@<semver>` is installable: an alias, a git/URL/file spec, a tag or
221
- // a range would run code the user never agreed to.
222
- if (!isRuntimeSpecFor(cfg.runtime.packageName, requested)) {
223
- throw new Error(`--runtime must be ${cfg.runtime.packageName}@<version>`);
224
- }
225
- const trusted = cfg.trustRuntime === true
226
- || Boolean(flags['trust-runtime'])
227
- || process.env.REMCP_TRUST_RUNTIME === '1'
228
- // A configuration written before the field existed paired with the official server, which is
229
- // trusted by definition; refusing it would silently stop every existing device updating.
230
- || new URL(cfg.serverUrl).origin === officialOrigin;
231
- if (!trusted) {
232
- 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.`);
233
- }
234
- runtimeSpec = normalizeRuntime({ kind: 'npm', packageName: cfg.runtime.packageName, packageSpec: requested, entry: cfg.runtime.entry }).packageSpec;
235
- }
236
- if (flags.check) {
237
- console.log(JSON.stringify({
238
- current: VERSION,
239
- installedRuntime: installedVersion(cfg.runtime.packageName),
240
- runtimeSpec: runtimeSpec,
241
- available: `${PACKAGE_NAME}@latest`,
242
- managedService: fs.existsSync(linuxServiceFile) || fs.existsSync(macServiceFile),
243
- supervisor: supervisorRestart() ?? 'none',
244
- }, null, 2));
245
- return;
246
- }
247
- const before = { cli: VERSION, runtime: installedVersion(cfg.runtime.packageName) };
248
- console.log(`Updating ReMCP to the latest published version (${runtimeSpec})…`);
249
- npmGlobalInstall(`${PACKAGE_NAME}@latest`, runtimeSpec);
250
- // The npm prefix can change across Node-manager upgrades. Repair an existing persistent-service
251
- // launcher only after the install, when globalCliPath() points at the CLI we just installed.
252
- ensureServiceIfRecorded(cfg);
253
- // Only a validated spec is persisted, so a failed update cannot leave the install unable to start.
254
- if (requested && runtimeSpec !== cfg.runtime.packageSpec) saveConfig({ ...cfg, runtime: { ...cfg.runtime, packageSpec: runtimeSpec } });
255
- const after = { cli: installedVersion(PACKAGE_NAME), runtime: installedVersion(cfg.runtime.packageName) };
256
- const restarted = restartPersistentServiceIfInstalled();
257
- if (restarted) {
258
- console.log(`ReMCP updated and ${restarted} restarted (client ${before.cli} → ${after.cli ?? '?'}, runtime ${before.runtime ?? '?'} → ${after.runtime ?? '?'}).`);
259
- return;
260
- }
261
- // This process is the updater, not the agent: exiting here would restart nothing. The agent sees
262
- // the exit status, verifies the installed version, and restarts itself.
263
- console.log(`ReMCP updated (client ${before.cli} → ${after.cli ?? '?'}, runtime ${before.runtime ?? '?'} → ${after.runtime ?? '?'}). The running agent restarts itself to apply it.`);
264
- if (after.cli === before.cli && after.runtime === before.runtime) {
265
- console.log('Nothing changed: the installed versions already match the requested ones.');
266
- }
211
+ await updateCommand(flags);
267
212
  return;
268
213
  }
269
214
 
@@ -278,4 +223,4 @@ export async function main(argv = process.argv.slice(2)) {
278
223
  }
279
224
 
280
225
  printHelp();
281
- }
226
+ }
@@ -1,38 +0,0 @@
1
- import os from 'node:os';
2
- import path from 'node:path';
3
- import { readdir } from 'node:fs/promises';
4
-
5
- // macOS gates Desktop, Documents, Downloads and iCloud Drive behind TCC. A paired Mac can be online,
6
- // healthy and answering tools, and still fail every write into Desktop with `EACCES: permission
7
- // denied` — the failure people actually report, because the workspace and the model only ever see the
8
- // errno. `remcp doctor` is the one place that can look at the machine itself, so it reports which of
9
- // those folders this process may use.
10
- //
11
- // The probe is read-only on purpose: reading a directory is what TCC gates, so a directory that
12
- // cannot be listed is a directory that cannot be written either, and listing one cannot change it.
13
- export const MACOS_PROTECTED_FOLDERS = Object.freeze(['Desktop', 'Documents', 'Downloads']);
14
-
15
- export function macosPermissionHint(execPath = process.execPath) {
16
- return `macOS is blocking one or more protected folders. Grant access by hand: System Settings → Privacy & Security → Full Disk Access → + → ${execPath}, then restart the agent with \`remcp start\`. Folders outside Desktop, Documents, Downloads and iCloud Drive need no new permission.`;
17
- }
18
-
19
- export async function probeMacosFolderAccess({ platform = process.platform, home = os.homedir(), list = readdir } = {}) {
20
- if (platform !== 'darwin' || !home) return { supported: false, folders: [] };
21
- const candidates = MACOS_PROTECTED_FOLDERS.map(name => ({ name, path: path.join(home, name) }));
22
- // iCloud Drive only exists when it is switched on; a missing folder is not a permission problem.
23
- candidates.push({ name: 'iCloud Drive', path: path.join(home, 'Library', 'Mobile Documents') });
24
- const folders = [];
25
- for (const candidate of candidates) {
26
- try {
27
- await list(candidate.path);
28
- folders.push({ ...candidate, state: 'ok' });
29
- } catch (error) {
30
- const code = typeof error?.code === 'string' ? error.code : '';
31
- if (code === 'ENOENT') folders.push({ ...candidate, state: 'missing' });
32
- else if (code === 'EACCES' || code === 'EPERM') folders.push({ ...candidate, state: 'denied', code });
33
- else folders.push({ ...candidate, state: 'error', code: code || 'unknown' });
34
- }
35
- }
36
- const denied = folders.filter(folder => folder.state === 'denied');
37
- return { supported: true, folders, ...(denied.length ? { denied: denied.map(folder => folder.name), hint: macosPermissionHint() } : {}) };
38
- }