@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.
@@ -0,0 +1,195 @@
1
+ // 小工具:随机串、时间、手机号/令牌掩码、文本。
2
+ //
3
+ // 与 Rust 版逐条对齐的两处:
4
+ // * `randomHex()` 用 `crypto.randomBytes`(Rust 那边读 `/dev/urandom`);
5
+ // * `nowRfc3339()` 输出 `2026-10-16T03:21:15Z` 这种**秒级、带 Z** 的形状
6
+ // (Rust 是 `to_rfc3339_opts(Secs, true)`),因为服务端与凭据文件里比较的就是它。
7
+
8
+ import { randomBytes } from 'node:crypto';
9
+
10
+ /** 32 位十六进制随机串(device_id / 请求 id / 幂等键用;不用于密码学)。 */
11
+ export function randomHex() {
12
+ return randomBytes(16).toString('hex');
13
+ }
14
+
15
+ /** 请求 id:`req_<16 hex>`。字符集符合服务端 `_clean_request_id` 的要求。 */
16
+ export function newRequestId() {
17
+ return `req_${randomHex().slice(0, 16)}`;
18
+ }
19
+
20
+ /** 新的设备指纹:`dev-<32 hex>`(首次登录时生成并持久化,契约 §4)。 */
21
+ export function newDeviceId() {
22
+ return `dev-${randomHex()}`;
23
+ }
24
+
25
+ /**
26
+ * 幂等键:稳定字符串(服务端只要求 `[A-Za-z0-9_.:-]` 且 ≤128 字符)。
27
+ *
28
+ * scope 里的非字母数字字符统一折成 `-`,只取前 24 个字符 ——
29
+ * 与 Rust 的 `new_idempotency_key` 同形,判卷按形状断言时两边一致。
30
+ */
31
+ export function newIdempotencyKey(scope) {
32
+ const cleaned = Array.from(String(scope ?? ''))
33
+ .map((ch) => (/[A-Za-z0-9]/.test(ch) ? ch : '-'))
34
+ .join('')
35
+ .slice(0, 24);
36
+ return `${cleaned}:${randomHex().slice(0, 24)}`;
37
+ }
38
+
39
+ /** 现在(UTC,RFC3339,秒级 + Z)。 */
40
+ export function nowRfc3339() {
41
+ return new Date().toISOString().replace(/\.\d{3}Z$/, 'Z');
42
+ }
43
+
44
+ /**
45
+ * 解析 RFC3339 时间戳(服务端 `iso()` 的输出)。
46
+ * 解析不了返回 null(调用方据此决定"当作已过期"还是"当作没过期")。
47
+ */
48
+ export function parseRfc3339(raw) {
49
+ if (typeof raw !== 'string') return null;
50
+ const text = raw.trim();
51
+ if (text.length === 0) return null;
52
+ const millis = Date.parse(text);
53
+ return Number.isNaN(millis) ? null : millis;
54
+ }
55
+
56
+ /** 手机号掩码(日志/展示用;**永不打印完整号码**)。 */
57
+ export function maskPhone(phone) {
58
+ const digits = Array.from(String(phone ?? ''))
59
+ .filter((ch) => ch >= '0' && ch <= '9')
60
+ .join('');
61
+ if (digits.length < 7) return '****';
62
+ return `${digits.slice(0, 3)}****${digits.slice(-4)}`;
63
+ }
64
+
65
+ /** 令牌掩码:只给前 4 位与长度,够"确认是哪一枚"但不够"拿来用"。 */
66
+ export function maskToken(token) {
67
+ if (!token) return '<empty>';
68
+ const chars = Array.from(token);
69
+ return `${chars.slice(0, 4).join('')}…(共 ${chars.length} 字符)`;
70
+ }
71
+
72
+ /**
73
+ * 显示宽度:CJK 字符占两列(表格对齐用)。
74
+ *
75
+ * 与 Rust `display_width` 同一张区间表 —— 两边渲染出来的表格必须逐字符一致,
76
+ * 否则 `--format table` 的对比测试会一直红。
77
+ */
78
+ export function displayWidth(text) {
79
+ let width = 0;
80
+ for (const ch of String(text ?? '')) {
81
+ const code = ch.codePointAt(0);
82
+ const wide =
83
+ (code >= 0x1100 && code <= 0x115f) ||
84
+ (code >= 0x2e80 && code <= 0xa4cf) ||
85
+ (code >= 0xac00 && code <= 0xd7a3) ||
86
+ (code >= 0xf900 && code <= 0xfaff) ||
87
+ (code >= 0xfe30 && code <= 0xfe6f) ||
88
+ (code >= 0xff00 && code <= 0xff60) ||
89
+ (code >= 0xffe0 && code <= 0xffe6);
90
+ width += wide ? 2 : 1;
91
+ }
92
+ return width;
93
+ }
94
+
95
+ /** 按**显示宽度**截断(不是字符数),否则中文列会错位。 */
96
+ export function truncateWidth(text, maxWidth) {
97
+ const source = String(text ?? '');
98
+ if (displayWidth(source) <= maxWidth) return source;
99
+ let out = '';
100
+ let width = 0;
101
+ for (const ch of source) {
102
+ const charWidth = displayWidth(ch);
103
+ if (width + charWidth > Math.max(0, maxWidth - 1)) break;
104
+ out += ch;
105
+ width += charWidth;
106
+ }
107
+ return `${out}…`;
108
+ }
109
+
110
+ /** 按显示宽度右侧补空格。 */
111
+ export function padWidth(text, width) {
112
+ const source = String(text ?? '');
113
+ const current = displayWidth(source);
114
+ return current >= width ? source : source + ' '.repeat(width - current);
115
+ }
116
+
117
+ /** 紧凑 JSON(对象键顺序保持插入序,与 Rust 的 serde_json Map 一致)。 */
118
+ export function compactJson(value) {
119
+ return JSON.stringify(value);
120
+ }
121
+
122
+ /**
123
+ * 按 UTF-8 字节序递归排序对象键 —— **对齐 Rust 的 `serde_json`**。
124
+ *
125
+ * Rust 那份 CLI 没开 `preserve_order`,`serde_json::Map` 因此是 `BTreeMap`:
126
+ * 所有 JSON 对象(含 `meta`、`data` 里命令自己拼的那些)的键都是**字典序**。
127
+ * 这里不排序的话,Node 版与 Rust 版的差别只体现在"键的先后"上 ——
128
+ * 判卷做字节/文本比对时那是噪声,看 `diff` 的人也会被误导。
129
+ *
130
+ * 只影响输出;命令内部的对象顺序(表格列序等)不受影响。
131
+ */
132
+ export function sortKeysDeep(value) {
133
+ if (Array.isArray(value)) return value.map(sortKeysDeep);
134
+ if (value === null || typeof value !== 'object') return value;
135
+ const out = {};
136
+ for (const key of Object.keys(value).sort()) out[key] = sortKeysDeep(value[key]);
137
+ return out;
138
+ }
139
+
140
+ /**
141
+ * 把 Node 的 errno 渲染成 **Rust `std::io::Error` 的措辞**
142
+ * (`No such file or directory (os error 2)`)。
143
+ *
144
+ * 为什么值得这么做:`读不了文件 X:…` 这句话会进 `--json` 的 `error.message`,
145
+ * 而它是**对拍与判卷会逐字比**的字段。Node 原生给的是
146
+ * `ENOENT: no such file or directory, open './x.pdf'`(libuv 措辞),Rust 给的是
147
+ * `No such file or directory (os error 2)` —— 同一个事实、两种说法。
148
+ * 用户拿到的可执行信息(路径 + 原因)两者都有,所以这里只是把措辞对齐契约基线。
149
+ *
150
+ * 认不出来的错误码**退回 Node 原文**(不猜:宁可保留 libuv 措辞,
151
+ * 也不编一个可能不对的 os error 号)。
152
+ */
153
+ const ERRNO_TEXT = {
154
+ EPERM: [1, 'Operation not permitted'],
155
+ ENOENT: [2, 'No such file or directory'],
156
+ ESRCH: [3, 'No such process'],
157
+ EINTR: [4, 'Interrupted system call'],
158
+ EIO: [5, 'Input/output error'],
159
+ ENXIO: [6, 'No such device or address'],
160
+ E2BIG: [7, 'Argument list too long'],
161
+ ENOEXEC: [8, 'Exec format error'],
162
+ EBADF: [9, 'Bad file descriptor'],
163
+ EAGAIN: [11, 'Resource temporarily unavailable'],
164
+ ENOMEM: [12, 'Cannot allocate memory'],
165
+ EACCES: [13, 'Permission denied'],
166
+ EFAULT: [14, 'Bad address'],
167
+ EBUSY: [16, 'Device or resource busy'],
168
+ EEXIST: [17, 'File exists'],
169
+ EXDEV: [18, 'Invalid cross-device link'],
170
+ ENODEV: [19, 'No such device'],
171
+ ENOTDIR: [20, 'Not a directory'],
172
+ EISDIR: [21, 'Is a directory'],
173
+ EINVAL: [22, 'Invalid argument'],
174
+ ENFILE: [23, 'Too many open files in system'],
175
+ EMFILE: [24, 'Too many open files'],
176
+ ENOTTY: [25, 'Inappropriate ioctl for device'],
177
+ EFBIG: [27, 'File too large'],
178
+ ENOSPC: [28, 'No space left on device'],
179
+ ESPIPE: [29, 'Illegal seek'],
180
+ EROFS: [30, 'Read-only file system'],
181
+ EMLINK: [31, 'Too many links'],
182
+ EPIPE: [32, 'Broken pipe'],
183
+ ENAMETOOLONG: [36, 'File name too long'],
184
+ ENOSYS: [38, 'Function not implemented'],
185
+ ENOTEMPTY: [39, 'Directory not empty'],
186
+ ELOOP: [40, 'Too many levels of symbolic links'],
187
+ EOVERFLOW: [75, 'Value too large for defined data type'],
188
+ };
189
+
190
+ /** `ENOENT: no such file or directory, open './x.pdf'` → `No such file or directory (os error 2)`。 */
191
+ export function rustIoErrorText(err) {
192
+ const known = ERRNO_TEXT[err?.code];
193
+ if (known === undefined) return err?.message ?? String(err);
194
+ return `${known[1]} (os error ${known[0]})`;
195
+ }
@@ -0,0 +1,278 @@
1
+ // `--type` / `--set` / `--filter` 这类**取值语法**的解析(对齐 Rust 的 args.rs)。
2
+ //
3
+ // 这一层与"哪些旗标存在"(`src/spec/command_surface.json`)分开:
4
+ // 规格管"能不能这么写",这里管"写出来的值是什么意思"。
5
+
6
+ import { CliError } from './error.js';
7
+
8
+ /** FILE-005 §6.2:本期能写的**三档**(`public` 刻意不在表里,本地就拦下)。 */
9
+ export const FILE_VISIBILITY_TIERS = ['private', 'dept', 'internal'];
10
+ /** 文件 ACL 只认两个动作(`download = read`)。 */
11
+ export const FILE_ACL_ACTIONS = ['read', 'write'];
12
+ export const FILE_SEARCH_MAX_LIMIT = 200;
13
+ export const FILE_SEARCH_MAX_WORKERS = 16;
14
+ export const FILE_SEARCH_MIN_QUERY = 2;
15
+
16
+ export const REPORT_STATUSES = [
17
+ 'pending',
18
+ 'filled',
19
+ 'submitted',
20
+ 'confirmed',
21
+ 'rejected',
22
+ 'waived',
23
+ ];
24
+ export const REPORT_CADENCES = ['daily', 'weekly', 'monthly', 'quarterly'];
25
+
26
+ /** 值的字面量解析:数字/布尔/JSON/字符串。命令行里写 `true` 就该是布尔。 */
27
+ export function parseScalar(raw) {
28
+ const trimmed = String(raw ?? '').trim();
29
+ if (trimmed.length === 0) return null;
30
+ if (trimmed === 'null') return null;
31
+
32
+ // 只有"规范写法"才当数字:带前导零的 `007` 是单据号,不是 7。
33
+ const canonicalInteger =
34
+ !trimmed.startsWith('0') ||
35
+ trimmed === '0' ||
36
+ trimmed.startsWith('0.') ||
37
+ trimmed.startsWith('-0.') ||
38
+ (trimmed.startsWith('-0') && trimmed.length > 2 && /^\d+$/.test(trimmed.slice(2)));
39
+ if (canonicalInteger) {
40
+ if (/^-?\d+$/.test(trimmed)) {
41
+ const asInt = Number.parseInt(trimmed, 10);
42
+ // 超出安全整数范围时退回字符串:JSON 里那个数已经不是它本来的值了。
43
+ if (Number.isSafeInteger(asInt)) return asInt;
44
+ } else if (/^-?\d*\.\d+([eE][+-]?\d+)?$/.test(trimmed)) {
45
+ const asFloat = Number.parseFloat(trimmed);
46
+ if (Number.isFinite(asFloat)) return asFloat;
47
+ }
48
+ }
49
+ if (trimmed === 'true') return true;
50
+ if (trimmed === 'false') return false;
51
+ if (
52
+ (trimmed.startsWith('[') && trimmed.endsWith(']')) ||
53
+ (trimmed.startsWith('{') && trimmed.endsWith('}'))
54
+ ) {
55
+ try {
56
+ return JSON.parse(trimmed);
57
+ } catch {
58
+ /* 不是合法 JSON 就当字符串 */
59
+ }
60
+ }
61
+ return trimmed;
62
+ }
63
+
64
+ /** `--key 键=值`:拆成 `[键, 值]`(值为字符串)。 */
65
+ export function parsePairs(items, flag) {
66
+ const out = [];
67
+ for (const item of items) {
68
+ const index = String(item).indexOf('=');
69
+ if (index < 0) {
70
+ throw CliError.usage(`${flag} 的写法是 \`键=值\`,收到 ${JSON.stringify(item)}`).withHint(
71
+ `例:${flag} 金额=1200`,
72
+ );
73
+ }
74
+ const key = String(item).slice(0, index).trim();
75
+ if (key.length === 0) {
76
+ throw CliError.usage(`${flag} 的键不能为空(收到 ${JSON.stringify(item)})`);
77
+ }
78
+ out.push([key, String(item).slice(index + 1)]);
79
+ }
80
+ return out;
81
+ }
82
+
83
+ /**
84
+ * `--set` 的一个键值对。
85
+ *
86
+ * `forceString` = 用户写了 `键:=值`,值**原样当字符串**,不做数字/布尔/JSON 推断。
87
+ *
88
+ * 为什么需要它:`--set 流水号=21474238` 会被 `parseScalar` 当整数发出去,
89
+ * 而 text 字段收到 int 会被服务端拒;加引号又会把引号一起存进去。于是
90
+ * "把纯数字文本写进 text 字段"这件事在现场没有任何写法能做到 ——
91
+ * 而流水号、手机号、门诊号、票据号全是这种值。用 `:=`(curl/jq 那一系的约定)解决。
92
+ */
93
+ export function parseSetPairs(items, flag) {
94
+ const out = [];
95
+ for (const item of items) {
96
+ const text = String(item);
97
+ const eq = text.indexOf('=');
98
+ if (eq < 0) {
99
+ throw CliError.usage(
100
+ `${flag} 的写法是 \`键=值\`(或 \`键:=值\` 强制字符串),收到 ${JSON.stringify(item)}`,
101
+ ).withHint(`例:${flag} 金额=1200 / ${flag} 流水号:=21474238`);
102
+ }
103
+ // 判据只看**第一个** `=`:若它前一个字符是 `:`,就是强制字符串。
104
+ // 这样 `备注=a:=b` 不会被误判(第一个 `=` 前面是 `a`)。
105
+ const rawKey = text.slice(0, eq);
106
+ const forceString = rawKey.endsWith(':');
107
+ const key = (forceString ? rawKey.slice(0, -1) : rawKey).trim();
108
+ if (key.length === 0) {
109
+ throw CliError.usage(`${flag} 的键不能为空(收到 ${JSON.stringify(item)})`);
110
+ }
111
+ out.push({ key, value: text.slice(eq + 1), forceString });
112
+ }
113
+ return out;
114
+ }
115
+
116
+ /** 把 `--set` 的键值转成 `values` 对象(`键:=值` 强制字符串)。 */
117
+ export function valuesObject(pairs) {
118
+ const out = {};
119
+ for (const pair of pairs) {
120
+ out[pair.key] = pair.forceString ? pair.value : parseScalar(pair.value);
121
+ }
122
+ return out;
123
+ }
124
+
125
+ /**
126
+ * `--filter 字段:算子:值` → 视图过滤 AST(组内 AND,最多两层 —— 与服务端一致)。
127
+ *
128
+ * 字段用 **semantic_key**(`bitable field --list` 里的机器名):写显示名不报错,
129
+ * 但客户端筛不到任何行,"静默 0 条"是这里最贵的坑。
130
+ */
131
+ export function filtersToAst(items) {
132
+ if (items.length === 0) return null;
133
+ const children = [];
134
+ for (const item of items) {
135
+ const parts = String(item).split(':');
136
+ const field = (parts[0] ?? '').trim();
137
+ const operator = (parts[1] ?? '').trim();
138
+ const raw = parts.slice(2).join(':');
139
+ if (field.length === 0 || operator.length === 0) {
140
+ throw CliError.usage(`--filter 的写法是 \`字段:算子:值\`,收到 ${JSON.stringify(item)}`).withHint(
141
+ '写法是 `字段:算子:值`(字段用 semantic_key,例:--filter visit_date:gte:2026-01-01)',
142
+ );
143
+ }
144
+ children.push({ field, operator, value: parseScalar(raw) });
145
+ }
146
+ return { op: 'and', children };
147
+ }
148
+
149
+ /** `字段:asc` → 服务端的 `order` 参数(缺方向补 asc)。 */
150
+ export function orderParam(raw) {
151
+ return String(raw)
152
+ .split(',')
153
+ .map((pieceRaw) => {
154
+ const piece = pieceRaw.trim();
155
+ const index = piece.lastIndexOf(':');
156
+ if (index > 0) {
157
+ const field = piece.slice(0, index).trim();
158
+ const direction = piece.slice(index + 1).trim().toLowerCase();
159
+ if (direction === 'asc' || direction === 'desc') return `${field}:${direction}`;
160
+ }
161
+ return `${piece}:asc`;
162
+ })
163
+ .join(',');
164
+ }
165
+
166
+ /**
167
+ * 校验 `--on-conflict` 与 `--new-key` / `--new-name` 的组合。
168
+ *
169
+ * | 输入 | 结果 |
170
+ * |---|---|
171
+ * | 缺失 + 没给 new-* | `null`:交给服务端判"有没有冲突" |
172
+ * | 缺失 + 给了 new-* | 退出码 2:`--new-key` 只在 `rename` 时才有意义 |
173
+ * | `rename` + 有 new-key | `{kind:'rename', newKey, newName}` |
174
+ * | `rename` + 缺 new-key | 退出码 2:改名却不给新 key,等于没表态 |
175
+ * | `update` + 给了 new-* | 退出码 2:update 是**覆盖**,不是改名 |
176
+ * | 其它值 | 退出码 2 |
177
+ */
178
+ export function onConflictPlan(onConflict, newKey, newName) {
179
+ const trimmed = (value) => {
180
+ const text = value === null || value === undefined ? '' : String(value).trim();
181
+ return text.length > 0 ? text : null;
182
+ };
183
+ const key = trimmed(newKey);
184
+ const name = trimmed(newName);
185
+ const mode = trimmed(onConflict);
186
+ if (mode === null) {
187
+ if (key !== null || name !== null) {
188
+ throw CliError.usage(
189
+ '--new-key / --new-name 只在 `--on-conflict rename` 时才有意义',
190
+ ).withHint(
191
+ '要么 `--on-conflict rename --new-key <新KEY>`,要么先 `skills --conflicts` 看清冲突再决定',
192
+ );
193
+ }
194
+ return null;
195
+ }
196
+ if (mode === 'rename') {
197
+ if (key === null) {
198
+ throw CliError.usage('`--on-conflict rename` 必须给 --new-key <新KEY>').withHint(
199
+ '例:--on-conflict rename --new-key finance_monthly_v2 [--new-name 财务月结V2]',
200
+ );
201
+ }
202
+ return { kind: 'rename', newKey: key, newName: name };
203
+ }
204
+ if (mode === 'update') {
205
+ if (key !== null || name !== null) {
206
+ throw CliError.usage(
207
+ '`--on-conflict update` 的语义是**覆盖已有的那个技能**(发布新版本),不接受 --new-key / --new-name',
208
+ ).withHint('想改名就换 `--on-conflict rename --new-key <新KEY>`');
209
+ }
210
+ return { kind: 'update' };
211
+ }
212
+ throw CliError.usage(
213
+ `--on-conflict 只能是 rename(改名后分享)或 update(覆盖已有的那个),收到 ${JSON.stringify(mode)}`,
214
+ ).withHint('两条出路:--on-conflict rename --new-key <新KEY> | --on-conflict update');
215
+ }
216
+
217
+ /** 技能可见性:`private`(默认)| `shared` | `tenant`。 */
218
+ export function parseVisibility(raw) {
219
+ const value = String(raw ?? '').trim().toLowerCase();
220
+ if (value === 'private' || value === 'shared' || value === 'tenant') return value;
221
+ throw CliError.usage(
222
+ `--visibility 只能是 private(默认,只有自己)/ shared(点对点分享过的)/ tenant(企业内共享,要审批),收到 ${JSON.stringify(value)}`,
223
+ ).withHint('例:--visibility tenant');
224
+ }
225
+
226
+ /** 新建技能时的可见性:**不传就是 private**(fail-closed)。 */
227
+ export function createVisibility(raw) {
228
+ if (raw === null || raw === undefined) return 'private';
229
+ return parseVisibility(raw);
230
+ }
231
+
232
+ /** 文件可见性档位:只认 private / dept / internal(`public` 本地拒,不发请求)。 */
233
+ export function parseFileVisibilityTier(raw) {
234
+ const value = String(raw ?? '').trim().toLowerCase();
235
+ if (FILE_VISIBILITY_TIERS.includes(value)) return value;
236
+ if (value === 'public') {
237
+ throw CliError.usage('`public` 这一档本期不可写(服务端会拒)').withHint(
238
+ `能写的是:${FILE_VISIBILITY_TIERS.join(' / ')}`,
239
+ );
240
+ }
241
+ throw CliError.usage(
242
+ `--set 只能是 ${FILE_VISIBILITY_TIERS.join(' / ')},收到 ${JSON.stringify(value)}`,
243
+ );
244
+ }
245
+
246
+ /** 审批状态过滤:空串 = 不筛;只认已知状态。 */
247
+ export function approvalStatusFilter(raw) {
248
+ const value = String(raw ?? '').trim().toLowerCase();
249
+ if (value.length === 0) return null;
250
+ const known = ['pending', 'approved', 'rejected', 'cancelled', 'reverted'];
251
+ if (!known.includes(value)) {
252
+ throw CliError.usage(`--status 只能是 ${known.join(' / ')},收到 ${JSON.stringify(value)}`);
253
+ }
254
+ return value;
255
+ }
256
+
257
+ /** 从 URL 或本地路径里取文件名。 */
258
+ export function fileNameOf(path) {
259
+ const parts = String(path ?? '').split(/[/\\]/);
260
+ return parts[parts.length - 1] ?? String(path ?? '');
261
+ }
262
+
263
+ /** `{key: value}` → 单个对象的查询参数数组(空值不进 query)。 */
264
+ export function queryPairs(entries) {
265
+ const out = [];
266
+ for (const [key, value] of entries) {
267
+ if (value === null || value === undefined) continue;
268
+ const text = String(value);
269
+ if (text.trim().length === 0) continue;
270
+ out.push([key, text]);
271
+ }
272
+ return out;
273
+ }
274
+
275
+ /** 布尔渲染:`true` / `false`(服务端与文档都用字面量)。 */
276
+ export function asBool(value) {
277
+ return value === true;
278
+ }