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,105 @@
1
+ import { createHash } from "crypto";
2
+ import { mkdir, readFile, writeFile } from "fs/promises";
3
+ import { join } from "path";
4
+ import { EngineApiClient } from "../api-client.js";
5
+ import { checkEngineHealth, getApiPort, getApiToken } from "../engine.js";
6
+ /**
7
+ * Engine coordination (spec 8.6.1 and 8.7). All calls degrade gracefully: an
8
+ * unreachable engine never blocks a sync; the stability check and conflict
9
+ * routing cover the gaps.
10
+ */
11
+ /** sha256 hex of the stable .summer/local/.project_id (mirrors SummerEngineIdentity::project_id_hash). */
12
+ export async function readProjectIdHash(projectRoot) {
13
+ try {
14
+ const id = (await readFile(join(projectRoot, ".summer", "local", ".project_id"), "utf8")).trim();
15
+ if (!id)
16
+ return null;
17
+ return createHash("sha256").update(id, "utf8").digest("hex");
18
+ }
19
+ catch {
20
+ return null;
21
+ }
22
+ }
23
+ export async function readProjectPathMarker(projectRoot) {
24
+ try {
25
+ const marker = (await readFile(join(projectRoot, ".summer", "local", ".project_path"), "utf8")).trim();
26
+ return marker || null;
27
+ }
28
+ catch {
29
+ return null;
30
+ }
31
+ }
32
+ export async function writeProjectPathMarker(projectRoot) {
33
+ const dir = join(projectRoot, ".summer", "local");
34
+ await mkdir(dir, { recursive: true });
35
+ await writeFile(join(dir, ".project_path"), projectRoot, "utf8");
36
+ }
37
+ async function connectForProject(projectRoot) {
38
+ const projectIdHash = await readProjectIdHash(projectRoot);
39
+ if (!projectIdHash)
40
+ return null;
41
+ try {
42
+ const port = await getApiPort();
43
+ const token = await getApiToken();
44
+ if (!token)
45
+ return null;
46
+ const health = await checkEngineHealth(port);
47
+ if (!health)
48
+ return null;
49
+ // A different project's engine instance must never be asked to save or
50
+ // rescan; the executor rejects mismatches too, but skip the round trip.
51
+ if (health.projectIdHash && health.projectIdHash !== projectIdHash)
52
+ return null;
53
+ return { client: new EngineApiClient(port, token), projectIdHash, scene: health.scene };
54
+ }
55
+ catch {
56
+ return null;
57
+ }
58
+ }
59
+ /**
60
+ * Before hashing (spec 8.6.1): ask the running engine to flush dirty scenes
61
+ * to disk so the walk hashes what the user actually sees.
62
+ */
63
+ export async function saveDirtyScenesIfRunning(projectRoot) {
64
+ const session = await connectForProject(projectRoot);
65
+ if (!session)
66
+ return false;
67
+ try {
68
+ await session.client.executeOps([{ op: "SaveDirtyScenes" }], { projectIdHash: session.projectIdHash });
69
+ return true;
70
+ }
71
+ catch {
72
+ return false;
73
+ }
74
+ }
75
+ /**
76
+ * After apply (spec 8.7): trigger a filesystem rescan and reload the open
77
+ * scene if the pull replaced it. ScanChanges is the engine-side hook; older
78
+ * engines without it simply reject the op and the editor's own focus scan
79
+ * picks the changes up.
80
+ */
81
+ export async function notifyEngineAfterApply(projectRoot, appliedPaths) {
82
+ if (!appliedPaths.length)
83
+ return;
84
+ const session = await connectForProject(projectRoot);
85
+ if (!session)
86
+ return;
87
+ try {
88
+ await session.client.executeOps([{ op: "ScanChanges" }], { projectIdHash: session.projectIdHash });
89
+ }
90
+ catch {
91
+ // Engine build without the ScanChanges op; non-fatal.
92
+ }
93
+ try {
94
+ const scene = session.scene;
95
+ if (scene && scene.startsWith("res://")) {
96
+ const sceneRel = scene.slice("res://".length);
97
+ if (appliedPaths.includes(sceneRel)) {
98
+ await session.client.executeOps([{ op: "OpenScene", path: scene }], { projectIdHash: session.projectIdHash });
99
+ }
100
+ }
101
+ }
102
+ catch {
103
+ // Reload is best-effort; the editor reloads from disk on focus.
104
+ }
105
+ }
@@ -0,0 +1,23 @@
1
+ export interface HashCacheEntry {
2
+ sha256: string;
3
+ size: number;
4
+ mtimeNs: string;
5
+ ctimeNs: string;
6
+ inode: string;
7
+ }
8
+ export type HashCache = Record<string, HashCacheEntry>;
9
+ export declare function readHashCache(projectRoot: string): Promise<HashCache>;
10
+ export declare function writeHashCache(projectRoot: string, cache: HashCache): Promise<void>;
11
+ export interface StatLike {
12
+ size: bigint;
13
+ mtimeNs: bigint;
14
+ ctimeNs: bigint;
15
+ ino: bigint;
16
+ }
17
+ /**
18
+ * Cache hit rule (spec 6.2): rehash when any field differs, and apply Git's
19
+ * racily-clean rule: any file whose mtime is at or after the sync start time
20
+ * is rehashed regardless of cache.
21
+ */
22
+ export declare function cacheHit(entry: HashCacheEntry | undefined, stat: StatLike, syncStartMs: number): boolean;
23
+ export declare function cacheEntryFor(sha256: string, stat: StatLike): HashCacheEntry;
@@ -0,0 +1,44 @@
1
+ import { readFile } from "fs/promises";
2
+ import { hashCachePath, writeJsonAtomic } from "./paths.js";
3
+ export async function readHashCache(projectRoot) {
4
+ try {
5
+ const parsed = JSON.parse(await readFile(hashCachePath(projectRoot), "utf8"));
6
+ return parsed && typeof parsed === "object" ? parsed : {};
7
+ }
8
+ catch {
9
+ return {};
10
+ }
11
+ }
12
+ export async function writeHashCache(projectRoot, cache) {
13
+ await writeJsonAtomic(hashCachePath(projectRoot), cache);
14
+ }
15
+ /**
16
+ * Cache hit rule (spec 6.2): rehash when any field differs, and apply Git's
17
+ * racily-clean rule: any file whose mtime is at or after the sync start time
18
+ * is rehashed regardless of cache.
19
+ */
20
+ export function cacheHit(entry, stat, syncStartMs) {
21
+ if (!entry)
22
+ return false;
23
+ if (BigInt(entry.size) !== stat.size)
24
+ return false;
25
+ if (entry.mtimeNs !== stat.mtimeNs.toString())
26
+ return false;
27
+ if (entry.ctimeNs !== stat.ctimeNs.toString())
28
+ return false;
29
+ if (entry.inode !== stat.ino.toString())
30
+ return false;
31
+ const mtimeMs = Number(stat.mtimeNs / 1000000n);
32
+ if (mtimeMs >= syncStartMs)
33
+ return false;
34
+ return true;
35
+ }
36
+ export function cacheEntryFor(sha256, stat) {
37
+ return {
38
+ sha256,
39
+ size: Number(stat.size),
40
+ mtimeNs: stat.mtimeNs.toString(),
41
+ ctimeNs: stat.ctimeNs.toString(),
42
+ inode: stat.ino.toString(),
43
+ };
44
+ }
@@ -0,0 +1,10 @@
1
+ import { type TrackedRules } from "./rules.js";
2
+ import type { WalkResult } from "./types.js";
3
+ export interface WalkOptions {
4
+ /** Sync start time for the racily-clean rule. Defaults to now. */
5
+ syncStartMs?: number;
6
+ /** Skip reading/writing hash-cache.json (used by tests). */
7
+ noCache?: boolean;
8
+ }
9
+ export declare function walkProject(projectRoot: string, options?: WalkOptions): Promise<WalkResult>;
10
+ export type { TrackedRules };
@@ -0,0 +1,111 @@
1
+ import { createHash } from "crypto";
2
+ import { open, readdir } from "fs/promises";
3
+ import { join } from "path";
4
+ import { cacheEntryFor, cacheHit, readHashCache, writeHashCache } from "./hash-cache.js";
5
+ import { isHardExcludedDir, isHardExcludedFile, isIgnoredByRules, loadTrackedRules } from "./rules.js";
6
+ import { findPathCollisions, validateCloudPath } from "./validate-path.js";
7
+ export async function walkProject(projectRoot, options = {}) {
8
+ const rules = await loadTrackedRules(projectRoot);
9
+ const syncStartMs = options.syncStartMs ?? Date.now();
10
+ const cache = options.noCache ? {} : await readHashCache(projectRoot);
11
+ const nextCache = {};
12
+ const files = {};
13
+ const fileByHash = new Map();
14
+ const diskPathByKey = new Map();
15
+ const statByKey = new Map();
16
+ const skippedSymlinks = [];
17
+ const unstablePaths = [];
18
+ async function walk(dir, relKey) {
19
+ const entries = await readdir(dir, { withFileTypes: true });
20
+ const siblingNames = new Set(entries.map((entry) => entry.name.normalize("NFC")));
21
+ for (const entry of entries) {
22
+ // Manifest keys are NFC; diskPathByKey keeps the actual byte names for I/O.
23
+ const nfcName = entry.name.normalize("NFC");
24
+ const fullPath = join(dir, entry.name);
25
+ const key = relKey ? `${relKey}/${nfcName}` : nfcName;
26
+ if (!key || key === "summer-cloud.json")
27
+ continue;
28
+ if (entry.isSymbolicLink()) {
29
+ skippedSymlinks.push(key);
30
+ continue;
31
+ }
32
+ if (entry.isDirectory()) {
33
+ if (isHardExcludedDir(key, nfcName))
34
+ continue;
35
+ if (isIgnoredByRules(key, true, rules))
36
+ continue;
37
+ await walk(fullPath, key);
38
+ continue;
39
+ }
40
+ if (!entry.isFile())
41
+ continue;
42
+ if (isHardExcludedFile(key, nfcName, siblingNames))
43
+ continue;
44
+ if (isIgnoredByRules(key, false, rules))
45
+ continue;
46
+ const valid = validateCloudPath(key);
47
+ if (!valid.ok) {
48
+ throw new Error(`Refusing to sync invalid path ${key}: ${valid.reason}`);
49
+ }
50
+ const hashed = await hashStableFile(fullPath, cache[key], syncStartMs);
51
+ if (!hashed) {
52
+ unstablePaths.push(key);
53
+ continue;
54
+ }
55
+ files[key] = { sha256: hashed.sha256, size: hashed.size };
56
+ nextCache[key] = cacheEntryFor(hashed.sha256, hashed.stat);
57
+ diskPathByKey.set(key, fullPath);
58
+ statByKey.set(key, {
59
+ size: hashed.size,
60
+ mtimeNs: hashed.stat.mtimeNs.toString(),
61
+ inode: hashed.stat.ino.toString(),
62
+ });
63
+ if (!fileByHash.has(hashed.sha256)) {
64
+ fileByHash.set(hashed.sha256, fullPath);
65
+ }
66
+ }
67
+ }
68
+ await walk(projectRoot, "");
69
+ const collisions = findPathCollisions(Object.keys(files));
70
+ if (collisions.length) {
71
+ const lines = collisions.map((c) => `${c.a} and ${c.b} (${c.kind})`).join("; ");
72
+ throw new Error(`On-disk file names collide after normalization and cannot sync: ${lines}. Rename one of each pair.`);
73
+ }
74
+ if (!options.noCache) {
75
+ await writeHashCache(projectRoot, nextCache).catch(() => { });
76
+ }
77
+ return { files, fileByHash, diskPathByKey, statByKey, skippedSymlinks, unstablePaths };
78
+ }
79
+ /**
80
+ * Per-file stability check (spec 8.6.2): fstat the open fd before hashing,
81
+ * hash, fstat again; on any difference re-read; after 3 attempts return null
82
+ * so the caller excludes the file from THIS push and carries forward its
83
+ * previous manifest entry. Never throws for instability.
84
+ */
85
+ async function hashStableFile(path, cached, syncStartMs) {
86
+ for (let attempt = 0; attempt < 3; attempt += 1) {
87
+ const handle = await open(path, "r");
88
+ try {
89
+ const before = statLike(await handle.stat({ bigint: true }));
90
+ if (cacheHit(cached, before, syncStartMs)) {
91
+ return { sha256: cached.sha256, size: Number(before.size), stat: before };
92
+ }
93
+ const bytes = await handle.readFile();
94
+ const after = statLike(await handle.stat({ bigint: true }));
95
+ if (before.size === after.size && before.mtimeNs === after.mtimeNs && before.ino === after.ino) {
96
+ return {
97
+ sha256: createHash("sha256").update(bytes).digest("hex"),
98
+ size: bytes.length,
99
+ stat: after,
100
+ };
101
+ }
102
+ }
103
+ finally {
104
+ await handle.close();
105
+ }
106
+ }
107
+ return null;
108
+ }
109
+ function statLike(stat) {
110
+ return { size: stat.size, mtimeNs: stat.mtimeNs, ctimeNs: stat.ctimeNs, ino: stat.ino };
111
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Blob transport boundary. Production talks to presigned R2 URLs; tests swap
3
+ * in a double so no test ever needs the network.
4
+ */
5
+ export interface BlobTransport {
6
+ /** Streams a GET to a file, hashing as it goes. Returns sha256 hex and size. */
7
+ getToFile(url: string, destPath: string): Promise<{
8
+ sha256: string;
9
+ size: number;
10
+ status: number;
11
+ }>;
12
+ /** PUTs bytes from a file or buffer. Returns the HTTP status. */
13
+ put(url: string, headers: Record<string, string>, body: Buffer): Promise<number>;
14
+ }
15
+ export declare function getBlobTransport(): BlobTransport;
16
+ export declare function setBlobTransportForTests(transport: BlobTransport | null): void;
17
+ export declare function readFileBytes(path: string): Promise<Buffer>;
@@ -0,0 +1,47 @@
1
+ import { createHash } from "crypto";
2
+ import { createWriteStream } from "fs";
3
+ import { open, readFile } from "fs/promises";
4
+ import { Readable } from "stream";
5
+ import { pipeline } from "stream/promises";
6
+ const realTransport = {
7
+ async getToFile(url, destPath) {
8
+ const res = await fetch(url);
9
+ if (!res.ok || !res.body) {
10
+ return { sha256: "", size: 0, status: res.status };
11
+ }
12
+ const hash = createHash("sha256");
13
+ let size = 0;
14
+ const out = createWriteStream(destPath, { mode: 0o600 });
15
+ await pipeline(Readable.fromWeb(res.body), async function* (source) {
16
+ for await (const chunk of source) {
17
+ const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
18
+ hash.update(buf);
19
+ size += buf.length;
20
+ yield buf;
21
+ }
22
+ }, out);
23
+ // fsync so a staged blob survives a crash before apply.
24
+ const handle = await open(destPath, "r+");
25
+ try {
26
+ await handle.sync();
27
+ }
28
+ finally {
29
+ await handle.close();
30
+ }
31
+ return { sha256: hash.digest("hex"), size, status: res.status };
32
+ },
33
+ async put(url, headers, body) {
34
+ const res = await fetch(url, { method: "PUT", headers, body: new Uint8Array(body) });
35
+ return res.status;
36
+ },
37
+ };
38
+ let activeTransport = realTransport;
39
+ export function getBlobTransport() {
40
+ return activeTransport;
41
+ }
42
+ export function setBlobTransportForTests(transport) {
43
+ activeTransport = transport ?? realTransport;
44
+ }
45
+ export async function readFileBytes(path) {
46
+ return readFile(path);
47
+ }
@@ -0,0 +1 @@
1
+ export declare function lockPath(projectRoot: string): string;
@@ -0,0 +1,5 @@
1
+ import { join } from "path";
2
+ import { localCloudDir } from "./paths.js";
3
+ export function lockPath(projectRoot) {
4
+ return join(localCloudDir(projectRoot), "lock");
5
+ }
@@ -0,0 +1,14 @@
1
+ export interface CloudLock {
2
+ release(): Promise<void>;
3
+ }
4
+ type Face = "cli" | "mcp" | "engine";
5
+ /**
6
+ * Per-project sync mutex (spec 8.2). O_EXCL create, 30s heartbeat, stale only
7
+ * by heartbeat age (120s). Two fixed races: the payload is written to the fd
8
+ * before close and verified by token after create (a contender that reads an
9
+ * empty just-created file treats parse failure as FRESH while the file is
10
+ * young); takeover goes through an atomic rename so two waiters can never
11
+ * both remove the same stale lock and both proceed.
12
+ */
13
+ export declare function acquireCloudLock(projectRoot: string, face?: Face): Promise<CloudLock>;
14
+ export {};
@@ -0,0 +1,143 @@
1
+ import { randomUUID } from "crypto";
2
+ import { open, readFile, rename, rm, stat, writeFile } from "fs/promises";
3
+ import { lockPath } from "./lock-path.js";
4
+ const STALE_MS = 120_000;
5
+ const WAIT_MS = 10_000;
6
+ const HEARTBEAT_MS = 30_000;
7
+ /**
8
+ * Per-project sync mutex (spec 8.2). O_EXCL create, 30s heartbeat, stale only
9
+ * by heartbeat age (120s). Two fixed races: the payload is written to the fd
10
+ * before close and verified by token after create (a contender that reads an
11
+ * empty just-created file treats parse failure as FRESH while the file is
12
+ * young); takeover goes through an atomic rename so two waiters can never
13
+ * both remove the same stale lock and both proceed.
14
+ */
15
+ export async function acquireCloudLock(projectRoot, face = "cli") {
16
+ const path = lockPath(projectRoot);
17
+ const started = Date.now();
18
+ const token = randomUUID();
19
+ while (Date.now() - started < WAIT_MS) {
20
+ const acquired = await tryCreate(path, face, token);
21
+ if (acquired)
22
+ return acquired;
23
+ if (await isStale(path)) {
24
+ await takeoverStale(path);
25
+ continue;
26
+ }
27
+ await sleep(250);
28
+ }
29
+ throw new Error("Summer Cloud sync already in progress");
30
+ }
31
+ async function tryCreate(path, face, token) {
32
+ const startedAt = new Date().toISOString();
33
+ let file;
34
+ try {
35
+ file = await open(path, "wx", 0o600);
36
+ }
37
+ catch {
38
+ return null;
39
+ }
40
+ try {
41
+ await file.writeFile(JSON.stringify(payload(face, token, startedAt)), { encoding: "utf8" });
42
+ await file.sync();
43
+ }
44
+ finally {
45
+ await file.close();
46
+ }
47
+ // Re-check ownership: a racing waiter may have taken the file over between
48
+ // our create and write. If the token on disk is not ours, we lost.
49
+ if (!(await ownsLock(path, token))) {
50
+ return null;
51
+ }
52
+ const heartbeat = setInterval(() => {
53
+ void writeFile(path, JSON.stringify(payload(face, token, startedAt)), { encoding: "utf8" }).catch(() => { });
54
+ }, HEARTBEAT_MS);
55
+ heartbeat.unref?.();
56
+ return {
57
+ async release() {
58
+ clearInterval(heartbeat);
59
+ if (await ownsLock(path, token)) {
60
+ await rm(path, { force: true });
61
+ }
62
+ },
63
+ };
64
+ }
65
+ function payload(face, token, startedAt) {
66
+ return { pid: process.pid, face, token, startedAt, heartbeatAt: new Date().toISOString() };
67
+ }
68
+ async function ownsLock(path, token) {
69
+ try {
70
+ const raw = JSON.parse(await readFile(path, "utf8"));
71
+ return raw.token === token;
72
+ }
73
+ catch {
74
+ return false;
75
+ }
76
+ }
77
+ async function isStale(path) {
78
+ let raw;
79
+ try {
80
+ raw = await readFile(path, "utf8");
81
+ }
82
+ catch {
83
+ // Gone already; the create loop will race for it.
84
+ return false;
85
+ }
86
+ try {
87
+ const parsed = JSON.parse(raw);
88
+ const heartbeat = parsed.heartbeatAt ? Date.parse(parsed.heartbeatAt) : NaN;
89
+ if (!Number.isFinite(heartbeat))
90
+ throw new Error("no heartbeat");
91
+ return Date.now() - heartbeat > STALE_MS;
92
+ }
93
+ catch {
94
+ // Unparseable: probably a holder between open('wx') and write. Treat as
95
+ // fresh while the file is young; stale only once the mtime ages out.
96
+ try {
97
+ const info = await stat(path);
98
+ return Date.now() - info.mtimeMs > STALE_MS;
99
+ }
100
+ catch {
101
+ return false;
102
+ }
103
+ }
104
+ }
105
+ async function takeoverStale(path) {
106
+ // Atomic claim: only one waiter wins the rename; the loser's rename fails
107
+ // with ENOENT and it simply retries the exclusive create.
108
+ const claimed = `${path}.takeover.${process.pid}.${Date.now()}`;
109
+ try {
110
+ await rename(path, claimed);
111
+ }
112
+ catch {
113
+ return;
114
+ }
115
+ // Verify what we actually grabbed. If it turned fresh under us (the holder
116
+ // heartbeated between our staleness read and the rename), restore it
117
+ // without ever clobbering a lock someone else created in the gap.
118
+ try {
119
+ const content = await readFile(claimed, "utf8");
120
+ const parsed = JSON.parse(content);
121
+ const heartbeat = parsed.heartbeatAt ? Date.parse(parsed.heartbeatAt) : NaN;
122
+ if (Number.isFinite(heartbeat) && Date.now() - heartbeat <= STALE_MS) {
123
+ try {
124
+ const file = await open(path, "wx", 0o600);
125
+ await file.writeFile(content, { encoding: "utf8" });
126
+ await file.close();
127
+ }
128
+ catch {
129
+ // Someone else already created a new lock; the displaced holder's
130
+ // next heartbeat write will contend through ownsLock on release.
131
+ }
132
+ await rm(claimed, { force: true });
133
+ return;
134
+ }
135
+ }
136
+ catch {
137
+ // Unparseable claimed file: it aged past staleness to get here; discard.
138
+ }
139
+ await rm(claimed, { force: true });
140
+ }
141
+ function sleep(ms) {
142
+ return new Promise((resolve) => setTimeout(resolve, ms));
143
+ }
@@ -0,0 +1,6 @@
1
+ import type { CloudManifest } from "./types.js";
2
+ export declare function sha256Hex(bytes: Buffer | Uint8Array | string): string;
3
+ /** Spec 6.3: keys sorted bytewise (UTF-8), no whitespace, then gzip. */
4
+ export declare function compareKeysBytewise(a: string, b: string): number;
5
+ export declare function serializeManifest(manifest: CloudManifest): Buffer;
6
+ export declare function parseManifest(bytes: Buffer, expectedProjectId?: string): CloudManifest;
@@ -0,0 +1,41 @@
1
+ import { createHash } from "crypto";
2
+ import { gunzipSync, gzipSync } from "zlib";
3
+ import { findPathCollisions, validateCloudPath } from "./validate-path.js";
4
+ export function sha256Hex(bytes) {
5
+ return createHash("sha256").update(bytes).digest("hex");
6
+ }
7
+ /** Spec 6.3: keys sorted bytewise (UTF-8), no whitespace, then gzip. */
8
+ export function compareKeysBytewise(a, b) {
9
+ return Buffer.compare(Buffer.from(a, "utf8"), Buffer.from(b, "utf8"));
10
+ }
11
+ export function serializeManifest(manifest) {
12
+ const files = Object.fromEntries(Object.entries(manifest.files).sort(([a], [b]) => compareKeysBytewise(a, b)));
13
+ const document = {
14
+ schemaVersion: manifest.schemaVersion,
15
+ projectId: manifest.projectId,
16
+ rulesVersion: manifest.rulesVersion,
17
+ files,
18
+ };
19
+ return gzipSync(Buffer.from(JSON.stringify(document), "utf8"));
20
+ }
21
+ export function parseManifest(bytes, expectedProjectId) {
22
+ const parsed = JSON.parse(gunzipSync(bytes).toString("utf8"));
23
+ if (parsed.schemaVersion !== 1 || typeof parsed.projectId !== "string" || typeof parsed.files !== "object") {
24
+ throw new Error("Invalid Summer Cloud manifest");
25
+ }
26
+ if (expectedProjectId && parsed.projectId !== expectedProjectId) {
27
+ throw new Error("Manifest projectId mismatch");
28
+ }
29
+ const keys = Object.keys(parsed.files);
30
+ for (const path of keys) {
31
+ const valid = validateCloudPath(path);
32
+ if (!valid.ok)
33
+ throw new Error(`Invalid manifest path ${path}: ${valid.reason}`);
34
+ }
35
+ const collisions = findPathCollisions(keys);
36
+ if (collisions.length) {
37
+ const first = collisions[0];
38
+ throw new Error(`Manifest path collision (${first.kind}): ${first.a} and ${first.b}`);
39
+ }
40
+ return parsed;
41
+ }
@@ -0,0 +1,20 @@
1
+ import type { BaseState, CloudBinding, PullJournal } from "./types.js";
2
+ export declare function resolveProjectRoot(project?: string): string;
3
+ export declare function bindingPath(projectRoot: string): string;
4
+ export declare function localCloudDir(projectRoot: string): string;
5
+ export declare function basePath(projectRoot: string): string;
6
+ export declare function journalPath(projectRoot: string): string;
7
+ export declare function hashCachePath(projectRoot: string): string;
8
+ export declare function stagingDir(projectRoot: string): string;
9
+ export declare function conflictsDir(projectRoot: string): string;
10
+ export declare function ensureCloudDirs(projectRoot: string): Promise<void>;
11
+ export declare function readBinding(projectRoot: string): Promise<CloudBinding | null>;
12
+ export declare function writeBinding(projectRoot: string, binding: CloudBinding): Promise<void>;
13
+ export declare function readBase(projectRoot: string): Promise<BaseState | null>;
14
+ export declare function writeBase(projectRoot: string, base: BaseState): Promise<void>;
15
+ export declare function readJournal(projectRoot: string): Promise<PullJournal | null>;
16
+ export declare function writeJournal(projectRoot: string, journal: PullJournal): Promise<void>;
17
+ export declare function removeJournal(projectRoot: string): Promise<void>;
18
+ export declare function writeJsonAtomic(path: string, value: unknown): Promise<void>;
19
+ export declare function projectDisplayName(projectRoot: string): Promise<string>;
20
+ export declare function isCloudEnabled(projectRoot: string): boolean;