summer-engine 2.5.0 → 2.6.0

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.
Files changed (82) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +5 -4
  3. package/.codex-plugin/plugin.json +5 -4
  4. package/.cursor-plugin/plugin.json +4 -3
  5. package/.factory-plugin/plugin.json +3 -3
  6. package/AGENTS.md +6 -4
  7. package/CHANGELOG.md +20 -2
  8. package/GEMINI.md +3 -3
  9. package/README.md +83 -97
  10. package/dist/bin/summer.js +6 -1
  11. package/dist/commands/cloud.d.ts +2 -0
  12. package/dist/commands/cloud.js +80 -0
  13. package/dist/commands/doctor.js +19 -0
  14. package/dist/commands/login.js +4 -1
  15. package/dist/commands/logout.js +1 -1
  16. package/dist/commands/open.js +1 -1
  17. package/dist/commands/plan.d.ts +2 -0
  18. package/dist/commands/plan.js +60 -0
  19. package/dist/commands/run.js +132 -35
  20. package/dist/lib/auth.d.ts +2 -0
  21. package/dist/lib/auth.js +13 -0
  22. package/dist/lib/cloud/api.d.ts +79 -0
  23. package/dist/lib/cloud/api.js +107 -0
  24. package/dist/lib/cloud/apply.d.ts +32 -0
  25. package/dist/lib/cloud/apply.js +294 -0
  26. package/dist/lib/cloud/checkpoint.d.ts +26 -0
  27. package/dist/lib/cloud/checkpoint.js +142 -0
  28. package/dist/lib/cloud/conflicts.d.ts +55 -0
  29. package/dist/lib/cloud/conflicts.js +131 -0
  30. package/dist/lib/cloud/containment.d.ts +8 -0
  31. package/dist/lib/cloud/containment.js +52 -0
  32. package/dist/lib/cloud/diff.d.ts +39 -0
  33. package/dist/lib/cloud/diff.js +138 -0
  34. package/dist/lib/cloud/engine-bridge.d.ts +21 -0
  35. package/dist/lib/cloud/engine-bridge.js +105 -0
  36. package/dist/lib/cloud/hash-cache.d.ts +23 -0
  37. package/dist/lib/cloud/hash-cache.js +44 -0
  38. package/dist/lib/cloud/hash.d.ts +10 -0
  39. package/dist/lib/cloud/hash.js +111 -0
  40. package/dist/lib/cloud/http.d.ts +17 -0
  41. package/dist/lib/cloud/http.js +47 -0
  42. package/dist/lib/cloud/lock-path.d.ts +1 -0
  43. package/dist/lib/cloud/lock-path.js +5 -0
  44. package/dist/lib/cloud/lock.d.ts +14 -0
  45. package/dist/lib/cloud/lock.js +143 -0
  46. package/dist/lib/cloud/manifest.d.ts +6 -0
  47. package/dist/lib/cloud/manifest.js +41 -0
  48. package/dist/lib/cloud/paths.d.ts +20 -0
  49. package/dist/lib/cloud/paths.js +90 -0
  50. package/dist/lib/cloud/project-godot-merge.d.ts +32 -0
  51. package/dist/lib/cloud/project-godot-merge.js +185 -0
  52. package/dist/lib/cloud/rules.d.ts +39 -0
  53. package/dist/lib/cloud/rules.js +159 -0
  54. package/dist/lib/cloud/sync.d.ts +21 -0
  55. package/dist/lib/cloud/sync.js +518 -0
  56. package/dist/lib/cloud/transfer.d.ts +23 -0
  57. package/dist/lib/cloud/transfer.js +184 -0
  58. package/dist/lib/cloud/types.d.ts +62 -0
  59. package/dist/lib/cloud/types.js +1 -0
  60. package/dist/lib/cloud/validate-path.d.ts +23 -0
  61. package/dist/lib/cloud/validate-path.js +103 -0
  62. package/dist/lib/engine.d.ts +6 -0
  63. package/dist/lib/engine.js +36 -1
  64. package/dist/lib/game-task-plan.d.ts +42 -0
  65. package/dist/lib/game-task-plan.js +376 -0
  66. package/dist/mcp/server.js +2 -0
  67. package/dist/mcp/tools/asset-tools.js +265 -45
  68. package/dist/mcp/tools/cloud-tools.d.ts +2 -0
  69. package/dist/mcp/tools/cloud-tools.js +42 -0
  70. package/dist/mcp/tools/generate-tools.js +101 -13
  71. package/dist/mcp/tools/project-tools.js +59 -16
  72. package/gemini-extension.json +3 -3
  73. package/package.json +16 -5
  74. package/skills/3d-assets/character-model/SKILL.md +27 -5
  75. package/skills/3d-assets/environment-kit/SKILL.md +7 -2
  76. package/skills/3d-assets/organic-model/SKILL.md +4 -3
  77. package/skills/3d-assets/prop-model/SKILL.md +16 -8
  78. package/skills/3d-assets/vehicle-model/SKILL.md +4 -3
  79. package/skills/animation/animation-tree/SKILL.md +2 -0
  80. package/skills/animation/generate-motion/SKILL.md +25 -4
  81. package/skills/asset-pipeline/asset-strategy/SKILL.md +3 -2
  82. package/skills/workflow/mcpupdate/SKILL.md +134 -0
@@ -0,0 +1,518 @@
1
+ import { randomUUID } from "crypto";
2
+ import { stat } from "fs/promises";
3
+ import { resolve } from "path";
4
+ import { CloudApiError, getCloudApi } from "./api.js";
5
+ import { applyRemotePlan } from "./apply.js";
6
+ import { createCheckpoint, listCheckpoints, restoreCheckpoint } from "./checkpoint.js";
7
+ import { ConflictSetWriter, conflictStamp, listConflictSets, restoreConflictPath } from "./conflicts.js";
8
+ import { assertDeleteGuard, assertNotEmptyLocalTree, diffManifests } from "./diff.js";
9
+ import { notifyEngineAfterApply, readProjectPathMarker, saveDirtyScenesIfRunning, writeProjectPathMarker, } from "./engine-bridge.js";
10
+ import { walkProject } from "./hash.js";
11
+ import { acquireCloudLock } from "./lock.js";
12
+ import { serializeManifest, sha256Hex } from "./manifest.js";
13
+ import { ensureCloudDirs, projectDisplayName, readBase, readBinding, readJournal, removeJournal, resolveProjectRoot, writeBase, writeBinding, writeJournal, } from "./paths.js";
14
+ import { isTrackedByCurrentRules, loadTrackedRules, RULES_VERSION } from "./rules.js";
15
+ import { downloadBlobs, uploadBlobs } from "./transfer.js";
16
+ const CAS_RETRY_ATTEMPTS = 5;
17
+ /** Jittered 409 backoff schedule. Exported so tests can shrink the delays. */
18
+ export const CAS_BACKOFF_MS = [1_000, 2_000, 4_000, 8_000, 16_000];
19
+ const REUPLOAD_ATTEMPTS = 3;
20
+ export async function cloudInit(options = {}) {
21
+ const projectRoot = resolveProjectRoot(options.project);
22
+ await ensureCloudDirs(projectRoot);
23
+ const existing = await readBinding(projectRoot);
24
+ if (existing) {
25
+ return { ok: true, action: "init", projectId: existing.projectId, message: "Project is already bound to Summer Cloud" };
26
+ }
27
+ const name = await projectDisplayName(projectRoot);
28
+ const created = await getCloudApi().createCloudProject({ name });
29
+ await writeBinding(projectRoot, { schemaVersion: 1, projectId: created.projectId });
30
+ // base.json is only ever replaced under the sync lock (spec 8.2).
31
+ const lock = await acquireCloudLock(projectRoot, options.face ?? "cli");
32
+ try {
33
+ await writeBase(projectRoot, {
34
+ schemaVersion: 1,
35
+ projectId: created.projectId,
36
+ version: created.headVersion,
37
+ rulesVersion: RULES_VERSION,
38
+ files: {},
39
+ });
40
+ }
41
+ finally {
42
+ await lock.release();
43
+ }
44
+ return { ok: true, action: "init", projectId: created.projectId, version: created.headVersion, message: "Summer Cloud enabled" };
45
+ }
46
+ export async function cloudStatus(options = {}) {
47
+ const projectRoot = resolveProjectRoot(options.project);
48
+ const binding = await requireBinding(projectRoot);
49
+ const api = getCloudApi();
50
+ const [base, project, local, journal, conflictSets] = await Promise.all([
51
+ readBase(projectRoot),
52
+ api.getCloudProject(binding.projectId),
53
+ walkProject(projectRoot),
54
+ readJournal(projectRoot),
55
+ listConflictSets(projectRoot),
56
+ ]);
57
+ const remote = await api.getManifest(binding.projectId, binding.pinnedVersion);
58
+ const rules = await loadTrackedRules(projectRoot);
59
+ const notices = [];
60
+ const localManifest = buildLocalManifest(binding.projectId, remote, base, local, rules, notices);
61
+ const plan = diffManifests(baseToManifest(base, binding.projectId), localManifest, remote.manifest);
62
+ return {
63
+ ok: true,
64
+ action: "status",
65
+ projectId: binding.projectId,
66
+ version: project.headVersion,
67
+ message: summarizePlan(plan),
68
+ notices,
69
+ details: {
70
+ localFiles: Object.keys(local.files).length,
71
+ remoteFiles: Object.keys(remote.manifest.files).length,
72
+ baseVersion: base?.version ?? null,
73
+ pinnedVersion: binding.pinnedVersion ?? null,
74
+ push: plan.pushPaths.length + plan.deleteRemotePaths.length,
75
+ pull: plan.pullPaths.length + plan.deleteLocalPaths.length,
76
+ conflicts: plan.conflictPaths.length,
77
+ keepLocal: plan.keepLocalPaths,
78
+ hydrating: Boolean(journal),
79
+ conflictSets: conflictSets.map((set) => set.stamp),
80
+ skippedSymlinks: local.skippedSymlinks,
81
+ unstablePaths: local.unstablePaths,
82
+ },
83
+ };
84
+ }
85
+ export async function cloudPush(options = {}) {
86
+ const projectRoot = resolveProjectRoot(options.project);
87
+ await ensureCloudDirs(projectRoot);
88
+ const binding = await requireBinding(projectRoot);
89
+ if (binding.pinnedVersion !== undefined) {
90
+ throw new Error(`summer-cloud.json pins version ${binding.pinnedVersion} (read-only checkout). Remove pinnedVersion to push.`);
91
+ }
92
+ const notices = [];
93
+ await assertProjectPathGate(projectRoot, options, notices);
94
+ const lock = await acquireCloudLock(projectRoot, options.face ?? "cli");
95
+ try {
96
+ await saveDirtyScenesIfRunning(projectRoot);
97
+ for (let attempt = 0;; attempt += 1) {
98
+ try {
99
+ return await pushOnce(projectRoot, binding.projectId, options, notices);
100
+ }
101
+ catch (err) {
102
+ // Spec 8.6.6: on CAS 409 re-fetch head, re-run the FULL diff, retry
103
+ // with a fresh syncId. Bounded with jittered backoff.
104
+ if (err instanceof CloudApiError && err.status === 409 && attempt < CAS_RETRY_ATTEMPTS - 1) {
105
+ await sleep(jitter(CAS_BACKOFF_MS[Math.min(attempt, CAS_BACKOFF_MS.length - 1)]));
106
+ continue;
107
+ }
108
+ if (err instanceof CloudApiError && err.status === 409) {
109
+ throw new Error("Project is being synced from another machine, retry later");
110
+ }
111
+ throw err;
112
+ }
113
+ }
114
+ }
115
+ finally {
116
+ await lock.release();
117
+ }
118
+ }
119
+ async function pushOnce(projectRoot, projectId, options, notices) {
120
+ const api = getCloudApi();
121
+ const remote = await api.getManifest(projectId);
122
+ const base = await readBase(projectRoot);
123
+ const walked = await walkProject(projectRoot, { syncStartMs: Date.now() });
124
+ assertBootstrapGate("push", base, walked, remote, options);
125
+ const rules = await loadTrackedRules(projectRoot);
126
+ const local = buildLocalManifest(projectId, remote, base, walked, rules, notices);
127
+ const baseFileCount = Object.keys(base?.files ?? {}).length;
128
+ // Hard abort BEFORE any confirmation (spec 8.5): a non-empty base with an
129
+ // empty local walk reads as 100% deletions and must never be pushable.
130
+ assertNotEmptyLocalTree(baseFileCount, Object.keys(walked.files).length);
131
+ const plan = diffManifests(baseToManifest(base, projectId), local, remote.manifest);
132
+ assertDeleteGuard(plan, baseFileCount, options.confirmDeletes);
133
+ // Spec 8.6.3: if pulls are needed, run the pull phase first so the push
134
+ // manifests a converged tree (skipped only for the keep-local bootstrap).
135
+ const needsRemote = plan.pullPaths.length + plan.deleteLocalPaths.length + plan.conflictPaths.length;
136
+ if (needsRemote && options.bootstrap !== "keep-local") {
137
+ await pullPhase(projectRoot, projectId, plan, remote, base, walked, notices);
138
+ }
139
+ else {
140
+ notices.push(...rowNotices(plan));
141
+ }
142
+ const finalWalk = await walkProject(projectRoot, { syncStartMs: Date.now() });
143
+ const finalManifest = buildLocalManifest(projectId, remote, options.bootstrap === "keep-local" ? null : base, finalWalk, rules, notices);
144
+ if (options.bootstrap === "keep-local") {
145
+ // Keep-local replaces the cloud tree with the local one (spec 8.3.b).
146
+ finalManifest.files = { ...finalWalk.files };
147
+ }
148
+ const uploadSources = new Map();
149
+ for (const [, file] of Object.entries(finalManifest.files)) {
150
+ const source = finalWalk.fileByHash.get(file.sha256);
151
+ if (source)
152
+ uploadSources.set(file.sha256, { size: file.size, path: source });
153
+ }
154
+ const outcome = await uploadBlobs(projectId, uploadSources);
155
+ if (outcome.requeued.length) {
156
+ applyRequeuedCarryForward(finalManifest, base, new Set(outcome.requeued), notices);
157
+ }
158
+ const committed = await commitWithReuploadLoop(projectId, finalManifest, remote.version, (hash) => finalWalk.fileByHash.get(hash));
159
+ await writeBase(projectRoot, { ...finalManifest, version: committed.version });
160
+ return {
161
+ ok: true,
162
+ action: "push",
163
+ projectId,
164
+ version: committed.version,
165
+ message: "Pushed to Summer Cloud",
166
+ notices: [...new Set(notices)],
167
+ };
168
+ }
169
+ async function commitWithReuploadLoop(projectId, manifest, baseVersion, diskPathForHash) {
170
+ const api = getCloudApi();
171
+ const manifestBytes = serializeManifest(manifest);
172
+ const manifestSha256 = sha256Hex(manifestBytes);
173
+ await uploadBlobs(projectId, new Map([[manifestSha256, { size: manifestBytes.length, bytes: manifestBytes }]]));
174
+ for (let attempt = 0;; attempt += 1) {
175
+ try {
176
+ return await api.commitManifest(projectId, {
177
+ baseVersion,
178
+ manifestSha256,
179
+ syncId: randomUUID(),
180
+ rulesVersion: Math.max(manifest.rulesVersion, RULES_VERSION),
181
+ });
182
+ }
183
+ catch (err) {
184
+ // Spec 8.6.6: 422 blobs_not_verified lists hashes to re-upload; the
185
+ // client has the bytes, it just hashed them. Also covers the GC race.
186
+ if (err instanceof CloudApiError && err.status === 422 && err.code === "blobs_not_verified" && attempt < REUPLOAD_ATTEMPTS - 1) {
187
+ const missing = extractMissingHashes(err);
188
+ if (!missing.length)
189
+ throw err;
190
+ const sizeByHash = new Map();
191
+ for (const file of Object.values(manifest.files))
192
+ sizeByHash.set(file.sha256, file.size);
193
+ const sources = new Map();
194
+ for (const hash of missing) {
195
+ if (hash === manifestSha256) {
196
+ sources.set(hash, { size: manifestBytes.length, bytes: manifestBytes });
197
+ continue;
198
+ }
199
+ const path = diskPathForHash(hash);
200
+ const size = sizeByHash.get(hash);
201
+ if (path && size !== undefined)
202
+ sources.set(hash, { size, path });
203
+ }
204
+ if (!sources.size)
205
+ throw err;
206
+ await uploadBlobs(projectId, sources);
207
+ continue;
208
+ }
209
+ throw err;
210
+ }
211
+ }
212
+ }
213
+ function extractMissingHashes(err) {
214
+ const fromDetails = err.details ?? {};
215
+ const list = Array.isArray(fromDetails.missing)
216
+ ? fromDetails.missing
217
+ : Array.isArray(fromDetails.blobs)
218
+ ? fromDetails.blobs
219
+ : [];
220
+ return list.filter((value) => typeof value === "string");
221
+ }
222
+ export async function cloudPull(options = {}) {
223
+ const projectRoot = resolveProjectRoot(options.project);
224
+ await ensureCloudDirs(projectRoot);
225
+ const binding = await requireBinding(projectRoot);
226
+ const notices = [];
227
+ await assertProjectPathGate(projectRoot, options, notices);
228
+ const lock = await acquireCloudLock(projectRoot, options.face ?? "cli");
229
+ try {
230
+ await saveDirtyScenesIfRunning(projectRoot);
231
+ const api = getCloudApi();
232
+ const remote = await api.getManifest(binding.projectId, binding.pinnedVersion);
233
+ const base = await readBase(projectRoot);
234
+ const walked = await walkProject(projectRoot, { syncStartMs: Date.now() });
235
+ assertBootstrapGate("pull", base, walked, remote, options);
236
+ const rules = await loadTrackedRules(projectRoot);
237
+ const local = buildLocalManifest(binding.projectId, remote, base, walked, rules, notices);
238
+ const plan = diffManifests(baseToManifest(base, binding.projectId), local, remote.manifest);
239
+ await pullPhase(projectRoot, binding.projectId, plan, remote, base, walked, notices);
240
+ await writeBase(projectRoot, { ...remote.manifest, version: remote.version });
241
+ return {
242
+ ok: true,
243
+ action: "pull",
244
+ projectId: binding.projectId,
245
+ version: remote.version,
246
+ message: "Pulled from Summer Cloud",
247
+ notices: [...new Set(notices)],
248
+ };
249
+ }
250
+ finally {
251
+ await lock.release();
252
+ }
253
+ }
254
+ /**
255
+ * Two-phase pull (spec 8.7): stage (journal phase "staging"), then apply
256
+ * (journal phase "applying"), then conflict finalization, R2 preservation of
257
+ * losing bytes, journal removal, and engine notification.
258
+ */
259
+ async function pullPhase(projectRoot, projectId, plan, remote, base, walked, notices) {
260
+ const paths = [...plan.pullPaths, ...plan.deleteLocalPaths, ...plan.conflictPaths];
261
+ if (!paths.length) {
262
+ notices.push(...rowNotices(plan));
263
+ return;
264
+ }
265
+ const syncId = randomUUID();
266
+ const pending = paths.map((path) => ({ path, sha256: remote.manifest.files[path]?.sha256 ?? "" }));
267
+ const hashes = pending.map((entry) => entry.sha256).filter(Boolean);
268
+ // The base version of project.godot is needed for its key-level 3-way merge.
269
+ const baseGodot = base?.files["project.godot"]?.sha256;
270
+ if (baseGodot && plan.conflictPaths.includes("project.godot"))
271
+ hashes.push(baseGodot);
272
+ await writeJournal(projectRoot, { syncId, targetVersion: remote.version, phase: "staging", pending });
273
+ const staged = await downloadBlobs(projectRoot, projectId, hashes);
274
+ await writeJournal(projectRoot, { syncId, targetVersion: remote.version, phase: "applying", pending });
275
+ const conflicts = new ConflictSetWriter(projectRoot, conflictStamp(), projectId, base?.version ?? null, remote.version);
276
+ const applyResult = await applyRemotePlan(projectRoot, plan, remote.manifest, {
277
+ staged,
278
+ diskPathByKey: walked.diskPathByKey,
279
+ statByKey: walked.statByKey,
280
+ conflicts,
281
+ base,
282
+ onBeforeDestructive: async () => {
283
+ await createCheckpoint(projectRoot, projectId);
284
+ },
285
+ });
286
+ await conflicts.finalize();
287
+ // Push the losing bytes to R2 (blob-only, granted, unreferenced) so
288
+ // conflict recovery survives this machine (spec 10.1).
289
+ if (applyResult.preservedLocalHashes.size) {
290
+ const sources = new Map();
291
+ for (const [hash, path] of applyResult.preservedLocalHashes) {
292
+ try {
293
+ const info = await stat(path);
294
+ sources.set(hash, { size: info.size, path });
295
+ }
296
+ catch {
297
+ // The file may have been deleted by the apply; preservation copy only.
298
+ }
299
+ }
300
+ try {
301
+ await uploadBlobs(projectId, sources);
302
+ }
303
+ catch (err) {
304
+ notices.push(`Conflict copies are saved locally but could not be backed up to the cloud: ${err.message}`);
305
+ }
306
+ }
307
+ await removeJournal(projectRoot);
308
+ notices.push(...applyResult.notices);
309
+ notices.push(...rowNotices(plan));
310
+ await notifyEngineAfterApply(projectRoot, applyResult.appliedPaths);
311
+ }
312
+ function rowNotices(plan) {
313
+ // Row 14 (spec 8.4): edit beats delete must be surfaced, even when no pull
314
+ // work was needed.
315
+ return plan.keepLocalPaths.map((path) => `${path}: deleted in cloud but edited here; keeping local file (edit beats delete)`);
316
+ }
317
+ export async function cloudConflicts(options = {}) {
318
+ const projectRoot = resolveProjectRoot(options.project);
319
+ if (options.restorePath) {
320
+ const restored = await restoreConflictPath(projectRoot, options.restorePath, options.set);
321
+ return {
322
+ ok: true,
323
+ action: "conflicts-restore",
324
+ message: `Restored ${restored.path} from conflict set ${restored.stamp}. Run summer cloud push to sync it.`,
325
+ details: restored,
326
+ };
327
+ }
328
+ const sets = await listConflictSets(projectRoot);
329
+ return {
330
+ ok: true,
331
+ action: "conflicts",
332
+ message: sets.length ? `${sets.length} conflict set(s)` : "No local conflict sets",
333
+ details: {
334
+ conflictSets: sets.map((set) => ({
335
+ stamp: set.stamp,
336
+ files: set.meta?.entries.map((entry) => entry.path) ?? [],
337
+ })),
338
+ },
339
+ };
340
+ }
341
+ export async function cloudRestore(options = {}) {
342
+ const projectRoot = resolveProjectRoot(options.project);
343
+ const binding = await requireBinding(projectRoot);
344
+ if (options.checkpoint) {
345
+ const lock = await acquireCloudLock(projectRoot, options.face ?? "cli");
346
+ try {
347
+ const walked = await walkProject(projectRoot);
348
+ const restored = await restoreCheckpoint(projectRoot, binding.projectId, options.checkpoint, Object.keys(walked.files));
349
+ const message = restored.extraneousFiles.length
350
+ ? `Checkpoint restored. ${restored.extraneousFiles.length} file(s) were added after this checkpoint and were NOT deleted; review and remove them manually.`
351
+ : "Checkpoint restored.";
352
+ return {
353
+ ok: true,
354
+ action: "restore-checkpoint",
355
+ projectId: binding.projectId,
356
+ message,
357
+ details: { ref: restored.ref, extraneousFiles: restored.extraneousFiles },
358
+ };
359
+ }
360
+ finally {
361
+ await lock.release();
362
+ }
363
+ }
364
+ if (!options.version)
365
+ throw new Error("restore requires --version <n> or --checkpoint <stamp>");
366
+ // Spec 7.12: restore creates a NEW head version equal to toVersion's
367
+ // manifest server-side; the pull then converges the local tree onto it.
368
+ const api = getCloudApi();
369
+ for (let attempt = 0;; attempt += 1) {
370
+ const project = await api.getCloudProject(binding.projectId);
371
+ try {
372
+ await api.restoreVersion(binding.projectId, {
373
+ toVersion: options.version,
374
+ baseVersion: project.headVersion,
375
+ syncId: randomUUID(),
376
+ });
377
+ break;
378
+ }
379
+ catch (err) {
380
+ if (err instanceof CloudApiError && err.status === 409 && attempt < CAS_RETRY_ATTEMPTS - 1) {
381
+ await sleep(jitter(CAS_BACKOFF_MS[Math.min(attempt, CAS_BACKOFF_MS.length - 1)]));
382
+ continue;
383
+ }
384
+ throw err;
385
+ }
386
+ }
387
+ const pulled = await cloudPull(options);
388
+ return {
389
+ ...pulled,
390
+ action: "restore",
391
+ message: `Cloud project restored to the contents of version ${options.version} (as a new head version) and pulled locally`,
392
+ };
393
+ }
394
+ export async function cloudCheckpoints(options = {}) {
395
+ const projectRoot = resolveProjectRoot(options.project);
396
+ const binding = await requireBinding(projectRoot);
397
+ const refs = await listCheckpoints(projectRoot, binding.projectId);
398
+ return {
399
+ ok: true,
400
+ action: "checkpoints",
401
+ projectId: binding.projectId,
402
+ message: refs.length ? `${refs.length} local checkpoint(s)` : "No local checkpoints",
403
+ details: { checkpoints: refs.map((ref) => ref.split("/").pop() ?? ref) },
404
+ };
405
+ }
406
+ async function requireBinding(projectRoot) {
407
+ const binding = await readBinding(projectRoot);
408
+ if (!binding?.projectId) {
409
+ throw new Error("Project is not cloud-enabled. Run: summer cloud init");
410
+ }
411
+ return binding;
412
+ }
413
+ /**
414
+ * Finder-copy gate (spec 8.3): two directories must never fight over one
415
+ * projectId. On marker mismatch, block and offer adopt (--adopt-path) or
416
+ * fork (re-init).
417
+ */
418
+ async function assertProjectPathGate(projectRoot, options, notices) {
419
+ const marker = await readProjectPathMarker(projectRoot);
420
+ if (!marker)
421
+ return;
422
+ const here = resolve(projectRoot);
423
+ if (resolve(marker) === here)
424
+ return;
425
+ if (options.adoptPath) {
426
+ await writeProjectPathMarker(here);
427
+ notices.push(`Adopted new project path: ${here}`);
428
+ return;
429
+ }
430
+ throw new Error(`This folder (${here}) does not match the recorded project path (${marker}). ` +
431
+ "If you moved the project, re-run with --adopt-path. " +
432
+ "If this is a copy, delete summer-cloud.json and run summer cloud init to fork it into a new cloud project.");
433
+ }
434
+ function assertBootstrapGate(direction, base, walked, remote, options) {
435
+ if (base)
436
+ return;
437
+ const localCount = Object.keys(walked.files).length;
438
+ const remoteCount = Object.keys(remote.manifest.files).length;
439
+ if (localCount > 0 && remoteCount > 0 && !options.bootstrap) {
440
+ throw new Error("Cloud and local project both have files but this machine has no sync base. Re-run with --bootstrap merge, keep-cloud, or keep-local.");
441
+ }
442
+ if (direction === "push" && options.bootstrap === "keep-cloud") {
443
+ throw new Error("Use `summer cloud pull --bootstrap keep-cloud` to keep the cloud version on a no-base project.");
444
+ }
445
+ if (direction === "pull" && options.bootstrap === "keep-local") {
446
+ throw new Error("Use `summer cloud push --bootstrap keep-local` to keep the local version on a no-base project.");
447
+ }
448
+ }
449
+ /**
450
+ * Local manifest construction with carry-forward (spec 13 and 8.6.2):
451
+ * - Base entries that no longer match the CURRENT tracked rules are carried
452
+ * forward UNCHANGED, never read as deletions (data-loss blocker 1).
453
+ * - Files that kept changing while hashing carry forward their previous
454
+ * manifest entry so the path is never dropped.
455
+ * - A manifest produced by newer rules than this client knows restricts the
456
+ * client: entries it cannot evaluate are carried forward verbatim.
457
+ */
458
+ function buildLocalManifest(projectId, remote, base, walked, rules, notices) {
459
+ const files = { ...walked.files };
460
+ const unstable = new Set(walked.unstablePaths);
461
+ const staleRules = remote.rulesVersion > RULES_VERSION;
462
+ if (staleRules) {
463
+ notices.push("This project was synced by a newer summer CLI; untracked entries are carried forward unchanged. Update summer CLI.");
464
+ }
465
+ for (const [path, entry] of Object.entries(base?.files ?? {})) {
466
+ if (files[path])
467
+ continue;
468
+ if (unstable.has(path)) {
469
+ files[path] = entry;
470
+ notices.push(`${path}: file kept changing during sync; carried forward, will retry next run`);
471
+ continue;
472
+ }
473
+ if (staleRules || !isTrackedByCurrentRules(path, rules)) {
474
+ // Excluded by current rules (or unevaluable under newer rules): carry
475
+ // forward unchanged; only tracked-and-absent paths may become deletions.
476
+ files[path] = entry;
477
+ }
478
+ }
479
+ // Unstable files with no base entry but a remote entry: carry the remote
480
+ // entry so a teammate's file never reads as deleted.
481
+ for (const path of unstable) {
482
+ if (!files[path] && remote.manifest.files[path]) {
483
+ files[path] = remote.manifest.files[path];
484
+ }
485
+ }
486
+ return { schemaVersion: 1, projectId, rulesVersion: Math.max(RULES_VERSION, remote.rulesVersion), files };
487
+ }
488
+ function applyRequeuedCarryForward(manifest, base, requeued, notices) {
489
+ for (const [path, file] of Object.entries(manifest.files)) {
490
+ if (!requeued.has(file.sha256))
491
+ continue;
492
+ const baseEntry = base?.files[path];
493
+ if (baseEntry) {
494
+ manifest.files[path] = baseEntry;
495
+ }
496
+ else {
497
+ delete manifest.files[path];
498
+ }
499
+ notices.push(`${path}: file changed while uploading; carried forward, will retry next run`);
500
+ }
501
+ }
502
+ function baseToManifest(base, projectId) {
503
+ return base ?? { schemaVersion: 1, projectId, rulesVersion: RULES_VERSION, files: {} };
504
+ }
505
+ function summarizePlan(plan) {
506
+ const push = plan.pushPaths.length + plan.deleteRemotePaths.length;
507
+ const pull = plan.pullPaths.length + plan.deleteLocalPaths.length;
508
+ const conflicts = plan.conflictPaths.length;
509
+ if (!push && !pull && !conflicts)
510
+ return "Cloud project is in sync";
511
+ return `push ${push}, pull ${pull}, conflicts ${conflicts}`;
512
+ }
513
+ function jitter(ms) {
514
+ return Math.round(ms * (0.5 + Math.random()));
515
+ }
516
+ function sleep(ms) {
517
+ return new Promise((resolveSleep) => setTimeout(resolveSleep, ms));
518
+ }
@@ -0,0 +1,23 @@
1
+ /** Server batch caps (spec 7.5 to 7.8); the server 400s above these. */
2
+ export declare const CHECK_BATCH = 10000;
3
+ export declare const PRESIGN_BATCH = 500;
4
+ export declare const COMPLETE_BATCH = 500;
5
+ export declare const PRESIGN_GET_BATCH = 500;
6
+ export interface UploadSource {
7
+ size: number;
8
+ path?: string;
9
+ bytes?: Buffer;
10
+ }
11
+ export interface UploadOutcome {
12
+ uploaded: string[];
13
+ /** Hashes whose bytes diverged from the diff-time hash; requeue next run. */
14
+ requeued: string[];
15
+ }
16
+ export declare function uploadBlobs(projectId: string, blobs: Map<string, UploadSource>): Promise<UploadOutcome>;
17
+ /**
18
+ * Two-phase pull, phase 1 (spec 8.7): stream every needed blob to
19
+ * .summer/local/cloud/staging/{sha256}, verify its sha256, fsync. Returns the
20
+ * staged file path per hash. Already-staged blobs with a verified hash are
21
+ * reused (journal resume).
22
+ */
23
+ export declare function downloadBlobs(projectRoot: string, projectId: string, hashes: string[]): Promise<Map<string, string>>;