@stage-labs/metro 0.1.0-beta.41 → 0.1.0-beta.43
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/api.js +19 -2
- package/dist/cli.js +11 -8
- package/dist/local.js +67 -0
- package/package.json +1 -1
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/claude-api.ts +95 -0
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/claude-files.ts +321 -0
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/connector-url.ts +20 -7
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/local-cli-api.ts +70 -0
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/local-mode.ts +67 -22
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/relay.ts +3 -2
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/session-apis.ts +7 -8
- package/runtime/node_modules/@metro-labs/mcp/src/db/connectors.ts +12 -2
- package/runtime/node_modules/@metro-labs/mcp/src/db/file-admin.ts +36 -1
- package/runtime/node_modules/@metro-labs/mcp/src/db/file-source.ts +11 -0
- package/runtime/node_modules/@metro-labs/mcp/src/db/local-connectors.ts +312 -0
- package/runtime/runtime.json +1 -1
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/hosted-connectors.ts +0 -118
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/local-connectors-api.ts +0 -56
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
2
|
+
import { errMsg, log } from './log.js';
|
|
3
|
+
import { cors, sendJson, type AgentIdentity } from './api-http.js';
|
|
4
|
+
import { publicBaseOrDefault } from './attach-serve.js';
|
|
5
|
+
import { relayServersJson, type RelayServerEntry } from './connector-json.js';
|
|
6
|
+
import { agentIdForKey } from '../db/key-map.js';
|
|
7
|
+
import type { ConnectorSummary } from '../db/connectors.js';
|
|
8
|
+
|
|
9
|
+
const PREFIX = '/api/cli';
|
|
10
|
+
|
|
11
|
+
export interface LocalCliDeps {
|
|
12
|
+
agentName: (agentId: string) => string | null;
|
|
13
|
+
connectorEntries: (agentId: string) => Promise<RelayServerEntry[]>;
|
|
14
|
+
connectorSummaries: (agentId: string) => Promise<ConnectorSummary[]>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function keyOf(req: IncomingMessage): string {
|
|
18
|
+
const header = req.headers.authorization ?? '';
|
|
19
|
+
if (header.toLowerCase().startsWith('bearer ')) return header.slice(7).trim();
|
|
20
|
+
const query = new URLSearchParams((req.url ?? '').split('?')[1] ?? '');
|
|
21
|
+
return query.get('token')?.trim() ?? '';
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function keyIdentity(req: IncomingMessage): AgentIdentity | null {
|
|
25
|
+
const key = keyOf(req);
|
|
26
|
+
const agentId = key === '' ? undefined : agentIdForKey(key);
|
|
27
|
+
return agentId === undefined ? null : { subject: 'agent-key', agentId };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function answer(path: string, key: string, agentId: string, deps: LocalCliDeps): Promise<unknown> {
|
|
31
|
+
const agent = deps.agentName(agentId) ?? '';
|
|
32
|
+
if (path === `${PREFIX}/session`) return { email: 'this machine', subject: 'this machine', agent };
|
|
33
|
+
if (path === `${PREFIX}/connectors`) return { agent, connectors: await deps.connectorSummaries(agentId) };
|
|
34
|
+
return { json: relayServersJson(await deps.connectorEntries(agentId), publicBaseOrDefault(), key), agent };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function handleLocalCliRequest(
|
|
38
|
+
req: IncomingMessage,
|
|
39
|
+
res: ServerResponse,
|
|
40
|
+
deps: LocalCliDeps,
|
|
41
|
+
): boolean {
|
|
42
|
+
const path = (req.url ?? '').split('?')[0] ?? '';
|
|
43
|
+
if (path !== PREFIX && !path.startsWith(`${PREFIX}/`)) return false;
|
|
44
|
+
if (req.method === 'OPTIONS') {
|
|
45
|
+
res.writeHead(204, cors(req)).end();
|
|
46
|
+
return true;
|
|
47
|
+
}
|
|
48
|
+
if (![`${PREFIX}/mcp`, `${PREFIX}/session`, `${PREFIX}/connectors`].includes(path)) {
|
|
49
|
+
sendJson(req, res, 404, { error: 'not on a local daemon' });
|
|
50
|
+
return true;
|
|
51
|
+
}
|
|
52
|
+
if (req.method !== 'GET') {
|
|
53
|
+
sendJson(req, res, 405, { error: 'method not allowed' });
|
|
54
|
+
return true;
|
|
55
|
+
}
|
|
56
|
+
const who = keyIdentity(req);
|
|
57
|
+
if (who === null) {
|
|
58
|
+
sendJson(req, res, 401, { error: 'unauthorized' });
|
|
59
|
+
return true;
|
|
60
|
+
}
|
|
61
|
+
answer(path, keyOf(req), who.agentId, deps)
|
|
62
|
+
.then((body) => {
|
|
63
|
+
sendJson(req, res, 200, body);
|
|
64
|
+
})
|
|
65
|
+
.catch((err: unknown) => {
|
|
66
|
+
log.warn({ err: errMsg(err) }, 'local cli: request failed');
|
|
67
|
+
if (!res.headersSent) sendJson(req, res, 500, { error: 'cli failed' });
|
|
68
|
+
});
|
|
69
|
+
return true;
|
|
70
|
+
}
|
|
@@ -10,10 +10,31 @@ import type { SessionApis } from './session-apis.js';
|
|
|
10
10
|
import type { ModeInfo } from './mode-api.js';
|
|
11
11
|
import type { ImportApiDeps } from './import-api.js';
|
|
12
12
|
import { fetchAgentWithCode } from './agent-import.js';
|
|
13
|
-
import {
|
|
13
|
+
import type { ConnectorApiDeps } from './connector-api.js';
|
|
14
|
+
import { allowLocalConnectors } from './connector-url.js';
|
|
15
|
+
import { keyIdentity, type LocalCliDeps } from './local-cli-api.js';
|
|
16
|
+
import type { RelayApiDeps } from './relay.js';
|
|
17
|
+
import {
|
|
18
|
+
localAddConnector,
|
|
19
|
+
localAgentConnectors,
|
|
20
|
+
localConnectorNamesByIds,
|
|
21
|
+
localConnectorSummariesByIds,
|
|
22
|
+
localCreateConnector,
|
|
23
|
+
localCreatePendingConnector,
|
|
24
|
+
localDeleteConnector,
|
|
25
|
+
localDisconnectConnector,
|
|
26
|
+
localGetConnector,
|
|
27
|
+
localListConnectors,
|
|
28
|
+
localReconnectConnector,
|
|
29
|
+
localRelayTarget,
|
|
30
|
+
localRemoveConnector,
|
|
31
|
+
localRenameConnector,
|
|
32
|
+
localVerifyConnector,
|
|
33
|
+
} from '../db/local-connectors.js';
|
|
14
34
|
import {
|
|
15
35
|
assertLocalOwner,
|
|
16
36
|
claimLocalOwner,
|
|
37
|
+
connectorIdsOfLocalAgent,
|
|
17
38
|
LOCAL_PROJECT_ID,
|
|
18
39
|
localAttachAccount,
|
|
19
40
|
localCreateAgent,
|
|
@@ -26,6 +47,7 @@ import {
|
|
|
26
47
|
localResetAgentKey,
|
|
27
48
|
} from '../db/file-admin.js';
|
|
28
49
|
import { normalizeAddress } from '../db/users.js';
|
|
50
|
+
import { listAgentFiles, readAgentFile } from '../db/file-source.js';
|
|
29
51
|
import type { Project } from '../db/projects.js';
|
|
30
52
|
import type { StationName } from '../db/schema.js';
|
|
31
53
|
|
|
@@ -124,32 +146,52 @@ const projectApi: ProjectApiDeps = {
|
|
|
124
146
|
removeMember: () => notHere('members'),
|
|
125
147
|
};
|
|
126
148
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
ids.map(async (id) => [id, (await hostedConnectorsFor(id)).map((c) => c.id)] as const),
|
|
130
|
-
);
|
|
131
|
-
return new Map(pairs);
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
async function connectorsOfOwner(subject: string): Promise<HostedConnector[]> {
|
|
135
|
-
const agents = await localListAgents(subject, LOCAL_PROJECT_ID).catch(() => []);
|
|
136
|
-
const lists = await Promise.all(agents.map((a) => hostedConnectorsFor(a.id)));
|
|
137
|
-
const seen = new Map<string, HostedConnector>();
|
|
138
|
-
for (const c of lists.flat()) seen.set(c.id, c);
|
|
139
|
-
return [...seen.values()].sort((a, b) => a.name.localeCompare(b.name));
|
|
149
|
+
function connectorIdsOfLocalAgents(ids: string[]): Promise<Map<string, string[]>> {
|
|
150
|
+
return Promise.resolve(new Map(ids.map((id) => [id, connectorIdsOfLocalAgent(id) ?? []] as const)));
|
|
140
151
|
}
|
|
141
152
|
|
|
142
153
|
const agentConnectorApi: AgentConnectorApiDeps = {
|
|
143
|
-
agentConnectors:
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
},
|
|
147
|
-
addConnector: () => notHere('connectors'),
|
|
148
|
-
removeConnector: () => notHere('connectors'),
|
|
154
|
+
agentConnectors: localAgentConnectors,
|
|
155
|
+
addConnector: localAddConnector,
|
|
156
|
+
removeConnector: localRemoveConnector,
|
|
149
157
|
mintCode: () =>
|
|
150
158
|
Promise.reject(new ApiError('a local daemon has no pairing codes', 400)),
|
|
151
159
|
};
|
|
152
160
|
|
|
161
|
+
const connectorApi: ConnectorApiDeps = {
|
|
162
|
+
listConnectors: localListConnectors,
|
|
163
|
+
connectorSummariesByIds: (ids) => localConnectorSummariesByIds(ids),
|
|
164
|
+
connectorNamesByIds: (ids) => localConnectorNamesByIds(ids),
|
|
165
|
+
agentConnectors: localAgentConnectors,
|
|
166
|
+
fenceRuntime: () => Promise.resolve(),
|
|
167
|
+
createConnector: localCreateConnector,
|
|
168
|
+
verifyConnector: localVerifyConnector,
|
|
169
|
+
disconnectConnector: localDisconnectConnector,
|
|
170
|
+
renameConnector: localRenameConnector,
|
|
171
|
+
deleteConnector: localDeleteConnector,
|
|
172
|
+
createPendingConnector: localCreatePendingConnector,
|
|
173
|
+
reconnectConnector: localReconnectConnector,
|
|
174
|
+
getConnector: localGetConnector,
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
const relayApi: RelayApiDeps = {
|
|
178
|
+
target: (agentId, connectorId, force) => localRelayTarget(agentId, connectorId, force),
|
|
179
|
+
fence: () => Promise.resolve(),
|
|
180
|
+
identify: keyIdentity,
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
function agentNameOf(agentId: string): string | null {
|
|
184
|
+
return listAgentFiles()
|
|
185
|
+
.map((path) => readAgentFile(path))
|
|
186
|
+
.find((file) => file.id === agentId)?.name ?? null;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const localCli: LocalCliDeps = {
|
|
190
|
+
agentName: agentNameOf,
|
|
191
|
+
connectorEntries: (agentId) => localConnectorNamesByIds(connectorIdsOfLocalAgent(agentId) ?? []),
|
|
192
|
+
connectorSummaries: (agentId) => localConnectorSummariesByIds(connectorIdsOfLocalAgent(agentId) ?? []),
|
|
193
|
+
};
|
|
194
|
+
|
|
153
195
|
function importApi(deps: LocalModeDeps): ImportApiDeps {
|
|
154
196
|
const fetchAgent = deps.fetchAgent ?? fetchAgentWithCode;
|
|
155
197
|
return {
|
|
@@ -157,7 +199,6 @@ function importApi(deps: LocalModeDeps): ImportApiDeps {
|
|
|
157
199
|
assertLocalOwner(subject);
|
|
158
200
|
const agent = await fetchAgent(code, hostname());
|
|
159
201
|
const made = await localImportAgent(subject, agent);
|
|
160
|
-
forgetHostedConnectors();
|
|
161
202
|
for (const station of new Set(agent.accounts.map((a) => a.station)))
|
|
162
203
|
await deps.syncStations(station).catch((err: unknown) => {
|
|
163
204
|
log.warn(
|
|
@@ -175,11 +216,15 @@ export function localModeInfo(): ModeInfo {
|
|
|
175
216
|
}
|
|
176
217
|
|
|
177
218
|
export function localSessionApis(deps: LocalModeDeps): SessionApis {
|
|
219
|
+
allowLocalConnectors(true);
|
|
178
220
|
return {
|
|
179
221
|
agentApi: agentApi(deps),
|
|
180
222
|
agentConnectorApi,
|
|
181
223
|
importApi: importApi(deps),
|
|
182
|
-
|
|
224
|
+
connectorApi,
|
|
225
|
+
relayApi,
|
|
226
|
+
localCli,
|
|
227
|
+
claudeApi: { authorize: (subject) => { assertLocalOwner(subject); } },
|
|
183
228
|
projectApi,
|
|
184
229
|
siwe: { ensureUser: claimLocalOwner },
|
|
185
230
|
mode: localModeInfo,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
2
2
|
import type { RelayTarget } from '../db/connector-relay.js';
|
|
3
|
-
import { agentIdentity, assertLease, type Fence } from './api-http.js';
|
|
3
|
+
import { agentIdentity, assertLease, type AgentIdentity, type Fence } from './api-http.js';
|
|
4
4
|
import { ApiError } from './api-error.js';
|
|
5
5
|
import { errMsg, log } from './log.js';
|
|
6
6
|
|
|
@@ -11,6 +11,7 @@ export interface RelayApiDeps {
|
|
|
11
11
|
force: boolean,
|
|
12
12
|
) => Promise<RelayTarget>;
|
|
13
13
|
fence: Fence;
|
|
14
|
+
identify?: (req: IncomingMessage) => AgentIdentity | null;
|
|
14
15
|
}
|
|
15
16
|
|
|
16
17
|
const ID_PATH_RE = /^\/relay\/([A-Za-z0-9][A-Za-z0-9_-]{10})$/;
|
|
@@ -263,7 +264,7 @@ function dispatch(
|
|
|
263
264
|
connectorId: string,
|
|
264
265
|
deps: RelayApiDeps,
|
|
265
266
|
): void {
|
|
266
|
-
const who = agentIdentity(req);
|
|
267
|
+
const who = (deps.identify ?? agentIdentity)(req);
|
|
267
268
|
if (who === null) {
|
|
268
269
|
answer(res, 401, { error: 'unauthorized' });
|
|
269
270
|
return;
|
|
@@ -18,17 +18,16 @@ import {
|
|
|
18
18
|
} from './agent-connector-api.js';
|
|
19
19
|
import type { SiweRouteDeps } from './siwe-routes.js';
|
|
20
20
|
import { handleImportRequest, type ImportApiDeps } from './import-api.js';
|
|
21
|
-
import {
|
|
22
|
-
|
|
23
|
-
type LocalConnectorsDeps,
|
|
24
|
-
} from './local-connectors-api.js';
|
|
21
|
+
import { handleLocalCliRequest, type LocalCliDeps } from './local-cli-api.js';
|
|
22
|
+
import { handleClaudeRequest, type ClaudeApiDeps } from './claude-api.js';
|
|
25
23
|
import type { ModeInfo } from './mode-api.js';
|
|
26
24
|
|
|
27
25
|
export interface SessionApis {
|
|
28
26
|
agentApi?: AgentApiDeps;
|
|
29
27
|
agentConnectorApi?: AgentConnectorApiDeps;
|
|
30
28
|
importApi?: ImportApiDeps;
|
|
31
|
-
|
|
29
|
+
localCli?: LocalCliDeps;
|
|
30
|
+
claudeApi?: ClaudeApiDeps;
|
|
32
31
|
connectorApi?: ConnectorApiDeps;
|
|
33
32
|
projectApi?: ProjectApiDeps;
|
|
34
33
|
runApi?: RunApiDeps;
|
|
@@ -43,8 +42,9 @@ export function handleSessionApis(
|
|
|
43
42
|
apis: SessionApis,
|
|
44
43
|
): boolean {
|
|
45
44
|
const { agentApi, agentConnectorApi, connectorApi, projectApi, runApi, importApi } = apis;
|
|
46
|
-
const {
|
|
45
|
+
const { localCli, claudeApi } = apis;
|
|
47
46
|
const routes: (() => boolean)[] = [() => handleSessionApiRequest(req, res)];
|
|
47
|
+
if (localCli) routes.push(() => handleLocalCliRequest(req, res, localCli));
|
|
48
48
|
if (runApi) routes.push(() => handleRunApiRequest(req, res, runApi));
|
|
49
49
|
if (projectApi)
|
|
50
50
|
routes.push(() => handleProjectApiRequest(req, res, projectApi));
|
|
@@ -54,8 +54,7 @@ export function handleSessionApis(
|
|
|
54
54
|
() => handleConnectorApiRequest(req, res, connectorApi),
|
|
55
55
|
);
|
|
56
56
|
if (importApi) routes.push(() => handleImportRequest(req, res, importApi));
|
|
57
|
-
if (
|
|
58
|
-
routes.push(() => handleLocalConnectorsRequest(req, res, localConnectors));
|
|
57
|
+
if (claudeApi) routes.push(() => handleClaudeRequest(req, res, claudeApi));
|
|
59
58
|
if (agentConnectorApi)
|
|
60
59
|
routes.push(() => handleAgentConnectorRequest(req, res, agentConnectorApi));
|
|
61
60
|
if (agentApi) routes.push(() => handleAgentApiRequest(req, res, agentApi));
|
|
@@ -58,7 +58,15 @@ export interface DeletedConnector {
|
|
|
58
58
|
|
|
59
59
|
type ConnectorRow = typeof connectors.$inferSelect;
|
|
60
60
|
|
|
61
|
-
|
|
61
|
+
export interface ConnectorLike {
|
|
62
|
+
id: string;
|
|
63
|
+
name: string;
|
|
64
|
+
url: string;
|
|
65
|
+
transport: ConnectorTransport;
|
|
66
|
+
config: unknown;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function connectorFromRow(row: ConnectorLike): Connector {
|
|
62
70
|
const config = readConfig(row.config);
|
|
63
71
|
const auth = config.auth;
|
|
64
72
|
return {
|
|
@@ -76,6 +84,8 @@ function toConnector(row: ConnectorRow): Connector {
|
|
|
76
84
|
};
|
|
77
85
|
}
|
|
78
86
|
|
|
87
|
+
const toConnector = (row: ConnectorRow): Connector => connectorFromRow(row);
|
|
88
|
+
|
|
79
89
|
const missing = (): ConnectorError =>
|
|
80
90
|
new ConnectorError('no such connector', 404);
|
|
81
91
|
|
|
@@ -193,7 +203,7 @@ async function insertConnector(
|
|
|
193
203
|
return toConnector(row);
|
|
194
204
|
}
|
|
195
205
|
|
|
196
|
-
const UNVERIFIED = {
|
|
206
|
+
export const UNVERIFIED = {
|
|
197
207
|
at: '',
|
|
198
208
|
server: '',
|
|
199
209
|
version: '',
|
|
@@ -153,7 +153,7 @@ export async function localCreateAgent(
|
|
|
153
153
|
ensureSecureDir(folder);
|
|
154
154
|
save({
|
|
155
155
|
path: join(folder, AGENT_FILE),
|
|
156
|
-
file: { version: 1, id, name, key, owner: localOwner(dir), stations: [] },
|
|
156
|
+
file: { version: 1, id, name, key, owner: localOwner(dir), stations: [], connectors: [] },
|
|
157
157
|
});
|
|
158
158
|
registerKey(key, id);
|
|
159
159
|
return Promise.resolve({ id, name, key });
|
|
@@ -247,6 +247,7 @@ function fileFor(agent: LoadedAgent, owner: string | null, path: string): AgentF
|
|
|
247
247
|
key: agent.key,
|
|
248
248
|
owner,
|
|
249
249
|
stations: agent.accounts.map((a) => ({ ...a, allowlist: a.allowlist ?? ['*'] })),
|
|
250
|
+
connectors: [],
|
|
250
251
|
}),
|
|
251
252
|
path,
|
|
252
253
|
);
|
|
@@ -257,6 +258,40 @@ function fileFor(agent: LoadedAgent, owner: string | null, path: string): AgentF
|
|
|
257
258
|
}
|
|
258
259
|
}
|
|
259
260
|
|
|
261
|
+
export function connectorIdsOfLocalAgent(agentId: string, dir = agentsDir()): string[] | null {
|
|
262
|
+
const found = storedAgents(dir).find((s) => s.file.id === agentId);
|
|
263
|
+
return found === undefined ? null : found.file.connectors;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
export function localAgentConnectorIds(subject: string, agentId: string, dir = agentsDir()): string[] {
|
|
267
|
+
return ownedOrThrow(subject, agentId, dir).file.connectors;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
export function localSetAgentConnectors(
|
|
271
|
+
subject: string,
|
|
272
|
+
agentId: string,
|
|
273
|
+
ids: string[],
|
|
274
|
+
dir = agentsDir(),
|
|
275
|
+
): void {
|
|
276
|
+
const stored = ownedOrThrow(subject, agentId, dir);
|
|
277
|
+
stored.file.connectors = ids;
|
|
278
|
+
save(stored);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
export function localAgentsWith(connectorId: string, dir = agentsDir()): { id: string; name: string }[] {
|
|
282
|
+
return storedAgents(dir)
|
|
283
|
+
.filter((s) => s.file.connectors.includes(connectorId))
|
|
284
|
+
.map((s) => ({ id: s.file.id, name: s.file.name }));
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
export function localDropConnectorEverywhere(connectorId: string, dir = agentsDir()): void {
|
|
288
|
+
for (const stored of storedAgents(dir)) {
|
|
289
|
+
if (!stored.file.connectors.includes(connectorId)) continue;
|
|
290
|
+
stored.file.connectors = stored.file.connectors.filter((id) => id !== connectorId);
|
|
291
|
+
save(stored);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
260
295
|
function removeIfEmpty(folder: string): void {
|
|
261
296
|
try {
|
|
262
297
|
rmdirSync(folder);
|
|
@@ -20,6 +20,7 @@ export interface AgentFile {
|
|
|
20
20
|
key: string | null;
|
|
21
21
|
owner: string | null;
|
|
22
22
|
stations: LoadedAccount[];
|
|
23
|
+
connectors: string[];
|
|
23
24
|
}
|
|
24
25
|
|
|
25
26
|
export function agentsDir(): string {
|
|
@@ -71,6 +72,15 @@ function optionalMatch(
|
|
|
71
72
|
return value;
|
|
72
73
|
}
|
|
73
74
|
|
|
75
|
+
function connectorsOf(raw: unknown, path: string): string[] {
|
|
76
|
+
if (raw === undefined || raw === null) return [];
|
|
77
|
+
const list: unknown[] = Array.isArray(raw) ? raw.map((item: unknown) => item) : [];
|
|
78
|
+
const ids = list.filter((id): id is string => typeof id === 'string' && ID_RE.test(id));
|
|
79
|
+
if (!Array.isArray(raw) || ids.length !== list.length)
|
|
80
|
+
fail(path, 'connectors is not a list of 11-character ids');
|
|
81
|
+
return ids;
|
|
82
|
+
}
|
|
83
|
+
|
|
74
84
|
export function parseAgentFile(raw: string, path: string): AgentFile {
|
|
75
85
|
let parsed: unknown;
|
|
76
86
|
try {
|
|
@@ -100,6 +110,7 @@ export function parseAgentFile(raw: string, path: string): AgentFile {
|
|
|
100
110
|
key,
|
|
101
111
|
owner,
|
|
102
112
|
stations: stations.map((s, i) => stationOf(s, path, i)),
|
|
113
|
+
connectors: connectorsOf(parsed.connectors, path),
|
|
103
114
|
};
|
|
104
115
|
}
|
|
105
116
|
|