@genex-ai/cli-demo 1.5.2-dev.397 → 1.6.0-dev.399
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +288 -10
- package/package.json +1 -1
- package/templates/asset-viewer/asset.config.json +25 -0
- package/templates/asset-viewer/genex-asset.example.json +222 -0
- package/templates/asset-viewer/index.html +200 -0
- package/templates/asset-viewer/package.json +24 -0
- package/templates/asset-viewer/public/fonts/Geist-variable.woff2 +0 -0
- package/templates/asset-viewer/public/fonts/GeistMono-variable.woff2 +0 -0
- package/templates/asset-viewer/shared-files.sha256.json +14 -0
- package/templates/asset-viewer/src/asset/PLACEHOLDER.ts +237 -0
- package/templates/asset-viewer/src/main.js +149 -0
- package/templates/asset-viewer/src/viewer/gates-overlay.js +521 -0
- package/templates/asset-viewer/src/viewer/hud.js +73 -0
- package/templates/asset-viewer/src/viewer/stage.js +760 -0
- package/templates/asset-viewer/tools/emit-manifest.mjs +653 -0
- package/templates/asset-viewer/tools/gates.mjs +682 -0
- package/templates/asset-viewer/tools/stamp-manifest.mjs +134 -0
- package/templates/asset-viewer/vite.config.js +24 -0
- package/templates/skills/genex-asset-author/SKILL.md +101 -0
- package/templates/skills/genex-game-director/references/routing-map.md +1 -0
|
@@ -0,0 +1,653 @@
|
|
|
1
|
+
// The asset manifest - emitted here, stamped by stamp-manifest.mjs, read by the
|
|
2
|
+
// dashboard through GET /api/projects/asset-manifest/:slug.
|
|
3
|
+
//
|
|
4
|
+
// This file is BOTH the Vite plugin (phase 1 of AG-859 §3.4) and the pure
|
|
5
|
+
// helpers gates.mjs and stamp-manifest.mjs import, so the manifest shape, the
|
|
6
|
+
// licence text and the source-scanning rules are defined exactly once.
|
|
7
|
+
//
|
|
8
|
+
// Copied verbatim into every asset project by scripts/new-asset.mjs and hashed
|
|
9
|
+
// by gate G13. Edit it in packages/asset-viewer-template/template/tools - never
|
|
10
|
+
// inside a scaffolded asset folder, or that folder fails its own build.
|
|
11
|
+
|
|
12
|
+
import { createHash } from "node:crypto";
|
|
13
|
+
import fs from "node:fs";
|
|
14
|
+
import path from "node:path";
|
|
15
|
+
|
|
16
|
+
export const SCHEMA_VERSION = 1;
|
|
17
|
+
export const MANIFEST_KIND = "genex-asset";
|
|
18
|
+
export const MANIFEST_FILENAME = "genex-asset.json";
|
|
19
|
+
|
|
20
|
+
/** §1.1 hard ceiling. Enforced here (G14) and re-checked by the API. */
|
|
21
|
+
export const MANIFEST_MAX_BYTES = 256 * 1024;
|
|
22
|
+
|
|
23
|
+
/** The three revision every asset is built and gated against. */
|
|
24
|
+
export const BUILT_AGAINST_THREE = "0.185.1";
|
|
25
|
+
|
|
26
|
+
export const LICENSE_NOTICE =
|
|
27
|
+
"Free to use, modify and ship, commercially included. Keep the copyright line in the file.";
|
|
28
|
+
|
|
29
|
+
export function mitLicenseText(holder, year) {
|
|
30
|
+
return [
|
|
31
|
+
"MIT License",
|
|
32
|
+
"",
|
|
33
|
+
`Copyright (c) ${year} ${holder}`,
|
|
34
|
+
"",
|
|
35
|
+
'Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:',
|
|
36
|
+
"",
|
|
37
|
+
"The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.",
|
|
38
|
+
"",
|
|
39
|
+
'THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.',
|
|
40
|
+
].join("\n");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The file set gate G13 hashes. Every one of these is copied verbatim by
|
|
45
|
+
* scripts/new-asset.mjs; a hand-tweaked light in asset 7 fails its own build.
|
|
46
|
+
*/
|
|
47
|
+
export const SHARED_FILES = [
|
|
48
|
+
"index.html",
|
|
49
|
+
"vite.config.js",
|
|
50
|
+
"src/main.js",
|
|
51
|
+
"src/viewer/stage.js",
|
|
52
|
+
"src/viewer/hud.js",
|
|
53
|
+
"src/viewer/gates-overlay.js",
|
|
54
|
+
"tools/emit-manifest.mjs",
|
|
55
|
+
"tools/gates.mjs",
|
|
56
|
+
"tools/stamp-manifest.mjs",
|
|
57
|
+
];
|
|
58
|
+
|
|
59
|
+
/** Where new-asset.mjs records the hashes G13 compares against. */
|
|
60
|
+
export const PARITY_FILENAME = "viewer-parity.json";
|
|
61
|
+
|
|
62
|
+
export function sha256(input) {
|
|
63
|
+
return createHash("sha256")
|
|
64
|
+
.update(typeof input === "string" ? Buffer.from(input, "utf8") : input)
|
|
65
|
+
.digest("hex");
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* @param {string} projectDir
|
|
70
|
+
* @returns {Record<string, string | null>} sha256 per shared file; null when absent.
|
|
71
|
+
*/
|
|
72
|
+
export function hashSharedFiles(projectDir) {
|
|
73
|
+
/** @type {Record<string, string | null>} */
|
|
74
|
+
const out = {};
|
|
75
|
+
for (const rel of SHARED_FILES) {
|
|
76
|
+
const abs = path.join(projectDir, rel);
|
|
77
|
+
if (!fs.existsSync(abs)) {
|
|
78
|
+
out[rel] = null;
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
out[rel] = sha256(fs.readFileSync(abs));
|
|
82
|
+
}
|
|
83
|
+
return out;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// ---------------------------------------------------------------------------
|
|
87
|
+
// Source scanning (contract §4, gates G10/G11)
|
|
88
|
+
// ---------------------------------------------------------------------------
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Drop line and block comments, leaving string literals intact.
|
|
92
|
+
* The forbidden-identifier scan runs on the stripped text so the MIT header -
|
|
93
|
+
* which is a comment - can never trip a gate about `window` or a URL.
|
|
94
|
+
*/
|
|
95
|
+
export function stripComments(code) {
|
|
96
|
+
let out = "";
|
|
97
|
+
let state = "code"; // code | line | block | sq | dq | tpl
|
|
98
|
+
let i = 0;
|
|
99
|
+
while (i < code.length) {
|
|
100
|
+
const c = code[i];
|
|
101
|
+
const d = code[i + 1];
|
|
102
|
+
if (state === "code") {
|
|
103
|
+
if (c === "/" && d === "/") {
|
|
104
|
+
state = "line";
|
|
105
|
+
i += 2;
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
if (c === "/" && d === "*") {
|
|
109
|
+
state = "block";
|
|
110
|
+
i += 2;
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
if (c === "'") state = "sq";
|
|
114
|
+
else if (c === '"') state = "dq";
|
|
115
|
+
else if (c === "`") state = "tpl";
|
|
116
|
+
out += c;
|
|
117
|
+
i += 1;
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
if (state === "line") {
|
|
121
|
+
if (c === "\n") {
|
|
122
|
+
state = "code";
|
|
123
|
+
out += c;
|
|
124
|
+
}
|
|
125
|
+
i += 1;
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
if (state === "block") {
|
|
129
|
+
if (c === "*" && d === "/") {
|
|
130
|
+
state = "code";
|
|
131
|
+
i += 2;
|
|
132
|
+
} else {
|
|
133
|
+
if (c === "\n") out += c;
|
|
134
|
+
i += 1;
|
|
135
|
+
}
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
// inside a string literal
|
|
139
|
+
if (c === "\\") {
|
|
140
|
+
out += c + (d ?? "");
|
|
141
|
+
i += 2;
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
if ((state === "sq" && c === "'") || (state === "dq" && c === '"') || (state === "tpl" && c === "`")) {
|
|
145
|
+
state = "code";
|
|
146
|
+
}
|
|
147
|
+
out += c;
|
|
148
|
+
i += 1;
|
|
149
|
+
}
|
|
150
|
+
return out;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Every module specifier the file pulls in, static or dynamic. The contract
|
|
155
|
+
* allows exactly one: 'three'.
|
|
156
|
+
*/
|
|
157
|
+
export function parseImports(code) {
|
|
158
|
+
const src = stripComments(code);
|
|
159
|
+
const found = new Set();
|
|
160
|
+
const patterns = [
|
|
161
|
+
/\bimport\s[^;]*?\bfrom\s*['"]([^'"]+)['"]/g, // import x from 'y'
|
|
162
|
+
/\bimport\s*['"]([^'"]+)['"]/g, // import 'y'
|
|
163
|
+
/\bexport\s[^;]*?\bfrom\s*['"]([^'"]+)['"]/g, // export … from 'y'
|
|
164
|
+
/\bimport\s*\(\s*['"]([^'"]+)['"]\s*\)/g, // import('y')
|
|
165
|
+
/\brequire\s*\(\s*['"]([^'"]+)['"]\s*\)/g, // require('y')
|
|
166
|
+
];
|
|
167
|
+
for (const re of patterns) {
|
|
168
|
+
for (const m of src.matchAll(re)) found.add(m[1]);
|
|
169
|
+
}
|
|
170
|
+
return [...found].sort();
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* G11's banned-construct list, plus G10's "no bare Math.random()".
|
|
175
|
+
* Scanned on comment-stripped source.
|
|
176
|
+
*/
|
|
177
|
+
const FORBIDDEN = [
|
|
178
|
+
{ id: "document", re: /\bdocument\b/ },
|
|
179
|
+
{ id: "window", re: /\bwindow\b/ },
|
|
180
|
+
{ id: "fetch", re: /\bfetch\s*\(/ },
|
|
181
|
+
{ id: "XMLHttpRequest", re: /\bXMLHttpRequest\b/ },
|
|
182
|
+
{ id: "TextureLoader", re: /\bTextureLoader\b/ },
|
|
183
|
+
{ id: "CanvasTexture", re: /\bCanvasTexture\b/ },
|
|
184
|
+
{ id: "ImageBitmapLoader", re: /\bImageBitmapLoader\b/ },
|
|
185
|
+
{ id: "three/examples", re: /three\/examples/ },
|
|
186
|
+
{ id: "http-url", re: /\bhttps?:\/\// },
|
|
187
|
+
{ id: "ShaderMaterial", re: /\bShaderMaterial\b/ },
|
|
188
|
+
{ id: "onBeforeCompile", re: /\bonBeforeCompile\b/ },
|
|
189
|
+
{ id: "performance", re: /\bperformance\s*\./ },
|
|
190
|
+
];
|
|
191
|
+
|
|
192
|
+
const NON_ERASABLE = [
|
|
193
|
+
{ id: "enum", re: /(^|[^.\w])(?:const\s+)?enum\s+[A-Za-z_$]/ },
|
|
194
|
+
{ id: "namespace", re: /(^|[^.\w])namespace\s+[A-Za-z_$]/ },
|
|
195
|
+
{ id: "decorator", re: /^\s*@[A-Za-z_$][\w$]*\s*(\(|$)/m },
|
|
196
|
+
];
|
|
197
|
+
|
|
198
|
+
export function scanForbidden(code) {
|
|
199
|
+
const src = stripComments(code);
|
|
200
|
+
const hits = [];
|
|
201
|
+
for (const rule of FORBIDDEN) if (rule.re.test(src)) hits.push(rule.id);
|
|
202
|
+
for (const rule of NON_ERASABLE) if (rule.re.test(src)) hits.push(`non-erasable:${rule.id}`);
|
|
203
|
+
return hits;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export function usesBareMathRandom(code) {
|
|
207
|
+
return /\bMath\s*\.\s*random\s*\(/.test(stripComments(code));
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* The single exported factory. Contract §4.4: exactly one
|
|
212
|
+
* `export function create<PascalCase>Model`, plus its options interface.
|
|
213
|
+
*/
|
|
214
|
+
export function findFactory(code) {
|
|
215
|
+
const src = stripComments(code);
|
|
216
|
+
const names = [...src.matchAll(/export\s+function\s+(create[A-Z][A-Za-z0-9_]*Model)\s*\(/g)].map((m) => m[1]);
|
|
217
|
+
const unique = [...new Set(names)];
|
|
218
|
+
if (unique.length !== 1) {
|
|
219
|
+
throw new Error(
|
|
220
|
+
`expected exactly one "export function create<Name>Model", found ${unique.length}${unique.length ? ` (${unique.join(", ")})` : ""}`,
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
const optionsMatch = src.match(/export\s+interface\s+([A-Za-z0-9_]*Options)\b/);
|
|
224
|
+
return { entry: unique[0], optionsType: optionsMatch ? optionsMatch[1] : null };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** Locate the one asset module. There is exactly one per project, by contract. */
|
|
228
|
+
export function findAssetSource(projectDir) {
|
|
229
|
+
const dir = path.join(projectDir, "src", "asset");
|
|
230
|
+
const entries = fs.existsSync(dir) ? fs.readdirSync(dir).filter((f) => f.endsWith(".ts")) : [];
|
|
231
|
+
if (entries.length !== 1) {
|
|
232
|
+
throw new Error(`src/asset must hold exactly one .ts module, found ${entries.length}: ${entries.join(", ") || "none"}`);
|
|
233
|
+
}
|
|
234
|
+
const filename = entries[0];
|
|
235
|
+
const absPath = path.join(dir, filename);
|
|
236
|
+
const code = fs.readFileSync(absPath, "utf8");
|
|
237
|
+
return { absPath, filename, code };
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
export function readAssetConfig(projectDir) {
|
|
241
|
+
const abs = path.join(projectDir, "asset.config.json");
|
|
242
|
+
const config = JSON.parse(fs.readFileSync(abs, "utf8"));
|
|
243
|
+
const missing = ["slug", "name", "summary", "statedSizeMeters", "triBand"].filter((k) => config[k] == null);
|
|
244
|
+
if (missing.length) throw new Error(`asset.config.json is missing: ${missing.join(", ")}`);
|
|
245
|
+
return config;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// ---------------------------------------------------------------------------
|
|
249
|
+
// Building the manifest
|
|
250
|
+
// ---------------------------------------------------------------------------
|
|
251
|
+
|
|
252
|
+
export function pascalCase(slug) {
|
|
253
|
+
return slug
|
|
254
|
+
.split(/[^A-Za-z0-9]+/)
|
|
255
|
+
.filter(Boolean)
|
|
256
|
+
.map((p) => p[0].toUpperCase() + p.slice(1))
|
|
257
|
+
.join("");
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
export function camelCase(slug) {
|
|
261
|
+
const p = pascalCase(slug);
|
|
262
|
+
return p ? p[0].toLowerCase() + p.slice(1) : p;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
export function defaultPlayUrl(slug) {
|
|
266
|
+
return `https://preview--${slug}.genex.technology/`;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* The slug this build is actually SERVED under.
|
|
271
|
+
*
|
|
272
|
+
* `asset.config.json` names the asset; the Genex project names the URL, and the two
|
|
273
|
+
* are not the same string. The project slug is derived from the title at `genex
|
|
274
|
+
* init` and can pick up a uniquifier on a collision - "Street Fire Hydrant" becomes
|
|
275
|
+
* `street-fire-hydrant`, not `fire-hydrant`. Composing the play URL from the config
|
|
276
|
+
* slug therefore pointed 7 of the first 10 assets at an origin that does not exist,
|
|
277
|
+
* and the dashboard's "look at it turning" link was dead on every one of them.
|
|
278
|
+
*
|
|
279
|
+
* `.genex/project.json` is written by the CLI and holds the real slug, so read it
|
|
280
|
+
* when it is there and fall back to the config only for a project that has not been
|
|
281
|
+
* created yet. Keeping two slugs in sync by hand is not a fix; not needing to is.
|
|
282
|
+
*/
|
|
283
|
+
export function servedSlug(projectDir, configSlug) {
|
|
284
|
+
try {
|
|
285
|
+
const raw = fs.readFileSync(path.join(projectDir, ".genex", "project.json"), "utf8");
|
|
286
|
+
const slug = JSON.parse(raw)?.slug;
|
|
287
|
+
if (typeof slug === "string" && slug.length > 0) return slug;
|
|
288
|
+
} catch {
|
|
289
|
+
// Not linked yet (or unreadable): the config slug is the best guess available.
|
|
290
|
+
}
|
|
291
|
+
return configSlug;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function formatSize(size) {
|
|
295
|
+
return size.map((n) => n.toFixed(2)).join(" × ") + " m";
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
export function buildUsage(config, entry, filename) {
|
|
299
|
+
const varName = camelCase(config.slug);
|
|
300
|
+
const module = `./${filename.replace(/\.ts$/, "")}`;
|
|
301
|
+
const snippet = [
|
|
302
|
+
`import { ${entry} } from '${module}';`,
|
|
303
|
+
"",
|
|
304
|
+
`const ${varName} = ${entry}();`,
|
|
305
|
+
`${varName}.position.set(2, 0, -3);`,
|
|
306
|
+
`scene.add(${varName});`,
|
|
307
|
+
].join("\n");
|
|
308
|
+
const promptHint =
|
|
309
|
+
config.promptHint ??
|
|
310
|
+
`A ${formatSize(config.statedSizeMeters)} ${config.name.toLowerCase()}. Units are metres, Y-up, +Z forward, and the origin sits at the base centre on y = 0 - drop it straight onto your ground plane with position.set(x, groundY, z). Share one instance's materials if you place many, and call root.userData.dispose() when you remove it.`;
|
|
311
|
+
return { snippet, promptHint };
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Phase 1: everything knowable without a browser. `geometry` and the measured
|
|
316
|
+
* half of `space` stay null until stamp-manifest.mjs writes the gate results in.
|
|
317
|
+
*/
|
|
318
|
+
export function buildBaseManifest({ config, source, entry, optionsType, imports, generatedAt, playUrl }) {
|
|
319
|
+
const holder = config.license?.holder ?? "Genex";
|
|
320
|
+
const year = config.license?.year ?? new Date(generatedAt).getUTCFullYear();
|
|
321
|
+
const usage = buildUsage(config, entry, source.filename);
|
|
322
|
+
return {
|
|
323
|
+
schemaVersion: SCHEMA_VERSION,
|
|
324
|
+
kind: MANIFEST_KIND,
|
|
325
|
+
generatedAt,
|
|
326
|
+
asset: {
|
|
327
|
+
slug: config.slug,
|
|
328
|
+
name: config.name,
|
|
329
|
+
summary: config.summary,
|
|
330
|
+
tags: config.tags ?? [],
|
|
331
|
+
materialClass: config.materialClass ?? "unclassified",
|
|
332
|
+
geometryClass: config.geometryClass ?? "unclassified",
|
|
333
|
+
difficulty: config.difficulty ?? "easy",
|
|
334
|
+
version: config.version ?? "1.0.0",
|
|
335
|
+
},
|
|
336
|
+
license: {
|
|
337
|
+
id: "MIT",
|
|
338
|
+
holder,
|
|
339
|
+
year,
|
|
340
|
+
notice: LICENSE_NOTICE,
|
|
341
|
+
text: mitLicenseText(holder, year),
|
|
342
|
+
},
|
|
343
|
+
source: {
|
|
344
|
+
filename: source.filename,
|
|
345
|
+
language: "ts",
|
|
346
|
+
entry,
|
|
347
|
+
optionsType,
|
|
348
|
+
imports,
|
|
349
|
+
minThreeRevision: config.minThreeRevision ?? 160,
|
|
350
|
+
builtAgainstThree: BUILT_AGAINST_THREE,
|
|
351
|
+
bytes: Buffer.byteLength(source.code, "utf8"),
|
|
352
|
+
sha256: sha256(source.code),
|
|
353
|
+
code: source.code,
|
|
354
|
+
},
|
|
355
|
+
usage,
|
|
356
|
+
geometry: null,
|
|
357
|
+
space: {
|
|
358
|
+
units: "meters",
|
|
359
|
+
up: "+Y",
|
|
360
|
+
forward: "+Z",
|
|
361
|
+
originRule: "base-centre",
|
|
362
|
+
boundingBox: null,
|
|
363
|
+
sizeMeters: null,
|
|
364
|
+
statedSizeMeters: config.statedSizeMeters,
|
|
365
|
+
sizeDeltaPct: null,
|
|
366
|
+
},
|
|
367
|
+
gates: { runAt: null, runner: "asset-gates@1", allPassed: false, results: [] },
|
|
368
|
+
preview: {
|
|
369
|
+
playUrl: config.previewPlayUrl ?? playUrl ?? defaultPlayUrl(config.slug),
|
|
370
|
+
views: ["front", "three-quarter", "side", "top"],
|
|
371
|
+
},
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// ---------------------------------------------------------------------------
|
|
376
|
+
// Validation (G14). Mirrors the API's zod schema: tolerant of unknown keys,
|
|
377
|
+
// strict on required ones, every string length-capped.
|
|
378
|
+
// ---------------------------------------------------------------------------
|
|
379
|
+
|
|
380
|
+
// INVARIANT: no cap here may be LOOSER than the matching one in the API's
|
|
381
|
+
// assetManifestSchema (apps/api/src/assets/manifest.ts). A manifest that builds must
|
|
382
|
+
// always validate server-side, because the API answers an invalid one with 422 and
|
|
383
|
+
// the dashboard takes BOTH asset buttons off the page - a failure that surfaces in
|
|
384
|
+
// production rather than in the build that caused it. Stricter here is fine and is
|
|
385
|
+
// the safe direction: it fails where the author can see it.
|
|
386
|
+
//
|
|
387
|
+
// Four of these were looser and have been brought in line (filename 160→120,
|
|
388
|
+
// importName 120→80, url 400→300, gateName 64→60). Nothing shipped was near those
|
|
389
|
+
// windows, so this closes a latent gap rather than fixing a live break.
|
|
390
|
+
const CAPS = {
|
|
391
|
+
slug: 64,
|
|
392
|
+
name: 120,
|
|
393
|
+
summary: 400,
|
|
394
|
+
tag: 32,
|
|
395
|
+
tags: 8,
|
|
396
|
+
className: 48,
|
|
397
|
+
version: 32,
|
|
398
|
+
holder: 120,
|
|
399
|
+
notice: 400,
|
|
400
|
+
licenseText: 8000,
|
|
401
|
+
filename: 120,
|
|
402
|
+
entry: 120,
|
|
403
|
+
optionsType: 120,
|
|
404
|
+
imports: 8,
|
|
405
|
+
importName: 80,
|
|
406
|
+
code: 200_000,
|
|
407
|
+
snippet: 4000,
|
|
408
|
+
promptHint: 2000,
|
|
409
|
+
url: 300,
|
|
410
|
+
gateId: 8,
|
|
411
|
+
gateName: 60,
|
|
412
|
+
gateThreshold: 160,
|
|
413
|
+
gateResults: 64,
|
|
414
|
+
views: 12,
|
|
415
|
+
};
|
|
416
|
+
|
|
417
|
+
function isFiniteNumber(v) {
|
|
418
|
+
return typeof v === "number" && Number.isFinite(v);
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
function checkString(errors, value, at, cap, { required = true } = {}) {
|
|
422
|
+
if (value == null) {
|
|
423
|
+
if (required) errors.push(`${at}: required`);
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
426
|
+
if (typeof value !== "string") return void errors.push(`${at}: expected string`);
|
|
427
|
+
if (value.length === 0 && required) errors.push(`${at}: must not be empty`);
|
|
428
|
+
if (value.length > cap) errors.push(`${at}: longer than ${cap} chars (${value.length})`);
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
function checkVec3(errors, value, at, { required = true } = {}) {
|
|
432
|
+
if (value == null) {
|
|
433
|
+
if (required) errors.push(`${at}: required`);
|
|
434
|
+
return;
|
|
435
|
+
}
|
|
436
|
+
if (!Array.isArray(value) || value.length !== 3 || !value.every(isFiniteNumber)) {
|
|
437
|
+
errors.push(`${at}: expected 3 finite numbers`);
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
function checkCount(errors, value, at, { required = true } = {}) {
|
|
442
|
+
if (value == null) {
|
|
443
|
+
if (required) errors.push(`${at}: required`);
|
|
444
|
+
return;
|
|
445
|
+
}
|
|
446
|
+
if (!isFiniteNumber(value) || value < 0 || !Number.isInteger(value)) {
|
|
447
|
+
errors.push(`${at}: expected a non-negative integer`);
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/**
|
|
452
|
+
* @param {unknown} manifest
|
|
453
|
+
* @param {{ requireMeasured?: boolean, requireAllPassed?: boolean, bytes?: number }} [opts]
|
|
454
|
+
*/
|
|
455
|
+
export function validateManifest(manifest, opts = {}) {
|
|
456
|
+
const errors = [];
|
|
457
|
+
const requireMeasured = opts.requireMeasured ?? true;
|
|
458
|
+
if (typeof manifest !== "object" || manifest === null || Array.isArray(manifest)) {
|
|
459
|
+
return { ok: false, errors: ["manifest: expected an object"] };
|
|
460
|
+
}
|
|
461
|
+
const m = /** @type {Record<string, any>} */ (manifest);
|
|
462
|
+
|
|
463
|
+
if (m.schemaVersion !== SCHEMA_VERSION) errors.push(`schemaVersion: expected ${SCHEMA_VERSION}`);
|
|
464
|
+
if (m.kind !== MANIFEST_KIND) errors.push(`kind: expected "${MANIFEST_KIND}"`);
|
|
465
|
+
checkString(errors, m.generatedAt, "generatedAt", 40);
|
|
466
|
+
|
|
467
|
+
const a = m.asset;
|
|
468
|
+
if (typeof a !== "object" || a === null) errors.push("asset: required object");
|
|
469
|
+
else {
|
|
470
|
+
checkString(errors, a.slug, "asset.slug", CAPS.slug);
|
|
471
|
+
if (typeof a.slug === "string" && !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(a.slug)) {
|
|
472
|
+
errors.push("asset.slug: expected kebab-case");
|
|
473
|
+
}
|
|
474
|
+
checkString(errors, a.name, "asset.name", CAPS.name);
|
|
475
|
+
checkString(errors, a.summary, "asset.summary", CAPS.summary);
|
|
476
|
+
if (!Array.isArray(a.tags)) errors.push("asset.tags: expected an array");
|
|
477
|
+
else if (a.tags.length > CAPS.tags) errors.push(`asset.tags: more than ${CAPS.tags} entries`);
|
|
478
|
+
else a.tags.forEach((t, i) => checkString(errors, t, `asset.tags[${i}]`, CAPS.tag));
|
|
479
|
+
checkString(errors, a.materialClass, "asset.materialClass", CAPS.className);
|
|
480
|
+
checkString(errors, a.geometryClass, "asset.geometryClass", CAPS.className);
|
|
481
|
+
checkString(errors, a.difficulty, "asset.difficulty", CAPS.className);
|
|
482
|
+
checkString(errors, a.version, "asset.version", CAPS.version);
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
const l = m.license;
|
|
486
|
+
if (typeof l !== "object" || l === null) errors.push("license: required object");
|
|
487
|
+
else {
|
|
488
|
+
if (l.id !== "MIT") errors.push('license.id: expected "MIT"');
|
|
489
|
+
checkString(errors, l.holder, "license.holder", CAPS.holder);
|
|
490
|
+
if (!isFiniteNumber(l.year) || l.year < 2000 || l.year > 2100) errors.push("license.year: expected a plausible year");
|
|
491
|
+
checkString(errors, l.notice, "license.notice", CAPS.notice);
|
|
492
|
+
checkString(errors, l.text, "license.text", CAPS.licenseText);
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
const s = m.source;
|
|
496
|
+
if (typeof s !== "object" || s === null) errors.push("source: required object");
|
|
497
|
+
else {
|
|
498
|
+
checkString(errors, s.filename, "source.filename", CAPS.filename);
|
|
499
|
+
if (s.language !== "ts") errors.push('source.language: expected "ts"');
|
|
500
|
+
checkString(errors, s.entry, "source.entry", CAPS.entry);
|
|
501
|
+
checkString(errors, s.optionsType, "source.optionsType", CAPS.optionsType, { required: false });
|
|
502
|
+
if (!Array.isArray(s.imports)) errors.push("source.imports: expected an array");
|
|
503
|
+
else if (s.imports.length > CAPS.imports) errors.push(`source.imports: more than ${CAPS.imports} entries`);
|
|
504
|
+
else s.imports.forEach((v, i) => checkString(errors, v, `source.imports[${i}]`, CAPS.importName));
|
|
505
|
+
if (!isFiniteNumber(s.minThreeRevision)) errors.push("source.minThreeRevision: expected a number");
|
|
506
|
+
checkString(errors, s.builtAgainstThree, "source.builtAgainstThree", CAPS.version);
|
|
507
|
+
checkCount(errors, s.bytes, "source.bytes");
|
|
508
|
+
if (typeof s.sha256 !== "string" || !/^[0-9a-f]{64}$/.test(s.sha256)) errors.push("source.sha256: expected 64 hex chars");
|
|
509
|
+
checkString(errors, s.code, "source.code", CAPS.code);
|
|
510
|
+
if (typeof s.code === "string" && typeof s.bytes === "number" && Buffer.byteLength(s.code, "utf8") !== s.bytes) {
|
|
511
|
+
errors.push("source.bytes: does not match source.code");
|
|
512
|
+
}
|
|
513
|
+
if (typeof s.code === "string" && typeof s.sha256 === "string" && sha256(s.code) !== s.sha256) {
|
|
514
|
+
errors.push("source.sha256: does not match source.code");
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
const u = m.usage;
|
|
519
|
+
if (typeof u !== "object" || u === null) errors.push("usage: required object");
|
|
520
|
+
else {
|
|
521
|
+
checkString(errors, u.snippet, "usage.snippet", CAPS.snippet);
|
|
522
|
+
checkString(errors, u.promptHint, "usage.promptHint", CAPS.promptHint);
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
if (requireMeasured) {
|
|
526
|
+
const g = m.geometry;
|
|
527
|
+
if (typeof g !== "object" || g === null) errors.push("geometry: required object once stamped");
|
|
528
|
+
else {
|
|
529
|
+
for (const k of ["triangles", "vertices", "meshes", "instancedMeshes", "drawCalls", "materials", "textures", "textureBytes"]) {
|
|
530
|
+
checkCount(errors, g[k], `geometry.${k}`);
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
const sp = m.space;
|
|
536
|
+
if (typeof sp !== "object" || sp === null) errors.push("space: required object");
|
|
537
|
+
else {
|
|
538
|
+
if (sp.units !== "meters") errors.push('space.units: expected "meters"');
|
|
539
|
+
if (sp.up !== "+Y") errors.push('space.up: expected "+Y"');
|
|
540
|
+
if (sp.forward !== "+Z") errors.push('space.forward: expected "+Z"');
|
|
541
|
+
checkString(errors, sp.originRule, "space.originRule", CAPS.className);
|
|
542
|
+
checkVec3(errors, sp.statedSizeMeters, "space.statedSizeMeters");
|
|
543
|
+
if (requireMeasured) {
|
|
544
|
+
checkVec3(errors, sp.sizeMeters, "space.sizeMeters");
|
|
545
|
+
checkVec3(errors, sp.sizeDeltaPct, "space.sizeDeltaPct");
|
|
546
|
+
if (typeof sp.boundingBox !== "object" || sp.boundingBox === null) errors.push("space.boundingBox: required once stamped");
|
|
547
|
+
else {
|
|
548
|
+
checkVec3(errors, sp.boundingBox.min, "space.boundingBox.min");
|
|
549
|
+
checkVec3(errors, sp.boundingBox.max, "space.boundingBox.max");
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
const gt = m.gates;
|
|
555
|
+
if (typeof gt !== "object" || gt === null) errors.push("gates: required object");
|
|
556
|
+
else {
|
|
557
|
+
if (typeof gt.allPassed !== "boolean") errors.push("gates.allPassed: expected a boolean");
|
|
558
|
+
checkString(errors, gt.runner, "gates.runner", CAPS.gateName);
|
|
559
|
+
if (!Array.isArray(gt.results)) errors.push("gates.results: expected an array");
|
|
560
|
+
else if (gt.results.length > CAPS.gateResults) errors.push(`gates.results: more than ${CAPS.gateResults} entries`);
|
|
561
|
+
else {
|
|
562
|
+
gt.results.forEach((r, i) => {
|
|
563
|
+
if (typeof r !== "object" || r === null) return void errors.push(`gates.results[${i}]: expected an object`);
|
|
564
|
+
checkString(errors, r.id, `gates.results[${i}].id`, CAPS.gateId);
|
|
565
|
+
checkString(errors, r.name, `gates.results[${i}].name`, CAPS.gateName);
|
|
566
|
+
checkString(errors, r.threshold, `gates.results[${i}].threshold`, CAPS.gateThreshold);
|
|
567
|
+
if (typeof r.passed !== "boolean") errors.push(`gates.results[${i}].passed: expected a boolean`);
|
|
568
|
+
});
|
|
569
|
+
}
|
|
570
|
+
if (opts.requireAllPassed && gt.allPassed !== true) errors.push("gates.allPassed: expected true");
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
const p = m.preview;
|
|
574
|
+
if (typeof p !== "object" || p === null) errors.push("preview: required object");
|
|
575
|
+
else {
|
|
576
|
+
checkString(errors, p.playUrl, "preview.playUrl", CAPS.url);
|
|
577
|
+
if (typeof p.playUrl === "string" && !/^https:\/\//.test(p.playUrl)) errors.push("preview.playUrl: expected an https URL");
|
|
578
|
+
if (!Array.isArray(p.views)) errors.push("preview.views: expected an array");
|
|
579
|
+
else if (p.views.length > CAPS.views) errors.push(`preview.views: more than ${CAPS.views} entries`);
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
const bytes = opts.bytes ?? Buffer.byteLength(JSON.stringify(manifest), "utf8");
|
|
583
|
+
if (bytes > MANIFEST_MAX_BYTES) errors.push(`manifest: ${bytes} bytes exceeds the ${MANIFEST_MAX_BYTES} byte ceiling`);
|
|
584
|
+
|
|
585
|
+
return { ok: errors.length === 0, errors };
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
// ---------------------------------------------------------------------------
|
|
589
|
+
// The Vite plugin
|
|
590
|
+
// ---------------------------------------------------------------------------
|
|
591
|
+
|
|
592
|
+
/**
|
|
593
|
+
* Writes dist/genex-asset.json at closeBundle with everything knowable without
|
|
594
|
+
* a browser, and FAILS THE BUILD when the asset module breaks the contract -
|
|
595
|
+
* a bad import can therefore never reach a manifest.
|
|
596
|
+
*
|
|
597
|
+
* @param {{ projectDir?: string, outDir?: string }} [options]
|
|
598
|
+
*/
|
|
599
|
+
export function emitManifest(options = {}) {
|
|
600
|
+
let resolvedRoot = options.projectDir ?? process.cwd();
|
|
601
|
+
let resolvedOut = options.outDir ?? "dist";
|
|
602
|
+
return {
|
|
603
|
+
name: "genex-emit-asset-manifest",
|
|
604
|
+
apply: "build",
|
|
605
|
+
configResolved(config) {
|
|
606
|
+
if (!options.projectDir) resolvedRoot = config.root;
|
|
607
|
+
if (!options.outDir) resolvedOut = config.build.outDir;
|
|
608
|
+
},
|
|
609
|
+
closeBundle() {
|
|
610
|
+
const config = readAssetConfig(resolvedRoot);
|
|
611
|
+
const source = findAssetSource(resolvedRoot);
|
|
612
|
+
|
|
613
|
+
const imports = parseImports(source.code);
|
|
614
|
+
if (imports.length !== 1 || imports[0] !== "three") {
|
|
615
|
+
this.error(
|
|
616
|
+
`[genex-asset] ${source.filename} may import 'three' and nothing else (contract §4.2). Found: ${imports.join(", ") || "none"}`,
|
|
617
|
+
);
|
|
618
|
+
}
|
|
619
|
+
const forbidden = scanForbidden(source.code);
|
|
620
|
+
if (forbidden.length) {
|
|
621
|
+
this.error(`[genex-asset] ${source.filename} uses banned constructs (contract §4): ${forbidden.join(", ")}`);
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
let factory;
|
|
625
|
+
try {
|
|
626
|
+
factory = findFactory(source.code);
|
|
627
|
+
} catch (err) {
|
|
628
|
+
return void this.error(`[genex-asset] ${source.filename}: ${err.message}`);
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
const manifest = buildBaseManifest({
|
|
632
|
+
config,
|
|
633
|
+
source,
|
|
634
|
+
entry: factory.entry,
|
|
635
|
+
optionsType: factory.optionsType,
|
|
636
|
+
imports,
|
|
637
|
+
generatedAt: new Date().toISOString(),
|
|
638
|
+
// The URL must name the slug this build is SERVED under, which the CLI
|
|
639
|
+
// records in .genex/project.json and which is not necessarily the config's.
|
|
640
|
+
playUrl: defaultPlayUrl(servedSlug(resolvedRoot, config.slug)),
|
|
641
|
+
});
|
|
642
|
+
|
|
643
|
+
const outDir = path.isAbsolute(resolvedOut) ? resolvedOut : path.join(resolvedRoot, resolvedOut);
|
|
644
|
+
fs.mkdirSync(outDir, { recursive: true });
|
|
645
|
+
fs.writeFileSync(path.join(outDir, MANIFEST_FILENAME), JSON.stringify(manifest, null, 2) + "\n", "utf8");
|
|
646
|
+
|
|
647
|
+
const shape = validateManifest(manifest, { requireMeasured: false });
|
|
648
|
+
if (!shape.ok) this.error(`[genex-asset] emitted manifest is invalid: ${shape.errors.join("; ")}`);
|
|
649
|
+
},
|
|
650
|
+
};
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
export default emitManifest;
|