@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,4 +1,5 @@
1
1
  import http from 'node:http';
2
+ import { URL } from 'node:url';
2
3
  import path from 'node:path';
3
4
  import os from 'node:os';
4
5
  import pc from 'picocolors';
@@ -39,7 +40,12 @@ export class PlayCraftAgent {
39
40
  console.log(`${pc.bold('端口:')} ${this.config.port}\n`);
40
41
  }
41
42
  const fsHandler = new FSHandler(this.config);
42
- const app = createServer(this.config, fsHandler);
43
+ // 延迟绑定:watcher 创建后才绑定实际函数
44
+ let suppressFileChange;
45
+ const onFileWritten = (relativePath) => {
46
+ suppressFileChange?.(relativePath);
47
+ };
48
+ const app = createServer(this.config, fsHandler, { onFileWritten });
43
49
  const server = http.createServer(app);
44
50
  let messenger;
45
51
  let realtime;
@@ -73,6 +79,7 @@ export class PlayCraftAgent {
73
79
  onFileChange: (invalidate) => {
74
80
  invalidateFsCache = invalidate;
75
81
  },
82
+ onFileWritten,
76
83
  });
77
84
  app.use('/api', localBackend.router);
78
85
  // 3) /emit (internal emit compatibility)
@@ -146,6 +153,50 @@ export class PlayCraftAgent {
146
153
  }
147
154
  lastConnectionCount = count;
148
155
  });
156
+ // 🔧 统一的 WebSocket upgrade handler:
157
+ // SocketServer 使用 noServer 模式,需要手动处理 upgrade
158
+ // 在 full-local 模式下还有 Messenger 和 Realtime 的 WebSocketServer
159
+ {
160
+ const socketWss = socketServer.getWss();
161
+ if (this.mode === 'full-local') {
162
+ // 移除 Messenger 和 Realtime 自动注册的所有 upgrade listener
163
+ const priorListeners = server.listenerCount('upgrade');
164
+ server.removeAllListeners('upgrade');
165
+ console.log(`[agent] 🔧 Removed ${priorListeners} auto-registered upgrade listeners`);
166
+ const messengerWss = messenger?.wss;
167
+ const realtimeWss = realtime?.wss;
168
+ server.on('upgrade', (req, socket, head) => {
169
+ const pathname = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`).pathname;
170
+ console.log(`[agent] 🔌 WebSocket upgrade request: pathname=${pathname}`);
171
+ if (pathname === '/messenger' && messengerWss) {
172
+ messengerWss.handleUpgrade(req, socket, head, (ws) => {
173
+ messengerWss.emit('connection', ws, req);
174
+ });
175
+ }
176
+ else if (pathname === '/realtime' && realtimeWss) {
177
+ realtimeWss.handleUpgrade(req, socket, head, (ws) => {
178
+ realtimeWss.emit('connection', ws, req);
179
+ });
180
+ }
181
+ else {
182
+ console.log(`[agent] ✅ Routing to SocketServer (path=${pathname})`);
183
+ socketWss.handleUpgrade(req, socket, head, (ws) => {
184
+ socketWss.emit('connection', ws, req);
185
+ });
186
+ }
187
+ });
188
+ }
189
+ else {
190
+ // 非 full-local 模式:只有 SocketServer 需要 upgrade handler
191
+ server.on('upgrade', (req, socket, head) => {
192
+ const pathname = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`).pathname;
193
+ console.log(`[agent] 🔌 WebSocket upgrade request (non-full-local): pathname=${pathname}`);
194
+ socketWss.handleUpgrade(req, socket, head, (ws) => {
195
+ socketWss.emit('connection', ws, req);
196
+ });
197
+ });
198
+ }
199
+ }
149
200
  const watcher = new Watcher(this.config, async (filePath, type) => {
150
201
  await logger.info(`[${type.toUpperCase()}] ${filePath}`);
151
202
  invalidateFsCache?.();
@@ -154,6 +205,8 @@ export class PlayCraftAgent {
154
205
  void syncManager.upload(filePath);
155
206
  }
156
207
  });
208
+ // 绑定延迟引用:让 server/local-backend 写文件后能抑制 watcher 回弹
209
+ suppressFileChange = (relativePath) => watcher.suppressFile(relativePath);
157
210
  await new Promise((resolve) => {
158
211
  server.listen(this.config.port, async () => {
159
212
  await logger.info(`Local server running at http://localhost:${this.config.port}`);
@@ -1,14 +1,49 @@
1
1
  import fs from 'node:fs/promises';
2
2
  import path from 'node:path';
3
+ /** 文件扩展名 → 资产类型映射 */
4
+ const EXT_TO_TYPE = {
5
+ '.js': 'script',
6
+ '.mjs': 'script',
7
+ '.ts': 'script',
8
+ '.json': 'json',
9
+ '.png': 'texture',
10
+ '.jpg': 'texture',
11
+ '.jpeg': 'texture',
12
+ '.webp': 'texture',
13
+ '.hdr': 'texture',
14
+ '.gif': 'texture',
15
+ '.glb': 'container',
16
+ '.gltf': 'container',
17
+ '.fbx': 'container',
18
+ '.mp3': 'audio',
19
+ '.wav': 'audio',
20
+ '.ogg': 'audio',
21
+ '.css': 'css',
22
+ '.html': 'html',
23
+ '.txt': 'text',
24
+ '.wasm': 'wasm',
25
+ '.ttf': 'font',
26
+ '.woff': 'font',
27
+ '.woff2': 'font',
28
+ };
3
29
  /**
4
30
  * File-system backed data source for local development.
5
31
  * Reads project data from manifest.json and scene/asset files.
6
32
  * Supports both PlayCraft and PlayCanvas project formats.
33
+ *
34
+ * Also reads assets/assets.json for full asset data (if exists).
35
+ * Additionally scans the filesystem for undeclared files in assets/ directory.
7
36
  */
8
37
  export class FileSystemBackend {
9
38
  projectDir;
10
39
  manifestCache = null;
11
40
  manifestMtime = 0;
41
+ assetsJsonCache = null;
42
+ assetsJsonMtime = 0;
43
+ /** 用于给新发现的文件生成唯一 ID(从 90000000 开始,避免与 numericId 冲突) */
44
+ nextAutoId = 90000000;
45
+ /** 缓存已自动生成的 ID(filename → id),确保同一文件在多次调用间 ID 一致 */
46
+ autoIdMap = new Map();
12
47
  constructor(projectDir) {
13
48
  this.projectDir = projectDir;
14
49
  }
@@ -33,6 +68,28 @@ export class FileSystemBackend {
33
68
  throw e;
34
69
  }
35
70
  }
71
+ /**
72
+ * Read and parse assets/assets.json (with cache).
73
+ * This file contains full asset data in PlayCanvas format.
74
+ */
75
+ async getAssetsJson() {
76
+ const assetsPath = path.join(this.projectDir, 'assets', 'assets.json');
77
+ try {
78
+ const stat = await fs.stat(assetsPath);
79
+ if (this.assetsJsonCache && stat.mtimeMs === this.assetsJsonMtime) {
80
+ return this.assetsJsonCache;
81
+ }
82
+ const content = await fs.readFile(assetsPath, 'utf-8');
83
+ this.assetsJsonCache = JSON.parse(content);
84
+ this.assetsJsonMtime = stat.mtimeMs;
85
+ return this.assetsJsonCache;
86
+ }
87
+ catch (e) {
88
+ if (e?.code === 'ENOENT')
89
+ return null;
90
+ throw e;
91
+ }
92
+ }
36
93
  /**
37
94
  * Detect project format from manifest.
38
95
  */
@@ -43,31 +100,254 @@ export class FileSystemBackend {
43
100
  return 'playcanvas';
44
101
  }
45
102
  /**
46
- * Get all assets from manifest.
103
+ * Get all assets from manifest.json + assets/assets.json (merged).
104
+ *
105
+ * Strategy (matches cloud mode in local-repo.service.ts):
106
+ * - When assets.json exists: use it as PRIMARY data source (has full data: file, data, preload, source, etc.)
107
+ * and only use manifest.json for treePath/path enrichment.
108
+ * - When assets.json is absent: fall back to manifest.json only.
47
109
  */
48
110
  async getAssets() {
49
111
  const manifest = await this.getManifest();
112
+ const assetsJson = await this.getAssetsJson();
113
+ let resultAssets;
114
+ if (assetsJson && Object.keys(assetsJson).length > 0) {
115
+ // assets.json exists — use it as primary source (same as cloud mode)
116
+ resultAssets = this.mergeAssetsJsonPrimary(manifest, assetsJson);
117
+ }
118
+ else {
119
+ // No assets.json — fall back to manifest only
120
+ resultAssets = this.getAssetsFromManifestOnly(manifest);
121
+ }
122
+ // Scan filesystem for undeclared files in assets/ directory
123
+ try {
124
+ const discovered = await this.scanUndeclaredFiles(resultAssets);
125
+ if (discovered.length > 0) {
126
+ console.log(`[fs-backend] 📂 Discovered ${discovered.length} undeclared files on disk`);
127
+ resultAssets.push(...discovered);
128
+ }
129
+ }
130
+ catch (err) {
131
+ console.warn('[fs-backend] ⚠️ Failed to scan for undeclared files:', err);
132
+ }
133
+ return resultAssets;
134
+ }
135
+ /**
136
+ * Primary strategy: assets.json is the main data source.
137
+ * Manifest is only used for treePath / path enrichment.
138
+ * This matches cloud mode (local-repo.service.ts readAssetsFromManifest).
139
+ */
140
+ mergeAssetsJsonPrimary(manifest, assetsJson) {
141
+ // Build treePath/path maps from manifest
142
+ const treePathMap = new Map();
143
+ const manifestPathMap = new Map();
144
+ const manifestAssetsList = [];
145
+ if (manifest?.assets) {
146
+ const rawAssets = Array.isArray(manifest.assets) ? manifest.assets : Object.values(manifest.assets);
147
+ for (const a of rawAssets) {
148
+ if (a.id != null) {
149
+ const idStr = String(a.id);
150
+ if (a.treePath)
151
+ treePathMap.set(idStr, a.treePath);
152
+ if (Array.isArray(a.path))
153
+ manifestPathMap.set(idStr, a.path);
154
+ }
155
+ manifestAssetsList.push(a);
156
+ }
157
+ }
158
+ // Iterate all assets.json entries as primary source
159
+ const result = [];
160
+ const seenIds = new Set();
161
+ for (const [key, asset] of Object.entries(assetsJson)) {
162
+ if (!asset || typeof asset !== 'object')
163
+ continue;
164
+ const idStr = String(asset.id ?? key);
165
+ seenIds.add(idStr);
166
+ // Resolve treePath: prefer manifest's treePath, then compute from assets.json path
167
+ let treePath = treePathMap.get(idStr);
168
+ if (!treePath) {
169
+ treePath = this.resolveTreePath(asset, assetsJson) ?? undefined;
170
+ }
171
+ result.push({
172
+ ...asset,
173
+ id: asset.id ?? (parseInt(key, 10) || key),
174
+ treePath: treePath || '',
175
+ });
176
+ }
177
+ // Also include manifest-only assets (those not in assets.json, e.g. auto-discovered by previous runs)
178
+ for (const a of manifestAssetsList) {
179
+ if (a.id != null && !seenIds.has(String(a.id))) {
180
+ result.push(a);
181
+ seenIds.add(String(a.id));
182
+ }
183
+ }
184
+ return result;
185
+ }
186
+ /**
187
+ * Fallback strategy: only manifest.json available.
188
+ */
189
+ getAssetsFromManifestOnly(manifest) {
50
190
  if (!manifest)
51
191
  return [];
52
192
  const format = this.detectFormat(manifest);
53
193
  if (format === 'playcraft') {
54
- return Array.isArray(manifest.assets) ? manifest.assets : [];
194
+ return Array.isArray(manifest.assets) ? [...manifest.assets] : [];
55
195
  }
56
- const assets = manifest.assets;
57
- if (typeof assets === 'object' && assets !== null && !Array.isArray(assets)) {
58
- return Object.values(assets);
196
+ else {
197
+ const assets = manifest.assets;
198
+ if (typeof assets === 'object' && assets !== null && !Array.isArray(assets)) {
199
+ return Object.values(assets);
200
+ }
59
201
  }
60
202
  return [];
61
203
  }
204
+ /**
205
+ * 递归扫描 assets/ 目录,发现未声明的文件并生成 asset 条目。
206
+ * 只扫描能识别类型的文件(脚本、纹理等),忽略 .meta 文件和 assets.json 本身。
207
+ */
208
+ async scanUndeclaredFiles(existingAssets) {
209
+ const assetsDir = path.join(this.projectDir, 'assets');
210
+ try {
211
+ await fs.access(assetsDir);
212
+ }
213
+ catch {
214
+ return []; // assets/ 目录不存在
215
+ }
216
+ // 构建已知文件名集合(用于去重)
217
+ const knownFilenames = new Set();
218
+ for (const asset of existingAssets) {
219
+ const filename = asset.file?.filename || asset.fileFilename || asset.filename;
220
+ if (filename) {
221
+ knownFilenames.add(filename);
222
+ }
223
+ // 也记录 name(有些情况 name 就是文件名)
224
+ if (asset.name && asset.type !== 'folder') {
225
+ knownFilenames.add(asset.name);
226
+ }
227
+ }
228
+ const discovered = [];
229
+ await this.walkDir(assetsDir, 'assets', knownFilenames, discovered);
230
+ return discovered;
231
+ }
232
+ /**
233
+ * 递归遍历目录
234
+ */
235
+ async walkDir(dirPath, relativePath, knownFilenames, result) {
236
+ let entries;
237
+ try {
238
+ entries = await fs.readdir(dirPath, { withFileTypes: true });
239
+ }
240
+ catch {
241
+ return;
242
+ }
243
+ for (const entry of entries) {
244
+ // 忽略隐藏文件(.meta 等)
245
+ if (entry.name.startsWith('.'))
246
+ continue;
247
+ // 忽略 assets.json
248
+ if (entry.name === 'assets.json')
249
+ continue;
250
+ const entryRelPath = `${relativePath}/${entry.name}`;
251
+ if (entry.isDirectory()) {
252
+ await this.walkDir(path.join(dirPath, entry.name), entryRelPath, knownFilenames, result);
253
+ }
254
+ else if (entry.isFile()) {
255
+ const ext = path.extname(entry.name).toLowerCase();
256
+ const assetType = EXT_TO_TYPE[ext];
257
+ if (!assetType)
258
+ continue; // 无法识别的文件类型,跳过
259
+ // 检查是否已在已知列表中
260
+ if (knownFilenames.has(entry.name))
261
+ continue;
262
+ // 生成稳定的 auto ID
263
+ let autoId;
264
+ if (this.autoIdMap.has(entryRelPath)) {
265
+ autoId = this.autoIdMap.get(entryRelPath);
266
+ }
267
+ else {
268
+ autoId = this.nextAutoId++;
269
+ this.autoIdMap.set(entryRelPath, autoId);
270
+ }
271
+ result.push({
272
+ id: autoId,
273
+ name: entry.name,
274
+ type: assetType,
275
+ treePath: relativePath,
276
+ file: {
277
+ filename: entry.name,
278
+ size: 0,
279
+ },
280
+ // 标记为自动发现的资产
281
+ _autoDiscovered: true,
282
+ });
283
+ knownFilenames.add(entry.name); // 避免同名文件重复
284
+ }
285
+ }
286
+ }
62
287
  /**
63
288
  * Get a single asset by id or uniqueId (for Realtime).
289
+ * First tries manifest.json, then falls back to assets/assets.json.
64
290
  */
65
291
  async getAsset(assetId) {
292
+ // 1. Try manifest.json first
66
293
  const assets = await this.getAssets();
67
294
  const idStr = String(assetId);
68
- return (assets.find((a) => String(a.id) === idStr ||
295
+ const fromManifest = assets.find((a) => String(a.id) === idStr ||
69
296
  String(a.uniqueId) === idStr ||
70
- (a.uniqueId && String(a.uniqueId) === idStr)) ?? null);
297
+ (a.uniqueId && String(a.uniqueId) === idStr));
298
+ if (fromManifest) {
299
+ return fromManifest;
300
+ }
301
+ // 2. Fallback to assets/assets.json (PlayCanvas format)
302
+ const assetsJson = await this.getAssetsJson();
303
+ if (assetsJson) {
304
+ // Direct key lookup
305
+ if (assetsJson[idStr]) {
306
+ const asset = assetsJson[idStr];
307
+ // Normalize to include treePath for file path building
308
+ return this.normalizeAssetFromAssetsJson(asset, idStr, assetsJson);
309
+ }
310
+ // Search by id field
311
+ for (const [key, asset] of Object.entries(assetsJson)) {
312
+ if (asset && typeof asset === 'object' &&
313
+ (String(asset.id) === idStr || String(asset.uniqueId) === idStr)) {
314
+ return this.normalizeAssetFromAssetsJson(asset, key, assetsJson);
315
+ }
316
+ }
317
+ }
318
+ return null;
319
+ }
320
+ /**
321
+ * Resolve treePath from assets.json path array (folder IDs -> folder names).
322
+ * Returns e.g. "assets/scripts" or null if no path.
323
+ */
324
+ resolveTreePath(asset, assetsJson) {
325
+ if (!Array.isArray(asset.path) || asset.path.length === 0) {
326
+ return 'assets';
327
+ }
328
+ const pathParts = ['assets'];
329
+ for (const folderId of asset.path) {
330
+ const folderKey = String(folderId);
331
+ const folder = assetsJson[folderKey];
332
+ if (folder?.type === 'folder' && folder.name) {
333
+ pathParts.push(folder.name);
334
+ }
335
+ else {
336
+ pathParts.push(folderKey);
337
+ }
338
+ }
339
+ return pathParts.join('/');
340
+ }
341
+ /**
342
+ * Normalize asset from assets.json to include treePath.
343
+ * assets.json uses path: [folderId, ...] format, need to resolve to treePath.
344
+ */
345
+ normalizeAssetFromAssetsJson(asset, id, assetsJson) {
346
+ if (!asset)
347
+ return null;
348
+ const normalized = { ...asset, id };
349
+ normalized.treePath = this.resolveTreePath(asset, assetsJson);
350
+ return normalized;
71
351
  }
72
352
  /**
73
353
  * Get all scenes from manifest.
@@ -122,7 +402,9 @@ export class FileSystemBackend {
122
402
  const filename = file.filename ?? file.fileFilename;
123
403
  if (!filename)
124
404
  return null;
125
- const relativePath = this.buildAssetFilePath(asset, filename);
405
+ let relativePath = this.buildAssetFilePath(asset, filename);
406
+ // Resolve folder IDs in the path to actual folder names
407
+ relativePath = await this.resolveTreePathIds(relativePath);
126
408
  const fullPath = path.join(this.projectDir, relativePath);
127
409
  try {
128
410
  return await fs.readFile(fullPath, 'utf-8');
@@ -148,11 +430,33 @@ export class FileSystemBackend {
148
430
  }
149
431
  return path.join('assets', filename);
150
432
  }
433
+ /**
434
+ * Resolve a treePath string that may contain folder numeric IDs to actual filesystem names.
435
+ * e.g. "assets/30014288" → "assets/ammo.js" (where 30014288 is a folder asset with name "ammo.js")
436
+ */
437
+ async resolveTreePathIds(treePath) {
438
+ const assets = await this.getAssets();
439
+ // Build folder ID → name map (only for folder-type assets)
440
+ const folderMap = new Map();
441
+ for (const a of assets) {
442
+ if (a.type === 'folder' && a.id != null && a.name) {
443
+ folderMap.set(String(a.id), a.name);
444
+ if (a.numericId != null)
445
+ folderMap.set(String(a.numericId), a.name);
446
+ }
447
+ }
448
+ // Split treePath and replace any segment that is a folder ID
449
+ const segments = treePath.split('/');
450
+ const resolved = segments.map(seg => folderMap.get(seg) ?? seg);
451
+ return resolved.join('/');
452
+ }
151
453
  /**
152
454
  * Invalidate cache (call when files change).
153
455
  */
154
456
  invalidateCache() {
155
457
  this.manifestCache = null;
156
458
  this.manifestMtime = 0;
459
+ this.assetsJsonCache = null;
460
+ this.assetsJsonMtime = 0;
157
461
  }
158
462
  }