@cnbcool/cnb-api-generate 2.6.0 → 2.6.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.
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
@@ -72,43 +72,34 @@ function mimeLookup(filePath: string): string {
72
72
  // 上传文件大小上限:100MB
73
73
  const MAX_FILE_SIZE = 100 * 1024 * 1024;
74
74
 
75
- /** 上传 API 的 path 参数(issue 走 asset-group 流程时只需要 repo) */
76
- interface UploadPathParams {
75
+ /** 上传 API 的 path 参数(issue 走 comment-asset-upload-url 流程需要 repo + number) */
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
- /** Issue asset-group 单条附件的描述(请求体里的元素) */
82
- interface AssetItem {
83
+ /** Issue comment-asset-upload-url 接口请求体(单文件,与 swagger PostIssueAssetUploadURLForm 对齐) */
84
+ interface IssueAssetUploadURLBody {
83
85
  name: string;
84
86
  size: number;
85
87
  content_type: string;
86
88
  }
87
89
 
88
- /** Issue asset-group 创建请求体 */
89
- interface IssueAssetGroupBody {
90
- file_assets?: AssetItem[];
91
- image_assets?: AssetItem[];
92
- }
93
-
94
90
  /** Pull 上传请求体 */
95
91
  interface PullUploadBody {
96
92
  name: string;
97
93
  size: number;
98
94
  }
99
95
 
100
- /** Issue asset-group 接口响应(与 swagger 一致;只列出我们用到的字段) */
101
- interface AssetUploadEntry {
96
+ /** Issue comment-asset-upload-url 接口响应(与 swagger api.IssueAssetUploadURL 对齐) */
97
+ interface IssueAssetUploadURLResponseData {
102
98
  name?: string;
103
99
  path?: string;
104
100
  asset_link?: string;
105
101
  upload_url?: string;
106
102
  }
107
- interface IssueAssetGroupResponseData {
108
- asset_group_id?: string;
109
- file_upload_urls?: AssetUploadEntry[];
110
- image_upload_urls?: AssetUploadEntry[];
111
- }
112
103
 
113
104
  /** Pull 上传 API 响应 */
114
105
  interface PullUploadResponseData {
@@ -117,11 +108,11 @@ interface PullUploadResponseData {
117
108
  token?: string;
118
109
  }
119
110
 
120
- /** 上传 API 函数签名(issue 与 pull 分别由对应的 swagger 函数承担,统一为 (firstArg, body)) */
111
+ /** 上传 API 函数签名(issue 与 pull 分别由对应的 swagger 函数承担) */
121
112
  type UploadApiFunction = (
122
113
  firstArg: UploadPathParams | string,
123
- body: IssueAssetGroupBody | PullUploadBody,
124
- ) => Promise<{ status?: number; data?: IssueAssetGroupResponseData | PullUploadResponseData }>;
114
+ body: IssueAssetUploadURLBody | PullUploadBody,
115
+ ) => Promise<{ status?: number; data?: IssueAssetUploadURLResponseData | PullUploadResponseData }>;
125
116
 
126
117
  /** 上传失败时的 data */
127
118
  interface UploadErrorData {
@@ -129,12 +120,13 @@ interface UploadErrorData {
129
120
  detail?: string;
130
121
  }
131
122
 
132
- /** Issue 上传成功时的 data */
123
+ /** Issue 上传成功时的 data(asset_group_id 仅在创建 issue 流程下返回) */
133
124
  interface IssueUploadData {
134
125
  asset_link?: string;
135
- asset_group_id?: string;
136
126
  name: string;
127
+ path?: string;
137
128
  size: number;
129
+ asset_group_id?: string;
138
130
  }
139
131
 
140
132
  /** Pull 上传成功时的 data */
@@ -145,120 +137,64 @@ interface PullUploadData {
145
137
  token?: string;
146
138
  }
147
139
 
148
- /** handleUpload 的返回值 */
140
+ /** handleUpload / handleIssueCreateUpload 的返回值 */
149
141
  interface UploadResult {
150
142
  status: number;
151
143
  data: UploadErrorData | IssueUploadData | PullUploadData;
152
144
  }
153
145
 
154
- /**
155
- * 处理完整上传流程
156
- *
157
- * Issue 路径(走 asset-group 一次性接口):
158
- * 1. POST /{repo}/-/issues/asset-groups 携带 file_assets 或 image_assets
159
- * → 服务端创建 asset_group 并返回 upload_url + asset_link
160
- * 2. PUT 文件流到 upload_url
161
- * 3. 返回 asset_link 给调用方拼到评论或 issue 描述里
162
- *
163
- * Pull 路径(保留原有的 upload-files / upload-imgs 一步上传):
164
- * 1. POST /{repo}/upload-files(或 imgs) upload_url
165
- * 2. PUT 文件流到 upload_url
166
- *
167
- * @param shortcut 解析后的快捷命令;shortcut.kind 决定 issue 上传走 file 还是 image 通道
168
- * @param filePath 本地文件路径
169
- * @param toolFunction 原始上传 API 函数
170
- * @param pathParams API 调用的 path 参数(issue 只需 repo;pull 也只需 repo)
171
- */
172
- export async function handleUpload(
173
- shortcut: ResolvedShortcut,
174
- filePath: string | undefined,
175
- toolFunction: UploadApiFunction,
176
- pathParams: UploadPathParams,
177
- ): Promise<UploadResult> {
178
- // 校验 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 {
179
157
  if (!filePath) {
180
- return { status: 400, data: { error: `上传命令需要指定文件路径,如: --data '{"file":"./path/to/file"}'` } };
158
+ return { ok: false, result: { status: 400, data: { error: `上传命令需要指定文件路径,如: --data '{"file":"./path/to/file"}'` } } };
181
159
  }
182
-
183
- // 1. 读取本地文件信息
184
160
  if (!fs.existsSync(filePath)) {
185
- return { status: 400, data: { error: `文件不存在: ${filePath}` } };
161
+ return { ok: false, result: { status: 400, data: { error: `文件不存在: ${filePath}` } } };
186
162
  }
187
-
188
163
  const stat = fs.statSync(filePath);
189
164
  if (!stat.isFile()) {
190
- return { status: 400, data: { error: `不是文件: ${filePath}` } };
165
+ return { ok: false, result: { status: 400, data: { error: `不是文件: ${filePath}` } } };
191
166
  }
192
-
193
- const fileName = nodePath.basename(filePath);
194
167
  const fileSize = stat.size;
195
-
196
168
  if (fileSize === 0) {
197
- return { status: 400, data: { error: '文件为空' } };
169
+ return { ok: false, result: { status: 400, data: { error: '文件为空' } } };
198
170
  }
199
171
  if (fileSize > MAX_FILE_SIZE) {
200
- return { status: 400, data: { error: `文件过大 (${(fileSize / 1024 / 1024).toFixed(1)}MB),上限 ${MAX_FILE_SIZE / 1024 / 1024}MB` } };
201
- }
202
-
203
- const contentType = mimeLookup(filePath);
204
-
205
- // 2. 调用上传 API 获取 upload_url
206
- const isIssueUpload = shortcut.module === 'issues';
207
- const isImageKind = shortcut.kind === 'image';
208
-
209
- let uploadUrl: string | undefined;
210
- let issueAssetLink: string | undefined;
211
- let issueAssetGroupId: string | undefined;
212
- let pullResponseData: PullUploadResponseData | undefined;
213
-
214
- if (isIssueUpload) {
215
- // Issue: 调 post-asset-group,一次拿 asset_group_id + upload_url + asset_link
216
- // 函数签名:(repo: string, { file_assets | image_assets }, ...)
217
- const assetItem: AssetItem = { name: fileName, size: fileSize, content_type: contentType };
218
- const requestBody: IssueAssetGroupBody = isImageKind
219
- ? { image_assets: [assetItem] }
220
- : { file_assets: [assetItem] };
221
-
222
- const apiResponse = await toolFunction(pathParams.repo, requestBody);
223
- const data = apiResponse?.data as IssueAssetGroupResponseData | undefined;
224
-
225
- // 服务端可能返回 201/200,从对应数组里取第一条 upload 描述
226
- const entries = isImageKind ? data?.image_upload_urls : data?.file_upload_urls;
227
- const entry = entries?.[0];
228
- uploadUrl = entry?.upload_url;
229
- issueAssetLink = entry?.asset_link;
230
- issueAssetGroupId = data?.asset_group_id;
231
-
232
- if (!uploadUrl) {
233
- // 拿 URL 失败:把原始响应作为错误返回,便于排查
234
- return apiResponse as unknown as UploadResult;
235
- }
236
- } else {
237
- // Pull: 沿用原有"一步走"上传接口
238
- // 函数签名:(repo: string, { name, size }, ...)
239
- const requestBody: PullUploadBody = { name: fileName, size: fileSize };
240
- const apiResponse = await toolFunction(pathParams.repo, requestBody);
241
- pullResponseData = apiResponse?.data as PullUploadResponseData | undefined;
242
- uploadUrl = pullResponseData?.upload_url;
243
-
244
- if (!uploadUrl) {
245
- return apiResponse as unknown as UploadResult;
246
- }
172
+ return { ok: false, result: { status: 400, data: { error: `文件过大 (${(fileSize / 1024 / 1024).toFixed(1)}MB),上限 ${MAX_FILE_SIZE / 1024 / 1024}MB` } } };
247
173
  }
248
-
249
- // 3. PUT 文件内容到 upload_url(流式读取,避免大文件撑爆内存)
250
- const fileStream = fs.createReadStream(filePath);
251
-
252
- const putHeaders: Record<string, string> = {
253
- 'Content-Type': contentType,
254
- '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
+ },
255
182
  };
183
+ }
256
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);
257
193
  let putResponse: Response;
258
194
  try {
259
195
  putResponse = await fetch(uploadUrl, {
260
196
  method: 'PUT',
261
- headers: putHeaders,
197
+ headers: { 'Content-Type': contentType, 'Content-Length': String(fileSize) },
262
198
  body: fileStream as any,
263
199
  // @ts-ignore duplex required for streaming body in Node.js fetch
264
200
  duplex: 'half',
@@ -267,36 +203,168 @@ export async function handleUpload(
267
203
  fileStream.destroy();
268
204
  throw e;
269
205
  }
270
-
271
206
  if (!putResponse.ok) {
272
207
  fileStream.destroy();
273
208
  const errText = await putResponse.text().catch(() => '');
274
- return {
275
- status: putResponse.status,
276
- data: { error: `文件上传失败: ${putResponse.statusText}`, detail: errText },
277
- };
209
+ return { status: putResponse.status, data: { error: `文件上传失败: ${putResponse.statusText}`, detail: errText } };
278
210
  }
211
+ return undefined;
212
+ }
279
213
 
280
- // 4. 返回最终结果
281
- if (isIssueUpload) {
282
- return {
283
- status: 200,
284
- data: {
285
- asset_link: issueAssetLink,
286
- asset_group_id: issueAssetGroupId,
287
- name: fileName,
288
- size: fileSize,
289
- },
290
- };
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;
291
277
  }
292
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 备用)
293
284
  return {
294
285
  status: 200,
295
286
  data: {
296
- name: pullResponseData?.assets?.name || fileName,
297
- path: pullResponseData?.assets?.path,
287
+ asset_link: firstEntry?.asset_link,
288
+ name: firstEntry?.name || fileName,
289
+ path: firstEntry?.path,
298
290
  size: fileSize,
299
- token: pullResponseData?.token,
291
+ asset_group_id: groupData?.asset_group_id,
300
292
  },
301
293
  };
302
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.0",
3
+ "version": "2.6.2",
4
4
  "main": "./built/index.js",
5
5
  "module": "./src/index.ts",
6
6
  "types": "./src/index.ts",