@stage5/lumine 0.1.6 → 0.1.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/lumine.js CHANGED
@@ -125,6 +125,9 @@ const COMMANDS = new Set([
125
125
  "pull",
126
126
  "reference",
127
127
  "fork",
128
+ "diff",
129
+ "merge",
130
+ "replace-main",
128
131
  "save",
129
132
  "push",
130
133
  "check",
@@ -188,6 +191,18 @@ async function main() {
188
191
  await fork(options);
189
192
  return;
190
193
  }
194
+ if (options.command === "diff") {
195
+ await diff(options);
196
+ return;
197
+ }
198
+ if (options.command === "merge") {
199
+ await mergeBranch(options);
200
+ return;
201
+ }
202
+ if (options.command === "replace-main") {
203
+ await replaceMainWithBranch(options);
204
+ return;
205
+ }
191
206
  if (options.command === "save" || options.command === "push") {
192
207
  await save(options);
193
208
  return;
@@ -302,11 +317,7 @@ async function whoami(options) {
302
317
  async function workspace(options) {
303
318
  const auth = await ensureAuth(options);
304
319
  const selectedBuild = options.target
305
- ? await loadBuildMetadata({
306
- options,
307
- auth,
308
- buildId: resolveRequiredBuildId(options.target),
309
- })
320
+ ? await loadTargetBuildMetadata({ options, auth })
310
321
  : await chooseProject({
311
322
  builds: await listBuilds({ options, auth }),
312
323
  });
@@ -337,11 +348,7 @@ async function explore(options) {
337
348
  async function selectProject(options) {
338
349
  const auth = await resolveAuth(options);
339
350
  const selectedBuild = options.target
340
- ? await loadBuildMetadata({
341
- options,
342
- auth,
343
- buildId: resolveRequiredBuildId(options.target),
344
- })
351
+ ? await loadTargetBuildMetadata({ options, auth })
345
352
  : await chooseProject({
346
353
  builds: await listBuilds({ options, auth }),
347
354
  });
@@ -376,7 +383,7 @@ async function pull(options) {
376
383
 
377
384
  async function reference(options) {
378
385
  const auth = await resolveAuth(options);
379
- const buildId = resolveRequiredBuildId(options.target);
386
+ const buildId = await resolveRequiredBuildIdOrSelected(options, auth);
380
387
  const result = await pullReferenceFiles({ options, auth, buildId });
381
388
  printReferenceResult(result);
382
389
  }
@@ -384,7 +391,7 @@ async function reference(options) {
384
391
  async function fork(options) {
385
392
  const auth = await resolveAuth(options);
386
393
  await assertAuthScope({ options, auth, scope: "build:write" });
387
- const buildId = resolveRequiredBuildId(options.target);
394
+ const buildId = await resolveRequiredBuildIdOrSelected(options, auth);
388
395
  const forkResult = await forkBuild({ options, auth, buildId });
389
396
  const forkedBuildId = Number(forkResult.build?.id || 0);
390
397
  if (!forkedBuildId) {
@@ -399,6 +406,60 @@ async function fork(options) {
399
406
  printForkResult({ forkResult, pullResult: result });
400
407
  }
401
408
 
409
+ async function diff(options) {
410
+ const auth = await resolveAuth(options);
411
+ const build = await loadTargetBuildMetadata({ options, auth });
412
+ const { rootBuildId, contributionBuildId } =
413
+ resolveContributionActionBuildIds(build);
414
+ const result = await loadContributionDiff({
415
+ options,
416
+ auth,
417
+ rootBuildId,
418
+ contributionBuildId,
419
+ });
420
+ printContributionDiff({ result, build });
421
+ }
422
+
423
+ async function mergeBranch(options) {
424
+ const auth = await resolveAuth(options);
425
+ await assertAuthScope({ options, auth, scope: "build:write" });
426
+ const build = await loadTargetBuildMetadata({ options, auth });
427
+ const { rootBuildId, contributionBuildId } =
428
+ resolveContributionActionBuildIds(build);
429
+ const result = await mergeContributionIntoMain({
430
+ options,
431
+ auth,
432
+ rootBuildId,
433
+ contributionBuildId,
434
+ });
435
+ printContributionActionResult({
436
+ action: "Merged",
437
+ result,
438
+ rootBuildId,
439
+ contributionBuildId,
440
+ });
441
+ }
442
+
443
+ async function replaceMainWithBranch(options) {
444
+ const auth = await resolveAuth(options);
445
+ await assertAuthScope({ options, auth, scope: "build:write" });
446
+ const build = await loadTargetBuildMetadata({ options, auth });
447
+ const { rootBuildId, contributionBuildId } =
448
+ resolveContributionActionBuildIds(build);
449
+ const result = await replaceMainWithContribution({
450
+ options,
451
+ auth,
452
+ rootBuildId,
453
+ contributionBuildId,
454
+ });
455
+ printContributionActionResult({
456
+ action: "Replaced main with",
457
+ result,
458
+ rootBuildId,
459
+ contributionBuildId,
460
+ });
461
+ }
462
+
402
463
  async function save(options) {
403
464
  const auth = await resolveAuth(options);
404
465
  await assertAuthScope({ options, auth, scope: "build:write" });
@@ -415,6 +476,11 @@ async function save(options) {
415
476
  buildId,
416
477
  localProject,
417
478
  });
479
+ if (build?.canWrite === false) {
480
+ throw new Error(
481
+ "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.",
482
+ );
483
+ }
418
484
  buildId = Number(build?.id || buildId);
419
485
  const dir = resolveProjectDirForSave({ options, localProject });
420
486
  const files = await collectProjectFiles(dir);
@@ -566,6 +632,11 @@ async function publishBuild({ options, buildId, auth }) {
566
632
  function printCheck(result) {
567
633
  const checks = result.checks || {};
568
634
  console.log(`Launch check: ${result.ok ? "ok" : "fail"}`);
635
+ if (checks.canonicalBuild) {
636
+ console.log(
637
+ `- canonical build: ${checks.canonicalBuild.ok ? "ok" : "fail"}`,
638
+ );
639
+ }
569
640
  console.log(
570
641
  `- project files: ${checks.projectFiles?.ok ? "ok" : "fail"} ` +
571
642
  `files=${checks.projectFiles?.fileCount ?? 0}`,
@@ -645,6 +716,11 @@ async function loadBuildMetadata({ options, auth, buildId }) {
645
716
  return result.build;
646
717
  }
647
718
 
719
+ async function loadTargetBuildMetadata({ options, auth }) {
720
+ const buildId = await resolveRequiredBuildIdOrSelected(options, auth);
721
+ return await loadBuildMetadata({ options, auth, buildId });
722
+ }
723
+
648
724
  async function loadOpenSourceBuildFiles({
649
725
  options,
650
726
  auth,
@@ -670,6 +746,24 @@ async function loadBuildFiles({ options, auth, buildId, includeContent }) {
670
746
  });
671
747
  }
672
748
 
749
+ async function resolveBranchBuild({
750
+ options,
751
+ auth,
752
+ rootBuildId,
753
+ branchNumber,
754
+ }) {
755
+ const result = await requestJson({
756
+ url: `${options.apiUrl}/cli/build/${rootBuildId}/branches/${branchNumber}`,
757
+ authToken: auth.token,
758
+ timeoutMs: options.timeoutMs,
759
+ });
760
+ const branchBuildId = Number(result.build?.id || 0);
761
+ if (!branchBuildId) {
762
+ throw new Error(`Branch ${branchNumber} for Build ${rootBuildId} could not be loaded.`);
763
+ }
764
+ return result.build;
765
+ }
766
+
673
767
  async function forkBuild({ options, auth, buildId }) {
674
768
  return await requestJson({
675
769
  method: "POST",
@@ -694,6 +788,49 @@ async function saveProjectFiles({ options, auth, buildId, files, summary }) {
694
788
  });
695
789
  }
696
790
 
791
+ async function loadContributionDiff({
792
+ options,
793
+ auth,
794
+ rootBuildId,
795
+ contributionBuildId,
796
+ }) {
797
+ return await requestJson({
798
+ url: `${options.apiUrl}/build/${rootBuildId}/contributions/${contributionBuildId}`,
799
+ authToken: auth.token,
800
+ timeoutMs: options.timeoutMs,
801
+ });
802
+ }
803
+
804
+ async function mergeContributionIntoMain({
805
+ options,
806
+ auth,
807
+ rootBuildId,
808
+ contributionBuildId,
809
+ }) {
810
+ return await requestJson({
811
+ method: "POST",
812
+ url: `${options.apiUrl}/build/${rootBuildId}/contributions/${contributionBuildId}/merge`,
813
+ authToken: auth.token,
814
+ body: {},
815
+ timeoutMs: options.timeoutMs,
816
+ });
817
+ }
818
+
819
+ async function replaceMainWithContribution({
820
+ options,
821
+ auth,
822
+ rootBuildId,
823
+ contributionBuildId,
824
+ }) {
825
+ return await requestJson({
826
+ method: "POST",
827
+ url: `${options.apiUrl}/build/${rootBuildId}/contributions/${contributionBuildId}/replace-main`,
828
+ authToken: auth.token,
829
+ body: {},
830
+ timeoutMs: options.timeoutMs,
831
+ });
832
+ }
833
+
697
834
  async function resolveBuildForSave({ options, auth, buildId, localProject }) {
698
835
  const localBuild = localProject?.metadata?.build;
699
836
  const localBuildId =
@@ -1226,6 +1363,11 @@ function assertLocalProjectCanBeSaved(localProject) {
1226
1363
  `This is a read-only Lumine reference${sourceBuildId ? ` for Build ${sourceBuildId}` : ""}. Run \`lumine fork${sourceBuildId ? ` ${sourceBuildId}` : ""}\` to create an editable workspace.`,
1227
1364
  );
1228
1365
  }
1366
+ if (metadata.build?.canWrite === false) {
1367
+ throw new Error(
1368
+ "This Lumine checkout is read-only for the current CLI login. Pull or diff it for review; project-owner branch edits must go through merge or replace-main.",
1369
+ );
1370
+ }
1229
1371
  }
1230
1372
 
1231
1373
  function isReadOnlyReferenceMetadata(metadata) {
@@ -1344,6 +1486,17 @@ function isContributionBranch(build) {
1344
1486
  );
1345
1487
  }
1346
1488
 
1489
+ function resolveContributionActionBuildIds(build) {
1490
+ const contributionBuildId = Number(build?.id || 0);
1491
+ const rootBuildId = Number(build?.contributionRootBuildId || 0);
1492
+ if (!contributionBuildId || !rootBuildId || !isContributionBranch(build)) {
1493
+ throw new Error(
1494
+ "Pass a branch URL such as https://www.twin-kle.com/build/884/4, or run this from a pulled branch workspace.",
1495
+ );
1496
+ }
1497
+ return { rootBuildId, contributionBuildId };
1498
+ }
1499
+
1347
1500
  function printPullResult(result) {
1348
1501
  const build = result.build || {};
1349
1502
  const entryPath = result.manifest?.entryPath || "unknown";
@@ -1356,6 +1509,12 @@ function printPullResult(result) {
1356
1509
  console.log(`Next: cd ${shellQuote(result.dir)}`);
1357
1510
  if (build.canWrite === false) {
1358
1511
  console.log("This checkout is read-only for the current CLI login.");
1512
+ if (isContributionBranch(build)) {
1513
+ console.log("Review changes: lumine diff");
1514
+ if (build.role === "project_owner") {
1515
+ console.log("Owner actions: lumine merge, or lumine replace-main");
1516
+ }
1517
+ }
1359
1518
  return;
1360
1519
  }
1361
1520
  console.log('Codex: codex "Read AGENTS.md, then make the requested change."');
@@ -1426,6 +1585,57 @@ function printSaveResult({ result, build, dir, files }) {
1426
1585
  }
1427
1586
  }
1428
1587
 
1588
+ function printContributionDiff({ result, build }) {
1589
+ const summary = result.diff?.summary || {};
1590
+ const files = Array.isArray(result.diff?.changedFiles)
1591
+ ? result.diff.changedFiles
1592
+ : [];
1593
+ const branchNumber = Number(build.contributionBranchNumber || 0) || 0;
1594
+ const branchLabel = branchNumber ? `branch ${branchNumber}` : `branch #${build.id}`;
1595
+ console.log(`Diff for ${branchLabel}:`);
1596
+ console.log(
1597
+ `- total=${summary.total ?? files.length} added=${summary.added ?? 0} ` +
1598
+ `updated=${summary.updated ?? 0} deleted=${summary.deleted ?? 0}`,
1599
+ );
1600
+ if (result.rootDrifted) {
1601
+ console.log("- main changed after this branch was created");
1602
+ }
1603
+ if (!files.length) {
1604
+ console.log("No file changes.");
1605
+ return;
1606
+ }
1607
+ for (const file of files) {
1608
+ const mergeStatus = file.mergeStatus ? ` (${file.mergeStatus})` : "";
1609
+ console.log(`- ${file.status || "changed"} ${file.path}${mergeStatus}`);
1610
+ }
1611
+ }
1612
+
1613
+ function printContributionActionResult({
1614
+ action,
1615
+ result,
1616
+ rootBuildId,
1617
+ contributionBuildId,
1618
+ }) {
1619
+ if (!result?.success) {
1620
+ console.log(result?.error || "Branch action did not complete.");
1621
+ process.exitCode = 1;
1622
+ return;
1623
+ }
1624
+ const projectFiles = Array.isArray(result.projectFiles)
1625
+ ? result.projectFiles
1626
+ : [];
1627
+ console.log(`${action} branch #${contributionBuildId} for Build #${rootBuildId}.`);
1628
+ if (projectFiles.length > 0) {
1629
+ console.log(
1630
+ `Main now has ${projectFiles.length} project file${projectFiles.length === 1 ? "" : "s"}.`,
1631
+ );
1632
+ }
1633
+ if (result.mergeConflictsWritten || result.conflicts?.length > 0) {
1634
+ console.log("Merge wrote conflict markers. Resolve them in the Build workspace.");
1635
+ }
1636
+ console.log(`Main workspace: ${rootBuildId}`);
1637
+ }
1638
+
1429
1639
  async function resolveAuth(options) {
1430
1640
  if (options.authToken) {
1431
1641
  return { token: options.authToken };
@@ -1745,8 +1955,14 @@ async function resolveRequiredBuildIdOrSelected(
1745
1955
  auth,
1746
1956
  { localProject = null } = {},
1747
1957
  ) {
1748
- const buildId = resolveBuildId(options.target);
1749
- if (buildId > 0) return buildId;
1958
+ const targetReference = resolveBuildReference(options.target);
1959
+ if (targetReference.buildId > 0) {
1960
+ return await resolveBuildReferenceBuildId({
1961
+ options,
1962
+ auth,
1963
+ reference: targetReference,
1964
+ });
1965
+ }
1750
1966
  const resolvedLocalProject =
1751
1967
  localProject ||
1752
1968
  (await findLocalProjectMetadata(
@@ -1775,7 +1991,7 @@ async function resolveRequiredBuildIdOrSelected(
1775
1991
  }
1776
1992
 
1777
1993
  function resolveRequiredBuildId(value) {
1778
- const buildId = resolveBuildId(value);
1994
+ const buildId = resolveBuildReference(value).buildId;
1779
1995
  if (buildId > 0) return buildId;
1780
1996
  throw new Error(
1781
1997
  "Pass a Twinkle build URL, app URL, preview URL, or build id.",
@@ -1783,40 +1999,74 @@ function resolveRequiredBuildId(value) {
1783
1999
  }
1784
2000
 
1785
2001
  function resolveBuildId(value) {
2002
+ return resolveBuildReference(value).buildId;
2003
+ }
2004
+
2005
+ async function resolveBuildReferenceBuildId({ options, auth, reference }) {
2006
+ if (reference.branchNumber > 0) {
2007
+ const build = await resolveBranchBuild({
2008
+ options,
2009
+ auth,
2010
+ rootBuildId: reference.buildId,
2011
+ branchNumber: reference.branchNumber,
2012
+ });
2013
+ return Number(build.id || 0);
2014
+ }
2015
+ return Number(reference.buildId || 0);
2016
+ }
2017
+
2018
+ function resolveBuildReference(value) {
1786
2019
  const rawValue = String(value || "").trim();
1787
2020
  const directId = Number(rawValue);
1788
- if (Number.isFinite(directId) && directId > 0) return directId;
1789
- if (!rawValue) return 0;
2021
+ if (Number.isFinite(directId) && directId > 0) {
2022
+ return { buildId: directId, branchNumber: 0 };
2023
+ }
2024
+ if (!rawValue) return { buildId: 0, branchNumber: 0 };
1790
2025
 
1791
2026
  try {
1792
2027
  const parsedUrl = new URL(rawValue);
1793
2028
  const host = parsedUrl.hostname.toLowerCase();
1794
2029
  const previewHost = host.match(/^b-(\d+)\.preview\.lumine\.app$/);
1795
- if (previewHost) return Number(previewHost[1]) || 0;
2030
+ if (previewHost) {
2031
+ return { buildId: Number(previewHost[1]) || 0, branchNumber: 0 };
2032
+ }
1796
2033
 
1797
2034
  const parts = parsedUrl.pathname.split("/").filter(Boolean);
1798
2035
  const appIndex = parts.indexOf("app");
1799
- if (appIndex >= 0) return Number(parts[appIndex + 1]) || 0;
2036
+ if (appIndex >= 0) {
2037
+ return { buildId: Number(parts[appIndex + 1]) || 0, branchNumber: 0 };
2038
+ }
1800
2039
 
1801
2040
  const buildIndex = parts.indexOf("build");
1802
2041
  if (buildIndex >= 0) {
1803
2042
  if (parts[buildIndex + 1] === "preview") {
1804
2043
  const nestedBuildIndex = parts.indexOf("build", buildIndex + 2);
1805
- return Number(parts[nestedBuildIndex + 1]) || 0;
2044
+ return {
2045
+ buildId: Number(parts[nestedBuildIndex + 1]) || 0,
2046
+ branchNumber: 0,
2047
+ };
1806
2048
  }
1807
- return Number(parts[buildIndex + 1]) || 0;
2049
+ return {
2050
+ buildId: Number(parts[buildIndex + 1]) || 0,
2051
+ branchNumber: Number(parts[buildIndex + 2]) || 0,
2052
+ };
1808
2053
  }
1809
2054
 
1810
- return (
1811
- Number(parsedUrl.searchParams.get("buildId")) ||
1812
- Number(parsedUrl.searchParams.get("build")) ||
1813
- 0
1814
- );
2055
+ return {
2056
+ buildId:
2057
+ Number(parsedUrl.searchParams.get("buildId")) ||
2058
+ Number(parsedUrl.searchParams.get("build")) ||
2059
+ 0,
2060
+ branchNumber: 0,
2061
+ };
1815
2062
  } catch {
1816
2063
  const match = rawValue.match(
1817
- /(?:^|\/)(?:app|build)\/(?:preview\/build\/)?(\d+)(?:\/|$)/,
2064
+ /(?:^|\/)(?:app|build)\/(?:preview\/build\/)?(\d+)(?:\/(\d+))?(?:\/|$)/,
1818
2065
  );
1819
- return Number(match?.[1] || 0) || 0;
2066
+ return {
2067
+ buildId: Number(match?.[1] || 0) || 0,
2068
+ branchNumber: Number(match?.[2] || 0) || 0,
2069
+ };
1820
2070
  }
1821
2071
  }
1822
2072
 
@@ -1926,6 +2176,9 @@ function printHelp() {
1926
2176
  lumine pull [twinkle-build-url]
1927
2177
  lumine reference <twinkle-build-url>
1928
2178
  lumine fork <twinkle-build-url>
2179
+ lumine diff <twinkle-branch-url>
2180
+ lumine merge <twinkle-branch-url>
2181
+ lumine replace-main <twinkle-branch-url>
1929
2182
  lumine save
1930
2183
  lumine check [twinkle-build-url]
1931
2184
  lumine launch [twinkle-build-url]
@@ -1936,6 +2189,8 @@ Examples:
1936
2189
  npx @stage5/lumine@latest explore --sort forks
1937
2190
  npx @stage5/lumine@latest reference https://www.twin-kle.com/app/123
1938
2191
  npx @stage5/lumine@latest fork https://www.twin-kle.com/app/123
2192
+ npx @stage5/lumine@latest diff https://www.twin-kle.com/build/884/4
2193
+ npx @stage5/lumine@latest merge https://www.twin-kle.com/build/884/4
1939
2194
  npx @stage5/lumine@latest pull
1940
2195
  npx @stage5/lumine@latest save
1941
2196
  npx @stage5/lumine@latest save --publish
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stage5/lumine",
3
- "version": "0.1.6",
3
+ "version": "0.1.7",
4
4
  "description": "Command line tools for launching Lumine builds on Twinkle.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,8 +1,8 @@
1
1
  # Build SDK Index
2
2
 
3
- Version: 1.25.0
3
+ Version: 1.25.1
4
4
  Updated: 2026-05-28
5
- Generated: 2026-05-28T09:34:12.631Z
5
+ Generated: 2026-05-28T10:24:18.784Z
6
6
 
7
7
  ## Notes
8
8
  - This SDK is injected into Build iframes via the Build preview/runtime.
@@ -203,15 +203,15 @@ const result = await Twinkle.characters.chat({ character: 'zero', thinkingMode:
203
203
 
204
204
  ### Twinkle.aiCards
205
205
  - async list({ limit, cursor, level, minLevel, maxLevel, quality, userId, hasImage, hasExample } = {}) | scopes: content:read
206
- - Returns: { cards: [{ id, contentType, contentId, word, text, exampleText, prompt, level, wordLevel, quality, style, imagePath, imageUrl, creatorId, ownerId, username, profilePicUrl, timeStamp, lastInteraction }], cursor?, pagination: { limit, hasMore, nextCursor }, filters }
206
+ - Returns: { cards: [{ id, contentType, contentId, word, text, exampleText, prompt, level, wordLevel, quality, style, imagePath, imageUrl, isMysteryCard, isImageGenerating, creatorId, ownerId, username, profilePicUrl, timeStamp, lastInteraction }], cursor?, pagination: { limit, hasMore, nextCursor }, filters }
207
207
  - List existing public AI Cards newest first, including each card word, example sentence text, word level, and quality.
208
208
  - Example: const { cards } = await Twinkle.aiCards.list({ level: 2, hasExample: true, limit: 20 });
209
209
  - async search({ query, limit, cursor, level, minLevel, maxLevel, quality, userId, hasImage, hasExample } = {}) | scopes: content:read
210
- - Returns: { cards: [{ id, contentType, contentId, word, text, exampleText, prompt, level, wordLevel, quality, style, imagePath, imageUrl, creatorId, ownerId, username, profilePicUrl, timeStamp, lastInteraction }], cursor?, pagination: { limit, hasMore, nextCursor }, filters }
210
+ - Returns: { cards: [{ id, contentType, contentId, word, text, exampleText, prompt, level, wordLevel, quality, style, imagePath, imageUrl, isMysteryCard, isImageGenerating, creatorId, ownerId, username, profilePicUrl, timeStamp, lastInteraction }], cursor?, pagination: { limit, hasMore, nextCursor }, filters }
211
211
  - Search existing public AI Cards by word, with optional level and quality filters.
212
212
  - Example: const { cards } = await Twinkle.aiCards.search({ query: searchText, minLevel: 1, maxLevel: 3, hasExample: true, limit: 12 });
213
213
  - async get(cardId) | scopes: content:read
214
- - Returns: { card: { id, contentType, contentId, word, text, exampleText, prompt, level, wordLevel, quality, style, imagePath, imageUrl, creatorId, ownerId, username, profilePicUrl, timeStamp, lastInteraction } }
214
+ - Returns: { card: { id, contentType, contentId, word, text, exampleText, prompt, level, wordLevel, quality, style, imagePath, imageUrl, isMysteryCard, isImageGenerating, creatorId, ownerId, username, profilePicUrl, timeStamp, lastInteraction } }
215
215
  - Fetch one existing public AI Card by id, including word, example text, and level metadata.
216
216
  - Example: const { card } = await Twinkle.aiCards.get(cardId);
217
217