@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 +333 -15
- package/package.json +1 -1
- package/sdk/BUILD_SDK_INDEX.md +7 -6
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
|
|
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
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
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(
|
|
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,
|
|
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
package/sdk/BUILD_SDK_INDEX.md
CHANGED
|
@@ -2,16 +2,17 @@
|
|
|
2
2
|
|
|
3
3
|
Version: 1.26.2
|
|
4
4
|
Updated: 2026-06-09
|
|
5
|
-
Generated: 2026-06-
|
|
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
|
|
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
|
|
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
|