@stage-labs/metro 0.1.0-beta.60 → 0.1.0-beta.62
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/cli.js +6 -6
- package/dist/node-name.js +51 -0
- package/dist/serve.js +14 -34
- package/package.json +1 -1
- package/runtime/runtime.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -10,13 +10,13 @@ import { serve } from './serve.js';
|
|
|
10
10
|
import { currentVersion } from './version.js';
|
|
11
11
|
const USAGE = `metro — run your agent on this machine
|
|
12
12
|
|
|
13
|
-
metro serve [--port <n>] [--
|
|
13
|
+
metro serve [--port <n>] [--owner <address>]
|
|
14
14
|
run the daemon: the agent, its channels and its connectors live in
|
|
15
|
-
~/.metro/agents here, and
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
15
|
+
~/.metro/agents here, and it is published through Tailscale Funnel at a
|
|
16
|
+
permanent https://<machine>.<tailnet>.ts.net (Tailscale installed and
|
|
17
|
+
signed in on this machine); the page at metro.box manages it through the
|
|
18
|
+
link it prints; --owner names the one wallet that may sign in, needed on
|
|
19
|
+
the first start only
|
|
20
20
|
metro stop stop the metro daemon on this machine
|
|
21
21
|
metro tail <agent-id>
|
|
22
22
|
follow this machine's inbound events, one JSON line each
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import { randomBytes } from 'node:crypto';
|
|
3
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { agentsDir } from './local.js';
|
|
6
|
+
const NODE_FILE = '.node';
|
|
7
|
+
const ALPHABET = 'abcdefghjkmnpqrstuvwxyz23456789';
|
|
8
|
+
const NAME_RE = /^metro-[a-z0-9]{6}$/;
|
|
9
|
+
export function newNodeName() {
|
|
10
|
+
const bytes = randomBytes(6);
|
|
11
|
+
return `metro-${[...bytes].map((b) => ALPHABET[b % ALPHABET.length] ?? 'x').join('')}`;
|
|
12
|
+
}
|
|
13
|
+
export function nodeName(dir = agentsDir()) {
|
|
14
|
+
const path = join(dir, NODE_FILE);
|
|
15
|
+
if (existsSync(path)) {
|
|
16
|
+
const held = readFileSync(path, 'utf8').trim();
|
|
17
|
+
if (NAME_RE.test(held))
|
|
18
|
+
return held;
|
|
19
|
+
}
|
|
20
|
+
const name = newNodeName();
|
|
21
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
22
|
+
writeFileSync(path, `${name}\n`, { mode: 0o600 });
|
|
23
|
+
return name;
|
|
24
|
+
}
|
|
25
|
+
export function currentNodeLabel(bin) {
|
|
26
|
+
const run = spawnSync(bin, ['status', '--json'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
|
|
27
|
+
if (run.error !== undefined || run.status !== 0)
|
|
28
|
+
return null;
|
|
29
|
+
try {
|
|
30
|
+
const parsed = JSON.parse(run.stdout);
|
|
31
|
+
const dns = typeof parsed.Self?.DNSName === 'string' ? parsed.Self.DNSName : '';
|
|
32
|
+
const label = dns.split('.')[0] ?? '';
|
|
33
|
+
return label === '' ? null : label.toLowerCase();
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
export function ensureNodeName(bin, dir = agentsDir()) {
|
|
40
|
+
const wanted = nodeName(dir);
|
|
41
|
+
if (currentNodeLabel(bin) === wanted)
|
|
42
|
+
return wanted;
|
|
43
|
+
const run = spawnSync(bin, ['set', '--hostname', wanted], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
|
|
44
|
+
if (run.error !== undefined || run.status !== 0) {
|
|
45
|
+
const said = run.stderr.trim();
|
|
46
|
+
const reason = said === '' ? (run.error?.message ?? `exit ${String(run.status)}`) : said;
|
|
47
|
+
throw new Error(`could not name this machine ${wanted} on your tailnet (${reason}).\n` +
|
|
48
|
+
`Run it once yourself, then start again: sudo tailscale set --hostname ${wanted}`);
|
|
49
|
+
}
|
|
50
|
+
return wanted;
|
|
51
|
+
}
|
package/dist/serve.js
CHANGED
|
@@ -6,12 +6,13 @@ import { currentVersion } from './version.js';
|
|
|
6
6
|
import { serveLockedBy, serveStateDir } from './control.js';
|
|
7
7
|
import { findBun, localPort, SERVER_ENTRY, spawnPlan } from './runtime.js';
|
|
8
8
|
import { prepareRuntime } from './runtime-install.js';
|
|
9
|
+
import { ensureNodeName } from './node-name.js';
|
|
9
10
|
const SCRUBBED = new Set(['METRO_RUN_TOKEN', 'METRO_AGENT', 'DATABASE_URL']);
|
|
10
11
|
const PORT_FLAG = /^--port=(.*)$/;
|
|
11
12
|
const OWNER_FLAG = /^--owner=(.*)$/;
|
|
12
13
|
const ADDRESS = /^0x[0-9a-fA-F]{40}$/;
|
|
13
|
-
const USAGE = 'usage: metro serve [--port <n>] [--
|
|
14
|
-
const TUNNEL_FLAG = /^--tunnel
|
|
14
|
+
const USAGE = 'usage: metro serve [--port <n>] [--owner <address>]';
|
|
15
|
+
const TUNNEL_FLAG = /^--(no-)?tunnel(=.*)?$/;
|
|
15
16
|
const TAILSCALE_APP = '/Applications/Tailscale.app/Contents/MacOS/Tailscale';
|
|
16
17
|
function portOf(raw) {
|
|
17
18
|
const n = Number(raw);
|
|
@@ -19,23 +20,6 @@ function portOf(raw) {
|
|
|
19
20
|
throw new Error(`'${raw ?? ''}' is not a port — ${USAGE}`);
|
|
20
21
|
return n;
|
|
21
22
|
}
|
|
22
|
-
function tunnelOf(raw) {
|
|
23
|
-
if (raw === undefined || raw === '' || raw === 'tailscale')
|
|
24
|
-
return true;
|
|
25
|
-
throw new Error(`'${raw}' is not a tunnel metro knows; the public address is Tailscale Funnel — ${USAGE}`);
|
|
26
|
-
}
|
|
27
|
-
function tunnelFlag(argv, i) {
|
|
28
|
-
const arg = argv[i] ?? '';
|
|
29
|
-
const inline = TUNNEL_FLAG.exec(arg);
|
|
30
|
-
if (inline)
|
|
31
|
-
return { consumed: Number(tunnelOf(inline[1])) - 1 };
|
|
32
|
-
if (arg !== '--tunnel')
|
|
33
|
-
return null;
|
|
34
|
-
const next = argv[i + 1];
|
|
35
|
-
const takesValue = next !== undefined && !next.startsWith('-');
|
|
36
|
-
tunnelOf(takesValue ? next : undefined);
|
|
37
|
-
return { consumed: takesValue ? 1 : 0 };
|
|
38
|
-
}
|
|
39
23
|
function ownerOf(raw) {
|
|
40
24
|
if (raw === undefined || !ADDRESS.test(raw))
|
|
41
25
|
throw new Error(`'${raw ?? ''}' is not an Ethereum address — ${USAGE}`);
|
|
@@ -43,16 +27,11 @@ function ownerOf(raw) {
|
|
|
43
27
|
}
|
|
44
28
|
export function parseServeArgs(argv) {
|
|
45
29
|
let port = localPort();
|
|
46
|
-
let tunnel = false;
|
|
47
30
|
let owner = null;
|
|
48
31
|
for (let i = 0; i < argv.length; i += 1) {
|
|
49
32
|
const arg = argv[i] ?? '';
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
tunnel = true;
|
|
53
|
-
i += tunnelArg.consumed;
|
|
54
|
-
continue;
|
|
55
|
-
}
|
|
33
|
+
if (TUNNEL_FLAG.test(arg))
|
|
34
|
+
throw new Error('--tunnel is gone: metro serve always publishes through Tailscale Funnel — ' + USAGE);
|
|
56
35
|
const inlineOwner = OWNER_FLAG.exec(arg);
|
|
57
36
|
if (inlineOwner) {
|
|
58
37
|
owner = ownerOf(inlineOwner[1]);
|
|
@@ -75,7 +54,7 @@ export function parseServeArgs(argv) {
|
|
|
75
54
|
}
|
|
76
55
|
throw new Error(`unknown argument '${arg}' — ${USAGE}`);
|
|
77
56
|
}
|
|
78
|
-
return { port,
|
|
57
|
+
return { port, owner };
|
|
79
58
|
}
|
|
80
59
|
export function requireOwner(owner, dir = agentsDir()) {
|
|
81
60
|
if (owner !== null || existsSync(join(dir, '.owner')))
|
|
@@ -100,7 +79,7 @@ function tailscaleStatus(bin) {
|
|
|
100
79
|
export function findTailscale(candidates = ['tailscale', TAILSCALE_APP]) {
|
|
101
80
|
const bin = candidates.find((c) => spawnSync(c, ['version'], { stdio: 'ignore' }).status === 0);
|
|
102
81
|
if (bin === undefined)
|
|
103
|
-
throw new Error('metro serve
|
|
82
|
+
throw new Error('metro serve needs Tailscale on this machine: the daemon is published through Tailscale Funnel.\n' +
|
|
104
83
|
'macOS: brew install --cask tailscale (or the App Store app)\n' +
|
|
105
84
|
'Linux: curl -fsSL https://tailscale.com/install.sh | sh\n' +
|
|
106
85
|
'Then sign the machine in: tailscale up');
|
|
@@ -128,20 +107,21 @@ export function servePlan(opts) {
|
|
|
128
107
|
? {}
|
|
129
108
|
: { METRO_RUNTIME_STORE: opts.runtime.dir, METRO_RUNTIME_MANIFEST: opts.runtime.manifest }),
|
|
130
109
|
METRO_STATE_DIR: process.env.METRO_STATE_DIR ?? serveStateDir(),
|
|
131
|
-
|
|
132
|
-
|
|
110
|
+
METRO_TUNNEL: 'tailscale',
|
|
111
|
+
METRO_TAILSCALE_BIN: opts.tailscaleBin,
|
|
133
112
|
...(opts.owner === null ? {} : { METRO_OWNER: opts.owner }),
|
|
134
113
|
},
|
|
135
114
|
};
|
|
136
115
|
}
|
|
137
116
|
export function serve(argv) {
|
|
138
|
-
const { port,
|
|
117
|
+
const { port, owner } = parseServeArgs(argv);
|
|
139
118
|
const running = serveLockedBy();
|
|
140
119
|
if (running !== null)
|
|
141
120
|
throw new Error(`a metro serve daemon is already running on this machine (pid ${String(running)}). ` +
|
|
142
121
|
'Stop it first: metro stop');
|
|
143
122
|
requireOwner(owner);
|
|
144
|
-
const tailscaleBin =
|
|
145
|
-
|
|
146
|
-
|
|
123
|
+
const tailscaleBin = findTailscale();
|
|
124
|
+
const node = ensureNodeName(tailscaleBin);
|
|
125
|
+
process.stderr.write(`Starting a metro daemon of your own on http://127.0.0.1:${String(port)}, published as ${node} on your tailnet\n`);
|
|
126
|
+
return spawnPlan(() => servePlan({ runtime: prepareRuntime(), port, owner, tailscaleBin }));
|
|
147
127
|
}
|
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.62",
|
|
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": {
|
package/runtime/runtime.json
CHANGED