@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,407 @@
|
|
|
1
|
+
// 配置与凭据落盘(契约 §4)。
|
|
2
|
+
//
|
|
3
|
+
// 两个文件,都在 `~/.config/xinhe-oa-cli/`(`XINHE_OA_CONFIG_DIR` 可覆盖):
|
|
4
|
+
//
|
|
5
|
+
// | 文件 | 权限 | 内容 |
|
|
6
|
+
// |--------------------|--------|---------------------------------------------------|
|
|
7
|
+
// | `config.json` | `0600` | profile(后端地址、代理策略)与当前选中的 profile |
|
|
8
|
+
// | `credentials.json` | `0600` | 每个 profile 一份:`device_id` + 令牌 + 用户摘要 |
|
|
9
|
+
//
|
|
10
|
+
// 目录 `0700`、文件 `0600`:这两个文件里有机密(refresh 令牌),
|
|
11
|
+
// 同机器上的其它用户不该读到。凭据只由 CLI 读写 —— **不进 argv、不进日志**。
|
|
12
|
+
//
|
|
13
|
+
// **与 Rust 版逐字节兼容**:文件形状是跨实现的契约。老用户用 Rust 版登过录,
|
|
14
|
+
// 换成 Node 版必须**继续用得上**(同一个目录、同一份 JSON、同一个 device_id)——
|
|
15
|
+
// 否则每换一次实现就要重新短信登录一次,而 `device_id` 一变,服务端的
|
|
16
|
+
// `HMAC(UA + X-Device-Id)` 绑定立刻 `auth.device_mismatch`。
|
|
17
|
+
|
|
18
|
+
import fs from 'node:fs';
|
|
19
|
+
import path from 'node:path';
|
|
20
|
+
import { CliError } from './error.js';
|
|
21
|
+
import { rustIoErrorText } from './util.js';
|
|
22
|
+
import { nowRfc3339 } from './util.js';
|
|
23
|
+
|
|
24
|
+
export const DEFAULT_PROFILE = 'default';
|
|
25
|
+
|
|
26
|
+
/** 目录/文件的 Unix 权限位(Windows 上这两个调用是 no-op)。 */
|
|
27
|
+
const DIR_MODE = 0o700;
|
|
28
|
+
const FILE_MODE = 0o600;
|
|
29
|
+
|
|
30
|
+
function envNonEmpty(key) {
|
|
31
|
+
const value = process.env[key];
|
|
32
|
+
return value && value.trim().length > 0 ? value : null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** 配置目录与两个文件的位置。 */
|
|
36
|
+
export class Paths {
|
|
37
|
+
constructor(dir) {
|
|
38
|
+
this.dir = dir;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* 解析配置目录(按优先级):
|
|
43
|
+
*
|
|
44
|
+
* 1. `XINHE_OA_CONFIG_DIR` —— 显式指定,测试与多环境隔离用;
|
|
45
|
+
* 2. **Windows**:`APPDATA\xinhe-oa-cli`(`%APPDATA%` 是 Windows 的漫游配置目录),
|
|
46
|
+
* 退而用 `USERPROFILE\.config\xinhe-oa-cli`;
|
|
47
|
+
* 3. 类 Unix:`XDG_CONFIG_HOME/xinhe-oa-cli`,再退 `$HOME/.config/xinhe-oa-cli`。
|
|
48
|
+
*
|
|
49
|
+
* **为什么 Windows 单独处理**:cmd.exe / PowerShell 默认不设 `HOME`
|
|
50
|
+
* (只有 `USERPROFILE`)。只认 `HOME` 的话 Windows 用户第一次运行就拿到
|
|
51
|
+
* "找不到 HOME 环境变量" —— 而这句话对 Windows 用户毫无指导意义。
|
|
52
|
+
*/
|
|
53
|
+
static resolve() {
|
|
54
|
+
const explicit = envNonEmpty('XINHE_OA_CONFIG_DIR');
|
|
55
|
+
if (explicit) return new Paths(explicit);
|
|
56
|
+
|
|
57
|
+
if (process.platform === 'win32') {
|
|
58
|
+
const appdata = envNonEmpty('APPDATA');
|
|
59
|
+
if (appdata) return new Paths(path.join(appdata, 'xinhe-oa-cli'));
|
|
60
|
+
const profile = envNonEmpty('USERPROFILE');
|
|
61
|
+
if (profile) return new Paths(path.join(profile, '.config', 'xinhe-oa-cli'));
|
|
62
|
+
} else {
|
|
63
|
+
const xdg = envNonEmpty('XDG_CONFIG_HOME');
|
|
64
|
+
if (xdg) return new Paths(path.join(xdg, 'xinhe-oa-cli'));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const home = envNonEmpty('HOME') ?? envNonEmpty('USERPROFILE');
|
|
68
|
+
if (!home) {
|
|
69
|
+
const hint =
|
|
70
|
+
process.platform === 'win32'
|
|
71
|
+
? 'Windows 上请确认 USERPROFILE 或 APPDATA 存在(正常安装的 Windows 都有),或用 XINHE_OA_CONFIG_DIR=<目录> 指定配置目录'
|
|
72
|
+
: '设置 HOME,或用 XINHE_OA_CONFIG_DIR=<目录> 指定配置目录';
|
|
73
|
+
throw CliError.usage('找不到用户主目录,无法定位配置目录').withHint(hint);
|
|
74
|
+
}
|
|
75
|
+
return new Paths(path.join(home, '.config', 'xinhe-oa-cli'));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
get configFile() {
|
|
79
|
+
return path.join(this.dir, 'config.json');
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
get credentialsFile() {
|
|
83
|
+
return path.join(this.dir, 'credentials.json');
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* 断点续传记录目录(`<config>/checkpoints/`)。
|
|
88
|
+
*
|
|
89
|
+
* 放配置目录而不是当前工作目录:导入可以在任何目录下发起,
|
|
90
|
+
* "换个目录重跑就找不到上次的进度"正是续传最容易被废掉的方式。
|
|
91
|
+
*/
|
|
92
|
+
get checkpointsDir() {
|
|
93
|
+
return path.join(this.dir, 'checkpoints');
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** 建目录并**把权限收成 0700**(已存在也要收:别人可能建成了 0755)。 */
|
|
97
|
+
ensureDir() {
|
|
98
|
+
try {
|
|
99
|
+
fs.mkdirSync(this.dir, { recursive: true, mode: DIR_MODE });
|
|
100
|
+
} catch (err) {
|
|
101
|
+
throw CliError.usage(`建不了配置目录 ${this.dir}:${rustIoErrorText(err)}`);
|
|
102
|
+
}
|
|
103
|
+
if (process.platform !== 'win32') {
|
|
104
|
+
try {
|
|
105
|
+
// 已存在的目录不会被 mkdir 的 mode 改到,这里再收一次。
|
|
106
|
+
fs.chmodSync(this.dir, DIR_MODE);
|
|
107
|
+
} catch (err) {
|
|
108
|
+
throw CliError.usage(`配置目录权限收不紧(应为 0700):${this.dir}:${err.message}`);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* 写文件:**先收权限再写内容**,避免"先落明文、后 chmod"的窗口。
|
|
116
|
+
*
|
|
117
|
+
* 顺序是硬要求:`open(..., {mode: 0600})` 在创建时就把权限定死,
|
|
118
|
+
* 写完再 `chmod` 只兜"文件早就存在、权限被别人放宽了"这一种情况。
|
|
119
|
+
*/
|
|
120
|
+
function writePrivate(target, content) {
|
|
121
|
+
let fd;
|
|
122
|
+
try {
|
|
123
|
+
fd = fs.openSync(target, 'w', FILE_MODE);
|
|
124
|
+
fs.writeFileSync(fd, content);
|
|
125
|
+
fs.fsyncSync(fd);
|
|
126
|
+
} catch (err) {
|
|
127
|
+
throw CliError.usage(`写不了 ${target}:${rustIoErrorText(err)}`);
|
|
128
|
+
} finally {
|
|
129
|
+
if (fd !== undefined) {
|
|
130
|
+
try {
|
|
131
|
+
fs.closeSync(fd);
|
|
132
|
+
} catch {
|
|
133
|
+
/* 关不掉就算了:内容已经落盘 */
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
if (process.platform !== 'win32') {
|
|
138
|
+
try {
|
|
139
|
+
fs.chmodSync(target, FILE_MODE);
|
|
140
|
+
} catch (err) {
|
|
141
|
+
throw CliError.usage(`${target} 权限收不紧:${err.message}`);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function readJson(target) {
|
|
147
|
+
let text;
|
|
148
|
+
try {
|
|
149
|
+
text = fs.readFileSync(target, 'utf8');
|
|
150
|
+
} catch (err) {
|
|
151
|
+
if (err.code === 'ENOENT') return null;
|
|
152
|
+
throw CliError.usage(`读不了 ${target}:${rustIoErrorText(err)}`);
|
|
153
|
+
}
|
|
154
|
+
if (text.trim().length === 0) return null;
|
|
155
|
+
try {
|
|
156
|
+
return JSON.parse(text);
|
|
157
|
+
} catch (err) {
|
|
158
|
+
throw CliError.usage(`${target} 不是合法 JSON:${err.message}`).withHint(
|
|
159
|
+
'可以删掉它重新配置:xinhe-oa-cli config --set base_url=<地址>',
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// ---------------------------------------------------------------------------
|
|
165
|
+
// config.json:profile 与代理策略
|
|
166
|
+
// ---------------------------------------------------------------------------
|
|
167
|
+
/** 出网代理策略(值就两个字符串,序列化成小写,与 Rust 的 serde 一致)。 */
|
|
168
|
+
export const ProxyMode = Object.freeze({
|
|
169
|
+
/** 尊重进程环境里的 `HTTP_PROXY` / `HTTPS_PROXY` / `ALL_PROXY` 与 `NO_PROXY`(默认)。 */
|
|
170
|
+
System: 'system',
|
|
171
|
+
/** 完全不走代理(客户在**内网直连**后端时用这个)。 */
|
|
172
|
+
None: 'none',
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
/** 解析 proxy 取值:`system` / `env` / 空 → system;`none` / `off` / `direct` → none。 */
|
|
176
|
+
export function parseProxyMode(raw) {
|
|
177
|
+
const text = String(raw ?? '').trim().toLowerCase();
|
|
178
|
+
if (text === 'system' || text === 'env' || text === '') return ProxyMode.System;
|
|
179
|
+
if (text === 'none' || text === 'off' || text === 'direct') return ProxyMode.None;
|
|
180
|
+
throw CliError.usage(`proxy 只能是 system 或 none,收到 ${JSON.stringify(text)}`).withHint(
|
|
181
|
+
'xinhe-oa-cli config --set proxy=none(内网直连)| proxy=system(默认)',
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** config.json 的读写。 */
|
|
186
|
+
export class ConfigStore {
|
|
187
|
+
constructor(paths, file) {
|
|
188
|
+
this.paths = paths;
|
|
189
|
+
this.file = file ?? { current: '', profiles: {} };
|
|
190
|
+
if (!this.file.profiles || typeof this.file.profiles !== 'object') this.file.profiles = {};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
static load(paths) {
|
|
194
|
+
const raw = readJson(paths.configFile);
|
|
195
|
+
return new ConfigStore(paths, raw ?? { current: '', profiles: {} });
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** 当前 profile 名(`config --use` 切换;默认 `default`)。 */
|
|
199
|
+
currentName() {
|
|
200
|
+
const current = this.file.current;
|
|
201
|
+
return typeof current === 'string' && current.trim().length > 0 ? current : DEFAULT_PROFILE;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
profile(name) {
|
|
205
|
+
const found = this.file.profiles[name];
|
|
206
|
+
return found && typeof found === 'object' ? found : {};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
currentProfile() {
|
|
210
|
+
return this.profile(this.currentName());
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
names() {
|
|
214
|
+
const names = Object.keys(this.file.profiles).sort();
|
|
215
|
+
return names.length === 0 ? [DEFAULT_PROFILE] : names;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
setCurrent(name) {
|
|
219
|
+
this.file.current = name;
|
|
220
|
+
if (!this.file.profiles[name]) this.file.profiles[name] = {};
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
setCurrentBaseUrl(value) {
|
|
224
|
+
this.ensureProfile().base_url = value;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
setCurrentProxy(mode) {
|
|
228
|
+
this.ensureProfile().proxy = mode;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
setPhone(phone) {
|
|
232
|
+
const profile = this.ensureProfile();
|
|
233
|
+
if (phone === null || phone === undefined) delete profile.phone;
|
|
234
|
+
else profile.phone = phone;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
ensureProfile() {
|
|
238
|
+
const name = this.currentName();
|
|
239
|
+
if (!this.file.profiles[name] || typeof this.file.profiles[name] !== 'object') {
|
|
240
|
+
this.file.profiles[name] = {};
|
|
241
|
+
}
|
|
242
|
+
return this.file.profiles[name];
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* 落盘形状:**与 Rust 版逐字节同形**。
|
|
247
|
+
*
|
|
248
|
+
* 两处刻意的处理:
|
|
249
|
+
* * profile 按名字排序(Rust 侧是 `BTreeMap`)—— 同一份配置在两种实现下
|
|
250
|
+
* 序列化出的字节相同,`git diff` / 对拍才有意义;
|
|
251
|
+
* * 值为 `null` 的字段**整个省掉**(Rust 的 `skip_serializing_if = "Option::is_none"`)。
|
|
252
|
+
*/
|
|
253
|
+
toJson() {
|
|
254
|
+
const profiles = {};
|
|
255
|
+
for (const name of Object.keys(this.file.profiles).sort()) {
|
|
256
|
+
const raw = this.file.profiles[name] ?? {};
|
|
257
|
+
const profile = {};
|
|
258
|
+
if (typeof raw.base_url === 'string') profile.base_url = raw.base_url;
|
|
259
|
+
if (raw.proxy === ProxyMode.System || raw.proxy === ProxyMode.None) profile.proxy = raw.proxy;
|
|
260
|
+
if (typeof raw.phone === 'string') profile.phone = raw.phone;
|
|
261
|
+
profiles[name] = profile;
|
|
262
|
+
}
|
|
263
|
+
return { current: this.file.current ?? '', profiles };
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
save() {
|
|
267
|
+
this.paths.ensureDir();
|
|
268
|
+
writePrivate(this.paths.configFile, `${JSON.stringify(this.toJson(), null, 2)}\n`);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* 生效的 base_url:命令行 `--base-url` > 指定 profile > 环境 `XINHE_OA_BASE_URL`
|
|
273
|
+
* > 当前 profile。
|
|
274
|
+
*/
|
|
275
|
+
effectiveBaseUrl(cliOverride, profileOverride) {
|
|
276
|
+
if (cliOverride && cliOverride.trim().length > 0) return cliOverride.trim();
|
|
277
|
+
if (profileOverride) {
|
|
278
|
+
const url = this.profile(profileOverride).base_url;
|
|
279
|
+
if (url && url.trim().length > 0) return url;
|
|
280
|
+
}
|
|
281
|
+
const fromEnv = envNonEmpty('XINHE_OA_BASE_URL');
|
|
282
|
+
if (fromEnv) return fromEnv;
|
|
283
|
+
const current = this.currentProfile().base_url;
|
|
284
|
+
return current && current.trim().length > 0 ? current : null;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** 生效的代理策略:环境 `XINHE_OA_PROXY` > 当前 profile > `system`。 */
|
|
288
|
+
effectiveProxy() {
|
|
289
|
+
const fromEnv = envNonEmpty('XINHE_OA_PROXY');
|
|
290
|
+
if (fromEnv) return parseProxyMode(fromEnv);
|
|
291
|
+
const configured = this.currentProfile().proxy;
|
|
292
|
+
return configured === undefined || configured === null
|
|
293
|
+
? ProxyMode.System
|
|
294
|
+
: parseProxyMode(configured);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// ---------------------------------------------------------------------------
|
|
299
|
+
// credentials.json:设备指纹 + 令牌
|
|
300
|
+
// ---------------------------------------------------------------------------
|
|
301
|
+
/** 空令牌组(形状与服务端 `POST /auth/*` 的 `data.tokens` 对齐)。 */
|
|
302
|
+
export function emptyTokens() {
|
|
303
|
+
return { access_token: '', refresh_token: '' };
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/** 一个 profile 的凭据(默认全空)。 */
|
|
307
|
+
export function emptyCredential() {
|
|
308
|
+
return { device_id: '', tokens: emptyTokens() };
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/** 把服务端给的令牌组补成完整形状(缺的字段按 Rust 的 `Tokens` 默认值补)。 */
|
|
312
|
+
export function normalizeTokens(raw) {
|
|
313
|
+
const tokens = raw && typeof raw === 'object' ? raw : {};
|
|
314
|
+
const out = {
|
|
315
|
+
access_token: typeof tokens.access_token === 'string' ? tokens.access_token : '',
|
|
316
|
+
refresh_token: typeof tokens.refresh_token === 'string' ? tokens.refresh_token : '',
|
|
317
|
+
};
|
|
318
|
+
if (typeof tokens.access_expires_at === 'string') out.access_expires_at = tokens.access_expires_at;
|
|
319
|
+
if (typeof tokens.refresh_expires_at === 'string') {
|
|
320
|
+
out.refresh_expires_at = tokens.refresh_expires_at;
|
|
321
|
+
}
|
|
322
|
+
return out;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* 落盘形状:**与 Rust 版逐字节同形**(字段顺序固定、`null`/缺省字段省掉)。
|
|
327
|
+
*
|
|
328
|
+
* 这条不是洁癖:老用户先用 Rust 版登过录,再用 Node 版跑一次 —— 如果这里
|
|
329
|
+
* 把 `tokens` 之外的东西写坏(比如漏掉 `device_id`、或把 `user` 写成 null),
|
|
330
|
+
* 就是"换个实现就要重新短信登录一次",而 `device_id` 一丢服务端立刻
|
|
331
|
+
* `auth.device_mismatch`。
|
|
332
|
+
*/
|
|
333
|
+
export function toCredentialJson(credential) {
|
|
334
|
+
const out = {};
|
|
335
|
+
if (typeof credential?.base_url === 'string') out.base_url = credential.base_url;
|
|
336
|
+
out.device_id = typeof credential?.device_id === 'string' ? credential.device_id : '';
|
|
337
|
+
out.tokens = normalizeTokens(credential?.tokens);
|
|
338
|
+
if (credential?.user !== undefined && credential?.user !== null) out.user = credential.user;
|
|
339
|
+
if (typeof credential?.phone === 'string') out.phone = credential.phone;
|
|
340
|
+
if (typeof credential?.updated_at === 'string') out.updated_at = credential.updated_at;
|
|
341
|
+
return out;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
export function hasRefresh(credential) {
|
|
345
|
+
return Boolean(credential?.tokens?.refresh_token);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
export function hasAccess(credential) {
|
|
349
|
+
return Boolean(credential?.tokens?.access_token);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/** credentials.json 的读写。**唯一的凭据落点**。 */
|
|
353
|
+
export class CredentialStore {
|
|
354
|
+
constructor(paths, file) {
|
|
355
|
+
this.paths = paths;
|
|
356
|
+
this.file = file ?? { profiles: {} };
|
|
357
|
+
if (!this.file.profiles || typeof this.file.profiles !== 'object') this.file.profiles = {};
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
static load(paths) {
|
|
361
|
+
const raw = readJson(paths.credentialsFile);
|
|
362
|
+
return new CredentialStore(paths, raw ?? { profiles: {} });
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
get(name) {
|
|
366
|
+
const found = this.file.profiles[name];
|
|
367
|
+
return found && typeof found === 'object' ? found : null;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
put(name, credential) {
|
|
371
|
+
this.file.profiles[name] = credential;
|
|
372
|
+
this.save();
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
remove(name) {
|
|
376
|
+
const existed = Object.prototype.hasOwnProperty.call(this.file.profiles, name);
|
|
377
|
+
if (existed) {
|
|
378
|
+
delete this.file.profiles[name];
|
|
379
|
+
this.save();
|
|
380
|
+
}
|
|
381
|
+
return existed;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/** 落盘形状:字段顺序固定,`null` / 缺省字段省掉(见 `toCredentialJson`)。 */
|
|
385
|
+
toJson() {
|
|
386
|
+
const profiles = {};
|
|
387
|
+
for (const name of Object.keys(this.file.profiles).sort()) {
|
|
388
|
+
profiles[name] = toCredentialJson(this.file.profiles[name]);
|
|
389
|
+
}
|
|
390
|
+
return { profiles };
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
save() {
|
|
394
|
+
this.paths.ensureDir();
|
|
395
|
+
writePrivate(this.paths.credentialsFile, `${JSON.stringify(this.toJson(), null, 2)}\n`);
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/** 文件实际权限(`doctor` 与测试用它验证 0600)。Windows 上返回 null。 */
|
|
399
|
+
fileMode() {
|
|
400
|
+
if (process.platform === 'win32') return null;
|
|
401
|
+
try {
|
|
402
|
+
return fs.statSync(this.paths.credentialsFile).mode & 0o777;
|
|
403
|
+
} catch {
|
|
404
|
+
return null;
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
}
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
// 错误与退出码(契约 §3.2,冻结)。
|
|
2
|
+
//
|
|
3
|
+
// | 码 | 含义 |
|
|
4
|
+
// |----|---------------------------------------------------------|
|
|
5
|
+
// | 0 | 成功 |
|
|
6
|
+
// | 2 | 用法错误(CLI 本地判定,**不发请求**) |
|
|
7
|
+
// | 3 | 未登录 / 凭据失效(含 refresh 过期) |
|
|
8
|
+
// | 4 | 权限不足 |
|
|
9
|
+
// | 5 | 冲突 / 幂等 / 乐观锁 |
|
|
10
|
+
// | 6 | 服务端错误 |
|
|
11
|
+
// | 7 | 网络不可达 / 超时 |
|
|
12
|
+
// | 8 | 版本过旧(服务端要求升级) |
|
|
13
|
+
//
|
|
14
|
+
// 一条纪律:错误里**永不**出现令牌。服务端的 error.details 原样带回去给调用方,
|
|
15
|
+
// 但 CLI 自己新造的任何 hint / message 都必须只提命令与参数。
|
|
16
|
+
|
|
17
|
+
/** 成功。 */
|
|
18
|
+
export const EXIT_OK = 0;
|
|
19
|
+
/** 用法错误:参数拼错、缺必填、高风险命令没带 `--yes`。**本地判定,不发请求**。 */
|
|
20
|
+
export const EXIT_USAGE = 2;
|
|
21
|
+
/** 未登录 / 凭据失效。 */
|
|
22
|
+
export const EXIT_AUTH = 3;
|
|
23
|
+
/** 权限不足。 */
|
|
24
|
+
export const EXIT_AUTHZ = 4;
|
|
25
|
+
/** 冲突 / 幂等 / 乐观锁。 */
|
|
26
|
+
export const EXIT_CONFLICT = 5;
|
|
27
|
+
/** 服务端错误。 */
|
|
28
|
+
export const EXIT_SERVER = 6;
|
|
29
|
+
/** 网络不可达 / 超时。 */
|
|
30
|
+
export const EXIT_NETWORK = 7;
|
|
31
|
+
/** 版本过旧(服务端要求升级 CLI)。 */
|
|
32
|
+
export const EXIT_TOO_OLD = 8;
|
|
33
|
+
|
|
34
|
+
/** 错误分类(由 `error.code` + HTTP 状态共同决定)。 */
|
|
35
|
+
export const Kind = Object.freeze({
|
|
36
|
+
Usage: 'Usage',
|
|
37
|
+
Auth: 'Auth',
|
|
38
|
+
Authz: 'Authz',
|
|
39
|
+
Conflict: 'Conflict',
|
|
40
|
+
Server: 'Server',
|
|
41
|
+
Network: 'Network',
|
|
42
|
+
TooOld: 'TooOld',
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
const KIND_EXIT = Object.freeze({
|
|
46
|
+
[Kind.Usage]: EXIT_USAGE,
|
|
47
|
+
[Kind.Auth]: EXIT_AUTH,
|
|
48
|
+
[Kind.Authz]: EXIT_AUTHZ,
|
|
49
|
+
[Kind.Conflict]: EXIT_CONFLICT,
|
|
50
|
+
[Kind.Server]: EXIT_SERVER,
|
|
51
|
+
[Kind.Network]: EXIT_NETWORK,
|
|
52
|
+
[Kind.TooOld]: EXIT_TOO_OLD,
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
const KIND_LABEL = Object.freeze({
|
|
56
|
+
[Kind.Usage]: 'usage',
|
|
57
|
+
[Kind.Auth]: 'auth',
|
|
58
|
+
[Kind.Authz]: 'authz',
|
|
59
|
+
[Kind.Conflict]: 'conflict',
|
|
60
|
+
[Kind.Server]: 'server',
|
|
61
|
+
[Kind.Network]: 'network',
|
|
62
|
+
[Kind.TooOld]: 'too_old',
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* 服务端错误码 → 退出码分类(契约 §3.2 的判据)。
|
|
67
|
+
*
|
|
68
|
+
* 判定顺序是刻意的:先看码(码比 HTTP 状态更精确),再兜 HTTP 状态。
|
|
69
|
+
* `httpStatus` 为 null / undefined 表示这不是一次 HTTP 往返(本地判定或网络故障)。
|
|
70
|
+
*/
|
|
71
|
+
export function classify(code, httpStatus) {
|
|
72
|
+
const text = typeof code === 'string' ? code : '';
|
|
73
|
+
const status = typeof httpStatus === 'number' ? httpStatus : null;
|
|
74
|
+
if (text.startsWith('cli.too_old')) return Kind.TooOld;
|
|
75
|
+
if (text.startsWith('authz.')) {
|
|
76
|
+
// 契约 §3.2 点名的是 `authz.missing_scope`;`authz.*` 家族
|
|
77
|
+
// (field_forbidden / tenant_endorsement_required / delegate_*)在语义上
|
|
78
|
+
// 全是"权限不足",归到同一个退出码才是对调用方有用的行为。
|
|
79
|
+
return Kind.Authz;
|
|
80
|
+
}
|
|
81
|
+
if (text === 'skill.not_owner') {
|
|
82
|
+
// docs/34 §3 第二步:`--on-conflict update` 只有原创建者或企业负责人能选。
|
|
83
|
+
// 服务端用的是 `skill.*` 命名空间(不是 `authz.*`),但语义就是"你没有这个权力"。
|
|
84
|
+
return Kind.Authz;
|
|
85
|
+
}
|
|
86
|
+
if (text === 'file.not_owner' || text === 'file.visibility_exceeds_ceiling') {
|
|
87
|
+
// FILE-005:改可见性 / 移动 / 软删只有所有者(或企业负责人)能做;
|
|
88
|
+
// 超出容器上限同样要容器管理员 —— 两者服务端都是 403,语义都是"权限不足"。
|
|
89
|
+
return Kind.Authz;
|
|
90
|
+
}
|
|
91
|
+
if (text === 'skill.name_reserved') {
|
|
92
|
+
// 内置技能不可覆盖:归 5 而不是 2 —— 退出码 2 的判据写死了"CLI 本地判定、不发请求",
|
|
93
|
+
// 而这个码只可能从服务端回来;它的出路(换个名字重来)与 `--on-conflict rename` 同类。
|
|
94
|
+
return Kind.Conflict;
|
|
95
|
+
}
|
|
96
|
+
if (text.startsWith('auth.')) return Kind.Auth;
|
|
97
|
+
// 契约写的是 `write.conflict*` 与 `idempotency.*`;服务端实际的码是
|
|
98
|
+
// `write.version_conflict` / `write.duplicate_row` / `write.duplicate_idempotency_key`。
|
|
99
|
+
// 按**语义**归并:冲突与幂等一类进 5。`write.missing_idempotency_key` /
|
|
100
|
+
// `write.invalid_idempotency_key` 是调用方的请求错误(400),不算"冲突",留在 6。
|
|
101
|
+
const idempotencyFamily =
|
|
102
|
+
text.includes('idempotency') &&
|
|
103
|
+
!text.endsWith('missing_idempotency_key') &&
|
|
104
|
+
!text.endsWith('invalid_idempotency_key');
|
|
105
|
+
if (text.includes('conflict') || idempotencyFamily || status === 409) return Kind.Conflict;
|
|
106
|
+
if (status !== null && status >= 500) return Kind.Server;
|
|
107
|
+
return Kind.Server;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** CLI 的统一错误类型。 */
|
|
111
|
+
export class CliError extends Error {
|
|
112
|
+
/**
|
|
113
|
+
* @param {string} kind Kind.*
|
|
114
|
+
* @param {string} code 服务端 `error.code`(CLI 不新造码;本地错误用 `cli.*` 前缀)
|
|
115
|
+
* @param {string} message 给人看的一句话
|
|
116
|
+
*/
|
|
117
|
+
constructor(kind, code, message) {
|
|
118
|
+
super(message);
|
|
119
|
+
this.name = 'CliError';
|
|
120
|
+
this.kind = kind;
|
|
121
|
+
this.code = code;
|
|
122
|
+
this.details = {};
|
|
123
|
+
this.hint = null;
|
|
124
|
+
this.requestId = null;
|
|
125
|
+
/** 分类前的 HTTP 状态(排障用,不进 `--json`)。 */
|
|
126
|
+
this.httpStatus = null;
|
|
127
|
+
/**
|
|
128
|
+
* 给人看的细节(人类可读输出用它替代 `details` 的紧凑 JSON)。
|
|
129
|
+
* `details` 是**机器**形状(判卷与脚本读它),有时又长又密(比如冲突清单);
|
|
130
|
+
* 直接压成一行 JSON 甩给人等于没说。这一栏只影响 `table` / `md` 渲染,
|
|
131
|
+
* **不进 `--json`** —— 两种读者的形状各自保持稳定。
|
|
132
|
+
*/
|
|
133
|
+
this.human = null;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** 用法错误(退出码 2)。`hint` 里给可直接照抄的下一步。 */
|
|
137
|
+
static usage(message) {
|
|
138
|
+
return new CliError(Kind.Usage, 'cli.usage', message);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** 本地错误(网络故障等),没有服务端参与。 */
|
|
142
|
+
static local(message) {
|
|
143
|
+
return new CliError(Kind.Network, 'cli.network', message);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
withHint(hint) {
|
|
147
|
+
this.hint = hint;
|
|
148
|
+
return this;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
withDetails(details) {
|
|
152
|
+
this.details = details === undefined || details === null ? {} : details;
|
|
153
|
+
return this;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** 给人看的细节(只影响 `table` / `md` 渲染;`--json` 的 `details` 不变)。 */
|
|
157
|
+
withHumanDetails(human) {
|
|
158
|
+
this.human = human;
|
|
159
|
+
return this;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
withRequestId(requestId) {
|
|
163
|
+
this.requestId = requestId ?? null;
|
|
164
|
+
return this;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
get exitCode() {
|
|
168
|
+
return KIND_EXIT[this.kind] ?? EXIT_SERVER;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** 错误分类标签(人类可读输出里标一下"这是权限问题还是网络问题")。 */
|
|
172
|
+
get kindLabel() {
|
|
173
|
+
return KIND_LABEL[this.kind] ?? 'server';
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** `--json` 的失败形状(契约 §3.1)。 */
|
|
177
|
+
toErrorJson() {
|
|
178
|
+
return {
|
|
179
|
+
code: this.code,
|
|
180
|
+
message: this.message,
|
|
181
|
+
details: this.details ?? {},
|
|
182
|
+
hint: this.hint ?? null,
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** 凭据问题的统一提示(契约 §4:`hint` 必须给出短信登录命令)。 */
|
|
188
|
+
export function loginHint(phone) {
|
|
189
|
+
if (phone && phone.length > 0) {
|
|
190
|
+
return (
|
|
191
|
+
`凭据已失效,重新登录:xinhe-oa-cli login --phone ${phone}(再用收到的验证码跑 ` +
|
|
192
|
+
`xinhe-oa-cli login --phone ${phone} --code <验证码>)`
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
return (
|
|
196
|
+
'凭据已失效,重新登录:xinhe-oa-cli login --phone <你的手机号> ' +
|
|
197
|
+
'(再用收到的验证码跑 xinhe-oa-cli login --phone <你的手机号> --code <验证码>)'
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* 把一组"候选动作"收成一个:0 个或多于 1 个都是用法错误。
|
|
203
|
+
*
|
|
204
|
+
* @param {Array<[string, boolean]>} selected [动作名, 是否被选中]
|
|
205
|
+
* @param {string} what 动作所在的位置(如 `bitable base`)
|
|
206
|
+
* @param {string} hint
|
|
207
|
+
*/
|
|
208
|
+
export function exactlyOne(selected, what, hint) {
|
|
209
|
+
const picked = selected.filter(([, on]) => on).map(([name]) => name);
|
|
210
|
+
if (picked.length === 1) return picked[0];
|
|
211
|
+
if (picked.length === 0) {
|
|
212
|
+
throw CliError.usage(`\`${what}\` 必须给一个动作`).withHint(hint);
|
|
213
|
+
}
|
|
214
|
+
throw CliError.usage(
|
|
215
|
+
`\`${what}\` 一次只能给一个动作,收到 ${picked.length} 个:${picked.join(' / ')}`,
|
|
216
|
+
).withHint(hint);
|
|
217
|
+
}
|