@cnbcool/cnb-api-generate 2.6.1 → 2.7.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/client/index.ts CHANGED
@@ -18,6 +18,7 @@ const program = new Command();
18
18
  program
19
19
  .name(process.env.CNB_CLI_CMD || 'cnb')
20
20
  .description('CNB OpenAPI 命令行工具')
21
+ .version('<CNB_CLI_VERSION>', '-v, --version', '显示版本号')
21
22
  .allowUnknownOption()
22
23
  .allowExcessArguments()
23
24
  .helpOption('-h, --help', '显示帮助文档')
@@ -1,8 +1,6 @@
1
- import fs from 'fs';
2
- import path from 'path';
3
1
  import { Command } from 'commander';
4
- import { showShort, resolveShortcut, type ResolvedShortcut } from '../shortcuts';
5
- import { handleUpload } from '../utils/upload';
2
+ import { showShort, resolveShortcut } from '../shortcuts';
3
+ import { handleUpload, handleIssueCreateUpload, type UploadPathParams } from '../utils/upload';
6
4
  import { diagnoseBuild } from './build-diagnostics';
7
5
  import { analyzeBuildTiming } from './build-timing';
8
6
  import { formatParams } from './format-params';
@@ -23,18 +21,6 @@ function loadToolFunction(moduleName: string, toolName: string): any {
23
21
  return toolFunction;
24
22
  }
25
23
 
26
- /**
27
- * 处理上传命令的独立分支
28
- * 上传命令只需要 --file 和自动注入的 path 参数,不经过 formatParams
29
- */
30
- async function executeUpload(
31
- shortcut: ResolvedShortcut,
32
- opts: Record<string, any>,
33
- ): Promise<any> {
34
- const toolFunction = loadToolFunction(shortcut.module, shortcut.tool);
35
- return handleUpload(shortcut, opts.file, toolFunction, shortcut.autoPath as any);
36
- }
37
-
38
24
  export async function executeAction(
39
25
  moduleArg: string | undefined,
40
26
  toolArg: string | undefined,
@@ -90,14 +76,22 @@ export async function executeAction(
90
76
 
91
77
  // 上传命令走独立分支:只需 --file + autoPath,跳过 formatParams 常规流程
92
78
  if (shortcut?.upload) {
93
- const data = await executeUpload(shortcut, opts);
94
- const toolKey = `${shortcut.module}/${shortcut.tool}`;
95
79
  const isVerbose = !!opts.verbose;
96
- console.log(formatOutput(data, isVerbose, !isVerbose, toolKey));
80
+ const toolFunction = loadToolFunction(shortcut.module, shortcut.tool);
81
+ const pathParams = shortcut.autoPath as UploadPathParams;
82
+ let data: any;
83
+
84
+ if (shortcut.tool === 'post-asset-group') {
85
+ // 创建 issue 时上传:asset-group 流程(携带 image_assets / file_assets)
86
+ data = await handleIssueCreateUpload(shortcut, opts.file, toolFunction, pathParams);
87
+ } else {
88
+ data = await handleUpload(shortcut, opts.file, toolFunction, pathParams);
89
+ }
90
+
91
+ console.log(formatOutput(data, isVerbose, !isVerbose, `${shortcut.module}/${shortcut.tool}`));
97
92
  return;
98
93
  }
99
94
 
100
- // 自定义命令走独立分支(不通过 swagger 生成的 tool 函数)
101
95
  if (shortcut?.tool === '__get-ci-logs__') {
102
96
  const result = await diagnoseBuild({
103
97
  repo: process.env.CNB_REPO_SLUG || '',
@@ -135,19 +135,16 @@ export function registerModuleCommands(program: Command): void {
135
135
  await executeAction(moduleName, shortcut.shortName, opts, sub);
136
136
  });
137
137
 
138
- // 上传命令只需要 --file,不复用真实 tool body 选项(name/size/content_type 由上传流程自动获取)
139
- if (shortcut.custom) {
140
- // 自定义命令注册自己的选项
141
- if (
142
- shortcut.realTool === '__get-ci-logs__' ||
143
- shortcut.realTool === '__get-ci-timing__'
144
- ) {
145
- shortcutCmd.option('--sn <string>', '构建号(可选,默认自动从环境变量或 PR 状态获取)');
146
- }
147
- } else if (!shortcut.upload) {
148
- registerToolOptions(shortcutCmd, moduleName, shortcut.realTool, true);
149
- } else {
138
+ // 按命令类别注册选项:upload / ci 自定义 / 普通 swagger 命令
139
+ if (shortcut.upload) {
150
140
  shortcutCmd.requiredOption('--file <string>', '本地文件路径。必填。');
141
+ } else if (
142
+ shortcut.realTool === '__get-ci-logs__' ||
143
+ shortcut.realTool === '__get-ci-timing__'
144
+ ) {
145
+ shortcutCmd.option('--sn <string>', '构建号(可选,默认自动从环境变量或 PR 状态获取)');
146
+ } else {
147
+ registerToolOptions(shortcutCmd, moduleName, shortcut.realTool, true);
151
148
  }
152
149
 
153
150
  registeredNames.add(shortcut.shortName);
@@ -1,7 +1,13 @@
1
1
  import { Command } from 'commander';
2
- import { getTokenPath } from '../utils/token-path';
3
- import { loadToken } from '../utils/load-token';
2
+ import { resolveTokenSource } from '../utils/resolve-token-source';
4
3
  import { refreshAccessToken } from '../utils/refresh-token';
4
+ import { getTokenPath } from '../utils/token-path';
5
+
6
+ const SOURCE_LABEL: Record<string, string> = {
7
+ workbuddy: 'WorkBuddy 远程获取',
8
+ codebuddy_env: 'CNB_TOKEN_FOR_CODEBUDDY 环境变量',
9
+ env: 'CNB_TOKEN 环境变量',
10
+ };
5
11
 
6
12
  export function registerStatusCommand(program: Command): void {
7
13
  program
@@ -10,38 +16,46 @@ export function registerStatusCommand(program: Command): void {
10
16
  .option('--debug', '打印调试信息', false)
11
17
  .helpOption('-h, --help', '显示帮助文档')
12
18
  .action(async (opts) => {
13
- const tokenPath = getTokenPath();
14
- const token = loadToken();
19
+ const result = await resolveTokenSource();
20
+
21
+ if (result.source === 'none') {
22
+ console.log(result.reason ? `⚠️ ${result.reason}` : '⚠️ 未登录,请执行 `cnb login` 进行授权。');
23
+ return;
24
+ }
15
25
 
16
- if (!token) {
17
- console.log('⚠️ 未登录,请执行 `cnb login` 进行授权。');
26
+ // 环境变量 / WorkBuddy 来源,无过期概念
27
+ if (result.source !== 'file') {
28
+ console.log('✅ 已登录。');
29
+ if (opts.debug) {
30
+ console.log(` Token 来源: ${SOURCE_LABEL[result.source]}`);
31
+ }
18
32
  return;
19
33
  }
20
34
 
35
+ // 文件来源 - 检查过期
36
+ const tokenPath = getTokenPath();
37
+ const store = result.store;
38
+
21
39
  if (opts.debug) {
22
40
  console.log(`Token 文件路径 : ${tokenPath}`);
23
- console.log(`Platform URL : ${token.platform_url || '未知'}`);
24
- console.log(`Client ID : ${token.client_id || '未知'}`);
25
- console.log(`Expires At : ${new Date(token.expires_at * 1000).toLocaleString()}`);
41
+ console.log(`Platform URL : ${store.platform_url || '未知'}`);
42
+ console.log(`Client ID : ${store.client_id || '未知'}`);
43
+ console.log(`Expires At : ${new Date(store.expires_at * 1000).toLocaleString()}`);
26
44
  }
27
45
 
28
46
  const nowSec = Math.floor(Date.now() / 1000);
29
- const isExpired = nowSec >= token.expires_at;
47
+ const isExpired = nowSec >= store.expires_at;
30
48
 
31
49
  if (isExpired) {
32
- // 尝试使用 refresh_token 刷新
33
- if (token.refresh_token) {
50
+ if (store.refresh_token) {
34
51
  if (opts.debug) {
35
52
  console.log('Token 已过期,尝试使用 refresh_token 刷新...');
36
53
  }
37
54
  try {
38
- await refreshAccessToken(token);
39
- const refreshedToken = loadToken();
40
- if (refreshedToken) {
41
- console.log(' 已登录。');
42
- if (opts.debug) {
43
- console.log(' (Token 已自动刷新)');
44
- }
55
+ await refreshAccessToken(store);
56
+ console.log('✅ 已登录。');
57
+ if (opts.debug) {
58
+ console.log(' (Token 已自动刷新)');
45
59
  }
46
60
  } catch (err: any) {
47
61
  if (opts.debug) {
@@ -0,0 +1,82 @@
1
+ import { getTokenEnvKey } from './get-token-env-key';
2
+ import { isWorkBuddySandbox } from './is-workbuddy-sandbox';
3
+ import { loadToken } from './load-token';
4
+ import type { TokenStore } from './types';
5
+
6
+ export type TokenSourceResult =
7
+ | { source: 'workbuddy'; access_token: string }
8
+ | { source: 'codebuddy_env'; access_token: string }
9
+ | { source: 'env'; access_token: string }
10
+ | { source: 'file'; access_token: string; store: TokenStore }
11
+ | { source: 'none'; reason?: string };
12
+
13
+ /**
14
+ * 统一判断 token 来源,不退出进程、不自动刷新。
15
+ *
16
+ * 优先级:
17
+ * 1. WorkBuddy 沙箱环境 → 远程接口获取
18
+ * 2. CNB_TOKEN_FOR_CODEBUDDY 环境变量
19
+ * 3. CNB_TOKEN 环境变量
20
+ * 4. 文件 token(~/.cnb/token)
21
+ */
22
+ export async function resolveTokenSource(): Promise<TokenSourceResult> {
23
+ // WorkBuddy 沙箱环境,从远程接口获取 token
24
+ if (isWorkBuddySandbox()) {
25
+ const tokenURL = process.env[getTokenEnvKey()];
26
+ if (!tokenURL) {
27
+ return {
28
+ source: 'none',
29
+ reason: `未找到环境变量 ${getTokenEnvKey()},无法在 WorkBuddy 沙箱环境中获取 token。`,
30
+ };
31
+ }
32
+
33
+ try {
34
+ const resp = await fetch(tokenURL, {
35
+ method: 'GET',
36
+ headers: { Accept: 'application/json' },
37
+ });
38
+
39
+ if (!resp.ok) {
40
+ return {
41
+ source: 'none',
42
+ reason: `从 WorkBuddy 获取 token 失败,HTTP 状态码: ${resp.status} ${resp.statusText}`,
43
+ };
44
+ }
45
+
46
+ const body: any = await resp.json();
47
+ if (body?.code !== 0 || !body?.data?.access_token) {
48
+ return {
49
+ source: 'none',
50
+ reason: `从 WorkBuddy 获取 token 失败,响应内容异常: ${JSON.stringify(body)}`,
51
+ };
52
+ }
53
+
54
+ return { source: 'workbuddy', access_token: body.data.access_token as string };
55
+ } catch (err: any) {
56
+ return {
57
+ source: 'none',
58
+ reason: `从 WorkBuddy 获取 token 失败: ${err?.message || err}`,
59
+ };
60
+ }
61
+ }
62
+
63
+ // 环境变量优先 CodeBuddy
64
+ if (process.env.CNB_TOKEN_FOR_CODEBUDDY) {
65
+ if (!process.env.CNB_NPC_SLUG && process.env.CNB_NPC_NAME === 'CodeBuddy') {
66
+ return { source: 'codebuddy_env', access_token: process.env.CNB_TOKEN_FOR_CODEBUDDY };
67
+ }
68
+ }
69
+
70
+ // 环境变量优先,不做过期判断
71
+ if (process.env.CNB_TOKEN) {
72
+ return { source: 'env', access_token: process.env.CNB_TOKEN };
73
+ }
74
+
75
+ // 文件 token
76
+ const store = loadToken();
77
+ if (!store || !store.access_token) {
78
+ return { source: 'none' };
79
+ }
80
+
81
+ return { source: 'file', access_token: store.access_token, store };
82
+ }
@@ -1,6 +1,4 @@
1
- import { getTokenEnvKey } from './get-token-env-key';
2
- import { isWorkBuddySandbox } from './is-workbuddy-sandbox';
3
- import { loadToken } from './load-token';
1
+ import { resolveTokenSource } from './resolve-token-source';
4
2
  import { refreshAccessToken } from './refresh-token';
5
3
 
6
4
  /**
@@ -11,61 +9,25 @@ import { refreshAccessToken } from './refresh-token';
11
9
  * 刷新失败或无 refresh_token 时终止进程并提示用户重新登录。
12
10
  */
13
11
  export async function resolveToken(): Promise<string> {
14
- // WorkBuddy 沙箱环境,从远程接口获取 token
15
- if (isWorkBuddySandbox()) {
16
- const tokenURL = process.env[getTokenEnvKey()];
17
- if (!tokenURL) {
18
- console.error(`未找到环境变量 ${getTokenEnvKey()},无法在 WorkBuddy 沙箱环境中获取 token。`);
19
- process.exit(1);
20
- }
21
-
22
- try {
23
- const resp = await fetch(tokenURL, {
24
- method: 'GET',
25
- headers: { Accept: 'application/json' },
26
- });
27
-
28
- if (!resp.ok) {
29
- console.error(`从 WorkBuddy 获取 token 失败,HTTP 状态码: ${resp.status} ${resp.statusText}`);
30
- process.exit(1);
31
- }
32
-
33
- const body: any = await resp.json();
34
- if (body?.code !== 0 || !body?.data?.access_token) {
35
- console.error(`从 WorkBuddy 获取 token 失败,响应内容异常: ${JSON.stringify(body)}`);
36
- process.exit(1);
37
- }
38
-
39
- return body.data.access_token as string;
40
- } catch (err: any) {
41
- console.error(`从 WorkBuddy 获取 token 失败: ${err?.message || err}`);
42
- process.exit(1);
43
- }
44
- }
45
-
46
- // 环境变量优先 CodeBuddy
47
- if (process.env.CNB_TOKEN_FOR_CODEBUDDY) {
48
- if (!process.env.CNB_NPC_SLUG && process.env.CNB_NPC_NAME === 'CodeBuddy') {
49
- return process.env.CNB_TOKEN_FOR_CODEBUDDY;
50
- }
51
- }
12
+ const result = await resolveTokenSource();
52
13
 
53
- // 环境变量优先,不做过期判断
54
- if (process.env.CNB_TOKEN) {
55
- return process.env.CNB_TOKEN;
14
+ if (result.source === 'none') {
15
+ console.error(result.reason || '未找到有效的登录凭证,请先执行 `cnb login` 进行授权。');
16
+ process.exit(1);
56
17
  }
57
18
 
58
- const store = loadToken();
59
- if (!store || !store.access_token) {
60
- console.error('未找到有效的登录凭证,请先执行 `cnb login` 进行授权。');
61
- process.exit(1);
19
+ // 环境变量来源直接返回,不做过期检查
20
+ if (result.source === 'workbuddy' || result.source === 'codebuddy_env' || result.source === 'env') {
21
+ return result.access_token;
62
22
  }
63
23
 
24
+ // 文件来源 - 检查过期
25
+ const store = result.store;
64
26
  const nowSec = Math.floor(Date.now() / 1000);
65
27
 
66
28
  // 未过期,直接返回
67
29
  if (nowSec < store.expires_at) {
68
- return store.access_token;
30
+ return result.access_token;
69
31
  }
70
32
 
71
33
  // 已过期,尝试 refresh
@@ -73,9 +73,11 @@ function mimeLookup(filePath: string): string {
73
73
  const MAX_FILE_SIZE = 100 * 1024 * 1024;
74
74
 
75
75
  /** 上传 API 的 path 参数(issue 走 comment-asset-upload-url 流程需要 repo + number) */
76
- interface UploadPathParams {
76
+ export interface UploadPathParams {
77
77
  repo: string;
78
78
  number?: string;
79
+ /** 兼容 autoPath 的动态字段(如 review_id) */
80
+ [key: string]: string | undefined;
79
81
  }
80
82
 
81
83
  /** Issue comment-asset-upload-url 接口请求体(单文件,与 swagger PostIssueAssetUploadURLForm 对齐) */
@@ -118,12 +120,13 @@ interface UploadErrorData {
118
120
  detail?: string;
119
121
  }
120
122
 
121
- /** Issue 上传成功时的 data */
123
+ /** Issue 上传成功时的 data(asset_group_id 仅在创建 issue 流程下返回) */
122
124
  interface IssueUploadData {
123
125
  asset_link?: string;
124
126
  name: string;
125
127
  path?: string;
126
128
  size: number;
129
+ asset_group_id?: string;
127
130
  }
128
131
 
129
132
  /** Pull 上传成功时的 data */
@@ -134,121 +137,64 @@ interface PullUploadData {
134
137
  token?: string;
135
138
  }
136
139
 
137
- /** handleUpload 的返回值 */
140
+ /** handleUpload / handleIssueCreateUpload 的返回值 */
138
141
  interface UploadResult {
139
142
  status: number;
140
143
  data: UploadErrorData | IssueUploadData | PullUploadData;
141
144
  }
142
145
 
143
- /**
144
- * 处理完整上传流程
145
- *
146
- * Issue 路径(走 comment-asset-upload-url 接口,按 file/image 分别调用):
147
- * 1. POST /{repo}/-/issues/{number}/comment-file-asset-upload-url
148
- * 或 /{repo}/-/issues/{number}/comment-image-asset-upload-url
149
- * 携带 { name, size, content_type } → 服务端返回 upload_url + asset_link
150
- * 2. PUT 文件流到 upload_url
151
- * 3. 返回 asset_link 给调用方拼到评论 body 里
152
- *
153
- * Pull 路径(保留原有的 upload-files / upload-imgs 一步上传):
154
- * 1. POST /{repo}/upload-files(或 imgs) 拿 upload_url
155
- * 2. PUT 文件流到 upload_url
156
- *
157
- * @param shortcut 解析后的快捷命令;shortcut.kind 决定 issue 上传走 file 还是 image 通道
158
- * @param filePath 本地文件路径
159
- * @param toolFunction 原始上传 API 函数
160
- * @param pathParams API 调用的 path 参数(issue 需要 repo+number;pull 只需 repo)
161
- */
162
- export async function handleUpload(
163
- shortcut: ResolvedShortcut,
164
- filePath: string | undefined,
165
- toolFunction: UploadApiFunction,
166
- pathParams: UploadPathParams,
167
- ): Promise<UploadResult> {
168
- // 校验 file 参数
146
+ /** 文件校验结果 */
147
+ interface FileInfo {
148
+ filePath: string;
149
+ fileName: string;
150
+ fileSize: number;
151
+ contentType: string;
152
+ }
153
+ type ValidateFileResult = { ok: true; info: FileInfo } | { ok: false; result: UploadResult };
154
+
155
+ /** 校验本地文件是否可上传,返回文件元信息或错误 */
156
+ function validateFile(filePath: string | undefined): ValidateFileResult {
169
157
  if (!filePath) {
170
- return { status: 400, data: { error: `上传命令需要指定文件路径,如: --data '{"file":"./path/to/file"}'` } };
158
+ return { ok: false, result: { status: 400, data: { error: `上传命令需要指定文件路径,如: --data '{"file":"./path/to/file"}'` } } };
171
159
  }
172
-
173
- // 1. 读取本地文件信息
174
160
  if (!fs.existsSync(filePath)) {
175
- return { status: 400, data: { error: `文件不存在: ${filePath}` } };
161
+ return { ok: false, result: { status: 400, data: { error: `文件不存在: ${filePath}` } } };
176
162
  }
177
-
178
163
  const stat = fs.statSync(filePath);
179
164
  if (!stat.isFile()) {
180
- return { status: 400, data: { error: `不是文件: ${filePath}` } };
165
+ return { ok: false, result: { status: 400, data: { error: `不是文件: ${filePath}` } } };
181
166
  }
182
-
183
- const fileName = nodePath.basename(filePath);
184
167
  const fileSize = stat.size;
185
-
186
168
  if (fileSize === 0) {
187
- return { status: 400, data: { error: '文件为空' } };
169
+ return { ok: false, result: { status: 400, data: { error: '文件为空' } } };
188
170
  }
189
171
  if (fileSize > MAX_FILE_SIZE) {
190
- return { status: 400, data: { error: `文件过大 (${(fileSize / 1024 / 1024).toFixed(1)}MB),上限 ${MAX_FILE_SIZE / 1024 / 1024}MB` } };
191
- }
192
-
193
- const contentType = mimeLookup(filePath);
194
-
195
- // 2. 调用上传 API 获取 upload_url
196
- const isIssueUpload = shortcut.module === 'issues';
197
-
198
- let uploadUrl: string | undefined;
199
- let issueResponseData: IssueAssetUploadURLResponseData | undefined;
200
- let pullResponseData: PullUploadResponseData | undefined;
201
-
202
- if (isIssueUpload) {
203
- // Issue: 调 post-issue-comment-{file|image}-asset-upload-url,
204
- // 由 cag.config.js 的 quickCommands 通过 kind=file/image 选择 realTool。
205
- // 函数签名:({ repo, number }, { name, size, content_type })
206
- if (!pathParams.number) {
207
- return { status: 400, data: { error: '缺少 issue number(请确认 CNB_ISSUE_IID 是否已设置)' } };
208
- }
209
- const requestBody: IssueAssetUploadURLBody = {
210
- name: fileName,
211
- size: fileSize,
212
- content_type: contentType,
213
- };
214
-
215
- const apiResponse = await toolFunction(
216
- { repo: pathParams.repo, number: pathParams.number },
217
- requestBody,
218
- );
219
- issueResponseData = apiResponse?.data as IssueAssetUploadURLResponseData | undefined;
220
- uploadUrl = issueResponseData?.upload_url;
221
-
222
- if (!uploadUrl) {
223
- // 拿 URL 失败:把原始响应作为错误返回,便于排查
224
- return apiResponse as unknown as UploadResult;
225
- }
226
- } else {
227
- // Pull: 沿用原有"一步走"上传接口
228
- // 函数签名:(repo: string, { name, size }, ...)
229
- const requestBody: PullUploadBody = { name: fileName, size: fileSize };
230
- const apiResponse = await toolFunction(pathParams.repo, requestBody);
231
- pullResponseData = apiResponse?.data as PullUploadResponseData | undefined;
232
- uploadUrl = pullResponseData?.upload_url;
233
-
234
- if (!uploadUrl) {
235
- return apiResponse as unknown as UploadResult;
236
- }
172
+ return { ok: false, result: { status: 400, data: { error: `文件过大 (${(fileSize / 1024 / 1024).toFixed(1)}MB),上限 ${MAX_FILE_SIZE / 1024 / 1024}MB` } } };
237
173
  }
238
-
239
- // 3. PUT 文件内容到 upload_url(流式读取,避免大文件撑爆内存)
240
- const fileStream = fs.createReadStream(filePath);
241
-
242
- const putHeaders: Record<string, string> = {
243
- 'Content-Type': contentType,
244
- 'Content-Length': String(fileSize),
174
+ return {
175
+ ok: true,
176
+ info: {
177
+ filePath,
178
+ fileName: nodePath.basename(filePath),
179
+ fileSize,
180
+ contentType: mimeLookup(filePath),
181
+ },
245
182
  };
183
+ }
246
184
 
185
+ /** PUT 文件流到指定 URL,返回错误结果或 undefined(成功) */
186
+ async function putFileStream(
187
+ filePath: string,
188
+ uploadUrl: string,
189
+ contentType: string,
190
+ fileSize: number,
191
+ ): Promise<UploadResult | undefined> {
192
+ const fileStream = fs.createReadStream(filePath);
247
193
  let putResponse: Response;
248
194
  try {
249
195
  putResponse = await fetch(uploadUrl, {
250
196
  method: 'PUT',
251
- headers: putHeaders,
197
+ headers: { 'Content-Type': contentType, 'Content-Length': String(fileSize) },
252
198
  body: fileStream as any,
253
199
  // @ts-ignore duplex required for streaming body in Node.js fetch
254
200
  duplex: 'half',
@@ -257,36 +203,168 @@ export async function handleUpload(
257
203
  fileStream.destroy();
258
204
  throw e;
259
205
  }
260
-
261
206
  if (!putResponse.ok) {
262
207
  fileStream.destroy();
263
208
  const errText = await putResponse.text().catch(() => '');
264
- return {
265
- status: putResponse.status,
266
- data: { error: `文件上传失败: ${putResponse.statusText}`, detail: errText },
267
- };
209
+ return { status: putResponse.status, data: { error: `文件上传失败: ${putResponse.statusText}`, detail: errText } };
268
210
  }
211
+ return undefined;
212
+ }
269
213
 
270
- // 4. 返回最终结果
271
- if (isIssueUpload) {
272
- return {
273
- status: 200,
274
- data: {
275
- asset_link: issueResponseData?.asset_link,
276
- name: issueResponseData?.name || fileName,
277
- path: issueResponseData?.path,
278
- size: fileSize,
279
- },
280
- };
214
+ /** asset-group 接口请求体 */
215
+ interface PostAssetGroupForm {
216
+ image_assets?: Array<{ name: string; size: number; content_type: string }>;
217
+ file_assets?: Array<{ name: string; size: number; content_type: string }>;
218
+ }
219
+
220
+ /** asset-group 接口响应 */
221
+ interface AssetGroupResponseData {
222
+ asset_group_id?: string;
223
+ image_upload_urls?: IssueAssetUploadURLResponseData[];
224
+ file_upload_urls?: IssueAssetUploadURLResponseData[];
225
+ }
226
+
227
+ /** 创建 asset-group 的 API 函数签名 */
228
+ type PostAssetGroupFunction = (
229
+ repo: string,
230
+ body: PostAssetGroupForm,
231
+ ) => Promise<{ status?: number; data?: AssetGroupResponseData }>;
232
+
233
+ /**
234
+ * 处理创建 Issue 时的图片/文件上传(asset-group 两步流程)
235
+ *
236
+ * 流程:
237
+ * 1. POST /{repo}/-/issues/asset-groups 携带 { image_assets: [{name, size, content_type}] }
238
+ * 或 { file_assets: [...] } → 服务端返回 asset_group_id + upload_url + asset_link
239
+ * 2. PUT 文件流到 upload_url
240
+ * 3. 返回 asset_link 给调用方拼到创建 issue 的 body 中
241
+ *
242
+ * @param shortcut 解析后的快捷命令;shortcut.kind 决定走 image 还是 file 通道
243
+ * @param filePath 本地文件路径
244
+ * @param postAssetGroup POST asset-groups 的 API 函数
245
+ * @param pathParams API 调用的 path 参数(取 repo)
246
+ */
247
+ export async function handleIssueCreateUpload(
248
+ shortcut: ResolvedShortcut,
249
+ filePath: string | undefined,
250
+ postAssetGroup: PostAssetGroupFunction,
251
+ pathParams: UploadPathParams,
252
+ ): Promise<UploadResult> {
253
+ const validated = validateFile(filePath);
254
+ if (!validated.ok) return validated.result;
255
+ const { filePath: validPath, fileName, fileSize, contentType } = validated.info;
256
+
257
+ const kind: 'image' | 'file' = shortcut.kind === 'image' ? 'image' : 'file';
258
+ const assetEntry = { name: fileName, size: fileSize, content_type: contentType };
259
+
260
+ // 1. 创建 asset-group,同时获取上传 URL
261
+ const groupBody: PostAssetGroupForm = kind === 'image'
262
+ ? { image_assets: [assetEntry] }
263
+ : { file_assets: [assetEntry] };
264
+
265
+ const groupResponse = await postAssetGroup(pathParams.repo, groupBody);
266
+ const groupData = groupResponse?.data as AssetGroupResponseData | undefined;
267
+
268
+ const uploadEntries = kind === 'image'
269
+ ? groupData?.image_upload_urls
270
+ : groupData?.file_upload_urls;
271
+
272
+ const firstEntry = uploadEntries?.[0];
273
+ const uploadUrl = firstEntry?.upload_url;
274
+
275
+ if (!uploadUrl) {
276
+ return groupResponse as unknown as UploadResult;
281
277
  }
282
278
 
279
+ // 2. PUT 文件流到 upload_url
280
+ const putErr = await putFileStream(validPath, uploadUrl, contentType, fileSize);
281
+ if (putErr) return putErr;
282
+
283
+ // 3. 返回结果(asset_link 拼入创建 issue 的 body,asset_group_id 备用)
283
284
  return {
284
285
  status: 200,
285
286
  data: {
286
- name: pullResponseData?.assets?.name || fileName,
287
- path: pullResponseData?.assets?.path,
287
+ asset_link: firstEntry?.asset_link,
288
+ name: firstEntry?.name || fileName,
289
+ path: firstEntry?.path,
288
290
  size: fileSize,
289
- token: pullResponseData?.token,
291
+ asset_group_id: groupData?.asset_group_id,
290
292
  },
291
293
  };
292
294
  }
295
+
296
+ /**
297
+ * 处理完整上传流程
298
+ *
299
+ * Issue 评论路径(走 comment-asset-upload-url 接口,按 file/image 分别调用):
300
+ * 1. POST /{repo}/-/issues/{number}/comment-file-asset-upload-url
301
+ * 或 /{repo}/-/issues/{number}/comment-image-asset-upload-url
302
+ * 携带 { name, size, content_type } → 服务端返回 upload_url + asset_link
303
+ * 2. PUT 文件流到 upload_url
304
+ * 3. 返回 asset_link 给调用方拼到评论 body 里
305
+ *
306
+ * Pull 路径(保留原有的 upload-files / upload-imgs 一步上传):
307
+ * 1. POST /{repo}/upload-files(或 imgs) 拿 upload_url
308
+ * 2. PUT 文件流到 upload_url
309
+ *
310
+ * @param shortcut 解析后的快捷命令;shortcut.kind 决定 issue 上传走 file 还是 image 通道
311
+ * @param filePath 本地文件路径
312
+ * @param toolFunction 原始上传 API 函数
313
+ * @param pathParams API 调用的 path 参数(issue 需要 repo+number;pull 只需 repo)
314
+ */
315
+ export async function handleUpload(
316
+ shortcut: ResolvedShortcut,
317
+ filePath: string | undefined,
318
+ toolFunction: UploadApiFunction,
319
+ pathParams: UploadPathParams,
320
+ ): Promise<UploadResult> {
321
+ // 1. 校验文件
322
+ const validated = validateFile(filePath);
323
+ if (!validated.ok) return validated.result;
324
+ const { filePath: validPath, fileName, fileSize, contentType } = validated.info;
325
+
326
+ // 2. 调用上传 API 获取 upload_url,并预先组装好成功时要返回的 data
327
+ let uploadUrl: string | undefined;
328
+ let successData: IssueUploadData | PullUploadData;
329
+
330
+ if (shortcut.module === 'issues') {
331
+ // Issue 评论:调 post-issue-comment-{file|image}-asset-upload-url
332
+ // 函数签名:({ repo, number }, { name, size, content_type })
333
+ if (!pathParams.number) {
334
+ return { status: 400, data: { error: '缺少 issue number(请确认 CNB_ISSUE_IID 是否已设置)' } };
335
+ }
336
+ const apiResponse = await toolFunction(
337
+ { repo: pathParams.repo, number: pathParams.number },
338
+ { name: fileName, size: fileSize, content_type: contentType },
339
+ );
340
+ const data = apiResponse?.data as IssueAssetUploadURLResponseData | undefined;
341
+ uploadUrl = data?.upload_url;
342
+ if (!uploadUrl) return apiResponse as unknown as UploadResult;
343
+ successData = {
344
+ asset_link: data?.asset_link,
345
+ name: data?.name || fileName,
346
+ path: data?.path,
347
+ size: fileSize,
348
+ };
349
+ } else {
350
+ // Pull:沿用 upload-files / upload-imgs 一步走接口
351
+ // 函数签名:(repo: string, { name, size })
352
+ const apiResponse = await toolFunction(pathParams.repo, { name: fileName, size: fileSize });
353
+ const data = apiResponse?.data as PullUploadResponseData | undefined;
354
+ uploadUrl = data?.upload_url;
355
+ if (!uploadUrl) return apiResponse as unknown as UploadResult;
356
+ successData = {
357
+ name: data?.assets?.name || fileName,
358
+ path: data?.assets?.path,
359
+ size: fileSize,
360
+ token: data?.token,
361
+ };
362
+ }
363
+
364
+ // 3. PUT 文件流到 upload_url
365
+ const putErr = await putFileStream(validPath, uploadUrl, contentType, fileSize);
366
+ if (putErr) return putErr;
367
+
368
+ // 4. 返回最终结果
369
+ return { status: 200, data: successData };
370
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cnbcool/cnb-api-generate",
3
- "version": "2.6.1",
3
+ "version": "2.7.0",
4
4
  "main": "./built/index.js",
5
5
  "module": "./src/index.ts",
6
6
  "types": "./src/index.ts",