@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,1474 @@
|
|
|
1
|
+
// 文件:`file --upload|--show|--download|--versions|--delete|--compare|--lookup|--search`
|
|
2
|
+
// `|--visibility|--move|--acl-list|--acl-revoke|--permission-request|--permission-assign`
|
|
3
|
+
// (FILE-001 / FILE-002 / FILE-003 / FILE-005 / FILE-006,docs/37 §3、
|
|
4
|
+
// docs/issues/FILE-00x §3、docs/31 §2.1.1~§2.1.3)—— 第 17 个顶层域。
|
|
5
|
+
//
|
|
6
|
+
// 文件与附件(`attachment` 域)是**同一个对象**:同一张表、同一条版本链、同一个身份键。
|
|
7
|
+
// 差别只在"入口的语义":
|
|
8
|
+
//
|
|
9
|
+
// - `file --upload` 走 `POST /files`:`--key` 是**业务主键**(`<type>:<business-id>`,
|
|
10
|
+
// 如 `contract:HT-2026-001`)、`--summary` 必填(摘要由客户端给,平台不做文档智能);
|
|
11
|
+
// `--expected-version` 是乐观锁,`0` = 只创建不覆盖。
|
|
12
|
+
// - 其余命令(元数据 / 下载 / 版本 / 删除)复用 `/attachments` 面;`--compare` /
|
|
13
|
+
// `--lookup` 走 `GET /files/by-key`。
|
|
14
|
+
//
|
|
15
|
+
// 四件事在 CLI 本地判定(退出码 2、**不发请求**,docs/31 §3.2 的判据),
|
|
16
|
+
// 因为它们是"客户端完全能提前知道"的事,而服务端的 422 会被归到退出码 6
|
|
17
|
+
// (`write.validation_failed` + 422 → 服务端错误),直接透传会把"调用方漏了参数"
|
|
18
|
+
// 误导成"服务端故障":
|
|
19
|
+
//
|
|
20
|
+
// 1. `--upload` 缺 `--file` / `--key` / `--summary`(或给了空白值)、
|
|
21
|
+
// `--expected-version` 为负;
|
|
22
|
+
// 2. `--compare` 缺 `--file` / `--key`;`--lookup` 缺 `--key`;
|
|
23
|
+
// 3. 高风险动作(`--delete` / `--visibility --set` / `--move`)必须 `--yes`;
|
|
24
|
+
// 4. `--search` 的参数越界(`--limit`、`--since/--until`、单字符查询串、`--match` 枚举)。
|
|
25
|
+
//
|
|
26
|
+
// **上传遇 409 绝不自动重试**:丢 `--expected-version` 再发一次、或改成服务端给的
|
|
27
|
+
// `current_version` 再发一次,都会把"冲突"变成"用本地内容覆盖了别人的版本"。
|
|
28
|
+
// 没有 `--force`。
|
|
29
|
+
|
|
30
|
+
import fs from 'node:fs';
|
|
31
|
+
import path from 'node:path';
|
|
32
|
+
import { createHash } from 'node:crypto';
|
|
33
|
+
|
|
34
|
+
import { CliError, Kind, exactlyOne } from '../core/error.js';
|
|
35
|
+
import { flagOn, flagValue, intFlag } from '../core/args.js';
|
|
36
|
+
import { Auth, multipartBody, requestSpec } from '../core/http.js';
|
|
37
|
+
import { dataOf, idempotencyKey, readFileBytes } from '../core/runtime.js';
|
|
38
|
+
import { warn } from '../core/output.js';
|
|
39
|
+
import {
|
|
40
|
+
FILE_SEARCH_MAX_LIMIT,
|
|
41
|
+
FILE_SEARCH_MAX_WORKERS,
|
|
42
|
+
FILE_SEARCH_MIN_QUERY,
|
|
43
|
+
FILE_VISIBILITY_TIERS,
|
|
44
|
+
fileNameOf,
|
|
45
|
+
queryPairs,
|
|
46
|
+
} from '../core/values.js';
|
|
47
|
+
|
|
48
|
+
/** 一次 `--all` 最多翻到第几行(护栏:别把服务端拖住;与 `record --list` 同源)。 */
|
|
49
|
+
const MAX_SEARCH_OFFSET = 100_000;
|
|
50
|
+
|
|
51
|
+
/** `file` 一次只能给一个动作时的 hint(与 Rust 的 `exactly_one` 逐字一致)。 */
|
|
52
|
+
const FILE_ACTION_HINT =
|
|
53
|
+
'例:xinhe-oa-cli file --upload --file ./合同扫描件.pdf --key contract:HT-2026-001 --summary "2026 年度技术服务合同" --tenant kashu';
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* `file` 的动作分发。
|
|
57
|
+
*
|
|
58
|
+
* **先做"恰好一个动作"的判定**,与 Rust 的 clap `ArgGroup(action, multiple=false)` 同义。
|
|
59
|
+
* 这一步不能省:规格驱动的解析器只对"注册在 `params` 里的布尔动作旗标"查互斥,
|
|
60
|
+
* 而 `file` 的动作旗标一半来自 `extra_flags`、一半是**带取值的**(`--delete <ID>`),
|
|
61
|
+
* 于是 `file --download a --delete a` 这种命令行能过解析 —— Rust 会拒(退出码 2、
|
|
62
|
+
* 不发请求),所以必须在这里补上同一道闸门,否则我们会替调用方"挑一个执行"。
|
|
63
|
+
*
|
|
64
|
+
* `--visibility <文件>` 是**带子动作的动作**(Rust 里刻意不在 clap 的 action 组里):
|
|
65
|
+
* 真正的动作是 `--show`(读)或 `--set <档位>`(写),两者仍互斥。
|
|
66
|
+
*/
|
|
67
|
+
export async function handleFile({ args, positional, runtime, command }) {
|
|
68
|
+
const picked = exactlyOne(
|
|
69
|
+
[
|
|
70
|
+
['--upload', flagOn(args, '--upload')],
|
|
71
|
+
['--show', args['--show'] !== undefined],
|
|
72
|
+
['--download', args['--download'] !== undefined],
|
|
73
|
+
['--versions', args['--versions'] !== undefined],
|
|
74
|
+
['--delete', args['--delete'] !== undefined],
|
|
75
|
+
['--compare', flagOn(args, '--compare')],
|
|
76
|
+
['--lookup', flagOn(args, '--lookup')],
|
|
77
|
+
['--search', flagOn(args, '--search')],
|
|
78
|
+
['--move', args['--move'] !== undefined],
|
|
79
|
+
['--acl-list', args['--acl-list'] !== undefined],
|
|
80
|
+
['--acl-revoke', args['--acl-revoke'] !== undefined],
|
|
81
|
+
['--permission-request', args['--permission-request'] !== undefined],
|
|
82
|
+
['--permission-assign', args['--permission-assign'] !== undefined],
|
|
83
|
+
['--set', args['--set'] !== undefined],
|
|
84
|
+
],
|
|
85
|
+
'file',
|
|
86
|
+
FILE_ACTION_HINT,
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
if (args['--visibility'] !== undefined) return visibility(runtime, args);
|
|
90
|
+
if (picked === '--upload') return upload(runtime, args);
|
|
91
|
+
if (picked === '--show') return show(runtime, args);
|
|
92
|
+
if (picked === '--download') return download(runtime, args, positional ?? [], command);
|
|
93
|
+
if (picked === '--versions') return versions(runtime, args);
|
|
94
|
+
if (picked === '--delete') return deleteFile(runtime, args);
|
|
95
|
+
if (picked === '--compare') return compare(runtime, args);
|
|
96
|
+
if (picked === '--lookup') return lookup(runtime, args);
|
|
97
|
+
if (picked === '--search') return search(runtime, args);
|
|
98
|
+
if (picked === '--move') return move(runtime, args);
|
|
99
|
+
if (picked === '--acl-list') return aclList(runtime, args);
|
|
100
|
+
if (picked === '--acl-revoke') return aclRevoke(runtime, args);
|
|
101
|
+
if (picked === '--permission-request') return permissionRequest(runtime, args);
|
|
102
|
+
if (picked === '--permission-assign') return permissionAssign(runtime, args);
|
|
103
|
+
// `--set` 只与 `--visibility <文件>` 一起用(单独出现 = 用法错误)。
|
|
104
|
+
throw CliError.usage('`file` 的 --set 只与 --visibility <文件> 一起用').withHint(
|
|
105
|
+
'例:xinhe-oa-cli file --visibility contract:HT-2026-001 --set internal --yes --json',
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// ---------------------------------------------------------------------------
|
|
110
|
+
// 共用件:企业解析 + 签名 URL 下载
|
|
111
|
+
//
|
|
112
|
+
// 这两件在 Rust 侧分别住在 `commands/tenant.rs` 与 `commands/attachment.rs`,
|
|
113
|
+
// Node 版把它们放在 `file.js` 里由 attachment / event 两个域 import:
|
|
114
|
+
// 三个域的移植是同一批改动,放在**被依赖方**(file.js 不反向 import 另两个域)
|
|
115
|
+
// 才不会出现 ESM 循环导入。
|
|
116
|
+
// ---------------------------------------------------------------------------
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* 企业 id:显式 `--tenant` 优先,否则用当前唯一可见企业(那是一次 `GET /me`)。
|
|
120
|
+
*
|
|
121
|
+
* 与 Rust `tenant::resolve_tenant` 逐条同义:
|
|
122
|
+
* - 显式值 trim 后非空 → 直接用,**不发请求**;
|
|
123
|
+
* - 否则 `GET /me`,从 `data.scope.tenants` 里取:恰好 1 个才自动选;
|
|
124
|
+
* 0 个 → 调用方给"必须指明企业"的用法错误;多个 → 退出码 2 + `visible_tenants` 明细
|
|
125
|
+
* ("你有多个可见企业"是**调用方**要解决的事,不能替它猜)。
|
|
126
|
+
*/
|
|
127
|
+
export async function resolveTenant(runtime, requested, action) {
|
|
128
|
+
const explicit = requested === null || requested === undefined ? '' : String(requested).trim();
|
|
129
|
+
if (explicit.length > 0) return explicit;
|
|
130
|
+
const response = await runtime.sendAuthed('GET', '/me', requestSpec());
|
|
131
|
+
const data = dataOf(response);
|
|
132
|
+
const tenants = Array.isArray(data?.scope?.tenants) ? data.scope.tenants : [];
|
|
133
|
+
if (tenants.length === 1) {
|
|
134
|
+
const id = tenants[0]?.id;
|
|
135
|
+
if (typeof id === 'string' && id.length > 0) return id;
|
|
136
|
+
throw tenantUsage(action);
|
|
137
|
+
}
|
|
138
|
+
if (tenants.length === 0) throw tenantUsage(action);
|
|
139
|
+
throw CliError.usage('你有多个可见企业,请用 --tenant <TENANT_ID> 指明').withDetails({
|
|
140
|
+
visible_tenants: tenants,
|
|
141
|
+
hint: 'xinhe-oa-cli tenant --list',
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function tenantUsage(action) {
|
|
146
|
+
return CliError.usage(`${action}必须指明企业`).withHint(
|
|
147
|
+
'加 --tenant <TENANT_ID>;用 `xinhe-oa-cli tenant --list` 看有哪些企业',
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* 取签名 URL → **匿名**下载 → 落盘 + sha256 校验(`attachment --download` 与
|
|
153
|
+
* `file --download` 共用;Rust 侧同为一段实现:文件与附件是同一条版本链,
|
|
154
|
+
* 下载语义各写一遍迟早漂移 —— 比如只记得一边补 sha256 删除落地文件)。
|
|
155
|
+
*
|
|
156
|
+
* 返回 `{response, data}`;`data` 的键是
|
|
157
|
+
* `{attachment_id, version, path, bytes, sha256, content_hash_verified, expires_at}`,
|
|
158
|
+
* 由调用方决定把 `attachment_id` 改名成 `file_id`(file 域的契约键名)。
|
|
159
|
+
*/
|
|
160
|
+
export async function downloadWithSignedUrl(runtime, attachmentId, version, output) {
|
|
161
|
+
// ① 取签名 URL(这一步带身份:没有读权限会 403)
|
|
162
|
+
const query = queryPairs([
|
|
163
|
+
['version', version === null || version === undefined ? null : String(version)],
|
|
164
|
+
]);
|
|
165
|
+
const urlPath = `/attachments/${attachmentId}/download-url`;
|
|
166
|
+
const response = await runtime.sendAuthed('POST', urlPath, requestSpec({ query }));
|
|
167
|
+
const signed = dataOf(response);
|
|
168
|
+
const url = typeof signed?.url === 'string' ? signed.url : null;
|
|
169
|
+
if (url === null) {
|
|
170
|
+
throw new CliError(Kind.Server, 'cli.bad_response', '服务端没有回签名 URL(data.url)');
|
|
171
|
+
}
|
|
172
|
+
const expectedHash = typeof signed?.content_hash === 'string' ? signed.content_hash : null;
|
|
173
|
+
const remoteName = typeof signed?.name === 'string' ? signed.name : 'attachment.bin';
|
|
174
|
+
|
|
175
|
+
// ② 用签名 URL **匿名**下载(签名即授权;这里刻意不带 Authorization)
|
|
176
|
+
const baseUrl = runtime.requireBaseUrl();
|
|
177
|
+
const relative = url.startsWith(baseUrl) ? url.slice(baseUrl.length) : url;
|
|
178
|
+
const http = runtime.anonymousHttp();
|
|
179
|
+
let downloaded;
|
|
180
|
+
try {
|
|
181
|
+
downloaded = await http.send('GET', relative, Auth.none(), requestSpec(), 'attachment --download');
|
|
182
|
+
} catch (err) {
|
|
183
|
+
if (err instanceof CliError) {
|
|
184
|
+
// 签名 URL 有效期默认 300 秒:失败时最可能的原因就是它过期了。
|
|
185
|
+
err.hint = '签名 URL 有效期默认 300 秒:过期就重跑一次这个命令(第 ① 步会换新链接)';
|
|
186
|
+
}
|
|
187
|
+
throw err;
|
|
188
|
+
}
|
|
189
|
+
// 二进制响应走 `response.raw`(`core/http.js` 对非 JSON 响应把原始字节放这里)。
|
|
190
|
+
const content = downloaded.raw === null || downloaded.raw === undefined ? Buffer.alloc(0) : downloaded.raw;
|
|
191
|
+
if (content.length === 0) {
|
|
192
|
+
throw new CliError(Kind.Server, 'cli.bad_response', '下载到 0 字节(附件没有文件体?)')
|
|
193
|
+
.withDetails({ attachment_id: attachmentId })
|
|
194
|
+
.withHint('`attachment --show <id>` 看 storage_backend:none 表示只有元数据、没有文件体');
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// ③ 落盘 + 校验 sha256(传输坏了要当场发现,而不是让人拿着坏文件干活)
|
|
198
|
+
const target = resolveOutput(output, remoteName);
|
|
199
|
+
const parent = path.dirname(target);
|
|
200
|
+
if (parent.length > 0 && parent !== '.') {
|
|
201
|
+
try {
|
|
202
|
+
fs.mkdirSync(parent, { recursive: true });
|
|
203
|
+
} catch (err) {
|
|
204
|
+
throw CliError.usage(`建不了目录 ${parent}:${err.message}`);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
try {
|
|
208
|
+
fs.writeFileSync(target, content);
|
|
209
|
+
} catch (err) {
|
|
210
|
+
throw CliError.usage(`写不了 ${target}:${err.message}`);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const actualHash = sha256Hex(content);
|
|
214
|
+
const verified = expectedHash === null ? null : expectedHash === actualHash;
|
|
215
|
+
if (verified === false) {
|
|
216
|
+
// 落到盘上的内容与服务端不一致:删掉,别让人拿着坏文件干活。
|
|
217
|
+
fs.rmSync(target, { force: true });
|
|
218
|
+
throw new CliError(
|
|
219
|
+
Kind.Server,
|
|
220
|
+
'cli.checksum_mismatch',
|
|
221
|
+
'下载内容与附件不一致(sha256 不符),已删除落地文件',
|
|
222
|
+
)
|
|
223
|
+
.withDetails({
|
|
224
|
+
expected_sha256: expectedHash,
|
|
225
|
+
actual_sha256: actualHash,
|
|
226
|
+
attachment_id: attachmentId,
|
|
227
|
+
})
|
|
228
|
+
.withHint('重跑一次;若持续不符,把 attachment_id 与 request_id 给运维');
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
return {
|
|
232
|
+
response,
|
|
233
|
+
data: {
|
|
234
|
+
attachment_id: attachmentId,
|
|
235
|
+
version: signed?.version ?? null,
|
|
236
|
+
path: target,
|
|
237
|
+
bytes: content.length,
|
|
238
|
+
sha256: actualHash,
|
|
239
|
+
content_hash_verified: verified,
|
|
240
|
+
expires_at: signed?.expires_at ?? null,
|
|
241
|
+
},
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* `--output` 的解析(与 Rust `attachment::resolve_output` 逐条同义):
|
|
247
|
+
* 目录(以 `/` 结尾,或已存在且是目录)就拼远端文件名,否则原样当文件路径。
|
|
248
|
+
*/
|
|
249
|
+
export function resolveOutput(argsOutput, remoteName) {
|
|
250
|
+
if (argsOutput === null || argsOutput === undefined) return remoteName;
|
|
251
|
+
const value = String(argsOutput);
|
|
252
|
+
let isDir = value.endsWith('/');
|
|
253
|
+
if (!isDir) {
|
|
254
|
+
try {
|
|
255
|
+
isDir = fs.statSync(value).isDirectory();
|
|
256
|
+
} catch {
|
|
257
|
+
isDir = false;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
return isDir ? path.join(value, remoteName) : value;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// ---------------------------------------------------------------------------
|
|
264
|
+
// `--upload` / `--show` / `--download` / `--versions` / `--delete`
|
|
265
|
+
// ---------------------------------------------------------------------------
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* `file --upload --file <PATH> --key <KEY> --summary <TEXT> --tenant <TENANT_ID>`。
|
|
269
|
+
*
|
|
270
|
+
* 校验顺序照抄 Rust:三件必填 + `--expected-version` 先在**本地**判(一个请求都不发),
|
|
271
|
+
* 再读文件(读不了也是用法错误),最后才解析企业(那一步才可能联网)。
|
|
272
|
+
*/
|
|
273
|
+
async function upload(runtime, args) {
|
|
274
|
+
const file = requiredText(
|
|
275
|
+
flagValue(args, '--file'),
|
|
276
|
+
'`file --upload` 需要 --file <路径>',
|
|
277
|
+
'例:xinhe-oa-cli file --upload --file ./合同扫描件.pdf --key contract:HT-2026-001 --summary "2026 年度技术服务合同" --tenant kashu',
|
|
278
|
+
);
|
|
279
|
+
const key = requiredText(
|
|
280
|
+
flagValue(args, '--key'),
|
|
281
|
+
'`file --upload` 需要 --key <业务主键>',
|
|
282
|
+
'格式 --key <type>:<business-id>(例:--key contract:HT-2026-001);类型段是文档类型,不许用部门/可见性等保留字',
|
|
283
|
+
);
|
|
284
|
+
const summary = requiredText(
|
|
285
|
+
flagValue(args, '--summary'),
|
|
286
|
+
'`file --upload` 需要 --summary <摘要>',
|
|
287
|
+
'摘要由客户端生成(1..2000 字符),平台不做文档智能;例:--summary "2026 年度技术服务合同,含保密条款"',
|
|
288
|
+
);
|
|
289
|
+
const expectedVersion = expectedVersionValue(args);
|
|
290
|
+
|
|
291
|
+
const bytes = readFileBytes(file);
|
|
292
|
+
const uploadName = fileNameOf(file);
|
|
293
|
+
const tenant = await resolveTenant(runtime, flagValue(args, '--tenant'), '上传文件');
|
|
294
|
+
|
|
295
|
+
const fields = [
|
|
296
|
+
['tenant', tenant],
|
|
297
|
+
['key', key],
|
|
298
|
+
['summary', summary],
|
|
299
|
+
];
|
|
300
|
+
// `--name` / `--mime-type` 给了就进表单(空串也是"给了"—— 与 Rust 的
|
|
301
|
+
// `if let Some(...)` 同义;缺省时显示名由服务端取文件名)。
|
|
302
|
+
const name = flagValue(args, '--name');
|
|
303
|
+
if (name !== null) fields.push(['name', name]);
|
|
304
|
+
const mime = flagValue(args, '--mime-type');
|
|
305
|
+
if (mime !== null) fields.push(['mime_type', mime]);
|
|
306
|
+
if (expectedVersion !== null) {
|
|
307
|
+
// 乐观锁:服务端据此判"我读到的版本有没有被别人改过"(0 = 只创建不覆盖)。
|
|
308
|
+
fields.push(['expected_version', String(expectedVersion)]);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const form = multipartBody(fields, ['file', uploadName, bytes]);
|
|
312
|
+
if (runtime.dryRun) {
|
|
313
|
+
runtime.dryRunReport('POST', '/files', requestSpec({ multipart: form }));
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
// **绝不自动重试**:409 直接向上抛(含 details),由调用方解冲突。
|
|
317
|
+
const response = await runtime.sendUpload('/files', form, idempotencyKey('file-upload'));
|
|
318
|
+
runtime.finishFrom(response, {
|
|
319
|
+
file: dataOf(response),
|
|
320
|
+
next_step: '挂到记录上:xinhe-oa-cli attachment --link <FILE_ID> --resource-id <RECORD_ID>',
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/** `file --show <FILE_ID>`:元数据 + 版本 + 引用(同一端点,读 `data` 整体)。 */
|
|
325
|
+
async function show(runtime, args) {
|
|
326
|
+
const reference = fileReference(
|
|
327
|
+
flagValue(args, '--show'),
|
|
328
|
+
'file --show',
|
|
329
|
+
'--show <FILE_ID>',
|
|
330
|
+
'例:xinhe-oa-cli file --show att_123 --json',
|
|
331
|
+
);
|
|
332
|
+
const spec = requestSpec();
|
|
333
|
+
const requestPath = `/attachments/${reference}`;
|
|
334
|
+
if (runtime.dryRun) {
|
|
335
|
+
runtime.dryRunReport('GET', requestPath, spec);
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
const response = await runtime.sendAuthed('GET', requestPath, spec);
|
|
339
|
+
runtime.finishFrom(response, { file: dataOf(response) });
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/** `file --download <FILE_ID> [--version N] [--output <PATH>]`。 */
|
|
343
|
+
async function download(runtime, args, positional, command) {
|
|
344
|
+
const fileId = String(args['--download'] ?? '');
|
|
345
|
+
const version =
|
|
346
|
+
positiveVersionValue(args, '`file --download` 的 --version 必须是正整数') ??
|
|
347
|
+
versionFromPositional(positional, command);
|
|
348
|
+
if (runtime.dryRun) {
|
|
349
|
+
const query = queryPairs([['version', version === null ? null : String(version)]]);
|
|
350
|
+
runtime.dryRunReport(
|
|
351
|
+
'POST',
|
|
352
|
+
`/attachments/${fileId}/download-url`,
|
|
353
|
+
requestSpec({ query }),
|
|
354
|
+
);
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
const { response, data } = await downloadWithSignedUrl(
|
|
358
|
+
runtime,
|
|
359
|
+
fileId,
|
|
360
|
+
version,
|
|
361
|
+
flagValue(args, '--output'),
|
|
362
|
+
);
|
|
363
|
+
// 输出键名按 file 域的契约:file_id,不是 attachment_id(同一个对象,两种域叫法)。
|
|
364
|
+
const renamed = { ...data, file_id: data.attachment_id };
|
|
365
|
+
delete renamed.attachment_id;
|
|
366
|
+
runtime.finishFrom(response, renamed);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* `--version` 的取值兜底。
|
|
371
|
+
*
|
|
372
|
+
* ⚠️ `--version` 同时是**全局旗标**(`xinhe-oa-cli --version`):core 的
|
|
373
|
+
* `extractGlobals()` 在挑命令之前就把它摘进 `globals.version`,它后面的取值
|
|
374
|
+
* (`--version 4` 里的 `4`)于是掉进 `positional`。Rust 侧没有这个问题
|
|
375
|
+
* (clap 的子命令参数优先于全局 `--version`),实测 `file --download att_1
|
|
376
|
+
* --version 4` 在 Rust 版发出的是 `?version=4`。
|
|
377
|
+
*
|
|
378
|
+
* 所以这里把"唯一的、纯数字的位置参数"认回版本号:不认的话会**静默下载最新版**
|
|
379
|
+
* (用户以为下的是 v4)—— 那比"多认一种写法"危险得多。core 修好后这条自然失效
|
|
380
|
+
* (`args['--version']` 有了值,走不到这里)。
|
|
381
|
+
*/
|
|
382
|
+
function versionFromPositional(positional, command) {
|
|
383
|
+
if (!Array.isArray(positional) || positional.length === 0) return null;
|
|
384
|
+
const nameWords = String(command?.name ?? '')
|
|
385
|
+
.split(' ')
|
|
386
|
+
.filter((word) => word.length > 0 && !word.startsWith('-'));
|
|
387
|
+
// 命令名自身有时也会出现在 `positional` 里(core 的实现细节),只容忍这一种前缀;
|
|
388
|
+
// 剩下的恰好一个纯数字 token 才认作被摘走的 `--version` 取值。
|
|
389
|
+
const head = positional.slice(0, -1);
|
|
390
|
+
const headIsCommandName =
|
|
391
|
+
head.length <= nameWords.length && head.every((word, index) => word === nameWords[index]);
|
|
392
|
+
if (!headIsCommandName) return null;
|
|
393
|
+
const text = String(positional[positional.length - 1]).trim();
|
|
394
|
+
if (!/^\d+$/.test(text) || !Number.isSafeInteger(Number.parseInt(text, 10))) return null;
|
|
395
|
+
const value = Number.parseInt(text, 10);
|
|
396
|
+
if (value === 0) {
|
|
397
|
+
throw CliError.usage('`file --download` 的 --version 必须是正整数').withHint(
|
|
398
|
+
'版本号从 1 开始;不带 --version 就是最新版本',
|
|
399
|
+
);
|
|
400
|
+
}
|
|
401
|
+
return value;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/** `file --versions <FILE_ID>`:版本链(版本号倒序;同一端点的 `data.versions`)。 */
|
|
405
|
+
async function versions(runtime, args) {
|
|
406
|
+
const fileId = String(args['--versions'] ?? '');
|
|
407
|
+
const spec = requestSpec();
|
|
408
|
+
const requestPath = `/attachments/${fileId}`;
|
|
409
|
+
if (runtime.dryRun) {
|
|
410
|
+
runtime.dryRunReport('GET', requestPath, spec);
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
413
|
+
const response = await runtime.sendAuthed('GET', requestPath, spec);
|
|
414
|
+
const detail = dataOf(response);
|
|
415
|
+
runtime.finishFrom(response, {
|
|
416
|
+
file_id: fileId,
|
|
417
|
+
versions: detail?.versions ?? null,
|
|
418
|
+
});
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/** `file --delete <FILE_ID> [--reason <TEXT>]`(高风险:必须 `--yes`)。 */
|
|
422
|
+
async function deleteFile(runtime, args) {
|
|
423
|
+
const fileId = String(args['--delete'] ?? '');
|
|
424
|
+
// ⚠️ 这里的 example **带** `--yes`:Rust 传的就是 `… --delete {id} --yes`,
|
|
425
|
+
// 而 `gate_high_risk` 还会再拼一个 ` --yes`,于是 Rust 实际的 hint 是
|
|
426
|
+
// `确认后重跑:xinhe-oa-cli file --delete att_1 --yes --yes`。
|
|
427
|
+
// 看着别扭,但"行为等价"优先:hint 在 `--json` 的 error 段里,判卷会比。
|
|
428
|
+
runtime.requireYes('file --delete', `xinhe-oa-cli file --delete ${fileId} --yes`);
|
|
429
|
+
const spec = requestSpec({ query: queryPairs([['reason', flagValue(args, '--reason')]]) });
|
|
430
|
+
const requestPath = `/attachments/${fileId}`;
|
|
431
|
+
if (runtime.dryRun) {
|
|
432
|
+
runtime.dryRunReport('DELETE', requestPath, spec);
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
435
|
+
const response = await runtime.sendAuthed('DELETE', requestPath, spec);
|
|
436
|
+
const body = dataOf(response);
|
|
437
|
+
// 两个分支(本次删除 / already_deleted)对外都是同一形状:软删完成。
|
|
438
|
+
runtime.finishFrom(response, {
|
|
439
|
+
file_id: typeof body?.attachment_id === 'string' ? body.attachment_id : fileId,
|
|
440
|
+
status: 'deleted',
|
|
441
|
+
deleted_at: body?.deleted_at ?? null,
|
|
442
|
+
});
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
// ---------------------------------------------------------------------------
|
|
446
|
+
// FILE-005:可见性 / 移动 / 显式 ACL(docs/31 §2.1.2)
|
|
447
|
+
// ---------------------------------------------------------------------------
|
|
448
|
+
// 三个写动作(`--set` / `--move` / `--acl-revoke`)由 CLI **自动生成幂等键**:
|
|
449
|
+
// 网络抖动后重跑同一条命令不会重复作废 ACL、不会重复写审计。
|
|
450
|
+
// 文件引用一律走 `<file>` 段:`att_...` id 或业务 `key`(`<type>:<business-id>`)
|
|
451
|
+
// 都能收,CLI 原样拼进路径(服务端对两种形状做同一件事)。
|
|
452
|
+
|
|
453
|
+
/** `file --visibility <file> --show|--set <档位>` 的子动作分发。 */
|
|
454
|
+
async function visibility(runtime, args) {
|
|
455
|
+
const reference = fileReference(
|
|
456
|
+
flagValue(args, '--visibility'),
|
|
457
|
+
'file --visibility',
|
|
458
|
+
'文件 <FILE_ID|KEY>',
|
|
459
|
+
'例:xinhe-oa-cli file --visibility contract:HT-2026-001 --show --json',
|
|
460
|
+
);
|
|
461
|
+
// 到这里 `handleFile` 已经保证"14 个动作旗标里恰好一个",而 `--visibility` 在场时
|
|
462
|
+
// 那个动作只可能是 `--show`(裸旗标)或 `--set <档位>`;`--set` 优先认。
|
|
463
|
+
if (args['--set'] !== undefined) {
|
|
464
|
+
return visibilitySet(runtime, args, reference, String(args['--set']));
|
|
465
|
+
}
|
|
466
|
+
if (args['--show'] !== true) {
|
|
467
|
+
// `--visibility <文件> --show <值>`:把两种用法混在了一起(Rust 由 clap 在语法层拒)。
|
|
468
|
+
throw CliError.usage(
|
|
469
|
+
'`file --visibility <文件> --show` 的 --show 不接受值(文件写在 --visibility 后面)',
|
|
470
|
+
).withHint('例:xinhe-oa-cli file --visibility contract:HT-2026-001 --show --json');
|
|
471
|
+
}
|
|
472
|
+
return visibilityShow(runtime, args, reference);
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/** `GET /files/{id_or_key}/visibility`(read):声明档位 + 夹紧后的有效档位 + ACL。 */
|
|
476
|
+
async function visibilityShow(runtime, args, reference) {
|
|
477
|
+
const tenant = await resolveTenant(runtime, flagValue(args, '--tenant'), '查看文件可见性');
|
|
478
|
+
const requestPath = `/files/${reference}/visibility`;
|
|
479
|
+
const spec = requestSpec({ query: [['tenant', tenant]] });
|
|
480
|
+
if (runtime.dryRun) {
|
|
481
|
+
runtime.dryRunReport('GET', requestPath, spec);
|
|
482
|
+
return;
|
|
483
|
+
}
|
|
484
|
+
const response = await runtime.sendAuthed('GET', requestPath, spec);
|
|
485
|
+
runtime.finishFrom(response, dataOf(response));
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/**
|
|
489
|
+
* `POST /files/{id_or_key}/visibility`:**高风险写**(一次可能放开或收掉一批人)。
|
|
490
|
+
*
|
|
491
|
+
* 顺序照抄 Rust:先本地校验档位(`public` 本地就拒)、再 `--yes` 闸门、
|
|
492
|
+
* 最后才解析企业 —— 没确认过的命令一个请求都不许发。
|
|
493
|
+
*/
|
|
494
|
+
async function visibilitySet(runtime, args, reference, rawTier) {
|
|
495
|
+
const tier = validatedTier(rawTier);
|
|
496
|
+
runtime.requireYes(
|
|
497
|
+
'file --visibility --set(一次可能放开一批人或收掉一批人)',
|
|
498
|
+
`xinhe-oa-cli file --visibility ${reference} --set ${tier}`,
|
|
499
|
+
);
|
|
500
|
+
const tenant = await resolveTenant(runtime, flagValue(args, '--tenant'), '改文件可见性');
|
|
501
|
+
const body = { visibility: tier };
|
|
502
|
+
// `--dept` / `--expected-epoch` 只在给的时候进 body:不传 = 不校验(服务端向后兼容)。
|
|
503
|
+
const dept = trimmedOrNull(flagValue(args, '--dept'));
|
|
504
|
+
if (dept !== null) body.dept_id = dept;
|
|
505
|
+
const epoch = expectedEpochValue(args);
|
|
506
|
+
if (epoch !== null) body.expected_epoch = epoch;
|
|
507
|
+
const requestPath = `/files/${reference}/visibility`;
|
|
508
|
+
const spec = requestSpec({
|
|
509
|
+
body,
|
|
510
|
+
query: [['tenant', tenant]],
|
|
511
|
+
idempotencyKey: idempotencyKey('file-visibility'),
|
|
512
|
+
});
|
|
513
|
+
if (runtime.dryRun) {
|
|
514
|
+
runtime.dryRunReport('POST', requestPath, spec);
|
|
515
|
+
return;
|
|
516
|
+
}
|
|
517
|
+
const response = await runtime.sendAuthed('POST', requestPath, spec);
|
|
518
|
+
runtime.finishFrom(response, dataOf(response));
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
/**
|
|
522
|
+
* `file --move <file> --to-dept <ou_id> --yes`:**高风险写**(换归属部门会夹紧档位
|
|
523
|
+
* 并作废越出容器的 ACL)。成功响应(`visibility_after` / `revoked_acl` /
|
|
524
|
+
* `affected_users` / `inherit_epoch`)原样透传,CLI 不做二次加工 ——
|
|
525
|
+
* 操作者要当场看见代价。
|
|
526
|
+
*/
|
|
527
|
+
async function move(runtime, args) {
|
|
528
|
+
const reference = fileReference(
|
|
529
|
+
flagValue(args, '--move'),
|
|
530
|
+
'file --move',
|
|
531
|
+
'--move <FILE_ID|KEY>',
|
|
532
|
+
'例:xinhe-oa-cli file --move contract:HT-2026-001 --to-dept ou_legal --yes --json',
|
|
533
|
+
);
|
|
534
|
+
const toDept = fileReference(
|
|
535
|
+
flagValue(args, '--to-dept'),
|
|
536
|
+
'file --move',
|
|
537
|
+
'--to-dept <OU_ID>',
|
|
538
|
+
'例:xinhe-oa-cli file --move contract:HT-2026-001 --to-dept ou_legal --yes --json',
|
|
539
|
+
);
|
|
540
|
+
const epoch = expectedEpochValue(args);
|
|
541
|
+
runtime.requireYes(
|
|
542
|
+
'file --move(换归属部门会夹紧档位并作废越出容器的 ACL)',
|
|
543
|
+
`xinhe-oa-cli file --move ${reference} --to-dept ${toDept}`,
|
|
544
|
+
);
|
|
545
|
+
const tenant = await resolveTenant(runtime, flagValue(args, '--tenant'), '移动文件');
|
|
546
|
+
const body = { to_dept_id: toDept };
|
|
547
|
+
if (epoch !== null) body.expected_epoch = epoch;
|
|
548
|
+
const requestPath = `/files/${reference}/move`;
|
|
549
|
+
const spec = requestSpec({
|
|
550
|
+
body,
|
|
551
|
+
query: [['tenant', tenant]],
|
|
552
|
+
idempotencyKey: idempotencyKey('file-move'),
|
|
553
|
+
});
|
|
554
|
+
if (runtime.dryRun) {
|
|
555
|
+
runtime.dryRunReport('POST', requestPath, spec);
|
|
556
|
+
return;
|
|
557
|
+
}
|
|
558
|
+
const response = await runtime.sendAuthed('POST', requestPath, spec);
|
|
559
|
+
runtime.finishFrom(response, dataOf(response));
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
/** `file --acl-list <file>`(read):显式 ACL(点名到人 / 点名到部门)。 */
|
|
563
|
+
async function aclList(runtime, args) {
|
|
564
|
+
const reference = fileReference(
|
|
565
|
+
flagValue(args, '--acl-list'),
|
|
566
|
+
'file --acl-list',
|
|
567
|
+
'--acl-list <FILE_ID|KEY>',
|
|
568
|
+
'例:xinhe-oa-cli file --acl-list contract:HT-2026-001 --json',
|
|
569
|
+
);
|
|
570
|
+
const tenant = await resolveTenant(runtime, flagValue(args, '--tenant'), '列出文件 ACL');
|
|
571
|
+
const requestPath = `/files/${reference}/acl`;
|
|
572
|
+
const spec = requestSpec({ query: [['tenant', tenant]] });
|
|
573
|
+
if (runtime.dryRun) {
|
|
574
|
+
runtime.dryRunReport('GET', requestPath, spec);
|
|
575
|
+
return;
|
|
576
|
+
}
|
|
577
|
+
const response = await runtime.sendAuthed('GET', requestPath, spec);
|
|
578
|
+
runtime.finishFrom(response, dataOf(response));
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
/**
|
|
582
|
+
* `file --acl-revoke <file> --from-user <user_id>|--from-dept <ou_id> --action read|write`:
|
|
583
|
+
* **write**(降险动作,故意不卡 `--yes` —— "发现给错了、赶紧收回"不该变慢)。
|
|
584
|
+
*/
|
|
585
|
+
async function aclRevoke(runtime, args) {
|
|
586
|
+
const reference = fileReference(
|
|
587
|
+
flagValue(args, '--acl-revoke'),
|
|
588
|
+
'file --acl-revoke',
|
|
589
|
+
'--acl-revoke <FILE_ID|KEY>',
|
|
590
|
+
'例:xinhe-oa-cli file --acl-revoke contract:HT-2026-001 --from-user usr_wang --action read --json',
|
|
591
|
+
);
|
|
592
|
+
const subject = aclSubject(
|
|
593
|
+
args,
|
|
594
|
+
'file --acl-revoke',
|
|
595
|
+
'--from-user',
|
|
596
|
+
'--from-dept',
|
|
597
|
+
'点名到人:--from-user usr_xxx;点名到部门:--from-dept ou_xxx',
|
|
598
|
+
'例:xinhe-oa-cli file --acl-revoke contract:HT-2026-001 --from-user usr_wang --action read --json',
|
|
599
|
+
);
|
|
600
|
+
const action = aclAction(flagValue(args, '--action'), 'file --acl-revoke');
|
|
601
|
+
const tenant = await resolveTenant(runtime, flagValue(args, '--tenant'), '收回文件 ACL');
|
|
602
|
+
// 服务端的模型是 `{from_user | from_dept, action}`(多态主体在**线上**用两个可选键表达)。
|
|
603
|
+
const body = subject.dept
|
|
604
|
+
? { from_dept: subject.id, action }
|
|
605
|
+
: { from_user: subject.id, action };
|
|
606
|
+
const requestPath = `/files/${reference}/acl/revoke`;
|
|
607
|
+
const spec = requestSpec({
|
|
608
|
+
body,
|
|
609
|
+
query: [['tenant', tenant]],
|
|
610
|
+
idempotencyKey: idempotencyKey('file-acl-revoke'),
|
|
611
|
+
});
|
|
612
|
+
if (runtime.dryRun) {
|
|
613
|
+
runtime.dryRunReport('POST', requestPath, spec);
|
|
614
|
+
return;
|
|
615
|
+
}
|
|
616
|
+
const response = await runtime.sendAuthed('POST', requestPath, spec);
|
|
617
|
+
runtime.finishFrom(response, dataOf(response));
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
// ---------------------------------------------------------------------------
|
|
621
|
+
// FILE-006:`--permission-request` / `--permission-assign`(docs/31 §2.1.3)
|
|
622
|
+
// ---------------------------------------------------------------------------
|
|
623
|
+
// 申请走 `base.approval` 的 `file_permission`:只创建审批、`acl_written: false`,
|
|
624
|
+
// **批准之前什么权限都不会发生**;分配是所有者/管理员的**直接写**(不经审批)。
|
|
625
|
+
// 两条都是 `write`(不是 high-risk),都由 CLI 自动生成幂等键。
|
|
626
|
+
|
|
627
|
+
/** `file --permission-request <file> --action read|write [--reason] [--expires-at]`。 */
|
|
628
|
+
async function permissionRequest(runtime, args) {
|
|
629
|
+
const reference = fileReference(
|
|
630
|
+
flagValue(args, '--permission-request'),
|
|
631
|
+
'file --permission-request',
|
|
632
|
+
'--permission-request <FILE_ID|KEY>',
|
|
633
|
+
'例:xinhe-oa-cli file --permission-request contract:HT-2026-001 --action read --reason "对账需要" --json',
|
|
634
|
+
);
|
|
635
|
+
if (hasAssignSubject(args)) {
|
|
636
|
+
throw CliError.usage(
|
|
637
|
+
'`file --permission-request` 不接受 --to-user / --to-dept:申请交给谁由审批人定',
|
|
638
|
+
).withHint(
|
|
639
|
+
'直接分配(不经审批)用:xinhe-oa-cli file --permission-assign <FILE_ID|KEY> --to-user usr_... | --to-dept ou_... --action read --json',
|
|
640
|
+
);
|
|
641
|
+
}
|
|
642
|
+
const action = aclAction(flagValue(args, '--action'), 'file --permission-request');
|
|
643
|
+
const body = { action };
|
|
644
|
+
const reason = trimmedOrNull(flagValue(args, '--reason'));
|
|
645
|
+
if (reason !== null) body.reason = reason;
|
|
646
|
+
const expiresAt = expiresAtValue(args);
|
|
647
|
+
if (expiresAt !== null) body.expires_at = expiresAt;
|
|
648
|
+
const tenant = await resolveTenant(runtime, flagValue(args, '--tenant'), '申请文件权限');
|
|
649
|
+
const requestPath = `/files/${reference}/permission-request`;
|
|
650
|
+
const spec = requestSpec({
|
|
651
|
+
body,
|
|
652
|
+
query: [['tenant', tenant]],
|
|
653
|
+
idempotencyKey: idempotencyKey('file-permission-request'),
|
|
654
|
+
});
|
|
655
|
+
if (runtime.dryRun) {
|
|
656
|
+
runtime.dryRunReport('POST', requestPath, spec);
|
|
657
|
+
return;
|
|
658
|
+
}
|
|
659
|
+
const response = await runtime.sendAuthed('POST', requestPath, spec);
|
|
660
|
+
runtime.finishFrom(response, dataOf(response));
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
/** `file --permission-assign <file> --to-user|--to-dept --action read|write [...]`。 */
|
|
664
|
+
async function permissionAssign(runtime, args) {
|
|
665
|
+
const reference = fileReference(
|
|
666
|
+
flagValue(args, '--permission-assign'),
|
|
667
|
+
'file --permission-assign',
|
|
668
|
+
'--permission-assign <FILE_ID|KEY>',
|
|
669
|
+
'例:xinhe-oa-cli file --permission-assign contract:HT-2026-001 --to-user usr_wang --action read --json',
|
|
670
|
+
);
|
|
671
|
+
const subject = aclSubject(
|
|
672
|
+
args,
|
|
673
|
+
'file --permission-assign',
|
|
674
|
+
'--to-user',
|
|
675
|
+
'--to-dept',
|
|
676
|
+
'--to-user usr_... | --to-dept ou_...',
|
|
677
|
+
'--to-user usr_... | --to-dept ou_...',
|
|
678
|
+
);
|
|
679
|
+
const action = aclAction(flagValue(args, '--action'), 'file --permission-assign');
|
|
680
|
+
const body = subject.dept
|
|
681
|
+
? { to_dept: subject.id, action }
|
|
682
|
+
: { to_user: subject.id, action };
|
|
683
|
+
const reason = trimmedOrNull(flagValue(args, '--reason'));
|
|
684
|
+
if (reason !== null) body.reason = reason;
|
|
685
|
+
const expiresAt = expiresAtValue(args);
|
|
686
|
+
if (expiresAt !== null) body.expires_at = expiresAt;
|
|
687
|
+
// 两道墙由服务端判:授出 ⊆ 持有、不得突破文件有效可见性上限;CLI 只保证参数本地合法。
|
|
688
|
+
const tenant = await resolveTenant(runtime, flagValue(args, '--tenant'), '分配文件权限');
|
|
689
|
+
const requestPath = `/files/${reference}/permission-assign`;
|
|
690
|
+
const spec = requestSpec({
|
|
691
|
+
body,
|
|
692
|
+
query: [['tenant', tenant]],
|
|
693
|
+
idempotencyKey: idempotencyKey('file-permission-assign'),
|
|
694
|
+
});
|
|
695
|
+
if (runtime.dryRun) {
|
|
696
|
+
runtime.dryRunReport('POST', requestPath, spec);
|
|
697
|
+
return;
|
|
698
|
+
}
|
|
699
|
+
const response = await runtime.sendAuthed('POST', requestPath, spec);
|
|
700
|
+
runtime.finishFrom(response, dataOf(response));
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
// ---------------------------------------------------------------------------
|
|
704
|
+
// FILE-003:`file --compare` / `file --lookup`
|
|
705
|
+
// ---------------------------------------------------------------------------
|
|
706
|
+
|
|
707
|
+
/**
|
|
708
|
+
* `file --compare --tenant <T> --key <K> --file <PATH> [--base-version N]`:
|
|
709
|
+
* **只读预检**,不发任何写请求。
|
|
710
|
+
*
|
|
711
|
+
* 为什么 `behind` 归退出码 **5**(而不是 2 或 6):它**不是参数错** —— 退出码 2 的
|
|
712
|
+
* 判据写死了"CLI 本地判定、**不发请求**",而这个判决只可能从一次**已经发生的**读
|
|
713
|
+
* (`GET /files/by-key`)里长出来;它也**不是服务端 5xx** —— 服务端一切正常。
|
|
714
|
+
* 这份结果与文件**当时**的状态冲突,重跑一次/解冲突后重提就能拿到一致的结果,
|
|
715
|
+
* 与 `cli.paging_unstable`、`cli.skill_conflict` 同一类(契约 §3.2 的两条补充)。
|
|
716
|
+
*/
|
|
717
|
+
async function compare(runtime, args) {
|
|
718
|
+
const file = requiredText(
|
|
719
|
+
flagValue(args, '--file'),
|
|
720
|
+
'`file --compare` 需要 --file <本地文件>',
|
|
721
|
+
'例:xinhe-oa-cli file --compare --file ./合同修订版.pdf --key contract:HT-2026-001 --base-version 3',
|
|
722
|
+
);
|
|
723
|
+
const key = requiredText(
|
|
724
|
+
flagValue(args, '--key'),
|
|
725
|
+
'`file --compare` 需要 --key <业务主键>',
|
|
726
|
+
'例:--key contract:HT-2026-001(与要上传的 --key 一致)',
|
|
727
|
+
);
|
|
728
|
+
const baseVersion = baseVersionValue(args);
|
|
729
|
+
const bytes = readFileBytes(file);
|
|
730
|
+
const localSha256 = sha256Hex(bytes);
|
|
731
|
+
const tenant = await resolveTenant(runtime, flagValue(args, '--tenant'), '文件比对');
|
|
732
|
+
const spec = requestSpec({
|
|
733
|
+
query: [
|
|
734
|
+
['tenant', tenant],
|
|
735
|
+
['key', key],
|
|
736
|
+
],
|
|
737
|
+
});
|
|
738
|
+
if (runtime.dryRun) {
|
|
739
|
+
runtime.dryRunReport('GET', '/files/by-key', spec);
|
|
740
|
+
return;
|
|
741
|
+
}
|
|
742
|
+
// 404(`attachment.not_found`)= 服务端没有这个 key(absent);其余错误原样抛出,绝不猜。
|
|
743
|
+
let response = null;
|
|
744
|
+
try {
|
|
745
|
+
response = await runtime.sendAuthed('GET', '/files/by-key', spec);
|
|
746
|
+
} catch (err) {
|
|
747
|
+
if (err instanceof CliError && err.code === 'attachment.not_found') response = null;
|
|
748
|
+
else throw err;
|
|
749
|
+
}
|
|
750
|
+
const remote = response === null ? null : dataOf(response);
|
|
751
|
+
const verdict = compareVerdict(localSha256, remote, baseVersion);
|
|
752
|
+
if (verdict === 'behind') {
|
|
753
|
+
// 只有一个请求(GET /files/by-key),且**没有任何 POST**。
|
|
754
|
+
throw behindError(key, localSha256, file, baseVersion, remote);
|
|
755
|
+
}
|
|
756
|
+
runtime.finishFrom(response, {
|
|
757
|
+
verdict,
|
|
758
|
+
key,
|
|
759
|
+
local: { base_version: baseVersion, sha256: localSha256, path: file },
|
|
760
|
+
remote: remote === null ? null : remoteSummary(remote),
|
|
761
|
+
how_to_resolve: howToResolve(verdict),
|
|
762
|
+
});
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
/** `file --lookup --tenant <T> --key <K>`:按业务主键取文件(与 `--show` 同形)。 */
|
|
766
|
+
async function lookup(runtime, args) {
|
|
767
|
+
const key = requiredText(
|
|
768
|
+
flagValue(args, '--key'),
|
|
769
|
+
'`file --lookup` 需要 --key <业务主键>',
|
|
770
|
+
'例:xinhe-oa-cli file --lookup --key contract:HT-2026-001 --tenant kashu --json',
|
|
771
|
+
);
|
|
772
|
+
const tenant = await resolveTenant(runtime, flagValue(args, '--tenant'), '按主键取文件');
|
|
773
|
+
const spec = requestSpec({
|
|
774
|
+
query: [
|
|
775
|
+
['tenant', tenant],
|
|
776
|
+
['key', key],
|
|
777
|
+
],
|
|
778
|
+
});
|
|
779
|
+
if (runtime.dryRun) {
|
|
780
|
+
runtime.dryRunReport('GET', '/files/by-key', spec);
|
|
781
|
+
return;
|
|
782
|
+
}
|
|
783
|
+
const response = await runtime.sendAuthed('GET', '/files/by-key', spec);
|
|
784
|
+
runtime.finishFrom(response, { file: dataOf(response) });
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
/**
|
|
788
|
+
* 本地文件哈希 vs 服务端当前状态(纯函数)。
|
|
789
|
+
*
|
|
790
|
+
* **哈希相同优先报 `same`** —— 即使 `--base-version` 落后,内容一样就没有冲突可解。
|
|
791
|
+
* 判据只认版本号,不认时间戳。
|
|
792
|
+
*/
|
|
793
|
+
export function compareVerdict(localSha256, remote, baseVersion) {
|
|
794
|
+
if (remote === null || remote === undefined) return 'absent';
|
|
795
|
+
const remoteHash = typeof remote.content_hash === 'string' ? remote.content_hash : '';
|
|
796
|
+
if (remoteHash === localSha256) return 'same';
|
|
797
|
+
if (baseVersion === null) return 'unknown';
|
|
798
|
+
const remoteVersion = Number.isInteger(remote.version) && remote.version >= 0 ? remote.version : 0;
|
|
799
|
+
// `N == remote.version` → ahead;`N > remote.version`(本地自认更新,服务端不这么认为)
|
|
800
|
+
// 同样算 ahead;只有 `N < remote.version` 才是 behind。
|
|
801
|
+
return baseVersion >= remoteVersion ? 'ahead' : 'behind';
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
/** `--compare` 的下一步提示(`how_to_resolve`;第一条永远是"别自动覆盖")。 */
|
|
805
|
+
function howToResolve(verdict) {
|
|
806
|
+
if (verdict === 'behind') {
|
|
807
|
+
return [
|
|
808
|
+
'先 `file --download <remote.id> --version <remote.version>` 拿服务端最新版',
|
|
809
|
+
'在本地合并后,用刚拿到的版本号重新提交:--expected-version <remote.version>',
|
|
810
|
+
'合并不了就交给人工决定,**不要**用 --upload 覆盖别人的版本',
|
|
811
|
+
];
|
|
812
|
+
}
|
|
813
|
+
if (verdict === 'absent') return ['直接 --upload(会创建 v1)'];
|
|
814
|
+
if (verdict === 'same') return ['不要传:内容与服务端一致,上传也只会得到 no_change'];
|
|
815
|
+
return ['直接提交并接受可能的 409(--expected-version 会再判一次)'];
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
/** `remote` 的展示形状(与 409 `details.current` 同源:版本 / 哈希 / 上传人 / 时间)。 */
|
|
819
|
+
function remoteSummary(remote) {
|
|
820
|
+
return {
|
|
821
|
+
id: remote?.id ?? null,
|
|
822
|
+
version: remote?.version ?? null,
|
|
823
|
+
content_hash: remote?.content_hash ?? null,
|
|
824
|
+
size_bytes: remote?.size_bytes ?? null,
|
|
825
|
+
created_by: remote?.created_by ?? null,
|
|
826
|
+
created_at: remote?.created_at ?? null,
|
|
827
|
+
name: remote?.name ?? null,
|
|
828
|
+
};
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
/**
|
|
832
|
+
* Behind(冲突)的失败形状:`cli.file_conflict` + 冻结的 `details`。
|
|
833
|
+
*
|
|
834
|
+
* 必须**显式构造 `Kind.Conflict`**:`error.js` 的 `classify()` 不认 `cli.*` 码,
|
|
835
|
+
* 归 5 的判据与 `cli.paging_unstable` 同一类。`details` 是机器面(解冲突的输入),
|
|
836
|
+
* `human` 是给人看的同一件事(两份各自稳定)。
|
|
837
|
+
*/
|
|
838
|
+
function behindError(key, localSha256, filePath, baseVersion, remote) {
|
|
839
|
+
const summary = remoteSummary(remote);
|
|
840
|
+
const details = {
|
|
841
|
+
key,
|
|
842
|
+
local: { base_version: baseVersion, sha256: localSha256, path: filePath },
|
|
843
|
+
remote: summary,
|
|
844
|
+
how_to_resolve: howToResolve('behind'),
|
|
845
|
+
};
|
|
846
|
+
const human =
|
|
847
|
+
`本地文件落后于服务端:key=${key} base_version=${optionDebug(baseVersion)} ` +
|
|
848
|
+
`remote_version=${jsonDisplay(summary.version)}\n` +
|
|
849
|
+
`解冲突:先 \`file --download ${typeof summary.id === 'string' ? summary.id : '<id>'} ` +
|
|
850
|
+
`--version ${jsonDisplay(summary.version)}\`,合并后用 ` +
|
|
851
|
+
`--expected-version ${jsonDisplay(summary.version)} 重新提交`;
|
|
852
|
+
return new CliError(
|
|
853
|
+
Kind.Conflict,
|
|
854
|
+
'cli.file_conflict',
|
|
855
|
+
`文件 ${key} 已被别人更新,本地文件基于的版本已落后`,
|
|
856
|
+
)
|
|
857
|
+
.withDetails(details)
|
|
858
|
+
.withHumanDetails(human);
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
// ---------------------------------------------------------------------------
|
|
862
|
+
// FILE-002:`file --search`(三个维度 + 服务端匹配 + `--all` 翻页自检)
|
|
863
|
+
// ---------------------------------------------------------------------------
|
|
864
|
+
|
|
865
|
+
/**
|
|
866
|
+
* `file --search --tenant <T> [--name] [--summary] [--match] [--since/--until]
|
|
867
|
+
* [--include-deleted] [--limit] [--offset] [--all] [--workers]`。
|
|
868
|
+
*
|
|
869
|
+
* **只定位、不取链接**:输出永不含 `storage_ref`,也不发签名 URL ——
|
|
870
|
+
* 取链接走 `file --download`(那里才是"谁拿了下载链接"的独立审计点)。
|
|
871
|
+
*/
|
|
872
|
+
async function search(runtime, args) {
|
|
873
|
+
const plan = validateSearch(args);
|
|
874
|
+
// fuzzy 对短中文串的告警在**企业检查之前**:告警讲的是"这个检索大概率搜不到",
|
|
875
|
+
// 与后面成不成功无关(Rust 同序)。`--quiet` 静音,但**不拒绝**。
|
|
876
|
+
const warning = fuzzyWarningMessage(plan, runtime.quiet);
|
|
877
|
+
if (warning !== null) warn(warning);
|
|
878
|
+
|
|
879
|
+
// `--tenant` 必填:没有"搜我所有企业"的形态(本地判、不发请求)。
|
|
880
|
+
const tenantRaw = flagValue(args, '--tenant');
|
|
881
|
+
const tenant = tenantRaw === null ? '' : String(tenantRaw).trim();
|
|
882
|
+
if (tenant.length === 0) {
|
|
883
|
+
throw CliError.usage('`file --search` 必须指明 --tenant <TENANT_ID>').withHint(
|
|
884
|
+
'检索只在自己可见的企业内做,没有跨企业全局检索',
|
|
885
|
+
);
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
if (runtime.dryRun) {
|
|
889
|
+
runtime.dryRunReport(
|
|
890
|
+
'GET',
|
|
891
|
+
'/files',
|
|
892
|
+
requestSpec({ query: searchQuery(plan, tenant, plan.offset) }),
|
|
893
|
+
);
|
|
894
|
+
return;
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
if (!plan.all) {
|
|
898
|
+
const response = await fetchSearchPage(runtime, plan, tenant, plan.offset);
|
|
899
|
+
// 服务端的 `data` 就是契约形状(files/total/count/limit/offset/has_more/
|
|
900
|
+
// next_offset/order/match),原样透传。
|
|
901
|
+
runtime.finishFrom(response, dataOf(response));
|
|
902
|
+
return;
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
const start = plan.offset;
|
|
906
|
+
const collected = [];
|
|
907
|
+
let lastResponse = null;
|
|
908
|
+
let reachedEnd = false;
|
|
909
|
+
let total = null;
|
|
910
|
+
|
|
911
|
+
// 第一页永远串行:既拿 `total`(并发翻页要用它算页数),也顺带确认这条路走得通。
|
|
912
|
+
{
|
|
913
|
+
const response = await fetchSearchPage(runtime, plan, tenant, start);
|
|
914
|
+
const data = dataOf(response);
|
|
915
|
+
const files = searchPageFiles(data);
|
|
916
|
+
total = unsignedOrNull(data?.total);
|
|
917
|
+
collected.push(...files);
|
|
918
|
+
lastResponse = response;
|
|
919
|
+
reachedEnd = files.length < plan.limit;
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
if (!reachedEnd) {
|
|
923
|
+
const coveredToEnd = total !== null && start + collected.length >= total;
|
|
924
|
+
if (coveredToEnd) {
|
|
925
|
+
reachedEnd = true;
|
|
926
|
+
} else if (plan.workers > 1 && total !== null) {
|
|
927
|
+
// 并发取页的前提是知道总行数;页与页彼此独立,结果按 offset 排序后拼接。
|
|
928
|
+
const count = total;
|
|
929
|
+
const offsets = [];
|
|
930
|
+
let next = start + plan.limit;
|
|
931
|
+
while (next < count) {
|
|
932
|
+
if (next > MAX_SEARCH_OFFSET) break;
|
|
933
|
+
offsets.push(next);
|
|
934
|
+
next += plan.limit;
|
|
935
|
+
}
|
|
936
|
+
if (offsets.length === 0) {
|
|
937
|
+
reachedEnd = true;
|
|
938
|
+
} else {
|
|
939
|
+
const covered = offsets[offsets.length - 1] + plan.limit >= count;
|
|
940
|
+
const pages = await fetchSearchPages(runtime, plan, tenant, offsets, plan.workers);
|
|
941
|
+
for (const response of pages) {
|
|
942
|
+
const data = dataOf(response);
|
|
943
|
+
collected.push(...searchPageFiles(data));
|
|
944
|
+
lastResponse = response;
|
|
945
|
+
}
|
|
946
|
+
reachedEnd = covered;
|
|
947
|
+
}
|
|
948
|
+
} else {
|
|
949
|
+
let offset = start + plan.limit;
|
|
950
|
+
for (;;) {
|
|
951
|
+
if (offset > MAX_SEARCH_OFFSET) {
|
|
952
|
+
warn(`翻页超过 10 万条,停下(避免把服务端拖住);要接着搜:--offset ${offset}`);
|
|
953
|
+
break;
|
|
954
|
+
}
|
|
955
|
+
const response = await fetchSearchPage(runtime, plan, tenant, offset);
|
|
956
|
+
const data = dataOf(response);
|
|
957
|
+
const files = searchPageFiles(data);
|
|
958
|
+
const hasMore =
|
|
959
|
+
typeof data?.has_more === 'boolean' ? data.has_more : files.length >= plan.limit;
|
|
960
|
+
collected.push(...files);
|
|
961
|
+
lastResponse = response;
|
|
962
|
+
if (!hasMore || files.length < plan.limit) {
|
|
963
|
+
reachedEnd = true;
|
|
964
|
+
break;
|
|
965
|
+
}
|
|
966
|
+
offset += plan.limit;
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
// 完整性自检:`--all` 号称"搜全了",那就得真的是全的 —— 宁可让调用方重跑一次,
|
|
972
|
+
// 也不要把"看着像全量"的半份数据交出去。
|
|
973
|
+
const integrity = searchPagingIntegrity(collected, total, start, reachedEnd);
|
|
974
|
+
const integrityError = searchIntegrityError(integrity, plan, tenant);
|
|
975
|
+
if (integrityError !== null) throw integrityError;
|
|
976
|
+
|
|
977
|
+
const count = collected.length;
|
|
978
|
+
const out = {
|
|
979
|
+
files: collected,
|
|
980
|
+
total,
|
|
981
|
+
count,
|
|
982
|
+
limit: plan.limit,
|
|
983
|
+
offset: start,
|
|
984
|
+
has_more: !reachedEnd,
|
|
985
|
+
next_offset: reachedEnd ? null : start + count,
|
|
986
|
+
order: 'created_at:desc,id:desc',
|
|
987
|
+
match: plan.matchMode,
|
|
988
|
+
};
|
|
989
|
+
if (lastResponse === null) runtime.finish(out);
|
|
990
|
+
else runtime.finishFrom(lastResponse, out);
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
/**
|
|
994
|
+
* `--search` 的本地校验与解析:参数越界一律退出码 2、**不发请求**。
|
|
995
|
+
*
|
|
996
|
+
* 拦四类本地可判的错:`--limit` 越界、`--name`/`--summary` 空白或单字符、
|
|
997
|
+
* `--since` 晚于 `--until`、`--match` 不在枚举内。服务端的 422 是给非 CLI
|
|
998
|
+
* 调用方的兜底 —— 直接透传会被归到退出码 6,误导成服务端故障。
|
|
999
|
+
*/
|
|
1000
|
+
export function validateSearch(args) {
|
|
1001
|
+
const limit = intFlag(args, '--limit', { fallback: 20 });
|
|
1002
|
+
if (limit < 1 || limit > FILE_SEARCH_MAX_LIMIT) {
|
|
1003
|
+
throw CliError.usage(
|
|
1004
|
+
`\`file --search\` 的 --limit 必须在 1..=${FILE_SEARCH_MAX_LIMIT}(服务端上限 ${FILE_SEARCH_MAX_LIMIT}),收到 ${limit}`,
|
|
1005
|
+
).withHint('文件行比记录行重(summary 上限 2000 字符),所以上限比 record --list 小;翻全量用 --all');
|
|
1006
|
+
}
|
|
1007
|
+
const matchRaw = trimmedOrNull(flagValue(args, '--match'));
|
|
1008
|
+
let matchMode = 'substring';
|
|
1009
|
+
if (matchRaw !== null) {
|
|
1010
|
+
const lowered = matchRaw.toLowerCase();
|
|
1011
|
+
if (lowered !== 'substring' && lowered !== 'fuzzy') {
|
|
1012
|
+
throw CliError.usage(
|
|
1013
|
+
`\`file --search\` 的 --match 只支持 substring|fuzzy,收到 ${JSON.stringify(matchRaw)}`,
|
|
1014
|
+
).withHint('substring(默认,子串匹配)或 fuzzy(pg_trgm 相似度 ≥ 0.3)');
|
|
1015
|
+
}
|
|
1016
|
+
matchMode = lowered;
|
|
1017
|
+
}
|
|
1018
|
+
const name = searchTerm(flagValue(args, '--name'), '--name');
|
|
1019
|
+
const summary = searchTerm(flagValue(args, '--summary'), '--summary');
|
|
1020
|
+
const since = trimmedOrNull(flagValue(args, '--since'));
|
|
1021
|
+
const until = trimmedOrNull(flagValue(args, '--until'));
|
|
1022
|
+
// **只在两端都给时才解析**(与 Rust 同序):只给一端时原样发给服务端,
|
|
1023
|
+
// 由它去解释 —— 客户端不替服务端判"这个时间写法对不对"。
|
|
1024
|
+
if (since !== null && until !== null) {
|
|
1025
|
+
const startAt = parseSearchTime(since, '--since', false);
|
|
1026
|
+
const endAt = parseSearchTime(until, '--until', true);
|
|
1027
|
+
if (startAt > endAt) {
|
|
1028
|
+
throw CliError.usage('`file --search` 的 --since 晚于 --until(空区间)').withHint(
|
|
1029
|
+
'闭区间要求 since <= until;`--until YYYY-MM-DD` 含当天 23:59:59Z',
|
|
1030
|
+
);
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
1033
|
+
const offset = intFlag(args, '--offset', { fallback: 0, min: 0 });
|
|
1034
|
+
const workers = intFlag(args, '--workers', { fallback: 1 });
|
|
1035
|
+
return {
|
|
1036
|
+
name,
|
|
1037
|
+
summary,
|
|
1038
|
+
matchMode,
|
|
1039
|
+
since,
|
|
1040
|
+
until,
|
|
1041
|
+
includeDeleted: flagOn(args, '--include-deleted'),
|
|
1042
|
+
limit,
|
|
1043
|
+
offset,
|
|
1044
|
+
all: flagOn(args, '--all'),
|
|
1045
|
+
// 与 Rust 的 `.clamp(1, 16)` 同义:越界的 workers 是"夹紧"而不是"拒绝"。
|
|
1046
|
+
workers: Math.min(Math.max(workers, 1), FILE_SEARCH_MAX_WORKERS),
|
|
1047
|
+
};
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
/** 一个检索维度:空白(给了等于没给)与单字符都在本地拒(退出码 2)。 */
|
|
1051
|
+
function searchTerm(raw, flag) {
|
|
1052
|
+
if (raw === null || raw === undefined) return null;
|
|
1053
|
+
const trimmed = String(raw).trim();
|
|
1054
|
+
if (trimmed.length === 0) {
|
|
1055
|
+
throw CliError.usage(`\`file --search\` 的 ${flag} 不能为空`).withHint(
|
|
1056
|
+
'不给就是不按这个维度筛;要搜就给至少 2 个字符',
|
|
1057
|
+
);
|
|
1058
|
+
}
|
|
1059
|
+
if (Array.from(trimmed).length < FILE_SEARCH_MIN_QUERY) {
|
|
1060
|
+
throw CliError.usage(
|
|
1061
|
+
`\`file --search\` 的 ${flag} 至少需要 ${FILE_SEARCH_MIN_QUERY} 个字符:少于 3 个字符生成不出 trigram,\`ILIKE\` 会退化成全表扫描`,
|
|
1062
|
+
).withHint('中文双字词(如「合同」)就够;单字请配合别的维度缩小范围');
|
|
1063
|
+
}
|
|
1064
|
+
return trimmed;
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
const DAY_PATTERN = /^(\d{4})-(\d{2})-(\d{2})$/;
|
|
1068
|
+
/** RFC3339:**必须带时区**(本地时间本地拒 —— 服务端要的是绝对时刻)。 */
|
|
1069
|
+
const RFC3339_PATTERN = /^\d{4}-\d{2}-\d{2}[Tt ]\d{2}:\d{2}:\d{2}(\.\d+)?([Zz]|[+-]\d{2}:\d{2})$/;
|
|
1070
|
+
|
|
1071
|
+
/**
|
|
1072
|
+
* `--since` / `--until` 的时间解析(只用于本地比较先后)。
|
|
1073
|
+
*
|
|
1074
|
+
* `YYYY-MM-DD`:起点补 00:00:00Z、终点补 23:59:59Z(**闭区间**,含当天末尾)。
|
|
1075
|
+
* 其余按 RFC3339 解析(必须带时区;解析不了是本地退出码 2)。
|
|
1076
|
+
*/
|
|
1077
|
+
function parseSearchTime(raw, flag, end) {
|
|
1078
|
+
const day = DAY_PATTERN.exec(raw);
|
|
1079
|
+
if (day !== null) {
|
|
1080
|
+
const year = Number(day[1]);
|
|
1081
|
+
const month = Number(day[2]);
|
|
1082
|
+
const date = Number(day[3]);
|
|
1083
|
+
// 格式对但日期不存在(2026-02-30)也要拒:Rust 的 NaiveDate 同样拒。
|
|
1084
|
+
if (month < 1 || month > 12 || date < 1 || date > daysInMonth(year, month)) {
|
|
1085
|
+
throw searchTimeError(flag, raw);
|
|
1086
|
+
}
|
|
1087
|
+
const millis = Date.UTC(
|
|
1088
|
+
year,
|
|
1089
|
+
month - 1,
|
|
1090
|
+
date,
|
|
1091
|
+
end ? 23 : 0,
|
|
1092
|
+
end ? 59 : 0,
|
|
1093
|
+
end ? 59 : 0,
|
|
1094
|
+
);
|
|
1095
|
+
return millis;
|
|
1096
|
+
}
|
|
1097
|
+
if (RFC3339_PATTERN.test(raw)) {
|
|
1098
|
+
const millis = Date.parse(raw);
|
|
1099
|
+
if (!Number.isNaN(millis)) return millis;
|
|
1100
|
+
}
|
|
1101
|
+
throw searchTimeError(flag, raw);
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
function searchTimeError(flag, raw) {
|
|
1105
|
+
return CliError.usage(`\`file --search\` 的 ${flag} 不是合法时间:${JSON.stringify(raw)}`).withHint(
|
|
1106
|
+
'接受 ISO8601(2026-08-01T00:00:00Z)或 YYYY-MM-DD(2026-08-01)',
|
|
1107
|
+
);
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
function daysInMonth(year, month) {
|
|
1111
|
+
const lengths = [
|
|
1112
|
+
31,
|
|
1113
|
+
(year % 4 === 0 && year % 100 !== 0) || year % 400 === 0 ? 29 : 28,
|
|
1114
|
+
31,
|
|
1115
|
+
30,
|
|
1116
|
+
31,
|
|
1117
|
+
30,
|
|
1118
|
+
31,
|
|
1119
|
+
31,
|
|
1120
|
+
30,
|
|
1121
|
+
31,
|
|
1122
|
+
30,
|
|
1123
|
+
31,
|
|
1124
|
+
];
|
|
1125
|
+
return lengths[month - 1];
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
function isCjk(ch) {
|
|
1129
|
+
const code = ch.codePointAt(0);
|
|
1130
|
+
return (
|
|
1131
|
+
(code >= 0x3400 && code <= 0x4dbf) ||
|
|
1132
|
+
(code >= 0x4e00 && code <= 0x9fff) ||
|
|
1133
|
+
(code >= 0xf900 && code <= 0xfaff)
|
|
1134
|
+
);
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
/**
|
|
1138
|
+
* `--match fuzzy` 对"全中文且 < 4 字"的查询串几乎不会命中(trigram 相似度远低于 0.3)。
|
|
1139
|
+
*
|
|
1140
|
+
* **告警而不是拒绝**:拒绝会让跨语言关键词(`HT-2026-001`)被误伤 ——
|
|
1141
|
+
* fuzzy 的用武之地正是拉丁字母 / 编号 / 拼写错误。
|
|
1142
|
+
*/
|
|
1143
|
+
export function fuzzyShortCjkWarning(plan) {
|
|
1144
|
+
if (plan.matchMode !== 'fuzzy') return null;
|
|
1145
|
+
const terms = [plan.name, plan.summary].filter((term) => term !== null);
|
|
1146
|
+
const short = terms.find(
|
|
1147
|
+
(term) => Array.from(term).length < 4 && Array.from(term).every(isCjk),
|
|
1148
|
+
);
|
|
1149
|
+
if (short === undefined) return null;
|
|
1150
|
+
return (
|
|
1151
|
+
`fuzzy 对短中文串几乎不会命中(similarity(${JSON.stringify(short)}, …) 远低于 0.3),` +
|
|
1152
|
+
'建议用默认的 --match substring'
|
|
1153
|
+
);
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1156
|
+
/** 该不该把 fuzzy 告警打到 stderr(`--quiet` 下静音)。 */
|
|
1157
|
+
export function fuzzyWarningMessage(plan, quiet) {
|
|
1158
|
+
if (quiet) return null;
|
|
1159
|
+
return fuzzyShortCjkWarning(plan);
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
/** 检索的查询参数(单页 / 串行 / 并发共用同一段,避免三条路径漂移)。 */
|
|
1163
|
+
function searchQuery(plan, tenant, offset) {
|
|
1164
|
+
const query = [
|
|
1165
|
+
['tenant', tenant],
|
|
1166
|
+
['limit', String(plan.limit)],
|
|
1167
|
+
['offset', String(offset)],
|
|
1168
|
+
['match', plan.matchMode],
|
|
1169
|
+
];
|
|
1170
|
+
if (plan.name !== null) query.push(['name', plan.name]);
|
|
1171
|
+
if (plan.summary !== null) query.push(['summary', plan.summary]);
|
|
1172
|
+
if (plan.since !== null) query.push(['since', plan.since]);
|
|
1173
|
+
if (plan.until !== null) query.push(['until', plan.until]);
|
|
1174
|
+
if (plan.includeDeleted) query.push(['include_deleted', 'true']);
|
|
1175
|
+
return query;
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
/** 取检索的一页(`GET /files`)。 */
|
|
1179
|
+
function fetchSearchPage(runtime, plan, tenant, offset) {
|
|
1180
|
+
return runtime.sendAuthed(
|
|
1181
|
+
'GET',
|
|
1182
|
+
'/files',
|
|
1183
|
+
requestSpec({ query: searchQuery(plan, tenant, offset) }),
|
|
1184
|
+
);
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
/**
|
|
1188
|
+
* 并发取多页(`--all --workers N`):页与页彼此独立,一次发一批。
|
|
1189
|
+
*
|
|
1190
|
+
* 并发前先续期一次凭据(续期是"读改写"的一次性令牌,不能并发做);
|
|
1191
|
+
* 同一波里按 offset 排序后拼接 —— 结果与串行翻页逐行一致。
|
|
1192
|
+
*/
|
|
1193
|
+
async function fetchSearchPages(runtime, plan, tenant, offsets, workers) {
|
|
1194
|
+
await runtime.ensureFreshToken();
|
|
1195
|
+
const out = [];
|
|
1196
|
+
const queue = [...offsets];
|
|
1197
|
+
while (queue.length > 0) {
|
|
1198
|
+
const take = Math.min(workers, queue.length);
|
|
1199
|
+
const batch = queue.splice(0, take);
|
|
1200
|
+
const settled = await Promise.all(
|
|
1201
|
+
batch.map(async (offset) => ({
|
|
1202
|
+
offset,
|
|
1203
|
+
response: await fetchSearchPage(runtime, plan, tenant, offset),
|
|
1204
|
+
})),
|
|
1205
|
+
);
|
|
1206
|
+
settled.sort((left, right) => left.offset - right.offset);
|
|
1207
|
+
for (const item of settled) out.push(item.response);
|
|
1208
|
+
}
|
|
1209
|
+
return out;
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
/** 一页里的 `files` 数组:**缺键 = 契约错误**(退出码 6),绝不静默当空数组。 */
|
|
1213
|
+
export function searchPageFiles(data) {
|
|
1214
|
+
if (data !== null && typeof data === 'object' && Array.isArray(data.files)) return data.files;
|
|
1215
|
+
const keys =
|
|
1216
|
+
data !== null && typeof data === 'object' && !Array.isArray(data) ? Object.keys(data).sort() : [];
|
|
1217
|
+
throw new CliError(
|
|
1218
|
+
Kind.Server,
|
|
1219
|
+
'cli.contract',
|
|
1220
|
+
'GET /files 的响应里没有 `files` 数组 —— 客户端与服务端的响应形状对不上',
|
|
1221
|
+
)
|
|
1222
|
+
.withDetails({ expected_keys: ['files'], response_keys: keys })
|
|
1223
|
+
.withHint('先升级客户端:xinhe-oa-cli doctor --upgrade;仍复现就把 response_keys 报上来');
|
|
1224
|
+
}
|
|
1225
|
+
|
|
1226
|
+
/** `--all` 到底取回了什么(纯函数,便于单测)。 */
|
|
1227
|
+
export function searchPagingIntegrity(rows, total, startOffset, reachedEnd) {
|
|
1228
|
+
const fetched = rows.length;
|
|
1229
|
+
// 只按 `id` 去重:`id` 是主键,"两条文件同一个 id"就是服务端把同一行发了两遍。
|
|
1230
|
+
const ids = [];
|
|
1231
|
+
for (const row of rows) {
|
|
1232
|
+
if (row !== null && typeof row === 'object' && typeof row.id === 'string') ids.push(row.id);
|
|
1233
|
+
}
|
|
1234
|
+
const withoutId = fetched - ids.length;
|
|
1235
|
+
const unique = new Set(ids).size + withoutId;
|
|
1236
|
+
const duplicates = Math.max(0, fetched - unique);
|
|
1237
|
+
const expected = total === null ? null : Math.max(0, total - startOffset);
|
|
1238
|
+
const missing =
|
|
1239
|
+
expected !== null && reachedEnd ? Math.max(0, expected - unique) : 0;
|
|
1240
|
+
return {
|
|
1241
|
+
fetched,
|
|
1242
|
+
unique,
|
|
1243
|
+
duplicates,
|
|
1244
|
+
expected,
|
|
1245
|
+
missing,
|
|
1246
|
+
reachedEnd,
|
|
1247
|
+
consistent: duplicates === 0 && missing === 0,
|
|
1248
|
+
};
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1251
|
+
/**
|
|
1252
|
+
* 完整性不过关时把"残缺结果"变成一条**错误**(退出码 5、`cli.paging_unstable`)。
|
|
1253
|
+
*
|
|
1254
|
+
* 与 `cli.file_conflict` 同一条判据:它只可能从一次**已经发生的**读里长出来,
|
|
1255
|
+
* 不是参数错(2),也不是服务端 5xx(6)—— 重跑一次就能拿到一致的结果。
|
|
1256
|
+
*/
|
|
1257
|
+
export function searchIntegrityError(integrity, plan, tenant) {
|
|
1258
|
+
if (integrity.consistent) return null;
|
|
1259
|
+
const why =
|
|
1260
|
+
integrity.duplicates > 0
|
|
1261
|
+
? `翻页读到的 ${integrity.fetched} 行里只有 ${integrity.unique} 个唯一文件(${integrity.duplicates} 行重复)`
|
|
1262
|
+
: `翻完了却只读到 ${integrity.unique} 行,服务端说有 ${integrity.expected ?? 0} 行(少 ${integrity.missing} 行)`;
|
|
1263
|
+
return new CliError(
|
|
1264
|
+
Kind.Conflict,
|
|
1265
|
+
'cli.paging_unstable',
|
|
1266
|
+
`${why} —— 这份结果不是一致的快照,已拒绝按全量返回`,
|
|
1267
|
+
)
|
|
1268
|
+
.withDetails({
|
|
1269
|
+
tenant,
|
|
1270
|
+
name: plan.name,
|
|
1271
|
+
summary: plan.summary,
|
|
1272
|
+
match: plan.matchMode,
|
|
1273
|
+
limit: plan.limit,
|
|
1274
|
+
fetched: integrity.fetched,
|
|
1275
|
+
unique: integrity.unique,
|
|
1276
|
+
duplicates: integrity.duplicates,
|
|
1277
|
+
expected: integrity.expected,
|
|
1278
|
+
missing: integrity.missing,
|
|
1279
|
+
reached_end: integrity.reachedEnd,
|
|
1280
|
+
})
|
|
1281
|
+
.withHint(
|
|
1282
|
+
'重跑一次(多半是翻页期间有人上传了新文件);要一致的结果就缩小时间窗 / 只看一页走 next_offset',
|
|
1283
|
+
);
|
|
1284
|
+
}
|
|
1285
|
+
|
|
1286
|
+
// ---------------------------------------------------------------------------
|
|
1287
|
+
// 取值解析(本地校验;全部退出码 2、不发请求)
|
|
1288
|
+
// ---------------------------------------------------------------------------
|
|
1289
|
+
|
|
1290
|
+
/** 必填文本:缺失或全空白 → 用法错误(`what` / `hint` 与 Rust 逐字一致)。 */
|
|
1291
|
+
function requiredText(raw, what, hint) {
|
|
1292
|
+
const value = raw === null || raw === undefined ? '' : String(raw).trim();
|
|
1293
|
+
if (value.length === 0) throw CliError.usage(what).withHint(hint);
|
|
1294
|
+
return value;
|
|
1295
|
+
}
|
|
1296
|
+
|
|
1297
|
+
/** 文件引用:`att_...` id 或业务 `key`;空白 = 没给(退出码 2、不发请求)。 */
|
|
1298
|
+
function fileReference(raw, action, what, example) {
|
|
1299
|
+
const value = raw === null || raw === undefined ? '' : String(raw).trim();
|
|
1300
|
+
if (value.length === 0) throw CliError.usage(`\`${action}\` 需要 ${what}`).withHint(example);
|
|
1301
|
+
return value;
|
|
1302
|
+
}
|
|
1303
|
+
|
|
1304
|
+
function trimmedOrNull(raw) {
|
|
1305
|
+
if (raw === null || raw === undefined) return null;
|
|
1306
|
+
const value = String(raw).trim();
|
|
1307
|
+
return value.length === 0 ? null : value;
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1310
|
+
/**
|
|
1311
|
+
* `--expected-version`:非负整数(`0` = 只创建不覆盖)。
|
|
1312
|
+
*
|
|
1313
|
+
* 负数在本地就判死(FILE-003 §2.5):负数是客户端完全能提前知道的事,
|
|
1314
|
+
* 而服务端的 422 只是给非 CLI 调用方的兜底。
|
|
1315
|
+
*/
|
|
1316
|
+
function expectedVersionValue(args) {
|
|
1317
|
+
const raw = flagValue(args, '--expected-version');
|
|
1318
|
+
if (raw === null) return null;
|
|
1319
|
+
const text = String(raw).trim();
|
|
1320
|
+
if (!/^-?\d+$/.test(text) || !Number.isSafeInteger(Number.parseInt(text, 10))) {
|
|
1321
|
+
throw CliError.usage(`\`file --upload\` 的 --expected-version 要一个整数,收到 ${JSON.stringify(text)}`).withHint(
|
|
1322
|
+
'0 = 只创建不覆盖;不给 = 不比对版本;要基于已有版本提交就写它的版本号',
|
|
1323
|
+
);
|
|
1324
|
+
}
|
|
1325
|
+
const value = Number.parseInt(text, 10);
|
|
1326
|
+
if (value < 0) {
|
|
1327
|
+
throw CliError.usage('`file --upload` 的 --expected-version 不能为负').withHint(
|
|
1328
|
+
'0 = 只创建不覆盖;不给 = 不比对版本;要基于已有版本提交就写它的版本号',
|
|
1329
|
+
);
|
|
1330
|
+
}
|
|
1331
|
+
return value;
|
|
1332
|
+
}
|
|
1333
|
+
|
|
1334
|
+
/** `--base-version`:非负整数(服务端是 u64,负数在 clap 层就解析不出)。 */
|
|
1335
|
+
function baseVersionValue(args) {
|
|
1336
|
+
const raw = flagValue(args, '--base-version');
|
|
1337
|
+
if (raw === null) return null;
|
|
1338
|
+
const text = String(raw).trim();
|
|
1339
|
+
if (!/^\d+$/.test(text) || !Number.isSafeInteger(Number.parseInt(text, 10))) {
|
|
1340
|
+
throw CliError.usage(`\`file --compare\` 的 --base-version 要一个非负整数,收到 ${JSON.stringify(text)}`).withHint(
|
|
1341
|
+
'不给 = 只比内容、不判先后;要判先后就写本地文件基于的版本号',
|
|
1342
|
+
);
|
|
1343
|
+
}
|
|
1344
|
+
return Number.parseInt(text, 10);
|
|
1345
|
+
}
|
|
1346
|
+
|
|
1347
|
+
/** `--download --version`:正整数(版本号从 1 开始)。 */
|
|
1348
|
+
function positiveVersionValue(args, message) {
|
|
1349
|
+
const raw = flagValue(args, '--version');
|
|
1350
|
+
if (raw === null) return null;
|
|
1351
|
+
const text = String(raw).trim();
|
|
1352
|
+
if (!/^\d+$/.test(text) || !Number.isSafeInteger(Number.parseInt(text, 10))) {
|
|
1353
|
+
throw CliError.usage(message).withHint('版本号从 1 开始;不带 --version 就是最新版本');
|
|
1354
|
+
}
|
|
1355
|
+
const value = Number.parseInt(text, 10);
|
|
1356
|
+
if (value === 0) {
|
|
1357
|
+
throw CliError.usage(message).withHint('版本号从 1 开始;不带 --version 就是最新版本');
|
|
1358
|
+
}
|
|
1359
|
+
return value;
|
|
1360
|
+
}
|
|
1361
|
+
|
|
1362
|
+
/**
|
|
1363
|
+
* `--expected-epoch`(`--set` / `--move`):非负整数。
|
|
1364
|
+
*
|
|
1365
|
+
* 服务端的 `inherit_epoch` 从 1 起,`0` 只可能来自陈旧的客户端;负数 / 非数字
|
|
1366
|
+
* 都是本地退出码 2、不发请求。**不要丢掉它自动重试** —— 那等于用旧状态覆盖
|
|
1367
|
+
* 别人刚做的修改。
|
|
1368
|
+
*/
|
|
1369
|
+
function expectedEpochValue(args) {
|
|
1370
|
+
const raw = flagValue(args, '--expected-epoch');
|
|
1371
|
+
if (raw === null) return null;
|
|
1372
|
+
const text = String(raw).trim();
|
|
1373
|
+
const value = /^\d+$/.test(text) ? Number.parseInt(text, 10) : Number.NaN;
|
|
1374
|
+
if (!Number.isSafeInteger(value)) {
|
|
1375
|
+
throw CliError.usage(
|
|
1376
|
+
`\`file --expected-epoch\` 只能是非负整数(inherit_epoch 从 1 起),收到 ${JSON.stringify(text)}`,
|
|
1377
|
+
).withHint('先 `xinhe-oa-cli file --visibility <文件> --show --json` 拿最新 inherit_epoch,再原样传回来');
|
|
1378
|
+
}
|
|
1379
|
+
return value;
|
|
1380
|
+
}
|
|
1381
|
+
|
|
1382
|
+
/**
|
|
1383
|
+
* 可见性档位:只有 `private|dept|internal`。
|
|
1384
|
+
*
|
|
1385
|
+
* `public` **本地就拒**(退出码 2、不发请求):档位定义保留(决策 65),但写入
|
|
1386
|
+
* 一律被拒(服务端 400 `file.visibility_not_available`)—— 让用户跑一趟网络
|
|
1387
|
+
* 换回一个 400,不如在本地把"本期没有对外分享入口"直接说清楚。
|
|
1388
|
+
*/
|
|
1389
|
+
function validatedTier(raw) {
|
|
1390
|
+
const text = String(raw ?? '').trim();
|
|
1391
|
+
const lowered = text.toLowerCase();
|
|
1392
|
+
if (lowered === 'public') {
|
|
1393
|
+
throw CliError.usage('`file --visibility --set` 不支持 public:本期没有对外链接分享入口').withHint(
|
|
1394
|
+
'档位只能是 private|dept|internal;服务端对 public 回 400 file.visibility_not_available',
|
|
1395
|
+
);
|
|
1396
|
+
}
|
|
1397
|
+
if (!FILE_VISIBILITY_TIERS.includes(lowered)) {
|
|
1398
|
+
throw CliError.usage(
|
|
1399
|
+
`\`file --visibility --set\` 的档位只能是 private|dept|internal,收到 ${JSON.stringify(text)}`,
|
|
1400
|
+
).withHint('private(默认;只有所有者 + 点名 ACL)| dept(归属部门子树)| internal(本企业在职成员)');
|
|
1401
|
+
}
|
|
1402
|
+
return lowered;
|
|
1403
|
+
}
|
|
1404
|
+
|
|
1405
|
+
/**
|
|
1406
|
+
* `--from-user` / `--to-user`(user)与 `--from-dept` / `--to-dept`(dept)恰好一个。
|
|
1407
|
+
*
|
|
1408
|
+
* 两个 `hint` 由调用方给:Rust 侧 `acl_subject` 与 `assign_subject` 的措辞不同
|
|
1409
|
+
* (前者点名到人/部门,后者给的是可照抄的两种写法),这里逐字照抄,不合并。
|
|
1410
|
+
*/
|
|
1411
|
+
function aclSubject(args, action, userFlag, deptFlag, conflictHint, missingHint) {
|
|
1412
|
+
const user = trimmedOrNull(flagValue(args, userFlag));
|
|
1413
|
+
const dept = trimmedOrNull(flagValue(args, deptFlag));
|
|
1414
|
+
if (user !== null && dept !== null) {
|
|
1415
|
+
throw CliError.usage(`\`${action}\` 的 ${userFlag} 与 ${deptFlag} 只能给一个`).withHint(
|
|
1416
|
+
conflictHint,
|
|
1417
|
+
);
|
|
1418
|
+
}
|
|
1419
|
+
if (user !== null) return { dept: false, id: user };
|
|
1420
|
+
if (dept !== null) return { dept: true, id: dept };
|
|
1421
|
+
throw CliError.usage(`\`${action}\` 需要 ${userFlag} <USER_ID> 或 ${deptFlag} <OU_ID>`).withHint(
|
|
1422
|
+
missingHint,
|
|
1423
|
+
);
|
|
1424
|
+
}
|
|
1425
|
+
|
|
1426
|
+
/** `--action`:只认 `read|write`(文件 ACL 的 ALL_ACTIONS 冻结,下载 = read)。 */
|
|
1427
|
+
function aclAction(raw, what) {
|
|
1428
|
+
const text = String(raw ?? '').trim().toLowerCase();
|
|
1429
|
+
if (text !== 'read' && text !== 'write') {
|
|
1430
|
+
throw CliError.usage(`\`${what}\` 的 --action 只支持 read|write,收到 ${JSON.stringify(String(raw ?? '').trim())}`).withHint(
|
|
1431
|
+
'文件 ACL 只有 read / write 两个动作(下载 = read)',
|
|
1432
|
+
);
|
|
1433
|
+
}
|
|
1434
|
+
return text;
|
|
1435
|
+
}
|
|
1436
|
+
|
|
1437
|
+
/** `--permission-request` 要拒掉 `--to-user` / `--to-dept`(申请交给谁由审批人定)。 */
|
|
1438
|
+
function hasAssignSubject(args) {
|
|
1439
|
+
return (
|
|
1440
|
+
trimmedOrNull(flagValue(args, '--to-user')) !== null ||
|
|
1441
|
+
trimmedOrNull(flagValue(args, '--to-dept')) !== null
|
|
1442
|
+
);
|
|
1443
|
+
}
|
|
1444
|
+
|
|
1445
|
+
/** `--expires-at`:必须是**带时区**的 RFC3339(服务端要绝对时刻;本地时间本地拒)。 */
|
|
1446
|
+
function expiresAtValue(args) {
|
|
1447
|
+
const raw = trimmedOrNull(flagValue(args, '--expires-at'));
|
|
1448
|
+
if (raw === null) return null;
|
|
1449
|
+
if (!RFC3339_PATTERN.test(raw)) {
|
|
1450
|
+
throw CliError.usage(
|
|
1451
|
+
`\`file --permission-*\` 的 --expires-at 必须是带时区的 RFC3339,收到 ${JSON.stringify(raw)}`,
|
|
1452
|
+
).withHint('例:--expires-at 2026-12-31T23:59:59+08:00(不带时区的本地时间会被拒)');
|
|
1453
|
+
}
|
|
1454
|
+
return raw;
|
|
1455
|
+
}
|
|
1456
|
+
|
|
1457
|
+
/** u64 形状的字段:非整数 / 负数一律当"没有"(对齐 serde_json 的 `as_u64`)。 */
|
|
1458
|
+
function unsignedOrNull(value) {
|
|
1459
|
+
return Number.isInteger(value) && value >= 0 ? value : null;
|
|
1460
|
+
}
|
|
1461
|
+
|
|
1462
|
+
/** Rust `Option<u64>` 的 `{:?}` 展示(`Some(3)` / `None`)。 */
|
|
1463
|
+
function optionDebug(value) {
|
|
1464
|
+
return value === null ? 'None' : `Some(${value})`;
|
|
1465
|
+
}
|
|
1466
|
+
|
|
1467
|
+
/** serde_json `Value` 的 Display:字符串带引号,其余与 JSON 一致。 */
|
|
1468
|
+
function jsonDisplay(value) {
|
|
1469
|
+
return typeof value === 'string' ? JSON.stringify(value) : JSON.stringify(value ?? null);
|
|
1470
|
+
}
|
|
1471
|
+
|
|
1472
|
+
function sha256Hex(bytes) {
|
|
1473
|
+
return createHash('sha256').update(bytes).digest('hex');
|
|
1474
|
+
}
|