@stage-labs/metro 0.1.0-beta.122 → 0.1.0-beta.124

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stage-labs/metro",
3
- "version": "0.1.0-beta.122",
3
+ "version": "0.1.0-beta.124",
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": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "metro",
3
- "version": "0.1.0-beta.122",
3
+ "version": "0.1.0-beta.124",
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, the standing rules loaded at each session start, and a refresh skill for when the daemon could not keep the server list current. No vendor credential ever sits in a config file.",
5
5
  "author": {
6
6
  "name": "Bonustrack",
@@ -31,6 +31,7 @@ import {
31
31
  stopSession,
32
32
  type SessionDeps,
33
33
  } from './session.js';
34
+ import { claudeVersion, updateClaude, type VersionDeps } from './version.js';
34
35
  import {
35
36
  claudeDir,
36
37
  deleteClaudeSession,
@@ -54,6 +55,7 @@ export interface ClaudeApiDeps {
54
55
  login?: LoginDeps;
55
56
  session?: SessionDeps;
56
57
  setup?: SetupDeps;
58
+ version?: VersionDeps;
57
59
  }
58
60
 
59
61
  function projectOf(query: URLSearchParams): string {
@@ -120,6 +122,7 @@ async function created(req: IncomingMessage, path: string, dir: string): Promise
120
122
  const LOGIN = 'login';
121
123
  const SESSION = 'session';
122
124
  const SETUP = 'setup';
125
+ const VERSION = 'version';
123
126
 
124
127
  interface SetupChange {
125
128
  privacy?: boolean;
@@ -211,9 +214,18 @@ function answer(method: string, path: string, query: URLSearchParams, dir: strin
211
214
  return handler(query, dir, item);
212
215
  }
213
216
 
217
+ function versionAnswer(req: IncomingMessage, deps: ClaudeApiDeps): Promise<unknown> {
218
+ const version: VersionDeps = { ...deps.version, session: deps.version?.session ?? deps.session };
219
+ const method = req.method ?? 'GET';
220
+ if (method === 'GET') return claudeVersion(version);
221
+ if (method !== 'POST') throw new ApiError('method not allowed', 405);
222
+ return updateClaude(version);
223
+ }
224
+
214
225
  const SINGLETONS: Record<string, (req: IncomingMessage, deps: ClaudeApiDeps) => Promise<unknown>> = {
215
226
  [SESSION]: sessionAnswer,
216
227
  [SETUP]: setupAnswer,
228
+ [VERSION]: versionAnswer,
217
229
  };
218
230
 
219
231
  function routed(req: IncomingMessage, path: string, search: string, deps: ClaudeApiDeps): unknown {
@@ -0,0 +1,109 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import { homedir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ import { ApiError } from '@metro-labs/http/api-error';
5
+ import { errMsg, log } from '@metro-labs/core/log';
6
+ import { isRecord } from '@metro-labs/core/is-record';
7
+ import { sessionRunning, stopSession, type SessionDeps } from './session.js';
8
+
9
+ const LATEST_URL = 'https://registry.npmjs.org/@anthropic-ai/claude-code/latest';
10
+ const VERSION_RE = /(\d+)\.(\d+)\.(\d+)/;
11
+ const CACHE_MS = 10 * 60_000;
12
+ const INSTALL_TIMEOUT_MS = 5 * 60_000;
13
+ const TAIL = 400;
14
+
15
+ export interface VersionDeps {
16
+ claude?: string;
17
+ fetchImpl?: typeof fetch;
18
+ session?: SessionDeps;
19
+ now?: () => number;
20
+ }
21
+
22
+ export interface ClaudeVersion {
23
+ installed: string | null;
24
+ latest: string | null;
25
+ newer: boolean;
26
+ }
27
+
28
+ let cached: { latest: string | null; at: number } | null = null;
29
+
30
+ const candidates = (deps: VersionDeps): string[] => (deps.claude === undefined ? ['claude', join(homedir(), '.local', 'bin', 'claude')] : [deps.claude]);
31
+
32
+ const parse = (text: string): string | null => {
33
+ const m = VERSION_RE.exec(text);
34
+ return m === null ? null : `${m[1] ?? ''}.${m[2] ?? ''}.${m[3] ?? ''}`;
35
+ };
36
+
37
+ function probe(bin: string): string | null {
38
+ const run = spawnSync(bin, ['--version'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
39
+ return run.error === undefined && run.status === 0 && typeof run.stdout === 'string' ? parse(run.stdout) : null;
40
+ }
41
+
42
+ function located(deps: VersionDeps): { bin: string; installed: string } | null {
43
+ for (const bin of candidates(deps)) {
44
+ const installed = probe(bin);
45
+ if (installed !== null) return { bin, installed };
46
+ }
47
+ return null;
48
+ }
49
+
50
+ export function newerThan(a: string, b: string): boolean {
51
+ const left = a.split('.').map(Number);
52
+ const right = b.split('.').map(Number);
53
+ for (let i = 0; i < 3; i += 1) {
54
+ const d = (left[i] ?? 0) - (right[i] ?? 0);
55
+ if (d !== 0) return d > 0;
56
+ }
57
+ return false;
58
+ }
59
+
60
+ export async function latestClaudeVersion(deps: VersionDeps = {}): Promise<string | null> {
61
+ const now = (deps.now ?? Date.now)();
62
+ if (cached !== null && now - cached.at < CACHE_MS) return cached.latest;
63
+ const fetchImpl = deps.fetchImpl ?? fetch;
64
+ let latest: string | null = null;
65
+ try {
66
+ const res = await fetchImpl(LATEST_URL, { headers: { accept: 'application/json' }, redirect: 'manual' });
67
+ const body: unknown = res.ok ? await res.json() : null;
68
+ latest = isRecord(body) && typeof body.version === 'string' ? parse(body.version) : null;
69
+ } catch (err) {
70
+ log.warn({ err: errMsg(err) }, 'claude-version: could not read the latest version from npm');
71
+ }
72
+ cached = { latest, at: now };
73
+ return latest;
74
+ }
75
+
76
+ export function forgetClaudeVersion(): void {
77
+ cached = null;
78
+ }
79
+
80
+ export async function claudeVersion(deps: VersionDeps = {}): Promise<ClaudeVersion> {
81
+ const installed = located(deps)?.installed ?? null;
82
+ const latest = await latestClaudeVersion(deps);
83
+ return { installed, latest, newer: installed !== null && latest !== null && newerThan(latest, installed) };
84
+ }
85
+
86
+ function install(bin: string): void {
87
+ const run = spawnSync(bin, ['install', 'latest'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], timeout: INSTALL_TIMEOUT_MS });
88
+ if (run.error !== undefined) throw new ApiError(`claude install latest failed: ${errMsg(run.error)}`, 502);
89
+ if (run.status === 0) return;
90
+ const said = `${run.stderr ?? ''}${run.stdout ?? ''}`.trim().slice(-TAIL);
91
+ throw new ApiError(`claude install latest failed: ${said}`, 502);
92
+ }
93
+
94
+ function restartIfChanged(changed: boolean, session: SessionDeps): boolean {
95
+ if (!changed || !sessionRunning(session.tmux ?? 'tmux')) return false;
96
+ stopSession(session);
97
+ return true;
98
+ }
99
+
100
+ export async function updateClaude(deps: VersionDeps = {}): Promise<ClaudeVersion & { restarted: boolean }> {
101
+ const found = located(deps);
102
+ if (found === null) throw new ApiError('Claude Code is not installed on this machine', 404);
103
+ install(found.bin);
104
+ forgetClaudeVersion();
105
+ const after = await claudeVersion(deps);
106
+ const restarted = restartIfChanged(after.installed !== null && after.installed !== found.installed, deps.session ?? {});
107
+ log.info({ from: found.installed, to: after.installed, restarted }, 'claude-version: Claude Code updated');
108
+ return { ...after, restarted };
109
+ }
@@ -8,8 +8,6 @@ import { mintTerminalTicket } from './tickets.js';
8
8
 
9
9
  const PREFIX = '/api/terminal';
10
10
  const TICKETS = `${PREFIX}/tickets`;
11
- const BUFFER = `${PREFIX}/buffer`;
12
- const BUFFER_MAX = 1024 * 1024;
13
11
  const SESSION_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,31}$/;
14
12
 
15
13
  export const tmuxCommand = (session: string, home = homedir()): string[] => [
@@ -39,7 +37,6 @@ export const tmuxCommand = (session: string, home = homedir()): string[] => [
39
37
  ];
40
38
 
41
39
  export interface TerminalApiDeps {
42
- tmux?: string;
43
40
  authorize: (subject: string) => void;
44
41
  command?: (session: string) => string[];
45
42
  }
@@ -67,15 +64,9 @@ export function sessionOf(raw: unknown): string {
67
64
  return raw;
68
65
  }
69
66
 
70
- export function tmuxBuffer(tmux = 'tmux'): string {
71
- const run = spawnSync(tmux, ['save-buffer', '-'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], maxBuffer: BUFFER_MAX });
72
- if (run.error !== undefined || run.status !== 0) throw new ApiError('tmux holds nothing copied yet: select text in the terminal first', 404);
73
- return run.stdout;
74
- }
75
-
76
67
  export function handleTerminalRequest(req: IncomingMessage, res: ServerResponse, deps: TerminalApiDeps): boolean {
77
68
  const path = (req.url ?? '').split('?')[0] ?? '';
78
- if (path !== PREFIX && path !== TICKETS && path !== BUFFER) return false;
69
+ if (path !== PREFIX && path !== TICKETS) return false;
79
70
  if (req.method === 'OPTIONS') {
80
71
  res.writeHead(204, cors(req)).end();
81
72
  return true;
@@ -93,10 +84,6 @@ export function handleTerminalRequest(req: IncomingMessage, res: ServerResponse,
93
84
  sendJson(req, res, 200, { available: tmuxAvailable(deps), sessions: tmuxSessions() });
94
85
  return;
95
86
  }
96
- if (path === BUFFER) {
97
- sendJson(req, res, 200, { text: tmuxBuffer(deps.tmux) });
98
- return;
99
- }
100
87
  const wanted = sessionOf(bodyField(await readJsonBody(req), 'session'));
101
88
  const minted = mintTerminalTicket(session.subject, wanted);
102
89
  log.info({ subject: session.subject, session: wanted }, 'terminal: ticket minted');
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "0.1.0-beta.122"
2
+ "version": "0.1.0-beta.124"
3
3
  }