@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
package/src/core/args.js
ADDED
|
@@ -0,0 +1,901 @@
|
|
|
1
|
+
// 参数解析(对齐 Rust 版 clap 的接受面与报错口径)。
|
|
2
|
+
//
|
|
3
|
+
// 三条硬规矩,与 Rust 版逐条对应:
|
|
4
|
+
//
|
|
5
|
+
// 1. **参数即约束**:能力面由 `src/spec/command_surface.json`(从 Rust 产物生成,
|
|
6
|
+
// 见 `scripts/gen-spec.py`)决定。解析不出来的旗标一律**退出码 2、不发请求** ——
|
|
7
|
+
// 绝没有"任意旗标透传"这种口子。
|
|
8
|
+
// 2. **多动作 / 缺动作是用法错误**:`bitable base --list --create` 与
|
|
9
|
+
// `bitable base`(什么都不给)都在本地判死(退出码 2)。
|
|
10
|
+
// 3. **高风险写操作必须 `--yes`**:`schema` 里 `requires_yes=true` 的命令少了它,
|
|
11
|
+
// 同样退出码 2 并明确提示"要加 --yes"。这条检查在**发请求之前**做。
|
|
12
|
+
|
|
13
|
+
import { readFileSync } from 'node:fs';
|
|
14
|
+
import { CliError, Kind } from './error.js';
|
|
15
|
+
|
|
16
|
+
/** 全局旗标(对所有子命令生效,位置任意)。 */
|
|
17
|
+
export const GLOBAL_FLAGS = Object.freeze({
|
|
18
|
+
'--base-url': { key: 'baseUrl', value: 'URL' },
|
|
19
|
+
'--profile': { key: 'profile', value: 'NAME' },
|
|
20
|
+
'--json': { key: 'json', bool: true },
|
|
21
|
+
'--dry-run': { key: 'dryRun', bool: true },
|
|
22
|
+
'--yes': { key: 'yes', bool: true },
|
|
23
|
+
'--timeout': { key: 'timeout', value: 'SECONDS' },
|
|
24
|
+
'-v': { key: 'verbose', bool: true },
|
|
25
|
+
'--verbose': { key: 'verbose', bool: true },
|
|
26
|
+
'--quiet': { key: 'quiet', bool: true },
|
|
27
|
+
'--no-color': { key: 'noColor', bool: true },
|
|
28
|
+
'--format': { key: 'format', value: 'FORMAT' },
|
|
29
|
+
'--help': { key: 'help', bool: true },
|
|
30
|
+
'-h': { key: 'help', bool: true },
|
|
31
|
+
'--version': { key: 'version', bool: true },
|
|
32
|
+
'-V': { key: 'version', bool: true },
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
const SURFACE_URL = new URL('../spec/command_surface.json', import.meta.url);
|
|
36
|
+
|
|
37
|
+
let cachedSurface = null;
|
|
38
|
+
|
|
39
|
+
/** 命令面规格(懒加载 + 缓存;只在进程内读一次)。 */
|
|
40
|
+
export function commandSurface() {
|
|
41
|
+
if (cachedSurface === null) {
|
|
42
|
+
cachedSurface = JSON.parse(readFileSync(SURFACE_URL, 'utf8'));
|
|
43
|
+
}
|
|
44
|
+
return cachedSurface;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* 规格里的每条命令 → 便于查询的形状。
|
|
49
|
+
*
|
|
50
|
+
* 旗标来自两处,缺一不可:
|
|
51
|
+
* * `params`:`schema --json` 登记过的(人读的那份);
|
|
52
|
+
* * `extra_flags`:**源码里有、schema 没登记**的布尔旗标(`config --show` 就是)——
|
|
53
|
+
* 漏掉它会把合法命令判成"不认识这个旗标"(本项目第一次自测的翻车点)。
|
|
54
|
+
* 另有 `rejected_flags`:属于**兄弟子命令**的动作旗标,本命令必须拒
|
|
55
|
+
* (`member --list --offboard x` 在 Rust 版是用法错误)。
|
|
56
|
+
*/
|
|
57
|
+
function indexSurface(surface) {
|
|
58
|
+
const commands = surface.commands.map((entry) => {
|
|
59
|
+
const tokens = entry.name.split(' ');
|
|
60
|
+
const flags = new Map();
|
|
61
|
+
const add = (flag, shape) => {
|
|
62
|
+
const existing = flags.get(flag) ?? {
|
|
63
|
+
takesValue: false,
|
|
64
|
+
variadic: false,
|
|
65
|
+
optional: true,
|
|
66
|
+
sourceShape: null,
|
|
67
|
+
placeholder: '',
|
|
68
|
+
};
|
|
69
|
+
flags.set(flag, {
|
|
70
|
+
takesValue: existing.takesValue || shape.takesValue,
|
|
71
|
+
variadic: existing.variadic || shape.variadic,
|
|
72
|
+
optional: existing.optional && shape.optional,
|
|
73
|
+
sourceShape: shape.sourceShape ?? existing.sourceShape,
|
|
74
|
+
placeholder: shape.placeholder || existing.placeholder,
|
|
75
|
+
});
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
// 散文那侧(`schema --json` 的 `params`):只用来定"必填/可选"与占位符 ——
|
|
79
|
+
// **取值吃不吃下一个词一律以源码事实为准**(见下),因为散文里
|
|
80
|
+
// `--offboard <USER_ID>` 会被写成裸 `--offboard`。
|
|
81
|
+
const declared = new Set();
|
|
82
|
+
for (const param of entry.params) {
|
|
83
|
+
if (!param.flag) continue;
|
|
84
|
+
declared.add(param.flag);
|
|
85
|
+
add(param.flag, {
|
|
86
|
+
takesValue: false,
|
|
87
|
+
variadic: Boolean(param.variadic),
|
|
88
|
+
optional: Boolean(param.optional),
|
|
89
|
+
placeholder: param.value ?? '',
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
for (const flag of entry.extra_flags ?? []) {
|
|
93
|
+
declared.add(flag);
|
|
94
|
+
add(flag, { takesValue: false, optional: true });
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// 必填只认真·必填:散文里写着必填、**且**源码类型不是 `Option`/`Vec` 的。
|
|
98
|
+
//
|
|
99
|
+
// 两个坑都在这里躲开:
|
|
100
|
+
// * `member --search` 的散文写的是 `--name <TEXT> --phone <PHONE> --dept <DEPT_ID>`
|
|
101
|
+
// (三个都没加方括号),但契约是"**至少给一个**"—— 当三个都必填校验,
|
|
102
|
+
// 合法调用会被判死。`Option<String>` 在 clap 里天然可选,所以以源码为准。
|
|
103
|
+
// * 全局旗标(`--yes` / `--dry-run`)由 `extractGlobals()` 提前摘走,
|
|
104
|
+
// 算成必填会让 33 条 `requires_yes` 的命令**永远**解析失败。
|
|
105
|
+
const requiredFlags = entry.params
|
|
106
|
+
.filter((param) => param.flag && !param.optional && !(param.flag in GLOBAL_FLAGS))
|
|
107
|
+
.filter((param) => {
|
|
108
|
+
const shape = (entry.flags ?? {})[param.flag];
|
|
109
|
+
// 源码类型是 `Option`/`Vec` ⇒ clap 层面可选,散文里的"没加方括号"不算数
|
|
110
|
+
// (`member --search` 的 `--name/--phone/--dept` 就是"至少给一个")。
|
|
111
|
+
// 有默认值的(`--resource-type` → `record`)同样不必给。
|
|
112
|
+
if (shape !== undefined && shape.optional_value === true) return false;
|
|
113
|
+
return shape === undefined || shape.required === true;
|
|
114
|
+
})
|
|
115
|
+
.map((param) => param.flag);
|
|
116
|
+
|
|
117
|
+
// 源码事实那侧(`spec.flags` 来自 `args.rs` 的字段类型):
|
|
118
|
+
// **取值语义以它为准,但只对它声明的旗标生效**。
|
|
119
|
+
//
|
|
120
|
+
// 两件事必须分开:
|
|
121
|
+
// * **有哪些旗标** → 以**本命令的 `params`(散文)**为准。`spec.flags` 是整个
|
|
122
|
+
// 结构体的旗标表,而 `MemberArgs` 一个结构体承载 16 条命令;拿它当"这条命令
|
|
123
|
+
// 接受什么",`member --list --reason x` 也会被放过去(clap 按子命令校验,会拒)。
|
|
124
|
+
// * **吃不吃取值** → 以源码为准。散文里 `--offboard <USER_ID>` 可能被写成裸
|
|
125
|
+
// `--offboard`,照散文解析会把 `member --offboard usr_x` 读成"裸旗标 + 位置参数"。
|
|
126
|
+
for (const flag of flags.keys()) {
|
|
127
|
+
const shape = (entry.flags ?? {})[flag];
|
|
128
|
+
// 全局旗标一般由 `extractGlobals()` 提前摘走,不在这里定形状。
|
|
129
|
+
// **例外**:源码说它是取值型业务参数(`report rule --update --version 3` 的
|
|
130
|
+
// `--version`)—— 那就得让它有形状,否则"它吃不吃取值"永远是 false,
|
|
131
|
+
// `--version` 的取值会掉进位置参数、请求体里丢掉 version。
|
|
132
|
+
if (flag in GLOBAL_FLAGS && shape?.takes_value !== true) continue;
|
|
133
|
+
if (shape === undefined || shape.takes_value !== true) continue;
|
|
134
|
+
add(flag, {
|
|
135
|
+
takesValue: true,
|
|
136
|
+
// **有默认值的旗标也是可选**(clap 的 `default_value = "record"`):
|
|
137
|
+
// 散文里 `--resource-type <record>` 没写方括号,但"不给就用默认值"。
|
|
138
|
+
optional: shape.optional_value === true || shape.optional === true,
|
|
139
|
+
sourceShape: shape.optional_value === true ? 'optional' : 'required',
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// `args.rs` 的 `only(...)` 白名单:**它约束的是"业务参数",不是动作旗标**
|
|
144
|
+
// (`report --list` 的白名单里没有 `--list`,但 `--list` 当然是这条命令的一部分)。
|
|
145
|
+
// 所以判据是"在命令自己的旗标表里 **且**(在白名单里 **或** 是动作旗标)"。
|
|
146
|
+
// **同义词**:同族命令的另一个名字旗标(`member --set-manager` ↔ `--clear-manager`)。
|
|
147
|
+
// 它们与命令名里的旗标同等看待:可以写、算动作。
|
|
148
|
+
const synonyms = new Set(entry.synonym_flags ?? []);
|
|
149
|
+
for (const flag of synonyms) {
|
|
150
|
+
if (!flags.has(flag)) add(flag, { takesValue: false, optional: true });
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// 动作旗标 = 命令名里出现的那些 + 旗标表里不吃取值的那些。
|
|
154
|
+
// **命令名里出现的一律无条件允许**:`report --show <ID>` / `bitable table --show <ID>`
|
|
155
|
+
// 的动作旗标本身就吃取值,只按"不吃取值"判会把它们判成"不认识"。
|
|
156
|
+
const actionFlagNames = new Set([
|
|
157
|
+
...actionFlagNamesOf({ tokens, flags }),
|
|
158
|
+
...(entry.synonym_flags ?? []),
|
|
159
|
+
]);
|
|
160
|
+
const allowed = (entry.allowed_flags ?? []).filter((flag) => !(flag in GLOBAL_FLAGS));
|
|
161
|
+
const allowedNames = allowed.length > 0 ? new Set(allowed) : null;
|
|
162
|
+
|
|
163
|
+
return {
|
|
164
|
+
entry,
|
|
165
|
+
tokens,
|
|
166
|
+
flags,
|
|
167
|
+
allowedNames,
|
|
168
|
+
actionFlagNames,
|
|
169
|
+
// 必填 = 散文里必填的 + 命令名里的动作旗标(`org --move` 的 `--move`)。
|
|
170
|
+
requiredFlags: [
|
|
171
|
+
...new Set([...requiredFlags, ...tokens.filter((token) => token.startsWith('-'))]),
|
|
172
|
+
],
|
|
173
|
+
// 同义词从"被拒"里摘掉:`--clear-manager` 是**这条命令**的备选写法,
|
|
174
|
+
// 不是"别的命令的动作"(命令层用它来分流 `--set-manager` / `--clear-manager`)。
|
|
175
|
+
rejected: new Set((entry.rejected_flags ?? []).filter((flag) => !synonyms.has(flag))),
|
|
176
|
+
name: entry.name,
|
|
177
|
+
};
|
|
178
|
+
});
|
|
179
|
+
// 出现在**某条命令名里**的旗标(`org --archive`、`file --visibility`、`schema --agent`)
|
|
180
|
+
// 与"会吃掉下一个词的旗标",都是命令名收集要用的信息。
|
|
181
|
+
const nameFlags = new Set();
|
|
182
|
+
const requiredAnywhere = new Set();
|
|
183
|
+
const optionalAnywhere = new Set();
|
|
184
|
+
for (const command of commands) {
|
|
185
|
+
for (const token of command.tokens) {
|
|
186
|
+
if (token.startsWith('-')) nameFlags.add(token);
|
|
187
|
+
}
|
|
188
|
+
// 同义词也是"可以出现在名字位置"的旗标(`member --clear-manager`)。
|
|
189
|
+
for (const flag of command.entry.synonym_flags ?? []) nameFlags.add(flag);
|
|
190
|
+
for (const [flag, shape] of Object.entries(command.entry.flags ?? {})) {
|
|
191
|
+
if (shape.takes_value !== true) continue;
|
|
192
|
+
if (shape.optional_value === true) optionalAnywhere.add(flag);
|
|
193
|
+
else requiredAnywhere.add(flag);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
// 被**任何命令**声明成取值型业务参数的旗标名。目前只有 `--version`
|
|
197
|
+
// (顶层是 `-V/--version`,`report rule --update` 拿来当乐观锁版本号)。
|
|
198
|
+
const commandValueFlags = new Set();
|
|
199
|
+
for (const command of surface.commands) {
|
|
200
|
+
for (const [flag, shape] of Object.entries(command.flags ?? {})) {
|
|
201
|
+
if (shape.takes_value === true) commandValueFlags.add(flag);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// 名字收集时的"到此为止"判据:**只在任何命令里都吃取值**的旗标才算(`--visibility`)。
|
|
206
|
+
// 取值可省的(`--show`)不算 —— 否则 `file --show att_1` 会被读成
|
|
207
|
+
// `file --visibility <ID> --show` 那种形状(本项目的真实翻车点)。
|
|
208
|
+
const valueTaking = new Set(
|
|
209
|
+
[...requiredAnywhere].filter((flag) => !optionalAnywhere.has(flag)),
|
|
210
|
+
);
|
|
211
|
+
// 同族命令的名字旗标同义词(`--clear-manager` 之于 `member --set-manager`)。
|
|
212
|
+
// 键是旗标名,值是**它以命令名身份出现时对应的那条规格**。
|
|
213
|
+
const synonymFlags = new Set();
|
|
214
|
+
const synonymTargets = new Map();
|
|
215
|
+
// 索引后的候选名(`commands` 此时已经有 `tokens` 了)。
|
|
216
|
+
for (const command of commands) {
|
|
217
|
+
for (const flag of command.entry.synonym_flags ?? []) {
|
|
218
|
+
synonymFlags.add(flag);
|
|
219
|
+
// 把它换掉命令名里**最后一个**旗标,其余词不动 ——
|
|
220
|
+
// `member --set-manager` + `--clear-manager` → `member --clear-manager`。
|
|
221
|
+
const lastFlag = command.tokens.filter((token) => token.startsWith('-')).pop();
|
|
222
|
+
if (lastFlag === undefined) continue;
|
|
223
|
+
const replaced = [
|
|
224
|
+
...command.tokens.slice(0, command.tokens.lastIndexOf(lastFlag)),
|
|
225
|
+
flag,
|
|
226
|
+
].join(' ');
|
|
227
|
+
synonymTargets.set(replaced, { name: replaced, target: command.name });
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
return {
|
|
232
|
+
surface,
|
|
233
|
+
commands,
|
|
234
|
+
nameFlags,
|
|
235
|
+
synonymFlags,
|
|
236
|
+
synonymTargets,
|
|
237
|
+
valueTaking,
|
|
238
|
+
requiredValueFlags: valueTaking,
|
|
239
|
+
commandValueFlags,
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* 这个旗标在**这条命令**里吃不吃取值(`'required'` / `'optional'` / `null`)。
|
|
245
|
+
*
|
|
246
|
+
* 必须是**每条命令各算**:`--show` 在 `bitable table --show <ID>` 里吃取值,
|
|
247
|
+
* 在 `file --visibility <ID> --show` 里是裸旗标。用一张全局的"哪些旗标吃取值"表,
|
|
248
|
+
* 两种写法里必然有一种被解析错(本项目踩过)。
|
|
249
|
+
*/
|
|
250
|
+
function valueShapeOf(declared, flag) {
|
|
251
|
+
if (declared === undefined) return null;
|
|
252
|
+
if (declared.sourceShape === 'required' || declared.sourceShape === 'optional') {
|
|
253
|
+
return declared.sourceShape;
|
|
254
|
+
}
|
|
255
|
+
return null;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** 最近一次解析的 argv(仅供报错排序用;CLI 是单次进程,没有并发问题)。 */
|
|
259
|
+
let lastArgv = [];
|
|
260
|
+
|
|
261
|
+
let cachedIndex = null;
|
|
262
|
+
|
|
263
|
+
function surfaceIndex() {
|
|
264
|
+
if (cachedIndex === null) cachedIndex = indexSurface(commandSurface());
|
|
265
|
+
return cachedIndex;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** 全局旗标先摘出来(它们在命令行里的位置是任意的)。 */
|
|
269
|
+
function extractGlobals(argv) {
|
|
270
|
+
const globals = {
|
|
271
|
+
baseUrl: null,
|
|
272
|
+
profile: null,
|
|
273
|
+
json: false,
|
|
274
|
+
dryRun: false,
|
|
275
|
+
yes: false,
|
|
276
|
+
timeout: null,
|
|
277
|
+
verbose: false,
|
|
278
|
+
quiet: false,
|
|
279
|
+
noColor: false,
|
|
280
|
+
format: null,
|
|
281
|
+
help: false,
|
|
282
|
+
version: false,
|
|
283
|
+
};
|
|
284
|
+
const rest = [];
|
|
285
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
286
|
+
const token = argv[index];
|
|
287
|
+
const equal = token.startsWith('--') ? token.indexOf('=') : -1;
|
|
288
|
+
const name = equal > 0 ? token.slice(0, equal) : token;
|
|
289
|
+
const spec = GLOBAL_FLAGS[name];
|
|
290
|
+
if (!spec) {
|
|
291
|
+
rest.push(token);
|
|
292
|
+
continue;
|
|
293
|
+
}
|
|
294
|
+
// `--version` 是顶层 `-V/--version` 与某些命令的业务参数(`report rule --update
|
|
295
|
+
// --version 3` 是乐观锁版本号)**同名**。Rust/clap 那里命令级声明覆盖全局;
|
|
296
|
+
// 这里必须照做 —— 无条件把它当全局旗标,那 3 就掉进位置参数、请求体里没有 version。
|
|
297
|
+
// 判据:这个旗标被某条命令声明成取值型 **且** 后面确实跟着取值
|
|
298
|
+
// (只看前者的话,裸 `xinhe-oa-cli --version` 也会被当业务参数,版本号就打不出来)。
|
|
299
|
+
if (surfaceIndex().commandValueFlags.has(name)) {
|
|
300
|
+
// `--version=4` 与 `--version 4` 两种写法都要留给命令解析:
|
|
301
|
+
// 前者拆成两个 token(后面对命令来说是"取值"),否则 `4` 会连着 `=` 一起丢,
|
|
302
|
+
// 命令层拿不到版本号 —— `file --download --version 4` 会**静默下最新版**。
|
|
303
|
+
if (equal > 0) {
|
|
304
|
+
rest.push(name, token.slice(equal + 1));
|
|
305
|
+
continue;
|
|
306
|
+
}
|
|
307
|
+
const next = argv[index + 1];
|
|
308
|
+
if (next !== undefined && !next.startsWith('-')) {
|
|
309
|
+
rest.push(token);
|
|
310
|
+
continue;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
if (spec.bool) {
|
|
314
|
+
globals[spec.key] = true;
|
|
315
|
+
continue;
|
|
316
|
+
}
|
|
317
|
+
if (equal > 0) {
|
|
318
|
+
globals[spec.key] = token.slice(equal + 1);
|
|
319
|
+
continue;
|
|
320
|
+
}
|
|
321
|
+
const next = argv[index + 1];
|
|
322
|
+
if (next === undefined || next.startsWith('--')) {
|
|
323
|
+
throw CliError.usage(`\`${name}\` 需要一个取值`).withHint(
|
|
324
|
+
`例:xinhe-oa-cli ${name} <${spec.value ?? '值'}>`,
|
|
325
|
+
);
|
|
326
|
+
}
|
|
327
|
+
globals[spec.key] = next;
|
|
328
|
+
index += 1;
|
|
329
|
+
}
|
|
330
|
+
return { globals, rest };
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* 找命令(对齐 Rust 侧"最长命令名优先,再看哪条参数面能容下这些旗标")。
|
|
335
|
+
*
|
|
336
|
+
* 命令名的形状是 `<域> [<资源>] --<动作>`:**动作是名字的一部分**(`org --archive`、
|
|
337
|
+
* `file --visibility --set`)。所以"名字 token"不能只收第一个旗标之前的那几个词 ——
|
|
338
|
+
* 那样 `org --archive ou_1` 只会拿 `org` 去匹配,最后撞在 `org --tree 不认识 --archive`
|
|
339
|
+
* 上(本项目踩过的真实缺陷)。规则:逐个词看,**不带取值的旗标**算名字的一部分,
|
|
340
|
+
* 带取值的旗标从它开始就是参数了。
|
|
341
|
+
*/
|
|
342
|
+
function nameTokens(rest) {
|
|
343
|
+
const { commands, nameFlags, synonymFlags, valueTaking } = surfaceIndex();
|
|
344
|
+
const names = commands.map((command) => command.name.split(' '));
|
|
345
|
+
const words = [];
|
|
346
|
+
|
|
347
|
+
const wordsSoFar = () => words.map((word) => word.token);
|
|
348
|
+
|
|
349
|
+
/** 当前收到的这些词,**恰好**是某条命令名(收完了)。 */
|
|
350
|
+
const completesSomeName = () => {
|
|
351
|
+
const joined = wordsSoFar().join(' ');
|
|
352
|
+
return names.some((tokens) => tokens.join(' ') === joined);
|
|
353
|
+
};
|
|
354
|
+
|
|
355
|
+
/** 当前收到的这些词,还是某条命令名的前缀(还能继续收)。 */
|
|
356
|
+
const prefixesSomeName = () => {
|
|
357
|
+
const joined = wordsSoFar().join(' ');
|
|
358
|
+
return names.some(
|
|
359
|
+
(tokens) => tokens.join(' ').startsWith(joined) && tokens.length > words.length,
|
|
360
|
+
);
|
|
361
|
+
};
|
|
362
|
+
|
|
363
|
+
for (const [index, token] of rest.entries()) {
|
|
364
|
+
if (token.startsWith('-')) {
|
|
365
|
+
if (GLOBAL_FLAGS[token]) break;
|
|
366
|
+
if (!nameFlags.has(token) && !synonymFlags.has(token)) break;
|
|
367
|
+
words.push({ token, index, flag: true });
|
|
368
|
+
// **命令名收完就停**:`org --explain-scope --user usr_1` 里 `--user` 是参数,
|
|
369
|
+
// 继续往名字里塞会让参数解析跳过它、取值掉进位置参数(本项目刚踩的坑)。
|
|
370
|
+
if (completesSomeName() && !prefixesSomeName()) break;
|
|
371
|
+
// 取值型的名字旗标(`--delete <ID>`):它的取值**也要收进来**(标成
|
|
372
|
+
// `flag: false`),否则 `consumeCommandPrefix` 找不到它。
|
|
373
|
+
// 判据只看"后面那个词是不是旗标":`--clear-manager --user usr_1` 里
|
|
374
|
+
// `--user` 是旗标,不会被误当成取值。
|
|
375
|
+
if (valueTaking.has(token)) {
|
|
376
|
+
const value = rest[index + 1];
|
|
377
|
+
if (value !== undefined && !value.startsWith('-')) {
|
|
378
|
+
words.push({ token: value, index: index + 1, flag: false });
|
|
379
|
+
}
|
|
380
|
+
break;
|
|
381
|
+
}
|
|
382
|
+
continue;
|
|
383
|
+
}
|
|
384
|
+
words.push({ token, index, flag: false });
|
|
385
|
+
if (completesSomeName() && !prefixesSomeName()) break;
|
|
386
|
+
}
|
|
387
|
+
return words;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* 命令名吃掉了 `words` 里的前几个词,剩下的才是命令参数。
|
|
392
|
+
*
|
|
393
|
+
* 关键点:**只有真正属于命令名的那几个旗标词才算"被吃掉"**。`org --archive ou_1`
|
|
394
|
+
* 里 `--archive` 既是名字的一部分、又要被解析成动作旗标 —— 直接按词数切,
|
|
395
|
+
* 命令参数就从 `ou_1` 之后开始了,解析器于是抱怨"必须带 --archive"(本项目踩过)。
|
|
396
|
+
*/
|
|
397
|
+
function consumeCommandPrefix(words, candidateName) {
|
|
398
|
+
const nameTokensList = candidateName.split(' ');
|
|
399
|
+
const actions = [];
|
|
400
|
+
const consumed = new Set();
|
|
401
|
+
let index = 0;
|
|
402
|
+
for (const word of words) {
|
|
403
|
+
if (nameTokensList[index] === word.token) {
|
|
404
|
+
// **只吃非旗标的名字词**(`file` / `bitable` / `table`):它们是命令名的骨架,
|
|
405
|
+
// 不该出现在位置参数里。
|
|
406
|
+
if (!word.flag) consumed.add(word.index);
|
|
407
|
+
// 旗标(`--visibility` / `--delete` / `--show` / `--tree`…)一律**留在 tokens 里**,
|
|
408
|
+
// 由 `parseCommandArgs` 的主循环按"这条命令的旗标表"统一解析 ——
|
|
409
|
+
// 那样取值、布尔、可省取值三种形状都只有一处判据。
|
|
410
|
+
// 只有"名字末尾的裸旗标"(`file --visibility --show` 的 `--show`)需要在这里
|
|
411
|
+
// 记一笔:它不是名字 token、但要算进这条命令的动作里。
|
|
412
|
+
index += 1;
|
|
413
|
+
if (index === nameTokensList.length) {
|
|
414
|
+
const next = words[words.indexOf(word) + 1];
|
|
415
|
+
if (next !== undefined && next.flag) actions.push(next.token);
|
|
416
|
+
break;
|
|
417
|
+
}
|
|
418
|
+
continue;
|
|
419
|
+
}
|
|
420
|
+
if (!word.flag) break;
|
|
421
|
+
}
|
|
422
|
+
return { consumed, actions };
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* 解析命令行。
|
|
427
|
+
*
|
|
428
|
+
* 返回 `{ globals, command, args, positional }`:
|
|
429
|
+
* * `command` 是命中的规格条目(`name` / `risk` / `requires_yes` / `endpoints`);
|
|
430
|
+
* * `args` 是旗标 → 取值(布尔旗标为 `true`,可重复的是数组);
|
|
431
|
+
* * `positional` 是位置参数(只有 `schema [COMMAND]` 用得上)。
|
|
432
|
+
*/
|
|
433
|
+
/**
|
|
434
|
+
* 找出可能的命令:拿 argv 开头那些词(含命令名里的旗标)去跟规格里的命令名对齐,
|
|
435
|
+
* 取"最长的那段前缀"命中的全部候选。
|
|
436
|
+
*
|
|
437
|
+
* 例:`file --visibility att_1 --show` → 前缀 `file --visibility` 命中两条
|
|
438
|
+
* (`file --visibility --show` / `--set`),由后面的排序决定是哪一条。
|
|
439
|
+
*/
|
|
440
|
+
function candidatesFor(rest) {
|
|
441
|
+
const { commands } = surfaceIndex();
|
|
442
|
+
|
|
443
|
+
const words = nameTokens(rest);
|
|
444
|
+
const tokens = words.map((word) => word.token);
|
|
445
|
+
const { synonymTargets } = surfaceIndex();
|
|
446
|
+
for (let take = tokens.length; take >= 1; take -= 1) {
|
|
447
|
+
const prefix = tokens.slice(0, take).join(' ');
|
|
448
|
+
// **同义词旗标**(`member --clear-manager`):没有哪条命令叫这个名字,
|
|
449
|
+
// 它对应的其实是 `member --set-manager`。查表换过去、按目标名取候选 ——
|
|
450
|
+
// 这一步必须在"名字前缀匹配"**之前**:同义词前缀永远匹配不上任何命令名,
|
|
451
|
+
// 放在后面会被 `continue` 跳过(本项目刚踩的坑)。
|
|
452
|
+
const synonym = synonymTargets.get(prefix);
|
|
453
|
+
if (synonym !== undefined) {
|
|
454
|
+
const target = commands.filter((command) => command.name === synonym.target);
|
|
455
|
+
if (target.length > 0) return { matched: target, words };
|
|
456
|
+
}
|
|
457
|
+
const matched = commands.filter(
|
|
458
|
+
(command) => command.name === prefix || command.name.startsWith(`${prefix} `),
|
|
459
|
+
);
|
|
460
|
+
if (matched.length === 0) continue;
|
|
461
|
+
return { matched, words };
|
|
462
|
+
}
|
|
463
|
+
return { matched: [], words };
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
export function parseArgv(argv) {
|
|
467
|
+
const { globals, rest } = extractGlobals(argv);
|
|
468
|
+
// `--help` 先于一切取值的合法性检查:clap 也是这样 ——
|
|
469
|
+
// `login --phone --help` 该打出帮助,而不是抱怨"`--phone` 需要一个取值"。
|
|
470
|
+
if (globals.help) return { globals, command: null, args: {}, positional: [] };
|
|
471
|
+
if (globals.version && rest.length === 0) return { globals, command: null, args: {}, positional: [] };
|
|
472
|
+
if (rest.length === 0) {
|
|
473
|
+
throw CliError.usage('缺命令').withHint(
|
|
474
|
+
'看全部命令:xinhe-oa-cli schema;最短上手:xinhe-oa-cli login --phone <号码>',
|
|
475
|
+
);
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
const { matched, words } = candidatesFor(rest);
|
|
479
|
+
if (matched.length === 0) {
|
|
480
|
+
const domain = rest.find((token) => !token.startsWith('-'));
|
|
481
|
+
if (domain === undefined) {
|
|
482
|
+
throw CliError.usage(`不认识的旗标 ${JSON.stringify(rest[0])}`).withHint(
|
|
483
|
+
'看全部命令:xinhe-oa-cli schema',
|
|
484
|
+
);
|
|
485
|
+
}
|
|
486
|
+
throw CliError.usage(`不认识的命令 ${JSON.stringify(domain)}`).withHint(
|
|
487
|
+
'看全部命令:xinhe-oa-cli schema',
|
|
488
|
+
);
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
const errors = [];
|
|
492
|
+
const parsedCandidates = [];
|
|
493
|
+
lastArgv = rest;
|
|
494
|
+
for (const candidate of matched) {
|
|
495
|
+
// 每条候选名吃掉的名字长度不同(`org --move` 吃两个词、`org` 只吃一个词),
|
|
496
|
+
// 所以要**按候选**算命令参数;而且必须切 `rest` 而不是 `words` ——
|
|
497
|
+
// 带取值的旗标不在 `words` 里,按词数切会把它们的取值丢掉。
|
|
498
|
+
// 命令名吃掉的是**具体的几个 token**(不是"前 N 个"):`file --show att_1` 里
|
|
499
|
+
// `--show` 属于名字、`att_1` 是它的取值,按界限切就会把取值一起切掉。
|
|
500
|
+
const { consumed, actions } = consumeCommandPrefix(words, candidate.name);
|
|
501
|
+
const commandArgs = rest.filter((_, position) => !consumed.has(position));
|
|
502
|
+
try {
|
|
503
|
+
const parsed = parseCommandArgs(candidate, commandArgs, globals, actions);
|
|
504
|
+
if (parsed === null) continue; // 缺取值:留给同命令的其他候选
|
|
505
|
+
parsedCandidates.push({ candidate, ...parsed });
|
|
506
|
+
} catch (err) {
|
|
507
|
+
if (err instanceof CliError) err.candidateName = candidate.name;
|
|
508
|
+
errors.push(err);
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
if (parsedCandidates.length > 0) {
|
|
512
|
+
// 变体选择(**只按"名字里的旗标"判**,不看参数面容不容得下 ——
|
|
513
|
+
// 同一个结构体的兄弟命令参数面彼此容得下,那是误判的来源):
|
|
514
|
+
// 0) **名字里的动作旗标被调用方写上了**(`attachment --delete <ID>` 的 `--delete`)
|
|
515
|
+
// —— 这是最硬的信号,写了它就是这条命令,权重给足;
|
|
516
|
+
// 1) 名字里缺的动作旗标越少越好;
|
|
517
|
+
// 2) 名字与 argv 前缀吻合越长越好(`file --visibility --show` 胜过 `file --show`);
|
|
518
|
+
// 3) 用上的旗标越多越具体;4) 可选旗标占比越低越专用。
|
|
519
|
+
parsedCandidates.sort((left, right) => {
|
|
520
|
+
const byAction = matchedNameFlags(right) * 1000 - matchedNameFlags(left) * 1000;
|
|
521
|
+
if (byAction !== 0) return byAction;
|
|
522
|
+
const byActionMissing = missingActionCount(left) * 100 - missingActionCount(right) * 100;
|
|
523
|
+
if (byActionMissing !== 0) return byActionMissing;
|
|
524
|
+
const byRequiredMissing = missingRequiredCount(left) * 10 - missingRequiredCount(right) * 10;
|
|
525
|
+
if (byRequiredMissing !== 0) return byRequiredMissing;
|
|
526
|
+
const byName = nameMatchLength(right) - nameMatchLength(left);
|
|
527
|
+
if (byName !== 0) return byName;
|
|
528
|
+
const byFlags = providedFlagCount(right) - providedFlagCount(left);
|
|
529
|
+
if (byFlags !== 0) return byFlags;
|
|
530
|
+
return optionalRatio(left.candidate) - optionalRatio(right.candidate);
|
|
531
|
+
});
|
|
532
|
+
// **变体只挑"动作齐了"的那些**:`file --visibility <ID> --set internal` 同时能被
|
|
533
|
+
// `file --visibility --show` 与 `… --set` 容下,但前者要求 `--show`(没给就把它排除)。
|
|
534
|
+
// 少了这道过滤,`attachment --delete <ID>` 会被判给 `attachment --upload`
|
|
535
|
+
// (两者同属一个结构体、参数面彼此容得下),错误文案于是指到别的命令上。
|
|
536
|
+
const complete = parsedCandidates.filter((item) => missingActionCount(item) === 0);
|
|
537
|
+
const best = (complete.length > 0 ? complete : parsedCandidates)[0];
|
|
538
|
+
return { globals, command: best.candidate, args: best.args, positional: best.positional };
|
|
539
|
+
}
|
|
540
|
+
// 没有一条参数面能容下:报**最贴切**那条命令的错。
|
|
541
|
+
// 判据:动作旗标给得越多越贴切(`attachment --delete <ID>` 的错要来自
|
|
542
|
+
// `attachment --delete`,而不是同结构体的 `attachment --upload`)。
|
|
543
|
+
const ranked = [...errors].sort(
|
|
544
|
+
(left, right) => actionHitsOf(right) - actionHitsOf(left),
|
|
545
|
+
);
|
|
546
|
+
throw ranked[0] ?? CliError.usage('参数不对');
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/**
|
|
550
|
+
* 调用方写的旗标里,有几个出现在这条候选的**命令名**里。
|
|
551
|
+
*
|
|
552
|
+
* 这是"他要的是哪个动作"最直接的回答:`org --move ou_1 --parent ou_2` 里
|
|
553
|
+
* argv 有 `--move`,而 `org --move` 的名字里也有它 —— 1 分;`org --create` 是 0 分。
|
|
554
|
+
*/
|
|
555
|
+
function matchedNameFlags(parsed) {
|
|
556
|
+
let hits = 0;
|
|
557
|
+
for (const token of parsed.candidate.tokens) {
|
|
558
|
+
if (!token.startsWith('-')) continue;
|
|
559
|
+
const declared = parsed.candidate.flags.get(token);
|
|
560
|
+
const written = declared?.takesValue
|
|
561
|
+
? parsed.args[token] !== undefined && parsed.args[token] !== true
|
|
562
|
+
: parsed.args[token] === true;
|
|
563
|
+
if (written) hits += 1;
|
|
564
|
+
}
|
|
565
|
+
return hits;
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
/**
|
|
569
|
+
* 这条候选缺了几个**必填旗标**(散文里必填的 + 命令名里的动作旗标)。
|
|
570
|
+
*
|
|
571
|
+
* "命令名里的动作旗标"要算进去:`attachment --upload` 的 `--upload` 在散文里被写成
|
|
572
|
+
* 裸旗标(必填),但它其实写在命令名里、不会出现在 `args` 里 —— 不算进去,
|
|
573
|
+
* 就会在一条**别的**候选上抛"`attachment --upload` 必须带 --upload"。
|
|
574
|
+
*/
|
|
575
|
+
function missingRequiredCount(parsed) {
|
|
576
|
+
let missing = 0;
|
|
577
|
+
for (const flag of parsed.candidate.requiredFlags) {
|
|
578
|
+
if (parsed.args[flag] === undefined) missing += 1;
|
|
579
|
+
}
|
|
580
|
+
for (const token of parsed.candidate.tokens) {
|
|
581
|
+
if (!token.startsWith('-')) continue;
|
|
582
|
+
const declared = parsed.candidate.flags.get(token);
|
|
583
|
+
if (declared?.takesValue) {
|
|
584
|
+
if (parsed.args[token] === undefined || parsed.args[token] === true) missing += 1;
|
|
585
|
+
} else if (parsed.args[token] !== true) {
|
|
586
|
+
missing += 1;
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
return missing;
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
/** 这条候选的**动作**齐不齐(只看动作旗标,不看普通必填参数)。 */
|
|
593
|
+
function missingActionCount(parsed) {
|
|
594
|
+
let missing = 0;
|
|
595
|
+
for (const token of parsed.candidate.tokens) {
|
|
596
|
+
if (!token.startsWith('-')) continue;
|
|
597
|
+
const declared = parsed.candidate.flags.get(token);
|
|
598
|
+
if (declared?.takesValue) {
|
|
599
|
+
if (parsed.args[token] === undefined || parsed.args[token] === true) missing += 1;
|
|
600
|
+
} else if (parsed.args[token] !== true) {
|
|
601
|
+
missing += 1;
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
return missing;
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
/**
|
|
608
|
+
* 报错排序用:这条错误对应的候选,动作旗标被调用方写了几个。
|
|
609
|
+
*
|
|
610
|
+
* `errors` 里存的是 `CliError`,它身上带着出错时那条候选的名字;
|
|
611
|
+
* 名字里的旗标有几个出现在原始 argv 里,就是几个"动作命中"。
|
|
612
|
+
*/
|
|
613
|
+
function actionHitsOf(error) {
|
|
614
|
+
const name = error?.candidateName ?? '';
|
|
615
|
+
let hits = 0;
|
|
616
|
+
for (const token of name.split(' ')) {
|
|
617
|
+
if (token.startsWith('-') && lastArgv.includes(token)) hits += 1;
|
|
618
|
+
}
|
|
619
|
+
return hits;
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
/** 命令名与 argv 前缀吻合到第几个词(越长说明这条候选越贴切)。 */
|
|
623
|
+
function nameMatchLength(parsed) {
|
|
624
|
+
let length = 0;
|
|
625
|
+
for (const token of parsed.candidate.tokens) {
|
|
626
|
+
if (parsed.args[token] !== undefined || !token.startsWith('-')) length += 1;
|
|
627
|
+
else break;
|
|
628
|
+
}
|
|
629
|
+
return length;
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
/** 这条候选命令"被用上了几个旗标"(越多越具体)。 */
|
|
633
|
+
function providedFlagCount(parsed) {
|
|
634
|
+
return Object.keys(parsed.args).length;
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
/**
|
|
638
|
+
* 候选**自己声明的**旗标里,有几个被调用方用上了。
|
|
639
|
+
*
|
|
640
|
+
* 这是"哪条命令更贴切"最直接的信号:`member --offboard usr_x --reason 离职 --yes`
|
|
641
|
+
* 同时能被 `--list` / `--grants` / `--transfer-list` 容下(它们只看"有没有不认识的旗标"),
|
|
642
|
+
* 但只有 `--offboard` 自己声明的旗标全被用上了。
|
|
643
|
+
*/
|
|
644
|
+
function ownMatchCount(parsed) {
|
|
645
|
+
let hits = 0;
|
|
646
|
+
for (const flag of parsed.candidate.flags.keys()) {
|
|
647
|
+
// 被拒的兄弟动作旗标不算"命中":`member --offboard usr_x` 里 `--offboard`
|
|
648
|
+
// 在 `member --list` 的旗标表里也存在,但那只是同一个结构体的兄弟动作。
|
|
649
|
+
if (parsed.candidate.rejected.has(flag)) continue;
|
|
650
|
+
// **只算动作旗标**(命令名里出现的那些)。把可选参数也算进来会让
|
|
651
|
+
// "参数恰好同形"的候选追平真实候选,最后靠名字长度分胜负 ——
|
|
652
|
+
// `org --move <ID> --parent <ID>` 就是这么被判给 `org --explain-scope` 的。
|
|
653
|
+
if (parsed.actionFlags === undefined) {
|
|
654
|
+
parsed.actionFlags = new Set(
|
|
655
|
+
parsed.candidate.entry.params
|
|
656
|
+
.filter((param) => param.flag && !param.optional && param.value === undefined)
|
|
657
|
+
.map((param) => param.flag),
|
|
658
|
+
);
|
|
659
|
+
}
|
|
660
|
+
if (!parsed.actionFlags.has(flag)) continue;
|
|
661
|
+
if (parsed.args[flag] !== undefined) hits += 1;
|
|
662
|
+
}
|
|
663
|
+
return hits;
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
/** 可选旗标占比:越小越"专用"(`config --show` 是 0,`config --list` 是 1/4)。 */
|
|
667
|
+
function optionalRatio(candidate) {
|
|
668
|
+
const total = candidate.flags.size;
|
|
669
|
+
if (total === 0) return 0;
|
|
670
|
+
let optional = 0;
|
|
671
|
+
for (const spec of candidate.flags.values()) if (spec.optional) optional += 1;
|
|
672
|
+
return optional / total;
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
/** 动作旗标个数(`--list` / `--show` 这类;用来打破 `config --list` 与 `config --show` 的平局)。 */
|
|
676
|
+
function actionCount(candidate) {
|
|
677
|
+
return candidate.requiredFlags.length;
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
function parseCommandArgs(candidate, tokens, globals, namedActions = []) {
|
|
681
|
+
const args = {};
|
|
682
|
+
const positional = [];
|
|
683
|
+
// 命令名里的旗标(`--delete <ID>` / `--visibility <ID> --show` / `org --tree`):
|
|
684
|
+
// 它们的取值由 `consumeCommandPrefix` 连同旗标一起交过来,这里**先**记进 `args`,
|
|
685
|
+
// 并在主循环里**跳过**这些旗标 —— 一处处理,不留第二次机会去误判。
|
|
686
|
+
// (以前是"注入一次、主循环再解析一次",于是取值可能被覆盖成 `true`。)
|
|
687
|
+
const namedTokens = new Set();
|
|
688
|
+
for (const flag of namedActions) {
|
|
689
|
+
if (!candidate.flags.has(flag) || candidate.rejected.has(flag)) continue;
|
|
690
|
+
namedTokens.add(flag);
|
|
691
|
+
if (args[flag] === undefined) args[flag] = true;
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
for (let index = 0; index < tokens.length; index += 1) {
|
|
695
|
+
const token = tokens[index];
|
|
696
|
+
if (!token.startsWith('-')) {
|
|
697
|
+
positional.push(token);
|
|
698
|
+
continue;
|
|
699
|
+
}
|
|
700
|
+
const equal = token.startsWith('--') ? token.indexOf('=') : -1;
|
|
701
|
+
const name = equal > 0 ? token.slice(0, equal) : token;
|
|
702
|
+
// 命令名里的旗标已经处理过了(见函数开头)。
|
|
703
|
+
if (namedTokens.has(name)) continue;
|
|
704
|
+
if (namedTokens.has(name)) continue;
|
|
705
|
+
if (GLOBAL_FLAGS[name]) {
|
|
706
|
+
// 全局旗标已经摘过了;走到这里说明它出现在命令名之后,按原样吃掉。
|
|
707
|
+
// (`--profile` 之类允许写在命令后面:`me --profile staging`。)
|
|
708
|
+
//
|
|
709
|
+
// **例外**:它同时是这条命令的取值型业务参数(`--version`)——
|
|
710
|
+
// 那就得把取值留下,否则请求体里会少一个字段。
|
|
711
|
+
const commandShape = candidate.flags.get(name);
|
|
712
|
+
if (commandShape !== undefined && commandShape.takesValue) {
|
|
713
|
+
let value = equal > 0 ? token.slice(equal + 1) : undefined;
|
|
714
|
+
if (value === undefined && tokens[index + 1] !== undefined && !tokens[index + 1].startsWith('-')) {
|
|
715
|
+
value = tokens[index + 1];
|
|
716
|
+
index += 1;
|
|
717
|
+
}
|
|
718
|
+
args[name] = value ?? true;
|
|
719
|
+
continue;
|
|
720
|
+
}
|
|
721
|
+
const spec = GLOBAL_FLAGS[name];
|
|
722
|
+
if (spec.bool) continue;
|
|
723
|
+
if (equal > 0) continue;
|
|
724
|
+
if (tokens[index + 1] !== undefined) index += 1;
|
|
725
|
+
continue;
|
|
726
|
+
}
|
|
727
|
+
const declared = candidate.flags.get(name);
|
|
728
|
+
// 非全局旗标必须在**这条命令**的声明里:`member --list --reason x` 里的 `--reason`
|
|
729
|
+
// 是兄弟命令的旗标,Rust 的 clap 会按子命令拒掉它;这里不单独判,
|
|
730
|
+
// 会和 `rejected` 一起在下面的 "严格参数面" 里统一处理。
|
|
731
|
+
if (!declared || candidate.rejected.has(name)) {
|
|
732
|
+
// 提示里给**这条命令**的域名(`config --show` 报错时提示看 `config` 那一组),
|
|
733
|
+
// 不要拼一个"可能不存在"的命令名出来 —— 现场照抄一条跑不通的命令最伤人。
|
|
734
|
+
const domain = candidate.name.split(' ').slice(0, 2).join(' ');
|
|
735
|
+
throw CliError.usage(`${candidate.name} 不认识 ${name}`).withHint(
|
|
736
|
+
`跑 \`xinhe-oa-cli schema "${domain}"\` 看这一组命令的参数`,
|
|
737
|
+
);
|
|
738
|
+
}
|
|
739
|
+
let value = equal > 0 ? token.slice(equal + 1) : undefined;
|
|
740
|
+
const shape = valueShapeOf(declared, name);
|
|
741
|
+
if (value === undefined && shape !== null) {
|
|
742
|
+
// `--show` 这类"取值可省"的旗标:后面跟着的不是旗标就吃掉它当取值,否则当裸旗标。
|
|
743
|
+
const mayOmit = shape === 'optional';
|
|
744
|
+
const next = tokens[index + 1];
|
|
745
|
+
if (next !== undefined && !next.startsWith('-')) {
|
|
746
|
+
value = next;
|
|
747
|
+
index += 1;
|
|
748
|
+
} else if (!mayOmit) {
|
|
749
|
+
throw CliError.usage(`\`${name}\` 需要一个取值`).withHint(
|
|
750
|
+
`例:${name} <${declared.placeholder || '值'}>`,
|
|
751
|
+
);
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
if (value === undefined) {
|
|
755
|
+
// 裸旗标:布尔 true;但可重复的旗标要能给"出现多次"记账。
|
|
756
|
+
args[name] = declared.variadic && Array.isArray(args[name]) ? args[name] : true;
|
|
757
|
+
continue;
|
|
758
|
+
}
|
|
759
|
+
if (declared.variadic || args[name] !== undefined) {
|
|
760
|
+
const list = Array.isArray(args[name]) ? args[name] : args[name] === undefined ? [] : [args[name]];
|
|
761
|
+
list.push(value);
|
|
762
|
+
args[name] = list;
|
|
763
|
+
continue;
|
|
764
|
+
}
|
|
765
|
+
args[name] = value;
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
// 必填旗标。**同义词在场的算满足**:`member --clear-manager` 就是
|
|
769
|
+
// `member --set-manager <ID>` 那个动作的另一种写法(Rust 的 `action()` 判"恰好一个"),
|
|
770
|
+
// 不能因为 `--set-manager` 没出现就报"必须带 --set-manager"。
|
|
771
|
+
const satisfiedBySynonym = new Set();
|
|
772
|
+
for (const [flag, value] of Object.entries(args)) {
|
|
773
|
+
if (value === undefined) continue;
|
|
774
|
+
const command = candidate.entry.synonym_flags?.includes(flag) ? candidate.name : null;
|
|
775
|
+
if (command !== null) {
|
|
776
|
+
for (const token of candidate.tokens) {
|
|
777
|
+
if (token.startsWith('-')) satisfiedBySynonym.add(token);
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
for (const flag of candidate.requiredFlags) {
|
|
782
|
+
if (satisfiedBySynonym.has(flag)) continue;
|
|
783
|
+
if (args[flag] === undefined) {
|
|
784
|
+
const declared = candidate.flags.get(flag);
|
|
785
|
+
throw CliError.usage(`${candidate.name} 必须带 ${flag}`).withHint(
|
|
786
|
+
`例:${flag} <${declared?.placeholder || '值'}>`,
|
|
787
|
+
);
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
// **严格参数面**:这条命令声明之外的旗标一律拒。
|
|
792
|
+
// 少了这道检查,`member --list --reason x` 会被"容下"(`--reason` 是 `MemberArgs`
|
|
793
|
+
// 的字段),于是多条命令同时命中、解析结果变得看运气 —— 而 Rust 的 clap
|
|
794
|
+
// 是按子命令校验的,会直接报错。
|
|
795
|
+
for (const flag of Object.keys(args)) {
|
|
796
|
+
if (flag in GLOBAL_FLAGS) continue;
|
|
797
|
+
const inOwnSurface = candidate.flags.has(flag) && !candidate.rejected.has(flag);
|
|
798
|
+
const inAllowList =
|
|
799
|
+
candidate.allowedNames === null ||
|
|
800
|
+
candidate.allowedNames.has(flag) ||
|
|
801
|
+
candidate.actionFlagNames.has(flag);
|
|
802
|
+
if (inOwnSurface && inAllowList) continue;
|
|
803
|
+
const domain = candidate.name.split(' ').slice(0, 2).join(' ');
|
|
804
|
+
throw CliError.usage(`${candidate.name} 不认识 ${flag}`).withHint(
|
|
805
|
+
`跑 \`xinhe-oa-cli schema "${domain}"\` 看这一组命令的参数`,
|
|
806
|
+
);
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
// 动作互斥:同一条命令的多个"动作旗标"(规格里带值的那些,如 `--list` / `--create` /
|
|
810
|
+
// `--archive`)一次只能给一个 —— 与 Rust 的 clap `ArgGroup(multiple=false)` 同义。
|
|
811
|
+
const actionFlags = candidate.entry.params
|
|
812
|
+
.filter((param) => param.flag && !param.optional && param.value === undefined)
|
|
813
|
+
.map((param) => param.flag);
|
|
814
|
+
const picked = actionFlags.filter(
|
|
815
|
+
(flag) => args[flag] === true && !candidate.flags.get(flag)?.takesValue,
|
|
816
|
+
);
|
|
817
|
+
if (picked.length > 1) {
|
|
818
|
+
throw CliError.usage(
|
|
819
|
+
`\`${candidate.name.split(' ').slice(0, 2).join(' ')}\` 一次只能给一个动作,收到 ${picked.length} 个:${picked.join(' / ')}`,
|
|
820
|
+
);
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
// **`--yes` 闸门不在这里判**:各域 handler 都调 `runtime.requireYes`,
|
|
824
|
+
// 而 Rust 的 `what` 文案是**每处调用自己写的**(`file --visibility --set(一次可能
|
|
825
|
+
// 放开一批人或收掉一批人)`),解析层拼不出逐字一样的 hint。
|
|
826
|
+
// 在这里抢答会让"缺 --yes"的 error JSON 与 Rust 不一致(退出码 2 与零请求仍然对)。
|
|
827
|
+
// `schema` 的 `requires_yes` 仍用于文档与校验前的提示(见 `missingActionCount`)。
|
|
828
|
+
|
|
829
|
+
return { args, positional, actionFlagNames: new Set(actionFlags) };
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
/**
|
|
833
|
+
* 这条命令的**动作旗标**名:命令名里出现的那些(`org --archive` 的 `--archive`)
|
|
834
|
+
* 加上旗标表里不吃取值的那些(`config --show` 的 `--show`)。
|
|
835
|
+
*/
|
|
836
|
+
function actionFlagNamesOf(candidate) {
|
|
837
|
+
const names = new Set(candidate.tokens.filter((token) => token.startsWith('-')));
|
|
838
|
+
for (const [flag, spec] of candidate.flags) {
|
|
839
|
+
if (!spec.takesValue) names.add(flag);
|
|
840
|
+
}
|
|
841
|
+
return [...names];
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
/** 取旗标值(`--flag <v>`):没给就是 null;给了但写成裸旗标则给空串。 */
|
|
845
|
+
export function flagValue(args, flag) {
|
|
846
|
+
const value = args[flag];
|
|
847
|
+
if (value === undefined || value === true) return value === true ? '' : null;
|
|
848
|
+
if (Array.isArray(value)) return value[value.length - 1] ?? null;
|
|
849
|
+
return value;
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
/** 取可重复旗标(`[--store x]...`):没给就是空数组。 */
|
|
853
|
+
export function flagList(args, flag) {
|
|
854
|
+
const value = args[flag];
|
|
855
|
+
if (value === undefined || value === true) return [];
|
|
856
|
+
return Array.isArray(value) ? value : [value];
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
/** 布尔旗标是否给了。 */
|
|
860
|
+
export function flagOn(args, flag) {
|
|
861
|
+
return args[flag] !== undefined;
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
/**
|
|
865
|
+
* 取值转整数(供 `--limit` / `--workers` / `--timeout` 这类用)。
|
|
866
|
+
*
|
|
867
|
+
* 越界与非法都在**本地**判死(退出码 2、不发请求)—— 这正是 Rust 版的做法:
|
|
868
|
+
* 服务端 `Query(le=1000)` 的 422 不该原样丢给调用方。
|
|
869
|
+
*/
|
|
870
|
+
export function intFlag(args, flag, { min = null, max = null, fallback = null } = {}) {
|
|
871
|
+
const raw = flagValue(args, flag);
|
|
872
|
+
if (raw === null) return fallback;
|
|
873
|
+
const text = String(raw).trim();
|
|
874
|
+
if (!/^-?\d+$/.test(text)) {
|
|
875
|
+
throw CliError.usage(`\`${flag}\` 要一个整数,收到 ${JSON.stringify(raw)}`);
|
|
876
|
+
}
|
|
877
|
+
const value = Number.parseInt(text, 10);
|
|
878
|
+
if (min !== null && value < min) {
|
|
879
|
+
throw CliError.usage(`\`${flag}\` 不能小于 ${min}(收到 ${value})`);
|
|
880
|
+
}
|
|
881
|
+
if (max !== null && value > max) {
|
|
882
|
+
throw CliError.usage(`\`${flag}\` 不能大于 ${max}(收到 ${value})`);
|
|
883
|
+
}
|
|
884
|
+
return value;
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
/** 把 `--type 列=类型` 这类 `K=V` 解析成 `[K, V]`(右侧可以为空)。 */
|
|
888
|
+
export function splitPair(raw, flag) {
|
|
889
|
+
const index = String(raw).indexOf('=');
|
|
890
|
+
if (index <= 0) {
|
|
891
|
+
throw CliError.usage(`\`${flag}\` 要写成 K=V,收到 ${JSON.stringify(raw)}`);
|
|
892
|
+
}
|
|
893
|
+
return [String(raw).slice(0, index), String(raw).slice(index + 1)];
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
/** 规格里所有命令名(`schema` 用)。 */
|
|
897
|
+
export function commandNames() {
|
|
898
|
+
return commandSurface().commands.map((entry) => entry.name);
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
export { Kind };
|