@stage-labs/metro 0.1.0-beta.16 → 0.1.0-beta.17

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/api.js CHANGED
@@ -23,8 +23,8 @@ function tokenOrThrow() {
23
23
  throw new NotSignedIn('not signed in — run `metro login` first');
24
24
  return token;
25
25
  }
26
- async function get(path, presented) {
27
- const auth = presented ?? tokenOrThrow();
26
+ async function get(path) {
27
+ const auth = tokenOrThrow();
28
28
  let res;
29
29
  try {
30
30
  res = await fetch(`${metroUrl()}${path}`, {
@@ -44,14 +44,43 @@ async function get(path, presented) {
44
44
  function isRecord(value) {
45
45
  return typeof value === 'object' && value !== null && !Array.isArray(value);
46
46
  }
47
+ async function post(path, body) {
48
+ if (!carriesSecretsSafely(metroUrl()))
49
+ throw new Error(`refusing to send your code to ${metroUrl()} in the clear — use https, or a loopback address`);
50
+ let res;
51
+ try {
52
+ res = await fetch(`${metroUrl()}${path}`, {
53
+ method: 'POST',
54
+ headers: { 'content-type': 'application/json' },
55
+ body: JSON.stringify(body),
56
+ signal: AbortSignal.timeout(TIMEOUT_MS),
57
+ });
58
+ }
59
+ catch {
60
+ throw new Error(`could not reach ${metroUrl()}`);
61
+ }
62
+ const parsed = await res.json().catch(() => null);
63
+ if (res.ok)
64
+ return parsed;
65
+ const detail = isRecord(parsed) ? parsed.error : undefined;
66
+ throw new Error(typeof detail === 'string' ? detail : `metro answered ${String(res.status)}`);
67
+ }
68
+ export async function claimCode(code) {
69
+ const body = await post('/api/cli/claim', { code });
70
+ if (!isRecord(body) ||
71
+ typeof body.session !== 'string' ||
72
+ typeof body.email !== 'string')
73
+ throw new Error('metro returned an unexpected response');
74
+ return { session: body.session, email: body.email };
75
+ }
47
76
  export async function mcpServers() {
48
77
  const body = await get('/api/connectors');
49
78
  if (!isRecord(body) || typeof body.json !== 'string')
50
79
  throw new Error('metro returned an unexpected response');
51
80
  return body.json;
52
81
  }
53
- export async function sessionEmail(presented) {
54
- const body = await get('/api/session', presented);
82
+ export async function sessionEmail() {
83
+ const body = await get('/api/session');
55
84
  if (!isRecord(body) || typeof body.email !== 'string')
56
85
  throw new Error('metro returned an unexpected response');
57
86
  return body.email;
package/dist/cli.js CHANGED
@@ -1,12 +1,12 @@
1
1
  #!/usr/bin/env node
2
- import { mcpServers, NotSignedIn, sessionEmail } from './api.js';
3
- import { signIn } from './login.js';
4
- import { clearToken, credentialsPath, metroUrl, writeToken } from './store.js';
2
+ import { claimCode, mcpServers, NotSignedIn, sessionEmail } from './api.js';
3
+ import { askLine } from './prompt.js';
4
+ import { clearToken, credentialsPath, metroUrl, metroWebUrl, writeToken, } from './store.js';
5
5
  import { update } from './update.js';
6
6
  import { currentVersion } from './version.js';
7
7
  const USAGE = `metro — the command line for your MCP connectors
8
8
 
9
- metro login sign in to metro in your browser
9
+ metro login sign in with a code from the metro web UI
10
10
  metro logout forget this machine's sign-in
11
11
  metro whoami print the account this machine is signed in as
12
12
  metro mcp print an mcpServers block for every connector
@@ -19,12 +19,13 @@ Start Claude Code with all of them, without writing them to disk:
19
19
 
20
20
  METRO_URL the metro to talk to (default https://mcp.metro.box)
21
21
  METRO_TOKEN use this session instead of the stored one
22
+ METRO_UI_URL where the web UI lives (default https://metro.box)
22
23
  `;
23
24
  async function login() {
24
- const token = await signIn();
25
- const email = await sessionEmail(token).catch(() => '');
26
- writeToken(token);
27
- process.stderr.write(`Signed in${email === '' ? '' : ` as ${email}`}. Stored in ${credentialsPath()}\n`);
25
+ process.stderr.write(`Get a code from ${metroWebUrl()}/#/settings\n`);
26
+ const { session, email } = await claimCode(await askLine('Paste it here: '));
27
+ writeToken(session);
28
+ process.stderr.write(`Signed in as ${email}. Stored in ${credentialsPath()}\n`);
28
29
  }
29
30
  async function whoami() {
30
31
  process.stdout.write(`${await sessionEmail()} on ${metroUrl()}\n`);
package/dist/prompt.js ADDED
@@ -0,0 +1,10 @@
1
+ import { createInterface } from 'node:readline';
2
+ export function askLine(question) {
3
+ const rl = createInterface({ input: process.stdin, output: process.stderr });
4
+ return new Promise((resolve) => {
5
+ rl.question(question, (answer) => {
6
+ rl.close();
7
+ resolve(answer.trim());
8
+ });
9
+ });
10
+ }
package/dist/store.js CHANGED
@@ -2,9 +2,16 @@ import { chmodSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:
2
2
  import { homedir } from 'node:os';
3
3
  import { dirname, join } from 'node:path';
4
4
  const DEFAULT_BASE = 'https://mcp.metro.box';
5
+ const DEFAULT_WEB = 'https://metro.box';
6
+ function fromEnv(name, fallback) {
7
+ const raw = process.env[name]?.trim();
8
+ return raw === undefined || raw === '' ? fallback : raw.replace(/\/+$/, '');
9
+ }
5
10
  export function metroUrl() {
6
- const raw = process.env.METRO_URL?.trim();
7
- return raw === undefined || raw === '' ? DEFAULT_BASE : raw.replace(/\/+$/, '');
11
+ return fromEnv('METRO_URL', DEFAULT_BASE);
12
+ }
13
+ export function metroWebUrl() {
14
+ return fromEnv('METRO_UI_URL', DEFAULT_WEB);
8
15
  }
9
16
  function configDir() {
10
17
  const xdg = process.env.XDG_CONFIG_HOME?.trim();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stage-labs/metro",
3
- "version": "0.1.0-beta.16",
3
+ "version": "0.1.0-beta.17",
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": {
package/dist/login.js DELETED
@@ -1,102 +0,0 @@
1
- import { createServer } from 'node:http';
2
- import { randomBytes } from 'node:crypto';
3
- import { spawn } from 'node:child_process';
4
- import { metroUrl } from './store.js';
5
- const TIMEOUT_MS = 5 * 60_000;
6
- const MAX_BODY = 16 * 1024;
7
- const PAGE = `<!doctype html><meta charset="utf-8"><title>Metro</title>
8
- <body style="font:16px system-ui;padding:3rem;color:#222">
9
- <p id="m">Finishing sign-in…</p>
10
- <script>
11
- const nonce = new URLSearchParams(location.search).get('s') || '';
12
- const body = new URLSearchParams(location.hash.replace(/^#/, ''));
13
- body.set('s', nonce);
14
- fetch('/token', { method: 'POST', body }).then(
15
- () => { document.getElementById('m').textContent = 'Signed in. You can close this tab.'; },
16
- () => { document.getElementById('m').textContent = 'Could not reach the metro CLI.'; },
17
- );
18
- </script>`;
19
- function browserCommand(url) {
20
- if (process.platform === 'darwin')
21
- return { cmd: 'open', args: [url] };
22
- if (process.platform === 'win32')
23
- return { cmd: 'cmd', args: ['/c', 'start', '', url] };
24
- return { cmd: 'xdg-open', args: [url] };
25
- }
26
- function openBrowser(url) {
27
- if (process.env.METRO_NO_BROWSER === '1')
28
- return;
29
- const { cmd, args } = browserCommand(url);
30
- const child = spawn(cmd, args, { stdio: 'ignore', detached: true });
31
- child.on('error', () => undefined);
32
- child.unref();
33
- }
34
- async function readBody(req) {
35
- const chunks = [];
36
- let size = 0;
37
- for await (const chunk of req) {
38
- const buf = chunk;
39
- size += buf.length;
40
- if (size > MAX_BODY)
41
- throw new Error('sign-in body too large');
42
- chunks.push(buf);
43
- }
44
- return Buffer.concat(chunks).toString('utf8');
45
- }
46
- export function sessionFrom(body, nonce) {
47
- const params = new URLSearchParams(body);
48
- if (params.get('s') !== nonce)
49
- return '';
50
- return params.get('session') ?? '';
51
- }
52
- export function signIn() {
53
- const nonce = randomBytes(16).toString('base64url');
54
- return new Promise((resolve, reject) => {
55
- let settled = false;
56
- const finish = (err, token) => {
57
- if (settled)
58
- return;
59
- settled = true;
60
- server.close();
61
- clearTimeout(timer);
62
- if (err !== null)
63
- reject(err);
64
- else
65
- resolve(token ?? '');
66
- };
67
- const accept = (res, body) => {
68
- const session = sessionFrom(body, nonce);
69
- if (session === '') {
70
- res.writeHead(400).end();
71
- return;
72
- }
73
- res.writeHead(204).end();
74
- finish(null, session);
75
- };
76
- const server = createServer((req, res) => {
77
- if (req.method !== 'POST' || !(req.url ?? '').startsWith('/token')) {
78
- res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }).end(PAGE);
79
- return;
80
- }
81
- readBody(req).then((body) => {
82
- accept(res, body);
83
- }, () => {
84
- res.writeHead(400).end();
85
- });
86
- });
87
- const timer = setTimeout(() => {
88
- finish(new Error('sign-in timed out'));
89
- }, TIMEOUT_MS);
90
- timer.unref();
91
- server.on('error', (err) => {
92
- finish(err);
93
- });
94
- server.listen(0, '127.0.0.1', () => {
95
- const { port } = server.address();
96
- const returnTo = `http://127.0.0.1:${String(port)}/callback?s=${nonce}`;
97
- const url = `${metroUrl()}/auth/google/start?return_to=${encodeURIComponent(returnTo)}`;
98
- process.stderr.write(`Opening ${url}\n`);
99
- openBrowser(url);
100
- });
101
- });
102
- }