@stage5/lumine 0.1.0

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.
Files changed (3) hide show
  1. package/README.md +52 -0
  2. package/bin/lumine.js +1429 -0
  3. package/package.json +20 -0
package/bin/lumine.js ADDED
@@ -0,0 +1,1429 @@
1
+ #!/usr/bin/env node
2
+ import fs from "fs/promises";
3
+ import os from "os";
4
+ import path from "path";
5
+ import readline from "readline/promises";
6
+ import { spawn } from "child_process";
7
+ import { stdin as input, stdout as output } from "process";
8
+
9
+ const DEFAULT_API_URL = "https://api.twinkle.network";
10
+ const DEFAULT_SITE_URL = "https://www.twin-kle.com";
11
+ const DEFAULT_AUTH_FILE = path.join(
12
+ os.homedir(),
13
+ ".twinkle",
14
+ "lumine-cli-auth.json",
15
+ );
16
+ const DEFAULT_TIMEOUT_MS = 20000;
17
+ const DEFAULT_PROJECT_LIMIT = 50;
18
+ const PROJECT_METADATA_DIR = ".twinkle";
19
+ const PROJECT_METADATA_FILE = "lumine-project.json";
20
+ const DEFAULT_SAVE_SUMMARY = "Saved from Lumine CLI.";
21
+ const EXCLUDED_UPLOAD_DIRS = new Set([".git", ".twinkle", "node_modules"]);
22
+ const EXCLUDED_UPLOAD_FILES = new Set([".DS_Store", "AGENTS.md", "CLAUDE.md"]);
23
+ const LUMINE_AGENT_INSTRUCTIONS_MARKER =
24
+ "<!-- Lumine CLI Agent Instructions -->";
25
+ const LUMINE_AGENT_INSTRUCTIONS = `${LUMINE_AGENT_INSTRUCTIONS_MARKER}
26
+ # Lumine Project Agent Guide
27
+
28
+ This directory contains Twinkle Build project files pulled by Lumine CLI. Use
29
+ Lumine CLI as the source of truth for saving this workspace back to Twinkle.
30
+
31
+ ## Source Of Truth
32
+
33
+ - Read .twinkle/lumine-project.json before changing files.
34
+ - Treat build.canWrite, build.canPublish, and build.contributionRootBuildId as authoritative.
35
+ - If build.canWrite is false, do not save changes.
36
+ - If build.canPublish is false or contributionRootBuildId is set, this checkout is a contribution branch. Save only to this branch and do not run lumine launch or lumine save --publish.
37
+ - Do not edit another local checkout to bypass branch rules.
38
+
39
+ ## Workflow
40
+
41
+ - Edit only project files in this workspace.
42
+ - Keep /index.html or /index.htm as the entry file.
43
+ - Run lumine save from this folder after edits, with a short summary:
44
+
45
+ \`\`\`bash
46
+ lumine save --summary "Describe the change"
47
+ \`\`\`
48
+
49
+ - Run lumine check before launch when possible.
50
+ - Owned canonical builds may be published only when the user explicitly asks.
51
+
52
+ ## App Constraints
53
+
54
+ - Use local project files with relative or root-local imports only. Do not add package imports, CDN scripts, external network calls, or app-local /api/* routes.
55
+ - Build apps run in sandboxed iframes without allow-forms. Do not use <form> elements, native form submission, requestSubmit(), or browser form navigation. Build input flows with JavaScript-handled inputs and buttons instead.
56
+ - For canvas, WebGL, Three.js, fullscreen, or game builds, use Twinkle.preview for layout. Do not size roots from 100vh, 100vw, 100dvh, 100dvw, window.innerWidth, window.innerHeight, visualViewport, or document viewport dimensions.
57
+ - For Three.js, use import * as THREE from '/build/vendor/three/0.160.0/three.module.min.js';.
58
+
59
+ ## Completion Report
60
+
61
+ Report the changed files, the lumine save result, the build or branch id, and
62
+ whether the result is published or unpublished changes.
63
+ `;
64
+ const AGENT_INSTRUCTION_FILES = ["AGENTS.md", "CLAUDE.md"];
65
+ const COMMANDS = new Set([
66
+ "workspace",
67
+ "login",
68
+ "logout",
69
+ "whoami",
70
+ "projects",
71
+ "select",
72
+ "pull",
73
+ "save",
74
+ "push",
75
+ "check",
76
+ "launch",
77
+ "help",
78
+ ]);
79
+
80
+ main().catch((error) => {
81
+ console.error(`lumine: ${error?.message || error}`);
82
+ process.exitCode = 1;
83
+ });
84
+
85
+ async function main() {
86
+ const options = parseArgs(process.argv.slice(2));
87
+ if (options.help) {
88
+ printHelp();
89
+ return;
90
+ }
91
+
92
+ if (options.command === "workspace") {
93
+ await workspace(options);
94
+ return;
95
+ }
96
+ if (options.command === "login") {
97
+ await login(options);
98
+ return;
99
+ }
100
+ if (options.command === "logout") {
101
+ await logout(options);
102
+ return;
103
+ }
104
+ if (options.command === "whoami") {
105
+ await whoami(options);
106
+ return;
107
+ }
108
+ if (options.command === "projects") {
109
+ await projects(options);
110
+ return;
111
+ }
112
+ if (options.command === "select") {
113
+ await selectProject(options);
114
+ return;
115
+ }
116
+ if (options.command === "pull") {
117
+ await pull(options);
118
+ return;
119
+ }
120
+ if (options.command === "save" || options.command === "push") {
121
+ await save(options);
122
+ return;
123
+ }
124
+ if (options.command === "check") {
125
+ await check(options);
126
+ return;
127
+ }
128
+ if (options.command === "launch") {
129
+ await launch(options);
130
+ return;
131
+ }
132
+
133
+ printHelp();
134
+ }
135
+
136
+ async function login(options) {
137
+ const start = await requestJson({
138
+ method: "POST",
139
+ url: `${options.apiUrl}/cli/device/start`,
140
+ body: {
141
+ clientName: options.clientName,
142
+ scopes: ["build:read", "build:write", "build:check", "build:publish"],
143
+ },
144
+ timeoutMs: options.timeoutMs,
145
+ });
146
+
147
+ const approvalUrl = start.verificationUriComplete || start.verificationUri;
148
+ console.log("Connect Lumine CLI to Twinkle.");
149
+ if (options.openBrowser && approvalUrl) {
150
+ console.log("Opening Twinkle in your browser...");
151
+ const opened = await openBrowser(approvalUrl);
152
+ if (!opened) console.log("Could not open the browser automatically.");
153
+ }
154
+ console.log(`Approval link: ${approvalUrl}`);
155
+ console.log(`Code: ${start.userCode}`);
156
+ console.log("Leave this terminal open. Waiting for approval...");
157
+
158
+ const intervalMs = Math.max(Number(start.interval || 3), 1) * 1000;
159
+ const startedAt = Date.now();
160
+ const expiresInMs = Math.max(Number(start.expiresIn || 600), 1) * 1000;
161
+
162
+ while (Date.now() - startedAt < expiresInMs) {
163
+ await sleep(intervalMs);
164
+ const tokenResponse = await pollToken({
165
+ options,
166
+ deviceCode: start.deviceCode,
167
+ });
168
+ if (!tokenResponse) continue;
169
+
170
+ await writeAuth({
171
+ options,
172
+ token: tokenResponse.accessToken,
173
+ username: tokenResponse.user?.username || "",
174
+ userId: tokenResponse.user?.id || null,
175
+ expiresAt: Date.now() + Number(tokenResponse.expiresIn || 0) * 1000,
176
+ });
177
+ console.log(
178
+ `Logged in${tokenResponse.user?.username ? ` as ${tokenResponse.user.username}` : ""}.`,
179
+ );
180
+ console.log("You can now run `lumine` to choose a project.");
181
+ return;
182
+ }
183
+
184
+ throw new Error("Login code expired. Run `lumine login` again.");
185
+ }
186
+
187
+ async function pollToken({ options, deviceCode }) {
188
+ try {
189
+ return await requestJson({
190
+ method: "POST",
191
+ url: `${options.apiUrl}/cli/device/token`,
192
+ body: { deviceCode },
193
+ timeoutMs: options.timeoutMs,
194
+ });
195
+ } catch (error) {
196
+ if (error.status === 428 || error.data?.error === "authorization_pending") {
197
+ return null;
198
+ }
199
+ throw error;
200
+ }
201
+ }
202
+
203
+ async function logout(options) {
204
+ let removed = false;
205
+ try {
206
+ await fs.unlink(options.authFile);
207
+ removed = true;
208
+ } catch (error) {
209
+ if (error.code !== "ENOENT") throw error;
210
+ }
211
+ console.log(
212
+ removed
213
+ ? `Removed Lumine CLI login at ${options.authFile}`
214
+ : `No Lumine CLI login found at ${options.authFile}`,
215
+ );
216
+ }
217
+
218
+ async function whoami(options) {
219
+ const auth = await resolveAuth(options);
220
+ const session = await requestJson({
221
+ url: `${options.apiUrl}/cli/session`,
222
+ authToken: auth.token,
223
+ timeoutMs: options.timeoutMs,
224
+ });
225
+ console.log(
226
+ `Logged in as ${session.username || auth.username || "unknown"} ` +
227
+ `(userId=${session.userId || auth.userId || "unknown"})`,
228
+ );
229
+ }
230
+
231
+ async function workspace(options) {
232
+ const auth = await ensureAuth(options);
233
+ const selectedBuild = options.target
234
+ ? await loadBuildMetadata({
235
+ options,
236
+ auth,
237
+ buildId: resolveRequiredBuildId(options.target),
238
+ })
239
+ : await chooseProject({
240
+ builds: await listBuilds({ options, auth }),
241
+ });
242
+ const build = await resolveEditableWorkspaceBuild({
243
+ options,
244
+ auth,
245
+ build: selectedBuild,
246
+ });
247
+
248
+ await saveSelectedBuild({ options, auth, build });
249
+ const result = await pullBuildFiles({ options, auth, buildId: build.id });
250
+ await saveSelectedBuild({ options, auth, build: result.build || build });
251
+ printPullResult(result);
252
+ }
253
+
254
+ async function projects(options) {
255
+ const auth = await resolveAuth(options);
256
+ const builds = await listBuilds({ options, auth });
257
+ printBuildList(builds);
258
+ }
259
+
260
+ async function selectProject(options) {
261
+ const auth = await resolveAuth(options);
262
+ const selectedBuild = options.target
263
+ ? await loadBuildMetadata({
264
+ options,
265
+ auth,
266
+ buildId: resolveRequiredBuildId(options.target),
267
+ })
268
+ : await chooseProject({
269
+ builds: await listBuilds({ options, auth }),
270
+ });
271
+ const build = await resolveEditableWorkspaceBuild({
272
+ options,
273
+ auth,
274
+ build: selectedBuild,
275
+ });
276
+ await saveSelectedBuild({ options, auth, build });
277
+ console.log(
278
+ `Selected ${formatBuildTitle(build)}. Run \`lumine pull\` to get the files.`,
279
+ );
280
+ }
281
+
282
+ async function pull(options) {
283
+ const auth = await resolveAuth(options);
284
+ const requestedBuildId = await resolveRequiredBuildIdOrSelected(options, auth);
285
+ const selectedBuild = await loadBuildMetadata({
286
+ options,
287
+ auth,
288
+ buildId: requestedBuildId,
289
+ });
290
+ const build = await resolveEditableWorkspaceBuild({
291
+ options,
292
+ auth,
293
+ build: selectedBuild,
294
+ });
295
+ const result = await pullBuildFiles({ options, auth, buildId: build.id });
296
+ await saveSelectedBuild({ options, auth, build: result.build });
297
+ printPullResult(result);
298
+ }
299
+
300
+ async function save(options) {
301
+ const auth = await resolveAuth(options);
302
+ await assertAuthScope({ options, auth, scope: "build:write" });
303
+ const localProject = await findLocalProjectMetadata(
304
+ path.resolve(options.dir || process.cwd()),
305
+ );
306
+ let buildId = await resolveRequiredBuildIdOrSelected(options, auth, {
307
+ localProject,
308
+ });
309
+ let build = await resolveBuildForSave({
310
+ options,
311
+ auth,
312
+ buildId,
313
+ localProject,
314
+ });
315
+ buildId = Number(build?.id || buildId);
316
+ const dir = resolveProjectDirForSave({ options, localProject });
317
+ const files = await collectProjectFiles(dir);
318
+ const result = await saveProjectFiles({
319
+ options,
320
+ auth,
321
+ buildId,
322
+ files,
323
+ summary: options.summary || DEFAULT_SAVE_SUMMARY,
324
+ });
325
+ build = result.build ||
326
+ build ||
327
+ (await loadBuildMetadata({ options, auth, buildId }).catch(() => null)) || {
328
+ id: buildId,
329
+ title: `Build ${buildId}`,
330
+ };
331
+ await saveSelectedBuild({ options, auth, build });
332
+ await writeProjectMetadata({
333
+ dir,
334
+ options,
335
+ build,
336
+ manifest: result.projectManifest || null,
337
+ lastSavedAt: new Date().toISOString(),
338
+ });
339
+ printSaveResult({ result, build, dir, files });
340
+
341
+ if (options.publish) {
342
+ if (build?.canPublish === false) {
343
+ console.log(
344
+ "Saved to your branch. The project owner can merge or replace main from Twinkle.",
345
+ );
346
+ return;
347
+ }
348
+ const publish = await publishBuild({ options, buildId, auth });
349
+ if (publish.skipped) {
350
+ console.log("Publish skipped: already up to date.");
351
+ } else {
352
+ console.log("Publish complete.");
353
+ }
354
+ console.log(`App: ${options.siteUrl}/app/${buildId}`);
355
+ }
356
+ }
357
+
358
+ async function check(options) {
359
+ const auth = await resolveAuth(options);
360
+ const buildId = await resolveRequiredBuildIdOrSelected(options, auth);
361
+ const result = await requestJson({
362
+ url: `${options.apiUrl}/cli/build/${buildId}/launch-check`,
363
+ authToken: auth.token,
364
+ timeoutMs: options.timeoutMs,
365
+ });
366
+ printCheck(result);
367
+ if (!result.ok) process.exitCode = 1;
368
+ }
369
+
370
+ async function launch(options) {
371
+ const auth = await resolveAuth(options);
372
+ if (options.saveFirst) {
373
+ await save({
374
+ ...options,
375
+ publish: false,
376
+ saveFirst: false,
377
+ });
378
+ }
379
+ const buildId = await resolveRequiredBuildIdOrSelected(options, auth);
380
+ const checkResult = await requestJson({
381
+ url: `${options.apiUrl}/cli/build/${buildId}/launch-check`,
382
+ authToken: auth.token,
383
+ timeoutMs: options.timeoutMs,
384
+ });
385
+ printCheck(checkResult);
386
+ const launchOk =
387
+ typeof checkResult.launchOk === "boolean"
388
+ ? checkResult.launchOk
389
+ : checkResult.ok;
390
+ if (!checkResult.ok || !launchOk) {
391
+ process.exitCode = 1;
392
+ return;
393
+ }
394
+
395
+ const publish = await publishBuild({ options, buildId, auth });
396
+ const build = publish.build || checkResult.build || {};
397
+ const appUrl = `${options.siteUrl}/app/${buildId}`;
398
+ const versionId =
399
+ Number(build.publishedArtifactVersionId || 0) ||
400
+ Number(build.releaseStatus?.publishedArtifactVersionId || 0) ||
401
+ 0;
402
+
403
+ const appProbe = await probeUrl({
404
+ url: appUrl,
405
+ timeoutMs: options.timeoutMs,
406
+ });
407
+ const previewProbe = versionId
408
+ ? await probeUrl({
409
+ url: `${options.apiUrl}/build/preview/build/${buildId}/version/${versionId}`,
410
+ authToken: auth.token,
411
+ timeoutMs: options.timeoutMs,
412
+ })
413
+ : null;
414
+
415
+ if (publish.skipped) {
416
+ console.log("Publish skipped: already up to date.");
417
+ } else {
418
+ console.log("Publish complete.");
419
+ }
420
+ console.log(`App: ${appUrl}`);
421
+ console.log(
422
+ `Prod shell: ${appProbe.ok ? "ok" : "fail"} ${appProbe.status} bytes=${appProbe.bytes}`,
423
+ );
424
+ if (previewProbe) {
425
+ console.log(
426
+ `Published preview: ${previewProbe.ok ? "ok" : "fail"} ` +
427
+ `${previewProbe.status} bytes=${previewProbe.bytes}`,
428
+ );
429
+ }
430
+
431
+ if (!appProbe.ok || (previewProbe && !previewProbe.ok) || !versionId) {
432
+ process.exitCode = 1;
433
+ }
434
+ }
435
+
436
+ async function publishBuild({ options, buildId, auth }) {
437
+ if (auth.releaseStatus?.state === "up_to_date") {
438
+ return { skipped: true };
439
+ }
440
+ try {
441
+ const result = await requestJson({
442
+ method: "POST",
443
+ url: `${options.apiUrl}/build/${buildId}/publish`,
444
+ authToken: auth.token,
445
+ body: {},
446
+ timeoutMs: options.timeoutMs,
447
+ });
448
+ return { skipped: false, build: result.build || null };
449
+ } catch (error) {
450
+ if (
451
+ error.status === 409 &&
452
+ error.data?.code === "build_release_up_to_date"
453
+ ) {
454
+ return {
455
+ skipped: true,
456
+ build: { releaseStatus: error.data.releaseStatus || null },
457
+ };
458
+ }
459
+ throw error;
460
+ }
461
+ }
462
+
463
+ function printCheck(result) {
464
+ const checks = result.checks || {};
465
+ console.log(`Launch check: ${result.ok ? "ok" : "fail"}`);
466
+ console.log(
467
+ `- project files: ${checks.projectFiles?.ok ? "ok" : "fail"} ` +
468
+ `files=${checks.projectFiles?.fileCount ?? 0}`,
469
+ );
470
+ console.log(`- toolchain: ${checks.toolchain?.ok ? "ok" : "fail"}`);
471
+ if (checks.publishPermission) {
472
+ console.log(
473
+ `- publish permission: ${checks.publishPermission.ok ? "ok" : "fail"}`,
474
+ );
475
+ if (checks.publishPermission.reason) {
476
+ console.log(` ${checks.publishPermission.reason}`);
477
+ }
478
+ }
479
+ if (typeof result.launchOk === "boolean") {
480
+ console.log(`- launch gate: ${result.launchOk ? "ok" : "fail"}`);
481
+ }
482
+ console.log(
483
+ `- conflict markers: ${checks.conflictMarkers?.ok ? "ok" : "fail"}`,
484
+ );
485
+ for (const diagnostic of checks.toolchain?.diagnostics || []) {
486
+ console.log(
487
+ ` ${diagnostic.kind} ${diagnostic.filePath}` +
488
+ `${diagnostic.line ? `:${diagnostic.line}` : ""} ${diagnostic.message}`,
489
+ );
490
+ }
491
+ for (const conflictPath of checks.conflictMarkers?.paths || []) {
492
+ console.log(` conflict marker: ${conflictPath}`);
493
+ }
494
+ }
495
+
496
+ async function ensureAuth(options) {
497
+ try {
498
+ return await resolveAuth(options);
499
+ } catch (error) {
500
+ if (options.authToken || !isMissingLoginError(error)) throw error;
501
+ }
502
+ await login(options);
503
+ return await resolveAuth(options);
504
+ }
505
+
506
+ async function listBuilds({ options, auth }) {
507
+ const url = new URL(`${options.apiUrl}/cli/builds`);
508
+ url.searchParams.set("limit", String(options.limit));
509
+ const result = await requestJson({
510
+ url: url.toString(),
511
+ authToken: auth.token,
512
+ timeoutMs: options.timeoutMs,
513
+ });
514
+ return Array.isArray(result.builds) ? result.builds : [];
515
+ }
516
+
517
+ async function loadBuildMetadata({ options, auth, buildId }) {
518
+ const result = await loadBuildFiles({
519
+ options,
520
+ auth,
521
+ buildId,
522
+ includeContent: false,
523
+ });
524
+ if (!result.build) {
525
+ throw new Error(`Build ${buildId} could not be loaded.`);
526
+ }
527
+ return result.build;
528
+ }
529
+
530
+ async function loadBuildFiles({ options, auth, buildId, includeContent }) {
531
+ const url = new URL(`${options.apiUrl}/cli/build/${buildId}/files`);
532
+ if (!includeContent) url.searchParams.set("includeContent", "0");
533
+ return await requestJson({
534
+ url: url.toString(),
535
+ authToken: auth.token,
536
+ timeoutMs: options.timeoutMs,
537
+ });
538
+ }
539
+
540
+ async function saveProjectFiles({ options, auth, buildId, files, summary }) {
541
+ return await requestJson({
542
+ method: "PUT",
543
+ url: `${options.apiUrl}/build/${buildId}/project-files`,
544
+ authToken: auth.token,
545
+ body: {
546
+ files,
547
+ createVersion: true,
548
+ summary,
549
+ },
550
+ timeoutMs: options.timeoutMs,
551
+ });
552
+ }
553
+
554
+ async function resolveBuildForSave({ options, auth, buildId, localProject }) {
555
+ const localBuild = localProject?.metadata?.build;
556
+ const localBuildId =
557
+ Number(localBuild?.id || 0) ||
558
+ Number(localProject?.metadata?.buildId || 0);
559
+ const build =
560
+ localBuild && localBuildId === Number(buildId)
561
+ ? { ...localBuild, id: buildId }
562
+ : await loadBuildMetadata({ options, auth, buildId });
563
+ return await resolveEditableWorkspaceBuild({
564
+ options,
565
+ auth,
566
+ build,
567
+ skipWriteScopeCheck: true,
568
+ });
569
+ }
570
+
571
+ async function resolveEditableWorkspaceBuild({
572
+ options,
573
+ auth,
574
+ build,
575
+ skipWriteScopeCheck = false,
576
+ }) {
577
+ if (!shouldUseContributionBranch(build)) return build;
578
+
579
+ if (!skipWriteScopeCheck) {
580
+ await assertAuthScope({ options, auth, scope: "build:write" });
581
+ }
582
+ const branch = await ensureDefaultContributionBranch({
583
+ options,
584
+ auth,
585
+ build,
586
+ });
587
+ console.log(
588
+ `Using your branch ${formatBuildTitle(branch)} for team project ${formatBuildTitle(build)}.`,
589
+ );
590
+ return branch;
591
+ }
592
+
593
+ function shouldUseContributionBranch(build) {
594
+ return (
595
+ build?.role === "collaborator" &&
596
+ build.canWrite !== true &&
597
+ !isContributionBranch(build)
598
+ );
599
+ }
600
+
601
+ async function ensureDefaultContributionBranch({ options, auth, build }) {
602
+ const rootBuildId =
603
+ Number(build?.contributionRootBuildId || 0) || Number(build?.id || 0);
604
+ if (!rootBuildId) {
605
+ throw new Error("Could not resolve the team project for this branch.");
606
+ }
607
+
608
+ const result = await requestJson({
609
+ method: "POST",
610
+ url: `${options.apiUrl}/build/${rootBuildId}/contributions/default-branch`,
611
+ authToken: auth.token,
612
+ body: {},
613
+ timeoutMs: options.timeoutMs,
614
+ });
615
+ const branchId = Number(result.build?.id || 0);
616
+ if (!branchId) {
617
+ throw new Error("Twinkle did not return a contribution branch.");
618
+ }
619
+
620
+ return await loadBuildMetadata({
621
+ options,
622
+ auth,
623
+ buildId: branchId,
624
+ }).catch(() =>
625
+ normalizeContributionBranchBuild({
626
+ branch: result.build,
627
+ sourceBuild: build,
628
+ }),
629
+ );
630
+ }
631
+
632
+ function normalizeContributionBranchBuild({ branch, sourceBuild }) {
633
+ const branchId = Number(branch?.id || 0);
634
+ return {
635
+ ...branch,
636
+ id: branchId,
637
+ title: branch?.title || `Branch ${branchId}`,
638
+ role: "owner",
639
+ ownerUsername:
640
+ branch?.ownerUsername ||
641
+ branch?.username ||
642
+ sourceBuild?.ownerUsername ||
643
+ null,
644
+ canWrite: true,
645
+ canPublish: false,
646
+ contributionStatus: branch?.contributionStatus || "draft",
647
+ contributionRootBuildId:
648
+ Number(branch?.contributionRootBuildId || sourceBuild?.id || 0) || null,
649
+ contributionContributorId:
650
+ Number(branch?.contributionContributorId || branch?.userId || 0) || null,
651
+ contributionBranchNumber:
652
+ Number(branch?.contributionBranchNumber || 0) || null,
653
+ };
654
+ }
655
+
656
+ async function assertAuthScope({ options, auth, scope }) {
657
+ const session = await requestJson({
658
+ url: `${options.apiUrl}/cli/session`,
659
+ authToken: auth.token,
660
+ timeoutMs: options.timeoutMs,
661
+ });
662
+ const scopes = Array.isArray(session.scopes) ? session.scopes : [];
663
+ if (!scopes.includes(scope)) {
664
+ throw new Error(
665
+ `Saved login is missing ${scope}. Run \`lumine login\` again to approve file saves.`,
666
+ );
667
+ }
668
+ }
669
+
670
+ async function chooseProject({ builds }) {
671
+ if (!builds.length) {
672
+ throw new Error("No owned or team Twinkle builds were found.");
673
+ }
674
+ if (builds.length === 1) {
675
+ console.log(`Selected ${formatBuildTitle(builds[0])}.`);
676
+ return builds[0];
677
+ }
678
+ if (!input.isTTY || !output.isTTY) {
679
+ throw new Error(
680
+ "Choose a project by running `lumine select <twinkle-build-url>`.",
681
+ );
682
+ }
683
+
684
+ printBuildList(builds);
685
+ const rl = readline.createInterface({ input, output });
686
+ try {
687
+ while (true) {
688
+ const answer = await rl.question("Choose a project number: ");
689
+ const index = Number(answer.trim());
690
+ if (Number.isInteger(index) && index >= 1 && index <= builds.length) {
691
+ return builds[index - 1];
692
+ }
693
+ console.log(`Enter a number from 1 to ${builds.length}.`);
694
+ }
695
+ } finally {
696
+ rl.close();
697
+ }
698
+ }
699
+
700
+ async function pullBuildFiles({ options, auth, buildId }) {
701
+ const result = await loadBuildFiles({
702
+ options,
703
+ auth,
704
+ buildId,
705
+ includeContent: true,
706
+ });
707
+ const build = result.build || { id: buildId, title: `Build ${buildId}` };
708
+ const files = Array.isArray(result.projectFiles) ? result.projectFiles : [];
709
+ const dir = path.resolve(options.dir || defaultWorkspaceDir(build));
710
+ await writeProjectFiles({ dir, files });
711
+ await writeAgentInstructions({ dir });
712
+ await writeProjectMetadata({
713
+ dir,
714
+ options,
715
+ build,
716
+ manifest: result.projectManifest || null,
717
+ pulledAt: new Date().toISOString(),
718
+ });
719
+ return {
720
+ build,
721
+ dir,
722
+ fileCount: files.length,
723
+ manifest: result.projectManifest || null,
724
+ };
725
+ }
726
+
727
+ async function writeAgentInstructions({ dir }) {
728
+ for (const fileName of AGENT_INSTRUCTION_FILES) {
729
+ const filePath = path.join(dir, fileName);
730
+ try {
731
+ const existing = await fs.readFile(filePath, "utf8");
732
+ if (!existing.includes(LUMINE_AGENT_INSTRUCTIONS_MARKER)) {
733
+ continue;
734
+ }
735
+ } catch (error) {
736
+ if (error.code !== "ENOENT") throw error;
737
+ }
738
+ await fs.writeFile(filePath, LUMINE_AGENT_INSTRUCTIONS, "utf8");
739
+ }
740
+ }
741
+
742
+ async function collectProjectFiles(dir) {
743
+ const root = path.resolve(dir);
744
+ const files = [];
745
+ await collectProjectFilesFromDir({ root, dir: root, files });
746
+ if (!files.some((file) => isIndexHtmlPath(file.path))) {
747
+ throw new Error("Project files must include /index.html or /index.htm.");
748
+ }
749
+ assertProjectFilesAvoidNativeFormSubmission(files);
750
+ return files.sort((a, b) => a.path.localeCompare(b.path));
751
+ }
752
+
753
+ async function collectProjectFilesFromDir({ root, dir, files }) {
754
+ let entries = [];
755
+ try {
756
+ entries = await fs.readdir(dir, { withFileTypes: true });
757
+ } catch (error) {
758
+ if (error.code === "ENOENT") {
759
+ throw new Error(`Project directory does not exist: ${dir}`);
760
+ }
761
+ throw error;
762
+ }
763
+
764
+ for (const entry of entries) {
765
+ if (entry.isDirectory() && EXCLUDED_UPLOAD_DIRS.has(entry.name)) continue;
766
+ if (entry.isFile() && EXCLUDED_UPLOAD_FILES.has(entry.name)) continue;
767
+ const fullPath = path.join(dir, entry.name);
768
+ if (entry.isDirectory()) {
769
+ await collectProjectFilesFromDir({ root, dir: fullPath, files });
770
+ continue;
771
+ }
772
+ if (!entry.isFile()) continue;
773
+ const buffer = await fs.readFile(fullPath);
774
+ if (buffer.includes(0)) {
775
+ const relativePath = localFilePathToProjectPath({
776
+ root,
777
+ filePath: fullPath,
778
+ });
779
+ throw new Error(
780
+ `Cannot save binary file ${relativePath}. Twinkle project files must be text files.`,
781
+ );
782
+ }
783
+ files.push({
784
+ path: localFilePathToProjectPath({ root, filePath: fullPath }),
785
+ content: buffer.toString("utf8"),
786
+ });
787
+ }
788
+ }
789
+
790
+ function localFilePathToProjectPath({ root, filePath }) {
791
+ const relative = path.relative(root, filePath).replace(/\\/g, "/");
792
+ if (!relative || relative.startsWith("../") || path.isAbsolute(relative)) {
793
+ throw new Error(`Unsafe local project file path: ${filePath}`);
794
+ }
795
+ return `/${relative}`;
796
+ }
797
+
798
+ function isIndexHtmlPath(projectPath) {
799
+ const normalized = String(projectPath || "")
800
+ .trim()
801
+ .toLowerCase();
802
+ return normalized === "/index.html" || normalized === "/index.htm";
803
+ }
804
+
805
+ function isNativeFormMarkupProjectPath(projectPath) {
806
+ return /\.(?:html?|jsx?|tsx?|mjs|cjs)$/i.test(
807
+ String(projectPath || "").trim(),
808
+ );
809
+ }
810
+
811
+ function getLineColumnForSourceIndex(content, sourceIndex) {
812
+ const before = String(content || "").slice(0, Math.max(0, sourceIndex));
813
+ const lines = before.split("\n");
814
+ return {
815
+ line: lines.length,
816
+ column: lines[lines.length - 1].length + 1,
817
+ };
818
+ }
819
+
820
+ function formatProjectFileLocation({ filePath, line, column }) {
821
+ return `${filePath}:${line}:${column}`;
822
+ }
823
+
824
+ function assertProjectFilesAvoidNativeFormSubmission(files) {
825
+ for (const file of files) {
826
+ const content = String(file.content || "");
827
+ if (isNativeFormMarkupProjectPath(file.path)) {
828
+ const formMatch = content.match(/<form\b[^>]*>/i);
829
+ if (formMatch) {
830
+ const { line, column } = getLineColumnForSourceIndex(
831
+ content,
832
+ formMatch.index || 0,
833
+ );
834
+ throw new Error(
835
+ `Cannot save native form markup at ${formatProjectFileLocation({
836
+ filePath: file.path,
837
+ line,
838
+ column,
839
+ })}. Twinkle Build apps run in sandboxed iframes without form-submit permission. Replace <form> with JavaScript-handled inputs and buttons.`,
840
+ );
841
+ }
842
+ }
843
+
844
+ const requestSubmitMatch = content.match(/\brequestSubmit\s*\(/);
845
+ if (requestSubmitMatch) {
846
+ const { line, column } = getLineColumnForSourceIndex(
847
+ content,
848
+ requestSubmitMatch.index || 0,
849
+ );
850
+ throw new Error(
851
+ `Cannot save native form submission at ${formatProjectFileLocation({
852
+ filePath: file.path,
853
+ line,
854
+ column,
855
+ })}. requestSubmit() is blocked by the Build iframe sandbox; use a JavaScript click handler instead.`,
856
+ );
857
+ }
858
+ }
859
+ }
860
+
861
+ async function writeProjectFiles({ dir, files }) {
862
+ await fs.mkdir(dir, { recursive: true });
863
+ for (const file of files) {
864
+ const filePath = resolveLocalProjectFilePath({
865
+ rootDir: dir,
866
+ projectPath: file.path,
867
+ });
868
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
869
+ await fs.writeFile(filePath, String(file.content || ""), "utf8");
870
+ }
871
+ }
872
+
873
+ async function writeProjectMetadata({
874
+ dir,
875
+ options,
876
+ build,
877
+ manifest,
878
+ pulledAt,
879
+ lastSavedAt,
880
+ }) {
881
+ const metadataDir = path.join(dir, PROJECT_METADATA_DIR);
882
+ await fs.mkdir(metadataDir, { recursive: true });
883
+ await fs.writeFile(
884
+ path.join(metadataDir, PROJECT_METADATA_FILE),
885
+ JSON.stringify(
886
+ {
887
+ schemaVersion: 1,
888
+ buildId: Number(build?.id || 0) || null,
889
+ build: build
890
+ ? {
891
+ id: Number(build.id || 0) || null,
892
+ title: build.title || "",
893
+ role: build.role || "",
894
+ ownerUsername: build.ownerUsername || null,
895
+ contributionStatus: build.contributionStatus || "none",
896
+ contributionRootBuildId:
897
+ Number(build.contributionRootBuildId || 0) || null,
898
+ contributionContributorId:
899
+ Number(build.contributionContributorId || 0) || null,
900
+ contributionBranchNumber:
901
+ Number(build.contributionBranchNumber || 0) || null,
902
+ canWrite:
903
+ build.canWrite !== undefined
904
+ ? Boolean(build.canWrite)
905
+ : build.role === "owner",
906
+ canPublish:
907
+ build.canPublish !== undefined
908
+ ? Boolean(build.canPublish)
909
+ : build.role === "owner",
910
+ }
911
+ : null,
912
+ apiUrl: options.apiUrl,
913
+ siteUrl: options.siteUrl,
914
+ manifest,
915
+ pulledAt,
916
+ lastSavedAt,
917
+ },
918
+ null,
919
+ 2,
920
+ ),
921
+ "utf8",
922
+ );
923
+ }
924
+
925
+ async function findLocalProjectMetadata(startDir) {
926
+ let current = path.resolve(startDir || process.cwd());
927
+ while (true) {
928
+ const metadataPath = path.join(
929
+ current,
930
+ PROJECT_METADATA_DIR,
931
+ PROJECT_METADATA_FILE,
932
+ );
933
+ try {
934
+ const metadata = JSON.parse(await fs.readFile(metadataPath, "utf8"));
935
+ return { rootDir: current, metadata, metadataPath };
936
+ } catch (error) {
937
+ if (error.code !== "ENOENT") throw error;
938
+ }
939
+ const parent = path.dirname(current);
940
+ if (parent === current) return null;
941
+ current = parent;
942
+ }
943
+ }
944
+
945
+ function resolveProjectDirForSave({ options, localProject }) {
946
+ if (options.dir) return path.resolve(options.dir);
947
+ if (localProject?.rootDir) return localProject.rootDir;
948
+ return process.cwd();
949
+ }
950
+
951
+ function resolveLocalProjectFilePath({ rootDir, projectPath }) {
952
+ const relativePath = projectPathToRelativePath(projectPath);
953
+ const root = path.resolve(rootDir);
954
+ const filePath = path.resolve(root, relativePath);
955
+ if (filePath !== root && filePath.startsWith(`${root}${path.sep}`)) {
956
+ return filePath;
957
+ }
958
+ throw new Error(`Unsafe project file path: ${projectPath}`);
959
+ }
960
+
961
+ function projectPathToRelativePath(projectPath) {
962
+ const segments = String(projectPath || "")
963
+ .replace(/\\/g, "/")
964
+ .split("/")
965
+ .filter(Boolean);
966
+ if (
967
+ segments.length === 0 ||
968
+ segments.some((segment) => segment === "." || segment === "..")
969
+ ) {
970
+ throw new Error(`Unsafe project file path: ${projectPath}`);
971
+ }
972
+ return path.join(...segments);
973
+ }
974
+
975
+ async function saveSelectedBuild({ options, auth, build }) {
976
+ if (options.authToken || !build?.id) return;
977
+ await writeAuthFile(options, {
978
+ ...auth,
979
+ selectedBuildId: Number(build.id),
980
+ selectedBuildTitle: build.title || `Build ${build.id}`,
981
+ selectedBuildRole: build.role || "",
982
+ selectedAt: new Date().toISOString(),
983
+ });
984
+ }
985
+
986
+ function printBuildList(builds) {
987
+ if (!builds.length) {
988
+ console.log("No owned or team Twinkle builds found.");
989
+ return;
990
+ }
991
+ console.log("Twinkle builds:");
992
+ builds.forEach((build, index) => {
993
+ console.log(`${index + 1}. ${formatBuildListItem(build)}`);
994
+ });
995
+ }
996
+
997
+ function formatBuildListItem(build) {
998
+ const role =
999
+ build.role === "owner"
1000
+ ? "owned by you"
1001
+ : `team project${build.ownerUsername ? ` with ${build.ownerUsername}` : ""}`;
1002
+ const published = build.isPublic ? "public" : "private";
1003
+ return `${formatBuildTitle(build)} - ${role}, ${published}`;
1004
+ }
1005
+
1006
+ function formatBuildTitle(build) {
1007
+ return `${build.title || `Build ${build.id}`} (#${build.id})`;
1008
+ }
1009
+
1010
+ function isContributionBranch(build) {
1011
+ return (
1012
+ String(build?.contributionStatus || "none") !== "none" ||
1013
+ Number(build?.contributionRootBuildId || 0) > 0 ||
1014
+ Number(build?.contributionBranchNumber || 0) > 0
1015
+ );
1016
+ }
1017
+
1018
+ function printPullResult(result) {
1019
+ const build = result.build || {};
1020
+ const entryPath = result.manifest?.entryPath || "unknown";
1021
+ console.log(`Selected ${formatBuildTitle(build)}.`);
1022
+ console.log(
1023
+ `Pulled ${result.fileCount} file${result.fileCount === 1 ? "" : "s"} to ${result.dir}`,
1024
+ );
1025
+ console.log(`Entry: ${entryPath}`);
1026
+ console.log(`Next: cd ${shellQuote(result.dir)}`);
1027
+ if (build.canWrite === false) {
1028
+ console.log("This checkout is read-only for the current CLI login.");
1029
+ return;
1030
+ }
1031
+ console.log('Codex: codex "Read AGENTS.md, then make the requested change."');
1032
+ console.log(
1033
+ 'Claude Code: claude "Read CLAUDE.md, then make the requested change."',
1034
+ );
1035
+ console.log('Save after edits: lumine save --summary "Describe the change"');
1036
+ if (isContributionBranch(build) && build.canPublish === false) {
1037
+ console.log("The project owner can merge or replace main from Twinkle.");
1038
+ } else {
1039
+ console.log("Run `lumine check` or `lumine launch --save` when ready.");
1040
+ }
1041
+ }
1042
+
1043
+ function printSaveResult({ result, build, dir, files }) {
1044
+ const entryPath = result.projectManifest?.entryPath || "unknown";
1045
+ const version = result.artifactVersion?.versionNumber
1046
+ ? ` v${result.artifactVersion.versionNumber}`
1047
+ : "";
1048
+ const releaseState = result.releaseStatus?.state || "unknown";
1049
+ console.log(`Saved ${formatBuildTitle(build)}${version}.`);
1050
+ console.log(
1051
+ `Uploaded ${files.length} file${files.length === 1 ? "" : "s"} from ${dir}`,
1052
+ );
1053
+ console.log(`Entry: ${entryPath}`);
1054
+ console.log(`Release status: ${releaseState}`);
1055
+ if (isContributionBranch(build) && build.canPublish === false) {
1056
+ console.log(
1057
+ "Next: the project owner can merge or replace main from Twinkle.",
1058
+ );
1059
+ } else {
1060
+ console.log(
1061
+ "Next: run `lumine launch` to publish, or `lumine save --publish` next time.",
1062
+ );
1063
+ }
1064
+ }
1065
+
1066
+ async function resolveAuth(options) {
1067
+ if (options.authToken) {
1068
+ return { token: options.authToken };
1069
+ }
1070
+ try {
1071
+ const text = await fs.readFile(options.authFile, "utf8");
1072
+ const auth = JSON.parse(text);
1073
+ if (auth.apiUrl && trimTrailingSlash(auth.apiUrl) !== options.apiUrl) {
1074
+ throw new Error(
1075
+ `Saved login is for ${auth.apiUrl}. Run ` +
1076
+ `lumine login --api-url ${options.apiUrl}.`,
1077
+ );
1078
+ }
1079
+ if (auth.token) return auth;
1080
+ } catch (error) {
1081
+ if (error.code !== "ENOENT") throw error;
1082
+ }
1083
+ throw new Error("Run `lumine login` before launching a Twinkle build.");
1084
+ }
1085
+
1086
+ async function writeAuth({ options, token, username, userId, expiresAt }) {
1087
+ let existingAuth = {};
1088
+ try {
1089
+ existingAuth = JSON.parse(await fs.readFile(options.authFile, "utf8"));
1090
+ } catch (error) {
1091
+ if (error.code !== "ENOENT") throw error;
1092
+ }
1093
+ await writeAuthFile(options, {
1094
+ ...existingAuth,
1095
+ token,
1096
+ username,
1097
+ userId,
1098
+ expiresAt,
1099
+ apiUrl: options.apiUrl,
1100
+ createdAt: new Date().toISOString(),
1101
+ });
1102
+ }
1103
+
1104
+ async function writeAuthFile(options, auth) {
1105
+ await fs.mkdir(path.dirname(options.authFile), {
1106
+ recursive: true,
1107
+ mode: 0o700,
1108
+ });
1109
+ await fs.writeFile(options.authFile, JSON.stringify(auth, null, 2), {
1110
+ mode: 0o600,
1111
+ });
1112
+ await fs.chmod(options.authFile, 0o600);
1113
+ }
1114
+
1115
+ async function requestJson({
1116
+ method = "GET",
1117
+ url,
1118
+ authToken,
1119
+ body,
1120
+ timeoutMs,
1121
+ }) {
1122
+ const response = await request({
1123
+ method,
1124
+ url,
1125
+ authToken,
1126
+ body,
1127
+ timeoutMs,
1128
+ });
1129
+ const text = await response.text();
1130
+ const data = parseJson(text);
1131
+ if (!response.ok) {
1132
+ const error = new Error(
1133
+ data?.error || data?.message || `${method} ${url} failed`,
1134
+ );
1135
+ error.status = response.status;
1136
+ error.data = data;
1137
+ throw error;
1138
+ }
1139
+ return data || {};
1140
+ }
1141
+
1142
+ async function probeUrl({ url, authToken, timeoutMs }) {
1143
+ const response = await request({ url, authToken, timeoutMs });
1144
+ const text = await response.text();
1145
+ return {
1146
+ ok: response.ok && text.trim().length > 0,
1147
+ status: response.status,
1148
+ bytes: Buffer.byteLength(text),
1149
+ };
1150
+ }
1151
+
1152
+ async function request({ method = "GET", url, authToken, body, timeoutMs }) {
1153
+ const controller = new AbortController();
1154
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
1155
+ try {
1156
+ return await fetch(url, {
1157
+ method,
1158
+ headers: {
1159
+ ...(authToken ? { authorization: authorizationHeader(authToken) } : {}),
1160
+ ...(body ? { "content-type": "application/json" } : {}),
1161
+ },
1162
+ body: body ? JSON.stringify(body) : undefined,
1163
+ signal: controller.signal,
1164
+ });
1165
+ } finally {
1166
+ clearTimeout(timeout);
1167
+ }
1168
+ }
1169
+
1170
+ function parseArgs(args) {
1171
+ const firstArg = args[0] || "";
1172
+ const firstArgIsCommand =
1173
+ firstArg && !firstArg.startsWith("--") && COMMANDS.has(firstArg);
1174
+ const firstArgLooksLikeTarget =
1175
+ firstArg && !firstArg.startsWith("--") && resolveBuildId(firstArg) > 0;
1176
+ const command = firstArgIsCommand
1177
+ ? firstArg
1178
+ : !firstArg || firstArg.startsWith("--") || firstArgLooksLikeTarget
1179
+ ? "workspace"
1180
+ : "help";
1181
+ const rest = command === "workspace" ? args : args.slice(1);
1182
+ const raw = {};
1183
+ const positional = [];
1184
+ const booleanFlags = new Set(["noOpen", "open", "publish", "save"]);
1185
+
1186
+ for (let i = 0; i < rest.length; i += 1) {
1187
+ const arg = rest[i];
1188
+ if (arg === "--help" || arg === "-h") {
1189
+ raw.help = true;
1190
+ continue;
1191
+ }
1192
+ if (!arg.startsWith("--")) {
1193
+ positional.push(arg);
1194
+ continue;
1195
+ }
1196
+ const [key, inlineValue] = arg.slice(2).split("=", 2);
1197
+ const camelKey = toCamelCase(key);
1198
+ if (inlineValue !== undefined) {
1199
+ raw[camelKey] = inlineValue;
1200
+ } else if (booleanFlags.has(camelKey)) {
1201
+ raw[camelKey] = true;
1202
+ } else {
1203
+ raw[camelKey] = rest[i + 1] ?? "";
1204
+ i += 1;
1205
+ }
1206
+ }
1207
+
1208
+ return {
1209
+ command,
1210
+ target: raw.url || raw.target || positional[0] || "",
1211
+ apiUrl: trimTrailingSlash(
1212
+ String(raw.apiUrl || process.env.TWINKLE_API_URL || DEFAULT_API_URL),
1213
+ ),
1214
+ siteUrl: trimTrailingSlash(
1215
+ String(raw.siteUrl || process.env.TWINKLE_SITE_URL || DEFAULT_SITE_URL),
1216
+ ),
1217
+ authFile: String(
1218
+ raw.authFile || process.env.TWINKLE_CLI_AUTH_FILE || DEFAULT_AUTH_FILE,
1219
+ ),
1220
+ authToken:
1221
+ String(raw.authToken || process.env.TWINKLE_AUTH_TOKEN || "").trim() ||
1222
+ null,
1223
+ clientName: String(raw.clientName || "Lumine CLI").slice(0, 120),
1224
+ dir: raw.dir ? String(raw.dir) : "",
1225
+ summary: raw.summary ? String(raw.summary) : "",
1226
+ publish: parseBoolean(raw.publish, false),
1227
+ saveFirst: parseBoolean(raw.save, false),
1228
+ limit: Math.min(
1229
+ Math.max(
1230
+ Number(raw.limit || process.env.TWINKLE_PROJECT_LIMIT) ||
1231
+ DEFAULT_PROJECT_LIMIT,
1232
+ 1,
1233
+ ),
1234
+ 100,
1235
+ ),
1236
+ openBrowser: parseBoolean(raw.noOpen, false)
1237
+ ? false
1238
+ : parseBoolean(raw.open, true),
1239
+ timeoutMs: Math.max(
1240
+ Number(raw.timeoutMs || process.env.TWINKLE_TIMEOUT_MS) ||
1241
+ DEFAULT_TIMEOUT_MS,
1242
+ 1000,
1243
+ ),
1244
+ help: !!raw.help || command === "help",
1245
+ };
1246
+ }
1247
+
1248
+ async function resolveRequiredBuildIdOrSelected(
1249
+ options,
1250
+ auth,
1251
+ { localProject = null } = {},
1252
+ ) {
1253
+ const buildId = resolveBuildId(options.target);
1254
+ if (buildId > 0) return buildId;
1255
+ const resolvedLocalProject =
1256
+ localProject ||
1257
+ (await findLocalProjectMetadata(
1258
+ path.resolve(options.dir || process.cwd()),
1259
+ ));
1260
+ const localBuildId = Number(resolvedLocalProject?.metadata?.buildId || 0);
1261
+ if (localBuildId > 0) return localBuildId;
1262
+ const selectedBuildId = Number(auth?.selectedBuildId || 0);
1263
+ if (selectedBuildId > 0) return selectedBuildId;
1264
+ throw new Error(
1265
+ "Choose a project with `lumine select`, run `lumine`, or pass a Twinkle build URL.",
1266
+ );
1267
+ }
1268
+
1269
+ function resolveRequiredBuildId(value) {
1270
+ const buildId = resolveBuildId(value);
1271
+ if (buildId > 0) return buildId;
1272
+ throw new Error(
1273
+ "Pass a Twinkle build URL, app URL, preview URL, or build id.",
1274
+ );
1275
+ }
1276
+
1277
+ function resolveBuildId(value) {
1278
+ const rawValue = String(value || "").trim();
1279
+ const directId = Number(rawValue);
1280
+ if (Number.isFinite(directId) && directId > 0) return directId;
1281
+ if (!rawValue) return 0;
1282
+
1283
+ try {
1284
+ const parsedUrl = new URL(rawValue);
1285
+ const host = parsedUrl.hostname.toLowerCase();
1286
+ const previewHost = host.match(/^b-(\d+)\.preview\.lumine\.app$/);
1287
+ if (previewHost) return Number(previewHost[1]) || 0;
1288
+
1289
+ const parts = parsedUrl.pathname.split("/").filter(Boolean);
1290
+ const appIndex = parts.indexOf("app");
1291
+ if (appIndex >= 0) return Number(parts[appIndex + 1]) || 0;
1292
+
1293
+ const buildIndex = parts.indexOf("build");
1294
+ if (buildIndex >= 0) {
1295
+ if (parts[buildIndex + 1] === "preview") {
1296
+ const nestedBuildIndex = parts.indexOf("build", buildIndex + 2);
1297
+ return Number(parts[nestedBuildIndex + 1]) || 0;
1298
+ }
1299
+ return Number(parts[buildIndex + 1]) || 0;
1300
+ }
1301
+
1302
+ return (
1303
+ Number(parsedUrl.searchParams.get("buildId")) ||
1304
+ Number(parsedUrl.searchParams.get("build")) ||
1305
+ 0
1306
+ );
1307
+ } catch {
1308
+ const match = rawValue.match(
1309
+ /(?:^|\/)(?:app|build)\/(?:preview\/build\/)?(\d+)(?:\/|$)/,
1310
+ );
1311
+ return Number(match?.[1] || 0) || 0;
1312
+ }
1313
+ }
1314
+
1315
+ function parseJson(text) {
1316
+ if (!text.trim()) return null;
1317
+ try {
1318
+ return JSON.parse(text);
1319
+ } catch {
1320
+ return { message: text.slice(0, 500) };
1321
+ }
1322
+ }
1323
+
1324
+ function toCamelCase(value) {
1325
+ return value.replace(/-([a-z])/g, (_match, letter) => letter.toUpperCase());
1326
+ }
1327
+
1328
+ function trimTrailingSlash(value) {
1329
+ return String(value || "").replace(/\/+$/, "");
1330
+ }
1331
+
1332
+ function authorizationHeader(authToken) {
1333
+ const token = String(authToken || "").trim();
1334
+ if (!token) return "";
1335
+ return token.toLowerCase().startsWith("bearer ") ? token : `Bearer ${token}`;
1336
+ }
1337
+
1338
+ function isMissingLoginError(error) {
1339
+ return String(error?.message || "").includes("Run `lumine login`");
1340
+ }
1341
+
1342
+ function parseBoolean(value, fallback) {
1343
+ if (value === undefined || value === null || value === "") return fallback;
1344
+ if (value === true || value === false) return value;
1345
+ const normalized = String(value).trim().toLowerCase();
1346
+ if (["1", "true", "yes", "on"].includes(normalized)) return true;
1347
+ if (["0", "false", "no", "off"].includes(normalized)) return false;
1348
+ return fallback;
1349
+ }
1350
+
1351
+ async function openBrowser(url) {
1352
+ const command =
1353
+ process.platform === "darwin"
1354
+ ? "open"
1355
+ : process.platform === "win32"
1356
+ ? "cmd"
1357
+ : "xdg-open";
1358
+ const args = process.platform === "win32" ? ["/c", "start", "", url] : [url];
1359
+
1360
+ return await new Promise((resolve) => {
1361
+ const child = spawn(command, args, {
1362
+ detached: true,
1363
+ stdio: "ignore",
1364
+ });
1365
+ child.once("error", () => resolve(false));
1366
+ child.once("spawn", () => {
1367
+ child.unref();
1368
+ resolve(true);
1369
+ });
1370
+ });
1371
+ }
1372
+
1373
+ function sleep(ms) {
1374
+ return new Promise((resolve) => setTimeout(resolve, ms));
1375
+ }
1376
+
1377
+ function defaultWorkspaceDir(build) {
1378
+ const titleSlug = slugify(build?.title || "");
1379
+ const buildId = Number(build?.id || 0) || "build";
1380
+ return `twinkle-${titleSlug || "build"}-${buildId}`;
1381
+ }
1382
+
1383
+ function slugify(value) {
1384
+ return String(value || "")
1385
+ .toLowerCase()
1386
+ .replace(/[^a-z0-9]+/g, "-")
1387
+ .replace(/^-+|-+$/g, "")
1388
+ .slice(0, 48);
1389
+ }
1390
+
1391
+ function shellQuote(value) {
1392
+ return `'${String(value).replace(/'/g, "'\\''")}'`;
1393
+ }
1394
+
1395
+ function printHelp() {
1396
+ console.log(`Usage:
1397
+ lumine
1398
+ lumine login
1399
+ lumine whoami
1400
+ lumine logout
1401
+ lumine projects
1402
+ lumine select [twinkle-build-url]
1403
+ lumine pull [twinkle-build-url]
1404
+ lumine save
1405
+ lumine check [twinkle-build-url]
1406
+ lumine launch [twinkle-build-url]
1407
+
1408
+ Examples:
1409
+ npx @stage5/lumine@latest
1410
+ npx @stage5/lumine@latest login
1411
+ npx @stage5/lumine@latest pull
1412
+ npx @stage5/lumine@latest save
1413
+ npx @stage5/lumine@latest save --publish
1414
+ npx @stage5/lumine@latest launch --save
1415
+ npx @stage5/lumine@latest launch https://www.twin-kle.com/app/123
1416
+
1417
+ Options:
1418
+ --api-url <url> Twinkle API origin
1419
+ --site-url <url> Twinkle website origin
1420
+ --auth-file <path> Saved login path
1421
+ --auth-token <token> Override saved login
1422
+ --dir <path> Directory for pulled project files
1423
+ --summary <text> Save summary
1424
+ --publish Publish after saving
1425
+ --save Save local files before launch
1426
+ --limit <number> Number of projects to show
1427
+ --no-open Print the approval URL without opening a browser
1428
+ `);
1429
+ }