@stage-labs/metro 0.1.0-beta.46 → 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/dist/cli.js +1 -1
- package/dist/serve.js +34 -4
- package/package.json +1 -1
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/boot.ts +8 -3
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/connect-hint.ts +9 -3
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/import-api.ts +2 -1
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/local-mode.ts +5 -3
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/local-owner.ts +14 -0
- package/runtime/node_modules/@metro-labs/mcp/src/db/file-admin.ts +60 -47
- package/runtime/node_modules/@metro-labs/mcp/src/db/local-connectors.ts +21 -0
- package/runtime/node_modules/@metro-labs/mcp/src/db/materialize.ts +29 -1
- package/runtime/runtime.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -16,7 +16,7 @@ const USAGE = `metro — the command line for your MCP connectors
|
|
|
16
16
|
|
|
17
17
|
metro start <agent-id> [--detach]
|
|
18
18
|
run that agent's stations on this machine
|
|
19
|
-
metro serve [--port <n>] [--tunnel]
|
|
19
|
+
metro serve [--port <n>] [--tunnel] [--owner <address>]
|
|
20
20
|
run a daemon of your own: its agents and stations live in ~/.metro/agents
|
|
21
21
|
here, and metro.box manages it through the link it prints; --tunnel adds
|
|
22
22
|
a public https address through a Cloudflare quick tunnel (needs
|
package/dist/serve.js
CHANGED
|
@@ -1,25 +1,45 @@
|
|
|
1
1
|
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
2
3
|
import { join } from 'node:path';
|
|
4
|
+
import { agentsDir } from './local.js';
|
|
3
5
|
import { serveLockedBy, serveStateDir } from './control.js';
|
|
4
6
|
import { findBun, localPort, runtimeDir, SERVER_ENTRY, spawnPlan, } from './runtime.js';
|
|
5
7
|
const SCRUBBED = new Set(['METRO_RUN_TOKEN', 'METRO_AGENT', 'DATABASE_URL']);
|
|
6
8
|
const PORT_FLAG = /^--port=(.*)$/;
|
|
7
|
-
const
|
|
9
|
+
const OWNER_FLAG = /^--owner=(.*)$/;
|
|
10
|
+
const ADDRESS = /^0x[0-9a-fA-F]{40}$/;
|
|
11
|
+
const USAGE = 'usage: metro serve [--port <n>] [--tunnel] [--owner <address>]';
|
|
8
12
|
function portOf(raw) {
|
|
9
13
|
const n = Number(raw);
|
|
10
14
|
if (raw === undefined || raw === '' || !Number.isInteger(n) || n < 1 || n > 65535)
|
|
11
15
|
throw new Error(`'${raw ?? ''}' is not a port — ${USAGE}`);
|
|
12
16
|
return n;
|
|
13
17
|
}
|
|
18
|
+
function ownerOf(raw) {
|
|
19
|
+
if (raw === undefined || !ADDRESS.test(raw))
|
|
20
|
+
throw new Error(`'${raw ?? ''}' is not an Ethereum address — ${USAGE}`);
|
|
21
|
+
return raw.toLowerCase();
|
|
22
|
+
}
|
|
14
23
|
export function parseServeArgs(argv) {
|
|
15
24
|
let port = localPort();
|
|
16
25
|
let tunnel = false;
|
|
26
|
+
let owner = null;
|
|
17
27
|
for (let i = 0; i < argv.length; i += 1) {
|
|
18
28
|
const arg = argv[i] ?? '';
|
|
19
29
|
if (arg === '--tunnel') {
|
|
20
30
|
tunnel = true;
|
|
21
31
|
continue;
|
|
22
32
|
}
|
|
33
|
+
const inlineOwner = OWNER_FLAG.exec(arg);
|
|
34
|
+
if (inlineOwner) {
|
|
35
|
+
owner = ownerOf(inlineOwner[1]);
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
if (arg === '--owner') {
|
|
39
|
+
owner = ownerOf(argv[i + 1]);
|
|
40
|
+
i += 1;
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
23
43
|
const inline = PORT_FLAG.exec(arg);
|
|
24
44
|
if (inline) {
|
|
25
45
|
port = portOf(inline[1]);
|
|
@@ -32,7 +52,15 @@ export function parseServeArgs(argv) {
|
|
|
32
52
|
}
|
|
33
53
|
throw new Error(`unknown argument '${arg}' — ${USAGE}`);
|
|
34
54
|
}
|
|
35
|
-
return { port, tunnel };
|
|
55
|
+
return { port, tunnel, owner };
|
|
56
|
+
}
|
|
57
|
+
export function requireOwner(owner, dir = agentsDir()) {
|
|
58
|
+
if (owner !== null || existsSync(join(dir, '.owner')))
|
|
59
|
+
return;
|
|
60
|
+
throw new Error('no owner is set for this machine, so no wallet could sign in.\n' +
|
|
61
|
+
'Pass the wallet that owns it once; it is remembered in ' +
|
|
62
|
+
join(dir, '.owner') +
|
|
63
|
+
':\n metro serve --owner <address>');
|
|
36
64
|
}
|
|
37
65
|
function findCloudflared() {
|
|
38
66
|
const found = spawnSync('cloudflared', ['--version'], { stdio: 'ignore' });
|
|
@@ -60,18 +88,20 @@ export function servePlan(opts) {
|
|
|
60
88
|
METRO_TRAINS_DIR: join(opts.dir, 'trains'),
|
|
61
89
|
METRO_STATE_DIR: process.env.METRO_STATE_DIR ?? serveStateDir(),
|
|
62
90
|
...(opts.tunnel ? { METRO_TUNNEL: 'quick' } : {}),
|
|
91
|
+
...(opts.owner === null ? {} : { METRO_OWNER: opts.owner }),
|
|
63
92
|
},
|
|
64
93
|
};
|
|
65
94
|
}
|
|
66
95
|
export function serve(argv) {
|
|
67
|
-
const { port, tunnel } = parseServeArgs(argv);
|
|
96
|
+
const { port, tunnel, owner } = parseServeArgs(argv);
|
|
68
97
|
const running = serveLockedBy();
|
|
69
98
|
if (running !== null)
|
|
70
99
|
throw new Error(`a metro serve daemon is already running on this machine (pid ${String(running)}). ` +
|
|
71
100
|
'Stop it first: metro stop');
|
|
101
|
+
requireOwner(owner);
|
|
72
102
|
if (tunnel)
|
|
73
103
|
findCloudflared();
|
|
74
|
-
const plan = servePlan({ dir: runtimeDir(), port, tunnel });
|
|
104
|
+
const plan = servePlan({ dir: runtimeDir(), port, tunnel, owner });
|
|
75
105
|
process.stderr.write(`Starting a metro daemon of your own on http://127.0.0.1:${String(port)}\n`);
|
|
76
106
|
return spawnPlan(plan);
|
|
77
107
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stage-labs/metro",
|
|
3
|
-
"version": "0.1.0-beta.
|
|
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": {
|
|
@@ -33,6 +33,8 @@ import {
|
|
|
33
33
|
import { loadAgentForRuntime, localAgentKey } from '../db/materialize.js';
|
|
34
34
|
import { fileSource } from '../db/file-source.js';
|
|
35
35
|
import { ensureLocalSessionSecret } from './local-secret.js';
|
|
36
|
+
import { applyLocalOwner } from './local-owner.js';
|
|
37
|
+
import { localOwner } from '../db/file-admin.js';
|
|
36
38
|
import { localSessionApis } from './local-mode.js';
|
|
37
39
|
import type { ModeInfo } from './mode-api.js';
|
|
38
40
|
import type { SessionApis } from './session-apis.js';
|
|
@@ -124,7 +126,7 @@ let webhookServer: Server | null = null;
|
|
|
124
126
|
const tunnel = quickTunnelWanted() ? new Tunnel(webhookPort(), announcePublic) : null;
|
|
125
127
|
|
|
126
128
|
function announcePublic(url: string): void {
|
|
127
|
-
process.stderr.write(`\n${publicConnectHint(url)}`);
|
|
129
|
+
process.stderr.write(`\n${publicConnectHint(url, localOwner())}`);
|
|
128
130
|
}
|
|
129
131
|
|
|
130
132
|
setTrainCallBackend((train, action, args) =>
|
|
@@ -138,7 +140,7 @@ const localSource = linkedSource ?? (isLocalMode() ? fileSource : null);
|
|
|
138
140
|
function announceLocalEndpoint(): void {
|
|
139
141
|
if (isLocalMode())
|
|
140
142
|
process.stderr.write(
|
|
141
|
-
`\n${tunnel === null ? localConnectHint(webhookPort()) : tunnelPendingHint()}`,
|
|
143
|
+
`\n${tunnel === null ? localConnectHint(webhookPort(), localOwner()) : tunnelPendingHint()}`,
|
|
142
144
|
);
|
|
143
145
|
const key = localAgentKey();
|
|
144
146
|
if (key === null) {
|
|
@@ -303,7 +305,10 @@ function sessionApis(): SessionApis {
|
|
|
303
305
|
}
|
|
304
306
|
|
|
305
307
|
async function main(): Promise<void> {
|
|
306
|
-
if (isLocalMode())
|
|
308
|
+
if (isLocalMode()) {
|
|
309
|
+
ensureLocalSessionSecret();
|
|
310
|
+
applyLocalOwner();
|
|
311
|
+
}
|
|
307
312
|
if (localSource === null) await materializeFromDb();
|
|
308
313
|
else await materializeFrom(localSource, { allowEmpty: linkedSource === null });
|
|
309
314
|
supervisor.start();
|
|
@@ -5,7 +5,12 @@ const uiBase = (): string => process.env.METRO_UI_URL ?? 'https://metro.box';
|
|
|
5
5
|
const connectLink = (base: string): string =>
|
|
6
6
|
`${uiBase()}/#/${new URL(base).host}`;
|
|
7
7
|
|
|
8
|
-
|
|
8
|
+
const ownerLine = (owner: string | null): string =>
|
|
9
|
+
owner === null
|
|
10
|
+
? 'No owner is set, so no wallet can sign in. Restart with: metro serve --owner <address>'
|
|
11
|
+
: `Only ${owner} can sign in.`;
|
|
12
|
+
|
|
13
|
+
export function localConnectHint(port: number, owner: string | null): string {
|
|
9
14
|
const here = `http://127.0.0.1:${String(port)}`;
|
|
10
15
|
const forward = `ssh -L ${String(port)}:127.0.0.1:${String(port)} ${hostname()}`;
|
|
11
16
|
return [
|
|
@@ -14,17 +19,18 @@ export function localConnectHint(port: number): string {
|
|
|
14
19
|
` ${connectLink(here)}`,
|
|
15
20
|
'',
|
|
16
21
|
`From another computer, forward the port first: ${forward}`,
|
|
22
|
+
ownerLine(owner),
|
|
17
23
|
'',
|
|
18
24
|
].join('\n');
|
|
19
25
|
}
|
|
20
26
|
|
|
21
|
-
export function publicConnectHint(url: string): string {
|
|
27
|
+
export function publicConnectHint(url: string, owner: string | null): string {
|
|
22
28
|
return [
|
|
23
29
|
'Manage this machine from the web UI, from anywhere:',
|
|
24
30
|
'',
|
|
25
31
|
` ${connectLink(url)}`,
|
|
26
32
|
'',
|
|
27
|
-
|
|
33
|
+
ownerLine(owner),
|
|
28
34
|
'',
|
|
29
35
|
].join('\n');
|
|
30
36
|
}
|
|
@@ -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,
|
|
@@ -32,7 +33,7 @@ import {
|
|
|
32
33
|
} from '../db/local-connectors.js';
|
|
33
34
|
import {
|
|
34
35
|
assertLocalOwner,
|
|
35
|
-
|
|
36
|
+
ownerSignIn,
|
|
36
37
|
connectorIdsOfLocalAgent,
|
|
37
38
|
LOCAL_PROJECT_ID,
|
|
38
39
|
localAttachAccount,
|
|
@@ -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
|
}
|
|
@@ -190,7 +192,7 @@ export function localSessionApis(deps: LocalModeDeps): SessionApis {
|
|
|
190
192
|
relayApi,
|
|
191
193
|
localCli,
|
|
192
194
|
claudeApi: { authorize: (subject) => { assertLocalOwner(subject); } },
|
|
193
|
-
siwe: { ensureUser:
|
|
195
|
+
siwe: { ensureUser: ownerSignIn },
|
|
194
196
|
mode: localModeInfo,
|
|
195
197
|
};
|
|
196
198
|
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { localOwner, setLocalOwner } from '../db/file-admin.js';
|
|
2
|
+
import { log } from './log.js';
|
|
3
|
+
|
|
4
|
+
export function applyLocalOwner(): string | null {
|
|
5
|
+
const wanted = process.env.METRO_OWNER?.trim() ?? '';
|
|
6
|
+
if (wanted !== '') {
|
|
7
|
+
const address = setLocalOwner(wanted);
|
|
8
|
+
log.info({ owner: address }, 'local daemon: owner set from METRO_OWNER');
|
|
9
|
+
return address;
|
|
10
|
+
}
|
|
11
|
+
const stored = localOwner();
|
|
12
|
+
if (stored === null) log.warn('local daemon: no owner set, so no wallet can sign in; restart with metro serve --owner <address>');
|
|
13
|
+
return stored;
|
|
14
|
+
}
|
|
@@ -1,12 +1,4 @@
|
|
|
1
|
-
import {
|
|
2
|
-
closeSync,
|
|
3
|
-
existsSync,
|
|
4
|
-
openSync,
|
|
5
|
-
readFileSync,
|
|
6
|
-
rmdirSync,
|
|
7
|
-
rmSync,
|
|
8
|
-
writeSync,
|
|
9
|
-
} from 'node:fs';
|
|
1
|
+
import { existsSync, readFileSync, rmdirSync, rmSync } from 'node:fs';
|
|
10
2
|
import { join } from 'node:path';
|
|
11
3
|
import { ApiError } from '../daemon/api-error.js';
|
|
12
4
|
import { ensureSecureDir, writeSecure } from '../daemon/secure-fs.js';
|
|
@@ -54,31 +46,23 @@ export function localOwner(dir = agentsDir()): string | null {
|
|
|
54
46
|
}
|
|
55
47
|
}
|
|
56
48
|
|
|
57
|
-
function
|
|
58
|
-
let fd: number;
|
|
59
|
-
try {
|
|
60
|
-
fd = openSync(path, 'wx', 0o600);
|
|
61
|
-
} catch (err) {
|
|
62
|
-
if ((err as NodeJS.ErrnoException).code === 'EEXIST') return false;
|
|
63
|
-
throw err;
|
|
64
|
-
}
|
|
65
|
-
writeSync(fd, `${address}\n`);
|
|
66
|
-
closeSync(fd);
|
|
67
|
-
return true;
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
export function claimLocalOwner(raw: string, dir = agentsDir()): Promise<string> {
|
|
49
|
+
export function setLocalOwner(raw: string, dir = agentsDir()): string {
|
|
71
50
|
const address = normalizeAddress(raw);
|
|
72
|
-
if (address === null)
|
|
73
|
-
return Promise.reject(new ApiError('an Ethereum address is required', 400));
|
|
51
|
+
if (address === null) throw new ApiError(`'${raw}' is not an Ethereum address`, 400);
|
|
74
52
|
ensureSecureDir(dir);
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
53
|
+
writeSecure(join(dir, OWNER_FILE), `${address}\n`);
|
|
54
|
+
return address;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function ownerSignIn(raw: string, dir = agentsDir()): Promise<string> {
|
|
58
|
+
const owner = localOwner(dir);
|
|
59
|
+
if (owner === null)
|
|
78
60
|
return Promise.reject(
|
|
79
|
-
new ApiError('this machine
|
|
61
|
+
new ApiError('this machine has no owner yet: start it with metro serve --owner <address>', 403),
|
|
80
62
|
);
|
|
81
|
-
|
|
63
|
+
if (owner !== normalizeAddress(raw))
|
|
64
|
+
return Promise.reject(new ApiError('this machine belongs to another wallet', 403));
|
|
65
|
+
return Promise.resolve(owner);
|
|
82
66
|
}
|
|
83
67
|
|
|
84
68
|
function isOwner(subject: string, dir: string): boolean {
|
|
@@ -203,12 +187,7 @@ export function assertLocalOwner(subject: string, dir = agentsDir()): void {
|
|
|
203
187
|
if (!isOwner(subject, dir)) throw new AgentAdminError('no such project', 404);
|
|
204
188
|
}
|
|
205
189
|
|
|
206
|
-
|
|
207
|
-
subject: string,
|
|
208
|
-
agent: LoadedAgent,
|
|
209
|
-
dir = agentsDir(),
|
|
210
|
-
): Promise<{ id: string; name: string; key: string; stations: number }> {
|
|
211
|
-
assertLocalOwner(subject, dir);
|
|
190
|
+
function assertImportable(agent: LoadedAgent): asserts agent is LoadedAgent & { key: string } {
|
|
212
191
|
const immovable = agent.accounts.find((a) => !MOVABLE_STATIONS.has(a.station));
|
|
213
192
|
if (immovable !== undefined)
|
|
214
193
|
throw new AgentAdminError(
|
|
@@ -217,37 +196,71 @@ export async function localImportAgent(
|
|
|
217
196
|
);
|
|
218
197
|
if (agent.key === null)
|
|
219
198
|
throw new AgentAdminError('that agent has no key; reset it on metro.box first', 400);
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
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))
|
|
223
211
|
throw new AgentAdminError(
|
|
224
212
|
`an agent named '${agent.name}' already exists on this machine`,
|
|
225
213
|
409,
|
|
226
214
|
);
|
|
227
|
-
assertUnclaimed(
|
|
228
|
-
|
|
229
|
-
|
|
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, '..'));
|
|
230
229
|
save({ path, file });
|
|
230
|
+
if (previous !== undefined && previous.key !== agent.key) unregisterAgentKey(agent.id);
|
|
231
231
|
registerKey(agent.key, agent.id);
|
|
232
232
|
return Promise.resolve({
|
|
233
233
|
id: agent.id,
|
|
234
234
|
name: agent.name,
|
|
235
235
|
key: agent.key,
|
|
236
|
-
stations:
|
|
236
|
+
stations: file.stations.length,
|
|
237
237
|
});
|
|
238
238
|
}
|
|
239
239
|
|
|
240
|
-
function
|
|
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 {
|
|
241
254
|
try {
|
|
242
255
|
return parseAgentFile(
|
|
243
256
|
JSON.stringify({
|
|
244
257
|
version: 1,
|
|
245
258
|
id: agent.id,
|
|
246
|
-
name: agent.name,
|
|
259
|
+
name: previous?.name ?? agent.name,
|
|
247
260
|
key: agent.key,
|
|
248
261
|
owner,
|
|
249
|
-
stations: agent
|
|
250
|
-
connectors: [],
|
|
262
|
+
stations: mergedStations(agent, previous),
|
|
263
|
+
connectors: previous?.connectors ?? [],
|
|
251
264
|
}),
|
|
252
265
|
path,
|
|
253
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 {
|
|
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) => ({
|
package/runtime/runtime.json
CHANGED