wucaishi-generative-react-skill 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +51 -0
- package/SKILL.md +196 -0
- package/bin/install.mjs +114 -0
- package/package.json +27 -0
- package/scripts/check_component_name_exists.mjs +187 -0
- package/scripts/check_h5_font_sizes.mjs +85 -0
- package/scripts/check_h5_vw_units.mjs +62 -0
- package/subskills/build-version-confirm-zh/SKILL.md +93 -0
- package/subskills/html-template-to-react-components-zh/SKILL.md +134 -0
- package/subskills/html-template-to-react-components-zh/scripts/analyze_template_pair.mjs +377 -0
- package/subskills/html-template-to-react-components-zh/scripts/validate_component_contract.mjs +217 -0
- package/subskills/react-component-spec-zh/SKILL.md +601 -0
- package/subskills/upload-aliyun-oss-zh/SKILL.md +145 -0
- package/subskills/upload-aliyun-oss-zh/agents/openai.yaml +7 -0
- package/subskills/upload-aliyun-oss-zh/scripts/upload_dist_to_aliyun_oss.mjs +640 -0
|
@@ -0,0 +1,640 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import { chmod, mkdir, readdir, readFile, stat, writeFile } from 'node:fs/promises';
|
|
4
|
+
import { homedir } from 'node:os';
|
|
5
|
+
import { basename, dirname, extname, join, resolve, relative } from 'node:path';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
|
|
8
|
+
const API_BASE = 'https://prism-stone-pre.byering.com/api';
|
|
9
|
+
const AUTH_CACHE_PATH = join(homedir(), '.wucaishi-component-workflow', 'auth.json');
|
|
10
|
+
|
|
11
|
+
function fail(message, code = 1) {
|
|
12
|
+
console.error(`错误:${message}`);
|
|
13
|
+
process.exit(code);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function parseArgs(argv) {
|
|
17
|
+
const args = {
|
|
18
|
+
projectRoot: '.',
|
|
19
|
+
dist: null,
|
|
20
|
+
prefix: null,
|
|
21
|
+
phone: null,
|
|
22
|
+
password: null,
|
|
23
|
+
expiresInSeconds: 600,
|
|
24
|
+
skipScan: false,
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
28
|
+
const arg = argv[i];
|
|
29
|
+
const nextValue = () => {
|
|
30
|
+
const value = argv[i + 1];
|
|
31
|
+
if (!value || value.startsWith('--')) {
|
|
32
|
+
fail(`${arg} 缺少参数值`);
|
|
33
|
+
}
|
|
34
|
+
i += 1;
|
|
35
|
+
return value;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
if (arg === '-h' || arg === '--help') {
|
|
39
|
+
printHelp();
|
|
40
|
+
process.exit(0);
|
|
41
|
+
} else if (arg === '--project-root') {
|
|
42
|
+
args.projectRoot = nextValue();
|
|
43
|
+
} else if (arg === '--dist') {
|
|
44
|
+
args.dist = nextValue();
|
|
45
|
+
} else if (arg === '--prefix') {
|
|
46
|
+
args.prefix = nextValue();
|
|
47
|
+
} else if (arg === '--phone') {
|
|
48
|
+
args.phone = nextValue();
|
|
49
|
+
} else if (arg === '--password') {
|
|
50
|
+
args.password = nextValue();
|
|
51
|
+
} else if (arg === '--expires-in-seconds') {
|
|
52
|
+
args.expiresInSeconds = Number.parseInt(nextValue(), 10);
|
|
53
|
+
if (!Number.isFinite(args.expiresInSeconds) || args.expiresInSeconds <= 0) {
|
|
54
|
+
fail('--expires-in-seconds 必须是正整数');
|
|
55
|
+
}
|
|
56
|
+
} else if (arg === '--skip-scan') {
|
|
57
|
+
args.skipScan = true;
|
|
58
|
+
} else {
|
|
59
|
+
fail(`未知参数:${arg}`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return args;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function printHelp() {
|
|
67
|
+
console.log(`usage: upload_dist_to_aliyun_oss.mjs [options]
|
|
68
|
+
|
|
69
|
+
登录 VoxBean 预发环境并上传 dist 下所有文件到阿里云 OSS
|
|
70
|
+
|
|
71
|
+
options:
|
|
72
|
+
-h, --help 显示帮助
|
|
73
|
+
--project-root PROJECT_ROOT 项目根目录,默认当前目录
|
|
74
|
+
--dist DIST dist 目录,默认 project-root/dist
|
|
75
|
+
--prefix PREFIX 手动覆盖 OSS 目录前缀;默认 h5/components/{package.name}/{package.version}
|
|
76
|
+
--phone PHONE 登录账号,首次登录必填;之后可读取本地缓存
|
|
77
|
+
--password PASSWORD 登录密码,首次登录必填;之后可读取本地缓存
|
|
78
|
+
--expires-in-seconds EXPIRES presign 有效期,默认 600 秒
|
|
79
|
+
--skip-scan 跳过 file:// 和本机绝对路径扫描`);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function loadSavedCredentials() {
|
|
83
|
+
try {
|
|
84
|
+
const raw = await readFile(AUTH_CACHE_PATH, 'utf8');
|
|
85
|
+
const saved = JSON.parse(raw);
|
|
86
|
+
if (saved && typeof saved.phone === 'string' && typeof saved.password === 'string') {
|
|
87
|
+
return {
|
|
88
|
+
phone: saved.phone.trim(),
|
|
89
|
+
password: saved.password.trim(),
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
} catch {
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function saveCredentials(phone, password) {
|
|
99
|
+
const payload = JSON.stringify({ phone, password }, null, 2);
|
|
100
|
+
await mkdir(dirname(AUTH_CACHE_PATH), { recursive: true, mode: 0o700 });
|
|
101
|
+
await writeFile(AUTH_CACHE_PATH, payload, { mode: 0o600 });
|
|
102
|
+
await chmod(AUTH_CACHE_PATH, 0o600);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function resolveCredentials(args) {
|
|
106
|
+
const saved = await loadSavedCredentials();
|
|
107
|
+
const phone = args.phone ? args.phone.trim() : saved?.phone;
|
|
108
|
+
const password = args.password ? args.password.trim() : saved?.password;
|
|
109
|
+
|
|
110
|
+
if (!phone || !password) {
|
|
111
|
+
fail(`缺少登录账号或密码。请先传入 --phone 登录账号 --password 登录密码,登录成功后会保存到本地:${AUTH_CACHE_PATH}`);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return { phone, password };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async function requestJson(method, url, { body, token } = {}) {
|
|
118
|
+
const headers = { Accept: 'application/json', 'X-Device-Id': 'codex-component-publisher' };
|
|
119
|
+
const init = { method, headers };
|
|
120
|
+
if (body !== undefined) {
|
|
121
|
+
headers['Content-Type'] = 'application/json';
|
|
122
|
+
init.body = JSON.stringify(body);
|
|
123
|
+
}
|
|
124
|
+
if (token) {
|
|
125
|
+
headers.Authorization = `Bearer ${token}`;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
let response;
|
|
129
|
+
try {
|
|
130
|
+
response = await fetch(url, init);
|
|
131
|
+
} catch (error) {
|
|
132
|
+
fail(`请求失败:${method} ${url}\n${error.message}`);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const raw = await response.text();
|
|
136
|
+
if (!response.ok) {
|
|
137
|
+
fail(`HTTP ${response.status}:${method} ${url}\n${raw}`);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
try {
|
|
141
|
+
return JSON.parse(raw);
|
|
142
|
+
} catch {
|
|
143
|
+
fail(`接口返回不是 JSON:${method} ${url}\n${raw}`);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function entityOrFail(payload, apiName) {
|
|
148
|
+
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
|
|
149
|
+
fail(`${apiName} 返回不是对象:${String(payload)}`);
|
|
150
|
+
}
|
|
151
|
+
if (String(payload.code) !== '10000') {
|
|
152
|
+
fail(`${apiName} 返回失败:${JSON.stringify(payload, null, 2)}`);
|
|
153
|
+
}
|
|
154
|
+
if (!payload.entity || typeof payload.entity !== 'object' || Array.isArray(payload.entity)) {
|
|
155
|
+
fail(`${apiName} 返回缺少 entity:${JSON.stringify(payload, null, 2)}`);
|
|
156
|
+
}
|
|
157
|
+
return payload.entity;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async function login(phone, password) {
|
|
161
|
+
const body = { phone, password };
|
|
162
|
+
const payload = await requestJson('POST', `${API_BASE}/web/auth/login`, { body });
|
|
163
|
+
const entity = entityOrFail(payload, '测试登录');
|
|
164
|
+
if (!entity.accessToken) {
|
|
165
|
+
fail(`测试登录返回缺少 entity.accessToken:${JSON.stringify(payload, null, 2)}`);
|
|
166
|
+
}
|
|
167
|
+
return entity.accessToken;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
async function presign(token, prefix, fileName, contentType, expiresInSeconds) {
|
|
171
|
+
const params = new URLSearchParams({
|
|
172
|
+
fileName,
|
|
173
|
+
prefix,
|
|
174
|
+
expiresInSeconds: String(expiresInSeconds),
|
|
175
|
+
contentType,
|
|
176
|
+
});
|
|
177
|
+
const payload = await requestJson('GET', `${API_BASE}/bean/storage/oss/presign?${params}`, { token });
|
|
178
|
+
const entity = entityOrFail(payload, 'OSS presign');
|
|
179
|
+
const missing = ['accessId', 'policy', 'signature', 'host', 'objectKey'].filter((key) => !entity[key]);
|
|
180
|
+
if (missing.length > 0) {
|
|
181
|
+
fail(`OSS presign 返回缺少字段 ${missing.join(', ')}:${JSON.stringify(payload, null, 2)}`);
|
|
182
|
+
}
|
|
183
|
+
return entity;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
async function createDeliverableH5Component(token, payload) {
|
|
187
|
+
const response = await requestJson('POST', `${API_BASE}/admin/deliverable-h5-components/create`, {
|
|
188
|
+
body: payload,
|
|
189
|
+
token,
|
|
190
|
+
});
|
|
191
|
+
return entityOrFail(response, '新增组件');
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
async function uploadForm(entity, filePath, contentType) {
|
|
195
|
+
const bytes = await readFile(filePath);
|
|
196
|
+
const form = new FormData();
|
|
197
|
+
form.set('key', entity.objectKey);
|
|
198
|
+
form.set('OSSAccessKeyId', entity.accessId);
|
|
199
|
+
form.set('policy', entity.policy);
|
|
200
|
+
form.set('Signature', entity.signature);
|
|
201
|
+
form.set('success_action_status', '200');
|
|
202
|
+
form.set('Content-Type', entity.contentType || contentType);
|
|
203
|
+
form.set('x-oss-object-acl', 'public-read');
|
|
204
|
+
form.set('file', new Blob([bytes], { type: contentType }), basename(filePath));
|
|
205
|
+
|
|
206
|
+
let response;
|
|
207
|
+
try {
|
|
208
|
+
response = await fetch(entity.host, { method: 'POST', body: form });
|
|
209
|
+
} catch (error) {
|
|
210
|
+
fail(`OSS 上传失败:${entity.objectKey}\n${error.message}`);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const raw = await response.text();
|
|
214
|
+
if (!response.ok) {
|
|
215
|
+
fail(`OSS 上传失败 HTTP ${response.status}:${entity.objectKey}\n${raw}`);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function stripUrlSignature(urlValue) {
|
|
220
|
+
if (!urlValue) {
|
|
221
|
+
return null;
|
|
222
|
+
}
|
|
223
|
+
try {
|
|
224
|
+
const url = new URL(String(urlValue));
|
|
225
|
+
url.search = '';
|
|
226
|
+
url.hash = '';
|
|
227
|
+
return url.toString();
|
|
228
|
+
} catch {
|
|
229
|
+
const cleaned = String(urlValue).split('?')[0].split('#')[0].trim();
|
|
230
|
+
return cleaned || null;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function encodeObjectKey(objectKey) {
|
|
235
|
+
return String(objectKey)
|
|
236
|
+
.split('/')
|
|
237
|
+
.map((part) => encodeURIComponent(part))
|
|
238
|
+
.join('/');
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function resolvePublicReadUrl(entity) {
|
|
242
|
+
const publicUrl = stripUrlSignature(entity.publicUrl);
|
|
243
|
+
if (publicUrl) {
|
|
244
|
+
return publicUrl;
|
|
245
|
+
}
|
|
246
|
+
if (!entity.host || !entity.objectKey) {
|
|
247
|
+
return null;
|
|
248
|
+
}
|
|
249
|
+
try {
|
|
250
|
+
const base = String(entity.host).endsWith('/') ? String(entity.host) : `${entity.host}/`;
|
|
251
|
+
const url = new URL(encodeObjectKey(entity.objectKey), base);
|
|
252
|
+
url.search = '';
|
|
253
|
+
url.hash = '';
|
|
254
|
+
return url.toString();
|
|
255
|
+
} catch {
|
|
256
|
+
return null;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
async function listDistFiles(dir) {
|
|
261
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
262
|
+
const files = [];
|
|
263
|
+
|
|
264
|
+
for (const entry of entries) {
|
|
265
|
+
const fullPath = resolve(dir, entry.name);
|
|
266
|
+
if (entry.isDirectory()) {
|
|
267
|
+
files.push(...(await listDistFiles(fullPath)));
|
|
268
|
+
} else if (entry.isFile()) {
|
|
269
|
+
files.push(fullPath);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
return files.sort((a, b) => a.localeCompare(b));
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function toPosixPath(pathValue) {
|
|
277
|
+
return pathValue.split('\\').join('/');
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
async function scanDist(files) {
|
|
281
|
+
const textSuffixes = new Set(['.html', '.css', '.js', '.mjs', '.json', '.map', '.txt', '.svg']);
|
|
282
|
+
const absPathPattern = /(?<![A-Za-z0-9_])\/(Users|home|var|tmp|opt|private)\//;
|
|
283
|
+
const blockers = [];
|
|
284
|
+
const warnings = [];
|
|
285
|
+
|
|
286
|
+
for (const filePath of files) {
|
|
287
|
+
if (!textSuffixes.has(extname(filePath).toLowerCase())) {
|
|
288
|
+
continue;
|
|
289
|
+
}
|
|
290
|
+
const text = await readFile(filePath, 'utf8');
|
|
291
|
+
if (text.includes('file://') || absPathPattern.test(text)) {
|
|
292
|
+
blockers.push(filePath);
|
|
293
|
+
}
|
|
294
|
+
if (/(?:src|href)=["']\/assets\//.test(text) || text.includes('url("/assets/') || text.includes("url('/assets/")) {
|
|
295
|
+
warnings.push(filePath);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
return { blockers, warnings };
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const MIME_TYPES = {
|
|
303
|
+
'.html': 'text/html',
|
|
304
|
+
'.css': 'text/css',
|
|
305
|
+
'.js': 'application/javascript',
|
|
306
|
+
'.mjs': 'application/javascript',
|
|
307
|
+
'.json': 'application/json',
|
|
308
|
+
'.svg': 'image/svg+xml',
|
|
309
|
+
'.png': 'image/png',
|
|
310
|
+
'.jpg': 'image/jpeg',
|
|
311
|
+
'.jpeg': 'image/jpeg',
|
|
312
|
+
'.gif': 'image/gif',
|
|
313
|
+
'.webp': 'image/webp',
|
|
314
|
+
'.ico': 'image/x-icon',
|
|
315
|
+
'.woff': 'font/woff',
|
|
316
|
+
'.woff2': 'font/woff2',
|
|
317
|
+
'.ttf': 'font/ttf',
|
|
318
|
+
'.otf': 'font/otf',
|
|
319
|
+
'.map': 'application/json',
|
|
320
|
+
'.txt': 'text/plain',
|
|
321
|
+
'.pdf': 'application/pdf',
|
|
322
|
+
};
|
|
323
|
+
|
|
324
|
+
function guessContentType(filePath) {
|
|
325
|
+
return MIME_TYPES[extname(filePath).toLowerCase()] || 'application/octet-stream';
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function normalizePrefix(prefix) {
|
|
329
|
+
const normalized = String(prefix || '')
|
|
330
|
+
.trim()
|
|
331
|
+
.replace(/^\/+|\/+$/g, '');
|
|
332
|
+
if (!normalized) {
|
|
333
|
+
fail('prefix 不能为空');
|
|
334
|
+
}
|
|
335
|
+
return normalized;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
async function loadManifest(projectRoot) {
|
|
339
|
+
const manifestPath = resolve(projectRoot, 'src', 'manifest.json');
|
|
340
|
+
let manifest;
|
|
341
|
+
try {
|
|
342
|
+
manifest = JSON.parse(await readFile(manifestPath, 'utf8'));
|
|
343
|
+
} catch (error) {
|
|
344
|
+
fail(`无法读取组件 manifest:${manifestPath}\n${error.message}`);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
const missing = ['name', 'description', 'version', 'props'].filter((field) => manifest[field] === undefined);
|
|
348
|
+
if (missing.length > 0) {
|
|
349
|
+
fail(`src/manifest.json 缺少必填字段:${missing.join(', ')}`);
|
|
350
|
+
}
|
|
351
|
+
if (typeof manifest.name !== 'string' || !manifest.name.trim()) {
|
|
352
|
+
fail('src/manifest.json 的 name 必须是非空字符串');
|
|
353
|
+
}
|
|
354
|
+
validatePlatformComponentName(manifest.name.trim());
|
|
355
|
+
if (typeof manifest.description !== 'string' || !manifest.description.trim()) {
|
|
356
|
+
fail('src/manifest.json 的 description 必须是非空字符串');
|
|
357
|
+
}
|
|
358
|
+
if (typeof manifest.version !== 'string' || !manifest.version.trim()) {
|
|
359
|
+
fail('src/manifest.json 的 version 必须是非空字符串');
|
|
360
|
+
}
|
|
361
|
+
if (!Array.isArray(manifest.props) || manifest.props.length === 0) {
|
|
362
|
+
fail('src/manifest.json 的 props 必须是非空数组');
|
|
363
|
+
}
|
|
364
|
+
validateManifestPropsForPlatform(manifest.props);
|
|
365
|
+
|
|
366
|
+
return manifest;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function validatePlatformComponentName(name) {
|
|
370
|
+
const parts = name.split('_');
|
|
371
|
+
if (parts.length < 3) {
|
|
372
|
+
fail(`src/manifest.json 的 name 必须使用 <中文场景或模板>_<中文组件名>_<english_component_code>,例如 会议总结_一句话看懂_meeting_minutes_summary:${name}`);
|
|
373
|
+
}
|
|
374
|
+
const englishCode = parts.at(-1);
|
|
375
|
+
const chineseParts = parts.slice(0, -1);
|
|
376
|
+
if (!englishCode || !/^[a-z0-9]+(?:_[a-z0-9]+)*$/.test(englishCode)) {
|
|
377
|
+
fail(`src/manifest.json 的 name 最后一段 english_component_code 只能包含小写英文、数字和下划线:${name}`);
|
|
378
|
+
}
|
|
379
|
+
if (!chineseParts.every((part) => part.trim())) {
|
|
380
|
+
fail(`src/manifest.json 的 name 中文场景和中文组件名不能为空:${name}`);
|
|
381
|
+
}
|
|
382
|
+
if (!chineseParts.some((part) => /[\u4e00-\u9fff]/.test(part))) {
|
|
383
|
+
fail(`src/manifest.json 的 name 必须包含中文场景或中文组件名,不能只使用纯英文机器名:${name}`);
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
const DISALLOWED_PLATFORM_PROP_NAMES = new Set([
|
|
388
|
+
'className',
|
|
389
|
+
'style',
|
|
390
|
+
'open',
|
|
391
|
+
'collapsed',
|
|
392
|
+
'onOpenChange',
|
|
393
|
+
'onCollapsedChange',
|
|
394
|
+
]);
|
|
395
|
+
|
|
396
|
+
function validateManifestPropsForPlatform(props) {
|
|
397
|
+
const errors = [];
|
|
398
|
+
const missingDefaults = [];
|
|
399
|
+
const disallowed = [];
|
|
400
|
+
|
|
401
|
+
for (const prop of props) {
|
|
402
|
+
if (!prop || typeof prop !== 'object' || Array.isArray(prop)) {
|
|
403
|
+
fail(`src/manifest.json 的 props 每一项都必须是对象:${JSON.stringify(prop)}`);
|
|
404
|
+
}
|
|
405
|
+
const name = typeof prop.name === 'string' ? prop.name.trim() : '';
|
|
406
|
+
if (!name) {
|
|
407
|
+
fail(`src/manifest.json 的 props 每一项都必须有非空 name:${JSON.stringify(prop)}`);
|
|
408
|
+
}
|
|
409
|
+
if (!Object.prototype.hasOwnProperty.call(prop, 'default')) {
|
|
410
|
+
missingDefaults.push(name);
|
|
411
|
+
}
|
|
412
|
+
const type = typeof prop.type === 'string' ? prop.type : '';
|
|
413
|
+
if (DISALLOWED_PLATFORM_PROP_NAMES.has(name) || /^on[A-Z]/.test(name) || /\b=>\b|\bfunction\b/.test(type)) {
|
|
414
|
+
disallowed.push(name);
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
if (missingDefaults.length > 0) {
|
|
419
|
+
errors.push(`src/manifest.json 的 props 缺少 default 字段:${missingDefaults.join(', ')}。请显式提供默认值;数组或对象默认值可写结构化值,上传时会转为 JSON 字符串。`);
|
|
420
|
+
}
|
|
421
|
+
if (disallowed.length > 0) {
|
|
422
|
+
errors.push(`src/manifest.json 的 props 包含不应发布到平台的运行控制/回调字段:${disallowed.join(', ')}。请从 manifest.props 移除,仅保留业务可配置字段。`);
|
|
423
|
+
}
|
|
424
|
+
if (errors.length > 0) {
|
|
425
|
+
fail(errors.join('\n'));
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
function buildCreatePayload(manifest, sourceUrl) {
|
|
430
|
+
const payload = {
|
|
431
|
+
name: manifest.name,
|
|
432
|
+
version: manifest.version,
|
|
433
|
+
description: manifest.description,
|
|
434
|
+
props: normalizeManifestPropsForPlatform(manifest.props),
|
|
435
|
+
source: sourceUrl,
|
|
436
|
+
};
|
|
437
|
+
|
|
438
|
+
for (const field of ['usageScenarios', 'limitations', 'remark']) {
|
|
439
|
+
if (manifest[field] !== undefined) {
|
|
440
|
+
payload[field] = manifest[field];
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
return payload;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
function isJsonObject(value) {
|
|
448
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
export function normalizeManifestPropsForPlatform(props) {
|
|
452
|
+
return normalizeDefaultValuesForPlatform(props, 'props');
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
function normalizeDefaultValuesForPlatform(value, path) {
|
|
456
|
+
if (Array.isArray(value)) {
|
|
457
|
+
return value.map((item, index) => normalizeDefaultValuesForPlatform(item, `${path}[${index}]`));
|
|
458
|
+
}
|
|
459
|
+
if (!isJsonObject(value)) {
|
|
460
|
+
return value;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
const normalized = {};
|
|
464
|
+
for (const [key, childValue] of Object.entries(value)) {
|
|
465
|
+
if (key === 'default' && (Array.isArray(childValue) || isJsonObject(childValue))) {
|
|
466
|
+
normalized[key] = JSON.stringify(childValue);
|
|
467
|
+
} else {
|
|
468
|
+
normalized[key] = normalizeDefaultValuesForPlatform(childValue, `${path}.${key}`);
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
return normalized;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
export function validateManifestPackageCompatibility(manifest, packageInfo) {
|
|
475
|
+
const errors = [];
|
|
476
|
+
if (manifest.version !== packageInfo.version) {
|
|
477
|
+
errors.push(`src/manifest.json 的 version (${manifest.version}) 与 package.json 的 version (${packageInfo.version}) 不一致`);
|
|
478
|
+
}
|
|
479
|
+
if (manifest.packageName !== undefined && manifest.packageName !== packageInfo.name) {
|
|
480
|
+
errors.push(`src/manifest.json 的 packageName (${manifest.packageName}) 与 package.json 的 name (${packageInfo.name}) 不一致`);
|
|
481
|
+
}
|
|
482
|
+
return { errors };
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
function normalizePackagePathPart(value, fieldName) {
|
|
486
|
+
const normalized = String(value || '')
|
|
487
|
+
.trim()
|
|
488
|
+
.replace(/^@/, '')
|
|
489
|
+
.replace(/\//g, '-');
|
|
490
|
+
if (!normalized) {
|
|
491
|
+
fail(`package.json 缺少有效的 ${fieldName} 字段`);
|
|
492
|
+
}
|
|
493
|
+
if (normalized.includes('..') || normalized.startsWith('.') || normalized.endsWith('.')) {
|
|
494
|
+
fail(`package.json 的 ${fieldName} 字段不适合作为 OSS 路径:${value}`);
|
|
495
|
+
}
|
|
496
|
+
return normalized;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
async function loadPackageInfo(projectRoot) {
|
|
500
|
+
const packageJsonPath = resolve(projectRoot, 'package.json');
|
|
501
|
+
let packageJson;
|
|
502
|
+
try {
|
|
503
|
+
packageJson = JSON.parse(await readFile(packageJsonPath, 'utf8'));
|
|
504
|
+
} catch (error) {
|
|
505
|
+
fail(`无法读取项目根目录下的 package.json:${packageJsonPath}\n${error.message}`);
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
const name = normalizePackagePathPart(packageJson.name, 'name');
|
|
509
|
+
const version = normalizePackagePathPart(packageJson.version, 'version');
|
|
510
|
+
return {
|
|
511
|
+
name,
|
|
512
|
+
version,
|
|
513
|
+
prefix: `h5/components/${name}/${version}`,
|
|
514
|
+
};
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
async function hashFiles(files, distDir) {
|
|
518
|
+
const hash = createHash('sha256');
|
|
519
|
+
for (const filePath of files) {
|
|
520
|
+
const rel = toPosixPath(relative(distDir, filePath));
|
|
521
|
+
hash.update(rel);
|
|
522
|
+
hash.update('\0');
|
|
523
|
+
hash.update(await readFile(filePath));
|
|
524
|
+
hash.update('\0');
|
|
525
|
+
}
|
|
526
|
+
return hash.digest('hex');
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
async function pathIsDirectory(pathValue) {
|
|
530
|
+
try {
|
|
531
|
+
return (await stat(pathValue)).isDirectory();
|
|
532
|
+
} catch {
|
|
533
|
+
return false;
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
async function main() {
|
|
538
|
+
const args = parseArgs(process.argv.slice(2));
|
|
539
|
+
const existingToken = process.env.COMPONENT_AUTH_TOKEN;
|
|
540
|
+
const credentials = existingToken ? null : await resolveCredentials(args);
|
|
541
|
+
if (!existingToken) {
|
|
542
|
+
args.phone = credentials.phone;
|
|
543
|
+
args.password = credentials.password;
|
|
544
|
+
}
|
|
545
|
+
const projectRoot = resolve(args.projectRoot);
|
|
546
|
+
const distDir = args.dist ? resolve(args.dist) : resolve(projectRoot, 'dist');
|
|
547
|
+
|
|
548
|
+
if (!(await pathIsDirectory(distDir))) {
|
|
549
|
+
fail(`dist 目录不存在:${distDir}`);
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
const files = await listDistFiles(distDir);
|
|
553
|
+
if (files.length === 0) {
|
|
554
|
+
fail(`dist 目录为空:${distDir}`);
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
const manifest = await loadManifest(projectRoot);
|
|
558
|
+
|
|
559
|
+
if (!args.skipScan) {
|
|
560
|
+
const { blockers, warnings } = await scanDist(files);
|
|
561
|
+
if (blockers.length > 0) {
|
|
562
|
+
fail(`发现 file:// 或本机绝对路径,请先修复:\n${blockers.map((item) => `- ${item}`).join('\n')}`);
|
|
563
|
+
}
|
|
564
|
+
if (warnings.length > 0) {
|
|
565
|
+
console.log('警告:发现 /assets/... 根路径资源,部署到子目录时可能失效:');
|
|
566
|
+
for (const item of warnings) {
|
|
567
|
+
console.log(`- ${item}`);
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
const packageInfo = await loadPackageInfo(projectRoot);
|
|
573
|
+
const compatibility = validateManifestPackageCompatibility(manifest, packageInfo);
|
|
574
|
+
if (compatibility.errors.length > 0) {
|
|
575
|
+
fail(compatibility.errors.join('\n'));
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
const prefix = normalizePrefix(args.prefix || packageInfo.prefix);
|
|
579
|
+
|
|
580
|
+
console.log(`API 域名:${API_BASE}`);
|
|
581
|
+
console.log(`登录账号:${existingToken ? '使用 COMPONENT_AUTH_TOKEN' : credentials.phone}`);
|
|
582
|
+
console.log(`dist 路径:${distDir}`);
|
|
583
|
+
console.log(`manifest:${resolve(projectRoot, 'src', 'manifest.json')}`);
|
|
584
|
+
console.log(`组件名称:${manifest.name}`);
|
|
585
|
+
console.log(`组件版本:${manifest.version}`);
|
|
586
|
+
console.log(`OSS prefix:${prefix}`);
|
|
587
|
+
console.log(`待上传文件数:${files.length}`);
|
|
588
|
+
|
|
589
|
+
const token = existingToken || (await login(credentials.phone, credentials.password));
|
|
590
|
+
if (!existingToken) {
|
|
591
|
+
await saveCredentials(credentials.phone, credentials.password);
|
|
592
|
+
}
|
|
593
|
+
const uploaded = [];
|
|
594
|
+
let totalSize = 0;
|
|
595
|
+
|
|
596
|
+
for (const filePath of files) {
|
|
597
|
+
const rel = toPosixPath(relative(distDir, filePath));
|
|
598
|
+
const contentType = guessContentType(filePath);
|
|
599
|
+
const signed = await presign(token, prefix, rel, contentType, args.expiresInSeconds);
|
|
600
|
+
await uploadForm(signed, filePath, contentType);
|
|
601
|
+
const publicUrl = resolvePublicReadUrl(signed);
|
|
602
|
+
if (!publicUrl) {
|
|
603
|
+
fail(`上传成功,但无法生成不带签名的公网地址:${signed.objectKey}`);
|
|
604
|
+
}
|
|
605
|
+
const size = (await stat(filePath)).size;
|
|
606
|
+
totalSize += size;
|
|
607
|
+
uploaded.push({
|
|
608
|
+
relativePath: rel,
|
|
609
|
+
objectKey: signed.objectKey,
|
|
610
|
+
publicUrl,
|
|
611
|
+
contentType: signed.contentType || contentType,
|
|
612
|
+
size,
|
|
613
|
+
});
|
|
614
|
+
console.log(`已上传:${rel} -> ${signed.objectKey}`);
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
const entry = uploaded.find((item) => item.relativePath === 'index.html') || uploaded[0];
|
|
618
|
+
const result = {
|
|
619
|
+
prefix,
|
|
620
|
+
entryObjectKey: entry.objectKey,
|
|
621
|
+
entryUrl: entry.publicUrl,
|
|
622
|
+
fileCount: uploaded.length,
|
|
623
|
+
totalSizeBytes: totalSize,
|
|
624
|
+
sourceHash: await hashFiles(files, distDir),
|
|
625
|
+
files: uploaded,
|
|
626
|
+
};
|
|
627
|
+
|
|
628
|
+
const createPayload = buildCreatePayload(manifest, entry.publicUrl);
|
|
629
|
+
const componentRecord = await createDeliverableH5Component(token, createPayload);
|
|
630
|
+
result.component = componentRecord;
|
|
631
|
+
|
|
632
|
+
console.log('新增组件成功:');
|
|
633
|
+
console.log(JSON.stringify(componentRecord, null, 2));
|
|
634
|
+
console.log('上传完成:');
|
|
635
|
+
console.log(JSON.stringify(result, null, 2));
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
if (process.argv[1] === fileURLToPath(import.meta.url)) {
|
|
639
|
+
main().catch((error) => fail(error.stack || error.message || String(error)));
|
|
640
|
+
}
|