@stage-labs/metro 0.1.0-beta.201 → 0.1.0-beta.203
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/claude.js +4 -15
- package/dist/cli.js +5 -3
- package/dist/hold.js +1 -2
- package/dist/runtime-install.js +4 -5
- package/dist/runtime.js +1 -4
- package/dist/serve.js +4 -5
- package/dist/service.js +82 -21
- package/dist/update.js +1 -1
- package/package.json +2 -2
- package/runtime/marketplace/plugin/.claude-plugin/plugin.json +1 -1
- package/runtime/marketplace/plugin/orchestrator.md +1 -1
- package/runtime/node_modules/@metro-labs/core/src/events.ts +4 -2
- package/runtime/node_modules/@metro-labs/core/src/lines.ts +12 -25
- package/runtime/node_modules/@metro-labs/core/src/stations/account-store.ts +45 -22
- package/runtime/node_modules/@metro-labs/core/src/stations/attachments.ts +0 -1
- package/runtime/node_modules/@metro-labs/core/src/stations/messaging-normalize.ts +8 -80
- package/runtime/node_modules/@metro-labs/core/src/stations/station-runtime.ts +3 -5
- package/runtime/node_modules/@metro-labs/core/src/stations/train-events.ts +4 -9
- package/runtime/node_modules/@metro-labs/core/src/stations/types.ts +3 -1
- package/runtime/node_modules/@metro-labs/daemon/src/agent-user/files.ts +9 -39
- package/runtime/node_modules/@metro-labs/daemon/src/agent-user/job-env.ts +1 -1
- package/runtime/node_modules/@metro-labs/daemon/src/agent-user/provision.ts +9 -28
- package/runtime/node_modules/@metro-labs/daemon/src/agent-user/schedule-detail.ts +0 -1
- package/runtime/node_modules/@metro-labs/daemon/src/agent-user/schedules-api.ts +3 -3
- package/runtime/node_modules/@metro-labs/daemon/src/agent-user/schedules.ts +8 -116
- package/runtime/node_modules/@metro-labs/daemon/src/agent-user/unit-files.ts +11 -35
- package/runtime/node_modules/@metro-labs/daemon/src/agent-user/user.ts +7 -9
- package/runtime/node_modules/@metro-labs/daemon/src/agent-user/view.ts +1 -1
- package/runtime/node_modules/@metro-labs/daemon/src/agents/account-lookups.ts +1 -1
- package/runtime/node_modules/@metro-labs/daemon/src/agents/accounts-api.ts +1 -1
- package/runtime/node_modules/@metro-labs/daemon/src/agents/allowlist.ts +1 -1
- package/runtime/node_modules/@metro-labs/daemon/src/agents/bundle.ts +3 -3
- package/runtime/node_modules/@metro-labs/daemon/src/agents/file-admin.ts +2 -2
- package/runtime/node_modules/@metro-labs/daemon/src/agents/files.ts +3 -29
- package/runtime/node_modules/@metro-labs/daemon/src/approvals/pending.ts +5 -8
- package/runtime/node_modules/@metro-labs/daemon/src/approvals/preview.ts +1 -1
- package/runtime/node_modules/@metro-labs/daemon/src/boot/boot.ts +5 -8
- package/runtime/node_modules/@metro-labs/daemon/src/boot/connect-hint.ts +3 -6
- package/runtime/node_modules/@metro-labs/daemon/src/channels/inbound.ts +25 -3
- package/runtime/node_modules/@metro-labs/daemon/src/channels/media-note.ts +2 -3
- package/runtime/node_modules/@metro-labs/daemon/src/claude/files.ts +2 -2
- package/runtime/node_modules/@metro-labs/daemon/src/claude/login.ts +1 -1
- package/runtime/node_modules/@metro-labs/daemon/src/claude/memory.ts +6 -56
- package/runtime/node_modules/@metro-labs/daemon/src/claude/plugin-install.ts +3 -3
- package/runtime/node_modules/@metro-labs/daemon/src/claude/session-files.ts +1 -1
- package/runtime/node_modules/@metro-labs/daemon/src/claude/session.ts +7 -6
- package/runtime/node_modules/@metro-labs/daemon/src/claude/settings.ts +1 -1
- package/runtime/node_modules/@metro-labs/daemon/src/claude/setup.ts +8 -7
- package/runtime/node_modules/@metro-labs/daemon/src/claude/skills.ts +4 -4
- package/runtime/node_modules/@metro-labs/daemon/src/claude/version.ts +1 -1
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/api.ts +2 -4
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/config.ts +19 -22
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/gates.ts +1 -2
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/oauth-discovery.ts +6 -8
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/oauth-routes.ts +3 -6
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/plugin-sync.ts +1 -1
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/relay.ts +2 -3
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/store.ts +28 -1
- package/runtime/node_modules/@metro-labs/daemon/src/files/attach-serve.ts +1 -1
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/bedrock.ts +4 -4
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/codex-auth.ts +1 -1
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/codex-device.ts +1 -1
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/codex-stream.ts +1 -1
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/codex-translate.ts +3 -2
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/codex.ts +1 -1
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/effort.ts +2 -2
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/eventstream.ts +1 -1
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/forward.ts +3 -3
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/frames.ts +1 -1
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/gateway.ts +3 -3
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/gemini-auth.ts +6 -6
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/gemini-client.ts +7 -7
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/gemini-stream.ts +1 -1
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/gemini-translate.ts +3 -2
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/gemini.ts +1 -1
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-config.ts +10 -31
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-signin.ts +1 -1
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-store.ts +1 -1
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/openrouter.ts +2 -2
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/provider-models.ts +3 -3
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/text.ts +1 -2
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/usage.ts +2 -2
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/call-tools.ts +19 -1
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/policy-gate.ts +5 -6
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/policy-snapshot.ts +3 -3
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/profile-lookup.ts +2 -2
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/read-tool.ts +2 -2
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/tool-schemas.ts +14 -0
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/typing.ts +46 -0
- package/runtime/node_modules/@metro-labs/daemon/src/metro-user/helper-script.ts +2 -2
- package/runtime/node_modules/@metro-labs/daemon/src/metro-user/install.ts +37 -0
- package/runtime/node_modules/@metro-labs/daemon/src/net/tunnel.ts +2 -2
- package/runtime/node_modules/@metro-labs/daemon/src/policy/policy.ts +1 -1
- package/runtime/node_modules/@metro-labs/daemon/src/routes/local-mode.ts +1 -4
- package/runtime/node_modules/@metro-labs/daemon/src/routes/session-apis.ts +3 -8
- package/runtime/node_modules/@metro-labs/daemon/src/server/machine.ts +2 -2
- package/runtime/node_modules/@metro-labs/daemon/src/server/update.ts +2 -2
- package/runtime/node_modules/@metro-labs/daemon/src/stations/attach-session.ts +2 -2
- package/runtime/node_modules/@metro-labs/daemon/src/stations/attach-xmtp.ts +6 -7
- package/runtime/node_modules/@metro-labs/daemon/src/stations/supervisor-io.ts +1 -1
- package/runtime/node_modules/@metro-labs/daemon/src/stations/supervisor.ts +2 -2
- package/runtime/node_modules/@metro-labs/daemon/src/terminal/api.ts +2 -2
- package/runtime/node_modules/@metro-labs/daemon/src/terminal/socket.ts +1 -1
- package/runtime/node_modules/@metro-labs/daemon/src/vault/firewall.ts +5 -44
- package/runtime/node_modules/@metro-labs/daemon/src/vault/index.ts +4 -4
- package/runtime/node_modules/@metro-labs/daemon/src/vault/proxy.ts +6 -7
- package/runtime/node_modules/@metro-labs/daemon/src/vault/store.ts +12 -13
- package/runtime/node_modules/@metro-labs/daemon/src/vault/trust.ts +5 -22
- package/runtime/node_modules/@metro-labs/discord-bot/src/accounts.ts +1 -19
- package/runtime/node_modules/@metro-labs/discord-bot/src/actions.ts +7 -35
- package/runtime/node_modules/@metro-labs/discord-bot/src/format.ts +5 -10
- package/runtime/node_modules/@metro-labs/discord-bot/src/group-actions.ts +1 -1
- package/runtime/node_modules/@metro-labs/discord-bot/src/index.ts +1 -2
- package/runtime/node_modules/@metro-labs/discord-bot/src/profile.ts +1 -1
- package/runtime/node_modules/@metro-labs/discord-bot/src/send-files.ts +11 -12
- package/runtime/node_modules/@metro-labs/http/src/api-http.ts +2 -5
- package/runtime/node_modules/@metro-labs/http/src/mode-api.ts +0 -1
- package/runtime/node_modules/@metro-labs/outlook/src/accounts.ts +1 -15
- package/runtime/node_modules/@metro-labs/outlook/src/actions.ts +0 -6
- package/runtime/node_modules/@metro-labs/outlook/src/format.ts +0 -1
- package/runtime/node_modules/@metro-labs/outlook/src/state.ts +1 -1
- package/runtime/node_modules/@metro-labs/telegram/src/accounts.ts +4 -22
- package/runtime/node_modules/@metro-labs/telegram/src/actions.ts +2 -2
- package/runtime/node_modules/@metro-labs/telegram/src/format.ts +1 -3
- package/runtime/node_modules/@metro-labs/telegram/src/inbound.ts +1 -1
- package/runtime/node_modules/@metro-labs/telegram/src/media-actions.ts +1 -2
- package/runtime/node_modules/@metro-labs/telegram/src/reactions.ts +1 -1
- package/runtime/node_modules/@metro-labs/telegram-bot/src/accounts.ts +4 -37
- package/runtime/node_modules/@metro-labs/telegram-bot/src/actions.ts +19 -23
- package/runtime/node_modules/@metro-labs/telegram-bot/src/format.ts +1 -4
- package/runtime/node_modules/@metro-labs/telegram-bot/src/media-actions.ts +4 -9
- package/runtime/node_modules/@metro-labs/telegram-bot/src/profile.ts +3 -3
- package/runtime/node_modules/@metro-labs/telegram-bot/src/station.ts +2 -0
- package/runtime/node_modules/@metro-labs/threema/src/accounts.ts +0 -11
- package/runtime/node_modules/@metro-labs/threema/src/actions.ts +2 -12
- package/runtime/node_modules/@metro-labs/threema/src/files.ts +4 -15
- package/runtime/node_modules/@metro-labs/threema/src/format.ts +2 -7
- package/runtime/node_modules/@metro-labs/whatsapp/package.json +1 -2
- package/runtime/node_modules/@metro-labs/whatsapp/src/accounts.ts +5 -26
- package/runtime/node_modules/@metro-labs/whatsapp/src/actions.ts +14 -6
- package/runtime/node_modules/@metro-labs/whatsapp/src/client.ts +5 -0
- package/runtime/node_modules/@metro-labs/whatsapp/src/format.ts +1 -3
- package/runtime/node_modules/@metro-labs/whatsapp/src/inbound.ts +1 -1
- package/runtime/node_modules/@metro-labs/whatsapp/src/station.ts +2 -0
- package/runtime/node_modules/@metro-labs/xmtp/src/accounts.ts +1 -12
- package/runtime/node_modules/@metro-labs/xmtp/src/actions-close.ts +1 -1
- package/runtime/node_modules/@metro-labs/xmtp/src/actions-conv.ts +14 -5
- package/runtime/node_modules/@metro-labs/xmtp/src/actions-meta.ts +1 -1
- package/runtime/node_modules/@metro-labs/xmtp/src/actions.ts +31 -78
- package/runtime/node_modules/@metro-labs/xmtp/src/emit.ts +3 -4
- package/runtime/node_modules/@metro-labs/xmtp/src/index.ts +1 -6
- package/runtime/node_modules/@metro-labs/xmtp/src/profile.ts +1 -1
- package/runtime/node_modules/@metro-labs/xmtp/src/sender.ts +1 -1
- package/runtime/node_modules/@metro-labs/xmtp/src/tools-handlers.ts +1 -1
- package/runtime/node_modules/@metro-labs/xmtp/src/transcribe.ts +2 -2
- package/runtime/node_modules/@metro-labs/xmtp/src/wire.ts +0 -2
- package/runtime/runtime.json +1 -1
- package/runtime/node_modules/@metro-labs/core/src/identity.ts +0 -7
- package/runtime/node_modules/@metro-labs/daemon/src/approvals/needs.ts +0 -2
- package/runtime/node_modules/@metro-labs/daemon/src/channels/dedupe.ts +0 -12
- package/runtime/node_modules/@metro-labs/daemon/src/channels/react-note.ts +0 -13
- package/runtime/node_modules/@metro-labs/daemon/src/channels/reply-id.ts +0 -1
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/relay-target.ts +0 -29
- package/runtime/node_modules/@metro-labs/daemon/src/metro-user/api.ts +0 -43
- package/runtime/node_modules/@metro-labs/daemon/src/metro-user/move.ts +0 -165
- package/runtime/node_modules/@metro-labs/discord-bot/src/wire.ts +0 -1
- package/runtime/node_modules/@metro-labs/telegram/src/normalize.ts +0 -22
- package/runtime/node_modules/@metro-labs/telegram/src/wire.ts +0 -1
- package/runtime/node_modules/@metro-labs/telegram-bot/src/wire.ts +0 -1
- package/runtime/node_modules/@metro-labs/whatsapp/src/normalize.ts +0 -22
- package/runtime/node_modules/@metro-labs/whatsapp/src/wire.ts +0 -1
package/dist/claude.js
CHANGED
|
@@ -21,11 +21,6 @@ export const claudeArgs = (extra, mcpConfig, mode = 'auto', prompt = null) => [
|
|
|
21
21
|
...extra,
|
|
22
22
|
];
|
|
23
23
|
const set = (env, name) => (env[name] ?? '').trim() !== '';
|
|
24
|
-
export function bypassSandboxEnv(env, mode, uid) {
|
|
25
|
-
if (mode !== 'bypass' || uid !== 0 || env.IS_SANDBOX !== undefined)
|
|
26
|
-
return env;
|
|
27
|
-
return { ...env, IS_SANDBOX: '1' };
|
|
28
|
-
}
|
|
29
24
|
export function pinnedBy(env) {
|
|
30
25
|
if (set(env, 'ANTHROPIC_BASE_URL'))
|
|
31
26
|
return 'ANTHROPIC_BASE_URL';
|
|
@@ -49,7 +44,7 @@ export function credentialEnv(env, agentKey, signedIn) {
|
|
|
49
44
|
return env;
|
|
50
45
|
return { ...env, ANTHROPIC_AUTH_TOKEN: agentKey };
|
|
51
46
|
}
|
|
52
|
-
|
|
47
|
+
function claudeSignedIn() {
|
|
53
48
|
const run = spawnSync('claude', ['auth', 'status', '--json'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
|
|
54
49
|
if (run.error !== undefined || typeof run.stdout !== 'string')
|
|
55
50
|
return false;
|
|
@@ -93,7 +88,7 @@ async function verdict() {
|
|
|
93
88
|
return picked;
|
|
94
89
|
return { skip: 'the daemon is not serving here (stopped, or not running), so Claude Code talks to Anthropic directly' };
|
|
95
90
|
}
|
|
96
|
-
|
|
91
|
+
function runClaude(args, env) {
|
|
97
92
|
return new Promise((resolve, reject) => {
|
|
98
93
|
const leaveToChild = () => undefined;
|
|
99
94
|
process.on('SIGINT', leaveToChild);
|
|
@@ -144,18 +139,12 @@ export async function launchClaude(extra) {
|
|
|
144
139
|
const prompt = systemPrompt();
|
|
145
140
|
if (prompt !== null)
|
|
146
141
|
process.stderr.write('metro claude: the system prompt from the Harness page is appended to this session\n');
|
|
147
|
-
const sandbox = (env) => {
|
|
148
|
-
const out = bypassSandboxEnv(env, mode, process.getuid?.());
|
|
149
|
-
if (out !== env)
|
|
150
|
-
process.stderr.write('metro claude: bypass permission mode under root, so IS_SANDBOX=1 is set for Claude Code (it refuses to skip permissions as root otherwise); this box is a dedicated single-tenant machine\n');
|
|
151
|
-
return out;
|
|
152
|
-
};
|
|
153
142
|
try {
|
|
154
143
|
if ('skip' in decision) {
|
|
155
144
|
process.stderr.write(`metro claude: ${decision.skip}\n`);
|
|
156
|
-
return await runClaude(claudeArgs(extra, mcp?.path, mode, prompt),
|
|
145
|
+
return await runClaude(claudeArgs(extra, mcp?.path, mode, prompt), process.env);
|
|
157
146
|
}
|
|
158
|
-
return await runClaude(claudeArgs(extra, mcp?.path, mode, prompt),
|
|
147
|
+
return await runClaude(claudeArgs(extra, mcp?.path, mode, prompt), gatewayLaunchEnv(decision.key, port));
|
|
159
148
|
}
|
|
160
149
|
finally {
|
|
161
150
|
mcp?.cleanup();
|
package/dist/cli.js
CHANGED
|
@@ -7,7 +7,7 @@ import { update } from './update.js';
|
|
|
7
7
|
import { serve } from './serve.js';
|
|
8
8
|
import { service, serviceStopHint } from './service.js';
|
|
9
9
|
import { currentVersion } from './version.js';
|
|
10
|
-
const USAGE = `metro
|
|
10
|
+
const USAGE = `metro: run your agent on this machine
|
|
11
11
|
|
|
12
12
|
metro serve [--port <n>] [--owner <organization id>]
|
|
13
13
|
run the daemon: the agent, its channels and its connectors live in
|
|
@@ -17,10 +17,12 @@ const USAGE = `metro — run your agent on this machine
|
|
|
17
17
|
link it prints; --owner names the organization whose members may sign
|
|
18
18
|
in, needed on the first start only; Stop on the page parks the daemon and keeps the
|
|
19
19
|
address, so Start works from the page too
|
|
20
|
-
metro service install [--port <n>] [--owner <organization id>]
|
|
20
|
+
metro service install [--port <n>] [--owner <organization id>] [--user metro]
|
|
21
21
|
run metro serve as a service (systemd on Linux, launchd on macOS): it
|
|
22
22
|
starts at boot and after a crash, so the page can stop, start and
|
|
23
|
-
restart it with no shell;
|
|
23
|
+
restart it with no shell; on Linux, root passes --user metro so Metro
|
|
24
|
+
runs as its own user and Claude Code as the user agent;
|
|
25
|
+
metro service uninstall and status as well
|
|
24
26
|
metro stop stop metro on this machine, metro serve included
|
|
25
27
|
metro tail [agent-id]
|
|
26
28
|
follow this machine's inbound events, one JSON line each
|
package/dist/hold.js
CHANGED
|
@@ -26,7 +26,7 @@ function sendJson(req, res, status, body) {
|
|
|
26
26
|
});
|
|
27
27
|
res.end(JSON.stringify(body));
|
|
28
28
|
}
|
|
29
|
-
|
|
29
|
+
function holdRequest(req, res, info, onStart) {
|
|
30
30
|
const path = (req.url ?? '').split('?')[0] ?? '';
|
|
31
31
|
if (req.method === 'OPTIONS') {
|
|
32
32
|
res.writeHead(204, corsHeaders(req)).end();
|
|
@@ -36,7 +36,6 @@ export function holdRequest(req, res, info, onStart) {
|
|
|
36
36
|
sendJson(req, res, 200, {
|
|
37
37
|
mode: 'local',
|
|
38
38
|
owner: info.owner,
|
|
39
|
-
project: 'localdaemon',
|
|
40
39
|
version: info.version,
|
|
41
40
|
stopped: true,
|
|
42
41
|
});
|
package/dist/runtime-install.js
CHANGED
|
@@ -4,8 +4,8 @@ import { homedir } from 'node:os';
|
|
|
4
4
|
import { join } from 'node:path';
|
|
5
5
|
import { localStations } from './local.js';
|
|
6
6
|
import { STORE_ENTRY, daemonEntry, findBun, runtimeDir } from './runtime.js';
|
|
7
|
-
|
|
8
|
-
|
|
7
|
+
const MANIFEST_FILE = 'stations.json';
|
|
8
|
+
const MARKETPLACE = 'marketplace';
|
|
9
9
|
const METRO_SOURCES = join('node_modules', '@metro-labs');
|
|
10
10
|
const INSTALL_TIMEOUT_MS = 15 * 60_000;
|
|
11
11
|
export function runtimeStore() {
|
|
@@ -83,8 +83,7 @@ export function installDependencies(store, deps, bun, log) {
|
|
|
83
83
|
export function prepareRuntime(opts = {}) {
|
|
84
84
|
const sources = opts.sources ?? runtimeDir();
|
|
85
85
|
const manifestPath = join(sources, MANIFEST_FILE);
|
|
86
|
-
|
|
87
|
-
return { dir: sources, entry: daemonEntry(sources), trains: join(sources, 'trains'), manifest: null };
|
|
86
|
+
const manifest = readManifest(manifestPath);
|
|
88
87
|
const store = opts.store ?? runtimeStore();
|
|
89
88
|
const log = opts.log ??
|
|
90
89
|
((line) => {
|
|
@@ -92,6 +91,6 @@ export function prepareRuntime(opts = {}) {
|
|
|
92
91
|
});
|
|
93
92
|
mkdirSync(join(store, 'trains'), { recursive: true });
|
|
94
93
|
syncSources(sources, store);
|
|
95
|
-
installDependencies(store, dependenciesFor(
|
|
94
|
+
installDependencies(store, dependenciesFor(manifest, localStations(opts.agents)), opts.bun ?? findBun(), log);
|
|
96
95
|
return { dir: store, entry: daemonEntry(store), trains: join(store, 'trains'), manifest: manifestPath };
|
|
97
96
|
}
|
package/dist/runtime.js
CHANGED
|
@@ -4,10 +4,7 @@ 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
6
|
export const STORE_ENTRY = 'server.ts';
|
|
7
|
-
export const
|
|
8
|
-
export const daemonEntry = (dir) => existsSync(join(dir, STORE_ENTRY))
|
|
9
|
-
? join(dir, STORE_ENTRY)
|
|
10
|
-
: join(dir, PACKAGE_ENTRY);
|
|
7
|
+
export const daemonEntry = (dir) => join(dir, STORE_ENTRY);
|
|
11
8
|
export class MissingRuntime extends Error {
|
|
12
9
|
}
|
|
13
10
|
export function runtimeDir() {
|
package/dist/serve.js
CHANGED
|
@@ -72,7 +72,7 @@ export function requireOwner(args, dir = agentsDir()) {
|
|
|
72
72
|
join(dir, '.owner') +
|
|
73
73
|
':\n metro serve --owner <org_…>');
|
|
74
74
|
}
|
|
75
|
-
|
|
75
|
+
function readOwner(dir = agentsDir()) {
|
|
76
76
|
try {
|
|
77
77
|
const raw = readFileSync(join(dir, '.owner'), 'utf8').trim();
|
|
78
78
|
return ORGANIZATION.test(raw) ? raw : null;
|
|
@@ -81,7 +81,7 @@ export function readOwner(dir = agentsDir()) {
|
|
|
81
81
|
return null;
|
|
82
82
|
}
|
|
83
83
|
}
|
|
84
|
-
|
|
84
|
+
function holdInfo(port, owner, tailscaleBin) {
|
|
85
85
|
return {
|
|
86
86
|
port,
|
|
87
87
|
host: process.env.METRO_HTTP_HOST ?? '127.0.0.1',
|
|
@@ -133,9 +133,8 @@ export function servePlan(opts) {
|
|
|
133
133
|
METRO_WEBHOOK_PORT: String(opts.port),
|
|
134
134
|
METRO_HTTP_HOST: process.env.METRO_HTTP_HOST ?? '127.0.0.1',
|
|
135
135
|
METRO_TRAINS_DIR: opts.runtime.trains,
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
: { METRO_RUNTIME_STORE: opts.runtime.dir, METRO_RUNTIME_MANIFEST: opts.runtime.manifest }),
|
|
136
|
+
METRO_RUNTIME_STORE: opts.runtime.dir,
|
|
137
|
+
METRO_RUNTIME_MANIFEST: opts.runtime.manifest,
|
|
139
138
|
METRO_STATE_DIR: process.env.METRO_STATE_DIR ?? serveStateDir(),
|
|
140
139
|
METRO_TUNNEL: 'tailscale',
|
|
141
140
|
METRO_TAILSCALE_BIN: opts.tailscaleBin,
|
package/dist/service.js
CHANGED
|
@@ -3,12 +3,15 @@ import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
|
|
|
3
3
|
import { homedir, platform, userInfo } from 'node:os';
|
|
4
4
|
import { dirname, join, resolve } from 'node:path';
|
|
5
5
|
import { serveLockedBy } from './control.js';
|
|
6
|
-
import { findBun } from './runtime.js';
|
|
6
|
+
import { findBun, runtimeDir } from './runtime.js';
|
|
7
7
|
import { findTailscale, parseServeArgs, requireOwner } from './serve.js';
|
|
8
|
-
const USAGE = 'usage: metro service install [--port <n>] [--owner <organization id>] | uninstall | status';
|
|
8
|
+
const USAGE = 'usage: metro service install [--port <n>] [--owner <organization id>] [--user metro] | uninstall | status';
|
|
9
9
|
const SERVICE = 'metro';
|
|
10
10
|
const LABEL = 'box.metro.serve';
|
|
11
11
|
const CARRIED = /^(METRO_.*|XDG_CACHE_HOME|PATH|HOME)$/;
|
|
12
|
+
const OWN_PATHS = new Set(['METRO_AGENTS_DIR', 'METRO_RUNTIME_STORE']);
|
|
13
|
+
const METRO_USER = 'metro';
|
|
14
|
+
const USER_FLAG = /^--user=(.*)$/;
|
|
12
15
|
const PLAIN_WORD = /^[A-Za-z0-9_/.:=+@,-]+$/;
|
|
13
16
|
const carriedEnv = (env) => Object.entries(env)
|
|
14
17
|
.filter((entry) => CARRIED.test(entry[0]) && typeof entry[1] === 'string' && entry[1] !== '')
|
|
@@ -23,8 +26,7 @@ function unitWord(arg) {
|
|
|
23
26
|
return escaped;
|
|
24
27
|
return `"${escaped.replaceAll('\\', '\\\\').replaceAll('"', '\\"')}"`;
|
|
25
28
|
}
|
|
26
|
-
|
|
27
|
-
const env = carriedEnv(host.env).map(([key, value]) => `Environment=${unitWord(`${key}=${value}`)}`);
|
|
29
|
+
function unitText(service, target) {
|
|
28
30
|
return [
|
|
29
31
|
'[Unit]',
|
|
30
32
|
'Description=Metro daemon (metro serve)',
|
|
@@ -32,25 +34,44 @@ export function systemdUnit(host, exec, target) {
|
|
|
32
34
|
'Wants=network-online.target',
|
|
33
35
|
'',
|
|
34
36
|
'[Service]',
|
|
35
|
-
|
|
36
|
-
'Restart=always',
|
|
37
|
-
'RestartSec=2',
|
|
38
|
-
'OOMPolicy=continue',
|
|
39
|
-
`WorkingDirectory=${unitWord(host.home)}`,
|
|
40
|
-
...env,
|
|
37
|
+
...service,
|
|
41
38
|
'',
|
|
42
39
|
'[Install]',
|
|
43
40
|
`WantedBy=${target}`,
|
|
44
41
|
'',
|
|
45
42
|
].join('\n');
|
|
46
43
|
}
|
|
44
|
+
const RESTART = ['Restart=always', 'RestartSec=2', 'OOMPolicy=continue'];
|
|
45
|
+
function systemdUnit(host, exec, target) {
|
|
46
|
+
const env = carriedEnv(host.env).map(([key, value]) => `Environment=${unitWord(`${key}=${value}`)}`);
|
|
47
|
+
return unitText([`ExecStart=${exec.map(unitWord).join(' ')}`, ...RESTART, `WorkingDirectory=${unitWord(host.home)}`, ...env], target);
|
|
48
|
+
}
|
|
49
|
+
const npmPrefix = (home) => join(home, '.npm-global');
|
|
50
|
+
function runAsUnit(host, runAs, serveArgs) {
|
|
51
|
+
const prefix = npmPrefix(runAs.home);
|
|
52
|
+
const exec = [host.node, join(prefix, 'lib', 'node_modules', '@stage-labs', 'metro', 'dist', 'cli.js'), 'serve', ...serveArgs];
|
|
53
|
+
const env = carriedEnv(host.env)
|
|
54
|
+
.filter(([key]) => key.startsWith('METRO_') && !OWN_PATHS.has(key))
|
|
55
|
+
.map(([key, value]) => `Environment=${unitWord(`${key}=${value}`)}`);
|
|
56
|
+
return unitText([
|
|
57
|
+
`User=${runAs.name}`,
|
|
58
|
+
`Group=${runAs.name}`,
|
|
59
|
+
`ExecStart=${exec.map(unitWord).join(' ')}`,
|
|
60
|
+
...RESTART,
|
|
61
|
+
'WorkingDirectory=/',
|
|
62
|
+
`Environment=HOME=${unitWord(runAs.home)}`,
|
|
63
|
+
`Environment=${unitWord(`PATH=${prefix}/bin:/usr/local/bin:/usr/bin:/bin`)}`,
|
|
64
|
+
`Environment=${unitWord(`NPM_CONFIG_PREFIX=${prefix}`)}`,
|
|
65
|
+
...env,
|
|
66
|
+
], 'multi-user.target');
|
|
67
|
+
}
|
|
47
68
|
const xml = (text) => text
|
|
48
69
|
.replaceAll('&', '&')
|
|
49
70
|
.replaceAll('<', '<')
|
|
50
71
|
.replaceAll('>', '>')
|
|
51
72
|
.replaceAll('"', '"')
|
|
52
73
|
.replaceAll("'", ''');
|
|
53
|
-
|
|
74
|
+
function launchdPlist(host, exec, logFile) {
|
|
54
75
|
const env = carriedEnv(host.env).map(([key, value]) => ` <key>${xml(key)}</key><string>${xml(value)}</string>`);
|
|
55
76
|
return [
|
|
56
77
|
'<?xml version="1.0" encoding="UTF-8"?>',
|
|
@@ -76,7 +97,9 @@ export function launchdPlist(host, exec, logFile) {
|
|
|
76
97
|
'',
|
|
77
98
|
].join('\n');
|
|
78
99
|
}
|
|
79
|
-
|
|
100
|
+
const ROOT_HINT = `Metro running as root never starts Claude Code; install it as its own user: metro service install --user ${METRO_USER}`;
|
|
101
|
+
function systemdPlan(host, serveArgs, runAs) {
|
|
102
|
+
const exec = [host.node, host.cli, 'serve', ...serveArgs];
|
|
80
103
|
const system = host.uid === 0;
|
|
81
104
|
const ctl = system ? ['systemctl'] : ['systemctl', '--user'];
|
|
82
105
|
const file = system
|
|
@@ -85,14 +108,15 @@ function systemdPlan(host, exec) {
|
|
|
85
108
|
return {
|
|
86
109
|
kind: 'systemd',
|
|
87
110
|
file,
|
|
88
|
-
content: systemdUnit(host, exec, system ? 'multi-user.target' : 'default.target'),
|
|
111
|
+
content: runAs === undefined ? systemdUnit(host, exec, system ? 'multi-user.target' : 'default.target') : runAsUnit(host, runAs, serveArgs),
|
|
89
112
|
dirs: [],
|
|
90
|
-
install: [{ args: [...ctl, 'daemon-reload'] }, { args: [...ctl, 'enable', '--now', SERVICE] }],
|
|
113
|
+
install: [...(runAs === undefined ? [] : [{ args: ['bun', runAs.helper] }]), { args: [...ctl, 'daemon-reload'] }, { args: [...ctl, 'enable', '--now', SERVICE] }],
|
|
91
114
|
uninstall: [{ args: [...ctl, 'disable', '--now', SERVICE], mayFail: true }],
|
|
92
115
|
reload: [{ args: [...ctl, 'daemon-reload'], mayFail: true }],
|
|
93
116
|
status: { args: [...ctl, 'is-active', SERVICE], mayFail: true },
|
|
94
117
|
hints: [
|
|
95
118
|
`Logs: journalctl ${system ? '' : '--user '}-u ${SERVICE} -f`,
|
|
119
|
+
...(system && runAs === undefined ? [ROOT_HINT] : []),
|
|
96
120
|
...(system
|
|
97
121
|
? []
|
|
98
122
|
: [`A user service stops with your login session; keep it up without one: loginctl enable-linger ${host.user}`]),
|
|
@@ -118,22 +142,52 @@ function launchdPlan(host, exec) {
|
|
|
118
142
|
hints: [`Logs: tail -f ${logFile}`],
|
|
119
143
|
};
|
|
120
144
|
}
|
|
121
|
-
export function servicePlan(host, serveArgs) {
|
|
145
|
+
export function servicePlan(host, serveArgs, runAs) {
|
|
122
146
|
const exec = [host.node, host.cli, 'serve', ...serveArgs];
|
|
147
|
+
if (runAs !== undefined && (host.platform !== 'linux' || host.uid !== 0))
|
|
148
|
+
throw new Error('--user installs a system service for another user: run it as root on Linux');
|
|
123
149
|
if (host.platform === 'linux')
|
|
124
|
-
return systemdPlan(host,
|
|
150
|
+
return systemdPlan(host, serveArgs, runAs);
|
|
125
151
|
if (host.platform === 'darwin')
|
|
126
152
|
return launchdPlan(host, exec);
|
|
127
153
|
throw new Error(`metro service supports Linux (systemd) and macOS (launchd), not ${host.platform}`);
|
|
128
154
|
}
|
|
129
|
-
function
|
|
155
|
+
function splitUser(argv) {
|
|
156
|
+
const rest = [];
|
|
157
|
+
let user = null;
|
|
158
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
159
|
+
const arg = argv[i] ?? '';
|
|
160
|
+
const inline = USER_FLAG.exec(arg);
|
|
161
|
+
if (inline === null && arg !== '--user') {
|
|
162
|
+
rest.push(arg);
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
user = inline?.[1] ?? argv[i + 1] ?? '';
|
|
166
|
+
if (inline === null)
|
|
167
|
+
i += 1;
|
|
168
|
+
}
|
|
169
|
+
return { user, rest };
|
|
170
|
+
}
|
|
171
|
+
function runAsOf(name, deps) {
|
|
172
|
+
if (name === null)
|
|
173
|
+
return undefined;
|
|
174
|
+
if (name !== METRO_USER)
|
|
175
|
+
throw new Error(`the root helper and its sudo rules are written for the user ${METRO_USER}; pass --user ${METRO_USER}, not '${name}'`);
|
|
176
|
+
const account = deps.account(name);
|
|
177
|
+
if (account === null)
|
|
178
|
+
throw new Error(`there is no user ${name} on this machine; create it first (useradd --system --create-home --home-dir /var/lib/${name} ${name})`);
|
|
179
|
+
return { ...account, helper: deps.helperEntry() };
|
|
180
|
+
}
|
|
181
|
+
function install(argv, deps) {
|
|
182
|
+
const { user, rest: serveArgs } = splitUser(argv);
|
|
130
183
|
const args = parseServeArgs(serveArgs);
|
|
131
|
-
const
|
|
184
|
+
const runAs = runAsOf(user, deps);
|
|
185
|
+
const plan = servicePlan(deps.host, serveArgs, runAs);
|
|
132
186
|
if (deps.exists(plan.file)) {
|
|
133
187
|
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`);
|
|
134
188
|
return 0;
|
|
135
189
|
}
|
|
136
|
-
deps.preflight(args);
|
|
190
|
+
deps.preflight(args, runAs === undefined ? undefined : join(runAs.home, '.metro', 'agents'));
|
|
137
191
|
const pid = deps.running();
|
|
138
192
|
if (pid !== null)
|
|
139
193
|
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`);
|
|
@@ -196,6 +250,11 @@ export function serviceStopHint(host = realHost(), exists = existsSync) {
|
|
|
196
250
|
: `launchctl bootout gui/${String(host.uid)}/${LABEL}`;
|
|
197
251
|
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}`;
|
|
198
252
|
}
|
|
253
|
+
function lookupAccount(name) {
|
|
254
|
+
const run = spawnSync('getent', ['passwd', name], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
|
|
255
|
+
const home = run.status === 0 ? (run.stdout.trim().split(':')[5] ?? '') : '';
|
|
256
|
+
return home.startsWith('/') ? { name, home } : null;
|
|
257
|
+
}
|
|
199
258
|
function realDeps() {
|
|
200
259
|
return {
|
|
201
260
|
host: realHost(),
|
|
@@ -210,8 +269,10 @@ function realDeps() {
|
|
|
210
269
|
return { status: code, output: `${result.stdout}${result.stderr}` };
|
|
211
270
|
},
|
|
212
271
|
running: serveLockedBy,
|
|
213
|
-
|
|
214
|
-
|
|
272
|
+
account: lookupAccount,
|
|
273
|
+
helperEntry: () => join(runtimeDir(), 'node_modules', '@metro-labs', 'daemon', 'src', 'metro-user', 'install.ts'),
|
|
274
|
+
preflight: (args, agents) => {
|
|
275
|
+
requireOwner(args, agents);
|
|
215
276
|
findBun();
|
|
216
277
|
findTailscale();
|
|
217
278
|
},
|
package/dist/update.js
CHANGED
|
@@ -14,7 +14,7 @@ export const checkReport = (current, latest) => ({
|
|
|
14
14
|
latest,
|
|
15
15
|
newer: latest !== '' && isNewer(latest, current),
|
|
16
16
|
});
|
|
17
|
-
|
|
17
|
+
async function updateCheck() {
|
|
18
18
|
return checkReport(currentVersion(), await publishedVersion());
|
|
19
19
|
}
|
|
20
20
|
export async function update(argv = []) {
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stage-labs/metro",
|
|
3
|
-
"version": "0.1.0-beta.
|
|
4
|
-
"description": "The metro command line
|
|
3
|
+
"version": "0.1.0-beta.203",
|
|
4
|
+
"description": "The metro command line: run your agent on this machine, relaying its chat channels and connectors to Claude Code.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
7
7
|
"type": "git",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "metro",
|
|
3
|
-
"version": "0.1.0-beta.
|
|
3
|
+
"version": "0.1.0-beta.203",
|
|
4
4
|
"description": "Metro for Claude Code, installed by the metro daemon on this machine: every connector the agent holds as its own MCP server relayed through the daemon, the orchestrator-only guard on the main thread, and the standing rules loaded at each session start. No vendor credential ever sits in a config file.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Bonustrack",
|
|
@@ -5,7 +5,7 @@ description: How this box's agent works on metro. Acknowledge, delegate, relay,
|
|
|
5
5
|
|
|
6
6
|
You are the main thread of a Claude Code session that is connected to chat through metro, on a machine nobody watches. The main thread is an orchestrator: it may only delegate and talk. A guard in the metro plugin denies every other tool on this thread, so this is the shape of the work, not a preference.
|
|
7
7
|
|
|
8
|
-
**Inbound.** A message arrives as a `<channel source="metro" …>` block. Read `addressed` first: `direct`, `mention` or `reply` means it is for you; without it the message merely happened in a room you watch, so treat it as context and stay silent unless the person plainly wants you. For a message that is for you, the first tool call is `react` on it (👀, then 👍 or ✅ once done) so the person knows you saw it, and any reply goes on the same `line`, passed verbatim.
|
|
8
|
+
**Inbound.** A message arrives as a `<channel source="metro" …>` block. Read `addressed` first: `direct`, `mention` or `reply` means it is for you; without it the message merely happened in a room you watch, so treat it as context and stay silent unless the person plainly wants you. For a message that is for you, the first tool call is `react` on it (👀, then 👍 or ✅ once done) so the person knows you saw it, and any reply goes on the same `line`, passed verbatim. When the answer will take more than a few seconds, call `typing` on that `line` right after the 👀: metro keeps "typing…" showing until you send or reply there (or 2 minutes pass). Only WhatsApp and the Telegram bot support it; elsewhere skip it.
|
|
9
9
|
|
|
10
10
|
**Delegate everything that is work.** Reading files, running commands, searching, fetching pages, editing, and every connector tool: hand it to a subagent with the `Agent` tool, `subagent_type: "worker"`, always `run_in_background: true`, then keep answering chat while it runs and relay the result when the task notification arrives. A foreground subagent blinds you to chat and the guard refuses it. You may read image files yourself, so a screenshot someone sends is yours to look at.
|
|
11
11
|
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { Line } from './lines.js';
|
|
2
|
+
import { errMsg, log } from './log.js';
|
|
2
3
|
|
|
3
|
-
export
|
|
4
|
+
export const userSelf = (): Line =>
|
|
5
|
+
(process.env.METRO_FROM ?? 'metro://user') as Line;
|
|
4
6
|
|
|
5
7
|
export type StructuredEvent =
|
|
6
8
|
| { type: 'msg' }
|
|
@@ -98,7 +100,7 @@ export function publishEvent(event: MetroEvent): void {
|
|
|
98
100
|
try {
|
|
99
101
|
fn(event, seq);
|
|
100
102
|
} catch (err) {
|
|
101
|
-
|
|
103
|
+
log.error({ busSeq: seq, err: errMsg(err) }, 'bus: a listener threw');
|
|
102
104
|
}
|
|
103
105
|
}
|
|
104
106
|
}
|
|
@@ -11,15 +11,9 @@ function parseAccountScoped(
|
|
|
11
11
|
validate?: (resource: string) => boolean,
|
|
12
12
|
): { accountId: string; resource: string } | null {
|
|
13
13
|
const p = Line.parse(line);
|
|
14
|
-
if (p?.station !== station) return null;
|
|
15
|
-
|
|
16
|
-
if (
|
|
17
|
-
accountId = p.path[0];
|
|
18
|
-
resource = p.path[1];
|
|
19
|
-
} else if (p.path.length === 1 && p.path[0] !== undefined) {
|
|
20
|
-
accountId = 'default';
|
|
21
|
-
resource = p.path[0];
|
|
22
|
-
} else return null;
|
|
14
|
+
if (p?.station !== station || p.path.length !== 2) return null;
|
|
15
|
+
const [accountId, resource] = p.path;
|
|
16
|
+
if (accountId === undefined || resource === undefined) return null;
|
|
23
17
|
if (validate && !validate(resource)) return null;
|
|
24
18
|
return { accountId, resource };
|
|
25
19
|
}
|
|
@@ -30,28 +24,21 @@ const isThreemaResource = (s: string): boolean =>
|
|
|
30
24
|
const isOutlookResource = (s: string): boolean => /^[A-Za-z0-9=_%.@+-]+$/.test(s);
|
|
31
25
|
const isSignedInt = (s: string): boolean => /^-?\d+$/.test(s);
|
|
32
26
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
return { accountId: 'default', rest: path };
|
|
38
|
-
}
|
|
27
|
+
const topicPart = (topicId: string | undefined): { topicId?: number } | null => {
|
|
28
|
+
if (topicId === undefined) return {};
|
|
29
|
+
return isSnowflake(topicId) ? { topicId: Number(topicId) } : null;
|
|
30
|
+
};
|
|
39
31
|
|
|
40
32
|
function parseTelegramLine(
|
|
41
33
|
line: Line | string,
|
|
42
34
|
): { accountId: string; chatId: number; topicId?: number } | null {
|
|
43
35
|
const p = Line.parse(line);
|
|
44
36
|
if (p?.station !== 'telegram-bot') return null;
|
|
45
|
-
const
|
|
46
|
-
|
|
47
|
-
if (
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
return {
|
|
51
|
-
accountId,
|
|
52
|
-
chatId: Number(chatId),
|
|
53
|
-
...(topicId !== undefined ? { topicId: Number(topicId) } : {}),
|
|
54
|
-
};
|
|
37
|
+
const [accountId, chatId, topicId, ...extra] = p.path;
|
|
38
|
+
if (accountId === undefined || chatId === undefined || extra.length > 0) return null;
|
|
39
|
+
if (!isSignedInt(chatId)) return null;
|
|
40
|
+
const topic = topicPart(topicId);
|
|
41
|
+
return topic === null ? null : { accountId, chatId: Number(chatId), ...topic };
|
|
55
42
|
}
|
|
56
43
|
|
|
57
44
|
export const Line = {
|
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
2
4
|
import { errMsg } from '../log.js';
|
|
3
5
|
import { chmodIfExists } from '../secure-fs.js';
|
|
4
6
|
|
|
5
7
|
export type Die = (msg: string) => never;
|
|
6
8
|
|
|
7
|
-
export interface
|
|
9
|
+
export interface AccountStoreOpts<T> {
|
|
8
10
|
prefix: string;
|
|
9
|
-
|
|
10
|
-
validate: (raw: T[], die: Die) => void;
|
|
11
|
+
validate?: (raw: T[], die: Die) => void;
|
|
11
12
|
}
|
|
12
13
|
|
|
13
14
|
export interface Loader<T> {
|
|
@@ -15,8 +16,40 @@ export interface Loader<T> {
|
|
|
15
16
|
loadAccounts: () => T[];
|
|
16
17
|
}
|
|
17
18
|
|
|
19
|
+
const envName = (prefix: string): string =>
|
|
20
|
+
`${prefix.toUpperCase().replace(/-/g, '_')}_ACCOUNTS_FILE`;
|
|
21
|
+
|
|
22
|
+
const accountsFile = (prefix: string): string =>
|
|
23
|
+
process.env[envName(prefix)] ?? join(homedir(), '.metro', `${prefix}-accounts.json`);
|
|
24
|
+
|
|
25
|
+
function checkIds(raw: { id: string }[], die: Die): void {
|
|
26
|
+
const seen = new Set<string>();
|
|
27
|
+
for (const a of raw) {
|
|
28
|
+
if (!a.id) die('account missing id');
|
|
29
|
+
if (seen.has(a.id)) die(`duplicate account id '${a.id}'`);
|
|
30
|
+
seen.add(a.id);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function readList<T>(file: string, die: Die): T[] {
|
|
35
|
+
if (!existsSync(file))
|
|
36
|
+
return die(
|
|
37
|
+
`no accounts file ${file} — the daemon writes it from ~/.metro/agents/agent.json at boot; attach an account to this station from the page first`,
|
|
38
|
+
);
|
|
39
|
+
chmodIfExists(file);
|
|
40
|
+
let raw: unknown;
|
|
41
|
+
try {
|
|
42
|
+
raw = JSON.parse(readFileSync(file, 'utf8'));
|
|
43
|
+
} catch (e) {
|
|
44
|
+
return die(`bad ${file}: ${errMsg(e)}`);
|
|
45
|
+
}
|
|
46
|
+
if (!Array.isArray(raw) || raw.length === 0)
|
|
47
|
+
return die(`${file} must be a non-empty array`);
|
|
48
|
+
return raw as T[];
|
|
49
|
+
}
|
|
50
|
+
|
|
18
51
|
export function makeAccountStore<T extends { id: string }>(
|
|
19
|
-
opts:
|
|
52
|
+
opts: AccountStoreOpts<T>,
|
|
20
53
|
): Loader<T> {
|
|
21
54
|
const die: Die = (msg) => {
|
|
22
55
|
process.stderr.write(`${opts.prefix}: ${msg}\n`);
|
|
@@ -24,20 +57,9 @@ export function makeAccountStore<T extends { id: string }>(
|
|
|
24
57
|
};
|
|
25
58
|
|
|
26
59
|
function loadAccounts(): T[] {
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
);
|
|
31
|
-
chmodIfExists(opts.file);
|
|
32
|
-
let raw: T[];
|
|
33
|
-
try {
|
|
34
|
-
raw = JSON.parse(readFileSync(opts.file, 'utf8')) as T[];
|
|
35
|
-
} catch (e) {
|
|
36
|
-
return die(`bad ${opts.file}: ${errMsg(e)}`);
|
|
37
|
-
}
|
|
38
|
-
if (!Array.isArray(raw) || raw.length === 0)
|
|
39
|
-
die(`${opts.file} must be a non-empty array`);
|
|
40
|
-
opts.validate(raw, die);
|
|
60
|
+
const raw = readList<T>(accountsFile(opts.prefix), die);
|
|
61
|
+
checkIds(raw, die);
|
|
62
|
+
opts.validate?.(raw, die);
|
|
41
63
|
return raw;
|
|
42
64
|
}
|
|
43
65
|
|
|
@@ -49,12 +71,13 @@ export function resolveAccountId(
|
|
|
49
71
|
args: { account?: string; line?: string },
|
|
50
72
|
parseAccountId: (line: string) => string | undefined,
|
|
51
73
|
): string {
|
|
74
|
+
const have = [...accounts.keys()];
|
|
52
75
|
let id = args.account;
|
|
53
76
|
id ??= args.line ? parseAccountId(args.line) : undefined;
|
|
54
|
-
id ??=
|
|
77
|
+
id ??= have.length === 1 ? have[0] : undefined;
|
|
78
|
+
if (id === undefined)
|
|
79
|
+
throw new Error(`name an account (have: ${have.join(', ')})`);
|
|
55
80
|
if (!accounts.has(id))
|
|
56
|
-
throw new Error(
|
|
57
|
-
`unknown account '${id}' (have: ${[...accounts.keys()].join(', ')})`,
|
|
58
|
-
);
|
|
81
|
+
throw new Error(`unknown account '${id}' (have: ${have.join(', ')})`);
|
|
59
82
|
return id;
|
|
60
83
|
}
|