@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/README.md +86 -0
- package/dist/chunk-KLBU4HNA.js +1311 -0
- package/dist/index.js +26 -0
- package/dist/sync.js +1946 -0
- package/index.d.ts +211 -0
- package/package.json +56 -0
- package/sync.d.ts +58 -0
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
|
+
};
|