@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.
- package/dist/agent/agent.js +54 -1
- package/dist/agent/fs-backend.js +312 -8
- package/dist/agent/local-backend.js +249 -18
- package/dist/build-config.js +11 -2
- package/dist/commands/build-all.js +477 -0
- package/dist/commands/build.js +248 -178
- package/dist/fs-handler.js +117 -0
- package/dist/index.js +57 -15
- package/dist/server.js +23 -6
- package/dist/socket.js +7 -2
- package/dist/watcher.js +27 -1
- package/package.json +3 -3
|
@@ -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
|
|
59
|
-
|
|
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:
|
|
95
|
-
numericId
|
|
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
|
|
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:
|
|
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
|
-
|
|
459
|
+
const assetId = req.params.id;
|
|
298
460
|
const filename = req.params.filename;
|
|
299
|
-
|
|
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(
|
|
302
|
-
res.setHeader('Content-Type',
|
|
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
|
|
313
|
-
|
|
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
|
}
|
package/dist/build-config.js
CHANGED
|
@@ -3,8 +3,17 @@ import path from 'path';
|
|
|
3
3
|
export async function loadBuildConfigFromFile(configPath) {
|
|
4
4
|
const content = await fs.readFile(configPath, 'utf-8');
|
|
5
5
|
const parsed = JSON.parse(content);
|
|
6
|
-
if (parsed && typeof parsed === 'object'
|
|
7
|
-
|
|
6
|
+
if (parsed && typeof parsed === 'object') {
|
|
7
|
+
// 优先从 build 字段读取
|
|
8
|
+
const buildConfig = parsed.build || {};
|
|
9
|
+
// 如果 build 中没有 storeUrls,尝试从 agent 字段读取
|
|
10
|
+
if (!buildConfig.storeUrls && parsed.agent?.storeUrls) {
|
|
11
|
+
buildConfig.storeUrls = parsed.agent.storeUrls;
|
|
12
|
+
}
|
|
13
|
+
// 如果有 build 或 agent 配置,返回合并后的 buildConfig
|
|
14
|
+
if (parsed.build || parsed.agent) {
|
|
15
|
+
return buildConfig;
|
|
16
|
+
}
|
|
8
17
|
}
|
|
9
18
|
return parsed || {};
|
|
10
19
|
}
|