@thegitai/cli 1.0.0-preview.5 → 1.0.0-preview.51

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.
Files changed (76) hide show
  1. package/README.md +93 -4
  2. package/dist/bin/ai.js +142 -112
  3. package/dist/bin/browser-host.js +265 -0
  4. package/dist/src/agent-mode.js +32 -7
  5. package/dist/src/api/auth.js +10 -4
  6. package/dist/src/api/browser-login.js +149 -37
  7. package/dist/src/api/chat.js +680 -166
  8. package/dist/src/api/contracts.js +55 -1
  9. package/dist/src/api/default-host.js +1 -0
  10. package/dist/src/api/http.js +151 -7
  11. package/dist/src/api/models.js +29 -10
  12. package/dist/src/api/startup-handshake.js +36 -0
  13. package/dist/src/api/turn-recovery.js +189 -0
  14. package/dist/src/background-jobs.js +4 -2
  15. package/dist/src/browser/bridge.js +262 -0
  16. package/dist/src/browser/framing.js +42 -0
  17. package/dist/src/browser/native-host.js +219 -0
  18. package/dist/src/browser/protocol.js +46 -0
  19. package/dist/src/browser/session-bridge.js +130 -0
  20. package/dist/src/cli-args.js +19 -5
  21. package/dist/src/client-environment.js +2 -0
  22. package/dist/src/core/clipboard.js +7 -13
  23. package/dist/src/core/image-limits.js +56 -0
  24. package/dist/src/core/image-path-extractor.js +70 -3
  25. package/dist/src/core/session-image-store.js +246 -0
  26. package/dist/src/edit-journal.js +33 -2
  27. package/dist/src/executor.js +25 -3
  28. package/dist/src/help-text.js +112 -21
  29. package/dist/src/patcher.js +43 -4
  30. package/dist/src/permissions.js +288 -0
  31. package/dist/src/preferences.js +39 -0
  32. package/dist/src/quota-notice.js +35 -0
  33. package/dist/src/session-safety.js +0 -12
  34. package/dist/src/session-store.js +121 -20
  35. package/dist/src/session.js +19 -4
  36. package/dist/src/signin.js +58 -0
  37. package/dist/src/tool-executor.js +13 -46
  38. package/dist/src/tools/browser.js +641 -0
  39. package/dist/src/tools/delete-file.js +20 -4
  40. package/dist/src/tools/index.js +32 -10
  41. package/dist/src/tools/patch-file.js +12 -26
  42. package/dist/src/tools/read-file.js +5 -0
  43. package/dist/src/tools/read-image-file.js +85 -0
  44. package/dist/src/tools/replace-document-text.js +28 -18
  45. package/dist/src/tools/restore-checkpoint.js +0 -1
  46. package/dist/src/tools/run-command.js +14 -71
  47. package/dist/src/tools/run-node-script.js +12 -81
  48. package/dist/src/tools/save-generated-image.js +120 -0
  49. package/dist/src/tools/str-replace.js +12 -26
  50. package/dist/src/tools/undo-edit.js +1 -6
  51. package/dist/src/tools/write-file.js +67 -11
  52. package/dist/src/ui/prompt-history-store.js +1 -1
  53. package/dist/src/ui/repl.js +949 -279
  54. package/dist/src/ui/slash-commands.js +121 -0
  55. package/dist/src/ui/tui/attention.js +25 -0
  56. package/dist/src/ui/tui/bridge.js +38 -5
  57. package/dist/src/ui/tui/build-frame.js +686 -223
  58. package/dist/src/ui/tui/composer-layout.js +76 -0
  59. package/dist/src/ui/tui/desktop-notification.js +118 -0
  60. package/dist/src/ui/tui/markdown-render.js +11 -2
  61. package/dist/src/ui/tui/shell-input.js +256 -73
  62. package/dist/src/ui/tui/shortcuts.js +61 -0
  63. package/dist/src/ui/tui/terminal-theme.js +28 -0
  64. package/dist/src/ui/tui/user-input.js +593 -0
  65. package/dist/src/update-check.js +205 -0
  66. package/dist/src/usage.js +5 -2
  67. package/dist/src/utils.js +13 -0
  68. package/dist/src/version.js +52 -2
  69. package/package.json +22 -9
  70. package/dist/src/project-index.js +0 -233
  71. package/dist/src/tools/code-intel.js +0 -472
  72. package/dist/src/tools/find-symbol.js +0 -70
  73. package/dist/src/tools/hover-symbol.js +0 -95
  74. package/dist/src/tools/list-symbols.js +0 -55
  75. package/dist/src/tools/search-code.js +0 -37
  76. package/dist/src/tools/signature-help.js +0 -118
@@ -0,0 +1,265 @@
1
+ #!/usr/bin/env node
2
+ import { execFileSync } from 'node:child_process';
3
+ import crypto from 'node:crypto';
4
+ import fs from 'node:fs';
5
+ import net from 'node:net';
6
+ import path from 'node:path';
7
+ import { decodeFrames, encodeFrame, fragmentPayload, MAX_FRAME_BYTES } from '../src/browser/framing.js';
8
+ import { BROWSER_PROTOCOL_VERSION, browserRuntimeDir, sessionKeyFromRecordFile, sessionSocketPath, socketIsFile, } from '../src/browser/protocol.js';
9
+ const SCAN_INTERVAL_MS = 1000;
10
+ const NUL = String.fromCharCode(0);
11
+ const sessions = new Map();
12
+ const runtimeDir = browserRuntimeDir(process.env);
13
+ let extensionVersion = '';
14
+ let extensionProfileId = '';
15
+ let extensionPlatform = '';
16
+ function log(message) {
17
+ process.stderr.write(`[thegitai-browser-host] ${message}\n`);
18
+ }
19
+ function writeToExtension(frame) {
20
+ const payload = Buffer.from(JSON.stringify(frame), 'utf8');
21
+ if (payload.length > MAX_FRAME_BYTES) {
22
+ for (const fragment of fragmentPayload(payload, crypto.randomUUID())) {
23
+ writeToExtension(fragment);
24
+ }
25
+ return;
26
+ }
27
+ process.stdout.write(encodeFrame(payload));
28
+ }
29
+ let inbound = Buffer.alloc(0);
30
+ process.stdin.on('data', (chunk) => {
31
+ inbound = Buffer.concat([inbound, Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk, 'utf8')]);
32
+ const { frames, rest, unparseable } = decodeFrames(inbound);
33
+ inbound = rest;
34
+ for (let i = 0; i < unparseable; i += 1) {
35
+ log('discarded an unparseable frame from the extension');
36
+ }
37
+ for (const frame of frames)
38
+ handleExtensionFrame(frame);
39
+ });
40
+ process.stdin.on('end', () => {
41
+ for (const link of sessions.values())
42
+ link.socket.destroy();
43
+ process.exit(0);
44
+ });
45
+ function describeParentBrowser() {
46
+ const cmdline = process.platform === 'win32' ? windowsParentCommandLine() : unixParentCommandLine();
47
+ return describeFromCommandLine(cmdline);
48
+ }
49
+ function windowsParentCommandLine() {
50
+ const PASS_THROUGH = /^(cmd|conhost|powershell|pwsh|node)\.exe$/i;
51
+ try {
52
+ const rows = execFileSync('powershell.exe', [
53
+ '-NoProfile',
54
+ '-NonInteractive',
55
+ '-Command',
56
+ 'Get-CimInstance Win32_Process | ForEach-Object {' +
57
+ ' "{0}`t{1}`t{2}`t{3}" -f $_.ProcessId, $_.ParentProcessId, $_.Name, $_.CommandLine }',
58
+ ], {
59
+ encoding: 'utf8',
60
+ timeout: 15000,
61
+ maxBuffer: 8 * 1024 * 1024,
62
+ stdio: ['ignore', 'pipe', 'ignore'],
63
+ });
64
+ const byPid = new Map();
65
+ for (const line of rows.split(/\r?\n/)) {
66
+ const [pid, parent, name, ...rest] = line.split('\t');
67
+ const id = Number(pid);
68
+ if (!Number.isInteger(id))
69
+ continue;
70
+ byPid.set(id, {
71
+ parent: Number(parent),
72
+ name: String(name ?? ''),
73
+ command: rest.join('\t'),
74
+ });
75
+ }
76
+ let current = byPid.get(process.ppid);
77
+ for (let hop = 0; hop < 4 && current && PASS_THROUGH.test(current.name); hop += 1) {
78
+ current = byPid.get(current.parent);
79
+ }
80
+ if (!current?.command)
81
+ return [];
82
+ return (current.command.match(/"[^"]*"|\S+/g) ?? []).map((token) => token.startsWith('"') && token.endsWith('"') ? token.slice(1, -1) : token);
83
+ }
84
+ catch {
85
+ return [];
86
+ }
87
+ }
88
+ function unixParentCommandLine() {
89
+ try {
90
+ return fs.readFileSync(`/proc/${process.ppid}/cmdline`, 'utf8').split(NUL);
91
+ }
92
+ catch {
93
+ return [];
94
+ }
95
+ }
96
+ function describeFromCommandLine(cmdline) {
97
+ try {
98
+ if (cmdline.length === 0)
99
+ throw new Error('no command line');
100
+ const brand = path
101
+ .basename(cmdline[0] ?? '')
102
+ .replace(/\.exe$/i, '')
103
+ .replace(/[-_](browser|stable|beta|dev|unstable|bin)$/i, '')
104
+ .replace(/[-_]+/g, ' ')
105
+ .replace(/\b[a-z]/g, (letter) => letter.toUpperCase())
106
+ .trim() || 'Chrome';
107
+ const profileArg = cmdline.find((part) => part.startsWith('--profile-directory='));
108
+ const userDataArg = cmdline.find((part) => part.startsWith('--user-data-dir='));
109
+ const profile = profileArg ? profileArg.slice('--profile-directory='.length) : 'Default';
110
+ const suffix = userDataArg
111
+ ? ` (${path.basename(userDataArg.slice('--user-data-dir='.length))})`
112
+ : '';
113
+ return { browser: brand, profile: `${profile}${suffix}` };
114
+ }
115
+ catch {
116
+ return { browser: 'Chrome', profile: 'Default' };
117
+ }
118
+ }
119
+ const parent = describeParentBrowser();
120
+ function browserKey() {
121
+ const seed = extensionProfileId || `${parent.browser} ${parent.profile}`;
122
+ return crypto.createHash('sha256').update(seed).digest('hex').slice(0, 12);
123
+ }
124
+ function identity() {
125
+ return {
126
+ key: browserKey(),
127
+ browser: parent.browser,
128
+ profile: parent.profile !== 'Default' || !extensionProfileId
129
+ ? parent.profile
130
+ : `profile ${extensionProfileId.slice(0, 6)}`,
131
+ platform: extensionPlatform || process.platform,
132
+ extensionVersion,
133
+ };
134
+ }
135
+ function handleExtensionFrame(frame) {
136
+ if (frame?.t === 'hello') {
137
+ extensionVersion = String(frame.extensionVersion ?? '');
138
+ extensionProfileId = String(frame.profileId ?? '');
139
+ extensionPlatform = String(frame.platform ?? '');
140
+ writeToExtension({ t: 'hello', protocol: BROWSER_PROTOCOL_VERSION });
141
+ publishSessions();
142
+ for (const link of sessions.values())
143
+ sendHello(link);
144
+ return;
145
+ }
146
+ if (frame?.t === 'res' || frame?.t === 'event') {
147
+ const target = frame.session ? sessions.get(frame.session) : null;
148
+ const { session: _routed, ...rest } = frame;
149
+ if (target) {
150
+ writeToSession(target, rest);
151
+ return;
152
+ }
153
+ if (frame.t === 'event') {
154
+ for (const link of sessions.values())
155
+ writeToSession(link, rest);
156
+ }
157
+ }
158
+ }
159
+ function writeToSession(link, frame) {
160
+ try {
161
+ link.socket.write(`${JSON.stringify(frame)}\n`);
162
+ }
163
+ catch {
164
+ }
165
+ }
166
+ function sendHello(link) {
167
+ writeToSession(link, { t: 'hello', protocol: BROWSER_PROTOCOL_VERSION, browser: identity() });
168
+ }
169
+ function publishSessions() {
170
+ writeToExtension({
171
+ t: 'sessions',
172
+ sessions: [...sessions.values()].map((link) => ({
173
+ session: link.key,
174
+ label: link.record.label,
175
+ cwd: link.record.cwd,
176
+ })),
177
+ });
178
+ }
179
+ function connectToSession(record, socketPath) {
180
+ if (sessions.has(record.session))
181
+ return;
182
+ const socket = net.createConnection(socketPath);
183
+ socket.setNoDelay(true);
184
+ const link = { key: record.session, socket, record };
185
+ socket.on('connect', () => {
186
+ sessions.set(record.session, link);
187
+ sendHello(link);
188
+ publishSessions();
189
+ log(`attached to session ${record.session} (${record.cwd})`);
190
+ });
191
+ let buffer = '';
192
+ socket.on('data', (chunk) => {
193
+ buffer += chunk.toString('utf8');
194
+ let newline = buffer.indexOf('\n');
195
+ while (newline !== -1) {
196
+ const line = buffer.slice(0, newline);
197
+ buffer = buffer.slice(newline + 1);
198
+ newline = buffer.indexOf('\n');
199
+ if (!line.trim())
200
+ continue;
201
+ let frame;
202
+ try {
203
+ frame = JSON.parse(line);
204
+ }
205
+ catch {
206
+ continue;
207
+ }
208
+ writeToExtension({ ...frame, session: record.session });
209
+ }
210
+ });
211
+ const drop = () => {
212
+ if (sessions.get(record.session) === link) {
213
+ sessions.delete(record.session);
214
+ publishSessions();
215
+ }
216
+ socket.destroy();
217
+ };
218
+ socket.on('close', drop);
219
+ socket.on('error', () => {
220
+ drop();
221
+ });
222
+ }
223
+ function scanForSessions() {
224
+ let entries;
225
+ try {
226
+ entries = fs.readdirSync(runtimeDir);
227
+ }
228
+ catch {
229
+ return;
230
+ }
231
+ for (const entry of entries) {
232
+ const sessionKey = sessionKeyFromRecordFile(entry);
233
+ if (!sessionKey)
234
+ continue;
235
+ const recordPath = path.join(runtimeDir, entry);
236
+ let record;
237
+ try {
238
+ record = JSON.parse(fs.readFileSync(recordPath, 'utf8'));
239
+ }
240
+ catch {
241
+ continue;
242
+ }
243
+ if (record.protocol !== BROWSER_PROTOCOL_VERSION)
244
+ continue;
245
+ try {
246
+ process.kill(record.pid, 0);
247
+ }
248
+ catch (error) {
249
+ if (error?.code !== 'EPERM') {
250
+ try {
251
+ fs.unlinkSync(recordPath);
252
+ if (socketIsFile())
253
+ fs.unlinkSync(sessionSocketPath(runtimeDir, sessionKey));
254
+ }
255
+ catch {
256
+ }
257
+ continue;
258
+ }
259
+ }
260
+ connectToSession(record, sessionSocketPath(runtimeDir, sessionKey));
261
+ }
262
+ }
263
+ setInterval(scanForSessions, SCAN_INTERVAL_MS);
264
+ scanForSessions();
265
+ log(`started for ${parent.browser} / ${parent.profile}`);
@@ -1,19 +1,24 @@
1
1
  export const AGENT_MODES = ['default', 'auto-accept', 'plan'];
2
2
  const PLAN_MODE_TOOL_NAMES = new Set([
3
- 'search_code',
4
3
  'list_files',
5
4
  'list_directories',
6
5
  'read_file',
7
6
  'grep_code',
8
- 'find_symbol',
9
- 'list_symbols',
10
- 'hover_symbol',
11
- 'signature_help',
12
7
  'read_document',
13
8
  'analyze_image',
14
9
  'run_command',
15
10
  'shell_job_output',
16
11
  'update_todos',
12
+ 'browser',
13
+ 'browser_tabs',
14
+ 'browser_read_page',
15
+ 'browser_get_page_text',
16
+ 'browser_find',
17
+ 'browser_console',
18
+ 'browser_downloads',
19
+ 'browser_network',
20
+ 'browser_screenshot',
21
+ 'browser_batch',
17
22
  ]);
18
23
  const PLAN_MODE_RUN_COMMAND_NAMES = new Set([
19
24
  'pwd',
@@ -46,7 +51,7 @@ export function nextAgentMode(mode) {
46
51
  export function agentModeAllowsTool(mode, toolName) {
47
52
  return mode !== 'plan' || PLAN_MODE_TOOL_NAMES.has(toolName);
48
53
  }
49
- function getUnquotedShellText(command) {
54
+ export function getUnquotedShellText(command) {
50
55
  let quote = null;
51
56
  let escaped = false;
52
57
  let text = '';
@@ -140,8 +145,28 @@ export function buildPlanModeToolBlockedResult(toolName, action = 'Plan mode is
140
145
  }
141
146
  export function agentModeLabel(mode) {
142
147
  if (mode === 'auto-accept')
143
- return 'Auto-Accept';
148
+ return 'Bypass all permissions';
144
149
  if (mode === 'plan')
145
150
  return 'Plan · read-only';
151
+ return 'Default · Ask for Approval';
152
+ }
153
+ export function agentModeLabelTiers(mode) {
154
+ return [agentModeLabel(mode)];
155
+ }
156
+ export function agentModeShortLabel(mode) {
157
+ if (mode === 'auto-accept')
158
+ return 'Bypass';
159
+ if (mode === 'plan')
160
+ return 'Plan';
146
161
  return 'Default';
147
162
  }
163
+ export function agentModeColor(mode) {
164
+ if (mode === 'auto-accept')
165
+ return 'red';
166
+ if (mode === 'plan')
167
+ return 'yellow';
168
+ return 'cyan';
169
+ }
170
+ export function agentModeCycleSequence() {
171
+ return AGENT_MODES.map(agentModeShortLabel).join(' → ');
172
+ }
@@ -1,7 +1,7 @@
1
1
  import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync, } from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { getClientStateDir } from '../client-state.js';
4
- import { ServerApiError, authorizedJson, createTraceContext, failureMessage, normalizeServerUrl, readJsonResponse, retryTransient, } from './http.js';
4
+ import { ServerApiError, authorizedJson, createTraceContext, failureCode, failureMessage, normalizeServerUrl, readJsonResponse, retryTransient, } from './http.js';
5
5
  export function getAuthConfigPath(env = process.env) {
6
6
  const configured = String(env.THEGITAI_AUTH_CONFIG ?? '').trim();
7
7
  if (configured) {
@@ -50,20 +50,26 @@ export async function fetchWhoami({ config, fetchImpl = globalThis.fetch, }) {
50
50
  const data = await fetchWhoamiResponse({ config, fetchImpl });
51
51
  return data.customer;
52
52
  }
53
- export async function fetchWhoamiResponse({ config, fetchImpl = globalThis.fetch, }) {
53
+ export async function fetchWhoamiResponse({ config, fetchImpl = globalThis.fetch, budget = {}, }) {
54
+ const { timeoutMs, ...ladder } = budget;
54
55
  const data = (await retryTransient(() => authorizedJson({
55
56
  config,
56
57
  path: '/v1/auth/whoami',
57
58
  fetchImpl,
58
- })));
59
+ ...(timeoutMs == null ? {} : { timeoutMs }),
60
+ }), ladder));
59
61
  if (!data?.customer?.email) {
60
62
  throw new Error('Server returned an invalid whoami response.');
61
63
  }
64
+ const latestVersion = data.release?.latestVersion;
62
65
  return {
63
66
  customer: data.customer,
64
67
  debugUi: {
65
68
  showSessionId: data.debugUi?.showSessionId === true,
66
69
  },
70
+ ...(typeof latestVersion === 'string' && latestVersion
71
+ ? { release: { latestVersion } }
72
+ : {}),
67
73
  ...(data.usage ? { usage: data.usage } : {}),
68
74
  };
69
75
  }
@@ -78,6 +84,6 @@ export async function logoutFromServer({ config, fetchImpl = globalThis.fetch, }
78
84
  });
79
85
  if (!response.ok && response.status !== 401) {
80
86
  const data = (await readJsonResponse(response));
81
- throw new ServerApiError(failureMessage(data, response.status), response.status, trace.traceId);
87
+ throw new ServerApiError(failureMessage(data, response.status), response.status, trace.traceId, failureCode(data));
82
88
  }
83
89
  }
@@ -1,17 +1,28 @@
1
+ import { execSync } from 'node:child_process';
1
2
  import crypto from 'node:crypto';
3
+ import { readFileSync } from 'node:fs';
2
4
  import http from 'node:http';
3
5
  import os from 'node:os';
4
6
  import { openUrl } from '../core/open-url.js';
5
- import { ServerApiError, createTraceContext, failureMessage, normalizeServerUrl, readJsonResponse, } from './http.js';
6
- const DEFAULT_WEBSITE_URL = 'https://thegit.ai';
7
- const DEFAULT_SERVER_URL = 'https://thegit.ai';
8
- const DEFAULT_TIMEOUT_MS = 5 * 60 * 1000;
7
+ import { ServerApiError, createTraceContext, failureCode, failureMessage, normalizeServerUrl, readJsonResponse, } from './http.js';
8
+ import { DEFAULT_THEGITAI_HOST } from './default-host.js';
9
+ import { isAbortError } from '../utils.js';
10
+ const DEFAULT_TIMEOUT_MS = 10 * 60 * 1000;
9
11
  function shutDownServer(server) {
10
12
  server.closeAllConnections?.();
11
13
  server.close();
12
14
  }
15
+ export class SignInCancelledError extends Error {
16
+ constructor() {
17
+ super('Sign-in cancelled.');
18
+ this.name = 'SignInCancelledError';
19
+ }
20
+ }
21
+ export function isSignInCancelled(error) {
22
+ return error instanceof SignInCancelledError;
23
+ }
13
24
  export function resolveWebsiteUrl() {
14
- return DEFAULT_WEBSITE_URL.replace(/\/+$/, '');
25
+ return DEFAULT_THEGITAI_HOST.replace(/\/+$/, '');
15
26
  }
16
27
  function defaultDeviceName() {
17
28
  try {
@@ -21,6 +32,73 @@ function defaultDeviceName() {
21
32
  return os.hostname();
22
33
  }
23
34
  }
35
+ function readLinuxOsPrettyName() {
36
+ try {
37
+ const content = readFileSync('/etc/os-release', 'utf8');
38
+ return content.match(/^PRETTY_NAME="?([^"\n]*)"?$/m)?.[1]?.trim() ?? '';
39
+ }
40
+ catch {
41
+ return '';
42
+ }
43
+ }
44
+ function macArchLabel() {
45
+ try {
46
+ if (process.arch === 'arm64')
47
+ return 'Apple Silicon';
48
+ if (os.cpus().some((cpu) => cpu.model.includes('Apple'))) {
49
+ return 'Apple Silicon';
50
+ }
51
+ }
52
+ catch {
53
+ }
54
+ return 'Intel';
55
+ }
56
+ export function describeOperatingSystem() {
57
+ try {
58
+ if (process.platform === 'linux') {
59
+ const base = readLinuxOsPrettyName() || `Linux ${os.release()}`;
60
+ return process.arch === 'arm64' ? `${base}, ARM64` : base;
61
+ }
62
+ if (process.platform === 'darwin') {
63
+ let version = '';
64
+ try {
65
+ version = execSync('sw_vers -productVersion', {
66
+ stdio: ['ignore', 'pipe', 'ignore'],
67
+ })
68
+ .toString()
69
+ .trim();
70
+ }
71
+ catch {
72
+ }
73
+ return `${version ? `macOS ${version}` : 'macOS'}, ${macArchLabel()}`;
74
+ }
75
+ if (process.platform === 'win32') {
76
+ let label = '';
77
+ try {
78
+ label = os.version();
79
+ }
80
+ catch {
81
+ }
82
+ const build = Number(os.release().split('.')[2] ?? '0');
83
+ if (build >= 22000)
84
+ label = label.replace(/Windows 10/i, 'Windows 11');
85
+ const base = label || `Windows ${os.release()}`;
86
+ return process.arch === 'arm64' ? `${base}, ARM64` : base;
87
+ }
88
+ return `${process.platform} ${os.release()}`;
89
+ }
90
+ catch {
91
+ return process.platform;
92
+ }
93
+ }
94
+ export function withOperatingSystemInfo(name) {
95
+ const trimmed = name.trim();
96
+ const osLabel = describeOperatingSystem();
97
+ const combined = osLabel && !trimmed.includes(osLabel)
98
+ ? `${trimmed} (${osLabel})`
99
+ : trimmed;
100
+ return combined.slice(0, 180);
101
+ }
24
102
  export function generatePkce() {
25
103
  const verifier = crypto.randomBytes(32).toString('base64url');
26
104
  const challenge = crypto
@@ -33,12 +111,8 @@ function buildAuthUrl(websiteUrl, params) {
33
111
  const url = new URL(`${websiteUrl}/cli-auth`);
34
112
  url.searchParams.set('code_challenge', params.codeChallenge);
35
113
  url.searchParams.set('device_name', params.deviceName);
36
- if (params.redirectUri)
37
- url.searchParams.set('redirect_uri', params.redirectUri);
38
- if (params.state)
39
- url.searchParams.set('state', params.state);
40
- if (params.paste)
41
- url.searchParams.set('mode', 'paste');
114
+ url.searchParams.set('redirect_uri', params.redirectUri);
115
+ url.searchParams.set('state', params.state);
42
116
  return url.toString();
43
117
  }
44
118
  const RESULT_PAGE = (heading, detail) => `<!doctype html><html><head><meta charset="utf-8"><title>TheGitAI CLI</title>` +
@@ -56,7 +130,7 @@ async function exchangeCodeForToken({ serverUrl, code, codeVerifier, fetchImpl,
56
130
  });
57
131
  const data = (await readJsonResponse(response));
58
132
  if (!response.ok) {
59
- throw new ServerApiError(failureMessage(data, response.status), response.status, trace.traceId);
133
+ throw new ServerApiError(failureMessage(data, response.status), response.status, trace.traceId, failureCode(data));
60
134
  }
61
135
  const token = String(data?.token ?? '').trim();
62
136
  const customer = data?.customer;
@@ -71,36 +145,38 @@ async function exchangeCodeForToken({ serverUrl, code, codeVerifier, fetchImpl,
71
145
  customer,
72
146
  };
73
147
  }
148
+ async function readPastedResult({ promptCode, signal, serverUrl, codeVerifier, fetchImpl, onPasteRejected, }) {
149
+ while (!signal.aborted) {
150
+ const code = (await promptCode(signal)).trim();
151
+ if (code) {
152
+ try {
153
+ return await exchangeCodeForToken({ serverUrl, code, codeVerifier, fetchImpl });
154
+ }
155
+ catch (error) {
156
+ if (signal.aborted)
157
+ break;
158
+ onPasteRejected?.(error.message);
159
+ }
160
+ }
161
+ await new Promise((resolve) => setImmediate(resolve));
162
+ }
163
+ return await new Promise(() => { });
164
+ }
74
165
  export async function loginViaBrowser(options) {
75
- const serverUrl = normalizeServerUrl(options.serverUrl ?? DEFAULT_SERVER_URL);
166
+ const serverUrl = normalizeServerUrl(options.serverUrl ?? DEFAULT_THEGITAI_HOST);
76
167
  const websiteUrl = resolveWebsiteUrl();
77
168
  const fetchImpl = options.fetchImpl ?? globalThis.fetch;
78
169
  const openBrowser = options.openBrowser ?? openUrl;
79
170
  const onUrl = options.onUrl ?? (() => { });
80
- const deviceName = options.deviceName ?? defaultDeviceName();
171
+ const deviceName = withOperatingSystemInfo(options.deviceName ?? defaultDeviceName());
81
172
  const { verifier, challenge } = generatePkce();
82
- if (options.noBrowser) {
83
- const authUrl = buildAuthUrl(websiteUrl, {
84
- codeChallenge: challenge,
85
- deviceName,
86
- paste: true,
87
- });
88
- onUrl(authUrl);
89
- if (!options.promptCode) {
90
- throw new Error('No way to read the authorization code in this context.');
91
- }
92
- const code = (await options.promptCode()).trim();
93
- if (!code) {
94
- throw new Error('No authorization code was entered.');
95
- }
96
- return exchangeCodeForToken({ serverUrl, code, codeVerifier: verifier, fetchImpl });
97
- }
98
173
  const state = crypto.randomBytes(16).toString('base64url');
99
174
  const server = http.createServer();
175
+ let timer;
100
176
  const codePromise = new Promise((resolve, reject) => {
101
- const timer = setTimeout(() => {
177
+ timer = setTimeout(() => {
102
178
  shutDownServer(server);
103
- reject(new Error('Timed out waiting for the browser login to complete.'));
179
+ reject(new Error('Sign-in timed out. Authorization codes last 10 minutes — run `ai` again to start over.'));
104
180
  }, options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
105
181
  server.on('request', (req, res) => {
106
182
  const requestUrl = new URL(req.url ?? '/', 'http://127.0.0.1');
@@ -113,7 +189,7 @@ export async function loginViaBrowser(options) {
113
189
  const returnedState = requestUrl.searchParams.get('state') ?? '';
114
190
  if (!code || returnedState !== state) {
115
191
  res.writeHead(400, { 'content-type': 'text/html', connection: 'close' });
116
- res.end(RESULT_PAGE('Login failed', 'The request could not be verified. Please run ai login again.'));
192
+ res.end(RESULT_PAGE('Login failed', 'The request could not be verified. Please run `ai` again.'));
117
193
  clearTimeout(timer);
118
194
  shutDownServer(server);
119
195
  reject(new Error('The login callback could not be verified.'));
@@ -143,8 +219,44 @@ export async function loginViaBrowser(options) {
143
219
  state,
144
220
  });
145
221
  onUrl(authUrl);
146
- await openBrowser(authUrl).catch(() => false);
147
- options.onWaiting?.();
148
- const code = await codePromise;
149
- return exchangeCodeForToken({ serverUrl, code, codeVerifier: verifier, fetchImpl });
222
+ const opened = await openBrowser(authUrl).catch(() => false);
223
+ options.onBrowserOpen?.(opened);
224
+ let raceSettled = false;
225
+ const untilSettled = (promise) => promise.catch((error) => {
226
+ if (raceSettled)
227
+ return new Promise(() => { });
228
+ throw error;
229
+ });
230
+ const pasteAbort = new AbortController();
231
+ const routes = [
232
+ untilSettled(codePromise.then((code) => exchangeCodeForToken({ serverUrl, code, codeVerifier: verifier, fetchImpl }))),
233
+ ];
234
+ if (options.promptCode) {
235
+ const pasted = readPastedResult({
236
+ promptCode: options.promptCode,
237
+ signal: pasteAbort.signal,
238
+ serverUrl,
239
+ codeVerifier: verifier,
240
+ fetchImpl,
241
+ onPasteRejected: options.onPasteRejected,
242
+ }).catch((error) => {
243
+ if (!raceSettled && isAbortError(error))
244
+ throw new SignInCancelledError();
245
+ if (!raceSettled) {
246
+ return new Promise(() => { });
247
+ }
248
+ throw error;
249
+ });
250
+ routes.push(untilSettled(pasted));
251
+ }
252
+ try {
253
+ return await Promise.race(routes);
254
+ }
255
+ finally {
256
+ raceSettled = true;
257
+ pasteAbort.abort();
258
+ if (timer)
259
+ clearTimeout(timer);
260
+ shutDownServer(server);
261
+ }
150
262
  }