@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 +56 -7
- package/package.json +1 -1
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/boot.ts +4 -3
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/connect-hint.ts +4 -2
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/tunnel.ts +87 -24
- package/runtime/runtime.json +1 -1
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 =
|
|
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
|
-
|
|
32
|
-
|
|
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 ? {
|
|
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.
|
|
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 {
|
|
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
|
|
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${
|
|
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
|
-
|
|
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
|
|
12
|
-
|
|
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
|
|
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 =
|
|
159
|
+
const url = this.driver.urlIn(text);
|
|
120
160
|
if (url === null || url === liveUrl) return;
|
|
121
161
|
liveUrl = url;
|
|
122
|
-
|
|
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) },
|
|
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 },
|
|
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({
|
|
138
|
-
|
|
139
|
-
|
|
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
|
-
|
|
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 },
|
|
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
|
-
|
|
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) },
|
|
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
|
}
|
package/runtime/runtime.json
CHANGED