@guanwenai/high-tech-project-cli 0.1.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/package.json +19 -0
- package/scripts/cli/arguments.mjs +54 -0
- package/scripts/cli/commandRegistry.mjs +80 -0
- package/scripts/cli/output.mjs +23 -0
- package/scripts/core/context.mjs +35 -0
- package/scripts/core/errors.mjs +37 -0
- package/scripts/core/files.mjs +143 -0
- package/scripts/core/gatewayClient.mjs +275 -0
- package/scripts/core/userConfig.mjs +100 -0
- package/scripts/high-tech-project-cli.mjs +272 -0
- package/scripts/install/configure-gateway.mjs +39 -0
- package/scripts/methodMappings.mjs +29 -0
- package/scripts/services/achievement.mjs +147 -0
- package/scripts/services/finance.mjs +145 -0
- package/scripts/services/ipr.mjs +121 -0
- package/scripts/services/knowledge.mjs +205 -0
- package/scripts/services/material.mjs +252 -0
- package/scripts/services/people.mjs +160 -0
- package/scripts/services/policy.mjs +209 -0
- package/scripts/services/product.mjs +220 -0
- package/scripts/services/project.mjs +99 -0
- package/scripts/services/rd.mjs +225 -0
- package/scripts/services/report.mjs +117 -0
- package/scripts/services/shared.mjs +234 -0
- package/scripts/services/sop.mjs +592 -0
- package/scripts/services/workflow.mjs +99 -0
package/package.json
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@guanwenai/high-tech-project-cli",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Gateway-only CLI for Chinese high-tech enterprise project declarations",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"high-tech-project": "scripts/high-tech-project-cli.mjs"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"scripts"
|
|
11
|
+
],
|
|
12
|
+
"scripts": {
|
|
13
|
+
"postinstall": "node scripts/install/configure-gateway.mjs"
|
|
14
|
+
},
|
|
15
|
+
"engines": {
|
|
16
|
+
"node": ">=20"
|
|
17
|
+
},
|
|
18
|
+
"license": "UNLICENSED"
|
|
19
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { CliError } from '../core/errors.mjs';
|
|
2
|
+
|
|
3
|
+
const OPTION_NAMES = new Map([
|
|
4
|
+
['--input', 'input'],
|
|
5
|
+
['--base-dir', 'baseDir'],
|
|
6
|
+
['--gateway-url', 'gatewayUrl'],
|
|
7
|
+
['--tenant-code', 'tenantCode'],
|
|
8
|
+
['--user-id', 'userId'],
|
|
9
|
+
['--user-name', 'userName'],
|
|
10
|
+
['--realname', 'realname'],
|
|
11
|
+
['--context-id', 'contextId'],
|
|
12
|
+
['--pretty', 'pretty'],
|
|
13
|
+
]);
|
|
14
|
+
|
|
15
|
+
function invalid(message, details = {}) {
|
|
16
|
+
throw new CliError('ARGUMENT_INVALID', message, details);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function parseArguments(argv) {
|
|
20
|
+
const optionStart = argv.findIndex((token) => token.startsWith('--'));
|
|
21
|
+
const commandTokens = optionStart === -1 ? argv : argv.slice(0, optionStart);
|
|
22
|
+
const optionTokens = optionStart === -1 ? [] : argv.slice(optionStart);
|
|
23
|
+
if (commandTokens.length < 1 || commandTokens.length > 2) {
|
|
24
|
+
invalid('命令格式应为 <domain> <operation>');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const options = {};
|
|
28
|
+
for (let index = 0; index < optionTokens.length; index += 1) {
|
|
29
|
+
const token = optionTokens[index];
|
|
30
|
+
if (token === '--uuid') {
|
|
31
|
+
invalid('当前项目由 contextId 唯一绑定,禁止通过 --uuid 覆盖');
|
|
32
|
+
}
|
|
33
|
+
const optionName = OPTION_NAMES.get(token);
|
|
34
|
+
if (!optionName) {
|
|
35
|
+
invalid(token === '--kfcloud-auth' ? '认证令牌禁止通过命令行传入' : `未知参数: ${token}`);
|
|
36
|
+
}
|
|
37
|
+
if (Object.hasOwn(options, optionName)) {
|
|
38
|
+
invalid(`参数不可重复: ${token}`);
|
|
39
|
+
}
|
|
40
|
+
if (token === '--pretty') {
|
|
41
|
+
options[optionName] = true;
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const value = optionTokens[index + 1];
|
|
46
|
+
if (value === undefined || value.startsWith('--')) {
|
|
47
|
+
invalid(`参数缺少值: ${token}`);
|
|
48
|
+
}
|
|
49
|
+
options[optionName] = value;
|
|
50
|
+
index += 1;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return { command: commandTokens.join('.'), options };
|
|
54
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { CliError } from '../core/errors.mjs';
|
|
2
|
+
|
|
3
|
+
const METHODS = new Set(['GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'DELETE', 'LOCAL']);
|
|
4
|
+
const ALLOWED_PATHS = [
|
|
5
|
+
'/api/internal/v1/project-writing/',
|
|
6
|
+
'/api/project-writing/',
|
|
7
|
+
'/api/knowledge/',
|
|
8
|
+
'/api/v1/knowledge/',
|
|
9
|
+
'/api/file-storage/',
|
|
10
|
+
];
|
|
11
|
+
|
|
12
|
+
function invalidDefinition(message, details = {}) {
|
|
13
|
+
throw new CliError('CLI_INTERNAL_ERROR', message, details);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function validatePath(path) {
|
|
17
|
+
return typeof path === 'string'
|
|
18
|
+
&& !path.includes('\\')
|
|
19
|
+
&& !path.split('/').includes('..')
|
|
20
|
+
&& ALLOWED_PATHS.some((prefix) => path.startsWith(prefix));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function isMutatingPath(path) {
|
|
24
|
+
const action = path.split('/').at(-1) ?? '';
|
|
25
|
+
return /^(?:create|update|save|save-or-update|batch-save|delete|delete-batch|generate|sort|upload|import|bind(?:-|$)|move|retry)/u
|
|
26
|
+
.test(action);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function validateDefinition(definition) {
|
|
30
|
+
if (!definition || typeof definition !== 'object') {
|
|
31
|
+
invalidDefinition('命令定义必须是对象');
|
|
32
|
+
}
|
|
33
|
+
if (!/^[a-z][a-z0-9-]*(?:\.[a-z][a-z0-9-]*)?$/u.test(definition.name ?? '')) {
|
|
34
|
+
invalidDefinition('命令名称无效', { name: definition.name });
|
|
35
|
+
}
|
|
36
|
+
if (!METHODS.has(definition.method)) {
|
|
37
|
+
invalidDefinition('命令方法无效', { name: definition.name, method: definition.method });
|
|
38
|
+
}
|
|
39
|
+
if (definition.method === 'LOCAL') {
|
|
40
|
+
if (definition.path !== null) {
|
|
41
|
+
invalidDefinition('本地命令不能声明 gateway 路径', { name: definition.name });
|
|
42
|
+
}
|
|
43
|
+
} else if (!validatePath(definition.path)) {
|
|
44
|
+
invalidDefinition('命令路径无效', { name: definition.name, path: definition.path });
|
|
45
|
+
}
|
|
46
|
+
if (definition.idempotent === true && definition.method !== 'LOCAL'
|
|
47
|
+
&& isMutatingPath(definition.path)) {
|
|
48
|
+
invalidDefinition('写命令不能标记为幂等', { name: definition.name });
|
|
49
|
+
}
|
|
50
|
+
if (!Array.isArray(definition.requiredContext)
|
|
51
|
+
|| definition.requiredContext.some((field) => typeof field !== 'string')) {
|
|
52
|
+
invalidDefinition('requiredContext 必须是字符串数组', { name: definition.name });
|
|
53
|
+
}
|
|
54
|
+
if (typeof definition.validate !== 'function') {
|
|
55
|
+
invalidDefinition('命令缺少 validate', { name: definition.name });
|
|
56
|
+
}
|
|
57
|
+
if (typeof definition.handler !== 'function') {
|
|
58
|
+
invalidDefinition('命令缺少 handler', { name: definition.name });
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export class CommandRegistry {
|
|
63
|
+
#commands = new Map();
|
|
64
|
+
|
|
65
|
+
register(definition) {
|
|
66
|
+
validateDefinition(definition);
|
|
67
|
+
if (this.#commands.has(definition.name)) {
|
|
68
|
+
invalidDefinition('命令不可重复注册', { name: definition.name });
|
|
69
|
+
}
|
|
70
|
+
this.#commands.set(definition.name, definition);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
get(name) {
|
|
74
|
+
return this.#commands.get(name);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
list() {
|
|
78
|
+
return [...this.#commands.values()];
|
|
79
|
+
}
|
|
80
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { normalizeCliError } from '../core/errors.mjs';
|
|
2
|
+
|
|
3
|
+
export function successEnvelope(command, data, meta) {
|
|
4
|
+
return { ok: true, command, data, meta };
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function failureEnvelope(command, error, meta) {
|
|
8
|
+
const normalized = normalizeCliError(error);
|
|
9
|
+
return {
|
|
10
|
+
ok: false,
|
|
11
|
+
command,
|
|
12
|
+
error: {
|
|
13
|
+
code: normalized.code,
|
|
14
|
+
message: normalized.message,
|
|
15
|
+
details: normalized.details ?? {},
|
|
16
|
+
},
|
|
17
|
+
meta,
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function serializeEnvelope(envelope, pretty = false) {
|
|
22
|
+
return JSON.stringify(envelope, null, pretty ? 2 : undefined);
|
|
23
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
const ENVIRONMENT_FIELDS = {
|
|
2
|
+
gatewayUrl: 'GUANWEN_PROJECT_WRITING_GATEWAY_URL',
|
|
3
|
+
tenantCode: 'GUANWEN_PROJECT_WRITING_TENANT_CODE',
|
|
4
|
+
userId: 'GUANWEN_PROJECT_WRITING_USER_ID',
|
|
5
|
+
userName: 'GUANWEN_PROJECT_WRITING_USER_NAME',
|
|
6
|
+
realname: 'GUANWEN_PROJECT_WRITING_REALNAME',
|
|
7
|
+
contextId: 'GUANWEN_PROJECT_WRITING_CONTEXT_ID',
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
function firstDefined(...values) {
|
|
11
|
+
const value = values.find((candidate) => (
|
|
12
|
+
candidate !== undefined && candidate !== null && String(candidate).trim() !== ''
|
|
13
|
+
));
|
|
14
|
+
return value === undefined ? '' : String(value);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function resolveProjectContext({
|
|
18
|
+
options = {}, input = {}, environment = {}, configuration = {},
|
|
19
|
+
}) {
|
|
20
|
+
const context = {};
|
|
21
|
+
for (const [field, environmentName] of Object.entries(ENVIRONMENT_FIELDS)) {
|
|
22
|
+
context[field] = firstDefined(options[field], input[field], environment[environmentName]);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return {
|
|
26
|
+
gatewayUrl: firstDefined(context.gatewayUrl, configuration.gatewayUrl),
|
|
27
|
+
kfcloudAuth: firstDefined(environment.GUANWEN_PROJECT_WRITING_KFCLOUD_AUTH),
|
|
28
|
+
tenantCode: context.tenantCode,
|
|
29
|
+
userId: context.userId,
|
|
30
|
+
userName: context.userName,
|
|
31
|
+
realname: context.realname,
|
|
32
|
+
contextId: context.contextId,
|
|
33
|
+
uuid: context.contextId,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
const EXIT_CODES = new Map([
|
|
2
|
+
['ARGUMENT_INVALID', 2],
|
|
3
|
+
['CONFIG_MISSING', 2],
|
|
4
|
+
['CONFIG_INVALID', 2],
|
|
5
|
+
['AUTH_EXPIRED', 3],
|
|
6
|
+
['AUTH_FORBIDDEN', 3],
|
|
7
|
+
['BUSINESS_ERROR', 4],
|
|
8
|
+
['NETWORK_ERROR', 5],
|
|
9
|
+
['GATEWAY_TIMEOUT', 5],
|
|
10
|
+
['FILE_NOT_FOUND', 6],
|
|
11
|
+
['FILE_OUTSIDE_BASE', 6],
|
|
12
|
+
['FILE_TOO_LARGE', 6],
|
|
13
|
+
['FILE_WRITE_FAILED', 6],
|
|
14
|
+
['CLI_INTERNAL_ERROR', 7],
|
|
15
|
+
]);
|
|
16
|
+
|
|
17
|
+
export function exitCodeForError(code) {
|
|
18
|
+
return EXIT_CODES.get(code) ?? 4;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export class CliError extends Error {
|
|
22
|
+
constructor(code, message, details = {}, exitCode = exitCodeForError(code)) {
|
|
23
|
+
super(message);
|
|
24
|
+
this.name = 'CliError';
|
|
25
|
+
this.code = code;
|
|
26
|
+
this.details = details;
|
|
27
|
+
this.exitCode = exitCode;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function normalizeCliError(error) {
|
|
32
|
+
if (error instanceof CliError) {
|
|
33
|
+
return error;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return new CliError('CLI_INTERNAL_ERROR', 'CLI 执行失败', {}, 7);
|
|
37
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { readFile, stat } from 'node:fs/promises';
|
|
2
|
+
import { isAbsolute, relative, resolve, sep } from 'node:path';
|
|
3
|
+
|
|
4
|
+
import { CliError } from './errors.mjs';
|
|
5
|
+
|
|
6
|
+
export const MAX_INPUT_BYTES = 10 * 1024 * 1024;
|
|
7
|
+
|
|
8
|
+
function pathDetails(relativePath, baseDir, resolvedPath) {
|
|
9
|
+
return { relativePath, baseDir, resolvedPath };
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function resolveWithinBase(relativePath, baseDir) {
|
|
13
|
+
const resolvedBase = resolve(baseDir);
|
|
14
|
+
const resolvedPath = resolve(resolvedBase, relativePath);
|
|
15
|
+
const relation = relative(resolvedBase, resolvedPath);
|
|
16
|
+
const outside = isAbsolute(relativePath)
|
|
17
|
+
|| relation === '..'
|
|
18
|
+
|| relation.startsWith(`..${sep}`)
|
|
19
|
+
|| isAbsolute(relation);
|
|
20
|
+
|
|
21
|
+
if (outside) {
|
|
22
|
+
throw new CliError(
|
|
23
|
+
'FILE_OUTSIDE_BASE',
|
|
24
|
+
'文件路径超出工作目录',
|
|
25
|
+
pathDetails(relativePath, baseDir, resolvedPath),
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return resolvedPath;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function skipWhitespace(source, state) {
|
|
33
|
+
while (/\s/u.test(source[state.index] ?? '')) {
|
|
34
|
+
state.index += 1;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function readString(source, state) {
|
|
39
|
+
const start = state.index;
|
|
40
|
+
state.index += 1;
|
|
41
|
+
while (state.index < source.length) {
|
|
42
|
+
if (source[state.index] === '\\') {
|
|
43
|
+
state.index += 2;
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
if (source[state.index] === '"') {
|
|
47
|
+
state.index += 1;
|
|
48
|
+
return JSON.parse(source.slice(start, state.index));
|
|
49
|
+
}
|
|
50
|
+
state.index += 1;
|
|
51
|
+
}
|
|
52
|
+
return '';
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function scanValue(source, state) {
|
|
56
|
+
skipWhitespace(source, state);
|
|
57
|
+
if (source[state.index] === '{') {
|
|
58
|
+
scanObject(source, state);
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
if (source[state.index] === '[') {
|
|
62
|
+
state.index += 1;
|
|
63
|
+
skipWhitespace(source, state);
|
|
64
|
+
while (source[state.index] !== ']') {
|
|
65
|
+
scanValue(source, state);
|
|
66
|
+
skipWhitespace(source, state);
|
|
67
|
+
if (source[state.index] === ',') {
|
|
68
|
+
state.index += 1;
|
|
69
|
+
skipWhitespace(source, state);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
state.index += 1;
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
if (source[state.index] === '"') {
|
|
76
|
+
readString(source, state);
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
while (state.index < source.length && !/[\s,}\]]/u.test(source[state.index])) {
|
|
80
|
+
state.index += 1;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function scanObject(source, state) {
|
|
85
|
+
const keys = new Set();
|
|
86
|
+
state.index += 1;
|
|
87
|
+
skipWhitespace(source, state);
|
|
88
|
+
while (source[state.index] !== '}') {
|
|
89
|
+
const key = readString(source, state);
|
|
90
|
+
if (keys.has(key)) {
|
|
91
|
+
throw new CliError('ARGUMENT_INVALID', `JSON 对象包含重复字段: ${key}`);
|
|
92
|
+
}
|
|
93
|
+
keys.add(key);
|
|
94
|
+
skipWhitespace(source, state);
|
|
95
|
+
state.index += 1;
|
|
96
|
+
scanValue(source, state);
|
|
97
|
+
skipWhitespace(source, state);
|
|
98
|
+
if (source[state.index] === ',') {
|
|
99
|
+
state.index += 1;
|
|
100
|
+
skipWhitespace(source, state);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
state.index += 1;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function rejectDuplicateKeys(source) {
|
|
107
|
+
scanValue(source, { index: 0 });
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export async function readJsonInput(relativePath, baseDir) {
|
|
111
|
+
const resolvedPath = resolveWithinBase(relativePath, baseDir);
|
|
112
|
+
const details = pathDetails(relativePath, baseDir, resolvedPath);
|
|
113
|
+
let fileStat;
|
|
114
|
+
try {
|
|
115
|
+
fileStat = await stat(resolvedPath);
|
|
116
|
+
} catch {
|
|
117
|
+
throw new CliError('FILE_NOT_FOUND', '输入文件不存在', details);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (!fileStat.isFile()) {
|
|
121
|
+
throw new CliError('FILE_NOT_FOUND', '输入路径不是文件', details);
|
|
122
|
+
}
|
|
123
|
+
if (fileStat.size > MAX_INPUT_BYTES) {
|
|
124
|
+
throw new CliError('FILE_TOO_LARGE', '输入文件超过 10 MiB', details);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const source = await readFile(resolvedPath, 'utf8');
|
|
128
|
+
let value;
|
|
129
|
+
try {
|
|
130
|
+
value = JSON.parse(source);
|
|
131
|
+
rejectDuplicateKeys(source);
|
|
132
|
+
} catch (error) {
|
|
133
|
+
if (error instanceof CliError) {
|
|
134
|
+
throw error;
|
|
135
|
+
}
|
|
136
|
+
throw new CliError('ARGUMENT_INVALID', '输入文件不是合法 JSON', details);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (value === null || Array.isArray(value) || typeof value !== 'object') {
|
|
140
|
+
throw new CliError('ARGUMENT_INVALID', '输入 JSON 根节点必须是对象', details);
|
|
141
|
+
}
|
|
142
|
+
return value;
|
|
143
|
+
}
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
import { CliError } from './errors.mjs';
|
|
2
|
+
|
|
3
|
+
const ALLOWED_PATHS = [
|
|
4
|
+
'/api/internal/v1/project-writing/',
|
|
5
|
+
'/api/project-writing/',
|
|
6
|
+
'/api/knowledge/',
|
|
7
|
+
'/api/v1/knowledge/',
|
|
8
|
+
'/api/file-storage/',
|
|
9
|
+
];
|
|
10
|
+
const RETRYABLE_STATUS = new Set([502, 503, 504]);
|
|
11
|
+
const FORBIDDEN_AGENT_PATH = ['/api', 'agent/'].join('/');
|
|
12
|
+
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
13
|
+
const DEFAULT_FILE_TIMEOUT_MS = 120_000;
|
|
14
|
+
|
|
15
|
+
function validateGatewayUrl(value) {
|
|
16
|
+
let url;
|
|
17
|
+
try {
|
|
18
|
+
url = new URL(value);
|
|
19
|
+
} catch {
|
|
20
|
+
throw new CliError('ARGUMENT_INVALID', 'gateway URL 无效');
|
|
21
|
+
}
|
|
22
|
+
if (!['http:', 'https:'].includes(url.protocol)
|
|
23
|
+
|| url.username
|
|
24
|
+
|| url.password
|
|
25
|
+
|| url.hash) {
|
|
26
|
+
throw new CliError('ARGUMENT_INVALID', 'gateway URL 必须是安全的 HTTP(S) 基础地址');
|
|
27
|
+
}
|
|
28
|
+
return url;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function validatePath(path) {
|
|
32
|
+
if (typeof path !== 'string'
|
|
33
|
+
|| !path.startsWith('/')
|
|
34
|
+
|| path.startsWith('//')
|
|
35
|
+
|| path.includes('\\')) {
|
|
36
|
+
throw new CliError('ARGUMENT_INVALID', 'gateway 路径无效');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
let decodedPath;
|
|
40
|
+
try {
|
|
41
|
+
decodedPath = decodeURIComponent(path);
|
|
42
|
+
} catch {
|
|
43
|
+
throw new CliError('ARGUMENT_INVALID', 'gateway 路径编码无效');
|
|
44
|
+
}
|
|
45
|
+
if (decodedPath.split('/').includes('..')
|
|
46
|
+
|| decodedPath.startsWith(FORBIDDEN_AGENT_PATH)
|
|
47
|
+
|| !ALLOWED_PATHS.some((prefix) => decodedPath.startsWith(prefix))) {
|
|
48
|
+
throw new CliError('ARGUMENT_INVALID', 'gateway 路径不在允许范围内');
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function normalizeTimeout(value, fallback) {
|
|
53
|
+
return Number.isFinite(value) && value > 0 ? value : fallback;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function headerValue(value) {
|
|
57
|
+
const text = String(value ?? '').trim();
|
|
58
|
+
return /^[\x20-\xFF]*$/u.test(text) ? text : encodeURIComponent(text);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function contextHeaders(context) {
|
|
62
|
+
const headers = {};
|
|
63
|
+
const fields = [
|
|
64
|
+
['kfcloud-auth', 'kfcloudAuth'],
|
|
65
|
+
['tenant-code', 'tenantCode'],
|
|
66
|
+
['user-id', 'userId'],
|
|
67
|
+
['user-name', 'userName'],
|
|
68
|
+
['realname', 'realname'],
|
|
69
|
+
['context-id', 'contextId'],
|
|
70
|
+
];
|
|
71
|
+
for (const [header, field] of fields) {
|
|
72
|
+
if (context[field]) {
|
|
73
|
+
headers[header] = headerValue(context[field]);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return headers;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function buildUrl(gatewayUrl, path, query = {}) {
|
|
80
|
+
const url = new URL(path, gatewayUrl);
|
|
81
|
+
for (const [key, rawValue] of Object.entries(query)) {
|
|
82
|
+
if (rawValue === undefined || rawValue === null) {
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
const values = Array.isArray(rawValue) ? rawValue : [rawValue];
|
|
86
|
+
for (const value of values) {
|
|
87
|
+
url.searchParams.append(key, String(value));
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return url.toString();
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function redact(text, secret) {
|
|
94
|
+
const value = String(text ?? '');
|
|
95
|
+
return secret ? value.split(secret).join('[REDACTED]') : value;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function safeMessage(payload, fallback, secret) {
|
|
99
|
+
const raw = payload?.msg ?? payload?.message ?? fallback;
|
|
100
|
+
const message = typeof raw === 'string' ? raw : JSON.stringify(raw);
|
|
101
|
+
return redact(message, secret);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function authError(status) {
|
|
105
|
+
return status === 401
|
|
106
|
+
? new CliError('AUTH_EXPIRED', '登录状态已过期', { httpStatus: status })
|
|
107
|
+
: new CliError('AUTH_FORBIDDEN', '当前账户无权执行此操作', { httpStatus: status });
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async function parseJsonResponse(response, secret) {
|
|
111
|
+
const text = await response.text();
|
|
112
|
+
let payload;
|
|
113
|
+
try {
|
|
114
|
+
payload = text ? JSON.parse(text) : {};
|
|
115
|
+
} catch {
|
|
116
|
+
const responseSummary = redact(text.slice(0, 500), secret);
|
|
117
|
+
const code = RETRYABLE_STATUS.has(response.status) ? 'NETWORK_ERROR' : 'BUSINESS_ERROR';
|
|
118
|
+
throw new CliError(code, 'gateway 返回了非 JSON 响应', {
|
|
119
|
+
httpStatus: response.status,
|
|
120
|
+
responseSummary,
|
|
121
|
+
retryable: RETRYABLE_STATUS.has(response.status),
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const rawCode = payload?.code;
|
|
126
|
+
const numericCode = rawCode === undefined || rawCode === null || rawCode === ''
|
|
127
|
+
? undefined
|
|
128
|
+
: Number(rawCode);
|
|
129
|
+
const authStatus = response.status === 401 || response.status === 403
|
|
130
|
+
? response.status
|
|
131
|
+
: numericCode === 401 || numericCode === 403 ? numericCode : undefined;
|
|
132
|
+
if (authStatus) {
|
|
133
|
+
throw authError(authStatus);
|
|
134
|
+
}
|
|
135
|
+
if (RETRYABLE_STATUS.has(response.status)) {
|
|
136
|
+
throw new CliError('NETWORK_ERROR', safeMessage(payload, 'gateway 暂时不可用', secret), {
|
|
137
|
+
httpStatus: response.status,
|
|
138
|
+
retryable: true,
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const successCode = rawCode === undefined || rawCode === null || rawCode === ''
|
|
143
|
+
|| numericCode === 0
|
|
144
|
+
|| numericCode === 200;
|
|
145
|
+
if (!response.ok || !successCode) {
|
|
146
|
+
const code = successCode ? 'BUSINESS_ERROR' : String(rawCode);
|
|
147
|
+
throw new CliError(code, safeMessage(payload, '业务请求失败', secret), {
|
|
148
|
+
httpStatus: response.status,
|
|
149
|
+
backendCode: rawCode,
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
return Object.hasOwn(payload, 'data') ? payload.data : payload;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export class GatewayClient {
|
|
156
|
+
constructor({
|
|
157
|
+
context,
|
|
158
|
+
fetchImpl = globalThis.fetch,
|
|
159
|
+
logger = console,
|
|
160
|
+
timeoutMs = DEFAULT_TIMEOUT_MS,
|
|
161
|
+
fileTimeoutMs = DEFAULT_FILE_TIMEOUT_MS,
|
|
162
|
+
}) {
|
|
163
|
+
this.context = context ?? {};
|
|
164
|
+
this.gatewayUrl = validateGatewayUrl(this.context.gatewayUrl);
|
|
165
|
+
this.fetchImpl = fetchImpl;
|
|
166
|
+
this.logger = logger;
|
|
167
|
+
this.timeoutMs = normalizeTimeout(timeoutMs, DEFAULT_TIMEOUT_MS);
|
|
168
|
+
this.fileTimeoutMs = normalizeTimeout(fileTimeoutMs, DEFAULT_FILE_TIMEOUT_MS);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async request({ method = 'GET', path, query, body, idempotent = false }) {
|
|
172
|
+
const normalizedMethod = method.toUpperCase();
|
|
173
|
+
const retryable = idempotent;
|
|
174
|
+
return await this.#withRetry({
|
|
175
|
+
method: normalizedMethod,
|
|
176
|
+
path,
|
|
177
|
+
query,
|
|
178
|
+
body,
|
|
179
|
+
timeoutMs: this.timeoutMs,
|
|
180
|
+
retryable,
|
|
181
|
+
responseHandler: (response) => parseJsonResponse(response, this.context.kfcloudAuth),
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
async upload({ path, formData }) {
|
|
186
|
+
return await this.#withRetry({
|
|
187
|
+
method: 'POST',
|
|
188
|
+
path,
|
|
189
|
+
body: formData,
|
|
190
|
+
timeoutMs: this.fileTimeoutMs,
|
|
191
|
+
retryable: false,
|
|
192
|
+
responseHandler: (response) => parseJsonResponse(response, this.context.kfcloudAuth),
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
async download({ path, query, method = 'POST', body }) {
|
|
197
|
+
return await this.#withRetry({
|
|
198
|
+
method: method.toUpperCase(),
|
|
199
|
+
path,
|
|
200
|
+
query,
|
|
201
|
+
body,
|
|
202
|
+
timeoutMs: this.fileTimeoutMs,
|
|
203
|
+
retryable: false,
|
|
204
|
+
responseHandler: async (response) => {
|
|
205
|
+
if (!response.ok) {
|
|
206
|
+
return await parseJsonResponse(response, this.context.kfcloudAuth);
|
|
207
|
+
}
|
|
208
|
+
const length = Number(response.headers.get('content-length'));
|
|
209
|
+
return {
|
|
210
|
+
body: response.body,
|
|
211
|
+
contentType: response.headers.get('content-type') ?? '',
|
|
212
|
+
contentLength: Number.isFinite(length) ? length : null,
|
|
213
|
+
disposition: response.headers.get('content-disposition') ?? '',
|
|
214
|
+
};
|
|
215
|
+
},
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
async #withRetry(input) {
|
|
220
|
+
validatePath(input.path);
|
|
221
|
+
const attempts = input.retryable ? 2 : 1;
|
|
222
|
+
for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
|
223
|
+
try {
|
|
224
|
+
const response = await this.#fetch(input);
|
|
225
|
+
return await input.responseHandler(response);
|
|
226
|
+
} catch (error) {
|
|
227
|
+
const canRetry = attempt < attempts
|
|
228
|
+
&& (error instanceof TypeError || error?.details?.retryable === true);
|
|
229
|
+
if (canRetry) {
|
|
230
|
+
this.logger?.warn?.({ event: 'gateway_retry', path: input.path, attempt });
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
233
|
+
if (error instanceof CliError) {
|
|
234
|
+
throw error;
|
|
235
|
+
}
|
|
236
|
+
if (error?.name === 'AbortError') {
|
|
237
|
+
throw new CliError('GATEWAY_TIMEOUT', 'gateway 请求超时', { path: input.path });
|
|
238
|
+
}
|
|
239
|
+
throw new CliError('NETWORK_ERROR', '无法连接 gateway', { path: input.path });
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
throw new CliError('NETWORK_ERROR', '无法连接 gateway', { path: input.path });
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
async #fetch({ method, path, query, body, timeoutMs }) {
|
|
246
|
+
const controller = new AbortController();
|
|
247
|
+
let timedOut = false;
|
|
248
|
+
const timer = setTimeout(() => {
|
|
249
|
+
timedOut = true;
|
|
250
|
+
controller.abort();
|
|
251
|
+
}, timeoutMs);
|
|
252
|
+
const headers = contextHeaders(this.context);
|
|
253
|
+
let requestBody = body;
|
|
254
|
+
if (body !== undefined && body !== null && !(body instanceof FormData)) {
|
|
255
|
+
headers['content-type'] = 'application/json';
|
|
256
|
+
requestBody = JSON.stringify(body);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
try {
|
|
260
|
+
return await this.fetchImpl(buildUrl(this.gatewayUrl, path, query), {
|
|
261
|
+
method,
|
|
262
|
+
headers,
|
|
263
|
+
body: requestBody,
|
|
264
|
+
signal: controller.signal,
|
|
265
|
+
});
|
|
266
|
+
} catch (error) {
|
|
267
|
+
if (timedOut) {
|
|
268
|
+
throw new CliError('GATEWAY_TIMEOUT', 'gateway 请求超时', { path });
|
|
269
|
+
}
|
|
270
|
+
throw error;
|
|
271
|
+
} finally {
|
|
272
|
+
clearTimeout(timer);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
}
|