@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remcp/remcp",
3
- "version": "0.2.13",
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
- const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm';
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(npmCommand, ['root', '--global'], { encoding: 'utf8' });
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
- console.log(`ReMCP ${installed || 'a newer version'} installed; restarting to apply it.`);
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
- function supervisorRestart() {
151
- if (process.env.INVOCATION_ID || process.env.JOURNAL_STREAM) return 'systemd';
152
- try { if (existsSync('/.dockerenv')) return 'docker'; } catch {}
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(npmCommand, ['prefix', '--global'], { encoding: 'utf8' });
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
- const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm';
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(npmCommand, ['prefix', '--global']);
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(npmCommand, ['install', '--global', ...specs, '--no-audit', '--no-fund', '--loglevel=error']);
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
- // systemd marks every unit process with INVOCATION_ID and Docker leaves /.dockerenv, so those two
223
- // cases can be handed over by exiting (the supervisor starts the new build); anything else gets an
224
- // explicit instruction instead of a silent exit that would take the device offline.
225
- function supervisorRestart() {
226
- if (process.env.INVOCATION_ID || process.env.JOURNAL_STREAM) return 'systemd';
227
- try { if (fs.existsSync('/.dockerenv')) return 'docker'; } catch {}
228
- return null;
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(npmCommand, ['prefix', '--global'], { encoding: 'utf8' });
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
- 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));
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(npmCommand, ['uninstall', '--global', ...specs, '--no-audit', '--no-fund', '--loglevel=error']);
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
+ }