phibelle-kit 1.0.30 → 1.0.32

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.
@@ -4,11 +4,10 @@ import * as fs from "node:fs/promises";
4
4
  import { execFile } from "node:child_process";
5
5
  import { promisify } from "node:util";
6
6
  import { WebSocketServer } from "ws";
7
+ import { WS_PORT, CLONE_REQUEST_TYPE, CLONE_RESPONSE_TYPE, SCENE_FILE_NAME, } from "../lib/constants.js";
8
+ import { getExecErrorText } from "../lib/utils.js";
7
9
  import { parseSceneJson, unpackageScene } from "../lib/scene/scene-packaging.js";
8
10
  import { setupSceneDirectory } from "../scene-setup/setup.js";
9
- const WS_PORT = 31113;
10
- const CLONE_REQUEST_TYPE = "phibelle-kit-clone-request";
11
- const CLONE_RESPONSE_TYPE = "phibelle-kit-clone-response";
12
11
  const CLONE_TIMEOUT_MS = 30_000;
13
12
  const INITIAL_COMMIT_MESSAGE = "Initial scene clone from Phibelle";
14
13
  const execFileAsync = promisify(execFile);
@@ -26,7 +25,7 @@ export async function cloneSceneCommand() {
26
25
  await ensureDirectoryDoesNotExist(sceneDir);
27
26
  await fs.mkdir(sceneDir, { recursive: false });
28
27
  setupSceneDirectory(sceneDir);
29
- await fs.writeFile(path.join(sceneDir, "scene.phibelle"), payload.sceneData, "utf8");
28
+ await fs.writeFile(path.join(sceneDir, SCENE_FILE_NAME), payload.sceneData, "utf8");
30
29
  await unpackageScene(payload.sceneData, sceneDir, payload.sceneId);
31
30
  await initializeGitRepository(sceneDir);
32
31
  console.log(chalk.green(" ✓ Scene cloned successfully"));
@@ -148,7 +147,7 @@ async function initializeGitRepository(sceneDir) {
148
147
  }
149
148
  catch (error) {
150
149
  console.log(chalk.yellow(" ! Git initialization or first commit was skipped"));
151
- const details = getErrorText(error);
150
+ const details = getExecErrorText(error);
152
151
  if (details) {
153
152
  console.log(chalk.gray(` ${details}`));
154
153
  }
@@ -158,12 +157,3 @@ async function initializeGitRepository(sceneDir) {
158
157
  async function runGit(args, cwd) {
159
158
  await execFileAsync("git", args, { cwd });
160
159
  }
161
- function getErrorText(error) {
162
- if (!error || typeof error !== "object")
163
- return "";
164
- const err = error;
165
- const stderr = err.stderr?.trim();
166
- if (stderr)
167
- return stderr.split("\n").pop() ?? stderr;
168
- return err.message?.trim() ?? "";
169
- }
@@ -1,16 +1,14 @@
1
1
  import chalk from "chalk";
2
- import * as path from "path";
3
- import * as fs from "fs";
2
+ import * as path from "node:path";
3
+ import * as fs from "node:fs";
4
+ import readline from "node:readline";
4
5
  import { WebSocketServer } from "ws";
6
+ import { WS_PORT, SCENE_SYNC_TYPE, SCENE_FILE_NAME } from "../lib/constants.js";
7
+ import { getRequiredManifestSceneId } from "../lib/manifest.js";
5
8
  import { unpackageScene, parseSceneJson, packageSceneFromFilesystem } from "../lib/scene/scene-packaging.js";
6
9
  import { createScriptWatcher } from "../lib/scene/script-watcher.js";
7
10
  import { toErrorMessage } from "../lib/utils.js";
8
- import readline from "readline";
9
11
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
10
- const WS_PORT = 31113;
11
- const SCENE_SYNC_TYPE = "phibelle-kit-scene-sync";
12
- const SCENE_FILE_NAME = "scene.phibelle";
13
- const MANIFEST_FILE_NAME = "manifest.json";
14
12
  export const BASE_URL = process.env.NODE_ENV === "development" ? "http://localhost:3131" : "https://phibelle.studio";
15
13
  export async function watchSceneCommand() {
16
14
  const sceneDir = process.cwd();
@@ -186,27 +184,3 @@ function askQuestion(prompt) {
186
184
  rl.question(prompt, resolve);
187
185
  });
188
186
  }
189
- function getRequiredManifestSceneId(sceneDir) {
190
- const manifestPath = path.join(sceneDir, MANIFEST_FILE_NAME);
191
- if (!fs.existsSync(manifestPath)) {
192
- throw new Error(`Missing ${MANIFEST_FILE_NAME} in ${sceneDir}. Run "phibelle-kit clone" first or watch from a cloned scene folder.`);
193
- }
194
- let manifestRaw;
195
- try {
196
- manifestRaw = fs.readFileSync(manifestPath, "utf8");
197
- }
198
- catch {
199
- throw new Error(`Failed to read ${MANIFEST_FILE_NAME} in ${sceneDir}.`);
200
- }
201
- let manifest;
202
- try {
203
- manifest = JSON.parse(manifestRaw);
204
- }
205
- catch {
206
- throw new Error(`Invalid ${MANIFEST_FILE_NAME}: expected valid JSON.`);
207
- }
208
- if (typeof manifest.sceneId !== "string" || !manifest.sceneId.trim()) {
209
- throw new Error(`Invalid ${MANIFEST_FILE_NAME}: missing required "sceneId".`);
210
- }
211
- return manifest.sceneId.trim();
212
- }
package/dist/index.js CHANGED
@@ -1,15 +1,10 @@
1
1
  #!/usr/bin/env node
2
2
  import chalk from "chalk";
3
3
  import { watchSceneCommand } from "./commands/watch-scene.js";
4
- import { initSceneCommand } from "./commands/init-scene.js";
5
4
  import { cloneSceneCommand } from "./commands/clone-scene.js";
6
- const USAGE = " Usage: phibelle-kit init | phibelle-kit watch | phibelle-kit clone";
5
+ const USAGE = " Usage: phibelle-kit watch | phibelle-kit clone";
7
6
  async function main() {
8
7
  const command = process.argv[2];
9
- if (command === "init") {
10
- initSceneCommand();
11
- return;
12
- }
13
8
  if (command === "watch") {
14
9
  await watchSceneCommand();
15
10
  return;
@@ -0,0 +1,12 @@
1
+ /** WebSocket port for phibelle-kit CLI ↔ web app communication. Must match app's use-cli-websocket.ts */
2
+ export declare const WS_PORT = 31113;
3
+ /** Scene JSON file in cloned scene directory */
4
+ export declare const SCENE_FILE_NAME = "scene.phibelle";
5
+ /** Manifest file mapping entity IDs to filesystem paths */
6
+ export declare const MANIFEST_FILE_NAME = "manifest.json";
7
+ /** Message type for scene sync over WebSocket */
8
+ export declare const SCENE_SYNC_TYPE = "phibelle-kit-scene-sync";
9
+ /** Message type for clone request from kit to app */
10
+ export declare const CLONE_REQUEST_TYPE = "phibelle-kit-clone-request";
11
+ /** Message type for clone response from app to kit */
12
+ export declare const CLONE_RESPONSE_TYPE = "phibelle-kit-clone-response";
@@ -0,0 +1,12 @@
1
+ /** WebSocket port for phibelle-kit CLI ↔ web app communication. Must match app's use-cli-websocket.ts */
2
+ export const WS_PORT = 31113;
3
+ /** Scene JSON file in cloned scene directory */
4
+ export const SCENE_FILE_NAME = "scene.phibelle";
5
+ /** Manifest file mapping entity IDs to filesystem paths */
6
+ export const MANIFEST_FILE_NAME = "manifest.json";
7
+ /** Message type for scene sync over WebSocket */
8
+ export const SCENE_SYNC_TYPE = "phibelle-kit-scene-sync";
9
+ /** Message type for clone request from kit to app */
10
+ export const CLONE_REQUEST_TYPE = "phibelle-kit-clone-request";
11
+ /** Message type for clone response from app to kit */
12
+ export const CLONE_RESPONSE_TYPE = "phibelle-kit-clone-response";
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Read manifest.json from sceneDir and return the required sceneId.
3
+ * Throws with user-facing errors if manifest is missing, unreadable, invalid JSON, or missing sceneId.
4
+ */
5
+ export declare function getRequiredManifestSceneId(sceneDir: string): string;
@@ -0,0 +1,31 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import { MANIFEST_FILE_NAME } from "./constants.js";
4
+ /**
5
+ * Read manifest.json from sceneDir and return the required sceneId.
6
+ * Throws with user-facing errors if manifest is missing, unreadable, invalid JSON, or missing sceneId.
7
+ */
8
+ export function getRequiredManifestSceneId(sceneDir) {
9
+ const manifestPath = path.join(sceneDir, MANIFEST_FILE_NAME);
10
+ if (!fs.existsSync(manifestPath)) {
11
+ throw new Error(`Missing ${MANIFEST_FILE_NAME} in ${sceneDir}. Run "phibelle-kit clone" first or watch from a cloned scene folder.`);
12
+ }
13
+ let manifestRaw;
14
+ try {
15
+ manifestRaw = fs.readFileSync(manifestPath, "utf8");
16
+ }
17
+ catch {
18
+ throw new Error(`Failed to read ${MANIFEST_FILE_NAME} in ${sceneDir}.`);
19
+ }
20
+ let manifest;
21
+ try {
22
+ manifest = JSON.parse(manifestRaw);
23
+ }
24
+ catch {
25
+ throw new Error(`Invalid ${MANIFEST_FILE_NAME}: expected valid JSON.`);
26
+ }
27
+ if (typeof manifest.sceneId !== "string" || !manifest.sceneId.trim()) {
28
+ throw new Error(`Invalid ${MANIFEST_FILE_NAME}: missing required "sceneId".`);
29
+ }
30
+ return manifest.sceneId.trim();
31
+ }
@@ -1,5 +1,6 @@
1
+ import { SCENE_FILE_NAME } from "../constants.js";
1
2
  export declare const SCENE_APP_DIR = "app";
2
- export declare const SCENE_FILE_NAME = "scene.phibelle";
3
+ export { SCENE_FILE_NAME };
3
4
  export declare function getSceneAppDir(sceneDir: string): string;
4
5
  export declare function folderExists(folderPath: string): boolean;
5
6
  export declare function createFolder(folderPath: string): void;
@@ -1,7 +1,8 @@
1
1
  import * as path from "node:path";
2
- import * as fs from "fs";
2
+ import * as fs from "node:fs";
3
+ import { SCENE_FILE_NAME } from "../constants.js";
3
4
  export const SCENE_APP_DIR = "app";
4
- export const SCENE_FILE_NAME = "scene.phibelle";
5
+ export { SCENE_FILE_NAME };
5
6
  export function getSceneAppDir(sceneDir) {
6
7
  return path.join(sceneDir, SCENE_APP_DIR);
7
8
  }
@@ -2,20 +2,16 @@ import type { PhibelleScene } from "../types.js";
2
2
  /** Scene-level file names in app/. */
3
3
  export declare const SCENE_SCRIPT_FILE = "scene-script.tsx";
4
4
  export declare const SCENE_PROPERTIES_FILE = "scene-properties.json";
5
- export declare function parseSceneJson(content: string): PhibelleScene;
6
- /** Convert entity display name to slug: "Main Enemy" → "main-enemy". */
7
- export declare function entityNameToSlug(name: string): string;
8
- /** Convert slug to display name: "main-enemy" → "Main Enemy". */
9
- export declare function slugToEntityName(slug: string): string;
10
5
  /** Entity filenames inside an entity folder. */
11
6
  export declare const ENTITY_SCRIPT_FILE = "script.tsx";
12
7
  export declare const ENTITY_PROPERTIES_FILE = "properties.json";
13
8
  export declare const ENTITY_TRANSFORMS_FILE = "transforms.json";
9
+ export declare function parseSceneJson(content: string): PhibelleScene;
10
+ /** Convert entity display name to slug: "Main Enemy" -> "main-enemy". */
11
+ export declare function entityNameToSlug(name: string): string;
12
+ /** Convert slug to display name: "main-enemy" -> "Main Enemy". */
13
+ export declare function slugToEntityName(slug: string): string;
14
14
  /** Unpackage scene JSON to filesystem. No DB; accepts full scene JSON string. */
15
15
  export declare function unpackageScene(sceneJson: string, sceneDir: string, sceneId?: string): Promise<void>;
16
- /**
17
- * Apply a single file change to the in-memory scene and return updated JSON.
18
- * Used by script watcher to produce scene snapshot to send to FE.
19
- */
20
- export declare function patchSceneFromFile(sceneJson: string, sceneDir: string, relativePath: string, eventType: "change" | "add"): Promise<string>;
16
+ export declare function patchSceneFromFile(sceneJson: string, sceneDir: string, _relativePath: string, _eventType: "change" | "add" | "unlink" | "addDir" | "unlinkDir"): Promise<string>;
21
17
  export declare function packageSceneFromFilesystem(sceneJson: string, sceneDir: string): Promise<string>;
@@ -1,9 +1,19 @@
1
- import * as fs from "fs/promises";
2
- import * as path from "path";
1
+ import * as fs from "node:fs/promises";
2
+ import * as path from "node:path";
3
3
  import { getSceneAppDir } from "./file-system.js";
4
+ import { MANIFEST_FILE_NAME } from "../constants.js";
5
+ const LEGACY_SCENE_FILE = "scene.tsx";
6
+ const CHILDREN_DIR_NAME = "children";
4
7
  /** Scene-level file names in app/. */
5
8
  export const SCENE_SCRIPT_FILE = "scene-script.tsx";
6
9
  export const SCENE_PROPERTIES_FILE = "scene-properties.json";
10
+ /** Entity filenames inside an entity folder. */
11
+ export const ENTITY_SCRIPT_FILE = "script.tsx";
12
+ export const ENTITY_PROPERTIES_FILE = "properties.json";
13
+ export const ENTITY_TRANSFORMS_FILE = "transforms.json";
14
+ const DEFAULT_POSITION = '{"x":0,"y":0,"z":0}';
15
+ const DEFAULT_ROTATION = '{"isEuler":true,"_x":0,"_y":0,"_z":0,"_order":"XYZ"}';
16
+ const DEFAULT_SCALE = '{"x":1,"y":1,"z":1}';
7
17
  export function parseSceneJson(content) {
8
18
  try {
9
19
  return JSON.parse(content);
@@ -12,7 +22,7 @@ export function parseSceneJson(content) {
12
22
  throw new Error("Invalid scene file: not valid JSON");
13
23
  }
14
24
  }
15
- /** Convert entity display name to slug: "Main Enemy" "main-enemy". */
25
+ /** Convert entity display name to slug: "Main Enemy" -> "main-enemy". */
16
26
  export function entityNameToSlug(name) {
17
27
  const slug = name
18
28
  .toLowerCase()
@@ -20,7 +30,7 @@ export function entityNameToSlug(name) {
20
30
  .replace(/^-|-$/g, "");
21
31
  return slug || "entity";
22
32
  }
23
- /** Convert slug to display name: "main-enemy" "Main Enemy". */
33
+ /** Convert slug to display name: "main-enemy" -> "Main Enemy". */
24
34
  export function slugToEntityName(slug) {
25
35
  if (!slug || slug === "entity")
26
36
  return "Entity";
@@ -29,13 +39,6 @@ export function slugToEntityName(slug) {
29
39
  .map((word) => word.charAt(0).toUpperCase() + word.slice(1))
30
40
  .join(" ");
31
41
  }
32
- /** Entity filenames inside an entity folder. */
33
- export const ENTITY_SCRIPT_FILE = "script.tsx";
34
- export const ENTITY_PROPERTIES_FILE = "properties.json";
35
- export const ENTITY_TRANSFORMS_FILE = "transforms.json";
36
- const DEFAULT_POSITION = '{"x":0,"y":0,"z":0}';
37
- const DEFAULT_ROTATION = '{"isEuler":true,"_x":0,"_y":0,"_z":0,"_order":"XYZ"}';
38
- const DEFAULT_SCALE = '{"x":1,"y":1,"z":1}';
39
42
  /** Unpackage scene JSON to filesystem. No DB; accepts full scene JSON string. */
40
43
  export async function unpackageScene(sceneJson, sceneDir, sceneId) {
41
44
  const parsed = parseSceneJson(sceneJson);
@@ -43,288 +46,508 @@ export async function unpackageScene(sceneJson, sceneDir, sceneId) {
43
46
  ? parsed._id
44
47
  : "";
45
48
  const appDir = getSceneAppDir(sceneDir);
49
+ const previousManifest = await readManifest(sceneDir);
46
50
  await fs.mkdir(appDir, { recursive: true });
51
+ await cleanupKnownEntityDirectories(appDir, previousManifest);
52
+ await cleanupLegacySceneFiles(appDir);
53
+ const manifest = {
54
+ sceneId: sceneId?.trim() || parsedSceneId,
55
+ sceneScript: SCENE_SCRIPT_FILE,
56
+ sceneProperties: SCENE_PROPERTIES_FILE,
57
+ entities: {},
58
+ };
59
+ await writeSceneFiles(parsed, appDir);
60
+ const store = ensureEntityStore(parsed);
61
+ const entities = store.entities ?? {};
62
+ const usedFolderNamesByParent = new Map();
63
+ const rootIds = getRootEntityIds(store);
64
+ for (const rootId of rootIds) {
65
+ await writeEntityTree({
66
+ appDir,
67
+ entities,
68
+ entityId: rootId,
69
+ manifest,
70
+ usedFolderNamesByParent,
71
+ });
72
+ }
73
+ await fs.mkdir(sceneDir, { recursive: true });
74
+ await writeManifest(sceneDir, manifest);
75
+ }
76
+ export async function patchSceneFromFile(sceneJson, sceneDir, _relativePath, _eventType) {
77
+ return packageSceneFromFilesystem(sceneJson, sceneDir);
78
+ }
79
+ export async function packageSceneFromFilesystem(sceneJson, sceneDir) {
80
+ const manifest = await readManifest(sceneDir);
81
+ const parsed = parseSceneJson(sceneJson);
82
+ const appDir = getSceneAppDir(sceneDir);
83
+ await applySceneFileChanges(parsed, appDir, manifest);
84
+ await reconcileEntitiesFromFilesystem(parsed, manifest, appDir);
85
+ await writeManifest(sceneDir, manifest);
86
+ return JSON.stringify(parsed);
87
+ }
88
+ function normalizeRelativePath(relativePath) {
89
+ return relativePath.replace(/\\/g, "/").replace(/^\.\/+/, "").replace(/^\/+/, "");
90
+ }
91
+ function getEntityFolderFromRelativePath(relativePath) {
92
+ const normalized = normalizeRelativePath(relativePath);
93
+ const fileName = path.posix.basename(normalized);
94
+ if (fileName === ENTITY_SCRIPT_FILE ||
95
+ fileName === ENTITY_PROPERTIES_FILE ||
96
+ fileName === ENTITY_TRANSFORMS_FILE) {
97
+ const entityDir = path.posix.dirname(normalized);
98
+ return entityDir === "." ? null : entityDir;
99
+ }
100
+ return null;
101
+ }
102
+ function getEntityIdFromRelativePath(manifest, relativePath) {
103
+ const entityDir = getEntityFolderFromRelativePath(relativePath);
104
+ if (!entityDir)
105
+ return null;
106
+ return Object.entries(manifest.entities).find(([, folderPath]) => folderPath === entityDir)?.[0] ?? null;
107
+ }
108
+ function ensureEntityStore(parsed) {
109
+ if (!parsed.entityStore) {
110
+ parsed.entityStore = {
111
+ _currId: 0,
112
+ rootEntities: [],
113
+ entities: {},
114
+ rootRenderVersion: 0,
115
+ lastRenderedVersion: 0,
116
+ selectedEntityIds: [],
117
+ };
118
+ }
119
+ if (!parsed.entityStore.entities) {
120
+ parsed.entityStore.entities = {};
121
+ }
122
+ if (!Array.isArray(parsed.entityStore.rootEntities)) {
123
+ parsed.entityStore.rootEntities = [];
124
+ }
125
+ return parsed.entityStore;
126
+ }
127
+ function getRootEntityIds(store) {
128
+ if (Array.isArray(store.rootEntities) && store.rootEntities.length > 0) {
129
+ return store.rootEntities;
130
+ }
131
+ return Object.values(store.entities ?? {})
132
+ .filter((entity) => entity.parentId === undefined)
133
+ .map((entity) => entity.engineId);
134
+ }
135
+ async function readManifest(sceneDir) {
136
+ try {
137
+ const manifestRaw = await fs.readFile(path.join(sceneDir, MANIFEST_FILE_NAME), "utf8");
138
+ const parsed = JSON.parse(manifestRaw);
139
+ return {
140
+ sceneId: typeof parsed.sceneId === "string" ? parsed.sceneId : "",
141
+ sceneScript: typeof parsed.sceneScript === "string" ? parsed.sceneScript : SCENE_SCRIPT_FILE,
142
+ sceneProperties: typeof parsed.sceneProperties === "string" ? parsed.sceneProperties : SCENE_PROPERTIES_FILE,
143
+ entities: parsed.entities ?? {},
144
+ };
145
+ }
146
+ catch {
147
+ return {
148
+ sceneId: "",
149
+ sceneScript: SCENE_SCRIPT_FILE,
150
+ sceneProperties: SCENE_PROPERTIES_FILE,
151
+ entities: {},
152
+ };
153
+ }
154
+ }
155
+ async function writeManifest(sceneDir, manifest) {
156
+ await fs.writeFile(path.join(sceneDir, MANIFEST_FILE_NAME), JSON.stringify(manifest, null, 2), "utf8");
157
+ }
158
+ async function cleanupLegacySceneFiles(appDir) {
47
159
  try {
48
160
  const entries = await fs.readdir(appDir, { withFileTypes: true });
49
- for (const ent of entries) {
50
- if (ent.isFile()) {
51
- if (ent.name === "scene.tsx")
52
- await fs.unlink(path.join(appDir, ent.name));
53
- else if (ent.name.endsWith(".tsx") && ent.name !== SCENE_SCRIPT_FILE)
54
- await fs.unlink(path.join(appDir, ent.name));
161
+ for (const entry of entries) {
162
+ if (!entry.isFile())
163
+ continue;
164
+ if (entry.name === LEGACY_SCENE_FILE) {
165
+ await fs.rm(path.join(appDir, entry.name), { force: true });
55
166
  }
56
- if (ent.isDirectory()) {
57
- const scriptPath = path.join(appDir, ent.name, ENTITY_SCRIPT_FILE);
58
- try {
59
- await fs.access(scriptPath);
60
- await fs.rm(path.join(appDir, ent.name), { recursive: true });
61
- }
62
- catch {
63
- /* no script.tsx, leave dir alone */
64
- }
167
+ else if (entry.name.endsWith(".tsx") && entry.name !== SCENE_SCRIPT_FILE) {
168
+ await fs.rm(path.join(appDir, entry.name), { force: true });
65
169
  }
66
170
  }
67
171
  }
68
172
  catch {
69
- /* directory might not exist or be empty */
173
+ // app directory may not exist yet
70
174
  }
71
- const entities = parsed.entityStore?.entities ?? {};
72
- const manifest = {
73
- sceneId: sceneId?.trim() || parsedSceneId,
74
- sceneScript: SCENE_SCRIPT_FILE,
75
- sceneProperties: SCENE_PROPERTIES_FILE,
76
- entities: {},
77
- };
175
+ }
176
+ async function cleanupKnownEntityDirectories(appDir, manifest) {
177
+ const knownPaths = Array.from(new Set(Object.values(manifest.entities)
178
+ .map((relativePath) => normalizeRelativePath(relativePath))
179
+ .filter(Boolean)
180
+ .sort((a, b) => b.split("/").length - a.split("/").length)));
181
+ for (const relativePath of knownPaths) {
182
+ await fs.rm(path.join(appDir, relativePath), { recursive: true, force: true });
183
+ await removeEmptyAncestorChildrenDirs(appDir, path.posix.dirname(relativePath));
184
+ }
185
+ }
186
+ async function removeEmptyAncestorChildrenDirs(appDir, relativeDir) {
187
+ let current = relativeDir;
188
+ while (current && current !== ".") {
189
+ const basename = path.posix.basename(current);
190
+ if (basename !== CHILDREN_DIR_NAME) {
191
+ current = path.posix.dirname(current);
192
+ continue;
193
+ }
194
+ const absoluteDir = path.join(appDir, current);
195
+ try {
196
+ const entries = await fs.readdir(absoluteDir);
197
+ if (entries.length > 0)
198
+ break;
199
+ await fs.rmdir(absoluteDir);
200
+ }
201
+ catch {
202
+ break;
203
+ }
204
+ current = path.posix.dirname(current);
205
+ }
206
+ }
207
+ async function writeSceneFiles(parsed, appDir) {
78
208
  await fs.writeFile(path.join(appDir, SCENE_SCRIPT_FILE), parsed.sceneScriptStore?.sceneScript ?? "", "utf8");
79
209
  const scenePropertiesJson = JSON.stringify(parsed.sceneScriptStore?.sceneProperties ?? [], null, 2);
80
210
  await fs.writeFile(path.join(appDir, SCENE_PROPERTIES_FILE), scenePropertiesJson, "utf8");
81
- const usedFolderNames = new Set();
82
- for (const idStr of Object.keys(entities)) {
83
- const entity = entities[idStr];
84
- if (!entity || typeof entity.script !== "string")
85
- continue;
86
- const engineId = String(entity.engineId ?? idStr);
87
- const baseName = entity.name ?? "Entity";
88
- let folderName = baseName;
89
- if (usedFolderNames.has(folderName))
90
- folderName = `${baseName}-${engineId}`;
91
- usedFolderNames.add(folderName);
92
- const entityDir = path.join(appDir, folderName);
93
- await fs.mkdir(entityDir, { recursive: true });
94
- manifest.entities[engineId] = folderName;
95
- await fs.writeFile(path.join(entityDir, ENTITY_SCRIPT_FILE), entity.script, "utf8");
96
- const propertiesJson = JSON.stringify(entity.properties ?? [], null, 2);
97
- await fs.writeFile(path.join(entityDir, ENTITY_PROPERTIES_FILE), propertiesJson, "utf8");
98
- const transform = entity.transform ?? {
99
- position: DEFAULT_POSITION,
100
- rotation: DEFAULT_ROTATION,
101
- scale: DEFAULT_SCALE,
102
- };
103
- const transformsData = {
104
- position: JSON.parse(transform.position),
105
- rotation: JSON.parse(transform.rotation),
106
- scale: JSON.parse(transform.scale),
107
- };
108
- await fs.writeFile(path.join(entityDir, ENTITY_TRANSFORMS_FILE), JSON.stringify(transformsData, null, 2), "utf8");
211
+ }
212
+ async function writeEntityTree(args) {
213
+ const { appDir, entities, entityId, manifest, parentRelativePath, usedFolderNamesByParent } = args;
214
+ const entity = entities[String(entityId)];
215
+ if (!entity || typeof entity.script !== "string")
216
+ return;
217
+ const parentKey = parentRelativePath ?? "__root__";
218
+ const siblingNames = usedFolderNamesByParent.get(parentKey) ?? new Set();
219
+ usedFolderNamesByParent.set(parentKey, siblingNames);
220
+ const engineId = String(entity.engineId ?? entityId);
221
+ const baseName = (entity.name ?? "Entity").trim() || "Entity";
222
+ let folderName = baseName;
223
+ if (siblingNames.has(folderName))
224
+ folderName = `${baseName}-${engineId}`;
225
+ siblingNames.add(folderName);
226
+ const relativePath = parentRelativePath
227
+ ? path.posix.join(parentRelativePath, CHILDREN_DIR_NAME, folderName)
228
+ : folderName;
229
+ const entityDir = path.join(appDir, relativePath);
230
+ await fs.mkdir(entityDir, { recursive: true });
231
+ manifest.entities[engineId] = relativePath;
232
+ await writeEntityFiles(entityDir, entity);
233
+ for (const childId of entity.childrenIds ?? []) {
234
+ await writeEntityTree({
235
+ appDir,
236
+ entities,
237
+ entityId: childId,
238
+ manifest,
239
+ parentRelativePath: relativePath,
240
+ usedFolderNamesByParent,
241
+ });
109
242
  }
110
- await fs.mkdir(sceneDir, { recursive: true });
111
- await fs.writeFile(path.join(sceneDir, "manifest.json"), JSON.stringify(manifest, null, 2), "utf8");
112
243
  }
113
- function getEntityIdFromRelativePath(manifest, relativePath) {
114
- const normalized = relativePath.replace(/\\/g, "/");
115
- const match = normalized.match(/^([^/]+)\/(.+)$/);
116
- if (!match)
117
- return null;
118
- const [, folderName] = match;
119
- const entityId = Object.entries(manifest.entities).find(([, folder]) => folder === folderName)?.[0];
120
- return entityId ?? null;
121
- }
122
- /**
123
- * Apply a single file change to the in-memory scene and return updated JSON.
124
- * Used by script watcher to produce scene snapshot to send to FE.
125
- */
126
- export async function patchSceneFromFile(sceneJson, sceneDir, relativePath, eventType) {
127
- const manifestRaw = await fs.readFile(path.join(sceneDir, "manifest.json"), "utf8");
128
- const manifest = JSON.parse(manifestRaw);
129
- const parsed = parseSceneJson(sceneJson);
130
- const appDir = getSceneAppDir(sceneDir);
131
- const filePath = path.join(appDir, relativePath);
132
- if (relativePath === manifest.sceneScript || relativePath === SCENE_SCRIPT_FILE) {
133
- const fileContent = await fs.readFile(filePath, "utf8");
134
- if (!parsed.sceneScriptStore)
135
- parsed.sceneScriptStore = { sceneScript: "", sceneProperties: [], lastModifiedSceneScript: 0 };
136
- if (parsed.sceneScriptStore.sceneScript === fileContent)
137
- return sceneJson;
138
- parsed.sceneScriptStore.sceneScript = fileContent;
139
- parsed.sceneScriptStore.lastModifiedSceneScript = Date.now();
140
- return JSON.stringify(parsed);
141
- }
142
- if (relativePath === (manifest.sceneProperties ?? SCENE_PROPERTIES_FILE)) {
143
- const fileContent = await fs.readFile(filePath, "utf8");
144
- const sceneProperties = JSON.parse(fileContent);
145
- if (!parsed.sceneScriptStore)
146
- parsed.sceneScriptStore = { sceneScript: "", sceneProperties: [], lastModifiedSceneScript: 0 };
147
- const nextSceneProperties = Array.isArray(sceneProperties) ? sceneProperties : [];
148
- if (JSON.stringify(parsed.sceneScriptStore.sceneProperties ?? []) === JSON.stringify(nextSceneProperties)) {
149
- return sceneJson;
244
+ async function writeEntityFiles(entityDir, entity) {
245
+ await fs.writeFile(path.join(entityDir, ENTITY_SCRIPT_FILE), entity.script, "utf8");
246
+ await fs.writeFile(path.join(entityDir, ENTITY_PROPERTIES_FILE), JSON.stringify(entity.properties ?? [], null, 2), "utf8");
247
+ const transform = entity.transform ?? {
248
+ position: DEFAULT_POSITION,
249
+ rotation: DEFAULT_ROTATION,
250
+ scale: DEFAULT_SCALE,
251
+ };
252
+ const transformsData = {
253
+ position: safeJsonParse(transform.position, JSON.parse(DEFAULT_POSITION)),
254
+ rotation: safeJsonParse(transform.rotation, JSON.parse(DEFAULT_ROTATION)),
255
+ scale: safeJsonParse(transform.scale, JSON.parse(DEFAULT_SCALE)),
256
+ };
257
+ await fs.writeFile(path.join(entityDir, ENTITY_TRANSFORMS_FILE), JSON.stringify(transformsData, null, 2), "utf8");
258
+ }
259
+ function safeJsonParse(value, fallback) {
260
+ try {
261
+ return JSON.parse(value);
262
+ }
263
+ catch {
264
+ return fallback;
265
+ }
266
+ }
267
+ async function applySceneFileChanges(parsed, appDir, manifest) {
268
+ if (!parsed.sceneScriptStore) {
269
+ parsed.sceneScriptStore = { sceneScript: "", sceneProperties: [], lastModifiedSceneScript: 0 };
270
+ }
271
+ const sceneScriptPath = path.join(appDir, manifest.sceneScript ?? SCENE_SCRIPT_FILE);
272
+ try {
273
+ const sceneScript = await fs.readFile(sceneScriptPath, "utf8");
274
+ if (parsed.sceneScriptStore.sceneScript !== sceneScript) {
275
+ parsed.sceneScriptStore.sceneScript = sceneScript;
276
+ parsed.sceneScriptStore.lastModifiedSceneScript = Date.now();
150
277
  }
151
- parsed.sceneScriptStore.sceneProperties = nextSceneProperties;
152
- return JSON.stringify(parsed);
153
- }
154
- const scriptMatch = relativePath.match(new RegExp(`^([^/]+)\\/${ENTITY_SCRIPT_FILE.replace(".", "\\.")}$`));
155
- if (scriptMatch) {
156
- const folderName = scriptMatch[1];
157
- const fileContent = await fs.readFile(filePath, "utf8");
158
- const existingId = Object.entries(manifest.entities).find(([, f]) => f === folderName)?.[0];
159
- if (existingId != null) {
160
- const entities = parsed.entityStore?.entities ?? {};
161
- const entity = entities[existingId];
162
- if (!entity)
163
- return sceneJson;
164
- if (entity.script === fileContent)
165
- return sceneJson;
166
- entity.script = fileContent;
278
+ }
279
+ catch {
280
+ // keep existing script if file is missing
281
+ }
282
+ const scenePropertiesPath = path.join(appDir, manifest.sceneProperties ?? SCENE_PROPERTIES_FILE);
283
+ try {
284
+ const rawSceneProperties = await fs.readFile(scenePropertiesPath, "utf8");
285
+ const sceneProperties = JSON.parse(rawSceneProperties);
286
+ parsed.sceneScriptStore.sceneProperties = Array.isArray(sceneProperties) ? sceneProperties : [];
287
+ }
288
+ catch {
289
+ // keep existing properties if file is missing or invalid
290
+ }
291
+ }
292
+ async function reconcileEntitiesFromFilesystem(parsed, manifest, appDir) {
293
+ const store = ensureEntityStore(parsed);
294
+ const entities = store.entities;
295
+ const previousRootEntities = [...store.rootEntities];
296
+ const previousChildrenById = new Map();
297
+ for (const [entityId, entity] of Object.entries(entities)) {
298
+ previousChildrenById.set(entityId, [...(entity.childrenIds ?? [])]);
299
+ }
300
+ const discoveredEntities = await scanFilesystemEntities(appDir);
301
+ const discoveredByPath = new Map(discoveredEntities.map((entity) => [entity.relativePath, entity]));
302
+ const currentIds = Object.keys(entities);
303
+ const assignedEntityIds = new Set();
304
+ const discoveryIdByPath = new Map();
305
+ for (const [entityId, entityPath] of Object.entries(manifest.entities)) {
306
+ if (!entities[entityId])
307
+ continue;
308
+ const normalizedPath = normalizeRelativePath(entityPath);
309
+ if (!discoveredByPath.has(normalizedPath))
310
+ continue;
311
+ assignedEntityIds.add(entityId);
312
+ discoveryIdByPath.set(normalizedPath, entityId);
313
+ }
314
+ const unmatchedEntityIds = currentIds.filter((entityId) => !assignedEntityIds.has(entityId));
315
+ const unmatchedDiscoveries = discoveredEntities.filter((entity) => !discoveryIdByPath.has(entity.relativePath));
316
+ matchDiscoveriesByFolderName(unmatchedDiscoveries, unmatchedEntityIds, entities, manifest, assignedEntityIds, discoveryIdByPath);
317
+ matchDiscoveriesByContent(unmatchedDiscoveries, unmatchedEntityIds, entities, assignedEntityIds, discoveryIdByPath);
318
+ let nextEntityId = Math.max(store._currId, ...currentIds.map(Number).filter(Number.isFinite), 0);
319
+ for (const discovery of unmatchedDiscoveries) {
320
+ if (discoveryIdByPath.has(discovery.relativePath))
321
+ continue;
322
+ nextEntityId += 1;
323
+ const entityId = String(nextEntityId);
324
+ assignedEntityIds.add(entityId);
325
+ discoveryIdByPath.set(discovery.relativePath, entityId);
326
+ entities[entityId] = createEntityFromDiscovery(Number(entityId), discovery);
327
+ }
328
+ store._currId = nextEntityId;
329
+ for (const discovery of discoveredEntities) {
330
+ const entityId = discoveryIdByPath.get(discovery.relativePath);
331
+ if (!entityId)
332
+ continue;
333
+ const entity = entities[entityId] ?? createEntityFromDiscovery(Number(entityId), discovery);
334
+ entity.engineId = Number(entityId);
335
+ entity.name = discovery.folderName;
336
+ if (entity.script !== discovery.script) {
167
337
  entity.scriptVersion = (entity.scriptVersion ?? 0) + 1;
168
- return JSON.stringify(parsed);
338
+ entity.script = discovery.script;
169
339
  }
170
- if (eventType === "add") {
171
- return addNewEntityFromFolder(parsed, manifest, sceneDir, folderName, appDir);
340
+ entity.properties = discovery.properties;
341
+ entity.transform = discovery.transform;
342
+ entity.childrenIds = [];
343
+ entity.childrenIdsSet = [];
344
+ entities[entityId] = entity;
345
+ }
346
+ const desiredParentById = new Map();
347
+ const desiredChildrenByParent = new Map();
348
+ for (const discovery of discoveredEntities) {
349
+ const entityId = discoveryIdByPath.get(discovery.relativePath);
350
+ if (!entityId)
351
+ continue;
352
+ const parentId = discovery.parentRelativePath != null
353
+ ? discoveryIdByPath.get(discovery.parentRelativePath)
354
+ : undefined;
355
+ desiredParentById.set(entityId, parentId);
356
+ const siblings = desiredChildrenByParent.get(parentId) ?? [];
357
+ siblings.push(entityId);
358
+ desiredChildrenByParent.set(parentId, siblings);
359
+ }
360
+ const finalEntityIds = new Set(discoveryIdByPath.values());
361
+ for (const entityId of Object.keys(entities)) {
362
+ if (!finalEntityIds.has(entityId)) {
363
+ delete entities[entityId];
172
364
  }
173
- return sceneJson;
174
- }
175
- const propsMatch = relativePath.match(new RegExp(`^([^/]+)\\/${ENTITY_PROPERTIES_FILE.replace(".", "\\.")}$`));
176
- if (propsMatch) {
177
- const entityId = getEntityIdFromRelativePath(manifest, relativePath);
178
- if (entityId == null)
179
- return sceneJson;
180
- const fileContent = await fs.readFile(filePath, "utf8");
181
- const properties = JSON.parse(fileContent);
182
- const entities = parsed.entityStore?.entities ?? {};
365
+ }
366
+ for (const entityId of finalEntityIds) {
183
367
  const entity = entities[entityId];
184
368
  if (!entity)
185
- return sceneJson;
186
- const nextProperties = Array.isArray(properties) ? properties : [];
187
- if (JSON.stringify(entity.properties ?? []) === JSON.stringify(nextProperties))
188
- return sceneJson;
189
- entity.properties = nextProperties;
190
- return JSON.stringify(parsed);
191
- }
192
- const transformsMatch = relativePath.match(new RegExp(`^([^/]+)\\/${ENTITY_TRANSFORMS_FILE.replace(".", "\\.")}$`));
193
- if (transformsMatch) {
194
- const entityId = getEntityIdFromRelativePath(manifest, relativePath);
195
- if (entityId == null)
196
- return sceneJson;
197
- const fileContent = await fs.readFile(filePath, "utf8");
198
- const data = JSON.parse(fileContent);
199
- const entities = parsed.entityStore?.entities ?? {};
369
+ continue;
370
+ const desiredParentId = desiredParentById.get(entityId);
371
+ entity.parentId = desiredParentId != null ? Number(desiredParentId) : undefined;
372
+ }
373
+ store.rootEntities = mergeSiblingOrder(previousRootEntities.map(String), desiredChildrenByParent.get(undefined) ?? [], desiredParentById, undefined).map(Number);
374
+ for (const entityId of finalEntityIds) {
200
375
  const entity = entities[entityId];
201
376
  if (!entity)
202
- return sceneJson;
203
- if (!entity.transform)
204
- entity.transform = { position: DEFAULT_POSITION, rotation: DEFAULT_ROTATION, scale: DEFAULT_SCALE };
205
- const nextPosition = data.position !== undefined ? JSON.stringify(data.position) : entity.transform.position;
206
- const nextRotation = data.rotation !== undefined ? JSON.stringify(data.rotation) : entity.transform.rotation;
207
- const nextScale = data.scale !== undefined ? JSON.stringify(data.scale) : entity.transform.scale;
208
- if (entity.transform.position === nextPosition &&
209
- entity.transform.rotation === nextRotation &&
210
- entity.transform.scale === nextScale) {
211
- return sceneJson;
212
- }
213
- entity.transform.position = nextPosition;
214
- entity.transform.rotation = nextRotation;
215
- entity.transform.scale = nextScale;
216
- return JSON.stringify(parsed);
377
+ continue;
378
+ const currentChildren = (previousChildrenById.get(entityId) ?? []).map(String);
379
+ const desiredChildren = desiredChildrenByParent.get(entityId) ?? [];
380
+ const mergedChildren = mergeSiblingOrder(currentChildren, desiredChildren, desiredParentById, entityId);
381
+ entity.childrenIds = mergedChildren.map(Number);
382
+ entity.childrenIdsSet = [...entity.childrenIds];
217
383
  }
218
- return sceneJson;
384
+ manifest.entities = buildManifestEntityMap(store, entities, discoveryIdByPath);
219
385
  }
220
- export async function packageSceneFromFilesystem(sceneJson, sceneDir) {
221
- const manifestRaw = await fs.readFile(path.join(sceneDir, "manifest.json"), "utf8");
222
- const manifest = JSON.parse(manifestRaw);
223
- const appDir = getSceneAppDir(sceneDir);
224
- let packagedSceneJson = sceneJson;
225
- packagedSceneJson = await applyIfExists(packagedSceneJson, sceneDir, manifest.sceneScript ?? SCENE_SCRIPT_FILE);
226
- packagedSceneJson = await applyIfExists(packagedSceneJson, sceneDir, manifest.sceneProperties ?? SCENE_PROPERTIES_FILE);
227
- for (const folderName of Object.values(manifest.entities)) {
228
- packagedSceneJson = await applyIfExists(packagedSceneJson, sceneDir, path.join(folderName, ENTITY_SCRIPT_FILE).replace(/\\/g, "/"));
229
- packagedSceneJson = await applyIfExists(packagedSceneJson, sceneDir, path.join(folderName, ENTITY_PROPERTIES_FILE).replace(/\\/g, "/"));
230
- packagedSceneJson = await applyIfExists(packagedSceneJson, sceneDir, path.join(folderName, ENTITY_TRANSFORMS_FILE).replace(/\\/g, "/"));
231
- }
232
- let appEntries = [];
233
- try {
234
- appEntries = await fs.readdir(appDir, { withFileTypes: true });
386
+ function matchDiscoveriesByFolderName(discoveries, unmatchedEntityIds, entities, manifest, assignedEntityIds, discoveryIdByPath) {
387
+ for (const discovery of discoveries) {
388
+ if (discoveryIdByPath.has(discovery.relativePath))
389
+ continue;
390
+ const candidates = unmatchedEntityIds.filter((entityId) => {
391
+ if (assignedEntityIds.has(entityId))
392
+ return false;
393
+ const manifestPath = manifest.entities[entityId];
394
+ return manifestPath != null && path.posix.basename(normalizeRelativePath(manifestPath)) === discovery.folderName;
395
+ });
396
+ if (candidates.length !== 1)
397
+ continue;
398
+ const entityId = candidates[0];
399
+ if (!entities[entityId])
400
+ continue;
401
+ assignedEntityIds.add(entityId);
402
+ discoveryIdByPath.set(discovery.relativePath, entityId);
235
403
  }
236
- catch {
237
- return packagedSceneJson;
404
+ }
405
+ function matchDiscoveriesByContent(discoveries, unmatchedEntityIds, entities, assignedEntityIds, discoveryIdByPath) {
406
+ const signatureToEntityIds = new Map();
407
+ for (const entityId of unmatchedEntityIds) {
408
+ if (assignedEntityIds.has(entityId) || !entities[entityId])
409
+ continue;
410
+ const signature = getEntityContentSignature(entities[entityId]);
411
+ const entityIds = signatureToEntityIds.get(signature) ?? [];
412
+ entityIds.push(entityId);
413
+ signatureToEntityIds.set(signature, entityIds);
238
414
  }
239
- for (const entry of appEntries) {
240
- if (!entry.isDirectory())
415
+ for (const discovery of discoveries) {
416
+ if (discoveryIdByPath.has(discovery.relativePath))
241
417
  continue;
242
- if (Object.values(manifest.entities).includes(entry.name))
418
+ const candidateIds = (signatureToEntityIds.get(getDiscoveryContentSignature(discovery)) ?? []).filter((entityId) => !assignedEntityIds.has(entityId));
419
+ if (candidateIds.length !== 1)
243
420
  continue;
244
- const relativeScriptPath = path.join(entry.name, ENTITY_SCRIPT_FILE).replace(/\\/g, "/");
245
- packagedSceneJson = await applyIfExists(packagedSceneJson, sceneDir, relativeScriptPath, "add");
246
- packagedSceneJson = await applyIfExists(packagedSceneJson, sceneDir, path.join(entry.name, ENTITY_PROPERTIES_FILE).replace(/\\/g, "/"));
247
- packagedSceneJson = await applyIfExists(packagedSceneJson, sceneDir, path.join(entry.name, ENTITY_TRANSFORMS_FILE).replace(/\\/g, "/"));
421
+ const entityId = candidateIds[0];
422
+ assignedEntityIds.add(entityId);
423
+ discoveryIdByPath.set(discovery.relativePath, entityId);
248
424
  }
249
- return packagedSceneJson;
250
425
  }
251
- async function applyIfExists(sceneJson, sceneDir, relativePath, eventType = "change") {
252
- const appDir = getSceneAppDir(sceneDir);
253
- const filePath = path.join(appDir, relativePath);
254
- try {
255
- await fs.access(filePath);
426
+ function getEntityContentSignature(entity) {
427
+ return JSON.stringify({
428
+ script: entity.script,
429
+ properties: entity.properties ?? [],
430
+ transform: entity.transform ?? {
431
+ position: DEFAULT_POSITION,
432
+ rotation: DEFAULT_ROTATION,
433
+ scale: DEFAULT_SCALE,
434
+ },
435
+ });
436
+ }
437
+ function getDiscoveryContentSignature(discovery) {
438
+ return JSON.stringify({
439
+ script: discovery.script,
440
+ properties: discovery.properties,
441
+ transform: discovery.transform,
442
+ });
443
+ }
444
+ function createEntityFromDiscovery(engineId, discovery) {
445
+ return {
446
+ name: discovery.folderName,
447
+ engineId,
448
+ parentId: undefined,
449
+ childrenIdsSet: [],
450
+ childrenIds: [],
451
+ isNodeOpen: false,
452
+ script: discovery.script,
453
+ scriptVersion: 0,
454
+ transform: discovery.transform,
455
+ renderVersion: 0,
456
+ properties: discovery.properties,
457
+ threeId: 0,
458
+ };
459
+ }
460
+ function mergeSiblingOrder(previousOrder, desiredOrder, desiredParentById, parentId) {
461
+ const desiredSet = new Set(desiredOrder);
462
+ const preserved = previousOrder.filter((entityId) => desiredSet.has(entityId) && desiredParentById.get(entityId) === parentId);
463
+ const appended = desiredOrder.filter((entityId) => !preserved.includes(entityId));
464
+ return [...preserved, ...appended];
465
+ }
466
+ function buildManifestEntityMap(store, entities, discoveryIdByPath) {
467
+ const manifestEntities = {};
468
+ const pathByEntityId = new Map();
469
+ for (const [relativePath, entityId] of discoveryIdByPath.entries()) {
470
+ pathByEntityId.set(entityId, relativePath);
256
471
  }
257
- catch {
258
- return sceneJson;
472
+ function visit(entityId) {
473
+ const entity = entities[String(entityId)];
474
+ if (!entity)
475
+ return;
476
+ const relativePath = pathByEntityId.get(String(entityId));
477
+ if (!relativePath)
478
+ return;
479
+ manifestEntities[String(entityId)] = relativePath;
480
+ for (const childId of entity.childrenIds ?? []) {
481
+ visit(childId);
482
+ }
483
+ }
484
+ for (const rootId of store.rootEntities ?? []) {
485
+ visit(rootId);
259
486
  }
260
- return patchSceneFromFile(sceneJson, sceneDir, relativePath, eventType);
487
+ return manifestEntities;
261
488
  }
262
- async function addNewEntityFromFolder(parsed, manifest, sceneDir, folderName, appDir) {
263
- if (Object.values(manifest.entities).includes(folderName))
264
- return JSON.stringify(parsed);
265
- const scriptPath = path.join(appDir, folderName, ENTITY_SCRIPT_FILE);
266
- let script;
489
+ async function scanFilesystemEntities(appDir) {
490
+ const discovered = [];
491
+ await scanEntityCollection(appDir, appDir, discovered);
492
+ return discovered;
493
+ }
494
+ async function scanEntityCollection(collectionDir, appDir, discovered, parentRelativePath) {
495
+ let entries = [];
267
496
  try {
268
- script = await fs.readFile(scriptPath, "utf8");
497
+ entries = await fs.readdir(collectionDir, { withFileTypes: true });
269
498
  }
270
499
  catch {
271
- return JSON.stringify(parsed);
500
+ return;
272
501
  }
273
- let properties = [];
502
+ entries = entries.filter((entry) => entry.isDirectory()).sort((a, b) => a.name.localeCompare(b.name));
503
+ for (const entry of entries) {
504
+ const entityDir = path.join(collectionDir, entry.name);
505
+ const relativePath = normalizeRelativePath(path.relative(appDir, entityDir));
506
+ const scriptPath = path.join(entityDir, ENTITY_SCRIPT_FILE);
507
+ let script;
508
+ try {
509
+ script = await fs.readFile(scriptPath, "utf8");
510
+ }
511
+ catch {
512
+ continue;
513
+ }
514
+ const discovery = {
515
+ relativePath,
516
+ folderName: entry.name,
517
+ parentRelativePath,
518
+ script,
519
+ properties: await readPropertiesFile(entityDir),
520
+ transform: await readTransformFile(entityDir),
521
+ };
522
+ discovered.push(discovery);
523
+ await scanEntityCollection(path.join(entityDir, CHILDREN_DIR_NAME), appDir, discovered, relativePath);
524
+ }
525
+ }
526
+ async function readPropertiesFile(entityDir) {
274
527
  try {
275
- const raw = await fs.readFile(path.join(appDir, folderName, ENTITY_PROPERTIES_FILE), "utf8");
276
- const parsedProps = JSON.parse(raw);
277
- if (Array.isArray(parsedProps))
278
- properties = parsedProps;
528
+ const raw = await fs.readFile(path.join(entityDir, ENTITY_PROPERTIES_FILE), "utf8");
529
+ const parsed = JSON.parse(raw);
530
+ return Array.isArray(parsed) ? parsed : [];
279
531
  }
280
532
  catch {
281
- /* optional */
533
+ return [];
282
534
  }
283
- let transform = { position: DEFAULT_POSITION, rotation: DEFAULT_ROTATION, scale: DEFAULT_SCALE };
535
+ }
536
+ async function readTransformFile(entityDir) {
284
537
  try {
285
- const raw = await fs.readFile(path.join(appDir, folderName, ENTITY_TRANSFORMS_FILE), "utf8");
286
- const t = JSON.parse(raw);
287
- if (t.position !== undefined)
288
- transform.position = JSON.stringify(t.position);
289
- if (t.rotation !== undefined)
290
- transform.rotation = JSON.stringify(t.rotation);
291
- if (t.scale !== undefined)
292
- transform.scale = JSON.stringify(t.scale);
538
+ const raw = await fs.readFile(path.join(entityDir, ENTITY_TRANSFORMS_FILE), "utf8");
539
+ const parsed = JSON.parse(raw);
540
+ return {
541
+ position: parsed.position !== undefined ? JSON.stringify(parsed.position) : DEFAULT_POSITION,
542
+ rotation: parsed.rotation !== undefined ? JSON.stringify(parsed.rotation) : DEFAULT_ROTATION,
543
+ scale: parsed.scale !== undefined ? JSON.stringify(parsed.scale) : DEFAULT_SCALE,
544
+ };
293
545
  }
294
546
  catch {
295
- /* optional */
296
- }
297
- if (!parsed.entityStore) {
298
- parsed.entityStore = {
299
- _currId: 0,
300
- rootEntities: [],
301
- entities: {},
302
- rootRenderVersion: 0,
303
- lastRenderedVersion: 0,
304
- selectedEntityIds: [],
547
+ return {
548
+ position: DEFAULT_POSITION,
549
+ rotation: DEFAULT_ROTATION,
550
+ scale: DEFAULT_SCALE,
305
551
  };
306
552
  }
307
- const store = parsed.entityStore;
308
- const existingIds = [...Object.keys(manifest.entities).map(Number), store._currId];
309
- const engineId = existingIds.length ? Math.max(...existingIds) + 1 : 1;
310
- const idStr = String(engineId);
311
- store._currId = Math.max(store._currId, engineId);
312
- if (!store.rootEntities.includes(engineId))
313
- store.rootEntities.push(engineId);
314
- store.entities[idStr] = {
315
- name: folderName,
316
- engineId,
317
- childrenIdsSet: [],
318
- childrenIds: [],
319
- isNodeOpen: false,
320
- script,
321
- scriptVersion: 0,
322
- transform,
323
- renderVersion: 0,
324
- properties,
325
- threeId: 0,
326
- };
327
- manifest.entities[idStr] = folderName;
328
- await fs.writeFile(path.join(sceneDir, "manifest.json"), JSON.stringify(manifest, null, 2), "utf8");
329
- return JSON.stringify(parsed);
330
553
  }
@@ -1,39 +1,43 @@
1
- import * as path from "path";
1
+ import * as path from "node:path";
2
2
  import chokidar from "chokidar";
3
+ import { MANIFEST_FILE_NAME } from "../constants.js";
3
4
  import { getSceneAppDir } from "./file-system.js";
4
- import { patchSceneFromFile, ENTITY_SCRIPT_FILE, ENTITY_PROPERTIES_FILE, ENTITY_TRANSFORMS_FILE, SCENE_SCRIPT_FILE, SCENE_PROPERTIES_FILE } from "./scene-packaging.js";
5
+ import { packageSceneFromFilesystem, ENTITY_SCRIPT_FILE, ENTITY_PROPERTIES_FILE, ENTITY_TRANSFORMS_FILE, SCENE_SCRIPT_FILE, SCENE_PROPERTIES_FILE, } from "./scene-packaging.js";
5
6
  const DEBOUNCE_MS = 100;
6
7
  export function createScriptWatcher(sceneDir, getLatestSceneJson, onSceneUpdated, onError) {
7
8
  const appDir = getSceneAppDir(sceneDir);
8
9
  function toRelativePath(filePath) {
9
10
  return path.relative(appDir, filePath).replace(/\\/g, "/");
10
11
  }
11
- function isRelevantFile(relativePath) {
12
+ function isRelevantPath(relativePath) {
12
13
  if (relativePath === SCENE_SCRIPT_FILE || relativePath === SCENE_PROPERTIES_FILE)
13
14
  return true;
14
- const match = relativePath.match(/^([^/]+)\/(.+)$/);
15
- if (!match)
15
+ if (!relativePath || relativePath === ".")
16
16
  return false;
17
- const [, , fileName] = match;
18
- return (fileName === ENTITY_SCRIPT_FILE ||
19
- fileName === ENTITY_PROPERTIES_FILE ||
20
- fileName === ENTITY_TRANSFORMS_FILE);
17
+ const fileName = path.posix.basename(relativePath);
18
+ if (fileName === ENTITY_SCRIPT_FILE || fileName === ENTITY_PROPERTIES_FILE || fileName === ENTITY_TRANSFORMS_FILE) {
19
+ return true;
20
+ }
21
+ // Directory adds/removals can represent entity creates, deletes, or moves.
22
+ if (!path.posix.extname(fileName)) {
23
+ return true;
24
+ }
25
+ return false;
21
26
  }
22
27
  let debounceTimer = null;
23
- let lastEvent = null;
24
- function schedule(type, relativePath) {
25
- lastEvent = { type, relativePath };
28
+ let lastRelativePath = null;
29
+ function schedule(relativePath) {
30
+ lastRelativePath = relativePath;
26
31
  if (debounceTimer)
27
32
  clearTimeout(debounceTimer);
28
33
  debounceTimer = setTimeout(async () => {
29
34
  debounceTimer = null;
30
- const ev = lastEvent;
31
- lastEvent = null;
32
- if (!ev)
35
+ const rp = lastRelativePath;
36
+ lastRelativePath = null;
37
+ if (!rp)
33
38
  return;
34
- const rp = ev.relativePath;
35
39
  try {
36
- const updated = await patchSceneFromFile(getLatestSceneJson(), sceneDir, rp, ev.type);
40
+ const updated = await packageSceneFromFilesystem(getLatestSceneJson(), sceneDir);
37
41
  onSceneUpdated(updated);
38
42
  }
39
43
  catch (err) {
@@ -43,20 +47,18 @@ export function createScriptWatcher(sceneDir, getLatestSceneJson, onSceneUpdated
43
47
  }, DEBOUNCE_MS);
44
48
  }
45
49
  const watcher = chokidar.watch(appDir, { ignoreInitial: true });
46
- watcher.on("change", (filePath) => {
50
+ function handlePathEvent(filePath) {
47
51
  const relativePath = toRelativePath(filePath);
48
- if (relativePath === "manifest.json")
52
+ if (relativePath === MANIFEST_FILE_NAME)
49
53
  return;
50
- if (isRelevantFile(relativePath))
51
- schedule("change", relativePath);
52
- });
53
- watcher.on("add", (filePath) => {
54
- const relativePath = toRelativePath(filePath);
55
- if (relativePath === "manifest.json")
56
- return;
57
- if (isRelevantFile(relativePath))
58
- schedule("add", relativePath);
59
- });
54
+ if (isRelevantPath(relativePath))
55
+ schedule(relativePath);
56
+ }
57
+ watcher.on("change", handlePathEvent);
58
+ watcher.on("add", handlePathEvent);
59
+ watcher.on("unlink", handlePathEvent);
60
+ watcher.on("addDir", handlePathEvent);
61
+ watcher.on("unlinkDir", handlePathEvent);
60
62
  return {
61
63
  close: () => {
62
64
  if (debounceTimer)
@@ -19,6 +19,7 @@ export type PhibelleScene = {
19
19
  [id: string]: {
20
20
  name: string;
21
21
  engineId: number;
22
+ parentId?: number;
22
23
  childrenIdsSet: number[];
23
24
  childrenIds: number[];
24
25
  isNodeOpen: boolean;
@@ -1 +1,3 @@
1
1
  export declare function toErrorMessage(err: unknown): string;
2
+ /** For exec/child_process errors: prefer stderr (last line), otherwise message. */
3
+ export declare function getExecErrorText(error: unknown): string;
package/dist/lib/utils.js CHANGED
@@ -1,3 +1,13 @@
1
1
  export function toErrorMessage(err) {
2
2
  return err instanceof Error ? err.message : String(err);
3
3
  }
4
+ /** For exec/child_process errors: prefer stderr (last line), otherwise message. */
5
+ export function getExecErrorText(error) {
6
+ if (!error || typeof error !== "object")
7
+ return "";
8
+ const err = error;
9
+ const stderr = err.stderr?.trim();
10
+ if (stderr)
11
+ return stderr.split("\n").pop() ?? stderr;
12
+ return err.message?.trim() ?? "";
13
+ }
@@ -1,4 +1,3 @@
1
1
  export declare function setupSceneDirectory(sceneDir: string): {
2
2
  shouldPromptInstall: boolean;
3
3
  };
4
- export declare function getInstallHint(sceneDir: string): string;
@@ -39,18 +39,3 @@ export function setupSceneDirectory(sceneDir) {
39
39
  }
40
40
  return { shouldPromptInstall };
41
41
  }
42
- export function getInstallHint(sceneDir) {
43
- const hasPnpm = fs.existsSync(path.join(sceneDir, "pnpm-lock.yaml"));
44
- const hasYarn = fs.existsSync(path.join(sceneDir, "yarn.lock"));
45
- const hasBun = fs.existsSync(path.join(sceneDir, "bun.lockb"));
46
- const hasNpm = fs.existsSync(path.join(sceneDir, "package-lock.json"));
47
- if (hasPnpm)
48
- return "pnpm install";
49
- if (hasBun)
50
- return "bun install";
51
- if (hasYarn)
52
- return "yarn install";
53
- if (hasNpm)
54
- return "npm install";
55
- return "npm install (or pnpm / bun / yarn install)";
56
- }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "phibelle-kit",
3
- "version": "1.0.30",
3
+ "version": "1.0.32",
4
4
  "description": "CLI tool for interacting with the Phibelle engine",
5
5
  "type": "module",
6
6
  "bin": {
@@ -8,7 +8,8 @@
8
8
  },
9
9
  "scripts": {
10
10
  "build": "tsc",
11
- "start": "node dist/index.js"
11
+ "start": "node dist/index.js",
12
+ "test": "npm run build && node --test tests/*.test.mjs"
12
13
  },
13
14
  "engines": {
14
15
  "node": ">=20"