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/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
|
+
}
|