@stage5/lumine 0.2.4 → 0.2.6
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/README.md +26 -0
- package/bin/lumine.js +1 -3158
- package/lib/api.js +445 -0
- package/lib/assets.js +833 -0
- package/lib/auth.js +217 -0
- package/lib/commands.js +1512 -0
- package/lib/constants.js +296 -0
- package/lib/http.js +72 -0
- package/lib/sdk.js +489 -0
- package/lib/thumbnail.js +330 -0
- package/lib/util.js +154 -0
- package/lib/workspace.js +577 -0
- package/package.json +5 -1
package/lib/thumbnail.js
ADDED
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
import fs from "fs/promises";
|
|
2
|
+
import path from "path";
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
ASSET_GENERATE_TIMEOUT_MS,
|
|
6
|
+
THUMBNAIL_CAPTURE_TIMEOUT_MS,
|
|
7
|
+
THUMBNAIL_CONTENT_TYPE_BY_EXTENSION,
|
|
8
|
+
THUMBNAIL_MAX_FILE_SIZE_BYTES,
|
|
9
|
+
} from "./constants.js";
|
|
10
|
+
import {
|
|
11
|
+
captureBuildThumbnailPreview,
|
|
12
|
+
generateBuildThumbnailRequest,
|
|
13
|
+
loadBuildFiles,
|
|
14
|
+
mintBuildApiToken,
|
|
15
|
+
requestThumbnailSignedUpload,
|
|
16
|
+
updateBuildThumbnailUrl,
|
|
17
|
+
} from "./api.js";
|
|
18
|
+
import { assertAuthScope, ensureAuth } from "./auth.js";
|
|
19
|
+
import { resolveSdkBuildId } from "./sdk.js";
|
|
20
|
+
import {
|
|
21
|
+
confirmPrompt,
|
|
22
|
+
describeGenerateHttpError,
|
|
23
|
+
formatBatteryPercent,
|
|
24
|
+
putAssetPartWithRetry,
|
|
25
|
+
resolveGenerateModel,
|
|
26
|
+
} from "./assets.js";
|
|
27
|
+
import { formatBytes } from "./util.js";
|
|
28
|
+
import { findLocalProjectMetadata } from "./workspace.js";
|
|
29
|
+
|
|
30
|
+
export async function thumbnailCommand(options) {
|
|
31
|
+
const subcommand = String(options.positional[0] || "");
|
|
32
|
+
if (subcommand === "set") {
|
|
33
|
+
await thumbnailSet(options);
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
if (subcommand === "capture") {
|
|
37
|
+
await thumbnailCapture(options);
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
if (subcommand === "generate") {
|
|
41
|
+
await thumbnailGenerate(options);
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
throw new Error(
|
|
45
|
+
'Usage: lumine thumbnail set <file> | lumine thumbnail capture [--out <file>] | lumine thumbnail generate "<prompt>" --model <gpt-image-2|nano-banana>',
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function refuseMainCheckout(options) {
|
|
50
|
+
const local = await findLocalProjectMetadata(
|
|
51
|
+
options.dir ? path.resolve(options.dir) : process.cwd(),
|
|
52
|
+
);
|
|
53
|
+
if (local?.metadata?.mainCheckout === true && !options.buildIdFlag) {
|
|
54
|
+
throw new Error(
|
|
55
|
+
"This is a read-only main checkout. Set the thumbnail from your contribution-branch workspace (or pass --build for a build you own).",
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function resolveThumbnailContext(options) {
|
|
61
|
+
await refuseMainCheckout(options);
|
|
62
|
+
const buildId = await resolveSdkBuildId(options);
|
|
63
|
+
const auth = await ensureAuth(options);
|
|
64
|
+
await assertAuthScope({ options, auth, scope: "build:write" });
|
|
65
|
+
const metadata = await loadBuildFiles({
|
|
66
|
+
options,
|
|
67
|
+
auth,
|
|
68
|
+
buildId,
|
|
69
|
+
includeContent: false,
|
|
70
|
+
});
|
|
71
|
+
const build = metadata?.build || {};
|
|
72
|
+
if (build.canWrite === false) {
|
|
73
|
+
throw new Error(
|
|
74
|
+
`You cannot write to Build #${buildId}. Thumbnails can only be set on builds/branches you can save.`,
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
return { buildId, auth, build };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function confirmThumbnailOverwrite({ options, build, buildId }) {
|
|
81
|
+
if (options.assumeYes) return true;
|
|
82
|
+
if (!String(build?.thumbnailUrl || "").trim()) return true;
|
|
83
|
+
const confirmed = await confirmPrompt(
|
|
84
|
+
`Build #${buildId} already has a thumbnail. Replace it? [y/N] `,
|
|
85
|
+
);
|
|
86
|
+
if (confirmed === null) {
|
|
87
|
+
console.log("Not a TTY — re-run with --yes to replace the thumbnail.");
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
if (!confirmed) {
|
|
91
|
+
console.log("Aborted. Thumbnail unchanged.");
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
94
|
+
return true;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export async function commitThumbnailImage({
|
|
98
|
+
options,
|
|
99
|
+
auth,
|
|
100
|
+
buildId,
|
|
101
|
+
buffer,
|
|
102
|
+
contentType,
|
|
103
|
+
}) {
|
|
104
|
+
const { signedRequest, thumbnailUrl } = await requestThumbnailSignedUpload({
|
|
105
|
+
options,
|
|
106
|
+
auth,
|
|
107
|
+
buildId,
|
|
108
|
+
fileSize: buffer.length,
|
|
109
|
+
contentType,
|
|
110
|
+
});
|
|
111
|
+
if (!signedRequest || !thumbnailUrl) {
|
|
112
|
+
throw new Error("The server did not return a thumbnail upload URL.");
|
|
113
|
+
}
|
|
114
|
+
await putAssetPartWithRetry({
|
|
115
|
+
url: signedRequest,
|
|
116
|
+
chunk: buffer,
|
|
117
|
+
mimeType: contentType,
|
|
118
|
+
partLabel: "thumbnail",
|
|
119
|
+
});
|
|
120
|
+
const result = await updateBuildThumbnailUrl({
|
|
121
|
+
options,
|
|
122
|
+
auth,
|
|
123
|
+
buildId,
|
|
124
|
+
thumbnailUrl,
|
|
125
|
+
});
|
|
126
|
+
if (result?.success !== true) {
|
|
127
|
+
throw new Error(result?.error || "Failed to save the thumbnail.");
|
|
128
|
+
}
|
|
129
|
+
return { thumbnailUrl, build: result.build || null };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export async function thumbnailSet(options) {
|
|
133
|
+
const filePath = String(options.positional[1] || "").trim();
|
|
134
|
+
if (!filePath) {
|
|
135
|
+
throw new Error("Usage: lumine thumbnail set <file> (jpg, png, or webp)");
|
|
136
|
+
}
|
|
137
|
+
const absolutePath = path.resolve(filePath);
|
|
138
|
+
const extension = path.extname(absolutePath).toLowerCase();
|
|
139
|
+
const contentType = THUMBNAIL_CONTENT_TYPE_BY_EXTENSION[extension];
|
|
140
|
+
if (!contentType) {
|
|
141
|
+
throw new Error(
|
|
142
|
+
`Unsupported thumbnail type for ${path.basename(absolutePath)}. Thumbnails support ${Object.keys(THUMBNAIL_CONTENT_TYPE_BY_EXTENSION).join(", ")}.`,
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
let buffer;
|
|
146
|
+
try {
|
|
147
|
+
buffer = await fs.readFile(absolutePath);
|
|
148
|
+
} catch (error) {
|
|
149
|
+
if (error.code === "ENOENT") {
|
|
150
|
+
throw new Error(`Thumbnail file does not exist: ${filePath}`);
|
|
151
|
+
}
|
|
152
|
+
throw error;
|
|
153
|
+
}
|
|
154
|
+
if (!buffer.length) {
|
|
155
|
+
throw new Error(`Thumbnail file is empty: ${filePath}`);
|
|
156
|
+
}
|
|
157
|
+
if (buffer.length > THUMBNAIL_MAX_FILE_SIZE_BYTES) {
|
|
158
|
+
throw new Error(
|
|
159
|
+
`Thumbnail file is too big (${formatBytes(buffer.length)}; max ${formatBytes(THUMBNAIL_MAX_FILE_SIZE_BYTES)}).`,
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const { buildId, auth, build } = await resolveThumbnailContext(options);
|
|
164
|
+
const proceed = await confirmThumbnailOverwrite({ options, build, buildId });
|
|
165
|
+
if (!proceed) return;
|
|
166
|
+
|
|
167
|
+
const { thumbnailUrl } = await commitThumbnailImage({
|
|
168
|
+
options,
|
|
169
|
+
auth,
|
|
170
|
+
buildId,
|
|
171
|
+
buffer,
|
|
172
|
+
contentType,
|
|
173
|
+
});
|
|
174
|
+
console.log(`Thumbnail set for Build #${buildId}:`);
|
|
175
|
+
console.log(` ${thumbnailUrl}`);
|
|
176
|
+
console.log("Tip: thumbnails display ~16:9; the image was uploaded uncropped.");
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function decodeImageDataUrl(dataUrl) {
|
|
180
|
+
const match = String(dataUrl || "").match(
|
|
181
|
+
/^data:(image\/[a-z+.-]+);base64,(.+)$/i,
|
|
182
|
+
);
|
|
183
|
+
if (!match) return null;
|
|
184
|
+
return {
|
|
185
|
+
contentType: match[1].toLowerCase(),
|
|
186
|
+
buffer: Buffer.from(match[2], "base64"),
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export async function thumbnailCapture(options) {
|
|
191
|
+
const { buildId, auth, build } = await resolveThumbnailContext(options);
|
|
192
|
+
await assertAuthScope({ options, auth, scope: "build:sdk" });
|
|
193
|
+
const proceed = await confirmThumbnailOverwrite({ options, build, buildId });
|
|
194
|
+
if (!proceed) return;
|
|
195
|
+
|
|
196
|
+
const { token: previewToken } = await mintBuildApiToken({
|
|
197
|
+
options,
|
|
198
|
+
auth,
|
|
199
|
+
buildId,
|
|
200
|
+
scopes: ["preview:read"],
|
|
201
|
+
});
|
|
202
|
+
const previewPath = `/build/preview/build/${buildId}/current?rev=${Number(build.updatedAt || 0)}&buildApiToken=${encodeURIComponent(previewToken)}`;
|
|
203
|
+
|
|
204
|
+
console.log("Capturing the app preview server-side... this can take ~30s.");
|
|
205
|
+
const captureResult = await captureBuildThumbnailPreview({
|
|
206
|
+
options,
|
|
207
|
+
auth,
|
|
208
|
+
buildId,
|
|
209
|
+
previewPath,
|
|
210
|
+
timeoutMs: THUMBNAIL_CAPTURE_TIMEOUT_MS,
|
|
211
|
+
});
|
|
212
|
+
const decoded = decodeImageDataUrl(captureResult?.imageUrl);
|
|
213
|
+
if (!decoded) {
|
|
214
|
+
throw new Error(
|
|
215
|
+
captureResult?.error || "Preview capture returned no image.",
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
if (decoded.buffer.length > THUMBNAIL_MAX_FILE_SIZE_BYTES) {
|
|
219
|
+
throw new Error("Captured image exceeds the 8MB thumbnail limit.");
|
|
220
|
+
}
|
|
221
|
+
if (options.out) {
|
|
222
|
+
const outPath = path.resolve(String(options.out));
|
|
223
|
+
await fs.writeFile(outPath, decoded.buffer);
|
|
224
|
+
console.log(`Saved capture to ${outPath}`);
|
|
225
|
+
}
|
|
226
|
+
const normalizedContentType = THUMBNAIL_CONTENT_TYPE_BY_EXTENSION[
|
|
227
|
+
`.${decoded.contentType.split("/")[1] || "png"}`
|
|
228
|
+
]
|
|
229
|
+
? decoded.contentType
|
|
230
|
+
: "image/png";
|
|
231
|
+
const { thumbnailUrl } = await commitThumbnailImage({
|
|
232
|
+
options,
|
|
233
|
+
auth,
|
|
234
|
+
buildId,
|
|
235
|
+
buffer: decoded.buffer,
|
|
236
|
+
contentType: normalizedContentType,
|
|
237
|
+
});
|
|
238
|
+
console.log(`Thumbnail captured and set for Build #${buildId}:`);
|
|
239
|
+
console.log(` ${thumbnailUrl}`);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export async function thumbnailGenerate(options) {
|
|
243
|
+
const prompt = String(options.positional[1] || "").trim();
|
|
244
|
+
const { model, quality } = resolveGenerateModel(options);
|
|
245
|
+
const { buildId, auth, build } = await resolveThumbnailContext(options);
|
|
246
|
+
|
|
247
|
+
const requestBody = {
|
|
248
|
+
model,
|
|
249
|
+
...(quality ? { quality } : {}),
|
|
250
|
+
...(prompt ? { prompt } : {}),
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
if (!options.assumeYes) {
|
|
254
|
+
let estimate = null;
|
|
255
|
+
try {
|
|
256
|
+
const estimateResult = await generateBuildThumbnailRequest({
|
|
257
|
+
options,
|
|
258
|
+
auth,
|
|
259
|
+
buildId,
|
|
260
|
+
body: { ...requestBody, estimateOnly: true },
|
|
261
|
+
});
|
|
262
|
+
estimate = estimateResult?.estimate || null;
|
|
263
|
+
} catch (error) {
|
|
264
|
+
const friendly = describeGenerateHttpError(error);
|
|
265
|
+
if (friendly) throw new Error(friendly);
|
|
266
|
+
throw error;
|
|
267
|
+
}
|
|
268
|
+
const selectedOption = (estimate?.options || []).find(
|
|
269
|
+
(option) => option.model === model,
|
|
270
|
+
);
|
|
271
|
+
console.log(
|
|
272
|
+
`Generate a thumbnail with ${model}${quality ? ` (quality ${quality})` : ""}${prompt ? "" : " (prompt auto-composed from the build title/description)"}?`,
|
|
273
|
+
);
|
|
274
|
+
if (String(build?.thumbnailUrl || "").trim()) {
|
|
275
|
+
console.log(" This will REPLACE the existing thumbnail.");
|
|
276
|
+
}
|
|
277
|
+
if (selectedOption) {
|
|
278
|
+
console.log(
|
|
279
|
+
` Estimated battery cost: ${formatBatteryPercent(selectedOption.energyUnits, estimate?.fullBatteryUnits)} of a full AI battery (~$${Number(selectedOption.estimatedUsd || 0).toFixed(2)})`,
|
|
280
|
+
);
|
|
281
|
+
}
|
|
282
|
+
if (estimate) {
|
|
283
|
+
console.log(
|
|
284
|
+
` Battery remaining now: ${formatBatteryPercent(estimate.energyRemaining, estimate.fullBatteryUnits)}`,
|
|
285
|
+
);
|
|
286
|
+
}
|
|
287
|
+
const confirmed = await confirmPrompt("Proceed? [y/N] ");
|
|
288
|
+
if (confirmed === null) {
|
|
289
|
+
console.log("Not a TTY — re-run with --yes to generate.");
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
if (!confirmed) {
|
|
293
|
+
console.log("Aborted. Nothing generated.");
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
console.log("Generating the thumbnail... this can take a minute.");
|
|
299
|
+
let result;
|
|
300
|
+
try {
|
|
301
|
+
result = await generateBuildThumbnailRequest({
|
|
302
|
+
options,
|
|
303
|
+
auth,
|
|
304
|
+
buildId,
|
|
305
|
+
body: requestBody,
|
|
306
|
+
timeoutMs: ASSET_GENERATE_TIMEOUT_MS,
|
|
307
|
+
});
|
|
308
|
+
} catch (error) {
|
|
309
|
+
const friendly = describeGenerateHttpError(error);
|
|
310
|
+
if (friendly) throw new Error(friendly);
|
|
311
|
+
throw error;
|
|
312
|
+
}
|
|
313
|
+
if (result?.success !== true || !result?.thumbnailUrl) {
|
|
314
|
+
throw new Error(result?.error || "Thumbnail generation failed.");
|
|
315
|
+
}
|
|
316
|
+
console.log(`Thumbnail generated and set for Build #${buildId}:`);
|
|
317
|
+
console.log(` ${result.thumbnailUrl}`);
|
|
318
|
+
if (result?.asset?.url) {
|
|
319
|
+
console.log(
|
|
320
|
+
`The generated image is also stored as reusable asset #${result.asset.id}:`,
|
|
321
|
+
);
|
|
322
|
+
console.log(` ${result.asset.url}`);
|
|
323
|
+
}
|
|
324
|
+
const remaining = result?.aiUsagePolicy?.energyRemaining;
|
|
325
|
+
if (Number.isFinite(Number(remaining))) {
|
|
326
|
+
console.log(
|
|
327
|
+
`Battery remaining: ${formatBatteryPercent(Number(remaining), result?.aiUsagePolicy?.fullBatteryUnits)}`,
|
|
328
|
+
);
|
|
329
|
+
}
|
|
330
|
+
}
|
package/lib/util.js
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
|
|
2
|
+
|
|
3
|
+
export function parseJson(text) {
|
|
4
|
+
if (!text.trim()) return null;
|
|
5
|
+
try {
|
|
6
|
+
return JSON.parse(text);
|
|
7
|
+
} catch {
|
|
8
|
+
return { message: text.slice(0, 500) };
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function toCamelCase(value) {
|
|
13
|
+
return value.replace(/-([a-z])/g, (_match, letter) => letter.toUpperCase());
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function trimTrailingSlash(value) {
|
|
17
|
+
return String(value || "").replace(/\/+$/, "");
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function parseBoolean(value, fallback) {
|
|
21
|
+
if (value === undefined || value === null || value === "") return fallback;
|
|
22
|
+
if (value === true || value === false) return value;
|
|
23
|
+
const normalized = String(value).trim().toLowerCase();
|
|
24
|
+
if (["1", "true", "yes", "on"].includes(normalized)) return true;
|
|
25
|
+
if (["0", "false", "no", "off"].includes(normalized)) return false;
|
|
26
|
+
return fallback;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function sleep(ms) {
|
|
30
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function formatBytes(bytes) {
|
|
34
|
+
const value = Math.max(0, Number(bytes) || 0);
|
|
35
|
+
if (value < 1024) return `${value} B`;
|
|
36
|
+
if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} KB`;
|
|
37
|
+
return `${(value / (1024 * 1024)).toFixed(1)} MB`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function isNewerVersion(latestVersion, currentVersion) {
|
|
41
|
+
const latestParts = parseSemverParts(latestVersion);
|
|
42
|
+
const currentParts = parseSemverParts(currentVersion);
|
|
43
|
+
if (!latestParts || !currentParts) return false;
|
|
44
|
+
for (let index = 0; index < 3; index += 1) {
|
|
45
|
+
if (latestParts[index] > currentParts[index]) return true;
|
|
46
|
+
if (latestParts[index] < currentParts[index]) return false;
|
|
47
|
+
}
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function parseSemverParts(value) {
|
|
52
|
+
const match = String(value || "")
|
|
53
|
+
.trim()
|
|
54
|
+
.match(/^v?(\d+)\.(\d+)\.(\d+)/);
|
|
55
|
+
if (!match) return null;
|
|
56
|
+
return [Number(match[1]), Number(match[2]), Number(match[3])];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function slugify(value) {
|
|
60
|
+
return String(value || "")
|
|
61
|
+
.toLowerCase()
|
|
62
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
63
|
+
.replace(/^-+|-+$/g, "")
|
|
64
|
+
.slice(0, 48);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function shellQuote(value) {
|
|
68
|
+
return `'${String(value).replace(/'/g, "'\\''")}'`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function defaultWorkspaceDir(build) {
|
|
72
|
+
const titleSlug = slugify(build?.title || "");
|
|
73
|
+
const buildId = Number(build?.id || 0) || "build";
|
|
74
|
+
return `twinkle-${titleSlug || "build"}-${buildId}`;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function defaultReferenceDir(build) {
|
|
78
|
+
const titleSlug = slugify(build?.title || "");
|
|
79
|
+
const buildId = Number(build?.id || 0) || "build";
|
|
80
|
+
return `twinkle-reference-${titleSlug || "build"}-${buildId}`;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function defaultMainCheckoutDir(build) {
|
|
84
|
+
const titleSlug = slugify(build?.title || "");
|
|
85
|
+
const buildId = Number(build?.id || 0) || "build";
|
|
86
|
+
return `twinkle-main-${titleSlug || "build"}-${buildId}`;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function resolveRequiredBuildId(value) {
|
|
90
|
+
const buildId = resolveBuildReference(value).buildId;
|
|
91
|
+
if (buildId > 0) return buildId;
|
|
92
|
+
throw new Error(
|
|
93
|
+
"Pass a Twinkle build URL, app URL, preview URL, or build id.",
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function resolveBuildId(value) {
|
|
98
|
+
return resolveBuildReference(value).buildId;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function resolveBuildReference(value) {
|
|
102
|
+
const rawValue = String(value || "").trim();
|
|
103
|
+
const directId = Number(rawValue);
|
|
104
|
+
if (Number.isFinite(directId) && directId > 0) {
|
|
105
|
+
return { buildId: directId, branchNumber: 0 };
|
|
106
|
+
}
|
|
107
|
+
if (!rawValue) return { buildId: 0, branchNumber: 0 };
|
|
108
|
+
|
|
109
|
+
try {
|
|
110
|
+
const parsedUrl = new URL(rawValue);
|
|
111
|
+
const host = parsedUrl.hostname.toLowerCase();
|
|
112
|
+
const previewHost = host.match(/^b-(\d+)\.preview\.lumine\.app$/);
|
|
113
|
+
if (previewHost) {
|
|
114
|
+
return { buildId: Number(previewHost[1]) || 0, branchNumber: 0 };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const parts = parsedUrl.pathname.split("/").filter(Boolean);
|
|
118
|
+
const appIndex = parts.indexOf("app");
|
|
119
|
+
if (appIndex >= 0) {
|
|
120
|
+
return { buildId: Number(parts[appIndex + 1]) || 0, branchNumber: 0 };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const buildIndex = parts.indexOf("build");
|
|
124
|
+
if (buildIndex >= 0) {
|
|
125
|
+
if (parts[buildIndex + 1] === "preview") {
|
|
126
|
+
const nestedBuildIndex = parts.indexOf("build", buildIndex + 2);
|
|
127
|
+
return {
|
|
128
|
+
buildId: Number(parts[nestedBuildIndex + 1]) || 0,
|
|
129
|
+
branchNumber: 0,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
return {
|
|
133
|
+
buildId: Number(parts[buildIndex + 1]) || 0,
|
|
134
|
+
branchNumber: Number(parts[buildIndex + 2]) || 0,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return {
|
|
139
|
+
buildId:
|
|
140
|
+
Number(parsedUrl.searchParams.get("buildId")) ||
|
|
141
|
+
Number(parsedUrl.searchParams.get("build")) ||
|
|
142
|
+
0,
|
|
143
|
+
branchNumber: 0,
|
|
144
|
+
};
|
|
145
|
+
} catch {
|
|
146
|
+
const match = rawValue.match(
|
|
147
|
+
/(?:^|\/)(?:app|build)\/(?:preview\/build\/)?(\d+)(?:\/(\d+))?(?:\/|$)/,
|
|
148
|
+
);
|
|
149
|
+
return {
|
|
150
|
+
buildId: Number(match?.[1] || 0) || 0,
|
|
151
|
+
branchNumber: Number(match?.[2] || 0) || 0,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
}
|