@stage-labs/metro 0.1.0-beta.32 → 0.1.0-beta.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/dist/cli.js CHANGED
@@ -16,9 +16,11 @@ const USAGE = `metro — the command line for your MCP connectors
16
16
 
17
17
  metro start <agent-id> [--detach]
18
18
  run that agent's stations on this machine
19
- metro serve [--port <n>]
19
+ metro serve [--port <n>] [--tunnel]
20
20
  run a daemon of your own: its agents and stations live in ~/.metro/agents
21
- here, and metro.box manages it through the link it prints
21
+ here, and metro.box manages it through the link it prints; --tunnel adds
22
+ a public https address through a Cloudflare quick tunnel (needs
23
+ cloudflared, no account) so the link works from anywhere
22
24
  metro stop [agent-id]
23
25
  stop every metro daemon on this machine, however it was started
24
26
  metro status <agent-id>
package/dist/serve.js CHANGED
@@ -1,9 +1,10 @@
1
+ import { spawnSync } from 'node:child_process';
1
2
  import { homedir } from 'node:os';
2
3
  import { join } from 'node:path';
3
4
  import { findBun, localPort, runtimeDir, SERVER_ENTRY, spawnPlan, } from './runtime.js';
4
5
  const SCRUBBED = new Set(['METRO_RUN_TOKEN', 'METRO_AGENT', 'DATABASE_URL']);
5
6
  const PORT_FLAG = /^--port=(.*)$/;
6
- const USAGE = 'usage: metro serve [--port <n>]';
7
+ const USAGE = 'usage: metro serve [--port <n>] [--tunnel]';
7
8
  function portOf(raw) {
8
9
  const n = Number(raw);
9
10
  if (raw === undefined || raw === '' || !Number.isInteger(n) || n < 1 || n > 65535)
@@ -12,8 +13,13 @@ function portOf(raw) {
12
13
  }
13
14
  export function parseServeArgs(argv) {
14
15
  let port = localPort();
16
+ let tunnel = false;
15
17
  for (let i = 0; i < argv.length; i += 1) {
16
18
  const arg = argv[i] ?? '';
19
+ if (arg === '--tunnel') {
20
+ tunnel = true;
21
+ continue;
22
+ }
17
23
  const inline = PORT_FLAG.exec(arg);
18
24
  if (inline) {
19
25
  port = portOf(inline[1]);
@@ -26,7 +32,14 @@ export function parseServeArgs(argv) {
26
32
  }
27
33
  throw new Error(`unknown argument '${arg}' — ${USAGE}`);
28
34
  }
29
- return { port };
35
+ return { port, tunnel };
36
+ }
37
+ export function findCloudflared() {
38
+ const found = spawnSync('cloudflared', ['--version'], { stdio: 'ignore' });
39
+ if (found.error !== undefined || found.status !== 0)
40
+ throw new Error('metro serve --tunnel needs cloudflared, which is not on PATH.\n' +
41
+ 'Install it with: brew install cloudflared (macOS), or from\n' +
42
+ 'https://developers.cloudflare.com/cloudflare-one/connections/connect-apps/downloads/');
30
43
  }
31
44
  export function serveStateDir() {
32
45
  const xdg = process.env.XDG_CACHE_HOME?.trim();
@@ -46,12 +59,15 @@ export function servePlan(opts) {
46
59
  METRO_HTTP_HOST: process.env.METRO_HTTP_HOST ?? '127.0.0.1',
47
60
  METRO_TRAINS_DIR: join(opts.dir, 'trains'),
48
61
  METRO_STATE_DIR: process.env.METRO_STATE_DIR ?? serveStateDir(),
62
+ ...(opts.tunnel ? { METRO_TUNNEL: 'quick' } : {}),
49
63
  },
50
64
  };
51
65
  }
52
66
  export function serve(argv) {
53
- const { port } = parseServeArgs(argv);
54
- const plan = servePlan({ dir: runtimeDir(), port });
67
+ const { port, tunnel } = parseServeArgs(argv);
68
+ if (tunnel)
69
+ findCloudflared();
70
+ const plan = servePlan({ dir: runtimeDir(), port, tunnel });
55
71
  process.stderr.write(`Starting a metro daemon of your own on http://127.0.0.1:${String(port)}\n`);
56
72
  return spawnPlan(plan);
57
73
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stage-labs/metro",
3
- "version": "0.1.0-beta.32",
3
+ "version": "0.1.0-beta.33",
4
4
  "description": "The metro command line. Sign in once per machine, then hand your MCP connector list to Claude Code without the credentials touching disk, argv or shell history.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -14,7 +14,7 @@ import {
14
14
  import { attachmentOwner, recordAttachmentOwner } from './attach-owner.js';
15
15
  import { grantAllows, issueAttachmentGrant } from './attach-grant.js';
16
16
  import { errMsg, log } from './log.js';
17
- import { loadTunnelConfig, webhookPort } from './tunnel.js';
17
+ import { configuredTunnelHost, currentTunnelUrl, webhookPort } from './tunnel.js';
18
18
  import { isLocalMode } from './paths.js';
19
19
 
20
20
  function authorized(req: IncomingMessage, name: string): boolean {
@@ -31,7 +31,9 @@ const DEFAULT_PUBLIC_BASE = 'https://mcp.metro.box';
31
31
  export function publicBaseUrl(): string | null {
32
32
  const env = process.env.METRO_PUBLIC_URL?.trim();
33
33
  if (env) return env.replace(/\/+$/, '');
34
- const host = loadTunnelConfig()?.hostname;
34
+ const live = currentTunnelUrl();
35
+ if (live !== null) return live;
36
+ const host = configuredTunnelHost();
35
37
  return host ? `https://${host}` : null;
36
38
  }
37
39
 
@@ -15,9 +15,15 @@ import { installCrashGuard, markDaemonReady } from './crash-guard.js';
15
15
  import {
16
16
  loadTunnelConfig,
17
17
  Tunnel,
18
+ tunnelConfigFromEnv,
18
19
  warnOnLegacyWebhooks,
19
20
  webhookPort,
20
21
  } from './tunnel.js';
22
+ import {
23
+ localConnectHint,
24
+ publicConnectHint,
25
+ tunnelPendingHint,
26
+ } from './connect-hint.js';
21
27
  import { TrainSupervisor } from './supervisor.js';
22
28
  import {
23
29
  makeEmit,
@@ -39,7 +45,7 @@ import {
39
45
  import { loadAgentForRuntime, localAgentKey } from '../db/materialize.js';
40
46
  import { fileSource } from '../db/file-source.js';
41
47
  import { ensureLocalSessionSecret } from './local-secret.js';
42
- import { localConnectHint, localSessionApis } from './local-mode.js';
48
+ import { localSessionApis } from './local-mode.js';
43
49
  import type { ModeInfo } from './mode-api.js';
44
50
  import type { SessionApis } from './session-apis.js';
45
51
  import {
@@ -128,8 +134,12 @@ supervisor.onTrainEvent((env, train) => {
128
134
  });
129
135
 
130
136
  let webhookServer: Server | null = null;
131
- const tunnelCfg = loadTunnelConfig();
132
- const tunnel = tunnelCfg ? new Tunnel(tunnelCfg, webhookPort()) : null;
137
+ const tunnelCfg = loadTunnelConfig() ?? tunnelConfigFromEnv();
138
+ const tunnel = tunnelCfg ? new Tunnel(tunnelCfg, webhookPort(), announcePublic) : null;
139
+
140
+ function announcePublic(url: string): void {
141
+ process.stderr.write(`\n${publicConnectHint(url)}`);
142
+ }
133
143
 
134
144
  setTrainCallBackend((train, action, args) =>
135
145
  supervisor.call(train, action, args),
@@ -140,7 +150,10 @@ const linkedSource = runtime === null ? null : httpSource(runtime);
140
150
  const localSource = linkedSource ?? (isLocalMode() ? fileSource : null);
141
151
 
142
152
  function announceLocalEndpoint(): void {
143
- if (isLocalMode()) process.stderr.write(`\n${localConnectHint(webhookPort())}`);
153
+ if (isLocalMode())
154
+ process.stderr.write(
155
+ `\n${tunnel === null ? localConnectHint(webhookPort()) : tunnelPendingHint()}`,
156
+ );
144
157
  const key = localAgentKey();
145
158
  if (key === null) {
146
159
  if (isLocalMode()) log.info('no agent on this machine yet');
@@ -0,0 +1,33 @@
1
+ import { hostname } from 'node:os';
2
+
3
+ const uiBase = (): string => process.env.METRO_UI_URL ?? 'https://metro.box';
4
+
5
+ export const connectLink = (base: string): string =>
6
+ `${uiBase()}/#/connect/${encodeURIComponent(base)}`;
7
+
8
+ export function localConnectHint(port: number): string {
9
+ const here = `http://127.0.0.1:${String(port)}`;
10
+ const forward = `ssh -L ${String(port)}:127.0.0.1:${String(port)} ${hostname()}`;
11
+ return [
12
+ 'Manage this machine from the web UI:',
13
+ '',
14
+ ` ${connectLink(here)}`,
15
+ '',
16
+ `From another computer, forward the port first: ${forward}`,
17
+ '',
18
+ ].join('\n');
19
+ }
20
+
21
+ export function publicConnectHint(url: string): string {
22
+ return [
23
+ 'Manage this machine from the web UI, from anywhere:',
24
+ '',
25
+ ` ${connectLink(url)}`,
26
+ '',
27
+ 'Sign in right away: the first wallet to sign in owns this machine.',
28
+ '',
29
+ ].join('\n');
30
+ }
31
+
32
+ export const tunnelPendingHint = (): string =>
33
+ 'Bringing up a public address through a Cloudflare quick tunnel…\n';
@@ -129,20 +129,6 @@ const agentConnectorApi: AgentConnectorApiDeps = {
129
129
  Promise.reject(new ApiError('a local daemon has no pairing codes', 400)),
130
130
  };
131
131
 
132
- export function localConnectHint(port: number): string {
133
- const ui = process.env.METRO_UI_URL ?? 'https://metro.box';
134
- const here = `http://127.0.0.1:${String(port)}`;
135
- const forward = `ssh -L ${String(port)}:127.0.0.1:${String(port)} ${hostname()}`;
136
- return [
137
- 'Manage this machine from the web UI:',
138
- '',
139
- ` ${ui}/#/connect/${encodeURIComponent(here)}`,
140
- '',
141
- `From another computer, forward the port first: ${forward}`,
142
- '',
143
- ].join('\n');
144
- }
145
-
146
132
  export function localModeInfo(): ModeInfo {
147
133
  return { mode: 'local', owner: localOwner(), project: LOCAL_PROJECT_ID };
148
134
  }
@@ -1,5 +1,6 @@
1
1
  import { spawn, spawnSync, type ChildProcess } from 'node:child_process';
2
2
  import { timingSafeEqual } from 'node:crypto';
3
+ import { Resolver } from 'node:dns/promises';
3
4
  import { homedir } from 'node:os';
4
5
  import { join } from 'node:path';
5
6
  import { STATE_DIR } from './paths.js';
@@ -10,11 +11,61 @@ const FILE = join(STATE_DIR, 'tunnel.json');
10
11
  const LEGACY_WEBHOOKS_FILE = join(STATE_DIR, 'webhooks.json');
11
12
  const RESTART_DELAY_MS = 2_000;
12
13
 
13
- interface TunnelConfig {
14
+ interface NamedTunnel {
14
15
  name: string;
15
16
  hostname: string;
16
17
  }
17
18
 
19
+ interface QuickTunnel {
20
+ quick: true;
21
+ }
22
+
23
+ export type TunnelConfig = NamedTunnel | QuickTunnel;
24
+
25
+ const isQuick = (cfg: TunnelConfig): cfg is QuickTunnel => 'quick' in cfg;
26
+
27
+ export const tunnelConfigFromEnv = (): TunnelConfig | null =>
28
+ process.env.METRO_TUNNEL?.trim() === 'quick' ? { quick: true } : null;
29
+
30
+ const QUICK_URL_RE = /https:\/\/[a-z0-9-]+\.trycloudflare\.com/;
31
+
32
+ export const quickTunnelUrlIn = (text: string): string | null =>
33
+ QUICK_URL_RE.exec(text)?.[0] ?? null;
34
+
35
+ let liveUrl: string | null = null;
36
+
37
+ export const currentTunnelUrl = (): string | null => liveUrl;
38
+
39
+ export type Resolves = (host: string) => Promise<boolean>;
40
+
41
+ const PUBLIC_RESOLVERS = ['1.1.1.1', '1.0.0.1'];
42
+ const RESOLVE_EVERY_MS = 3_000;
43
+ const RESOLVE_GIVE_UP_MS = 180_000;
44
+
45
+ export async function resolvesAtCloudflare(host: string): Promise<boolean> {
46
+ const resolver = new Resolver();
47
+ resolver.setServers(PUBLIC_RESOLVERS);
48
+ try {
49
+ return (await resolver.resolve4(host)).length > 0;
50
+ } catch {
51
+ return false;
52
+ }
53
+ }
54
+
55
+ async function untilResolvable(host: string, resolves: Resolves): Promise<boolean> {
56
+ const deadline = Date.now() + RESOLVE_GIVE_UP_MS;
57
+ while (Date.now() < deadline) {
58
+ if (await resolves(host)) return true;
59
+ await new Promise((r) => setTimeout(r, RESOLVE_EVERY_MS));
60
+ }
61
+ return false;
62
+ }
63
+
64
+ export function configuredTunnelHost(): string | null {
65
+ const cfg = loadTunnelConfig();
66
+ return cfg !== null && !isQuick(cfg) ? cfg.hostname : null;
67
+ }
68
+
18
69
  export interface Endpoint {
19
70
  id: string;
20
71
  webhookId?: string;
@@ -98,6 +149,12 @@ function fetchTunnelToken(name: string): string | null {
98
149
  return token.length > 0 ? token : null;
99
150
  }
100
151
 
152
+ interface Plan {
153
+ args: string[];
154
+ env: NodeJS.ProcessEnv;
155
+ mode: string;
156
+ }
157
+
101
158
  export class Tunnel {
102
159
  private child: ChildProcess | null = null;
103
160
  private closed = false;
@@ -106,57 +163,88 @@ export class Tunnel {
106
163
  constructor(
107
164
  private cfg: TunnelConfig,
108
165
  private port: number,
166
+ private onUrl: (url: string) => void = () => undefined,
167
+ private resolves: Resolves = resolvesAtCloudflare,
109
168
  ) {}
110
169
 
111
170
  get hostname(): string {
112
- return this.cfg.hostname;
171
+ if (!isQuick(this.cfg)) return this.cfg.hostname;
172
+ return liveUrl === null ? '' : new URL(liveUrl).host;
173
+ }
174
+
175
+ private origin(): string {
176
+ return `http://127.0.0.1:${String(this.port)}`;
177
+ }
178
+
179
+ private plan(): Plan {
180
+ if (isQuick(this.cfg))
181
+ return {
182
+ args: ['--no-autoupdate', 'tunnel', '--url', this.origin()],
183
+ env: process.env,
184
+ mode: 'quick',
185
+ };
186
+ if (this.token === undefined) this.token = fetchTunnelToken(this.cfg.name);
187
+ const args = ['--no-autoupdate', 'tunnel', 'run', '--url', this.origin()];
188
+ if (!this.token) args.push(this.cfg.name);
189
+ return {
190
+ args,
191
+ env: this.token ? { ...process.env, TUNNEL_TOKEN: this.token } : process.env,
192
+ mode: this.token ? 'token' : 'named',
193
+ };
194
+ }
195
+
196
+ private noticeUrl(text: string): void {
197
+ if (!isQuick(this.cfg)) return;
198
+ const url = quickTunnelUrlIn(text);
199
+ if (url === null || url === liveUrl) return;
200
+ liveUrl = url;
201
+ log.info({ url }, 'quick tunnel up; waiting for its name to resolve');
202
+ this.announceWhenResolvable(url).catch((err: unknown) => {
203
+ log.warn({ err: errMsg(err) }, 'quick tunnel: announce failed');
204
+ });
205
+ }
206
+
207
+ private async announceWhenResolvable(url: string): Promise<void> {
208
+ const resolved = await untilResolvable(new URL(url).host, this.resolves);
209
+ if (liveUrl !== url) return;
210
+ if (!resolved) log.warn({ url }, 'quick tunnel name still not resolving; announcing anyway');
211
+ this.onUrl(url);
113
212
  }
114
213
 
115
214
  start(): void {
116
215
  if (this.closed) return;
117
- if (this.token === undefined) this.token = fetchTunnelToken(this.cfg.name);
118
- const mode = this.token ? 'token' : 'named';
216
+ const plan = this.plan();
119
217
  log.info(
120
- {
121
- name: this.cfg.name,
122
- hostname: this.cfg.hostname,
123
- port: this.port,
124
- mode,
125
- },
218
+ { tunnel: isQuick(this.cfg) ? 'quick' : this.cfg.name, port: this.port, mode: plan.mode },
126
219
  'cloudflared tunnel starting',
127
220
  );
128
- const args = [
129
- '--no-autoupdate',
130
- 'tunnel',
131
- 'run',
132
- '--url',
133
- `http://127.0.0.1:${this.port}`,
134
- ];
135
- if (!this.token) args.push(this.cfg.name);
136
- const env = this.token
137
- ? { ...process.env, TUNNEL_TOKEN: this.token }
138
- : process.env;
139
- this.child = spawn('cloudflared', args, {
221
+ const child = spawn('cloudflared', plan.args, {
140
222
  stdio: ['ignore', 'pipe', 'pipe'],
141
- env,
223
+ env: plan.env,
142
224
  });
143
- this.child.stderr?.on('data', (d: Buffer | string) => {
144
- log.debug(
145
- {
146
- cloudflared: (typeof d === 'string' ? d : d.toString('utf8')).trim(),
147
- },
148
- 'cloudflared',
149
- );
225
+ this.child = child;
226
+ child.stderr?.on('data', (d: Buffer | string) => {
227
+ const text = (typeof d === 'string' ? d : d.toString('utf8')).trim();
228
+ log.debug({ cloudflared: text }, 'cloudflared');
229
+ this.noticeUrl(text);
150
230
  });
151
- this.child.on('exit', (code) => {
231
+ child.on('exit', (code) => {
152
232
  this.child = null;
233
+ liveUrl = null;
153
234
  if (this.closed) return;
154
235
  log.warn({ code }, 'cloudflared exited; restarting');
155
236
  setTimeout(() => {
156
237
  this.start();
157
238
  }, RESTART_DELAY_MS);
158
239
  });
159
- this.child.on('error', (err) => {
240
+ child.on('error', (err) => {
241
+ if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
242
+ this.closed = true;
243
+ log.error(
244
+ 'cloudflared is not installed, so there is no public address; install it and restart',
245
+ );
246
+ return;
247
+ }
160
248
  log.warn({ err: errMsg(err) }, 'cloudflared spawn error');
161
249
  });
162
250
  }
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "0.1.0-beta.32"
2
+ "version": "0.1.0-beta.33"
3
3
  }