@stage5/lumine 0.2.6 → 0.2.8

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/lib/api.js CHANGED
@@ -68,6 +68,29 @@ export async function loadBuildFiles({ options, auth, buildId, includeContent })
68
68
  });
69
69
  }
70
70
 
71
+ export async function loadBuildVersions({ options, auth, buildId, limit }) {
72
+ const url = new URL(`${options.apiUrl}/cli/build/${buildId}/versions`);
73
+ if (limit) url.searchParams.set("limit", String(limit));
74
+ return await requestJson({
75
+ url: url.toString(),
76
+ authToken: auth.token,
77
+ timeoutMs: options.timeoutMs,
78
+ });
79
+ }
80
+
81
+ export async function loadBuildVersionFiles({
82
+ options,
83
+ auth,
84
+ buildId,
85
+ versionNumber,
86
+ }) {
87
+ return await requestJson({
88
+ url: `${options.apiUrl}/cli/build/${buildId}/versions/${versionNumber}/files`,
89
+ authToken: auth.token,
90
+ timeoutMs: options.timeoutMs,
91
+ });
92
+ }
93
+
71
94
  export async function resolveBranchBuild({
72
95
  options,
73
96
  auth,
package/lib/commands.js CHANGED
@@ -14,6 +14,8 @@ import {
14
14
  DEFAULT_TIMEOUT_MS,
15
15
  LUMINE_MAIN_CHECKOUT_INSTRUCTIONS,
16
16
  LUMINE_MAIN_CHECKOUT_INSTRUCTIONS_MARKER,
17
+ LUMINE_VERSION_CHECKOUT_INSTRUCTIONS,
18
+ LUMINE_VERSION_CHECKOUT_INSTRUCTIONS_MARKER,
17
19
  MAIN_CHECKOUT_READONLY_COMMANDS,
18
20
  PROJECT_METADATA_DIR,
19
21
  SDK_REFERENCE_FILE,
@@ -26,6 +28,8 @@ import {
26
28
  listOpenSourceBuilds,
27
29
  loadBuildFiles,
28
30
  loadBuildMetadata,
31
+ loadBuildVersionFiles,
32
+ loadBuildVersions,
29
33
  loadContributionDiff,
30
34
  loadLumineCliVersionInfo,
31
35
  loadOpenSourceBuildFiles,
@@ -50,13 +54,16 @@ import {
50
54
  } from "./auth.js";
51
55
  import { probeUrl, requestJson } from "./http.js";
52
56
  import { sdkCommand } from "./sdk.js";
57
+ import { doctorCommand, normalizePreviewUrl } from "./doctor.js";
53
58
  import {
54
59
  defaultMainCheckoutDir,
55
60
  defaultReferenceDir,
61
+ defaultVersionCheckoutDir,
56
62
  defaultWorkspaceDir,
57
63
  parseBoolean,
58
64
  resolveBuildId,
59
65
  resolveBuildReference,
66
+ resolveRequiredBuildId,
60
67
  shellQuote,
61
68
  toCamelCase,
62
69
  trimTrailingSlash,
@@ -75,6 +82,7 @@ import {
75
82
  writeInstructionFiles,
76
83
  writeMainCheckoutMetadata,
77
84
  writeProjectFiles,
85
+ writeVersionCheckoutMetadata,
78
86
  writeProjectMetadata,
79
87
  writeReferenceInstructions,
80
88
  writeReferenceMetadata,
@@ -160,6 +168,14 @@ export async function main() {
160
168
  await check(options);
161
169
  return;
162
170
  }
171
+ if (options.command === "versions") {
172
+ await versionsCommand(options);
173
+ return;
174
+ }
175
+ if (options.command === "restore") {
176
+ await restoreVersion(options);
177
+ return;
178
+ }
163
179
  if (options.command === "launch") {
164
180
  await launch(options);
165
181
  return;
@@ -176,6 +192,10 @@ export async function main() {
176
192
  await thumbnailCommand(options);
177
193
  return;
178
194
  }
195
+ if (options.command === "doctor") {
196
+ await doctorCommand(options);
197
+ return;
198
+ }
179
199
 
180
200
  printHelp();
181
201
  }
@@ -250,6 +270,11 @@ export async function selectProject(options) {
250
270
  }
251
271
 
252
272
  export async function pull(options) {
273
+ if (options.versionProvided && !(options.pullVersion > 0)) {
274
+ throw new Error(
275
+ "Pass a save number: `lumine pull --version <n>` (run `lumine versions` to see save numbers).",
276
+ );
277
+ }
253
278
  const auth = await resolveAuth(options);
254
279
  const localProject = await findLocalProjectMetadata(
255
280
  path.resolve(options.dir || process.cwd()),
@@ -264,6 +289,10 @@ export async function pull(options) {
264
289
  auth,
265
290
  buildId: requestedBuildId,
266
291
  });
292
+ if (options.pullVersion > 0) {
293
+ await pullVersionBuildFiles({ options, auth, build: selectedBuild });
294
+ return;
295
+ }
267
296
  if (options.pullMain) {
268
297
  await pullMainBuildFiles({ options, auth, build: selectedBuild });
269
298
  return;
@@ -681,6 +710,18 @@ export function shouldUseContributionBranch(build) {
681
710
  );
682
711
  }
683
712
 
713
+ // A canonical team build the viewer contributes to: plain pull/restore would
714
+ // switch to their contribution branch, so version-history follow-up commands
715
+ // for THIS build's history need --main. An owner's canonical build is their
716
+ // editable workspace and never needs it.
717
+ export function isCollaboratorMainBuild(build) {
718
+ return (
719
+ build?.role === "collaborator" &&
720
+ build.canWrite !== true &&
721
+ !Number(build?.contributionRootBuildId || 0)
722
+ );
723
+ }
724
+
684
725
  export async function ensureDefaultContributionBranch({ options, auth, build }) {
685
726
  const rootBuildId =
686
727
  Number(build?.contributionRootBuildId || 0) || Number(build?.id || 0);
@@ -919,6 +960,269 @@ export async function pullMainBuildFiles({ options, auth, build }) {
919
960
  );
920
961
  }
921
962
 
963
+ // Read-only checkout of ONE previous save (`lumine pull --version <n>`).
964
+ // Without --main it targets the build the workspace edits (your branch on team
965
+ // projects); with --main it targets the team project's main history.
966
+ export async function pullVersionBuildFiles({ options, auth, build }) {
967
+ const versionNumber = options.pullVersion;
968
+ let targetBuild = build;
969
+ if (options.pullMain) {
970
+ const rootBuildId =
971
+ Number(build?.contributionRootBuildId || 0) || Number(build?.id || 0);
972
+ targetBuild =
973
+ rootBuildId === Number(build?.id || 0)
974
+ ? build
975
+ : await loadBuildMetadata({ options, auth, buildId: rootBuildId });
976
+ } else {
977
+ targetBuild = await resolveEditableWorkspaceBuild({
978
+ options,
979
+ auth,
980
+ build,
981
+ });
982
+ }
983
+ const buildId = Number(targetBuild?.id || 0);
984
+ const result = await loadBuildVersionFiles({
985
+ options,
986
+ auth,
987
+ buildId,
988
+ versionNumber,
989
+ });
990
+ const resolvedBuild = result.build || targetBuild;
991
+ const version = result.version || { version: versionNumber };
992
+ const files = Array.isArray(result.projectFiles) ? result.projectFiles : [];
993
+ // Same nesting rules as a main checkout: never create a version checkout
994
+ // inside another Lumine workspace; re-pulling the same checkout refreshes it
995
+ // in place.
996
+ const enclosing = options.dir
997
+ ? null
998
+ : await findLocalProjectMetadata(process.cwd());
999
+ const enclosingIsThisCheckout =
1000
+ enclosing?.metadata?.versionCheckout === true &&
1001
+ Number(enclosing.metadata.buildId || 0) === buildId &&
1002
+ Number(enclosing.metadata.checkoutVersion || 0) ===
1003
+ Number(version.version || 0);
1004
+ const dirName = defaultVersionCheckoutDir(resolvedBuild, version.version);
1005
+ const dir = path.resolve(
1006
+ options.dir ||
1007
+ (enclosingIsThisCheckout
1008
+ ? enclosing.rootDir
1009
+ : enclosing
1010
+ ? path.join(path.dirname(enclosing.rootDir), dirName)
1011
+ : dirName),
1012
+ );
1013
+ await writeProjectFiles({ dir, files });
1014
+ if (files.some((file) => isIndexHtmlPath(String(file.path)))) {
1015
+ await removeLocalProjectFilesNotIn({ dir, files });
1016
+ }
1017
+ await writeInstructionFiles({
1018
+ dir,
1019
+ marker: LUMINE_VERSION_CHECKOUT_INSTRUCTIONS_MARKER,
1020
+ content: LUMINE_VERSION_CHECKOUT_INSTRUCTIONS,
1021
+ });
1022
+ await writeSdkReference({ dir });
1023
+ await writeVersionCheckoutMetadata({
1024
+ dir,
1025
+ options,
1026
+ build: resolvedBuild,
1027
+ manifest: result.projectManifest || null,
1028
+ version,
1029
+ pulledAt: new Date().toISOString(),
1030
+ });
1031
+ const summaryText = version.summary ? ` — "${version.summary}"` : "";
1032
+ console.log(
1033
+ `Pulled save v${version.version} of ${formatBuildTitle(resolvedBuild)} (read-only)${summaryText}`,
1034
+ );
1035
+ console.log(
1036
+ `Pulled ${files.length} file${files.length === 1 ? "" : "s"} to ${dir}`,
1037
+ );
1038
+ console.log(
1039
+ isCollaboratorMainBuild(resolvedBuild)
1040
+ ? `Restore it with \`lumine restore ${version.version} --main\` from your branch workspace, then \`lumine save\`.`
1041
+ : `Restore it with \`lumine restore ${version.version}\` from Build ${buildId}'s editable workspace, then \`lumine save\`.`,
1042
+ );
1043
+ }
1044
+
1045
+ // List a build's previous saves (every workspace/CLI save creates one).
1046
+ // Targets the SAME build `lumine pull --version <n>` would snapshot, so the
1047
+ // listed numbers and the printed follow-up commands always agree: without
1048
+ // --main that's the editable workspace build (a collaborator's branch on team
1049
+ // projects), with --main the team project's main.
1050
+ export async function versionsCommand(options) {
1051
+ const auth = await resolveAuth(options);
1052
+ const localProject = await findLocalProjectMetadata(
1053
+ path.resolve(options.dir || process.cwd()),
1054
+ );
1055
+ const requestedBuildId = options.buildIdFlag
1056
+ ? resolveRequiredBuildId(options.buildIdFlag)
1057
+ : await resolveRequiredBuildIdOrSelected(options, auth, { localProject });
1058
+ const requestedBuild = await loadBuildMetadata({
1059
+ options,
1060
+ auth,
1061
+ buildId: requestedBuildId,
1062
+ });
1063
+ // Standing in a read-only checkout (pull --main / pull --version), the
1064
+ // resolved id IS the build being looked at — don't bounce it to the
1065
+ // viewer's contribution branch.
1066
+ const resolvedFromReadOnlyCheckout =
1067
+ !options.buildIdFlag &&
1068
+ !(resolveBuildReference(options.target).buildId > 0) &&
1069
+ (localProject?.metadata?.mainCheckout === true ||
1070
+ localProject?.metadata?.versionCheckout === true);
1071
+ let buildId = requestedBuildId;
1072
+ if (options.pullMain) {
1073
+ buildId =
1074
+ Number(requestedBuild?.contributionRootBuildId || 0) || requestedBuildId;
1075
+ } else if (!resolvedFromReadOnlyCheckout) {
1076
+ const editableBuild = await resolveEditableWorkspaceBuild({
1077
+ options,
1078
+ auth,
1079
+ build: requestedBuild,
1080
+ });
1081
+ buildId = Number(editableBuild?.id || 0) || requestedBuildId;
1082
+ }
1083
+ const result = await loadBuildVersions({
1084
+ options,
1085
+ auth,
1086
+ buildId,
1087
+ limit: options.limit,
1088
+ });
1089
+ printVersionList({ result });
1090
+ }
1091
+
1092
+ export function printVersionList({ result }) {
1093
+ const build = result.build || {};
1094
+ const versions = Array.isArray(result.versions) ? result.versions : [];
1095
+ if (!versions.length) {
1096
+ console.log(`No previous saves found for ${formatBuildTitle(build)}.`);
1097
+ return;
1098
+ }
1099
+ console.log(
1100
+ `Previous saves for ${formatBuildTitle(build)} (newest first):`,
1101
+ );
1102
+ for (const version of versions) {
1103
+ const author =
1104
+ version.createdByUsername ||
1105
+ (version.createdByRole === "assistant" ? "Lumine" : null) ||
1106
+ version.createdByRole ||
1107
+ "unknown";
1108
+ const fileText =
1109
+ Number(version.fileCount || 0) > 0
1110
+ ? `${version.fileCount} file${Number(version.fileCount) === 1 ? "" : "s"}`
1111
+ : "single-file save";
1112
+ const summaryText = version.summary ? ` — ${version.summary}` : "";
1113
+ console.log(
1114
+ ` v${version.version} ${formatVersionTimestamp(version.createdAt)} by ${author} (${fileText})${summaryText}`,
1115
+ );
1116
+ }
1117
+ // The follow-up commands must hit exactly the history that was just listed,
1118
+ // regardless of where the user runs them or which flags produced this list.
1119
+ // Embedding the listed build id makes pull workspace-independent; --main is
1120
+ // needed only when the listed build is a canonical team build the viewer
1121
+ // contributes to (plain pull/restore would switch to their branch — mirrors
1122
+ // shouldUseContributionBranch). An owner's canonical build never needs
1123
+ // --main: it IS their editable workspace.
1124
+ const listedBuildId = Number(build.id || 0);
1125
+ const collaboratorMain = isCollaboratorMainBuild(build);
1126
+ const pullTarget = listedBuildId ? ` ${listedBuildId}` : "";
1127
+ console.log(
1128
+ `View one read-only: lumine pull${pullTarget} --version <n>${collaboratorMain ? " --main" : ""}`,
1129
+ );
1130
+ console.log(
1131
+ collaboratorMain
1132
+ ? `Bring one back: lumine restore <n> --main (run from your branch workspace; then lumine save)`
1133
+ : `Bring one back: lumine restore <n> (run from ${listedBuildId ? `Build ${listedBuildId}'s` : "the build's"} editable workspace; then lumine save)`,
1134
+ );
1135
+ }
1136
+
1137
+ function formatVersionTimestamp(seconds) {
1138
+ const timestamp = Number(seconds || 0);
1139
+ if (!timestamp) return "unknown time";
1140
+ const date = new Date(timestamp * 1000);
1141
+ const pad = (value) => String(value).padStart(2, "0");
1142
+ return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`;
1143
+ }
1144
+
1145
+ // Write a previous save's files into the CURRENT editable workspace. Local
1146
+ // only on purpose: the user reviews the result and `lumine save` makes it a
1147
+ // new version (nothing on the server is rewritten in place). With --main in a
1148
+ // branch workspace, the save is read from the team project's MAIN history —
1149
+ // the recovery path when work was lost on main itself.
1150
+ export async function restoreVersion(options) {
1151
+ // Same strictness as `pull --version`: only a plain positive integer may
1152
+ // reach the workspace-overwriting path below. "2.5" must error, not
1153
+ // silently restore v2.
1154
+ const rawInput = String(options.positional[0] ?? "").trim();
1155
+ let versionNumber = 0;
1156
+ if (rawInput) {
1157
+ if (!/^\d+$/.test(rawInput)) {
1158
+ throw new Error(
1159
+ "Pass a plain save number, e.g. `lumine restore 41` (see `lumine versions`).",
1160
+ );
1161
+ }
1162
+ versionNumber = parseInt(rawInput, 10);
1163
+ } else if (options.pullVersion > 0) {
1164
+ // --version <n>, already strict-parsed by parseArgs.
1165
+ versionNumber = options.pullVersion;
1166
+ } else if (options.versionProvided) {
1167
+ throw new Error(
1168
+ "Pass a plain save number, e.g. `lumine restore 41` (see `lumine versions`).",
1169
+ );
1170
+ }
1171
+ if (!(versionNumber > 0)) {
1172
+ throw new Error(
1173
+ "Pass the save number to restore, e.g. `lumine restore 41` (see `lumine versions`).",
1174
+ );
1175
+ }
1176
+ const auth = await resolveAuth(options);
1177
+ const localProject = await findLocalProjectMetadata(
1178
+ path.resolve(options.dir || process.cwd()),
1179
+ );
1180
+ if (!Number(localProject?.metadata?.buildId || 0)) {
1181
+ throw new Error(
1182
+ "Run `lumine restore` inside a pulled Lumine workspace (or pass --dir <workspace>).",
1183
+ );
1184
+ }
1185
+ assertLocalProjectCanBeSaved(localProject);
1186
+ const workspaceBuildId = Number(localProject.metadata.buildId);
1187
+ let sourceBuildId = workspaceBuildId;
1188
+ if (options.pullMain) {
1189
+ const rootBuildId = Number(
1190
+ localProject.metadata.build?.contributionRootBuildId || 0,
1191
+ );
1192
+ if (!rootBuildId) {
1193
+ throw new Error(
1194
+ "--main restores from the team project's main history, which needs a contribution-branch workspace.",
1195
+ );
1196
+ }
1197
+ sourceBuildId = rootBuildId;
1198
+ }
1199
+ const result = await loadBuildVersionFiles({
1200
+ options,
1201
+ auth,
1202
+ buildId: sourceBuildId,
1203
+ versionNumber,
1204
+ });
1205
+ const files = Array.isArray(result.projectFiles) ? result.projectFiles : [];
1206
+ if (!files.length) {
1207
+ throw new Error(`Save v${versionNumber} has no files to restore.`);
1208
+ }
1209
+ const dir = resolveProjectDirForSave({ options, localProject });
1210
+ await writeProjectFiles({ dir, files });
1211
+ if (files.some((file) => isIndexHtmlPath(String(file.path)))) {
1212
+ await removeLocalProjectFilesNotIn({ dir, files });
1213
+ }
1214
+ const version = result.version || { version: versionNumber };
1215
+ const sourceBuild = result.build || { id: sourceBuildId };
1216
+ const summaryText = version.summary ? ` ("${version.summary}")` : "";
1217
+ console.log(
1218
+ `Restored ${files.length} file${files.length === 1 ? "" : "s"} from ${formatBuildTitle(sourceBuild)} save v${version.version}${summaryText} into ${dir}`,
1219
+ );
1220
+ console.log("Local only so far — review the files, then run:");
1221
+ console.log(
1222
+ ` lumine save --summary ${shellQuote(`Restore from ${options.pullMain ? "main " : ""}v${version.version}`)}`,
1223
+ );
1224
+ }
1225
+
922
1226
  export function printBuildList(builds) {
923
1227
  if (!builds.length) {
924
1228
  console.log("No owned or team Twinkle builds found.");
@@ -1202,6 +1506,9 @@ export function parseArgs(args) {
1202
1506
  "allowWrite",
1203
1507
  "main",
1204
1508
  "yes",
1509
+ "json",
1510
+ "keepAssets",
1511
+ "noBrowser",
1205
1512
  ]);
1206
1513
 
1207
1514
  for (let i = 0; i < rest.length; i += 1) {
@@ -1270,6 +1577,11 @@ export function parseArgs(args) {
1270
1577
  siteUrl: trimTrailingSlash(
1271
1578
  String(raw.siteUrl || process.env.TWINKLE_SITE_URL || DEFAULT_SITE_URL),
1272
1579
  ),
1580
+ previewUrl: normalizePreviewUrl(
1581
+ raw.previewUrl ||
1582
+ process.env.TWINKLE_PREVIEW_URL ||
1583
+ process.env.TWINKLE_BUILD_PREVIEW_URL,
1584
+ ),
1273
1585
  npmRegistryUrl: trimTrailingSlash(
1274
1586
  String(
1275
1587
  raw.npmRegistryUrl ||
@@ -1286,6 +1598,14 @@ export function parseArgs(args) {
1286
1598
  clientName: String(raw.clientName || "Lumine CLI").slice(0, 120),
1287
1599
  dir: raw.dir ? String(raw.dir) : "",
1288
1600
  pullMain: parseBoolean(raw.main, false),
1601
+ // Strict: only a plain positive integer counts. Anything else (missing
1602
+ // value, "foo", a swallowed flag like "--main", "2.5") stays 0 and the
1603
+ // consuming command must reject it via versionProvided instead of
1604
+ // silently falling back to a live pull.
1605
+ versionProvided: Object.prototype.hasOwnProperty.call(raw, "version"),
1606
+ pullVersion: /^\d+$/.test(String(raw.version ?? "").trim())
1607
+ ? parseInt(String(raw.version).trim(), 10)
1608
+ : 0,
1289
1609
  summary: raw.summary ? String(raw.summary) : "",
1290
1610
  publish: parseBoolean(raw.publish, false),
1291
1611
  saveFirst: parseBoolean(raw.save, false),
@@ -1300,6 +1620,9 @@ export function parseArgs(args) {
1300
1620
  openBrowser: parseBoolean(raw.noOpen, false)
1301
1621
  ? false
1302
1622
  : parseBoolean(raw.open, true),
1623
+ json: parseBoolean(raw.json, false),
1624
+ keepAssets: parseBoolean(raw.keepAssets, false),
1625
+ noBrowser: parseBoolean(raw.noBrowser, false),
1303
1626
  updateCheck: parseBoolean(raw.noUpdateCheck, false) ? false : true,
1304
1627
  timeoutMs: Math.max(
1305
1628
  Number(raw.timeoutMs || process.env.TWINKLE_TIMEOUT_MS) ||
@@ -1344,6 +1667,22 @@ export async function resolveRequiredBuildIdOrSelected(
1344
1667
  }
1345
1668
  if (mainBuildId > 0) return mainBuildId;
1346
1669
  }
1670
+ // Same deal for a `pull --version <n>` checkout: read-only commands resolve
1671
+ // the build it snapshots; mutating commands are pointed back at the
1672
+ // editable workspace.
1673
+ if (resolvedLocalProject?.metadata?.versionCheckout === true) {
1674
+ const checkoutBuildId =
1675
+ Number(resolvedLocalProject.metadata.buildId || 0) ||
1676
+ Number(resolvedLocalProject.metadata.build?.id || 0);
1677
+ const checkoutVersion =
1678
+ Number(resolvedLocalProject.metadata.checkoutVersion || 0) || 0;
1679
+ if (!MAIN_CHECKOUT_READONLY_COMMANDS.has(options.command)) {
1680
+ throw new Error(
1681
+ `This is a read-only checkout of a previous save${checkoutVersion ? ` (v${checkoutVersion})` : ""}${checkoutBuildId ? ` for Build ${checkoutBuildId}` : ""}; \`lumine ${options.command}\` isn't available here. Run it from the editable workspace, or pass an explicit Build URL.`,
1682
+ );
1683
+ }
1684
+ if (checkoutBuildId > 0) return checkoutBuildId;
1685
+ }
1347
1686
  if (
1348
1687
  resolvedLocalProject?.metadata &&
1349
1688
  isReadOnlyReferenceMetadata(resolvedLocalProject.metadata)
@@ -1433,6 +1772,9 @@ export function printHelp() {
1433
1772
  lumine select [twinkle-build-url]
1434
1773
  lumine pull [twinkle-build-url]
1435
1774
  lumine pull [twinkle-build-url] --main
1775
+ lumine pull [twinkle-build-url] --version <n> [--main]
1776
+ lumine versions [twinkle-build-url] [--main] [--limit <n>]
1777
+ lumine restore <n> [--main]
1436
1778
  lumine reference <twinkle-build-url>
1437
1779
  lumine fork <twinkle-build-url>
1438
1780
  lumine diff <twinkle-branch-url>
@@ -1452,6 +1794,7 @@ export function printHelp() {
1452
1794
  lumine thumbnail set <file>
1453
1795
  lumine thumbnail capture [--out <file>]
1454
1796
  lumine thumbnail generate ["<prompt>"] --model <gpt-image-2|nano-banana>
1797
+ lumine doctor runtime-assets
1455
1798
 
1456
1799
  Examples:
1457
1800
  npx @stage5/lumine@latest
@@ -1464,6 +1807,9 @@ Examples:
1464
1807
  npx @stage5/lumine@latest diff https://www.twin-kle.com/build/884/4
1465
1808
  npx @stage5/lumine@latest merge https://www.twin-kle.com/build/884/4
1466
1809
  npx @stage5/lumine@latest pull 884 --main
1810
+ npx @stage5/lumine@latest versions --main
1811
+ npx @stage5/lumine@latest pull --version 41 --main
1812
+ npx @stage5/lumine@latest restore 41 --main
1467
1813
  npx @stage5/lumine@latest update-from-main
1468
1814
  npx @stage5/lumine@latest pull
1469
1815
  npx @stage5/lumine@latest save
@@ -1479,14 +1825,17 @@ Examples:
1479
1825
  npx @stage5/lumine@latest thumbnail set art/cover.png
1480
1826
  npx @stage5/lumine@latest thumbnail capture --out capture.png
1481
1827
  npx @stage5/lumine@latest thumbnail generate --model gpt-image-2 --yes
1828
+ npx @stage5/lumine@latest doctor runtime-assets --build 917 --json
1482
1829
 
1483
1830
  Options:
1484
1831
  --api-url <url> Twinkle API origin
1485
1832
  --site-url <url> Twinkle website origin
1833
+ --preview-url <url> Twinkle Build preview origin
1486
1834
  --auth-file <path> Saved login path
1487
1835
  --auth-token <token> Override saved login
1488
1836
  --dir <path> Directory for pulled project files
1489
- --main With pull: read-only checkout of the team project's main
1837
+ --main With pull/versions/restore: target the team project's main
1838
+ --version <n> With pull: read-only checkout of previous save v<n>
1490
1839
  --title <text> New Build title
1491
1840
  --description <text> Optional New Build description
1492
1841
  --no-description Skip the New Build description prompt
@@ -1503,6 +1852,9 @@ Options:
1503
1852
  --allow-write Permit sdk methods that mutate app data
1504
1853
  --path <api/...> Call an sdk endpoint not in the curated list
1505
1854
  --scopes <a,b> Override requested build API token scopes
1855
+ --json Print machine-readable output for doctor commands
1856
+ --keep-assets Keep doctor probe assets instead of deleting them
1857
+ --no-browser Skip doctor browser probes
1506
1858
  --model <model> Image model for generate: gpt-image-2 or nano-banana (required, no default)
1507
1859
  --quality <q> gpt-image-2 quality: low, medium, high (default high)
1508
1860
  --name <fileName> File name hint for a generated asset
package/lib/constants.js CHANGED
@@ -88,6 +88,8 @@ export const LUMINE_REFERENCE_INSTRUCTIONS_MARKER =
88
88
  "<!-- Lumine CLI Reference Instructions -->";
89
89
  export const LUMINE_MAIN_CHECKOUT_INSTRUCTIONS_MARKER =
90
90
  "<!-- Lumine CLI Main Checkout Instructions -->";
91
+ export const LUMINE_VERSION_CHECKOUT_INSTRUCTIONS_MARKER =
92
+ "<!-- Lumine CLI Version Checkout Instructions -->";
91
93
  export const BUNDLED_SDK_REFERENCE_URL = new URL(
92
94
  "../sdk/BUILD_SDK_INDEX.md",
93
95
  import.meta.url,
@@ -139,6 +141,12 @@ lumine save --summary "Describe the change"
139
141
  \`\`\`
140
142
 
141
143
  - Run lumine check before launch when possible.
144
+ - \`lumine versions\` lists this build's previous saves (add --main inside a
145
+ branch workspace for the team project's main history). \`lumine pull
146
+ --version <n>\` checks a previous save out read-only alongside the workspace
147
+ for inspection; \`lumine restore <n>\` writes that save's files into THIS
148
+ workspace (local only — review, then lumine save). Use these to recover work
149
+ lost to a bad edit, merge, or overwrite instead of rebuilding it from memory.
142
150
  - Use \`lumine sdk call <namespace.method> '{...}'\` to inspect real endpoint
143
151
  data and measure latency (add --repeat <n>); \`lumine sdk list\` shows
144
152
  callable methods. It prints the raw HTTP endpoint response, which can differ
@@ -206,6 +214,7 @@ lumine save --summary "Describe the change"
206
214
 
207
215
  - Use local project files with relative or root-local imports only. Do not add package imports, CDN scripts, external network calls, or app-local /api/* routes.
208
216
  - Build apps run in sandboxed iframes without allow-forms. Do not use <form> elements, native form submission, requestSubmit(), or browser form navigation. Build input flows with JavaScript-handled inputs and buttons instead.
217
+ - Interface text must not be selectable on touch devices: long-pressing UI on mobile must not highlight it. Apply user-select: none plus -webkit-user-select: none and -webkit-touch-callout: none to interface text (HUD, buttons, labels, menus, scores, game controls). Keep text inputs and genuinely user-copyable content (story text, chat messages, user-written text) selectable. lumine check flags projects whose reachable files have clickable UI but no user-select: none rule.
209
218
  - CAUTION: the preview runtime AUTO-DETECTS "game apps" — any <canvas> in the body (even a decorative background canvas) or game-y words in visible text switch the app to viewport-app mode: html/body get overflow:hidden !important and body becomes a centering flexbox, so tall document-flow pages clip and stop scrolling. Document-style apps that use a canvas must call Twinkle.preview.subscribe (or getLayout/reserveInsets) early at boot — any of those opts out of auto game mode — then pad by layout.safeInsets and scroll within layout.viewport.height.
210
219
  - 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.
211
220
  - 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.
@@ -240,6 +249,22 @@ the workspace to save.
240
249
  - To start from this Build, run lumine fork with the source build id and edit the forked workspace.
241
250
  - Do not edit another local checkout to bypass reference read-only semantics.
242
251
  `;
252
+ export const LUMINE_VERSION_CHECKOUT_INSTRUCTIONS = `${LUMINE_VERSION_CHECKOUT_INSTRUCTIONS_MARKER}
253
+ # Lumine Version Checkout (Read-Only)
254
+
255
+ This directory is a read-only snapshot of ONE PREVIOUS SAVE of a Twinkle Build,
256
+ pulled with \`lumine pull --version <n>\`. Use it to inspect or diff what the
257
+ project looked like at that save; it is not the workspace to edit or save.
258
+
259
+ ## Source Of Truth
260
+
261
+ - Read .twinkle/lumine-project.json before using these files.
262
+ - metadata.versionCheckout is true here: do not run lumine save from this directory.
263
+ - To bring this save's files back into the live project, run \`lumine restore
264
+ <n>\` from the editable workspace (then \`lumine save\`), or copy the specific
265
+ parts you need into that workspace.
266
+ - Do not edit another local checkout to bypass read-only semantics.
267
+ `;
243
268
  export const LUMINE_MAIN_CHECKOUT_INSTRUCTIONS = `${LUMINE_MAIN_CHECKOUT_INSTRUCTIONS_MARKER}
244
269
  # Lumine Main Checkout (Read-Only)
245
270
 
@@ -268,6 +293,8 @@ export const MAIN_CHECKOUT_READONLY_COMMANDS = new Set([
268
293
  // assets never touch main's project files (uploads go to the current
269
294
  // user's own asset space), so listing/uploading from a main checkout is safe.
270
295
  "assets",
296
+ // versions only reads save history.
297
+ "versions",
271
298
  ]);
272
299
  export const COMMANDS = new Set([
273
300
  "workspace",
@@ -289,8 +316,11 @@ export const COMMANDS = new Set([
289
316
  "push",
290
317
  "check",
291
318
  "launch",
319
+ "versions",
320
+ "restore",
292
321
  "sdk",
293
322
  "assets",
294
323
  "thumbnail",
324
+ "doctor",
295
325
  "help",
296
326
  ]);