summer-engine 2.3.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 (237) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +24 -8
  3. package/.codex-plugin/plugin.json +19 -8
  4. package/.cursor-plugin/plugin.json +18 -7
  5. package/.factory-plugin/plugin.json +3 -3
  6. package/.opencode/INSTALL.md +1 -1
  7. package/AGENTS.md +6 -4
  8. package/CHANGELOG.md +46 -7
  9. package/CLAUDE.md +2 -2
  10. package/GEMINI.md +5 -6
  11. package/README.md +116 -109
  12. package/_persona/summer/SKILL.md +5 -6
  13. package/commands/gameskill.md +103 -0
  14. package/dist/bin/summer.js +17 -4
  15. package/dist/commands/cloud.d.ts +2 -0
  16. package/dist/commands/cloud.js +80 -0
  17. package/dist/commands/create.js +2 -0
  18. package/dist/commands/doctor.js +90 -1
  19. package/dist/commands/install.d.ts +17 -0
  20. package/dist/commands/install.js +63 -4
  21. package/dist/commands/login.js +4 -1
  22. package/dist/commands/logout.js +1 -1
  23. package/dist/commands/memory.d.ts +14 -0
  24. package/dist/commands/memory.js +196 -0
  25. package/dist/commands/open.js +1 -1
  26. package/dist/commands/plan.d.ts +2 -0
  27. package/dist/commands/plan.js +60 -0
  28. package/dist/commands/run.js +132 -35
  29. package/dist/commands/setup.js +8 -2
  30. package/dist/commands/skills.js +90 -6
  31. package/dist/commands/status.js +3 -0
  32. package/dist/lib/agent-config.d.ts +2 -2
  33. package/dist/lib/agent-config.js +134 -7
  34. package/dist/lib/api-client.d.ts +13 -0
  35. package/dist/lib/api-client.js +63 -26
  36. package/dist/lib/async-op-lifecycle.d.ts +60 -0
  37. package/dist/lib/async-op-lifecycle.js +83 -0
  38. package/dist/lib/auth.d.ts +2 -0
  39. package/dist/lib/auth.js +13 -0
  40. package/dist/lib/cloud/api.d.ts +79 -0
  41. package/dist/lib/cloud/api.js +107 -0
  42. package/dist/lib/cloud/apply.d.ts +32 -0
  43. package/dist/lib/cloud/apply.js +294 -0
  44. package/dist/lib/cloud/checkpoint.d.ts +26 -0
  45. package/dist/lib/cloud/checkpoint.js +142 -0
  46. package/dist/lib/cloud/conflicts.d.ts +55 -0
  47. package/dist/lib/cloud/conflicts.js +131 -0
  48. package/dist/lib/cloud/containment.d.ts +8 -0
  49. package/dist/lib/cloud/containment.js +52 -0
  50. package/dist/lib/cloud/diff.d.ts +39 -0
  51. package/dist/lib/cloud/diff.js +138 -0
  52. package/dist/lib/cloud/engine-bridge.d.ts +21 -0
  53. package/dist/lib/cloud/engine-bridge.js +105 -0
  54. package/dist/lib/cloud/hash-cache.d.ts +23 -0
  55. package/dist/lib/cloud/hash-cache.js +44 -0
  56. package/dist/lib/cloud/hash.d.ts +10 -0
  57. package/dist/lib/cloud/hash.js +111 -0
  58. package/dist/lib/cloud/http.d.ts +17 -0
  59. package/dist/lib/cloud/http.js +47 -0
  60. package/dist/lib/cloud/lock-path.d.ts +1 -0
  61. package/dist/lib/cloud/lock-path.js +5 -0
  62. package/dist/lib/cloud/lock.d.ts +14 -0
  63. package/dist/lib/cloud/lock.js +143 -0
  64. package/dist/lib/cloud/manifest.d.ts +6 -0
  65. package/dist/lib/cloud/manifest.js +41 -0
  66. package/dist/lib/cloud/paths.d.ts +20 -0
  67. package/dist/lib/cloud/paths.js +90 -0
  68. package/dist/lib/cloud/project-godot-merge.d.ts +32 -0
  69. package/dist/lib/cloud/project-godot-merge.js +185 -0
  70. package/dist/lib/cloud/rules.d.ts +39 -0
  71. package/dist/lib/cloud/rules.js +159 -0
  72. package/dist/lib/cloud/sync.d.ts +21 -0
  73. package/dist/lib/cloud/sync.js +518 -0
  74. package/dist/lib/cloud/transfer.d.ts +23 -0
  75. package/dist/lib/cloud/transfer.js +184 -0
  76. package/dist/lib/cloud/types.d.ts +62 -0
  77. package/dist/lib/cloud/validate-path.d.ts +23 -0
  78. package/dist/lib/cloud/validate-path.js +103 -0
  79. package/dist/lib/engine.d.ts +6 -0
  80. package/dist/lib/engine.js +36 -1
  81. package/dist/lib/game-task-plan.d.ts +42 -0
  82. package/dist/lib/game-task-plan.js +376 -0
  83. package/dist/lib/project-memory.d.ts +33 -0
  84. package/dist/lib/project-memory.js +220 -0
  85. package/dist/lib/skills-registry.d.ts +134 -61
  86. package/dist/lib/skills-registry.js +102 -4
  87. package/dist/lib/version-check.d.ts +106 -0
  88. package/dist/lib/version-check.js +338 -0
  89. package/dist/mcp/server.d.ts +2 -0
  90. package/dist/mcp/server.js +83 -0
  91. package/dist/mcp/tools/asset-tools.js +265 -45
  92. package/dist/mcp/tools/cloud-tools.d.ts +2 -0
  93. package/dist/mcp/tools/cloud-tools.js +42 -0
  94. package/dist/mcp/tools/debug-tools.js +32 -2
  95. package/dist/mcp/tools/generate-tools.js +101 -13
  96. package/dist/mcp/tools/project-tools.js +76 -21
  97. package/dist/mcp/tools/with-engine.d.ts +14 -0
  98. package/dist/mcp/tools/with-engine.js +52 -7
  99. package/gemini-extension.json +3 -3
  100. package/hooks/pre-commit-doctor.sh +0 -0
  101. package/hooks/run-hook.cmd +0 -0
  102. package/hooks/session-start.sh +0 -0
  103. package/package.json +20 -7
  104. package/skills/2d-assets/character-portrait/SKILL.md +1 -1
  105. package/skills/2d-assets/concept-art/SKILL.md +1 -1
  106. package/skills/2d-assets/create-asset-sheet/SKILL.md +163 -0
  107. package/skills/2d-assets/instantiate-asset-pack/SKILL.md +245 -0
  108. package/skills/2d-assets/pixel-art/SKILL.md +1 -1
  109. package/skills/2d-assets/skybox-panorama/SKILL.md +1 -1
  110. package/skills/2d-assets/sprite-sheet/SKILL.md +1 -1
  111. package/skills/2d-assets/tileable-texture/SKILL.md +1 -1
  112. package/skills/2d-assets/ui-graphics/SKILL.md +1 -1
  113. package/skills/2d-assets/use-widget-asset/SKILL.md +217 -0
  114. package/skills/3d-assets/character-model/SKILL.md +28 -6
  115. package/skills/3d-assets/environment-kit/SKILL.md +7 -2
  116. package/skills/3d-assets/organic-model/SKILL.md +5 -4
  117. package/skills/3d-assets/prop-model/SKILL.md +17 -9
  118. package/skills/3d-assets/vehicle-model/SKILL.md +5 -4
  119. package/skills/README.md +37 -30
  120. package/skills/ai-and-npcs/design-npc/SKILL.md +6 -6
  121. package/skills/animation/animation-tree/SKILL.md +4 -2
  122. package/skills/animation/facial-and-lipsync/SKILL.md +1 -1
  123. package/skills/animation/generate-motion/SKILL.md +26 -5
  124. package/skills/animation/procedural-animation/SKILL.md +2 -2
  125. package/skills/asset-pipeline/asset-strategy/SKILL.md +3 -2
  126. package/skills/audio/adaptive-music/SKILL.md +1 -1
  127. package/skills/audio/audio-direction/SKILL.md +5 -5
  128. package/skills/audio/sound-effect/SKILL.md +2 -2
  129. package/skills/audio/voice-line/SKILL.md +25 -3
  130. package/skills/character-controllers/fps-controller/SKILL.md +5 -5
  131. package/skills/debugging/debug/SKILL.md +39 -1
  132. package/skills/deployment/export-and-ship/SKILL.md +4 -4
  133. package/skills/gameplay-mechanics/auto-fire-targeting/SKILL.md +170 -0
  134. package/skills/gameplay-mechanics/design-mechanic/SKILL.md +8 -8
  135. package/skills/level-design/design-level/SKILL.md +5 -5
  136. package/skills/level-design/scene-to-level/SKILL.md +125 -0
  137. package/skills/multiplayer-and-networking/host-authoritative-state/SKILL.md +4 -4
  138. package/skills/multiplayer-and-networking/peer-to-peer-multiplayer/SKILL.md +4 -4
  139. package/skills/multiplayer-and-networking/setup-multiplayer/SKILL.md +7 -7
  140. package/skills/performance/tune-performance/SKILL.md +5 -5
  141. package/skills/rendering-and-lighting/3d-lighting/SKILL.md +1 -1
  142. package/skills/rendering-and-lighting/art-direction/SKILL.md +5 -5
  143. package/skills/scene-and-project/brainstorm-game/SKILL.md +4 -4
  144. package/skills/scene-and-project/play/SKILL.md +1 -1
  145. package/skills/scene-and-project/scene-composition/SKILL.md +30 -1
  146. package/skills/scripting-patterns/gdscript-patterns/SKILL.md +53 -0
  147. package/skills/ui-and-ux/ui-basics/SKILL.md +1 -1
  148. package/skills/video/animated-loop/SKILL.md +1 -1
  149. package/skills/video/cinematic-cutscene/SKILL.md +1 -1
  150. package/skills/video/trailer-shot/SKILL.md +1 -1
  151. package/skills/visual-effects/game-feel/SKILL.md +6 -6
  152. package/skills/workflow/brainstorming/SKILL.md +164 -0
  153. package/skills/workflow/brainstorming/scripts/frame-template.html +214 -0
  154. package/skills/workflow/brainstorming/scripts/helper.js +88 -0
  155. package/skills/workflow/brainstorming/scripts/server.cjs +354 -0
  156. package/skills/workflow/brainstorming/scripts/start-server.sh +148 -0
  157. package/skills/workflow/brainstorming/scripts/stop-server.sh +56 -0
  158. package/skills/workflow/brainstorming/spec-document-reviewer-prompt.md +49 -0
  159. package/skills/workflow/brainstorming/visual-companion.md +287 -0
  160. package/skills/workflow/debugging-game-feel/SKILL.md +174 -0
  161. package/skills/workflow/diagnosing-perf-regressions/SKILL.md +190 -0
  162. package/skills/workflow/dispatching-parallel-agents/SKILL.md +182 -0
  163. package/skills/workflow/gameskill/SKILL.md +111 -0
  164. package/skills/workflow/investigating-bugs/CREATION-LOG.md +119 -0
  165. package/skills/workflow/investigating-bugs/SKILL.md +296 -0
  166. package/skills/workflow/investigating-bugs/condition-based-waiting-example.ts +158 -0
  167. package/skills/workflow/investigating-bugs/condition-based-waiting.md +115 -0
  168. package/skills/workflow/investigating-bugs/defense-in-depth.md +122 -0
  169. package/skills/workflow/investigating-bugs/find-polluter.sh +63 -0
  170. package/skills/workflow/investigating-bugs/root-cause-tracing.md +169 -0
  171. package/skills/workflow/investigating-bugs/test-academic.md +14 -0
  172. package/skills/workflow/investigating-bugs/test-pressure-1.md +58 -0
  173. package/skills/workflow/investigating-bugs/test-pressure-2.md +68 -0
  174. package/skills/workflow/investigating-bugs/test-pressure-3.md +69 -0
  175. package/skills/workflow/mcpupdate/SKILL.md +134 -0
  176. package/skills/workflow/playtesting-a-feature/SKILL.md +173 -0
  177. package/skills/{_meta → workflow}/skill-create/SKILL.md +32 -22
  178. package/skills/{_meta → workflow}/skill-improve/SKILL.md +9 -10
  179. package/skills/{_meta → workflow}/skill-test/SKILL.md +12 -12
  180. package/skills/{_meta → workflow}/using-summer/SKILL.md +5 -2
  181. package/skills/workflow/verification-before-completion/SKILL.md +139 -0
  182. package/skills/workflow/writing-plans/SKILL.md +152 -0
  183. package/skills/workflow/writing-plans/plan-document-reviewer-prompt.md +49 -0
  184. package/skills/workflow/writing-skills/SKILL.md +655 -0
  185. package/skills/workflow/writing-skills/anthropic-best-practices.md +1150 -0
  186. package/skills/workflow/writing-skills/examples/CLAUDE_MD_TESTING.md +189 -0
  187. package/skills/workflow/writing-skills/graphviz-conventions.dot +172 -0
  188. package/skills/workflow/writing-skills/persuasion-principles.md +187 -0
  189. package/skills/workflow/writing-skills/render-graphs.js +168 -0
  190. package/skills/workflow/writing-skills/testing-skills-with-subagents.md +384 -0
  191. package/dist/commands/assets.d.ts +0 -2
  192. package/dist/commands/assets.js +0 -189
  193. package/dist/commands/debug.d.ts +0 -2
  194. package/dist/commands/debug.js +0 -127
  195. package/dist/commands/doctor.test.js +0 -29
  196. package/dist/commands/project.d.ts +0 -2
  197. package/dist/commands/project.js +0 -170
  198. package/dist/commands/scene.d.ts +0 -2
  199. package/dist/commands/scene.js +0 -290
  200. package/dist/lib/agent-config.test.d.ts +0 -1
  201. package/dist/lib/agent-config.test.js +0 -80
  202. package/dist/lib/format.test.d.ts +0 -1
  203. package/dist/lib/format.test.js +0 -36
  204. package/dist/lib/plugin-manifests.test.d.ts +0 -1
  205. package/dist/lib/plugin-manifests.test.js +0 -134
  206. package/dist/lib/with-engine.d.ts +0 -14
  207. package/dist/lib/with-engine.js +0 -109
  208. package/skills/_shared/collaborative-protocol.md +0 -50
  209. package/skills/_shared/gd-style.md +0 -121
  210. package/skills/_shared/godot-version.md +0 -32
  211. package/skills/_shared/mcp-tools-reference.md +0 -133
  212. package/skills/_shared/summer-folder.md +0 -44
  213. package/skills/_shared/template-registry.md +0 -40
  214. package/skills/_tests/runner.md +0 -90
  215. package/skills/_tests/specs/3d-lighting.md +0 -19
  216. package/skills/_tests/specs/art-direction.md +0 -115
  217. package/skills/_tests/specs/asset-strategy.md +0 -19
  218. package/skills/_tests/specs/audio-direction.md +0 -123
  219. package/skills/_tests/specs/brainstorm-game.md +0 -99
  220. package/skills/_tests/specs/debug.md +0 -94
  221. package/skills/_tests/specs/design-level.md +0 -113
  222. package/skills/_tests/specs/design-mechanic.md +0 -112
  223. package/skills/_tests/specs/design-npc.md +0 -143
  224. package/skills/_tests/specs/export-and-ship.md +0 -114
  225. package/skills/_tests/specs/fps-controller.md +0 -113
  226. package/skills/_tests/specs/gdscript-patterns.md +0 -19
  227. package/skills/_tests/specs/host-authoritative-state.md +0 -104
  228. package/skills/_tests/specs/make-game.md +0 -19
  229. package/skills/_tests/specs/peer-to-peer-multiplayer.md +0 -99
  230. package/skills/_tests/specs/play.md +0 -88
  231. package/skills/_tests/specs/scene-composition.md +0 -19
  232. package/skills/_tests/specs/setup-multiplayer.md +0 -106
  233. package/skills/_tests/specs/tune-performance.md +0 -104
  234. package/skills/_tests/specs/ui-basics.md +0 -19
  235. package/skills/_tests/specs/vfx.md +0 -116
  236. package/skills/catalog.yaml +0 -991
  237. /package/dist/{commands/doctor.test.d.ts → lib/cloud/types.js} +0 -0
@@ -0,0 +1,83 @@
1
+ /**
2
+ * 0.5.34 Block E — async tool-lifecycle consumer for the CLI/MCP.
3
+ *
4
+ * Ported from the web orchestrator (publicsummerengine
5
+ * src/lib/bridge/async-op-lifecycle.ts) so the CLI speaks the SAME contract as
6
+ * the engine's responsive tool server (Block E):
7
+ * - NEW path: `202 {requestId, status:"queued"}` -> long-poll
8
+ * GET /api/ops/result?requestId=&wait=ms until terminal (done|failed|canceled).
9
+ * - LEGACY path (dormant/older engine): a synchronous `200` with the full apply
10
+ * result, exactly as before.
11
+ *
12
+ * It inspects the RESPONSE (status/body), not a flag, so it stays compatible with
13
+ * both an async and a synchronous engine. Pure + dependency-injected (now/sleep/
14
+ * pollOnce) so it is unit-tested without HTTP.
15
+ */
16
+ const TERMINAL_STATUSES = new Set(["done", "failed", "canceled"]);
17
+ export function isTerminalStatus(status) {
18
+ return status != null && TERMINAL_STATUSES.has(status);
19
+ }
20
+ /**
21
+ * Decide whether a POST /api/ops (or GET /api/snapshot/*) response is the new
22
+ * async ack (`202`) or the legacy synchronous result (`200`). Inspecting the HTTP
23
+ * status (not a flag) is what keeps this compatible with both engines.
24
+ */
25
+ export function classifyOpsResponse(httpStatus, body) {
26
+ const b = (body ?? {});
27
+ const looksQueued = b.accepted === true && b.status === "queued" && typeof b.requestId === "string";
28
+ if (httpStatus === 202 || looksQueued) {
29
+ return { mode: "queued", requestId: String(b.requestId ?? "") };
30
+ }
31
+ return { mode: "legacy", legacyResult: body };
32
+ }
33
+ const TIMED_OUT_RESULT = {
34
+ status: "error",
35
+ error: "Tool timeout - Summer Engine may be unresponsive",
36
+ terminalState: "timed_out",
37
+ errorClass: "transient",
38
+ };
39
+ /**
40
+ * Drive an async op to a terminal result by polling. `pollOnce(waitMs)` performs
41
+ * one GET /api/ops/result (long-poll up to waitMs). Returns the engine's apply
42
+ * dict with terminalState/errorClass/appliedSeq merged on top, or a synthetic
43
+ * `timed_out` result if the budget is exhausted.
44
+ */
45
+ export async function pollOpToTerminal(pollOnce, opts) {
46
+ const now = opts.now ?? (() => Date.now());
47
+ const sleep = opts.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
48
+ const pollWaitMs = opts.pollWaitMs ?? 5000;
49
+ const pacingMs = opts.pacingMs ?? 250;
50
+ const noProgressMs = opts.noProgressTimeoutMs ?? opts.totalTimeoutMs;
51
+ const start = now();
52
+ let lastAdvanceAt = start;
53
+ let lastStatus;
54
+ let lastSeq = -1;
55
+ let lastProgress = "";
56
+ for (;;) {
57
+ const env = await pollOnce(pollWaitMs);
58
+ if (isTerminalStatus(env.status)) {
59
+ const out = { ...(env.result ?? {}) };
60
+ if (env.terminalState)
61
+ out.terminalState = env.terminalState;
62
+ if (env.errorClass)
63
+ out.errorClass = env.errorClass;
64
+ if (env.appliedSeq != null)
65
+ out.appliedSeq = env.appliedSeq;
66
+ return out;
67
+ }
68
+ const progressJson = env.progress != null ? JSON.stringify(env.progress) : "";
69
+ const seq = env.appliedSeq ?? -1;
70
+ const advanced = env.status !== lastStatus || seq !== lastSeq || progressJson !== lastProgress;
71
+ const t = now();
72
+ if (advanced) {
73
+ lastAdvanceAt = t;
74
+ lastStatus = env.status;
75
+ lastSeq = seq;
76
+ lastProgress = progressJson;
77
+ }
78
+ if (t - start >= opts.totalTimeoutMs || t - lastAdvanceAt >= noProgressMs) {
79
+ return { ...TIMED_OUT_RESULT };
80
+ }
81
+ await sleep(pacingMs);
82
+ }
83
+ }
@@ -1,6 +1,8 @@
1
1
  export declare function getSummerDir(): string;
2
2
  export declare function getAuthToken(): Promise<string | null>;
3
3
  export declare function saveAuthToken(token: string): Promise<void>;
4
+ export declare function getCloudToken(): Promise<string | null>;
5
+ export declare function saveCloudToken(token: string): Promise<void>;
4
6
  export declare function getUserInfo(): Promise<{
5
7
  id: string;
6
8
  email: string;
package/dist/lib/auth.js CHANGED
@@ -24,6 +24,19 @@ export async function saveAuthToken(token) {
24
24
  await ensureSummerDir();
25
25
  await writeFile(join(SUMMER_DIR, "auth-token"), token, { encoding: "utf-8", mode: 0o600 });
26
26
  }
27
+ export async function getCloudToken() {
28
+ try {
29
+ const token = await readFile(join(SUMMER_DIR, "cloud-token"), "utf-8");
30
+ return token.trim() || null;
31
+ }
32
+ catch {
33
+ return null;
34
+ }
35
+ }
36
+ export async function saveCloudToken(token) {
37
+ await ensureSummerDir();
38
+ await writeFile(join(SUMMER_DIR, "cloud-token"), token, { encoding: "utf-8", mode: 0o600 });
39
+ }
27
40
  export async function getUserInfo() {
28
41
  try {
29
42
  const data = await readFile(join(SUMMER_DIR, "user.json"), "utf-8");
@@ -0,0 +1,79 @@
1
+ import type { CloudManifest } from "./types.js";
2
+ export declare class CloudApiError extends Error {
3
+ readonly status: number;
4
+ readonly code: string;
5
+ readonly details: Record<string, unknown>;
6
+ constructor(status: number, code: string, message: string, details?: Record<string, unknown>);
7
+ }
8
+ export interface PresignedBlob {
9
+ sha256: string;
10
+ status: "exists" | "upload" | "quota_exceeded" | "too_large";
11
+ uploadId?: string;
12
+ url?: string;
13
+ headers?: Record<string, string>;
14
+ message?: string;
15
+ }
16
+ export interface RemoteManifest {
17
+ version: number;
18
+ rulesVersion: number;
19
+ manifest: CloudManifest;
20
+ }
21
+ /**
22
+ * Every server interaction goes through this interface so tests can run the
23
+ * full sync pipeline against an in-memory double (no network, no server).
24
+ */
25
+ export interface CloudApi {
26
+ createCloudProject(body: {
27
+ projectId: string;
28
+ } | {
29
+ name: string;
30
+ }): Promise<{
31
+ projectId: string;
32
+ headVersion: number;
33
+ }>;
34
+ getCloudProject(projectId: string): Promise<{
35
+ projectId: string;
36
+ headVersion: number;
37
+ rulesVersion: number;
38
+ }>;
39
+ checkBlobs(projectId: string, hashes: string[]): Promise<{
40
+ missing: string[];
41
+ }>;
42
+ presignBlobs(projectId: string, blobs: Array<{
43
+ sha256: string;
44
+ size: number;
45
+ }>): Promise<{
46
+ blobs: PresignedBlob[];
47
+ }>;
48
+ completeBlobs(projectId: string, uploads: Array<{
49
+ sha256: string;
50
+ uploadId: string;
51
+ }>): Promise<{
52
+ uploads: Array<{
53
+ sha256: string;
54
+ status: string;
55
+ }>;
56
+ }>;
57
+ presignGet(projectId: string, hashes: string[]): Promise<{
58
+ urls: Record<string, string>;
59
+ missing: string[];
60
+ }>;
61
+ getManifest(projectId: string, version?: number): Promise<RemoteManifest>;
62
+ commitManifest(projectId: string, body: {
63
+ baseVersion: number;
64
+ manifestSha256: string;
65
+ syncId: string;
66
+ rulesVersion: number;
67
+ }): Promise<{
68
+ version: number;
69
+ }>;
70
+ restoreVersion(projectId: string, body: {
71
+ toVersion: number;
72
+ baseVersion: number;
73
+ syncId: string;
74
+ }): Promise<{
75
+ version: number;
76
+ }>;
77
+ }
78
+ export declare function getCloudApi(): CloudApi;
79
+ export declare function setCloudApiForTests(api: CloudApi | null): void;
@@ -0,0 +1,107 @@
1
+ import { getCloudToken } from "../auth.js";
2
+ import { parseManifest } from "./manifest.js";
3
+ const GATEWAY_URL = process.env.SUMMER_GATEWAY_URL || "https://www.summerengine.com";
4
+ export class CloudApiError extends Error {
5
+ status;
6
+ code;
7
+ details;
8
+ constructor(status, code, message, details = {}) {
9
+ super(message);
10
+ this.status = status;
11
+ this.code = code;
12
+ this.details = details;
13
+ }
14
+ }
15
+ async function cloudFetch(path, init = {}) {
16
+ const token = await getCloudToken();
17
+ if (!token) {
18
+ throw new CloudApiError(401, "unauthorized", "Run: summer login");
19
+ }
20
+ const res = await fetch(`${GATEWAY_URL}${path}`, {
21
+ ...init,
22
+ headers: {
23
+ ...(init.body ? { "content-type": "application/json" } : {}),
24
+ ...(init.headers ?? {}),
25
+ authorization: `Bearer ${token}`,
26
+ },
27
+ });
28
+ if (!res.ok) {
29
+ let body = null;
30
+ try {
31
+ body = await res.json();
32
+ }
33
+ catch {
34
+ // ignore
35
+ }
36
+ const error = body?.error ?? {};
37
+ throw new CloudApiError(res.status, error.code ?? "request_failed", error.message ?? `Cloud request failed (${res.status})`, error);
38
+ }
39
+ return (await res.json());
40
+ }
41
+ const realCloudApi = {
42
+ async createCloudProject(body) {
43
+ return cloudFetch("/api/cloud/projects", { method: "POST", body: JSON.stringify(body) });
44
+ },
45
+ async getCloudProject(projectId) {
46
+ return cloudFetch(`/api/cloud/projects/${projectId}`);
47
+ },
48
+ async checkBlobs(projectId, hashes) {
49
+ return cloudFetch("/api/cloud/blobs/check", {
50
+ method: "POST",
51
+ body: JSON.stringify({ projectId, hashes }),
52
+ });
53
+ },
54
+ async presignBlobs(projectId, blobs) {
55
+ return cloudFetch("/api/cloud/blobs/presign", {
56
+ method: "POST",
57
+ body: JSON.stringify({ projectId, blobs }),
58
+ });
59
+ },
60
+ async completeBlobs(projectId, uploads) {
61
+ return cloudFetch("/api/cloud/blobs/complete", {
62
+ method: "POST",
63
+ body: JSON.stringify({ projectId, uploads }),
64
+ });
65
+ },
66
+ async presignGet(projectId, hashes) {
67
+ return cloudFetch("/api/cloud/blobs/presign-get", {
68
+ method: "POST",
69
+ body: JSON.stringify({ projectId, hashes }),
70
+ });
71
+ },
72
+ async getManifest(projectId, version) {
73
+ const suffix = version !== undefined ? `?version=${version}` : "";
74
+ const meta = await cloudFetch(`/api/cloud/projects/${projectId}/manifest${suffix}`);
75
+ if (meta.version === 0) {
76
+ return {
77
+ version: 0,
78
+ rulesVersion: meta.rulesVersion ?? 1,
79
+ manifest: { schemaVersion: 1, projectId, rulesVersion: meta.rulesVersion ?? 1, files: {} },
80
+ };
81
+ }
82
+ const res = await fetch(meta.url);
83
+ if (!res.ok)
84
+ throw new CloudApiError(res.status, "manifest_download_failed", "Could not download manifest");
85
+ const bytes = Buffer.from(await res.arrayBuffer());
86
+ return { version: meta.version, rulesVersion: meta.rulesVersion, manifest: parseManifest(bytes, projectId) };
87
+ },
88
+ async commitManifest(projectId, body) {
89
+ return cloudFetch(`/api/cloud/projects/${projectId}/commit`, {
90
+ method: "POST",
91
+ body: JSON.stringify(body),
92
+ });
93
+ },
94
+ async restoreVersion(projectId, body) {
95
+ return cloudFetch(`/api/cloud/projects/${projectId}/restore-version`, {
96
+ method: "POST",
97
+ body: JSON.stringify(body),
98
+ });
99
+ },
100
+ };
101
+ let activeApi = realCloudApi;
102
+ export function getCloudApi() {
103
+ return activeApi;
104
+ }
105
+ export function setCloudApiForTests(api) {
106
+ activeApi = api ?? realCloudApi;
107
+ }
@@ -0,0 +1,32 @@
1
+ import type { ConflictSetWriter } from "./conflicts.js";
2
+ import type { DiffPlan } from "./diff.js";
3
+ import type { CloudManifest, LocalFileStat } from "./types.js";
4
+ export interface ApplyContext {
5
+ /** Staged blob path per sha256 (downloaded and hash-verified). */
6
+ staged: Map<string, string>;
7
+ /** Actual on-disk byte-name path per NFC manifest key (from the walk). */
8
+ diskPathByKey: Map<string, string>;
9
+ /** Pre-image stats captured at hash time for the apply-time stat check. */
10
+ statByKey: Map<string, LocalFileStat>;
11
+ /** Conflict set writer; losing bytes are preserved through it. */
12
+ conflicts: ConflictSetWriter;
13
+ /** Base manifest (for the project.godot 3-way merge). */
14
+ base: CloudManifest | null;
15
+ /** Called once before the first destructive apply (modify or delete). */
16
+ onBeforeDestructive: () => Promise<void>;
17
+ }
18
+ export interface ApplyResult {
19
+ /** Paths written or deleted on disk (for the engine rescan). */
20
+ appliedPaths: string[];
21
+ notices: string[];
22
+ /** Local hashes preserved as conflict losers (pushed to R2 by the caller). */
23
+ preservedLocalHashes: Map<string, string>;
24
+ }
25
+ /**
26
+ * Two-phase pull, phase 2 (spec 8.7). Fixed order: renames first (case-only
27
+ * renames as two-step), then writes (project.godot and *.tscn last), then
28
+ * deletes. Every write is an atomic rename from staging; every modify or
29
+ * delete is preceded by exactly one full-tree checkpoint and a pre-image
30
+ * stat check that routes drifted files to conflicts instead of overwriting.
31
+ */
32
+ export declare function applyRemotePlan(projectRoot: string, plan: DiffPlan, remote: CloudManifest, context: ApplyContext): Promise<ApplyResult>;
@@ -0,0 +1,294 @@
1
+ import { chmod, copyFile, lstat, readFile, rename, rm, stat, writeFile } from "fs/promises";
2
+ import { basename, dirname, join } from "path";
3
+ import { containedProjectPath } from "./containment.js";
4
+ import { mergeProjectGodot } from "./project-godot-merge.js";
5
+ import { casefoldKey } from "./validate-path.js";
6
+ const RENAME_RETRIES = 7;
7
+ const RENAME_BACKOFF_MS = 50;
8
+ /**
9
+ * Two-phase pull, phase 2 (spec 8.7). Fixed order: renames first (case-only
10
+ * renames as two-step), then writes (project.godot and *.tscn last), then
11
+ * deletes. Every write is an atomic rename from staging; every modify or
12
+ * delete is preceded by exactly one full-tree checkpoint and a pre-image
13
+ * stat check that routes drifted files to conflicts instead of overwriting.
14
+ */
15
+ export async function applyRemotePlan(projectRoot, plan, remote, context) {
16
+ const result = { appliedPaths: [], notices: [], preservedLocalHashes: new Map() };
17
+ const decisionByPath = new Map(plan.decisions.map((d) => [d.path, d]));
18
+ let writes = [...plan.pullPaths, ...plan.conflictPaths].filter((path) => remote.files[path]);
19
+ let deletes = [...plan.deleteLocalPaths];
20
+ if (!writes.length && !deletes.length)
21
+ return result;
22
+ // Checkpoint before ANY destructive apply (spec 11): a modify of an
23
+ // existing file or a delete.
24
+ let checkpointed = false;
25
+ const ensureCheckpoint = async () => {
26
+ if (checkpointed)
27
+ return;
28
+ checkpointed = true;
29
+ await context.onBeforeDestructive();
30
+ };
31
+ for (const path of writes) {
32
+ if (await pathExistsOnDisk(projectRoot, path, context)) {
33
+ await ensureCheckpoint();
34
+ break;
35
+ }
36
+ }
37
+ if (!checkpointed && deletes.length)
38
+ await ensureCheckpoint();
39
+ // Phase: renames. A delete-local plus pull pair with the same content hash
40
+ // and casefold-equal keys is a case-only rename, executed as a two-step
41
+ // rename so case-insensitive volumes never lose the file (spec 8.7).
42
+ const renamed = await applyCaseOnlyRenames(projectRoot, plan, remote, context, decisionByPath, result);
43
+ writes = writes.filter((path) => !renamed.has(path));
44
+ deletes = deletes.filter((path) => !renamed.has(path));
45
+ // Phase: writes, with project.godot and *.tscn last so a partially
46
+ // hydrated project is never scanned with dangling scene dependencies.
47
+ const stagedUseCount = new Map();
48
+ for (const path of writes) {
49
+ const hash = remote.files[path].sha256;
50
+ stagedUseCount.set(hash, (stagedUseCount.get(hash) ?? 0) + 1);
51
+ }
52
+ writes.sort((a, b) => writeOrderRank(a) - writeOrderRank(b) || (a < b ? -1 : a > b ? 1 : 0));
53
+ for (const path of writes) {
54
+ const decision = decisionByPath.get(path);
55
+ const remoteFile = remote.files[path];
56
+ const isConflict = plan.conflictPaths.includes(path);
57
+ // Pre-image stat check (spec 8.7): a file that changed between diff and
58
+ // apply is routed to conflicts instead of being blindly overwritten.
59
+ const drifted = await driftedSinceDiff(projectRoot, path, context);
60
+ if (isConflict || drifted) {
61
+ await preserveLoser(projectRoot, path, decision, remoteFile.sha256, context, result, drifted && !isConflict);
62
+ }
63
+ if (path === "project.godot" && isConflict) {
64
+ const merged = await mergeProjectGodotFromContext(projectRoot, path, decision, remote, context, result);
65
+ if (merged) {
66
+ result.appliedPaths.push(path);
67
+ continue;
68
+ }
69
+ }
70
+ const stagedPath = context.staged.get(remoteFile.sha256);
71
+ if (!stagedPath)
72
+ throw new Error(`Missing staged blob for ${path} (${remoteFile.sha256})`);
73
+ const remaining = stagedUseCount.get(remoteFile.sha256) - 1;
74
+ stagedUseCount.set(remoteFile.sha256, remaining);
75
+ await placeFromStaging(projectRoot, path, stagedPath, remaining > 0, context);
76
+ result.appliedPaths.push(path);
77
+ }
78
+ // Phase: deletes.
79
+ const survivingFold = new Map();
80
+ for (const key of Object.keys(remote.files))
81
+ survivingFold.set(casefoldKey(key), key);
82
+ for (const path of deletes) {
83
+ const decision = decisionByPath.get(path);
84
+ const onDisk = context.diskPathByKey.get(path) ?? join(projectRoot, path);
85
+ // Pre-image check: a delete target edited since the diff is row 14 now
86
+ // (edit beats delete); keep the local file and surface it.
87
+ if (await driftedSinceDiff(projectRoot, path, context)) {
88
+ result.notices.push(`${path}: changed during sync; kept local file (edit beats delete)`);
89
+ continue;
90
+ }
91
+ // Inode-gated casefold skip (spec 8.7): a delete whose casefolded path
92
+ // equals a surviving manifest key is skipped only when a samefile check
93
+ // proves both names map to one on-disk file.
94
+ const survivor = survivingFold.get(casefoldKey(path));
95
+ if (survivor && survivor !== path) {
96
+ if (await isSameFile(onDisk, join(projectRoot, survivor))) {
97
+ continue;
98
+ }
99
+ }
100
+ if (decision?.local?.sha256) {
101
+ result.preservedLocalHashes.set(decision.local.sha256, onDisk);
102
+ }
103
+ await rm(onDisk, { force: true });
104
+ result.appliedPaths.push(path);
105
+ }
106
+ for (const path of plan.keepLocalPaths) {
107
+ result.notices.push(`${path}: deleted in cloud but edited here; keeping local file (edit beats delete)`);
108
+ }
109
+ for (const path of plan.restoredRemotePaths) {
110
+ result.notices.push(`${path}: deleted here but edited in cloud; restored the cloud file (edit beats delete)`);
111
+ }
112
+ return result;
113
+ }
114
+ function writeOrderRank(path) {
115
+ if (path === "project.godot")
116
+ return 2;
117
+ if (path.endsWith(".tscn"))
118
+ return 1;
119
+ return 0;
120
+ }
121
+ async function pathExistsOnDisk(projectRoot, path, context) {
122
+ const onDisk = context.diskPathByKey.get(path) ?? join(projectRoot, path);
123
+ try {
124
+ await lstat(onDisk);
125
+ return true;
126
+ }
127
+ catch {
128
+ return false;
129
+ }
130
+ }
131
+ async function driftedSinceDiff(projectRoot, path, context) {
132
+ const recorded = context.statByKey.get(path);
133
+ if (!recorded)
134
+ return false;
135
+ const onDisk = context.diskPathByKey.get(path) ?? join(projectRoot, path);
136
+ try {
137
+ const info = await lstat(onDisk, { bigint: true });
138
+ return Number(info.size) !== recorded.size || info.mtimeNs.toString() !== recorded.mtimeNs;
139
+ }
140
+ catch {
141
+ return false; // Gone since the diff; nothing to preserve.
142
+ }
143
+ }
144
+ async function preserveLoser(projectRoot, path, decision, remoteSha256, context, result, drifted) {
145
+ const onDisk = context.diskPathByKey.get(path) ?? join(projectRoot, path);
146
+ const localSha = decision?.local?.sha256;
147
+ const preserved = await context.conflicts.preserve(path, {
148
+ localSha256: localSha,
149
+ remoteSha256,
150
+ winner: "remote",
151
+ note: drifted ? "changed between diff and apply" : undefined,
152
+ }, onDisk);
153
+ if (preserved) {
154
+ if (localSha)
155
+ result.preservedLocalHashes.set(localSha, join(context.conflicts.setDir, path));
156
+ result.notices.push(`${path}: cloud version won; your bytes are preserved (summer cloud conflicts restore ${path})`);
157
+ // Sidecar bytes ride inside the conflict set for reference, never as a
158
+ // second conflict copy in the scanned tree (spec 8.4).
159
+ for (const suffix of [".import", ".uid"]) {
160
+ const sidecar = `${path}${suffix}`;
161
+ const sidecarOnDisk = context.diskPathByKey.get(sidecar) ?? join(projectRoot, sidecar);
162
+ await context.conflicts.preserve(sidecar, { winner: "remote", sidecar: true }, sidecarOnDisk);
163
+ }
164
+ }
165
+ }
166
+ async function mergeProjectGodotFromContext(projectRoot, path, decision, remote, context, result) {
167
+ const baseHash = context.base?.files[path]?.sha256;
168
+ const baseStaged = baseHash ? context.staged.get(baseHash) : undefined;
169
+ const remoteStaged = context.staged.get(remote.files[path].sha256);
170
+ const onDisk = context.diskPathByKey.get(path) ?? join(projectRoot, path);
171
+ if (!remoteStaged)
172
+ return false;
173
+ let baseText = "";
174
+ let localText = "";
175
+ let remoteText = "";
176
+ try {
177
+ baseText = baseStaged ? await readFile(baseStaged, "utf8") : "";
178
+ localText = await readFile(onDisk, "utf8");
179
+ remoteText = await readFile(remoteStaged, "utf8");
180
+ }
181
+ catch {
182
+ return false; // Fall through to whole-file remote-wins via the caller.
183
+ }
184
+ const merged = mergeProjectGodot(baseText, localText, remoteText);
185
+ if (merged.fallback) {
186
+ result.notices.push("project.godot: could not parse all three versions; cloud file won whole-file, local copy preserved");
187
+ return false;
188
+ }
189
+ for (const losing of merged.losingValues) {
190
+ const where = losing.section ? `[${losing.section}] ` : "";
191
+ result.notices.push(`project.godot: ${where}${losing.key}: cloud value won; local value was: ${losing.localValue}`);
192
+ }
193
+ const target = await containedProjectPath(projectRoot, path);
194
+ const tmp = join(dirname(target), `.${basename(target)}.summer-cloud-tmp`);
195
+ await writeFile(tmp, merged.merged, "utf8");
196
+ await renameWithRetry(tmp, target);
197
+ return true;
198
+ }
199
+ /**
200
+ * Atomic placement from staging (spec 8.7): clear a read-only attribute on
201
+ * the destination, then rename from staging into place (same volume). When
202
+ * the same blob lands at several paths, all but the last use copy.
203
+ */
204
+ async function placeFromStaging(projectRoot, path, stagedPath, keepStaged, context) {
205
+ const target = await containedProjectPath(projectRoot, path);
206
+ await clearReadOnly(target);
207
+ // The walker's byte-name map may point at an NFD or different-case name for
208
+ // this key; replace that exact on-disk file so we never strand a twin.
209
+ const onDisk = context.diskPathByKey.get(path);
210
+ if (onDisk && onDisk !== target) {
211
+ await clearReadOnly(onDisk);
212
+ await rm(onDisk, { force: true });
213
+ }
214
+ if (keepStaged) {
215
+ const tmp = join(dirname(target), `.${basename(target)}.summer-cloud-tmp`);
216
+ await copyFile(stagedPath, tmp);
217
+ await renameWithRetry(tmp, target);
218
+ }
219
+ else {
220
+ await renameWithRetry(stagedPath, target);
221
+ }
222
+ }
223
+ async function clearReadOnly(path) {
224
+ try {
225
+ const info = await stat(path);
226
+ if ((info.mode & 0o200) === 0) {
227
+ await chmod(path, info.mode | 0o200);
228
+ }
229
+ }
230
+ catch {
231
+ // Missing file: nothing to clear.
232
+ }
233
+ }
234
+ /** Windows retry ladder (spec 8.7): EPERM/EBUSY/EACCES, 50 ms doubling, 7 tries. */
235
+ async function renameWithRetry(from, to) {
236
+ let delay = RENAME_BACKOFF_MS;
237
+ for (let attempt = 0;; attempt += 1) {
238
+ try {
239
+ await rename(from, to);
240
+ return;
241
+ }
242
+ catch (err) {
243
+ const code = err.code;
244
+ const retryable = code === "EPERM" || code === "EBUSY" || code === "EACCES";
245
+ if (!retryable || attempt >= RENAME_RETRIES - 1)
246
+ throw err;
247
+ await sleep(delay);
248
+ delay *= 2;
249
+ }
250
+ }
251
+ }
252
+ async function applyCaseOnlyRenames(projectRoot, plan, remote, context, decisionByPath, result) {
253
+ const done = new Set();
254
+ const deleteByFold = new Map();
255
+ for (const path of plan.deleteLocalPaths)
256
+ deleteByFold.set(casefoldKey(path), path);
257
+ for (const pullPath of plan.pullPaths) {
258
+ const oldPath = deleteByFold.get(casefoldKey(pullPath));
259
+ if (!oldPath || oldPath === pullPath)
260
+ continue;
261
+ const oldDecision = decisionByPath.get(oldPath);
262
+ const remoteFile = remote.files[pullPath];
263
+ if (!remoteFile || oldDecision?.local?.sha256 !== remoteFile.sha256)
264
+ continue;
265
+ if (await driftedSinceDiff(projectRoot, oldPath, context))
266
+ continue;
267
+ const source = context.diskPathByKey.get(oldPath) ?? join(projectRoot, oldPath);
268
+ const target = await containedProjectPath(projectRoot, pullPath);
269
+ const tmp = join(dirname(target), `.${basename(target)}.summer-cloud-rename-tmp`);
270
+ try {
271
+ await renameWithRetry(source, tmp);
272
+ await renameWithRetry(tmp, target);
273
+ }
274
+ catch {
275
+ continue; // Fall back to download-and-delete handling for this pair.
276
+ }
277
+ done.add(pullPath);
278
+ done.add(oldPath);
279
+ result.appliedPaths.push(pullPath);
280
+ }
281
+ return done;
282
+ }
283
+ async function isSameFile(a, b) {
284
+ try {
285
+ const [ia, ib] = await Promise.all([stat(a, { bigint: true }), stat(b, { bigint: true })]);
286
+ return ia.dev === ib.dev && ia.ino === ib.ino;
287
+ }
288
+ catch {
289
+ return false;
290
+ }
291
+ }
292
+ function sleep(ms) {
293
+ return new Promise((resolve) => setTimeout(resolve, ms));
294
+ }
@@ -0,0 +1,26 @@
1
+ export interface CheckpointResult {
2
+ ref: string;
3
+ commit: string;
4
+ }
5
+ export declare function summerGitDir(projectRoot: string): string;
6
+ export declare function isGitAvailable(): Promise<boolean>;
7
+ export declare function checkpointStamp(date?: Date): string;
8
+ /**
9
+ * Full-tree SummerGit checkpoint before any destructive apply (spec 11).
10
+ * Plumbing only: stage all into a private index, write-tree, commit-tree,
11
+ * update-ref. Keeps the last KEEP_CHECKPOINTS refs.
12
+ */
13
+ export declare function createCheckpoint(projectRoot: string, projectId: string): Promise<CheckpointResult>;
14
+ export declare function listCheckpoints(projectRoot: string, projectId: string): Promise<string[]>;
15
+ export interface CheckpointRestoreResult {
16
+ ref: string;
17
+ restoredFiles: string[];
18
+ /** Files present on disk but not in the checkpoint; restore does not delete them. */
19
+ extraneousFiles: string[];
20
+ }
21
+ /**
22
+ * Restores a checkpoint's bytes (spec 11). checkout-index restores bytes but
23
+ * does not delete files a bad sync ADDED, so the result lists extraneous
24
+ * files for the user or agent to remove.
25
+ */
26
+ export declare function restoreCheckpoint(projectRoot: string, projectId: string, refOrStamp: string, currentTrackedFiles: readonly string[]): Promise<CheckpointRestoreResult>;