@remcp/remcp 0.2.238 → 0.2.240

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.238",
3
+ "version": "0.2.240",
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,7 +29,7 @@ export function persistentServiceState(config) {
29
29
  const system = linuxSystemAgentOwned();
30
30
  const installed = system || fs.existsSync(linuxServiceFile);
31
31
  const active = installed && spawnSync('systemctl', [...(system ? [] : ['--user']), 'is-active', '--quiet', 'remcp-agent.service'], { stdio: 'ignore' }).status === 0;
32
- return { expected, installed, active, manager:system ? 'systemd-system' : 'systemd-user', name:'remcp-agent.service' };
32
+ return { expected, installed, active, manager:installed ? (system ? 'systemd-system' : 'systemd-user') : 'none', name:'remcp-agent.service' };
33
33
  }
34
34
  if (platform === 'darwin') {
35
35
  const installed = fs.existsSync(macServiceFile);
@@ -19,6 +19,76 @@ export function isManagedLinuxSystemUnit(text, launcher = linuxServiceLauncherFi
19
19
  return text.split(/\r?\n/).some(line => expected.has(line.trim()));
20
20
  }
21
21
 
22
+ // Discover BOTH supervisors before uninstalling either. A PID-1 migration may
23
+ // leave a disabled historical user unit behind; removing only the active scope
24
+ // makes status incorrectly report an installed service after uninstall.
25
+ export function discoverOwnedLinuxUnits({
26
+ systemFile = linuxSystemServiceFile,
27
+ userFile = linuxServiceFile,
28
+ launcher = linuxServiceLauncherFile,
29
+ includeSystem = process.getuid?.() === 0,
30
+ } = {}) {
31
+ const candidates = [
32
+ ...(includeSystem ? [{ scope:'system', file:systemFile }] : []),
33
+ { scope:'user', file:userFile },
34
+ ];
35
+ const found = [];
36
+ for (const unit of candidates) {
37
+ let stat;
38
+ try { stat = fs.lstatSync(unit.file); }
39
+ catch (error) {
40
+ if (error?.code === 'ENOENT') continue;
41
+ throw error;
42
+ }
43
+ if (!stat.isFile() || stat.isSymbolicLink() ||
44
+ !isManagedLinuxSystemUnit(fs.readFileSync(unit.file, 'utf8'), launcher)) {
45
+ throw new Error('Refusing to uninstall an unrecognized ReMCP systemd unit: ' + unit.file);
46
+ }
47
+ found.push(unit);
48
+ }
49
+ return found;
50
+ }
51
+
52
+ // Stop and remove all owned scopes, not just the newest one. Require the
53
+ // stop and daemon-reload to succeed; never claim success after a failed stop.
54
+ export function uninstallOwnedLinuxUnits({
55
+ systemFile = linuxSystemServiceFile,
56
+ userFile = linuxServiceFile,
57
+ launcher = linuxServiceLauncherFile,
58
+ includeSystem = process.getuid?.() === 0,
59
+ runner = spawnSync,
60
+ } = {}) {
61
+ const units = discoverOwnedLinuxUnits({ systemFile, userFile, launcher, includeSystem });
62
+ for (const { scope, file } of units) {
63
+ const option = scope === 'user' ? ['--user'] : [];
64
+ const stop = runner('systemctl', [...option, 'disable', '--now', 'remcp-agent.service'],
65
+ { encoding:'utf8', timeout:20000 });
66
+ if (stop.status !== 0) {
67
+ throw new Error('Failed to stop ReMCP ' + scope +
68
+ ' systemd service; refusing to report a successful uninstall');
69
+ }
70
+ const active = runner('systemctl', [...option, 'is-active', '--quiet', 'remcp-agent.service'],
71
+ { stdio:'ignore', timeout:10000 });
72
+ if (active.status === 0) {
73
+ throw new Error('ReMCP ' + scope + ' systemd service remains active after stop');
74
+ }
75
+ // Another actor must not replace an owned unit with a symlink or foreign
76
+ // content between discovery and removal.
77
+ const stat = fs.lstatSync(file);
78
+ if (!stat.isFile() || stat.isSymbolicLink() ||
79
+ !isManagedLinuxSystemUnit(fs.readFileSync(file, 'utf8'), launcher)) {
80
+ throw new Error('ReMCP systemd unit changed during uninstall: ' + file);
81
+ }
82
+ fs.unlinkSync(file);
83
+ const reload = runner('systemctl', [...option, 'daemon-reload'],
84
+ { encoding:'utf8', timeout:20000 });
85
+ if (reload.status !== 0) {
86
+ throw new Error('Failed to reload ' + scope + ' systemd after removing ReMCP service');
87
+ }
88
+ }
89
+ return units.map(unit => unit.scope);
90
+ }
91
+
22
92
  export function linuxSystemAgentOwned() {
23
93
  if (process.platform !== 'linux' || process.getuid?.() !== 0) return false;
24
94
  try {
@@ -40,13 +110,16 @@ export function linuxActiveServiceFile() {
40
110
  // even if systemctl --user enable --now reported success.
41
111
  export function ensureLinuxLinger() {
42
112
  if (process.platform !== 'linux') return;
43
- // Tests isolate HOME in a temporary fixture. Never mutate the real host's loginctl
44
- // configuration on behalf of that synthetic service installation.
45
- if (path.resolve(home) !== path.resolve(os.userInfo().homedir)) {
46
- if (process.env.NODE_ENV === 'test') return;
113
+ // Test-only synthetic HOME has no logind account and must never mutate the host.
114
+ // Guard before os.userInfo(): an isolated CI UID may not have a passwd entry.
115
+ if (process.env.NODE_ENV === 'test' && process.env.REMCP_TEST_PLATFORM === 'linux') return;
116
+ let account;
117
+ try { account = os.userInfo(); }
118
+ catch { throw new Error('ReMCP cannot resolve the current Linux account for persistent systemd supervision.'); }
119
+ if (path.resolve(home) !== path.resolve(account.homedir)) {
47
120
  throw new Error('Refusing a persistent Linux service with HOME different from the account home directory.');
48
121
  }
49
- const username = os.userInfo().username;
122
+ const username = account.username;
50
123
  const query = () => spawnSync('loginctl', ['show-user', username, '-p', 'Linger', '--value'],
51
124
  { encoding:'utf8', timeout:10000 });
52
125
  let state = query();
@@ -18,7 +18,7 @@ import { PACKAGE_NAME, VERSION } from '../version.mjs';
18
18
  import { saveConfig } from './config.mjs';
19
19
  import { configDir, home, linuxServiceFile, linuxServiceLauncherFile, macServiceFile, macServiceLabel, windowsServiceLauncherFile, windowsStartupScriptFile, windowsTaskName } from './env.mjs';
20
20
  import { run } from './shell.mjs';
21
- import { linuxActiveServiceFile, linuxServiceScope, linuxSystemAgentOwned, linuxSystemServiceFile, ensureLinuxLinger, ensureLinuxCliCommand } from './service-linux.mjs';
21
+ import { linuxActiveServiceFile, linuxServiceScope, linuxSystemAgentOwned, linuxSystemServiceFile, ensureLinuxLinger, ensureLinuxCliCommand, uninstallOwnedLinuxUnits } from './service-linux.mjs';
22
22
  import { windowsServiceLauncherText, windowsTaskAbsenceCommand, windowsStartupScriptText, windowsStartupScriptInstalled } from './windows-supervisor.mjs';
23
23
  import { ensureWindowsSupervisorKey, windowsSupervisorCommand } from './windows-user-supervisor.mjs';
24
24
 
@@ -66,7 +66,9 @@ export function installLinuxService(cliPath = globalCliPath(), nodePath = proces
66
66
  const launcherFile = writeLinuxServiceLauncher(cliPath, nodePath);
67
67
  // Fresh root installs are boot services owned by PID 1. Legacy per-user units are
68
68
  // left under their existing supervisor, but must have linger enabled.
69
- const privileged = process.getuid?.() === 0 && path.resolve(home) === path.resolve(os.userInfo().homedir);
69
+ const syntheticLinuxFixture = process.env.NODE_ENV === 'test' && process.env.REMCP_TEST_PLATFORM === 'linux';
70
+ const privileged = !syntheticLinuxFixture && process.getuid?.() === 0
71
+ && path.resolve(home) === path.resolve(os.userInfo().homedir);
70
72
  if (privileged && fs.existsSync(linuxSystemServiceFile) && !linuxSystemAgentOwned()) {
71
73
  throw new Error('Refusing to replace an unmanaged systemd remcp-agent.service');
72
74
  }
@@ -384,11 +386,7 @@ export function restartPersistentServiceIfInstalled(config, { deferLinux = false
384
386
  export function uninstallPersistentService() {
385
387
  const platform = servicePlatform();
386
388
  if (platform === 'linux') {
387
- const scope = linuxServiceScope();
388
- const option = scope === 'user' ? ['--user'] : [];
389
- spawnSync('systemctl', [...option, 'disable', '--now', 'remcp-agent.service'], { stdio:'inherit' });
390
- try { fs.unlinkSync(linuxActiveServiceFile()); } catch {}
391
- spawnSync('systemctl', [...option, 'daemon-reload'], { stdio:'inherit' });
389
+ uninstallOwnedLinuxUnits();
392
390
  } else if (platform === 'darwin') {
393
391
  const domain = macLaunchDomain();
394
392
  spawnSync('launchctl', ['bootout', domain, macServiceFile], { stdio: 'ignore' });
package/src/cli.mjs CHANGED
@@ -360,6 +360,7 @@ export async function main(argv = process.argv.slice(2)) {
360
360
  },
361
361
  agent:{
362
362
  mode:service.installed ? 'service' : 'manual',
363
+ installed:service.installed,
363
364
  running:service.active,
364
365
  manager:service.manager,
365
366
  serviceCliVersion:serviceInstall.cliVersion,
@@ -419,6 +420,9 @@ export async function main(argv = process.argv.slice(2)) {
419
420
  if (flags.purge) {
420
421
  const specs = [PACKAGE_NAME, ...(cfg?.runtime?.packageName ? [cfg.runtime.packageName] : [])];
421
422
  run(npm.command, [...npm.args, 'uninstall', '--global', ...specs, '--no-audit', '--no-fund', '--loglevel=error']);
423
+ console.log('ReMCP background service removed and global npm packages uninstalled. Device pairing was retained.');
424
+ } else {
425
+ console.log('ReMCP background service removed. The CLI, runtime packages and device pairing were retained. Use remcp uninstall --purge to also uninstall global npm packages.');
422
426
  }
423
427
  return;
424
428
  }