@playcraft/devkit 1.0.16 → 1.0.17
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 +259 -0
- package/cli/bin/playable-scripts.js +44 -1
- package/cli/commands/build.js +122 -17
- package/cli/commands/builds.js +13 -2
- package/cli/commands/dev.js +7 -1
- package/cli/commands/pack.js +308 -39
- package/cli/commands/vite-dev.js +7 -1
- package/core/batch-build.js +1091 -124
- package/core/cos-uploader.js +264 -20
- package/core/index.js +58 -4
- package/core/loaders/gltf-loader.js +74 -28
- package/core/options.js +310 -15
- package/core/plugins/AdikteevInjectorPlugin.js +26 -4
- package/core/plugins/BigoAdsInjectorPlugin.js +21 -4
- package/core/plugins/DAPIInjectorPlugin.js +25 -2
- package/core/plugins/DebuggerInjectionPlugin.js +31 -3
- package/core/plugins/ExitAPIInjectorPlugin.js +57 -3
- package/core/plugins/FflateCompressionPlugin.js +225 -37
- package/core/plugins/LiftoffInjectorPlugin.js +26 -4
- package/core/plugins/MRAIDInjectorPlugin.js +24 -2
- package/core/plugins/MintegralInjectorPlugin.js +25 -2
- package/core/plugins/PangleInjectorPlugin.js +24 -2
- package/core/plugins/SnapchatInjectorPlugin.js +26 -4
- package/core/plugins/TikTokInjectorPlugin.js +24 -2
- package/core/plugins/UnityInjectorPlugin.js +37 -8
- package/core/plugins/ZipPlugin.js +138 -19
- package/core/utils/buildDefines.js +29 -2
- package/core/utils/buildTemplateString.js +40 -5
- package/core/utils/date.js +16 -1
- package/core/utils/generateAdikteevHtmlWebpackPluginConfig.js +42 -7
- package/core/utils/generateBigabidHtmlWebpackPluginConfig.js +30 -3
- package/core/utils/generateInMobiHtmlWebpackPluginConfig.js +43 -7
- package/core/utils/injectSDKPlugins.js +58 -11
- package/core/utils/logOptions.js +37 -4
- package/core/utils/mergeOptions.js +19 -2
- package/core/utils/parseArgvOptions.js +110 -5
- package/core/utils/resolveChannelFold2Zip.js +15 -3
- package/core/utils/validateThemeData.js +220 -25
- package/core/validators/pre-build-checker.js +201 -21
- package/core/validators/tracking-validator.js +358 -40
- package/core/vite.dev.js +181 -15
- package/core/webpack.build.js +446 -52
- package/core/webpack.common.js +177 -11
- package/core/webpack.dev.js +82 -5
- package/package.json +3 -2
package/core/batch-build.js
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
|
|
2
|
+
|
|
3
|
+
'use strict';
|
|
4
|
+
|
|
5
|
+
/**
|
|
3
6
|
* 批量构建核心模块
|
|
4
7
|
*
|
|
5
8
|
* 核心特性:
|
|
@@ -7,21 +10,113 @@
|
|
|
7
10
|
* 2. 每个构建任务使用独立的临时目录(dist-temp-{taskId})
|
|
8
11
|
* 3. 构建完成后自动移动到目标目录并清理临时目录
|
|
9
12
|
* 4. 支持灵活的配置系统
|
|
10
|
-
*/
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const { execSync, exec } = require('child_process');
|
|
16
|
+
const fs = require('fs');
|
|
17
|
+
const path = require('path');
|
|
18
|
+
const os = require('os');
|
|
19
|
+
const { promisify } = require('util');
|
|
20
|
+
const { fetchTemporaryCredentials, createCOSClient, uploadDirectoryToCOS, uploadFileToCOS, purgeCDNCache } = require('./cos-uploader');
|
|
21
|
+
const { runThemeValidation, restoreSchema } = require('./utils/validateThemeData');
|
|
22
|
+
const { runPreBuildChecks } = require('./validators/pre-build-checker.js');
|
|
23
|
+
|
|
24
|
+
const execAsync = promisify(exec);
|
|
25
|
+
|
|
26
|
+
/**
|
|
11
27
|
* 更新 preview.json 文件
|
|
12
|
-
*/
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
28
|
+
*/
|
|
29
|
+
async function updatePreviewJson(cosConfig, cosPrefix, currentTheme, previewLinks, credentials = null) {
|
|
30
|
+
const previewJsonKey = cosPrefix ? `${cosPrefix}/preview.json` : 'preview.json';
|
|
31
|
+
|
|
32
|
+
// 如果没有传入凭证,尝试获取一次
|
|
33
|
+
if (!credentials) {
|
|
34
|
+
try {
|
|
35
|
+
credentials = await fetchTemporaryCredentials();
|
|
36
|
+
} catch (error) {
|
|
37
|
+
log(`获取临时凭证失败,跳过 preview.json 更新: ${error.message}`, 'warn');
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// 读取现有的 preview.json 文件
|
|
43
|
+
let existingPreviewData = {};
|
|
44
|
+
|
|
45
|
+
try {
|
|
46
|
+
const cos = await createCOSClient(null, credentials);
|
|
47
|
+
const getObjectResult = await new Promise((resolve, reject) => {
|
|
48
|
+
cos.getObject({
|
|
49
|
+
Bucket: credentials.bucket,
|
|
50
|
+
Region: credentials.region,
|
|
51
|
+
Key: previewJsonKey,
|
|
52
|
+
}, (err, data) => {
|
|
53
|
+
if (err) {
|
|
54
|
+
if (err.statusCode === 404) {
|
|
55
|
+
// 文件不存在,使用空对象
|
|
56
|
+
resolve(null);
|
|
57
|
+
} else {
|
|
58
|
+
reject(err);
|
|
59
|
+
}
|
|
60
|
+
} else {
|
|
61
|
+
resolve(data);
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
if (getObjectResult && getObjectResult.Body) {
|
|
67
|
+
existingPreviewData = JSON.parse(getObjectResult.Body.toString());
|
|
68
|
+
}
|
|
69
|
+
} catch (error) {
|
|
70
|
+
// 如果读取失败,使用空对象继续
|
|
71
|
+
log(`读取现有 preview.json 失败,将创建新文件: ${error.message}`, 'debug');
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// 更新预览数据
|
|
75
|
+
const updatedPreviewData = { ...existingPreviewData };
|
|
76
|
+
|
|
77
|
+
// 获取当前时间
|
|
78
|
+
const currentTime = new Date().toISOString();
|
|
79
|
+
|
|
80
|
+
// 更新预览链接(如果指定了 currentTheme 则只更新该主题,否则更新所有)
|
|
81
|
+
const currentThemeLinks = currentTheme ? previewLinks.filter(link => link.theme === currentTheme) : previewLinks;
|
|
82
|
+
currentThemeLinks.forEach(link => {
|
|
83
|
+
const fileName = link.file.replace(/\.[^.]+$/, ''); // 去掉文件扩展名
|
|
84
|
+
updatedPreviewData[fileName] = {
|
|
85
|
+
url: link.url,
|
|
86
|
+
update_time: currentTime
|
|
87
|
+
};
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
// 将更新后的数据写入临时文件
|
|
91
|
+
const tempDir = path.join(os.tmpdir(), 'playable-preview');
|
|
92
|
+
if (!fs.existsSync(tempDir)) {
|
|
93
|
+
fs.mkdirSync(tempDir, { recursive: true });
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const tempPreviewPath = path.join(tempDir, 'preview.json');
|
|
97
|
+
fs.writeFileSync(tempPreviewPath, JSON.stringify(updatedPreviewData, null, 2), 'utf8');
|
|
98
|
+
|
|
99
|
+
// 上传更新后的 preview.json 文件
|
|
100
|
+
await uploadFileToCOS(null, tempPreviewPath, previewJsonKey, credentials);
|
|
101
|
+
|
|
102
|
+
// 刷新 CDN 缓存
|
|
103
|
+
const cdnDomain = 'static.aix.intlgame.com';
|
|
104
|
+
const previewJsonUrl = `https://${cdnDomain}/${previewJsonKey}`;
|
|
105
|
+
try {
|
|
106
|
+
await purgeCDNCache(null, [previewJsonUrl], credentials);
|
|
107
|
+
log(`✓ CDN 缓存已刷新: ${previewJsonUrl}`, 'success');
|
|
108
|
+
} catch (error) {
|
|
109
|
+
log(`⚠️ CDN 缓存刷新失败 (preview.json): ${error.message}`, 'warn');
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// 清理临时文件
|
|
113
|
+
fs.unlinkSync(tempPreviewPath);
|
|
114
|
+
|
|
115
|
+
log(`✓ preview.json 文件已更新并上传`, 'success');
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// ==================== 默认配置 ====================
|
|
119
|
+
|
|
25
120
|
/**
|
|
26
121
|
* 计算默认并发数
|
|
27
122
|
* 策略: CPU 核心数 - 1,最小为 2,最大为 8
|
|
@@ -29,77 +124,552 @@ fs.unlinkSync(tempPreviewPath);log(`✓ preview.json 文件已更新并上传`,"
|
|
|
29
124
|
* - 4 核 CPU → 3 并发
|
|
30
125
|
* - 8 核 CPU → 7 并发
|
|
31
126
|
* - 16 核 CPU → 8 并发(限制最大值)
|
|
32
|
-
*/
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
127
|
+
*/
|
|
128
|
+
function getDefaultParallel() {
|
|
129
|
+
const cpuCount = os.cpus().length;
|
|
130
|
+
const calculated = Math.max(2, cpuCount - 1); // 至少 2 个并发
|
|
131
|
+
const parallel = Math.min(8, calculated); // 最多 8 个并发
|
|
132
|
+
return parallel;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const DEFAULT_CONFIG = {
|
|
136
|
+
title: 'Phaser Game',
|
|
137
|
+
appName: '', // 用于从远程仓库拉取配置
|
|
138
|
+
|
|
139
|
+
channels: [
|
|
140
|
+
'google',
|
|
141
|
+
'facebook',
|
|
142
|
+
'tiktok',
|
|
143
|
+
'applovin',
|
|
144
|
+
'ironsource',
|
|
145
|
+
'unity',
|
|
146
|
+
'moloco',
|
|
147
|
+
'liftoff',
|
|
148
|
+
'bigoads'
|
|
149
|
+
],
|
|
150
|
+
|
|
151
|
+
themes: {
|
|
152
|
+
enabled: true,
|
|
153
|
+
sourceDir: 'src/theme',
|
|
154
|
+
entryFile: 'src/theme/index.ts',
|
|
155
|
+
whitelist: null,
|
|
156
|
+
},
|
|
157
|
+
|
|
158
|
+
naming: {
|
|
159
|
+
language: 'EN',
|
|
160
|
+
format: '无',
|
|
161
|
+
supplier: 'AIX',
|
|
162
|
+
follower: 'ZQL',
|
|
163
|
+
},
|
|
164
|
+
|
|
165
|
+
build: {
|
|
166
|
+
outputDir: 'dist-all',
|
|
167
|
+
tempDirPrefix: 'dist-temp',
|
|
168
|
+
continueOnError: true,
|
|
169
|
+
cleanTemp: true,
|
|
170
|
+
parallel: getDefaultParallel(), // 根据 CPU 核心数自动设置
|
|
171
|
+
},
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
// ==================== 工具函数 ====================
|
|
175
|
+
|
|
176
|
+
let isExiting = false;
|
|
177
|
+
let backupContent = null;
|
|
178
|
+
let backupPath = null;
|
|
179
|
+
let activeSchemaPath = null; // 跟踪当前活动的 schema 路径,用于中断时恢复
|
|
180
|
+
const activeTempDirs = new Set(); // 跟踪所有活动的临时目录
|
|
181
|
+
|
|
182
|
+
function log(message, level = 'info') {
|
|
183
|
+
const timestamp = new Date().toLocaleTimeString();
|
|
184
|
+
const colors = {
|
|
185
|
+
info: '\x1b[36m',
|
|
186
|
+
success: '\x1b[32m',
|
|
187
|
+
error: '\x1b[31m',
|
|
188
|
+
warn: '\x1b[33m',
|
|
189
|
+
debug: '\x1b[90m'
|
|
190
|
+
};
|
|
191
|
+
const reset = '\x1b[0m';
|
|
192
|
+
const prefix = {
|
|
193
|
+
info: '📦',
|
|
194
|
+
success: '✅',
|
|
195
|
+
error: '❌',
|
|
196
|
+
warn: '⚠️',
|
|
197
|
+
debug: '🔍'
|
|
198
|
+
}[level] || 'ℹ️';
|
|
199
|
+
|
|
200
|
+
console.log(`${colors[level]}[${timestamp}] ${prefix} ${message}${reset}`);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function getCurrentMonth() {
|
|
204
|
+
const month = new Date().getMonth() + 1;
|
|
205
|
+
return `${month}月交付`;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function getCurrentDate() {
|
|
209
|
+
const date = new Date();
|
|
210
|
+
const year = date.getFullYear();
|
|
211
|
+
const month = String(date.getMonth() + 1).padStart(2, '0');
|
|
212
|
+
const day = String(date.getDate()).padStart(2, '0');
|
|
213
|
+
return `${year}${month}${day}`;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function defaultFileNameGenerator(config, themeName, channel) {
|
|
217
|
+
const parts = [
|
|
218
|
+
themeName,
|
|
219
|
+
config.naming.language,
|
|
220
|
+
config.naming.format,
|
|
221
|
+
getCurrentMonth(),
|
|
222
|
+
getCurrentDate(),
|
|
223
|
+
config.naming.supplier,
|
|
224
|
+
config.naming.follower,
|
|
225
|
+
channel.toLowerCase()
|
|
226
|
+
].filter(item => !!item);
|
|
227
|
+
|
|
228
|
+
return parts.join('-');
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function getAllThemes(config, projectRoot) {
|
|
232
|
+
const themesDir = path.join(projectRoot, config.themes.sourceDir);
|
|
233
|
+
|
|
234
|
+
if (!fs.existsSync(themesDir)) {
|
|
235
|
+
log(`主题目录不存在: ${themesDir}`, 'warn');
|
|
236
|
+
return [];
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const entries = fs.readdirSync(themesDir, { withFileTypes: true });
|
|
240
|
+
let themes = entries
|
|
241
|
+
.filter(entry => entry.isDirectory())
|
|
242
|
+
.map(entry => entry.name)
|
|
243
|
+
.sort();
|
|
244
|
+
|
|
245
|
+
if (config.themes.whitelist && typeof config.themes.whitelist === 'function') {
|
|
246
|
+
themes = themes.filter(config.themes.whitelist);
|
|
247
|
+
log(`应用主题白名单过滤: ${themes.length} 个主题`, 'debug');
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
return themes;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function backupEntryFile(config, projectRoot) {
|
|
254
|
+
const entryPath = path.join(projectRoot, config.themes.entryFile);
|
|
255
|
+
|
|
256
|
+
if (!fs.existsSync(entryPath)) {
|
|
257
|
+
throw new Error(`入口文件不存在: ${entryPath}`);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
backupPath = entryPath;
|
|
261
|
+
backupContent = fs.readFileSync(entryPath, 'utf8');
|
|
262
|
+
log(`已备份入口文件: ${config.themes.entryFile}`, 'debug');
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function restoreEntryFile() {
|
|
266
|
+
if (backupPath && backupContent) {
|
|
267
|
+
fs.writeFileSync(backupPath, backupContent, 'utf8');
|
|
268
|
+
log(`已恢复入口文件`, 'debug');
|
|
269
|
+
backupPath = null;
|
|
270
|
+
backupContent = null;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function switchTheme(config, themeName, projectRoot) {
|
|
275
|
+
const entryPath = path.join(projectRoot, config.themes.entryFile);
|
|
276
|
+
|
|
277
|
+
// 使用配置文件中的 generateEntryFile 函数(如果提供)
|
|
278
|
+
let content;
|
|
279
|
+
if (config.themes.switching && typeof config.themes.switching.generateEntryFile === 'function') {
|
|
280
|
+
content = config.themes.switching.generateEntryFile(themeName);
|
|
281
|
+
} else {
|
|
282
|
+
// 兼容旧格式:简化的 export 语句
|
|
283
|
+
const themePath = `./${themeName}`;
|
|
284
|
+
content = `export { default } from '${themePath}';\n`;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
fs.writeFileSync(entryPath, content, 'utf8');
|
|
288
|
+
|
|
289
|
+
log(`已切换主题: ${themeName}`, 'debug');
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
42
293
|
* 生成唯一的任务 ID
|
|
43
|
-
*/
|
|
294
|
+
*/
|
|
295
|
+
function generateTaskId() {
|
|
296
|
+
return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
44
300
|
* 创建独立的临时目录
|
|
45
|
-
*/
|
|
301
|
+
*/
|
|
302
|
+
function createTempDir(config, projectRoot, taskId) {
|
|
303
|
+
const tempDir = path.join(projectRoot, `${config.build.tempDirPrefix}-${taskId}`);
|
|
304
|
+
|
|
305
|
+
if (fs.existsSync(tempDir)) {
|
|
306
|
+
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
fs.mkdirSync(tempDir, { recursive: true });
|
|
310
|
+
activeTempDirs.add(tempDir);
|
|
311
|
+
|
|
312
|
+
return tempDir;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
46
316
|
* 清理临时目录
|
|
47
|
-
*/
|
|
317
|
+
*/
|
|
318
|
+
function cleanTempDir(tempDir) {
|
|
319
|
+
if (fs.existsSync(tempDir)) {
|
|
320
|
+
try {
|
|
321
|
+
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
322
|
+
activeTempDirs.delete(tempDir);
|
|
323
|
+
log(`已清理目录: ${path.basename(tempDir)}`, 'debug');
|
|
324
|
+
} catch (error) {
|
|
325
|
+
log(`清理临时目录失败: ${error.message}`, 'warn');
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
48
331
|
* 清理所有活动的临时目录
|
|
49
|
-
*/
|
|
332
|
+
*/
|
|
333
|
+
function cleanAllTempDirs() {
|
|
334
|
+
for (const tempDir of activeTempDirs) {
|
|
335
|
+
cleanTempDir(tempDir);
|
|
336
|
+
}
|
|
337
|
+
activeTempDirs.clear();
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
50
341
|
* 递归复制目录
|
|
51
|
-
*/
|
|
342
|
+
*/
|
|
343
|
+
function copyDirRecursive(src, dest) {
|
|
344
|
+
if (!fs.existsSync(dest)) {
|
|
345
|
+
fs.mkdirSync(dest, { recursive: true });
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
const entries = fs.readdirSync(src, { withFileTypes: true });
|
|
349
|
+
|
|
350
|
+
for (const entry of entries) {
|
|
351
|
+
const srcPath = path.join(src, entry.name);
|
|
352
|
+
const destPath = path.join(dest, entry.name);
|
|
353
|
+
|
|
354
|
+
if (entry.isDirectory()) {
|
|
355
|
+
copyDirRecursive(srcPath, destPath);
|
|
356
|
+
} else {
|
|
357
|
+
fs.copyFileSync(srcPath, destPath);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/**
|
|
52
363
|
* 构建单个渠道(使用独立临时目录)
|
|
53
|
-
*/
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
364
|
+
*/
|
|
365
|
+
async function buildChannel(config, themeName, channel, outputDir, projectRoot, isChannelFold2Zip) {
|
|
366
|
+
const taskId = generateTaskId();
|
|
367
|
+
const tempDir = createTempDir(config, projectRoot, taskId);
|
|
368
|
+
|
|
369
|
+
log(`开始构建: 主题=${themeName}, 渠道=${channel}, 任务ID=${taskId.substr(0, 8)}...`);
|
|
370
|
+
|
|
371
|
+
try {
|
|
372
|
+
// 生成文件名
|
|
373
|
+
const fileName = config.naming.generator
|
|
374
|
+
? config.naming.generator(themeName, channel, getCurrentDate(), getCurrentMonth())
|
|
375
|
+
: defaultFileNameGenerator(config, themeName, channel);
|
|
376
|
+
|
|
377
|
+
log(`文件名: ${fileName}`, 'debug');
|
|
378
|
+
|
|
379
|
+
// 执行构建,指定独立的输出目录(使用绝对路径)
|
|
380
|
+
// 注意:
|
|
381
|
+
// 1. 命令行参数使用 --out-dir (连字符格式)
|
|
382
|
+
// 2. 通过环境变量 PLAYABLE_FILENAME 传递文件名,避免并发时修改 build.json 导致竞态条件
|
|
383
|
+
// 3. 使用 execAsync 实现真正的并发构建
|
|
384
|
+
// 4. 通过命令行参数传递商店链接(如果配置中有定义)
|
|
385
|
+
|
|
386
|
+
// 构建命令行参数
|
|
387
|
+
const buildArgs = [channel, `--out-dir "${tempDir}"`];
|
|
388
|
+
|
|
389
|
+
// 如果配置中有商店链接,通过命令行参数传递
|
|
390
|
+
if (config.googlePlayUrl) {
|
|
391
|
+
buildArgs.push(`--google-play-url "${config.googlePlayUrl}"`);
|
|
392
|
+
}
|
|
393
|
+
if (config.appStoreUrl) {
|
|
394
|
+
buildArgs.push(`--app-store-url "${config.appStoreUrl}"`);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// 如果启用了 isChannelFold2Zip,透传参数给子进程
|
|
398
|
+
if (isChannelFold2Zip) {
|
|
399
|
+
buildArgs.push('--is-channel-fold2zip');
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
await execAsync(`npm run build -- ${buildArgs.join(' ')}`, {
|
|
403
|
+
cwd: projectRoot,
|
|
404
|
+
env: {
|
|
405
|
+
...process.env,
|
|
406
|
+
PLAYABLE_BUILD_TASK_ID: taskId,
|
|
407
|
+
PLAYABLE_FILENAME: fileName, // 通过环境变量传递文件名
|
|
408
|
+
PLAYABLE_BATCH_MODE: '1' // 标识批量构建模式,子进程跳过 runThemeValidation(主进程已处理)
|
|
409
|
+
},
|
|
410
|
+
// 不使用 stdio: 'inherit' 以支持并发,而是捕获输出
|
|
411
|
+
maxBuffer: 10 * 1024 * 1024 // 10MB buffer
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
// 复制 tempDir 所有文件到目标目录
|
|
415
|
+
function copyTempFiles(outputDir, themeName, channel) {
|
|
416
|
+
const targetDir = path.join(outputDir, themeName, channel);
|
|
417
|
+
if (!fs.existsSync(targetDir)) {
|
|
418
|
+
fs.mkdirSync(targetDir, { recursive: true });
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
const files = fs.readdirSync(tempDir);
|
|
422
|
+
files.forEach(file => {
|
|
423
|
+
const srcPath = path.join(tempDir, file);
|
|
424
|
+
const destPath = path.join(targetDir, file);
|
|
425
|
+
|
|
426
|
+
if (fs.statSync(srcPath).isDirectory()) {
|
|
427
|
+
copyDirRecursive(srcPath, destPath);
|
|
428
|
+
} else {
|
|
429
|
+
fs.copyFileSync(srcPath, destPath);
|
|
430
|
+
}
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
log(`✓ 构建成功: ${themeName}/${channel}`, 'success');
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
// 移动构建产物到目标目录
|
|
437
|
+
if (fs.existsSync(tempDir)) {
|
|
438
|
+
if (isChannelFold2Zip && channel !== 'preview') {
|
|
439
|
+
// isChannelFold2Zip 模式:只移动 渠道名.zip 到 outputDir/themeName/渠道名.zip
|
|
440
|
+
const zipFileName = `${channel}.zip`;
|
|
441
|
+
const srcZipPath = path.join(tempDir, zipFileName);
|
|
442
|
+
const themeDir = path.join(outputDir, themeName);
|
|
443
|
+
const destZipPath = path.join(themeDir, zipFileName);
|
|
444
|
+
|
|
445
|
+
if (fs.existsSync(srcZipPath)) {
|
|
446
|
+
if (!fs.existsSync(themeDir)) {
|
|
447
|
+
fs.mkdirSync(themeDir, { recursive: true });
|
|
448
|
+
}
|
|
449
|
+
fs.copyFileSync(srcZipPath, destZipPath);
|
|
450
|
+
log(`✓ 构建成功: ${themeName}/${channel} → ${themeName}/${zipFileName}`, 'success');
|
|
451
|
+
} else {
|
|
452
|
+
log(`⚠️ 未找到 zip 文件: ${srcZipPath},回退到普通文件移动`, 'warn');
|
|
453
|
+
// 回退:移动所有文件到 outputDir/themeName/channel/
|
|
454
|
+
copyTempFiles(outputDir, themeName, channel);
|
|
455
|
+
}
|
|
456
|
+
} else {
|
|
457
|
+
// 普通模式:移动所有文件到 outputDir/themeName/channel/
|
|
458
|
+
copyTempFiles(outputDir, themeName, channel);
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// 清理临时目录
|
|
463
|
+
if (config.build.cleanTemp) {
|
|
464
|
+
cleanTempDir(tempDir);
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
return { success: true, theme: themeName, channel };
|
|
468
|
+
} catch (error) {
|
|
469
|
+
log(`构建失败: ${themeName}/${channel} - ${error.message}`, 'error');
|
|
470
|
+
|
|
471
|
+
// 清理临时目录
|
|
472
|
+
if (config.build.cleanTemp) {
|
|
473
|
+
cleanTempDir(tempDir);
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
if (!config.build.continueOnError) {
|
|
477
|
+
throw error;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
return { success: false, theme: themeName, channel, error: error.message };
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/**
|
|
75
485
|
* 并发构建多个渠道
|
|
76
|
-
*/
|
|
77
|
-
|
|
486
|
+
*/
|
|
487
|
+
async function buildChannelsInParallel(config, themeName, channels, outputDir, projectRoot, isChannelFold2Zip) {
|
|
488
|
+
const maxParallel = config.build.parallel || 3;
|
|
489
|
+
const results = [];
|
|
490
|
+
|
|
491
|
+
// 分批并发构建
|
|
492
|
+
for (let i = 0; i < channels.length; i += maxParallel) {
|
|
493
|
+
const batch = channels.slice(i, i + maxParallel);
|
|
494
|
+
const batchPromises = batch.map(channel =>
|
|
495
|
+
buildChannel(config, themeName, channel, outputDir, projectRoot, isChannelFold2Zip)
|
|
496
|
+
);
|
|
497
|
+
|
|
498
|
+
const batchResults = await Promise.allSettled(batchPromises);
|
|
499
|
+
|
|
500
|
+
batchResults.forEach((result, index) => {
|
|
501
|
+
if (result.status === 'fulfilled') {
|
|
502
|
+
results.push(result.value);
|
|
503
|
+
} else {
|
|
504
|
+
results.push({
|
|
505
|
+
success: false,
|
|
506
|
+
theme: themeName,
|
|
507
|
+
channel: batch[index],
|
|
508
|
+
error: result.reason?.message || '未知错误'
|
|
509
|
+
});
|
|
510
|
+
}
|
|
511
|
+
});
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
return results;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
/**
|
|
78
518
|
* 解析命令行参数
|
|
79
|
-
*/
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
519
|
+
*/
|
|
520
|
+
function parseArgs() {
|
|
521
|
+
const args = process.argv.slice(2);
|
|
522
|
+
|
|
523
|
+
const result = {
|
|
524
|
+
themes: null,
|
|
525
|
+
channels: null,
|
|
526
|
+
parallel: null,
|
|
527
|
+
isChannelFold2Zip: false
|
|
528
|
+
};
|
|
529
|
+
|
|
530
|
+
for (let i = 0; i < args.length; i++) {
|
|
531
|
+
const currentArg = args[i];
|
|
532
|
+
const nextArg = args[i + 1];
|
|
533
|
+
|
|
534
|
+
// 支持 -t 和 --themes 格式
|
|
535
|
+
if (currentArg === '-t' || currentArg === '--themes') {
|
|
536
|
+
if (nextArg && !nextArg.startsWith('-')) {
|
|
537
|
+
result.themes = nextArg.split(',').map(t => t.trim()).filter(Boolean);
|
|
538
|
+
i++;
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
// 支持 -c 和 --channels 格式
|
|
542
|
+
else if (currentArg === '-c' || currentArg === '--channels') {
|
|
543
|
+
if (nextArg && !nextArg.startsWith('-')) {
|
|
544
|
+
result.channels = nextArg.split(',').map(c => c.trim()).filter(Boolean);
|
|
545
|
+
i++;
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
// 支持 -p 和 --parallel 格式
|
|
549
|
+
else if (currentArg === '-p' || currentArg === '--parallel') {
|
|
550
|
+
if (nextArg && !nextArg.startsWith('-')) {
|
|
551
|
+
result.parallel = parseInt(nextArg, 10);
|
|
552
|
+
i++;
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
// 支持 --themes=value 格式(Windows PowerShell 兼容)
|
|
556
|
+
else if (currentArg.startsWith('--themes=')) {
|
|
557
|
+
result.themes = currentArg.substring(9).split(',').map(t => t.trim()).filter(Boolean);
|
|
558
|
+
}
|
|
559
|
+
// 支持 --channels=value 格式(Windows PowerShell 兼容)
|
|
560
|
+
else if (currentArg.startsWith('--channels=')) {
|
|
561
|
+
result.channels = currentArg.substring(11).split(',').map(c => c.trim()).filter(Boolean);
|
|
562
|
+
}
|
|
563
|
+
// 支持 --parallel=value 格式(Windows PowerShell 兼容)
|
|
564
|
+
else if (currentArg.startsWith('--parallel=')) {
|
|
565
|
+
result.parallel = parseInt(currentArg.substring(11), 10);
|
|
566
|
+
}
|
|
567
|
+
// 支持 --is-channel-fold2zip 参数
|
|
568
|
+
else if (currentArg === '--is-channel-fold2zip') {
|
|
569
|
+
result.isChannelFold2Zip = true;
|
|
570
|
+
}
|
|
571
|
+
// 兼容位置参数:第一个参数作为主题,第二个作为渠道
|
|
572
|
+
else if (!currentArg.startsWith('-') && result.themes === null) {
|
|
573
|
+
result.themes = [currentArg];
|
|
574
|
+
}
|
|
575
|
+
else if (!currentArg.startsWith('-') && result.themes !== null && result.channels === null) {
|
|
576
|
+
result.channels = [currentArg];
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
return result;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
// 远程配置仓库地址
|
|
584
|
+
const REMOTE_CONFIG_REPO = 'https://git.woa.com/maclerylin/playable-build-config.git';
|
|
585
|
+
|
|
586
|
+
/**
|
|
89
587
|
* 执行命令并返回结果
|
|
90
588
|
* @param {string} command - 要执行的命令
|
|
91
589
|
* @param {object} options - execSync 选项
|
|
92
590
|
* @returns {string|null} 命令输出,失败返回 null
|
|
93
|
-
*/
|
|
591
|
+
*/
|
|
592
|
+
function execCommand(command, options = {}) {
|
|
593
|
+
const { execSync } = require('child_process');
|
|
594
|
+
try {
|
|
595
|
+
return execSync(command, {
|
|
596
|
+
encoding: 'utf-8',
|
|
597
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
598
|
+
...options
|
|
599
|
+
}).trim();
|
|
600
|
+
} catch (error) {
|
|
601
|
+
return null;
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
/**
|
|
94
606
|
* 从远程仓库获取配置文件内容
|
|
95
607
|
* 使用 git clone 方式获取,读取后删除临时目录
|
|
96
608
|
*
|
|
97
609
|
* @param {string} appName - 应用名称,对应远程仓库中的文件夹名
|
|
98
610
|
* @returns {Promise<object|null>} 远程配置对象,失败返回 null
|
|
99
|
-
*/
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
611
|
+
*/
|
|
612
|
+
async function fetchRemoteConfig(appName) {
|
|
613
|
+
if (!appName) return null;
|
|
614
|
+
|
|
615
|
+
const https = require('https');
|
|
616
|
+
const http = require('http');
|
|
617
|
+
const url = require('url');
|
|
618
|
+
|
|
619
|
+
// 构建远程配置文件 URL
|
|
620
|
+
const configUrl = `https://wd-act.woa.com/aix/playable-build-config/${encodeURIComponent(appName)}/build.config.js`;
|
|
621
|
+
|
|
622
|
+
log(`尝试从远程 URL 获取配置: ${appName}`, 'info');
|
|
623
|
+
log(`URL: ${configUrl}`, 'debug');
|
|
624
|
+
|
|
625
|
+
try {
|
|
626
|
+
// 发起 HTTP 请求获取配置文件
|
|
627
|
+
const configContent = await new Promise((resolve, reject) => {
|
|
628
|
+
const parsedUrl = url.parse(configUrl);
|
|
629
|
+
const protocol = parsedUrl.protocol === 'https:' ? https : http;
|
|
630
|
+
|
|
631
|
+
const request = protocol.get(configUrl, (response) => {
|
|
632
|
+
if (response.statusCode !== 200) {
|
|
633
|
+
reject(new Error(`HTTP ${response.statusCode}: ${response.statusMessage}`));
|
|
634
|
+
return;
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
let data = '';
|
|
638
|
+
response.on('data', chunk => data += chunk);
|
|
639
|
+
response.on('end', () => resolve(data));
|
|
640
|
+
});
|
|
641
|
+
|
|
642
|
+
request.on('error', reject);
|
|
643
|
+
request.setTimeout(30000, () => {
|
|
644
|
+
request.destroy();
|
|
645
|
+
reject(new Error('Request timeout'));
|
|
646
|
+
});
|
|
647
|
+
});
|
|
648
|
+
|
|
649
|
+
log('✓ 远程配置文件下载成功', 'success');
|
|
650
|
+
|
|
651
|
+
// 使用 vm 模块执行配置文件,避免 require 缓存问题
|
|
652
|
+
const vm = require('vm');
|
|
653
|
+
const sandbox = { module: { exports: {} }, exports: {}, require: require };
|
|
654
|
+
sandbox.exports = sandbox.module.exports;
|
|
655
|
+
|
|
656
|
+
try {
|
|
657
|
+
vm.runInNewContext(configContent, sandbox);
|
|
658
|
+
log(`✓ 成功加载远程配置: ${appName}`, 'success');
|
|
659
|
+
return sandbox.module.exports;
|
|
660
|
+
} catch (parseError) {
|
|
661
|
+
log(`解析远程配置文件失败: ${parseError.message}`, 'error');
|
|
662
|
+
log(`配置内容预览: ${configContent.substring(0, 200)}...`, 'debug');
|
|
663
|
+
return null;
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
} catch (error) {
|
|
667
|
+
log(`获取远程配置失败: ${error.message}`, 'warn');
|
|
668
|
+
return null;
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
/**
|
|
103
673
|
* 加载配置文件
|
|
104
674
|
*
|
|
105
675
|
* 优先级: 本地 builds.config.js > 远程配置 > build.json > 默认值
|
|
@@ -121,60 +691,457 @@ const vm=require("vm");const sandbox={module:{exports:{}},exports:{},require:req
|
|
|
121
691
|
* 忽略字段:
|
|
122
692
|
* - filename: 由批量构建自动生成
|
|
123
693
|
* - version: 不在批量构建中使用
|
|
124
|
-
*/
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
const
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
if
|
|
694
|
+
*/
|
|
695
|
+
async function loadConfig(projectRoot) {
|
|
696
|
+
const buildsConfigPath = path.join(projectRoot, 'builds.config.js');
|
|
697
|
+
const buildJsonPath = path.join(projectRoot, 'build.json');
|
|
698
|
+
|
|
699
|
+
let config = { ...DEFAULT_CONFIG };
|
|
700
|
+
let localConfig = null;
|
|
701
|
+
|
|
702
|
+
// 1. 先尝试加载本地 builds.config.js
|
|
703
|
+
if (fs.existsSync(buildsConfigPath)) {
|
|
704
|
+
log('加载配置文件: builds.config.js', 'debug');
|
|
705
|
+
// 清除 require 缓存,确保读取最新配置
|
|
706
|
+
delete require.cache[buildsConfigPath];
|
|
707
|
+
localConfig = require(buildsConfigPath);
|
|
708
|
+
} else {
|
|
709
|
+
log('未找到 builds.config.js,使用默认配置', 'debug');
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
// 2. 如果配置了 appName,从远程仓库拉取配置并合并
|
|
713
|
+
const appName = localConfig?.appName;
|
|
714
|
+
if (appName) {
|
|
715
|
+
const remoteConfig = await fetchRemoteConfig(appName);
|
|
716
|
+
if (remoteConfig) {
|
|
717
|
+
// 远程配置先合并到默认配置
|
|
718
|
+
config = mergeConfig(config, remoteConfig);
|
|
719
|
+
log(`已合并远程配置: ${appName}`, 'info');
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
// 3. 本地配置具有最高优先级,覆盖远程配置
|
|
724
|
+
if (localConfig) {
|
|
725
|
+
config = mergeConfig(config, localConfig);
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
// 5. 尝试加载 build.json 作为补充(向后兼容)
|
|
729
|
+
if (fs.existsSync(buildJsonPath)) {
|
|
730
|
+
try {
|
|
731
|
+
const buildJson = JSON.parse(fs.readFileSync(buildJsonPath, 'utf8'));
|
|
732
|
+
log('检测到 build.json,读取兼容配置', 'debug');
|
|
733
|
+
|
|
734
|
+
// 合并 app + name 为 title(仅当 builds.config.js 未指定时)
|
|
735
|
+
if (!config.title || config.title === DEFAULT_CONFIG.title) {
|
|
736
|
+
if (buildJson.app && buildJson.name) {
|
|
737
|
+
config.title = `${buildJson.app} ${buildJson.name}`;
|
|
738
|
+
log(` ├─ title: "${config.title}" (来自 build.json)`, 'debug');
|
|
739
|
+
} else if (buildJson.app) {
|
|
740
|
+
config.title = buildJson.app;
|
|
741
|
+
log(` ├─ title: "${config.title}" (来自 build.json.app)`, 'debug');
|
|
742
|
+
} else if (buildJson.name) {
|
|
743
|
+
config.title = buildJson.name;
|
|
744
|
+
log(` ├─ title: "${config.title}" (来自 build.json.name)`, 'debug');
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
// 读取商店链接(如果 builds.config.js 未指定)
|
|
749
|
+
if (buildJson.google_play_url && !config.googlePlayUrl) {
|
|
750
|
+
config.googlePlayUrl = buildJson.google_play_url;
|
|
751
|
+
log(` ├─ googlePlayUrl: "${config.googlePlayUrl}" (来自 build.json)`, 'debug');
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
if (buildJson.app_store_url && !config.appStoreUrl) {
|
|
755
|
+
config.appStoreUrl = buildJson.app_store_url;
|
|
756
|
+
log(` └─ appStoreUrl: "${config.appStoreUrl}" (来自 build.json)`, 'debug');
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
// 忽略的字段
|
|
760
|
+
if (buildJson.filename) {
|
|
761
|
+
log(' ⚠️ build.json.filename 将被忽略(由批量构建自动生成)', 'debug');
|
|
762
|
+
}
|
|
763
|
+
if (buildJson.version) {
|
|
764
|
+
log(' ⚠️ build.json.version 将被忽略(不在批量构建中使用)', 'debug');
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
} catch (error) {
|
|
768
|
+
log(`⚠️ 解析 build.json 失败: ${error.message}`, 'warn');
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
console.log('config', config);
|
|
773
|
+
|
|
774
|
+
return config;
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
/**
|
|
134
778
|
* 深度合并配置
|
|
135
|
-
*/
|
|
779
|
+
*/
|
|
780
|
+
function mergeConfig(defaultConfig, userConfig) {
|
|
781
|
+
const result = { ...defaultConfig };
|
|
782
|
+
|
|
783
|
+
for (const key in userConfig) {
|
|
784
|
+
if (typeof userConfig[key] === 'object' && !Array.isArray(userConfig[key])) {
|
|
785
|
+
result[key] = { ...defaultConfig[key], ...userConfig[key] };
|
|
786
|
+
} else {
|
|
787
|
+
result[key] = userConfig[key];
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
return result;
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
/**
|
|
136
795
|
* 打印配置摘要
|
|
137
|
-
*/
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
const
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
const
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
796
|
+
*/
|
|
797
|
+
function printSummary(config, themes, channels) {
|
|
798
|
+
const totalBuilds = themes.length * channels.length;
|
|
799
|
+
const cpuCount = os.cpus().length;
|
|
800
|
+
const cpuModel = os.cpus()[0]?.model || 'Unknown';
|
|
801
|
+
|
|
802
|
+
log('========================================');
|
|
803
|
+
log('开始批量构建任务');
|
|
804
|
+
log(`项目: ${config.title}`);
|
|
805
|
+
log(`主题数: ${themes.length}`);
|
|
806
|
+
log(`渠道数: ${channels.length}`);
|
|
807
|
+
log(`总构建数: ${totalBuilds}`);
|
|
808
|
+
log(`CPU: ${cpuCount} 核心 (${cpuModel.substring(0, 40)}${cpuModel.length > 40 ? '...' : ''})`);
|
|
809
|
+
log(`并发数: ${config.build.parallel} (自动优化)`);
|
|
810
|
+
log('========================================\n');
|
|
811
|
+
|
|
812
|
+
if (themes.length <= 10) {
|
|
813
|
+
log(`主题列表: ${themes.join(', ')}`, 'debug');
|
|
814
|
+
}
|
|
815
|
+
log(`渠道列表: ${channels.join(', ')}`, 'debug');
|
|
816
|
+
log('');
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
// ==================== 主函数 ====================
|
|
820
|
+
|
|
821
|
+
async function buildBatch() {
|
|
822
|
+
const projectRoot = process.cwd();
|
|
823
|
+
const startTime = Date.now();
|
|
824
|
+
|
|
825
|
+
try {
|
|
826
|
+
// ============================================================
|
|
827
|
+
// 📌 编译前检查(埋点验证等)- 仅在主进程执行一次
|
|
828
|
+
// ============================================================
|
|
829
|
+
const shouldSkipChecks = process.env.SKIP_PRE_BUILD_CHECKS === '1';
|
|
830
|
+
|
|
831
|
+
if (!shouldSkipChecks) {
|
|
832
|
+
log('执行编译前检查(埋点验证等)...', 'info');
|
|
833
|
+
const checkResult = await runPreBuildChecks(projectRoot, {
|
|
834
|
+
verbose: false,
|
|
835
|
+
showDetails: true,
|
|
836
|
+
failFast: false
|
|
837
|
+
});
|
|
838
|
+
|
|
839
|
+
if (!checkResult.success) {
|
|
840
|
+
log('编译前检查失败,终止批量构建。', 'error');
|
|
841
|
+
log('提示:如需跳过检查,可设置环境变量: SKIP_PRE_BUILD_CHECKS=1', 'warn');
|
|
842
|
+
process.exit(1);
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
log('✓ 编译前检查通过', 'success');
|
|
846
|
+
} else {
|
|
847
|
+
log('跳过编译前检查(SKIP_PRE_BUILD_CHECKS=1)', 'warn');
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
// 加载配置(包含远程配置合并)
|
|
851
|
+
const config = await loadConfig(projectRoot);
|
|
852
|
+
|
|
853
|
+
// 解析命令行参数
|
|
854
|
+
const args = parseArgs();
|
|
855
|
+
|
|
856
|
+
// 应用命令行参数
|
|
857
|
+
if (args.parallel !== null) {
|
|
858
|
+
config.build.parallel = args.parallel;
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
// 是否启用渠道折叠为 zip 模式
|
|
862
|
+
const isChannelFold2Zip = args.isChannelFold2Zip;
|
|
863
|
+
if (isChannelFold2Zip) {
|
|
864
|
+
log('已启用 --is-channel-fold2zip 模式:所有渠道输出将压缩为 zip 文件', 'info');
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
// 确定主题列表
|
|
868
|
+
let themes;
|
|
869
|
+
if (!config.themes.enabled) {
|
|
870
|
+
themes = [config.title.replace(/\s+/g, '-')];
|
|
871
|
+
} else if (args.themes) {
|
|
872
|
+
themes = args.themes;
|
|
873
|
+
log(`使用命令行指定的主题: ${themes.join(', ')}`, 'info');
|
|
874
|
+
} else {
|
|
875
|
+
themes = getAllThemes(config, projectRoot);
|
|
876
|
+
if (themes.length === 0) {
|
|
877
|
+
throw new Error('未找到任何主题');
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
// 确定渠道列表
|
|
882
|
+
let channels = args.channels || config.channels;
|
|
883
|
+
|
|
884
|
+
// 始终添加 preview 渠道,用于收集上传文件
|
|
885
|
+
if (!channels.includes('preview')) {
|
|
886
|
+
channels = [...channels, 'preview'];
|
|
887
|
+
log('已添加 preview 渠道', 'info');
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
// 打印摘要
|
|
891
|
+
printSummary(config, themes, channels);
|
|
892
|
+
|
|
893
|
+
// 备份入口文件(如果启用主题切换)
|
|
894
|
+
if (config.themes.enabled) {
|
|
895
|
+
backupEntryFile(config, projectRoot);
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
// 批量构建
|
|
899
|
+
const outputDir = path.join(projectRoot, config.build.outputDir);
|
|
900
|
+
const allResults = [];
|
|
901
|
+
const previewLinks = []; // 存储预览链接
|
|
902
|
+
|
|
903
|
+
// 批量构建前,清理一下目录
|
|
904
|
+
cleanTempDir(outputDir);
|
|
905
|
+
|
|
906
|
+
// 获取临时凭证,用于所有主题的上传操作
|
|
907
|
+
let credentials = null;
|
|
908
|
+
try {
|
|
909
|
+
credentials = await fetchTemporaryCredentials();
|
|
910
|
+
log('✓ 获取临时凭证成功', 'success');
|
|
911
|
+
} catch (error) {
|
|
912
|
+
log(`获取临时凭证失败: ${error.message},将跳过所有上传操作`, 'warn');
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
for (const theme of themes) {
|
|
916
|
+
// 切换主题
|
|
917
|
+
if (config.themes.enabled) {
|
|
918
|
+
switchTheme(config, theme, projectRoot);
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
// Validate theme data and prepare schema (backup + overwrite with merged data)
|
|
922
|
+
const { valid, errors, schemaPath } = runThemeValidation(theme, projectRoot, config.themes, { log });
|
|
923
|
+
activeSchemaPath = schemaPath; // Track for cleanup on interrupt
|
|
924
|
+
if (!valid) {
|
|
925
|
+
// Restore schema if backup was created
|
|
926
|
+
if (schemaPath) restoreSchema(schemaPath);
|
|
927
|
+
activeSchemaPath = null;
|
|
928
|
+
if (!config.build.continueOnError) {
|
|
929
|
+
throw new Error(`Theme data validation failed for "${theme}"`);
|
|
930
|
+
}
|
|
931
|
+
// Record failures for all channels of this theme and skip building
|
|
932
|
+
channels.forEach(channel => {
|
|
933
|
+
allResults.push({ success: false, theme, channel, error: 'Theme data validation failed' });
|
|
934
|
+
});
|
|
935
|
+
continue;
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
// 并发构建该主题的所有渠道
|
|
939
|
+
let themeResults;
|
|
940
|
+
try {
|
|
941
|
+
themeResults = await buildChannelsInParallel(
|
|
942
|
+
config,
|
|
943
|
+
theme,
|
|
944
|
+
channels,
|
|
945
|
+
outputDir,
|
|
946
|
+
projectRoot,
|
|
947
|
+
isChannelFold2Zip
|
|
948
|
+
);
|
|
949
|
+
} finally {
|
|
950
|
+
// Always restore schema after building this theme
|
|
951
|
+
if (schemaPath) restoreSchema(schemaPath);
|
|
952
|
+
activeSchemaPath = null;
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
allResults.push(...themeResults);
|
|
956
|
+
|
|
957
|
+
// 上传 preview 渠道产物到 COS
|
|
958
|
+
const previewResult = themeResults.find(r => r.channel === 'preview' && r.success);
|
|
959
|
+
if (previewResult) {
|
|
960
|
+
const previewDir = path.join(outputDir, theme, 'preview');
|
|
961
|
+
const cosPrefix = config.PathPrefix || 'playable/preview';
|
|
962
|
+
|
|
963
|
+
// 尝试上传,如果凭证过期则重试
|
|
964
|
+
async function uploadPreviewWithRetry() {
|
|
965
|
+
log(`开始上传 ${theme} 预览文件到 COS...`, 'info');
|
|
966
|
+
const uploadResults = await uploadDirectoryToCOS(
|
|
967
|
+
null, previewDir, cosPrefix, credentials
|
|
968
|
+
);
|
|
969
|
+
return uploadResults;
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
try {
|
|
973
|
+
const uploadResults = await uploadPreviewWithRetry();
|
|
974
|
+
|
|
975
|
+
// 保存预览链接
|
|
976
|
+
uploadResults.forEach(result => {
|
|
977
|
+
previewLinks.push({
|
|
978
|
+
theme,
|
|
979
|
+
file: result.file,
|
|
980
|
+
url: result.url,
|
|
981
|
+
});
|
|
982
|
+
});
|
|
983
|
+
|
|
984
|
+
log(`✓ ${theme} 预览文件上传成功`, 'success');
|
|
985
|
+
} catch (error) {
|
|
986
|
+
// 检查是否是凭证过期错误,如果是则重试一次
|
|
987
|
+
if (error.message && error.message.includes('Request has expired')) {
|
|
988
|
+
log(`凭证可能已过期,重新获取临时凭证后重试...`, 'warn');
|
|
989
|
+
try {
|
|
990
|
+
credentials = await fetchTemporaryCredentials();
|
|
991
|
+
log('✓ 获取临时凭证成功', 'success');
|
|
992
|
+
|
|
993
|
+
// 使用新凭证重试上传
|
|
994
|
+
const uploadResults = await uploadPreviewWithRetry();
|
|
995
|
+
uploadResults.forEach(result => {
|
|
996
|
+
previewLinks.push({
|
|
997
|
+
theme,
|
|
998
|
+
file: result.file,
|
|
999
|
+
url: result.url,
|
|
1000
|
+
});
|
|
1001
|
+
});
|
|
1002
|
+
log(`✓ ${theme} 预览文件上传成功(重试)`, 'success');
|
|
1003
|
+
} catch (retryError) {
|
|
1004
|
+
log(`上传 ${theme} 预览文件失败: ${retryError.message}`, 'error');
|
|
1005
|
+
}
|
|
1006
|
+
} else {
|
|
1007
|
+
log(`上传 ${theme} 预览文件失败: ${error.message}`, 'error');
|
|
1008
|
+
}
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
// 打印进度
|
|
1013
|
+
const completedBuilds = allResults.length;
|
|
1014
|
+
const totalBuilds = themes.length * channels.length;
|
|
1015
|
+
const progress = Math.round(completedBuilds / totalBuilds * 100);
|
|
1016
|
+
log(`进度: ${completedBuilds}/${totalBuilds} (${progress}%)`, 'success');
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
// 所有主题构建完成后,统一上传 preview.json(只上传一次)
|
|
1020
|
+
if (previewLinks.length > 0) {
|
|
1021
|
+
const cosPrefix = config.PathPrefix || 'playable/preview';
|
|
1022
|
+
try {
|
|
1023
|
+
log('正在更新 preview.json(包含所有主题的预览链接)...', 'info');
|
|
1024
|
+
// 传入 null 作为 currentTheme,表示更新所有主题的链接
|
|
1025
|
+
await updatePreviewJson(null, cosPrefix, null, previewLinks, credentials);
|
|
1026
|
+
} catch (error) {
|
|
1027
|
+
// 检查是否是凭证过期错误,如果是则重试一次
|
|
1028
|
+
if (error.message && error.message.includes('Request has expired')) {
|
|
1029
|
+
log(`凭证可能已过期,重新获取临时凭证后重试...`, 'warn');
|
|
1030
|
+
try {
|
|
1031
|
+
credentials = await fetchTemporaryCredentials();
|
|
1032
|
+
log('✓ 获取临时凭证成功', 'success');
|
|
1033
|
+
await updatePreviewJson(null, cosPrefix, null, previewLinks, credentials);
|
|
1034
|
+
} catch (retryError) {
|
|
1035
|
+
log(`更新 preview.json 失败: ${retryError.message}`, 'warn');
|
|
1036
|
+
}
|
|
1037
|
+
} else {
|
|
1038
|
+
log(`更新 preview.json 失败: ${error.message}`, 'warn');
|
|
1039
|
+
}
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
// 恢复入口文件
|
|
1044
|
+
if (config.themes.enabled) {
|
|
1045
|
+
restoreEntryFile();
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
// 确保清理所有临时目录
|
|
1049
|
+
if (config.build.cleanTemp) {
|
|
1050
|
+
cleanAllTempDirs();
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
// 统计结果
|
|
1054
|
+
const successBuilds = allResults.filter(r => r.success).length;
|
|
1055
|
+
const failedBuilds = allResults.filter(r => !r.success);
|
|
1056
|
+
|
|
1057
|
+
// 打印结果
|
|
1058
|
+
const duration = ((Date.now() - startTime) / 1000 / 60).toFixed(2);
|
|
1059
|
+
log('\n========================================');
|
|
1060
|
+
log('🎉 所有构建任务完成!', 'success');
|
|
1061
|
+
log(`✓ 成功构建: ${successBuilds}/${allResults.length}`);
|
|
1062
|
+
|
|
1063
|
+
if (failedBuilds.length > 0) {
|
|
1064
|
+
log(`✗ 失败构建: ${failedBuilds.length}`, 'warn');
|
|
1065
|
+
failedBuilds.forEach(({ theme, channel, error }) => {
|
|
1066
|
+
log(` - ${theme}/${channel}: ${error}`, 'error');
|
|
1067
|
+
});
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
log(`⏱️ 耗时: ${duration} 分钟`);
|
|
1071
|
+
log(`📁 输出目录: ${outputDir}`);
|
|
1072
|
+
|
|
1073
|
+
// 打印预览链接
|
|
1074
|
+
if (previewLinks.length > 0) {
|
|
1075
|
+
log('\n========================================');
|
|
1076
|
+
log('🔗 预览链接', 'info');
|
|
1077
|
+
log('========================================');
|
|
1078
|
+
|
|
1079
|
+
// 按主题分组
|
|
1080
|
+
const linksByTheme = {};
|
|
1081
|
+
previewLinks.forEach(link => {
|
|
1082
|
+
if (!linksByTheme[link.theme]) {
|
|
1083
|
+
linksByTheme[link.theme] = [];
|
|
1084
|
+
}
|
|
1085
|
+
linksByTheme[link.theme].push(link);
|
|
1086
|
+
});
|
|
1087
|
+
|
|
1088
|
+
// 打印链接
|
|
1089
|
+
Object.keys(linksByTheme).sort().forEach(theme => {
|
|
1090
|
+
log(`\n📦 ${theme}:`, 'info');
|
|
1091
|
+
linksByTheme[theme].forEach(link => {
|
|
1092
|
+
console.log(`${link.url}`);
|
|
1093
|
+
});
|
|
1094
|
+
});
|
|
1095
|
+
|
|
1096
|
+
log('\n========================================');
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
log('========================================');
|
|
1100
|
+
|
|
1101
|
+
} catch (error) {
|
|
1102
|
+
log(`严重错误: ${error.message}`, 'error');
|
|
1103
|
+
console.error(error);
|
|
1104
|
+
|
|
1105
|
+
// Restore schema if it was overwritten
|
|
1106
|
+
if (activeSchemaPath) {
|
|
1107
|
+
restoreSchema(activeSchemaPath);
|
|
1108
|
+
activeSchemaPath = null;
|
|
1109
|
+
}
|
|
1110
|
+
restoreEntryFile();
|
|
1111
|
+
cleanAllTempDirs();
|
|
1112
|
+
|
|
1113
|
+
process.exit(1);
|
|
1114
|
+
}
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
// ==================== 信号处理 ====================
|
|
1118
|
+
|
|
1119
|
+
function cleanup() {
|
|
1120
|
+
if (isExiting) return;
|
|
1121
|
+
isExiting = true;
|
|
1122
|
+
|
|
1123
|
+
log('\n收到中断信号,正在清理...', 'warn');
|
|
1124
|
+
|
|
1125
|
+
// Restore schema if it was overwritten during theme validation
|
|
1126
|
+
if (activeSchemaPath) {
|
|
1127
|
+
try {
|
|
1128
|
+
restoreSchema(activeSchemaPath);
|
|
1129
|
+
log('已恢复 theme.schema.json5', 'info');
|
|
1130
|
+
} catch (e) {
|
|
1131
|
+
log(`恢复 theme.schema.json5 失败: ${e.message}`, 'error');
|
|
1132
|
+
}
|
|
1133
|
+
activeSchemaPath = null;
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
restoreEntryFile();
|
|
1137
|
+
cleanAllTempDirs();
|
|
1138
|
+
|
|
1139
|
+
process.exit(1);
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
process.on('SIGINT', cleanup);
|
|
1143
|
+
process.on('SIGTERM', cleanup);
|
|
1144
|
+
|
|
1145
|
+
// ==================== 导出 ====================
|
|
1146
|
+
|
|
1147
|
+
module.exports = { buildBatch };
|