redskillhub-upload 1.0.0-alpha.1
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/README.md +76 -0
- package/cli/auth-prompt.mjs +41 -0
- package/cli/auth.mjs +453 -0
- package/cli/config.mjs +51 -0
- package/cli/errors.mjs +30 -0
- package/cli/fetch.mjs +127 -0
- package/cli/index.mjs +311 -0
- package/cli/output.mjs +15 -0
- package/cli/pack.mjs +494 -0
- package/cli/prompt.mjs +115 -0
- package/cli/redact.mjs +8 -0
- package/cli/submit.mjs +253 -0
- package/cli/tags.mjs +149 -0
- package/cli/upload.mjs +214 -0
- package/package.json +29 -0
- package/skill/SKILL.md +115 -0
package/cli/submit.mjs
ADDED
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
import { deriveIdentifier } from './pack.mjs';
|
|
2
|
+
import { resolveApiBase, PATHS } from './config.mjs';
|
|
3
|
+
import { ExitCodes, SkillhubUploadError } from './errors.mjs';
|
|
4
|
+
import { compatibleFetch } from './fetch.mjs';
|
|
5
|
+
import { redactMessage } from './redact.mjs';
|
|
6
|
+
import { FALLBACK_CONTENT_TAGS } from './tags.mjs';
|
|
7
|
+
|
|
8
|
+
const EDITABLE_FIELDS = new Set(['name', 'identifier', 'version', 'description', 'detail', 'tag']);
|
|
9
|
+
const VALID_SOURCES = new Set(['original', 'repost']);
|
|
10
|
+
const SENSITIVE_PAYLOAD_FIELDS = new Set([
|
|
11
|
+
'access_token',
|
|
12
|
+
'refresh_token',
|
|
13
|
+
'token',
|
|
14
|
+
'bundle_file_id',
|
|
15
|
+
'bundle_sha256',
|
|
16
|
+
'skill_md_content'
|
|
17
|
+
]);
|
|
18
|
+
|
|
19
|
+
export const CONTENT_TAG_OPTIONS = FALLBACK_CONTENT_TAGS;
|
|
20
|
+
|
|
21
|
+
function normalizeTagText(value) {
|
|
22
|
+
return String(value || '').trim().toLowerCase().replace(/\s+/g, '');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function resolveNamedTagId(rawTag, tagOptions) {
|
|
26
|
+
const normalized = normalizeTagText(rawTag);
|
|
27
|
+
const matched = tagOptions.find((tag) => {
|
|
28
|
+
const names = [tag.tagId, tag.name, ...(tag.aliases || [])];
|
|
29
|
+
return names.some((name) => normalizeTagText(name) === normalized);
|
|
30
|
+
});
|
|
31
|
+
return matched?.tagId || '';
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function splitTagValues(value) {
|
|
35
|
+
if (value === undefined || value === null || value === '') {
|
|
36
|
+
return [];
|
|
37
|
+
}
|
|
38
|
+
if (Array.isArray(value)) {
|
|
39
|
+
return value.flatMap(splitTagValues);
|
|
40
|
+
}
|
|
41
|
+
return String(value).split(/[,,]/)
|
|
42
|
+
.map((item) => item.trim())
|
|
43
|
+
.filter(Boolean);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function formatContentTagOptions(tagOptions = FALLBACK_CONTENT_TAGS) {
|
|
47
|
+
return tagOptions.map((tag) => tag.name).join(' / ');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function resolveTagIds(flags, tagOptions = FALLBACK_CONTENT_TAGS) {
|
|
51
|
+
const rawTagId = flags.tagId ?? flags['tag-id'] ?? flags.tag;
|
|
52
|
+
const rawTags = splitTagValues(rawTagId);
|
|
53
|
+
if (rawTags.length === 0) {
|
|
54
|
+
return [];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const tagIds = [];
|
|
58
|
+
const seen = new Set();
|
|
59
|
+
for (const rawTag of rawTags) {
|
|
60
|
+
const tagId = /^\d+$/.test(rawTag) ? rawTag : resolveNamedTagId(rawTag, tagOptions);
|
|
61
|
+
if (!tagId) {
|
|
62
|
+
throw new Error(`未找到内容标签:${rawTag},请选择:${formatContentTagOptions(tagOptions)}`);
|
|
63
|
+
}
|
|
64
|
+
if (seen.has(tagId)) {
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
seen.add(tagId);
|
|
68
|
+
tagIds.push(tagId);
|
|
69
|
+
}
|
|
70
|
+
return tagIds;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function resolveSourceFields(flags) {
|
|
74
|
+
if (flags.source === undefined || flags.source === null || flags.source === '') {
|
|
75
|
+
return {};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const source = String(flags.source).trim();
|
|
79
|
+
if (!VALID_SOURCES.has(source)) {
|
|
80
|
+
throw new Error('内容来源只能选择“原创”或“转载”');
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (source === 'original') {
|
|
84
|
+
return { original: true, repost_source: '' };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const repostSource = String(flags.repostSource ?? flags['repost-source'] ?? '').trim();
|
|
88
|
+
if (!repostSource) {
|
|
89
|
+
throw new Error('选择转载时,请填写转载来源');
|
|
90
|
+
}
|
|
91
|
+
return { original: false, repost_source: repostSource };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function stripVersionSuffix(value) {
|
|
95
|
+
return String(value || '').replace(/-v?\d+(\.\d+)*$/, '');
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function resolveIdentifier(flags, metadata) {
|
|
99
|
+
if (flags.identifier) {
|
|
100
|
+
return String(flags.identifier).trim();
|
|
101
|
+
}
|
|
102
|
+
const fromName = deriveIdentifier(metadata.name || '');
|
|
103
|
+
if (fromName) {
|
|
104
|
+
return fromName;
|
|
105
|
+
}
|
|
106
|
+
const fromBaseName = deriveIdentifier(stripVersionSuffix(metadata.sourcePathBaseName || ''));
|
|
107
|
+
if (fromBaseName) {
|
|
108
|
+
return fromBaseName;
|
|
109
|
+
}
|
|
110
|
+
throw new Error(
|
|
111
|
+
`Skill ID 为空,无法从名称“${metadata.name || ''}”或目录名“${metadata.sourcePathBaseName || ''}”自动生成,请输入 Skill ID`
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function buildDraftPayload({ flags = {}, metadata = {}, bundle = {}, tagOptions = FALLBACK_CONTENT_TAGS }) {
|
|
116
|
+
const identifier = resolveIdentifier(flags, metadata);
|
|
117
|
+
const contentTagIds = resolveTagIds(flags, tagOptions);
|
|
118
|
+
const sourceFields = resolveSourceFields(flags);
|
|
119
|
+
return {
|
|
120
|
+
skill_identifier: identifier,
|
|
121
|
+
version: flags.version || metadata.version || '1.0.0',
|
|
122
|
+
name: flags.name || metadata.name || identifier,
|
|
123
|
+
description: flags.description || metadata.description || '',
|
|
124
|
+
skill_md_content: flags.detail || metadata.detail || '',
|
|
125
|
+
...sourceFields,
|
|
126
|
+
content_tag_ids: contentTagIds,
|
|
127
|
+
bundle_file_id: bundle.bundleFileId || '',
|
|
128
|
+
bundle_sha256: bundle.bundleSha256,
|
|
129
|
+
bundle_size_bytes: bundle.bundleSizeBytes
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function applyConfirmEdit(payload, values, { tagOptions = FALLBACK_CONTENT_TAGS } = {}) {
|
|
134
|
+
for (const key of Object.keys(values)) {
|
|
135
|
+
if (!EDITABLE_FIELDS.has(key)) {
|
|
136
|
+
throw new Error(`提交确认阶段不能修改 ${key},请取消后重新发布`);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return {
|
|
141
|
+
...payload,
|
|
142
|
+
skill_identifier: values.identifier || payload.skill_identifier,
|
|
143
|
+
version: values.version || payload.version,
|
|
144
|
+
name: values.name || payload.name,
|
|
145
|
+
description: values.description || payload.description,
|
|
146
|
+
skill_md_content: values.detail || payload.skill_md_content,
|
|
147
|
+
content_tag_ids: values.tag ? resolveTagIds({ tag: values.tag }, tagOptions) : payload.content_tag_ids
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function collectSensitiveValues(payload = {}, accessToken = '') {
|
|
152
|
+
const values = [];
|
|
153
|
+
if (accessToken) {
|
|
154
|
+
values.push(String(accessToken));
|
|
155
|
+
}
|
|
156
|
+
for (const [key, value] of Object.entries(payload || {})) {
|
|
157
|
+
if (!SENSITIVE_PAYLOAD_FIELDS.has(key) || value === undefined || value === null || value === '') {
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
values.push(String(value));
|
|
161
|
+
}
|
|
162
|
+
return values;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function unwrapResponseBody(body) {
|
|
166
|
+
return body?.data && typeof body.data === 'object' ? body.data : body;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function getRejectMessage(body) {
|
|
170
|
+
const data = unwrapResponseBody(body);
|
|
171
|
+
return data?.result?.message
|
|
172
|
+
|| data?.result?.errorMessage
|
|
173
|
+
|| data?.message
|
|
174
|
+
|| data?.msg
|
|
175
|
+
|| body?.result?.message
|
|
176
|
+
|| body?.result?.errorMessage
|
|
177
|
+
|| body?.message
|
|
178
|
+
|| body?.msg
|
|
179
|
+
|| '提交被拒绝';
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function isRejectedBody(body) {
|
|
183
|
+
const data = unwrapResponseBody(body);
|
|
184
|
+
if (
|
|
185
|
+
body?.result?.success === false
|
|
186
|
+
|| body?.success === false
|
|
187
|
+
|| data?.result?.success === false
|
|
188
|
+
|| data?.success === false
|
|
189
|
+
) {
|
|
190
|
+
return true;
|
|
191
|
+
}
|
|
192
|
+
const codes = [
|
|
193
|
+
body?.code,
|
|
194
|
+
body?.result?.code,
|
|
195
|
+
data?.code,
|
|
196
|
+
data?.result?.code
|
|
197
|
+
].filter((code) => code !== undefined && code !== null && code !== '');
|
|
198
|
+
|
|
199
|
+
return codes.some((code) => code !== 0 && code !== '0' && code !== 'OK');
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export async function submitSkillVersion(payload, options = {}) {
|
|
203
|
+
const apiBase = options.apiBase || resolveApiBase(options.flags || {});
|
|
204
|
+
const fetchImpl = options.fetchImpl || compatibleFetch;
|
|
205
|
+
const sensitiveValues = collectSensitiveValues(payload, options.accessToken);
|
|
206
|
+
let response;
|
|
207
|
+
|
|
208
|
+
try {
|
|
209
|
+
response = await fetchImpl(`${apiBase}${PATHS.SUBMIT_SKILL_VERSION}`, {
|
|
210
|
+
method: 'POST',
|
|
211
|
+
headers: {
|
|
212
|
+
authorization: `Bearer ${options.accessToken}`,
|
|
213
|
+
'content-type': 'application/json'
|
|
214
|
+
},
|
|
215
|
+
body: JSON.stringify(payload)
|
|
216
|
+
});
|
|
217
|
+
} catch (error) {
|
|
218
|
+
throw new SkillhubUploadError(
|
|
219
|
+
'SUBMIT_FAILED',
|
|
220
|
+
redactMessage(`提交失败: ${error?.message || String(error)}`, sensitiveValues),
|
|
221
|
+
ExitCodes.SUBMIT_FAILED
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
let body;
|
|
226
|
+
try {
|
|
227
|
+
body = await response.json();
|
|
228
|
+
} catch (error) {
|
|
229
|
+
throw new SkillhubUploadError(
|
|
230
|
+
'SUBMIT_FAILED',
|
|
231
|
+
redactMessage(`提交失败: 响应解析失败`, sensitiveValues),
|
|
232
|
+
ExitCodes.SUBMIT_FAILED
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
if (!response.ok) {
|
|
237
|
+
throw new SkillhubUploadError(
|
|
238
|
+
'SUBMIT_FAILED',
|
|
239
|
+
redactMessage(`提交失败: HTTP ${response.status}`, sensitiveValues),
|
|
240
|
+
ExitCodes.SUBMIT_FAILED
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
if (isRejectedBody(body)) {
|
|
245
|
+
throw new SkillhubUploadError(
|
|
246
|
+
'SUBMIT_REJECTED',
|
|
247
|
+
redactMessage(getRejectMessage(body), sensitiveValues),
|
|
248
|
+
ExitCodes.SUBMIT_REJECTED
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
return unwrapResponseBody(body);
|
|
253
|
+
}
|
package/cli/tags.mjs
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import {
|
|
4
|
+
CONTENT_TAG_CACHE_TTL_MS,
|
|
5
|
+
CONTENT_TAG_FETCH_TIMEOUT_MS,
|
|
6
|
+
CONTENT_TAG_MATERIAL_ID,
|
|
7
|
+
CONTENT_TAG_MODULE_ID,
|
|
8
|
+
PATHS,
|
|
9
|
+
getContentTagCachePath,
|
|
10
|
+
getHomeDir,
|
|
11
|
+
resolveApiBase
|
|
12
|
+
} from './config.mjs';
|
|
13
|
+
import { compatibleFetch } from './fetch.mjs';
|
|
14
|
+
|
|
15
|
+
export const FALLBACK_CONTENT_TAGS = Object.freeze([
|
|
16
|
+
Object.freeze({ tagId: '1001', name: '效率工具' }),
|
|
17
|
+
Object.freeze({ tagId: '1002', name: '内容创作' }),
|
|
18
|
+
Object.freeze({ tagId: '1003', name: '学习成长' }),
|
|
19
|
+
Object.freeze({ tagId: '1004', name: '职场办公' }),
|
|
20
|
+
Object.freeze({ tagId: '1005', name: '编程开发' }),
|
|
21
|
+
Object.freeze({ tagId: '1006', name: '生活决策' }),
|
|
22
|
+
Object.freeze({ tagId: '1007', name: '金融理财' }),
|
|
23
|
+
Object.freeze({ tagId: '1008', name: '其它' })
|
|
24
|
+
]);
|
|
25
|
+
|
|
26
|
+
function normalizeRemoteTag(raw) {
|
|
27
|
+
const tagId = String(raw?.tagId ?? raw?.tag_id ?? '').trim();
|
|
28
|
+
const name = String(raw?.tagName ?? raw?.tag_name ?? raw?.name ?? '').trim();
|
|
29
|
+
if (!tagId || !name) {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
return { tagId, name };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function normalizeRemoteList(data) {
|
|
36
|
+
if (!Array.isArray(data)) {
|
|
37
|
+
return [];
|
|
38
|
+
}
|
|
39
|
+
const seen = new Set();
|
|
40
|
+
const list = [];
|
|
41
|
+
for (const item of data) {
|
|
42
|
+
const tag = normalizeRemoteTag(item);
|
|
43
|
+
if (!tag || seen.has(tag.tagId)) {
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
seen.add(tag.tagId);
|
|
47
|
+
list.push(tag);
|
|
48
|
+
}
|
|
49
|
+
return list;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export async function fetchContentTags({
|
|
53
|
+
fetchImpl = compatibleFetch,
|
|
54
|
+
apiBase,
|
|
55
|
+
signal,
|
|
56
|
+
flags = {}
|
|
57
|
+
} = {}) {
|
|
58
|
+
const base = apiBase || resolveApiBase(flags);
|
|
59
|
+
const url = `${base}${PATHS.QUERY_CONTENT_TAG_CONFIG}?material_id=${CONTENT_TAG_MATERIAL_ID}&module_id=${CONTENT_TAG_MODULE_ID}`;
|
|
60
|
+
const response = await fetchImpl(url, { method: 'GET', signal });
|
|
61
|
+
if (!response.ok) {
|
|
62
|
+
throw new Error(`tag config HTTP ${response.status}`);
|
|
63
|
+
}
|
|
64
|
+
const body = await response.json();
|
|
65
|
+
if (body?.success === false || (body?.code !== undefined && body.code !== 0 && body.code !== '0')) {
|
|
66
|
+
throw new Error(body?.msg || body?.message || 'tag config rejected');
|
|
67
|
+
}
|
|
68
|
+
const list = normalizeRemoteList(body?.data);
|
|
69
|
+
if (list.length === 0) {
|
|
70
|
+
throw new Error('tag config returned empty list');
|
|
71
|
+
}
|
|
72
|
+
return list;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function readCachedContentTags(cachePath) {
|
|
76
|
+
try {
|
|
77
|
+
const raw = await fs.readFile(cachePath, 'utf8');
|
|
78
|
+
const parsed = JSON.parse(raw);
|
|
79
|
+
const tags = normalizeRemoteList(parsed?.tags);
|
|
80
|
+
if (tags.length === 0) {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
const fetchedAtMs = Number(parsed?.fetchedAtMs);
|
|
84
|
+
return { tags, fetchedAtMs: Number.isFinite(fetchedAtMs) ? fetchedAtMs : 0 };
|
|
85
|
+
} catch {
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function writeCachedContentTags(cachePath, tags, nowMs) {
|
|
91
|
+
try {
|
|
92
|
+
await fs.mkdir(path.dirname(cachePath), { recursive: true });
|
|
93
|
+
await fs.writeFile(
|
|
94
|
+
cachePath,
|
|
95
|
+
JSON.stringify({ fetchedAtMs: nowMs, tags }, null, 2),
|
|
96
|
+
'utf8'
|
|
97
|
+
);
|
|
98
|
+
} catch (error) {
|
|
99
|
+
// best-effort cache write; do not break main flow
|
|
100
|
+
process.stderr.write(`[redskillhub-upload] warn: cache write failed: ${error?.message || error}\n`);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function withTimeout(timeoutMs) {
|
|
105
|
+
if (!timeoutMs || timeoutMs <= 0) {
|
|
106
|
+
return { signal: undefined, cancel: () => {} };
|
|
107
|
+
}
|
|
108
|
+
const controller = new AbortController();
|
|
109
|
+
const timer = setTimeout(() => controller.abort(new Error(`tag config fetch timed out after ${timeoutMs}ms`)), timeoutMs);
|
|
110
|
+
return { signal: controller.signal, cancel: () => clearTimeout(timer) };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export async function loadContentTags({
|
|
114
|
+
fetchImpl = compatibleFetch,
|
|
115
|
+
env = process.env,
|
|
116
|
+
now = Date.now,
|
|
117
|
+
flags = {},
|
|
118
|
+
timeoutMs = CONTENT_TAG_FETCH_TIMEOUT_MS,
|
|
119
|
+
cacheTtlMs = CONTENT_TAG_CACHE_TTL_MS
|
|
120
|
+
} = {}) {
|
|
121
|
+
if (env.SKILLHUB_UPLOAD_SKIP_REMOTE_TAGS === '1') {
|
|
122
|
+
return FALLBACK_CONTENT_TAGS.map((tag) => ({ ...tag }));
|
|
123
|
+
}
|
|
124
|
+
const cachePath = getContentTagCachePath(env);
|
|
125
|
+
const nowMs = typeof now === 'function' ? now() : Number(now) || Date.now();
|
|
126
|
+
const cached = await readCachedContentTags(cachePath);
|
|
127
|
+
if (cached && nowMs - cached.fetchedAtMs < cacheTtlMs) {
|
|
128
|
+
return cached.tags;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const { signal, cancel } = withTimeout(timeoutMs);
|
|
132
|
+
try {
|
|
133
|
+
const tags = await fetchContentTags({ fetchImpl, flags, signal });
|
|
134
|
+
cancel();
|
|
135
|
+
await writeCachedContentTags(cachePath, tags, nowMs);
|
|
136
|
+
return tags;
|
|
137
|
+
} catch (error) {
|
|
138
|
+
cancel();
|
|
139
|
+
process.stderr.write(`[redskillhub-upload] warn: load content tags failed: ${error?.message || error}\n`);
|
|
140
|
+
if (cached?.tags?.length) {
|
|
141
|
+
return cached.tags;
|
|
142
|
+
}
|
|
143
|
+
return FALLBACK_CONTENT_TAGS.map((tag) => ({ ...tag }));
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export function getContentTagsHomeDir(env = process.env) {
|
|
148
|
+
return getHomeDir(env);
|
|
149
|
+
}
|
package/cli/upload.mjs
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import { resolveApiBase, PATHS } from './config.mjs';
|
|
2
|
+
import { ExitCodes, SkillhubUploadError } from './errors.mjs';
|
|
3
|
+
import { compatibleFetch } from './fetch.mjs';
|
|
4
|
+
import { redactMessage } from './redact.mjs';
|
|
5
|
+
import { writeUploadProgress } from './output.mjs';
|
|
6
|
+
const SLICE_SIZE = 1024 * 1024;
|
|
7
|
+
|
|
8
|
+
function firstPresent(...values) {
|
|
9
|
+
return values.find((value) => value !== undefined && value !== null && value !== '');
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function resolvePermitFields(permit = {}) {
|
|
13
|
+
return {
|
|
14
|
+
fileId: firstPresent(permit.fileId, permit.fileIds?.[0], permit.file_id, permit.file_ids?.[0]),
|
|
15
|
+
bucket: firstPresent(permit.bucket, permit.Bucket),
|
|
16
|
+
region: firstPresent(permit.region, permit.Region),
|
|
17
|
+
uploadAddr: firstPresent(permit.uploadAddr, permit.upload_addr, permit.Domain),
|
|
18
|
+
secretId: firstPresent(permit.secretId, permit.tmpSecretId, permit.tmp_secret_id, permit.TmpSecretId, permit.secret?.secretId),
|
|
19
|
+
secretKey: firstPresent(permit.secretKey, permit.tmpSecretKey, permit.tmp_secret_key, permit.TmpSecretKey, permit.secret?.secretKey),
|
|
20
|
+
token: firstPresent(permit.token, permit.securityToken, permit.security_token, permit.SecurityToken, permit.secret?.token),
|
|
21
|
+
expireTime: firstPresent(permit.expireTime, permit.expire_time, permit.expiredTime, permit.expired_time, permit.ExpiredTime)
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function collectSensitiveValues(...items) {
|
|
26
|
+
return items
|
|
27
|
+
.flatMap((item) => {
|
|
28
|
+
if (!item) return [];
|
|
29
|
+
if (typeof item === 'string') return [item];
|
|
30
|
+
const fields = resolvePermitFields(item);
|
|
31
|
+
return [
|
|
32
|
+
item.accessToken,
|
|
33
|
+
fields.secretId,
|
|
34
|
+
fields.secretKey,
|
|
35
|
+
fields.token
|
|
36
|
+
];
|
|
37
|
+
})
|
|
38
|
+
.filter((value) => typeof value === 'string' && value.length > 0);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function buildPermitError(message) {
|
|
42
|
+
return new SkillhubUploadError('PERMIT_FAILED', message, ExitCodes.PERMIT_FAILED);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function isRosSelfBuiltChannel(fields) {
|
|
46
|
+
return fields.bucket === 'unknown';
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function validatePermit(permit) {
|
|
50
|
+
const fields = resolvePermitFields(permit);
|
|
51
|
+
const ros = isRosSelfBuiltChannel(fields);
|
|
52
|
+
const requiredFields = ros
|
|
53
|
+
? ['fileId', 'bucket', 'region', 'uploadAddr', 'token']
|
|
54
|
+
: ['fileId', 'bucket', 'region', 'uploadAddr', 'secretId', 'secretKey', 'token'];
|
|
55
|
+
const missing = requiredFields.find((key) => !fields[key]);
|
|
56
|
+
if (missing) {
|
|
57
|
+
throw buildPermitError(`上传授权字段不完整 (${missing})`);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return fields;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function unwrapResponseBody(body) {
|
|
64
|
+
return body?.data && typeof body.data === 'object' ? body.data : body;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function resolvePermits(token = {}) {
|
|
68
|
+
const data = unwrapResponseBody(token);
|
|
69
|
+
return data.uploadTempPermits || data.upload_temp_permits || data.permits || [];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function defaultUploadFile(cos, params) {
|
|
73
|
+
await new Promise((resolve, reject) => {
|
|
74
|
+
cos.uploadFile(params, (error) => {
|
|
75
|
+
if (error) reject(error);
|
|
76
|
+
else resolve();
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function createDefaultCos(options) {
|
|
82
|
+
const { default: COS } = await import('cos-nodejs-sdk-v5');
|
|
83
|
+
return new COS(options);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function buildCosUploadError(error, sensitiveValues) {
|
|
87
|
+
return new SkillhubUploadError(
|
|
88
|
+
'COS_UPLOAD_FAILED',
|
|
89
|
+
`COS 上传失败: ${redactMessage(error?.message || String(error), sensitiveValues)}`,
|
|
90
|
+
ExitCodes.COS_UPLOAD_FAILED
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export async function requestUploadToken({ apiBase, accessToken, fetchImpl = compatibleFetch }) {
|
|
95
|
+
const url = new URL(PATHS.UPLOAD_TOKEN, apiBase);
|
|
96
|
+
url.searchParams.set('biz_name', 'spectrum');
|
|
97
|
+
url.searchParams.set('scene', 'skill');
|
|
98
|
+
url.searchParams.set('file_count', '1');
|
|
99
|
+
url.searchParams.set('file_format', 'zip');
|
|
100
|
+
url.searchParams.set('version', '1');
|
|
101
|
+
url.searchParams.set('source', 'web');
|
|
102
|
+
|
|
103
|
+
try {
|
|
104
|
+
const response = await fetchImpl(url, {
|
|
105
|
+
headers: { authorization: `Bearer ${accessToken}` }
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
if (!response.ok) {
|
|
109
|
+
throw buildPermitError(`获取上传授权失败: HTTP ${response.status}`);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return unwrapResponseBody(await response.json());
|
|
113
|
+
} catch (error) {
|
|
114
|
+
if (error instanceof SkillhubUploadError) {
|
|
115
|
+
throw error;
|
|
116
|
+
}
|
|
117
|
+
throw buildPermitError(`获取上传授权失败: ${redactMessage(error?.message || String(error), [accessToken])}`);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const ROS_SELF_BUILT_APP_ID = '1251524319';
|
|
122
|
+
|
|
123
|
+
export async function uploadWithPermit(permit, zipPath, options = {}) {
|
|
124
|
+
const fields = validatePermit(permit);
|
|
125
|
+
const ros = isRosSelfBuiltChannel(fields);
|
|
126
|
+
const uploadFileImpl = options.uploadFileImpl;
|
|
127
|
+
const cosFactory = options.cosFactory || createDefaultCos;
|
|
128
|
+
|
|
129
|
+
const cosInit = {
|
|
130
|
+
Domain: fields.uploadAddr,
|
|
131
|
+
getAuthorization: (_, callback) => callback({
|
|
132
|
+
TmpSecretId: fields.secretId || 'null',
|
|
133
|
+
TmpSecretKey: fields.secretKey || 'null',
|
|
134
|
+
SecurityToken: fields.token,
|
|
135
|
+
StartTime: Math.floor(Date.now() / 1000),
|
|
136
|
+
ExpiredTime: fields.expireTime ? Math.floor(Number(fields.expireTime) / 1000) : undefined
|
|
137
|
+
})
|
|
138
|
+
};
|
|
139
|
+
if (ros) {
|
|
140
|
+
cosInit.AppId = ROS_SELF_BUILT_APP_ID;
|
|
141
|
+
}
|
|
142
|
+
const cos = uploadFileImpl ? null : await cosFactory(cosInit);
|
|
143
|
+
|
|
144
|
+
const params = {
|
|
145
|
+
Bucket: fields.bucket,
|
|
146
|
+
Region: fields.region,
|
|
147
|
+
Key: fields.fileId,
|
|
148
|
+
FilePath: zipPath,
|
|
149
|
+
SliceSize: SLICE_SIZE,
|
|
150
|
+
onProgress: (event = {}) => {
|
|
151
|
+
const percent = Math.floor(Number(event.percent || 0) * 100);
|
|
152
|
+
writeUploadProgress(percent, options.progressStream || process.stdout);
|
|
153
|
+
}
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
try {
|
|
157
|
+
if (uploadFileImpl) {
|
|
158
|
+
await uploadFileImpl(params, { permit, fields });
|
|
159
|
+
} else {
|
|
160
|
+
await defaultUploadFile(cos, params);
|
|
161
|
+
}
|
|
162
|
+
} catch (error) {
|
|
163
|
+
throw buildCosUploadError(error, collectSensitiveValues(permit));
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return fields.fileId;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export async function uploadBundle(bundle, options = {}) {
|
|
170
|
+
if (options.dryRun) {
|
|
171
|
+
return {
|
|
172
|
+
bundleFileId: 'dry-run-bundle-file-id',
|
|
173
|
+
bundleSha256: bundle.bundleSha256,
|
|
174
|
+
bundleSizeBytes: bundle.bundleSizeBytes,
|
|
175
|
+
uploaded: false
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const apiBase = options.apiBase || resolveApiBase(options.flags || {});
|
|
180
|
+
const token = await requestUploadToken({
|
|
181
|
+
apiBase,
|
|
182
|
+
accessToken: options.accessToken,
|
|
183
|
+
fetchImpl: options.fetchImpl || compatibleFetch
|
|
184
|
+
});
|
|
185
|
+
const permits = [...resolvePermits(token)].sort((a, b) => Number(b.qos || 0) - Number(a.qos || 0));
|
|
186
|
+
|
|
187
|
+
if (permits.length === 0) {
|
|
188
|
+
throw new SkillhubUploadError('COS_UPLOAD_FAILED', '没有可用上传授权', ExitCodes.COS_UPLOAD_FAILED);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
let lastError;
|
|
192
|
+
for (const permit of permits) {
|
|
193
|
+
try {
|
|
194
|
+
const fileId = await uploadWithPermit(permit, bundle.zipPath, {
|
|
195
|
+
cosFactory: options.cosFactory,
|
|
196
|
+
uploadFileImpl: options.uploadFileImpl,
|
|
197
|
+
progressStream: options.progressStream
|
|
198
|
+
});
|
|
199
|
+
return {
|
|
200
|
+
bundleFileId: fileId,
|
|
201
|
+
bundleSha256: bundle.bundleSha256,
|
|
202
|
+
bundleSizeBytes: bundle.bundleSizeBytes,
|
|
203
|
+
uploaded: true
|
|
204
|
+
};
|
|
205
|
+
} catch (error) {
|
|
206
|
+
lastError = error;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
throw buildCosUploadError(lastError, [
|
|
211
|
+
options.accessToken,
|
|
212
|
+
...collectSensitiveValues(...permits)
|
|
213
|
+
]);
|
|
214
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "redskillhub-upload",
|
|
3
|
+
"version": "1.0.0-alpha.1",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"publishConfig": {
|
|
6
|
+
"access": "public",
|
|
7
|
+
"registry": "https://registry.npmjs.org/"
|
|
8
|
+
},
|
|
9
|
+
"bin": {
|
|
10
|
+
"redskillhub-upload": "cli/index.mjs"
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"cli/",
|
|
14
|
+
"skill/",
|
|
15
|
+
"README.md"
|
|
16
|
+
],
|
|
17
|
+
"scripts": {
|
|
18
|
+
"test": "node --test test/*.test.mjs",
|
|
19
|
+
"dry-run": "node cli/index.mjs publish test/fixtures/minimal-skill --dry-run --agent --yes"
|
|
20
|
+
},
|
|
21
|
+
"dependencies": {
|
|
22
|
+
"adm-zip": "^0.5.16",
|
|
23
|
+
"cos-nodejs-sdk-v5": "^2.14.7",
|
|
24
|
+
"qrcode": "^1.5.4"
|
|
25
|
+
},
|
|
26
|
+
"engines": {
|
|
27
|
+
"node": ">=18"
|
|
28
|
+
}
|
|
29
|
+
}
|