@invitedlabs/cli 0.0.0-stage → 0.2.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 logs 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 logs in to Invited itself: in Cursor, open Customize > MCP, log 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 logs 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 logs in to Invited itself: start gemini and approve in your browser when it connects (/mcp auth invited logs 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, the Missing piece and the wordmark (public/brand/missing-piece/lockup-ink.svg, the Figma
6
+ * kit's drawing), drawn in the page's ink. */
7
+ const LOCKUP = '<svg viewBox="69 90.25 394.75 105.75" fill="none" xmlns="http://www.w3.org/2000/svg" aria-label="Invited" role="img">' +
8
+ '<path fill-rule="evenodd" clip-rule="evenodd" d="M70.2305 150.001C70.2305 161.904 74.9589 173.319 83.3755 181.736C91.7921 190.153 103.208 194.881 115.11 194.881C127.013 194.881 138.429 190.153 146.845 181.736C155.262 173.319 159.99 161.904 159.99 150.001C159.99 138.098 155.262 126.683 146.845 118.266C138.429 109.85 127.013 105.121 115.11 105.121C103.208 105.121 91.7921 109.85 83.3755 118.266C74.9589 126.683 70.2305 138.098 70.2305 150.001ZM118.898 129.349C118.898 125.521 119.959 124.453 123.794 124.453H135.762C139.59 124.453 140.658 125.521 140.658 129.349V141.317C140.658 145.153 139.59 146.213 135.762 146.213H123.794C119.959 146.213 118.898 145.153 118.898 141.317V129.349Z" fill="currentColor"/>' +
9
+ '<path d="M149.268 100.665C148.336 96.9456 149.111 95.6604 152.831 94.7288L164.445 91.832C168.165 90.9072 169.45 91.6824 170.382 95.402L173.278 107.016C174.203 110.736 173.428 112.021 169.708 112.946L158.094 115.843C154.374 116.774 153.089 115.999 152.164 112.28L149.268 100.665Z" fill="currentColor"/>' +
10
+ '<path d="M211.085 184.179H198.029V115.827H211.085V184.179ZM261.975 184.179H249.591V153.843C249.591 147.123 247.191 143.763 241.143 143.763C234.615 143.763 230.391 148.467 230.391 156.531V184.179H218.007V134.643H229.815V142.707C232.503 137.427 236.919 133.683 244.983 133.683C256.311 133.683 261.975 139.443 261.975 151.443V184.179ZM295.223 184.179H280.247L261.911 134.643H274.871L287.735 173.331L300.599 134.643H313.559L295.223 184.179ZM328.225 184.179H315.841V134.643H328.225V184.179ZM322.081 127.251C317.473 127.251 314.881 124.467 314.881 120.819C314.881 117.171 317.473 114.387 322.081 114.387C326.689 114.387 329.185 117.171 329.185 120.819C329.185 124.467 326.689 127.251 322.081 127.251ZM353.535 184.659C342.303 184.659 338.559 181.203 338.559 170.259V143.475H330.783V134.643H338.559V119.667H350.943V134.643H361.695V143.475H350.943V170.739C350.943 174.579 352.575 175.923 356.511 175.923L361.695 175.827V184.179C359.679 184.371 355.743 184.659 353.535 184.659ZM386.843 185.139C370.715 185.139 361.691 175.059 361.691 159.411C361.691 144.147 370.331 133.683 386.267 133.683C402.299 133.683 410.939 144.243 410.939 158.835C410.939 159.891 410.843 160.851 410.747 162.003H374.075C374.459 172.083 379.259 176.691 386.843 176.691C392.987 176.691 397.019 173.619 398.459 168.531H409.883C407.771 178.707 400.187 185.139 386.843 185.139ZM386.267 142.131C379.451 142.131 375.131 146.067 374.075 153.939H398.555C397.499 146.067 393.083 142.131 386.267 142.131ZM435.073 185.139C421.729 185.139 413.281 175.635 413.281 159.411C413.281 143.187 421.729 133.683 435.073 133.683C442.561 133.683 447.553 136.755 450.337 142.131V115.827H462.721V184.179H450.912V175.731C448.225 181.491 443.329 185.139 435.073 185.139ZM437.953 175.731C445.345 175.731 450.337 170.739 450.337 159.411C450.337 148.083 445.345 143.091 437.953 143.091C430.657 143.091 425.665 148.083 425.665 159.411C425.665 170.739 430.657 175.731 437.953 175.731Z" 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(70px,23vw,107px);height:auto;margin:0 0 28px;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 log-in has finished. Return to the terminal.');
65
+ }
66
+ if (request.headers.host !== host)
67
+ return page(response, 400, 'Not this log-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 log-in', 'This answer does not belong to the log-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 log-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
+ }