@stage5/lumine 0.2.7 → 0.2.8

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/lib/commands.js CHANGED
@@ -54,6 +54,7 @@ import {
54
54
  } from "./auth.js";
55
55
  import { probeUrl, requestJson } from "./http.js";
56
56
  import { sdkCommand } from "./sdk.js";
57
+ import { doctorCommand, normalizePreviewUrl } from "./doctor.js";
57
58
  import {
58
59
  defaultMainCheckoutDir,
59
60
  defaultReferenceDir,
@@ -191,6 +192,10 @@ export async function main() {
191
192
  await thumbnailCommand(options);
192
193
  return;
193
194
  }
195
+ if (options.command === "doctor") {
196
+ await doctorCommand(options);
197
+ return;
198
+ }
194
199
 
195
200
  printHelp();
196
201
  }
@@ -1501,6 +1506,9 @@ export function parseArgs(args) {
1501
1506
  "allowWrite",
1502
1507
  "main",
1503
1508
  "yes",
1509
+ "json",
1510
+ "keepAssets",
1511
+ "noBrowser",
1504
1512
  ]);
1505
1513
 
1506
1514
  for (let i = 0; i < rest.length; i += 1) {
@@ -1569,6 +1577,11 @@ export function parseArgs(args) {
1569
1577
  siteUrl: trimTrailingSlash(
1570
1578
  String(raw.siteUrl || process.env.TWINKLE_SITE_URL || DEFAULT_SITE_URL),
1571
1579
  ),
1580
+ previewUrl: normalizePreviewUrl(
1581
+ raw.previewUrl ||
1582
+ process.env.TWINKLE_PREVIEW_URL ||
1583
+ process.env.TWINKLE_BUILD_PREVIEW_URL,
1584
+ ),
1572
1585
  npmRegistryUrl: trimTrailingSlash(
1573
1586
  String(
1574
1587
  raw.npmRegistryUrl ||
@@ -1607,6 +1620,9 @@ export function parseArgs(args) {
1607
1620
  openBrowser: parseBoolean(raw.noOpen, false)
1608
1621
  ? false
1609
1622
  : parseBoolean(raw.open, true),
1623
+ json: parseBoolean(raw.json, false),
1624
+ keepAssets: parseBoolean(raw.keepAssets, false),
1625
+ noBrowser: parseBoolean(raw.noBrowser, false),
1610
1626
  updateCheck: parseBoolean(raw.noUpdateCheck, false) ? false : true,
1611
1627
  timeoutMs: Math.max(
1612
1628
  Number(raw.timeoutMs || process.env.TWINKLE_TIMEOUT_MS) ||
@@ -1778,6 +1794,7 @@ export function printHelp() {
1778
1794
  lumine thumbnail set <file>
1779
1795
  lumine thumbnail capture [--out <file>]
1780
1796
  lumine thumbnail generate ["<prompt>"] --model <gpt-image-2|nano-banana>
1797
+ lumine doctor runtime-assets
1781
1798
 
1782
1799
  Examples:
1783
1800
  npx @stage5/lumine@latest
@@ -1808,10 +1825,12 @@ Examples:
1808
1825
  npx @stage5/lumine@latest thumbnail set art/cover.png
1809
1826
  npx @stage5/lumine@latest thumbnail capture --out capture.png
1810
1827
  npx @stage5/lumine@latest thumbnail generate --model gpt-image-2 --yes
1828
+ npx @stage5/lumine@latest doctor runtime-assets --build 917 --json
1811
1829
 
1812
1830
  Options:
1813
1831
  --api-url <url> Twinkle API origin
1814
1832
  --site-url <url> Twinkle website origin
1833
+ --preview-url <url> Twinkle Build preview origin
1815
1834
  --auth-file <path> Saved login path
1816
1835
  --auth-token <token> Override saved login
1817
1836
  --dir <path> Directory for pulled project files
@@ -1833,6 +1852,9 @@ Options:
1833
1852
  --allow-write Permit sdk methods that mutate app data
1834
1853
  --path <api/...> Call an sdk endpoint not in the curated list
1835
1854
  --scopes <a,b> Override requested build API token scopes
1855
+ --json Print machine-readable output for doctor commands
1856
+ --keep-assets Keep doctor probe assets instead of deleting them
1857
+ --no-browser Skip doctor browser probes
1836
1858
  --model <model> Image model for generate: gpt-image-2 or nano-banana (required, no default)
1837
1859
  --quality <q> gpt-image-2 quality: low, medium, high (default high)
1838
1860
  --name <fileName> File name hint for a generated asset
package/lib/constants.js CHANGED
@@ -321,5 +321,6 @@ export const COMMANDS = new Set([
321
321
  "sdk",
322
322
  "assets",
323
323
  "thumbnail",
324
+ "doctor",
324
325
  "help",
325
326
  ]);
package/lib/doctor.js ADDED
@@ -0,0 +1,720 @@
1
+ import { createRequire } from "module";
2
+
3
+ import { buildApiJson, mintBuildApiToken } from "./api.js";
4
+ import { ensureAuth, assertAuthScope } from "./auth.js";
5
+ import { uploadRuntimeAsset } from "./assets.js";
6
+ import { requestJson } from "./http.js";
7
+ import { resolveSdkBuildId } from "./sdk.js";
8
+ import { formatBytes, trimTrailingSlash } from "./util.js";
9
+
10
+ const DEFAULT_PREVIEW_URL = "https://preview.lumine.app";
11
+ const DOCTOR_SOURCE = "lumine-runtime-assets-doctor";
12
+ const PREVIEW_IFRAME_SANDBOX = "allow-scripts allow-downloads allow-pointer-lock";
13
+
14
+ export async function doctorCommand(options) {
15
+ const topic = String(options.positional[0] || "runtime-assets").trim();
16
+ if (topic !== "runtime-assets") {
17
+ throw new Error("Usage: lumine doctor runtime-assets [--build <id>] [--json] [--keep-assets] [--no-browser]");
18
+ }
19
+ await runtimeAssetsDoctor(options);
20
+ }
21
+
22
+ export async function runtimeAssetsDoctor(options) {
23
+ const buildId = await resolveSdkBuildId(options);
24
+ const auth = await ensureAuth(options);
25
+ await assertAuthScope({ options, auth, scope: "build:sdk" });
26
+ await assertAuthScope({ options, auth, scope: "build:check" });
27
+
28
+ const { token: buildApiToken } = await mintBuildApiToken({
29
+ options,
30
+ auth,
31
+ buildId,
32
+ scopes: ["files:read", "files:write", "preview:read"],
33
+ });
34
+
35
+ const result = {
36
+ ok: false,
37
+ buildId,
38
+ checkedAt: new Date().toISOString(),
39
+ uploadedAssets: [],
40
+ cloudfront: [],
41
+ previewSession: null,
42
+ browser: null,
43
+ cleanup: [],
44
+ };
45
+ const uploadedAssets = [];
46
+
47
+ try {
48
+ const candidates = makeRuntimeAssetProbeCandidates();
49
+ for (const candidate of candidates) {
50
+ const asset = await uploadRuntimeAsset({
51
+ options,
52
+ auth,
53
+ buildId,
54
+ buildApiToken,
55
+ candidate,
56
+ });
57
+ uploadedAssets.push(asset);
58
+ result.uploadedAssets.push(formatAssetForReport(asset));
59
+ }
60
+
61
+ for (const asset of uploadedAssets) {
62
+ result.cloudfront.push(await probeCloudFrontAsset(asset, options));
63
+ }
64
+
65
+ if (options.noBrowser) {
66
+ result.previewSession = {
67
+ ok: false,
68
+ skipped: true,
69
+ reason: "browser_probe_disabled",
70
+ };
71
+ result.browser = {
72
+ ok: false,
73
+ skipped: true,
74
+ reason: "browser_probe_disabled",
75
+ };
76
+ } else {
77
+ result.previewSession = await createRuntimeAssetsPreviewSession({
78
+ options,
79
+ auth,
80
+ buildId,
81
+ hdrAsset: findAssetByExtension(uploadedAssets, ".hdr"),
82
+ glbAsset: findAssetByExtension(uploadedAssets, ".glb"),
83
+ });
84
+ result.browser = await runBrowserLoaderProbe({
85
+ siteUrl: options.siteUrl,
86
+ previewUrl: options.previewUrl,
87
+ previewEntryUrl: result.previewSession.previewEntryUrl,
88
+ timeoutMs: options.timeoutMs,
89
+ });
90
+ }
91
+
92
+ result.headersOk = cloudFrontRequiredProbesPass(result.cloudfront);
93
+ result.complete = Boolean(result.browser?.ok);
94
+ result.ok = result.headersOk && (options.noBrowser || result.complete);
95
+ } catch (error) {
96
+ result.ok = false;
97
+ result.headersOk = cloudFrontRequiredProbesPass(result.cloudfront);
98
+ result.complete = false;
99
+ result.error = {
100
+ message: error?.message || String(error),
101
+ status: error?.status || null,
102
+ data: error?.data || null,
103
+ };
104
+ if (!result.previewSession) {
105
+ result.previewSession = {
106
+ ok: false,
107
+ error: result.error.message,
108
+ status: result.error.status,
109
+ };
110
+ }
111
+ } finally {
112
+ if (!options.keepAssets) {
113
+ for (const asset of uploadedAssets) {
114
+ result.cleanup.push(
115
+ await deleteRuntimeAssetQuietly({
116
+ options,
117
+ auth,
118
+ buildId,
119
+ buildApiToken,
120
+ asset,
121
+ }),
122
+ );
123
+ }
124
+ } else {
125
+ result.cleanup.push({
126
+ skipped: true,
127
+ reason: "keep_assets",
128
+ assetIds: uploadedAssets.map((asset) => Number(asset.id || 0)).filter(Boolean),
129
+ });
130
+ }
131
+ }
132
+
133
+ printRuntimeAssetsDoctorResult(result, options);
134
+ if (!result.ok) {
135
+ process.exitCode = 1;
136
+ }
137
+ }
138
+
139
+ function makeRuntimeAssetProbeCandidates() {
140
+ const stamp = new Date().toISOString().replace(/[^0-9A-Za-z]+/g, "-");
141
+ return [
142
+ {
143
+ absolutePath: null,
144
+ fileName: `lumine-doctor-env-${stamp}.hdr`,
145
+ mimeType: "image/vnd.radiance",
146
+ buffer: makeMinimalHdr(),
147
+ },
148
+ {
149
+ absolutePath: null,
150
+ fileName: `lumine-doctor-scene-${stamp}.glb`,
151
+ mimeType: "model/gltf-binary",
152
+ buffer: makeMinimalGlb(),
153
+ },
154
+ ];
155
+ }
156
+
157
+ function makeMinimalHdr() {
158
+ const header = "#?RADIANCE\nFORMAT=32-bit_rle_rgbe\n\n-Y 1 +X 1\n";
159
+ return Buffer.concat([
160
+ Buffer.from(header, "ascii"),
161
+ Buffer.from([255, 255, 255, 128]),
162
+ ]);
163
+ }
164
+
165
+ function makeMinimalGlb() {
166
+ const json = JSON.stringify({
167
+ asset: { version: "2.0", generator: "lumine-runtime-assets-doctor" },
168
+ scene: 0,
169
+ scenes: [{ nodes: [] }],
170
+ nodes: [],
171
+ });
172
+ const jsonPadding = (4 - (Buffer.byteLength(json) % 4)) % 4;
173
+ const jsonChunk = Buffer.concat([
174
+ Buffer.from(json, "utf8"),
175
+ Buffer.alloc(jsonPadding, 0x20),
176
+ ]);
177
+ const totalLength = 12 + 8 + jsonChunk.length;
178
+ const buffer = Buffer.alloc(totalLength);
179
+ buffer.writeUInt32LE(0x46546c67, 0);
180
+ buffer.writeUInt32LE(2, 4);
181
+ buffer.writeUInt32LE(totalLength, 8);
182
+ buffer.writeUInt32LE(jsonChunk.length, 12);
183
+ buffer.writeUInt32LE(0x4e4f534a, 16);
184
+ jsonChunk.copy(buffer, 20);
185
+ return buffer;
186
+ }
187
+
188
+ function formatAssetForReport(asset) {
189
+ return {
190
+ id: Number(asset?.id || 0),
191
+ fileName: asset?.fileName || "",
192
+ originalFileName: asset?.originalFileName || asset?.fileName || "",
193
+ mimeType: asset?.mimeType || null,
194
+ sizeBytes: Number(asset?.sizeBytes || 0),
195
+ url: asset?.url || "",
196
+ };
197
+ }
198
+
199
+ function findAssetByExtension(assets, extension) {
200
+ return assets.find((asset) =>
201
+ String(asset?.fileName || asset?.originalFileName || "")
202
+ .toLowerCase()
203
+ .endsWith(extension),
204
+ );
205
+ }
206
+
207
+ async function probeCloudFrontAsset(asset, options) {
208
+ const probes = buildCloudFrontProbeSpecs(options);
209
+ const requests = [];
210
+ for (const probe of probes) {
211
+ requests.push(await probeCloudFrontGet(asset.url, probe, options.timeoutMs));
212
+ }
213
+ return {
214
+ asset: formatAssetForReport(asset),
215
+ requests,
216
+ };
217
+ }
218
+
219
+ function buildCloudFrontProbeSpecs(options) {
220
+ const probes = [
221
+ {
222
+ label: "opaque sandbox",
223
+ origin: "null",
224
+ required: true,
225
+ },
226
+ {
227
+ label: "preview origin",
228
+ origin: normalizeOrigin(options.previewUrl),
229
+ required: false,
230
+ },
231
+ {
232
+ label: "site origin",
233
+ origin: normalizeOrigin(options.siteUrl),
234
+ required: false,
235
+ },
236
+ {
237
+ label: "no Origin",
238
+ origin: null,
239
+ required: false,
240
+ },
241
+ ];
242
+ const merged = new Map();
243
+ for (const probe of probes) {
244
+ if (probe.origin === "") continue;
245
+ const key = probe.origin === null ? "no-origin" : probe.origin;
246
+ const existing = merged.get(key);
247
+ if (!existing) {
248
+ merged.set(key, probe);
249
+ continue;
250
+ }
251
+ existing.required = existing.required || probe.required;
252
+ existing.label = existing.required ? existing.label : probe.label;
253
+ }
254
+ return Array.from(merged.values());
255
+ }
256
+
257
+ function normalizeOrigin(value) {
258
+ try {
259
+ return new URL(String(value || "")).origin;
260
+ } catch {
261
+ return "";
262
+ }
263
+ }
264
+
265
+ function cloudFrontRequiredProbesPass(cloudfront) {
266
+ if (!cloudfront.length) return false;
267
+ return cloudfront.every((entry) =>
268
+ entry.requests.filter((request) => request.required).length > 0 &&
269
+ entry.requests
270
+ .filter((request) => request.required)
271
+ .every((request) => request.ok && request.corsAllowed),
272
+ );
273
+ }
274
+
275
+ async function probeCloudFrontGet(url, probe, timeoutMs) {
276
+ const controller = new AbortController();
277
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
278
+ const startedAt = Date.now();
279
+ try {
280
+ const response = await fetch(url, {
281
+ method: "GET",
282
+ headers: probe.origin ? { origin: probe.origin } : {},
283
+ signal: controller.signal,
284
+ });
285
+ const body = await response.arrayBuffer();
286
+ const headers = pickHeaders(response.headers);
287
+ const corsAllowed = accessControlAllowOriginMatches({
288
+ headers,
289
+ origin: probe.origin,
290
+ });
291
+ return {
292
+ label: probe.label,
293
+ origin: probe.origin,
294
+ required: probe.required,
295
+ ok: response.ok,
296
+ status: response.status,
297
+ ms: Date.now() - startedAt,
298
+ bytes: body.byteLength,
299
+ hasAccessControlAllowOrigin: Boolean(headers["access-control-allow-origin"]),
300
+ corsAllowed,
301
+ headers,
302
+ };
303
+ } catch (error) {
304
+ return {
305
+ label: probe.label,
306
+ origin: probe.origin,
307
+ required: probe.required,
308
+ ok: false,
309
+ status: 0,
310
+ ms: Date.now() - startedAt,
311
+ bytes: 0,
312
+ hasAccessControlAllowOrigin: false,
313
+ corsAllowed: false,
314
+ error: error?.message || String(error),
315
+ headers: {},
316
+ };
317
+ } finally {
318
+ clearTimeout(timeout);
319
+ }
320
+ }
321
+
322
+ function accessControlAllowOriginMatches({ headers, origin }) {
323
+ const acao = String(headers["access-control-allow-origin"] || "").trim();
324
+ if (!acao) return false;
325
+ // No-Origin requests are telemetry only. They are not browser CORS checks, so
326
+ // report ACAO presence but never let this path affect required pass/fail.
327
+ if (origin === null) return true;
328
+ return acao === "*" || acao === origin;
329
+ }
330
+
331
+ function pickHeaders(headers) {
332
+ const keys = [
333
+ "access-control-allow-origin",
334
+ "access-control-allow-methods",
335
+ "access-control-expose-headers",
336
+ "content-type",
337
+ "content-length",
338
+ "cache-control",
339
+ "etag",
340
+ "x-cache",
341
+ "via",
342
+ "x-amz-cf-pop",
343
+ ];
344
+ const picked = {};
345
+ for (const key of keys) {
346
+ const value = headers.get(key);
347
+ if (value !== null) picked[key] = value;
348
+ }
349
+ return picked;
350
+ }
351
+
352
+ async function createRuntimeAssetsPreviewSession({
353
+ options,
354
+ auth,
355
+ buildId,
356
+ hdrAsset,
357
+ glbAsset,
358
+ }) {
359
+ if (!hdrAsset?.url || !glbAsset?.url) {
360
+ throw new Error("Runtime asset probe did not produce both .hdr and .glb URLs.");
361
+ }
362
+ const session = await requestJson({
363
+ method: "POST",
364
+ url: `${options.apiUrl}/build/${buildId}/preview-session`,
365
+ authToken: auth.token,
366
+ body: {
367
+ entryPath: "/index.html",
368
+ files: [
369
+ {
370
+ path: "/index.html",
371
+ content: buildRuntimeAssetsProbeHtml({
372
+ hdrUrl: hdrAsset.url,
373
+ glbUrl: glbAsset.url,
374
+ }),
375
+ },
376
+ ],
377
+ },
378
+ timeoutMs: options.timeoutMs,
379
+ });
380
+ const entryUrl = String(session?.entryUrl || "");
381
+ if (!entryUrl) {
382
+ throw new Error("Twinkle did not return a preview-session entry URL.");
383
+ }
384
+ return {
385
+ ok: true,
386
+ sessionId: session.sessionId || null,
387
+ entryUrl,
388
+ previewEntryUrl: `${options.previewUrl}${entryUrl}`,
389
+ };
390
+ }
391
+
392
+ function buildRuntimeAssetsProbeHtml({ hdrUrl, glbUrl }) {
393
+ return `<!doctype html>
394
+ <html>
395
+ <head>
396
+ <meta charset="utf-8">
397
+ <title>Lumine Runtime Assets Doctor</title>
398
+ </head>
399
+ <body>
400
+ <script type="module">
401
+ const source = ${JSON.stringify(DOCTOR_SOURCE)};
402
+ const hdrUrl = ${JSON.stringify(hdrUrl)};
403
+ const glbUrl = ${JSON.stringify(glbUrl)};
404
+
405
+ function headersToObject(headers) {
406
+ const result = {};
407
+ for (const [key, value] of headers.entries()) {
408
+ result[key.toLowerCase()] = value;
409
+ }
410
+ return result;
411
+ }
412
+
413
+ async function fetchAsset(url) {
414
+ const response = await fetch(url, {
415
+ method: 'GET',
416
+ mode: 'cors',
417
+ cache: 'no-store',
418
+ credentials: 'omit'
419
+ });
420
+ const body = await response.arrayBuffer();
421
+ return {
422
+ ok: response.ok,
423
+ status: response.status,
424
+ type: response.type,
425
+ url: response.url,
426
+ bytes: body.byteLength,
427
+ headers: headersToObject(response.headers)
428
+ };
429
+ }
430
+
431
+ async function loadHdr(url) {
432
+ const { RGBELoader } = await import('/build/vendor/three/0.184.0/addons/loaders/RGBELoader.js');
433
+ const texture = await new Promise((resolve, reject) => {
434
+ new RGBELoader().load(url, resolve, undefined, reject);
435
+ });
436
+ return {
437
+ ok: true,
438
+ width: texture && texture.image ? texture.image.width || null : null,
439
+ height: texture && texture.image ? texture.image.height || null : null,
440
+ type: texture ? texture.type || null : null
441
+ };
442
+ }
443
+
444
+ async function loadGlb(url) {
445
+ const { GLTFLoader } = await import('/build/vendor/three/0.184.0/addons/loaders/GLTFLoader.js');
446
+ const gltf = await new Promise((resolve, reject) => {
447
+ new GLTFLoader().load(url, resolve, undefined, reject);
448
+ });
449
+ return {
450
+ ok: true,
451
+ sceneType: gltf && gltf.scene ? gltf.scene.type || null : null,
452
+ sceneChildren: gltf && gltf.scene && gltf.scene.children ? gltf.scene.children.length : null,
453
+ scenes: gltf && gltf.scenes ? gltf.scenes.length : null
454
+ };
455
+ }
456
+
457
+ async function run() {
458
+ const result = {
459
+ source,
460
+ ok: false,
461
+ href: location.href,
462
+ origin: location.origin,
463
+ crossOriginIsolated: window.crossOriginIsolated === true,
464
+ hdrFetch: null,
465
+ glbFetch: null,
466
+ hdrLoader: null,
467
+ glbLoader: null
468
+ };
469
+ try {
470
+ result.hdrFetch = await fetchAsset(hdrUrl);
471
+ result.glbFetch = await fetchAsset(glbUrl);
472
+ result.hdrLoader = await loadHdr(hdrUrl).catch((error) => ({
473
+ ok: false,
474
+ name: error && error.name ? error.name : null,
475
+ error: error && error.message ? error.message : String(error)
476
+ }));
477
+ result.glbLoader = await loadGlb(glbUrl).catch((error) => ({
478
+ ok: false,
479
+ name: error && error.name ? error.name : null,
480
+ error: error && error.message ? error.message : String(error)
481
+ }));
482
+ result.ok = Boolean(
483
+ result.hdrFetch && result.hdrFetch.ok &&
484
+ result.glbFetch && result.glbFetch.ok &&
485
+ result.hdrLoader && result.hdrLoader.ok &&
486
+ result.glbLoader && result.glbLoader.ok
487
+ );
488
+ } catch (error) {
489
+ result.error = error && error.message ? error.message : String(error);
490
+ }
491
+ window.parent.postMessage(result, '*');
492
+ }
493
+
494
+ run();
495
+ </script>
496
+ </body>
497
+ </html>`;
498
+ }
499
+
500
+ async function runBrowserLoaderProbe({
501
+ siteUrl,
502
+ previewUrl,
503
+ previewEntryUrl,
504
+ timeoutMs,
505
+ }) {
506
+ const playwright = await loadPlaywright();
507
+ if (!playwright?.chromium) {
508
+ return {
509
+ ok: false,
510
+ skipped: true,
511
+ reason: "playwright_not_available",
512
+ message:
513
+ "Install Playwright or set LUMINE_PLAYWRIGHT_MODULE to enable the sandboxed loader probe.",
514
+ previewEntryUrl,
515
+ };
516
+ }
517
+
518
+ const browser = await playwright.chromium.launch({ headless: true });
519
+ const page = await browser.newPage({
520
+ viewport: { width: 1280, height: 900 },
521
+ });
522
+ const consoleMessages = [];
523
+ page.on("console", (message) => {
524
+ consoleMessages.push({
525
+ type: message.type(),
526
+ text: message.text().slice(0, 500),
527
+ });
528
+ });
529
+
530
+ try {
531
+ const harnessUrl = buildBrowserHarnessUrl(siteUrl);
532
+ await page.route(harnessUrl, async (route) => {
533
+ await route.fulfill({
534
+ status: 200,
535
+ contentType: "text/html; charset=utf-8",
536
+ headers: { "cache-control": "no-store" },
537
+ body: buildOuterSandboxHarness(previewEntryUrl),
538
+ });
539
+ });
540
+ await page.goto(harnessUrl, {
541
+ waitUntil: "domcontentloaded",
542
+ timeout: timeoutMs,
543
+ });
544
+ const loaderResult = await waitForBrowserProbeResult(page, timeoutMs);
545
+ return {
546
+ ok: Boolean(loaderResult?.ok),
547
+ harnessUrl,
548
+ previewOrigin: previewUrl,
549
+ previewEntryUrl,
550
+ sandbox: PREVIEW_IFRAME_SANDBOX,
551
+ result: loaderResult,
552
+ consoleMessages,
553
+ };
554
+ } finally {
555
+ await browser.close();
556
+ }
557
+ }
558
+
559
+ function buildBrowserHarnessUrl(siteUrl) {
560
+ const url = new URL("/__lumine-doctor/runtime-assets-harness", siteUrl);
561
+ url.searchParams.set("t", String(Date.now()));
562
+ return url.toString();
563
+ }
564
+
565
+ async function waitForBrowserProbeResult(page, timeoutMs) {
566
+ try {
567
+ await page.waitForFunction(
568
+ () => Boolean(window.__lumineRuntimeAssetsDoctorResult),
569
+ null,
570
+ { timeout: timeoutMs },
571
+ );
572
+ return await page.evaluate(() => window.__lumineRuntimeAssetsDoctorResult);
573
+ } catch (error) {
574
+ return {
575
+ ok: false,
576
+ error: error?.message || "Timed out waiting for runtime asset probe message.",
577
+ };
578
+ }
579
+ }
580
+
581
+ async function loadPlaywright() {
582
+ const require = createRequire(import.meta.url);
583
+ const candidates = [
584
+ process.env.LUMINE_PLAYWRIGHT_MODULE,
585
+ "playwright",
586
+ ].filter(Boolean);
587
+ for (const candidate of candidates) {
588
+ try {
589
+ return require(candidate);
590
+ } catch {
591
+ // Try the next candidate.
592
+ }
593
+ }
594
+ return null;
595
+ }
596
+
597
+ function buildOuterSandboxHarness(previewEntryUrl) {
598
+ return `<!doctype html>
599
+ <html>
600
+ <head>
601
+ <meta charset="utf-8">
602
+ <title>Lumine Runtime Assets Doctor Harness</title>
603
+ </head>
604
+ <body>
605
+ <script>
606
+ window.__lumineRuntimeAssetsDoctorResult = null;
607
+ window.addEventListener('message', (event) => {
608
+ if (!event || !event.data || event.data.source !== ${JSON.stringify(DOCTOR_SOURCE)}) return;
609
+ window.__lumineRuntimeAssetsDoctorResult = event.data;
610
+ });
611
+ </script>
612
+ <iframe
613
+ title="Lumine runtime assets doctor"
614
+ sandbox="${PREVIEW_IFRAME_SANDBOX}"
615
+ src="${escapeHtmlAttribute(previewEntryUrl)}"
616
+ style="width: 800px; height: 600px; border: 0;"
617
+ ></iframe>
618
+ </body>
619
+ </html>`;
620
+ }
621
+
622
+ function escapeHtmlAttribute(value) {
623
+ return String(value || "")
624
+ .replace(/&/g, "&amp;")
625
+ .replace(/"/g, "&quot;")
626
+ .replace(/</g, "&lt;")
627
+ .replace(/>/g, "&gt;");
628
+ }
629
+
630
+ async function deleteRuntimeAssetQuietly({
631
+ options,
632
+ auth,
633
+ buildId,
634
+ buildApiToken,
635
+ asset,
636
+ }) {
637
+ const assetId = Number(asset?.id || 0);
638
+ if (!assetId) {
639
+ return { ok: false, assetId, error: "missing_asset_id" };
640
+ }
641
+ try {
642
+ await buildApiJson({
643
+ options,
644
+ auth,
645
+ buildId,
646
+ buildApiToken,
647
+ endpointPath: "api/files/delete",
648
+ body: { assetId },
649
+ });
650
+ return { ok: true, assetId };
651
+ } catch (error) {
652
+ return {
653
+ ok: false,
654
+ assetId,
655
+ status: error?.status || null,
656
+ error: error?.message || String(error),
657
+ };
658
+ }
659
+ }
660
+
661
+ function printRuntimeAssetsDoctorResult(result, options) {
662
+ if (options.json) {
663
+ console.log(JSON.stringify(result, null, 2));
664
+ return;
665
+ }
666
+
667
+ console.log(`Runtime asset doctor for Build #${result.buildId}`);
668
+ for (const asset of result.uploadedAssets) {
669
+ console.log(
670
+ `- uploaded #${asset.id} ${asset.originalFileName} ${formatBytes(asset.sizeBytes)}`,
671
+ );
672
+ console.log(` ${asset.url}`);
673
+ }
674
+
675
+ for (const entry of result.cloudfront) {
676
+ console.log(`- CloudFront ${entry.asset.originalFileName}`);
677
+ for (const request of entry.requests) {
678
+ const origin = request.origin === null ? "(no Origin)" : request.origin;
679
+ const acao = request.headers["access-control-allow-origin"] || "(missing)";
680
+ const contentType = request.headers["content-type"] || "(missing)";
681
+ const required = request.required ? "required" : "info";
682
+ const cors = request.corsAllowed ? "allowed" : "blocked";
683
+ console.log(
684
+ ` GET ${request.label} [${required}] Origin ${origin}: ` +
685
+ `${request.ok ? "ok" : "fail"} ${request.status} ` +
686
+ `ACAO=${acao} CORS=${cors} Content-Type=${contentType} bytes=${request.bytes}`,
687
+ );
688
+ }
689
+ }
690
+
691
+ if (result.previewSession?.previewEntryUrl) {
692
+ console.log(`- preview session: ${result.previewSession.previewEntryUrl}`);
693
+ }
694
+ if (result.error?.message) {
695
+ const status = result.error.status ? ` (${result.error.status})` : "";
696
+ console.log(`- error: ${result.error.message}${status}`);
697
+ }
698
+ if (result.browser?.skipped) {
699
+ console.log(`- browser loader probe: skipped (${result.browser.reason})`);
700
+ if (result.browser.message) console.log(` ${result.browser.message}`);
701
+ } else if (result.browser) {
702
+ console.log(`- browser loader probe: ${result.browser.ok ? "ok" : "fail"}`);
703
+ const loader = result.browser.result || {};
704
+ console.log(` HDRLoader: ${loader.hdrLoader?.ok ? "ok" : "fail"}`);
705
+ console.log(` GLTFLoader: ${loader.glbLoader?.ok ? "ok" : "fail"}`);
706
+ }
707
+ for (const cleanup of result.cleanup) {
708
+ if (cleanup.skipped) {
709
+ console.log(`- cleanup: skipped (${cleanup.reason})`);
710
+ } else {
711
+ console.log(`- cleanup asset #${cleanup.assetId}: ${cleanup.ok ? "ok" : "fail"}`);
712
+ }
713
+ }
714
+ const suffix = result.ok && !result.complete ? " (browser skipped)" : "";
715
+ console.log(`Result: ${result.ok ? "ok" : "fail"}${suffix}`);
716
+ }
717
+
718
+ export function normalizePreviewUrl(value) {
719
+ return trimTrailingSlash(String(value || DEFAULT_PREVIEW_URL));
720
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stage5/lumine",
3
- "version": "0.2.7",
3
+ "version": "0.2.8",
4
4
  "description": "Command line tools for launching Lumine builds on Twinkle.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,8 +1,8 @@
1
1
  # Build SDK Index
2
2
 
3
- Version: 1.26.2
4
- Updated: 2026-07-05
5
- Generated: 2026-07-05T06:47:32.349Z
3
+ Version: 1.26.3
4
+ Updated: 2026-07-08
5
+ Generated: 2026-07-08T09:25:17.959Z
6
6
 
7
7
  ## Notes
8
8
  - This SDK is injected into Build iframes via the Build preview/runtime.
@@ -22,12 +22,27 @@ Generated: 2026-07-05T06:47:32.349Z
22
22
  - Use Twinkle.characters.chat for real Zero/Ciel NPC dialogue with shared room context and AI Energy-aware thinking modes.
23
23
  - Twinkle.ai.chat history entries must use { role, content }; map local message.text fields to content before passing history.
24
24
  - Interface text must not be selectable on touch devices: apply user-select: none plus -webkit-user-select: none and -webkit-touch-callout: none to interface text (HUD, buttons, labels, menus, scores, game controls) so mobile long-press does not highlight UI. Keep text inputs and genuinely user-copyable content selectable.
25
+ - Build app tab mute is enforced by the host runtime automatically for standard media elements and Web Audio connections to AudioContext.destination. Apps with custom audio engines can also observe Twinkle.onAudioMuteChange and check Twinkle.isAudioMuted.
25
26
 
26
27
  ## Token Scopes
27
28
  files:read, user:read, users:read, dailyReflections:read, content:read, sharedDb:read, sharedDb:write, privateDb:read, privateDb:write, files:write, chat:read, chat:write, notifications:read, notifications:write, notifications:emit, reminders:read, reminders:write
28
29
 
29
30
  ## Namespaces
30
31
 
32
+ ### Twinkle
33
+ - isAudioMuted() | scopes: none
34
+ - Returns: boolean
35
+ - Returns whether the host runtime currently has this Build app tab muted.
36
+ - The host automatically mutes standard <audio>/<video> elements and Web Audio nodes connected to AudioContext.destination.
37
+ - Use this only when your app manages audio outside those standard paths.
38
+ - Example: if (Twinkle.isAudioMuted()) pauseCustomMixerOutput();
39
+ - onAudioMuteChange(listener, options?) | scopes: none
40
+ - Returns: unsubscribe function
41
+ - Subscribe to host tab mute changes for custom audio engines.
42
+ - The listener receives the current muted boolean immediately by default.
43
+ - Pass { immediate: false } to skip the initial callback.
44
+ - Example: const unsubscribe = Twinkle.onAudioMuteChange((muted) => customMixer.setMuted(muted));
45
+
31
46
  ### Twinkle.capabilities
32
47
  - async get() | scopes: none
33
48
  - Returns: Capability snapshot