@hraness/slopcamera 3.2.7 → 3.3.1
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/NOTICE.md +1 -7
- package/PRIVACY.md +16 -7
- package/README.md +65 -20
- package/apps/desktop/README.md +14 -130
- package/apps/desktop/application/context.ts +6 -9
- package/apps/desktop/application/default-registry.ts +26 -10
- package/apps/desktop/application/operation.ts +14 -5
- package/apps/desktop/application/operations/index.ts +4 -1
- package/apps/desktop/application/operations/spatial-behavior.ts +151 -0
- package/apps/desktop/application/operations/spatial-direction.ts +222 -0
- package/apps/desktop/application/operations/spatial-rendered-audit.ts +174 -0
- package/apps/desktop/application/operations/spatial-review.ts +194 -0
- package/apps/desktop/application/operations/spatial-scene.ts +25 -1
- package/apps/desktop/application/registry.ts +2 -2
- package/apps/desktop/application/spatial-asset-admission.ts +201 -0
- package/apps/desktop/application/spatial-assets.ts +160 -31
- package/apps/desktop/application/spatial-render.ts +217 -20
- package/apps/desktop/application/spatial-rendered-audit.ts +273 -0
- package/apps/desktop/application/spatial-review.ts +361 -0
- package/apps/desktop/application/spatial-spz.ts +48 -3
- package/apps/desktop/application/spatial-world-import.ts +26 -5
- package/apps/desktop/application/spatial-world-metadata.ts +105 -0
- package/apps/desktop/cli/args.ts +528 -97
- package/apps/desktop/cli/capability-manifest.ts +374 -0
- package/apps/desktop/cli/cinema-renderer.ts +608 -0
- package/apps/desktop/cli/command-host-resources.ts +31 -2
- package/apps/desktop/cli/commands.ts +1215 -70
- package/apps/desktop/cli/gateway-review-provider.ts +334 -0
- package/apps/desktop/cli/help.ts +183 -23
- package/apps/desktop/cli/html-scene.ts +139 -28
- package/apps/desktop/cli/io.ts +2 -1
- package/apps/desktop/cli/main.ts +26 -50
- package/apps/desktop/cli/menubar.ts +244 -0
- package/apps/desktop/cli/music-analysis-service.ts +61 -0
- package/apps/desktop/cli/portable-surface.ts +18 -5
- package/apps/desktop/cli/spatial-asset-service.ts +124 -0
- package/apps/desktop/cli/spatial-design-service.ts +237 -0
- package/apps/desktop/cli/spatial-generate-service.ts +370 -0
- package/apps/desktop/cli/spatial-scene-service.ts +268 -4
- package/apps/desktop/cli/workflow-code.ts +1 -1
- package/apps/desktop/code/application-node-planner.ts +0 -47
- package/apps/desktop/code/host-source-layout.ts +46 -0
- package/apps/desktop/code/public.ts +0 -1
- package/apps/desktop/code/runtime-identity.ts +50 -8
- package/apps/desktop/code/semantic-builder.ts +24 -43
- package/apps/desktop/code/source-bundle.ts +23 -6
- package/apps/desktop/code/source-typecheck.ts +131 -43
- package/apps/desktop/code/worker-client.ts +14 -2
- package/apps/desktop/contracts/cinema.ts +736 -0
- package/apps/desktop/contracts/index.ts +1 -0
- package/apps/desktop/contracts/spatial-asset.ts +16 -0
- package/apps/desktop/contracts/spatial-world.ts +26 -3
- package/apps/desktop/core/cinema-plan.ts +1320 -0
- package/apps/desktop/core/index.ts +1 -0
- package/apps/desktop/core/music-analysis.ts +124 -1
- package/apps/desktop/core/storage.ts +75 -0
- package/apps/desktop/dist/cli/main.js +737 -291
- package/apps/desktop/html-overlay/audio-reactivity.ts +127 -0
- package/apps/desktop/html-overlay/index.ts +9 -0
- package/apps/desktop/html-overlay/runtime.ts +2 -0
- package/apps/desktop/html-overlay/scene.ts +29 -0
- package/apps/desktop/html-overlay/spatial.ts +619 -57
- package/apps/desktop/workflows/cinematic-world.ts +127 -0
- package/apps/desktop/workflows/index.ts +6 -0
- package/dist/cli.js +32 -434
- package/dist/code/advanced.js +1 -269
- package/dist/code/index.js +16 -2239
- package/dist/generate.js +1 -23
- package/dist/host-resources.js +1 -25
- package/dist/index-0bbesn4m.js +35 -0
- package/dist/index-296qhpf2.js +3 -0
- package/dist/index-3cxf2c58.js +3 -0
- package/dist/index-77fjfg9f.js +3 -0
- package/dist/index-80a36bc0.js +3 -0
- package/dist/index-a3vvs3rf.js +3 -0
- package/dist/index-b3geqczn.js +5 -0
- package/dist/index-cgpkyjqa.js +3 -0
- package/dist/index-f0ptv8dm.js +7 -0
- package/dist/index-grk47tgs.js +3 -0
- package/dist/index-qry58nj2.js +10 -0
- package/dist/index.js +1 -240
- package/dist/operations.js +1 -28
- package/dist/skill-install-gx7afxsd.js +2 -0
- package/dist/vectorize/worker.js +1 -141
- package/dist/workflow.js +1 -15
- package/docs/README.md +5 -1
- package/docs/studio.md +1 -1
- package/package.json +11 -24
- package/skills/slopcamera/SKILL.md +14 -1
- package/skills/slopcamera/references/diagrams.md +5 -14
- package/skills/slopcamera/references/directed-scenes.md +22 -2
- package/skills/slopcamera/references/gateway-media.md +15 -1
- package/skills/slopcamera/references/image-galleries.md +166 -0
- package/skills/slopcamera/references/install.md +2 -2
- package/skills/slopcamera/references/music-video.md +23 -3
- package/skills/slopcamera/references/parametric-design.md +14 -0
- package/skills/slopcamera/references/reference-led-3d.md +1 -1
- package/skills/slopcamera/references/scene-building.md +32 -0
- package/skills/slopcamera/references/social-collage-banners.md +204 -0
- package/skills/slopcamera/references/support.md +25 -0
- package/skills/slopcamera/references/web-media-excerpts.md +120 -0
- package/skills/slopcamera/references/workflows-sdk.md +3 -3
- package/skills/slopcamera/scripts/compose-social-collage-banner.ts +728 -0
- package/src/capability-manifest.ts +361 -0
- package/src/cli.ts +250 -102
- package/src/code/canonical-json.ts +9 -0
- package/src/generate.ts +2 -2
- package/src/icon.ts +1111 -0
- package/src/image-gallery.ts +1049 -0
- package/src/index.ts +11 -23
- package/src/mcp/boundary.ts +11 -11
- package/src/mcp/index.ts +3 -0
- package/src/mcp/server.ts +2 -1
- package/src/mcp/tools.ts +1642 -130
- package/src/mcp/types.ts +19 -0
- package/src/operations.ts +412 -0
- package/src/portable-capability-manifest.ts +67 -0
- package/src/scene-gallery.ts +184 -0
- package/src/spatial-scene/asset-admission.ts +183 -0
- package/src/spatial-scene/audit-rendered.ts +692 -0
- package/src/spatial-scene/audit.ts +617 -0
- package/src/spatial-scene/behavior-audit.ts +252 -0
- package/src/spatial-scene/behavior-bake.ts +233 -0
- package/src/spatial-scene/behavior-fns.ts +585 -0
- package/src/spatial-scene/behavior-gallery.ts +109 -0
- package/src/spatial-scene/behavior-stdlib.ts +323 -0
- package/src/spatial-scene/behavior-trace.ts +325 -0
- package/src/spatial-scene/behavior.ts +618 -0
- package/src/spatial-scene/build.ts +632 -0
- package/src/spatial-scene/camera-rig.ts +125 -0
- package/src/spatial-scene/camera-track.ts +4 -2
- package/src/spatial-scene/character.ts +124 -0
- package/src/spatial-scene/contracts.ts +75 -7
- package/src/spatial-scene/design-templates.ts +351 -0
- package/src/spatial-scene/design.ts +327 -0
- package/src/spatial-scene/direction-compile.ts +584 -0
- package/src/spatial-scene/direction.ts +122 -0
- package/src/spatial-scene/effects.ts +149 -0
- package/src/spatial-scene/evaluate.ts +109 -29
- package/src/spatial-scene/gallery.ts +277 -0
- package/src/spatial-scene/generate.ts +239 -0
- package/src/spatial-scene/geometry-native.ts +165 -0
- package/src/spatial-scene/geometry.ts +1216 -0
- package/src/spatial-scene/gltf.ts +600 -77
- package/src/spatial-scene/identity.ts +55 -2
- package/src/spatial-scene/index.ts +36 -0
- package/src/spatial-scene/inspect.ts +35 -6
- package/src/spatial-scene/material-lighting.ts +609 -0
- package/src/spatial-scene/math.ts +21 -4
- package/src/spatial-scene/motion-evidence.ts +88 -0
- package/src/spatial-scene/parametric.ts +765 -0
- package/src/spatial-scene/particle-preparation.ts +183 -0
- package/src/spatial-scene/particle.ts +195 -0
- package/src/spatial-scene/patch.ts +109 -15
- package/src/spatial-scene/performance.ts +1613 -0
- package/src/spatial-scene/probe.ts +137 -0
- package/src/spatial-scene/recipe-pack.ts +86 -0
- package/src/spatial-scene/render-effects.ts +240 -0
- package/src/spatial-scene/review.ts +639 -0
- package/src/spatial-scene/simulation-bake.ts +206 -0
- package/src/spatial-scene/simulation.ts +187 -0
- package/src/spatial-scene/solve.ts +366 -0
- package/src/spatial-scene/temporal-audit.ts +354 -0
- package/src/support-completion.ts +16 -0
- package/src/support.ts +42 -0
- package/src/version.ts +1 -1
- package/apps/desktop/application/operations/recording/index.ts +0 -5
- package/apps/desktop/application/operations/recording/pause.ts +0 -34
- package/apps/desktop/application/operations/recording/resume.ts +0 -34
- package/apps/desktop/application/operations/recording/shared.ts +0 -199
- package/apps/desktop/application/operations/recording/start.ts +0 -169
- package/apps/desktop/application/operations/recording/stop.ts +0 -34
- package/apps/desktop/capture/protocol.ts +0 -689
- package/apps/desktop/cli/capture-bundle.ts +0 -1117
- package/apps/desktop/cli/recording-controller.ts +0 -1567
- package/apps/desktop/cli/recording-daemon.ts +0 -931
- package/dist/index-42zsesc1.js +0 -2257
- package/dist/index-7308egqr.js +0 -48
- package/dist/index-8txs6fkn.js +0 -712
- package/dist/index-mcy8z0br.js +0 -192
- package/dist/index-p63wavx0.js +0 -2029
- package/dist/index-r7gdhmsp.js +0 -424
- package/dist/index-rvghzt7b.js +0 -2013
- package/dist/index-sh6xbav6.js +0 -1219
- package/dist/index-z1w83f81.js +0 -4
- package/dist/index-z7239b4h.js +0 -2363
- package/dist/skill-install-dh0ntqba.js +0 -10
- package/src/desktop.ts +0 -253
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import { constants } from "node:fs";
|
|
2
|
+
import { mkdir, open, realpath, unlink } from "node:fs/promises";
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
4
|
+
import { basename, dirname, join, relative, resolve } from "node:path";
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
import { createSha256HexHasher } from "../../../src/code/sha256";
|
|
7
|
+
import { SlopcameraCodeError } from "../../../src/code/errors";
|
|
8
|
+
import { compileSpatialDesign, editSpatialDesignParameters, inspectSpatialDesign } from "../../../src/spatial-scene/design";
|
|
9
|
+
import { createSpatialDesignStarter, listSpatialDesignTemplates } from "../../../src/spatial-scene/design-templates";
|
|
10
|
+
import { parseSpatialScene, parseSpatialValue, spatialSceneSha256, spatialValueSha256 } from "../../../src/spatial-scene/identity";
|
|
11
|
+
import type { SpatialSceneV1 } from "../../../src/spatial-scene/contracts";
|
|
12
|
+
import type { ApplicationContext } from "../application/context";
|
|
13
|
+
import { createNodeBundleFileSystem } from "../core/storage";
|
|
14
|
+
import type { SpatialDesignCommand } from "./args";
|
|
15
|
+
import { CliError } from "./errors";
|
|
16
|
+
import { ensurePhysicalPrivateDirectoryWithin, resolveSafePath } from "./paths";
|
|
17
|
+
import { publishSpatialSource, readSpatialJson } from "./spatial-scene-service";
|
|
18
|
+
|
|
19
|
+
const MAX_BUNDLE_BYTES = 64 * 1024 * 1024;
|
|
20
|
+
const MAX_GALLERY_BYTES = 128 * 1024 * 1024;
|
|
21
|
+
const ParametersSchema = z.record(z.string().min(1).max(64), z.number().finite()).refine(value => Object.keys(value).length <= 128, "At most 128 parameter updates are allowed.");
|
|
22
|
+
export const SpatialDesignVariantsSchema = z.strictObject({
|
|
23
|
+
kind: z.literal("slopcamera.spatial-design-variants"), schemaVersion: z.literal(1),
|
|
24
|
+
variants: z.array(z.strictObject({
|
|
25
|
+
id: z.string().min(1).max(48).regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/u),
|
|
26
|
+
label: z.string().min(1).max(120), parameters: ParametersSchema,
|
|
27
|
+
})).min(1).max(6),
|
|
28
|
+
}).superRefine((value, context) => {
|
|
29
|
+
if (new Set(value.variants.map(variant => variant.id)).size !== value.variants.length) context.addIssue({ code: "custom", message: "Variant IDs must be unique." });
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
type Compilation = ReturnType<typeof compileSpatialDesign>;
|
|
33
|
+
type CapturedAsset = Readonly<{ path: string; bytes: Uint8Array }>;
|
|
34
|
+
|
|
35
|
+
function inspectionSummary(input: unknown): unknown {
|
|
36
|
+
const report = inspectSpatialDesign(input);
|
|
37
|
+
return { designId: report.design.designId, name: report.design.name, designSha256: report.designSha256,
|
|
38
|
+
parameters: report.design.parameters, values: report.values, valueOrder: report.valueOrder,
|
|
39
|
+
constraints: report.constraints, estimate: report.estimate,
|
|
40
|
+
stages: report.stages.map(({ spec: _spec, ...stage }) => stage) };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function digest(bytes: Uint8Array): string {
|
|
44
|
+
const hasher = createSha256HexHasher(); hasher.update(bytes); return hasher.digestHex();
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function captureAsset(sourceRoot: string, path: string, expected: { bytes: number; sha256: string }): Promise<CapturedAsset> {
|
|
48
|
+
const absolute = resolve(sourceRoot, path);
|
|
49
|
+
if (absolute !== await realpath(absolute)) throw new CliError("unsafe-path", "Design assets must have physical paths without symlinks.");
|
|
50
|
+
const handle = await open(absolute, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
51
|
+
try {
|
|
52
|
+
const before = await handle.stat({ bigint: true });
|
|
53
|
+
if (!before.isFile() || before.size !== BigInt(expected.bytes) || expected.bytes > MAX_BUNDLE_BYTES) throw new CliError("invalid-data", `Design asset size differs: ${path}`);
|
|
54
|
+
const bytes = new Uint8Array(expected.bytes);
|
|
55
|
+
let offset = 0;
|
|
56
|
+
while (offset < bytes.length) {
|
|
57
|
+
const result = await handle.read(bytes, offset, Math.min(262144, bytes.length - offset), offset);
|
|
58
|
+
if (result.bytesRead === 0) throw new CliError("conflict", `Design asset changed: ${path}`);
|
|
59
|
+
offset += result.bytesRead;
|
|
60
|
+
}
|
|
61
|
+
const after = await handle.stat({ bigint: true });
|
|
62
|
+
if (before.size !== after.size || before.mtimeNs !== after.mtimeNs || before.ctimeNs !== after.ctimeNs || digest(bytes) !== expected.sha256) throw new CliError("conflict", `Design asset identity differs: ${path}`);
|
|
63
|
+
return { path, bytes };
|
|
64
|
+
} finally { await handle.close(); }
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Capture only the assets still referenced by the compiled scene, including base-scene dependencies. */
|
|
68
|
+
async function captureCompilation(compilation: Compilation, sourceRoot: string | undefined): Promise<readonly CapturedAsset[]> {
|
|
69
|
+
const emitted = new Map(compilation.outputs.flatMap(output => output.artifacts).map(artifact => [artifact.assetId, artifact]));
|
|
70
|
+
const seen = new Map<string, { path: string; sha256: string; bytes: number }>();
|
|
71
|
+
let total = 0;
|
|
72
|
+
const assets: CapturedAsset[] = [];
|
|
73
|
+
for (const asset of compilation.scene.assets) {
|
|
74
|
+
const { path, bytes, sha256 } = asset.payload;
|
|
75
|
+
const pathKey = path.normalize("NFC").toLowerCase();
|
|
76
|
+
if (["design.json", "scene.json", "receipt.json", "render.json", "gallery.json", "base.scene.json"].some(reserved => pathKey === reserved || pathKey.startsWith(`${reserved}/`))) throw new CliError("conflict", `Asset uses a reserved bundle path: ${path}`);
|
|
77
|
+
const prior = seen.get(pathKey);
|
|
78
|
+
if (prior !== undefined) {
|
|
79
|
+
if (prior.sha256 !== sha256 || prior.bytes !== bytes || prior.path !== path) throw new CliError("conflict", `Asset paths have conflicting identities: ${path}`);
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
for (const existing of seen.keys()) if (pathKey.startsWith(`${existing}/`) || existing.startsWith(`${pathKey}/`)) throw new CliError("conflict", `Asset file and directory paths collide: ${path}`);
|
|
83
|
+
seen.set(pathKey, { path, sha256, bytes });
|
|
84
|
+
total += bytes;
|
|
85
|
+
if (total > MAX_BUNDLE_BYTES) throw new CliError("invalid-data", "Design bundle exceeds 64 MiB.");
|
|
86
|
+
const generated = emitted.get(asset.assetId);
|
|
87
|
+
if (generated !== undefined) {
|
|
88
|
+
if (generated.path !== path || generated.bytes.byteLength !== bytes || digest(generated.bytes) !== sha256) throw new CliError("invalid-data", "Generated design asset identity differs.");
|
|
89
|
+
assets.push({ path, bytes: generated.bytes });
|
|
90
|
+
} else {
|
|
91
|
+
if (sourceRoot === undefined) throw new CliError("invalid-data", "A base scene with retained assets requires --scene.");
|
|
92
|
+
assets.push(await captureAsset(sourceRoot, path, asset.payload));
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return assets;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function reserveDirectory(root: string, requested: string, fence: () => Promise<void>): Promise<string> {
|
|
99
|
+
const target = await resolveSafePath(root, requested);
|
|
100
|
+
if (target === root) throw new CliError("unsafe-path", "Design output must be a new directory below the workspace.");
|
|
101
|
+
const parent = relative(root, dirname(target));
|
|
102
|
+
if (parent !== "") await ensurePhysicalPrivateDirectoryWithin(root, parent);
|
|
103
|
+
await fence();
|
|
104
|
+
// The custody callback may yield; validate physical parents again afterwards.
|
|
105
|
+
await resolveSafePath(root, requested);
|
|
106
|
+
if (parent !== "") await ensurePhysicalPrivateDirectoryWithin(root, parent);
|
|
107
|
+
try { await mkdir(target, { mode: 0o700 }); }
|
|
108
|
+
catch (error) {
|
|
109
|
+
if (error instanceof Error && "code" in error && error.code === "EEXIST") throw new CliError("conflict", "Design output already exists. Choose a new directory to preserve all revisions.");
|
|
110
|
+
throw error;
|
|
111
|
+
}
|
|
112
|
+
return target;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async function publishBytes(root: string, asset: CapturedAsset, fence: () => Promise<void>): Promise<void> {
|
|
116
|
+
const folder = dirname(asset.path);
|
|
117
|
+
const directory = folder === "." ? root : await ensurePhysicalPrivateDirectoryWithin(root, folder);
|
|
118
|
+
const path = join(directory, basename(asset.path));
|
|
119
|
+
const temporary = join(directory, `.design-${randomUUID()}.tmp`);
|
|
120
|
+
const handle = await open(temporary, "wx", 0o600);
|
|
121
|
+
try {
|
|
122
|
+
await handle.writeFile(asset.bytes); await handle.sync();
|
|
123
|
+
} finally { await handle.close(); }
|
|
124
|
+
try {
|
|
125
|
+
const fileSystem = createNodeBundleFileSystem(root);
|
|
126
|
+
// The storage boundary rechecks staging bytes and physical parents after
|
|
127
|
+
// the custody callback and publishes by a guarded no-replace hard link.
|
|
128
|
+
const result = await fileSystem.copyFileNoReplace!(relative(root, temporary), relative(root, path),
|
|
129
|
+
{ bytes: asset.bytes.byteLength, sha256: digest(asset.bytes) }, fence).catch((error: unknown) => {
|
|
130
|
+
if (error instanceof Error && !(error instanceof CliError) && !("code" in error)) throw new CliError("conflict", error.message);
|
|
131
|
+
throw error;
|
|
132
|
+
});
|
|
133
|
+
if (result !== "created") throw new CliError("conflict", "Design asset output already exists.");
|
|
134
|
+
} finally { await unlink(temporary); }
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async function publishCompilation(root: string, compilation: Compilation, assets: readonly CapturedAsset[], fence: () => Promise<void>): Promise<unknown> {
|
|
138
|
+
for (const asset of assets) await publishBytes(root, asset, fence);
|
|
139
|
+
await publishSpatialSource(join(root, "design.json"), compilation.design, fence);
|
|
140
|
+
await publishSpatialSource(join(root, "scene.json"), compilation.scene, fence);
|
|
141
|
+
const cameraId = compilation.scene.cameras.find(camera => camera.cameraId === "camera_hero")?.cameraId ?? compilation.scene.cameras[0]?.cameraId;
|
|
142
|
+
if (cameraId !== undefined) await publishSpatialSource(join(root, "render.json"), { cameraId, selection: { kind: "frame", timeUs: 0 }, mode: { kind: "beauty" } }, fence);
|
|
143
|
+
const receipt = {
|
|
144
|
+
kind: "slopcamera.spatial-design-bundle", schemaVersion: 1,
|
|
145
|
+
compilation: compilation.receipt, compilationSha256: compilation.receiptSha256,
|
|
146
|
+
stages: compilation.outputs.map(output => ({ receipt: output.receipt, receiptSha256: output.receiptSha256 })),
|
|
147
|
+
sceneSha256: spatialSceneSha256(compilation.scene),
|
|
148
|
+
files: assets.map(asset => ({ path: asset.path, bytes: asset.bytes.byteLength, sha256: digest(asset.bytes) })),
|
|
149
|
+
};
|
|
150
|
+
await publishSpatialSource(join(root, "receipt.json"), receipt, fence);
|
|
151
|
+
return { directory: root, scene: join(root, "scene.json"), design: join(root, "design.json"), receipt: join(root, "receipt.json"),
|
|
152
|
+
receiptSha256: spatialValueSha256(receipt), sceneSha256: receipt.sceneSha256,
|
|
153
|
+
...(cameraId === undefined ? {} : { renderRequest: join(root, "render.json") }),
|
|
154
|
+
stages: compilation.outputs.length, assets: assets.length };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async function executeDesign(application: ApplicationContext, command: SpatialDesignCommand, signal?: AbortSignal): Promise<unknown> {
|
|
158
|
+
const root = application.paths.repositoryRoot;
|
|
159
|
+
const fence = async () => { signal?.throwIfAborted(); await application.hostResourceLease?.assertOwned(); signal?.throwIfAborted(); };
|
|
160
|
+
await fence();
|
|
161
|
+
if (command.action === "catalog") return { templates: listSpatialDesignTemplates() };
|
|
162
|
+
if (command.action === "init") {
|
|
163
|
+
const starter = createSpatialDesignStarter(command.template);
|
|
164
|
+
inspectSpatialDesign(starter.design);
|
|
165
|
+
const directory = await reserveDirectory(root, command.directory, fence);
|
|
166
|
+
await publishSpatialSource(join(directory, "design.json"), starter.design, fence);
|
|
167
|
+
await publishSpatialSource(join(directory, "base.scene.json"), starter.scene, fence);
|
|
168
|
+
return { directory, design: join(directory, "design.json"), scene: join(directory, "base.scene.json"), template: command.template };
|
|
169
|
+
}
|
|
170
|
+
const design = await readSpatialJson(resolve(root, command.path));
|
|
171
|
+
if (command.action === "inspect") return inspectionSummary(design);
|
|
172
|
+
if (command.action === "set") {
|
|
173
|
+
const parameters = parseSpatialValue(ParametersSchema, await readSpatialJson(resolve(root, command.parameters), 65536), "design parameter updates");
|
|
174
|
+
const edited = editSpatialDesignParameters(design, parameters);
|
|
175
|
+
const output = await resolveSafePath(root, command.output);
|
|
176
|
+
const folder = relative(root, dirname(output));
|
|
177
|
+
if (folder !== "") await ensurePhysicalPrivateDirectoryWithin(root, folder);
|
|
178
|
+
await publishSpatialSource(output, edited, fence);
|
|
179
|
+
return { path: output, inspection: inspectionSummary(edited) };
|
|
180
|
+
}
|
|
181
|
+
const scenePath = command.scene === undefined ? undefined : resolve(root, command.scene);
|
|
182
|
+
const scene: SpatialSceneV1 | undefined = scenePath === undefined ? undefined : parseSpatialScene(await readSpatialJson(scenePath));
|
|
183
|
+
const options = scene === undefined ? {} : { scene };
|
|
184
|
+
const sourceRoot = scenePath === undefined ? undefined : await realpath(dirname(scenePath));
|
|
185
|
+
if (command.action === "compile") {
|
|
186
|
+
const compilation = compileSpatialDesign(design, options);
|
|
187
|
+
const assets = await captureCompilation(compilation, sourceRoot);
|
|
188
|
+
const directory = await reserveDirectory(root, command.outputDir, fence);
|
|
189
|
+
return await publishCompilation(directory, compilation, assets, fence);
|
|
190
|
+
}
|
|
191
|
+
const variants = parseSpatialValue(SpatialDesignVariantsSchema, await readSpatialJson(resolve(root, command.variants), 65536), "design variants");
|
|
192
|
+
// Inspect every candidate before emitting any geometry or publishing a directory.
|
|
193
|
+
const designs = variants.variants.map(variant => editSpatialDesignParameters(design, variant.parameters));
|
|
194
|
+
let estimatedBytes = 0;
|
|
195
|
+
for (const candidate of designs) {
|
|
196
|
+
const inspection = inspectSpatialDesign(candidate);
|
|
197
|
+
estimatedBytes += inspection.estimate.bytes;
|
|
198
|
+
if (estimatedBytes > MAX_GALLERY_BYTES) throw new CliError("invalid-data", "Design gallery exceeds 128 MiB of estimated output.");
|
|
199
|
+
}
|
|
200
|
+
const compiled = [];
|
|
201
|
+
let total = 0;
|
|
202
|
+
for (let index = 0; index < designs.length; index++) {
|
|
203
|
+
await fence();
|
|
204
|
+
const compilation = compileSpatialDesign(designs[index], options);
|
|
205
|
+
const assets = await captureCompilation(compilation, sourceRoot);
|
|
206
|
+
total += assets.reduce((sum, asset) => sum + asset.bytes.byteLength, 0);
|
|
207
|
+
if (total > MAX_GALLERY_BYTES) throw new CliError("invalid-data", "Design gallery exceeds 128 MiB.");
|
|
208
|
+
compiled.push({ variant: variants.variants[index]!, compilation, assets });
|
|
209
|
+
}
|
|
210
|
+
const directory = await reserveDirectory(root, command.outputDir, fence);
|
|
211
|
+
const rows = [];
|
|
212
|
+
for (const { variant, compilation, assets } of compiled) {
|
|
213
|
+
const child = await reserveDirectory(directory, variant.id, fence);
|
|
214
|
+
const bundle = await publishCompilation(child, compilation, assets, fence);
|
|
215
|
+
rows.push({ ...variant, bundle });
|
|
216
|
+
}
|
|
217
|
+
const gallery = { kind: "slopcamera.spatial-design-gallery", schemaVersion: 1, rendered: false, variants: rows };
|
|
218
|
+
await publishSpatialSource(join(directory, "gallery.json"), gallery, fence);
|
|
219
|
+
return { directory, gallery: join(directory, "gallery.json"), variants: rows, rendered: false,
|
|
220
|
+
sourceSha256: spatialValueSha256(design) };
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export async function executeSpatialDesignCommand(application: ApplicationContext, command: SpatialDesignCommand, signal?: AbortSignal): Promise<unknown> {
|
|
224
|
+
try { return await executeDesign(application, command, signal); }
|
|
225
|
+
catch (error) {
|
|
226
|
+
if (error instanceof CliError) throw error;
|
|
227
|
+
if (signal?.aborted) throw new CliError("cancelled", "Design command was cancelled; retain any partial bundle.");
|
|
228
|
+
if (error instanceof SlopcameraCodeError) throw new CliError(error.code, error.message, error.details);
|
|
229
|
+
if (error instanceof SyntaxError) throw new CliError("invalid-data", "Design input must be valid UTF-8 JSON.");
|
|
230
|
+
if (error instanceof Error && "code" in error) {
|
|
231
|
+
if (error.code === "ENOENT" || error.code === "ENOTDIR") throw new CliError("not-found", "Design source, asset, or output parent does not exist.");
|
|
232
|
+
if (error.code === "ELOOP") throw new CliError("unsafe-path", "Design inputs and outputs must use physical paths without symlinks.");
|
|
233
|
+
if (error.code === "EEXIST") throw new CliError("conflict", "Design output already exists; choose a new path.");
|
|
234
|
+
}
|
|
235
|
+
throw error;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
import { constants } from "node:fs";
|
|
2
|
+
import { mkdir, open, realpath, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
3
|
+
import { basename, dirname, extname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
4
|
+
import { pathToFileURL } from "node:url";
|
|
5
|
+
import { createSha256HexHasher } from "../../../src/code/sha256";
|
|
6
|
+
import {
|
|
7
|
+
buildSpatialGeneratorRecord,
|
|
8
|
+
deriveSpatialGeneratorSeed,
|
|
9
|
+
generatedSpatialEntityId,
|
|
10
|
+
mergeSpatialGeneratorOutput,
|
|
11
|
+
parseSpatialGeneratorParameters,
|
|
12
|
+
parseSpatialScene,
|
|
13
|
+
SPATIAL_GENERATOR_LIMITS,
|
|
14
|
+
spatialGeneratorParametersSha256,
|
|
15
|
+
spatialSceneSha256,
|
|
16
|
+
SpatialGeneratorIdSchema,
|
|
17
|
+
validateSpatialGeneratorOutput,
|
|
18
|
+
} from "../../../src/spatial-scene/index";
|
|
19
|
+
import { canonicalJsonSha256, sha256Hex } from "../core/canonical-json";
|
|
20
|
+
import type { ApplicationContext } from "../application/context";
|
|
21
|
+
import type { SpatialSceneCommand } from "./args";
|
|
22
|
+
import { CliError, errorMessage } from "./errors";
|
|
23
|
+
import { publishSpatialSource, readSpatialJson } from "./spatial-scene-service";
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Capture an explicit local generator file once: a bounded regular file
|
|
27
|
+
* pinned by descriptor stat before and after the read, never a symlink leaf.
|
|
28
|
+
*/
|
|
29
|
+
async function readGeneratorModuleBytes(path: string, label: string): Promise<Uint8Array> {
|
|
30
|
+
const handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK)
|
|
31
|
+
.catch((error: NodeJS.ErrnoException) => {
|
|
32
|
+
if (error.code === "ELOOP") throw new CliError("invalid-data", `${label} must be a real file, not a symlink.`);
|
|
33
|
+
if (error.code === "ENOENT" || error.code === "ENOTDIR") throw new CliError("not-found", `${label} does not exist.`);
|
|
34
|
+
throw error;
|
|
35
|
+
});
|
|
36
|
+
try {
|
|
37
|
+
const before = await handle.stat({ bigint: true });
|
|
38
|
+
if (!before.isFile() || before.size === 0n || before.size > BigInt(SPATIAL_GENERATOR_LIMITS.moduleSourceBytes)) {
|
|
39
|
+
throw new CliError("invalid-data", `${label} must be a nonempty bounded regular file.`);
|
|
40
|
+
}
|
|
41
|
+
const bytes = new Uint8Array(Number(before.size));
|
|
42
|
+
let offset = 0;
|
|
43
|
+
while (offset < bytes.byteLength) {
|
|
44
|
+
const read = await handle.read(bytes, offset, bytes.byteLength - offset, offset);
|
|
45
|
+
if (read.bytesRead === 0) throw new CliError("conflict", `${label} ended while it was being read.`);
|
|
46
|
+
offset += read.bytesRead;
|
|
47
|
+
}
|
|
48
|
+
const after = await handle.stat({ bigint: true });
|
|
49
|
+
if (after.size !== before.size || after.ino !== before.ino || after.mtimeNs !== before.mtimeNs || after.ctimeNs !== before.ctimeNs) {
|
|
50
|
+
throw new CliError("conflict", `${label} changed while it was being read.`);
|
|
51
|
+
}
|
|
52
|
+
return bytes;
|
|
53
|
+
} finally { await handle.close(); }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* One verified real file in the generator closure. realPath is the canonical
|
|
58
|
+
* dedupe and containment identity and the verified-read target; relativePath
|
|
59
|
+
* is the POSIX manifest key and the staged location below the module root.
|
|
60
|
+
*/
|
|
61
|
+
interface GeneratorClosureFile {
|
|
62
|
+
readonly realPath: string;
|
|
63
|
+
readonly relativePath: string;
|
|
64
|
+
readonly bytes: Uint8Array;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
interface GeneratorModuleClosure {
|
|
68
|
+
readonly files: readonly GeneratorClosureFile[];
|
|
69
|
+
readonly entryRelativePath: string;
|
|
70
|
+
readonly sourceSha256: string;
|
|
71
|
+
readonly closureSha256: string;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Closure bounds: at most 64 real files whose verified bytes together stay
|
|
76
|
+
* inside the pre-existing per-module ceiling (1 MiB), so hashing, staging,
|
|
77
|
+
* and module loading remain cheap. A single-file module therefore keeps its
|
|
78
|
+
* exact prior acceptance range.
|
|
79
|
+
*/
|
|
80
|
+
const GENERATOR_MODULE_CLOSURE_LIMITS = Object.freeze({
|
|
81
|
+
files: 64,
|
|
82
|
+
totalBytes: SPATIAL_GENERATOR_LIMITS.moduleSourceBytes,
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
/** The transpiler loader for a scanned member, or undefined for data (.json) files that carry no imports. */
|
|
86
|
+
function generatorModuleLoader(relativePath: string): "ts" | "js" | undefined {
|
|
87
|
+
if (relativePath.endsWith(".ts")) return "ts";
|
|
88
|
+
if (relativePath.endsWith(".js")) return "js";
|
|
89
|
+
return undefined;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function generatorModuleLabel(entry: GeneratorClosureFile, file: GeneratorClosureFile): string {
|
|
93
|
+
return file === entry ? "Generator module" : `Generator dependency "${file.relativePath}"`;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function decodeGeneratorModuleSource(file: GeneratorClosureFile, label: string): string {
|
|
97
|
+
try {
|
|
98
|
+
return new TextDecoder("utf-8", { fatal: true }).decode(file.bytes);
|
|
99
|
+
} catch {
|
|
100
|
+
throw new CliError("invalid-data", `${label} must be UTF-8 TypeScript/JavaScript source.`);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function sha256BytesHex(bytes: Uint8Array): string {
|
|
105
|
+
const hasher = createSha256HexHasher();
|
|
106
|
+
hasher.update(bytes);
|
|
107
|
+
return hasher.digestHex();
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Source forms the static closure cannot see are rejected in every file:
|
|
112
|
+
* require()/require.resolve(), dynamic import(), and the Bun import.meta
|
|
113
|
+
* facilities that mint module references at runtime.
|
|
114
|
+
*/
|
|
115
|
+
function assertClosureVisibleModuleForms(source: string, label: string): void {
|
|
116
|
+
if (/\brequire(?:\s*\.\s*resolve)?\s*\(/u.test(source)) {
|
|
117
|
+
throw new CliError("invalid-data", `${label} must be ESM; require() escapes the retained closure digest.`);
|
|
118
|
+
}
|
|
119
|
+
if (/(?<![\w$.])import\s*\(/u.test(source)) {
|
|
120
|
+
throw new CliError("invalid-data", `${label} uses dynamic import(), which escapes the retained closure digest.`);
|
|
121
|
+
}
|
|
122
|
+
if (/\bimport\s*\.\s*meta\s*\.\s*(?:glob|resolve|require)\b/u.test(source)) {
|
|
123
|
+
throw new CliError("invalid-data", `${label} uses import.meta glob/resolve/require, which escapes the retained closure digest.`);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function isRelativeModuleSpecifier(specifier: string): boolean {
|
|
128
|
+
return specifier === "." || specifier === ".." || specifier.startsWith("./") || specifier.startsWith("../");
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Absolute filesystem specifiers, including drive-letter, UNC, and file: URL forms. */
|
|
132
|
+
function isAbsoluteModuleSpecifier(specifier: string): boolean {
|
|
133
|
+
return specifier.startsWith("/") || specifier.startsWith("\\")
|
|
134
|
+
|| /^[a-zA-Z]:/u.test(specifier) || specifier.toLowerCase().startsWith("file:");
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Resolution candidates mirroring Bun's file-then-index probing, restricted
|
|
139
|
+
* to the code/data extensions the closure admits; `.js` also probes the
|
|
140
|
+
* TypeScript `.ts` spelling because the runtime resolves that way.
|
|
141
|
+
*/
|
|
142
|
+
function generatorModuleSpecifierCandidates(resolved: string, specifier: string): readonly string[] {
|
|
143
|
+
if (resolved.endsWith(".ts") || resolved.endsWith(".json")) return [resolved];
|
|
144
|
+
if (resolved.endsWith(".js")) return [resolved, `${resolved.slice(0, -3)}.ts`];
|
|
145
|
+
if (extname(resolved) !== "") {
|
|
146
|
+
throw new CliError("invalid-data", `Generator import "${specifier}" must target a .ts, .js, or .json file inside the module directory.`);
|
|
147
|
+
}
|
|
148
|
+
if (specifier.endsWith("/") || specifier === "." || specifier === "..") {
|
|
149
|
+
return [join(resolved, "index.ts"), join(resolved, "index.js"), join(resolved, "index.json")];
|
|
150
|
+
}
|
|
151
|
+
return [`${resolved}.ts`, `${resolved}.js`, `${resolved}.json`,
|
|
152
|
+
join(resolved, "index.ts"), join(resolved, "index.js"), join(resolved, "index.json")];
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async function realpathIfPresent(candidate: string, specifier: string): Promise<string | undefined> {
|
|
156
|
+
try {
|
|
157
|
+
return await realpath(candidate);
|
|
158
|
+
} catch (error) {
|
|
159
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
160
|
+
if (code === "ENOENT" || code === "ENOTDIR") return undefined;
|
|
161
|
+
if (code === "ELOOP") {
|
|
162
|
+
throw new CliError("invalid-data", `Generator import "${specifier}" resolves through a symlink; generator modules must be a real file tree.`);
|
|
163
|
+
}
|
|
164
|
+
throw error;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* BFS over the transitive relative-import graph so the retained closureSha256
|
|
170
|
+
* covers the complete real file set. The module root is the entry file's real
|
|
171
|
+
* directory; every resolved file must stay inside it, must be a real
|
|
172
|
+
* (non-symlink) regular file, and gets the same verified read as the entry.
|
|
173
|
+
* Erased `import type`/`export type` entries carry no runtime dependency and
|
|
174
|
+
* are allowed; bare package specifiers (including node:/bun: builtins) resolve
|
|
175
|
+
* ambient dependencies the digests do not cover — a documented prototype gap
|
|
176
|
+
* unchanged from single-file mode.
|
|
177
|
+
*/
|
|
178
|
+
async function resolveGeneratorModuleClosure(modulePath: string): Promise<GeneratorModuleClosure> {
|
|
179
|
+
const entryBytes = await readGeneratorModuleBytes(modulePath, "Generator module");
|
|
180
|
+
let entryRealPath: string;
|
|
181
|
+
try {
|
|
182
|
+
entryRealPath = await realpath(modulePath);
|
|
183
|
+
} catch {
|
|
184
|
+
throw new CliError("not-found", "Generator module does not resolve to a real file.");
|
|
185
|
+
}
|
|
186
|
+
const rootReal = dirname(entryRealPath);
|
|
187
|
+
const entry: GeneratorClosureFile = { realPath: entryRealPath, relativePath: basename(entryRealPath), bytes: entryBytes };
|
|
188
|
+
if (generatorModuleLoader(entry.relativePath) === undefined) {
|
|
189
|
+
throw new CliError("invalid-data", "Generator module must be a .ts or .js file.");
|
|
190
|
+
}
|
|
191
|
+
const files: GeneratorClosureFile[] = [entry];
|
|
192
|
+
const seen = new Set([entry.realPath]);
|
|
193
|
+
const queue = [entry];
|
|
194
|
+
let totalBytes = entry.bytes.byteLength;
|
|
195
|
+
|
|
196
|
+
const resolveImport = async (importer: GeneratorClosureFile, specifier: string): Promise<string> => {
|
|
197
|
+
const resolved = resolve(dirname(importer.realPath), specifier);
|
|
198
|
+
for (const candidate of generatorModuleSpecifierCandidates(resolved, specifier)) {
|
|
199
|
+
const real = await realpathIfPresent(candidate, specifier);
|
|
200
|
+
if (real === undefined) continue;
|
|
201
|
+
const contained = relative(rootReal, real);
|
|
202
|
+
if (contained === "" || contained === ".." || contained.startsWith(`..${sep}`) || isAbsolute(contained)) {
|
|
203
|
+
throw new CliError("invalid-data", `Generator import "${specifier}" escapes the generator module directory.`);
|
|
204
|
+
}
|
|
205
|
+
if (real !== candidate && real.toLowerCase() !== candidate.toLowerCase()) {
|
|
206
|
+
throw new CliError("invalid-data", `Generator import "${specifier}" resolves through a symlink; generator modules must be a real file tree.`);
|
|
207
|
+
}
|
|
208
|
+
return real;
|
|
209
|
+
}
|
|
210
|
+
throw new CliError("not-found", `Generator import "${specifier}" does not resolve to a .ts, .js, or .json file below the module directory.`);
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
while (queue.length > 0) {
|
|
214
|
+
const importer = queue.shift()!;
|
|
215
|
+
const loader = generatorModuleLoader(importer.relativePath);
|
|
216
|
+
if (loader === undefined) continue; // data files carry no further imports
|
|
217
|
+
const label = generatorModuleLabel(entry, importer);
|
|
218
|
+
const source = decodeGeneratorModuleSource(importer, label);
|
|
219
|
+
assertClosureVisibleModuleForms(source, label);
|
|
220
|
+
for (const scanned of new Bun.Transpiler({ loader }).scan(source).imports) {
|
|
221
|
+
if (scanned.kind === "dynamic-import") {
|
|
222
|
+
throw new CliError("invalid-data", `${label} uses dynamic import(), which escapes the retained closure digest.`);
|
|
223
|
+
}
|
|
224
|
+
const specifier = scanned.path;
|
|
225
|
+
if (isAbsoluteModuleSpecifier(specifier)) {
|
|
226
|
+
throw new CliError("invalid-data", `Generator import "${specifier}" is absolute; only relative imports inside the module directory are allowed.`);
|
|
227
|
+
}
|
|
228
|
+
if (!isRelativeModuleSpecifier(specifier)) continue; // ambient bare specifier — documented gap
|
|
229
|
+
const real = await resolveImport(importer, specifier);
|
|
230
|
+
if (seen.has(real)) continue;
|
|
231
|
+
const relativePath = relative(rootReal, real).split(sep).join("/");
|
|
232
|
+
const bytes = await readGeneratorModuleBytes(real, `Generator dependency "${relativePath}"`);
|
|
233
|
+
const file: GeneratorClosureFile = { realPath: real, relativePath, bytes };
|
|
234
|
+
files.push(file);
|
|
235
|
+
seen.add(real);
|
|
236
|
+
queue.push(file);
|
|
237
|
+
totalBytes += bytes.byteLength;
|
|
238
|
+
if (files.length > GENERATOR_MODULE_CLOSURE_LIMITS.files) {
|
|
239
|
+
throw new CliError("invalid-data", `Generator module closure exceeds ${GENERATOR_MODULE_CLOSURE_LIMITS.files} files.`);
|
|
240
|
+
}
|
|
241
|
+
if (totalBytes > GENERATOR_MODULE_CLOSURE_LIMITS.totalBytes) {
|
|
242
|
+
throw new CliError("invalid-data", `Generator module closure exceeds ${GENERATOR_MODULE_CLOSURE_LIMITS.totalBytes} total bytes.`);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const manifest = files
|
|
248
|
+
.map(file => ({ relativePath: file.relativePath, sha256: sha256BytesHex(file.bytes) }))
|
|
249
|
+
.sort((left, right) => left.relativePath < right.relativePath ? -1 : left.relativePath > right.relativePath ? 1 : 0);
|
|
250
|
+
const sourceSha256 = sha256BytesHex(entry.bytes);
|
|
251
|
+
// Single-file modules keep closureSha256 == sourceSha256 exactly, so the
|
|
252
|
+
// retained record is byte-identical to the pre-closure behavior.
|
|
253
|
+
const closureSha256 = files.length === 1 ? sourceSha256 : canonicalJsonSha256(manifest);
|
|
254
|
+
return { files, entryRelativePath: entry.relativePath, sourceSha256, closureSha256 };
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
async function directoryExists(path: string): Promise<boolean> {
|
|
258
|
+
return stat(path).then(result => result.isDirectory()).catch(() => false);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* The closure executes from a private content-addressed tree mirroring the
|
|
263
|
+
* module's relative structure, so the code that runs is exactly the bytes the
|
|
264
|
+
* retained digests describe. Plain-path imports would return stale modules
|
|
265
|
+
* from the in-process registry after any member file changed.
|
|
266
|
+
*/
|
|
267
|
+
async function stageGeneratorModuleTree(privateRoot: string, closure: GeneratorModuleClosure): Promise<string> {
|
|
268
|
+
const directory = join(privateRoot, "scene-generator-modules");
|
|
269
|
+
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
270
|
+
const staged = join(directory, closure.closureSha256);
|
|
271
|
+
const stagedEntry = join(staged, ...closure.entryRelativePath.split("/"));
|
|
272
|
+
if (await directoryExists(staged)) return stagedEntry;
|
|
273
|
+
const temporary = join(directory, `.${closure.closureSha256}.${process.pid}.tmp`);
|
|
274
|
+
await rm(temporary, { recursive: true, force: true });
|
|
275
|
+
try {
|
|
276
|
+
for (const file of closure.files) {
|
|
277
|
+
const target = join(temporary, ...file.relativePath.split("/"));
|
|
278
|
+
await mkdir(dirname(target), { recursive: true, mode: 0o700 });
|
|
279
|
+
await writeFile(target, file.bytes, { mode: 0o600 });
|
|
280
|
+
}
|
|
281
|
+
await rename(temporary, staged);
|
|
282
|
+
} catch (error) {
|
|
283
|
+
await rm(temporary, { recursive: true, force: true }).catch(() => undefined);
|
|
284
|
+
if (await directoryExists(staged)) return stagedEntry; // a concurrent invocation staged identical content
|
|
285
|
+
throw error;
|
|
286
|
+
}
|
|
287
|
+
return stagedEntry;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
export type SpatialGenerateCommand = Extract<SpatialSceneCommand, { readonly action: "generate" }>;
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Executes a trusted current-user generator module — the same trust class as an
|
|
294
|
+
* explicitly imported workflow module, with no sandbox claim — then stamps
|
|
295
|
+
* generated identity, validates bounded output, records attempt provenance and
|
|
296
|
+
* merges through replace-generator-output semantics.
|
|
297
|
+
*/
|
|
298
|
+
export async function executeSpatialGenerateCommand(
|
|
299
|
+
application: ApplicationContext,
|
|
300
|
+
command: SpatialGenerateCommand,
|
|
301
|
+
): Promise<unknown> {
|
|
302
|
+
const parsedGeneratorId = SpatialGeneratorIdSchema.safeParse(command.generatorId);
|
|
303
|
+
if (!parsedGeneratorId.success) {
|
|
304
|
+
throw new CliError("invalid-data", "--generator-id must match generator_<alphanumeric> (1–128 characters).");
|
|
305
|
+
}
|
|
306
|
+
const generatorId = parsedGeneratorId.data;
|
|
307
|
+
const modulePath = resolve(application.paths.repositoryRoot, command.module);
|
|
308
|
+
const closure = await resolveGeneratorModuleClosure(modulePath);
|
|
309
|
+
const { sourceSha256, closureSha256 } = closure;
|
|
310
|
+
const stagedPath = await stageGeneratorModuleTree(application.paths.privateRoot, closure);
|
|
311
|
+
|
|
312
|
+
const parameters = command.parameters === undefined
|
|
313
|
+
? {}
|
|
314
|
+
: parseSpatialGeneratorParameters(
|
|
315
|
+
await readSpatialJson(resolve(application.paths.repositoryRoot, command.parameters), SPATIAL_GENERATOR_LIMITS.parametersBytes));
|
|
316
|
+
const parametersSha256 = spatialGeneratorParametersSha256(parameters);
|
|
317
|
+
const seed = command.seed ?? deriveSpatialGeneratorSeed(sourceSha256);
|
|
318
|
+
|
|
319
|
+
let loaded: unknown;
|
|
320
|
+
try {
|
|
321
|
+
loaded = await import(pathToFileURL(stagedPath).href);
|
|
322
|
+
} catch (error) {
|
|
323
|
+
throw new CliError("invalid-data", `Generator module failed to load: ${errorMessage(error)}`);
|
|
324
|
+
}
|
|
325
|
+
const generate = (loaded as Readonly<Record<string, unknown>>).generate;
|
|
326
|
+
if (typeof generate !== "function") {
|
|
327
|
+
throw new CliError("invalid-data", 'Generator module must export a "generate" function.');
|
|
328
|
+
}
|
|
329
|
+
const context = Object.freeze({
|
|
330
|
+
seed,
|
|
331
|
+
parameters,
|
|
332
|
+
lib: Object.freeze({ entityId: (key: string) => generatedSpatialEntityId(generatorId, key) }),
|
|
333
|
+
});
|
|
334
|
+
let raw: unknown;
|
|
335
|
+
try {
|
|
336
|
+
raw = await (generate as (context: unknown) => unknown)(context);
|
|
337
|
+
} catch (error) {
|
|
338
|
+
throw new CliError("invalid-data", `Generator module failed: ${errorMessage(error)}`);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
const output = validateSpatialGeneratorOutput(generatorId, raw);
|
|
342
|
+
const runtimeSha256 = sha256Hex(Bun.version);
|
|
343
|
+
const generator = buildSpatialGeneratorRecord({
|
|
344
|
+
generatorId,
|
|
345
|
+
sourceSha256,
|
|
346
|
+
closureSha256,
|
|
347
|
+
parametersSha256,
|
|
348
|
+
seed,
|
|
349
|
+
runtimeSha256,
|
|
350
|
+
entities: output.entities,
|
|
351
|
+
editableKeys: output.editableKeys,
|
|
352
|
+
});
|
|
353
|
+
const into = command.into === undefined
|
|
354
|
+
? undefined
|
|
355
|
+
: parseSpatialScene(await readSpatialJson(resolve(application.paths.repositoryRoot, command.into)));
|
|
356
|
+
const scene = mergeSpatialGeneratorOutput(into, generator, output.entities);
|
|
357
|
+
const outputPath = resolve(application.paths.repositoryRoot, command.output);
|
|
358
|
+
await publishSpatialSource(outputPath, scene);
|
|
359
|
+
return {
|
|
360
|
+
path: outputPath,
|
|
361
|
+
sceneSha256: spatialSceneSha256(scene),
|
|
362
|
+
generatorId,
|
|
363
|
+
seed,
|
|
364
|
+
entities: output.entities.length,
|
|
365
|
+
outputSha256: generator.outputSha256,
|
|
366
|
+
sourceSha256,
|
|
367
|
+
attemptId: generator.execution.kind === "attempt" ? generator.execution.attemptId : undefined,
|
|
368
|
+
merged: into !== undefined,
|
|
369
|
+
};
|
|
370
|
+
}
|