@stage-labs/metro 0.1.0-beta.66 → 0.1.0-beta.67
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 +9 -2
- package/dist/hold.js +197 -0
- package/dist/runtime.js +10 -3
- package/dist/serve.js +22 -2
- package/dist/service.js +224 -0
- package/package.json +1 -1
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/boot.ts +5 -0
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/control-api.ts +64 -0
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/local-mode.ts +2 -0
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/session-apis.ts +3 -0
- package/runtime/runtime.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -7,6 +7,7 @@ import { bedrock } from './bedrock.js';
|
|
|
7
7
|
import { installPlugin } from './plugin.js';
|
|
8
8
|
import { update } from './update.js';
|
|
9
9
|
import { serve } from './serve.js';
|
|
10
|
+
import { service } from './service.js';
|
|
10
11
|
import { currentVersion } from './version.js';
|
|
11
12
|
const USAGE = `metro — run your agent on this machine
|
|
12
13
|
|
|
@@ -16,8 +17,13 @@ const USAGE = `metro — run your agent on this machine
|
|
|
16
17
|
permanent https://<machine>.<tailnet>.ts.net (Tailscale installed and
|
|
17
18
|
signed in on this machine); the page at metro.box manages it through the
|
|
18
19
|
link it prints; --owner names the one wallet that may sign in, needed on
|
|
19
|
-
the first start only
|
|
20
|
-
|
|
20
|
+
the first start only; Stop on the page parks the daemon and keeps the
|
|
21
|
+
address, so Start works from the page too
|
|
22
|
+
metro service install [--port <n>] [--owner <address>]
|
|
23
|
+
run metro serve as a service (systemd on Linux, launchd on macOS): it
|
|
24
|
+
starts at boot and after a crash, so the page can stop, start and
|
|
25
|
+
restart it with no shell; metro service uninstall and status as well
|
|
26
|
+
metro stop stop metro on this machine, metro serve included
|
|
21
27
|
metro tail <agent-id>
|
|
22
28
|
follow this machine's inbound events, one JSON line each
|
|
23
29
|
metro whoami [agent]
|
|
@@ -64,6 +70,7 @@ const COMMANDS = {
|
|
|
64
70
|
return 0;
|
|
65
71
|
},
|
|
66
72
|
serve: () => serve(process.argv.slice(3)),
|
|
73
|
+
service: () => service(process.argv.slice(3)),
|
|
67
74
|
stop: stopDaemon,
|
|
68
75
|
tail: () => tailEvents(process.argv.slice(3)),
|
|
69
76
|
mcp: async () => {
|
package/dist/hold.js
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { createServer } from 'node:http';
|
|
4
|
+
const FUNNEL_RETRY_MS = 2_000;
|
|
5
|
+
const FUNNEL_EXIT_WAIT_MS = 5_000;
|
|
6
|
+
const LISTEN_ATTEMPTS = 10;
|
|
7
|
+
const LISTEN_RETRY_MS = 500;
|
|
8
|
+
const STOPPED = 503;
|
|
9
|
+
export const STOPPED_MESSAGE = 'metro is stopped on this machine. Start it from the Server page on metro.box, or run metro serve.';
|
|
10
|
+
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
11
|
+
function corsHeaders(req) {
|
|
12
|
+
return {
|
|
13
|
+
'access-control-allow-origin': req.headers.origin ?? '*',
|
|
14
|
+
'access-control-allow-methods': 'GET, POST, PUT, DELETE, OPTIONS',
|
|
15
|
+
'access-control-allow-headers': 'Authorization, Content-Type',
|
|
16
|
+
'access-control-allow-private-network': 'true',
|
|
17
|
+
'access-control-max-age': '86400',
|
|
18
|
+
vary: 'Origin',
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
function sendJson(req, res, status, body) {
|
|
22
|
+
res.writeHead(status, {
|
|
23
|
+
'content-type': 'application/json',
|
|
24
|
+
'cache-control': 'no-store',
|
|
25
|
+
...corsHeaders(req),
|
|
26
|
+
});
|
|
27
|
+
res.end(JSON.stringify(body));
|
|
28
|
+
}
|
|
29
|
+
export function holdRequest(req, res, info, onStart) {
|
|
30
|
+
const path = (req.url ?? '').split('?')[0] ?? '';
|
|
31
|
+
if (req.method === 'OPTIONS') {
|
|
32
|
+
res.writeHead(204, corsHeaders(req)).end();
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
if (path === '/api/mode' && req.method === 'GET') {
|
|
36
|
+
sendJson(req, res, 200, {
|
|
37
|
+
mode: 'local',
|
|
38
|
+
owner: info.owner,
|
|
39
|
+
project: 'localdaemon',
|
|
40
|
+
version: info.version,
|
|
41
|
+
stopped: true,
|
|
42
|
+
});
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
if (path === '/api/start') {
|
|
46
|
+
if (req.method !== 'POST') {
|
|
47
|
+
sendJson(req, res, 405, { error: 'method not allowed' });
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
sendJson(req, res, 200, { starting: true });
|
|
51
|
+
onStart();
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
if (path === '/health' || path === '/healthz') {
|
|
55
|
+
sendJson(req, res, STOPPED, { status: 'stopped', version: info.version });
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
sendJson(req, res, STOPPED, { error: STOPPED_MESSAGE, stopped: true });
|
|
59
|
+
}
|
|
60
|
+
export const holdServer = (info, onStart) => createServer((req, res) => {
|
|
61
|
+
holdRequest(req, res, info, onStart);
|
|
62
|
+
});
|
|
63
|
+
const lastLine = (chunk) => String(chunk).trim().split('\n').at(-1) ?? '';
|
|
64
|
+
class HeldFunnel {
|
|
65
|
+
bin;
|
|
66
|
+
port;
|
|
67
|
+
log;
|
|
68
|
+
child = null;
|
|
69
|
+
closed = false;
|
|
70
|
+
timer = null;
|
|
71
|
+
exits = 0;
|
|
72
|
+
constructor(bin, port, log) {
|
|
73
|
+
this.bin = bin;
|
|
74
|
+
this.port = port;
|
|
75
|
+
this.log = log;
|
|
76
|
+
}
|
|
77
|
+
start() {
|
|
78
|
+
if (this.closed)
|
|
79
|
+
return;
|
|
80
|
+
const child = spawn(this.bin, ['funnel', String(this.port)], { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
81
|
+
this.child = child;
|
|
82
|
+
let last = '';
|
|
83
|
+
const read = (chunk) => {
|
|
84
|
+
const line = lastLine(chunk);
|
|
85
|
+
if (line !== '')
|
|
86
|
+
last = line;
|
|
87
|
+
};
|
|
88
|
+
child.stdout?.on('data', read);
|
|
89
|
+
child.stderr?.on('data', read);
|
|
90
|
+
child.on('error', (err) => {
|
|
91
|
+
this.child = null;
|
|
92
|
+
this.closed = true;
|
|
93
|
+
this.log(`funnel could not start (${err.message}); the loopback address is still held`);
|
|
94
|
+
});
|
|
95
|
+
child.on('exit', () => {
|
|
96
|
+
this.child = null;
|
|
97
|
+
if (this.closed)
|
|
98
|
+
return;
|
|
99
|
+
this.exits += 1;
|
|
100
|
+
if (this.exits > 1)
|
|
101
|
+
this.log(`funnel exited (${last}); retrying in ${String(FUNNEL_RETRY_MS / 1000)}s`);
|
|
102
|
+
this.timer = setTimeout(() => {
|
|
103
|
+
this.start();
|
|
104
|
+
}, FUNNEL_RETRY_MS);
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
async stop() {
|
|
108
|
+
this.closed = true;
|
|
109
|
+
if (this.timer !== null)
|
|
110
|
+
clearTimeout(this.timer);
|
|
111
|
+
const child = this.child;
|
|
112
|
+
this.child = null;
|
|
113
|
+
if (child === null)
|
|
114
|
+
return;
|
|
115
|
+
if (child.exitCode !== null || child.signalCode !== null)
|
|
116
|
+
return;
|
|
117
|
+
await new Promise((resolve) => {
|
|
118
|
+
const kill = setTimeout(() => {
|
|
119
|
+
child.kill('SIGKILL');
|
|
120
|
+
}, FUNNEL_EXIT_WAIT_MS);
|
|
121
|
+
child.once('exit', () => {
|
|
122
|
+
clearTimeout(kill);
|
|
123
|
+
resolve();
|
|
124
|
+
});
|
|
125
|
+
child.kill('SIGINT');
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
async function listen(server, info) {
|
|
130
|
+
for (let attempt = 1;; attempt += 1) {
|
|
131
|
+
try {
|
|
132
|
+
await new Promise((resolve, reject) => {
|
|
133
|
+
server.once('error', reject);
|
|
134
|
+
server.listen(info.port, info.host, () => {
|
|
135
|
+
server.off('error', reject);
|
|
136
|
+
resolve();
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
catch (err) {
|
|
142
|
+
if (attempt >= LISTEN_ATTEMPTS)
|
|
143
|
+
throw err;
|
|
144
|
+
await wait(LISTEN_RETRY_MS);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
const close = (server) => new Promise((resolve) => {
|
|
149
|
+
server.close(() => {
|
|
150
|
+
resolve();
|
|
151
|
+
});
|
|
152
|
+
server.closeAllConnections();
|
|
153
|
+
});
|
|
154
|
+
function releaseLock(lockFile) {
|
|
155
|
+
try {
|
|
156
|
+
if (readFileSync(lockFile, 'utf8').trim() === String(process.pid))
|
|
157
|
+
rmSync(lockFile, { force: true });
|
|
158
|
+
}
|
|
159
|
+
catch {
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
export const holdBanner = (info) => `metro is stopped. Holding http://${info.host}:${String(info.port)}${info.funnel === null ? '' : ' and the Funnel address'} ` +
|
|
164
|
+
'until Start on the Server page; Ctrl-C or metro stop ends metro serve';
|
|
165
|
+
export async function holdUntilStart(info, deps = {}) {
|
|
166
|
+
const signals = deps.signals ?? process;
|
|
167
|
+
const log = deps.log ??
|
|
168
|
+
((line) => {
|
|
169
|
+
process.stderr.write(`${line}\n`);
|
|
170
|
+
});
|
|
171
|
+
let finish = () => undefined;
|
|
172
|
+
const ended = new Promise((resolve) => {
|
|
173
|
+
finish = resolve;
|
|
174
|
+
});
|
|
175
|
+
const server = holdServer(info, () => {
|
|
176
|
+
finish('start');
|
|
177
|
+
});
|
|
178
|
+
const onSignal = () => {
|
|
179
|
+
finish('exit');
|
|
180
|
+
};
|
|
181
|
+
if (info.lockFile !== null)
|
|
182
|
+
writeFileSync(info.lockFile, String(process.pid));
|
|
183
|
+
await listen(server, info);
|
|
184
|
+
const funnel = info.funnel === null ? null : new HeldFunnel(info.funnel, info.port, log);
|
|
185
|
+
funnel?.start();
|
|
186
|
+
signals.on('SIGINT', onSignal);
|
|
187
|
+
signals.on('SIGTERM', onSignal);
|
|
188
|
+
log(holdBanner(info));
|
|
189
|
+
const end = await ended;
|
|
190
|
+
signals.off('SIGINT', onSignal);
|
|
191
|
+
signals.off('SIGTERM', onSignal);
|
|
192
|
+
await funnel?.stop();
|
|
193
|
+
await close(server);
|
|
194
|
+
if (info.lockFile !== null)
|
|
195
|
+
releaseLock(info.lockFile);
|
|
196
|
+
return end;
|
|
197
|
+
}
|
package/dist/runtime.js
CHANGED
|
@@ -32,6 +32,7 @@ export function findBun() {
|
|
|
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
34
|
export const RESTART_CODE = 75;
|
|
35
|
+
export const HOLD_CODE = 76;
|
|
35
36
|
function spawnOnce(plan) {
|
|
36
37
|
const child = spawn(plan.command, plan.args, {
|
|
37
38
|
cwd: plan.cwd,
|
|
@@ -53,11 +54,17 @@ function spawnOnce(plan) {
|
|
|
53
54
|
});
|
|
54
55
|
});
|
|
55
56
|
}
|
|
56
|
-
export async function spawnPlan(plan) {
|
|
57
|
+
export async function spawnPlan(plan, hold) {
|
|
57
58
|
for (;;) {
|
|
58
59
|
const code = await spawnOnce(plan());
|
|
59
|
-
if (code
|
|
60
|
+
if (code === RESTART_CODE) {
|
|
61
|
+
process.stderr.write('metro was updated; restarting the daemon on the new version\n');
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
if (code !== HOLD_CODE || hold === undefined)
|
|
60
65
|
return code;
|
|
61
|
-
|
|
66
|
+
if ((await hold()) === 'exit')
|
|
67
|
+
return 0;
|
|
68
|
+
process.stderr.write('starting the daemon again\n');
|
|
62
69
|
}
|
|
63
70
|
}
|
package/dist/serve.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { spawnSync } from 'node:child_process';
|
|
2
|
-
import { existsSync } from 'node:fs';
|
|
2
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
import { agentsDir } from './local.js';
|
|
5
5
|
import { currentVersion } from './version.js';
|
|
@@ -7,6 +7,7 @@ import { serveLockedBy, serveStateDir } from './control.js';
|
|
|
7
7
|
import { findBun, localPort, SERVER_ENTRY, spawnPlan } from './runtime.js';
|
|
8
8
|
import { prepareRuntime } from './runtime-install.js';
|
|
9
9
|
import { ensureNodeName } from './node-name.js';
|
|
10
|
+
import { holdUntilStart } from './hold.js';
|
|
10
11
|
const SCRUBBED = new Set(['METRO_RUN_TOKEN', 'METRO_AGENT', 'DATABASE_URL']);
|
|
11
12
|
const PORT_FLAG = /^--port=(.*)$/;
|
|
12
13
|
const OWNER_FLAG = /^--owner=(.*)$/;
|
|
@@ -64,6 +65,25 @@ export function requireOwner(owner, dir = agentsDir()) {
|
|
|
64
65
|
join(dir, '.owner') +
|
|
65
66
|
':\n metro serve --owner <address>');
|
|
66
67
|
}
|
|
68
|
+
export function readOwner(dir = agentsDir()) {
|
|
69
|
+
try {
|
|
70
|
+
const raw = readFileSync(join(dir, '.owner'), 'utf8').trim().toLowerCase();
|
|
71
|
+
return ADDRESS.test(raw) ? raw : null;
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
export function holdInfo(port, owner, tailscaleBin) {
|
|
78
|
+
return {
|
|
79
|
+
port,
|
|
80
|
+
host: process.env.METRO_HTTP_HOST ?? '127.0.0.1',
|
|
81
|
+
owner: owner ?? readOwner(),
|
|
82
|
+
version: currentVersion(),
|
|
83
|
+
funnel: tailscaleBin,
|
|
84
|
+
lockFile: join(process.env.METRO_STATE_DIR ?? serveStateDir(), '.tail-lock'),
|
|
85
|
+
};
|
|
86
|
+
}
|
|
67
87
|
function tailscaleStatus(bin) {
|
|
68
88
|
const run = spawnSync(bin, ['status', '--json'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
|
|
69
89
|
if (run.error !== undefined || run.status !== 0)
|
|
@@ -123,5 +143,5 @@ export function serve(argv) {
|
|
|
123
143
|
const tailscaleBin = findTailscale();
|
|
124
144
|
const node = ensureNodeName(tailscaleBin);
|
|
125
145
|
process.stderr.write(`Starting a metro daemon of your own on http://127.0.0.1:${String(port)}, published as ${node} on your tailnet\n`);
|
|
126
|
-
return spawnPlan(() => servePlan({ runtime: prepareRuntime(), port, owner, tailscaleBin }));
|
|
146
|
+
return spawnPlan(() => servePlan({ runtime: prepareRuntime(), port, owner, tailscaleBin }), () => holdUntilStart(holdInfo(port, owner, tailscaleBin)));
|
|
127
147
|
}
|
package/dist/service.js
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { homedir, platform, userInfo } from 'node:os';
|
|
4
|
+
import { dirname, join, resolve } from 'node:path';
|
|
5
|
+
import { serveLockedBy } from './control.js';
|
|
6
|
+
import { findBun } from './runtime.js';
|
|
7
|
+
import { findTailscale, parseServeArgs, requireOwner } from './serve.js';
|
|
8
|
+
const USAGE = 'usage: metro service install [--port <n>] [--owner <address>] | uninstall | status';
|
|
9
|
+
const SERVICE = 'metro';
|
|
10
|
+
const LABEL = 'box.metro.serve';
|
|
11
|
+
const CARRIED = /^(METRO_.*|XDG_CACHE_HOME|PATH|HOME)$/;
|
|
12
|
+
const PLAIN_WORD = /^[A-Za-z0-9_/.:=+@,-]+$/;
|
|
13
|
+
const carriedEnv = (env) => Object.entries(env)
|
|
14
|
+
.filter((entry) => CARRIED.test(entry[0]) && typeof entry[1] === 'string' && entry[1] !== '')
|
|
15
|
+
.sort(([a], [b]) => a.localeCompare(b));
|
|
16
|
+
const cacheDir = (host) => {
|
|
17
|
+
const xdg = host.env.XDG_CACHE_HOME?.trim();
|
|
18
|
+
return xdg === undefined || xdg === '' ? join(host.home, '.cache') : xdg;
|
|
19
|
+
};
|
|
20
|
+
function unitWord(arg) {
|
|
21
|
+
const escaped = arg.replaceAll('%', '%%');
|
|
22
|
+
if (PLAIN_WORD.test(arg))
|
|
23
|
+
return escaped;
|
|
24
|
+
return `"${escaped.replaceAll('\\', '\\\\').replaceAll('"', '\\"')}"`;
|
|
25
|
+
}
|
|
26
|
+
export function systemdUnit(host, exec, target) {
|
|
27
|
+
const env = carriedEnv(host.env).map(([key, value]) => `Environment=${unitWord(`${key}=${value}`)}`);
|
|
28
|
+
return [
|
|
29
|
+
'[Unit]',
|
|
30
|
+
'Description=Metro daemon (metro serve)',
|
|
31
|
+
'After=network-online.target tailscaled.service',
|
|
32
|
+
'Wants=network-online.target',
|
|
33
|
+
'',
|
|
34
|
+
'[Service]',
|
|
35
|
+
`ExecStart=${exec.map(unitWord).join(' ')}`,
|
|
36
|
+
'Restart=always',
|
|
37
|
+
'RestartSec=2',
|
|
38
|
+
`WorkingDirectory=${unitWord(host.home)}`,
|
|
39
|
+
...env,
|
|
40
|
+
'',
|
|
41
|
+
'[Install]',
|
|
42
|
+
`WantedBy=${target}`,
|
|
43
|
+
'',
|
|
44
|
+
].join('\n');
|
|
45
|
+
}
|
|
46
|
+
const xml = (text) => text
|
|
47
|
+
.replaceAll('&', '&')
|
|
48
|
+
.replaceAll('<', '<')
|
|
49
|
+
.replaceAll('>', '>')
|
|
50
|
+
.replaceAll('"', '"')
|
|
51
|
+
.replaceAll("'", ''');
|
|
52
|
+
export function launchdPlist(host, exec, logFile) {
|
|
53
|
+
const env = carriedEnv(host.env).map(([key, value]) => ` <key>${xml(key)}</key><string>${xml(value)}</string>`);
|
|
54
|
+
return [
|
|
55
|
+
'<?xml version="1.0" encoding="UTF-8"?>',
|
|
56
|
+
'<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">',
|
|
57
|
+
'<plist version="1.0">',
|
|
58
|
+
'<dict>',
|
|
59
|
+
` <key>Label</key><string>${LABEL}</string>`,
|
|
60
|
+
' <key>ProgramArguments</key>',
|
|
61
|
+
' <array>',
|
|
62
|
+
...exec.map((arg) => ` <string>${xml(arg)}</string>`),
|
|
63
|
+
' </array>',
|
|
64
|
+
' <key>EnvironmentVariables</key>',
|
|
65
|
+
' <dict>',
|
|
66
|
+
...env,
|
|
67
|
+
' </dict>',
|
|
68
|
+
` <key>WorkingDirectory</key><string>${xml(host.home)}</string>`,
|
|
69
|
+
' <key>RunAtLoad</key><true/>',
|
|
70
|
+
' <key>KeepAlive</key><true/>',
|
|
71
|
+
` <key>StandardOutPath</key><string>${xml(logFile)}</string>`,
|
|
72
|
+
` <key>StandardErrorPath</key><string>${xml(logFile)}</string>`,
|
|
73
|
+
'</dict>',
|
|
74
|
+
'</plist>',
|
|
75
|
+
'',
|
|
76
|
+
].join('\n');
|
|
77
|
+
}
|
|
78
|
+
function systemdPlan(host, exec) {
|
|
79
|
+
const system = host.uid === 0;
|
|
80
|
+
const ctl = system ? ['systemctl'] : ['systemctl', '--user'];
|
|
81
|
+
const file = system
|
|
82
|
+
? `/etc/systemd/system/${SERVICE}.service`
|
|
83
|
+
: join(host.home, '.config', 'systemd', 'user', `${SERVICE}.service`);
|
|
84
|
+
return {
|
|
85
|
+
kind: 'systemd',
|
|
86
|
+
file,
|
|
87
|
+
content: systemdUnit(host, exec, system ? 'multi-user.target' : 'default.target'),
|
|
88
|
+
dirs: [],
|
|
89
|
+
install: [{ args: [...ctl, 'daemon-reload'] }, { args: [...ctl, 'enable', '--now', SERVICE] }],
|
|
90
|
+
uninstall: [{ args: [...ctl, 'disable', '--now', SERVICE], mayFail: true }],
|
|
91
|
+
reload: [{ args: [...ctl, 'daemon-reload'], mayFail: true }],
|
|
92
|
+
status: { args: [...ctl, 'is-active', SERVICE], mayFail: true },
|
|
93
|
+
hints: [
|
|
94
|
+
`Logs: journalctl ${system ? '' : '--user '}-u ${SERVICE} -f`,
|
|
95
|
+
...(system
|
|
96
|
+
? []
|
|
97
|
+
: [`A user service stops with your login session; keep it up without one: loginctl enable-linger ${host.user}`]),
|
|
98
|
+
],
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
function launchdPlan(host, exec) {
|
|
102
|
+
const file = join(host.home, 'Library', 'LaunchAgents', `${LABEL}.plist`);
|
|
103
|
+
const domain = `gui/${String(host.uid)}`;
|
|
104
|
+
const logFile = join(cacheDir(host), 'metro', 'serve', 'service.log');
|
|
105
|
+
return {
|
|
106
|
+
kind: 'launchd',
|
|
107
|
+
file,
|
|
108
|
+
content: launchdPlist(host, exec, logFile),
|
|
109
|
+
dirs: [dirname(logFile)],
|
|
110
|
+
install: [
|
|
111
|
+
{ args: ['launchctl', 'bootout', `${domain}/${LABEL}`], mayFail: true },
|
|
112
|
+
{ args: ['launchctl', 'bootstrap', domain, file] },
|
|
113
|
+
],
|
|
114
|
+
uninstall: [{ args: ['launchctl', 'bootout', `${domain}/${LABEL}`], mayFail: true }],
|
|
115
|
+
reload: [],
|
|
116
|
+
status: { args: ['launchctl', 'print', `${domain}/${LABEL}`], mayFail: true },
|
|
117
|
+
hints: [`Logs: tail -f ${logFile}`],
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
export function servicePlan(host, serveArgs) {
|
|
121
|
+
const exec = [host.node, host.cli, 'serve', ...serveArgs];
|
|
122
|
+
if (host.platform === 'linux')
|
|
123
|
+
return systemdPlan(host, exec);
|
|
124
|
+
if (host.platform === 'darwin')
|
|
125
|
+
return launchdPlan(host, exec);
|
|
126
|
+
throw new Error(`metro service supports Linux (systemd) and macOS (launchd), not ${host.platform}`);
|
|
127
|
+
}
|
|
128
|
+
function install(serveArgs, deps) {
|
|
129
|
+
const { owner } = parseServeArgs(serveArgs);
|
|
130
|
+
deps.preflight(owner);
|
|
131
|
+
const pid = deps.running();
|
|
132
|
+
if (pid !== null)
|
|
133
|
+
throw new Error(`a metro serve is running on this machine (pid ${String(pid)}). Stop it first (metro stop), then install: the service takes over from there`);
|
|
134
|
+
const plan = servicePlan(deps.host, serveArgs);
|
|
135
|
+
for (const dir of plan.dirs)
|
|
136
|
+
deps.mkdir(dir);
|
|
137
|
+
deps.write(plan.file, plan.content);
|
|
138
|
+
for (const command of plan.install)
|
|
139
|
+
deps.run(command);
|
|
140
|
+
deps.out(`Installed ${plan.file}: metro serve now starts at boot and after a crash, and the Server page on metro.box can stop, start and restart it.`);
|
|
141
|
+
for (const hint of plan.hints)
|
|
142
|
+
deps.out(hint);
|
|
143
|
+
return 0;
|
|
144
|
+
}
|
|
145
|
+
function uninstall(deps) {
|
|
146
|
+
const plan = servicePlan(deps.host, []);
|
|
147
|
+
if (!deps.exists(plan.file)) {
|
|
148
|
+
deps.out('metro is not installed as a service on this machine');
|
|
149
|
+
return 1;
|
|
150
|
+
}
|
|
151
|
+
for (const command of plan.uninstall)
|
|
152
|
+
deps.run(command);
|
|
153
|
+
deps.remove(plan.file);
|
|
154
|
+
for (const command of plan.reload)
|
|
155
|
+
deps.run(command);
|
|
156
|
+
deps.out(`Removed ${plan.file}; a running daemon was stopped, and metro serve no longer starts on its own`);
|
|
157
|
+
return 0;
|
|
158
|
+
}
|
|
159
|
+
function status(deps) {
|
|
160
|
+
const plan = servicePlan(deps.host, []);
|
|
161
|
+
if (!deps.exists(plan.file)) {
|
|
162
|
+
deps.out(`not installed (metro service install writes ${plan.file})`);
|
|
163
|
+
return 1;
|
|
164
|
+
}
|
|
165
|
+
const active = deps.run(plan.status).status === 0;
|
|
166
|
+
deps.out(active
|
|
167
|
+
? `running as a ${plan.kind} service (${plan.file})`
|
|
168
|
+
: `installed as a ${plan.kind} service (${plan.file}), not running`);
|
|
169
|
+
return active ? 0 : 1;
|
|
170
|
+
}
|
|
171
|
+
function realDeps() {
|
|
172
|
+
const who = userInfo();
|
|
173
|
+
return {
|
|
174
|
+
host: {
|
|
175
|
+
platform: platform(),
|
|
176
|
+
uid: who.uid,
|
|
177
|
+
user: who.username,
|
|
178
|
+
home: homedir(),
|
|
179
|
+
node: process.execPath,
|
|
180
|
+
cli: resolve(process.argv[1] ?? ''),
|
|
181
|
+
env: process.env,
|
|
182
|
+
},
|
|
183
|
+
run: (command) => {
|
|
184
|
+
const [bin = '', ...args] = command.args;
|
|
185
|
+
const result = spawnSync(bin, args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
|
|
186
|
+
const code = result.status ?? 1;
|
|
187
|
+
if (code !== 0 && command.mayFail !== true) {
|
|
188
|
+
const said = [result.stderr, result.stdout, result.error?.message].find((t) => typeof t === 'string' && t.trim() !== '') ?? '';
|
|
189
|
+
throw new Error(`${command.args.join(' ')} failed (exit ${String(code)}): ${said.trim()}`);
|
|
190
|
+
}
|
|
191
|
+
return { status: code, output: `${result.stdout}${result.stderr}` };
|
|
192
|
+
},
|
|
193
|
+
running: serveLockedBy,
|
|
194
|
+
preflight: (owner) => {
|
|
195
|
+
requireOwner(owner);
|
|
196
|
+
findBun();
|
|
197
|
+
findTailscale();
|
|
198
|
+
},
|
|
199
|
+
mkdir: (dir) => {
|
|
200
|
+
mkdirSync(dir, { recursive: true });
|
|
201
|
+
},
|
|
202
|
+
write: (file, content) => {
|
|
203
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
204
|
+
writeFileSync(file, content, { mode: 0o644 });
|
|
205
|
+
},
|
|
206
|
+
remove: (file) => {
|
|
207
|
+
rmSync(file, { force: true });
|
|
208
|
+
},
|
|
209
|
+
exists: existsSync,
|
|
210
|
+
out: (line) => {
|
|
211
|
+
process.stderr.write(`${line}\n`);
|
|
212
|
+
},
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
export function service(argv, deps = realDeps()) {
|
|
216
|
+
const [verb, ...rest] = argv;
|
|
217
|
+
if (verb === 'install')
|
|
218
|
+
return Promise.resolve(install(rest, deps));
|
|
219
|
+
if (verb === 'uninstall' && rest.length === 0)
|
|
220
|
+
return Promise.resolve(uninstall(deps));
|
|
221
|
+
if (verb === 'status' && rest.length === 0)
|
|
222
|
+
return Promise.resolve(status(deps));
|
|
223
|
+
throw new Error(USAGE);
|
|
224
|
+
}
|
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.67",
|
|
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": {
|
|
@@ -111,6 +111,10 @@ async function sessionApis(): Promise<SessionApis> {
|
|
|
111
111
|
exitCode = RESTART_EXIT;
|
|
112
112
|
onShutdown();
|
|
113
113
|
},
|
|
114
|
+
stop: () => {
|
|
115
|
+
exitCode = HOLD_EXIT;
|
|
116
|
+
onShutdown();
|
|
117
|
+
},
|
|
114
118
|
closeAgentSession,
|
|
115
119
|
gatherAccounts: gatherAccountsForAgents,
|
|
116
120
|
capabilities: accountStationCapabilities,
|
|
@@ -162,6 +166,7 @@ async function main(): Promise<void> {
|
|
|
162
166
|
|
|
163
167
|
const SHUTDOWN_TIMEOUT_MS = 3_000;
|
|
164
168
|
const RESTART_EXIT = 75;
|
|
169
|
+
const HOLD_EXIT = 76;
|
|
165
170
|
let exitCode = 0;
|
|
166
171
|
let shuttingDown = false;
|
|
167
172
|
async function shutdown(): Promise<void> {
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
2
|
+
import { ApiError } from './api-error.js';
|
|
3
|
+
import { apiFailure, apiSession, cors, sendJson } from './api-http.js';
|
|
4
|
+
import { errMsg, log } from './log.js';
|
|
5
|
+
|
|
6
|
+
const STOP = '/api/stop';
|
|
7
|
+
const RESTART = '/api/restart';
|
|
8
|
+
const EXIT_DELAY_MS = 500;
|
|
9
|
+
|
|
10
|
+
export interface ControlApiDeps {
|
|
11
|
+
authorize: (subject: string) => void;
|
|
12
|
+
restart: () => void;
|
|
13
|
+
stop: () => void;
|
|
14
|
+
served?: () => boolean;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface Action {
|
|
18
|
+
run: () => void;
|
|
19
|
+
body: Record<string, boolean>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const servedByCli = (): boolean => (process.env.METRO_CLI_BIN?.trim() ?? '') !== '';
|
|
23
|
+
|
|
24
|
+
function actionFor(path: string, deps: ControlApiDeps): Action | null {
|
|
25
|
+
if (path === STOP) return { run: deps.stop, body: { stopping: true } };
|
|
26
|
+
if (path === RESTART) return { run: deps.restart, body: { restarting: true } };
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function handleControlRequest(req: IncomingMessage, res: ServerResponse, deps: ControlApiDeps): boolean {
|
|
31
|
+
const path = (req.url ?? '').split('?')[0] ?? '';
|
|
32
|
+
const action = actionFor(path, deps);
|
|
33
|
+
if (action === null) return false;
|
|
34
|
+
if (req.method === 'OPTIONS') {
|
|
35
|
+
res.writeHead(204, cors(req)).end();
|
|
36
|
+
return true;
|
|
37
|
+
}
|
|
38
|
+
if (req.method !== 'POST') {
|
|
39
|
+
sendJson(req, res, 405, { error: 'method not allowed' });
|
|
40
|
+
return true;
|
|
41
|
+
}
|
|
42
|
+
Promise.resolve()
|
|
43
|
+
.then(async () => {
|
|
44
|
+
const session = await apiSession(req);
|
|
45
|
+
if (!session) throw new ApiError('unauthorized', 401);
|
|
46
|
+
deps.authorize(session.subject);
|
|
47
|
+
if (!(deps.served ?? servedByCli)())
|
|
48
|
+
throw new ApiError(
|
|
49
|
+
'this daemon was not started by metro serve, so nothing on the machine would bring it back: use the shell instead',
|
|
50
|
+
400,
|
|
51
|
+
);
|
|
52
|
+
log.info({ path, subject: session.subject }, 'control-api: requested from the page');
|
|
53
|
+
setTimeout(action.run, EXIT_DELAY_MS).unref();
|
|
54
|
+
sendJson(req, res, 200, action.body);
|
|
55
|
+
})
|
|
56
|
+
.catch((err: unknown) => {
|
|
57
|
+
if (err instanceof ApiError) apiFailure(req, res, err, 'control-api');
|
|
58
|
+
else {
|
|
59
|
+
log.warn({ err: errMsg(err) }, 'control-api: request failed');
|
|
60
|
+
if (!res.headersSent) sendJson(req, res, 500, { error: 'control api failed' });
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
return true;
|
|
64
|
+
}
|
|
@@ -52,6 +52,7 @@ import type { StationName } from '../db/stations.js';
|
|
|
52
52
|
export interface LocalModeDeps {
|
|
53
53
|
syncStations: (station: StationName) => Promise<void>;
|
|
54
54
|
restart: () => void;
|
|
55
|
+
stop: () => void;
|
|
55
56
|
closeAgentSession: (id: string) => Promise<boolean>;
|
|
56
57
|
gatherAccounts: AgentApiDeps['gatherAccounts'];
|
|
57
58
|
capabilities: AgentApiDeps['capabilities'];
|
|
@@ -190,6 +191,7 @@ export function localSessionApis(deps: LocalModeDeps): SessionApis {
|
|
|
190
191
|
localCli,
|
|
191
192
|
claudeApi: { authorize: (subject) => { assertLocalOwner(subject); } },
|
|
192
193
|
updateApi: { authorize: (subject) => { assertLocalOwner(subject); }, restart: deps.restart },
|
|
194
|
+
controlApi: { authorize: (subject) => { assertLocalOwner(subject); }, restart: deps.restart, stop: deps.stop },
|
|
193
195
|
machineApi: { authorize: (subject) => { assertLocalOwner(subject); } },
|
|
194
196
|
terminalApi: { authorize: (subject) => { assertLocalOwner(subject); } },
|
|
195
197
|
identity: { owner: localOwner },
|
|
@@ -18,6 +18,7 @@ import { handleVaultApiRequest, type VaultApiDeps } from './vault-api.js';
|
|
|
18
18
|
import { handleServersApiRequest, type ServersApiDeps } from './servers-api.js';
|
|
19
19
|
import { handleBundleRequest, type BundleApiDeps } from './bundle-api.js';
|
|
20
20
|
import { handleUpdateRequest, type UpdateApiDeps } from './update-api.js';
|
|
21
|
+
import { handleControlRequest, type ControlApiDeps } from './control-api.js';
|
|
21
22
|
import { handleMachineRequest, type MachineApiDeps } from './machine-api.js';
|
|
22
23
|
import { handleTerminalRequest, type TerminalApiDeps } from './terminal-api.js';
|
|
23
24
|
|
|
@@ -28,6 +29,7 @@ export interface SessionApis {
|
|
|
28
29
|
vaultApi?: VaultApiDeps;
|
|
29
30
|
serversApi?: ServersApiDeps;
|
|
30
31
|
updateApi?: UpdateApiDeps;
|
|
32
|
+
controlApi?: ControlApiDeps;
|
|
31
33
|
machineApi?: MachineApiDeps;
|
|
32
34
|
terminalApi?: TerminalApiDeps;
|
|
33
35
|
localCli?: LocalCliDeps;
|
|
@@ -54,6 +56,7 @@ export function handleSessionApis(
|
|
|
54
56
|
...when(apis.vaultApi, (d) => handleVaultApiRequest(req, res, d)),
|
|
55
57
|
...when(apis.serversApi, (d) => handleServersApiRequest(req, res, d)),
|
|
56
58
|
...when(apis.updateApi, (d) => handleUpdateRequest(req, res, d)),
|
|
59
|
+
...when(apis.controlApi, (d) => handleControlRequest(req, res, d)),
|
|
57
60
|
...when(apis.machineApi, (d) => handleMachineRequest(req, res, d)),
|
|
58
61
|
...when(apis.terminalApi, (d) => handleTerminalRequest(req, res, d)),
|
|
59
62
|
...when(apis.claudeApi, (d) => handleClaudeRequest(req, res, d)),
|
package/runtime/runtime.json
CHANGED