@cnbcool/cnb-api-generate 2.21.2 → 2.21.4
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/core.ts +41 -11
- package/client/lib/build-helpers.ts +3 -2
- package/client/lib/login.ts +9 -1
- package/client/shortcuts.ts +1 -1
- package/client/utils/do-token-request.ts +2 -1
- package/client/utils/fetch-error.ts +71 -0
- package/client/utils/refresh-token.ts +2 -1
- package/client/utils/request-device-code.ts +2 -1
- package/client/utils/upload.ts +2 -1
- package/client/utils/wrap-fetch-error.ts +42 -0
- package/package.json +1 -1
- package/quick-commands.json +0 -1
- package/skills-template/SKILL.md +0 -1
package/client/core.ts
CHANGED
|
@@ -5,6 +5,8 @@ import { fetchResponseHandler } from "./fetch-response-handler";
|
|
|
5
5
|
import { generateUniqueId } from './utils/generate-unique-id';
|
|
6
6
|
import { resolveToken } from './utils/resolve-token';
|
|
7
7
|
import { resolveApiDomain } from './utils/resolve-api-domain';
|
|
8
|
+
import { wrapFetchError } from './utils/wrap-fetch-error';
|
|
9
|
+
import { describeFetchError, isNetworkError } from './utils/fetch-error';
|
|
8
10
|
|
|
9
11
|
/**
|
|
10
12
|
* 将 buffer 写入临时文件并返回文件路径
|
|
@@ -128,6 +130,27 @@ async function formatResponse(data: any, response: any) {
|
|
|
128
130
|
return responseData;
|
|
129
131
|
}
|
|
130
132
|
|
|
133
|
+
/**
|
|
134
|
+
* 网络层失败时给出一条能定位问题的错误响应,而不是把 `TypeError: fetch failed` 抛出去。
|
|
135
|
+
* 输出结构与正常响应保持一致(含 status / data.error),下游退出码逻辑照常生效。
|
|
136
|
+
* 错误文案直接用底层原始信息,不另做翻译。
|
|
137
|
+
*/
|
|
138
|
+
function buildNetworkErrorResponse(err: any, url: string) {
|
|
139
|
+
return {
|
|
140
|
+
status: 0,
|
|
141
|
+
error: {
|
|
142
|
+
reason: describeFetchError(err),
|
|
143
|
+
message: err?.message || String(err),
|
|
144
|
+
url,
|
|
145
|
+
},
|
|
146
|
+
data: {
|
|
147
|
+
error: describeFetchError(err),
|
|
148
|
+
message: err?.message || String(err),
|
|
149
|
+
request_url: url,
|
|
150
|
+
},
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
131
154
|
async function clientFetch(data: any): Promise<any> {
|
|
132
155
|
const token = await resolveToken();
|
|
133
156
|
const domain = resolveApiDomain()
|
|
@@ -140,17 +163,24 @@ async function clientFetch(data: any): Promise<any> {
|
|
|
140
163
|
}
|
|
141
164
|
}
|
|
142
165
|
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
166
|
+
let response: Response;
|
|
167
|
+
try {
|
|
168
|
+
response = await wrapFetchError(fetch)(urlParse.href, {
|
|
169
|
+
method: data.method.toUpperCase(),
|
|
170
|
+
body: data.data ? JSON.stringify(data.data) : undefined,
|
|
171
|
+
headers: {
|
|
172
|
+
Authorization: `Bearer ${token}`,
|
|
173
|
+
Accept: 'application/vnd.cnb.api+json',
|
|
174
|
+
// 当请求体存在时,附加 Content-Type,避免后端无法识别 JSON body
|
|
175
|
+
...(data.data ? { 'Content-Type': 'application/json' } : {}),
|
|
176
|
+
...(data?.header || {}),
|
|
177
|
+
},
|
|
178
|
+
});
|
|
179
|
+
} catch (err) {
|
|
180
|
+
if (!isNetworkError(err)) throw err;
|
|
181
|
+
// 不打印到 stderr(避免与格式化输出混在一起),直接把原因放进响应体
|
|
182
|
+
return buildNetworkErrorResponse(err, urlParse.href);
|
|
183
|
+
}
|
|
154
184
|
|
|
155
185
|
return await formatResponse(data, response);
|
|
156
186
|
}
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
import { resolveToken } from '../utils/resolve-token';
|
|
10
10
|
import { resolveApiDomain } from '../utils/resolve-api-domain';
|
|
11
|
+
import { wrapFetchError } from '../utils/wrap-fetch-error';
|
|
11
12
|
|
|
12
13
|
/** CNB 构建号格式:cnb-xxx-xxxxxxxxx(字母/数字组成的两段) */
|
|
13
14
|
export const SN_PATTERN = /^cnb-[a-z0-9]+-[a-z0-9]+$/i;
|
|
@@ -26,7 +27,7 @@ export function invalidSnMessage(sn: string): string {
|
|
|
26
27
|
}
|
|
27
28
|
|
|
28
29
|
export async function apiRequest(url: string, headers: Record<string, string>): Promise<any> {
|
|
29
|
-
const res = await fetch(url, { headers });
|
|
30
|
+
const res = await wrapFetchError(fetch)(url, { headers });
|
|
30
31
|
if (!res.ok) {
|
|
31
32
|
// 注意:错误消息中暴露 URL 是有意的,便于调试。
|
|
32
33
|
// 本项目所有 API 凭证通过 Authorization header 传递,不走 query string,
|
|
@@ -37,7 +38,7 @@ export async function apiRequest(url: string, headers: Record<string, string>):
|
|
|
37
38
|
}
|
|
38
39
|
|
|
39
40
|
export async function apiRequestText(url: string, headers: Record<string, string>): Promise<string> {
|
|
40
|
-
const res = await fetch(url, { headers });
|
|
41
|
+
const res = await wrapFetchError(fetch)(url, { headers });
|
|
41
42
|
if (!res.ok) {
|
|
42
43
|
// 见 apiRequest 注释
|
|
43
44
|
throw new Error(`请求失败: ${res.status} ${res.statusText} — ${url}`);
|
package/client/lib/login.ts
CHANGED
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
} from '../utils/device-auth';
|
|
11
11
|
import { tryOpenBrowser } from '../utils/open-browser';
|
|
12
12
|
import { resolvePlatformUrl } from '../utils/resolve-platform-host';
|
|
13
|
+
import { isNetworkError, printFetchError } from '../utils/fetch-error';
|
|
13
14
|
|
|
14
15
|
export function registerLoginCommand(program: Command): void {
|
|
15
16
|
program
|
|
@@ -98,7 +99,14 @@ export function registerLoginCommand(program: Command): void {
|
|
|
98
99
|
console.log(`Token 已保存到 ${getTokenPath()}`);
|
|
99
100
|
}
|
|
100
101
|
} catch (err: any) {
|
|
101
|
-
console.error(
|
|
102
|
+
console.error('');
|
|
103
|
+
if (isNetworkError(err)) {
|
|
104
|
+
// 网络层失败(DNS / 连接 / 证书 / 超时等),原样输出底层错误信息,不做翻译
|
|
105
|
+
printFetchError(err, '登录失败');
|
|
106
|
+
console.error('提示:若网络需要代理,请先设置 HTTPS_PROXY 后重试;也可用 --debug 查看请求详情。');
|
|
107
|
+
} else {
|
|
108
|
+
console.error(`登录失败: ${err?.message || err}`);
|
|
109
|
+
}
|
|
102
110
|
process.exit(1);
|
|
103
111
|
}
|
|
104
112
|
});
|
package/client/shortcuts.ts
CHANGED
|
@@ -28,7 +28,7 @@ export interface ShortcutDefinition {
|
|
|
28
28
|
custom?: boolean;
|
|
29
29
|
/** 显示用的中文说明 */
|
|
30
30
|
description: string;
|
|
31
|
-
/** 自动注入的 data 参数(如 close/open
|
|
31
|
+
/** 自动注入的 data 参数(如 close/open 的状态值) */
|
|
32
32
|
autoData?: Record<string, any>;
|
|
33
33
|
/** --data 的使用提示(展示用) */
|
|
34
34
|
dataTip?: string;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { LoginConfig, TokenResponse, TokenErrorResponse } from './types';
|
|
2
|
+
import { wrapFetchError } from './wrap-fetch-error';
|
|
2
3
|
|
|
3
4
|
const GRANT_TYPE_DEVICE_CODE = 'urn:ietf:params:oauth:grant-type:device_code';
|
|
4
5
|
|
|
@@ -24,7 +25,7 @@ export async function doTokenRequest(
|
|
|
24
25
|
console.log(`[DEBUG] POST ${tokenURL} body=${form.toString()}`);
|
|
25
26
|
}
|
|
26
27
|
|
|
27
|
-
const resp = await fetch(tokenURL, {
|
|
28
|
+
const resp = await wrapFetchError(fetch)(tokenURL, {
|
|
28
29
|
method: 'POST',
|
|
29
30
|
headers,
|
|
30
31
|
body: form.toString(),
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import type { FetchErrorLike } from './wrap-fetch-error';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* fetch 抛出的 `TypeError: fetch failed` 本身没有信息量,
|
|
5
|
+
* 真正的原因(ENOTFOUND / ECONNREFUSED / 证书 / 超时…)藏在 err.cause 里。
|
|
6
|
+
*
|
|
7
|
+
* 这里只做「把底层原始信息摊平」,不做翻译:错误文案由 Node / undici 提供,
|
|
8
|
+
* 随版本更新自动同步,也不会因为自造措辞与真实原因对不上而误导排查。
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** 错误对象的宽松视图(Node 的 err.cause / err.code 不在标准 Error 类型里) */
|
|
12
|
+
type AnyError = FetchErrorLike & {
|
|
13
|
+
code?: string;
|
|
14
|
+
cause?: any;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
/** 从 aggregate error(Node 双栈连接失败时常见)里挑一个最能说明问题的子错误 */
|
|
18
|
+
function pickCauseError(cause: any): any {
|
|
19
|
+
if (!cause) return undefined;
|
|
20
|
+
if (Array.isArray(cause.errors) && cause.errors.length > 0) {
|
|
21
|
+
// 优先选非 ECONNREFUSED 的错误:双栈下 IPv4 被拒、IPv6 缺路由,后者更贴近真实原因
|
|
22
|
+
const meaningful = cause.errors.find((e: any) => e && e.code && e.code !== 'ECONNREFUSED');
|
|
23
|
+
return meaningful || cause.errors[0];
|
|
24
|
+
}
|
|
25
|
+
return cause;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* 判断是否为网络层失败(fetch 抛出的 TypeError / 超时 / 被中断)。
|
|
30
|
+
* HTTP 4xx/5xx 不会走到这里——那是响应,不是异常。
|
|
31
|
+
*/
|
|
32
|
+
export function isNetworkError(err: any): boolean {
|
|
33
|
+
if (!err) return false;
|
|
34
|
+
return err.name === 'TypeError' || err.name === 'AbortError' || err.name === 'TimeoutError' || !!err.cause;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* 生成错误描述:优先用底层原始信息,并附上错误码。
|
|
39
|
+
* HTTP 状态码层面的错误(4xx/5xx)不走这里,由调用方自行拼装。
|
|
40
|
+
*
|
|
41
|
+
* @param err 捕获到的错误
|
|
42
|
+
* @returns 形如 `getaddrinfo ENOTFOUND abc.invalid.cool(ENOTFOUND)`
|
|
43
|
+
*/
|
|
44
|
+
export function describeFetchError(err: AnyError | undefined): string {
|
|
45
|
+
// new Error('') 的 message 为空串但不是 falsy,需显式判空
|
|
46
|
+
const rawMessage = err?.message || (err ? String(err) : '');
|
|
47
|
+
const cause = pickCauseError(err?.cause);
|
|
48
|
+
const code = cause?.code || err?.code;
|
|
49
|
+
const causeMessage = cause?.message || '';
|
|
50
|
+
// `fetch failed` 是 fetch 的兜底文案,有错误码时只给码和底层信息,免得输出「fetch failed(ENOTFOUND)」
|
|
51
|
+
const hasDetail = !!causeMessage || !!code;
|
|
52
|
+
const usableRaw = rawMessage && rawMessage !== 'Error' && !(hasDetail && rawMessage === 'fetch failed') ? rawMessage : '';
|
|
53
|
+
const reason = causeMessage || usableRaw || code || '未知错误';
|
|
54
|
+
// 底层 message 有时已含错误码(如 getaddrinfo ENOTFOUND x),避免重复追加
|
|
55
|
+
return code && !reason.includes(code) ? `${reason}(${code})` : reason;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* 直接打印网络错误提示到 stderr。
|
|
60
|
+
* 保留 `错误:` / `Error:` 双语的仓库惯例,两行都输出原始信息。
|
|
61
|
+
*
|
|
62
|
+
* @param err 捕获到的错误
|
|
63
|
+
* @param context 可选的上下文描述,如「请求 API 失败」「获取 device code 失败」
|
|
64
|
+
*/
|
|
65
|
+
export function printFetchError(err: AnyError | undefined, context?: string): void {
|
|
66
|
+
const head = context ? `${context}:` : '';
|
|
67
|
+
console.error(`错误:${head}${describeFetchError(err)}`);
|
|
68
|
+
console.error(`Error: ${head}${err?.message || err}`);
|
|
69
|
+
const url = err?.requestURL;
|
|
70
|
+
if (url) console.error(`请求地址: ${url}`);
|
|
71
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { saveToken } from './save-token';
|
|
2
2
|
import type { TokenStore, TokenResponse } from './types';
|
|
3
|
+
import { wrapFetchError } from './wrap-fetch-error';
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* 使用 refresh_token 刷新 access_token。
|
|
@@ -20,7 +21,7 @@ export async function refreshAccessToken(store: TokenStore): Promise<string> {
|
|
|
20
21
|
form.set('client_id', clientID);
|
|
21
22
|
|
|
22
23
|
const nowSec = Math.floor(Date.now() / 1000);
|
|
23
|
-
const resp = await fetch(tokenURL, {
|
|
24
|
+
const resp = await wrapFetchError(fetch)(tokenURL, {
|
|
24
25
|
method: 'POST',
|
|
25
26
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
26
27
|
body: form.toString(),
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { LoginConfig, DeviceAuthResponse } from './types';
|
|
2
|
+
import { wrapFetchError } from './wrap-fetch-error';
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* 调用 /oauth2/device/auth 端点获取 device_code / user_code 等。
|
|
@@ -17,7 +18,7 @@ export async function requestDeviceCode(cfg: LoginConfig, signal?: AbortSignal):
|
|
|
17
18
|
console.log(`[DEBUG] POST ${endpoint} body=${form.toString()}`);
|
|
18
19
|
}
|
|
19
20
|
|
|
20
|
-
const resp = await fetch(endpoint, {
|
|
21
|
+
const resp = await wrapFetchError(fetch)(endpoint, {
|
|
21
22
|
method: 'POST',
|
|
22
23
|
headers,
|
|
23
24
|
body: form.toString(),
|
package/client/utils/upload.ts
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
import fs from 'fs';
|
|
10
10
|
import nodePath from 'path';
|
|
11
11
|
import type { ResolvedShortcut } from '../shortcuts';
|
|
12
|
+
import { wrapFetchError } from './wrap-fetch-error';
|
|
12
13
|
|
|
13
14
|
/** 常见文件扩展名 → MIME 类型映射(仅用于上传场景,不引入外部依赖) */
|
|
14
15
|
const MIME_MAP: Record<string, string> = {
|
|
@@ -199,7 +200,7 @@ async function putFileStream(
|
|
|
199
200
|
const fileStream = fs.createReadStream(filePath);
|
|
200
201
|
let putResponse: Response;
|
|
201
202
|
try {
|
|
202
|
-
putResponse = await fetch(uploadUrl, {
|
|
203
|
+
putResponse = await wrapFetchError(fetch)(uploadUrl, {
|
|
203
204
|
method: 'PUT',
|
|
204
205
|
headers: { 'Content-Type': contentType, 'Content-Length': String(fileSize) },
|
|
205
206
|
body: fileStream as any,
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* fetch 失败时抛出的 `TypeError: fetch failed` 丢掉了真正的原因,
|
|
3
|
+
* 这里统一补上请求地址(err.requestURL),供上层拼装可读的错误提示。
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/** 带请求地址的 fetch 错误 */
|
|
7
|
+
export interface FetchErrorLike extends Error {
|
|
8
|
+
requestURL?: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* 给 fetch 错误挂上请求地址,方便上层说明「哪个地址失败了」。
|
|
13
|
+
* 不修改原始 message,避免影响既有日志与测试断言。
|
|
14
|
+
*/
|
|
15
|
+
export function createFetchError(err: unknown, url?: string): FetchErrorLike {
|
|
16
|
+
const error: FetchErrorLike = err instanceof Error ? err : new Error(String(err));
|
|
17
|
+
if (url && !error.requestURL) {
|
|
18
|
+
try {
|
|
19
|
+
error.requestURL = url;
|
|
20
|
+
} catch {
|
|
21
|
+
// 只读属性等异常场景直接忽略,不影响主流程
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return error;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* 包装 fetch 函数:仅在请求抛错(网络层失败)时附加 requestURL。
|
|
29
|
+
* 用法:`const wrappedFetch = wrapFetchError(fetch); await wrappedFetch(url, init)`
|
|
30
|
+
*/
|
|
31
|
+
export function wrapFetchError(
|
|
32
|
+
fetchImpl: typeof fetch,
|
|
33
|
+
): (input: RequestInfo | URL, init?: RequestInit) => Promise<Response> {
|
|
34
|
+
return async function wrappedFetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
|
|
35
|
+
const url = typeof input === 'string' ? input : (input as any)?.url || String(input);
|
|
36
|
+
try {
|
|
37
|
+
return await fetchImpl(input, init);
|
|
38
|
+
} catch (err) {
|
|
39
|
+
throw createFetchError(err, url);
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
}
|
package/package.json
CHANGED
package/quick-commands.json
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
{
|
|
2
2
|
"issues": [
|
|
3
|
-
{ "shortName": "create-issue", "realTool": "create-issue", "description": "创建 issue", "autoData": { "work_mode": true }, "hideInSkill": true, "dataTip": "--title 标题" },
|
|
4
3
|
{ "shortName": "get", "realTool": "get-issue", "description": "获取详情" },
|
|
5
4
|
{ "shortName": "list-comments", "realTool": "list-issue-comments", "description": "获取评论列表" },
|
|
6
5
|
{ "shortName": "comment", "realTool": "post-issue-comment", "description": "评论", "dataTip": "--body 内容" },
|
package/skills-template/SKILL.md
CHANGED
|
@@ -13,7 +13,6 @@ description: CNB 平台交互命令,支持代码仓库、Issue、PR、CI、制
|
|
|
13
13
|
|
|
14
14
|
- **默认摘要省流**:默认精简输出,加 `--verbose` 输出完整数据。
|
|
15
15
|
- **多行用单引号**:bash 参数为多行文本时用单引号,降低命令注入风险。
|
|
16
|
-
- **创建 issue 默认带 `--work-mode`**:`cnb issues create-issue` 已默认注入 `work_mode: true`,不必再传;`--work-mode` 也仍可从 `--help` 看到并显式覆盖。
|
|
17
16
|
|
|
18
17
|
## issue/pr 访问说明
|
|
19
18
|
|