@stage-labs/metro 0.1.0-beta.61 → 0.1.0-beta.63
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/node-name.js +51 -0
- package/dist/serve.js +3 -1
- package/package.json +1 -1
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/attach-serve.ts +1 -1
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/local-mode.ts +1 -0
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/machine-api.ts +61 -0
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/session-apis.ts +3 -0
- package/runtime/runtime.json +1 -1
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import { randomBytes } from 'node:crypto';
|
|
3
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { agentsDir } from './local.js';
|
|
6
|
+
const NODE_FILE = '.node';
|
|
7
|
+
const ALPHABET = 'abcdefghjkmnpqrstuvwxyz23456789';
|
|
8
|
+
const NAME_RE = /^metro-[a-z0-9]{6}$/;
|
|
9
|
+
export function newNodeName() {
|
|
10
|
+
const bytes = randomBytes(6);
|
|
11
|
+
return `metro-${[...bytes].map((b) => ALPHABET[b % ALPHABET.length] ?? 'x').join('')}`;
|
|
12
|
+
}
|
|
13
|
+
export function nodeName(dir = agentsDir()) {
|
|
14
|
+
const path = join(dir, NODE_FILE);
|
|
15
|
+
if (existsSync(path)) {
|
|
16
|
+
const held = readFileSync(path, 'utf8').trim();
|
|
17
|
+
if (NAME_RE.test(held))
|
|
18
|
+
return held;
|
|
19
|
+
}
|
|
20
|
+
const name = newNodeName();
|
|
21
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
22
|
+
writeFileSync(path, `${name}\n`, { mode: 0o600 });
|
|
23
|
+
return name;
|
|
24
|
+
}
|
|
25
|
+
export function currentNodeLabel(bin) {
|
|
26
|
+
const run = spawnSync(bin, ['status', '--json'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
|
|
27
|
+
if (run.error !== undefined || run.status !== 0)
|
|
28
|
+
return null;
|
|
29
|
+
try {
|
|
30
|
+
const parsed = JSON.parse(run.stdout);
|
|
31
|
+
const dns = typeof parsed.Self?.DNSName === 'string' ? parsed.Self.DNSName : '';
|
|
32
|
+
const label = dns.split('.')[0] ?? '';
|
|
33
|
+
return label === '' ? null : label.toLowerCase();
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
export function ensureNodeName(bin, dir = agentsDir()) {
|
|
40
|
+
const wanted = nodeName(dir);
|
|
41
|
+
if (currentNodeLabel(bin) === wanted)
|
|
42
|
+
return wanted;
|
|
43
|
+
const run = spawnSync(bin, ['set', '--hostname', wanted], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
|
|
44
|
+
if (run.error !== undefined || run.status !== 0) {
|
|
45
|
+
const said = run.stderr.trim();
|
|
46
|
+
const reason = said === '' ? (run.error?.message ?? `exit ${String(run.status)}`) : said;
|
|
47
|
+
throw new Error(`could not name this machine ${wanted} on your tailnet (${reason}).\n` +
|
|
48
|
+
`Run it once yourself, then start again: sudo tailscale set --hostname ${wanted}`);
|
|
49
|
+
}
|
|
50
|
+
return wanted;
|
|
51
|
+
}
|
package/dist/serve.js
CHANGED
|
@@ -6,6 +6,7 @@ import { currentVersion } from './version.js';
|
|
|
6
6
|
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
|
+
import { ensureNodeName } from './node-name.js';
|
|
9
10
|
const SCRUBBED = new Set(['METRO_RUN_TOKEN', 'METRO_AGENT', 'DATABASE_URL']);
|
|
10
11
|
const PORT_FLAG = /^--port=(.*)$/;
|
|
11
12
|
const OWNER_FLAG = /^--owner=(.*)$/;
|
|
@@ -120,6 +121,7 @@ export function serve(argv) {
|
|
|
120
121
|
'Stop it first: metro stop');
|
|
121
122
|
requireOwner(owner);
|
|
122
123
|
const tailscaleBin = findTailscale();
|
|
123
|
-
|
|
124
|
+
const node = ensureNodeName(tailscaleBin);
|
|
125
|
+
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`);
|
|
124
126
|
return spawnPlan(() => servePlan({ runtime: prepareRuntime(), port, owner, tailscaleBin }));
|
|
125
127
|
}
|
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.63",
|
|
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": {
|
|
@@ -25,7 +25,7 @@ function authorized(req: IncomingMessage, name: string): boolean {
|
|
|
25
25
|
);
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
-
const DEFAULT_PUBLIC_BASE = 'https://
|
|
28
|
+
const DEFAULT_PUBLIC_BASE = 'https://api.metro.box';
|
|
29
29
|
|
|
30
30
|
export function publicBaseUrl(): string | null {
|
|
31
31
|
const env = process.env.METRO_PUBLIC_URL?.trim();
|
|
@@ -190,6 +190,7 @@ export function localSessionApis(deps: LocalModeDeps): SessionApis {
|
|
|
190
190
|
localCli,
|
|
191
191
|
claudeApi: { authorize: (subject) => { assertLocalOwner(subject); } },
|
|
192
192
|
updateApi: { authorize: (subject) => { assertLocalOwner(subject); }, restart: deps.restart },
|
|
193
|
+
machineApi: { authorize: (subject) => { assertLocalOwner(subject); } },
|
|
193
194
|
identity: { owner: localOwner },
|
|
194
195
|
mode: localModeInfo,
|
|
195
196
|
};
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
2
|
+
import { hostname } from 'node:os';
|
|
3
|
+
import { ApiError } from './api-error.js';
|
|
4
|
+
import { apiFailure, apiSession, cors, sendJson } from './api-http.js';
|
|
5
|
+
import { publicBaseUrl } from './attach-serve.js';
|
|
6
|
+
import { claudeDir } from './claude-files.js';
|
|
7
|
+
import { webhookPort } from './tunnel.js';
|
|
8
|
+
import { METRO_VERSION } from './version.js';
|
|
9
|
+
import { agentsDir } from '../db/file-source.js';
|
|
10
|
+
import { localOwner } from '../db/file-admin.js';
|
|
11
|
+
|
|
12
|
+
const PATH = '/api/server';
|
|
13
|
+
|
|
14
|
+
export interface MachineApiDeps {
|
|
15
|
+
authorize: (subject: string) => void;
|
|
16
|
+
startedAt?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const bootedAt = new Date(Date.now() - process.uptime() * 1000).toISOString();
|
|
20
|
+
|
|
21
|
+
export function machineInfo(startedAt = bootedAt): Record<string, unknown> {
|
|
22
|
+
const store = process.env.METRO_RUNTIME_STORE?.trim() ?? '';
|
|
23
|
+
return {
|
|
24
|
+
version: METRO_VERSION,
|
|
25
|
+
owner: localOwner(),
|
|
26
|
+
hostname: hostname(),
|
|
27
|
+
platform: process.platform,
|
|
28
|
+
arch: process.arch,
|
|
29
|
+
port: webhookPort(),
|
|
30
|
+
publicUrl: publicBaseUrl(),
|
|
31
|
+
uptimeSeconds: Math.round(process.uptime()),
|
|
32
|
+
startedAt,
|
|
33
|
+
bun: process.versions.bun ?? null,
|
|
34
|
+
agentsDir: agentsDir(),
|
|
35
|
+
claudeDir: claudeDir(),
|
|
36
|
+
runtimeStore: store === '' ? null : store,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function handleMachineRequest(req: IncomingMessage, res: ServerResponse, deps: MachineApiDeps): boolean {
|
|
41
|
+
const path = (req.url ?? '').split('?')[0] ?? '';
|
|
42
|
+
if (path !== PATH) return false;
|
|
43
|
+
if (req.method === 'OPTIONS') {
|
|
44
|
+
res.writeHead(204, cors(req)).end();
|
|
45
|
+
return true;
|
|
46
|
+
}
|
|
47
|
+
if (req.method !== 'GET') {
|
|
48
|
+
sendJson(req, res, 405, { error: 'method not allowed' });
|
|
49
|
+
return true;
|
|
50
|
+
}
|
|
51
|
+
apiSession(req)
|
|
52
|
+
.then((session) => {
|
|
53
|
+
if (!session) throw new ApiError('unauthorized', 401);
|
|
54
|
+
deps.authorize(session.subject);
|
|
55
|
+
sendJson(req, res, 200, machineInfo(deps.startedAt));
|
|
56
|
+
})
|
|
57
|
+
.catch((err: unknown) => {
|
|
58
|
+
apiFailure(req, res, err, 'machine-api');
|
|
59
|
+
});
|
|
60
|
+
return true;
|
|
61
|
+
}
|
|
@@ -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 { handleMachineRequest, type MachineApiDeps } from './machine-api.js';
|
|
21
22
|
|
|
22
23
|
export interface SessionApis {
|
|
23
24
|
agentApi?: AgentApiDeps;
|
|
@@ -26,6 +27,7 @@ export interface SessionApis {
|
|
|
26
27
|
vaultApi?: VaultApiDeps;
|
|
27
28
|
serversApi?: ServersApiDeps;
|
|
28
29
|
updateApi?: UpdateApiDeps;
|
|
30
|
+
machineApi?: MachineApiDeps;
|
|
29
31
|
localCli?: LocalCliDeps;
|
|
30
32
|
claudeApi?: ClaudeApiDeps;
|
|
31
33
|
connectorApi?: ConnectorApiDeps;
|
|
@@ -50,6 +52,7 @@ export function handleSessionApis(
|
|
|
50
52
|
...when(apis.vaultApi, (d) => handleVaultApiRequest(req, res, d)),
|
|
51
53
|
...when(apis.serversApi, (d) => handleServersApiRequest(req, res, d)),
|
|
52
54
|
...when(apis.updateApi, (d) => handleUpdateRequest(req, res, d)),
|
|
55
|
+
...when(apis.machineApi, (d) => handleMachineRequest(req, res, d)),
|
|
53
56
|
...when(apis.claudeApi, (d) => handleClaudeRequest(req, res, d)),
|
|
54
57
|
...when(apis.agentConnectorApi, (d) => handleAgentConnectorRequest(req, res, d)),
|
|
55
58
|
...when(apis.agentApi, (d) => handleAgentApiRequest(req, res, d)),
|
package/runtime/runtime.json
CHANGED