@stage5/lumine 0.2.3 → 0.2.5

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/assets.js ADDED
@@ -0,0 +1,670 @@
1
+ import fs from "fs/promises";
2
+ import path from "path";
3
+ import readline from "readline/promises";
4
+ import { stdin as input, stdout as output } from "process";
5
+
6
+ import {
7
+ ASSET_MIME_BY_EXTENSION,
8
+ ASSET_UPLOAD_CHUNK_BYTES,
9
+ ASSETS_METADATA_FILE,
10
+ PROJECT_METADATA_DIR,
11
+ RUNTIME_ASSET_REFERENCE_PATTERN,
12
+ } from "./constants.js";
13
+ import {
14
+ buildApiJson,
15
+ fetchAllRuntimeAssets,
16
+ loadBuildFiles,
17
+ mintBuildApiToken,
18
+ } from "./api.js";
19
+ import { assertAuthScope, ensureAuth } from "./auth.js";
20
+ import { resolveSdkBuildId } from "./sdk.js";
21
+ import { formatBytes, sleep } from "./util.js";
22
+ import {
23
+ collectWorkspaceTextFilesLenient,
24
+ findLocalProjectMetadata,
25
+ } from "./workspace.js";
26
+
27
+ // ---------------------------------------------------------------------------
28
+ // Build assets (runtime files): binary media referenced from project files by
29
+ // absolute URL. Uploads go straight to S3 via presigned multipart URLs — the
30
+ // same prepare -> PUT parts -> complete flow the web workspace uses.
31
+ // ---------------------------------------------------------------------------
32
+
33
+ export async function assetsCommand(options) {
34
+ const subcommand = String(options.positional[0] || "list");
35
+ if (subcommand === "list") {
36
+ await assetsList(options);
37
+ return;
38
+ }
39
+ if (subcommand === "upload") {
40
+ await assetsUpload(options);
41
+ return;
42
+ }
43
+ if (subcommand === "delete") {
44
+ await assetsDelete(options);
45
+ return;
46
+ }
47
+ if (subcommand === "prune") {
48
+ await assetsPrune(options);
49
+ return;
50
+ }
51
+ throw new Error(
52
+ "Usage: lumine assets [list] | lumine assets upload <file...> | lumine assets delete <assetId> | lumine assets prune [--yes]",
53
+ );
54
+ }
55
+
56
+ export async function assetsList(options) {
57
+ const buildId = await resolveSdkBuildId(options);
58
+ const auth = await ensureAuth(options);
59
+ await assertAuthScope({ options, auth, scope: "build:sdk" });
60
+ const { token } = await mintBuildApiToken({
61
+ options,
62
+ auth,
63
+ buildId,
64
+ scopes: ["files:read"],
65
+ });
66
+ const { assets, usage, projectAssets } = await fetchAllRuntimeAssets({
67
+ options,
68
+ auth,
69
+ buildId,
70
+ buildApiToken: token,
71
+ });
72
+ if (!assets.length) {
73
+ console.log(
74
+ `No uploaded assets for Build #${buildId} (your uploads). Add one with \`lumine assets upload <file>\`.`,
75
+ );
76
+ } else {
77
+ console.log(`Assets for Build #${buildId} (your uploads):`);
78
+ for (const asset of assets) {
79
+ console.log(
80
+ ` #${asset.id} ${asset.fileType || "file"} ${formatBytes(asset.sizeBytes)} ${asset.originalFileName || asset.fileName}`,
81
+ );
82
+ console.log(` ${asset.url}`);
83
+ }
84
+ }
85
+ if (projectAssets?.length) {
86
+ console.log(
87
+ `Project assets (the project owner's uploads — reuse, do not re-upload):`,
88
+ );
89
+ for (const asset of projectAssets) {
90
+ console.log(
91
+ ` #${asset.id} ${asset.fileType || "file"} ${formatBytes(asset.sizeBytes)} ${asset.originalFileName || asset.fileName}`,
92
+ );
93
+ console.log(` ${asset.url}`);
94
+ }
95
+ }
96
+ printAssetUsage(usage);
97
+ const manifest = await maybeWriteWorkspaceAssetsManifest({
98
+ options,
99
+ buildId,
100
+ assets,
101
+ usage,
102
+ projectAssets,
103
+ });
104
+ if (manifest) {
105
+ console.log(
106
+ `Manifest refreshed: ${path.join(manifest.dir, PROJECT_METADATA_DIR, ASSETS_METADATA_FILE)}`,
107
+ );
108
+ }
109
+ }
110
+
111
+ export async function assetsUpload(options) {
112
+ const filePaths = options.positional.slice(1);
113
+ if (!filePaths.length) {
114
+ throw new Error(
115
+ "Usage: lumine assets upload <file...> (images and audio only)",
116
+ );
117
+ }
118
+ const buildId = await resolveSdkBuildId(options);
119
+ // Validate local input fully before any auth/remote side effects.
120
+ const candidates = [];
121
+ for (const filePath of filePaths) {
122
+ candidates.push(await readAssetUploadCandidate(filePath));
123
+ }
124
+ const auth = await ensureAuth(options);
125
+ await assertAuthScope({ options, auth, scope: "build:sdk" });
126
+ const { token } = await mintBuildApiToken({
127
+ options,
128
+ auth,
129
+ buildId,
130
+ scopes: ["files:read", "files:write"],
131
+ });
132
+ // Duplicate-name heads-up: uploading never replaces — same name creates a
133
+ // second asset with a new URL, and the old one keeps eating quota. Project-
134
+ // owner assets are checked too: re-uploading media the project already has
135
+ // defeats the reuse flow.
136
+ try {
137
+ const { assets: existingAssets, projectAssets: existingProjectAssets } =
138
+ await fetchAllRuntimeAssets({
139
+ options,
140
+ auth,
141
+ buildId,
142
+ buildApiToken: token,
143
+ });
144
+ const nameMatches = (asset, fileName) =>
145
+ asset.originalFileName === fileName || asset.fileName === fileName;
146
+ for (const candidate of candidates) {
147
+ const existing = existingAssets.find((asset) =>
148
+ nameMatches(asset, candidate.fileName),
149
+ );
150
+ if (existing) {
151
+ console.error(
152
+ `lumine: note — an asset named "${candidate.fileName}" already exists (#${existing.id}). ` +
153
+ `Uploading creates a NEW asset with a new URL; if this replaces #${existing.id}, update the code and run \`lumine assets delete ${existing.id}\` (or \`lumine assets prune\`).`,
154
+ );
155
+ continue;
156
+ }
157
+ const projectExisting = (existingProjectAssets || []).find((asset) =>
158
+ nameMatches(asset, candidate.fileName),
159
+ );
160
+ if (projectExisting) {
161
+ console.error(
162
+ `lumine: note — the project owner already has an asset named "${candidate.fileName}" (#${projectExisting.id}). ` +
163
+ `Consider reusing its URL instead of uploading a duplicate:\n ${projectExisting.url}`,
164
+ );
165
+ }
166
+ }
167
+ } catch {
168
+ // Advisory only — never block an upload on the pre-listing.
169
+ }
170
+ const uploaded = [];
171
+ for (const candidate of candidates) {
172
+ const asset = await uploadRuntimeAsset({
173
+ options,
174
+ auth,
175
+ buildId,
176
+ buildApiToken: token,
177
+ candidate,
178
+ });
179
+ uploaded.push(asset);
180
+ console.log(
181
+ `Uploaded ${candidate.fileName} (${formatBytes(candidate.buffer.length)}) as asset #${asset.id}`,
182
+ );
183
+ console.log(` ${asset.url}`);
184
+ }
185
+ console.log(
186
+ `Reference ${uploaded.length === 1 ? "this URL" : "these URLs"} from your project files (e.g. a src/assets.js module).`,
187
+ );
188
+ await refreshWorkspaceAssetsManifest({
189
+ options,
190
+ auth,
191
+ buildId,
192
+ buildApiToken: token,
193
+ });
194
+ }
195
+
196
+ export async function assetsDelete(options) {
197
+ const assetId = Number(options.positional[1] || 0);
198
+ if (!Number.isInteger(assetId) || assetId <= 0) {
199
+ throw new Error(
200
+ "Usage: lumine assets delete <assetId> (ids come from `lumine assets list`)",
201
+ );
202
+ }
203
+ const buildId = await resolveSdkBuildId(options);
204
+ const auth = await ensureAuth(options);
205
+ await assertAuthScope({ options, auth, scope: "build:sdk" });
206
+ const { token } = await mintBuildApiToken({
207
+ options,
208
+ auth,
209
+ buildId,
210
+ scopes: ["files:read", "files:write"],
211
+ });
212
+ const result = await buildApiJson({
213
+ options,
214
+ auth,
215
+ buildId,
216
+ buildApiToken: token,
217
+ endpointPath: "api/files/delete",
218
+ body: { assetId },
219
+ });
220
+ console.log(
221
+ `Deleted asset #${assetId}. Remove any references to it from your project files.`,
222
+ );
223
+ printAssetUsage(result?.usage);
224
+ await refreshWorkspaceAssetsManifest({
225
+ options,
226
+ auth,
227
+ buildId,
228
+ buildApiToken: token,
229
+ });
230
+ }
231
+
232
+ // Delete uploaded assets that nothing the platform can see references.
233
+ // Reference truth is threefold: (1) the server's persisted-content scan
234
+ // (`referencedByProject`: draft files, every artifact version snapshot
235
+ // including the published one, and derived builds), (2) the build's saved
236
+ // files scanned locally, and (3) unsaved local edits when run inside the
237
+ // matching workspace. An asset must be unreferenced by ALL THREE to be a
238
+ // candidate. If the server does not provide `referencedByProject` (older API
239
+ // deployment), prune shows the plan but refuses to delete — the published-
240
+ // snapshot check cannot be done client-side. Structural blind spot, always
241
+ // disclosed: references stored in app data (privateDb/sharedDb/viewer DB by
242
+ // Twinkle.files-using apps) are invisible to every scan.
243
+ export async function assetsPrune(options) {
244
+ const buildId = await resolveSdkBuildId(options);
245
+ const auth = await ensureAuth(options);
246
+ await assertAuthScope({ options, auth, scope: "build:sdk" });
247
+ const { token } = await mintBuildApiToken({
248
+ options,
249
+ auth,
250
+ buildId,
251
+ scopes: ["files:read", "files:write"],
252
+ });
253
+ const { assets, usage } = await fetchAllRuntimeAssets({
254
+ options,
255
+ auth,
256
+ buildId,
257
+ buildApiToken: token,
258
+ includeReferences: true,
259
+ });
260
+ if (!assets.length) {
261
+ console.log(`No uploaded assets for Build #${buildId}. Nothing to prune.`);
262
+ return;
263
+ }
264
+ const serverVerified = assets.every(
265
+ (asset) => typeof asset.referencedByProject === "boolean",
266
+ );
267
+ const remote = await loadBuildFiles({
268
+ options,
269
+ auth,
270
+ buildId,
271
+ includeContent: true,
272
+ });
273
+ const referenceFiles = Array.isArray(remote.projectFiles)
274
+ ? [...remote.projectFiles]
275
+ : [];
276
+ const localProject = await findLocalProjectMetadata(
277
+ options.dir ? path.resolve(options.dir) : process.cwd(),
278
+ );
279
+ if (Number(localProject?.metadata?.buildId || 0) === buildId) {
280
+ referenceFiles.push(
281
+ ...(await collectWorkspaceTextFilesLenient(localProject.rootDir)),
282
+ );
283
+ }
284
+ // Content-level matching (not URL extraction): unsaved local edits may
285
+ // reference an asset by any backend-supported variant, including bare
286
+ // filePath ("123/7/5") or uploadKey forms that no URL regex would find.
287
+ const contentBlob = referenceFiles
288
+ .map((file) => String(file?.content || ""))
289
+ .join("\n");
290
+ const unreferenced = assets.filter(
291
+ (asset) =>
292
+ asset.referencedByProject !== true &&
293
+ !contentReferencesRuntimeAsset(asset, contentBlob),
294
+ );
295
+ if (!unreferenced.length) {
296
+ console.log(
297
+ `All ${assets.length} uploaded asset${assets.length === 1 ? " is" : "s are"} referenced by the build's project files. Nothing to prune.`,
298
+ );
299
+ return;
300
+ }
301
+ console.log(
302
+ `Unreferenced assets for Build #${buildId} (${unreferenced.length} of ${assets.length}):`,
303
+ );
304
+ for (const asset of unreferenced) {
305
+ console.log(
306
+ ` #${asset.id} ${formatBytes(asset.sizeBytes)} ${asset.originalFileName || asset.fileName}`,
307
+ );
308
+ }
309
+ console.log(
310
+ "Caution: asset URLs stored in app data (privateDb/sharedDb/user DBs by apps using Twinkle.files) cannot be detected. If this app stores uploaded-file URLs at runtime, do not prune its assets.",
311
+ );
312
+ if (!serverVerified) {
313
+ console.log(
314
+ "The server did not report published/version reference info for these assets, so deletion is disabled — an asset may still be referenced by the published app. Update the Twinkle API (files/list includeReferences) to enable pruning.",
315
+ );
316
+ return;
317
+ }
318
+ if (!options.assumeYes) {
319
+ const confirmed = await confirmPrompt(
320
+ `Delete ${unreferenced.length} unreferenced asset${unreferenced.length === 1 ? "" : "s"}? [y/N] `,
321
+ );
322
+ if (confirmed === null) {
323
+ console.log("Not a TTY — re-run with --yes to delete these assets.");
324
+ return;
325
+ }
326
+ if (!confirmed) {
327
+ console.log("Aborted. Nothing deleted.");
328
+ return;
329
+ }
330
+ }
331
+ let lastUsage = usage;
332
+ for (const asset of unreferenced) {
333
+ const result = await buildApiJson({
334
+ options,
335
+ auth,
336
+ buildId,
337
+ buildApiToken: token,
338
+ endpointPath: "api/files/delete",
339
+ body: { assetId: asset.id },
340
+ });
341
+ lastUsage = result?.usage || lastUsage;
342
+ console.log(`Deleted #${asset.id} ${asset.originalFileName || asset.fileName}`);
343
+ }
344
+ printAssetUsage(lastUsage);
345
+ await refreshWorkspaceAssetsManifest({
346
+ options,
347
+ auth,
348
+ buildId,
349
+ buildApiToken: token,
350
+ });
351
+ }
352
+
353
+ export async function confirmPrompt(question) {
354
+ if (!input.isTTY || !output.isTTY) return null;
355
+ const rl = readline.createInterface({ input, output });
356
+ try {
357
+ const answer = await rl.question(question);
358
+ return /^y(es)?$/i.test(answer.trim());
359
+ } finally {
360
+ rl.close();
361
+ }
362
+ }
363
+
364
+ // Presigned URL: no Authorization header, no request timeout (parts can be
365
+ // 5MB on slow links) — mirrors the web workspace uploader, plus bounded
366
+ // retries for transient network/5xx failures. 4xx means the presigned URL
367
+ // itself is bad; retrying cannot help.
368
+ export async function putAssetPartWithRetry({ url, chunk, mimeType, partLabel }) {
369
+ let lastError = null;
370
+ for (let attempt = 1; attempt <= 3; attempt += 1) {
371
+ try {
372
+ const response = await fetch(url, {
373
+ method: "PUT",
374
+ headers: { "content-type": mimeType },
375
+ body: chunk,
376
+ });
377
+ if (response.ok) return response;
378
+ lastError = new Error(`Upload ${partLabel} failed (${response.status}).`);
379
+ if (response.status >= 400 && response.status < 500) break;
380
+ } catch (error) {
381
+ lastError = error;
382
+ }
383
+ if (attempt < 3) {
384
+ console.error(`lumine: retrying ${partLabel} (attempt ${attempt + 1}/3)`);
385
+ await sleep(attempt * 1000);
386
+ }
387
+ }
388
+ throw lastError || new Error(`Upload ${partLabel} failed.`);
389
+ }
390
+
391
+ export async function readAssetUploadCandidate(filePath) {
392
+ const absolutePath = path.resolve(filePath);
393
+ const fileName = path.basename(absolutePath);
394
+ const extension = path.extname(fileName).toLowerCase();
395
+ const mimeType = ASSET_MIME_BY_EXTENSION[extension];
396
+ if (!mimeType) {
397
+ throw new Error(
398
+ `Unsupported asset type for ${fileName}. Build assets support image and audio files (${Object.keys(ASSET_MIME_BY_EXTENSION).join(", ")}).`,
399
+ );
400
+ }
401
+ let buffer;
402
+ try {
403
+ buffer = await fs.readFile(absolutePath);
404
+ } catch (error) {
405
+ if (error.code === "ENOENT") {
406
+ throw new Error(`Asset file does not exist: ${filePath}`);
407
+ }
408
+ throw error;
409
+ }
410
+ if (!buffer.length) {
411
+ throw new Error(`Asset file is empty: ${filePath}`);
412
+ }
413
+ return { absolutePath, fileName, mimeType, buffer };
414
+ }
415
+
416
+ export async function uploadRuntimeAsset({
417
+ options,
418
+ auth,
419
+ buildId,
420
+ buildApiToken,
421
+ candidate,
422
+ }) {
423
+ const prepared = await buildApiJson({
424
+ options,
425
+ auth,
426
+ buildId,
427
+ buildApiToken,
428
+ endpointPath: "api/files/prepare-upload",
429
+ body: {
430
+ fileName: candidate.fileName,
431
+ fileSize: candidate.buffer.length,
432
+ mimeType: candidate.mimeType,
433
+ },
434
+ });
435
+ const uploadId = String(prepared?.uploadId || "");
436
+ const key = String(prepared?.key || "");
437
+ const urls = Array.isArray(prepared?.urls) ? prepared.urls : [];
438
+ const assetId = Number(prepared?.asset?.id);
439
+ if (!uploadId || !key || urls.length === 0 || !assetId) {
440
+ throw new Error(`Failed to prepare upload for ${candidate.fileName}.`);
441
+ }
442
+
443
+ try {
444
+ const parts = [];
445
+ for (let partNumber = 0; partNumber < urls.length; partNumber += 1) {
446
+ const start = partNumber * ASSET_UPLOAD_CHUNK_BYTES;
447
+ const end = Math.min(start + ASSET_UPLOAD_CHUNK_BYTES, candidate.buffer.length);
448
+ const response = await putAssetPartWithRetry({
449
+ url: urls[partNumber],
450
+ chunk: candidate.buffer.subarray(start, end),
451
+ mimeType: candidate.mimeType,
452
+ partLabel: `part ${partNumber + 1}/${urls.length} of ${candidate.fileName}`,
453
+ });
454
+ const etag = response.headers.get("etag");
455
+ if (!etag) {
456
+ throw new Error(
457
+ `Missing ETag in upload response for part ${partNumber + 1} of ${candidate.fileName}.`,
458
+ );
459
+ }
460
+ parts.push({
461
+ ETag: etag.replace(/['"]/g, ""),
462
+ PartNumber: partNumber + 1,
463
+ });
464
+ }
465
+
466
+ const completed = await buildApiJson({
467
+ options,
468
+ auth,
469
+ buildId,
470
+ buildApiToken,
471
+ endpointPath: "api/files/complete-upload",
472
+ body: { assetId, uploadId, key, parts },
473
+ });
474
+ const asset = completed?.asset || prepared?.asset;
475
+ if (!asset?.url) {
476
+ throw new Error(`Upload of ${candidate.fileName} completed without an asset URL.`);
477
+ }
478
+ return asset;
479
+ } catch (error) {
480
+ await buildApiJson({
481
+ options,
482
+ auth,
483
+ buildId,
484
+ buildApiToken,
485
+ endpointPath: "api/files/abort-upload",
486
+ body: { assetId, reason: "upload_failed" },
487
+ }).catch(() => {});
488
+ throw error;
489
+ }
490
+ }
491
+
492
+ // Re-fetch the asset list and rewrite .twinkle/assets.json when running inside
493
+ // the matching workspace. Best-effort: a manifest failure must not fail the
494
+ // command that already did its real work.
495
+ export async function refreshWorkspaceAssetsManifest({
496
+ options,
497
+ auth,
498
+ buildId,
499
+ buildApiToken,
500
+ }) {
501
+ try {
502
+ const localProject = await findLocalProjectMetadata(
503
+ options.dir ? path.resolve(options.dir) : process.cwd(),
504
+ );
505
+ if (Number(localProject?.metadata?.buildId || 0) !== buildId) return;
506
+ const { assets, usage, projectAssets } = await fetchAllRuntimeAssets({
507
+ options,
508
+ auth,
509
+ buildId,
510
+ buildApiToken,
511
+ });
512
+ await writeAssetsManifest({
513
+ dir: localProject.rootDir,
514
+ buildId,
515
+ assets,
516
+ usage,
517
+ projectAssets,
518
+ codeFiles: await collectWorkspaceTextFilesLenient(localProject.rootDir),
519
+ });
520
+ console.log(
521
+ `Manifest refreshed: ${path.join(localProject.rootDir, PROJECT_METADATA_DIR, ASSETS_METADATA_FILE)}`,
522
+ );
523
+ } catch (error) {
524
+ console.error(
525
+ `lumine: could not refresh ${ASSETS_METADATA_FILE} (${error?.message || error})`,
526
+ );
527
+ }
528
+ }
529
+
530
+ export async function maybeWriteWorkspaceAssetsManifest({
531
+ options,
532
+ buildId,
533
+ assets,
534
+ usage,
535
+ projectAssets,
536
+ }) {
537
+ const localProject = await findLocalProjectMetadata(
538
+ options.dir ? path.resolve(options.dir) : process.cwd(),
539
+ );
540
+ if (Number(localProject?.metadata?.buildId || 0) !== buildId) return null;
541
+ await writeAssetsManifest({
542
+ dir: localProject.rootDir,
543
+ buildId,
544
+ assets,
545
+ usage,
546
+ projectAssets,
547
+ codeFiles: await collectWorkspaceTextFilesLenient(localProject.rootDir),
548
+ });
549
+ return { dir: localProject.rootDir };
550
+ }
551
+
552
+ export async function writeAssetsManifest({
553
+ dir,
554
+ buildId,
555
+ assets,
556
+ usage,
557
+ codeFiles,
558
+ projectAssets,
559
+ }) {
560
+ // Annotation is content-level (covers bare filePath/uploadKey variants);
561
+ // the URL regex is only used below to DISCOVER references that match no
562
+ // known asset.
563
+ const contentBlob = codeFiles
564
+ ? codeFiles.map((file) => String(file?.content || "")).join("\n")
565
+ : null;
566
+ const refs = codeFiles
567
+ ? Array.from(collectRuntimeAssetReferences(codeFiles))
568
+ : null;
569
+ const annotate = (list) =>
570
+ contentBlob !== null
571
+ ? list.map((asset) => ({
572
+ ...asset,
573
+ referencedInCode: contentReferencesRuntimeAsset(asset, contentBlob),
574
+ }))
575
+ : list;
576
+ const annotatedAssets = annotate(assets);
577
+ const annotatedProjectAssets = projectAssets ? annotate(projectAssets) : null;
578
+ // Asset references in code that match neither the current user's uploads
579
+ // nor the project owner's assets: stale references to deleted assets, or
580
+ // assets of a collaborator the server did not include.
581
+ const knownAssets = [...assets, ...(projectAssets || [])];
582
+ const unmatchedReferences = refs
583
+ ? refs.filter(
584
+ (ref) =>
585
+ !knownAssets.some((asset) => runtimeAssetMatchesReference(asset, ref)),
586
+ )
587
+ : null;
588
+ const metadataDir = path.join(dir, PROJECT_METADATA_DIR);
589
+ await fs.mkdir(metadataDir, { recursive: true });
590
+ await fs.writeFile(
591
+ path.join(metadataDir, ASSETS_METADATA_FILE),
592
+ JSON.stringify(
593
+ {
594
+ schemaVersion: 2,
595
+ buildId,
596
+ note: "Runtime assets for this build. `assets` are the current CLI user's uploads; `projectAssets` are the project owner's uploads (team projects) — reference either from project files by its absolute `url`, and reuse `projectAssets` instead of re-uploading media the project already has. `unmatchedReferences` are asset URLs found in the code that match no known asset (stale, or another collaborator's). Refresh with `lumine assets list`; add with `lumine assets upload <file>`; remove your unreferenced uploads with `lumine assets prune`.",
597
+ fetchedAt: new Date().toISOString(),
598
+ usage: usage || null,
599
+ assets: annotatedAssets,
600
+ ...(annotatedProjectAssets ? { projectAssets: annotatedProjectAssets } : {}),
601
+ ...(unmatchedReferences ? { unmatchedReferences } : {}),
602
+ },
603
+ null,
604
+ 2,
605
+ ),
606
+ "utf8",
607
+ );
608
+ }
609
+
610
+ export function collectRuntimeAssetReferences(files) {
611
+ const refs = new Set();
612
+ for (const file of files || []) {
613
+ const content = String(file?.content || "");
614
+ for (const match of content.matchAll(RUNTIME_ASSET_REFERENCE_PATTERN)) {
615
+ refs.add(match[0]);
616
+ }
617
+ }
618
+ return refs;
619
+ }
620
+
621
+ // Content-level reference test mirroring the backend's variant support: the
622
+ // filePath ("buildId/userId/assetId") is a substring of every non-thumb
623
+ // variant it recognizes (absolute URL, URL pathname, uploadKey, bare
624
+ // filePath), so one substring check covers them all. Likewise for thumbs: the
625
+ // bare object key ("attachments/optimized/...", no leading slash) is a
626
+ // substring of every thumb form the backend recognizes (full thumbUrl,
627
+ // pathname, and /key), so matching the bare key covers them all. Plain
628
+ // substring matching errs toward "referenced" — the safe direction.
629
+ export function contentReferencesRuntimeAsset(asset, contentBlob) {
630
+ if (!contentBlob) return false;
631
+ if (asset?.filePath && contentBlob.includes(asset.filePath)) return true;
632
+ if (asset?.thumbUrl) {
633
+ if (contentBlob.includes(asset.thumbUrl)) return true;
634
+ const thumbPathname = urlPathname(asset.thumbUrl);
635
+ const bareThumbKey = thumbPathname
636
+ ? thumbPathname.replace(/^\//, "")
637
+ : null;
638
+ if (bareThumbKey && contentBlob.includes(bareThumbKey)) return true;
639
+ }
640
+ return false;
641
+ }
642
+
643
+ function urlPathname(value) {
644
+ try {
645
+ return new URL(String(value)).pathname;
646
+ } catch {
647
+ return null;
648
+ }
649
+ }
650
+
651
+ // Errs toward "referenced": filePath ("buildId/userId/assetId") is a substring
652
+ // of every server-recognized reference variant (full URL, pathname, uploadKey),
653
+ // and thumb URLs are matched separately since they live under a different path.
654
+ export function runtimeAssetMatchesReference(asset, ref) {
655
+ if (asset?.filePath && ref.includes(asset.filePath)) return true;
656
+ if (asset?.thumbUrl) {
657
+ if (ref === asset.thumbUrl || asset.thumbUrl.endsWith(ref)) return true;
658
+ }
659
+ return false;
660
+ }
661
+
662
+ export function printAssetUsage(usage) {
663
+ if (!usage) return;
664
+ const total = Number(usage.totalBytes || 0);
665
+ const max = Number(usage.maxRuntimeFileStorageBytes || 0);
666
+ const remaining = Number(usage.remainingBytes || 0);
667
+ console.log(
668
+ `Storage: ${formatBytes(total)} of ${formatBytes(max)} used · ${formatBytes(remaining)} free (across all your builds)`,
669
+ );
670
+ }