@remcp/remcp 0.2.54 → 0.2.56

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.54",
3
+ "version": "0.2.56",
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",
@@ -29,9 +29,92 @@ export function npmGlobalInstall(...specs) {
29
29
  run(npm.command, [...npm.args, 'install', '--global', ...specs, '--no-audit', '--no-fund', '--loglevel=error']);
30
30
  }
31
31
 
32
- export function npmGlobalInstallForNode(nodePath, ...specs) {
32
+ function pathEntryExists(file) {
33
+ try {
34
+ fs.lstatSync(file);
35
+ return true;
36
+ } catch {
37
+ return false;
38
+ }
39
+ }
40
+
41
+ function globalPackagePath(prefix, packageName) {
42
+ const name = String(packageName || '').trim();
43
+ const parts = name.split('/');
44
+ const validUnscoped = parts.length === 1 && parts[0] && !parts[0].startsWith('@') && parts[0] !== '.' && parts[0] !== '..';
45
+ const validScoped = parts.length === 2 && /^@[^/]+$/.test(parts[0]) && parts[1] && parts[1] !== '.' && parts[1] !== '..';
46
+ if (!validUnscoped && !validScoped) throw new Error(`Invalid npm package name for update: ${name || '(empty)'}`);
47
+ return path.join(prefix, 'lib', 'node_modules', ...parts);
48
+ }
49
+
50
+ function transactionalMacGlobalInstall({ resolved, prefix, packageNames, specs }) {
51
+ prefix = String(prefix || '').trim();
52
+ if (!path.isAbsolute(prefix)) throw new Error(`npm global prefix must be absolute for a macOS update: ${prefix || '(empty)'}`);
53
+ const backupRoot = path.join(prefix, `.remcp-update-backup-${process.pid}-${Date.now()}`);
54
+ const moved = [];
55
+ const moveAside = (source, label) => {
56
+ if (!pathEntryExists(source)) return;
57
+ const backup = path.join(backupRoot, label);
58
+ fs.renameSync(source, backup);
59
+ moved.push({ source, backup });
60
+ };
61
+ const restore = originalError => {
62
+ let rollbackError = null;
63
+ for (const item of [...moved].reverse()) {
64
+ try {
65
+ if (pathEntryExists(item.source)) fs.rmSync(item.source, { recursive:true, force:true });
66
+ fs.mkdirSync(path.dirname(item.source), { recursive:true });
67
+ fs.renameSync(item.backup, item.source);
68
+ } catch (error) {
69
+ rollbackError ??= error;
70
+ }
71
+ }
72
+ try { fs.rmSync(backupRoot, { recursive:true, force:true }); } catch (error) { rollbackError ??= error; }
73
+ if (rollbackError) {
74
+ throw new Error(
75
+ `ReMCP update failed and the previous macOS installation could not be fully restored: ${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}`,
76
+ { cause:originalError },
77
+ );
78
+ }
79
+ };
80
+
81
+ fs.mkdirSync(prefix, { recursive:true });
82
+ fs.mkdirSync(backupRoot, { recursive:false, mode:0o700 });
83
+ try {
84
+ const uniqueNames = [...new Set(packageNames.map(name => String(name || '').trim()).filter(Boolean))];
85
+ uniqueNames.forEach((name, index) => moveAside(globalPackagePath(prefix, name), `package-${index}`));
86
+ if (uniqueNames.includes(PACKAGE_NAME)) moveAside(path.join(prefix, 'bin', 'remcp'), 'bin-remcp');
87
+
88
+ run(resolved.command, [...resolved.args, 'install', '--global', ...specs, '--no-audit', '--no-fund', '--loglevel=error']);
89
+ } catch (error) {
90
+ restore(error);
91
+ throw error;
92
+ }
93
+
94
+ try {
95
+ fs.rmSync(backupRoot, { recursive:true, force:true });
96
+ } catch (error) {
97
+ console.error(`ReMCP update: could not remove macOS rollback backup ${backupRoot}: ${error instanceof Error ? error.message : String(error)}`);
98
+ }
99
+ }
100
+
101
+ export function npmGlobalUpdate(packageNames, ...specs) {
102
+ if (servicePlatform() !== 'darwin') {
103
+ npmGlobalInstall(...specs);
104
+ return;
105
+ }
106
+ const prefix = globalPrefix();
107
+ transactionalMacGlobalInstall({ resolved:npm, prefix, packageNames, specs });
108
+ }
109
+
110
+ export function npmGlobalUpdateForNode(nodePath, packageNames, ...specs) {
33
111
  const resolved = resolveNpm({ nodePath, home, platform:servicePlatform() });
34
- run(resolved.command, [...resolved.args, 'install', '--global', ...specs, '--no-audit', '--no-fund', '--loglevel=error']);
112
+ if (servicePlatform() !== 'darwin') {
113
+ run(resolved.command, [...resolved.args, 'install', '--global', ...specs, '--no-audit', '--no-fund', '--loglevel=error']);
114
+ return;
115
+ }
116
+ const prefix = output(resolved.command, [...resolved.args, 'prefix', '--global']);
117
+ transactionalMacGlobalInstall({ resolved, prefix, packageNames, specs });
35
118
  }
36
119
 
37
120
  export function quoteSystemd(value) {
@@ -75,6 +158,100 @@ function shellQuote(value) {
75
158
  return `'${String(value).replaceAll("'", "'\\''")}'`;
76
159
  }
77
160
 
161
+ const remcpPathBlockStart = '# >>> ReMCP CLI PATH >>>';
162
+ const remcpPathBlockEnd = '# <<< ReMCP CLI PATH <<<';
163
+ const remcpCliShimMarker = '# ReMCP managed CLI shim';
164
+
165
+ function macCliShimPath() {
166
+ return path.join(home, '.local', 'bin', 'remcp');
167
+ }
168
+
169
+ function interactiveShellProfile() {
170
+ const shell = String(process.env.REMCP_TEST_SHELL || process.env.SHELL || os.userInfo().shell || '/bin/zsh');
171
+ const name = path.basename(shell);
172
+ if (name === 'zsh') return path.join(home, '.zshrc');
173
+ if (name === 'bash') return path.join(home, '.bash_profile');
174
+ return '';
175
+ }
176
+
177
+ function upsertManagedPathBlock(file) {
178
+ if (!file) return false;
179
+ const block = [
180
+ remcpPathBlockStart,
181
+ 'case ":$PATH:" in',
182
+ ' *":$HOME/.local/bin:"*) ;;',
183
+ ' *) export PATH="$HOME/.local/bin:$PATH" ;;',
184
+ 'esac',
185
+ remcpPathBlockEnd,
186
+ ].join('\n');
187
+ let current = '';
188
+ try { current = fs.readFileSync(file, 'utf8'); } catch {}
189
+ const start = current.indexOf(remcpPathBlockStart);
190
+ const end = start >= 0 ? current.indexOf(remcpPathBlockEnd, start + remcpPathBlockStart.length) : -1;
191
+ let next;
192
+ if (start >= 0 && end >= 0) {
193
+ next = current.slice(0, start) + block + current.slice(end + remcpPathBlockEnd.length);
194
+ } else {
195
+ const prefix = current && !current.endsWith('\n') ? current + '\n' : current;
196
+ next = prefix + (prefix ? '\n' : '') + block + '\n';
197
+ }
198
+ if (next === current) return false;
199
+ fs.writeFileSync(file, next, { mode:0o600 });
200
+ return true;
201
+ }
202
+
203
+ function removeManagedPathBlock(file) {
204
+ if (!file) return;
205
+ let current = '';
206
+ try { current = fs.readFileSync(file, 'utf8'); } catch { return; }
207
+ const start = current.indexOf(remcpPathBlockStart);
208
+ const end = start >= 0 ? current.indexOf(remcpPathBlockEnd, start + remcpPathBlockStart.length) : -1;
209
+ if (start < 0 || end < 0) return;
210
+ let before = current.slice(0, start);
211
+ let after = current.slice(end + remcpPathBlockEnd.length);
212
+ if (before.endsWith('\n') && after.startsWith('\n')) after = after.slice(1);
213
+ const next = before + after;
214
+ fs.writeFileSync(file, next, { mode:0o600 });
215
+ }
216
+
217
+ export function ensureMacCliCommand(config = {}, { cliPath = '', nodePath = '' } = {}) {
218
+ if (servicePlatform() !== 'darwin') return null;
219
+ const effectiveCli = cliPath || (persistentServiceExpected(config) ? canonicalServiceCliPath(config) : globalCliPath());
220
+ const effectiveNode = nodePath || (persistentServiceExpected(config) ? canonicalServiceNodePath(config) : process.execPath);
221
+ const cliScript = resolvedCliScript(effectiveCli);
222
+ const shim = macCliShimPath();
223
+ try {
224
+ const existing = fs.readFileSync(shim, 'utf8');
225
+ if (!existing.includes(remcpCliShimMarker)) {
226
+ console.error(`ReMCP did not replace ${shim} because it is not a ReMCP-managed command.`);
227
+ return null;
228
+ }
229
+ } catch (error) {
230
+ if (error?.code && error.code !== 'ENOENT') {
231
+ console.error(`ReMCP could not inspect ${shim}: ${error instanceof Error ? error.message : String(error)}`);
232
+ return null;
233
+ }
234
+ }
235
+ const launcher = `#!/bin/sh\n${remcpCliShimMarker}\nexec ${shellQuote(effectiveNode)} ${shellQuote(cliScript)} "$@"\n`;
236
+ fs.mkdirSync(path.dirname(shim), { recursive:true, mode:0o755 });
237
+ const temporary = `${shim}.${process.pid}.${Date.now()}.tmp`;
238
+ fs.writeFileSync(temporary, launcher, { mode:0o755 });
239
+ fs.chmodSync(temporary, 0o755);
240
+ fs.renameSync(temporary, shim);
241
+ upsertManagedPathBlock(interactiveShellProfile());
242
+ return shim;
243
+ }
244
+
245
+ function removeMacCliCommand() {
246
+ if (servicePlatform() !== 'darwin') return;
247
+ const shim = macCliShimPath();
248
+ try {
249
+ const existing = fs.readFileSync(shim, 'utf8');
250
+ if (existing.includes(remcpCliShimMarker)) fs.unlinkSync(shim);
251
+ } catch {}
252
+ removeManagedPathBlock(interactiveShellProfile());
253
+ }
254
+
78
255
  function macServicePlist(cliPath, nodePath = process.execPath) {
79
256
  // launchd wants an absolute path; a symlinked prefix that npm has not materialised yet (or a path
80
257
  // that is about to be replaced by the next install) must not abort the repair — a stale plist is
@@ -337,7 +514,7 @@ export function syncKnownInstallations(config, ...specs) {
337
514
  seen.add(resolvedNode);
338
515
  const required = Boolean(candidate?.service) || resolvedNode === serviceInfo.nodePath;
339
516
  try {
340
- npmGlobalInstallForNode(resolvedNode, ...specs);
517
+ npmGlobalUpdateForNode(resolvedNode, [PACKAGE_NAME, config.runtime.packageName], ...specs);
341
518
  results.push({ nodePath:resolvedNode, ok:true, required });
342
519
  } catch (error) {
343
520
  const message = error instanceof Error ? error.message : String(error);
@@ -403,8 +580,10 @@ export function installPersistentAgent(config) {
403
580
  const cliScript = resolvedCliScript(cliPath);
404
581
  const nodePath = process.execPath;
405
582
  if (platform === 'linux') installLinuxService(cliPath, nodePath);
406
- else if (platform === 'darwin') installMacService(cliPath, { nodePath });
407
- else installWindowsService(cliPath);
583
+ else if (platform === 'darwin') {
584
+ installMacService(cliPath, { nodePath });
585
+ ensureMacCliCommand(config, { cliPath:cliScript, nodePath });
586
+ } else installWindowsService(cliPath);
408
587
  configurePostInstallAccess();
409
588
  saveConfig({ ...config, serviceInstalled: true, serviceCliPath: cliScript, serviceNodePath: nodePath });
410
589
  console.log('ReMCP is installed as a background service. Future updates: remcp update');
@@ -447,6 +626,7 @@ export function ensureServiceIfRecorded(config) {
447
626
  // installMacService either leaves an unchanged loaded job alone, bootstraps an unloaded job, or
448
627
  // hands a changed launcher to an independent transient launchd helper.
449
628
  const state = installMacService(cliPath, { restart: false, nodePath });
629
+ ensureMacCliCommand(config, { cliPath, nodePath });
450
630
  if (state === 'reload-scheduled') restartScheduled = macServiceLabel;
451
631
  } else if (platform === 'win32') installWindowsService(cliPath);
452
632
  // Upgrade the legacy inferred state only after the supervisor repair succeeded. A failed repair
@@ -503,6 +683,7 @@ export function uninstallPersistentService() {
503
683
  const domain = macLaunchDomain();
504
684
  spawnSync('launchctl', ['bootout', domain, macServiceFile], { stdio: 'ignore' });
505
685
  try { fs.unlinkSync(macServiceFile); } catch {}
686
+ removeMacCliCommand();
506
687
  } else if (platform === 'win32') {
507
688
  spawnSync('schtasks.exe', ['/End', '/TN', windowsTaskName], { stdio: 'ignore' });
508
689
  spawnSync('schtasks.exe', ['/Delete', '/TN', windowsTaskName, '/F'], { stdio: 'ignore' });
@@ -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 { currentInstallationInfo, ensureServiceIfRecorded, installationVersionsAtCliPath, npmGlobalInstall, persistentServiceExpected, rememberCurrentInstallation, restartPersistentServiceIfInstalled, serviceInstallationInfo, syncKnownInstallations } from './service.mjs';
14
+ import { currentInstallationInfo, ensureMacCliCommand, ensureServiceIfRecorded, installationVersionsAtCliPath, npmGlobalUpdate, persistentServiceExpected, rememberCurrentInstallation, restartPersistentServiceIfInstalled, serviceInstallationInfo, syncKnownInstallations } from './service.mjs';
15
15
 
16
16
  const UPDATE_DISCOVERY_TIMEOUT_MS = 5000;
17
17
 
@@ -232,6 +232,9 @@ export async function updateCommand(flags) {
232
232
  }
233
233
 
234
234
  if (alreadyCurrent) {
235
+ // `npx @remcp/remcp@latest update` is the recovery path when a Node-manager prefix was never
236
+ // exported into the user's shell. Even with no package delta, repair the stable macOS command.
237
+ ensureMacCliCommand(cfg);
235
238
  if (targets.persistRuntime) {
236
239
  saveConfig({ ...cfg, runtime:{ ...cfg.runtime, packageSpec:targets.runtimeSpec } });
237
240
  }
@@ -242,7 +245,7 @@ export async function updateCommand(flags) {
242
245
 
243
246
  const before = { cli: VERSION, runtime: installedVersion(cfg.runtime.packageName) };
244
247
  console.log(`Updating ReMCP to ${targets.clientSpec} with ${targets.runtimeSpec}…`);
245
- npmGlobalInstall(targets.clientSpec, targets.runtimeSpec);
248
+ npmGlobalUpdate([PACKAGE_NAME, cfg.runtime.packageName], targets.clientSpec, targets.runtimeSpec);
246
249
 
247
250
  // Converge every ReMCP installation this account has actually used on this machine. The canonical
248
251
  // service installation is mandatory; secondary nvm/Hermes/Homebrew copies are best-effort and
package/src/cli.mjs CHANGED
@@ -11,7 +11,7 @@ import { ensureMachineId, loadConfig, readJsonFile, saveConfig, setTelemetry, te
11
11
  import { assertRuntimeTrust, pairWithDeviceCode } from './cli/connect.mjs';
12
12
  import { diagnoseLocalRuntime, installedVersion, runtimeAllowedRoots } from './cli/doctor.mjs';
13
13
  import { configFile, npm, officialOrigin, runtimeConfigFile } from './cli/env.mjs';
14
- import { currentInstallationInfo, installPersistentAgent, persistentServiceState, rememberCurrentInstallation, restartPersistentServiceIfInstalled, serviceInstallationInfo, uninstallPersistentService } from './cli/service.mjs';
14
+ import { currentInstallationInfo, ensureMacCliCommand, installPersistentAgent, persistentServiceState, rememberCurrentInstallation, restartPersistentServiceIfInstalled, serviceInstallationInfo, uninstallPersistentService } from './cli/service.mjs';
15
15
  import { run } from './cli/shell.mjs';
16
16
  import { updateCommand } from './cli/update.mjs';
17
17
  function parse(argv) {
@@ -97,6 +97,10 @@ export async function main(argv = process.argv.slice(2)) {
97
97
  || Boolean(process.env.INVOCATION_ID)
98
98
  || String(process.env.XPC_SERVICE_NAME || '').includes('com.remcp.agent');
99
99
  const cfg = rememberCurrentInstallation(loadConfig(), { service:supervised });
100
+ // A service can start from an absolute fnm/nvm Node path even when an interactive macOS shell
101
+ // cannot resolve the npm-global `remcp` binary. Refresh the stable user shim on every start so
102
+ // an auto-update repairs the next Terminal session without requiring a manual PATH edit.
103
+ ensureMacCliCommand(cfg);
100
104
  const service = persistentServiceState(cfg);
101
105
  if (!supervised && service.active) {
102
106
  console.log(`ReMCP is already running as a background service (${service.name}). Use \`remcp status\` to inspect it or \`remcp doctor\` to diagnose it.`);