@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.
|
|
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
|
|
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:
|
|
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
|
-
|
|
216
|
-
|
|
217
|
-
|
|
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');
|
package/runtime/runtime.json
CHANGED