@remcp/remcp 0.1.4 → 0.2.5
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 +44 -65
- package/package.json +36 -10
- package/src/agent.mjs +198 -14
- package/src/cli.mjs +176 -25
- package/src/version.mjs +1 -1
package/README.md
CHANGED
|
@@ -1,92 +1,71 @@
|
|
|
1
|
-
|
|
2
|
-
<img src="./assets/remcp-logo.png" width="168" alt="ReMCP logo">
|
|
3
|
-
</p>
|
|
1
|
+
# Device client
|
|
4
2
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
<p align="center"><strong>Your computer. Your tools. One secure MCP connection.</strong></p>
|
|
8
|
-
|
|
9
|
-
<p align="center">
|
|
10
|
-
<a href="https://www.npmjs.com/package/@remcp/remcp"><img alt="npm" src="https://img.shields.io/npm/v/%40remcp%2Fremcp?style=flat-square&label=npm"></a>
|
|
11
|
-
<a href="https://www.npmjs.com/package/@remcp/remcp"><img alt="downloads" src="https://img.shields.io/npm/dm/%40remcp%2Fremcp?style=flat-square&label=downloads"></a>
|
|
12
|
-
<img alt="Node.js" src="https://img.shields.io/node/v/%40remcp%2Fremcp?style=flat-square&label=node">
|
|
13
|
-
<a href="./LICENSE"><img alt="license" src="https://img.shields.io/npm/l/%40remcp%2Fremcp?style=flat-square"></a>
|
|
14
|
-
<a href="https://github.com/antonbaider/remcp/stargazers"><img alt="GitHub stars" src="https://img.shields.io/github/stars/antonbaider/remcp?style=flat-square"></a>
|
|
15
|
-
</p>
|
|
16
|
-
|
|
17
|
-
<p align="center">
|
|
18
|
-
<a href="https://remcp.delio24.com">Website</a> ·
|
|
19
|
-
<a href="https://remcp.delio24.com/docs">Docs</a> ·
|
|
20
|
-
<a href="https://remcp.delio24.com/security">Security</a> ·
|
|
21
|
-
<a href="https://remcp.delio24.com/support">Support</a>
|
|
22
|
-
</p>
|
|
23
|
-
|
|
24
|
-
ReMCP is the lightweight device client that connects a computer you own or administer to your ReMCP workspace. The agent makes an outbound connection only; you do not need to expose an inbound port on the machine.
|
|
25
|
-
|
|
26
|
-
## Install
|
|
27
|
-
|
|
28
|
-
**Requires Node.js 22.5 or newer.**
|
|
3
|
+
`@remcp/remcp` is the ReMCP device client. It pairs a computer with ReMCP, installs the first-party
|
|
4
|
+
local runtime, and runs the outbound-only agent that serves your MCP client's tool calls.
|
|
29
5
|
|
|
30
6
|
```bash
|
|
31
7
|
npm install --global @remcp/remcp@latest
|
|
8
|
+
remcp --version
|
|
9
|
+
remcp status
|
|
32
10
|
```
|
|
33
11
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
12
|
+
Pairing commands are generated in the workspace at <https://remcp.delio24.com/app/connect>. The
|
|
13
|
+
generated command runs `remcp connect --server … --code … --install`, which stores a per-device
|
|
14
|
+
credential under `~/.config/remcp/`, installs the runtime from npm, and registers a user service
|
|
15
|
+
(systemd on Linux, LaunchAgent on macOS, Scheduled Task on Windows).
|
|
37
16
|
|
|
38
17
|
## Commands
|
|
39
18
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
19
|
+
```text
|
|
20
|
+
remcp start Run the device agent in the foreground
|
|
21
|
+
remcp status Show version, pairing, runtime, telemetry and server health as JSON
|
|
22
|
+
remcp doctor Alias for status
|
|
23
|
+
remcp update Update the client and runtime, then restart the user service
|
|
24
|
+
remcp install Install or repair the user service
|
|
25
|
+
remcp uninstall Remove the user service
|
|
26
|
+
remcp uninstall --purge Remove the service and the global packages
|
|
27
|
+
remcp telemetry [status|on|off]
|
|
28
|
+
remcp --version
|
|
29
|
+
```
|
|
50
30
|
|
|
51
|
-
|
|
31
|
+
`remcp status` reports the runtime the device would install, whether the agent service is running,
|
|
32
|
+
and the current usage-metrics state, so a support request can be answered with one paste.
|
|
52
33
|
|
|
53
|
-
|
|
54
|
-
2. Generate a one-time pairing command.
|
|
55
|
-
3. Run the command on your machine.
|
|
56
|
-
4. ReMCP stores the device credential locally with restrictive permissions.
|
|
57
|
-
5. The agent establishes an outbound WebSocket connection to the configured ReMCP service.
|
|
34
|
+
## What runs on your computer
|
|
58
35
|
|
|
59
|
-
|
|
36
|
+
- the agent (`remcp start`), which holds the device credential and dials
|
|
37
|
+
`wss://remcp.delio24.com/agent`;
|
|
38
|
+
- [`@remcp/runtime`](https://www.npmjs.com/package/@remcp/runtime), spawned by the agent as an MCP
|
|
39
|
+
stdio server. The runtime executes the tools, opens no network connection, and is supervised: if it
|
|
40
|
+
exits, the agent restarts it with backoff and reports the restart instead of failing silently.
|
|
60
41
|
|
|
61
|
-
|
|
42
|
+
Configuration lives in `~/.config/remcp/config.json` (client) and `~/.config/remcp/runtime.json`
|
|
43
|
+
(runtime: allowed roots, blocked commands, output and write limits, and the usage-metrics switch).
|
|
44
|
+
Run `npx @remcp/runtime --describe` to print the runtime's effective configuration.
|
|
62
45
|
|
|
63
|
-
|
|
64
|
-
- Per-device revocable credential.
|
|
65
|
-
- Local client configuration stored with restrictive permissions.
|
|
66
|
-
- Runtime metadata validated before installation.
|
|
67
|
-
- Custom servers require an explicit runtime trust decision.
|
|
68
|
-
- No pairing secrets belong in issues, screenshots, logs, or documentation.
|
|
46
|
+
## Usage metrics
|
|
69
47
|
|
|
70
|
-
|
|
48
|
+
Opt-out, self-hosted, and limited to tool names, timings, outcomes, error classes, and device health
|
|
49
|
+
samples. No paths, no commands, no arguments, no output, no third-party endpoint, no install ping.
|
|
50
|
+
Disable with `remcp telemetry off` or `REMCP_RUNTIME_DISABLE_TELEMETRY=1`; the switch applies to both
|
|
51
|
+
the client and the runtime, and restarts the service so it takes effect immediately.
|
|
71
52
|
|
|
72
|
-
##
|
|
53
|
+
## Security
|
|
73
54
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
55
|
+
- Outbound-only connection; the machine never listens.
|
|
56
|
+
- One revocable credential per paired device, stored with restrictive permissions.
|
|
57
|
+
- Runtime metadata from a custom server is only accepted with an explicit `--trust-runtime`.
|
|
58
|
+
- Revoking a device in the workspace closes the connection; the agent then stops retrying and says so
|
|
59
|
+
instead of reconnecting forever.
|
|
79
60
|
|
|
80
61
|
## Development
|
|
81
62
|
|
|
82
63
|
```bash
|
|
83
|
-
npm
|
|
64
|
+
npm install
|
|
84
65
|
npm run check
|
|
85
66
|
npm test
|
|
86
|
-
npm audit --omit=dev
|
|
87
|
-
npm pack --dry-run
|
|
88
67
|
```
|
|
89
68
|
|
|
90
69
|
## License
|
|
91
70
|
|
|
92
|
-
MIT
|
|
71
|
+
MIT.
|
package/package.json
CHANGED
|
@@ -1,13 +1,22 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remcp/remcp",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.2.5",
|
|
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",
|
|
7
7
|
"homepage": "https://remcp.delio24.com",
|
|
8
|
-
"bin": {
|
|
9
|
-
|
|
10
|
-
|
|
8
|
+
"bin": {
|
|
9
|
+
"remcp": "bin/remcp.mjs"
|
|
10
|
+
},
|
|
11
|
+
"engines": {
|
|
12
|
+
"node": ">=22.5.0"
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"bin",
|
|
16
|
+
"src",
|
|
17
|
+
"LICENSE",
|
|
18
|
+
"README.md"
|
|
19
|
+
],
|
|
11
20
|
"scripts": {
|
|
12
21
|
"check": "node --check bin/remcp.mjs && node --check src/cli.mjs && node --check src/agent.mjs && node --check src/runtime.mjs && node --check src/version.mjs",
|
|
13
22
|
"test": "node --test test/*.test.mjs"
|
|
@@ -16,9 +25,26 @@
|
|
|
16
25
|
"@modelcontextprotocol/sdk": "^1.30.0",
|
|
17
26
|
"ws": "^8.21.3"
|
|
18
27
|
},
|
|
19
|
-
"
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
28
|
+
"repository": {
|
|
29
|
+
"type": "git",
|
|
30
|
+
"url": "git+https://github.com/antonbaider/remcp.git",
|
|
31
|
+
"directory": "packages/client"
|
|
32
|
+
},
|
|
33
|
+
"bugs": {
|
|
34
|
+
"url": "https://github.com/antonbaider/remcp/issues"
|
|
35
|
+
},
|
|
36
|
+
"publishConfig": {
|
|
37
|
+
"access": "public"
|
|
38
|
+
},
|
|
39
|
+
"keywords": [
|
|
40
|
+
"mcp",
|
|
41
|
+
"model-context-protocol",
|
|
42
|
+
"remote",
|
|
43
|
+
"computer",
|
|
44
|
+
"cli",
|
|
45
|
+
"agent",
|
|
46
|
+
"chatgpt",
|
|
47
|
+
"codex",
|
|
48
|
+
"self-hosted"
|
|
49
|
+
]
|
|
24
50
|
}
|
package/src/agent.mjs
CHANGED
|
@@ -10,6 +10,17 @@ import { normalizeRuntime } from './runtime.mjs';
|
|
|
10
10
|
import { VERSION } from './version.mjs';
|
|
11
11
|
|
|
12
12
|
const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm';
|
|
13
|
+
const METRICS_INTERVAL_MS = 60_000;
|
|
14
|
+
const TELEMETRY_QUEUE_LIMIT = 500;
|
|
15
|
+
const TELEMETRY_BATCH_LIMIT = 100;
|
|
16
|
+
const TELEMETRY_SEND_INTERVAL_MS = 5_000;
|
|
17
|
+
const RECONNECT_BASE_MS = 2_000;
|
|
18
|
+
const RECONNECT_MAX_MS = 60_000;
|
|
19
|
+
const RUNTIME_RESTART_BASE_MS = 1_000;
|
|
20
|
+
const RUNTIME_RESTART_MAX_MS = 30_000;
|
|
21
|
+
// Below the relay's RPC timeout so the model gets a real error instead of a client-side
|
|
22
|
+
// timeout while the device keeps working invisibly.
|
|
23
|
+
const CALL_TIMEOUT_MARGIN_MS = 10_000;
|
|
13
24
|
|
|
14
25
|
function globalNodeModules() {
|
|
15
26
|
const result = spawnSync(npmCommand, ['root', '--global'], { encoding: 'utf8' });
|
|
@@ -24,6 +35,28 @@ function localRuntimeEntry(runtimeValue) {
|
|
|
24
35
|
return candidate;
|
|
25
36
|
}
|
|
26
37
|
|
|
38
|
+
function jitter(ms) {
|
|
39
|
+
return Math.round(ms * (0.75 + Math.random() * 0.5));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Device health is sampled locally and is the only thing ReMCP stores about the machine
|
|
43
|
+
// beyond its name, platform and last-seen time. No process list, no file names.
|
|
44
|
+
function deviceMetrics(extra = {}) {
|
|
45
|
+
const load = os.loadavg?.()[0] ?? 0;
|
|
46
|
+
const totalMem = os.totalmem();
|
|
47
|
+
const freeMem = os.freemem();
|
|
48
|
+
return {
|
|
49
|
+
uptimeSeconds: Math.round(os.uptime()),
|
|
50
|
+
agentUptimeSeconds: Math.round(process.uptime()),
|
|
51
|
+
rssBytes: process.memoryUsage().rss,
|
|
52
|
+
load1: Number.isFinite(load) ? Number(load.toFixed(2)) : 0,
|
|
53
|
+
cpuCount: os.cpus?.().length ?? 0,
|
|
54
|
+
freeMemoryRatio: totalMem > 0 ? Number((freeMem / totalMem).toFixed(3)) : 0,
|
|
55
|
+
agentVersion: VERSION,
|
|
56
|
+
...extra,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
27
60
|
export async function runAgent(options) {
|
|
28
61
|
const serverUrl = String(options.serverUrl || '').replace(/\/$/, '');
|
|
29
62
|
const deviceToken = String(options.deviceToken || '');
|
|
@@ -31,25 +64,137 @@ export async function runAgent(options) {
|
|
|
31
64
|
const deviceName = String(options.deviceName || os.hostname());
|
|
32
65
|
if (!serverUrl || !deviceToken || !deviceId) throw new Error('serverUrl, deviceToken and deviceId are required');
|
|
33
66
|
const agentUrl = serverUrl.replace(/^http/, 'ws') + '/agent';
|
|
34
|
-
const
|
|
67
|
+
const callTimeoutMs = Math.max(5_000, Number(options.rpcTimeoutMs || 120000) - CALL_TIMEOUT_MARGIN_MS);
|
|
68
|
+
const telemetryEnabled = options.telemetryEnabled !== false;
|
|
69
|
+
const persistState = typeof options.persistState === 'function' ? options.persistState : () => {};
|
|
35
70
|
let stopping = false;
|
|
36
71
|
let activeSocket;
|
|
72
|
+
let reconnects = 0;
|
|
73
|
+
let pendingRequests = 0;
|
|
74
|
+
let runtimeVersion = 'unknown';
|
|
75
|
+
let runtimeRestarts = 0;
|
|
76
|
+
let runtimeDown = false;
|
|
77
|
+
const telemetryQueue = [];
|
|
78
|
+
let telemetryTimer = null;
|
|
79
|
+
const runtimeEntry = localRuntimeEntry(options.runtime);
|
|
80
|
+
|
|
81
|
+
// --- local runtime supervision ------------------------------------------------------
|
|
82
|
+
// If the runtime dies (a bad shell, a broken pipe, an OOM) the agent used to stay
|
|
83
|
+
// "online" forever and every later call failed with an opaque "Not connected". Now the
|
|
84
|
+
// transport is watched and the runtime is restarted with backoff.
|
|
85
|
+
let mcp = null;
|
|
86
|
+
let transport = null;
|
|
87
|
+
let runtimeRestartDelay = RUNTIME_RESTART_BASE_MS;
|
|
88
|
+
|
|
89
|
+
function runtimeEnv() {
|
|
90
|
+
// The SDK's stdio transport does not inherit the environment by default; spreading
|
|
91
|
+
// process.env here is what gives the runtime its REMCP_RUNTIME_* configuration, PATH,
|
|
92
|
+
// HOME and the telemetry opt-out.
|
|
93
|
+
const env = { ...process.env };
|
|
94
|
+
if (!telemetryEnabled) env.REMCP_RUNTIME_DISABLE_TELEMETRY = '1';
|
|
95
|
+
return env;
|
|
96
|
+
}
|
|
37
97
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
98
|
+
async function startRuntime() {
|
|
99
|
+
if (stopping) return;
|
|
100
|
+
runtimeDown = false;
|
|
101
|
+
const client = new Client({ name: 'remcp-agent', version: VERSION });
|
|
102
|
+
const stdio = new StdioClientTransport({ command: process.execPath, args: [runtimeEntry], env: runtimeEnv() });
|
|
103
|
+
mcp = client;
|
|
104
|
+
transport = stdio;
|
|
105
|
+
client.fallbackNotificationHandler = async notification => {
|
|
106
|
+
if (!telemetryEnabled) return;
|
|
107
|
+
if (notification?.method !== 'notifications/remcp/telemetry') return;
|
|
108
|
+
const events = Array.isArray(notification.params?.events) ? notification.params.events : [];
|
|
109
|
+
if (!events.length) return;
|
|
110
|
+
if (notification.params?.runtimeVersion) runtimeVersion = String(notification.params.runtimeVersion);
|
|
111
|
+
for (const event of events) {
|
|
112
|
+
if (telemetryQueue.length >= TELEMETRY_QUEUE_LIMIT) telemetryQueue.shift();
|
|
113
|
+
telemetryQueue.push(event);
|
|
114
|
+
}
|
|
115
|
+
};
|
|
116
|
+
stdio.onclose = () => handleRuntimeExit('closed');
|
|
117
|
+
stdio.onerror = error => console.error(`ReMCP local runtime error: ${error instanceof Error ? error.message : String(error)}`);
|
|
118
|
+
try {
|
|
119
|
+
await client.connect(stdio);
|
|
120
|
+
runtimeVersion = client.getServerVersion()?.version || runtimeVersion;
|
|
121
|
+
runtimeRestarts += 1;
|
|
122
|
+
runtimeRestartDelay = RUNTIME_RESTART_BASE_MS;
|
|
123
|
+
console.log(`ReMCP local runtime ready (${runtimeVersion})`);
|
|
124
|
+
send({ type: 'metrics', metrics: deviceMetrics({ reconnects, pendingRequests, runtimeVersion, runtimeRestarts, runtimeDown: false }) });
|
|
125
|
+
if (runtimeRestarts > 1) queueEvent({ event: 'runtime_restart', at: Date.now(), count: runtimeRestarts, success: true });
|
|
126
|
+
} catch (error) {
|
|
127
|
+
console.error(`ReMCP local runtime failed to start: ${error instanceof Error ? error.message : String(error)}`);
|
|
128
|
+
handleRuntimeExit('failed');
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function handleRuntimeExit(reason) {
|
|
133
|
+
if (stopping || runtimeDown) return;
|
|
134
|
+
runtimeDown = true;
|
|
135
|
+
const delay = jitter(runtimeRestartDelay);
|
|
136
|
+
runtimeRestartDelay = Math.min(RUNTIME_RESTART_MAX_MS, runtimeRestartDelay * 2);
|
|
137
|
+
console.error(`ReMCP local runtime ${reason}; restarting in ${delay}ms`);
|
|
138
|
+
queueEvent({ event: 'runtime_down', at: Date.now(), reason: reason.slice(0, 24) });
|
|
139
|
+
send({ type: 'metrics', metrics: deviceMetrics({ reconnects, pendingRequests, runtimeVersion, runtimeRestarts, runtimeDown: true }) });
|
|
140
|
+
setTimeout(() => { void startRuntime(); }, delay).unref?.();
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// --- relay connection ---------------------------------------------------------------
|
|
144
|
+
function send(message) {
|
|
145
|
+
if (activeSocket?.readyState === 1) {
|
|
146
|
+
activeSocket.send(JSON.stringify(message));
|
|
147
|
+
return true;
|
|
148
|
+
}
|
|
149
|
+
return false;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function flushTelemetry() {
|
|
153
|
+
if (!telemetryEnabled || !telemetryQueue.length) return;
|
|
154
|
+
const batch = telemetryQueue.splice(0, TELEMETRY_BATCH_LIMIT);
|
|
155
|
+
if (!send({ type: 'telemetry', runtimeVersion, agentVersion: VERSION, events: batch })) telemetryQueue.unshift(...batch);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function queueEvent(event) {
|
|
159
|
+
if (!telemetryEnabled) return;
|
|
160
|
+
if (telemetryQueue.length >= TELEMETRY_QUEUE_LIMIT) telemetryQueue.shift();
|
|
161
|
+
telemetryQueue.push(event);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function reportInstallOnce() {
|
|
165
|
+
if (!telemetryEnabled || options.installReported === true) return;
|
|
166
|
+
if (send({
|
|
167
|
+
type: 'install',
|
|
168
|
+
agentVersion: VERSION,
|
|
169
|
+
runtimeVersion,
|
|
170
|
+
node: process.versions.node,
|
|
171
|
+
platform: process.platform,
|
|
172
|
+
arch: process.arch,
|
|
173
|
+
installSpec: String(options.installSpec || ''),
|
|
174
|
+
})) {
|
|
175
|
+
persistState({ installReported: true });
|
|
176
|
+
console.log('ReMCP reported this installation to your own workspace (disable with `remcp telemetry off`).');
|
|
177
|
+
}
|
|
178
|
+
}
|
|
42
179
|
|
|
43
180
|
async function respond(ws, message) {
|
|
181
|
+
pendingRequests += 1;
|
|
44
182
|
try {
|
|
45
183
|
let result;
|
|
46
|
-
if (message.method === '
|
|
47
|
-
|
|
48
|
-
else if (
|
|
49
|
-
|
|
184
|
+
if (message.method === 'ping') {
|
|
185
|
+
result = { ok: true, hostname: os.hostname(), platform: process.platform, arch: process.arch, uptimeSeconds: Math.floor(os.uptime()), agentVersion: VERSION, runtimeVersion, runtimeRestarts };
|
|
186
|
+
} else if (!runtimeDown && mcp) {
|
|
187
|
+
if (message.method === 'tools/list') result = await mcp.listTools(undefined, { timeout: callTimeoutMs });
|
|
188
|
+
else if (message.method === 'tools/call') result = await mcp.callTool(message.params, undefined, { timeout: callTimeoutMs });
|
|
189
|
+
else throw new Error(`Unsupported relay method: ${message.method}`);
|
|
190
|
+
} else {
|
|
191
|
+
throw new Error('The ReMCP local runtime is restarting. Retry in a few seconds.');
|
|
192
|
+
}
|
|
50
193
|
ws.send(JSON.stringify({ type: 'response', id: message.id, result }));
|
|
51
194
|
} catch (error) {
|
|
52
195
|
ws.send(JSON.stringify({ type: 'response', id: message.id, error: { message: error instanceof Error ? error.message : String(error) } }));
|
|
196
|
+
} finally {
|
|
197
|
+
pendingRequests = Math.max(0, pendingRequests - 1);
|
|
53
198
|
}
|
|
54
199
|
}
|
|
55
200
|
|
|
@@ -58,28 +203,67 @@ export async function runAgent(options) {
|
|
|
58
203
|
const ws = new WebSocket(agentUrl, { headers: { Authorization: `Bearer ${deviceToken}` } });
|
|
59
204
|
activeSocket = ws;
|
|
60
205
|
ws.on('open', () => {
|
|
61
|
-
|
|
206
|
+
reconnects = 0;
|
|
207
|
+
ws.send(JSON.stringify({
|
|
208
|
+
type: 'hello',
|
|
209
|
+
deviceId,
|
|
210
|
+
deviceName,
|
|
211
|
+
hostname: os.hostname(),
|
|
212
|
+
platform: process.platform,
|
|
213
|
+
arch: process.arch,
|
|
214
|
+
agentVersion: VERSION,
|
|
215
|
+
runtimeVersion,
|
|
216
|
+
telemetryEnabled,
|
|
217
|
+
reconnects,
|
|
218
|
+
}));
|
|
62
219
|
console.log(`Connected to ${serverUrl} as ${deviceName}`);
|
|
220
|
+
send({ type: 'metrics', metrics: deviceMetrics({ reconnects, pendingRequests, runtimeVersion, runtimeRestarts, runtimeDown }) });
|
|
221
|
+
reportInstallOnce();
|
|
222
|
+
flushTelemetry();
|
|
63
223
|
});
|
|
64
224
|
ws.on('message', raw => {
|
|
65
225
|
let message;
|
|
66
226
|
try { message = JSON.parse(raw.toString()); } catch { return; }
|
|
67
227
|
if (message?.type === 'request') void respond(ws, message);
|
|
68
228
|
});
|
|
69
|
-
ws.on('close',
|
|
229
|
+
ws.on('close', code => {
|
|
230
|
+
if (stopping) return;
|
|
231
|
+
if (code === 1008) {
|
|
232
|
+
// The relay closes with 1008 when the device was revoked. Retrying forever would
|
|
233
|
+
// hide that from the person at the computer.
|
|
234
|
+
console.error('ReMCP access for this device was revoked. Pair the machine again from the ReMCP workspace: remcp connect --server <url> --code <code> --install');
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
reconnects += 1;
|
|
238
|
+
const delay = jitter(Math.min(RECONNECT_MAX_MS, RECONNECT_BASE_MS * 2 ** Math.min(reconnects, 5)));
|
|
239
|
+
setTimeout(connect, delay);
|
|
240
|
+
});
|
|
70
241
|
ws.on('error', error => console.error(`ReMCP relay: ${error.message}`));
|
|
71
242
|
}
|
|
72
243
|
|
|
244
|
+
telemetryTimer = setInterval(() => {
|
|
245
|
+
if (activeSocket?.readyState === 1) {
|
|
246
|
+
send({ type: 'metrics', metrics: deviceMetrics({ reconnects, pendingRequests, runtimeVersion, runtimeRestarts, runtimeDown, queueDepth: telemetryQueue.length }) });
|
|
247
|
+
flushTelemetry();
|
|
248
|
+
}
|
|
249
|
+
}, METRICS_INTERVAL_MS);
|
|
250
|
+
telemetryTimer.unref?.();
|
|
251
|
+
const telemetryFlushTimer = setInterval(flushTelemetry, TELEMETRY_SEND_INTERVAL_MS);
|
|
252
|
+
telemetryFlushTimer.unref?.();
|
|
253
|
+
|
|
73
254
|
async function stop() {
|
|
74
255
|
if (stopping) return;
|
|
75
256
|
stopping = true;
|
|
257
|
+
if (telemetryTimer) clearInterval(telemetryTimer);
|
|
258
|
+
clearInterval(telemetryFlushTimer);
|
|
76
259
|
try { activeSocket?.close(); } catch {}
|
|
77
|
-
try { await mcp
|
|
78
|
-
try { await transport
|
|
260
|
+
try { await mcp?.close(); } catch {}
|
|
261
|
+
try { await transport?.close(); } catch {}
|
|
79
262
|
}
|
|
80
263
|
|
|
81
264
|
process.once('SIGINT', () => void stop().finally(() => process.exit(0)));
|
|
82
265
|
process.once('SIGTERM', () => void stop().finally(() => process.exit(0)));
|
|
266
|
+
await startRuntime();
|
|
83
267
|
connect();
|
|
84
|
-
return { stop };
|
|
268
|
+
return { stop, runtimeVersion: () => runtimeVersion, runtimeRestarts: () => runtimeRestarts, isRuntimeDown: () => runtimeDown };
|
|
85
269
|
}
|
package/src/cli.mjs
CHANGED
|
@@ -3,6 +3,7 @@ import os from 'node:os';
|
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import process from 'node:process';
|
|
5
5
|
import { spawnSync } from 'node:child_process';
|
|
6
|
+
import { randomUUID } from 'node:crypto';
|
|
6
7
|
import { runAgent } from './agent.mjs';
|
|
7
8
|
import { normalizeRuntime } from './runtime.mjs';
|
|
8
9
|
import { PACKAGE_NAME, VERSION } from './version.mjs';
|
|
@@ -10,19 +11,26 @@ import { PACKAGE_NAME, VERSION } from './version.mjs';
|
|
|
10
11
|
const home = os.homedir();
|
|
11
12
|
const configDir = process.env.REMCP_CONFIG_DIR || path.join(home, '.config', 'remcp');
|
|
12
13
|
const configFile = path.join(configDir, 'config.json');
|
|
13
|
-
const
|
|
14
|
+
const runtimeConfigFile = path.join(configDir, 'runtime.json');
|
|
15
|
+
const machineIdFile = path.join(configDir, 'machine-id');
|
|
16
|
+
const linuxServiceFile = path.join(home, '.config', 'systemd', 'user', 'remcp-agent.service');
|
|
17
|
+
const macServiceLabel = 'com.remcp.agent';
|
|
18
|
+
const macServiceFile = path.join(home, 'Library', 'LaunchAgents', `${macServiceLabel}.plist`);
|
|
19
|
+
const macLogFile = path.join(home, 'Library', 'Logs', 'remcp-agent.log');
|
|
20
|
+
const windowsTaskName = 'ReMCP Agent';
|
|
14
21
|
const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm';
|
|
15
22
|
const officialOrigin = 'https://remcp.delio24.com';
|
|
16
23
|
|
|
17
24
|
function parse(argv) {
|
|
18
25
|
const [command = 'help', ...rest] = argv;
|
|
19
26
|
const flags = {};
|
|
27
|
+
const positional = [];
|
|
20
28
|
for (let i = 0; i < rest.length; i++) {
|
|
21
|
-
if (!rest[i].startsWith('--')) continue;
|
|
29
|
+
if (!rest[i].startsWith('--')) { positional.push(rest[i]); continue; }
|
|
22
30
|
const key = rest[i].slice(2);
|
|
23
31
|
flags[key] = rest[i + 1] && !rest[i + 1].startsWith('--') ? rest[++i] : true;
|
|
24
32
|
}
|
|
25
|
-
return { command, flags };
|
|
33
|
+
return { command, flags, positional };
|
|
26
34
|
}
|
|
27
35
|
|
|
28
36
|
function run(command, args, options = {}) {
|
|
@@ -55,13 +63,77 @@ function saveConfig(value) {
|
|
|
55
63
|
fs.chmodSync(configFile, 0o600);
|
|
56
64
|
}
|
|
57
65
|
|
|
66
|
+
function readJsonFile(file) {
|
|
67
|
+
try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return {}; }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function writeJsonFile(file, value) {
|
|
71
|
+
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
72
|
+
fs.writeFileSync(file, JSON.stringify(value, null, 2) + '\n', { mode: 0o600 });
|
|
73
|
+
fs.chmodSync(file, 0o600);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function flagEnabled(value) {
|
|
77
|
+
return value === undefined || value === null ? true : value !== false;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// One switch for the whole machine: the client and the runtime it spawns must agree,
|
|
81
|
+
// otherwise the runtime would keep reporting after the user opted out.
|
|
82
|
+
function telemetryState() {
|
|
83
|
+
const client = readJsonFile(configFile);
|
|
84
|
+
const runtime = readJsonFile(runtimeConfigFile);
|
|
85
|
+
const clientEnabled = flagEnabled(client.telemetryEnabled);
|
|
86
|
+
const runtimeEnabled = flagEnabled(runtime.telemetryEnabled);
|
|
87
|
+
return {
|
|
88
|
+
enabled: clientEnabled && runtimeEnabled,
|
|
89
|
+
clientEnabled,
|
|
90
|
+
runtimeEnabled,
|
|
91
|
+
installReported: client.installReported === true,
|
|
92
|
+
configFile,
|
|
93
|
+
runtimeConfigFile,
|
|
94
|
+
transport: 'paired-agent-only',
|
|
95
|
+
endpoint: null,
|
|
96
|
+
collects: 'tool names, durations, outcomes, error classes, and device health samples',
|
|
97
|
+
neverCollects: 'file paths, file contents, command strings, tool arguments, and tool output',
|
|
98
|
+
thirdParty: false,
|
|
99
|
+
installPing: false,
|
|
100
|
+
remoteFeatureFlags: false,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function setTelemetry(enabled) {
|
|
105
|
+
const client = readJsonFile(configFile);
|
|
106
|
+
client.telemetryEnabled = enabled;
|
|
107
|
+
writeJsonFile(configFile, client);
|
|
108
|
+
const runtime = readJsonFile(runtimeConfigFile);
|
|
109
|
+
runtime.telemetryEnabled = enabled;
|
|
110
|
+
writeJsonFile(runtimeConfigFile, runtime);
|
|
111
|
+
return telemetryState();
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function ensureMachineId() {
|
|
115
|
+
fs.mkdirSync(configDir, { recursive: true, mode: 0o700 });
|
|
116
|
+
try {
|
|
117
|
+
const existing = fs.readFileSync(machineIdFile, 'utf8').trim();
|
|
118
|
+
if (/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(existing)) return existing;
|
|
119
|
+
} catch {}
|
|
120
|
+
const value = randomUUID();
|
|
121
|
+
fs.writeFileSync(machineIdFile, value + '\n', { mode: 0o600 });
|
|
122
|
+
fs.chmodSync(machineIdFile, 0o600);
|
|
123
|
+
return value;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function servicePlatform() {
|
|
127
|
+
return process.env.NODE_ENV === 'test' && process.env.REMCP_TEST_PLATFORM ? process.env.REMCP_TEST_PLATFORM : process.platform;
|
|
128
|
+
}
|
|
129
|
+
|
|
58
130
|
function globalPrefix() {
|
|
59
131
|
return output(npmCommand, ['prefix', '--global']);
|
|
60
132
|
}
|
|
61
133
|
|
|
62
134
|
function globalCliPath() {
|
|
63
135
|
const prefix = globalPrefix();
|
|
64
|
-
return
|
|
136
|
+
return servicePlatform() === 'win32' ? path.join(prefix, 'remcp.cmd') : path.join(prefix, 'bin', 'remcp');
|
|
65
137
|
}
|
|
66
138
|
|
|
67
139
|
function npmGlobalInstall(...specs) {
|
|
@@ -72,33 +144,82 @@ function quoteSystemd(value) {
|
|
|
72
144
|
return `"${String(value).replaceAll('\\', '\\\\').replaceAll('"', '\\"')}"`;
|
|
73
145
|
}
|
|
74
146
|
|
|
147
|
+
function xmlEscape(value) {
|
|
148
|
+
return String(value).replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"').replaceAll("'", ''');
|
|
149
|
+
}
|
|
150
|
+
|
|
75
151
|
function installLinuxService(cliPath = globalCliPath()) {
|
|
76
152
|
const unit = `[Unit]\nDescription=ReMCP device agent\nAfter=network-online.target\nWants=network-online.target\n\n[Service]\nType=simple\nExecStart=${quoteSystemd(cliPath)} start\nRestart=always\nRestartSec=3\nNoNewPrivileges=true\n\n[Install]\nWantedBy=default.target\n`;
|
|
77
|
-
fs.mkdirSync(path.dirname(
|
|
78
|
-
fs.writeFileSync(
|
|
153
|
+
fs.mkdirSync(path.dirname(linuxServiceFile), { recursive: true });
|
|
154
|
+
fs.writeFileSync(linuxServiceFile, unit);
|
|
79
155
|
run('systemctl', ['--user', 'daemon-reload']);
|
|
80
156
|
run('systemctl', ['--user', 'enable', '--now', 'remcp-agent.service']);
|
|
81
157
|
}
|
|
82
158
|
|
|
159
|
+
function macLaunchDomain() {
|
|
160
|
+
if (typeof process.getuid !== 'function') throw new Error('Could not determine the current macOS user');
|
|
161
|
+
return `gui/${process.getuid()}`;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function installMacService(cliPath = globalCliPath()) {
|
|
165
|
+
const domain = macLaunchDomain();
|
|
166
|
+
const target = `${domain}/${macServiceLabel}`;
|
|
167
|
+
const cliScript = fs.realpathSync(cliPath);
|
|
168
|
+
fs.mkdirSync(path.dirname(macServiceFile), { recursive: true });
|
|
169
|
+
fs.mkdirSync(path.dirname(macLogFile), { recursive: true });
|
|
170
|
+
const plist = `<?xml version="1.0" encoding="UTF-8"?>\n<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">\n<plist version="1.0"><dict>\n<key>Label</key><string>${macServiceLabel}</string>\n<key>ProgramArguments</key><array><string>${xmlEscape(process.execPath)}</string><string>${xmlEscape(cliScript)}</string><string>start</string></array>\n<key>RunAtLoad</key><true/><key>KeepAlive</key><true/>\n<key>ProcessType</key><string>Background</string>\n<key>StandardOutPath</key><string>${xmlEscape(macLogFile)}</string>\n<key>StandardErrorPath</key><string>${xmlEscape(macLogFile)}</string>\n</dict></plist>\n`;
|
|
171
|
+
fs.writeFileSync(macServiceFile, plist, { mode: 0o600 });
|
|
172
|
+
spawnSync('launchctl', ['bootout', domain, macServiceFile], { stdio: 'ignore' });
|
|
173
|
+
run('launchctl', ['bootstrap', domain, macServiceFile]);
|
|
174
|
+
run('launchctl', ['enable', target]);
|
|
175
|
+
run('launchctl', ['kickstart', '-k', target]);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function installWindowsService(cliPath = globalCliPath()) {
|
|
179
|
+
const command = `"${cliPath}" start`;
|
|
180
|
+
run('schtasks.exe', ['/Create', '/TN', windowsTaskName, '/TR', command, '/SC', 'ONLOGON', '/RL', 'LIMITED', '/F']);
|
|
181
|
+
run('schtasks.exe', ['/Run', '/TN', windowsTaskName]);
|
|
182
|
+
}
|
|
183
|
+
|
|
83
184
|
function installPersistentAgent(config) {
|
|
84
|
-
|
|
185
|
+
const platform = servicePlatform();
|
|
186
|
+
if (!['linux', 'darwin', 'win32'].includes(platform)) throw new Error(`Automatic background service installation is not supported on ${platform}`);
|
|
85
187
|
console.log(`Installing ReMCP ${VERSION}…`);
|
|
86
188
|
npmGlobalInstall(`${PACKAGE_NAME}@${VERSION}`, config.runtime.packageSpec);
|
|
87
|
-
|
|
88
|
-
|
|
189
|
+
const cliPath = globalCliPath();
|
|
190
|
+
if (platform === 'linux') installLinuxService(cliPath);
|
|
191
|
+
else if (platform === 'darwin') installMacService(cliPath);
|
|
192
|
+
else installWindowsService(cliPath);
|
|
193
|
+
console.log('ReMCP is installed as a background service. Future updates: remcp update');
|
|
89
194
|
}
|
|
90
195
|
|
|
91
|
-
function
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
196
|
+
function restartPersistentServiceIfInstalled() {
|
|
197
|
+
const platform = servicePlatform();
|
|
198
|
+
if (platform === 'linux' && fs.existsSync(linuxServiceFile)) {
|
|
199
|
+
run('systemctl', ['--user', 'daemon-reload']);
|
|
200
|
+
run('systemctl', ['--user', 'restart', 'remcp-agent.service']);
|
|
201
|
+
} else if (platform === 'darwin' && fs.existsSync(macServiceFile)) {
|
|
202
|
+
run('launchctl', ['kickstart', '-k', `${macLaunchDomain()}/${macServiceLabel}`]);
|
|
203
|
+
} else if (platform === 'win32') {
|
|
204
|
+
const result = spawnSync('schtasks.exe', ['/Query', '/TN', windowsTaskName], { stdio: 'ignore' });
|
|
205
|
+
if (result.status === 0) run('schtasks.exe', ['/Run', '/TN', windowsTaskName]);
|
|
206
|
+
}
|
|
95
207
|
}
|
|
96
208
|
|
|
97
|
-
function
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
209
|
+
function uninstallPersistentService() {
|
|
210
|
+
const platform = servicePlatform();
|
|
211
|
+
if (platform === 'linux') {
|
|
212
|
+
spawnSync('systemctl', ['--user', 'disable', '--now', 'remcp-agent.service'], { stdio: 'inherit' });
|
|
213
|
+
try { fs.unlinkSync(linuxServiceFile); } catch {}
|
|
214
|
+
spawnSync('systemctl', ['--user', 'daemon-reload'], { stdio: 'inherit' });
|
|
215
|
+
} else if (platform === 'darwin') {
|
|
216
|
+
const domain = macLaunchDomain();
|
|
217
|
+
spawnSync('launchctl', ['bootout', domain, macServiceFile], { stdio: 'ignore' });
|
|
218
|
+
try { fs.unlinkSync(macServiceFile); } catch {}
|
|
219
|
+
} else if (platform === 'win32') {
|
|
220
|
+
spawnSync('schtasks.exe', ['/End', '/TN', windowsTaskName], { stdio: 'ignore' });
|
|
221
|
+
spawnSync('schtasks.exe', ['/Delete', '/TN', windowsTaskName, '/F'], { stdio: 'ignore' });
|
|
222
|
+
}
|
|
102
223
|
}
|
|
103
224
|
|
|
104
225
|
function assertRuntimeTrust(server, flags) {
|
|
@@ -109,11 +230,11 @@ function assertRuntimeTrust(server, flags) {
|
|
|
109
230
|
}
|
|
110
231
|
|
|
111
232
|
function printHelp() {
|
|
112
|
-
console.log(`ReMCP ${VERSION}\n\nCommands:\n remcp start\n remcp status\n remcp doctor\n remcp update\n remcp install\n remcp uninstall\n remcp uninstall --purge\n remcp --version\n\nPairing commands are generated in the ReMCP workspace
|
|
233
|
+
console.log(`ReMCP ${VERSION}\n\nCommands:\n remcp start\n remcp status\n remcp doctor\n remcp update\n remcp install\n remcp uninstall\n remcp uninstall --purge\n remcp telemetry [status|on|off]\n remcp --version\n\nPairing commands are generated in the ReMCP workspace.\n\nUsage metrics are opt-out (tool names, timings, outcomes only, sent to your own ReMCP\naccount through the paired agent). Disable them at any time with: remcp telemetry off`);
|
|
113
234
|
}
|
|
114
235
|
|
|
115
236
|
export async function main(argv = process.argv.slice(2)) {
|
|
116
|
-
const { command, flags } = parse(argv);
|
|
237
|
+
const { command, flags, positional } = parse(argv);
|
|
117
238
|
|
|
118
239
|
if (command === '--version' || command === '-v' || command === 'version') {
|
|
119
240
|
console.log(VERSION);
|
|
@@ -128,7 +249,7 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
128
249
|
const response = await fetch(`${server}/api/pair/claim`, {
|
|
129
250
|
method: 'POST',
|
|
130
251
|
headers: { 'content-type': 'application/json' },
|
|
131
|
-
body: JSON.stringify({ code, name: String(flags.name || os.hostname()), hostname: os.hostname(), platform: process.platform, arch: process.arch }),
|
|
252
|
+
body: JSON.stringify({ code, machineId: ensureMachineId(), name: String(flags.name || os.hostname()), hostname: os.hostname(), platform: process.platform, arch: process.arch }),
|
|
132
253
|
});
|
|
133
254
|
if (!response.ok) throw new Error(`Pairing failed (${response.status}): ${await response.text()}`);
|
|
134
255
|
const paired = await response.json();
|
|
@@ -138,6 +259,7 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
138
259
|
deviceToken: paired.deviceToken,
|
|
139
260
|
deviceName: String(flags.name || os.hostname()),
|
|
140
261
|
runtime: normalizeRuntime(paired.runtime),
|
|
262
|
+
machineId: ensureMachineId(),
|
|
141
263
|
};
|
|
142
264
|
saveConfig(config);
|
|
143
265
|
console.log(`Paired ${os.hostname()} with ${server}`);
|
|
@@ -146,14 +268,43 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
146
268
|
}
|
|
147
269
|
|
|
148
270
|
if (command === 'start') {
|
|
149
|
-
|
|
271
|
+
const cfg = loadConfig();
|
|
272
|
+
const telemetry = telemetryState();
|
|
273
|
+
await runAgent({
|
|
274
|
+
...cfg,
|
|
275
|
+
telemetryEnabled: telemetry.enabled,
|
|
276
|
+
installReported: telemetry.installReported,
|
|
277
|
+
installSpec: `${PACKAGE_NAME}@${VERSION}`,
|
|
278
|
+
persistState: patch => saveConfig({ ...cfg, ...patch }),
|
|
279
|
+
});
|
|
150
280
|
return;
|
|
151
281
|
}
|
|
152
282
|
|
|
283
|
+
if (command === 'telemetry') {
|
|
284
|
+
const action = String(positional[0] || 'status').toLowerCase();
|
|
285
|
+
if (action === 'status') {
|
|
286
|
+
console.log(JSON.stringify(telemetryState(), null, 2));
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
if (action === 'on' || action === 'enable') {
|
|
290
|
+
console.log(JSON.stringify(setTelemetry(true), null, 2));
|
|
291
|
+
restartPersistentServiceIfInstalled();
|
|
292
|
+
console.log('Usage metrics enabled and the agent restarted to apply it.');
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
if (action === 'off' || action === 'disable') {
|
|
296
|
+
console.log(JSON.stringify(setTelemetry(false), null, 2));
|
|
297
|
+
restartPersistentServiceIfInstalled();
|
|
298
|
+
console.log('Usage metrics disabled and the agent restarted to apply it.');
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
throw new Error('Usage: remcp telemetry [status|on|off]');
|
|
302
|
+
}
|
|
303
|
+
|
|
153
304
|
if (command === 'status' || command === 'doctor') {
|
|
154
305
|
const cfg = loadConfig();
|
|
155
306
|
const health = await fetch(`${cfg.serverUrl}/health?fresh=${Date.now()}`, { cache: 'no-store' }).then(r => r.json());
|
|
156
|
-
console.log(JSON.stringify({ configured: true, cliVersion: VERSION, deviceId: cfg.deviceId, deviceName: cfg.deviceName, server: cfg.serverUrl, serverHealth: health }, null, 2));
|
|
307
|
+
console.log(JSON.stringify({ configured: true, cliVersion: VERSION, deviceId: cfg.deviceId, deviceName: cfg.deviceName, server: cfg.serverUrl, runtime: cfg.runtime, telemetry: telemetryState(), serverHealth: health }, null, 2));
|
|
157
308
|
return;
|
|
158
309
|
}
|
|
159
310
|
|
|
@@ -166,13 +317,13 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
166
317
|
const cfg = loadConfig();
|
|
167
318
|
console.log('Updating ReMCP to the latest published version…');
|
|
168
319
|
npmGlobalInstall(`${PACKAGE_NAME}@latest`, cfg.runtime.packageSpec);
|
|
169
|
-
|
|
320
|
+
restartPersistentServiceIfInstalled();
|
|
170
321
|
console.log('ReMCP updated. Run `remcp --version` or `remcp status` to verify.');
|
|
171
322
|
return;
|
|
172
323
|
}
|
|
173
324
|
|
|
174
325
|
if (command === 'uninstall') {
|
|
175
|
-
|
|
326
|
+
uninstallPersistentService();
|
|
176
327
|
if (flags.purge) {
|
|
177
328
|
const cfg = loadConfig(false);
|
|
178
329
|
const specs = [PACKAGE_NAME, ...(cfg?.runtime?.packageName ? [cfg.runtime.packageName] : [])];
|
package/src/version.mjs
CHANGED