@stage-labs/metro 0.1.0-beta.51 → 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/cli.js CHANGED
@@ -31,7 +31,7 @@ const USAGE = `metro — run your agent on this machine
31
31
  metro bedrock [args...]
32
32
  the same, with inference on Amazon Bedrock through a local proxy so the
33
33
  channel still works (needs AWS_BEARER_TOKEN_BEDROCK and AWS_REGION)
34
- metro update update to the newest published version
34
+ metro update update to the newest published version (--check only reports)
35
35
  metro version print this CLI's version
36
36
 
37
37
  Start Claude Code with every connector, writing nothing to disk:
@@ -73,7 +73,7 @@ const COMMANDS = {
73
73
  plugin: installPlugin,
74
74
  claude: () => launchClaude(process.argv.slice(3)),
75
75
  bedrock: () => bedrock(process.argv.slice(3)),
76
- update,
76
+ update: () => update(process.argv.slice(3)),
77
77
  version: async () => {
78
78
  process.stdout.write(`${currentVersion()}\n`);
79
79
  return Promise.resolve(0);
package/dist/runtime.js CHANGED
@@ -31,7 +31,8 @@ export function findBun() {
31
31
  }
32
32
  export const localPort = () => Number(process.env.METRO_WEBHOOK_PORT) || 8420;
33
33
  export const localUrl = () => `http://127.0.0.1:${String(localPort())}`;
34
- export function spawnPlan(plan) {
34
+ export const RESTART_CODE = 75;
35
+ function spawnOnce(plan) {
35
36
  const child = spawn(plan.command, plan.args, {
36
37
  cwd: plan.cwd,
37
38
  env: plan.env,
@@ -40,11 +41,23 @@ export function spawnPlan(plan) {
40
41
  const relay = (sig) => () => {
41
42
  child.kill(sig);
42
43
  };
43
- process.on('SIGINT', relay('SIGINT'));
44
- process.on('SIGTERM', relay('SIGTERM'));
44
+ const onInt = relay('SIGINT');
45
+ const onTerm = relay('SIGTERM');
46
+ process.on('SIGINT', onInt);
47
+ process.on('SIGTERM', onTerm);
45
48
  return new Promise((resolve) => {
46
49
  child.on('exit', (code) => {
50
+ process.off('SIGINT', onInt);
51
+ process.off('SIGTERM', onTerm);
47
52
  resolve(code ?? 0);
48
53
  });
49
54
  });
50
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 plan = servePlan({ dir: runtimeDir(), port, tunnel, owner });
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(plan);
109
+ return spawnPlan(() => servePlan({ dir, port, tunnel, owner }));
109
110
  }
package/dist/update.js CHANGED
@@ -9,7 +9,19 @@ export function installArgs(manager, version) {
9
9
  ? ['add', '--global', spec]
10
10
  : ['install', '--global', spec];
11
11
  }
12
- export async function update() {
12
+ export const checkReport = (current, latest) => ({
13
+ current,
14
+ latest,
15
+ newer: latest !== '' && isNewer(latest, current),
16
+ });
17
+ export async function updateCheck() {
18
+ return checkReport(currentVersion(), await publishedVersion());
19
+ }
20
+ export async function update(argv = []) {
21
+ if (argv.includes('--check')) {
22
+ process.stdout.write(`${JSON.stringify(await updateCheck())}\n`);
23
+ return 0;
24
+ }
13
25
  const current = currentVersion();
14
26
  const newest = await publishedVersion();
15
27
  if (newest === '') {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stage-labs/metro",
3
- "version": "0.1.0-beta.51",
3
+ "version": "0.1.0-beta.52",
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": {
@@ -110,6 +110,10 @@ function sessionApis(): SessionApis {
110
110
  if (isLocalMode())
111
111
  return localSessionApis({
112
112
  syncStations,
113
+ restart: () => {
114
+ exitCode = RESTART_EXIT;
115
+ onShutdown();
116
+ },
113
117
  closeAgentSession,
114
118
  gatherAccounts: gatherAccountsForAgents,
115
119
  capabilities: accountStationCapabilities,
@@ -153,6 +157,8 @@ async function main(): Promise<void> {
153
157
  }
154
158
 
155
159
  const SHUTDOWN_TIMEOUT_MS = 3_000;
160
+ const RESTART_EXIT = 75;
161
+ let exitCode = 0;
156
162
  let shuttingDown = false;
157
163
  async function shutdown(): Promise<void> {
158
164
  if (shuttingDown) return;
@@ -173,7 +179,7 @@ async function shutdown(): Promise<void> {
173
179
  ]);
174
180
  }
175
181
  await supervisor.stop();
176
- process.exit(0);
182
+ process.exit(exitCode);
177
183
  }
178
184
  const onShutdown = (): void => {
179
185
  shutdown().catch((err: unknown) => {
@@ -52,6 +52,7 @@ import type { StationName } from '../db/schema.js';
52
52
 
53
53
  export interface LocalModeDeps {
54
54
  syncStations: (station: StationName) => Promise<void>;
55
+ restart: () => void;
55
56
  closeAgentSession: (id: string) => Promise<boolean>;
56
57
  gatherAccounts: AgentApiDeps['gatherAccounts'];
57
58
  capabilities: AgentApiDeps['capabilities'];
@@ -189,6 +190,7 @@ export function localSessionApis(deps: LocalModeDeps): SessionApis {
189
190
  relayApi,
190
191
  localCli,
191
192
  claudeApi: { authorize: (subject) => { assertLocalOwner(subject); } },
193
+ updateApi: { authorize: (subject) => { assertLocalOwner(subject); }, restart: deps.restart },
192
194
  siwe: { ensureUser: ownerSignIn },
193
195
  mode: localModeInfo,
194
196
  };
@@ -16,12 +16,14 @@ import { handleClaudeRequest, type ClaudeApiDeps } from './claude-api.js';
16
16
  import type { ModeInfo } from './mode-api.js';
17
17
  import { handleVaultApiRequest, type VaultApiDeps } from './vault-api.js';
18
18
  import { handleBundleRequest, type BundleApiDeps } from './bundle-api.js';
19
+ import { handleUpdateRequest, type UpdateApiDeps } from './update-api.js';
19
20
 
20
21
  export interface SessionApis {
21
22
  agentApi?: AgentApiDeps;
22
23
  agentConnectorApi?: AgentConnectorApiDeps;
23
24
  bundleApi?: BundleApiDeps;
24
25
  vaultApi?: VaultApiDeps;
26
+ updateApi?: UpdateApiDeps;
25
27
  localCli?: LocalCliDeps;
26
28
  claudeApi?: ClaudeApiDeps;
27
29
  connectorApi?: ConnectorApiDeps;
@@ -44,6 +46,7 @@ export function handleSessionApis(
44
46
  ...when(apis.connectorApi, (d) => handleConnectorApiRequest(req, res, d)),
45
47
  ...when(apis.bundleApi, (d) => handleBundleRequest(req, res, d)),
46
48
  ...when(apis.vaultApi, (d) => handleVaultApiRequest(req, res, d)),
49
+ ...when(apis.updateApi, (d) => handleUpdateRequest(req, res, d)),
47
50
  ...when(apis.claudeApi, (d) => handleClaudeRequest(req, res, d)),
48
51
  ...when(apis.agentConnectorApi, (d) => handleAgentConnectorRequest(req, res, d)),
49
52
  ...when(apis.agentApi, (d) => handleAgentApiRequest(req, res, d)),
@@ -0,0 +1,119 @@
1
+ import { execFile } from 'node:child_process';
2
+ import type { IncomingMessage, ServerResponse } from 'node:http';
3
+ import { errMsg, log } from './log.js';
4
+ import { ApiError } from './api-error.js';
5
+ import { apiFailure, apiSession, cors, sendJson } from './api-http.js';
6
+ import { isRecord } from './is-record.js';
7
+ import { METRO_VERSION } from './version.js';
8
+
9
+ const PATH = '/api/update';
10
+ const CHECK_TTL_MS = 10 * 60_000;
11
+ const CHECK_TIMEOUT_MS = 30_000;
12
+ const UPDATE_TIMEOUT_MS = 5 * 60_000;
13
+ const RESTART_DELAY_MS = 500;
14
+ const OUTPUT_TAIL = 800;
15
+
16
+ export interface UpdateCheck {
17
+ current: string;
18
+ latest: string;
19
+ newer: boolean;
20
+ }
21
+
22
+ export interface UpdateResult {
23
+ updated: boolean;
24
+ version: string;
25
+ restarting: boolean;
26
+ }
27
+
28
+ export interface UpdateApiDeps {
29
+ authorize: (subject: string) => void;
30
+ restart: () => void;
31
+ cliBin?: () => string;
32
+ }
33
+
34
+ const cliBinFromEnv = (): string => process.env.METRO_CLI_BIN?.trim() ?? '';
35
+
36
+ function runCli(bin: string, args: string[], timeout: number): Promise<{ stdout: string; stderr: string }> {
37
+ return new Promise((resolve, reject) => {
38
+ execFile(process.execPath, [bin, ...args], { timeout, maxBuffer: 4 * 1024 * 1024 }, (err, stdout, stderr) => {
39
+ if (err === null) resolve({ stdout, stderr });
40
+ else reject(new ApiError(`metro update failed: ${(stderr || err.message).trim().slice(-OUTPUT_TAIL)}`, 502));
41
+ });
42
+ });
43
+ }
44
+
45
+ let cached: { at: number; check: UpdateCheck } | null = null;
46
+
47
+ function parseCheck(raw: string): UpdateCheck {
48
+ let body: unknown;
49
+ try {
50
+ body = JSON.parse(raw.trim().split('\n').at(-1) ?? '');
51
+ } catch {
52
+ throw new ApiError('metro update --check returned no report', 502);
53
+ }
54
+ if (!isRecord(body) || typeof body.current !== 'string' || typeof body.latest !== 'string')
55
+ throw new ApiError('metro update --check returned an unexpected report', 502);
56
+ return { current: body.current, latest: body.latest, newer: body.newer === true };
57
+ }
58
+
59
+ async function check(bin: string, now = Date.now()): Promise<UpdateCheck> {
60
+ if (cached !== null && now - cached.at < CHECK_TTL_MS) return cached.check;
61
+ const out = await runCli(bin, ['update', '--check'], CHECK_TIMEOUT_MS);
62
+ cached = { at: now, check: parseCheck(out.stdout) };
63
+ return cached.check;
64
+ }
65
+
66
+ async function apply(bin: string, deps: UpdateApiDeps): Promise<UpdateResult> {
67
+ const before = await check(bin);
68
+ if (!before.newer) return { updated: false, version: before.current, restarting: false };
69
+ const out = await runCli(bin, ['update'], UPDATE_TIMEOUT_MS);
70
+ cached = null;
71
+ log.info({ from: before.current, to: before.latest, output: out.stderr.trim().slice(-OUTPUT_TAIL) }, 'update-api: metro updated, restarting');
72
+ setTimeout(() => {
73
+ deps.restart();
74
+ }, RESTART_DELAY_MS).unref();
75
+ return { updated: true, version: before.latest, restarting: true };
76
+ }
77
+
78
+ function binOrThrow(deps: UpdateApiDeps): string {
79
+ const bin = (deps.cliBin ?? cliBinFromEnv)();
80
+ if (bin === '')
81
+ throw new ApiError('this daemon was not started by metro serve, so it cannot update itself: run metro update on the machine', 400);
82
+ return bin;
83
+ }
84
+
85
+ export function handleUpdateRequest(req: IncomingMessage, res: ServerResponse, deps: UpdateApiDeps): boolean {
86
+ const path = (req.url ?? '').split('?')[0] ?? '';
87
+ if (path !== PATH) return false;
88
+ if (req.method === 'OPTIONS') {
89
+ res.writeHead(204, cors(req)).end();
90
+ return true;
91
+ }
92
+ if (req.method !== 'GET' && req.method !== 'POST') {
93
+ sendJson(req, res, 405, { error: 'method not allowed' });
94
+ return true;
95
+ }
96
+ const session = apiSession(req);
97
+ if (!session) {
98
+ sendJson(req, res, 401, { error: 'unauthorized' });
99
+ return true;
100
+ }
101
+ Promise.resolve()
102
+ .then(async (): Promise<unknown> => {
103
+ deps.authorize(session.subject);
104
+ const bin = binOrThrow(deps);
105
+ if (req.method === 'GET') return { ...(await check(bin)), running: METRO_VERSION };
106
+ return apply(bin, deps);
107
+ })
108
+ .then((body) => {
109
+ sendJson(req, res, 200, body);
110
+ })
111
+ .catch((err: unknown) => {
112
+ if (err instanceof ApiError) apiFailure(req, res, err, 'update-api');
113
+ else {
114
+ log.warn({ err: errMsg(err) }, 'update-api: request failed');
115
+ if (!res.headersSent) sendJson(req, res, 500, { error: 'update api failed' });
116
+ }
117
+ });
118
+ return true;
119
+ }
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "0.1.0-beta.51"
2
+ "version": "0.1.0-beta.52"
3
3
  }