@xinhe-oa/cli 0.0.0-stage → 0.2.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 +159 -2
- package/bin/xinhe-oa-cli.js +13 -0
- package/package.json +40 -4
- package/src/commands/application.js +244 -0
- package/src/commands/approval.js +275 -0
- package/src/commands/attachment.js +202 -0
- package/src/commands/bitable.js +3040 -0
- package/src/commands/config.js +186 -0
- package/src/commands/dashboard.js +132 -0
- package/src/commands/doctor.js +975 -0
- package/src/commands/event.js +151 -0
- package/src/commands/file.js +1474 -0
- package/src/commands/index.js +78 -0
- package/src/commands/investor.js +179 -0
- package/src/commands/member.js +1341 -0
- package/src/commands/org.js +399 -0
- package/src/commands/report.js +908 -0
- package/src/commands/schema.js +75 -0
- package/src/commands/session.js +516 -0
- package/src/commands/skills.js +1117 -0
- package/src/commands/tenant.js +202 -0
- package/src/commands/workflow.js +176 -0
- package/src/core/args.js +901 -0
- package/src/core/auth.js +349 -0
- package/src/core/checkpoint.js +226 -0
- package/src/core/config.js +407 -0
- package/src/core/error.js +217 -0
- package/src/core/http.js +903 -0
- package/src/core/import.js +753 -0
- package/src/core/output.js +300 -0
- package/src/core/runtime.js +302 -0
- package/src/core/util.js +195 -0
- package/src/core/values.js +278 -0
- package/src/core/xlsx.js +321 -0
- package/src/main.js +205 -0
- package/src/spec/agent_guide.md +55 -0
- package/src/spec/command_surface.json +27073 -0
- package/src/spec/embedded_skills.json +19 -0
- package/src/version.js +16 -0
|
@@ -0,0 +1,975 @@
|
|
|
1
|
+
// 自检:`doctor`(契约 §2 的「自检」行、§5 的第 ⑦ 段、§3.2 退出码)。
|
|
2
|
+
//
|
|
3
|
+
// 回答四个问题:**版本对不对、凭据在不在、设备指纹稳不稳、连不连得上**,
|
|
4
|
+
// 外加"服务端当前发布的是哪一版、这台机器要不要/能不能升上去"。
|
|
5
|
+
// 每一项都给"下一步该敲什么",而不是只报一串状态。
|
|
6
|
+
//
|
|
7
|
+
// ## 五件容易做错的事(都是 Rust 版拿实测换来的口径,逐条照抄)
|
|
8
|
+
//
|
|
9
|
+
// 1. **`doctor` 正常路径永远以退出码 0 收尾**,哪怕报告里全是问题。
|
|
10
|
+
// "连不上服务端"只是 `problems` 里的一项(`what="连不上服务端"`),不是退出码 7:
|
|
11
|
+
// 自检的价值恰恰在于网络不通时也能把本机状态(凭据、权限、代理判定)说清楚;
|
|
12
|
+
// `/cli/latest` 问不到(旧服务端没有这个端点)同理,只记进 `release.error`。
|
|
13
|
+
// 唯一的硬失败是服务端**明确判定太旧**(`cli.too_old`)——那是退出码 8。
|
|
14
|
+
// 2. **`/health` 与 `/cli/latest` 都是匿名请求**(`capability="public"`):
|
|
15
|
+
// 凭据过期、刚装好还没登录的人也要能自检、能问出该装哪一版 ——
|
|
16
|
+
// 把版本协商关在登录之后,等于让最需要自救的人连"我该升到哪一版"都问不出来。
|
|
17
|
+
// 唯一的例外是 `--upgrade` 的第一步:它走 `sendAuthed`(见下)。
|
|
18
|
+
// 3. **`--dry-run` 与 `--out` 是两件事**(契约 §5 ⑦ 写得很细):
|
|
19
|
+
// * `--dry-run`:**升级动作本身**止步于"问一次 `/cli/latest`"——不下载、不写任何文件。
|
|
20
|
+
// 它是给人看的"将要发生什么",不是"轻量地做一次"。Rust 版真踩过这个坑:
|
|
21
|
+
// `doctor --upgrade --dry-run` 曾经真的把自己换掉了。
|
|
22
|
+
// (注意线上其实有**两次** `/cli/latest`:一次是上面第 2 条的匿名 release 探测,
|
|
23
|
+
// 一次是 `--upgrade` 自己的第一步,两次都只读,且实测 Rust 0.1.7 也是两次。)
|
|
24
|
+
// * `--out <路径>`:把**服务端当前发布的那一份产物**写到指定路径,
|
|
25
|
+
// **当前可执行文件一个字节都不动**;它**与本机是不是最新无关** ——
|
|
26
|
+
// 本机恰好最新时若走"已是最新,什么都不做"的早退,用户拿到的是
|
|
27
|
+
// "命令成功、路径上却什么都没有"(装到别的机器 / 排障 / 核对 sha256 都靠它)。
|
|
28
|
+
// 4. **`--upgrade` 失败不改写 `problems`、也不改退出码**:它落进
|
|
29
|
+
// `data.upgrade.error` + `data.next_steps`,`ok` 仍可能是 true。
|
|
30
|
+
// 这是 Rust 的口径:`problems` 是"本机自检发现的问题",升级失败是"这次动作没做成",
|
|
31
|
+
// 两件事分开报,脚本才不会把"升级失败"读成"这台机器坏了"。
|
|
32
|
+
// 绝不假装成功:没落盘就一定 `replaced=false` + `error`。
|
|
33
|
+
// 5. **升级的动作有两套,凭据要求也不同**(2026-10-16 起):
|
|
34
|
+
// * **不带 `--out`**(= 常规升级)只问 public 的 npm registry,**不需要登录** ——
|
|
35
|
+
// 升级是包管理器的事,把一个 npm 查询绑在平台凭据上只会让"登录过期的人连
|
|
36
|
+
// 升级命令都问不出来"。这条路**一个字节都不写**,只给 `npm i -g …@latest`。
|
|
37
|
+
// * **带 `--out`** 走服务端的产物通道:`/cli/download/<os>/<arch>` 要有效令牌
|
|
38
|
+
// (产物不该匿名可取),而令牌只活在 0600 的凭据文件里(刻意不打印、不进 argv),
|
|
39
|
+
// 所以没登录会报 `auth.missing_token`;"把 URL 给人拿浏览器下"这条路走不通,
|
|
40
|
+
// 要用产物就得让 CLI 自己带凭据去取。
|
|
41
|
+
|
|
42
|
+
import crypto from 'node:crypto';
|
|
43
|
+
import fs from 'node:fs';
|
|
44
|
+
import path from 'node:path';
|
|
45
|
+
|
|
46
|
+
import embeddedSkills from '../spec/embedded_skills.json' with { type: 'json' };
|
|
47
|
+
import { EXIT_TOO_OLD, Kind, CliError, loginHint } from '../core/error.js';
|
|
48
|
+
import { PACKAGE_NAME } from '../version.js';
|
|
49
|
+
import { flagOn, flagValue } from '../core/args.js';
|
|
50
|
+
import { ProxyPlan, USER_AGENT, VERSION_HEADER, requestSpec } from '../core/http.js';
|
|
51
|
+
import { CredentialStore } from '../core/config.js';
|
|
52
|
+
import { renderHuman } from '../core/output.js';
|
|
53
|
+
import { maskPhone, maskToken, sortKeysDeep } from '../core/util.js';
|
|
54
|
+
import { accessNeedsRefresh, refreshExpired } from '../core/auth.js';
|
|
55
|
+
import { VERSION } from '../version.js';
|
|
56
|
+
import { probeHealth, fetchLatestRelease } from './config.js';
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* 关于"换掉自己"这件事(2026-10-16 之后 **没有**这条路了,读之前先看结论)
|
|
60
|
+
*
|
|
61
|
+
* Node 版曾经移植过 Rust 的自替换:把 `/cli/download/...` 下来的产物写到
|
|
62
|
+
* `package.json` 的 `bin` 映射指向的 `bin/xinhe-oa-cli.js`(用 `import.meta.url`
|
|
63
|
+
* 自定位,而不是 `argv[1]` —— 本模块也能被 `import { runCli }` 当库用,那时的
|
|
64
|
+
* `argv[1]` 是宿主的脚本,拿它当"自己"等于删别人的文件)。
|
|
65
|
+
*
|
|
66
|
+
* 现在这条路**整段删掉**了,理由两条(细节见下面 `npmUpgradePlan` 的注释):
|
|
67
|
+
*
|
|
68
|
+
* 1. 入口由 npm 管理:包的文件树归 npm 记账,自己替换其中一个文件会让
|
|
69
|
+
* `package.json` 的版本与磁盘内容对不上,下次 `npm i -g` 的行为不可预期;
|
|
70
|
+
* 2. 通道会串:`/cli/download/{os}/{arch}` 现在是**给安装脚本与手工下载**用的
|
|
71
|
+
* Node 入口脚本(见 `cli-node/scripts/download-entry.template.js`),
|
|
72
|
+
* 而它过去发的是 Rust 二进制 —— 同一组平台键下换内容,只靠 sha256 分辨不出来。
|
|
73
|
+
*
|
|
74
|
+
* 于是 `--upgrade` 只剩两条落盘语义,都不碰"自己":
|
|
75
|
+
* * 不带 `--out`:只问 npm registry,给一句 `npm i -g …@latest`(`npmUpgradePlan`);
|
|
76
|
+
* * 带 `--out`:把服务端当前发布的那份产物写到**那个路径**(`--out` 的本义),
|
|
77
|
+
* 并且落盘前过一道内容嗅探(`assertLooksLikeJavaScript`)。
|
|
78
|
+
*/
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* 内嵌技能数量。
|
|
82
|
+
*
|
|
83
|
+
* Rust 版是 `build.rs` 在**构建期**扫仓库 `skills/<名字>/SKILL.md` 得到的常量;
|
|
84
|
+
* Node 版没有构建期,内嵌清单由 `scripts/gen-embedded-skills.py` 生成到
|
|
85
|
+
* `src/spec/embedded_skills.json`(`skills.js` 读的也是它)。两者是同一份事实,
|
|
86
|
+
* 所以这里**读真实清单**而不是写常数 —— 写死 0 会让 `doctor --json` 的
|
|
87
|
+
* `embedded_skills` 与 Rust 不一致,而那是"两边功能一致"要盯的字段。
|
|
88
|
+
*/
|
|
89
|
+
const EMBEDDED_SKILLS = embeddedSkills.skills.length;
|
|
90
|
+
|
|
91
|
+
/** 平台词汇必须与服务端 `/cli/latest` 的 `platforms[].os/arch` 同一套。 */
|
|
92
|
+
export function platform() {
|
|
93
|
+
const os =
|
|
94
|
+
process.platform === 'win32' ? 'windows' : process.platform === 'darwin' ? 'darwin' : 'linux';
|
|
95
|
+
// 与 Rust 的 `cfg!(target_arch = "aarch64")` 等价:**只有** arm64 是 arm64,
|
|
96
|
+
// 其余一律 amd64(服务端只认这两套词汇)。
|
|
97
|
+
const arch = process.arch === 'arm64' ? 'arm64' : 'amd64';
|
|
98
|
+
return [os, arch];
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** 服务端 `{"data": ...}` 拆包(`/cli/latest` 两种形状都容忍)。 */
|
|
102
|
+
function unwrap(body) {
|
|
103
|
+
if (body && typeof body === 'object' && body.data !== undefined && body.data !== null) {
|
|
104
|
+
return body.data;
|
|
105
|
+
}
|
|
106
|
+
return body ?? {};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function isPlainObject(value) {
|
|
110
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** 错误摘要(`doctor` 的报告里错误只以 `{code, message}` 出现)。 */
|
|
114
|
+
function errorSummary(err) {
|
|
115
|
+
return {
|
|
116
|
+
code: typeof err?.code === 'string' ? err.code : 'cli.network',
|
|
117
|
+
message: typeof err?.message === 'string' ? err.message : String(err),
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* 出网期间**钉住事件循环**(一个兜底定时器,跑完就放掉)。
|
|
123
|
+
*
|
|
124
|
+
* 为什么需要:`core/http.js` 的退避重试 `sleep()` 一度用的是 **unref 过的**
|
|
125
|
+
* `setTimeout`。当进程里只剩这一个待办时,Node 判定"没活干了"直接退出 ——
|
|
126
|
+
* 顶层 `await` 还没结算,于是退出码 13(`Detected unsettled top-level await`),
|
|
127
|
+
* stdout 一个字节都没有。
|
|
128
|
+
*
|
|
129
|
+
* 后果正好落在最需要自检的那条路径上:`base_url` 指向一个没人听的端口时,
|
|
130
|
+
* `doctor --json` 只往 stderr 打一行"网络故障…重试",然后**退出 13、没有报告**
|
|
131
|
+
* (实测四种 stdio 组合都一样)。Rust 版没有这个问题(tokio 的 sleep 会撑着运行时),
|
|
132
|
+
* 而契约 §2 要求"不联网时 doctor 仍以退出码 0 收尾,报告里如实记一条'连不上服务端'"。
|
|
133
|
+
*
|
|
134
|
+
* 本文件写完时 core 已经把那处 unref 去掉了(`http.js` 的 `sleep` 现在是普通定时器),
|
|
135
|
+
* 这层兜底仍然保留:`doctor` 的不联网口径不该取决于"别人的定时器有没有 unref",
|
|
136
|
+
* 一个空定时器的成本可以忽略,而失效的样子(静默退出、没有报告)最难排查。
|
|
137
|
+
*/
|
|
138
|
+
async function keepAlive(task) {
|
|
139
|
+
const handle = setInterval(() => {}, 1000);
|
|
140
|
+
try {
|
|
141
|
+
return await task();
|
|
142
|
+
} finally {
|
|
143
|
+
clearInterval(handle);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Unix 权限位的八进制串(`700` / `644` …)。
|
|
149
|
+
*
|
|
150
|
+
* 与 Rust 的 `format!("{:o}", mode & 0o777)` 同形:**不带**前导 0,
|
|
151
|
+
* 所以下面的比较写的是 `"600"` 而不是 `"0600"`。
|
|
152
|
+
*/
|
|
153
|
+
function modeString(target) {
|
|
154
|
+
if (process.platform === 'win32') return null;
|
|
155
|
+
try {
|
|
156
|
+
return (fs.statSync(target).mode & 0o777).toString(8);
|
|
157
|
+
} catch {
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Rust `Path::parent()` 的口径(`cfg` 的父目录是 `""`,不是 Node 的 `"."`)。 */
|
|
163
|
+
function parentPath(target) {
|
|
164
|
+
const text = String(target).replace(/[\\/]+$/, '');
|
|
165
|
+
if (text.length === 0) return null;
|
|
166
|
+
const cut = Math.max(text.lastIndexOf('/'), text.lastIndexOf('\\'));
|
|
167
|
+
if (cut < 0) return '';
|
|
168
|
+
return cut === 0 ? text.slice(0, 1) : text.slice(0, cut);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** sha256(小写十六进制,64 字符)——校验下载产物用。 */
|
|
172
|
+
function sha256Hex(bytes) {
|
|
173
|
+
return crypto.createHash('sha256').update(bytes).digest('hex');
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Rust `Path::with_extension()`:**替换**最后一个点之后的部分(不是往后追加)。 */
|
|
177
|
+
function withExtension(target, extension) {
|
|
178
|
+
const base = path.basename(target);
|
|
179
|
+
const dot = base.lastIndexOf('.');
|
|
180
|
+
// 以 `.` 开头且再无点(`.hidden`)在 Rust 里算"没有扩展名",stem 是整个文件名。
|
|
181
|
+
const stem = dot > 0 ? base.slice(0, dot) : base;
|
|
182
|
+
return path.join(path.dirname(target), `${stem}.${extension}`);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function removeQuietly(target) {
|
|
186
|
+
try {
|
|
187
|
+
fs.unlinkSync(target);
|
|
188
|
+
return true;
|
|
189
|
+
} catch {
|
|
190
|
+
return false;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** 落盘 + **强制 0755**(脚本要能直接执行;Rust 侧同样是写后 set_permissions)。 */
|
|
195
|
+
function writeExecutable(target, bytes, label = '') {
|
|
196
|
+
try {
|
|
197
|
+
fs.writeFileSync(target, bytes);
|
|
198
|
+
} catch (err) {
|
|
199
|
+
throw CliError.usage(`写不了${label} ${target}:${err.message}`);
|
|
200
|
+
}
|
|
201
|
+
if (process.platform !== 'win32') {
|
|
202
|
+
try {
|
|
203
|
+
fs.chmodSync(target, 0o755);
|
|
204
|
+
} catch {
|
|
205
|
+
// 与 Rust 的 `let _ = set_permissions(...)` 一致:收权限失败不阻断落盘。
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* 落位:`<目标>` → `<目标>.old`,新内容 → `<目标>`(Rust `swap_in_place` 的逐句移植)。
|
|
212
|
+
*
|
|
213
|
+
* 两条纪律:
|
|
214
|
+
* * 目标**不存在**不是"替换"而是"装到新位置"(`--out <新路径>`):没有旧文件可改名,
|
|
215
|
+
* 直接落盘(父目录按需创建)。Rust 侧实测踩过:rename 会报 "No such file or directory",
|
|
216
|
+
* 而错误信息把原因说成"可能没有写权限"——指错方向。
|
|
217
|
+
* * 替换失败**尽量还原**:宁可回到旧版本,也不要"两个都没有"。
|
|
218
|
+
*/
|
|
219
|
+
function swapInPlace(target, bytes) {
|
|
220
|
+
// 上一次升级留下的 `.old`:这次能删就删(Windows 上上一次的进程早已退出)。
|
|
221
|
+
const stale = `${target}.old`;
|
|
222
|
+
const staleRemoved = fs.existsSync(stale) ? removeQuietly(stale) : false;
|
|
223
|
+
|
|
224
|
+
if (!fs.existsSync(target)) {
|
|
225
|
+
const parent = path.dirname(target);
|
|
226
|
+
if (parent.length > 0 && parent !== '.') {
|
|
227
|
+
try {
|
|
228
|
+
fs.mkdirSync(parent, { recursive: true });
|
|
229
|
+
} catch {
|
|
230
|
+
// 与 Rust 的 `let _ = create_dir_all` 一致:建不了就让下面的写操作去报错。
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
writeExecutable(target, bytes);
|
|
234
|
+
return staleRemoved;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const staging = withExtension(target, 'new');
|
|
238
|
+
writeExecutable(staging, bytes, '临时文件');
|
|
239
|
+
try {
|
|
240
|
+
fs.renameSync(target, stale);
|
|
241
|
+
} catch (err) {
|
|
242
|
+
removeQuietly(staging);
|
|
243
|
+
throw CliError.usage(
|
|
244
|
+
`换不了 ${target}:${err.message}(可能没有写权限;试 \`--out\` 写到别处,或用管理员/root 再跑一次)`,
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
try {
|
|
248
|
+
fs.renameSync(staging, target);
|
|
249
|
+
} catch (err) {
|
|
250
|
+
try {
|
|
251
|
+
fs.renameSync(stale, target);
|
|
252
|
+
} catch {
|
|
253
|
+
// 还原也失败:下面的错误信息会如实说"已尝试还原"。
|
|
254
|
+
}
|
|
255
|
+
removeQuietly(staging);
|
|
256
|
+
throw CliError.usage(`新版本落位失败:${err.message};已尝试还原旧版本`);
|
|
257
|
+
}
|
|
258
|
+
return staleRemoved;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** 从 `/cli/latest` 的响应里挑出本机平台的产物。 */
|
|
262
|
+
function artifactFor(latest, os, arch) {
|
|
263
|
+
const platforms = Array.isArray(latest?.platforms) ? latest.platforms : null;
|
|
264
|
+
if (platforms === null) return null;
|
|
265
|
+
return (
|
|
266
|
+
platforms.find(
|
|
267
|
+
(item) => item?.os === os && item?.arch === arch,
|
|
268
|
+
) ?? null
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* 升级结果 → `data.upgrade`(Rust `UpgradeOutcome::to_json` 的逐字移植)。
|
|
274
|
+
*
|
|
275
|
+
* `replaced` 说的是"有没有落盘",`target` 说的是"落在哪儿":
|
|
276
|
+
* 两者合起来(`current_binary_touched`)才不会把 `--out` 误读成"我被换了"。
|
|
277
|
+
*/
|
|
278
|
+
function outcomeJson(outcome) {
|
|
279
|
+
const touchedSelf = outcome.replaced && !outcome.installedElsewhere;
|
|
280
|
+
const pathText = outcome.path;
|
|
281
|
+
let nextStep;
|
|
282
|
+
// npm 通道的 `next_step` 在 `npmUpgradePlan` 里就算好了(它知道 registry 通不通、
|
|
283
|
+
// 有没有新版本),这里**不要**用下面的"落盘/替换"话术覆盖它。
|
|
284
|
+
if (outcome.npm !== undefined && outcome.npm !== null) {
|
|
285
|
+
nextStep = outcome.next_step;
|
|
286
|
+
} else if (outcome.dryRun && outcome.installedElsewhere) {
|
|
287
|
+
nextStep = `--dry-run:没有下载、也没写任何文件;去掉 --dry-run 才会把 ${outcome.to} 的产物写到 ${pathText}`;
|
|
288
|
+
} else if (outcome.dryRun) {
|
|
289
|
+
nextStep = `--dry-run:没有下载、没有替换;去掉 --dry-run 才会把 ${outcome.from} 换成 ${outcome.to}(${pathText})`;
|
|
290
|
+
} else if (outcome.installedElsewhere) {
|
|
291
|
+
nextStep = `产物已写到 ${pathText}(sha256 已校验);当前运行的二进制没有改动`;
|
|
292
|
+
} else if (outcome.replaced) {
|
|
293
|
+
nextStep =
|
|
294
|
+
'重开一个终端(或重新执行命令)用的就是新版本;`xinhe-oa-cli --version` 可以核对';
|
|
295
|
+
} else {
|
|
296
|
+
nextStep = '已经是最新,什么都不用做';
|
|
297
|
+
}
|
|
298
|
+
return {
|
|
299
|
+
from: outcome.from,
|
|
300
|
+
to: outcome.to,
|
|
301
|
+
path: pathText,
|
|
302
|
+
target: outcome.installedElsewhere ? 'out' : 'npm',
|
|
303
|
+
// npm 通道的细节(`target: "npm"` 时才有):包名、registry、探测到的版本、
|
|
304
|
+
// "是不是更新",以及可直接照抄的命令。`error` 非空表示问不到 registry。
|
|
305
|
+
...(outcome.npm !== undefined && outcome.npm !== null ? { npm: outcome.npm } : {}),
|
|
306
|
+
replaced: outcome.replaced,
|
|
307
|
+
dry_run: outcome.dryRun,
|
|
308
|
+
current_binary_touched: touchedSelf,
|
|
309
|
+
bytes: outcome.bytes,
|
|
310
|
+
sha256: outcome.sha256,
|
|
311
|
+
stale_removed: outcome.staleRemoved,
|
|
312
|
+
next_step: nextStep,
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/** 升级成功后 `doctor` 往 `next_steps` 里追加的那一句(比 `upgrade.next_step` 详细)。 */
|
|
317
|
+
function outcomeNextStep(outcome) {
|
|
318
|
+
// npm 通道:升级由包管理器做,CLI 只给命令。
|
|
319
|
+
if (outcome.npm !== undefined && outcome.npm !== null) {
|
|
320
|
+
return outcome.next_step;
|
|
321
|
+
}
|
|
322
|
+
if (outcome.dryRun && outcome.installedElsewhere) {
|
|
323
|
+
return `--dry-run:没有下载、也没写任何文件;去掉 --dry-run 才会把 ${outcome.to} 的产物写到 ${outcome.path}`;
|
|
324
|
+
}
|
|
325
|
+
if (outcome.dryRun) {
|
|
326
|
+
return `--dry-run:没有下载、没有替换;去掉 --dry-run 才会把 ${outcome.path}(当前 ${outcome.from})换成 ${outcome.to}`;
|
|
327
|
+
}
|
|
328
|
+
if (outcome.installedElsewhere) {
|
|
329
|
+
return `产物已写到 ${outcome.path}(${outcome.from} → ${outcome.to},${outcome.bytes} 字节,sha256 已校验);当前运行的二进制没有改动`;
|
|
330
|
+
}
|
|
331
|
+
if (outcome.replaced) {
|
|
332
|
+
return `已从 ${outcome.from} 升到 ${outcome.to}:${outcome.path}(重开终端即用新版本)`;
|
|
333
|
+
}
|
|
334
|
+
return `已经是最新(${outcome.to}),什么都没做`;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* 执行自更新(Rust `update::upgrade` 的逐句移植)。`outPath` 为空则替换当前可执行文件。
|
|
339
|
+
*
|
|
340
|
+
* 两条硬纪律(见文件头):`--dry-run` 时**升级动作本身**只问一次 `/cli/latest`、
|
|
341
|
+
* 一个字节都不写;`--out` 时产物写到那个路径、当前可执行文件一个字节都不动。
|
|
342
|
+
*/
|
|
343
|
+
async function performUpgrade(runtime, outPath) {
|
|
344
|
+
const [os, arch] = platform();
|
|
345
|
+
const installedElsewhere = outPath !== null;
|
|
346
|
+
|
|
347
|
+
// **不带 `--out` 就是"我要升级"**,而升级现在走 npm:这条路**不碰**产物通道
|
|
348
|
+
// (`/cli/latest` 的产物是给安装脚本与手工下载用的 Node 入口脚本,与"升级本机"无关),
|
|
349
|
+
// 所以先返回,连"服务端有没有发本平台产物"都不必过问。
|
|
350
|
+
if (!installedElsewhere) {
|
|
351
|
+
return await npmUpgradePlan(runtime);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// ① 问服务端发布了什么。这一次**带凭据**(`sendAuthed`):接下来要下载的
|
|
355
|
+
// `/cli/download/...` 要令牌,先在这里把"没登录"如实报出来
|
|
356
|
+
// (`auth.missing_token` → 退出码 3 的 hint 会进 next_steps)。
|
|
357
|
+
const response = await keepAlive(() => runtime.sendAuthed('GET', '/cli/latest', requestSpec()));
|
|
358
|
+
const body = unwrap(response.body);
|
|
359
|
+
|
|
360
|
+
const published = typeof body.version === 'string' ? body.version : '';
|
|
361
|
+
if (published.length === 0) {
|
|
362
|
+
throw new CliError(
|
|
363
|
+
Kind.Server,
|
|
364
|
+
'cli.artifact_missing',
|
|
365
|
+
'服务端没有报告 CLI 版本号(/cli/latest 的响应里没有 version)',
|
|
366
|
+
).withHint('确认服务端已配置 CLI_DIST_DIR,且 dist/cli/manifest.json 存在');
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
const artifact = artifactFor(body, os, arch);
|
|
370
|
+
if (artifact === null) {
|
|
371
|
+
const available = Array.isArray(body.platforms)
|
|
372
|
+
? body.platforms
|
|
373
|
+
.filter((item) => isPlainObject(item) && typeof item.os === 'string' && typeof item.arch === 'string')
|
|
374
|
+
.map((item) => `${item.os}/${item.arch}`)
|
|
375
|
+
: [];
|
|
376
|
+
throw new CliError(
|
|
377
|
+
Kind.Server,
|
|
378
|
+
'cli.artifact_missing',
|
|
379
|
+
`服务端没有发布 ${os}/${arch} 的产物`,
|
|
380
|
+
).withHint(
|
|
381
|
+
`服务端当前有:${
|
|
382
|
+
available.length === 0 ? '(一个都没有)' : available.join('、')
|
|
383
|
+
}。这台机器的平台可能还没构建(docs/31 §5 的说明)`,
|
|
384
|
+
);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// 走到这里只剩一条落盘路径:`--out <路径>`(把服务端当前发布的那份产物写到别处,
|
|
388
|
+
// 供核对/装机/排障用),当前可执行文件一个字节都不动。
|
|
389
|
+
const target = outPath;
|
|
390
|
+
// `/cli/latest` 报的产物指纹:落盘前拿它校验;预演时它就是"将要写入的东西"。
|
|
391
|
+
const expected = typeof artifact.sha256 === 'string' ? artifact.sha256.toLowerCase() : '';
|
|
392
|
+
const expectedSize =
|
|
393
|
+
Number.isInteger(artifact.size) && artifact.size >= 0 ? artifact.size : 0;
|
|
394
|
+
|
|
395
|
+
// ② `--dry-run` 到这里止步:上面那次 `/cli/latest` 是**只读**的,下面才是"写"。
|
|
396
|
+
if (runtime.dryRun) {
|
|
397
|
+
return {
|
|
398
|
+
from: VERSION,
|
|
399
|
+
to: published,
|
|
400
|
+
path: target,
|
|
401
|
+
replaced: false,
|
|
402
|
+
installedElsewhere,
|
|
403
|
+
dryRun: true,
|
|
404
|
+
staleRemoved: false,
|
|
405
|
+
bytes: expectedSize,
|
|
406
|
+
sha256: expected,
|
|
407
|
+
};
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// ③ 下载(**用已有登录凭据**:令牌由 CLI 自己从 0600 的凭据文件取,不进 argv、不打印)。
|
|
411
|
+
// 二进制响应由 http 层按 content-type 落进 `raw`(与附件下载同一条路)。
|
|
412
|
+
const download = await keepAlive(() =>
|
|
413
|
+
runtime.sendAuthed('GET', `/cli/download/${os}/${arch}`, requestSpec()),
|
|
414
|
+
);
|
|
415
|
+
const bytes = Buffer.isBuffer(download.raw) ? download.raw : Buffer.alloc(0);
|
|
416
|
+
if (bytes.length === 0) {
|
|
417
|
+
throw new CliError(
|
|
418
|
+
Kind.Server,
|
|
419
|
+
'cli.artifact_missing',
|
|
420
|
+
'下载到的是空文件,没有替换任何东西',
|
|
421
|
+
);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// ④a **内容嗅探**:产物通道现在发的是 Node 入口脚本(`cli-node/scripts/
|
|
425
|
+
// download-entry.template.js`),而它过去发的是 Rust 二进制(ELF/PE)——
|
|
426
|
+
// 两种内容共用同一组平台键。只看 sha256 的话,"服务端发的不是 JS"会被当成
|
|
427
|
+
// "新版本"照落,落下去的就是一个永远不会执行的入口。这道闸门与 sha256 是
|
|
428
|
+
// **两件事**:sha256 管完整性,这一道管"它是不是能当我自己的东西"。
|
|
429
|
+
assertLooksLikeJavaScript(outPath, bytes);
|
|
430
|
+
|
|
431
|
+
// ④ 校验:不通过 ⇒ **一个字节都不落盘**(换上去的是用户干活唯一的工具,宁可不动)。
|
|
432
|
+
if (expected.length > 0) {
|
|
433
|
+
const actual = sha256Hex(bytes);
|
|
434
|
+
if (actual !== expected) {
|
|
435
|
+
throw new CliError(
|
|
436
|
+
Kind.Server,
|
|
437
|
+
'cli.checksum_mismatch',
|
|
438
|
+
`下载内容与 /cli/latest 报的 sha256 不一致(期望 ${expected},实际 ${actual})`,
|
|
439
|
+
).withHint('没有替换任何文件;重试一次,仍不一致请联系运维');
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// ⑤ 落位(自己或 `--out` 那个路径)。
|
|
444
|
+
const staleRemoved = swapInPlace(target, bytes);
|
|
445
|
+
return {
|
|
446
|
+
from: VERSION,
|
|
447
|
+
to: published,
|
|
448
|
+
path: target,
|
|
449
|
+
replaced: true,
|
|
450
|
+
installedElsewhere,
|
|
451
|
+
dryRun: false,
|
|
452
|
+
staleRemoved,
|
|
453
|
+
bytes: bytes.length,
|
|
454
|
+
sha256: sha256Hex(bytes),
|
|
455
|
+
};
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
// ---------------------------------------------------------------------------
|
|
460
|
+
// 升级:npm 是唯一通道(Node 作为唯一客户端入口之后)
|
|
461
|
+
// ---------------------------------------------------------------------------
|
|
462
|
+
/**
|
|
463
|
+
* 为什么 Node 版**不再自更新**(这是一条刻意的行为变更,改回去前先读这段)
|
|
464
|
+
*
|
|
465
|
+
* 1. **通道冲突**:`/cli/download/{os}/{arch}` 是**服务端产物通道**,内容由服务端
|
|
466
|
+
* 决定,Node 版过去把自己也接到这条通道上升级 —— 于是"服务端发的是 ELF 还是 JS"
|
|
467
|
+
* 决定了用户下次还能不能启动客户端(只看 sha256 是拦不住的:那是服务端报的、
|
|
468
|
+
* 对得上的哈希)。两种东西抢同一个 slot,迟早出事。
|
|
469
|
+
* (2026-10-16 起这条通道改发 Node 入口脚本 —— 但那是**给安装脚本与手工下载**的,
|
|
470
|
+
* 升级仍然只走 npm;`--out` 那道内容嗅探留着,防的就是"通道又被换回二进制"。)
|
|
471
|
+
* 2. **npm 生态里升级的正确路径是包管理器**:`npm i -g @xinhe-oa/cli@latest`。
|
|
472
|
+
* 自己替换一个由 npm 管理的文件树里的入口脚本,会把 npm 的账本
|
|
473
|
+
* (`package.json` 版本、`node_modules` 记录)与实际内容弄不一致 ——
|
|
474
|
+
* 下一次 `npm i -g` 的行为就不可预期了。
|
|
475
|
+
*
|
|
476
|
+
* 所以 `doctor --upgrade` 的含义变成"**核对并给出升级命令**":
|
|
477
|
+
* * 问 npm registry 当前 latest 是多少(尽力而为,连不上就如实说,不报错);
|
|
478
|
+
* * 与本机版本比一比,给出 `npm i -g …@latest` 或"已经是最新";
|
|
479
|
+
* * **一个字节都不写**,"自己"永远不动(`current_binary_touched: false`)。
|
|
480
|
+
*
|
|
481
|
+
* `--out <路径>` 保留原义:把**服务端当前发布的那份产物**写到那个路径
|
|
482
|
+
* (核对 sha256 / 装到别处 / 排障用),同样不动当前可执行文件。
|
|
483
|
+
*/
|
|
484
|
+
async function npmUpgradePlan(runtime) {
|
|
485
|
+
const registry = process.env.XINHE_OA_NPM_REGISTRY ?? 'https://registry.npmjs.org';
|
|
486
|
+
const packageName = process.env.XINHE_OA_NPM_PACKAGE ?? PACKAGE_NAME;
|
|
487
|
+
const queried = await queryNpmLatest(registry, packageName);
|
|
488
|
+
|
|
489
|
+
const to = typeof queried.version === 'string' ? queried.version : VERSION;
|
|
490
|
+
const newer = isNewerVersion(to, VERSION);
|
|
491
|
+
const installCommand = `npm i -g ${packageName}@latest`;
|
|
492
|
+
|
|
493
|
+
let nextStep;
|
|
494
|
+
if (queried.error !== null) {
|
|
495
|
+
nextStep = `问不到 npm registry(${queried.error});升级仍然只有一条路:${installCommand}`;
|
|
496
|
+
} else if (newer) {
|
|
497
|
+
nextStep = `有新版本:${installCommand}(装完重开终端,用 xinhe-oa-cli --version 核对)`;
|
|
498
|
+
} else {
|
|
499
|
+
nextStep = '已经是最新,什么都不用做';
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
return {
|
|
503
|
+
from: VERSION,
|
|
504
|
+
to,
|
|
505
|
+
path: null,
|
|
506
|
+
target: 'npm',
|
|
507
|
+
replaced: false,
|
|
508
|
+
installedElsewhere: false,
|
|
509
|
+
dryRun: runtime.dryRun,
|
|
510
|
+
staleRemoved: false,
|
|
511
|
+
bytes: 0,
|
|
512
|
+
sha256: '',
|
|
513
|
+
npm: {
|
|
514
|
+
package: packageName,
|
|
515
|
+
registry,
|
|
516
|
+
latest_version: queried.version,
|
|
517
|
+
latest_is_newer: newer,
|
|
518
|
+
command: installCommand,
|
|
519
|
+
error: queried.error,
|
|
520
|
+
},
|
|
521
|
+
next_step: nextStep,
|
|
522
|
+
};
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/** 问一次 npm registry 的 `latest`。**尽力而为**:问不到就回 `error`,不抛。 */
|
|
526
|
+
async function queryNpmLatest(registry, packageName) {
|
|
527
|
+
const base = String(registry).replace(/\/+$/, '');
|
|
528
|
+
// 只把**包名里有歧义的字符**处理一下,**不能**把 `/` 编码成 `%2F`:
|
|
529
|
+
// npm registry 的 scoped 包路径就是 `/@scope/name/latest`,
|
|
530
|
+
// 编码成 `/@scope%2Fname/latest` 在真 registry 与内网源上都不认。
|
|
531
|
+
const url = `${base}/${packageName}/latest`;
|
|
532
|
+
const controller = new AbortController();
|
|
533
|
+
const timer = setTimeout(() => controller.abort(), 10_000);
|
|
534
|
+
if (typeof timer.unref === 'function') timer.unref();
|
|
535
|
+
try {
|
|
536
|
+
const response = await fetch(url, {
|
|
537
|
+
signal: controller.signal,
|
|
538
|
+
headers: { accept: 'application/json', 'user-agent': USER_AGENT },
|
|
539
|
+
});
|
|
540
|
+
if (!response.ok) {
|
|
541
|
+
return { version: null, error: `HTTP ${response.status}` };
|
|
542
|
+
}
|
|
543
|
+
const body = await response.json();
|
|
544
|
+
const version = typeof body?.version === 'string' ? body.version : null;
|
|
545
|
+
return version === null
|
|
546
|
+
? { version: null, error: '响应里没有 version' }
|
|
547
|
+
: { version, error: null };
|
|
548
|
+
} catch (err) {
|
|
549
|
+
return { version: null, error: err?.name === 'AbortError' ? '超时' : String(err?.message ?? err) };
|
|
550
|
+
} finally {
|
|
551
|
+
clearTimeout(timer);
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
/** 语义化版本比较(只回答"a 比 b 新吗";号段非法时按"不比"处理,宁可说已经是最新)。 */
|
|
556
|
+
export function isNewerVersion(candidate, current) {
|
|
557
|
+
const parse = (text) =>
|
|
558
|
+
String(text)
|
|
559
|
+
.split('-')[0]
|
|
560
|
+
.split('.')
|
|
561
|
+
.map((part) => Number.parseInt(part, 10));
|
|
562
|
+
const a = parse(candidate);
|
|
563
|
+
const b = parse(current);
|
|
564
|
+
if (a.length === 0 || b.length === 0) return false;
|
|
565
|
+
if (a.some(Number.isNaN) || b.some(Number.isNaN)) return false;
|
|
566
|
+
for (let i = 0; i < Math.max(a.length, b.length); i += 1) {
|
|
567
|
+
const left = a[i] ?? 0;
|
|
568
|
+
const right = b[i] ?? 0;
|
|
569
|
+
if (left !== right) return left > right;
|
|
570
|
+
}
|
|
571
|
+
return false;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
/**
|
|
575
|
+
* 落盘前的**内容嗅探**:这份字节像不像一个 Node 入口脚本。
|
|
576
|
+
*
|
|
577
|
+
* sha256 管的是"传输有没有坏",这一道管的是"**它是不是能当我自己的东西**"。
|
|
578
|
+
* 产物通道的内容换过一次(Rust 二进制 → Node 入口脚本,见
|
|
579
|
+
* `cli-node/scripts/download-entry.template.js`),而两种内容共用同一组平台键:
|
|
580
|
+
* 把 ELF 写进入口位置不会报任何错 —— 只会在下次启动时变成一个再也跑不起来的文件。
|
|
581
|
+
* 宁可拒绝落盘并说清楚。
|
|
582
|
+
*/
|
|
583
|
+
export function assertLooksLikeJavaScript(target, bytes) {
|
|
584
|
+
const head = bytes.subarray(0, 2).toString('utf8');
|
|
585
|
+
if (head === '#!') return;
|
|
586
|
+
const ascii = bytes.subarray(0, 4).toString('hex');
|
|
587
|
+
const kind =
|
|
588
|
+
ascii === '7f454c46'
|
|
589
|
+
? 'ELF(Linux 可执行文件)'
|
|
590
|
+
: ascii.startsWith('4d5a')
|
|
591
|
+
? 'PE(Windows 可执行文件)'
|
|
592
|
+
: '不是以 #! 开头的文本';
|
|
593
|
+
throw new CliError(
|
|
594
|
+
Kind.Server,
|
|
595
|
+
'cli.artifact_not_javascript',
|
|
596
|
+
`服务端发布的那份产物不是 Node 入口脚本(${kind}),拒绝写到 ${target}`,
|
|
597
|
+
).withHint(
|
|
598
|
+
'这多半是服务端还没切到 Node 入口(或有人把 Rust 产物归集回了 dist/cli);' +
|
|
599
|
+
'升级改用 npm:npm i -g @xinhe-oa/cli@latest',
|
|
600
|
+
);
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
/** `doctor`(含 `--upgrade`)。 */
|
|
604
|
+
export async function handleDoctor({ args, runtime }) {
|
|
605
|
+
// `--out` 只有跟着 `--upgrade` 才有意义(Rust 侧是 clap 的 `requires = "upgrade"`)。
|
|
606
|
+
// 这道闸门必须在**任何网络请求之前**:Rust 是解析期判死(本地退出码 2、一个请求都不发)。
|
|
607
|
+
const wantsUpgrade = flagOn(args, '--upgrade');
|
|
608
|
+
const outPath = flagValue(args, '--out');
|
|
609
|
+
if (outPath !== null && !wantsUpgrade) {
|
|
610
|
+
throw CliError.usage('`doctor --out` 要配合 `--upgrade` 一起用').withHint(
|
|
611
|
+
'它只把服务端当前发布的那份产物写到指定路径:xinhe-oa-cli doctor --upgrade --out <路径>',
|
|
612
|
+
);
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
const problems = [];
|
|
616
|
+
const nextSteps = [];
|
|
617
|
+
|
|
618
|
+
// ---- 出网路径(代理判定是客户机器上最常见的坑)----
|
|
619
|
+
// 真的建一个 Http:代理判定与超时从"将要发请求的那个对象"上读,
|
|
620
|
+
// 而不是在 doctor 里再算一遍(两处各算一次必然漂移)。
|
|
621
|
+
let transport;
|
|
622
|
+
try {
|
|
623
|
+
const http = runtime.anonymousHttp();
|
|
624
|
+
const decision =
|
|
625
|
+
http.proxyPlan() === ProxyPlan.DirectLoopback
|
|
626
|
+
? 'direct_loopback'
|
|
627
|
+
: http.proxyPlan() === ProxyPlan.System
|
|
628
|
+
? 'system_proxy'
|
|
629
|
+
: 'direct_by_config';
|
|
630
|
+
transport = {
|
|
631
|
+
base_url: http.baseUrl,
|
|
632
|
+
device_id_present: http.deviceId.length > 0,
|
|
633
|
+
proxy_config: runtime.proxy,
|
|
634
|
+
proxy_decision: decision,
|
|
635
|
+
explanation: runtime.proxyDescription(),
|
|
636
|
+
request_timeout_seconds: Math.floor(http.timeout() / 1000),
|
|
637
|
+
user_agent: USER_AGENT,
|
|
638
|
+
note: '本机与内网地址永远直连:中间隔代理去连 127.0.0.1 必然连不上',
|
|
639
|
+
};
|
|
640
|
+
} catch (err) {
|
|
641
|
+
// 没有可用的 base_url:把 `anonymous_http()` 的报错原样记进报告(`cli.usage`),
|
|
642
|
+
// 而不是在这里另造一句话 —— 两处措辞不一致时,用户会以为是两个毛病。
|
|
643
|
+
transport = {
|
|
644
|
+
base_url: runtime.baseUrl ?? null,
|
|
645
|
+
proxy_config: runtime.proxy,
|
|
646
|
+
error: errorSummary(err),
|
|
647
|
+
note: '还没有可用的 base_url,所以判定不出出网路径',
|
|
648
|
+
};
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
// ---- 版本 ----
|
|
652
|
+
const version = {
|
|
653
|
+
cli_version: VERSION,
|
|
654
|
+
user_agent: USER_AGENT,
|
|
655
|
+
version_header: VERSION_HEADER,
|
|
656
|
+
note: 'UA 里刻意不带版本号:带上的话用户一升级就会被 auth.device_mismatch 踢下线',
|
|
657
|
+
};
|
|
658
|
+
|
|
659
|
+
// ---- 配置 ----
|
|
660
|
+
const configDir = runtime.paths.dir;
|
|
661
|
+
const dirMode = modeString(configDir);
|
|
662
|
+
const credentialsModeRaw = runtime.credentials.fileMode();
|
|
663
|
+
const credentialsMode = credentialsModeRaw === null ? null : credentialsModeRaw.toString(8);
|
|
664
|
+
if (credentialsMode !== null && credentialsMode !== '600') {
|
|
665
|
+
problems.push({
|
|
666
|
+
what: '凭据文件权限太松',
|
|
667
|
+
detail: `${runtime.paths.credentialsFile} 是 ${credentialsMode},应当是 600`,
|
|
668
|
+
how_to_fix:
|
|
669
|
+
'chmod 600 ~/.config/xinhe-oa-cli/credentials.json(CLI 下次写凭据时也会自动收紧)',
|
|
670
|
+
});
|
|
671
|
+
}
|
|
672
|
+
if (dirMode !== null && dirMode !== '700') {
|
|
673
|
+
problems.push({
|
|
674
|
+
what: '配置目录权限太松',
|
|
675
|
+
detail: `${configDir} 是 ${dirMode},应当是 700`,
|
|
676
|
+
how_to_fix: 'chmod 700 ~/.config/xinhe-oa-cli',
|
|
677
|
+
});
|
|
678
|
+
}
|
|
679
|
+
const config = {
|
|
680
|
+
profile: runtime.profile,
|
|
681
|
+
base_url: runtime.baseUrl ?? null,
|
|
682
|
+
proxy_config: runtime.proxy,
|
|
683
|
+
proxy_effective: runtime.proxyDescription(),
|
|
684
|
+
config_dir: configDir,
|
|
685
|
+
config_dir_mode: dirMode,
|
|
686
|
+
config_file: runtime.paths.configFile,
|
|
687
|
+
credentials_file: runtime.paths.credentialsFile,
|
|
688
|
+
credentials_file_mode: credentialsMode,
|
|
689
|
+
env_overrides: {
|
|
690
|
+
XINHE_OA_CONFIG_DIR:
|
|
691
|
+
typeof process.env.XINHE_OA_CONFIG_DIR === 'string' ? process.env.XINHE_OA_CONFIG_DIR : null,
|
|
692
|
+
XINHE_OA_BASE_URL:
|
|
693
|
+
typeof process.env.XINHE_OA_BASE_URL === 'string' ? process.env.XINHE_OA_BASE_URL : null,
|
|
694
|
+
XINHE_OA_PROXY:
|
|
695
|
+
typeof process.env.XINHE_OA_PROXY === 'string' ? process.env.XINHE_OA_PROXY : null,
|
|
696
|
+
},
|
|
697
|
+
};
|
|
698
|
+
if (runtime.baseUrl === null || runtime.baseUrl === undefined) {
|
|
699
|
+
problems.push({
|
|
700
|
+
what: '还没配置后端地址',
|
|
701
|
+
how_to_fix: 'xinhe-oa-cli config --set base_url=http://127.0.0.1:8102',
|
|
702
|
+
});
|
|
703
|
+
nextSteps.push('xinhe-oa-cli config --set base_url=<后端地址>');
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
// ---- 凭据与设备指纹 ----
|
|
707
|
+
const credential = runtime.credentials.get(runtime.profile);
|
|
708
|
+
let credentials;
|
|
709
|
+
let device;
|
|
710
|
+
if (credential !== null) {
|
|
711
|
+
const accessToken = typeof credential.tokens?.access_token === 'string' ? credential.tokens.access_token : '';
|
|
712
|
+
const refreshToken = typeof credential.tokens?.refresh_token === 'string' ? credential.tokens.refresh_token : '';
|
|
713
|
+
const deviceId = typeof credential.device_id === 'string' ? credential.device_id : '';
|
|
714
|
+
const hasAccess = accessToken.length > 0;
|
|
715
|
+
const hasRefresh = refreshToken.length > 0;
|
|
716
|
+
if (!hasAccess && !hasRefresh) {
|
|
717
|
+
problems.push({
|
|
718
|
+
what: '凭据文件存在但没有令牌',
|
|
719
|
+
how_to_fix: 'xinhe-oa-cli login --phone <手机号>',
|
|
720
|
+
});
|
|
721
|
+
}
|
|
722
|
+
const expired = refreshExpired(credential);
|
|
723
|
+
if (expired) {
|
|
724
|
+
problems.push({
|
|
725
|
+
what: 'refresh 已过期',
|
|
726
|
+
how_to_fix: loginHint(typeof credential.phone === 'string' ? credential.phone : null),
|
|
727
|
+
});
|
|
728
|
+
nextSteps.push('xinhe-oa-cli login --phone <手机号>');
|
|
729
|
+
}
|
|
730
|
+
credentials = {
|
|
731
|
+
present: true,
|
|
732
|
+
has_access_token: hasAccess,
|
|
733
|
+
has_refresh_token: hasRefresh,
|
|
734
|
+
access_token: maskToken(accessToken),
|
|
735
|
+
refresh_token: maskToken(refreshToken),
|
|
736
|
+
access_expires_at:
|
|
737
|
+
typeof credential.tokens?.access_expires_at === 'string'
|
|
738
|
+
? credential.tokens.access_expires_at
|
|
739
|
+
: null,
|
|
740
|
+
refresh_expires_at:
|
|
741
|
+
typeof credential.tokens?.refresh_expires_at === 'string'
|
|
742
|
+
? credential.tokens.refresh_expires_at
|
|
743
|
+
: null,
|
|
744
|
+
refresh_expired: expired,
|
|
745
|
+
needs_refresh: accessNeedsRefresh(credential),
|
|
746
|
+
user: credential.user ?? null,
|
|
747
|
+
updated_at: typeof credential.updated_at === 'string' ? credential.updated_at : null,
|
|
748
|
+
note: '令牌只在这里以掩码出现;argv 与日志里搜不到',
|
|
749
|
+
};
|
|
750
|
+
device = {
|
|
751
|
+
device_id: deviceId,
|
|
752
|
+
stable: deviceId.length > 0,
|
|
753
|
+
tokens_empty: !hasAccess && !hasRefresh,
|
|
754
|
+
note: '每个请求都带 X-Device-Id;服务端把 refresh 绑在 HMAC(UA + X-Device-Id) 上',
|
|
755
|
+
};
|
|
756
|
+
} else {
|
|
757
|
+
if (runtime.baseUrl !== null && runtime.baseUrl !== undefined) {
|
|
758
|
+
nextSteps.push('xinhe-oa-cli login --phone <手机号>');
|
|
759
|
+
}
|
|
760
|
+
credentials = { present: false, how_to_fix: 'xinhe-oa-cli login --phone <手机号>' };
|
|
761
|
+
device = { device_id: null, note: '还没有设备指纹:首次 login 时生成并持久化' };
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
// ---- 磁盘 ----
|
|
765
|
+
// 注意这里查的是"配置目录(或其父目录)在不在",不是真的写一次 ——
|
|
766
|
+
// 与 Rust 同口径:凭据文件读不出来(坏 JSON)也算不可写,因为登录时同样落不了盘。
|
|
767
|
+
let writable = false;
|
|
768
|
+
try {
|
|
769
|
+
CredentialStore.load(runtime.paths);
|
|
770
|
+
const parent = parentPath(configDir);
|
|
771
|
+
writable = fs.existsSync(configDir) || (parent !== null && fs.existsSync(parent));
|
|
772
|
+
} catch {
|
|
773
|
+
writable = false;
|
|
774
|
+
}
|
|
775
|
+
const disk = {
|
|
776
|
+
config_dir_writable: writable,
|
|
777
|
+
note: '配置目录需要可写(登录要落凭据)',
|
|
778
|
+
};
|
|
779
|
+
if (!writable) {
|
|
780
|
+
problems.push({
|
|
781
|
+
what: '配置目录不可写',
|
|
782
|
+
detail: configDir,
|
|
783
|
+
how_to_fix: '检查目录权限,或用 XINHE_OA_CONFIG_DIR=<可写目录> 换一个位置',
|
|
784
|
+
});
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
// ---- 连通性(`--probe` 在 Rust 里默认就是 true,所以有 base_url 就一定探一次)----
|
|
788
|
+
let network = { probed: false };
|
|
789
|
+
let server = {};
|
|
790
|
+
if (runtime.baseUrl !== null && runtime.baseUrl !== undefined) {
|
|
791
|
+
try {
|
|
792
|
+
const body = await keepAlive(() => probeHealth(runtime));
|
|
793
|
+
const health = isPlainObject(body) ? body : {};
|
|
794
|
+
// 与 Rust 的 `.and_then(Value::as_str).unwrap_or("unknown")` 同口径:
|
|
795
|
+
// 只有"字段不是字符串"才退成 unknown(空串原样留着)。
|
|
796
|
+
const serverVersion = typeof health.version === 'string' ? health.version : 'unknown';
|
|
797
|
+
const ok = health.ok === true;
|
|
798
|
+
server = {
|
|
799
|
+
reachable: true,
|
|
800
|
+
version: serverVersion,
|
|
801
|
+
ok,
|
|
802
|
+
degraded: health.degraded ?? null,
|
|
803
|
+
degraded_reasons: health.degraded_reasons ?? null,
|
|
804
|
+
env: health.env ?? null,
|
|
805
|
+
};
|
|
806
|
+
network = {
|
|
807
|
+
probed: true,
|
|
808
|
+
reachable: true,
|
|
809
|
+
target: runtime.baseUrl,
|
|
810
|
+
proxy_effective: runtime.proxyDescription(),
|
|
811
|
+
};
|
|
812
|
+
if (!ok) {
|
|
813
|
+
problems.push({
|
|
814
|
+
what: '服务端自报 not ok',
|
|
815
|
+
detail: health.degraded_reasons ?? null,
|
|
816
|
+
how_to_fix: '把 request_id 与 /health 的输出给运维',
|
|
817
|
+
});
|
|
818
|
+
}
|
|
819
|
+
} catch (err) {
|
|
820
|
+
const failure = errorSummary(err);
|
|
821
|
+
network = {
|
|
822
|
+
probed: true,
|
|
823
|
+
reachable: false,
|
|
824
|
+
target: runtime.baseUrl,
|
|
825
|
+
proxy_effective: runtime.proxyDescription(),
|
|
826
|
+
error: failure,
|
|
827
|
+
};
|
|
828
|
+
const hint = typeof err?.hint === 'string' && err.hint.length > 0 ? err.hint : null;
|
|
829
|
+
problems.push({
|
|
830
|
+
what: '连不上服务端',
|
|
831
|
+
detail: failure.message,
|
|
832
|
+
how_to_fix: hint ?? '检查 base_url 与服务端进程',
|
|
833
|
+
});
|
|
834
|
+
if (hint !== null) nextSteps.push(hint);
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
// ---- 版本协商(#71 的 `/cli/latest`)----
|
|
839
|
+
//
|
|
840
|
+
// 服务端的**最小版本门禁只挂在 `/cli/latest` 上**(业务接口不挂:能力与数据范围
|
|
841
|
+
// 问一次服务端的发布信息 —— 但**只作为诊断**,不再当作"我该不该升级"的依据。
|
|
842
|
+
//
|
|
843
|
+
// 为什么还留着:`release` 段能回答"这台机器要装客户端的话,服务端有没有产物",
|
|
844
|
+
// 运维与排障需要它(`doctor` 的定位就是自检报告)。
|
|
845
|
+
//
|
|
846
|
+
// 为什么不再当升级依据:那个端点回答的是"该装哪个 **Rust** 产物"。
|
|
847
|
+
// Node 版升级走 npm,两套版本号不是一回事 —— 拿它判断会得出误导性结论
|
|
848
|
+
// ("服务端是 0.1.7、你是 0.2.0"会被读成"有新版")。
|
|
849
|
+
// 更要紧的是:服务端的版本闸会对旧客户端回 426,而它的指引是重跑 install.sh
|
|
850
|
+
// (装的是 Rust 版)—— 对 Node 用户那是死路。所以这里**不抛 8**,
|
|
851
|
+
// 只把"服务端觉得该升级了"如实记进 `release`,升级指引由 npm 那条路给。
|
|
852
|
+
let release = { probed: false };
|
|
853
|
+
if (runtime.baseUrl !== null && runtime.baseUrl !== undefined) {
|
|
854
|
+
let body = null;
|
|
855
|
+
let failure = null;
|
|
856
|
+
try {
|
|
857
|
+
body = await keepAlive(() => fetchLatestRelease(runtime));
|
|
858
|
+
} catch (err) {
|
|
859
|
+
failure = err;
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
if (failure === null) {
|
|
863
|
+
const info = isPlainObject(body) ? body : {};
|
|
864
|
+
release = {
|
|
865
|
+
probed: true,
|
|
866
|
+
latest_version: typeof info.version === 'string' ? info.version : 'unknown',
|
|
867
|
+
min_supported_version:
|
|
868
|
+
typeof info.min_supported_version === 'string' ? info.min_supported_version : 'unknown',
|
|
869
|
+
artifact_present: typeof info.artifact_present === 'boolean' ? info.artifact_present : null,
|
|
870
|
+
platforms: info.platforms ?? null,
|
|
871
|
+
install_script_url: info.install_script_url ?? null,
|
|
872
|
+
notes: info.notes ?? null,
|
|
873
|
+
problems: info.problems ?? null,
|
|
874
|
+
};
|
|
875
|
+
if (info.artifact_present === false) {
|
|
876
|
+
// 没发布产物不是错误("只跑服务端"是最常见的部署形态),但要报出来。
|
|
877
|
+
// 产物通道对 Node 版只影响"下载入口脚本 / --out 核对",**不影响升级**(那走 npm),
|
|
878
|
+
// 所以这句只说清怎么补上,不说"去升级"。
|
|
879
|
+
nextSteps.push(
|
|
880
|
+
'服务端还没发布 CLI 入口脚本;运维执行 bash cli-node/scripts/publish-download-artifact.sh 归集后即可下载',
|
|
881
|
+
);
|
|
882
|
+
}
|
|
883
|
+
} else if (failure?.exitCode === EXIT_TOO_OLD) {
|
|
884
|
+
// 服务端认为这个客户端太旧。**对 Node 版这不是退出码 8**:那条指引会让人
|
|
885
|
+
// 去重跑安装脚本(装回来的是 Rust 版),而 Node 版的升级只有 npm 一条路。
|
|
886
|
+
release = {
|
|
887
|
+
probed: true,
|
|
888
|
+
error: errorSummary(failure),
|
|
889
|
+
note: '服务端认为该升级了;Node 版升级走 npm:xinhe-oa-cli doctor --upgrade',
|
|
890
|
+
};
|
|
891
|
+
nextSteps.push('升级客户端:npm i -g @xinhe-oa/cli@latest(服务端的安装脚本装的是 Rust 版)');
|
|
892
|
+
} else {
|
|
893
|
+
release = {
|
|
894
|
+
probed: true,
|
|
895
|
+
error: errorSummary(failure),
|
|
896
|
+
note: '问不到服务端发布的版本;不影响其它检查',
|
|
897
|
+
};
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
// `--upgrade` 的结果(不升级时是 null)。放进报告而不是只打一行日志 ——
|
|
902
|
+
// 复盘时要能回答"当时服务端发布的是哪个版本、换了没有"。
|
|
903
|
+
let upgrade = null;
|
|
904
|
+
if (wantsUpgrade) {
|
|
905
|
+
// 真的自更新(不是打印一句"去跑 install.sh"):三种落法必须分得清 ——
|
|
906
|
+
// * 默认 → 替换**当前这个可执行文件**(`bin/xinhe-oa-cli.js`);
|
|
907
|
+
// * `--out <路径>` → 只把产物写到那个路径,当前可执行文件不动;
|
|
908
|
+
// * `--dry-run` → 升级动作本身只问一次 `/cli/latest`,不下载、不写任何文件。
|
|
909
|
+
try {
|
|
910
|
+
const outcome = await performUpgrade(runtime, outPath);
|
|
911
|
+
nextSteps.push(outcomeNextStep(outcome));
|
|
912
|
+
upgrade = outcomeJson(outcome);
|
|
913
|
+
if (upgrade.current_binary_touched) {
|
|
914
|
+
nextSteps.push(
|
|
915
|
+
'旧版本留在同名 `.old`(Windows 上运行中的文件删不掉),下次启动会自动清理',
|
|
916
|
+
);
|
|
917
|
+
}
|
|
918
|
+
} catch (err) {
|
|
919
|
+
// 升级失败**不影响别的自检结论** —— 把错误如实放进报告(`replaced` 也不会出现
|
|
920
|
+
// "成功"的形状),而不是整体失败(见文件头第 4 条)。
|
|
921
|
+
upgrade = { probed: true, error: errorSummary(err) };
|
|
922
|
+
nextSteps.push(
|
|
923
|
+
typeof err?.hint === 'string' && err.hint.length > 0
|
|
924
|
+
? err.hint
|
|
925
|
+
: '升级失败:没有替换任何文件,当前版本照常可用',
|
|
926
|
+
);
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
nextSteps.push('xinhe-oa-cli schema 看全部命令面');
|
|
930
|
+
nextSteps.push('xinhe-oa-cli me --json 我是谁、能做什么、缺什么权限');
|
|
931
|
+
|
|
932
|
+
const data = {
|
|
933
|
+
ok: problems.length === 0,
|
|
934
|
+
cli_version: VERSION,
|
|
935
|
+
version,
|
|
936
|
+
config,
|
|
937
|
+
transport,
|
|
938
|
+
credentials,
|
|
939
|
+
device,
|
|
940
|
+
network,
|
|
941
|
+
server,
|
|
942
|
+
release,
|
|
943
|
+
upgrade,
|
|
944
|
+
disk,
|
|
945
|
+
embedded_skills: EMBEDDED_SKILLS,
|
|
946
|
+
problems,
|
|
947
|
+
next_steps: nextSteps,
|
|
948
|
+
phone_masked: phoneMasked(runtime),
|
|
949
|
+
};
|
|
950
|
+
const requestId = null;
|
|
951
|
+
const serverVersion = typeof server.version === 'string' ? server.version : null;
|
|
952
|
+
|
|
953
|
+
// 人类可读面(`--json` 一个字节都不变):**自更新的结论必须看得见**。
|
|
954
|
+
//
|
|
955
|
+
// 通用表格渲染只会挑第一个"对象数组"字段(doctor 里是 `problems`),
|
|
956
|
+
// `upgrade` 与 `next_steps` 会被整段吞掉 —— 于是 `doctor --upgrade` 在默认输出下只剩
|
|
957
|
+
// 一行 `cli_version=…`,"到底动没动这台机器 / 产物写到哪了"一个字都不说。
|
|
958
|
+
// 实测就有人因此以为 `--out` 没生效、而本体其实已经被换掉。
|
|
959
|
+
//
|
|
960
|
+
// 渲染前先按字典序排一遍键(`sortKeysDeep`):Rust 的 `data` 是 BTreeMap,
|
|
961
|
+
// 它的 `render_human` 读到的顶层标量本来就是排好序的(`cli_version / embedded_skills /
|
|
962
|
+
// ok / phone_masked`),不排的话 Node 会按**插入序**印(`ok / cli_version / …`)——
|
|
963
|
+
// 内容一样、顺序不同,对拍时全是噪声。
|
|
964
|
+
let human = renderHuman(sortKeysDeep(data), runtime.markdown);
|
|
965
|
+
if (upgrade !== null && typeof upgrade.next_step === 'string') {
|
|
966
|
+
human += `\n自更新:${upgrade.next_step}`;
|
|
967
|
+
}
|
|
968
|
+
runtime.finishFromWithHuman({ request_id: requestId, server_version: serverVersion }, data, human);
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
/** `phone_masked`(配置里没存手机号时是 null,不是 `****`)。 */
|
|
972
|
+
function phoneMasked(runtime) {
|
|
973
|
+
const phone = runtime.config.profile(runtime.profile).phone;
|
|
974
|
+
return typeof phone === 'string' ? maskPhone(phone) : null;
|
|
975
|
+
}
|