@remcp/remcp 0.2.18 → 0.2.20

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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/cli.mjs +99 -13
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remcp/remcp",
3
- "version": "0.2.18",
3
+ "version": "0.2.20",
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",
package/src/cli.mjs CHANGED
@@ -2,7 +2,7 @@ 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 { spawnSync } from 'node:child_process';
5
+ import { spawn, spawnSync } from 'node:child_process';
6
6
  import { randomUUID } from 'node:crypto';
7
7
  import { localRuntimeEntry, runAgent, supervisorRestart } from './agent.mjs';
8
8
  import { npmVersion, resolveNpm } from './npm.mjs';
@@ -217,6 +217,63 @@ function ensureServiceIfRecorded(config) {
217
217
  }
218
218
  }
219
219
 
220
+ // Opens the approval page in the person's browser. A machine that nobody is looking at only gets the
221
+ // printed URL, so every failure here is silent and non-fatal.
222
+ function openInBrowser(url) {
223
+ try {
224
+ const command = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'cmd' : 'xdg-open';
225
+ const args = process.platform === 'win32' ? ['/c', 'start', '', url] : [url];
226
+ const child = spawn(command, args, { stdio: 'ignore', detached: true });
227
+ child.on('error', () => {});
228
+ child.unref();
229
+ } catch {}
230
+ }
231
+
232
+ const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
233
+
234
+ // Device authorization (RFC 8628): the computer asks for a code, the person approves it in the
235
+ // browser while signed in, and this process collects the credential by polling. The device never
236
+ // sees a browser session or an account password.
237
+ async function pairWithDeviceCode(server, flags) {
238
+ const authorization = await fetch(`${server}/oauth/device_authorization`, {
239
+ method: 'POST',
240
+ headers: { 'content-type': 'application/json' },
241
+ body: JSON.stringify({
242
+ name: String(flags.name || os.hostname()),
243
+ hostname: os.hostname(),
244
+ platform: process.platform,
245
+ arch: process.arch,
246
+ machineId: ensureMachineId(),
247
+ }),
248
+ });
249
+ if (!authorization.ok) throw new Error(`Pairing failed (${authorization.status}): ${await authorization.text()}`);
250
+ const grant = await authorization.json();
251
+ const approvalUrl = grant.verification_uri_complete || grant.verification_uri;
252
+ console.log(`Approve this computer in your browser: ${approvalUrl}`);
253
+ console.log(`Pairing code: ${grant.user_code} (expires in ${Math.max(1, Math.round(Number(grant.expires_in || 600) / 60))} minutes)`);
254
+ openInBrowser(approvalUrl);
255
+ const deadline = Date.now() + (Number(grant.expires_in) || 600) * 1000;
256
+ const intervalMs = Math.max(1, Number(grant.interval) || 5) * 1000;
257
+ while (Date.now() < deadline) {
258
+ await sleep(intervalMs);
259
+ const response = await fetch(`${server}/oauth/token`, {
260
+ method: 'POST',
261
+ headers: { 'content-type': 'application/x-www-form-urlencoded' },
262
+ body: new URLSearchParams({
263
+ grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
264
+ device_code: String(grant.device_code || ''),
265
+ }).toString(),
266
+ });
267
+ const data = await response.json().catch(() => ({}));
268
+ if (response.ok && data.device_token) return data;
269
+ if (data.error === 'authorization_pending' || data.error === 'slow_down') continue;
270
+ 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.');
271
+ if (data.error === 'expired_token') break;
272
+ throw new Error(`Pairing failed (${response.status}): ${JSON.stringify(data)}`);
273
+ }
274
+ throw new Error('The pairing code expired before it was approved. Run the command again.');
275
+ }
276
+
220
277
  function restartPersistentServiceIfInstalled() {
221
278
  const platform = servicePlatform();
222
279
  if (platform === 'linux' && fs.existsSync(linuxServiceFile)) {
@@ -345,21 +402,30 @@ export async function main(argv = process.argv.slice(2)) {
345
402
  }
346
403
 
347
404
  if (command === 'connect') {
348
- const server = String(flags.server || '').replace(/\/$/, '');
405
+ // Like the desktop-app flow this mirrors: with no flags the command talks to the official
406
+ // server, prints a code, opens the browser to approve it, and pairs. `--code` keeps working for
407
+ // the workspace-generated command and for CI, and `--server` for self-hosted deployments.
408
+ const server = String(flags.server || officialOrigin).replace(/\/$/, '');
349
409
  const code = String(flags.code || '').replace(/\s+/g, '').toUpperCase();
350
- if (!server || !code) throw new Error('--server and --code are required');
351
410
  assertRuntimeTrust(server, flags);
352
- const response = await fetch(`${server}/api/pair/claim`, {
353
- method: 'POST',
354
- headers: { 'content-type': 'application/json' },
355
- body: JSON.stringify({ code, machineId: ensureMachineId(), name: String(flags.name || os.hostname()), hostname: os.hostname(), platform: process.platform, arch: process.arch }),
356
- });
357
- if (!response.ok) throw new Error(`Pairing failed (${response.status}): ${await response.text()}`);
358
- const paired = await response.json();
411
+ let paired;
412
+ let deviceInitiated = false;
413
+ if (code) {
414
+ const response = await fetch(`${server}/api/pair/claim`, {
415
+ method: 'POST',
416
+ headers: { 'content-type': 'application/json' },
417
+ body: JSON.stringify({ code, machineId: ensureMachineId(), name: String(flags.name || os.hostname()), hostname: os.hostname(), platform: process.platform, arch: process.arch }),
418
+ });
419
+ if (!response.ok) throw new Error(`Pairing failed (${response.status}): ${await response.text()}`);
420
+ paired = await response.json();
421
+ } else {
422
+ deviceInitiated = true;
423
+ paired = await pairWithDeviceCode(server, flags);
424
+ }
359
425
  const config = {
360
426
  serverUrl: server,
361
- deviceId: paired.deviceId,
362
- deviceToken: paired.deviceToken,
427
+ deviceId: paired.deviceId || paired.device_id,
428
+ deviceToken: paired.deviceToken || paired.device_token,
363
429
  deviceName: String(flags.name || os.hostname()),
364
430
  runtime: normalizeRuntime(paired.runtime),
365
431
  machineId: ensureMachineId(),
@@ -369,7 +435,27 @@ export async function main(argv = process.argv.slice(2)) {
369
435
  };
370
436
  saveConfig(config);
371
437
  console.log(`Paired ${os.hostname()} with ${server}`);
372
- if (flags.install) installPersistentAgent(config);
438
+ if (flags.install) {
439
+ installPersistentAgent(config);
440
+ return;
441
+ }
442
+ // Only the command that asked for its own code keeps running: someone who ran `remcp connect` on
443
+ // a fresh machine expects the connection to be live when the command finishes. A workspace code
444
+ // keeps its old meaning (pair, then `remcp start` or `--install`).
445
+ if (!deviceInitiated) return;
446
+ // Nobody supervises this machine yet, so the agent runs in this window: Ctrl+C disconnects it,
447
+ // which is the behaviour people expect from a command they just ran themselves.
448
+ console.log('ReMCP is connected. Keep this window open, or run `remcp install` for a background service. Press Ctrl+C to stop.');
449
+ const telemetry = telemetryState();
450
+ await runAgent({
451
+ ...config,
452
+ autoUpdate: config.autoUpdate !== false,
453
+ trustRuntime: config.trustRuntime === true,
454
+ telemetryEnabled: telemetry.enabled,
455
+ installReported: telemetry.installReported,
456
+ installSpec: `${PACKAGE_NAME}@${VERSION}`,
457
+ persistState: patch => saveConfig({ ...config, ...patch }),
458
+ });
373
459
  return;
374
460
  }
375
461