@xbongbong/xbbcli 2.2.0 → 2.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +66 -5
- package/auth-login.js +306 -0
- package/auth-logout.js +175 -0
- package/auth-status.js +105 -0
- package/{ai-call-add-task.js → hkh-call-add-task.js} +1 -1
- package/{ai-word-list.js → hkh-word-list.js} +1 -1
- package/package.json +2 -2
- package/token-set.js +4 -388
- package/xbb-browser.js +330 -0
- package/xbb-config.js +33 -0
- package/xbb-token-store.js +389 -0
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# xbbcli
|
|
2
2
|
|
|
3
|
-
销帮帮 CRM (`xbb`)
|
|
3
|
+
销帮帮 CRM (`xbb`) 命令行工具。业务命令全部直接调用 HTTP API,不依赖 `opencli`;只有 `auth-login` 会拉起本地浏览器完成一次交互式登录。
|
|
4
4
|
|
|
5
5
|
## 安装
|
|
6
6
|
|
|
@@ -15,9 +15,13 @@ xbbcli --help
|
|
|
15
15
|
|
|
16
16
|
|
|
17
17
|
|
|
18
|
-
|
|
18
|
+
首次使用时配置销帮帮凭证(二选一):
|
|
19
19
|
|
|
20
20
|
```bash
|
|
21
|
+
# 方式一:浏览器登录(推荐),自动换取 API token 并完成初始化
|
|
22
|
+
xbbcli auth-login
|
|
23
|
+
|
|
24
|
+
# 方式二:手动填入已有 token
|
|
21
25
|
xbbcli token-set --corpid <CORPID> --token <TOKEN> --userId <USERID>
|
|
22
26
|
```
|
|
23
27
|
|
|
@@ -75,7 +79,26 @@ xbbcli token-set --corpid <CORPID> --token <TOKEN> --userId <USERID>
|
|
|
75
79
|
]
|
|
76
80
|
```
|
|
77
81
|
|
|
78
|
-
|
|
82
|
+
### 浏览器登录(auth-login,推荐)
|
|
83
|
+
|
|
84
|
+
不带参数执行 `xbbcli auth-login`:命令会拉起本地 Chrome / Edge 打开销帮帮登录页,你在浏览器里用**任意方式**完成登录(账号密码、短信验证码、钉钉 / 企微 / 飞书扫码都可以),命令检测到登录态后自动换取 API token:
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
xbbcli auth-login # 自动探测 Chrome / Edge
|
|
88
|
+
xbbcli auth-login --browser edge # 指定浏览器(chrome / edge / 可执行文件路径)
|
|
89
|
+
xbbcli auth-login --timeout 600 # 等待登录完成的秒数,默认 300
|
|
90
|
+
xbbcli auth-login --corpid <CORPID> # 校验登录的企业,不一致时报错
|
|
91
|
+
xbbcli auth-login --keepOpen # 登录成功后保留浏览器窗口(默认自动关闭)
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
执行后与 `token-set` 等价:写入 `~/.xbbcli/config.env`、刷新表单缓存、命令映射与部门/员工缓存,并同步 `XBB_*` 环境变量(加 `--noEnv` 可跳过)。
|
|
95
|
+
|
|
96
|
+
技术实现:零依赖,用 `node:child_process` 以 `--remote-debugging-pipe` 启动浏览器(fd3 写 / fd4 读 CDP,**不开任何调试端口**),profile 固定为 `~/.xbbcli/browser-profile`(与日常浏览器隔离,登录态可复用);CLI 读取页面 `localStorage` 的 `{corpid, userId, xbbAccessToken}`,再在页面内 `fetch` 网关 `apiToken/getApiToken` 换取个人 token。只支持 Chromium 内核浏览器(Chrome / Edge / Chromium),更多细节见 [`doc/auth-login-command-design.md`](doc/auth-login-command-design.md)。
|
|
97
|
+
|
|
98
|
+
- `--debug` 输出请求体、返回体与浏览器信息(密钥只显示掩码),`--raw` 输出换取 API token 接口的原始响应
|
|
99
|
+
- 失败时返回合成错误行:`NO_BROWSER`、`BROWSER_CLOSED`、`LOGIN_TIMEOUT`、`API_TOKEN_FAILED`、`CORPID_MISMATCH`、`SAVE_FAILED` 等
|
|
100
|
+
|
|
101
|
+
`token-set` 会按 `corpid` 新增或覆盖一家公司,并把该公司置为 `enable=true`(其余公司自动置为 `false`),同时写入公司名称 `corpName`(取部门 `id` 为 `1` 的部门名称)和操作人姓名 `userName`(从员工列表中按 `userId` 匹配)。除 `token-set`、`token-list`、`token-use`、`token-del`、`auth-status`、`auth-logout` 外,其余命令都使用 `enable` 为 `true` 的公司配置。
|
|
79
102
|
|
|
80
103
|
同时会自动拉取两份表单清单并合并缓存到:
|
|
81
104
|
|
|
@@ -116,6 +139,37 @@ xbbcli user-list --pageSize 200 -f json
|
|
|
116
139
|
- `corpid` 以 `ding` 开头,或包含 `$$ding` 时,使用 `https://proapi.xbongbong.com`
|
|
117
140
|
- 其他 `corpid` 使用 `https://appapi.xbongbong.com`
|
|
118
141
|
|
|
142
|
+
## 查看当前配置与登出
|
|
143
|
+
|
|
144
|
+
```bash
|
|
145
|
+
xbbcli auth-status # 回显当前激活的配置(token 默认掩码)
|
|
146
|
+
xbbcli auth-status --showToken # 需要完整 token 时
|
|
147
|
+
xbbcli auth-logout # 登出:删除激活的那一家配置 + 清除 XBB_* 环境变量
|
|
148
|
+
xbbcli auth-logout --noEnv # 只删 config.env,保留环境变量
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
- `auth-status` 以 `key` / `value` 两列回显当前生效的配置值,固定 6 行:`corpid`、`corpName`、`userName`、`userId`、`baseurl`、`token`;不再输出 `status`、`source`、`message`、`enable`、`companyCount`、`envActive`、`configFile`、`code`、`msg` 这些元信息列。
|
|
152
|
+
|
|
153
|
+
```text
|
|
154
|
+
key value
|
|
155
|
+
corpid dinge3fa697f86d461d2
|
|
156
|
+
corpName 杭州逍邦网络科技有限公司
|
|
157
|
+
userName 陈会兵
|
|
158
|
+
userId 02415643151585
|
|
159
|
+
baseurl https://proapi.xbongbong.com
|
|
160
|
+
token user_6***a7da
|
|
161
|
+
note 以上取值来自环境变量 XBB_*(config.env 中没有启用公司,回落到环境变量);环境变量模式不支持多公司切换
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
- 来源(`config.env` 启用公司 还是 `XBB_*`)与冲突提醒只在需要时以 `note` 行追加:环境变量与 `config.env` 同时存在时提示「config.env 启用项优先」;`config.env` 里出现多个 `enable=true` 时提示用 `token-use` 修正。
|
|
165
|
+
- 没有任何生效配置时,6 个值一律留空并给出 `note` 提示下一步命令(即使 `XBB_BASEURL` / `XBB_USERID` 单独残留也不会当成生效值回显)。配置文件路径、公司数量、生效的环境变量名改用 `--debug` 输出到 stderr。
|
|
166
|
+
- `auth-status` 默认把 token 掩码为「前 6 + `***` + 后 4」(长度不足 11 时只保留前 2 位),只有 `--showToken` 才输出明文。
|
|
167
|
+
- `auth-logout` 只删除 `enable=true` 的那一条,**不会**把其他公司自动提升为启用:登出后本地可能一个启用公司都没有,需要 `xbbcli token-use --corpid <CORPID>` 或重新 `auth-login` / `token-set`。想保留其他公司并可切换时,用 `xbbcli token-del --corpid <CORPID>`(它会保证剩余配置仍有且仅有一个启用)。
|
|
168
|
+
- 删除前 `config.env` 会整份备份到 `~/.xbbcli/config.env.bak`(每次登出覆盖),`backupFile` 列给出路径;误删把 `.bak` 复制回 `config.env` 即可恢复。
|
|
169
|
+
- 默认**同时**清除 `XBB_*` 环境变量:Windows 用 `reg delete HKCU\Environment` 删除用户级变量(已打开的终端要重开才生效,`envStored` 为 `user`,原本就不存在时为 `none`);macOS/Linux 清理 `~/.xbbcli/env.sh` 里的 `export XBB_*` 行(文件清空则删除,`envStored` 为 `cleared` / `removed` / `none`)。加 `--noEnv`(或 `--env=false`)时只删 `config.env`,此时若环境变量仍在,`message` 会提示「业务命令会回落到它,本次不算彻底登出」(`XBB_ENV_ONLY=1` 时提示为「已强制只用环境变量」)。
|
|
170
|
+
- `config.env` 没有启用公司时:只有在环境变量确实存在时才只清环境变量(`config.env` 保持不动);两者都不存在则返回 `NO_ACTIVE_CONFIG`,不写任何文件。
|
|
171
|
+
- 任何情况都不改动 `<corpid>.formlist.json` / `<corpid>.command-map.md` / `<corpid>.department-user.json` 三份缓存,重新启用该公司时无需再次拉取。
|
|
172
|
+
|
|
119
173
|
## 环境变量模式(单公司)
|
|
120
174
|
|
|
121
175
|
除 `config.env` 外,命令也支持从环境变量读取一家公司的凭证,便于 CI、容器与其他工具集成:
|
|
@@ -142,12 +196,15 @@ xbbcli user-list --pageSize 200 -f json
|
|
|
142
196
|
|
|
143
197
|
环境变量模式只支持一家公司:此时 `token-list` 以 `source=env` 单行显示,`token-use` / `token-del` 会返回不支持多公司的错误行,并提示改用 `config.env`。
|
|
144
198
|
|
|
145
|
-
> 注意:`token-del`
|
|
199
|
+
> 注意:`token-del` 不会清理已经写入的环境变量。要连环境变量一起登出,执行 `xbbcli auth-logout`(默认就会清除 `XBB_*`);也可以手工执行 `setx XBB_TOKEN ""`(其余 `XBB_*` 同理,Windows 需重开终端)或删除 `~/.xbbcli/env.sh` 并 `unset` 当前 shell 中已导出的变量。
|
|
146
200
|
|
|
147
201
|
## 当前支持的命令
|
|
148
202
|
|
|
149
203
|
### 配置
|
|
150
204
|
|
|
205
|
+
- `auth-login`:拉起本地浏览器完成交互式登录(账号密码 / 短信 / 扫码均可),自动读取 Web 会话、换取 API token 并复用 `token-set` 落盘;支持 `--browser`、`--timeout`、`--corpid`、`--keepOpen`、`--noEnv`、`--debug`、`--raw`
|
|
206
|
+
- `auth-status`:以 `key` / `value` 两列回显当前激活配置的 `corpid`、`corpName`、`userName`、`userId`、`baseurl`、`token`(token 默认中间掩码,`--showToken` 出明文),来源与冲突提醒以 `note` 行追加,无生效配置时六个值留空并给出 `note`;`--debug` 在 stderr 输出配置文件路径、公司数量与生效的环境变量名
|
|
207
|
+
- `auth-logout`:删除 `config.env` 中 `enable=true` 的那一条配置,删除前整份备份为 `~/.xbbcli/config.env.bak`;不自动把其他公司提升为启用;默认同时清除 `XBB_*` 环境变量(`--noEnv` / `--env=false` 跳过,返回 `envStored` / `envFile`);既无启用公司又无环境变量时返回 `NO_ACTIVE_CONFIG` 且不改动文件
|
|
151
208
|
- `token-set`:保存个人 token、`corpid`、`userId`、`baseurl`,并刷新本地表单缓存、命令映射文件与部门/员工缓存;传入的 token 不以 `user_` 开头时,会先为该 `userId` 刷新并保存个人 token;成功后同步写入 `XBB_*` 环境变量(Windows 用 `setx`,其他平台写 `~/.xbbcli/env.sh`),`--noEnv` 可跳过;返回 `envStored` / `envFile` 两列
|
|
152
209
|
- `token-list`:列出本地保存的所有公司配置和唯一启用的公司(含 `corpName` 公司名称与 `userName` 操作人姓名,`source` 列标记来源 `config`/`env`);`--showToken` 显示完整 token(默认脱敏);环境变量模式下单行显示
|
|
153
210
|
- `token-use`:切换当前启用的公司(`xbbcli token-use --corpid <CORPID>`),保证仅且只有一个公司被启用
|
|
@@ -394,7 +451,8 @@ xbbcli user-list --pageSize 200 -f json
|
|
|
394
451
|
|
|
395
452
|
## 通用行为
|
|
396
453
|
|
|
397
|
-
- 先执行一次 `token-set`
|
|
454
|
+
- 先执行一次 `token-set`(或 `auth-login`)保存有效的 `corpid`、`token`、`userId`
|
|
455
|
+
- 用 `xbbcli auth-status` 查看当前生效的配置来自 `config.env` 还是环境变量;`xbbcli auth-logout` 删除激活配置并默认清除 `XBB_*` 环境变量(`--noEnv` 保留)
|
|
398
456
|
- 除 `token-set` 外,其余命令都会从 `~/.xbbcli/config.env` 读取 `corpid`
|
|
399
457
|
- 大部分命令会从 `~/.xbbcli/config.env` 读取 `token`
|
|
400
458
|
- 未配置 `config.env`(或其中没有启用公司)时,命令会回落到 `XBB_CORPID` / `XBB_TOKEN` / `XBB_BASEURL` / `XBB_USERID` / `XBB_CORPNAME` / `XBB_USERNAME` 环境变量;`XBB_ENV_ONLY=1` 可强制只用环境变量
|
|
@@ -416,7 +474,10 @@ xbbcli user-list --pageSize 200 -f json
|
|
|
416
474
|
### 基础配置/初始化
|
|
417
475
|
|
|
418
476
|
```bash
|
|
477
|
+
xbbcli auth-login
|
|
419
478
|
xbbcli token-set --corpid your_corpid --token your_token --userId your_userid
|
|
479
|
+
xbbcli auth-status
|
|
480
|
+
xbbcli auth-logout
|
|
420
481
|
```
|
|
421
482
|
|
|
422
483
|
### 员工信息
|
package/auth-login.js
ADDED
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import { cli, Strategy } from './xbb-registry.js';
|
|
3
|
+
import { openBrowser, findBrowserExecutable, LOGIN_PAGE_URL } from './xbb-browser.js';
|
|
4
|
+
import { saveCompanyCredentials } from './xbb-token-store.js';
|
|
5
|
+
|
|
6
|
+
const API_TOKEN_PATH = '/pro/v1/apiToken/getApiToken';
|
|
7
|
+
const PRO_GATEWAY_ORIGIN = 'https://progateway.xbongbong.com';
|
|
8
|
+
const APP_GATEWAY_ORIGIN = 'https://appgateway.xbongbong.com';
|
|
9
|
+
const PLATFORM = 'web';
|
|
10
|
+
// 登录成功判据:页面已跳转到工作台首页 /#/app/home
|
|
11
|
+
const LOGIN_SUCCESS_PATTERN = /#\/app\/home/;
|
|
12
|
+
const DEFAULT_TIMEOUT_SECONDS = 300;
|
|
13
|
+
const POLL_INTERVAL_MS = 1000;
|
|
14
|
+
// 网关提示登录态过期时,清掉页面里的旧会话并继续等待用户重新登录
|
|
15
|
+
const SESSION_EXPIRED_CODE = 100012;
|
|
16
|
+
|
|
17
|
+
function delay(ms) {
|
|
18
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function normalizeArg(value) {
|
|
22
|
+
return String(value ?? '').trim();
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// 网关域名与企业 baseurl 用同一套 corpid 判定:ding 开头或含 $$ding 走 pro 网关
|
|
26
|
+
function resolveGatewayUrl(corpid) {
|
|
27
|
+
const value = normalizeArg(corpid);
|
|
28
|
+
const origin = value.startsWith('ding') || value.includes('$$ding') ? PRO_GATEWAY_ORIGIN : APP_GATEWAY_ORIGIN;
|
|
29
|
+
return `${origin}${API_TOKEN_PATH}`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function buildSign(body, secret) {
|
|
33
|
+
return crypto.createHash('sha256').update(body + secret).digest('hex');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function maskSecret(value) {
|
|
37
|
+
const text = String(value || '');
|
|
38
|
+
if (!text) return '';
|
|
39
|
+
return `${text.slice(0, 6)}***(${text.length})`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// 统一输出:成功时与 token-set 的列保持一致,失败时回落到合成错误行(不抛异常)
|
|
43
|
+
function makeRow(fields) {
|
|
44
|
+
return [{
|
|
45
|
+
status: '',
|
|
46
|
+
message: '',
|
|
47
|
+
configFile: '',
|
|
48
|
+
corpid: '',
|
|
49
|
+
baseurl: '',
|
|
50
|
+
userId: '',
|
|
51
|
+
enable: '',
|
|
52
|
+
companyCount: '',
|
|
53
|
+
formlistFile: '',
|
|
54
|
+
commandMapFile: '',
|
|
55
|
+
departmentUserFile: '',
|
|
56
|
+
corpName: '',
|
|
57
|
+
userName: '',
|
|
58
|
+
envStored: '',
|
|
59
|
+
envFile: '',
|
|
60
|
+
code: '',
|
|
61
|
+
msg: '',
|
|
62
|
+
...fields,
|
|
63
|
+
}];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function makeErrorRow(code, msg, extra = {}) {
|
|
67
|
+
return makeRow({ status: 'error', code, msg, message: msg, ...extra });
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// 读取登录页写入的 web 会话(登录成功后前端会写入 corpid / userId / xbbAccessToken)
|
|
71
|
+
const READ_SESSION_EXPRESSION = `JSON.stringify({
|
|
72
|
+
corpid: localStorage.getItem('corpid') || '',
|
|
73
|
+
userId: localStorage.getItem('userId') || '',
|
|
74
|
+
accessToken: localStorage.getItem('xbbAccessToken') || '',
|
|
75
|
+
corpName: localStorage.getItem('corpName') || '',
|
|
76
|
+
userName: localStorage.getItem('userName') || '',
|
|
77
|
+
href: location.href
|
|
78
|
+
})`;
|
|
79
|
+
|
|
80
|
+
async function readSession(session) {
|
|
81
|
+
const raw = await session.evaluate(READ_SESSION_EXPRESSION, { attempts: 1, delayMs: 100 });
|
|
82
|
+
if (typeof raw !== 'string' || !raw) throw new Error('未读取到页面登录态');
|
|
83
|
+
const parsed = JSON.parse(raw);
|
|
84
|
+
return {
|
|
85
|
+
corpid: normalizeArg(parsed.corpid),
|
|
86
|
+
userId: normalizeArg(parsed.userId),
|
|
87
|
+
accessToken: normalizeArg(parsed.accessToken),
|
|
88
|
+
corpName: normalizeArg(parsed.corpName),
|
|
89
|
+
userName: normalizeArg(parsed.userName),
|
|
90
|
+
href: normalizeArg(parsed.href),
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// 在页面内调用网关换取 API token:CORS 全开,sign = SHA256(JSON body + xbbAccessToken)
|
|
95
|
+
async function exchangeApiToken(session, loginSession, debug) {
|
|
96
|
+
const url = resolveGatewayUrl(loginSession.corpid);
|
|
97
|
+
const body = JSON.stringify({ corpid: loginSession.corpid, userId: loginSession.userId, platform: PLATFORM });
|
|
98
|
+
const sign = buildSign(body, loginSession.accessToken);
|
|
99
|
+
const expression = `(async () => {
|
|
100
|
+
const response = await fetch(${JSON.stringify(url)}, {
|
|
101
|
+
method: 'POST',
|
|
102
|
+
headers: { 'Content-Type': 'application/json;charset=UTF-8', corpid: ${JSON.stringify(loginSession.corpid)}, sign: ${JSON.stringify(sign)} },
|
|
103
|
+
body: ${JSON.stringify(body)}
|
|
104
|
+
});
|
|
105
|
+
return await response.text();
|
|
106
|
+
})()`;
|
|
107
|
+
|
|
108
|
+
if (debug) {
|
|
109
|
+
process.stderr.write(`[debug] Gateway: ${url}\n[debug] Headers: ${JSON.stringify({ 'Content-Type': 'application/json;charset=UTF-8', corpid: loginSession.corpid, sign })}\n[debug] RequestBody: ${body}\n[debug] SessionToken: ${maskSecret(loginSession.accessToken)}\n`);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const text = await session.evaluate(expression, { attempts: 3, delayMs: 200 });
|
|
113
|
+
const responseText = typeof text === 'string' ? text : JSON.stringify(text);
|
|
114
|
+
if (debug) process.stderr.write(`[debug] ResponseBody: ${responseText}\n`);
|
|
115
|
+
|
|
116
|
+
let data;
|
|
117
|
+
try {
|
|
118
|
+
data = JSON.parse(responseText);
|
|
119
|
+
} catch {
|
|
120
|
+
return { error: `换取 API token 失败:返回内容不是 JSON(${responseText.slice(0, 200)})` };
|
|
121
|
+
}
|
|
122
|
+
if (Number(data.code) === SESSION_EXPIRED_CODE) {
|
|
123
|
+
return { sessionExpired: true };
|
|
124
|
+
}
|
|
125
|
+
if (Number(data.code) !== 1) {
|
|
126
|
+
return { error: `换取 API token 失败:${data.msg || '未知错误'}(code ${data.code ?? ''});也可到销帮帮「个人中心 - API 设置」复制 token 后执行 xbbcli token-set` };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const result = data.result;
|
|
130
|
+
const token = normalizeArg(typeof result === 'string' ? result : (result && result.token));
|
|
131
|
+
if (!token) {
|
|
132
|
+
return { error: '接口未返回 API token;可到销帮帮「个人中心 - API 设置」复制 token 后执行 xbbcli token-set' };
|
|
133
|
+
}
|
|
134
|
+
return {
|
|
135
|
+
token,
|
|
136
|
+
corpid: normalizeArg(result && result.corpid) || loginSession.corpid,
|
|
137
|
+
userId: normalizeArg(result && result.userId) || loginSession.userId,
|
|
138
|
+
responseText,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async function clearAccessToken(session) {
|
|
143
|
+
try {
|
|
144
|
+
await session.evaluate(`(() => { localStorage.removeItem('xbbAccessToken'); return 'ok'; })()`, { attempts: 1 });
|
|
145
|
+
} catch {
|
|
146
|
+
// 忽略:清理旧会话失败不阻塞后续等待
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async function finish(session, keepOpen) {
|
|
151
|
+
if (!session) return;
|
|
152
|
+
if (keepOpen) {
|
|
153
|
+
session.detach();
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
try {
|
|
157
|
+
await session.close();
|
|
158
|
+
} catch {
|
|
159
|
+
// 忽略:浏览器可能已被用户手动关闭
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async function authLogin(kwargs) {
|
|
164
|
+
const debug = Boolean(kwargs.debug);
|
|
165
|
+
const explicitBrowser = normalizeArg(kwargs.browser);
|
|
166
|
+
const keepOpen = Boolean(kwargs.keepOpen);
|
|
167
|
+
const expectCorpid = normalizeArg(kwargs.corpid);
|
|
168
|
+
const timeoutValue = normalizeArg(kwargs.timeout);
|
|
169
|
+
const timeoutSeconds = timeoutValue === '' ? DEFAULT_TIMEOUT_SECONDS : Number(timeoutValue);
|
|
170
|
+
|
|
171
|
+
if (!Number.isFinite(timeoutSeconds) || timeoutSeconds <= 0) {
|
|
172
|
+
return makeErrorRow('INVALID_TIMEOUT', '--timeout 需为大于 0 的秒数');
|
|
173
|
+
}
|
|
174
|
+
if (!findBrowserExecutable(explicitBrowser)) {
|
|
175
|
+
return makeErrorRow('NO_BROWSER', '未找到 Chrome 或 Edge 浏览器;请用 --browser <chrome|edge|可执行文件路径> 指定');
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
let session;
|
|
179
|
+
try {
|
|
180
|
+
session = await openBrowser({ browser: explicitBrowser, url: LOGIN_PAGE_URL });
|
|
181
|
+
} catch (error) {
|
|
182
|
+
return makeErrorRow('BROWSER_SPAWN_FAILED', `启动浏览器失败:${error.message || error}`);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (debug) {
|
|
186
|
+
process.stderr.write(`[debug] Browser: ${session.executablePath}\n[debug] Profile: ${session.userDataDir}\n[debug] LoginPage: ${LOGIN_PAGE_URL}\n`);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
try {
|
|
190
|
+
await session.waitForPage(30000);
|
|
191
|
+
} catch (error) {
|
|
192
|
+
await finish(session, keepOpen);
|
|
193
|
+
return makeErrorRow('BROWSER_LAUNCH_FAILED', `打开销帮帮登录页失败:${error.message || error}`);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
process.stderr.write('请在打开的浏览器中完成登录,登录成功后本命令会自动获取并保存 API token。\n');
|
|
197
|
+
|
|
198
|
+
const deadline = Date.now() + timeoutSeconds * 1000;
|
|
199
|
+
let lastMessage = '';
|
|
200
|
+
while (Date.now() < deadline) {
|
|
201
|
+
if (session.isClosed()) {
|
|
202
|
+
await finish(session, keepOpen);
|
|
203
|
+
return makeErrorRow('BROWSER_CLOSED', '浏览器已被关闭,登录未完成');
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
let current;
|
|
207
|
+
try {
|
|
208
|
+
current = await readSession(session);
|
|
209
|
+
} catch (error) {
|
|
210
|
+
lastMessage = String(error.message || error);
|
|
211
|
+
await delay(POLL_INTERVAL_MS);
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
if (!current.accessToken) {
|
|
216
|
+
await delay(POLL_INTERVAL_MS);
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// accessToken 已写入但页面还在登录流程中:等跳转到工作台首页再换取,避免会话未就绪时误判
|
|
221
|
+
if (!LOGIN_SUCCESS_PATTERN.test(current.href)) {
|
|
222
|
+
lastMessage = `页面地址为 ${current.href || '未知'},尚未跳转到登录成功页 /#/app/home`;
|
|
223
|
+
await delay(POLL_INTERVAL_MS);
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const exchange = await exchangeApiToken(session, current, debug);
|
|
228
|
+
if (exchange.sessionExpired) {
|
|
229
|
+
// 旧会话已过期:清掉后继续等待用户重新登录
|
|
230
|
+
await clearAccessToken(session);
|
|
231
|
+
await delay(POLL_INTERVAL_MS);
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
if (exchange.error) {
|
|
235
|
+
await finish(session, keepOpen);
|
|
236
|
+
return makeErrorRow('API_TOKEN_FAILED', exchange.error, { corpid: current.corpid, userId: current.userId });
|
|
237
|
+
}
|
|
238
|
+
if (kwargs.raw) {
|
|
239
|
+
await finish(session, keepOpen);
|
|
240
|
+
return [{ raw: exchange.responseText }];
|
|
241
|
+
}
|
|
242
|
+
if (expectCorpid && exchange.corpid !== expectCorpid) {
|
|
243
|
+
await finish(session, keepOpen);
|
|
244
|
+
return makeErrorRow(
|
|
245
|
+
'CORPID_MISMATCH',
|
|
246
|
+
`浏览器登录的企业是 ${exchange.corpid},与 --corpid ${expectCorpid} 不一致;请确认浏览器中登录了正确的企业`,
|
|
247
|
+
{ corpid: exchange.corpid, userId: exchange.userId },
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
let savedRows;
|
|
252
|
+
try {
|
|
253
|
+
savedRows = await saveCompanyCredentials({
|
|
254
|
+
corpid: exchange.corpid,
|
|
255
|
+
token: exchange.token,
|
|
256
|
+
userId: exchange.userId,
|
|
257
|
+
noEnv: Boolean(kwargs.noEnv),
|
|
258
|
+
});
|
|
259
|
+
} catch (error) {
|
|
260
|
+
await finish(session, keepOpen);
|
|
261
|
+
return makeErrorRow('SAVE_FAILED', `保存凭证失败:${error.message || error}`, { corpid: exchange.corpid, userId: exchange.userId });
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
await finish(session, keepOpen);
|
|
265
|
+
|
|
266
|
+
const saved = (savedRows && savedRows[0]) || {};
|
|
267
|
+
const savedOk = normalizeArg(saved.status) === 'ok';
|
|
268
|
+
return [{
|
|
269
|
+
...saved,
|
|
270
|
+
status: savedOk ? 'ok' : (normalizeArg(saved.status) || 'error'),
|
|
271
|
+
message: savedOk
|
|
272
|
+
? `登录成功;${saved.message || ''}`
|
|
273
|
+
: `已换取 API token,但初始化未完成:${saved.message || '未知原因'}`,
|
|
274
|
+
corpid: normalizeArg(saved.corpid) || exchange.corpid,
|
|
275
|
+
userId: normalizeArg(saved.userId) || exchange.userId,
|
|
276
|
+
baseurl: normalizeArg(saved.baseurl),
|
|
277
|
+
}];
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
await finish(session, keepOpen);
|
|
281
|
+
return makeErrorRow(
|
|
282
|
+
'LOGIN_TIMEOUT',
|
|
283
|
+
`等待登录超时(${timeoutSeconds}s),未在浏览器中检测到登录成功态${lastMessage ? `;${lastMessage}` : ''}`,
|
|
284
|
+
);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
cli({
|
|
288
|
+
site: 'xbb',
|
|
289
|
+
name: 'auth-login',
|
|
290
|
+
description: '打开浏览器完成销帮帮登录授权(等价于token-set)',
|
|
291
|
+
strategy: Strategy.PUBLIC,
|
|
292
|
+
access: 'write',
|
|
293
|
+
browser: false,
|
|
294
|
+
domain: 'appwebfront.xbongbong.com',
|
|
295
|
+
args: [
|
|
296
|
+
{ name: 'browser', type: 'str', default: '', help: '浏览器:chrome / edge / 可执行文件路径;不传则自动探测' },
|
|
297
|
+
{ name: 'timeout', type: 'str', default: '', help: `等待登录完成的秒数(默认 ${DEFAULT_TIMEOUT_SECONDS})` },
|
|
298
|
+
{ name: 'corpid', type: 'str', default: '', help: '期望登录的企业 ID;与实际登录企业不一致时报错(可选)' },
|
|
299
|
+
{ name: 'keepOpen', type: 'bool', default: false, help: '登录成功后保留浏览器窗口(默认自动关闭)' },
|
|
300
|
+
{ name: 'noEnv', type: 'bool', default: false, help: '不写入环境变量,仅保存到 config.env' },
|
|
301
|
+
{ name: 'debug', type: 'bool', default: false, help: '输出请求体、返回体与浏览器信息(密钥只显示掩码)' },
|
|
302
|
+
{ name: 'raw', type: 'bool', default: false, help: '输出换取 API token 接口返回的原文' },
|
|
303
|
+
],
|
|
304
|
+
columns: ['status', 'message', 'configFile', 'corpid', 'baseurl', 'userId', 'enable', 'companyCount', 'formlistFile', 'commandMapFile', 'departmentUserFile', 'corpName', 'userName', 'envStored', 'envFile', 'code', 'msg'],
|
|
305
|
+
func: authLogin,
|
|
306
|
+
});
|
package/auth-logout.js
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { cli, Strategy } from './xbb-registry.js';
|
|
5
|
+
import { clearEnvVars, getEnvVarNames, hasEnvConfig, isEnvOnly, readEnvConfig } from './xbb-config.js';
|
|
6
|
+
|
|
7
|
+
const CONFIG_DIR = path.join(os.homedir(), '.xbbcli');
|
|
8
|
+
const CONFIG_FILE = path.join(CONFIG_DIR, 'config.env');
|
|
9
|
+
const BACKUP_FILE = path.join(CONFIG_DIR, 'config.env.bak');
|
|
10
|
+
|
|
11
|
+
function trim(value) {
|
|
12
|
+
return String(value ?? '').trim();
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function readCompanies() {
|
|
16
|
+
try {
|
|
17
|
+
const parsed = JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8'));
|
|
18
|
+
if (Array.isArray(parsed)) {
|
|
19
|
+
return parsed.filter((item) => item && typeof item === 'object');
|
|
20
|
+
}
|
|
21
|
+
if (parsed && Array.isArray(parsed.companies)) {
|
|
22
|
+
return parsed.companies.filter((item) => item && typeof item === 'object');
|
|
23
|
+
}
|
|
24
|
+
if (parsed && parsed.corpid) {
|
|
25
|
+
const { corpid, token, baseurl, userId } = parsed;
|
|
26
|
+
return [{ corpid, token, baseurl, userId, enable: true }];
|
|
27
|
+
}
|
|
28
|
+
return [];
|
|
29
|
+
} catch {
|
|
30
|
+
return [];
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function writeCompanies(companies) {
|
|
35
|
+
fs.mkdirSync(CONFIG_DIR, { recursive: true });
|
|
36
|
+
fs.writeFileSync(CONFIG_FILE, JSON.stringify(companies, null, 2) + '\n', 'utf8');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function createRow(fields) {
|
|
40
|
+
return [{
|
|
41
|
+
status: '',
|
|
42
|
+
message: '',
|
|
43
|
+
configFile: CONFIG_FILE,
|
|
44
|
+
backupFile: '',
|
|
45
|
+
corpid: '',
|
|
46
|
+
corpName: '',
|
|
47
|
+
userId: '',
|
|
48
|
+
companyCount: '',
|
|
49
|
+
hasActive: '',
|
|
50
|
+
envStored: '',
|
|
51
|
+
envFile: '',
|
|
52
|
+
code: '',
|
|
53
|
+
msg: '',
|
|
54
|
+
...fields,
|
|
55
|
+
}];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function previewCorpids(companies) {
|
|
59
|
+
const names = companies.slice(0, 3).map((item) => trim(item.corpid)).filter(Boolean);
|
|
60
|
+
return names.join(', ') + (companies.length > 3 ? ' …' : '');
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function authLogout(kwargs) {
|
|
64
|
+
const alsoEnv = !kwargs.noEnv && kwargs.env !== false;
|
|
65
|
+
const companies = readCompanies();
|
|
66
|
+
const index = companies.findIndex((item) => item && item.enable === true);
|
|
67
|
+
const envPresent = hasEnvConfig();
|
|
68
|
+
const envBefore = envPresent ? trim(readEnvConfig().corpid) : '';
|
|
69
|
+
|
|
70
|
+
if (kwargs.debug) {
|
|
71
|
+
process.stderr.write(`[debug] ConfigFile: ${CONFIG_FILE}\n[debug] BackupFile: ${BACKUP_FILE}\n[debug] Companies: ${companies.length}\n[debug] EnabledIndex: ${index}\n[debug] ClearEnv: ${alsoEnv}\n[debug] EnvCorpid: ${envBefore || 'none'}\n[debug] EnvVarsSet: ${getEnvVarNames().filter((name) => trim(process.env[name])).join(', ') || 'none'}\n`);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (index === -1 && (!alsoEnv || !envPresent)) {
|
|
75
|
+
const scope = companies.length
|
|
76
|
+
? `本地共 ${companies.length} 家公司(${previewCorpids(companies)})且均未启用;要登出某家请先用 xbbcli token-use --corpid <CORPID> 启用,或改用 xbbcli token-del --corpid <CORPID> 直接删除`
|
|
77
|
+
: '本地没有任何公司配置';
|
|
78
|
+
const envNote = envPresent
|
|
79
|
+
? '环境变量 XBB_* 仍存在,本次未清除(去掉 --noEnv / --env=false 即可一并清除)'
|
|
80
|
+
: '环境变量 XBB_* 也不存在,本次无可清除内容';
|
|
81
|
+
const hint = `${scope};${envNote}`;
|
|
82
|
+
return createRow({
|
|
83
|
+
status: 'error',
|
|
84
|
+
message: `config.env 中没有处于激活状态(enable=true)的配置;${hint}`,
|
|
85
|
+
companyCount: String(companies.length),
|
|
86
|
+
hasActive: 'false',
|
|
87
|
+
envStored: 'skipped',
|
|
88
|
+
code: 'NO_ACTIVE_CONFIG',
|
|
89
|
+
msg: isEnvOnly() ? `${hint}(当前 XBB_ENV_ONLY=1,config.env 被忽略)` : hint,
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const target = index === -1 ? null : companies[index];
|
|
94
|
+
const remaining = target ? companies.filter((item, itemIndex) => itemIndex !== index) : companies;
|
|
95
|
+
let backupFile = '';
|
|
96
|
+
|
|
97
|
+
if (target) {
|
|
98
|
+
try {
|
|
99
|
+
if (fs.existsSync(CONFIG_FILE)) {
|
|
100
|
+
fs.copyFileSync(CONFIG_FILE, BACKUP_FILE);
|
|
101
|
+
backupFile = BACKUP_FILE;
|
|
102
|
+
}
|
|
103
|
+
writeCompanies(remaining);
|
|
104
|
+
} catch (error) {
|
|
105
|
+
const detail = String(error.message || error);
|
|
106
|
+
return createRow({
|
|
107
|
+
status: 'error',
|
|
108
|
+
message: `删除激活配置失败:${detail};config.env 未被改动`,
|
|
109
|
+
corpid: trim(target.corpid),
|
|
110
|
+
companyCount: String(companies.length),
|
|
111
|
+
envStored: 'skipped',
|
|
112
|
+
code: 'WRITE_FAILED',
|
|
113
|
+
msg: detail,
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
let envStored = 'skipped';
|
|
119
|
+
let envFile = '';
|
|
120
|
+
const parts = [];
|
|
121
|
+
parts.push(target
|
|
122
|
+
? `已登出 corpid=${trim(target.corpid)}(config.env 中该条配置已删除,剩余 ${remaining.length} 家${remaining.length ? ',均未启用' : ',本地已无配置'})`
|
|
123
|
+
: 'config.env 中没有激活配置,未改动');
|
|
124
|
+
if (backupFile) {
|
|
125
|
+
parts.push(`原配置已备份到 ${backupFile}`);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (alsoEnv) {
|
|
129
|
+
try {
|
|
130
|
+
const result = clearEnvVars();
|
|
131
|
+
envStored = result.stored;
|
|
132
|
+
envFile = result.file;
|
|
133
|
+
const suffix = process.platform === 'win32' ? ';已打开的终端需重开才会消失' : '';
|
|
134
|
+
parts.push(`环境变量 ${getEnvVarNames().join('、')} 已清除(${envStored})${suffix}`);
|
|
135
|
+
} catch (error) {
|
|
136
|
+
envStored = 'failed';
|
|
137
|
+
envFile = String(error.message || error);
|
|
138
|
+
parts.push(`清除环境变量失败:${envFile}`);
|
|
139
|
+
}
|
|
140
|
+
} else if (envBefore) {
|
|
141
|
+
const forced = isEnvOnly() ? '且 XBB_ENV_ONLY=1 已强制只用环境变量' : '业务命令会回落到它';
|
|
142
|
+
parts.push(`注意:环境变量 XBB_*(corpid=${envBefore})仍然有效,${forced},本次不算彻底登出;如需一并清除请去掉 --noEnv(auth-logout 默认会清除环境变量)`);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const remainingCompanies = target ? remaining : readCompanies();
|
|
146
|
+
return createRow({
|
|
147
|
+
status: envStored === 'failed' ? 'partial' : 'ok',
|
|
148
|
+
message: parts.join(';'),
|
|
149
|
+
backupFile,
|
|
150
|
+
corpid: target ? trim(target.corpid) : '',
|
|
151
|
+
corpName: target ? trim(target.corpName) : '',
|
|
152
|
+
userId: target ? trim(target.userId) : '',
|
|
153
|
+
companyCount: String(remainingCompanies.length),
|
|
154
|
+
hasActive: remainingCompanies.some((item) => item && item.enable === true) ? 'true' : 'false',
|
|
155
|
+
envStored,
|
|
156
|
+
envFile,
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
cli({
|
|
161
|
+
site: 'xbb',
|
|
162
|
+
name: 'auth-logout',
|
|
163
|
+
description: '登出',
|
|
164
|
+
strategy: Strategy.PUBLIC,
|
|
165
|
+
access: 'write',
|
|
166
|
+
browser: false,
|
|
167
|
+
domain: 'proapi.xbongbong.com',
|
|
168
|
+
args: [
|
|
169
|
+
{ name: 'env', type: 'bool', default: true, help: '同时清除 XBB_* 环境变量(默认开启;--env=false 跳过)。Windows 删除用户级变量,其他平台清理 ~/.xbbcli/env.sh' },
|
|
170
|
+
{ name: 'noEnv', type: 'bool', default: false, help: '不清除环境变量,仅删除 config.env 中的激活配置(与 token-set 的 --noEnv 一致)' },
|
|
171
|
+
{ name: 'debug', type: 'bool', default: false, help: '输出配置文件、备份路径与生效的环境变量名(不打印 token)' },
|
|
172
|
+
],
|
|
173
|
+
columns: ['status', 'message', 'configFile', 'backupFile', 'corpid', 'corpName', 'userId', 'companyCount', 'hasActive', 'envStored', 'envFile', 'code', 'msg'],
|
|
174
|
+
func: authLogout,
|
|
175
|
+
});
|