dgxay-code-cli 0.0.0-stage → 1.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.
Files changed (46) hide show
  1. package/LICENSE +674 -0
  2. package/README-zh.md +242 -0
  3. package/README.md +223 -2
  4. package/bin/dgxay.js +42 -0
  5. package/package.json +47 -4
  6. package/src/cli/ansi.js +539 -0
  7. package/src/cli/approval.js +186 -0
  8. package/src/cli/args.js +162 -0
  9. package/src/cli/command-table.js +52 -0
  10. package/src/cli/editor.js +828 -0
  11. package/src/cli/i18n.js +276 -0
  12. package/src/cli/index.js +228 -0
  13. package/src/cli/print.js +148 -0
  14. package/src/cli/render.js +395 -0
  15. package/src/cli/repl-state.js +85 -0
  16. package/src/cli/repl.js +1231 -0
  17. package/src/cli/slash.js +75 -0
  18. package/src/cli/ui.js +52 -0
  19. package/src/commands/_shared.js +20 -0
  20. package/src/commands/chat.js +212 -0
  21. package/src/commands/config.js +363 -0
  22. package/src/commands/context.js +43 -0
  23. package/src/commands/doctor.js +192 -0
  24. package/src/commands/export.js +102 -0
  25. package/src/commands/mcp.js +257 -0
  26. package/src/commands/model.js +223 -0
  27. package/src/commands/oauth.js +246 -0
  28. package/src/commands/permission.js +108 -0
  29. package/src/commands/provider.js +332 -0
  30. package/src/commands/run.js +172 -0
  31. package/src/commands/skill.js +94 -0
  32. package/src/commands/workspace.js +100 -0
  33. package/src/core/agent.js +420 -0
  34. package/src/core/llm.js +709 -0
  35. package/src/core/mcp.js +271 -0
  36. package/src/core/oauth.js +737 -0
  37. package/src/core/prompt.js +269 -0
  38. package/src/core/sessions.js +208 -0
  39. package/src/core/tools.js +391 -0
  40. package/src/services/config.js +182 -0
  41. package/src/services/input.js +91 -0
  42. package/src/services/session-bus.js +104 -0
  43. package/src/services/settings.js +340 -0
  44. package/src/services/skills.js +125 -0
  45. package/src/services/terminal.js +124 -0
  46. package/src/services/version.js +25 -0
@@ -0,0 +1,737 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * 账户 OAuth 连接(PKCE),CLI 版实现。
5
+ *
6
+ * 与桌面版差异:
7
+ * - 用系统默认浏览器打开授权页(Windows: start / macOS: open / Linux: xdg-open),
8
+ * 打不开时打印授权链接让用户手动打开;
9
+ * - 不再使用 Electron safeStorage:令牌保存在 ~/.dcb/oauth.json(0600),
10
+ * 可用 DGXAY_TOKEN_KEY 设置口令,用 scrypt+AES-256-GCM 加密(enc:'aes')。
11
+ * 若 oauth.json 中存在桌面版 safeStorage 加密的数据(enc:true),CLI 无法解密,
12
+ * 退出码会提示改在桌面版或重新连接。
13
+ *
14
+ * 支持的账户:openai / anthropic / xai / google。
15
+ * 账户连接只提供凭据;是否用于推理由供应商的 authMode 决定。
16
+ */
17
+
18
+ const fs = require('fs');
19
+ const path = require('path');
20
+ const http = require('http');
21
+ const net = require('net');
22
+ const crypto = require('crypto');
23
+ const { spawn } = require('child_process');
24
+ const { DCB_DIR, OAUTH_PATH } = require('../services/config');
25
+ const { USER_AGENT } = require('../services/version');
26
+
27
+ const FLOW_TIMEOUT = 5 * 60 * 1000;
28
+
29
+ const PROVIDERS = {
30
+ openai: {
31
+ id: 'openai',
32
+ name: 'OpenAI',
33
+ subtitle: 'ChatGPT / Codex account',
34
+ authorizeUrl: 'https://auth.openai.com/oauth/authorize',
35
+ tokenUrl: 'https://auth.openai.com/oauth/token',
36
+ clientId: 'app_EMoamEEZ73f0CkXaXp7hrann',
37
+ scopes: 'openid profile email offline_access',
38
+ redirectMode: 'loopback',
39
+ redirectUri: 'http://localhost:1455/auth/callback',
40
+ fixedPort: 1455,
41
+ callbackPath: '/auth/callback',
42
+ tokenFormat: 'form',
43
+ extraAuth: {
44
+ id_token_add_organizations: 'true',
45
+ codex_cli_simplified_flow: 'true',
46
+ originator: 'dgxay_code'
47
+ },
48
+ baseUrl: 'https://api.openai.com/v1',
49
+ protocol: 'openai',
50
+ hint: 'Uses your ChatGPT subscription account. If the inference endpoint rejects subscription tokens, use an API key for this provider instead.',
51
+ publicClient: true
52
+ },
53
+ anthropic: {
54
+ id: 'anthropic',
55
+ name: 'Anthropic',
56
+ subtitle: 'Claude account',
57
+ authorizeUrl: 'https://claude.ai/oauth/authorize',
58
+ tokenUrl: 'https://console.anthropic.com/v1/oauth/token',
59
+ clientId: '9d1c250a-e61b-44d9-88ed-5944d1962f5e',
60
+ scopes: 'org:create_api_key user:profile user:inference',
61
+ redirectMode: 'manual',
62
+ redirectUri: 'https://console.anthropic.com/oauth/code/callback',
63
+ tokenFormat: 'json',
64
+ stateEqualsVerifier: true,
65
+ extraAuth: { code: 'true' },
66
+ baseUrl: 'https://api.anthropic.com',
67
+ protocol: 'anthropic',
68
+ hint: 'After approving in the browser you get an authorization code; paste it back into the terminal to finish connecting.',
69
+ publicClient: true
70
+ },
71
+ xai: {
72
+ id: 'xai',
73
+ name: 'xAI',
74
+ subtitle: 'SuperGrok / X Premium+',
75
+ authorizeUrl: 'https://auth.x.ai/oauth2/authorize',
76
+ tokenUrl: 'https://auth.x.ai/oauth2/token',
77
+ clientId: 'b1a00492-073a-47ea-816f-4c329264a828',
78
+ scopes: 'openid profile email offline_access grok-cli:access api:access',
79
+ redirectMode: 'loopback',
80
+ redirectUri: '',
81
+ fixedPort: 0,
82
+ callbackPath: '/callback',
83
+ tokenFormat: 'form',
84
+ extraAuth: {},
85
+ baseUrl: 'https://api.x.ai/v1',
86
+ protocol: 'openai',
87
+ hint: 'Requires SuperGrok or X Premium+. xAI may restrict OAuth inference by plan; use an API key if you get a 403.',
88
+ publicClient: true
89
+ },
90
+ google: {
91
+ id: 'google',
92
+ name: 'Google',
93
+ subtitle: 'Gemini API account',
94
+ authorizeUrl: 'https://accounts.google.com/o/oauth2/v2/auth',
95
+ tokenUrl: 'https://oauth2.googleapis.com/token',
96
+ clientId: '',
97
+ scopes: 'openid email profile https://www.googleapis.com/auth/generative-language.retriever',
98
+ redirectMode: 'loopback',
99
+ redirectUri: '',
100
+ fixedPort: 0,
101
+ callbackPath: '/callback',
102
+ tokenFormat: 'form',
103
+ extraAuth: { access_type: 'offline', prompt: 'consent', include_granted_scopes: 'true' },
104
+ userInfoUrl: 'https://openidconnect.googleapis.com/v1/userinfo',
105
+ baseUrl: 'https://generativelanguage.googleapis.com/v1beta/openai',
106
+ protocol: 'openai',
107
+ hint: 'First create a "Desktop app" OAuth client in Google Cloud Console and enable the Generative Language API.',
108
+ publicClient: false
109
+ }
110
+ };
111
+
112
+ const PROVIDER_ORDER = ['openai', 'anthropic', 'xai', 'google'];
113
+
114
+ /** id -> flow */
115
+ const flows = new Map();
116
+
117
+ function base64url(buffer) {
118
+ return Buffer.from(buffer).toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
119
+ }
120
+
121
+ function readStore() {
122
+ const empty = { version: 1, clients: {}, connections: {} };
123
+ try {
124
+ if (!fs.existsSync(OAUTH_PATH)) return empty;
125
+ const parsed = JSON.parse(fs.readFileSync(OAUTH_PATH, 'utf8'));
126
+ return {
127
+ version: 1,
128
+ clients: parsed.clients && typeof parsed.clients === 'object' ? parsed.clients : {},
129
+ connections: parsed.connections && typeof parsed.connections === 'object' ? parsed.connections : {}
130
+ };
131
+ } catch {
132
+ return empty;
133
+ }
134
+ }
135
+
136
+ function writeStore(store) {
137
+ fs.mkdirSync(DCB_DIR, { recursive: true, mode: 0o700 });
138
+ const tmp = `${OAUTH_PATH}.${process.pid}.tmp`;
139
+ fs.writeFileSync(tmp, `${JSON.stringify(store, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
140
+ fs.renameSync(tmp, OAUTH_PATH);
141
+ }
142
+
143
+ /* ------------------------------ 加密存储 ------------------------------ */
144
+
145
+ function tokenKey() {
146
+ return process.env.DGXAY_TOKEN_KEY || process.env.DCB_TOKEN_KEY || '';
147
+ }
148
+
149
+ function encryptWithKey(json) {
150
+ const key = tokenKey();
151
+ if (!key) return null;
152
+ const salt = crypto.randomBytes(16);
153
+ const iv = crypto.randomBytes(12);
154
+ const derived = crypto.scryptSync(key, salt, 32);
155
+ const cipher = crypto.createCipheriv('aes-256-gcm', derived, iv);
156
+ const data = Buffer.concat([cipher.update(json, 'utf8'), cipher.final()]);
157
+ return {
158
+ enc: 'aes',
159
+ salt: salt.toString('base64'),
160
+ iv: iv.toString('base64'),
161
+ tag: cipher.getAuthTag().toString('base64'),
162
+ data: data.toString('base64')
163
+ };
164
+ }
165
+
166
+ function decodeConnection(raw) {
167
+ if (!raw || typeof raw !== 'object') return null;
168
+ try {
169
+ if (raw.enc === true) {
170
+ // 桌面版 Electron safeStorage 加密的数据,CLI 无法解密
171
+ const error = new Error('This token was encrypted by the desktop app and cannot be read by the CLI. Reconnect in the desktop app, or re-authorize in this tool.');
172
+ error.code = 'DESKTOP_ENCRYPTED';
173
+ throw error;
174
+ }
175
+ if (raw.enc === 'aes') {
176
+ const key = tokenKey();
177
+ if (!key) {
178
+ const error = new Error(
179
+ 'The token is encrypted with DGXAY_TOKEN_KEY, but that variable is missing in this environment, so it cannot be decrypted. Set the same DGXAY_TOKEN_KEY and try again.'
180
+ );
181
+ error.code = 'MISSING_TOKEN_KEY';
182
+ throw error;
183
+ }
184
+ const derived = crypto.scryptSync(key, Buffer.from(raw.salt || '', 'base64'), 32);
185
+ const decipher = crypto.createDecipheriv('aes-256-gcm', derived, Buffer.from(raw.iv || '', 'base64'));
186
+ decipher.setAuthTag(Buffer.from(raw.tag || '', 'base64'));
187
+ const plain = Buffer.concat([decipher.update(Buffer.from(raw.data || '', 'base64')), decipher.final()]);
188
+ const parsed = JSON.parse(plain.toString('utf8'));
189
+ return parsed && typeof parsed === 'object' ? parsed : null;
190
+ }
191
+ const parsed = JSON.parse(Buffer.from(String(raw.data || ''), 'base64').toString('utf8'));
192
+ return parsed && typeof parsed === 'object' ? parsed : null;
193
+ } catch (err) {
194
+ if (err.code === 'DESKTOP_ENCRYPTED' || err.code === 'MISSING_TOKEN_KEY') {
195
+ err.cryptoReason = true;
196
+ return { __error: err };
197
+ }
198
+ return null;
199
+ }
200
+ }
201
+
202
+ function encodeConnection(connection) {
203
+ const json = JSON.stringify(connection);
204
+ const encrypted = encryptWithKey(json);
205
+ if (encrypted) return encrypted;
206
+ return { enc: false, data: Buffer.from(json).toString('base64') };
207
+ }
208
+
209
+ /* ------------------------------ 客户端配置 ------------------------------ */
210
+
211
+ function clientConfig(id, store = readStore()) {
212
+ const def = PROVIDERS[id];
213
+ if (!def) throw new Error('Unsupported OAuth provider');
214
+ const saved = store.clients[id] || {};
215
+ const pick = (key) => {
216
+ const value = saved[key];
217
+ if (typeof value === 'string' && value.trim()) return value.trim();
218
+ return def[key] || '';
219
+ };
220
+ return {
221
+ id,
222
+ name: def.name,
223
+ authorizeUrl: pick('authorizeUrl'),
224
+ tokenUrl: pick('tokenUrl'),
225
+ clientId: pick('clientId'),
226
+ clientSecret: saved.clientSecret ? String(saved.clientSecret) : '',
227
+ scopes: pick('scopes'),
228
+ redirectUri: def.redirectMode === 'manual' ? pick('redirectUri') : '',
229
+ hint: def.hint,
230
+ baseUrl: def.baseUrl,
231
+ protocol: def.protocol,
232
+ tokenFormat: def.tokenFormat || 'form',
233
+ publicClient: def.publicClient
234
+ };
235
+ }
236
+
237
+ function connectionSummary(id, connection) {
238
+ if (!connection) return null;
239
+ if (connection.__error) {
240
+ return {
241
+ provider: id,
242
+ account: '(cannot decrypt)',
243
+ broken: true,
244
+ error: connection.__error.message,
245
+ expired: true,
246
+ canRefresh: false
247
+ };
248
+ }
249
+ return {
250
+ provider: id,
251
+ account: connection.account || '',
252
+ expiresAt: connection.expiresAt || 0,
253
+ expired: Boolean(connection.expiresAt && Date.now() > connection.expiresAt - 30_000),
254
+ connectedAt: connection.connectedAt || 0,
255
+ canRefresh: Boolean(connection.refreshToken)
256
+ };
257
+ }
258
+
259
+ function listProviders() {
260
+ const store = readStore();
261
+ return PROVIDER_ORDER.map((id) => {
262
+ const def = PROVIDERS[id];
263
+ const config = clientConfig(id, store);
264
+ return {
265
+ id,
266
+ name: def.name,
267
+ subtitle: def.subtitle,
268
+ hint: def.hint,
269
+ redirectMode: def.redirectMode,
270
+ redirectUri: config.redirectUri || (def.redirectMode === 'loopback' ? 'Loopback port assigned by the system' : def.redirectUri),
271
+ scopes: config.scopes,
272
+ authorizeUrl: config.authorizeUrl,
273
+ tokenUrl: config.tokenUrl,
274
+ clientId: config.clientId,
275
+ hasSecret: Boolean(config.clientSecret),
276
+ publicClient: def.publicClient,
277
+ baseUrl: def.baseUrl,
278
+ protocol: def.protocol,
279
+ connection: connectionSummary(id, decodeConnection(store.connections[id]))
280
+ };
281
+ });
282
+ }
283
+
284
+ function saveClient(id, patch = {}) {
285
+ const def = PROVIDERS[id];
286
+ if (!def) throw new Error('Unsupported OAuth provider');
287
+ const store = readStore();
288
+ const current = store.clients[id] || {};
289
+ const next = { ...current };
290
+
291
+ for (const key of ['authorizeUrl', 'tokenUrl', 'clientId', 'scopes', 'redirectUri']) {
292
+ if (typeof patch[key] === 'string') {
293
+ const value = patch[key].trim();
294
+ if (value) next[key] = value;
295
+ else delete next[key];
296
+ }
297
+ }
298
+ if (typeof patch.clientSecret === 'string') {
299
+ if (patch.clientSecret.trim()) next.clientSecret = patch.clientSecret.trim();
300
+ else if (patch.clearSecret) delete next.clientSecret;
301
+ }
302
+
303
+ if (next.authorizeUrl && !/^https?:\/\//i.test(next.authorizeUrl)) {
304
+ throw new Error('The authorization endpoint must start with http:// or https://');
305
+ }
306
+ if (next.tokenUrl && !/^https?:\/\//i.test(next.tokenUrl)) {
307
+ throw new Error('The token endpoint must start with http:// or https://');
308
+ }
309
+
310
+ store.clients[id] = next;
311
+ writeStore(store);
312
+ return listProviders().find((item) => item.id === id);
313
+ }
314
+
315
+ function disconnect(id) {
316
+ const store = readStore();
317
+ delete store.connections[id];
318
+ writeStore(store);
319
+ return true;
320
+ }
321
+
322
+ /* ------------------------------ 令牌 ------------------------------ */
323
+
324
+ function decodeJwt(token) {
325
+ try {
326
+ const part = String(token || '').split('.')[1];
327
+ if (!part) return {};
328
+ const padded = part.replace(/-/g, '+').replace(/_/g, '/').padEnd(Math.ceil(part.length / 4) * 4, '=');
329
+ return JSON.parse(Buffer.from(padded, 'base64').toString('utf8'));
330
+ } catch {
331
+ return {};
332
+ }
333
+ }
334
+
335
+ function accountFromTokens(tokens) {
336
+ const claims = decodeJwt(tokens.id_token || '');
337
+ const accessClaims = decodeJwt(tokens.access_token || '');
338
+ const email = claims.email || accessClaims.email || '';
339
+ const name = claims.name || claims.preferred_username || accessClaims.name || '';
340
+ const subject = claims.sub || accessClaims.sub || '';
341
+ return email || name || (subject ? `Account ${String(subject).slice(0, 10)}` : 'Connected account');
342
+ }
343
+
344
+ async function fetchAccount(id, tokens) {
345
+ const def = PROVIDERS[id];
346
+ if (!def.userInfoUrl || !tokens.access_token) return '';
347
+ try {
348
+ const controller = new AbortController();
349
+ const timer = setTimeout(() => controller.abort(), 8000);
350
+ const res = await fetch(def.userInfoUrl, {
351
+ headers: { Authorization: `Bearer ${tokens.access_token}`, Accept: 'application/json' },
352
+ signal: controller.signal
353
+ });
354
+ clearTimeout(timer);
355
+ if (!res.ok) return '';
356
+ const data = await res.json();
357
+ return data.email || data.name || data.sub || '';
358
+ } catch {
359
+ return '';
360
+ }
361
+ }
362
+
363
+ async function requestToken(config, params) {
364
+ const body = config.tokenFormat === 'json' ? JSON.stringify(params) : new URLSearchParams(params).toString();
365
+ const controller = new AbortController();
366
+ const timer = setTimeout(() => controller.abort(), 25000);
367
+ try {
368
+ const res = await fetch(config.tokenUrl, {
369
+ method: 'POST',
370
+ headers: {
371
+ 'Content-Type': config.tokenFormat === 'json' ? 'application/json' : 'application/x-www-form-urlencoded',
372
+ Accept: 'application/json',
373
+ 'User-Agent': USER_AGENT
374
+ },
375
+ body,
376
+ signal: controller.signal
377
+ });
378
+ const text = await res.text();
379
+ let data = {};
380
+ try {
381
+ data = JSON.parse(text);
382
+ } catch {
383
+ data = {};
384
+ }
385
+ if (!res.ok || !data.access_token) {
386
+ const detail = data.error_description || data.error?.message || data.error || text.slice(0, 300);
387
+ throw new Error(`OAuth token exchange failed: HTTP ${res.status}${detail ? ` · ${detail}` : ''}`);
388
+ }
389
+ return data;
390
+ } finally {
391
+ clearTimeout(timer);
392
+ }
393
+ }
394
+
395
+ function persistConnection(id, tokens) {
396
+ const store = readStore();
397
+ const existing = decodeConnection(store.connections[id]) || {};
398
+ const expiresIn = Number(tokens.expires_in);
399
+ const connection = {
400
+ accessToken: tokens.access_token,
401
+ refreshToken: tokens.refresh_token || existing.refreshToken || '',
402
+ tokenType: tokens.token_type || 'Bearer',
403
+ scope: tokens.scope || '',
404
+ expiresAt: Number.isFinite(expiresIn) && expiresIn > 0 ? Date.now() + expiresIn * 1000 : 0,
405
+ account: tokens.account || existing.account || accountFromTokens(tokens),
406
+ connectedAt: existing.connectedAt || Date.now(),
407
+ updatedAt: Date.now()
408
+ };
409
+ store.connections[id] = encodeConnection(connection);
410
+ writeStore(store);
411
+ return connection;
412
+ }
413
+
414
+ async function refreshConnection(id, connection) {
415
+ const store = readStore();
416
+ const config = clientConfig(id, store);
417
+ const params = {
418
+ grant_type: 'refresh_token',
419
+ refresh_token: connection.refreshToken,
420
+ client_id: config.clientId
421
+ };
422
+ if (config.clientSecret) params.client_secret = config.clientSecret;
423
+ const tokens = await requestToken(config, params);
424
+ return persistConnection(id, {
425
+ ...tokens,
426
+ refresh_token: tokens.refresh_token || connection.refreshToken,
427
+ account: connection.account
428
+ });
429
+ }
430
+
431
+ async function getAccessToken(id) {
432
+ const store = readStore();
433
+ const connection = decodeConnection(store.connections[id]);
434
+ if (connection?.__error) throw connection.__error;
435
+ if (!connection?.accessToken) {
436
+ throw new Error(`${PROVIDERS[id]?.name || id} account is not connected. Run dgxay oauth connect ${id} first`);
437
+ }
438
+ if (connection.expiresAt && Date.now() > connection.expiresAt - 60_000) {
439
+ if (!connection.refreshToken) {
440
+ throw new Error(`The ${PROVIDERS[id].name} login has expired. Run dgxay oauth connect ${id} again`);
441
+ }
442
+ const refreshed = await refreshConnection(id, connection);
443
+ return refreshed.accessToken;
444
+ }
445
+ return connection.accessToken;
446
+ }
447
+
448
+ /* ------------------------------ 浏览器与回环 ------------------------------ */
449
+
450
+ function openBrowser(url) {
451
+ return new Promise((resolve) => {
452
+ if (process.env.DGXAY_OAUTH_NO_BROWSER === '1' || process.env.DCB_OAUTH_NO_BROWSER === '1') {
453
+ resolve({ ok: true, skipped: true });
454
+ return;
455
+ }
456
+ let command;
457
+ let args;
458
+ if (process.platform === 'win32') {
459
+ command = 'cmd';
460
+ args = ['/c', 'start', '', url];
461
+ } else if (process.platform === 'darwin') {
462
+ command = 'open';
463
+ args = [url];
464
+ } else {
465
+ command = 'xdg-open';
466
+ args = [url];
467
+ }
468
+ try {
469
+ const child = spawn(command, args, { detached: true, stdio: 'ignore', windowsHide: true });
470
+ child.on('error', () => resolve({ ok: false }));
471
+ child.unref();
472
+ resolve({ ok: true });
473
+ } catch {
474
+ resolve({ ok: false });
475
+ }
476
+ });
477
+ }
478
+
479
+ /** 固定端口被占用时给出明确错误(桌面版固定 1455,常被 Codex CLI 占用) */
480
+ function probePort(port, host) {
481
+ return new Promise((resolve) => {
482
+ const server = net.createServer();
483
+ server.once('error', (err) => resolve({ free: false, err }));
484
+ server.once('listening', () => server.close(() => resolve({ free: true })));
485
+ server.listen(port, host);
486
+ });
487
+ }
488
+
489
+ function startLoopback(flow) {
490
+ return new Promise((resolve, reject) => {
491
+ const server = http.createServer(async (req, res) => {
492
+ let url;
493
+ try {
494
+ url = new URL(req.url, 'http://127.0.0.1');
495
+ } catch {
496
+ res.writeHead(400);
497
+ res.end('bad request');
498
+ return;
499
+ }
500
+ if (url.pathname !== flow.callbackPath) {
501
+ res.writeHead(404);
502
+ res.end('not found');
503
+ return;
504
+ }
505
+
506
+ const error = url.searchParams.get('error');
507
+ const code = url.searchParams.get('code');
508
+ const state = url.searchParams.get('state') || '';
509
+ const ok = !error && Boolean(code) && state === flow.state;
510
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
511
+ res.end(
512
+ `<!doctype html><meta charset="utf-8"><title>DgxayCode</title>` +
513
+ `<body style="font-family:system-ui,-apple-system,'Segoe UI',sans-serif;display:grid;place-items:center;height:100vh;margin:0;background:#f7f7f5;color:#1f2023">` +
514
+ `<div style="text-align:center"><div style="width:44px;height:44px;border-radius:12px;background:#2563eb;display:inline-grid;place-items:center;color:#fff;font-size:22px">✓</div>` +
515
+ `<h1 style="font-size:18px;margin:16px 0 6px">${ok ? 'Authorization complete' : 'Authorization incomplete'}</h1>` +
516
+ `<p style="font-size:13px;color:#6b6d72;margin:0">${ok ? 'You can close this page and return to the terminal.' : 'Please return to the terminal and try again.'}</p></div></body>`
517
+ );
518
+
519
+ if (!ok) {
520
+ const err = new Error(
521
+ error ? `Authorization was denied: ${error}` : state !== flow.state ? 'Authorization callback state mismatch' : 'Authorization callback is missing the code'
522
+ );
523
+ failFlow(flow, err);
524
+ return;
525
+ }
526
+ try {
527
+ await finalizeFlow(flow, code, state);
528
+ } catch (err) {
529
+ failFlow(flow, err);
530
+ }
531
+ });
532
+
533
+ server.once('error', (err) => {
534
+ const hint =
535
+ err.code === 'EADDRINUSE'
536
+ ? `Port ${flow.fixedPort || ''} is already in use (often Codex CLI or another auth instance is running). Close it and try again.`
537
+ : err.message;
538
+ reject(new Error(`Cannot start the local callback server: ${hint}`));
539
+ });
540
+ const port = flow.fixedPort || 0;
541
+ const host = flow.fixedPort ? 'localhost' : '127.0.0.1';
542
+ server.listen(port, host, () => {
543
+ const address = server.address();
544
+ const actualPort = address.port;
545
+ flow.server = server;
546
+ flow.redirectUri = flow.redirectUriTemplate.replace('{port}', String(actualPort));
547
+ resolve();
548
+ });
549
+ });
550
+ }
551
+
552
+ /* ------------------------------ 流程 ------------------------------ */
553
+
554
+ async function begin(input = {}) {
555
+ const id = String(input.provider || '');
556
+ const def = PROVIDERS[id];
557
+ if (!def) throw new Error('Unsupported OAuth provider');
558
+
559
+ const store = readStore();
560
+ const config = clientConfig(id, store);
561
+ if (!config.clientId) throw new Error(`Set this platform's OAuth Client ID first: dgxay oauth client ${id} --client-id <ID>`);
562
+
563
+ for (const [label, url] of [
564
+ ['Authorization endpoint', config.authorizeUrl],
565
+ ['Token endpoint', config.tokenUrl]
566
+ ]) {
567
+ if (!/^https?:\/\//i.test(url || '')) throw new Error(`${label} must be an http:// or https:// URL`);
568
+ }
569
+
570
+ const verifier = base64url(crypto.randomBytes(32));
571
+ const challenge = base64url(crypto.createHash('sha256').update(verifier).digest());
572
+ const state = def.stateEqualsVerifier ? verifier : base64url(crypto.randomBytes(16));
573
+ const flowId = `oauth_${Date.now().toString(36)}_${crypto.randomBytes(4).toString('hex')}`;
574
+
575
+ const flow = {
576
+ id: flowId,
577
+ provider: id,
578
+ verifier,
579
+ state,
580
+ config,
581
+ callbackPath: def.callbackPath,
582
+ fixedPort: def.fixedPort || 0,
583
+ redirectUriTemplate:
584
+ def.redirectMode === 'manual'
585
+ ? config.redirectUri
586
+ : def.fixedPort
587
+ ? config.redirectUri
588
+ : `http://127.0.0.1:{port}${def.callbackPath}`,
589
+ redirectUri: '',
590
+ server: null,
591
+ settled: false,
592
+ timer: null,
593
+ resolve: null,
594
+ reject: null
595
+ };
596
+
597
+ flow.promise = new Promise((resolve, reject) => {
598
+ flow.resolve = resolve;
599
+ flow.reject = reject;
600
+ });
601
+ // 手动授权流程不会调用 wait(),避免超时后出现未处理的 rejection
602
+ flow.promise.catch(() => {});
603
+
604
+ if (def.redirectMode === 'loopback') {
605
+ await startLoopback(flow);
606
+ } else {
607
+ flow.redirectUri = config.redirectUri;
608
+ }
609
+
610
+ const authUrl = new URL(config.authorizeUrl);
611
+ authUrl.searchParams.set('response_type', 'code');
612
+ authUrl.searchParams.set('client_id', config.clientId);
613
+ authUrl.searchParams.set('redirect_uri', flow.redirectUri);
614
+ authUrl.searchParams.set('scope', config.scopes);
615
+ authUrl.searchParams.set('state', state);
616
+ authUrl.searchParams.set('code_challenge', challenge);
617
+ authUrl.searchParams.set('code_challenge_method', 'S256');
618
+ for (const [key, value] of Object.entries(def.extraAuth || {})) {
619
+ authUrl.searchParams.set(key, value);
620
+ }
621
+
622
+ flows.set(flowId, flow);
623
+ flow.timer = setTimeout(() => {
624
+ failFlow(flow, new Error('Authorization timed out, please start the connection again'));
625
+ }, FLOW_TIMEOUT);
626
+
627
+ const opened = await openBrowser(authUrl.toString());
628
+
629
+ return {
630
+ id: flowId,
631
+ provider: id,
632
+ mode: def.redirectMode,
633
+ authUrl: authUrl.toString(),
634
+ redirectUri: flow.redirectUri,
635
+ browserOpened: opened.ok && !opened.skipped,
636
+ expiresAt: Date.now() + FLOW_TIMEOUT
637
+ };
638
+ }
639
+
640
+ async function finalizeFlow(flow, rawCode, state) {
641
+ if (!flow || flow.settled) return null;
642
+ if (state && flow.state && state !== flow.state) throw new Error('Authorization callback state mismatch, aborted');
643
+ const code = String(rawCode || '').trim();
644
+ if (!code) throw new Error('The authorization code is empty');
645
+
646
+ const params = {
647
+ grant_type: 'authorization_code',
648
+ code,
649
+ client_id: flow.config.clientId,
650
+ redirect_uri: flow.redirectUri,
651
+ code_verifier: flow.verifier
652
+ };
653
+ if (flow.config.clientSecret) params.client_secret = flow.config.clientSecret;
654
+ if (PROVIDERS[flow.provider].stateEqualsVerifier) params.state = state || flow.state;
655
+
656
+ const tokens = await requestToken(flow.config, params);
657
+ const account = (await fetchAccount(flow.provider, tokens)) || accountFromTokens(tokens);
658
+ const connection = persistConnection(flow.provider, { ...tokens, account });
659
+
660
+ flow.settled = true;
661
+ clearTimeout(flow.timer);
662
+ if (flow.server) {
663
+ try {
664
+ flow.server.close();
665
+ } catch {
666
+ /* ignore */
667
+ }
668
+ }
669
+ const summary = connectionSummary(flow.provider, connection);
670
+ flow.resolve(summary);
671
+ setTimeout(() => flows.delete(flow.id), 30_000).unref?.();
672
+ return summary;
673
+ }
674
+
675
+ function failFlow(flow, error) {
676
+ if (!flow || flow.settled) return;
677
+ flow.settled = true;
678
+ clearTimeout(flow.timer);
679
+ if (flow.server) {
680
+ try {
681
+ flow.server.close();
682
+ } catch {
683
+ /* ignore */
684
+ }
685
+ }
686
+ flow.reject(error);
687
+ setTimeout(() => flows.delete(flow.id), 30_000).unref?.();
688
+ }
689
+
690
+ function wait(id) {
691
+ const flow = flows.get(String(id));
692
+ if (!flow) throw new Error('The authorization session does not exist or has ended. Start the connection again');
693
+ return flow.promise;
694
+ }
695
+
696
+ async function complete(id, rawInput) {
697
+ const flow = flows.get(String(id));
698
+ if (!flow) throw new Error('The authorization session does not exist or has ended. Start the connection again');
699
+ let code = String(rawInput || '').trim();
700
+ let state = '';
701
+ if (code.includes('#')) {
702
+ const parts = code.split('#');
703
+ code = parts[0].trim();
704
+ state = parts.slice(1).join('#').trim();
705
+ } else if (/^https?:\/\//i.test(code)) {
706
+ try {
707
+ const url = new URL(code);
708
+ code = url.searchParams.get('code') || '';
709
+ state = url.searchParams.get('state') || '';
710
+ } catch {
711
+ /* keep raw */
712
+ }
713
+ }
714
+ if (!code) throw new Error('No authorization code recognized. Copy it again');
715
+ return finalizeFlow(flow, code, state);
716
+ }
717
+
718
+ function cancel(id) {
719
+ const flow = flows.get(String(id));
720
+ if (!flow) return false;
721
+ failFlow(flow, new Error('Authorization cancelled'));
722
+ return true;
723
+ }
724
+
725
+ module.exports = {
726
+ PROVIDERS,
727
+ PROVIDER_ORDER,
728
+ listProviders,
729
+ saveClient,
730
+ disconnect,
731
+ begin,
732
+ wait,
733
+ complete,
734
+ cancel,
735
+ getAccessToken,
736
+ probePort
737
+ };