@playcraft/devkit 1.0.31-beta.1 → 1.0.31-beta.10

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 (33) hide show
  1. package/cli/commands/build.js +1 -1
  2. package/cli/commands/pack.js +8 -6
  3. package/core/batch-build.js +2 -4
  4. package/core/engines/cocos/clean-cache.js +14 -0
  5. package/core/engines/cocos/clean-cache.test.js +1 -0
  6. package/core/engines/cocos/cli-build.js +2 -2
  7. package/core/engines/cocos/cli.js +1 -1
  8. package/core/engines/cocos/constants.js +1 -1
  9. package/core/engines/cocos/dev-server.js +4 -2
  10. package/core/engines/cocos/fs.js +1 -1
  11. package/core/engines/cocos/merge.js +2 -2
  12. package/core/engines/cocos/pack.js +1 -1
  13. package/core/engines/cocos/preview-cache.js +5 -0
  14. package/core/engines/cocos/preview-cache.test.js +1 -0
  15. package/core/engines/cocos/preview-concat.js +8 -0
  16. package/core/engines/cocos/preview-concat.test.js +1 -0
  17. package/core/engines/cocos/preview-json-hook.js +1 -0
  18. package/core/engines/cocos/preview-json-pack.js +5 -0
  19. package/core/engines/cocos/preview-json-pack.test.js +2 -0
  20. package/core/engines/cocos/preview-proxy.js +1 -0
  21. package/core/engines/cocos/rebundle-preview.js +4 -0
  22. package/core/engines/cocos/theme-assets.js +2 -2
  23. package/core/engines/cocos/writable-cli.js +4 -0
  24. package/core/engines/cocos/writable-cli.test.js +1 -0
  25. package/core/engines/web/webpack.common.js +1 -1
  26. package/core/index.d.ts +3 -0
  27. package/core/index.js +1 -1
  28. package/core/options.js +1 -1
  29. package/core/validators/index.d.ts +4 -0
  30. package/core/validators/pre-build-checker.js +1 -1
  31. package/core/validators/tracking-validator.js +15 -15
  32. package/core/validators/tracking-validator.test.js +10 -0
  33. package/package.json +1 -1
@@ -21,7 +21,7 @@ function handleInterrupt(){if(isExiting)return;isExiting=true;console.log("\n\x1
21
21
  // 支持通过环境变量控制是否跳过检查
22
22
  // - SKIP_PRE_BUILD_CHECKS=1: 强制跳过检查
23
23
  // - PLAYABLE_BATCH_MODE=1: 批量构建模式,子进程自动跳过检查(主进程已执行)
24
- const isBatchMode=process.env.PLAYABLE_BATCH_MODE==="1";const shouldSkipChecks=process.env.SKIP_PRE_BUILD_CHECKS==="1"||isBatchMode;async function runPreChecks(){if(shouldSkipChecks){console.log("\n\u26A0\uFE0F \u8DF3\u8FC7\u7F16\u8BD1\u524D\u68C0\u67E5\uFF08SKIP_PRE_BUILD_CHECKS=1\uFF09\n");return}const checkResult=await runPreBuildChecks(projectRoot,{verbose:false,showDetails:true,failFast:false,themes:themeName?[themeName]:undefined,channels:options["network"]?[options["network"]]:undefined});if(!checkResult.success){console.error("\n\u274C \u7F16\u8BD1\u524D\u68C0\u67E5\u5931\u8D25\uFF0C\u7EC8\u6B62\u6784\u5EFA\u3002");console.error("\u63D0\u793A\uFF1A\u5982\u9700\u8DF3\u8FC7\u68C0\u67E5\uFF0C\u53EF\u8BBE\u7F6E\u73AF\u5883\u53D8\u91CF: SKIP_PRE_BUILD_CHECKS=1\n");process.exit(1)}}// 执行编译前检查(异步)
24
+ const isBatchMode=process.env.PLAYABLE_BATCH_MODE==="1";const shouldSkipChecks=process.env.SKIP_PRE_BUILD_CHECKS==="1"||isBatchMode;async function runPreChecks(){if(shouldSkipChecks){console.log("\n\u26A0\uFE0F \u8DF3\u8FC7\u7F16\u8BD1\u524D\u68C0\u67E5\uFF08SKIP_PRE_BUILD_CHECKS=1\uFF09\n");return}const checkResult=await runPreBuildChecks(projectRoot,{verbose:false,showDetails:true,failFast:false,themes:themeName?[themeName]:undefined,channels:options["network"]?[options["network"]]:undefined,engine:options.engine});if(!checkResult.success){console.error("\n\u274C \u7F16\u8BD1\u524D\u68C0\u67E5\u5931\u8D25\uFF0C\u7EC8\u6B62\u6784\u5EFA\u3002");console.error("\u63D0\u793A\uFF1A\u5982\u9700\u8DF3\u8FC7\u68C0\u67E5\uFF0C\u53EF\u8BBE\u7F6E\u73AF\u5883\u53D8\u91CF: SKIP_PRE_BUILD_CHECKS=1\n");process.exit(1)}}// 执行编译前检查(异步)
25
25
  runPreChecks().then(()=>{// 检查通过后继续原有流程
26
26
  continueWithBuild()}).catch(err=>{console.error("\n\u274C \u7F16\u8BD1\u524D\u68C0\u67E5\u51FA\u9519:",err);process.exit(1)});function continueWithBuild(){// 当前广告渠道(用于决定是否启用 social_media 的 OG meta 注入)
27
27
  const adNetwork=options["network"]||"";// In batch mode (PLAYABLE_BATCH_MODE=1), the parent process has already
@@ -1,5 +1,7 @@
1
1
  "use strict";const fs=require("fs");const path=require("path");const yazl=require("yazl");// ==================== 配置 ====================
2
2
  const projectRoot=process.cwd();/**
3
+ * 内置忽略:即使已提交且未写进 .gitignore / .playcraftignore,打包时也排除。
4
+ */const BUILTIN_IGNORE_PATTERNS=["package-lock.json","npm-shrinkwrap.json","yarn.lock","pnpm-lock.yaml","bun.lock","bun.lockb"];/**
3
5
  * 解析 appName,依次从 builds.config.js、build.json 或 CLI 参数中获取。
4
6
  * 优先级: CLI --app-name > builds.config.js > build.json > 文件夹名称
5
7
  * @returns {string}
@@ -24,10 +26,10 @@ if(line.startsWith("!")){console.log(`\x1b[33m⚠️ 不支持取反模式,
24
26
  // - 移除前导 ./(gitignore 中 ./foo 等同于 foo)
25
27
  let normalized=line.replace(/\/+$/,"");normalized=normalized.replace(/^\.\//g,"");patterns.push(normalized)}return patterns}/**
26
28
  * 解析 .gitignore 和 .playcraftignore 文件,合并并去重。
27
- * .gitignore 提供基础忽略规则,.playcraftignore 提供额外补充规则。
28
- * @returns {{ patterns: string[], sources: { gitignore: number, playcraftignore: number } }}
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}}}/**
29
+ * 内置规则始终生效;.gitignore 提供基础忽略,.playcraftignore 再补充。
30
+ * @returns {{ patterns: string[], sources: { builtin: number, gitignore: number, playcraftignore: number } }}
31
+ */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\u4EC5\u5E94\u7528\u5185\u7F6E\u5FFD\u7565\u89C4\u5219\u3002\x1B[0m")}// 合并并去重(内置 → .gitignore → .playcraftignore)
32
+ const seen=new Set;const merged=[];for(const p of[...BUILTIN_IGNORE_PATTERNS,...gitignorePatterns,...playcraftPatterns]){if(!seen.has(p)){seen.add(p);merged.push(p)}}return{patterns:merged,sources:{builtin:BUILTIN_IGNORE_PATTERNS.length,gitignore:gitignorePatterns.length,playcraftignore:playcraftPatterns.length}}}/**
31
33
  * 将 gitignore 风格的模式转换为正则表达式。
32
34
  *
33
35
  * gitignore 语义:
@@ -75,7 +77,7 @@ const outputDir=path.dirname(outputPath);if(!fs.existsSync(outputDir)){fs.mkdirS
75
77
  * @returns {string}
76
78
  */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
79
  async function main(){console.log("\x1B[36m\uD83D\uDCE6 playable-scripts \u6253\u5305\x1B[0m\n");// 1. 解析 appName
78
- const appName=resolveAppName();console.log(` 应用名称: \x1b[1m${appName}\x1b[0m`);// 2. 解析忽略规则(合并 .gitignore + .playcraftignore)
79
- const{patterns,sources}=parseIgnoreFiles();const ignoreRegexes=patterns.map(patternToRegex);if(patterns.length>0){const parts=[];if(sources.gitignore>0)parts.push(`${sources.gitignore} 条来自 .gitignore`);if(sources.playcraftignore>0)parts.push(`${sources.playcraftignore} 条来自 .playcraftignore`);console.log(` 忽略规则: \x1b[33m${patterns.length}\x1b[0m 条模式 (${parts.join(", ")})`)}// 3. 收集文件
80
+ const appName=resolveAppName();console.log(` 应用名称: \x1b[1m${appName}\x1b[0m`);// 2. 解析忽略规则(内置 lock 文件 + .gitignore + .playcraftignore)
81
+ const{patterns,sources}=parseIgnoreFiles();const ignoreRegexes=patterns.map(patternToRegex);if(patterns.length>0){const parts=[];if(sources.builtin>0)parts.push(`${sources.builtin} 条内置`);if(sources.gitignore>0)parts.push(`${sources.gitignore} 条来自 .gitignore`);if(sources.playcraftignore>0)parts.push(`${sources.playcraftignore} 条来自 .playcraftignore`);console.log(` 忽略规则: \x1b[33m${patterns.length}\x1b[0m 条模式 (${parts.join(", ")})`)}// 3. 收集文件
80
82
  console.log(` 正在扫描项目文件...`);const files=collectFiles(projectRoot,projectRoot,ignoreRegexes);if(files.length===0){console.error("\x1B[31m\u274C \u6CA1\u6709\u53EF\u6253\u5305\u7684\u6587\u4EF6\uFF01\u8BF7\u68C0\u67E5 .playcraftignore \u89C4\u5219\u3002\x1B[0m");process.exit(1)}console.log(` 文件数量: \x1b[32m${files.length}\x1b[0m 个待打包文件`);// 4. 创建 zip 压缩包
81
83
  const outDir=resolveOutputDir();const outputPath=path.resolve(projectRoot,outDir,`${appName}.zip`);console.log(` 输出路径: ${outputPath}\n`);console.log(" \u6B63\u5728\u6253\u5305...");try{const{fileCount,zipSize}=await createZip(files,appName,outputPath);console.log(`\n\x1b[32m✅ 打包完成!\x1b[0m`);console.log(` 文件数: ${fileCount}`);console.log(` 大小: ${formatSize(zipSize)}`);console.log(` 输出: ${outputPath}`)}catch(err){console.error(`\n\x1b[31m❌ 打包失败: ${err.message}\x1b[0m`);process.exit(1)}}main();
@@ -196,10 +196,8 @@ let channels=args.channels||config.channels;// 始终添加 preview 渠道,用
196
196
  if(!channels.includes("preview")){channels=[...channels,"preview"];log("\u5DF2\u6DFB\u52A0 preview \u6E20\u9053","info")}// 根据 playcraftOrientationChannel / playcraftOrientation 配置,把需要
197
197
  // 多方向版本的渠道拆分为独立的构建任务(渠道_L / 渠道_P / 渠道),
198
198
  // 后续统一交给渠道级并发调度,每个方向各自独立进程构建。
199
- const tasks=buildChannelTasks(channels,config);if(!isWebEngine(config.engine)){log(`引擎: cocos(Creator 编一次,按主题注入 json5 / assets)`,"info");printSummary(config,themes,channels,tasks);const outputDir=path.join(projectRoot,config.build.outputDir);cleanTempDir(outputDir);const allResults=await packCocosBatch({config,themes,tasks,outputDir,projectRoot,isChannelFold2Zip});const failed=allResults.filter(r=>!r.success);const elapsed=((Date.now()-startTime)/1000).toFixed(1);log(`Cocos 批量构建完成: ${allResults.length-failed.length}/${allResults.length} 成功,耗时 ${elapsed}s`,failed.length?"warn":"success");if(failed.length&&!config.build.continueOnError)process.exit(1);return}// ============================================================
200
- // 📌 编译前检查(埋点验证、主题名称检查等)- 仅在主进程执行一次
201
- // ============================================================
202
- const shouldSkipChecks=process.env.SKIP_PRE_BUILD_CHECKS==="1";if(!shouldSkipChecks){log("\u6267\u884C\u7F16\u8BD1\u524D\u68C0\u67E5\uFF08\u57CB\u70B9\u9A8C\u8BC1\u3001\u4E3B\u9898\u540D\u79F0\u68C0\u67E5\u7B49\uFF09...","info");const checkResult=await runPreBuildChecks(projectRoot,{verbose:false,showDetails:true,failFast:false,themes,channels});if(!checkResult.success){log("\u7F16\u8BD1\u524D\u68C0\u67E5\u5931\u8D25\uFF0C\u7EC8\u6B62\u6279\u91CF\u6784\u5EFA\u3002","error");log("\u63D0\u793A\uFF1A\u5982\u9700\u8DF3\u8FC7\u68C0\u67E5\uFF0C\u53EF\u8BBE\u7F6E\u73AF\u5883\u53D8\u91CF: SKIP_PRE_BUILD_CHECKS=1","warn");process.exit(1)}log("\u2713 \u7F16\u8BD1\u524D\u68C0\u67E5\u901A\u8FC7","success")}else{log("\u8DF3\u8FC7\u7F16\u8BD1\u524D\u68C0\u67E5\uFF08SKIP_PRE_BUILD_CHECKS=1\uFF09","warn")}// 打印摘要
199
+ const tasks=buildChannelTasks(channels,config);// 📌 编译前检查:webpack 与 Cocos 共用,按引擎扫不同源码目录
200
+ const shouldSkipChecks=process.env.SKIP_PRE_BUILD_CHECKS==="1";if(!shouldSkipChecks){log("\u6267\u884C\u7F16\u8BD1\u524D\u68C0\u67E5\uFF08\u57CB\u70B9\u9A8C\u8BC1\u3001\u4E3B\u9898\u540D\u79F0\u68C0\u67E5\u7B49\uFF09...","info");const checkResult=await runPreBuildChecks(projectRoot,{verbose:false,showDetails:true,failFast:false,themes,channels,engine:config.engine});if(!checkResult.success){log("\u7F16\u8BD1\u524D\u68C0\u67E5\u5931\u8D25\uFF0C\u7EC8\u6B62\u6279\u91CF\u6784\u5EFA\u3002","error");log("\u63D0\u793A\uFF1A\u5982\u9700\u8DF3\u8FC7\u68C0\u67E5\uFF0C\u53EF\u8BBE\u7F6E\u73AF\u5883\u53D8\u91CF: SKIP_PRE_BUILD_CHECKS=1","warn");process.exit(1)}log("\u2713 \u7F16\u8BD1\u524D\u68C0\u67E5\u901A\u8FC7","success")}else{log("\u8DF3\u8FC7\u7F16\u8BD1\u524D\u68C0\u67E5\uFF08SKIP_PRE_BUILD_CHECKS=1\uFF09","warn")}if(!isWebEngine(config.engine)){log(`引擎: cocos(Creator 编一次,按主题注入 json5 / assets)`,"info");printSummary(config,themes,channels,tasks);const outputDir=path.join(projectRoot,config.build.outputDir);cleanTempDir(outputDir);const allResults=await packCocosBatch({config,themes,tasks,outputDir,projectRoot,isChannelFold2Zip});const failed=allResults.filter(r=>!r.success);const elapsed=((Date.now()-startTime)/1000).toFixed(1);log(`Cocos 批量构建完成: ${allResults.length-failed.length}/${allResults.length} 成功,耗时 ${elapsed}s`,failed.length?"warn":"success");if(failed.length&&!config.build.continueOnError)process.exit(1);return}// 打印摘要
203
201
  printSummary(config,themes,channels,tasks);// 备份入口文件(如果启用主题切换)
204
202
  if(config.themes.enabled){backupEntryFile(config,projectRoot)}// 批量构建
205
203
  const outputDir=path.join(projectRoot,config.build.outputDir);const allResults=[];const previewLinks=[];// 存储预览链接
@@ -0,0 +1,14 @@
1
+ "use strict";const fs=require("fs");const path=require("path");/** Overlay of /opt/cocos-cli. Not a script cache; recreating it on Linux is expensive. */const KEEP_TEMP_DIRS=new Set(["cocos-cli"]);/** Preview merge lives here; wiping it breaks npm run dev until re-bundle (many UUID .json requests). */const PREVIEW_CACHE_RELS=["temp/programming/preview","temp/programming/packer-driver/targets/preview"];/** Other temp/* dirs safe to drop before engine build. */const TEMP_CACHE_DIRS=new Set(["asset-db","builder","declarations","logs","writablePath"]);/** Cocos packer-driver / asset-db recreate these immediately after a clean. */const WRITABLE_WORKDIR_RELS=["library","temp","temp/programming","temp/programming/packer-driver","temp/programming/packer-driver/logs","temp/builder","build"];function exists(p){try{return Boolean(p)&&fs.existsSync(p)}catch{return false}}function chmodTreeWritable(dir){try{const st=fs.lstatSync(dir);if(st.isSymbolicLink())return;fs.chmodSync(dir,st.mode|128);if(!st.isDirectory())return;for(const name of fs.readdirSync(dir)){chmodTreeWritable(path.join(dir,name))}}catch{// best-effort; rmSync may still fail and we want that error
2
+ }}function rmTree(dir){if(!exists(dir))return false;chmodTreeWritable(dir);fs.rmSync(dir,{recursive:true,force:true});return true}/**
3
+ * Drop Creator / cocos-cli project caches so packer-driver does not reuse
4
+ * import-map + chunks that still point at another machine's paths.
5
+ *
6
+ * Keeps `temp/cocos-cli` (writable CLI overlay) unless keepCliOverlay is false.
7
+ *
8
+ * @returns {string[]} relative paths that were removed
9
+ */function shouldWipePreviewCaches(options={},env=process.env){if(options.cleanPreviewCache===true)return true;if(options.cleanPreviewCache===false)return false;return env.PLAYABLE_CLEAN_PREVIEW_CACHE==="1"}function cleanProgrammingBuildCaches(projectRoot,removed,wipePreview){const programming=path.join(projectRoot,"temp","programming");if(!exists(programming))return;if(wipePreview){for(const rel of PREVIEW_CACHE_RELS){if(rmTree(path.join(projectRoot,rel)))removed.push(rel)}if(rmTree(programming)){removed.push("temp/programming");return}}const editor=path.join(programming,"packer-driver","targets","editor");if(rmTree(editor))removed.push("temp/programming/packer-driver/targets/editor");const logs=path.join(programming,"packer-driver","logs");if(rmTree(logs))removed.push("temp/programming/packer-driver/logs")}function cleanCocosProjectCaches(projectRoot,options={}){const keepOverlay=options.keepCliOverlay!==false;const wipePreview=shouldWipePreviewCaches(options,process.env);const removed=[];const library=path.join(projectRoot,"library");if(rmTree(library))removed.push("library");const tempRoot=path.join(projectRoot,"temp");if(!exists(tempRoot))return removed;if(!keepOverlay){if(rmTree(tempRoot))removed.push("temp");return removed}for(const name of fs.readdirSync(tempRoot)){if(KEEP_TEMP_DIRS.has(name))continue;if(name==="programming"){cleanProgrammingBuildCaches(projectRoot,removed,wipePreview);continue}if(TEMP_CACHE_DIRS.has(name)&&rmTree(path.join(tempRoot,name))){removed.push(`temp/${name}`)}}return removed}function ensureDir(dir){try{const st=fs.lstatSync(dir);if(st.isSymbolicLink()||!st.isDirectory()){fs.rmSync(dir,{recursive:true,force:true})}}catch(err){if(err.code!=="ENOENT")throw err}fs.mkdirSync(dir,{recursive:true,mode:511});try{fs.chmodSync(dir,511)}catch{// chmod may fail on some mounts; mkdir succeeding is enough if we own the dir.
10
+ }}/**
11
+ * Recreate the dirs cocos-cli writes on startup (winston logs, library).
12
+ * A leftover read-only `temp/programming` from git / a previous root-owned
13
+ * overlay makes `mkdir logs` throw EACCES.
14
+ */function ensureWritableCocosWorkdirs(projectRoot){const created=[];for(const rel of WRITABLE_WORKDIR_RELS){const dir=path.join(projectRoot,rel);ensureDir(dir);created.push(rel)}const probe=path.join(projectRoot,"temp","programming","packer-driver","logs",`.write-probe-${process.pid}`);try{fs.writeFileSync(probe,"ok");fs.unlinkSync(probe)}catch(err){throw new Error(`[cocos] ${path.join("temp","programming","packer-driver","logs")} is not writable (${err.code||err.message}). `+"In the sandbox image, chown the remix project to the build user and chmod u+rwX temp library build. "+"Do not chmod /opt/cocos-cli.")}return created}function shouldCleanCocosCache(buildOptions={},env=process.env){if(env.PLAYABLE_KEEP_COCOS_CACHE==="1")return false;if(env.PLAYABLE_CLEAN_COCOS_CACHE==="1")return true;const cocos=buildOptions.cocos||{};if(cocos.cleanCache===false)return false;if(cocos.cleanCache===true)return true;return Boolean(buildOptions.cleanCocosCache)}module.exports={KEEP_TEMP_DIRS,PREVIEW_CACHE_RELS,WRITABLE_WORKDIR_RELS,cleanCocosProjectCaches,ensureWritableCocosWorkdirs,shouldCleanCocosCache,shouldWipePreviewCaches};
@@ -0,0 +1 @@
1
+ "use strict";const{describe,it,afterEach}=require("node:test");const assert=require("node:assert/strict");const fs=require("fs");const os=require("os");const path=require("path");const{cleanCocosProjectCaches,ensureWritableCocosWorkdirs,shouldCleanCocosCache}=require("./clean-cache.js");function writeFile(file,body){fs.mkdirSync(path.dirname(file),{recursive:true});fs.writeFileSync(file,body)}describe("cleanCocosProjectCaches",()=>{let root;afterEach(()=>{if(root&&fs.existsSync(root))fs.rmSync(root,{recursive:true,force:true})});it("removes editor build cache but keeps preview merge + cli overlay",()=>{root=fs.mkdtempSync(path.join(os.tmpdir(),"cocos-clean-cache-"));writeFile(path.join(root,"library","uuid-to-mtime.json"),"{}");writeFile(path.join(root,"temp","programming","packer-driver","targets","editor","import-map.json"),"{}");writeFile(path.join(root,"temp","programming","packer-driver","targets","preview","chunks","playcraft-bundle.js"),"bundle");writeFile(path.join(root,"temp","programming","preview","systemjs","playcraft-json-pack.json"),"{}");writeFile(path.join(root,"temp","builder","log.txt"),"x");writeFile(path.join(root,"temp","cocos-cli","bin","cocos"),"#!/usr/bin/env node\n");const removed=cleanCocosProjectCaches(root);assert.deepEqual(new Set(removed),new Set(["library","temp/programming/packer-driver/targets/editor","temp/builder"]));assert.equal(fs.existsSync(path.join(root,"library")),false);assert.equal(fs.existsSync(path.join(root,"temp","programming","packer-driver","targets","editor")),false);assert.equal(fs.existsSync(path.join(root,"temp","programming","packer-driver","targets","preview","chunks","playcraft-bundle.js")),true);assert.equal(fs.existsSync(path.join(root,"temp","cocos-cli","bin","cocos")),true)});it("replaces a leftover programming dir with writable packer logs",()=>{root=fs.mkdtempSync(path.join(os.tmpdir(),"cocos-clean-cache-"));writeFile(path.join(root,"temp","programming","packer-driver","stale.txt"),"old");fs.chmodSync(path.join(root,"temp","programming","packer-driver"),365);cleanCocosProjectCaches(root);ensureWritableCocosWorkdirs(root);const logs=path.join(root,"temp","programming","packer-driver","logs");const probe=path.join(logs,"ok.txt");fs.writeFileSync(probe,"1");assert.equal(fs.readFileSync(probe,"utf8"),"1")});it("can wipe the overlay when keepCliOverlay is false",()=>{root=fs.mkdtempSync(path.join(os.tmpdir(),"cocos-clean-cache-"));writeFile(path.join(root,"temp","cocos-cli","bin","cocos"),"ok");const removed=cleanCocosProjectCaches(root,{keepCliOverlay:false});assert.deepEqual(removed,["temp"]);assert.equal(fs.existsSync(path.join(root,"temp")),false)})});describe("shouldCleanCocosCache",()=>{it("defaults off, env and cocos.cleanCache win",()=>{assert.equal(shouldCleanCocosCache({},{}),false);assert.equal(shouldCleanCocosCache({cocos:{cleanCache:true}},{}),true);assert.equal(shouldCleanCocosCache({cocos:{cleanCache:true}},{PLAYABLE_KEEP_COCOS_CACHE:"1"}),false);assert.equal(shouldCleanCocosCache({cocos:{cleanCache:false}},{PLAYABLE_CLEAN_COCOS_CACHE:"1"}),true)})});
@@ -1,4 +1,4 @@
1
- "use strict";const fs=require("fs");const path=require("path");const{spawnCocosSync,needsXvfb}=require("./cli.js");const{resolveCreatorOutputDir}=require("./creator-cli.js");const{stripAdsdkCjsImport}=require("./strip-social-share.js");function shouldSkipEngineBuild(buildOptions={}){const cocos=buildOptions.cocos||{};return Boolean(buildOptions.skipEngineBuild||cocos.skipEngineBuild||process.env.PLAYABLE_SKIP_ENGINE_BUILD==="1")}function resolveWebMobileDest(projectRoot,buildOptions={}){const cocos=buildOptions.cocos||{};const fromConfig=resolveCreatorOutputDir(projectRoot,cocos.buildConfig||"build-configs/web-mobile.json");const explicit=cocos.webMobileDir?path.resolve(projectRoot,cocos.webMobileDir):null;if(shouldSkipEngineBuild(buildOptions)){return explicit||fromConfig||path.resolve(projectRoot,"dist/web-mobile")}return fromConfig||explicit||path.resolve(projectRoot,"dist/web-mobile")}function logStrip(result){if(result.skipped&&result.reason==="missing"){console.warn("[cocos] adsdk CJS missing, skip social-share strip");return}if(result.skipped&&result.reason==="already"){console.log("[cocos] social-share import() already stripped");return}if(result.skipped){console.warn("[cocos] social-share import() not found in CJS");return}console.log("[cocos] stripped social-share import() from adsdk CJS")}/**
1
+ "use strict";const fs=require("fs");const path=require("path");const{spawnCocosSync,needsXvfb}=require("./cli.js");const{resolveCreatorOutputDir}=require("./creator-cli.js");const{stripAdsdkCjsImport}=require("./strip-social-share.js");const{prepareWritableCocosCli}=require("./writable-cli.js");const{cleanCocosProjectCaches,ensureWritableCocosWorkdirs,shouldCleanCocosCache}=require("./clean-cache.js");function shouldSkipEngineBuild(buildOptions={}){const cocos=buildOptions.cocos||{};return Boolean(buildOptions.skipEngineBuild||cocos.skipEngineBuild||process.env.PLAYABLE_SKIP_ENGINE_BUILD==="1")}function resolveWebMobileDest(projectRoot,buildOptions={}){const cocos=buildOptions.cocos||{};const fromConfig=resolveCreatorOutputDir(projectRoot,cocos.buildConfig||"build-configs/web-mobile.json");const explicit=cocos.webMobileDir?path.resolve(projectRoot,cocos.webMobileDir):null;if(shouldSkipEngineBuild(buildOptions)){return explicit||fromConfig||path.resolve(projectRoot,"dist/web-mobile")}return fromConfig||explicit||path.resolve(projectRoot,"dist/web-mobile")}function logStrip(result){if(result.skipped&&result.reason==="missing"){console.warn("[cocos] adsdk CJS missing, skip social-share strip");return}if(result.skipped&&result.reason==="already"){console.log("[cocos] social-share import() already stripped");return}if(result.skipped){console.warn("[cocos] social-share import() not found in CJS");return}console.log("[cocos] stripped social-share import() from adsdk CJS")}/**
2
2
  * 用官方 cocos CLI 打 web-mobile(不再走 Creator 编辑器)。
3
3
  * Linux 上自动套 xvfb-run -a(shader 编译需要虚拟显示)。
4
- */function runCocosEngineBuild(projectRoot,buildOptions={}){logStrip(stripAdsdkCjsImport(projectRoot));const dest=resolveWebMobileDest(projectRoot,buildOptions);if(shouldSkipEngineBuild(buildOptions)){if(!fs.existsSync(dest)){throw new Error(`[cocos] skipEngineBuild but missing ${path.relative(projectRoot,dest)}`)}console.log(`[cocos] skip engine build, using ${path.relative(projectRoot,dest)}`);return dest}const cocos=buildOptions.cocos||{};const configRel=cocos.buildConfig||"build-configs/web-mobile.json";const configPath=path.resolve(projectRoot,configRel);if(!fs.existsSync(configPath)){throw new Error(`[cocos] missing build config: ${configRel}`)}const args=["--no-interactive","build","--project",projectRoot,"--platform","web-mobile","--build-config",configPath];if(needsXvfb()){console.log("[cocos] linux, wrapping xvfb-run -a")}console.log(`[cocos] build web-mobile ← ${configRel}`);const result=spawnCocosSync(args,{cwd:projectRoot,stdio:"inherit",env:process.env});if(result.error){throw result.error}if(result.status!==0){throw new Error(`[cocos] build failed, exit ${result.status}`)}console.log(`[cocos] web-mobile ready at ${path.relative(projectRoot,dest)}`);return dest}module.exports={runCocosEngineBuild,resolveWebMobileDest,shouldSkipEngineBuild};
4
+ */function runCocosEngineBuild(projectRoot,buildOptions={}){logStrip(stripAdsdkCjsImport(projectRoot));const dest=resolveWebMobileDest(projectRoot,buildOptions);if(shouldSkipEngineBuild(buildOptions)){if(!fs.existsSync(dest)){throw new Error(`[cocos] skipEngineBuild but missing ${path.relative(projectRoot,dest)}`)}console.log(`[cocos] skip engine build, using ${path.relative(projectRoot,dest)}`);return dest}if(shouldCleanCocosCache(buildOptions)){const removed=cleanCocosProjectCaches(projectRoot);console.log(`[cocos] cleaned project caches: ${removed.join(", ")||"(none)"}`)}ensureWritableCocosWorkdirs(projectRoot);const cocos=buildOptions.cocos||{};const configRel=cocos.buildConfig||"build-configs/web-mobile.json";const configPath=path.resolve(projectRoot,configRel);if(!fs.existsSync(configPath)){throw new Error(`[cocos] missing build config: ${configRel}`)}const args=["--no-interactive","build","--project",projectRoot,"--platform","web-mobile","--build-config",configPath];const prepared=prepareWritableCocosCli(projectRoot);const env={...process.env,...prepared.env};if(needsXvfb()){console.log("[cocos] linux, wrapping xvfb-run -a")}console.log(`[cocos] build web-mobile ← ${configRel}`);const result=spawnCocosSync(args,{cwd:projectRoot,stdio:"inherit",env});if(result.error){throw result.error}if(result.status!==0){throw new Error(`[cocos] build failed, exit ${result.status}`)}console.log(`[cocos] web-mobile ready at ${path.relative(projectRoot,dest)}`);return dest}module.exports={runCocosEngineBuild,resolveWebMobileDest,shouldSkipEngineBuild,cleanCocosProjectCaches,ensureWritableCocosWorkdirs,shouldCleanCocosCache};
@@ -1 +1 @@
1
- "use strict";const{spawn,spawnSync}=require("child_process");function resolveCocosBin(){return process.env.COCOS_CLI||"cocos"}function needsXvfb(){return process.platform==="linux"}function cocosCommand(args){const bin=resolveCocosBin();if(needsXvfb()){return{cmd:"xvfb-run",args:["-a",bin,...args]}}return{cmd:bin,args}}function spawnCocos(args,options){const{cmd,args:fullArgs}=cocosCommand(args);return spawn(cmd,fullArgs,options)}function spawnCocosSync(args,options){const{cmd,args:fullArgs}=cocosCommand(args);return spawnSync(cmd,fullArgs,options)}module.exports={resolveCocosBin,needsXvfb,spawnCocos,spawnCocosSync};
1
+ "use strict";const{spawn,spawnSync}=require("child_process");function resolveCocosBin(env=process.env){return env.COCOS_CLI||"cocos"}function needsXvfb(){return process.platform==="linux"}function cocosCommand(args,env=process.env){const bin=resolveCocosBin(env);if(needsXvfb()){return{cmd:"xvfb-run",args:["-a",bin,...args]}}return{cmd:bin,args}}function spawnCocos(args,options={}){const env=options.env||process.env;const{cmd,args:fullArgs}=cocosCommand(args,env);return spawn(cmd,fullArgs,options)}function spawnCocosSync(args,options={}){const env=options.env||process.env;const{cmd,args:fullArgs}=cocosCommand(args,env);return spawnSync(cmd,fullArgs,options)}module.exports={resolveCocosBin,needsXvfb,spawnCocos,spawnCocosSync};
@@ -1 +1 @@
1
- "use strict";const ADAPTER_FETCH="adapterFetch";const TO_STRING_EXTNAME=[".txt",".xml",".vsh",".fsh",".atlas",".tmx",".tsx",".json",".ExportJson",".plist",".fnt",".js",".zip"];const TO_SKIP_EXTNAME=[".ico",".html",".css"];const TRANSPARENT_GIF="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7";const FILE_MAX_SIZE=2*1024*1024*0.8;const THEME_ASSET_EXT=/\.(png|webp|jpe?g|gif|svg|mp3|wav|m4a|ogg|mp4)$/i;module.exports={ADAPTER_FETCH,TO_STRING_EXTNAME,TO_SKIP_EXTNAME,TRANSPARENT_GIF,FILE_MAX_SIZE,THEME_ASSET_EXT};
1
+ "use strict";const ADAPTER_FETCH="adapterFetch";const TO_STRING_EXTNAME=[".txt",".xml",".vsh",".fsh",".atlas",".tmx",".tsx",".json",".ExportJson",".plist",".fnt",".js",".zip"];const TO_SKIP_EXTNAME=[".ico",".html",".css"];const TRANSPARENT_GIF="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7";const FILE_MAX_SIZE=2*1024*1024*0.8;const THEME_ASSET_EXT=/\.(png|webp|jpe?g|gif|svg|mp3|mpeg|wav|m4a|aac|ogg|mp4)$/i;module.exports={ADAPTER_FETCH,TO_STRING_EXTNAME,TO_SKIP_EXTNAME,TRANSPARENT_GIF,FILE_MAX_SIZE,THEME_ASSET_EXT};
@@ -1,3 +1,5 @@
1
- "use strict";const fs=require("fs");const http=require("http");const path=require("path");const{spawnCocos,needsXvfb}=require("./cli.js");const{syncGeneratedTheme}=require("./theme-assets.js");const{refreshAssets}=require("./mcp-refresh.js");const{stripAdsdkCjsImport}=require("./strip-social-share.js");function sleep(ms){return new Promise(resolve=>setTimeout(resolve,ms))}function request(url,{method="GET",timeout=8000}={}){return new Promise((resolve,reject)=>{const req=http.request(url,{method,timeout},res=>{const chunks=[];res.on("data",c=>chunks.push(c));res.on("end",()=>{resolve({status:res.statusCode||0,body:Buffer.concat(chunks).toString("utf8")})})});req.on("timeout",()=>{req.destroy();reject(new Error(`timeout ${url}`))});req.on("error",reject);req.end()})}function resolvePreviewScene(projectRoot,explicit){if(explicit)return explicit;if(process.env.PREVIEW_SCENE)return process.env.PREVIEW_SCENE;try{const cfgPath=path.join(projectRoot,"settings/cocos.config.json");if(fs.existsSync(cfgPath)){const cfg=JSON.parse(fs.readFileSync(cfgPath,"utf8"));const fromConfig=cfg.builder&&cfg.builder.common&&cfg.builder.common.startScene||cfg.builder&&cfg.builder.platforms&&cfg.builder.platforms["web-desktop"]&&cfg.builder.platforms["web-desktop"].startScene||cfg.builder&&cfg.builder.platforms&&cfg.builder.platforms["web-mobile"]&&cfg.builder.platforms["web-mobile"].startScene;if(fromConfig)return fromConfig}}catch(_){}const scenesDir=path.join(projectRoot,"assets/scenes");if(fs.existsSync(scenesDir)){const first=fs.readdirSync(scenesDir).find(name=>name.endsWith(".scene"));if(first)return`db://assets/scenes/${first}`}return""}function killSocialShareForDev(projectRoot){const result=stripAdsdkCjsImport(projectRoot);if(result.skipped&&result.reason==="missing"){console.warn("[dev] adsdk CJS missing, skip social-share strip");return}if(result.skipped&&result.reason==="already"){console.log("[dev] social-share import() already stripped");return}if(result.skipped){console.warn("[dev] social-share import() not found in CJS");return}console.log("[dev] stripped social-share import() from adsdk CJS")}function shouldIgnoreWatch(file){if(!file)return false;const base=path.basename(file);if(base===".DS_Store"||base==="theme.generated.ts")return true;if(base.startsWith(".")&&base!==".json5")return true;const ext=path.extname(file).toLowerCase();return ext===".meta"}/**
1
+ "use strict";const fs=require("fs");const http=require("http");const path=require("path");const{spawnCocos,needsXvfb}=require("./cli.js");const{syncGeneratedTheme,detectCurrentTheme,themeAssetStamp}=require("./theme-assets.js");const{refreshAssets}=require("./mcp-refresh.js");const{stripAdsdkCjsImport}=require("./strip-social-share.js");const{concatPreviewScripts}=require("./preview-concat.js");const{packPreviewJson}=require("./preview-json-pack.js");const{findFreePort,startPreviewProxy}=require("./preview-proxy.js");function sleep(ms){return new Promise(resolve=>setTimeout(resolve,ms))}function request(url,{method="GET",timeout=8000}={}){return new Promise((resolve,reject)=>{const req=http.request(url,{method,timeout},res=>{const chunks=[];res.on("data",c=>chunks.push(c));res.on("end",()=>{resolve({status:res.statusCode||0,body:Buffer.concat(chunks).toString("utf8")})})});req.on("timeout",()=>{req.destroy();reject(new Error(`timeout ${url}`))});req.on("error",reject);req.end()})}function resolvePreviewScene(projectRoot,explicit){if(explicit)return explicit;if(process.env.PREVIEW_SCENE)return process.env.PREVIEW_SCENE;try{const cfgPath=path.join(projectRoot,"settings/cocos.config.json");if(fs.existsSync(cfgPath)){const cfg=JSON.parse(fs.readFileSync(cfgPath,"utf8"));const fromConfig=cfg.builder&&cfg.builder.common&&cfg.builder.common.startScene||cfg.builder&&cfg.builder.platforms&&cfg.builder.platforms["web-desktop"]&&cfg.builder.platforms["web-desktop"].startScene||cfg.builder&&cfg.builder.platforms&&cfg.builder.platforms["web-mobile"]&&cfg.builder.platforms["web-mobile"].startScene;if(fromConfig)return fromConfig}}catch(_){}const scenesDir=path.join(projectRoot,"assets/scenes");if(fs.existsSync(scenesDir)){const first=fs.readdirSync(scenesDir).find(name=>name.endsWith(".scene"));if(first)return`db://assets/scenes/${first}`}return""}function killSocialShareForDev(projectRoot){const result=stripAdsdkCjsImport(projectRoot);if(result.skipped&&result.reason==="missing"){console.warn("[dev] adsdk CJS missing, skip social-share strip");return}if(result.skipped&&result.reason==="already"){console.log("[dev] social-share import() already stripped");return}if(result.skipped){console.warn("[dev] social-share import() not found in CJS");return}console.log("[dev] stripped social-share import() from adsdk CJS")}const PREVIEW_MERGE_MARKER=path.join("temp",".playcraft-preview-merge-ready");function previewMergeMarkerPath(projectRoot){return path.join(projectRoot,PREVIEW_MERGE_MARKER)}function clearPreviewMergeMarker(projectRoot){try{fs.unlinkSync(previewMergeMarkerPath(projectRoot))}catch{// ignore
2
+ }}function markPreviewMergeReady(projectRoot){const marker=previewMergeMarkerPath(projectRoot);fs.mkdirSync(path.dirname(marker),{recursive:true});fs.writeFileSync(marker,`${Date.now()}\n`)}function shouldIgnoreWatch(file){if(!file)return false;const base=path.basename(file);if(base===".DS_Store"||base==="theme.generated.ts")return true;if(base.startsWith(".")&&base!==".json5")return true;const ext=path.extname(file).toLowerCase();return ext===".meta"}/**
2
3
  * 常驻官方 cocos CLI 预览:start-mcp-server(同一端口既有 / 也有 /mcp)。
3
- */async function runCocosDev(opts={}){const projectRoot=opts.projectRoot||process.cwd();const port=Number(opts.port||process.env.PREVIEW_PORT||3000);const timeoutMs=Number(opts.timeoutMs||process.env.DEV_TIMEOUT_MS||180000);const debounceMs=Number(opts.debounceMs||process.env.DEV_DEBOUNCE_MS||400);const cmd=opts.cmd||process.argv[2]||"start";const scene=resolvePreviewScene(projectRoot,opts.scene);const previewUrl=scene?`http://127.0.0.1:${port}/?scene=${encodeURIComponent(scene)}`:`http://127.0.0.1:${port}/`;const watchDirs=["assets","src/theme"].filter(dir=>fs.existsSync(path.join(projectRoot,dir)));async function isPreviewUp(){try{const res=await request(previewUrl,{timeout:1500});return res.status>0&&res.status<500}catch{return false}}async function waitForPreview(){const start=Date.now();let lastLog=0;while(Date.now()-start<timeoutMs){if(await isPreviewUp())return;if(Date.now()-lastLog>=5000){console.log(`[dev] waiting for ${previewUrl}`);lastLog=Date.now()}await sleep(1000)}throw new Error(`preview not ready after ${timeoutMs}ms: ${previewUrl}`)}function printBanner(reused){console.log(`[dev] ${reused?"reusing cocos CLI":"cocos CLI ready"}`);console.log(`[dev] iframe → ${previewUrl}`);console.log(`[dev] mcp → http://127.0.0.1:${port}/mcp`);console.log(`[dev] watch ${watchDirs.map(d=>`${d}/`).join(" ")}`)}function syncThemeOnce(){try{const result=syncGeneratedTheme(projectRoot);if(!result){console.log("[dev] no theme entry; skip generated module");return false}console.log(`[dev] synced theme ${result.themeName}${result.changed===false?" (unchanged)":""}`);return result.changed!==false}catch(syncErr){console.warn(`[dev] theme sync skipped: ${syncErr.message}`);return false}}async function compile({fatal=true}={}){if(!(await isPreviewUp())){console.error(`[dev] preview is down: ${previewUrl}`);if(fatal){console.error("[dev] Run npm run dev first.");process.exit(1)}return}console.log(`[dev] assets-refresh db://assets`);try{const res=await refreshAssets(port,"db://assets");const err=res.parsed&&(res.parsed.error||res.parsed.result&&res.parsed.result.isError);console.log(`[dev] ${res.status}${err?" error":""}`);if(res.status===404){console.error("[dev] /mcp \u4E0D\u5B58\u5728\u3002\u8BF7\u505C\u6389\u5F53\u524D\u9884\u89C8\uFF0C\u91CD\u65B0 npm run dev\uFF08\u4F1A\u8D77 start-mcp-server\uFF09")}if((res.status>=400||err)&&fatal)process.exit(1)}catch(err){console.error(`[dev] compile failed: ${err.message}`);if(fatal)process.exit(1)}}function watchAndCompile(){let timer=null;let compiling=false;let pending=null;const queue=rel=>{pending=rel;if(timer)clearTimeout(timer);timer=setTimeout(()=>{timer=null;run()},debounceMs)};const run=async()=>{if(compiling)return;const rel=pending;pending=null;compiling=true;if(rel)console.log(`[dev] change ${rel}`);try{if(!rel||rel.startsWith("src/theme")){syncThemeOnce()}await compile({fatal:false})}finally{compiling=false;if(pending)queue(pending)}};for(const dir of watchDirs){const abs=path.join(projectRoot,dir);fs.watch(abs,{recursive:true},(_event,file)=>{if(shouldIgnoreWatch(file))return;const rel=path.join(dir,file||"").replace(/\\/g,"/");queue(rel)})}}function holdUntilExit(child){const stop=()=>{if(child&&child.exitCode==null){child.kill("SIGTERM")}process.exit(0)};process.on("SIGINT",stop);process.on("SIGTERM",stop);if(child){child.on("exit",(code,signal)=>{console.log(`[dev] cocos CLI exited (${signal||code})`);process.exit(code==null?1:code)});return}console.log("[dev] CLI already running; Ctrl+C leaves it open");setInterval(()=>{},60*1000)}async function start(){if(await isPreviewUp()){printBanner(true);const synced=syncThemeOnce();if(synced)await compile({fatal:false});watchAndCompile();holdUntilExit(null);return}if(needsXvfb()){console.log("[dev] linux, wrapping xvfb-run -a")}const args=["--no-interactive","start-mcp-server","--project",projectRoot,"--port",String(port)];if(scene){console.log(`[dev] start scene ${scene}`)}console.log(`[dev] cocos ${args.join(" ")}`);const child=spawnCocos(args,{cwd:projectRoot,env:process.env,stdio:"inherit"});child.on("error",err=>{console.error(err);process.exit(1)});try{await waitForPreview()}catch(err){console.error(`[dev] ${err.message}`);child.kill("SIGTERM");process.exit(1)}printBanner(false);const synced=syncThemeOnce();if(synced)await compile({fatal:false});watchAndCompile();holdUntilExit(child)}killSocialShareForDev(projectRoot);if(cmd==="compile"||cmd==="refresh"){await compile();return}if(cmd==="start"||cmd==="dev"){await start();return}console.error(`[dev] unknown command: ${cmd}`);console.error("[dev] usage: playable-scripts dev [start|compile]");process.exit(1)}module.exports={runCocosDev};
4
+ */async function runCocosDev(opts={}){const projectRoot=opts.projectRoot||process.cwd();const port=Number(opts.port||process.env.PREVIEW_PORT||3000);const timeoutMs=Number(opts.timeoutMs||process.env.DEV_TIMEOUT_MS||180000);const debounceMs=Number(opts.debounceMs||process.env.DEV_DEBOUNCE_MS||400);const cmd=opts.cmd||process.argv[2]||"start";const scene=resolvePreviewScene(projectRoot,opts.scene);const previewUrl=scene?`http://127.0.0.1:${port}/?scene=${encodeURIComponent(scene)}`:`http://127.0.0.1:${port}/`;const watchDirs=["assets","src/theme"].filter(dir=>fs.existsSync(path.join(projectRoot,dir)));function previewUrlFor(previewPort){return scene?`http://127.0.0.1:${previewPort}/?scene=${encodeURIComponent(scene)}`:`http://127.0.0.1:${previewPort}/`}async function isPreviewUpOn(previewPort){try{const res=await request(previewUrlFor(previewPort),{timeout:1500});return res.status>0&&res.status<500}catch{return false}}async function isPreviewUp(){return isPreviewUpOn(port)}async function waitForPreviewOn(previewPort){const url=previewUrlFor(previewPort);const start=Date.now();let lastLog=0;while(Date.now()-start<timeoutMs){if(await isPreviewUpOn(previewPort))return;if(Date.now()-lastLog>=5000){console.log(`[dev] waiting for ${url}`);lastLog=Date.now()}await sleep(1000)}throw new Error(`preview not ready after ${timeoutMs}ms: ${url}`)}function printBanner(reused){console.log(`[dev] ${reused?"reusing cocos CLI":"cocos CLI ready"}`);console.log(`[dev] iframe → ${previewUrl}`);console.log(`[dev] mcp → http://127.0.0.1:${port}/mcp`);console.log(`[dev] watch ${watchDirs.map(d=>`${d}/`).join(" ")}`)}function syncThemeOnce(){try{const result=syncGeneratedTheme(projectRoot);if(!result){console.log("[dev] no theme entry; skip generated module");return false}console.log(`[dev] synced theme ${result.themeName}${result.changed===false?" (unchanged)":""}`);return result.changed!==false}catch(syncErr){console.warn(`[dev] theme sync skipped: ${syncErr.message}`);return false}}async function compile({fatal=true,previewPort=port}={}){const origin=previewUrlFor(previewPort);if(!(await isPreviewUpOn(previewPort))){console.error(`[dev] preview is down: ${origin}`);if(fatal){console.error("[dev] Run npm run dev first.");process.exit(1)}return}console.log(`[dev] assets-refresh db://assets`);try{const res=await refreshAssets(previewPort,"db://assets");const err=res.parsed&&(res.parsed.error||res.parsed.result&&res.parsed.result.isError);console.log(`[dev] ${res.status}${err?" error":""}`);if(res.status===404){console.error("[dev] /mcp \u4E0D\u5B58\u5728\u3002\u8BF7\u505C\u6389\u5F53\u524D\u9884\u89C8\uFF0C\u91CD\u65B0 npm run dev\uFF08\u4F1A\u8D77 start-mcp-server\uFF09")}if((res.status>=400||err)&&fatal)process.exit(1)}catch(err){console.error(`[dev] compile failed: ${err.message}`);if(fatal)process.exit(1)}await bundlePreviewAssets({previewPort})}async function bundlePreviewAssets({previewPort=port}={}){bundlePreviewScripts();try{const result=await packPreviewJson(projectRoot,{previewOrigin:`http://127.0.0.1:${previewPort}`});if(!result)return;const kb=Math.max(1,Math.round(result.bytes/1024));console.log(`[dev] preview json ${result.files} → 1 pack (${kb} kB)`);markPreviewMergeReady(projectRoot)}catch(err){console.warn(`[dev] preview json pack skipped: ${err.message}`)}}function bundlePreviewScripts(){try{const result=concatPreviewScripts(projectRoot);if(!result)return;const kb=Math.max(1,Math.round(result.bytes/1024));console.log(`[dev] preview scripts ${result.modules} → 1 bundle (${kb} kB)`)}catch(err){console.warn(`[dev] preview script concat skipped: ${err.message}`)}}function watchAndCompile(){let timer=null;let compiling=false;let pending=null;const queue=rel=>{pending=rel;if(timer)clearTimeout(timer);timer=setTimeout(()=>{timer=null;run()},debounceMs)};const run=async()=>{if(compiling)return;const rel=pending;pending=null;compiling=true;if(rel)console.log(`[dev] change ${rel}`);try{if(!rel||rel.startsWith("src/theme")||/resources\/textures\//.test(rel)){syncThemeOnce()}await compile({fatal:false})}finally{compiling=false;if(pending)queue(pending)}};for(const dir of watchDirs){const abs=path.join(projectRoot,dir);fs.watch(abs,{recursive:true},(_event,file)=>{if(shouldIgnoreWatch(file))return;const rel=path.join(dir,file||"").replace(/\\/g,"/");queue(rel)})}const themeName=detectCurrentTheme(projectRoot,{sourceDir:"src/theme",entryFile:"src/theme/index.ts"});const themeAssetsDir=themeName?path.join(projectRoot,"src/theme",themeName,"assets"):"";if(themeAssetsDir&&fs.existsSync(themeAssetsDir)){fs.watch(themeAssetsDir,()=>{queue(path.join("src/theme",themeName,"assets"))});let stamp=themeAssetStamp(themeAssetsDir);setInterval(()=>{const next=themeAssetStamp(themeAssetsDir);if(next!==stamp){stamp=next;queue(path.join("src/theme",themeName,"assets"))}},1000)}}function holdUntilExit(child,proxy){const stop=()=>{if(child&&child.exitCode==null){child.kill("SIGTERM")}if(proxy&&proxy.close){Promise.resolve(proxy.close()).finally(()=>process.exit(0));return}process.exit(0)};process.on("SIGINT",stop);process.on("SIGTERM",stop);if(child){child.on("exit",(code,signal)=>{console.log(`[dev] cocos CLI exited (${signal||code})`);if(proxy&&proxy.close){Promise.resolve(proxy.close()).finally(()=>process.exit(code==null?1:code));return}process.exit(code==null?1:code)});return}console.log("[dev] CLI already running; Ctrl+C leaves it open");setInterval(()=>{},60*1000)}async function warmPreviewAfterReady(previewPort){// Cold sandbox: library/ is empty until assets-refresh; skipping compile leaves JSON unpack hooked but empty → 100+ UUID requests.
5
+ syncThemeOnce();await compile({fatal:false,previewPort})}async function start(){clearPreviewMergeMarker(projectRoot);if(await isPreviewUp()){console.warn("[dev] reusing CLI without cache proxy; restart npm run dev to enable long-cache");printBanner(true);await warmPreviewAfterReady(port);watchAndCompile();holdUntilExit(null);return}if(needsXvfb()){console.log("[dev] linux, wrapping xvfb-run -a")}const cliPort=await findFreePort(port);const args=["--no-interactive","start-mcp-server","--project",projectRoot,"--port",String(cliPort)];if(scene){console.log(`[dev] start scene ${scene}`)}console.log(`[dev] cocos ${args.join(" ")}`);const child=spawnCocos(args,{cwd:projectRoot,env:process.env,stdio:"inherit"});child.on("error",err=>{console.error(err);process.exit(1)});let proxy;try{await waitForPreviewOn(cliPort);console.log(`[dev] cocos CLI listening on ${cliPort} (public ${port} still closed)`);await warmPreviewAfterReady(cliPort);proxy=await startPreviewProxy({listenPort:port,targetPort:cliPort});console.log(`[dev] cache proxy ${port} → CLI ${cliPort} (preview merge ready)`)}catch(err){console.error(`[dev] ${err.message}`);child.kill("SIGTERM");if(proxy&&proxy.close)await proxy.close();process.exit(1)}printBanner(false);watchAndCompile();holdUntilExit(child,proxy)}killSocialShareForDev(projectRoot);if(cmd==="compile"||cmd==="refresh"){await compile();return}if(cmd==="start"||cmd==="dev"){await start();return}console.error(`[dev] unknown command: ${cmd}`);console.error("[dev] usage: playable-scripts dev [start|compile]");process.exit(1)}module.exports={runCocosDev,previewMergeMarkerPath,clearPreviewMergeMarker,markPreviewMergeReady,PREVIEW_MERGE_MARKER};
@@ -1 +1 @@
1
- "use strict";const fs=require("fs");const path=require("path");const{lookup}=require("mime-types");const{ADAPTER_FETCH,TO_STRING_EXTNAME,TO_SKIP_EXTNAME}=require("./constants");function getRealPath(pathStr){return pathStr.indexOf("\\")!==-1?pathStr.replace(/\\/g,"/"):pathStr}function getAllFilesFromDir(dirPath){let files=[];if(!fs.existsSync(dirPath))return files;for(const fileName of fs.readdirSync(dirPath)){const filePath=path.join(dirPath,fileName);const stat=fs.statSync(filePath);if(stat.isDirectory())files=files.concat(getAllFilesFromDir(filePath));else files.push(filePath)}return files}function getBase64FromFile(filePath){const data=fs.readFileSync(filePath).toString("base64");return`data:${lookup(filePath)||"application/octet-stream"};base64,${data}`}function getTargetResData(filePath){const fileExtname=path.extname(filePath);if(!fileExtname)return"";if(TO_STRING_EXTNAME.includes(fileExtname)){return fs.readFileSync(filePath,"utf-8")}return getBase64FromFile(filePath)}function removeXMLHttpRequest(codeStr){return codeStr.replace(/XMLHttpRequest/g,ADAPTER_FETCH)}async function getResourceMapper({dirPath,rmHttp=false,mountCbFn,skipFiles=[]}){const resMapper={};const readPkgPath=getRealPath(`${dirPath}/`);for(const filePath of getAllFilesFromDir(dirPath)){const fileExtname=path.extname(filePath);if(TO_SKIP_EXTNAME.includes(fileExtname))continue;if(skipFiles.includes(filePath))continue;const objKey=getRealPath(filePath).replace(readPkgPath,"");let data=getTargetResData(filePath);if(mountCbFn)data=mountCbFn(objKey,data);if(rmHttp&&fileExtname===".js")data=removeXMLHttpRequest(data);resMapper[objKey]=data}return{resMapper}}module.exports={getRealPath,getAllFilesFromDir,getBase64FromFile,getTargetResData,getResourceMapper,removeXMLHttpRequest};
1
+ "use strict";const fs=require("fs");const path=require("path");const{lookup}=require("mime-types");const{ADAPTER_FETCH,TO_STRING_EXTNAME,TO_SKIP_EXTNAME}=require("./constants");function getRealPath(pathStr){return pathStr.indexOf("\\")!==-1?pathStr.replace(/\\/g,"/"):pathStr}function getAllFilesFromDir(dirPath){let files=[];if(!fs.existsSync(dirPath))return files;for(const fileName of fs.readdirSync(dirPath)){const filePath=path.join(dirPath,fileName);const stat=fs.statSync(filePath);if(stat.isDirectory())files=files.concat(getAllFilesFromDir(filePath));else files.push(filePath)}return files}function sniffImageMime(buf){if(!buf||buf.length<12){if(buf&&buf.length>=8&&buf[0]===137&&buf[1]===80&&buf[2]===78&&buf[3]===71){return"image/png"}return""}if(buf[0]===137&&buf[1]===80&&buf[2]===78&&buf[3]===71)return"image/png";if(buf[0]===255&&buf[1]===216&&buf[2]===255)return"image/jpeg";if(buf[0]===82&&buf[1]===73&&buf[2]===70&&buf[3]===70&&buf[8]===87&&buf[9]===69&&buf[10]===66&&buf[11]===80){return"image/webp"}return""}function sniffAudioMime(buf){if(!buf||buf.length<4)return"";if(buf[0]===73&&buf[1]===68&&buf[2]===51)return"audio/mpeg";if(buf[0]===255&&(buf[1]&224)===224)return"audio/mpeg";if(buf[0]===79&&buf[1]===103&&buf[2]===103&&buf[3]===83)return"audio/ogg";if(buf[0]===82&&buf[1]===73&&buf[2]===70&&buf[3]===70){if(buf.length>=12&&buf[8]===87&&buf[9]===69&&buf[10]===66&&buf[11]===80){return""}return"audio/wav"}if(buf.length>=8&&buf[4]===102&&buf[5]===116&&buf[6]===121&&buf[7]===112)return"audio/mp4";return""}function getBase64FromFile(filePath){const buf=fs.readFileSync(filePath);const lookedUp=lookup(filePath)||"";const imageMime=lookedUp.startsWith("image/")?lookedUp:sniffImageMime(buf);const mime=imageMime||(lookedUp.startsWith("audio/")?lookedUp:sniffAudioMime(buf)||lookedUp)||"application/octet-stream";return`data:${mime};base64,${buf.toString("base64")}`}function getTargetResData(filePath){const fileExtname=path.extname(filePath);if(!fileExtname)return"";if(TO_STRING_EXTNAME.includes(fileExtname)){return fs.readFileSync(filePath,"utf-8")}return getBase64FromFile(filePath)}function removeXMLHttpRequest(codeStr){return codeStr.replace(/XMLHttpRequest/g,ADAPTER_FETCH)}async function getResourceMapper({dirPath,rmHttp=false,mountCbFn,skipFiles=[]}){const resMapper={};const readPkgPath=getRealPath(`${dirPath}/`);for(const filePath of getAllFilesFromDir(dirPath)){const fileExtname=path.extname(filePath);if(TO_SKIP_EXTNAME.includes(fileExtname))continue;if(skipFiles.includes(filePath))continue;const objKey=getRealPath(filePath).replace(readPkgPath,"");let data=getTargetResData(filePath);if(mountCbFn)data=mountCbFn(objKey,data);if(rmHttp&&fileExtname===".js")data=removeXMLHttpRequest(data);resMapper[objKey]=data}return{resMapper}}module.exports={getRealPath,getAllFilesFromDir,getBase64FromFile,getTargetResData,getResourceMapper,removeXMLHttpRequest,sniffAudioMime};
@@ -1,6 +1,6 @@
1
- "use strict";const fs=require("fs");const path=require("path");const{load}=require("cheerio");const{deflate}=require("pako");const{getResourceMapper,getBase64FromFile}=require("./fs");const{TRANSPARENT_GIF}=require("./constants");function readInject(rel){return fs.readFileSync(path.join(__dirname,rel),"utf8")}function paddingStyleTags($,originPkgPath,enableSplash){$("link[type=\"text/css\"]").toArray().forEach(item=>{const href=$(item).attr("href");if(!href)return;const cssPath=path.join(originPkgPath,href);if(!fs.existsSync(cssPath))return;const cssStr=fs.readFileSync(cssPath,"utf-8");$(`<style>${cssStr}</style>`).appendTo("head")});if(!enableSplash){$("<style>#splash .progress-bar{opacity:0;visibility:hidden}</style>").appendTo("head")}$("link[type=\"text/css\"]").remove();$("head").find("style").each((_index,elem)=>{const cssUrlReg=/url\("?'?.*"?'?\)/g;let styleTagStr=$(elem).html()||"";const matchStrList=styleTagStr.match(cssUrlReg);if(!matchStrList)return;matchStrList.forEach(str=>{const imgUrl=str.replace(/"|'|url|\(|\)/g,"");const imgPath=path.join(originPkgPath,imgUrl);const imgBase64=enableSplash&&fs.existsSync(imgPath)?getBase64FromFile(imgPath):TRANSPARENT_GIF;styleTagStr=styleTagStr.replace(str,`url(${imgBase64})`)});$(elem).html(styleTagStr)})}function paddingScriptTags($,originPkgPath){let scriptTags="";$("script[type=\"systemjs-importmap\"]").toArray().forEach(item=>{const href=$(item).attr("src");if(!href)return;const srcPath=path.join(originPkgPath,href);if(!fs.existsSync(srcPath))return;scriptTags+=`<script type="systemjs-importmap">${fs.readFileSync(srcPath,"utf-8")}</script>`});$("head link").remove();$("body script").remove();if(scriptTags)$(scriptTags).appendTo("body")}function getJsListFromSettingsJson(data){const jsonData=JSON.parse(data);jsonData.plugins={jsList:[],...jsonData.plugins};const jsList=[...jsonData.plugins.jsList];jsonData.plugins.jsList=[];return{jsList,settingsData:jsonData}}function getJsListFromSettingsJs(data){const originData={jsList:[],settingsData:data};const settingsStrList=data.split("jsList:");if(settingsStrList.length<2)return originData;const settingsStr=settingsStrList.pop()||"";const jsListStrRegExp=/\[[^\]]*\]/.exec(settingsStr);if(!jsListStrRegExp)return originData;const jsListStr=jsListStrRegExp[0];return{jsList:JSON.parse(jsListStr),settingsData:data.replace(jsListStr,"[]")}}/**
1
+ "use strict";const fs=require("fs");const path=require("path");const{load}=require("cheerio");const{deflate}=require("pako");const{getResourceMapper,getBase64FromFile}=require("./fs");const{TRANSPARENT_GIF}=require("./constants");function readInject(rel){return fs.readFileSync(path.join(__dirname,rel),"utf8")}function paddingStyleTags($,originPkgPath,enableSplash){$("link[type=\"text/css\"]").toArray().forEach(item=>{const href=$(item).attr("href");if(!href)return;const cssPath=path.join(originPkgPath,href);if(!fs.existsSync(cssPath))return;const cssStr=fs.readFileSync(cssPath,"utf-8");$(`<style>${cssStr}</style>`).appendTo("head")});if(!enableSplash){$("<style>#splash,#splash .progress-bar{display:none!important;opacity:0;visibility:hidden}</style>").appendTo("head")}$("link[type=\"text/css\"]").remove();$("head").find("style").each((_index,elem)=>{const cssUrlReg=/url\("?'?.*"?'?\)/g;let styleTagStr=$(elem).html()||"";const matchStrList=styleTagStr.match(cssUrlReg);if(!matchStrList)return;matchStrList.forEach(str=>{const imgUrl=str.replace(/"|'|url|\(|\)/g,"");const imgPath=path.join(originPkgPath,imgUrl);const imgBase64=enableSplash&&fs.existsSync(imgPath)?getBase64FromFile(imgPath):TRANSPARENT_GIF;styleTagStr=styleTagStr.replace(str,`url(${imgBase64})`)});$(elem).html(styleTagStr)})}function paddingScriptTags($,originPkgPath){let scriptTags="";$("script[type=\"systemjs-importmap\"]").toArray().forEach(item=>{const href=$(item).attr("src");if(!href)return;const srcPath=path.join(originPkgPath,href);if(!fs.existsSync(srcPath))return;scriptTags+=`<script type="systemjs-importmap">${fs.readFileSync(srcPath,"utf-8")}</script>`});$("head link").remove();$("body script").remove();if(scriptTags)$(scriptTags).appendTo("body")}function getJsListFromSettingsJson(data){const jsonData=JSON.parse(data);jsonData.plugins={jsList:[],...jsonData.plugins};const jsList=[...jsonData.plugins.jsList];jsonData.plugins.jsList=[];return{jsList,settingsData:jsonData}}function stripSplashScreen(settingsData){if(!settingsData||!settingsData.splashScreen)return settingsData;settingsData.splashScreen.totalTime=0;settingsData.splashScreen.logo={type:"none",base64:""};return settingsData}function getJsListFromSettingsJs(data){const originData={jsList:[],settingsData:data};const settingsStrList=data.split("jsList:");if(settingsStrList.length<2)return originData;const settingsStr=settingsStrList.pop()||"";const jsListStrRegExp=/\[[^\]]*\]/.exec(settingsStr);if(!jsListStrRegExp)return originData;const jsListStr=jsListStrRegExp[0];return{jsList:JSON.parse(jsListStr),settingsData:data.replace(jsListStr,"[]")}}/**
2
2
  * Inline a Creator 3.x web-mobile folder into a single HTML file.
3
3
  * @param {string} originPkgPath - Absolute path to build/web-mobile
4
4
  * @param {string} singleFilePath - Output html path
5
5
  * @param {{ enableSplash?: boolean, isZip?: boolean }} [opts]
6
- */async function mergeWebMobile(originPkgPath,singleFilePath,opts={}){const enableSplash=opts.enableSplash!==false;const isZip=opts.isZip!==false;const htmlPath=path.join(originPkgPath,"index.html");if(!fs.existsSync(htmlPath)){throw new Error(`[cocos] web-mobile index.html not found: ${htmlPath}`)}const $=load(fs.readFileSync(htmlPath,"utf-8"));paddingStyleTags($,originPkgPath,enableSplash);paddingScriptTags($,originPkgPath);let pluginJsList=[];const{resMapper}=await getResourceMapper({dirPath:originPkgPath,rmHttp:true,mountCbFn:(objKey,data)=>{if(objKey.indexOf("src/settings.json")!==-1){var _settingsData$splashS;const{jsList,settingsData}=getJsListFromSettingsJson(data);pluginJsList=jsList;if(!enableSplash&&settingsData!==null&&settingsData!==void 0&&(_settingsData$splashS=settingsData.splashScreen)!==null&&_settingsData$splashS!==void 0&&_settingsData$splashS.totalTime){settingsData.splashScreen.totalTime=0}return JSON.stringify(settingsData)}if(objKey.indexOf("src/settings.js")!==-1){const{jsList,settingsData}=getJsListFromSettingsJs(data);pluginJsList=jsList;return settingsData}return data}});let resStr=JSON.stringify(resMapper);let compDiff=0;if(isZip){const zip=deflate(resStr);const zipStr=Buffer.from(zip).toString("base64");console.log("[cocos] origin pkg",resStr.length,"compressed",zipStr.length);if(zipStr.length<resStr.length){compDiff=resStr.length-zipStr.length;resStr=zipStr}}if(compDiff>0){$(`<script data-id="jszip">${readInject("injects/libs/pako.js")}</script>`).appendTo("body");$(`<script data-id="adapter-zip-0">window.__adapter_zip__="${resStr}";</script>`).appendTo("body")}else{$(`<script data-id="adapter-resource-0">window.__adapter_resource__=${resStr}</script>`).appendTo("body")}$(`<script data-id="adapter-plugins">window.__adapter_plugins__=${JSON.stringify(pluginJsList)}</script>`).appendTo("body");$(`<script data-id="adapter-init">${readInject("injects/3x/init.js")}</script>`).appendTo("body");$(`<script data-id="adapter-main">${readInject("injects/3x/main.js")}</script>`).appendTo("body");fs.mkdirSync(path.dirname(singleFilePath),{recursive:true});fs.writeFileSync(singleFilePath,$.html());const kb=(fs.statSync(singleFilePath).size/1024).toFixed(1);console.log(`[cocos] single-file template ${kb}kb → ${singleFilePath}`);return{resMapper,compDiff,singleFilePath}}module.exports={mergeWebMobile};
6
+ */async function mergeWebMobile(originPkgPath,singleFilePath,opts={}){const enableSplash=opts.enableSplash===true;const isZip=opts.isZip!==false;const htmlPath=path.join(originPkgPath,"index.html");if(!fs.existsSync(htmlPath)){throw new Error(`[cocos] web-mobile index.html not found: ${htmlPath}`)}const $=load(fs.readFileSync(htmlPath,"utf-8"));paddingStyleTags($,originPkgPath,enableSplash);paddingScriptTags($,originPkgPath);let pluginJsList=[];const{resMapper}=await getResourceMapper({dirPath:originPkgPath,rmHttp:true,mountCbFn:(objKey,data)=>{if(objKey.indexOf("src/settings.json")!==-1){const{jsList,settingsData}=getJsListFromSettingsJson(data);pluginJsList=jsList;if(!enableSplash)stripSplashScreen(settingsData);return JSON.stringify(settingsData)}if(objKey.indexOf("src/settings.js")!==-1){const{jsList,settingsData}=getJsListFromSettingsJs(data);pluginJsList=jsList;return settingsData}return data}});let resStr=JSON.stringify(resMapper);let compDiff=0;if(isZip){const zip=deflate(resStr);const zipStr=Buffer.from(zip).toString("base64");console.log("[cocos] origin pkg",resStr.length,"compressed",zipStr.length);if(zipStr.length<resStr.length){compDiff=resStr.length-zipStr.length;resStr=zipStr}}if(compDiff>0){$(`<script data-id="jszip">${readInject("injects/libs/pako.js")}</script>`).appendTo("body");$(`<script data-id="adapter-zip-0">window.__adapter_zip__="${resStr}";</script>`).appendTo("body")}else{$(`<script data-id="adapter-resource-0">window.__adapter_resource__=${resStr}</script>`).appendTo("body")}$(`<script data-id="adapter-plugins">window.__adapter_plugins__=${JSON.stringify(pluginJsList)}</script>`).appendTo("body");$(`<script data-id="adapter-init">${readInject("injects/3x/init.js")}</script>`).appendTo("body");$(`<script data-id="adapter-main">${readInject("injects/3x/main.js")}</script>`).appendTo("body");fs.mkdirSync(path.dirname(singleFilePath),{recursive:true});fs.writeFileSync(singleFilePath,$.html());const kb=(fs.statSync(singleFilePath).size/1024).toFixed(1);console.log(`[cocos] single-file template ${kb}kb → ${singleFilePath}`);return{resMapper,compDiff,singleFilePath}}module.exports={mergeWebMobile};
@@ -1 +1 @@
1
- "use strict";const fs=require("fs");const path=require("path");const{options}=require("../../options.js");const{mergeOptions}=require("../../utils/mergeOptions.js");const{logOptions}=require("../../utils/logOptions.js");const{resolveCreatorOutputDir}=require("./creator-cli");const{runCocosEngineBuild}=require("./cli-build");const{mergeWebMobile}=require("./merge");const{detectCurrentTheme,loadThemePayload,writeGeneratedThemeModule}=require("./theme-assets");const{emitChannel}=require("../../artifacts/emit");const{isSocialMediaNetwork,stripSocialShareFromDir}=require("./strip-social-share");function resolveWebMobileDir(root,buildOptions){const cocos=buildOptions.cocos||{};const fromCreator=resolveCreatorOutputDir(root,cocos.buildConfig||"build-configs/web-mobile.json");const explicit=cocos.webMobileDir?path.resolve(root,cocos.webMobileDir):null;if(cocos.skipEngineBuild){return explicit||fromCreator||path.resolve(root,"dist/web-mobile")}if(fromCreator){if(explicit&&explicit!==fromCreator){console.warn(`[cocos] webMobileDir (${path.relative(root,explicit)}) != Creator output (${path.relative(root,fromCreator)}); using Creator buildPath/outputName`)}return fromCreator}return explicit||path.resolve(root,"dist/web-mobile")}async function mergeTemplate(root,buildOptions){const webMobileDir=resolveWebMobileDir(root,buildOptions);const singleFilePath=path.join(root,"build","single-file-3x.html");await mergeWebMobile(webMobileDir,singleFilePath);return fs.readFileSync(singleFilePath,"utf8")}function resolveThemeName(root,buildOptions,themeConfig){return buildOptions.themeName||detectCurrentTheme(root,themeConfig)||"default"}function loadBuildsThemeConfig(root){const configPath=path.join(root,"builds.config.js");if(!fs.existsSync(configPath))return{sourceDir:"src/theme",entryFile:"src/theme/index.ts"};try{delete require.cache[configPath];const config=require(configPath);return{sourceDir:"src/theme",entryFile:"src/theme/index.ts",...(config.themes||{})}}catch{return{sourceDir:"src/theme",entryFile:"src/theme/index.ts"}}}function stripPackedChannel(outDir,network){if(isSocialMediaNetwork(network)){console.log("[cocos] social_media: keep AiX hooks (inject IIFE later)");return}const packed=stripSocialShareFromDir(outDir);console.log(`[cocos] stripped social-share from ${packed.changed}/${packed.files} files in ${path.relative(process.cwd(),outDir)||"."}`)}async function prepareCocosTemplate(customOptions){const buildOptions=mergeOptions(options,customOptions);const root=process.cwd();await runCocosEngineBuild(root,buildOptions);return mergeTemplate(root,buildOptions)}async function emitCocosChannel(templateHtml,customOptions){const buildOptions=mergeOptions(options,customOptions);const root=process.cwd();const themeConfig=loadBuildsThemeConfig(root);const themeName=resolveThemeName(root,buildOptions,themeConfig);let themePayload={themeName,mergedData:{},i18nOverride:{},assetRecordByPath:{}};try{themePayload=loadThemePayload(root,themeName,themeConfig)}catch(err){console.warn(`[cocos] theme payload skipped: ${err.message}`)}const generated=buildOptions.cocos&&buildOptions.cocos.generatedThemeFile||"assets/scripts/platform/theme.generated.ts";writeGeneratedThemeModule(path.join(root,generated),themePayload);const outDir=path.resolve(root,buildOptions.outDir);await emitChannel({templateHtml,buildOptions,themePayload,outDir});stripPackedChannel(outDir,buildOptions.network)}async function runCocosBuild(customOptions){const buildOptions=mergeOptions(options,customOptions);logOptions(buildOptions);const templateHtml=await prepareCocosTemplate(buildOptions);await emitCocosChannel(templateHtml,customOptions)}async function packCocosBatch({config,themes,tasks,outputDir,projectRoot,isChannelFold2Zip}){const buildOptionsBase=mergeOptions(options,{engine:"cocos",googlePlayUrl:config.googlePlayUrl,appStoreUrl:config.appStoreUrl,isChannelFold2Zip,obfuscateLevel:config.obfuscateLevel,fflateCompression:config.fflateCompression});if(config.cocos)Object.assign(buildOptionsBase.cocos,config.cocos);await runCocosEngineBuild(projectRoot,buildOptionsBase);const templateHtml=await mergeTemplate(projectRoot,buildOptionsBase);const themeConfig=config.themes||loadBuildsThemeConfig(projectRoot);const results=[];for(const theme of themes){let themePayload;try{themePayload=loadThemePayload(projectRoot,theme,themeConfig)}catch(err){console.error(`[cocos] theme ${theme}: ${err.message}`);for(const task of tasks){results.push({success:false,theme,channel:task.channel,orientation:task.orientation,error:err.message})}continue}for(const task of tasks){const orientationValue=task.orientation||"both";const fileName=config.naming&&typeof config.naming.generator==="function"?config.naming.generator(theme,task.channel):`${theme}-${task.channel}`;const tempDir=path.join(projectRoot,`${config.build.tempDirPrefix||"dist-temp"}-cocos-${theme}-${task.channel}-${orientationValue}`);try{const channelOptions=mergeOptions(buildOptionsBase,{network:task.channel,playcraftOrientationValue:orientationValue,filename:fileName||options.filename,outDir:tempDir,isChannelFold2Zip});await emitChannel({templateHtml,buildOptions:channelOptions,themePayload,outDir:tempDir});stripPackedChannel(tempDir,task.channel);const themeDir=path.join(outputDir,theme);fs.mkdirSync(themeDir,{recursive:true});for(const file of fs.readdirSync(tempDir)){const src=path.join(tempDir,file);if(fs.statSync(src).isDirectory())continue;fs.copyFileSync(src,path.join(themeDir,file))}results.push({success:true,theme,channel:task.channel,orientation:orientationValue})}catch(error){results.push({success:false,theme,channel:task.channel,orientation:orientationValue,error:error.message})}finally{if(config.build.cleanTemp!==false&&fs.existsSync(tempDir)){fs.rmSync(tempDir,{recursive:true,force:true})}}}}return results}async function syncThemeForPreview(projectRoot,themeName,themeConfig,generatedRel){const payload=loadThemePayload(projectRoot,themeName,themeConfig);writeGeneratedThemeModule(path.join(projectRoot,generatedRel),payload);return payload}module.exports={runCocosBuild,prepareCocosTemplate,emitCocosChannel,packCocosBatch,mergeTemplate,syncThemeForPreview};
1
+ "use strict";const fs=require("fs");const path=require("path");const{options}=require("../../options.js");const{mergeOptions}=require("../../utils/mergeOptions.js");const{logOptions}=require("../../utils/logOptions.js");const{resolveCreatorOutputDir}=require("./creator-cli");const{runCocosEngineBuild}=require("./cli-build");const{rebundlePreviewMerge}=require("./rebundle-preview.js");const{mergeWebMobile}=require("./merge");const{detectCurrentTheme,loadThemePayload,writeGeneratedThemeModule}=require("./theme-assets");const{emitChannel}=require("../../artifacts/emit");const{isSocialMediaNetwork,stripSocialShareFromDir}=require("./strip-social-share");function resolveWebMobileDir(root,buildOptions){const cocos=buildOptions.cocos||{};const fromCreator=resolveCreatorOutputDir(root,cocos.buildConfig||"build-configs/web-mobile.json");const explicit=cocos.webMobileDir?path.resolve(root,cocos.webMobileDir):null;if(cocos.skipEngineBuild){return explicit||fromCreator||path.resolve(root,"dist/web-mobile")}if(fromCreator){if(explicit&&explicit!==fromCreator){console.warn(`[cocos] webMobileDir (${path.relative(root,explicit)}) != Creator output (${path.relative(root,fromCreator)}); using Creator buildPath/outputName`)}return fromCreator}return explicit||path.resolve(root,"dist/web-mobile")}async function mergeTemplate(root,buildOptions){const webMobileDir=resolveWebMobileDir(root,buildOptions);const singleFilePath=path.join(root,"build","single-file-3x.html");await mergeWebMobile(webMobileDir,singleFilePath,{enableSplash:!!(buildOptions.cocos&&buildOptions.cocos.enableSplash)});return fs.readFileSync(singleFilePath,"utf8")}function resolveThemeName(root,buildOptions,themeConfig){return buildOptions.themeName||detectCurrentTheme(root,themeConfig)||"default"}function loadBuildsThemeConfig(root){const configPath=path.join(root,"builds.config.js");if(!fs.existsSync(configPath))return{sourceDir:"src/theme",entryFile:"src/theme/index.ts"};try{delete require.cache[configPath];const config=require(configPath);return{sourceDir:"src/theme",entryFile:"src/theme/index.ts",...(config.themes||{})}}catch{return{sourceDir:"src/theme",entryFile:"src/theme/index.ts"}}}function stripPackedChannel(outDir,network){if(isSocialMediaNetwork(network)){console.log("[cocos] social_media: keep AiX hooks (inject IIFE later)");return}const packed=stripSocialShareFromDir(outDir);console.log(`[cocos] stripped social-share from ${packed.changed}/${packed.files} files in ${path.relative(process.cwd(),outDir)||"."}`)}async function prepareCocosTemplate(customOptions){const buildOptions=mergeOptions(options,customOptions);const root=process.cwd();await runCocosEngineBuild(root,buildOptions);await rebundlePreviewMerge(root);return mergeTemplate(root,buildOptions)}async function emitCocosChannel(templateHtml,customOptions){const buildOptions=mergeOptions(options,customOptions);const root=process.cwd();const themeConfig=loadBuildsThemeConfig(root);const themeName=resolveThemeName(root,buildOptions,themeConfig);let themePayload={themeName,mergedData:{},i18nOverride:{},assetRecordByPath:{}};try{themePayload=loadThemePayload(root,themeName,themeConfig)}catch(err){console.warn(`[cocos] theme payload skipped: ${err.message}`)}const generated=buildOptions.cocos&&buildOptions.cocos.generatedThemeFile||"assets/scripts/platform/theme.generated.ts";writeGeneratedThemeModule(path.join(root,generated),themePayload);const outDir=path.resolve(root,buildOptions.outDir);await emitChannel({templateHtml,buildOptions,themePayload,outDir});stripPackedChannel(outDir,buildOptions.network)}async function runCocosBuild(customOptions){const buildOptions=mergeOptions(options,customOptions);logOptions(buildOptions);const templateHtml=await prepareCocosTemplate(buildOptions);await emitCocosChannel(templateHtml,customOptions)}async function packCocosBatch({config,themes,tasks,outputDir,projectRoot,isChannelFold2Zip}){const buildOptionsBase=mergeOptions(options,{engine:"cocos",googlePlayUrl:config.googlePlayUrl,appStoreUrl:config.appStoreUrl,isChannelFold2Zip,obfuscateLevel:config.obfuscateLevel,fflateCompression:config.fflateCompression});if(config.cocos)Object.assign(buildOptionsBase.cocos,config.cocos);if(buildOptionsBase.cocos.cleanCache!==false&&process.env.PLAYABLE_KEEP_COCOS_CACHE!=="1"){buildOptionsBase.cocos.cleanCache=true}await runCocosEngineBuild(projectRoot,buildOptionsBase);await rebundlePreviewMerge(projectRoot);const templateHtml=await mergeTemplate(projectRoot,buildOptionsBase);const themeConfig=config.themes||loadBuildsThemeConfig(projectRoot);const results=[];for(const theme of themes){let themePayload;try{themePayload=loadThemePayload(projectRoot,theme,themeConfig)}catch(err){console.error(`[cocos] theme ${theme}: ${err.message}`);for(const task of tasks){results.push({success:false,theme,channel:task.channel,orientation:task.orientation,error:err.message})}continue}for(const task of tasks){const orientationValue=task.orientation||"both";const fileName=config.naming&&typeof config.naming.generator==="function"?config.naming.generator(theme,task.channel):`${theme}-${task.channel}`;const tempDir=path.join(projectRoot,`${config.build.tempDirPrefix||"dist-temp"}-cocos-${theme}-${task.channel}-${orientationValue}`);try{const channelOptions=mergeOptions(buildOptionsBase,{network:task.channel,playcraftOrientationValue:orientationValue,filename:fileName||options.filename,outDir:tempDir,isChannelFold2Zip});await emitChannel({templateHtml,buildOptions:channelOptions,themePayload,outDir:tempDir});stripPackedChannel(tempDir,task.channel);const themeDir=path.join(outputDir,theme);fs.mkdirSync(themeDir,{recursive:true});for(const file of fs.readdirSync(tempDir)){const src=path.join(tempDir,file);if(fs.statSync(src).isDirectory())continue;fs.copyFileSync(src,path.join(themeDir,file))}results.push({success:true,theme,channel:task.channel,orientation:orientationValue})}catch(error){results.push({success:false,theme,channel:task.channel,orientation:orientationValue,error:error.message})}finally{if(config.build.cleanTemp!==false&&fs.existsSync(tempDir)){fs.rmSync(tempDir,{recursive:true,force:true})}}}}return results}async function syncThemeForPreview(projectRoot,themeName,themeConfig,generatedRel){const payload=loadThemePayload(projectRoot,themeName,themeConfig);writeGeneratedThemeModule(path.join(projectRoot,generatedRel),payload);return payload}module.exports={runCocosBuild,prepareCocosTemplate,emitCocosChannel,packCocosBatch,mergeTemplate,syncThemeForPreview};
@@ -0,0 +1,5 @@
1
+ "use strict";const IMMUTABLE="public, max-age=31536000, immutable";const REVALIDATE="public, max-age=0, must-revalidate";const NO_STORE="no-store";function pathOnly(url){return String(url||"").split("?")[0]}function hasContentHash(url){return /[?&]h=[a-f0-9]{6,}/i.test(String(url||""))}/**
2
+ * Cache-Control for CLI preview. Engine / static never change during a remix
3
+ * session; hashed playcraft artifacts are immutable until the next compile.
4
+ * HTML / settings / MCP stay no-store.
5
+ */function cacheControlFor(url){const pathname=pathOnly(url);if(!pathname||pathname==="/")return NO_STORE;if(pathname.endsWith(".html"))return NO_STORE;if(pathname==="/preview/settings.js")return NO_STORE;if(pathname.startsWith("/mcp"))return NO_STORE;if(pathname.startsWith("/console-log"))return NO_STORE;if(pathname.startsWith("/socket.io/")&&!pathname.endsWith(".js"))return null;if(/^\/scene\/.+\.json$/.test(pathname))return NO_STORE;if(pathname.startsWith("/scripting/engine-dist/"))return IMMUTABLE;if(pathname.startsWith("/static/web/"))return IMMUTABLE;if(pathname.startsWith("/scripting/engine/"))return IMMUTABLE;if(pathname==="/socket.io/socket.io.js")return IMMUTABLE;if(/\/chunks\/[0-9a-f]{2}\/[0-9a-f]{40}\.js$/.test(pathname))return IMMUTABLE;if(hasContentHash(url)&&(pathname.endsWith("/playcraft-bundle.js")||pathname.endsWith("/playcraft-json-pack.json"))){return IMMUTABLE}return REVALIDATE}module.exports={cacheControlFor,IMMUTABLE,REVALIDATE,NO_STORE};
@@ -0,0 +1 @@
1
+ "use strict";const{describe,it}=require("node:test");const assert=require("node:assert/strict");const{cacheControlFor,IMMUTABLE,REVALIDATE,NO_STORE}=require("./preview-cache.js");describe("cacheControlFor",()=>{it("does not cache html, settings, or mcp",()=>{assert.equal(cacheControlFor("/"),NO_STORE);assert.equal(cacheControlFor("/preview/settings.js"),NO_STORE);assert.equal(cacheControlFor("/mcp"),NO_STORE);assert.equal(cacheControlFor("/scene/0a1b2c3d-4e5f-4a6b-8c7d-9e0f1a2b3c4d.json"),NO_STORE)});it("long-caches engine and static files that do not change",()=>{assert.equal(cacheControlFor("/scripting/engine-dist/bundled/index.js"),IMMUTABLE);assert.equal(cacheControlFor("/static/web/polyfills.bundle.js"),IMMUTABLE);assert.equal(cacheControlFor("/scripting/engine/effect-settings"),IMMUTABLE)});it("long-caches hashed playcraft artifacts and revalidates unhashed ones",()=>{assert.equal(cacheControlFor("/scripting/x/chunks/playcraft-bundle.js?h=abcdef12"),IMMUTABLE);assert.equal(cacheControlFor("/scripting/x/chunks/playcraft-bundle.js"),REVALIDATE);assert.equal(cacheControlFor("/scripting/systemjs/playcraft-json-pack.json?h=abcdef12"),IMMUTABLE)})});
@@ -0,0 +1,8 @@
1
+ "use strict";const crypto=require("crypto");const fs=require("fs");const path=require("path");const ENTRY="cce:/internal/x/prerequisite-imports";const SKIP=new Set(["cce:/internal/x/cc"]);const GLOBAL_DEPS=new Set(["cc","cc/env","cc/userland/macro"]);const BUNDLE_FILE="playcraft-bundle.js";const BUNDLE_URL=`./chunks/${BUNDLE_FILE}`;const REGISTER_RE=/^System\.register\(\s*(\[[\s\S]*?\])\s*,\s*function/;function previewDir(projectRoot){return path.join(projectRoot,"temp/programming/packer-driver/targets/preview")}function normalizeChunkUrl(url){const raw=String(url||"").replace(/\\/g,"/");if(raw.startsWith("./chunks/"))return raw;if(raw.startsWith("chunks/"))return`./${raw}`;return raw}function isChunkUrl(url){return normalizeChunkUrl(url).startsWith("./chunks/")&&normalizeChunkUrl(url).endsWith(".js")}function packChunkName(chunkUrl){return`pack-chunk:${path.basename(normalizeChunkUrl(chunkUrl),".js")}`}function shortHash(buf){return crypto.createHash("sha1").update(buf).digest("hex").slice(0,10)}function isBundledImportMap(map){const imports=map&&map.imports||{};const entry=String(imports[ENTRY]||"").split("?")[0];return entry===BUNDLE_URL&&Object.keys(imports).length===1}function loadSourceImportMap(dir){const livePath=path.join(dir,"import-map.json");const backupPath=path.join(dir,"import-map.quickpack.json");if(!fs.existsSync(livePath))return null;const live=JSON.parse(fs.readFileSync(livePath,"utf8"));if(isBundledImportMap(live)){if(!fs.existsSync(backupPath))return null;return JSON.parse(fs.readFileSync(backupPath,"utf8"))}fs.writeFileSync(backupPath,`${JSON.stringify(live,null,2)}\n`);return live}function skipChunkUrls(map){const skip=new Set;const imports=map.imports||{};for(const spec of SKIP){if(isChunkUrl(imports[spec]))skip.add(normalizeChunkUrl(imports[spec]))}return skip}function collectChunkUrls(map){const skipUrls=skipChunkUrls(map);const urls=new Set;const queue=[];const add=url=>{if(!isChunkUrl(url))return;const normalized=normalizeChunkUrl(url);if(skipUrls.has(normalized)||urls.has(normalized))return;urls.add(normalized);queue.push(normalized)};const imports=map.imports||{};for(const[spec,url]of Object.entries(imports)){if(SKIP.has(spec))continue;add(url)}while(queue.length){const scopeUrl=queue.shift();for(const target of Object.values((map.scopes||{})[scopeUrl]||{})){add(target)}}return urls}function rewriteDep(dep,chunkUrl,map,nameOf){if(GLOBAL_DEPS.has(dep))return dep;const mapped=((map.scopes||{})[chunkUrl]||{})[dep]||(map.imports||{})[dep];if(!mapped)return dep;if(typeof mapped==="string"&&mapped.startsWith("cce:/internal/x/cc-fu"))return"cc";if(SKIP.has(mapped)||mapped===(map.imports||{})["cce:/internal/x/cc"])return"cc";if(isChunkUrl(mapped))return nameOf(normalizeChunkUrl(mapped));return mapped}function rewriteChunk(source,name,chunkUrl,map,nameOf,anonymous){const stripped=source.replace(/\n\/\/# sourceMappingURL=.*$/m,"");const match=stripped.match(REGISTER_RE);if(!match){throw new Error(`no System.register in ${chunkUrl}`)}const deps=JSON.parse(match[1]).map(dep=>rewriteDep(dep,chunkUrl,map,nameOf));const head=anonymous?`System.register(${JSON.stringify(deps)}, function`:`System.register(${JSON.stringify(name)}, ${JSON.stringify(deps)}, function`;return`${head}${stripped.slice(match[0].length)}`}/**
2
+ * Fold QuickPack's per-file chunks into one named-register bundle and
3
+ * point prerequisite-imports at it. Original chunks stay on disk for
4
+ * incremental compile; import-map.quickpack.json keeps the pre-rewrite map.
5
+ */function concatPreviewScripts(projectRoot){const dir=previewDir(projectRoot);const map=loadSourceImportMap(dir);if(!map)return null;const imports=map.imports||{};const chunkToSpecifier=new Map;for(const[spec,url]of Object.entries(imports)){if(SKIP.has(spec)||!isChunkUrl(url))continue;chunkToSpecifier.set(normalizeChunkUrl(url),spec)}const entryChunk=imports[ENTRY]&&isChunkUrl(imports[ENTRY])?normalizeChunkUrl(imports[ENTRY]):null;const nameOf=chunkUrl=>{if(entryChunk&&chunkUrl===entryChunk)return ENTRY;return chunkToSpecifier.get(chunkUrl)||packChunkName(chunkUrl)};const chunkUrls=[...collectChunkUrls(map)];if(!chunkUrls.length)return null;// Named modules first; anonymous entry last so script-tag onload getRegister()
6
+ // binds prerequisite-imports (named-register's firstNamedDefine is cleared
7
+ // before the load event).
8
+ const ordered=[];for(const url of chunkUrls){if(url!==entryChunk)ordered.push(url)}if(entryChunk&&chunkUrls.includes(entryChunk))ordered.push(entryChunk);const parts=[];for(const chunkUrl of ordered){const abs=path.join(dir,chunkUrl.replace(/^\.\//,""));if(!fs.existsSync(abs)){throw new Error(`missing chunk ${chunkUrl}`)}parts.push(rewriteChunk(fs.readFileSync(abs,"utf8"),nameOf(chunkUrl),chunkUrl,map,nameOf,chunkUrl===entryChunk))}const bundle=`${parts.join("\n")}\n`;const bundleAbs=path.join(dir,"chunks",BUNDLE_FILE);fs.mkdirSync(path.dirname(bundleAbs),{recursive:true});fs.writeFileSync(bundleAbs,bundle);const hashedUrl=`${BUNDLE_URL}?h=${shortHash(bundle)}`;const bundledMap={imports:{[ENTRY]:hashedUrl}};const tmp=path.join(dir,"import-map.json.tmp");fs.writeFileSync(tmp,`${JSON.stringify(bundledMap)}\n`);fs.renameSync(tmp,path.join(dir,"import-map.json"));return{modules:ordered.length,bytes:Buffer.byteLength(bundle),bundle:hashedUrl}}module.exports={concatPreviewScripts,ENTRY,BUNDLE_URL,BUNDLE_FILE,isBundledImportMap};
@@ -0,0 +1 @@
1
+ "use strict";const{describe,it,before,after}=require("node:test");const assert=require("node:assert/strict");const fs=require("fs");const os=require("os");const path=require("path");const{concatPreviewScripts,ENTRY}=require("./preview-concat.js");describe("concatPreviewScripts",()=>{let root;let preview;before(()=>{root=fs.mkdtempSync(path.join(os.tmpdir(),"preview-concat-"));preview=path.join(root,"temp/programming/packer-driver/targets/preview");fs.mkdirSync(path.join(preview,"chunks/aa"),{recursive:true});fs.mkdirSync(path.join(preview,"chunks/bb"),{recursive:true});fs.writeFileSync(path.join(preview,"chunks/aa/aaa111.js"),"System.register([\"__unresolved_0\", \"cc\", \"@playcraft/adsdk/cjs\"], function (_export, _context) {\n return { setters: [], execute() {} };\n});\n//# sourceMappingURL=aaa111.js.map\n");fs.writeFileSync(path.join(preview,"chunks/bb/bbb222.js"),"System.register([], function (_export, _context) {\n return { setters: [], execute() {} };\n});\n");fs.writeFileSync(path.join(preview,"import-map.json"),JSON.stringify({imports:{[ENTRY]:"./chunks/aa/aaa111.js","cce:/internal/x/cc":"./chunks/cc/skip-me.js"},scopes:{"./chunks/aa/aaa111.js":{__unresolved_0:"./chunks/bb/bbb222.js","@playcraft/adsdk/cjs":"./chunks/bb/bbb222.js",cc:"./chunks/cc/skip-me.js"}}}))});after(()=>{fs.rmSync(root,{recursive:true,force:true})});it("writes one named-register bundle and a single import-map entry",()=>{const result=concatPreviewScripts(root);assert.equal(result.modules,2);const map=JSON.parse(fs.readFileSync(path.join(preview,"import-map.json"),"utf8"));assert.match(map.imports[ENTRY],/^\.\/chunks\/playcraft-bundle\.js\?h=[a-f0-9]+$/);assert.equal(result.bundle,map.imports[ENTRY]);const bundle=fs.readFileSync(path.join(preview,"chunks/playcraft-bundle.js"),"utf8");assert.equal(bundle.includes("sourceMappingURL"),false);assert.equal(bundle.includes(`System.register("${ENTRY}"`),false);assert.match(bundle,/System\.register\("pack-chunk:bbb222"/);assert.match(bundle,/System\.register\(\["pack-chunk:bbb222","cc","pack-chunk:bbb222"\], function \(_export/);assert.equal(bundle.includes("\"@playcraft/adsdk/cjs\""),false);const last=bundle.trim().split("System.register").pop();assert.match(last,/^\(\[/);assert.equal(bundle.includes("functionfunction"),false);assert.equal(bundle.includes("cce:/internal/x/cc"),false)});it("is idempotent using the quickpack backup",()=>{const again=concatPreviewScripts(root);assert.equal(again.modules,2)})});
@@ -0,0 +1 @@
1
+ "use strict";/* playcraft-json-pack:start */(function(){var pack=null;var pending=[];var packHash="";function resolvePackUrl(){var src="";try{if(document.currentScript&&document.currentScript.src)src=document.currentScript.src}catch(err){}if(!src){try{var scripts=document.getElementsByTagName("script");for(var i=scripts.length-1;i>=0;--i){var candidate=scripts[i].src||"";if(candidate.indexOf("system.js")!==-1){src=candidate;break}}}catch(err){}}if(!src){try{var entries=performance.getEntriesByType("resource");for(var i=entries.length-1;i>=0;--i){var name=entries[i].name;if(name.indexOf("/systemjs/system.js")!==-1){src=name;break}}}catch(err){}}var url=src?src.replace(/system\.js(\?.*)?$/,"playcraft-json-pack.json"):"scripting/systemjs/playcraft-json-pack.json";if(packHash&&url.indexOf("?")===-1)url+="?h="+packHash;return url}var packUrl=resolvePackUrl();function uuidFromUrl(url){var match=String(url||"").match(/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}(?:@[^.\\/?]+)?)(?:\.json)?(?:\?|$)/i);return match?match[1].toLowerCase():""}function extnameUuidFromUrl(url){var match=String(url||"").match(/\/query-extname\/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}(?:@[^.\\/?]+)?)(?:\?|$)/i);return match?match[1].toLowerCase():""}function configNameFromUrl(url){var match=String(url||"").match(/\/(?:assets|remote)\/([^/]+)\/(?:config|cc\.config)\.json(?:\?|$)/i);return match?match[1]:""}function lookup(url){if(!pack)return null;var extUuid=extnameUuidFromUrl(url);if(extUuid){if(pack.assets&&Object.prototype.hasOwnProperty.call(pack.assets,extUuid))return"";return null}var uuid=uuidFromUrl(url);if(uuid&&pack.assets&&Object.prototype.hasOwnProperty.call(pack.assets,uuid)){return pack.assets[uuid]}var configName=configNameFromUrl(url);if(configName&&pack.configs&&Object.prototype.hasOwnProperty.call(pack.configs,configName)){return pack.configs[configName]}return null}function serve(xhr,data){var isString=typeof data==="string";var text=isString?data:JSON.stringify(data);Object.defineProperty(xhr,"readyState",{configurable:true,get:function(){return 4}});Object.defineProperty(xhr,"status",{configurable:true,get:function(){return 200}});Object.defineProperty(xhr,"response",{configurable:true,get:function(){return xhr.responseType==="json"&&!isString?data:text}});Object.defineProperty(xhr,"responseText",{configurable:true,get:function(){return text}});if(typeof xhr.onreadystatechange==="function")xhr.onreadystatechange();if(typeof xhr.onload==="function")xhr.onload()}function flush(){var queue=pending.splice(0);for(var i=0;i<queue.length;i++)queue[i]()}var xhrOpen=XMLHttpRequest.prototype.open;var xhrSend=XMLHttpRequest.prototype.send;XMLHttpRequest.prototype.open=function(method,url){this.__pcUrl=url;return xhrOpen.apply(this,arguments)};XMLHttpRequest.prototype.send=function(body){var xhr=this;var url=xhr.__pcUrl;if(url&&String(url).indexOf("playcraft-json-pack.json")!==-1){return xhrSend.apply(xhr,arguments)}var run=function(){var data=lookup(url);if(data!=null){serve(xhr,data);return}xhrSend.call(xhr,body)};if(pack){run();return}pending.push(run)};if(typeof globalThis!=="undefined"&&typeof globalThis.fetch==="function"){var nativeFetch=globalThis.fetch.bind(globalThis);globalThis.fetch=function(input,init){var url=typeof input==="string"?input:input&&input.url;if(url&&String(url).indexOf("playcraft-json-pack.json")!==-1){return nativeFetch.apply(this,arguments)}var run=function(){var data=lookup(url);if(data!=null){var isString=typeof data==="string";var text=isString?data:JSON.stringify(data);return Promise.resolve(new Response(text,{status:200,headers:{"Content-Type":isString?"text/plain":"application/json"}}))}return nativeFetch.call(this,input,init)};if(pack)return run();return new Promise(function(resolve,reject){pending.push(function(){Promise.resolve(run()).then(resolve,reject)})})}}var req=new XMLHttpRequest;req.open("GET",packUrl,true);req.responseType="json";req.onload=function(){pack=req.response&&typeof req.response==="object"?req.response:{assets:{},configs:{}};flush()};req.onerror=function(){pack={assets:{},configs:{}};flush()};req.send(null)})();/* playcraft-json-pack:end */
@@ -0,0 +1,5 @@
1
+ "use strict";const crypto=require("crypto");const fs=require("fs");const http=require("http");const https=require("https");const path=require("path");const{resolveCocosBin}=require("./cli.js");const HOOK_START="/* playcraft-json-pack:start */";const HOOK_END="/* playcraft-json-pack:end */";const PACK_FILE="playcraft-json-pack.json";const UUID_RE=/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}(?:@[0-9a-z]+)?/gi;const UUID_FILE_RE=/^([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}(?:@.+)?)\.json$/i;const FALLBACK_MAX_BYTES=200*1024;const FILE_MAX_BYTES=5*1024*1024;function systemJsDir(projectRoot){return path.join(projectRoot,"temp/programming/preview/systemjs")}function hookSource(){return fs.readFileSync(path.join(__dirname,"preview-json-hook.js"),"utf8").trim()+"\n"}function uuidFromUrl(url){const match=String(url||"").match(/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}(?:@[^.\\/?]+)?)(?:\.json)?(?:\?|$)/i);return match?match[1].toLowerCase():""}function configNameFromUrl(url){const match=String(url||"").match(/\/(?:assets|remote)\/([^/]+)\/(?:config|cc\.config)\.json(?:\?|$)/i);return match?match[1]:""}function addUuid(set,value){if(typeof value!=="string"||!value)return;const inner=value.match(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}(?:@[0-9a-z]+)?/i);if(inner)set.add(inner[0].toLowerCase())}function collectFromSettings(settings,seeds){if(!settings||typeof settings!=="object")return;const builtins=settings.engine&&settings.engine.builtinAssets;if(Array.isArray(builtins)){for(const uuid of builtins)addUuid(seeds,uuid)}addUuid(seeds,settings.launch&&settings.launch.launchScene);addUuid(seeds,settings.launchScene);const scenes=settings.scenes||settings.launch&&settings.launch.scenes;if(Array.isArray(scenes)){for(const scene of scenes){if(scene&&typeof scene==="object")addUuid(seeds,scene.uuid);else addUuid(seeds,scene)}}}function collectFromConfig(config,seeds){if(!config||typeof config!=="object")return;for(const uuid of config.uuids||[])addUuid(seeds,String(uuid));for(const uuid of Object.keys(config.paths||{}))addUuid(seeds,uuid);const scenes=config.scenes||{};if(typeof scenes==="object"){for(const value of Object.values(scenes)){if(Array.isArray(value)){for(const item of value)addUuid(seeds,String(item))}else{addUuid(seeds,String(value))}}}}function parseSettings(text){const match=String(text||"").match(/window\._CCSettings\s*=\s*(\{[\s\S]*\});\s*$/);if(!match)return null;return JSON.parse(match[1])}function fetchText(url,timeout=8000){return new Promise((resolve,reject)=>{const client=String(url).startsWith("https:")?https:http;const req=client.get(url,{timeout},res=>{const chunks=[];res.on("data",chunk=>chunks.push(chunk));res.on("end",()=>{const body=Buffer.concat(chunks).toString("utf8");if((res.statusCode||0)>=400){reject(new Error(`${res.statusCode} ${url}`));return}resolve(body)})});req.on("timeout",()=>{req.destroy();reject(new Error(`timeout ${url}`))});req.on("error",reject)})}function startSceneUuid(projectRoot){try{const cfg=JSON.parse(fs.readFileSync(path.join(projectRoot,"settings/cocos.config.json"),"utf8"));return cfg.builder&&cfg.builder.common&&cfg.builder.common.startScene||cfg.builder&&cfg.builder.platforms&&cfg.builder.platforms["web-mobile"]&&cfg.builder.platforms["web-mobile"].startScene||cfg.builder&&cfg.builder.platforms&&cfg.builder.platforms["web-desktop"]&&cfg.builder.platforms["web-desktop"].startScene||""}catch{return""}}function resolveEngineLibraryDirs(){const dirs=[];const add=dir=>{if(dir&&fs.existsSync(dir)&&!dirs.includes(dir))dirs.push(dir)};if(process.env.COCOS_ENGINE){add(path.join(process.env.COCOS_ENGINE,"editor/library"))}const bin=resolveCocosBin();const starts=[];if(bin&&path.isAbsolute(bin))starts.push(bin);try{const{execSync}=require("child_process");const which=execSync("command -v cocos",{encoding:"utf8"}).trim();if(which)starts.push(which)}catch{}for(const start of starts){try{let current=fs.realpathSync(start);for(let i=0;i<8;i++){add(path.join(current,"packages/engine/editor/library"));add(path.join(current,"engine/editor/library"));add(path.join(current,"editor/library"));current=path.dirname(current)}}catch{}}add("/usr/local/lib/node_modules/cocos-cli/packages/engine/editor/library");return dirs}function libraryDirs(projectRoot,extraDirs){const dirs=[];const projectLib=path.join(projectRoot,"library");if(fs.existsSync(projectLib))dirs.push(projectLib);for(const dir of extraDirs||resolveEngineLibraryDirs()){if(dir&&fs.existsSync(dir)&&!dirs.includes(dir))dirs.push(dir)}return dirs}function findLibraryJson(uuid,dirs){const name=`${uuid}.json`;const prefix=uuid.slice(0,2);for(const dir of dirs){const abs=path.join(dir,prefix,name);if(fs.existsSync(abs))return abs}return""}function scanFallbackUuids(dirs){const seeds=new Set;for(const dir of dirs){if(!fs.existsSync(dir))continue;for(const bucket of fs.readdirSync(dir)){const absBucket=path.join(dir,bucket);let stat;try{stat=fs.statSync(absBucket)}catch{continue}if(!stat.isDirectory())continue;for(const name of fs.readdirSync(absBucket)){const match=name.match(UUID_FILE_RE);if(!match)continue;const abs=path.join(absBucket,name);try{if(fs.statSync(abs).size>FALLBACK_MAX_BYTES)continue}catch{continue}seeds.add(match[1].toLowerCase())}}}return seeds}function closeUuidJson(seeds,dirs){const assets={};const queue=[...seeds];const seen=new Set(seeds);while(queue.length){const uuid=queue.pop();const abs=findLibraryJson(uuid,dirs);if(!abs)continue;let stat;try{stat=fs.statSync(abs)}catch{continue}if(stat.size>FILE_MAX_BYTES)continue;let json;try{json=JSON.parse(fs.readFileSync(abs,"utf8"))}catch{continue}assets[uuid]=json;const text=JSON.stringify(json);UUID_RE.lastIndex=0;let match;while(match=UUID_RE.exec(text)){const next=match[0].toLowerCase();if(seen.has(next))continue;seen.add(next);queue.push(next)}}return assets}function injectJsonPackHook(projectRoot,packHash){const dir=systemJsDir(projectRoot);const systemJsPath=path.join(dir,"system.js");if(!fs.existsSync(systemJsPath))return false;let source=fs.readFileSync(systemJsPath,"utf8");const start=source.indexOf(HOOK_START);const end=source.indexOf(HOOK_END);if(start!==-1&&end!==-1&&end>start){source=`${source.slice(0,start)}${source.slice(end+HOOK_END.length)}`.replace(/^\n/,"")}let hook=hookSource();if(packHash){hook=hook.replace(/var packHash = '';/,`var packHash = ${JSON.stringify(packHash)};`)}fs.writeFileSync(systemJsPath,`${hook}${source}`);return true}async function loadPreviewMeta(options){if(options.settings||options.bundleConfigs){return{settings:options.settings||null,configs:options.bundleConfigs||{}}}const origin=options.previewOrigin;if(!origin)return{settings:null,configs:{}};const fetch=options.fetchText||fetchText;const settings=parseSettings(await fetch(`${origin.replace(/\/$/,"")}/preview/settings.js`));const bundles=settings&&settings.assets&&settings.assets.projectBundles||["internal","main","resources"];const configs={};for(const name of bundles){try{configs[name]=JSON.parse(await fetch(`${origin.replace(/\/$/,"")}/assets/${name}/config.json`))}catch{}}return{settings,configs}}/**
2
+ * Pack preview UUID JSON + bundle configs into one file and intercept XHR
3
+ * from system.js (before builtin assets load). Official merge_all_json is
4
+ * builder-only and uses a compiled format preview cannot feed.
5
+ */async function packPreviewJson(projectRoot,options={}){const dirs=libraryDirs(projectRoot,options.engineLibraryDirs);const{settings,configs}=await loadPreviewMeta(options);const seeds=new Set;collectFromSettings(settings,seeds);for(const config of Object.values(configs||{}))collectFromConfig(config,seeds);addUuid(seeds,startSceneUuid(projectRoot));if(!seeds.size){for(const uuid of scanFallbackUuids(dirs))seeds.add(uuid)}const assets=closeUuidJson(seeds,dirs);const pack={assets,configs:configs||{}};const json=`${JSON.stringify(pack)}\n`;const dir=systemJsDir(projectRoot);fs.mkdirSync(dir,{recursive:true});const abs=path.join(dir,PACK_FILE);const tmp=`${abs}.tmp`;fs.writeFileSync(tmp,json);fs.renameSync(tmp,abs);const packHash=crypto.createHash("sha1").update(json).digest("hex").slice(0,10);const hooked=injectJsonPackHook(projectRoot,packHash);return{files:Object.keys(assets).length,configs:Object.keys(pack.configs).length,bytes:Buffer.byteLength(json),hooked,pack:`./${PACK_FILE}`}}module.exports={packPreviewJson,injectJsonPackHook,uuidFromUrl,configNameFromUrl,parseSettings,collectFromSettings,collectFromConfig,PACK_FILE,HOOK_START};
@@ -0,0 +1,2 @@
1
+ "use strict";const{describe,it,before,after}=require("node:test");const assert=require("node:assert/strict");const fs=require("fs");const os=require("os");const path=require("path");const{packPreviewJson,uuidFromUrl,configNameFromUrl,HOOK_START}=require("./preview-json-pack.js");describe("packPreviewJson",()=>{let root;const scene="0a1b2c3d-4e5f-4a6b-8c7d-9e0f1a2b3c4d";const builtin="970b0598-bcb0-4714-91fb-2e81440dccd8";const extra="11111111-2222-4333-8444-555555555555";before(()=>{root=fs.mkdtempSync(path.join(os.tmpdir(),"preview-json-"));const writeJson=(dir,uuid,json)=>{const abs=path.join(dir,uuid.slice(0,2),`${uuid}.json`);fs.mkdirSync(path.dirname(abs),{recursive:true});fs.writeFileSync(abs,`${JSON.stringify(json)}\n`)};writeJson(path.join(root,"library"),scene,{name:"scene",ref:{__uuid__:extra}});writeJson(path.join(root,"library"),extra,{name:"extra"});writeJson(path.join(root,"engine-lib"),builtin,{name:"effect"});fs.mkdirSync(path.join(root,"temp/programming/preview/systemjs"),{recursive:true});fs.writeFileSync(path.join(root,"temp/programming/preview/systemjs/system.js"),"(function () { 'use strict'; window.System = {}; })();\n");fs.mkdirSync(path.join(root,"settings"),{recursive:true});fs.writeFileSync(path.join(root,"settings/cocos.config.json"),JSON.stringify({builder:{common:{startScene:scene}}}))});after(()=>{fs.rmSync(root,{recursive:true,force:true})});it("parses preview json urls",()=>{assert.equal(uuidFromUrl("/assets/internal/import/97/970b0598-bcb0-4714-91fb-2e81440dccd8.json?t=1"),builtin);assert.equal(uuidFromUrl(`/scene/${scene}.json`),scene);assert.equal(uuidFromUrl(`https://host/api/sandbox-preview/assets/sid/slot/import/97/${builtin}`),builtin);assert.equal(configNameFromUrl("/assets/internal/config.json"),"internal")});it("packs uuid closure + configs and injects the xhr hook",async()=>{const result=await packPreviewJson(root,{engineLibraryDirs:[path.join(root,"engine-lib")],settings:{engine:{builtinAssets:[builtin]},launch:{launchScene:scene},assets:{projectBundles:["internal"]}},bundleConfigs:{internal:{name:"internal",uuids:[builtin],paths:{[builtin]:["db://internal/effect","cc.EffectAsset"]}}}});assert.equal(result.files,3);assert.equal(result.configs,1);assert.equal(result.hooked,true);const pack=JSON.parse(fs.readFileSync(path.join(root,"temp/programming/preview/systemjs/playcraft-json-pack.json"),"utf8"));assert.equal(pack.assets[scene].name,"scene");assert.equal(pack.assets[extra].name,"extra");assert.equal(pack.assets[builtin].name,"effect");assert.equal(pack.configs.internal.name,"internal");const systemJs=fs.readFileSync(path.join(root,"temp/programming/preview/systemjs/system.js"),"utf8");assert.equal(systemJs.includes(HOOK_START),true);assert.equal(systemJs.includes("XMLHttpRequest.prototype.send"),true);assert.match(systemJs,/window\.System = \{\}/);const again=await packPreviewJson(root,{engineLibraryDirs:[path.join(root,"engine-lib")],settings:{engine:{builtinAssets:[builtin]},launch:{launchScene:scene}},bundleConfigs:{internal:{name:"internal",uuids:[builtin],paths:{}}}});assert.equal(again.hooked,true);const injected=fs.readFileSync(path.join(root,"temp/programming/preview/systemjs/system.js"),"utf8");assert.equal(injected.split(HOOK_START).length-1,1)});it("serves packed uuid json from the xhr hook without network",async()=>{await packPreviewJson(root,{engineLibraryDirs:[path.join(root,"engine-lib")],settings:{engine:{builtinAssets:[builtin]},launch:{launchScene:scene}},bundleConfigs:{internal:{name:"internal",uuids:[builtin],paths:{}}}});const hook=fs.readFileSync(path.join(root,"temp/programming/preview/systemjs/system.js"),"utf8").split("(function () { 'use strict'; window.System")[0];const pack=JSON.parse(fs.readFileSync(path.join(root,"temp/programming/preview/systemjs/playcraft-json-pack.json"),"utf8"));const instances=[];function FakeXHR(){instances.push(this);this.readyState=1;this.status=0;this.response=null;this.responseType="json";this.onload=null;this.onreadystatechange=null;this.__network=false}FakeXHR.prototype.open=function(method,url){this.__pcUrl=url};FakeXHR.prototype.send=function(){this.__network=true};global.performance={getEntriesByType:()=>[]};global.document={currentScript:null,getElementsByTagName:()=>[]};global.XMLHttpRequest=FakeXHR;// eslint-disable-next-line no-eval
2
+ eval(hook.replace(HOOK_START,"").replace("/* playcraft-json-pack:end */",""));const packXhr=instances[0];assert.match(String(packXhr.__pcUrl),/playcraft-json-pack\.json/);assert.equal(String(packXhr.__pcUrl).startsWith("/scripting/"),false);packXhr.response=pack;packXhr.onload();const asset=new FakeXHR;let loaded=null;asset.onload=function(){loaded=asset.response};XMLHttpRequest.prototype.open.call(asset,"GET",`/scene/${scene}.json`);XMLHttpRequest.prototype.send.call(asset,null);assert.equal(asset.__network,false);assert.equal(asset.status,200);assert.equal(loaded.name,"scene");const config=new FakeXHR;let configLoaded=null;config.onload=function(){configLoaded=config.response};XMLHttpRequest.prototype.open.call(config,"GET","/assets/internal/config.json");XMLHttpRequest.prototype.send.call(config,null);assert.equal(config.__network,false);assert.equal(configLoaded.name,"internal");const ext=new FakeXHR;ext.responseType="";let extLoaded=null;ext.onload=function(){extLoaded=ext.response};XMLHttpRequest.prototype.open.call(ext,"GET",`/query-extname/${scene}`);XMLHttpRequest.prototype.send.call(ext,null);assert.equal(ext.__network,false);assert.equal(extLoaded,"");const bare=new FakeXHR;let bareLoaded=null;bare.onload=function(){bareLoaded=bare.response};XMLHttpRequest.prototype.open.call(bare,"GET",`/api/sandbox-preview/assets/x/y/${builtin}`);XMLHttpRequest.prototype.send.call(bare,null);assert.equal(bare.__network,false);assert.equal(bareLoaded.name,"effect");delete global.XMLHttpRequest;delete global.performance;delete global.document})});
@@ -0,0 +1 @@
1
+ "use strict";const http=require("http");const net=require("net");const{cacheControlFor}=require("./preview-cache.js");const HOP=new Set(["connection","keep-alive","proxy-authenticate","proxy-authorization","te","trailer","transfer-encoding","upgrade"]);function findFreePort(exclude=0){return new Promise((resolve,reject)=>{const tryListen=()=>{const server=net.createServer();server.unref();server.once("error",()=>tryListen());server.listen(0,"127.0.0.1",()=>{const addr=server.address();const port=addr&&addr.port;server.close(err=>{if(err){reject(err);return}if(!port||port===exclude)tryListen();else resolve(port)})})};tryListen()})}function copyHeaders(src){const out={};for(const[key,value]of Object.entries(src||{})){if(HOP.has(key.toLowerCase()))continue;if(value==null)continue;out[key]=value}return out}function applyCacheControl(url,headers){const next=cacheControlFor(url);if(!next)return headers;const out={...headers};out["cache-control"]=next;return out}function startPreviewProxy({listenPort,targetPort}){const server=http.createServer((req,res)=>{const headers=copyHeaders(req.headers);headers.host=`127.0.0.1:${targetPort}`;const proxyReq=http.request({hostname:"127.0.0.1",port:targetPort,path:req.url,method:req.method,headers},proxyRes=>{const outHeaders=applyCacheControl(req.url,copyHeaders(proxyRes.headers));res.writeHead(proxyRes.statusCode||502,outHeaders);proxyRes.pipe(res)});proxyReq.on("error",()=>{if(!res.headersSent){res.writeHead(502)}res.end("preview upstream down")});req.pipe(proxyReq)});server.on("upgrade",(req,clientSocket,head)=>{const headers=copyHeaders(req.headers);headers.host=`127.0.0.1:${targetPort}`;headers.connection="upgrade";headers.upgrade=req.headers.upgrade||"websocket";const proxyReq=http.request({hostname:"127.0.0.1",port:targetPort,path:req.url,method:req.method,headers});proxyReq.on("upgrade",(proxyRes,proxySocket,proxyHead)=>{const lines=["HTTP/1.1 101 Switching Protocols"];for(const[key,value]of Object.entries(proxyRes.headers)){if(value==null)continue;const text=Array.isArray(value)?value.join(", "):String(value);lines.push(`${key}: ${text}`)}clientSocket.write(`${lines.join("\r\n")}\r\n\r\n`);if(head&&head.length)proxySocket.write(head);if(proxyHead&&proxyHead.length)clientSocket.write(proxyHead);proxySocket.pipe(clientSocket);clientSocket.pipe(proxySocket)});proxyReq.on("error",()=>clientSocket.destroy());proxyReq.end()});return new Promise((resolve,reject)=>{server.once("error",reject);server.listen(listenPort,"127.0.0.1",()=>{resolve({close:()=>new Promise((done,fail)=>{server.close(err=>err?fail(err):done())})})})})}module.exports={findFreePort,startPreviewProxy,applyCacheControl};
@@ -0,0 +1,4 @@
1
+ "use strict";const fs=require("fs");const path=require("path");const{concatPreviewScripts}=require("./preview-concat.js");const{packPreviewJson}=require("./preview-json-pack.js");function previewTargetDir(projectRoot){return path.join(projectRoot,"temp/programming/packer-driver/targets/preview")}/**
2
+ * Re-apply script concat + JSON pack after a build cleared editor caches.
3
+ * Safe no-op if preview was never compiled.
4
+ */async function rebundlePreviewMerge(projectRoot,options={}){const previewDir=previewTargetDir(projectRoot);if(!fs.existsSync(path.join(previewDir,"import-map.json"))){return null}let scriptResult=null;try{scriptResult=concatPreviewScripts(projectRoot);if(scriptResult){const kb=Math.max(1,Math.round(scriptResult.bytes/1024));console.log(`[cocos] preview scripts ${scriptResult.modules} → 1 bundle (${kb} kB)`)}}catch(err){console.warn(`[cocos] preview script concat skipped: ${err.message}`)}let jsonResult=null;try{const port=options.previewPort||process.env.PREVIEW_PORT;const previewOrigin=port?`http://127.0.0.1:${port}`:options.previewOrigin;jsonResult=await packPreviewJson(projectRoot,{previewOrigin,fetchText:options.fetchText,engineLibraryDirs:options.engineLibraryDirs});if(jsonResult){const kb=Math.max(1,Math.round(jsonResult.bytes/1024));console.log(`[cocos] preview json ${jsonResult.files} → 1 pack (${kb} kB)`)}}catch(err){console.warn(`[cocos] preview json pack skipped: ${err.message}`)}return{scriptResult,jsonResult}}module.exports={previewTargetDir,rebundlePreviewMerge};
@@ -1,3 +1,3 @@
1
- "use strict";const fs=require("fs");const path=require("path");const JSON5=require("json5");const{getBase64FromFile,getAllFilesFromDir}=require("./fs");const{THEME_ASSET_EXT}=require("./constants");const{validateThemeData}=require("../../utils/validateThemeData");function detectCurrentTheme(projectRoot,themeConfig){const entryFile=themeConfig&&themeConfig.entryFile||"src/theme/index.ts";const entryPath=path.join(projectRoot,entryFile);if(!fs.existsSync(entryPath))return null;const content=fs.readFileSync(entryPath,"utf8");const match=content.match(/from\s+['"]\.\/([^'"]+)['"]/);return match?match[1]:null}function collectDirAssets(assetsDir,keyPrefix){const map={};if(!fs.existsSync(assetsDir))return map;const root=assetsDir.replace(/\\/g,"/");for(const filePath of getAllFilesFromDir(assetsDir)){if(!THEME_ASSET_EXT.test(filePath))continue;const rel=filePath.replace(/\\/g,"/").slice(root.length).replace(/^\//,"");map[`${keyPrefix}${rel}`]=getBase64FromFile(filePath)}return map}function collectReferencedFiles(projectRoot,sourceDir,themeName,mergedData,assetRecord){if(!mergedData||typeof mergedData!=="object")return;const themeRoot=path.join(projectRoot,sourceDir,themeName);for(const value of Object.values(mergedData)){if(typeof value==="string"){if(assetRecord[value])continue;const rel=value.replace(/^\.\//,"");const candidates=[path.join(themeRoot,rel),path.join(themeRoot,"assets",path.basename(rel)),path.resolve(projectRoot,rel)];const abs=candidates.find(p=>fs.existsSync(p)&&fs.statSync(p).isFile()&&THEME_ASSET_EXT.test(p));if(abs)assetRecord[value]=getBase64FromFile(abs)}else if(value&&typeof value==="object"&&!Array.isArray(value)){collectReferencedFiles(projectRoot,sourceDir,themeName,value,assetRecord)}}}function loadThemeI18n(projectRoot,sourceDir,themeName){const p=path.join(projectRoot,sourceDir,themeName,"theme.i18n.runtime.json5");if(!fs.existsSync(p))return{};try{return JSON5.parse(fs.readFileSync(p,"utf8"))||{}}catch{return{}}}/**
1
+ "use strict";const fs=require("fs");const path=require("path");const JSON5=require("json5");const{getBase64FromFile,getAllFilesFromDir,sniffAudioMime}=require("./fs");const{THEME_ASSET_EXT}=require("./constants");const{validateThemeData}=require("../../utils/validateThemeData");function detectCurrentTheme(projectRoot,themeConfig){const entryFile=themeConfig&&themeConfig.entryFile||"src/theme/index.ts";const entryPath=path.join(projectRoot,entryFile);if(!fs.existsSync(entryPath))return null;const content=fs.readFileSync(entryPath,"utf8");const match=content.match(/from\s+['"]\.\/([^'"]+)['"]/);return match?match[1]:null}function isInlinableThemeAsset(filePath){if(THEME_ASSET_EXT.test(filePath))return true;try{return Boolean(sniffAudioMime(fs.readFileSync(filePath)))}catch{return false}}function collectDirAssets(assetsDir,keyPrefix){const map={};if(!fs.existsSync(assetsDir))return map;const root=assetsDir.replace(/\\/g,"/");for(const filePath of getAllFilesFromDir(assetsDir)){if(!isInlinableThemeAsset(filePath))continue;const rel=filePath.replace(/\\/g,"/").slice(root.length).replace(/^\//,"");map[`${keyPrefix}${rel}`]=getBase64FromFile(filePath)}return map}function collectReferencedFiles(projectRoot,sourceDir,themeName,mergedData,assetRecord){if(!mergedData||typeof mergedData!=="object")return;const themeRoot=path.join(projectRoot,sourceDir,themeName);for(const value of Object.values(mergedData)){if(typeof value==="string"){if(assetRecord[value])continue;const rel=value.replace(/^\.\//,"");const candidates=[path.join(themeRoot,rel),path.join(themeRoot,"assets",path.basename(rel)),path.resolve(projectRoot,rel)];const abs=candidates.find(p=>fs.existsSync(p)&&fs.statSync(p).isFile()&&isInlinableThemeAsset(p));if(abs)assetRecord[value]=getBase64FromFile(abs)}else if(value&&typeof value==="object"&&!Array.isArray(value)){collectReferencedFiles(projectRoot,sourceDir,themeName,value,assetRecord)}}}function loadThemeI18n(projectRoot,sourceDir,themeName){const p=path.join(projectRoot,sourceDir,themeName,"theme.i18n.runtime.json5");if(!fs.existsSync(p))return{};try{return JSON5.parse(fs.readFileSync(p,"utf8"))||{}}catch{return{}}}/**
2
2
  * Read src/theme json5 + theme/assets the same way Phaser webpack inlines them.
3
- */function loadThemePayload(projectRoot,themeName,themeConfig={}){const sourceDir=themeConfig.sourceDir||"src/theme";const{mergedData,errors}=validateThemeData(themeName,projectRoot,themeConfig);if(errors){throw new Error(`Theme "${themeName}" validation failed:\n${errors.join("\n")}`)}const assetsDir=path.join(projectRoot,sourceDir,themeName,"assets");const assetRecordByPath=collectDirAssets(assetsDir,"./assets/");collectReferencedFiles(projectRoot,sourceDir,themeName,mergedData,assetRecordByPath);return{themeName,mergedData:mergedData||{},i18nOverride:loadThemeI18n(projectRoot,sourceDir,themeName),assetRecordByPath}}function writeGeneratedThemeModule(filePath,payload){const header="/* eslint-disable */\n"+"// Generated from src/theme by playable-scripts. Do not edit.\n";const body=`export const THEME_DATA = ${JSON.stringify(payload.mergedData,null,2)} as Record<string, unknown>;\n`+`export const THEME_I18N_OVERRIDE = ${JSON.stringify(payload.i18nOverride,null,2)} as Record<string, Record<string, string>>;\n`+`export const THEME_ASSETS = ${JSON.stringify(payload.assetRecordByPath)} as Record<string, string>;\n`;const next=header+body;if(fs.existsSync(filePath)&&fs.readFileSync(filePath,"utf8")===next){return false}fs.mkdirSync(path.dirname(filePath),{recursive:true});fs.writeFileSync(filePath,next);return true}function syncGeneratedTheme(projectRoot,options={}){const themeConfig=options.themeConfig||{sourceDir:"src/theme",entryFile:"src/theme/index.ts"};const themeName=options.themeName||detectCurrentTheme(projectRoot,themeConfig);if(!themeName)return null;const payload=loadThemePayload(projectRoot,themeName,themeConfig);const rel=options.generatedRel||"assets/scripts/platform/theme.generated.ts";const changed=writeGeneratedThemeModule(path.join(projectRoot,rel),payload);return{themeName,payload,generatedRel:rel,changed}}module.exports={detectCurrentTheme,loadThemePayload,writeGeneratedThemeModule,syncGeneratedTheme,collectDirAssets};
3
+ */function loadThemePayload(projectRoot,themeName,themeConfig={}){const sourceDir=themeConfig.sourceDir||"src/theme";const{mergedData,errors}=validateThemeData(themeName,projectRoot,themeConfig);if(errors){throw new Error(`Theme "${themeName}" validation failed:\n${errors.join("\n")}`)}const assetsDir=path.join(projectRoot,sourceDir,themeName,"assets");const assetRecordByPath=collectDirAssets(assetsDir,"./assets/");collectReferencedFiles(projectRoot,sourceDir,themeName,mergedData,assetRecordByPath);return{themeName,mergedData:mergedData||{},i18nOverride:loadThemeI18n(projectRoot,sourceDir,themeName),assetRecordByPath}}function copyThemeAssetsToResources(projectRoot,themeName){const srcDir=path.join(projectRoot,"src/theme",themeName,"assets");const destDir=path.join(projectRoot,"assets/resources/textures");if(!fs.existsSync(srcDir)||!fs.existsSync(destDir))return false;let changed=false;for(const name of fs.readdirSync(srcDir)){if(!/\.(png|webp|jpe?g)$/i.test(name))continue;const dest=path.join(destDir,name);if(!fs.existsSync(dest))continue;const from=path.join(srcDir,name);const next=fs.readFileSync(from);const prev=fs.readFileSync(dest);if(Buffer.compare(next,prev)===0)continue;fs.writeFileSync(dest,next);changed=true}return changed}function themeAssetStamp(dir){if(!fs.existsSync(dir))return"";try{return fs.readdirSync(dir).filter(name=>THEME_ASSET_EXT.test(name)).map(name=>{const st=fs.statSync(path.join(dir,name));return`${name}:${st.size}:${Math.round(st.mtimeMs)}`}).join("|")}catch{return""}}function writeGeneratedThemeModule(filePath,payload){const header="/* eslint-disable */\n"+"// Generated from src/theme by playable-scripts. Do not edit.\n";const body=`export const THEME_DATA = ${JSON.stringify(payload.mergedData,null,2)} as Record<string, unknown>;\n`+`export const THEME_I18N_OVERRIDE = ${JSON.stringify(payload.i18nOverride,null,2)} as Record<string, Record<string, string>>;\n`+`export const THEME_ASSETS = ${JSON.stringify(payload.assetRecordByPath)} as Record<string, string>;\n`;const next=header+body;if(fs.existsSync(filePath)&&fs.readFileSync(filePath,"utf8")===next){return false}fs.mkdirSync(path.dirname(filePath),{recursive:true});fs.writeFileSync(filePath,next);return true}function syncGeneratedTheme(projectRoot,options={}){const themeConfig=options.themeConfig||{sourceDir:"src/theme",entryFile:"src/theme/index.ts"};const themeName=options.themeName||detectCurrentTheme(projectRoot,themeConfig);if(!themeName)return null;const payload=loadThemePayload(projectRoot,themeName,themeConfig);const rel=options.generatedRel||"assets/scripts/platform/theme.generated.ts";const copied=copyThemeAssetsToResources(projectRoot,themeName);const changed=writeGeneratedThemeModule(path.join(projectRoot,rel),payload);return{themeName,payload,generatedRel:rel,changed:changed||copied}}module.exports={detectCurrentTheme,loadThemePayload,writeGeneratedThemeModule,syncGeneratedTheme,collectDirAssets,themeAssetStamp};
@@ -0,0 +1,4 @@
1
+ "use strict";const fs=require("fs");const path=require("path");const{spawnSync}=require("child_process");function exists(p){try{return Boolean(p)&&fs.existsSync(p)}catch{return false}}function isWritableDir(dir){try{fs.mkdirSync(dir,{recursive:true});const probe=path.join(dir,`.write-probe-${process.pid}`);fs.writeFileSync(probe,"ok");fs.unlinkSync(probe);return true}catch{return false}}function which(bin){if(!bin)return"";if(path.isAbsolute(bin)&&exists(bin))return bin;const result=spawnSync(process.platform==="win32"?"where":"which",[bin],{encoding:"utf8"});const line=(result.stdout||"").split(/\r?\n/).map(s=>s.trim()).find(Boolean);if(!line)return"";try{return fs.realpathSync(line)}catch{return line}}function climbToCliRoot(start){let dir=start;try{if(exists(start)&&fs.statSync(start).isFile())dir=path.dirname(start)}catch{return""}for(let i=0;i<6;i++){if(exists(path.join(dir,"packages","engine","package.json"))||exists(path.join(dir,"packages","engine","cc.config.json"))||exists(path.join(dir,"packages","engine"))){return dir}const parent=path.dirname(dir);if(parent===dir)break;dir=parent}return""}function resolveCocosCliRoot(env=process.env){if(env.COCOS_CLI_ROOT&&exists(env.COCOS_CLI_ROOT)){return path.resolve(env.COCOS_CLI_ROOT)}const bin=which(env.COCOS_CLI||"cocos");if(bin){const root=climbToCliRoot(bin);if(root)return root}const fallbacks=["/opt/cocos-cli","/usr/local/cocos-cli",path.join(env.HOME||"","cocos-cli")];return fallbacks.find(p=>p&&exists(path.join(p,"packages","engine")))||""}function copyTree(src,dest){if(exists(dest))return;fs.mkdirSync(path.dirname(dest),{recursive:true});const al=spawnSync("cp",["-al",src,dest],{encoding:"utf8"});if(al.status===0&&exists(dest))return;const a=spawnSync("cp",["-a",src,dest],{encoding:"utf8"});if(a.status===0&&exists(dest))return;if(typeof fs.cpSync==="function"){fs.cpSync(src,dest,{recursive:true});return}throw new Error(`[cocos] failed to copy ${src} → ${dest}: ${(al.stderr||a.stderr||"").trim()}`)}function symlinkOrSkip(from,to){if(exists(to))return;fs.symlinkSync(from,to)}function overlayEngine(srcEngine,destEngine){fs.mkdirSync(destEngine,{recursive:true});for(const name of fs.readdirSync(srcEngine)){if(name==="bin"||name==="editor")continue;symlinkOrSkip(path.join(srcEngine,name),path.join(destEngine,name))}const srcBin=path.join(srcEngine,"bin");const srcEditor=path.join(srcEngine,"editor");if(exists(srcBin))copyTree(srcBin,path.join(destEngine,"bin"));else fs.mkdirSync(path.join(destEngine,"bin"),{recursive:true});if(exists(srcEditor))copyTree(srcEditor,path.join(destEngine,"editor"));else fs.mkdirSync(path.join(destEngine,"editor","library"),{recursive:true});fs.mkdirSync(path.join(destEngine,"bin","temp"),{recursive:true});fs.mkdirSync(path.join(destEngine,"editor","library"),{recursive:true})}function shimLinuxFbxConverter(cliRoot){if(process.platform!=="linux")return false;const binDir=path.join(cliRoot,"node_modules","@cocos","fbx-gltf-conv","bin");const linux=path.join(binDir,"linux","FBX-glTF-conv");const darwin=path.join(binDir,"darwin","FBX-glTF-conv");if(!exists(linux)||!exists(darwin))return false;try{fs.unlinkSync(darwin);fs.copyFileSync(linux,darwin);fs.chmodSync(darwin,493);return true}catch{return false}}function writeBinWrapper(destRoot){const candidates=[path.join(destRoot,"src","cli.js"),path.join(destRoot,"dist","cli.js"),path.join(destRoot,"cli.js")];const cliJs=candidates.find(exists);if(!cliJs){throw new Error(`[cocos] overlay missing cli entry under ${destRoot}`)}const binDir=path.join(destRoot,"bin");fs.mkdirSync(binDir,{recursive:true});const bin=path.join(binDir,"cocos");fs.writeFileSync(bin,`#!/usr/bin/env node\nrequire(${JSON.stringify(cliJs)});\n`);fs.chmodSync(bin,493);return bin}function overlayNodeModules(srcNm,destNm){fs.mkdirSync(destNm,{recursive:true});for(const name of fs.readdirSync(srcNm)){if(name==="@cocos")continue;symlinkOrSkip(path.join(srcNm,name),path.join(destNm,name))}const srcCocos=path.join(srcNm,"@cocos");if(!exists(srcCocos))return;const destCocos=path.join(destNm,"@cocos");fs.mkdirSync(destCocos,{recursive:true});for(const name of fs.readdirSync(srcCocos)){if(name==="fbx-gltf-conv"){copyTree(path.join(srcCocos,name),path.join(destCocos,name));continue}symlinkOrSkip(path.join(srcCocos,name),path.join(destCocos,name))}}function overlayCocosCli(srcRoot,destRoot){fs.mkdirSync(destRoot,{recursive:true});const skip=new Set(["src","dist","packages","bin","node_modules"]);for(const name of fs.readdirSync(srcRoot)){if(skip.has(name))continue;symlinkOrSkip(path.join(srcRoot,name),path.join(destRoot,name))}if(exists(path.join(srcRoot,"src")))copyTree(path.join(srcRoot,"src"),path.join(destRoot,"src"));if(exists(path.join(srcRoot,"dist")))copyTree(path.join(srcRoot,"dist"),path.join(destRoot,"dist"));if(exists(path.join(srcRoot,"node_modules"))){overlayNodeModules(path.join(srcRoot,"node_modules"),path.join(destRoot,"node_modules"))}const srcPkgs=path.join(srcRoot,"packages");const destPkgs=path.join(destRoot,"packages");fs.mkdirSync(destPkgs,{recursive:true});if(exists(srcPkgs)){for(const name of fs.readdirSync(srcPkgs)){if(name==="engine")continue;symlinkOrSkip(path.join(srcPkgs,name),path.join(destPkgs,name))}overlayEngine(path.join(srcPkgs,"engine"),path.join(destPkgs,"engine"))}const bin=writeBinWrapper(destRoot);shimLinuxFbxConverter(destRoot);return bin}function engineNeedsOverlay(cliRoot){const engine=path.join(cliRoot,"packages","engine");if(!exists(engine))return false;return!isWritableDir(path.join(engine,"bin","temp"))||!isWritableDir(path.join(engine,"editor","library"))}/**
2
+ * Linux 沙箱里 /opt/cocos-cli/packages/engine 只读时,把 CLI 映到项目 temp,
3
+ * 让 GlobalPaths.enginePath(join(__dirname, '../packages/engine'))落在可写目录。
4
+ */function prepareWritableCocosCli(projectRoot,env=process.env){const srcRoot=resolveCocosCliRoot(env);if(!srcRoot){return{overlaid:false,cliRoot:"",env:{}}}if(!engineNeedsOverlay(srcRoot)){return{overlaid:false,cliRoot:srcRoot,env:{COCOS_ENGINE:path.join(srcRoot,"packages","engine")}}}const destRoot=path.join(projectRoot,"temp","cocos-cli");const destEngine=path.join(destRoot,"packages","engine");const destBin=path.join(destRoot,"bin","cocos");if(!(exists(destBin)&&exists(destEngine)&&isWritableDir(path.join(destEngine,"bin","temp")))){if(exists(destRoot))fs.rmSync(destRoot,{recursive:true,force:true});overlayCocosCli(srcRoot,destRoot)}else{shimLinuxFbxConverter(destRoot)}console.log(`[cocos] engine not writable (${srcRoot}); overlay ${path.relative(projectRoot,destRoot)}`);return{overlaid:true,cliRoot:destRoot,env:{COCOS_CLI:destBin,COCOS_CLI_ROOT:destRoot,COCOS_ENGINE:destEngine}}}module.exports={exists,isWritableDir,which,climbToCliRoot,resolveCocosCliRoot,overlayCocosCli,overlayEngine,shimLinuxFbxConverter,engineNeedsOverlay,prepareWritableCocosCli};
@@ -0,0 +1 @@
1
+ "use strict";const{describe,it,before,after,beforeEach}=require("node:test");const assert=require("node:assert/strict");const fs=require("fs");const os=require("os");const path=require("path");const{overlayCocosCli,engineNeedsOverlay,prepareWritableCocosCli,shimLinuxFbxConverter}=require("./writable-cli.js");function writeFile(file,body){fs.mkdirSync(path.dirname(file),{recursive:true});fs.writeFileSync(file,body)}function makeCliTree(root){writeFile(path.join(root,"src","cli.js"),"console.log(\"cli\");\n");writeFile(path.join(root,"src","global.js"),"const { join } = require(\"path\");\nmodule.exports = { GlobalPaths: { enginePath: join(__dirname, \"..\", \"packages\", \"engine\") } };\n");writeFile(path.join(root,"packages","engine","cc.config.json"),"{}\n");writeFile(path.join(root,"packages","engine","bin","keep.txt"),"cache\n");writeFile(path.join(root,"packages","engine","editor","library",".internal"),"meta\n");writeFile(path.join(root,"packages","other","pkg.json"),"{}\n");writeFile(path.join(root,"node_modules","@cocos","fbx-gltf-conv","bin","linux","FBX-glTF-conv"),"linux-bin");writeFile(path.join(root,"node_modules","@cocos","fbx-gltf-conv","bin","darwin","FBX-glTF-conv"),"darwin-bin");writeFile(path.join(root,"bin","cocos"),"#!/usr/bin/env node\nrequire(\"../src/cli.js\");\n");fs.chmodSync(path.join(root,"bin","cocos"),493)}describe("writable cocos-cli overlay",()=>{let root;beforeEach(()=>{if(root&&fs.existsSync(root))fs.rmSync(root,{recursive:true,force:true});root=fs.mkdtempSync(path.join(os.tmpdir(),"cocos-cli-overlay-"))});after(()=>{if(root&&fs.existsSync(root))fs.rmSync(root,{recursive:true,force:true})});it("reports overlay when engine bin/temp is not writable",()=>{const cli=path.join(root,"opt-cli");makeCliTree(cli);fs.chmodSync(path.join(cli,"packages","engine","bin"),365);assert.equal(engineNeedsOverlay(cli),true);fs.chmodSync(path.join(cli,"packages","engine","bin"),493)});it("maps GlobalPaths.enginePath onto a writable overlay",()=>{const cli=path.join(root,"opt-cli");makeCliTree(cli);const dest=path.join(root,"overlay");const bin=overlayCocosCli(cli,dest);assert.equal(fs.existsSync(bin),true);const enginePath=path.join(dest,"packages","engine");fs.mkdirSync(path.join(enginePath,"bin","temp"),{recursive:true});fs.writeFileSync(path.join(enginePath,"bin","temp","ok.txt"),"1");assert.equal(fs.readFileSync(path.join(enginePath,"bin","keep.txt"),"utf8"),"cache\n");const globalJs=path.join(dest,"src","global.js");delete require.cache[require.resolve(globalJs)];const resolved=require(globalJs).GlobalPaths.enginePath;assert.equal(resolved,enginePath);const srcDarwin=path.join(cli,"node_modules","@cocos","fbx-gltf-conv","bin","darwin","FBX-glTF-conv");const destDarwin=path.join(dest,"node_modules","@cocos","fbx-gltf-conv","bin","darwin","FBX-glTF-conv");assert.equal(fs.readFileSync(srcDarwin,"utf8"),"darwin-bin");if(process.platform==="linux"){assert.equal(fs.readFileSync(destDarwin,"utf8"),"linux-bin")}});it("replaces darwin FBX converter with the linux binary",()=>{const cli=path.join(root,"opt-cli");makeCliTree(cli);if(process.platform!=="linux"){const original=process.platform;Object.defineProperty(process,"platform",{value:"linux"});try{assert.equal(shimLinuxFbxConverter(cli),true)}finally{Object.defineProperty(process,"platform",{value:original})}}else{assert.equal(shimLinuxFbxConverter(cli),true)}const darwin=path.join(cli,"node_modules","@cocos","fbx-gltf-conv","bin","darwin","FBX-glTF-conv");assert.equal(fs.readFileSync(darwin,"utf8"),"linux-bin")});it("prepareWritableCocosCli overlays a locked engine into project temp",()=>{const cli=path.join(root,"opt-cli");makeCliTree(cli);fs.chmodSync(path.join(cli,"packages","engine","bin"),365);const project=path.join(root,"game");fs.mkdirSync(project);const result=prepareWritableCocosCli(project,{COCOS_CLI_ROOT:cli});assert.equal(result.overlaid,true);assert.equal(fs.existsSync(result.env.COCOS_CLI),true);fs.mkdirSync(path.join(result.env.COCOS_ENGINE,"bin","temp"),{recursive:true});fs.writeFileSync(path.join(result.env.COCOS_ENGINE,"bin","temp","ok.txt"),"1");fs.chmodSync(path.join(cli,"packages","engine","bin"),493)})});
@@ -7,5 +7,5 @@
7
7
  {test:/\.ts?$/,exclude:/node_modules/,use:[{loader:"babel-loader",options:{// presets: ['@babel/preset-env'],
8
8
  plugins:options.compilation.allowTemplateLiterals===false?["@babel/plugin-transform-template-literals"]:[]}},{loader:"esbuild-loader",options:{loader:"ts",target:"es2015"}}]},{test:/\.css$/,use:["style-loader","css-loader"]},{test:/\.(js|mjs)$/,// exclude: /node_modules/,
9
9
  use:{loader:"babel-loader",options:{// presets: ['@babel/preset-env'],
10
- plugins:options.compilation.allowTemplateLiterals===false?["@babel/plugin-transform-template-literals"]:[]}}},{test:/\.(gltf)$/,loader:path.join(__dirname,"../../loaders/gltf-loader.js")},{test:/\.(gif|png|jpe?g|webp|mp3|mp4|m4a|ogg|wav|glb|fbx|obj)$/i,type:"asset/inline"},{test:/\.json5$/i,type:"javascript/auto",use:{loader:"json5-loader",options:{esModule:false}}},// 处理 json?url → base64
10
+ plugins:options.compilation.allowTemplateLiterals===false?["@babel/plugin-transform-template-literals"]:[]}}},{test:/\.(gltf)$/,loader:path.join(__dirname,"../../loaders/gltf-loader.js")},{test:/\.(gif|png|jpe?g|webp|mp3|mpeg|mp4|m4a|ogg|wav|glb|fbx|obj)$/i,type:"asset/inline"},{test:/\.json5$/i,type:"javascript/auto",use:{loader:"json5-loader",options:{esModule:false}}},// 处理 json?url → base64
11
11
  {test:/\.json$/i,resourceQuery:/url/,type:"asset/inline",generator:{dataUrl:content=>`data:application/json;base64,${content.toString("base64")}`}},{test:/\.(svg|xml)$/i,type:"asset/source"},{test:/\.atlas$/,type:"asset/inline",generator:{dataUrl:content=>{return`data:text/atlas;base64,${content.toString("base64")}`}}},{test:/\.fnt$/i,type:"asset/inline",generator:{dataUrl:content=>{return`data:text/plain;base64,${content.toString("base64")}`}}},{test:/\.(woff|woff2|eot|ttf|otf)$/i,type:"asset/inline"}]},plugins:[new VueLoaderPlugin]};exports.allowedExtensions=allowedExtensions;exports.webpackCommonConfig=webpackConfig;
package/core/index.d.ts CHANGED
@@ -135,6 +135,9 @@ export interface CLIOptions {
135
135
  skipEngineBuild: boolean;
136
136
  creatorBin: string;
137
137
  generatedThemeFile: string;
138
+ enableSplash: boolean;
139
+ /** Drop library/ + temp/programming before `cocos build`. Batch builds default this on. */
140
+ cleanCache?: boolean;
138
141
  };
139
142
  }
140
143
 
package/core/index.js CHANGED
@@ -4,4 +4,4 @@ const{runPreBuildChecks,runTrackingCheck,registerChecker,toggleChecker,listCheck
4
4
  exports.json5Plugin=json5Plugin;exports.assetInlinePlugin=assetInlinePlugin;exports.fontFamilyPlugin=fontFamilyPlugin;// 编译前检查器导出
5
5
  exports.runPreBuildChecks=runPreBuildChecks;exports.runTrackingCheck=runTrackingCheck;exports.registerChecker=registerChecker;exports.toggleChecker=toggleChecker;exports.listCheckers=listCheckers;// 埋点验证器导出
6
6
  exports.validateTracking=validateTracking;exports.printValidationResult=printValidationResult;exports.TRACKING_METHODS=TRACKING_METHODS;exports.SDK_TO_TRACKING_MAP=SDK_TO_TRACKING_MAP;// 字体注入导出
7
- exports.FontInjectionWebpackPlugin=FontInjectionWebpackPlugin;exports.FONT_REGISTRY=FONT_REGISTRY;exports.getRegisteredFontKeys=getRegisteredFontKeys;exports.isBuiltinFont=isBuiltinFont;exports.getFontEntry=getFontEntry;exports.getOrGenerateFontCSS=getOrGenerateFontCSS;const{runCocosBuild,syncThemeForPreview}=require("./engines/cocos/pack.js");const{runCocosEngineBuild}=require("./engines/cocos/cli-build.js");const{syncGeneratedTheme}=require("./engines/cocos/theme-assets.js");const{refreshAssets}=require("./engines/cocos/mcp-refresh.js");const{spawnCocos,needsXvfb}=require("./engines/cocos/cli.js");const{stripAdsdkCjsImport,stripSocialShareFromDir,isSocialMediaNetwork}=require("./engines/cocos/strip-social-share.js");const{runCocosDev}=require("./engines/cocos/dev-server.js");exports.runCocosBuild=runCocosBuild;exports.syncThemeForPreview=syncThemeForPreview;exports.runCocosEngineBuild=runCocosEngineBuild;exports.syncGeneratedTheme=syncGeneratedTheme;exports.refreshAssets=refreshAssets;exports.spawnCocos=spawnCocos;exports.needsXvfb=needsXvfb;exports.stripAdsdkCjsImport=stripAdsdkCjsImport;exports.stripSocialShareFromDir=stripSocialShareFromDir;exports.isSocialMediaNetwork=isSocialMediaNetwork;exports.runCocosDev=runCocosDev;
7
+ exports.FontInjectionWebpackPlugin=FontInjectionWebpackPlugin;exports.FONT_REGISTRY=FONT_REGISTRY;exports.getRegisteredFontKeys=getRegisteredFontKeys;exports.isBuiltinFont=isBuiltinFont;exports.getFontEntry=getFontEntry;exports.getOrGenerateFontCSS=getOrGenerateFontCSS;const{runCocosBuild,syncThemeForPreview}=require("./engines/cocos/pack.js");const{runCocosEngineBuild}=require("./engines/cocos/cli-build.js");const{prepareWritableCocosCli}=require("./engines/cocos/writable-cli.js");const{syncGeneratedTheme}=require("./engines/cocos/theme-assets.js");const{refreshAssets}=require("./engines/cocos/mcp-refresh.js");const{spawnCocos,needsXvfb}=require("./engines/cocos/cli.js");const{stripAdsdkCjsImport,stripSocialShareFromDir,isSocialMediaNetwork}=require("./engines/cocos/strip-social-share.js");const{runCocosDev}=require("./engines/cocos/dev-server.js");exports.runCocosBuild=runCocosBuild;exports.syncThemeForPreview=syncThemeForPreview;exports.runCocosEngineBuild=runCocosEngineBuild;exports.prepareWritableCocosCli=prepareWritableCocosCli;exports.syncGeneratedTheme=syncGeneratedTheme;exports.refreshAssets=refreshAssets;exports.spawnCocos=spawnCocos;exports.needsXvfb=needsXvfb;exports.stripAdsdkCjsImport=stripAdsdkCjsImport;exports.stripSocialShareFromDir=stripSocialShareFromDir;exports.isSocialMediaNetwork=isSocialMediaNetwork;exports.runCocosDev=runCocosDev;
package/core/options.js CHANGED
@@ -1,7 +1,7 @@
1
1
  "use strict";const{parseArgvOptions,allowedAdProtocols,allowedAdNetworks,allowedPlaycraftOrientations,allowedLanguages,allowedEngines}=require("./utils/parseArgvOptions");const path=require("path");const fs=require("fs");const{name,version}=require("../package.json");const possibleOptions=[{name:"out-dir",alias:"outDir",defaultValue:"dist",hasValue:true,description:"Output directory for build files"},{name:"build-config",alias:"buildConfig",defaultValue:"build.json",hasValue:true,description:"Path to build.json configuration file"},{name:"ts-config",alias:"tsConfig",defaultValue:"tsconfig.json",hasValue:true,description:"For TypeScript projects, path to tsconfig.json file"},{name:"js-config",alias:"jsConfig",defaultValue:"jsconfig.json",hasValue:true,description:"For JavaScript projects, path to jsconfig.json file"},{name:"port",hasValue:true,defaultValue:3000,description:"Development server port number",parser:function(rawValue){const value=+rawValue;if(isNaN(value))throw new Error("--port should be a number");return value}},{name:"open",defaultValue:false,hasValue:false,description:"Open browser automatically when server starts"},{name:"protocol",hasValue:true,defaultValue:"none",description:"Ad protocol to use (none, mraid, or dapi)",parser:function(rawValue){if(!allowedAdProtocols.includes(rawValue)){throw new Error(`--protocol should have one of the value: ${allowedAdProtocols.join(", ")}`)}return rawValue}},{name:"network",defaultValue:"preview",hasValue:true,description:"Target Ad network",parser:function(rawValue){if(!allowedAdNetworks.includes(rawValue)){throw new Error(`--network should have one of the value: ${allowedAdNetworks.join(", ")}`)}return rawValue}},{name:"zip",defaultValue:false,hasValue:false,description:"Should the build be zipped? (only for some ad networks)"},{name:"is-channel-fold2zip",defaultValue:false,hasValue:false,description:"\u5F53\u542F\u7528\u65F6\uFF0C\u6784\u5EFA\u4EA7\u7269\u683C\u5F0F\u4E3A\u300C\u4E3B\u9898\u6587\u4EF6\u540D/\u6E20\u9053.zip\u300D"},{name:"dev",hasValue:true,description:"Enable development mode (true/false)",parser:function(rawValue){if(!(rawValue==="true"||rawValue==="false"))throw new Error("--dev should have either true or false value");return rawValue==="true"}},{name:"filename",defaultValue:"{app}_{name}_{version}_{date}_{language}_{network}",hasValue:true,description:"Specifies the build filename template"},{name:"app",defaultValue:"AppName",hasValue:true,description:"Specifies the application name used in build filename and APP define"},{name:"name",defaultValue:"ConceptName",hasValue:true,description:"Specifies the concept name used in build filename and NAME define"},{name:"version",defaultValue:"v1",hasValue:true,description:"Specifies the version name used in build filename and VERSION define"},{name:"language",defaultValue:"en",hasValue:true,description:"Specifies the language of the build used in LANGUAGE define",parser:function(rawValue){if(!allowedLanguages.includes(rawValue)){throw new Error(`--platform should have one of the value: ${allowedLanguages.join(", ")}`)}return rawValue}},{name:"playcraft-orientation",alias:"playcraftOrientation",defaultValue:["both"],hasValue:true,description:"\u5F53\u524D\u8981\u6253\u5305\u7684\u6A2A\u7AD6\u5C4F\u65B9\u5411\u7248\u672C\u5217\u8868\uFF0C\u53EF\u6307\u5B9A\u591A\u4E2A\uFF08\u9017\u53F7\u5206\u9694\uFF09\uFF0C\u53D6\u503C\uFF1Alandscape/portrait/both\u3002\u7528\u4E8E PLAYCRAFT_ORIENTATION define \u53CA window.playcraftOrientation \u8FD0\u884C\u65F6\u53D8\u91CF",parser:function(rawValue){const values=rawValue.split(",").map(v=>v.trim()).filter(Boolean);for(const value of values){if(!allowedPlaycraftOrientations.includes(value)){throw new Error(`--playcraft-orientation should have one of the value: ${allowedPlaycraftOrientations.join(", ")}`)}}return values}},{name:"playcraft-orientation-channel",alias:"playcraftOrientationChannel",defaultValue:[],hasValue:true,description:"\u54EA\u4E9B\u6E20\u9053\u9700\u8981\u6309 playcraftOrientation \u5206\u522B\u6253\u51FA\u591A\u4E2A\u65B9\u5411\u7248\u672C\u7684\u5305\uFF08\u9017\u53F7\u5206\u9694\u7684\u6E20\u9053\u540D\u5217\u8868\uFF09\u3002\u4E0D\u5728\u6B64\u5217\u8868\u4E2D\u7684\u6E20\u9053\u59CB\u7EC8\u6309 both\uFF08\u81EA\u9002\u5E94\uFF09\u5355\u4E00\u7248\u672C\u6253\u5305",parser:function(rawValue){return rawValue.split(",").map(v=>v.trim()).filter(Boolean)}},{name:"google-play-url",alias:"googlePlayUrl",defaultValue:"https://play.google.com/store/games",hasValue:true,description:"Google Play Store URL for the app"},{name:"app-store-url",alias:"appStoreUrl",defaultValue:"https://www.apple.com/app-store/",hasValue:true,description:"App Store URL for the app"},{name:"skip-recommended-meta",alias:"skipRecommendedMeta",hasValue:false,description:"Don't inject recommended for playable ads META tags"},{name:"debugger",hasValue:true,description:"URL of debugger script to inject into code"},{name:"obfuscate-level",alias:"obfuscateLevel",defaultValue:4,hasValue:true,description:"Code obfuscation level: 0=no minimize/obfuscate, 1=terser only, 2=obfuscator basic(default), 3=obfuscator+dead code, 4=maximum obfuscation",parser:function(rawValue){const value=+rawValue;if(isNaN(value)||value<0||value>4){throw new Error("--obfuscate-level should be a number between 0 and 4")}return value}},{name:"fflate-compression",alias:"fflateCompression",defaultValue:false,hasValue:true,description:"Enable fflate compression for HTML output (level 9, production only)",parser:function(rawValue){// 支持 true/false 字符串和布尔值
2
2
  if(rawValue==="true"||rawValue===true||rawValue==="1")return true;if(rawValue==="false"||rawValue===false||rawValue==="0")return false;return Boolean(rawValue)}},{name:"exclude-compress-dirs",alias:"excludeCompressDirs",defaultValue:[],hasValue:true,description:"Directories to exclude from compression (comma-separated list of paths)",parser:function(rawValue){if(!rawValue)return[];return rawValue.split(",").map(dir=>dir.trim()).filter(Boolean)}},{name:"engine",defaultValue:"",hasValue:true,description:"Playable engine: phaser, threejs, or cocos. Empty = original webpack pack",parser:function(rawValue){if(rawValue==null||rawValue==="")return"";if(!allowedEngines.includes(rawValue)){throw new Error(`--engine should have one of the value: ${allowedEngines.join(", ")} (omit or empty = webpack)`)}return rawValue}}];/** @type {import('./index').CLIOptions} */const options=parseArgvOptions(possibleOptions);// Extract CLI explicit keys set, then remove it from options
3
3
  const cliExplicitKeys=options._cliExplicitKeys||new Set;delete options._cliExplicitKeys;options.defines={};options.compilation={};options.adNetworkNames={};options.cocos={buildConfig:"build-configs/web-mobile.json",// empty = derive from Creator buildConfig buildPath + outputName
4
- webMobileDir:"",skipEngineBuild:false,creatorBin:"",generatedThemeFile:"assets/scripts/platform/theme.generated.ts"};// ==================== 配置文件加载 ====================
4
+ webMobileDir:"",skipEngineBuild:false,creatorBin:"",generatedThemeFile:"assets/scripts/platform/theme.generated.ts",enableSplash:false,cleanCache:false};// ==================== 配置文件加载 ====================
5
5
  //
6
6
  // 优先级(从低到高):
7
7
  // 1. builds.config.js (新配置文件,优先读取)
@@ -52,6 +52,10 @@ export interface ValidationOptions {
52
52
  extensions?: string[];
53
53
  /** 排除的目录列表 */
54
54
  excludeDirs?: string[];
55
+ /** 引擎:cocos / phaser / threejs,决定扫描目录 */
56
+ engine?: string;
57
+ /** 覆盖默认扫描目录(相对项目根) */
58
+ scanDirs?: string[];
55
59
  /** 是否输出详细信息 */
56
60
  verbose?: boolean;
57
61
  }
@@ -22,7 +22,7 @@
22
22
  // ============================================================
23
23
  /**
24
24
  * 埋点验证检查器
25
- */checkers.set("tracking",{name:"\u57CB\u70B9\u9A8C\u8BC1",description:"\u9A8C\u8BC1\u9879\u76EE\u4E2D\u662F\u5426\u6B63\u786E\u8C03\u7528\u4E86\u6240\u6709\u5FC5\u987B\u7684\u57CB\u70B9\u65B9\u6CD5",enabled:true,validator:(projectRoot,options={})=>{const result=validateTracking(projectRoot,{verbose:options.verbose||false,extensions:options.extensions,excludeDirs:options.excludeDirs});return{success:result.success,message:result.success?"\u2705 \u57CB\u70B9\u9A8C\u8BC1\u901A\u8FC7":`❌ 埋点验证失败:缺少 ${result.errors.length} 个必须的埋点方法`,data:result}}});/**
25
+ */checkers.set("tracking",{name:"\u57CB\u70B9\u9A8C\u8BC1",description:"\u9A8C\u8BC1\u9879\u76EE\u4E2D\u662F\u5426\u6B63\u786E\u8C03\u7528\u4E86\u6240\u6709\u5FC5\u987B\u7684\u57CB\u70B9\u65B9\u6CD5",enabled:true,validator:(projectRoot,options={})=>{const result=validateTracking(projectRoot,{verbose:options.verbose||false,extensions:options.extensions,excludeDirs:options.excludeDirs,engine:options.engine,scanDirs:options.scanDirs});return{success:result.success,message:result.success?"\u2705 \u57CB\u70B9\u9A8C\u8BC1\u901A\u8FC7":`❌ 埋点验证失败:缺少 ${result.errors.length} 个必须的埋点方法`,data:result}}});/**
26
26
  * 主题名称检查器
27
27
  * 校验本次构建涉及的主题名称长度是否超过限制(不超过 50 个字符)。
28
28
  * 主题名称过长可能导致下游文件名 / 目录名 / COS Key 超出系统或第三方平台限制。
@@ -14,7 +14,8 @@
14
14
  */const TRACKING_METHODS=[{name:"onPlaycraftTrackingInit",required:true,description:"SDK \u521D\u59CB\u5316\u5F00\u59CB",channels:["Bigabid:mraid_viewable","InMobi:Ad_Load_Start","AppLovin:LOADING"]},{name:"onPlaycraftTrackingLoading",required:true,description:"\u6E38\u620F\u8D44\u6E90\u52A0\u8F7D\u4E2D",channels:["Bigabid:game_viewable","InMobi:Ad_Viewable","AppLovin:LOADED"]},{name:"onPlaycraftTrackingLoaded",required:true,description:"\u4E3B\u573A\u666F\u5C31\u7EEA\uFF0C\u7528\u6237\u53EF\u4EA4\u4E92",channels:["AppLovin:DISPLAYED"]},{name:"onPlaycraftChallengeStart",required:true,description:"\u6311\u6218\u5F00\u59CB\uFF08\u9996\u6B21\u7528\u6237\u4EA4\u4E92\uFF09",channels:["Bigabid:engagement","InMobi:First_Engagement","AppLovin:CHALLENGE_STARTED"]},{name:"onPlaycraftChallengeProgress",required:false,description:"\u8FDB\u5EA6\u767E\u5206\u6BD4\u8FFD\u8E2A\uFF08\u53EF\u9009\uFF09",channels:["AppLovin:CHALLENGE_PASS_*"]},{name:"onPlaycraftChallengeSuccess",required:true,description:"\u6E38\u620F\u6311\u6218\u6210\u529F",channels:["AppLovin:CHALLENGE_SOLVED","Bigabid:complete","InMobi:Gameplay_Complete"]},{name:"onPlaycraftChallengeFinish",required:false,description:"\u6E38\u620F\u7ED3\u675F\uFF08\u53EF\u9009\uFF09",channels:["Bigabid:complete","InMobi:Gameplay_Complete","AppLovin:ENDCARD_SHOWN"]},{name:"onPlaycraftInstall",required:true,description:"\u70B9\u51FB\u5B89\u88C5",channels:["Bigabid:click","InMobi:DSP_Click","AppLovin:CTA_CLICKED"]},{name:"onPlaycraftRetry",required:false,description:"\u91CD\u8BD5\u6E38\u620F\uFF08\u53EF\u9009\uFF09",channels:["AppLovin:CHALLENGE_RETRY"]},{name:"onPlaycraftChallengeFailed",required:true,description:"\u6E38\u620F\u6311\u6218\u5931\u8D25",channels:["AppLovin:CHALLENGE_FAILED"]}];/**
15
15
  * SDK 静态方法映射到 tracking 方法
16
16
  * 这些 SDK 方法内部会调用对应的 tracking 方法
17
- */const SDK_TO_TRACKING_MAP={"playcraftInit":"onPlaycraftTrackingInit","playcraftStart":"onPlaycraftTrackingLoading","playcraftInteractive":"onPlaycraftTrackingLoaded","playcraftChallengeStart":"onPlaycraftChallengeStart","recordPlaycraftChallengeProgress":"onPlaycraftChallengeProgress","playcraftRecordSuccess":"onPlaycraftChallengeSuccess","playcraftFinish":"onPlaycraftChallengeFinish","playcraftInstall":"onPlaycraftInstall","playcraftRetry":"onPlaycraftRetry","playcraftRecordFailed":"onPlaycraftChallengeFailed"};// ============================================================
17
+ */const SDK_TO_TRACKING_MAP={"playcraftInit":"onPlaycraftTrackingInit","playcraftStart":"onPlaycraftTrackingLoading","playcraftInteractive":"onPlaycraftTrackingLoaded","playcraftChallengeStart":"onPlaycraftChallengeStart","recordPlaycraftChallengeProgress":"onPlaycraftChallengeProgress","playcraftRecordSuccess":"onPlaycraftChallengeSuccess","playcraftFinish":"onPlaycraftChallengeFinish","playcraftInstall":"onPlaycraftInstall","playcraftRetry":"onPlaycraftRetry","playcraftRecordFailed":"onPlaycraftChallengeFailed"};/** Phaser / Three 游戏逻辑在 src;Cocos 在 assets/scripts。不要扫 library/temp。 */const ENGINE_SCAN_ROOTS={cocos:["assets/scripts"],phaser:["src"],threejs:["src"]};const DEFAULT_EXCLUDE_DIRS=["node_modules","dist","build",".git",".claude","library","temp","profiles","native","local","dist-all"];function detectProjectEngine(projectRoot,explicit){if(explicit)return String(explicit).toLowerCase();try{const configPath=path.join(projectRoot,"builds.config.js");if(fs.existsSync(configPath)){delete require.cache[require.resolve(configPath)];const cfg=require(configPath);if(cfg&&cfg.engine)return String(cfg.engine).toLowerCase()}}catch{// ignore invalid builds.config.js
18
+ }if(fs.existsSync(path.join(projectRoot,"settings/cocos.config.json")))return"cocos";if(fs.existsSync(path.join(projectRoot,"assets/scripts"))&&fs.existsSync(path.join(projectRoot,"assets/scenes"))){return"cocos"}return""}function resolveScanRoots(projectRoot,engine){const rels=ENGINE_SCAN_ROOTS[engine]||ENGINE_SCAN_ROOTS.phaser;const roots=rels.map(rel=>path.join(projectRoot,rel)).filter(dir=>fs.existsSync(dir));return roots.length?roots:[projectRoot]}function toRelScanRoots(projectRoot,roots){return roots.map(dir=>{const rel=path.relative(projectRoot,dir);return rel||"."})}// ============================================================
18
19
  // 文件扫描工具
19
20
  // ============================================================
20
21
  /**
@@ -23,9 +24,9 @@
23
24
  * @param {string[]} extensions - 文件扩展名列表
24
25
  * @param {string[]} excludeDirs - 排除的目录名列表
25
26
  * @returns {string[]} 文件路径列表
26
- */function scanSourceFiles(dir,extensions=[".ts",".tsx",".js",".jsx"],excludeDirs=["node_modules","dist","build",".git",".claude"]){const files=[];function scan(currentDir){if(!fs.existsSync(currentDir))return;const entries=fs.readdirSync(currentDir,{withFileTypes:true});for(const entry of entries){const fullPath=path.join(currentDir,entry.name);if(entry.isDirectory()){// 跳过排除的目录
27
+ */function scanSourceFiles(dir,extensions=[".ts",".tsx",".js",".jsx"],excludeDirs=DEFAULT_EXCLUDE_DIRS){const files=[];function scan(currentDir){if(!fs.existsSync(currentDir))return;const entries=fs.readdirSync(currentDir,{withFileTypes:true});for(const entry of entries){const fullPath=path.join(currentDir,entry.name);if(entry.isDirectory()){// 跳过排除的目录
27
28
  if(excludeDirs.includes(entry.name))continue;scan(fullPath)}else if(entry.isFile()){// 检查文件扩展名
28
- const ext=path.extname(entry.name);if(extensions.includes(ext)){files.push(fullPath)}}}}scan(dir);return files}/**
29
+ const ext=path.extname(entry.name);if(extensions.includes(ext)){files.push(fullPath)}}}}scan(dir);return files}function scanSourceFilesFromRoots(roots,extensions,excludeDirs){const files=[];const seen=new Set;for(const root of roots){for(const file of scanSourceFiles(root,extensions,excludeDirs)){if(seen.has(file))continue;seen.add(file);files.push(file)}}return files}/**
29
30
  * 读取文件内容(带错误处理)
30
31
  * @param {string} filePath - 文件路径
31
32
  * @returns {string|null} 文件内容或 null
@@ -58,11 +59,9 @@ new RegExp(`\\["${sdkMethod}"\\]\\s*\\(`)];return patterns.some(pattern=>pattern
58
59
  * 检测是否导入了 @playcraft/ads-tracking
59
60
  * @param {string} content - 文件内容
60
61
  * @returns {boolean} 是否导入了 tracking
61
- */function hasTrackingImport(content){return /from\s+['"]@playcraft\/ads-tracking['"]/.test(content)||/require\s*\(\s*['"]@playcraft\/ads-tracking['"]\s*\)/.test(content)}/**
62
- * 检测是否导入了 @playcraft/adsdk
63
- * @param {string} content - 文件内容
64
- * @returns {boolean} 是否导入了 SDK
65
- */function hasSDKImport(content){return /from\s+['"]@playcraft\/adsdk['"]/.test(content)||/require\s*\(\s*['"]@playcraft\/adsdk['"]\s*\)/.test(content)}// ============================================================
62
+ */function hasTrackingImport(content){return /from\s+['"]@playcraft\/ads-tracking(?:\/[^'"]*)?['"]/.test(content)||/require\s*\(\s*['"]@playcraft\/ads-tracking(?:\/[^'"]*)?['"]\s*\)/.test(content)}/**
63
+ * 检测是否导入了 @playcraft/adsdk(含 /cjs 等子路径,以及项目内 playcraft-adsdk 封装)
64
+ */function hasSDKImport(content){return /from\s+['"]@playcraft\/adsdk(?:\/[^'"]*)?['"]/.test(content)||/require\s*\(\s*['"]@playcraft\/adsdk(?:\/[^'"]*)?['"]\s*\)/.test(content)||/from\s+['"][^'"]*playcraft-adsdk['"]/.test(content)||/require\s*\(\s*['"][^'"]*playcraft-adsdk['"]\s*\)/.test(content)}function hasAnySdkTrackingCall(content){return Object.keys(SDK_TO_TRACKING_MAP).some(sdkMethod=>detectSDKCall(content,sdkMethod))}// ============================================================
66
65
  // 验证核心逻辑
67
66
  // ============================================================
68
67
  /**
@@ -80,13 +79,14 @@ new RegExp(`\\["${sdkMethod}"\\]\\s*\\(`)];return patterns.some(pattern=>pattern
80
79
  * @param {Object} [options] - 验证选项
81
80
  * @param {string[]} [options.extensions] - 文件扩展名
82
81
  * @param {string[]} [options.excludeDirs] - 排除的目录
82
+ * @param {string} [options.engine] - 引擎:cocos / phaser / threejs
83
+ * @param {string[]} [options.scanDirs] - 覆盖默认扫描目录(相对项目根)
83
84
  * @param {boolean} [options.verbose] - 是否输出详细信息
84
85
  * @returns {ValidationResult} 验证结果
85
- */function validateTracking(projectRoot,options={}){const{extensions=[".ts",".tsx",".js",".jsx"],excludeDirs=["node_modules","dist","build",".git"],verbose=false}=options;const result={success:false,integrationType:"none",methods:[],errors:[],warnings:[],stats:{totalFiles:0,scannedFiles:0,hasTrackingImport:false,hasSDKImport:false}};// 1. 扫描所有源码文件
86
- if(verbose)console.log(`\n[扫描] 开始扫描项目: ${projectRoot}`);const files=scanSourceFiles(projectRoot,extensions,excludeDirs);result.stats.totalFiles=files.length;if(files.length===0){result.errors.push("\u672A\u627E\u5230\u4EFB\u4F55\u6E90\u7801\u6587\u4EF6");return result}if(verbose)console.log(`[扫描] 找到 ${files.length} 个源码文件\n`);// 2. 读取所有文件内容并合并(用于全局检测)
86
+ */function validateTracking(projectRoot,options={}){const{extensions=[".ts",".tsx",".js",".jsx"],excludeDirs=DEFAULT_EXCLUDE_DIRS,verbose=false}=options;const engine=detectProjectEngine(projectRoot,options.engine);const scanRoots=Array.isArray(options.scanDirs)&&options.scanDirs.length?options.scanDirs.map(dir=>path.resolve(projectRoot,dir)).filter(dir=>fs.existsSync(dir)):resolveScanRoots(projectRoot,engine);const result={success:false,integrationType:"none",methods:[],errors:[],warnings:[],stats:{totalFiles:0,scannedFiles:0,hasTrackingImport:false,hasSDKImport:false,engine,scanRoots:toRelScanRoots(projectRoot,scanRoots)}};// 1. 按引擎扫描源码目录,避免 Cocos library/temp 把检测带偏
87
+ if(verbose){console.log(`\n[扫描] 项目: ${projectRoot}`);console.log(`[扫描] 引擎: ${engine||"(default/src)"}`);console.log(`[扫描] 目录: ${result.stats.scanRoots.join(", ")}`)}const files=scanSourceFilesFromRoots(scanRoots,extensions,excludeDirs);result.stats.totalFiles=files.length;if(files.length===0){result.errors.push("\u672A\u627E\u5230\u4EFB\u4F55\u6E90\u7801\u6587\u4EF6");return result}if(verbose)console.log(`[扫描] 找到 ${files.length} 个源码文件\n`);// 2. 读取所有文件内容并合并(用于全局检测)
87
88
  let allContent="";for(const file of files){const content=readFileContent(file);if(content){allContent+=content+"\n";result.stats.scannedFiles++}}// 3. 判断集成类型
88
- result.stats.hasTrackingImport=hasTrackingImport(allContent);result.stats.hasSDKImport=hasSDKImport(allContent);if(result.stats.hasTrackingImport&&result.stats.hasSDKImport){result.integrationType="sdk";// SDK 优先
89
- }else if(result.stats.hasSDKImport){result.integrationType="sdk"}else if(result.stats.hasTrackingImport){result.integrationType="tracking"}else{result.errors.push("\u672A\u68C0\u6D4B\u5230 @playcraft/adsdk \u6216 @playcraft/ads-tracking \u7684\u5BFC\u5165");return result}if(verbose){console.log(`[集成类型] ${result.integrationType==="sdk"?"SDK \u96C6\u6210\uFF08\u901A\u8FC7 sdk \u9759\u6001\u65B9\u6CD5\u8C03\u7528\uFF09":"\u76F4\u63A5 tracking \u96C6\u6210"}`);console.log(`[导入检测] SDK: ${result.stats.hasSDKImport?"\u2713":"\u2717"} | Tracking: ${result.stats.hasTrackingImport?"\u2713":"\u2717"}\n`)}// 4. 检测每个埋点方法
89
+ result.stats.hasTrackingImport=hasTrackingImport(allContent);result.stats.hasSDKImport=hasSDKImport(allContent);if(result.stats.hasSDKImport||hasAnySdkTrackingCall(allContent)){result.integrationType="sdk"}else if(result.stats.hasTrackingImport){result.integrationType="tracking"}else{result.errors.push("\u672A\u68C0\u6D4B\u5230 @playcraft/adsdk \u6216 @playcraft/ads-tracking \u7684\u5BFC\u5165");return result}if(verbose){console.log(`[集成类型] ${result.integrationType==="sdk"?"SDK \u96C6\u6210\uFF08\u901A\u8FC7 sdk \u9759\u6001\u65B9\u6CD5\u8C03\u7528\uFF09":"\u76F4\u63A5 tracking \u96C6\u6210"}`);console.log(`[导入检测] SDK: ${result.stats.hasSDKImport?"\u2713":"\u2717"} | Tracking: ${result.stats.hasTrackingImport?"\u2713":"\u2717"}\n`)}// 4. 检测每个埋点方法
90
90
  for(const method of TRACKING_METHODS){let found=false;if(result.integrationType==="sdk"){// SDK 集成:检测 SDK 方法调用
91
91
  const sdkMethod=Object.keys(SDK_TO_TRACKING_MAP).find(key=>SDK_TO_TRACKING_MAP[key]===method.name);if(sdkMethod){found=detectSDKCall(allContent,sdkMethod)}}else if(result.integrationType==="tracking"){// 直接 tracking 集成:检测 tracking 方法调用
92
92
  found=detectDirectTrackingCall(allContent,method.name)}result.methods.push({method:method.name,found,required:method.required,description:method.description});// 必须方法未找到 -> 错误
@@ -100,7 +100,7 @@ result.success=result.errors.length===0;return result}// =======================
100
100
  * @param {ValidationResult} result - 验证结果
101
101
  * @param {boolean} [showDetails=true] - 是否显示详细信息
102
102
  */function printValidationResult(result,showDetails=true){console.log("\n"+"=".repeat(60));console.log("\uD83D\uDCCA \u57CB\u70B9\u9A8C\u8BC1\u62A5\u544A");console.log("=".repeat(60));// 统计信息
103
- console.log(`\n📁 文件统计: 扫描 ${result.stats.scannedFiles}/${result.stats.totalFiles} 个文件`);console.log(`🔗 集成类型: ${result.integrationType==="sdk"?"SDK \u96C6\u6210":result.integrationType==="tracking"?"Tracking \u76F4\u63A5\u96C6\u6210":"\u672A\u68C0\u6D4B\u5230"}`);// 方法检测结果
103
+ console.log(`\n📁 文件统计: 扫描 ${result.stats.scannedFiles}/${result.stats.totalFiles} 个文件`);if(result.stats.engine||result.stats.scanRoots&&result.stats.scanRoots.length){console.log(`🧭 引擎: ${result.stats.engine||"default"} | 目录: ${(result.stats.scanRoots||[]).join(", ")||"."}`)}console.log(`🔗 集成类型: ${result.integrationType==="sdk"?"SDK \u96C6\u6210":result.integrationType==="tracking"?"Tracking \u76F4\u63A5\u96C6\u6210":"\u672A\u68C0\u6D4B\u5230"}`);// 方法检测结果
104
104
  if(showDetails){console.log("\n\uD83D\uDCCB \u65B9\u6CD5\u68C0\u6D4B\u660E\u7EC6:");console.log("-".repeat(60));const requiredMethods=result.methods.filter(m=>m.required);const optionalMethods=result.methods.filter(m=>!m.required);console.log("\n\u5FC5\u987B\u65B9\u6CD5:");for(const m of requiredMethods){const status=m.found?"\u2705":"\u274C";console.log(` ${status} ${m.method.padEnd(35)} ${m.found?"\u5DF2\u8C03\u7528":"\u672A\u8C03\u7528"}`)}console.log("\n\u53EF\u9009\u65B9\u6CD5:");for(const m of optionalMethods){const status=m.found?"\u2705":"\u26A0\uFE0F ";console.log(` ${status} ${m.method.padEnd(35)} ${m.found?"\u5DF2\u8C03\u7528":"\u672A\u8C03\u7528"}`)}}// 错误信息
105
105
  if(result.errors.length>0){console.log("\n\u274C \u9519\u8BEF:");result.errors.forEach(err=>console.log(` ${err}`))}// 警告信息
106
106
  if(result.warnings.length>0){console.log("\n\u26A0\uFE0F \u8B66\u544A:");result.warnings.forEach(warn=>console.log(` ${warn}`))}// 最终结果
@@ -109,5 +109,5 @@ console.log("\n"+"=".repeat(60));if(result.success){console.log("\u2705 \u9A8C\u
109
109
  // ============================================================
110
110
  module.exports={// 核心验证函数
111
111
  validateTracking,printValidationResult,// 配置数据(供外部使用)
112
- TRACKING_METHODS,SDK_TO_TRACKING_MAP,// 工具函数(供外部使用)
113
- scanSourceFiles,detectDirectTrackingCall,detectSDKCall,hasTrackingImport,hasSDKImport};
112
+ TRACKING_METHODS,SDK_TO_TRACKING_MAP,ENGINE_SCAN_ROOTS,DEFAULT_EXCLUDE_DIRS,// 工具函数(供外部使用)
113
+ detectProjectEngine,resolveScanRoots,scanSourceFiles,detectDirectTrackingCall,detectSDKCall,hasTrackingImport,hasSDKImport};
@@ -0,0 +1,10 @@
1
+ "use strict";const{describe,it,before,after}=require("node:test");const assert=require("node:assert/strict");const fs=require("fs");const os=require("os");const path=require("path");const{validateTracking,detectProjectEngine,resolveScanRoots,hasSDKImport}=require("./tracking-validator.js");const SDK_SOURCE=`
2
+ import { sdk } from '@playcraft/adsdk/cjs';
3
+ sdk.playcraftInit(() => {});
4
+ sdk.playcraftStart();
5
+ sdk.playcraftInteractive();
6
+ sdk.playcraftChallengeStart();
7
+ sdk.playcraftRecordSuccess();
8
+ sdk.playcraftRecordFailed();
9
+ sdk.playcraftInstall();
10
+ `;describe("tracking validator engine scan roots",()=>{let root;before(()=>{root=fs.mkdtempSync(path.join(os.tmpdir(),"tracking-scan-"));fs.mkdirSync(path.join(root,"assets/scripts/game"),{recursive:true});fs.mkdirSync(path.join(root,"assets/scenes"),{recursive:true});fs.mkdirSync(path.join(root,"library/import"),{recursive:true});fs.mkdirSync(path.join(root,"src"),{recursive:true});fs.writeFileSync(path.join(root,"builds.config.js"),"module.exports = { engine: 'cocos' };\n");fs.writeFileSync(path.join(root,"assets/scripts/game/GameRoot.ts"),SDK_SOURCE);fs.writeFileSync(path.join(root,"library/import/noise.js"),"import { tracking } from '@playcraft/ads-tracking';\ntracking.onPlaycraftTrackingInit();\n");fs.writeFileSync(path.join(root,"src/index.ts"),"export const unused = 1;\n")});after(()=>{fs.rmSync(root,{recursive:true,force:true})});it("treats @playcraft/adsdk/cjs as an SDK import",()=>{assert.equal(hasSDKImport("import * as adsdk from '@playcraft/adsdk/cjs';"),true);assert.equal(hasSDKImport("import { sdk } from '@playcraft/adsdk';"),true)});it("detects cocos from builds.config.js and scans assets/scripts only",()=>{assert.equal(detectProjectEngine(root),"cocos");const roots=resolveScanRoots(root,"cocos").map(dir=>path.relative(root,dir));assert.deepEqual(roots,["assets/scripts"])});it("passes required tracking checks for a cocos SDK wrapper project",()=>{const result=validateTracking(root,{engine:"cocos"});assert.equal(result.stats.engine,"cocos");assert.deepEqual(result.stats.scanRoots,["assets/scripts"]);assert.equal(result.integrationType,"sdk");assert.equal(result.success,true,result.errors.join("\n"));assert.equal(result.stats.hasSDKImport,true)});it("does not let library ads-tracking imports flip the integration type",()=>{const result=validateTracking(root,{engine:"cocos"});assert.equal(result.stats.hasTrackingImport,false);assert.ok(result.stats.scannedFiles<10)});it("scans src for phaser",()=>{const phaserRoot=fs.mkdtempSync(path.join(os.tmpdir(),"tracking-phaser-"));fs.mkdirSync(path.join(phaserRoot,"src"),{recursive:true});fs.mkdirSync(path.join(phaserRoot,"library"),{recursive:true});fs.writeFileSync(path.join(phaserRoot,"src/main.ts"),SDK_SOURCE.replace("/cjs",""));fs.writeFileSync(path.join(phaserRoot,"library/noise.js"),"import { tracking } from '@playcraft/ads-tracking';\n");try{const result=validateTracking(phaserRoot,{engine:"phaser"});assert.deepEqual(result.stats.scanRoots,["src"]);assert.equal(result.integrationType,"sdk");assert.equal(result.success,true,result.errors.join("\n"))}finally{fs.rmSync(phaserRoot,{recursive:true,force:true})}})});
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@playcraft/devkit",
3
- "version": "1.0.31-beta.1",
3
+ "version": "1.0.31-beta.10",
4
4
  "description": "HTML5 Playable Ads 构建工具,支持多广告渠道打包",
5
5
  "main": "core/index.js",
6
6
  "module": "core/index.js",