@stage5/lumine 0.2.5 → 0.2.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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,
@@ -38,6 +42,7 @@ import {
38
42
  saveProjectFiles,
39
43
  } from "./api.js";
40
44
  import { assetsCommand, writeAssetsManifest } from "./assets.js";
45
+ import { thumbnailCommand } from "./thumbnail.js";
41
46
  import {
42
47
  assertAuthScope,
43
48
  ensureAuth,
@@ -52,10 +57,12 @@ import { sdkCommand } from "./sdk.js";
52
57
  import {
53
58
  defaultMainCheckoutDir,
54
59
  defaultReferenceDir,
60
+ defaultVersionCheckoutDir,
55
61
  defaultWorkspaceDir,
56
62
  parseBoolean,
57
63
  resolveBuildId,
58
64
  resolveBuildReference,
65
+ resolveRequiredBuildId,
59
66
  shellQuote,
60
67
  toCamelCase,
61
68
  trimTrailingSlash,
@@ -74,6 +81,7 @@ import {
74
81
  writeInstructionFiles,
75
82
  writeMainCheckoutMetadata,
76
83
  writeProjectFiles,
84
+ writeVersionCheckoutMetadata,
77
85
  writeProjectMetadata,
78
86
  writeReferenceInstructions,
79
87
  writeReferenceMetadata,
@@ -159,6 +167,14 @@ export async function main() {
159
167
  await check(options);
160
168
  return;
161
169
  }
170
+ if (options.command === "versions") {
171
+ await versionsCommand(options);
172
+ return;
173
+ }
174
+ if (options.command === "restore") {
175
+ await restoreVersion(options);
176
+ return;
177
+ }
162
178
  if (options.command === "launch") {
163
179
  await launch(options);
164
180
  return;
@@ -171,6 +187,10 @@ export async function main() {
171
187
  await assetsCommand(options);
172
188
  return;
173
189
  }
190
+ if (options.command === "thumbnail") {
191
+ await thumbnailCommand(options);
192
+ return;
193
+ }
174
194
 
175
195
  printHelp();
176
196
  }
@@ -245,6 +265,11 @@ export async function selectProject(options) {
245
265
  }
246
266
 
247
267
  export async function pull(options) {
268
+ if (options.versionProvided && !(options.pullVersion > 0)) {
269
+ throw new Error(
270
+ "Pass a save number: `lumine pull --version <n>` (run `lumine versions` to see save numbers).",
271
+ );
272
+ }
248
273
  const auth = await resolveAuth(options);
249
274
  const localProject = await findLocalProjectMetadata(
250
275
  path.resolve(options.dir || process.cwd()),
@@ -259,6 +284,10 @@ export async function pull(options) {
259
284
  auth,
260
285
  buildId: requestedBuildId,
261
286
  });
287
+ if (options.pullVersion > 0) {
288
+ await pullVersionBuildFiles({ options, auth, build: selectedBuild });
289
+ return;
290
+ }
262
291
  if (options.pullMain) {
263
292
  await pullMainBuildFiles({ options, auth, build: selectedBuild });
264
293
  return;
@@ -676,6 +705,18 @@ export function shouldUseContributionBranch(build) {
676
705
  );
677
706
  }
678
707
 
708
+ // A canonical team build the viewer contributes to: plain pull/restore would
709
+ // switch to their contribution branch, so version-history follow-up commands
710
+ // for THIS build's history need --main. An owner's canonical build is their
711
+ // editable workspace and never needs it.
712
+ export function isCollaboratorMainBuild(build) {
713
+ return (
714
+ build?.role === "collaborator" &&
715
+ build.canWrite !== true &&
716
+ !Number(build?.contributionRootBuildId || 0)
717
+ );
718
+ }
719
+
679
720
  export async function ensureDefaultContributionBranch({ options, auth, build }) {
680
721
  const rootBuildId =
681
722
  Number(build?.contributionRootBuildId || 0) || Number(build?.id || 0);
@@ -914,6 +955,269 @@ export async function pullMainBuildFiles({ options, auth, build }) {
914
955
  );
915
956
  }
916
957
 
958
+ // Read-only checkout of ONE previous save (`lumine pull --version <n>`).
959
+ // Without --main it targets the build the workspace edits (your branch on team
960
+ // projects); with --main it targets the team project's main history.
961
+ export async function pullVersionBuildFiles({ options, auth, build }) {
962
+ const versionNumber = options.pullVersion;
963
+ let targetBuild = build;
964
+ if (options.pullMain) {
965
+ const rootBuildId =
966
+ Number(build?.contributionRootBuildId || 0) || Number(build?.id || 0);
967
+ targetBuild =
968
+ rootBuildId === Number(build?.id || 0)
969
+ ? build
970
+ : await loadBuildMetadata({ options, auth, buildId: rootBuildId });
971
+ } else {
972
+ targetBuild = await resolveEditableWorkspaceBuild({
973
+ options,
974
+ auth,
975
+ build,
976
+ });
977
+ }
978
+ const buildId = Number(targetBuild?.id || 0);
979
+ const result = await loadBuildVersionFiles({
980
+ options,
981
+ auth,
982
+ buildId,
983
+ versionNumber,
984
+ });
985
+ const resolvedBuild = result.build || targetBuild;
986
+ const version = result.version || { version: versionNumber };
987
+ const files = Array.isArray(result.projectFiles) ? result.projectFiles : [];
988
+ // Same nesting rules as a main checkout: never create a version checkout
989
+ // inside another Lumine workspace; re-pulling the same checkout refreshes it
990
+ // in place.
991
+ const enclosing = options.dir
992
+ ? null
993
+ : await findLocalProjectMetadata(process.cwd());
994
+ const enclosingIsThisCheckout =
995
+ enclosing?.metadata?.versionCheckout === true &&
996
+ Number(enclosing.metadata.buildId || 0) === buildId &&
997
+ Number(enclosing.metadata.checkoutVersion || 0) ===
998
+ Number(version.version || 0);
999
+ const dirName = defaultVersionCheckoutDir(resolvedBuild, version.version);
1000
+ const dir = path.resolve(
1001
+ options.dir ||
1002
+ (enclosingIsThisCheckout
1003
+ ? enclosing.rootDir
1004
+ : enclosing
1005
+ ? path.join(path.dirname(enclosing.rootDir), dirName)
1006
+ : dirName),
1007
+ );
1008
+ await writeProjectFiles({ dir, files });
1009
+ if (files.some((file) => isIndexHtmlPath(String(file.path)))) {
1010
+ await removeLocalProjectFilesNotIn({ dir, files });
1011
+ }
1012
+ await writeInstructionFiles({
1013
+ dir,
1014
+ marker: LUMINE_VERSION_CHECKOUT_INSTRUCTIONS_MARKER,
1015
+ content: LUMINE_VERSION_CHECKOUT_INSTRUCTIONS,
1016
+ });
1017
+ await writeSdkReference({ dir });
1018
+ await writeVersionCheckoutMetadata({
1019
+ dir,
1020
+ options,
1021
+ build: resolvedBuild,
1022
+ manifest: result.projectManifest || null,
1023
+ version,
1024
+ pulledAt: new Date().toISOString(),
1025
+ });
1026
+ const summaryText = version.summary ? ` — "${version.summary}"` : "";
1027
+ console.log(
1028
+ `Pulled save v${version.version} of ${formatBuildTitle(resolvedBuild)} (read-only)${summaryText}`,
1029
+ );
1030
+ console.log(
1031
+ `Pulled ${files.length} file${files.length === 1 ? "" : "s"} to ${dir}`,
1032
+ );
1033
+ console.log(
1034
+ isCollaboratorMainBuild(resolvedBuild)
1035
+ ? `Restore it with \`lumine restore ${version.version} --main\` from your branch workspace, then \`lumine save\`.`
1036
+ : `Restore it with \`lumine restore ${version.version}\` from Build ${buildId}'s editable workspace, then \`lumine save\`.`,
1037
+ );
1038
+ }
1039
+
1040
+ // List a build's previous saves (every workspace/CLI save creates one).
1041
+ // Targets the SAME build `lumine pull --version <n>` would snapshot, so the
1042
+ // listed numbers and the printed follow-up commands always agree: without
1043
+ // --main that's the editable workspace build (a collaborator's branch on team
1044
+ // projects), with --main the team project's main.
1045
+ export async function versionsCommand(options) {
1046
+ const auth = await resolveAuth(options);
1047
+ const localProject = await findLocalProjectMetadata(
1048
+ path.resolve(options.dir || process.cwd()),
1049
+ );
1050
+ const requestedBuildId = options.buildIdFlag
1051
+ ? resolveRequiredBuildId(options.buildIdFlag)
1052
+ : await resolveRequiredBuildIdOrSelected(options, auth, { localProject });
1053
+ const requestedBuild = await loadBuildMetadata({
1054
+ options,
1055
+ auth,
1056
+ buildId: requestedBuildId,
1057
+ });
1058
+ // Standing in a read-only checkout (pull --main / pull --version), the
1059
+ // resolved id IS the build being looked at — don't bounce it to the
1060
+ // viewer's contribution branch.
1061
+ const resolvedFromReadOnlyCheckout =
1062
+ !options.buildIdFlag &&
1063
+ !(resolveBuildReference(options.target).buildId > 0) &&
1064
+ (localProject?.metadata?.mainCheckout === true ||
1065
+ localProject?.metadata?.versionCheckout === true);
1066
+ let buildId = requestedBuildId;
1067
+ if (options.pullMain) {
1068
+ buildId =
1069
+ Number(requestedBuild?.contributionRootBuildId || 0) || requestedBuildId;
1070
+ } else if (!resolvedFromReadOnlyCheckout) {
1071
+ const editableBuild = await resolveEditableWorkspaceBuild({
1072
+ options,
1073
+ auth,
1074
+ build: requestedBuild,
1075
+ });
1076
+ buildId = Number(editableBuild?.id || 0) || requestedBuildId;
1077
+ }
1078
+ const result = await loadBuildVersions({
1079
+ options,
1080
+ auth,
1081
+ buildId,
1082
+ limit: options.limit,
1083
+ });
1084
+ printVersionList({ result });
1085
+ }
1086
+
1087
+ export function printVersionList({ result }) {
1088
+ const build = result.build || {};
1089
+ const versions = Array.isArray(result.versions) ? result.versions : [];
1090
+ if (!versions.length) {
1091
+ console.log(`No previous saves found for ${formatBuildTitle(build)}.`);
1092
+ return;
1093
+ }
1094
+ console.log(
1095
+ `Previous saves for ${formatBuildTitle(build)} (newest first):`,
1096
+ );
1097
+ for (const version of versions) {
1098
+ const author =
1099
+ version.createdByUsername ||
1100
+ (version.createdByRole === "assistant" ? "Lumine" : null) ||
1101
+ version.createdByRole ||
1102
+ "unknown";
1103
+ const fileText =
1104
+ Number(version.fileCount || 0) > 0
1105
+ ? `${version.fileCount} file${Number(version.fileCount) === 1 ? "" : "s"}`
1106
+ : "single-file save";
1107
+ const summaryText = version.summary ? ` — ${version.summary}` : "";
1108
+ console.log(
1109
+ ` v${version.version} ${formatVersionTimestamp(version.createdAt)} by ${author} (${fileText})${summaryText}`,
1110
+ );
1111
+ }
1112
+ // The follow-up commands must hit exactly the history that was just listed,
1113
+ // regardless of where the user runs them or which flags produced this list.
1114
+ // Embedding the listed build id makes pull workspace-independent; --main is
1115
+ // needed only when the listed build is a canonical team build the viewer
1116
+ // contributes to (plain pull/restore would switch to their branch — mirrors
1117
+ // shouldUseContributionBranch). An owner's canonical build never needs
1118
+ // --main: it IS their editable workspace.
1119
+ const listedBuildId = Number(build.id || 0);
1120
+ const collaboratorMain = isCollaboratorMainBuild(build);
1121
+ const pullTarget = listedBuildId ? ` ${listedBuildId}` : "";
1122
+ console.log(
1123
+ `View one read-only: lumine pull${pullTarget} --version <n>${collaboratorMain ? " --main" : ""}`,
1124
+ );
1125
+ console.log(
1126
+ collaboratorMain
1127
+ ? `Bring one back: lumine restore <n> --main (run from your branch workspace; then lumine save)`
1128
+ : `Bring one back: lumine restore <n> (run from ${listedBuildId ? `Build ${listedBuildId}'s` : "the build's"} editable workspace; then lumine save)`,
1129
+ );
1130
+ }
1131
+
1132
+ function formatVersionTimestamp(seconds) {
1133
+ const timestamp = Number(seconds || 0);
1134
+ if (!timestamp) return "unknown time";
1135
+ const date = new Date(timestamp * 1000);
1136
+ const pad = (value) => String(value).padStart(2, "0");
1137
+ return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`;
1138
+ }
1139
+
1140
+ // Write a previous save's files into the CURRENT editable workspace. Local
1141
+ // only on purpose: the user reviews the result and `lumine save` makes it a
1142
+ // new version (nothing on the server is rewritten in place). With --main in a
1143
+ // branch workspace, the save is read from the team project's MAIN history —
1144
+ // the recovery path when work was lost on main itself.
1145
+ export async function restoreVersion(options) {
1146
+ // Same strictness as `pull --version`: only a plain positive integer may
1147
+ // reach the workspace-overwriting path below. "2.5" must error, not
1148
+ // silently restore v2.
1149
+ const rawInput = String(options.positional[0] ?? "").trim();
1150
+ let versionNumber = 0;
1151
+ if (rawInput) {
1152
+ if (!/^\d+$/.test(rawInput)) {
1153
+ throw new Error(
1154
+ "Pass a plain save number, e.g. `lumine restore 41` (see `lumine versions`).",
1155
+ );
1156
+ }
1157
+ versionNumber = parseInt(rawInput, 10);
1158
+ } else if (options.pullVersion > 0) {
1159
+ // --version <n>, already strict-parsed by parseArgs.
1160
+ versionNumber = options.pullVersion;
1161
+ } else if (options.versionProvided) {
1162
+ throw new Error(
1163
+ "Pass a plain save number, e.g. `lumine restore 41` (see `lumine versions`).",
1164
+ );
1165
+ }
1166
+ if (!(versionNumber > 0)) {
1167
+ throw new Error(
1168
+ "Pass the save number to restore, e.g. `lumine restore 41` (see `lumine versions`).",
1169
+ );
1170
+ }
1171
+ const auth = await resolveAuth(options);
1172
+ const localProject = await findLocalProjectMetadata(
1173
+ path.resolve(options.dir || process.cwd()),
1174
+ );
1175
+ if (!Number(localProject?.metadata?.buildId || 0)) {
1176
+ throw new Error(
1177
+ "Run `lumine restore` inside a pulled Lumine workspace (or pass --dir <workspace>).",
1178
+ );
1179
+ }
1180
+ assertLocalProjectCanBeSaved(localProject);
1181
+ const workspaceBuildId = Number(localProject.metadata.buildId);
1182
+ let sourceBuildId = workspaceBuildId;
1183
+ if (options.pullMain) {
1184
+ const rootBuildId = Number(
1185
+ localProject.metadata.build?.contributionRootBuildId || 0,
1186
+ );
1187
+ if (!rootBuildId) {
1188
+ throw new Error(
1189
+ "--main restores from the team project's main history, which needs a contribution-branch workspace.",
1190
+ );
1191
+ }
1192
+ sourceBuildId = rootBuildId;
1193
+ }
1194
+ const result = await loadBuildVersionFiles({
1195
+ options,
1196
+ auth,
1197
+ buildId: sourceBuildId,
1198
+ versionNumber,
1199
+ });
1200
+ const files = Array.isArray(result.projectFiles) ? result.projectFiles : [];
1201
+ if (!files.length) {
1202
+ throw new Error(`Save v${versionNumber} has no files to restore.`);
1203
+ }
1204
+ const dir = resolveProjectDirForSave({ options, localProject });
1205
+ await writeProjectFiles({ dir, files });
1206
+ if (files.some((file) => isIndexHtmlPath(String(file.path)))) {
1207
+ await removeLocalProjectFilesNotIn({ dir, files });
1208
+ }
1209
+ const version = result.version || { version: versionNumber };
1210
+ const sourceBuild = result.build || { id: sourceBuildId };
1211
+ const summaryText = version.summary ? ` ("${version.summary}")` : "";
1212
+ console.log(
1213
+ `Restored ${files.length} file${files.length === 1 ? "" : "s"} from ${formatBuildTitle(sourceBuild)} save v${version.version}${summaryText} into ${dir}`,
1214
+ );
1215
+ console.log("Local only so far — review the files, then run:");
1216
+ console.log(
1217
+ ` lumine save --summary ${shellQuote(`Restore from ${options.pullMain ? "main " : ""}v${version.version}`)}`,
1218
+ );
1219
+ }
1220
+
917
1221
  export function printBuildList(builds) {
918
1222
  if (!builds.length) {
919
1223
  console.log("No owned or team Twinkle builds found.");
@@ -1233,6 +1537,10 @@ export function parseArgs(args) {
1233
1537
  .map((scope) => scope.trim())
1234
1538
  .filter(Boolean),
1235
1539
  buildIdFlag: raw.build ? String(raw.build) : "",
1540
+ model: raw.model ? String(raw.model) : "",
1541
+ quality: raw.quality ? String(raw.quality) : "",
1542
+ assetName: raw.name ? String(raw.name) : "",
1543
+ out: raw.out ? String(raw.out) : "",
1236
1544
  target: raw.url || raw.target || positional[0] || "",
1237
1545
  title:
1238
1546
  String(
@@ -1277,6 +1585,14 @@ export function parseArgs(args) {
1277
1585
  clientName: String(raw.clientName || "Lumine CLI").slice(0, 120),
1278
1586
  dir: raw.dir ? String(raw.dir) : "",
1279
1587
  pullMain: parseBoolean(raw.main, false),
1588
+ // Strict: only a plain positive integer counts. Anything else (missing
1589
+ // value, "foo", a swallowed flag like "--main", "2.5") stays 0 and the
1590
+ // consuming command must reject it via versionProvided instead of
1591
+ // silently falling back to a live pull.
1592
+ versionProvided: Object.prototype.hasOwnProperty.call(raw, "version"),
1593
+ pullVersion: /^\d+$/.test(String(raw.version ?? "").trim())
1594
+ ? parseInt(String(raw.version).trim(), 10)
1595
+ : 0,
1280
1596
  summary: raw.summary ? String(raw.summary) : "",
1281
1597
  publish: parseBoolean(raw.publish, false),
1282
1598
  saveFirst: parseBoolean(raw.save, false),
@@ -1335,6 +1651,22 @@ export async function resolveRequiredBuildIdOrSelected(
1335
1651
  }
1336
1652
  if (mainBuildId > 0) return mainBuildId;
1337
1653
  }
1654
+ // Same deal for a `pull --version <n>` checkout: read-only commands resolve
1655
+ // the build it snapshots; mutating commands are pointed back at the
1656
+ // editable workspace.
1657
+ if (resolvedLocalProject?.metadata?.versionCheckout === true) {
1658
+ const checkoutBuildId =
1659
+ Number(resolvedLocalProject.metadata.buildId || 0) ||
1660
+ Number(resolvedLocalProject.metadata.build?.id || 0);
1661
+ const checkoutVersion =
1662
+ Number(resolvedLocalProject.metadata.checkoutVersion || 0) || 0;
1663
+ if (!MAIN_CHECKOUT_READONLY_COMMANDS.has(options.command)) {
1664
+ throw new Error(
1665
+ `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.`,
1666
+ );
1667
+ }
1668
+ if (checkoutBuildId > 0) return checkoutBuildId;
1669
+ }
1338
1670
  if (
1339
1671
  resolvedLocalProject?.metadata &&
1340
1672
  isReadOnlyReferenceMetadata(resolvedLocalProject.metadata)
@@ -1424,6 +1756,9 @@ export function printHelp() {
1424
1756
  lumine select [twinkle-build-url]
1425
1757
  lumine pull [twinkle-build-url]
1426
1758
  lumine pull [twinkle-build-url] --main
1759
+ lumine pull [twinkle-build-url] --version <n> [--main]
1760
+ lumine versions [twinkle-build-url] [--main] [--limit <n>]
1761
+ lumine restore <n> [--main]
1427
1762
  lumine reference <twinkle-build-url>
1428
1763
  lumine fork <twinkle-build-url>
1429
1764
  lumine diff <twinkle-branch-url>
@@ -1437,8 +1772,12 @@ export function printHelp() {
1437
1772
  lumine sdk call <namespace.method> [jsonArgs]
1438
1773
  lumine assets [list]
1439
1774
  lumine assets upload <file...>
1775
+ lumine assets generate "<prompt>" --model <gpt-image-2|nano-banana>
1440
1776
  lumine assets delete <assetId>
1441
1777
  lumine assets prune [--yes]
1778
+ lumine thumbnail set <file>
1779
+ lumine thumbnail capture [--out <file>]
1780
+ lumine thumbnail generate ["<prompt>"] --model <gpt-image-2|nano-banana>
1442
1781
 
1443
1782
  Examples:
1444
1783
  npx @stage5/lumine@latest
@@ -1451,6 +1790,9 @@ Examples:
1451
1790
  npx @stage5/lumine@latest diff https://www.twin-kle.com/build/884/4
1452
1791
  npx @stage5/lumine@latest merge https://www.twin-kle.com/build/884/4
1453
1792
  npx @stage5/lumine@latest pull 884 --main
1793
+ npx @stage5/lumine@latest versions --main
1794
+ npx @stage5/lumine@latest pull --version 41 --main
1795
+ npx @stage5/lumine@latest restore 41 --main
1454
1796
  npx @stage5/lumine@latest update-from-main
1455
1797
  npx @stage5/lumine@latest pull
1456
1798
  npx @stage5/lumine@latest save
@@ -1462,6 +1804,10 @@ Examples:
1462
1804
  npx @stage5/lumine@latest sdk call aiStories.list '{"difficulty": 1}' --repeat 5
1463
1805
  npx @stage5/lumine@latest assets upload art/hero.png sounds/theme.mp3
1464
1806
  npx @stage5/lumine@latest assets list --build 917
1807
+ npx @stage5/lumine@latest assets generate "pixel-art forest background" --model nano-banana
1808
+ npx @stage5/lumine@latest thumbnail set art/cover.png
1809
+ npx @stage5/lumine@latest thumbnail capture --out capture.png
1810
+ npx @stage5/lumine@latest thumbnail generate --model gpt-image-2 --yes
1465
1811
 
1466
1812
  Options:
1467
1813
  --api-url <url> Twinkle API origin
@@ -1469,7 +1815,8 @@ Options:
1469
1815
  --auth-file <path> Saved login path
1470
1816
  --auth-token <token> Override saved login
1471
1817
  --dir <path> Directory for pulled project files
1472
- --main With pull: read-only checkout of the team project's main
1818
+ --main With pull/versions/restore: target the team project's main
1819
+ --version <n> With pull: read-only checkout of previous save v<n>
1473
1820
  --title <text> New Build title
1474
1821
  --description <text> Optional New Build description
1475
1822
  --no-description Skip the New Build description prompt
@@ -1481,11 +1828,15 @@ Options:
1481
1828
  --limit <number> Number of projects to show
1482
1829
  --no-update-check Skip the npm latest-version check
1483
1830
  --no-open Print the approval URL without opening a browser
1484
- --build <id> Build id for sdk/assets calls outside a workspace
1831
+ --build <id> Build id for sdk/assets/thumbnail calls outside a workspace
1485
1832
  --repeat <n> Repeat an sdk call (1-20) and print latency stats
1486
1833
  --allow-write Permit sdk methods that mutate app data
1487
1834
  --path <api/...> Call an sdk endpoint not in the curated list
1488
1835
  --scopes <a,b> Override requested build API token scopes
1489
- --yes Skip the confirmation prompt (assets prune)
1836
+ --model <model> Image model for generate: gpt-image-2 or nano-banana (required, no default)
1837
+ --quality <q> gpt-image-2 quality: low, medium, high (default high)
1838
+ --name <fileName> File name hint for a generated asset
1839
+ --out <path> With thumbnail capture: also save the capture locally
1840
+ --yes Skip confirmation prompts (assets prune/generate, thumbnail)
1490
1841
  `);
1491
1842
  }
package/lib/constants.js CHANGED
@@ -10,6 +10,25 @@ export const DEFAULT_AUTH_FILE = path.join(
10
10
  "lumine-cli-auth.json",
11
11
  );
12
12
  export const DEFAULT_TIMEOUT_MS = 20000;
13
+ // Image generation runs a multi-minute provider call; thumbnail capture runs a
14
+ // headless browser server-side. Both need per-call timeouts far above the
15
+ // 20s default.
16
+ export const ASSET_GENERATE_TIMEOUT_MS = 6 * 60 * 1000;
17
+ export const THUMBNAIL_CAPTURE_TIMEOUT_MS = 90 * 1000;
18
+ export const GENERATE_MODEL_ALIASES = {
19
+ "gpt-image-2": "gpt-image-2",
20
+ "nano-banana": "gemini-3-pro-image-preview",
21
+ "gemini-3-pro-image-preview": "gemini-3-pro-image-preview",
22
+ };
23
+ export const GENERATE_QUALITIES = new Set(["low", "medium", "high"]);
24
+ // Server accepts only these thumbnail content types (8MB max).
25
+ export const THUMBNAIL_CONTENT_TYPE_BY_EXTENSION = {
26
+ ".jpg": "image/jpeg",
27
+ ".jpeg": "image/jpeg",
28
+ ".png": "image/png",
29
+ ".webp": "image/webp",
30
+ };
31
+ export const THUMBNAIL_MAX_FILE_SIZE_BYTES = 8 * 1024 * 1024;
13
32
  export const UPDATE_CHECK_TIMEOUT_MS = 1500;
14
33
  export const DEFAULT_PROJECT_LIMIT = 50;
15
34
  export const PROJECT_METADATA_DIR = ".twinkle";
@@ -69,6 +88,8 @@ export const LUMINE_REFERENCE_INSTRUCTIONS_MARKER =
69
88
  "<!-- Lumine CLI Reference Instructions -->";
70
89
  export const LUMINE_MAIN_CHECKOUT_INSTRUCTIONS_MARKER =
71
90
  "<!-- Lumine CLI Main Checkout Instructions -->";
91
+ export const LUMINE_VERSION_CHECKOUT_INSTRUCTIONS_MARKER =
92
+ "<!-- Lumine CLI Version Checkout Instructions -->";
72
93
  export const BUNDLED_SDK_REFERENCE_URL = new URL(
73
94
  "../sdk/BUILD_SDK_INDEX.md",
74
95
  import.meta.url,
@@ -120,6 +141,12 @@ lumine save --summary "Describe the change"
120
141
  \`\`\`
121
142
 
122
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.
123
150
  - Use \`lumine sdk call <namespace.method> '{...}'\` to inspect real endpoint
124
151
  data and measure latency (add --repeat <n>); \`lumine sdk list\` shows
125
152
  callable methods. It prints the raw HTTP endpoint response, which can differ
@@ -152,6 +179,26 @@ lumine save --summary "Describe the change"
152
179
  replaces. Update code references, then delete or prune the old asset.
153
180
  - Prefer one small assets module (e.g. src/assets.js) mapping names to asset
154
181
  URLs over scattering URLs through the code.
182
+ - \`lumine assets generate "<prompt>" --model <gpt-image-2|nano-banana>\` creates
183
+ an AI-generated image asset. --model is REQUIRED (no default): gpt-image-2 is
184
+ best quality but slower and pricier; nano-banana (Gemini) is faster and
185
+ cheaper. It spends the logged-in user's AI Battery, so it asks y/N with the
186
+ estimated cost first; non-interactive runs must pass --yes to consent.
187
+ Optional: --quality low|medium|high (gpt-image-2 only, default high),
188
+ --name <fileName>. The asset lands in .twinkle/${ASSETS_METADATA_FILE} like an upload.
189
+
190
+ ## Thumbnail
191
+
192
+ - \`lumine thumbnail set <file>\` uploads a jpg/png/webp (max 8MB) as the
193
+ build's thumbnail. Thumbnails display ~16:9; the image is uploaded uncropped.
194
+ - \`lumine thumbnail capture\` screenshots the running app server-side and sets
195
+ the result as the thumbnail (add --out <file> to also save it locally).
196
+ - \`lumine thumbnail generate "<prompt>" --model <gpt-image-2|nano-banana>\`
197
+ generates an AI image and sets it as the thumbnail (also stored as a normal
198
+ reusable asset). Same battery-cost consent rules as \`assets generate\`.
199
+ - Replacing an existing thumbnail asks y/N; pass --yes for non-interactive runs.
200
+ - Never run thumbnail commands from a read-only \`pull --main\` checkout; use
201
+ your contribution-branch workspace or an owned build.
155
202
 
156
203
  ## Project File Limits
157
204
 
@@ -167,6 +214,7 @@ lumine save --summary "Describe the change"
167
214
 
168
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.
169
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.
170
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.
171
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.
172
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.
@@ -201,6 +249,22 @@ the workspace to save.
201
249
  - To start from this Build, run lumine fork with the source build id and edit the forked workspace.
202
250
  - Do not edit another local checkout to bypass reference read-only semantics.
203
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
+ `;
204
268
  export const LUMINE_MAIN_CHECKOUT_INSTRUCTIONS = `${LUMINE_MAIN_CHECKOUT_INSTRUCTIONS_MARKER}
205
269
  # Lumine Main Checkout (Read-Only)
206
270
 
@@ -229,6 +293,8 @@ export const MAIN_CHECKOUT_READONLY_COMMANDS = new Set([
229
293
  // assets never touch main's project files (uploads go to the current
230
294
  // user's own asset space), so listing/uploading from a main checkout is safe.
231
295
  "assets",
296
+ // versions only reads save history.
297
+ "versions",
232
298
  ]);
233
299
  export const COMMANDS = new Set([
234
300
  "workspace",
@@ -250,7 +316,10 @@ export const COMMANDS = new Set([
250
316
  "push",
251
317
  "check",
252
318
  "launch",
319
+ "versions",
320
+ "restore",
253
321
  "sdk",
254
322
  "assets",
323
+ "thumbnail",
255
324
  "help",
256
325
  ]);