@playcraft/cli 0.0.15 → 0.0.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -20,6 +20,34 @@ function parseJson(s, fallback) {
20
20
  return fallback;
21
21
  }
22
22
  }
23
+ /** 根据文件扩展名获取 Content-Type */
24
+ const FILE_CONTENT_TYPES = {
25
+ '.js': 'application/javascript',
26
+ '.mjs': 'application/javascript',
27
+ '.json': 'application/json',
28
+ '.png': 'image/png',
29
+ '.jpg': 'image/jpeg',
30
+ '.jpeg': 'image/jpeg',
31
+ '.webp': 'image/webp',
32
+ '.gif': 'image/gif',
33
+ '.glb': 'model/gltf-binary',
34
+ '.gltf': 'model/gltf+json',
35
+ '.mp3': 'audio/mpeg',
36
+ '.wav': 'audio/wav',
37
+ '.ogg': 'audio/ogg',
38
+ '.css': 'text/css',
39
+ '.html': 'text/html',
40
+ '.txt': 'text/plain',
41
+ '.wasm': 'application/wasm',
42
+ '.ttf': 'font/ttf',
43
+ '.woff': 'font/woff',
44
+ '.woff2': 'font/woff2',
45
+ '.hdr': 'application/octet-stream',
46
+ };
47
+ function getContentType(filename) {
48
+ const ext = path.extname(filename).toLowerCase();
49
+ return FILE_CONTENT_TYPES[ext] || 'application/octet-stream';
50
+ }
23
51
  export function createLocalBackendRouter(options) {
24
52
  const router = express.Router();
25
53
  router.use(express.json({ limit: '50mb' }));
@@ -53,14 +81,19 @@ export function createLocalBackendRouter(options) {
53
81
  });
54
82
  // Health
55
83
  router.get('/health', (_req, res) => res.json({ ok: true, service: 'agent-backend' }));
56
- // Projects
84
+ // Projects — local mode only has one project; always return it regardless of requested ID
57
85
  router.get('/projects/:id', async (req, res) => {
58
- const id = req.params.id;
59
- const [row] = await db.select().from(schema.projectsSqlite).where(eq(schema.projectsSqlite.id, id));
86
+ const requestedId = req.params.id;
87
+ // Always query the single local project (options.projectId), not the URL id
88
+ const [row] = await db.select().from(schema.projectsSqlite).where(eq(schema.projectsSqlite.id, options.projectId));
60
89
  if (!row)
61
90
  return res.status(404).json({ error: 'Project not found' });
91
+ // 使用数据库记录的 id,如果请求 ID 是数字则作为 numericId
92
+ // SQLite schema 中 projects 表没有 numericId 字段,只能从 requestedId 解析
93
+ const numericId = parseInt(requestedId, 10) || 0;
62
94
  const project = {
63
95
  id: row.id,
96
+ numericId, // toRecord uses numericId for the numeric business ID
64
97
  externalId: row.externalId,
65
98
  name: row.name,
66
99
  description: row.description,
@@ -90,18 +123,60 @@ export function createLocalBackendRouter(options) {
90
123
  const sceneMeta = await fsBackend.getScene(id);
91
124
  if (!sceneMeta)
92
125
  return res.status(404).json({ error: 'Scene not found' });
126
+ // Launch mode: fakeConnection.subscribe() fetches this endpoint and uses
127
+ // the response directly as scene.data (ShareDB snapshot format).
128
+ // The snapshot must contain { entities, settings, name } — the full scene file.
129
+ const sceneData = await fsBackend.getSceneData(id);
130
+ if (sceneData) {
131
+ // Return full scene data (entities + settings) — same as ShareDB snapshot
132
+ return res.json(sceneData);
133
+ }
134
+ // Fallback: return metadata only (for non-Launch API consumers)
135
+ const rawId = sceneMeta.id ?? id;
136
+ const numericId = typeof rawId === 'number' ? rawId : (parseInt(String(rawId), 10) || 0);
93
137
  const sceneRecord = {
94
- id: sceneMeta.id ?? id,
95
- numericId: typeof sceneMeta.id === 'number' ? sceneMeta.id : (sceneMeta.numericId ?? 0),
138
+ id: rawId,
139
+ numericId,
96
140
  externalId: sceneMeta.externalId ?? null,
97
141
  projectId: sceneMeta.projectId ?? options.projectId,
98
142
  name: sceneMeta.name ?? 'Untitled',
99
143
  data: sceneMeta.data,
100
- createdAt: sceneMeta.createdAt,
101
- updatedAt: sceneMeta.updatedAt,
144
+ createdAt: sceneMeta.createdAt ? new Date(sceneMeta.createdAt) : new Date(),
145
+ updatedAt: sceneMeta.updatedAt ? new Date(sceneMeta.updatedAt) : new Date(),
102
146
  };
103
147
  return res.json({ result: SceneModel.toRecord(sceneRecord) });
104
148
  });
149
+ // Project scenes list (needed by Launch route.ts SSR)
150
+ router.get('/projects/:projectId/scenes', async (req, res) => {
151
+ const scenes = await fsBackend.getScenes();
152
+ const defaultDate = new Date();
153
+ const out = scenes.map((s) => {
154
+ const rawId = s.id;
155
+ const numericId = typeof rawId === 'number' ? rawId : (parseInt(String(rawId), 10) || 0);
156
+ const sceneRecord = {
157
+ id: rawId,
158
+ numericId,
159
+ externalId: s.externalId ?? null,
160
+ projectId: s.projectId ?? options.projectId,
161
+ name: s.name ?? 'Untitled',
162
+ data: undefined,
163
+ createdAt: s.createdAt ? new Date(s.createdAt) : defaultDate,
164
+ updatedAt: s.updatedAt ? new Date(s.updatedAt) : defaultDate,
165
+ };
166
+ return SceneModel.toRecord(sceneRecord);
167
+ });
168
+ return res.json(out);
169
+ });
170
+ // Auth session stub (needed by Launch route.ts SSR)
171
+ router.get('/auth-session', (_req, res) => {
172
+ return res.json({
173
+ user: {
174
+ id: 'local-user',
175
+ name: 'Local Developer',
176
+ email: 'local@dev',
177
+ }
178
+ });
179
+ });
105
180
  router.post('/scenes', async (req, res) => {
106
181
  const { projectId, name } = req.body || {};
107
182
  if (!projectId || !name)
@@ -115,7 +190,7 @@ export function createLocalBackendRouter(options) {
115
190
  externalId: null,
116
191
  projectId,
117
192
  name,
118
- dataJson: JSON.stringify(SceneModel.getDefaultData(numericId)),
193
+ dataJson: JSON.stringify(SceneModel.getDefaultData(numericId, name)),
119
194
  createdAt: now,
120
195
  updatedAt: now,
121
196
  });
@@ -133,6 +208,73 @@ export function createLocalBackendRouter(options) {
133
208
  options.onEmit?.('scene.create', { scene: SceneModel.toRecord(scene) }, projectId);
134
209
  return res.json({ result: SceneModel.toRecord(scene) });
135
210
  });
211
+ // Project-level assets (used by code editor: GET /projects/:id/assets?view=codeeditor)
212
+ router.get('/projects/:projectId/assets', async (req, res) => {
213
+ const projectId = String(req.params.projectId || options.projectId);
214
+ const view = String(req.query.view || 'designer');
215
+ const assets = await fsBackend.getAssets();
216
+ const out = assets.map((a, index) => {
217
+ const file = a.file ?? {};
218
+ let numericId;
219
+ if (typeof a.numericId === 'number' && a.numericId > 0) {
220
+ numericId = a.numericId;
221
+ }
222
+ else if (typeof a.id === 'number' && a.id > 0) {
223
+ numericId = a.id;
224
+ }
225
+ else {
226
+ numericId = 30000000 + index;
227
+ }
228
+ let assetPath = [];
229
+ if (view === 'codeeditor') {
230
+ // Code editor needs path as array of folder IDs
231
+ if (Array.isArray(a.path)) {
232
+ assetPath = a.path.filter((p) => typeof p === 'number' || (typeof p === 'string' && /^\d+$/.test(p)));
233
+ }
234
+ }
235
+ else {
236
+ // Designer mode: path stays as-is
237
+ if (Array.isArray(a.path)) {
238
+ assetPath = a.path.filter((p) => typeof p === 'number' || (typeof p === 'string' && /^\d+$/.test(p)));
239
+ }
240
+ }
241
+ const row = {
242
+ id: a.id ?? a.uniqueId ?? String(numericId),
243
+ numericId,
244
+ externalId: a.externalId ?? null,
245
+ projectId: a.projectId ?? projectId,
246
+ name: a.name ?? 'asset',
247
+ type: a.type ?? 'unknown',
248
+ tags: a.tags ?? [],
249
+ path: assetPath,
250
+ data: a.data ?? {},
251
+ preload: a.preload !== false,
252
+ exclude: a.exclude ?? false,
253
+ source: a.source,
254
+ sourceId: a.sourceId,
255
+ i18n: a.i18n ?? {},
256
+ task: a.task ?? null,
257
+ meta: a.meta ?? null,
258
+ revision: a.revision ?? 1,
259
+ fileFilename: file.filename ?? file.fileFilename ?? null,
260
+ fileSize: file.size ?? file.fileSize ?? null,
261
+ fileHash: file.hash ?? file.fileHash ?? null,
262
+ fileUrl: file.url ?? file.fileUrl ?? null,
263
+ createdAt: a.createdAt ? new Date(a.createdAt) : new Date(),
264
+ modifiedAt: a.modifiedAt ? new Date(a.modifiedAt) : new Date(),
265
+ };
266
+ return AssetModel.toRecord(row);
267
+ });
268
+ // Editor expects a plain array when branchId or view is specified
269
+ if (req.query.branchId || req.query.view) {
270
+ return res.json(out);
271
+ }
272
+ return res.json({ result: out });
273
+ });
274
+ // Sync-to-git (no-op in local mode — files are already on disk)
275
+ router.post('/projects/:projectId/sync-to-git', async (_req, res) => {
276
+ return res.json({ success: true, message: 'Local mode: files are already on disk' });
277
+ });
136
278
  // Assets (file-system first for local dev)
137
279
  router.get('/assets/internal/:id/file', async (req, res) => {
138
280
  const secret = req.headers['x-internal-secret'];
@@ -159,17 +301,37 @@ export function createLocalBackendRouter(options) {
159
301
  router.get('/assets', async (req, res) => {
160
302
  const projectId = String(req.query.projectId || options.projectId);
161
303
  const assets = await fsBackend.getAssets();
162
- const out = assets.map((a) => {
304
+ const out = assets.map((a, index) => {
163
305
  const file = a.file ?? {};
306
+ // 处理 id:优先使用 numericId,其次 id(如果是数字),最后生成一个
307
+ let numericId;
308
+ if (typeof a.numericId === 'number' && a.numericId > 0) {
309
+ numericId = a.numericId;
310
+ }
311
+ else if (typeof a.id === 'number' && a.id > 0) {
312
+ numericId = a.id;
313
+ }
314
+ else {
315
+ // 基于索引生成稳定的 numericId
316
+ numericId = 30000000 + index;
317
+ }
318
+ // 处理 path:PlayCraft manifest.json 中 path 可能是文件路径字符串,需要转换为数组
319
+ // 根目录资产的 path 应该是空数组 []
320
+ let assetPath = [];
321
+ if (Array.isArray(a.path)) {
322
+ // 如果是数组,检查是否是数字数组(文件夹 ID 列表)
323
+ assetPath = a.path.filter((p) => typeof p === 'number' || (typeof p === 'string' && /^\d+$/.test(p)));
324
+ }
325
+ // 如果 path 是字符串(文件路径如 "assets/xxx/file.png"),则视为根目录资产,path 为 []
164
326
  const row = {
165
- id: a.id ?? a.uniqueId ?? '',
166
- numericId: typeof a.id === 'number' ? a.id : (a.numericId ?? 0),
327
+ id: a.id ?? a.uniqueId ?? String(numericId),
328
+ numericId,
167
329
  externalId: a.externalId ?? null,
168
330
  projectId: a.projectId ?? projectId,
169
331
  name: a.name ?? 'asset',
170
332
  type: a.type ?? 'unknown',
171
333
  tags: a.tags ?? [],
172
- path: a.path ?? [],
334
+ path: assetPath,
173
335
  data: a.data ?? {},
174
336
  preload: a.preload !== false,
175
337
  exclude: a.exclude ?? false,
@@ -294,23 +456,90 @@ export function createLocalBackendRouter(options) {
294
456
  return res.json({ ok: true });
295
457
  });
296
458
  router.get('/assets/:id/file/:filename', async (req, res) => {
297
- // Minimal local file serving for editor compatibility.
459
+ const assetId = req.params.id;
298
460
  const filename = req.params.filename;
299
- const full = path.join(options.projectDir, 'assets', filename);
461
+ // 安全检查:防止路径遍历攻击
462
+ // 禁止 filename 中包含 ".." 或绝对路径
463
+ if (filename.includes('..') || path.isAbsolute(filename)) {
464
+ return res.status(400).send('Invalid filename');
465
+ }
466
+ // Look up asset to find its treePath for correct file location
467
+ const asset = await fsBackend.getAsset(assetId);
468
+ let fullPath = null;
469
+ if (asset) {
470
+ // Build path using treePath (e.g. "scripts" -> "assets/scripts/follow-camera.js")
471
+ let treePath = asset.treePath;
472
+ if (treePath) {
473
+ // 安全检查:treePath 也不能包含 ".."
474
+ if (typeof treePath === 'string' && treePath.includes('..')) {
475
+ return res.status(400).send('Invalid asset path');
476
+ }
477
+ // Resolve folder IDs in treePath to actual folder names
478
+ treePath = await fsBackend.resolveTreePathIds(treePath);
479
+ // treePath may or may not start with "assets/"
480
+ const base = treePath.startsWith('assets/') || treePath === 'assets' ? treePath : `assets/${treePath}`;
481
+ fullPath = path.join(options.projectDir, base, filename);
482
+ }
483
+ }
484
+ // Fallback: try flat assets/ directory
485
+ if (!fullPath) {
486
+ fullPath = path.join(options.projectDir, 'assets', filename);
487
+ }
488
+ // 安全检查:确保最终路径在项目目录内
489
+ const resolvedPath = path.resolve(fullPath);
490
+ const projectDirResolved = path.resolve(options.projectDir);
491
+ if (!resolvedPath.startsWith(projectDirResolved + path.sep)) {
492
+ return res.status(403).send('Access denied: path outside project directory');
493
+ }
300
494
  try {
301
- const buf = await fs.readFile(full);
302
- res.setHeader('Content-Type', 'application/octet-stream');
495
+ const buf = await fs.readFile(resolvedPath);
496
+ res.setHeader('Content-Type', getContentType(filename));
303
497
  res.send(buf);
304
498
  }
305
499
  catch (e) {
500
+ // If treePath-based lookup failed, try flat assets/ as final fallback
501
+ if (e?.code === 'ENOENT' && asset?.treePath) {
502
+ const flatPath = path.join(options.projectDir, 'assets', filename);
503
+ // 安全检查:回退路径也必须在项目目录内
504
+ const flatResolved = path.resolve(flatPath);
505
+ if (!flatResolved.startsWith(projectDirResolved + path.sep)) {
506
+ return res.status(403).send('Access denied: path outside project directory');
507
+ }
508
+ try {
509
+ const buf = await fs.readFile(flatResolved);
510
+ res.setHeader('Content-Type', getContentType(filename));
511
+ return res.send(buf);
512
+ }
513
+ catch {
514
+ // fall through to 404
515
+ }
516
+ }
306
517
  if (e?.code === 'ENOENT')
307
518
  return res.status(404).send('not found');
308
519
  return res.status(500).send('error');
309
520
  }
310
521
  });
311
522
  router.get('/assets/:id/file', async (req, res) => {
312
- // Fallback when editor requests /assets/:id/file/:filename via other paths
313
- return res.status(400).json({ error: 'filename required' });
523
+ // Fallback when no filename is provided in URL
524
+ // Look up asset to determine filename, then delegate to normal file serving
525
+ const assetId = req.params.id;
526
+ const asset = await fsBackend.getAsset(assetId);
527
+ if (!asset)
528
+ return res.status(404).json({ error: 'Asset not found' });
529
+ const filename = asset.file?.filename || asset.name;
530
+ if (!filename)
531
+ return res.status(400).json({ error: 'Cannot determine filename' });
532
+ // Redirect to the canonical URL with filename
533
+ const qs = req.url.includes('?') ? req.url.substring(req.url.indexOf('?')) : '';
534
+ return res.redirect(`/api/assets/${assetId}/file/${encodeURIComponent(filename)}${qs}`);
535
+ });
536
+ // Single asset metadata (used by fakeConnection subscribe for assets collection)
537
+ router.get('/assets/:id', async (req, res) => {
538
+ const assetId = req.params.id;
539
+ const asset = await fsBackend.getAsset(assetId);
540
+ if (!asset)
541
+ return res.status(404).json({ error: 'Asset not found' });
542
+ return res.json({ result: asset });
314
543
  });
315
544
  // Documents (code editor)
316
545
  router.post('/documents/:id/save', async (req, res) => {
@@ -347,6 +576,8 @@ export function createLocalBackendRouter(options) {
347
576
  const dir = path.dirname(fullPath);
348
577
  await fs.mkdir(dir, { recursive: true });
349
578
  await fs.writeFile(fullPath, content, 'utf-8');
579
+ // 通知 watcher 抑制此文件的变更通知(防止 save→watcher→reload 循环)
580
+ options.onFileWritten?.(filePath.replace(/\\/g, '/'));
350
581
  console.log(`[local-backend] ✅ Saved document: ${filePath}`);
351
582
  return res.json({ success: true, path: filePath });
352
583
  }