@playcraft/devkit 1.0.12 → 1.0.14

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.
Files changed (39) hide show
  1. package/LICENSE +21 -21
  2. package/cli/bin/playable-scripts.js +0 -0
  3. package/cli/commands/build.js +5 -5
  4. package/cli/commands/builds.js +14 -14
  5. package/cli/commands/pack.js +47 -47
  6. package/core/batch-build.js +72 -72
  7. package/core/cos-uploader.js +39 -47
  8. package/core/index.d.ts +185 -185
  9. package/core/loaders/gltf-loader.js +19 -19
  10. package/core/plugins/AdikteevInjectorPlugin.js +11 -11
  11. package/core/plugins/BigoAdsInjectorPlugin.js +4 -4
  12. package/core/plugins/DAPIInjectorPlugin.js +4 -4
  13. package/core/plugins/DebuggerInjectionPlugin.js +5 -5
  14. package/core/plugins/ExitAPIInjectorPlugin.js +9 -9
  15. package/core/plugins/FflateCompressionPlugin.js +43 -43
  16. package/core/plugins/LiftoffInjectorPlugin.js +11 -11
  17. package/core/plugins/MRAIDInjectorPlugin.js +3 -3
  18. package/core/plugins/MintegralInjectorPlugin.js +4 -4
  19. package/core/plugins/PangleInjectorPlugin.js +3 -3
  20. package/core/plugins/SnapchatInjectorPlugin.js +4 -4
  21. package/core/plugins/TikTokInjectorPlugin.js +3 -3
  22. package/core/plugins/UnityInjectorPlugin.js +13 -13
  23. package/core/resources/snapchat-config.json +1 -1
  24. package/core/resources/tiktok-config.json +1 -1
  25. package/core/utils/buildDefines.js +2 -2
  26. package/core/utils/buildTemplateString.js +2 -2
  27. package/core/utils/generateAdikteevHtmlWebpackPluginConfig.js +26 -26
  28. package/core/utils/generateInMobiHtmlWebpackPluginConfig.js +16 -16
  29. package/core/utils/injectSDKPlugins.js +10 -10
  30. package/core/utils/logOptions.js +2 -2
  31. package/core/utils/mergeOptions.js +5 -5
  32. package/core/utils/parseArgvOptions.js +4 -4
  33. package/core/utils/resolveChannelFold2Zip.js +12 -12
  34. package/core/utils/validateThemeData.js +80 -80
  35. package/core/vite.dev.js +12 -12
  36. package/core/webpack.build.js +12 -12
  37. package/core/webpack.dev.js +12 -12
  38. package/defines.d.ts +54 -54
  39. package/package.json +1 -1
package/LICENSE CHANGED
@@ -1,21 +1,21 @@
1
- MIT License
2
-
3
- Copyright (c) 2025-present Smoud
4
-
5
- Permission is hereby granted, free of charge, to any person obtaining a copy
6
- of this software and associated documentation files (the "Software"), to deal
7
- in the Software without restriction, including without limitation the rights
8
- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
- copies of the Software, and to permit persons to whom the Software is
10
- furnished to do so, subject to the following conditions:
11
-
12
- The above copyright notice and this permission notice shall be included in all
13
- copies or substantial portions of the Software.
14
-
15
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
- SOFTWARE.
1
+ MIT License
2
+
3
+ Copyright (c) 2025-present Smoud
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
File without changes
@@ -1,8 +1,8 @@
1
- "use strict";process.env.BABEL_ENV="production";process.env.NODE_ENV="production";const fs=require("fs");const path=require("path");const{runBuild}=require("../../core/webpack.build.js");const{runThemeValidation,restoreSchema}=require("../../core/utils/validateThemeData");/**
2
- * 从主题入口文件中检测当前主题名称。
3
- * 支持以下模式:
4
- * export { default } from './themeName';
5
- * import Theme from './themeName';
1
+ "use strict";process.env.BABEL_ENV="production";process.env.NODE_ENV="production";const fs=require("fs");const path=require("path");const{runBuild}=require("../../core/webpack.build.js");const{runThemeValidation,restoreSchema}=require("../../core/utils/validateThemeData");/**
2
+ * 从主题入口文件中检测当前主题名称。
3
+ * 支持以下模式:
4
+ * export { default } from './themeName';
5
+ * import Theme from './themeName';
6
6
  */function detectCurrentTheme(projectRoot){// 尝试加载 builds.config.js 获取主题配置
7
7
  const buildsConfigPath=path.join(projectRoot,"builds.config.js");let themeConfig={sourceDir:"src/theme",entryFile:"src/theme/index.ts"};if(fs.existsSync(buildsConfigPath)){try{delete require.cache[buildsConfigPath];const config=require(buildsConfigPath);if(config.themes){themeConfig={...themeConfig,...config.themes}}}catch(e){// 忽略错误
8
8
  }}const entryPath=path.join(projectRoot,themeConfig.entryFile);if(!fs.existsSync(entryPath)){return{themeName:null,themeConfig}}const content=fs.readFileSync(entryPath,"utf8");// 匹配: export { default } from './themeName' 或 import ... from './themeName'
@@ -1,16 +1,16 @@
1
1
  #!/usr/bin/env node
2
- "use strict";/**
3
- * Batch Builds Command for playable-scripts
4
- *
5
- * 支持:
6
- * - 单个主题下多个渠道的并发构建
7
- * - 独立临时目录,避免并发冲突
8
- * - 灵活的配置系统
9
- *
10
- * 使用方法:
11
- * playable-scripts builds # 使用默认配置
12
- * playable-scripts builds -t "theme1,theme2" # 指定主题
13
- * playable-scripts builds -c "google,facebook" # 指定渠道
14
- * playable-scripts builds -t "theme1" -c "google" # 指定主题和渠道
15
- * playable-scripts builds --parallel 3 # 设置并发数
2
+ "use strict";/**
3
+ * Batch Builds Command for playable-scripts
4
+ *
5
+ * 支持:
6
+ * - 单个主题下多个渠道的并发构建
7
+ * - 独立临时目录,避免并发冲突
8
+ * - 灵活的配置系统
9
+ *
10
+ * 使用方法:
11
+ * playable-scripts builds # 使用默认配置
12
+ * playable-scripts builds -t "theme1,theme2" # 指定主题
13
+ * playable-scripts builds -c "google,facebook" # 指定渠道
14
+ * playable-scripts builds -t "theme1" -c "google" # 指定主题和渠道
15
+ * playable-scripts builds --parallel 3 # 设置并发数
16
16
  */process.on("unhandledRejection",err=>{throw err});const{buildBatch}=require("../../core/batch-build");buildBatch();
@@ -1,43 +1,43 @@
1
1
  "use strict";const fs=require("fs");const path=require("path");const yazl=require("yazl");// ==================== 配置 ====================
2
- const projectRoot=process.cwd();/**
3
- * 解析 appName,依次从 builds.config.js、build.json 或 CLI 参数中获取。
4
- * 优先级: CLI --app-name > builds.config.js > build.json > 文件夹名称
5
- * @returns {string}
2
+ const projectRoot=process.cwd();/**
3
+ * 解析 appName,依次从 builds.config.js、build.json 或 CLI 参数中获取。
4
+ * 优先级: CLI --app-name > builds.config.js > build.json > 文件夹名称
5
+ * @returns {string}
6
6
  */function resolveAppName(){// 1. CLI 参数: --app-name=xxx
7
7
  const args=process.argv.slice(2);for(const arg of args){if(arg.startsWith("--app-name=")){return arg.split("=")[1].trim()}}// 2. builds.config.js → appName 或 title
8
8
  const buildsConfigPath=path.join(projectRoot,"builds.config.js");if(fs.existsSync(buildsConfigPath)){try{delete require.cache[buildsConfigPath];const config=require(buildsConfigPath);if(config.appName)return config.appName;if(config.title)return config.title}catch(e){// 忽略错误
9
9
  }}// 3. build.json → app
10
10
  const buildJsonPath=path.join(projectRoot,"build.json");if(fs.existsSync(buildJsonPath)){try{const data=JSON.parse(fs.readFileSync(buildJsonPath,"utf8"));if(data.app)return data.app}catch(e){// 忽略错误
11
11
  }}// 4. 兜底: 使用项目文件夹名称
12
- return path.basename(projectRoot)}/**
13
- * 从 CLI 参数中解析输出目录。
14
- * @returns {string}
12
+ return path.basename(projectRoot)}/**
13
+ * 从 CLI 参数中解析输出目录。
14
+ * @returns {string}
15
15
  */function resolveOutputDir(){const args=process.argv.slice(2);for(const arg of args){if(arg.startsWith("--out-dir=")){return arg.split("=")[1].trim()}}return"dist"}// ==================== 忽略规则解析 ====================
16
- /**
17
- * 解析单个忽略文件,返回规范化后的模式数组。
18
- * @param {string} filePath - 忽略文件的绝对路径
19
- * @returns {string[]} 规范化后的忽略模式数组
16
+ /**
17
+ * 解析单个忽略文件,返回规范化后的模式数组。
18
+ * @param {string} filePath - 忽略文件的绝对路径
19
+ * @returns {string[]} 规范化后的忽略模式数组
20
20
  */function parseSingleIgnoreFile(filePath){if(!fs.existsSync(filePath)){return[]}const content=fs.readFileSync(filePath,"utf8");const patterns=[];for(const rawLine of content.split(/\r?\n/)){const line=rawLine.trim();// 跳过空行和注释
21
21
  if(!line||line.startsWith("#"))continue;// 不支持取反模式,给出警告并跳过
22
22
  if(line.startsWith("!")){console.log(`\x1b[33m⚠️ 不支持取反模式,已跳过: ${line}\x1b[0m`);continue}// 规范化:
23
23
  // - 移除尾部斜杠(用于目录匹配)
24
24
  // - 移除前导 ./(gitignore 中 ./foo 等同于 foo)
25
- let normalized=line.replace(/\/+$/,"");normalized=normalized.replace(/^\.\//g,"");patterns.push(normalized)}return patterns}/**
26
- * 解析 .gitignore 和 .playcraftignore 文件,合并并去重。
27
- * .gitignore 提供基础忽略规则,.playcraftignore 提供额外补充规则。
28
- * @returns {{ patterns: string[], sources: { gitignore: number, playcraftignore: number } }}
25
+ let normalized=line.replace(/\/+$/,"");normalized=normalized.replace(/^\.\//g,"");patterns.push(normalized)}return patterns}/**
26
+ * 解析 .gitignore 和 .playcraftignore 文件,合并并去重。
27
+ * .gitignore 提供基础忽略规则,.playcraftignore 提供额外补充规则。
28
+ * @returns {{ patterns: string[], sources: { gitignore: number, playcraftignore: number } }}
29
29
  */function parseIgnoreFiles(){const gitignorePath=path.join(projectRoot,".gitignore");const playcraftignorePath=path.join(projectRoot,".playcraftignore");const gitignorePatterns=parseSingleIgnoreFile(gitignorePath);const playcraftPatterns=parseSingleIgnoreFile(playcraftignorePath);if(gitignorePatterns.length===0&&playcraftPatterns.length===0){console.log("\x1B[33m\u26A0\uFE0F \u672A\u627E\u5230 .gitignore \u6216 .playcraftignore \u6587\u4EF6\uFF0C\u5C06\u6253\u5305\u6240\u6709\u6587\u4EF6\u3002\x1B[0m");return{patterns:[],sources:{gitignore:0,playcraftignore:0}}}// 合并并去重(保持顺序,.gitignore 优先)
30
- const seen=new Set;const merged=[];for(const p of[...gitignorePatterns,...playcraftPatterns]){if(!seen.has(p)){seen.add(p);merged.push(p)}}return{patterns:merged,sources:{gitignore:gitignorePatterns.length,playcraftignore:playcraftPatterns.length}}}/**
31
- * 将 gitignore 风格的模式转换为正则表达式。
32
- *
33
- * gitignore 语义:
34
- * - 不含 `/` 的模式 → 在目录树中任意位置匹配文件/目录名
35
- * - 含 `/` 的模式(如 `scripts/foo`)→ 锚定到项目根目录
36
- * - 前导 `/` 表示"仅匹配根目录",构建正则前会被剥离
37
- * - 前导 `./` 与 `/` 等效(已在解析阶段被剥离)
38
- *
39
- * @param {string} pattern
40
- * @returns {RegExp}
30
+ const seen=new Set;const merged=[];for(const p of[...gitignorePatterns,...playcraftPatterns]){if(!seen.has(p)){seen.add(p);merged.push(p)}}return{patterns:merged,sources:{gitignore:gitignorePatterns.length,playcraftignore:playcraftPatterns.length}}}/**
31
+ * 将 gitignore 风格的模式转换为正则表达式。
32
+ *
33
+ * gitignore 语义:
34
+ * - 不含 `/` 的模式 → 在目录树中任意位置匹配文件/目录名
35
+ * - 含 `/` 的模式(如 `scripts/foo`)→ 锚定到项目根目录
36
+ * - 前导 `/` 表示"仅匹配根目录",构建正则前会被剥离
37
+ * - 前导 `./` 与 `/` 等效(已在解析阶段被剥离)
38
+ *
39
+ * @param {string} pattern
40
+ * @returns {RegExp}
41
41
  */function patternToRegex(pattern){let p=pattern;let anchored=false;// 前导 / 表示锚定到根目录;构建正则前剥离
42
42
  if(p.startsWith("/")){p=p.slice(1);anchored=true}// 如果模式中包含 /,同样视为锚定到根目录
43
43
  if(p.includes("/")){anchored=true}// 转义正则特殊字符(* 和 ? 除外)
@@ -47,32 +47,32 @@ regexStr=regexStr.replace(/\*/g,"[^/]*");// 处理 ?(匹配除路径分隔符
47
47
  regexStr=regexStr.replace(/\?/g,"[^/]");// 还原 globstar 占位符
48
48
  regexStr=regexStr.replace(/\{\{GLOBSTAR\}\}/g,".*");if(anchored){// 锚定模式:从相对路径的起始位置匹配
49
49
  return new RegExp(`^${regexStr}(/|$)`)}// 非锚定模式:在目录树中任意位置匹配路径段
50
- return new RegExp(`(^|/)${regexStr}(/|$)`)}/**
51
- * 检查相对路径是否应被忽略。
52
- * @param {string} relativePath - 使用正斜杠规范化后的相对路径
53
- * @param {RegExp[]} regexPatterns
54
- * @returns {boolean}
50
+ return new RegExp(`(^|/)${regexStr}(/|$)`)}/**
51
+ * 检查相对路径是否应被忽略。
52
+ * @param {string} relativePath - 使用正斜杠规范化后的相对路径
53
+ * @param {RegExp[]} regexPatterns
54
+ * @returns {boolean}
55
55
  */function shouldIgnore(relativePath,regexPatterns){for(const regex of regexPatterns){if(regex.test(relativePath))return true}return false}// ==================== 文件收集 ====================
56
- /**
57
- * 递归收集目录下的所有文件,遵循忽略规则。
58
- * @param {string} dir - 目录的绝对路径
59
- * @param {string} baseDir - 项目根目录,用于计算相对路径
60
- * @param {RegExp[]} ignoreRegexes
61
- * @returns {string[]} 文件绝对路径数组
56
+ /**
57
+ * 递归收集目录下的所有文件,遵循忽略规则。
58
+ * @param {string} dir - 目录的绝对路径
59
+ * @param {string} baseDir - 项目根目录,用于计算相对路径
60
+ * @param {RegExp[]} ignoreRegexes
61
+ * @returns {string[]} 文件绝对路径数组
62
62
  */function collectFiles(dir,baseDir,ignoreRegexes){const results=[];let entries;try{entries=fs.readdirSync(dir,{withFileTypes:true})}catch(e){console.warn(`\x1b[33m⚠️ 无法读取目录: ${dir}\x1b[0m`);return results}for(const entry of entries){const fullPath=path.join(dir,entry.name);// 使用正斜杠以保持匹配一致性
63
63
  const relativePath=path.relative(baseDir,fullPath).replace(/\\/g,"/");if(shouldIgnore(relativePath,ignoreRegexes)){continue}if(entry.isDirectory()){results.push(...collectFiles(fullPath,baseDir,ignoreRegexes))}else if(entry.isFile()||entry.isSymbolicLink()){results.push(fullPath)}}return results}// ==================== Zip 创建 ====================
64
- /**
65
- * 根据收集到的文件创建 zip 压缩包。
66
- * @param {string[]} files - 文件绝对路径数组
67
- * @param {string} rootFolderName - zip 内的根文件夹名称
68
- * @param {string} outputPath - 输出 zip 文件的绝对路径
69
- * @returns {Promise<{fileCount: number, zipSize: number}>}
64
+ /**
65
+ * 根据收集到的文件创建 zip 压缩包。
66
+ * @param {string[]} files - 文件绝对路径数组
67
+ * @param {string} rootFolderName - zip 内的根文件夹名称
68
+ * @param {string} outputPath - 输出 zip 文件的绝对路径
69
+ * @returns {Promise<{fileCount: number, zipSize: number}>}
70
70
  */function createZip(files,rootFolderName,outputPath){return new Promise((resolve,reject)=>{const zipFile=new yazl.ZipFile;let fileCount=0;for(const filePath of files){const relativePath=path.relative(projectRoot,filePath).replace(/\\/g,"/");const zipEntryPath=`${rootFolderName}/${relativePath}`;zipFile.addFile(filePath,zipEntryPath);fileCount++}zipFile.end();// 确保输出目录存在
71
71
  const outputDir=path.dirname(outputPath);if(!fs.existsSync(outputDir)){fs.mkdirSync(outputDir,{recursive:true})}const writeStream=fs.createWriteStream(outputPath);writeStream.on("error",err=>{reject(err)});writeStream.on("close",()=>{const stats=fs.statSync(outputPath);resolve({fileCount,zipSize:stats.size})});zipFile.outputStream.pipe(writeStream)})}// ==================== 格式化工具 ====================
72
- /**
73
- * 将字节数格式化为可读字符串。
74
- * @param {number} bytes
75
- * @returns {string}
72
+ /**
73
+ * 将字节数格式化为可读字符串。
74
+ * @param {number} bytes
75
+ * @returns {string}
76
76
  */function formatSize(bytes){if(bytes<1024)return`${bytes} B`;if(bytes<1024*1024)return`${(bytes/1024).toFixed(2)} KB`;return`${(bytes/(1024*1024)).toFixed(2)} MB`}// ==================== 主流程 ====================
77
77
  async function main(){console.log("\x1B[36m\uD83D\uDCE6 playable-scripts \u6253\u5305\x1B[0m\n");// 1. 解析 appName
78
78
  const appName=resolveAppName();console.log(` 应用名称: \x1b[1m${appName}\x1b[0m`);// 2. 解析忽略规则(合并 .gitignore + .playcraftignore)
@@ -1,14 +1,14 @@
1
1
  #!/usr/bin/env node
2
- "use strict";/**
3
- * 批量构建核心模块
4
- *
5
- * 核心特性:
6
- * 1. 支持单个主题下多个渠道的并发构建
7
- * 2. 每个构建任务使用独立的临时目录(dist-temp-{taskId})
8
- * 3. 构建完成后自动移动到目标目录并清理临时目录
9
- * 4. 支持灵活的配置系统
10
- */const{execSync,exec}=require("child_process");const fs=require("fs");const path=require("path");const os=require("os");const{promisify}=require("util");const{fetchTemporaryCredentials,createCOSClient,uploadDirectoryToCOS,uploadFileToCOS,purgeCDNCache}=require("./cos-uploader");const{runThemeValidation,restoreSchema}=require("./utils/validateThemeData");const execAsync=promisify(exec);/**
11
- * 更新 preview.json 文件
2
+ "use strict";/**
3
+ * 批量构建核心模块
4
+ *
5
+ * 核心特性:
6
+ * 1. 支持单个主题下多个渠道的并发构建
7
+ * 2. 每个构建任务使用独立的临时目录(dist-temp-{taskId})
8
+ * 3. 构建完成后自动移动到目标目录并清理临时目录
9
+ * 4. 支持灵活的配置系统
10
+ */const{execSync,exec}=require("child_process");const fs=require("fs");const path=require("path");const os=require("os");const{promisify}=require("util");const{fetchTemporaryCredentials,createCOSClient,uploadDirectoryToCOS,uploadFileToCOS,purgeCDNCache}=require("./cos-uploader");const{runThemeValidation,restoreSchema}=require("./utils/validateThemeData");const execAsync=promisify(exec);/**
11
+ * 更新 preview.json 文件
12
12
  */async function updatePreviewJson(cosConfig,cosPrefix,currentTheme,previewLinks,credentials=null){const previewJsonKey=cosPrefix?`${cosPrefix}/preview.json`:"preview.json";// 如果没有传入凭证,尝试获取一次
13
13
  if(!credentials){try{credentials=await fetchTemporaryCredentials()}catch(error){log(`获取临时凭证失败,跳过 preview.json 更新: ${error.message}`,"warn");return}}// 读取现有的 preview.json 文件
14
14
  let existingPreviewData={};try{const cos=await createCOSClient(null,credentials);const getObjectResult=await new Promise((resolve,reject)=>{cos.getObject({Bucket:credentials.bucket,Region:credentials.region,Key:previewJsonKey},(err,data)=>{if(err){if(err.statusCode===404){// 文件不存在,使用空对象
@@ -20,15 +20,15 @@ const currentThemeLinks=previewLinks.filter(link=>link.theme===currentTheme);cur
20
20
  updatedPreviewData[fileName]={url:link.url,update_time:currentTime}});// 将更新后的数据写入临时文件
21
21
  const tempDir=path.join(os.tmpdir(),"playable-preview");if(!fs.existsSync(tempDir)){fs.mkdirSync(tempDir,{recursive:true})}const tempPreviewPath=path.join(tempDir,"preview.json");fs.writeFileSync(tempPreviewPath,JSON.stringify(updatedPreviewData,null,2),"utf8");// 上传更新后的 preview.json 文件
22
22
  await uploadFileToCOS(null,tempPreviewPath,previewJsonKey,credentials);// 刷新 CDN 缓存
23
- const cdnDomain="static.aix.intlgame.com";const previewJsonUrl=`https://${cdnDomain}/${previewJsonKey}`;try{await purgeCDNCache(null,[previewJsonUrl],credentials);log(`✓ CDN 缓存已刷新: ${previewJsonUrl}`,"success")}catch(error){log(`⚠️ CDN 缓存刷新失败: ${error.message}`,"warn")}// 清理临时文件
23
+ const cdnDomain="static.aix.intlgame.com";const previewJsonUrl=`https://${cdnDomain}/${previewJsonKey}`;try{await purgeCDNCache(null,[previewJsonUrl],credentials);log(`✓ CDN 缓存已刷新: ${previewJsonUrl}`,"success")}catch(error){log(`⚠️ CDN 缓存刷新失败 (preview.json): ${error.message}`,"warn")}// 清理临时文件
24
24
  fs.unlinkSync(tempPreviewPath);log(`✓ preview.json 文件已更新并上传`,"success")}// ==================== 默认配置 ====================
25
- /**
26
- * 计算默认并发数
27
- * 策略: CPU 核心数 - 1,最小为 2,最大为 8
28
- * 例如:
29
- * - 4 核 CPU → 3 并发
30
- * - 8 核 CPU → 7 并发
31
- * - 16 核 CPU → 8 并发(限制最大值)
25
+ /**
26
+ * 计算默认并发数
27
+ * 策略: CPU 核心数 - 1,最小为 2,最大为 8
28
+ * 例如:
29
+ * - 4 核 CPU → 3 并发
30
+ * - 8 核 CPU → 7 并发
31
+ * - 16 核 CPU → 8 并发(限制最大值)
32
32
  */function getDefaultParallel(){const cpuCount=os.cpus().length;const calculated=Math.max(2,cpuCount-1);// 至少 2 个并发
33
33
  const parallel=Math.min(8,calculated);// 最多 8 个并发
34
34
  return parallel}const DEFAULT_CONFIG={title:"Phaser Game",appName:"",// 用于从远程仓库拉取配置
@@ -38,18 +38,18 @@ let isExiting=false;let backupContent=null;let backupPath=null;let activeSchemaP
38
38
  const activeTempDirs=new Set;// 跟踪所有活动的临时目录
39
39
  function log(message,level="info"){const timestamp=new Date().toLocaleTimeString();const colors={info:"\x1B[36m",success:"\x1B[32m",error:"\x1B[31m",warn:"\x1B[33m",debug:"\x1B[90m"};const reset="\x1B[0m";const prefix={info:"\uD83D\uDCE6",success:"\u2705",error:"\u274C",warn:"\u26A0\uFE0F",debug:"\uD83D\uDD0D"}[level]||"\u2139\uFE0F";console.log(`${colors[level]}[${timestamp}] ${prefix} ${message}${reset}`)}function getCurrentMonth(){const month=new Date().getMonth()+1;return`${month}月交付`}function getCurrentDate(){const date=new Date;const year=date.getFullYear();const month=String(date.getMonth()+1).padStart(2,"0");const day=String(date.getDate()).padStart(2,"0");return`${year}${month}${day}`}function defaultFileNameGenerator(config,themeName,channel){const parts=[themeName,config.naming.language,config.naming.format,getCurrentMonth(),getCurrentDate(),config.naming.supplier,config.naming.follower,channel.toLowerCase()].filter(item=>!!item);return parts.join("-")}function getAllThemes(config,projectRoot){const themesDir=path.join(projectRoot,config.themes.sourceDir);if(!fs.existsSync(themesDir)){log(`主题目录不存在: ${themesDir}`,"warn");return[]}const entries=fs.readdirSync(themesDir,{withFileTypes:true});let themes=entries.filter(entry=>entry.isDirectory()).map(entry=>entry.name).sort();if(config.themes.whitelist&&typeof config.themes.whitelist==="function"){themes=themes.filter(config.themes.whitelist);log(`应用主题白名单过滤: ${themes.length} 个主题`,"debug")}return themes}function backupEntryFile(config,projectRoot){const entryPath=path.join(projectRoot,config.themes.entryFile);if(!fs.existsSync(entryPath)){throw new Error(`入口文件不存在: ${entryPath}`)}backupPath=entryPath;backupContent=fs.readFileSync(entryPath,"utf8");log(`已备份入口文件: ${config.themes.entryFile}`,"debug")}function restoreEntryFile(){if(backupPath&&backupContent){fs.writeFileSync(backupPath,backupContent,"utf8");log(`已恢复入口文件`,"debug");backupPath=null;backupContent=null}}function switchTheme(config,themeName,projectRoot){const entryPath=path.join(projectRoot,config.themes.entryFile);// 使用配置文件中的 generateEntryFile 函数(如果提供)
40
40
  let content;if(config.themes.switching&&typeof config.themes.switching.generateEntryFile==="function"){content=config.themes.switching.generateEntryFile(themeName)}else{// 兼容旧格式:简化的 export 语句
41
- const themePath=`./${themeName}`;content=`export { default } from '${themePath}';\n`}fs.writeFileSync(entryPath,content,"utf8");log(`已切换主题: ${themeName}`,"debug")}/**
42
- * 生成唯一的任务 ID
43
- */function generateTaskId(){return`${Date.now()}-${Math.random().toString(36).substr(2,9)}`}/**
44
- * 创建独立的临时目录
45
- */function createTempDir(config,projectRoot,taskId){const tempDir=path.join(projectRoot,`${config.build.tempDirPrefix}-${taskId}`);if(fs.existsSync(tempDir)){fs.rmSync(tempDir,{recursive:true,force:true})}fs.mkdirSync(tempDir,{recursive:true});activeTempDirs.add(tempDir);return tempDir}/**
46
- * 清理临时目录
47
- */function cleanTempDir(tempDir){if(fs.existsSync(tempDir)){try{fs.rmSync(tempDir,{recursive:true,force:true});activeTempDirs.delete(tempDir);log(`已清理目录: ${path.basename(tempDir)}`,"debug")}catch(error){log(`清理临时目录失败: ${error.message}`,"warn")}}}/**
48
- * 清理所有活动的临时目录
49
- */function cleanAllTempDirs(){for(const tempDir of activeTempDirs){cleanTempDir(tempDir)}activeTempDirs.clear()}/**
50
- * 递归复制目录
51
- */function copyDirRecursive(src,dest){if(!fs.existsSync(dest)){fs.mkdirSync(dest,{recursive:true})}const entries=fs.readdirSync(src,{withFileTypes:true});for(const entry of entries){const srcPath=path.join(src,entry.name);const destPath=path.join(dest,entry.name);if(entry.isDirectory()){copyDirRecursive(srcPath,destPath)}else{fs.copyFileSync(srcPath,destPath)}}}/**
52
- * 构建单个渠道(使用独立临时目录)
41
+ const themePath=`./${themeName}`;content=`export { default } from '${themePath}';\n`}fs.writeFileSync(entryPath,content,"utf8");log(`已切换主题: ${themeName}`,"debug")}/**
42
+ * 生成唯一的任务 ID
43
+ */function generateTaskId(){return`${Date.now()}-${Math.random().toString(36).substr(2,9)}`}/**
44
+ * 创建独立的临时目录
45
+ */function createTempDir(config,projectRoot,taskId){const tempDir=path.join(projectRoot,`${config.build.tempDirPrefix}-${taskId}`);if(fs.existsSync(tempDir)){fs.rmSync(tempDir,{recursive:true,force:true})}fs.mkdirSync(tempDir,{recursive:true});activeTempDirs.add(tempDir);return tempDir}/**
46
+ * 清理临时目录
47
+ */function cleanTempDir(tempDir){if(fs.existsSync(tempDir)){try{fs.rmSync(tempDir,{recursive:true,force:true});activeTempDirs.delete(tempDir);log(`已清理目录: ${path.basename(tempDir)}`,"debug")}catch(error){log(`清理临时目录失败: ${error.message}`,"warn")}}}/**
48
+ * 清理所有活动的临时目录
49
+ */function cleanAllTempDirs(){for(const tempDir of activeTempDirs){cleanTempDir(tempDir)}activeTempDirs.clear()}/**
50
+ * 递归复制目录
51
+ */function copyDirRecursive(src,dest){if(!fs.existsSync(dest)){fs.mkdirSync(dest,{recursive:true})}const entries=fs.readdirSync(src,{withFileTypes:true});for(const entry of entries){const srcPath=path.join(src,entry.name);const destPath=path.join(dest,entry.name);if(entry.isDirectory()){copyDirRecursive(srcPath,destPath)}else{fs.copyFileSync(srcPath,destPath)}}}/**
52
+ * 构建单个渠道(使用独立临时目录)
53
53
  */async function buildChannel(config,themeName,channel,outputDir,projectRoot,isChannelFold2Zip){const taskId=generateTaskId();const tempDir=createTempDir(config,projectRoot,taskId);log(`开始构建: 主题=${themeName}, 渠道=${channel}, 任务ID=${taskId.substr(0,8)}...`);try{// 生成文件名
54
54
  const fileName=config.naming.generator?config.naming.generator(themeName,channel,getCurrentDate(),getCurrentMonth()):defaultFileNameGenerator(config,themeName,channel);log(`文件名: ${fileName}`,"debug");// 执行构建,指定独立的输出目录(使用绝对路径)
55
55
  // 注意:
@@ -71,11 +71,11 @@ const zipFileName=`${channel}.zip`;const srcZipPath=path.join(tempDir,zipFileNam
71
71
  copyTempFiles(outputDir,themeName,channel)}}else{// 普通模式:移动所有文件到 outputDir/themeName/channel/
72
72
  copyTempFiles(outputDir,themeName,channel)}}// 清理临时目录
73
73
  if(config.build.cleanTemp){cleanTempDir(tempDir)}return{success:true,theme:themeName,channel}}catch(error){log(`构建失败: ${themeName}/${channel} - ${error.message}`,"error");// 清理临时目录
74
- if(config.build.cleanTemp){cleanTempDir(tempDir)}if(!config.build.continueOnError){throw error}return{success:false,theme:themeName,channel,error:error.message}}}/**
75
- * 并发构建多个渠道
74
+ if(config.build.cleanTemp){cleanTempDir(tempDir)}if(!config.build.continueOnError){throw error}return{success:false,theme:themeName,channel,error:error.message}}}/**
75
+ * 并发构建多个渠道
76
76
  */async function buildChannelsInParallel(config,themeName,channels,outputDir,projectRoot,isChannelFold2Zip){const maxParallel=config.build.parallel||3;const results=[];// 分批并发构建
77
- for(let i=0;i<channels.length;i+=maxParallel){const batch=channels.slice(i,i+maxParallel);const batchPromises=batch.map(channel=>buildChannel(config,themeName,channel,outputDir,projectRoot,isChannelFold2Zip));const batchResults=await Promise.allSettled(batchPromises);batchResults.forEach((result,index)=>{if(result.status==="fulfilled"){results.push(result.value)}else{var _result$reason;results.push({success:false,theme:themeName,channel:batch[index],error:((_result$reason=result.reason)===null||_result$reason===void 0?void 0:_result$reason.message)||"\u672A\u77E5\u9519\u8BEF"})}})}return results}/**
78
- * 解析命令行参数
77
+ for(let i=0;i<channels.length;i+=maxParallel){const batch=channels.slice(i,i+maxParallel);const batchPromises=batch.map(channel=>buildChannel(config,themeName,channel,outputDir,projectRoot,isChannelFold2Zip));const batchResults=await Promise.allSettled(batchPromises);batchResults.forEach((result,index)=>{if(result.status==="fulfilled"){results.push(result.value)}else{var _result$reason;results.push({success:false,theme:themeName,channel:batch[index],error:((_result$reason=result.reason)===null||_result$reason===void 0?void 0:_result$reason.message)||"\u672A\u77E5\u9519\u8BEF"})}})}return results}/**
78
+ * 解析命令行参数
79
79
  */function parseArgs(){const args=process.argv.slice(2);const result={themes:null,channels:null,parallel:null,isChannelFold2Zip:false};for(let i=0;i<args.length;i++){const currentArg=args[i];const nextArg=args[i+1];// 支持 -t 和 --themes 格式
80
80
  if(currentArg==="-t"||currentArg==="--themes"){if(nextArg&&!nextArg.startsWith("-")){result.themes=nextArg.split(",").map(t=>t.trim()).filter(Boolean);i++}}// 支持 -c 和 --channels 格式
81
81
  else if(currentArg==="-c"||currentArg==="--channels"){if(nextArg&&!nextArg.startsWith("-")){result.channels=nextArg.split(",").map(c=>c.trim()).filter(Boolean);i++}}// 支持 -p 和 --parallel 格式
@@ -85,42 +85,42 @@ else if(currentArg.startsWith("--channels=")){result.channels=currentArg.substri
85
85
  else if(currentArg.startsWith("--parallel=")){result.parallel=parseInt(currentArg.substring(11),10)}// 支持 --is-channel-fold2zip 参数
86
86
  else if(currentArg==="--is-channel-fold2zip"){result.isChannelFold2Zip=true}// 兼容位置参数:第一个参数作为主题,第二个作为渠道
87
87
  else if(!currentArg.startsWith("-")&&result.themes===null){result.themes=[currentArg]}else if(!currentArg.startsWith("-")&&result.themes!==null&&result.channels===null){result.channels=[currentArg]}}return result}// 远程配置仓库地址
88
- const REMOTE_CONFIG_REPO="https://git.woa.com/maclerylin/playable-build-config.git";/**
89
- * 执行命令并返回结果
90
- * @param {string} command - 要执行的命令
91
- * @param {object} options - execSync 选项
92
- * @returns {string|null} 命令输出,失败返回 null
93
- */function execCommand(command,options={}){const{execSync}=require("child_process");try{return execSync(command,{encoding:"utf-8",stdio:["pipe","pipe","pipe"],...options}).trim()}catch(error){return null}}/**
94
- * 从远程仓库获取配置文件内容
95
- * 使用 git clone 方式获取,读取后删除临时目录
96
- *
97
- * @param {string} appName - 应用名称,对应远程仓库中的文件夹名
98
- * @returns {Promise<object|null>} 远程配置对象,失败返回 null
88
+ const REMOTE_CONFIG_REPO="https://git.woa.com/maclerylin/playable-build-config.git";/**
89
+ * 执行命令并返回结果
90
+ * @param {string} command - 要执行的命令
91
+ * @param {object} options - execSync 选项
92
+ * @returns {string|null} 命令输出,失败返回 null
93
+ */function execCommand(command,options={}){const{execSync}=require("child_process");try{return execSync(command,{encoding:"utf-8",stdio:["pipe","pipe","pipe"],...options}).trim()}catch(error){return null}}/**
94
+ * 从远程仓库获取配置文件内容
95
+ * 使用 git clone 方式获取,读取后删除临时目录
96
+ *
97
+ * @param {string} appName - 应用名称,对应远程仓库中的文件夹名
98
+ * @returns {Promise<object|null>} 远程配置对象,失败返回 null
99
99
  */async function fetchRemoteConfig(appName){if(!appName)return null;const https=require("https");const http=require("http");const url=require("url");// 构建远程配置文件 URL
100
100
  const configUrl=`https://wd-act.woa.com/aix/playable-build-config/${encodeURIComponent(appName)}/build.config.js`;log(`尝试从远程 URL 获取配置: ${appName}`,"info");log(`URL: ${configUrl}`,"debug");try{// 发起 HTTP 请求获取配置文件
101
101
  const configContent=await new Promise((resolve,reject)=>{const parsedUrl=url.parse(configUrl);const protocol=parsedUrl.protocol==="https:"?https:http;const request=protocol.get(configUrl,response=>{if(response.statusCode!==200){reject(new Error(`HTTP ${response.statusCode}: ${response.statusMessage}`));return}let data="";response.on("data",chunk=>data+=chunk);response.on("end",()=>resolve(data))});request.on("error",reject);request.setTimeout(30000,()=>{request.destroy();reject(new Error("Request timeout"))})});log("\u2713 \u8FDC\u7A0B\u914D\u7F6E\u6587\u4EF6\u4E0B\u8F7D\u6210\u529F","success");// 使用 vm 模块执行配置文件,避免 require 缓存问题
102
- const vm=require("vm");const sandbox={module:{exports:{}},exports:{},require:require};sandbox.exports=sandbox.module.exports;try{vm.runInNewContext(configContent,sandbox);log(`✓ 成功加载远程配置: ${appName}`,"success");return sandbox.module.exports}catch(parseError){log(`解析远程配置文件失败: ${parseError.message}`,"error");log(`配置内容预览: ${configContent.substring(0,200)}...`,"debug");return null}}catch(error){log(`获取远程配置失败: ${error.message}`,"warn");return null}}/**
103
- * 加载配置文件
104
- *
105
- * 优先级: 本地 builds.config.js > 远程配置 > build.json > 默认值
106
- *
107
- * 配置合并逻辑:
108
- * 1. 首先读取本地 builds.config.js
109
- * 2. 如果配置了 appName,从远程仓库拉取对应配置并合并(本地优先)
110
- * 3. 读取 build.json 作为补充(向后兼容)
111
- *
112
- * 兼容性说明:
113
- * - builds.config.js: 新的推荐配置文件,支持所有配置项
114
- * - build.json: 旧的配置文件,向后兼容,仅读取 app, name, google_play_url, app_store_url
115
- *
116
- * 字段映射:
117
- * - build.json.app + build.json.name → title (如果 builds.config.js 未指定)
118
- * - build.json.google_play_url → googlePlayUrl
119
- * - build.json.app_store_url → appStoreUrl
120
- *
121
- * 忽略字段:
122
- * - filename: 由批量构建自动生成
123
- * - version: 不在批量构建中使用
102
+ const vm=require("vm");const sandbox={module:{exports:{}},exports:{},require:require};sandbox.exports=sandbox.module.exports;try{vm.runInNewContext(configContent,sandbox);log(`✓ 成功加载远程配置: ${appName}`,"success");return sandbox.module.exports}catch(parseError){log(`解析远程配置文件失败: ${parseError.message}`,"error");log(`配置内容预览: ${configContent.substring(0,200)}...`,"debug");return null}}catch(error){log(`获取远程配置失败: ${error.message}`,"warn");return null}}/**
103
+ * 加载配置文件
104
+ *
105
+ * 优先级: 本地 builds.config.js > 远程配置 > build.json > 默认值
106
+ *
107
+ * 配置合并逻辑:
108
+ * 1. 首先读取本地 builds.config.js
109
+ * 2. 如果配置了 appName,从远程仓库拉取对应配置并合并(本地优先)
110
+ * 3. 读取 build.json 作为补充(向后兼容)
111
+ *
112
+ * 兼容性说明:
113
+ * - builds.config.js: 新的推荐配置文件,支持所有配置项
114
+ * - build.json: 旧的配置文件,向后兼容,仅读取 app, name, google_play_url, app_store_url
115
+ *
116
+ * 字段映射:
117
+ * - build.json.app + build.json.name → title (如果 builds.config.js 未指定)
118
+ * - build.json.google_play_url → googlePlayUrl
119
+ * - build.json.app_store_url → appStoreUrl
120
+ *
121
+ * 忽略字段:
122
+ * - filename: 由批量构建自动生成
123
+ * - version: 不在批量构建中使用
124
124
  */async function loadConfig(projectRoot){var _localConfig;const buildsConfigPath=path.join(projectRoot,"builds.config.js");const buildJsonPath=path.join(projectRoot,"build.json");let config={...DEFAULT_CONFIG};let localConfig=null;// 1. 先尝试加载本地 builds.config.js
125
125
  if(fs.existsSync(buildsConfigPath)){log("\u52A0\u8F7D\u914D\u7F6E\u6587\u4EF6: builds.config.js","debug");// 清除 require 缓存,确保读取最新配置
126
126
  delete require.cache[buildsConfigPath];localConfig=require(buildsConfigPath)}else{log("\u672A\u627E\u5230 builds.config.js\uFF0C\u4F7F\u7528\u9ED8\u8BA4\u914D\u7F6E","debug")}// 2. 如果配置了 appName,从远程仓库拉取配置并合并
@@ -130,10 +130,10 @@ if(localConfig){config=mergeConfig(config,localConfig)}// 5. 尝试加载 build.
130
130
  if(fs.existsSync(buildJsonPath)){try{const buildJson=JSON.parse(fs.readFileSync(buildJsonPath,"utf8"));log("\u68C0\u6D4B\u5230 build.json\uFF0C\u8BFB\u53D6\u517C\u5BB9\u914D\u7F6E","debug");// 合并 app + name 为 title(仅当 builds.config.js 未指定时)
131
131
  if(!config.title||config.title===DEFAULT_CONFIG.title){if(buildJson.app&&buildJson.name){config.title=`${buildJson.app} ${buildJson.name}`;log(` ├─ title: "${config.title}" (来自 build.json)`,"debug")}else if(buildJson.app){config.title=buildJson.app;log(` ├─ title: "${config.title}" (来自 build.json.app)`,"debug")}else if(buildJson.name){config.title=buildJson.name;log(` ├─ title: "${config.title}" (来自 build.json.name)`,"debug")}}// 读取商店链接(如果 builds.config.js 未指定)
132
132
  if(buildJson.google_play_url&&!config.googlePlayUrl){config.googlePlayUrl=buildJson.google_play_url;log(` ├─ googlePlayUrl: "${config.googlePlayUrl}" (来自 build.json)`,"debug")}if(buildJson.app_store_url&&!config.appStoreUrl){config.appStoreUrl=buildJson.app_store_url;log(` └─ appStoreUrl: "${config.appStoreUrl}" (来自 build.json)`,"debug")}// 忽略的字段
133
- if(buildJson.filename){log(" \u26A0\uFE0F build.json.filename \u5C06\u88AB\u5FFD\u7565\uFF08\u7531\u6279\u91CF\u6784\u5EFA\u81EA\u52A8\u751F\u6210\uFF09","debug")}if(buildJson.version){log(" \u26A0\uFE0F build.json.version \u5C06\u88AB\u5FFD\u7565\uFF08\u4E0D\u5728\u6279\u91CF\u6784\u5EFA\u4E2D\u4F7F\u7528\uFF09","debug")}}catch(error){log(`⚠️ 解析 build.json 失败: ${error.message}`,"warn")}}console.log("config",config);return config}/**
134
- * 深度合并配置
135
- */function mergeConfig(defaultConfig,userConfig){const result={...defaultConfig};for(const key in userConfig){if(typeof userConfig[key]==="object"&&!Array.isArray(userConfig[key])){result[key]={...defaultConfig[key],...userConfig[key]}}else{result[key]=userConfig[key]}}return result}/**
136
- * 打印配置摘要
133
+ if(buildJson.filename){log(" \u26A0\uFE0F build.json.filename \u5C06\u88AB\u5FFD\u7565\uFF08\u7531\u6279\u91CF\u6784\u5EFA\u81EA\u52A8\u751F\u6210\uFF09","debug")}if(buildJson.version){log(" \u26A0\uFE0F build.json.version \u5C06\u88AB\u5FFD\u7565\uFF08\u4E0D\u5728\u6279\u91CF\u6784\u5EFA\u4E2D\u4F7F\u7528\uFF09","debug")}}catch(error){log(`⚠️ 解析 build.json 失败: ${error.message}`,"warn")}}console.log("config",config);return config}/**
134
+ * 深度合并配置
135
+ */function mergeConfig(defaultConfig,userConfig){const result={...defaultConfig};for(const key in userConfig){if(typeof userConfig[key]==="object"&&!Array.isArray(userConfig[key])){result[key]={...defaultConfig[key],...userConfig[key]}}else{result[key]=userConfig[key]}}return result}/**
136
+ * 打印配置摘要
137
137
  */function printSummary(config,themes,channels){var _os$cpus$;const totalBuilds=themes.length*channels.length;const cpuCount=os.cpus().length;const cpuModel=((_os$cpus$=os.cpus()[0])===null||_os$cpus$===void 0?void 0:_os$cpus$.model)||"Unknown";log("========================================");log("\u5F00\u59CB\u6279\u91CF\u6784\u5EFA\u4EFB\u52A1");log(`项目: ${config.title}`);log(`主题数: ${themes.length}`);log(`渠道数: ${channels.length}`);log(`总构建数: ${totalBuilds}`);log(`CPU: ${cpuCount} 核心 (${cpuModel.substring(0,40)}${cpuModel.length>40?"...":""})`);log(`并发数: ${config.build.parallel} (自动优化)`);log("========================================\n");if(themes.length<=10){log(`主题列表: ${themes.join(", ")}`,"debug")}log(`渠道列表: ${channels.join(", ")}`,"debug");log("")}// ==================== 主函数 ====================
138
138
  async function buildBatch(){const projectRoot=process.cwd();const startTime=Date.now();try{// 加载配置(包含远程配置合并)
139
139
  const config=await loadConfig(projectRoot);// 解析命令行参数
@@ -157,7 +157,7 @@ channels.forEach(channel=>{allResults.push({success:false,theme,channel,error:"T
157
157
  let themeResults;try{themeResults=await buildChannelsInParallel(config,theme,channels,outputDir,projectRoot,isChannelFold2Zip)}finally{// Always restore schema after building this theme
158
158
  if(schemaPath)restoreSchema(schemaPath);activeSchemaPath=null}allResults.push(...themeResults);// 上传 preview 渠道产物到 COS
159
159
  const previewResult=themeResults.find(r=>r.channel==="preview"&&r.success);if(previewResult&&credentials){try{const previewDir=path.join(outputDir,theme,"preview");// 去掉 theme 这一级目录,直接使用 PathPrefix
160
- const cosPrefix=config.PathPrefix||"";log(`开始上传 ${theme} 预览文件到 COS...`,"info");const uploadResults=await uploadDirectoryToCOS(null,// 不传递 cosConfig,使用传入的凭证
160
+ const cosPrefix=config.PathPrefix||"playable/preview";log(`开始上传 ${theme} 预览文件到 COS...`,"info");const uploadResults=await uploadDirectoryToCOS(null,// 不传递 cosConfig,使用传入的凭证
161
161
  previewDir,cosPrefix,credentials// 传入凭证
162
162
  );// 保存预览链接
163
163
  uploadResults.forEach(result=>{previewLinks.push({theme,file:result.file,url:result.url})});log(`✓ ${theme} 预览文件上传成功`,"success");// 更新 preview.json 文件(使用同一个凭证)
@@ -1,57 +1,49 @@
1
1
  #!/usr/bin/env node
2
- "use strict";/**
3
- * COS 上传模块
4
- *
5
- * 功能:上传文件到腾讯云 COS,并刷新 CDN 缓存
6
- */const COS=require("cos-nodejs-sdk-v5");const fs=require("fs");const path=require("path");/**
7
- * 从远程 API 获取临时凭证
8
- */async function fetchTemporaryCredentials(){const https=require("https");const credentialsUrl="https://wd-act.woa.com/aix/playable/api/cos/credentials";return new Promise((resolve,reject)=>{https.get(credentialsUrl,response=>{if(response.statusCode!==200){reject(new Error(`HTTP ${response.statusCode}: ${response.statusMessage}`));return}let data="";response.on("data",chunk=>data+=chunk);response.on("end",()=>{try{const result=JSON.parse(data);if(result.code==="SUCCESS"){resolve(result.data)}else{reject(new Error(result.message||"Failed to get credentials"))}}catch(error){reject(new Error(`Failed to parse response: ${error.message}`))}})}).on("error",reject).setTimeout(30000,function(){this.destroy();reject(new Error("Request timeout"))})})}/**
9
- * 创建 COS 客户端(使用临时凭证)
10
- */async function createCOSClient(cosConfig){// 如果配置中提供了 SecretId SecretKey,使用传统方式
11
- if(cosConfig&&cosConfig.SecretId&&cosConfig.SecretKey){return new COS({SecretId:cosConfig.SecretId,SecretKey:cosConfig.SecretKey})}// 否则使用临时凭证 API
12
- const credentials=await fetchTemporaryCredentials();return new COS({getAuthorization:(options,callback)=>{callback({TmpSecretId:credentials.credentials.tmpSecretId,TmpSecretKey:credentials.credentials.tmpSecretKey,SecurityToken:credentials.credentials.sessionToken,StartTime:credentials.startTime,ExpiredTime:credentials.expiredTime})}})}/**
13
- * 刷新 CDN 缓存
14
- *
15
- * @param {Object} cosConfig - COS 配置
16
- * @param {Array<string>} urls - 需要刷新的 URL 列表
17
- * @param {Object} credentials - 可选的凭证对象
18
- * @returns {Promise<void>}
19
- */async function purgeCDNCache(cosConfig,urls,credentials=null){if(!urls||urls.length===0){return}// 如果没有配置 CDN 刷新,跳过
20
- const cdnDomain=(cosConfig===null||cosConfig===void 0?void 0:cosConfig.CdnDomain)||"static.aix.intlgame.com";if(!cdnDomain){console.log("\u26A0\uFE0F \u672A\u914D\u7F6E CdnDomain\uFF0C\u8DF3\u8FC7 CDN \u5237\u65B0");return}try{// 动态加载 CDN SDK
21
- const tencentcloud=require("tencentcloud-sdk-nodejs-cdn");const CdnClient=tencentcloud.cdn.v20180606.Client;// 获取凭证
22
- let secretId,secretKey;if(cosConfig&&cosConfig.SecretId&&cosConfig.SecretKey){// 使用配置中的凭证
23
- secretId=cosConfig.SecretId;secretKey=cosConfig.SecretKey}else{// 使用临时凭证(优先使用传入的凭证,否则重新获取)
24
- if(!credentials){credentials=await fetchTemporaryCredentials()}secretId=credentials.credentials.tmpSecretId;secretKey=credentials.credentials.tmpSecretKey}const clientConfig={credential:{secretId,secretKey},region:"",// CDN 是全球服务,不需要指定 region
25
- profile:{httpProfile:{endpoint:"cdn.tencentcloudapi.com"}}};const client=new CdnClient(clientConfig);// CDN 刷新区域(从配置中读取,默认为 overseas)
26
- // mainland: 中国境内
27
- // overseas: 中国境外
28
- // global: 全球
29
- const area=(cosConfig===null||cosConfig===void 0?void 0:cosConfig.CdnArea)||"overseas";// 每次最多刷新 1000 URL(腾讯云限制)
30
- const batchSize=1000;for(let i=0;i<urls.length;i+=batchSize){const batch=urls.slice(i,i+batchSize);const params={Urls:batch,Area:area};await client.PurgeUrlsCache(params);console.log(`✓ 已刷新 ${batch.length} 个 URL 的 CDN 缓存 (区域: ${area})`)}}catch(error){// 如果没有安装 CDN SDK,给出提示
31
- if(error.code==="MODULE_NOT_FOUND"){console.warn("\u26A0\uFE0F \u672A\u5B89\u88C5 tencentcloud-sdk-nodejs-cdn\uFF0C\u8DF3\u8FC7 CDN \u5237\u65B0");console.warn(" \u5B89\u88C5\u547D\u4EE4: npm install tencentcloud-sdk-nodejs-cdn --save-dev")}else{console.error(`CDN 刷新失败: ${error.message}`);// 不抛出错误,避免影响上传流程
32
- }}}/**
33
- * 上传单个文件到 COS
34
- *
35
- * @param {Object} cosConfig - COS 配置
36
- * @param {string} filePath - 本地文件路径
37
- * @param {string} cosKey - COS 对象键(远程路径)
38
- * @param {Object} credentials - 可选的凭证对象
39
- * @returns {Promise<string>} - 上传成功返回访问链接
2
+ "use strict";/**
3
+ * COS 上传模块
4
+ *
5
+ * 功能:上传文件到腾讯云 COS,并刷新 CDN 缓存
6
+ */const COS=require("cos-nodejs-sdk-v5");const fs=require("fs");const path=require("path");/**
7
+ * 从远程 API 获取临时凭证
8
+ */async function fetchTemporaryCredentials(){const https=require("https");const credentialsUrl="https://wd-act.woa.com/aix/playable/api/cos/credentials";return new Promise((resolve,reject)=>{https.get(credentialsUrl,response=>{if(response.statusCode!==200){reject(new Error(`HTTP ${response.statusCode}: ${response.statusMessage}`));return}let data="";response.on("data",chunk=>data+=chunk);response.on("end",()=>{try{const result=JSON.parse(data);if(result.code==="SUCCESS"){resolve(result.data)}else{reject(new Error(result.message||"Failed to get credentials"))}}catch(error){reject(new Error(`Failed to parse response: ${error.message}`))}})}).on("error",reject).setTimeout(30000,function(){this.destroy();reject(new Error("Request timeout"))})})}/**
9
+ * 创建 COS 客户端(使用临时凭证)
10
+ * @param {Object} cosConfig - COS 配置
11
+ * @param {Object} credentials - 已获取的临时凭证(可选,避免重复请求)
12
+ */async function createCOSClient(cosConfig,credentials=null){// 如果配置中提供了 SecretId 和 SecretKey,使用传统方式
13
+ if(cosConfig&&cosConfig.SecretId&&cosConfig.SecretKey){return new COS({SecretId:cosConfig.SecretId,SecretKey:cosConfig.SecretKey})}// 使用已传入的凭证,或重新获取
14
+ if(!credentials){credentials=await fetchTemporaryCredentials()}return new COS({getAuthorization:(options,callback)=>{callback({TmpSecretId:credentials.credentials.tmpSecretId,TmpSecretKey:credentials.credentials.tmpSecretKey,SecurityToken:credentials.credentials.sessionToken,StartTime:credentials.startTime,ExpiredTime:credentials.expiredTime})}})}/**
15
+ * 刷新 CDN 缓存
16
+ * 通过服务端 API 代理执行(STS 临时凭证无法携带 CDN 权限,需由服务端使用主账号凭证刷新)
17
+ *
18
+ * @param {Object} cosConfig - COS 配置(如有 SecretId/SecretKey 则本地直接刷新)
19
+ * @param {Array<string>} urls - 需要刷新的 URL 列表
20
+ * @param {Object} credentials - 未使用,保留参数兼容性
21
+ * @returns {Promise<void>}
22
+ */async function purgeCDNCache(cosConfig,urls,credentials=null){if(!urls||urls.length===0){return}const area=(cosConfig===null||cosConfig===void 0?void 0:cosConfig.CdnArea)||"overseas";// 如果配置中有 SecretId/SecretKey,本地直接刷新
23
+ if(cosConfig&&cosConfig.SecretId&&cosConfig.SecretKey){try{const tencentcloud=require("tencentcloud-sdk-nodejs-cdn");const client=new tencentcloud.cdn.v20180606.Client({credential:{secretId:cosConfig.SecretId,secretKey:cosConfig.SecretKey},region:"",profile:{httpProfile:{endpoint:"cdn.tencentcloudapi.com"}}});const batchSize=1000;for(let i=0;i<urls.length;i+=batchSize){const batch=urls.slice(i,i+batchSize);await client.PurgeUrlsCache({Urls:batch,Area:area});console.log(`✓ CDN 缓存已刷新 ${batch.length} 个 URL (区域: ${area})`)}}catch(error){if(error.code==="MODULE_NOT_FOUND"){console.warn("\u26A0\uFE0F \u672A\u5B89\u88C5 tencentcloud-sdk-nodejs-cdn\uFF0C\u8DF3\u8FC7 CDN \u5237\u65B0")}else{throw error}}return}// 否则通过服务端 API 代理刷新
24
+ const https=require("https");const purgeUrl="https://wd-act.woa.com/aix/playable/api/cdn/purge";const postData=JSON.stringify({urls,area});return new Promise((resolve,reject)=>{const req=https.request(purgeUrl,{method:"POST",headers:{"Content-Type":"application/json","Content-Length":Buffer.byteLength(postData)}},response=>{let data="";response.on("data",chunk=>data+=chunk);response.on("end",()=>{try{const result=JSON.parse(data);if(result.code==="SUCCESS"){console.log(`✓ CDN 缓存已刷新 ${urls.length} 个 URL (区域: ${area})`);resolve()}else{reject(new Error(result.message||"CDN purge failed"))}}catch(error){reject(new Error(`Failed to parse CDN purge response: ${error.message}`))}})});req.on("error",reject);req.setTimeout(30000,function(){this.destroy();reject(new Error("CDN purge request timeout"))});req.write(postData);req.end()})}/**
25
+ * 上传单个文件到 COS
26
+ *
27
+ * @param {Object} cosConfig - COS 配置
28
+ * @param {string} filePath - 本地文件路径
29
+ * @param {string} cosKey - COS 对象键(远程路径)
30
+ * @param {Object} credentials - 可选的凭证对象
31
+ * @returns {Promise<string>} - 上传成功返回访问链接
40
32
  */async function uploadFileToCOS(cosConfig,filePath,cosKey,credentials=null){if(!fs.existsSync(filePath)){throw new Error(`文件不存在: ${filePath}`)}// 获取临时凭证和配置信息
41
33
  let bucket,region,cdnDomain;if(cosConfig&&cosConfig.Bucket&&cosConfig.Region){// 使用配置文件中的信息
42
34
  bucket=cosConfig.Bucket;region=cosConfig.Region;cdnDomain=cosConfig.CdnDomain||"static.aix.intlgame.com"}else{// 从临时凭证 API 获取配置(优先使用传入的凭证,否则重新获取)
43
35
  if(!credentials){credentials=await fetchTemporaryCredentials()}bucket=credentials.bucket;region=credentials.region;cdnDomain="static.aix.intlgame.com"}const cos=await createCOSClient(cosConfig||null,credentials);return new Promise((resolve,reject)=>{cos.putObject({Bucket:bucket,Region:region,Key:cosKey,Body:fs.createReadStream(filePath)},(err,data)=>{if(err){reject(new Error(`上传失败: ${err.message}`))}else{// 生成访问链接
44
36
  // 对路径的每个部分分别进行 URL 编码
45
- const pathParts=cosKey.split("/");const encodedPath=pathParts.map(part=>encodeURIComponent(part)).join("/");const url=`https://${cdnDomain}/${encodedPath}`;resolve(url)}})})}/**
46
- * 上传目录下的所有文件到 COS
47
- *
48
- * @param {Object} cosConfig - COS 配置
49
- * @param {string} localDir - 本地目录路径
50
- * @param {string} cosPrefix - COS 路径前缀
51
- * @param {Object} credentials - 可选的凭证对象
52
- * @returns {Promise<Array>} - 上传成功返回文件列表
37
+ const pathParts=cosKey.split("/");const encodedPath=pathParts.map(part=>encodeURIComponent(part)).join("/");const url=`https://${cdnDomain}/${encodedPath}`;resolve(url)}})})}/**
38
+ * 上传目录下的所有文件到 COS
39
+ *
40
+ * @param {Object} cosConfig - COS 配置
41
+ * @param {string} localDir - 本地目录路径
42
+ * @param {string} cosPrefix - COS 路径前缀
43
+ * @param {Object} credentials - 可选的凭证对象
44
+ * @returns {Promise<Array>} - 上传成功返回文件列表
53
45
  */async function uploadDirectoryToCOS(cosConfig,localDir,cosPrefix,credentials=null){if(!fs.existsSync(localDir)){throw new Error(`目录不存在: ${localDir}`)}// 如果没有传入凭证且没有配置,获取一次凭证用于所有文件上传
54
46
  if(!credentials&&(!cosConfig||!cosConfig.Bucket||!cosConfig.Region)){try{credentials=await fetchTemporaryCredentials();console.log("\u2713 \u83B7\u53D6\u4E34\u65F6\u51ED\u8BC1\u6210\u529F")}catch(error){throw new Error(`获取临时凭证失败: ${error.message}`)}}const files=fs.readdirSync(localDir);const results=[];const uploadedUrls=[];for(const file of files){const filePath=path.join(localDir,file);const stat=fs.statSync(filePath);if(stat.isFile()){// 去掉文件名中的 -preview 或 _preview 后缀
55
47
  let cleanFileName=file;cleanFileName=cleanFileName.replace(/-preview\./i,".");cleanFileName=cleanFileName.replace(/_preview\./i,".");const cosKey=cosPrefix?`${cosPrefix}/${cleanFileName}`:cleanFileName;// 传入凭证,避免重复获取
56
48
  const url=await uploadFileToCOS(cosConfig,filePath,cosKey,credentials);results.push({file:cleanFileName,url});uploadedUrls.push(url)}}// 上传完成后刷新 CDN 缓存,传入凭证
57
- if(uploadedUrls.length>0){await purgeCDNCache(cosConfig,uploadedUrls,credentials)}return results}module.exports={fetchTemporaryCredentials,createCOSClient,uploadFileToCOS,uploadDirectoryToCOS,purgeCDNCache};
49
+ if(uploadedUrls.length>0){try{await purgeCDNCache(cosConfig,uploadedUrls,credentials);console.log(`✓ CDN 缓存刷新成功 (${uploadedUrls.length} 个文件)`)}catch(error){console.warn(`⚠️ CDN 刷新失败: ${error.message}`)}}return results}module.exports={fetchTemporaryCredentials,createCOSClient,uploadFileToCOS,uploadDirectoryToCOS,purgeCDNCache};