@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 +8 -1
- package/lib/api.js +16 -1
- package/lib/commands.js +195 -45
- package/lib/constants.js +6 -0
- package/lib/workspace.js +232 -30
- package/package.json +1 -1
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.
|
|
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({
|
|
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
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
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
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
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({
|
|
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({
|
|
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 || {
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
|
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(
|
|
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(
|
|
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
|
-
|
|
1560
|
-
|
|
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(
|
|
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({
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
147
|
+
dir: fullPath,
|
|
148
|
+
onEntry,
|
|
149
|
+
onDirectory,
|
|
143
150
|
});
|
|
144
|
-
|
|
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}
|
|
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
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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(
|
|
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(
|
|
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) =>
|
|
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
|
|
634
|
+
sourceAppUrl: sourceBuildId
|
|
635
|
+
? `${options.siteUrl}/app/${sourceBuildId}`
|
|
636
|
+
: null,
|
|
436
637
|
},
|
|
437
638
|
build: {
|
|
438
639
|
id: sourceBuildId,
|
|
439
|
-
title:
|
|
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",
|