@playcraft/cli 0.0.15 → 0.0.18

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.
@@ -1,7 +1,13 @@
1
1
  import fs from 'fs/promises';
2
2
  import path from 'path';
3
+ /** Maximum recursion depth for file search to prevent performance issues */
4
+ const MAX_SEARCH_DEPTH = 10;
5
+ /** Cache TTL in milliseconds (5 minutes) */
6
+ const CACHE_TTL_MS = 5 * 60 * 1000;
3
7
  export class FSHandler {
4
8
  config;
9
+ /** Cache for file name to path mapping */
10
+ filePathCache = new Map();
5
11
  constructor(config) {
6
12
  this.config = config;
7
13
  }
@@ -93,6 +99,8 @@ export class FSHandler {
93
99
  else {
94
100
  await fs.writeFile(fullPath, content);
95
101
  }
102
+ // Invalidate cache for the affected file
103
+ this.invalidateCache(relativePath);
96
104
  }
97
105
  async deleteFile(relativePath) {
98
106
  if (!this.isSafePath(relativePath)) {
@@ -100,5 +108,114 @@ export class FSHandler {
100
108
  }
101
109
  const fullPath = path.join(this.config.dir, relativePath);
102
110
  await fs.unlink(fullPath);
111
+ // Invalidate cache for the deleted file
112
+ this.invalidateCache(relativePath);
113
+ }
114
+ /**
115
+ * Recursively search for a file by name within a directory.
116
+ * Returns the relative path if found, or null.
117
+ * Uses caching and depth limiting to optimize performance.
118
+ */
119
+ async findFileRecursive(dir, fileName) {
120
+ // Normalize directory path for consistent cache keys
121
+ const normalizedDir = this.normalizePath(dir);
122
+ // Check cache first
123
+ const cacheKey = `${normalizedDir}:${fileName}`;
124
+ const cached = this.filePathCache.get(cacheKey);
125
+ if (cached) {
126
+ // Validate cache entry is not expired
127
+ if (Date.now() - cached.timestamp < CACHE_TTL_MS) {
128
+ // Verify the cached path still exists
129
+ try {
130
+ const fullPath = path.join(this.config.dir, cached.path);
131
+ await fs.access(fullPath);
132
+ return cached.path;
133
+ }
134
+ catch {
135
+ // File no longer exists, remove from cache
136
+ this.filePathCache.delete(cacheKey);
137
+ }
138
+ }
139
+ else {
140
+ // Cache expired
141
+ this.filePathCache.delete(cacheKey);
142
+ }
143
+ }
144
+ const result = await this.findFileRecursiveInternal(normalizedDir, fileName, 0);
145
+ // Cache the result if found
146
+ if (result) {
147
+ this.filePathCache.set(cacheKey, {
148
+ path: result,
149
+ timestamp: Date.now(),
150
+ });
151
+ }
152
+ return result;
153
+ }
154
+ /**
155
+ * Internal recursive search with depth limiting.
156
+ */
157
+ async findFileRecursiveInternal(dir, fileName, depth) {
158
+ // Limit recursion depth to prevent performance issues
159
+ if (depth >= MAX_SEARCH_DEPTH)
160
+ return null;
161
+ if (!this.isSafePath(dir))
162
+ return null;
163
+ const fullDir = path.join(this.config.dir, dir);
164
+ try {
165
+ const entries = await fs.readdir(fullDir, { withFileTypes: true });
166
+ // First pass: check files in current directory (breadth-first approach)
167
+ for (const entry of entries) {
168
+ if (entry.name.startsWith('.'))
169
+ continue;
170
+ if (entry.isFile() && entry.name === fileName) {
171
+ return path.posix.join(dir, entry.name);
172
+ }
173
+ }
174
+ // Second pass: recurse into subdirectories
175
+ for (const entry of entries) {
176
+ if (entry.name.startsWith('.'))
177
+ continue;
178
+ if (entry.isDirectory()) {
179
+ const rel = path.posix.join(dir, entry.name);
180
+ const found = await this.findFileRecursiveInternal(rel, fileName, depth + 1);
181
+ if (found)
182
+ return found;
183
+ }
184
+ }
185
+ }
186
+ catch {
187
+ // ignore errors (e.g. permission denied)
188
+ }
189
+ return null;
190
+ }
191
+ /**
192
+ * Normalize path separators to forward slashes for consistent cache key matching.
193
+ */
194
+ normalizePath(p) {
195
+ return p.replace(/\\/g, '/');
196
+ }
197
+ /**
198
+ * Invalidate cache entries for a specific file path.
199
+ * Call this when files are written or deleted.
200
+ */
201
+ invalidateCache(relativePath) {
202
+ if (!relativePath) {
203
+ this.filePathCache.clear();
204
+ return;
205
+ }
206
+ // Normalize path separators for consistent matching
207
+ const normalizedPath = this.normalizePath(relativePath);
208
+ const fileName = path.basename(normalizedPath);
209
+ const dirName = path.dirname(normalizedPath);
210
+ // Build the exact cache key that would have been used
211
+ // Cache key format: "dir:fileName" (consistent with findFileRecursive)
212
+ const exactKey = `${dirName}:${fileName}`;
213
+ // Remove entries by exact key match or by result path match
214
+ for (const [key, entry] of this.filePathCache) {
215
+ const normalizedEntryPath = this.normalizePath(entry.path);
216
+ if (key === exactKey || normalizedEntryPath === normalizedPath) {
217
+ this.filePathCache.delete(key);
218
+ }
219
+ }
103
220
  }
104
221
  }
package/dist/index.js CHANGED
@@ -10,6 +10,7 @@ import { statusCommand } from './commands/status.js';
10
10
  import { logsCommand } from './commands/logs.js';
11
11
  import { configCommand } from './commands/config.js';
12
12
  import { buildCommand } from './commands/build.js';
13
+ import { buildAllCommand } from './commands/build-all.js';
13
14
  import { upgradeCommand } from './commands/upgrade.js';
14
15
  import { checkForUpdates } from './utils/version-checker.js';
15
16
  import { syncCommand } from './commands/sync.js';
@@ -124,18 +125,20 @@ program
124
125
  .command('build')
125
126
  .description('打包项目为 Playable Ad(自动执行基础构建和渠道打包)')
126
127
  .argument('[project-path]', '项目路径', process.cwd())
127
- .option('-p, --platform <platform>', '目标平台 (facebook|snapchat|ironsource|applovin|google|tiktok|unity|liftoff|moloco|bigo)')
128
+ .option('-p, --platform <platform>', '目标平台 (facebook|snapchat|ironsource|applovin|google|tiktok|unity|liftoff|moloco|bigo|inmobi|adikteev|remerge|mintegral)')
128
129
  .option('-f, --format <format>', '输出格式 (html|zip)', 'html')
129
130
  .option('-o, --output <path>', '输出目录', './dist')
130
131
  .option('-c, --config <file>', '配置文件路径')
131
- .option('-s, --scenes <scenes>', '选择场景(逗号分隔),例如: MainMenu,Gameplay')
132
+ .option('-s, --scenes <scenes>', '选择场景(逗号分隔),例如: "Main Scene" 或 MainMenu,Gameplay')
133
+ .option('-e, --engine <engine>', '指定引擎类型 (playcanvas|phaser|pixijs|threejs|cocos|babylonjs|layaair|egret|generic),默认自动检测')
132
134
  .option('--clean', '构建前清理旧的输出目录(默认启用)', true)
133
135
  .option('--no-clean', '跳过清理旧的输出目录')
134
- .option('--compress', '压缩引擎代码(使用 LZ4)')
135
- .option('--compress-config', '压缩 config.json(使用 LZ4,可减少 60-70% 体积)')
136
+ .option('--compress', '压缩引擎代码(使用 LZ4,仅 PlayCanvas)')
137
+ .option('--compress-config', '压缩 config.json(使用 LZ4,可减少 60-70% 体积,仅 PlayCanvas)')
138
+ .option('--compress-js', '压缩所有 JS 代码(LZ4 源码保护,浏览器中无法查看变量名和方法名)')
136
139
  .option('--analyze', '生成打包分析报告')
137
- .option('--replace-ammo', '检测到 Ammo 时自动替换为 p2 或 cannon')
138
- .option('--ammo-engine <engine>', '指定替换物理引擎 (p2|cannon)')
140
+ .option('--replace-ammo', '检测到 Ammo 时自动替换为 p2 或 cannon(仅 PlayCanvas)')
141
+ .option('--ammo-engine <engine>', '指定替换物理引擎 (p2|cannon),仅 PlayCanvas')
139
142
  .option('--auto-build', '如果是源代码项目,自动执行本地构建')
140
143
  .option('--skip-build', '跳过本地构建,直接从源代码打包(可能不完整)')
141
144
  .option('--mode <mode>', '构建模式 (full|base|playable)')
@@ -150,7 +153,7 @@ program
150
153
  .option('--image-quality <quality>', '图片质量 0-100(默认根据平台配置)', parseInt)
151
154
  .option('--convert-webp', '转换为 WebP 格式(默认启用)', true)
152
155
  .option('--no-convert-webp', '禁用 WebP 转换')
153
- .option('--compress-models', '压缩 3D 模型(默认根据平台配置)')
156
+ .option('--compress-models', '压缩 3D 模型(默认根据平台配置,仅 PlayCanvas/Three.js/Babylon.js)')
154
157
  .option('--no-compress-models', '禁用模型压缩')
155
158
  .option('--model-compression <method>', '模型压缩方法 (draco|meshopt)', 'draco')
156
159
  .action(async (projectPath, options) => {
@@ -162,7 +165,8 @@ program
162
165
  .description('生成可运行的多文件构建产物(阶段1)')
163
166
  .argument('<project-path>', '项目路径')
164
167
  .option('-o, --output <path>', '输出目录', './build')
165
- .option('-s, --scenes <scenes>', '选择场景(逗号分隔),例如: MainMenu,Gameplay')
168
+ .option('-s, --scenes <scenes>', '选择场景(逗号分隔),例如: "Main Scene" 或 MainMenu,Gameplay')
169
+ .option('-e, --engine <engine>', '指定引擎类型 (playcanvas|phaser|pixijs|threejs|cocos|babylonjs|layaair|egret|generic),默认自动检测')
166
170
  .action(async (projectPath, options) => {
167
171
  await buildCommand(projectPath, {
168
172
  ...options,
@@ -175,14 +179,16 @@ program
175
179
  .command('analyze')
176
180
  .description('生成打包分析报告(可视化 + 体积建议)')
177
181
  .argument('[project-path]', '项目路径', process.cwd())
178
- .option('-p, --platform <platform>', '目标平台 (facebook|snapchat|ironsource|applovin|google|tiktok|unity|liftoff|moloco|bigo)')
182
+ .option('-p, --platform <platform>', '目标平台 (facebook|snapchat|ironsource|applovin|google|tiktok|unity|liftoff|moloco|bigo|inmobi|adikteev|remerge|mintegral)')
179
183
  .option('-f, --format <format>', '输出格式 (html|zip)', 'html')
180
184
  .option('-o, --output <path>', '输出目录', './dist')
181
185
  .option('-c, --config <file>', '配置文件路径')
182
- .option('--compress', '压缩引擎代码(使用 LZ4)')
183
- .option('--compress-config', '压缩 config.json(使用 LZ4,可减少 60-70% 体积)')
184
- .option('--replace-ammo', '检测到 Ammo 时自动替换为 p2 cannon')
185
- .option('--ammo-engine <engine>', '指定替换物理引擎 (p2|cannon)')
186
+ .option('-e, --engine <engine>', '指定引擎类型 (playcanvas|phaser|pixijs|threejs|cocos|babylonjs|layaair|egret|generic),默认自动检测')
187
+ .option('--compress', '压缩引擎代码(使用 LZ4,仅 PlayCanvas)')
188
+ .option('--compress-config', '压缩 config.json(使用 LZ4,可减少 60-70% 体积,仅 PlayCanvas)')
189
+ .option('--compress-js', '压缩所有 JS 代码(LZ4 源码保护)')
190
+ .option('--replace-ammo', '检测到 Ammo 时自动替换为 p2 或 cannon(仅 PlayCanvas)')
191
+ .option('--ammo-engine <engine>', '指定替换物理引擎 (p2|cannon),仅 PlayCanvas')
186
192
  .option('--mode <mode>', '构建模式 (full|base|playable)')
187
193
  .option('--use-vite', '使用 Vite 构建(默认启用)', true)
188
194
  .option('--no-use-vite', '禁用 Vite,使用旧构建器')
@@ -195,7 +201,7 @@ program
195
201
  .option('--image-quality <quality>', '图片质量 0-100(默认根据平台配置)', parseInt)
196
202
  .option('--convert-webp', '转换为 WebP 格式(默认启用)', true)
197
203
  .option('--no-convert-webp', '禁用 WebP 转换')
198
- .option('--compress-models', '压缩 3D 模型(默认根据平台配置)')
204
+ .option('--compress-models', '压缩 3D 模型(默认根据平台配置,仅 PlayCanvas/Three.js/Babylon.js)')
199
205
  .option('--no-compress-models', '禁用模型压缩')
200
206
  .option('--model-compression <method>', '模型压缩方法 (draco|meshopt)', 'draco')
201
207
  .option('--esm-mode <mode>', 'ESM 模块处理模式 (auto|enabled|disabled)', 'auto')
@@ -210,7 +216,7 @@ program
210
216
  .command('build-playable')
211
217
  .description('将多文件构建产物转换为单HTML Playable Ads(阶段2)')
212
218
  .argument('<base-build-dir>', '多文件构建产物目录')
213
- .requiredOption('-p, --platform <platform>', '目标平台 (facebook|snapchat|ironsource|applovin|google|tiktok|unity|liftoff|moloco|bigo)')
219
+ .requiredOption('-p, --platform <platform>', '目标平台 (facebook|snapchat|ironsource|applovin|google|tiktok|unity|liftoff|moloco|bigo|inmobi|adikteev|remerge|mintegral)')
214
220
  .option('-f, --format <format>', '输出格式 (html|zip)', 'html')
215
221
  .option('-o, --output <path>', '输出目录', './dist')
216
222
  .option('-c, --config <file>', '配置文件路径')
@@ -242,6 +248,42 @@ program
242
248
  mode: 'playable',
243
249
  });
244
250
  });
251
+ // build-all 命令 - 批量打渠道包
252
+ program
253
+ .command('build-all')
254
+ .description('批量打多渠道包(一次 Base Build + 多渠道 Channel Build)')
255
+ .argument('[project-path]', '项目路径', process.cwd())
256
+ .option('--platforms <platforms>', '目标平台列表(逗号分隔),例如: facebook,google,tiktok,applovin')
257
+ .option('--group <group>', '使用预定义分组 (all|mainstream|html|zip)')
258
+ .option('-o, --output <path>', '输出根目录(每个渠道一个子目录)', './dist')
259
+ .option('-c, --config <file>', '配置文件路径')
260
+ .option('-s, --scenes <scenes>', '选择场景(逗号分隔)')
261
+ .option('--clean', '构建前清理旧的输出目录', true)
262
+ .option('--no-clean', '跳过清理旧的输出目录')
263
+ .option('--compress', '压缩引擎代码(使用 LZ4)')
264
+ .option('--compress-config', '压缩 config.json(使用 LZ4)')
265
+ .option('--compress-js', '压缩所有 JS 代码(LZ4 源码保护)')
266
+ .option('--analyze', '生成打包分析报告')
267
+ .option('--replace-ammo', '检测到 Ammo 时自动替换')
268
+ .option('--ammo-engine <engine>', '指定替换物理引擎 (p2|cannon)')
269
+ .option('--use-vite', '使用 Vite 构建(默认启用)', true)
270
+ .option('--no-use-vite', '禁用 Vite,使用旧构建器')
271
+ .option('--css-minify', '压缩 CSS')
272
+ .option('--no-css-minify', '禁用 CSS 压缩')
273
+ .option('--js-minify', '压缩 JS')
274
+ .option('--no-js-minify', '禁用 JS 压缩')
275
+ .option('--compress-images', '压缩图片')
276
+ .option('--no-compress-images', '禁用图片压缩')
277
+ .option('--image-quality <quality>', '图片质量 0-100', parseInt)
278
+ .option('--convert-webp', '转换为 WebP 格式', true)
279
+ .option('--no-convert-webp', '禁用 WebP 转换')
280
+ .option('--compress-models', '压缩 3D 模型')
281
+ .option('--no-compress-models', '禁用模型压缩')
282
+ .option('--model-compression <method>', '模型压缩方法 (draco|meshopt)', 'draco')
283
+ .option('--esm-mode <mode>', 'ESM 模块处理模式 (auto|enabled|disabled)', 'auto')
284
+ .action(async (projectPath, options) => {
285
+ await buildAllCommand(projectPath, options);
286
+ });
245
287
  // 新增:inspect 命令 - 检查项目类型
246
288
  program
247
289
  .command('inspect')
package/dist/server.js CHANGED
@@ -1,9 +1,13 @@
1
1
  import express from 'express';
2
2
  import cors from 'cors';
3
- export function createServer(config, fsHandler) {
3
+ import path from 'path';
4
+ export function createServer(config, fsHandler, options) {
4
5
  const app = express();
5
- app.use(cors());
6
- app.use(express.json());
6
+ app.use(cors({
7
+ origin: true, // reflect request origin
8
+ credentials: true, // allow credentials (withCredentials)
9
+ }));
10
+ app.use(express.json({ limit: '50mb' }));
7
11
  // Health check
8
12
  app.get('/health', (req, res) => {
9
13
  res.json({ status: 'ok', projectId: config.projectId });
@@ -54,10 +58,22 @@ export function createServer(config, fsHandler) {
54
58
  return res.status(400).json({ error: 'Missing path parameter' });
55
59
  }
56
60
  try {
57
- const result = await fsHandler.readBinaryFile(filePath);
61
+ let result = await fsHandler.readBinaryFile(filePath);
62
+ let actualPath = filePath;
63
+ // Fallback: if exact path not found and path starts with "assets/",
64
+ // search recursively in the assets directory for a file with the same name.
65
+ // Note: findFileRecursive() has built-in caching (5min TTL) and depth limiting (max 10 levels)
66
+ if (!result.exists && filePath.startsWith('assets/')) {
67
+ const fileName = path.basename(filePath);
68
+ const found = await fsHandler.findFileRecursive('assets', fileName);
69
+ if (found) {
70
+ result = await fsHandler.readBinaryFile(found);
71
+ actualPath = found;
72
+ }
73
+ }
58
74
  if (result.exists) {
59
- // Set appropriate content type
60
- const ext = filePath.split('.').pop()?.toLowerCase();
75
+ // Set appropriate content type based on the actual file path
76
+ const ext = actualPath.split('.').pop()?.toLowerCase();
61
77
  const contentType = getContentType(ext || '');
62
78
  res.setHeader('Content-Type', contentType);
63
79
  if (result.size !== undefined) {
@@ -81,6 +97,7 @@ export function createServer(config, fsHandler) {
81
97
  }
82
98
  try {
83
99
  await fsHandler.writeFile(filePath, content ?? '');
100
+ options?.onFileWritten?.(filePath);
84
101
  res.json({ success: true, path: filePath });
85
102
  }
86
103
  catch (error) {
package/dist/socket.js CHANGED
@@ -59,8 +59,9 @@ export class SocketServer {
59
59
  onConnectionChange;
60
60
  constructor(server, config, onConnectionChange) {
61
61
  this.config = config;
62
- // 注意:必须显式指定 path='/',避免拦截 /realtime、/messenger 等其他 WebSocket 服务
63
- this.wss = new WebSocketServer({ server, path: '/' });
62
+ // 使用 noServer 模式,由 agent.ts 的统一 upgrade handler 分发 WebSocket 连接
63
+ // 避免多个 WebSocketServer 在同一个 HTTP server 上的 upgrade 事件冲突
64
+ this.wss = new WebSocketServer({ noServer: true });
64
65
  this.onConnectionChange = onConnectionChange;
65
66
  this.wss.on('connection', (ws) => {
66
67
  this.clients.set(ws, { patterns: ['**/*'] });
@@ -173,4 +174,8 @@ export class SocketServer {
173
174
  getConnectionCount() {
174
175
  return this.clients.size;
175
176
  }
177
+ /** 获取内部 WebSocketServer 实例,用于手动 handleUpgrade */
178
+ getWss() {
179
+ return this.wss;
180
+ }
176
181
  }
package/dist/watcher.js CHANGED
@@ -4,6 +4,13 @@ export class Watcher {
4
4
  config;
5
5
  onChange;
6
6
  watcher;
7
+ /**
8
+ * 被抑制的文件路径及其过期时间戳。
9
+ * 当 Editor 通过 HTTP 保存文件时,将文件路径加入此 Map,
10
+ * Watcher 在窗口期内忽略该文件的变更通知,防止保存→watcher→reload 循环。
11
+ */
12
+ suppressedFiles = new Map();
13
+ static SUPPRESS_WINDOW_MS = 3000; // 3 秒抑制窗口
7
14
  constructor(config, onChange) {
8
15
  this.config = config;
9
16
  this.onChange = onChange;
@@ -24,9 +31,28 @@ export class Watcher {
24
31
  .on('unlink', (filePath) => this.handleEvent(filePath, 'delete'));
25
32
  }
26
33
  handleEvent(filePath, type) {
27
- const relativePath = path.relative(this.config.dir, filePath);
34
+ // 统一使用 / 分隔符,确保 Windows 和 Unix 行为一致
35
+ const relativePath = path.relative(this.config.dir, filePath).replace(/\\/g, '/');
36
+ // 检查是否在抑制窗口内
37
+ const suppressUntil = this.suppressedFiles.get(relativePath);
38
+ if (suppressUntil && Date.now() < suppressUntil) {
39
+ // 文件刚被 Editor 保存,忽略 Watcher 回弹
40
+ return;
41
+ }
42
+ // 过期清理
43
+ if (suppressUntil) {
44
+ this.suppressedFiles.delete(relativePath);
45
+ }
28
46
  this.onChange(relativePath, type);
29
47
  }
48
+ /**
49
+ * 抑制指定文件的 Watcher 通知(在窗口期内)。
50
+ * 应在 Editor 通过 HTTP API 保存文件后调用。
51
+ */
52
+ suppressFile(relativePath, durationMs) {
53
+ const normalizedPath = relativePath.replace(/\\/g, '/');
54
+ this.suppressedFiles.set(normalizedPath, Date.now() + (durationMs || Watcher.SUPPRESS_WINDOW_MS));
55
+ }
30
56
  close() {
31
57
  return this.watcher.close();
32
58
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@playcraft/cli",
3
- "version": "0.0.15",
3
+ "version": "0.0.18",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "bin": {
@@ -22,8 +22,8 @@
22
22
  "release": "node scripts/release.js"
23
23
  },
24
24
  "dependencies": {
25
- "@playcraft/common": "^0.0.6",
26
- "@playcraft/build": "^0.0.13",
25
+ "@playcraft/common": "^0.0.8",
26
+ "@playcraft/build": "^0.0.15",
27
27
  "chokidar": "^4.0.3",
28
28
  "commander": "^13.1.0",
29
29
  "cors": "^2.8.6",