@remcp/remcp 0.2.46 → 0.2.48
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 +12 -9
- package/package.json +1 -1
- package/src/agent-update.mjs +29 -4
- package/src/agent.mjs +50 -2
- package/src/cli/env.mjs +1 -0
- package/src/cli/service.mjs +220 -24
- package/src/cli/update.mjs +68 -7
- package/src/cli.mjs +99 -22
package/README.md
CHANGED
|
@@ -17,8 +17,8 @@ credential under `~/.config/remcp/`, installs the runtime from npm, and register
|
|
|
17
17
|
## Commands
|
|
18
18
|
|
|
19
19
|
```text
|
|
20
|
-
remcp start Run the device agent
|
|
21
|
-
remcp status Show
|
|
20
|
+
remcp start Run the device agent when no managed background service is already running
|
|
21
|
+
remcp status Show device/agent versions, update consistency and server reachability as JSON
|
|
22
22
|
remcp doctor Same report plus a real tool handshake with the local runtime, and which
|
|
23
23
|
macOS privacy folders (Desktop, Documents, Downloads, iCloud Drive) this
|
|
24
24
|
computer currently lets ReMCP use
|
|
@@ -31,8 +31,7 @@ remcp godmode [status|on|off]
|
|
|
31
31
|
remcp --version
|
|
32
32
|
```
|
|
33
33
|
|
|
34
|
-
`remcp status` reports the runtime the
|
|
35
|
-
and the current usage-metrics state, so a support request can be answered with one paste.
|
|
34
|
+
`remcp status` reports the configured/installed runtime, the managed-agent state, whether multiple local ReMCP installations are consistent, whether an update is required, server reachability, and the current usage-metrics state. It deliberately does not expose backend database/provider, relay topology, fleet size, or other operational internals. `remcp doctor` adds local runtime/filesystem diagnostics, so a support request can still be answered with one paste.
|
|
36
35
|
|
|
37
36
|
## Unrestricted mode
|
|
38
37
|
|
|
@@ -66,11 +65,15 @@ permission, and `remcp doctor` reports the state of each one.
|
|
|
66
65
|
|
|
67
66
|
## Updates
|
|
68
67
|
|
|
69
|
-
The agent asks the server which versions it should run
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
68
|
+
The agent asks the server which versions it should run on every successful connection and every six hours, installs the exact trusted client/runtime release pair in the background, and restarts the managed service so it takes effect. The updater resolves itself and the sibling runtime from the package that is actually running instead of depending on an interactive shell PATH. When the same computer has been used from more than one Node manager (for example NVM plus Hermes/FNM/Homebrew), ReMCP keeps the canonical service installation stable and converges the known local installations on the same release pair. `remcp status` reports mixed installations instead of hiding the skew. A failed install is retried no more often than every thirty minutes and update-check failures are logged, so a broken release cannot become a silent install loop. `remcp update` does the same immediately; `remcp auto-update off` disables automatic checks.
|
|
69
|
+
|
|
70
|
+
## Computer-use routing
|
|
71
|
+
|
|
72
|
+
When an AI host controls a visible application, ReMCP is designed for semantic targeting rather than screenshot-coordinate loops:
|
|
73
|
+
|
|
74
|
+
`Accessibility/UI Automation → browser DOM/CDP → OCR → coordinates`
|
|
75
|
+
|
|
76
|
+
Use `computer_snapshot` for an unfamiliar state, native `ui_*` tools for desktop applications, and `browser_*` tools for debuggable Chromium page content. `browser_navigate action=new_tab` can create the first page target when the debug browser has none. `type_text` with its default `method=auto` is for exact Unicode text; `keyboard` is for shortcuts and navigation/control keys. `type_text method=keys` emits physical keys and therefore follows the computer's active keyboard layout. Prefer `wait_for_ui` or `browser_wait` to fixed sleeps and use `screenshot_region` only when pixel/layout verification adds information. For PDF/DOCX/XLSX content, use the structured document tools before automating Office applications.
|
|
74
77
|
|
|
75
78
|
## Screenshots
|
|
76
79
|
|
package/package.json
CHANGED
package/src/agent-update.mjs
CHANGED
|
@@ -2,11 +2,23 @@ import { existsSync, readFileSync } from 'node:fs';
|
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import process from 'node:process';
|
|
4
4
|
import { spawn, spawnSync } from 'node:child_process';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
5
6
|
import { resolveNpm } from './npm.mjs';
|
|
6
7
|
import { isRuntimeSpecFor, normalizeRuntime } from './runtime.mjs';
|
|
7
8
|
import { PACKAGE_NAME, VERSION } from './version.mjs';
|
|
8
9
|
|
|
9
10
|
const npm = resolveNpm();
|
|
11
|
+
const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
12
|
+
const PACKAGE_CLI_ENTRY = path.join(PACKAGE_ROOT, 'bin', 'remcp.mjs');
|
|
13
|
+
const PACKAGE_MANIFEST = path.join(PACKAGE_ROOT, 'package.json');
|
|
14
|
+
|
|
15
|
+
function packageLocalNodeModules() {
|
|
16
|
+
const marker = `${path.sep}node_modules${path.sep}`;
|
|
17
|
+
const index = PACKAGE_ROOT.lastIndexOf(marker);
|
|
18
|
+
if (index < 0) return null;
|
|
19
|
+
return PACKAGE_ROOT.slice(0, index + `${path.sep}node_modules`.length);
|
|
20
|
+
}
|
|
21
|
+
|
|
10
22
|
// How long a handing-over agent waits for its replacement to take the device over before it keeps
|
|
11
23
|
// running itself. Long enough for a fresh process to connect and be registered.
|
|
12
24
|
const REPLACEMENT_HANDOVER_TIMEOUT_MS = 20_000;
|
|
@@ -19,6 +31,11 @@ function globalNodeModules() {
|
|
|
19
31
|
|
|
20
32
|
export function localRuntimeEntry(runtimeValue) {
|
|
21
33
|
const runtime = normalizeRuntime(runtimeValue);
|
|
34
|
+
const localModules = packageLocalNodeModules();
|
|
35
|
+
if (localModules) {
|
|
36
|
+
const sibling = path.join(localModules, ...runtime.packageName.split('/'), ...runtime.entry.split(/[\\/]+/));
|
|
37
|
+
if (existsSync(sibling)) return sibling;
|
|
38
|
+
}
|
|
22
39
|
const candidate = path.join(globalNodeModules(), ...runtime.packageName.split('/'), ...runtime.entry.split(/[\\/]+/));
|
|
23
40
|
if (!existsSync(candidate)) throw new Error('ReMCP local runtime is not installed. Run `remcp install`.');
|
|
24
41
|
return candidate;
|
|
@@ -31,7 +48,7 @@ function parseVersion(value) {
|
|
|
31
48
|
return match ? { parts: [Number(match[1]), Number(match[2]), Number(match[3])], prerelease: match[4] || '' } : null;
|
|
32
49
|
}
|
|
33
50
|
|
|
34
|
-
function isNewer(candidate, current) {
|
|
51
|
+
export function isNewer(candidate, current) {
|
|
35
52
|
const a = parseVersion(candidate);
|
|
36
53
|
const b = parseVersion(current);
|
|
37
54
|
if (!a || !b) return false;
|
|
@@ -142,7 +159,11 @@ export async function restartToApplyUpdate(cli, stopAgent, markStopping, onRunti
|
|
|
142
159
|
// The version of the globally installed client, read from the package the CLI resolves to.
|
|
143
160
|
export function globalInstalledVersion() {
|
|
144
161
|
try {
|
|
145
|
-
|
|
162
|
+
try {
|
|
163
|
+
const local = JSON.parse(readFileSync(PACKAGE_MANIFEST, 'utf8')).version;
|
|
164
|
+
if (local) return local;
|
|
165
|
+
} catch {}
|
|
166
|
+
const cli = globalCliEntry({ packageLocal:false });
|
|
146
167
|
if (!cli) return null;
|
|
147
168
|
const base = path.dirname(cli);
|
|
148
169
|
const candidates = [
|
|
@@ -182,8 +203,12 @@ export function supervisorRestart({ platform = process.platform, dockerenv = exi
|
|
|
182
203
|
return null;
|
|
183
204
|
}
|
|
184
205
|
|
|
185
|
-
export function globalCliEntry() {
|
|
186
|
-
|
|
206
|
+
export function globalCliEntry({ packageLocal = true } = {}) {
|
|
207
|
+
// The running agent already knows exactly which @remcp/remcp package owns it. Prefer the sibling
|
|
208
|
+
// CLI entry and avoid asking npm which global prefix happens to be active in a minimal service
|
|
209
|
+
// environment (FNM/NVM/Hermes/Homebrew can all disagree with the shell).
|
|
210
|
+
if (packageLocal && existsSync(PACKAGE_CLI_ENTRY)) return PACKAGE_CLI_ENTRY;
|
|
211
|
+
const prefix = spawnSync(npm.command, [...npm.args, 'prefix', '--global'], { encoding:'utf8' });
|
|
187
212
|
if (prefix.error || prefix.status !== 0) return null;
|
|
188
213
|
const base = String(prefix.stdout || '').trim();
|
|
189
214
|
return process.platform === 'win32'
|
package/src/agent.mjs
CHANGED
|
@@ -84,6 +84,7 @@ export async function runAgent(options) {
|
|
|
84
84
|
let runtimeVersion = 'unknown';
|
|
85
85
|
let runtimeRestarts = 0;
|
|
86
86
|
let runtimeDown = false;
|
|
87
|
+
let runtimeTools = [];
|
|
87
88
|
// The reason the runtime is not running, sent to the server so the workspace can show something
|
|
88
89
|
// actionable instead of a machine that merely looks connected.
|
|
89
90
|
let runtimeError = '';
|
|
@@ -119,6 +120,25 @@ export async function runAgent(options) {
|
|
|
119
120
|
return env;
|
|
120
121
|
}
|
|
121
122
|
|
|
123
|
+
function toolNames(listed) {
|
|
124
|
+
const tools = Array.isArray(listed?.tools) ? listed.tools : [];
|
|
125
|
+
return [...new Set(tools.map(tool => String(tool?.name || '').trim().slice(0, 128)).filter(Boolean))].slice(0, 256);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async function refreshRuntimeTools(client = mcp, { announce = true } = {}) {
|
|
129
|
+
if (!client || runtimeDown) return runtimeTools;
|
|
130
|
+
try {
|
|
131
|
+
const listed = await client.listTools(undefined, { timeout: 3000 });
|
|
132
|
+
const next = toolNames(listed);
|
|
133
|
+
if (next.length === runtimeTools.length && next.every((name, index) => name === runtimeTools[index])) return runtimeTools;
|
|
134
|
+
runtimeTools = next;
|
|
135
|
+
if (announce) send({ type: 'capabilities', runtimeTools });
|
|
136
|
+
} catch (error) {
|
|
137
|
+
console.error(`ReMCP could not refresh runtime capabilities: ${error instanceof Error ? error.message : String(error)}`);
|
|
138
|
+
}
|
|
139
|
+
return runtimeTools;
|
|
140
|
+
}
|
|
141
|
+
|
|
122
142
|
async function startRuntime() {
|
|
123
143
|
if (stopping) return;
|
|
124
144
|
clearTimeout(runtimeRestartTimer);
|
|
@@ -139,6 +159,10 @@ export async function runAgent(options) {
|
|
|
139
159
|
mcp = client;
|
|
140
160
|
transport = stdio;
|
|
141
161
|
client.fallbackNotificationHandler = async notification => {
|
|
162
|
+
if (notification?.method === 'notifications/tools/list_changed') {
|
|
163
|
+
await refreshRuntimeTools(client);
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
142
166
|
if (!telemetryEnabled) return;
|
|
143
167
|
if (notification?.method !== 'notifications/remcp/telemetry') return;
|
|
144
168
|
const events = Array.isArray(notification.params?.events) ? notification.params.events : [];
|
|
@@ -159,6 +183,7 @@ export async function runAgent(options) {
|
|
|
159
183
|
runtimeRestartDelay = RUNTIME_RESTART_BASE_MS;
|
|
160
184
|
runtimeDown = false;
|
|
161
185
|
runtimeError = '';
|
|
186
|
+
await refreshRuntimeTools(client);
|
|
162
187
|
console.log(`ReMCP local runtime ready (${runtimeVersion})`);
|
|
163
188
|
send({ type: 'metrics', runtimeState: 'ready', runtimeError: '', metrics: deviceMetrics({ reconnects, pendingRequests, runtimeVersion, runtimeRestarts, runtimeDown: false }) });
|
|
164
189
|
if (runtimeRestarts > 1) queueEvent({ event: 'runtime_restart', at: Date.now(), count: runtimeRestarts, success: true });
|
|
@@ -173,6 +198,8 @@ export async function runAgent(options) {
|
|
|
173
198
|
function handleRuntimeExit(reason) {
|
|
174
199
|
if (stopping || runtimeRestartTimer) return;
|
|
175
200
|
runtimeDown = true;
|
|
201
|
+
runtimeTools = [];
|
|
202
|
+
send({ type: 'capabilities', runtimeTools });
|
|
176
203
|
const delay = jitter(runtimeRestartDelay);
|
|
177
204
|
runtimeRestartDelay = Math.min(RUNTIME_RESTART_MAX_MS, runtimeRestartDelay * 2);
|
|
178
205
|
console.error(`ReMCP local runtime ${reason}; restarting in ${delay}ms`);
|
|
@@ -286,6 +313,15 @@ export async function runAgent(options) {
|
|
|
286
313
|
// Service access is a user-controlled pause, not an outage. Poll slowly enough not to hammer
|
|
287
314
|
// the relay, but cap recovery so an enable action becomes effective within seconds.
|
|
288
315
|
scheduleReconnect(jitter(RECONNECT_BASE_MS));
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
const status = Number(response.statusCode || 0);
|
|
319
|
+
const transientHandshakeFailure = status === 408 || status === 425 || status === 429 || (status >= 500 && status <= 599);
|
|
320
|
+
if (transientHandshakeFailure && !revoked) {
|
|
321
|
+
// A reverse proxy or relay deploy can refuse the HTTP upgrade before a WebSocket exists.
|
|
322
|
+
// With an unexpected-response listener the client does not reliably emit close afterwards,
|
|
323
|
+
// so this branch must schedule its own retry or a still-running agent can stay offline forever.
|
|
324
|
+
scheduleBackoffReconnect();
|
|
289
325
|
}
|
|
290
326
|
});
|
|
291
327
|
activeSocket = ws;
|
|
@@ -306,6 +342,7 @@ export async function runAgent(options) {
|
|
|
306
342
|
runtimeVersion,
|
|
307
343
|
runtimeState: runtimeDown ? 'down' : 'ready',
|
|
308
344
|
runtimeError,
|
|
345
|
+
runtimeTools,
|
|
309
346
|
telemetryEnabled,
|
|
310
347
|
reconnects,
|
|
311
348
|
}));
|
|
@@ -371,6 +408,8 @@ export async function runAgent(options) {
|
|
|
371
408
|
let updateInFlight = false;
|
|
372
409
|
let lastAttemptedVersion = '';
|
|
373
410
|
let lastAttemptAt = 0;
|
|
411
|
+
let lastUpdateCheckError = '';
|
|
412
|
+
let lastUpdateCheckErrorAt = 0;
|
|
374
413
|
async function checkForUpdate() {
|
|
375
414
|
if (options.autoUpdate === false) return;
|
|
376
415
|
if (updateInFlight) return;
|
|
@@ -451,8 +490,17 @@ export async function runAgent(options) {
|
|
|
451
490
|
console.error(`remcp update could not start: ${error.message}`);
|
|
452
491
|
});
|
|
453
492
|
child.unref();
|
|
454
|
-
} catch {
|
|
455
|
-
//
|
|
493
|
+
} catch (error) {
|
|
494
|
+
// Update discovery must never take the agent offline, but swallowing the exception made FNM/
|
|
495
|
+
// launchd failures impossible to diagnose. Log only when the reason changes or every 30 min.
|
|
496
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
497
|
+
const now = Date.now();
|
|
498
|
+
if (message !== lastUpdateCheckError || now - lastUpdateCheckErrorAt >= UPDATE_RETRY_COOLDOWN_MS) {
|
|
499
|
+
console.error(`ReMCP auto-update check failed: ${message}`);
|
|
500
|
+
lastUpdateCheckError = message;
|
|
501
|
+
lastUpdateCheckErrorAt = now;
|
|
502
|
+
}
|
|
503
|
+
queueEvent({ event:'agent_update', at:now, reason:'check-failed', success:false, errorClass:error?.name || 'Error' });
|
|
456
504
|
}
|
|
457
505
|
}
|
|
458
506
|
|
package/src/cli/env.mjs
CHANGED
|
@@ -11,6 +11,7 @@ export const configFile = path.join(configDir, 'config.json');
|
|
|
11
11
|
export const runtimeConfigFile = path.join(configDir, 'runtime.json');
|
|
12
12
|
export const machineIdFile = path.join(configDir, 'machine-id');
|
|
13
13
|
export const linuxServiceFile = path.join(home, '.config', 'systemd', 'user', 'remcp-agent.service');
|
|
14
|
+
export const linuxServiceLauncherFile = path.join(configDir, 'remcp-agent-launcher');
|
|
14
15
|
export const macServiceLabel = 'com.remcp.agent';
|
|
15
16
|
export const macServiceFile = path.join(home, 'Library', 'LaunchAgents', `${macServiceLabel}.plist`);
|
|
16
17
|
export const macLogFile = path.join(home, 'Library', 'Logs', 'remcp-agent.log');
|
package/src/cli/service.mjs
CHANGED
|
@@ -5,10 +5,11 @@ import os from 'node:os';
|
|
|
5
5
|
import path from 'node:path';
|
|
6
6
|
import process from 'node:process';
|
|
7
7
|
import { spawnSync } from 'node:child_process';
|
|
8
|
+
import { resolveNpm } from '../npm.mjs';
|
|
8
9
|
import { PACKAGE_NAME, VERSION } from '../version.mjs';
|
|
9
10
|
|
|
10
11
|
import { saveConfig } from './config.mjs';
|
|
11
|
-
import { configDir, home, linuxServiceFile, macLogFile, macServiceFile, macServiceLabel, npm, windowsTaskName } from './env.mjs';
|
|
12
|
+
import { configDir, home, linuxServiceFile, linuxServiceLauncherFile, macLogFile, macServiceFile, macServiceLabel, npm, windowsTaskName } from './env.mjs';
|
|
12
13
|
import { output, run } from './shell.mjs';
|
|
13
14
|
|
|
14
15
|
export function servicePlatform() {
|
|
@@ -28,16 +29,37 @@ export function npmGlobalInstall(...specs) {
|
|
|
28
29
|
run(npm.command, [...npm.args, 'install', '--global', ...specs, '--no-audit', '--no-fund', '--loglevel=error']);
|
|
29
30
|
}
|
|
30
31
|
|
|
32
|
+
export function npmGlobalInstallForNode(nodePath, ...specs) {
|
|
33
|
+
const resolved = resolveNpm({ nodePath, home, platform:servicePlatform() });
|
|
34
|
+
run(resolved.command, [...resolved.args, 'install', '--global', ...specs, '--no-audit', '--no-fund', '--loglevel=error']);
|
|
35
|
+
}
|
|
36
|
+
|
|
31
37
|
export function quoteSystemd(value) {
|
|
32
38
|
return `"${String(value).replaceAll('\\', '\\\\').replaceAll('"', '\\"')}"`;
|
|
33
39
|
}
|
|
34
40
|
|
|
41
|
+
function resolvedCliScript(cliPath) {
|
|
42
|
+
return fs.existsSync(cliPath) ? fs.realpathSync(cliPath) : path.resolve(cliPath);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function writeLinuxServiceLauncher(cliPath = globalCliPath(), nodePath = process.execPath) {
|
|
46
|
+
const cliScript = resolvedCliScript(cliPath);
|
|
47
|
+
const launcher = `#!/bin/sh\nexec ${shellQuote(nodePath)} ${shellQuote(cliScript)} \"$@\"\n`;
|
|
48
|
+
fs.mkdirSync(path.dirname(linuxServiceLauncherFile), { recursive: true, mode: 0o700 });
|
|
49
|
+
const temporary = `${linuxServiceLauncherFile}.${process.pid}.${Date.now()}.tmp`;
|
|
50
|
+
fs.writeFileSync(temporary, launcher, { mode: 0o700 });
|
|
51
|
+
fs.chmodSync(temporary, 0o700);
|
|
52
|
+
fs.renameSync(temporary, linuxServiceLauncherFile);
|
|
53
|
+
return linuxServiceLauncherFile;
|
|
54
|
+
}
|
|
55
|
+
|
|
35
56
|
export function xmlEscape(value) {
|
|
36
57
|
return String(value).replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"').replaceAll("'", ''');
|
|
37
58
|
}
|
|
38
59
|
|
|
39
|
-
export function installLinuxService(cliPath = globalCliPath()) {
|
|
40
|
-
const
|
|
60
|
+
export function installLinuxService(cliPath = globalCliPath(), nodePath = process.execPath) {
|
|
61
|
+
const launcherFile = writeLinuxServiceLauncher(cliPath, nodePath);
|
|
62
|
+
const unit = `[Unit]\nDescription=ReMCP device agent\nAfter=network-online.target\nWants=network-online.target\n\n[Service]\nType=simple\nExecStart=${quoteSystemd(launcherFile)} start --service\nRestart=always\nRestartSec=3\nNoNewPrivileges=true\n\n[Install]\nWantedBy=default.target\n`;
|
|
41
63
|
fs.mkdirSync(path.dirname(linuxServiceFile), { recursive: true });
|
|
42
64
|
fs.writeFileSync(linuxServiceFile, unit);
|
|
43
65
|
run('systemctl', ['--user', 'daemon-reload']);
|
|
@@ -53,12 +75,12 @@ function shellQuote(value) {
|
|
|
53
75
|
return `'${String(value).replaceAll("'", "'\\''")}'`;
|
|
54
76
|
}
|
|
55
77
|
|
|
56
|
-
function macServicePlist(cliPath) {
|
|
78
|
+
function macServicePlist(cliPath, nodePath = process.execPath) {
|
|
57
79
|
// launchd wants an absolute path; a symlinked prefix that npm has not materialised yet (or a path
|
|
58
80
|
// that is about to be replaced by the next install) must not abort the repair — a stale plist is
|
|
59
81
|
// exactly the loop this function exists to break.
|
|
60
82
|
const cliScript = fs.existsSync(cliPath) ? fs.realpathSync(cliPath) : path.resolve(cliPath);
|
|
61
|
-
return `<?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(
|
|
83
|
+
return `<?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(nodePath)}</string><string>${xmlEscape(cliScript)}</string><string>start</string><string>--service</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`;
|
|
62
84
|
}
|
|
63
85
|
|
|
64
86
|
function macJobLoaded(target) {
|
|
@@ -102,10 +124,10 @@ function scheduleMacServiceRestart(target) {
|
|
|
102
124
|
]);
|
|
103
125
|
}
|
|
104
126
|
|
|
105
|
-
export function installMacService(cliPath = globalCliPath(), { restart = true } = {}) {
|
|
127
|
+
export function installMacService(cliPath = globalCliPath(), { restart = true, nodePath = process.execPath } = {}) {
|
|
106
128
|
const domain = macLaunchDomain();
|
|
107
129
|
const target = `${domain}/${macServiceLabel}`;
|
|
108
|
-
const plist = macServicePlist(cliPath);
|
|
130
|
+
const plist = macServicePlist(cliPath, nodePath);
|
|
109
131
|
let previous = '';
|
|
110
132
|
try { previous = fs.readFileSync(macServiceFile, 'utf8'); } catch {}
|
|
111
133
|
const loaded = macJobLoaded(target);
|
|
@@ -136,7 +158,7 @@ export function installMacService(cliPath = globalCliPath(), { restart = true }
|
|
|
136
158
|
}
|
|
137
159
|
|
|
138
160
|
export function installWindowsService(cliPath = globalCliPath()) {
|
|
139
|
-
const command = `"${cliPath}" start`;
|
|
161
|
+
const command = `"${cliPath}" start --service`;
|
|
140
162
|
run('schtasks.exe', ['/Create', '/TN', windowsTaskName, '/TR', command, '/SC', 'ONLOGON', '/RL', 'HIGHEST', '/F']);
|
|
141
163
|
run('schtasks.exe', ['/Run', '/TN', windowsTaskName]);
|
|
142
164
|
}
|
|
@@ -157,6 +179,175 @@ export function persistentServiceExpected(config) {
|
|
|
157
179
|
return false;
|
|
158
180
|
}
|
|
159
181
|
|
|
182
|
+
export function persistentServiceState(config) {
|
|
183
|
+
const platform = servicePlatform();
|
|
184
|
+
const expected = persistentServiceExpected(config);
|
|
185
|
+
if (platform === 'linux') {
|
|
186
|
+
const installed = fs.existsSync(linuxServiceFile);
|
|
187
|
+
const active = installed && spawnSync('systemctl', ['--user', 'is-active', '--quiet', 'remcp-agent.service'], { stdio: 'ignore' }).status === 0;
|
|
188
|
+
return { expected, installed, active, manager:'systemd-user', name:'remcp-agent.service' };
|
|
189
|
+
}
|
|
190
|
+
if (platform === 'darwin') {
|
|
191
|
+
const installed = fs.existsSync(macServiceFile);
|
|
192
|
+
const target = typeof process.getuid === 'function' ? 'gui/' + process.getuid() + '/' + macServiceLabel : macServiceLabel;
|
|
193
|
+
const active = installed && spawnSync('launchctl', ['print', target], { stdio: 'ignore' }).status === 0;
|
|
194
|
+
return { expected, installed, active, manager:'launchd', name:macServiceLabel };
|
|
195
|
+
}
|
|
196
|
+
if (platform === 'win32') {
|
|
197
|
+
const query = spawnSync('schtasks.exe', ['/Query', '/TN', windowsTaskName, '/FO', 'LIST'], { encoding:'utf8' });
|
|
198
|
+
const installed = query.status === 0;
|
|
199
|
+
const outputText = String(query.stdout || '');
|
|
200
|
+
const active = installed && /(?:Status|Состояние):\s*Running/i.test(outputText);
|
|
201
|
+
return { expected, installed, active, manager:'schtasks', name:windowsTaskName };
|
|
202
|
+
}
|
|
203
|
+
return { expected:false, installed:false, active:false, manager:null, name:null };
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function inferServiceIdentity() {
|
|
207
|
+
const platform = servicePlatform();
|
|
208
|
+
if (platform === 'linux' && fs.existsSync(linuxServiceLauncherFile)) {
|
|
209
|
+
try {
|
|
210
|
+
const body = fs.readFileSync(linuxServiceLauncherFile, 'utf8');
|
|
211
|
+
const match = body.match(/^exec\s+'([^']+)'\s+'([^']+)'/m);
|
|
212
|
+
if (match) return { nodePath:match[1], cliPath:match[2] };
|
|
213
|
+
} catch {}
|
|
214
|
+
}
|
|
215
|
+
if (platform === 'darwin' && fs.existsSync(macServiceFile)) {
|
|
216
|
+
try {
|
|
217
|
+
const body = fs.readFileSync(macServiceFile, 'utf8');
|
|
218
|
+
const args = [...body.matchAll(/<string>([^<]+)<\/string>/g)].map(match => match[1]);
|
|
219
|
+
const startIndex = args.indexOf('start');
|
|
220
|
+
if (startIndex >= 2) return { nodePath:args[startIndex - 2], cliPath:args[startIndex - 1] };
|
|
221
|
+
} catch {}
|
|
222
|
+
}
|
|
223
|
+
return {};
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function canonicalServiceCliPath(config) {
|
|
227
|
+
const configured = String(config?.serviceCliPath || '').trim();
|
|
228
|
+
if (configured && fs.existsSync(configured)) return configured;
|
|
229
|
+
const inferred = inferServiceIdentity().cliPath;
|
|
230
|
+
if (inferred && fs.existsSync(inferred)) return inferred;
|
|
231
|
+
return globalCliPath();
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function canonicalServiceNodePath(config) {
|
|
235
|
+
const configured = String(config?.serviceNodePath || '').trim();
|
|
236
|
+
if (configured && fs.existsSync(configured)) return configured;
|
|
237
|
+
const inferred = inferServiceIdentity().nodePath;
|
|
238
|
+
if (inferred && fs.existsSync(inferred)) return inferred;
|
|
239
|
+
return process.execPath;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function versionFromPackageJson(file) {
|
|
243
|
+
try {
|
|
244
|
+
const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
245
|
+
return typeof parsed.version === 'string' ? parsed.version : null;
|
|
246
|
+
} catch {
|
|
247
|
+
return null;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function packageVersionBesideCli(cliPath, packageName) {
|
|
252
|
+
if (!cliPath || !packageName) return null;
|
|
253
|
+
const marker = path.sep + 'node_modules' + path.sep;
|
|
254
|
+
const index = cliPath.lastIndexOf(marker);
|
|
255
|
+
if (index < 0) return null;
|
|
256
|
+
const nodeModules = cliPath.slice(0, index + (path.sep + 'node_modules').length);
|
|
257
|
+
return versionFromPackageJson(path.join(nodeModules, ...String(packageName).split('/'), 'package.json'));
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
export function installationVersionsAtCliPath(cliPath, runtimePackageName) {
|
|
261
|
+
if (!cliPath) return { cliVersion:null, runtimeVersion:null, cliPath:null };
|
|
262
|
+
let resolvedCliPath = cliPath;
|
|
263
|
+
try { if (fs.existsSync(cliPath)) resolvedCliPath = fs.realpathSync(cliPath); } catch {}
|
|
264
|
+
return {
|
|
265
|
+
cliVersion:packageVersionBesideCli(resolvedCliPath, PACKAGE_NAME),
|
|
266
|
+
runtimeVersion:packageVersionBesideCli(resolvedCliPath, runtimePackageName),
|
|
267
|
+
cliPath:resolvedCliPath,
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
export function serviceInstallationInfo(config) {
|
|
272
|
+
if (!persistentServiceExpected(config)) return { cliVersion:null, runtimeVersion:null, cliPath:null, nodePath:null };
|
|
273
|
+
const cliPath = canonicalServiceCliPath(config);
|
|
274
|
+
const nodePath = canonicalServiceNodePath(config);
|
|
275
|
+
let resolvedCliPath = cliPath;
|
|
276
|
+
try { if (cliPath && fs.existsSync(cliPath)) resolvedCliPath = fs.realpathSync(cliPath); } catch {}
|
|
277
|
+
return {
|
|
278
|
+
cliVersion:packageVersionBesideCli(resolvedCliPath, PACKAGE_NAME),
|
|
279
|
+
runtimeVersion:packageVersionBesideCli(resolvedCliPath, config?.runtime?.packageName),
|
|
280
|
+
cliPath:resolvedCliPath || cliPath,
|
|
281
|
+
nodePath,
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
export function currentInstallationInfo(config) {
|
|
286
|
+
let cliPath = process.argv[1] || '';
|
|
287
|
+
try { if (cliPath && fs.existsSync(cliPath)) cliPath = fs.realpathSync(cliPath); } catch {}
|
|
288
|
+
return {
|
|
289
|
+
cliVersion:VERSION,
|
|
290
|
+
runtimeVersion:config?.runtime?.packageName ? packageVersionBesideCli(cliPath, config.runtime.packageName) : null,
|
|
291
|
+
cliPath:cliPath || null,
|
|
292
|
+
nodePath:process.execPath,
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
export function rememberCurrentInstallation(config, { service = false } = {}) {
|
|
297
|
+
if (!config || typeof config !== 'object') return config;
|
|
298
|
+
const info = currentInstallationInfo(config);
|
|
299
|
+
if (!info.cliPath || !info.nodePath) return config;
|
|
300
|
+
const key = info.nodePath + '\u0000' + info.cliPath;
|
|
301
|
+
const existing = Array.isArray(config.installations) ? config.installations.filter(item => item && typeof item === 'object') : [];
|
|
302
|
+
const filtered = existing.filter(item => ((item.nodePath || '') + '\u0000' + (item.cliPath || '')) !== key);
|
|
303
|
+
const record = {
|
|
304
|
+
nodePath:info.nodePath,
|
|
305
|
+
cliPath:info.cliPath,
|
|
306
|
+
cliVersion:info.cliVersion,
|
|
307
|
+
runtimeVersion:info.runtimeVersion,
|
|
308
|
+
service:Boolean(service),
|
|
309
|
+
lastSeenAt:new Date().toISOString(),
|
|
310
|
+
};
|
|
311
|
+
const installations = [...filtered, record].slice(-8);
|
|
312
|
+
const next = {
|
|
313
|
+
...config,
|
|
314
|
+
installations,
|
|
315
|
+
...(service ? { serviceInstalled:true, serviceCliPath:info.cliPath, serviceNodePath:info.nodePath } : {}),
|
|
316
|
+
};
|
|
317
|
+
saveConfig(next);
|
|
318
|
+
return next;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
export function syncKnownInstallations(config, ...specs) {
|
|
322
|
+
const serviceInfo = serviceInstallationInfo(config);
|
|
323
|
+
const current = currentInstallationInfo(config);
|
|
324
|
+
const candidates = [
|
|
325
|
+
...(Array.isArray(config?.installations) ? config.installations : []),
|
|
326
|
+
...(serviceInfo.nodePath ? [{ nodePath:serviceInfo.nodePath, cliPath:serviceInfo.cliPath, service:true }] : []),
|
|
327
|
+
];
|
|
328
|
+
const currentNode = (() => { try { return fs.realpathSync(current.nodePath); } catch { return current.nodePath; } })();
|
|
329
|
+
const seen = new Set();
|
|
330
|
+
const results = [];
|
|
331
|
+
for (const candidate of candidates) {
|
|
332
|
+
const nodePath = String(candidate?.nodePath || '').trim();
|
|
333
|
+
if (!nodePath || !fs.existsSync(nodePath)) continue;
|
|
334
|
+
let resolvedNode = nodePath;
|
|
335
|
+
try { resolvedNode = fs.realpathSync(nodePath); } catch {}
|
|
336
|
+
if (resolvedNode === currentNode || seen.has(resolvedNode)) continue;
|
|
337
|
+
seen.add(resolvedNode);
|
|
338
|
+
const required = Boolean(candidate?.service) || resolvedNode === serviceInfo.nodePath;
|
|
339
|
+
try {
|
|
340
|
+
npmGlobalInstallForNode(resolvedNode, ...specs);
|
|
341
|
+
results.push({ nodePath:resolvedNode, ok:true, required });
|
|
342
|
+
} catch (error) {
|
|
343
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
344
|
+
results.push({ nodePath:resolvedNode, ok:false, required, error:message });
|
|
345
|
+
if (required) throw new Error('Could not update canonical ReMCP service installation at ' + resolvedNode + ': ' + message);
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
return results;
|
|
349
|
+
}
|
|
350
|
+
|
|
160
351
|
export function configurePostInstallAccess() {
|
|
161
352
|
const platform = servicePlatform();
|
|
162
353
|
try {
|
|
@@ -209,11 +400,13 @@ export function installPersistentAgent(config) {
|
|
|
209
400
|
console.log(`Installing ReMCP ${VERSION}…`);
|
|
210
401
|
npmGlobalInstall(`${PACKAGE_NAME}@${VERSION}`, config.runtime.packageSpec);
|
|
211
402
|
const cliPath = globalCliPath();
|
|
212
|
-
|
|
213
|
-
|
|
403
|
+
const cliScript = resolvedCliScript(cliPath);
|
|
404
|
+
const nodePath = process.execPath;
|
|
405
|
+
if (platform === 'linux') installLinuxService(cliPath, nodePath);
|
|
406
|
+
else if (platform === 'darwin') installMacService(cliPath, { nodePath });
|
|
214
407
|
else installWindowsService(cliPath);
|
|
215
408
|
configurePostInstallAccess();
|
|
216
|
-
saveConfig({ ...config, serviceInstalled: true });
|
|
409
|
+
saveConfig({ ...config, serviceInstalled: true, serviceCliPath: cliScript, serviceNodePath: nodePath });
|
|
217
410
|
console.log('ReMCP is installed as a background service. Future updates: remcp update');
|
|
218
411
|
}
|
|
219
412
|
|
|
@@ -223,24 +416,24 @@ export function installPersistentAgent(config) {
|
|
|
223
416
|
export function ensureServiceIfRecorded(config) {
|
|
224
417
|
if (!persistentServiceExpected(config)) return false;
|
|
225
418
|
try {
|
|
226
|
-
const cliPath =
|
|
419
|
+
const cliPath = canonicalServiceCliPath(config);
|
|
420
|
+
const nodePath = canonicalServiceNodePath(config);
|
|
227
421
|
const platform = servicePlatform();
|
|
228
422
|
if (platform === 'linux') {
|
|
229
423
|
if (!fs.existsSync(linuxServiceFile)) {
|
|
230
|
-
installLinuxService(cliPath);
|
|
424
|
+
installLinuxService(cliPath, nodePath);
|
|
231
425
|
} else {
|
|
232
|
-
//
|
|
233
|
-
//
|
|
234
|
-
|
|
426
|
+
// Keep the supervisor bound to the installation that owns the service. A manual update from
|
|
427
|
+
// another nvm/Hermes/Homebrew prefix must not silently steal service ownership.
|
|
428
|
+
const launcherFile = writeLinuxServiceLauncher(cliPath, nodePath);
|
|
235
429
|
const unit = fs.readFileSync(linuxServiceFile, 'utf8');
|
|
236
|
-
const expected = `ExecStart=${quoteSystemd(
|
|
430
|
+
const expected = `ExecStart=${quoteSystemd(launcherFile)} start --service`;
|
|
237
431
|
if (!unit.includes(expected)) {
|
|
238
432
|
const repaired = /^ExecStart=/m.test(unit) ? unit.replace(/^ExecStart=.*$/m, expected) : '';
|
|
239
433
|
if (!repaired) {
|
|
240
|
-
// A unit
|
|
241
|
-
//
|
|
242
|
-
|
|
243
|
-
installLinuxService(cliPath);
|
|
434
|
+
// A hand-edited unit with no launcher line is replaced wholesale; the stable launcher is
|
|
435
|
+
// still refreshed first so the replacement never points back at a retired Node manager.
|
|
436
|
+
installLinuxService(cliPath, nodePath);
|
|
244
437
|
} else {
|
|
245
438
|
fs.writeFileSync(linuxServiceFile, repaired);
|
|
246
439
|
run('systemctl', ['--user', 'daemon-reload']);
|
|
@@ -252,11 +445,14 @@ export function ensureServiceIfRecorded(config) {
|
|
|
252
445
|
// boot out a loaded agent inline: this updater may itself be a descendant of that LaunchAgent.
|
|
253
446
|
// installMacService either leaves an unchanged loaded job alone, bootstraps an unloaded job, or
|
|
254
447
|
// hands a changed launcher to an independent transient launchd helper.
|
|
255
|
-
installMacService(cliPath, { restart: false });
|
|
448
|
+
installMacService(cliPath, { restart: false, nodePath });
|
|
256
449
|
} else if (platform === 'win32') installWindowsService(cliPath);
|
|
257
450
|
// Upgrade the legacy inferred state only after the supervisor repair succeeded. A failed repair
|
|
258
451
|
// must not turn a stale artifact into a permanent "managed service" declaration.
|
|
259
|
-
|
|
452
|
+
const cliScript = resolvedCliScript(cliPath);
|
|
453
|
+
if (config?.serviceInstalled !== true || config?.serviceCliPath !== cliScript || config?.serviceNodePath !== nodePath) {
|
|
454
|
+
saveConfig({ ...config, serviceInstalled: true, serviceCliPath: cliScript, serviceNodePath: nodePath });
|
|
455
|
+
}
|
|
260
456
|
return true;
|
|
261
457
|
} catch (error) {
|
|
262
458
|
console.error(`Could not ensure the background service: ${error instanceof Error ? error.message : String(error)}`);
|
|
@@ -281,7 +477,7 @@ export function restartPersistentServiceIfInstalled(config) {
|
|
|
281
477
|
} else {
|
|
282
478
|
// A plist can survive while launchd has no loaded job (older installs, logout/login cleanup,
|
|
283
479
|
// manual bootout, or a failed previous update). Re-register it directly.
|
|
284
|
-
installMacService(
|
|
480
|
+
installMacService(canonicalServiceCliPath(config), { nodePath:canonicalServiceNodePath(config) });
|
|
285
481
|
}
|
|
286
482
|
return macServiceLabel;
|
|
287
483
|
}
|
package/src/cli/update.mjs
CHANGED
|
@@ -11,7 +11,7 @@ import { PACKAGE_NAME, VERSION } from '../version.mjs';
|
|
|
11
11
|
import { loadConfig, saveConfig } from './config.mjs';
|
|
12
12
|
import { installedVersion } from './doctor.mjs';
|
|
13
13
|
import { linuxServiceFile, macServiceFile, officialOrigin } from './env.mjs';
|
|
14
|
-
import { ensureServiceIfRecorded, npmGlobalInstall, restartPersistentServiceIfInstalled } from './service.mjs';
|
|
14
|
+
import { currentInstallationInfo, ensureServiceIfRecorded, installationVersionsAtCliPath, npmGlobalInstall, persistentServiceExpected, rememberCurrentInstallation, restartPersistentServiceIfInstalled, serviceInstallationInfo, syncKnownInstallations } from './service.mjs';
|
|
15
15
|
|
|
16
16
|
const UPDATE_DISCOVERY_TIMEOUT_MS = 5000;
|
|
17
17
|
|
|
@@ -168,19 +168,59 @@ export async function resolveUpdateTargets({
|
|
|
168
168
|
}
|
|
169
169
|
}
|
|
170
170
|
|
|
171
|
+
export function updateAlreadyCurrent({ cfg, targets, currentInstall, serviceInstall, serviceExpected }) {
|
|
172
|
+
const targetClientVersion = specVersion(PACKAGE_NAME, targets?.clientSpec);
|
|
173
|
+
const targetRuntimeVersion = specVersion(cfg?.runtime?.packageName, targets?.runtimeSpec);
|
|
174
|
+
if (!targetClientVersion || !targetRuntimeVersion) return false;
|
|
175
|
+
|
|
176
|
+
const currentClientVersion = String(currentInstall?.cliVersion || '').trim();
|
|
177
|
+
const currentRuntimeVersion = String(currentInstall?.runtimeVersion || '').trim();
|
|
178
|
+
if (currentClientVersion !== targetClientVersion || currentRuntimeVersion !== targetRuntimeVersion) return false;
|
|
179
|
+
|
|
180
|
+
if (serviceExpected) {
|
|
181
|
+
if (String(serviceInstall?.cliVersion || '').trim() !== targetClientVersion) return false;
|
|
182
|
+
if (String(serviceInstall?.runtimeVersion || '').trim() !== targetRuntimeVersion) return false;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// A machine can have more than one Node manager. Do not claim "already current" while a known,
|
|
186
|
+
// still-existing ReMCP installation is recorded on an older release; the normal update path will
|
|
187
|
+
// converge it through syncKnownInstallations().
|
|
188
|
+
for (const item of Array.isArray(cfg?.installations) ? cfg.installations : []) {
|
|
189
|
+
const cliPath = String(item?.cliPath || '').trim();
|
|
190
|
+
if (!cliPath || !fs.existsSync(cliPath)) continue;
|
|
191
|
+
const actual = installationVersionsAtCliPath(cliPath, cfg.runtime.packageName);
|
|
192
|
+
if (String(actual.cliVersion || '').trim() !== targetClientVersion) return false;
|
|
193
|
+
if (String(actual.runtimeVersion || '').trim() !== targetRuntimeVersion) return false;
|
|
194
|
+
}
|
|
195
|
+
return true;
|
|
196
|
+
}
|
|
197
|
+
|
|
171
198
|
export async function updateCommand(flags) {
|
|
172
|
-
|
|
199
|
+
let cfg = rememberCurrentInstallation(loadConfig());
|
|
173
200
|
const targets = await resolveUpdateTargets({ cfg, flags });
|
|
174
201
|
if (targets.warning) console.error(`ReMCP update: ${targets.warning}`);
|
|
175
202
|
|
|
203
|
+
const currentInstall = currentInstallationInfo(cfg);
|
|
204
|
+
if (!currentInstall.runtimeVersion) currentInstall.runtimeVersion = installedVersion(cfg.runtime.packageName);
|
|
205
|
+
const serviceExpected = persistentServiceExpected(cfg);
|
|
206
|
+
const serviceInstall = serviceInstallationInfo(cfg);
|
|
207
|
+
const alreadyCurrent = targets.installable && updateAlreadyCurrent({
|
|
208
|
+
cfg,
|
|
209
|
+
targets,
|
|
210
|
+
currentInstall,
|
|
211
|
+
serviceInstall,
|
|
212
|
+
serviceExpected,
|
|
213
|
+
});
|
|
214
|
+
|
|
176
215
|
if (flags.check) {
|
|
177
216
|
console.log(JSON.stringify({
|
|
178
217
|
current: VERSION,
|
|
179
|
-
installedRuntime:
|
|
218
|
+
installedRuntime: currentInstall.runtimeVersion,
|
|
180
219
|
clientSpec: targets.clientSpec,
|
|
181
220
|
runtimeSpec: targets.runtimeSpec,
|
|
182
221
|
updateSource: targets.source,
|
|
183
222
|
installable: targets.installable,
|
|
223
|
+
upToDate: alreadyCurrent,
|
|
184
224
|
managedService: fs.existsSync(linuxServiceFile) || fs.existsSync(macServiceFile),
|
|
185
225
|
supervisor: supervisorRestart() ?? 'none',
|
|
186
226
|
}, null, 2));
|
|
@@ -191,14 +231,35 @@ export async function updateCommand(flags) {
|
|
|
191
231
|
throw new Error(targets.warning || 'Could not resolve a complete client/runtime release pair.');
|
|
192
232
|
}
|
|
193
233
|
|
|
234
|
+
if (alreadyCurrent) {
|
|
235
|
+
if (targets.persistRuntime) {
|
|
236
|
+
saveConfig({ ...cfg, runtime:{ ...cfg.runtime, packageSpec:targets.runtimeSpec } });
|
|
237
|
+
}
|
|
238
|
+
const currentRuntimeVersion = String(currentInstall.runtimeVersion || specVersion(cfg.runtime.packageName, targets.runtimeSpec) || '?');
|
|
239
|
+
console.log(`ReMCP is already up to date (client ${currentInstall.cliVersion || VERSION}, runtime ${currentRuntimeVersion}).`);
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
|
|
194
243
|
const before = { cli: VERSION, runtime: installedVersion(cfg.runtime.packageName) };
|
|
195
244
|
console.log(`Updating ReMCP to ${targets.clientSpec} with ${targets.runtimeSpec}…`);
|
|
196
245
|
npmGlobalInstall(targets.clientSpec, targets.runtimeSpec);
|
|
197
|
-
|
|
198
|
-
//
|
|
246
|
+
|
|
247
|
+
// Converge every ReMCP installation this account has actually used on this machine. The canonical
|
|
248
|
+
// service installation is mandatory; secondary nvm/Hermes/Homebrew copies are best-effort and
|
|
249
|
+
// remain visible in status if they cannot be updated.
|
|
250
|
+
const synced = syncKnownInstallations(cfg, targets.clientSpec, targets.runtimeSpec);
|
|
251
|
+
for (const item of synced) {
|
|
252
|
+
if (!item.ok) console.error(`ReMCP update: could not sync secondary installation at ${item.nodePath}: ${item.error}`);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// Persist the validated runtime pair before repairing the supervisor so a repair cannot be
|
|
256
|
+
// overwritten by an older in-memory config object.
|
|
257
|
+
if (targets.persistRuntime) {
|
|
258
|
+
saveConfig({ ...cfg, runtime:{ ...cfg.runtime, packageSpec:targets.runtimeSpec } });
|
|
259
|
+
cfg = loadConfig();
|
|
260
|
+
}
|
|
199
261
|
ensureServiceIfRecorded(cfg);
|
|
200
|
-
|
|
201
|
-
if (targets.persistRuntime) saveConfig({ ...cfg, runtime: { ...cfg.runtime, packageSpec: targets.runtimeSpec } });
|
|
262
|
+
cfg = loadConfig();
|
|
202
263
|
const after = { cli: installedVersion(PACKAGE_NAME), runtime: installedVersion(cfg.runtime.packageName) };
|
|
203
264
|
const restarted = restartPersistentServiceIfInstalled(cfg);
|
|
204
265
|
if (restarted) {
|
package/src/cli.mjs
CHANGED
|
@@ -2,15 +2,16 @@ import os from 'node:os';
|
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import process from 'node:process';
|
|
4
4
|
import { runAgent } from './agent.mjs';
|
|
5
|
+
import { isNewer } from './agent-update.mjs';
|
|
5
6
|
import { normalizeRuntime } from './runtime.mjs';
|
|
6
7
|
import { PACKAGE_NAME, VERSION } from './version.mjs';
|
|
7
8
|
import { probeFilesystemAccess } from './fs-access.mjs';
|
|
8
9
|
|
|
9
10
|
import { ensureMachineId, loadConfig, readJsonFile, saveConfig, setTelemetry, telemetryState, writeJsonFile } from './cli/config.mjs';
|
|
10
11
|
import { assertRuntimeTrust, pairWithDeviceCode } from './cli/connect.mjs';
|
|
11
|
-
import { diagnoseLocalRuntime, runtimeAllowedRoots } from './cli/doctor.mjs';
|
|
12
|
+
import { diagnoseLocalRuntime, installedVersion, runtimeAllowedRoots } from './cli/doctor.mjs';
|
|
12
13
|
import { configFile, npm, officialOrigin, runtimeConfigFile } from './cli/env.mjs';
|
|
13
|
-
import { installPersistentAgent, restartPersistentServiceIfInstalled, uninstallPersistentService } from './cli/service.mjs';
|
|
14
|
+
import { currentInstallationInfo, installPersistentAgent, persistentServiceState, rememberCurrentInstallation, restartPersistentServiceIfInstalled, serviceInstallationInfo, uninstallPersistentService } from './cli/service.mjs';
|
|
14
15
|
import { run } from './cli/shell.mjs';
|
|
15
16
|
import { updateCommand } from './cli/update.mjs';
|
|
16
17
|
function parse(argv) {
|
|
@@ -92,7 +93,15 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
92
93
|
}
|
|
93
94
|
|
|
94
95
|
if (command === 'start') {
|
|
95
|
-
const
|
|
96
|
+
const supervised = flags.service === true
|
|
97
|
+
|| Boolean(process.env.INVOCATION_ID)
|
|
98
|
+
|| String(process.env.XPC_SERVICE_NAME || '').includes('com.remcp.agent');
|
|
99
|
+
const cfg = rememberCurrentInstallation(loadConfig(), { service:supervised });
|
|
100
|
+
const service = persistentServiceState(cfg);
|
|
101
|
+
if (!supervised && service.active) {
|
|
102
|
+
console.log(`ReMCP is already running as a background service (${service.name}). Use \`remcp status\` to inspect it or \`remcp doctor\` to diagnose it.`);
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
96
105
|
const telemetry = telemetryState();
|
|
97
106
|
await runAgent({
|
|
98
107
|
...cfg,
|
|
@@ -173,29 +182,97 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
173
182
|
}
|
|
174
183
|
|
|
175
184
|
if (command === 'status' || command === 'doctor') {
|
|
176
|
-
const cfg = loadConfig();
|
|
177
|
-
const health = await
|
|
185
|
+
const cfg = rememberCurrentInstallation(loadConfig());
|
|
186
|
+
const [health, advertised] = await Promise.all([
|
|
187
|
+
fetch(`${cfg.serverUrl}/health?fresh=${Date.now()}`, { cache:'no-store' })
|
|
188
|
+
.then(async response => ({ reachable:response.ok && (await response.json().catch(() => null))?.ok === true }))
|
|
189
|
+
.catch(error => ({ reachable:false, error:error.message })),
|
|
190
|
+
fetch(`${cfg.serverUrl}/api/agent/version?fresh=${Date.now()}`, { cache:'no-store' })
|
|
191
|
+
.then(response => response.ok ? response.json() : null)
|
|
192
|
+
.catch(() => null),
|
|
193
|
+
]);
|
|
194
|
+
const telemetry = telemetryState();
|
|
195
|
+
const service = persistentServiceState(cfg);
|
|
196
|
+
const currentInstall = currentInstallationInfo(cfg);
|
|
197
|
+
const serviceInstall = serviceInstallationInfo(cfg);
|
|
198
|
+
const installedRuntime = installedVersion(cfg.runtime.packageName);
|
|
199
|
+
const targetClient = String(advertised?.cliVersion || '').trim() || null;
|
|
200
|
+
const targetRuntime = String(advertised?.runtime || '').trim() || null;
|
|
201
|
+
const configuredRuntime = String(cfg.runtime.packageSpec || '');
|
|
202
|
+
const installationPaths = new Set([
|
|
203
|
+
currentInstall.cliPath,
|
|
204
|
+
serviceInstall.cliPath,
|
|
205
|
+
...(Array.isArray(cfg.installations) ? cfg.installations.map(item => item?.cliPath) : []),
|
|
206
|
+
].filter(Boolean));
|
|
207
|
+
const multipleInstallations = installationPaths.size > 1;
|
|
208
|
+
const knownCliVersions = [VERSION, serviceInstall.cliVersion].filter(Boolean);
|
|
209
|
+
const knownRuntimeVersions = [installedRuntime, serviceInstall.runtimeVersion].filter(Boolean);
|
|
210
|
+
const versionConsistent = cfg.runtime.packageName === '@remcp/runtime'
|
|
211
|
+
? new Set([...knownCliVersions, ...knownRuntimeVersions]).size <= 1
|
|
212
|
+
: new Set(knownCliVersions).size <= 1 && new Set(knownRuntimeVersions).size <= 1;
|
|
213
|
+
const clientMeetsTarget = Boolean(targetClient && !isNewer(targetClient, VERSION));
|
|
214
|
+
const configuredRuntimeMeetsTarget = Boolean(!targetRuntime || (configuredRuntime && !isNewer(targetRuntime, configuredRuntime)));
|
|
215
|
+
const runtimeMeetsTarget = Boolean(!targetRuntime || (installedRuntime && !isNewer(targetRuntime, installedRuntime)));
|
|
216
|
+
const serviceClientMeetsTarget = !service.installed
|
|
217
|
+
|| Boolean(targetClient && serviceInstall.cliVersion && !isNewer(targetClient, serviceInstall.cliVersion));
|
|
218
|
+
const serviceRuntimeMeetsTarget = !service.installed
|
|
219
|
+
|| Boolean(!targetRuntime || (serviceInstall.runtimeVersion && !isNewer(targetRuntime, serviceInstall.runtimeVersion)));
|
|
220
|
+
const upToDate = Boolean(
|
|
221
|
+
targetClient
|
|
222
|
+
&& clientMeetsTarget
|
|
223
|
+
&& configuredRuntimeMeetsTarget
|
|
224
|
+
&& runtimeMeetsTarget
|
|
225
|
+
&& serviceClientMeetsTarget
|
|
226
|
+
&& serviceRuntimeMeetsTarget
|
|
227
|
+
);
|
|
228
|
+
const updateState = !targetClient
|
|
229
|
+
? 'unknown'
|
|
230
|
+
: !upToDate
|
|
231
|
+
? 'update_available'
|
|
232
|
+
: versionConsistent
|
|
233
|
+
? (isNewer(VERSION, targetClient) ? 'ahead' : 'current')
|
|
234
|
+
: 'mixed_current';
|
|
178
235
|
const report = {
|
|
179
|
-
configured:
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
236
|
+
configured:true,
|
|
237
|
+
device:{ id:cfg.deviceId, name:cfg.deviceName },
|
|
238
|
+
client:{ version:VERSION },
|
|
239
|
+
runtime:{
|
|
240
|
+
packageName:cfg.runtime.packageName,
|
|
241
|
+
configured:configuredRuntime,
|
|
242
|
+
installed:installedRuntime,
|
|
243
|
+
},
|
|
244
|
+
agent:{
|
|
245
|
+
mode:service.installed ? 'service' : 'manual',
|
|
246
|
+
running:service.active,
|
|
247
|
+
manager:service.manager,
|
|
248
|
+
serviceCliVersion:serviceInstall.cliVersion,
|
|
249
|
+
serviceRuntimeVersion:serviceInstall.runtimeVersion,
|
|
250
|
+
},
|
|
251
|
+
server:{
|
|
252
|
+
url:cfg.serverUrl,
|
|
253
|
+
reachable:health.reachable === true,
|
|
254
|
+
...(health.error ? { error:health.error } : {}),
|
|
255
|
+
},
|
|
256
|
+
update:{
|
|
257
|
+
auto:cfg.autoUpdate !== false,
|
|
258
|
+
targetClient,
|
|
259
|
+
targetRuntime,
|
|
260
|
+
multipleInstallations,
|
|
261
|
+
versionConsistent,
|
|
262
|
+
state:updateState,
|
|
263
|
+
upToDate,
|
|
264
|
+
},
|
|
265
|
+
telemetry:{ enabled:telemetry.enabled },
|
|
187
266
|
};
|
|
188
|
-
// `doctor` answers the question the workspace cannot: is this machine actually able to run a
|
|
189
|
-
// tool? It resolves the runtime entry, installs nothing, and tries one real MCP handshake with
|
|
190
|
-
// the runtime, so the failure is visible here instead of only as "runtime not running".
|
|
191
267
|
if (command === 'doctor') {
|
|
192
268
|
report.diagnosis = await diagnoseLocalRuntime(cfg);
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
269
|
+
report.diagnosis.filesystem = await probeFilesystemAccess({ roots:runtimeAllowedRoots() });
|
|
270
|
+
report.diagnostic = {
|
|
271
|
+
serviceExpected:service.expected,
|
|
272
|
+
serviceInstalled:service.installed,
|
|
273
|
+
runtimeEntry:cfg.runtime.entry,
|
|
274
|
+
npmManaged:true,
|
|
275
|
+
};
|
|
199
276
|
}
|
|
200
277
|
console.log(JSON.stringify(report, null, 2));
|
|
201
278
|
if (command === 'doctor' && report.diagnosis.verdict !== 'ok') process.exitCode = 1;
|