dgxay-code-cli 0.0.0-stage → 1.0.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.
- package/LICENSE +674 -0
- package/README-zh.md +200 -0
- package/README.md +182 -2
- package/bin/dgxay.js +42 -0
- package/package.json +47 -4
- package/src/cli/ansi.js +509 -0
- package/src/cli/approval.js +186 -0
- package/src/cli/args.js +162 -0
- package/src/cli/command-table.js +52 -0
- package/src/cli/editor.js +574 -0
- package/src/cli/index.js +228 -0
- package/src/cli/print.js +148 -0
- package/src/cli/render.js +324 -0
- package/src/cli/repl-state.js +85 -0
- package/src/cli/repl.js +1167 -0
- package/src/cli/slash.js +74 -0
- package/src/cli/ui.js +52 -0
- package/src/commands/_shared.js +20 -0
- package/src/commands/chat.js +212 -0
- package/src/commands/config.js +363 -0
- package/src/commands/context.js +43 -0
- package/src/commands/doctor.js +192 -0
- package/src/commands/export.js +102 -0
- package/src/commands/mcp.js +257 -0
- package/src/commands/model.js +223 -0
- package/src/commands/oauth.js +246 -0
- package/src/commands/permission.js +108 -0
- package/src/commands/provider.js +332 -0
- package/src/commands/run.js +172 -0
- package/src/commands/skill.js +94 -0
- package/src/commands/workspace.js +100 -0
- package/src/core/agent.js +419 -0
- package/src/core/llm.js +709 -0
- package/src/core/mcp.js +271 -0
- package/src/core/oauth.js +737 -0
- package/src/core/prompt.js +269 -0
- package/src/core/sessions.js +208 -0
- package/src/core/tools.js +391 -0
- package/src/services/config.js +182 -0
- package/src/services/input.js +91 -0
- package/src/services/session-bus.js +104 -0
- package/src/services/settings.js +340 -0
- package/src/services/skills.js +125 -0
- package/src/services/terminal.js +124 -0
- 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 账户',
|
|
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: '使用 ChatGPT 订阅账户。若推理接口拒绝订阅令牌,请为该供应商改用 API Key。',
|
|
51
|
+
publicClient: true
|
|
52
|
+
},
|
|
53
|
+
anthropic: {
|
|
54
|
+
id: 'anthropic',
|
|
55
|
+
name: 'Anthropic',
|
|
56
|
+
subtitle: 'Claude 账户',
|
|
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: '浏览器授权后会显示一段授权码,复制回终端即可完成连接。',
|
|
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: '需要 SuperGrok 或 X Premium+。xAI 可能对 OAuth 推理接口做套餐限制,返回 403 时请改用 API Key。',
|
|
88
|
+
publicClient: true
|
|
89
|
+
},
|
|
90
|
+
google: {
|
|
91
|
+
id: 'google',
|
|
92
|
+
name: 'Google',
|
|
93
|
+
subtitle: 'Gemini API 账户',
|
|
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: '需要先在 Google Cloud Console 创建「桌面应用」OAuth 客户端,并启用 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('该令牌由桌面版加密保存,命令行版无法读取。请在桌面版中重新连接,或在本工具中重新授权。');
|
|
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
|
+
'令牌使用 DGXAY_TOKEN_KEY 加密保存,但当前环境没有该变量,无法解密。请设置相同的 DGXAY_TOKEN_KEY 后重试。'
|
|
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('不支持的 OAuth 供应商');
|
|
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: '(无法解密)',
|
|
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' ? '由系统分配回环端口' : 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('不支持的 OAuth 供应商');
|
|
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('授权端点必须以 http:// 或 https:// 开头');
|
|
305
|
+
}
|
|
306
|
+
if (next.tokenUrl && !/^https?:\/\//i.test(next.tokenUrl)) {
|
|
307
|
+
throw new Error('令牌端点必须以 http:// 或 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 ? `账户 ${String(subject).slice(0, 10)}` : '已连接账户');
|
|
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 令牌交换失败: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} 账户,请先运行 dgxay oauth connect ${id}`);
|
|
437
|
+
}
|
|
438
|
+
if (connection.expiresAt && Date.now() > connection.expiresAt - 60_000) {
|
|
439
|
+
if (!connection.refreshToken) {
|
|
440
|
+
throw new Error(`${PROVIDERS[id].name} 的登录已过期,请重新运行 dgxay oauth connect ${id}`);
|
|
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 ? '授权完成' : '授权未完成'}</h1>` +
|
|
516
|
+
`<p style="font-size:13px;color:#6b6d72;margin:0">${ok ? '可以关闭此页面,回到终端。' : '请回到终端重试。'}</p></div></body>`
|
|
517
|
+
);
|
|
518
|
+
|
|
519
|
+
if (!ok) {
|
|
520
|
+
const err = new Error(
|
|
521
|
+
error ? `授权被拒绝:${error}` : state !== flow.state ? '授权回调 state 不匹配' : '授权回调缺少 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
|
+
? `端口 ${flow.fixedPort || ''} 已被占用(常见原因:Codex CLI 或其他授权实例正在运行),请关闭后重试。`
|
|
537
|
+
: err.message;
|
|
538
|
+
reject(new Error(`无法启动本地回调服务:${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('不支持的 OAuth 供应商');
|
|
558
|
+
|
|
559
|
+
const store = readStore();
|
|
560
|
+
const config = clientConfig(id, store);
|
|
561
|
+
if (!config.clientId) throw new Error(`请先填写该平台的 OAuth Client ID:dgxay oauth client ${id} --client-id <ID>`);
|
|
562
|
+
|
|
563
|
+
for (const [label, url] of [
|
|
564
|
+
['授权端点', config.authorizeUrl],
|
|
565
|
+
['令牌端点', config.tokenUrl]
|
|
566
|
+
]) {
|
|
567
|
+
if (!/^https?:\/\//i.test(url || '')) throw new Error(`${label}必须是 http:// 或 https:// 地址`);
|
|
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('授权等待超时,请重新发起连接'));
|
|
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('授权回调 state 不匹配,已中止');
|
|
643
|
+
const code = String(rawCode || '').trim();
|
|
644
|
+
if (!code) throw new Error('授权码为空');
|
|
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('授权会话不存在或已结束,请重新发起连接');
|
|
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('授权会话不存在或已结束,请重新发起连接');
|
|
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('没有识别到授权码,请重新复制');
|
|
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('已取消授权'));
|
|
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
|
+
};
|