@stage-labs/metro 0.1.0-beta.51 → 0.1.0-beta.53

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.
Files changed (34) hide show
  1. package/dist/cli.js +2 -2
  2. package/dist/local.js +26 -0
  3. package/dist/runtime-install.js +84 -0
  4. package/dist/runtime.js +16 -3
  5. package/dist/serve.js +9 -5
  6. package/dist/update.js +13 -1
  7. package/package.json +1 -22
  8. package/runtime/node_modules/@metro-labs/mcp/src/daemon/account-api.ts +1 -1
  9. package/runtime/node_modules/@metro-labs/mcp/src/daemon/attach-session.ts +3 -1
  10. package/runtime/node_modules/@metro-labs/mcp/src/daemon/boot.ts +22 -15
  11. package/runtime/node_modules/@metro-labs/mcp/src/daemon/bundle-api.ts +1 -1
  12. package/runtime/node_modules/@metro-labs/mcp/src/daemon/http.ts +1 -1
  13. package/runtime/node_modules/@metro-labs/mcp/src/daemon/local-mode.ts +3 -1
  14. package/runtime/node_modules/@metro-labs/mcp/src/daemon/runtime-deps.ts +82 -0
  15. package/runtime/node_modules/@metro-labs/mcp/src/daemon/session-apis.ts +3 -0
  16. package/runtime/node_modules/@metro-labs/mcp/src/daemon/siwe-routes.ts +1 -4
  17. package/runtime/node_modules/@metro-labs/mcp/src/daemon/update-api.ts +119 -0
  18. package/runtime/node_modules/@metro-labs/mcp/src/daemon/vault-api.ts +2 -2
  19. package/runtime/node_modules/@metro-labs/mcp/src/daemon/vault-types.ts +12 -0
  20. package/runtime/node_modules/@metro-labs/mcp/src/db/account-attach.ts +1 -1
  21. package/runtime/node_modules/@metro-labs/mcp/src/db/address.ts +6 -0
  22. package/runtime/node_modules/@metro-labs/mcp/src/db/agent-scope.ts +1 -1
  23. package/runtime/node_modules/@metro-labs/mcp/src/db/connectors.ts +1 -1
  24. package/runtime/node_modules/@metro-labs/mcp/src/db/file-admin.ts +2 -2
  25. package/runtime/node_modules/@metro-labs/mcp/src/db/file-source.ts +1 -1
  26. package/runtime/node_modules/@metro-labs/mcp/src/db/materialize.ts +1 -1
  27. package/runtime/node_modules/@metro-labs/mcp/src/db/schema.ts +2 -10
  28. package/runtime/node_modules/@metro-labs/mcp/src/db/stations.ts +12 -0
  29. package/runtime/node_modules/@metro-labs/mcp/src/db/users.ts +1 -5
  30. package/runtime/node_modules/@metro-labs/mcp/src/db/vault.ts +4 -14
  31. package/runtime/node_modules/@metro-labs/mcp/src/mcp/accounts.ts +1 -1
  32. package/runtime/node_modules/@metro-labs/mcp/src/stations/attach.ts +2 -0
  33. package/runtime/runtime.json +1 -1
  34. package/runtime/stations.json +34 -0
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/local.js CHANGED
@@ -29,6 +29,32 @@ export function localAgents(dir = agentsDir()) {
29
29
  }
30
30
  return out.sort((a, b) => a.name.localeCompare(b.name));
31
31
  }
32
+ export function localStations(dir = agentsDir()) {
33
+ if (!existsSync(dir))
34
+ return [];
35
+ const out = new Set();
36
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
37
+ if (!entry.isDirectory())
38
+ continue;
39
+ const path = join(dir, entry.name, 'agent.json');
40
+ if (!existsSync(path))
41
+ continue;
42
+ try {
43
+ const file = JSON.parse(readFileSync(path, 'utf8'));
44
+ if (!Array.isArray(file.stations))
45
+ continue;
46
+ for (const s of file.stations) {
47
+ const station = s.station;
48
+ if (typeof station === 'string')
49
+ out.add(station);
50
+ }
51
+ }
52
+ catch {
53
+ continue;
54
+ }
55
+ }
56
+ return [...out].sort();
57
+ }
32
58
  export function pickLocalAgent(agents, wanted) {
33
59
  if (wanted !== undefined && wanted !== '') {
34
60
  const found = agents.find((a) => a.name === wanted || a.id === wanted);
@@ -0,0 +1,84 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import { cpSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
3
+ import { homedir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import { localStations } from './local.js';
6
+ import { SERVER_ENTRY, findBun, runtimeDir } from './runtime.js';
7
+ export const MANIFEST_FILE = 'stations.json';
8
+ const METRO_SOURCES = join('node_modules', '@metro-labs');
9
+ const INSTALL_TIMEOUT_MS = 15 * 60_000;
10
+ export function runtimeStore() {
11
+ const env = process.env.METRO_RUNTIME_STORE?.trim() ?? '';
12
+ return env === '' ? join(homedir(), '.metro', 'runtime') : env;
13
+ }
14
+ const isRecord = (v) => typeof v === 'object' && v !== null;
15
+ const ranges = (raw) => isRecord(raw)
16
+ ? Object.fromEntries(Object.entries(raw).filter((e) => typeof e[1] === 'string'))
17
+ : {};
18
+ export function readManifest(path) {
19
+ const raw = JSON.parse(readFileSync(path, 'utf8'));
20
+ if (!isRecord(raw))
21
+ throw new Error(`${path} is not a runtime manifest`);
22
+ const stations = isRecord(raw.stations) ? raw.stations : {};
23
+ return {
24
+ core: ranges(raw.core),
25
+ stations: Object.fromEntries(Object.entries(stations).map(([name, deps]) => [name, ranges(deps)])),
26
+ };
27
+ }
28
+ export function dependenciesFor(manifest, stations) {
29
+ const out = { ...manifest.core };
30
+ for (const station of new Set(stations))
31
+ Object.assign(out, manifest.stations[station] ?? {});
32
+ return Object.fromEntries(Object.entries(out).sort(([a], [b]) => a.localeCompare(b)));
33
+ }
34
+ const packageText = (deps) => `${JSON.stringify({ name: 'metro-runtime', private: true, dependencies: deps }, null, 2)}\n`;
35
+ function readOrNull(path) {
36
+ try {
37
+ return readFileSync(path, 'utf8');
38
+ }
39
+ catch {
40
+ return null;
41
+ }
42
+ }
43
+ function syncSources(sources, store) {
44
+ const stamp = readOrNull(join(sources, 'runtime.json'));
45
+ if (stamp !== null && readOrNull(join(store, 'runtime.json')) === stamp)
46
+ return false;
47
+ rmSync(join(store, METRO_SOURCES), { recursive: true, force: true });
48
+ cpSync(join(sources, METRO_SOURCES), join(store, METRO_SOURCES), { recursive: true });
49
+ if (stamp !== null)
50
+ writeFileSync(join(store, 'runtime.json'), stamp);
51
+ return true;
52
+ }
53
+ export function installDependencies(store, deps, bun, log) {
54
+ const wanted = packageText(deps);
55
+ const marker = join(store, 'node_modules', '.metro-installed');
56
+ if (existsSync(marker) && readOrNull(join(store, 'package.json')) === wanted)
57
+ return false;
58
+ writeFileSync(join(store, 'package.json'), wanted);
59
+ log(`Installing the channel SDKs this machine needs into ${store}`);
60
+ const run = spawnSync(bun, ['install', '--no-summary', '--no-progress'], {
61
+ cwd: store,
62
+ stdio: ['ignore', 'inherit', 'inherit'],
63
+ timeout: INSTALL_TIMEOUT_MS,
64
+ });
65
+ if (run.error !== undefined || run.status !== 0)
66
+ throw new Error(`bun install failed in ${store}: ${run.error?.message ?? `exit ${String(run.status)}`}`);
67
+ writeFileSync(marker, `${new Date().toISOString()}\n`);
68
+ return true;
69
+ }
70
+ export function prepareRuntime(opts = {}) {
71
+ const sources = opts.sources ?? runtimeDir();
72
+ const manifestPath = join(sources, MANIFEST_FILE);
73
+ if (!existsSync(manifestPath))
74
+ return { dir: sources, entry: join(sources, SERVER_ENTRY), trains: join(sources, 'trains'), manifest: null };
75
+ const store = opts.store ?? runtimeStore();
76
+ const log = opts.log ??
77
+ ((line) => {
78
+ process.stderr.write(`${line}\n`);
79
+ });
80
+ mkdirSync(join(store, 'trains'), { recursive: true });
81
+ syncSources(sources, store);
82
+ installDependencies(store, dependenciesFor(readManifest(manifestPath), localStations(opts.agents)), opts.bun ?? findBun(), log);
83
+ return { dir: store, entry: join(store, SERVER_ENTRY), trains: join(store, 'trains'), manifest: manifestPath };
84
+ }
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
@@ -4,7 +4,8 @@ import { join } from 'node:path';
4
4
  import { agentsDir } from './local.js';
5
5
  import { currentVersion } from './version.js';
6
6
  import { serveLockedBy, serveStateDir } from './control.js';
7
- import { findBun, localPort, runtimeDir, SERVER_ENTRY, spawnPlan, } from './runtime.js';
7
+ import { findBun, localPort, SERVER_ENTRY, spawnPlan } from './runtime.js';
8
+ import { prepareRuntime } from './runtime-install.js';
8
9
  const SCRUBBED = new Set(['METRO_RUN_TOKEN', 'METRO_AGENT', 'DATABASE_URL']);
9
10
  const PORT_FLAG = /^--port=(.*)$/;
10
11
  const OWNER_FLAG = /^--owner=(.*)$/;
@@ -80,14 +81,18 @@ export function servePlan(opts) {
80
81
  return {
81
82
  command: findBun(),
82
83
  args: [SERVER_ENTRY],
83
- cwd: opts.dir,
84
+ cwd: opts.runtime.dir,
84
85
  env: {
85
86
  ...env,
86
87
  METRO_MODE: 'local',
87
88
  METRO_VERSION: currentVersion(),
89
+ METRO_CLI_BIN: process.argv[1] ?? '',
88
90
  METRO_WEBHOOK_PORT: String(opts.port),
89
91
  METRO_HTTP_HOST: process.env.METRO_HTTP_HOST ?? '127.0.0.1',
90
- METRO_TRAINS_DIR: join(opts.dir, 'trains'),
92
+ METRO_TRAINS_DIR: opts.runtime.trains,
93
+ ...(opts.runtime.manifest === null
94
+ ? {}
95
+ : { METRO_RUNTIME_STORE: opts.runtime.dir, METRO_RUNTIME_MANIFEST: opts.runtime.manifest }),
91
96
  METRO_STATE_DIR: process.env.METRO_STATE_DIR ?? serveStateDir(),
92
97
  ...(opts.tunnel ? { METRO_TUNNEL: 'quick' } : {}),
93
98
  ...(opts.owner === null ? {} : { METRO_OWNER: opts.owner }),
@@ -103,7 +108,6 @@ export function serve(argv) {
103
108
  requireOwner(owner);
104
109
  if (tunnel)
105
110
  findCloudflared();
106
- const plan = servePlan({ dir: runtimeDir(), port, tunnel, owner });
107
111
  process.stderr.write(`Starting a metro daemon of your own on http://127.0.0.1:${String(port)}\n`);
108
- return spawnPlan(plan);
112
+ return spawnPlan(() => servePlan({ runtime: prepareRuntime(), port, tunnel, owner }));
109
113
  }
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.53",
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": {
@@ -35,26 +35,5 @@
35
35
  "knip": "^6.15.0",
36
36
  "madge": "^8.0.0",
37
37
  "typescript": "^5"
38
- },
39
- "dependencies": {
40
- "@discordjs/voice": "^0.19.2",
41
- "@modelcontextprotocol/sdk": "^1.12.0",
42
- "@mtcute/bun": "0.30.1",
43
- "@xmtp/content-type-primitives": "^3.0.1",
44
- "@xmtp/content-type-reaction": "^2.0.2",
45
- "@xmtp/content-type-remote-attachment": "^2.0.4",
46
- "@xmtp/content-type-reply": "^2.0.3",
47
- "@xmtp/content-type-wallet-send-calls": "^2.0.0",
48
- "@xmtp/node-bindings": "1.9.1",
49
- "@xmtp/node-sdk": "^6.0.0",
50
- "baileys": "7.0.0-rc14",
51
- "discord.js": "^14.26.4",
52
- "drizzle-orm": "^0.44.0",
53
- "pino": "^9.5.0",
54
- "pino-pretty": "^13.1.3",
55
- "postgres": "^3.4.5",
56
- "prism-media": "^1.3.5",
57
- "viem": "^2.52.2",
58
- "zod": "^3.23.8"
59
38
  }
60
39
  }
@@ -13,7 +13,7 @@ import {
13
13
  parseAccountId,
14
14
  type AccountRef,
15
15
  } from '../db/account-attach.js';
16
- import type { StationName } from '../db/schema.js';
16
+ import type { StationName } from '../db/stations.js';
17
17
  import {
18
18
  ATTACHABLE_STATIONS,
19
19
  isAttachStation,
@@ -1,7 +1,8 @@
1
1
  import { randomBytes } from 'node:crypto';
2
2
  import { ApiError } from './api-error.js';
3
3
  import { errMsg, log } from './log.js';
4
- import type { StationName } from '../db/schema.js';
4
+ import type { StationName } from '../db/stations.js';
5
+ import { ensureStationDeps } from './runtime-deps.js';
5
6
  import {
6
7
  startInteractiveAttach,
7
8
  type AttachDriver,
@@ -216,6 +217,7 @@ export class AttachSessions {
216
217
  };
217
218
  this.sessions.set(attachId, session);
218
219
  try {
220
+ ensureStationDeps(station);
219
221
  const started = await (this.deps.start ?? startInteractiveAttach)(
220
222
  station,
221
223
  input,
@@ -23,6 +23,7 @@ import { fileSource } from '../db/file-source.js';
23
23
  import { ensureLocalSessionSecret } from './local-secret.js';
24
24
  import { applyLocalOwner } from './local-owner.js';
25
25
  import { localOwner } from '../db/file-admin.js';
26
+ import { ensureStationDeps } from './runtime-deps.js';
26
27
  import { localSessionApis } from './local-mode.js';
27
28
  import type { ModeInfo } from './mode-api.js';
28
29
  import type { SessionApis } from './session-apis.js';
@@ -39,13 +40,7 @@ import {
39
40
  } from '../stations/registry.js';
40
41
  import { prepareAccount } from '../stations/attach.js';
41
42
  import { materializeFrom, reloadFrom } from '../db/materialize.js';
42
- import {
43
- deleteVaultForOwner,
44
- getVaultForOwner,
45
- listVaultForOwner,
46
- putVaultForOwner,
47
- } from '../db/vault.js';
48
- import type { StationName } from '../db/schema.js';
43
+ import type { StationName } from '../db/stations.js';
49
44
  import { startUploadReaper } from './upload-store.js';
50
45
 
51
46
  installCrashGuard();
@@ -96,7 +91,10 @@ async function syncStations(station: StationName): Promise<void> {
96
91
  const { removed } = await reloadFrom(fileSource);
97
92
  if (stationByName(station)?.hasTrain === false) return;
98
93
  if (removed.includes(station)) await supervisor.stopTrain(station);
99
- else supervisor.requestReload(station);
94
+ else {
95
+ ensureStationDeps(station);
96
+ supervisor.requestReload(station);
97
+ }
100
98
  }
101
99
 
102
100
  const hostedMode = (): ModeInfo => ({
@@ -106,23 +104,30 @@ const hostedMode = (): ModeInfo => ({
106
104
  version: METRO_VERSION,
107
105
  });
108
106
 
109
- function sessionApis(): SessionApis {
107
+ async function sessionApis(): Promise<SessionApis> {
110
108
  if (isLocalMode())
111
109
  return localSessionApis({
112
110
  syncStations,
111
+ restart: () => {
112
+ exitCode = RESTART_EXIT;
113
+ onShutdown();
114
+ },
113
115
  closeAgentSession,
114
116
  gatherAccounts: gatherAccountsForAgents,
115
117
  capabilities: accountStationCapabilities,
116
118
  liveness: agentLiveness,
117
119
  prepareAccount,
118
120
  });
121
+ const vault = await import('../db/vault.js');
122
+ const users = await import('../db/users.js');
119
123
  return {
120
124
  vaultApi: {
121
- list: listVaultForOwner,
122
- put: putVaultForOwner,
123
- get: getVaultForOwner,
124
- remove: deleteVaultForOwner,
125
+ list: vault.listVaultForOwner,
126
+ put: vault.putVaultForOwner,
127
+ get: vault.getVaultForOwner,
128
+ remove: vault.deleteVaultForOwner,
125
129
  },
130
+ siwe: { ensureUser: users.ensureUserByAddress },
126
131
  mode: hostedMode,
127
132
  };
128
133
  }
@@ -137,7 +142,7 @@ async function main(): Promise<void> {
137
142
  const metroMcp = await createMetroMcp();
138
143
  webhookServer = await startWebhookServer(
139
144
  emit,
140
- sessionApis(),
145
+ await sessionApis(),
141
146
  metroMcp.httpHandler,
142
147
  metroCall,
143
148
  );
@@ -153,6 +158,8 @@ async function main(): Promise<void> {
153
158
  }
154
159
 
155
160
  const SHUTDOWN_TIMEOUT_MS = 3_000;
161
+ const RESTART_EXIT = 75;
162
+ let exitCode = 0;
156
163
  let shuttingDown = false;
157
164
  async function shutdown(): Promise<void> {
158
165
  if (shuttingDown) return;
@@ -173,7 +180,7 @@ async function shutdown(): Promise<void> {
173
180
  ]);
174
181
  }
175
182
  await supervisor.stop();
176
- process.exit(0);
183
+ process.exit(exitCode);
177
184
  }
178
185
  const onShutdown = (): void => {
179
186
  shutdown().catch((err: unknown) => {
@@ -5,7 +5,7 @@ import { ApiError } from './api-error.js';
5
5
  import { isRecord } from './is-record.js';
6
6
  import { parseId } from '../db/ids.js';
7
7
  import type { LoadedAccount, LoadedAgent, LoadedConnector } from '../db/materialize.js';
8
- import { STATIONS } from '../db/schema.js';
8
+ import { STATIONS } from '../db/stations.js';
9
9
 
10
10
  const AGENTS = '/api/agents';
11
11
  const RESTORE_PATH = `${AGENTS}/restore`;
@@ -328,7 +328,7 @@ function handleSignInRoutes(
328
328
  res: ServerResponse,
329
329
  apis: SessionApis,
330
330
  ): boolean {
331
- if (handleSiweAuthRequest(req, res, apis.siwe)) return true;
331
+ if (apis.siwe !== undefined && handleSiweAuthRequest(req, res, apis.siwe)) return true;
332
332
  return apis.mode !== undefined && handleModeRequest(req, res, apis.mode);
333
333
  }
334
334
 
@@ -48,10 +48,11 @@ import {
48
48
  readLocalAgentFile,
49
49
  } from '../db/file-admin.js';
50
50
  import { listAgentFiles, readAgentFile } from '../db/file-source.js';
51
- import type { StationName } from '../db/schema.js';
51
+ import type { StationName } from '../db/stations.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
  };
@@ -0,0 +1,82 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { log } from './log.js';
5
+ import { isRecord } from './is-record.js';
6
+ import type { StationName } from '../db/stations.js';
7
+ import { knownAccounts } from '../db/agent-map.js';
8
+
9
+ const INSTALL_TIMEOUT_MS = 15 * 60_000;
10
+
11
+ export interface RuntimeManifest {
12
+ core: Record<string, string>;
13
+ stations: Record<string, Record<string, string>>;
14
+ }
15
+
16
+ export interface RuntimeStore {
17
+ dir: string;
18
+ manifest: RuntimeManifest;
19
+ }
20
+
21
+ const ranges = (raw: unknown): Record<string, string> =>
22
+ isRecord(raw)
23
+ ? Object.fromEntries(Object.entries(raw).filter((e): e is [string, string] => typeof e[1] === 'string'))
24
+ : {};
25
+
26
+ export function readManifest(path: string): RuntimeManifest {
27
+ const raw: unknown = JSON.parse(readFileSync(path, 'utf8'));
28
+ if (!isRecord(raw)) throw new Error(`${path} is not a runtime manifest`);
29
+ const stations = isRecord(raw.stations) ? raw.stations : {};
30
+ return {
31
+ core: ranges(raw.core),
32
+ stations: Object.fromEntries(Object.entries(stations).map(([name, deps]) => [name, ranges(deps)])),
33
+ };
34
+ }
35
+
36
+ export function runtimeStore(): RuntimeStore | null {
37
+ const dir = process.env.METRO_RUNTIME_STORE?.trim() ?? '';
38
+ const manifest = process.env.METRO_RUNTIME_MANIFEST?.trim() ?? '';
39
+ if (dir === '' || manifest === '') return null;
40
+ return { dir, manifest: readManifest(manifest) };
41
+ }
42
+
43
+ export function dependenciesFor(manifest: RuntimeManifest, stations: Iterable<string>): Record<string, string> {
44
+ const out: Record<string, string> = { ...manifest.core };
45
+ for (const station of new Set(stations)) Object.assign(out, manifest.stations[station] ?? {});
46
+ return Object.fromEntries(Object.entries(out).sort(([a], [b]) => a.localeCompare(b)));
47
+ }
48
+
49
+ const packageText = (deps: Record<string, string>): string =>
50
+ `${JSON.stringify({ name: 'metro-runtime', private: true, dependencies: deps }, null, 2)}\n`;
51
+
52
+ function current(dir: string): string | null {
53
+ try {
54
+ return readFileSync(join(dir, 'package.json'), 'utf8');
55
+ } catch {
56
+ return null;
57
+ }
58
+ }
59
+
60
+ export function installRuntime(store: RuntimeStore, stations: Iterable<string>): boolean {
61
+ const wanted = packageText(dependenciesFor(store.manifest, stations));
62
+ const installed = existsSync(join(store.dir, 'node_modules', '.metro-installed'));
63
+ if (installed && current(store.dir) === wanted) return false;
64
+ mkdirSync(store.dir, { recursive: true });
65
+ writeFileSync(join(store.dir, 'package.json'), wanted);
66
+ log.info({ dir: store.dir }, 'runtime: installing the channel SDKs this machine needs');
67
+ const run = spawnSync('bun', ['install', '--no-summary', '--no-progress'], {
68
+ cwd: store.dir,
69
+ stdio: ['ignore', 'inherit', 'inherit'],
70
+ timeout: INSTALL_TIMEOUT_MS,
71
+ });
72
+ if (run.error !== undefined || run.status !== 0)
73
+ throw new Error(`bun install failed in ${store.dir}: ${run.error?.message ?? `exit ${String(run.status)}`}`);
74
+ writeFileSync(join(store.dir, 'node_modules', '.metro-installed'), `${new Date().toISOString()}\n`);
75
+ return true;
76
+ }
77
+
78
+ export function ensureStationDeps(station: StationName): void {
79
+ const store = runtimeStore();
80
+ if (store === null) return;
81
+ installRuntime(store, [...knownAccounts().map((a) => a.station), station]);
82
+ }
@@ -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)),
@@ -11,7 +11,6 @@ import { mintNonce, takeNonce } from './siwe-nonces.js';
11
11
  import { verifySiweLogin } from './siwe-auth.js';
12
12
  import { signSession } from './session.js';
13
13
  import { SESSION_TTL_SEC } from './session-config.js';
14
- import { ensureUserByAddress } from '../db/users.js';
15
14
 
16
15
  const NONCE_PATH = '/auth/siwe/nonce';
17
16
  const VERIFY_PATH = '/auth/siwe/verify';
@@ -20,8 +19,6 @@ export interface SiweRouteDeps {
20
19
  ensureUser: (address: string) => Promise<string>;
21
20
  }
22
21
 
23
- const defaultDeps: SiweRouteDeps = { ensureUser: ensureUserByAddress };
24
-
25
22
  async function handleVerify(
26
23
  req: IncomingMessage,
27
24
  res: ServerResponse,
@@ -68,7 +65,7 @@ function refused(
68
65
  export function handleSiweAuthRequest(
69
66
  req: IncomingMessage,
70
67
  res: ServerResponse,
71
- deps: SiweRouteDeps = defaultDeps,
68
+ deps: SiweRouteDeps,
72
69
  ): boolean {
73
70
  const path = (req.url ?? '').split('?')[0] ?? '';
74
71
  if (ROUTES[path] === undefined) return false;
@@ -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
+ }
@@ -2,9 +2,9 @@ import type { IncomingMessage, ServerResponse } from 'node:http';
2
2
  import { errMsg, log } from './log.js';
3
3
  import { verifyMessage } from 'viem';
4
4
  import { apiFailure, cors, readJsonBody, sendJson } from './api-http.js';
5
- import { normalizeAddress } from '../db/users.js';
5
+ import { normalizeAddress } from '../db/address.js';
6
6
  import { parseId } from '../db/ids.js';
7
- import { ENVELOPE_MAX, type VaultBundle, type VaultEntry } from '../db/vault.js';
7
+ import { ENVELOPE_MAX, type VaultBundle, type VaultEntry } from './vault-types.js';
8
8
 
9
9
  const PREFIX = '/api/vault';
10
10
  const BODY_MAX = ENVELOPE_MAX + 64 * 1024;
@@ -0,0 +1,12 @@
1
+ export interface VaultEntry {
2
+ id: string;
3
+ name: string;
4
+ stations: string[];
5
+ syncedAt: string;
6
+ }
7
+
8
+ export interface VaultBundle extends VaultEntry {
9
+ envelope: Record<string, unknown>;
10
+ }
11
+
12
+ export const ENVELOPE_MAX = 2 * 1024 * 1024;
@@ -1,4 +1,4 @@
1
- import { STATIONS, type StationName } from './schema.js';
1
+ import { STATIONS, type StationName } from './stations.js';
2
2
 
3
3
  const ACCOUNT_ID_RE = /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/;
4
4
 
@@ -0,0 +1,6 @@
1
+ const ADDRESS_RE = /^0x[0-9a-f]{40}$/;
2
+
3
+ export function normalizeAddress(raw: string): string | null {
4
+ const address = raw.trim().toLowerCase();
5
+ return ADDRESS_RE.test(address) ? address : null;
6
+ }
@@ -3,7 +3,7 @@ import {
3
3
  agentIdForAccount,
4
4
  stationAgentIds,
5
5
  } from './agent-map.js';
6
- import { STATIONS } from './schema.js';
6
+ import { STATIONS } from './stations.js';
7
7
 
8
8
  const ACCOUNT_STATIONS = new Set<string>(STATIONS);
9
9
 
@@ -1,6 +1,6 @@
1
1
  import type { ConnectorAuth, VerifiedRecord } from '../daemon/connector-verify.js';
2
2
  import { readConfig, signInState, type ConnectorSignIn } from './connector-config.js';
3
- import type { ConnectorTransport } from './schema.js';
3
+ import type { ConnectorTransport } from './stations.js';
4
4
 
5
5
  export interface Connector {
6
6
  id: string;
@@ -25,8 +25,8 @@ import {
25
25
  import { newId } from './ids.js';
26
26
  import { registerKey, rotateAgentKey, unregisterAgentKey } from './key-map.js';
27
27
  import { MOVABLE_STATIONS, type LoadedAgent } from './materialize.js';
28
- import type { StationName } from './schema.js';
29
- import { normalizeAddress } from './users.js';
28
+ import type { StationName } from './stations.js';
29
+ import { normalizeAddress } from './address.js';
30
30
 
31
31
  export const LOCAL_PROJECT_ID = 'localdaemon';
32
32
  const OWNER_FILE = '.owner';
@@ -2,7 +2,7 @@ import { existsSync, readdirSync, readFileSync } from 'node:fs';
2
2
  import { homedir } from 'node:os';
3
3
  import { join } from 'node:path';
4
4
  import { AGENT_NAME_RE, ID_RE } from './ids.js';
5
- import { STATIONS, type StationName } from './schema.js';
5
+ import { STATIONS, type StationName } from './stations.js';
6
6
  import type { LoadedAccount, LoadedAgent, StationSource } from './materialize.js';
7
7
  import { isRecord } from '../daemon/is-record.js';
8
8
 
@@ -18,7 +18,7 @@ import {
18
18
  type AllowlistMap,
19
19
  } from './agent-map.js';
20
20
  import { setKeyMap } from './key-map.js';
21
- import { STATIONS, type StationName } from './schema.js';
21
+ import { STATIONS, type StationName } from './stations.js';
22
22
 
23
23
  interface StationTarget {
24
24
  file: string;
@@ -1,15 +1,8 @@
1
1
  import { index, jsonb, pgTable, text } from 'drizzle-orm/pg-core';
2
2
 
3
- export const STATIONS = [
4
- 'xmtp',
5
- 'telegram-bot',
6
- 'telegram',
7
- 'discord-bot',
8
- 'whatsapp',
9
- 'webhook',
10
- ] as const;
3
+ import { STATIONS, type ConnectorTransport, type StationName } from './stations.js';
11
4
 
12
- export type StationName = (typeof STATIONS)[number];
5
+ export { STATIONS, type ConnectorTransport, type StationName };
13
6
 
14
7
  export const users = pgTable('users', {
15
8
  id: text('id').primaryKey(),
@@ -19,7 +12,6 @@ export const users = pgTable('users', {
19
12
 
20
13
  export type ProjectRole = 'admin' | 'member';
21
14
 
22
- export type ConnectorTransport = 'http' | 'sse';
23
15
 
24
16
  export const vault = pgTable(
25
17
  'vault',
@@ -0,0 +1,12 @@
1
+ export const STATIONS = [
2
+ 'xmtp',
3
+ 'telegram-bot',
4
+ 'telegram',
5
+ 'discord-bot',
6
+ 'whatsapp',
7
+ 'webhook',
8
+ ] as const;
9
+
10
+ export type StationName = (typeof STATIONS)[number];
11
+
12
+ export type ConnectorTransport = 'http' | 'sse';
@@ -3,9 +3,9 @@ import { ApiError } from '../daemon/api-error.js';
3
3
  import { getDb } from './client.js';
4
4
  import { newId } from './ids.js';
5
5
  import { users } from './schema.js';
6
+ import { normalizeAddress } from './address.js';
6
7
 
7
8
  const UNIQUE_VIOLATION = '23505';
8
- const ADDRESS_RE = /^0x[0-9a-f]{40}$/;
9
9
 
10
10
  export class UserError extends ApiError {}
11
11
 
@@ -22,10 +22,6 @@ export function isUniqueViolation(err: unknown): boolean {
22
22
  return false;
23
23
  }
24
24
 
25
- export function normalizeAddress(raw: string): string | null {
26
- const address = raw.trim().toLowerCase();
27
- return ADDRESS_RE.test(address) ? address : null;
28
- }
29
25
 
30
26
  export async function ensureUserByAddress(raw: string): Promise<string> {
31
27
  const address = normalizeAddress(raw);
@@ -3,29 +3,19 @@ import { ApiError } from '../daemon/api-error.js';
3
3
  import { isRecord } from '../daemon/is-record.js';
4
4
  import { getDb } from './client.js';
5
5
  import { AGENT_NAME_RE, parseId } from './ids.js';
6
- import { STATIONS, vault } from './schema.js';
7
- import { normalizeAddress } from './users.js';
6
+ import { vault } from './schema.js';
7
+ import { STATIONS } from './stations.js';
8
+ import { normalizeAddress } from './address.js';
8
9
 
9
10
  export class VaultError extends ApiError {}
10
11
 
11
- export interface VaultEntry {
12
- id: string;
13
- name: string;
14
- stations: string[];
15
- syncedAt: string;
16
- }
17
-
18
- export interface VaultBundle extends VaultEntry {
19
- envelope: Record<string, unknown>;
20
- }
12
+ import { ENVELOPE_MAX, type VaultBundle, type VaultEntry } from '../daemon/vault-types.js';
21
13
 
22
14
  export interface VaultInput {
23
15
  name: string;
24
16
  stations: string[];
25
17
  envelope: Record<string, unknown>;
26
18
  }
27
-
28
- export const ENVELOPE_MAX = 2 * 1024 * 1024;
29
19
  const STATION_NAMES = new Set<string>(STATIONS);
30
20
 
31
21
  const missing = (): VaultError => new VaultError('no such bundle', 404);
@@ -7,7 +7,7 @@ import { listEndpoints } from '../daemon/tunnel.js';
7
7
  import { hookUrl } from '../stations/attach.js';
8
8
  import { agentIdForAccount, knownAccounts, type KnownAccount } from '../db/agent-map.js';
9
9
  import { stationRunsHere } from '../db/materialize.js';
10
- import { type StationName } from '../db/schema.js';
10
+ import { type StationName } from '../db/stations.js';
11
11
 
12
12
  const accountId = (acc: unknown): string | undefined => {
13
13
  const id = (acc as { id?: unknown }).id;
@@ -8,6 +8,7 @@ import {
8
8
  verifyTelegramBotToken,
9
9
  } from '@metro-labs/telegram-bot/verify';
10
10
  import { ApiError } from '../daemon/api-error.js';
11
+ import { ensureStationDeps } from '../daemon/runtime-deps.js';
11
12
  import { publicBaseOrDefault } from '../daemon/attach-serve.js';
12
13
  import {
13
14
  discardXmtpDb,
@@ -175,6 +176,7 @@ export async function prepareAccount(
175
176
  input: AttachInput,
176
177
  verify: VerifyXmtpKey = verifyXmtpKeyOutOfProcess,
177
178
  ): Promise<PreparedAccount> {
179
+ ensureStationDeps(input.station);
178
180
  if (input.station === 'discord-bot') return prepareDiscord(input.token);
179
181
  if (input.station === 'telegram-bot') return prepareTelegram(input.token);
180
182
  if (input.station === 'webhook') return prepareWebhook();
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "0.1.0-beta.51"
2
+ "version": "0.1.0-beta.53"
3
3
  }
@@ -0,0 +1,34 @@
1
+ {
2
+ "core": {
3
+ "@modelcontextprotocol/sdk": "^1.12.0",
4
+ "pino": "^9.5.0",
5
+ "pino-pretty": "^13.1.3",
6
+ "viem": "2.52.2",
7
+ "zod": "^3.23.8"
8
+ },
9
+ "stations": {
10
+ "xmtp": {
11
+ "@xmtp/content-type-primitives": "^3.0.1",
12
+ "@xmtp/content-type-reaction": "^2.0.2",
13
+ "@xmtp/content-type-remote-attachment": "^2.0.4",
14
+ "@xmtp/content-type-reply": "^2.0.3",
15
+ "@xmtp/content-type-wallet-send-calls": "^2.0.0",
16
+ "@xmtp/node-bindings": "1.9.1",
17
+ "@xmtp/node-sdk": "^6.0.0",
18
+ "viem": "^2.52.2"
19
+ },
20
+ "telegram-bot": {},
21
+ "telegram": {
22
+ "@mtcute/bun": "0.30.1"
23
+ },
24
+ "discord-bot": {
25
+ "@discordjs/voice": "^0.19.2",
26
+ "discord.js": "^14.26.4",
27
+ "prism-media": "^1.3.5"
28
+ },
29
+ "whatsapp": {
30
+ "baileys": "7.0.0-rc14"
31
+ },
32
+ "webhook": {}
33
+ }
34
+ }