@thegitai/cli 1.0.0-preview.5 → 1.0.0-preview.50
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/README.md +93 -4
- package/dist/bin/ai.js +142 -112
- package/dist/bin/browser-host.js +265 -0
- package/dist/src/agent-mode.js +32 -7
- package/dist/src/api/auth.js +10 -4
- package/dist/src/api/browser-login.js +149 -37
- package/dist/src/api/chat.js +680 -166
- package/dist/src/api/contracts.js +55 -1
- package/dist/src/api/default-host.js +1 -0
- package/dist/src/api/http.js +151 -7
- package/dist/src/api/models.js +29 -10
- package/dist/src/api/startup-handshake.js +36 -0
- package/dist/src/api/turn-recovery.js +189 -0
- package/dist/src/background-jobs.js +4 -2
- package/dist/src/browser/bridge.js +262 -0
- package/dist/src/browser/framing.js +42 -0
- package/dist/src/browser/native-host.js +219 -0
- package/dist/src/browser/protocol.js +46 -0
- package/dist/src/browser/session-bridge.js +130 -0
- package/dist/src/cli-args.js +19 -5
- package/dist/src/client-environment.js +2 -0
- package/dist/src/core/clipboard.js +7 -13
- package/dist/src/core/image-limits.js +56 -0
- package/dist/src/core/image-path-extractor.js +70 -3
- package/dist/src/core/session-image-store.js +246 -0
- package/dist/src/edit-journal.js +33 -2
- package/dist/src/executor.js +25 -3
- package/dist/src/help-text.js +112 -21
- package/dist/src/patcher.js +43 -4
- package/dist/src/permissions.js +288 -0
- package/dist/src/preferences.js +39 -0
- package/dist/src/quota-notice.js +35 -0
- package/dist/src/session-safety.js +0 -12
- package/dist/src/session-store.js +121 -20
- package/dist/src/session.js +19 -4
- package/dist/src/signin.js +58 -0
- package/dist/src/tool-executor.js +13 -46
- package/dist/src/tools/browser.js +628 -0
- package/dist/src/tools/delete-file.js +20 -4
- package/dist/src/tools/index.js +32 -10
- package/dist/src/tools/patch-file.js +12 -26
- package/dist/src/tools/read-image-file.js +85 -0
- package/dist/src/tools/replace-document-text.js +28 -18
- package/dist/src/tools/restore-checkpoint.js +0 -1
- package/dist/src/tools/run-command.js +14 -71
- package/dist/src/tools/run-node-script.js +12 -81
- package/dist/src/tools/save-generated-image.js +120 -0
- package/dist/src/tools/str-replace.js +12 -26
- package/dist/src/tools/undo-edit.js +1 -6
- package/dist/src/tools/write-file.js +67 -11
- package/dist/src/ui/prompt-history-store.js +1 -1
- package/dist/src/ui/repl.js +949 -279
- package/dist/src/ui/slash-commands.js +121 -0
- package/dist/src/ui/tui/attention.js +25 -0
- package/dist/src/ui/tui/bridge.js +38 -5
- package/dist/src/ui/tui/build-frame.js +686 -223
- package/dist/src/ui/tui/composer-layout.js +76 -0
- package/dist/src/ui/tui/desktop-notification.js +118 -0
- package/dist/src/ui/tui/markdown-render.js +11 -2
- package/dist/src/ui/tui/shell-input.js +256 -73
- package/dist/src/ui/tui/shortcuts.js +61 -0
- package/dist/src/ui/tui/terminal-theme.js +28 -0
- package/dist/src/ui/tui/user-input.js +593 -0
- package/dist/src/update-check.js +205 -0
- package/dist/src/usage.js +5 -2
- package/dist/src/utils.js +13 -0
- package/dist/src/version.js +52 -2
- package/package.json +22 -9
- package/dist/src/project-index.js +0 -233
- package/dist/src/tools/code-intel.js +0 -472
- package/dist/src/tools/find-symbol.js +0 -70
- package/dist/src/tools/hover-symbol.js +0 -95
- package/dist/src/tools/list-symbols.js +0 -55
- package/dist/src/tools/search-code.js +0 -37
- 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}`);
|
package/dist/src/agent-mode.js
CHANGED
|
@@ -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 '
|
|
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
|
+
}
|
package/dist/src/api/auth.js
CHANGED
|
@@ -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
|
-
|
|
7
|
-
|
|
8
|
-
const DEFAULT_TIMEOUT_MS =
|
|
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
|
|
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
|
-
|
|
37
|
-
|
|
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 ??
|
|
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
|
-
|
|
177
|
+
timer = setTimeout(() => {
|
|
102
178
|
shutDownServer(server);
|
|
103
|
-
reject(new Error('
|
|
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
|
|
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.
|
|
148
|
-
|
|
149
|
-
|
|
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
|
}
|