@stage-labs/metro 0.1.0-beta.54 → 0.1.0-beta.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/dist/serve.js CHANGED
@@ -10,13 +10,33 @@ const SCRUBBED = new Set(['METRO_RUN_TOKEN', 'METRO_AGENT', 'DATABASE_URL']);
10
10
  const PORT_FLAG = /^--port=(.*)$/;
11
11
  const OWNER_FLAG = /^--owner=(.*)$/;
12
12
  const ADDRESS = /^0x[0-9a-fA-F]{40}$/;
13
- const USAGE = 'usage: metro serve [--port <n>] [--tunnel] [--owner <address>]';
13
+ const USAGE = 'usage: metro serve [--port <n>] [--tunnel [quick|tailscale]] [--owner <address>]';
14
+ const TUNNEL_FLAG = /^--tunnel=(.*)$/;
15
+ const TAILSCALE_APP = '/Applications/Tailscale.app/Contents/MacOS/Tailscale';
14
16
  function portOf(raw) {
15
17
  const n = Number(raw);
16
18
  if (raw === undefined || raw === '' || !Number.isInteger(n) || n < 1 || n > 65535)
17
19
  throw new Error(`'${raw ?? ''}' is not a port — ${USAGE}`);
18
20
  return n;
19
21
  }
22
+ function tunnelOf(raw) {
23
+ if (raw === undefined || raw === '' || raw === 'quick')
24
+ return 'quick';
25
+ if (raw === 'tailscale')
26
+ return 'tailscale';
27
+ throw new Error(`'${raw}' is not a tunnel kind (quick or tailscale) — ${USAGE}`);
28
+ }
29
+ function tunnelFlag(argv, i) {
30
+ const arg = argv[i] ?? '';
31
+ const inline = TUNNEL_FLAG.exec(arg);
32
+ if (inline)
33
+ return { kind: tunnelOf(inline[1]), consumed: 0 };
34
+ if (arg !== '--tunnel')
35
+ return null;
36
+ const next = argv[i + 1];
37
+ const takesValue = next !== undefined && !next.startsWith('-');
38
+ return { kind: tunnelOf(takesValue ? next : undefined), consumed: takesValue ? 1 : 0 };
39
+ }
20
40
  function ownerOf(raw) {
21
41
  if (raw === undefined || !ADDRESS.test(raw))
22
42
  throw new Error(`'${raw ?? ''}' is not an Ethereum address — ${USAGE}`);
@@ -24,12 +44,14 @@ function ownerOf(raw) {
24
44
  }
25
45
  export function parseServeArgs(argv) {
26
46
  let port = localPort();
27
- let tunnel = false;
47
+ let tunnel = null;
28
48
  let owner = null;
29
49
  for (let i = 0; i < argv.length; i += 1) {
30
50
  const arg = argv[i] ?? '';
31
- if (arg === '--tunnel') {
32
- tunnel = true;
51
+ const tunnelArg = tunnelFlag(argv, i);
52
+ if (tunnelArg) {
53
+ tunnel = tunnelArg.kind;
54
+ i += tunnelArg.consumed;
33
55
  continue;
34
56
  }
35
57
  const inlineOwner = OWNER_FLAG.exec(arg);
@@ -64,6 +86,31 @@ export function requireOwner(owner, dir = agentsDir()) {
64
86
  join(dir, '.owner') +
65
87
  ':\n metro serve --owner <address>');
66
88
  }
89
+ function tailscaleStatus(bin) {
90
+ const run = spawnSync(bin, ['status', '--json'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
91
+ if (run.error !== undefined || run.status !== 0)
92
+ return { ok: false, state: 'not running' };
93
+ try {
94
+ const parsed = JSON.parse(run.stdout);
95
+ return parsed.BackendState === 'Running' ? { ok: true } : { ok: false, state: String(parsed.BackendState) };
96
+ }
97
+ catch {
98
+ return { ok: false, state: 'unreadable' };
99
+ }
100
+ }
101
+ export function findTailscale(candidates = ['tailscale', TAILSCALE_APP]) {
102
+ const bin = candidates.find((c) => spawnSync(c, ['version'], { stdio: 'ignore' }).status === 0);
103
+ if (bin === undefined)
104
+ throw new Error('metro serve --tunnel tailscale needs Tailscale on this machine.\n' +
105
+ 'macOS: brew install --cask tailscale (or the App Store app)\n' +
106
+ 'Linux: curl -fsSL https://tailscale.com/install.sh | sh\n' +
107
+ 'Then sign the machine in: tailscale up');
108
+ const status = tailscaleStatus(bin);
109
+ if (!status.ok)
110
+ throw new Error(`Tailscale is installed but this machine is not connected (${status.state}). Sign it in first: tailscale up\n` +
111
+ 'Funnel also has to be enabled once on your tailnet: https://tailscale.com/kb/1223/funnel');
112
+ return bin;
113
+ }
67
114
  function findCloudflared() {
68
115
  const found = spawnSync('cloudflared', ['--version'], { stdio: 'ignore' });
69
116
  if (found.error !== undefined || found.status !== 0)
@@ -94,7 +141,8 @@ export function servePlan(opts) {
94
141
  ? {}
95
142
  : { METRO_RUNTIME_STORE: opts.runtime.dir, METRO_RUNTIME_MANIFEST: opts.runtime.manifest }),
96
143
  METRO_STATE_DIR: process.env.METRO_STATE_DIR ?? serveStateDir(),
97
- ...(opts.tunnel ? { METRO_TUNNEL: 'quick' } : {}),
144
+ ...(opts.tunnel === null ? {} : { METRO_TUNNEL: opts.tunnel }),
145
+ ...(opts.tailscaleBin === undefined ? {} : { METRO_TAILSCALE_BIN: opts.tailscaleBin }),
98
146
  ...(opts.owner === null ? {} : { METRO_OWNER: opts.owner }),
99
147
  },
100
148
  };
@@ -106,8 +154,9 @@ export function serve(argv) {
106
154
  throw new Error(`a metro serve daemon is already running on this machine (pid ${String(running)}). ` +
107
155
  'Stop it first: metro stop');
108
156
  requireOwner(owner);
109
- if (tunnel)
157
+ if (tunnel === 'quick')
110
158
  findCloudflared();
159
+ const tailscaleBin = tunnel === 'tailscale' ? findTailscale() : undefined;
111
160
  process.stderr.write(`Starting a metro daemon of your own on http://127.0.0.1:${String(port)}\n`);
112
- return spawnPlan(() => servePlan({ runtime: prepareRuntime(), port, tunnel, owner }));
161
+ return spawnPlan(() => servePlan({ runtime: prepareRuntime(), port, tunnel, owner, ...(tailscaleBin === undefined ? {} : { tailscaleBin }) }));
113
162
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stage-labs/metro",
3
- "version": "0.1.0-beta.54",
3
+ "version": "0.1.0-beta.56",
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": {
@@ -6,7 +6,7 @@ import { errMsg, log, logFatalSync } from './log.js';
6
6
  import { acquireLock, isLocalMode, STATE_DIR, trainsDir } from './paths.js';
7
7
  import { installCrashGuard, markDaemonReady } from './crash-guard.js';
8
8
  import { METRO_VERSION } from './version.js';
9
- import { quickTunnelWanted, Tunnel, webhookPort } from './tunnel.js';
9
+ import { driverFor, Tunnel, tunnelKind, webhookPort } from './tunnel.js';
10
10
  import {
11
11
  localConnectHint,
12
12
  publicConnectHint,
@@ -61,7 +61,8 @@ supervisor.onTrainEvent((env, train) => {
61
61
  });
62
62
 
63
63
  let webhookServer: Server | null = null;
64
- const tunnel = quickTunnelWanted() ? new Tunnel(webhookPort(), announcePublic) : null;
64
+ const tunnelWanted = tunnelKind();
65
+ const tunnel = tunnelWanted === null ? null : new Tunnel(driverFor(tunnelWanted, webhookPort()), announcePublic);
65
66
 
66
67
  function announcePublic(url: string): void {
67
68
  process.stderr.write(`\n${publicConnectHint(url, localOwner())}`);
@@ -73,7 +74,7 @@ setTrainCallBackend((train, action, args) =>
73
74
 
74
75
  function announceLocalEndpoint(): void {
75
76
  process.stderr.write(
76
- `\n${tunnel === null ? localConnectHint(webhookPort(), localOwner()) : tunnelPendingHint()}`,
77
+ `\n${tunnelWanted === null ? localConnectHint(webhookPort(), localOwner()) : tunnelPendingHint(tunnelWanted)}`,
77
78
  );
78
79
  const key = localAgentKey();
79
80
  if (key === null) {
@@ -35,5 +35,7 @@ export function publicConnectHint(url: string, owner: string | null): string {
35
35
  ].join('\n');
36
36
  }
37
37
 
38
- export const tunnelPendingHint = (): string =>
39
- 'Bringing up a public address through a Cloudflare quick tunnel…\n';
38
+ export const tunnelPendingHint = (kind: 'quick' | 'tailscale'): string =>
39
+ kind === 'quick'
40
+ ? 'Bringing up a public address through a Cloudflare quick tunnel…\n'
41
+ : 'Publishing this daemon through Tailscale Funnel… (if Tailscale asks to enable Funnel, its link is in the log)\n';
@@ -1,4 +1,4 @@
1
- import { spawn, type ChildProcess } from 'node:child_process';
1
+ import { execFile, spawn, type ChildProcess } from 'node:child_process';
2
2
  import { timingSafeEqual } from 'node:crypto';
3
3
  import { Resolver } from 'node:dns/promises';
4
4
  import { homedir } from 'node:os';
@@ -7,15 +7,93 @@ import { errMsg, log } from './log.js';
7
7
  import { readJson } from './secure-fs.js';
8
8
 
9
9
  const RESTART_DELAY_MS = 2_000;
10
+ const RESTART_DELAY_MAX_MS = 30_000;
11
+ const TAKEN_RE = /listener already exists/i;
12
+ const FUNNEL_ON_RE = /\(Funnel on\)/i;
10
13
 
11
- export const quickTunnelWanted = (): boolean =>
12
- process.env.METRO_TUNNEL?.trim() === 'quick';
14
+ export type TunnelKind = 'quick' | 'tailscale';
15
+
16
+ export function tunnelKind(): TunnelKind | null {
17
+ const raw = process.env.METRO_TUNNEL?.trim();
18
+ return raw === 'quick' || raw === 'tailscale' ? raw : null;
19
+ }
20
+
21
+ export const quickTunnelWanted = (): boolean => tunnelKind() === 'quick';
13
22
 
14
23
  const QUICK_URL_RE = /https:\/\/[a-z0-9-]+\.trycloudflare\.com/;
24
+ const FUNNEL_URL_RE = /https:\/\/[a-z0-9-]+(?:\.[a-z0-9-]+)+\.ts\.net(?::\d{2,5})?(?=[\s/]|$)/i;
25
+ const SILENT_MS = 20_000;
15
26
 
16
27
  export const quickTunnelUrlIn = (text: string): string | null =>
17
28
  QUICK_URL_RE.exec(text)?.[0] ?? null;
18
29
 
30
+ export const funnelUrlIn = (text: string): string | null =>
31
+ FUNNEL_URL_RE.exec(text)?.[0]?.toLowerCase() ?? null;
32
+
33
+ export interface Adopted {
34
+ url: string | null;
35
+ hint: string;
36
+ }
37
+
38
+ export interface TunnelDriver {
39
+ name: string;
40
+ command: string;
41
+ args: string[];
42
+ urlIn: (text: string) => string | null;
43
+ waitsForDns: boolean;
44
+ adopt?: () => Promise<Adopted>;
45
+ }
46
+
47
+ const runText = (command: string, args: string[]): Promise<string> =>
48
+ new Promise((resolve) => {
49
+ execFile(command, args, { encoding: 'utf8', timeout: 10_000 }, (_err, stdout, stderr) => {
50
+ resolve(`${stdout}\n${stderr}`);
51
+ });
52
+ });
53
+
54
+ export function funnelAlreadyServing(status: string, port: number): Adopted {
55
+ const lines = status.split('\n');
56
+ const target = `http://127.0.0.1:${String(port)}`;
57
+ for (const [i, line] of lines.entries()) {
58
+ if (line.trimStart().startsWith('#')) continue;
59
+ const url = funnelUrlIn(line);
60
+ if (url === null) continue;
61
+ const body = lines.slice(i + 1, i + 6).join('\n');
62
+ if (!body.includes(target)) continue;
63
+ if (FUNNEL_ON_RE.test(line)) return { url, hint: `a Funnel already publishes this daemon at ${url}; using it` };
64
+ return {
65
+ url: null,
66
+ hint: `${url} is a tailnet-only serve config on this node, not a Funnel; run: tailscale serve reset`,
67
+ };
68
+ }
69
+ return { url: null, hint: 'port 443 is held by another serve config on this node; run: tailscale serve reset' };
70
+ }
71
+
72
+ export const quickDriver = (port: number): TunnelDriver => ({
73
+ name: 'cloudflared quick tunnel',
74
+ command: 'cloudflared',
75
+ args: ['--no-autoupdate', 'tunnel', '--url', `http://127.0.0.1:${String(port)}`],
76
+ urlIn: quickTunnelUrlIn,
77
+ waitsForDns: true,
78
+ });
79
+
80
+ function tailscaleBin(): string {
81
+ const configured = process.env.METRO_TAILSCALE_BIN?.trim() ?? '';
82
+ return configured === '' ? 'tailscale' : configured;
83
+ }
84
+
85
+ export const funnelDriver = (port: number, bin = tailscaleBin()): TunnelDriver => ({
86
+ name: 'tailscale funnel',
87
+ command: bin,
88
+ args: ['funnel', String(port)],
89
+ urlIn: funnelUrlIn,
90
+ waitsForDns: true,
91
+ adopt: async () => funnelAlreadyServing(await runText(bin, ['funnel', 'status']), port),
92
+ });
93
+
94
+ export const driverFor = (kind: TunnelKind, port: number): TunnelDriver =>
95
+ kind === 'quick' ? quickDriver(port) : funnelDriver(port);
96
+
19
97
  let liveUrl: string | null = null;
20
98
 
21
99
  export const currentTunnelUrl = (): string | null => liveUrl;
@@ -104,72 +182,111 @@ export function tokenMatches(secret: string, given: string): boolean {
104
182
  export class Tunnel {
105
183
  private child: ChildProcess | null = null;
106
184
  private closed = false;
185
+ private output: string[] = [];
186
+ private restartDelay = RESTART_DELAY_MS;
107
187
 
108
188
  constructor(
109
- private port: number,
189
+ private driver: TunnelDriver,
110
190
  private onUrl: (url: string) => void = () => undefined,
111
191
  private resolves: Resolves = resolvesAtCloudflare,
112
192
  ) {}
113
193
 
114
- private origin(): string {
115
- return `http://127.0.0.1:${String(this.port)}`;
116
- }
117
-
118
194
  private noticeUrl(text: string): void {
119
- const url = quickTunnelUrlIn(text);
195
+ const url = this.driver.urlIn(text);
120
196
  if (url === null || url === liveUrl) return;
121
197
  liveUrl = url;
122
- log.info({ url }, 'quick tunnel up; waiting for its name to resolve');
198
+ this.restartDelay = RESTART_DELAY_MS;
199
+ if (!this.driver.waitsForDns) {
200
+ log.info({ url }, `${this.driver.name} up`);
201
+ this.onUrl(url);
202
+ return;
203
+ }
204
+ log.info({ url }, `${this.driver.name} up; waiting for its name to resolve`);
123
205
  this.announceWhenResolvable(url).catch((err: unknown) => {
124
- log.warn({ err: errMsg(err) }, 'quick tunnel: announce failed');
206
+ log.warn({ err: errMsg(err) }, `${this.driver.name}: announce failed`);
125
207
  });
126
208
  }
127
209
 
128
210
  private async announceWhenResolvable(url: string): Promise<void> {
129
211
  const resolved = await untilResolvable(new URL(url).host, this.resolves);
130
212
  if (liveUrl !== url) return;
131
- if (!resolved) log.warn({ url }, 'quick tunnel name still not resolving; announcing anyway');
213
+ if (!resolved) log.warn({ url }, `${this.driver.name} name still not resolving; announcing anyway`);
132
214
  this.onUrl(url);
133
215
  }
134
216
 
217
+ private read(d: Buffer | string): void {
218
+ const text = (typeof d === 'string' ? d : d.toString('utf8')).trim();
219
+ if (text === '') return;
220
+ this.output.push(text);
221
+ log.debug({ output: text }, this.driver.name);
222
+ this.noticeUrl(text);
223
+ }
224
+
225
+ private silentCheck(): void {
226
+ if (this.closed || liveUrl !== null || this.child === null) return;
227
+ log.warn(
228
+ { output: this.output.join('\n') },
229
+ `${this.driver.name}: no public address after ${String(SILENT_MS / 1000)}s; its own output follows`,
230
+ );
231
+ }
232
+
135
233
  start(): void {
136
234
  if (this.closed) return;
137
- log.info({ port: this.port }, 'cloudflared quick tunnel starting');
138
- const child = spawn(
139
- 'cloudflared',
140
- ['--no-autoupdate', 'tunnel', '--url', this.origin()],
141
- { stdio: ['ignore', 'pipe', 'pipe'] },
142
- );
235
+ log.info({ command: this.driver.command, args: this.driver.args }, `${this.driver.name} starting`);
236
+ this.output = [];
237
+ const child = spawn(this.driver.command, this.driver.args, { stdio: ['ignore', 'pipe', 'pipe'] });
143
238
  this.child = child;
239
+ child.stdout?.on('data', (d: Buffer | string) => {
240
+ this.read(d);
241
+ });
144
242
  child.stderr?.on('data', (d: Buffer | string) => {
145
- const text = (typeof d === 'string' ? d : d.toString('utf8')).trim();
146
- log.debug({ cloudflared: text }, 'cloudflared');
147
- this.noticeUrl(text);
243
+ this.read(d);
148
244
  });
245
+ setTimeout(() => {
246
+ this.silentCheck();
247
+ }, SILENT_MS).unref();
149
248
  child.on('exit', (code) => {
150
249
  this.child = null;
151
250
  liveUrl = null;
152
251
  if (this.closed) return;
153
- log.warn({ code }, 'cloudflared exited; restarting');
154
- setTimeout(() => {
155
- this.start();
156
- }, RESTART_DELAY_MS);
252
+ this.afterExit(code).catch((err: unknown) => {
253
+ log.warn({ err: errMsg(err) }, `${this.driver.name}: exit handling failed`);
254
+ });
157
255
  });
158
256
  child.on('error', (err) => {
159
257
  if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
160
258
  this.closed = true;
161
259
  log.error(
162
- 'cloudflared is not installed, so there is no public address; install it and restart',
260
+ { command: this.driver.command },
261
+ `${this.driver.name}: the binary is not installed, so there is no public address; install it and restart`,
163
262
  );
164
263
  return;
165
264
  }
166
- log.warn({ err: errMsg(err) }, 'cloudflared spawn error');
265
+ log.warn({ err: errMsg(err) }, `${this.driver.name} spawn error`);
167
266
  });
168
267
  }
169
268
 
269
+ private async afterExit(code: number | null): Promise<void> {
270
+ const output = this.output.join('\n');
271
+ if (this.driver.adopt !== undefined && TAKEN_RE.test(output)) {
272
+ const found = await this.driver.adopt();
273
+ if (this.closed) return;
274
+ if (found.url !== null) {
275
+ log.info({ url: found.url }, `${this.driver.name}: ${found.hint}`);
276
+ this.noticeUrl(found.url);
277
+ return;
278
+ }
279
+ log.error({ output }, `${this.driver.name}: ${found.hint}`);
280
+ } else log.warn({ code, output: this.output.slice(-5).join('\n') }, `${this.driver.name} exited; restarting`);
281
+ setTimeout(() => {
282
+ this.start();
283
+ }, this.restartDelay);
284
+ this.restartDelay = Math.min(this.restartDelay * 2, RESTART_DELAY_MAX_MS);
285
+ }
286
+
170
287
  stop(): void {
171
288
  this.closed = true;
172
- this.child?.kill();
289
+ this.child?.kill('SIGINT');
173
290
  this.child = null;
174
291
  }
175
292
  }
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "0.1.0-beta.54"
2
+ "version": "0.1.0-beta.56"
3
3
  }