@stage-labs/metro 0.1.0-beta.22 → 0.1.0-beta.23
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
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { hostname } from 'node:os';
|
|
3
3
|
import { claimCode, claimRuntime, mcpServers, NotSignedIn, whoisAuthorized, } from './api.js';
|
|
4
|
-
import {
|
|
4
|
+
import { askSecret } from './prompt.js';
|
|
5
5
|
import { clearToken, credentialsPath, metroUrl, metroWebUrl, writeToken, } from './store.js';
|
|
6
|
-
import { detach, probe, runningPid,
|
|
6
|
+
import { detach, lockedBy, probe, runningPid, stopAll, tail } from './control.js';
|
|
7
7
|
import { assertAgentId, daemonPlan, localUrl, readRunToken, runDaemon, runtimeDir, writeRunToken, } from './runtime.js';
|
|
8
8
|
import { update } from './update.js';
|
|
9
9
|
import { currentVersion } from './version.js';
|
|
@@ -11,8 +11,8 @@ const USAGE = `metro — the command line for your MCP connectors
|
|
|
11
11
|
|
|
12
12
|
metro start <agent-id> [--detach]
|
|
13
13
|
run that agent's stations on this machine
|
|
14
|
-
metro stop
|
|
15
|
-
stop
|
|
14
|
+
metro stop [agent-id]
|
|
15
|
+
stop every metro daemon on this machine, however it was started
|
|
16
16
|
metro status <agent-id>
|
|
17
17
|
is it running, and is it healthy
|
|
18
18
|
metro logs <agent-id> [-f]
|
|
@@ -40,7 +40,7 @@ function hostLabel() {
|
|
|
40
40
|
}
|
|
41
41
|
async function authorizeRuntime(agentId) {
|
|
42
42
|
process.stderr.write(`Authorize this machine at ${metroWebUrl()}/#/authorize/${agentId}\n`);
|
|
43
|
-
const code = (await
|
|
43
|
+
const code = (await askSecret('Paste the code (input is hidden): ')).trim();
|
|
44
44
|
if (code === '')
|
|
45
45
|
throw new Error('no code given');
|
|
46
46
|
const claimed = await claimRuntime(code, hostLabel());
|
|
@@ -50,8 +50,9 @@ async function authorizeRuntime(agentId) {
|
|
|
50
50
|
}
|
|
51
51
|
async function start(argv) {
|
|
52
52
|
const agentId = assertAgentId(argv[0]);
|
|
53
|
-
if (runningPid(agentId) !== null)
|
|
54
|
-
throw new Error(
|
|
53
|
+
if (runningPid(agentId) !== null || lockedBy() !== null)
|
|
54
|
+
throw new Error('a metro daemon is already running on this machine. ' +
|
|
55
|
+
'Stop it first: metro stop');
|
|
55
56
|
const dir = runtimeDir();
|
|
56
57
|
const token = readRunToken(agentId) ?? (await authorizeRuntime(agentId));
|
|
57
58
|
const detached = argv.includes('--detach');
|
|
@@ -65,10 +66,15 @@ async function start(argv) {
|
|
|
65
66
|
return 0;
|
|
66
67
|
}
|
|
67
68
|
async function stopDaemon(argv) {
|
|
68
|
-
const agentId = assertAgentId(argv[0]);
|
|
69
|
-
const stopped = await
|
|
70
|
-
|
|
71
|
-
|
|
69
|
+
const agentId = argv[0] === undefined ? undefined : assertAgentId(argv[0]);
|
|
70
|
+
const stopped = await stopAll(agentId);
|
|
71
|
+
if (stopped.length === 0) {
|
|
72
|
+
process.stderr.write('no metro daemon is running on this machine\n');
|
|
73
|
+
return 1;
|
|
74
|
+
}
|
|
75
|
+
for (const d of stopped)
|
|
76
|
+
process.stderr.write(`Stopped metro (pid ${String(d.pid)}, via ${d.via})\n`);
|
|
77
|
+
return 0;
|
|
72
78
|
}
|
|
73
79
|
async function status(argv) {
|
|
74
80
|
const agentId = assertAgentId(argv[0]);
|
|
@@ -90,7 +96,7 @@ function logs(argv) {
|
|
|
90
96
|
}
|
|
91
97
|
async function login() {
|
|
92
98
|
process.stderr.write(`Choose a connector collection at ${metroWebUrl()}/#/authorize\n`);
|
|
93
|
-
const { token, email, collection } = await claimCode(await
|
|
99
|
+
const { token, email, collection } = await claimCode(await askSecret('Paste the code here (input is hidden): '));
|
|
94
100
|
writeToken(token);
|
|
95
101
|
process.stderr.write(`Authorized '${collection}' for ${email}. Stored in ${credentialsPath()}\n`);
|
|
96
102
|
}
|
package/dist/control.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { spawn } from 'node:child_process';
|
|
2
|
-
import { existsSync, mkdirSync, openSync, readFileSync, rmSync, writeFileSync, } from 'node:fs';
|
|
1
|
+
import { execFileSync, spawn } from 'node:child_process';
|
|
2
|
+
import { existsSync, mkdirSync, openSync, readdirSync, readFileSync, rmSync, writeFileSync, } from 'node:fs';
|
|
3
3
|
import { homedir } from 'node:os';
|
|
4
4
|
import { join } from 'node:path';
|
|
5
5
|
const STATE = () => join(homedir(), '.metro');
|
|
@@ -58,22 +58,78 @@ export function detach(opts) {
|
|
|
58
58
|
writePid(opts.agentId, pid);
|
|
59
59
|
return pid;
|
|
60
60
|
}
|
|
61
|
-
|
|
62
|
-
const pid = runningPid(agentId);
|
|
63
|
-
if (pid === null)
|
|
64
|
-
return false;
|
|
61
|
+
async function stopPid(pid, waitMs) {
|
|
65
62
|
process.kill(pid, 'SIGTERM');
|
|
66
63
|
const until = Date.now() + waitMs;
|
|
67
64
|
while (Date.now() < until) {
|
|
68
|
-
if (!isAlive(pid))
|
|
69
|
-
|
|
70
|
-
return true;
|
|
71
|
-
}
|
|
65
|
+
if (!isAlive(pid))
|
|
66
|
+
return;
|
|
72
67
|
await new Promise((r) => setTimeout(r, 100));
|
|
73
68
|
}
|
|
74
69
|
process.kill(pid, 'SIGKILL');
|
|
75
|
-
|
|
76
|
-
|
|
70
|
+
}
|
|
71
|
+
const lockPath = () => process.env.METRO_STATE_DIR?.trim()
|
|
72
|
+
? join(process.env.METRO_STATE_DIR.trim(), '.tail-lock')
|
|
73
|
+
: join(homedir(), '.cache', 'metro', '.tail-lock');
|
|
74
|
+
function commandOf(pid) {
|
|
75
|
+
try {
|
|
76
|
+
return execFileSync('ps', ['-o', 'command=', '-p', String(pid)], {
|
|
77
|
+
encoding: 'utf8',
|
|
78
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
return '';
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
const looksLikeMetro = (command) => command.includes('server.ts') || command.includes('metro');
|
|
86
|
+
export function lockedBy() {
|
|
87
|
+
let pid;
|
|
88
|
+
try {
|
|
89
|
+
pid = Number(readFileSync(lockPath(), 'utf8').trim());
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
if (!Number.isInteger(pid) || pid <= 0 || !isAlive(pid))
|
|
95
|
+
return null;
|
|
96
|
+
return looksLikeMetro(commandOf(pid)) ? pid : null;
|
|
97
|
+
}
|
|
98
|
+
function trackedAgents(agentId) {
|
|
99
|
+
const tracked = new Set();
|
|
100
|
+
if (agentId !== undefined)
|
|
101
|
+
tracked.add(agentId);
|
|
102
|
+
try {
|
|
103
|
+
for (const name of readdirSync(STATE())) {
|
|
104
|
+
const found = /^run-(.+)\.pid$/.exec(name);
|
|
105
|
+
if (found?.[1] !== undefined)
|
|
106
|
+
tracked.add(found[1]);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
return tracked;
|
|
111
|
+
}
|
|
112
|
+
return tracked;
|
|
113
|
+
}
|
|
114
|
+
export async function stopAll(agentId, waitMs = 10_000) {
|
|
115
|
+
const stopped = [];
|
|
116
|
+
const seen = new Set();
|
|
117
|
+
for (const id of trackedAgents(agentId)) {
|
|
118
|
+
const pid = runningPid(id);
|
|
119
|
+
if (pid === null || seen.has(pid))
|
|
120
|
+
continue;
|
|
121
|
+
seen.add(pid);
|
|
122
|
+
await stopPid(pid, waitMs);
|
|
123
|
+
clearPid(id);
|
|
124
|
+
stopped.push({ pid, via: id });
|
|
125
|
+
}
|
|
126
|
+
const holder = lockedBy();
|
|
127
|
+
if (holder !== null && !seen.has(holder)) {
|
|
128
|
+
await stopPid(holder, waitMs);
|
|
129
|
+
rmSync(lockPath(), { force: true });
|
|
130
|
+
stopped.push({ pid: holder, via: 'the machine lock' });
|
|
131
|
+
}
|
|
132
|
+
return stopped;
|
|
77
133
|
}
|
|
78
134
|
export function tail(agentId, follow) {
|
|
79
135
|
const path = logPath(agentId);
|
package/dist/prompt.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { createInterface } from 'node:readline';
|
|
2
|
+
const CTRL_C = '\u0003';
|
|
3
|
+
const BACKSPACE = '\u007f';
|
|
2
4
|
export function askLine(question) {
|
|
3
5
|
const rl = createInterface({ input: process.stdin, output: process.stderr });
|
|
4
6
|
return new Promise((resolve) => {
|
|
@@ -8,3 +10,52 @@ export function askLine(question) {
|
|
|
8
10
|
});
|
|
9
11
|
});
|
|
10
12
|
}
|
|
13
|
+
export function feedSecret(value, chunk) {
|
|
14
|
+
let out = value;
|
|
15
|
+
for (const ch of chunk) {
|
|
16
|
+
if (ch === CTRL_C)
|
|
17
|
+
return { kind: 'cancelled' };
|
|
18
|
+
if (ch === '\r' || ch === '\n')
|
|
19
|
+
return { kind: 'done', value: out };
|
|
20
|
+
if (ch === BACKSPACE || ch === '\b') {
|
|
21
|
+
out = out.slice(0, -1);
|
|
22
|
+
continue;
|
|
23
|
+
}
|
|
24
|
+
if (ch >= ' ')
|
|
25
|
+
out += ch;
|
|
26
|
+
}
|
|
27
|
+
return { kind: 'more', value: out };
|
|
28
|
+
}
|
|
29
|
+
export function askSecret(question) {
|
|
30
|
+
const stdin = process.stdin;
|
|
31
|
+
if (!stdin.isTTY)
|
|
32
|
+
return askLine(question);
|
|
33
|
+
process.stderr.write(question);
|
|
34
|
+
stdin.setRawMode(true);
|
|
35
|
+
stdin.resume();
|
|
36
|
+
stdin.setEncoding('utf8');
|
|
37
|
+
return new Promise((resolve, reject) => {
|
|
38
|
+
let value = '\u0003';
|
|
39
|
+
const finish = () => {
|
|
40
|
+
stdin.setRawMode(false);
|
|
41
|
+
stdin.pause();
|
|
42
|
+
stdin.off('data', onData);
|
|
43
|
+
process.stderr.write('\n');
|
|
44
|
+
};
|
|
45
|
+
const onData = (chunk) => {
|
|
46
|
+
const step = feedSecret(value, chunk);
|
|
47
|
+
if (step.kind === 'cancelled') {
|
|
48
|
+
finish();
|
|
49
|
+
reject(new Error('cancelled'));
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
if (step.kind === 'done') {
|
|
53
|
+
finish();
|
|
54
|
+
resolve(step.value.trim());
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
value = step.value;
|
|
58
|
+
};
|
|
59
|
+
stdin.on('data', onData);
|
|
60
|
+
});
|
|
61
|
+
}
|
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.23",
|
|
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": {
|
|
@@ -144,11 +144,9 @@ async function applyStations(removed: StationName[]): Promise<void> {
|
|
|
144
144
|
|
|
145
145
|
async function syncLocal(): Promise<void> {
|
|
146
146
|
if (localSource === null) return;
|
|
147
|
-
const
|
|
148
|
-
const { active, removed } = await reloadFrom(localSource);
|
|
147
|
+
const { removed, changed } = await reloadFrom(localSource);
|
|
149
148
|
await applyStations(removed);
|
|
150
|
-
for (const station of
|
|
151
|
-
if (before.has(station)) supervisor.requestReload(station);
|
|
149
|
+
for (const station of changed) supervisor.requestReload(station);
|
|
152
150
|
}
|
|
153
151
|
|
|
154
152
|
async function stopLocalStations(): Promise<void> {
|
|
@@ -236,7 +236,12 @@ export const isLocalRuntime = (): boolean =>
|
|
|
236
236
|
export const stationRunsHere = (station: StationName): boolean =>
|
|
237
237
|
isLocalRuntime() || !MOVABLE_STATIONS.has(station);
|
|
238
238
|
|
|
239
|
-
|
|
239
|
+
interface WrittenStations {
|
|
240
|
+
active: Map<StationName, number>;
|
|
241
|
+
changed: StationName[];
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function writeStations(list: LoadedAgent[]): WrittenStations {
|
|
240
245
|
mkdirSync(METRO_DIR, { recursive: true });
|
|
241
246
|
mkdirSync(TRAINS_DIR, { recursive: true });
|
|
242
247
|
|
|
@@ -268,29 +273,36 @@ function writeStations(list: LoadedAgent[]): Map<StationName, number> {
|
|
|
268
273
|
carryTokenStores(list.flatMap((agent) => agent.accounts));
|
|
269
274
|
|
|
270
275
|
const active = new Map<StationName, number>();
|
|
276
|
+
const changed: StationName[] = [];
|
|
271
277
|
for (const [station, accts] of byStation) {
|
|
272
278
|
const records = accts.map((a) => ({ id: a.id, ...trainConfig(a.config) }));
|
|
273
|
-
|
|
279
|
+
const path = accountFilePath(station);
|
|
280
|
+
const content = JSON.stringify(records, null, 2);
|
|
281
|
+
if (currentText(path) !== content) {
|
|
282
|
+
writeSecure(path, content);
|
|
283
|
+
changed.push(station);
|
|
284
|
+
}
|
|
274
285
|
const { trainImport } = STATION_TARGETS[station];
|
|
275
286
|
if (trainImport !== null)
|
|
276
287
|
writeIfChanged(trainStubPath(station), `import '${trainImport}';\n`);
|
|
277
288
|
active.set(station, accts.length);
|
|
278
289
|
}
|
|
279
|
-
return active;
|
|
290
|
+
return { active, changed };
|
|
280
291
|
}
|
|
281
292
|
|
|
293
|
+
const BLANKED = '[]\n';
|
|
294
|
+
|
|
282
295
|
function pruneStations(present: Map<StationName, number>): StationName[] {
|
|
283
296
|
const removed: StationName[] = [];
|
|
284
297
|
for (const station of Object.keys(STATION_TARGETS) as StationName[]) {
|
|
285
298
|
if (present.has(station)) continue;
|
|
286
299
|
const hasTrain = STATION_TARGETS[station].trainImport !== null;
|
|
287
300
|
const stub = trainStubPath(station);
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
)
|
|
292
|
-
|
|
293
|
-
writeSecure(accountFilePath(station), '[]\n');
|
|
301
|
+
const stubGone = !hasTrain || !existsSync(stub);
|
|
302
|
+
const file = currentText(accountFilePath(station));
|
|
303
|
+
const fileInert = file === null || file === BLANKED;
|
|
304
|
+
if (stubGone && fileInert) continue;
|
|
305
|
+
if (!fileInert) writeSecure(accountFilePath(station), BLANKED);
|
|
294
306
|
if (hasTrain) rmSync(stub, { force: true });
|
|
295
307
|
removed.push(station);
|
|
296
308
|
}
|
|
@@ -317,14 +329,15 @@ export function localAgentKey(): string | null {
|
|
|
317
329
|
|
|
318
330
|
async function loadAndWrite(
|
|
319
331
|
source: StationSource,
|
|
320
|
-
): Promise<
|
|
332
|
+
): Promise<WrittenStations & { agents: number }> {
|
|
321
333
|
const list = await source();
|
|
322
334
|
applyKeyMap(list);
|
|
323
|
-
return {
|
|
335
|
+
return { ...writeStations(list), agents: list.length };
|
|
324
336
|
}
|
|
325
337
|
|
|
326
338
|
export async function materializeFrom(source: StationSource): Promise<void> {
|
|
327
339
|
const { active, agents: found } = await loadAndWrite(source);
|
|
340
|
+
|
|
328
341
|
if (found === 0) throw new Error('no agents found — nothing to materialize');
|
|
329
342
|
log.info(
|
|
330
343
|
{ stations: stationLabels(active), agents: found },
|
|
@@ -345,18 +358,20 @@ export async function materializeFromDb(): Promise<void> {
|
|
|
345
358
|
export interface ReloadedStations {
|
|
346
359
|
active: StationName[];
|
|
347
360
|
removed: StationName[];
|
|
361
|
+
changed: StationName[];
|
|
348
362
|
}
|
|
349
363
|
|
|
350
364
|
export async function reloadFrom(
|
|
351
365
|
source: StationSource,
|
|
352
366
|
): Promise<ReloadedStations> {
|
|
353
|
-
const { active } = await loadAndWrite(source);
|
|
367
|
+
const { active, changed } = await loadAndWrite(source);
|
|
354
368
|
const removed = pruneStations(active);
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
369
|
+
if (removed.length > 0 || changed.length > 0)
|
|
370
|
+
log.info(
|
|
371
|
+
{ stations: stationLabels(active), removed, changed },
|
|
372
|
+
'reloaded station accounts',
|
|
373
|
+
);
|
|
374
|
+
return { active: [...active.keys()], removed, changed };
|
|
360
375
|
}
|
|
361
376
|
|
|
362
377
|
export async function reloadAccountsFromDb(): Promise<ReloadedStations> {
|
package/runtime/runtime.json
CHANGED