yidaconnector 2026.6.11

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 (79) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +383 -0
  3. package/bin/yida.js +670 -0
  4. package/lib/app/form-navigation.js +58 -0
  5. package/lib/app/get-schema.js +538 -0
  6. package/lib/auth/auth.js +294 -0
  7. package/lib/auth/cdp-browser-login.js +390 -0
  8. package/lib/auth/codex-login.js +71 -0
  9. package/lib/auth/login.js +475 -0
  10. package/lib/auth/org.js +363 -0
  11. package/lib/auth/qr-login.js +1563 -0
  12. package/lib/core/chalk.js +384 -0
  13. package/lib/core/check-update.js +82 -0
  14. package/lib/core/cli-error.js +39 -0
  15. package/lib/core/command-manifest.js +106 -0
  16. package/lib/core/env-cmd.js +545 -0
  17. package/lib/core/env-manager.js +601 -0
  18. package/lib/core/env.js +287 -0
  19. package/lib/core/i18n.js +177 -0
  20. package/lib/core/locales/ar.js +805 -0
  21. package/lib/core/locales/de.js +805 -0
  22. package/lib/core/locales/en.js +1623 -0
  23. package/lib/core/locales/es.js +805 -0
  24. package/lib/core/locales/fr.js +805 -0
  25. package/lib/core/locales/hi.js +805 -0
  26. package/lib/core/locales/ja.js +1197 -0
  27. package/lib/core/locales/ko.js +807 -0
  28. package/lib/core/locales/pt.js +805 -0
  29. package/lib/core/locales/vi.js +805 -0
  30. package/lib/core/locales/zh-HK.js +1233 -0
  31. package/lib/core/locales/zh.js +1584 -0
  32. package/lib/core/query-data.js +781 -0
  33. package/lib/core/redact.js +100 -0
  34. package/lib/core/utils.js +799 -0
  35. package/lib/core/yida-client.js +117 -0
  36. package/package.json +94 -0
  37. package/project/config.json +4 -0
  38. package/project/pages/src/demo-birthday-game.oyd.jsx +832 -0
  39. package/project/pages/src/demo-chip-insight.oyd.jsx +983 -0
  40. package/project/pages/src/demo-compat-smoke.oyd.jsx +58 -0
  41. package/project/pages/src/demo-crm-batch-entry.oyd.jsx +805 -0
  42. package/project/pages/src/demo-crm-dashboard.oyd.jsx +677 -0
  43. package/project/pages/src/demo-future-vision-2026.oyd.jsx +1102 -0
  44. package/project/pages/src/demo-ppt.oyd.jsx +1192 -0
  45. package/project/pages/src/demo-salary-calculator.oyd.jsx +904 -0
  46. package/project/pages/src/yidaconnector-knowledge-doc.oyd.jsx +1714 -0
  47. package/project/prd/demo-birthday-game.md +39 -0
  48. package/project/prd/demo-crm.md +463 -0
  49. package/project/prd/demo-dingtalk-ai-solution-center.md +425 -0
  50. package/project/prd/demo-future-vision-2026.md +78 -0
  51. package/project/prd/demo-salary-calculator.md +101 -0
  52. package/scripts/build-skills-package.js +406 -0
  53. package/scripts/check-syntax.js +59 -0
  54. package/scripts/demo-dws.sh +106 -0
  55. package/scripts/e2e-real/cleanup.js +67 -0
  56. package/scripts/e2e-real/fixtures/form-fields.json +18 -0
  57. package/scripts/e2e-real/full-runner.js +1566 -0
  58. package/scripts/e2e-real/runner.js +293 -0
  59. package/scripts/e2e-real/skill-coverage.js +115 -0
  60. package/scripts/generate-command-docs.js +109 -0
  61. package/scripts/nightly-smoke.js +134 -0
  62. package/scripts/postinstall.js +545 -0
  63. package/scripts/solution-center-runner.js +368 -0
  64. package/scripts/validate-ci.sh +50 -0
  65. package/scripts/validate-command-manifest.js +119 -0
  66. package/scripts/validate-package-size.js +78 -0
  67. package/scripts/validate-skills.js +247 -0
  68. package/scripts/validate-structure.js +66 -0
  69. package/yida-skills/SKILL.md +163 -0
  70. package/yida-skills/references/yida-api.md +1309 -0
  71. package/yida-skills/skills/large-file-write/SKILL.md +91 -0
  72. package/yida-skills/skills/large-file-write/references/write-patterns.md +149 -0
  73. package/yida-skills/skills/large-file-write/scripts/write.js +157 -0
  74. package/yida-skills/skills/yida-data-management/SKILL.md +252 -0
  75. package/yida-skills/skills/yida-data-management/references/api-matrix.md +49 -0
  76. package/yida-skills/skills/yida-data-management/references/data-format-guide.md +159 -0
  77. package/yida-skills/skills/yida-data-management/references/verified-endpoints.md +62 -0
  78. package/yida-skills/skills/yida-login/SKILL.md +159 -0
  79. package/yida-skills/skills/yida-logout/SKILL.md +67 -0
@@ -0,0 +1,1563 @@
1
+ /**
2
+ * qr-login.js - 终端二维码扫码登录
3
+ *
4
+ * 实现流程:
5
+ * 1. 调用宜搭登录接口获取钉钉二维码 URL
6
+ * 2. 在终端渲染二维码(使用 qrcode 包)
7
+ * 3. 轮询扫码状态,等待用户用钉钉扫码确认
8
+ * 4. 获取登录 Cookie
9
+ * 5. 调用接口获取用户可访问的组织列表
10
+ * 6. 交互式问答让用户选择组织
11
+ * 7. 切换到目标组织,保存最终 Cookie
12
+ *
13
+ * 导出函数:
14
+ * qrLogin() - 执行完整的终端二维码登录流程
15
+ */
16
+
17
+ 'use strict';
18
+
19
+ const https = require('https');
20
+ const http = require('http');
21
+ const readline = require('readline');
22
+ const { extractInfoFromCookies, findProjectRoot } = require('../core/utils');
23
+ const { saveCookieCache } = require('./login');
24
+ const { t } = require('../core/i18n');
25
+ const { warn } = require('../core/chalk');
26
+
27
+ const { resolveLoginUrl, resolveEndpoint, deriveBaseUrlFromUrl, getCurrentEnvConfig } = require('../core/env-manager');
28
+
29
+ function shellQuote(value, options = {}) {
30
+ if ((options.platform || process.platform) === 'win32') {
31
+ return `"${String(value).replace(/"/g, '\\"')}"`;
32
+ }
33
+ return `'${String(value).replace(/'/g, "'\\''")}'`;
34
+ }
35
+
36
+ function getTargetCorpId(options = {}, session = {}) {
37
+ return options.corpId || options.targetCorpId || session.targetCorpId || null;
38
+ }
39
+
40
+ function buildCodexPollCommand(sessionFile, targetCorpId, envName, options = {}) {
41
+ const baseCommand = `yidaconnector login --agent-poll ${shellQuote(sessionFile, options)}`;
42
+ const envArg = envName ? ` --env ${shellQuote(envName, options)}` : '';
43
+ const corpArg = targetCorpId ? ` --corp-id ${shellQuote(targetCorpId, options)}` : '';
44
+ return `${baseCommand}${envArg}${corpArg}`;
45
+ }
46
+
47
+ function buildQrImageMarkdown(qrImageFile) {
48
+ if (!qrImageFile) {return null;}
49
+ return `![YidaConnector login QR code](${String(qrImageFile).replace(/\\/g, '/')})`;
50
+ }
51
+
52
+ function buildAgentQrResponseMarkdown(result) {
53
+ const lines = [t('qr_login.scan_hint').trim(), ''];
54
+ if (result.qr_image_markdown) {
55
+ lines.push(result.qr_image_markdown, '');
56
+ }
57
+ lines.push(t('qr_login.qr_url_label', result.qr_url).trim());
58
+ lines.push('');
59
+ lines.push(`poll_command: \`${result.poll_command}\``);
60
+ return lines.join('\n');
61
+ }
62
+
63
+ function buildNeedQrScanResult({ qrUrl, qrImageFile, qrImageOpened, sessionFile, targetCorpId, envName, platform }) {
64
+ const qrImageMarkdown = buildQrImageMarkdown(qrImageFile);
65
+ const result = {
66
+ status: 'need_qr_scan',
67
+ handoff_type: 'qr',
68
+ can_auto_use: false,
69
+ qr_url: qrUrl,
70
+ qr_image_file: qrImageFile || null,
71
+ qr_image_opened: !!qrImageOpened,
72
+ qr_image_markdown: qrImageMarkdown,
73
+ session_file: sessionFile,
74
+ poll_command: buildCodexPollCommand(sessionFile, targetCorpId, envName, { platform }),
75
+ message: 'Scan the QR code with DingTalk, then run poll_command.',
76
+ };
77
+ result.agent_response_markdown = buildAgentQrResponseMarkdown(result);
78
+ return result;
79
+ }
80
+
81
+ // ── HTTP 工具 ─────────────────────────────────────────
82
+
83
+ /**
84
+ * 发送 GET 请求,返回响应体字符串和 Set-Cookie 头。
85
+ * @param {string} url - 完整 URL
86
+ * @param {object} [options] - 额外选项
87
+ * @param {string} [options.cookieHeader] - Cookie 请求头
88
+ * @returns {Promise<{ body: string, cookies: string[], statusCode: number, headers: object }>}
89
+ */
90
+ function fetchGet(url, options = {}) {
91
+ return new Promise((resolve, reject) => {
92
+ const parsedUrl = new URL(url);
93
+ const isHttps = parsedUrl.protocol === 'https:';
94
+ const requestModule = isHttps ? https : http;
95
+
96
+ const reqOptions = {
97
+ hostname: parsedUrl.hostname,
98
+ port: parsedUrl.port || (isHttps ? 443 : 80),
99
+ path: parsedUrl.pathname + parsedUrl.search,
100
+ method: 'GET',
101
+ headers: {
102
+ 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36',
103
+ Accept: 'application/json, text/plain, */*',
104
+ ...(options.cookieHeader ? { Cookie: options.cookieHeader } : {}),
105
+ ...(options.referer ? { Referer: options.referer } : {}),
106
+ ...(options.origin ? { Origin: options.origin } : {}),
107
+ ...(options.headers || {}),
108
+ },
109
+ timeout: 30000,
110
+ };
111
+
112
+ const req = requestModule.request(reqOptions, (res) => {
113
+ let body = '';
114
+ res.on('data', (chunk) => { body += chunk; });
115
+ res.on('end', () => {
116
+ const setCookieHeaders = res.headers['set-cookie'] || [];
117
+ resolve({ body, cookies: setCookieHeaders, statusCode: res.statusCode, headers: res.headers });
118
+ });
119
+ });
120
+
121
+ req.on('timeout', () => { req.destroy(); reject(new Error(t('common.request_timeout'))); });
122
+ req.on('error', reject);
123
+ req.end();
124
+ });
125
+ }
126
+
127
+ /**
128
+ * 发送 POST 请求,返回响应体字符串和 Set-Cookie 头。
129
+ * @param {string} url - 完整 URL
130
+ * @param {string} postData - 请求体
131
+ * @param {object} [options] - 额外选项
132
+ * @returns {Promise<{ body: string, cookies: string[], statusCode: number, headers: object }>}
133
+ */
134
+ function fetchPost(url, postData, options = {}) {
135
+ return new Promise((resolve, reject) => {
136
+ const parsedUrl = new URL(url);
137
+ const isHttps = parsedUrl.protocol === 'https:';
138
+ const requestModule = isHttps ? https : http;
139
+
140
+ const reqOptions = {
141
+ hostname: parsedUrl.hostname,
142
+ port: parsedUrl.port || (isHttps ? 443 : 80),
143
+ path: parsedUrl.pathname + parsedUrl.search,
144
+ method: 'POST',
145
+ headers: {
146
+ 'Content-Type': options.contentType || 'application/x-www-form-urlencoded',
147
+ 'Content-Length': Buffer.byteLength(postData),
148
+ 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36',
149
+ Accept: 'application/json, text/plain, */*',
150
+ ...(options.cookieHeader ? { Cookie: options.cookieHeader } : {}),
151
+ ...(options.referer ? { Referer: options.referer } : {}),
152
+ ...(options.origin ? { Origin: options.origin } : {}),
153
+ ...(options.headers || {}),
154
+ },
155
+ timeout: 30000,
156
+ };
157
+
158
+ const req = requestModule.request(reqOptions, (res) => {
159
+ let body = '';
160
+ res.on('data', (chunk) => { body += chunk; });
161
+ res.on('end', () => {
162
+ const setCookieHeaders = res.headers['set-cookie'] || [];
163
+ resolve({ body, cookies: setCookieHeaders, statusCode: res.statusCode, headers: res.headers });
164
+ });
165
+ });
166
+
167
+ req.on('timeout', () => { req.destroy(); reject(new Error(t('common.request_timeout'))); });
168
+ req.on('error', reject);
169
+ req.write(postData);
170
+ req.end();
171
+ });
172
+ }
173
+
174
+ function isRedirectStatus(statusCode) {
175
+ return [301, 302, 303, 307, 308].includes(statusCode);
176
+ }
177
+
178
+ /**
179
+ * 跟随 GET 重定向,并持续合并 Set-Cookie。
180
+ * @param {string} url
181
+ * @param {object} [options]
182
+ * @param {number} [maxRedirects]
183
+ * @returns {Promise<{ body: string, cookies: string[], statusCode: number, headers: object, finalUrl: string, cookieHeader: string }>}
184
+ */
185
+ async function fetchGetFollowRedirects(url, options = {}, maxRedirects = 10) {
186
+ let currentUrl = url;
187
+ let referer = options.referer;
188
+ let cookieHeader = options.cookieHeader || '';
189
+
190
+ for (let redirectCount = 0; redirectCount <= maxRedirects; redirectCount++) {
191
+ const response = await fetchGet(currentUrl, {
192
+ ...options,
193
+ cookieHeader,
194
+ referer,
195
+ });
196
+
197
+ cookieHeader = mergeCookies(cookieHeader, response.cookies);
198
+
199
+ const location = response.headers && response.headers.location;
200
+ if (!isRedirectStatus(response.statusCode) || !location) {
201
+ return {
202
+ ...response,
203
+ finalUrl: currentUrl,
204
+ cookieHeader,
205
+ };
206
+ }
207
+
208
+ referer = currentUrl;
209
+ currentUrl = new URL(location, currentUrl).toString();
210
+ }
211
+
212
+ throw new Error(t('qr_login.get_qr_failed', 'too many redirects'));
213
+ }
214
+
215
+ // ── Cookie 工具 ───────────────────────────────────────
216
+
217
+ /**
218
+ * 将 Set-Cookie 响应头数组解析为 name=value 格式的 Cookie 字符串。
219
+ * @param {string[]} setCookieHeaders
220
+ * @returns {string}
221
+ */
222
+ function buildCookieHeader(setCookieHeaders) {
223
+ return setCookieHeaders
224
+ .map((header) => header.split(';')[0].trim())
225
+ .join('; ');
226
+ }
227
+
228
+ /**
229
+ * 将 Set-Cookie 响应头数组合并到已有 Cookie 字符串中(去重,新值覆盖旧值)。
230
+ * @param {string} existingCookieHeader
231
+ * @param {string[]} newSetCookieHeaders
232
+ * @returns {string}
233
+ */
234
+ function mergeCookies(existingCookieHeader, newSetCookieHeaders) {
235
+ const cookieMap = new Map();
236
+
237
+ // 解析已有 Cookie
238
+ if (existingCookieHeader) {
239
+ for (const pair of existingCookieHeader.split(';')) {
240
+ const trimmed = pair.trim();
241
+ const eqIndex = trimmed.indexOf('=');
242
+ if (eqIndex > 0) {
243
+ cookieMap.set(trimmed.slice(0, eqIndex).trim(), trimmed.slice(eqIndex + 1).trim());
244
+ }
245
+ }
246
+ }
247
+
248
+ // 合并新 Cookie(覆盖旧值)
249
+ for (const header of newSetCookieHeaders) {
250
+ const pair = header.split(';')[0].trim();
251
+ const eqIndex = pair.indexOf('=');
252
+ if (eqIndex > 0) {
253
+ cookieMap.set(pair.slice(0, eqIndex).trim(), pair.slice(eqIndex + 1).trim());
254
+ }
255
+ }
256
+
257
+ return Array.from(cookieMap.entries())
258
+ .map(([name, value]) => `${name}=${value}`)
259
+ .join('; ');
260
+ }
261
+
262
+ /**
263
+ * 将 Cookie 字符串转换为 Playwright 格式的 Cookie 对象数组。
264
+ * @param {string} cookieHeader
265
+ * @param {string} domain
266
+ * @returns {Array<{ name: string, value: string, domain: string, path: string }>}
267
+ */
268
+ function cookieHeaderToObjects(cookieHeader, domain) {
269
+ return cookieHeader
270
+ .split(';')
271
+ .map((pair) => pair.trim())
272
+ .filter(Boolean)
273
+ .map((pair) => {
274
+ const eqIndex = pair.indexOf('=');
275
+ if (eqIndex < 0) {return null;}
276
+ return {
277
+ name: pair.slice(0, eqIndex).trim(),
278
+ value: pair.slice(eqIndex + 1).trim(),
279
+ domain,
280
+ path: '/',
281
+ };
282
+ })
283
+ .filter(Boolean);
284
+ }
285
+
286
+ // ── 二维码渲染 ────────────────────────────────────────
287
+
288
+ /**
289
+ * 查找 qrcode 模块。
290
+ * @param {Function} [requireFn]
291
+ * @returns {object|null}
292
+ */
293
+ function resolveQrcodeModule(requireFn = require) {
294
+ try {
295
+ return requireFn('qrcode');
296
+ } catch {
297
+ // qrcode 未安装,尝试从全局或相邻路径加载
298
+ const path = require('path');
299
+ const candidates = [
300
+ path.join(__dirname, '..', 'node_modules', 'qrcode'),
301
+ path.join(__dirname, '..', '..', 'node_modules', 'qrcode'),
302
+ ];
303
+ for (const candidate of candidates) {
304
+ try {
305
+ return requireFn(candidate);
306
+ } catch {
307
+ // continue
308
+ }
309
+ }
310
+ }
311
+
312
+ return null;
313
+ }
314
+
315
+ /**
316
+ * 在终端渲染二维码。
317
+ * 优先使用 qrcode 包的 toString 方法(小尺寸),若不可用则降级输出 URL。
318
+ * 二维码本体必须直接写入终端,不能加 warn/info 前缀,否则可能破坏终端二维码的对齐。
319
+ * @param {string} url - 要编码的 URL
320
+ * @param {object} [options] - 测试注入选项
321
+ * @param {object|null} [options.qrcode] - qrcode 模块
322
+ * @param {Function} [options.requireFn] - 自定义 require
323
+ * @param {Function} [options.writeFn] - 直接输出二维码
324
+ * @param {Function} [options.warnFn] - 输出 fallback/错误提示
325
+ */
326
+ async function renderQrCodeInTerminal(url, options = {}) {
327
+ const qrcode = Object.prototype.hasOwnProperty.call(options, 'qrcode')
328
+ ? options.qrcode
329
+ : resolveQrcodeModule(options.requireFn || require);
330
+ const writeFn = options.writeFn || ((text) => process.stderr.write(text));
331
+ const warnFn = options.warnFn || warn;
332
+
333
+ try {
334
+ if (qrcode && typeof qrcode.toString === 'function') {
335
+ const qrString = await qrcode.toString(url, {
336
+ type: 'terminal',
337
+ small: true,
338
+ errorCorrectionLevel: 'M',
339
+ });
340
+ writeFn(qrString.endsWith('\n') ? qrString : `${qrString}\n`);
341
+ } else {
342
+ // 降级:输出 URL 提示用户手动访问
343
+ warnFn(t('qr_login.qrcode_fallback'));
344
+ warnFn(` ${url}`);
345
+ }
346
+ } catch (err) {
347
+ warnFn(t('qr_login.qrcode_render_failed', err.message));
348
+ warnFn(` ${url}`);
349
+ }
350
+ }
351
+
352
+ async function writeQrCodeImage(url, filePath, options = {}) {
353
+ const qrcode = Object.prototype.hasOwnProperty.call(options, 'qrcode')
354
+ ? options.qrcode
355
+ : resolveQrcodeModule(options.requireFn || require);
356
+
357
+ if (!qrcode || typeof qrcode.toFile !== 'function') {
358
+ return false;
359
+ }
360
+
361
+ await qrcode.toFile(filePath, url, {
362
+ type: 'png',
363
+ margin: 2,
364
+ width: 360,
365
+ errorCorrectionLevel: 'M',
366
+ });
367
+ return true;
368
+ }
369
+
370
+ function shouldPreferQrImageInTerminal(options = {}) {
371
+ if (Object.prototype.hasOwnProperty.call(options, 'preferImage')) {
372
+ return !!options.preferImage;
373
+ }
374
+ return (options.platform || process.platform) === 'win32';
375
+ }
376
+
377
+ function shouldAutoOpenQrImageFile(options = {}) {
378
+ if (Object.prototype.hasOwnProperty.call(options, 'autoOpenImage')) {
379
+ return !!options.autoOpenImage;
380
+ }
381
+ return (options.platform || process.platform) === 'win32';
382
+ }
383
+
384
+ function getTerminalQrImageFile(options = {}) {
385
+ const fs = require('fs');
386
+ const path = require('path');
387
+ const projectRoot = options.projectRoot || findProjectRoot();
388
+ const qrDir = path.join(projectRoot, '.cache', 'qr-login');
389
+ const fileName = options.fileName || `login-${Date.now().toString(36)}.png`;
390
+ fs.mkdirSync(qrDir, { recursive: true });
391
+ return path.join(qrDir, fileName);
392
+ }
393
+
394
+ function openQrCodeImage(filePath, options = {}) {
395
+ const platform = options.platform || process.platform;
396
+ const spawnFn = options.spawnFn || require('child_process').spawn;
397
+ let command;
398
+ let args;
399
+
400
+ if (platform === 'win32') {
401
+ command = 'explorer.exe';
402
+ args = [filePath];
403
+ } else if (platform === 'darwin') {
404
+ command = 'open';
405
+ args = [filePath];
406
+ } else {
407
+ command = 'xdg-open';
408
+ args = [filePath];
409
+ }
410
+
411
+ try {
412
+ const child = spawnFn(command, args, {
413
+ detached: true,
414
+ stdio: 'ignore',
415
+ windowsHide: true,
416
+ });
417
+ if (child && typeof child.unref === 'function') {
418
+ child.unref();
419
+ }
420
+ return true;
421
+ } catch {
422
+ return false;
423
+ }
424
+ }
425
+
426
+ async function displayQrCodeForLogin(url, options = {}) {
427
+ if (!shouldPreferQrImageInTerminal(options)) {
428
+ await renderQrCodeInTerminal(url, options);
429
+ return {
430
+ terminalRendered: true,
431
+ imageWritten: false,
432
+ imageOpened: false,
433
+ imageFile: null,
434
+ };
435
+ }
436
+
437
+ const imageFile = options.qrImageFile || getTerminalQrImageFile(options);
438
+ try {
439
+ const imageWritten = await writeQrCodeImage(url, imageFile, options);
440
+ if (imageWritten) {
441
+ return {
442
+ terminalRendered: false,
443
+ imageWritten: true,
444
+ imageOpened: openQrCodeImage(imageFile, options),
445
+ imageFile,
446
+ };
447
+ }
448
+ } catch {
449
+ // Fall back to the terminal renderer below.
450
+ }
451
+
452
+ await renderQrCodeInTerminal(url, options);
453
+ return {
454
+ terminalRendered: true,
455
+ imageWritten: false,
456
+ imageOpened: false,
457
+ imageFile: null,
458
+ };
459
+ }
460
+
461
+ // ── 宜搭登录 API ──────────────────────────────────────
462
+
463
+ function isDingtalkOAuthChallengeUrl(url) {
464
+ try {
465
+ const parsedUrl = new URL(url);
466
+ const hostname = parsedUrl.hostname;
467
+ const isDingtalkDomain = hostname.endsWith('dingtalk.com') || hostname.endsWith('dingtalk.io');
468
+ return isDingtalkDomain && parsedUrl.pathname.startsWith('/oauth2/');
469
+ } catch {
470
+ return false;
471
+ }
472
+ }
473
+
474
+ function buildOAuthPostData(loginPageUrl, extraParams = {}) {
475
+ const parsedUrl = new URL(loginPageUrl);
476
+ const params = new URLSearchParams(parsedUrl.searchParams);
477
+
478
+ Object.entries(extraParams).forEach(([key, value]) => {
479
+ if (value !== undefined && value !== null) {
480
+ params.set(key, String(value));
481
+ }
482
+ });
483
+
484
+ return params.toString();
485
+ }
486
+
487
+ function extractDingtalkQrCode(qrUrl) {
488
+ try {
489
+ return new URL(qrUrl).searchParams.get('code');
490
+ } catch {
491
+ const matched = qrUrl.match(/[?&]code=([^&]+)/);
492
+ return matched ? decodeURIComponent(matched[1]) : null;
493
+ }
494
+ }
495
+
496
+ function resolveDingtalkLoginResultUrl(loginResult) {
497
+ if (typeof loginResult === 'string') {
498
+ return loginResult;
499
+ }
500
+
501
+ if (!loginResult || typeof loginResult !== 'object') {
502
+ return null;
503
+ }
504
+
505
+ return loginResult.url ||
506
+ loginResult.redirectUrl ||
507
+ loginResult.loginUrl ||
508
+ loginResult.nextUrl ||
509
+ null;
510
+ }
511
+
512
+ function isDingtalkOAuthPassResult(loginResult) {
513
+ return !!(
514
+ loginResult &&
515
+ typeof loginResult === 'object' &&
516
+ loginResult.pass === true
517
+ );
518
+ }
519
+
520
+ function getDingtalkOAuthRedirectUri(context = {}) {
521
+ try {
522
+ const parsedUrl = new URL(context.loginPageUrl);
523
+ return parsedUrl.searchParams.get('redirect_uri') || '';
524
+ } catch {
525
+ return '';
526
+ }
527
+ }
528
+
529
+ async function confirmDingtalkOAuthAuth(context, cookieHeader, loginResult = {}, options = {}) {
530
+ const postConfirmAuth = options.fetchPost || fetchPost;
531
+ const confirmUrl = `${context.origin}/oauth2/confirm_auth`;
532
+ const targetCorpId = options.corpId || loginResult.corpId || context.corpId || '';
533
+ const redirectUri = getDingtalkOAuthRedirectUri(context);
534
+
535
+ const confirmParams = {};
536
+ if (targetCorpId) {confirmParams.corpId = targetCorpId;}
537
+ if (loginResult.secondaryValidationResult) {
538
+ confirmParams.secondaryValidationResult = loginResult.secondaryValidationResult;
539
+ }
540
+ if (redirectUri) {confirmParams.redirect_uri = redirectUri;}
541
+
542
+ const response = await postConfirmAuth(confirmUrl, buildOAuthPostData(context.loginPageUrl, confirmParams), {
543
+ cookieHeader,
544
+ referer: context.loginPageUrl,
545
+ origin: context.origin,
546
+ });
547
+
548
+ const updatedCookieHeader = mergeCookies(cookieHeader, response.cookies);
549
+ let parsed;
550
+ try {
551
+ parsed = JSON.parse(response.body);
552
+ } catch {
553
+ throw new Error(t('qr_login.exchange_failed', response.body.substring(0, 200)));
554
+ }
555
+
556
+ if (!parsed.success) {
557
+ throw new Error(t('qr_login.exchange_api_failed', parsed.errorMsg || JSON.stringify(parsed)));
558
+ }
559
+
560
+ const result = parsed.result || parsed.content || parsed;
561
+ const redirectUrl = resolveDingtalkLoginResultUrl(result);
562
+ if (!redirectUrl) {
563
+ throw new Error(t('qr_login.exchange_api_failed', JSON.stringify(result)));
564
+ }
565
+
566
+ return {
567
+ redirectUrl,
568
+ cookieHeader: updatedCookieHeader,
569
+ };
570
+ }
571
+
572
+ function shouldChooseDingtalkOAuthOrganization(loginResult) {
573
+ return !!(
574
+ loginResult &&
575
+ typeof loginResult === 'object' &&
576
+ loginResult.chooseOrganization === true &&
577
+ Array.isArray(loginResult.orgList)
578
+ );
579
+ }
580
+
581
+ function normalizeDingtalkOAuthOrgList(orgList) {
582
+ if (!Array.isArray(orgList)) {return [];}
583
+
584
+ return orgList
585
+ .map((org) => ({
586
+ corpId: org.corpId || org.corpID || org.id,
587
+ corpName: org.corpName || org.name || org.corpId || org.id,
588
+ mainOrg: !!org.mainOrg,
589
+ }))
590
+ .filter((org) => org.corpId);
591
+ }
592
+
593
+ function selectCorpById(corpList, targetCorpId) {
594
+ const selectedCorp = corpList.find((corp) => corp.corpId === targetCorpId);
595
+ if (!selectedCorp) {
596
+ throw new Error(t('qr_login.target_corp_not_found', targetCorpId));
597
+ }
598
+ return selectedCorp;
599
+ }
600
+
601
+ async function resolveCorpSelection(corpList, options = {}) {
602
+ const targetCorpId = options.corpId || options.targetCorpId;
603
+ if (targetCorpId) {
604
+ return selectCorpById(corpList, targetCorpId);
605
+ }
606
+
607
+ const selectCorp = options.selectCorp || selectCorpInteractively;
608
+ return selectCorp(corpList);
609
+ }
610
+
611
+ function deriveAliworkBaseUrl(fallbackBaseUrl, finalUrl) {
612
+ return deriveBaseUrlFromUrl(fallbackBaseUrl, finalUrl);
613
+ }
614
+
615
+ /**
616
+ * Step 1:访问宜搭登录页,获取初始 Cookie 和 CSRF Token。
617
+ * @param {string} baseUrl
618
+ * @param {object} [options]
619
+ * @returns {Promise<{ cookieHeader: string, loginPageUrl: string }>}
620
+ */
621
+ async function fetchInitialSession(baseUrl, options = {}) {
622
+ const loginPageUrl = options.loginUrl ||
623
+ (options.baseUrl ? `${baseUrl}/workPlatform` : (resolveLoginUrl() || `${baseUrl}/workPlatform`));
624
+ const response = await fetchGetFollowRedirects(loginPageUrl, {
625
+ cookieHeader: options.cookieHeader || '',
626
+ });
627
+
628
+ return {
629
+ cookieHeader: response.cookieHeader || buildCookieHeader(response.cookies),
630
+ loginPageUrl: response.finalUrl,
631
+ };
632
+ }
633
+
634
+ /**
635
+ * 旧版宜搭二维码接口。保留给私有化/旧环境兜底。
636
+ * @param {string} baseUrl
637
+ * @param {string} cookieHeader
638
+ * @returns {Promise<{ qrUrl: string, state: string, cookieHeader: string, context: object }>}
639
+ */
640
+ async function fetchLegacyQrCodeUrl(baseUrl, cookieHeader) {
641
+ const apiUrl = `${baseUrl}/dingtalk/web/getLoginQrCode.json`;
642
+ const response = await fetchGet(apiUrl, {
643
+ cookieHeader,
644
+ referer: `${baseUrl}/login.html`,
645
+ });
646
+
647
+ const updatedCookieHeader = mergeCookies(cookieHeader, response.cookies);
648
+
649
+ let parsed;
650
+ try {
651
+ parsed = JSON.parse(response.body);
652
+ } catch {
653
+ throw new Error(t('qr_login.get_qr_failed', response.body.substring(0, 200)));
654
+ }
655
+
656
+ if (!parsed.success || !parsed.content) {
657
+ throw new Error(t('qr_login.get_qr_api_failed', parsed.errorMsg || JSON.stringify(parsed)));
658
+ }
659
+
660
+ const { qrUrl, state } = parsed.content;
661
+ return {
662
+ qrUrl,
663
+ state,
664
+ cookieHeader: updatedCookieHeader,
665
+ context: { type: 'legacy' },
666
+ };
667
+ }
668
+
669
+ async function fetchDingtalkOAuthQrCodeUrl(loginPageUrl, cookieHeader, options = {}) {
670
+ const parsedLoginUrl = new URL(loginPageUrl);
671
+ const origin = parsedLoginUrl.origin;
672
+ const apiUrl = `${origin}/oauth2/generate_qrcode`;
673
+ const targetCorpId = getTargetCorpId(options);
674
+
675
+ const response = await fetchPost(apiUrl, buildOAuthPostData(loginPageUrl, {
676
+ ...(targetCorpId ? { corpId: targetCorpId } : {}),
677
+ }), {
678
+ cookieHeader,
679
+ referer: loginPageUrl,
680
+ origin,
681
+ });
682
+
683
+ const updatedCookieHeader = mergeCookies(cookieHeader, response.cookies);
684
+
685
+ let parsed;
686
+ try {
687
+ parsed = JSON.parse(response.body);
688
+ } catch {
689
+ throw new Error(t('qr_login.get_qr_failed', response.body.substring(0, 200)));
690
+ }
691
+
692
+ const qrUrl = parsed.result || parsed.content || parsed.qrUrl;
693
+ if (!parsed.success || !qrUrl) {
694
+ throw new Error(t('qr_login.get_qr_api_failed', parsed.errorMsg || JSON.stringify(parsed)));
695
+ }
696
+
697
+ const code = extractDingtalkQrCode(qrUrl);
698
+ if (!code) {
699
+ throw new Error(t('qr_login.get_qr_api_failed', JSON.stringify(parsed)));
700
+ }
701
+
702
+ return {
703
+ qrUrl,
704
+ state: code,
705
+ cookieHeader: updatedCookieHeader,
706
+ context: {
707
+ type: 'dingtalk_oauth',
708
+ loginPageUrl,
709
+ origin,
710
+ code,
711
+ corpId: targetCorpId,
712
+ },
713
+ };
714
+ }
715
+
716
+ /**
717
+ * Step 2:获取钉钉扫码登录的二维码信息。
718
+ * 当前公有云使用钉钉 OAuth 二维码;旧环境继续走旧版宜搭接口。
719
+ * @param {string} baseUrl
720
+ * @param {string} cookieHeader
721
+ * @param {string} loginPageUrl
722
+ * @returns {Promise<{ qrUrl: string, state: string, cookieHeader: string, context: object }>}
723
+ */
724
+ async function fetchQrCodeUrl(baseUrl, cookieHeader, loginPageUrl, options = {}) {
725
+ if (isDingtalkOAuthChallengeUrl(loginPageUrl)) {
726
+ return fetchDingtalkOAuthQrCodeUrl(loginPageUrl, cookieHeader, options);
727
+ }
728
+
729
+ return fetchLegacyQrCodeUrl(baseUrl, cookieHeader);
730
+ }
731
+
732
+ async function postDingtalkOAuthLoginWithQr(context, cookieHeader, extraParams = {}) {
733
+ const pollUrl = `${context.origin}/oauth2/login_with_qr`;
734
+ const response = await fetchPost(pollUrl, buildOAuthPostData(context.loginPageUrl, {
735
+ code: context.code,
736
+ ...(context.corpId ? { corpId: context.corpId } : {}),
737
+ stayLogin: false,
738
+ ...extraParams,
739
+ }), {
740
+ cookieHeader,
741
+ referer: context.loginPageUrl,
742
+ origin: context.origin,
743
+ });
744
+
745
+ const updatedCookieHeader = mergeCookies(cookieHeader, response.cookies);
746
+
747
+ let parsed;
748
+ try {
749
+ parsed = JSON.parse(response.body);
750
+ } catch {
751
+ parsed = null;
752
+ }
753
+
754
+ return {
755
+ parsed,
756
+ cookieHeader: updatedCookieHeader,
757
+ };
758
+ }
759
+
760
+ async function pollDingtalkQrCodeStatus(state, cookieHeader, onWaiting, context, options = {}) {
761
+ const maxAttempts = options.maxAttempts || 120;
762
+ const pollIntervalMs = Object.prototype.hasOwnProperty.call(options, 'pollIntervalMs')
763
+ ? options.pollIntervalMs
764
+ : 1000;
765
+ const postLoginWithQr = options.postLoginWithQr || postDingtalkOAuthLoginWithQr;
766
+ const targetCorpId = getTargetCorpId(options);
767
+
768
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
769
+ if (pollIntervalMs > 0) {
770
+ await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
771
+ }
772
+
773
+ const { parsed, cookieHeader: updatedCookieHeader } = await postLoginWithQr(context, cookieHeader, {
774
+ code: state,
775
+ ...(targetCorpId ? { corpId: targetCorpId } : {}),
776
+ stayLogin: false,
777
+ });
778
+ cookieHeader = updatedCookieHeader;
779
+
780
+ if (!parsed) {
781
+ continue;
782
+ }
783
+
784
+ if (parsed.success) {
785
+ return {
786
+ loginResult: parsed.result || parsed.content || parsed,
787
+ cookieHeader,
788
+ };
789
+ }
790
+
791
+ const errorCode = String(parsed.errorCode || '');
792
+ if (errorCode === '11041') {
793
+ if (onWaiting) {onWaiting('scanned');}
794
+ continue;
795
+ }
796
+
797
+ if (errorCode === '11021') {
798
+ continue;
799
+ }
800
+
801
+ if (errorCode === '11019') {
802
+ throw new Error(t('qr_login.qr_expired'));
803
+ }
804
+
805
+ const errorMessage = parsed.errorMsg || parsed.message || '';
806
+ if (targetCorpId && errorMessage.includes('对应组织的企业账号')) {
807
+ if (onWaiting) {onWaiting('scanned');}
808
+ continue;
809
+ }
810
+
811
+ throw new Error(parsed.errorMsg || JSON.stringify(parsed));
812
+ }
813
+
814
+ throw new Error(t('qr_login.poll_timeout'));
815
+ }
816
+
817
+ /**
818
+ * Step 3:轮询扫码状态,等待用户扫码并确认。
819
+ * @param {string} baseUrl
820
+ * @param {string} state - 二维码状态标识
821
+ * @param {string} cookieHeader
822
+ * @param {Function} onWaiting - 等待回调(可用于显示进度)
823
+ * @param {object} [context]
824
+ * @returns {Promise<{ authCode?: string, loginResult?: object|string, cookieHeader: string }>}
825
+ */
826
+ async function pollQrCodeStatus(baseUrl, state, cookieHeader, onWaiting, context = {}, options = {}) {
827
+ if (context.type === 'dingtalk_oauth') {
828
+ return pollDingtalkQrCodeStatus(state, cookieHeader, onWaiting, context, options);
829
+ }
830
+
831
+ const pollUrl = `${baseUrl}/dingtalk/web/checkLoginQrCode.json`;
832
+ const maxAttempts = 120; // 最多轮询 2 分钟(每秒一次)
833
+ const pollIntervalMs = 1000;
834
+
835
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
836
+ await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
837
+
838
+ const response = await fetchGet(`${pollUrl}?state=${encodeURIComponent(state)}`, {
839
+ cookieHeader,
840
+ referer: `${baseUrl}/login.html`,
841
+ });
842
+
843
+ const updatedCookieHeader = mergeCookies(cookieHeader, response.cookies);
844
+ cookieHeader = updatedCookieHeader;
845
+
846
+ let parsed;
847
+ try {
848
+ parsed = JSON.parse(response.body);
849
+ } catch {
850
+ continue;
851
+ }
852
+
853
+ if (!parsed.success) {continue;}
854
+
855
+ const { status, authCode } = parsed.content || {};
856
+
857
+ if (status === 'scanned') {
858
+ // 已扫码,等待用户在手机上确认
859
+ if (onWaiting) {onWaiting('scanned');}
860
+ } else if (status === 'confirmed' && authCode) {
861
+ // 用户已确认,返回 authCode
862
+ return { authCode, cookieHeader };
863
+ } else if (status === 'expired') {
864
+ throw new Error(t('qr_login.qr_expired'));
865
+ }
866
+ }
867
+
868
+ throw new Error(t('qr_login.poll_timeout'));
869
+ }
870
+
871
+ /**
872
+ * Step 4:用 authCode 换取登录 Cookie。
873
+ * @param {string} baseUrl
874
+ * @param {string} authCode
875
+ * @param {string} cookieHeader
876
+ * @returns {Promise<{ cookieHeader: string }>}
877
+ */
878
+ async function exchangeAuthCodeForCookie(baseUrl, authCode, cookieHeader) {
879
+ const exchangeUrl = `${baseUrl}/dingtalk/web/loginByAuthCode.json`;
880
+ const postData = `authCode=${encodeURIComponent(authCode)}`;
881
+
882
+ const response = await fetchPost(exchangeUrl, postData, {
883
+ cookieHeader,
884
+ referer: `${baseUrl}/login.html`,
885
+ });
886
+
887
+ const updatedCookieHeader = mergeCookies(cookieHeader, response.cookies);
888
+
889
+ let parsed;
890
+ try {
891
+ parsed = JSON.parse(response.body);
892
+ } catch {
893
+ throw new Error(t('qr_login.exchange_failed', response.body.substring(0, 200)));
894
+ }
895
+
896
+ if (!parsed.success) {
897
+ throw new Error(t('qr_login.exchange_api_failed', parsed.errorMsg || JSON.stringify(parsed)));
898
+ }
899
+
900
+ return { cookieHeader: updatedCookieHeader };
901
+ }
902
+
903
+ async function exchangeDingtalkOAuthResult(baseUrl, loginResult, cookieHeader, context, options = {}) {
904
+ const getFollowRedirects = options.fetchGetFollowRedirects || fetchGetFollowRedirects;
905
+ let selectedCorp = null;
906
+ const resolvedLoginResult = loginResult;
907
+
908
+ if (shouldChooseDingtalkOAuthOrganization(resolvedLoginResult)) {
909
+ const corpList = normalizeDingtalkOAuthOrgList(resolvedLoginResult.orgList);
910
+ selectedCorp = await resolveCorpSelection(corpList, options);
911
+ }
912
+
913
+ let redirectUrl = resolveDingtalkLoginResultUrl(resolvedLoginResult);
914
+ if (!redirectUrl && (selectedCorp || isDingtalkOAuthPassResult(resolvedLoginResult))) {
915
+ const confirmResult = await confirmDingtalkOAuthAuth(context, cookieHeader, resolvedLoginResult, {
916
+ ...options,
917
+ corpId: options.corpId || selectedCorp?.corpId || resolvedLoginResult.corpId || context.corpId,
918
+ });
919
+ redirectUrl = confirmResult.redirectUrl;
920
+ cookieHeader = confirmResult.cookieHeader;
921
+ }
922
+
923
+ if (!redirectUrl) {
924
+ throw new Error(t('qr_login.exchange_api_failed', JSON.stringify(resolvedLoginResult)));
925
+ }
926
+
927
+ const response = await getFollowRedirects(redirectUrl, {
928
+ cookieHeader,
929
+ referer: context.loginPageUrl,
930
+ });
931
+
932
+ return {
933
+ cookieHeader: response.cookieHeader,
934
+ baseUrl: deriveAliworkBaseUrl(baseUrl, response.finalUrl),
935
+ selectedCorp,
936
+ };
937
+ }
938
+
939
+ /**
940
+ * Step 5:获取用户可访问的组织列表。
941
+ * @param {string} baseUrl
942
+ * @param {string} cookieHeader
943
+ * @returns {Promise<Array<{ corpId: string, corpName: string }>>}
944
+ */
945
+ async function fetchCorpList(baseUrl, cookieHeader) {
946
+ const apiUrl = `${baseUrl}/dingtalk/web/getCorpList.json`;
947
+ const response = await fetchGet(apiUrl, {
948
+ cookieHeader,
949
+ referer: `${baseUrl}/workPlatform`,
950
+ });
951
+
952
+ let parsed;
953
+ try {
954
+ parsed = JSON.parse(response.body);
955
+ } catch {
956
+ throw new Error(t('qr_login.get_corp_list_failed', response.body.substring(0, 200)));
957
+ }
958
+
959
+ if (!parsed.success || !parsed.content) {
960
+ throw new Error(t('qr_login.get_corp_list_api_failed', parsed.errorMsg || JSON.stringify(parsed)));
961
+ }
962
+
963
+ // 兼容不同的响应结构
964
+ const corpList = Array.isArray(parsed.content)
965
+ ? parsed.content
966
+ : parsed.content.corpList || parsed.content.list || [];
967
+
968
+ return corpList.map((corp) => ({
969
+ corpId: corp.corpId || corp.id,
970
+ corpName: corp.corpName || corp.name || corp.corpId,
971
+ }));
972
+ }
973
+
974
+ /**
975
+ * Step 6:切换到指定组织,获取该组织的登录 Cookie。
976
+ * @param {string} baseUrl
977
+ * @param {string} corpId
978
+ * @param {string} cookieHeader
979
+ * @returns {Promise<{ cookieHeader: string }>}
980
+ */
981
+ async function switchCorp(baseUrl, corpId, cookieHeader) {
982
+ const switchUrl = `${baseUrl}/dingtalk/web/switchCorp.json`;
983
+ const postData = `corpId=${encodeURIComponent(corpId)}`;
984
+
985
+ const response = await fetchPost(switchUrl, postData, {
986
+ cookieHeader,
987
+ referer: `${baseUrl}/workPlatform`,
988
+ });
989
+
990
+ const updatedCookieHeader = mergeCookies(cookieHeader, response.cookies);
991
+
992
+ let parsed;
993
+ try {
994
+ parsed = JSON.parse(response.body);
995
+ } catch {
996
+ // 切换组织可能不返回 JSON,直接使用更新后的 Cookie
997
+ return { cookieHeader: updatedCookieHeader };
998
+ }
999
+
1000
+ if (parsed.success === false) {
1001
+ throw new Error(t('qr_login.switch_corp_failed', parsed.errorMsg || JSON.stringify(parsed)));
1002
+ }
1003
+
1004
+ return { cookieHeader: updatedCookieHeader };
1005
+ }
1006
+
1007
+ // ── 交互式组织选择 ────────────────────────────────────
1008
+
1009
+ /**
1010
+ * 在终端交互式地让用户选择组织。
1011
+ * @param {Array<{ corpId: string, corpName: string }>} corpList
1012
+ * @returns {Promise<{ corpId: string, corpName: string }>}
1013
+ */
1014
+ async function selectCorpInteractively(corpList) {
1015
+ if (corpList.length === 0) {
1016
+ throw new Error(t('qr_login.no_corp_available'));
1017
+ }
1018
+
1019
+ if (corpList.length === 1) {
1020
+ const { info: scInfo } = require('../core/chalk');
1021
+ scInfo(t('qr_login.only_one_corp', corpList[0].corpName));
1022
+ return corpList[0];
1023
+ }
1024
+
1025
+ const { c: sc } = require('../core/chalk');
1026
+ process.stderr.write(`\n ${sc.bold}${t('qr_login.select_corp_prompt')}${sc.reset}\n\n`);
1027
+
1028
+ corpList.forEach((corp, index) => {
1029
+ process.stderr.write(` ${sc.cyan}${index + 1}.${sc.reset} ${corp.corpName} ${sc.dim}(${corp.corpId})${sc.reset}\n`);
1030
+ });
1031
+
1032
+ process.stderr.write('\n');
1033
+
1034
+ return new Promise((resolve, reject) => {
1035
+ let settled = false;
1036
+ const rl = readline.createInterface({
1037
+ input: process.stdin,
1038
+ output: process.stderr,
1039
+ });
1040
+
1041
+ const askQuestion = () => {
1042
+ rl.question(t('qr_login.select_corp_input', corpList.length), (answer) => {
1043
+ const trimmed = answer.trim();
1044
+ const selectedIndex = parseInt(trimmed, 10) - 1;
1045
+
1046
+ if (
1047
+ !isNaN(selectedIndex) &&
1048
+ selectedIndex >= 0 &&
1049
+ selectedIndex < corpList.length
1050
+ ) {
1051
+ settled = true;
1052
+ rl.close();
1053
+ resolve(corpList[selectedIndex]);
1054
+ } else {
1055
+ const { warn: scWarn } = require('../core/chalk');
1056
+ scWarn(t('qr_login.select_corp_invalid', corpList.length));
1057
+ askQuestion();
1058
+ }
1059
+ });
1060
+ };
1061
+
1062
+ rl.on('close', () => {
1063
+ if (!settled) {
1064
+ reject(new Error(t('qr_login.stdin_closed')));
1065
+ }
1066
+ });
1067
+
1068
+ askQuestion();
1069
+ });
1070
+ }
1071
+
1072
+ function getCodexQrSessionPaths(sessionId) {
1073
+ const fs = require('fs');
1074
+ const path = require('path');
1075
+ const projectRoot = findProjectRoot();
1076
+ const sessionDir = path.join(projectRoot, '.cache', 'codex-qr-login');
1077
+ fs.mkdirSync(sessionDir, { recursive: true });
1078
+
1079
+ return {
1080
+ sessionDir,
1081
+ sessionFile: path.join(sessionDir, `${sessionId}.json`),
1082
+ qrImageFile: path.join(sessionDir, `${sessionId}.png`),
1083
+ };
1084
+ }
1085
+
1086
+ function createCodexQrSessionId() {
1087
+ const crypto = require('crypto');
1088
+ return `${Date.now().toString(36)}-${crypto.randomBytes(6).toString('hex')}`;
1089
+ }
1090
+
1091
+ function saveCodexQrSession(sessionFile, session) {
1092
+ const fs = require('fs');
1093
+ fs.writeFileSync(sessionFile, JSON.stringify(session, null, 2), 'utf8');
1094
+ }
1095
+
1096
+ function loadCodexQrSession(sessionFile) {
1097
+ const fs = require('fs');
1098
+ return JSON.parse(fs.readFileSync(sessionFile, 'utf8'));
1099
+ }
1100
+
1101
+ function buildCodexCorpInteraction(corpList) {
1102
+ return {
1103
+ type: 'single_select',
1104
+ title: '选择宜搭组织',
1105
+ options: corpList.map((corp) => ({
1106
+ label: corp.mainOrg ? `${corp.corpName}(主组织)` : corp.corpName,
1107
+ value: corp.corpId,
1108
+ description: corp.corpId,
1109
+ })),
1110
+ };
1111
+ }
1112
+
1113
+ function buildNeedCorpSelectionResult(sessionFile, corpList, envName) {
1114
+ const envArg = envName ? ` --env ${shellQuote(envName)}` : '';
1115
+ return {
1116
+ status: 'need_corp_selection',
1117
+ handoff_type: 'codex_native_select',
1118
+ can_auto_use: false,
1119
+ session_file: sessionFile,
1120
+ organizations: corpList.map((corp) => ({
1121
+ corp_id: corp.corpId,
1122
+ corp_name: corp.corpName,
1123
+ main_org: !!corp.mainOrg,
1124
+ })),
1125
+ interaction: buildCodexCorpInteraction(corpList),
1126
+ select_command_template: `yidaconnector login --codex-select ${shellQuote(sessionFile)}${envArg} --corp-id <corpId>`,
1127
+ };
1128
+ }
1129
+
1130
+ function finalizeCodexQrLogin(baseUrl, cookieHeader, selectedCorp = null, sessionFile = null) {
1131
+ const fs = require('fs');
1132
+ const parsedDomain = new URL(baseUrl).hostname;
1133
+ const cookieObjects = cookieHeaderToObjects(cookieHeader, parsedDomain);
1134
+ const { csrfToken, corpId, userId } = extractInfoFromCookies(cookieObjects);
1135
+
1136
+ if (!csrfToken) {
1137
+ throw new Error(t('qr_login.no_csrf_in_cookie'));
1138
+ }
1139
+
1140
+ saveCookieCache(cookieObjects, baseUrl);
1141
+
1142
+ if (sessionFile) {
1143
+ try { fs.unlinkSync(sessionFile); } catch { /* ignore */ }
1144
+ }
1145
+
1146
+ return {
1147
+ ok: true,
1148
+ status: 'ok',
1149
+ can_auto_use: true,
1150
+ csrf_token: csrfToken,
1151
+ corp_id: corpId,
1152
+ user_id: userId,
1153
+ selected_corp: selectedCorp,
1154
+ base_url: baseUrl,
1155
+ cookies: cookieObjects,
1156
+ };
1157
+ }
1158
+
1159
+ async function maybeReturnCorpSelectionAfterExchange(session, sessionFile, options = {}) {
1160
+ const { baseUrl } = session;
1161
+ let { cookieHeader, selectedCorp } = session;
1162
+
1163
+ let corpList = [];
1164
+ try {
1165
+ corpList = await fetchCorpList(baseUrl, cookieHeader);
1166
+ } catch {
1167
+ return finalizeCodexQrLogin(baseUrl, cookieHeader, selectedCorp, sessionFile);
1168
+ }
1169
+
1170
+ const targetCorpId = options.corpId || options.targetCorpId;
1171
+ if (!selectedCorp && targetCorpId && corpList.length > 0) {
1172
+ selectedCorp = selectCorpById(corpList, targetCorpId);
1173
+ if (corpList.length > 1) {
1174
+ ({ cookieHeader } = await switchCorp(baseUrl, selectedCorp.corpId, cookieHeader));
1175
+ }
1176
+ return finalizeCodexQrLogin(baseUrl, cookieHeader, selectedCorp, sessionFile);
1177
+ }
1178
+
1179
+ if (!selectedCorp && corpList.length > 1) {
1180
+ saveCodexQrSession(sessionFile, {
1181
+ ...session,
1182
+ baseUrl,
1183
+ cookieHeader,
1184
+ corpList,
1185
+ stage: 'pending_corp_switch',
1186
+ updatedAt: new Date().toISOString(),
1187
+ });
1188
+ return buildNeedCorpSelectionResult(sessionFile, corpList, session.currentEnvName);
1189
+ }
1190
+
1191
+ if (!selectedCorp && corpList.length === 1) {
1192
+ selectedCorp = corpList[0];
1193
+ }
1194
+
1195
+ return finalizeCodexQrLogin(baseUrl, cookieHeader, selectedCorp, sessionFile);
1196
+ }
1197
+
1198
+ function buildFakeCodexQrLoginResult(options = {}) {
1199
+ const sessionId = 'test-session';
1200
+ const { sessionFile, qrImageFile } = getCodexQrSessionPaths(sessionId);
1201
+ const targetCorpId = getTargetCorpId(options);
1202
+ const currentEnvName = getCurrentEnvConfig().name;
1203
+ saveCodexQrSession(sessionFile, {
1204
+ schema_version: 1,
1205
+ mode: 'codex_qr_login',
1206
+ stage: 'waiting_scan',
1207
+ baseUrl: 'https://www.aliwork.com',
1208
+ qrUrl: 'https://login.example.test/qr?code=test',
1209
+ state: 'test',
1210
+ cookieHeader: '',
1211
+ context: { type: 'legacy' },
1212
+ targetCorpId,
1213
+ currentEnvName,
1214
+ createdAt: new Date().toISOString(),
1215
+ });
1216
+
1217
+ return buildNeedQrScanResult({
1218
+ qrUrl: 'https://login.example.test/qr?code=test',
1219
+ qrImageFile,
1220
+ sessionFile,
1221
+ targetCorpId,
1222
+ envName: currentEnvName,
1223
+ platform: options.platform,
1224
+ });
1225
+ }
1226
+
1227
+ async function startCodexQrLogin(options = {}) {
1228
+ if (process.env.YIDACONNECTOR_CODEX_QR_FAKE === '1') {
1229
+ return buildFakeCodexQrLoginResult(options);
1230
+ }
1231
+
1232
+ const baseUrl = (options.baseUrl || resolveEndpoint(null)).replace(/\/+$/, '');
1233
+ const targetCorpId = getTargetCorpId(options);
1234
+ const currentEnvName = getCurrentEnvConfig().name;
1235
+ const sessionId = options.sessionId || createCodexQrSessionId();
1236
+ const { sessionFile, qrImageFile } = getCodexQrSessionPaths(sessionId);
1237
+
1238
+ const session = await fetchInitialSession(baseUrl, options);
1239
+ let { cookieHeader } = session;
1240
+ const { loginPageUrl } = session;
1241
+ const { qrUrl, state, cookieHeader: updatedCookieHeader, context } = await fetchQrCodeUrl(baseUrl, cookieHeader, loginPageUrl, {
1242
+ corpId: targetCorpId,
1243
+ });
1244
+ cookieHeader = updatedCookieHeader;
1245
+
1246
+ let imageWritten = false;
1247
+ let imageOpened = false;
1248
+ try {
1249
+ imageWritten = await writeQrCodeImage(qrUrl, qrImageFile, options);
1250
+ if (imageWritten && shouldAutoOpenQrImageFile(options)) {
1251
+ imageOpened = openQrCodeImage(qrImageFile, options);
1252
+ }
1253
+ } catch {
1254
+ imageWritten = false;
1255
+ imageOpened = false;
1256
+ }
1257
+
1258
+ saveCodexQrSession(sessionFile, {
1259
+ schema_version: 1,
1260
+ mode: 'codex_qr_login',
1261
+ stage: 'waiting_scan',
1262
+ baseUrl,
1263
+ loginPageUrl,
1264
+ qrUrl,
1265
+ state,
1266
+ cookieHeader,
1267
+ context,
1268
+ targetCorpId,
1269
+ currentEnvName,
1270
+ createdAt: new Date().toISOString(),
1271
+ });
1272
+
1273
+ return buildNeedQrScanResult({
1274
+ qrUrl,
1275
+ qrImageFile: imageWritten ? qrImageFile : null,
1276
+ qrImageOpened: imageOpened,
1277
+ sessionFile,
1278
+ targetCorpId,
1279
+ envName: currentEnvName,
1280
+ platform: options.platform,
1281
+ });
1282
+ }
1283
+
1284
+ async function pollCodexQrLogin(sessionFile, options = {}) {
1285
+ const session = loadCodexQrSession(sessionFile);
1286
+ const { state, context } = session;
1287
+ const targetCorpId = getTargetCorpId(options, session);
1288
+ let { baseUrl, cookieHeader } = session;
1289
+
1290
+ const pollResult = await pollQrCodeStatus(
1291
+ baseUrl,
1292
+ state,
1293
+ cookieHeader,
1294
+ null,
1295
+ context,
1296
+ {
1297
+ ...options,
1298
+ corpId: targetCorpId,
1299
+ }
1300
+ );
1301
+ const { authCode, loginResult } = pollResult;
1302
+ cookieHeader = pollResult.cookieHeader;
1303
+
1304
+ if (context && context.type === 'dingtalk_oauth') {
1305
+ if (shouldChooseDingtalkOAuthOrganization(loginResult)) {
1306
+ const corpList = normalizeDingtalkOAuthOrgList(loginResult.orgList);
1307
+ if (!targetCorpId && corpList.length > 1) {
1308
+ saveCodexQrSession(sessionFile, {
1309
+ ...session,
1310
+ cookieHeader,
1311
+ loginResult,
1312
+ corpList,
1313
+ stage: 'pending_dingtalk_oauth_org',
1314
+ targetCorpId,
1315
+ currentEnvName: session.currentEnvName,
1316
+ updatedAt: new Date().toISOString(),
1317
+ });
1318
+ return buildNeedCorpSelectionResult(sessionFile, corpList, session.currentEnvName);
1319
+ }
1320
+ }
1321
+
1322
+ const exchangeResult = await exchangeDingtalkOAuthResult(baseUrl, loginResult, cookieHeader, context, {
1323
+ ...options,
1324
+ corpId: targetCorpId,
1325
+ selectCorp: (corpList) => corpList[0],
1326
+ });
1327
+ baseUrl = exchangeResult.baseUrl;
1328
+ cookieHeader = exchangeResult.cookieHeader;
1329
+ return maybeReturnCorpSelectionAfterExchange({
1330
+ ...session,
1331
+ baseUrl,
1332
+ cookieHeader,
1333
+ selectedCorp: exchangeResult.selectedCorp,
1334
+ }, sessionFile, {
1335
+ ...options,
1336
+ corpId: targetCorpId,
1337
+ });
1338
+ }
1339
+
1340
+ ({ cookieHeader } = await exchangeAuthCodeForCookie(baseUrl, authCode, cookieHeader));
1341
+ return maybeReturnCorpSelectionAfterExchange({
1342
+ ...session,
1343
+ baseUrl,
1344
+ cookieHeader,
1345
+ selectedCorp: null,
1346
+ }, sessionFile, {
1347
+ ...options,
1348
+ corpId: targetCorpId,
1349
+ });
1350
+ }
1351
+
1352
+ async function selectCodexQrCorp(sessionFile, options = {}) {
1353
+ const corpId = options.corpId || options.targetCorpId;
1354
+ if (!corpId) {
1355
+ throw new Error(t('qr_login.target_corp_not_found', '<empty>'));
1356
+ }
1357
+
1358
+ const session = loadCodexQrSession(sessionFile);
1359
+ let { baseUrl, cookieHeader } = session;
1360
+ const selectedCorp = selectCorpById(session.corpList || [], corpId);
1361
+
1362
+ if (session.stage === 'pending_dingtalk_oauth_org') {
1363
+ const exchangeResult = await exchangeDingtalkOAuthResult(baseUrl, session.loginResult, cookieHeader, session.context, {
1364
+ corpId,
1365
+ });
1366
+ baseUrl = exchangeResult.baseUrl;
1367
+ cookieHeader = exchangeResult.cookieHeader;
1368
+ return maybeReturnCorpSelectionAfterExchange({
1369
+ ...session,
1370
+ baseUrl,
1371
+ cookieHeader,
1372
+ selectedCorp: exchangeResult.selectedCorp || selectedCorp,
1373
+ }, sessionFile, options);
1374
+ }
1375
+
1376
+ if (session.stage === 'pending_corp_switch') {
1377
+ ({ cookieHeader } = await switchCorp(baseUrl, corpId, cookieHeader));
1378
+ return finalizeCodexQrLogin(baseUrl, cookieHeader, selectedCorp, sessionFile);
1379
+ }
1380
+
1381
+ throw new Error(`Unsupported Codex QR session stage: ${session.stage || '<empty>'}`);
1382
+ }
1383
+
1384
+ // ── 主流程 ────────────────────────────────────────────
1385
+
1386
+ /**
1387
+ * 执行完整的终端二维码登录流程。
1388
+ * @param {object} [options]
1389
+ * @param {string} [options.baseUrl] - 宜搭基础 URL(不传则从环境配置自动解析)
1390
+ * @returns {Promise<object>} loginResult - 与 interactiveLogin() 返回格式一致
1391
+ */
1392
+ async function qrLogin(options = {}) {
1393
+ // 优先使用传入的 baseUrl,否则从环境配置解析(支持私有化)
1394
+ let baseUrl = (options.baseUrl || resolveEndpoint(null)).replace(/\/+$/, '');
1395
+ const targetCorpId = getTargetCorpId(options);
1396
+
1397
+ const { banner: qBanner, step: qStep, info: qInfo, success: qSuccess, warn: qWarn, label: qLabel, sep: qSep } = require('../core/chalk');
1398
+
1399
+ qBanner(t('qr_login.title'));
1400
+
1401
+ // Step 1: 获取初始 Session
1402
+ qStep(1, t('qr_login.step_init'));
1403
+ const session = await fetchInitialSession(baseUrl, options);
1404
+ let { cookieHeader } = session;
1405
+ const { loginPageUrl } = session;
1406
+
1407
+ // Step 2: 获取二维码
1408
+ qStep(2, t('qr_login.step_get_qr'));
1409
+ let qrUrl, state, context;
1410
+ try {
1411
+ ({ qrUrl, state, cookieHeader, context } = await fetchQrCodeUrl(baseUrl, cookieHeader, loginPageUrl, {
1412
+ corpId: targetCorpId,
1413
+ }));
1414
+ } catch (err) {
1415
+ throw new Error(t('qr_login.get_qr_error', err.message));
1416
+ }
1417
+
1418
+ // Step 3: 在终端渲染二维码
1419
+ qStep(3, t('qr_login.scan_hint'));
1420
+ process.stderr.write('\n');
1421
+ const qrDisplay = await displayQrCodeForLogin(qrUrl);
1422
+ if (qrDisplay.imageWritten) {
1423
+ qLabel('PNG', qrDisplay.imageFile);
1424
+ }
1425
+ process.stderr.write('\n');
1426
+ qLabel('URL', qrUrl);
1427
+ process.stderr.write('\n');
1428
+ qInfo(t('qr_login.waiting_scan'));
1429
+
1430
+ // Step 4: 轮询扫码状态
1431
+ let scannedMessageShown = false;
1432
+ let authCode, loginResult;
1433
+ try {
1434
+ ({ authCode, loginResult, cookieHeader } = await pollQrCodeStatus(
1435
+ baseUrl,
1436
+ state,
1437
+ cookieHeader,
1438
+ (status) => {
1439
+ if (status === 'scanned' && !scannedMessageShown) {
1440
+ qInfo(t('qr_login.scanned_confirm'));
1441
+ scannedMessageShown = true;
1442
+ }
1443
+ },
1444
+ context,
1445
+ {
1446
+ corpId: targetCorpId,
1447
+ }
1448
+ ));
1449
+ } catch (err) {
1450
+ throw new Error(t('qr_login.poll_error', err.message));
1451
+ }
1452
+
1453
+ qSuccess(t('qr_login.scan_success'));
1454
+
1455
+ // Step 5: 换取登录 Cookie
1456
+ qStep(5, t('qr_login.step_exchange'));
1457
+ let selectedCorp = null;
1458
+ try {
1459
+ if (context && context.type === 'dingtalk_oauth') {
1460
+ const exchangeResult = await exchangeDingtalkOAuthResult(baseUrl, loginResult, cookieHeader, context, {
1461
+ corpId: targetCorpId,
1462
+ });
1463
+ ({ cookieHeader, baseUrl, selectedCorp } = exchangeResult);
1464
+ if (selectedCorp) {
1465
+ qSuccess(t('qr_login.corp_selected', selectedCorp.corpName));
1466
+ }
1467
+ } else {
1468
+ ({ cookieHeader } = await exchangeAuthCodeForCookie(baseUrl, authCode, cookieHeader));
1469
+ }
1470
+ } catch (err) {
1471
+ throw new Error(t('qr_login.exchange_error', err.message));
1472
+ }
1473
+
1474
+ // Step 6: 获取组织列表
1475
+ qStep(6, t('qr_login.step_get_corps'));
1476
+ let corpList = [];
1477
+ try {
1478
+ corpList = await fetchCorpList(baseUrl, cookieHeader);
1479
+ } catch (err) {
1480
+ // 获取组织列表失败不阻断流程,直接使用当前 Cookie
1481
+ qWarn(t('qr_login.get_corps_warn', err.message));
1482
+ }
1483
+
1484
+ // Step 7: 选择组织(如果有多个)
1485
+ if (!selectedCorp && corpList.length > 0) {
1486
+ try {
1487
+ selectedCorp = await resolveCorpSelection(corpList, { corpId: targetCorpId });
1488
+ qSuccess(t('qr_login.corp_selected', selectedCorp.corpName));
1489
+
1490
+ // 切换到目标组织
1491
+ if (corpList.length > 1) {
1492
+ qStep(7, t('qr_login.step_switch_corp'));
1493
+ try {
1494
+ ({ cookieHeader } = await switchCorp(baseUrl, selectedCorp.corpId, cookieHeader));
1495
+ } catch (err) {
1496
+ qWarn(t('qr_login.switch_corp_warn', err.message));
1497
+ }
1498
+ }
1499
+ } catch (err) {
1500
+ qWarn(t('qr_login.select_corp_warn', err.message));
1501
+ }
1502
+ }
1503
+
1504
+ // Step 8: 将 Cookie 字符串转换为对象数组并保存
1505
+ const parsedDomain = new URL(baseUrl).hostname;
1506
+ const cookieObjects = cookieHeaderToObjects(cookieHeader, parsedDomain);
1507
+
1508
+ const { csrfToken, corpId, userId } = extractInfoFromCookies(cookieObjects);
1509
+ if (!csrfToken) {
1510
+ throw new Error(t('qr_login.no_csrf_in_cookie'));
1511
+ }
1512
+
1513
+ saveCookieCache(cookieObjects, baseUrl);
1514
+
1515
+ process.stderr.write('\n');
1516
+ qSuccess(t('qr_login.login_success'));
1517
+ qLabel('CSRF', `${csrfToken.slice(0, 16)}…`);
1518
+ if (corpId) {qLabel('Corp ID', corpId);}
1519
+ process.stderr.write(` ${qSep()}\n\n`);
1520
+
1521
+ return {
1522
+ csrf_token: csrfToken,
1523
+ corp_id: corpId,
1524
+ user_id: userId,
1525
+ base_url: baseUrl,
1526
+ cookies: cookieObjects,
1527
+ };
1528
+ }
1529
+
1530
+ module.exports = {
1531
+ qrLogin,
1532
+ startCodexQrLogin,
1533
+ pollCodexQrLogin,
1534
+ selectCodexQrCorp,
1535
+ __test__: {
1536
+ renderQrCodeInTerminal,
1537
+ writeQrCodeImage,
1538
+ displayQrCodeForLogin,
1539
+ shouldPreferQrImageInTerminal,
1540
+ shouldAutoOpenQrImageFile,
1541
+ getTerminalQrImageFile,
1542
+ openQrCodeImage,
1543
+ resolveQrcodeModule,
1544
+ normalizeDingtalkOAuthOrgList,
1545
+ shouldChooseDingtalkOAuthOrganization,
1546
+ selectCorpById,
1547
+ resolveCorpSelection,
1548
+ exchangeDingtalkOAuthResult,
1549
+ pollDingtalkQrCodeStatus,
1550
+ buildOAuthPostData,
1551
+ confirmDingtalkOAuthAuth,
1552
+ getDingtalkOAuthRedirectUri,
1553
+ isDingtalkOAuthPassResult,
1554
+ buildCodexCorpInteraction,
1555
+ buildCodexPollCommand,
1556
+ buildQrImageMarkdown,
1557
+ buildAgentQrResponseMarkdown,
1558
+ buildNeedQrScanResult,
1559
+ getTargetCorpId,
1560
+ deriveAliworkBaseUrl,
1561
+ isDingtalkOAuthChallengeUrl,
1562
+ },
1563
+ };