@stage-labs/metro 0.1.0-beta.55 → 0.1.0-beta.57
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.57",
|
|
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 =>
|
|
@@ -65,18 +100,23 @@ export const currentTunnelUrl = (): string | null => liveUrl;
|
|
|
65
100
|
|
|
66
101
|
export type Resolves = (host: string) => Promise<boolean>;
|
|
67
102
|
|
|
68
|
-
const PUBLIC_RESOLVERS = ['1.1.1.1', '
|
|
103
|
+
const PUBLIC_RESOLVERS = ['1.1.1.1', '8.8.8.8'];
|
|
69
104
|
const RESOLVE_EVERY_MS = 3_000;
|
|
70
105
|
const RESOLVE_GIVE_UP_MS = 180_000;
|
|
71
106
|
|
|
72
|
-
|
|
107
|
+
async function resolvesAt(server: string, host: string): Promise<boolean> {
|
|
73
108
|
const resolver = new Resolver();
|
|
74
|
-
resolver.setServers(
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
109
|
+
resolver.setServers([server]);
|
|
110
|
+
const answers = await Promise.all([
|
|
111
|
+
resolver.resolve4(host).catch(() => []),
|
|
112
|
+
resolver.resolve6(host).catch(() => []),
|
|
113
|
+
]);
|
|
114
|
+
return answers.some((a) => a.length > 0);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export async function resolvesAtCloudflare(host: string): Promise<boolean> {
|
|
118
|
+
const found = await Promise.all(PUBLIC_RESOLVERS.map((server) => resolvesAt(server, host)));
|
|
119
|
+
return found.some(Boolean);
|
|
80
120
|
}
|
|
81
121
|
|
|
82
122
|
async function untilResolvable(host: string, resolves: Resolves): Promise<boolean> {
|
|
@@ -148,6 +188,7 @@ export class Tunnel {
|
|
|
148
188
|
private child: ChildProcess | null = null;
|
|
149
189
|
private closed = false;
|
|
150
190
|
private output: string[] = [];
|
|
191
|
+
private restartDelay = RESTART_DELAY_MS;
|
|
151
192
|
|
|
152
193
|
constructor(
|
|
153
194
|
private driver: TunnelDriver,
|
|
@@ -159,6 +200,7 @@ export class Tunnel {
|
|
|
159
200
|
const url = this.driver.urlIn(text);
|
|
160
201
|
if (url === null || url === liveUrl) return;
|
|
161
202
|
liveUrl = url;
|
|
203
|
+
this.restartDelay = RESTART_DELAY_MS;
|
|
162
204
|
if (!this.driver.waitsForDns) {
|
|
163
205
|
log.info({ url }, `${this.driver.name} up`);
|
|
164
206
|
this.onUrl(url);
|
|
@@ -212,10 +254,9 @@ export class Tunnel {
|
|
|
212
254
|
this.child = null;
|
|
213
255
|
liveUrl = null;
|
|
214
256
|
if (this.closed) return;
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
}, RESTART_DELAY_MS);
|
|
257
|
+
this.afterExit(code).catch((err: unknown) => {
|
|
258
|
+
log.warn({ err: errMsg(err) }, `${this.driver.name}: exit handling failed`);
|
|
259
|
+
});
|
|
219
260
|
});
|
|
220
261
|
child.on('error', (err) => {
|
|
221
262
|
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
|
|
@@ -230,6 +271,24 @@ export class Tunnel {
|
|
|
230
271
|
});
|
|
231
272
|
}
|
|
232
273
|
|
|
274
|
+
private async afterExit(code: number | null): Promise<void> {
|
|
275
|
+
const output = this.output.join('\n');
|
|
276
|
+
if (this.driver.adopt !== undefined && TAKEN_RE.test(output)) {
|
|
277
|
+
const found = await this.driver.adopt();
|
|
278
|
+
if (this.closed) return;
|
|
279
|
+
if (found.url !== null) {
|
|
280
|
+
log.info({ url: found.url }, `${this.driver.name}: ${found.hint}`);
|
|
281
|
+
this.noticeUrl(found.url);
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
log.error({ output }, `${this.driver.name}: ${found.hint}`);
|
|
285
|
+
} else log.warn({ code, output: this.output.slice(-5).join('\n') }, `${this.driver.name} exited; restarting`);
|
|
286
|
+
setTimeout(() => {
|
|
287
|
+
this.start();
|
|
288
|
+
}, this.restartDelay);
|
|
289
|
+
this.restartDelay = Math.min(this.restartDelay * 2, RESTART_DELAY_MAX_MS);
|
|
290
|
+
}
|
|
291
|
+
|
|
233
292
|
stop(): void {
|
|
234
293
|
this.closed = true;
|
|
235
294
|
this.child?.kill('SIGINT');
|
package/runtime/runtime.json
CHANGED