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,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;
@@ -0,0 +1,90 @@
1
+ import { mkdir, readFile, writeFile } from "fs/promises";
2
+ import { existsSync } from "fs";
3
+ import { basename, dirname, isAbsolute, join, resolve } from "path";
4
+ export function resolveProjectRoot(project) {
5
+ return resolve(project ? (isAbsolute(project) ? project : join(process.cwd(), project)) : process.cwd());
6
+ }
7
+ export function bindingPath(projectRoot) {
8
+ return join(projectRoot, "summer-cloud.json");
9
+ }
10
+ export function localCloudDir(projectRoot) {
11
+ return join(projectRoot, ".summer", "local", "cloud");
12
+ }
13
+ export function basePath(projectRoot) {
14
+ return join(localCloudDir(projectRoot), "base.json");
15
+ }
16
+ export function journalPath(projectRoot) {
17
+ return join(localCloudDir(projectRoot), "journal.json");
18
+ }
19
+ export function hashCachePath(projectRoot) {
20
+ return join(localCloudDir(projectRoot), "hash-cache.json");
21
+ }
22
+ export function stagingDir(projectRoot) {
23
+ return join(localCloudDir(projectRoot), "staging");
24
+ }
25
+ export function conflictsDir(projectRoot) {
26
+ return join(localCloudDir(projectRoot), "conflicts");
27
+ }
28
+ export async function ensureCloudDirs(projectRoot) {
29
+ await mkdir(stagingDir(projectRoot), { recursive: true, mode: 0o700 });
30
+ await mkdir(conflictsDir(projectRoot), { recursive: true, mode: 0o700 });
31
+ }
32
+ export async function readBinding(projectRoot) {
33
+ try {
34
+ return JSON.parse(await readFile(bindingPath(projectRoot), "utf8"));
35
+ }
36
+ catch {
37
+ return null;
38
+ }
39
+ }
40
+ export async function writeBinding(projectRoot, binding) {
41
+ await writeJsonAtomic(bindingPath(projectRoot), binding);
42
+ }
43
+ export async function readBase(projectRoot) {
44
+ try {
45
+ return JSON.parse(await readFile(basePath(projectRoot), "utf8"));
46
+ }
47
+ catch {
48
+ return null;
49
+ }
50
+ }
51
+ export async function writeBase(projectRoot, base) {
52
+ await writeJsonAtomic(basePath(projectRoot), base);
53
+ }
54
+ export async function readJournal(projectRoot) {
55
+ try {
56
+ return JSON.parse(await readFile(journalPath(projectRoot), "utf8"));
57
+ }
58
+ catch {
59
+ return null;
60
+ }
61
+ }
62
+ export async function writeJournal(projectRoot, journal) {
63
+ await writeJsonAtomic(journalPath(projectRoot), journal);
64
+ }
65
+ export async function removeJournal(projectRoot) {
66
+ const { rm } = await import("fs/promises");
67
+ await rm(journalPath(projectRoot), { force: true });
68
+ }
69
+ export async function writeJsonAtomic(path, value) {
70
+ await mkdir(dirname(path), { recursive: true, mode: 0o700 });
71
+ const tmp = `${path}.${process.pid}.${Date.now()}.tmp`;
72
+ await writeFile(tmp, `${JSON.stringify(value, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
73
+ const { rename } = await import("fs/promises");
74
+ await rename(tmp, path);
75
+ }
76
+ export async function projectDisplayName(projectRoot) {
77
+ try {
78
+ const projectGodot = await readFile(join(projectRoot, "project.godot"), "utf8");
79
+ const match = projectGodot.match(/^\s*config\/name\s*=\s*"([^"]+)"/m);
80
+ if (match?.[1])
81
+ return match[1];
82
+ }
83
+ catch {
84
+ // Fall back to folder name.
85
+ }
86
+ return basename(projectRoot) || "Summer Project";
87
+ }
88
+ export function isCloudEnabled(projectRoot) {
89
+ return existsSync(bindingPath(projectRoot));
90
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Section+key 3-way merge for project.godot (spec 10.2). ConfigFile semantics:
3
+ * keys added on either side are unioned; a key deleted on one side and
4
+ * unchanged on the other stays deleted (a naive union would resurrect deleted
5
+ * autoloads); keys changed on both sides take the CAS winner's (remote) value
6
+ * and the losing value is surfaced in the sync notice. config_version comes
7
+ * from the winner. Values can span multiple lines (Object(InputEventKey, ...))
8
+ * so parsing uses a balanced-delimiter tokenizer, not line splitting.
9
+ */
10
+ export interface ParsedConfig {
11
+ /** Section name ("" for the top section) to ordered key/value text map. */
12
+ sections: Map<string, Map<string, string>>;
13
+ /** Section order as encountered. */
14
+ order: string[];
15
+ }
16
+ export declare class ConfigParseError extends Error {
17
+ }
18
+ export declare function parseGodotConfig(text: string): ParsedConfig;
19
+ export declare function serializeGodotConfig(config: ParsedConfig): string;
20
+ export interface GodotMergeResult {
21
+ ok: boolean;
22
+ merged: string;
23
+ /** Both-changed keys where the remote value won; notices for the sync result. */
24
+ losingValues: Array<{
25
+ section: string;
26
+ key: string;
27
+ localValue: string;
28
+ }>;
29
+ /** Parse failure on any input: whole-file remote-wins fallback. */
30
+ fallback: boolean;
31
+ }
32
+ export declare function mergeProjectGodot(baseText: string, localText: string, remoteText: string): GodotMergeResult;
@@ -0,0 +1,185 @@
1
+ /**
2
+ * Section+key 3-way merge for project.godot (spec 10.2). ConfigFile semantics:
3
+ * keys added on either side are unioned; a key deleted on one side and
4
+ * unchanged on the other stays deleted (a naive union would resurrect deleted
5
+ * autoloads); keys changed on both sides take the CAS winner's (remote) value
6
+ * and the losing value is surfaced in the sync notice. config_version comes
7
+ * from the winner. Values can span multiple lines (Object(InputEventKey, ...))
8
+ * so parsing uses a balanced-delimiter tokenizer, not line splitting.
9
+ */
10
+ export class ConfigParseError extends Error {
11
+ }
12
+ export function parseGodotConfig(text) {
13
+ const sections = new Map();
14
+ const order = [];
15
+ let current = "";
16
+ ensureSection(sections, order, current);
17
+ let i = 0;
18
+ const len = text.length;
19
+ while (i < len) {
20
+ // Skip whitespace and blank lines.
21
+ while (i < len && (text[i] === " " || text[i] === "\t" || text[i] === "\r" || text[i] === "\n"))
22
+ i += 1;
23
+ if (i >= len)
24
+ break;
25
+ const ch = text[i];
26
+ if (ch === ";" || ch === "#") {
27
+ while (i < len && text[i] !== "\n")
28
+ i += 1;
29
+ continue;
30
+ }
31
+ if (ch === "[") {
32
+ const close = text.indexOf("]", i);
33
+ if (close === -1)
34
+ throw new ConfigParseError("Unterminated section header");
35
+ current = text.slice(i + 1, close).trim();
36
+ ensureSection(sections, order, current);
37
+ i = close + 1;
38
+ continue;
39
+ }
40
+ // key=value. Key runs to the first unquoted '='.
41
+ const eq = text.indexOf("=", i);
42
+ const lineEnd = text.indexOf("\n", i);
43
+ if (eq === -1 || (lineEnd !== -1 && lineEnd < eq)) {
44
+ throw new ConfigParseError(`Expected key=value near offset ${i}`);
45
+ }
46
+ const key = text.slice(i, eq).trim();
47
+ if (!key)
48
+ throw new ConfigParseError(`Empty key near offset ${i}`);
49
+ i = eq + 1;
50
+ const { value, next } = readValue(text, i);
51
+ sections.get(current).set(key, value.trim());
52
+ i = next;
53
+ }
54
+ return { sections, order };
55
+ }
56
+ /**
57
+ * Reads a Variant value starting at `start`, consuming across newlines while
58
+ * any parenthesis, bracket, or brace is unbalanced or a string is open.
59
+ */
60
+ function readValue(text, start) {
61
+ let depth = 0;
62
+ let inString = false;
63
+ let i = start;
64
+ const len = text.length;
65
+ while (i < len) {
66
+ const ch = text[i];
67
+ if (inString) {
68
+ if (ch === "\\") {
69
+ i += 2;
70
+ continue;
71
+ }
72
+ if (ch === '"')
73
+ inString = false;
74
+ i += 1;
75
+ continue;
76
+ }
77
+ if (ch === '"') {
78
+ inString = true;
79
+ i += 1;
80
+ continue;
81
+ }
82
+ if (ch === "(" || ch === "[" || ch === "{")
83
+ depth += 1;
84
+ else if (ch === ")" || ch === "]" || ch === "}") {
85
+ depth -= 1;
86
+ if (depth < 0)
87
+ throw new ConfigParseError(`Unbalanced delimiter at offset ${i}`);
88
+ }
89
+ else if (ch === "\n" && depth === 0) {
90
+ return { value: text.slice(start, i), next: i + 1 };
91
+ }
92
+ i += 1;
93
+ }
94
+ if (inString || depth > 0)
95
+ throw new ConfigParseError("Unterminated value");
96
+ return { value: text.slice(start, i), next: i };
97
+ }
98
+ function ensureSection(sections, order, name) {
99
+ if (!sections.has(name)) {
100
+ sections.set(name, new Map());
101
+ order.push(name);
102
+ }
103
+ }
104
+ export function serializeGodotConfig(config) {
105
+ const parts = [];
106
+ for (const name of config.order) {
107
+ const entries = config.sections.get(name);
108
+ if (!entries || entries.size === 0) {
109
+ if (name === "")
110
+ continue;
111
+ }
112
+ if (name !== "") {
113
+ parts.push(`[${name}]`);
114
+ parts.push("");
115
+ }
116
+ for (const [key, value] of entries ?? []) {
117
+ parts.push(`${key}=${value}`);
118
+ }
119
+ parts.push("");
120
+ }
121
+ return `${parts.join("\n").replace(/\n+$/, "")}\n`;
122
+ }
123
+ export function mergeProjectGodot(baseText, localText, remoteText) {
124
+ let base;
125
+ let local;
126
+ let remote;
127
+ try {
128
+ base = parseGodotConfig(baseText);
129
+ local = parseGodotConfig(localText);
130
+ remote = parseGodotConfig(remoteText);
131
+ }
132
+ catch {
133
+ // Parse failure on any of the three inputs: fall back to whole-file
134
+ // remote-wins; the caller writes the local bytes as a conflict copy.
135
+ return { ok: true, merged: remoteText, losingValues: [], fallback: true };
136
+ }
137
+ const merged = { sections: new Map(), order: [] };
138
+ const losingValues = [];
139
+ const sectionNames = [];
140
+ for (const name of [...remote.order, ...local.order, ...base.order]) {
141
+ if (!sectionNames.includes(name))
142
+ sectionNames.push(name);
143
+ }
144
+ for (const section of sectionNames) {
145
+ const b = base.sections.get(section) ?? new Map();
146
+ const l = local.sections.get(section) ?? new Map();
147
+ const r = remote.sections.get(section) ?? new Map();
148
+ const keys = [];
149
+ for (const key of [...r.keys(), ...l.keys(), ...b.keys()]) {
150
+ if (!keys.includes(key))
151
+ keys.push(key);
152
+ }
153
+ const out = new Map();
154
+ for (const key of keys) {
155
+ const bv = b.get(key);
156
+ const lv = l.get(key);
157
+ const rv = r.get(key);
158
+ const resolved = mergeKey(bv, lv, rv);
159
+ if (resolved.losing !== undefined) {
160
+ losingValues.push({ section, key, localValue: resolved.losing });
161
+ }
162
+ if (resolved.value !== undefined)
163
+ out.set(key, resolved.value);
164
+ }
165
+ if (out.size > 0 || section === "") {
166
+ merged.sections.set(section, out);
167
+ merged.order.push(section);
168
+ }
169
+ }
170
+ return { ok: true, merged: serializeGodotConfig(merged), losingValues, fallback: false };
171
+ }
172
+ function mergeKey(base, local, remote) {
173
+ if (local === remote)
174
+ return { value: local };
175
+ const localChanged = local !== base;
176
+ const remoteChanged = remote !== base;
177
+ if (localChanged && !remoteChanged)
178
+ return { value: local };
179
+ if (remoteChanged && !localChanged)
180
+ return { value: remote };
181
+ // Changed on both sides (including delete vs change): the CAS winner's
182
+ // (remote) value wins at key granularity; deletion on the remote side wins
183
+ // too. The losing local value is surfaced.
184
+ return { value: remote, losing: local ?? "(deleted locally)" };
185
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Tracked-set rules (spec section 13). Full-project sync, single mode.
3
+ * Hard excludes cannot be re-included; .summercloudignore can only exclude
4
+ * further. The built-in rule set carries RULES_VERSION; manifests record the
5
+ * rulesVersion that produced them so stale clients can operate restricted.
6
+ */
7
+ export declare const RULES_VERSION = 1;
8
+ export declare function isHardExcludedDir(relPath: string, name: string): boolean;
9
+ /**
10
+ * Hard-excluded files. `siblings` is the set of basenames in the same
11
+ * directory, used to detect Godot safe-save temps (name-XXXXXX with the base
12
+ * file present).
13
+ */
14
+ export declare function isHardExcludedFile(relPath: string, name: string, siblings: ReadonlySet<string>): boolean;
15
+ /** Godot safe-save writes `name-XXXXXX` (six trailing random chars) next to `name`. */
16
+ export declare function isGodotSafeSaveTemp(name: string, siblings: ReadonlySet<string>): boolean;
17
+ /** True when a manifest key falls under the built-in hard excludes. */
18
+ export declare function isHardExcludedPath(path: string): boolean;
19
+ export interface IgnoreRule {
20
+ raw: string;
21
+ /** Trailing slash: matches directories (and everything under them). */
22
+ directory: boolean;
23
+ /** Leading slash: anchored to the project root. */
24
+ anchored: boolean;
25
+ regex: RegExp;
26
+ }
27
+ export interface TrackedRules {
28
+ rulesVersion: number;
29
+ ignore: IgnoreRule[];
30
+ }
31
+ export declare function loadTrackedRules(projectRoot: string): Promise<TrackedRules>;
32
+ export declare function parseIgnoreRules(raw: string): IgnoreRule[];
33
+ export declare function isIgnoredByRules(path: string, isDirectory: boolean, rules: TrackedRules): boolean;
34
+ /**
35
+ * Deletion semantics (spec 13): a path may be marked deleted only if it
36
+ * matches the CURRENT tracked rules AND is absent on disk. Paths that merely
37
+ * stopped matching rules are carried forward unchanged.
38
+ */
39
+ export declare function isTrackedByCurrentRules(path: string, rules: TrackedRules): boolean;