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 ADDED
@@ -0,0 +1,76 @@
1
+ # SkillHub Upload CLI
2
+
3
+ Node.js CLI for uploading local Skills to 小红书 SkillHub.
4
+
5
+ Install from the public npm registry:
6
+
7
+ ```bash
8
+ npm install -g redskillhub-upload
9
+ ```
10
+
11
+ The CLI owns the executable flow: authorization, local packaging, upload, submit prompts, and final submit. `skill/SKILL.md` is a thin agent workflow that translates chat requests into CLI invocations.
12
+
13
+ Publish input can be a local Skill directory or a `.zip` file. User-provided zip archives are unpacked and validated as source containers; the zip uploaded to COS is always regenerated by this CLI after local validation.
14
+
15
+ ## API Endpoints
16
+
17
+ All paths and hosts are centralized in `cli/config.mjs` (`PATHS` + `DEFAULT_API_BASE` / `DEFAULT_OAS_BASE`).
18
+
19
+ | Path Key | Path | Host | Description |
20
+ |----------|------|------|-------------|
21
+ | `UPLOAD_TOKEN` | `/api/sns/v2/red_skill/upload/permit` | `DEFAULT_API_BASE` | 获取上传授权(COS 临时凭证) |
22
+ | `SUBMIT_SKILL_VERSION` | `/api/sns/v1/openapi/skillhub/submit_skill_version` | `DEFAULT_API_BASE` | 提交 skill 版本 |
23
+ | `CREATE_CLI_OAUTH_DEVICE_CODE` | `/api/sns/v1/creator/red_skill/create_cli_oauth_device_code` | `DEFAULT_API_BASE` | 创建 CLI OAuth 设备码 |
24
+ | `POLL_CLI_OAUTH_DEVICE_TOKEN` | `/api/sns/v1/creator/red_skill/poll_cli_oauth_device_token` | `DEFAULT_API_BASE` | 轮询 CLI OAuth 授权状态 |
25
+ | `OAS_ACCESS_TOKEN` | `/api/sns/v1/oauth2/access_token` | `DEFAULT_OAS_BASE` | 用授权码换取 access_token |
26
+ | `OAS_REFRESH_TOKEN` | `/api/sns/v1/oauth2/refresh_token` | `DEFAULT_OAS_BASE` | 用 refresh_token 续期 access_token |
27
+
28
+ - `DEFAULT_API_BASE` = `https://edith-skillhub.sl.beta.xiaohongshu.com` — 业务接口
29
+ - `DEFAULT_OAS_BASE` = `https://openaccount.beta.xiaohongshu.com` — OAuth2 开放平台接口
30
+ - 业务接口 host 可通过 `--api-base` 参数或 `SKILLHUB_UPLOAD_API_BASE` 环境变量覆盖
31
+ - OAS 换 token 如需 confidential client 凭证,通过 `SKILLHUB_UPLOAD_APP_SECRET` 注入;不要写入仓库或命令行参数
32
+
33
+ ## Auth Flow
34
+
35
+ `publish` 会自动保证登录可用,不需要先执行 `whoami` 或 `login`:
36
+
37
+ 1. access_token 有效:直接继续发布
38
+ 2. access_token 过期但 refresh_token 有效:自动调用 `refreshToken` 静默续期后继续发布
39
+ 3. 首次登录或 refresh_token 也过期:自动进入设备码 OAuth(`createCliOauthDeviceCode` → 用户授权 → `pollCliOauthDeviceToken` → `exchangeToken`),授权成功后原地继续发布
40
+
41
+ `login` / `whoami` 仍保留用于手动登录和状态诊断。
42
+
43
+ ## Local Dry Run
44
+
45
+ ```bash
46
+ cd tools/redskillhub-upload
47
+ npm install
48
+ node cli/index.mjs publish test/fixtures/minimal-skill --dry-run --agent --source original --tag-id 101,102 --yes
49
+ ```
50
+
51
+ Dry-run examples may use numeric tag IDs because the backend contract accepts `contentTagIds`. Multiple tags are comma-separated; `--tag` can also accept display names resolved from the live tag dictionary.
52
+
53
+ ## Commands
54
+
55
+ ```bash
56
+ redskillhub-upload login
57
+ redskillhub-upload login --agent
58
+ redskillhub-upload login --cancel
59
+ redskillhub-upload publish /absolute/path/to/skill
60
+ redskillhub-upload publish /absolute/path/to/skill --agent
61
+ redskillhub-upload publish /absolute/path/to/skill.zip --agent
62
+ redskillhub-upload whoami
63
+ redskillhub-upload logout
64
+ ```
65
+
66
+ ## Agent Protocol
67
+
68
+ See `docs/protocol.md` for the line protocol:
69
+
70
+ - `PROMPT:<json>` requests user or agent input.
71
+ - `RESULT_JSON:<json>` reports terminal command state.
72
+ - `UPLOAD_PROGRESS:<number>` reports upload progress.
73
+
74
+ See `docs/design.md` for the full tool architecture, auth/upload flow, boundaries, and verification plan.
75
+
76
+ Dry-run never calls external services. Real OAuth, COS upload, and submit adapters are wired; production use depends on the SkillHub OpenAPI endpoints being reachable and configured.
@@ -0,0 +1,41 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import QRCode from 'qrcode';
4
+ import { getAuthQrPath } from './config.mjs';
5
+ import { writePrompt } from './output.mjs';
6
+
7
+ export async function renderDeviceAuthPrompt(payload, options = {}) {
8
+ const {
9
+ agent = false,
10
+ env = process.env,
11
+ stream = process.stdout,
12
+ qrCode = QRCode
13
+ } = options;
14
+
15
+ if (agent) {
16
+ const qrCodePath = getAuthQrPath(env);
17
+ await fs.mkdir(path.dirname(qrCodePath), { recursive: true, mode: 0o700 });
18
+ await qrCode.toFile(qrCodePath, payload.authorizeUrl, {
19
+ type: 'png',
20
+ width: 512,
21
+ margin: 2,
22
+ errorCorrectionLevel: 'M'
23
+ });
24
+ await fs.chmod(qrCodePath, 0o600);
25
+ writePrompt({ ...payload, qrCodePath }, stream);
26
+ return;
27
+ }
28
+
29
+ const qr = await qrCode.toString(payload.authorizeUrl, {
30
+ type: 'terminal',
31
+ small: true,
32
+ errorCorrectionLevel: 'M'
33
+ });
34
+ const minutes = Math.max(1, Math.ceil(Number(payload.expiresInSeconds || 0) / 60));
35
+ stream.write(`${qr}\n`);
36
+ stream.write(`${payload.message}\n`);
37
+ stream.write(`授权链接:${payload.authorizeUrl}\n`);
38
+ stream.write(`授权码:${payload.userCode}\n`);
39
+ stream.write(`有效期:${minutes} 分钟\n`);
40
+ }
41
+
package/cli/auth.mjs ADDED
@@ -0,0 +1,453 @@
1
+ import crypto from 'node:crypto';
2
+ import fs from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import {
5
+ DEFAULT_API_BASE,
6
+ DEFAULT_APP_ID,
7
+ DEFAULT_OAS_BASE,
8
+ DEFAULT_SCOPE,
9
+ DEFAULT_SCOPES,
10
+ PATHS,
11
+ TOKEN_REFRESH_BUFFER_MS,
12
+ getAuthQrPath,
13
+ getCredentialsPath,
14
+ getHomeDir,
15
+ getPendingAuthPath,
16
+ resolveApiBase
17
+ } from './config.mjs';
18
+ import { ExitCodes, SkillhubUploadError } from './errors.mjs';
19
+ import { compatibleFetch } from './fetch.mjs';
20
+ import { renderDeviceAuthPrompt } from './auth-prompt.mjs';
21
+
22
+ function base64Url(input) {
23
+ return Buffer.from(input).toString('base64url');
24
+ }
25
+
26
+ export async function createPkcePair() {
27
+ const codeVerifier = base64Url(crypto.randomBytes(48)).slice(0, 64).replace(/-/g, '.');
28
+ const hash = crypto.createHash('sha256').update(codeVerifier).digest();
29
+ return {
30
+ codeVerifier,
31
+ codeChallenge: base64Url(hash),
32
+ codeChallengeMethod: 'S256'
33
+ };
34
+ }
35
+
36
+ function isValidCredentials(credentials, nowMs = Date.now()) {
37
+ if (!credentials?.accessToken) return false;
38
+ const expireTimeMs = Number(credentials.expireTimeMs || 0);
39
+ return Number.isFinite(expireTimeMs) && expireTimeMs > nowMs + TOKEN_REFRESH_BUFFER_MS;
40
+ }
41
+
42
+ function canRefreshCredentials(credentials, nowMs = Date.now()) {
43
+ if (!credentials?.refreshToken) return false;
44
+ const refreshExpireTimeMs = Number(credentials.refreshExpireTimeMs || 0);
45
+ return Number.isFinite(refreshExpireTimeMs) && refreshExpireTimeMs > nowMs + TOKEN_REFRESH_BUFFER_MS;
46
+ }
47
+
48
+ export async function readCredentials(env = process.env) {
49
+ try {
50
+ const file = getCredentialsPath(env);
51
+ await fs.chmod(file, 0o600).catch(() => {});
52
+ return JSON.parse(await fs.readFile(file, 'utf8'));
53
+ } catch (error) {
54
+ if (error.code === 'ENOENT') return null;
55
+ throw error;
56
+ }
57
+ }
58
+
59
+ export async function writeCredentials(credentials, env = process.env) {
60
+ const home = getHomeDir(env);
61
+ const file = getCredentialsPath(env);
62
+ const tmp = path.join(path.dirname(file), `.${path.basename(file)}.${process.pid}.${crypto.randomUUID()}.tmp`);
63
+ await fs.mkdir(home, { recursive: true, mode: 0o700 });
64
+ try {
65
+ await fs.writeFile(tmp, `${JSON.stringify(credentials, null, 2)}\n`, { mode: 0o600 });
66
+ await fs.chmod(tmp, 0o600);
67
+ await fs.rename(tmp, file);
68
+ await fs.chmod(file, 0o600);
69
+ } catch (error) {
70
+ await fs.rm(tmp, { force: true }).catch(() => {});
71
+ throw error;
72
+ }
73
+ }
74
+
75
+ export async function removeCredentials(env = process.env) {
76
+ await fs.rm(getCredentialsPath(env), { force: true });
77
+ }
78
+
79
+ export function maskCredentials(credentials) {
80
+ if (!credentials) return { loggedIn: false };
81
+ const loggedIn = Boolean(credentials.accessToken);
82
+ return {
83
+ loggedIn,
84
+ appId: credentials.appId,
85
+ openId: credentials.openId,
86
+ scope: credentials.scope,
87
+ expireTimeMs: credentials.expireTimeMs,
88
+ refreshExpireTimeMs: credentials.refreshExpireTimeMs,
89
+ accessToken: '<hidden>',
90
+ refreshToken: '<hidden>'
91
+ };
92
+ }
93
+
94
+ function normalizeAuthStatus(status) {
95
+ if (typeof status === 'number') {
96
+ if (status === 1) return 'pending';
97
+ if (status === 2) return 'approved';
98
+ if (status === 3) return 'denied';
99
+ if (status === 4) return 'expired';
100
+ if (status === 5) return 'cancelled';
101
+ return 'unknown';
102
+ }
103
+ return String(status || '').toLowerCase();
104
+ }
105
+
106
+ function unwrapResponseBody(body) {
107
+ return body?.data && typeof body.data === 'object' ? body.data : body;
108
+ }
109
+
110
+ function resolveAppSecret(flags = {}, env = process.env) {
111
+ return flags.appSecret || flags['app-secret'] || env.SKILLHUB_UPLOAD_APP_SECRET || '';
112
+ }
113
+
114
+ function buildTokenRequestBody(fields = {}) {
115
+ const body = {
116
+ app_id: fields.appId,
117
+ code: fields.authorizationCode,
118
+ code_verifier: fields.codeVerifier
119
+ };
120
+ if (fields.appSecret) {
121
+ body.app_secret = fields.appSecret;
122
+ }
123
+ return body;
124
+ }
125
+
126
+ function buildRefreshRequestBody(fields = {}) {
127
+ const body = {
128
+ app_id: fields.appId,
129
+ refresh_token: fields.refreshToken
130
+ };
131
+ if (fields.appSecret) {
132
+ body.app_secret = fields.appSecret;
133
+ }
134
+ return body;
135
+ }
136
+
137
+ function getRejectMessage(body, fallback) {
138
+ const data = unwrapResponseBody(body);
139
+ return data?.message
140
+ || data?.msg
141
+ || data?.error_description
142
+ || data?.error
143
+ || body?.message
144
+ || body?.msg
145
+ || body?.error_description
146
+ || body?.error
147
+ || fallback;
148
+ }
149
+
150
+ function assertTokenResponseAccepted(body, errorCode, fallbackMessage) {
151
+ const data = unwrapResponseBody(body);
152
+ if (body?.success === false || data?.success === false) {
153
+ throw new SkillhubUploadError(
154
+ errorCode,
155
+ getRejectMessage(body, fallbackMessage),
156
+ ExitCodes.AUTH
157
+ );
158
+ }
159
+ }
160
+
161
+ function normalizeDeviceCodePayload(body) {
162
+ const data = unwrapResponseBody(body);
163
+ return {
164
+ deviceCode: data.deviceCode || data.device_code || '',
165
+ userCode: data.userCode || data.user_code || '',
166
+ authorizeUrl: data.authorizeUrl || data.authorize_url || '',
167
+ expiresInSeconds: data.expiresInSeconds || data.expires_in_seconds,
168
+ pollIntervalSeconds: data.pollIntervalSeconds || data.poll_interval_seconds
169
+ };
170
+ }
171
+
172
+ function normalizePollPayload(body) {
173
+ const data = unwrapResponseBody(body);
174
+ const result = {
175
+ authStatus: data.authStatus ?? data.auth_status ?? data.status
176
+ };
177
+ const authorizationCode = data.authorizationCode || data.authorization_code;
178
+ if (authorizationCode) {
179
+ result.authorizationCode = authorizationCode;
180
+ }
181
+ return result;
182
+ }
183
+
184
+ export async function requestDeviceCode({ apiBase, pkce, fetchImpl = compatibleFetch }) {
185
+ const response = await fetchImpl(`${apiBase}${PATHS.CREATE_CLI_OAUTH_DEVICE_CODE}`, {
186
+ method: 'POST',
187
+ headers: { 'content-type': 'application/json' },
188
+ body: JSON.stringify({
189
+ code_challenge: pkce.codeChallenge,
190
+ code_challenge_method: pkce.codeChallengeMethod,
191
+ scopes: DEFAULT_SCOPES
192
+ })
193
+ });
194
+ if (!response.ok) {
195
+ throw new SkillhubUploadError(
196
+ 'AUTH_DEVICE_CODE_FAILED',
197
+ `申请授权码失败: HTTP ${response.status}`,
198
+ ExitCodes.AUTH
199
+ );
200
+ }
201
+ return normalizeDeviceCodePayload(await response.json());
202
+ }
203
+
204
+ export async function pollDeviceToken({ apiBase, deviceCode, fetchImpl = compatibleFetch }) {
205
+ const url = new URL(`${apiBase}${PATHS.POLL_CLI_OAUTH_DEVICE_TOKEN}`);
206
+ url.searchParams.set('device_code', deviceCode);
207
+ const response = await fetchImpl(url.toString(), {
208
+ method: 'GET'
209
+ });
210
+ if (!response.ok) {
211
+ throw new SkillhubUploadError(
212
+ 'AUTH_POLL_FAILED',
213
+ `轮询授权状态失败: HTTP ${response.status}`,
214
+ ExitCodes.AUTH
215
+ );
216
+ }
217
+ return normalizePollPayload(await response.json());
218
+ }
219
+
220
+ export async function exchangeToken({
221
+ tokenUrl = `${DEFAULT_OAS_BASE}${PATHS.OAS_ACCESS_TOKEN}`,
222
+ appId = DEFAULT_APP_ID,
223
+ appSecret = '',
224
+ authorizationCode,
225
+ codeVerifier,
226
+ fetchImpl = compatibleFetch
227
+ }) {
228
+ const body = buildTokenRequestBody({ appId, appSecret, authorizationCode, codeVerifier });
229
+ const response = await fetchImpl(tokenUrl, {
230
+ method: 'POST',
231
+ headers: { 'content-type': 'application/json' },
232
+ body: JSON.stringify(body)
233
+ });
234
+ if (!response.ok) {
235
+ throw new SkillhubUploadError(
236
+ 'OAS_TOKEN_EXCHANGE_UNAVAILABLE',
237
+ `换取 token 失败: HTTP ${response.status}`,
238
+ ExitCodes.AUTH
239
+ );
240
+ }
241
+ const responseBody = await response.json();
242
+ assertTokenResponseAccepted(responseBody, 'OAS_TOKEN_EXCHANGE_REJECTED', '换取 token 被 OAS 拒绝');
243
+ return unwrapResponseBody(responseBody);
244
+ }
245
+
246
+ export async function refreshToken({
247
+ tokenUrl = `${DEFAULT_OAS_BASE}${PATHS.OAS_REFRESH_TOKEN}`,
248
+ appId = DEFAULT_APP_ID,
249
+ appSecret = '',
250
+ refreshToken: rt,
251
+ fetchImpl = compatibleFetch
252
+ }) {
253
+ const body = buildRefreshRequestBody({ appId, appSecret, refreshToken: rt });
254
+ const response = await fetchImpl(tokenUrl, {
255
+ method: 'POST',
256
+ headers: { 'content-type': 'application/json' },
257
+ body: JSON.stringify(body)
258
+ });
259
+ if (!response.ok) {
260
+ throw new SkillhubUploadError(
261
+ 'OAS_TOKEN_REFRESH_UNAVAILABLE',
262
+ `刷新 token 失败: HTTP ${response.status}`,
263
+ ExitCodes.AUTH
264
+ );
265
+ }
266
+ const responseBody = await response.json();
267
+ assertTokenResponseAccepted(responseBody, 'OAS_TOKEN_REFRESH_REJECTED', '刷新 token 被 OAS 拒绝');
268
+ return unwrapResponseBody(responseBody);
269
+ }
270
+
271
+ function buildCredentialsFromToken(token, nowMs, errorCode) {
272
+ const credentials = {
273
+ appId: token.app_id || token.appId || DEFAULT_APP_ID,
274
+ accessToken: token.access_token || token.accessToken || '',
275
+ refreshToken: token.refresh_token || token.refreshToken || '',
276
+ expireTimeMs: String(token.expire_time_ms || token.expireTimeMs || nowMs + Number(token.expires_in || 7200) * 1000),
277
+ refreshExpireTimeMs: String(
278
+ token.refresh_expire_time_ms
279
+ || token.refreshExpireTimeMs
280
+ || nowMs + 180 * 24 * 3600 * 1000
281
+ ),
282
+ openId: token.open_id || token.openId || '',
283
+ scope: token.scope || DEFAULT_SCOPE,
284
+ obtainedAtMs: String(nowMs)
285
+ };
286
+ if (!credentials.accessToken || !credentials.refreshToken) {
287
+ throw new SkillhubUploadError(
288
+ errorCode,
289
+ '换取 token 响应缺少 access_token 或 refresh_token',
290
+ ExitCodes.AUTH
291
+ );
292
+ }
293
+ return credentials;
294
+ }
295
+
296
+ async function readPendingAuth(env, now) {
297
+ try {
298
+ const raw = JSON.parse(await fs.readFile(getPendingAuthPath(env), 'utf8'));
299
+ if (raw?.expiresAtMs && raw.expiresAtMs > now() && raw.deviceCode && raw.codeVerifier) {
300
+ return raw;
301
+ }
302
+ } catch {
303
+ // no pending state
304
+ }
305
+ return null;
306
+ }
307
+
308
+ async function writePendingAuth(env, state) {
309
+ const file = getPendingAuthPath(env);
310
+ await fs.mkdir(path.dirname(file), { recursive: true, mode: 0o700 });
311
+ await fs.writeFile(file, `${JSON.stringify(state, null, 2)}\n`, { mode: 0o600 });
312
+ }
313
+
314
+ async function clearPendingAuth(env) {
315
+ await fs.rm(getPendingAuthPath(env), { force: true });
316
+ await fs.rm(getAuthQrPath(env), { force: true });
317
+ }
318
+
319
+ export async function cancelLogin(env = process.env) {
320
+ let hadPending = true;
321
+ try {
322
+ await fs.access(getPendingAuthPath(env));
323
+ } catch (error) {
324
+ if (error.code !== 'ENOENT') throw error;
325
+ hadPending = false;
326
+ }
327
+ await clearPendingAuth(env);
328
+ return { hadPending };
329
+ }
330
+
331
+ export async function login({
332
+ flags = {},
333
+ env = process.env,
334
+ fetchImpl = compatibleFetch,
335
+ sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
336
+ now = () => Date.now(),
337
+ promptStream = process.stdout
338
+ } = {}) {
339
+ const cachedCredentials = await readCredentials(env);
340
+ if (isValidCredentials(cachedCredentials, now())) {
341
+ return maskCredentials(cachedCredentials);
342
+ }
343
+
344
+ // access_token 过期但 refresh_token 仍有效,静默续期
345
+ if (canRefreshCredentials(cachedCredentials, now())) {
346
+ try {
347
+ const appSecret = resolveAppSecret(flags, env);
348
+ const token = await refreshToken({
349
+ appId: cachedCredentials.appId || DEFAULT_APP_ID,
350
+ appSecret,
351
+ refreshToken: cachedCredentials.refreshToken,
352
+ tokenUrl: flags.tokenUrl || `${DEFAULT_OAS_BASE}${PATHS.OAS_REFRESH_TOKEN}`,
353
+ fetchImpl
354
+ });
355
+ await writeCredentials(buildCredentialsFromToken(token, now(), 'OAS_TOKEN_REFRESH_INVALID_RESPONSE'), env);
356
+ return maskCredentials(await readCredentials(env));
357
+ } catch (error) {
358
+ if (error instanceof SkillhubUploadError) {
359
+ // 刷新失败,降级走设备码
360
+ } else {
361
+ throw error;
362
+ }
363
+ }
364
+ }
365
+
366
+ const apiBase = resolveApiBase(flags);
367
+
368
+ // 从磁盘恢复未完成的授权状态,避免进程重启后要求用户重新授权
369
+ let pending = await readPendingAuth(env, now);
370
+ let codeVerifier;
371
+ if (pending) {
372
+ codeVerifier = pending.codeVerifier;
373
+ await renderDeviceAuthPrompt({
374
+ type: 'auth_device_code',
375
+ authorizeUrl: pending.authorizeUrl,
376
+ userCode: pending.userCode,
377
+ expiresInSeconds: Math.round((pending.expiresAtMs - now()) / 1000),
378
+ message: '检测到未完成的授权,继续等待中(无需重新打开授权链接)'
379
+ }, { agent: Boolean(flags.agent), env, stream: promptStream });
380
+ } else {
381
+ const pkce = await createPkcePair();
382
+ codeVerifier = pkce.codeVerifier;
383
+ const device = await requestDeviceCode({ apiBase, pkce, fetchImpl });
384
+ await writePendingAuth(env, {
385
+ deviceCode: device.deviceCode,
386
+ codeVerifier: pkce.codeVerifier,
387
+ authorizeUrl: device.authorizeUrl,
388
+ userCode: device.userCode,
389
+ pollIntervalSeconds: device.pollIntervalSeconds || 5,
390
+ expiresAtMs: now() + (device.expiresInSeconds || 600) * 1000
391
+ });
392
+ pending = await readPendingAuth(env, now);
393
+ await renderDeviceAuthPrompt({
394
+ type: 'auth_device_code',
395
+ authorizeUrl: device.authorizeUrl,
396
+ userCode: device.userCode,
397
+ expiresInSeconds: device.expiresInSeconds || 600,
398
+ message: '请用手机自带浏览器打开授权链接,页面会自动跳转至小红书 App 授权;授权页输入授权码后 CLI 会自动轮询'
399
+ }, { agent: Boolean(flags.agent), env, stream: promptStream });
400
+ }
401
+
402
+ const deadline = pending.expiresAtMs;
403
+ let authorizationCode = '';
404
+ try {
405
+ while (now() < deadline) {
406
+ await sleep((pending.pollIntervalSeconds || 5) * 1000);
407
+ // 这里只检测跨进程取消,不把自然过期误判为取消。
408
+ const activePending = await readPendingAuth(env, () => 0);
409
+ if (!activePending || activePending.deviceCode !== pending.deviceCode) {
410
+ throw new SkillhubUploadError(
411
+ 'AUTH_CANCELLED',
412
+ '已取消等待登录',
413
+ ExitCodes.CANCELLED
414
+ );
415
+ }
416
+ const polled = await pollDeviceToken({ apiBase, deviceCode: pending.deviceCode, fetchImpl });
417
+ const authStatus = normalizeAuthStatus(polled.authStatus ?? polled.status);
418
+ if (authStatus === 'approved') {
419
+ authorizationCode = polled.authorizationCode || polled.authorization_code || '';
420
+ break;
421
+ }
422
+ if (authStatus === 'expired') {
423
+ await clearPendingAuth(env);
424
+ throw new SkillhubUploadError('AUTH_TIMEOUT', '授权超时,请重新发起', ExitCodes.AUTH);
425
+ }
426
+ if (authStatus === 'cancelled' || authStatus === 'denied' || authStatus === 'rejected') {
427
+ await clearPendingAuth(env);
428
+ throw new SkillhubUploadError('AUTH_DENIED', '用户拒绝授权', ExitCodes.AUTH);
429
+ }
430
+ }
431
+ if (!authorizationCode) {
432
+ await clearPendingAuth(env);
433
+ throw new SkillhubUploadError('AUTH_TIMEOUT', '授权超时,请重新发起', ExitCodes.AUTH);
434
+ }
435
+ } catch (error) {
436
+ if (!(error instanceof SkillhubUploadError)) {
437
+ await clearPendingAuth(env);
438
+ }
439
+ throw error;
440
+ }
441
+
442
+ const token = await exchangeToken({
443
+ appId: DEFAULT_APP_ID,
444
+ appSecret: resolveAppSecret(flags, env),
445
+ authorizationCode,
446
+ codeVerifier,
447
+ tokenUrl: flags.tokenUrl || `${DEFAULT_OAS_BASE}${PATHS.OAS_ACCESS_TOKEN}`,
448
+ fetchImpl
449
+ });
450
+ await writeCredentials(buildCredentialsFromToken(token, now(), 'OAS_TOKEN_EXCHANGE_INVALID_RESPONSE'), env);
451
+ await clearPendingAuth(env);
452
+ return maskCredentials(await readCredentials(env));
453
+ }
package/cli/config.mjs ADDED
@@ -0,0 +1,51 @@
1
+ import os from 'node:os';
2
+ import path from 'node:path';
3
+
4
+ export const DEFAULT_API_BASE = 'https://edith.xiaohongshu.com';
5
+ export const DEFAULT_OAS_BASE = 'https://openaccount.xiaohongshu.com';
6
+ export const DEFAULT_APP_ID = 'xhsyfHfmZQAQ24o';
7
+ export const DEFAULT_SCOPES = ['base_info', 'skill_publish'];
8
+ export const DEFAULT_SCOPE = DEFAULT_SCOPES.join(',');
9
+
10
+ export const PATHS = {
11
+ UPLOAD_TOKEN: '/api/sns/v2/red_skill/upload/permit',
12
+ SUBMIT_SKILL_VERSION: '/api/sns/v1/creator/red_skill/cli_submit_skill_version',
13
+ CREATE_CLI_OAUTH_DEVICE_CODE: '/api/sns/v1/creator/red_skill/create_cli_oauth_device_code',
14
+ POLL_CLI_OAUTH_DEVICE_TOKEN: '/api/sns/v1/creator/red_skill/poll_cli_oauth_device_token',
15
+ OAS_ACCESS_TOKEN: '/api/sns/v1/oauth2/access_token',
16
+ OAS_REFRESH_TOKEN: '/api/sns/v1/oauth2/refresh_token',
17
+ QUERY_CONTENT_TAG_CONFIG: '/api/sns/v1/activity_platform/config/query_config'
18
+ };
19
+ export const TOKEN_REFRESH_BUFFER_MS = 5 * 60 * 1000;
20
+ export const CONTENT_TAG_MATERIAL_ID = '750';
21
+ export const CONTENT_TAG_MODULE_ID = '811';
22
+ export const CONTENT_TAG_CACHE_TTL_MS = 24 * 60 * 60 * 1000;
23
+ export const CONTENT_TAG_FETCH_TIMEOUT_MS = 3000;
24
+
25
+ export function getHomeDir(env = process.env) {
26
+ return env.SKILLHUB_UPLOAD_HOME || path.join(os.homedir(), '.skillhub-upload');
27
+ }
28
+
29
+ export function getCredentialsPath(env = process.env) {
30
+ return path.join(getHomeDir(env), 'credentials.json');
31
+ }
32
+
33
+ export function getTmpDir(env = process.env) {
34
+ return path.join(getHomeDir(env), 'tmp');
35
+ }
36
+
37
+ export function getContentTagCachePath(env = process.env) {
38
+ return path.join(getHomeDir(env), 'content-tags.json');
39
+ }
40
+
41
+ export function getPendingAuthPath(env = process.env) {
42
+ return path.join(getHomeDir(env), 'pending-auth.json');
43
+ }
44
+
45
+ export function getAuthQrPath(env = process.env) {
46
+ return path.join(getHomeDir(env), 'auth-qr.png');
47
+ }
48
+
49
+ export function resolveApiBase(flags = {}) {
50
+ return flags.apiBase || process.env.SKILLHUB_UPLOAD_API_BASE || DEFAULT_API_BASE;
51
+ }
package/cli/errors.mjs ADDED
@@ -0,0 +1,30 @@
1
+ export class SkillhubUploadError extends Error {
2
+ constructor(code, message, exitCode = 1) {
3
+ super(message);
4
+ this.name = 'SkillhubUploadError';
5
+ this.code = code;
6
+ this.exitCode = exitCode;
7
+ }
8
+ }
9
+
10
+ export const ExitCodes = Object.freeze({
11
+ OK: 0,
12
+ GENERAL: 1,
13
+ NEED_LOGIN: 2,
14
+ AUTH: 3,
15
+ CANCELLED: 130,
16
+ INVALID_ARGS: 64,
17
+ LOCAL_VALIDATION: 10,
18
+ PACK_FAILED: 11,
19
+ PERMIT_FAILED: 20,
20
+ COS_UPLOAD_FAILED: 21,
21
+ SUBMIT_REJECTED: 22,
22
+ SUBMIT_FAILED: 23
23
+ });
24
+
25
+ export function toResultError(error) {
26
+ if (error instanceof SkillhubUploadError) {
27
+ return { status: 'error', code: error.code, message: error.message };
28
+ }
29
+ return { status: 'error', code: 'UNEXPECTED_ERROR', message: error?.message || String(error) };
30
+ }