@stage5/lumine 0.2.12 → 0.2.14

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/README.md CHANGED
@@ -40,7 +40,14 @@ to pull source files into a read-only reference folder, or
40
40
  `lumine fork <build-url-or-id>` to create your own editable fork and pull it
41
41
  locally.
42
42
 
43
- After editing pulled files, run `lumine save` from that folder. The CLI saves
43
+ After editing pulled files, run `lumine save` from that folder. Workspaces record
44
+ a `filesHash` in `.twinkle/lumine-project.json` when you pull or save; the next
45
+ save sends that hash so the server can reject a stale checkout instead of
46
+ silently overwriting newer files. Saves from a folder with no `filesHash` are
47
+ refused unless you pass `--force` (intentional overwrite). Prefer
48
+ `lumine pull` to resync before saving if the folder might be out of date.
49
+
50
+ The CLI saves
44
51
  through Twinkle's normal workspace project-file route, creates a project artifact
45
52
  version, records the same save metadata, and marks public builds as having
46
53
  unpublished changes. For projects you own, run `lumine launch` to publish the
package/lib/api.js CHANGED
@@ -132,7 +132,15 @@ 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
+ force = false,
143
+ }) {
136
144
  return await requestJson({
137
145
  method: "PUT",
138
146
  url: `${options.apiUrl}/build/${buildId}/project-files`,
@@ -141,6 +149,13 @@ export async function saveProjectFiles({ options, auth, buildId, files, summary
141
149
  files,
142
150
  createVersion: true,
143
151
  summary,
152
+ // Proves this save is based on the snapshot we pulled so the server can
153
+ // reject it instead of silently rewinding newer state (e.g. a branch
154
+ // merged into main after our pull).
155
+ ...(baseFilesHash ? { baseFilesHash } : {}),
156
+ // Intentional overwrite of a non-empty project without a base claim.
157
+ // Required when the workspace has no filesHash (old/stale checkout).
158
+ ...(force ? { force: true } : {}),
144
159
  },
145
160
  timeoutMs: options.timeoutMs,
146
161
  });
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,72 @@ 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. Missing filesHash used to mean "unguarded save",
513
+ // which let stale checkouts silently rewind newer server versions — refuse
514
+ // that path unless --force is explicit.
515
+ const metadataBuildId = Number(localProject?.metadata?.buildId || 0);
516
+ const metadataFilesHash =
517
+ typeof localProject?.metadata?.filesHash === "string" &&
518
+ localProject.metadata.filesHash.trim()
519
+ ? localProject.metadata.filesHash.trim()
520
+ : null;
521
+ const baseFilesHash =
522
+ !options.force && metadataBuildId === Number(buildId) && metadataFilesHash
523
+ ? metadataFilesHash
524
+ : null;
525
+ if (!options.force && !baseFilesHash) {
526
+ throw new Error(
527
+ [
528
+ "Save refused: this workspace has no filesHash (server snapshot token).",
529
+ "That usually means an old or stale checkout that never recorded what",
530
+ "it was based on — saving it would overwrite the server without a guard.",
531
+ "Run `lumine pull` to sync with the server (local edits are preserved),",
532
+ "then save again. To intentionally overwrite the server with these",
533
+ "local files, re-run with --force.",
534
+ ].join("\n"),
535
+ );
536
+ }
537
+ if (options.force && !baseFilesHash) {
538
+ console.error(
539
+ "lumine: warning — saving with --force and no filesHash; this overwrites the server project with local files and skips the stale-workspace guard.",
540
+ );
541
+ }
542
+ let result;
543
+ try {
544
+ result = await saveProjectFiles({
545
+ options,
546
+ auth,
547
+ buildId,
548
+ files,
549
+ summary: options.summary || DEFAULT_SAVE_SUMMARY,
550
+ baseFilesHash,
551
+ force: Boolean(options.force),
552
+ });
553
+ } catch (error) {
554
+ if (error?.data?.code === "build_project_files_stale") {
555
+ throw new Error(
556
+ [
557
+ "Save rejected: the project changed on the server since this workspace was pulled",
558
+ "(for example a merged branch or a save from another session).",
559
+ "Run `lumine pull` to sync the canonical server files first,",
560
+ "or re-run with --force to overwrite the server's current files anyway.",
561
+ ].join("\n"),
562
+ );
563
+ }
564
+ if (error?.data?.code === "build_project_files_base_required") {
565
+ throw new Error(
566
+ [
567
+ "Save rejected: the server requires a filesHash base for this project",
568
+ "(non-empty projects cannot be saved from a workspace that never",
569
+ "recorded its server snapshot).",
570
+ "Run `lumine pull` to establish a base, then save again,",
571
+ "or re-run with --force to overwrite deliberately.",
572
+ ].join("\n"),
573
+ );
574
+ }
575
+ throw error;
576
+ }
480
577
  build = result.build ||
481
578
  build ||
482
579
  (await loadBuildMetadata({ options, auth, buildId }).catch(() => null)) || {
@@ -490,6 +587,7 @@ export async function save(options) {
490
587
  build,
491
588
  manifest: result.projectManifest || null,
492
589
  lastSavedAt: new Date().toISOString(),
590
+ filesHash: typeof result.filesHash === "string" ? result.filesHash : null,
493
591
  });
494
592
  printSaveResult({ result, build, dir, files });
495
593
 
@@ -663,11 +761,15 @@ export async function loadTargetBuildMetadata({ options, auth }) {
663
761
  return await loadBuildMetadata({ options, auth, buildId });
664
762
  }
665
763
 
666
- export async function resolveBuildForSave({ options, auth, buildId, localProject }) {
764
+ export async function resolveBuildForSave({
765
+ options,
766
+ auth,
767
+ buildId,
768
+ localProject,
769
+ }) {
667
770
  const localBuild = localProject?.metadata?.build;
668
771
  const localBuildId =
669
- Number(localBuild?.id || 0) ||
670
- Number(localProject?.metadata?.buildId || 0);
772
+ Number(localBuild?.id || 0) || Number(localProject?.metadata?.buildId || 0);
671
773
  const build =
672
774
  localBuild && localBuildId === Number(buildId)
673
775
  ? { ...localBuild, id: buildId }
@@ -722,7 +824,11 @@ export function isCollaboratorMainBuild(build) {
722
824
  );
723
825
  }
724
826
 
725
- export async function ensureDefaultContributionBranch({ options, auth, build }) {
827
+ export async function ensureDefaultContributionBranch({
828
+ options,
829
+ auth,
830
+ build,
831
+ }) {
726
832
  const rootBuildId =
727
833
  Number(build?.contributionRootBuildId || 0) || Number(build?.id || 0);
728
834
  if (!rootBuildId) {
@@ -817,7 +923,29 @@ export async function pullBuildFiles({ options, auth, buildId }) {
817
923
  const build = result.build || { id: buildId, title: `Build ${buildId}` };
818
924
  const files = Array.isArray(result.projectFiles) ? result.projectFiles : [];
819
925
  const dir = path.resolve(options.dir || defaultWorkspaceDir(build));
926
+ // Preserve local work BEFORE server files land: pull is the recommended
927
+ // stale-save recovery step, and the edits that triggered the rejection are
928
+ // usually in files the server snapshot also contains — writing first would
929
+ // destroy them. Extras (paths not in the snapshot) are moved out so the
930
+ // pulled hash's claim that this workspace matches the server stays true;
931
+ // modified tracked files are copied so the pre-pull content stays
932
+ // recoverable after the overwrite.
933
+ const { movedPaths, backedUpPaths } = await stashLocalProjectFilesBeforePull({
934
+ dir,
935
+ files,
936
+ });
820
937
  await writeProjectFiles({ dir, files });
938
+ const stashDirLabel = path.join(PROJECT_METADATA_DIR, "removed");
939
+ if (movedPaths.length > 0) {
940
+ console.log(
941
+ `Moved ${movedPaths.length} local file${movedPaths.length === 1 ? "" : "s"} not in the server project to ${stashDirLabel}/: ${movedPaths.join(", ")}`,
942
+ );
943
+ }
944
+ if (backedUpPaths.length > 0) {
945
+ console.log(
946
+ `Backed up ${backedUpPaths.length} locally modified file${backedUpPaths.length === 1 ? "" : "s"} to ${stashDirLabel}/ before overwriting with the server version: ${backedUpPaths.join(", ")}`,
947
+ );
948
+ }
821
949
  await writeAgentInstructions({ dir });
822
950
  await writeSdkReference({ dir });
823
951
  await writeProjectMetadata({
@@ -826,6 +954,7 @@ export async function pullBuildFiles({ options, auth, buildId }) {
826
954
  build,
827
955
  manifest: result.projectManifest || null,
828
956
  pulledAt: new Date().toISOString(),
957
+ filesHash: typeof result.filesHash === "string" ? result.filesHash : null,
829
958
  });
830
959
  // Best-effort asset manifest so terminal agents know what media already
831
960
  // exists (the workspace UI gives Lumine the same context). Never fail the
@@ -916,7 +1045,10 @@ export async function pullMainBuildFiles({ options, auth, build }) {
916
1045
  buildId: rootBuildId,
917
1046
  includeContent: true,
918
1047
  });
919
- const rootBuild = result.build || { id: rootBuildId, title: `Build ${rootBuildId}` };
1048
+ const rootBuild = result.build || {
1049
+ id: rootBuildId,
1050
+ title: `Build ${rootBuildId}`,
1051
+ };
920
1052
  const files = Array.isArray(result.projectFiles) ? result.projectFiles : [];
921
1053
  // Never nest a main checkout inside another Lumine workspace (a later save
922
1054
  // there would upload it as project files) — default to a sibling instead.
@@ -933,7 +1065,10 @@ export async function pullMainBuildFiles({ options, auth, build }) {
933
1065
  (enclosingIsThisMain
934
1066
  ? enclosing.rootDir
935
1067
  : enclosing
936
- ? path.join(path.dirname(enclosing.rootDir), defaultMainCheckoutDir(rootBuild))
1068
+ ? path.join(
1069
+ path.dirname(enclosing.rootDir),
1070
+ defaultMainCheckoutDir(rootBuild),
1071
+ )
937
1072
  : defaultMainCheckoutDir(rootBuild)),
938
1073
  );
939
1074
  await writeProjectFiles({ dir, files });
@@ -954,7 +1089,9 @@ export async function pullMainBuildFiles({ options, auth, build }) {
954
1089
  pulledAt: new Date().toISOString(),
955
1090
  });
956
1091
  console.log(`Pulled main for ${formatBuildTitle(rootBuild)} (read-only).`);
957
- console.log(`Pulled ${files.length} file${files.length === 1 ? "" : "s"} to ${dir}`);
1092
+ console.log(
1093
+ `Pulled ${files.length} file${files.length === 1 ? "" : "s"} to ${dir}`,
1094
+ );
958
1095
  console.log(
959
1096
  `Edits belong on your branch: \`lumine pull ${rootBuildId}\`, and \`lumine update-from-main\` brings main's changes into it.`,
960
1097
  );
@@ -1096,9 +1233,7 @@ export function printVersionList({ result }) {
1096
1233
  console.log(`No previous saves found for ${formatBuildTitle(build)}.`);
1097
1234
  return;
1098
1235
  }
1099
- console.log(
1100
- `Previous saves for ${formatBuildTitle(build)} (newest first):`,
1101
- );
1236
+ console.log(`Previous saves for ${formatBuildTitle(build)} (newest first):`);
1102
1237
  for (const version of versions) {
1103
1238
  const author =
1104
1239
  version.createdByUsername ||
@@ -1321,7 +1456,9 @@ export function printPullResult(result) {
1321
1456
  return;
1322
1457
  }
1323
1458
  if (result.fileCount === 0) {
1324
- console.log("No project files yet. Create /index.html before your first save.");
1459
+ console.log(
1460
+ "No project files yet. Create /index.html before your first save.",
1461
+ );
1325
1462
  }
1326
1463
  console.log('Codex: codex "Read AGENTS.md, then make the requested change."');
1327
1464
  console.log(
@@ -1403,7 +1540,9 @@ export function printContributionDiff({ result, build }) {
1403
1540
  ? result.diff.changedFiles
1404
1541
  : [];
1405
1542
  const branchNumber = Number(build.contributionBranchNumber || 0) || 0;
1406
- const branchLabel = branchNumber ? `branch ${branchNumber}` : `branch #${build.id}`;
1543
+ const branchLabel = branchNumber
1544
+ ? `branch ${branchNumber}`
1545
+ : `branch #${build.id}`;
1407
1546
  console.log(`Diff for ${branchLabel}:`);
1408
1547
  console.log(
1409
1548
  `- total=${summary.total ?? files.length} added=${summary.added ?? 0} ` +
@@ -1470,14 +1609,18 @@ export function printContributionActionResult({
1470
1609
  const projectFiles = Array.isArray(result.projectFiles)
1471
1610
  ? result.projectFiles
1472
1611
  : [];
1473
- console.log(`${action} branch #${contributionBuildId} for Build #${rootBuildId}.`);
1612
+ console.log(
1613
+ `${action} branch #${contributionBuildId} for Build #${rootBuildId}.`,
1614
+ );
1474
1615
  if (projectFiles.length > 0) {
1475
1616
  console.log(
1476
1617
  `Main now has ${projectFiles.length} project file${projectFiles.length === 1 ? "" : "s"}.`,
1477
1618
  );
1478
1619
  }
1479
1620
  if (result.mergeConflictsWritten || result.conflicts?.length > 0) {
1480
- console.log("Merge wrote conflict markers. Resolve them in the Build workspace.");
1621
+ console.log(
1622
+ "Merge wrote conflict markers. Resolve them in the Build workspace.",
1623
+ );
1481
1624
  }
1482
1625
  console.log(`Main workspace: ${rootBuildId}`);
1483
1626
  }
@@ -1502,6 +1645,7 @@ export function parseArgs(args) {
1502
1645
  "noDescription",
1503
1646
  "publish",
1504
1647
  "save",
1648
+ "force",
1505
1649
  "noUpdateCheck",
1506
1650
  "allowWrite",
1507
1651
  "main",
@@ -1552,13 +1696,11 @@ export function parseArgs(args) {
1552
1696
  target: raw.url || raw.target || positional[0] || "",
1553
1697
  title:
1554
1698
  String(
1555
- raw.title ||
1556
- (command === "new" ? positional.join(" ") : ""),
1699
+ raw.title || (command === "new" ? positional.join(" ") : ""),
1557
1700
  ).trim() || "",
1558
- description:
1559
- Object.prototype.hasOwnProperty.call(raw, "description")
1560
- ? String(raw.description || "").trim()
1561
- : null,
1701
+ description: Object.prototype.hasOwnProperty.call(raw, "description")
1702
+ ? String(raw.description || "").trim()
1703
+ : null,
1562
1704
  descriptionProvided: Object.prototype.hasOwnProperty.call(
1563
1705
  raw,
1564
1706
  "description",
@@ -1608,6 +1750,7 @@ export function parseArgs(args) {
1608
1750
  : 0,
1609
1751
  summary: raw.summary ? String(raw.summary) : "",
1610
1752
  publish: parseBoolean(raw.publish, false),
1753
+ force: parseBoolean(raw.force, false),
1611
1754
  saveFirst: parseBoolean(raw.save, false),
1612
1755
  limit: Math.min(
1613
1756
  Math.max(
@@ -1709,7 +1852,9 @@ export async function resolveNewBuildTitle(options) {
1709
1852
  const title = String(options.title || "").trim();
1710
1853
  if (title) return title;
1711
1854
  if (!input.isTTY || !output.isTTY) {
1712
- throw new Error('Pass a title: `lumine new "My Build"` or `lumine new --title "My Build"`.');
1855
+ throw new Error(
1856
+ 'Pass a title: `lumine new "My Build"` or `lumine new --title "My Build"`.',
1857
+ );
1713
1858
  }
1714
1859
  const rl = readline.createInterface({ input, output });
1715
1860
  try {
@@ -1739,7 +1884,11 @@ export async function resolveNewBuildDescription(options) {
1739
1884
  }
1740
1885
  }
1741
1886
 
1742
- export async function resolveBuildReferenceBuildId({ options, auth, reference }) {
1887
+ export async function resolveBuildReferenceBuildId({
1888
+ options,
1889
+ auth,
1890
+ reference,
1891
+ }) {
1743
1892
  if (reference.branchNumber > 0) {
1744
1893
  const build = await resolveBranchBuild({
1745
1894
  options,
@@ -1840,6 +1989,7 @@ Options:
1840
1989
  --description <text> Optional New Build description
1841
1990
  --no-description Skip the New Build description prompt
1842
1991
  --summary <text> Save summary
1992
+ --force Overwrite server files even if this workspace is stale or missing filesHash
1843
1993
  --search <text> Search public open-source Builds
1844
1994
  --sort <sort> Sort open-source Builds: forks, popular, recent
1845
1995
  --publish Publish after saving
package/lib/constants.js CHANGED
@@ -143,6 +143,12 @@ Lumine CLI as the source of truth for saving this workspace back to Twinkle.
143
143
 
144
144
  - Edit only project files in this workspace.
145
145
  - Keep /index.html or /index.htm as the entry file.
146
+ - Before editing an existing project, confirm \`.twinkle/lumine-project.json\`
147
+ has a \`filesHash\` and matches the build you intend. If the folder may be
148
+ stale (old pull, missing files, fewer files than last known save), run
149
+ \`lumine pull\` first — never \`lumine save\` from a guessed/old checkout.
150
+ Saves without \`filesHash\` are refused (use \`--force\` only for deliberate
151
+ overwrites). A stale save can delete newer server files.
146
152
  - Run lumine save from this folder after edits, with a short summary:
147
153
 
148
154
  \`\`\`bash
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,11 @@ 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. Missing/null refuses save unless --force
601
+ // (server also rejects unguarded saves onto non-empty projects).
602
+ filesHash: typeof filesHash === "string" ? filesHash : null,
404
603
  },
405
604
  null,
406
605
  2,
@@ -432,11 +631,14 @@ export async function writeReferenceMetadata({
432
631
  readOnly: true,
433
632
  forkable: true,
434
633
  sourceBuildId,
435
- sourceAppUrl: sourceBuildId ? `${options.siteUrl}/app/${sourceBuildId}` : null,
634
+ sourceAppUrl: sourceBuildId
635
+ ? `${options.siteUrl}/app/${sourceBuildId}`
636
+ : null,
436
637
  },
437
638
  build: {
438
639
  id: sourceBuildId,
439
- title: build?.title || (sourceBuildId ? `Build ${sourceBuildId}` : ""),
640
+ title:
641
+ build?.title || (sourceBuildId ? `Build ${sourceBuildId}` : ""),
440
642
  role: "reference",
441
643
  ownerUsername: build?.ownerUsername || null,
442
644
  collaborationMode: build?.collaborationMode || "open_source",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stage5/lumine",
3
- "version": "0.2.12",
3
+ "version": "0.2.14",
4
4
  "description": "Command line tools for launching Lumine builds on Twinkle.",
5
5
  "type": "module",
6
6
  "bin": {