@stage-labs/metro 0.1.0-beta.77 → 0.1.0-beta.79
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 +4 -1
- package/dist/runtime-install.js +7 -4
- package/dist/runtime.js +6 -2
- package/dist/serve.js +2 -2
- package/dist/service.js +29 -11
- package/package.json +1 -1
- package/runtime/node_modules/@metro-labs/daemon/src/net/tunnel.ts +43 -2
- package/runtime/node_modules/@metro-labs/daemon/src/stations/attach-resolve.ts +2 -0
- package/runtime/runtime.json +1 -1
- package/runtime/server.ts +1 -0
package/dist/cli.js
CHANGED
|
@@ -6,7 +6,7 @@ import { launchClaude } from './claude.js';
|
|
|
6
6
|
import { installPlugin } from './plugin.js';
|
|
7
7
|
import { update } from './update.js';
|
|
8
8
|
import { serve } from './serve.js';
|
|
9
|
-
import { service } from './service.js';
|
|
9
|
+
import { service, serviceStopHint } from './service.js';
|
|
10
10
|
import { currentVersion } from './version.js';
|
|
11
11
|
const USAGE = `metro — run your agent on this machine
|
|
12
12
|
|
|
@@ -58,6 +58,9 @@ async function stopDaemon() {
|
|
|
58
58
|
}
|
|
59
59
|
for (const d of stopped)
|
|
60
60
|
process.stderr.write(`Stopped metro (pid ${String(d.pid)}, via ${d.via})\n`);
|
|
61
|
+
const hint = serviceStopHint();
|
|
62
|
+
if (hint !== null)
|
|
63
|
+
process.stderr.write(`${hint}\n`);
|
|
61
64
|
return 0;
|
|
62
65
|
}
|
|
63
66
|
const HELP = new Set([undefined, 'help', '--help', '-h']);
|
package/dist/runtime-install.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { spawnSync } from 'node:child_process';
|
|
2
|
-
import { cpSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { copyFileSync, cpSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
3
3
|
import { homedir } from 'node:os';
|
|
4
4
|
import { join } from 'node:path';
|
|
5
5
|
import { localStations } from './local.js';
|
|
6
|
-
import {
|
|
6
|
+
import { STORE_ENTRY, daemonEntry, findBun, runtimeDir } from './runtime.js';
|
|
7
7
|
export const MANIFEST_FILE = 'stations.json';
|
|
8
8
|
const METRO_SOURCES = join('node_modules', '@metro-labs');
|
|
9
9
|
const INSTALL_TIMEOUT_MS = 15 * 60_000;
|
|
@@ -41,6 +41,9 @@ function readOrNull(path) {
|
|
|
41
41
|
}
|
|
42
42
|
}
|
|
43
43
|
function syncSources(sources, store) {
|
|
44
|
+
const shim = join(sources, STORE_ENTRY);
|
|
45
|
+
if (existsSync(shim))
|
|
46
|
+
copyFileSync(shim, join(store, STORE_ENTRY));
|
|
44
47
|
const stamp = readOrNull(join(sources, 'runtime.json'));
|
|
45
48
|
if (stamp !== null && readOrNull(join(store, 'runtime.json')) === stamp)
|
|
46
49
|
return false;
|
|
@@ -71,7 +74,7 @@ export function prepareRuntime(opts = {}) {
|
|
|
71
74
|
const sources = opts.sources ?? runtimeDir();
|
|
72
75
|
const manifestPath = join(sources, MANIFEST_FILE);
|
|
73
76
|
if (!existsSync(manifestPath))
|
|
74
|
-
return { dir: sources, entry:
|
|
77
|
+
return { dir: sources, entry: daemonEntry(sources), trains: join(sources, 'trains'), manifest: null };
|
|
75
78
|
const store = opts.store ?? runtimeStore();
|
|
76
79
|
const log = opts.log ??
|
|
77
80
|
((line) => {
|
|
@@ -80,5 +83,5 @@ export function prepareRuntime(opts = {}) {
|
|
|
80
83
|
mkdirSync(join(store, 'trains'), { recursive: true });
|
|
81
84
|
syncSources(sources, store);
|
|
82
85
|
installDependencies(store, dependenciesFor(readManifest(manifestPath), localStations(opts.agents)), opts.bun ?? findBun(), log);
|
|
83
|
-
return { dir: store, entry:
|
|
86
|
+
return { dir: store, entry: daemonEntry(store), trains: join(store, 'trains'), manifest: manifestPath };
|
|
84
87
|
}
|
package/dist/runtime.js
CHANGED
|
@@ -3,7 +3,11 @@ import { existsSync } from 'node:fs';
|
|
|
3
3
|
import { dirname, join } from 'node:path';
|
|
4
4
|
import { fileURLToPath } from 'node:url';
|
|
5
5
|
const AGENT_RE = /^[A-Za-z0-9][A-Za-z0-9_-]{10}$/;
|
|
6
|
-
export const
|
|
6
|
+
export const STORE_ENTRY = 'server.ts';
|
|
7
|
+
export const PACKAGE_ENTRY = join('node_modules', '@metro-labs', 'daemon', 'src', 'server.ts');
|
|
8
|
+
export const daemonEntry = (dir) => existsSync(join(dir, STORE_ENTRY))
|
|
9
|
+
? join(dir, STORE_ENTRY)
|
|
10
|
+
: join(dir, PACKAGE_ENTRY);
|
|
7
11
|
export class MissingRuntime extends Error {
|
|
8
12
|
}
|
|
9
13
|
export function runtimeDir() {
|
|
@@ -11,7 +15,7 @@ export function runtimeDir() {
|
|
|
11
15
|
const dir = explicit !== undefined && explicit !== ''
|
|
12
16
|
? explicit
|
|
13
17
|
: join(dirname(dirname(fileURLToPath(import.meta.url))), 'runtime');
|
|
14
|
-
if (!existsSync(
|
|
18
|
+
if (!existsSync(daemonEntry(dir)))
|
|
15
19
|
throw new MissingRuntime(`no metro daemon at ${dir}. Reinstall with: npm i -g @stage-labs/metro@beta`);
|
|
16
20
|
return dir;
|
|
17
21
|
}
|
package/dist/serve.js
CHANGED
|
@@ -4,7 +4,7 @@ import { join } from 'node:path';
|
|
|
4
4
|
import { agentsDir } from './local.js';
|
|
5
5
|
import { currentVersion } from './version.js';
|
|
6
6
|
import { serveLockedBy, serveStateDir } from './control.js';
|
|
7
|
-
import { findBun, localPort,
|
|
7
|
+
import { findBun, localPort, spawnPlan } from './runtime.js';
|
|
8
8
|
import { prepareRuntime } from './runtime-install.js';
|
|
9
9
|
import { ensureNodeName } from './node-name.js';
|
|
10
10
|
import { holdUntilStart } from './hold.js';
|
|
@@ -113,7 +113,7 @@ export function servePlan(opts) {
|
|
|
113
113
|
const env = Object.fromEntries(Object.entries(process.env).filter(([key]) => !SCRUBBED.has(key)));
|
|
114
114
|
return {
|
|
115
115
|
command: findBun(),
|
|
116
|
-
args: [
|
|
116
|
+
args: [opts.runtime.entry],
|
|
117
117
|
cwd: opts.runtime.dir,
|
|
118
118
|
env: {
|
|
119
119
|
...env,
|
package/dist/service.js
CHANGED
|
@@ -127,11 +127,15 @@ export function servicePlan(host, serveArgs) {
|
|
|
127
127
|
}
|
|
128
128
|
function install(serveArgs, deps) {
|
|
129
129
|
const { owner } = parseServeArgs(serveArgs);
|
|
130
|
+
const plan = servicePlan(deps.host, serveArgs);
|
|
131
|
+
if (deps.exists(plan.file)) {
|
|
132
|
+
deps.out(`metro is already installed as a ${plan.kind} service (${plan.file}) and restarts on its own; metro service status says whether it is running. To change its arguments: metro service uninstall, then install again`);
|
|
133
|
+
return 0;
|
|
134
|
+
}
|
|
130
135
|
deps.preflight(owner);
|
|
131
136
|
const pid = deps.running();
|
|
132
137
|
if (pid !== null)
|
|
133
138
|
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
139
|
for (const dir of plan.dirs)
|
|
136
140
|
deps.mkdir(dir);
|
|
137
141
|
deps.write(plan.file, plan.content);
|
|
@@ -168,18 +172,32 @@ function status(deps) {
|
|
|
168
172
|
: `installed as a ${plan.kind} service (${plan.file}), not running`);
|
|
169
173
|
return active ? 0 : 1;
|
|
170
174
|
}
|
|
171
|
-
function
|
|
175
|
+
function realHost() {
|
|
172
176
|
const who = userInfo();
|
|
173
177
|
return {
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
178
|
+
platform: platform(),
|
|
179
|
+
uid: who.uid,
|
|
180
|
+
user: who.username,
|
|
181
|
+
home: homedir(),
|
|
182
|
+
node: process.execPath,
|
|
183
|
+
cli: resolve(process.argv[1] ?? ''),
|
|
184
|
+
env: process.env,
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
export function serviceStopHint(host = realHost(), exists = existsSync) {
|
|
188
|
+
if (host.platform !== 'linux' && host.platform !== 'darwin')
|
|
189
|
+
return null;
|
|
190
|
+
const plan = servicePlan(host, []);
|
|
191
|
+
if (!exists(plan.file))
|
|
192
|
+
return null;
|
|
193
|
+
const keep = plan.kind === 'systemd'
|
|
194
|
+
? `systemctl ${host.uid === 0 ? '' : '--user '}stop ${SERVICE}`
|
|
195
|
+
: `launchctl bootout gui/${String(host.uid)}/${LABEL}`;
|
|
196
|
+
return `metro runs as a ${plan.kind} service here (${plan.file}), so it starts again on its own in a moment. To keep it stopped: ${keep}`;
|
|
197
|
+
}
|
|
198
|
+
function realDeps() {
|
|
199
|
+
return {
|
|
200
|
+
host: realHost(),
|
|
183
201
|
run: (command) => {
|
|
184
202
|
const [bin = '', ...args] = command.args;
|
|
185
203
|
const result = spawnSync(bin, args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
|
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.79",
|
|
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": {
|
|
@@ -12,6 +12,8 @@ import { localOwner } from '../agents/file-admin.js';
|
|
|
12
12
|
const RESTART_DELAY_MS = 2_000;
|
|
13
13
|
const RESTART_DELAY_MAX_MS = 30_000;
|
|
14
14
|
const TAKEN_RE = /listener already exists/i;
|
|
15
|
+
const ADOPTED_MISSES = 2;
|
|
16
|
+
const adoptedCheckMs = (): number => Number(process.env.METRO_TUNNEL_WATCH_MS) || 30_000;
|
|
15
17
|
const FUNNEL_ON_RE = /\(Funnel on\)/i;
|
|
16
18
|
|
|
17
19
|
export const tunnelWanted = (): boolean => process.env.METRO_TUNNEL?.trim() === 'tailscale';
|
|
@@ -25,6 +27,7 @@ export const funnelUrlIn = (text: string): string | null =>
|
|
|
25
27
|
export interface Adopted {
|
|
26
28
|
url: string | null;
|
|
27
29
|
hint: string;
|
|
30
|
+
alive?: () => Promise<boolean>;
|
|
28
31
|
}
|
|
29
32
|
|
|
30
33
|
export interface TunnelDriver {
|
|
@@ -106,11 +109,17 @@ export function nodeNameIn(statusJson: string): string | null {
|
|
|
106
109
|
|
|
107
110
|
async function adoptFunnel(bin: string, port: number, probe: Probe, owner: () => string | null): Promise<Adopted> {
|
|
108
111
|
const name = nodeNameIn(await runText(bin, ['status', '--json']));
|
|
112
|
+
const watched = (url: string): Adopted => ({
|
|
113
|
+
url,
|
|
114
|
+
hint: `a Funnel already publishes this daemon at ${url}; using it`,
|
|
115
|
+
alive: () => probe(url, owner()),
|
|
116
|
+
});
|
|
109
117
|
if (name !== null) {
|
|
110
118
|
const url = `https://${name}`;
|
|
111
|
-
if (await probe(url, owner())) return
|
|
119
|
+
if (await probe(url, owner())) return watched(url);
|
|
112
120
|
}
|
|
113
|
-
|
|
121
|
+
const found = funnelAlreadyServing(await runText(bin, ['funnel', 'status']), port);
|
|
122
|
+
return found.url === null ? found : watched(found.url);
|
|
114
123
|
}
|
|
115
124
|
|
|
116
125
|
export function funnelAlreadyServing(status: string, port: number): Adopted {
|
|
@@ -240,6 +249,7 @@ export class Tunnel {
|
|
|
240
249
|
private closed = false;
|
|
241
250
|
private output: string[] = [];
|
|
242
251
|
private restartDelay = RESTART_DELAY_MS;
|
|
252
|
+
private watch: ReturnType<typeof setInterval> | null = null;
|
|
243
253
|
|
|
244
254
|
constructor(
|
|
245
255
|
private driver: TunnelDriver,
|
|
@@ -330,6 +340,7 @@ export class Tunnel {
|
|
|
330
340
|
if (found.url !== null) {
|
|
331
341
|
log.info({ url: found.url }, `${this.driver.name}: ${found.hint}`);
|
|
332
342
|
this.noticeUrl(found.url);
|
|
343
|
+
if (found.alive !== undefined) this.watchAdopted(found.url, found.alive);
|
|
333
344
|
return;
|
|
334
345
|
}
|
|
335
346
|
log.error({ output }, `${this.driver.name}: ${found.hint}`);
|
|
@@ -340,8 +351,38 @@ export class Tunnel {
|
|
|
340
351
|
this.restartDelay = Math.min(this.restartDelay * 2, RESTART_DELAY_MAX_MS);
|
|
341
352
|
}
|
|
342
353
|
|
|
354
|
+
private watchAdopted(url: string, alive: () => Promise<boolean>): void {
|
|
355
|
+
let misses = 0;
|
|
356
|
+
const tick = async (): Promise<void> => {
|
|
357
|
+
if (this.closed || liveUrl !== url) {
|
|
358
|
+
this.unwatch();
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
misses = (await alive()) ? 0 : misses + 1;
|
|
362
|
+
if (misses < ADOPTED_MISSES) return;
|
|
363
|
+
this.unwatch();
|
|
364
|
+
log.warn({ url }, `${this.driver.name}: the Funnel this daemon adopted no longer answers; publishing our own`);
|
|
365
|
+
liveUrl = null;
|
|
366
|
+
this.restartDelay = RESTART_DELAY_MS;
|
|
367
|
+
this.start();
|
|
368
|
+
};
|
|
369
|
+
this.unwatch();
|
|
370
|
+
this.watch = setInterval(() => {
|
|
371
|
+
tick().catch((err: unknown) => {
|
|
372
|
+
log.warn({ err: errMsg(err) }, `${this.driver.name}: adopted-funnel check failed`);
|
|
373
|
+
});
|
|
374
|
+
}, adoptedCheckMs());
|
|
375
|
+
this.watch.unref();
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
private unwatch(): void {
|
|
379
|
+
if (this.watch !== null) clearInterval(this.watch);
|
|
380
|
+
this.watch = null;
|
|
381
|
+
}
|
|
382
|
+
|
|
343
383
|
stop(): void {
|
|
344
384
|
this.closed = true;
|
|
385
|
+
this.unwatch();
|
|
345
386
|
this.child?.kill('SIGINT');
|
|
346
387
|
this.child = null;
|
|
347
388
|
}
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
writeInlineTemp,
|
|
13
13
|
} from './attach-inline.js';
|
|
14
14
|
import { realpathSync } from 'node:fs';
|
|
15
|
+
import { log } from '@metro-labs/core/log';
|
|
15
16
|
import { readUpload, UPLOAD_TTL_MS } from '../files/upload-store.js';
|
|
16
17
|
import type { CanonicalAttachment } from '@metro-labs/core/stations/types';
|
|
17
18
|
|
|
@@ -167,6 +168,7 @@ async function resolveAttachment(
|
|
|
167
168
|
.map((s) => `\`${s}\``)
|
|
168
169
|
.join(', ')}); pass exactly one of \`upload\`, \`data\`, \`url\` or \`path\``,
|
|
169
170
|
);
|
|
171
|
+
log.info({ source: sources[0] }, 'send: attachment source');
|
|
170
172
|
if (a.upload) return fromUpload(a, a.upload, opts.allowed);
|
|
171
173
|
if (a.data) return fromData(a, a.data, budget);
|
|
172
174
|
if (a.path) return fromPath(a, a.path);
|
package/runtime/runtime.json
CHANGED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import './node_modules/@metro-labs/daemon/src/server.ts';
|