@stage5/lumine 0.2.3 → 0.2.4

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 (2) hide show
  1. package/bin/lumine.js +332 -15
  2. package/package.json +1 -1
package/bin/lumine.js CHANGED
@@ -33,6 +33,8 @@ const LUMINE_AGENT_INSTRUCTIONS_MARKER =
33
33
  const LUMINE_SDK_REFERENCE_MARKER = "<!-- Lumine CLI SDK Reference -->";
34
34
  const LUMINE_REFERENCE_INSTRUCTIONS_MARKER =
35
35
  "<!-- Lumine CLI Reference Instructions -->";
36
+ const LUMINE_MAIN_CHECKOUT_INSTRUCTIONS_MARKER =
37
+ "<!-- Lumine CLI Main Checkout Instructions -->";
36
38
  const BUNDLED_SDK_REFERENCE_URL = new URL(
37
39
  "../sdk/BUILD_SDK_INDEX.md",
38
40
  import.meta.url,
@@ -70,6 +72,7 @@ Lumine CLI as the source of truth for saving this workspace back to Twinkle.
70
72
  - Read ${SDK_REFERENCE_FILE} before adding, removing, or changing any Twinkle.* SDK calls.
71
73
  - If build.canWrite is false, do not save changes.
72
74
  - 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.
75
+ - On a contribution branch, main may have moved since the branch was created. Before starting large edits, run \`lumine update-from-main\` to three-way-merge main into the branch (resolve any <<<<<<< conflict markers it reports, then save). \`lumine pull --main\` gives a read-only checkout of main for comparison (created alongside your workspace, never inside it).
73
76
  - Do not edit another local checkout to bypass branch rules.
74
77
 
75
78
  ## Workflow
@@ -100,6 +103,14 @@ lumine save --summary "Describe the change"
100
103
  - Do not invent or guess Twinkle.* SDK method names. Use ${SDK_REFERENCE_FILE} as the local SDK reference and prefer Twinkle.capabilities checks for gated features.
101
104
  - Match storage to update frequency. Twinkle.privateDb and Twinkle.sharedDb are for LOW-frequency durable state only — things that change on a user action (settings, inventory checkpoints, completed quests, saved progress; comments, votes, room settings, submitted records). NEVER write high-frequency or per-frame/per-tick state to them (camera or cursor position, animation state, live movement, presence, autosave every frame/tick). Keep live state in client memory, broadcast realtime/presence via Twinkle.world, and for durable per-user state flush an occasional snapshot on an interval or on exit (never per frame) — e.g. the viewer/user DB or a single latest-snapshot key. The server rate-limits these writes per key and returns 429 on excess; never retry-loop a 429.
102
105
 
106
+ ## Local Testing (Playwright / browser probes)
107
+
108
+ - Serve the workspace with a tiny local HTTP server and drive it with Playwright. NEVER copy probe/vendor files into the workspace dir — lumine save uploads everything here (and binary files fail validation). Build a sibling probe dir that symlinks the workspace files instead.
109
+ - Vendored imports like /build/vendor/three/0.184.0/... are absolute paths: mirror that directory under your probe dir's root and fetch the files from the LIVE SITE (e.g. https://www.twin-kle.com/build/vendor/three/0.184.0/three.webgpu.min.js). Do NOT use npm/CDN copies — the platform's vendored builds have rewritten import specifiers (npm three.tsl.min.js still imports bare "three/webgpu" and breaks the module graph).
110
+ - The three WebGPU renderer falls back to WebGL2 in headless Chromium automatically. Headless software rendering runs at ~2-5fps, so anything time-based (walking a character, timers) takes ~10-20x longer than real time — loop with generous waits instead of fixed short sleeps, and bump navigation timeouts.
111
+ - To inspect module-scope game state, append debug getters when SERVING main.js (e.g. body += "window.__dbg = () => ({...})") rather than editing workspace files.
112
+ - SDK calls are absent when serving locally; well-written builds optional-chain window.Twinkle and fall back to localStorage. Seed localStorage in the probe to fake saves.
113
+
103
114
  ## Completion Report
104
115
 
105
116
  Report the changed files, any SDK methods used, the lumine save result, the
@@ -120,7 +131,32 @@ the workspace to save.
120
131
  - To start from this Build, run lumine fork with the source build id and edit the forked workspace.
121
132
  - Do not edit another local checkout to bypass reference read-only semantics.
122
133
  `;
134
+ const LUMINE_MAIN_CHECKOUT_INSTRUCTIONS = `${LUMINE_MAIN_CHECKOUT_INSTRUCTIONS_MARKER}
135
+ # Lumine Main Checkout (Read-Only)
136
+
137
+ This directory is a read-only snapshot of a team project's MAIN workspace,
138
+ pulled with \`lumine pull --main\`. Use it to inspect what main currently looks
139
+ like; it is not the workspace to edit or save.
140
+
141
+ ## Source Of Truth
142
+
143
+ - Read .twinkle/lumine-project.json before using these files.
144
+ - metadata.mainCheckout is true here: do not run lumine save from this directory.
145
+ - Make changes in your contribution-branch workspace (lumine pull <buildId>).
146
+ - Bring main's latest changes into your branch with lumine update-from-main.
147
+ - Do not edit another local checkout to bypass read-only semantics.
148
+ `;
123
149
  const AGENT_INSTRUCTION_FILES = ["AGENTS.md", "CLAUDE.md"];
150
+ // Commands allowed to resolve a read-only `pull --main` checkout's build id.
151
+ // Everything else (launch/save/merge/…) mutates and must not run from one.
152
+ const MAIN_CHECKOUT_READONLY_COMMANDS = new Set([
153
+ "pull",
154
+ "check",
155
+ "diff",
156
+ "sdk",
157
+ "select",
158
+ "workspace",
159
+ ]);
124
160
  const COMMANDS = new Set([
125
161
  "workspace",
126
162
  "login",
@@ -136,6 +172,7 @@ const COMMANDS = new Set([
136
172
  "diff",
137
173
  "merge",
138
174
  "replace-main",
175
+ "update-from-main",
139
176
  "save",
140
177
  "push",
141
178
  "check",
@@ -216,6 +253,10 @@ async function main() {
216
253
  await replaceMainWithBranch(options);
217
254
  return;
218
255
  }
256
+ if (options.command === "update-from-main") {
257
+ await updateBranchFromMain(options);
258
+ return;
259
+ }
219
260
  if (options.command === "save" || options.command === "push") {
220
261
  await save(options);
221
262
  return;
@@ -408,12 +449,23 @@ async function selectProject(options) {
408
449
 
409
450
  async function pull(options) {
410
451
  const auth = await resolveAuth(options);
411
- const requestedBuildId = await resolveRequiredBuildIdOrSelected(options, auth);
452
+ const localProject = await findLocalProjectMetadata(
453
+ path.resolve(options.dir || process.cwd()),
454
+ );
455
+ const requestedBuildId = await resolveRequiredBuildIdOrSelected(
456
+ options,
457
+ auth,
458
+ options.pullMain ? { localProject } : {},
459
+ );
412
460
  const selectedBuild = await loadBuildMetadata({
413
461
  options,
414
462
  auth,
415
463
  buildId: requestedBuildId,
416
464
  });
465
+ if (options.pullMain) {
466
+ await pullMainBuildFiles({ options, auth, build: selectedBuild });
467
+ return;
468
+ }
417
469
  const build = await resolveEditableWorkspaceBuild({
418
470
  options,
419
471
  auth,
@@ -424,6 +476,65 @@ async function pull(options) {
424
476
  printPullResult(result);
425
477
  }
426
478
 
479
+ // Sync a contribution branch with its team project's main: the server runs a
480
+ // three-way merge (auto-merging where it can, writing git-style conflict
481
+ // markers where it cannot) and saves the result to the branch. When run inside
482
+ // the branch's workspace, local edits are sent along as the branch's pending
483
+ // state and the merged files are written back to disk.
484
+ async function updateBranchFromMain(options) {
485
+ const auth = await resolveAuth(options);
486
+ await assertAuthScope({ options, auth, scope: "build:write" });
487
+ const localProject = await findLocalProjectMetadata(
488
+ path.resolve(options.dir || process.cwd()),
489
+ );
490
+ const buildId = await resolveRequiredBuildIdOrSelected(options, auth, {
491
+ localProject,
492
+ });
493
+ const build = await loadBuildMetadata({ options, auth, buildId });
494
+ const rootBuildId = Number(build?.contributionRootBuildId || 0);
495
+ const contributionBuildId = Number(build?.id || 0);
496
+ if (!rootBuildId || !contributionBuildId) {
497
+ throw new Error(
498
+ "update-from-main only applies to contribution branches of a team project. Pull the team project first so your branch workspace exists.",
499
+ );
500
+ }
501
+ let dir = null;
502
+ let projectFiles = null;
503
+ if (Number(localProject?.metadata?.buildId || 0) === contributionBuildId) {
504
+ dir = resolveProjectDirForSave({ options, localProject });
505
+ projectFiles = await collectProjectFiles(dir);
506
+ }
507
+ const result = await requestJson({
508
+ method: "POST",
509
+ url: `${options.apiUrl}/build/${rootBuildId}/contributions/${contributionBuildId}/update-from-main`,
510
+ authToken: auth.token,
511
+ body: projectFiles ? { projectFiles } : {},
512
+ timeoutMs: options.timeoutMs,
513
+ });
514
+ const mergedFiles = Array.isArray(result.projectFiles)
515
+ ? result.projectFiles
516
+ : [];
517
+ if (dir && mergedFiles.length > 0) {
518
+ await writeProjectFiles({ dir, files: mergedFiles });
519
+ await removeLocalProjectFilesNotIn({ dir, files: mergedFiles });
520
+ const refreshed = await loadBuildMetadata({
521
+ options,
522
+ auth,
523
+ buildId: contributionBuildId,
524
+ }).catch(() => null);
525
+ if (refreshed) {
526
+ await writeProjectMetadata({
527
+ dir,
528
+ options,
529
+ build: refreshed,
530
+ manifest: localProject?.metadata?.manifest || null,
531
+ pulledAt: new Date().toISOString(),
532
+ });
533
+ }
534
+ }
535
+ printUpdateFromMainResult({ result, build, dir, mergedFiles });
536
+ }
537
+
427
538
  async function reference(options) {
428
539
  const auth = await resolveAuth(options);
429
540
  const buildId = await resolveRequiredBuildIdOrSelected(options, auth);
@@ -1579,33 +1690,95 @@ async function pullReferenceFiles({ options, auth, buildId }) {
1579
1690
  };
1580
1691
  }
1581
1692
 
1582
- async function writeAgentInstructions({ dir }) {
1583
- for (const fileName of AGENT_INSTRUCTION_FILES) {
1584
- const filePath = path.join(dir, fileName);
1585
- try {
1586
- const existing = await fs.readFile(filePath, "utf8");
1587
- if (!existing.includes(LUMINE_AGENT_INSTRUCTIONS_MARKER)) {
1588
- continue;
1589
- }
1590
- } catch (error) {
1591
- if (error.code !== "ENOENT") throw error;
1592
- }
1593
- await fs.writeFile(filePath, LUMINE_AGENT_INSTRUCTIONS, "utf8");
1693
+ // Read-only checkout of a team project's MAIN workspace. Collaborators edit on
1694
+ // their contribution branch; this exists so branch work can consult what main
1695
+ // currently looks like without the pull auto-redirecting to the branch.
1696
+ async function pullMainBuildFiles({ options, auth, build }) {
1697
+ const rootBuildId =
1698
+ Number(build?.contributionRootBuildId || 0) || Number(build?.id || 0);
1699
+ if (!rootBuildId) {
1700
+ throw new Error("Could not resolve the team project for --main.");
1701
+ }
1702
+ const result = await loadBuildFiles({
1703
+ options,
1704
+ auth,
1705
+ buildId: rootBuildId,
1706
+ includeContent: true,
1707
+ });
1708
+ const rootBuild = result.build || { id: rootBuildId, title: `Build ${rootBuildId}` };
1709
+ const files = Array.isArray(result.projectFiles) ? result.projectFiles : [];
1710
+ // Never nest a main checkout inside another Lumine workspace (a later save
1711
+ // there would upload it as project files) — default to a sibling instead.
1712
+ // Running from a main checkout of this same build refreshes it in place,
1713
+ // and the refresh prunes files main has deleted (a snapshot must not lie).
1714
+ const enclosing = options.dir
1715
+ ? null
1716
+ : await findLocalProjectMetadata(process.cwd());
1717
+ const enclosingIsThisMain =
1718
+ enclosing?.metadata?.mainCheckout === true &&
1719
+ Number(enclosing.metadata.buildId || 0) === rootBuildId;
1720
+ const dir = path.resolve(
1721
+ options.dir ||
1722
+ (enclosingIsThisMain
1723
+ ? enclosing.rootDir
1724
+ : enclosing
1725
+ ? path.join(path.dirname(enclosing.rootDir), defaultMainCheckoutDir(rootBuild))
1726
+ : defaultMainCheckoutDir(rootBuild)),
1727
+ );
1728
+ await writeProjectFiles({ dir, files });
1729
+ if (files.some((file) => isIndexHtmlPath(String(file.path)))) {
1730
+ await removeLocalProjectFilesNotIn({ dir, files });
1594
1731
  }
1732
+ await writeInstructionFiles({
1733
+ dir,
1734
+ marker: LUMINE_MAIN_CHECKOUT_INSTRUCTIONS_MARKER,
1735
+ content: LUMINE_MAIN_CHECKOUT_INSTRUCTIONS,
1736
+ });
1737
+ await writeSdkReference({ dir });
1738
+ await writeMainCheckoutMetadata({
1739
+ dir,
1740
+ options,
1741
+ build: rootBuild,
1742
+ manifest: result.projectManifest || null,
1743
+ pulledAt: new Date().toISOString(),
1744
+ });
1745
+ console.log(`Pulled main for ${formatBuildTitle(rootBuild)} (read-only).`);
1746
+ console.log(`Pulled ${files.length} file${files.length === 1 ? "" : "s"} to ${dir}`);
1747
+ console.log(
1748
+ `Edits belong on your branch: \`lumine pull ${rootBuildId}\`, and \`lumine update-from-main\` brings main's changes into it.`,
1749
+ );
1750
+ }
1751
+
1752
+ async function writeAgentInstructions({ dir }) {
1753
+ await writeInstructionFiles({
1754
+ dir,
1755
+ marker: LUMINE_AGENT_INSTRUCTIONS_MARKER,
1756
+ content: LUMINE_AGENT_INSTRUCTIONS,
1757
+ });
1595
1758
  }
1596
1759
 
1597
1760
  async function writeReferenceInstructions({ dir }) {
1761
+ await writeInstructionFiles({
1762
+ dir,
1763
+ marker: LUMINE_REFERENCE_INSTRUCTIONS_MARKER,
1764
+ content: LUMINE_REFERENCE_INSTRUCTIONS,
1765
+ });
1766
+ }
1767
+
1768
+ // Write AGENTS.md/CLAUDE.md, but never clobber a file the user customized:
1769
+ // only (re)write when the file is absent or still carries our marker.
1770
+ async function writeInstructionFiles({ dir, marker, content }) {
1598
1771
  for (const fileName of AGENT_INSTRUCTION_FILES) {
1599
1772
  const filePath = path.join(dir, fileName);
1600
1773
  try {
1601
1774
  const existing = await fs.readFile(filePath, "utf8");
1602
- if (!existing.includes(LUMINE_REFERENCE_INSTRUCTIONS_MARKER)) {
1775
+ if (!existing.includes(marker)) {
1603
1776
  continue;
1604
1777
  }
1605
1778
  } catch (error) {
1606
1779
  if (error.code !== "ENOENT") throw error;
1607
1780
  }
1608
- await fs.writeFile(filePath, LUMINE_REFERENCE_INSTRUCTIONS, "utf8");
1781
+ await fs.writeFile(filePath, content, "utf8");
1609
1782
  }
1610
1783
  }
1611
1784
 
@@ -1660,6 +1833,14 @@ async function collectProjectFilesFromDir({ root, dir, files }) {
1660
1833
  if (entry.isFile() && EXCLUDED_UPLOAD_FILES.has(entry.name)) continue;
1661
1834
  const fullPath = path.join(dir, entry.name);
1662
1835
  if (entry.isDirectory()) {
1836
+ // A nested Lumine checkout (its own .twinkle metadata) is another
1837
+ // project that happens to sit here — never upload it as project files.
1838
+ if (await isNestedLumineCheckout(fullPath)) {
1839
+ console.error(
1840
+ `lumine: skipping nested Lumine checkout ${path.relative(root, fullPath)}/ (not part of this project)`,
1841
+ );
1842
+ continue;
1843
+ }
1663
1844
  await collectProjectFilesFromDir({ root, dir: fullPath, files });
1664
1845
  continue;
1665
1846
  }
@@ -1681,6 +1862,15 @@ async function collectProjectFilesFromDir({ root, dir, files }) {
1681
1862
  }
1682
1863
  }
1683
1864
 
1865
+ async function isNestedLumineCheckout(dir) {
1866
+ try {
1867
+ await fs.access(path.join(dir, PROJECT_METADATA_DIR, PROJECT_METADATA_FILE));
1868
+ return true;
1869
+ } catch {
1870
+ return false;
1871
+ }
1872
+ }
1873
+
1684
1874
  function localFilePathToProjectPath({ root, filePath }) {
1685
1875
  const relative = path.relative(root, filePath).replace(/\\/g, "/");
1686
1876
  if (!relative || relative.startsWith("../") || path.isAbsolute(relative)) {
@@ -1764,6 +1954,19 @@ async function writeProjectFiles({ dir, files }) {
1764
1954
  }
1765
1955
  }
1766
1956
 
1957
+ // After update-from-main, files that main deleted must also leave the local
1958
+ // workspace — otherwise the next save would resurrect them on the branch.
1959
+ async function removeLocalProjectFilesNotIn({ dir, files }) {
1960
+ const keep = new Set(files.map((file) => String(file.path)));
1961
+ const localFiles = await collectProjectFiles(dir);
1962
+ for (const file of localFiles) {
1963
+ if (keep.has(file.path)) continue;
1964
+ await fs.unlink(
1965
+ resolveLocalProjectFilePath({ rootDir: dir, projectPath: file.path }),
1966
+ );
1967
+ }
1968
+ }
1969
+
1767
1970
  async function writeProjectMetadata({
1768
1971
  dir,
1769
1972
  options,
@@ -1864,6 +2067,50 @@ async function writeReferenceMetadata({
1864
2067
  );
1865
2068
  }
1866
2069
 
2070
+ // Metadata for a read-only `pull --main` checkout: canWrite is forced false
2071
+ // (the files endpoint reports token scope, not role) and mainCheckout marks it
2072
+ // so save can explain where edits belong.
2073
+ async function writeMainCheckoutMetadata({
2074
+ dir,
2075
+ options,
2076
+ build,
2077
+ manifest,
2078
+ pulledAt,
2079
+ }) {
2080
+ const metadataDir = path.join(dir, PROJECT_METADATA_DIR);
2081
+ await fs.mkdir(metadataDir, { recursive: true });
2082
+ const rootBuildId = Number(build?.id || 0) || null;
2083
+ await fs.writeFile(
2084
+ path.join(metadataDir, PROJECT_METADATA_FILE),
2085
+ JSON.stringify(
2086
+ {
2087
+ schemaVersion: 1,
2088
+ buildId: rootBuildId,
2089
+ readOnly: true,
2090
+ mainCheckout: true,
2091
+ build: {
2092
+ id: rootBuildId,
2093
+ title: build?.title || (rootBuildId ? `Build ${rootBuildId}` : ""),
2094
+ role: build?.role || "collaborator",
2095
+ ownerUsername: build?.ownerUsername || null,
2096
+ contributionStatus: "none",
2097
+ contributionRootBuildId: null,
2098
+ canWrite: false,
2099
+ canPublish: false,
2100
+ },
2101
+ apiUrl: options.apiUrl,
2102
+ siteUrl: options.siteUrl,
2103
+ lumineCli: serializeLumineCliMetadata(options),
2104
+ manifest,
2105
+ pulledAt,
2106
+ },
2107
+ null,
2108
+ 2,
2109
+ ),
2110
+ "utf8",
2111
+ );
2112
+ }
2113
+
1867
2114
  async function findLocalProjectMetadata(startDir) {
1868
2115
  let current = path.resolve(startDir || process.cwd());
1869
2116
  while (true) {
@@ -1893,6 +2140,13 @@ function resolveProjectDirForSave({ options, localProject }) {
1893
2140
  function assertLocalProjectCanBeSaved(localProject) {
1894
2141
  const metadata = localProject?.metadata;
1895
2142
  if (!metadata) return;
2143
+ if (metadata.mainCheckout === true) {
2144
+ const rootBuildId =
2145
+ Number(metadata.buildId || 0) || Number(metadata.build?.id || 0) || 0;
2146
+ throw new Error(
2147
+ `This is a read-only checkout of main${rootBuildId ? ` for Build ${rootBuildId}` : ""}. Make edits in your branch workspace (\`lumine pull${rootBuildId ? ` ${rootBuildId}` : ""}\`), and run \`lumine update-from-main\` there to bring main's changes into it.`,
2148
+ );
2149
+ }
1896
2150
  if (isReadOnlyReferenceMetadata(metadata)) {
1897
2151
  const sourceBuildId =
1898
2152
  Number(metadata.reference?.sourceBuildId || 0) ||
@@ -2159,6 +2413,40 @@ function printContributionDiff({ result, build }) {
2159
2413
  }
2160
2414
  }
2161
2415
 
2416
+ function printUpdateFromMainResult({ result, build, dir, mergedFiles }) {
2417
+ const branchNumber = Number(build?.contributionBranchNumber || 0) || 0;
2418
+ const branchLabel = branchNumber
2419
+ ? `branch ${branchNumber}`
2420
+ : `branch #${build?.id}`;
2421
+ const autoMerged = Array.isArray(result.autoMergedPaths)
2422
+ ? result.autoMergedPaths
2423
+ : [];
2424
+ const conflicts = Array.isArray(result.conflicts) ? result.conflicts : [];
2425
+ console.log(
2426
+ `Updated ${branchLabel} from main (${mergedFiles.length} file${mergedFiles.length === 1 ? "" : "s"}).`,
2427
+ );
2428
+ if (autoMerged.length > 0) {
2429
+ console.log(`Auto-merged: ${autoMerged.join(", ")}`);
2430
+ }
2431
+ if (conflicts.length > 0) {
2432
+ const conflictPaths = conflicts.map((conflict) =>
2433
+ typeof conflict === "string" ? conflict : conflict?.path || "unknown",
2434
+ );
2435
+ console.log(`CONFLICTS (markers written): ${conflictPaths.join(", ")}`);
2436
+ console.log(
2437
+ dir
2438
+ ? "Resolve the <<<<<<< / >>>>>>> markers in the files above, then run `lumine save`."
2439
+ : "Pull the branch, resolve the <<<<<<< / >>>>>>> markers, then run `lumine save`.",
2440
+ );
2441
+ } else if (!dir) {
2442
+ console.log(
2443
+ "The branch was updated on Twinkle. Run `lumine pull` to refresh your local workspace.",
2444
+ );
2445
+ } else {
2446
+ console.log(`Local workspace updated: ${dir}`);
2447
+ }
2448
+ }
2449
+
2162
2450
  function printContributionActionResult({
2163
2451
  action,
2164
2452
  result,
@@ -2430,6 +2718,7 @@ function parseArgs(args) {
2430
2718
  "save",
2431
2719
  "noUpdateCheck",
2432
2720
  "allowWrite",
2721
+ "main",
2433
2722
  ]);
2434
2723
 
2435
2724
  for (let i = 0; i < rest.length; i += 1) {
@@ -2508,6 +2797,7 @@ function parseArgs(args) {
2508
2797
  null,
2509
2798
  clientName: String(raw.clientName || "Lumine CLI").slice(0, 120),
2510
2799
  dir: raw.dir ? String(raw.dir) : "",
2800
+ pullMain: parseBoolean(raw.main, false),
2511
2801
  summary: raw.summary ? String(raw.summary) : "",
2512
2802
  publish: parseBoolean(raw.publish, false),
2513
2803
  saveFirst: parseBoolean(raw.save, false),
@@ -2550,6 +2840,22 @@ async function resolveRequiredBuildIdOrSelected(
2550
2840
  (await findLocalProjectMetadata(
2551
2841
  path.resolve(options.dir || process.cwd()),
2552
2842
  ));
2843
+ // A `pull --main` checkout is read-only but still knows its root build id.
2844
+ // Read-only commands run from it (check, diff, a refreshing `pull --main`)
2845
+ // resolve that id instead of bouncing off the reference/fork error — but
2846
+ // mutating commands (launch would PUBLISH main, save, merge, …) stay blocked
2847
+ // with main-checkout-specific guidance.
2848
+ if (resolvedLocalProject?.metadata?.mainCheckout === true) {
2849
+ const mainBuildId =
2850
+ Number(resolvedLocalProject.metadata.buildId || 0) ||
2851
+ Number(resolvedLocalProject.metadata.build?.id || 0);
2852
+ if (!MAIN_CHECKOUT_READONLY_COMMANDS.has(options.command)) {
2853
+ throw new Error(
2854
+ `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.`,
2855
+ );
2856
+ }
2857
+ if (mainBuildId > 0) return mainBuildId;
2858
+ }
2553
2859
  if (
2554
2860
  resolvedLocalProject?.metadata &&
2555
2861
  isReadOnlyReferenceMetadata(resolvedLocalProject.metadata)
@@ -2768,6 +3074,12 @@ function defaultReferenceDir(build) {
2768
3074
  return `twinkle-reference-${titleSlug || "build"}-${buildId}`;
2769
3075
  }
2770
3076
 
3077
+ function defaultMainCheckoutDir(build) {
3078
+ const titleSlug = slugify(build?.title || "");
3079
+ const buildId = Number(build?.id || 0) || "build";
3080
+ return `twinkle-main-${titleSlug || "build"}-${buildId}`;
3081
+ }
3082
+
2771
3083
  function slugify(value) {
2772
3084
  return String(value || "")
2773
3085
  .toLowerCase()
@@ -2791,11 +3103,13 @@ function printHelp() {
2791
3103
  lumine explore [search terms]
2792
3104
  lumine select [twinkle-build-url]
2793
3105
  lumine pull [twinkle-build-url]
3106
+ lumine pull [twinkle-build-url] --main
2794
3107
  lumine reference <twinkle-build-url>
2795
3108
  lumine fork <twinkle-build-url>
2796
3109
  lumine diff <twinkle-branch-url>
2797
3110
  lumine merge <twinkle-branch-url>
2798
3111
  lumine replace-main <twinkle-branch-url>
3112
+ lumine update-from-main [twinkle-branch-url]
2799
3113
  lumine save
2800
3114
  lumine check [twinkle-build-url]
2801
3115
  lumine launch [twinkle-build-url]
@@ -2812,6 +3126,8 @@ Examples:
2812
3126
  npx @stage5/lumine@latest fork https://www.twin-kle.com/app/123
2813
3127
  npx @stage5/lumine@latest diff https://www.twin-kle.com/build/884/4
2814
3128
  npx @stage5/lumine@latest merge https://www.twin-kle.com/build/884/4
3129
+ npx @stage5/lumine@latest pull 884 --main
3130
+ npx @stage5/lumine@latest update-from-main
2815
3131
  npx @stage5/lumine@latest pull
2816
3132
  npx @stage5/lumine@latest save
2817
3133
  npx @stage5/lumine@latest save --publish
@@ -2827,6 +3143,7 @@ Options:
2827
3143
  --auth-file <path> Saved login path
2828
3144
  --auth-token <token> Override saved login
2829
3145
  --dir <path> Directory for pulled project files
3146
+ --main With pull: read-only checkout of the team project's main
2830
3147
  --title <text> New Build title
2831
3148
  --description <text> Optional New Build description
2832
3149
  --no-description Skip the New Build description prompt
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stage5/lumine",
3
- "version": "0.2.3",
3
+ "version": "0.2.4",
4
4
  "description": "Command line tools for launching Lumine builds on Twinkle.",
5
5
  "type": "module",
6
6
  "bin": {