pixifact-cli 0.1.3
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/README.md +54 -0
- package/package.json +36 -0
- package/src/automation.ts +646 -0
- package/src/liveBridge.ts +28 -0
- package/src/liveBridgeServer.ts +163 -0
- package/src/pixifact-cli.ts +361 -0
package/README.md
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# Pixifact CLI
|
|
2
|
+
|
|
3
|
+
Command line tools for Pixifact Scene automation.
|
|
4
|
+
|
|
5
|
+
The CLI is Bun-first in this release.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
bun add -d pixifact-cli
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Commands
|
|
14
|
+
|
|
15
|
+
Inspect a Scene:
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
pixifact scene inspect --project-root . --scene src/scenes/Hud.scene
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Validate one Scene:
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
pixifact scene validate --project-root . --scene src/scenes/Hud.scene
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Validate every compiler Scene:
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
pixifact scene validate --project-root . --all
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Compile generated Scene runtime files:
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
pixifact compile-scenes --project-root .
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Agent Workflow
|
|
40
|
+
|
|
41
|
+
Codex, Claude Code, and other coding agents should edit `.scene` files directly, then run:
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
pixifact scene validate --project-root . --all
|
|
45
|
+
pixifact compile-scenes --project-root .
|
|
46
|
+
bun run build
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Do not edit generated files under `.pixifact/generated/**`.
|
|
50
|
+
|
|
51
|
+
## Requirements
|
|
52
|
+
|
|
53
|
+
- Bun
|
|
54
|
+
- `pixifact` installed in the target project
|
package/package.json
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pixifact-cli",
|
|
3
|
+
"version": "0.1.3",
|
|
4
|
+
"description": "CLI for Pixifact Scene automation.",
|
|
5
|
+
"packageManager": "bun@1.3.13",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"bin": {
|
|
8
|
+
"pixifact": "src/pixifact-cli.ts"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"src/"
|
|
12
|
+
],
|
|
13
|
+
"repository": {
|
|
14
|
+
"type": "git",
|
|
15
|
+
"url": "git+https://github.com/hxg2050/pixifact.git",
|
|
16
|
+
"directory": "packages/pixifact-cli"
|
|
17
|
+
},
|
|
18
|
+
"keywords": [
|
|
19
|
+
"pixifact",
|
|
20
|
+
"scene",
|
|
21
|
+
"cli",
|
|
22
|
+
"pixijs",
|
|
23
|
+
"game-editor"
|
|
24
|
+
],
|
|
25
|
+
"author": "youxia <243802688@qq.com>",
|
|
26
|
+
"license": "MIT",
|
|
27
|
+
"publishConfig": {
|
|
28
|
+
"registry": "https://registry.npmjs.org/"
|
|
29
|
+
},
|
|
30
|
+
"scripts": {
|
|
31
|
+
"start": "bun src/pixifact-cli.ts"
|
|
32
|
+
},
|
|
33
|
+
"dependencies": {
|
|
34
|
+
"pixifact": "^0.1.3"
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,646 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import {
|
|
4
|
+
createSceneRevision,
|
|
5
|
+
defaultSceneSourceRoots,
|
|
6
|
+
extractSceneScriptInterface,
|
|
7
|
+
findMissingScenePartReferences,
|
|
8
|
+
inspectSceneTemplate,
|
|
9
|
+
isIgnoredSceneSourceDirectory,
|
|
10
|
+
normalizeSceneAssetId,
|
|
11
|
+
pairedSceneScriptPath,
|
|
12
|
+
parseSceneTemplate,
|
|
13
|
+
resolveSceneReference,
|
|
14
|
+
sceneLocalName,
|
|
15
|
+
validateSceneContent,
|
|
16
|
+
} from 'pixifact/compiler';
|
|
17
|
+
import {
|
|
18
|
+
container,
|
|
19
|
+
scene,
|
|
20
|
+
SceneDocument,
|
|
21
|
+
parsePixifactProjectConfig,
|
|
22
|
+
pixifactProjectConfigFileName,
|
|
23
|
+
summarizePixifactProjectConfig,
|
|
24
|
+
} from 'pixifact';
|
|
25
|
+
import type { SceneSpec, NodeSpec } from 'pixifact';
|
|
26
|
+
import type { SceneProjectState } from 'pixifact';
|
|
27
|
+
import type { SceneTemplate, SceneTemplateInterface, SceneValidationDiagnostic } from 'pixifact/compiler';
|
|
28
|
+
|
|
29
|
+
interface ProjectFileSummary {
|
|
30
|
+
path: string;
|
|
31
|
+
kind: 'folder' | 'scene' | 'file';
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface EditableDocument {
|
|
35
|
+
projectRoot: string;
|
|
36
|
+
target: string;
|
|
37
|
+
document: SceneDocument;
|
|
38
|
+
isProjectFile: boolean;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
interface ToolInput {
|
|
42
|
+
projectRoot?: unknown;
|
|
43
|
+
scenePath?: unknown;
|
|
44
|
+
node?: unknown;
|
|
45
|
+
name?: unknown;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
type CompilerSceneValidationSuccess = ReturnType<typeof validateCompilerSceneFile> & { ok: true };
|
|
49
|
+
type CompilerSceneValidationFailure = ReturnType<typeof validateCompilerSceneFile> & { ok: false };
|
|
50
|
+
|
|
51
|
+
type DetailedNode = NodeSpec & {
|
|
52
|
+
locator: string;
|
|
53
|
+
depth: number;
|
|
54
|
+
parent?: string;
|
|
55
|
+
childCount: number;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
interface NodeSummary {
|
|
59
|
+
id?: string;
|
|
60
|
+
key?: string;
|
|
61
|
+
role?: string;
|
|
62
|
+
name?: string;
|
|
63
|
+
kind: NodeSpec['kind'];
|
|
64
|
+
locator: string;
|
|
65
|
+
depth: number;
|
|
66
|
+
transform?: NodeSpec['transform'];
|
|
67
|
+
components: Array<{
|
|
68
|
+
id?: string;
|
|
69
|
+
type: string;
|
|
70
|
+
propKeys: string[];
|
|
71
|
+
}>;
|
|
72
|
+
childCount: number;
|
|
73
|
+
children: NodeSummary[];
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const projectStateType = 'pixifact.aiEditorProject';
|
|
77
|
+
const sceneType = 'scene';
|
|
78
|
+
|
|
79
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
80
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function assertRecord(value: unknown, name: string) {
|
|
84
|
+
if (!isRecord(value)) {
|
|
85
|
+
throw new Error(`${name} must be an object.`);
|
|
86
|
+
}
|
|
87
|
+
return value;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function assertString(value: unknown, name: string) {
|
|
91
|
+
if (typeof value !== 'string' || value.trim() === '') {
|
|
92
|
+
throw new Error(`${name} must be a non-empty string.`);
|
|
93
|
+
}
|
|
94
|
+
return value;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function isInside(root: string, target: string) {
|
|
98
|
+
const relative = path.relative(root, target);
|
|
99
|
+
return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function resolveProjectPath(projectRoot: unknown, filePath: unknown = '.') {
|
|
103
|
+
const root = path.resolve(assertString(projectRoot, 'projectRoot'));
|
|
104
|
+
const target = path.resolve(root, assertString(filePath, 'filePath'));
|
|
105
|
+
if (!isInside(root, target)) {
|
|
106
|
+
throw new Error('filePath must stay inside projectRoot.');
|
|
107
|
+
}
|
|
108
|
+
return { root, target };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function readJsonFile(filePath: string): unknown {
|
|
112
|
+
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function readTextFile(filePath: string) {
|
|
116
|
+
return fs.readFileSync(filePath, 'utf8');
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function readPixifactProjectSummary(root: string) {
|
|
120
|
+
const configPath = path.join(root, pixifactProjectConfigFileName);
|
|
121
|
+
if (!fs.existsSync(configPath)) {
|
|
122
|
+
return undefined;
|
|
123
|
+
}
|
|
124
|
+
return summarizePixifactProjectConfig(parsePixifactProjectConfig(readJsonFile(configPath)));
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function writeTextFile(filePath: string, value: string) {
|
|
128
|
+
fs.writeFileSync(filePath, value, 'utf8');
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function writeNewJsonFile(filePath: string, value: unknown) {
|
|
132
|
+
try {
|
|
133
|
+
fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, {
|
|
134
|
+
encoding: 'utf8',
|
|
135
|
+
flag: 'wx',
|
|
136
|
+
});
|
|
137
|
+
} catch (error) {
|
|
138
|
+
if (isRecord(error) && error.code === 'EEXIST') {
|
|
139
|
+
throw new Error('Scene file already exists.');
|
|
140
|
+
}
|
|
141
|
+
throw error;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function isProjectState(value: unknown): value is SceneProjectState {
|
|
146
|
+
return isRecord(value)
|
|
147
|
+
&& value.version === 1
|
|
148
|
+
&& value.type === projectStateType
|
|
149
|
+
&& isRecord(value.scene)
|
|
150
|
+
&& (value.scene as { type?: unknown }).type === sceneType;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function isScene(value: unknown): value is SceneSpec {
|
|
154
|
+
return isRecord(value)
|
|
155
|
+
&& value.version === 1
|
|
156
|
+
&& value.type === sceneType
|
|
157
|
+
&& isRecord(value.root);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function loadEditableDocument(projectRoot: unknown, scenePath: unknown): EditableDocument {
|
|
161
|
+
const { root, target } = resolveProjectPath(projectRoot, scenePath);
|
|
162
|
+
const content = readJsonFile(target);
|
|
163
|
+
if (isProjectState(content)) {
|
|
164
|
+
const document = new SceneDocument(content.scene);
|
|
165
|
+
document.loadState(content);
|
|
166
|
+
return {
|
|
167
|
+
projectRoot: root,
|
|
168
|
+
target,
|
|
169
|
+
document,
|
|
170
|
+
isProjectFile: true,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
if (!isScene(content)) {
|
|
174
|
+
throw new Error('Target file must be a Pixifact Scene or AI editor project JSON.');
|
|
175
|
+
}
|
|
176
|
+
const document = new SceneDocument(content);
|
|
177
|
+
return {
|
|
178
|
+
projectRoot: root,
|
|
179
|
+
target,
|
|
180
|
+
document,
|
|
181
|
+
isProjectFile: false,
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function loadCompilerScene(projectRoot: unknown, scenePath: unknown) {
|
|
186
|
+
const { root, target } = resolveProjectPath(projectRoot, scenePath);
|
|
187
|
+
const content = readTextFile(target);
|
|
188
|
+
return {
|
|
189
|
+
root,
|
|
190
|
+
target,
|
|
191
|
+
scenePath: normalizeSceneAssetId(path.relative(root, target)),
|
|
192
|
+
content,
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function collectProjectAssets(root: string) {
|
|
197
|
+
const assets = new Set<string>();
|
|
198
|
+
const assetsRoot = path.join(root, 'assets');
|
|
199
|
+
if (!fs.existsSync(assetsRoot)) {
|
|
200
|
+
return assets;
|
|
201
|
+
}
|
|
202
|
+
collectAssetFiles(root, assetsRoot, assets);
|
|
203
|
+
return assets;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function collectAssetFiles(root: string, directory: string, assets: Set<string>) {
|
|
207
|
+
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
|
|
208
|
+
const absolute = path.join(directory, entry.name);
|
|
209
|
+
if (entry.isDirectory()) {
|
|
210
|
+
collectAssetFiles(root, absolute, assets);
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
if (entry.isFile()) {
|
|
214
|
+
assets.add(path.relative(root, absolute).replaceAll(path.sep, '/'));
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function collectCompilerSceneInterfaces(root: string, skippedScene?: string) {
|
|
220
|
+
const interfaces: Record<string, SceneTemplateInterface> = {};
|
|
221
|
+
|
|
222
|
+
for (const scenePath of collectCompilerScenePaths(root)) {
|
|
223
|
+
if (scenePath === skippedScene) {
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
try {
|
|
227
|
+
const template = parseSceneTemplate(readTextFile(path.join(root, scenePath)));
|
|
228
|
+
const pair = readCompilerScenePairContract(root, scenePath, template);
|
|
229
|
+
if (pair.diagnostics.length > 0 || !pair.sceneInterface) {
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
232
|
+
interfaces[scenePath] = pair.sceneInterface;
|
|
233
|
+
} catch {
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
return interfaces;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function collectCompilerScenePaths(root: string) {
|
|
241
|
+
const results: string[] = [];
|
|
242
|
+
for (const sourceRoot of defaultSceneSourceRoots) {
|
|
243
|
+
const absoluteSourceRoot = path.join(root, sourceRoot);
|
|
244
|
+
if (fs.existsSync(absoluteSourceRoot)) {
|
|
245
|
+
collectCompilerScenePathsIn(root, absoluteSourceRoot, results);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
return results.sort();
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function collectCompilerScenePathsIn(root: string, directory: string, results: string[]) {
|
|
252
|
+
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
|
|
253
|
+
if (entry.isDirectory()) {
|
|
254
|
+
if (!isIgnoredSceneSourceDirectory(entry.name)) {
|
|
255
|
+
collectCompilerScenePathsIn(root, path.join(directory, entry.name), results);
|
|
256
|
+
}
|
|
257
|
+
continue;
|
|
258
|
+
}
|
|
259
|
+
if (entry.isFile() && entry.name.endsWith('.scene')) {
|
|
260
|
+
results.push(normalizeSceneAssetId(path.relative(root, path.join(directory, entry.name))));
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function compilerSceneSourceRootDiagnostic(scenePath: string) {
|
|
266
|
+
const normalized = normalizeSceneAssetId(scenePath);
|
|
267
|
+
const insideRoot = defaultSceneSourceRoots.some((sourceRoot) => normalized.startsWith(`${sourceRoot}/`));
|
|
268
|
+
if (insideRoot) {
|
|
269
|
+
return undefined;
|
|
270
|
+
}
|
|
271
|
+
return {
|
|
272
|
+
path: '__scene__',
|
|
273
|
+
prop: 'path',
|
|
274
|
+
expected: 'Scene under source root "src/"',
|
|
275
|
+
actual: normalized,
|
|
276
|
+
hint: 'Move the .scene/.ts pair under src/ or configure an explicit Scene source root.',
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function normalizeCompilerSceneReference(scenePath: string) {
|
|
281
|
+
return (scene: string) => resolveSceneReference(scenePath, scene);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function compilerSceneSourceRootFailure(scenePath: string) {
|
|
285
|
+
const diagnostic = compilerSceneSourceRootDiagnostic(scenePath);
|
|
286
|
+
if (!diagnostic) {
|
|
287
|
+
return undefined;
|
|
288
|
+
}
|
|
289
|
+
return {
|
|
290
|
+
ok: false as const,
|
|
291
|
+
scene: scenePath,
|
|
292
|
+
error: 'Scene validation failed.',
|
|
293
|
+
diagnostics: [diagnostic],
|
|
294
|
+
hint: 'Fix the listed diagnostics, then run scene validate again.',
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function readCompilerScenePairContract(
|
|
299
|
+
root: string,
|
|
300
|
+
scenePath: string,
|
|
301
|
+
template: SceneTemplate,
|
|
302
|
+
): { diagnostics: SceneValidationDiagnostic[]; sceneInterface?: SceneTemplateInterface } {
|
|
303
|
+
const expectedName = sceneLocalName(scenePath);
|
|
304
|
+
if (template.name !== expectedName) {
|
|
305
|
+
return {
|
|
306
|
+
diagnostics: [compilerSceneBasenameDiagnostic(template.name, expectedName)],
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const scriptPath = pairedSceneScriptPath(scenePath);
|
|
311
|
+
const absoluteScriptPath = path.join(root, scriptPath);
|
|
312
|
+
if (!fs.existsSync(absoluteScriptPath)) {
|
|
313
|
+
return {
|
|
314
|
+
diagnostics: [missingCompilerSceneScriptDiagnostic(scriptPath)],
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
let descriptor: ReturnType<typeof extractSceneScriptInterface>;
|
|
319
|
+
try {
|
|
320
|
+
descriptor = extractSceneScriptInterface(readTextFile(absoluteScriptPath), absoluteScriptPath, { scene: scenePath });
|
|
321
|
+
} catch (error) {
|
|
322
|
+
return {
|
|
323
|
+
diagnostics: [compilerSceneScriptContractDiagnostic(error)],
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
if (descriptor.className !== template.name) {
|
|
327
|
+
return {
|
|
328
|
+
diagnostics: [compilerSceneClassDiagnostic(template.name, descriptor.className)],
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
const missingParts = findMissingScenePartReferences(template, descriptor.parts);
|
|
332
|
+
if (missingParts.length > 0) {
|
|
333
|
+
return {
|
|
334
|
+
diagnostics: missingParts.map((part) => compilerScenePartDiagnostic(part.property, part.id)),
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
return {
|
|
339
|
+
diagnostics: [],
|
|
340
|
+
sceneInterface: descriptor.interface,
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function compilerSceneBasenameDiagnostic(actual: string, expectedName: string): SceneValidationDiagnostic {
|
|
345
|
+
return {
|
|
346
|
+
path: '__scene__',
|
|
347
|
+
prop: 'name',
|
|
348
|
+
expected: `file basename "${expectedName}"`,
|
|
349
|
+
actual,
|
|
350
|
+
hint: 'Rename the <Scene name> to match the .scene file basename, or rename the .scene/.ts pair.',
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function missingCompilerSceneScriptDiagnostic(scriptPath: string): SceneValidationDiagnostic {
|
|
355
|
+
return {
|
|
356
|
+
path: '__scene__',
|
|
357
|
+
prop: 'script',
|
|
358
|
+
expected: `paired script "${scriptPath}"`,
|
|
359
|
+
actual: 'missing script',
|
|
360
|
+
hint: 'Create a colocated TypeScript file with the same basename as the .scene file.',
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function compilerSceneClassDiagnostic(actual: string, expectedClass: string): SceneValidationDiagnostic {
|
|
365
|
+
return {
|
|
366
|
+
path: '__scene__',
|
|
367
|
+
prop: 'name',
|
|
368
|
+
expected: `paired @scene class name "${expectedClass}"`,
|
|
369
|
+
actual,
|
|
370
|
+
hint: 'Rename the <Scene name> to match the paired @scene class, or update the class name in the paired script.',
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function compilerSceneScriptContractDiagnostic(error: unknown): SceneValidationDiagnostic {
|
|
375
|
+
return {
|
|
376
|
+
path: '__scene__',
|
|
377
|
+
prop: 'script',
|
|
378
|
+
expected: 'paired script with one @scene class',
|
|
379
|
+
actual: error instanceof Error ? error.message : String(error),
|
|
380
|
+
hint: 'Add a @scene() class to the paired TypeScript file and keep its class name aligned with the .scene basename.',
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function compilerScenePartDiagnostic(property: string, id: string): SceneValidationDiagnostic {
|
|
385
|
+
return {
|
|
386
|
+
path: '__scene__',
|
|
387
|
+
prop: `@part ${property}`,
|
|
388
|
+
expected: `node id "${id}"`,
|
|
389
|
+
actual: 'missing node',
|
|
390
|
+
hint: 'Add a node with this id to the .scene file or update @part({ id }).',
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
function compilerSceneValidationFailure(scenePath: string, content: string, diagnostics: SceneValidationDiagnostic[]) {
|
|
395
|
+
return {
|
|
396
|
+
ok: false as const,
|
|
397
|
+
scene: scenePath,
|
|
398
|
+
revision: createSceneRevision(content),
|
|
399
|
+
error: 'Scene validation failed.',
|
|
400
|
+
diagnostics,
|
|
401
|
+
hint: 'Fix the listed diagnostics, then run scene validate again.',
|
|
402
|
+
};
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
function validateCompilerSceneFile(root: string, scenePath: string) {
|
|
406
|
+
const content = readTextFile(path.join(root, scenePath));
|
|
407
|
+
const sourceRootFailure = compilerSceneSourceRootFailure(scenePath);
|
|
408
|
+
if (sourceRootFailure) {
|
|
409
|
+
return sourceRootFailure;
|
|
410
|
+
}
|
|
411
|
+
const result = validateSceneContent({
|
|
412
|
+
scene: scenePath,
|
|
413
|
+
content,
|
|
414
|
+
existingAssets: collectProjectAssets(root),
|
|
415
|
+
sceneInterfaces: collectCompilerSceneInterfaces(root, scenePath),
|
|
416
|
+
normalizeSceneReference: normalizeCompilerSceneReference(scenePath),
|
|
417
|
+
});
|
|
418
|
+
if (!result.ok) {
|
|
419
|
+
return result;
|
|
420
|
+
}
|
|
421
|
+
const pair = readCompilerScenePairContract(root, scenePath, parseSceneTemplate(content));
|
|
422
|
+
if (pair.diagnostics.length > 0) {
|
|
423
|
+
return compilerSceneValidationFailure(scenePath, content, pair.diagnostics);
|
|
424
|
+
}
|
|
425
|
+
return {
|
|
426
|
+
...result,
|
|
427
|
+
scenePath,
|
|
428
|
+
};
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
function validateAllCompilerScenes(root: string) {
|
|
432
|
+
const results = collectCompilerScenePaths(root).map((scenePath) => validateCompilerSceneFile(root, scenePath));
|
|
433
|
+
const failures = results.filter((result): result is CompilerSceneValidationFailure => !result.ok);
|
|
434
|
+
const scenes = results
|
|
435
|
+
.filter((result): result is CompilerSceneValidationSuccess => result.ok)
|
|
436
|
+
.map(({ scenePath, revision, summary }) => ({ scenePath, revision, summary }));
|
|
437
|
+
if (failures.length > 0) {
|
|
438
|
+
return {
|
|
439
|
+
ok: false as const,
|
|
440
|
+
projectRoot: root,
|
|
441
|
+
sceneCount: results.length,
|
|
442
|
+
scenes,
|
|
443
|
+
failures,
|
|
444
|
+
hint: 'Fix the listed diagnostics, then run scene validate --all again.',
|
|
445
|
+
};
|
|
446
|
+
}
|
|
447
|
+
return {
|
|
448
|
+
ok: true as const,
|
|
449
|
+
projectRoot: root,
|
|
450
|
+
sceneCount: results.length,
|
|
451
|
+
scenes,
|
|
452
|
+
};
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
function nodeLocator(node: NodeSpec) {
|
|
456
|
+
return node.id ?? node.key ?? node.name ?? '';
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
function summarizeNode(node: NodeSpec, depth = 0): NodeSummary {
|
|
460
|
+
const children = node.kind === 'container' ? node.children ?? [] : [];
|
|
461
|
+
return {
|
|
462
|
+
id: node.id,
|
|
463
|
+
key: node.key,
|
|
464
|
+
role: node.role,
|
|
465
|
+
name: node.name,
|
|
466
|
+
kind: node.kind,
|
|
467
|
+
locator: nodeLocator(node),
|
|
468
|
+
depth,
|
|
469
|
+
transform: node.transform,
|
|
470
|
+
components: (node.components ?? []).map((component) => ({
|
|
471
|
+
id: component.id,
|
|
472
|
+
type: component.type,
|
|
473
|
+
propKeys: Object.keys(component.props ?? {}),
|
|
474
|
+
})),
|
|
475
|
+
childCount: children.length,
|
|
476
|
+
children: children.map((child) => summarizeNode(child, depth + 1)),
|
|
477
|
+
};
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
function collectNodes(node: NodeSpec, depth = 0, parent?: string): Array<{
|
|
481
|
+
id?: string;
|
|
482
|
+
key?: string;
|
|
483
|
+
role?: string;
|
|
484
|
+
name?: string;
|
|
485
|
+
kind: NodeSpec['kind'];
|
|
486
|
+
locator: string;
|
|
487
|
+
depth: number;
|
|
488
|
+
parent?: string;
|
|
489
|
+
componentCount: number;
|
|
490
|
+
childCount: number;
|
|
491
|
+
}> {
|
|
492
|
+
return [
|
|
493
|
+
{
|
|
494
|
+
id: node.id,
|
|
495
|
+
key: node.key,
|
|
496
|
+
role: node.role,
|
|
497
|
+
name: node.name,
|
|
498
|
+
kind: node.kind,
|
|
499
|
+
locator: nodeLocator(node),
|
|
500
|
+
depth,
|
|
501
|
+
parent,
|
|
502
|
+
componentCount: node.components?.length ?? 0,
|
|
503
|
+
childCount: node.kind === 'container' ? node.children?.length ?? 0 : 0,
|
|
504
|
+
},
|
|
505
|
+
...(node.kind === 'container' ? (node.children ?? []).flatMap((child) => collectNodes(child, depth + 1, nodeLocator(node))) : []),
|
|
506
|
+
];
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
function summarizeScene(scene: SceneSpec) {
|
|
510
|
+
const nodes = collectNodes(scene.root);
|
|
511
|
+
return {
|
|
512
|
+
name: scene.name,
|
|
513
|
+
version: scene.version,
|
|
514
|
+
nodeCount: nodes.length,
|
|
515
|
+
componentCount: nodes.reduce((sum, node) => sum + node.componentCount, 0),
|
|
516
|
+
root: summarizeNode(scene.root),
|
|
517
|
+
};
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
function walkFiles(root: string, directory: string, depth: number, maxDepth: number, results: ProjectFileSummary[]) {
|
|
521
|
+
if (depth > maxDepth) {
|
|
522
|
+
return;
|
|
523
|
+
}
|
|
524
|
+
const entries = fs.readdirSync(directory, { withFileTypes: true })
|
|
525
|
+
.filter((entry) => !entry.name.startsWith('.') && entry.name !== 'node_modules' && entry.name !== 'target')
|
|
526
|
+
.sort((left, right) => {
|
|
527
|
+
if (left.isDirectory() !== right.isDirectory()) {
|
|
528
|
+
return left.isDirectory() ? -1 : 1;
|
|
529
|
+
}
|
|
530
|
+
return left.name.localeCompare(right.name);
|
|
531
|
+
});
|
|
532
|
+
|
|
533
|
+
for (const entry of entries) {
|
|
534
|
+
const absolute = path.join(directory, entry.name);
|
|
535
|
+
const relative = path.relative(root, absolute);
|
|
536
|
+
if (entry.isDirectory()) {
|
|
537
|
+
results.push({
|
|
538
|
+
path: relative,
|
|
539
|
+
kind: 'folder',
|
|
540
|
+
});
|
|
541
|
+
walkFiles(root, absolute, depth + 1, maxDepth, results);
|
|
542
|
+
} else {
|
|
543
|
+
results.push({
|
|
544
|
+
path: relative,
|
|
545
|
+
kind: entry.name.endsWith('.scene') ? 'scene' : 'file',
|
|
546
|
+
});
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
export function createPixifactAutomation() {
|
|
552
|
+
return {
|
|
553
|
+
getProjectSummary(input: unknown) {
|
|
554
|
+
const args = assertRecord(input, 'input') as ToolInput;
|
|
555
|
+
const { root } = resolveProjectPath(args.projectRoot, '.');
|
|
556
|
+
const files: ProjectFileSummary[] = [];
|
|
557
|
+
walkFiles(root, root, 0, 4, files);
|
|
558
|
+
return {
|
|
559
|
+
projectRoot: root,
|
|
560
|
+
files,
|
|
561
|
+
scenes: files.filter((file) => file.kind === 'scene').map((file) => file.path),
|
|
562
|
+
project: readPixifactProjectSummary(root),
|
|
563
|
+
};
|
|
564
|
+
},
|
|
565
|
+
|
|
566
|
+
getScene(input: unknown) {
|
|
567
|
+
const args = assertRecord(input, 'input') as ToolInput;
|
|
568
|
+
const editable = loadEditableDocument(args.projectRoot, args.scenePath);
|
|
569
|
+
return {
|
|
570
|
+
scenePath: path.relative(editable.projectRoot, editable.target),
|
|
571
|
+
sourceType: editable.isProjectFile ? 'project' : 'scene',
|
|
572
|
+
scene: editable.document.scene,
|
|
573
|
+
summary: summarizeScene(editable.document.scene),
|
|
574
|
+
};
|
|
575
|
+
},
|
|
576
|
+
|
|
577
|
+
createScene(input: unknown) {
|
|
578
|
+
const args = assertRecord(input, 'input') as ToolInput;
|
|
579
|
+
const { root, target } = resolveProjectPath(args.projectRoot, args.scenePath);
|
|
580
|
+
const sceneName = assertString(args.name, 'name');
|
|
581
|
+
const nextScene = scene(sceneName, container('Root', {
|
|
582
|
+
id: 'root',
|
|
583
|
+
key: 'root',
|
|
584
|
+
width: 320,
|
|
585
|
+
height: 180,
|
|
586
|
+
children: [],
|
|
587
|
+
}));
|
|
588
|
+
|
|
589
|
+
writeNewJsonFile(target, nextScene);
|
|
590
|
+
return {
|
|
591
|
+
ok: true,
|
|
592
|
+
scenePath: path.relative(root, target),
|
|
593
|
+
scene: nextScene,
|
|
594
|
+
summary: summarizeScene(nextScene),
|
|
595
|
+
};
|
|
596
|
+
},
|
|
597
|
+
|
|
598
|
+
inspectCompilerScene(input: unknown) {
|
|
599
|
+
const args = assertRecord(input, 'input') as ToolInput;
|
|
600
|
+
const loaded = loadCompilerScene(args.projectRoot, args.scenePath);
|
|
601
|
+
const template = parseSceneTemplate(loaded.content);
|
|
602
|
+
return {
|
|
603
|
+
ok: true,
|
|
604
|
+
scenePath: loaded.scenePath,
|
|
605
|
+
revision: createSceneRevision(loaded.content),
|
|
606
|
+
summary: inspectSceneTemplate(template),
|
|
607
|
+
};
|
|
608
|
+
},
|
|
609
|
+
|
|
610
|
+
validateCompilerScene(input: unknown) {
|
|
611
|
+
const args = assertRecord(input, 'input') as ToolInput;
|
|
612
|
+
const loaded = loadCompilerScene(args.projectRoot, args.scenePath);
|
|
613
|
+
return validateCompilerSceneFile(loaded.root, loaded.scenePath);
|
|
614
|
+
},
|
|
615
|
+
|
|
616
|
+
validateAllCompilerScenes(input: unknown) {
|
|
617
|
+
const args = assertRecord(input, 'input') as ToolInput;
|
|
618
|
+
const { root } = resolveProjectPath(args.projectRoot, '.');
|
|
619
|
+
return validateAllCompilerScenes(root);
|
|
620
|
+
},
|
|
621
|
+
|
|
622
|
+
inspectNode(input: unknown) {
|
|
623
|
+
const args = assertRecord(input, 'input') as ToolInput;
|
|
624
|
+
const editable = loadEditableDocument(args.projectRoot, args.scenePath);
|
|
625
|
+
const nodeId = assertString(args.node, 'node');
|
|
626
|
+
const node = collectDetailedNodes(editable.document.scene.root).find((item) => item.locator === nodeId);
|
|
627
|
+
if (!node) {
|
|
628
|
+
throw new Error(`Node "${nodeId}" was not found.`);
|
|
629
|
+
}
|
|
630
|
+
return node;
|
|
631
|
+
},
|
|
632
|
+
};
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
function collectDetailedNodes(node: NodeSpec, depth = 0, parent?: string): DetailedNode[] {
|
|
636
|
+
return [
|
|
637
|
+
{
|
|
638
|
+
...structuredClone(node),
|
|
639
|
+
locator: nodeLocator(node),
|
|
640
|
+
depth,
|
|
641
|
+
parent,
|
|
642
|
+
childCount: node.kind === 'container' ? node.children?.length ?? 0 : 0,
|
|
643
|
+
},
|
|
644
|
+
...(node.kind === 'container' ? (node.children ?? []).flatMap((child) => collectDetailedNodes(child, depth + 1, nodeLocator(node))) : []),
|
|
645
|
+
];
|
|
646
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export const pixifactAgentBridgePort = 8791;
|
|
2
|
+
export const pixifactAgentBridgePath = '/pixifact-agent';
|
|
3
|
+
export const pixifactAgentBridgeUrl = `ws://127.0.0.1:${pixifactAgentBridgePort}${pixifactAgentBridgePath}`;
|
|
4
|
+
|
|
5
|
+
export type LiveBridgeRole = 'editor';
|
|
6
|
+
|
|
7
|
+
export interface LiveBridgeHelloMessage {
|
|
8
|
+
type: 'hello';
|
|
9
|
+
role: LiveBridgeRole;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface LiveBridgeRequestMessage {
|
|
13
|
+
type: 'request';
|
|
14
|
+
id: string;
|
|
15
|
+
action: string;
|
|
16
|
+
arguments: unknown;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface LiveBridgeResponseMessage {
|
|
20
|
+
type: 'response';
|
|
21
|
+
id: string;
|
|
22
|
+
ok: boolean;
|
|
23
|
+
result?: unknown;
|
|
24
|
+
error?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export type LiveBridgeClientMessage = LiveBridgeHelloMessage | LiveBridgeResponseMessage;
|
|
28
|
+
export type LiveBridgeServerMessage = LiveBridgeRequestMessage;
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import {
|
|
2
|
+
pixifactAgentBridgePath,
|
|
3
|
+
pixifactAgentBridgePort,
|
|
4
|
+
type LiveBridgeClientMessage,
|
|
5
|
+
type LiveBridgeRequestMessage,
|
|
6
|
+
type LiveBridgeResponseMessage,
|
|
7
|
+
} from './liveBridge';
|
|
8
|
+
|
|
9
|
+
interface BunServerSocket {
|
|
10
|
+
send(data: string): void;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface BunServer {
|
|
14
|
+
upgrade(request: Request): boolean;
|
|
15
|
+
stop(force?: boolean): void;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
interface BunServeOptions {
|
|
19
|
+
hostname: string;
|
|
20
|
+
port: number;
|
|
21
|
+
fetch(request: Request, server: BunServer): Response | undefined;
|
|
22
|
+
websocket: {
|
|
23
|
+
message(socket: BunServerSocket, data: string | Uint8Array): void;
|
|
24
|
+
close(socket: BunServerSocket): void;
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
declare const Bun: {
|
|
29
|
+
serve(options: BunServeOptions): BunServer;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
interface PendingRequest {
|
|
33
|
+
resolve(result: unknown): void;
|
|
34
|
+
reject(error: Error): void;
|
|
35
|
+
timer: ReturnType<typeof setTimeout>;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface LiveBridgeServerOptions {
|
|
39
|
+
port?: number;
|
|
40
|
+
requestTimeoutMs?: number;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function responseError(message: LiveBridgeResponseMessage) {
|
|
44
|
+
return new Error(message.error ?? 'Live editor request failed.');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function createLiveBridgeServer(options: LiveBridgeServerOptions = {}) {
|
|
48
|
+
const port = options.port ?? pixifactAgentBridgePort;
|
|
49
|
+
const requestTimeoutMs = options.requestTimeoutMs ?? 30000;
|
|
50
|
+
let editor: BunServerSocket | undefined;
|
|
51
|
+
const pending = new Map<string, PendingRequest>();
|
|
52
|
+
const connectionWaiters = new Set<() => void>();
|
|
53
|
+
let nextRequestId = 0;
|
|
54
|
+
|
|
55
|
+
function rejectPending(error: Error) {
|
|
56
|
+
for (const pendingRequest of pending.values()) {
|
|
57
|
+
clearTimeout(pendingRequest.timer);
|
|
58
|
+
pendingRequest.reject(error);
|
|
59
|
+
}
|
|
60
|
+
pending.clear();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const server = Bun.serve({
|
|
64
|
+
hostname: '127.0.0.1',
|
|
65
|
+
port,
|
|
66
|
+
fetch(request, server) {
|
|
67
|
+
const url = new URL(request.url);
|
|
68
|
+
if (url.pathname !== pixifactAgentBridgePath) {
|
|
69
|
+
return new Response('Not found.', { status: 404 });
|
|
70
|
+
}
|
|
71
|
+
if (server.upgrade(request)) {
|
|
72
|
+
return undefined;
|
|
73
|
+
}
|
|
74
|
+
return new Response('WebSocket upgrade failed.', { status: 400 });
|
|
75
|
+
},
|
|
76
|
+
websocket: {
|
|
77
|
+
message(socket, data) {
|
|
78
|
+
const message = JSON.parse(String(data)) as LiveBridgeClientMessage;
|
|
79
|
+
if (message.type === 'hello' && message.role === 'editor') {
|
|
80
|
+
editor = socket;
|
|
81
|
+
for (const resolve of connectionWaiters) {
|
|
82
|
+
resolve();
|
|
83
|
+
}
|
|
84
|
+
connectionWaiters.clear();
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
if (message.type !== 'response') {
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
const pendingRequest = pending.get(message.id);
|
|
91
|
+
if (!pendingRequest) {
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
pending.delete(message.id);
|
|
95
|
+
clearTimeout(pendingRequest.timer);
|
|
96
|
+
if (message.ok) {
|
|
97
|
+
pendingRequest.resolve(message.result);
|
|
98
|
+
} else {
|
|
99
|
+
pendingRequest.reject(responseError(message));
|
|
100
|
+
}
|
|
101
|
+
},
|
|
102
|
+
close(socket) {
|
|
103
|
+
if (socket === editor) {
|
|
104
|
+
editor = undefined;
|
|
105
|
+
rejectPending(new Error('Live editor disconnected.'));
|
|
106
|
+
}
|
|
107
|
+
},
|
|
108
|
+
},
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
return {
|
|
112
|
+
get connected() {
|
|
113
|
+
return editor !== undefined;
|
|
114
|
+
},
|
|
115
|
+
|
|
116
|
+
async waitForConnection(timeoutMs = 3000) {
|
|
117
|
+
if (editor) {
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
return new Promise<void>((resolve, reject) => {
|
|
121
|
+
const timer = setTimeout(() => {
|
|
122
|
+
connectionWaiters.delete(done);
|
|
123
|
+
reject(new Error('No live Pixifact editor is connected.'));
|
|
124
|
+
}, timeoutMs);
|
|
125
|
+
function done() {
|
|
126
|
+
clearTimeout(timer);
|
|
127
|
+
resolve();
|
|
128
|
+
}
|
|
129
|
+
connectionWaiters.add(done);
|
|
130
|
+
});
|
|
131
|
+
},
|
|
132
|
+
|
|
133
|
+
async callAction(action: string, args: unknown) {
|
|
134
|
+
if (!editor) {
|
|
135
|
+
throw new Error('No live Pixifact editor is connected.');
|
|
136
|
+
}
|
|
137
|
+
const id = `live-${Date.now()}-${nextRequestId++}`;
|
|
138
|
+
const message: LiveBridgeRequestMessage = {
|
|
139
|
+
type: 'request',
|
|
140
|
+
id,
|
|
141
|
+
action,
|
|
142
|
+
arguments: args,
|
|
143
|
+
};
|
|
144
|
+
return new Promise((resolve, reject) => {
|
|
145
|
+
const timer = setTimeout(() => {
|
|
146
|
+
pending.delete(id);
|
|
147
|
+
reject(new Error(`Live editor action "${action}" timed out.`));
|
|
148
|
+
}, requestTimeoutMs);
|
|
149
|
+
pending.set(id, {
|
|
150
|
+
resolve,
|
|
151
|
+
reject,
|
|
152
|
+
timer,
|
|
153
|
+
});
|
|
154
|
+
editor!.send(JSON.stringify(message));
|
|
155
|
+
});
|
|
156
|
+
},
|
|
157
|
+
|
|
158
|
+
stop() {
|
|
159
|
+
rejectPending(new Error('Live bridge stopped.'));
|
|
160
|
+
server.stop(true);
|
|
161
|
+
},
|
|
162
|
+
};
|
|
163
|
+
}
|
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import { createPixifactAutomation } from './automation';
|
|
3
|
+
import { hintForCommandError } from 'pixifact';
|
|
4
|
+
import { CompileSceneError, compileScenes } from 'pixifact/compiler-node';
|
|
5
|
+
import type { SceneValidationDiagnostic } from 'pixifact/compiler';
|
|
6
|
+
import { createLiveBridgeServer } from './liveBridgeServer';
|
|
7
|
+
|
|
8
|
+
type Automation = ReturnType<typeof createPixifactAutomation>;
|
|
9
|
+
type LiveBridge = Pick<ReturnType<typeof createLiveBridgeServer>, 'connected' | 'callAction' | 'stop'> & {
|
|
10
|
+
waitForConnection?: (timeoutMs?: number) => Promise<void>;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
interface CliOptions {
|
|
14
|
+
automation?: Automation;
|
|
15
|
+
liveBridge?: LiveBridge;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
interface CliResult {
|
|
19
|
+
exitCode: number;
|
|
20
|
+
stdout: string;
|
|
21
|
+
stderr: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface CliJsonResult {
|
|
25
|
+
ok?: boolean;
|
|
26
|
+
[key: string]: unknown;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
interface ParsedArgs {
|
|
30
|
+
positionals: string[];
|
|
31
|
+
flags: Record<string, string | true>;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function parseArgs(argv: string[]): ParsedArgs {
|
|
35
|
+
const positionals: string[] = [];
|
|
36
|
+
const flags: Record<string, string | true> = {};
|
|
37
|
+
|
|
38
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
39
|
+
const arg = argv[index];
|
|
40
|
+
if (!arg.startsWith('--')) {
|
|
41
|
+
positionals.push(arg);
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const name = arg.slice(2);
|
|
46
|
+
if (name === 'help' || name === 'all') {
|
|
47
|
+
flags[name] = true;
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const value = argv[index + 1];
|
|
52
|
+
if (value === undefined || value.startsWith('--')) {
|
|
53
|
+
throw new Error(`--${name} must include a value.`);
|
|
54
|
+
}
|
|
55
|
+
flags[name] = value;
|
|
56
|
+
index += 1;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return { positionals, flags };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function requireFlag(flags: Record<string, string | true>, name: string) {
|
|
63
|
+
const value = flags[name];
|
|
64
|
+
if (typeof value !== 'string' || value.trim() === '') {
|
|
65
|
+
throw new Error(`--${name} must include a value.`);
|
|
66
|
+
}
|
|
67
|
+
return value;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function jsonLine(value: unknown) {
|
|
71
|
+
return `${JSON.stringify(value, null, 2)}\n`;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function isFailedResult(value: unknown): value is CliJsonResult {
|
|
75
|
+
return typeof value === 'object'
|
|
76
|
+
&& value !== null
|
|
77
|
+
&& (value as CliJsonResult).ok === false;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function compileScenesFailure(error: unknown): CliJsonResult {
|
|
81
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
82
|
+
if (error instanceof CompileSceneError) {
|
|
83
|
+
const sourceDiagnostic = sourceDiagnosticFromMessage(error.source ?? '', message);
|
|
84
|
+
if (sourceDiagnostic) {
|
|
85
|
+
return {
|
|
86
|
+
ok: false,
|
|
87
|
+
scene: error.scene,
|
|
88
|
+
error: 'Scene compile failed.',
|
|
89
|
+
diagnostics: [sourceDiagnostic],
|
|
90
|
+
hint: 'Fix the listed diagnostics, then run compile-scenes again.',
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
const basenameMismatch = message.match(/^Scene "([^"]+)" name "([^"]+)" must match file basename "([^"]+)"\.$/);
|
|
95
|
+
if (basenameMismatch) {
|
|
96
|
+
const [, scene, actual, expectedName] = basenameMismatch;
|
|
97
|
+
return {
|
|
98
|
+
ok: false,
|
|
99
|
+
scene,
|
|
100
|
+
error: 'Scene compile failed.',
|
|
101
|
+
diagnostics: [{
|
|
102
|
+
path: '__scene__',
|
|
103
|
+
prop: 'name',
|
|
104
|
+
expected: `file basename "${expectedName}"`,
|
|
105
|
+
actual,
|
|
106
|
+
hint: 'Rename the <Scene name> to match the .scene file basename, or rename the .scene/.ts pair.',
|
|
107
|
+
} satisfies SceneValidationDiagnostic],
|
|
108
|
+
hint: 'Fix the listed diagnostics, then run compile-scenes again.',
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
const missingPair = message.match(/^Scene "([^"]+)" requires paired script "([^"]+)"\.$/);
|
|
112
|
+
if (missingPair) {
|
|
113
|
+
const [, scene, scriptPath] = missingPair;
|
|
114
|
+
return {
|
|
115
|
+
ok: false,
|
|
116
|
+
scene,
|
|
117
|
+
error: 'Scene compile failed.',
|
|
118
|
+
diagnostics: [{
|
|
119
|
+
path: '__scene__',
|
|
120
|
+
prop: 'script',
|
|
121
|
+
expected: `paired script "${scriptPath}"`,
|
|
122
|
+
actual: 'missing script',
|
|
123
|
+
hint: 'Create a colocated TypeScript file with the same basename as the .scene file.',
|
|
124
|
+
} satisfies SceneValidationDiagnostic],
|
|
125
|
+
hint: 'Fix the listed diagnostics, then run compile-scenes again.',
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
const classMismatch = message.match(/^Scene "([^"]+)" name "([^"]+)" must match @scene class "([^"]+)"\.$/);
|
|
129
|
+
if (classMismatch) {
|
|
130
|
+
const [, scene, actual, expectedClass] = classMismatch;
|
|
131
|
+
return {
|
|
132
|
+
ok: false,
|
|
133
|
+
scene,
|
|
134
|
+
error: 'Scene compile failed.',
|
|
135
|
+
diagnostics: [{
|
|
136
|
+
path: '__scene__',
|
|
137
|
+
prop: 'name',
|
|
138
|
+
expected: `paired @scene class name "${expectedClass}"`,
|
|
139
|
+
actual,
|
|
140
|
+
hint: 'Rename the <Scene name> to match the paired @scene class, or update the class name in the paired script.',
|
|
141
|
+
} satisfies SceneValidationDiagnostic],
|
|
142
|
+
hint: 'Fix the listed diagnostics, then run compile-scenes again.',
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
const missingPart = message.match(/^Scene "([^"]+)" @part "([^"]+)" references missing node id "([^"]+)"\.$/);
|
|
146
|
+
if (missingPart) {
|
|
147
|
+
const [, scene, property, id] = missingPart;
|
|
148
|
+
return {
|
|
149
|
+
ok: false,
|
|
150
|
+
scene,
|
|
151
|
+
error: 'Scene compile failed.',
|
|
152
|
+
diagnostics: [{
|
|
153
|
+
path: '__scene__',
|
|
154
|
+
prop: `@part ${property}`,
|
|
155
|
+
expected: `node id "${id}"`,
|
|
156
|
+
actual: 'missing node',
|
|
157
|
+
hint: 'Add a node with this id to the .scene file or update @part({ id }).',
|
|
158
|
+
} satisfies SceneValidationDiagnostic],
|
|
159
|
+
hint: 'Fix the listed diagnostics, then run compile-scenes again.',
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
return {
|
|
163
|
+
ok: false,
|
|
164
|
+
error: message,
|
|
165
|
+
hint: hintForCommandError(message),
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function sourceDiagnosticFromMessage(source: string, message: string): SceneValidationDiagnostic | undefined {
|
|
170
|
+
if (!message.includes('offset')) {
|
|
171
|
+
return undefined;
|
|
172
|
+
}
|
|
173
|
+
return {
|
|
174
|
+
path: '__scene__',
|
|
175
|
+
prop: 'source',
|
|
176
|
+
expected: 'valid Pixifact .scene source',
|
|
177
|
+
actual: message,
|
|
178
|
+
hint: 'Fix the .scene source syntax near the reported location.',
|
|
179
|
+
...sourcePositionFromMessage(source, message),
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function sourcePositionFromMessage(source: string, message: string) {
|
|
184
|
+
const match = message.match(/\boffset (\d+)\b/);
|
|
185
|
+
if (!match) {
|
|
186
|
+
return {};
|
|
187
|
+
}
|
|
188
|
+
const offset = Number(match[1]);
|
|
189
|
+
if (!Number.isInteger(offset) || offset < 0) {
|
|
190
|
+
return {};
|
|
191
|
+
}
|
|
192
|
+
const before = source.slice(0, offset);
|
|
193
|
+
const lines = before.split('\n');
|
|
194
|
+
return {
|
|
195
|
+
line: lines.length,
|
|
196
|
+
column: lines.at(-1)!.length + 1,
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
async function executeFileCommand(positionals: string[], flags: Record<string, string | true>, automation: Automation) {
|
|
201
|
+
const [area, action] = positionals;
|
|
202
|
+
|
|
203
|
+
if (area === 'compile-scenes' && action === undefined) {
|
|
204
|
+
const projectRoot = typeof flags['project-root'] === 'string' ? flags['project-root'] : process.cwd();
|
|
205
|
+
try {
|
|
206
|
+
await compileScenes({ projectRoot });
|
|
207
|
+
} catch (error) {
|
|
208
|
+
return compileScenesFailure(error);
|
|
209
|
+
}
|
|
210
|
+
return {
|
|
211
|
+
ok: true,
|
|
212
|
+
projectRoot,
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
if (area === 'summary' && action === undefined) {
|
|
217
|
+
return automation.getProjectSummary({
|
|
218
|
+
projectRoot: requireFlag(flags, 'project-root'),
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
if (area === 'scene' && action === 'get') {
|
|
223
|
+
return automation.getScene({
|
|
224
|
+
projectRoot: requireFlag(flags, 'project-root'),
|
|
225
|
+
scenePath: requireFlag(flags, 'scene'),
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
if (area === 'scene' && action === 'create') {
|
|
230
|
+
return automation.createScene({
|
|
231
|
+
projectRoot: requireFlag(flags, 'project-root'),
|
|
232
|
+
scenePath: requireFlag(flags, 'scene'),
|
|
233
|
+
name: requireFlag(flags, 'name'),
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
if (area === 'scene' && action === 'inspect') {
|
|
238
|
+
return automation.inspectCompilerScene({
|
|
239
|
+
projectRoot: requireFlag(flags, 'project-root'),
|
|
240
|
+
scenePath: requireFlag(flags, 'scene'),
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
if (area === 'scene' && action === 'validate') {
|
|
245
|
+
if (flags.all === true) {
|
|
246
|
+
if (typeof flags.scene === 'string') {
|
|
247
|
+
throw new Error('Use either --all or --scene, not both.');
|
|
248
|
+
}
|
|
249
|
+
return automation.validateAllCompilerScenes({
|
|
250
|
+
projectRoot: requireFlag(flags, 'project-root'),
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
return automation.validateCompilerScene({
|
|
254
|
+
projectRoot: requireFlag(flags, 'project-root'),
|
|
255
|
+
scenePath: requireFlag(flags, 'scene'),
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
if (area === 'node' && action === 'inspect') {
|
|
260
|
+
return automation.inspectNode({
|
|
261
|
+
projectRoot: requireFlag(flags, 'project-root'),
|
|
262
|
+
scenePath: requireFlag(flags, 'scene'),
|
|
263
|
+
node: requireFlag(flags, 'node'),
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
throw new Error(`Unknown Pixifact CLI command "${positionals.join(' ')}".`);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
async function executeLiveCommand(positionals: string[], flags: Record<string, string | true>, bridge: LiveBridge) {
|
|
271
|
+
if (!bridge.connected && bridge.waitForConnection) {
|
|
272
|
+
await bridge.waitForConnection();
|
|
273
|
+
}
|
|
274
|
+
if (!bridge.connected) {
|
|
275
|
+
throw new Error('No live Pixifact editor is connected.');
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const [area, action] = positionals;
|
|
279
|
+
if (area === 'summary' && action === undefined) {
|
|
280
|
+
return bridge.callAction('summary', {});
|
|
281
|
+
}
|
|
282
|
+
if (area === 'scene' && action === 'get') {
|
|
283
|
+
return bridge.callAction('scene.get', {});
|
|
284
|
+
}
|
|
285
|
+
if (area === 'node' && action === 'inspect') {
|
|
286
|
+
return bridge.callAction('node.inspect', {
|
|
287
|
+
node: requireFlag(flags, 'node'),
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
throw new Error(`Unknown Pixifact live command "${positionals.join(' ')}".`);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
export async function executePixifactCli(argv: string[], options: CliOptions = {}): Promise<CliResult> {
|
|
295
|
+
let ownedBridge: ReturnType<typeof createLiveBridgeServer> | undefined;
|
|
296
|
+
try {
|
|
297
|
+
const parsed = parseArgs(argv);
|
|
298
|
+
if (parsed.flags.help === true) {
|
|
299
|
+
return {
|
|
300
|
+
exitCode: 0,
|
|
301
|
+
stdout: jsonLine({
|
|
302
|
+
commands: [
|
|
303
|
+
'compile-scenes',
|
|
304
|
+
'summary',
|
|
305
|
+
'scene create',
|
|
306
|
+
'scene get',
|
|
307
|
+
'scene inspect',
|
|
308
|
+
'scene validate',
|
|
309
|
+
'scene validate --all',
|
|
310
|
+
'node inspect',
|
|
311
|
+
'live summary',
|
|
312
|
+
'live scene get',
|
|
313
|
+
'live node inspect',
|
|
314
|
+
],
|
|
315
|
+
}),
|
|
316
|
+
stderr: '',
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
const automation = options.automation ?? createPixifactAutomation();
|
|
321
|
+
const isLive = parsed.positionals[0] === 'live';
|
|
322
|
+
const result = isLive
|
|
323
|
+
? await executeLiveCommand(
|
|
324
|
+
parsed.positionals.slice(1),
|
|
325
|
+
parsed.flags,
|
|
326
|
+
options.liveBridge ?? (ownedBridge = createLiveBridgeServer()),
|
|
327
|
+
)
|
|
328
|
+
: await executeFileCommand(parsed.positionals, parsed.flags, automation);
|
|
329
|
+
if (isFailedResult(result)) {
|
|
330
|
+
return {
|
|
331
|
+
exitCode: 1,
|
|
332
|
+
stdout: '',
|
|
333
|
+
stderr: jsonLine(result),
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
return {
|
|
337
|
+
exitCode: 0,
|
|
338
|
+
stdout: jsonLine(result),
|
|
339
|
+
stderr: '',
|
|
340
|
+
};
|
|
341
|
+
} catch (error) {
|
|
342
|
+
return {
|
|
343
|
+
exitCode: 1,
|
|
344
|
+
stdout: '',
|
|
345
|
+
stderr: jsonLine({
|
|
346
|
+
ok: false,
|
|
347
|
+
error: error instanceof Error ? error.message : String(error),
|
|
348
|
+
hint: hintForCommandError(error instanceof Error ? error.message : String(error)),
|
|
349
|
+
}),
|
|
350
|
+
};
|
|
351
|
+
} finally {
|
|
352
|
+
ownedBridge?.stop();
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
357
|
+
const result = await executePixifactCli(process.argv.slice(2));
|
|
358
|
+
process.stdout.write(result.stdout);
|
|
359
|
+
process.stderr.write(result.stderr);
|
|
360
|
+
process.exitCode = result.exitCode;
|
|
361
|
+
}
|