@invitedlabs/cli 0.0.0-stage → 0.1.0

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.
@@ -0,0 +1,192 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ import { mkdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises';
3
+ import { dirname, join, resolve } from 'node:path';
4
+ import { CliError, errorCode } from './errors.js';
5
+ import { plural } from './format.js';
6
+ import { mcpUrl } from './oauth.js';
7
+ import { setServerTable, ShapeError, tomlArray, tomlString } from './toml.js';
8
+ /**
9
+ * `invited connect <host>`: writes Invited's MCP server (https://invited.you/mcp)
10
+ * into a host's own configuration file under the name `invited`, merging with
11
+ * what is there and never touching another server's entry. Each host signs in
12
+ * to Invited itself (OAuth with PKCE, as its documentation describes), so the
13
+ * entry holds no token. `--bridge` writes the mcp-remote bridge instead
14
+ * (`npx -y mcp-remote@<pinned> <url>`), for a host version without remote OAuth.
15
+ *
16
+ * Formats, checked against each host's documentation on 11 October 2026
17
+ * (docs/mcp-connector-2026-10-11.md, "3e: CLI"):
18
+ * claude-code ~/.claude.json, mcpServers.invited = { type: "http", url } (user scope)
19
+ * cursor ~/.cursor/mcp.json, mcpServers.invited = { url }
20
+ * codex ~/.codex/config.toml ($CODEX_HOME), [mcp_servers.invited] url = "…"
21
+ * gemini ~/.gemini/settings.json, mcpServers.invited = { httpUrl }
22
+ */
23
+ export const SERVER_NAME = 'invited';
24
+ export const HOSTS = ['claude-code', 'cursor', 'codex', 'gemini'];
25
+ const HOST = {
26
+ 'claude-code': {
27
+ label: 'Claude Code',
28
+ format: 'json',
29
+ file: home => join(home, '.claude.json'),
30
+ scope: 'user scope: every project',
31
+ native: url => ({ type: 'http', url }),
32
+ bridged: bridge => ({ type: 'stdio', ...bridge }),
33
+ signIn: 'Claude Code signs in to Invited itself: run `claude mcp login invited` (or /mcp inside Claude Code) and approve in your browser.',
34
+ source: 'https://code.claude.com/docs/en/mcp',
35
+ },
36
+ cursor: {
37
+ label: 'Cursor',
38
+ format: 'json',
39
+ file: home => join(home, '.cursor', 'mcp.json'),
40
+ scope: 'global: every project',
41
+ native: url => ({ url }),
42
+ bridged: bridge => ({ type: 'stdio', ...bridge }),
43
+ signIn: 'Cursor signs in to Invited itself: in Cursor, open Customize > MCP, sign in to invited and approve in your browser.',
44
+ source: 'https://cursor.com/docs/mcp',
45
+ },
46
+ codex: {
47
+ label: 'Codex CLI',
48
+ format: 'toml',
49
+ file: (home, env) => join(env.CODEX_HOME?.trim() || join(home, '.codex'), 'config.toml'),
50
+ scope: 'user configuration, shared by the Codex CLI, IDE extension and app',
51
+ native: url => ({ url }),
52
+ bridged: bridge => ({ ...bridge }),
53
+ signIn: 'Codex signs in to Invited itself: run `codex mcp login invited` and approve in your browser.',
54
+ source: 'https://developers.openai.com/codex/mcp',
55
+ },
56
+ gemini: {
57
+ label: 'Gemini CLI',
58
+ format: 'json',
59
+ file: home => join(home, '.gemini', 'settings.json'),
60
+ scope: 'user settings: every project',
61
+ native: url => ({ httpUrl: url }),
62
+ bridged: bridge => ({ ...bridge }),
63
+ signIn: 'Gemini CLI signs in to Invited itself: start gemini and approve in your browser when it connects (/mcp auth invited signs in again).',
64
+ source: 'https://geminicli.com/docs/tools/mcp-server/',
65
+ },
66
+ };
67
+ /** Keys that say how a host reaches a server; an entry of one transport loses the other's. */
68
+ const TRANSPORT_KEYS = ['type', 'url', 'httpUrl', 'command', 'args', 'env', 'cwd'];
69
+ /** The bridge, pinned to the version reviewed for 5b (npm, September 2026).
70
+ * Unpinned, `npx -y` would run whatever is published next inside the host;
71
+ * versions below 0.1.16 had a remote code execution flaw (security review
72
+ * L10). Raise it only after reviewing the new version. */
73
+ export const MCP_REMOTE = 'mcp-remote@0.14.3';
74
+ export function bridgeFor(url, platform) {
75
+ const args = ['-y', MCP_REMOTE, url, ...(url.startsWith('http://') ? ['--allow-http'] : [])];
76
+ // npx is a .cmd script on Windows, which a host can only start through cmd.
77
+ return platform === 'win32' ? { command: 'cmd', args: ['/c', 'npx', ...args] } : { command: 'npx', args };
78
+ }
79
+ /** The entry merged into a JSON configuration: the existing entry's own
80
+ * settings (a timeout, trust, tool lists) are kept, its transport replaced. */
81
+ export function mergeJson(source, entry) {
82
+ const bom = source?.startsWith('\uFEFF') ? '\uFEFF' : '';
83
+ const text = bom ? source.slice(1) : (source ?? '');
84
+ let root = {};
85
+ if (text.trim()) {
86
+ try {
87
+ root = JSON.parse(text);
88
+ }
89
+ catch {
90
+ throw new ShapeError('it is not plain JSON (comments or a syntax error)');
91
+ }
92
+ }
93
+ if (typeof root !== 'object' || root === null || Array.isArray(root))
94
+ throw new ShapeError('it is not a JSON object');
95
+ const config = root;
96
+ const servers = config.mcpServers ?? {};
97
+ if (typeof servers !== 'object' || servers === null || Array.isArray(servers))
98
+ throw new ShapeError('its mcpServers is not an object');
99
+ const existing = servers[SERVER_NAME];
100
+ const kept = typeof existing === 'object' && existing !== null && !Array.isArray(existing)
101
+ ? Object.fromEntries(Object.entries(existing).filter(([key]) => !TRANSPORT_KEYS.includes(key)))
102
+ : {};
103
+ const next = { ...entry, ...kept };
104
+ const others = Object.keys(servers).filter(name => name !== SERVER_NAME);
105
+ if (JSON.stringify(existing) === JSON.stringify(next))
106
+ return { text: source ?? '', change: 'unchanged', others };
107
+ servers[SERVER_NAME] = next;
108
+ config.mcpServers = servers;
109
+ const indent = /^([ \t]+)"/m.exec(text)?.[1] ?? ' ';
110
+ const eol = text.includes('\r\n') ? '\r\n' : '\n';
111
+ const body = JSON.stringify(config, null, indent).replace(/\n/g, eol);
112
+ return {
113
+ text: bom + body + (text && !/\r?\n$/.test(text) ? '' : eol),
114
+ change: existing === undefined ? 'added' : 'updated',
115
+ others,
116
+ };
117
+ }
118
+ async function readOptional(path) {
119
+ try {
120
+ return await readFile(path, 'utf8');
121
+ }
122
+ catch (error) {
123
+ if (errorCode(error) === 'ENOENT')
124
+ return null;
125
+ throw new CliError(`Could not read ${path} (${errorCode(error) ?? 'unknown error'}).`);
126
+ }
127
+ }
128
+ /** Replace the file atomically, keeping its permissions. */
129
+ async function writeAtomically(path, text) {
130
+ const mode = (await stat(path).catch(() => null))?.mode;
131
+ await mkdir(dirname(path), { recursive: true });
132
+ const temporary = join(dirname(path), `.${SERVER_NAME}-${process.pid}-${randomBytes(6).toString('hex')}.tmp`);
133
+ try {
134
+ await writeFile(temporary, text, { flag: 'wx', mode: mode === undefined ? 0o644 : mode & 0o777 });
135
+ await rename(temporary, path);
136
+ }
137
+ catch (error) {
138
+ await rm(temporary, { force: true });
139
+ throw new CliError(`Could not write ${path} (${errorCode(error) ?? 'unknown error'}).`);
140
+ }
141
+ }
142
+ export async function connect(options) {
143
+ const name = options.host;
144
+ if (!name || !HOSTS.includes(name))
145
+ throw new CliError(`${name ? `${name} is not a host this CLI configures.` : 'Name the host to configure.'} Use one of: ${HOSTS.join(', ')}. For example: invited connect claude-code`);
146
+ const host = HOST[name];
147
+ const url = mcpUrl(options.origin);
148
+ const file = resolve(options.config ?? host.file(options.home, options.env));
149
+ const bridge = bridgeFor(url, options.platform);
150
+ const entry = options.bridge ? host.bridged(bridge) : host.native(url);
151
+ const source = await readOptional(file);
152
+ const values = Object.entries(entry).map(([key, value]) => [
153
+ key,
154
+ Array.isArray(value) ? tomlArray(value) : tomlString(String(value)),
155
+ ]);
156
+ const shown = host.format === 'json'
157
+ ? [`mcpServers.${SERVER_NAME} = ${JSON.stringify(entry)}`]
158
+ : [`[mcp_servers.${SERVER_NAME}]`, ...values.map(([key, value]) => `${key} = ${value}`)];
159
+ let result;
160
+ try {
161
+ result =
162
+ host.format === 'json'
163
+ ? mergeJson(source, entry)
164
+ : setServerTable(source, SERVER_NAME, values, TRANSPORT_KEYS.filter(key => !(key in entry)));
165
+ }
166
+ catch (error) {
167
+ if (!(error instanceof ShapeError))
168
+ throw error;
169
+ const manual = host.format === 'json' ? JSON.stringify({ mcpServers: { [SERVER_NAME]: entry } }, null, 2) : shown.join('\n');
170
+ throw new CliError(`${file} was left as it is: ${error.message}. Add this to it by hand:\n${manual}`);
171
+ }
172
+ const lines = [];
173
+ if (result.change === 'unchanged')
174
+ lines.push(`${host.label} already has Invited (${host.scope}); nothing changed.`);
175
+ else if (options.dryRun)
176
+ lines.push(`Dry run: nothing was written. This would ${result.change === 'added' ? 'add' : 'update'} Invited in ${host.label} (${host.scope}):`);
177
+ else
178
+ lines.push(`${result.change === 'added' ? 'Added' : 'Updated'} Invited in ${host.label} (${host.scope}):`);
179
+ lines.push(` file ${file}`, ` entry ${shown.join('\n ')}`, ` format ${host.source}`);
180
+ lines.push(result.others.length
181
+ ? `The ${plural(result.others.length, 'other MCP server')} in that file ${result.others.length === 1 ? 'is' : 'are'} left as ${result.others.length === 1 ? 'it is' : 'they are'} (${result.others.join(', ')}).`
182
+ : 'No other MCP server is configured in that file.');
183
+ if (options.bridge)
184
+ lines.push(`Configured through the mcp-remote bridge (${[bridge.command, ...bridge.args].join(' ')}): ${host.label} starts it, and mcp-remote opens your browser to approve Invited the first time.`);
185
+ else
186
+ lines.push(host.signIn);
187
+ if (name === 'claude-code' && options.env.CLAUDE_CONFIG_DIR && !options.config)
188
+ lines.push('CLAUDE_CONFIG_DIR is set: if Claude Code keeps its configuration there, run this again with --config <that folder>/.claude.json.');
189
+ if (!options.dryRun && result.change !== 'unchanged')
190
+ await writeAtomically(file, result.text);
191
+ options.out(lines.join('\n') + '\n');
192
+ }
@@ -0,0 +1,150 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ import { chmod, mkdir, open, readFile, rename, rm, stat, writeFile } from 'node:fs/promises';
3
+ import { join } from 'node:path';
4
+ import { CliError, errorCode } from './errors.js';
5
+ export const configDir = (env, home) => env.INVITED_CONFIG_DIR?.trim() || join(home, '.invited');
6
+ const DAY_MS = 86_400_000;
7
+ /** How long a lock may be held before another command takes it: three times
8
+ * the longest request a renewal makes (cli/src/http.ts, 30 seconds), so a
9
+ * slow renewal is never abandoned mid-way (security review L10). A lock whose
10
+ * process has ended is taken at once. */
11
+ export const LOCK_LIFETIME_MS = 90_000;
12
+ /** Is this process still running? (Signal 0 tests without signalling; EPERM
13
+ * means it runs as someone else.) */
14
+ function running(pid) {
15
+ try {
16
+ process.kill(pid, 0);
17
+ return true;
18
+ }
19
+ catch (error) {
20
+ return errorCode(error) === 'EPERM';
21
+ }
22
+ }
23
+ /** Invited's refresh token lifetimes (migration 136): 30 days idle, 90 days in all. */
24
+ export function refreshExpiry(connection) {
25
+ const idle = Date.parse(connection.lastUsedAt) + 30 * DAY_MS, latest = Date.parse(connection.connectedAt) + 90 * DAY_MS;
26
+ return { expiresAt: Math.min(idle, latest), latest };
27
+ }
28
+ const text = (value) => typeof value === 'string' && value.length > 0;
29
+ const optionalText = (value) => value === null || text(value);
30
+ function isConnection(value) {
31
+ if (typeof value !== 'object' || value === null)
32
+ return false;
33
+ const item = value;
34
+ return (['origin', 'clientId', 'resource', 'tokenEndpoint', 'revocationEndpoint', 'identityId', 'refreshToken'].every(key => text(item[key])) &&
35
+ ['connectedAt', 'lastUsedAt'].every(key => text(item[key]) && !Number.isNaN(Date.parse(item[key]))) &&
36
+ Array.isArray(item.scopes) &&
37
+ item.scopes.every(text) &&
38
+ optionalText(item.pageUrl) &&
39
+ optionalText(item.pageReadAt));
40
+ }
41
+ export function credentialStore(dir) {
42
+ const path = join(dir, 'credentials.json');
43
+ const lockPath = join(dir, 'credentials.lock');
44
+ async function read() {
45
+ let source;
46
+ try {
47
+ source = await readFile(path, 'utf8');
48
+ }
49
+ catch (error) {
50
+ if (errorCode(error) === 'ENOENT')
51
+ return { version: 1, connections: {} };
52
+ throw new CliError(`Could not read ${path} (${errorCode(error) ?? 'unknown error'}).`);
53
+ }
54
+ let parsed;
55
+ try {
56
+ parsed = JSON.parse(source);
57
+ }
58
+ catch {
59
+ throw new CliError(`${path} is not valid JSON. Delete it and run \`invited login\` again.`);
60
+ }
61
+ const file = parsed;
62
+ if (file?.version !== 1 ||
63
+ typeof file.connections !== 'object' ||
64
+ file.connections === null ||
65
+ !Object.entries(file.connections).every(([origin, item]) => isConnection(item) && item.origin === origin))
66
+ throw new CliError(`${path} is not in the format this CLI writes. Delete it and run \`invited login\` again.`);
67
+ return file;
68
+ }
69
+ /** Is the file readable by other users? (POSIX only; Windows keeps it to the user's profile.) */
70
+ async function tooOpen() {
71
+ if (process.platform === 'win32')
72
+ return false;
73
+ const info = await stat(path).catch(() => null);
74
+ return !!info && (info.mode & 0o077) !== 0;
75
+ }
76
+ /** Replace the file atomically (a temporary file renamed over it), or
77
+ * delete it when no connection is left. */
78
+ async function write(file) {
79
+ if (!Object.keys(file.connections).length) {
80
+ await rm(path, { force: true });
81
+ return;
82
+ }
83
+ await mkdir(dir, { recursive: true, mode: 0o700 });
84
+ const temporary = join(dir, `.credentials-${process.pid}-${randomBytes(6).toString('hex')}.tmp`);
85
+ try {
86
+ await writeFile(temporary, JSON.stringify(file, null, 2) + '\n', { mode: 0o600, flag: 'wx' });
87
+ await rename(temporary, path);
88
+ await chmod(path, 0o600);
89
+ }
90
+ catch (error) {
91
+ await rm(temporary, { force: true });
92
+ throw new CliError(`Could not save ${path} (${errorCode(error) ?? 'unknown error'}).`);
93
+ }
94
+ }
95
+ /** Is the lock's holder gone? Its process has ended, or it took the lock
96
+ * more than LOCK_LIFETIME_MS ago. The lock holds `{ pid, at }`; an older
97
+ * CLI's lock holds the PID alone and is dated by the file. */
98
+ async function abandoned() {
99
+ const info = await stat(lockPath).catch(() => null);
100
+ if (!info)
101
+ return false;
102
+ let holder = null;
103
+ try {
104
+ holder = JSON.parse(await readFile(lockPath, 'utf8'));
105
+ }
106
+ catch {
107
+ holder = null;
108
+ }
109
+ const record = typeof holder === 'object' && holder !== null ? holder : {};
110
+ const pid = typeof holder === 'number' ? holder : record.pid;
111
+ const at = typeof record.at === 'number' ? record.at : info.mtimeMs;
112
+ if (Date.now() - at > LOCK_LIFETIME_MS)
113
+ return true;
114
+ return typeof pid === 'number' && Number.isInteger(pid) && pid > 0 && pid !== process.pid && !running(pid);
115
+ }
116
+ /** Run `task` holding the credentials lock, so two commands never renew
117
+ * the same refresh token (Invited would see the second use as theft and
118
+ * end the connection). A lock is abandoned only when its process has ended
119
+ * or after LOCK_LIFETIME_MS. */
120
+ async function locked(task) {
121
+ await mkdir(dir, { recursive: true, mode: 0o700 });
122
+ const started = Date.now();
123
+ for (;;) {
124
+ try {
125
+ const handle = await open(lockPath, 'wx', 0o600);
126
+ await handle.writeFile(JSON.stringify({ pid: process.pid, at: Date.now() }));
127
+ await handle.close();
128
+ break;
129
+ }
130
+ catch (error) {
131
+ if (!['EEXIST', 'EPERM', 'EBUSY'].includes(errorCode(error) ?? ''))
132
+ throw new CliError(`Could not lock ${lockPath} (${errorCode(error) ?? 'unknown error'}).`);
133
+ if (await abandoned()) {
134
+ await rm(lockPath, { force: true });
135
+ continue;
136
+ }
137
+ if (Date.now() - started > LOCK_LIFETIME_MS + 5_000)
138
+ throw new CliError(`Another invited command is using ${path}. Try again in a moment, or delete ${lockPath} if none is running.`);
139
+ await new Promise(resolve => setTimeout(resolve, 100));
140
+ }
141
+ }
142
+ try {
143
+ return await task();
144
+ }
145
+ finally {
146
+ await rm(lockPath, { force: true });
147
+ }
148
+ }
149
+ return { path, read, write, locked, tooOpen };
150
+ }
package/dist/errors.js ADDED
@@ -0,0 +1,9 @@
1
+ /** A failure the CLI explains in plain words: printed on stderr, exit code 1. */
2
+ export class CliError extends Error {
3
+ constructor(message) {
4
+ super(message);
5
+ this.name = 'CliError';
6
+ }
7
+ }
8
+ /** The error code Node gives a failed file or network operation, if any. */
9
+ export const errorCode = (error) => typeof error === 'object' && error !== null && 'code' in error ? String(error.code) : null;
package/dist/format.js ADDED
@@ -0,0 +1,25 @@
1
+ export function style(env) {
2
+ const forced = !!env.FORCE_COLOR && !['0', 'false'].includes(env.FORCE_COLOR);
3
+ const colour = forced && !env.NO_COLOR;
4
+ return { bold: text => (colour ? `\u001b[1m${text}\u001b[22m` : text) };
5
+ }
6
+ /** "11 Oct 2026, 14:02 BST", in the reader's zone (or the one given). */
7
+ export function formatTime(time, timeZone) {
8
+ return new Intl.DateTimeFormat('en-GB', {
9
+ day: 'numeric',
10
+ month: 'short',
11
+ year: 'numeric',
12
+ hour: '2-digit',
13
+ minute: '2-digit',
14
+ hourCycle: 'h23',
15
+ timeZoneName: 'short',
16
+ timeZone,
17
+ }).format(new Date(time));
18
+ }
19
+ /** "5 minutes", "30 seconds". */
20
+ export function duration(ms) {
21
+ if (ms >= 60_000 && ms % 60_000 === 0)
22
+ return plural(ms / 60_000, 'minute');
23
+ return plural(Math.max(1, Math.ceil(ms / 1000)), 'second');
24
+ }
25
+ export const plural = (count, word) => `${count} ${word}${count === 1 ? '' : 's'}`;
package/dist/http.js ADDED
@@ -0,0 +1,39 @@
1
+ import { CliError, errorCode } from './errors.js';
2
+ import { USER_AGENT } from './version.js';
3
+ const TIMEOUT_MS = 30_000;
4
+ /** One HTTP request to Invited. Redirects are refused: a token is only ever
5
+ * sent to the address it was issued for. A network failure is explained. */
6
+ export async function send(fetch, url, init = {}) {
7
+ const headers = new Headers(init.headers);
8
+ headers.set('user-agent', USER_AGENT);
9
+ try {
10
+ return await fetch(url, { ...init, headers, redirect: 'error', signal: AbortSignal.timeout(TIMEOUT_MS) });
11
+ }
12
+ catch (error) {
13
+ throw new CliError(`Could not reach ${new URL(url).origin} (${networkReason(error)}). Check your connection and try again.`);
14
+ }
15
+ }
16
+ function networkReason(error) {
17
+ if (error instanceof Error && (error.name === 'TimeoutError' || error.name === 'AbortError'))
18
+ return 'no answer';
19
+ const cause = error instanceof Error ? error.cause : undefined;
20
+ return errorCode(cause) ?? errorCode(error) ?? (error instanceof Error ? error.message : String(error));
21
+ }
22
+ /** A JSON body as an object, or an empty object when it is not JSON. */
23
+ export async function readJson(response) {
24
+ const text = await response.text();
25
+ try {
26
+ const value = JSON.parse(text);
27
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
28
+ ? value
29
+ : {};
30
+ }
31
+ catch {
32
+ return {};
33
+ }
34
+ }
35
+ /** Retry-After in whole seconds, when the answer gives one. */
36
+ export function retryAfter(response) {
37
+ const value = Number(response.headers.get('retry-after'));
38
+ return Number.isFinite(value) && value > 0 ? Math.ceil(value) : null;
39
+ }
package/dist/index.js ADDED
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env node
2
+ import { main } from './main.js';
3
+ process.exitCode = await main(process.argv.slice(2));
@@ -0,0 +1,140 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ import { createServer } from 'node:http';
3
+ import { duration } from './format.js';
4
+ const escape = (text) => text.replace(/[&<>"']/g, character => `&#${character.charCodeAt(0)};`);
5
+ /** The Invited lockup (public/brand/invited-lockup.svg: the loading-screen
6
+ * proportions, mark larger than wordmark), drawn in the page's ink. */
7
+ const LOCKUP = '<svg viewBox="0 0 440 160" fill="none" xmlns="http://www.w3.org/2000/svg" aria-label="Invited" role="img">' +
8
+ '<path d="M60.3125 69.9999H70.5454C72.0037 69.9999 73.2485 69.4843 74.2797 68.4531L107.632 35.1007C108.663 34.0695 109.695 34.0695 110.726 35.1007L121.774 46.1492C122.805 47.1804 122.805 48.2116 121.774 49.2428L92.5639 78.4531C91.5327 79.4843 91.5327 80.5155 92.5639 81.5467L121.774 110.757C122.805 111.788 122.805 112.819 121.774 113.851L110.726 124.899C109.695 125.93 108.663 125.93 107.632 124.899L74.2797 91.5467C73.2485 90.5155 72.0037 89.9999 70.5454 89.9999H60.3125C58.8542 89.9999 58.125 89.2708 58.125 87.8124V72.1874C58.125 70.7291 58.8542 69.9999 60.3125 69.9999Z" fill="currentColor"/>' +
9
+ '<path d="M49.6875 66.875H35.3125C34.1044 66.875 33.125 67.8544 33.125 69.0625V90.9375C33.125 92.1456 34.1044 93.125 35.3125 93.125H49.6875C50.8956 93.125 51.875 92.1456 51.875 90.9375V69.0625C51.875 67.8544 50.8956 66.875 49.6875 66.875Z" fill="currentColor"/>' +
10
+ '<path d="M163.28 106H152.4V49.04H163.28V106ZM205.688 106H195.368V80.72C195.368 75.12 193.368 72.32 188.328 72.32C182.888 72.32 179.368 76.24 179.368 82.96V106H169.048V64.72H178.888V71.44C181.128 67.04 184.808 63.92 191.528 63.92C200.968 63.92 205.688 68.72 205.688 78.72V106ZM233.395 106H220.915L205.635 64.72H216.435L227.155 96.96L237.875 64.72H248.675L233.395 106ZM260.896 106H250.576V64.72H260.896V106ZM255.776 58.56C251.936 58.56 249.776 56.24 249.776 53.2C249.776 50.16 251.936 47.84 255.776 47.84C259.616 47.84 261.696 50.16 261.696 53.2C261.696 56.24 259.616 58.56 255.776 58.56ZM281.988 106.4C272.628 106.4 269.508 103.52 269.508 94.4V72.08H263.028V64.72H269.508V52.24H279.828V64.72H288.788V72.08H279.828V94.8C279.828 98 281.188 99.12 284.468 99.12L288.788 99.04V106C287.108 106.16 283.828 106.4 281.988 106.4ZM309.745 106.8C296.305 106.8 288.785 98.4 288.785 85.36C288.785 72.64 295.985 63.92 309.265 63.92C322.625 63.92 329.825 72.72 329.825 84.88C329.825 85.76 329.745 86.56 329.665 87.52H299.105C299.425 95.92 303.425 99.76 309.745 99.76C314.865 99.76 318.225 97.2 319.425 92.96H328.945C327.185 101.44 320.865 106.8 309.745 106.8ZM309.265 70.96C303.585 70.96 299.985 74.24 299.105 80.8H319.505C318.625 74.24 314.945 70.96 309.265 70.96ZM349.936 106.8C338.816 106.8 331.776 98.88 331.776 85.36C331.776 71.84 338.816 63.92 349.936 63.92C356.176 63.92 360.336 66.48 362.656 70.96V49.04H372.976V106H363.136V98.96C360.896 103.76 356.816 106.8 349.936 106.8ZM352.336 98.96C358.496 98.96 362.656 94.8 362.656 85.36C362.656 75.92 358.496 71.76 352.336 71.76C346.256 71.76 342.096 75.92 342.096 85.36C342.096 94.8 346.256 98.96 352.336 98.96Z" fill="currentColor"/>' +
11
+ '</svg>';
12
+ /** The page the browser shows when it comes back to the CLI: the sign-in
13
+ * screens' look (dark, the lockup, one cut-corner panel; styles/invited.css
14
+ * tokens copied here, since this page loads nothing from anywhere). The
15
+ * stylesheet carries a per-response nonce, so the policy admits nothing else. */
16
+ function page(response, status, title, message, after) {
17
+ const nonce = randomBytes(16).toString('base64url');
18
+ const tone = status === 200 ? 'Connected' : 'Not connected';
19
+ const style = `:root{color-scheme:dark}*{box-sizing:border-box}` +
20
+ `body{margin:0;min-height:100dvh;display:grid;place-items:center;padding:32px 24px;background:#121212;color:#f5f5f5;` +
21
+ `font:400 14px/20px 'Creato Display','Helvetica Neue',Arial,sans-serif;-webkit-font-smoothing:antialiased}` +
22
+ // The design system's cut-corner frame (styles/invited.css, .iv-dialog): a
23
+ // 1px frame in the line colour, and the surface inside it cut 2 − √2 of a
24
+ // pixel less, so the diagonal frame is as thick as the straight one.
25
+ `main{width:100%;max-width:440px;padding:1px;background:#4a4a4a;` +
26
+ `clip-path:polygon(0 0,calc(100% - 16px) 0,100% 16px,100% 100%,16px 100%,0 calc(100% - 16px))}` +
27
+ `main>div{padding:40px 36px 36px;background:#1c1c1c;` +
28
+ `clip-path:polygon(0 0,calc(100% - 15.414px) 0,100% 15.414px,100% 100%,15.414px 100%,0 calc(100% - 15.414px))}` +
29
+ `.lockup{display:block;width:clamp(91px,30vw,138px);height:auto;margin:0 0 28px -10px;color:#f5f5f5}` +
30
+ `.eyebrow{margin:0 0 12px;font:400 11px/16px 'B612 Mono',ui-monospace,Menlo,Consolas,monospace;letter-spacing:.08em;text-transform:uppercase;color:#bdbdbd}` +
31
+ `.eyebrow b{font-weight:400;color:${status === 200 ? '#8fd3a4' : '#f0b36c'}}` +
32
+ `h1{margin:0 0 10px;font:500 24px/30px 'Creato Display','Helvetica Neue',Arial,sans-serif;letter-spacing:-.3px;text-wrap:balance}` +
33
+ `p{margin:0;color:#bdbdbd}` +
34
+ `@media (max-width:480px){main>div{padding:28px 24px 24px}}`;
35
+ const body = `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">` +
36
+ `<title>${escape(title)}</title><style nonce="${nonce}">${style}</style></head>` +
37
+ `<body><main><div>${LOCKUP.replace('<svg ', '<svg class="lockup" ')}` +
38
+ `<p class="eyebrow">Invited CLI · <b>${tone}</b></p>` +
39
+ `<h1>${escape(title)}</h1><p>${escape(message)}</p></div></main></body></html>`;
40
+ response.writeHead(status, {
41
+ 'Content-Type': 'text/html; charset=utf-8',
42
+ 'Cache-Control': 'no-store',
43
+ 'Content-Security-Policy': `default-src 'none'; style-src 'nonce-${nonce}'`,
44
+ 'X-Content-Type-Options': 'nosniff',
45
+ 'Referrer-Policy': 'no-referrer',
46
+ Connection: 'close',
47
+ });
48
+ response.end(body, after);
49
+ }
50
+ export async function listenForCallback(options) {
51
+ let settle;
52
+ const outcome = new Promise(resolve => (settle = resolve));
53
+ let finished = false;
54
+ let answered = null;
55
+ let lingerTimer;
56
+ const server = createServer((request, response) => {
57
+ const host = `127.0.0.1:${port}`;
58
+ if (finished) {
59
+ // The same answer again (a browser retrying its navigation) sees the
60
+ // same page; anything else learns the sign-in is over.
61
+ const again = new URL(request.url ?? '/', `http://${host}`);
62
+ if (answered && again.pathname === '/callback' && again.searchParams.get('state') === options.state)
63
+ return page(response, answered.status, answered.title, answered.message);
64
+ return page(response, 410, 'Already finished', 'This sign-in has finished. Return to the terminal.');
65
+ }
66
+ if (request.headers.host !== host)
67
+ return page(response, 400, 'Not this sign-in', 'This address is only for the Invited CLI on this computer.');
68
+ if (request.method !== 'GET')
69
+ return page(response, 405, 'Not allowed', 'Return to the terminal.');
70
+ const url = new URL(request.url ?? '/', `http://${host}`);
71
+ if (url.pathname !== '/callback')
72
+ return page(response, 404, 'Not found', 'Return to the terminal.');
73
+ const query = url.searchParams;
74
+ // Another request (a stray tab, a page probing ports) changes nothing.
75
+ if (query.getAll('state').length !== 1 || query.get('state') !== options.state)
76
+ return page(response, 400, 'Not this sign-in', 'This answer does not belong to the sign-in the terminal started. Return to the terminal.');
77
+ const finish = (status, title, message, result) => {
78
+ finished = true;
79
+ answered = { status, title, message };
80
+ page(response, status, title, message, () => settle(result));
81
+ };
82
+ const iss = query.get('iss');
83
+ if ((options.requireIss && iss === null) || (iss !== null && iss !== options.issuer))
84
+ return finish(400, 'Refused', 'The answer did not come from Invited. Return to the terminal.', {
85
+ ok: false,
86
+ message: `The browser's answer came from ${iss ?? 'an unnamed server'}, not ${options.issuer}, so it was refused. Run \`invited login\` again.`,
87
+ });
88
+ const error = query.get('error');
89
+ if (error !== null) {
90
+ const description = query.get('error_description');
91
+ return finish(400, 'Not connected', 'The Invited CLI was not connected. Return to the terminal.', {
92
+ ok: false,
93
+ message: error === 'access_denied'
94
+ ? 'You cancelled the connection in your browser, so nothing was connected.'
95
+ : `Invited did not connect the CLI (${error}${description ? ': ' + description : ''}).`,
96
+ });
97
+ }
98
+ const code = query.get('code');
99
+ if (!code)
100
+ return finish(400, 'Not connected', 'The answer had no code. Return to the terminal.', {
101
+ ok: false,
102
+ message: 'The browser came back without a sign-in code. Run `invited login` again.',
103
+ });
104
+ return finish(200, 'Invited CLI connected', 'You can close this tab and return to the terminal.', {
105
+ ok: true,
106
+ code,
107
+ });
108
+ });
109
+ await new Promise((resolve, reject) => {
110
+ server.once('error', reject);
111
+ server.listen(0, '127.0.0.1', () => {
112
+ server.off('error', reject);
113
+ resolve();
114
+ });
115
+ });
116
+ const port = server.address().port;
117
+ const timer = setTimeout(() => {
118
+ finished = true;
119
+ settle({
120
+ ok: false,
121
+ message: `No answer came from your browser within ${duration(options.timeoutMs)}. Run \`invited login\` again.`,
122
+ });
123
+ close();
124
+ }, options.timeoutMs);
125
+ /** Once the browser has been answered, closing lingers a moment: the page
126
+ * finishes reaching the browser (it was once cut off mid-response), and a
127
+ * browser that retries its navigation gets the same page again instead of
128
+ * "This site can't be reached". The time limit closes at once. */
129
+ function close(force = false) {
130
+ clearTimeout(timer);
131
+ if (!force && answered) {
132
+ lingerTimer ??= setTimeout(() => close(true), options.lingerMs ?? 1_500);
133
+ return;
134
+ }
135
+ clearTimeout(lingerTimer);
136
+ server.close();
137
+ server.closeAllConnections();
138
+ }
139
+ return { redirectUri: `http://127.0.0.1:${port}/callback`, port, outcome, close: () => close() };
140
+ }