@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 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>] [--tunnel] [--owner <address>]
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 the page at metro.box manages it through the
16
- link it prints; --owner names the one wallet that may sign in (remembered
17
- after the first start); --tunnel publishes the daemon through Tailscale
18
- Funnel at a permanent https://<machine>.<tailnet>.ts.net (Tailscale
19
- installed and signed in on this machine), so the link works from anywhere
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>] [--tunnel] [--owner <address>]';
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
- const tunnelArg = tunnelFlag(argv, i);
51
- if (tunnelArg) {
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, tunnel, owner };
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 --tunnel tailscale needs Tailscale on this machine.\n' +
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
- ...(opts.tunnel ? { METRO_TUNNEL: 'tailscale' } : {}),
132
- ...(opts.tailscaleBin === undefined ? {} : { METRO_TAILSCALE_BIN: opts.tailscaleBin }),
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, tunnel, owner } = parseServeArgs(argv);
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 = tunnel ? findTailscale() : undefined;
145
- process.stderr.write(`Starting a metro daemon of your own on http://127.0.0.1:${String(port)}\n`);
146
- return spawnPlan(() => servePlan({ runtime: prepareRuntime(), port, tunnel, owner, ...(tailscaleBin === undefined ? {} : { tailscaleBin }) }));
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.60",
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": {
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "0.1.0-beta.60"
2
+ "version": "0.1.0-beta.62"
3
3
  }