e10-data 0.1.17 → 0.1.20

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/bin/e10-data.mjs CHANGED
@@ -1,1932 +1,2525 @@
1
- #!/usr/bin/env node
2
- // -*- coding: utf-8 -*-
3
- /**
4
- * e10-data CLI —— data-assistant 专家统一数据接口命令行(2026-09-03 扩展)
5
- *
6
- * 覆盖(数据查询链路全部家族;登录/HRM 复用 e10-login / hrm-cli,checkEbModulePermission 已废弃不提供):
7
- * e10-data login xiaoe-env [--domain <url>] **无凭据时**用环境变量 XIAOE_USER_TOKEN + XIAOE_DOMAIN
8
- * (XiaoE 全局 token)换 ETEAMSID 并落 profile(免浏览器/免 python;2026-09-17 移植)
9
- * e10-data query DSL 查询 executeQueryDsl(conn/model/schema/tableId/dslQuery/debug)
10
- * e10-data ds tables|fields|list|chart|stat|count 数据源(业务模块)取数与元数据
11
- * appId 一律由调用方显式传入(v0.1.14 起 CLI 不再自动反查):
12
- * EB 建模表单(groupId=weaver-ebuilder-form-service)取数必须带 --app-id <id>,
13
- * 该值只能来自 chat_bi_eb_config_map 的权限校验查询(同一次查询给出权限与 appId);
14
- * 不带 appId 时按原样发送,由后端报错(CLI 不做拦截)
15
- * e10-data doc count|list 文档查询 documentListCount / documentList
16
- * e10-data noun list 词库 getChatDocsBySkillKey
17
- * e10-data field id2name|name2id 关联字段翻译 testRemoteBrowser / complete
18
- * e10-data conf get|batch|batch-file|group|group-file|resolve|resolve-file|add|add-file|update|delete
19
- * 环境参数配置中心(chatBi config map,替代 config-resolve 脚本 URL 直连;
20
- * confType=api 时支持 confGroup 分组二级定位,见 --group)
21
- * e10-data attend statis 考勤假期余额统计 getAttendInfoStatis(hr-attendance 病假/事假,2026-09-07)
22
- *
23
- * 通用:--json 原样输出;复杂 JSON 参数支持 @文件 或 内联(中文走 @文件 更稳)
24
- * 认证:加解密全部由 e10-login 负责(本 CLI 不实现任何加解密)。凭据出口优先级:
25
- * ① e10-login auth --json → ② e10-login whoami --json → ③ e10-login whoami(文本解析)
26
- * → ④ 明文镜像 ~/.workbuddy/e10-login-cache.md(兜底,命中时告警);均不可用时提示先 e10-login oidc 登录
27
- * 例外:`login xiaoe-env` 在"还没有任何凭据"时可独立完成登录(换 token 自己算,落 profile 仍交 e10-login)
28
- *
29
- * 用法示例:
30
- * node e10-data.mjs query --conn 1144919996804022272 --model dw --schema dw --dsl @q.json
31
- * node e10-data.mjs ds count --group weaver-project-servicetask --table 715738355537494016
32
- * node e10-data.mjs doc count --name 测试
33
- * node e10-data.mjs noun list --skill rd-department
34
- * node e10-data.mjs field id2name --module ebuilder/form --type ebuilder \
35
- * --ids 3194494277170470571 --form-param '{"fieldId":"966849910772850766","module":"ebuilderform"}'
36
- */
37
- import fs from "node:fs";
38
- import os from "node:os";
39
- import path from "node:path";
40
- import crypto from "node:crypto";
41
- import net from "node:net";
42
- import { spawn, spawnSync } from "node:child_process";
43
- import { fileURLToPath } from "node:url";
44
- import { createRequire } from "node:module";
45
-
46
- const __dirname = path.dirname(fileURLToPath(import.meta.url));
47
- function cliVersion() {
48
- try { return JSON.parse(fs.readFileSync(path.join(__dirname, "..", "package.json"), "utf-8")).version; }
49
- catch { return "dev"; }
50
- }
51
-
52
- const UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.0.0 Safari/537.36";
53
- const EB_BIZ_ID = "10000000000000000";
54
-
55
- // ---------------- 认证(加解密全部交由 e10-login,本 CLI 不再实现任何加解密)----------------
56
- // 背景:auth 文件由 e10-login 自己的密钥体系加密 —— e10-login 1.5.0 起为「每机随机密钥 + OS keychain
57
- // (@napi-rs/keyring,service=e10-cli / account=auth-key;keychain 不可用才落 ~/.e10-cli/.key)」,
58
- // 早期版本为固定 seed。**只有 e10-login 解得开**,所以本 CLI 只负责「向 e10-login 要明文凭据」,
59
- // 不再自行解密 —— e10-login 以后更换密钥体系 / 算法 / profile 机制,本 CLI 都无需改动。
60
- // 凭据出口优先级(命中即返回;命中的"模式名"会记入 ~/.e10-cli/.e10-data-auth-mode,只存模式、不存凭据):
61
- // ① e10-login auth --json 正式契约(推荐 e10-login 侧新增;一旦可用自动优先)
62
- // ② e10-login whoami --json 其 JSON 输出带 cookies 字段时可用
63
- // ③ e10-login whoami 文本输出解析(当前 e10-login 1.5.0 的实际可用路径)
64
- // ④ ~/.workbuddy/e10-login-cache.md 明文镜像 —— 最后兜底,命中时打印告警(该文件可能已过期)
65
- // 环境变量:E10_DATA_AUTH_PROBE=1 强制重新探测优先级;E10_DATA_AUTH_MODE=<模式名> 手动指定;
66
- // E10_DATA_QUIET=1 抑制告警输出(告警一律走 stderr,不污染 --json 的 stdout)。
67
- const AUTH_MODE_FILE = path.join(os.homedir(), ".e10-cli", ".e10-data-auth-mode");
68
-
69
- /** 定位当前 profile 的 auth 文件(仅用于"是否已登录"的判断与报错提示,本 CLI 不读其内容做解密) */
70
- function authFilePath() {
71
- const home = os.homedir();
72
- let profile = "default";
73
- try {
74
- const activeFile = path.join(home, ".e10-cli", "profile");
75
- if (fs.existsSync(activeFile)) { const n = fs.readFileSync(activeFile, "utf-8").trim(); if (n) profile = n; }
76
- } catch {}
77
- return { profile, file: path.join(home, ".e10-cli", "profiles", profile, "auth") };
78
- }
79
-
80
- // ---------------- e10-login 入口解析(v0.1.17:去掉 shell:true,这是最大的性能改动)----------------
81
- // 背景(2026-09-20 实测):`spawnSync("e10-login", …, { shell: true })` 在 Windows 上会先起一层
82
- // cmd.exe;该层**单独**耗时 ~0.98~1.04s(杀软对每个新进程的实时扫描所致),而 e10-login 自身
83
- // 的活(读 profile + 解密 auth + 打印)只花 ~0.1s。改为「定位 e10-login 的 JS 入口,用
84
- // process.execPath 直接拉起」后,实测 **1.0s → 0.22s**(每调用省 ~0.78s)。
85
- // ⚠️ 不能直接 spawn `e10-login.cmd`:Node ≥18.20/20.12 起为修 CVE-2024-27980,禁止在 shell:false
86
- // 下执行 .cmd/.bat(抛 EINVAL)。因此必须解析到 .cmd shim 背后**真正的 .js 入口**。
87
- // 解析顺序:① 环境变量 E10_LOGIN_ENTRY → ② Node 模块解析(与 e10-data 同 prefix 时命中)
88
- // → ③ 沿 PATH 找 shim,再取 <shimDir>/node_modules/e10-login → ④ 常见全局前缀兜底。
89
- // 全部失败 → 返回 null,调用方退化回 shell:true(保持旧行为可用,只慢不错)。
90
- let _loginEntry; // 进程内缓存解析结果;null 表示"已尝试但未找到"
91
- let _loginEntryDone = false;
92
-
93
- function resolveLoginEntry() {
94
- if (_loginEntryDone) return _loginEntry;
95
- _loginEntryDone = true;
96
- const tried = [];
97
- const isFile = (p) => { try { return !!p && fs.statSync(p).isFile(); } catch { return false; } };
98
-
99
- /** 读 <dir>/package.json 的 bin 字段定位入口文件;非 e10-login 包返回 null */
100
- const entryFromPkgDir = (dir) => {
101
- try {
102
- const pj = path.join(dir, "package.json");
103
- if (!isFile(pj)) return null;
104
- const j = JSON.parse(fs.readFileSync(pj, "utf-8"));
105
- if (j.name !== "e10-login") return null;
106
- let b = j.bin;
107
- if (typeof b === "string") b = { "e10-login": b };
108
- const rel = b && b["e10-login"];
109
- if (!rel) return null;
110
- const entry = path.resolve(dir, rel);
111
- return isFile(entry) ? entry : null;
112
- } catch { return null; }
113
- };
114
-
115
- // ① 环境变量显式指定(排障与特殊安装方式用)
116
- const envEntry = (process.env.E10_LOGIN_ENTRY || "").trim();
117
- if (envEntry) {
118
- if (isFile(envEntry)) { _loginEntry = envEntry; return _loginEntry; }
119
- tried.push("E10_LOGIN_ENTRY 指向的文件不存在: " + envEntry);
120
- }
121
-
122
- // ② Node 模块解析(e10-data 与 e10-login 装在同一个 prefix 时命中)
123
- try {
124
- const pj = createRequire(import.meta.url).resolve("e10-login/package.json");
125
- const e = entryFromPkgDir(path.dirname(pj));
126
- if (e) { _loginEntry = e; return _loginEntry; }
127
- tried.push("模块解析命中 " + pj + " 但 bin 入口不可用");
128
- } catch (e) { tried.push("模块解析: " + ((e && e.message) || e)); }
129
-
130
- // ③ 沿 PATH 找 shim,再取 shim 同级的 node_modules/e10-login
131
- // (npm 全局安装的常规布局:<prefix>/e10-login.cmd + <prefix>/node_modules/e10-login)
132
- const pathDirs = String(process.env.PATH || "").split(path.delimiter).filter(Boolean);
133
- const shimNames = process.platform === "win32"
134
- ? ["e10-login.cmd", "e10-login.exe", "e10-login"]
135
- : ["e10-login"];
136
- for (const d of pathDirs) {
137
- for (const n of shimNames) {
138
- const shim = path.join(d, n);
139
- if (!isFile(shim)) continue;
140
- const e = entryFromPkgDir(path.join(d, "node_modules", "e10-login"));
141
- if (e) { _loginEntry = e; return _loginEntry; }
142
- tried.push("找到 shim " + shim + ",但其同级 node_modules/e10-login 不可用");
143
- }
144
- }
145
-
146
- // ④ 常见全局前缀兜底
147
- const home = os.homedir();
148
- const prefixes = [
149
- path.join(path.dirname(process.execPath), "node_modules"),
150
- path.join(path.dirname(process.execPath), "..", "lib", "node_modules"),
151
- path.join(home, ".npm-global", "lib", "node_modules"),
152
- process.env.APPDATA ? path.join(process.env.APPDATA, "npm", "node_modules") : null,
153
- process.env.ProgramFiles ? path.join(process.env.ProgramFiles, "nodejs", "node_modules") : null,
154
- ].filter(Boolean);
155
- for (const p of prefixes) {
156
- const e = entryFromPkgDir(path.join(p, "e10-login"));
157
- if (e) { _loginEntry = e; return _loginEntry; }
158
- }
159
- tried.push("常见全局前缀均未命中: " + prefixes.join(" | "));
160
-
161
- if (!process.env.E10_DATA_QUIET) {
162
- console.error("[e10-data][warn] 未定位到 e10-login 的 JS 入口,本次退化为 shell 调用(较慢,且 Windows 上多花约 1 秒)。"
163
- + "可用 E10_LOGIN_ENTRY=<e10-login/bin/e10-login.js 绝对路径> 显式指定。诊断:" + tried.join(" | "));
164
- }
165
- _loginEntry = null;
166
- return null;
167
- }
168
-
169
- let lastLoginDiag = "";
170
- /** 底层调用 e10-login,返回 spawnSync 结果(供需要看 stderr/status 的调用方使用)。
171
- * 优先 shell:false 直连 JS 入口;解析失败才退化为 shell:true。 */
172
- function spawnLoginCli(args, timeout = 20000) {
173
- const entry = resolveLoginEntry();
174
- if (entry) return spawnSync(process.execPath, [entry, ...args], { encoding: "utf-8", timeout, windowsHide: true });
175
- return spawnSync("e10-login", args, { shell: true, encoding: "utf-8", timeout, windowsHide: true });
176
- }
177
-
178
- /** 调用 e10-login(凭据的唯一来源);失败返回 null,并把诊断信息留在 lastLoginDiag */
179
- function runLoginCli(args) {
180
- try {
181
- const r = spawnLoginCli(args);
182
- const out = (r.stdout || "").trim();
183
- if (r.error || r.status !== 0) {
184
- lastLoginDiag = ((r.stderr || "") + out).trim().split(/\r?\n/).slice(0, 2).join(" / ") || (r.error ? String(r.error.message) : "exit=" + r.status);
185
- return null;
186
- }
187
- return out || null;
188
- } catch (e) { lastLoginDiag = String((e && e.message) || e); return null; }
189
- }
190
-
191
- /** 归一化 + 校验(缺 baseUrl / eteamsid / cookie 一律视为该出口不可用,继续下一个出口) */
192
- function normalizeAuth(o) {
193
- if (!o) return null;
194
- const baseUrl = String(o.baseUrl || "").trim().replace(/\/+$/, "");
195
- const eteamsid = String(o.eteamsid || "").trim();
196
- const cookie = String(o.cookie || "").trim();
197
- if (!baseUrl || !eteamsid || !cookie) return null;
198
- if (/^\((未知|空)\)$/.test(eteamsid) || /^\((未知|空)\)$/.test(cookie)) return null;
199
- return { baseUrl, eteamsid, cookie, userId: String(o.userId || "").trim(), tenantKey: String(o.tenantKey || "").trim(), source: o.source };
200
- }
201
-
202
- /** ① e10-login auth --json(正式契约:{v,baseUrl,eteamsId,cookie,userAgent,userId,tenantKey,agentType,profile}) */
203
- function providerAuthJson() {
204
- const out = runLoginCli(["auth", "--json"]);
205
- if (!out) return null;
206
- let j; try { j = JSON.parse(out); } catch { return null; }
207
- if (!j || typeof j !== "object" || Array.isArray(j)) return null;
208
- return normalizeAuth({
209
- baseUrl: j.baseUrl || j.base_url,
210
- eteamsid: j.eteamsId || j.eteamsid,
211
- cookie: typeof j.cookie === "string" ? j.cookie : "",
212
- userId: j.userId || j.employeeId,
213
- tenantKey: j.tenantKey,
214
- source: "e10-login auth --json",
215
- });
216
- }
217
-
218
- /** ② e10-login whoami --json(需其 JSON 带 cookies 字符串;当前 1.5.0 未带 → 自动落到 ③) */
219
- function providerWhoamiJson() {
220
- const out = runLoginCli(["whoami", "--json"]);
221
- if (!out) return null;
222
- let j; try { j = JSON.parse(out); } catch { return null; }
223
- if (!j || typeof j !== "object" || Array.isArray(j)) return null;
224
- const cookie = typeof j.cookies === "string" ? j.cookies : (typeof j.cookie === "string" ? j.cookie : "");
225
- return normalizeAuth({
226
- baseUrl: j.baseUrl,
227
- eteamsid: j.eteamsId || j.eteamsid,
228
- cookie,
229
- userId: j.employeeId || j.userId,
230
- tenantKey: j.tenantKey,
231
- source: "e10-login whoami --json",
232
- });
233
- }
234
-
235
- /** ③ e10-login whoami(文本输出解析:employeeId / tenantKey / baseUrl / ETEAMSID / cookies / User-Agent) */
236
- function providerWhoamiText() {
237
- const out = runLoginCli(["whoami"]);
238
- if (!out) return null;
239
- const m = {};
240
- for (const line of out.split(/\r?\n/)) {
241
- const x = line.match(/^\s*([A-Za-z][A-Za-z0-9_-]*)\s*:\s*(.+?)\s*$/);
242
- if (x) m[x[1].toLowerCase()] = x[2];
243
- }
244
- return normalizeAuth({
245
- baseUrl: m["baseurl"],
246
- eteamsid: m["eteamsid"],
247
- cookie: m["cookies"],
248
- userId: m["employeeid"] || m["userid"],
249
- tenantKey: m["tenantkey"],
250
- source: "e10-login whoami(文本)",
251
- });
252
- }
253
-
254
- /** ④ 明文镜像(最后兜底:不涉及任何解密,但可能已过期 → 命中时告警) */
255
- function providerCacheMd() {
256
- const p = path.join(os.homedir(), ".workbuddy", "e10-login-cache.md");
257
- if (!fs.existsSync(p)) return null;
258
- const txt = fs.readFileSync(p, "utf-8");
259
- const get = (key) => {
260
- const mm = txt.match(new RegExp("\\*\\*" + key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + "\\*\\*\\s*[::]\\s*`?([^`\\n]+)`?"));
261
- return mm ? mm[1].trim() : "";
262
- };
263
- const eteamsid = get("ETEAMSID");
264
- let cookie = get("完整 Cookie 串");
265
- if (!cookie && eteamsid) cookie = "langType=zh_CN; ETEAMSID=" + eteamsid + "; agentType=workbuddy; isAgent=true";
266
- return normalizeAuth({
267
- baseUrl: get("baseUrl"), eteamsid, cookie,
268
- userId: get("userId") || get("employeeId"), tenantKey: get("tenantKey"),
269
- source: "明文镜像 ~/.workbuddy/e10-login-cache.md(可能已过期)",
270
- });
271
- }
272
-
273
- const AUTH_PROVIDERS = [
274
- ["auth-json", providerAuthJson],
275
- ["whoami-json", providerWhoamiJson],
276
- ["whoami-text", providerWhoamiText],
277
- ["cache-md", providerCacheMd],
278
- ];
279
- /** 兜底出口不进记忆:否则 e10-login 偶发不可用一次,就会把"用镜像"永久设为首选、次次告警 */
280
- const AUTH_FALLBACK_PROVIDERS = new Set(["cache-md"]);
281
-
282
- function readAuthModeMemo() {
283
- try {
284
- const s = fs.readFileSync(AUTH_MODE_FILE, "utf-8").trim();
285
- return AUTH_PROVIDERS.some(([n]) => n === s) ? s : null;
286
- } catch { return null; }
287
- }
288
- function writeAuthModeMemo(name) {
289
- try { fs.mkdirSync(path.dirname(AUTH_MODE_FILE), { recursive: true }); fs.writeFileSync(AUTH_MODE_FILE, name, "utf-8"); } catch {}
290
- }
291
-
292
- /** 凭据取自 e10-login 时,顺手把明文镜像刷新到最新(保持镜像可用;镜像不参与正常取数) */
293
- function syncCacheMd(auth) {
294
- try {
295
- const p = path.join(os.homedir(), ".workbuddy", "e10-login-cache.md");
296
- if (!fs.existsSync(p) || !/🔐 登录信息缓存/.test(fs.readFileSync(p, "utf-8"))) return; // 非标准缓存文件不动它
297
- const d = new Date();
298
- const ts = d.getFullYear() + "-" + String(d.getMonth() + 1).padStart(2, "0") + "-" + String(d.getDate()).padStart(2, "0") + " " + String(d.getHours()).padStart(2, "0") + ":" + String(d.getMinutes()).padStart(2, "0");
299
- let txt = fs.readFileSync(p, "utf-8");
300
- let changed = false;
301
- const rep = (key, val) => {
302
- const re = new RegExp("(\\*\\*" + key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + "\\*\\*\\s*[::]\\s*`?)([^`\\n]*)(`?)");
303
- const n = txt.replace(re, (m, a, b, c) => { if (b === val && c === "`") return m; changed = true; return a + val + (c || "`"); });
304
- txt = n;
305
- };
306
- rep("baseUrl", auth.baseUrl); rep("ETEAMSID", auth.eteamsid); rep("完整 Cookie 串", auth.cookie); rep("userId", auth.userId || ""); rep("tenantKey", auth.tenantKey || "");
307
- rep("缓存记录时间", ts + "(e10-data 自动同步,凭据来源:" + (auth.source || "") + ")");
308
- if (changed) fs.writeFileSync(p, txt, "utf-8");
309
- } catch {}
310
- }
311
-
312
- /** 取凭据:按优先级依次尝试各出口;命中的模式会被记住(下次优先尝试它,省一次进程调用) */
313
- function loadAuth() {
314
- const { profile, file } = authFilePath();
315
- const override = (process.env.E10_DATA_AUTH_MODE || "").trim();
316
- const memo = override || (process.env.E10_DATA_AUTH_PROBE ? null : readAuthModeMemo());
317
- const order = memo
318
- ? AUTH_PROVIDERS.filter(([n]) => n === memo).concat(AUTH_PROVIDERS.filter(([n]) => n !== memo))
319
- : AUTH_PROVIDERS;
320
-
321
- const diag = [];
322
- for (const [name, fn] of order) {
323
- let got = null;
324
- lastLoginDiag = "";
325
- try { got = fn(); } catch (e) { diag.push(name + ": " + ((e && e.message) || e)); }
326
- if (got) {
327
- if (name !== memo && !AUTH_FALLBACK_PROVIDERS.has(name)) writeAuthModeMemo(name);
328
- got.profile = profile;
329
- if (name === "cache-md") {
330
- if (!process.env.E10_DATA_QUIET) {
331
- console.error("[e10-data][warn] 凭据来源=" + got.source + ";正常应由 e10-login 提供凭据,请检查 `e10-login whoami` 是否能输出 cookies。");
332
- }
333
- } else {
334
- syncCacheMd(got); // 凭据来自 e10-login → 顺便把镜像刷新到最新
335
- }
336
- return got;
337
- }
338
- if (lastLoginDiag) diag.push(name + ": " + lastLoginDiag);
339
- }
340
-
341
- throw new Error(
342
- "未取得登录凭据(解密由 e10-login 负责,本 CLI 不参与)。请依次检查:\n" +
343
- " ① e10-login 是否可用:`e10-login --version`(缺失 → `npm i -g e10-login`);`e10-login whoami` 应能输出 cookies\n" +
344
- " ② 是否已登录 / 登录态是否失效:`e10-login oidc` 重新登录(当前 profile: " + profile + ")\n" +
345
- " ③ auth 文件:" + (fs.existsSync(file) ? "存在" : "不存在") + "(" + file + ")—— 其加解密由 e10-login 负责\n" +
346
- (diag.length ? " 探测诊断:" + diag.join(" | ") + "\n" : "")
347
- );
348
- }
349
-
350
- // ---------------- 凭据进程间 TTL 缓存(v0.1.17 新增,不落明文)----------------
351
- // 目的:避免每次调用都重新拉起 e10-login 取凭据(去掉 shell 后仍约 0.22s/次)。
352
- // 形态:一个 detached 的「持有者」进程把凭据**只保存在内存**里,监听本机命名管道
353
- // (Windows)/ Unix 域套接字(0600),供后续同用户·同 profile 的 e10-data 调用取用,
354
- // TTL(默认 300s)到期自动退出。**不写任何文件、不涉及网络。**
355
- // 边界:
356
- // · 任何异常(连不上/超时/JSON 坏/过期/profile 不符)一律静默回落正常取凭据路径;
357
- // · 兜底出口(明文镜像)的凭据不会写入缓存,避免把过期凭据固化;
358
- // · E10_DATA_NO_CRED_CACHE=1 整体关闭;E10_DATA_AUTH_MODE / E10_DATA_AUTH_PROBE 存在时绕过缓存;
359
- // · 缓存通道名含「主目录+用户名+profile」哈希 → 不同用户/不同 profile 互不可见。
360
- const CRED_CACHE_TTL = (() => {
361
- const n = Number(process.env.E10_DATA_CRED_TTL);
362
- return Number.isFinite(n) && n > 0 ? Math.floor(n) : 300;
363
- })();
364
-
365
- /** 缓存通道地址(只有 e10-data 与 e10-cred-helper 使用;由父进程算好后以 argv 传给持有者) */
366
- function credCachePath() {
367
- const { profile } = authFilePath();
368
- const tag = crypto.createHash("sha1")
369
- .update([os.homedir(), process.env.USERNAME || process.env.USER || "", profile].join("|"))
370
- .digest("hex").slice(0, 16);
371
- if (process.platform === "win32") return "\\\\.\\pipe\\e10-data-cred-" + tag;
372
- const uid = typeof process.getuid === "function" ? process.getuid() : "n";
373
- const dir = process.env.XDG_RUNTIME_DIR || path.join(os.homedir(), ".e10-cli");
374
- return path.join(dir, "e10-data-cred-" + tag + "-" + uid + ".sock");
375
- }
376
-
377
- /** 读缓存:命中返回 auth(结构同 loadAuth 的返回值);未命中/超时/任何异常 → null */
378
- function credCacheGet() {
379
- const { profile } = authFilePath();
380
- return new Promise((resolve) => {
381
- let done = false, buf = "", sock;
382
- const finish = (v) => {
383
- if (done) return;
384
- done = true;
385
- clearTimeout(timer);
386
- try { sock.destroy(); } catch {}
387
- resolve(v);
388
- };
389
- const timer = setTimeout(() => finish(null), 1500); // 取缓存必须快;超时即当未命中
390
- try { sock = net.connect(credCachePath()); } catch { return finish(null); }
391
- sock.setEncoding("utf-8");
392
- sock.on("connect", () => { try { sock.write("GET\n"); } catch { finish(null); } });
393
- sock.on("data", (d) => {
394
- buf += d;
395
- if (!buf.includes("\n")) return;
396
- try {
397
- const j = JSON.parse(buf.split("\n")[0]);
398
- if (!j || j.v !== 1 || Number(j.exp) < Date.now()) return finish(null);
399
- if ((j.profile || "default") !== profile) return finish(null);
400
- const a = normalizeAuth(Object.assign({}, j.auth, { source: "进程间凭据缓存(e10-data cred-cache)" }));
401
- return finish(a || null);
402
- } catch { return finish(null); }
403
- });
404
- sock.on("error", () => finish(null));
405
- sock.on("close", () => finish(null));
406
- });
407
- }
408
-
409
- /** 写缓存:拉起 detached 持有者,凭据经 stdin 传入(不经 argv,避免出现在进程列表里) */
410
- function credCachePublish(auth) {
411
- return new Promise((resolve) => {
412
- let child;
413
- try {
414
- const helper = path.join(__dirname, "e10-cred-helper.mjs");
415
- if (!fs.existsSync(helper)) return resolve();
416
- child = spawn(process.execPath, [helper, credCachePath(), String(CRED_CACHE_TTL)], {
417
- detached: true, stdio: ["pipe", "ignore", "ignore"], windowsHide: true,
418
- });
419
- } catch { return resolve(); }
420
- const payload = JSON.stringify({
421
- exp: Date.now() + CRED_CACHE_TTL * 1000,
422
- profile: auth.profile || "default",
423
- auth: {
424
- baseUrl: auth.baseUrl, eteamsid: auth.eteamsid, cookie: auth.cookie,
425
- userId: auth.userId, tenantKey: auth.tenantKey, source: auth.source,
426
- },
427
- });
428
- let settled = false;
429
- const done = () => { if (settled) return; settled = true; try { child.unref(); } catch {} resolve(); };
430
- child.on("error", done);
431
- child.stdin.on("error", done);
432
- child.stdin.end(payload, done);
433
- setTimeout(done, 1000); // 兜底:不让发布环节拖住主命令
434
- });
435
- }
436
- // ---------------- login(唯一"不需要已有凭据"的命令族:xiaoe-env,2026-09-17 移植)----------------
437
- // 来源:e10-login 1.6.4-xiaoe 的 `e10-login xiaoe-env [--domain <url>]`
438
- // (lib/xiaoe.js 命令定义 + lib/login-flow.js 收尾 + scripts/xiaoe_env.py 换 token)。
439
- // 原实现是「Node 里 spawn python scripts/xiaoe_env.py 换 ETEAMSID」,而本 CLI 是零依赖 Node,
440
- // **不需要 python**:换 token 本身就是一次 POST,此处原生复刻(逐字段对齐:URL 拼接、请求头
441
- // authorization、body {domain}、code 判定 '0000'/200、取 data.eteamId、错误信息取 msg)。
442
- // 分工(与本 CLI 既有的认证边界一致,不越界):
443
- // ① 换 token → 本 CLI 原生实现(无 python 依赖);
444
- // ② 落 profile → 仍交 e10-login `set --eteamsid --base-url`(auth 文件由它的密钥体系加密,
445
- // 只有它解得开;`set` 与原命令的收尾 finishLoginByEteamsId 是同一条路径:
446
- // teamsCheck 解析用户信息 + 写 auth/config + 切换 active profile)。
447
- // ⚠️ 本段必须在 loadAuth() 之前执行 —— 该命令存在的意义就是"还没有凭据时把凭据造出来",
448
- // 因此它在 main() 之前拦截(见下方拦截块),不走常规 main() 分发。
449
- const LOGIN_DEFAULT_BASE_URL = "https://www.e-cology.com.cn";
450
- const XIAOE_ENV_ETEAMID_PATH = "/spec-apps/api/app/rpachat/skill/extend/eteamId";
451
- const ETEAMSID_SAFE_RE = /^[A-Za-z0-9_.\-]+$/; // 透传给 e10-login 的参数白名单(该调用走 shell,防注入)
452
-
453
- /** --domain → 当前 profile 的 config.json.baseUrl → 默认平台地址(对齐 e10-login 的 resolveBaseUrl;
454
- * config.json 是明文,读它不涉及任何解密) */
455
- function resolveLoginBaseUrl(optDomain) {
456
- if (optDomain !== undefined && typeof optDomain !== "string") throw new Error("--domain 需要跟一个平台地址,如 --domain https://www.e-cology.com.cn");
457
- let cfgBase = "";
458
- try {
459
- const cfgFile = path.join(path.dirname(authFilePath().file), "config.json");
460
- if (fs.existsSync(cfgFile)) cfgBase = String(JSON.parse(fs.readFileSync(cfgFile, "utf-8")).baseUrl || "").trim();
461
- } catch {}
462
- return String(optDomain || cfgBase || LOGIN_DEFAULT_BASE_URL).trim().replace(/\/+$/, "");
463
- }
464
-
465
- /** 复刻 scripts/xiaoe_env.py:POST {XIAOE_DOMAIN}/spec-apps/api/app/rpachat/skill/extend/eteamId
466
- * 请求头 authorization=<XIAOE_USER_TOKEN>;body {"domain": <目标平台域名>} → 取 data.eteamId */
467
- async function xiaoeEnvFetchEteamsId(domain) {
468
- const token = String(process.env.XIAOE_USER_TOKEN || "").trim();
469
- const erpa = String(process.env.XIAOE_DOMAIN || "").trim();
470
- if (!token || !erpa) {
471
- throw new Error("缺少环境变量 XIAOE_USER_TOKEN / XIAOE_DOMAIN(请先设置后重试,如 export XIAOE_USER_TOKEN=<token> XIAOE_DOMAIN=<erpa域名>)");
472
- }
473
- const url = erpa.replace(/\/+$/, "") + XIAOE_ENV_ETEAMID_PATH;
474
- let resp, text;
475
- try {
476
- resp = await fetch(url, {
477
- method: "POST",
478
- headers: { authorization: token, "content-type": "application/json", accept: "application/json" },
479
- body: JSON.stringify({ domain }),
480
- });
481
- text = await resp.text();
482
- } catch (e) {
483
- throw new Error("请求 XiaoE 换 token 接口失败(" + url + "):" + ((e && e.message) || e));
484
- }
485
- let j = null;
486
- try { j = JSON.parse(text); } catch {}
487
- if (!j) throw new Error("XiaoE 接口返回非 JSON(HTTP " + resp.status + "):" + text.slice(0, 200));
488
- const code = j.code;
489
- const eteamId = String((j.data && j.data.eteamId) || "").trim();
490
- if (!(code === "0000" || code === 200 || code === "200") || !eteamId) {
491
- throw new Error("XiaoE 换取失败:" + (j.msg || ("HTTP " + resp.status + " code=" + code)));
492
- }
493
- return eteamId;
494
- }
495
-
496
- /** e10-data login xiaoe-env:环境变量全局 token → ETEAMSID → 写 profile(走 e10-login set 收尾) */
497
- async function cmdLoginXiaoeEnv(args, json) {
498
- const say = json ? console.error : console.log; // --json 时人读信息一律走 stderr,保持 stdout 只有 JSON
499
- const baseUrl = resolveLoginBaseUrl(args.domain);
500
- say("ℹ️ 目标平台: " + baseUrl + (args.domain ? "(--domain)" : baseUrl === LOGIN_DEFAULT_BASE_URL ? "(默认平台地址)" : "(当前 profile 的 config.json)"));
501
-
502
- const eteamsId = await xiaoeEnvFetchEteamsId(baseUrl);
503
- say("✅ 已取得 ETEAMSID(来源: env,不回显)");
504
-
505
- if (!ETEAMSID_SAFE_RE.test(eteamsId)) throw new Error("换回的 ETEAMSID 含异常字符,已拒绝透传 e10-login(安全防护)");
506
- // v0.1.17:改为数组直传 + shell:false(不再手工拼 shell 字符串、不再需要 shq 转义),
507
- // 既省掉 cmd.exe 那一层开销,也彻底消除 shell 注入面。ETEAMSID_SAFE_RE 仍作为二道防线保留。
508
- const r = spawnLoginCli(["set", "--eteamsid", eteamsId, "--base-url", baseUrl], 60000);
509
- const out = (r.stdout || "").trim(), err = (r.stderr || "").trim();
510
- if (r.error || r.status !== 0) {
511
- throw new Error("换 token 成功,但 e10-login 写 profile 失败(auth 文件的加密写入只有 e10-login 能做):"
512
- + (err || out || (r.error && r.error.message) || ("exit=" + r.status)));
513
- }
514
- for (const line of (out + (err ? "\n" + err : "")).split(/\r?\n/)) if (line.trim()) say(" [e10-login] " + line.trim());
515
-
516
- const profile = authFilePath().profile;
517
- // 自检:立刻用取数链路(loadAuth)把凭据读回来,确认"真能读到"而不是"只写了文件"
518
- // (历史坑:e10-login 旧版 whoami 不带 cookies → 登录成功但 e10-data 取不到凭据;此处必须报出来)
519
- // 强制重新探测(E10_DATA_AUTH_PROBE),并拒绝把"明文镜像兜底"当成自检通过 —— 镜像可能是旧环境的
520
- let readBack = "";
521
- try {
522
- process.env.E10_DATA_AUTH_PROBE = "1";
523
- const a = loadAuth();
524
- const viaMirror = /镜像/.test(String(a.source || ""));
525
- readBack = (viaMirror ? "⚠️ 仅命中明文镜像:" : "ok(") + (a.source || "?") + ";baseUrl: " + a.baseUrl + (a.userId ? ";userId: " + a.userId : "") + (viaMirror ? "" : ")");
526
- if (viaMirror) throw new Error("凭据来自明文镜像而非新写入的 profile(镜像可能是旧环境/已过期)");
527
- say("✅ 凭据自检通过:" + readBack);
528
- } catch (e) {
529
- if (!readBack) readBack = "失败: " + String((e && e.message) || e).split(/\r?\n/)[0];
530
- throw new Error("profile 已写入 ~/.e10-cli/profiles/" + profile + "/auth,但本 CLI 读不回凭据(" + readBack
531
- + ")—— 取数仍会失败。请确认 `e10-login whoami` 能输出 cookies(缺则升级 e10-login),或改用 `e10-login oidc`。");
532
- }
533
- return {
534
- ok: true,
535
- baseUrl,
536
- profile,
537
- credentialSource: "env(XIAOE_USER_TOKEN + XIAOE_DOMAIN)",
538
- writtenBy: "e10-login set",
539
- eteamsId: "(已写入,不回显)",
540
- readback: readBack,
541
- };
542
- }
543
-
544
- // ---- 拦截(必须早于 loadAuth()):login 族 = 唯一不需要已有凭据的命令族 ----
545
- {
546
- const a2 = process.argv[2] || "";
547
- const bare = a2 === "xiaoe-env"; // 别名:与 e10-login 同名命令保持一致的肌肉记忆
548
- if (a2 === "login" || bare) {
549
- const sub = bare ? a2 : process.argv[3] || "";
550
- const { args } = parseArgs(process.argv.slice(bare ? 3 : 4));
551
- const json = !!args.json;
552
- try {
553
- if (sub !== "xiaoe-env") throw new Error("未知子命令 `login " + (sub || "(空)") + "`(当前仅移植 xiaoe-env)");
554
- const r = await cmdLoginXiaoeEnv(args, json);
555
- if (json) console.log(JSON.stringify(r, null, 1));
556
- process.exit(0);
557
- } catch (e) {
558
- console.error("ERROR: " + ((e && e.message) || e));
559
- process.exit(1);
560
- }
561
- }
562
- }
563
-
564
- // ---- 命令白名单(早期校验用;必须与下方 main() 的分发表保持一致)----
565
- // 返回 true = 该命令存在、可以继续(可能还需要凭据);false = 未知命令,直接给用法提示。
566
- // 为什么需要:loadAuth() 写在模块顶层,过去"未知命令"也要先付一整笔取凭据的开销
567
- // (Windows 上 ~1.0s,去 shell 后 ~0.22s)才打印用法。v0.1.17 起提前判定。
568
- const CMD_SUBS = {
569
- ds: ["tables", "fields", "list", "chart", "stat", "count"],
570
- doc: ["count", "list"],
571
- noun: ["list"],
572
- field: ["id2name", "name2id"],
573
- conf: ["get", "batch", "batch-file", "group", "group-file", "resolve", "resolve-file", "add", "add-file", "update", "delete"],
574
- exp: ["status", "manifest", "commits", "file", "pull", "init", "commit", "lock", "release", "rollback"],
575
- attend: ["statis"],
576
- eb: ["perm-page"],
577
- };
578
- function commandIsKnown(pos) {
579
- const family = pos[0], sub = pos[1];
580
- if (!family) return false; // 无命令 → 用法
581
- if (family === "query") return true; // query 无子命令(cmdQuery 自行处理入参)
582
- if (family === "login" || family === "xiaoe-env") return true; // 已由上方拦截块接管
583
- const subs = CMD_SUBS[family];
584
- if (!subs) return false;
585
- return subs.includes(sub);
586
- }
587
-
588
- // ---- 拦截(必须早于 loadAuth()):免凭据命令族 ----
589
- // ① 版本 / ② 帮助 / ③ 未知命令 —— 这三类都不需要凭据,故在取凭据之前 return。
590
- // (login 族由上方独立拦截块处理;exp release/rollback 的 --version 是版本号参数、长度 >1 不受影响)
591
- {
592
- const rawArgv = process.argv.slice(2);
593
- const { args: earlyArgs, pos: earlyPos } = parseArgs(rawArgv);
594
-
595
- // ① --version / -v:仅顶层单参数
596
- if (rawArgv.length === 1 && (rawArgv[0] === "--version" || rawArgv[0] === "-v")) {
597
- console.log("e10-data " + cliVersion());
598
- process.exit(0);
599
- }
600
-
601
- // ② --help / -h:显式求帮助 → 用法 + 退出码 0(区别于"用错了"的退出码 1)
602
- // 只认「裸开关」(值为 true),避免把 --value --help 这类取值误判为求助;
603
- // -h 是单短横线、parseArgs 不识别,单独按顶层单参数处理
604
- if (earlyArgs.help === true || (rawArgv.length === 1 && rawArgv[0] === "-h")) {
605
- usage();
606
- process.exit(0);
607
- }
608
-
609
- // ③ 未知命令族 / 未知子命令 / 缺子命令 → 用法提示,退出码 1
610
- if (!commandIsKnown(earlyPos)) {
611
- usage();
612
- process.exit(1);
613
- }
614
- }
615
-
616
- // ---- 取凭据(v0.1.17:先试进程间缓存,未命中再走 e10-login,并把结果发布给后续调用)----
617
- let auth = null;
618
- const CRED_CACHE_ON = !process.env.E10_DATA_NO_CRED_CACHE
619
- && !process.env.E10_DATA_AUTH_MODE && !process.env.E10_DATA_AUTH_PROBE;
620
- if (CRED_CACHE_ON) auth = await credCacheGet();
621
- if (!auth) {
622
- auth = loadAuth();
623
- // 兜底出口(明文镜像)的凭据不写入缓存 —— 避免把可能已过期的凭据固化整个 TTL
624
- if (CRED_CACHE_ON && !/镜像/.test(String(auth.source || ""))) await credCachePublish(auth);
625
- }
626
- const BASE = auth.baseUrl.replace(/\/+$/, "");
627
- const USER = { id: auth.userId || "", tenantKey: auth.tenantKey || "" };
628
-
629
- // ---------------- HTTP ----------------
630
- function checkAuthFail(resp, text, j) {
631
- if (resp.status === 401 || (j && String(j.code) === "-1" && /token|登录|失效/i.test(String(j.msg || "")))) {
632
- const from = (auth && auth.source) || "未知";
633
- const hint = /镜像/.test(from)
634
- ? "当前用的是明文镜像兜底凭据(可能已过期),请先确认 `e10-login whoami` 能正常导出 cookies,或执行 `e10-login oidc` 重新登录"
635
- : "凭据由 e10-login 提供,请执行 `e10-login oidc` 重新登录";
636
- throw new Error("登录态失效(" + (j.msg || resp.status) + "):凭据来源=" + from + ";" + hint);
637
- }
638
- }
639
- async function postJson(endpoint, bodyObj, extraHeaders = {}) {
640
- const resp = await fetch(BASE + endpoint, {
641
- method: "POST",
642
- headers: {
643
- "Content-Type": "application/json;charset=UTF-8",
644
- "accept": "application/json, text/plain, */*",
645
- "langType": "zh_CN",
646
- "cookie": auth.cookie,
647
- "eteamsid": auth.eteamsid,
648
- "ebbusinessid": EB_BIZ_ID,
649
- "user-agent": UA,
650
- "timezoneoffset": "-480",
651
- ...extraHeaders,
652
- },
653
- body: JSON.stringify(bodyObj),
654
- });
655
- const text = await resp.text();
656
- let j; try { j = parseJsonSafe(text); } catch { throw new Error("HTTP " + resp.status + " 非JSON响应: " + text.slice(0, 300)); }
657
- checkAuthFail(resp, text, j);
658
- return j;
659
- }
660
- async function getUrl(url) {
661
- const resp = await fetch(BASE + url, {
662
- method: "GET",
663
- headers: {
664
- "accept": "application/json, text/plain, */*",
665
- "langType": "zh_CN",
666
- "cookie": auth.cookie,
667
- "eteamsid": auth.eteamsid,
668
- "ebbusinessid": EB_BIZ_ID,
669
- "user-agent": UA,
670
- },
671
- });
672
- const text = await resp.text();
673
- let j; try { j = parseJsonSafe(text); } catch { throw new Error("HTTP " + resp.status + " 非JSON响应: " + text.slice(0, 300)); }
674
- checkAuthFail(resp, text, j);
675
- return j;
676
- }
677
- async function postMultipart(endpoint, formObj) {
678
- const fd = new FormData();
679
- for (const [k, v] of Object.entries(formObj)) fd.append(k, typeof v === "string" ? v : JSON.stringify(v));
680
- const resp = await fetch(BASE + endpoint, {
681
- method: "POST",
682
- headers: {
683
- "accept": "application/json, text/plain, */*",
684
- "langType": "zh_CN",
685
- "cookie": auth.cookie,
686
- "eteamsid": auth.eteamsid,
687
- "ebbusinessid": EB_BIZ_ID,
688
- "user-agent": UA,
689
- "timezoneoffset": "-480",
690
- "referer": BASE + "/sp/ebddesigner/1282331905881874436",
691
- },
692
- body: fd,
693
- });
694
- const text = await resp.text();
695
- let j; try { j = parseJsonSafe(text); } catch { throw new Error("HTTP " + resp.status + " 非JSON响应: " + text.slice(0, 300)); }
696
- checkAuthFail(resp, text, j);
697
- return j;
698
- }
699
-
700
- // ---------------- 参数解析 ----------------
701
- function parseArgs(argv) {
702
- const args = {};
703
- const pos = [];
704
- for (let i = 0; i < argv.length; i++) {
705
- const a = argv[i];
706
- if (a.startsWith("--")) {
707
- const key = a.slice(2);
708
- const v = argv[i + 1];
709
- if (v === undefined || v.startsWith("--")) { args[key] = true; }
710
- else { args[key] = v; i++; }
711
- } else pos.push(a);
712
- }
713
- return { args, pos };
714
- }
715
- // ⚠️ 大整数精度保护(2026-09-03):E10 主键/ID 多为 19 位大整数,超过 JS 安全整数 2^53-1
716
- // (9007199254740991)。JSON.parse 会将其舍入为近似 double(如 1297901170990161977 → ...1920),
717
- // 导致 DSL 条件查不到数据(静默空结果)或返回 ID 失真。此处把 JSON 中 >2^53 的裸数字字面量
718
- // 加引号转字符串(后端实测接受字符串形式做 = / IN / BETWEEN 数值比较),请求与响应两侧统一保护。
719
- function protectBigInts(raw) {
720
- // 逐字符扫描:跟踪 JSON 字符串状态(含转义),仅在引号外的结构层把 >2^53 的裸整数加引号,
721
- // 避免误伤字符串内容(如 avatar JSON、文档正文里含 ",大数字" 片段)。
722
- let out = "", inStr = false, esc = false;
723
- for (let i = 0; i < raw.length; i++) {
724
- const ch = raw[i];
725
- if (inStr) {
726
- out += ch;
727
- if (esc) esc = false;
728
- else if (ch === "\\") esc = true;
729
- else if (ch === '"') inStr = false;
730
- continue;
731
- }
732
- if (ch === '"') { inStr = true; out += ch; continue; }
733
- if (ch === "-" || (ch >= "0" && ch <= "9")) {
734
- let j = i + 1;
735
- while (j < raw.length && raw[j] >= "0" && raw[j] <= "9") j++; // 纯整数 token(无小数点/指数)
736
- let k = i - 1;
737
- while (k >= 0 && /\s/.test(raw[k])) k--;
738
- const prev = k < 0 ? "" : raw[k];
739
- let n = j;
740
- while (n < raw.length && /\s/.test(raw[n])) n++;
741
- const next = n < raw.length ? raw[n] : "";
742
- const token = raw.slice(i, j);
743
- if ((prev === ":" || prev === "," || prev === "[") && (next === "," || next === "}" || next === "]")) {
744
- try {
745
- const bi = BigInt(token);
746
- if (bi > 9007199254740991n || bi < -9007199254740991n) { out += '"' + token + '"'; i = j - 1; continue; }
747
- } catch { /* 非纯整数不转换 */ }
748
- }
749
- out += raw.slice(i, j);
750
- i = j - 1;
751
- continue;
752
- }
753
- out += ch;
754
- }
755
- return out;
756
- }
757
- function parseJsonSafe(raw, label) {
758
- try { return JSON.parse(protectBigInts(raw)); }
759
- catch (e) { throw new Error((label || "JSON") + " 解析失败: " + e.message); }
760
- }
761
- function readJsonArg(val, label) {
762
- let raw = val;
763
- if (val.startsWith("@")) raw = fs.readFileSync(val.slice(1), "utf-8");
764
- return parseJsonSafe(raw, label);
765
- }
766
- function need(arg, args) { if (!args[arg]) throw new Error("缺少必填参数 --" + arg); return args[arg]; }
767
- // 文件/JSON 参数取值:兼容 --flag <v> 与位置参数 <v>(如 conf add-file @items.json / --add-file @items.json)
768
- function flagOrPos(args, flag, keys) {
769
- if (args[flag] !== undefined) return args[flag];
770
- return keys && keys.length ? keys[0] : undefined;
771
- }
772
-
773
- // ---------------- 展示 ----------------
774
- function fmt(v) {
775
- if (v === null || v === undefined) return "";
776
- if (typeof v === "object") { const s = JSON.stringify(v); return s.length > 80 ? s.slice(0, 80) + "…" : s; }
777
- return String(v);
778
- }
779
- function printHuman(title, rows, cols) {
780
- if (title) console.log("== " + title + " ==");
781
- if (!rows || !rows.length) { console.log("(空)"); return; }
782
- if (cols) {
783
- console.log(cols.join("\t"));
784
- for (const r of rows) console.log(cols.map((c) => fmt(r[c])).join("\t"));
785
- } else {
786
- for (const r of rows.slice(0, 100)) console.log(JSON.stringify(r));
787
- if (rows.length > 100) console.log("... 共 " + rows.length + " 行(--json 取全量)");
788
- }
789
- }
790
-
791
- // ---------------- query:DSL 查询 ----------------
792
- async function cmdQuery(args, json) {
793
- const conn = need("conn", args), model = need("model", args), schema = need("schema", args);
794
- const tableId = args["table-id"] || "";
795
- const dslVal = need("dsl", args);
796
- const dslQuery = dslVal.startsWith("@") ? readJsonArg(dslVal, "--dsl") : parseJsonSafe(dslVal, "--dsl");
797
- const body = { connId: conn, model, schema, tableId, dslQuery };
798
- if (args.debug !== undefined) { // --debug true|false,与 conn/model 同层级
799
- const dbg = String(args.debug).toLowerCase();
800
- if (dbg === "true" || dbg === "1") body.debug = true;
801
- else if (dbg === "false" || dbg === "0") body.debug = false;
802
- else throw new Error("--debug 值必须为 true 或 false(当前: " + args.debug + ")");
803
- }
804
- const r = await postJson("/api/dw/dsl/executeQueryDsl", body);
805
- if (json) return r;
806
- if (String(r.code) !== "200" && r.code !== undefined && String(r.code) !== "0") {
807
- console.log("code=" + r.code + " msg=" + (r.msg || ""));
808
- if (/没有权限|无权限|权限不足|未授权|无权访问|无查询权限/i.test(String(r.msg || ""))) {
809
- throw new Error("当前用户对该数据无查询权限,请检查 ETEAMSID 是否有效或联系管理员开通");
810
- }
811
- return;
812
- }
813
- const d = r.data;
814
- // debug=true 时 data 为 { sql, records };否则为行数组
815
- const rows = Array.isArray(d) ? d : (d && Array.isArray(d.records) ? d.records : []);
816
- if (d && !Array.isArray(d) && d.sql) console.log("SQL: " + d.sql);
817
- if (!rows.length) { console.log("(空:0 行)"); return; }
818
- printHuman("结果 " + rows.length + " 行", rows);
819
- }
820
-
821
- // ---------------- ds:数据源 ----------------
822
- function dyn() {
823
- return { eteamsId: auth.eteamsid, userId: auth.userId || "", tenantKey: auth.tenantKey || "", langId: 7, formatDate: "yyyy-MM-dd HH:mm:ss", isPublic: false };
824
- }
825
- function baseDto(tableId, groupId, pageNo, pageSize, queryType) {
826
- return { tableId: String(tableId), groupId, pageNo, pageSize, queryType, containDetailTable: false, needFormat: true, dynamicParams: dyn() };
827
- }
828
- // 条件树(conditionTrees)协议:根/组节点 { key, relationship("1"=AND|"0"=OR), isParent:true, parentNode, conditionList:[...] }
829
- // 叶子节点 { key, relationship:"1", isParent:false, parentNode, conditionId, conditionType, compareType, conditionValue, ... }
830
- // v0.1.8 起 parseWhere 支持递归树(OR);向后兼容:数组=AND 根、叶子键 fieldId 优先(conditionId 兼容兜底)
831
- function normRel(r) { return (r === "0" || r === 0 || r === false) ? "0" : "1"; }
832
- function isCondGroup(c) { return !!c && Array.isArray(c.conditionList) && c.conditionList.length > 0; }
833
- function buildCondNode(c, key, parentKey) {
834
- if (isCondGroup(c)) {
835
- return {
836
- key, relationship: normRel(c.relationship), isParent: true, parentNode: parentKey,
837
- mainField: c.mainField !== false,
838
- conditionList: c.conditionList.map((k, i) => buildCondNode(k, key + "_" + i, key)),
839
- };
840
- }
841
- const fid = (c && (c.fieldId || c.conditionId)) || "";
842
- if (!fid) throw new Error("--where 条件缺少字段:叶子须提供 fieldId(或 conditionId),条件组须提供非空 conditionList");
843
- return {
844
- key, relationship: "1", isParent: false, parentNode: parentKey,
845
- objId: c.objId || "", conditionId: fid, fieldName: c.fieldName || fid,
846
- conditionType: c.conditionType || "", compareType: c.compareType, conditionValue: c.conditionValue ?? "",
847
- fieldValue: c.fieldValue || "", mainField: c.mainField !== false, ignoreCaseMark: c.ignoreCaseMark || false,
848
- };
849
- }
850
- function parseWhere(val) {
851
- if (!val) return null;
852
- const raw = readJsonArg(val, "--where");
853
- if (!raw) return null;
854
- const ts = Date.now(), rootKey = "root_" + ts;
855
- if (Array.isArray(raw)) { // 数组 → AND 根(元素可为叶子或内嵌条件组,组自带 relationship)
856
- const kids = [];
857
- raw.forEach((c, i) => { if (c && typeof c === "object") kids.push(buildCondNode(c, rootKey + "_" + i, rootKey)); });
858
- if (!kids.length) return null;
859
- return { key: rootKey, relationship: "1", isParent: true, parentNode: "", mainField: true, conditionList: kids };
860
- }
861
- if (typeof raw !== "object") throw new Error("--where 须为条件数组或条件树对象");
862
- // 对象形态 = 完整条件树({relationship, conditionList:[...]},缺省 AND);无 conditionList 视为无条件
863
- if (!Array.isArray(raw.conditionList) || !raw.conditionList.length) return null;
864
- return buildCondNode(raw, rootKey, "");
865
- }
866
- function parseStat(val) {
867
- const arr = readJsonArg(val, "--stat");
868
- const list = Array.isArray(arr) ? arr : [arr];
869
- return list.map((s) => ({ fieldId: s.fieldId, fieldName: s.fieldId, objId: "", mainField: true, aggregator: s.aggregator || "SUM", ...(s.fieldAlias ? { fieldAlias: s.fieldAlias } : {}) }));
870
- }
871
- function parseOrder(val) {
872
- if (!val) return [];
873
- return val.split(",").map((s) => {
874
- const [f, dir] = s.trim().split(/\s+/);
875
- return { fieldId: f, fieldName: f, objId: "", isAsc: (dir || "asc").toLowerCase() !== "desc", mainField: true };
876
- });
877
- }
878
- // ---------------- ds appId:一律显式传入(v0.1.14,2026-09-11 用户确认调整)----------------
879
- // 背景:ChatBI 数据源取数接口(getListDatas / getChartDatas / getStatDatas / getCountData)
880
- // 对部分 EB 应用(独立部署)的数据集必须携带 queryDto.appId,否则后端一律返回 code=500。
881
- // 设计:appId 属「权限校验」的产物 —— 调用方先查 dw.chat_bi_eb_config_map 判权限(无权限即终止),
882
- // 同一条记录的 app_id 即本次取数必须携带的 appId,再显式传入本 CLI:
883
- // → CLI 不再自行反查 appId(v0.1.13 的「chat_bi_eb_config_map → ebdf_obj 自动解析」已整体移除),
884
- // 避免把权限表当普通字典使用、也避免 appId 来源不可见;
885
- // → 仅 EB 建模表单(groupId = weaver-ebuilder-form-service)需要 appId,其它数据集可不传;
886
- // → 不传也**不做拦截**,由后端按原样报错。
887
- function dsFieldsForm(gid, tid, st) {
888
- return { groupId: gid, sourceId: tid, sourceType: st, detailFieldsGroup: "true", enableMerge: "true", needTableName: "true", params: "[]" };
889
- }
890
- // ds 取数统一入口:appId 只认显式 --app-id <id>
891
- // (--app-id auto / --no-app-id 一律视为未传:v0.1.14 起不再自动解析,也不做缺失拦截)
892
- async function dsDto(args, tid, gid, pageNo, pageSize, queryType) {
893
- const dto = baseDto(tid, gid, pageNo, pageSize, queryType);
894
- const appId = args["app-id"];
895
- if (typeof appId === "string" && appId !== "" && appId !== "auto") dto.appId = appId;
896
- return dto;
897
- }
898
- async function cmdTables(args, json) {
899
- const gid = need("group", args);
900
- const form = { name: args.name || "", pageNo: args.page || "1", pageSize: args.size || "100", groupId: gid, sourceType: args["source-type"] || "LOGIC", dataUsage: "" };
901
- if (typeof args["app-id"] === "string" && args["app-id"] !== "" && args["app-id"] !== "auto") form.appId = args["app-id"];
902
- const r = await postMultipart("/api/dw/newetl/chatBi/ds/listByPage", form);
903
- if (json) return r;
904
- const recs = ((r.data || {}).records) || [];
905
- printHuman("分组表清单 (total=" + ((r.data || {}).total) + ")", recs, ["id", "name", "appId", "tableText", "detailTable", "usage"]);
906
- }
907
- async function cmdFields(args, json) {
908
- const gid = need("group", args), tid = need("table", args);
909
- const form = dsFieldsForm(gid, tid, args["source-type"] || "LOGIC");
910
- if (typeof args["app-id"] === "string" && args["app-id"] !== "" && args["app-id"] !== "auto") form.appId = args["app-id"];
911
- const r = await postMultipart("/api/ebuilder/common/ds/fields", form);
912
- if (json) return r;
913
- for (const d of (r.data || [])) {
914
- console.log("== 表: " + d.name + " (id=" + d.id + ") 字段 " + (d.fields || []).length + " 个 ==");
915
- for (const f of (d.fields || [])) console.log([f.name, f.text, f.type, f.mainPrimaryKey ? "PK" : ""].filter(Boolean).join("\t"));
916
- }
917
- }
918
- async function cmdList(args, json) {
919
- const gid = need("group", args), tid = need("table", args), fld = need("fields", args);
920
- const fields = fld.split(",").map((f) => ({ fieldId: f, fieldName: f, objId: String(tid), mainField: true }));
921
- const dto = await dsDto(args, tid, gid, parseInt(args.page || "1", 10), parseInt(args.size || "20", 10), "getListData");
922
- dto.queryFields = fields;
923
- dto.orderFields = parseOrder(args.order);
924
- const ct = parseWhere(args.where); if (ct) dto.conditionTrees = ct;
925
- dto.queryCount = true;
926
- const r = await postMultipart("/api/dw/newetl/chatBi/ds/getListDatas", { sourceType: args["source-type"] || "LOGIC", queryDto: dto });
927
- if (json) return r;
928
- const data = r.data || {};
929
- console.log("== 数据列表 count=" + data.count + " 行=" + (data.list || []).length + " ==");
930
- printHuman("", data.list || [], fields.map((f) => f.fieldId));
931
- }
932
- async function cmdChart(args, json) {
933
- const gid = need("group", args), tid = need("table", args), gb = need("group-by", args);
934
- const dto = await dsDto(args, tid, gid, parseInt(args.page || "1", 10), parseInt(args.size || "100", 10), "getChartData");
935
- dto.groupFields = gb.split(",").map((f) => ({ fieldId: f, fieldName: f, objId: String(tid), mainField: true }));
936
- dto.groupStatFields = parseStat(need("stat", args)).map((s) => ({ ...s, objId: String(tid) }));
937
- const ct = parseWhere(args.where); if (ct) dto.conditionTrees = ct;
938
- const r = await postMultipart("/api/dw/newetl/chatBi/ds/getChartDatas", { sourceType: args["source-type"] || "LOGIC", queryDto: dto });
939
- if (json) return r;
940
- printHuman("分组聚合 rows=" + (r.data || []).length, r.data || []);
941
- }
942
- async function cmdStat(args, json) {
943
- const gid = need("group", args), tid = need("table", args);
944
- const dto = await dsDto(args, tid, gid, 1, 10, "getStatData");
945
- dto.statFields = parseStat(need("stat", args)).map((s) => ({ ...s, objId: String(tid) }));
946
- const ct = parseWhere(args.where); if (ct) dto.conditionTrees = ct;
947
- const r = await postMultipart("/api/dw/newetl/chatBi/ds/getStatDatas", { sourceType: args["source-type"] || "LOGIC", queryDto: dto });
948
- if (json) return r;
949
- const data = r.data || {};
950
- if (data.fields && data.fields.length) { for (const f of data.fields) console.log(JSON.stringify(f)); }
951
- else console.log("(无统计结果)");
952
- }
953
- async function cmdCount(args, json) {
954
- const gid = need("group", args), tid = need("table", args);
955
- const dto = await dsDto(args, tid, gid, 1, 10, "getCountData");
956
- const ct = parseWhere(args.where); if (ct) dto.conditionTrees = ct;
957
- const r = await postMultipart("/api/dw/newetl/chatBi/ds/getCountData", { sourceType: args["source-type"] || "LOGIC", queryDto: dto });
958
- if (json) return r;
959
- const data = r.data || {};
960
- console.log("totalCount = " + (data.totalCount ?? "-"));
961
- }
962
-
963
- // ---------------- eb:EB 建模表单权限校验 ----------------
964
- // chatBi EB 配置映射分页查询:EB 表权限前置校验的 CLI 通道
965
- // (替代原「DSL 查 dw.chat_bi_eb_config_map + 客户端拼 (all_scop=1 OR target_id=本人)」写法)
966
- // 服务端已按登录人过滤:有权限 → total>=1 且行内回带 appId;无权限 / 表不存在 → total=0
967
- const EB_PERM_PAGE_PATH = "/api/dw/newetl/chatBi/getChatBiEbConfigMapByPage";
968
- async function cmdEbPermPage(args, json) {
969
- // ⚠️ obj-id / app-id 为 19 位大整数:必须原样字符串传递。
970
- // 经 JS Number 会舍入(840360795890851856 → 840360795890851840),实测后端接受字符串,
971
- // 但吃下舍入后的数字会返回 total=0 —— 把「有权限」静默伪装成「无权限」。
972
- const objId = String(need("obj-id", args));
973
- const formTable = String(need("form-table", args));
974
- // ⚠️ obj-id / form-table 缺失时后端不是报错,而是 HTTP 200 + 无 actionMsg + data 空,
975
- // 外观与「无权限」完全一致 → 必须用 need() 在本地拦掉(need 对空串同样报错)。
976
- const body = {
977
- objId,
978
- formTable,
979
- pageNum: parseInt(args.page || "1", 10),
980
- pageSize: parseInt(args.size || "10", 10),
981
- };
982
- if (typeof args["app-id"] === "string" && args["app-id"] !== "") body.app_id = String(args["app-id"]);
983
- const r = await postJson(EB_PERM_PAGE_PATH, body);
984
- if (json) return r;
985
- // ⚠️ 失败判定:非法入参时 actionMsg 缺失(code=undefined),业务失败时 code!==0 → 一并按失败输出
986
- const am = r.actionMsg || {};
987
- if (am.code !== 0) { console.log("code=" + am.code + " msg=" + (am.message || "")); return; }
988
- const d = r.data || {};
989
- const recs = d.records || [];
990
- const total = d.total === undefined || d.total === null ? 0 : d.total;
991
- const first = recs[0] || null;
992
- console.log("total = " + total);
993
- printHuman("", recs, ["id", "objId", "formTable", "appId", "allScop", "targetId", "enabled", "deleteType"]);
994
- console.log(total >= 1
995
- ? (first
996
- ? "结论: 有权限(appId=" + first.appId + ")"
997
- : "结论: 有权限(total>=1;当前页无记录,appId 请用 --page 1 获取)")
998
- : "结论: 无权限(total=0)");
999
- if (args.require && total < 1) throw new Error("没有查询该 EB 表的权限(total=0)");
1000
- }
1001
-
1002
- // ---------------- doc:文档查询 ----------------
1003
- function docBaseBody(pageNo, pageSize) {
1004
- return {
1005
- curMenuType: "", limitedFolderIncludeSub: "", limitedFolderIds: "", docAppConsole: "",
1006
- viewType: "DOC_PAGINATION", pageNo, pageSize, morePageId: "", dimension: "ALL", includeBelongFolders: true,
1007
- order: { property: "default", direction: "DESC" }, searchType: "all", conditions: [], customConditions: [],
1008
- userId: auth.userId || "", current: pageNo,
1009
- columnOrders: [{ orderKey: "default", orderType: "" }], getColumn: false, quickSearchType: "normal",
1010
- withFullSearch: false, fetchFullSearchParam: true,
1011
- };
1012
- }
1013
- function buildDocConditions(args) {
1014
- const conds = [];
1015
- if (args.name) conds.push({ id: "name", type: "string", value: args.name });
1016
- if (args.creator) conds.push({ id: "creator", operate: args["creator-neq"] ? "neq" : "eq", type: "array", value: args.creator });
1017
- if (args.author) conds.push({ id: "author", operate: args["author-neq"] ? "neq" : "eq", type: "array", value: args.author });
1018
- if (args["content-type"]) conds.push({ id: "contentType", operate: "eq", type: "array", value: args["content-type"] });
1019
- if (args.time) conds.push({ id: "create_time", type: "date", value: args.time });
1020
- if (args["last-time"]) conds.push({ id: "last_update_time", type: "date", value: args["last-time"] });
1021
- if (args.folder) conds.push({ id: "folder", operate: "eq", type: "folder", value: args.folder });
1022
- if (args["folder-neq"]) conds.push({ id: "folder", operate: "neq", type: "folder", value: args["folder-neq"] });
1023
- return conds;
1024
- }
1025
- async function cmdDocCount(args, json) {
1026
- const body = docBaseBody(1, parseInt(args.size || "20", 10));
1027
- body.conditions = buildDocConditions(args);
1028
- const r = await postJson("/api/doc/documents/documentListCount", body);
1029
- if (json) return r;
1030
- const data = r.data || {};
1031
- console.log("total = " + (data.total ?? "-"));
1032
- }
1033
- async function cmdDocList(args, json) {
1034
- const pageSize = parseInt(args.size || "100", 10);
1035
- const body = docBaseBody(1, pageSize);
1036
- body.conditions = buildDocConditions(args);
1037
- const r1 = await postJson("/api/doc/documents/documentListCount", body);
1038
- if (json) return r1;
1039
- const total = (r1.data || {}).total || 0;
1040
- if (!total) { console.log("total = 0(未查询到符合条件的文档)"); return; }
1041
- const totalPages = Math.ceil(total / pageSize);
1042
- const maxPages = args.all ? totalPages : Math.min(parseInt(args.page || "1", 10), totalPages);
1043
- const startPage = args.all ? 1 : parseInt(args.page || "1", 10);
1044
- const allRows = [];
1045
- for (let pno = startPage; pno <= maxPages; pno++) {
1046
- const b = docBaseBody(pno, pageSize);
1047
- b.conditions = buildDocConditions(args);
1048
- const rr = await postJson("/api/doc/documents/documentList", b);
1049
- const rows = ((rr.data || {}).data) || [];
1050
- for (const row of rows) if (!allRows.some((x) => x.id === row.id)) allRows.push(row);
1051
- if (!args.all && pno === maxPages) break;
1052
- }
1053
- console.log("== 文档列表 total=" + total + " 本批=" + allRows.length + " 行 ==");
1054
- printHuman("", allRows, ["id", "name", "docCreatorName", "createTime", "folder"]);
1055
- }
1056
-
1057
- // ---------------- noun:词库 ----------------
1058
- async function cmdNoun(args, json) {
1059
- const skill = need("skill", args);
1060
- const q = new URLSearchParams({ skillKey: skill });
1061
- if (args["source-type"]) q.append("sourceType", args["source-type"]);
1062
- if (args["source-group"]) q.append("sourceGroupId", args["source-group"]);
1063
- if (args["source-table"]) q.append("sourceTableId", args["source-table"]);
1064
- const r = await getUrl("/api/dw/newetl/chatBi/skill/getChatDocsBySkillKey?" + q.toString());
1065
- if (json) return r;
1066
- const data = r.data;
1067
- if (!data || !data.nouns) {
1068
- console.log("(该 skill 词库不存在或为空) msg=" + (r.msg || ""));
1069
- return;
1070
- }
1071
- console.log("== 名词解释 " + data.nouns.length + " 条 (skill=" + skill + ") ==");
1072
- for (const n of data.nouns) {
1073
- console.log("【" + (n.keyword || "") + "】" + (n.synonyms ? " 同义:" + n.synonyms : ""));
1074
- if (n.wordExplain) console.log(" " + String(n.wordExplain).replace(/\n/g, " "));
1075
- }
1076
- }
1077
-
1078
- // ---------------- field:关联字段翻译 ----------------
1079
- function customParamFromArgs(args) {
1080
- if (args["form-param"]) return { formParam: args["form-param"] };
1081
- if (args["eb-browser-param"]) return { ebBrowserForEditParam: readJsonArg(args["eb-browser-param"], "--eb-browser-param") };
1082
- if (args["property-type"]) return { propertyType: args["property-type"] };
1083
- return {};
1084
- }
1085
- function extractRemoteRows(r, browserType) {
1086
- const remote = r.remote || {};
1087
- const keys = Object.keys(remote);
1088
- const out = [];
1089
- for (const k of keys) {
1090
- if (Array.isArray(remote[k])) for (const it of remote[k]) out.push({ key: k, id: it.id, content: it.content ?? it.name ?? "" });
1091
- }
1092
- return out;
1093
- }
1094
- async function cmdFieldId2name(args, json) {
1095
- const module = need("module", args), type = need("type", args), idsVal = need("ids", args);
1096
- const dataIds = idsVal.split(",").map((s) => s.trim()).filter(Boolean);
1097
- const body = [{ module, browserType: type, dataIds, user: USER, customParam: customParamFromArgs(args) }];
1098
- const r = await postJson("/api/bcw/test/testRemoteBrowser", body);
1099
- if (json) return r;
1100
- const rows = extractRemoteRows(r, type);
1101
- if (!rows.length) {
1102
- const nf = (r.notFound || {});
1103
- const list = nf && nf[module + "_" + type] && nf[module + "_" + type].ids ? nf[module + "_" + type].ids : [];
1104
- console.log("(未翻译到名称) notFound ids: " + (list.join(",") || "全部"));
1105
- return;
1106
- }
1107
- printHuman("id→name (" + rows.length + "/" + dataIds.length + ")", rows, ["id", "content"]);
1108
- }
1109
- async function cmdFieldName2id(args, json) {
1110
- const module = need("module", args), type = need("type", args), name = need("name", args);
1111
- const body = { newly: 1, searchValue: name, ...customParamFromArgs(args) };
1112
- const url = "/api/" + module + "/common/browser/complete/" + type;
1113
- const r = await postJson(url, body);
1114
- if (json) return r;
1115
- const rows = ((r.data || {}).data) || [];
1116
- if (!rows.length) { console.log("(未匹配到名称对应 ID)"); return; }
1117
- printHuman("name→id 候选 " + rows.length + " 条", rows.map((x) => ({ id: x.id, name: x.name ?? x.title ?? "" })), ["id", "name"]);
1118
- }
1119
-
1120
- // ---------------- conf:环境参数配置中心(chatBi config map) ----------------
1121
- // 覆盖 config-resolve 原 resolve_config.py 的 URL 直连调用:get/batch/batch-file/group/group-file/resolve/resolve-file
1122
- // 与管理 add/add-file/update/delete。认证复用进程内登录缓存(完整 Cookie + eteamsid,同 query/ds/doc),
1123
- // 无内置令牌;baseUrl 取登录缓存(与 executeQueryDsl 同域)。confType 默认由 key 前缀推导
1124
- // (CONN_→conn / DS_→ds / WF_→wf / API_→api),可用 --type 显式指定任意大类(如 data-source-query / system-config)。
1125
- //
1126
- // v0.1.9 confGroup(配置分组,可选):
1127
- // 后端在 confType=api 时支持按分组二次分层(如 api/workflow、api/hr),定位维度变为
1128
- // 「confType + confGroup + confKey」。本 CLI 统一用 --group 传分组:
1129
- // · confType 为 api(或显式传了 --group)时,新增/更新/删除/单查 才下发 confGroup 字段,
1130
- // 其他 confType 一律不带该字段,避免污染既有(无分组列)的配置类型。
1131
- // · conf group <confType> <confGroup> 走 batchGetChatBiConfigMapByTypeAndGroup 按类型+分组批量取回。
1132
- const CONF_PREFIX_TYPE = [["CONN_", "conn"], ["DS_", "ds"], ["WF_", "wf"], ["API_", "api"]];
1133
- const CONF_GET_PATH = "/api/dw/newetl/chatBi/getChatBiConfigMap";
1134
- const CONF_BATCH_PATH = "/api/dw/newetl/chatBi/batchGetChatBiConfigMap";
1135
- const CONF_BATCH_GROUP_PATH = "/api/dw/newetl/chatBi/batchGetChatBiConfigMapByTypeAndGroup";
1136
- const CONF_ADD_PATH = "/api/dw/newetl/chatBi/batchAddChatBiConfigMap";
1137
- const CONF_UPDATE_PATH = "/api/dw/newetl/chatBi/updateChatBiConfigMap";
1138
- const CONF_DELETE_PATH = "/api/dw/newetl/chatBi/deleteChatBiConfigMap";
1139
- const CONF_TOKEN_RE = /#\{([A-Z][A-Z0-9_]{2,})\}/g;
1140
- function confKeyType(key, explicitType) {
1141
- if (explicitType) return explicitType;
1142
- for (const [p, t] of CONF_PREFIX_TYPE) if (key.startsWith(p)) return t;
1143
- throw new Error("无法由 confKey 推导 confType(需 CONN_/DS_/WF_/API_ 前缀或显式 --type): " + key);
1144
- }
1145
- // confGroup 仅在 confType=api 或调用方显式指定时下发(见上方说明)
1146
- function confUseGroup(ctype, group) {
1147
- return ctype === "api" || (group !== undefined && group !== null && group !== "");
1148
- }
1149
- function confPutGroup(body, ctype, group) {
1150
- if (confUseGroup(ctype, group)) body.confGroup = group === undefined || group === null ? "" : String(group);
1151
- return body;
1152
- }
1153
- function confGroupSuffix(ctype, group) { // GET/DELETE 用的 query 后缀
1154
- return confUseGroup(ctype, group) ? "&confGroup=" + encodeURIComponent(group === undefined || group === null ? "" : String(group)) : "";
1155
- }
1156
- function confItemId(it) { // 同 confKey 可分布在多个 confGroup,带分组时用「组|键」做映射键,避免互相覆盖
1157
- const g = it && it.confGroup;
1158
- return g ? g + "|" + (it.confKey || "") : ((it && it.confKey) || "");
1159
- }
1160
- function confLookup(map, it) { // 兼容后端回包是否带 confGroup:先按「组|键」取,再退化到 confKey
1161
- return map[confItemId(it)] || map[it.confKey];
1162
- }
1163
- function confFail(j, url) {
1164
- const m = (j && (j.msg || (j.actionMsg && j.actionMsg.message))) || "";
1165
- throw new Error("配置服务请求失败 [" + url + "]" + (m ? ": " + m : ""));
1166
- }
1167
- async function confRequest(method, url, bodyObj) {
1168
- const resp = await fetch(BASE + url, {
1169
- method,
1170
- headers: {
1171
- "accept": "application/json, text/plain, */*",
1172
- "langType": "zh_CN",
1173
- "cookie": auth.cookie,
1174
- "eteamsid": auth.eteamsid,
1175
- "ebbusinessid": EB_BIZ_ID,
1176
- "user-agent": UA,
1177
- ...(bodyObj !== undefined ? { "Content-Type": "application/json;charset=UTF-8" } : {}),
1178
- },
1179
- ...(bodyObj !== undefined ? { body: JSON.stringify(bodyObj) } : {}),
1180
- });
1181
- const text = await resp.text();
1182
- let j; try { j = parseJsonSafe(text); } catch { throw new Error("HTTP " + resp.status + " 非JSON响应: " + text.slice(0, 300)); }
1183
- checkAuthFail(resp, text, j);
1184
- return j;
1185
- }
1186
- async function confGetData(ctype, key, silent, group) {
1187
- const url = CONF_GET_PATH + "?confType=" + encodeURIComponent(ctype) + "&confKey=" + encodeURIComponent(key) + confGroupSuffix(ctype, group);
1188
- let r;
1189
- try { r = await confRequest("GET", url); }
1190
- catch (e) { if (silent) return null; throw e; }
1191
- const data = (r && r.data) || null;
1192
- if (!silent && (!data || data.confValue === undefined || data.confValue === null || data.confValue === "")) {
1193
- throw new Error("配置不存在或为空: confType=" + ctype + (group ? " confGroup=" + group : "") + " confKey=" + key);
1194
- }
1195
- return data;
1196
- }
1197
- function confNormalizeBatch(payload) {
1198
- const data = (payload && payload.data) || null;
1199
- const out = {};
1200
- const put = (row, fallbackKey) => {
1201
- if (!row || typeof row !== "object") return;
1202
- const k = row.confKey || row.conf_key || fallbackKey;
1203
- if (!k) return;
1204
- if (row.confGroup === undefined) { const g = row.conf_group; if (g !== undefined && g !== null && g !== "") row.confGroup = g; }
1205
- out[confItemId({ confGroup: row.confGroup, confKey: k })] = row;
1206
- };
1207
- if (Array.isArray(data)) for (const row of data) put(row);
1208
- else if (data && typeof data === "object") for (const [k, v] of Object.entries(data)) put(v, k);
1209
- return out;
1210
- }
1211
- // 按类型+分组批量取回(batchGetChatBiConfigMapByTypeAndGroup),payload = [{confType, confGroup}...]
1212
- async function confBatchByTypeAndGroup(pairs) {
1213
- const payload = pairs.map((p) => ({ confType: p.confType, confGroup: p.confGroup === undefined || p.confGroup === null ? "" : String(p.confGroup) }));
1214
- const r = await confRequest("POST", CONF_BATCH_GROUP_PATH, payload);
1215
- return confNormalizeBatch(r);
1216
- }
1217
- async function cmdConfGet(args, json, keys) {
1218
- const key = keys[0];
1219
- if (!key) throw new Error("conf get 缺少 confKey(用法: conf get <key> [--type t] [--group g])");
1220
- const ctype = confKeyType(key, args.type);
1221
- let data;
1222
- try {
1223
- data = await confGetData(ctype, key, false, args.group);
1224
- } catch (e) {
1225
- if (ctype === "api" && !args.group) {
1226
- throw new Error(e.message + "(confType=api 建议用 --group 指定分组,如 --group workflow;或用 conf group api <分组> 查看该分组下全部配置)");
1227
- }
1228
- throw e;
1229
- }
1230
- if (json) return data;
1231
- console.log(JSON.stringify(data, null, 1));
1232
- }
1233
- async function cmdConfBatch(args, json, keys) {
1234
- if (!keys.length) throw new Error("conf batch 缺少 confKey 列表(用法: conf batch <key1> [key2 ...] [--type t] [--group g])");
1235
- const items = keys.map((k) => { const t = confKeyType(k, args.type); return confPutGroup({ confType: t, confKey: k }, t, args.group); });
1236
- let map = {};
1237
- try {
1238
- const r = await confRequest("POST", CONF_BATCH_PATH, items);
1239
- map = confNormalizeBatch(r);
1240
- for (const it of items) {
1241
- const c = confLookup(map, it);
1242
- if (!c || c.confValue === undefined || c.confValue === null || c.confValue === "") {
1243
- throw new Error("批量响应缺失 confKey=" + it.confKey);
1244
- }
1245
- }
1246
- } catch (e) {
1247
- if (String(e.message).indexOf("批量响应缺失") === 0) throw e;
1248
- console.error("[config-resolve] 批量接口不可用,降级逐条单查: " + e.message);
1249
- map = {};
1250
- for (const it of items) map[confItemId(it)] = await confGetData(it.confType, it.confKey, false, it.confGroup);
1251
- }
1252
- if (json) return map;
1253
- printHuman("配置 " + items.length + " 条", keys.map((k) => {
1254
- const it = items.find((x) => x.confKey === k);
1255
- const c = confLookup(map, it) || {};
1256
- return { key: k, confType: it.confType, confGroup: it.confGroup ?? "", confValue: c.confValue ?? "", confName: c.confName ?? "" };
1257
- }), ["key", "confType", "confGroup", "confValue", "confName"]);
1258
- }
1259
- async function cmdConfBatchFile(args, json, keys) {
1260
- const val = flagOrPos(args, "batch-file", keys);
1261
- if (val === undefined) throw new Error("conf batch-file 缺少参数(用法: conf batch-file @items.json)");
1262
- const items = readJsonArg(String(val), "--batch-file");
1263
- const list = Array.isArray(items) ? items : [items];
1264
- const map = {};
1265
- const meta = {};
1266
- for (const it of list) {
1267
- if (!it.confKey || !it.confType) continue;
1268
- const id = confItemId(it);
1269
- map[id] = await confGetData(it.confType, it.confKey, false, it.confGroup);
1270
- meta[id] = it;
1271
- }
1272
- if (json) return map;
1273
- printHuman("配置 " + Object.keys(map).length + " 条", Object.keys(map).map((id) => ({ key: meta[id].confKey, confGroup: meta[id].confGroup ?? "", confValue: (map[id] || {}).confValue ?? "", confName: (map[id] || {}).confName ?? "" })), ["key", "confGroup", "confValue", "confName"]);
1274
- }
1275
- // 按类型+分组批量查(batchGetChatBiConfigMapByTypeAndGroup):pairs = [{confType, confGroup}...]
1276
- async function cmdConfGroup(args, json, keys) {
1277
- const ctype = keys[0] || args.type;
1278
- const group = keys[1] !== undefined ? keys[1] : args.group;
1279
- if (!ctype) throw new Error("conf group 缺少 confType(用法: conf group <confType> <confGroup> | --type api --group workflow)");
1280
- if (group === undefined) throw new Error("conf group 缺少 confGroup(用法: conf group <confType> <confGroup>)");
1281
- const map = await confBatchByTypeAndGroup([{ confType: ctype, confGroup: group }]);
1282
- const rows = Object.values(map);
1283
- const onlyKey = args.key;
1284
- const filtered = onlyKey ? rows.filter((r) => (r.confKey || r.conf_key) === onlyKey) : rows;
1285
- if (json) return onlyKey ? (filtered[0] ?? null) : filtered;
1286
- if (onlyKey && !filtered.length) throw new Error("配置不存在: confType=" + ctype + " confGroup=" + group + " confKey=" + onlyKey);
1287
- printHuman("confType=" + ctype + " confGroup=" + group + " 配置 " + filtered.length + " 条",
1288
- filtered.map((r) => ({ confKey: r.confKey || r.conf_key || "", confValue: r.confValue ?? "", confName: r.confName ?? "", confAttrs: r.confAttrs ?? "" })),
1289
- ["confKey", "confValue", "confName", "confAttrs"]);
1290
- return filtered;
1291
- }
1292
- async function cmdConfGroupFile(args, json, keys) {
1293
- const val = flagOrPos(args, "group-file", keys);
1294
- if (val === undefined) throw new Error("conf group-file 缺少参数(用法: conf group-file @items.json,形如 [{\"confType\":\"api\",\"confGroup\":\"workflow\"}])");
1295
- const pairs = readJsonArg(String(val), "--group-file");
1296
- const list = (Array.isArray(pairs) ? pairs : [pairs]).filter((p) => p && p.confType);
1297
- if (!list.length) throw new Error("conf group-file 内容为空或缺少 confType");
1298
- const map = await confBatchByTypeAndGroup(list);
1299
- const rows = Object.values(map);
1300
- if (json) return map;
1301
- printHuman("批量分组配置 " + rows.length + " 条",
1302
- rows.map((r) => ({ confType: r.confType ?? "", confGroup: r.confGroup ?? "", confKey: r.confKey || r.conf_key || "", confValue: r.confValue ?? "", confName: r.confName ?? "" })),
1303
- ["confType", "confGroup", "confKey", "confValue", "confName"]);
1304
- return map;
1305
- }
1306
- async function confResolveAll(keys, group) {
1307
- // 多占位符:先批量预取一轮(失败降级逐条单查),返回 {key: data}
1308
- const items = keys.map((k) => confPutGroup({ confType: confKeyType(k, ""), confKey: k }, confKeyType(k, ""), group));
1309
- const map = {};
1310
- try {
1311
- const r = await confRequest("POST", CONF_BATCH_PATH, items);
1312
- const got = confNormalizeBatch(r);
1313
- for (const it of items) {
1314
- const c = confLookup(got, it);
1315
- if (!c || c.confValue === undefined || c.confValue === null || c.confValue === "") throw new Error("缺失 " + it.confKey);
1316
- map[it.confKey] = c;
1317
- }
1318
- } catch {
1319
- for (const it of items) map[it.confKey] = await confGetData(it.confType, it.confKey, false, it.confGroup);
1320
- }
1321
- return map;
1322
- }
1323
- async function confResolveText(text, json, group) {
1324
- const found = [...new Set([...text.matchAll(CONF_TOKEN_RE)].map((m) => m[1]))];
1325
- const map = found.length ? await confResolveAll(found, group) : {};
1326
- const out = text.replace(CONF_TOKEN_RE, (m0, k) => {
1327
- const cfg = map[k];
1328
- if (!cfg) throw new Error("配置不存在或为空: " + k);
1329
- return cfg.confValue ?? "";
1330
- });
1331
- if (json) return { input: text, output: out, resolved: Object.fromEntries(Object.entries(map).map(([k, v]) => [k, v.confValue ?? ""])) };
1332
- return out;
1333
- }
1334
- async function cmdConfResolve(args, json, keys) {
1335
- let text = keys[0];
1336
- if (!text) throw new Error("conf resolve 缺少文本(用法: conf resolve '<含#{KEY}文本>' | @file.txt [--group g])");
1337
- if (text.startsWith("@")) text = fs.readFileSync(text.slice(1), "utf-8");
1338
- const out = await confResolveText(text, json, args.group);
1339
- if (json) { console.log(JSON.stringify(out, null, 1)); return out; }
1340
- process.stdout.write(out + "\n");
1341
- }
1342
- async function cmdConfResolveFile(args, json, keys) {
1343
- const p = flagOrPos(args, "resolve-file", keys);
1344
- if (!p) throw new Error("conf resolve-file 缺少文件路径(用法: conf resolve-file <文件路径> [--group g])");
1345
- if (!fs.existsSync(p)) throw new Error("文件不存在: " + p);
1346
- const text = fs.readFileSync(p, "utf-8");
1347
- const out = await confResolveText(text, json, args.group);
1348
- if (json) { console.log(JSON.stringify(out, null, 1)); return out; }
1349
- process.stdout.write(out + "\n");
1350
- }
1351
- async function cmdConfAdd(args, json, keys) {
1352
- const key = keys[0];
1353
- if (!key || args.value === undefined) throw new Error("conf add 缺少 confKey/confValue(用法: conf add <key> --value <真实值> [--name] [--attrs] [--type] [--group] [--force])");
1354
- const ctype = confKeyType(key, args.type);
1355
- if (!args.force && await confGetData(ctype, key, true, args.group)) {
1356
- console.error("[config-resolve] 配置已存在,跳过新增(如需修改请用 conf update;确需重建可先 conf delete): " + key);
1357
- return null;
1358
- }
1359
- const item = confPutGroup({ confType: ctype, confKey: key, confValue: String(args.value), confAttrs: args.attrs || "", confName: args.name || "" }, ctype, args.group);
1360
- const r = await confRequest("POST", CONF_ADD_PATH, [item]);
1361
- if (json) return r;
1362
- console.log("新增成功: " + (item.confGroup ? item.confGroup + "/" : "") + key + " = " + item.confValue);
1363
- }
1364
- async function cmdConfAddFile(args, json, keys) {
1365
- const val = flagOrPos(args, "add-file", keys);
1366
- if (val === undefined) throw new Error("conf add-file 缺少参数(用法: conf add-file @items.json,元素形如 {confType,confGroup,confKey,confValue,confName,confAttrs})");
1367
- const items = readJsonArg(String(val), "--add-file");
1368
- const list = Array.isArray(items) ? items : [items];
1369
- const toAdd = [];
1370
- for (const it of list) {
1371
- if (!it.confKey || it.confValue === undefined) continue;
1372
- const ctype = it.confType || confKeyType(it.confKey, args.type);
1373
- const group = it.confGroup !== undefined ? it.confGroup : args.group;
1374
- const dup = await confGetData(ctype, it.confKey, true, group);
1375
- if (dup) { console.error("[config-resolve] 已存在跳过: " + it.confKey); continue; }
1376
- toAdd.push(confPutGroup({ confType: ctype, confKey: it.confKey, confValue: String(it.confValue), confAttrs: it.confAttrs || "", confName: it.confName || "" }, ctype, group));
1377
- }
1378
- if (!toAdd.length) { console.log("全部已存在,无新增"); return null; }
1379
- const r = await confRequest("POST", CONF_ADD_PATH, toAdd);
1380
- if (json) return r;
1381
- console.log("批量新增 " + toAdd.length + " 条成功");
1382
- }
1383
- async function cmdConfUpdate(args, json, keys) {
1384
- const key = keys[0];
1385
- if (!key) throw new Error("conf update 缺少 confKey(用法: conf update <key> [--value v] [--name n] [--attrs a] [--type t] [--group g])");
1386
- if (args.value === undefined && args.name === undefined && args.attrs === undefined) {
1387
- throw new Error("conf update 至少需提供 --value / --name / --attrs 之一");
1388
- }
1389
- const ctype = confKeyType(key, args.type);
1390
- const body = confPutGroup({ confType: ctype, confKey: key }, ctype, args.group);
1391
- if (args.value !== undefined) body.confValue = String(args.value);
1392
- if (args.name !== undefined) body.confName = args.name;
1393
- if (args.attrs !== undefined) body.confAttrs = args.attrs;
1394
- const r = await confRequest("POST", CONF_UPDATE_PATH, body);
1395
- if (json) return r;
1396
- console.log("更新成功: " + (body.confGroup ? body.confGroup + "/" : "") + key);
1397
- }
1398
- async function cmdConfDelete(args, json, keys) {
1399
- const key = keys[0];
1400
- if (!key) throw new Error("conf delete 缺少 confKey(用法: conf delete <key> [--type t] [--group g])");
1401
- const ctype = confKeyType(key, args.type);
1402
- const url = CONF_DELETE_PATH + "?confType=" + encodeURIComponent(ctype) + "&confKey=" + encodeURIComponent(key) + confGroupSuffix(ctype, args.group);
1403
- const r = await confRequest("DELETE", url);
1404
- if (json) return r;
1405
- console.log("删除成功: " + (confUseGroup(ctype, args.group) && args.group ? args.group + "/" : "") + key);
1406
- }
1407
- async function cmdConf(sub, args, json, pos) {
1408
- const keys = pos.slice(2);
1409
- switch (sub) {
1410
- case "get": return cmdConfGet(args, json, keys);
1411
- case "batch": return cmdConfBatch(args, json, keys);
1412
- case "batch-file": return cmdConfBatchFile(args, json, keys);
1413
- case "group": return cmdConfGroup(args, json, keys);
1414
- case "group-file": return cmdConfGroupFile(args, json, keys);
1415
- case "resolve": return cmdConfResolve(args, json, keys);
1416
- case "resolve-file": return cmdConfResolveFile(args, json, keys);
1417
- case "add": return cmdConfAdd(args, json, keys);
1418
- case "add-file": return cmdConfAddFile(args, json, keys);
1419
- case "update": return cmdConfUpdate(args, json, keys);
1420
- case "delete": return cmdConfDelete(args, json, keys);
1421
- default: usage(); process.exit(1);
1422
- }
1423
- }
1424
-
1425
- // ---------------- attend:考勤 REST 接口 ----------------
1426
- // 2026-09-07:封装 /api/attend/web/attendInfoV2/getAttendInfoStatis(假期余额统计,hr-attendance 场景六病假/事假)。
1427
- // 请求参数 {beginDate, endDate, userId};返回 data.vacationBalanceList[]:{id,title,data:[{name,value,key}]},
1428
- // key 含 lastRemaining(历年可请)/remaining(今年可请)/total(总计可请)。
1429
- function defaultMonthRange() {
1430
- const now = new Date();
1431
- const y = now.getFullYear();
1432
- const m = now.getMonth() + 1;
1433
- const lastDay = new Date(y, m, 0).getDate();
1434
- const p = (n) => String(n).padStart(2, "0");
1435
- return { begin: y + "-" + p(m) + "-01", end: y + "-" + p(m) + "-" + p(lastDay) };
1436
- }
1437
- async function cmdAttendStatis(args, json) {
1438
- const user = args.user || auth.userId || "";
1439
- if (!user) throw new Error("缺少查询人员:--user <人员ID>(查本人可不传,默认当前登录用户)");
1440
- const def = defaultMonthRange();
1441
- const body = { beginDate: args.begin || def.begin, endDate: args.end || def.end, userId: user };
1442
- const r = await postJson("/api/attend/web/attendInfoV2/getAttendInfoStatis", body);
1443
- if (json) return r;
1444
- if (String(r.code) !== "200") {
1445
- console.log("code=" + r.code + " msg=" + (r.msg || ""));
1446
- if (/没有权限|无权限|权限不足|未授权|无权访问|无查询权限/i.test(String(r.msg || ""))) {
1447
- throw new Error("当前用户对该人员考勤数据无查询权限,请检查 ETEAMSID 或联系管理员开通");
1448
- }
1449
- return;
1450
- }
1451
- const d = r.data || {};
1452
- const list = Array.isArray(d.vacationBalanceList) ? d.vacationBalanceList : [];
1453
- if (!list.length) { console.log("(空:该人员无假期余额数据) userId=" + user + " " + body.beginDate + "~" + body.endDate); return; }
1454
- console.log("== 考勤假期余额 userId=" + user + "(" + body.beginDate + " ~ " + body.endDate + ")==");
1455
- for (const item of list) {
1456
- console.log((item.title || item.id || "?") + (item.id ? " [" + item.id + "]" : ""));
1457
- if (Array.isArray(item.data) && item.data.length) {
1458
- console.log(" " + item.data.map((x) => (x.name || x.key) + "=" + (x.value ?? "")).join(" "));
1459
- }
1460
- }
1461
- }
1462
-
1463
- // ---------------- exp:专家包版本管理(expert-repository) ----------------
1464
- async function expGet(url) {
1465
- // 返回 {raw, j}:raw 用于正则提取大整数 id(commitId 19 位超 JS Number 精度,JSON.parse 会丢精度!)
1466
- const resp = await fetch(BASE + url, {
1467
- method: "GET",
1468
- headers: { "accept": "application/json, text/plain, */*", "langType": "zh_CN", "cookie": auth.cookie, "eteamsid": auth.eteamsid, "ebbusinessid": EB_BIZ_ID, "user-agent": UA },
1469
- });
1470
- const raw = await resp.text();
1471
- let j; try { j = parseJsonSafe(raw); } catch { throw new Error("HTTP " + resp.status + " 非JSON响应: " + raw.slice(0, 300)); }
1472
- if (resp.status === 401 || (j && String(j.code) === "-1" && /token|登录|失效/i.test(String(j.msg || "")))) throw new Error("登录态失效,请重新 e10-login oidc 登录");
1473
- return { raw, j };
1474
- }
1475
- function rawNum(raw, key) {
1476
- // 从原始 JSON 文本提取整数字段为精确字符串(避免 JSON.parse 大数丢精度,如 commitId)
1477
- const m = raw.match(new RegExp('"' + key + '":\\s*(\\d+)'));
1478
- return m ? m[1] : "";
1479
- }
1480
- function rawNumSeq(raw, key) {
1481
- // 提取某 key 全部整数序列(按出现顺序),如提交历史里的 commitId
1482
- const re = new RegExp('"' + key + '":\\s*(\\d+)', "g");
1483
- const out = []; let m;
1484
- while ((m = re.exec(raw)) !== null) out.push(m[1]);
1485
- return out;
1486
- }
1487
- const EXP_ALLOWED_DIRS = [".codebuddy-plugin", "agents", "avatars", "skills"];
1488
- const EXP_SKIP_DIRS = [".git", ".workbuddy", "node_modules", "__pycache__"];
1489
- const EXP_SKIP_FILES = [".sync-state.json"]; // 本地同步状态元文件(含基线 sha),永不随包提交
1490
- function sha256(buf) { return crypto.createHash("sha256").update(buf).digest("hex"); }
1491
- function walkDir(dir, root) {
1492
- const out = [];
1493
- for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
1494
- const abs = path.join(dir, entry.name);
1495
- const rel = path.relative(root, abs).split(path.sep).join("/");
1496
- if (entry.isDirectory()) {
1497
- if (EXP_SKIP_DIRS.includes(entry.name)) continue;
1498
- const top = rel.split("/")[0];
1499
- if (rel.includes("/") && !EXP_ALLOWED_DIRS.includes(top)) continue; // 仅允许根文件+四大目录
1500
- walkDir(abs, root).forEach((x) => out.push(x));
1501
- } else if (entry.isFile()) {
1502
- if (EXP_SKIP_FILES.includes(entry.name)) continue; // .sync-state.json 不入包
1503
- const top = rel.split("/")[0];
1504
- if (!rel.includes("/") || EXP_ALLOWED_DIRS.includes(top)) out.push({ rel, abs });
1505
- }
1506
- }
1507
- return out;
1508
- }
1509
- function collectPackage(dir) {
1510
- if (!fs.existsSync(dir)) throw new Error("本地专家包目录不存在: " + dir);
1511
- return walkDir(dir, dir).map(({ rel, abs }) => {
1512
- const buf = fs.readFileSync(abs);
1513
- if (buf.length > 10 * 1024 * 1024) throw new Error("文件超 10MB 上限: " + rel);
1514
- return { relativePath: rel, contentBase64: buf.toString("base64"), sha256: sha256(buf), abs };
1515
- });
1516
- }
1517
- function localShaMap(dir) {
1518
- const map = {};
1519
- for (const f of walkDir(dir, dir)) map[f.rel] = sha256(fs.readFileSync(f.abs));
1520
- return map;
1521
- }
1522
- function expOk(r) {
1523
- if (!r) return false;
1524
- if (r.actionMsg) return String(r.actionMsg.code) === "0";
1525
- const code = r.code;
1526
- if (code === undefined) return !r.fail;
1527
- return String(code) === "0" || String(code) === "200";
1528
- }
1529
- function expFailMsg(r) {
1530
- if (r && r.actionMsg && String(r.actionMsg.code) !== "0") return r.actionMsg.message;
1531
- if (r && r.msg) return r.msg;
1532
- return "请求失败";
1533
- }
1534
- function printExpData(r, title) {
1535
- if (!expOk(r)) throw new Error(expFailMsg(r));
1536
- if (title) console.log("== " + title + " ==");
1537
- console.log(JSON.stringify(r.data ?? {}, null, 1));
1538
- }
1539
- async function rawGet(url) {
1540
- const resp = await fetch(BASE + url, {
1541
- method: "GET",
1542
- headers: { "accept": "*/*", "langType": "zh_CN", "cookie": auth.cookie, "eteamsid": auth.eteamsid, "ebbusinessid": EB_BIZ_ID, "user-agent": UA },
1543
- });
1544
- if (resp.status === 401) throw new Error("登录态失效,请重新 e10-login oidc 登录");
1545
- return Buffer.from(await resp.arrayBuffer());
1546
- }
1547
- async function expStatus(args, json) {
1548
- const expert = need("expert", args);
1549
- const { raw, j: r } = await expGet("/api/dw/expert-repository/v1/repositories/status?expertCode=" + encodeURIComponent(expert) + "&branchName=" + (args.branch || "main"));
1550
- if (json) return r;
1551
- if (!expOk(r)) throw new Error(expFailMsg(r));
1552
- const d = r.data || {};
1553
- console.log("== 仓库状态 " + expert + " ==");
1554
- console.log("expertCode = " + (d.expertCode ?? ""));
1555
- console.log("displayName = " + (d.displayName ?? ""));
1556
- console.log("branchName = " + (d.branchName ?? ""));
1557
- console.log("headCommitId = " + (rawNum(raw, "headCommitId") || d.headCommitId));
1558
- console.log("headRevision = " + (d.headRevision ?? ""));
1559
- for (const k of ["headCommitHash", "headCommitMessage", "headCommitTime", "headAuthor", "protected"]) if (d[k] !== undefined) console.log(k + " = " + d[k]);
1560
- if (Array.isArray(d.locks) && d.locks.length) { console.log("有效锁:"); for (const l of d.locks) console.log(" " + JSON.stringify(l)); }
1561
- }
1562
- async function expManifest(args, json) {
1563
- const expert = need("expert", args);
1564
- let url = "/api/dw/expert-repository/v1/repositories/manifest?expertCode=" + encodeURIComponent(expert) + "&branchName=" + (args.branch || "main");
1565
- if (args.commit) url += "&commitId=" + encodeURIComponent(args.commit);
1566
- const r = await getUrl(url);
1567
- if (json) return r;
1568
- if (!expOk(r)) throw new Error(expFailMsg(r));
1569
- const items = r.data || [];
1570
- console.log("== 文件清单 " + items.length + " 项 ==");
1571
- printHuman("", items, ["relativePath", "sha256", "size", "action"]);
1572
- }
1573
- async function expCommits(args, json) {
1574
- const expert = need("expert", args);
1575
- const { raw, j: r } = await expGet("/api/dw/expert-repository/v1/repositories/commits?expertCode=" + encodeURIComponent(expert) + "&branchName=" + (args.branch || "main"));
1576
- if (json) return r;
1577
- if (!expOk(r)) throw new Error(expFailMsg(r));
1578
- const rows = (r.data || []).map((x, i) => ({ ...x, commitId: (rawNumSeq(raw, "commitId")[i] || x.commitId) }));
1579
- console.log("== 提交历史 " + rows.length + " 条(commitId 为精确值,可复制用于 file/release/rollback)==");
1580
- printHuman("", rows, ["commitId", "submitMode", "author", "createTime", "message"]);
1581
- }
1582
- async function expFile(args, json) {
1583
- const expert = need("expert", args), commit = need("commit", args), rp = need("path", args);
1584
- const buf = await rawGet("/api/dw/expert-repository/v1/repositories/file?expertCode=" + encodeURIComponent(expert) + "&commitId=" + encodeURIComponent(commit) + "&relativePath=" + encodeURIComponent(rp));
1585
- if (args.out) { fs.writeFileSync(args.out, buf); console.log("已下载 " + buf.length + " 字节 -> " + args.out); return; }
1586
- if (json) { console.log(JSON.stringify({ relativePath: rp, size: buf.length, base64: buf.toString("base64") })); return; }
1587
- process.stdout.write(buf.toString("utf-8"));
1588
- }
1589
- // ---- exp 智能拉取:基线状态文件(三方差异保护,2026-09-04 实现)----
1590
- // .sync-state.json 记录上次成功同步的 commitId/revision 与每文件 sha256(基线)。
1591
- // pull 逐文件三方对比:local(当前本地) vs base(上次同步基线) vs remote(远端 manifest),
1592
- // 决策:仅远端改→覆盖;仅本地改→保留本地;双方都改→备份本地副本后覆盖(备份目录保留本地版);
1593
- // 无基线文件/基线无记录→保守备份后覆盖。拉取成功后把 .sync-state.json 更新为新 head。
1594
- function syncStatePath(dir) { return path.join(dir, ".sync-state.json"); }
1595
- function readSyncState(dir) {
1596
- const p = syncStatePath(dir);
1597
- if (!fs.existsSync(p)) return null;
1598
- try {
1599
- const j = JSON.parse(fs.readFileSync(p, "utf-8"));
1600
- const files = {};
1601
- for (const [rel, v] of Object.entries(j.files || {})) files[rel] = (typeof v === "string") ? v : (v.sha256 || "");
1602
- return { commitId: String(j.commitId || ""), headRevision: j.headRevision, files };
1603
- } catch { return null; }
1604
- }
1605
- function writeSyncState(dir, commitId, headRevision, fileShaMap) {
1606
- const files = {};
1607
- for (const [rel, sha] of Object.entries(fileShaMap)) files[rel] = { sha256: sha };
1608
- const obj = { commitId: String(commitId), headRevision, files, updateTime: new Date().toISOString() };
1609
- fs.writeFileSync(syncStatePath(dir), JSON.stringify(obj, null, 1), "utf-8");
1610
- }
1611
- async function expPull(args, json) {
1612
- const expert = need("expert", args), dir = need("dir", args);
1613
- const dry = !!args["dry-run"];
1614
- const force = !!args["force"]; // --force = 一律以远端覆盖(放弃本地修改)
1615
- const st = await expGet("/api/dw/expert-repository/v1/repositories/status?expertCode=" + encodeURIComponent(expert) + "&branchName=" + (args.branch || "main"));
1616
- if (!expOk(st.j)) throw new Error(expFailMsg(st.j));
1617
- const headId = rawNum(st.raw, "headCommitId") || String((st.j.data || {}).headCommitId);
1618
- const headRev = (st.j.data || {}).headRevision;
1619
- const mf = await expGet("/api/dw/expert-repository/v1/repositories/manifest?expertCode=" + encodeURIComponent(expert) + "&branchName=" + (args.branch || "main"));
1620
- if (!expOk(mf.j)) throw new Error(expFailMsg(mf.j));
1621
- const items = (mf.j.data || []).filter((f) => f.relativePath && f.relativePath !== "DELETE");
1622
- const local = fs.existsSync(dir) ? localShaMap(dir) : {};
1623
- const base = readSyncState(dir); // 基线:上次同步后的文件 sha(无则 null)
1624
- const baseCommit = (base && base.commitId) || "";
1625
- // 基线有效判定:上次同步 commitId 须在提交历史上(取不到历史就保守按无基线处理)
1626
- let backupRoot = "";
1627
- let downloaded = 0, unchanged = 0, preserved = 0, backed = 0, missing = 0;
1628
- const plan = []; // {rel, action, reason}
1629
- const mkBackupRoot = () => {
1630
- if (backupRoot) return backupRoot;
1631
- const absDir = path.resolve(dir);
1632
- const ts = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
1633
- backupRoot = path.join(path.dirname(absDir), path.basename(absDir) + ".pull-backup-" + ts);
1634
- fs.mkdirSync(backupRoot, { recursive: true });
1635
- return backupRoot;
1636
- };
1637
- for (const it of items) {
1638
- const lp = path.join(dir, it.relativePath.split("/").join(path.sep));
1639
- const ls = local[it.relativePath];
1640
- const baseSha = (base && base.files) ? base.files[it.relativePath] : undefined;
1641
- if (ls === it.sha256) { unchanged++; continue; }
1642
- if (ls === undefined) { // 本地缺失 → 直接下载(新增/恢复)
1643
- missing++; plan.push({ rel: it.relativePath, action: dry ? "待下载" : "已下载", reason: "本地缺失" });
1644
- } else if (!force && baseSha !== undefined && ls === baseSha && baseSha !== it.sha256) {
1645
- // 仅远端改(本地未动,与基线一致)→ 覆盖
1646
- plan.push({ rel: it.relativePath, action: dry ? "待下载" : "已下载", reason: "远端更新(本地未动)" });
1647
- } else if (!force && baseSha !== undefined && baseSha === it.sha256) {
1648
- // 仅本地改(远端=基线,本地≠基线)→ 保留本地,不覆盖
1649
- preserved++; plan.push({ rel: it.relativePath, action: "保留本地", reason: "本地修改(远端未动)" });
1650
- continue;
1651
- } else { // 双方都改 / 无基线可判 / --force → 先备份本地副本再覆盖
1652
- backed++;
1653
- if (!dry && !force) {
1654
- const bdir = path.join(mkBackupRoot(), it.relativePath.split("/").join(path.sep));
1655
- fs.mkdirSync(path.dirname(bdir), { recursive: true });
1656
- fs.copyFileSync(lp, bdir);
1657
- }
1658
- plan.push({ rel: it.relativePath, action: dry ? "待下载(将备份本地)" : (force ? "已下载(force覆盖)" : "已下载(本地已备份)"), reason: (force ? "force 覆盖" : (baseSha === undefined ? "无基线记录" : "双方都改")) });
1659
- }
1660
- if (dry) continue;
1661
- fs.mkdirSync(path.dirname(lp), { recursive: true });
1662
- const buf = await rawGet("/api/dw/expert-repository/v1/repositories/file?expertCode=" + encodeURIComponent(expert) + "&commitId=" + encodeURIComponent(headId) + "&relativePath=" + encodeURIComponent(it.relativePath));
1663
- fs.writeFileSync(lp, buf);
1664
- downloaded++;
1665
- }
1666
- // 拉取成功后更新基线状态文件(记录新 head + 本地最终文件 sha)
1667
- if (!dry) {
1668
- const finalLocal = localShaMap(dir);
1669
- writeSyncState(dir, headId, headRev, finalLocal);
1670
- }
1671
- const out = { expert, headCommitId: headId, headRevision: headRev, total: items.length, unchanged, downloaded, preserved, backed, missing, dryRun: !!dry, backupDir: backupRoot || undefined };
1672
- if (json) { console.log(JSON.stringify(out)); return; }
1673
- console.log("== pull " + expert + "@" + headId + (dry ? " (dry-run)" : "") + " ==");
1674
- console.log("清单 " + out.total + " 项 | 未变 " + unchanged + " | 已下载 " + downloaded + " | 保留本地 " + preserved + " | 覆盖前备份 " + backed + " | 缺失 " + missing);
1675
- for (const p of plan.slice(0, 40)) console.log(" [" + p.action + "] " + p.rel + (p.reason ? "(" + p.reason + ")" : ""));
1676
- if (plan.length > 40) console.log(" ... 共 " + plan.length + " 个文件");
1677
- if (backupRoot && !dry) console.log("本地修改副本备份于: " + backupRoot);
1678
- if (preserved > 0 && !dry) console.log("⚠️ " + preserved + " 个文件因含本地修改被保留(远端未改);如需强制覆盖请加 --force");
1679
- }
1680
- async function expInit(args, json) {
1681
- const expert = need("expert", args), dir = need("dir", args);
1682
- const files = collectPackage(dir);
1683
- const body = { expertCode: expert, displayName: args["display-name"] || expert, branchName: args.branch || "main",
1684
- description: args.description || "", initialCommitMessage: args.message || "初始化导入", files };
1685
- const r = await postJson("/api/dw/expert-repository/v1/repositories", body);
1686
- if (json) return r;
1687
- if (!expOk(r)) throw new Error(expFailMsg(r));
1688
- console.log("== 仓库已创建 =="); console.log(JSON.stringify(r.data ?? {}, null, 1));
1689
- }
1690
- async function expCommit(args, json) {
1691
- const expert = need("expert", args), dir = need("dir", args), msg = need("message", args);
1692
- const mode = (args.mode || "FULL_SNAPSHOT").toUpperCase();
1693
- const st = await expGet("/api/dw/expert-repository/v1/repositories/status?expertCode=" + encodeURIComponent(expert) + "&branchName=" + (args.branch || "main"));
1694
- if (!expOk(st.j)) throw new Error("无法获取基线: " + expFailMsg(st.j));
1695
- const base = st.j.data || {};
1696
- const body = {
1697
- branchName: base.branchName || "main",
1698
- baseCommitId: rawNum(st.raw, "headCommitId") || String(base.headCommitId), // 精确字符串,避免大数丢精度
1699
- baseRevision: base.headRevision,
1700
- commitMessage: msg,
1701
- };
1702
- if (mode === "FILE_PATCH") {
1703
- const mf = await expGet("/api/dw/expert-repository/v1/repositories/manifest?expertCode=" + encodeURIComponent(expert) + "&branchName=" + (args.branch || "main"));
1704
- if (!expOk(mf.j)) throw new Error("无法获取 manifest: " + expFailMsg(mf.j));
1705
- const remoteSha = {}; for (const f of (mf.j.data || [])) remoteSha[f.relativePath] = f.sha256;
1706
- const local = collectPackage(dir);
1707
- const localSha = {}; for (const f of local) localSha[f.relativePath] = f.sha256;
1708
- const changes = [];
1709
- for (const f of local) {
1710
- const rs = remoteSha[f.relativePath];
1711
- if (rs === undefined) changes.push({ operation: "ADD", relativePath: f.relativePath, contentBase64: f.contentBase64, sha256: f.sha256, baseFileSha256: "" });
1712
- else if (rs !== f.sha256) changes.push({ operation: "MODIFY", relativePath: f.relativePath, contentBase64: f.contentBase64, sha256: f.sha256, baseFileSha256: rs });
1713
- }
1714
- for (const rp of Object.keys(remoteSha)) if (localSha[rp] === undefined) changes.push({ operation: "DELETE", relativePath: rp, baseFileSha256: remoteSha[rp] });
1715
- if (!changes.length) throw new Error("本地与远端一致,无变更可提交");
1716
- body.submitMode = "FILE_PATCH"; body.changes = changes;
1717
- } else {
1718
- body.submitMode = "FULL_SNAPSHOT"; body.files = collectPackage(dir);
1719
- }
1720
- const r = await postJson("/api/dw/expert-repository/v1/repositories/commits?expertCode=" + encodeURIComponent(expert), body);
1721
- if (json) return r;
1722
- if (!expOk(r)) {
1723
- const m = expFailMsg(r);
1724
- if (/基线|过期|revision|冲突/i.test(m)) throw new Error("提交冲突: " + m + " —— 请先 exp pull 拉取最新并合并后重试");
1725
- throw new Error("提交失败: " + m);
1726
- }
1727
- // 提交成功后把本地 .sync-state.json 基线刷新到新 head(与本地文件 sha 一致),保证后续 pull 三方判定准确
1728
- const rj = r.data || {};
1729
- const newHead = rawNum(JSON.stringify(r), "headCommitId") || rawNum(JSON.stringify(r), "commitId") || rj.headCommitId || rj.commitId || "";
1730
- if (newHead && fs.existsSync(dir)) {
1731
- try { writeSyncState(dir, newHead, rj.headRevision ?? rj.revision ?? 0, localShaMap(dir)); } catch {}
1732
- }
1733
- console.log("== 提交成功 =="); console.log(JSON.stringify(r.data ?? {}, null, 1));
1734
- }
1735
- async function expLock(args, json) {
1736
- const expert = need("expert", args);
1737
- const act = (args.action || "").toLowerCase();
1738
- const branch = args.branch || "main";
1739
- if (act === "query") {
1740
- const r = await getUrl("/api/dw/expert-repository/v1/repositories/locks?expertCode=" + encodeURIComponent(expert) + "&branchName=" + encodeURIComponent(branch));
1741
- if (json) return r;
1742
- if (!expOk(r)) throw new Error(expFailMsg(r));
1743
- printExpData(r, "有效锁");
1744
- return;
1745
- }
1746
- if (act === "heartbeat") {
1747
- const token = need("lock-token", args);
1748
- const r = await postJson("/api/dw/expert-repository/v1/repositories/locks/heartbeat?lockToken=" + encodeURIComponent(token), {});
1749
- if (json) return r;
1750
- if (!expOk(r)) throw new Error(expFailMsg(r));
1751
- console.log("锁已续期: " + JSON.stringify(r.data ?? {}));
1752
- return;
1753
- }
1754
- if (act === "release") {
1755
- const token = need("lock-token", args);
1756
- const req = new Request(BASE + "/api/dw/expert-repository/v1/repositories/locks?lockToken=" + encodeURIComponent(token), { method: "DELETE" });
1757
- const resp = await fetch(req, { headers: { "cookie": auth.cookie, "eteamsid": auth.eteamsid, "accept": "application/json" } });
1758
- const text = await resp.text();
1759
- let r; try { r = JSON.parse(text); } catch { r = { data: { raw: text } }; }
1760
- if (json) return r;
1761
- if (!expOk(r)) throw new Error(expFailMsg(r));
1762
- console.log("锁已释放");
1763
- return;
1764
- }
1765
- // acquire(默认)
1766
- const body = { branchName: branch, componentKey: args.component || "ROOT", ttlMinutes: parseInt(args.ttl || "30", 10) };
1767
- const r = await postJson("/api/dw/expert-repository/v1/repositories/locks?expertCode=" + encodeURIComponent(expert), body);
1768
- if (json) return r;
1769
- if (!expOk(r)) throw new Error(expFailMsg(r));
1770
- console.log("== 锁已获取(记录 lockToken 供 heartbeat/release/commit 使用)==");
1771
- console.log(JSON.stringify(r.data ?? {}, null, 1));
1772
- }
1773
- async function expRelease(args, json) {
1774
- const expert = need("expert", args);
1775
- const r = await postJson("/api/dw/expert-repository/v1/repositories/releases?expertCode=" + encodeURIComponent(expert),
1776
- { commitId: need("commit", args), versionTag: need("version", args), releaseNote: args.note || "" });
1777
- if (json) return r;
1778
- if (!expOk(r)) throw new Error(expFailMsg(r));
1779
- console.log("== 发布成功 =="); console.log(JSON.stringify(r.data ?? {}, null, 1));
1780
- }
1781
- async function expRollback(args, json) {
1782
- const expert = need("expert", args);
1783
- const r = await postJson("/api/dw/expert-repository/v1/repositories/releases/rollback?expertCode=" + encodeURIComponent(expert) + "&versionTag=" + encodeURIComponent(need("version", args)), {});
1784
- if (json) return r;
1785
- if (!expOk(r)) throw new Error(expFailMsg(r));
1786
- console.log("== 回滚成功 =="); console.log(JSON.stringify(r.data ?? {}, null, 1));
1787
- }
1788
-
1789
- // ---------------- main ----------------
1790
- function usage() {
1791
- console.log(`e10-data CLI(data-assistant 数据接口统一命令行,2026-09-03 扩展)
1792
- 用法: node e10-data.mjs <族> <子命令> [--参数] 通用 --json 原样输出
1793
-
1794
- login 登录(**唯一不需要已有凭据**的命令族;其余命令都要求先有登录态)
1795
- login xiaoe-env [--domain <url>] # 用环境变量 XIAOE_USER_TOKEN + XIAOE_DOMAIN(XiaoE 全局 token)
1796
- # 换 ETEAMSID 并写入 profile(免浏览器;不依赖 python)
1797
- # --domain 缺省 = 当前 profile 的 config.json.baseUrl → 默认平台地址
1798
- # 依赖环境变量:XIAOE_USER_TOKEN(XiaoE token)、XIAOE_DOMAIN(erpa 域名)
1799
- # 别名 e10-data xiaoe-env(与 e10-login 同名命令一致)
1800
- # 说明:换 token 由本 CLI 原生完成;写 profile(auth 加密)仍交 e10-login set
1801
- # 成功后自动用取数链路自检读回凭据,读不回即报错(不假成功)
1802
-
1803
- query DSL 数据查询 executeQueryDsl
1804
- query --conn <connId> --model <ebuilder|flow|eteams|attend|hr|document|dw|blog> --schema <数据库> [--table-id <流程ID>] [--debug true|false] --dsl @file.json|'{...}'
1805
- ds 数据源(业务模块)取数与元数据
1806
- ds tables --group <gid> [--name 关键词] [--app-id <id>]
1807
- ds fields --group <gid> --table <tid> [--app-id <id>]
1808
- ds list --group <gid> --table <tid> --fields a,b [--where @f|json] [--order "f asc"] [--page N] [--size N] [--app-id <id>]
1809
- ds chart --group <gid> --table <tid> --group-by g1,g2 --stat @f.json [--where ...] [--app-id <id>]
1810
- ds stat --group <gid> --table <tid> --stat @f.json [--where ...] [--app-id <id>]
1811
- ds count --group <gid> --table <tid> [--where ...] [--app-id <id>]
1812
- (ds 通用 --source-type LOGIC|ETEAMS,默认 LOGIC)
1813
- (ds 取数的 appId:一律**显式传入** --app-id <id>(v0.1.14 起 CLI 不再自动反查)。
1814
- EB 建模表单(groupId=weaver-ebuilder-form-service)必须带:该值取自 chat_bi_eb_config_map 的权限校验查询
1815
- ——调用方先查权限(无权限即终止、不取数),同一条记录的 app_id 即本次必带的 appId;
1816
- 其他数据集可不传,CLI 不拦截、以接口报错为准;--app-id auto / --no-app-id 已废弃,等同不传)
1817
- --where 过滤条件(v0.1.8 起支持树形 OR,递归):
1818
- 数组 = AND 根: [{"fieldId":"f1","conditionType":"Date","compareType":"range","conditionValue":"2026-09~2026-10"},{"fieldId":"f2",...}]
1819
- 对象 = 完整树: {"relationship":"0","conditionList":[{"fieldId":"f1",...},{"fieldId":"f2",...}]} # "0"=OR、"1"=AND,缺省 AND
1820
- 数组元素可内嵌条件组 {"relationship":"0","conditionList":[...]} 实现 (A AND B) OR (C AND D)
1821
- 叶子键 fieldId/conditionId 均可(fieldId 优先);缺少字段的叶子会直接报错(不再静默当全表)
1822
- doc 文档查询
1823
- doc count [--name 关键词] [--creator id1,id2] [--author id1,id2] [--content-type office] [--time "起~止"] [--last-time "起~止"] [--folder id,id|--folder-neq id,id]
1824
- doc list [同上条件] [--all 翻页到底] [--page N] [--size 100]
1825
- noun 词库查询
1826
- noun list --skill <skillKey> [--source-type LOGIC] [--source-group gid] [--source-table tid](数据源场景带三参数)
1827
- field 关联字段翻译
1828
- field id2name --module <m> --type <t> --ids id1,id2 [--form-param '{"fieldId":"..","module":"ebuilderform"}'|--eb-browser-param '{..}'|--property-type x]
1829
- field name2id --module <m> --type <t> --name 名称 [同上附加参数]
1830
- conf 环境参数配置中心(chatBi config map,替代 config-resolve 脚本 URL 直连;confType 默认由 CONN_/DS_/WF_/API_ 前缀推导,可 --type 显式)
1831
- confGroup(配置分组,v0.1.9 新增,可选):confType=api 时后端支持二级分层(如 api/workflow),
1832
- 定位维度 = confType+confGroup+confKey;仅 confType=api(或显式传 --group)时才下发 confGroup,其他类型不带该字段
1833
- conf get <key> [--type t] [--group g] # 单查(confValue/confAttrs/confName 全量 JSON)
1834
- conf batch <key1> [key2 ...] [--type t] [--group g] # 批量查(批量接口异常自动降级逐条单查)
1835
- conf batch-file @items.json # 按 [{confType,confGroup,confKey}..] 显式批量查(支持任意 confType)
1836
- conf group <confType> <confGroup> [--key k] # 按类型+分组批量查(batchGetChatBiConfigMapByTypeAndGroup)
1837
- conf group-file @items.json # 按 [{"confType":"api","confGroup":"workflow"}..] 多分组批量查
1838
- conf resolve '<含#{KEY}文本>'|@file.txt [--group g] # 文本占位符替换为真实值(含中文建议 @file)
1839
- conf resolve-file <文件路径> [--group g] # 读文件替换全部占位符并打印(不改写原文件)
1840
- conf add <key> --value <真实值> [--name 说明] [--attrs '{"request":..,"response":..}'] [--type t] [--group g] [--force] # 新增(默认判重,已存在跳过)
1841
- conf add-file @items.json # JSON 数组批量新增(逐条判重,元素可带 confGroup)
1842
- conf update <key> [--value v] [--name n] [--attrs a] [--type t] [--group g] # 更新(confType+confGroup+confKey 定位,传哪个字段更哪个)
1843
- conf delete <key> [--type t] [--group g] # 删除
1844
- 示例: conf group api workflow | conf add GET_WORKFLOW_LIST --type api --group workflow --value /api/workflow/getList --name 查询流程列表
1845
- exp 专家包版本管理(expert-repository/v1,响应以 data.actionMsg.code=0 判成功)
1846
- exp status --expert <code> [--branch main] # 仓库状态(headCommitId/headRevision=提交基线)
1847
- exp manifest --expert <code> [--commit <id>] # 文件清单(sha256)
1848
- exp commits --expert <code> # 提交历史
1849
- exp file --expert <code> --commit <id> --path <相对路径> [--out 本地文件]
1850
- exp pull --expert <code> --dir <本地专家包目录> [--dry-run] [--force] # 智能拉取:基线三方对比(仅远端改→覆盖/仅本地改→保留/双方都改→备份后覆盖;--force 一律覆盖),成功后更新 .sync-state.json 基线
1851
- exp init --expert <code> --dir <本地目录> [--display-name X] [--message 初始化导入]
1852
- exp commit --expert <code> --dir <本地目录> --message "..." [--mode FULL_SNAPSHOT|FILE_PATCH]
1853
- exp lock --expert <code> --action query|acquire(默认)|heartbeat|release --component ROOT --ttl 30 --lock-token <token>
1854
- exp release --expert <code> --commit <id> --version vX.Y.Z [--note 说明]
1855
- exp rollback --expert <code> --version vX.Y.Z
1856
- attend 考勤 REST 接口(2026-09-07 新增,hr-attendance 病假/事假等假期余额)
1857
- attend statis [--user <人员ID>] [--begin 2026-09-01] [--end 2026-09-30] # getAttendInfoStatis 假期余额统计;--user 默认当前登录用户,--begin/--end 默认当月
1858
- eb EB 建模表单权限校验(chatBi EB 配置映射分页查询,替代 DSL 查 chat_bi_eb_config_map)
1859
- eb perm-page --obj-id <id> --form-table <物理表名> [--page N] [--size N] [--app-id <id>] [--require]
1860
- # obj-id / form-table 必填(本地强校验;缺失后端只回空、不报错)
1861
- # obj-id / app-id 必须字符串传参(19 位大整数,Number 舍入 → 静默 total=0)
1862
- # total>=1 = 有权限,并回带该行 appId;--require 时 total=0 直接报错退出
1863
- 认证: 加解密由 e10-login 负责(本 CLI 不参与);凭据出口 = e10-login auth/whoami(自动同步镜像);皆无请先 e10-login oidc
1864
- (无浏览器/无 python 环境:login xiaoe-env 用环境变量 token 直接登录)`);
1865
- }
1866
-
1867
- async function main() {
1868
- const rawArgv = process.argv.slice(2);
1869
- // 仅当顶层显式 -v/--version(无子命令)时打印版本;exp release/rollback 的 --version 是版本号参数、conf 文本可能含 version 字样,一律放行
1870
- if (rawArgv.length === 1 && (rawArgv[0] === "--version" || rawArgv[0] === "-v")) {
1871
- console.log("e10-data " + cliVersion());
1872
- process.exit(0);
1873
- }
1874
- const { args, pos } = parseArgs(rawArgv);
1875
- if (!pos.length) { usage(); process.exit(1); }
1876
- const family = pos[0], sub = pos[1], json = !!args.json;
1877
- let result;
1878
- if (family === "ds") {
1879
- if (!sub) { usage(); process.exit(1); }
1880
- switch (sub) {
1881
- case "tables": result = await cmdTables(args, json); break;
1882
- case "fields": result = await cmdFields(args, json); break;
1883
- case "list": result = await cmdList(args, json); break;
1884
- case "chart": result = await cmdChart(args, json); break;
1885
- case "stat": result = await cmdStat(args, json); break;
1886
- case "count": result = await cmdCount(args, json); break;
1887
- default: usage(); process.exit(1);
1888
- }
1889
- } else if (family === "query") {
1890
- result = await cmdQuery(args, json);
1891
- } else if (family === "doc") {
1892
- if (sub === "count") result = await cmdDocCount(args, json);
1893
- else if (sub === "list") result = await cmdDocList(args, json);
1894
- else { usage(); process.exit(1); }
1895
- } else if (family === "noun") {
1896
- if (sub === "list") result = await cmdNoun(args, json);
1897
- else { usage(); process.exit(1); }
1898
- } else if (family === "field") {
1899
- if (sub === "id2name") result = await cmdFieldId2name(args, json);
1900
- else if (sub === "name2id") result = await cmdFieldName2id(args, json);
1901
- else { usage(); process.exit(1); }
1902
- } else if (family === "conf") {
1903
- if (!sub) { usage(); process.exit(1); }
1904
- result = await cmdConf(sub, args, json, pos);
1905
- } else if (family === "exp") {
1906
- if (!sub) { usage(); process.exit(1); }
1907
- switch (sub) {
1908
- case "status": result = await expStatus(args, json); break;
1909
- case "manifest": result = await expManifest(args, json); break;
1910
- case "commits": result = await expCommits(args, json); break;
1911
- case "file": result = await expFile(args, json); break;
1912
- case "pull": result = await expPull(args, json); break;
1913
- case "init": result = await expInit(args, json); break;
1914
- case "commit": result = await expCommit(args, json); break;
1915
- case "lock": result = await expLock(args, json); break;
1916
- case "release": result = await expRelease(args, json); break;
1917
- case "rollback": result = await expRollback(args, json); break;
1918
- default: usage(); process.exit(1);
1919
- }
1920
- } else if (family === "attend") {
1921
- if (sub === "statis") result = await cmdAttendStatis(args, json);
1922
- else { usage(); process.exit(1); }
1923
- } else if (family === "eb") {
1924
- if (!sub) { usage(); process.exit(1); }
1925
- switch (sub) {
1926
- case "perm-page": result = await cmdEbPermPage(args, json); break;
1927
- default: usage(); process.exit(1);
1928
- }
1929
- } else { usage(); process.exit(1); }
1930
- if (json && result !== undefined) console.log(JSON.stringify(result, null, 1));
1931
- }
1932
- main().catch((e) => { console.error("ERROR: " + e.message); process.exit(1); });
1
+ #!/usr/bin/env node
2
+ // -*- coding: utf-8 -*-
3
+ /**
4
+ * e10-data CLI —— data-assistant 专家统一数据接口命令行(2026-09-03 扩展)
5
+ *
6
+ * 覆盖(数据查询链路全部家族;登录/HRM 复用 e10-login / hrm-cli,checkEbModulePermission 已废弃不提供):
7
+ * e10-data login xiaoe-env [--domain <url>] **无凭据时**用环境变量 XIAOE_USER_TOKEN + XIAOE_DOMAIN
8
+ * (XiaoE 全局 token)换 ETEAMSID 并落 profile(免浏览器/免 python;2026-09-17 移植)
9
+ * e10-data query DSL 查询 executeQueryDsl(conn/model/schema/tableId/dslQuery/debug)
10
+ * e10-data ds groups|find|describe|tables|fields|fields-dsl|list|chart|stat|count
11
+ * 数据源(业务模块)取数与元数据
12
+ * groups 内置分组清单(仅作 find 的默认扫描集)
13
+ * find 跨分组找表(进程内并发扇出;产物=候选表清单 + 路由建议,不取数)
14
+ * describe 一次并发取回「字段 + 词库」(不做权限校验,EB 权限仍走 eb perm-page)
15
+ * appId 一律由调用方显式传入(v0.1.14 起 CLI 不再自动反查):
16
+ * EB 建模表单(groupId=weaver-ebuilder-form-service)取数必须带 --app-id <id>,
17
+ * 该值只能来自 chat_bi_eb_config_map 的权限校验查询(同一次查询给出权限与 appId);
18
+ * 不带 appId 时按原样发送,由后端报错(CLI 不做拦截)
19
+ * e10-data doc count|list 文档查询 documentListCount / documentList
20
+ * e10-data noun list 词库 getChatDocsBySkillKey
21
+ * e10-data field id2name|name2id 关联字段翻译 testRemoteBrowser / complete
22
+ * e10-data conf get|batch|batch-file|group|group-file|resolve|resolve-file|add|add-file|update|delete
23
+ * 环境参数配置中心(chatBi config map,替代 config-resolve 脚本 URL 直连;
24
+ * confType=api 时支持 confGroup 分组二级定位,见 --group)
25
+ * e10-data attend statis 考勤假期余额统计 getAttendInfoStatis(hr-attendance 病假/事假,2026-09-07)
26
+ *
27
+ * 通用:--json 原样输出;复杂 JSON 参数支持 @文件 或 内联(中文走 @文件 更稳)
28
+ * 认证:凭据**唯一来源 = weaver-work-cli**(本 CLI 不实现任何加解密,**不设 e10-login 回退**):
29
+ * weaver-work-cli auth export-weaver-env —— 解析 WEAVER_BASE_URL / WEAVER_ETEAMSID / WEAVER_COOKIE
30
+ * e10-login 系出口 / 明文镜像已于 2026-09-24 停用;未登录时提示 `weaver-work-cli auth login`
31
+ * 例外:`login xiaoe-env` 在"还没有任何凭据"时可独立完成登录(换 token 自己算,落 profile 仍交 e10-login)
32
+ *
33
+ * 用法示例:
34
+ * node e10-data.mjs query --conn 1144919996804022272 --model dw --schema dw --dsl @q.json
35
+ * node e10-data.mjs ds count --group weaver-project-servicetask --table 715738355537494016
36
+ * node e10-data.mjs doc count --name 测试
37
+ * node e10-data.mjs noun list --skill rd-department
38
+ * node e10-data.mjs field id2name --module ebuilder/form --type ebuilder \
39
+ * --ids 3194494277170470571 --form-param '{"fieldId":"966849910772850766","module":"ebuilderform"}'
40
+ */
41
+ import fs from "node:fs";
42
+ import os from "node:os";
43
+ import path from "node:path";
44
+ import crypto from "node:crypto";
45
+ import net from "node:net";
46
+ import { spawn, spawnSync } from "node:child_process";
47
+ import { fileURLToPath } from "node:url";
48
+ import { createRequire } from "node:module";
49
+
50
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
51
+ function cliVersion() {
52
+ try { return JSON.parse(fs.readFileSync(path.join(__dirname, "..", "package.json"), "utf-8")).version; }
53
+ catch { return "dev"; }
54
+ }
55
+
56
+ const UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.0.0 Safari/537.36";
57
+ const EB_BIZ_ID = "10000000000000000";
58
+
59
+ // ---------------- 认证(加解密全部交由 e10-login,本 CLI 不再实现任何加解密)----------------
60
+ // 背景:auth 文件由 e10-login 自己的密钥体系加密 —— e10-login 1.5.0 起为「每机随机密钥 + OS keychain
61
+ // (@napi-rs/keyring,service=e10-cli / account=auth-key;keychain 不可用才落 ~/.e10-cli/.key)」,
62
+ // 早期版本为固定 seed。**只有 e10-login 解得开**,所以本 CLI 只负责「向 e10-login 要明文凭据」,
63
+ // 不再自行解密 —— e10-login 以后更换密钥体系 / 算法 / profile 机制,本 CLI 都无需改动。
64
+ // 凭据出口优先级(命中即返回;命中的"模式名"会记入 ~/.e10-cli/.e10-data-auth-mode,只存模式、不存凭据):
65
+ // ① e10-login auth --json 正式契约(推荐 e10-login 侧新增;一旦可用自动优先)
66
+ // ② e10-login whoami --json 其 JSON 输出带 cookies 字段时可用
67
+ // ③ e10-login whoami 文本输出解析(当前 e10-login 1.5.0 的实际可用路径)
68
+ // ④ ~/.workbuddy/e10-login-cache.md 明文镜像 —— 最后兜底,命中时打印告警(该文件可能已过期)
69
+ // 环境变量:E10_DATA_AUTH_PROBE=1 强制重新探测优先级;E10_DATA_AUTH_MODE=<模式名> 手动指定;
70
+ // E10_DATA_QUIET=1 抑制告警输出(告警一律走 stderr,不污染 --json 的 stdout)。
71
+ const AUTH_MODE_FILE = path.join(os.homedir(), ".e10-cli", ".e10-data-auth-mode");
72
+
73
+ /** 定位当前 profile 的 auth 文件(仅用于"是否已登录"的判断与报错提示,本 CLI 不读其内容做解密) */
74
+ function authFilePath() {
75
+ const home = os.homedir();
76
+ let profile = "default";
77
+ try {
78
+ const activeFile = path.join(home, ".e10-cli", "profile");
79
+ if (fs.existsSync(activeFile)) { const n = fs.readFileSync(activeFile, "utf-8").trim(); if (n) profile = n; }
80
+ } catch {}
81
+ return { profile, file: path.join(home, ".e10-cli", "profiles", profile, "auth") };
82
+ }
83
+
84
+ // ---------------- e10-login 入口解析(v0.1.17:去掉 shell:true,这是最大的性能改动)----------------
85
+ // 背景(2026-09-20 实测):`spawnSync("e10-login", …, { shell: true })` 在 Windows 上会先起一层
86
+ // cmd.exe;该层**单独**耗时 ~0.98~1.04s(杀软对每个新进程的实时扫描所致),而 e10-login 自身
87
+ // 的活(读 profile + 解密 auth + 打印)只花 ~0.1s。改为「定位 e10-login 的 JS 入口,用
88
+ // process.execPath 直接拉起」后,实测 **1.0s → 0.22s**(每调用省 ~0.78s)。
89
+ // ⚠️ 不能直接 spawn `e10-login.cmd`:Node ≥18.20/20.12 起为修 CVE-2024-27980,禁止在 shell:false
90
+ // 下执行 .cmd/.bat(抛 EINVAL)。因此必须解析到 .cmd shim 背后**真正的 .js 入口**。
91
+ // 解析顺序:① 环境变量 E10_LOGIN_ENTRY → ② Node 模块解析(与 e10-data 同 prefix 时命中)
92
+ // → ③ 沿 PATH 找 shim,再取 <shimDir>/node_modules/e10-login → ④ 常见全局前缀兜底。
93
+ // 全部失败 → 返回 null,调用方退化回 shell:true(保持旧行为可用,只慢不错)。
94
+ let _loginEntry; // 进程内缓存解析结果;null 表示"已尝试但未找到"
95
+ let _loginEntryDone = false;
96
+
97
+ function resolveLoginEntry() {
98
+ if (_loginEntryDone) return _loginEntry;
99
+ _loginEntryDone = true;
100
+ const tried = [];
101
+ const isFile = (p) => { try { return !!p && fs.statSync(p).isFile(); } catch { return false; } };
102
+
103
+ /** 读 <dir>/package.json 的 bin 字段定位入口文件;非 e10-login 包返回 null */
104
+ const entryFromPkgDir = (dir) => {
105
+ try {
106
+ const pj = path.join(dir, "package.json");
107
+ if (!isFile(pj)) return null;
108
+ const j = JSON.parse(fs.readFileSync(pj, "utf-8"));
109
+ if (j.name !== "e10-login") return null;
110
+ let b = j.bin;
111
+ if (typeof b === "string") b = { "e10-login": b };
112
+ const rel = b && b["e10-login"];
113
+ if (!rel) return null;
114
+ const entry = path.resolve(dir, rel);
115
+ return isFile(entry) ? entry : null;
116
+ } catch { return null; }
117
+ };
118
+
119
+ // ① 环境变量显式指定(排障与特殊安装方式用)
120
+ const envEntry = (process.env.E10_LOGIN_ENTRY || "").trim();
121
+ if (envEntry) {
122
+ if (isFile(envEntry)) { _loginEntry = envEntry; return _loginEntry; }
123
+ tried.push("E10_LOGIN_ENTRY 指向的文件不存在: " + envEntry);
124
+ }
125
+
126
+ // ② Node 模块解析(e10-data 与 e10-login 装在同一个 prefix 时命中)
127
+ try {
128
+ const pj = createRequire(import.meta.url).resolve("e10-login/package.json");
129
+ const e = entryFromPkgDir(path.dirname(pj));
130
+ if (e) { _loginEntry = e; return _loginEntry; }
131
+ tried.push("模块解析命中 " + pj + " 但 bin 入口不可用");
132
+ } catch (e) { tried.push("模块解析: " + ((e && e.message) || e)); }
133
+
134
+ // ③ 沿 PATH 找 shim,再取 shim 同级的 node_modules/e10-login
135
+ // (npm 全局安装的常规布局:<prefix>/e10-login.cmd + <prefix>/node_modules/e10-login)
136
+ const pathDirs = String(process.env.PATH || "").split(path.delimiter).filter(Boolean);
137
+ const shimNames = process.platform === "win32"
138
+ ? ["e10-login.cmd", "e10-login.exe", "e10-login"]
139
+ : ["e10-login"];
140
+ for (const d of pathDirs) {
141
+ for (const n of shimNames) {
142
+ const shim = path.join(d, n);
143
+ if (!isFile(shim)) continue;
144
+ const e = entryFromPkgDir(path.join(d, "node_modules", "e10-login"));
145
+ if (e) { _loginEntry = e; return _loginEntry; }
146
+ tried.push("找到 shim " + shim + ",但其同级 node_modules/e10-login 不可用");
147
+ }
148
+ }
149
+
150
+ // ④ 常见全局前缀兜底
151
+ const home = os.homedir();
152
+ const prefixes = [
153
+ path.join(path.dirname(process.execPath), "node_modules"),
154
+ path.join(path.dirname(process.execPath), "..", "lib", "node_modules"),
155
+ path.join(home, ".npm-global", "lib", "node_modules"),
156
+ process.env.APPDATA ? path.join(process.env.APPDATA, "npm", "node_modules") : null,
157
+ process.env.ProgramFiles ? path.join(process.env.ProgramFiles, "nodejs", "node_modules") : null,
158
+ ].filter(Boolean);
159
+ for (const p of prefixes) {
160
+ const e = entryFromPkgDir(path.join(p, "e10-login"));
161
+ if (e) { _loginEntry = e; return _loginEntry; }
162
+ }
163
+ tried.push("常见全局前缀均未命中: " + prefixes.join(" | "));
164
+
165
+ if (!process.env.E10_DATA_QUIET) {
166
+ console.error("[e10-data][warn] 未定位到 e10-login 的 JS 入口,本次退化为 shell 调用(较慢,且 Windows 上多花约 1 秒)。"
167
+ + "可用 E10_LOGIN_ENTRY=<e10-login/bin/e10-login.js 绝对路径> 显式指定。诊断:" + tried.join(" | "));
168
+ }
169
+ _loginEntry = null;
170
+ return null;
171
+ }
172
+
173
+ let lastLoginDiag = "";
174
+ /** 底层调用 e10-login,返回 spawnSync 结果(供需要看 stderr/status 的调用方使用)。
175
+ * 优先 shell:false 直连 JS 入口;解析失败才退化为 shell:true。 */
176
+ function spawnLoginCli(args, timeout = 20000) {
177
+ const entry = resolveLoginEntry();
178
+ if (entry) return spawnSync(process.execPath, [entry, ...args], { encoding: "utf-8", timeout, windowsHide: true });
179
+ return spawnSync("e10-login", args, { shell: true, encoding: "utf-8", timeout, windowsHide: true });
180
+ }
181
+
182
+ /** 调用 e10-login(凭据的唯一来源);失败返回 null,并把诊断信息留在 lastLoginDiag */
183
+ function runLoginCli(args) {
184
+ try {
185
+ const r = spawnLoginCli(args);
186
+ const out = (r.stdout || "").trim();
187
+ if (r.error || r.status !== 0) {
188
+ lastLoginDiag = ((r.stderr || "") + out).trim().split(/\r?\n/).slice(0, 2).join(" / ") || (r.error ? String(r.error.message) : "exit=" + r.status);
189
+ return null;
190
+ }
191
+ return out || null;
192
+ } catch (e) { lastLoginDiag = String((e && e.message) || e); return null; }
193
+ }
194
+
195
+ /** 归一化 + 校验(缺 baseUrl / eteamsid / cookie 一律视为该出口不可用,继续下一个出口) */
196
+ function normalizeAuth(o) {
197
+ if (!o) return null;
198
+ const baseUrl = String(o.baseUrl || "").trim().replace(/\/+$/, "");
199
+ const eteamsid = String(o.eteamsid || "").trim();
200
+ const cookie = String(o.cookie || "").trim();
201
+ if (!baseUrl || !eteamsid || !cookie) return null;
202
+ if (/^\((未知|空)\)$/.test(eteamsid) || /^\((未知|空)\)$/.test(cookie)) return null;
203
+ return { baseUrl, eteamsid, cookie, userId: String(o.userId || "").trim(), tenantKey: String(o.tenantKey || "").trim(), source: o.source };
204
+ }
205
+
206
+ /** ① e10-login auth --json(正式契约:{v,baseUrl,eteamsId,cookie,userAgent,userId,tenantKey,agentType,profile}) */
207
+ function providerAuthJson() {
208
+ const out = runLoginCli(["auth", "--json"]);
209
+ if (!out) return null;
210
+ let j; try { j = JSON.parse(out); } catch { return null; }
211
+ if (!j || typeof j !== "object" || Array.isArray(j)) return null;
212
+ return normalizeAuth({
213
+ baseUrl: j.baseUrl || j.base_url,
214
+ eteamsid: j.eteamsId || j.eteamsid,
215
+ cookie: typeof j.cookie === "string" ? j.cookie : "",
216
+ userId: j.userId || j.employeeId,
217
+ tenantKey: j.tenantKey,
218
+ source: "e10-login auth --json",
219
+ });
220
+ }
221
+
222
+ /** ② e10-login whoami --json(需其 JSON 带 cookies 字符串;当前 1.5.0 未带 → 自动落到 ③) */
223
+ function providerWhoamiJson() {
224
+ const out = runLoginCli(["whoami", "--json"]);
225
+ if (!out) return null;
226
+ let j; try { j = JSON.parse(out); } catch { return null; }
227
+ if (!j || typeof j !== "object" || Array.isArray(j)) return null;
228
+ const cookie = typeof j.cookies === "string" ? j.cookies : (typeof j.cookie === "string" ? j.cookie : "");
229
+ return normalizeAuth({
230
+ baseUrl: j.baseUrl,
231
+ eteamsid: j.eteamsId || j.eteamsid,
232
+ cookie,
233
+ userId: j.employeeId || j.userId,
234
+ tenantKey: j.tenantKey,
235
+ source: "e10-login whoami --json",
236
+ });
237
+ }
238
+
239
+ /** ③ e10-login whoami(文本输出解析:employeeId / tenantKey / baseUrl / ETEAMSID / cookies / User-Agent) */
240
+ function providerWhoamiText() {
241
+ const out = runLoginCli(["whoami"]);
242
+ if (!out) return null;
243
+ const m = {};
244
+ for (const line of out.split(/\r?\n/)) {
245
+ const x = line.match(/^\s*([A-Za-z][A-Za-z0-9_-]*)\s*:\s*(.+?)\s*$/);
246
+ if (x) m[x[1].toLowerCase()] = x[2];
247
+ }
248
+ return normalizeAuth({
249
+ baseUrl: m["baseurl"],
250
+ eteamsid: m["eteamsid"],
251
+ cookie: m["cookies"],
252
+ userId: m["employeeid"] || m["userid"],
253
+ tenantKey: m["tenantkey"],
254
+ source: "e10-login whoami(文本)",
255
+ });
256
+ }
257
+
258
+ /** ④ 明文镜像(最后兜底:不涉及任何解密,但可能已过期 → 命中时告警) */
259
+ function providerCacheMd() {
260
+ const p = path.join(os.homedir(), ".workbuddy", "e10-login-cache.md");
261
+ if (!fs.existsSync(p)) return null;
262
+ const txt = fs.readFileSync(p, "utf-8");
263
+ const get = (key) => {
264
+ const mm = txt.match(new RegExp("\\*\\*" + key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + "\\*\\*\\s*[::]\\s*`?([^`\\n]+)`?"));
265
+ return mm ? mm[1].trim() : "";
266
+ };
267
+ const eteamsid = get("ETEAMSID");
268
+ let cookie = get("完整 Cookie 串");
269
+ if (!cookie && eteamsid) cookie = "langType=zh_CN; ETEAMSID=" + eteamsid + "; agentType=workbuddy; isAgent=true";
270
+ return normalizeAuth({
271
+ baseUrl: get("baseUrl"), eteamsid, cookie,
272
+ userId: get("userId") || get("employeeId"), tenantKey: get("tenantKey"),
273
+ source: "明文镜像 ~/.workbuddy/e10-login-cache.md(可能已过期)",
274
+ });
275
+ }
276
+
277
+ // ---------------- weaver-work-cli 凭据出口(首选来源)----------------
278
+ // 背景:本包要求 e10-data 的凭据**优先取自 weaver-work-cli**(与连接器无关的用户级登录体系)。
279
+ // weaver-work-cli 的登录态存于 ~/.weaver-work-cli/(@napi-rs/keyring 加密),与 e10-login 完全独立、
280
+ // 互不通用;它提供 `auth export-weaver-env` 输出 WEAVER_* 变量,其中
281
+ // WEAVER_BASE_URL / WEAVER_ETEAMSID / WEAVER_COOKIE 正好对应本 CLI 需要的三要素。
282
+ // 安全边界:**只在进程内存中解析** —— 不经 shell、不落盘、不打印、不写入任何日志/报告/错误信息;
283
+ // 凭据随进程结束即释放(后续调用仍走 v0.1.17 的内存 TTL 缓存,不落明文)。
284
+ // 定位策略:与 e10-login 相同的四段式(WEAVER_WORK_CLI_ENTRY → 模块解析 → PATH shim → 常见全局前缀)。
285
+ const WEAVER_PROVIDER_NAME = "weaver-work-cli";
286
+ const WEAVER_CLI_ENTRY_ENV = "WEAVER_WORK_CLI_ENTRY";
287
+
288
+ let _weaverEntry;
289
+ let _weaverEntryDone = false;
290
+
291
+ function resolveWeaverEntry() {
292
+ if (_weaverEntryDone) return _weaverEntry;
293
+ _weaverEntryDone = true;
294
+ const isFile = (p) => { try { return !!p && fs.statSync(p).isFile(); } catch { return false; } };
295
+
296
+ const entryFromPkgDir = (dir) => {
297
+ try {
298
+ const pj = path.join(dir, "package.json");
299
+ if (!isFile(pj)) return null;
300
+ const j = JSON.parse(fs.readFileSync(pj, "utf-8"));
301
+ if (j.name !== "weaver-work-cli") return null;
302
+ let b = j.bin;
303
+ if (typeof b === "string") b = { "weaver-work-cli": b };
304
+ const rel = b && b["weaver-work-cli"];
305
+ if (!rel) return null;
306
+ const entry = path.resolve(dir, rel);
307
+ return isFile(entry) ? entry : null;
308
+ } catch { return null; }
309
+ };
310
+
311
+ const envEntry = (process.env[WEAVER_CLI_ENTRY_ENV] || "").trim();
312
+ if (envEntry && isFile(envEntry)) { _weaverEntry = envEntry; return _weaverEntry; }
313
+
314
+ try {
315
+ const pj = createRequire(import.meta.url).resolve("weaver-work-cli/package.json");
316
+ const e = entryFromPkgDir(path.dirname(pj));
317
+ if (e) { _weaverEntry = e; return _weaverEntry; }
318
+ } catch {}
319
+
320
+ const pathDirs = String(process.env.PATH || "").split(path.delimiter).filter(Boolean);
321
+ const shimNames = process.platform === "win32"
322
+ ? ["weaver-work-cli.cmd", "weaver-work-cli.exe", "weaver-work-cli"]
323
+ : ["weaver-work-cli"];
324
+ for (const d of pathDirs) {
325
+ for (const n of shimNames) {
326
+ if (!isFile(path.join(d, n))) continue;
327
+ const e = entryFromPkgDir(path.join(d, "node_modules", "weaver-work-cli"));
328
+ if (e) { _weaverEntry = e; return _weaverEntry; }
329
+ }
330
+ }
331
+
332
+ const home = os.homedir();
333
+ const prefixes = [
334
+ path.join(path.dirname(process.execPath), "node_modules"),
335
+ path.join(path.dirname(process.execPath), "..", "lib", "node_modules"),
336
+ path.join(home, ".npm-global", "lib", "node_modules"),
337
+ process.env.APPDATA ? path.join(process.env.APPDATA, "npm", "node_modules") : null,
338
+ process.env.ProgramFiles ? path.join(process.env.ProgramFiles, "nodejs", "node_modules") : null,
339
+ ].filter(Boolean);
340
+ for (const p of prefixes) {
341
+ const e = entryFromPkgDir(path.join(p, "weaver-work-cli"));
342
+ if (e) { _weaverEntry = e; return _weaverEntry; }
343
+ }
344
+
345
+ _weaverEntry = null;
346
+ return null;
347
+ }
348
+
349
+ /** 调用 weaver-work-cli(失败返回 null,诊断留在 lastLoginDiag 供 loadAuth 汇总) */
350
+ function runWeaverCli(args, timeout = 20000) {
351
+ try {
352
+ const entry = resolveWeaverEntry();
353
+ const r = entry
354
+ ? spawnSync(process.execPath, [entry, ...args], { encoding: "utf-8", timeout, windowsHide: true })
355
+ : spawnSync("weaver-work-cli", args, { shell: true, encoding: "utf-8", timeout, windowsHide: true });
356
+ const out = (r.stdout || "").trim();
357
+ if (r.error || r.status !== 0) {
358
+ lastLoginDiag = ((r.stderr || "") + out).trim().split(/\r?\n/).slice(0, 2).join(" / ") || (r.error ? String(r.error.message) : "exit=" + r.status);
359
+ return null;
360
+ }
361
+ return out || null;
362
+ } catch (e) { lastLoginDiag = String((e && e.message) || e); return null; }
363
+ }
364
+
365
+ /** 去掉 shell 引号包装(兼容 POSIX 单/双引号与 PowerShell 双引号) */
366
+ function unquoteShellValue(raw) {
367
+ const s = String(raw || "").trim();
368
+ if (s.length >= 2 && s.startsWith("'") && s.endsWith("'")) return s.slice(1, -1).replace(/'\\''/g, "'");
369
+ if (s.length >= 2 && s.startsWith('"') && s.endsWith('"')) return s.slice(1, -1).replace(/\\(["\\$`])/g, "$1");
370
+ return s;
371
+ }
372
+
373
+ /** 解析 KEY=VALUE 行(POSIX `export K='V'` / PowerShell `$env:K = "V"`) */
374
+ function parseShellExports(text) {
375
+ const env = {};
376
+ for (const line of String(text || "").split(/\r?\n/)) {
377
+ const s = line.trim();
378
+ if (!s) continue;
379
+ let m = s.match(/^export\s+([A-Za-z_][A-Za-z0-9_]*)=(.*)$/);
380
+ if (!m) m = s.match(/^\$env:([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/);
381
+ if (m) env[m[1]] = unquoteShellValue(m[2]);
382
+ }
383
+ return env;
384
+ }
385
+
386
+ /** ⓪ weaver-work-cli auth export-weaver-env(唯一凭据来源) */
387
+ function providerWeaverWorkCli() {
388
+ const out = runWeaverCli(["auth", "export-weaver-env", "--shell", "posix"]);
389
+ if (!out) return null;
390
+ const env = parseShellExports(out);
391
+ const auth = normalizeAuth({
392
+ baseUrl: env.WEAVER_BASE_URL,
393
+ eteamsid: env.WEAVER_ETEAMSID,
394
+ cookie: env.WEAVER_COOKIE,
395
+ source: "weaver-work-cli auth export-weaver-env",
396
+ });
397
+ if (!auth) return null;
398
+ // export-weaver-env 不导出 userId / tenantKey,需另向 auth status 取。
399
+ // 二者均为「非凭据」的身份字段:userId 会被部分命令用作"当前登录用户"默认值(如 attend statis 的 --user),
400
+ // 缺失会导致这些命令报「缺少查询人员」。取不到不影响凭据本身的可用性。
401
+ try {
402
+ const st = runWeaverCli(["--json", "auth", "status", "--no-check"]);
403
+ if (st) {
404
+ const j = JSON.parse(st);
405
+ if (j && j.loggedIn) {
406
+ auth.userId = String(j.userId || "").trim();
407
+ auth.tenantKey = String(j.tenantKey || "").trim();
408
+ }
409
+ }
410
+ } catch {}
411
+ return auth;
412
+ }
413
+
414
+ // ⚠️ 2026-09-24:凭据**唯一来源 = weaver-work-cli**,不再回退 e10-login。
415
+ // providerAuthJson / providerWhoamiJson / providerWhoamiText / providerCacheMd 保留函数定义但不挂载
416
+ // (如需恢复回退,把对应条目加回本数组即可)。
417
+ const AUTH_PROVIDERS = [
418
+ ["weaver-work-cli", providerWeaverWorkCli],
419
+ ];
420
+ /** 预留:登记"命中后不进记忆"的出口(当前为空——唯一出口需要被记忆) */
421
+ const AUTH_FALLBACK_PROVIDERS = new Set();
422
+
423
+ function readAuthModeMemo() {
424
+ try {
425
+ const s = fs.readFileSync(AUTH_MODE_FILE, "utf-8").trim();
426
+ return AUTH_PROVIDERS.some(([n]) => n === s) ? s : null;
427
+ } catch { return null; }
428
+ }
429
+ function writeAuthModeMemo(name) {
430
+ try { fs.mkdirSync(path.dirname(AUTH_MODE_FILE), { recursive: true }); fs.writeFileSync(AUTH_MODE_FILE, name, "utf-8"); } catch {}
431
+ }
432
+
433
+ /** 取凭据:按优先级依次尝试各出口;命中的模式会被记住(下次优先尝试它,省一次进程调用) */
434
+ function loadAuth() {
435
+ const { profile, file } = authFilePath();
436
+ const override = (process.env.E10_DATA_AUTH_MODE || "").trim();
437
+ const memo = override || (process.env.E10_DATA_AUTH_PROBE ? null : readAuthModeMemo());
438
+ const order = memo
439
+ ? AUTH_PROVIDERS.filter(([n]) => n === memo).concat(AUTH_PROVIDERS.filter(([n]) => n !== memo))
440
+ : AUTH_PROVIDERS.slice();
441
+ // 首选来源固定为 weaver-work-cli:无论 memo 指向谁,都把它提到最前再依次尝试
442
+ // (仅当未用 E10_DATA_AUTH_MODE 显式指定时;它取不到凭据会自动回落后续出口)
443
+ if (!override) {
444
+ const wi = order.findIndex(([n]) => n === WEAVER_PROVIDER_NAME);
445
+ if (wi > 0) order.unshift(order.splice(wi, 1)[0]);
446
+ }
447
+
448
+ const diag = [];
449
+ for (const [name, fn] of order) {
450
+ let got = null;
451
+ lastLoginDiag = "";
452
+ try { got = fn(); } catch (e) { diag.push(name + ": " + ((e && e.message) || e)); }
453
+ if (got) {
454
+ if (name !== memo && !AUTH_FALLBACK_PROVIDERS.has(name)) writeAuthModeMemo(name);
455
+ got.profile = profile;
456
+ if (name === "cache-md") {
457
+ if (!process.env.E10_DATA_QUIET) {
458
+ console.error("[e10-data][warn] 凭据来源=" + got.source + ";正常应由 e10-login 提供凭据,请检查 `e10-login whoami` 是否能输出 cookies。");
459
+ }
460
+ }
461
+ return got;
462
+ }
463
+ if (lastLoginDiag) diag.push(name + ": " + lastLoginDiag);
464
+ }
465
+
466
+ throw new Error(
467
+ "未取得登录凭据(唯一来源 = weaver-work-cli,**不设回退**)。请按顺序检查:\n" +
468
+ " ① 命令是否可用:`weaver-work-cli --version`(缺失 → 安装 weaver-work-cli)\n" +
469
+ " ② 是否已登录:`weaver-work-cli auth status --no-check`\n" +
470
+ " ③ 未登录 / 登录态失效 → 执行 `weaver-work-cli auth login --base-url <域名>` 重新登录(当前 profile: " + profile + ")\n" +
471
+ (diag.length ? " 探测诊断:" + diag.join(" | ") + "\n" : "")
472
+ );
473
+ }
474
+
475
+ // ---------------- 凭据进程间 TTL 缓存(v0.1.17 新增,不落明文)----------------
476
+ // 目的:避免每次调用都重新拉起 e10-login 取凭据(去掉 shell 后仍约 0.22s/次)。
477
+ // 形态:一个 detached 的「持有者」进程把凭据**只保存在内存**里,监听本机命名管道
478
+ // (Windows)/ Unix 域套接字(0600),供后续同用户·同 profile 的 e10-data 调用取用,
479
+ // TTL(默认 300s)到期自动退出。**不写任何文件、不涉及网络。**
480
+ // 边界:
481
+ // · 任何异常(连不上/超时/JSON 坏/过期/profile 不符)一律静默回落正常取凭据路径;
482
+ // · 兜底出口(明文镜像)的凭据不会写入缓存,避免把过期凭据固化;
483
+ // · E10_DATA_NO_CRED_CACHE=1 整体关闭;E10_DATA_AUTH_MODE / E10_DATA_AUTH_PROBE 存在时绕过缓存;
484
+ // · 缓存通道名含「主目录+用户名+profile」哈希 → 不同用户/不同 profile 互不可见。
485
+ const CRED_CACHE_TTL = (() => {
486
+ const n = Number(process.env.E10_DATA_CRED_TTL);
487
+ return Number.isFinite(n) && n > 0 ? Math.floor(n) : 300;
488
+ })();
489
+
490
+ /** 缓存通道地址(只有 e10-data 与 e10-cred-helper 使用;由父进程算好后以 argv 传给持有者) */
491
+ function credCachePath() {
492
+ const { profile } = authFilePath();
493
+ const tag = crypto.createHash("sha1")
494
+ .update([os.homedir(), process.env.USERNAME || process.env.USER || "", profile].join("|"))
495
+ .digest("hex").slice(0, 16);
496
+ if (process.platform === "win32") return "\\\\.\\pipe\\e10-data-cred-" + tag;
497
+ const uid = typeof process.getuid === "function" ? process.getuid() : "n";
498
+ const dir = process.env.XDG_RUNTIME_DIR || path.join(os.homedir(), ".e10-cli");
499
+ return path.join(dir, "e10-data-cred-" + tag + "-" + uid + ".sock");
500
+ }
501
+
502
+ /** 读缓存:命中返回 auth(结构同 loadAuth 的返回值);未命中/超时/任何异常 → null */
503
+ function credCacheGet() {
504
+ const { profile } = authFilePath();
505
+ return new Promise((resolve) => {
506
+ let done = false, buf = "", sock;
507
+ const finish = (v) => {
508
+ if (done) return;
509
+ done = true;
510
+ clearTimeout(timer);
511
+ try { sock.destroy(); } catch {}
512
+ resolve(v);
513
+ };
514
+ const timer = setTimeout(() => finish(null), 1500); // 取缓存必须快;超时即当未命中
515
+ try { sock = net.connect(credCachePath()); } catch { return finish(null); }
516
+ sock.setEncoding("utf-8");
517
+ sock.on("connect", () => { try { sock.write("GET\n"); } catch { finish(null); } });
518
+ sock.on("data", (d) => {
519
+ buf += d;
520
+ if (!buf.includes("\n")) return;
521
+ try {
522
+ const j = JSON.parse(buf.split("\n")[0]);
523
+ if (!j || j.v !== 1 || Number(j.exp) < Date.now()) return finish(null);
524
+ if ((j.profile || "default") !== profile) return finish(null);
525
+ const a = normalizeAuth(Object.assign({}, j.auth, { source: "进程间凭据缓存(e10-data cred-cache)" }));
526
+ return finish(a || null);
527
+ } catch { return finish(null); }
528
+ });
529
+ sock.on("error", () => finish(null));
530
+ sock.on("close", () => finish(null));
531
+ });
532
+ }
533
+
534
+ /** 写缓存:拉起 detached 持有者,凭据经 stdin 传入(不经 argv,避免出现在进程列表里) */
535
+ function credCachePublish(auth) {
536
+ return new Promise((resolve) => {
537
+ let child;
538
+ try {
539
+ const helper = path.join(__dirname, "e10-cred-helper.mjs");
540
+ if (!fs.existsSync(helper)) return resolve();
541
+ child = spawn(process.execPath, [helper, credCachePath(), String(CRED_CACHE_TTL)], {
542
+ detached: true, stdio: ["pipe", "ignore", "ignore"], windowsHide: true,
543
+ });
544
+ } catch { return resolve(); }
545
+ const payload = JSON.stringify({
546
+ exp: Date.now() + CRED_CACHE_TTL * 1000,
547
+ profile: auth.profile || "default",
548
+ auth: {
549
+ baseUrl: auth.baseUrl, eteamsid: auth.eteamsid, cookie: auth.cookie,
550
+ userId: auth.userId, tenantKey: auth.tenantKey, source: auth.source,
551
+ },
552
+ });
553
+ let settled = false;
554
+ const done = () => { if (settled) return; settled = true; try { child.unref(); } catch {} resolve(); };
555
+ child.on("error", done);
556
+ child.stdin.on("error", done);
557
+ child.stdin.end(payload, done);
558
+ setTimeout(done, 1000); // 兜底:不让发布环节拖住主命令
559
+ });
560
+ }
561
+ // ---------------- login(唯一"不需要已有凭据"的命令族:xiaoe-env,2026-09-17 移植)----------------
562
+ // 来源:e10-login 1.6.4-xiaoe 的 `e10-login xiaoe-env [--domain <url>]`
563
+ // (lib/xiaoe.js 命令定义 + lib/login-flow.js 收尾 + scripts/xiaoe_env.py 换 token)。
564
+ // 原实现是「Node 里 spawn python scripts/xiaoe_env.py 换 ETEAMSID」,而本 CLI 是零依赖 Node,
565
+ // **不需要 python**:换 token 本身就是一次 POST,此处原生复刻(逐字段对齐:URL 拼接、请求头
566
+ // authorization、body {domain}、code 判定 '0000'/200、取 data.eteamId、错误信息取 msg)。
567
+ // 分工(与本 CLI 既有的认证边界一致,不越界):
568
+ // ① 换 token → 本 CLI 原生实现(无 python 依赖);
569
+ // ② 落 profile → 仍交 e10-login `set --eteamsid --base-url`(auth 文件由它的密钥体系加密,
570
+ // 只有它解得开;`set` 与原命令的收尾 finishLoginByEteamsId 是同一条路径:
571
+ // teamsCheck 解析用户信息 + 写 auth/config + 切换 active profile)。
572
+ // ⚠️ 本段必须在 loadAuth() 之前执行 —— 该命令存在的意义就是"还没有凭据时把凭据造出来",
573
+ // 因此它在 main() 之前拦截(见下方拦截块),不走常规 main() 分发。
574
+ const LOGIN_DEFAULT_BASE_URL = "https://www.e-cology.com.cn";
575
+ const XIAOE_ENV_ETEAMID_PATH = "/spec-apps/api/app/rpachat/skill/extend/eteamId";
576
+ const ETEAMSID_SAFE_RE = /^[A-Za-z0-9_.\-]+$/; // 透传给 e10-login 的参数白名单(该调用走 shell,防注入)
577
+
578
+ /** --domain → 当前 profile 的 config.json.baseUrl → 默认平台地址(对齐 e10-login 的 resolveBaseUrl;
579
+ * config.json 是明文,读它不涉及任何解密) */
580
+ function resolveLoginBaseUrl(optDomain) {
581
+ if (optDomain !== undefined && typeof optDomain !== "string") throw new Error("--domain 需要跟一个平台地址,如 --domain https://www.e-cology.com.cn");
582
+ let cfgBase = "";
583
+ try {
584
+ const cfgFile = path.join(path.dirname(authFilePath().file), "config.json");
585
+ if (fs.existsSync(cfgFile)) cfgBase = String(JSON.parse(fs.readFileSync(cfgFile, "utf-8")).baseUrl || "").trim();
586
+ } catch {}
587
+ return String(optDomain || cfgBase || LOGIN_DEFAULT_BASE_URL).trim().replace(/\/+$/, "");
588
+ }
589
+
590
+ /** 复刻 scripts/xiaoe_env.py:POST {XIAOE_DOMAIN}/spec-apps/api/app/rpachat/skill/extend/eteamId
591
+ * 请求头 authorization=<XIAOE_USER_TOKEN>;body {"domain": <目标平台域名>} → 取 data.eteamId */
592
+ async function xiaoeEnvFetchEteamsId(domain) {
593
+ const token = String(process.env.XIAOE_USER_TOKEN || "").trim();
594
+ const erpa = String(process.env.XIAOE_DOMAIN || "").trim();
595
+ if (!token || !erpa) {
596
+ throw new Error("缺少环境变量 XIAOE_USER_TOKEN / XIAOE_DOMAIN(请先设置后重试,如 export XIAOE_USER_TOKEN=<token> XIAOE_DOMAIN=<erpa域名>)");
597
+ }
598
+ const url = erpa.replace(/\/+$/, "") + XIAOE_ENV_ETEAMID_PATH;
599
+ let resp, text;
600
+ try {
601
+ resp = await fetch(url, {
602
+ method: "POST",
603
+ headers: { authorization: token, "content-type": "application/json", accept: "application/json" },
604
+ body: JSON.stringify({ domain }),
605
+ });
606
+ text = await resp.text();
607
+ } catch (e) {
608
+ throw new Error("请求 XiaoE 换 token 接口失败(" + url + "):" + ((e && e.message) || e));
609
+ }
610
+ let j = null;
611
+ try { j = JSON.parse(text); } catch {}
612
+ if (!j) throw new Error("XiaoE 接口返回非 JSON(HTTP " + resp.status + "):" + text.slice(0, 200));
613
+ const code = j.code;
614
+ const eteamId = String((j.data && j.data.eteamId) || "").trim();
615
+ if (!(code === "0000" || code === 200 || code === "200") || !eteamId) {
616
+ throw new Error("XiaoE 换取失败:" + (j.msg || ("HTTP " + resp.status + " code=" + code)));
617
+ }
618
+ return eteamId;
619
+ }
620
+
621
+ /** e10-data login xiaoe-env:环境变量全局 token → ETEAMSID → 写 profile(走 e10-login set 收尾) */
622
+ async function cmdLoginXiaoeEnv(args, json) {
623
+ const say = json ? console.error : console.log; // --json 时人读信息一律走 stderr,保持 stdout 只有 JSON
624
+ const baseUrl = resolveLoginBaseUrl(args.domain);
625
+ say("ℹ️ 目标平台: " + baseUrl + (args.domain ? "(--domain)" : baseUrl === LOGIN_DEFAULT_BASE_URL ? "(默认平台地址)" : "(当前 profile 的 config.json)"));
626
+
627
+ const eteamsId = await xiaoeEnvFetchEteamsId(baseUrl);
628
+ say("✅ 已取得 ETEAMSID(来源: env,不回显)");
629
+
630
+ if (!ETEAMSID_SAFE_RE.test(eteamsId)) throw new Error("换回的 ETEAMSID 含异常字符,已拒绝透传 e10-login(安全防护)");
631
+ // v0.1.17:改为数组直传 + shell:false(不再手工拼 shell 字符串、不再需要 shq 转义),
632
+ // 既省掉 cmd.exe 那一层开销,也彻底消除 shell 注入面。ETEAMSID_SAFE_RE 仍作为二道防线保留。
633
+ const r = spawnLoginCli(["set", "--eteamsid", eteamsId, "--base-url", baseUrl], 60000);
634
+ const out = (r.stdout || "").trim(), err = (r.stderr || "").trim();
635
+ if (r.error || r.status !== 0) {
636
+ throw new Error("换 token 成功,但 e10-login 写 profile 失败(auth 文件的加密写入只有 e10-login 能做):"
637
+ + (err || out || (r.error && r.error.message) || ("exit=" + r.status)));
638
+ }
639
+ for (const line of (out + (err ? "\n" + err : "")).split(/\r?\n/)) if (line.trim()) say(" [e10-login] " + line.trim());
640
+
641
+ const profile = authFilePath().profile;
642
+ // 自检:立刻用取数链路(loadAuth)把凭据读回来,确认"真能读到"而不是"只写了文件"
643
+ // (历史坑:e10-login 旧版 whoami 不带 cookies → 登录成功但 e10-data 取不到凭据;此处必须报出来)
644
+ // 强制重新探测(E10_DATA_AUTH_PROBE),并拒绝把"明文镜像兜底"当成自检通过 —— 镜像可能是旧环境的
645
+ let readBack = "";
646
+ try {
647
+ process.env.E10_DATA_AUTH_PROBE = "1";
648
+ const a = loadAuth();
649
+ const viaMirror = /镜像/.test(String(a.source || ""));
650
+ readBack = (viaMirror ? "⚠️ 仅命中明文镜像:" : "ok(") + (a.source || "?") + ";baseUrl: " + a.baseUrl + (a.userId ? ";userId: " + a.userId : "") + (viaMirror ? "" : ")");
651
+ if (viaMirror) throw new Error("凭据来自明文镜像而非新写入的 profile(镜像可能是旧环境/已过期)");
652
+ say("✅ 凭据自检通过:" + readBack);
653
+ } catch (e) {
654
+ if (!readBack) readBack = "失败: " + String((e && e.message) || e).split(/\r?\n/)[0];
655
+ throw new Error("profile 已写入 ~/.e10-cli/profiles/" + profile + "/auth,但本 CLI 读不回凭据(" + readBack
656
+ + ")—— 取数仍会失败。请确认 `e10-login whoami` 能输出 cookies(缺则升级 e10-login),或改用 `e10-login oidc`。");
657
+ }
658
+ return {
659
+ ok: true,
660
+ baseUrl,
661
+ profile,
662
+ credentialSource: "env(XIAOE_USER_TOKEN + XIAOE_DOMAIN)",
663
+ writtenBy: "e10-login set",
664
+ eteamsId: "(已写入,不回显)",
665
+ readback: readBack,
666
+ };
667
+ }
668
+
669
+ // ---- 拦截(必须早于 loadAuth()):login 族 = 唯一不需要已有凭据的命令族 ----
670
+ {
671
+ const a2 = process.argv[2] || "";
672
+ const bare = a2 === "xiaoe-env"; // 别名:与 e10-login 同名命令保持一致的肌肉记忆
673
+ if (a2 === "login" || bare) {
674
+ const sub = bare ? a2 : process.argv[3] || "";
675
+ const { args } = parseArgs(process.argv.slice(bare ? 3 : 4));
676
+ const json = !!args.json;
677
+ try {
678
+ if (sub !== "xiaoe-env") throw new Error("未知子命令 `login " + (sub || "(空)") + "`(当前仅移植 xiaoe-env)");
679
+ const r = await cmdLoginXiaoeEnv(args, json);
680
+ if (json) console.log(JSON.stringify(r, null, 1));
681
+ process.exit(0);
682
+ } catch (e) {
683
+ console.error("ERROR: " + ((e && e.message) || e));
684
+ process.exit(1);
685
+ }
686
+ }
687
+ }
688
+
689
+ // ---- 命令白名单(早期校验用;必须与下方 main() 的分发表保持一致)----
690
+ // 返回 true = 该命令存在、可以继续(可能还需要凭据);false = 未知命令,直接给用法提示。
691
+ // 为什么需要:loadAuth() 写在模块顶层,过去"未知命令"也要先付一整笔取凭据的开销
692
+ // (Windows 上 ~1.0s,去 shell 后 ~0.22s)才打印用法。v0.1.17 起提前判定。
693
+ const CMD_SUBS = {
694
+ ds: ["groups", "find", "describe", "tables", "fields", "fields-dsl", "list", "chart", "stat", "count"],
695
+ doc: ["count", "list"],
696
+ noun: ["list"],
697
+ field: ["id2name", "name2id"],
698
+ conf: ["get", "batch", "batch-file", "group", "group-file", "resolve", "resolve-file", "add", "add-file", "update", "delete"],
699
+ exp: ["status", "manifest", "commits", "file", "pull", "init", "commit", "lock", "release", "rollback"],
700
+ attend: ["statis"],
701
+ eb: ["perm-page"],
702
+ };
703
+ function commandIsKnown(pos) {
704
+ const family = pos[0], sub = pos[1];
705
+ if (!family) return false; // 无命令 → 用法
706
+ if (family === "query") return true; // query 无子命令(cmdQuery 自行处理入参)
707
+ if (family === "login" || family === "xiaoe-env") return true; // 已由上方拦截块接管
708
+ const subs = CMD_SUBS[family];
709
+ if (!subs) return false;
710
+ return subs.includes(sub);
711
+ }
712
+
713
+ // ---- 拦截(必须早于 loadAuth()):免凭据命令族 ----
714
+ // ① 版本 / ② 帮助 / ③ 未知命令 —— 这三类都不需要凭据,故在取凭据之前 return。
715
+ // (login 族由上方独立拦截块处理;exp release/rollback 的 --version 是版本号参数、长度 >1 不受影响)
716
+ {
717
+ const rawArgv = process.argv.slice(2);
718
+ const { args: earlyArgs, pos: earlyPos } = parseArgs(rawArgv);
719
+
720
+ // ① --version / -v:仅顶层单参数
721
+ if (rawArgv.length === 1 && (rawArgv[0] === "--version" || rawArgv[0] === "-v")) {
722
+ console.log("e10-data " + cliVersion());
723
+ process.exit(0);
724
+ }
725
+
726
+ // ② --help / -h:显式求帮助 → 用法 + 退出码 0(区别于"用错了"的退出码 1)
727
+ // 只认「裸开关」(值为 true),避免把 --value --help 这类取值误判为求助;
728
+ // -h 是单短横线、parseArgs 不识别,单独按顶层单参数处理
729
+ if (earlyArgs.help === true || (rawArgv.length === 1 && rawArgv[0] === "-h")) {
730
+ usage();
731
+ process.exit(0);
732
+ }
733
+
734
+ // ③ 未知命令族 / 未知子命令 / 缺子命令 → 用法提示,退出码 1
735
+ if (!commandIsKnown(earlyPos)) {
736
+ usage();
737
+ process.exit(1);
738
+ }
739
+ }
740
+
741
+ // ---- 取凭据(v0.1.17:先试进程间缓存,未命中再走 e10-login,并把结果发布给后续调用)----
742
+ let auth = null;
743
+ const CRED_CACHE_ON = !process.env.E10_DATA_NO_CRED_CACHE
744
+ && !process.env.E10_DATA_AUTH_MODE && !process.env.E10_DATA_AUTH_PROBE;
745
+ if (CRED_CACHE_ON) auth = await credCacheGet();
746
+ if (!auth) {
747
+ auth = loadAuth();
748
+ // 兜底出口(明文镜像)的凭据不写入缓存 —— 避免把可能已过期的凭据固化整个 TTL
749
+ if (CRED_CACHE_ON && !/镜像/.test(String(auth.source || ""))) await credCachePublish(auth);
750
+ }
751
+ const BASE = auth.baseUrl.replace(/\/+$/, "");
752
+ const USER = { id: auth.userId || "", tenantKey: auth.tenantKey || "" };
753
+
754
+ // ---------------- HTTP ----------------
755
+ function checkAuthFail(resp, text, j) {
756
+ if (resp.status === 401 || (j && String(j.code) === "-1" && /token|登录|失效/i.test(String(j.msg || "")))) {
757
+ const from = (auth && auth.source) || "未知";
758
+ const hint = "凭据由 weaver-work-cli 提供,请执行 `weaver-work-cli auth login --base-url <域名>` 重新登录";
759
+ throw new Error("登录态失效(" + (j.msg || resp.status) + "):凭据来源=" + from + ";" + hint);
760
+ }
761
+ }
762
+ async function postJson(endpoint, bodyObj, extraHeaders = {}) {
763
+ const resp = await fetch(BASE + endpoint, {
764
+ method: "POST",
765
+ headers: {
766
+ "Content-Type": "application/json;charset=UTF-8",
767
+ "accept": "application/json, text/plain, */*",
768
+ "langType": "zh_CN",
769
+ "cookie": auth.cookie,
770
+ "eteamsid": auth.eteamsid,
771
+ "ebbusinessid": EB_BIZ_ID,
772
+ "user-agent": UA,
773
+ "timezoneoffset": "-480",
774
+ ...extraHeaders,
775
+ },
776
+ body: JSON.stringify(bodyObj),
777
+ });
778
+ const text = await resp.text();
779
+ let j; try { j = parseJsonSafe(text); } catch { throw new Error("HTTP " + resp.status + " 非JSON响应: " + text.slice(0, 300)); }
780
+ checkAuthFail(resp, text, j);
781
+ return j;
782
+ }
783
+ async function getUrl(url) {
784
+ const resp = await fetch(BASE + url, {
785
+ method: "GET",
786
+ headers: {
787
+ "accept": "application/json, text/plain, */*",
788
+ "langType": "zh_CN",
789
+ "cookie": auth.cookie,
790
+ "eteamsid": auth.eteamsid,
791
+ "ebbusinessid": EB_BIZ_ID,
792
+ "user-agent": UA,
793
+ },
794
+ });
795
+ const text = await resp.text();
796
+ let j; try { j = parseJsonSafe(text); } catch { throw new Error("HTTP " + resp.status + " 非JSON响应: " + text.slice(0, 300)); }
797
+ checkAuthFail(resp, text, j);
798
+ return j;
799
+ }
800
+ async function postMultipart(endpoint, formObj) {
801
+ const fd = new FormData();
802
+ for (const [k, v] of Object.entries(formObj)) fd.append(k, typeof v === "string" ? v : JSON.stringify(v));
803
+ const resp = await fetch(BASE + endpoint, {
804
+ method: "POST",
805
+ headers: {
806
+ "accept": "application/json, text/plain, */*",
807
+ "langType": "zh_CN",
808
+ "cookie": auth.cookie,
809
+ "eteamsid": auth.eteamsid,
810
+ "ebbusinessid": EB_BIZ_ID,
811
+ "user-agent": UA,
812
+ "timezoneoffset": "-480",
813
+ "referer": BASE + "/sp/ebddesigner/1282331905881874436",
814
+ },
815
+ body: fd,
816
+ });
817
+ const text = await resp.text();
818
+ let j; try { j = parseJsonSafe(text); } catch { throw new Error("HTTP " + resp.status + " 非JSON响应: " + text.slice(0, 300)); }
819
+ checkAuthFail(resp, text, j);
820
+ return j;
821
+ }
822
+
823
+ // ---------------- 参数解析 ----------------
824
+ function parseArgs(argv) {
825
+ const args = {};
826
+ const pos = [];
827
+ for (let i = 0; i < argv.length; i++) {
828
+ const a = argv[i];
829
+ if (a.startsWith("--")) {
830
+ const key = a.slice(2);
831
+ const v = argv[i + 1];
832
+ if (v === undefined || v.startsWith("--")) { args[key] = true; }
833
+ else { args[key] = v; i++; }
834
+ } else pos.push(a);
835
+ }
836
+ return { args, pos };
837
+ }
838
+ // ⚠️ 大整数精度保护(2026-09-03):E10 主键/ID 多为 19 位大整数,超过 JS 安全整数 2^53-1
839
+ // (9007199254740991)。JSON.parse 会将其舍入为近似 double(如 1297901170990161977 → ...1920),
840
+ // 导致 DSL 条件查不到数据(静默空结果)或返回 ID 失真。此处把 JSON 中 >2^53 的裸数字字面量
841
+ // 加引号转字符串(后端实测接受字符串形式做 = / IN / BETWEEN 数值比较),请求与响应两侧统一保护。
842
+ function protectBigInts(raw) {
843
+ // 逐字符扫描:跟踪 JSON 字符串状态(含转义),仅在引号外的结构层把 >2^53 的裸整数加引号,
844
+ // 避免误伤字符串内容(如 avatar JSON、文档正文里含 ",大数字" 片段)。
845
+ let out = "", inStr = false, esc = false;
846
+ for (let i = 0; i < raw.length; i++) {
847
+ const ch = raw[i];
848
+ if (inStr) {
849
+ out += ch;
850
+ if (esc) esc = false;
851
+ else if (ch === "\\") esc = true;
852
+ else if (ch === '"') inStr = false;
853
+ continue;
854
+ }
855
+ if (ch === '"') { inStr = true; out += ch; continue; }
856
+ if (ch === "-" || (ch >= "0" && ch <= "9")) {
857
+ let j = i + 1;
858
+ while (j < raw.length && raw[j] >= "0" && raw[j] <= "9") j++; // 纯整数 token(无小数点/指数)
859
+ let k = i - 1;
860
+ while (k >= 0 && /\s/.test(raw[k])) k--;
861
+ const prev = k < 0 ? "" : raw[k];
862
+ let n = j;
863
+ while (n < raw.length && /\s/.test(raw[n])) n++;
864
+ const next = n < raw.length ? raw[n] : "";
865
+ const token = raw.slice(i, j);
866
+ if ((prev === ":" || prev === "," || prev === "[") && (next === "," || next === "}" || next === "]")) {
867
+ try {
868
+ const bi = BigInt(token);
869
+ if (bi > 9007199254740991n || bi < -9007199254740991n) { out += '"' + token + '"'; i = j - 1; continue; }
870
+ } catch { /* 非纯整数不转换 */ }
871
+ }
872
+ out += raw.slice(i, j);
873
+ i = j - 1;
874
+ continue;
875
+ }
876
+ out += ch;
877
+ }
878
+ return out;
879
+ }
880
+ function parseJsonSafe(raw, label) {
881
+ try { return JSON.parse(protectBigInts(raw)); }
882
+ catch (e) { throw new Error((label || "JSON") + " 解析失败: " + e.message); }
883
+ }
884
+ function readJsonArg(val, label) {
885
+ let raw = val;
886
+ if (val.startsWith("@")) raw = fs.readFileSync(val.slice(1), "utf-8");
887
+ return parseJsonSafe(raw, label);
888
+ }
889
+ function need(arg, args) { if (!args[arg]) throw new Error("缺少必填参数 --" + arg); return args[arg]; }
890
+ // 文件/JSON 参数取值:兼容 --flag <v> 与位置参数 <v>(如 conf add-file @items.json / --add-file @items.json)
891
+ function flagOrPos(args, flag, keys) {
892
+ if (args[flag] !== undefined) return args[flag];
893
+ return keys && keys.length ? keys[0] : undefined;
894
+ }
895
+
896
+ // ---------------- 展示 ----------------
897
+ function fmt(v) {
898
+ if (v === null || v === undefined) return "";
899
+ if (typeof v === "object") { const s = JSON.stringify(v); return s.length > 80 ? s.slice(0, 80) + "…" : s; }
900
+ return String(v);
901
+ }
902
+ function printHuman(title, rows, cols) {
903
+ if (title) console.log("== " + title + " ==");
904
+ if (!rows || !rows.length) { console.log("(空)"); return; }
905
+ if (cols) {
906
+ console.log(cols.join("\t"));
907
+ for (const r of rows) console.log(cols.map((c) => fmt(r[c])).join("\t"));
908
+ } else {
909
+ for (const r of rows.slice(0, 100)) console.log(JSON.stringify(r));
910
+ if (rows.length > 100) console.log("... 共 " + rows.length + " 行(--json 取全量)");
911
+ }
912
+ }
913
+
914
+ // ---------------- query:DSL 查询 ----------------
915
+ async function cmdQuery(args, json) {
916
+ const conn = need("conn", args), model = need("model", args), schema = need("schema", args);
917
+ const tableId = args["table-id"] || "";
918
+ const dslVal = need("dsl", args);
919
+ const dslQuery = dslVal.startsWith("@") ? readJsonArg(dslVal, "--dsl") : parseJsonSafe(dslVal, "--dsl");
920
+ const body = { connId: conn, model, schema, tableId, dslQuery };
921
+ if (args.debug !== undefined) { // --debug true|false,与 conn/model 同层级
922
+ const dbg = String(args.debug).toLowerCase();
923
+ if (dbg === "true" || dbg === "1") body.debug = true;
924
+ else if (dbg === "false" || dbg === "0") body.debug = false;
925
+ else throw new Error("--debug 值必须为 true 或 false(当前: " + args.debug + ")");
926
+ }
927
+ const r = await postJson("/api/dw/dsl/executeQueryDsl", body);
928
+ if (json) return r;
929
+ if (String(r.code) !== "200" && r.code !== undefined && String(r.code) !== "0") {
930
+ console.log("code=" + r.code + " msg=" + (r.msg || ""));
931
+ if (/没有权限|无权限|权限不足|未授权|无权访问|无查询权限/i.test(String(r.msg || ""))) {
932
+ throw new Error("当前用户对该数据无查询权限,请检查 ETEAMSID 是否有效或联系管理员开通");
933
+ }
934
+ return;
935
+ }
936
+ const d = r.data;
937
+ // debug=true 时 data 为 { sql, records };否则为行数组
938
+ const rows = Array.isArray(d) ? d : (d && Array.isArray(d.records) ? d.records : []);
939
+ if (d && !Array.isArray(d) && d.sql) console.log("SQL: " + d.sql);
940
+ if (!rows.length) { console.log("(空:0 行)"); return; }
941
+ printHuman("结果 " + rows.length + " 行", rows);
942
+ }
943
+
944
+ // ---------------- ds:数据源 ----------------
945
+ function dyn() {
946
+ return { eteamsId: auth.eteamsid, userId: auth.userId || "", tenantKey: auth.tenantKey || "", langId: 7, formatDate: "yyyy-MM-dd HH:mm:ss", isPublic: false };
947
+ }
948
+ function baseDto(tableId, groupId, pageNo, pageSize, queryType) {
949
+ return { tableId: String(tableId), groupId, pageNo, pageSize, queryType, containDetailTable: false, needFormat: true, dynamicParams: dyn() };
950
+ }
951
+ // 条件树(conditionTrees)协议:根/组节点 { key, relationship("1"=AND|"0"=OR), isParent:true, parentNode, conditionList:[...] }
952
+ // 叶子节点 { key, relationship:"1", isParent:false, parentNode, conditionId, conditionType, compareType, conditionValue, ... }
953
+ // v0.1.8 起 parseWhere 支持递归树(OR);向后兼容:数组=AND 根、叶子键 fieldId 优先(conditionId 兼容兜底)
954
+ function normRel(r) { return (r === "0" || r === 0 || r === false) ? "0" : "1"; }
955
+ function isCondGroup(c) { return !!c && Array.isArray(c.conditionList) && c.conditionList.length > 0; }
956
+ function buildCondNode(c, key, parentKey) {
957
+ if (isCondGroup(c)) {
958
+ return {
959
+ key, relationship: normRel(c.relationship), isParent: true, parentNode: parentKey,
960
+ mainField: c.mainField !== false,
961
+ conditionList: c.conditionList.map((k, i) => buildCondNode(k, key + "_" + i, key)),
962
+ };
963
+ }
964
+ const fid = (c && (c.fieldId || c.conditionId)) || "";
965
+ if (!fid) throw new Error("--where 条件缺少字段:叶子须提供 fieldId(或 conditionId),条件组须提供非空 conditionList");
966
+ return {
967
+ key, relationship: "1", isParent: false, parentNode: parentKey,
968
+ objId: c.objId || "", conditionId: fid, fieldName: c.fieldName || fid,
969
+ conditionType: c.conditionType || "", compareType: c.compareType, conditionValue: c.conditionValue ?? "",
970
+ fieldValue: c.fieldValue || "", mainField: c.mainField !== false, ignoreCaseMark: c.ignoreCaseMark || false,
971
+ };
972
+ }
973
+ function parseWhere(val) {
974
+ if (!val) return null;
975
+ const raw = readJsonArg(val, "--where");
976
+ if (!raw) return null;
977
+ const ts = Date.now(), rootKey = "root_" + ts;
978
+ if (Array.isArray(raw)) { // 数组 → AND 根(元素可为叶子或内嵌条件组,组自带 relationship)
979
+ const kids = [];
980
+ raw.forEach((c, i) => { if (c && typeof c === "object") kids.push(buildCondNode(c, rootKey + "_" + i, rootKey)); });
981
+ if (!kids.length) return null;
982
+ return { key: rootKey, relationship: "1", isParent: true, parentNode: "", mainField: true, conditionList: kids };
983
+ }
984
+ if (typeof raw !== "object") throw new Error("--where 须为条件数组或条件树对象");
985
+ // 对象形态 = 完整条件树({relationship, conditionList:[...]},缺省 AND);无 conditionList 视为无条件
986
+ if (!Array.isArray(raw.conditionList) || !raw.conditionList.length) return null;
987
+ return buildCondNode(raw, rootKey, "");
988
+ }
989
+ function parseStat(val) {
990
+ const arr = readJsonArg(val, "--stat");
991
+ const list = Array.isArray(arr) ? arr : [arr];
992
+ return list.map((s) => ({ fieldId: s.fieldId, fieldName: s.fieldId, objId: "", mainField: true, aggregator: s.aggregator || "SUM", ...(s.fieldAlias ? { fieldAlias: s.fieldAlias } : {}) }));
993
+ }
994
+ function parseOrder(val) {
995
+ if (!val) return [];
996
+ return val.split(",").map((s) => {
997
+ const [f, dir] = s.trim().split(/\s+/);
998
+ return { fieldId: f, fieldName: f, objId: "", isAsc: (dir || "asc").toLowerCase() !== "desc", mainField: true };
999
+ });
1000
+ }
1001
+ // ---------------- ds appId:一律显式传入(v0.1.14,2026-09-11 用户确认调整)----------------
1002
+ // 背景:ChatBI 数据源取数接口(getListDatas / getChartDatas / getStatDatas / getCountData)
1003
+ // 对部分 EB 应用(独立部署)的数据集必须携带 queryDto.appId,否则后端一律返回 code=500。
1004
+ // 设计:appId 属「权限校验」的产物 —— 调用方先查 dw.chat_bi_eb_config_map 判权限(无权限即终止),
1005
+ // 同一条记录的 app_id 即本次取数必须携带的 appId,再显式传入本 CLI:
1006
+ // → CLI 不再自行反查 appId(v0.1.13 的「chat_bi_eb_config_map → ebdf_obj 自动解析」已整体移除),
1007
+ // 避免把权限表当普通字典使用、也避免 appId 来源不可见;
1008
+ // → 仅 EB 建模表单(groupId = weaver-ebuilder-form-service)需要 appId,其它数据集可不传;
1009
+ // → 不传也**不做拦截**,由后端按原样报错。
1010
+ function dsFieldsForm(gid, tid, st) {
1011
+ return { groupId: gid, sourceId: tid, sourceType: st, detailFieldsGroup: "true", enableMerge: "true", needTableName: "true", params: "[]" };
1012
+ }
1013
+ // ---- ds listByPage 端点(唯一):/api/dw/newetl/chatBi/ds/listByPage ----
1014
+ // ⚠️ 该端点**当前会忽略 name**(传 name/tableName/keyword/title 等任意字段名,都返回该分组全量,实测 8531 条)。
1015
+ // 后端后续会升级支持 name 过滤(用户 2026-09-24 确认)——升级后 `ds find` / `ds tables --name` **无需改代码**
1016
+ // 即自动恢复服务端过滤能力(本封装与分页逻辑已按「total 为过滤后总数」的语义写好)。
1017
+ // 在此之前,`ds find` 走「分页抓取 + 本地过滤」,未命中只代表「未在已扫描窗口内命中」。
1018
+ const DS_LB_PATH = "/api/dw/newetl/chatBi/ds/listByPage";
1019
+ async function dsListByPage(form) {
1020
+ return postMultipart(DS_LB_PATH, form);
1021
+ }
1022
+ // ds 取数统一入口:appId 只认显式 --app-id <id>
1023
+ // (--app-id auto / --no-app-id 一律视为未传:v0.1.14 起不再自动解析,也不做缺失拦截)
1024
+ async function dsDto(args, tid, gid, pageNo, pageSize, queryType) {
1025
+ const dto = baseDto(tid, gid, pageNo, pageSize, queryType);
1026
+ const appId = args["app-id"];
1027
+ if (typeof appId === "string" && appId !== "" && appId !== "auto") dto.appId = appId;
1028
+ return dto;
1029
+ }
1030
+ async function cmdTables(args, json) {
1031
+ const gid = need("group", args);
1032
+ const form = { name: args.name || "", pageNo: args.page || "1", pageSize: args.size || "100", groupId: gid, sourceType: args["source-type"] || "LOGIC", dataUsage: "" };
1033
+ if (typeof args["app-id"] === "string" && args["app-id"] !== "" && args["app-id"] !== "auto") form.appId = args["app-id"];
1034
+ const r = await dsListByPage(form);
1035
+ if (json) return r;
1036
+ const recs = ((r.data || {}).records) || [];
1037
+ printHuman("分组表清单 (total=" + ((r.data || {}).total) + ")", recs, ["id", "name", "appId", "tableText", "detailTable", "usage"]);
1038
+ }
1039
+ async function cmdFields(args, json) {
1040
+ const gid = need("group", args), tid = need("table", args);
1041
+ const form = dsFieldsForm(gid, tid, args["source-type"] || "LOGIC");
1042
+ if (typeof args["app-id"] === "string" && args["app-id"] !== "" && args["app-id"] !== "auto") form.appId = args["app-id"];
1043
+ const r = await postMultipart("/api/ebuilder/common/ds/fields", form);
1044
+ if (json) return r;
1045
+ for (const d of (r.data || [])) {
1046
+ console.log("== 表: " + d.name + " (id=" + d.id + ") 字段 " + (d.fields || []).length + " 个 ==");
1047
+ for (const f of (d.fields || [])) console.log([f.name, f.text, f.type, f.mainPrimaryKey ? "PK" : ""].filter(Boolean).join("\t"));
1048
+ }
1049
+ }
1050
+ // fieldsForDSL:参数与返回结构同 cmdFields,仅接口换为 /api/dw/newetl/chatBi/ds/fieldsForDSL
1051
+ async function cmdFieldsForDSL(args, json) {
1052
+ const gid = need("group", args), tid = need("table", args);
1053
+ const form = dsFieldsForm(gid, tid, args["source-type"] || "LOGIC");
1054
+ if (typeof args["app-id"] === "string" && args["app-id"] !== "" && args["app-id"] !== "auto") form.appId = args["app-id"];
1055
+ const r = await postMultipart("/api/dw/newetl/chatBi/ds/fieldsForDSL", form);
1056
+ if (json) return r;
1057
+ for (const d of (r.data || [])) {
1058
+ console.log("== 表: " + d.name + " (id=" + d.id + ") 字段 " + (d.fields || []).length + " 个 ==");
1059
+ for (const f of (d.fields || [])) console.log([f.name, f.text, f.type, f.mainPrimaryKey ? "PK" : ""].filter(Boolean).join("\t"));
1060
+ }
1061
+ }
1062
+ async function cmdList(args, json) {
1063
+ const gid = need("group", args), tid = need("table", args), fld = need("fields", args);
1064
+ const fields = fld.split(",").map((f) => ({ fieldId: f, fieldName: f, objId: String(tid), mainField: true }));
1065
+ const dto = await dsDto(args, tid, gid, parseInt(args.page || "1", 10), parseInt(args.size || "20", 10), "getListData");
1066
+ dto.queryFields = fields;
1067
+ dto.orderFields = parseOrder(args.order);
1068
+ const ct = parseWhere(args.where); if (ct) dto.conditionTrees = ct;
1069
+ dto.queryCount = true;
1070
+ const r = await postMultipart("/api/dw/newetl/chatBi/ds/getListDatas", { sourceType: args["source-type"] || "LOGIC", queryDto: dto });
1071
+ if (json) return r;
1072
+ const data = r.data || {};
1073
+ console.log("== 数据列表 count=" + data.count + " 行=" + (data.list || []).length + " ==");
1074
+ printHuman("", data.list || [], fields.map((f) => f.fieldId));
1075
+ }
1076
+ async function cmdChart(args, json) {
1077
+ const gid = need("group", args), tid = need("table", args), gb = need("group-by", args);
1078
+ const dto = await dsDto(args, tid, gid, parseInt(args.page || "1", 10), parseInt(args.size || "100", 10), "getChartData");
1079
+ dto.groupFields = gb.split(",").map((f) => ({ fieldId: f, fieldName: f, objId: String(tid), mainField: true }));
1080
+ dto.groupStatFields = parseStat(need("stat", args)).map((s) => ({ ...s, objId: String(tid) }));
1081
+ const ct = parseWhere(args.where); if (ct) dto.conditionTrees = ct;
1082
+ const r = await postMultipart("/api/dw/newetl/chatBi/ds/getChartDatas", { sourceType: args["source-type"] || "LOGIC", queryDto: dto });
1083
+ if (json) return r;
1084
+ printHuman("分组聚合 rows=" + (r.data || []).length, r.data || []);
1085
+ }
1086
+ async function cmdStat(args, json) {
1087
+ const gid = need("group", args), tid = need("table", args);
1088
+ const dto = await dsDto(args, tid, gid, 1, 10, "getStatData");
1089
+ dto.statFields = parseStat(need("stat", args)).map((s) => ({ ...s, objId: String(tid) }));
1090
+ const ct = parseWhere(args.where); if (ct) dto.conditionTrees = ct;
1091
+ const r = await postMultipart("/api/dw/newetl/chatBi/ds/getStatDatas", { sourceType: args["source-type"] || "LOGIC", queryDto: dto });
1092
+ if (json) return r;
1093
+ const data = r.data || {};
1094
+ if (data.fields && data.fields.length) { for (const f of data.fields) console.log(JSON.stringify(f)); }
1095
+ else console.log("(无统计结果)");
1096
+ }
1097
+ async function cmdCount(args, json) {
1098
+ const gid = need("group", args), tid = need("table", args);
1099
+ const dto = await dsDto(args, tid, gid, 1, 10, "getCountData");
1100
+ const ct = parseWhere(args.where); if (ct) dto.conditionTrees = ct;
1101
+ const r = await postMultipart("/api/dw/newetl/chatBi/ds/getCountData", { sourceType: args["source-type"] || "LOGIC", queryDto: dto });
1102
+ if (json) return r;
1103
+ const data = r.data || {};
1104
+ console.log("totalCount = " + (data.totalCount ?? "-"));
1105
+ }
1106
+
1107
+ // ================= ds 发现层聚合:groups / find / describe(2026-09-24 新增)=================
1108
+ // 背景:一次「表单业务词」问数原本要串行跑 5 跳(定分组 → 找表 → 取字段 → 查词库 → 查权限),
1109
+ // 每跳都是一次独立 CLI 调用。实测单次调用固定开销 ~5.4s,其中「凭据现取」~4.8s 且
1110
+ // **与请求条数/返回体量无关**(取 48 条 vs 取 1 条:5.5s vs 5.8s)⇒ 5 跳 ≈ 27s;
1111
+ // 若逐进程扫 58 个分组,串行累计约 313s。
1112
+ // 故把「找表」「字段+词库」各收敛成 1 次 CLI 调用,**在进程内并发发 HTTP**(一次凭据搞定)。
1113
+ // 实测同样的全分组扇出:进程内并发 ≈5.3~5.9s(逐进程 47.5s / 逐进程串行 ~313s)。
1114
+ //
1115
+ // ⚠️ 权限闸门不在本命令范围:`ds/listByPage` 虽然也回带 appId,但**数据源通道不拦权限**
1116
+ // (实测:未授权表在数据源上照样能取到数据)。拿 listByPage 的 appId 跳过校验 = 闸门静默失效。
1117
+ // ⇒ EB 建模表单取数前**必须单独**执行 `e10-data eb perm-page`,appId 只从那次调用取。
1118
+
1119
+ // 分组清单(技能文档 data-source-query.md 1.1 的 58 个)。**仅作 ds find 的「默认扫描候选集」**,
1120
+ // 不参与任何取数参数推导。分组随环境模块安装情况增减,故支持 --groups 显式覆盖。
1121
+ const DS_GROUPS = [
1122
+ [1, "业务表单", "weaver-formreport-service", "form"],
1123
+ [2, "流程", "weaver-workflow-report-serviceworkflowreport", "workflow"],
1124
+ [3, "任务", "weaver-project-servicetask", "task"],
1125
+ [4, "日志", "weaver-elog-service", "elog"],
1126
+ [5, "会议", "weaver-meeting-service", "meeting"],
1127
+ [6, "日报", "weaver-blog-service", "blog"],
1128
+ [7, "OKR", "weaver-wr-goal-service", "goal"],
1129
+ [8, "流程列表", "weaver-workflow-list-service", "workflow"],
1130
+ [9, "考勤", "weaver-attend-service", "attend"],
1131
+ [10, "CRM", "weaver-crm-service", "customer"],
1132
+ [11, "组织画像", "weaver-portrait-service", "portrait"],
1133
+ [12, "工作流报告", "weaver-workflow-report-serviceworkflow_report", "workflow"],
1134
+ [13, "绩效", "weaver-wr-performance-service", "kpi"],
1135
+ [14, "项目", "weaver-project-service", "mainline"],
1136
+ [15, "基础定时模块", "weaver-basic-schedule-service", "escheduler"],
1137
+ [16, "知识画像", "weaver-doc-service", "document"],
1138
+ [17, "HRM", "weaver-hrm-service", "hrm"],
1139
+ [18, "e-builder报表", "weaver-edcreportd-service", null],
1140
+ [19, "e-builder应用", "weaver-ebuilder-app-service", "ebuilder"],
1141
+ [20, "e-builder表单", "weaver-ebuilder-form-service", "ebuilder"],
1142
+ [21, "日程", "weaver-calendar-service", "calendar"],
1143
+ [22, "文档", "weaver-doc-servicedocument", "document"],
1144
+ [23, "文件", "weaver-file-service", "file"],
1145
+ [24, "公文管理", "weaver-odoc-service", "odoc"],
1146
+ [25, "SCRM", "weaver-crm-market-service", "scrm"],
1147
+ [26, "协作区", "weaver-cowork-service", "cowork"],
1148
+ [27, "数据源", "weaver-datasource-service", "datasource"],
1149
+ [28, "计划报告", "weaver-wr-plan-service", "workreport"],
1150
+ [29, "电子签", "weaver-signcenter-service", "signcontract"],
1151
+ [30, "表单加密", "weaver-datasecurity", "datasecurity"],
1152
+ [31, "人事", "weaver-hr-service", null],
1153
+ [32, "动作流", "weaver-esb-setting-serviceesbCustom", "esb"],
1154
+ [33, "发票云", "weaver-inc-biz-service", "inc"],
1155
+ [34, "绩效核算", "weaver-ebuilder-contract-servicecmdatauFJXHS", "ebuilder"],
1156
+ [35, "EB合同管理", "weaver-ebuilder-contract-service", null],
1157
+ [36, "待办事项", "weaver-my-service", null],
1158
+ [37, "签名", "weaver-signature-service", null],
1159
+ [38, "邮件", "weaver-mail-base-service", "email"],
1160
+ [39, "薪酬", "weaver-hrm-salary", "hrmsalary"],
1161
+ [40, "系统安全", "weaver-security-framework-service", "security"],
1162
+ [41, "公文交换中心", "weaver-odocexchange-service", "odocexchange"],
1163
+ [42, "招聘管理", "weaver-recruit-service", "recruit"],
1164
+ [43, "微搜", "weaver-esearch-search-service", null],
1165
+ [44, "统一审批中心", "weaver-intunifytodo-server-config-service", "intunifytodos"],
1166
+ [45, "公共数据源", "weaver-component-web-service", "common"],
1167
+ [46, "数据分析", "weaver-analyze-service", "analyze"],
1168
+ [47, "财务模块", "weaver-fna-expense-servicefexs", "fna"],
1169
+ [48, "ESB连接器", "weaver-esb-setting-serviceesbConnect", "esb"],
1170
+ [49, "AI连接器", "weaver-ai-application-service", "ai"],
1171
+ [50, "外部流程", "weaver-workflow-core-serviceworkflowCoreOuterwf", "workflow"],
1172
+ [51, "绩效考核", "weaver-wr-performance-eb-service", "ebuilder"],
1173
+ [52, "问题定位平台", "weaver-tissot-service", "tissot"],
1174
+ [53, "工作流基础效率", "weaver-workflow-report-serviceworkflowEffReport", "workflow"],
1175
+ [54, "工作流操作者效率", "weaver-workflow-report-serviceworkflowEffOperateReport", "workflow"],
1176
+ [55, "工作流节点效率", "weaver-workflow-report-serviceworkflowEffNodeReport", "workflow"],
1177
+ [56, "规则引擎", "weaver-logicflow-service", "logicflow"],
1178
+ [57, "工作流表单", "weaver-workflow-report-serviceworkflowFormReport", "workflow"],
1179
+ [58, "统一认证中心", "weaver-intunifyauth-server-base-service", null],
1180
+ ];
1181
+
1182
+ // 必探的核心分组(技能文档「分组探查强制范围」:e-builder 表单 + 业务表单 必探;流程类业务词也常落在流程分组)
1183
+ const DS_CORE_GROUPS = [
1184
+ "weaver-formreport-service",
1185
+ "weaver-ebuilder-form-service",
1186
+ "weaver-workflow-report-serviceworkflowreport",
1187
+ ];
1188
+ // 语义命中规则:只是「关键词 → 分组名片段」的启发式,命中即并入扫描集(不做任何取数参数推导)
1189
+ const DS_FIND_SEMANTIC_RULES = [
1190
+ { tag: "考勤/假期", re: /考勤|打卡|出勤|请假|假期|年假|事假|病假|加班|外勤|工时|调休|补卡|申诉/, groups: ["weaver-attend-service", "weaver-hr-service"] },
1191
+ { tag: "流程/审批", re: /流程|审批|待办|已办|发起|抄送|传阅|征询|签字|节点|报销|申请单/, groups: ["weaver-workflow-report-serviceworkflowreport", "weaver-workflow-list-service"] },
1192
+ { tag: "日报/周报", re: /日报|周报|月报|年报|工作总结/, groups: ["weaver-blog-service", "weaver-wr-plan-service"] },
1193
+ { tag: "文档/附件", re: /文档|附件|文件夹|共享|资料/, groups: ["weaver-doc-servicedocument", "weaver-doc-service", "weaver-file-service"] },
1194
+ { tag: "项目/任务", re: /项目|任务|工时|里程碑|延期|验收/, groups: ["weaver-project-service", "weaver-project-servicetask"] },
1195
+ { tag: "客户/商机", re: /客户|商机|线索|联系人|公海|销售|CRM|营销/i, groups: ["weaver-crm-service", "weaver-crm-market-service"] },
1196
+ { tag: "会议/日程", re: /会议|日程|日历|预约|会议室|预定/, groups: ["weaver-meeting-service", "weaver-calendar-service"] },
1197
+ { tag: "人事/招聘", re: /招聘|入职|离职|offer|人才|员工|人员|通讯录|调岗|组织/i, groups: ["weaver-recruit-service", "weaver-hr-service", "weaver-hrm-service"] },
1198
+ { tag: "薪酬/绩效", re: /薪酬|薪资|工资|社保|公积金|绩效|考核|KPI|OKR/i, groups: ["weaver-hrm-salary", "weaver-wr-performance-service", "weaver-wr-goal-service"] },
1199
+ { tag: "合同/收款", re: /合同|签约|收款|回款|开票|发票|应收/, groups: ["weaver-ebuilder-contract-service", "weaver-ebuilder-contract-servicecmdatauFJXHS", "weaver-inc-biz-service"] },
1200
+ { tag: "邮件/公文", re: /邮件|收件箱|公文|发文|档案|借阅/, groups: ["weaver-mail-base-service", "weaver-odoc-service"] },
1201
+ { tag: "资产/日志", re: /资产|台账|日志|动作流|自动化/, groups: ["weaver-elog-service", "weaver-esb-setting-serviceesbCustom"] },
1202
+ ];
1203
+
1204
+ function dsGroupLabel(gid) { const g = DS_GROUPS.find((x) => x[2] === gid); return g ? g[1] : null; }
1205
+ function dsGroupMark(gid) { const g = DS_GROUPS.find((x) => x[2] === gid); return g ? g[3] : null; }
1206
+
1207
+ /** 进程内并发闸门:一次凭据、N 个 HTTP 并发(把「N 次 CLI 调用」压成 1 次调用的核心) */
1208
+ async function mapLimit(items, limit, fn) {
1209
+ const out = new Array(items.length);
1210
+ let next = 0;
1211
+ const workers = Array.from({ length: Math.max(1, Math.min(limit, items.length)) }, async () => {
1212
+ for (;;) {
1213
+ const i = next++;
1214
+ if (i >= items.length) return;
1215
+ out[i] = await fn(items[i], i);
1216
+ }
1217
+ });
1218
+ await Promise.all(workers);
1219
+ return out;
1220
+ }
1221
+
1222
+ async function cmdDsGroups(args, json) {
1223
+ const pat = args.pattern ? String(args.pattern) : null;
1224
+ const groups = DS_GROUPS
1225
+ .filter((g) => !pat || String(g[2]).includes(pat) || String(g[1]).includes(pat))
1226
+ .map((g) => ({ groupId: g[2], label: g[1], serviceMark: g[3] }));
1227
+ if (json) {
1228
+ return {
1229
+ count: groups.length,
1230
+ groups,
1231
+ coreGroups: DS_CORE_GROUPS,
1232
+ semanticRules: DS_FIND_SEMANTIC_RULES.map((r) => ({ tag: r.tag, groups: r.groups })),
1233
+ note: "内置分组清单,仅用于 ds find 的默认扫描候选集;分组随环境变动时用 --groups 显式覆盖",
1234
+ };
1235
+ }
1236
+ printHuman("内置分组清单 " + groups.length + " 个", groups, ["groupId", "label", "serviceMark"]);
1237
+ }
1238
+
1239
+ /** 选扫描集:--groups 显式 > --all-groups 全量 > 核心分组 + 语义命中 */
1240
+ function pickFindGroups(args, keyword) {
1241
+ if (args.groups) {
1242
+ return { mode: "explicit", groups: String(args.groups).split(",").map((s) => s.trim()).filter(Boolean) };
1243
+ }
1244
+ if (args["all-groups"]) return { mode: "all", groups: DS_GROUPS.map((g) => g[2]) };
1245
+ const max = Math.max(parseInt(args["max-groups"] || "6", 10) || 6, DS_CORE_GROUPS.length);
1246
+ const picked = [];
1247
+ const push = (gid) => { if (gid && !picked.includes(gid)) picked.push(gid); };
1248
+ for (const gid of DS_CORE_GROUPS) push(gid);
1249
+ for (const r of DS_FIND_SEMANTIC_RULES) if (r.re.test(keyword)) for (const gid of r.groups) push(gid);
1250
+ return { mode: "core", groups: picked.slice(0, max) };
1251
+ }
1252
+
1253
+ /** 进程内并发扫分组(每分组 1~N 页 listByPage)。
1254
+ * ⚠️ 端点当前忽略 name ⇒ 抓回来的是「全量窗口」,关键词过滤由调用方在本地做;
1255
+ * 窗口大小 = pageSize × pages,**未命中只代表未落在窗口内**(后端支持 name 后即为精确结果)。
1256
+ * ⚠️ appId:EB 分组(weaver-ebuilder-form-service)**按应用分域**,需要时透传 */
1257
+ async function dsScanGroups(gidList, keyword, sourceType, pageSize, concurrency, appId, pages) {
1258
+ return mapLimit(gidList, concurrency, async (gid) => {
1259
+ const out = { groupId: gid, label: dsGroupLabel(gid), serviceMark: dsGroupMark(gid), records: [], error: null, total: null, pagesFetched: 0 };
1260
+ try {
1261
+ for (let p = 1; p <= pages; p++) {
1262
+ const form = { name: keyword, pageNo: String(p), pageSize: String(pageSize), groupId: gid, sourceType, dataUsage: "" };
1263
+ if (appId) form.appId = String(appId);
1264
+ const r = await dsListByPage(form);
1265
+ const recs = ((r.data || {}).records) || [];
1266
+ if (out.total === null) out.total = (r.data || {}).total ?? null; // 后端过滤生效时 = 过滤后总数
1267
+ out.records = out.records.concat(recs);
1268
+ out.pagesFetched++;
1269
+ if (recs.length < pageSize) break; // 已到末页
1270
+ }
1271
+ } catch (e) { out.error = String((e && e.message) || e); }
1272
+ return out;
1273
+ });
1274
+ }
1275
+
1276
+ // 同一实体(如一个流程)会在多个「报表/效率」分组里重复出现 → 归并去重,并保留落选分组做溯源
1277
+ function dsGroupPriority(gid) {
1278
+ if (gid === "weaver-ebuilder-form-service") return 100;
1279
+ if (gid === "weaver-formreport-service") return 95;
1280
+ if (gid === "weaver-workflow-report-serviceworkflowreport") return 90;
1281
+ if (/workflowEff|workflowFormReport|workflow_report$/i.test(gid)) return 20;
1282
+ return 50;
1283
+ }
1284
+ function mergeDsCandidates(scanResults, limit) {
1285
+ const map = new Map();
1286
+ for (const r of scanResults) {
1287
+ for (const rec of r.records) {
1288
+ const gid = rec.groupId || r.groupId;
1289
+ const name = String(rec.originalName || rec.tableText || rec.name || "").trim();
1290
+ const key = name.replace(/\s+/g, "").toLowerCase() || gid + "|" + rec.id;
1291
+ const item = {
1292
+ groupId: gid, groupLabel: r.label, serviceMark: rec.serviceMark ?? r.serviceMark ?? null,
1293
+ tableId: String(rec.id ?? ""), name: String(rec.tableText || rec.name || name), originalName: name,
1294
+ objType: rec.objType ?? null, physicalTable: rec.physicalTable ?? null, appId: rec.appId ?? null,
1295
+ isPhysical: rec.isPhysical ?? null, detailTable: !!rec.detailTable, usage: rec.usage ?? null,
1296
+ sourceType: rec.sourceType ?? null, alsoInGroups: [], _pri: dsGroupPriority(gid),
1297
+ };
1298
+ const prev = map.get(key);
1299
+ if (!prev) { map.set(key, item); continue; }
1300
+ const win = prev._pri >= item._pri ? prev : item;
1301
+ const lose = prev._pri >= item._pri ? item : prev;
1302
+ win.alsoInGroups = win.alsoInGroups.concat(
1303
+ [{ groupId: lose.groupId, groupLabel: lose.groupLabel, tableId: lose.tableId, serviceMark: lose.serviceMark }],
1304
+ lose.alsoInGroups || [],
1305
+ );
1306
+ map.set(key, win);
1307
+ }
1308
+ }
1309
+ const list = [...map.values()].sort((a, b) => b._pri - a._pri || String(a.originalName).localeCompare(String(b.originalName)));
1310
+ const n = parseInt(limit || "0", 10) || 0;
1311
+ return (n > 0 ? list.slice(0, n) : list).map((x) => { const { _pri, ...rest } = x; return rest; });
1312
+ }
1313
+ // 路由建议:命中所在的分组分布本身就是通道信号(比“先定通道再找表”更先验)
1314
+ function dsRouteHint(cands) {
1315
+ const has = (fn) => cands.some(fn);
1316
+ if (has((c) => /workflow/i.test(c.groupId) || c.serviceMark === "workflow")) {
1317
+ return { channel: "WORKFLOW", desc: "WORKFLOW 模块配置通道 —— 流程类问题一律走模块接口,不用 DSL,也不走数据源", reason: "命中流程类分组" };
1318
+ }
1319
+ if (has((c) => c.groupId === "weaver-ebuilder-form-service" || c.serviceMark === "ebuilder")) {
1320
+ return { channel: "DS_EB", desc: "数据源通道(EB 建模表单)—— 取数前必须先过 eb perm-page 权限校验,appId 取自那次校验", reason: "命中 EB 分组" };
1321
+ }
1322
+ if (has((c) => c.serviceMark === "form")) {
1323
+ return { channel: "DS_FORM", desc: "数据源通道(业务表单 / 流程业务表单)", reason: "命中业务表单分组" };
1324
+ }
1325
+ if (has((c) => c.serviceMark === "task" || c.serviceMark === "mainline")) return { channel: "PROJECT/PMO", desc: "项目类:PMO 指标走 business/project-mgmt + DSL;明细/下拉选项走 PROJECT 模块", reason: "命中项目分组" };
1326
+ if (has((c) => c.serviceMark === "attend")) return { channel: "EHR", desc: "EHR 模块配置通道(考勤/人事)", reason: "命中考勤分组" };
1327
+ if (has((c) => c.serviceMark === "blog")) return { channel: "BLOG_DAILY", desc: "BLOG_DAILY 模块配置通道(日报)", reason: "命中日报分组" };
1328
+ if (has((c) => c.serviceMark === "document")) return { channel: "DOCUMENT", desc: "DOCUMENT 模块配置通道(文档)", reason: "命中文档分组" };
1329
+ if (has((c) => c.serviceMark === "customer" || c.serviceMark === "scrm")) return { channel: "CRM", desc: "CRM 模块配置通道(客户/商机/线索)", reason: "命中 CRM 分组" };
1330
+ if (has((c) => c.serviceMark === "esb")) return { channel: "ESB", desc: "动作流(ESB)—— 无标准问数通道,需人工确认", reason: "仅命中动作流分组" };
1331
+ return { channel: "DS", desc: "数据源通道(ds list / chart / stat / count)", reason: "通用" };
1332
+ }
1333
+
1334
+ async function cmdDsFind(args, json) {
1335
+ const keyword = String(args.name || args.keyword || "").trim();
1336
+ if (!keyword) throw new Error("缺少必填参数 --name <关键词>");
1337
+ const sourceType = args["source-type"] || "LOGIC";
1338
+ // 端点忽略 name ⇒ 抓取窗口 = --size × --pages(后端支持 name 后,total 即为过滤后总数、pages 语义不变)
1339
+ const pageSize = Math.min(Math.max(parseInt(args.size || "100", 10) || 100, 1), 2000);
1340
+ const pages = Math.min(Math.max(parseInt(args.pages || "1", 10) || 1, 1), 50);
1341
+ const concurrency = Math.max(1, parseInt(args.concurrency || "10", 10) || 10);
1342
+ const appId = (typeof args["app-id"] === "string" && args["app-id"] !== "" && args["app-id"] !== "auto") ? args["app-id"] : null;
1343
+ const picked = pickFindGroups(args, keyword);
1344
+
1345
+ const t0 = Date.now();
1346
+ let scan = await dsScanGroups(picked.groups, keyword, sourceType, pageSize, concurrency, appId, pages);
1347
+ // 后端 name 搜索是宽松的:实测部分分组会忽略 name 直接返回全量、或按描述命中 ⇒ 本地再按「表名包含关键词」过一遍。
1348
+ // ⚠️ 端点忽略 name ⇒ 关键词过滤当前**只能在本地做**(见文件头 DS_LB_PATH 注释)。
1349
+ // 落选项**不静默丢弃**,计数与样例放在 nameFilter 里;--loose 可完全关闭该过滤(保守用法)。
1350
+ const kwNorm = keyword.replace(/\s+/g, "").toLowerCase();
1351
+ const looseSample = [];
1352
+ let looseDropped = 0;
1353
+ let rowsScanned = 0;
1354
+ const applyStrict = (rows) => rows.map((r) => {
1355
+ rowsScanned += r.records.length;
1356
+ if (args.loose) return r;
1357
+ const keep = [], drop = [];
1358
+ for (const rec of r.records) {
1359
+ const nm = String(rec.originalName || rec.tableText || rec.name || "").replace(/\s+/g, "").toLowerCase();
1360
+ (nm.includes(kwNorm) ? keep : drop).push(rec);
1361
+ }
1362
+ looseDropped += drop.length;
1363
+ for (const d of drop.slice(0, 3)) {
1364
+ if (looseSample.length < 6) looseSample.push({ groupId: r.groupId, name: String(d.originalName || d.tableText || d.name || "") });
1365
+ }
1366
+ return { ...r, records: keep };
1367
+ });
1368
+ scan = applyStrict(scan);
1369
+ let escalated = false;
1370
+ // 默认小集合全 miss → 自动升级为全分组扫一次(语义规则没覆盖到时兜底;全扫也是进程内并发)
1371
+ // ⚠️ 端点忽略 name 时「miss」不代表表不存在(只是没落进窗口)⇒ 窗口较大时不再自动全扫,避免 58 组 × N 页的请求风暴
1372
+ const escalateOk = pages * pageSize <= 2000;
1373
+ if (!scan.some((r) => r.records.length) && picked.mode === "core" && !args["no-escalate"] && escalateOk) {
1374
+ escalated = true;
1375
+ const seen = new Set(picked.groups);
1376
+ const extra = DS_GROUPS.map((g) => g[2]).filter((gid) => !seen.has(gid));
1377
+ scan = scan.concat(applyStrict(await dsScanGroups(extra, keyword, sourceType, pageSize, concurrency, appId, pages)));
1378
+ }
1379
+ const elapsedMs = Date.now() - t0;
1380
+
1381
+ const okScan = scan.filter((r) => !r.error);
1382
+ const errs = scan.filter((r) => r.error);
1383
+ // 全部失败且像登录态问题 → 直接抛(不要把 401 伪装成“没找到表”)
1384
+ if (!okScan.length && errs.length && errs.some((e) => /登录态失效|401/.test(e.error))) {
1385
+ throw new Error("登录态失效:请重新登录 weaver-work-cli(`weaver-work-cli auth login --base-url <域名>`)后重试(本次未取到任何分组)");
1386
+ }
1387
+ const candidates = mergeDsCandidates(scan, args.limit);
1388
+ const out = {
1389
+ keyword, sourceType, scanMode: picked.mode + (escalated ? "+escalated" : ""),
1390
+ scannedGroups: scan.length, hitGroups: scan.filter((r) => r.records.length).length,
1391
+ escalated, elapsedMs, concurrency,
1392
+ candidates, routeHint: dsRouteHint(candidates),
1393
+ // 端点忽略 name:必须让调用方知道「未命中 ≠ 表不存在」
1394
+ nameFilter: args.loose
1395
+ ? { serverSide: false, filtered: false, pages, pageSize, rowsScanned, note: "--loose:未做本地过滤,直接采用后端返回" }
1396
+ : {
1397
+ serverSide: false, filtered: true, pages, pageSize, rowsScanned, dropped: looseDropped, sample: looseSample,
1398
+ note: "端点 /api/dw/newetl/chatBi/ds/listByPage **当前忽略 name**(后端待升级)⇒ 本次为「抓 " + pages + " 页 × " + pageSize + " 行 + 本地按表名过滤」,共比对 " + rowsScanned + " 行;"
1399
+ + "**未命中只代表未落在该窗口内,不等于该表不存在**(若该分组行数 > " + (pages * pageSize) + " 请调大 --size/--pages)。后端支持 name 后自动恢复精确过滤,无需改调用方式。",
1400
+ },
1401
+ scopeNote: scan.some((r) => r.groupId === "weaver-ebuilder-form-service")
1402
+ ? (appId
1403
+ ? "本次已带 --app-id=" + appId + "(EB 分组按应用分域,用于限定/对齐应用范围)"
1404
+ : "⚠️ 本次未带 --app-id:EB 分组(weaver-ebuilder-form-service)按应用分域,如需限定某个应用请带 --app-id <该应用ID>")
1405
+ : null,
1406
+ errors: errs.map((e) => ({ groupId: e.groupId, error: e.error })),
1407
+ };
1408
+ if (json) return out;
1409
+
1410
+ console.log("== 找表「" + keyword + "」扫描 " + out.scannedGroups + " 组 / 抓取 " + rowsScanned + " 行 / 命中 " + out.hitGroups + " 组 (" + elapsedMs + "ms" + (escalated ? ",已升级全扫" : "") + ") ==");
1411
+ if (!candidates.length) {
1412
+ console.log("(未命中) ⚠️ 端点当前忽略 name,本次是「抓 " + pages + " 页 × " + pageSize + " 行 + 本地过滤」比对 " + rowsScanned + " 行;");
1413
+ console.log(" **未命中 ≠ 该表不存在** —— 该分组行数若超过 " + (pages * pageSize) + ",请调大 --size(≤2000)/--pages(≤50)再试;");
1414
+ console.log(" 也可显式指定 --groups a,b / --all-groups;分组清单见 ds groups。");
1415
+ } else {
1416
+ for (const c of candidates) {
1417
+ const extra = c.alsoInGroups && c.alsoInGroups.length ? " [同实体亦见于: " + c.alsoInGroups.map((x) => x.groupId).join(", ") + "]" : "";
1418
+ console.log([c.groupId, c.tableId, c.originalName || c.name, c.physicalTable ? "物理表=" + c.physicalTable : "", c.appId ? "appId=" + c.appId : ""].filter(Boolean).join("\t") + extra);
1419
+ }
1420
+ }
1421
+ console.log("路由建议: " + out.routeHint.channel + " —— " + out.routeHint.desc);
1422
+ if (out.scopeNote) console.log("范围提示: " + out.scopeNote);
1423
+ if (errs.length) console.log("⚠ 有 " + errs.length + " 个分组请求失败(--json 看详情)");
1424
+ }
1425
+
1426
+ // describe:一次并发取回「字段 + 词库」(两者入参同为 (groupId, tableId),彼此无依赖)
1427
+ // ⚠️ 本命令**不做权限校验** —— EB 建模表单取数前的权限闸门仍必须**单独**执行 `e10-data eb perm-page`
1428
+ // (appId 只从那次调用取)。原因:数据源通道本身不拦权限,权限判定与取数必须分开。
1429
+ function collectFieldNodes(list, out, subName) {
1430
+ for (const f of list || []) {
1431
+ if (!f) continue;
1432
+ if (f.isDetailtable) {
1433
+ const node = { subTableName: f.name, fields: [] };
1434
+ collectFieldNodes(f.fields, node.fields, f.name);
1435
+ out.push({ __detail: true, ...node });
1436
+ } else {
1437
+ out.push({
1438
+ name: f.name, text: f.text ?? null, type: f.type ?? null, dbFieldType: f.dbFieldType ?? null,
1439
+ isPK: !!f.mainPrimaryKey, subTable: subName || null,
1440
+ dataKey: (f.config && f.config.dataKey) || null,
1441
+ formTableId: (f.config && f.config.formTableId) || null,
1442
+ });
1443
+ }
1444
+ }
1445
+ }
1446
+ function normalizeFieldsResp(r) {
1447
+ const node = (r.data || [])[0] || {};
1448
+ const flat = [];
1449
+ collectFieldNodes(node.fields, flat, null);
1450
+ const main = flat.filter((x) => !x.__detail);
1451
+ const detail = flat.filter((x) => x.__detail).map((x) => ({ subTableName: x.subTableName, fieldCount: x.fields.length, fields: x.fields }));
1452
+ return {
1453
+ tableName: node.name ?? null, tableId: node.id ?? null,
1454
+ mainCount: main.length, detailCount: detail.reduce((a, b) => a + b.fieldCount, 0), main, detail,
1455
+ };
1456
+ }
1457
+ // 关联字段提取「表来源判定」的预判:groupId/serviceMark 已隐含表来源大类,不必再花 1~6 次 DSL 去反推
1458
+ function dsRelatedHint(gid, detailCount) {
1459
+ if (gid === "weaver-ebuilder-form-service") {
1460
+ return { likelyApplicable: true, tableOrigin: "eb", note: "EB 表:按 ebdf_obj ⋈ form_table ⋈ form 三表 JOIN 定位,再用 form_field / form_table_field 提关联字段;明细子表走 sub_form(本表明细字段 " + detailCount + " 个)" };
1461
+ }
1462
+ if (/^weaver-workflow/.test(gid)) return { likelyApplicable: true, tableOrigin: "flow", note: "流程相关表:按 ec_weaver_wfp_fusion 的 form_field / form_table_field 提关联字段" };
1463
+ if (gid === "weaver-formreport-service") {
1464
+ return { likelyApplicable: false, tableOrigin: "form-report", note: "业务表单(formreport):表名是中文显示名、非物理表名 ⇒ relate-fields-extract 的 ①ETL ②SQL视图 ③④EB ⑤⑥流程 六类判定全部不适用,该跳必然空转,**不要发起**" };
1465
+ }
1466
+ return { likelyApplicable: false, tableOrigin: "unknown", note: "非 EB / 非流程表,关联字段提取通常不适用;如确需先按表名形态(sv_ / uf_ / 系统表名)人工确认" };
1467
+ }
1468
+
1469
+ async function cmdDsDescribe(args, json) {
1470
+ const gid = need("group", args);
1471
+ const tid = String(need("table", args));
1472
+ const sourceType = args["source-type"] || "LOGIC";
1473
+ const t0 = Date.now();
1474
+
1475
+ const tasks = [
1476
+ (async () => {
1477
+ try {
1478
+ const form = dsFieldsForm(gid, tid, sourceType);
1479
+ if (typeof args["app-id"] === "string" && args["app-id"] !== "" && args["app-id"] !== "auto") form.appId = args["app-id"];
1480
+ return { key: "fields", value: normalizeFieldsResp(await postMultipart("/api/ebuilder/common/ds/fields", form)) };
1481
+ } catch (e) { return { key: "fields", error: String((e && e.message) || e) }; }
1482
+ })(),
1483
+ (async () => {
1484
+ const skillKey = args.skill || "data-source-query";
1485
+ try {
1486
+ const q = new URLSearchParams({ skillKey, sourceType, sourceGroupId: gid, sourceTableId: tid });
1487
+ const r = await getUrl("/api/dw/newetl/chatBi/skill/getChatDocsBySkillKey?" + q.toString());
1488
+ const data = r.data;
1489
+ const missing = !data || !Array.isArray(data.nouns);
1490
+ return {
1491
+ key: "lexicon",
1492
+ value: {
1493
+ skillKey, sourceType, sourceGroupId: gid, sourceTableId: tid,
1494
+ noLexicon: missing, count: missing ? 0 : data.nouns.length,
1495
+ nouns: missing ? [] : data.nouns.map((n) => ({ keyword: n.keyword ?? null, synonyms: n.synonyms ?? null, wordExplain: n.wordExplain ?? null })),
1496
+ note: missing
1497
+ ? "该数据集无词库(后端:" + (r.msg || "未返回 nouns") + ")—— 四参数均取自本次调用,故为**确定结论**;按铁律如实说明『该数据集暂无名词解释』,不自动建库、不新增名词"
1498
+ : "词库命中:须先把每条规则逐条映射到 --where / --fields / --group-by / --stat 并自检无遗漏,再生成查询",
1499
+ },
1500
+ };
1501
+ } catch (e) { return { key: "lexicon", error: String((e && e.message) || e) }; }
1502
+ })(),
1503
+ ];
1504
+
1505
+ const settled = await Promise.all(tasks);
1506
+ const out = { groupId: gid, groupLabel: dsGroupLabel(gid), tableId: tid, sourceType, branches: settled.length };
1507
+ const errors = [];
1508
+ for (const s of settled) {
1509
+ if (s.error) { out[s.key] = { error: s.error }; errors.push({ branch: s.key, error: s.error }); }
1510
+ else out[s.key] = s.value;
1511
+ }
1512
+ out.relatedFields = dsRelatedHint(gid, (out.fields && out.fields.detailCount) || 0);
1513
+ out.channelHint = dsRouteHint([{ groupId: gid, serviceMark: dsGroupMark(gid) }]);
1514
+ // ⚠️ 权限闸门**不在本命令范围**:EB 建模表单取数前必须另行执行 `e10-data eb perm-page`(appId 只从那次校验取)
1515
+ out.permissionNote = gid === "weaver-ebuilder-form-service"
1516
+ ? "该分组是 EB 建模表单:取数前必须另行执行 `e10-data eb perm-page --obj-id <tableId> --form-table <物理表名>` 判权限并取 appId(--form-table 用 ds find / ds tables 返回的 physicalTable)"
1517
+ : "非 EB 分组,数据源通道无需 eb perm-page 前置校验";
1518
+ out.elapsedMs = Date.now() - t0;
1519
+ if (errors.length && args.require) throw new Error("describe 存在失败分支,--require 下不继续:" + JSON.stringify(errors));
1520
+ if (json) return out;
1521
+
1522
+ const f = out.fields || {};
1523
+ console.log("== describe " + gid + " / " + tid + " (" + out.elapsedMs + "ms,并发 " + settled.length + " 路) ==");
1524
+ if (f.error) console.log("字段: 失败 " + f.error);
1525
+ else console.log("字段: 主表 " + f.mainCount + " 个,明细 " + f.detailCount + " 个(表名 " + (f.tableName ?? "-") + ")");
1526
+ const lx = out.lexicon || {};
1527
+ if (lx.error) console.log("词库: 失败 " + lx.error);
1528
+ else console.log("词库: " + (lx.noLexicon ? "该数据集无词库" : "命中 " + lx.count + " 条(skill=" + lx.skillKey + ")"));
1529
+ console.log("关联字段: " + (out.relatedFields.likelyApplicable ? "适用(" + out.relatedFields.tableOrigin + ")" : "不适用(" + out.relatedFields.tableOrigin + ")") + " —— " + out.relatedFields.note);
1530
+ console.log("通道提示: " + out.channelHint.channel + " —— " + out.channelHint.desc);
1531
+ console.log("权限: 不在本命令范围 —— " + out.permissionNote);
1532
+ }
1533
+
1534
+ // ---------------- eb:EB 建模表单权限校验 ----------------
1535
+ // chatBi EB 配置映射分页查询:EB 表权限前置校验的 CLI 通道
1536
+ // (替代原「DSL 查 dw.chat_bi_eb_config_map + 客户端拼 (all_scop=1 OR target_id=本人)」写法)
1537
+ // 服务端已按登录人过滤:有权限 → total>=1 且行内回带 appId;无权限 / 表不存在 → total=0
1538
+ const EB_PERM_PAGE_PATH = "/api/dw/newetl/chatBi/getChatBiEbConfigMapByPage";
1539
+ async function cmdEbPermPage(args, json) {
1540
+ // ⚠️ obj-id / app-id 为 19 位大整数:必须原样字符串传递。
1541
+ // 经 JS Number 会舍入(840360795890851856 → 840360795890851840),实测后端接受字符串,
1542
+ // 但吃下舍入后的数字会返回 total=0 —— 把「有权限」静默伪装成「无权限」。
1543
+ const objId = String(need("obj-id", args));
1544
+ const formTable = String(need("form-table", args));
1545
+ // ⚠️ obj-id / form-table 缺失时后端不是报错,而是 HTTP 200 + 无 actionMsg + data 空,
1546
+ // 外观与「无权限」完全一致 → 必须用 need() 在本地拦掉(need 对空串同样报错)。
1547
+ const body = {
1548
+ objId,
1549
+ formTable,
1550
+ pageNum: parseInt(args.page || "1", 10),
1551
+ pageSize: parseInt(args.size || "10", 10),
1552
+ };
1553
+ if (typeof args["app-id"] === "string" && args["app-id"] !== "") body.app_id = String(args["app-id"]);
1554
+ const r = await postJson(EB_PERM_PAGE_PATH, body);
1555
+ if (json) return r;
1556
+ // ⚠️ 失败判定:非法入参时 actionMsg 缺失(code=undefined),业务失败时 code!==0 → 一并按失败输出
1557
+ const am = r.actionMsg || {};
1558
+ if (am.code !== 0) { console.log("code=" + am.code + " msg=" + (am.message || "")); return; }
1559
+ const d = r.data || {};
1560
+ const recs = d.records || [];
1561
+ const total = d.total === undefined || d.total === null ? 0 : d.total;
1562
+ const first = recs[0] || null;
1563
+ console.log("total = " + total);
1564
+ printHuman("", recs, ["id", "objId", "formTable", "appId", "allScop", "targetId", "enabled", "deleteType"]);
1565
+ console.log(total >= 1
1566
+ ? (first
1567
+ ? "结论: 有权限(appId=" + first.appId + ")"
1568
+ : "结论: 有权限(total>=1;当前页无记录,appId 请用 --page 1 获取)")
1569
+ : "结论: 无权限(total=0)");
1570
+ if (args.require && total < 1) throw new Error("没有查询该 EB 表的权限(total=0)");
1571
+ }
1572
+
1573
+ // ---------------- doc:文档查询 ----------------
1574
+ function docBaseBody(pageNo, pageSize) {
1575
+ return {
1576
+ curMenuType: "", limitedFolderIncludeSub: "", limitedFolderIds: "", docAppConsole: "",
1577
+ viewType: "DOC_PAGINATION", pageNo, pageSize, morePageId: "", dimension: "ALL", includeBelongFolders: true,
1578
+ order: { property: "default", direction: "DESC" }, searchType: "all", conditions: [], customConditions: [],
1579
+ userId: auth.userId || "", current: pageNo,
1580
+ columnOrders: [{ orderKey: "default", orderType: "" }], getColumn: false, quickSearchType: "normal",
1581
+ withFullSearch: false, fetchFullSearchParam: true,
1582
+ };
1583
+ }
1584
+ function buildDocConditions(args) {
1585
+ const conds = [];
1586
+ if (args.name) conds.push({ id: "name", type: "string", value: args.name });
1587
+ if (args.creator) conds.push({ id: "creator", operate: args["creator-neq"] ? "neq" : "eq", type: "array", value: args.creator });
1588
+ if (args.author) conds.push({ id: "author", operate: args["author-neq"] ? "neq" : "eq", type: "array", value: args.author });
1589
+ if (args["content-type"]) conds.push({ id: "contentType", operate: "eq", type: "array", value: args["content-type"] });
1590
+ if (args.time) conds.push({ id: "create_time", type: "date", value: args.time });
1591
+ if (args["last-time"]) conds.push({ id: "last_update_time", type: "date", value: args["last-time"] });
1592
+ if (args.folder) conds.push({ id: "folder", operate: "eq", type: "folder", value: args.folder });
1593
+ if (args["folder-neq"]) conds.push({ id: "folder", operate: "neq", type: "folder", value: args["folder-neq"] });
1594
+ return conds;
1595
+ }
1596
+ async function cmdDocCount(args, json) {
1597
+ const body = docBaseBody(1, parseInt(args.size || "20", 10));
1598
+ body.conditions = buildDocConditions(args);
1599
+ const r = await postJson("/api/doc/documents/documentListCount", body);
1600
+ if (json) return r;
1601
+ const data = r.data || {};
1602
+ console.log("total = " + (data.total ?? "-"));
1603
+ }
1604
+ async function cmdDocList(args, json) {
1605
+ const pageSize = parseInt(args.size || "100", 10);
1606
+ const body = docBaseBody(1, pageSize);
1607
+ body.conditions = buildDocConditions(args);
1608
+ const r1 = await postJson("/api/doc/documents/documentListCount", body);
1609
+ if (json) return r1;
1610
+ const total = (r1.data || {}).total || 0;
1611
+ if (!total) { console.log("total = 0(未查询到符合条件的文档)"); return; }
1612
+ const totalPages = Math.ceil(total / pageSize);
1613
+ const maxPages = args.all ? totalPages : Math.min(parseInt(args.page || "1", 10), totalPages);
1614
+ const startPage = args.all ? 1 : parseInt(args.page || "1", 10);
1615
+ const allRows = [];
1616
+ for (let pno = startPage; pno <= maxPages; pno++) {
1617
+ const b = docBaseBody(pno, pageSize);
1618
+ b.conditions = buildDocConditions(args);
1619
+ const rr = await postJson("/api/doc/documents/documentList", b);
1620
+ const rows = ((rr.data || {}).data) || [];
1621
+ for (const row of rows) if (!allRows.some((x) => x.id === row.id)) allRows.push(row);
1622
+ if (!args.all && pno === maxPages) break;
1623
+ }
1624
+ console.log("== 文档列表 total=" + total + " 本批=" + allRows.length + " 行 ==");
1625
+ printHuman("", allRows, ["id", "name", "docCreatorName", "createTime", "folder"]);
1626
+ }
1627
+
1628
+ // ---------------- noun:词库 ----------------
1629
+ async function cmdNoun(args, json) {
1630
+ const skill = need("skill", args);
1631
+ const q = new URLSearchParams({ skillKey: skill });
1632
+ if (args["source-type"]) q.append("sourceType", args["source-type"]);
1633
+ if (args["source-group"]) q.append("sourceGroupId", args["source-group"]);
1634
+ if (args["source-table"]) q.append("sourceTableId", args["source-table"]);
1635
+ const r = await getUrl("/api/dw/newetl/chatBi/skill/getChatDocsBySkillKey?" + q.toString());
1636
+ if (json) return r;
1637
+ const data = r.data;
1638
+ if (!data || !data.nouns) {
1639
+ console.log("(该 skill 词库不存在或为空) msg=" + (r.msg || ""));
1640
+ return;
1641
+ }
1642
+ console.log("== 名词解释 " + data.nouns.length + " 条 (skill=" + skill + ") ==");
1643
+ for (const n of data.nouns) {
1644
+ console.log("【" + (n.keyword || "") + "】" + (n.synonyms ? " 同义:" + n.synonyms : ""));
1645
+ if (n.wordExplain) console.log(" " + String(n.wordExplain).replace(/\n/g, " "));
1646
+ }
1647
+ }
1648
+
1649
+ // ---------------- field:关联字段翻译 ----------------
1650
+ function customParamFromArgs(args) {
1651
+ if (args["form-param"]) return { formParam: args["form-param"] };
1652
+ if (args["eb-browser-param"]) return { ebBrowserForEditParam: readJsonArg(args["eb-browser-param"], "--eb-browser-param") };
1653
+ if (args["property-type"]) return { propertyType: args["property-type"] };
1654
+ return {};
1655
+ }
1656
+ function extractRemoteRows(r, browserType) {
1657
+ const remote = r.remote || {};
1658
+ const keys = Object.keys(remote);
1659
+ const out = [];
1660
+ for (const k of keys) {
1661
+ if (Array.isArray(remote[k])) for (const it of remote[k]) out.push({ key: k, id: it.id, content: it.content ?? it.name ?? "" });
1662
+ }
1663
+ return out;
1664
+ }
1665
+ async function cmdFieldId2name(args, json) {
1666
+ const module = need("module", args), type = need("type", args), idsVal = need("ids", args);
1667
+ const dataIds = idsVal.split(",").map((s) => s.trim()).filter(Boolean);
1668
+ const body = [{ module, browserType: type, dataIds, user: USER, customParam: customParamFromArgs(args) }];
1669
+ const r = await postJson("/api/bcw/test/testRemoteBrowser", body);
1670
+ if (json) return r;
1671
+ const rows = extractRemoteRows(r, type);
1672
+ if (!rows.length) {
1673
+ const nf = (r.notFound || {});
1674
+ const list = nf && nf[module + "_" + type] && nf[module + "_" + type].ids ? nf[module + "_" + type].ids : [];
1675
+ console.log("(未翻译到名称) notFound ids: " + (list.join(",") || "全部"));
1676
+ return;
1677
+ }
1678
+ printHuman("id→name (" + rows.length + "/" + dataIds.length + ")", rows, ["id", "content"]);
1679
+ }
1680
+ async function cmdFieldName2id(args, json) {
1681
+ const module = need("module", args), type = need("type", args), name = need("name", args);
1682
+ const body = { newly: 1, searchValue: name, ...customParamFromArgs(args) };
1683
+ const url = "/api/" + module + "/common/browser/complete/" + type;
1684
+ const r = await postJson(url, body);
1685
+ if (json) return r;
1686
+ const rows = ((r.data || {}).data) || [];
1687
+ if (!rows.length) { console.log("(未匹配到名称对应 ID)"); return; }
1688
+ printHuman("name→id 候选 " + rows.length + " 条", rows.map((x) => ({ id: x.id, name: x.name ?? x.title ?? "" })), ["id", "name"]);
1689
+ }
1690
+
1691
+ // ---------------- conf:环境参数配置中心(chatBi config map) ----------------
1692
+ // 覆盖 config-resolve 原 resolve_config.py 的 URL 直连调用:get/batch/batch-file/group/group-file/resolve/resolve-file
1693
+ // 与管理 add/add-file/update/delete。认证复用进程内登录缓存(完整 Cookie + eteamsid,同 query/ds/doc),
1694
+ // 无内置令牌;baseUrl 取登录缓存(与 executeQueryDsl 同域)。confType 默认由 key 前缀推导
1695
+ // (CONN_→conn / DS_→ds / WF_→wf / API_→api),可用 --type 显式指定任意大类(如 data-source-query / system-config)。
1696
+ //
1697
+ // v0.1.9 confGroup(配置分组,可选):
1698
+ // 后端在 confType=api 时支持按分组二次分层(如 api/workflow、api/hr),定位维度变为
1699
+ // 「confType + confGroup + confKey」。本 CLI 统一用 --group 传分组:
1700
+ // · confType 为 api(或显式传了 --group)时,新增/更新/删除/单查 才下发 confGroup 字段,
1701
+ // 其他 confType 一律不带该字段,避免污染既有(无分组列)的配置类型。
1702
+ // · conf group <confType> <confGroup> 走 batchGetChatBiConfigMapByTypeAndGroup 按类型+分组批量取回。
1703
+ const CONF_PREFIX_TYPE = [["CONN_", "conn"], ["DS_", "ds"], ["WF_", "wf"], ["API_", "api"]];
1704
+ const CONF_GET_PATH = "/api/dw/newetl/chatBi/getChatBiConfigMap";
1705
+ const CONF_BATCH_PATH = "/api/dw/newetl/chatBi/batchGetChatBiConfigMap";
1706
+ const CONF_BATCH_GROUP_PATH = "/api/dw/newetl/chatBi/batchGetChatBiConfigMapByTypeAndGroup";
1707
+ const CONF_ADD_PATH = "/api/dw/newetl/chatBi/batchAddChatBiConfigMap";
1708
+ const CONF_UPDATE_PATH = "/api/dw/newetl/chatBi/updateChatBiConfigMap";
1709
+ const CONF_DELETE_PATH = "/api/dw/newetl/chatBi/deleteChatBiConfigMap";
1710
+ const CONF_TOKEN_RE = /#\{([A-Z][A-Z0-9_]{2,})\}/g;
1711
+ function confKeyType(key, explicitType) {
1712
+ if (explicitType) return explicitType;
1713
+ for (const [p, t] of CONF_PREFIX_TYPE) if (key.startsWith(p)) return t;
1714
+ throw new Error("无法由 confKey 推导 confType(需 CONN_/DS_/WF_/API_ 前缀或显式 --type): " + key);
1715
+ }
1716
+ // confGroup 仅在 confType=api 或调用方显式指定时下发(见上方说明)
1717
+ function confUseGroup(ctype, group) {
1718
+ return ctype === "api" || (group !== undefined && group !== null && group !== "");
1719
+ }
1720
+ function confPutGroup(body, ctype, group) {
1721
+ if (confUseGroup(ctype, group)) body.confGroup = group === undefined || group === null ? "" : String(group);
1722
+ return body;
1723
+ }
1724
+ function confGroupSuffix(ctype, group) { // GET/DELETE 用的 query 后缀
1725
+ return confUseGroup(ctype, group) ? "&confGroup=" + encodeURIComponent(group === undefined || group === null ? "" : String(group)) : "";
1726
+ }
1727
+ function confItemId(it) { // 同 confKey 可分布在多个 confGroup,带分组时用「组|键」做映射键,避免互相覆盖
1728
+ const g = it && it.confGroup;
1729
+ return g ? g + "|" + (it.confKey || "") : ((it && it.confKey) || "");
1730
+ }
1731
+ function confLookup(map, it) { // 兼容后端回包是否带 confGroup:先按「组|键」取,再退化到 confKey
1732
+ return map[confItemId(it)] || map[it.confKey];
1733
+ }
1734
+ function confFail(j, url) {
1735
+ const m = (j && (j.msg || (j.actionMsg && j.actionMsg.message))) || "";
1736
+ throw new Error("配置服务请求失败 [" + url + "]" + (m ? ": " + m : ""));
1737
+ }
1738
+ async function confRequest(method, url, bodyObj) {
1739
+ const resp = await fetch(BASE + url, {
1740
+ method,
1741
+ headers: {
1742
+ "accept": "application/json, text/plain, */*",
1743
+ "langType": "zh_CN",
1744
+ "cookie": auth.cookie,
1745
+ "eteamsid": auth.eteamsid,
1746
+ "ebbusinessid": EB_BIZ_ID,
1747
+ "user-agent": UA,
1748
+ ...(bodyObj !== undefined ? { "Content-Type": "application/json;charset=UTF-8" } : {}),
1749
+ },
1750
+ ...(bodyObj !== undefined ? { body: JSON.stringify(bodyObj) } : {}),
1751
+ });
1752
+ const text = await resp.text();
1753
+ let j; try { j = parseJsonSafe(text); } catch { throw new Error("HTTP " + resp.status + " 非JSON响应: " + text.slice(0, 300)); }
1754
+ checkAuthFail(resp, text, j);
1755
+ return j;
1756
+ }
1757
+ async function confGetData(ctype, key, silent, group) {
1758
+ const url = CONF_GET_PATH + "?confType=" + encodeURIComponent(ctype) + "&confKey=" + encodeURIComponent(key) + confGroupSuffix(ctype, group);
1759
+ let r;
1760
+ try { r = await confRequest("GET", url); }
1761
+ catch (e) { if (silent) return null; throw e; }
1762
+ const data = (r && r.data) || null;
1763
+ if (!silent && (!data || data.confValue === undefined || data.confValue === null || data.confValue === "")) {
1764
+ throw new Error("配置不存在或为空: confType=" + ctype + (group ? " confGroup=" + group : "") + " confKey=" + key);
1765
+ }
1766
+ return data;
1767
+ }
1768
+ function confNormalizeBatch(payload) {
1769
+ const data = (payload && payload.data) || null;
1770
+ const out = {};
1771
+ const put = (row, fallbackKey) => {
1772
+ if (!row || typeof row !== "object") return;
1773
+ const k = row.confKey || row.conf_key || fallbackKey;
1774
+ if (!k) return;
1775
+ if (row.confGroup === undefined) { const g = row.conf_group; if (g !== undefined && g !== null && g !== "") row.confGroup = g; }
1776
+ out[confItemId({ confGroup: row.confGroup, confKey: k })] = row;
1777
+ };
1778
+ if (Array.isArray(data)) for (const row of data) put(row);
1779
+ else if (data && typeof data === "object") for (const [k, v] of Object.entries(data)) put(v, k);
1780
+ return out;
1781
+ }
1782
+ // 按类型+分组批量取回(batchGetChatBiConfigMapByTypeAndGroup),payload = [{confType, confGroup}...]
1783
+ async function confBatchByTypeAndGroup(pairs) {
1784
+ const payload = pairs.map((p) => ({ confType: p.confType, confGroup: p.confGroup === undefined || p.confGroup === null ? "" : String(p.confGroup) }));
1785
+ const r = await confRequest("POST", CONF_BATCH_GROUP_PATH, payload);
1786
+ return confNormalizeBatch(r);
1787
+ }
1788
+ async function cmdConfGet(args, json, keys) {
1789
+ const key = keys[0];
1790
+ if (!key) throw new Error("conf get 缺少 confKey(用法: conf get <key> [--type t] [--group g])");
1791
+ const ctype = confKeyType(key, args.type);
1792
+ let data;
1793
+ try {
1794
+ data = await confGetData(ctype, key, false, args.group);
1795
+ } catch (e) {
1796
+ if (ctype === "api" && !args.group) {
1797
+ throw new Error(e.message + "(confType=api 建议用 --group 指定分组,如 --group workflow;或用 conf group api <分组> 查看该分组下全部配置)");
1798
+ }
1799
+ throw e;
1800
+ }
1801
+ if (json) return data;
1802
+ console.log(JSON.stringify(data, null, 1));
1803
+ }
1804
+ async function cmdConfBatch(args, json, keys) {
1805
+ if (!keys.length) throw new Error("conf batch 缺少 confKey 列表(用法: conf batch <key1> [key2 ...] [--type t] [--group g])");
1806
+ const items = keys.map((k) => { const t = confKeyType(k, args.type); return confPutGroup({ confType: t, confKey: k }, t, args.group); });
1807
+ let map = {};
1808
+ try {
1809
+ const r = await confRequest("POST", CONF_BATCH_PATH, items);
1810
+ map = confNormalizeBatch(r);
1811
+ for (const it of items) {
1812
+ const c = confLookup(map, it);
1813
+ if (!c || c.confValue === undefined || c.confValue === null || c.confValue === "") {
1814
+ throw new Error("批量响应缺失 confKey=" + it.confKey);
1815
+ }
1816
+ }
1817
+ } catch (e) {
1818
+ if (String(e.message).indexOf("批量响应缺失") === 0) throw e;
1819
+ console.error("[config-resolve] 批量接口不可用,降级逐条单查: " + e.message);
1820
+ map = {};
1821
+ for (const it of items) map[confItemId(it)] = await confGetData(it.confType, it.confKey, false, it.confGroup);
1822
+ }
1823
+ if (json) return map;
1824
+ printHuman("配置 " + items.length + " 条", keys.map((k) => {
1825
+ const it = items.find((x) => x.confKey === k);
1826
+ const c = confLookup(map, it) || {};
1827
+ return { key: k, confType: it.confType, confGroup: it.confGroup ?? "", confValue: c.confValue ?? "", confName: c.confName ?? "" };
1828
+ }), ["key", "confType", "confGroup", "confValue", "confName"]);
1829
+ }
1830
+ async function cmdConfBatchFile(args, json, keys) {
1831
+ const val = flagOrPos(args, "batch-file", keys);
1832
+ if (val === undefined) throw new Error("conf batch-file 缺少参数(用法: conf batch-file @items.json)");
1833
+ const items = readJsonArg(String(val), "--batch-file");
1834
+ const list = Array.isArray(items) ? items : [items];
1835
+ const map = {};
1836
+ const meta = {};
1837
+ for (const it of list) {
1838
+ if (!it.confKey || !it.confType) continue;
1839
+ const id = confItemId(it);
1840
+ map[id] = await confGetData(it.confType, it.confKey, false, it.confGroup);
1841
+ meta[id] = it;
1842
+ }
1843
+ if (json) return map;
1844
+ printHuman("配置 " + Object.keys(map).length + " 条", Object.keys(map).map((id) => ({ key: meta[id].confKey, confGroup: meta[id].confGroup ?? "", confValue: (map[id] || {}).confValue ?? "", confName: (map[id] || {}).confName ?? "" })), ["key", "confGroup", "confValue", "confName"]);
1845
+ }
1846
+ // 按类型+分组批量查(batchGetChatBiConfigMapByTypeAndGroup):pairs = [{confType, confGroup}...]
1847
+ async function cmdConfGroup(args, json, keys) {
1848
+ const ctype = keys[0] || args.type;
1849
+ const group = keys[1] !== undefined ? keys[1] : args.group;
1850
+ if (!ctype) throw new Error("conf group 缺少 confType(用法: conf group <confType> <confGroup> | --type api --group workflow)");
1851
+ if (group === undefined) throw new Error("conf group 缺少 confGroup(用法: conf group <confType> <confGroup>)");
1852
+ const map = await confBatchByTypeAndGroup([{ confType: ctype, confGroup: group }]);
1853
+ const rows = Object.values(map);
1854
+ const onlyKey = args.key;
1855
+ const filtered = onlyKey ? rows.filter((r) => (r.confKey || r.conf_key) === onlyKey) : rows;
1856
+ if (json) return onlyKey ? (filtered[0] ?? null) : filtered;
1857
+ if (onlyKey && !filtered.length) throw new Error("配置不存在: confType=" + ctype + " confGroup=" + group + " confKey=" + onlyKey);
1858
+ printHuman("confType=" + ctype + " confGroup=" + group + " 配置 " + filtered.length + " 条",
1859
+ filtered.map((r) => ({ confKey: r.confKey || r.conf_key || "", confValue: r.confValue ?? "", confName: r.confName ?? "", confAttrs: r.confAttrs ?? "" })),
1860
+ ["confKey", "confValue", "confName", "confAttrs"]);
1861
+ return filtered;
1862
+ }
1863
+ async function cmdConfGroupFile(args, json, keys) {
1864
+ const val = flagOrPos(args, "group-file", keys);
1865
+ if (val === undefined) throw new Error("conf group-file 缺少参数(用法: conf group-file @items.json,形如 [{\"confType\":\"api\",\"confGroup\":\"workflow\"}])");
1866
+ const pairs = readJsonArg(String(val), "--group-file");
1867
+ const list = (Array.isArray(pairs) ? pairs : [pairs]).filter((p) => p && p.confType);
1868
+ if (!list.length) throw new Error("conf group-file 内容为空或缺少 confType");
1869
+ const map = await confBatchByTypeAndGroup(list);
1870
+ const rows = Object.values(map);
1871
+ if (json) return map;
1872
+ printHuman("批量分组配置 " + rows.length + " 条",
1873
+ rows.map((r) => ({ confType: r.confType ?? "", confGroup: r.confGroup ?? "", confKey: r.confKey || r.conf_key || "", confValue: r.confValue ?? "", confName: r.confName ?? "" })),
1874
+ ["confType", "confGroup", "confKey", "confValue", "confName"]);
1875
+ return map;
1876
+ }
1877
+ async function confResolveAll(keys, group) {
1878
+ // 多占位符:先批量预取一轮(失败降级逐条单查),返回 {key: data}
1879
+ const items = keys.map((k) => confPutGroup({ confType: confKeyType(k, ""), confKey: k }, confKeyType(k, ""), group));
1880
+ const map = {};
1881
+ try {
1882
+ const r = await confRequest("POST", CONF_BATCH_PATH, items);
1883
+ const got = confNormalizeBatch(r);
1884
+ for (const it of items) {
1885
+ const c = confLookup(got, it);
1886
+ if (!c || c.confValue === undefined || c.confValue === null || c.confValue === "") throw new Error("缺失 " + it.confKey);
1887
+ map[it.confKey] = c;
1888
+ }
1889
+ } catch {
1890
+ for (const it of items) map[it.confKey] = await confGetData(it.confType, it.confKey, false, it.confGroup);
1891
+ }
1892
+ return map;
1893
+ }
1894
+ async function confResolveText(text, json, group) {
1895
+ const found = [...new Set([...text.matchAll(CONF_TOKEN_RE)].map((m) => m[1]))];
1896
+ const map = found.length ? await confResolveAll(found, group) : {};
1897
+ const out = text.replace(CONF_TOKEN_RE, (m0, k) => {
1898
+ const cfg = map[k];
1899
+ if (!cfg) throw new Error("配置不存在或为空: " + k);
1900
+ return cfg.confValue ?? "";
1901
+ });
1902
+ if (json) return { input: text, output: out, resolved: Object.fromEntries(Object.entries(map).map(([k, v]) => [k, v.confValue ?? ""])) };
1903
+ return out;
1904
+ }
1905
+ async function cmdConfResolve(args, json, keys) {
1906
+ let text = keys[0];
1907
+ if (!text) throw new Error("conf resolve 缺少文本(用法: conf resolve '<含#{KEY}文本>' | @file.txt [--group g])");
1908
+ if (text.startsWith("@")) text = fs.readFileSync(text.slice(1), "utf-8");
1909
+ const out = await confResolveText(text, json, args.group);
1910
+ if (json) { console.log(JSON.stringify(out, null, 1)); return out; }
1911
+ process.stdout.write(out + "\n");
1912
+ }
1913
+ async function cmdConfResolveFile(args, json, keys) {
1914
+ const p = flagOrPos(args, "resolve-file", keys);
1915
+ if (!p) throw new Error("conf resolve-file 缺少文件路径(用法: conf resolve-file <文件路径> [--group g])");
1916
+ if (!fs.existsSync(p)) throw new Error("文件不存在: " + p);
1917
+ const text = fs.readFileSync(p, "utf-8");
1918
+ const out = await confResolveText(text, json, args.group);
1919
+ if (json) { console.log(JSON.stringify(out, null, 1)); return out; }
1920
+ process.stdout.write(out + "\n");
1921
+ }
1922
+ async function cmdConfAdd(args, json, keys) {
1923
+ const key = keys[0];
1924
+ if (!key || args.value === undefined) throw new Error("conf add 缺少 confKey/confValue(用法: conf add <key> --value <真实值> [--name] [--attrs] [--type] [--group] [--force])");
1925
+ const ctype = confKeyType(key, args.type);
1926
+ if (!args.force && await confGetData(ctype, key, true, args.group)) {
1927
+ console.error("[config-resolve] 配置已存在,跳过新增(如需修改请用 conf update;确需重建可先 conf delete): " + key);
1928
+ return null;
1929
+ }
1930
+ const item = confPutGroup({ confType: ctype, confKey: key, confValue: String(args.value), confAttrs: args.attrs || "", confName: args.name || "" }, ctype, args.group);
1931
+ const r = await confRequest("POST", CONF_ADD_PATH, [item]);
1932
+ if (json) return r;
1933
+ console.log("新增成功: " + (item.confGroup ? item.confGroup + "/" : "") + key + " = " + item.confValue);
1934
+ }
1935
+ async function cmdConfAddFile(args, json, keys) {
1936
+ const val = flagOrPos(args, "add-file", keys);
1937
+ if (val === undefined) throw new Error("conf add-file 缺少参数(用法: conf add-file @items.json,元素形如 {confType,confGroup,confKey,confValue,confName,confAttrs})");
1938
+ const items = readJsonArg(String(val), "--add-file");
1939
+ const list = Array.isArray(items) ? items : [items];
1940
+ const toAdd = [];
1941
+ for (const it of list) {
1942
+ if (!it.confKey || it.confValue === undefined) continue;
1943
+ const ctype = it.confType || confKeyType(it.confKey, args.type);
1944
+ const group = it.confGroup !== undefined ? it.confGroup : args.group;
1945
+ const dup = await confGetData(ctype, it.confKey, true, group);
1946
+ if (dup) { console.error("[config-resolve] 已存在跳过: " + it.confKey); continue; }
1947
+ toAdd.push(confPutGroup({ confType: ctype, confKey: it.confKey, confValue: String(it.confValue), confAttrs: it.confAttrs || "", confName: it.confName || "" }, ctype, group));
1948
+ }
1949
+ if (!toAdd.length) { console.log("全部已存在,无新增"); return null; }
1950
+ const r = await confRequest("POST", CONF_ADD_PATH, toAdd);
1951
+ if (json) return r;
1952
+ console.log("批量新增 " + toAdd.length + " 条成功");
1953
+ }
1954
+ async function cmdConfUpdate(args, json, keys) {
1955
+ const key = keys[0];
1956
+ if (!key) throw new Error("conf update 缺少 confKey(用法: conf update <key> [--value v] [--name n] [--attrs a] [--type t] [--group g])");
1957
+ if (args.value === undefined && args.name === undefined && args.attrs === undefined) {
1958
+ throw new Error("conf update 至少需提供 --value / --name / --attrs 之一");
1959
+ }
1960
+ const ctype = confKeyType(key, args.type);
1961
+ const body = confPutGroup({ confType: ctype, confKey: key }, ctype, args.group);
1962
+ if (args.value !== undefined) body.confValue = String(args.value);
1963
+ if (args.name !== undefined) body.confName = args.name;
1964
+ if (args.attrs !== undefined) body.confAttrs = args.attrs;
1965
+ const r = await confRequest("POST", CONF_UPDATE_PATH, body);
1966
+ if (json) return r;
1967
+ console.log("更新成功: " + (body.confGroup ? body.confGroup + "/" : "") + key);
1968
+ }
1969
+ async function cmdConfDelete(args, json, keys) {
1970
+ const key = keys[0];
1971
+ if (!key) throw new Error("conf delete 缺少 confKey(用法: conf delete <key> [--type t] [--group g])");
1972
+ const ctype = confKeyType(key, args.type);
1973
+ const url = CONF_DELETE_PATH + "?confType=" + encodeURIComponent(ctype) + "&confKey=" + encodeURIComponent(key) + confGroupSuffix(ctype, args.group);
1974
+ const r = await confRequest("DELETE", url);
1975
+ if (json) return r;
1976
+ console.log("删除成功: " + (confUseGroup(ctype, args.group) && args.group ? args.group + "/" : "") + key);
1977
+ }
1978
+ async function cmdConf(sub, args, json, pos) {
1979
+ const keys = pos.slice(2);
1980
+ switch (sub) {
1981
+ case "get": return cmdConfGet(args, json, keys);
1982
+ case "batch": return cmdConfBatch(args, json, keys);
1983
+ case "batch-file": return cmdConfBatchFile(args, json, keys);
1984
+ case "group": return cmdConfGroup(args, json, keys);
1985
+ case "group-file": return cmdConfGroupFile(args, json, keys);
1986
+ case "resolve": return cmdConfResolve(args, json, keys);
1987
+ case "resolve-file": return cmdConfResolveFile(args, json, keys);
1988
+ case "add": return cmdConfAdd(args, json, keys);
1989
+ case "add-file": return cmdConfAddFile(args, json, keys);
1990
+ case "update": return cmdConfUpdate(args, json, keys);
1991
+ case "delete": return cmdConfDelete(args, json, keys);
1992
+ default: usage(); process.exit(1);
1993
+ }
1994
+ }
1995
+
1996
+ // ---------------- attend:考勤 REST 接口 ----------------
1997
+ // 2026-09-07:封装 /api/attend/web/attendInfoV2/getAttendInfoStatis(假期余额统计,hr-attendance 场景六病假/事假)。
1998
+ // 请求参数 {beginDate, endDate, userId};返回 data.vacationBalanceList[]:{id,title,data:[{name,value,key}]},
1999
+ // key 含 lastRemaining(历年可请)/remaining(今年可请)/total(总计可请)。
2000
+ function defaultMonthRange() {
2001
+ const now = new Date();
2002
+ const y = now.getFullYear();
2003
+ const m = now.getMonth() + 1;
2004
+ const lastDay = new Date(y, m, 0).getDate();
2005
+ const p = (n) => String(n).padStart(2, "0");
2006
+ return { begin: y + "-" + p(m) + "-01", end: y + "-" + p(m) + "-" + p(lastDay) };
2007
+ }
2008
+ async function cmdAttendStatis(args, json) {
2009
+ const user = args.user || auth.userId || "";
2010
+ if (!user) throw new Error("缺少查询人员:--user <人员ID>(查本人可不传,默认当前登录用户)");
2011
+ const def = defaultMonthRange();
2012
+ const body = { beginDate: args.begin || def.begin, endDate: args.end || def.end, userId: user };
2013
+ const r = await postJson("/api/attend/web/attendInfoV2/getAttendInfoStatis", body);
2014
+ if (json) return r;
2015
+ if (String(r.code) !== "200") {
2016
+ console.log("code=" + r.code + " msg=" + (r.msg || ""));
2017
+ if (/没有权限|无权限|权限不足|未授权|无权访问|无查询权限/i.test(String(r.msg || ""))) {
2018
+ throw new Error("当前用户对该人员考勤数据无查询权限,请检查 ETEAMSID 或联系管理员开通");
2019
+ }
2020
+ return;
2021
+ }
2022
+ const d = r.data || {};
2023
+ const list = Array.isArray(d.vacationBalanceList) ? d.vacationBalanceList : [];
2024
+ if (!list.length) { console.log("(空:该人员无假期余额数据) userId=" + user + " " + body.beginDate + "~" + body.endDate); return; }
2025
+ console.log("== 考勤假期余额 userId=" + user + "(" + body.beginDate + " ~ " + body.endDate + ")==");
2026
+ for (const item of list) {
2027
+ console.log((item.title || item.id || "?") + (item.id ? " [" + item.id + "]" : ""));
2028
+ if (Array.isArray(item.data) && item.data.length) {
2029
+ console.log(" " + item.data.map((x) => (x.name || x.key) + "=" + (x.value ?? "")).join(" "));
2030
+ }
2031
+ }
2032
+ }
2033
+
2034
+ // ---------------- exp:专家包版本管理(expert-repository) ----------------
2035
+ async function expGet(url) {
2036
+ // 返回 {raw, j}:raw 用于正则提取大整数 id(commitId 19 位超 JS Number 精度,JSON.parse 会丢精度!)
2037
+ const resp = await fetch(BASE + url, {
2038
+ method: "GET",
2039
+ headers: { "accept": "application/json, text/plain, */*", "langType": "zh_CN", "cookie": auth.cookie, "eteamsid": auth.eteamsid, "ebbusinessid": EB_BIZ_ID, "user-agent": UA },
2040
+ });
2041
+ const raw = await resp.text();
2042
+ let j; try { j = parseJsonSafe(raw); } catch { throw new Error("HTTP " + resp.status + " 非JSON响应: " + raw.slice(0, 300)); }
2043
+ if (resp.status === 401 || (j && String(j.code) === "-1" && /token|登录|失效/i.test(String(j.msg || "")))) throw new Error("登录态失效,请重新登录 weaver-work-cli(`weaver-work-cli auth login --base-url <域名>`)");
2044
+ return { raw, j };
2045
+ }
2046
+ function rawNum(raw, key) {
2047
+ // 从原始 JSON 文本提取整数字段为精确字符串(避免 JSON.parse 大数丢精度,如 commitId)
2048
+ const m = raw.match(new RegExp('"' + key + '":\\s*(\\d+)'));
2049
+ return m ? m[1] : "";
2050
+ }
2051
+ function rawNumSeq(raw, key) {
2052
+ // 提取某 key 全部整数序列(按出现顺序),如提交历史里的 commitId
2053
+ const re = new RegExp('"' + key + '":\\s*(\\d+)', "g");
2054
+ const out = []; let m;
2055
+ while ((m = re.exec(raw)) !== null) out.push(m[1]);
2056
+ return out;
2057
+ }
2058
+ const EXP_ALLOWED_DIRS = [".codebuddy-plugin", "agents", "avatars", "skills"];
2059
+ const EXP_SKIP_DIRS = [".git", ".workbuddy", "node_modules", "__pycache__"];
2060
+ const EXP_SKIP_FILES = [".sync-state.json"]; // 本地同步状态元文件(含基线 sha),永不随包提交
2061
+ function sha256(buf) { return crypto.createHash("sha256").update(buf).digest("hex"); }
2062
+ function walkDir(dir, root) {
2063
+ const out = [];
2064
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
2065
+ const abs = path.join(dir, entry.name);
2066
+ const rel = path.relative(root, abs).split(path.sep).join("/");
2067
+ if (entry.isDirectory()) {
2068
+ if (EXP_SKIP_DIRS.includes(entry.name)) continue;
2069
+ const top = rel.split("/")[0];
2070
+ if (rel.includes("/") && !EXP_ALLOWED_DIRS.includes(top)) continue; // 仅允许根文件+四大目录
2071
+ walkDir(abs, root).forEach((x) => out.push(x));
2072
+ } else if (entry.isFile()) {
2073
+ if (EXP_SKIP_FILES.includes(entry.name)) continue; // .sync-state.json 不入包
2074
+ const top = rel.split("/")[0];
2075
+ if (!rel.includes("/") || EXP_ALLOWED_DIRS.includes(top)) out.push({ rel, abs });
2076
+ }
2077
+ }
2078
+ return out;
2079
+ }
2080
+ function collectPackage(dir) {
2081
+ if (!fs.existsSync(dir)) throw new Error("本地专家包目录不存在: " + dir);
2082
+ return walkDir(dir, dir).map(({ rel, abs }) => {
2083
+ const buf = fs.readFileSync(abs);
2084
+ if (buf.length > 10 * 1024 * 1024) throw new Error("文件超 10MB 上限: " + rel);
2085
+ return { relativePath: rel, contentBase64: buf.toString("base64"), sha256: sha256(buf), abs };
2086
+ });
2087
+ }
2088
+ function localShaMap(dir) {
2089
+ const map = {};
2090
+ for (const f of walkDir(dir, dir)) map[f.rel] = sha256(fs.readFileSync(f.abs));
2091
+ return map;
2092
+ }
2093
+ function expOk(r) {
2094
+ if (!r) return false;
2095
+ if (r.actionMsg) return String(r.actionMsg.code) === "0";
2096
+ const code = r.code;
2097
+ if (code === undefined) return !r.fail;
2098
+ return String(code) === "0" || String(code) === "200";
2099
+ }
2100
+ function expFailMsg(r) {
2101
+ if (r && r.actionMsg && String(r.actionMsg.code) !== "0") return r.actionMsg.message;
2102
+ if (r && r.msg) return r.msg;
2103
+ return "请求失败";
2104
+ }
2105
+ function printExpData(r, title) {
2106
+ if (!expOk(r)) throw new Error(expFailMsg(r));
2107
+ if (title) console.log("== " + title + " ==");
2108
+ console.log(JSON.stringify(r.data ?? {}, null, 1));
2109
+ }
2110
+ async function rawGet(url) {
2111
+ const resp = await fetch(BASE + url, {
2112
+ method: "GET",
2113
+ headers: { "accept": "*/*", "langType": "zh_CN", "cookie": auth.cookie, "eteamsid": auth.eteamsid, "ebbusinessid": EB_BIZ_ID, "user-agent": UA },
2114
+ });
2115
+ if (resp.status === 401) throw new Error("登录态失效,请重新登录 weaver-work-cli(`weaver-work-cli auth login --base-url <域名>`)");
2116
+ return Buffer.from(await resp.arrayBuffer());
2117
+ }
2118
+ async function expStatus(args, json) {
2119
+ const expert = need("expert", args);
2120
+ const { raw, j: r } = await expGet("/api/dw/expert-repository/v1/repositories/status?expertCode=" + encodeURIComponent(expert) + "&branchName=" + (args.branch || "main"));
2121
+ if (json) return r;
2122
+ if (!expOk(r)) throw new Error(expFailMsg(r));
2123
+ const d = r.data || {};
2124
+ console.log("== 仓库状态 " + expert + " ==");
2125
+ console.log("expertCode = " + (d.expertCode ?? ""));
2126
+ console.log("displayName = " + (d.displayName ?? ""));
2127
+ console.log("branchName = " + (d.branchName ?? ""));
2128
+ console.log("headCommitId = " + (rawNum(raw, "headCommitId") || d.headCommitId));
2129
+ console.log("headRevision = " + (d.headRevision ?? ""));
2130
+ for (const k of ["headCommitHash", "headCommitMessage", "headCommitTime", "headAuthor", "protected"]) if (d[k] !== undefined) console.log(k + " = " + d[k]);
2131
+ if (Array.isArray(d.locks) && d.locks.length) { console.log("有效锁:"); for (const l of d.locks) console.log(" " + JSON.stringify(l)); }
2132
+ }
2133
+ async function expManifest(args, json) {
2134
+ const expert = need("expert", args);
2135
+ let url = "/api/dw/expert-repository/v1/repositories/manifest?expertCode=" + encodeURIComponent(expert) + "&branchName=" + (args.branch || "main");
2136
+ if (args.commit) url += "&commitId=" + encodeURIComponent(args.commit);
2137
+ const r = await getUrl(url);
2138
+ if (json) return r;
2139
+ if (!expOk(r)) throw new Error(expFailMsg(r));
2140
+ const items = r.data || [];
2141
+ console.log("== 文件清单 " + items.length + " 项 ==");
2142
+ printHuman("", items, ["relativePath", "sha256", "size", "action"]);
2143
+ }
2144
+ async function expCommits(args, json) {
2145
+ const expert = need("expert", args);
2146
+ const { raw, j: r } = await expGet("/api/dw/expert-repository/v1/repositories/commits?expertCode=" + encodeURIComponent(expert) + "&branchName=" + (args.branch || "main"));
2147
+ if (json) return r;
2148
+ if (!expOk(r)) throw new Error(expFailMsg(r));
2149
+ const rows = (r.data || []).map((x, i) => ({ ...x, commitId: (rawNumSeq(raw, "commitId")[i] || x.commitId) }));
2150
+ console.log("== 提交历史 " + rows.length + " 条(commitId 为精确值,可复制用于 file/release/rollback)==");
2151
+ printHuman("", rows, ["commitId", "submitMode", "author", "createTime", "message"]);
2152
+ }
2153
+ async function expFile(args, json) {
2154
+ const expert = need("expert", args), commit = need("commit", args), rp = need("path", args);
2155
+ const buf = await rawGet("/api/dw/expert-repository/v1/repositories/file?expertCode=" + encodeURIComponent(expert) + "&commitId=" + encodeURIComponent(commit) + "&relativePath=" + encodeURIComponent(rp));
2156
+ if (args.out) { fs.writeFileSync(args.out, buf); console.log("已下载 " + buf.length + " 字节 -> " + args.out); return; }
2157
+ if (json) { console.log(JSON.stringify({ relativePath: rp, size: buf.length, base64: buf.toString("base64") })); return; }
2158
+ process.stdout.write(buf.toString("utf-8"));
2159
+ }
2160
+ // ---- exp 智能拉取:基线状态文件(三方差异保护,2026-09-04 实现)----
2161
+ // .sync-state.json 记录上次成功同步的 commitId/revision 与每文件 sha256(基线)。
2162
+ // pull 逐文件三方对比:local(当前本地) vs base(上次同步基线) vs remote(远端 manifest),
2163
+ // 决策:仅远端改→覆盖;仅本地改→保留本地;双方都改→备份本地副本后覆盖(备份目录保留本地版);
2164
+ // 无基线文件/基线无记录→保守备份后覆盖。拉取成功后把 .sync-state.json 更新为新 head。
2165
+ function syncStatePath(dir) { return path.join(dir, ".sync-state.json"); }
2166
+ function readSyncState(dir) {
2167
+ const p = syncStatePath(dir);
2168
+ if (!fs.existsSync(p)) return null;
2169
+ try {
2170
+ const j = JSON.parse(fs.readFileSync(p, "utf-8"));
2171
+ const files = {};
2172
+ for (const [rel, v] of Object.entries(j.files || {})) files[rel] = (typeof v === "string") ? v : (v.sha256 || "");
2173
+ return { commitId: String(j.commitId || ""), headRevision: j.headRevision, files };
2174
+ } catch { return null; }
2175
+ }
2176
+ function writeSyncState(dir, commitId, headRevision, fileShaMap) {
2177
+ const files = {};
2178
+ for (const [rel, sha] of Object.entries(fileShaMap)) files[rel] = { sha256: sha };
2179
+ const obj = { commitId: String(commitId), headRevision, files, updateTime: new Date().toISOString() };
2180
+ fs.writeFileSync(syncStatePath(dir), JSON.stringify(obj, null, 1), "utf-8");
2181
+ }
2182
+ async function expPull(args, json) {
2183
+ const expert = need("expert", args), dir = need("dir", args);
2184
+ const dry = !!args["dry-run"];
2185
+ const force = !!args["force"]; // --force = 一律以远端覆盖(放弃本地修改)
2186
+ const st = await expGet("/api/dw/expert-repository/v1/repositories/status?expertCode=" + encodeURIComponent(expert) + "&branchName=" + (args.branch || "main"));
2187
+ if (!expOk(st.j)) throw new Error(expFailMsg(st.j));
2188
+ const headId = rawNum(st.raw, "headCommitId") || String((st.j.data || {}).headCommitId);
2189
+ const headRev = (st.j.data || {}).headRevision;
2190
+ const mf = await expGet("/api/dw/expert-repository/v1/repositories/manifest?expertCode=" + encodeURIComponent(expert) + "&branchName=" + (args.branch || "main"));
2191
+ if (!expOk(mf.j)) throw new Error(expFailMsg(mf.j));
2192
+ const items = (mf.j.data || []).filter((f) => f.relativePath && f.relativePath !== "DELETE");
2193
+ const local = fs.existsSync(dir) ? localShaMap(dir) : {};
2194
+ const base = readSyncState(dir); // 基线:上次同步后的文件 sha(无则 null)
2195
+ const baseCommit = (base && base.commitId) || "";
2196
+ // 基线有效判定:上次同步 commitId 须在提交历史上(取不到历史就保守按无基线处理)
2197
+ let backupRoot = "";
2198
+ let downloaded = 0, unchanged = 0, preserved = 0, backed = 0, missing = 0;
2199
+ const plan = []; // {rel, action, reason}
2200
+ const mkBackupRoot = () => {
2201
+ if (backupRoot) return backupRoot;
2202
+ const absDir = path.resolve(dir);
2203
+ const ts = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
2204
+ backupRoot = path.join(path.dirname(absDir), path.basename(absDir) + ".pull-backup-" + ts);
2205
+ fs.mkdirSync(backupRoot, { recursive: true });
2206
+ return backupRoot;
2207
+ };
2208
+ for (const it of items) {
2209
+ const lp = path.join(dir, it.relativePath.split("/").join(path.sep));
2210
+ const ls = local[it.relativePath];
2211
+ const baseSha = (base && base.files) ? base.files[it.relativePath] : undefined;
2212
+ if (ls === it.sha256) { unchanged++; continue; }
2213
+ if (ls === undefined) { // 本地缺失 → 直接下载(新增/恢复)
2214
+ missing++; plan.push({ rel: it.relativePath, action: dry ? "待下载" : "已下载", reason: "本地缺失" });
2215
+ } else if (!force && baseSha !== undefined && ls === baseSha && baseSha !== it.sha256) {
2216
+ // 仅远端改(本地未动,与基线一致)→ 覆盖
2217
+ plan.push({ rel: it.relativePath, action: dry ? "待下载" : "已下载", reason: "远端更新(本地未动)" });
2218
+ } else if (!force && baseSha !== undefined && baseSha === it.sha256) {
2219
+ // 仅本地改(远端=基线,本地≠基线)→ 保留本地,不覆盖
2220
+ preserved++; plan.push({ rel: it.relativePath, action: "保留本地", reason: "本地修改(远端未动)" });
2221
+ continue;
2222
+ } else { // 双方都改 / 无基线可判 / --force → 先备份本地副本再覆盖
2223
+ backed++;
2224
+ if (!dry && !force) {
2225
+ const bdir = path.join(mkBackupRoot(), it.relativePath.split("/").join(path.sep));
2226
+ fs.mkdirSync(path.dirname(bdir), { recursive: true });
2227
+ fs.copyFileSync(lp, bdir);
2228
+ }
2229
+ plan.push({ rel: it.relativePath, action: dry ? "待下载(将备份本地)" : (force ? "已下载(force覆盖)" : "已下载(本地已备份)"), reason: (force ? "force 覆盖" : (baseSha === undefined ? "无基线记录" : "双方都改")) });
2230
+ }
2231
+ if (dry) continue;
2232
+ fs.mkdirSync(path.dirname(lp), { recursive: true });
2233
+ const buf = await rawGet("/api/dw/expert-repository/v1/repositories/file?expertCode=" + encodeURIComponent(expert) + "&commitId=" + encodeURIComponent(headId) + "&relativePath=" + encodeURIComponent(it.relativePath));
2234
+ fs.writeFileSync(lp, buf);
2235
+ downloaded++;
2236
+ }
2237
+ // 拉取成功后更新基线状态文件(记录新 head + 本地最终文件 sha)
2238
+ if (!dry) {
2239
+ const finalLocal = localShaMap(dir);
2240
+ writeSyncState(dir, headId, headRev, finalLocal);
2241
+ }
2242
+ const out = { expert, headCommitId: headId, headRevision: headRev, total: items.length, unchanged, downloaded, preserved, backed, missing, dryRun: !!dry, backupDir: backupRoot || undefined };
2243
+ if (json) { console.log(JSON.stringify(out)); return; }
2244
+ console.log("== pull " + expert + "@" + headId + (dry ? " (dry-run)" : "") + " ==");
2245
+ console.log("清单 " + out.total + " 项 | 未变 " + unchanged + " | 已下载 " + downloaded + " | 保留本地 " + preserved + " | 覆盖前备份 " + backed + " | 缺失 " + missing);
2246
+ for (const p of plan.slice(0, 40)) console.log(" [" + p.action + "] " + p.rel + (p.reason ? "(" + p.reason + ")" : ""));
2247
+ if (plan.length > 40) console.log(" ... 共 " + plan.length + " 个文件");
2248
+ if (backupRoot && !dry) console.log("本地修改副本备份于: " + backupRoot);
2249
+ if (preserved > 0 && !dry) console.log("⚠️ " + preserved + " 个文件因含本地修改被保留(远端未改);如需强制覆盖请加 --force");
2250
+ }
2251
+ async function expInit(args, json) {
2252
+ const expert = need("expert", args), dir = need("dir", args);
2253
+ const files = collectPackage(dir);
2254
+ const body = { expertCode: expert, displayName: args["display-name"] || expert, branchName: args.branch || "main",
2255
+ description: args.description || "", initialCommitMessage: args.message || "初始化导入", files };
2256
+ const r = await postJson("/api/dw/expert-repository/v1/repositories", body);
2257
+ if (json) return r;
2258
+ if (!expOk(r)) throw new Error(expFailMsg(r));
2259
+ console.log("== 仓库已创建 =="); console.log(JSON.stringify(r.data ?? {}, null, 1));
2260
+ }
2261
+ async function expCommit(args, json) {
2262
+ const expert = need("expert", args), dir = need("dir", args), msg = need("message", args);
2263
+ const mode = (args.mode || "FULL_SNAPSHOT").toUpperCase();
2264
+ const st = await expGet("/api/dw/expert-repository/v1/repositories/status?expertCode=" + encodeURIComponent(expert) + "&branchName=" + (args.branch || "main"));
2265
+ if (!expOk(st.j)) throw new Error("无法获取基线: " + expFailMsg(st.j));
2266
+ const base = st.j.data || {};
2267
+ const body = {
2268
+ branchName: base.branchName || "main",
2269
+ baseCommitId: rawNum(st.raw, "headCommitId") || String(base.headCommitId), // 精确字符串,避免大数丢精度
2270
+ baseRevision: base.headRevision,
2271
+ commitMessage: msg,
2272
+ };
2273
+ if (mode === "FILE_PATCH") {
2274
+ const mf = await expGet("/api/dw/expert-repository/v1/repositories/manifest?expertCode=" + encodeURIComponent(expert) + "&branchName=" + (args.branch || "main"));
2275
+ if (!expOk(mf.j)) throw new Error("无法获取 manifest: " + expFailMsg(mf.j));
2276
+ const remoteSha = {}; for (const f of (mf.j.data || [])) remoteSha[f.relativePath] = f.sha256;
2277
+ const local = collectPackage(dir);
2278
+ const localSha = {}; for (const f of local) localSha[f.relativePath] = f.sha256;
2279
+ const changes = [];
2280
+ for (const f of local) {
2281
+ const rs = remoteSha[f.relativePath];
2282
+ if (rs === undefined) changes.push({ operation: "ADD", relativePath: f.relativePath, contentBase64: f.contentBase64, sha256: f.sha256, baseFileSha256: "" });
2283
+ else if (rs !== f.sha256) changes.push({ operation: "MODIFY", relativePath: f.relativePath, contentBase64: f.contentBase64, sha256: f.sha256, baseFileSha256: rs });
2284
+ }
2285
+ for (const rp of Object.keys(remoteSha)) if (localSha[rp] === undefined) changes.push({ operation: "DELETE", relativePath: rp, baseFileSha256: remoteSha[rp] });
2286
+ if (!changes.length) throw new Error("本地与远端一致,无变更可提交");
2287
+ body.submitMode = "FILE_PATCH"; body.changes = changes;
2288
+ } else {
2289
+ body.submitMode = "FULL_SNAPSHOT"; body.files = collectPackage(dir);
2290
+ }
2291
+ const r = await postJson("/api/dw/expert-repository/v1/repositories/commits?expertCode=" + encodeURIComponent(expert), body);
2292
+ if (json) return r;
2293
+ if (!expOk(r)) {
2294
+ const m = expFailMsg(r);
2295
+ if (/基线|过期|revision|冲突/i.test(m)) throw new Error("提交冲突: " + m + " —— 请先 exp pull 拉取最新并合并后重试");
2296
+ throw new Error("提交失败: " + m);
2297
+ }
2298
+ // 提交成功后把本地 .sync-state.json 基线刷新到新 head(与本地文件 sha 一致),保证后续 pull 三方判定准确
2299
+ const rj = r.data || {};
2300
+ const newHead = rawNum(JSON.stringify(r), "headCommitId") || rawNum(JSON.stringify(r), "commitId") || rj.headCommitId || rj.commitId || "";
2301
+ if (newHead && fs.existsSync(dir)) {
2302
+ try { writeSyncState(dir, newHead, rj.headRevision ?? rj.revision ?? 0, localShaMap(dir)); } catch {}
2303
+ }
2304
+ console.log("== 提交成功 =="); console.log(JSON.stringify(r.data ?? {}, null, 1));
2305
+ }
2306
+ async function expLock(args, json) {
2307
+ const expert = need("expert", args);
2308
+ const act = (args.action || "").toLowerCase();
2309
+ const branch = args.branch || "main";
2310
+ if (act === "query") {
2311
+ const r = await getUrl("/api/dw/expert-repository/v1/repositories/locks?expertCode=" + encodeURIComponent(expert) + "&branchName=" + encodeURIComponent(branch));
2312
+ if (json) return r;
2313
+ if (!expOk(r)) throw new Error(expFailMsg(r));
2314
+ printExpData(r, "有效锁");
2315
+ return;
2316
+ }
2317
+ if (act === "heartbeat") {
2318
+ const token = need("lock-token", args);
2319
+ const r = await postJson("/api/dw/expert-repository/v1/repositories/locks/heartbeat?lockToken=" + encodeURIComponent(token), {});
2320
+ if (json) return r;
2321
+ if (!expOk(r)) throw new Error(expFailMsg(r));
2322
+ console.log("锁已续期: " + JSON.stringify(r.data ?? {}));
2323
+ return;
2324
+ }
2325
+ if (act === "release") {
2326
+ const token = need("lock-token", args);
2327
+ const req = new Request(BASE + "/api/dw/expert-repository/v1/repositories/locks?lockToken=" + encodeURIComponent(token), { method: "DELETE" });
2328
+ const resp = await fetch(req, { headers: { "cookie": auth.cookie, "eteamsid": auth.eteamsid, "accept": "application/json" } });
2329
+ const text = await resp.text();
2330
+ let r; try { r = JSON.parse(text); } catch { r = { data: { raw: text } }; }
2331
+ if (json) return r;
2332
+ if (!expOk(r)) throw new Error(expFailMsg(r));
2333
+ console.log("锁已释放");
2334
+ return;
2335
+ }
2336
+ // acquire(默认)
2337
+ const body = { branchName: branch, componentKey: args.component || "ROOT", ttlMinutes: parseInt(args.ttl || "30", 10) };
2338
+ const r = await postJson("/api/dw/expert-repository/v1/repositories/locks?expertCode=" + encodeURIComponent(expert), body);
2339
+ if (json) return r;
2340
+ if (!expOk(r)) throw new Error(expFailMsg(r));
2341
+ console.log("== 锁已获取(记录 lockToken 供 heartbeat/release/commit 使用)==");
2342
+ console.log(JSON.stringify(r.data ?? {}, null, 1));
2343
+ }
2344
+ async function expRelease(args, json) {
2345
+ const expert = need("expert", args);
2346
+ const r = await postJson("/api/dw/expert-repository/v1/repositories/releases?expertCode=" + encodeURIComponent(expert),
2347
+ { commitId: need("commit", args), versionTag: need("version", args), releaseNote: args.note || "" });
2348
+ if (json) return r;
2349
+ if (!expOk(r)) throw new Error(expFailMsg(r));
2350
+ console.log("== 发布成功 =="); console.log(JSON.stringify(r.data ?? {}, null, 1));
2351
+ }
2352
+ async function expRollback(args, json) {
2353
+ const expert = need("expert", args);
2354
+ const r = await postJson("/api/dw/expert-repository/v1/repositories/releases/rollback?expertCode=" + encodeURIComponent(expert) + "&versionTag=" + encodeURIComponent(need("version", args)), {});
2355
+ if (json) return r;
2356
+ if (!expOk(r)) throw new Error(expFailMsg(r));
2357
+ console.log("== 回滚成功 =="); console.log(JSON.stringify(r.data ?? {}, null, 1));
2358
+ }
2359
+
2360
+ // ---------------- main ----------------
2361
+ function usage() {
2362
+ console.log(`e10-data CLI(data-assistant 数据接口统一命令行,2026-09-03 扩展)
2363
+ 用法: node e10-data.mjs <族> <子命令> [--参数] 通用 --json 原样输出
2364
+
2365
+ login 登录(**唯一不需要已有凭据**的命令族;其余命令都要求先有登录态)
2366
+ login xiaoe-env [--domain <url>] # 用环境变量 XIAOE_USER_TOKEN + XIAOE_DOMAIN(XiaoE 全局 token)
2367
+ # 换 ETEAMSID 并写入 profile(免浏览器;不依赖 python)
2368
+ # --domain 缺省 = 当前 profile 的 config.json.baseUrl → 默认平台地址
2369
+ # 依赖环境变量:XIAOE_USER_TOKEN(XiaoE token)、XIAOE_DOMAIN(erpa 域名)
2370
+ # 别名 e10-data xiaoe-env(与 e10-login 同名命令一致)
2371
+ # 说明:换 token 由本 CLI 原生完成;写 profile(auth 加密)仍交 e10-login set
2372
+ # 成功后自动用取数链路自检读回凭据,读不回即报错(不假成功)
2373
+
2374
+ query DSL 数据查询 executeQueryDsl
2375
+ query --conn <connId> --model <ebuilder|flow|eteams|attend|hr|document|dw|blog> --schema <数据库> [--table-id <流程ID>] [--debug true|false] --dsl @file.json|'{...}'
2376
+ ds 数据源(业务模块)取数与元数据
2377
+ ds groups [--pattern <关键词>] [--json] # 内置分组清单(仅用于 find 的默认扫描集)
2378
+ ds find --name <关键词> [--groups g1,g2] [--all-groups] [--max-groups N] [--limit N]
2379
+ [--app-id <id>] [--size 100] [--pages N] [--concurrency 10] [--no-escalate] [--loose] [--source-type LOGIC]
2380
+ # 跨分组找表:**进程内并发**扇出(一次凭据),产物=候选表清单 + 路由建议,**不取数**
2381
+ # 默认扫描集 = 核心 3 组(业务表单/e-builder表单/流程)+ 语义命中组,≤ --max-groups
2382
+ # 默认集合全 miss 时自动升级为全分组扫一次(--no-escalate 可关;窗口 >2000 行时不自动升级)
2383
+ # --all-groups = 直接扫全部内置分组(进程内并发,实测 ~5.5s)
2384
+ # --app-id:EB 分组(weaver-ebuilder-form-service)按应用分域,需限定应用时带上
2385
+ # ⚠️ 端点 /api/dw/newetl/chatBi/ds/listByPage **当前忽略 name**(后端待升级)⇒ 本次是
2386
+ # 「抓 --pages 页 × --size 行 + 本地按表名过滤」:**未命中 ≠ 该表不存在**,
2387
+ # 分组行数大时请调大 --size(≤2000)/--pages(≤50)。后端支持 name 后自动精确、无需改调用
2388
+ # 输出按「实体」归并去重(同一流程会在多个报表/效率分组重复出现)
2389
+ ds describe --group <gid> --table <tid> [--app-id <id>] [--skill <skillKey,默认 data-source-query>]
2390
+ [--require] [--source-type LOGIC]
2391
+ # 一次**并发**取回两件套:字段 + 词库(两者入参同为 (groupId, tableId),互不依赖)
2392
+ # ⚠️ **不做权限校验**:EB 分组取数前仍必须单独执行 eb perm-page(appId 只从那次调用取)
2393
+ # --require:任一路失败即报错退出
2394
+ ds tables --group <gid> [--name 关键词] [--app-id <id>]
2395
+ ds fields --group <gid> --table <tid> [--app-id <id>]
2396
+ ds fields-dsl --group <gid> --table <tid> [--app-id <id>] # 同 fields,接口 /api/dw/newetl/chatBi/ds/fieldsForDSL
2397
+ ds list --group <gid> --table <tid> --fields a,b [--where @f|json] [--order "f asc"] [--page N] [--size N] [--app-id <id>]
2398
+ ds chart --group <gid> --table <tid> --group-by g1,g2 --stat @f.json [--where ...] [--app-id <id>]
2399
+ ds stat --group <gid> --table <tid> --stat @f.json [--where ...] [--app-id <id>]
2400
+ ds count --group <gid> --table <tid> [--where ...] [--app-id <id>]
2401
+ (ds 通用 --source-type LOGIC|ETEAMS,默认 LOGIC)
2402
+ (ds 取数的 appId:一律**显式传入** --app-id <id>(v0.1.14 起 CLI 不再自动反查)。
2403
+ EB 建模表单(groupId=weaver-ebuilder-form-service)必须带:该值取自 chat_bi_eb_config_map 的权限校验查询
2404
+ ——调用方先查权限(无权限即终止、不取数),同一条记录的 app_id 即本次必带的 appId;
2405
+ 其他数据集可不传,CLI 不拦截、以接口报错为准;--app-id auto / --no-app-id 已废弃,等同不传)
2406
+ --where 过滤条件(v0.1.8 起支持树形 OR,递归):
2407
+ 数组 = AND 根: [{"fieldId":"f1","conditionType":"Date","compareType":"range","conditionValue":"2026-09~2026-10"},{"fieldId":"f2",...}]
2408
+ 对象 = 完整树: {"relationship":"0","conditionList":[{"fieldId":"f1",...},{"fieldId":"f2",...}]} # "0"=OR、"1"=AND,缺省 AND
2409
+ 数组元素可内嵌条件组 {"relationship":"0","conditionList":[...]} 实现 (A AND B) OR (C AND D)
2410
+ 叶子键 fieldId/conditionId 均可(fieldId 优先);缺少字段的叶子会直接报错(不再静默当全表)
2411
+ doc 文档查询
2412
+ doc count [--name 关键词] [--creator id1,id2] [--author id1,id2] [--content-type office] [--time "起~止"] [--last-time "起~止"] [--folder id,id|--folder-neq id,id]
2413
+ doc list [同上条件] [--all 翻页到底] [--page N] [--size 100]
2414
+ noun 词库查询
2415
+ noun list --skill <skillKey> [--source-type LOGIC] [--source-group gid] [--source-table tid](数据源场景带三参数)
2416
+ field 关联字段翻译
2417
+ field id2name --module <m> --type <t> --ids id1,id2 [--form-param '{"fieldId":"..","module":"ebuilderform"}'|--eb-browser-param '{..}'|--property-type x]
2418
+ field name2id --module <m> --type <t> --name 名称 [同上附加参数]
2419
+ conf 环境参数配置中心(chatBi config map,替代 config-resolve 脚本 URL 直连;confType 默认由 CONN_/DS_/WF_/API_ 前缀推导,可 --type 显式)
2420
+ confGroup(配置分组,v0.1.9 新增,可选):confType=api 时后端支持二级分层(如 api/workflow),
2421
+ 定位维度 = confType+confGroup+confKey;仅 confType=api(或显式传 --group)时才下发 confGroup,其他类型不带该字段
2422
+ conf get <key> [--type t] [--group g] # 单查(confValue/confAttrs/confName 全量 JSON)
2423
+ conf batch <key1> [key2 ...] [--type t] [--group g] # 批量查(批量接口异常自动降级逐条单查)
2424
+ conf batch-file @items.json # 按 [{confType,confGroup,confKey}..] 显式批量查(支持任意 confType)
2425
+ conf group <confType> <confGroup> [--key k] # 按类型+分组批量查(batchGetChatBiConfigMapByTypeAndGroup)
2426
+ conf group-file @items.json # 按 [{"confType":"api","confGroup":"workflow"}..] 多分组批量查
2427
+ conf resolve '<含#{KEY}文本>'|@file.txt [--group g] # 文本占位符替换为真实值(含中文建议 @file)
2428
+ conf resolve-file <文件路径> [--group g] # 读文件替换全部占位符并打印(不改写原文件)
2429
+ conf add <key> --value <真实值> [--name 说明] [--attrs '{"request":..,"response":..}'] [--type t] [--group g] [--force] # 新增(默认判重,已存在跳过)
2430
+ conf add-file @items.json # JSON 数组批量新增(逐条判重,元素可带 confGroup)
2431
+ conf update <key> [--value v] [--name n] [--attrs a] [--type t] [--group g] # 更新(confType+confGroup+confKey 定位,传哪个字段更哪个)
2432
+ conf delete <key> [--type t] [--group g] # 删除
2433
+ 示例: conf group api workflow | conf add GET_WORKFLOW_LIST --type api --group workflow --value /api/workflow/getList --name 查询流程列表
2434
+ exp 专家包版本管理(expert-repository/v1,响应以 data.actionMsg.code=0 判成功)
2435
+ exp status --expert <code> [--branch main] # 仓库状态(headCommitId/headRevision=提交基线)
2436
+ exp manifest --expert <code> [--commit <id>] # 文件清单(sha256)
2437
+ exp commits --expert <code> # 提交历史
2438
+ exp file --expert <code> --commit <id> --path <相对路径> [--out 本地文件]
2439
+ exp pull --expert <code> --dir <本地专家包目录> [--dry-run] [--force] # 智能拉取:基线三方对比(仅远端改→覆盖/仅本地改→保留/双方都改→备份后覆盖;--force 一律覆盖),成功后更新 .sync-state.json 基线
2440
+ exp init --expert <code> --dir <本地目录> [--display-name X] [--message 初始化导入]
2441
+ exp commit --expert <code> --dir <本地目录> --message "..." [--mode FULL_SNAPSHOT|FILE_PATCH]
2442
+ exp lock --expert <code> --action query|acquire(默认)|heartbeat|release --component ROOT --ttl 30 --lock-token <token>
2443
+ exp release --expert <code> --commit <id> --version vX.Y.Z [--note 说明]
2444
+ exp rollback --expert <code> --version vX.Y.Z
2445
+ attend 考勤 REST 接口(2026-09-07 新增,hr-attendance 病假/事假等假期余额)
2446
+ attend statis [--user <人员ID>] [--begin 2026-09-01] [--end 2026-09-30] # getAttendInfoStatis 假期余额统计;--user 默认当前登录用户,--begin/--end 默认当月
2447
+ eb EB 建模表单权限校验(chatBi EB 配置映射分页查询,替代 DSL 查 chat_bi_eb_config_map)
2448
+ eb perm-page --obj-id <id> --form-table <物理表名> [--page N] [--size N] [--app-id <id>] [--require]
2449
+ # obj-id / form-table 必填(本地强校验;缺失后端只回空、不报错)
2450
+ # obj-id / app-id 必须字符串传参(19 位大整数,Number 舍入 → 静默 total=0)
2451
+ # total>=1 = 有权限,并回带该行 appId;--require 时 total=0 直接报错退出
2452
+ 认证: 加解密由 e10-login 负责(本 CLI 不参与);凭据出口 = e10-login auth/whoami(自动同步镜像);皆无请先 e10-login oidc
2453
+ (无浏览器/无 python 环境:login xiaoe-env 用环境变量 token 直接登录)`);
2454
+ }
2455
+
2456
+ async function main() {
2457
+ const rawArgv = process.argv.slice(2);
2458
+ // 仅当顶层显式 -v/--version(无子命令)时打印版本;exp release/rollback 的 --version 是版本号参数、conf 文本可能含 version 字样,一律放行
2459
+ if (rawArgv.length === 1 && (rawArgv[0] === "--version" || rawArgv[0] === "-v")) {
2460
+ console.log("e10-data " + cliVersion());
2461
+ process.exit(0);
2462
+ }
2463
+ const { args, pos } = parseArgs(rawArgv);
2464
+ if (!pos.length) { usage(); process.exit(1); }
2465
+ const family = pos[0], sub = pos[1], json = !!args.json;
2466
+ let result;
2467
+ if (family === "ds") {
2468
+ if (!sub) { usage(); process.exit(1); }
2469
+ switch (sub) {
2470
+ case "groups": result = await cmdDsGroups(args, json); break;
2471
+ case "find": result = await cmdDsFind(args, json); break;
2472
+ case "describe": result = await cmdDsDescribe(args, json); break;
2473
+ case "tables": result = await cmdTables(args, json); break;
2474
+ case "fields": result = await cmdFields(args, json); break;
2475
+ case "fields-dsl": result = await cmdFieldsForDSL(args, json); break;
2476
+ case "list": result = await cmdList(args, json); break;
2477
+ case "chart": result = await cmdChart(args, json); break;
2478
+ case "stat": result = await cmdStat(args, json); break;
2479
+ case "count": result = await cmdCount(args, json); break;
2480
+ default: usage(); process.exit(1);
2481
+ }
2482
+ } else if (family === "query") {
2483
+ result = await cmdQuery(args, json);
2484
+ } else if (family === "doc") {
2485
+ if (sub === "count") result = await cmdDocCount(args, json);
2486
+ else if (sub === "list") result = await cmdDocList(args, json);
2487
+ else { usage(); process.exit(1); }
2488
+ } else if (family === "noun") {
2489
+ if (sub === "list") result = await cmdNoun(args, json);
2490
+ else { usage(); process.exit(1); }
2491
+ } else if (family === "field") {
2492
+ if (sub === "id2name") result = await cmdFieldId2name(args, json);
2493
+ else if (sub === "name2id") result = await cmdFieldName2id(args, json);
2494
+ else { usage(); process.exit(1); }
2495
+ } else if (family === "conf") {
2496
+ if (!sub) { usage(); process.exit(1); }
2497
+ result = await cmdConf(sub, args, json, pos);
2498
+ } else if (family === "exp") {
2499
+ if (!sub) { usage(); process.exit(1); }
2500
+ switch (sub) {
2501
+ case "status": result = await expStatus(args, json); break;
2502
+ case "manifest": result = await expManifest(args, json); break;
2503
+ case "commits": result = await expCommits(args, json); break;
2504
+ case "file": result = await expFile(args, json); break;
2505
+ case "pull": result = await expPull(args, json); break;
2506
+ case "init": result = await expInit(args, json); break;
2507
+ case "commit": result = await expCommit(args, json); break;
2508
+ case "lock": result = await expLock(args, json); break;
2509
+ case "release": result = await expRelease(args, json); break;
2510
+ case "rollback": result = await expRollback(args, json); break;
2511
+ default: usage(); process.exit(1);
2512
+ }
2513
+ } else if (family === "attend") {
2514
+ if (sub === "statis") result = await cmdAttendStatis(args, json);
2515
+ else { usage(); process.exit(1); }
2516
+ } else if (family === "eb") {
2517
+ if (!sub) { usage(); process.exit(1); }
2518
+ switch (sub) {
2519
+ case "perm-page": result = await cmdEbPermPage(args, json); break;
2520
+ default: usage(); process.exit(1);
2521
+ }
2522
+ } else { usage(); process.exit(1); }
2523
+ if (json && result !== undefined) console.log(JSON.stringify(result, null, 1));
2524
+ }
2525
+ main().catch((e) => { console.error("ERROR: " + e.message); process.exit(1); });