@stage-labs/metro 0.1.0-beta.54 → 0.1.0-beta.55

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.55",
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…\n';
@@ -8,14 +8,57 @@ import { readJson } from './secure-fs.js';
8
8
 
9
9
  const RESTART_DELAY_MS = 2_000;
10
10
 
11
- export const quickTunnelWanted = (): boolean =>
12
- process.env.METRO_TUNNEL?.trim() === 'quick';
11
+ export type TunnelKind = 'quick' | 'tailscale';
12
+
13
+ export function tunnelKind(): TunnelKind | null {
14
+ const raw = process.env.METRO_TUNNEL?.trim();
15
+ return raw === 'quick' || raw === 'tailscale' ? raw : null;
16
+ }
17
+
18
+ export const quickTunnelWanted = (): boolean => tunnelKind() === 'quick';
13
19
 
14
20
  const QUICK_URL_RE = /https:\/\/[a-z0-9-]+\.trycloudflare\.com/;
21
+ const FUNNEL_URL_RE = /https:\/\/[a-z0-9-]+(?:\.[a-z0-9-]+)+\.ts\.net(?::\d{2,5})?(?=[\s/]|$)/i;
22
+ const SILENT_MS = 20_000;
15
23
 
16
24
  export const quickTunnelUrlIn = (text: string): string | null =>
17
25
  QUICK_URL_RE.exec(text)?.[0] ?? null;
18
26
 
27
+ export const funnelUrlIn = (text: string): string | null =>
28
+ FUNNEL_URL_RE.exec(text)?.[0]?.toLowerCase() ?? null;
29
+
30
+ export interface TunnelDriver {
31
+ name: string;
32
+ command: string;
33
+ args: string[];
34
+ urlIn: (text: string) => string | null;
35
+ waitsForDns: boolean;
36
+ }
37
+
38
+ export const quickDriver = (port: number): TunnelDriver => ({
39
+ name: 'cloudflared quick tunnel',
40
+ command: 'cloudflared',
41
+ args: ['--no-autoupdate', 'tunnel', '--url', `http://127.0.0.1:${String(port)}`],
42
+ urlIn: quickTunnelUrlIn,
43
+ waitsForDns: true,
44
+ });
45
+
46
+ function tailscaleBin(): string {
47
+ const configured = process.env.METRO_TAILSCALE_BIN?.trim() ?? '';
48
+ return configured === '' ? 'tailscale' : configured;
49
+ }
50
+
51
+ export const funnelDriver = (port: number, bin = tailscaleBin()): TunnelDriver => ({
52
+ name: 'tailscale funnel',
53
+ command: bin,
54
+ args: ['funnel', String(port)],
55
+ urlIn: funnelUrlIn,
56
+ waitsForDns: false,
57
+ });
58
+
59
+ export const driverFor = (kind: TunnelKind, port: number): TunnelDriver =>
60
+ kind === 'quick' ? quickDriver(port) : funnelDriver(port);
61
+
19
62
  let liveUrl: string | null = null;
20
63
 
21
64
  export const currentTunnelUrl = (): string | null => liveUrl;
@@ -104,53 +147,72 @@ export function tokenMatches(secret: string, given: string): boolean {
104
147
  export class Tunnel {
105
148
  private child: ChildProcess | null = null;
106
149
  private closed = false;
150
+ private output: string[] = [];
107
151
 
108
152
  constructor(
109
- private port: number,
153
+ private driver: TunnelDriver,
110
154
  private onUrl: (url: string) => void = () => undefined,
111
155
  private resolves: Resolves = resolvesAtCloudflare,
112
156
  ) {}
113
157
 
114
- private origin(): string {
115
- return `http://127.0.0.1:${String(this.port)}`;
116
- }
117
-
118
158
  private noticeUrl(text: string): void {
119
- const url = quickTunnelUrlIn(text);
159
+ const url = this.driver.urlIn(text);
120
160
  if (url === null || url === liveUrl) return;
121
161
  liveUrl = url;
122
- log.info({ url }, 'quick tunnel up; waiting for its name to resolve');
162
+ if (!this.driver.waitsForDns) {
163
+ log.info({ url }, `${this.driver.name} up`);
164
+ this.onUrl(url);
165
+ return;
166
+ }
167
+ log.info({ url }, `${this.driver.name} up; waiting for its name to resolve`);
123
168
  this.announceWhenResolvable(url).catch((err: unknown) => {
124
- log.warn({ err: errMsg(err) }, 'quick tunnel: announce failed');
169
+ log.warn({ err: errMsg(err) }, `${this.driver.name}: announce failed`);
125
170
  });
126
171
  }
127
172
 
128
173
  private async announceWhenResolvable(url: string): Promise<void> {
129
174
  const resolved = await untilResolvable(new URL(url).host, this.resolves);
130
175
  if (liveUrl !== url) return;
131
- if (!resolved) log.warn({ url }, 'quick tunnel name still not resolving; announcing anyway');
176
+ if (!resolved) log.warn({ url }, `${this.driver.name} name still not resolving; announcing anyway`);
132
177
  this.onUrl(url);
133
178
  }
134
179
 
180
+ private read(d: Buffer | string): void {
181
+ const text = (typeof d === 'string' ? d : d.toString('utf8')).trim();
182
+ if (text === '') return;
183
+ this.output.push(text);
184
+ log.debug({ output: text }, this.driver.name);
185
+ this.noticeUrl(text);
186
+ }
187
+
188
+ private silentCheck(): void {
189
+ if (this.closed || liveUrl !== null || this.child === null) return;
190
+ log.warn(
191
+ { output: this.output.join('\n') },
192
+ `${this.driver.name}: no public address after ${String(SILENT_MS / 1000)}s; its own output follows`,
193
+ );
194
+ }
195
+
135
196
  start(): void {
136
197
  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
- );
198
+ log.info({ command: this.driver.command, args: this.driver.args }, `${this.driver.name} starting`);
199
+ this.output = [];
200
+ const child = spawn(this.driver.command, this.driver.args, { stdio: ['ignore', 'pipe', 'pipe'] });
143
201
  this.child = child;
202
+ child.stdout?.on('data', (d: Buffer | string) => {
203
+ this.read(d);
204
+ });
144
205
  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);
206
+ this.read(d);
148
207
  });
208
+ setTimeout(() => {
209
+ this.silentCheck();
210
+ }, SILENT_MS).unref();
149
211
  child.on('exit', (code) => {
150
212
  this.child = null;
151
213
  liveUrl = null;
152
214
  if (this.closed) return;
153
- log.warn({ code }, 'cloudflared exited; restarting');
215
+ log.warn({ code, output: this.output.slice(-5).join('\n') }, `${this.driver.name} exited; restarting`);
154
216
  setTimeout(() => {
155
217
  this.start();
156
218
  }, RESTART_DELAY_MS);
@@ -159,17 +221,18 @@ export class Tunnel {
159
221
  if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
160
222
  this.closed = true;
161
223
  log.error(
162
- 'cloudflared is not installed, so there is no public address; install it and restart',
224
+ { command: this.driver.command },
225
+ `${this.driver.name}: the binary is not installed, so there is no public address; install it and restart`,
163
226
  );
164
227
  return;
165
228
  }
166
- log.warn({ err: errMsg(err) }, 'cloudflared spawn error');
229
+ log.warn({ err: errMsg(err) }, `${this.driver.name} spawn error`);
167
230
  });
168
231
  }
169
232
 
170
233
  stop(): void {
171
234
  this.closed = true;
172
- this.child?.kill();
235
+ this.child?.kill('SIGINT');
173
236
  this.child = null;
174
237
  }
175
238
  }
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "0.1.0-beta.54"
2
+ "version": "0.1.0-beta.55"
3
3
  }