@playcraft/cli 0.0.11 → 0.0.13

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.
@@ -0,0 +1,158 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ /**
4
+ * File-system backed data source for local development.
5
+ * Reads project data from manifest.json and scene/asset files.
6
+ * Supports both PlayCraft and PlayCanvas project formats.
7
+ */
8
+ export class FileSystemBackend {
9
+ projectDir;
10
+ manifestCache = null;
11
+ manifestMtime = 0;
12
+ constructor(projectDir) {
13
+ this.projectDir = projectDir;
14
+ }
15
+ /**
16
+ * Read and parse manifest.json (with cache).
17
+ */
18
+ async getManifest() {
19
+ const manifestPath = path.join(this.projectDir, 'manifest.json');
20
+ try {
21
+ const stat = await fs.stat(manifestPath);
22
+ if (this.manifestCache && stat.mtimeMs === this.manifestMtime) {
23
+ return this.manifestCache;
24
+ }
25
+ const content = await fs.readFile(manifestPath, 'utf-8');
26
+ this.manifestCache = JSON.parse(content);
27
+ this.manifestMtime = stat.mtimeMs;
28
+ return this.manifestCache;
29
+ }
30
+ catch (e) {
31
+ if (e?.code === 'ENOENT')
32
+ return null;
33
+ throw e;
34
+ }
35
+ }
36
+ /**
37
+ * Detect project format from manifest.
38
+ */
39
+ detectFormat(manifest) {
40
+ if (manifest.format === 'playcraft' || Array.isArray(manifest.assets)) {
41
+ return 'playcraft';
42
+ }
43
+ return 'playcanvas';
44
+ }
45
+ /**
46
+ * Get all assets from manifest.
47
+ */
48
+ async getAssets() {
49
+ const manifest = await this.getManifest();
50
+ if (!manifest)
51
+ return [];
52
+ const format = this.detectFormat(manifest);
53
+ if (format === 'playcraft') {
54
+ return Array.isArray(manifest.assets) ? manifest.assets : [];
55
+ }
56
+ const assets = manifest.assets;
57
+ if (typeof assets === 'object' && assets !== null && !Array.isArray(assets)) {
58
+ return Object.values(assets);
59
+ }
60
+ return [];
61
+ }
62
+ /**
63
+ * Get a single asset by id or uniqueId (for Realtime).
64
+ */
65
+ async getAsset(assetId) {
66
+ const assets = await this.getAssets();
67
+ const idStr = String(assetId);
68
+ return (assets.find((a) => String(a.id) === idStr ||
69
+ String(a.uniqueId) === idStr ||
70
+ (a.uniqueId && String(a.uniqueId) === idStr)) ?? null);
71
+ }
72
+ /**
73
+ * Get all scenes from manifest.
74
+ */
75
+ async getScenes() {
76
+ const manifest = await this.getManifest();
77
+ if (!manifest)
78
+ return [];
79
+ const format = this.detectFormat(manifest);
80
+ if (format === 'playcraft') {
81
+ return Array.isArray(manifest.scenes) ? manifest.scenes : [];
82
+ }
83
+ const scenes = manifest.scenes;
84
+ if (typeof scenes === 'object' && scenes !== null && !Array.isArray(scenes)) {
85
+ return Object.values(scenes);
86
+ }
87
+ return [];
88
+ }
89
+ /**
90
+ * Get a single scene metadata by id or uniqueId.
91
+ */
92
+ async getScene(sceneId) {
93
+ const scenes = await this.getScenes();
94
+ const idStr = String(sceneId);
95
+ return (scenes.find((s) => String(s.id) === idStr ||
96
+ String(s.uniqueId) === idStr ||
97
+ (s.uniqueId && String(s.uniqueId) === idStr)) ?? null);
98
+ }
99
+ /**
100
+ * Get scene data (entities, settings) from scenes/{id}.json.
101
+ */
102
+ async getSceneData(sceneId) {
103
+ const scenePath = path.join(this.projectDir, 'scenes', `${sceneId}.json`);
104
+ try {
105
+ const content = await fs.readFile(scenePath, 'utf-8');
106
+ return JSON.parse(content);
107
+ }
108
+ catch (e) {
109
+ if (e?.code === 'ENOENT')
110
+ return null;
111
+ throw e;
112
+ }
113
+ }
114
+ /**
115
+ * Get document (script) content by asset id.
116
+ */
117
+ async getDocumentContent(assetId) {
118
+ const asset = await this.getAsset(assetId);
119
+ if (!asset)
120
+ return null;
121
+ const file = asset.file ?? asset;
122
+ const filename = file.filename ?? file.fileFilename;
123
+ if (!filename)
124
+ return null;
125
+ const relativePath = this.buildAssetFilePath(asset, filename);
126
+ const fullPath = path.join(this.projectDir, relativePath);
127
+ try {
128
+ return await fs.readFile(fullPath, 'utf-8');
129
+ }
130
+ catch (e) {
131
+ if (e?.code === 'ENOENT')
132
+ return null;
133
+ throw e;
134
+ }
135
+ }
136
+ /**
137
+ * Build relative file path for an asset.
138
+ */
139
+ buildAssetFilePath(asset, filename) {
140
+ // PlayCraft: treePath is string like "assets/scripts"
141
+ if (asset.treePath) {
142
+ const base = typeof asset.treePath === 'string' ? asset.treePath : path.join(...(asset.treePath || []));
143
+ return path.join(base, filename);
144
+ }
145
+ // PlayCanvas: path is array of folder ids; we use filename under assets
146
+ if (Array.isArray(asset.path) && asset.path.length > 0) {
147
+ return path.join('assets', ...asset.path.map(String), filename);
148
+ }
149
+ return path.join('assets', filename);
150
+ }
151
+ /**
152
+ * Invalidate cache (call when files change).
153
+ */
154
+ invalidateCache() {
155
+ this.manifestCache = null;
156
+ this.manifestMtime = 0;
157
+ }
158
+ }
@@ -0,0 +1,359 @@
1
+ import express from 'express';
2
+ import crypto from 'node:crypto';
3
+ import path from 'node:path';
4
+ import fs from 'node:fs/promises';
5
+ import { eq, inArray } from 'drizzle-orm';
6
+ import pc from 'picocolors';
7
+ import { createSqliteAdapter } from '@playcraft/common/database';
8
+ import { AssetModel, ProjectModel, SceneModel } from '@playcraft/common/models';
9
+ import { FileSystemBackend } from './fs-backend.js';
10
+ function nowDate() {
11
+ return new Date();
12
+ }
13
+ function parseJson(s, fallback) {
14
+ if (!s)
15
+ return fallback;
16
+ try {
17
+ return JSON.parse(s);
18
+ }
19
+ catch {
20
+ return fallback;
21
+ }
22
+ }
23
+ export function createLocalBackendRouter(options) {
24
+ const router = express.Router();
25
+ router.use(express.json({ limit: '50mb' }));
26
+ const fsBackend = new FileSystemBackend(options.projectDir);
27
+ options.onFileChange?.(() => fsBackend.invalidateCache());
28
+ const { db, schema, client } = createSqliteAdapter(options.dbPath);
29
+ // Ensure default project exists
30
+ router.use(async (_req, _res, next) => {
31
+ try {
32
+ const [p] = await db.select().from(schema.projectsSqlite).where(eq(schema.projectsSqlite.id, options.projectId));
33
+ if (!p) {
34
+ const now = nowDate();
35
+ await db.insert(schema.projectsSqlite).values({
36
+ id: options.projectId,
37
+ externalId: null,
38
+ name: 'local-project',
39
+ description: null,
40
+ ownerId: null,
41
+ settingsJson: '{}',
42
+ private: true,
43
+ privateSourceAssets: false,
44
+ createdAt: now,
45
+ updatedAt: now,
46
+ });
47
+ }
48
+ }
49
+ catch (e) {
50
+ console.warn(pc.yellow('[agent][backend] failed ensuring default project:'), e);
51
+ }
52
+ next();
53
+ });
54
+ // Health
55
+ router.get('/health', (_req, res) => res.json({ ok: true, service: 'agent-backend' }));
56
+ // Projects
57
+ 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));
60
+ if (!row)
61
+ return res.status(404).json({ error: 'Project not found' });
62
+ const project = {
63
+ id: row.id,
64
+ externalId: row.externalId,
65
+ name: row.name,
66
+ description: row.description,
67
+ ownerId: row.ownerId,
68
+ settings: parseJson(row.settingsJson, {}),
69
+ private: row.private,
70
+ privateSourceAssets: row.privateSourceAssets,
71
+ createdAt: row.createdAt,
72
+ updatedAt: row.updatedAt,
73
+ };
74
+ return res.json({ result: ProjectModel.toRecord(project) });
75
+ });
76
+ // Scenes (file-system first for local dev)
77
+ router.get('/scenes/internal/:id', async (req, res) => {
78
+ const secret = req.headers['x-internal-secret'];
79
+ if (options.emitSecret && secret !== options.emitSecret) {
80
+ return res.status(403).json({ error: 'forbidden' });
81
+ }
82
+ const id = req.params.id;
83
+ const data = await fsBackend.getSceneData(id);
84
+ if (!data)
85
+ return res.status(404).json({ error: 'Scene not found' });
86
+ return res.json(data);
87
+ });
88
+ router.get('/scenes/:id', async (req, res) => {
89
+ const id = req.params.id;
90
+ const sceneMeta = await fsBackend.getScene(id);
91
+ if (!sceneMeta)
92
+ return res.status(404).json({ error: 'Scene not found' });
93
+ const sceneRecord = {
94
+ id: sceneMeta.id ?? id,
95
+ numericId: typeof sceneMeta.id === 'number' ? sceneMeta.id : (sceneMeta.numericId ?? 0),
96
+ externalId: sceneMeta.externalId ?? null,
97
+ projectId: sceneMeta.projectId ?? options.projectId,
98
+ name: sceneMeta.name ?? 'Untitled',
99
+ data: sceneMeta.data,
100
+ createdAt: sceneMeta.createdAt,
101
+ updatedAt: sceneMeta.updatedAt,
102
+ };
103
+ return res.json({ result: SceneModel.toRecord(sceneRecord) });
104
+ });
105
+ router.post('/scenes', async (req, res) => {
106
+ const { projectId, name } = req.body || {};
107
+ if (!projectId || !name)
108
+ return res.status(400).json({ error: 'Missing projectId or name' });
109
+ const id = crypto.randomUUID();
110
+ const now = nowDate();
111
+ const numericId = 20000000 + Math.floor(Math.random() * 1000000);
112
+ await db.insert(schema.scenesSqlite).values({
113
+ id,
114
+ numericId,
115
+ externalId: null,
116
+ projectId,
117
+ name,
118
+ dataJson: JSON.stringify(SceneModel.getDefaultData(numericId)),
119
+ createdAt: now,
120
+ updatedAt: now,
121
+ });
122
+ const [row] = await db.select().from(schema.scenesSqlite).where(eq(schema.scenesSqlite.id, id));
123
+ const scene = {
124
+ id: row.id,
125
+ numericId: row.numericId ?? numericId,
126
+ externalId: row.externalId,
127
+ projectId: row.projectId,
128
+ name: row.name,
129
+ data: parseJson(row.dataJson, undefined),
130
+ createdAt: row.createdAt,
131
+ updatedAt: row.updatedAt,
132
+ };
133
+ options.onEmit?.('scene.create', { scene: SceneModel.toRecord(scene) }, projectId);
134
+ return res.json({ result: SceneModel.toRecord(scene) });
135
+ });
136
+ // Assets (file-system first for local dev)
137
+ router.get('/assets/internal/:id/file', async (req, res) => {
138
+ const secret = req.headers['x-internal-secret'];
139
+ if (options.emitSecret && secret !== options.emitSecret) {
140
+ return res.status(403).json({ error: 'forbidden' });
141
+ }
142
+ const id = req.params.id;
143
+ const content = await fsBackend.getDocumentContent(id);
144
+ if (content === null)
145
+ return res.status(404).send('File not found');
146
+ res.type('text/plain').send(content);
147
+ });
148
+ router.get('/assets/internal/:id', async (req, res) => {
149
+ const secret = req.headers['x-internal-secret'];
150
+ if (options.emitSecret && secret !== options.emitSecret) {
151
+ return res.status(403).json({ error: 'forbidden' });
152
+ }
153
+ const id = req.params.id;
154
+ const asset = await fsBackend.getAsset(id);
155
+ if (!asset)
156
+ return res.status(404).json({ error: 'Asset not found' });
157
+ return res.json(asset);
158
+ });
159
+ router.get('/assets', async (req, res) => {
160
+ const projectId = String(req.query.projectId || options.projectId);
161
+ const assets = await fsBackend.getAssets();
162
+ const out = assets.map((a) => {
163
+ const file = a.file ?? {};
164
+ const row = {
165
+ id: a.id ?? a.uniqueId ?? '',
166
+ numericId: typeof a.id === 'number' ? a.id : (a.numericId ?? 0),
167
+ externalId: a.externalId ?? null,
168
+ projectId: a.projectId ?? projectId,
169
+ name: a.name ?? 'asset',
170
+ type: a.type ?? 'unknown',
171
+ tags: a.tags ?? [],
172
+ path: a.path ?? [],
173
+ data: a.data ?? {},
174
+ preload: a.preload !== false,
175
+ exclude: a.exclude ?? false,
176
+ source: a.source,
177
+ sourceId: a.sourceId,
178
+ i18n: a.i18n ?? {},
179
+ task: a.task ?? null,
180
+ meta: a.meta ?? null,
181
+ revision: a.revision ?? 1,
182
+ fileFilename: file.filename ?? file.fileFilename ?? null,
183
+ fileSize: file.size ?? file.fileSize ?? null,
184
+ fileHash: file.hash ?? file.fileHash ?? null,
185
+ fileUrl: file.url ?? file.fileUrl ?? null,
186
+ createdAt: a.createdAt ? new Date(a.createdAt) : new Date(),
187
+ modifiedAt: a.modifiedAt ? new Date(a.modifiedAt) : new Date(),
188
+ };
189
+ return AssetModel.toRecord(row);
190
+ });
191
+ return res.json({ result: out });
192
+ });
193
+ router.post('/assets', async (req, res) => {
194
+ const body = req.body || {};
195
+ const projectId = String(body.projectId || options.projectId);
196
+ const name = String(body.name || 'asset');
197
+ const type = String(body.type || 'unknown');
198
+ const tags = Array.isArray(body.tags) ? body.tags : [];
199
+ const assetPath = Array.isArray(body.path) ? body.path : [];
200
+ const data = body.data ?? {};
201
+ const preload = body.preload ?? true;
202
+ const exclude = body.exclude ?? false;
203
+ const source = body.source ?? false;
204
+ const id = crypto.randomUUID();
205
+ const now = nowDate();
206
+ const file = body.file || null;
207
+ const fileFilename = file?.filename || null;
208
+ const fileSize = file?.size ?? null;
209
+ const fileHash = file?.hash ?? null;
210
+ // Local file URL: default to /file/binary?path=assets/<filename> if exists
211
+ let fileUrl = null;
212
+ if (fileFilename) {
213
+ const guessRel = path.posix.join('assets', fileFilename);
214
+ fileUrl = `/file/binary?path=${encodeURIComponent(guessRel)}`;
215
+ }
216
+ const numericId = 30000000 + Math.floor(Math.random() * 1000000);
217
+ await db.insert(schema.assetsSqlite).values({
218
+ id,
219
+ numericId,
220
+ externalId: null,
221
+ projectId,
222
+ name,
223
+ type,
224
+ tagsJson: JSON.stringify(tags),
225
+ pathJson: JSON.stringify(assetPath),
226
+ dataJson: JSON.stringify(data),
227
+ preload,
228
+ exclude,
229
+ source,
230
+ sourceId: null,
231
+ i18nJson: JSON.stringify({}),
232
+ task: null,
233
+ metaJson: body.meta ? JSON.stringify(body.meta) : null,
234
+ revision: 1,
235
+ fileFilename,
236
+ fileSize,
237
+ fileHash,
238
+ fileUrl,
239
+ createdAt: now,
240
+ modifiedAt: now,
241
+ });
242
+ const [row] = await db.select().from(schema.assetsSqlite).where(eq(schema.assetsSqlite.id, id));
243
+ const asset = {
244
+ id: row.id,
245
+ numericId: row.numericId ?? numericId,
246
+ externalId: row.externalId,
247
+ projectId: row.projectId,
248
+ name: row.name,
249
+ type: row.type,
250
+ tags: parseJson(row.tagsJson, []),
251
+ path: parseJson(row.pathJson, []),
252
+ data: parseJson(row.dataJson, {}),
253
+ preload: row.preload,
254
+ exclude: row.exclude,
255
+ source: row.source,
256
+ sourceId: row.sourceId,
257
+ i18n: parseJson(row.i18nJson, {}),
258
+ task: row.task,
259
+ meta: parseJson(row.metaJson, null),
260
+ revision: row.revision,
261
+ fileFilename: row.fileFilename,
262
+ fileSize: row.fileSize,
263
+ fileHash: row.fileHash,
264
+ fileUrl: row.fileUrl,
265
+ createdAt: row.createdAt,
266
+ modifiedAt: row.modifiedAt,
267
+ };
268
+ options.onEmit?.('asset.create', { asset: AssetModel.toRecord(asset) }, projectId);
269
+ return res.json({ result: AssetModel.toRecord(asset) });
270
+ });
271
+ router.delete('/assets', async (req, res) => {
272
+ const idsParam = String(req.query.ids || '');
273
+ const ids = idsParam.split(',').map((s) => s.trim()).filter(Boolean);
274
+ if (ids.length === 0)
275
+ return res.status(400).json({ error: 'Missing ids' });
276
+ const projectId = String(req.query.projectId || options.projectId);
277
+ // delete
278
+ await db.delete(schema.assetsSqlite).where(inArray(schema.assetsSqlite.id, ids));
279
+ options.onEmit?.('asset.delete', { ids }, projectId);
280
+ return res.json({ ok: true });
281
+ });
282
+ router.post('/assets/move', async (req, res) => {
283
+ const { ids, to, projectId } = req.body || {};
284
+ if (!Array.isArray(ids) || !Array.isArray(to))
285
+ return res.status(400).json({ error: 'Missing ids/to' });
286
+ // update pathJson
287
+ for (const id of ids) {
288
+ await db
289
+ .update(schema.assetsSqlite)
290
+ .set({ pathJson: JSON.stringify(to), modifiedAt: nowDate() })
291
+ .where(eq(schema.assetsSqlite.id, id));
292
+ }
293
+ options.onEmit?.('asset.move', { ids, to }, String(projectId || options.projectId));
294
+ return res.json({ ok: true });
295
+ });
296
+ router.get('/assets/:id/file/:filename', async (req, res) => {
297
+ // Minimal local file serving for editor compatibility.
298
+ const filename = req.params.filename;
299
+ const full = path.join(options.projectDir, 'assets', filename);
300
+ try {
301
+ const buf = await fs.readFile(full);
302
+ res.setHeader('Content-Type', 'application/octet-stream');
303
+ res.send(buf);
304
+ }
305
+ catch (e) {
306
+ if (e?.code === 'ENOENT')
307
+ return res.status(404).send('not found');
308
+ return res.status(500).send('error');
309
+ }
310
+ });
311
+ 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' });
314
+ });
315
+ // Documents (code editor)
316
+ router.post('/documents/:id/save', async (req, res) => {
317
+ const id = req.params.id;
318
+ const { content } = req.body || {};
319
+ if (typeof content !== 'string') {
320
+ return res.status(400).json({ error: 'Invalid content' });
321
+ }
322
+ try {
323
+ // Get asset info to find file path
324
+ const asset = await fsBackend.getAsset(id);
325
+ if (!asset) {
326
+ return res.status(404).json({ error: 'Asset not found' });
327
+ }
328
+ const file = asset.file ?? asset;
329
+ const filename = file.filename ?? file.fileFilename;
330
+ if (!filename) {
331
+ return res.status(400).json({ error: 'Asset has no file' });
332
+ }
333
+ // Build file path
334
+ let filePath;
335
+ if (asset.treePath) {
336
+ const base = typeof asset.treePath === 'string' ? asset.treePath : path.join(...(asset.treePath || []));
337
+ filePath = path.join(base, filename);
338
+ }
339
+ else if (Array.isArray(asset.path) && asset.path.length > 0) {
340
+ filePath = path.join('assets', ...asset.path.map(String), filename);
341
+ }
342
+ else {
343
+ filePath = path.join('assets', filename);
344
+ }
345
+ // Write to file system
346
+ const fullPath = path.join(options.projectDir, filePath);
347
+ const dir = path.dirname(fullPath);
348
+ await fs.mkdir(dir, { recursive: true });
349
+ await fs.writeFile(fullPath, content, 'utf-8');
350
+ console.log(`[local-backend] ✅ Saved document: ${filePath}`);
351
+ return res.json({ success: true, path: filePath });
352
+ }
353
+ catch (error) {
354
+ console.error('[local-backend] Failed to save document:', error);
355
+ return res.status(500).json({ error: error.message || 'Save failed' });
356
+ }
357
+ });
358
+ return { router, db, schema, client };
359
+ }
@@ -0,0 +1,52 @@
1
+ import { afterAll, beforeAll, describe, expect, it } from 'vitest';
2
+ import express from 'express';
3
+ import http from 'node:http';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+ import fs from 'node:fs/promises';
7
+ import { createLocalBackendRouter } from './local-backend.js';
8
+ describe('createLocalBackendRouter', () => {
9
+ let server;
10
+ let baseUrl;
11
+ let tmpDir;
12
+ beforeAll(async () => {
13
+ tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'playcraft-agent-test-'));
14
+ const dbPath = path.join(tmpDir, 'local-db.sqlite');
15
+ const app = express();
16
+ const projectId = 'test-project';
17
+ const { router } = createLocalBackendRouter({
18
+ projectId,
19
+ projectDir: tmpDir,
20
+ dbPath,
21
+ emitSecret: 'test-secret',
22
+ });
23
+ app.use('/api', router);
24
+ server = http.createServer(app);
25
+ await new Promise((resolve) => {
26
+ server.listen(0, '127.0.0.1', () => resolve());
27
+ });
28
+ const addr = server.address();
29
+ baseUrl = `http://127.0.0.1:${addr.port}`;
30
+ });
31
+ afterAll(async () => {
32
+ if (server) {
33
+ await new Promise((resolve) => server.close(() => resolve()));
34
+ }
35
+ if (tmpDir) {
36
+ await fs.rm(tmpDir, { recursive: true, force: true });
37
+ }
38
+ });
39
+ it('responds health', async () => {
40
+ const res = await fetch(`${baseUrl}/api/health`);
41
+ expect(res.ok).toBe(true);
42
+ const json = await res.json();
43
+ expect(json).toHaveProperty('ok', true);
44
+ });
45
+ it('creates default project lazily and can fetch it', async () => {
46
+ const res = await fetch(`${baseUrl}/api/projects/test-project`);
47
+ expect(res.ok).toBe(true);
48
+ const json = await res.json();
49
+ expect(json).toHaveProperty('result');
50
+ expect(json.result).toHaveProperty('uuid', 'test-project');
51
+ });
52
+ });