@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 +1 -0
- package/client/lib/execute-action.ts +14 -20
- package/client/lib/register-modules.ts +9 -12
- package/client/lib/status.ts +33 -19
- package/client/utils/resolve-token-source.ts +82 -0
- package/client/utils/resolve-token.ts +11 -49
- package/client/utils/upload.ts +200 -132
- package/package.json +1 -1
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
|
|
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
|
-
|
|
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
|
-
//
|
|
139
|
-
if (shortcut.
|
|
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);
|
package/client/lib/status.ts
CHANGED
|
@@ -1,7 +1,13 @@
|
|
|
1
1
|
import { Command } from 'commander';
|
|
2
|
-
import {
|
|
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
|
|
14
|
-
|
|
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
|
-
|
|
17
|
-
|
|
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 : ${
|
|
24
|
-
console.log(`Client ID : ${
|
|
25
|
-
console.log(`Expires At : ${new Date(
|
|
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 >=
|
|
47
|
+
const isExpired = nowSec >= store.expires_at;
|
|
30
48
|
|
|
31
49
|
if (isExpired) {
|
|
32
|
-
|
|
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(
|
|
39
|
-
|
|
40
|
-
if (
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
55
|
-
|
|
14
|
+
if (result.source === 'none') {
|
|
15
|
+
console.error(result.reason || '未找到有效的登录凭证,请先执行 `cnb login` 进行授权。');
|
|
16
|
+
process.exit(1);
|
|
56
17
|
}
|
|
57
18
|
|
|
58
|
-
|
|
59
|
-
if (
|
|
60
|
-
|
|
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
|
|
30
|
+
return result.access_token;
|
|
69
31
|
}
|
|
70
32
|
|
|
71
33
|
// 已过期,尝试 refresh
|
package/client/utils/upload.ts
CHANGED
|
@@ -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-
|
|
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-
|
|
82
|
-
interface
|
|
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-
|
|
101
|
-
interface
|
|
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
|
|
111
|
+
/** 上传 API 函数签名(issue 与 pull 分别由对应的 swagger 函数承担) */
|
|
121
112
|
type UploadApiFunction = (
|
|
122
113
|
firstArg: UploadPathParams | string,
|
|
123
|
-
body:
|
|
124
|
-
) => Promise<{ status?: number; data?:
|
|
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
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
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:
|
|
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
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
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
|
-
|
|
297
|
-
|
|
287
|
+
asset_link: firstEntry?.asset_link,
|
|
288
|
+
name: firstEntry?.name || fileName,
|
|
289
|
+
path: firstEntry?.path,
|
|
298
290
|
size: fileSize,
|
|
299
|
-
|
|
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
|
+
}
|