@stage5/lumine 0.2.2 → 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.
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
@@ -98,6 +101,15 @@ lumine save --summary "Describe the change"
98
101
  - For canvas, WebGL, Three.js, fullscreen, or game builds, use Twinkle.preview for layout. Do not size roots from 100vh, 100vw, 100dvh, 100dvw, window.innerWidth, window.innerHeight, visualViewport, or document viewport dimensions.
99
102
  - For Three.js, use import * as THREE from '/build/vendor/three/0.184.0/three.module.min.js';. Addons (OrbitControls, GLTFLoader, ...) live under /build/vendor/three/0.184.0/addons/, e.g. import { OrbitControls } from '/build/vendor/three/0.184.0/addons/controls/OrbitControls.js';. Builds saved with the older /build/vendor/three/0.160.0/ path keep working.
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.
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.
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.
101
113
 
102
114
  ## Completion Report
103
115
 
@@ -119,7 +131,32 @@ the workspace to save.
119
131
  - To start from this Build, run lumine fork with the source build id and edit the forked workspace.
120
132
  - Do not edit another local checkout to bypass reference read-only semantics.
121
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
+ `;
122
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
+ ]);
123
160
  const COMMANDS = new Set([
124
161
  "workspace",
125
162
  "login",
@@ -135,6 +172,7 @@ const COMMANDS = new Set([
135
172
  "diff",
136
173
  "merge",
137
174
  "replace-main",
175
+ "update-from-main",
138
176
  "save",
139
177
  "push",
140
178
  "check",
@@ -215,6 +253,10 @@ async function main() {
215
253
  await replaceMainWithBranch(options);
216
254
  return;
217
255
  }
256
+ if (options.command === "update-from-main") {
257
+ await updateBranchFromMain(options);
258
+ return;
259
+ }
218
260
  if (options.command === "save" || options.command === "push") {
219
261
  await save(options);
220
262
  return;
@@ -407,12 +449,23 @@ async function selectProject(options) {
407
449
 
408
450
  async function pull(options) {
409
451
  const auth = await resolveAuth(options);
410
- 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
+ );
411
460
  const selectedBuild = await loadBuildMetadata({
412
461
  options,
413
462
  auth,
414
463
  buildId: requestedBuildId,
415
464
  });
465
+ if (options.pullMain) {
466
+ await pullMainBuildFiles({ options, auth, build: selectedBuild });
467
+ return;
468
+ }
416
469
  const build = await resolveEditableWorkspaceBuild({
417
470
  options,
418
471
  auth,
@@ -423,6 +476,65 @@ async function pull(options) {
423
476
  printPullResult(result);
424
477
  }
425
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
+
426
538
  async function reference(options) {
427
539
  const auth = await resolveAuth(options);
428
540
  const buildId = await resolveRequiredBuildIdOrSelected(options, auth);
@@ -1578,33 +1690,95 @@ async function pullReferenceFiles({ options, auth, buildId }) {
1578
1690
  };
1579
1691
  }
1580
1692
 
1581
- async function writeAgentInstructions({ dir }) {
1582
- for (const fileName of AGENT_INSTRUCTION_FILES) {
1583
- const filePath = path.join(dir, fileName);
1584
- try {
1585
- const existing = await fs.readFile(filePath, "utf8");
1586
- if (!existing.includes(LUMINE_AGENT_INSTRUCTIONS_MARKER)) {
1587
- continue;
1588
- }
1589
- } catch (error) {
1590
- if (error.code !== "ENOENT") throw error;
1591
- }
1592
- 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 });
1593
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
+ });
1594
1758
  }
1595
1759
 
1596
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 }) {
1597
1771
  for (const fileName of AGENT_INSTRUCTION_FILES) {
1598
1772
  const filePath = path.join(dir, fileName);
1599
1773
  try {
1600
1774
  const existing = await fs.readFile(filePath, "utf8");
1601
- if (!existing.includes(LUMINE_REFERENCE_INSTRUCTIONS_MARKER)) {
1775
+ if (!existing.includes(marker)) {
1602
1776
  continue;
1603
1777
  }
1604
1778
  } catch (error) {
1605
1779
  if (error.code !== "ENOENT") throw error;
1606
1780
  }
1607
- await fs.writeFile(filePath, LUMINE_REFERENCE_INSTRUCTIONS, "utf8");
1781
+ await fs.writeFile(filePath, content, "utf8");
1608
1782
  }
1609
1783
  }
1610
1784
 
@@ -1659,6 +1833,14 @@ async function collectProjectFilesFromDir({ root, dir, files }) {
1659
1833
  if (entry.isFile() && EXCLUDED_UPLOAD_FILES.has(entry.name)) continue;
1660
1834
  const fullPath = path.join(dir, entry.name);
1661
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
+ }
1662
1844
  await collectProjectFilesFromDir({ root, dir: fullPath, files });
1663
1845
  continue;
1664
1846
  }
@@ -1680,6 +1862,15 @@ async function collectProjectFilesFromDir({ root, dir, files }) {
1680
1862
  }
1681
1863
  }
1682
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
+
1683
1874
  function localFilePathToProjectPath({ root, filePath }) {
1684
1875
  const relative = path.relative(root, filePath).replace(/\\/g, "/");
1685
1876
  if (!relative || relative.startsWith("../") || path.isAbsolute(relative)) {
@@ -1763,6 +1954,19 @@ async function writeProjectFiles({ dir, files }) {
1763
1954
  }
1764
1955
  }
1765
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
+
1766
1970
  async function writeProjectMetadata({
1767
1971
  dir,
1768
1972
  options,
@@ -1863,6 +2067,50 @@ async function writeReferenceMetadata({
1863
2067
  );
1864
2068
  }
1865
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
+
1866
2114
  async function findLocalProjectMetadata(startDir) {
1867
2115
  let current = path.resolve(startDir || process.cwd());
1868
2116
  while (true) {
@@ -1892,6 +2140,13 @@ function resolveProjectDirForSave({ options, localProject }) {
1892
2140
  function assertLocalProjectCanBeSaved(localProject) {
1893
2141
  const metadata = localProject?.metadata;
1894
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
+ }
1895
2150
  if (isReadOnlyReferenceMetadata(metadata)) {
1896
2151
  const sourceBuildId =
1897
2152
  Number(metadata.reference?.sourceBuildId || 0) ||
@@ -2158,6 +2413,40 @@ function printContributionDiff({ result, build }) {
2158
2413
  }
2159
2414
  }
2160
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
+
2161
2450
  function printContributionActionResult({
2162
2451
  action,
2163
2452
  result,
@@ -2429,6 +2718,7 @@ function parseArgs(args) {
2429
2718
  "save",
2430
2719
  "noUpdateCheck",
2431
2720
  "allowWrite",
2721
+ "main",
2432
2722
  ]);
2433
2723
 
2434
2724
  for (let i = 0; i < rest.length; i += 1) {
@@ -2507,6 +2797,7 @@ function parseArgs(args) {
2507
2797
  null,
2508
2798
  clientName: String(raw.clientName || "Lumine CLI").slice(0, 120),
2509
2799
  dir: raw.dir ? String(raw.dir) : "",
2800
+ pullMain: parseBoolean(raw.main, false),
2510
2801
  summary: raw.summary ? String(raw.summary) : "",
2511
2802
  publish: parseBoolean(raw.publish, false),
2512
2803
  saveFirst: parseBoolean(raw.save, false),
@@ -2549,6 +2840,22 @@ async function resolveRequiredBuildIdOrSelected(
2549
2840
  (await findLocalProjectMetadata(
2550
2841
  path.resolve(options.dir || process.cwd()),
2551
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
+ }
2552
2859
  if (
2553
2860
  resolvedLocalProject?.metadata &&
2554
2861
  isReadOnlyReferenceMetadata(resolvedLocalProject.metadata)
@@ -2767,6 +3074,12 @@ function defaultReferenceDir(build) {
2767
3074
  return `twinkle-reference-${titleSlug || "build"}-${buildId}`;
2768
3075
  }
2769
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
+
2770
3083
  function slugify(value) {
2771
3084
  return String(value || "")
2772
3085
  .toLowerCase()
@@ -2790,11 +3103,13 @@ function printHelp() {
2790
3103
  lumine explore [search terms]
2791
3104
  lumine select [twinkle-build-url]
2792
3105
  lumine pull [twinkle-build-url]
3106
+ lumine pull [twinkle-build-url] --main
2793
3107
  lumine reference <twinkle-build-url>
2794
3108
  lumine fork <twinkle-build-url>
2795
3109
  lumine diff <twinkle-branch-url>
2796
3110
  lumine merge <twinkle-branch-url>
2797
3111
  lumine replace-main <twinkle-branch-url>
3112
+ lumine update-from-main [twinkle-branch-url]
2798
3113
  lumine save
2799
3114
  lumine check [twinkle-build-url]
2800
3115
  lumine launch [twinkle-build-url]
@@ -2811,6 +3126,8 @@ Examples:
2811
3126
  npx @stage5/lumine@latest fork https://www.twin-kle.com/app/123
2812
3127
  npx @stage5/lumine@latest diff https://www.twin-kle.com/build/884/4
2813
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
2814
3131
  npx @stage5/lumine@latest pull
2815
3132
  npx @stage5/lumine@latest save
2816
3133
  npx @stage5/lumine@latest save --publish
@@ -2826,6 +3143,7 @@ Options:
2826
3143
  --auth-file <path> Saved login path
2827
3144
  --auth-token <token> Override saved login
2828
3145
  --dir <path> Directory for pulled project files
3146
+ --main With pull: read-only checkout of the team project's main
2829
3147
  --title <text> New Build title
2830
3148
  --description <text> Optional New Build description
2831
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.2",
3
+ "version": "0.2.4",
4
4
  "description": "Command line tools for launching Lumine builds on Twinkle.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -2,16 +2,17 @@
2
2
 
3
3
  Version: 1.26.2
4
4
  Updated: 2026-06-09
5
- Generated: 2026-06-11T01:44:12.338Z
5
+ Generated: 2026-06-18T12:26:29.648Z
6
6
 
7
7
  ## Notes
8
8
  - This SDK is injected into Build iframes via the Build preview/runtime.
9
9
  - Widgets call SDK methods; the parent proxies to the API.
10
10
  - Data API methods require scoped tokens handled by the parent; some namespaces include write methods.
11
- - Use Twinkle.privateDb as the default private per-user persistence layer for preferences, drafts, settings, and small JSON state.
11
+ - Use Twinkle.privateDb for LOW-frequency durable private per-user state such as preferences, drafts, settings, inventory checkpoints, and saved progress. It is NOT for high-frequency or per-frame/per-tick writes; the server rate-limits writes and returns 429.
12
+ - Match storage to update frequency: privateDb and sharedDb are for LOW-frequency durable state that changes on a user action. NEVER write per-frame/per-tick state to them (camera or cursor position, animation, live movement, presence, autosave every frame/tick). Keep live state in client memory, broadcast realtime/presence via Twinkle.world, and flush only occasional durable snapshots (on an interval or on exit, never per frame). The server enforces per-key write rate limits and returns 429 on excess; never retry-loop a 429.
12
13
  - Use Twinkle.userDb only for advanced private SQLite needs such as tables, indexes, many rows, filtered queries, or aggregates.
13
14
  - Use Twinkle.leaderboards for public Build scoreboards. Signed-in viewers are ranked by Twinkle username; guests can submit with a display name.
14
- - Use Twinkle.sharedDb for custom shared multi-user structured data, guestbooks, votes, and append-only run history.
15
+ - Use Twinkle.sharedDb for LOW-frequency durable shared multi-user state such as guestbooks, votes, room settings, submitted records, and append-only run history. It is NOT for high-frequency or per-frame/per-tick writes; keep live/realtime state in Twinkle.world or client memory. The server rate-limits writes and returns 429.
15
16
  - Use Twinkle.subjects.search for in-app subject pickers. Twinkle.mount remains an optional host-provided preselection/context shortcut, not a data API.
16
17
  - Use Twinkle.aiCards for read-only existing public AI Card words and example texts, including word levels for typing games.
17
18
  - Use Twinkle.aiStories for read-only existing AI Story galleries, readers, quizzes, topic chapter indexes, and remix tools.
@@ -567,7 +568,7 @@ const result = await Twinkle.characters.chat({ character: 'zero', thinkingMode:
567
568
  - Returns: { sessionId, session, room, players, snapshot, subscribe(listener), updatePresence(patch), send(actionOrType, data), leave() }
568
569
  - Join a realtime Build world room and receive a snapshot plus a session handle for presence updates, actions, and room events.
569
570
  - Always available in the build iframe.
570
- - World state is ephemeral and heartbeat/TTL based. Use sharedDb/privateDb for durable inventory, XP, quests, ownership, and saved progress.
571
+ - World state is ephemeral and heartbeat/TTL based. Use sharedDb/privateDb for durable inventory, XP, quests, ownership, and saved progress — but write those LOW-frequency only (on a user action or an occasional snapshot, never per frame/tick); per-frame/live state stays in world presence or client memory. The server rate-limits sharedDb/privateDb writes and returns 429.
571
572
  - Events are room-scoped and include serverTime, seq, eventId, schemaVersion, sessionId, player, and room metadata.
572
573
  - Subscribe to session.ended and catch updatePresence/send errors. Stop using stale handles and reconnect only when Twinkle.world.isSessionEndedError(error) is true; for other Twinkle.world.isRecoverableSessionError(error) cases, drop the transient presence/action and keep the handle.
573
574
  - Use updatePresence for live avatar snapshots and send for lightweight actions such as emotes, interactions, and chat bubbles.
@@ -581,7 +582,7 @@ world.updatePresence({ x, y, z, facing });
581
582
  - Return true when a world request error is expected to be handled by app code instead of crashing.
582
583
  - Recoverable session errors include ended, missing, socket-disconnected, socket-not-ready, room-missing, preview-updating, and timed-out world session requests.
583
584
  - Only session-ended errors prove that the current handle should be discarded. Timed-out or preview-updating presence requests can be dropped without reconnecting.
584
- - For durable game state, write through sharedDb/privateDb instead of relying on world presence.
585
+ - For durable game state, write through sharedDb/privateDb instead of relying on world presence — but LOW-frequency only (on a user action or an occasional snapshot, never per frame/tick).
585
586
  - Example: try {
586
587
  await world.updatePresence({ x, y, z, facing });
587
588
  } catch (error) {
@@ -852,7 +853,7 @@ await Twinkle.chat.sendMessage('lobby', 'hello');
852
853
  ```
853
854
 
854
855
  ### Realtime MMO town room
855
- Use Twinkle.world for live avatar presence and lightweight room actions, recover stale session handles, and keep durable state like inventory and quests in sharedDb/privateDb.
856
+ Use Twinkle.world for live avatar presence and lightweight room actions, recover stale session handles, and keep durable state like inventory and quests in sharedDb/privateDb — written low-frequency (never per frame/tick).
856
857
  Keywords: multiplayer, mmo, town, presence, avatars, movement, three.js, realtime
857
858
 
858
859
  ```js