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,799 @@
1
+ /**
2
+ * utils.js - 宜搭 CLI 公共工具函数
3
+ *
4
+ * 导出函数:
5
+ * findProjectRoot() - 查找项目根目录(兼容悟空环境)
6
+ * extractInfoFromCookies() - 从 Cookie 列表中提取 csrf_token / corp_id / user_id
7
+ * loadCookieData() - 读取 .cache/cookies.json 登录态缓存
8
+ * triggerLogin() - 触发登录
9
+ * refreshCsrfToken() - 刷新 csrf_token
10
+ * resolveBaseUrl() - 从 cookieData 中解析 base_url
11
+ * isLoginExpired() - 检测响应体是否表示登录过期
12
+ * isCsrfTokenExpired() - 检测响应体是否表示 csrf_token 过期
13
+ */
14
+
15
+ 'use strict';
16
+
17
+ const fs = require('fs');
18
+ const path = require('path');
19
+ const os = require('os');
20
+ const { t } = require('./i18n');
21
+ const { warn } = require('./chalk');
22
+
23
+ // ── 项目根目录查找 ────────────────────────────────────
24
+
25
+ /**
26
+ * 检测当前活跃的 AI 工具。
27
+ * 优先级:环境变量 > 兜底检测
28
+ *
29
+ * 注意:只返回当前"活跃"的工具,不返回已安装但未使用的工具。
30
+ *
31
+ * @returns {{ tool: string, displayName: string, dirName: string, workspaceRoot: string }|null}
32
+ */
33
+ function detectActiveTool() {
34
+ const env = process.env;
35
+ const cwd = process.cwd();
36
+ const home = os.homedir();
37
+
38
+ // 优先级1:通过环境变量检测
39
+
40
+ // QoderWork (桌面客户端,__CFBundleIdentifier=com.qoder.work 或 QODERCLI_INTEGRATION_MODE=qoder_work)
41
+ // 必须在 Claude Code 之前检测,因为 QoderWork 内部设置了 CLAUDE_CODE_ENTRYPOINT 会干扰后续判断
42
+ if (
43
+ env.QODERCLI_INTEGRATION_MODE === 'qoder_work' ||
44
+ (env.__CFBundleIdentifier || '').toLowerCase().includes('qoder')
45
+ ) {
46
+ return {
47
+ tool: 'qoderwork',
48
+ displayName: 'QoderWork',
49
+ dirName: '.qoderwork',
50
+ workspaceRoot: path.join(cwd, 'project'),
51
+ };
52
+ }
53
+
54
+ // Qoder IDE / Qoder Agent(CLI 集成模式)
55
+ if (env.QODER_IDE || env.QODER_AGENT) {
56
+ return {
57
+ tool: 'qoder',
58
+ displayName: 'Qoder',
59
+ dirName: '.qoder',
60
+ workspaceRoot: path.join(cwd, 'project'),
61
+ };
62
+ }
63
+
64
+ // 悟空(Wukong)
65
+ // Windows 路径可能使用反斜杠,需同时兼容正斜杠和反斜杠。
66
+ // AGENT_WORK_ROOT 是悟空最明确的运行时信号,优先级高于可能继承到的
67
+ // 外层 IDE/agent 环境变量。
68
+ if (env.AGENT_WORK_ROOT && (env.AGENT_WORK_ROOT.includes('.real') || env.AGENT_WORK_ROOT.includes(path.join('.real')))) {
69
+ return {
70
+ tool: 'wukong',
71
+ displayName: '悟空(Wukong)',
72
+ dirName: '.real',
73
+ workspaceRoot: resolveWukongWorkspaceRoot(env.AGENT_WORK_ROOT),
74
+ };
75
+ }
76
+
77
+ // OpenAI Codex
78
+ if (
79
+ env.CODEX_SHELL ||
80
+ env.CODEX_CI ||
81
+ env.CODEX_THREAD_ID ||
82
+ env.CODEX_HOME ||
83
+ (env.__CFBundleIdentifier || '').toLowerCase().includes('codex')
84
+ ) {
85
+ return {
86
+ tool: 'codex',
87
+ displayName: 'Codex',
88
+ dirName: '.codex',
89
+ workspaceRoot: path.join(cwd, 'project'),
90
+ };
91
+ }
92
+
93
+ // Claude Code
94
+ if (env.CLAUDE_CODE_ENTRYPOINT || env.CLAUDE_CODE) {
95
+ return {
96
+ tool: 'claude-code',
97
+ displayName: 'Claude Code',
98
+ dirName: '.claude',
99
+ workspaceRoot: path.join(cwd, 'project'),
100
+ };
101
+ }
102
+
103
+ // OpenCode
104
+ // Windows 上配置目录为 ~/.config/opencode,macOS/Linux 为 ~/.opencode。
105
+ // OpenCode 当前运行时会暴露 OPENCODE_CLIENT;保留 OPENCODE 兼容旧检测。
106
+ if (env.OPENCODE || env.OPENCODE_CLIENT) {
107
+ const opencodeDirName = process.platform === 'win32'
108
+ ? path.join('.config', 'opencode')
109
+ : '.opencode';
110
+ return {
111
+ tool: 'opencode',
112
+ displayName: 'OpenCode',
113
+ dirName: opencodeDirName,
114
+ workspaceRoot: path.join(cwd, 'project'),
115
+ };
116
+ }
117
+
118
+ // Cursor
119
+ if (env.CURSOR_TRACE_ID || (env.VSCODE_GIT_ASKPASS_NODE || '').includes('Cursor')) {
120
+ return {
121
+ tool: 'cursor',
122
+ displayName: 'Cursor',
123
+ dirName: '.cursor',
124
+ workspaceRoot: path.join(cwd, 'project'),
125
+ };
126
+ }
127
+
128
+ // 优先级2:兜底检测
129
+
130
+ // Aone Copilot - 通过专属配置目录检测(VSCode 环境)
131
+ // Aone Copilot 没有独立的环境变量,但会在 home 目录创建 ~/.aone_copilot/
132
+ if (env.TERM_PROGRAM === 'vscode' && fs.existsSync(path.join(home, '.aone_copilot'))) {
133
+ return {
134
+ tool: 'aone-copilot',
135
+ displayName: 'Aone Copilot',
136
+ dirName: '.aone_copilot',
137
+ workspaceRoot: path.join(cwd, 'project'),
138
+ };
139
+ }
140
+
141
+ // 未检测到活跃工具
142
+ return null;
143
+ }
144
+
145
+ function hasDesktopEnvironment(env = process.env, platform = process.platform) {
146
+ if (env.YIDACONNECTOR_FORCE_TERMINAL_QR === '1') {
147
+ return false;
148
+ }
149
+ if (env.YIDACONNECTOR_ASSUME_DESKTOP === '1') {
150
+ return true;
151
+ }
152
+ if (env.CI || env.CODEX_CI) {
153
+ return false;
154
+ }
155
+ if (platform === 'darwin' || platform === 'win32') {
156
+ return true;
157
+ }
158
+ if (platform === 'linux') {
159
+ return !!(
160
+ env.DISPLAY ||
161
+ env.WAYLAND_DISPLAY ||
162
+ env.MIR_SOCKET ||
163
+ ['x11', 'wayland'].includes(String(env.XDG_SESSION_TYPE || '').toLowerCase())
164
+ );
165
+ }
166
+ return false;
167
+ }
168
+
169
+ /**
170
+ * 解析悟空工作区根目录。
171
+ *
172
+ * 悟空的 AGENT_WORK_ROOT 历史上有两种形态:
173
+ * - ~/.real/users/{uuid}/workspace/ 直接就是工作区
174
+ * - ~/.real/users/{uuid}/ workspace 在其下
175
+ *
176
+ * yidaconnector copy 在空工作区会把 project/ 内容直接铺入工作区,因此这里优先
177
+ * 识别已经含 config.json 的目录,最后回退到 AGENT_WORK_ROOT 本身。
178
+ *
179
+ * @param {string} agentWorkRoot
180
+ * @returns {string}
181
+ */
182
+ function resolveWukongWorkspaceRoot(agentWorkRoot) {
183
+ if (!agentWorkRoot) {
184
+ return path.join(os.homedir(), '.real', 'workspace');
185
+ }
186
+
187
+ const candidates = [
188
+ agentWorkRoot,
189
+ path.join(agentWorkRoot, 'project'),
190
+ path.join(agentWorkRoot, 'workspace'),
191
+ path.join(agentWorkRoot, 'workspace', 'project'),
192
+ ];
193
+
194
+ for (const candidate of candidates) {
195
+ if (fs.existsSync(path.join(candidate, 'config.json'))) {
196
+ return candidate;
197
+ }
198
+ }
199
+
200
+ return agentWorkRoot;
201
+ }
202
+
203
+ /**
204
+ * 获取悟空环境的 node bin 目录路径
205
+ * @returns {string|null} 悟空 node bin 目录路径,非悟空环境返回 null
206
+ */
207
+ function getWukongNodeBinDir() {
208
+ const activeTool = detectActiveTool();
209
+ if (activeTool && activeTool.tool === 'wukong') {
210
+ const wukongBin = path.join(os.homedir(), '.real', '.bin', 'node', 'bin');
211
+ if (fs.existsSync(wukongBin)) {
212
+ return wukongBin;
213
+ }
214
+ }
215
+ return null;
216
+ }
217
+
218
+ /**
219
+ * 获取当前环境应使用的 npm 可执行文件路径
220
+ * 悟空环境优先使用悟空自带的 npm,避免权限问题
221
+ * @returns {string} npm 可执行文件路径或命令名
222
+ */
223
+ function getNpmExecutable() {
224
+ const wukongBin = getWukongNodeBinDir();
225
+ if (wukongBin) {
226
+ const npmName = process.platform === 'win32' ? 'npm.cmd' : 'npm';
227
+ const npmPath = path.join(wukongBin, npmName);
228
+ if (fs.existsSync(npmPath)) {
229
+ return npmPath;
230
+ }
231
+ }
232
+ return 'npm';
233
+ }
234
+
235
+ /**
236
+ * 获取当前环境应使用的 node 可执行文件路径
237
+ * 悟空环境优先使用悟空自带的 node,避免权限问题
238
+ * @returns {string} node 可执行文件路径或命令名
239
+ */
240
+ function getNodeExecutable() {
241
+ const wukongBin = getWukongNodeBinDir();
242
+ if (wukongBin) {
243
+ const nodeName = process.platform === 'win32' ? 'node.exe' : 'node';
244
+ const nodePath = path.join(wukongBin, nodeName);
245
+ if (fs.existsSync(nodePath)) {
246
+ return nodePath;
247
+ }
248
+ }
249
+ return 'node';
250
+ }
251
+
252
+ /**
253
+ * 查找项目根目录(project 工作区)。
254
+ *
255
+ * 查找策略:
256
+ * 1. 通过环境变量检测当前活跃的 AI 工具
257
+ * 2. 返回对应工具的项目根目录
258
+ * 3. 兜底:返回 process.cwd()
259
+ *
260
+ * @returns {string} 项目根目录的绝对路径
261
+ */
262
+ function findProjectRoot() {
263
+ const activeTool = detectActiveTool();
264
+
265
+ if (activeTool) {
266
+ // 如果 project 目录存在,返回它;否则返回当前工作目录
267
+ if (fs.existsSync(activeTool.workspaceRoot)) {
268
+ return activeTool.workspaceRoot;
269
+ }
270
+ }
271
+
272
+ const cwd = process.cwd();
273
+ if (fs.existsSync(path.join(cwd, 'config.json'))) {
274
+ return cwd;
275
+ }
276
+
277
+ const nestedProjectRoot = path.join(cwd, 'project');
278
+ if (fs.existsSync(path.join(nestedProjectRoot, 'config.json'))) {
279
+ return nestedProjectRoot;
280
+ }
281
+
282
+ // 兜底:返回当前工作目录
283
+ return cwd;
284
+ }
285
+
286
+ // ── Cookie 解析 ───────────────────────────────────────
287
+
288
+ /**
289
+ * 从 Cookie 列表中提取 csrf_token、corp_id、user_id。
290
+ *
291
+ * 国内宜搭(aliwork.com):corpId/userId 合并写在 `tianshu_corp_user` 里,
292
+ * 形如 `${corpId}_${userId}`,按最后一个下划线切分。
293
+ *
294
+ * 海外 YiDA(yidaapps.com):不写 `tianshu_corp_user`,而是单独写 `corp_id` cookie
295
+ * 存放 corpId 明文;userId 加密在 `pub_uid` 里客户端无法解密,留 null 接受。
296
+ *
297
+ * @param {Array} cookies
298
+ * @returns {{ csrfToken: string|null, corpId: string|null, userId: string|null }}
299
+ */
300
+ function extractInfoFromCookies(cookies) {
301
+ let csrfToken = null;
302
+ let corpId = null;
303
+ let userId = null;
304
+
305
+ for (const cookie of cookies) {
306
+ if (cookie.name === 'tianshu_csrf_token') {
307
+ csrfToken = cookie.value;
308
+ } else if (cookie.name === 'tianshu_corp_user') {
309
+ const lastUnderscore = cookie.value.lastIndexOf('_');
310
+ if (lastUnderscore > 0) {
311
+ corpId = cookie.value.slice(0, lastUnderscore);
312
+ userId = cookie.value.slice(lastUnderscore + 1);
313
+ }
314
+ }
315
+ }
316
+
317
+ if (!corpId) {
318
+ const corpCookie = cookies.find((c) => c && c.name === 'corp_id' && c.value);
319
+ if (corpCookie) {
320
+ corpId = corpCookie.value;
321
+ }
322
+ }
323
+
324
+ return { csrfToken, corpId, userId };
325
+ }
326
+
327
+ // ── 登录态缓存读取 ────────────────────────────────────
328
+
329
+ /**
330
+ * 读取登录态缓存。
331
+ * 优先读取当前激活环境的 Cookie 文件(环境隔离),
332
+ * 若不存在则兼容旧版 cookies.json(向后兼容)。
333
+ * @param {string} [projectRoot]
334
+ * @param {string} [defaultBaseUrl]
335
+ * @returns {object|null}
336
+ */
337
+ function loadCookieData(projectRoot, defaultBaseUrl) {
338
+ const root = projectRoot || findProjectRoot();
339
+ const fallbackBaseUrl = defaultBaseUrl || 'https://www.aliwork.com';
340
+
341
+ // 尝试迁移旧版 cookies.json(仅在首次使用多环境功能时执行一次)
342
+ const { migrateOldCookieFile, getCookieFilePath } = require('./env-manager');
343
+ migrateOldCookieFile(root);
344
+
345
+ // 优先使用当前环境的 Cookie 文件
346
+ const envCookieFile = getCookieFilePath(root);
347
+ // 兜底:旧版 cookies.json(向后兼容)
348
+ const legacyCookieFile = path.join(root, '.cache', 'cookies.json');
349
+
350
+ const cookieFile = fs.existsSync(envCookieFile)
351
+ ? envCookieFile
352
+ : legacyCookieFile;
353
+
354
+ if (!fs.existsSync(cookieFile)) {return null;}
355
+
356
+ try {
357
+ const raw = fs.readFileSync(cookieFile, 'utf-8').trim();
358
+ if (!raw) {return null;}
359
+
360
+ const parsed = JSON.parse(raw);
361
+ let cookieData;
362
+
363
+ if (Array.isArray(parsed)) {
364
+ cookieData = { cookies: parsed, base_url: fallbackBaseUrl };
365
+ } else {
366
+ cookieData = parsed;
367
+ }
368
+
369
+ if (cookieData.cookies && cookieData.cookies.length > 0) {
370
+ const { csrfToken, corpId, userId } = extractInfoFromCookies(cookieData.cookies);
371
+ if (csrfToken) {cookieData.csrf_token = csrfToken;}
372
+ if (corpId) {cookieData.corp_id = corpId;}
373
+ if (userId) {cookieData.user_id = userId;}
374
+ }
375
+
376
+ return cookieData;
377
+ } catch {
378
+ return null;
379
+ }
380
+ }
381
+
382
+ // ── 登录触发 ──────────────────────────────────────────
383
+
384
+ /**
385
+ * 触发登录(浏览器扫码模式)。
386
+ * @param {object} [options]
387
+ * @param {boolean} [options.force=false] - 是否跳过本地缓存,强制重新登录
388
+ * @returns {object} loginResult
389
+ */
390
+ function triggerLogin(options = {}) {
391
+ warn(t('login.trigger_login'));
392
+ const { ensureLogin } = require('../auth/login');
393
+ return ensureLogin(options);
394
+ }
395
+
396
+ /**
397
+ * 刷新 csrf_token(从本地缓存重新提取,无需重新扫码)。
398
+ * @returns {object} loginResult
399
+ */
400
+ function refreshCsrfToken() {
401
+ warn(t('login.csrf_refresh'));
402
+ const { refreshCsrfFromCache } = require('../auth/login');
403
+ return refreshCsrfFromCache();
404
+ }
405
+
406
+ // ── 响应检测 ──────────────────────────────────────────
407
+
408
+ /**
409
+ * 检测响应体是否表示登录过期。
410
+ * @param {object} responseJson
411
+ * @returns {boolean}
412
+ */
413
+ function isLoginExpired(responseJson) {
414
+ return (
415
+ responseJson &&
416
+ responseJson.success === false &&
417
+ (responseJson.errorCode === '307' || responseJson.errorCode === '302')
418
+ );
419
+ }
420
+
421
+ /**
422
+ * 检测响应体是否表示 csrf_token 过期。
423
+ * @param {object} responseJson
424
+ * @returns {boolean}
425
+ */
426
+ function isCsrfTokenExpired(responseJson) {
427
+ return (
428
+ responseJson &&
429
+ responseJson.success === false &&
430
+ responseJson.errorCode === 'TIANSHU_000030'
431
+ );
432
+ }
433
+
434
+ function isHttpRedirectStatus(statusCode) {
435
+ return [301, 302, 303, 307, 308].includes(Number(statusCode));
436
+ }
437
+
438
+ // ── base_url 解析 ─────────────────────────────────────
439
+
440
+ /**
441
+ * 从 cookieData 中解析 base_url,支持多环境配置优先级。
442
+ *
443
+ * 优先级(高 → 低):
444
+ * 1. YIDACONNECTOR_ENDPOINT 环境变量
445
+ * 2. cookieData.base_url(登录后实际跳转域名)
446
+ * 3. 当前激活的私有化环境配置
447
+ * 4. 当前激活的环境配置(公有云默认)
448
+ * 5. defaultBaseUrl 参数 / 公有云兜底
449
+ *
450
+ * @param {object} cookieData
451
+ * @param {string} [defaultBaseUrl]
452
+ * @returns {string}
453
+ */
454
+ function resolveBaseUrl(cookieData, defaultBaseUrl) {
455
+ const { resolveEndpoint } = require('./env-manager');
456
+ const resolved = resolveEndpoint(cookieData, undefined);
457
+ if (defaultBaseUrl && resolved === 'https://www.aliwork.com' && (!cookieData || !cookieData.base_url)) {
458
+ return defaultBaseUrl.replace(/\/+$/, '');
459
+ }
460
+ return resolved;
461
+ }
462
+
463
+ // ── HTTP 请求工具 ─────────────────────────────────────
464
+
465
+ function collectResponseText(res, onEnd) {
466
+ const chunks = [];
467
+ res.on('data', (chunk) => {
468
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
469
+ });
470
+ res.on('end', () => {
471
+ onEnd(Buffer.concat(chunks).toString('utf8'));
472
+ });
473
+ }
474
+
475
+ /**
476
+ * 发送 HTTP POST 请求(application/x-www-form-urlencoded)
477
+ * @param {string} baseUrl
478
+ * @param {string} requestPath
479
+ * @param {string} postData - querystring 格式
480
+ * @param {Array} cookies
481
+ * @returns {Promise<object>}
482
+ */
483
+ function httpPost(baseUrl, requestPath, postData, cookies, optionsOverride = {}) {
484
+ const https = require('https');
485
+ const http = require('http');
486
+
487
+ return new Promise((resolve, reject) => {
488
+ const parsedUrl = new URL(baseUrl);
489
+ const requestHost = parsedUrl.hostname;
490
+ const filteredCookies = cookies.filter(c => {
491
+ const cookieDomain = (c.domain || '').replace(/^\./, '');
492
+ return requestHost === cookieDomain || requestHost.endsWith('.' + cookieDomain);
493
+ });
494
+ // 若 domain 匹配后为空(如 cookies.json 中 domain 字段缺失),fallback 到全量 cookies
495
+ const effectiveCookies = filteredCookies.length > 0 ? filteredCookies : cookies;
496
+ const cookieHeader = effectiveCookies.map((c) => `${c.name}=${c.value}`).join('; ');
497
+ const isHttps = parsedUrl.protocol === 'https:';
498
+ const requestModule = isHttps ? https : http;
499
+
500
+ // 从 cookies 中提取 csrf_token 用于 global_csrf_token 请求头
501
+ const csrfCookie = effectiveCookies.find(c => c.name === 'tianshu_csrf_token');
502
+ const globalCsrfToken = csrfCookie ? csrfCookie.value : '';
503
+
504
+ const options = {
505
+ hostname: parsedUrl.hostname,
506
+ port: parsedUrl.port || (isHttps ? 443 : 80),
507
+ path: requestPath,
508
+ method: 'POST',
509
+ headers: {
510
+ 'Content-Type': 'application/x-www-form-urlencoded',
511
+ 'Content-Length': Buffer.byteLength(postData),
512
+ Accept: 'application/json, text/plain, */*',
513
+ Origin: baseUrl,
514
+ Referer: optionsOverride.referer || baseUrl + '/',
515
+ Cookie: cookieHeader,
516
+ 'x-requested-with': 'XMLHttpRequest',
517
+ global_csrf_token: globalCsrfToken,
518
+ },
519
+ timeout: 30000,
520
+ };
521
+
522
+ const req = requestModule.request(options, (res) => {
523
+ collectResponseText(res, (data) => {
524
+ if (!optionsOverride.silentStatus) {
525
+ warn(t('common.http_status', res.statusCode));
526
+ }
527
+ if (isHttpRedirectStatus(res.statusCode)) {
528
+ resolve({
529
+ __needLogin: true,
530
+ __httpStatus: res.statusCode,
531
+ __location: res.headers.location || '',
532
+ });
533
+ return;
534
+ }
535
+ try {
536
+ const parsed = JSON.parse(data);
537
+ if (isLoginExpired(parsed)) {
538
+ resolve({ __needLogin: true });
539
+ return;
540
+ }
541
+ if (isCsrfTokenExpired(parsed)) {
542
+ resolve({ __csrfExpired: true });
543
+ return;
544
+ }
545
+ resolve(parsed);
546
+ } catch {
547
+ warn(t('common.http_response', data.substring(0, 500)));
548
+ resolve({ success: false, errorMsg: `HTTP ${res.statusCode}: ` + t('common.response_not_json') });
549
+ }
550
+ });
551
+ });
552
+
553
+ // 用标志位防止 timeout 后 req.destroy() 触发 error 事件导致双重 reject
554
+ let hasRejected = false;
555
+ req.on('timeout', () => {
556
+ hasRejected = true;
557
+ req.destroy();
558
+ reject(new Error(t('common.request_timeout')));
559
+ });
560
+ req.on('error', (err) => { if (!hasRejected) { reject(err); } });
561
+ req.write(postData);
562
+ req.end();
563
+ });
564
+ }
565
+
566
+ function httpPostJson(baseUrl, requestPath, payload, cookies, optionsOverride = {}) {
567
+ const https = require('https');
568
+ const http = require('http');
569
+
570
+ return new Promise((resolve, reject) => {
571
+ const parsedUrl = new URL(baseUrl);
572
+ const requestHost = parsedUrl.hostname;
573
+ const filteredCookies = cookies.filter(c => {
574
+ const cookieDomain = (c.domain || '').replace(/^\./, '');
575
+ return requestHost === cookieDomain || requestHost.endsWith('.' + cookieDomain);
576
+ });
577
+ const effectiveCookies = filteredCookies.length > 0 ? filteredCookies : cookies;
578
+ const cookieHeader = effectiveCookies.map((c) => `${c.name}=${c.value}`).join('; ');
579
+ const isHttps = parsedUrl.protocol === 'https:';
580
+ const requestModule = isHttps ? https : http;
581
+ const body = JSON.stringify(payload === undefined ? {} : payload);
582
+
583
+ const csrfCookie = effectiveCookies.find(c => c.name === 'tianshu_csrf_token');
584
+ const globalCsrfToken = optionsOverride.csrfToken || (csrfCookie ? csrfCookie.value : '');
585
+
586
+ const options = {
587
+ hostname: parsedUrl.hostname,
588
+ port: parsedUrl.port || (isHttps ? 443 : 80),
589
+ path: requestPath,
590
+ method: 'POST',
591
+ headers: {
592
+ 'Content-Type': 'application/json',
593
+ 'Content-Length': Buffer.byteLength(body),
594
+ Accept: 'application/json, text/plain, */*',
595
+ Origin: baseUrl,
596
+ Referer: optionsOverride.referer || baseUrl + '/',
597
+ Cookie: cookieHeader,
598
+ 'x-requested-with': 'XMLHttpRequest',
599
+ global_csrf_token: globalCsrfToken,
600
+ },
601
+ timeout: optionsOverride.timeout || 30000,
602
+ };
603
+
604
+ const req = requestModule.request(options, (res) => {
605
+ collectResponseText(res, (data) => {
606
+ if (!optionsOverride.silentStatus) {
607
+ warn(t('common.http_status', res.statusCode));
608
+ }
609
+ if (isHttpRedirectStatus(res.statusCode)) {
610
+ resolve({
611
+ __needLogin: true,
612
+ __httpStatus: res.statusCode,
613
+ __location: res.headers.location || '',
614
+ });
615
+ return;
616
+ }
617
+ try {
618
+ const parsed = JSON.parse(data);
619
+ if (isLoginExpired(parsed)) {
620
+ resolve({ __needLogin: true });
621
+ return;
622
+ }
623
+ if (isCsrfTokenExpired(parsed)) {
624
+ resolve({ __csrfExpired: true });
625
+ return;
626
+ }
627
+ resolve(parsed);
628
+ } catch {
629
+ warn(t('common.http_response', data.substring(0, 500)));
630
+ resolve({ success: false, errorMsg: `HTTP ${res.statusCode}: ` + t('common.response_not_json') });
631
+ }
632
+ });
633
+ });
634
+
635
+ let hasRejected = false;
636
+ req.on('timeout', () => {
637
+ hasRejected = true;
638
+ req.destroy();
639
+ reject(new Error(t('common.request_timeout')));
640
+ });
641
+ req.on('error', (err) => { if (!hasRejected) { reject(err); } });
642
+ req.write(body);
643
+ req.end();
644
+ });
645
+ }
646
+
647
+ /**
648
+ * 发送 HTTP GET 请求
649
+ * @param {string} baseUrl
650
+ * @param {string} requestPath
651
+ * @param {object} queryParams
652
+ * @param {Array} cookies
653
+ * @returns {Promise<object>}
654
+ */
655
+ function httpGet(baseUrl, requestPath, queryParams, cookies, optionsOverride = {}) {
656
+ const https = require('https');
657
+ const http = require('http');
658
+ const querystring = require('querystring');
659
+
660
+ return new Promise((resolve, reject) => {
661
+ const parsedUrl = new URL(baseUrl);
662
+ const requestHost = parsedUrl.hostname;
663
+ const filteredCookies = cookies.filter(c => {
664
+ const cookieDomain = (c.domain || '').replace(/^\./, '');
665
+ return requestHost === cookieDomain || requestHost.endsWith('.' + cookieDomain);
666
+ });
667
+ // 若 domain 匹配后为空(如 cookies.json 中 domain 字段缺失),fallback 到全量 cookies
668
+ const effectiveCookies = filteredCookies.length > 0 ? filteredCookies : cookies;
669
+ const cookieHeader = effectiveCookies.map((c) => `${c.name}=${c.value}`).join('; ');
670
+ const isHttps = parsedUrl.protocol === 'https:';
671
+ const requestModule = isHttps ? https : http;
672
+ const fullPath = queryParams ? `${requestPath}?${querystring.stringify(queryParams)}` : requestPath;
673
+
674
+ // 从 cookies 中提取 csrf_token 用于 global_csrf_token 请求头
675
+ const csrfCookie = effectiveCookies.find(c => c.name === 'tianshu_csrf_token');
676
+ const globalCsrfToken = csrfCookie ? csrfCookie.value : '';
677
+
678
+ const options = {
679
+ hostname: parsedUrl.hostname,
680
+ port: parsedUrl.port || (isHttps ? 443 : 80),
681
+ path: fullPath,
682
+ method: 'GET',
683
+ headers: {
684
+ Accept: 'application/json, text/plain, */*',
685
+ Origin: baseUrl,
686
+ Referer: baseUrl + '/',
687
+ Cookie: cookieHeader,
688
+ 'x-requested-with': 'XMLHttpRequest',
689
+ global_csrf_token: globalCsrfToken,
690
+ },
691
+ timeout: 30000,
692
+ };
693
+
694
+ const req = requestModule.request(options, (res) => {
695
+ collectResponseText(res, (data) => {
696
+ if (!optionsOverride.silentStatus) {
697
+ warn(t('common.http_status', res.statusCode));
698
+ }
699
+ if (isHttpRedirectStatus(res.statusCode)) {
700
+ resolve({
701
+ __needLogin: true,
702
+ __httpStatus: res.statusCode,
703
+ __location: res.headers.location || '',
704
+ });
705
+ return;
706
+ }
707
+ try {
708
+ const parsed = JSON.parse(data);
709
+ if (isLoginExpired(parsed)) {
710
+ resolve({ __needLogin: true });
711
+ return;
712
+ }
713
+ if (isCsrfTokenExpired(parsed)) {
714
+ resolve({ __csrfExpired: true });
715
+ return;
716
+ }
717
+ resolve(parsed);
718
+ } catch {
719
+ warn(t('common.http_response', data.substring(0, 500)));
720
+ resolve({ success: false, errorMsg: `HTTP ${res.statusCode}: ` + t('common.response_not_json') });
721
+ }
722
+ });
723
+ });
724
+
725
+ // 用标志位防止 timeout 后 req.destroy() 触发 error 事件导致双重 reject
726
+ let hasRejected = false;
727
+ req.on('timeout', () => {
728
+ hasRejected = true;
729
+ req.destroy();
730
+ reject(new Error(t('common.request_timeout')));
731
+ });
732
+ req.on('error', (err) => { if (!hasRejected) { reject(err); } });
733
+ req.end();
734
+ });
735
+ }
736
+
737
+ /**
738
+ * 带自动重登录的请求封装。
739
+ * @param {Function} requestFn - 接受 authRef 返回 Promise 的工厂函数
740
+ * @param {object} authRef - { csrfToken, cookies, baseUrl, cookieData }
741
+ * @returns {Promise<object>}
742
+ */
743
+ async function requestWithAutoLogin(requestFn, authRef) {
744
+ let result = await requestFn(authRef);
745
+
746
+ if (result && result.__csrfExpired) {
747
+ const refreshedData = refreshCsrfToken();
748
+ if (refreshedData && refreshedData.cookies && refreshedData.csrf_token) {
749
+ authRef.cookieData = refreshedData;
750
+ authRef.csrfToken = refreshedData.csrf_token;
751
+ authRef.cookies = refreshedData.cookies;
752
+ authRef.baseUrl = resolveBaseUrl(refreshedData);
753
+ warn(t('common.csrf_refreshed'));
754
+ result = await requestFn(authRef);
755
+ } else {
756
+ result = { __needLogin: true };
757
+ }
758
+ }
759
+
760
+ if (result && result.__needLogin) {
761
+ const newCookieData = triggerLogin({ force: true });
762
+ if (!newCookieData || !newCookieData.cookies || !newCookieData.csrf_token) {
763
+ return {
764
+ success: false,
765
+ __needLogin: true,
766
+ errorMsg: t('common.login_expired', 'yidaconnector login --qr / yidaconnector login --browser'),
767
+ };
768
+ }
769
+ authRef.cookieData = newCookieData;
770
+ authRef.csrfToken = newCookieData.csrf_token;
771
+ authRef.cookies = newCookieData.cookies;
772
+ authRef.baseUrl = resolveBaseUrl(newCookieData);
773
+ warn(t('common.relogin_retry'));
774
+ result = await requestFn(authRef);
775
+ }
776
+
777
+ return result;
778
+ }
779
+
780
+ module.exports = {
781
+ detectActiveTool,
782
+ hasDesktopEnvironment,
783
+ findProjectRoot,
784
+ extractInfoFromCookies,
785
+ loadCookieData,
786
+ triggerLogin,
787
+ refreshCsrfToken,
788
+ resolveBaseUrl,
789
+ isLoginExpired,
790
+ isCsrfTokenExpired,
791
+ httpPost,
792
+ httpPostJson,
793
+ httpGet,
794
+ requestWithAutoLogin,
795
+ getWukongNodeBinDir,
796
+ getNpmExecutable,
797
+ getNodeExecutable,
798
+ resolveWukongWorkspaceRoot,
799
+ };