@remcp/remcp 0.2.32 → 0.2.33

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.32",
3
+ "version": "0.2.33",
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 && node --check src/npm.mjs && node --check src/fs-access.mjs",
21
+ "check": "node --check bin/remcp.mjs && node --check src/agent.mjs && node --check src/cli.mjs && node --check src/cli/config.mjs && node --check src/cli/connect.mjs && node --check src/cli/doctor.mjs && node --check src/cli/env.mjs && node --check src/cli/service.mjs && node --check src/cli/shell.mjs && node --check src/fs-access.mjs && node --check src/npm.mjs && node --check src/runtime.mjs && node --check src/version.mjs",
22
22
  "test": "node --test test/*.test.mjs"
23
23
  },
24
24
  "dependencies": {
@@ -0,0 +1,86 @@
1
+ // The paired configuration and the two switches that live beside it: usage metrics and the machine id
2
+ // the server uses to recognise this computer across pairings.
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+ import { randomUUID } from 'node:crypto';
6
+ import { normalizeRuntime } from '../runtime.mjs';
7
+
8
+ import { configDir, configFile, machineIdFile, officialOrigin, runtimeConfigFile } from './env.mjs';
9
+
10
+ export function loadConfig(required = true) {
11
+ if (!fs.existsSync(configFile)) {
12
+ if (!required) return undefined;
13
+ throw new Error(`ReMCP is not paired. Generate a pairing command at ${officialOrigin}/app/connect`);
14
+ }
15
+ const value = JSON.parse(fs.readFileSync(configFile, 'utf8'));
16
+ // A configuration that only carries preferences (for example after `remcp auto-update off`
17
+ // before pairing) has no runtime yet; it must not fail as if it were corrupt.
18
+ if (value.runtime !== undefined) value.runtime = normalizeRuntime(value.runtime);
19
+ return value;
20
+ }
21
+
22
+ export function saveConfig(value) {
23
+ fs.mkdirSync(configDir, { recursive: true, mode: 0o700 });
24
+ fs.writeFileSync(configFile, JSON.stringify(value, null, 2) + '\n', { mode: 0o600 });
25
+ fs.chmodSync(configFile, 0o600);
26
+ }
27
+
28
+ export function readJsonFile(file) {
29
+ try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return {}; }
30
+ }
31
+
32
+ export function writeJsonFile(file, value) {
33
+ fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
34
+ fs.writeFileSync(file, JSON.stringify(value, null, 2) + '\n', { mode: 0o600 });
35
+ fs.chmodSync(file, 0o600);
36
+ }
37
+
38
+ export function flagEnabled(value) {
39
+ return value === undefined || value === null ? true : value !== false;
40
+ }
41
+
42
+ // One switch for the whole machine: the client and the runtime it spawns must agree,
43
+ // otherwise the runtime would keep reporting after the user opted out.
44
+ export function telemetryState() {
45
+ const client = readJsonFile(configFile);
46
+ const runtime = readJsonFile(runtimeConfigFile);
47
+ const clientEnabled = flagEnabled(client.telemetryEnabled);
48
+ const runtimeEnabled = flagEnabled(runtime.telemetryEnabled);
49
+ return {
50
+ enabled: clientEnabled && runtimeEnabled,
51
+ clientEnabled,
52
+ runtimeEnabled,
53
+ installReported: client.installReported === true,
54
+ configFile,
55
+ runtimeConfigFile,
56
+ transport: 'paired-agent-only',
57
+ endpoint: null,
58
+ collects: 'tool names, durations, outcomes, error classes, and device health samples',
59
+ neverCollects: 'file paths, file contents, command strings, tool arguments, and tool output',
60
+ thirdParty: false,
61
+ installPing: false,
62
+ remoteFeatureFlags: false,
63
+ };
64
+ }
65
+
66
+ export function setTelemetry(enabled) {
67
+ const client = readJsonFile(configFile);
68
+ client.telemetryEnabled = enabled;
69
+ writeJsonFile(configFile, client);
70
+ const runtime = readJsonFile(runtimeConfigFile);
71
+ runtime.telemetryEnabled = enabled;
72
+ writeJsonFile(runtimeConfigFile, runtime);
73
+ return telemetryState();
74
+ }
75
+
76
+ export function ensureMachineId() {
77
+ fs.mkdirSync(configDir, { recursive: true, mode: 0o700 });
78
+ try {
79
+ const existing = fs.readFileSync(machineIdFile, 'utf8').trim();
80
+ if (/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(existing)) return existing;
81
+ } catch {}
82
+ const value = randomUUID();
83
+ fs.writeFileSync(machineIdFile, value + '\n', { mode: 0o600 });
84
+ fs.chmodSync(machineIdFile, 0o600);
85
+ return value;
86
+ }
@@ -0,0 +1,81 @@
1
+ // Pairing a computer: the browser handshake a person approves, and the trust check that decides
2
+ // whether a custom server may name the local runtime version.
3
+ import os from 'node:os';
4
+ import process from 'node:process';
5
+ import { spawn } from 'node:child_process';
6
+
7
+ import { ensureMachineId } from './config.mjs';
8
+ import { officialOrigin } from './env.mjs';
9
+ import { sleep } from './shell.mjs';
10
+
11
+ // Opens the approval page in the person's browser. A machine that nobody is looking at only gets the
12
+ // printed URL, so every failure here is silent and non-fatal.
13
+ export function openInBrowser(url) {
14
+ try {
15
+ const command = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'cmd' : 'xdg-open';
16
+ const args = process.platform === 'win32' ? ['/c', 'start', '', url] : [url];
17
+ const child = spawn(command, args, { stdio: 'ignore', detached: true });
18
+ child.on('error', () => {});
19
+ child.unref();
20
+ } catch {}
21
+ }
22
+
23
+ // Device authorization (RFC 8628): the computer asks for a code, the person approves it in the
24
+ // browser while signed in, and this process collects the credential by polling. The device never
25
+ // sees a browser session or an account password.
26
+ export async function pairWithDeviceCode(server, flags) {
27
+ const authorization = await fetch(`${server}/oauth/device_authorization`, {
28
+ method: 'POST',
29
+ headers: { 'content-type': 'application/json' },
30
+ body: JSON.stringify({
31
+ name: String(flags.name || os.hostname()),
32
+ hostname: os.hostname(),
33
+ platform: process.platform,
34
+ arch: process.arch,
35
+ machineId: ensureMachineId(),
36
+ }),
37
+ });
38
+ if (!authorization.ok) throw new Error(`Pairing failed (${authorization.status}): ${await authorization.text()}`);
39
+ const grant = await authorization.json();
40
+ const approvalUrl = grant.verification_uri_complete || grant.verification_uri;
41
+ console.log(`Approve this computer in your browser: ${approvalUrl}`);
42
+ console.log(`Pairing code: ${grant.user_code} (expires in ${Math.max(1, Math.round(Number(grant.expires_in || 600) / 60))} minutes)`);
43
+ openInBrowser(approvalUrl);
44
+ console.log('Waiting for approval… (Ctrl+C to cancel)');
45
+ const deadline = Date.now() + (Number(grant.expires_in) || 600) * 1000;
46
+ let lastReminder = Date.now();
47
+ const intervalMs = Math.max(1, Number(grant.interval) || 5) * 1000;
48
+ while (Date.now() < deadline) {
49
+ await sleep(intervalMs);
50
+ const response = await fetch(`${server}/oauth/token`, {
51
+ method: 'POST',
52
+ headers: { 'content-type': 'application/x-www-form-urlencoded' },
53
+ body: new URLSearchParams({
54
+ grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
55
+ device_code: String(grant.device_code || ''),
56
+ machineId: ensureMachineId(),
57
+ }).toString(),
58
+ });
59
+ const data = await response.json().catch(() => ({}));
60
+ if (response.ok && data.device_token) return data;
61
+ if (data.error === 'authorization_pending' || data.error === 'slow_down') {
62
+ if (Date.now() - lastReminder > 60_000) {
63
+ lastReminder = Date.now();
64
+ const left = Math.max(0, Math.round((deadline - Date.now()) / 60_000));
65
+ console.log(`Still waiting — approve at ${approvalUrl} (about ${left} min left)`);
66
+ }
67
+ continue;
68
+ }
69
+ if (data.error === 'access_denied') throw new Error('That pairing request was denied in the browser. Run the command again if it was not you.');
70
+ if (data.error === 'expired_token') break;
71
+ throw new Error(`Pairing failed (${response.status}): ${JSON.stringify(data)}`);
72
+ }
73
+ throw new Error('The pairing code expired before it was approved. Run the command again.');
74
+ }
75
+
76
+ export function assertRuntimeTrust(server, flags) {
77
+ const origin = new URL(server).origin;
78
+ if (origin !== officialOrigin && !flags['trust-runtime']) {
79
+ throw new Error('Custom servers can provide local runtime metadata. Re-run with --trust-runtime only if you trust that server.');
80
+ }
81
+ }
@@ -0,0 +1,89 @@
1
+ // What remcp doctor answers: whether this machine can really run a tool, and where the runtime is
2
+ // allowed to write. The checks install nothing and report the exact failing step.
3
+ import fs from 'node:fs';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+ import process from 'node:process';
7
+ import { spawnSync } from 'node:child_process';
8
+ import { localRuntimeEntry } from '../agent.mjs';
9
+ import { npmVersion, resolveNpm } from '../npm.mjs';
10
+ import { PACKAGE_NAME, VERSION } from '../version.mjs';
11
+
12
+ import { npm, runtimeConfigFile } from './env.mjs';
13
+
14
+ // when it really owns this process: a terminal gets an explicit instruction instead of a silent exit
15
+ // that would take the device offline.
16
+ // One real handshake with the local runtime, plus everything needed to explain a failure: where the
17
+ // entry resolved, whether the package is installed, the node that would run it, and the exact error.
18
+ export async function diagnoseLocalRuntime(cfg) {
19
+ const packageName = cfg.runtime?.packageName || '';
20
+ const diagnosis = {
21
+ platform: `${process.platform} ${process.arch}`,
22
+ node: process.execPath,
23
+ nodeVersion: process.versions.node,
24
+ packageName,
25
+ packageSpec: cfg.runtime?.packageSpec || '',
26
+ installedRuntime: installedVersion(packageName),
27
+ installedClient: installedVersion(PACKAGE_NAME),
28
+ };
29
+ const resolved = resolveNpm();
30
+ const npmInfo = npmVersion(resolved);
31
+ diagnosis.npm = npmInfo ? { version: npmInfo.version, source: npmInfo.source } : { error: `npm could not be executed (tried ${resolved.source})` };
32
+ let entry = '';
33
+ try {
34
+ entry = localRuntimeEntry(cfg.runtime);
35
+ diagnosis.entry = entry;
36
+ diagnosis.entryExists = fs.existsSync(entry);
37
+ } catch (error) {
38
+ diagnosis.entry = null;
39
+ diagnosis.entryExists = false;
40
+ diagnosis.verdict = 'runtime-not-installed';
41
+ diagnosis.error = error instanceof Error ? error.message : String(error);
42
+ diagnosis.hint = `Reinstall with: npx --yes ${PACKAGE_NAME}@latest update`;
43
+ return diagnosis;
44
+ }
45
+ if (!diagnosis.entryExists) {
46
+ diagnosis.verdict = 'runtime-entry-missing';
47
+ diagnosis.hint = `Reinstall with: npx --yes ${PACKAGE_NAME}@latest update`;
48
+ return diagnosis;
49
+ }
50
+ try {
51
+ const { Client } = await import('@modelcontextprotocol/sdk/client/index.js');
52
+ const { StdioClientTransport } = await import('@modelcontextprotocol/sdk/client/stdio.js');
53
+ const client = new Client({ name: 'remcp-doctor', version: VERSION });
54
+ const stdio = new StdioClientTransport({ command: process.execPath, args: [entry], env: { ...process.env }, maxBufferSize: 4 * 1024 * 1024 });
55
+ const stderr = [];
56
+ stdio.onerror = error => stderr.push(String(error?.message || error));
57
+ await client.connect(stdio);
58
+ diagnosis.runtimeVersion = client.getServerVersion()?.version || 'unknown';
59
+ const tools = await client.listTools(undefined, { timeout: 20000 });
60
+ diagnosis.tools = tools.tools.length;
61
+ diagnosis.verdict = 'ok';
62
+ await client.close();
63
+ return diagnosis;
64
+ } catch (error) {
65
+ diagnosis.verdict = 'runtime-handshake-failed';
66
+ diagnosis.error = error instanceof Error ? error.message : String(error);
67
+ diagnosis.hint = 'Run the entry above by hand to see its output, then reinstall with: npx --yes @remcp/remcp@latest update';
68
+ return diagnosis;
69
+ }
70
+ }
71
+
72
+ // Reads the version a freshly installed global package reports, so an update that installed
73
+ // nothing (wrong prefix, npm cache, permissions) is reported instead of assumed successful.
74
+ // The roots the runtime is allowed to work in, as the person configured them. An empty list means
75
+ // "the whole file system", so the doctor probes the home directory instead of guessing a root.
76
+ export function runtimeAllowedRoots() {
77
+ try {
78
+ const configured = JSON.parse(fs.readFileSync(runtimeConfigFile, 'utf8')).allowedRoots;
79
+ if (Array.isArray(configured) && configured.length) return configured.map(root => String(root).replace(/^~/, os.homedir()));
80
+ } catch {}
81
+ return [os.homedir()];
82
+ }
83
+
84
+ export function installedVersion(packageName) {
85
+ const prefix = spawnSync(npm.command, [...npm.args, 'prefix', '--global'], { encoding: 'utf8' });
86
+ if (prefix.error || prefix.status !== 0) return null;
87
+ const manifest = path.join(String(prefix.stdout || '').trim(), 'lib', 'node_modules', ...packageName.split('/'), 'package.json');
88
+ try { return JSON.parse(fs.readFileSync(manifest, 'utf8')).version || null; } catch { return null; }
89
+ }
@@ -0,0 +1,21 @@
1
+ // Where the CLI keeps its state and which npm it drives. Everything here is resolved once, at import
2
+ // time, so a background service and an interactive shell agree on the same paths.
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import process from 'node:process';
6
+ import { resolveNpm } from '../npm.mjs';
7
+
8
+ export const home = os.homedir();
9
+ export const configDir = process.env.REMCP_CONFIG_DIR || path.join(home, '.config', 'remcp');
10
+ export const configFile = path.join(configDir, 'config.json');
11
+ export const runtimeConfigFile = path.join(configDir, 'runtime.json');
12
+ export const machineIdFile = path.join(configDir, 'machine-id');
13
+ export const linuxServiceFile = path.join(home, '.config', 'systemd', 'user', 'remcp-agent.service');
14
+ export const macServiceLabel = 'com.remcp.agent';
15
+ export const macServiceFile = path.join(home, 'Library', 'LaunchAgents', `${macServiceLabel}.plist`);
16
+ export const macLogFile = path.join(home, 'Library', 'Logs', 'remcp-agent.log');
17
+ export const windowsTaskName = 'ReMCP Agent';
18
+ // How npm is invoked is resolved from the running node when possible: a background service has a
19
+ // minimal PATH, which is why auto-update used to find no npm on macOS. See src/npm.mjs.
20
+ export const npm = resolveNpm();
21
+ export const officialOrigin = 'https://remcp.site';
@@ -0,0 +1,214 @@
1
+ // Installing, repairing and removing the background agent: one supervisor per platform, plus the
2
+ // write-access tweaks a fresh install needs. Nothing here is reachable from a model or an MCP tool.
3
+ import fs from 'node:fs';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+ import process from 'node:process';
7
+ import { spawnSync } from 'node:child_process';
8
+ import { PACKAGE_NAME, VERSION } from '../version.mjs';
9
+
10
+ import { saveConfig } from './config.mjs';
11
+ import { home, linuxServiceFile, macLogFile, macServiceFile, macServiceLabel, npm, windowsTaskName } from './env.mjs';
12
+ import { output, run } from './shell.mjs';
13
+
14
+ export function servicePlatform() {
15
+ return process.env.NODE_ENV === 'test' && process.env.REMCP_TEST_PLATFORM ? process.env.REMCP_TEST_PLATFORM : process.platform;
16
+ }
17
+
18
+ export function globalPrefix() {
19
+ return output(npm.command, [...npm.args, 'prefix', '--global']);
20
+ }
21
+
22
+ export function globalCliPath() {
23
+ const prefix = globalPrefix();
24
+ return servicePlatform() === 'win32' ? path.join(prefix, 'remcp.cmd') : path.join(prefix, 'bin', 'remcp');
25
+ }
26
+
27
+ export function npmGlobalInstall(...specs) {
28
+ run(npm.command, [...npm.args, 'install', '--global', ...specs, '--no-audit', '--no-fund', '--loglevel=error']);
29
+ }
30
+
31
+ export function quoteSystemd(value) {
32
+ return `"${String(value).replaceAll('\\', '\\\\').replaceAll('"', '\\"')}"`;
33
+ }
34
+
35
+ export function xmlEscape(value) {
36
+ return String(value).replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;').replaceAll('"', '&quot;').replaceAll("'", '&apos;');
37
+ }
38
+
39
+ export function installLinuxService(cliPath = globalCliPath()) {
40
+ const unit = `[Unit]\nDescription=ReMCP device agent\nAfter=network-online.target\nWants=network-online.target\n\n[Service]\nType=simple\nExecStart=${quoteSystemd(cliPath)} start\nRestart=always\nRestartSec=3\nNoNewPrivileges=true\n\n[Install]\nWantedBy=default.target\n`;
41
+ fs.mkdirSync(path.dirname(linuxServiceFile), { recursive: true });
42
+ fs.writeFileSync(linuxServiceFile, unit);
43
+ run('systemctl', ['--user', 'daemon-reload']);
44
+ run('systemctl', ['--user', 'enable', '--now', 'remcp-agent.service']);
45
+ }
46
+
47
+ export function macLaunchDomain() {
48
+ if (typeof process.getuid !== 'function') throw new Error('Could not determine the current macOS user');
49
+ return `gui/${process.getuid()}`;
50
+ }
51
+
52
+ export function installMacService(cliPath = globalCliPath()) {
53
+ const domain = macLaunchDomain();
54
+ const target = `${domain}/${macServiceLabel}`;
55
+ // launchd wants an absolute path; a symlinked prefix that npm has not materialised yet (or a path
56
+ // that is about to be replaced by the next install) must not abort the repair — a stale plist is
57
+ // exactly the loop this function exists to break.
58
+ const cliScript = fs.existsSync(cliPath) ? fs.realpathSync(cliPath) : path.resolve(cliPath);
59
+ fs.mkdirSync(path.dirname(macServiceFile), { recursive: true });
60
+ fs.mkdirSync(path.dirname(macLogFile), { recursive: true });
61
+ const plist = `<?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(process.execPath)}</string><string>${xmlEscape(cliScript)}</string><string>start</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
+ fs.writeFileSync(macServiceFile, plist, { mode: 0o600 });
63
+ spawnSync('launchctl', ['bootout', domain, macServiceFile], { stdio: 'ignore' });
64
+ run('launchctl', ['bootstrap', domain, macServiceFile]);
65
+ run('launchctl', ['enable', target]);
66
+ run('launchctl', ['kickstart', '-k', target]);
67
+ }
68
+
69
+ export function installWindowsService(cliPath = globalCliPath()) {
70
+ const command = `"${cliPath}" start`;
71
+ run('schtasks.exe', ['/Create', '/TN', windowsTaskName, '/TR', command, '/SC', 'ONLOGON', '/RL', 'HIGHEST', '/F']);
72
+ run('schtasks.exe', ['/Run', '/TN', windowsTaskName]);
73
+ }
74
+
75
+ export function configurePostInstallAccess() {
76
+ const platform = servicePlatform();
77
+ try {
78
+ if (platform === 'darwin') configureMacWriteAccess();
79
+ else if (platform === 'win32') configureWindowsWriteAccess();
80
+ else configureLinuxWriteAccess();
81
+ } catch (error) {
82
+ console.error(`Could not configure write access: ${error instanceof Error ? error.message : String(error)}`);
83
+ }
84
+ }
85
+
86
+ export function configureMacWriteAccess() {
87
+ const appName = path.basename(process.execPath);
88
+ const terminalApp = path.basename(process.env.SHELL || '/bin/zsh');
89
+ const workspaceDir = path.join(home, 'Library', 'Application Support', 'ReMCP');
90
+ try { fs.mkdirSync(workspaceDir, { recursive: true }); } catch {}
91
+ try { run('chmod', ['-R', '755', workspaceDir]); } catch {}
92
+ try {
93
+ const script = `tell application "System Preferences" to activate\ndelay 1\ntell application "System Events" to click UI element "Privacy" of toolbar 1 of window "Security & Privacy" of process "System Preferences"\ndelay 1\ntell application "System Events" to click row 4 of table 1 of scroll area 1 of window "Privacy" of application process "System Preferences"\ndelay 1\n`;
94
+ spawnSync('osascript', ['-e', script], { stdio: 'ignore' });
95
+ } catch {}
96
+ console.log('Note: For full Desktop/Documents access on macOS, go to System Settings → Privacy & Security → Full Disk Access and add ReMCP or your Terminal app.');
97
+ }
98
+
99
+ export function configureWindowsWriteAccess() {
100
+ try { run('schtasks.exe', ['/Change', '/TN', windowsTaskName, '/RL', 'HIGHEST', '/IT']); } catch {}
101
+ try {
102
+ const workspaceDir = path.join(process.env.LOCALAPPDATA || path.join(home, 'AppData', 'Local'), 'ReMCP');
103
+ fs.mkdirSync(workspaceDir, { recursive: true });
104
+ } catch {}
105
+ console.log('ReMCP configured with elevated privileges for full write access.');
106
+ }
107
+
108
+ export function configureLinuxWriteAccess() {
109
+ const dirs = [path.join(home, 'Desktop'), path.join(home, 'Documents'), path.join(home, 'Downloads')];
110
+ for (const dir of dirs) {
111
+ try {
112
+ if (fs.existsSync(dir)) run('chown', [`${os.userInfo().username}:${os.userInfo().gid}`, dir]);
113
+ } catch {}
114
+ }
115
+ try {
116
+ const workspaceDir = path.join(home, '.local', 'share', 'ReMCP');
117
+ fs.mkdirSync(workspaceDir, { recursive: true });
118
+ } catch {}
119
+ }
120
+
121
+ export function installPersistentAgent(config) {
122
+ const platform = servicePlatform();
123
+ if (!['linux', 'darwin', 'win32'].includes(platform)) throw new Error(`Automatic background service installation is not supported on ${platform}`);
124
+ console.log(`Installing ReMCP ${VERSION}…`);
125
+ npmGlobalInstall(`${PACKAGE_NAME}@${VERSION}`, config.runtime.packageSpec);
126
+ const cliPath = globalCliPath();
127
+ if (platform === 'linux') installLinuxService(cliPath);
128
+ else if (platform === 'darwin') installMacService(cliPath);
129
+ else installWindowsService(cliPath);
130
+ configurePostInstallAccess();
131
+ saveConfig({ ...config, serviceInstalled: true });
132
+ console.log('ReMCP is installed as a background service. Future updates: remcp update');
133
+ }
134
+
135
+ // A machine that was installed as a service must still be one after an update: if the job is missing
136
+ // (a failed install, a cleaned LaunchAgents directory, a re-imaged user), the next update recreates
137
+ // it instead of leaving a hand-over to a process nobody supervises.
138
+ export function ensureServiceIfRecorded(config) {
139
+ if (config?.serviceInstalled !== true) return false;
140
+ try {
141
+ const cliPath = globalCliPath();
142
+ const platform = servicePlatform();
143
+ if (platform === 'linux') {
144
+ if (!fs.existsSync(linuxServiceFile)) {
145
+ installLinuxService(cliPath);
146
+ } else {
147
+ // Node managers can move the global npm prefix between updates (nvm -> Hermes was observed in
148
+ // production). An existing systemd unit then keeps launching the old CLI forever even though
149
+ // npm successfully installed the new one. Repair the launcher in place before restarting it.
150
+ const unit = fs.readFileSync(linuxServiceFile, 'utf8');
151
+ const expected = `ExecStart=${quoteSystemd(cliPath)} start`;
152
+ if (!unit.includes(expected)) {
153
+ const repaired = /^ExecStart=/m.test(unit) ? unit.replace(/^ExecStart=.*$/m, expected) : '';
154
+ if (!repaired) {
155
+ // A unit that lost its ExecStart line (edited by hand, or written as `ExecStart = …`) cannot
156
+ // be patched by substitution. Rewriting the whole unit is what keeps the machine out of the
157
+ // "old CLI forever" loop, so fall back to a fresh install instead of giving up.
158
+ installLinuxService(cliPath);
159
+ } else {
160
+ fs.writeFileSync(linuxServiceFile, repaired);
161
+ run('systemctl', ['--user', 'daemon-reload']);
162
+ }
163
+ }
164
+ }
165
+ } else if (platform === 'darwin') {
166
+ // launchd bakes the interpreter and the CLI path into the plist, so a Node manager that moves
167
+ // its global prefix leaves the agent launching a file that no longer exists — the same loop the
168
+ // Linux unit above is repaired for. Reinstalling is idempotent (bootout, bootstrap, enable,
169
+ // kickstart) and is what the Windows task already does on every update.
170
+ installMacService(cliPath);
171
+ } else if (platform === 'win32') installWindowsService(cliPath);
172
+ return true;
173
+ } catch (error) {
174
+ console.error(`Could not ensure the background service: ${error instanceof Error ? error.message : String(error)}`);
175
+ return false;
176
+ }
177
+ }
178
+
179
+ export function restartPersistentServiceIfInstalled() {
180
+ const platform = servicePlatform();
181
+ if (platform === 'linux' && fs.existsSync(linuxServiceFile)) {
182
+ run('systemctl', ['--user', 'daemon-reload']);
183
+ run('systemctl', ['--user', 'restart', 'remcp-agent.service']);
184
+ return 'remcp-agent.service';
185
+ }
186
+ if (platform === 'darwin' && fs.existsSync(macServiceFile)) {
187
+ run('launchctl', ['kickstart', '-k', `${macLaunchDomain()}/${macServiceLabel}`]);
188
+ return macServiceLabel;
189
+ }
190
+ if (platform === 'win32') {
191
+ const result = spawnSync('schtasks.exe', ['/Query', '/TN', windowsTaskName], { stdio: 'ignore' });
192
+ if (result.status === 0) {
193
+ run('schtasks.exe', ['/Run', '/TN', windowsTaskName]);
194
+ return windowsTaskName;
195
+ }
196
+ }
197
+ return null;
198
+ }
199
+
200
+ export function uninstallPersistentService() {
201
+ const platform = servicePlatform();
202
+ if (platform === 'linux') {
203
+ spawnSync('systemctl', ['--user', 'disable', '--now', 'remcp-agent.service'], { stdio: 'inherit' });
204
+ try { fs.unlinkSync(linuxServiceFile); } catch {}
205
+ spawnSync('systemctl', ['--user', 'daemon-reload'], { stdio: 'inherit' });
206
+ } else if (platform === 'darwin') {
207
+ const domain = macLaunchDomain();
208
+ spawnSync('launchctl', ['bootout', domain, macServiceFile], { stdio: 'ignore' });
209
+ try { fs.unlinkSync(macServiceFile); } catch {}
210
+ } else if (platform === 'win32') {
211
+ spawnSync('schtasks.exe', ['/End', '/TN', windowsTaskName], { stdio: 'ignore' });
212
+ spawnSync('schtasks.exe', ['/Delete', '/TN', windowsTaskName, '/F'], { stdio: 'ignore' });
213
+ }
214
+ }
@@ -0,0 +1,20 @@
1
+ // The two ways this CLI runs another program: streaming straight to the terminal, or captured for a
2
+ // value it has to read back. Sleep lives here so pairing can wait without its own timer.
3
+ import { spawnSync } from 'node:child_process';
4
+
5
+ export function run(command, args, options = {}) {
6
+ const result = spawnSync(command, args, { stdio: 'inherit', ...options });
7
+ if (result.error) throw result.error;
8
+ if (result.status !== 0) throw new Error(`${command} failed with exit code ${result.status}`);
9
+ return result;
10
+ }
11
+
12
+ export function output(command, args) {
13
+ const result = spawnSync(command, args, { encoding: 'utf8' });
14
+ if (result.error) throw result.error;
15
+ if (result.status !== 0) throw new Error(`${command} failed with exit code ${result.status}`);
16
+ return String(result.stdout || '').trim();
17
+ }
18
+
19
+ export const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
20
+
package/src/cli.mjs CHANGED
@@ -2,29 +2,17 @@ import fs from 'node:fs';
2
2
  import os from 'node:os';
3
3
  import path from 'node:path';
4
4
  import process from 'node:process';
5
- import { spawn, spawnSync } from 'node:child_process';
6
- import { randomUUID } from 'node:crypto';
7
- import { localRuntimeEntry, runAgent, supervisorRestart } from './agent.mjs';
8
- import { npmVersion, resolveNpm } from './npm.mjs';
5
+ import { runAgent, supervisorRestart } from './agent.mjs';
9
6
  import { isRuntimeSpecFor, normalizeRuntime } from './runtime.mjs';
10
7
  import { PACKAGE_NAME, VERSION } from './version.mjs';
11
8
  import { probeFilesystemAccess } from './fs-access.mjs';
12
9
 
13
- const home = os.homedir();
14
- const configDir = process.env.REMCP_CONFIG_DIR || path.join(home, '.config', 'remcp');
15
- const configFile = path.join(configDir, 'config.json');
16
- const runtimeConfigFile = path.join(configDir, 'runtime.json');
17
- const machineIdFile = path.join(configDir, 'machine-id');
18
- const linuxServiceFile = path.join(home, '.config', 'systemd', 'user', 'remcp-agent.service');
19
- const macServiceLabel = 'com.remcp.agent';
20
- const macServiceFile = path.join(home, 'Library', 'LaunchAgents', `${macServiceLabel}.plist`);
21
- const macLogFile = path.join(home, 'Library', 'Logs', 'remcp-agent.log');
22
- const windowsTaskName = 'ReMCP Agent';
23
- // How npm is invoked is resolved from the running node when possible: a background service has a
24
- // minimal PATH, which is why auto-update used to find no npm on macOS. See src/npm.mjs.
25
- const npm = resolveNpm();
26
- const officialOrigin = 'https://remcp.site';
27
-
10
+ import { ensureMachineId, loadConfig, readJsonFile, saveConfig, setTelemetry, telemetryState, writeJsonFile } from './cli/config.mjs';
11
+ import { assertRuntimeTrust, pairWithDeviceCode } from './cli/connect.mjs';
12
+ import { diagnoseLocalRuntime, installedVersion, runtimeAllowedRoots } from './cli/doctor.mjs';
13
+ import { configFile, linuxServiceFile, macServiceFile, npm, officialOrigin, runtimeConfigFile } from './cli/env.mjs';
14
+ import { ensureServiceIfRecorded, globalCliPath, installPersistentAgent, npmGlobalInstall, restartPersistentServiceIfInstalled, uninstallPersistentService } from './cli/service.mjs';
15
+ import { run } from './cli/shell.mjs';
28
16
  function parse(argv) {
29
17
  const [command = 'help', ...rest] = argv;
30
18
  const flags = {};
@@ -37,455 +25,6 @@ function parse(argv) {
37
25
  return { command, flags, positional };
38
26
  }
39
27
 
40
- function run(command, args, options = {}) {
41
- const result = spawnSync(command, args, { stdio: 'inherit', ...options });
42
- if (result.error) throw result.error;
43
- if (result.status !== 0) throw new Error(`${command} failed with exit code ${result.status}`);
44
- return result;
45
- }
46
-
47
- function output(command, args) {
48
- const result = spawnSync(command, args, { encoding: 'utf8' });
49
- if (result.error) throw result.error;
50
- if (result.status !== 0) throw new Error(`${command} failed with exit code ${result.status}`);
51
- return String(result.stdout || '').trim();
52
- }
53
-
54
- function loadConfig(required = true) {
55
- if (!fs.existsSync(configFile)) {
56
- if (!required) return undefined;
57
- throw new Error(`ReMCP is not paired. Generate a pairing command at ${officialOrigin}/app/connect`);
58
- }
59
- const value = JSON.parse(fs.readFileSync(configFile, 'utf8'));
60
- // A configuration that only carries preferences (for example after `remcp auto-update off`
61
- // before pairing) has no runtime yet; it must not fail as if it were corrupt.
62
- if (value.runtime !== undefined) value.runtime = normalizeRuntime(value.runtime);
63
- return value;
64
- }
65
-
66
- function saveConfig(value) {
67
- fs.mkdirSync(configDir, { recursive: true, mode: 0o700 });
68
- fs.writeFileSync(configFile, JSON.stringify(value, null, 2) + '\n', { mode: 0o600 });
69
- fs.chmodSync(configFile, 0o600);
70
- }
71
-
72
- function readJsonFile(file) {
73
- try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return {}; }
74
- }
75
-
76
- function writeJsonFile(file, value) {
77
- fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
78
- fs.writeFileSync(file, JSON.stringify(value, null, 2) + '\n', { mode: 0o600 });
79
- fs.chmodSync(file, 0o600);
80
- }
81
-
82
- function flagEnabled(value) {
83
- return value === undefined || value === null ? true : value !== false;
84
- }
85
-
86
- // One switch for the whole machine: the client and the runtime it spawns must agree,
87
- // otherwise the runtime would keep reporting after the user opted out.
88
- function telemetryState() {
89
- const client = readJsonFile(configFile);
90
- const runtime = readJsonFile(runtimeConfigFile);
91
- const clientEnabled = flagEnabled(client.telemetryEnabled);
92
- const runtimeEnabled = flagEnabled(runtime.telemetryEnabled);
93
- return {
94
- enabled: clientEnabled && runtimeEnabled,
95
- clientEnabled,
96
- runtimeEnabled,
97
- installReported: client.installReported === true,
98
- configFile,
99
- runtimeConfigFile,
100
- transport: 'paired-agent-only',
101
- endpoint: null,
102
- collects: 'tool names, durations, outcomes, error classes, and device health samples',
103
- neverCollects: 'file paths, file contents, command strings, tool arguments, and tool output',
104
- thirdParty: false,
105
- installPing: false,
106
- remoteFeatureFlags: false,
107
- };
108
- }
109
-
110
- function setTelemetry(enabled) {
111
- const client = readJsonFile(configFile);
112
- client.telemetryEnabled = enabled;
113
- writeJsonFile(configFile, client);
114
- const runtime = readJsonFile(runtimeConfigFile);
115
- runtime.telemetryEnabled = enabled;
116
- writeJsonFile(runtimeConfigFile, runtime);
117
- return telemetryState();
118
- }
119
-
120
- function ensureMachineId() {
121
- fs.mkdirSync(configDir, { recursive: true, mode: 0o700 });
122
- try {
123
- const existing = fs.readFileSync(machineIdFile, 'utf8').trim();
124
- if (/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(existing)) return existing;
125
- } catch {}
126
- const value = randomUUID();
127
- fs.writeFileSync(machineIdFile, value + '\n', { mode: 0o600 });
128
- fs.chmodSync(machineIdFile, 0o600);
129
- return value;
130
- }
131
-
132
- function servicePlatform() {
133
- return process.env.NODE_ENV === 'test' && process.env.REMCP_TEST_PLATFORM ? process.env.REMCP_TEST_PLATFORM : process.platform;
134
- }
135
-
136
- function globalPrefix() {
137
- return output(npm.command, [...npm.args, 'prefix', '--global']);
138
- }
139
-
140
- function globalCliPath() {
141
- const prefix = globalPrefix();
142
- return servicePlatform() === 'win32' ? path.join(prefix, 'remcp.cmd') : path.join(prefix, 'bin', 'remcp');
143
- }
144
-
145
- function npmGlobalInstall(...specs) {
146
- run(npm.command, [...npm.args, 'install', '--global', ...specs, '--no-audit', '--no-fund', '--loglevel=error']);
147
- }
148
-
149
- function quoteSystemd(value) {
150
- return `"${String(value).replaceAll('\\', '\\\\').replaceAll('"', '\\"')}"`;
151
- }
152
-
153
- function xmlEscape(value) {
154
- return String(value).replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;').replaceAll('"', '&quot;').replaceAll("'", '&apos;');
155
- }
156
-
157
- function installLinuxService(cliPath = globalCliPath()) {
158
- const unit = `[Unit]\nDescription=ReMCP device agent\nAfter=network-online.target\nWants=network-online.target\n\n[Service]\nType=simple\nExecStart=${quoteSystemd(cliPath)} start\nRestart=always\nRestartSec=3\nNoNewPrivileges=true\n\n[Install]\nWantedBy=default.target\n`;
159
- fs.mkdirSync(path.dirname(linuxServiceFile), { recursive: true });
160
- fs.writeFileSync(linuxServiceFile, unit);
161
- run('systemctl', ['--user', 'daemon-reload']);
162
- run('systemctl', ['--user', 'enable', '--now', 'remcp-agent.service']);
163
- }
164
-
165
- function macLaunchDomain() {
166
- if (typeof process.getuid !== 'function') throw new Error('Could not determine the current macOS user');
167
- return `gui/${process.getuid()}`;
168
- }
169
-
170
- function installMacService(cliPath = globalCliPath()) {
171
- const domain = macLaunchDomain();
172
- const target = `${domain}/${macServiceLabel}`;
173
- // launchd wants an absolute path; a symlinked prefix that npm has not materialised yet (or a path
174
- // that is about to be replaced by the next install) must not abort the repair — a stale plist is
175
- // exactly the loop this function exists to break.
176
- const cliScript = fs.existsSync(cliPath) ? fs.realpathSync(cliPath) : path.resolve(cliPath);
177
- fs.mkdirSync(path.dirname(macServiceFile), { recursive: true });
178
- fs.mkdirSync(path.dirname(macLogFile), { recursive: true });
179
- const plist = `<?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(process.execPath)}</string><string>${xmlEscape(cliScript)}</string><string>start</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`;
180
- fs.writeFileSync(macServiceFile, plist, { mode: 0o600 });
181
- spawnSync('launchctl', ['bootout', domain, macServiceFile], { stdio: 'ignore' });
182
- run('launchctl', ['bootstrap', domain, macServiceFile]);
183
- run('launchctl', ['enable', target]);
184
- run('launchctl', ['kickstart', '-k', target]);
185
- }
186
-
187
- function installWindowsService(cliPath = globalCliPath()) {
188
- const command = `"${cliPath}" start`;
189
- run('schtasks.exe', ['/Create', '/TN', windowsTaskName, '/TR', command, '/SC', 'ONLOGON', '/RL', 'HIGHEST', '/F']);
190
- run('schtasks.exe', ['/Run', '/TN', windowsTaskName]);
191
- }
192
-
193
- function configurePostInstallAccess() {
194
- const platform = servicePlatform();
195
- try {
196
- if (platform === 'darwin') configureMacWriteAccess();
197
- else if (platform === 'win32') configureWindowsWriteAccess();
198
- else configureLinuxWriteAccess();
199
- } catch (error) {
200
- console.error(`Could not configure write access: ${error instanceof Error ? error.message : String(error)}`);
201
- }
202
- }
203
-
204
- function configureMacWriteAccess() {
205
- const appName = path.basename(process.execPath);
206
- const terminalApp = path.basename(process.env.SHELL || '/bin/zsh');
207
- const workspaceDir = path.join(home, 'Library', 'Application Support', 'ReMCP');
208
- try { fs.mkdirSync(workspaceDir, { recursive: true }); } catch {}
209
- try { run('chmod', ['-R', '755', workspaceDir]); } catch {}
210
- try {
211
- const script = `tell application "System Preferences" to activate\ndelay 1\ntell application "System Events" to click UI element "Privacy" of toolbar 1 of window "Security & Privacy" of process "System Preferences"\ndelay 1\ntell application "System Events" to click row 4 of table 1 of scroll area 1 of window "Privacy" of application process "System Preferences"\ndelay 1\n`;
212
- spawnSync('osascript', ['-e', script], { stdio: 'ignore' });
213
- } catch {}
214
- console.log('Note: For full Desktop/Documents access on macOS, go to System Settings → Privacy & Security → Full Disk Access and add ReMCP or your Terminal app.');
215
- }
216
-
217
- function configureWindowsWriteAccess() {
218
- try { run('schtasks.exe', ['/Change', '/TN', windowsTaskName, '/RL', 'HIGHEST', '/IT']); } catch {}
219
- try {
220
- const workspaceDir = path.join(process.env.LOCALAPPDATA || path.join(home, 'AppData', 'Local'), 'ReMCP');
221
- fs.mkdirSync(workspaceDir, { recursive: true });
222
- } catch {}
223
- console.log('ReMCP configured with elevated privileges for full write access.');
224
- }
225
-
226
- function configureLinuxWriteAccess() {
227
- const dirs = [path.join(home, 'Desktop'), path.join(home, 'Documents'), path.join(home, 'Downloads')];
228
- for (const dir of dirs) {
229
- try {
230
- if (fs.existsSync(dir)) run('chown', [`${os.userInfo().username}:${os.userInfo().gid}`, dir]);
231
- } catch {}
232
- }
233
- try {
234
- const workspaceDir = path.join(home, '.local', 'share', 'ReMCP');
235
- fs.mkdirSync(workspaceDir, { recursive: true });
236
- } catch {}
237
- }
238
-
239
- function installPersistentAgent(config) {
240
- const platform = servicePlatform();
241
- if (!['linux', 'darwin', 'win32'].includes(platform)) throw new Error(`Automatic background service installation is not supported on ${platform}`);
242
- console.log(`Installing ReMCP ${VERSION}…`);
243
- npmGlobalInstall(`${PACKAGE_NAME}@${VERSION}`, config.runtime.packageSpec);
244
- const cliPath = globalCliPath();
245
- if (platform === 'linux') installLinuxService(cliPath);
246
- else if (platform === 'darwin') installMacService(cliPath);
247
- else installWindowsService(cliPath);
248
- configurePostInstallAccess();
249
- saveConfig({ ...config, serviceInstalled: true });
250
- console.log('ReMCP is installed as a background service. Future updates: remcp update');
251
- }
252
-
253
- // A machine that was installed as a service must still be one after an update: if the job is missing
254
- // (a failed install, a cleaned LaunchAgents directory, a re-imaged user), the next update recreates
255
- // it instead of leaving a hand-over to a process nobody supervises.
256
- function ensureServiceIfRecorded(config) {
257
- if (config?.serviceInstalled !== true) return false;
258
- try {
259
- const cliPath = globalCliPath();
260
- const platform = servicePlatform();
261
- if (platform === 'linux') {
262
- if (!fs.existsSync(linuxServiceFile)) {
263
- installLinuxService(cliPath);
264
- } else {
265
- // Node managers can move the global npm prefix between updates (nvm -> Hermes was observed in
266
- // production). An existing systemd unit then keeps launching the old CLI forever even though
267
- // npm successfully installed the new one. Repair the launcher in place before restarting it.
268
- const unit = fs.readFileSync(linuxServiceFile, 'utf8');
269
- const expected = `ExecStart=${quoteSystemd(cliPath)} start`;
270
- if (!unit.includes(expected)) {
271
- const repaired = /^ExecStart=/m.test(unit) ? unit.replace(/^ExecStart=.*$/m, expected) : '';
272
- if (!repaired) {
273
- // A unit that lost its ExecStart line (edited by hand, or written as `ExecStart = …`) cannot
274
- // be patched by substitution. Rewriting the whole unit is what keeps the machine out of the
275
- // "old CLI forever" loop, so fall back to a fresh install instead of giving up.
276
- installLinuxService(cliPath);
277
- } else {
278
- fs.writeFileSync(linuxServiceFile, repaired);
279
- run('systemctl', ['--user', 'daemon-reload']);
280
- }
281
- }
282
- }
283
- } else if (platform === 'darwin') {
284
- // launchd bakes the interpreter and the CLI path into the plist, so a Node manager that moves
285
- // its global prefix leaves the agent launching a file that no longer exists — the same loop the
286
- // Linux unit above is repaired for. Reinstalling is idempotent (bootout, bootstrap, enable,
287
- // kickstart) and is what the Windows task already does on every update.
288
- installMacService(cliPath);
289
- } else if (platform === 'win32') installWindowsService(cliPath);
290
- return true;
291
- } catch (error) {
292
- console.error(`Could not ensure the background service: ${error instanceof Error ? error.message : String(error)}`);
293
- return false;
294
- }
295
- }
296
-
297
- // Opens the approval page in the person's browser. A machine that nobody is looking at only gets the
298
- // printed URL, so every failure here is silent and non-fatal.
299
- function openInBrowser(url) {
300
- try {
301
- const command = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'cmd' : 'xdg-open';
302
- const args = process.platform === 'win32' ? ['/c', 'start', '', url] : [url];
303
- const child = spawn(command, args, { stdio: 'ignore', detached: true });
304
- child.on('error', () => {});
305
- child.unref();
306
- } catch {}
307
- }
308
-
309
- const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
310
-
311
- // Device authorization (RFC 8628): the computer asks for a code, the person approves it in the
312
- // browser while signed in, and this process collects the credential by polling. The device never
313
- // sees a browser session or an account password.
314
- async function pairWithDeviceCode(server, flags) {
315
- const authorization = await fetch(`${server}/oauth/device_authorization`, {
316
- method: 'POST',
317
- headers: { 'content-type': 'application/json' },
318
- body: JSON.stringify({
319
- name: String(flags.name || os.hostname()),
320
- hostname: os.hostname(),
321
- platform: process.platform,
322
- arch: process.arch,
323
- machineId: ensureMachineId(),
324
- }),
325
- });
326
- if (!authorization.ok) throw new Error(`Pairing failed (${authorization.status}): ${await authorization.text()}`);
327
- const grant = await authorization.json();
328
- const approvalUrl = grant.verification_uri_complete || grant.verification_uri;
329
- console.log(`Approve this computer in your browser: ${approvalUrl}`);
330
- console.log(`Pairing code: ${grant.user_code} (expires in ${Math.max(1, Math.round(Number(grant.expires_in || 600) / 60))} minutes)`);
331
- openInBrowser(approvalUrl);
332
- console.log('Waiting for approval… (Ctrl+C to cancel)');
333
- const deadline = Date.now() + (Number(grant.expires_in) || 600) * 1000;
334
- let lastReminder = Date.now();
335
- const intervalMs = Math.max(1, Number(grant.interval) || 5) * 1000;
336
- while (Date.now() < deadline) {
337
- await sleep(intervalMs);
338
- const response = await fetch(`${server}/oauth/token`, {
339
- method: 'POST',
340
- headers: { 'content-type': 'application/x-www-form-urlencoded' },
341
- body: new URLSearchParams({
342
- grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
343
- device_code: String(grant.device_code || ''),
344
- machineId: ensureMachineId(),
345
- }).toString(),
346
- });
347
- const data = await response.json().catch(() => ({}));
348
- if (response.ok && data.device_token) return data;
349
- if (data.error === 'authorization_pending' || data.error === 'slow_down') {
350
- if (Date.now() - lastReminder > 60_000) {
351
- lastReminder = Date.now();
352
- const left = Math.max(0, Math.round((deadline - Date.now()) / 60_000));
353
- console.log(`Still waiting — approve at ${approvalUrl} (about ${left} min left)`);
354
- }
355
- continue;
356
- }
357
- if (data.error === 'access_denied') throw new Error('That pairing request was denied in the browser. Run the command again if it was not you.');
358
- if (data.error === 'expired_token') break;
359
- throw new Error(`Pairing failed (${response.status}): ${JSON.stringify(data)}`);
360
- }
361
- throw new Error('The pairing code expired before it was approved. Run the command again.');
362
- }
363
-
364
- function restartPersistentServiceIfInstalled() {
365
- const platform = servicePlatform();
366
- if (platform === 'linux' && fs.existsSync(linuxServiceFile)) {
367
- run('systemctl', ['--user', 'daemon-reload']);
368
- run('systemctl', ['--user', 'restart', 'remcp-agent.service']);
369
- return 'remcp-agent.service';
370
- }
371
- if (platform === 'darwin' && fs.existsSync(macServiceFile)) {
372
- run('launchctl', ['kickstart', '-k', `${macLaunchDomain()}/${macServiceLabel}`]);
373
- return macServiceLabel;
374
- }
375
- if (platform === 'win32') {
376
- const result = spawnSync('schtasks.exe', ['/Query', '/TN', windowsTaskName], { stdio: 'ignore' });
377
- if (result.status === 0) {
378
- run('schtasks.exe', ['/Run', '/TN', windowsTaskName]);
379
- return windowsTaskName;
380
- }
381
- }
382
- return null;
383
- }
384
-
385
- // The agent the user installed with `remcp install` is the one this CLI manages. A machine can also
386
- // be supervised by its own systemd unit, by Docker, or by a terminal, and in those cases installing
387
- // a new version is not enough: the running process keeps the old code until something restarts it.
388
- // `supervisorRestart` (agent.mjs) answers which of those is true, and only reports a service manager
389
- // when it really owns this process: a terminal gets an explicit instruction instead of a silent exit
390
- // that would take the device offline.
391
- // One real handshake with the local runtime, plus everything needed to explain a failure: where the
392
- // entry resolved, whether the package is installed, the node that would run it, and the exact error.
393
- async function diagnoseLocalRuntime(cfg) {
394
- const packageName = cfg.runtime?.packageName || '';
395
- const diagnosis = {
396
- platform: `${process.platform} ${process.arch}`,
397
- node: process.execPath,
398
- nodeVersion: process.versions.node,
399
- packageName,
400
- packageSpec: cfg.runtime?.packageSpec || '',
401
- installedRuntime: installedVersion(packageName),
402
- installedClient: installedVersion(PACKAGE_NAME),
403
- };
404
- const resolved = resolveNpm();
405
- const npmInfo = npmVersion(resolved);
406
- diagnosis.npm = npmInfo ? { version: npmInfo.version, source: npmInfo.source } : { error: `npm could not be executed (tried ${resolved.source})` };
407
- let entry = '';
408
- try {
409
- entry = localRuntimeEntry(cfg.runtime);
410
- diagnosis.entry = entry;
411
- diagnosis.entryExists = fs.existsSync(entry);
412
- } catch (error) {
413
- diagnosis.entry = null;
414
- diagnosis.entryExists = false;
415
- diagnosis.verdict = 'runtime-not-installed';
416
- diagnosis.error = error instanceof Error ? error.message : String(error);
417
- diagnosis.hint = `Reinstall with: npx --yes ${PACKAGE_NAME}@latest update`;
418
- return diagnosis;
419
- }
420
- if (!diagnosis.entryExists) {
421
- diagnosis.verdict = 'runtime-entry-missing';
422
- diagnosis.hint = `Reinstall with: npx --yes ${PACKAGE_NAME}@latest update`;
423
- return diagnosis;
424
- }
425
- try {
426
- const { Client } = await import('@modelcontextprotocol/sdk/client/index.js');
427
- const { StdioClientTransport } = await import('@modelcontextprotocol/sdk/client/stdio.js');
428
- const client = new Client({ name: 'remcp-doctor', version: VERSION });
429
- const stdio = new StdioClientTransport({ command: process.execPath, args: [entry], env: { ...process.env }, maxBufferSize: 4 * 1024 * 1024 });
430
- const stderr = [];
431
- stdio.onerror = error => stderr.push(String(error?.message || error));
432
- await client.connect(stdio);
433
- diagnosis.runtimeVersion = client.getServerVersion()?.version || 'unknown';
434
- const tools = await client.listTools(undefined, { timeout: 20000 });
435
- diagnosis.tools = tools.tools.length;
436
- diagnosis.verdict = 'ok';
437
- await client.close();
438
- return diagnosis;
439
- } catch (error) {
440
- diagnosis.verdict = 'runtime-handshake-failed';
441
- diagnosis.error = error instanceof Error ? error.message : String(error);
442
- diagnosis.hint = 'Run the entry above by hand to see its output, then reinstall with: npx --yes @remcp/remcp@latest update';
443
- return diagnosis;
444
- }
445
- }
446
-
447
- // Reads the version a freshly installed global package reports, so an update that installed
448
- // nothing (wrong prefix, npm cache, permissions) is reported instead of assumed successful.
449
- // The roots the runtime is allowed to work in, as the person configured them. An empty list means
450
- // "the whole file system", so the doctor probes the home directory instead of guessing a root.
451
- function runtimeAllowedRoots() {
452
- try {
453
- const configured = JSON.parse(fs.readFileSync(runtimeConfigFile, 'utf8')).allowedRoots;
454
- if (Array.isArray(configured) && configured.length) return configured.map(root => String(root).replace(/^~/, os.homedir()));
455
- } catch {}
456
- return [os.homedir()];
457
- }
458
-
459
- function installedVersion(packageName) {
460
- const prefix = spawnSync(npm.command, [...npm.args, 'prefix', '--global'], { encoding: 'utf8' });
461
- if (prefix.error || prefix.status !== 0) return null;
462
- const manifest = path.join(String(prefix.stdout || '').trim(), 'lib', 'node_modules', ...packageName.split('/'), 'package.json');
463
- try { return JSON.parse(fs.readFileSync(manifest, 'utf8')).version || null; } catch { return null; }
464
- }
465
-
466
- function uninstallPersistentService() {
467
- const platform = servicePlatform();
468
- if (platform === 'linux') {
469
- spawnSync('systemctl', ['--user', 'disable', '--now', 'remcp-agent.service'], { stdio: 'inherit' });
470
- try { fs.unlinkSync(linuxServiceFile); } catch {}
471
- spawnSync('systemctl', ['--user', 'daemon-reload'], { stdio: 'inherit' });
472
- } else if (platform === 'darwin') {
473
- const domain = macLaunchDomain();
474
- spawnSync('launchctl', ['bootout', domain, macServiceFile], { stdio: 'ignore' });
475
- try { fs.unlinkSync(macServiceFile); } catch {}
476
- } else if (platform === 'win32') {
477
- spawnSync('schtasks.exe', ['/End', '/TN', windowsTaskName], { stdio: 'ignore' });
478
- spawnSync('schtasks.exe', ['/Delete', '/TN', windowsTaskName, '/F'], { stdio: 'ignore' });
479
- }
480
- }
481
-
482
- function assertRuntimeTrust(server, flags) {
483
- const origin = new URL(server).origin;
484
- if (origin !== officialOrigin && !flags['trust-runtime']) {
485
- throw new Error('Custom servers can provide local runtime metadata. Re-run with --trust-runtime only if you trust that server.');
486
- }
487
- }
488
-
489
28
  function printHelp() {
490
29
  console.log(`ReMCP ${VERSION}\n\nCommands:\n remcp start\n remcp status\n remcp doctor\n remcp update\n remcp install\n remcp uninstall\n remcp uninstall --purge\n remcp telemetry [status|on|off]\n remcp godmode [status|on|off]\n remcp --version\n\nPairing commands are generated in the ReMCP workspace.\n\nUsage metrics are opt-out (tool names, timings, outcomes only, sent to your own ReMCP\naccount through the paired agent). Disable them at any time with: remcp telemetry off\n\nUnrestricted mode (remcp godmode on) lifts the access roots and the command guardrails for this\ncomputer only. It is deliberately not reachable from a model or an MCP tool.`);
491
30
  }
@@ -739,4 +278,4 @@ export async function main(argv = process.argv.slice(2)) {
739
278
  }
740
279
 
741
280
  printHelp();
742
- }
281
+ }