@remcp/remcp 0.2.13 → 0.2.18
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 +2 -2
- package/src/agent.mjs +51 -10
- package/src/cli.mjs +105 -15
- package/src/npm.mjs +83 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remcp/remcp",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.18",
|
|
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/cli.mjs && node --check src/agent.mjs && node --check src/runtime.mjs && node --check src/version.mjs",
|
|
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 && node --check src/npm.mjs",
|
|
22
22
|
"test": "node --test test/*.test.mjs"
|
|
23
23
|
},
|
|
24
24
|
"dependencies": {
|
package/src/agent.mjs
CHANGED
|
@@ -6,14 +6,20 @@ import { spawn, spawnSync } from 'node:child_process';
|
|
|
6
6
|
import WebSocket from 'ws';
|
|
7
7
|
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
|
8
8
|
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
|
|
9
|
+
import { resolveNpm } from './npm.mjs';
|
|
9
10
|
import { isRuntimeSpecFor, normalizeRuntime } from './runtime.mjs';
|
|
10
11
|
import { VERSION } from './version.mjs';
|
|
11
12
|
|
|
12
|
-
|
|
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();
|
|
13
16
|
const UPDATE_CHECK_INTERVAL_MS = 6 * 60 * 60 * 1000;
|
|
14
17
|
const UPDATE_CHECK_TIMEOUT_MS = 5000;
|
|
15
18
|
// A version that failed to install is retried after this cooldown instead of on every reconnect.
|
|
16
19
|
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;
|
|
17
23
|
const METRICS_INTERVAL_MS = 60_000;
|
|
18
24
|
const TELEMETRY_QUEUE_LIMIT = 500;
|
|
19
25
|
const TELEMETRY_BATCH_LIMIT = 100;
|
|
@@ -30,12 +36,12 @@ const RUNTIME_RESTART_MAX_MS = 30_000;
|
|
|
30
36
|
const CALL_TIMEOUT_MARGIN_MS = 10_000;
|
|
31
37
|
|
|
32
38
|
function globalNodeModules() {
|
|
33
|
-
const result = spawnSync(
|
|
39
|
+
const result = spawnSync(npm.command, [...npm.args, 'root', '--global'], { encoding: 'utf8' });
|
|
34
40
|
if (result.error || result.status !== 0) throw new Error('Could not locate the global npm modules directory');
|
|
35
41
|
return String(result.stdout || '').trim();
|
|
36
42
|
}
|
|
37
43
|
|
|
38
|
-
function localRuntimeEntry(runtimeValue) {
|
|
44
|
+
export function localRuntimeEntry(runtimeValue) {
|
|
39
45
|
const runtime = normalizeRuntime(runtimeValue);
|
|
40
46
|
const candidate = path.join(globalNodeModules(), ...runtime.packageName.split('/'), ...runtime.entry.split(/[\\/]+/));
|
|
41
47
|
if (!existsSync(candidate)) throw new Error('ReMCP local runtime is not installed. Run `remcp install`.');
|
|
@@ -106,7 +112,7 @@ export function updateDecision({ advertised, cliVersion, runtimeVersion, runtime
|
|
|
106
112
|
// Applies a freshly installed version. Exiting is what a supervisor needs; without one the new CLI is
|
|
107
113
|
// started in this process' place. Either way the agent stops holding a stale runtime, which is what
|
|
108
114
|
// makes an update actually take effect on a machine that no service manager watches.
|
|
109
|
-
async function restartToApplyUpdate(cli, stopAgent, markStopping, onRuntimeRepaired) {
|
|
115
|
+
async function restartToApplyUpdate(cli, stopAgent, markStopping, onRuntimeRepaired, isStopping) {
|
|
110
116
|
try {
|
|
111
117
|
const installed = globalInstalledVersion();
|
|
112
118
|
if (installed && !isNewer(installed, VERSION)) {
|
|
@@ -116,9 +122,26 @@ async function restartToApplyUpdate(cli, stopAgent, markStopping, onRuntimeRepai
|
|
|
116
122
|
await onRuntimeRepaired?.();
|
|
117
123
|
return;
|
|
118
124
|
}
|
|
119
|
-
|
|
125
|
+
// Handing over to a version that cannot start would take the machine offline with nobody left to
|
|
126
|
+
// retry. The new CLI has to answer `--version` before this process steps aside.
|
|
127
|
+
const probe = spawnSync(process.execPath, [cli, '--version'], { encoding: 'utf8', timeout: 30000 });
|
|
128
|
+
const reported = String(probe.stdout || '').trim();
|
|
129
|
+
if (probe.error || probe.status !== 0 || !/^\d+\.\d+\.\d+/.test(reported)) {
|
|
130
|
+
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`);
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
console.log(`ReMCP ${installed || 'a newer version'} installed and verified (${reported}); restarting to apply it.`);
|
|
120
134
|
if (!supervisorRestart()) {
|
|
121
135
|
spawn(process.execPath, [cli, 'start'], { detached: true, stdio: 'ignore', env: { ...process.env } }).unref();
|
|
136
|
+
// Stepping aside is only safe once the replacement really holds the device: the relay closes
|
|
137
|
+
// this socket with 1012 ('replaced') the moment another agent takes the machine over, and that
|
|
138
|
+
// close is what stops this process. Without the wait, a replacement that cannot start left the
|
|
139
|
+
// machine connected in `/health` and offline everywhere else, with nobody left to retry.
|
|
140
|
+
await new Promise(resolve => setTimeout(resolve, REPLACEMENT_HANDOVER_TIMEOUT_MS));
|
|
141
|
+
if (!isStopping()) {
|
|
142
|
+
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`);
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
122
145
|
}
|
|
123
146
|
markStopping();
|
|
124
147
|
await stopAgent().catch(() => {});
|
|
@@ -147,14 +170,32 @@ function globalInstalledVersion() {
|
|
|
147
170
|
}
|
|
148
171
|
}
|
|
149
172
|
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
173
|
+
// True when this process is the one a service manager owns: launchd and systemd's system manager run
|
|
174
|
+
// a unit's main process as a child of PID 1, and `systemd --user` runs it as a child of the user
|
|
175
|
+
// manager. Anything else — a terminal, a shell inside another unit, a CI runner job — has nobody
|
|
176
|
+
// waiting to start the agent again.
|
|
177
|
+
function parentIsServiceManager() {
|
|
178
|
+
if (process.ppid === 1) return true;
|
|
179
|
+
if (process.platform === 'win32') return false;
|
|
180
|
+
try { return readFileSync(`/proc/${process.ppid}/comm`, 'utf8').trim() === 'systemd'; } catch { return false; }
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// What starts the agent again after it exits to apply an update, or null when it has to start its own
|
|
184
|
+
// replacement. systemd sets INVOCATION_ID and JOURNAL_STREAM for a unit and every child of that unit
|
|
185
|
+
// inherits them, so a `remcp start` run from a shell inside a service (a CI runner, a systemd-run
|
|
186
|
+
// scope, another agent) believed a supervisor would bring it back: the update exited into nothing and
|
|
187
|
+
// the workspace showed the machine offline until someone started the agent by hand. Only the unit's
|
|
188
|
+
// own main process is restarted, so that is what the check requires.
|
|
189
|
+
//
|
|
190
|
+
// Injectable for tests: the verdict must not depend on the machine that runs them.
|
|
191
|
+
export function supervisorRestart({ platform = process.platform, dockerenv = existsSync('/.dockerenv'), parentOurs = parentIsServiceManager() } = {}) {
|
|
192
|
+
if (parentOurs) return platform === 'darwin' ? 'launchd' : 'systemd';
|
|
193
|
+
if (dockerenv) return 'docker';
|
|
153
194
|
return null;
|
|
154
195
|
}
|
|
155
196
|
|
|
156
197
|
function globalCliEntry() {
|
|
157
|
-
const prefix = spawnSync(
|
|
198
|
+
const prefix = spawnSync(npm.command, [...npm.args, 'prefix', '--global'], { encoding: 'utf8' });
|
|
158
199
|
if (prefix.error || prefix.status !== 0) return null;
|
|
159
200
|
const base = String(prefix.stdout || '').trim();
|
|
160
201
|
return process.platform === 'win32'
|
|
@@ -489,7 +530,7 @@ export async function runAgent(options) {
|
|
|
489
530
|
runtimeError = error instanceof Error ? error.message : String(error);
|
|
490
531
|
}
|
|
491
532
|
if (!runtimeDown && !stopping) await startRuntime();
|
|
492
|
-
});
|
|
533
|
+
}, () => stopping);
|
|
493
534
|
});
|
|
494
535
|
child.on('error', error => {
|
|
495
536
|
updateInFlight = false;
|
package/src/cli.mjs
CHANGED
|
@@ -4,7 +4,8 @@ import path from 'node:path';
|
|
|
4
4
|
import process from 'node:process';
|
|
5
5
|
import { spawnSync } from 'node:child_process';
|
|
6
6
|
import { randomUUID } from 'node:crypto';
|
|
7
|
-
import { runAgent } from './agent.mjs';
|
|
7
|
+
import { localRuntimeEntry, runAgent, supervisorRestart } from './agent.mjs';
|
|
8
|
+
import { npmVersion, resolveNpm } from './npm.mjs';
|
|
8
9
|
import { isRuntimeSpecFor, normalizeRuntime } from './runtime.mjs';
|
|
9
10
|
import { PACKAGE_NAME, VERSION } from './version.mjs';
|
|
10
11
|
|
|
@@ -18,7 +19,9 @@ const macServiceLabel = 'com.remcp.agent';
|
|
|
18
19
|
const macServiceFile = path.join(home, 'Library', 'LaunchAgents', `${macServiceLabel}.plist`);
|
|
19
20
|
const macLogFile = path.join(home, 'Library', 'Logs', 'remcp-agent.log');
|
|
20
21
|
const windowsTaskName = 'ReMCP Agent';
|
|
21
|
-
|
|
22
|
+
// How npm is invoked is resolved from the running node when possible: a background service has a
|
|
23
|
+
// minimal PATH, which is why auto-update used to find no npm on macOS. See src/npm.mjs.
|
|
24
|
+
const npm = resolveNpm();
|
|
22
25
|
const officialOrigin = 'https://remcp.delio24.com';
|
|
23
26
|
|
|
24
27
|
function parse(argv) {
|
|
@@ -130,7 +133,7 @@ function servicePlatform() {
|
|
|
130
133
|
}
|
|
131
134
|
|
|
132
135
|
function globalPrefix() {
|
|
133
|
-
return output(
|
|
136
|
+
return output(npm.command, [...npm.args, 'prefix', '--global']);
|
|
134
137
|
}
|
|
135
138
|
|
|
136
139
|
function globalCliPath() {
|
|
@@ -139,7 +142,7 @@ function globalCliPath() {
|
|
|
139
142
|
}
|
|
140
143
|
|
|
141
144
|
function npmGlobalInstall(...specs) {
|
|
142
|
-
run(
|
|
145
|
+
run(npm.command, [...npm.args, 'install', '--global', ...specs, '--no-audit', '--no-fund', '--loglevel=error']);
|
|
143
146
|
}
|
|
144
147
|
|
|
145
148
|
function quoteSystemd(value) {
|
|
@@ -192,9 +195,28 @@ function installPersistentAgent(config) {
|
|
|
192
195
|
if (platform === 'linux') installLinuxService(cliPath);
|
|
193
196
|
else if (platform === 'darwin') installMacService(cliPath);
|
|
194
197
|
else installWindowsService(cliPath);
|
|
198
|
+
saveConfig({ ...config, serviceInstalled: true });
|
|
195
199
|
console.log('ReMCP is installed as a background service. Future updates: remcp update');
|
|
196
200
|
}
|
|
197
201
|
|
|
202
|
+
// A machine that was installed as a service must still be one after an update: if the job is missing
|
|
203
|
+
// (a failed install, a cleaned LaunchAgents directory, a re-imaged user), the next update recreates
|
|
204
|
+
// it instead of leaving a hand-over to a process nobody supervises.
|
|
205
|
+
function ensureServiceIfRecorded(config) {
|
|
206
|
+
if (config?.serviceInstalled !== true) return false;
|
|
207
|
+
try {
|
|
208
|
+
const cliPath = globalCliPath();
|
|
209
|
+
const platform = servicePlatform();
|
|
210
|
+
if (platform === 'linux') { if (!fs.existsSync(linuxServiceFile)) installLinuxService(cliPath); }
|
|
211
|
+
else if (platform === 'darwin') { if (!fs.existsSync(macServiceFile)) installMacService(cliPath); }
|
|
212
|
+
else if (platform === 'win32') installWindowsService(cliPath);
|
|
213
|
+
return true;
|
|
214
|
+
} catch (error) {
|
|
215
|
+
console.error(`Could not ensure the background service: ${error instanceof Error ? error.message : String(error)}`);
|
|
216
|
+
return false;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
198
220
|
function restartPersistentServiceIfInstalled() {
|
|
199
221
|
const platform = servicePlatform();
|
|
200
222
|
if (platform === 'linux' && fs.existsSync(linuxServiceFile)) {
|
|
@@ -219,19 +241,69 @@ function restartPersistentServiceIfInstalled() {
|
|
|
219
241
|
// The agent the user installed with `remcp install` is the one this CLI manages. A machine can also
|
|
220
242
|
// be supervised by its own systemd unit, by Docker, or by a terminal, and in those cases installing
|
|
221
243
|
// a new version is not enough: the running process keeps the old code until something restarts it.
|
|
222
|
-
//
|
|
223
|
-
//
|
|
224
|
-
//
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
244
|
+
// `supervisorRestart` (agent.mjs) answers which of those is true, and only reports a service manager
|
|
245
|
+
// when it really owns this process: a terminal gets an explicit instruction instead of a silent exit
|
|
246
|
+
// that would take the device offline.
|
|
247
|
+
// One real handshake with the local runtime, plus everything needed to explain a failure: where the
|
|
248
|
+
// entry resolved, whether the package is installed, the node that would run it, and the exact error.
|
|
249
|
+
async function diagnoseLocalRuntime(cfg) {
|
|
250
|
+
const packageName = cfg.runtime?.packageName || '';
|
|
251
|
+
const diagnosis = {
|
|
252
|
+
platform: `${process.platform} ${process.arch}`,
|
|
253
|
+
node: process.execPath,
|
|
254
|
+
nodeVersion: process.versions.node,
|
|
255
|
+
packageName,
|
|
256
|
+
packageSpec: cfg.runtime?.packageSpec || '',
|
|
257
|
+
installedRuntime: installedVersion(packageName),
|
|
258
|
+
installedClient: installedVersion(PACKAGE_NAME),
|
|
259
|
+
};
|
|
260
|
+
const resolved = resolveNpm();
|
|
261
|
+
const npmInfo = npmVersion(resolved);
|
|
262
|
+
diagnosis.npm = npmInfo ? { version: npmInfo.version, source: npmInfo.source } : { error: `npm could not be executed (tried ${resolved.source})` };
|
|
263
|
+
let entry = '';
|
|
264
|
+
try {
|
|
265
|
+
entry = localRuntimeEntry(cfg.runtime);
|
|
266
|
+
diagnosis.entry = entry;
|
|
267
|
+
diagnosis.entryExists = fs.existsSync(entry);
|
|
268
|
+
} catch (error) {
|
|
269
|
+
diagnosis.entry = null;
|
|
270
|
+
diagnosis.entryExists = false;
|
|
271
|
+
diagnosis.verdict = 'runtime-not-installed';
|
|
272
|
+
diagnosis.error = error instanceof Error ? error.message : String(error);
|
|
273
|
+
diagnosis.hint = `Reinstall with: npx --yes ${PACKAGE_NAME}@latest update`;
|
|
274
|
+
return diagnosis;
|
|
275
|
+
}
|
|
276
|
+
if (!diagnosis.entryExists) {
|
|
277
|
+
diagnosis.verdict = 'runtime-entry-missing';
|
|
278
|
+
diagnosis.hint = `Reinstall with: npx --yes ${PACKAGE_NAME}@latest update`;
|
|
279
|
+
return diagnosis;
|
|
280
|
+
}
|
|
281
|
+
try {
|
|
282
|
+
const { Client } = await import('@modelcontextprotocol/sdk/client/index.js');
|
|
283
|
+
const { StdioClientTransport } = await import('@modelcontextprotocol/sdk/client/stdio.js');
|
|
284
|
+
const client = new Client({ name: 'remcp-doctor', version: VERSION });
|
|
285
|
+
const stdio = new StdioClientTransport({ command: process.execPath, args: [entry], env: { ...process.env }, maxBufferSize: 4 * 1024 * 1024 });
|
|
286
|
+
const stderr = [];
|
|
287
|
+
stdio.onerror = error => stderr.push(String(error?.message || error));
|
|
288
|
+
await client.connect(stdio);
|
|
289
|
+
diagnosis.runtimeVersion = client.getServerVersion()?.version || 'unknown';
|
|
290
|
+
const tools = await client.listTools(undefined, { timeout: 20000 });
|
|
291
|
+
diagnosis.tools = tools.tools.length;
|
|
292
|
+
diagnosis.verdict = 'ok';
|
|
293
|
+
await client.close();
|
|
294
|
+
return diagnosis;
|
|
295
|
+
} catch (error) {
|
|
296
|
+
diagnosis.verdict = 'runtime-handshake-failed';
|
|
297
|
+
diagnosis.error = error instanceof Error ? error.message : String(error);
|
|
298
|
+
diagnosis.hint = 'Run the entry above by hand to see its output, then reinstall with: npx --yes @remcp/remcp@latest update';
|
|
299
|
+
return diagnosis;
|
|
300
|
+
}
|
|
229
301
|
}
|
|
230
302
|
|
|
231
303
|
// Reads the version a freshly installed global package reports, so an update that installed
|
|
232
304
|
// nothing (wrong prefix, npm cache, permissions) is reported instead of assumed successful.
|
|
233
305
|
function installedVersion(packageName) {
|
|
234
|
-
const prefix = spawnSync(
|
|
306
|
+
const prefix = spawnSync(npm.command, [...npm.args, 'prefix', '--global'], { encoding: 'utf8' });
|
|
235
307
|
if (prefix.error || prefix.status !== 0) return null;
|
|
236
308
|
const manifest = path.join(String(prefix.stdout || '').trim(), 'lib', 'node_modules', ...packageName.split('/'), 'package.json');
|
|
237
309
|
try { return JSON.parse(fs.readFileSync(manifest, 'utf8')).version || null; } catch { return null; }
|
|
@@ -353,8 +425,25 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
353
425
|
|
|
354
426
|
if (command === 'status' || command === 'doctor') {
|
|
355
427
|
const cfg = loadConfig();
|
|
356
|
-
const health = await fetch(`${cfg.serverUrl}/health?fresh=${Date.now()}`, { cache: 'no-store' }).then(r => r.json());
|
|
357
|
-
|
|
428
|
+
const health = await fetch(`${cfg.serverUrl}/health?fresh=${Date.now()}`, { cache: 'no-store' }).then(r => r.json()).catch(error => ({ error: error.message }));
|
|
429
|
+
const report = {
|
|
430
|
+
configured: true,
|
|
431
|
+
cliVersion: VERSION,
|
|
432
|
+
deviceId: cfg.deviceId,
|
|
433
|
+
deviceName: cfg.deviceName,
|
|
434
|
+
server: cfg.serverUrl,
|
|
435
|
+
runtime: cfg.runtime,
|
|
436
|
+
telemetry: telemetryState(),
|
|
437
|
+
serverHealth: health,
|
|
438
|
+
};
|
|
439
|
+
// `doctor` answers the question the workspace cannot: is this machine actually able to run a
|
|
440
|
+
// tool? It resolves the runtime entry, installs nothing, and tries one real MCP handshake with
|
|
441
|
+
// the runtime, so the failure is visible here instead of only as "runtime not running".
|
|
442
|
+
if (command === 'doctor') {
|
|
443
|
+
report.diagnosis = await diagnoseLocalRuntime(cfg);
|
|
444
|
+
}
|
|
445
|
+
console.log(JSON.stringify(report, null, 2));
|
|
446
|
+
if (command === 'doctor' && report.diagnosis.verdict !== 'ok') process.exitCode = 1;
|
|
358
447
|
return;
|
|
359
448
|
}
|
|
360
449
|
|
|
@@ -401,6 +490,7 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
401
490
|
return;
|
|
402
491
|
}
|
|
403
492
|
const before = { cli: VERSION, runtime: installedVersion(cfg.runtime.packageName) };
|
|
493
|
+
ensureServiceIfRecorded(cfg);
|
|
404
494
|
console.log(`Updating ReMCP to the latest published version (${runtimeSpec})…`);
|
|
405
495
|
npmGlobalInstall(`${PACKAGE_NAME}@latest`, runtimeSpec);
|
|
406
496
|
// Only a validated spec is persisted, so a failed update cannot leave the install unable to start.
|
|
@@ -425,7 +515,7 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
425
515
|
if (flags.purge) {
|
|
426
516
|
const cfg = loadConfig(false);
|
|
427
517
|
const specs = [PACKAGE_NAME, ...(cfg?.runtime?.packageName ? [cfg.runtime.packageName] : [])];
|
|
428
|
-
run(
|
|
518
|
+
run(npm.command, [...npm.args, 'uninstall', '--global', ...specs, '--no-audit', '--no-fund', '--loglevel=error']);
|
|
429
519
|
}
|
|
430
520
|
return;
|
|
431
521
|
}
|
package/src/npm.mjs
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import process from 'node:process';
|
|
5
|
+
import { spawnSync } from 'node:child_process';
|
|
6
|
+
|
|
7
|
+
// A background service does not inherit the PATH a terminal has: a launchd agent, a systemd unit and
|
|
8
|
+
// a Windows scheduled task all start with a minimal environment where `npm` is often not on PATH at
|
|
9
|
+
// all. That is how auto-update silently stopped working on macOS — the agent looked for `npm`, did
|
|
10
|
+
// not find it, and told the user to run the command by hand.
|
|
11
|
+
//
|
|
12
|
+
// npm is a JavaScript entry point, so the reliable answer is to run it with the same node that is
|
|
13
|
+
// already executing us, and only fall back to a PATH lookup. The resolver also accepts an explicit
|
|
14
|
+
// override (REMCP_NPM) for unusual installations.
|
|
15
|
+
const NPM_CLI_RELATIVE = ['lib', 'node_modules', 'npm', 'bin', 'npm-cli.js'];
|
|
16
|
+
|
|
17
|
+
export function npmCandidates({ nodePath = process.execPath, home = os.homedir(), platform = process.platform } = {}) {
|
|
18
|
+
const nodeDir = path.dirname(nodePath);
|
|
19
|
+
const cli = [];
|
|
20
|
+
// nvm, n, the official installer and the Docker image all place npm beside node like this.
|
|
21
|
+
cli.push(path.join(nodeDir, '..', NPM_CLI_RELATIVE.join(path.sep)));
|
|
22
|
+
cli.push(path.join(nodeDir, NPM_CLI_RELATIVE.join(path.sep)));
|
|
23
|
+
if (platform === 'darwin') {
|
|
24
|
+
cli.push(path.join('/opt/homebrew', NPM_CLI_RELATIVE.join(path.sep)));
|
|
25
|
+
cli.push(path.join('/usr/local', NPM_CLI_RELATIVE.join(path.sep)));
|
|
26
|
+
} else if (platform === 'win32') {
|
|
27
|
+
cli.push(path.join(nodeDir, 'node_modules', 'npm', 'bin', 'npm-cli.js'));
|
|
28
|
+
} else {
|
|
29
|
+
cli.push(path.join('/usr', NPM_CLI_RELATIVE.join(path.sep)));
|
|
30
|
+
cli.push(path.join('/usr/local', NPM_CLI_RELATIVE.join(path.sep)));
|
|
31
|
+
}
|
|
32
|
+
cli.push(path.join(home, '.local', NPM_CLI_RELATIVE.join(path.sep)));
|
|
33
|
+
const binaries = [
|
|
34
|
+
path.join(nodeDir, platform === 'win32' ? 'npm.cmd' : 'npm'),
|
|
35
|
+
path.join(nodeDir, '..', 'bin', platform === 'win32' ? 'npm.cmd' : 'npm'),
|
|
36
|
+
platform === 'darwin' ? '/opt/homebrew/bin/npm' : '',
|
|
37
|
+
platform === 'win32' ? '' : '/usr/local/bin/npm',
|
|
38
|
+
platform === 'win32' ? '' : '/usr/bin/npm',
|
|
39
|
+
platform === 'win32' ? '' : path.join(home, '.local', 'bin', 'npm'),
|
|
40
|
+
].filter(Boolean);
|
|
41
|
+
return { cli: [...new Set(cli)], binaries: [...new Set(binaries)] };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Resolves how to run npm. `source` is reported by `remcp doctor` and logged at agent startup so a
|
|
45
|
+
// machine where npm cannot be found is obvious before an update is needed.
|
|
46
|
+
//
|
|
47
|
+
// `exists` is injectable so a test can describe a machine with no npm at all instead of asking the
|
|
48
|
+
// machine running the test: the Linux candidate list carries fixed prefixes (/usr, /usr/local) that
|
|
49
|
+
// a CI runner or a developer laptop usually does have, which made that case untestable there.
|
|
50
|
+
export function resolveNpm({ nodePath = process.execPath, home = os.homedir(), platform = process.platform, exists = existsSync } = {}) {
|
|
51
|
+
const override = String(process.env.REMCP_NPM || '').trim();
|
|
52
|
+
if (override) return { command: override, args: [], source: `REMCP_NPM=${override}` };
|
|
53
|
+
const { cli, binaries } = npmCandidates({ nodePath, home, platform });
|
|
54
|
+
for (const candidate of cli) {
|
|
55
|
+
if (exists(candidate)) return { command: nodePath, args: [candidate], source: `node ${candidate}` };
|
|
56
|
+
}
|
|
57
|
+
for (const candidate of binaries) {
|
|
58
|
+
if (exists(candidate)) return { command: candidate, args: [], source: candidate };
|
|
59
|
+
}
|
|
60
|
+
return { command: platform === 'win32' ? 'npm.cmd' : 'npm', args: [], source: 'PATH' };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// The version npm itself reports, or null when it cannot be executed at all.
|
|
64
|
+
export function npmVersion(resolved = resolveNpm()) {
|
|
65
|
+
const result = spawnSync(resolved.command, [...resolved.args, '--version'], { encoding: 'utf8', timeout: 15000 });
|
|
66
|
+
if (result.error || result.status !== 0) {
|
|
67
|
+
// A login shell may still find npm (nvm and Homebrew write their PATH into the profile).
|
|
68
|
+
const shell = process.platform === 'win32' ? null : spawnSync('/bin/sh', ['-lc', 'command -v npm'], { encoding: 'utf8', timeout: 15000 });
|
|
69
|
+
const found = String(shell?.stdout || '').trim().split('\n').pop();
|
|
70
|
+
if (found && existsSync(found)) {
|
|
71
|
+
const retry = spawnSync(found, ['--version'], { encoding: 'utf8', timeout: 15000 });
|
|
72
|
+
if (!retry.error && retry.status === 0) return { version: String(retry.stdout).trim(), source: found };
|
|
73
|
+
}
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
return { version: String(result.stdout).trim(), source: resolved.source };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Runs npm with the resolved command, so callers never depend on PATH.
|
|
80
|
+
export function npmRun(args, { encoding = 'utf8', stdio = 'inherit' } = {}) {
|
|
81
|
+
const resolved = resolveNpm();
|
|
82
|
+
return spawnSync(resolved.command, [...resolved.args, ...args], { encoding, stdio });
|
|
83
|
+
}
|