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/fetch.mjs
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import http from 'node:http';
|
|
2
|
+
import https from 'node:https';
|
|
3
|
+
import tls from 'node:tls';
|
|
4
|
+
import { DEFAULT_API_BASE } from './config.mjs';
|
|
5
|
+
|
|
6
|
+
const DEFAULT_API_HOST = new URL(DEFAULT_API_BASE).hostname;
|
|
7
|
+
const BETA_WILDCARD_SAN = 'DNS:*.beta.xiaohongshu.com';
|
|
8
|
+
|
|
9
|
+
function resolveUrl(input) {
|
|
10
|
+
if (input instanceof URL) {
|
|
11
|
+
return input;
|
|
12
|
+
}
|
|
13
|
+
if (input?.url) {
|
|
14
|
+
return new URL(input.url);
|
|
15
|
+
}
|
|
16
|
+
return new URL(String(input));
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function resolveErrorCode(error) {
|
|
20
|
+
return error?.code || error?.cause?.code;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function isDefaultBetaApiAltNameError(input, error) {
|
|
24
|
+
try {
|
|
25
|
+
const url = resolveUrl(input);
|
|
26
|
+
return url.protocol === 'https:'
|
|
27
|
+
&& url.hostname === DEFAULT_API_HOST
|
|
28
|
+
&& resolveErrorCode(error) === 'ERR_TLS_CERT_ALTNAME_INVALID';
|
|
29
|
+
} catch {
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function checkDefaultBetaApiServerIdentity(host, cert) {
|
|
35
|
+
const error = tls.checkServerIdentity(host, cert);
|
|
36
|
+
if (!error) {
|
|
37
|
+
return undefined;
|
|
38
|
+
}
|
|
39
|
+
if (
|
|
40
|
+
host === DEFAULT_API_HOST
|
|
41
|
+
&& error.code === 'ERR_TLS_CERT_ALTNAME_INVALID'
|
|
42
|
+
&& String(cert?.subjectaltname || '').includes(BETA_WILDCARD_SAN)
|
|
43
|
+
) {
|
|
44
|
+
return undefined;
|
|
45
|
+
}
|
|
46
|
+
return error;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function normalizeHeaders(headers) {
|
|
50
|
+
if (!headers) {
|
|
51
|
+
return {};
|
|
52
|
+
}
|
|
53
|
+
if (typeof headers.forEach === 'function') {
|
|
54
|
+
const result = {};
|
|
55
|
+
headers.forEach((value, key) => {
|
|
56
|
+
result[key] = value;
|
|
57
|
+
});
|
|
58
|
+
return result;
|
|
59
|
+
}
|
|
60
|
+
if (Array.isArray(headers)) {
|
|
61
|
+
return Object.fromEntries(headers);
|
|
62
|
+
}
|
|
63
|
+
return { ...headers };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
class NodeFetchResponse {
|
|
67
|
+
constructor(statusCode, headers, body) {
|
|
68
|
+
this.status = statusCode || 0;
|
|
69
|
+
this.ok = this.status >= 200 && this.status < 300;
|
|
70
|
+
this.headers = headers;
|
|
71
|
+
this.body = body;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async text() {
|
|
75
|
+
return this.body.toString('utf8');
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async json() {
|
|
79
|
+
return JSON.parse(await this.text());
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function nodeRequestFetch(input, init = {}) {
|
|
84
|
+
const url = resolveUrl(input);
|
|
85
|
+
const transport = url.protocol === 'http:' ? http : https;
|
|
86
|
+
const options = {
|
|
87
|
+
method: init.method || 'GET',
|
|
88
|
+
headers: normalizeHeaders(init.headers)
|
|
89
|
+
};
|
|
90
|
+
if (url.protocol === 'https:' && url.hostname === DEFAULT_API_HOST) {
|
|
91
|
+
options.checkServerIdentity = checkDefaultBetaApiServerIdentity;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return new Promise((resolve, reject) => {
|
|
95
|
+
const req = transport.request(url, options, (res) => {
|
|
96
|
+
const chunks = [];
|
|
97
|
+
res.on('data', (chunk) => chunks.push(chunk));
|
|
98
|
+
res.on('end', () => {
|
|
99
|
+
resolve(new NodeFetchResponse(res.statusCode, res.headers, Buffer.concat(chunks)));
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
req.on('error', reject);
|
|
103
|
+
if (init.body === undefined || init.body === null) {
|
|
104
|
+
req.end();
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
req.end(init.body);
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function createCompatibleFetch({
|
|
112
|
+
primaryFetch = fetch,
|
|
113
|
+
fallbackFetch = nodeRequestFetch
|
|
114
|
+
} = {}) {
|
|
115
|
+
return async function compatibleFetch(input, init) {
|
|
116
|
+
try {
|
|
117
|
+
return await primaryFetch(input, init);
|
|
118
|
+
} catch (error) {
|
|
119
|
+
if (!isDefaultBetaApiAltNameError(input, error)) {
|
|
120
|
+
throw error;
|
|
121
|
+
}
|
|
122
|
+
return fallbackFetch(input, init);
|
|
123
|
+
}
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export const compatibleFetch = createCompatibleFetch();
|
package/cli/index.mjs
ADDED
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { realpathSync } from 'node:fs';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import { writeResult } from './output.mjs';
|
|
5
|
+
import { ExitCodes, SkillhubUploadError, toResultError } from './errors.mjs';
|
|
6
|
+
import { prepareBundle } from './pack.mjs';
|
|
7
|
+
import {
|
|
8
|
+
applyConfirmEdit,
|
|
9
|
+
buildDraftPayload,
|
|
10
|
+
formatContentTagOptions,
|
|
11
|
+
submitSkillVersion
|
|
12
|
+
} from './submit.mjs';
|
|
13
|
+
import { uploadBundle } from './upload.mjs';
|
|
14
|
+
import { askAgent, confirmAgent, createPromptSession } from './prompt.mjs';
|
|
15
|
+
import { TOKEN_REFRESH_BUFFER_MS } from './config.mjs';
|
|
16
|
+
import { loadContentTags } from './tags.mjs';
|
|
17
|
+
|
|
18
|
+
function normalizeFlagName(name) {
|
|
19
|
+
if (name === 'dry-run') return 'dryRun';
|
|
20
|
+
if (name === 'api-base') return 'apiBase';
|
|
21
|
+
return name;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function setFlag(flags, rawName, value) {
|
|
25
|
+
const name = normalizeFlagName(rawName);
|
|
26
|
+
if (Object.prototype.hasOwnProperty.call(flags, name)) {
|
|
27
|
+
flags[name] = Array.isArray(flags[name]) ? [...flags[name], value] : [flags[name], value];
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
flags[name] = value;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function parseArgs(argv) {
|
|
34
|
+
const [command, ...rest] = argv;
|
|
35
|
+
let pathArg;
|
|
36
|
+
const flags = {};
|
|
37
|
+
for (let i = 0; i < rest.length; i += 1) {
|
|
38
|
+
const item = rest[i];
|
|
39
|
+
if (item === '--dry-run') flags.dryRun = true;
|
|
40
|
+
else if (item === '--agent') flags.agent = true;
|
|
41
|
+
else if (item === '--yes') flags.yes = true;
|
|
42
|
+
else if (item.startsWith('--') && item.includes('=')) {
|
|
43
|
+
const equalIndex = item.indexOf('=');
|
|
44
|
+
setFlag(flags, item.slice(2, equalIndex), item.slice(equalIndex + 1));
|
|
45
|
+
} else if (item.startsWith('--')) setFlag(flags, item.slice(2), rest[++i] || true);
|
|
46
|
+
else if (!pathArg) pathArg = item;
|
|
47
|
+
}
|
|
48
|
+
return { command, pathArg, flags };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function buildValidatedDraftPayload(input) {
|
|
52
|
+
try {
|
|
53
|
+
return buildDraftPayload(input);
|
|
54
|
+
} catch (error) {
|
|
55
|
+
if (error instanceof SkillhubUploadError) {
|
|
56
|
+
throw error;
|
|
57
|
+
}
|
|
58
|
+
throw new SkillhubUploadError(
|
|
59
|
+
'LOCAL_VALIDATION_FAILED',
|
|
60
|
+
error?.message || String(error),
|
|
61
|
+
ExitCodes.LOCAL_VALIDATION
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function wrapLocalValidation(error) {
|
|
67
|
+
if (error instanceof SkillhubUploadError) {
|
|
68
|
+
return error;
|
|
69
|
+
}
|
|
70
|
+
return new SkillhubUploadError(
|
|
71
|
+
'LOCAL_VALIDATION_FAILED',
|
|
72
|
+
error?.message || String(error),
|
|
73
|
+
ExitCodes.LOCAL_VALIDATION
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function assertUsableCredentials(credentials, nowMs = Date.now()) {
|
|
78
|
+
if (!credentials?.accessToken) {
|
|
79
|
+
throw new SkillhubUploadError('NEED_LOGIN', '请先执行 redskillhub-upload login', ExitCodes.NEED_LOGIN);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const expireTimeMs = Number(credentials.expireTimeMs || 0);
|
|
83
|
+
if (!Number.isFinite(expireTimeMs) || expireTimeMs <= nowMs + TOKEN_REFRESH_BUFFER_MS) {
|
|
84
|
+
throw new SkillhubUploadError('NEED_LOGIN', '登录已过期,请重新执行 redskillhub-upload login', ExitCodes.NEED_LOGIN);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export async function ensurePublishCredentials({
|
|
89
|
+
flags = {},
|
|
90
|
+
env = process.env,
|
|
91
|
+
io = {},
|
|
92
|
+
nowMs = Date.now(),
|
|
93
|
+
readCredentials,
|
|
94
|
+
login
|
|
95
|
+
}) {
|
|
96
|
+
let credentials = await readCredentials(env);
|
|
97
|
+
try {
|
|
98
|
+
assertUsableCredentials(credentials, nowMs);
|
|
99
|
+
return credentials;
|
|
100
|
+
} catch (error) {
|
|
101
|
+
if (!(error instanceof SkillhubUploadError) || error.code !== 'NEED_LOGIN') {
|
|
102
|
+
throw error;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
await login({
|
|
107
|
+
flags,
|
|
108
|
+
env,
|
|
109
|
+
fetchImpl: io.fetchImpl,
|
|
110
|
+
sleep: io.sleep,
|
|
111
|
+
now: io.now,
|
|
112
|
+
promptStream: io.out || process.stdout
|
|
113
|
+
});
|
|
114
|
+
credentials = await readCredentials(env);
|
|
115
|
+
assertUsableCredentials(credentials, io.now ? io.now() : nowMs);
|
|
116
|
+
return credentials;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async function confirmBeforeSubmit(payload, io = {}, { tagOptions } = {}) {
|
|
120
|
+
let current = payload;
|
|
121
|
+
while (true) {
|
|
122
|
+
const answer = await confirmAgent(current, io);
|
|
123
|
+
if (answer.action === 'submit') {
|
|
124
|
+
return current;
|
|
125
|
+
}
|
|
126
|
+
if (answer.action === 'edit') {
|
|
127
|
+
try {
|
|
128
|
+
current = applyConfirmEdit(current, answer.values || {}, { tagOptions });
|
|
129
|
+
} catch (error) {
|
|
130
|
+
throw wrapLocalValidation(error);
|
|
131
|
+
}
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function attachUploadResult(payload, upload) {
|
|
139
|
+
return {
|
|
140
|
+
...payload,
|
|
141
|
+
bundle_file_id: upload.bundleFileId || '',
|
|
142
|
+
bundle_sha256: upload.bundleSha256,
|
|
143
|
+
bundle_size_bytes: upload.bundleSizeBytes
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async function collectPublishFlags(flags, io = {}, { tagOptions = [] } = {}) {
|
|
148
|
+
const collected = { ...flags };
|
|
149
|
+
if (!collected.source) {
|
|
150
|
+
collected.source = await askAgent({
|
|
151
|
+
type: 'source',
|
|
152
|
+
options: ['original', 'repost'],
|
|
153
|
+
message: '请选择内容来源'
|
|
154
|
+
}, io);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (
|
|
158
|
+
collected.source === 'repost'
|
|
159
|
+
&& !collected.repostSource
|
|
160
|
+
&& !collected['repost-source']
|
|
161
|
+
) {
|
|
162
|
+
collected.repostSource = await askAgent({
|
|
163
|
+
type: 'repost_source',
|
|
164
|
+
message: '请输入转载平台名,15 字符内'
|
|
165
|
+
}, io);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (!collected.tagId && !collected['tag-id'] && !collected.tag) {
|
|
169
|
+
collected.tag = await askAgent({
|
|
170
|
+
type: 'tag',
|
|
171
|
+
multi: true,
|
|
172
|
+
options: tagOptions.map((tag) => tag.name),
|
|
173
|
+
message: `请选择一个或多个内容标签,多个用逗号分隔:${formatContentTagOptions(tagOptions)}`
|
|
174
|
+
}, io);
|
|
175
|
+
}
|
|
176
|
+
return collected;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export async function main(argv = process.argv.slice(2), env = process.env, io = {}) {
|
|
180
|
+
const { command, pathArg, flags } = parseArgs(argv);
|
|
181
|
+
const out = io.out || process.stdout;
|
|
182
|
+
if (!command) {
|
|
183
|
+
throw new SkillhubUploadError(
|
|
184
|
+
'INVALID_ARGS',
|
|
185
|
+
'请指定要执行的操作:login、publish、whoami 或 logout',
|
|
186
|
+
ExitCodes.INVALID_ARGS
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
if (command === 'whoami') {
|
|
190
|
+
const { readCredentials, maskCredentials } = await import('./auth.mjs');
|
|
191
|
+
writeResult({ status: 'ok', command, credentials: maskCredentials(await readCredentials(env)) }, out);
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
if (command === 'logout') {
|
|
195
|
+
const { removeCredentials } = await import('./auth.mjs');
|
|
196
|
+
await removeCredentials(env);
|
|
197
|
+
writeResult({ status: 'ok', command }, out);
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
if (command === 'login') {
|
|
201
|
+
if (flags.cancel) {
|
|
202
|
+
const { cancelLogin } = await import('./auth.mjs');
|
|
203
|
+
const cancelled = await cancelLogin(env);
|
|
204
|
+
writeResult({ status: 'cancelled', command, ...cancelled }, out);
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
const { login } = await import('./auth.mjs');
|
|
208
|
+
const credentials = await login({
|
|
209
|
+
flags,
|
|
210
|
+
env,
|
|
211
|
+
fetchImpl: io.fetchImpl,
|
|
212
|
+
sleep: io.sleep,
|
|
213
|
+
now: io.now,
|
|
214
|
+
promptStream: out
|
|
215
|
+
});
|
|
216
|
+
writeResult({ status: 'ok', command, credentials }, out);
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
if (command === 'publish') {
|
|
220
|
+
if (!pathArg) {
|
|
221
|
+
throw new SkillhubUploadError('INVALID_ARGS', '请指定本地 skill 目录路径', ExitCodes.INVALID_ARGS);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const dryRun = Boolean(flags.dryRun);
|
|
225
|
+
let credentials = null;
|
|
226
|
+
if (!dryRun) {
|
|
227
|
+
const { readCredentials, login } = await import('./auth.mjs');
|
|
228
|
+
credentials = await ensurePublishCredentials({
|
|
229
|
+
flags,
|
|
230
|
+
env,
|
|
231
|
+
io: { ...io, out },
|
|
232
|
+
nowMs: io.now ? io.now() : Date.now(),
|
|
233
|
+
readCredentials,
|
|
234
|
+
login
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const bundle = await prepareBundle(pathArg, { env });
|
|
239
|
+
const bundleMetadata = {
|
|
240
|
+
bundleSha256: bundle.bundleSha256,
|
|
241
|
+
bundleSizeBytes: bundle.bundleSizeBytes
|
|
242
|
+
};
|
|
243
|
+
const tagOptions = await loadContentTags({
|
|
244
|
+
fetchImpl: io.fetchImpl,
|
|
245
|
+
env,
|
|
246
|
+
now: io.now,
|
|
247
|
+
flags
|
|
248
|
+
});
|
|
249
|
+
const promptSession = createPromptSession({
|
|
250
|
+
input: io.input || process.stdin,
|
|
251
|
+
out
|
|
252
|
+
});
|
|
253
|
+
try {
|
|
254
|
+
const publishFlags = await collectPublishFlags(flags, promptSession, { tagOptions });
|
|
255
|
+
const draftPayload = buildValidatedDraftPayload({
|
|
256
|
+
flags: publishFlags,
|
|
257
|
+
metadata: bundle.metadata,
|
|
258
|
+
bundle: bundleMetadata,
|
|
259
|
+
tagOptions
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
if (dryRun) {
|
|
263
|
+
const upload = await uploadBundle(bundle, { dryRun: true });
|
|
264
|
+
writeResult({ status: 'dry_run', payload: attachUploadResult(draftPayload, upload) }, out);
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const confirmedPayload = await confirmBeforeSubmit(draftPayload, promptSession, { tagOptions });
|
|
269
|
+
if (!confirmedPayload) {
|
|
270
|
+
writeResult({ status: 'cancelled' }, out);
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const upload = await uploadBundle(bundle, {
|
|
275
|
+
dryRun: false,
|
|
276
|
+
flags: publishFlags,
|
|
277
|
+
accessToken: credentials.accessToken,
|
|
278
|
+
fetchImpl: io.fetchImpl,
|
|
279
|
+
cosFactory: io.cosFactory,
|
|
280
|
+
uploadFileImpl: io.uploadFileImpl,
|
|
281
|
+
progressStream: io.progressStream || out
|
|
282
|
+
});
|
|
283
|
+
const submitted = await submitSkillVersion(attachUploadResult(confirmedPayload, upload), {
|
|
284
|
+
flags: publishFlags,
|
|
285
|
+
accessToken: credentials.accessToken,
|
|
286
|
+
fetchImpl: io.fetchImpl
|
|
287
|
+
});
|
|
288
|
+
writeResult({ status: 'submitted', response: submitted }, out);
|
|
289
|
+
return;
|
|
290
|
+
} finally {
|
|
291
|
+
promptSession.close();
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
throw new SkillhubUploadError('INVALID_ARGS', `不支持的操作:${command},可用操作:login、publish、whoami、logout`, ExitCodes.INVALID_ARGS);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function isMainModule() {
|
|
298
|
+
if (!process.argv[1]) return false;
|
|
299
|
+
try {
|
|
300
|
+
return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url));
|
|
301
|
+
} catch {
|
|
302
|
+
return false;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
if (isMainModule()) {
|
|
307
|
+
main().catch((error) => {
|
|
308
|
+
writeResult(toResultError(error), process.stderr);
|
|
309
|
+
process.exit(error.exitCode || 1);
|
|
310
|
+
});
|
|
311
|
+
}
|
package/cli/output.mjs
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export function writeEvent(prefix, payload, stream = process.stdout) {
|
|
2
|
+
stream.write(`${prefix}:${JSON.stringify(payload)}\n`);
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
export function writePrompt(payload, stream = process.stdout) {
|
|
6
|
+
writeEvent('PROMPT', payload, stream);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function writeResult(payload, stream = process.stdout) {
|
|
10
|
+
writeEvent('RESULT_JSON', payload, stream);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function writeUploadProgress(percent, stream = process.stdout) {
|
|
14
|
+
stream.write(`UPLOAD_PROGRESS:${percent}\n`);
|
|
15
|
+
}
|