@stage-labs/metro 0.1.0-beta.47 → 0.1.0-beta.48

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stage-labs/metro",
3
- "version": "0.1.0-beta.47",
3
+ "version": "0.1.0-beta.48",
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": {
@@ -15,6 +15,7 @@ const PATH = '/api/agents/import';
15
15
 
16
16
  export interface ImportedAgent extends CreatedAgent {
17
17
  stations: number;
18
+ connectors: number;
18
19
  }
19
20
 
20
21
  export interface ImportApiDeps {
@@ -31,7 +32,7 @@ async function handle(
31
32
  const code = parsePairingCode(bodyField(await readJsonBody(req), 'code'));
32
33
  const made = await deps.importAgent(subject, code);
33
34
  log.info(
34
- { agent: made.name, id: made.id, stations: made.stations },
35
+ { agent: made.name, id: made.id, stations: made.stations, connectors: made.connectors },
35
36
  'import-api: agent imported from metro',
36
37
  );
37
38
  sendJson(req, res, 201, made);
@@ -14,6 +14,7 @@ import { allowLocalConnectors } from './connector-url.js';
14
14
  import { keyIdentity, type LocalCliDeps } from './local-cli-api.js';
15
15
  import type { RelayApiDeps } from './relay.js';
16
16
  import {
17
+ localImportConnectors,
17
18
  localAddConnector,
18
19
  localAgentConnectors,
19
20
  localConnectorNamesByIds,
@@ -164,6 +165,7 @@ function importApi(deps: LocalModeDeps): ImportApiDeps {
164
165
  assertLocalOwner(subject);
165
166
  const agent = await fetchAgent(code, hostname());
166
167
  const made = await localImportAgent(subject, agent);
168
+ const connectors = localImportConnectors(agent.connectors ?? []);
167
169
  for (const station of new Set(agent.accounts.map((a) => a.station)))
168
170
  await deps.syncStations(station).catch((err: unknown) => {
169
171
  log.warn(
@@ -171,7 +173,7 @@ function importApi(deps: LocalModeDeps): ImportApiDeps {
171
173
  'import: station reload failed, the change lands at the next boot',
172
174
  );
173
175
  });
174
- return made;
176
+ return { ...made, connectors };
175
177
  },
176
178
  };
177
179
  }
@@ -187,12 +187,7 @@ export function assertLocalOwner(subject: string, dir = agentsDir()): void {
187
187
  if (!isOwner(subject, dir)) throw new AgentAdminError('no such project', 404);
188
188
  }
189
189
 
190
- export async function localImportAgent(
191
- subject: string,
192
- agent: LoadedAgent,
193
- dir = agentsDir(),
194
- ): Promise<{ id: string; name: string; key: string; stations: number }> {
195
- assertLocalOwner(subject, dir);
190
+ function assertImportable(agent: LoadedAgent): asserts agent is LoadedAgent & { key: string } {
196
191
  const immovable = agent.accounts.find((a) => !MOVABLE_STATIONS.has(a.station));
197
192
  if (immovable !== undefined)
198
193
  throw new AgentAdminError(
@@ -201,37 +196,71 @@ export async function localImportAgent(
201
196
  );
202
197
  if (agent.key === null)
203
198
  throw new AgentAdminError('that agent has no key; reset it on metro.box first', 400);
204
- const folder = join(dir, agent.name);
205
- const path = join(folder, AGENT_FILE);
206
- if (existsSync(path))
199
+ }
200
+
201
+ interface ImportTarget {
202
+ path: string;
203
+ previous: AgentFile | undefined;
204
+ }
205
+
206
+ function importTarget(dir: string, agent: LoadedAgent & { key: string }): ImportTarget {
207
+ const existing = storedAgents(dir);
208
+ const same = existing.find((s) => s.file.id === agent.id);
209
+ const fresh = join(dir, agent.name, AGENT_FILE);
210
+ if (same === undefined && existsSync(fresh))
207
211
  throw new AgentAdminError(
208
212
  `an agent named '${agent.name}' already exists on this machine`,
209
213
  409,
210
214
  );
211
- assertUnclaimed(storedAgents(dir), agent);
212
- const file = fileFor(agent, localOwner(dir), path);
213
- ensureSecureDir(folder);
215
+ assertUnclaimed(existing.filter((s) => s !== same), agent);
216
+ return { path: same?.path ?? fresh, previous: same?.file };
217
+ }
218
+
219
+ export async function localImportAgent(
220
+ subject: string,
221
+ agent: LoadedAgent,
222
+ dir = agentsDir(),
223
+ ): Promise<{ id: string; name: string; key: string; stations: number }> {
224
+ assertLocalOwner(subject, dir);
225
+ assertImportable(agent);
226
+ const { path, previous } = importTarget(dir, agent);
227
+ const file = fileFor(agent, localOwner(dir), path, previous);
228
+ ensureSecureDir(join(path, '..'));
214
229
  save({ path, file });
230
+ if (previous !== undefined && previous.key !== agent.key) unregisterAgentKey(agent.id);
215
231
  registerKey(agent.key, agent.id);
216
232
  return Promise.resolve({
217
233
  id: agent.id,
218
234
  name: agent.name,
219
235
  key: agent.key,
220
- stations: agent.accounts.length,
236
+ stations: file.stations.length,
221
237
  });
222
238
  }
223
239
 
224
- function fileFor(agent: LoadedAgent, owner: string | null, path: string): AgentFile {
240
+ function mergedStations(agent: LoadedAgent, previous: AgentFile | undefined): AgentFile['stations'] {
241
+ const fromMetro = agent.accounts.map((a) => ({ ...a, allowlist: a.allowlist ?? ['*'] }));
242
+ const kept = (previous?.stations ?? []).filter(
243
+ (s) => !fromMetro.some((m) => m.station === s.station && m.id === s.id),
244
+ );
245
+ return [...fromMetro, ...kept];
246
+ }
247
+
248
+ function fileFor(
249
+ agent: LoadedAgent,
250
+ owner: string | null,
251
+ path: string,
252
+ previous?: AgentFile,
253
+ ): AgentFile {
225
254
  try {
226
255
  return parseAgentFile(
227
256
  JSON.stringify({
228
257
  version: 1,
229
258
  id: agent.id,
230
- name: agent.name,
259
+ name: previous?.name ?? agent.name,
231
260
  key: agent.key,
232
261
  owner,
233
- stations: agent.accounts.map((a) => ({ ...a, allowlist: a.allowlist ?? ['*'] })),
234
- connectors: [],
262
+ stations: mergedStations(agent, previous),
263
+ connectors: previous?.connectors ?? [],
235
264
  }),
236
265
  path,
237
266
  );
@@ -48,6 +48,7 @@ import {
48
48
  LOCAL_PROJECT_ID,
49
49
  } from './file-admin.js';
50
50
  import { newId } from './ids.js';
51
+ import type { LoadedConnector } from './materialize.js';
51
52
 
52
53
  export interface LocalConnectorRow {
53
54
  id: string;
@@ -113,6 +114,26 @@ async function oauthCapable(url: URL, auth: ConnectorAuth): Promise<boolean> {
113
114
  return advertisesOAuth(url);
114
115
  }
115
116
 
117
+ export function localImportConnectors(rows: LoadedConnector[], dir = agentsDir()): number {
118
+ const current = readLocalConnectors(dir);
119
+ for (const row of rows) {
120
+ const other = current.find((r) => r.id !== row.id && r.name === row.name);
121
+ if (other !== undefined)
122
+ throw new ConnectorError(`a connector named '${row.name}' already exists on this daemon; rename it first`, 409);
123
+ }
124
+ const imported: LocalConnectorRow[] = rows.map((row) => ({
125
+ id: row.id,
126
+ name: row.name,
127
+ url: row.url,
128
+ transport: 'http',
129
+ config: readConfig(row.config),
130
+ }));
131
+ const ids = new Set(imported.map((r) => r.id));
132
+ writeRows(dir, [...current.filter((r) => !ids.has(r.id)), ...imported]);
133
+ for (const row of imported) localHoldEverywhere(row.id, dir);
134
+ return imported.length;
135
+ }
136
+
116
137
  export async function localListConnectors(
117
138
  subject: string,
118
139
  project: string,
@@ -21,7 +21,14 @@ import {
21
21
  type AllowlistMap,
22
22
  } from './agent-map.js';
23
23
  import { setKeyMap } from './key-map.js';
24
- import { STATIONS, stations, agents, type StationName } from './schema.js';
24
+ import {
25
+ agentConnectors,
26
+ agents,
27
+ connectors,
28
+ stations,
29
+ STATIONS,
30
+ type StationName,
31
+ } from './schema.js';
25
32
 
26
33
  interface StationTarget {
27
34
  file: string;
@@ -36,11 +43,20 @@ export interface LoadedAccount {
36
43
  config: Record<string, unknown>;
37
44
  }
38
45
 
46
+ export interface LoadedConnector {
47
+ id: string;
48
+ name: string;
49
+ url: string;
50
+ transport: 'http';
51
+ config: Record<string, unknown>;
52
+ }
53
+
39
54
  export interface LoadedAgent {
40
55
  id: string;
41
56
  name: string;
42
57
  accounts: LoadedAccount[];
43
58
  key: string | null;
59
+ connectors?: LoadedConnector[];
44
60
  }
45
61
 
46
62
  const METRO_DIR = join(homedir(), '.metro');
@@ -133,10 +149,22 @@ export async function loadAgentForRuntime(
133
149
  .select()
134
150
  .from(stations)
135
151
  .where(eq(stations.agentId, a.id));
152
+ const held = await db
153
+ .select({ id: connectors.id, name: connectors.name, url: connectors.url, config: connectors.config })
154
+ .from(agentConnectors)
155
+ .innerJoin(connectors, eq(connectors.id, agentConnectors.connectorId))
156
+ .where(eq(agentConnectors.agentId, a.id));
136
157
  return {
137
158
  id: a.id,
138
159
  name: a.name,
139
160
  key: a.key,
161
+ connectors: held.map((c) => ({
162
+ id: c.id,
163
+ name: c.name,
164
+ url: c.url,
165
+ transport: 'http' as const,
166
+ config: c.config as Record<string, unknown>,
167
+ })),
140
168
  accounts: acctRows
141
169
  .filter((r) => MOVABLE_STATIONS.has(r.station))
142
170
  .map((r) => ({
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "0.1.0-beta.47"
2
+ "version": "0.1.0-beta.48"
3
3
  }