@stage-labs/metro 0.1.0-beta.55 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stage-labs/metro",
3
- "version": "0.1.0-beta.55",
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": {
@@ -38,4 +38,4 @@ export function publicConnectHint(url: string, owner: string | null): string {
38
38
  export const tunnelPendingHint = (kind: 'quick' | 'tailscale'): string =>
39
39
  kind === 'quick'
40
40
  ? 'Bringing up a public address through a Cloudflare quick tunnel…\n'
41
- : 'Publishing this daemon through Tailscale Funnel…\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,6 +7,9 @@ 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
14
  export type TunnelKind = 'quick' | 'tailscale';
12
15
 
@@ -27,12 +30,43 @@ export const quickTunnelUrlIn = (text: string): string | null =>
27
30
  export const funnelUrlIn = (text: string): string | null =>
28
31
  FUNNEL_URL_RE.exec(text)?.[0]?.toLowerCase() ?? null;
29
32
 
33
+ export interface Adopted {
34
+ url: string | null;
35
+ hint: string;
36
+ }
37
+
30
38
  export interface TunnelDriver {
31
39
  name: string;
32
40
  command: string;
33
41
  args: string[];
34
42
  urlIn: (text: string) => string | null;
35
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' };
36
70
  }
37
71
 
38
72
  export const quickDriver = (port: number): TunnelDriver => ({
@@ -53,7 +87,8 @@ export const funnelDriver = (port: number, bin = tailscaleBin()): TunnelDriver =
53
87
  command: bin,
54
88
  args: ['funnel', String(port)],
55
89
  urlIn: funnelUrlIn,
56
- waitsForDns: false,
90
+ waitsForDns: true,
91
+ adopt: async () => funnelAlreadyServing(await runText(bin, ['funnel', 'status']), port),
57
92
  });
58
93
 
59
94
  export const driverFor = (kind: TunnelKind, port: number): TunnelDriver =>
@@ -148,6 +183,7 @@ export class Tunnel {
148
183
  private child: ChildProcess | null = null;
149
184
  private closed = false;
150
185
  private output: string[] = [];
186
+ private restartDelay = RESTART_DELAY_MS;
151
187
 
152
188
  constructor(
153
189
  private driver: TunnelDriver,
@@ -159,6 +195,7 @@ export class Tunnel {
159
195
  const url = this.driver.urlIn(text);
160
196
  if (url === null || url === liveUrl) return;
161
197
  liveUrl = url;
198
+ this.restartDelay = RESTART_DELAY_MS;
162
199
  if (!this.driver.waitsForDns) {
163
200
  log.info({ url }, `${this.driver.name} up`);
164
201
  this.onUrl(url);
@@ -212,10 +249,9 @@ export class Tunnel {
212
249
  this.child = null;
213
250
  liveUrl = null;
214
251
  if (this.closed) return;
215
- log.warn({ code, output: this.output.slice(-5).join('\n') }, `${this.driver.name} exited; restarting`);
216
- setTimeout(() => {
217
- this.start();
218
- }, RESTART_DELAY_MS);
252
+ this.afterExit(code).catch((err: unknown) => {
253
+ log.warn({ err: errMsg(err) }, `${this.driver.name}: exit handling failed`);
254
+ });
219
255
  });
220
256
  child.on('error', (err) => {
221
257
  if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
@@ -230,6 +266,24 @@ export class Tunnel {
230
266
  });
231
267
  }
232
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
+
233
287
  stop(): void {
234
288
  this.closed = true;
235
289
  this.child?.kill('SIGINT');
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "0.1.0-beta.55"
2
+ "version": "0.1.0-beta.56"
3
3
  }