@stage-labs/metro 0.1.0-beta.50 → 0.1.0-beta.52
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/api.js +8 -142
- package/dist/cli.js +27 -131
- package/dist/control.js +5 -104
- package/dist/plugin.js +2 -4
- package/dist/runtime.js +18 -72
- package/dist/serve.js +3 -2
- package/dist/tail.js +7 -8
- package/dist/update.js +13 -1
- package/package.json +1 -1
- package/runtime/node_modules/@metro-labs/mcp/package.json +1 -2
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/agent-api.ts +3 -35
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/agent-connector-api.ts +7 -22
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/api-http.ts +4 -54
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/attach-serve.ts +1 -2
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/boot.ts +29 -208
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/connector-api.ts +0 -4
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/local-mode.ts +2 -37
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/paths.ts +0 -3
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/relay.ts +4 -6
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/session-apis.ts +3 -14
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/session.ts +0 -81
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/siwe-routes.ts +2 -2
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/update-api.ts +119 -0
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/vault-api.ts +42 -12
- package/runtime/node_modules/@metro-labs/mcp/src/db/account-attach.ts +1 -73
- package/runtime/node_modules/@metro-labs/mcp/src/db/agent-admin.ts +1 -172
- package/runtime/node_modules/@metro-labs/mcp/src/db/client.ts +0 -5
- package/runtime/node_modules/@metro-labs/mcp/src/db/connector-relay.ts +2 -89
- package/runtime/node_modules/@metro-labs/mcp/src/db/connectors.ts +3 -262
- package/runtime/node_modules/@metro-labs/mcp/src/db/local-connectors.ts +1 -1
- package/runtime/node_modules/@metro-labs/mcp/src/db/materialize.ts +2 -140
- package/runtime/node_modules/@metro-labs/mcp/src/db/schema.ts +1 -89
- package/runtime/node_modules/@metro-labs/mcp/src/db/users.ts +14 -41
- package/runtime/node_modules/@metro-labs/mcp/src/db/vault.ts +5 -5
- package/runtime/node_modules/@metro-labs/whatsapp/package.json +0 -2
- package/runtime/runtime.json +1 -1
- package/dist/prompt.js +0 -61
- package/dist/store.js +0 -93
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/agent-import.ts +0 -76
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/agent-pair.ts +0 -20
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/cli-pair-api.ts +0 -141
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/import-api.ts +0 -68
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/pair-codes.ts +0 -58
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/project-api.ts +0 -214
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/run-api.ts +0 -139
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/runtime-source.ts +0 -105
- package/runtime/node_modules/@metro-labs/mcp/src/db/agent-connectors.ts +0 -143
- package/runtime/node_modules/@metro-labs/mcp/src/db/projects.ts +0 -309
- package/runtime/node_modules/@metro-labs/mcp/src/db/runtime-admin.ts +0 -24
- package/runtime/node_modules/@metro-labs/mcp/src/db/runtimes.ts +0 -105
- package/runtime/node_modules/@metro-labs/mcp/src/db/whatsapp-login.ts +0 -18
package/dist/api.js
CHANGED
|
@@ -1,148 +1,14 @@
|
|
|
1
|
-
import { metroUrl, readRunTokens, readToken } from './store.js';
|
|
2
1
|
import { localAgents, localDaemonUp, localMcpServers, pickLocalAgent } from './local.js';
|
|
3
2
|
import { localUrl } from './runtime.js';
|
|
4
|
-
|
|
5
|
-
}
|
|
6
|
-
const TIMEOUT_MS = 20_000;
|
|
7
|
-
const LOOPBACK = new Set(['localhost', '127.0.0.1', '::1', '[::1]']);
|
|
8
|
-
export function carriesSecretsSafely(base) {
|
|
9
|
-
let url;
|
|
10
|
-
try {
|
|
11
|
-
url = new URL(base);
|
|
12
|
-
}
|
|
13
|
-
catch {
|
|
14
|
-
return false;
|
|
15
|
-
}
|
|
16
|
-
if (url.protocol === 'https:')
|
|
17
|
-
return true;
|
|
18
|
-
return url.protocol === 'http:' && LOOPBACK.has(url.hostname);
|
|
19
|
-
}
|
|
20
|
-
function tokenOrThrow() {
|
|
21
|
-
if (!carriesSecretsSafely(metroUrl()))
|
|
22
|
-
throw new Error(`refusing to send your session to ${metroUrl()} in the clear — use https, or a loopback address`);
|
|
23
|
-
const token = readToken() ?? soleRunToken();
|
|
24
|
-
if (token === null)
|
|
25
|
-
throw new NotSignedIn('not signed in — run `metro login`, or `metro start <agent-id>` on this machine');
|
|
26
|
-
return token;
|
|
27
|
-
}
|
|
28
|
-
function soleRunToken() {
|
|
29
|
-
const tokens = readRunTokens();
|
|
30
|
-
return tokens.length === 1 ? (tokens[0] ?? null) : null;
|
|
31
|
-
}
|
|
32
|
-
async function get(path) {
|
|
33
|
-
const auth = tokenOrThrow();
|
|
34
|
-
let res;
|
|
35
|
-
try {
|
|
36
|
-
res = await fetch(`${metroUrl()}${path}`, {
|
|
37
|
-
headers: { authorization: `Bearer ${auth}` },
|
|
38
|
-
signal: AbortSignal.timeout(TIMEOUT_MS),
|
|
39
|
-
});
|
|
40
|
-
}
|
|
41
|
-
catch {
|
|
42
|
-
throw new Error(`could not reach ${metroUrl()}`);
|
|
43
|
-
}
|
|
44
|
-
if (res.status === 401)
|
|
45
|
-
throw new NotSignedIn("metro refused this machine's sign-in — run `metro login` again");
|
|
46
|
-
if (!res.ok)
|
|
47
|
-
throw new Error(`metro answered ${String(res.status)}`);
|
|
48
|
-
return res.json();
|
|
49
|
-
}
|
|
50
|
-
function isRecord(value) {
|
|
51
|
-
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
52
|
-
}
|
|
53
|
-
async function post(path, body) {
|
|
54
|
-
if (!carriesSecretsSafely(metroUrl()))
|
|
55
|
-
throw new Error(`refusing to send your code to ${metroUrl()} in the clear — use https, or a loopback address`);
|
|
56
|
-
let res;
|
|
57
|
-
try {
|
|
58
|
-
res = await fetch(`${metroUrl()}${path}`, {
|
|
59
|
-
method: 'POST',
|
|
60
|
-
headers: { 'content-type': 'application/json' },
|
|
61
|
-
body: JSON.stringify(body),
|
|
62
|
-
signal: AbortSignal.timeout(TIMEOUT_MS),
|
|
63
|
-
});
|
|
64
|
-
}
|
|
65
|
-
catch {
|
|
66
|
-
throw new Error(`could not reach ${metroUrl()}`);
|
|
67
|
-
}
|
|
68
|
-
const parsed = await res.json().catch(() => null);
|
|
69
|
-
if (res.ok)
|
|
70
|
-
return parsed;
|
|
71
|
-
const detail = isRecord(parsed) ? parsed.error : undefined;
|
|
72
|
-
throw new Error(typeof detail === 'string' ? detail : `metro answered ${String(res.status)}`);
|
|
73
|
-
}
|
|
74
|
-
export class RuntimeRevoked extends Error {
|
|
75
|
-
}
|
|
76
|
-
export function keyOfRunConfig(body) {
|
|
77
|
-
const agent = isRecord(body) ? body.agent : undefined;
|
|
78
|
-
const key = isRecord(agent) ? agent.key : undefined;
|
|
79
|
-
if (typeof key !== 'string' || key === '')
|
|
80
|
-
throw new Error('this agent has no key — reset it on its page in the web UI');
|
|
81
|
-
return key;
|
|
82
|
-
}
|
|
83
|
-
export async function runConfigKey(token) {
|
|
84
|
-
if (!carriesSecretsSafely(metroUrl()))
|
|
85
|
-
throw new Error(`refusing to send this machine's authorization to ${metroUrl()} in the clear — use https, or a loopback address`);
|
|
86
|
-
let res;
|
|
87
|
-
try {
|
|
88
|
-
res = await fetch(`${metroUrl()}/api/run/config`, {
|
|
89
|
-
headers: { authorization: `Bearer ${token}` },
|
|
90
|
-
signal: AbortSignal.timeout(TIMEOUT_MS),
|
|
91
|
-
});
|
|
92
|
-
}
|
|
93
|
-
catch {
|
|
94
|
-
throw new Error(`could not reach ${metroUrl()}`);
|
|
95
|
-
}
|
|
96
|
-
if (res.status === 401 || res.status === 403 || res.status === 409)
|
|
97
|
-
throw new RuntimeRevoked('this machine no longer holds the agent — authorize it again from the agent page');
|
|
98
|
-
if (!res.ok)
|
|
99
|
-
throw new Error(`metro answered ${String(res.status)}`);
|
|
100
|
-
return keyOfRunConfig(await res.json());
|
|
101
|
-
}
|
|
102
|
-
export async function claimCode(code) {
|
|
103
|
-
const body = await post('/api/cli/claim', { code });
|
|
104
|
-
if (!isRecord(body) ||
|
|
105
|
-
typeof body.token !== 'string' ||
|
|
106
|
-
typeof body.subject !== 'string' ||
|
|
107
|
-
typeof body.agent !== 'string')
|
|
108
|
-
throw new Error('metro returned an unexpected response');
|
|
109
|
-
return { token: body.token, subject: body.subject, agent: body.agent };
|
|
110
|
-
}
|
|
111
|
-
export async function claimRuntime(code, label) {
|
|
112
|
-
const body = await post('/api/run/claim', { code, label });
|
|
113
|
-
if (!isRecord(body) ||
|
|
114
|
-
typeof body.token !== 'string' ||
|
|
115
|
-
typeof body.agent !== 'string' ||
|
|
116
|
-
typeof body.label !== 'string')
|
|
117
|
-
throw new Error('metro returned an unexpected response');
|
|
118
|
-
return { token: body.token, agent: body.agent, label: body.label };
|
|
119
|
-
}
|
|
3
|
+
const NO_DAEMON = `no metro daemon on ${localUrl()} — start one with: metro serve`;
|
|
120
4
|
export async function mcpServers(wanted) {
|
|
121
|
-
if (await localDaemonUp())
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
return localMcpServers(pickLocalAgent(agents, wanted));
|
|
125
|
-
}
|
|
126
|
-
const body = await get('/api/cli/mcp');
|
|
127
|
-
if (!isRecord(body) || typeof body.json !== 'string')
|
|
128
|
-
throw new Error('metro returned an unexpected response');
|
|
129
|
-
return body.json;
|
|
5
|
+
if (!(await localDaemonUp()))
|
|
6
|
+
throw new Error(NO_DAEMON);
|
|
7
|
+
return localMcpServers(pickLocalAgent(localAgents(), wanted));
|
|
130
8
|
}
|
|
131
9
|
export async function whoisAuthorized(wanted) {
|
|
132
|
-
if (await localDaemonUp())
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
return { subject: 'this machine', agent: agent.name, where: `${localUrl()} (local)` };
|
|
137
|
-
}
|
|
138
|
-
}
|
|
139
|
-
return { ...(await hostedIdentity()), where: metroUrl() };
|
|
140
|
-
}
|
|
141
|
-
async function hostedIdentity() {
|
|
142
|
-
const body = await get('/api/cli/session');
|
|
143
|
-
if (!isRecord(body) ||
|
|
144
|
-
typeof body.subject !== 'string' ||
|
|
145
|
-
typeof body.agent !== 'string')
|
|
146
|
-
throw new Error('metro returned an unexpected response');
|
|
147
|
-
return { subject: body.subject, agent: body.agent };
|
|
10
|
+
if (!(await localDaemonUp()))
|
|
11
|
+
throw new Error(NO_DAEMON);
|
|
12
|
+
const agent = pickLocalAgent(localAgents(), wanted);
|
|
13
|
+
return { agent: agent.name, where: localUrl() };
|
|
148
14
|
}
|
package/dist/cli.js
CHANGED
|
@@ -1,110 +1,53 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import { askSecret } from './prompt.js';
|
|
5
|
-
import { clearToken, credentialsPath, metroWebUrl, writeToken, } from './store.js';
|
|
6
|
-
import { detach, lockedBy, probe, runningPid, stopAll, tail } from './control.js';
|
|
2
|
+
import { mcpServers, whoisAuthorized } from './api.js';
|
|
3
|
+
import { stopAll } from './control.js';
|
|
7
4
|
import { tailEvents } from './tail.js';
|
|
8
|
-
import { assertAgentId, daemonPlan, localUrl, readRunToken, runDaemon, runtimeDir, runTokenState, writeRunToken, } from './runtime.js';
|
|
9
5
|
import { launchClaude } from './claude.js';
|
|
10
6
|
import { bedrock } from './bedrock.js';
|
|
11
|
-
import { installPlugin
|
|
7
|
+
import { installPlugin } from './plugin.js';
|
|
12
8
|
import { update } from './update.js';
|
|
13
9
|
import { serve } from './serve.js';
|
|
14
10
|
import { currentVersion } from './version.js';
|
|
15
|
-
const USAGE = `metro —
|
|
11
|
+
const USAGE = `metro — run your agent on this machine
|
|
16
12
|
|
|
17
|
-
metro start <agent-id> [--detach]
|
|
18
|
-
run that agent's stations on this machine
|
|
19
13
|
metro serve [--port <n>] [--tunnel] [--owner <address>]
|
|
20
|
-
run
|
|
21
|
-
here, and metro.box manages it through the
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
metro
|
|
27
|
-
is it running, and is it healthy
|
|
28
|
-
metro logs <agent-id> [-f]
|
|
29
|
-
show the detached daemon's log
|
|
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 adds a public https address through a
|
|
18
|
+
Cloudflare quick tunnel (needs cloudflared, no account) so the link works
|
|
19
|
+
from anywhere
|
|
20
|
+
metro stop stop the metro daemon on this machine
|
|
30
21
|
metro tail <agent-id>
|
|
31
22
|
follow this machine's inbound events, one JSON line each
|
|
32
|
-
metro login [code]
|
|
33
|
-
authorize this machine for one agent with a code from its page
|
|
34
|
-
metro logout forget this machine's sign-in
|
|
35
23
|
metro whoami [agent]
|
|
36
|
-
print the
|
|
24
|
+
print the agent this machine runs
|
|
37
25
|
metro mcp [agent]
|
|
38
|
-
print the mcpServers block of the agent's connectors
|
|
39
|
-
|
|
40
|
-
metro plugin set up the Claude Code plugin (connector servers + /metro:
|
|
26
|
+
print the mcpServers block of the agent's connectors, served through the
|
|
27
|
+
daemon's own relay (name the agent if several live here)
|
|
28
|
+
metro plugin set up the Claude Code plugin (connector servers + /metro:refresh)
|
|
41
29
|
metro claude [args...]
|
|
42
30
|
open Claude Code with the metro channel; every argument is passed through
|
|
43
31
|
metro bedrock [args...]
|
|
44
32
|
the same, with inference on Amazon Bedrock through a local proxy so the
|
|
45
33
|
channel still works (needs AWS_BEARER_TOKEN_BEDROCK and AWS_REGION)
|
|
46
|
-
metro update update to the newest published version
|
|
34
|
+
metro update update to the newest published version (--check only reports)
|
|
47
35
|
metro version print this CLI's version
|
|
48
36
|
|
|
49
|
-
Start Claude Code with
|
|
37
|
+
Start Claude Code with every connector, writing nothing to disk:
|
|
50
38
|
|
|
51
39
|
claude --mcp-config <(metro mcp)
|
|
52
40
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
a machine running metro start uses its run token
|
|
57
|
-
METRO_RUN_TOKEN use this runtime authorization instead of the stored one
|
|
41
|
+
METRO_WEBHOOK_PORT the daemon's port (default 8420)
|
|
42
|
+
METRO_AGENTS_DIR where the agents live (default ~/.metro/agents)
|
|
43
|
+
METRO_AGENT_KEY the key metro tail presents, instead of the agent file's
|
|
58
44
|
METRO_BEDROCK_MODEL
|
|
59
45
|
send every request to this Bedrock model id (default: derive from the
|
|
60
46
|
model Claude Code asks for, e.g. eu.anthropic.claude-sonnet-4-6)
|
|
61
47
|
METRO_RUNTIME_DIR run the daemon from this directory instead of the bundled one
|
|
62
48
|
`;
|
|
63
|
-
function
|
|
64
|
-
const
|
|
65
|
-
return name === '' ? 'unnamed machine' : name;
|
|
66
|
-
}
|
|
67
|
-
async function authorizeRuntime(agentId) {
|
|
68
|
-
process.stderr.write(`Authorize this machine with a pairing code for agent ${agentId} from ${metroWebUrl()}\n`);
|
|
69
|
-
const code = (await askSecret('Paste the code (input is hidden): ')).trim();
|
|
70
|
-
if (code === '')
|
|
71
|
-
throw new Error('no code given');
|
|
72
|
-
const claimed = await claimRuntime(code, hostLabel());
|
|
73
|
-
writeRunToken(claimed.agent, claimed.token);
|
|
74
|
-
process.stderr.write(`Authorized as '${claimed.label}'.\n`);
|
|
75
|
-
return claimed.token;
|
|
76
|
-
}
|
|
77
|
-
async function usableRunToken(agentId) {
|
|
78
|
-
const stored = readRunToken(agentId);
|
|
79
|
-
if (stored === null)
|
|
80
|
-
return authorizeRuntime(agentId);
|
|
81
|
-
const state = await runTokenState(stored);
|
|
82
|
-
if (state !== 'stale')
|
|
83
|
-
return stored;
|
|
84
|
-
process.stderr.write(`metro no longer recognises this machine as the holder of ${agentId}: ` +
|
|
85
|
-
'it was taken back, or another machine claimed it since. Authorize it again.\n');
|
|
86
|
-
return authorizeRuntime(agentId);
|
|
87
|
-
}
|
|
88
|
-
async function start(argv) {
|
|
89
|
-
const agentId = assertAgentId(argv[0]);
|
|
90
|
-
if (runningPid(agentId) !== null || lockedBy() !== null)
|
|
91
|
-
throw new Error('a metro daemon is already running on this machine. ' +
|
|
92
|
-
'Stop it first: metro stop');
|
|
93
|
-
const dir = runtimeDir();
|
|
94
|
-
const token = await usableRunToken(agentId);
|
|
95
|
-
const detached = argv.includes('--detach');
|
|
96
|
-
const plan = daemonPlan({ agentId, token, dir });
|
|
97
|
-
if (!detached)
|
|
98
|
-
return runDaemon(agentId, plan);
|
|
99
|
-
const pid = detach({ agentId, ...plan });
|
|
100
|
-
process.stderr.write(`metro is running for ${agentId} (pid ${String(pid)})\n` +
|
|
101
|
-
` logs: metro logs ${agentId} -f\n` +
|
|
102
|
-
` stop: metro stop ${agentId}\n`);
|
|
103
|
-
return 0;
|
|
104
|
-
}
|
|
105
|
-
async function stopDaemon(argv) {
|
|
106
|
-
const agentId = argv[0] === undefined ? undefined : assertAgentId(argv[0]);
|
|
107
|
-
const stopped = await stopAll(agentId);
|
|
49
|
+
async function stopDaemon() {
|
|
50
|
+
const stopped = await stopAll();
|
|
108
51
|
if (stopped.length === 0) {
|
|
109
52
|
process.stderr.write('no metro daemon is running on this machine\n');
|
|
110
53
|
return 1;
|
|
@@ -113,62 +56,15 @@ async function stopDaemon(argv) {
|
|
|
113
56
|
process.stderr.write(`Stopped metro (pid ${String(d.pid)}, via ${d.via})\n`);
|
|
114
57
|
return 0;
|
|
115
58
|
}
|
|
116
|
-
async function status(argv) {
|
|
117
|
-
const agentId = assertAgentId(argv[0]);
|
|
118
|
-
const pid = runningPid(agentId);
|
|
119
|
-
if (pid === null) {
|
|
120
|
-
process.stdout.write(`${agentId}: not running\n`);
|
|
121
|
-
return 1;
|
|
122
|
-
}
|
|
123
|
-
const health = await probe(localUrl());
|
|
124
|
-
process.stdout.write(`${agentId}: running (pid ${String(pid)}) · ` +
|
|
125
|
-
(health === null
|
|
126
|
-
? `not answering on ${localUrl()}\n`
|
|
127
|
-
: `healthy, up ${String(health.uptime)}s\n`));
|
|
128
|
-
return health === null ? 1 : 0;
|
|
129
|
-
}
|
|
130
|
-
function logs(argv) {
|
|
131
|
-
const agentId = assertAgentId(argv[0]);
|
|
132
|
-
return tail(agentId, argv.includes('-f') || argv.includes('--follow'));
|
|
133
|
-
}
|
|
134
|
-
async function pastedCode(given) {
|
|
135
|
-
if (given !== undefined && given.trim() !== '')
|
|
136
|
-
return given.trim();
|
|
137
|
-
process.stderr.write(`Get a pairing code for the agent from ${metroWebUrl()}\n`);
|
|
138
|
-
return askSecret('Paste the code here (input is hidden): ');
|
|
139
|
-
}
|
|
140
|
-
async function login(given) {
|
|
141
|
-
const { token, subject, agent } = await claimCode(await pastedCode(given));
|
|
142
|
-
writeToken(token);
|
|
143
|
-
process.stderr.write(`Authorized '${agent}' for ${subject}. Stored in ${credentialsPath()}\n`);
|
|
144
|
-
if (syncPluginServers())
|
|
145
|
-
process.stderr.write('Claude Code plugin refreshed — new sessions have the connectors; ' +
|
|
146
|
-
'run /reload-plugins in any session already open.\n');
|
|
147
|
-
}
|
|
148
|
-
async function whoami(wanted) {
|
|
149
|
-
const { subject, agent, where } = await whoisAuthorized(wanted);
|
|
150
|
-
process.stdout.write(`${subject} · agent '${agent}' on ${where}\n`);
|
|
151
|
-
}
|
|
152
59
|
const HELP = new Set([undefined, 'help', '--help', '-h']);
|
|
153
60
|
const COMMANDS = {
|
|
154
|
-
login: async () => {
|
|
155
|
-
await login(process.argv[3]);
|
|
156
|
-
return 0;
|
|
157
|
-
},
|
|
158
|
-
logout: async () => {
|
|
159
|
-
clearToken();
|
|
160
|
-
process.stderr.write('Signed out.\n');
|
|
161
|
-
return Promise.resolve(0);
|
|
162
|
-
},
|
|
163
61
|
whoami: async () => {
|
|
164
|
-
await
|
|
62
|
+
const { agent, where } = await whoisAuthorized(process.argv[3]);
|
|
63
|
+
process.stdout.write(`agent '${agent}' on ${where}\n`);
|
|
165
64
|
return 0;
|
|
166
65
|
},
|
|
167
|
-
start: () => start(process.argv.slice(3)),
|
|
168
66
|
serve: () => serve(process.argv.slice(3)),
|
|
169
|
-
stop:
|
|
170
|
-
status: () => status(process.argv.slice(3)),
|
|
171
|
-
logs: () => logs(process.argv.slice(3)),
|
|
67
|
+
stop: stopDaemon,
|
|
172
68
|
tail: () => tailEvents(process.argv.slice(3)),
|
|
173
69
|
mcp: async () => {
|
|
174
70
|
process.stdout.write(`${await mcpServers(process.argv[3])}\n`);
|
|
@@ -177,7 +73,7 @@ const COMMANDS = {
|
|
|
177
73
|
plugin: installPlugin,
|
|
178
74
|
claude: () => launchClaude(process.argv.slice(3)),
|
|
179
75
|
bedrock: () => bedrock(process.argv.slice(3)),
|
|
180
|
-
update,
|
|
76
|
+
update: () => update(process.argv.slice(3)),
|
|
181
77
|
version: async () => {
|
|
182
78
|
process.stdout.write(`${currentVersion()}\n`);
|
|
183
79
|
return Promise.resolve(0);
|
|
@@ -193,6 +89,6 @@ async function run(command) {
|
|
|
193
89
|
const code = await run(process.argv[2]).catch((err) => {
|
|
194
90
|
const message = err instanceof Error ? err.message : String(err);
|
|
195
91
|
process.stderr.write(`metro: ${message}\n`);
|
|
196
|
-
return
|
|
92
|
+
return 1;
|
|
197
93
|
});
|
|
198
94
|
process.exit(code);
|
package/dist/control.js
CHANGED
|
@@ -1,29 +1,13 @@
|
|
|
1
|
-
import { execFileSync
|
|
2
|
-
import {
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import { readFileSync, rmSync } from 'node:fs';
|
|
3
3
|
import { homedir } from 'node:os';
|
|
4
4
|
import { join } from 'node:path';
|
|
5
|
-
const STATE = () => join(homedir(), '.metro');
|
|
6
5
|
export function serveStateDir() {
|
|
7
6
|
const xdg = process.env.XDG_CACHE_HOME?.trim();
|
|
8
7
|
const base = xdg === undefined || xdg === '' ? join(homedir(), '.cache') : xdg;
|
|
9
8
|
return join(base, 'metro', 'serve');
|
|
10
9
|
}
|
|
11
10
|
const serveLockPath = () => join(serveStateDir(), '.tail-lock');
|
|
12
|
-
const pidPath = (agentId) => join(STATE(), `run-${agentId}.pid`);
|
|
13
|
-
export const logPath = (agentId) => join(STATE(), 'logs', `${agentId}.log`);
|
|
14
|
-
function ensureStateDirs() {
|
|
15
|
-
mkdirSync(STATE(), { recursive: true, mode: 0o700 });
|
|
16
|
-
mkdirSync(join(STATE(), 'logs'), { recursive: true, mode: 0o700 });
|
|
17
|
-
}
|
|
18
|
-
function readPid(agentId) {
|
|
19
|
-
try {
|
|
20
|
-
const pid = Number(readFileSync(pidPath(agentId), 'utf8').trim());
|
|
21
|
-
return Number.isInteger(pid) && pid > 0 ? pid : null;
|
|
22
|
-
}
|
|
23
|
-
catch {
|
|
24
|
-
return null;
|
|
25
|
-
}
|
|
26
|
-
}
|
|
27
11
|
function isAlive(pid) {
|
|
28
12
|
try {
|
|
29
13
|
process.kill(pid, 0);
|
|
@@ -33,37 +17,6 @@ function isAlive(pid) {
|
|
|
33
17
|
return false;
|
|
34
18
|
}
|
|
35
19
|
}
|
|
36
|
-
export function runningPid(agentId) {
|
|
37
|
-
const pid = readPid(agentId);
|
|
38
|
-
if (pid === null)
|
|
39
|
-
return null;
|
|
40
|
-
if (isAlive(pid))
|
|
41
|
-
return pid;
|
|
42
|
-
rmSync(pidPath(agentId), { force: true });
|
|
43
|
-
return null;
|
|
44
|
-
}
|
|
45
|
-
function writePid(agentId, pid) {
|
|
46
|
-
writeFileSync(pidPath(agentId), String(pid));
|
|
47
|
-
}
|
|
48
|
-
function clearPid(agentId) {
|
|
49
|
-
rmSync(pidPath(agentId), { force: true });
|
|
50
|
-
}
|
|
51
|
-
export function detach(opts) {
|
|
52
|
-
ensureStateDirs();
|
|
53
|
-
const out = openSync(logPath(opts.agentId), 'a');
|
|
54
|
-
const child = spawn(opts.command, opts.args, {
|
|
55
|
-
cwd: opts.cwd,
|
|
56
|
-
env: opts.env,
|
|
57
|
-
detached: true,
|
|
58
|
-
stdio: ['ignore', out, out],
|
|
59
|
-
});
|
|
60
|
-
child.unref();
|
|
61
|
-
const pid = child.pid ?? 0;
|
|
62
|
-
if (pid === 0)
|
|
63
|
-
throw new Error('could not start the metro daemon');
|
|
64
|
-
writePid(opts.agentId, pid);
|
|
65
|
-
return pid;
|
|
66
|
-
}
|
|
67
20
|
async function stopPid(pid, waitMs) {
|
|
68
21
|
process.kill(pid, 'SIGTERM');
|
|
69
22
|
const until = Date.now() + waitMs;
|
|
@@ -103,71 +56,19 @@ function holderOf(lockFile) {
|
|
|
103
56
|
}
|
|
104
57
|
export const lockedBy = () => holderOf(lockPath());
|
|
105
58
|
export const serveLockedBy = () => holderOf(serveLockPath());
|
|
106
|
-
function
|
|
107
|
-
const tracked = new Set();
|
|
108
|
-
if (agentId !== undefined)
|
|
109
|
-
tracked.add(agentId);
|
|
110
|
-
try {
|
|
111
|
-
for (const name of readdirSync(STATE())) {
|
|
112
|
-
const found = /^run-(.+)\.pid$/.exec(name);
|
|
113
|
-
if (found?.[1] !== undefined)
|
|
114
|
-
tracked.add(found[1]);
|
|
115
|
-
}
|
|
116
|
-
}
|
|
117
|
-
catch {
|
|
118
|
-
return tracked;
|
|
119
|
-
}
|
|
120
|
-
return tracked;
|
|
121
|
-
}
|
|
122
|
-
export async function stopAll(agentId, waitMs = 10_000) {
|
|
59
|
+
export async function stopAll(waitMs = 10_000) {
|
|
123
60
|
const stopped = [];
|
|
124
|
-
const seen = new Set();
|
|
125
|
-
for (const id of trackedAgents(agentId)) {
|
|
126
|
-
const pid = runningPid(id);
|
|
127
|
-
if (pid === null || seen.has(pid))
|
|
128
|
-
continue;
|
|
129
|
-
seen.add(pid);
|
|
130
|
-
await stopPid(pid, waitMs);
|
|
131
|
-
clearPid(id);
|
|
132
|
-
stopped.push({ pid, via: id });
|
|
133
|
-
}
|
|
134
61
|
const holder = lockedBy();
|
|
135
|
-
if (holder !== null
|
|
136
|
-
seen.add(holder);
|
|
62
|
+
if (holder !== null) {
|
|
137
63
|
await stopPid(holder, waitMs);
|
|
138
64
|
rmSync(lockPath(), { force: true });
|
|
139
65
|
stopped.push({ pid: holder, via: 'the machine lock' });
|
|
140
66
|
}
|
|
141
67
|
const served = serveLockedBy();
|
|
142
|
-
if (served !== null &&
|
|
68
|
+
if (served !== null && served !== holder) {
|
|
143
69
|
await stopPid(served, waitMs);
|
|
144
70
|
rmSync(serveLockPath(), { force: true });
|
|
145
71
|
stopped.push({ pid: served, via: 'metro serve' });
|
|
146
72
|
}
|
|
147
73
|
return stopped;
|
|
148
74
|
}
|
|
149
|
-
export function tail(agentId, follow) {
|
|
150
|
-
const path = logPath(agentId);
|
|
151
|
-
if (!existsSync(path))
|
|
152
|
-
throw new Error(`no log for ${agentId} yet — it is only written when started with --detach`);
|
|
153
|
-
const args = follow ? ['-n', '200', '-f', path] : ['-n', '200', path];
|
|
154
|
-
const child = spawn('tail', args, { stdio: ['ignore', 'inherit', 'inherit'] });
|
|
155
|
-
return new Promise((resolve) => {
|
|
156
|
-
child.on('exit', (code) => {
|
|
157
|
-
resolve(code ?? 0);
|
|
158
|
-
});
|
|
159
|
-
});
|
|
160
|
-
}
|
|
161
|
-
export async function probe(url) {
|
|
162
|
-
try {
|
|
163
|
-
const res = await fetch(`${url}/health`, {
|
|
164
|
-
signal: AbortSignal.timeout(3_000),
|
|
165
|
-
});
|
|
166
|
-
if (!res.ok)
|
|
167
|
-
return null;
|
|
168
|
-
return (await res.json());
|
|
169
|
-
}
|
|
170
|
-
catch {
|
|
171
|
-
return null;
|
|
172
|
-
}
|
|
173
|
-
}
|
package/dist/plugin.js
CHANGED
|
@@ -2,7 +2,6 @@ import { spawnSync } from 'node:child_process';
|
|
|
2
2
|
import { existsSync, readFileSync } from 'node:fs';
|
|
3
3
|
import { homedir } from 'node:os';
|
|
4
4
|
import { join } from 'node:path';
|
|
5
|
-
import { readToken } from './store.js';
|
|
6
5
|
const MARKETPLACE_REPO = 'bonustrack/metro';
|
|
7
6
|
const PLUGIN_SPEC = 'metro@metro';
|
|
8
7
|
export function installPathFrom(raw) {
|
|
@@ -48,7 +47,7 @@ function runClaude(args) {
|
|
|
48
47
|
const tolerable = (output) => /already|exists|latest version/i.test(output);
|
|
49
48
|
export function syncPluginServers() {
|
|
50
49
|
const path = pluginInstallPath();
|
|
51
|
-
if (path === null
|
|
50
|
+
if (path === null)
|
|
52
51
|
return false;
|
|
53
52
|
const script = join(path, 'bin', 'metro-plugin.mjs');
|
|
54
53
|
const refreshed = spawnSync(process.execPath, [script, 'refresh'], {
|
|
@@ -76,7 +75,6 @@ export async function installPlugin() {
|
|
|
76
75
|
'run /reload-plugins in any session already open.\n');
|
|
77
76
|
return 0;
|
|
78
77
|
}
|
|
79
|
-
process.stderr.write('
|
|
80
|
-
'or run /metro:login <code> inside Claude Code.\n');
|
|
78
|
+
process.stderr.write('Start the daemon on this machine (metro serve), then run /metro:refresh inside Claude Code.\n');
|
|
81
79
|
return Promise.resolve(0);
|
|
82
80
|
}
|
package/dist/runtime.js
CHANGED
|
@@ -1,10 +1,7 @@
|
|
|
1
1
|
import { spawn, spawnSync } from 'node:child_process';
|
|
2
|
-
import {
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
3
|
import { dirname, join } from 'node:path';
|
|
4
4
|
import { fileURLToPath } from 'node:url';
|
|
5
|
-
import { existsSync } from 'node:fs';
|
|
6
|
-
import { homedir } from 'node:os';
|
|
7
|
-
import { metroUrl } from './store.js';
|
|
8
5
|
const AGENT_RE = /^[A-Za-z0-9][A-Za-z0-9_-]{10}$/;
|
|
9
6
|
export const SERVER_ENTRY = join('node_modules', '@metro-labs', 'mcp', 'src', 'server.ts');
|
|
10
7
|
export class MissingRuntime extends Error {
|
|
@@ -28,77 +25,14 @@ export function assertAgentId(id) {
|
|
|
28
25
|
export function findBun() {
|
|
29
26
|
const found = spawnSync('bun', ['--version'], { stdio: 'ignore' });
|
|
30
27
|
if (found.error !== undefined || found.status !== 0)
|
|
31
|
-
throw new MissingBun('metro
|
|
28
|
+
throw new MissingBun('metro serve needs Bun, which is not on PATH.\n' +
|
|
32
29
|
'Install it with: curl -fsSL https://bun.sh/install | bash');
|
|
33
30
|
return 'bun';
|
|
34
31
|
}
|
|
35
|
-
function tokenPath(agentId) {
|
|
36
|
-
const xdg = process.env.XDG_CONFIG_HOME?.trim();
|
|
37
|
-
const base = xdg === undefined || xdg === '' ? join(homedir(), '.config') : xdg;
|
|
38
|
-
return join(base, 'metro', `runtime-${agentId}.json`);
|
|
39
|
-
}
|
|
40
|
-
export function readRunToken(agentId) {
|
|
41
|
-
const fromEnv = process.env.METRO_RUN_TOKEN?.trim();
|
|
42
|
-
if (fromEnv !== undefined && fromEnv !== '')
|
|
43
|
-
return fromEnv;
|
|
44
|
-
try {
|
|
45
|
-
const parsed = JSON.parse(readFileSync(tokenPath(agentId), 'utf8'));
|
|
46
|
-
if (typeof parsed.token !== 'string' || parsed.token === '')
|
|
47
|
-
return null;
|
|
48
|
-
return parsed.url === metroUrl() ? parsed.token : null;
|
|
49
|
-
}
|
|
50
|
-
catch {
|
|
51
|
-
return null;
|
|
52
|
-
}
|
|
53
|
-
}
|
|
54
|
-
export function writeRunToken(agentId, token) {
|
|
55
|
-
const path = tokenPath(agentId);
|
|
56
|
-
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
57
|
-
writeFileSync(path, JSON.stringify({ token, url: metroUrl() }, null, 2));
|
|
58
|
-
chmodSync(path, 0o600);
|
|
59
|
-
}
|
|
60
|
-
const PROBE_TIMEOUT_MS = 15_000;
|
|
61
|
-
export async function runTokenState(token) {
|
|
62
|
-
let res;
|
|
63
|
-
try {
|
|
64
|
-
res = await fetch(`${metroUrl()}/api/run/config`, {
|
|
65
|
-
headers: { authorization: `Bearer ${token}` },
|
|
66
|
-
signal: AbortSignal.timeout(PROBE_TIMEOUT_MS),
|
|
67
|
-
redirect: 'manual',
|
|
68
|
-
});
|
|
69
|
-
}
|
|
70
|
-
catch {
|
|
71
|
-
return 'unreachable';
|
|
72
|
-
}
|
|
73
|
-
await res.arrayBuffer().catch(() => undefined);
|
|
74
|
-
if (res.ok)
|
|
75
|
-
return 'ok';
|
|
76
|
-
return res.status === 401 || res.status === 403 || res.status === 409
|
|
77
|
-
? 'stale'
|
|
78
|
-
: 'unreachable';
|
|
79
|
-
}
|
|
80
32
|
export const localPort = () => Number(process.env.METRO_WEBHOOK_PORT) || 8420;
|
|
81
33
|
export const localUrl = () => `http://127.0.0.1:${String(localPort())}`;
|
|
82
|
-
export
|
|
83
|
-
|
|
84
|
-
command: findBun(),
|
|
85
|
-
args: [SERVER_ENTRY],
|
|
86
|
-
cwd: opts.dir,
|
|
87
|
-
env: {
|
|
88
|
-
...process.env,
|
|
89
|
-
METRO_RUN_TOKEN: opts.token,
|
|
90
|
-
METRO_AGENT: opts.agentId,
|
|
91
|
-
METRO_URL: metroUrl(),
|
|
92
|
-
METRO_HTTP_HOST: process.env.METRO_HTTP_HOST ?? '127.0.0.1',
|
|
93
|
-
METRO_TRAINS_DIR: join(opts.dir, 'trains'),
|
|
94
|
-
},
|
|
95
|
-
};
|
|
96
|
-
}
|
|
97
|
-
export function runDaemon(agentId, plan) {
|
|
98
|
-
process.stderr.write(`Starting metro for ${agentId}\n`);
|
|
99
|
-
return spawnPlan(plan);
|
|
100
|
-
}
|
|
101
|
-
export function spawnPlan(plan) {
|
|
34
|
+
export const RESTART_CODE = 75;
|
|
35
|
+
function spawnOnce(plan) {
|
|
102
36
|
const child = spawn(plan.command, plan.args, {
|
|
103
37
|
cwd: plan.cwd,
|
|
104
38
|
env: plan.env,
|
|
@@ -107,11 +41,23 @@ export function spawnPlan(plan) {
|
|
|
107
41
|
const relay = (sig) => () => {
|
|
108
42
|
child.kill(sig);
|
|
109
43
|
};
|
|
110
|
-
|
|
111
|
-
|
|
44
|
+
const onInt = relay('SIGINT');
|
|
45
|
+
const onTerm = relay('SIGTERM');
|
|
46
|
+
process.on('SIGINT', onInt);
|
|
47
|
+
process.on('SIGTERM', onTerm);
|
|
112
48
|
return new Promise((resolve) => {
|
|
113
49
|
child.on('exit', (code) => {
|
|
50
|
+
process.off('SIGINT', onInt);
|
|
51
|
+
process.off('SIGTERM', onTerm);
|
|
114
52
|
resolve(code ?? 0);
|
|
115
53
|
});
|
|
116
54
|
});
|
|
117
55
|
}
|
|
56
|
+
export async function spawnPlan(plan) {
|
|
57
|
+
for (;;) {
|
|
58
|
+
const code = await spawnOnce(plan());
|
|
59
|
+
if (code !== RESTART_CODE)
|
|
60
|
+
return code;
|
|
61
|
+
process.stderr.write('metro was updated; restarting the daemon on the new version\n');
|
|
62
|
+
}
|
|
63
|
+
}
|
package/dist/serve.js
CHANGED
|
@@ -85,6 +85,7 @@ export function servePlan(opts) {
|
|
|
85
85
|
...env,
|
|
86
86
|
METRO_MODE: 'local',
|
|
87
87
|
METRO_VERSION: currentVersion(),
|
|
88
|
+
METRO_CLI_BIN: process.argv[1] ?? '',
|
|
88
89
|
METRO_WEBHOOK_PORT: String(opts.port),
|
|
89
90
|
METRO_HTTP_HOST: process.env.METRO_HTTP_HOST ?? '127.0.0.1',
|
|
90
91
|
METRO_TRAINS_DIR: join(opts.dir, 'trains'),
|
|
@@ -103,7 +104,7 @@ export function serve(argv) {
|
|
|
103
104
|
requireOwner(owner);
|
|
104
105
|
if (tunnel)
|
|
105
106
|
findCloudflared();
|
|
106
|
-
const
|
|
107
|
+
const dir = runtimeDir();
|
|
107
108
|
process.stderr.write(`Starting a metro daemon of your own on http://127.0.0.1:${String(port)}\n`);
|
|
108
|
-
return spawnPlan(
|
|
109
|
+
return spawnPlan(() => servePlan({ dir, port, tunnel, owner }));
|
|
109
110
|
}
|