@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 { askLine } from './prompt.js';
4
+ import { askSecret } from './prompt.js';
5
5
  import { clearToken, credentialsPath, metroUrl, metroWebUrl, writeToken, } from './store.js';
6
- import { detach, probe, runningPid, stop, tail } from './control.js';
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 <agent-id>
15
- stop the daemon started with --detach
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 askLine('Paste the code: ')).trim();
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(`metro is already running for ${agentId} — stop it first`);
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 stop(agentId);
70
- process.stderr.write(stopped ? `Stopped metro for ${agentId}\n` : `metro is not running for ${agentId}\n`);
71
- return stopped ? 0 : 1;
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 askLine('Paste the code here: '));
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
- export async function stop(agentId, waitMs = 10_000) {
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
- clearPid(agentId);
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
- clearPid(agentId);
76
- return true;
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.22",
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 before = new Set(supervisor.running());
148
- const { active, removed } = await reloadFrom(localSource);
147
+ const { removed, changed } = await reloadFrom(localSource);
149
148
  await applyStations(removed);
150
- for (const station of active)
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
- function writeStations(list: LoadedAgent[]): Map<StationName, number> {
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
- writeSecure(accountFilePath(station), JSON.stringify(records, null, 2));
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
- if (
289
- (!hasTrain || !existsSync(stub)) &&
290
- !existsSync(accountFilePath(station))
291
- )
292
- continue;
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<{ active: Map<StationName, number>; agents: number }> {
332
+ ): Promise<WrittenStations & { agents: number }> {
321
333
  const list = await source();
322
334
  applyKeyMap(list);
323
- return { active: writeStations(list), agents: list.length };
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
- log.info(
356
- { stations: stationLabels(active), removed },
357
- 'reloaded station accounts',
358
- );
359
- return { active: [...active.keys()], removed };
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> {
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "0.1.0-beta.22"
2
+ "version": "0.1.0-beta.23"
3
3
  }