@stage-labs/metro 0.1.0-beta.22 → 0.1.0-beta.24
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 +28 -0
- package/dist/cli.js +22 -12
- package/dist/control.js +68 -12
- package/dist/prompt.js +51 -0
- package/dist/tail.js +120 -0
- package/package.json +1 -1
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/boot.ts +2 -4
- package/runtime/node_modules/@metro-labs/mcp/src/db/materialize.ts +33 -22
- package/runtime/runtime.json +1 -1
package/dist/api.js
CHANGED
|
@@ -65,6 +65,34 @@ async function post(path, body) {
|
|
|
65
65
|
const detail = isRecord(parsed) ? parsed.error : undefined;
|
|
66
66
|
throw new Error(typeof detail === 'string' ? detail : `metro answered ${String(res.status)}`);
|
|
67
67
|
}
|
|
68
|
+
export class RuntimeRevoked extends Error {
|
|
69
|
+
}
|
|
70
|
+
export function keyOfRunConfig(body) {
|
|
71
|
+
const agent = isRecord(body) ? body.agent : undefined;
|
|
72
|
+
const key = isRecord(agent) ? agent.key : undefined;
|
|
73
|
+
if (typeof key !== 'string' || key === '')
|
|
74
|
+
throw new Error('this agent has no key — reset it on its page in the web UI');
|
|
75
|
+
return key;
|
|
76
|
+
}
|
|
77
|
+
export async function runConfigKey(token) {
|
|
78
|
+
if (!carriesSecretsSafely(metroUrl()))
|
|
79
|
+
throw new Error(`refusing to send this machine's authorization to ${metroUrl()} in the clear — use https, or a loopback address`);
|
|
80
|
+
let res;
|
|
81
|
+
try {
|
|
82
|
+
res = await fetch(`${metroUrl()}/api/run/config`, {
|
|
83
|
+
headers: { authorization: `Bearer ${token}` },
|
|
84
|
+
signal: AbortSignal.timeout(TIMEOUT_MS),
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
throw new Error(`could not reach ${metroUrl()}`);
|
|
89
|
+
}
|
|
90
|
+
if (res.status === 401 || res.status === 403 || res.status === 409)
|
|
91
|
+
throw new RuntimeRevoked('this machine no longer holds the agent — authorize it again from the agent page');
|
|
92
|
+
if (!res.ok)
|
|
93
|
+
throw new Error(`metro answered ${String(res.status)}`);
|
|
94
|
+
return keyOfRunConfig(await res.json());
|
|
95
|
+
}
|
|
68
96
|
export async function claimCode(code) {
|
|
69
97
|
const body = await post('/api/cli/claim', { code });
|
|
70
98
|
if (!isRecord(body) ||
|
package/dist/cli.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { hostname } from 'node:os';
|
|
3
3
|
import { claimCode, claimRuntime, mcpServers, NotSignedIn, whoisAuthorized, } from './api.js';
|
|
4
|
-
import {
|
|
4
|
+
import { askSecret } from './prompt.js';
|
|
5
5
|
import { clearToken, credentialsPath, metroUrl, metroWebUrl, writeToken, } from './store.js';
|
|
6
|
-
import { detach, probe, runningPid,
|
|
6
|
+
import { detach, lockedBy, probe, runningPid, stopAll, tail } from './control.js';
|
|
7
|
+
import { tailEvents } from './tail.js';
|
|
7
8
|
import { assertAgentId, daemonPlan, localUrl, readRunToken, runDaemon, runtimeDir, writeRunToken, } from './runtime.js';
|
|
8
9
|
import { update } from './update.js';
|
|
9
10
|
import { currentVersion } from './version.js';
|
|
@@ -11,12 +12,14 @@ const USAGE = `metro — the command line for your MCP connectors
|
|
|
11
12
|
|
|
12
13
|
metro start <agent-id> [--detach]
|
|
13
14
|
run that agent's stations on this machine
|
|
14
|
-
metro stop
|
|
15
|
-
stop
|
|
15
|
+
metro stop [agent-id]
|
|
16
|
+
stop every metro daemon on this machine, however it was started
|
|
16
17
|
metro status <agent-id>
|
|
17
18
|
is it running, and is it healthy
|
|
18
19
|
metro logs <agent-id> [-f]
|
|
19
20
|
show the detached daemon's log
|
|
21
|
+
metro tail <agent-id>
|
|
22
|
+
follow this machine's inbound events, one JSON line each
|
|
20
23
|
metro login authorize a connector collection with a code from the web UI
|
|
21
24
|
metro logout forget this machine's sign-in
|
|
22
25
|
metro whoami print the account and collection this machine may read
|
|
@@ -40,7 +43,7 @@ function hostLabel() {
|
|
|
40
43
|
}
|
|
41
44
|
async function authorizeRuntime(agentId) {
|
|
42
45
|
process.stderr.write(`Authorize this machine at ${metroWebUrl()}/#/authorize/${agentId}\n`);
|
|
43
|
-
const code = (await
|
|
46
|
+
const code = (await askSecret('Paste the code (input is hidden): ')).trim();
|
|
44
47
|
if (code === '')
|
|
45
48
|
throw new Error('no code given');
|
|
46
49
|
const claimed = await claimRuntime(code, hostLabel());
|
|
@@ -50,8 +53,9 @@ async function authorizeRuntime(agentId) {
|
|
|
50
53
|
}
|
|
51
54
|
async function start(argv) {
|
|
52
55
|
const agentId = assertAgentId(argv[0]);
|
|
53
|
-
if (runningPid(agentId) !== null)
|
|
54
|
-
throw new Error(
|
|
56
|
+
if (runningPid(agentId) !== null || lockedBy() !== null)
|
|
57
|
+
throw new Error('a metro daemon is already running on this machine. ' +
|
|
58
|
+
'Stop it first: metro stop');
|
|
55
59
|
const dir = runtimeDir();
|
|
56
60
|
const token = readRunToken(agentId) ?? (await authorizeRuntime(agentId));
|
|
57
61
|
const detached = argv.includes('--detach');
|
|
@@ -65,10 +69,15 @@ async function start(argv) {
|
|
|
65
69
|
return 0;
|
|
66
70
|
}
|
|
67
71
|
async function stopDaemon(argv) {
|
|
68
|
-
const agentId = assertAgentId(argv[0]);
|
|
69
|
-
const stopped = await
|
|
70
|
-
|
|
71
|
-
|
|
72
|
+
const agentId = argv[0] === undefined ? undefined : assertAgentId(argv[0]);
|
|
73
|
+
const stopped = await stopAll(agentId);
|
|
74
|
+
if (stopped.length === 0) {
|
|
75
|
+
process.stderr.write('no metro daemon is running on this machine\n');
|
|
76
|
+
return 1;
|
|
77
|
+
}
|
|
78
|
+
for (const d of stopped)
|
|
79
|
+
process.stderr.write(`Stopped metro (pid ${String(d.pid)}, via ${d.via})\n`);
|
|
80
|
+
return 0;
|
|
72
81
|
}
|
|
73
82
|
async function status(argv) {
|
|
74
83
|
const agentId = assertAgentId(argv[0]);
|
|
@@ -90,7 +99,7 @@ function logs(argv) {
|
|
|
90
99
|
}
|
|
91
100
|
async function login() {
|
|
92
101
|
process.stderr.write(`Choose a connector collection at ${metroWebUrl()}/#/authorize\n`);
|
|
93
|
-
const { token, email, collection } = await claimCode(await
|
|
102
|
+
const { token, email, collection } = await claimCode(await askSecret('Paste the code here (input is hidden): '));
|
|
94
103
|
writeToken(token);
|
|
95
104
|
process.stderr.write(`Authorized '${collection}' for ${email}. Stored in ${credentialsPath()}\n`);
|
|
96
105
|
}
|
|
@@ -117,6 +126,7 @@ const COMMANDS = {
|
|
|
117
126
|
stop: () => stopDaemon(process.argv.slice(3)),
|
|
118
127
|
status: () => status(process.argv.slice(3)),
|
|
119
128
|
logs: () => logs(process.argv.slice(3)),
|
|
129
|
+
tail: () => tailEvents(process.argv.slice(3)),
|
|
120
130
|
mcp: async () => {
|
|
121
131
|
process.stdout.write(`${await mcpServers()}\n`);
|
|
122
132
|
return 0;
|
package/dist/control.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { spawn } from 'node:child_process';
|
|
2
|
-
import { existsSync, mkdirSync, openSync, readFileSync, rmSync, writeFileSync, } from 'node:fs';
|
|
1
|
+
import { execFileSync, spawn } from 'node:child_process';
|
|
2
|
+
import { existsSync, mkdirSync, openSync, readdirSync, readFileSync, rmSync, writeFileSync, } from 'node:fs';
|
|
3
3
|
import { homedir } from 'node:os';
|
|
4
4
|
import { join } from 'node:path';
|
|
5
5
|
const STATE = () => join(homedir(), '.metro');
|
|
@@ -58,22 +58,78 @@ export function detach(opts) {
|
|
|
58
58
|
writePid(opts.agentId, pid);
|
|
59
59
|
return pid;
|
|
60
60
|
}
|
|
61
|
-
|
|
62
|
-
const pid = runningPid(agentId);
|
|
63
|
-
if (pid === null)
|
|
64
|
-
return false;
|
|
61
|
+
async function stopPid(pid, waitMs) {
|
|
65
62
|
process.kill(pid, 'SIGTERM');
|
|
66
63
|
const until = Date.now() + waitMs;
|
|
67
64
|
while (Date.now() < until) {
|
|
68
|
-
if (!isAlive(pid))
|
|
69
|
-
|
|
70
|
-
return true;
|
|
71
|
-
}
|
|
65
|
+
if (!isAlive(pid))
|
|
66
|
+
return;
|
|
72
67
|
await new Promise((r) => setTimeout(r, 100));
|
|
73
68
|
}
|
|
74
69
|
process.kill(pid, 'SIGKILL');
|
|
75
|
-
|
|
76
|
-
|
|
70
|
+
}
|
|
71
|
+
const lockPath = () => process.env.METRO_STATE_DIR?.trim()
|
|
72
|
+
? join(process.env.METRO_STATE_DIR.trim(), '.tail-lock')
|
|
73
|
+
: join(homedir(), '.cache', 'metro', '.tail-lock');
|
|
74
|
+
function commandOf(pid) {
|
|
75
|
+
try {
|
|
76
|
+
return execFileSync('ps', ['-o', 'command=', '-p', String(pid)], {
|
|
77
|
+
encoding: 'utf8',
|
|
78
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
return '';
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
const looksLikeMetro = (command) => command.includes('server.ts') || command.includes('metro');
|
|
86
|
+
export function lockedBy() {
|
|
87
|
+
let pid;
|
|
88
|
+
try {
|
|
89
|
+
pid = Number(readFileSync(lockPath(), 'utf8').trim());
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
if (!Number.isInteger(pid) || pid <= 0 || !isAlive(pid))
|
|
95
|
+
return null;
|
|
96
|
+
return looksLikeMetro(commandOf(pid)) ? pid : null;
|
|
97
|
+
}
|
|
98
|
+
function trackedAgents(agentId) {
|
|
99
|
+
const tracked = new Set();
|
|
100
|
+
if (agentId !== undefined)
|
|
101
|
+
tracked.add(agentId);
|
|
102
|
+
try {
|
|
103
|
+
for (const name of readdirSync(STATE())) {
|
|
104
|
+
const found = /^run-(.+)\.pid$/.exec(name);
|
|
105
|
+
if (found?.[1] !== undefined)
|
|
106
|
+
tracked.add(found[1]);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
return tracked;
|
|
111
|
+
}
|
|
112
|
+
return tracked;
|
|
113
|
+
}
|
|
114
|
+
export async function stopAll(agentId, waitMs = 10_000) {
|
|
115
|
+
const stopped = [];
|
|
116
|
+
const seen = new Set();
|
|
117
|
+
for (const id of trackedAgents(agentId)) {
|
|
118
|
+
const pid = runningPid(id);
|
|
119
|
+
if (pid === null || seen.has(pid))
|
|
120
|
+
continue;
|
|
121
|
+
seen.add(pid);
|
|
122
|
+
await stopPid(pid, waitMs);
|
|
123
|
+
clearPid(id);
|
|
124
|
+
stopped.push({ pid, via: id });
|
|
125
|
+
}
|
|
126
|
+
const holder = lockedBy();
|
|
127
|
+
if (holder !== null && !seen.has(holder)) {
|
|
128
|
+
await stopPid(holder, waitMs);
|
|
129
|
+
rmSync(lockPath(), { force: true });
|
|
130
|
+
stopped.push({ pid: holder, via: 'the machine lock' });
|
|
131
|
+
}
|
|
132
|
+
return stopped;
|
|
77
133
|
}
|
|
78
134
|
export function tail(agentId, follow) {
|
|
79
135
|
const path = logPath(agentId);
|
package/dist/prompt.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { createInterface } from 'node:readline';
|
|
2
|
+
const CTRL_C = '\u0003';
|
|
3
|
+
const BACKSPACE = '\u007f';
|
|
2
4
|
export function askLine(question) {
|
|
3
5
|
const rl = createInterface({ input: process.stdin, output: process.stderr });
|
|
4
6
|
return new Promise((resolve) => {
|
|
@@ -8,3 +10,52 @@ export function askLine(question) {
|
|
|
8
10
|
});
|
|
9
11
|
});
|
|
10
12
|
}
|
|
13
|
+
export function feedSecret(value, chunk) {
|
|
14
|
+
let out = value;
|
|
15
|
+
for (const ch of chunk) {
|
|
16
|
+
if (ch === CTRL_C)
|
|
17
|
+
return { kind: 'cancelled' };
|
|
18
|
+
if (ch === '\r' || ch === '\n')
|
|
19
|
+
return { kind: 'done', value: out };
|
|
20
|
+
if (ch === BACKSPACE || ch === '\b') {
|
|
21
|
+
out = out.slice(0, -1);
|
|
22
|
+
continue;
|
|
23
|
+
}
|
|
24
|
+
if (ch >= ' ')
|
|
25
|
+
out += ch;
|
|
26
|
+
}
|
|
27
|
+
return { kind: 'more', value: out };
|
|
28
|
+
}
|
|
29
|
+
export function askSecret(question) {
|
|
30
|
+
const stdin = process.stdin;
|
|
31
|
+
if (!stdin.isTTY)
|
|
32
|
+
return askLine(question);
|
|
33
|
+
process.stderr.write(question);
|
|
34
|
+
stdin.setRawMode(true);
|
|
35
|
+
stdin.resume();
|
|
36
|
+
stdin.setEncoding('utf8');
|
|
37
|
+
return new Promise((resolve, reject) => {
|
|
38
|
+
let value = '\u0003';
|
|
39
|
+
const finish = () => {
|
|
40
|
+
stdin.setRawMode(false);
|
|
41
|
+
stdin.pause();
|
|
42
|
+
stdin.off('data', onData);
|
|
43
|
+
process.stderr.write('\n');
|
|
44
|
+
};
|
|
45
|
+
const onData = (chunk) => {
|
|
46
|
+
const step = feedSecret(value, chunk);
|
|
47
|
+
if (step.kind === 'cancelled') {
|
|
48
|
+
finish();
|
|
49
|
+
reject(new Error('cancelled'));
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
if (step.kind === 'done') {
|
|
53
|
+
finish();
|
|
54
|
+
resolve(step.value.trim());
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
value = step.value;
|
|
58
|
+
};
|
|
59
|
+
stdin.on('data', onData);
|
|
60
|
+
});
|
|
61
|
+
}
|
package/dist/tail.js
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { runConfigKey } from './api.js';
|
|
2
|
+
import { assertAgentId, localUrl, readRunToken } from './runtime.js';
|
|
3
|
+
const BACKOFF_MS = [1_000, 2_000, 5_000];
|
|
4
|
+
const STALL_MS = 90_000;
|
|
5
|
+
export const backoffAt = (attempt) => BACKOFF_MS[Math.min(attempt, BACKOFF_MS.length - 1)] ?? 5_000;
|
|
6
|
+
export function sseDataLines(buffer, chunk) {
|
|
7
|
+
const lines = (buffer + chunk).split('\n');
|
|
8
|
+
const rest = lines.pop() ?? '';
|
|
9
|
+
const events = [];
|
|
10
|
+
for (const raw of lines) {
|
|
11
|
+
const line = raw.endsWith('\r') ? raw.slice(0, -1) : raw;
|
|
12
|
+
if (!line.startsWith('data:'))
|
|
13
|
+
continue;
|
|
14
|
+
const value = line.slice(5);
|
|
15
|
+
events.push(value.startsWith(' ') ? value.slice(1) : value);
|
|
16
|
+
}
|
|
17
|
+
return { rest, events };
|
|
18
|
+
}
|
|
19
|
+
function keySource(agentId) {
|
|
20
|
+
const fromEnv = process.env.METRO_AGENT_KEY?.trim();
|
|
21
|
+
if (fromEnv !== undefined && fromEnv !== '')
|
|
22
|
+
return { resolve: () => Promise.resolve(fromEnv), fixed: true };
|
|
23
|
+
return {
|
|
24
|
+
resolve: () => {
|
|
25
|
+
const token = readRunToken(agentId);
|
|
26
|
+
if (token === null)
|
|
27
|
+
throw new Error(`this machine is not authorized for ${agentId} — ` +
|
|
28
|
+
`run 'metro start ${agentId}' once, or set METRO_AGENT_KEY`);
|
|
29
|
+
return runConfigKey(token);
|
|
30
|
+
},
|
|
31
|
+
fixed: false,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
const armStall = (control) => setTimeout(() => {
|
|
35
|
+
control.abort();
|
|
36
|
+
}, STALL_MS);
|
|
37
|
+
async function connect(key, signal) {
|
|
38
|
+
try {
|
|
39
|
+
return await fetch(`${localUrl()}/api/tail`, {
|
|
40
|
+
headers: { authorization: `Bearer ${key}` },
|
|
41
|
+
signal,
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
async function pump(body, control, stall) {
|
|
49
|
+
const reader = body.getReader();
|
|
50
|
+
const decoder = new TextDecoder();
|
|
51
|
+
let buffer = '';
|
|
52
|
+
try {
|
|
53
|
+
for (;;) {
|
|
54
|
+
const { done, value } = await reader.read();
|
|
55
|
+
if (done)
|
|
56
|
+
return 'ended';
|
|
57
|
+
clearTimeout(stall.timer);
|
|
58
|
+
stall.timer = armStall(control);
|
|
59
|
+
const out = sseDataLines(buffer, decoder.decode(value, { stream: true }));
|
|
60
|
+
buffer = out.rest;
|
|
61
|
+
for (const event of out.events)
|
|
62
|
+
process.stdout.write(`${event}\n`);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
return 'ended';
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
async function streamOnce(key) {
|
|
70
|
+
const control = new AbortController();
|
|
71
|
+
const stall = { timer: armStall(control) };
|
|
72
|
+
try {
|
|
73
|
+
const res = await connect(key, control.signal);
|
|
74
|
+
if (res === null)
|
|
75
|
+
return 'unreachable';
|
|
76
|
+
if (res.status === 401)
|
|
77
|
+
return 'unauthorized';
|
|
78
|
+
if (!res.ok || res.body === null)
|
|
79
|
+
return 'unreachable';
|
|
80
|
+
return await pump(res.body, control, stall);
|
|
81
|
+
}
|
|
82
|
+
finally {
|
|
83
|
+
clearTimeout(stall.timer);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
function note(state, message) {
|
|
87
|
+
if (state.last === message)
|
|
88
|
+
return;
|
|
89
|
+
state.last = message;
|
|
90
|
+
process.stderr.write(`metro: ${message}\n`);
|
|
91
|
+
}
|
|
92
|
+
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
93
|
+
async function settle(end, source, key, agentId, state) {
|
|
94
|
+
if (end === 'unauthorized') {
|
|
95
|
+
if (source.fixed)
|
|
96
|
+
throw new Error('the key in METRO_AGENT_KEY was refused by the local daemon');
|
|
97
|
+
note(state, 'key refused — fetching the current one');
|
|
98
|
+
return source.resolve();
|
|
99
|
+
}
|
|
100
|
+
if (end === 'unreachable')
|
|
101
|
+
note(state, `no metro daemon on ${localUrl()} — run 'metro start ${agentId}'`);
|
|
102
|
+
else
|
|
103
|
+
note(state, 'stream ended — reconnecting');
|
|
104
|
+
return key;
|
|
105
|
+
}
|
|
106
|
+
export async function tailEvents(argv) {
|
|
107
|
+
const agentId = assertAgentId(argv[0]);
|
|
108
|
+
const source = keySource(agentId);
|
|
109
|
+
let key = await source.resolve();
|
|
110
|
+
const state = { last: '' };
|
|
111
|
+
let attempt = 0;
|
|
112
|
+
for (;;) {
|
|
113
|
+
const end = await streamOnce(key);
|
|
114
|
+
if (end === 'ended')
|
|
115
|
+
attempt = 0;
|
|
116
|
+
key = await settle(end, source, key, agentId, state);
|
|
117
|
+
await delay(backoffAt(attempt));
|
|
118
|
+
attempt += 1;
|
|
119
|
+
}
|
|
120
|
+
}
|
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.24",
|
|
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": {
|
|
@@ -144,11 +144,9 @@ async function applyStations(removed: StationName[]): Promise<void> {
|
|
|
144
144
|
|
|
145
145
|
async function syncLocal(): Promise<void> {
|
|
146
146
|
if (localSource === null) return;
|
|
147
|
-
const
|
|
148
|
-
const { active, removed } = await reloadFrom(localSource);
|
|
147
|
+
const { removed, changed } = await reloadFrom(localSource);
|
|
149
148
|
await applyStations(removed);
|
|
150
|
-
for (const station of
|
|
151
|
-
if (before.has(station)) supervisor.requestReload(station);
|
|
149
|
+
for (const station of changed) supervisor.requestReload(station);
|
|
152
150
|
}
|
|
153
151
|
|
|
154
152
|
async function stopLocalStations(): Promise<void> {
|
|
@@ -181,10 +181,6 @@ async function loadAgents(): Promise<LoadedAgent[]> {
|
|
|
181
181
|
|
|
182
182
|
const out: LoadedAgent[] = [];
|
|
183
183
|
for (const a of agentRows) {
|
|
184
|
-
if (a.runtimeId !== null) {
|
|
185
|
-
out.push({ id: a.id, name: a.name, accounts: [], key: null });
|
|
186
|
-
continue;
|
|
187
|
-
}
|
|
188
184
|
const acctRows = await db
|
|
189
185
|
.select()
|
|
190
186
|
.from(stations)
|
|
@@ -198,7 +194,7 @@ async function loadAgents(): Promise<LoadedAgent[]> {
|
|
|
198
194
|
allowlist: r.allowlist,
|
|
199
195
|
config: r.config as Record<string, unknown>,
|
|
200
196
|
})),
|
|
201
|
-
key: a.key,
|
|
197
|
+
key: a.runtimeId === null ? a.key : null,
|
|
202
198
|
});
|
|
203
199
|
}
|
|
204
200
|
return out;
|
|
@@ -236,7 +232,12 @@ export const isLocalRuntime = (): boolean =>
|
|
|
236
232
|
export const stationRunsHere = (station: StationName): boolean =>
|
|
237
233
|
isLocalRuntime() || !MOVABLE_STATIONS.has(station);
|
|
238
234
|
|
|
239
|
-
|
|
235
|
+
interface WrittenStations {
|
|
236
|
+
active: Map<StationName, number>;
|
|
237
|
+
changed: StationName[];
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function writeStations(list: LoadedAgent[]): WrittenStations {
|
|
240
241
|
mkdirSync(METRO_DIR, { recursive: true });
|
|
241
242
|
mkdirSync(TRAINS_DIR, { recursive: true });
|
|
242
243
|
|
|
@@ -268,29 +269,36 @@ function writeStations(list: LoadedAgent[]): Map<StationName, number> {
|
|
|
268
269
|
carryTokenStores(list.flatMap((agent) => agent.accounts));
|
|
269
270
|
|
|
270
271
|
const active = new Map<StationName, number>();
|
|
272
|
+
const changed: StationName[] = [];
|
|
271
273
|
for (const [station, accts] of byStation) {
|
|
272
274
|
const records = accts.map((a) => ({ id: a.id, ...trainConfig(a.config) }));
|
|
273
|
-
|
|
275
|
+
const path = accountFilePath(station);
|
|
276
|
+
const content = JSON.stringify(records, null, 2);
|
|
277
|
+
if (currentText(path) !== content) {
|
|
278
|
+
writeSecure(path, content);
|
|
279
|
+
changed.push(station);
|
|
280
|
+
}
|
|
274
281
|
const { trainImport } = STATION_TARGETS[station];
|
|
275
282
|
if (trainImport !== null)
|
|
276
283
|
writeIfChanged(trainStubPath(station), `import '${trainImport}';\n`);
|
|
277
284
|
active.set(station, accts.length);
|
|
278
285
|
}
|
|
279
|
-
return active;
|
|
286
|
+
return { active, changed };
|
|
280
287
|
}
|
|
281
288
|
|
|
289
|
+
const BLANKED = '[]\n';
|
|
290
|
+
|
|
282
291
|
function pruneStations(present: Map<StationName, number>): StationName[] {
|
|
283
292
|
const removed: StationName[] = [];
|
|
284
293
|
for (const station of Object.keys(STATION_TARGETS) as StationName[]) {
|
|
285
294
|
if (present.has(station)) continue;
|
|
286
295
|
const hasTrain = STATION_TARGETS[station].trainImport !== null;
|
|
287
296
|
const stub = trainStubPath(station);
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
)
|
|
292
|
-
|
|
293
|
-
writeSecure(accountFilePath(station), '[]\n');
|
|
297
|
+
const stubGone = !hasTrain || !existsSync(stub);
|
|
298
|
+
const file = currentText(accountFilePath(station));
|
|
299
|
+
const fileInert = file === null || file === BLANKED;
|
|
300
|
+
if (stubGone && fileInert) continue;
|
|
301
|
+
if (!fileInert) writeSecure(accountFilePath(station), BLANKED);
|
|
294
302
|
if (hasTrain) rmSync(stub, { force: true });
|
|
295
303
|
removed.push(station);
|
|
296
304
|
}
|
|
@@ -317,14 +325,15 @@ export function localAgentKey(): string | null {
|
|
|
317
325
|
|
|
318
326
|
async function loadAndWrite(
|
|
319
327
|
source: StationSource,
|
|
320
|
-
): Promise<
|
|
328
|
+
): Promise<WrittenStations & { agents: number }> {
|
|
321
329
|
const list = await source();
|
|
322
330
|
applyKeyMap(list);
|
|
323
|
-
return {
|
|
331
|
+
return { ...writeStations(list), agents: list.length };
|
|
324
332
|
}
|
|
325
333
|
|
|
326
334
|
export async function materializeFrom(source: StationSource): Promise<void> {
|
|
327
335
|
const { active, agents: found } = await loadAndWrite(source);
|
|
336
|
+
|
|
328
337
|
if (found === 0) throw new Error('no agents found — nothing to materialize');
|
|
329
338
|
log.info(
|
|
330
339
|
{ stations: stationLabels(active), agents: found },
|
|
@@ -345,18 +354,20 @@ export async function materializeFromDb(): Promise<void> {
|
|
|
345
354
|
export interface ReloadedStations {
|
|
346
355
|
active: StationName[];
|
|
347
356
|
removed: StationName[];
|
|
357
|
+
changed: StationName[];
|
|
348
358
|
}
|
|
349
359
|
|
|
350
360
|
export async function reloadFrom(
|
|
351
361
|
source: StationSource,
|
|
352
362
|
): Promise<ReloadedStations> {
|
|
353
|
-
const { active } = await loadAndWrite(source);
|
|
363
|
+
const { active, changed } = await loadAndWrite(source);
|
|
354
364
|
const removed = pruneStations(active);
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
365
|
+
if (removed.length > 0 || changed.length > 0)
|
|
366
|
+
log.info(
|
|
367
|
+
{ stations: stationLabels(active), removed, changed },
|
|
368
|
+
'reloaded station accounts',
|
|
369
|
+
);
|
|
370
|
+
return { active: [...active.keys()], removed, changed };
|
|
360
371
|
}
|
|
361
372
|
|
|
362
373
|
export async function reloadAccountsFromDb(): Promise<ReloadedStations> {
|
package/runtime/runtime.json
CHANGED