@zergai/zergbox-client 0.1.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.
package/dist/sync.js ADDED
@@ -0,0 +1,1946 @@
1
+ import {
2
+ DEFAULT_IGNORE_PATTERNS,
3
+ MERGE_CANDIDATE_STATES,
4
+ SYNC_CHANGE_TYPES,
5
+ SYNC_NODE_TYPES,
6
+ ZergBoxDesktopClient,
7
+ ZergBoxSyncConflictError,
8
+ fetchRemoteTree,
9
+ isMergeCandidate,
10
+ isSafeSyncPath,
11
+ isSyncChange,
12
+ isSyncNode,
13
+ isSyncPathIgnored,
14
+ normalizeSyncPath,
15
+ planSync,
16
+ remoteFileFingerprint
17
+ } from "./chunk-KLBU4HNA.js";
18
+
19
+ // src/sync.mjs
20
+ import { watch as watchFileSystem } from "node:fs";
21
+ import fs7 from "node:fs/promises";
22
+ import path8 from "node:path";
23
+
24
+ // ../desktop/src/control.mjs
25
+ import { execFile } from "node:child_process";
26
+ import fs2 from "node:fs/promises";
27
+ import path2 from "node:path";
28
+ import { promisify } from "node:util";
29
+
30
+ // ../desktop/src/json-store.mjs
31
+ import fs from "node:fs/promises";
32
+ import { randomUUID } from "node:crypto";
33
+ import path from "node:path";
34
+ function makeCorruptBackupPath(filePath) {
35
+ const timestamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
36
+ return `${filePath}.corrupt-${timestamp}-${randomUUID()}`;
37
+ }
38
+ async function readJsonFile(filePath, fallback, options = {}) {
39
+ let rawJson;
40
+ await assertSafeJsonPath(filePath, options);
41
+ try {
42
+ rawJson = await fs.readFile(filePath, "utf8");
43
+ } catch (error) {
44
+ if (error?.code === "ENOENT") {
45
+ return fallback;
46
+ }
47
+ throw error;
48
+ }
49
+ try {
50
+ return JSON.parse(rawJson);
51
+ } catch (error) {
52
+ if (error instanceof SyntaxError && (options.fallbackInvalidJson || options.recoverInvalidJson)) {
53
+ let backupPath = null;
54
+ if (options.recoverInvalidJson) {
55
+ backupPath = makeCorruptBackupPath(filePath);
56
+ await assertSafeJsonPath(filePath, options);
57
+ await fs.rename(filePath, backupPath);
58
+ }
59
+ if (typeof options.onInvalidJson === "function") {
60
+ options.onInvalidJson({ filePath, backupPath, error });
61
+ }
62
+ return fallback;
63
+ }
64
+ throw error;
65
+ }
66
+ }
67
+ async function writeJsonFile(filePath, value, options = {}) {
68
+ const mode = 384;
69
+ const tempPath = `${filePath}.${process.pid}.${randomUUID()}.tmp`;
70
+ await assertSafeJsonPath(filePath, options);
71
+ await fs.mkdir(path.dirname(filePath), { recursive: true, mode: 448 });
72
+ await assertSafeJsonPath(filePath, options);
73
+ try {
74
+ await assertSafeJsonPath(filePath, options);
75
+ await fs.writeFile(tempPath, `${JSON.stringify(value, null, 2)}
76
+ `, { mode });
77
+ await assertSafeJsonPath(filePath, options);
78
+ await fs.rename(tempPath, filePath);
79
+ await assertSafeJsonPath(filePath, options);
80
+ await fs.chmod(filePath, mode);
81
+ } catch (error) {
82
+ await fs.rm(tempPath, { force: true });
83
+ throw error;
84
+ }
85
+ }
86
+ async function assertSafeJsonPath(filePath, options) {
87
+ if (typeof options.assertSafePath === "function") {
88
+ await options.assertSafePath(filePath);
89
+ }
90
+ }
91
+
92
+ // ../desktop/src/control.mjs
93
+ var CONTROL_DIR = ".zergbox-desktop";
94
+ var PAUSE_FILE = "paused.json";
95
+ var INVALID_PAUSE_REASON = "Pause marker is invalid; resume sync to clear it";
96
+ var execFileAsync = promisify(execFile);
97
+ function getPausePath(localRoot) {
98
+ return path2.join(localRoot, CONTROL_DIR, PAUSE_FILE);
99
+ }
100
+ async function pauseDesktopSync({ localRoot, reason = "Paused by user" }) {
101
+ const pausePath = getPausePath(localRoot);
102
+ const pausedAt = (/* @__PURE__ */ new Date()).toISOString();
103
+ await writeJsonFile(pausePath, { pausedAt, reason });
104
+ return {
105
+ paused: true,
106
+ pausePath,
107
+ pausedAt,
108
+ pauseReason: reason
109
+ };
110
+ }
111
+ async function resumeDesktopSync({ localRoot }) {
112
+ const pausePath = getPausePath(localRoot);
113
+ await fs2.rm(pausePath, { force: true });
114
+ return getDesktopRuntimeStatus({ localRoot });
115
+ }
116
+ async function getDesktopRuntimeStatus({ localRoot }) {
117
+ const pausePath = getPausePath(localRoot);
118
+ let invalidPausePath = null;
119
+ const marker = await readJsonFile(pausePath, null, {
120
+ fallbackInvalidJson: true,
121
+ onInvalidJson: ({ filePath }) => {
122
+ invalidPausePath = filePath;
123
+ }
124
+ });
125
+ if (invalidPausePath) {
126
+ return {
127
+ paused: true,
128
+ pausePath,
129
+ pausedAt: null,
130
+ pauseReason: INVALID_PAUSE_REASON,
131
+ pauseWarning: `Pause marker is not valid JSON: ${invalidPausePath}`
132
+ };
133
+ }
134
+ if (!marker) {
135
+ return {
136
+ paused: false,
137
+ pausePath,
138
+ pausedAt: null,
139
+ pauseReason: null,
140
+ pauseWarning: null
141
+ };
142
+ }
143
+ if (typeof marker !== "object") {
144
+ return {
145
+ paused: true,
146
+ pausePath,
147
+ pausedAt: null,
148
+ pauseReason: INVALID_PAUSE_REASON,
149
+ pauseWarning: `Pause marker is not valid JSON: ${pausePath}`
150
+ };
151
+ }
152
+ return {
153
+ paused: true,
154
+ pausePath,
155
+ pausedAt: marker.pausedAt || null,
156
+ pauseReason: marker.reason || null,
157
+ pauseWarning: null
158
+ };
159
+ }
160
+
161
+ // ../desktop/src/paths.mjs
162
+ import fs3 from "node:fs/promises";
163
+ import os from "node:os";
164
+ import path3 from "node:path";
165
+ function getDefaultLocalRoot() {
166
+ return path3.join(os.homedir(), "ZergBox");
167
+ }
168
+ function getDefaultConfigDir() {
169
+ if (process.platform === "darwin") {
170
+ return path3.join(os.homedir(), "Library", "Application Support", "ZergBox Desktop");
171
+ }
172
+ if (process.platform === "win32") {
173
+ return path3.join(process.env.APPDATA || path3.join(os.homedir(), "AppData", "Roaming"), "ZergBox Desktop");
174
+ }
175
+ return path3.join(process.env.XDG_CONFIG_HOME || path3.join(os.homedir(), ".config"), "zergbox-desktop");
176
+ }
177
+ function getDefaultConfigPath() {
178
+ return path3.join(getDefaultConfigDir(), "config.json");
179
+ }
180
+ function getDefaultStatePath(localRoot = getDefaultLocalRoot()) {
181
+ return path3.join(localRoot, ".zergbox-desktop", "state.json");
182
+ }
183
+ async function assertSafeLocalRoot(localRoot, home = os.homedir()) {
184
+ const resolvedRoot = resolveUsablePath(localRoot, "sync folder");
185
+ const resolvedHome = resolveUsablePath(home, "home directory");
186
+ const unexpectedRootSymlinks = await listUnexpectedPathSymlinks(resolvedRoot);
187
+ const rootResolution = await resolveProspectiveRealPath(resolvedRoot);
188
+ const homeResolution = await resolveProspectiveRealPath(resolvedHome);
189
+ const realRoot = rootResolution.prospectivePath;
190
+ const realHome = homeResolution.prospectivePath;
191
+ if (isUnsafeSyncRoot(resolvedRoot, resolvedHome) || isUnsafeSyncRoot(realRoot, realHome) || unexpectedRootSymlinks.length > 0 || rootResolution.existingAncestorIsSymlink && isUnsafeSyncRoot(rootResolution.realExistingAncestor, realHome)) {
192
+ throw new Error(`Refusing to use unsafe sync folder: ${localRoot}`);
193
+ }
194
+ }
195
+ async function assertSafeStatePath(statePath) {
196
+ const resolvedStatePath = resolveUsablePath(statePath, "sync state");
197
+ const unexpectedSymlinks = await listUnexpectedPathSymlinks(resolvedStatePath);
198
+ if (unexpectedSymlinks.length > 0) {
199
+ throw new Error(`Refusing to follow symbolic link in sync state path: ${statePath}`);
200
+ }
201
+ }
202
+ async function listUnexpectedPathSymlinks(filePath) {
203
+ const allowedPlatformSymlinks = new Set(await listAllowedPlatformSymlinks());
204
+ const symlinkComponents = await listExistingSymlinkComponents(filePath);
205
+ return symlinkComponents.filter((component) => !allowedPlatformSymlinks.has(component));
206
+ }
207
+ async function listAllowedPlatformSymlinks() {
208
+ if (process.platform !== "darwin") {
209
+ return [];
210
+ }
211
+ const symlinks = [];
212
+ for (const candidatePath of ["/var", "/tmp"]) {
213
+ try {
214
+ if ((await fs3.lstat(candidatePath)).isSymbolicLink()) {
215
+ symlinks.push(candidatePath);
216
+ }
217
+ } catch (error) {
218
+ if (error?.code !== "ENOENT") {
219
+ throw error;
220
+ }
221
+ }
222
+ }
223
+ return symlinks;
224
+ }
225
+ async function listExistingSymlinkComponents(filePath) {
226
+ const resolvedPath = path3.resolve(filePath);
227
+ const filesystemRoot = path3.parse(resolvedPath).root;
228
+ const segments = path3.relative(filesystemRoot, resolvedPath).split(path3.sep).filter(Boolean);
229
+ const symlinks = [];
230
+ let candidatePath = filesystemRoot;
231
+ for (const segment of segments) {
232
+ candidatePath = path3.join(candidatePath, segment);
233
+ try {
234
+ if ((await fs3.lstat(candidatePath)).isSymbolicLink()) {
235
+ symlinks.push(candidatePath);
236
+ }
237
+ } catch (error) {
238
+ if (error?.code === "ENOENT") {
239
+ break;
240
+ }
241
+ throw error;
242
+ }
243
+ }
244
+ return symlinks;
245
+ }
246
+ async function assertLocalPathHasNoSymlinks(localRoot, targetPath = localRoot) {
247
+ const resolvedRoot = resolveUsablePath(localRoot, "sync folder");
248
+ const resolvedTarget = resolveUsablePath(targetPath, "sync target");
249
+ const relativeTarget = path3.relative(resolvedRoot, resolvedTarget);
250
+ if (relativeTarget && (relativeTarget.startsWith("..") || path3.isAbsolute(relativeTarget))) {
251
+ throw new Error(`Sync path escapes local root: ${targetPath}`);
252
+ }
253
+ const pathSegments = relativeTarget ? relativeTarget.split(path3.sep).filter(Boolean) : [];
254
+ let candidatePath = resolvedRoot;
255
+ for (const segment of [null, ...pathSegments]) {
256
+ if (segment !== null) {
257
+ candidatePath = path3.join(candidatePath, segment);
258
+ }
259
+ let stats;
260
+ try {
261
+ stats = await fs3.lstat(candidatePath);
262
+ } catch (error) {
263
+ if (error?.code === "ENOENT") {
264
+ return;
265
+ }
266
+ throw error;
267
+ }
268
+ if (stats.isSymbolicLink()) {
269
+ const relativePath = path3.relative(resolvedRoot, candidatePath).split(path3.sep).join("/") || ".";
270
+ throw new Error(`Refusing to follow symbolic link in sync folder: ${relativePath}`);
271
+ }
272
+ }
273
+ }
274
+ function resolveUsablePath(value, label) {
275
+ if (typeof value !== "string" || value.trim() === "") {
276
+ throw new Error(`${label} path is required`);
277
+ }
278
+ return path3.resolve(value);
279
+ }
280
+ async function resolveProspectiveRealPath(filePath) {
281
+ let existingAncestor = filePath;
282
+ const missingSegments = [];
283
+ while (true) {
284
+ try {
285
+ const realExistingAncestor = await fs3.realpath(existingAncestor);
286
+ const existingAncestorStats = await fs3.lstat(existingAncestor);
287
+ return {
288
+ existingAncestor,
289
+ existingAncestorIsSymlink: existingAncestorStats.isSymbolicLink(),
290
+ realExistingAncestor,
291
+ prospectivePath: path3.resolve(realExistingAncestor, ...missingSegments)
292
+ };
293
+ } catch (error) {
294
+ if (error?.code !== "ENOENT") {
295
+ throw error;
296
+ }
297
+ const parentPath = path3.dirname(existingAncestor);
298
+ if (parentPath === existingAncestor) {
299
+ throw error;
300
+ }
301
+ missingSegments.unshift(path3.basename(existingAncestor));
302
+ existingAncestor = parentPath;
303
+ }
304
+ }
305
+ }
306
+ function isUnsafeSyncRoot(candidateRoot, home) {
307
+ const filesystemRoot = path3.parse(candidateRoot).root;
308
+ return candidateRoot === filesystemRoot || isSamePathOrAncestor(candidateRoot, home);
309
+ }
310
+ function isSamePathOrAncestor(candidateRoot, target) {
311
+ const relative = path3.relative(candidateRoot, target);
312
+ return relative === "" || Boolean(relative && !relative.startsWith("..") && !path3.isAbsolute(relative));
313
+ }
314
+
315
+ // ../desktop/src/status.mjs
316
+ import path4 from "node:path";
317
+ var CONTROL_DIR2 = ".zergbox-desktop";
318
+ var STATUS_FILE = "status.json";
319
+ function getDesktopStatusPath(localRoot) {
320
+ return path4.join(localRoot, CONTROL_DIR2, STATUS_FILE);
321
+ }
322
+ async function writeDesktopStatus({ localRoot, status }) {
323
+ const statusPath = getDesktopStatusPath(localRoot);
324
+ const nextStatus = {
325
+ version: 1,
326
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString(),
327
+ ...status
328
+ };
329
+ await writeJsonFile(statusPath, nextStatus);
330
+ return nextStatus;
331
+ }
332
+ async function readDesktopStatus({ localRoot, onInvalidJson } = {}) {
333
+ return readJsonFile(getDesktopStatusPath(localRoot), null, {
334
+ fallbackInvalidJson: true,
335
+ onInvalidJson
336
+ });
337
+ }
338
+
339
+ // ../desktop/src/sync-scheduler.mjs
340
+ var SyncScheduler = class {
341
+ constructor({ runOnce, debounceMs = 1e3, intervalMs = 3e4, onError = console.error } = {}) {
342
+ if (typeof runOnce !== "function") {
343
+ throw new Error("runOnce is required");
344
+ }
345
+ this.runOnce = runOnce;
346
+ this.debounceMs = debounceMs;
347
+ this.intervalMs = intervalMs;
348
+ this.onError = onError;
349
+ this.timer = null;
350
+ this.interval = null;
351
+ this.running = false;
352
+ this.pending = false;
353
+ this.stopped = false;
354
+ this.idleWaiters = [];
355
+ }
356
+ start({ immediate = true } = {}) {
357
+ this.stopped = false;
358
+ if (this.intervalMs > 0 && !this.interval) {
359
+ this.interval = setInterval(() => {
360
+ this.trigger("periodic");
361
+ }, this.intervalMs);
362
+ }
363
+ if (immediate) {
364
+ this.trigger("start");
365
+ }
366
+ }
367
+ stop() {
368
+ this.stopped = true;
369
+ if (this.timer) {
370
+ clearTimeout(this.timer);
371
+ this.timer = null;
372
+ }
373
+ if (this.interval) {
374
+ clearInterval(this.interval);
375
+ this.interval = null;
376
+ }
377
+ return this.waitForIdle();
378
+ }
379
+ waitForIdle() {
380
+ if (!this.running) {
381
+ return Promise.resolve();
382
+ }
383
+ return new Promise((resolve) => {
384
+ this.idleWaiters.push(resolve);
385
+ });
386
+ }
387
+ trigger(reason = "manual") {
388
+ if (this.stopped) {
389
+ return;
390
+ }
391
+ if (this.running) {
392
+ this.pending = true;
393
+ return;
394
+ }
395
+ if (this.timer) {
396
+ clearTimeout(this.timer);
397
+ }
398
+ this.timer = setTimeout(() => {
399
+ this.flush(reason);
400
+ }, this.debounceMs);
401
+ }
402
+ async flush(reason = "manual") {
403
+ if (this.stopped) {
404
+ return;
405
+ }
406
+ if (this.running) {
407
+ this.pending = true;
408
+ return;
409
+ }
410
+ if (this.timer) {
411
+ clearTimeout(this.timer);
412
+ this.timer = null;
413
+ }
414
+ this.running = true;
415
+ this.pending = false;
416
+ try {
417
+ await this.runOnce(reason);
418
+ } catch (error) {
419
+ this.onError(error);
420
+ } finally {
421
+ this.running = false;
422
+ for (const resolve of this.idleWaiters.splice(0)) {
423
+ resolve();
424
+ }
425
+ if (this.pending && !this.stopped) {
426
+ this.pending = false;
427
+ this.trigger("pending");
428
+ }
429
+ }
430
+ }
431
+ };
432
+
433
+ // ../desktop/src/sync-runner.mjs
434
+ import crypto3 from "node:crypto";
435
+ import { constants as fsConstants } from "node:fs";
436
+ import fs6 from "node:fs/promises";
437
+ import path7 from "node:path";
438
+ import posixPath from "node:path/posix";
439
+
440
+ // ../desktop/src/local-scan.mjs
441
+ import crypto from "node:crypto";
442
+ import fs4 from "node:fs";
443
+ import fsp from "node:fs/promises";
444
+ import path5 from "node:path";
445
+ function isTransientScanError(error) {
446
+ return ["ENOENT", "ENOTDIR", "EISDIR"].includes(error?.code);
447
+ }
448
+ function normalizeLocalRelativePath(relativePath) {
449
+ if (path5.sep === "\\") {
450
+ return normalizeSyncPath(relativePath);
451
+ }
452
+ return String(relativePath || "").split("/").filter((part) => part && part !== ".").join("/");
453
+ }
454
+ async function hashFile(root, filePath) {
455
+ await assertLocalPathHasNoSymlinks(root, filePath);
456
+ const hash = crypto.createHash("sha256");
457
+ const stream = fs4.createReadStream(filePath);
458
+ for await (const chunk of stream) {
459
+ hash.update(chunk);
460
+ }
461
+ return hash.digest("hex");
462
+ }
463
+ async function walk(root, current, entries, ignorePatterns) {
464
+ await assertLocalPathHasNoSymlinks(root, current);
465
+ let dirents;
466
+ try {
467
+ dirents = await fsp.readdir(current, { withFileTypes: true });
468
+ } catch (error) {
469
+ if (isTransientScanError(error)) {
470
+ return false;
471
+ }
472
+ throw error;
473
+ }
474
+ for (const dirent of dirents) {
475
+ const absolutePath = path5.join(current, dirent.name);
476
+ const relativePath = normalizeLocalRelativePath(path5.relative(root, absolutePath));
477
+ if (!relativePath || isSyncPathIgnored(relativePath, ignorePatterns)) {
478
+ continue;
479
+ }
480
+ if (dirent.isSymbolicLink()) {
481
+ throw new Error(`Refusing to follow symbolic link in sync folder: ${relativePath}`);
482
+ }
483
+ let stat;
484
+ try {
485
+ stat = await fsp.lstat(absolutePath);
486
+ } catch (error) {
487
+ if (isTransientScanError(error)) {
488
+ continue;
489
+ }
490
+ throw error;
491
+ }
492
+ if (stat.isSymbolicLink()) {
493
+ throw new Error(`Refusing to follow symbolic link in sync folder: ${relativePath}`);
494
+ }
495
+ if (dirent.isDirectory()) {
496
+ const childEntries = [];
497
+ const directoryExists = await walk(root, absolutePath, childEntries, ignorePatterns);
498
+ if (!directoryExists) {
499
+ continue;
500
+ }
501
+ entries.push({
502
+ path: relativePath,
503
+ type: "directory",
504
+ mtimeMs: stat.mtimeMs
505
+ });
506
+ entries.push(...childEntries);
507
+ continue;
508
+ }
509
+ if (dirent.isFile()) {
510
+ let contentHash;
511
+ try {
512
+ contentHash = await hashFile(root, absolutePath);
513
+ } catch (error) {
514
+ if (isTransientScanError(error)) {
515
+ continue;
516
+ }
517
+ throw error;
518
+ }
519
+ entries.push({
520
+ path: relativePath,
521
+ type: "file",
522
+ contentHash,
523
+ sizeBytes: stat.size,
524
+ mtimeMs: stat.mtimeMs
525
+ });
526
+ }
527
+ }
528
+ return true;
529
+ }
530
+ async function scanLocalTree(localRoot, options = {}) {
531
+ const ignorePatterns = options.ignorePatterns || DEFAULT_IGNORE_PATTERNS;
532
+ await fsp.mkdir(localRoot, { recursive: true });
533
+ await assertLocalPathHasNoSymlinks(localRoot);
534
+ const entries = [];
535
+ await walk(localRoot, localRoot, entries, ignorePatterns);
536
+ return entries.sort((a, b) => a.path.localeCompare(b.path));
537
+ }
538
+
539
+ // ../desktop/src/sync-lock.mjs
540
+ import crypto2 from "node:crypto";
541
+ import fs5 from "node:fs/promises";
542
+ import os2 from "node:os";
543
+ import path6 from "node:path";
544
+ var CONTROL_DIR3 = ".zergbox-desktop";
545
+ var LOCK_DIR = "sync.lock";
546
+ var OWNER_FILE = "owner.json";
547
+ var DEFAULT_STALE_LOCK_MS = 6 * 60 * 60 * 1e3;
548
+ function getSyncLockPath(localRoot) {
549
+ return path6.join(localRoot, CONTROL_DIR3, LOCK_DIR);
550
+ }
551
+ function getLockOwnerPath(lockPath) {
552
+ return path6.join(lockPath, OWNER_FILE);
553
+ }
554
+ function isProcessAlive(pid) {
555
+ const processId = Number(pid);
556
+ if (!Number.isInteger(processId) || processId <= 0) {
557
+ return false;
558
+ }
559
+ try {
560
+ process.kill(processId, 0);
561
+ return true;
562
+ } catch (error) {
563
+ return error?.code === "EPERM";
564
+ }
565
+ }
566
+ async function readLockOwner(lockPath) {
567
+ return readJsonFile(getLockOwnerPath(lockPath), null, { fallbackInvalidJson: true });
568
+ }
569
+ function getOwnerTimestampMs(owner, lockStat) {
570
+ const acquiredAt = Date.parse(String(owner?.acquiredAt || ""));
571
+ if (Number.isFinite(acquiredAt)) {
572
+ return acquiredAt;
573
+ }
574
+ return lockStat.mtimeMs;
575
+ }
576
+ function isStaleLock({ owner, lockStat, nowMs, staleLockMs, hostname }) {
577
+ if (owner?.hostname === hostname && owner.pid !== void 0) {
578
+ return !isProcessAlive(owner.pid);
579
+ }
580
+ return nowMs - getOwnerTimestampMs(owner, lockStat) > staleLockMs;
581
+ }
582
+ async function releaseSyncLock(lock) {
583
+ const owner = await readLockOwner(lock.lockPath);
584
+ if (owner?.token !== lock.token) {
585
+ return;
586
+ }
587
+ await fs5.rm(lock.lockPath, { recursive: true, force: true });
588
+ }
589
+ async function acquireSyncLock({
590
+ localRoot,
591
+ staleLockMs = DEFAULT_STALE_LOCK_MS,
592
+ now = () => Date.now(),
593
+ hostname = os2.hostname(),
594
+ pid = process.pid
595
+ }) {
596
+ const lockPath = getSyncLockPath(localRoot);
597
+ const token = crypto2.randomUUID();
598
+ const owner = {
599
+ version: 1,
600
+ token,
601
+ pid,
602
+ hostname,
603
+ acquiredAt: new Date(now()).toISOString()
604
+ };
605
+ await fs5.mkdir(path6.dirname(lockPath), { recursive: true, mode: 448 });
606
+ while (true) {
607
+ try {
608
+ await fs5.mkdir(lockPath, { mode: 448 });
609
+ try {
610
+ await writeJsonFile(getLockOwnerPath(lockPath), owner);
611
+ } catch (error) {
612
+ await fs5.rm(lockPath, { recursive: true, force: true });
613
+ throw error;
614
+ }
615
+ return {
616
+ acquired: true,
617
+ lockPath,
618
+ token,
619
+ owner,
620
+ release: () => releaseSyncLock({ lockPath, token })
621
+ };
622
+ } catch (error) {
623
+ if (error?.code !== "EEXIST") {
624
+ throw error;
625
+ }
626
+ }
627
+ let lockStat;
628
+ try {
629
+ lockStat = await fs5.stat(lockPath);
630
+ } catch (error) {
631
+ if (error?.code === "ENOENT") {
632
+ continue;
633
+ }
634
+ throw error;
635
+ }
636
+ const existingOwner = await readLockOwner(lockPath);
637
+ if (isStaleLock({
638
+ owner: existingOwner,
639
+ lockStat,
640
+ nowMs: now(),
641
+ staleLockMs,
642
+ hostname
643
+ })) {
644
+ await fs5.rm(lockPath, { recursive: true, force: true });
645
+ continue;
646
+ }
647
+ return {
648
+ acquired: false,
649
+ lockPath,
650
+ owner: existingOwner,
651
+ release: async () => {
652
+ }
653
+ };
654
+ }
655
+ }
656
+
657
+ // ../desktop/src/sync-runner.mjs
658
+ function dirname(remotePath) {
659
+ const parent = posixPath.dirname(remotePath);
660
+ return parent === "." ? "" : parent;
661
+ }
662
+ function basename(remotePath) {
663
+ return posixPath.basename(remotePath);
664
+ }
665
+ function indexRemoteFolders(rootFolder, remoteEntries) {
666
+ const folders = /* @__PURE__ */ new Map([["", rootFolder.id]]);
667
+ for (const entry of remoteEntries) {
668
+ if (entry.type === "directory") {
669
+ folders.set(entry.path, entry.id);
670
+ }
671
+ }
672
+ return folders;
673
+ }
674
+ function fetchConfiguredRemoteTree(client, config) {
675
+ return fetchRemoteTree(client, {
676
+ orgId: config.organizationId,
677
+ rootFolderId: config.rootFolderId
678
+ });
679
+ }
680
+ async function ensureRemoteFolder(client, { orgId, rootFolderId, remoteFolders, folderPath }) {
681
+ const normalizedPath = normalizeSyncPath(folderPath);
682
+ if (!normalizedPath) {
683
+ return rootFolderId;
684
+ }
685
+ const existing = remoteFolders.get(normalizedPath);
686
+ if (existing) {
687
+ return existing;
688
+ }
689
+ let parentId = rootFolderId;
690
+ let currentPath = "";
691
+ for (const segment of normalizedPath.split("/")) {
692
+ currentPath = normalizeSyncPath(currentPath ? `${currentPath}/${segment}` : segment);
693
+ if (remoteFolders.has(currentPath)) {
694
+ parentId = remoteFolders.get(currentPath);
695
+ continue;
696
+ }
697
+ const result = await client.createFolder({
698
+ orgId,
699
+ parentFolderId: parentId,
700
+ name: segment,
701
+ expectedAbsent: true
702
+ });
703
+ const folderId = result.folder?.id || result.id;
704
+ remoteFolders.set(currentPath, folderId);
705
+ parentId = folderId;
706
+ }
707
+ return parentId;
708
+ }
709
+ async function writeConflictMarker(localRoot, action) {
710
+ const conflictDir = path7.join(localRoot, ".zergbox-desktop", "conflicts");
711
+ const hash = getConflictIdentityHash(action);
712
+ const label = String(action.path || "conflict").replace(/[^A-Za-z0-9._-]+/g, "_").replace(/^_+|_+$/g, "").slice(0, 96) || "conflict";
713
+ const filename = `${label}-${hash}.json`;
714
+ const markerPath = path7.join(conflictDir, filename);
715
+ await fs6.mkdir(conflictDir, { recursive: true });
716
+ try {
717
+ await fs6.writeFile(markerPath, `${JSON.stringify(action, null, 2)}
718
+ `, { flag: "wx" });
719
+ } catch (error) {
720
+ if (error?.code === "EEXIST") {
721
+ return;
722
+ }
723
+ throw error;
724
+ }
725
+ }
726
+ function getConflictIdentityHash(action) {
727
+ const identity = JSON.stringify({
728
+ path: action.path,
729
+ remoteId: action.remoteId || null
730
+ });
731
+ return crypto3.createHash("sha256").update(identity).digest("hex").slice(0, 16);
732
+ }
733
+ async function pruneResolvedConflictMarkers(localRoot, conflictActions) {
734
+ const conflictDir = path7.join(localRoot, ".zergbox-desktop", "conflicts");
735
+ const activeHashes = new Set(conflictActions.map(getConflictIdentityHash));
736
+ let markerNames;
737
+ try {
738
+ markerNames = await fs6.readdir(conflictDir);
739
+ } catch (error) {
740
+ if (error?.code === "ENOENT") {
741
+ return;
742
+ }
743
+ throw error;
744
+ }
745
+ await Promise.all(markerNames.map(async (markerName) => {
746
+ const markerPath = path7.join(conflictDir, markerName);
747
+ let marker;
748
+ try {
749
+ marker = JSON.parse(await fs6.readFile(markerPath, "utf8"));
750
+ } catch {
751
+ return;
752
+ }
753
+ if (marker?.type !== "conflict" || typeof marker.path !== "string") {
754
+ return;
755
+ }
756
+ if (!activeHashes.has(getConflictIdentityHash(marker))) {
757
+ await fs6.rm(markerPath, { force: true });
758
+ }
759
+ }));
760
+ }
761
+ async function writeFileAtomically(filePath, data, options = {}) {
762
+ const directory = path7.dirname(filePath);
763
+ const tempPath = path7.join(directory, `.${path7.basename(filePath)}.zergbox-download`);
764
+ const replace = options.replace !== false;
765
+ await assertLocalPathHasNoSymlinks(options.localRoot, directory);
766
+ await fs6.mkdir(directory, { recursive: true });
767
+ await assertLocalPathHasNoSymlinks(options.localRoot, filePath);
768
+ await assertLocalPathHasNoSymlinks(options.localRoot, tempPath);
769
+ try {
770
+ await fs6.writeFile(tempPath, data, { flag: "wx" });
771
+ if (replace) {
772
+ await assertLocalFileStillMatches(filePath, options.expectedLocalHash, options.syncPath || filePath);
773
+ await assertLocalPathHasNoSymlinks(options.localRoot, filePath);
774
+ await fs6.rename(tempPath, filePath);
775
+ } else {
776
+ await assertLocalPathHasNoSymlinks(options.localRoot, filePath);
777
+ await fs6.copyFile(tempPath, filePath, fsConstants.COPYFILE_EXCL);
778
+ }
779
+ } catch (error) {
780
+ if (!replace && isDestinationExistsError(error)) {
781
+ throw makeLocalDestinationExistsError(options.syncPath || filePath);
782
+ }
783
+ throw error;
784
+ } finally {
785
+ await fs6.rm(tempPath, { force: true });
786
+ }
787
+ }
788
+ async function assertLocalFileStillMatches(filePath, expectedHash, syncPath) {
789
+ if (!expectedHash) {
790
+ return;
791
+ }
792
+ let data;
793
+ try {
794
+ data = await fs6.readFile(filePath);
795
+ } catch (error) {
796
+ throw makeLocalChangedAfterScanError(syncPath, error);
797
+ }
798
+ const currentHash = crypto3.createHash("sha256").update(data).digest("hex");
799
+ if (currentHash !== expectedHash) {
800
+ throw makeLocalChangedAfterScanError(syncPath);
801
+ }
802
+ }
803
+ async function assertLocalPathStillAbsent(filePath, syncPath) {
804
+ try {
805
+ await fs6.lstat(filePath);
806
+ } catch (error) {
807
+ if (error?.code === "ENOENT") {
808
+ return;
809
+ }
810
+ throw makeLocalChangedAfterScanError(syncPath, error);
811
+ }
812
+ throw makeLocalChangedAfterScanError(syncPath);
813
+ }
814
+ async function assertLocalUploadSourceStillMatches(filePath, expectedHash, syncPath) {
815
+ if (!expectedHash) {
816
+ return;
817
+ }
818
+ let data;
819
+ try {
820
+ data = await fs6.readFile(filePath);
821
+ } catch (error) {
822
+ throw makeLocalSourceChangedAfterScanError(syncPath, error);
823
+ }
824
+ const currentHash = crypto3.createHash("sha256").update(data).digest("hex");
825
+ if (currentHash !== expectedHash) {
826
+ throw makeLocalSourceChangedAfterScanError(syncPath);
827
+ }
828
+ }
829
+ async function assertLocalMoveSourceStillMatches(filePath, expectedHash, syncPath) {
830
+ if (!expectedHash) {
831
+ throw makeLocalSourceChangedAfterScanError(syncPath);
832
+ }
833
+ let data;
834
+ try {
835
+ data = await fs6.readFile(filePath);
836
+ } catch (error) {
837
+ throw makeLocalSourceChangedAfterScanError(syncPath, error);
838
+ }
839
+ const currentHash = crypto3.createHash("sha256").update(data).digest("hex");
840
+ if (currentHash !== expectedHash) {
841
+ throw makeLocalSourceChangedAfterScanError(syncPath);
842
+ }
843
+ }
844
+ async function assertLocalDeleteTargetStillMatches(filePath, expectedHash, syncPath) {
845
+ if (!expectedHash) {
846
+ return;
847
+ }
848
+ let data;
849
+ try {
850
+ data = await fs6.readFile(filePath);
851
+ } catch (error) {
852
+ if (error?.code === "ENOENT") {
853
+ return;
854
+ }
855
+ throw makeLocalChangedAfterScanError(syncPath, error);
856
+ }
857
+ const currentHash = crypto3.createHash("sha256").update(data).digest("hex");
858
+ if (currentHash !== expectedHash) {
859
+ throw makeLocalChangedAfterScanError(syncPath);
860
+ }
861
+ }
862
+ function collectLocalSubtreeEntries(entries, syncPath) {
863
+ const normalizedPath = normalizeSyncPath(syncPath);
864
+ const subtreeEntries = /* @__PURE__ */ new Map();
865
+ const iterableEntries = entries instanceof Map ? entries.values() : entries;
866
+ for (const entry of iterableEntries || []) {
867
+ if (pathIsWithin(entry.path, normalizedPath)) {
868
+ subtreeEntries.set(entry.path, entry);
869
+ }
870
+ }
871
+ return subtreeEntries;
872
+ }
873
+ function localSubtreeStillMatches(expectedEntries, currentEntries) {
874
+ if (currentEntries.size === 0) {
875
+ return true;
876
+ }
877
+ if (expectedEntries.size !== currentEntries.size) {
878
+ return false;
879
+ }
880
+ for (const [entryPath, expectedEntry] of expectedEntries) {
881
+ const currentEntry = currentEntries.get(entryPath);
882
+ if (!currentEntry || currentEntry.type !== expectedEntry.type) {
883
+ return false;
884
+ }
885
+ if (currentEntry.revision !== expectedEntry.revision) {
886
+ return false;
887
+ }
888
+ if (expectedEntry.type === "file" && currentEntry.contentHash !== expectedEntry.contentHash) {
889
+ return false;
890
+ }
891
+ }
892
+ return true;
893
+ }
894
+ function localSubtreeExactlyMatches(expectedEntries, currentEntries) {
895
+ if (expectedEntries.size !== currentEntries.size) {
896
+ return false;
897
+ }
898
+ for (const [entryPath, expectedEntry] of expectedEntries) {
899
+ const currentEntry = currentEntries.get(entryPath);
900
+ if (!currentEntry || currentEntry.type !== expectedEntry.type) {
901
+ return false;
902
+ }
903
+ if (expectedEntry.type === "file" && currentEntry.contentHash !== expectedEntry.contentHash) {
904
+ return false;
905
+ }
906
+ }
907
+ return true;
908
+ }
909
+ async function assertLocalFolderDeleteTargetStillMatches(localRoot, expectedEntries, syncPath) {
910
+ const currentEntries = collectLocalSubtreeEntries(await scanLocalTree(localRoot), syncPath);
911
+ if (!localSubtreeStillMatches(expectedEntries, currentEntries)) {
912
+ throw makeLocalChangedAfterScanError(syncPath);
913
+ }
914
+ }
915
+ async function assertLocalFolderMoveSourceStillMatches(localRoot, expectedEntries, syncPath) {
916
+ const currentEntries = collectLocalSubtreeEntries(await scanLocalTree(localRoot), syncPath);
917
+ if (!localSubtreeExactlyMatches(expectedEntries, currentEntries)) {
918
+ throw makeLocalChangedAfterScanError(syncPath);
919
+ }
920
+ }
921
+ function collectRemoteSubtreeEntries(entries, syncPath) {
922
+ const normalizedPath = normalizeSyncPath(syncPath);
923
+ const subtreeEntries = /* @__PURE__ */ new Map();
924
+ const iterableEntries = entries instanceof Map ? entries.values() : entries;
925
+ for (const entry of iterableEntries || []) {
926
+ if (pathIsWithin(entry.path, normalizedPath)) {
927
+ subtreeEntries.set(entry.path, entry);
928
+ }
929
+ }
930
+ return subtreeEntries;
931
+ }
932
+ function remoteSubtreeStillMatches(expectedEntries, currentEntries) {
933
+ if (expectedEntries.size !== currentEntries.size) {
934
+ return false;
935
+ }
936
+ for (const [entryPath, expectedEntry] of expectedEntries) {
937
+ const currentEntry = currentEntries.get(entryPath);
938
+ if (!currentEntry || currentEntry.type !== expectedEntry.type || currentEntry.id !== expectedEntry.id) {
939
+ return false;
940
+ }
941
+ if (expectedEntry.type === "file" && currentEntry.contentHash !== expectedEntry.contentHash) {
942
+ return false;
943
+ }
944
+ }
945
+ return true;
946
+ }
947
+ async function assertRemoteFolderDeleteTargetStillMatches(client, config, expectedEntries, action) {
948
+ const currentRemoteTree = await fetchConfiguredRemoteTree(client, config);
949
+ const currentTarget = currentRemoteTree.entries.find((entry) => entry.id === action.remoteId);
950
+ if (!currentTarget) {
951
+ return { alreadyGone: true };
952
+ }
953
+ if (currentTarget.type !== "directory" || currentTarget.path !== action.path) {
954
+ throw makeRemoteChangedAfterScanError(action.path);
955
+ }
956
+ const currentEntries = collectRemoteSubtreeEntries(currentRemoteTree.entries, action.path);
957
+ if (!remoteSubtreeStillMatches(expectedEntries, currentEntries)) {
958
+ throw makeRemoteChangedAfterScanError(action.path);
959
+ }
960
+ return { alreadyGone: false };
961
+ }
962
+ async function assertRemoteFileDeleteTargetStillMatches(client, config, expectedEntry, action) {
963
+ const currentRemoteTree = await fetchConfiguredRemoteTree(client, config);
964
+ const currentTarget = currentRemoteTree.entries.find((entry) => entry.id === action.remoteId);
965
+ if (!currentTarget) {
966
+ return { alreadyGone: true };
967
+ }
968
+ if (currentTarget.type !== "file" || currentTarget.path !== action.path || currentTarget.contentHash !== expectedEntry?.contentHash || currentTarget.revision !== expectedEntry?.revision) {
969
+ throw makeRemoteChangedAfterScanError(action.path);
970
+ }
971
+ return { alreadyGone: false };
972
+ }
973
+ async function assertRemoteFileAtPathStillMatches(client, config, expectedEntry, action, syncPath = action.path) {
974
+ const currentRemoteTree = await fetchConfiguredRemoteTree(client, config);
975
+ const currentTarget = currentRemoteTree.entries.find((entry) => entry.id === action.remoteId);
976
+ if (!currentTarget || currentTarget.type !== "file" || currentTarget.path !== action.path || currentTarget.contentHash !== expectedEntry?.contentHash || currentTarget.revision !== expectedEntry?.revision) {
977
+ throw makeRemoteChangedAfterScanError(syncPath);
978
+ }
979
+ return currentTarget;
980
+ }
981
+ async function assertRemotePathStillAbsent(client, config, syncPath) {
982
+ const currentRemoteTree = await fetchConfiguredRemoteTree(client, config);
983
+ const currentTarget = currentRemoteTree.entries.find((entry) => entry.path === syncPath);
984
+ if (currentTarget) {
985
+ throw makeRemoteChangedAfterScanError(syncPath);
986
+ }
987
+ }
988
+ async function assertRemoteFileMoveSourceStillMatches(client, config, expectedEntry, action) {
989
+ const currentRemoteTree = await fetchConfiguredRemoteTree(client, config);
990
+ const currentTarget = currentRemoteTree.entries.find((entry) => entry.id === action.remoteId);
991
+ const currentDestination = currentRemoteTree.entries.find((entry) => {
992
+ return entry.path === action.path && entry.id !== action.remoteId;
993
+ });
994
+ if (!currentTarget || currentTarget.type !== "file" || currentTarget.path !== action.fromPath || currentTarget.contentHash !== expectedEntry?.contentHash || currentTarget.revision !== expectedEntry?.revision || currentDestination) {
995
+ throw makeRemoteChangedAfterScanError(action.fromPath);
996
+ }
997
+ return currentTarget;
998
+ }
999
+ async function assertRemoteFolderMoveSourceStillMatches(client, config, expectedEntries, action) {
1000
+ const currentRemoteTree = await fetchConfiguredRemoteTree(client, config);
1001
+ const currentTarget = currentRemoteTree.entries.find((entry) => entry.id === action.remoteId);
1002
+ const currentDestination = currentRemoteTree.entries.find((entry) => {
1003
+ return entry.path === action.path && entry.id !== action.remoteId;
1004
+ });
1005
+ if (!currentTarget || currentTarget.type !== "directory" || currentTarget.path !== action.fromPath || currentTarget.revision !== expectedEntries.get(action.fromPath)?.revision || currentDestination) {
1006
+ throw makeRemoteChangedAfterScanError(action.fromPath);
1007
+ }
1008
+ const currentEntries = collectRemoteSubtreeEntries(currentRemoteTree.entries, action.fromPath);
1009
+ if (!remoteSubtreeStillMatches(expectedEntries, currentEntries)) {
1010
+ throw makeRemoteChangedAfterScanError(action.fromPath);
1011
+ }
1012
+ return currentTarget;
1013
+ }
1014
+ function makeLocalChangedAfterScanError(syncPath, cause) {
1015
+ const error = new Error(`Local sync destination changed after sync scan: ${syncPath}`);
1016
+ error.code = "ESTALE";
1017
+ if (cause) {
1018
+ error.cause = cause;
1019
+ }
1020
+ return error;
1021
+ }
1022
+ function makeLocalSourceChangedAfterScanError(syncPath, cause) {
1023
+ const error = new Error(`Local sync source changed after sync scan: ${syncPath}`);
1024
+ error.code = "ESTALE";
1025
+ if (cause) {
1026
+ error.cause = cause;
1027
+ }
1028
+ return error;
1029
+ }
1030
+ function makeLocalDestinationExistsError(syncPath) {
1031
+ const error = new Error(`Local sync destination already exists: ${syncPath}`);
1032
+ error.code = "EEXIST";
1033
+ return error;
1034
+ }
1035
+ function makeRemoteChangedAfterScanError(syncPath) {
1036
+ const error = new Error(`Remote sync target changed after sync scan: ${syncPath}`);
1037
+ error.code = "ESTALE";
1038
+ return error;
1039
+ }
1040
+ function isDestinationExistsError(error) {
1041
+ return ["EEXIST", "ENOTEMPTY", "EISDIR", "ERR_FS_CP_EEXIST"].includes(error?.code);
1042
+ }
1043
+ async function moveLocalFileWithoutReplacing(fromPath, toPath, syncPath) {
1044
+ try {
1045
+ await fs6.copyFile(fromPath, toPath, fsConstants.COPYFILE_EXCL);
1046
+ } catch (error) {
1047
+ if (isDestinationExistsError(error)) {
1048
+ throw makeLocalDestinationExistsError(syncPath);
1049
+ }
1050
+ throw error;
1051
+ }
1052
+ await fs6.unlink(fromPath);
1053
+ }
1054
+ async function moveLocalDirectoryWithoutReplacing(fromPath, toPath, syncPath, options = {}) {
1055
+ let destinationCreated = false;
1056
+ try {
1057
+ await fs6.mkdir(toPath);
1058
+ destinationCreated = true;
1059
+ await options.validateSource?.();
1060
+ } catch (error) {
1061
+ if (destinationCreated) {
1062
+ await fs6.rm(toPath, { recursive: true, force: true });
1063
+ }
1064
+ if (isDestinationExistsError(error)) {
1065
+ throw makeLocalDestinationExistsError(syncPath);
1066
+ }
1067
+ throw error;
1068
+ }
1069
+ try {
1070
+ for (const name of await fs6.readdir(fromPath)) {
1071
+ await fs6.cp(path7.join(fromPath, name), path7.join(toPath, name), {
1072
+ recursive: true,
1073
+ force: false,
1074
+ errorOnExist: true
1075
+ });
1076
+ }
1077
+ await fs6.rm(fromPath, { recursive: true, force: false });
1078
+ } catch (error) {
1079
+ if (isDestinationExistsError(error)) {
1080
+ throw makeLocalDestinationExistsError(syncPath);
1081
+ }
1082
+ throw error;
1083
+ }
1084
+ }
1085
+ function resolveLocalSyncPath(localRoot, syncPath) {
1086
+ if (!isSafeSyncPath(syncPath)) {
1087
+ throw new Error(`Unsafe sync path: ${syncPath}`);
1088
+ }
1089
+ const root = path7.resolve(localRoot);
1090
+ const localPath = path7.resolve(root, ...normalizeSyncPath(syncPath).split("/"));
1091
+ const relativePath = path7.relative(root, localPath);
1092
+ if (relativePath && (relativePath.startsWith("..") || path7.isAbsolute(relativePath))) {
1093
+ throw new Error(`Sync path escapes local root: ${syncPath}`);
1094
+ }
1095
+ return localPath;
1096
+ }
1097
+ function buildSyncState(localEntries, remoteEntries, syncedAt, options = {}) {
1098
+ const remoteByPath = new Map(remoteEntries.map((entry) => [entry.path, entry]));
1099
+ const skipPaths = options.skipPaths || /* @__PURE__ */ new Set();
1100
+ const entries = {};
1101
+ for (const localEntry of localEntries) {
1102
+ if (isSkippedSyncStatePath(localEntry.path, skipPaths)) {
1103
+ continue;
1104
+ }
1105
+ const remoteEntry = remoteByPath.get(localEntry.path);
1106
+ if (!remoteEntry || localEntry.type !== remoteEntry.type) {
1107
+ continue;
1108
+ }
1109
+ entries[localEntry.path] = {
1110
+ type: localEntry.type,
1111
+ remoteId: remoteEntry.id,
1112
+ localHash: localEntry.type === "file" ? localEntry.contentHash : null,
1113
+ remoteHash: remoteEntry.type === "file" ? remoteEntry.contentHash : null,
1114
+ syncedAt
1115
+ };
1116
+ }
1117
+ return {
1118
+ version: 1,
1119
+ syncedAt,
1120
+ entries
1121
+ };
1122
+ }
1123
+ function syncStateEntryMatchesCandidate(stateEntry, candidateEntry) {
1124
+ if (!stateEntry || !candidateEntry || stateEntry.type !== candidateEntry.type || stateEntry.remoteId !== candidateEntry.remoteId) {
1125
+ return false;
1126
+ }
1127
+ if (stateEntry.type === "directory") {
1128
+ return true;
1129
+ }
1130
+ return stateEntry.localHash === candidateEntry.localHash && stateEntry.remoteHash === candidateEntry.remoteHash;
1131
+ }
1132
+ function buildStableFinalSyncState(localEntries, remoteEntries, syncedAt, options = {}) {
1133
+ const candidateState = buildSyncState(localEntries, remoteEntries, syncedAt, options);
1134
+ const incrementalState = options.incrementalState;
1135
+ if (!incrementalState) {
1136
+ return candidateState;
1137
+ }
1138
+ const baselineLocalByPath = new Map((options.baselineLocalEntries || []).map((entry) => [entry.path, entry]));
1139
+ const baselineRemoteByPath = new Map((options.baselineRemoteEntries || []).map((entry) => [entry.path, entry]));
1140
+ const skipPaths = options.skipPaths || /* @__PURE__ */ new Set();
1141
+ const entries = {};
1142
+ for (const [entryPath, stateEntry] of Object.entries(incrementalState.entries || {})) {
1143
+ if (isSkippedSyncStatePath(entryPath, skipPaths)) {
1144
+ continue;
1145
+ }
1146
+ const candidateEntry = candidateState.entries[entryPath];
1147
+ if (syncStateEntryMatchesCandidate(stateEntry, candidateEntry)) {
1148
+ entries[entryPath] = candidateEntry;
1149
+ continue;
1150
+ }
1151
+ if (baselineLocalByPath.has(entryPath) || baselineRemoteByPath.has(entryPath)) {
1152
+ entries[entryPath] = stateEntry;
1153
+ }
1154
+ }
1155
+ for (const [entryPath, candidateEntry] of Object.entries(candidateState.entries)) {
1156
+ if (entries[entryPath] || candidateEntry.type !== "directory") {
1157
+ continue;
1158
+ }
1159
+ entries[entryPath] = candidateEntry;
1160
+ }
1161
+ return {
1162
+ version: 1,
1163
+ syncedAt,
1164
+ entries
1165
+ };
1166
+ }
1167
+ function isSkippedSyncStatePath(syncPath, skipPaths) {
1168
+ for (const skipPath of skipPaths) {
1169
+ if (!skipPath) {
1170
+ continue;
1171
+ }
1172
+ if (syncPath === skipPath || syncPath.startsWith(`${skipPath}/`)) {
1173
+ return true;
1174
+ }
1175
+ }
1176
+ return false;
1177
+ }
1178
+ function cloneSyncState(state) {
1179
+ return {
1180
+ version: 1,
1181
+ syncedAt: state.syncedAt || null,
1182
+ entries: { ...state.entries || {} }
1183
+ };
1184
+ }
1185
+ function removeStatePath(syncState, syncPath) {
1186
+ const normalizedPath = normalizeSyncPath(syncPath);
1187
+ if (!normalizedPath) {
1188
+ return;
1189
+ }
1190
+ for (const path9 of Object.keys(syncState.entries || {})) {
1191
+ if (path9 === normalizedPath || path9.startsWith(`${normalizedPath}/`)) {
1192
+ delete syncState.entries[path9];
1193
+ }
1194
+ }
1195
+ }
1196
+ function pathIsWithin(syncPath, parentPath) {
1197
+ return syncPath === parentPath || syncPath.startsWith(`${parentPath}/`);
1198
+ }
1199
+ function replacePathPrefix(syncPath, fromPath, toPath) {
1200
+ if (syncPath === fromPath) {
1201
+ return toPath;
1202
+ }
1203
+ return `${toPath}/${syncPath.slice(fromPath.length + 1)}`;
1204
+ }
1205
+ function moveStatePath(syncState, fromPath, toPath, syncedAt) {
1206
+ const nextEntries = {};
1207
+ for (const [entryPath, entry] of Object.entries(syncState.entries || {})) {
1208
+ if (pathIsWithin(entryPath, fromPath)) {
1209
+ nextEntries[replacePathPrefix(entryPath, fromPath, toPath)] = {
1210
+ ...entry,
1211
+ syncedAt
1212
+ };
1213
+ continue;
1214
+ }
1215
+ nextEntries[entryPath] = entry;
1216
+ }
1217
+ syncState.entries = nextEntries;
1218
+ }
1219
+ function rekeyRemoteFolders(remoteFolders, fromPath, toPath) {
1220
+ const updates = [];
1221
+ for (const [folderPath, folderId] of remoteFolders) {
1222
+ if (pathIsWithin(folderPath, fromPath)) {
1223
+ updates.push({
1224
+ oldPath: folderPath,
1225
+ newPath: replacePathPrefix(folderPath, fromPath, toPath),
1226
+ folderId
1227
+ });
1228
+ }
1229
+ }
1230
+ for (const update of updates) {
1231
+ remoteFolders.delete(update.oldPath);
1232
+ }
1233
+ for (const update of updates) {
1234
+ remoteFolders.set(update.newPath, update.folderId);
1235
+ }
1236
+ }
1237
+ function getResponseFile(result) {
1238
+ return result?.file || result;
1239
+ }
1240
+ function buildFileStateEntry({ remoteId, localHash, remoteHash }) {
1241
+ if (!remoteId || !localHash || !remoteHash) {
1242
+ return null;
1243
+ }
1244
+ return {
1245
+ type: "file",
1246
+ remoteId,
1247
+ localHash,
1248
+ remoteHash
1249
+ };
1250
+ }
1251
+ function buildDirectoryStateEntry({ remoteId }) {
1252
+ if (!remoteId) {
1253
+ return null;
1254
+ }
1255
+ return {
1256
+ type: "directory",
1257
+ remoteId,
1258
+ localHash: null,
1259
+ remoteHash: null
1260
+ };
1261
+ }
1262
+ function applyStateMutation(syncState, mutation, syncedAt) {
1263
+ if (!mutation) {
1264
+ return false;
1265
+ }
1266
+ let changed = false;
1267
+ if (mutation.removePath) {
1268
+ removeStatePath(syncState, mutation.removePath);
1269
+ changed = true;
1270
+ }
1271
+ if (mutation.movePath) {
1272
+ moveStatePath(syncState, mutation.movePath.fromPath, mutation.movePath.path, syncedAt);
1273
+ changed = true;
1274
+ }
1275
+ if (mutation.entryPath && mutation.entry) {
1276
+ syncState.entries[mutation.entryPath] = {
1277
+ ...mutation.entry,
1278
+ syncedAt
1279
+ };
1280
+ changed = true;
1281
+ }
1282
+ return changed;
1283
+ }
1284
+ function countConflicts(actions) {
1285
+ return actions.filter((action) => action.type === "conflict").length;
1286
+ }
1287
+ async function getLastKnownSyncedAt(config) {
1288
+ try {
1289
+ const status = await readDesktopStatus({ localRoot: config.localRoot });
1290
+ if (status?.lastSyncedAt) {
1291
+ return status.lastSyncedAt;
1292
+ }
1293
+ const state = await readJsonFile(config.statePath, null, {
1294
+ fallbackInvalidJson: true,
1295
+ assertSafePath: assertSafeStatePath
1296
+ });
1297
+ return state?.syncedAt || null;
1298
+ } catch {
1299
+ return null;
1300
+ }
1301
+ }
1302
+ async function applyAction(client, config, context, action) {
1303
+ switch (action.type) {
1304
+ case "create_remote_folder": {
1305
+ await assertRemotePathStillAbsent(client, config, action.path);
1306
+ const folderId = await ensureRemoteFolder(client, {
1307
+ orgId: config.organizationId,
1308
+ rootFolderId: context.rootFolder.id,
1309
+ remoteFolders: context.remoteFolders,
1310
+ folderPath: action.path
1311
+ });
1312
+ const entry = buildDirectoryStateEntry({ remoteId: folderId });
1313
+ return entry ? { entryPath: action.path, entry } : null;
1314
+ }
1315
+ case "upload_file": {
1316
+ const localPath = resolveLocalSyncPath(config.localRoot, action.path);
1317
+ const localEntry = context.localEntries.get(action.path);
1318
+ let result;
1319
+ await assertLocalPathHasNoSymlinks(config.localRoot, localPath);
1320
+ if (action.remoteId) {
1321
+ const currentRemote = await assertRemoteFileAtPathStillMatches(
1322
+ client,
1323
+ config,
1324
+ context.remoteEntries.get(action.path),
1325
+ action
1326
+ );
1327
+ result = await client.updateFileContent({
1328
+ fileId: action.remoteId,
1329
+ filePath: localPath,
1330
+ expectedRevision: currentRemote.revision
1331
+ });
1332
+ } else {
1333
+ await assertRemotePathStillAbsent(client, config, action.path);
1334
+ const folderId = await ensureRemoteFolder(client, {
1335
+ orgId: config.organizationId,
1336
+ rootFolderId: context.rootFolder.id,
1337
+ remoteFolders: context.remoteFolders,
1338
+ folderPath: dirname(action.path)
1339
+ });
1340
+ result = await client.uploadFile({
1341
+ orgId: config.organizationId,
1342
+ folderId,
1343
+ filePath: localPath,
1344
+ filename: basename(action.path),
1345
+ replaceExisting: false
1346
+ });
1347
+ }
1348
+ const file = getResponseFile(result);
1349
+ await assertLocalUploadSourceStillMatches(localPath, localEntry?.contentHash, action.path);
1350
+ const entry = buildFileStateEntry({
1351
+ remoteId: file?.id || action.remoteId,
1352
+ localHash: localEntry?.contentHash,
1353
+ remoteHash: file ? remoteFileFingerprint(file) : null
1354
+ });
1355
+ return entry ? { entryPath: action.path, entry } : null;
1356
+ }
1357
+ case "move_remote_file": {
1358
+ const localEntry = context.localEntries.get(action.path);
1359
+ const existingRemoteEntry = context.remoteEntries.get(action.fromPath);
1360
+ const currentRemote = await assertRemoteFileMoveSourceStillMatches(
1361
+ client,
1362
+ config,
1363
+ existingRemoteEntry,
1364
+ action
1365
+ );
1366
+ const folderId = await ensureRemoteFolder(client, {
1367
+ orgId: config.organizationId,
1368
+ rootFolderId: context.rootFolder.id,
1369
+ remoteFolders: context.remoteFolders,
1370
+ folderPath: dirname(action.path)
1371
+ });
1372
+ const result = await client.updateFile({
1373
+ fileId: action.remoteId,
1374
+ folderId,
1375
+ name: basename(action.path),
1376
+ expectedRevision: currentRemote.revision
1377
+ });
1378
+ const file = getResponseFile(result);
1379
+ const entry = buildFileStateEntry({
1380
+ remoteId: file?.id || action.remoteId,
1381
+ localHash: localEntry?.contentHash,
1382
+ remoteHash: file ? remoteFileFingerprint(file) : existingRemoteEntry?.contentHash
1383
+ });
1384
+ return entry ? { removePath: action.fromPath, entryPath: action.path, entry } : { removePath: action.fromPath };
1385
+ }
1386
+ case "move_remote_folder": {
1387
+ const currentRemote = await assertRemoteFolderMoveSourceStillMatches(
1388
+ client,
1389
+ config,
1390
+ collectRemoteSubtreeEntries(context.remoteEntries, action.fromPath),
1391
+ action
1392
+ );
1393
+ const parentFolderId = await ensureRemoteFolder(client, {
1394
+ orgId: config.organizationId,
1395
+ rootFolderId: context.rootFolder.id,
1396
+ remoteFolders: context.remoteFolders,
1397
+ folderPath: dirname(action.path)
1398
+ });
1399
+ await client.updateFolder({
1400
+ folderId: action.remoteId,
1401
+ parentFolderId,
1402
+ name: basename(action.path),
1403
+ expectedRevision: currentRemote.revision
1404
+ });
1405
+ rekeyRemoteFolders(context.remoteFolders, action.fromPath, action.path);
1406
+ return { movePath: { fromPath: action.fromPath, path: action.path } };
1407
+ }
1408
+ case "create_local_folder": {
1409
+ const localPath = resolveLocalSyncPath(config.localRoot, action.path);
1410
+ await assertLocalPathHasNoSymlinks(config.localRoot, localPath);
1411
+ await assertLocalPathStillAbsent(localPath, action.path);
1412
+ await fs6.mkdir(localPath, { recursive: true });
1413
+ await assertLocalPathHasNoSymlinks(config.localRoot, localPath);
1414
+ const remoteEntry = context.remoteEntries.get(action.path);
1415
+ const entry = buildDirectoryStateEntry({ remoteId: action.remoteId || remoteEntry?.id });
1416
+ return entry ? { entryPath: action.path, entry } : null;
1417
+ }
1418
+ case "move_local_file": {
1419
+ const fromLocalPath = resolveLocalSyncPath(config.localRoot, action.fromPath);
1420
+ const toLocalPath = resolveLocalSyncPath(config.localRoot, action.path);
1421
+ const localEntry = context.localEntries.get(action.fromPath);
1422
+ const remoteEntry = context.remoteEntries.get(action.path);
1423
+ await assertLocalPathHasNoSymlinks(config.localRoot, fromLocalPath);
1424
+ await assertLocalPathHasNoSymlinks(config.localRoot, toLocalPath);
1425
+ await fs6.mkdir(path7.dirname(toLocalPath), { recursive: true });
1426
+ await assertLocalPathHasNoSymlinks(config.localRoot, toLocalPath);
1427
+ await assertLocalMoveSourceStillMatches(fromLocalPath, localEntry?.contentHash, action.fromPath);
1428
+ await assertLocalPathHasNoSymlinks(config.localRoot, fromLocalPath);
1429
+ await moveLocalFileWithoutReplacing(fromLocalPath, toLocalPath, action.path);
1430
+ const entry = buildFileStateEntry({
1431
+ remoteId: action.remoteId,
1432
+ localHash: localEntry?.contentHash,
1433
+ remoteHash: remoteEntry?.contentHash
1434
+ });
1435
+ return entry ? { removePath: action.fromPath, entryPath: action.path, entry } : { removePath: action.fromPath };
1436
+ }
1437
+ case "move_local_folder": {
1438
+ const fromLocalPath = resolveLocalSyncPath(config.localRoot, action.fromPath);
1439
+ const toLocalPath = resolveLocalSyncPath(config.localRoot, action.path);
1440
+ await assertLocalPathHasNoSymlinks(config.localRoot, fromLocalPath);
1441
+ await assertLocalPathHasNoSymlinks(config.localRoot, toLocalPath);
1442
+ await fs6.mkdir(path7.dirname(toLocalPath), { recursive: true });
1443
+ await assertLocalPathHasNoSymlinks(config.localRoot, toLocalPath);
1444
+ await moveLocalDirectoryWithoutReplacing(fromLocalPath, toLocalPath, action.path, {
1445
+ validateSource: () => assertLocalFolderMoveSourceStillMatches(
1446
+ config.localRoot,
1447
+ collectLocalSubtreeEntries(context.localEntries, action.fromPath),
1448
+ action.fromPath
1449
+ )
1450
+ });
1451
+ return { movePath: { fromPath: action.fromPath, path: action.path } };
1452
+ }
1453
+ case "download_file": {
1454
+ const localPath = resolveLocalSyncPath(config.localRoot, action.path);
1455
+ const remoteEntry = context.remoteEntries.get(action.path);
1456
+ const localEntry = context.localEntries.get(action.path);
1457
+ await assertLocalPathHasNoSymlinks(config.localRoot, localPath);
1458
+ await assertRemoteFileAtPathStillMatches(client, config, remoteEntry, action);
1459
+ const data = await client.downloadFile(action.remoteId);
1460
+ await assertRemoteFileAtPathStillMatches(client, config, remoteEntry, action);
1461
+ await writeFileAtomically(localPath, data, {
1462
+ localRoot: config.localRoot,
1463
+ replace: action.reason !== "remote_created",
1464
+ expectedLocalHash: action.reason === "remote_changed" ? localEntry?.contentHash : null,
1465
+ syncPath: action.path
1466
+ });
1467
+ const entry = buildFileStateEntry({
1468
+ remoteId: action.remoteId,
1469
+ localHash: crypto3.createHash("sha256").update(data).digest("hex"),
1470
+ remoteHash: remoteEntry?.contentHash
1471
+ });
1472
+ return entry ? { entryPath: action.path, entry } : null;
1473
+ }
1474
+ case "delete_remote": {
1475
+ const deleteTarget = await assertRemoteFileDeleteTargetStillMatches(
1476
+ client,
1477
+ config,
1478
+ context.remoteEntries.get(action.path),
1479
+ action
1480
+ );
1481
+ if (!deleteTarget.alreadyGone) {
1482
+ await client.deleteRemoteFile(action.remoteId, {
1483
+ expectedRevision: context.remoteEntries.get(action.path)?.revision
1484
+ });
1485
+ }
1486
+ return { removePath: action.path };
1487
+ }
1488
+ case "delete_remote_folder": {
1489
+ const deleteTarget = await assertRemoteFolderDeleteTargetStillMatches(
1490
+ client,
1491
+ config,
1492
+ collectRemoteSubtreeEntries(context.remoteEntries, action.path),
1493
+ action
1494
+ );
1495
+ if (!deleteTarget.alreadyGone) {
1496
+ await client.deleteRemoteFolder(action.remoteId, {
1497
+ expectedRevision: context.remoteEntries.get(action.path)?.revision
1498
+ });
1499
+ }
1500
+ return { removePath: action.path };
1501
+ }
1502
+ case "delete_local": {
1503
+ const localPath = resolveLocalSyncPath(config.localRoot, action.path);
1504
+ await assertLocalPathHasNoSymlinks(config.localRoot, localPath);
1505
+ await assertLocalDeleteTargetStillMatches(
1506
+ localPath,
1507
+ context.localEntries.get(action.path)?.contentHash,
1508
+ action.path
1509
+ );
1510
+ await assertLocalPathHasNoSymlinks(config.localRoot, localPath);
1511
+ await fs6.rm(localPath, {
1512
+ force: true,
1513
+ recursive: false
1514
+ });
1515
+ return { removePath: action.path };
1516
+ }
1517
+ case "delete_local_folder": {
1518
+ const localPath = resolveLocalSyncPath(config.localRoot, action.path);
1519
+ await assertLocalPathHasNoSymlinks(config.localRoot, localPath);
1520
+ await assertLocalFolderDeleteTargetStillMatches(
1521
+ config.localRoot,
1522
+ collectLocalSubtreeEntries(context.localEntries, action.path),
1523
+ action.path
1524
+ );
1525
+ await assertLocalPathHasNoSymlinks(config.localRoot, localPath);
1526
+ await fs6.rm(localPath, {
1527
+ force: true,
1528
+ recursive: true
1529
+ });
1530
+ return { removePath: action.path };
1531
+ }
1532
+ case "conflict":
1533
+ await writeConflictMarker(config.localRoot, action);
1534
+ return null;
1535
+ default:
1536
+ throw new Error(`Unknown sync action: ${action.type}`);
1537
+ }
1538
+ }
1539
+ async function loadDesktopConfig(configPath = getDefaultConfigPath()) {
1540
+ const config = await readJsonFile(configPath, null);
1541
+ if (!config) {
1542
+ throw new Error(`ZergBox Desktop is not configured. Run "zergbox-desktop init" first. Missing: ${configPath}`);
1543
+ }
1544
+ return {
1545
+ intervalSeconds: 30,
1546
+ localRoot: getDefaultLocalRoot(),
1547
+ ...config,
1548
+ configPath,
1549
+ statePath: config.statePath || getDefaultStatePath(config.localRoot || getDefaultLocalRoot())
1550
+ };
1551
+ }
1552
+ async function runSyncOnce({
1553
+ configPath = getDefaultConfigPath(),
1554
+ config: providedConfig,
1555
+ client: providedClient,
1556
+ force = false
1557
+ } = {}) {
1558
+ const config = providedConfig ? {
1559
+ intervalSeconds: 30,
1560
+ localRoot: getDefaultLocalRoot(),
1561
+ ...providedConfig,
1562
+ statePath: providedConfig.statePath || getDefaultStatePath(providedConfig.localRoot || getDefaultLocalRoot())
1563
+ } : await loadDesktopConfig(configPath);
1564
+ await assertSafeLocalRoot(config.localRoot);
1565
+ await assertSafeStatePath(config.statePath);
1566
+ await fs6.mkdir(config.localRoot, { recursive: true });
1567
+ await assertLocalPathHasNoSymlinks(config.localRoot);
1568
+ const runtimeStatus = await getDesktopRuntimeStatus({ localRoot: config.localRoot });
1569
+ if (runtimeStatus.paused && !force) {
1570
+ const lastSyncedAt = await getLastKnownSyncedAt(config);
1571
+ await writeDesktopStatus({
1572
+ localRoot: config.localRoot,
1573
+ status: {
1574
+ state: "paused",
1575
+ actionCount: 0,
1576
+ conflictCount: 0,
1577
+ lastSyncedAt,
1578
+ pauseReason: runtimeStatus.pauseReason,
1579
+ pauseWarning: runtimeStatus.pauseWarning || null
1580
+ }
1581
+ });
1582
+ return {
1583
+ actions: [],
1584
+ localRoot: config.localRoot,
1585
+ statePath: config.statePath,
1586
+ syncedAt: null,
1587
+ paused: true,
1588
+ pauseReason: runtimeStatus.pauseReason,
1589
+ pauseWarning: runtimeStatus.pauseWarning || null,
1590
+ locked: false
1591
+ };
1592
+ }
1593
+ const lock = await acquireSyncLock({ localRoot: config.localRoot });
1594
+ if (!lock.acquired) {
1595
+ const lastSyncedAt = await getLastKnownSyncedAt(config);
1596
+ await writeDesktopStatus({
1597
+ localRoot: config.localRoot,
1598
+ status: {
1599
+ state: "locked",
1600
+ actionCount: 0,
1601
+ conflictCount: 0,
1602
+ lastSyncedAt,
1603
+ lockPath: lock.lockPath,
1604
+ lockOwner: lock.owner || null
1605
+ }
1606
+ });
1607
+ return {
1608
+ actions: [],
1609
+ localRoot: config.localRoot,
1610
+ statePath: config.statePath,
1611
+ syncedAt: null,
1612
+ paused: false,
1613
+ locked: true,
1614
+ lockPath: lock.lockPath,
1615
+ lockOwner: lock.owner || null
1616
+ };
1617
+ }
1618
+ let plannedActions = [];
1619
+ const completedActions = [];
1620
+ let failedAction = null;
1621
+ try {
1622
+ const lastSyncedAt = await getLastKnownSyncedAt(config);
1623
+ await writeDesktopStatus({
1624
+ localRoot: config.localRoot,
1625
+ status: {
1626
+ state: "syncing",
1627
+ actionCount: 0,
1628
+ plannedActionCount: null,
1629
+ conflictCount: 0,
1630
+ lastSyncedAt,
1631
+ currentAction: null,
1632
+ errorMessage: null
1633
+ }
1634
+ });
1635
+ const client = providedClient || new ZergBoxDesktopClient({
1636
+ baseUrl: config.baseUrl,
1637
+ token: config.token,
1638
+ requestTimeoutMs: config.requestTimeoutMs
1639
+ });
1640
+ const state = await readJsonFile(config.statePath, { version: 1, entries: {} }, {
1641
+ recoverInvalidJson: true,
1642
+ assertSafePath: assertSafeStatePath
1643
+ });
1644
+ const localEntries = await scanLocalTree(config.localRoot);
1645
+ const remoteTree = await fetchConfiguredRemoteTree(client, config);
1646
+ const actions = planSync({ localEntries, remoteEntries: remoteTree.entries, state });
1647
+ plannedActions = actions;
1648
+ const incrementalState = cloneSyncState(state);
1649
+ const context = {
1650
+ rootFolder: remoteTree.rootFolder,
1651
+ remoteFolders: indexRemoteFolders(remoteTree.rootFolder, remoteTree.entries),
1652
+ localEntries: new Map(localEntries.map((entry) => [entry.path, entry])),
1653
+ remoteEntries: new Map(remoteTree.entries.map((entry) => [entry.path, entry]))
1654
+ };
1655
+ for (const action of actions) {
1656
+ await writeDesktopStatus({
1657
+ localRoot: config.localRoot,
1658
+ status: {
1659
+ state: "syncing",
1660
+ actionCount: completedActions.length,
1661
+ plannedActionCount: actions.length,
1662
+ conflictCount: countConflicts(completedActions),
1663
+ lastSyncedAt,
1664
+ currentAction: action,
1665
+ errorMessage: null
1666
+ }
1667
+ });
1668
+ failedAction = action;
1669
+ let mutation;
1670
+ try {
1671
+ mutation = await applyAction(client, config, context, action);
1672
+ } catch (error) {
1673
+ if (!(error instanceof ZergBoxSyncConflictError)) {
1674
+ throw error;
1675
+ }
1676
+ const conflictAction = {
1677
+ type: "conflict",
1678
+ path: action.path,
1679
+ fromPath: action.fromPath,
1680
+ remoteId: action.remoteId,
1681
+ reason: "remote_write_conflict",
1682
+ expectedRevision: error.expectedRevision,
1683
+ status: error.status
1684
+ };
1685
+ await writeConflictMarker(config.localRoot, conflictAction);
1686
+ completedActions.push(conflictAction);
1687
+ failedAction = null;
1688
+ continue;
1689
+ }
1690
+ if (applyStateMutation(incrementalState, mutation, (/* @__PURE__ */ new Date()).toISOString())) {
1691
+ await writeJsonFile(config.statePath, incrementalState, { assertSafePath: assertSafeStatePath });
1692
+ }
1693
+ completedActions.push(action);
1694
+ failedAction = null;
1695
+ }
1696
+ const finalLocalEntries = await scanLocalTree(config.localRoot);
1697
+ const finalRemoteTree = await fetchConfiguredRemoteTree(client, config);
1698
+ const conflictActions = completedActions.filter((action) => action.type === "conflict");
1699
+ const unresolvedConflictPaths = new Set(conflictActions.map((action) => action.path));
1700
+ const conflictCount = countConflicts(completedActions);
1701
+ const nextState = buildStableFinalSyncState(finalLocalEntries, finalRemoteTree.entries, (/* @__PURE__ */ new Date()).toISOString(), {
1702
+ baselineLocalEntries: localEntries,
1703
+ baselineRemoteEntries: remoteTree.entries,
1704
+ incrementalState,
1705
+ skipPaths: unresolvedConflictPaths
1706
+ });
1707
+ await pruneResolvedConflictMarkers(config.localRoot, conflictActions);
1708
+ await writeJsonFile(config.statePath, nextState, { assertSafePath: assertSafeStatePath });
1709
+ await writeDesktopStatus({
1710
+ localRoot: config.localRoot,
1711
+ status: {
1712
+ state: conflictCount > 0 ? "conflicted" : "synced",
1713
+ actionCount: completedActions.length,
1714
+ conflictCount,
1715
+ lastSyncedAt: nextState.syncedAt,
1716
+ currentAction: null,
1717
+ errorMessage: null
1718
+ }
1719
+ });
1720
+ return {
1721
+ actions: completedActions,
1722
+ localRoot: config.localRoot,
1723
+ statePath: config.statePath,
1724
+ syncedAt: nextState.syncedAt,
1725
+ paused: false,
1726
+ locked: false
1727
+ };
1728
+ } catch (error) {
1729
+ const lastSyncedAt = await getLastKnownSyncedAt(config);
1730
+ await writeDesktopStatus({
1731
+ localRoot: config.localRoot,
1732
+ status: {
1733
+ state: "error",
1734
+ actionCount: completedActions.length,
1735
+ plannedActionCount: plannedActions.length,
1736
+ conflictCount: countConflicts(completedActions),
1737
+ lastSyncedAt,
1738
+ currentAction: null,
1739
+ failedAction,
1740
+ errorMessage: error.message
1741
+ }
1742
+ });
1743
+ throw error;
1744
+ } finally {
1745
+ await lock.release();
1746
+ }
1747
+ }
1748
+
1749
+ // src/sync.mjs
1750
+ var DEFAULT_INTERVAL_MS = 3e4;
1751
+ var DEFAULT_DEBOUNCE_MS = 1e3;
1752
+ function createSyncSession(options = {}) {
1753
+ const config = normalizeSessionOptions(options);
1754
+ let scheduler = null;
1755
+ let folderWatcher = null;
1756
+ let watching = false;
1757
+ let closed = false;
1758
+ let readyPromise = null;
1759
+ function ensureOpen() {
1760
+ if (closed) {
1761
+ throw new Error("ZergBox sync session is closed");
1762
+ }
1763
+ }
1764
+ function ensureReady() {
1765
+ if (!readyPromise) {
1766
+ readyPromise = (async () => {
1767
+ await assertSafeLocalRoot(config.localRoot);
1768
+ await assertSafeStatePath(config.statePath);
1769
+ await fs7.mkdir(config.localRoot, { recursive: true });
1770
+ await fs7.mkdir(path8.dirname(config.statePath), { recursive: true });
1771
+ await assertSafeStatePath(config.statePath);
1772
+ })();
1773
+ }
1774
+ return readyPromise;
1775
+ }
1776
+ async function resolveClient() {
1777
+ if (config.client) {
1778
+ return config.client;
1779
+ }
1780
+ const providedToken = await config.tokenProvider();
1781
+ const token = typeof providedToken === "string" ? providedToken : providedToken?.token;
1782
+ if (typeof token !== "string" || token.trim() === "") {
1783
+ throw new Error("tokenProvider must return a non-empty token");
1784
+ }
1785
+ return new ZergBoxDesktopClient({
1786
+ baseUrl: config.baseUrl,
1787
+ token,
1788
+ requestTimeoutMs: config.requestTimeoutMs
1789
+ });
1790
+ }
1791
+ async function syncOnce({ force = false } = {}) {
1792
+ ensureOpen();
1793
+ await ensureReady();
1794
+ const result = await runSyncOnce({
1795
+ config: {
1796
+ organizationId: config.organizationId,
1797
+ rootFolderId: config.rootFolderId,
1798
+ localRoot: config.localRoot,
1799
+ statePath: config.statePath,
1800
+ requestTimeoutMs: config.requestTimeoutMs
1801
+ },
1802
+ client: await resolveClient(),
1803
+ force
1804
+ });
1805
+ await config.afterSync(result);
1806
+ return result;
1807
+ }
1808
+ async function status() {
1809
+ await ensureReady();
1810
+ const runtimeStatus = await getDesktopRuntimeStatus({ localRoot: config.localRoot });
1811
+ const desktopStatus = await readDesktopStatus({ localRoot: config.localRoot });
1812
+ return {
1813
+ state: runtimeStatus.paused ? "paused" : desktopStatus?.state || (closed ? "closed" : "idle"),
1814
+ paused: runtimeStatus.paused,
1815
+ watching,
1816
+ closed,
1817
+ deviceId: config.deviceId,
1818
+ organizationId: config.organizationId,
1819
+ rootFolderId: config.rootFolderId,
1820
+ localRoot: config.localRoot,
1821
+ statePath: config.statePath,
1822
+ lastSyncedAt: desktopStatus?.lastSyncedAt || null,
1823
+ actionCount: desktopStatus?.actionCount ?? null,
1824
+ conflictCount: desktopStatus?.conflictCount ?? null,
1825
+ errorMessage: desktopStatus?.errorMessage || null,
1826
+ pauseReason: runtimeStatus.pauseReason,
1827
+ pauseWarning: runtimeStatus.pauseWarning
1828
+ };
1829
+ }
1830
+ async function watch({ immediate = true } = {}) {
1831
+ ensureOpen();
1832
+ await ensureReady();
1833
+ if (watching) {
1834
+ return status();
1835
+ }
1836
+ scheduler = new SyncScheduler({
1837
+ debounceMs: config.debounceMs,
1838
+ intervalMs: config.intervalMs,
1839
+ onError: config.onError,
1840
+ runOnce: () => syncOnce()
1841
+ });
1842
+ try {
1843
+ folderWatcher = watchFileSystem(
1844
+ config.localRoot,
1845
+ { recursive: process.platform === "darwin" || process.platform === "win32" },
1846
+ (_eventType, filename) => {
1847
+ const changedPath = normalizeSyncPath(filename || "");
1848
+ if (changedPath && isSyncPathIgnored(changedPath)) {
1849
+ return;
1850
+ }
1851
+ scheduler?.trigger("local-change");
1852
+ }
1853
+ );
1854
+ } catch (error) {
1855
+ config.onError(error);
1856
+ }
1857
+ watching = true;
1858
+ scheduler.start({ immediate });
1859
+ return status();
1860
+ }
1861
+ async function pause(reason = "Paused by user") {
1862
+ ensureOpen();
1863
+ await ensureReady();
1864
+ await pauseDesktopSync({ localRoot: config.localRoot, reason });
1865
+ return status();
1866
+ }
1867
+ async function resume() {
1868
+ ensureOpen();
1869
+ await ensureReady();
1870
+ await resumeDesktopSync({ localRoot: config.localRoot });
1871
+ return status();
1872
+ }
1873
+ async function stopWatchingResources() {
1874
+ const activeScheduler = scheduler;
1875
+ scheduler = null;
1876
+ const idle = activeScheduler?.stop();
1877
+ folderWatcher?.close();
1878
+ folderWatcher = null;
1879
+ watching = false;
1880
+ await idle;
1881
+ }
1882
+ async function stopWatching() {
1883
+ ensureOpen();
1884
+ await stopWatchingResources();
1885
+ return status();
1886
+ }
1887
+ async function close() {
1888
+ if (!closed) {
1889
+ closed = true;
1890
+ await stopWatchingResources();
1891
+ }
1892
+ return status();
1893
+ }
1894
+ return {
1895
+ syncOnce,
1896
+ watch,
1897
+ status,
1898
+ pause,
1899
+ resume,
1900
+ stopWatching,
1901
+ close
1902
+ };
1903
+ }
1904
+ function normalizeSessionOptions(options) {
1905
+ for (const name of ["organizationId", "rootFolderId", "localRoot", "deviceId"]) {
1906
+ if (typeof options[name] !== "string" || options[name].trim() === "") {
1907
+ throw new Error(`${name} is required`);
1908
+ }
1909
+ }
1910
+ if (!options.client) {
1911
+ if (typeof options.baseUrl !== "string" || options.baseUrl.trim() === "") {
1912
+ throw new Error("baseUrl is required when client is not provided");
1913
+ }
1914
+ if (typeof options.tokenProvider !== "function") {
1915
+ throw new Error("tokenProvider is required when client is not provided");
1916
+ }
1917
+ }
1918
+ if (options.afterSync !== void 0 && typeof options.afterSync !== "function") {
1919
+ throw new Error("afterSync must be a function");
1920
+ }
1921
+ return {
1922
+ ...options,
1923
+ localRoot: path8.resolve(options.localRoot),
1924
+ statePath: path8.resolve(
1925
+ options.statePath || path8.join(options.localRoot, ".zergbox-desktop", "state.json")
1926
+ ),
1927
+ intervalMs: normalizeNonNegativeNumber(options.intervalMs, DEFAULT_INTERVAL_MS),
1928
+ debounceMs: normalizeNonNegativeNumber(options.debounceMs, DEFAULT_DEBOUNCE_MS),
1929
+ onError: typeof options.onError === "function" ? options.onError : console.error,
1930
+ afterSync: options.afterSync || (async () => {
1931
+ })
1932
+ };
1933
+ }
1934
+ function normalizeNonNegativeNumber(value, fallback) {
1935
+ const parsed = Number(value ?? fallback);
1936
+ return Number.isFinite(parsed) && parsed >= 0 ? Math.trunc(parsed) : fallback;
1937
+ }
1938
+ export {
1939
+ MERGE_CANDIDATE_STATES,
1940
+ SYNC_CHANGE_TYPES,
1941
+ SYNC_NODE_TYPES,
1942
+ createSyncSession,
1943
+ isMergeCandidate,
1944
+ isSyncChange,
1945
+ isSyncNode
1946
+ };