@jive-sh/skill-sync 0.2.2-latest-linux-x64 → 0.2.2

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.
@@ -0,0 +1,53 @@
1
+ import { spawn } from "node:child_process";
2
+
3
+ /**
4
+ * A desktop notification, best effort.
5
+ *
6
+ * Best effort is the whole contract: a daemon that cannot tell you about a conflict is worse than one
7
+ * that cannot sync, but a daemon that *dies* because a notification helper is missing is worse than
8
+ * both. Every failure here is swallowed.
9
+ *
10
+ * This works because these daemons are registered per-user and run inside the logged-in session — a
11
+ * LaunchAgent, a `systemctl --user` unit, a scheduled task. A system-wide service would have no desktop
12
+ * to post to.
13
+ */
14
+
15
+ /** No shell anywhere below: the arguments carry file paths, and a path is not a safe thing to interpolate. */
16
+ function run(command: string, args: ReadonlyArray<string>): void {
17
+ try {
18
+ spawn(command, [...args], { stdio: "ignore", detached: true }).unref();
19
+ } catch {
20
+ // Swallowed on purpose; see above.
21
+ }
22
+ }
23
+
24
+ export function notify(title: string, body: string): void {
25
+ switch (process.platform) {
26
+ case "darwin":
27
+ // AppleScript string literals take double quotes and backslashes as escapes, and nothing else.
28
+ return run("osascript", [
29
+ "-e",
30
+ `display notification "${escapeAppleScript(body)}" with title "${escapeAppleScript(title)}"`,
31
+ ]);
32
+ case "linux":
33
+ return run("notify-send", [title, body]);
34
+ case "win32":
35
+ return run("powershell", [
36
+ "-NoProfile", "-NonInteractive", "-Command",
37
+ "[reflection.assembly]::LoadWithPartialName('System.Windows.Forms') > $null;"
38
+ + "$n = New-Object System.Windows.Forms.NotifyIcon;"
39
+ + "$n.Icon = [System.Drawing.SystemIcons]::Warning; $n.Visible = $true;"
40
+ + `$n.ShowBalloonTip(10000, '${escapeSingleQuotes(title)}', '${escapeSingleQuotes(body)}', 'Warning')`,
41
+ ]);
42
+ default:
43
+ return;
44
+ }
45
+ }
46
+
47
+ function escapeAppleScript(value: string): string {
48
+ return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
49
+ }
50
+
51
+ function escapeSingleQuotes(value: string): string {
52
+ return value.replace(/'/g, "''");
53
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Validation for the one piece of untrusted input that becomes a filesystem path.
3
+ *
4
+ * Shared by the Worker and the daemon on purpose, and deliberately free of any Node import so the Worker
5
+ * can bundle it. Both ends check: the server so one compromised client cannot poison what every machine
6
+ * pulls, and the daemon because a server it does not control is not a thing to take paths from either.
7
+ *
8
+ * Without this, a pushed path of `../../.ssh/authorized_keys` is joined to the skills directory and
9
+ * written wherever the daemon's user can write.
10
+ */
11
+
12
+ /** Long enough for any real nesting, short enough that nothing can be used to exhaust anything. */
13
+ const MAX_LENGTH = 512;
14
+
15
+ export function invalidPathReason(path: string): string | undefined {
16
+ if (path.length === 0 || path.length > MAX_LENGTH) return "path is empty or too long";
17
+ if (path.includes("\0")) return "path contains a null byte";
18
+ if (path.includes("\\")) return "path must use forward slashes";
19
+ if (path.startsWith("/")) return "path must be relative";
20
+ // A Windows drive letter is absolute on the machine that matters even though it has no leading slash.
21
+ if (/^[A-Za-z]:/.test(path)) return "path must be relative";
22
+
23
+ for (const segment of path.split("/")) {
24
+ if (segment === "") return "path has an empty segment";
25
+ if (segment === "." || segment === "..") return "path must not contain . or .. segments";
26
+ }
27
+ return undefined;
28
+ }
29
+
30
+ export function isValidPath(path: string): boolean {
31
+ return invalidPathReason(path) === undefined;
32
+ }
@@ -0,0 +1,166 @@
1
+ import { spawn } from "node:child_process";
2
+ import { existsSync, mkdirSync, writeFileSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { basename, delimiter, dirname, join } from "node:path";
5
+
6
+ /**
7
+ * Merges two versions of one skill file with Claude, on this machine.
8
+ *
9
+ * A conflict used to mean "the server's copy wins and yours is set aside as `.local`", which left the
10
+ * merge to a person who might not notice for days, while every machine ran the losing half. Skills are
11
+ * prose that Claude reads, so Claude is the right one to merge them: it keeps every rule either side
12
+ * added and resolves the ones that disagree.
13
+ *
14
+ * Claude runs with no tools, no MCP servers and no settings: it receives the versions on stdin and
15
+ * answers with the merged file, so it cannot touch anything else. It runs as the Claude account whose
16
+ * folder is being merged (`CLAUDE_CONFIG_DIR` set to that folder's home), so it never creates or writes
17
+ * `~/.claude` — a home the user may have deleted on purpose.
18
+ */
19
+
20
+ export interface Version {
21
+ readonly content: string;
22
+ /** Where this version came from, for the prompt: "this machine, edited …" or "MacBookPro, uploaded …". */
23
+ readonly label: string;
24
+ }
25
+
26
+ /** The merged file, and Claude's account of it for the audit log. */
27
+ export interface Resolution {
28
+ readonly merged: string;
29
+ /** What differed between the two versions. */
30
+ readonly conflict: string;
31
+ /** What the merge kept, dropped or reworded. */
32
+ readonly resolution: string;
33
+ }
34
+
35
+ export interface Conflict {
36
+ readonly folder: string;
37
+ readonly path: string;
38
+ readonly local: Version;
39
+ readonly remote: Version;
40
+ /** The last version both sides agreed on, when there is one; it lets Claude see what each side changed. */
41
+ readonly base: string | null;
42
+ }
43
+
44
+ const BACKUP_ROOT = join(homedir(), ".jive", "skill-sync", "reconciled");
45
+ /** A long skill is tens of thousands of tokens to write back out. */
46
+ const TIMEOUT_MS = 15 * 60 * 1000;
47
+ /**
48
+ * A merge shorter than this fraction of the longer input is refused as probably truncated: a model that
49
+ * stops early produces a file that looks fine and silently drops half the rules.
50
+ */
51
+ const MIN_LENGTH_RATIO = 0.6;
52
+
53
+ const SYSTEM_PROMPT = `You merge two versions of one file from a user's Claude skills folder into one.
54
+ Answer in exactly this form and nothing else:
55
+ <conflict>One or two plain sentences: what differs between the versions.</conflict>
56
+ <resolution>One or two plain sentences: what the merge kept from each, and anything dropped or reworded.</resolution>
57
+ <merged>
58
+ The complete merged file, exactly as it should be saved. No code fences.
59
+ </merged>
60
+ Rules for the merge:
61
+ - Keep every rule, example and fact that either version has, unless the other version clearly replaces it.
62
+ - Where the two disagree, prefer the newer version's wording, and keep anything only the older one has.
63
+ - If a base version is given, it is what both started from: use it to tell additions from deletions, and
64
+ honour a deletion made on one side when the other side did not edit that part.
65
+ - Keep the file's frontmatter and structure.
66
+ In <conflict> and <resolution>, call the two versions by the names they are given (for example "the copy in
67
+ ~/.claude-chaine/skills on MacBookPro" and "the synced copy"), never "Version A" or "Version B": these are
68
+ read later by a person auditing the merge, who never saw this prompt.`;
69
+
70
+ /**
71
+ * Both versions are copied out of the synced folder before anything is written, so a bad merge costs a
72
+ * file copy, not the user's work. Outside the folder so the copies are never synced or read as skills.
73
+ */
74
+ export function backUp(conflict: Conflict): string {
75
+ const stamp = new Date().toISOString().replace(/[:.]/g, "-");
76
+ const dir = join(BACKUP_ROOT, stamp, basename(dirname(conflict.folder)), conflict.path);
77
+ mkdirSync(dir, { recursive: true });
78
+ writeFileSync(join(dir, "local"), conflict.local.content);
79
+ writeFileSync(join(dir, "remote"), conflict.remote.content);
80
+ if (conflict.base !== null) writeFileSync(join(dir, "base"), conflict.base);
81
+ return dir;
82
+ }
83
+
84
+ export async function reconcile(conflict: Conflict): Promise<Resolution> {
85
+ const sections = [
86
+ `File: ${conflict.path}`,
87
+ conflict.base === null ? "No base version: these two have never been in sync." : `=== the base version both started from:\n${conflict.base}`,
88
+ `=== ${conflict.local.label}:\n${conflict.local.content}`,
89
+ `=== ${conflict.remote.label}:\n${conflict.remote.content}`,
90
+ "Write the merged file now.",
91
+ ];
92
+ const answer = await runClaude(sections.join("\n\n-----\n\n"), dirname(conflict.folder));
93
+ const start = answer.indexOf("<merged>");
94
+ const end = answer.lastIndexOf("</merged>");
95
+ if (start === -1 || end <= start) throw new Error("Claude's answer had no <merged> section");
96
+ // The file is everything between the tags, minus the newline that follows the opening one.
97
+ const merged = answer.slice(start + "<merged>".length, end).replace(/^\r?\n/, "");
98
+ const conflictText = /<conflict>([\s\S]*?)<\/conflict>/.exec(answer)?.[1]?.trim() ?? "";
99
+ const resolutionText = /<resolution>([\s\S]*?)<\/resolution>/.exec(answer)?.[1]?.trim() ?? "";
100
+
101
+ const longest = Math.max(conflict.local.content.length, conflict.remote.content.length);
102
+ if (merged.trim().length === 0) throw new Error("Claude returned an empty merge");
103
+ if (merged.length < longest * MIN_LENGTH_RATIO) {
104
+ throw new Error(`the merge is ${merged.length} characters against ${longest}; refusing it as probably truncated`);
105
+ }
106
+ return {
107
+ merged: merged.endsWith("\n") ? merged : `${merged}\n`,
108
+ conflict: conflictText || "Claude did not describe the conflict.",
109
+ resolution: resolutionText || "Claude did not describe the resolution.",
110
+ };
111
+ }
112
+
113
+ /** `configDir` is the Claude home that owns the folder, e.g. `~/.claude-chaine` for `~/.claude-chaine/skills`. */
114
+ function runClaude(input: string, configDir: string): Promise<string> {
115
+ const claude = findClaude();
116
+ if (claude === undefined) return Promise.reject(new Error("the claude CLI is not installed"));
117
+
118
+ // Always set, never inherited or left unset: unset, `claude` falls back to `~/.claude` and writes its
119
+ // session files, backups and caches there, recreating that home every time it is deleted.
120
+ const env = { ...process.env, CLAUDE_CONFIG_DIR: configDir };
121
+
122
+ return new Promise((resolve, reject) => {
123
+ const child = spawn(claude, [
124
+ "-p", "--output-format", "json", "--tools", "", "--strict-mcp-config", "--setting-sources", "",
125
+ "--no-session-persistence", "--system-prompt", SYSTEM_PROMPT,
126
+ ], { env, cwd: homedir(), stdio: ["pipe", "pipe", "pipe"] });
127
+
128
+ let stdout = "";
129
+ let stderr = "";
130
+ child.stdout.on("data", (chunk: Buffer) => { stdout += chunk.toString(); });
131
+ child.stderr.on("data", (chunk: Buffer) => { stderr += chunk.toString(); });
132
+ const timer = setTimeout(() => child.kill("SIGTERM"), TIMEOUT_MS);
133
+
134
+ child.on("error", (cause) => { clearTimeout(timer); reject(cause); });
135
+ child.on("close", (code) => {
136
+ clearTimeout(timer);
137
+ try {
138
+ const result = JSON.parse(stdout) as { is_error?: boolean; result?: string };
139
+ if (code !== 0 || result.is_error || typeof result.result !== "string") {
140
+ reject(new Error(`claude failed (exit ${code}): ${result.result ?? stderr.trim()}`));
141
+ } else {
142
+ resolve(result.result);
143
+ }
144
+ } catch {
145
+ reject(new Error(`claude failed (exit ${code}): ${stderr.trim() || stdout.slice(0, 300)}`));
146
+ }
147
+ });
148
+ child.stdin.end(input);
149
+ });
150
+ }
151
+
152
+ /**
153
+ * The daemon starts at login with the service manager's minimal PATH, which rarely includes where the
154
+ * Claude installer puts `claude`, so the usual install locations are checked too.
155
+ */
156
+ function findClaude(): string | undefined {
157
+ const name = process.platform === "win32" ? "claude.exe" : "claude";
158
+ const fromPath = (process.env["PATH"] ?? "").split(delimiter).filter(Boolean).map((dir) => join(dir, name));
159
+ const known = [
160
+ join(homedir(), ".local", "bin", name),
161
+ join(homedir(), ".claude", "local", "claude"),
162
+ "/opt/homebrew/bin/claude",
163
+ "/usr/local/bin/claude",
164
+ ];
165
+ return [...fromPath, ...known].find((candidate) => existsSync(candidate));
166
+ }
@@ -0,0 +1,304 @@
1
+ import { mkdirSync, readFileSync, rmSync, statSync, writeFileSync, existsSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { CONFLICT_SUFFIX, hashBytes, listLocalFiles, type Config, type State } from "./config";
4
+ import { invalidPathReason } from "./path";
5
+ import { backUp, type Conflict, type Resolution } from "./reconcile";
6
+ import { skillOf, type ReconciliationRequest } from "./wire";
7
+
8
+ /**
9
+ * One sync cycle: take everything the server has that we do not, then offer everything we have that it
10
+ * does not.
11
+ *
12
+ * Pull before push, deliberately. Pushing first would send a file the server already changed, get it
13
+ * refused, and report a conflict that pulling would have resolved on its own.
14
+ */
15
+
16
+ interface RemoteFile {
17
+ readonly path: string;
18
+ readonly hash: string | null;
19
+ readonly deleted: boolean;
20
+ readonly updatedAt: number;
21
+ readonly updatedBy: string;
22
+ }
23
+
24
+ /**
25
+ * How a cycle resolves a file changed both here and on the server. Without a `merge`, the server's copy
26
+ * wins and this machine's is kept beside it as `.local`; with one, the two are merged and the result
27
+ * is pushed. `onMerging` fires before a merge starts, since one can take minutes.
28
+ */
29
+ export interface Reconciler {
30
+ readonly merge: (conflict: Conflict) => Promise<Resolution>;
31
+ readonly onMerging?: (path: string) => void;
32
+ }
33
+
34
+ export interface Report {
35
+ readonly pulled: string[];
36
+ readonly pushed: string[];
37
+ readonly deletedLocally: string[];
38
+ readonly conflicts: string[];
39
+ /** Paths changed on both sides and merged; the merge is pushed in the same cycle. */
40
+ readonly merged: string[];
41
+ /** Merges that failed, with the reason; those paths fell back to `.local`. */
42
+ readonly mergeFailures: string[];
43
+ /** Every merge attempted this cycle, for the audit log. */
44
+ readonly reconciliations: Omit<ReconciliationRequest, "folder">[];
45
+ /** Paths the server offered that this machine refused to write. */
46
+ readonly rejected: string[];
47
+ /** A push lost a race to another machine; the next cycle pulls and reconciles it. */
48
+ retry: boolean;
49
+ }
50
+
51
+ /** True when a report has nothing in it worth telling anyone. */
52
+ export function isQuiet(report: Report): boolean {
53
+ return [report.pulled, report.pushed, report.deletedLocally, report.conflicts, report.merged,
54
+ report.mergeFailures, report.rejected].every((list) => list.length === 0);
55
+ }
56
+
57
+ /**
58
+ * `skills` are the skills this folder has opted into. Everything else in the folder is local to it: never
59
+ * pushed, never overwritten, never deleted by a sync.
60
+ */
61
+ export async function syncOnce(
62
+ config: Config, folder: string, state: State, skills: ReadonlySet<string>, reconciler?: Reconciler,
63
+ ): Promise<Report> {
64
+ const report: Report = {
65
+ pulled: [], pushed: [], deletedLocally: [], conflicts: [], merged: [], mergeFailures: [], reconciliations: [],
66
+ rejected: [], retry: false,
67
+ };
68
+ mkdirSync(folder, { recursive: true });
69
+
70
+ // A skill opted in since last cycle may have server files older than the cursor; re-read from the start.
71
+ if ([...skills].some((skill) => !(state.skills ?? []).includes(skill))) state.cursor = 0;
72
+ state.skills = [...skills].sort();
73
+ // A skill opted out stops being tracked here. Left in `known`, its files would look deleted from this
74
+ // folder and the push would tombstone them on every machine.
75
+ for (const path of Object.keys(state.known)) {
76
+ if (!skills.has(skillOf(path))) delete state.known[path];
77
+ }
78
+
79
+ const present = new Set(listLocalFiles(folder, config.exclude).filter((path) => skills.has(skillOf(path))));
80
+ const ctx: Cycle = { config, folder, state, report, reconciler, skills };
81
+
82
+ /**
83
+ * Files deleted here since the last cycle, worked out before anything is fetched.
84
+ *
85
+ * A push does not advance this machine's cursor, so the next pull sees its own earlier write as news.
86
+ * With the file now gone from disk that looked like "remote has it, we do not" and the pull put it
87
+ * straight back, undoing the delete every time. Deciding what was deleted up front, and refusing to
88
+ * recreate those paths, is what lets the push below turn them into tombstones.
89
+ */
90
+ const pendingDeletes = new Set(Object.keys(state.known).filter((path) => !present.has(path)));
91
+
92
+ // `present` is passed through both passes and kept current: the pull removes tombstoned files and adds
93
+ // new ones, and a push that read a stale snapshot would try to open a file the pull had just deleted.
94
+ await pull(ctx, pendingDeletes, present);
95
+ await push(ctx, present);
96
+ return report;
97
+ }
98
+
99
+ interface Cycle {
100
+ readonly config: Config;
101
+ readonly folder: string;
102
+ readonly state: State;
103
+ readonly report: Report;
104
+ readonly reconciler: Reconciler | undefined;
105
+ readonly skills: ReadonlySet<string>;
106
+ }
107
+
108
+ async function pull(ctx: Cycle, pendingDeletes: Set<string>, present: Set<string>): Promise<void> {
109
+ const { config, folder, state, report } = ctx;
110
+ const response = await call(config, `/files?since=${state.cursor}`);
111
+ const body = await response.json() as { cursor: number; files: RemoteFile[] };
112
+
113
+ for (const file of body.files) {
114
+ // The server is not trusted to supply a path either: one bad row would otherwise write outside the
115
+ // skills directory on every machine that syncs.
116
+ const badPath = invalidPathReason(file.path);
117
+ if (badPath) {
118
+ report.rejected.push(`${file.path} (${badPath})`);
119
+ continue;
120
+ }
121
+ // Local to this folder: the server's version is not wanted here.
122
+ if (!ctx.skills.has(skillOf(file.path))) continue;
123
+ const local = join(folder, file.path);
124
+ // Deleted here and not yet reported; the push pass owns this path for the rest of the cycle.
125
+ if (pendingDeletes.has(file.path)) continue;
126
+
127
+ if (file.deleted) {
128
+ if (existsSync(local)) {
129
+ // Never destroy content this machine never agreed with the server on. A file written here that
130
+ // the server happens to have tombstoned — a fresh machine, or one that was offline through the
131
+ // delete — is not the same file, and deleting it silently would lose work nobody could recover.
132
+ const localHash = hashBytes(readFileSync(local));
133
+ if (localHash !== state.known[file.path]) {
134
+ writeFileSync(`${local}${CONFLICT_SUFFIX}`, readFileSync(local));
135
+ report.conflicts.push(file.path);
136
+ }
137
+ rmSync(local, { force: true });
138
+ report.deletedLocally.push(file.path);
139
+ }
140
+ present.delete(file.path);
141
+ delete state.known[file.path];
142
+ continue;
143
+ }
144
+
145
+ const localHash = existsSync(local) ? hashBytes(readFileSync(local)) : undefined;
146
+ if (localHash === file.hash) {
147
+ // Already identical — record agreement so the push pass does not treat it as a local edit.
148
+ state.known[file.path] = file.hash!;
149
+ continue;
150
+ }
151
+
152
+ const content = new Uint8Array(await (await call(config, `/content/${file.hash}`)).arrayBuffer());
153
+
154
+ // Changed here and on the server. Merge the two when possible: the merge is written here, recorded as
155
+ // based on the server's copy, and so goes out in this cycle's push.
156
+ if (localHash !== undefined && localHash !== state.known[file.path]) {
157
+ if (await mergeInto(ctx, file, local, content)) {
158
+ present.add(file.path);
159
+ continue;
160
+ }
161
+ // No merge: a local edit we have not pushed yet would be destroyed by overwriting, so it is kept
162
+ // beside the incoming copy.
163
+ writeFileSync(`${local}${CONFLICT_SUFFIX}`, readFileSync(local));
164
+ report.conflicts.push(file.path);
165
+ }
166
+
167
+ mkdirSync(dirname(local), { recursive: true });
168
+ writeFileSync(local, content);
169
+ state.known[file.path] = file.hash!;
170
+ present.add(file.path);
171
+ report.pulled.push(file.path);
172
+ }
173
+
174
+ state.cursor = body.cursor;
175
+ }
176
+
177
+ /**
178
+ * Merges this machine's copy with the server's and writes the result over the local file. False when
179
+ * there is no merger, either side is not text, or the merge failed — the caller then keeps `.local`.
180
+ */
181
+ async function mergeInto(ctx: Cycle, file: RemoteFile, local: string, remote: Uint8Array): Promise<boolean> {
182
+ const { config, folder, state, report, reconciler } = ctx;
183
+ if (reconciler === undefined) return false;
184
+
185
+ const mine = readFileSync(local);
186
+ const localText = asText(mine);
187
+ const remoteText = asText(remote);
188
+ if (localText === undefined || remoteText === undefined) return false;
189
+
190
+ const known = state.known[file.path];
191
+ const base = known === undefined ? null : await fetchText(config, known);
192
+ const conflict: Conflict = {
193
+ folder,
194
+ path: file.path,
195
+ local: {
196
+ content: localText,
197
+ label: `the copy in ${folder} on ${config.machine}, last edited ${statSync(local).mtime.toISOString()}`,
198
+ },
199
+ remote: {
200
+ content: remoteText,
201
+ label: `the synced copy, last uploaded by ${file.updatedBy} at ${new Date(file.updatedAt).toISOString()}`,
202
+ },
203
+ base,
204
+ };
205
+
206
+ backUp(conflict);
207
+ reconciler.onMerging?.(file.path);
208
+ let resolution: Resolution;
209
+ try {
210
+ resolution = await reconciler.merge(conflict);
211
+ } catch (cause) {
212
+ const reason = cause instanceof Error ? cause.message : String(cause);
213
+ report.mergeFailures.push(`${file.path}: ${reason}`);
214
+ report.reconciliations.push({
215
+ path: file.path, outcome: "failed",
216
+ conflict: "This machine's copy and the server's differ.",
217
+ resolution: `Not merged: ${reason}. The server's version was kept and this machine's saved as ${file.path}${CONFLICT_SUFFIX}.`,
218
+ });
219
+ return false;
220
+ }
221
+ writeFileSync(local, resolution.merged);
222
+ state.known[file.path] = file.hash!;
223
+ report.merged.push(file.path);
224
+ report.reconciliations.push({
225
+ path: file.path, outcome: "merged", conflict: resolution.conflict, resolution: resolution.resolution,
226
+ });
227
+ return true;
228
+ }
229
+
230
+ /** Text only: a merge by a language model is meaningless for an image or a binary, and harmful. */
231
+ function asText(bytes: Uint8Array): string | undefined {
232
+ if (bytes.length > 512 * 1024 || bytes.includes(0)) return undefined;
233
+ try {
234
+ return new TextDecoder("utf-8", { fatal: true }).decode(bytes);
235
+ } catch {
236
+ return undefined;
237
+ }
238
+ }
239
+
240
+ /** The base is a nicety for the merge; if the server no longer has it, merge without one. */
241
+ async function fetchText(config: Config, hash: string): Promise<string | null> {
242
+ try {
243
+ return asText(new Uint8Array(await (await call(config, `/content/${hash}`)).arrayBuffer())) ?? null;
244
+ } catch {
245
+ return null;
246
+ }
247
+ }
248
+
249
+ async function push(ctx: Cycle, present: Set<string>): Promise<void> {
250
+ const { config, folder, state, report } = ctx;
251
+ for (const path of present) {
252
+ const bytes = readFileSync(join(folder, path));
253
+ const hash = hashBytes(bytes);
254
+ if (state.known[path] === hash) continue;
255
+
256
+ const accepted = await put(config, {
257
+ path,
258
+ baseHash: state.known[path] ?? null,
259
+ hash,
260
+ content: Buffer.from(bytes).toString("base64"),
261
+ });
262
+ if (accepted) {
263
+ state.known[path] = hash;
264
+ report.pushed.push(path);
265
+ } else {
266
+ // Another machine wrote first. Not a conflict yet: the next cycle pulls its version and merges.
267
+ report.retry = true;
268
+ }
269
+ }
270
+
271
+ // A path we previously agreed on that is no longer on disk was deleted here, and the server needs the
272
+ // tombstone or the next machine to sync will upload it back.
273
+ for (const path of Object.keys(state.known)) {
274
+ if (present.has(path)) continue;
275
+ if (await put(config, { path, baseHash: state.known[path]!, hash: null, content: null })) {
276
+ delete state.known[path];
277
+ report.pushed.push(`${path} (deleted)`);
278
+ } else {
279
+ report.conflicts.push(path);
280
+ }
281
+ }
282
+ }
283
+
284
+ /** True when the server accepted the write; false means another machine got there first. */
285
+ async function put(config: Config, body: {
286
+ path: string; baseHash: string | null; hash: string | null; content: string | null;
287
+ }): Promise<boolean> {
288
+ const response = await fetch(`${config.endpoint}/files`, {
289
+ method: "PUT",
290
+ headers: { authorization: `Bearer ${config.token}`, "content-type": "application/json" },
291
+ body: JSON.stringify({ ...body, machine: config.machine }),
292
+ });
293
+ if (response.status === 409) return false;
294
+ if (!response.ok) throw new Error(`PUT /files ${body.path}: ${response.status} ${await response.text()}`);
295
+ return true;
296
+ }
297
+
298
+ async function call(config: Config, path: string): Promise<Response> {
299
+ const response = await fetch(`${config.endpoint}${path}`, {
300
+ headers: { authorization: `Bearer ${config.token}` },
301
+ });
302
+ if (!response.ok) throw new Error(`GET ${path}: ${response.status} ${await response.text()}`);
303
+ return response;
304
+ }