@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.
@@ -0,0 +1,1512 @@
1
+ import path from "path";
2
+ import readline from "readline/promises";
3
+ import { stdin as input, stdout as output } from "process";
4
+
5
+ import {
6
+ ASSETS_METADATA_FILE,
7
+ COMMANDS,
8
+ DEFAULT_API_URL,
9
+ DEFAULT_AUTH_FILE,
10
+ DEFAULT_NPM_REGISTRY_URL,
11
+ DEFAULT_PROJECT_LIMIT,
12
+ DEFAULT_SAVE_SUMMARY,
13
+ DEFAULT_SITE_URL,
14
+ DEFAULT_TIMEOUT_MS,
15
+ LUMINE_MAIN_CHECKOUT_INSTRUCTIONS,
16
+ LUMINE_MAIN_CHECKOUT_INSTRUCTIONS_MARKER,
17
+ MAIN_CHECKOUT_READONLY_COMMANDS,
18
+ PROJECT_METADATA_DIR,
19
+ SDK_REFERENCE_FILE,
20
+ } from "./constants.js";
21
+ import {
22
+ createBuild,
23
+ fetchAllRuntimeAssets,
24
+ forkBuild,
25
+ listBuilds,
26
+ listOpenSourceBuilds,
27
+ loadBuildFiles,
28
+ loadBuildMetadata,
29
+ loadContributionDiff,
30
+ loadLumineCliVersionInfo,
31
+ loadOpenSourceBuildFiles,
32
+ maybeCheckForLumineCliUpdate,
33
+ mergeContributionIntoMain,
34
+ mintBuildApiToken,
35
+ publishBuild,
36
+ replaceMainWithContribution,
37
+ resolveBranchBuild,
38
+ saveProjectFiles,
39
+ } from "./api.js";
40
+ import { assetsCommand, writeAssetsManifest } from "./assets.js";
41
+ import { thumbnailCommand } from "./thumbnail.js";
42
+ import {
43
+ assertAuthScope,
44
+ ensureAuth,
45
+ login,
46
+ logout,
47
+ resolveAuth,
48
+ saveSelectedBuild,
49
+ whoami,
50
+ } from "./auth.js";
51
+ import { probeUrl, requestJson } from "./http.js";
52
+ import { sdkCommand } from "./sdk.js";
53
+ import {
54
+ defaultMainCheckoutDir,
55
+ defaultReferenceDir,
56
+ defaultWorkspaceDir,
57
+ parseBoolean,
58
+ resolveBuildId,
59
+ resolveBuildReference,
60
+ shellQuote,
61
+ toCamelCase,
62
+ trimTrailingSlash,
63
+ } from "./util.js";
64
+ import {
65
+ assertLocalProjectCanBeSaved,
66
+ assertProjectFilesWithinLimits,
67
+ collectProjectFiles,
68
+ collectProjectLimitFindings,
69
+ findLocalProjectMetadata,
70
+ isIndexHtmlPath,
71
+ isReadOnlyReferenceMetadata,
72
+ removeLocalProjectFilesNotIn,
73
+ resolveProjectDirForSave,
74
+ writeAgentInstructions,
75
+ writeInstructionFiles,
76
+ writeMainCheckoutMetadata,
77
+ writeProjectFiles,
78
+ writeProjectMetadata,
79
+ writeReferenceInstructions,
80
+ writeReferenceMetadata,
81
+ writeSdkReference,
82
+ } from "./workspace.js";
83
+
84
+ export async function main() {
85
+ const options = parseArgs(process.argv.slice(2));
86
+ options.lumineCli = await loadLumineCliVersionInfo({ options });
87
+ if (options.help) {
88
+ printHelp();
89
+ return;
90
+ }
91
+ if (options.updateCheck) {
92
+ await maybeCheckForLumineCliUpdate({ options });
93
+ }
94
+
95
+ if (options.command === "workspace") {
96
+ await workspace(options);
97
+ return;
98
+ }
99
+ if (options.command === "login") {
100
+ await login(options);
101
+ return;
102
+ }
103
+ if (options.command === "logout") {
104
+ await logout(options);
105
+ return;
106
+ }
107
+ if (options.command === "whoami") {
108
+ await whoami(options);
109
+ return;
110
+ }
111
+ if (options.command === "new") {
112
+ await newBuild(options);
113
+ return;
114
+ }
115
+ if (options.command === "projects") {
116
+ await projects(options);
117
+ return;
118
+ }
119
+ if (options.command === "explore") {
120
+ await explore(options);
121
+ return;
122
+ }
123
+ if (options.command === "select") {
124
+ await selectProject(options);
125
+ return;
126
+ }
127
+ if (options.command === "pull") {
128
+ await pull(options);
129
+ return;
130
+ }
131
+ if (options.command === "reference") {
132
+ await reference(options);
133
+ return;
134
+ }
135
+ if (options.command === "fork") {
136
+ await fork(options);
137
+ return;
138
+ }
139
+ if (options.command === "diff") {
140
+ await diff(options);
141
+ return;
142
+ }
143
+ if (options.command === "merge") {
144
+ await mergeBranch(options);
145
+ return;
146
+ }
147
+ if (options.command === "replace-main") {
148
+ await replaceMainWithBranch(options);
149
+ return;
150
+ }
151
+ if (options.command === "update-from-main") {
152
+ await updateBranchFromMain(options);
153
+ return;
154
+ }
155
+ if (options.command === "save" || options.command === "push") {
156
+ await save(options);
157
+ return;
158
+ }
159
+ if (options.command === "check") {
160
+ await check(options);
161
+ return;
162
+ }
163
+ if (options.command === "launch") {
164
+ await launch(options);
165
+ return;
166
+ }
167
+ if (options.command === "sdk") {
168
+ await sdkCommand(options);
169
+ return;
170
+ }
171
+ if (options.command === "assets") {
172
+ await assetsCommand(options);
173
+ return;
174
+ }
175
+ if (options.command === "thumbnail") {
176
+ await thumbnailCommand(options);
177
+ return;
178
+ }
179
+
180
+ printHelp();
181
+ }
182
+
183
+ export async function newBuild(options) {
184
+ const title = await resolveNewBuildTitle(options);
185
+ const description = await resolveNewBuildDescription(options);
186
+ const auth = await ensureAuth(options);
187
+ await assertAuthScope({ options, auth, scope: "build:write" });
188
+ const createResult = await createBuild({
189
+ options,
190
+ auth,
191
+ title,
192
+ description,
193
+ });
194
+ const buildId = Number(createResult.build?.id || 0);
195
+ if (!buildId) {
196
+ throw new Error("Twinkle did not return a created Build.");
197
+ }
198
+ const result = await pullBuildFiles({ options, auth, buildId });
199
+ await saveSelectedBuild({ options, auth, build: result.build });
200
+ printNewBuildResult({ createResult, pullResult: result });
201
+ }
202
+
203
+ export async function workspace(options) {
204
+ const auth = await ensureAuth(options);
205
+ const selectedBuild = options.target
206
+ ? await loadTargetBuildMetadata({ options, auth })
207
+ : await chooseProject({
208
+ builds: await listBuilds({ options, auth }),
209
+ });
210
+ const build = await resolveEditableWorkspaceBuild({
211
+ options,
212
+ auth,
213
+ build: selectedBuild,
214
+ });
215
+
216
+ await saveSelectedBuild({ options, auth, build });
217
+ const result = await pullBuildFiles({ options, auth, buildId: build.id });
218
+ await saveSelectedBuild({ options, auth, build: result.build || build });
219
+ printPullResult(result);
220
+ }
221
+
222
+ export async function projects(options) {
223
+ const auth = await resolveAuth(options);
224
+ const builds = await listBuilds({ options, auth });
225
+ printBuildList(builds);
226
+ }
227
+
228
+ export async function explore(options) {
229
+ const auth = await resolveAuth(options);
230
+ const builds = await listOpenSourceBuilds({ options, auth });
231
+ printOpenSourceBuildList(builds, options);
232
+ }
233
+
234
+ export async function selectProject(options) {
235
+ const auth = await resolveAuth(options);
236
+ const selectedBuild = options.target
237
+ ? await loadTargetBuildMetadata({ options, auth })
238
+ : await chooseProject({
239
+ builds: await listBuilds({ options, auth }),
240
+ });
241
+ const build = await resolveEditableWorkspaceBuild({
242
+ options,
243
+ auth,
244
+ build: selectedBuild,
245
+ });
246
+ await saveSelectedBuild({ options, auth, build });
247
+ console.log(
248
+ `Selected ${formatBuildTitle(build)}. Run \`lumine pull\` to get the files.`,
249
+ );
250
+ }
251
+
252
+ export async function pull(options) {
253
+ const auth = await resolveAuth(options);
254
+ const localProject = await findLocalProjectMetadata(
255
+ path.resolve(options.dir || process.cwd()),
256
+ );
257
+ const requestedBuildId = await resolveRequiredBuildIdOrSelected(
258
+ options,
259
+ auth,
260
+ options.pullMain ? { localProject } : {},
261
+ );
262
+ const selectedBuild = await loadBuildMetadata({
263
+ options,
264
+ auth,
265
+ buildId: requestedBuildId,
266
+ });
267
+ if (options.pullMain) {
268
+ await pullMainBuildFiles({ options, auth, build: selectedBuild });
269
+ return;
270
+ }
271
+ const build = await resolveEditableWorkspaceBuild({
272
+ options,
273
+ auth,
274
+ build: selectedBuild,
275
+ });
276
+ const result = await pullBuildFiles({ options, auth, buildId: build.id });
277
+ await saveSelectedBuild({ options, auth, build: result.build });
278
+ printPullResult(result);
279
+ }
280
+
281
+ // Sync a contribution branch with its team project's main: the server runs a
282
+ // three-way merge (auto-merging where it can, writing git-style conflict
283
+ // markers where it cannot) and saves the result to the branch. When run inside
284
+ // the branch's workspace, local edits are sent along as the branch's pending
285
+ // state and the merged files are written back to disk.
286
+ export async function updateBranchFromMain(options) {
287
+ const auth = await resolveAuth(options);
288
+ await assertAuthScope({ options, auth, scope: "build:write" });
289
+ const localProject = await findLocalProjectMetadata(
290
+ path.resolve(options.dir || process.cwd()),
291
+ );
292
+ const buildId = await resolveRequiredBuildIdOrSelected(options, auth, {
293
+ localProject,
294
+ });
295
+ const build = await loadBuildMetadata({ options, auth, buildId });
296
+ const rootBuildId = Number(build?.contributionRootBuildId || 0);
297
+ const contributionBuildId = Number(build?.id || 0);
298
+ if (!rootBuildId || !contributionBuildId) {
299
+ throw new Error(
300
+ "update-from-main only applies to contribution branches of a team project. Pull the team project first so your branch workspace exists.",
301
+ );
302
+ }
303
+ let dir = null;
304
+ let projectFiles = null;
305
+ if (Number(localProject?.metadata?.buildId || 0) === contributionBuildId) {
306
+ dir = resolveProjectDirForSave({ options, localProject });
307
+ projectFiles = await collectProjectFiles(dir);
308
+ }
309
+ const result = await requestJson({
310
+ method: "POST",
311
+ url: `${options.apiUrl}/build/${rootBuildId}/contributions/${contributionBuildId}/update-from-main`,
312
+ authToken: auth.token,
313
+ body: projectFiles ? { projectFiles } : {},
314
+ timeoutMs: options.timeoutMs,
315
+ });
316
+ const mergedFiles = Array.isArray(result.projectFiles)
317
+ ? result.projectFiles
318
+ : [];
319
+ if (dir && mergedFiles.length > 0) {
320
+ await writeProjectFiles({ dir, files: mergedFiles });
321
+ await removeLocalProjectFilesNotIn({ dir, files: mergedFiles });
322
+ const refreshed = await loadBuildMetadata({
323
+ options,
324
+ auth,
325
+ buildId: contributionBuildId,
326
+ }).catch(() => null);
327
+ if (refreshed) {
328
+ await writeProjectMetadata({
329
+ dir,
330
+ options,
331
+ build: refreshed,
332
+ manifest: localProject?.metadata?.manifest || null,
333
+ pulledAt: new Date().toISOString(),
334
+ });
335
+ }
336
+ }
337
+ printUpdateFromMainResult({ result, build, dir, mergedFiles });
338
+ }
339
+
340
+ export async function reference(options) {
341
+ const auth = await resolveAuth(options);
342
+ const buildId = await resolveRequiredBuildIdOrSelected(options, auth);
343
+ const result = await pullReferenceFiles({ options, auth, buildId });
344
+ printReferenceResult(result);
345
+ }
346
+
347
+ export async function fork(options) {
348
+ const auth = await resolveAuth(options);
349
+ await assertAuthScope({ options, auth, scope: "build:write" });
350
+ const buildId = await resolveRequiredBuildIdOrSelected(options, auth);
351
+ const forkResult = await forkBuild({ options, auth, buildId });
352
+ const forkedBuildId = Number(forkResult.build?.id || 0);
353
+ if (!forkedBuildId) {
354
+ throw new Error("Twinkle did not return a forked Build.");
355
+ }
356
+ const result = await pullBuildFiles({
357
+ options,
358
+ auth,
359
+ buildId: forkedBuildId,
360
+ });
361
+ await saveSelectedBuild({ options, auth, build: result.build });
362
+ printForkResult({ forkResult, pullResult: result });
363
+ }
364
+
365
+ export async function diff(options) {
366
+ const auth = await resolveAuth(options);
367
+ const build = await loadTargetBuildMetadata({ options, auth });
368
+ const { rootBuildId, contributionBuildId } =
369
+ resolveContributionActionBuildIds(build);
370
+ const result = await loadContributionDiff({
371
+ options,
372
+ auth,
373
+ rootBuildId,
374
+ contributionBuildId,
375
+ });
376
+ printContributionDiff({ result, build });
377
+ }
378
+
379
+ export async function mergeBranch(options) {
380
+ const auth = await resolveAuth(options);
381
+ await assertAuthScope({ options, auth, scope: "build:write" });
382
+ const build = await loadTargetBuildMetadata({ options, auth });
383
+ const { rootBuildId, contributionBuildId } =
384
+ resolveContributionActionBuildIds(build);
385
+ const result = await mergeContributionIntoMain({
386
+ options,
387
+ auth,
388
+ rootBuildId,
389
+ contributionBuildId,
390
+ });
391
+ printContributionActionResult({
392
+ action: "Merged",
393
+ result,
394
+ rootBuildId,
395
+ contributionBuildId,
396
+ });
397
+ }
398
+
399
+ export async function replaceMainWithBranch(options) {
400
+ const auth = await resolveAuth(options);
401
+ await assertAuthScope({ options, auth, scope: "build:write" });
402
+ const build = await loadTargetBuildMetadata({ options, auth });
403
+ const { rootBuildId, contributionBuildId } =
404
+ resolveContributionActionBuildIds(build);
405
+ const result = await replaceMainWithContribution({
406
+ options,
407
+ auth,
408
+ rootBuildId,
409
+ contributionBuildId,
410
+ });
411
+ printContributionActionResult({
412
+ action: "Replaced main with",
413
+ result,
414
+ rootBuildId,
415
+ contributionBuildId,
416
+ });
417
+ }
418
+
419
+ export async function save(options) {
420
+ const auth = await resolveAuth(options);
421
+ await assertAuthScope({ options, auth, scope: "build:write" });
422
+ const localProject = await findLocalProjectMetadata(
423
+ path.resolve(options.dir || process.cwd()),
424
+ );
425
+ assertLocalProjectCanBeSaved(localProject);
426
+ let buildId = await resolveRequiredBuildIdOrSelected(options, auth, {
427
+ localProject,
428
+ });
429
+ let build = await resolveBuildForSave({
430
+ options,
431
+ auth,
432
+ buildId,
433
+ localProject,
434
+ });
435
+ if (build?.canWrite === false) {
436
+ throw new Error(
437
+ "This checkout is read-only for the current CLI login. Pull or diff it for review, then merge or replace main from the project owner workflow.",
438
+ );
439
+ }
440
+ buildId = Number(build?.id || buildId);
441
+ const dir = resolveProjectDirForSave({ options, localProject });
442
+ const files = await collectProjectFiles(dir);
443
+ assertProjectFilesWithinLimits(files);
444
+ const result = await saveProjectFiles({
445
+ options,
446
+ auth,
447
+ buildId,
448
+ files,
449
+ summary: options.summary || DEFAULT_SAVE_SUMMARY,
450
+ });
451
+ build = result.build ||
452
+ build ||
453
+ (await loadBuildMetadata({ options, auth, buildId }).catch(() => null)) || {
454
+ id: buildId,
455
+ title: `Build ${buildId}`,
456
+ };
457
+ await saveSelectedBuild({ options, auth, build });
458
+ await writeProjectMetadata({
459
+ dir,
460
+ options,
461
+ build,
462
+ manifest: result.projectManifest || null,
463
+ lastSavedAt: new Date().toISOString(),
464
+ });
465
+ printSaveResult({ result, build, dir, files });
466
+
467
+ if (options.publish) {
468
+ if (build?.canPublish === false) {
469
+ console.log(
470
+ "Saved to your branch. The project owner can merge or replace main from Twinkle.",
471
+ );
472
+ return;
473
+ }
474
+ const publish = await publishBuild({ options, buildId, auth });
475
+ if (publish.skipped) {
476
+ console.log("Publish skipped: already up to date.");
477
+ } else {
478
+ console.log("Publish complete.");
479
+ }
480
+ console.log(`App: ${options.siteUrl}/app/${buildId}`);
481
+ }
482
+ }
483
+
484
+ export async function check(options) {
485
+ await reportLocalProjectFindings(options);
486
+ const auth = await resolveAuth(options);
487
+ const buildId = await resolveRequiredBuildIdOrSelected(options, auth);
488
+ const result = await requestJson({
489
+ url: `${options.apiUrl}/cli/build/${buildId}/launch-check`,
490
+ authToken: auth.token,
491
+ timeoutMs: options.timeoutMs,
492
+ });
493
+ printCheck(result);
494
+ if (!result.ok) process.exitCode = 1;
495
+ }
496
+
497
+ // Local, network-free half of `lumine check`: validate the workspace against
498
+ // the platform's project-file rules so agents see violations before saving.
499
+ export async function reportLocalProjectFindings(options) {
500
+ const localProject = await findLocalProjectMetadata(
501
+ path.resolve(options.dir || process.cwd()),
502
+ );
503
+ if (!localProject?.rootDir || localProject?.metadata?.readOnly) return;
504
+ let files;
505
+ try {
506
+ files = await collectProjectFiles(localProject.rootDir);
507
+ } catch (error) {
508
+ console.error(`Local check failed: ${error?.message || error}`);
509
+ process.exitCode = 1;
510
+ return;
511
+ }
512
+ const { errors, warnings } = collectProjectLimitFindings(files);
513
+ for (const warning of warnings) {
514
+ console.error(`Local check warning: ${warning}`);
515
+ }
516
+ for (const errorMessage of errors) {
517
+ console.error(`Local check error: ${errorMessage}`);
518
+ }
519
+ if (errors.length > 0) {
520
+ process.exitCode = 1;
521
+ } else {
522
+ console.log(
523
+ `Local check: ${files.length} project file${files.length === 1 ? "" : "s"} within limits.`,
524
+ );
525
+ }
526
+ }
527
+
528
+ export async function launch(options) {
529
+ const auth = await resolveAuth(options);
530
+ if (options.saveFirst) {
531
+ await save({
532
+ ...options,
533
+ publish: false,
534
+ saveFirst: false,
535
+ });
536
+ }
537
+ const buildId = await resolveRequiredBuildIdOrSelected(options, auth);
538
+ const checkResult = await requestJson({
539
+ url: `${options.apiUrl}/cli/build/${buildId}/launch-check`,
540
+ authToken: auth.token,
541
+ timeoutMs: options.timeoutMs,
542
+ });
543
+ printCheck(checkResult);
544
+ const launchOk =
545
+ typeof checkResult.launchOk === "boolean"
546
+ ? checkResult.launchOk
547
+ : checkResult.ok;
548
+ if (!checkResult.ok || !launchOk) {
549
+ process.exitCode = 1;
550
+ return;
551
+ }
552
+
553
+ const publish = await publishBuild({ options, buildId, auth });
554
+ const build = publish.build || checkResult.build || {};
555
+ const appUrl = `${options.siteUrl}/app/${buildId}`;
556
+ const versionId =
557
+ Number(build.publishedArtifactVersionId || 0) ||
558
+ Number(build.releaseStatus?.publishedArtifactVersionId || 0) ||
559
+ 0;
560
+
561
+ const appProbe = await probeUrl({
562
+ url: appUrl,
563
+ timeoutMs: options.timeoutMs,
564
+ });
565
+ const previewProbe = versionId
566
+ ? await probeUrl({
567
+ url: `${options.apiUrl}/build/preview/build/${buildId}/version/${versionId}`,
568
+ authToken: auth.token,
569
+ timeoutMs: options.timeoutMs,
570
+ })
571
+ : null;
572
+
573
+ if (publish.skipped) {
574
+ console.log("Publish skipped: already up to date.");
575
+ } else {
576
+ console.log("Publish complete.");
577
+ }
578
+ console.log(`App: ${appUrl}`);
579
+ console.log(
580
+ `Prod shell: ${appProbe.ok ? "ok" : "fail"} ${appProbe.status} bytes=${appProbe.bytes}`,
581
+ );
582
+ if (previewProbe) {
583
+ console.log(
584
+ `Published preview: ${previewProbe.ok ? "ok" : "fail"} ` +
585
+ `${previewProbe.status} bytes=${previewProbe.bytes}`,
586
+ );
587
+ }
588
+
589
+ if (!appProbe.ok || (previewProbe && !previewProbe.ok) || !versionId) {
590
+ process.exitCode = 1;
591
+ }
592
+ }
593
+
594
+ export function printCheck(result) {
595
+ const checks = result.checks || {};
596
+ console.log(`Launch check: ${result.ok ? "ok" : "fail"}`);
597
+ if (checks.canonicalBuild) {
598
+ console.log(
599
+ `- canonical build: ${checks.canonicalBuild.ok ? "ok" : "fail"}`,
600
+ );
601
+ }
602
+ console.log(
603
+ `- project files: ${checks.projectFiles?.ok ? "ok" : "fail"} ` +
604
+ `files=${checks.projectFiles?.fileCount ?? 0}`,
605
+ );
606
+ console.log(`- toolchain: ${checks.toolchain?.ok ? "ok" : "fail"}`);
607
+ if (checks.publishPermission) {
608
+ console.log(
609
+ `- publish permission: ${checks.publishPermission.ok ? "ok" : "fail"}`,
610
+ );
611
+ if (checks.publishPermission.reason) {
612
+ console.log(` ${checks.publishPermission.reason}`);
613
+ }
614
+ }
615
+ if (typeof result.launchOk === "boolean") {
616
+ console.log(`- launch gate: ${result.launchOk ? "ok" : "fail"}`);
617
+ }
618
+ console.log(
619
+ `- conflict markers: ${checks.conflictMarkers?.ok ? "ok" : "fail"}`,
620
+ );
621
+ for (const diagnostic of checks.toolchain?.diagnostics || []) {
622
+ console.log(
623
+ ` ${diagnostic.kind} ${diagnostic.filePath}` +
624
+ `${diagnostic.line ? `:${diagnostic.line}` : ""} ${diagnostic.message}`,
625
+ );
626
+ }
627
+ for (const conflictPath of checks.conflictMarkers?.paths || []) {
628
+ console.log(` conflict marker: ${conflictPath}`);
629
+ }
630
+ }
631
+
632
+ export async function loadTargetBuildMetadata({ options, auth }) {
633
+ const buildId = await resolveRequiredBuildIdOrSelected(options, auth);
634
+ return await loadBuildMetadata({ options, auth, buildId });
635
+ }
636
+
637
+ export async function resolveBuildForSave({ options, auth, buildId, localProject }) {
638
+ const localBuild = localProject?.metadata?.build;
639
+ const localBuildId =
640
+ Number(localBuild?.id || 0) ||
641
+ Number(localProject?.metadata?.buildId || 0);
642
+ const build =
643
+ localBuild && localBuildId === Number(buildId)
644
+ ? { ...localBuild, id: buildId }
645
+ : await loadBuildMetadata({ options, auth, buildId });
646
+ return await resolveEditableWorkspaceBuild({
647
+ options,
648
+ auth,
649
+ build,
650
+ skipWriteScopeCheck: true,
651
+ });
652
+ }
653
+
654
+ export async function resolveEditableWorkspaceBuild({
655
+ options,
656
+ auth,
657
+ build,
658
+ skipWriteScopeCheck = false,
659
+ }) {
660
+ if (!shouldUseContributionBranch(build)) return build;
661
+
662
+ if (!skipWriteScopeCheck) {
663
+ await assertAuthScope({ options, auth, scope: "build:write" });
664
+ }
665
+ const branch = await ensureDefaultContributionBranch({
666
+ options,
667
+ auth,
668
+ build,
669
+ });
670
+ console.log(
671
+ `Using your branch ${formatBuildTitle(branch)} for team project ${formatBuildTitle(build)}.`,
672
+ );
673
+ return branch;
674
+ }
675
+
676
+ export function shouldUseContributionBranch(build) {
677
+ return (
678
+ build?.role === "collaborator" &&
679
+ build.canWrite !== true &&
680
+ !isContributionBranch(build)
681
+ );
682
+ }
683
+
684
+ export async function ensureDefaultContributionBranch({ options, auth, build }) {
685
+ const rootBuildId =
686
+ Number(build?.contributionRootBuildId || 0) || Number(build?.id || 0);
687
+ if (!rootBuildId) {
688
+ throw new Error("Could not resolve the team project for this branch.");
689
+ }
690
+
691
+ const result = await requestJson({
692
+ method: "POST",
693
+ url: `${options.apiUrl}/build/${rootBuildId}/contributions/default-branch`,
694
+ authToken: auth.token,
695
+ body: {},
696
+ timeoutMs: options.timeoutMs,
697
+ });
698
+ const branchId = Number(result.build?.id || 0);
699
+ if (!branchId) {
700
+ throw new Error("Twinkle did not return a contribution branch.");
701
+ }
702
+
703
+ return await loadBuildMetadata({
704
+ options,
705
+ auth,
706
+ buildId: branchId,
707
+ }).catch(() =>
708
+ normalizeContributionBranchBuild({
709
+ branch: result.build,
710
+ sourceBuild: build,
711
+ }),
712
+ );
713
+ }
714
+
715
+ export function normalizeContributionBranchBuild({ branch, sourceBuild }) {
716
+ const branchId = Number(branch?.id || 0);
717
+ return {
718
+ ...branch,
719
+ id: branchId,
720
+ title: branch?.title || `Branch ${branchId}`,
721
+ role: "owner",
722
+ ownerUsername:
723
+ branch?.ownerUsername ||
724
+ branch?.username ||
725
+ sourceBuild?.ownerUsername ||
726
+ null,
727
+ canWrite: true,
728
+ canPublish: false,
729
+ contributionStatus: branch?.contributionStatus || "draft",
730
+ contributionRootBuildId:
731
+ Number(branch?.contributionRootBuildId || sourceBuild?.id || 0) || null,
732
+ contributionContributorId:
733
+ Number(branch?.contributionContributorId || branch?.userId || 0) || null,
734
+ contributionBranchNumber:
735
+ Number(branch?.contributionBranchNumber || 0) || null,
736
+ };
737
+ }
738
+
739
+ export async function chooseProject({ builds }) {
740
+ if (!builds.length) {
741
+ throw new Error("No owned or team Twinkle builds were found.");
742
+ }
743
+ if (builds.length === 1) {
744
+ console.log(`Selected ${formatBuildTitle(builds[0])}.`);
745
+ return builds[0];
746
+ }
747
+ if (!input.isTTY || !output.isTTY) {
748
+ throw new Error(
749
+ "Choose a project by running `lumine select <twinkle-build-url>`.",
750
+ );
751
+ }
752
+
753
+ printBuildList(builds);
754
+ const rl = readline.createInterface({ input, output });
755
+ try {
756
+ while (true) {
757
+ const answer = await rl.question("Choose a project number: ");
758
+ const index = Number(answer.trim());
759
+ if (Number.isInteger(index) && index >= 1 && index <= builds.length) {
760
+ return builds[index - 1];
761
+ }
762
+ console.log(`Enter a number from 1 to ${builds.length}.`);
763
+ }
764
+ } finally {
765
+ rl.close();
766
+ }
767
+ }
768
+
769
+ export async function pullBuildFiles({ options, auth, buildId }) {
770
+ const result = await loadBuildFiles({
771
+ options,
772
+ auth,
773
+ buildId,
774
+ includeContent: true,
775
+ });
776
+ const build = result.build || { id: buildId, title: `Build ${buildId}` };
777
+ const files = Array.isArray(result.projectFiles) ? result.projectFiles : [];
778
+ const dir = path.resolve(options.dir || defaultWorkspaceDir(build));
779
+ await writeProjectFiles({ dir, files });
780
+ await writeAgentInstructions({ dir });
781
+ await writeSdkReference({ dir });
782
+ await writeProjectMetadata({
783
+ dir,
784
+ options,
785
+ build,
786
+ manifest: result.projectManifest || null,
787
+ pulledAt: new Date().toISOString(),
788
+ });
789
+ // Best-effort asset manifest so terminal agents know what media already
790
+ // exists (the workspace UI gives Lumine the same context). Never fail the
791
+ // pull over it.
792
+ let assetCount = null;
793
+ try {
794
+ const { token } = await mintBuildApiToken({
795
+ options,
796
+ auth,
797
+ buildId: Number(build.id || buildId),
798
+ scopes: ["files:read"],
799
+ });
800
+ const { assets, usage, projectAssets } = await fetchAllRuntimeAssets({
801
+ options,
802
+ auth,
803
+ buildId: Number(build.id || buildId),
804
+ buildApiToken: token,
805
+ });
806
+ await writeAssetsManifest({
807
+ dir,
808
+ buildId: Number(build.id || buildId),
809
+ assets,
810
+ usage,
811
+ projectAssets,
812
+ codeFiles: files,
813
+ });
814
+ assetCount = assets.length + (projectAssets?.length || 0);
815
+ } catch (error) {
816
+ console.error(
817
+ `lumine: skipped ${ASSETS_METADATA_FILE} (${error?.message || error})`,
818
+ );
819
+ }
820
+ return {
821
+ build,
822
+ dir,
823
+ fileCount: files.length,
824
+ manifest: result.projectManifest || null,
825
+ assetCount,
826
+ };
827
+ }
828
+
829
+ export async function pullReferenceFiles({ options, auth, buildId }) {
830
+ const result = await loadOpenSourceBuildFiles({
831
+ options,
832
+ auth,
833
+ buildId,
834
+ includeContent: true,
835
+ });
836
+ const build = result.build || { id: buildId, title: `Build ${buildId}` };
837
+ const files = Array.isArray(result.projectFiles) ? result.projectFiles : [];
838
+ const dir = path.resolve(options.dir || defaultReferenceDir(build));
839
+ await writeProjectFiles({ dir, files });
840
+ await writeReferenceInstructions({ dir });
841
+ await writeSdkReference({ dir });
842
+ await writeReferenceMetadata({
843
+ dir,
844
+ options,
845
+ build,
846
+ manifest: result.projectManifest || null,
847
+ reference: result.reference || {
848
+ readOnly: true,
849
+ forkable: true,
850
+ sourceBuildId: Number(build.id || buildId),
851
+ },
852
+ pulledAt: new Date().toISOString(),
853
+ });
854
+ return {
855
+ build,
856
+ dir,
857
+ fileCount: files.length,
858
+ manifest: result.projectManifest || null,
859
+ reference: result.reference || null,
860
+ };
861
+ }
862
+
863
+ // Read-only checkout of a team project's MAIN workspace. Collaborators edit on
864
+ // their contribution branch; this exists so branch work can consult what main
865
+ // currently looks like without the pull auto-redirecting to the branch.
866
+ export async function pullMainBuildFiles({ options, auth, build }) {
867
+ const rootBuildId =
868
+ Number(build?.contributionRootBuildId || 0) || Number(build?.id || 0);
869
+ if (!rootBuildId) {
870
+ throw new Error("Could not resolve the team project for --main.");
871
+ }
872
+ const result = await loadBuildFiles({
873
+ options,
874
+ auth,
875
+ buildId: rootBuildId,
876
+ includeContent: true,
877
+ });
878
+ const rootBuild = result.build || { id: rootBuildId, title: `Build ${rootBuildId}` };
879
+ const files = Array.isArray(result.projectFiles) ? result.projectFiles : [];
880
+ // Never nest a main checkout inside another Lumine workspace (a later save
881
+ // there would upload it as project files) — default to a sibling instead.
882
+ // Running from a main checkout of this same build refreshes it in place,
883
+ // and the refresh prunes files main has deleted (a snapshot must not lie).
884
+ const enclosing = options.dir
885
+ ? null
886
+ : await findLocalProjectMetadata(process.cwd());
887
+ const enclosingIsThisMain =
888
+ enclosing?.metadata?.mainCheckout === true &&
889
+ Number(enclosing.metadata.buildId || 0) === rootBuildId;
890
+ const dir = path.resolve(
891
+ options.dir ||
892
+ (enclosingIsThisMain
893
+ ? enclosing.rootDir
894
+ : enclosing
895
+ ? path.join(path.dirname(enclosing.rootDir), defaultMainCheckoutDir(rootBuild))
896
+ : defaultMainCheckoutDir(rootBuild)),
897
+ );
898
+ await writeProjectFiles({ dir, files });
899
+ if (files.some((file) => isIndexHtmlPath(String(file.path)))) {
900
+ await removeLocalProjectFilesNotIn({ dir, files });
901
+ }
902
+ await writeInstructionFiles({
903
+ dir,
904
+ marker: LUMINE_MAIN_CHECKOUT_INSTRUCTIONS_MARKER,
905
+ content: LUMINE_MAIN_CHECKOUT_INSTRUCTIONS,
906
+ });
907
+ await writeSdkReference({ dir });
908
+ await writeMainCheckoutMetadata({
909
+ dir,
910
+ options,
911
+ build: rootBuild,
912
+ manifest: result.projectManifest || null,
913
+ pulledAt: new Date().toISOString(),
914
+ });
915
+ console.log(`Pulled main for ${formatBuildTitle(rootBuild)} (read-only).`);
916
+ console.log(`Pulled ${files.length} file${files.length === 1 ? "" : "s"} to ${dir}`);
917
+ console.log(
918
+ `Edits belong on your branch: \`lumine pull ${rootBuildId}\`, and \`lumine update-from-main\` brings main's changes into it.`,
919
+ );
920
+ }
921
+
922
+ export function printBuildList(builds) {
923
+ if (!builds.length) {
924
+ console.log("No owned or team Twinkle builds found.");
925
+ return;
926
+ }
927
+ console.log("Twinkle builds:");
928
+ builds.forEach((build, index) => {
929
+ console.log(`${index + 1}. ${formatBuildListItem(build)}`);
930
+ });
931
+ }
932
+
933
+ export function printOpenSourceBuildList(builds, options) {
934
+ if (!builds.length) {
935
+ const searchText = options.searchQuery
936
+ ? ` matching "${options.searchQuery}"`
937
+ : "";
938
+ console.log(`No public open-source Twinkle builds found${searchText}.`);
939
+ return;
940
+ }
941
+ const searchText = options.searchQuery ? ` for "${options.searchQuery}"` : "";
942
+ console.log(`Public open-source Twinkle builds${searchText}:`);
943
+ builds.forEach((build, index) => {
944
+ console.log(`${index + 1}. ${formatOpenSourceBuildListItem(build)}`);
945
+ });
946
+ console.log("Reference: lumine reference <build-id>");
947
+ console.log("Fork: lumine fork <build-id>");
948
+ }
949
+
950
+ export function formatBuildListItem(build) {
951
+ const role =
952
+ build.role === "owner"
953
+ ? "owned by you"
954
+ : `team project${build.ownerUsername ? ` with ${build.ownerUsername}` : ""}`;
955
+ const published = build.isPublic ? "public" : "private";
956
+ return `${formatBuildTitle(build)} - ${role}, ${published}`;
957
+ }
958
+
959
+ export function formatOpenSourceBuildListItem(build) {
960
+ const owner = build.ownerUsername ? ` by ${build.ownerUsername}` : "";
961
+ const stats = [
962
+ `${Math.max(0, Number(build.forkCount || 0))} forks`,
963
+ `${Math.max(0, Number(build.viewCount || 0))} views`,
964
+ ].join(", ");
965
+ const appUrl = build.appUrl ? ` - ${build.appUrl}` : "";
966
+ return `${formatBuildTitle(build)}${owner} - ${stats}${appUrl}`;
967
+ }
968
+
969
+ export function formatBuildTitle(build) {
970
+ return `${build.title || `Build ${build.id}`} (#${build.id})`;
971
+ }
972
+
973
+ export function isContributionBranch(build) {
974
+ return (
975
+ String(build?.contributionStatus || "none") !== "none" ||
976
+ Number(build?.contributionRootBuildId || 0) > 0 ||
977
+ Number(build?.contributionBranchNumber || 0) > 0
978
+ );
979
+ }
980
+
981
+ export function resolveContributionActionBuildIds(build) {
982
+ const contributionBuildId = Number(build?.id || 0);
983
+ const rootBuildId = Number(build?.contributionRootBuildId || 0);
984
+ if (!contributionBuildId || !rootBuildId || !isContributionBranch(build)) {
985
+ throw new Error(
986
+ "Pass a branch URL such as https://www.twin-kle.com/build/884/4, or run this from a pulled branch workspace.",
987
+ );
988
+ }
989
+ return { rootBuildId, contributionBuildId };
990
+ }
991
+
992
+ export function printPullResult(result) {
993
+ const build = result.build || {};
994
+ const entryPath = result.manifest?.entryPath || "unknown";
995
+ console.log(`Selected ${formatBuildTitle(build)}.`);
996
+ console.log(
997
+ `Pulled ${result.fileCount} file${result.fileCount === 1 ? "" : "s"} to ${result.dir}`,
998
+ );
999
+ console.log(`Entry: ${entryPath}`);
1000
+ console.log(`SDK reference: ${SDK_REFERENCE_FILE}`);
1001
+ if (typeof result.assetCount === "number") {
1002
+ console.log(
1003
+ result.assetCount > 0
1004
+ ? `Assets: ${result.assetCount} uploaded (URLs in ${PROJECT_METADATA_DIR}/${ASSETS_METADATA_FILE})`
1005
+ : "Assets: none uploaded yet (add media with `lumine assets upload <file>`)",
1006
+ );
1007
+ }
1008
+ console.log(`Next: cd ${shellQuote(result.dir)}`);
1009
+ if (build.canWrite === false) {
1010
+ console.log("This checkout is read-only for the current CLI login.");
1011
+ if (isContributionBranch(build)) {
1012
+ console.log("Review changes: lumine diff");
1013
+ if (build.role === "project_owner") {
1014
+ console.log("Owner actions: lumine merge, or lumine replace-main");
1015
+ }
1016
+ }
1017
+ return;
1018
+ }
1019
+ if (result.fileCount === 0) {
1020
+ console.log("No project files yet. Create /index.html before your first save.");
1021
+ }
1022
+ console.log('Codex: codex "Read AGENTS.md, then make the requested change."');
1023
+ console.log(
1024
+ 'Claude Code: claude "Read CLAUDE.md, then make the requested change."',
1025
+ );
1026
+ console.log('Save after edits: lumine save --summary "Describe the change"');
1027
+ if (isContributionBranch(build) && build.canPublish === false) {
1028
+ console.log("The project owner can merge or replace main from Twinkle.");
1029
+ } else {
1030
+ console.log("Run `lumine check` or `lumine launch --save` when ready.");
1031
+ }
1032
+ }
1033
+
1034
+ export function printNewBuildResult({ createResult, pullResult }) {
1035
+ const build = pullResult.build || createResult.build || {};
1036
+ console.log(`Created ${formatBuildTitle(build)}.`);
1037
+ printPullResult(pullResult);
1038
+ }
1039
+
1040
+ export function printReferenceResult(result) {
1041
+ const build = result.build || {};
1042
+ const sourceBuildId =
1043
+ Number(result.reference?.sourceBuildId || 0) || Number(build.id || 0);
1044
+ const entryPath = result.manifest?.entryPath || "unknown";
1045
+ console.log(`Referenced ${formatBuildTitle(build)}.`);
1046
+ console.log(
1047
+ `Pulled ${result.fileCount} file${result.fileCount === 1 ? "" : "s"} to ${result.dir}`,
1048
+ );
1049
+ console.log(`Entry: ${entryPath}`);
1050
+ console.log("Mode: read-only reference");
1051
+ console.log(`Next: cd ${shellQuote(result.dir)}`);
1052
+ if (sourceBuildId) {
1053
+ console.log(`Start from this app: lumine fork ${sourceBuildId}`);
1054
+ }
1055
+ }
1056
+
1057
+ export function printForkResult({ forkResult, pullResult }) {
1058
+ const build = pullResult.build || forkResult.build || {};
1059
+ const sourceBuildId =
1060
+ Number(forkResult.sourceBuild?.id || 0) ||
1061
+ Number(forkResult.sourceBuild?.contentId || 0) ||
1062
+ 0;
1063
+ console.log(
1064
+ forkResult.alreadyExists
1065
+ ? `Using your existing fork ${formatBuildTitle(build)}.`
1066
+ : `Forked Build ${sourceBuildId || "source"} into ${formatBuildTitle(
1067
+ build,
1068
+ )}.`,
1069
+ );
1070
+ printPullResult(pullResult);
1071
+ }
1072
+
1073
+ export function printSaveResult({ result, build, dir, files }) {
1074
+ const entryPath = result.projectManifest?.entryPath || "unknown";
1075
+ const version = result.artifactVersion?.versionNumber
1076
+ ? ` v${result.artifactVersion.versionNumber}`
1077
+ : "";
1078
+ const releaseState = result.releaseStatus?.state || "unknown";
1079
+ console.log(`Saved ${formatBuildTitle(build)}${version}.`);
1080
+ console.log(
1081
+ `Uploaded ${files.length} file${files.length === 1 ? "" : "s"} from ${dir}`,
1082
+ );
1083
+ console.log(`Entry: ${entryPath}`);
1084
+ console.log(`Release status: ${releaseState}`);
1085
+ if (isContributionBranch(build) && build.canPublish === false) {
1086
+ console.log(
1087
+ "Next: the project owner can merge or replace main from Twinkle.",
1088
+ );
1089
+ } else {
1090
+ console.log(
1091
+ "Next: run `lumine launch` to publish, or `lumine save --publish` next time.",
1092
+ );
1093
+ }
1094
+ }
1095
+
1096
+ export function printContributionDiff({ result, build }) {
1097
+ const summary = result.diff?.summary || {};
1098
+ const files = Array.isArray(result.diff?.changedFiles)
1099
+ ? result.diff.changedFiles
1100
+ : [];
1101
+ const branchNumber = Number(build.contributionBranchNumber || 0) || 0;
1102
+ const branchLabel = branchNumber ? `branch ${branchNumber}` : `branch #${build.id}`;
1103
+ console.log(`Diff for ${branchLabel}:`);
1104
+ console.log(
1105
+ `- total=${summary.total ?? files.length} added=${summary.added ?? 0} ` +
1106
+ `updated=${summary.updated ?? 0} deleted=${summary.deleted ?? 0}`,
1107
+ );
1108
+ if (result.rootDrifted) {
1109
+ console.log("- main changed after this branch was created");
1110
+ }
1111
+ if (!files.length) {
1112
+ console.log("No file changes.");
1113
+ return;
1114
+ }
1115
+ for (const file of files) {
1116
+ const mergeStatus = file.mergeStatus ? ` (${file.mergeStatus})` : "";
1117
+ console.log(`- ${file.status || "changed"} ${file.path}${mergeStatus}`);
1118
+ }
1119
+ }
1120
+
1121
+ export function printUpdateFromMainResult({ result, build, dir, mergedFiles }) {
1122
+ const branchNumber = Number(build?.contributionBranchNumber || 0) || 0;
1123
+ const branchLabel = branchNumber
1124
+ ? `branch ${branchNumber}`
1125
+ : `branch #${build?.id}`;
1126
+ const autoMerged = Array.isArray(result.autoMergedPaths)
1127
+ ? result.autoMergedPaths
1128
+ : [];
1129
+ const conflicts = Array.isArray(result.conflicts) ? result.conflicts : [];
1130
+ console.log(
1131
+ `Updated ${branchLabel} from main (${mergedFiles.length} file${mergedFiles.length === 1 ? "" : "s"}).`,
1132
+ );
1133
+ if (autoMerged.length > 0) {
1134
+ console.log(`Auto-merged: ${autoMerged.join(", ")}`);
1135
+ }
1136
+ if (conflicts.length > 0) {
1137
+ const conflictPaths = conflicts.map((conflict) =>
1138
+ typeof conflict === "string" ? conflict : conflict?.path || "unknown",
1139
+ );
1140
+ console.log(`CONFLICTS (markers written): ${conflictPaths.join(", ")}`);
1141
+ console.log(
1142
+ dir
1143
+ ? "Resolve the <<<<<<< / >>>>>>> markers in the files above, then run `lumine save`."
1144
+ : "Pull the branch, resolve the <<<<<<< / >>>>>>> markers, then run `lumine save`.",
1145
+ );
1146
+ } else if (!dir) {
1147
+ console.log(
1148
+ "The branch was updated on Twinkle. Run `lumine pull` to refresh your local workspace.",
1149
+ );
1150
+ } else {
1151
+ console.log(`Local workspace updated: ${dir}`);
1152
+ }
1153
+ }
1154
+
1155
+ export function printContributionActionResult({
1156
+ action,
1157
+ result,
1158
+ rootBuildId,
1159
+ contributionBuildId,
1160
+ }) {
1161
+ if (!result?.success) {
1162
+ console.log(result?.error || "Branch action did not complete.");
1163
+ process.exitCode = 1;
1164
+ return;
1165
+ }
1166
+ const projectFiles = Array.isArray(result.projectFiles)
1167
+ ? result.projectFiles
1168
+ : [];
1169
+ console.log(`${action} branch #${contributionBuildId} for Build #${rootBuildId}.`);
1170
+ if (projectFiles.length > 0) {
1171
+ console.log(
1172
+ `Main now has ${projectFiles.length} project file${projectFiles.length === 1 ? "" : "s"}.`,
1173
+ );
1174
+ }
1175
+ if (result.mergeConflictsWritten || result.conflicts?.length > 0) {
1176
+ console.log("Merge wrote conflict markers. Resolve them in the Build workspace.");
1177
+ }
1178
+ console.log(`Main workspace: ${rootBuildId}`);
1179
+ }
1180
+
1181
+ export function parseArgs(args) {
1182
+ const firstArg = args[0] || "";
1183
+ const firstArgIsCommand =
1184
+ firstArg && !firstArg.startsWith("--") && COMMANDS.has(firstArg);
1185
+ const firstArgLooksLikeTarget =
1186
+ firstArg && !firstArg.startsWith("--") && resolveBuildId(firstArg) > 0;
1187
+ const command = firstArgIsCommand
1188
+ ? firstArg
1189
+ : !firstArg || firstArg.startsWith("--") || firstArgLooksLikeTarget
1190
+ ? "workspace"
1191
+ : "help";
1192
+ const rest = command === "workspace" ? args : args.slice(1);
1193
+ const raw = {};
1194
+ const positional = [];
1195
+ const booleanFlags = new Set([
1196
+ "noOpen",
1197
+ "open",
1198
+ "noDescription",
1199
+ "publish",
1200
+ "save",
1201
+ "noUpdateCheck",
1202
+ "allowWrite",
1203
+ "main",
1204
+ "yes",
1205
+ ]);
1206
+
1207
+ for (let i = 0; i < rest.length; i += 1) {
1208
+ const arg = rest[i];
1209
+ if (arg === "--help" || arg === "-h") {
1210
+ raw.help = true;
1211
+ continue;
1212
+ }
1213
+ if (!arg.startsWith("--")) {
1214
+ positional.push(arg);
1215
+ continue;
1216
+ }
1217
+ const [key, inlineValue] = arg.slice(2).split("=", 2);
1218
+ const camelKey = toCamelCase(key);
1219
+ if (inlineValue !== undefined) {
1220
+ raw[camelKey] = inlineValue;
1221
+ } else if (booleanFlags.has(camelKey)) {
1222
+ raw[camelKey] = true;
1223
+ } else {
1224
+ raw[camelKey] = rest[i + 1] ?? "";
1225
+ i += 1;
1226
+ }
1227
+ }
1228
+
1229
+ return {
1230
+ command,
1231
+ positional,
1232
+ repeat: Math.min(Math.max(Math.floor(Number(raw.repeat) || 1), 1), 20),
1233
+ allowWrite: parseBoolean(raw.allowWrite, false),
1234
+ assumeYes: parseBoolean(raw.yes, false),
1235
+ sdkPath: raw.path ? String(raw.path) : "",
1236
+ sdkScopes: String(raw.scopes || "")
1237
+ .split(",")
1238
+ .map((scope) => scope.trim())
1239
+ .filter(Boolean),
1240
+ buildIdFlag: raw.build ? String(raw.build) : "",
1241
+ model: raw.model ? String(raw.model) : "",
1242
+ quality: raw.quality ? String(raw.quality) : "",
1243
+ assetName: raw.name ? String(raw.name) : "",
1244
+ out: raw.out ? String(raw.out) : "",
1245
+ target: raw.url || raw.target || positional[0] || "",
1246
+ title:
1247
+ String(
1248
+ raw.title ||
1249
+ (command === "new" ? positional.join(" ") : ""),
1250
+ ).trim() || "",
1251
+ description:
1252
+ Object.prototype.hasOwnProperty.call(raw, "description")
1253
+ ? String(raw.description || "").trim()
1254
+ : null,
1255
+ descriptionProvided: Object.prototype.hasOwnProperty.call(
1256
+ raw,
1257
+ "description",
1258
+ ),
1259
+ noDescription: parseBoolean(raw.noDescription, false),
1260
+ searchQuery:
1261
+ String(
1262
+ raw.search ||
1263
+ raw.query ||
1264
+ (command === "explore" ? positional.join(" ") : ""),
1265
+ ).trim() || "",
1266
+ sort: normalizeOpenSourceSort(raw.sort),
1267
+ apiUrl: trimTrailingSlash(
1268
+ String(raw.apiUrl || process.env.TWINKLE_API_URL || DEFAULT_API_URL),
1269
+ ),
1270
+ siteUrl: trimTrailingSlash(
1271
+ String(raw.siteUrl || process.env.TWINKLE_SITE_URL || DEFAULT_SITE_URL),
1272
+ ),
1273
+ npmRegistryUrl: trimTrailingSlash(
1274
+ String(
1275
+ raw.npmRegistryUrl ||
1276
+ process.env.LUMINE_NPM_REGISTRY_URL ||
1277
+ DEFAULT_NPM_REGISTRY_URL,
1278
+ ),
1279
+ ),
1280
+ authFile: String(
1281
+ raw.authFile || process.env.TWINKLE_CLI_AUTH_FILE || DEFAULT_AUTH_FILE,
1282
+ ),
1283
+ authToken:
1284
+ String(raw.authToken || process.env.TWINKLE_AUTH_TOKEN || "").trim() ||
1285
+ null,
1286
+ clientName: String(raw.clientName || "Lumine CLI").slice(0, 120),
1287
+ dir: raw.dir ? String(raw.dir) : "",
1288
+ pullMain: parseBoolean(raw.main, false),
1289
+ summary: raw.summary ? String(raw.summary) : "",
1290
+ publish: parseBoolean(raw.publish, false),
1291
+ saveFirst: parseBoolean(raw.save, false),
1292
+ limit: Math.min(
1293
+ Math.max(
1294
+ Number(raw.limit || process.env.TWINKLE_PROJECT_LIMIT) ||
1295
+ DEFAULT_PROJECT_LIMIT,
1296
+ 1,
1297
+ ),
1298
+ 100,
1299
+ ),
1300
+ openBrowser: parseBoolean(raw.noOpen, false)
1301
+ ? false
1302
+ : parseBoolean(raw.open, true),
1303
+ updateCheck: parseBoolean(raw.noUpdateCheck, false) ? false : true,
1304
+ timeoutMs: Math.max(
1305
+ Number(raw.timeoutMs || process.env.TWINKLE_TIMEOUT_MS) ||
1306
+ DEFAULT_TIMEOUT_MS,
1307
+ 1000,
1308
+ ),
1309
+ help: !!raw.help || command === "help",
1310
+ };
1311
+ }
1312
+
1313
+ export async function resolveRequiredBuildIdOrSelected(
1314
+ options,
1315
+ auth,
1316
+ { localProject = null } = {},
1317
+ ) {
1318
+ const targetReference = resolveBuildReference(options.target);
1319
+ if (targetReference.buildId > 0) {
1320
+ return await resolveBuildReferenceBuildId({
1321
+ options,
1322
+ auth,
1323
+ reference: targetReference,
1324
+ });
1325
+ }
1326
+ const resolvedLocalProject =
1327
+ localProject ||
1328
+ (await findLocalProjectMetadata(
1329
+ path.resolve(options.dir || process.cwd()),
1330
+ ));
1331
+ // A `pull --main` checkout is read-only but still knows its root build id.
1332
+ // Read-only commands run from it (check, diff, a refreshing `pull --main`)
1333
+ // resolve that id instead of bouncing off the reference/fork error — but
1334
+ // mutating commands (launch would PUBLISH main, save, merge, …) stay blocked
1335
+ // with main-checkout-specific guidance.
1336
+ if (resolvedLocalProject?.metadata?.mainCheckout === true) {
1337
+ const mainBuildId =
1338
+ Number(resolvedLocalProject.metadata.buildId || 0) ||
1339
+ Number(resolvedLocalProject.metadata.build?.id || 0);
1340
+ if (!MAIN_CHECKOUT_READONLY_COMMANDS.has(options.command)) {
1341
+ throw new Error(
1342
+ `This is a read-only checkout of main${mainBuildId ? ` for Build ${mainBuildId}` : ""}; \`lumine ${options.command}\` isn't available here. Run it from your branch workspace or the canonical checkout, or pass an explicit Build URL.`,
1343
+ );
1344
+ }
1345
+ if (mainBuildId > 0) return mainBuildId;
1346
+ }
1347
+ if (
1348
+ resolvedLocalProject?.metadata &&
1349
+ isReadOnlyReferenceMetadata(resolvedLocalProject.metadata)
1350
+ ) {
1351
+ const sourceBuildId =
1352
+ Number(resolvedLocalProject.metadata.reference?.sourceBuildId || 0) ||
1353
+ Number(resolvedLocalProject.metadata.buildId || 0) ||
1354
+ Number(resolvedLocalProject.metadata.build?.id || 0) ||
1355
+ 0;
1356
+ throw new Error(
1357
+ `This is a read-only Lumine reference${sourceBuildId ? ` for Build ${sourceBuildId}` : ""}. Run \`lumine fork${sourceBuildId ? ` ${sourceBuildId}` : ""}\` to create an editable workspace, or pass an explicit Build URL.`,
1358
+ );
1359
+ }
1360
+ const localBuildId = Number(resolvedLocalProject?.metadata?.buildId || 0);
1361
+ if (localBuildId > 0) return localBuildId;
1362
+ const selectedBuildId = Number(auth?.selectedBuildId || 0);
1363
+ if (selectedBuildId > 0) return selectedBuildId;
1364
+ throw new Error(
1365
+ "Choose a project with `lumine select`, run `lumine`, or pass a Twinkle build URL.",
1366
+ );
1367
+ }
1368
+
1369
+ export async function resolveNewBuildTitle(options) {
1370
+ const title = String(options.title || "").trim();
1371
+ if (title) return title;
1372
+ if (!input.isTTY || !output.isTTY) {
1373
+ throw new Error('Pass a title: `lumine new "My Build"` or `lumine new --title "My Build"`.');
1374
+ }
1375
+ const rl = readline.createInterface({ input, output });
1376
+ try {
1377
+ const answer = await rl.question("Build title: ");
1378
+ const normalized = String(answer || "").trim();
1379
+ if (!normalized) {
1380
+ throw new Error("Title is required.");
1381
+ }
1382
+ return normalized;
1383
+ } finally {
1384
+ rl.close();
1385
+ }
1386
+ }
1387
+
1388
+ export async function resolveNewBuildDescription(options) {
1389
+ if (options.noDescription) return null;
1390
+ if (options.descriptionProvided) {
1391
+ return String(options.description || "").trim() || null;
1392
+ }
1393
+ if (!input.isTTY || !output.isTTY) return null;
1394
+ const rl = readline.createInterface({ input, output });
1395
+ try {
1396
+ const answer = await rl.question("Description (optional): ");
1397
+ return String(answer || "").trim() || null;
1398
+ } finally {
1399
+ rl.close();
1400
+ }
1401
+ }
1402
+
1403
+ export async function resolveBuildReferenceBuildId({ options, auth, reference }) {
1404
+ if (reference.branchNumber > 0) {
1405
+ const build = await resolveBranchBuild({
1406
+ options,
1407
+ auth,
1408
+ rootBuildId: reference.buildId,
1409
+ branchNumber: reference.branchNumber,
1410
+ });
1411
+ return Number(build.id || 0);
1412
+ }
1413
+ return Number(reference.buildId || 0);
1414
+ }
1415
+
1416
+ export function normalizeOpenSourceSort(value) {
1417
+ const normalized = String(value || "")
1418
+ .trim()
1419
+ .toLowerCase();
1420
+ if (["recent", "popular", "forks"].includes(normalized)) return normalized;
1421
+ return "forks";
1422
+ }
1423
+
1424
+ export function printHelp() {
1425
+ console.log(`Usage:
1426
+ lumine
1427
+ lumine login
1428
+ lumine whoami
1429
+ lumine logout
1430
+ lumine new [title]
1431
+ lumine projects
1432
+ lumine explore [search terms]
1433
+ lumine select [twinkle-build-url]
1434
+ lumine pull [twinkle-build-url]
1435
+ lumine pull [twinkle-build-url] --main
1436
+ lumine reference <twinkle-build-url>
1437
+ lumine fork <twinkle-build-url>
1438
+ lumine diff <twinkle-branch-url>
1439
+ lumine merge <twinkle-branch-url>
1440
+ lumine replace-main <twinkle-branch-url>
1441
+ lumine update-from-main [twinkle-branch-url]
1442
+ lumine save
1443
+ lumine check [twinkle-build-url]
1444
+ lumine launch [twinkle-build-url]
1445
+ lumine sdk list
1446
+ lumine sdk call <namespace.method> [jsonArgs]
1447
+ lumine assets [list]
1448
+ lumine assets upload <file...>
1449
+ lumine assets generate "<prompt>" --model <gpt-image-2|nano-banana>
1450
+ lumine assets delete <assetId>
1451
+ lumine assets prune [--yes]
1452
+ lumine thumbnail set <file>
1453
+ lumine thumbnail capture [--out <file>]
1454
+ lumine thumbnail generate ["<prompt>"] --model <gpt-image-2|nano-banana>
1455
+
1456
+ Examples:
1457
+ npx @stage5/lumine@latest
1458
+ npx @stage5/lumine@latest login
1459
+ npx @stage5/lumine@latest new "Daily Reflection App"
1460
+ npx @stage5/lumine@latest new --title "Daily Reflection App" --description "Private journal with streaks"
1461
+ npx @stage5/lumine@latest explore --sort forks
1462
+ npx @stage5/lumine@latest reference https://www.twin-kle.com/app/123
1463
+ npx @stage5/lumine@latest fork https://www.twin-kle.com/app/123
1464
+ npx @stage5/lumine@latest diff https://www.twin-kle.com/build/884/4
1465
+ npx @stage5/lumine@latest merge https://www.twin-kle.com/build/884/4
1466
+ npx @stage5/lumine@latest pull 884 --main
1467
+ npx @stage5/lumine@latest update-from-main
1468
+ npx @stage5/lumine@latest pull
1469
+ npx @stage5/lumine@latest save
1470
+ npx @stage5/lumine@latest save --publish
1471
+ npx @stage5/lumine@latest launch --save
1472
+ npx @stage5/lumine@latest launch https://www.twin-kle.com/app/123
1473
+ npx @stage5/lumine@latest sdk call aiStories.chapters '{"limit": 5}'
1474
+ npx @stage5/lumine@latest sdk call privateDb.get '{"key": "prefs"}' --build 1374
1475
+ npx @stage5/lumine@latest sdk call aiStories.list '{"difficulty": 1}' --repeat 5
1476
+ npx @stage5/lumine@latest assets upload art/hero.png sounds/theme.mp3
1477
+ npx @stage5/lumine@latest assets list --build 917
1478
+ npx @stage5/lumine@latest assets generate "pixel-art forest background" --model nano-banana
1479
+ npx @stage5/lumine@latest thumbnail set art/cover.png
1480
+ npx @stage5/lumine@latest thumbnail capture --out capture.png
1481
+ npx @stage5/lumine@latest thumbnail generate --model gpt-image-2 --yes
1482
+
1483
+ Options:
1484
+ --api-url <url> Twinkle API origin
1485
+ --site-url <url> Twinkle website origin
1486
+ --auth-file <path> Saved login path
1487
+ --auth-token <token> Override saved login
1488
+ --dir <path> Directory for pulled project files
1489
+ --main With pull: read-only checkout of the team project's main
1490
+ --title <text> New Build title
1491
+ --description <text> Optional New Build description
1492
+ --no-description Skip the New Build description prompt
1493
+ --summary <text> Save summary
1494
+ --search <text> Search public open-source Builds
1495
+ --sort <sort> Sort open-source Builds: forks, popular, recent
1496
+ --publish Publish after saving
1497
+ --save Save local files before launch
1498
+ --limit <number> Number of projects to show
1499
+ --no-update-check Skip the npm latest-version check
1500
+ --no-open Print the approval URL without opening a browser
1501
+ --build <id> Build id for sdk/assets/thumbnail calls outside a workspace
1502
+ --repeat <n> Repeat an sdk call (1-20) and print latency stats
1503
+ --allow-write Permit sdk methods that mutate app data
1504
+ --path <api/...> Call an sdk endpoint not in the curated list
1505
+ --scopes <a,b> Override requested build API token scopes
1506
+ --model <model> Image model for generate: gpt-image-2 or nano-banana (required, no default)
1507
+ --quality <q> gpt-image-2 quality: low, medium, high (default high)
1508
+ --name <fileName> File name hint for a generated asset
1509
+ --out <path> With thumbnail capture: also save the capture locally
1510
+ --yes Skip confirmation prompts (assets prune/generate, thumbnail)
1511
+ `);
1512
+ }