@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/bin/lumine.js +1 -2841
- package/lib/api.js +381 -0
- package/lib/assets.js +670 -0
- package/lib/auth.js +217 -0
- package/lib/commands.js +1491 -0
- package/lib/constants.js +256 -0
- package/lib/http.js +72 -0
- package/lib/sdk.js +489 -0
- package/lib/util.js +154 -0
- package/lib/workspace.js +577 -0
- package/package.json +5 -1
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
|
+
}
|