@stage5/lumine 0.2.11 → 0.2.13

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
@@ -132,7 +132,14 @@ export async function createBuild({ options, auth, title, description }) {
132
132
  });
133
133
  }
134
134
 
135
- export async function saveProjectFiles({ options, auth, buildId, files, summary }) {
135
+ export async function saveProjectFiles({
136
+ options,
137
+ auth,
138
+ buildId,
139
+ files,
140
+ summary,
141
+ baseFilesHash,
142
+ }) {
136
143
  return await requestJson({
137
144
  method: "PUT",
138
145
  url: `${options.apiUrl}/build/${buildId}/project-files`,
@@ -141,6 +148,10 @@ export async function saveProjectFiles({ options, auth, buildId, files, summary
141
148
  files,
142
149
  createVersion: true,
143
150
  summary,
151
+ // Proves this save is based on the snapshot we pulled so the server can
152
+ // reject it instead of silently rewinding newer state (e.g. a branch
153
+ // merged into main after our pull).
154
+ ...(baseFilesHash ? { baseFilesHash } : {}),
144
155
  },
145
156
  timeoutMs: options.timeoutMs,
146
157
  });
package/lib/assets.js CHANGED
@@ -81,8 +81,17 @@ export function resolveGenerateModel(options) {
81
81
  }
82
82
 
83
83
  export function formatBatteryPercent(energyUnits, fullBatteryUnits) {
84
- const total = Number(fullBatteryUnits) || 1_000_000;
85
- const percent = ((Number(energyUnits) || 0) / total) * 100;
84
+ const total = Number(fullBatteryUnits);
85
+ const units = Number(energyUnits);
86
+ if (
87
+ !Number.isFinite(total) ||
88
+ total <= 0 ||
89
+ !Number.isFinite(units) ||
90
+ units < 0
91
+ ) {
92
+ return "unavailable";
93
+ }
94
+ const percent = (units / total) * 100;
86
95
  if (percent <= 0) return "~0%";
87
96
  if (percent < 1) return "<1%";
88
97
  return `~${Math.round(percent)}%`;
@@ -201,7 +210,7 @@ export async function assetsGenerate(options) {
201
210
  const remaining = result?.aiUsagePolicy?.energyRemaining;
202
211
  if (Number.isFinite(Number(remaining))) {
203
212
  console.log(
204
- `Battery remaining: ${formatBatteryPercent(Number(remaining), result?.aiUsagePolicy?.fullBatteryUnits)}`,
213
+ `Battery remaining: ${formatBatteryPercent(Number(remaining), result?.aiUsagePolicy?.baseEnergyUnitsPerDay)}`,
205
214
  );
206
215
  }
207
216
  console.log(
package/lib/commands.js CHANGED
@@ -77,6 +77,7 @@ import {
77
77
  isIndexHtmlPath,
78
78
  isReadOnlyReferenceMetadata,
79
79
  removeLocalProjectFilesNotIn,
80
+ stashLocalProjectFilesBeforePull,
80
81
  resolveProjectDirForSave,
81
82
  writeAgentInstructions,
82
83
  writeInstructionFiles,
@@ -331,17 +332,41 @@ export async function updateBranchFromMain(options) {
331
332
  }
332
333
  let dir = null;
333
334
  let projectFiles = null;
335
+ let baseFilesHash = null;
334
336
  if (Number(localProject?.metadata?.buildId || 0) === contributionBuildId) {
335
337
  dir = resolveProjectDirForSave({ options, localProject });
336
338
  projectFiles = await collectProjectFiles(dir);
339
+ baseFilesHash =
340
+ typeof localProject?.metadata?.filesHash === "string" &&
341
+ localProject.metadata.filesHash.trim()
342
+ ? localProject.metadata.filesHash.trim()
343
+ : null;
344
+ if (!baseFilesHash) {
345
+ throw new Error(
346
+ "This branch workspace has no verified server file base. Run `lumine pull` before `lumine update-from-main` so local edits cannot overwrite newer branch work.",
347
+ );
348
+ }
349
+ }
350
+ let result;
351
+ try {
352
+ result = await requestJson({
353
+ method: "POST",
354
+ url: `${options.apiUrl}/build/${rootBuildId}/contributions/${contributionBuildId}/update-from-main`,
355
+ authToken: auth.token,
356
+ body: projectFiles ? { projectFiles, baseFilesHash } : {},
357
+ timeoutMs: options.timeoutMs,
358
+ });
359
+ } catch (error) {
360
+ if (error?.data?.code === "build_project_files_stale") {
361
+ throw new Error(
362
+ [
363
+ "Update from main rejected: this branch changed on the server after the workspace was loaded.",
364
+ "Run `lumine pull` to load the canonical branch first, then reapply the backed-up local edits and retry.",
365
+ ].join("\n"),
366
+ );
367
+ }
368
+ throw error;
337
369
  }
338
- const result = await requestJson({
339
- method: "POST",
340
- url: `${options.apiUrl}/build/${rootBuildId}/contributions/${contributionBuildId}/update-from-main`,
341
- authToken: auth.token,
342
- body: projectFiles ? { projectFiles } : {},
343
- timeoutMs: options.timeoutMs,
344
- });
345
370
  const mergedFiles = Array.isArray(result.projectFiles)
346
371
  ? result.projectFiles
347
372
  : [];
@@ -353,15 +378,28 @@ export async function updateBranchFromMain(options) {
353
378
  auth,
354
379
  buildId: contributionBuildId,
355
380
  }).catch(() => null);
356
- if (refreshed) {
357
- await writeProjectMetadata({
358
- dir,
359
- options,
360
- build: refreshed,
361
- manifest: localProject?.metadata?.manifest || null,
362
- pulledAt: new Date().toISOString(),
363
- });
364
- }
381
+ const returnedContribution =
382
+ Number(result?.contribution?.id || 0) === contributionBuildId
383
+ ? result.contribution
384
+ : null;
385
+ // The sync response has already committed these files and their hash. A
386
+ // transient follow-up metadata failure must not leave the workspace with
387
+ // its pre-merge base hash, or its next legitimate save will be rejected.
388
+ // Preserve CLI permission fields from the confirmed pre-sync load when
389
+ // falling back to the canonical contribution returned by the mutation.
390
+ const metadataBuild = refreshed || {
391
+ ...build,
392
+ ...(returnedContribution || {}),
393
+ id: contributionBuildId,
394
+ };
395
+ await writeProjectMetadata({
396
+ dir,
397
+ options,
398
+ build: metadataBuild,
399
+ manifest: localProject?.metadata?.manifest || null,
400
+ pulledAt: new Date().toISOString(),
401
+ filesHash: typeof result.filesHash === "string" ? result.filesHash : null,
402
+ });
365
403
  }
366
404
  printUpdateFromMainResult({ result, build, dir, mergedFiles });
367
405
  }
@@ -470,13 +508,37 @@ export async function save(options) {
470
508
  const dir = resolveProjectDirForSave({ options, localProject });
471
509
  const files = await collectProjectFiles(dir);
472
510
  assertProjectFilesWithinLimits(files);
473
- const result = await saveProjectFiles({
474
- options,
475
- auth,
476
- buildId,
477
- files,
478
- summary: options.summary || DEFAULT_SAVE_SUMMARY,
479
- });
511
+ // Only claim a base snapshot when this workspace was pulled from the same
512
+ // build we are saving to; otherwise the save stays unguarded (old behavior).
513
+ const baseFilesHash =
514
+ !options.force &&
515
+ Number(localProject?.metadata?.buildId || 0) === Number(buildId) &&
516
+ typeof localProject?.metadata?.filesHash === "string"
517
+ ? localProject.metadata.filesHash
518
+ : null;
519
+ let result;
520
+ try {
521
+ result = await saveProjectFiles({
522
+ options,
523
+ auth,
524
+ buildId,
525
+ files,
526
+ summary: options.summary || DEFAULT_SAVE_SUMMARY,
527
+ baseFilesHash,
528
+ });
529
+ } catch (error) {
530
+ if (error?.data?.code === "build_project_files_stale") {
531
+ throw new Error(
532
+ [
533
+ "Save rejected: the project changed on the server since this workspace was pulled",
534
+ "(for example a merged branch or a save from another session).",
535
+ "Run `lumine pull` to sync the canonical server files first,",
536
+ "or re-run with --force to overwrite the server's current files anyway.",
537
+ ].join("\n"),
538
+ );
539
+ }
540
+ throw error;
541
+ }
480
542
  build = result.build ||
481
543
  build ||
482
544
  (await loadBuildMetadata({ options, auth, buildId }).catch(() => null)) || {
@@ -490,6 +552,7 @@ export async function save(options) {
490
552
  build,
491
553
  manifest: result.projectManifest || null,
492
554
  lastSavedAt: new Date().toISOString(),
555
+ filesHash: typeof result.filesHash === "string" ? result.filesHash : null,
493
556
  });
494
557
  printSaveResult({ result, build, dir, files });
495
558
 
@@ -663,11 +726,15 @@ export async function loadTargetBuildMetadata({ options, auth }) {
663
726
  return await loadBuildMetadata({ options, auth, buildId });
664
727
  }
665
728
 
666
- export async function resolveBuildForSave({ options, auth, buildId, localProject }) {
729
+ export async function resolveBuildForSave({
730
+ options,
731
+ auth,
732
+ buildId,
733
+ localProject,
734
+ }) {
667
735
  const localBuild = localProject?.metadata?.build;
668
736
  const localBuildId =
669
- Number(localBuild?.id || 0) ||
670
- Number(localProject?.metadata?.buildId || 0);
737
+ Number(localBuild?.id || 0) || Number(localProject?.metadata?.buildId || 0);
671
738
  const build =
672
739
  localBuild && localBuildId === Number(buildId)
673
740
  ? { ...localBuild, id: buildId }
@@ -722,7 +789,11 @@ export function isCollaboratorMainBuild(build) {
722
789
  );
723
790
  }
724
791
 
725
- export async function ensureDefaultContributionBranch({ options, auth, build }) {
792
+ export async function ensureDefaultContributionBranch({
793
+ options,
794
+ auth,
795
+ build,
796
+ }) {
726
797
  const rootBuildId =
727
798
  Number(build?.contributionRootBuildId || 0) || Number(build?.id || 0);
728
799
  if (!rootBuildId) {
@@ -817,7 +888,29 @@ export async function pullBuildFiles({ options, auth, buildId }) {
817
888
  const build = result.build || { id: buildId, title: `Build ${buildId}` };
818
889
  const files = Array.isArray(result.projectFiles) ? result.projectFiles : [];
819
890
  const dir = path.resolve(options.dir || defaultWorkspaceDir(build));
891
+ // Preserve local work BEFORE server files land: pull is the recommended
892
+ // stale-save recovery step, and the edits that triggered the rejection are
893
+ // usually in files the server snapshot also contains — writing first would
894
+ // destroy them. Extras (paths not in the snapshot) are moved out so the
895
+ // pulled hash's claim that this workspace matches the server stays true;
896
+ // modified tracked files are copied so the pre-pull content stays
897
+ // recoverable after the overwrite.
898
+ const { movedPaths, backedUpPaths } = await stashLocalProjectFilesBeforePull({
899
+ dir,
900
+ files,
901
+ });
820
902
  await writeProjectFiles({ dir, files });
903
+ const stashDirLabel = path.join(PROJECT_METADATA_DIR, "removed");
904
+ if (movedPaths.length > 0) {
905
+ console.log(
906
+ `Moved ${movedPaths.length} local file${movedPaths.length === 1 ? "" : "s"} not in the server project to ${stashDirLabel}/: ${movedPaths.join(", ")}`,
907
+ );
908
+ }
909
+ if (backedUpPaths.length > 0) {
910
+ console.log(
911
+ `Backed up ${backedUpPaths.length} locally modified file${backedUpPaths.length === 1 ? "" : "s"} to ${stashDirLabel}/ before overwriting with the server version: ${backedUpPaths.join(", ")}`,
912
+ );
913
+ }
821
914
  await writeAgentInstructions({ dir });
822
915
  await writeSdkReference({ dir });
823
916
  await writeProjectMetadata({
@@ -826,6 +919,7 @@ export async function pullBuildFiles({ options, auth, buildId }) {
826
919
  build,
827
920
  manifest: result.projectManifest || null,
828
921
  pulledAt: new Date().toISOString(),
922
+ filesHash: typeof result.filesHash === "string" ? result.filesHash : null,
829
923
  });
830
924
  // Best-effort asset manifest so terminal agents know what media already
831
925
  // exists (the workspace UI gives Lumine the same context). Never fail the
@@ -916,7 +1010,10 @@ export async function pullMainBuildFiles({ options, auth, build }) {
916
1010
  buildId: rootBuildId,
917
1011
  includeContent: true,
918
1012
  });
919
- const rootBuild = result.build || { id: rootBuildId, title: `Build ${rootBuildId}` };
1013
+ const rootBuild = result.build || {
1014
+ id: rootBuildId,
1015
+ title: `Build ${rootBuildId}`,
1016
+ };
920
1017
  const files = Array.isArray(result.projectFiles) ? result.projectFiles : [];
921
1018
  // Never nest a main checkout inside another Lumine workspace (a later save
922
1019
  // there would upload it as project files) — default to a sibling instead.
@@ -933,7 +1030,10 @@ export async function pullMainBuildFiles({ options, auth, build }) {
933
1030
  (enclosingIsThisMain
934
1031
  ? enclosing.rootDir
935
1032
  : enclosing
936
- ? path.join(path.dirname(enclosing.rootDir), defaultMainCheckoutDir(rootBuild))
1033
+ ? path.join(
1034
+ path.dirname(enclosing.rootDir),
1035
+ defaultMainCheckoutDir(rootBuild),
1036
+ )
937
1037
  : defaultMainCheckoutDir(rootBuild)),
938
1038
  );
939
1039
  await writeProjectFiles({ dir, files });
@@ -954,7 +1054,9 @@ export async function pullMainBuildFiles({ options, auth, build }) {
954
1054
  pulledAt: new Date().toISOString(),
955
1055
  });
956
1056
  console.log(`Pulled main for ${formatBuildTitle(rootBuild)} (read-only).`);
957
- console.log(`Pulled ${files.length} file${files.length === 1 ? "" : "s"} to ${dir}`);
1057
+ console.log(
1058
+ `Pulled ${files.length} file${files.length === 1 ? "" : "s"} to ${dir}`,
1059
+ );
958
1060
  console.log(
959
1061
  `Edits belong on your branch: \`lumine pull ${rootBuildId}\`, and \`lumine update-from-main\` brings main's changes into it.`,
960
1062
  );
@@ -1096,9 +1198,7 @@ export function printVersionList({ result }) {
1096
1198
  console.log(`No previous saves found for ${formatBuildTitle(build)}.`);
1097
1199
  return;
1098
1200
  }
1099
- console.log(
1100
- `Previous saves for ${formatBuildTitle(build)} (newest first):`,
1101
- );
1201
+ console.log(`Previous saves for ${formatBuildTitle(build)} (newest first):`);
1102
1202
  for (const version of versions) {
1103
1203
  const author =
1104
1204
  version.createdByUsername ||
@@ -1321,7 +1421,9 @@ export function printPullResult(result) {
1321
1421
  return;
1322
1422
  }
1323
1423
  if (result.fileCount === 0) {
1324
- console.log("No project files yet. Create /index.html before your first save.");
1424
+ console.log(
1425
+ "No project files yet. Create /index.html before your first save.",
1426
+ );
1325
1427
  }
1326
1428
  console.log('Codex: codex "Read AGENTS.md, then make the requested change."');
1327
1429
  console.log(
@@ -1403,7 +1505,9 @@ export function printContributionDiff({ result, build }) {
1403
1505
  ? result.diff.changedFiles
1404
1506
  : [];
1405
1507
  const branchNumber = Number(build.contributionBranchNumber || 0) || 0;
1406
- const branchLabel = branchNumber ? `branch ${branchNumber}` : `branch #${build.id}`;
1508
+ const branchLabel = branchNumber
1509
+ ? `branch ${branchNumber}`
1510
+ : `branch #${build.id}`;
1407
1511
  console.log(`Diff for ${branchLabel}:`);
1408
1512
  console.log(
1409
1513
  `- total=${summary.total ?? files.length} added=${summary.added ?? 0} ` +
@@ -1470,14 +1574,18 @@ export function printContributionActionResult({
1470
1574
  const projectFiles = Array.isArray(result.projectFiles)
1471
1575
  ? result.projectFiles
1472
1576
  : [];
1473
- console.log(`${action} branch #${contributionBuildId} for Build #${rootBuildId}.`);
1577
+ console.log(
1578
+ `${action} branch #${contributionBuildId} for Build #${rootBuildId}.`,
1579
+ );
1474
1580
  if (projectFiles.length > 0) {
1475
1581
  console.log(
1476
1582
  `Main now has ${projectFiles.length} project file${projectFiles.length === 1 ? "" : "s"}.`,
1477
1583
  );
1478
1584
  }
1479
1585
  if (result.mergeConflictsWritten || result.conflicts?.length > 0) {
1480
- console.log("Merge wrote conflict markers. Resolve them in the Build workspace.");
1586
+ console.log(
1587
+ "Merge wrote conflict markers. Resolve them in the Build workspace.",
1588
+ );
1481
1589
  }
1482
1590
  console.log(`Main workspace: ${rootBuildId}`);
1483
1591
  }
@@ -1502,6 +1610,7 @@ export function parseArgs(args) {
1502
1610
  "noDescription",
1503
1611
  "publish",
1504
1612
  "save",
1613
+ "force",
1505
1614
  "noUpdateCheck",
1506
1615
  "allowWrite",
1507
1616
  "main",
@@ -1552,13 +1661,11 @@ export function parseArgs(args) {
1552
1661
  target: raw.url || raw.target || positional[0] || "",
1553
1662
  title:
1554
1663
  String(
1555
- raw.title ||
1556
- (command === "new" ? positional.join(" ") : ""),
1664
+ raw.title || (command === "new" ? positional.join(" ") : ""),
1557
1665
  ).trim() || "",
1558
- description:
1559
- Object.prototype.hasOwnProperty.call(raw, "description")
1560
- ? String(raw.description || "").trim()
1561
- : null,
1666
+ description: Object.prototype.hasOwnProperty.call(raw, "description")
1667
+ ? String(raw.description || "").trim()
1668
+ : null,
1562
1669
  descriptionProvided: Object.prototype.hasOwnProperty.call(
1563
1670
  raw,
1564
1671
  "description",
@@ -1608,6 +1715,7 @@ export function parseArgs(args) {
1608
1715
  : 0,
1609
1716
  summary: raw.summary ? String(raw.summary) : "",
1610
1717
  publish: parseBoolean(raw.publish, false),
1718
+ force: parseBoolean(raw.force, false),
1611
1719
  saveFirst: parseBoolean(raw.save, false),
1612
1720
  limit: Math.min(
1613
1721
  Math.max(
@@ -1709,7 +1817,9 @@ export async function resolveNewBuildTitle(options) {
1709
1817
  const title = String(options.title || "").trim();
1710
1818
  if (title) return title;
1711
1819
  if (!input.isTTY || !output.isTTY) {
1712
- throw new Error('Pass a title: `lumine new "My Build"` or `lumine new --title "My Build"`.');
1820
+ throw new Error(
1821
+ 'Pass a title: `lumine new "My Build"` or `lumine new --title "My Build"`.',
1822
+ );
1713
1823
  }
1714
1824
  const rl = readline.createInterface({ input, output });
1715
1825
  try {
@@ -1739,7 +1849,11 @@ export async function resolveNewBuildDescription(options) {
1739
1849
  }
1740
1850
  }
1741
1851
 
1742
- export async function resolveBuildReferenceBuildId({ options, auth, reference }) {
1852
+ export async function resolveBuildReferenceBuildId({
1853
+ options,
1854
+ auth,
1855
+ reference,
1856
+ }) {
1743
1857
  if (reference.branchNumber > 0) {
1744
1858
  const build = await resolveBranchBuild({
1745
1859
  options,
@@ -1840,6 +1954,7 @@ Options:
1840
1954
  --description <text> Optional New Build description
1841
1955
  --no-description Skip the New Build description prompt
1842
1956
  --summary <text> Save summary
1957
+ --force Save even if the server files changed since this workspace was pulled
1843
1958
  --search <text> Search public open-source Builds
1844
1959
  --sort <sort> Sort open-source Builds: forks, popular, recent
1845
1960
  --publish Publish after saving
package/lib/thumbnail.js CHANGED
@@ -324,7 +324,7 @@ export async function thumbnailGenerate(options) {
324
324
  const remaining = result?.aiUsagePolicy?.energyRemaining;
325
325
  if (Number.isFinite(Number(remaining))) {
326
326
  console.log(
327
- `Battery remaining: ${formatBatteryPercent(Number(remaining), result?.aiUsagePolicy?.fullBatteryUnits)}`,
327
+ `Battery remaining: ${formatBatteryPercent(Number(remaining), result?.aiUsagePolicy?.baseEnergyUnitsPerDay)}`,
328
328
  );
329
329
  }
330
330
  }
package/lib/workspace.js CHANGED
@@ -105,11 +105,24 @@ export async function collectWorkspaceTextFilesLenient(dir) {
105
105
  return files;
106
106
  }
107
107
 
108
- export async function collectProjectFilesFromDir({ root, dir, files, lenient = false }) {
108
+ async function walkWorkspaceProjectEntries({
109
+ root,
110
+ dir,
111
+ onEntry,
112
+ onDirectory,
113
+ allowMissingRoot = false,
114
+ }) {
109
115
  let entries = [];
110
116
  try {
111
117
  entries = await fs.readdir(dir, { withFileTypes: true });
112
118
  } catch (error) {
119
+ if (
120
+ allowMissingRoot &&
121
+ error.code === "ENOENT" &&
122
+ path.resolve(dir) === path.resolve(root)
123
+ ) {
124
+ return;
125
+ }
113
126
  if (error.code === "ENOENT") {
114
127
  throw new Error(`Project directory does not exist: ${dir}`);
115
128
  }
@@ -117,10 +130,10 @@ export async function collectProjectFilesFromDir({ root, dir, files, lenient = f
117
130
  }
118
131
 
119
132
  for (const entry of entries) {
120
- if (entry.isDirectory() && EXCLUDED_UPLOAD_DIRS.has(entry.name)) continue;
121
- if (entry.isFile() && EXCLUDED_UPLOAD_FILES.has(entry.name)) continue;
122
133
  const fullPath = path.join(dir, entry.name);
123
134
  if (entry.isDirectory()) {
135
+ await onDirectory?.({ entry, fullPath });
136
+ if (EXCLUDED_UPLOAD_DIRS.has(entry.name)) continue;
124
137
  // A nested Lumine checkout (its own .twinkle metadata) is another
125
138
  // project that happens to sit here — never upload it as project files.
126
139
  if (await isNestedLumineCheckout(fullPath)) {
@@ -129,34 +142,85 @@ export async function collectProjectFilesFromDir({ root, dir, files, lenient = f
129
142
  );
130
143
  continue;
131
144
  }
132
- await collectProjectFilesFromDir({ root, dir: fullPath, files, lenient });
133
- continue;
134
- }
135
- if (!entry.isFile()) continue;
136
- const buffer = await fs.readFile(fullPath);
137
- const encodingIssue = detectProjectFileEncodingIssue(buffer);
138
- if (encodingIssue) {
139
- if (lenient) continue;
140
- const relativePath = localFilePathToProjectPath({
145
+ await walkWorkspaceProjectEntries({
141
146
  root,
142
- filePath: fullPath,
147
+ dir: fullPath,
148
+ onEntry,
149
+ onDirectory,
143
150
  });
144
- if (encodingIssue === "utf16") {
151
+ continue;
152
+ }
153
+ if (entry.isFile() && EXCLUDED_UPLOAD_FILES.has(entry.name)) continue;
154
+ await onEntry({ entry, fullPath });
155
+ }
156
+ }
157
+
158
+ export async function collectProjectFilesFromDir({
159
+ root,
160
+ dir,
161
+ files,
162
+ lenient = false,
163
+ }) {
164
+ await walkWorkspaceProjectEntries({
165
+ root,
166
+ dir,
167
+ async onEntry({ entry, fullPath }) {
168
+ if (!entry.isFile()) return;
169
+ const buffer = await fs.readFile(fullPath);
170
+ const encodingIssue = detectProjectFileEncodingIssue(buffer);
171
+ if (encodingIssue) {
172
+ if (lenient) return;
173
+ const relativePath = localFilePathToProjectPath({
174
+ root,
175
+ filePath: fullPath,
176
+ });
177
+ if (encodingIssue === "utf16") {
178
+ throw new Error(
179
+ `Cannot save ${relativePath}: it is UTF-16 encoded. Twinkle project files must be UTF-8 text — re-save the file as UTF-8 and retry.`,
180
+ );
181
+ }
145
182
  throw new Error(
146
- `Cannot save ${relativePath}: it is UTF-16 encoded. Twinkle project files must be UTF-8 text — re-save the file as UTF-8 and retry.`,
183
+ `Cannot save binary file ${relativePath}. Twinkle project files must be text files. ` +
184
+ `Upload media as a build asset instead: move the file out of this workspace, run ` +
185
+ "`lumine assets upload <path-to-file>`, and reference the printed URL from your code.",
147
186
  );
148
187
  }
149
- throw new Error(
150
- `Cannot save binary file ${relativePath}. Twinkle project files must be text files. ` +
151
- `Upload media as a build asset instead: move the file out of this workspace, run ` +
152
- "`lumine assets upload <path-to-file>`, and reference the printed URL from your code.",
188
+ files.push({
189
+ path: localFilePathToProjectPath({ root, filePath: fullPath }),
190
+ content: buffer.toString("utf8"),
191
+ });
192
+ },
193
+ });
194
+ }
195
+
196
+ // Pull preservation needs a lossless inventory, not the upload-oriented text
197
+ // collector above. Binary/UTF-16 files are still local work, and special
198
+ // entries such as symlinks must be relocated before writeProjectFiles can
199
+ // follow or replace their server-owned paths. Any scan/read error other than a
200
+ // missing fresh-pull root aborts the pull before local state is touched.
201
+ async function collectWorkspaceEntriesBeforePull(dir) {
202
+ const root = path.resolve(dir);
203
+ const entries = [];
204
+ const directories = [];
205
+ await walkWorkspaceProjectEntries({
206
+ root,
207
+ dir: root,
208
+ allowMissingRoot: true,
209
+ onDirectory({ fullPath }) {
210
+ directories.push(
211
+ localFilePathToProjectPath({ root, filePath: fullPath }),
153
212
  );
154
- }
155
- files.push({
156
- path: localFilePathToProjectPath({ root, filePath: fullPath }),
157
- content: buffer.toString("utf8"),
158
- });
159
- }
213
+ },
214
+ async onEntry({ entry, fullPath }) {
215
+ const isRegularFile = entry.isFile();
216
+ entries.push({
217
+ path: localFilePathToProjectPath({ root, filePath: fullPath }),
218
+ isRegularFile,
219
+ content: isRegularFile ? await fs.readFile(fullPath) : null,
220
+ });
221
+ },
222
+ });
223
+ return { entries, directories };
160
224
  }
161
225
 
162
226
  // UTF-16 BOMs get their own error (the file is text, just mis-encoded); NUL
@@ -180,7 +244,9 @@ export function detectProjectFileEncodingIssue(buffer) {
180
244
 
181
245
  export async function isNestedLumineCheckout(dir) {
182
246
  try {
183
- await fs.access(path.join(dir, PROJECT_METADATA_DIR, PROJECT_METADATA_FILE));
247
+ await fs.access(
248
+ path.join(dir, PROJECT_METADATA_DIR, PROJECT_METADATA_FILE),
249
+ );
184
250
  return true;
185
251
  } catch {
186
252
  return false;
@@ -268,7 +334,10 @@ export function countEffectiveLines(value) {
268
334
  .reduce(
269
335
  (total, line) =>
270
336
  total +
271
- Math.max(1, Math.ceil(line.length / PROJECT_EFFECTIVE_LINE_MAX_COLUMNS)),
337
+ Math.max(
338
+ 1,
339
+ Math.ceil(line.length / PROJECT_EFFECTIVE_LINE_MAX_COLUMNS),
340
+ ),
272
341
  0,
273
342
  );
274
343
  }
@@ -296,7 +365,8 @@ export function collectProjectLimitFindings(files) {
296
365
  }
297
366
  }
298
367
  const totalBytes = files.reduce(
299
- (total, file) => total + Buffer.byteLength(String(file.content || ""), "utf8"),
368
+ (total, file) =>
369
+ total + Buffer.byteLength(String(file.content || ""), "utf8"),
300
370
  0,
301
371
  );
302
372
  if (totalBytes > PROJECT_MAX_TOTAL_BYTES_DEFAULT) {
@@ -356,6 +426,129 @@ export async function removeLocalProjectFilesNotIn({ dir, files }) {
356
426
  }
357
427
  }
358
428
 
429
+ // Non-destructive local-work preservation for `lumine pull` into an existing
430
+ // workspace. Pull is the recommended recovery step after a stale-save
431
+ // rejection, so it must never destroy unsaved local work — and that work is
432
+ // usually edits to files the server snapshot ALSO contains, which is why this
433
+ // must run BEFORE the server files are written over them:
434
+ // - local files absent from the server snapshot may be server-side deletions
435
+ // or brand-new unsaved work: MOVE them into .twinkle/removed/<timestamp>/
436
+ // (excluded from uploads) so a leftover file cannot pass the stale guard on
437
+ // the next save and silently resurrect itself
438
+ // - local files whose content differs from the incoming server copy are about
439
+ // to be overwritten: COPY them into the same stash so the pre-pull edits
440
+ // remain recoverable
441
+ // Returns { movedPaths, backedUpPaths } of stashed project paths.
442
+ export async function stashLocalProjectFilesBeforePull({ dir, files }) {
443
+ const serverContentByPath = new Map(
444
+ files.map((file) => [String(file.path), String(file.content || "")]),
445
+ );
446
+ const {
447
+ entries: localFiles,
448
+ directories: localDirectoryPaths,
449
+ } = await collectWorkspaceEntriesBeforePull(dir);
450
+ const movedPaths = new Set();
451
+ const backedUpPaths = new Set();
452
+ let stashRoot = null;
453
+ const ensureStashRoot = () => {
454
+ if (!stashRoot) {
455
+ const stampedDirName = new Date().toISOString().replace(/[:.]/g, "-");
456
+ stashRoot = path.join(
457
+ dir,
458
+ PROJECT_METADATA_DIR,
459
+ "removed",
460
+ stampedDirName,
461
+ );
462
+ }
463
+ return stashRoot;
464
+ };
465
+ const stashPathFor = (projectPath) =>
466
+ path.join(
467
+ ensureStashRoot(),
468
+ ...projectPath.split("/").filter(Boolean),
469
+ );
470
+
471
+ // Resolve directory -> file transitions before processing individual local
472
+ // entries. Moving descendants first would leave their empty parent behind,
473
+ // and writeProjectFiles would then fail with EISDIR when the server snapshot
474
+ // writes a regular file at that path. Moving the directory as one unit also
475
+ // preserves excluded or nested-workspace content that the inventory skips.
476
+ const stashedDirectoryPaths = [];
477
+ const localDirectoryPathSet = new Set(localDirectoryPaths);
478
+ const incomingProjectPaths = [...serverContentByPath.keys()].sort(
479
+ (a, b) => a.split("/").length - b.split("/").length,
480
+ );
481
+ for (const projectPath of incomingProjectPaths) {
482
+ if (
483
+ stashedDirectoryPaths.some((directoryPath) =>
484
+ projectPath.startsWith(`${directoryPath}/`),
485
+ )
486
+ ) {
487
+ continue;
488
+ }
489
+ if (!localDirectoryPathSet.has(projectPath)) continue;
490
+
491
+ const sourcePath = resolveLocalProjectFilePath({
492
+ rootDir: dir,
493
+ projectPath,
494
+ });
495
+ const stashPath = stashPathFor(projectPath);
496
+ await fs.mkdir(path.dirname(stashPath), { recursive: true });
497
+ await fs.rename(sourcePath, stashPath);
498
+ stashedDirectoryPaths.push(projectPath);
499
+ for (const localFile of localFiles) {
500
+ if (!localFile.path.startsWith(`${projectPath}/`)) continue;
501
+ if (serverContentByPath.has(localFile.path)) {
502
+ backedUpPaths.add(localFile.path);
503
+ } else {
504
+ movedPaths.add(localFile.path);
505
+ }
506
+ }
507
+ }
508
+
509
+ for (const file of localFiles) {
510
+ if (
511
+ stashedDirectoryPaths.some((directoryPath) =>
512
+ file.path.startsWith(`${directoryPath}/`),
513
+ )
514
+ ) {
515
+ continue;
516
+ }
517
+ const serverContent = serverContentByPath.get(file.path);
518
+ const isTracked = serverContent !== undefined;
519
+ if (
520
+ isTracked &&
521
+ file.isRegularFile &&
522
+ file.content.equals(Buffer.from(serverContent, "utf8"))
523
+ ) {
524
+ continue;
525
+ }
526
+ const sourcePath = resolveLocalProjectFilePath({
527
+ rootDir: dir,
528
+ projectPath: file.path,
529
+ });
530
+ const stashPath = stashPathFor(file.path);
531
+ await fs.mkdir(path.dirname(stashPath), { recursive: true });
532
+ if (isTracked && file.isRegularFile) {
533
+ await fs.copyFile(sourcePath, stashPath);
534
+ backedUpPaths.add(file.path);
535
+ } else {
536
+ // Non-regular entries must move even when tracked: leaving a symlink at
537
+ // the server path would let writeProjectFiles overwrite its target.
538
+ await fs.rename(sourcePath, stashPath);
539
+ if (isTracked) {
540
+ backedUpPaths.add(file.path);
541
+ } else {
542
+ movedPaths.add(file.path);
543
+ }
544
+ }
545
+ }
546
+ return {
547
+ movedPaths: [...movedPaths],
548
+ backedUpPaths: [...backedUpPaths],
549
+ };
550
+ }
551
+
359
552
  export async function writeProjectMetadata({
360
553
  dir,
361
554
  options,
@@ -363,6 +556,7 @@ export async function writeProjectMetadata({
363
556
  manifest,
364
557
  pulledAt,
365
558
  lastSavedAt,
559
+ filesHash,
366
560
  }) {
367
561
  const metadataDir = path.join(dir, PROJECT_METADATA_DIR);
368
562
  await fs.mkdir(metadataDir, { recursive: true });
@@ -401,6 +595,10 @@ export async function writeProjectMetadata({
401
595
  manifest,
402
596
  pulledAt,
403
597
  lastSavedAt,
598
+ // Server-issued hash of the project files this workspace is based on.
599
+ // `lumine save` sends it back so a stale workspace cannot silently
600
+ // rewind newer server state; null means "unknown" (unguarded save).
601
+ filesHash: typeof filesHash === "string" ? filesHash : null,
404
602
  },
405
603
  null,
406
604
  2,
@@ -432,11 +630,14 @@ export async function writeReferenceMetadata({
432
630
  readOnly: true,
433
631
  forkable: true,
434
632
  sourceBuildId,
435
- sourceAppUrl: sourceBuildId ? `${options.siteUrl}/app/${sourceBuildId}` : null,
633
+ sourceAppUrl: sourceBuildId
634
+ ? `${options.siteUrl}/app/${sourceBuildId}`
635
+ : null,
436
636
  },
437
637
  build: {
438
638
  id: sourceBuildId,
439
- title: build?.title || (sourceBuildId ? `Build ${sourceBuildId}` : ""),
639
+ title:
640
+ build?.title || (sourceBuildId ? `Build ${sourceBuildId}` : ""),
440
641
  role: "reference",
441
642
  ownerUsername: build?.ownerUsername || null,
442
643
  collaborationMode: build?.collaborationMode || "open_source",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stage5/lumine",
3
- "version": "0.2.11",
3
+ "version": "0.2.13",
4
4
  "description": "Command line tools for launching Lumine builds on Twinkle.",
5
5
  "type": "module",
6
6
  "bin": {