@cnbcool/cnb-api-generate 2.17.1 → 2.17.2

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.
@@ -1,5 +1,5 @@
1
1
  import { Command } from 'commander';
2
- import { showShort, resolveShortcut } from '../shortcuts';
2
+ import { showShort, resolveShortcut, mergeShortcutParams } from '../shortcuts';
3
3
  import { handleUpload, handleIssueCreateUpload, type UploadPathParams } from '../utils/upload';
4
4
  import { diagnoseBuild } from './build-diagnostics';
5
5
  import { analyzeBuildTiming } from './build-timing';
@@ -76,31 +76,24 @@ export async function executeAction(
76
76
  if (shortcut) {
77
77
  params.module = shortcut.module;
78
78
  params.tool = shortcut.tool;
79
-
80
- for (const [key, value] of Object.entries(shortcut.autoPath)) {
81
- if (!params[key]) {
82
- params[key] = value;
83
- }
84
- }
85
-
86
- if (shortcut.autoData) {
87
- for (const [key, value] of Object.entries(shortcut.autoData)) {
88
- if (params[key] === undefined) {
89
- params[key] = value;
90
- }
91
- }
92
- }
79
+ // 用户显式传入的 --repo / --number 优先,未传时用环境变量自动注入的 autoPath 兜底
80
+ mergeShortcutParams(params, shortcut);
93
81
  }
94
82
 
95
83
  if (!params.tool) {
96
84
  parentCmd.help();
97
85
  }
98
86
 
99
- // 上传命令走独立分支:只需 --file + autoPath,跳过 formatParams 常规流程
87
+ // 上传命令走独立分支:只需 --file + path 参数,跳过 formatParams 常规流程。
88
+ // params 中已合并好 autoPath:用户显式传入的 --repo / --number 优先,
89
+ // 未传时由环境变量兜底,因此这里直接取 params 即可。
100
90
  if (shortcut?.upload) {
101
91
  const isVerbose = !!opts.verbose;
102
92
  const toolFunction = loadToolFunction(shortcut.module, shortcut.tool, parentCmd);
103
- const pathParams = shortcut.autoPath as UploadPathParams;
93
+ const pathParams: UploadPathParams = {
94
+ repo: (params.repo as string) || shortcut.autoPath.repo || '',
95
+ number: (params.number as string) || shortcut.autoPath.number,
96
+ };
104
97
  let data: any;
105
98
 
106
99
  if (shortcut.tool === 'post-asset-group') {
@@ -116,7 +109,7 @@ export async function executeAction(
116
109
 
117
110
  if (shortcut?.tool === '__get-ci-logs__') {
118
111
  const result = await diagnoseBuild({
119
- repo: process.env.CNB_REPO_SLUG || '',
112
+ repo: (params.repo as string) || '',
120
113
  sn: (opts.sn as string) || undefined,
121
114
  prNumber: process.env.CNB_PULL_REQUEST_IID || undefined,
122
115
  });
@@ -126,7 +119,7 @@ export async function executeAction(
126
119
 
127
120
  if (shortcut?.tool === '__get-ci-timing__') {
128
121
  const result = await analyzeBuildTiming({
129
- repo: process.env.CNB_REPO_SLUG || '',
122
+ repo: (params.repo as string) || '',
130
123
  sn: (opts.sn as string) || undefined,
131
124
  prNumber: process.env.CNB_PULL_REQUEST_IID || undefined,
132
125
  });
@@ -1,6 +1,5 @@
1
- import { removeSlashes } from 'slashes';
2
1
  import { helpData } from './help-data';
3
- import { tryParseJSON, readFileContent } from './parsers';
2
+ import { tryParseJSON, readFileContent, unescapeString } from './parsers';
4
3
  import { buildNestedFieldMap } from '../utils/build-nested-field-map';
5
4
  import type { NestedFieldMapping } from '../utils/build-nested-field-map';
6
5
  import { restoreOriginalKeys } from '../utils/restore-original-keys';
@@ -25,8 +24,11 @@ export function getToolParamDefs(moduleName: string, toolName: string) {
25
24
  * - 其他字段:走 tryParseJSON 以兼容嵌套 JSON
26
25
  *
27
26
  * 说明:shell 在单/双引号下不解释 `\n`,外部程序(包括本 CLI 经 commander)
28
- * 收到的是字面的反斜杠 + n。这里用 slashes.removeSlashes 做一次反解析,
27
+ * 收到的是字面的反斜杠 + n。这里用 unescapeString 做一次反解析,
29
28
  * 让 `--body "a\n b"` 与 `echo -e` 体验一致,正确还原为真实换行/制表符等。
29
+ *
30
+ * 不能用 slashes.removeSlashes:其「无损伤规则」会吞掉不认识的反斜杠,
31
+ * 把 `C:\path` 变成 `C:path`、`\s` 变成 `s`,静默损坏路径与正则片段。
30
32
  */
31
33
  /**
32
34
  * 过滤非法字符(如 \u0000 NUL),避免破坏环境变量和 HTTP 传输。
@@ -61,7 +63,7 @@ function assignBodyValue(
61
63
  if (Array.isArray(value)) {
62
64
  target[key] = value;
63
65
  } else if (propType === 'string') {
64
- target[key] = typeof value === 'string' ? filterIllegalChars(removeSlashes(value)) : value;
66
+ target[key] = typeof value === 'string' ? filterIllegalChars(unescapeString(value)) : value;
65
67
  } else {
66
68
  target[key] = tryParseJSON(value as string);
67
69
  }
@@ -121,10 +123,10 @@ function assignMapField(
121
123
  if (rawV === 'true') v = true;
122
124
  else if (rawV === 'false') v = false;
123
125
  } else {
124
- // 与 assignBodyValue 不同:map value 不做 removeSlashes 反解。
125
- // 原因:环境变量常见值(URL / Windows 路径 / JWT 等)中的反斜杠会被
126
- // removeSlashes 无损伤规则误吞(如 "C:\path" → "C:path")。
127
- // shell 侧的转义($VAR / 引号)用户已经掌控;这里仅过滤非法 NUL 字符。
126
+ // 与 assignBodyValue 的 string 字段不同:map value 不做转义反解。
127
+ // 原因:环境变量常见值(URL / Windows 路径 / JWT 等)中的反斜杠一旦被
128
+ // 误吞(如 "C:\path" → "C:path")就会损坏数据;且 map 值通常是原始串,
129
+ // 用户没有「像 --body 那样写 \n」的预期。这里仅过滤非法 NUL 字符。
128
130
  v = filterIllegalChars(rawV);
129
131
  }
130
132
  map[k] = v;
@@ -50,6 +50,29 @@ export function readFileContent(filePath: string): string {
50
50
  }
51
51
  }
52
52
 
53
+ /**
54
+ * 解析字符串中的转义序列
55
+ * 支持:\n \t \r \\ \a \f \v \e
56
+ * 不支持(避免破坏代码片段):\xHH \uHHHH \UHHHHHHHH \0NNN \c
57
+ * 不认识的转义序列原样保留(如 \s \d \w 等正则元字符)
58
+ * @param str 要解析的字符串
59
+ * @returns 解析后的字符串
60
+ */
61
+ export function unescapeString(str: unknown): any {
62
+ if (typeof str !== 'string') return str;
63
+ const map: Record<string, string> = {
64
+ n: '\n',
65
+ t: '\t',
66
+ r: '\r',
67
+ '\\': '\\',
68
+ a: '\x07',
69
+ f: '\f',
70
+ v: '\v',
71
+ e: '\x1B',
72
+ };
73
+ return str.replace(/\\(n|t|r|\\|a|f|v|e)/g, (_: string, seq: string) => map[seq]);
74
+ }
75
+
53
76
  /**
54
77
  * 解析 commander 未识别的未知选项
55
78
  * commander 开启 allowUnknownOption 后,未知选项会残留在 process.argv 中,
@@ -30,6 +30,19 @@ interface FlatOption {
30
30
  const AUTO_INJECTED_PATH_KEYS = new Set(['repo', 'number', 'review_id']);
31
31
  const AUTO_INJECTED_DISPLAY_KEYS = new Set(['repo', 'number', 'review-id']);
32
32
 
33
+ /**
34
+ * 为不走 registerToolOptions 的快捷命令(upload / CI 自定义命令)注册
35
+ * 由环境变量自动注入的 path 参数,使其可被显式覆盖。
36
+ * - repo:始终注册,未传时由 autoPath 从 CNB_REPO_SLUG 回填
37
+ * - number:仅在命令所属接口真的需要 number 时注册,避免帮助里出现「有选项但静默无效」
38
+ */
39
+ function registerAutoInjectedPathOptions(cmd: Command, needNumber?: boolean): void {
40
+ cmd.option('--repo <string>', '仓库名称(可选,默认取 CNB_REPO_SLUG)');
41
+ if (needNumber) {
42
+ cmd.option('--number <string>', '编号(可选,默认取 CNB_ISSUE_IID / CNB_PULL_REQUEST_IID)');
43
+ }
44
+ }
45
+
33
46
  /**
34
47
  * 为单个 tool 子命令注册 Commander options
35
48
  * 直接读取预生成的 flat-options.json 中已扁平化的参数定义,无需运行时计算
@@ -41,23 +54,28 @@ function registerToolOptions(toolCmd: Command, moduleName: string, toolName: str
41
54
  if (!toolOptions) return;
42
55
 
43
56
  for (const [, opt] of Object.entries(toolOptions) as [string, FlatOption][]) {
44
- // 快捷命令下:仅跳过"由 shortcut.autoPath 从环境变量自动注入"的 path 参数
45
- // (repo / number / review_id)。其余 path 参数(如 img_path、file_path)
46
- // 仍需要 NPC 通过 --xxx 显式传入,因此保留注册且保留 required 标记。
47
- if (
48
- skipPathMandatory
57
+ // 快捷命令下,"由 shortcut.autoPath 从环境变量自动注入"的 path 参数
58
+ // (repo / number / review_id)改为注册为【可选】参数,而非直接跳过。
59
+ // 这样既保留环境变量自动注入的省流默认行为(executeAction 中
60
+ // `if (!params[key])` 仅在用户未显式传值时回填 autoPath),
61
+ // 又允许用户显式覆盖,例如 `cnb pulls get --repo x/y --number 97`
62
+ // 或跨 Issue/PR 场景下查询其它编号。
63
+ // 其余 path 参数(如 img_path、file_path)仍需显式传入,保留 required 标记。
64
+ const isAutoInjectedPath = skipPathMandatory
49
65
  && opt.source === 'path'
50
- && (AUTO_INJECTED_PATH_KEYS.has(opt.optKey) || AUTO_INJECTED_DISPLAY_KEYS.has(opt.optKey))
51
- ) {
52
- continue;
53
- }
66
+ && (AUTO_INJECTED_PATH_KEYS.has(opt.optKey) || AUTO_INJECTED_DISPLAY_KEYS.has(opt.optKey));
67
+
68
+ // 自动注入参数在快捷命令下是「可选、可覆盖」的,去掉源描述里与语义冲突的 [必填] 前缀
69
+ const autoInjectedDesc = isAutoInjectedPath
70
+ ? `${opt.description.replace(/^\[必填\]\s*/, '')}(可选,默认取当前 Issue/PR 环境变量)`
71
+ : opt.description;
54
72
 
55
73
  const option = new Option(
56
74
  `--${opt.optKey} ${opt.valuePlaceholder}`.trim(),
57
- opt.description,
75
+ autoInjectedDesc,
58
76
  );
59
77
 
60
- if (opt.required && opt.source === 'path') {
78
+ if (opt.required && opt.source === 'path' && !isAutoInjectedPath) {
61
79
  option.makeOptionMandatory();
62
80
  }
63
81
 
@@ -98,6 +116,40 @@ function getShortcutsForModule(moduleName: string): ShortcutDefinition[] {
98
116
  return [];
99
117
  }
100
118
 
119
+ /**
120
+ * 模块下未匹配到任何子命令时的兜底处理。
121
+ *
122
+ * Commander 允许未知位置参数落到兜底 action,静默打印帮助并 return 会让调用方
123
+ * 拿到 exit 0 —— 对自动化(含 AI Agent)是「失败伪装成成功」:拼错一个字母
124
+ * (如 submit-reviewx)不会发出任何请求,却与成功调用一样返回 0,
125
+ * Agent 据此认为命令已生效。
126
+ *
127
+ * 因此这里向 stderr 输出双语报错并置非零退出码,让调用方能靠退出码判失败。
128
+ * 不传位置参数(仅 `cnb pulls`)时仍打印帮助并返回 0;
129
+ * 显式 `--help` / `-h` 由 Commander 内建路径处理,不会走到这里。
130
+ *
131
+ * 文案沿用本仓库既有惯例(见 execute-action.ts 的 buildToolNotFoundMessage):
132
+ * 中文行 `错误:` 开头、英文行 `Error:` 开头,两条平行,且都给出模块级与全局帮助入口。
133
+ *
134
+ * 区分「选项」与「命令」是因为 sub 上开了 allowUnknownOption():
135
+ * 所有未识别的 token 都会塞进 [tool],模块级选项(如 `cnb issues --repo foo/bar`、
136
+ * `cnb pulls --json`)同样落到这里。这类输入的真实错因是漏写子命令或选错了选项,
137
+ * 报成「未知命令」会把选项名当命令名,排障方向也带偏。
138
+ */
139
+ function guardUnknownTool(moduleName: string, sub: Command, toolArg: string | undefined): void {
140
+ if (!toolArg) {
141
+ sub.help();
142
+ return;
143
+ }
144
+ const kind = toolArg.startsWith('-') ? '选项' : '命令';
145
+ const kindEn = toolArg.startsWith('-') ? 'option' : 'command';
146
+ console.error(`错误:未知${kind}「${toolArg}」。`);
147
+ console.error(`可通过 \`cnb ${moduleName} --help\` 查看该模块支持的工具列表,或用 \`cnb --help\` 查看全部模块。`);
148
+ console.error(`Error: unknown ${kindEn} "${toolArg}". Please check the name.`);
149
+ console.error(`Run \`cnb ${moduleName} --help\` to list tools of this module, or \`cnb --help\` for all modules.`);
150
+ process.exitCode = 1;
151
+ }
152
+
101
153
  /**
102
154
  * 为每个模块注册子命令,使 cnb <module> -h 显示模块帮助
103
155
  * 每个 tool 的参数通过 Commander 的 option 系统注册,--help 自动展示参数说明
@@ -159,11 +211,19 @@ export function registerModuleCommands(program: Command): void {
159
211
  // 按命令类别注册选项:upload / ci 自定义 / 普通 swagger 命令
160
212
  if (shortcut.upload) {
161
213
  shortcutCmd.requiredOption('--file <string>', '本地文件路径。必填。');
214
+ // 是否需要 number 由接口本身决定,而非 shortcut.repoOnly:
215
+ // - post-issue-comment-*-asset-upload-url(issue 评论上传):path 为 repo + number
216
+ // - post-asset-group(创建 issue 时上传):path 只有 repo,number 传了也会被忽略
217
+ // - pull 侧 upload-files / upload-imgs:按仓库维度上传,同样不需要 number
218
+ const needNumber = shortcut.realTool !== 'post-asset-group' && !shortcut.repoOnly;
219
+ registerAutoInjectedPathOptions(shortcutCmd, needNumber);
162
220
  } else if (
163
221
  shortcut.realTool === '__get-ci-logs__' ||
164
222
  shortcut.realTool === '__get-ci-timing__'
165
223
  ) {
166
224
  shortcutCmd.option('--sn <string>', '构建号(可选,默认自动从环境变量或 PR 状态获取)');
225
+ // CI 命令按仓库维度查询,允许显式覆盖环境变量中的仓库
226
+ shortcutCmd.option('--repo <string>', '仓库名称(可选,默认取 CNB_REPO_SLUG)');
167
227
  } else {
168
228
  registerToolOptions(shortcutCmd, moduleName, shortcut.realTool, true);
169
229
  }
@@ -179,12 +239,8 @@ export function registerModuleCommands(program: Command): void {
179
239
  sub
180
240
  .argument('[tool]', '工具名称(支持快捷命令)')
181
241
  .action(async (toolArg: string | undefined, _opts: Record<string, any>) => {
182
- if (toolArg) {
183
- // 此处已不会再命中快捷命令(因为已注册为子命令),仅作兜底
184
- sub.help();
185
- return;
186
- }
187
- sub.help();
242
+ // 此处已不会再命中快捷命令(因为已注册为子命令),仅作兜底
243
+ guardUnknownTool(moduleName, sub, toolArg);
188
244
  });
189
245
  }
190
246
 
@@ -225,7 +281,7 @@ export function registerModuleCommands(program: Command): void {
225
281
  skillsSub
226
282
  .argument('[tool]', '工具名称(支持快捷命令)')
227
283
  .action(async (toolArg: string | undefined, _opts: Record<string, any>) => {
228
- skillsSub.help();
284
+ guardUnknownTool('skills', skillsSub, toolArg);
229
285
  });
230
286
  }
231
287
  }
@@ -285,3 +285,34 @@ export function resolveShortcut(
285
285
  };
286
286
  }
287
287
 
288
+ /**
289
+ * 将快捷命令的 autoPath / autoData 合并进用户传入的参数。
290
+ *
291
+ * 优先级:用户显式传入 > 环境变量自动注入(autoPath)> 常量默认值(autoData)。
292
+ * - autoPath(repo / number / review_id):用户未传或传空值时用环境变量兜底
293
+ * - autoData(如 close 的 state='closed'):仅在用户完全未传时兜底
294
+ *
295
+ * 这样 `--repo` / `--number` 可作为可选参数显式覆盖环境变量,
296
+ * 未传时保持原有「从环境变量自动注入」的省流行为。
297
+ *
298
+ * @param params 用户传入参数(会被就地修改)
299
+ * @param shortcut 解析后的快捷命令定义
300
+ * @returns 合并后的 params
301
+ */
302
+ export function mergeShortcutParams(
303
+ params: Record<string, any>,
304
+ shortcut: Pick<ResolvedShortcut, 'autoPath' | 'autoData'> | null | undefined,
305
+ ): Record<string, any> {
306
+ if (!shortcut) return params;
307
+ for (const [key, value] of Object.entries(shortcut.autoPath || {})) {
308
+ if (!params[key]) {
309
+ params[key] = value;
310
+ }
311
+ }
312
+ for (const [key, value] of Object.entries(shortcut.autoData || {})) {
313
+ if (params[key] === undefined) {
314
+ params[key] = value;
315
+ }
316
+ }
317
+ return params;
318
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cnbcool/cnb-api-generate",
3
- "version": "2.17.1",
3
+ "version": "2.17.2",
4
4
  "main": "./built/index.js",
5
5
  "module": "./src/index.ts",
6
6
  "types": "./src/index.ts",