pal-explorer-cli 0.4.7 → 0.4.9
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/bin/pal.js +231 -231
- package/extensions/@palexplorer/analytics/extension.json +1 -1
- package/extensions/@palexplorer/discovery/extension.json +1 -0
- package/extensions/@palexplorer/explorer-integration/extension.json +1 -1
- package/extensions/@palexplorer/networks/extension.json +1 -1
- package/extensions/@palexplorer/vfs/extension.json +1 -1
- package/lib/commands/sync.js +541 -389
- package/lib/core/extensions.js +16 -22
- package/lib/core/syncEngine.js +276 -3
- package/lib/core/syncOptions.js +80 -0
- package/lib/core/syncProtocolHandlers.js +87 -0
- package/lib/core/syncTransport.js +203 -0
- package/package.json +68 -68
package/lib/core/extensions.js
CHANGED
|
@@ -17,12 +17,8 @@ const EXTENSIONS_DIR = path.join(
|
|
|
17
17
|
|
|
18
18
|
const BUNDLED_DIR = path.join(EXTENSIONS_DIR, '@palexplorer');
|
|
19
19
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
'discovery',
|
|
23
|
-
'explorer-integration',
|
|
24
|
-
'vfs',
|
|
25
|
-
]);
|
|
20
|
+
// No "core" extensions — all extensions can be enabled/disabled by the user.
|
|
21
|
+
// Extensions that should be on by default set "config.enabled.default": true in their manifest.
|
|
26
22
|
|
|
27
23
|
// Ed25519 public key for verifying extension signatures (Palexplorer team key)
|
|
28
24
|
const TEAM_PUBLIC_KEY = 'ba71d876238e24b1b230c567a6b7339abdf55030e98580a5a883b97ee0a3f084';
|
|
@@ -517,7 +513,6 @@ function getInstalledExtensions() {
|
|
|
517
513
|
const manifest = readManifest(bundledPath);
|
|
518
514
|
if (manifest) {
|
|
519
515
|
const fullName = `@palexplorer/${bundled}`;
|
|
520
|
-
const isCore = CORE_EXTENSIONS.has(bundled);
|
|
521
516
|
const explicitEnabled = (config.get('enabledExtensions') || []).includes(fullName);
|
|
522
517
|
const explicitDisabled = disabled.includes(fullName);
|
|
523
518
|
const manifestDefault = manifest.config?.enabled?.default === true;
|
|
@@ -525,9 +520,7 @@ function getInstalledExtensions() {
|
|
|
525
520
|
...manifest,
|
|
526
521
|
path: bundledPath,
|
|
527
522
|
bundled: true,
|
|
528
|
-
enabled:
|
|
529
|
-
? !explicitDisabled
|
|
530
|
-
: explicitDisabled ? false : (explicitEnabled || manifestDefault),
|
|
523
|
+
enabled: explicitDisabled ? false : (explicitEnabled || manifestDefault),
|
|
531
524
|
});
|
|
532
525
|
}
|
|
533
526
|
}
|
|
@@ -896,12 +889,10 @@ function enableExtension(name) {
|
|
|
896
889
|
const newDisabled = disabled.filter(d => d !== name && d !== full && d !== short);
|
|
897
890
|
if (newDisabled.length !== disabled.length) config.set('disabledExtensions', newDisabled);
|
|
898
891
|
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
config.set('enabledExtensions', enabled);
|
|
904
|
-
}
|
|
892
|
+
const enabled = config.get('enabledExtensions') || [];
|
|
893
|
+
if (!enabled.includes(full)) {
|
|
894
|
+
enabled.push(full);
|
|
895
|
+
config.set('enabledExtensions', enabled);
|
|
905
896
|
}
|
|
906
897
|
}
|
|
907
898
|
|
|
@@ -915,11 +906,9 @@ function disableExtension(name) {
|
|
|
915
906
|
config.set('disabledExtensions', disabled);
|
|
916
907
|
}
|
|
917
908
|
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
if (newEnabled.length !== enabled.length) config.set('enabledExtensions', newEnabled);
|
|
922
|
-
}
|
|
909
|
+
const enabled = config.get('enabledExtensions') || [];
|
|
910
|
+
const newEnabled = enabled.filter(e => e !== name && e !== full && e !== short);
|
|
911
|
+
if (newEnabled.length !== enabled.length) config.set('enabledExtensions', newEnabled);
|
|
923
912
|
unloadExtension(name);
|
|
924
913
|
}
|
|
925
914
|
|
|
@@ -1012,6 +1001,11 @@ function getContributedPages() {
|
|
|
1012
1001
|
|
|
1013
1002
|
function ensureDefaultExtensions() {
|
|
1014
1003
|
if (!fs.existsSync(BUNDLED_DIR)) return;
|
|
1004
|
+
// Ensure extensions dir has ESM package.json so import() works
|
|
1005
|
+
const extPkg = path.join(EXTENSIONS_DIR, 'package.json');
|
|
1006
|
+
if (!fs.existsSync(extPkg)) {
|
|
1007
|
+
fs.writeFileSync(extPkg, '{ "type": "module" }\n');
|
|
1008
|
+
}
|
|
1015
1009
|
const enabled = config.get('enabledExtensions') || [];
|
|
1016
1010
|
const disabled = config.get('disabledExtensions') || [];
|
|
1017
1011
|
let changed = false;
|
|
@@ -1019,7 +1013,7 @@ function ensureDefaultExtensions() {
|
|
|
1019
1013
|
const manifest = readManifest(path.join(BUNDLED_DIR, bundled));
|
|
1020
1014
|
if (!manifest) continue;
|
|
1021
1015
|
const full = `@palexplorer/${bundled}`;
|
|
1022
|
-
if (manifest.config?.enabled?.default === true
|
|
1016
|
+
if (manifest.config?.enabled?.default === true) {
|
|
1023
1017
|
if (!enabled.includes(full) && !disabled.includes(full)) {
|
|
1024
1018
|
enabled.push(full);
|
|
1025
1019
|
changed = true;
|
package/lib/core/syncEngine.js
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
import crypto from 'crypto';
|
|
2
2
|
import fs from 'fs';
|
|
3
3
|
import path from 'path';
|
|
4
|
+
import { EventEmitter } from 'events';
|
|
4
5
|
import logger from '../utils/logger.js';
|
|
6
|
+
import { buildProtocolManifest, computeDelta } from '../protocol/sync.js';
|
|
7
|
+
import { syncManifest as createSyncManifestMsg, syncRequest as createSyncRequestMsg, syncConflict as createSyncConflictMsg } from '../protocol/messages.js';
|
|
8
|
+
import { registerSession, unregisterSession } from './syncProtocolHandlers.js';
|
|
5
9
|
|
|
6
10
|
// mtime+size hash cache: skip re-hashing files that haven't changed
|
|
7
11
|
const _hashCache = new Map();
|
|
@@ -30,6 +34,25 @@ function shouldIgnore(relativePath) {
|
|
|
30
34
|
return IGNORE_PATTERNS.some(p => p.test(relativePath));
|
|
31
35
|
}
|
|
32
36
|
|
|
37
|
+
function matchesAny(filePath, patterns) {
|
|
38
|
+
for (const pattern of patterns) {
|
|
39
|
+
// Simple glob: *.ext, dir/*, **/pattern
|
|
40
|
+
const regex = globToRegex(pattern);
|
|
41
|
+
if (regex.test(filePath)) return true;
|
|
42
|
+
}
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function globToRegex(glob) {
|
|
47
|
+
const escaped = glob
|
|
48
|
+
.replace(/[.+^${}()|[\]\\]/g, '\\$&')
|
|
49
|
+
.replace(/\*\*/g, '§DOUBLESTAR§')
|
|
50
|
+
.replace(/\*/g, '[^/]*')
|
|
51
|
+
.replace(/§DOUBLESTAR§/g, '.*')
|
|
52
|
+
.replace(/\?/g, '[^/]');
|
|
53
|
+
return new RegExp(`^${escaped}$|/${escaped}$|^${escaped}/`);
|
|
54
|
+
}
|
|
55
|
+
|
|
33
56
|
function hashFileStream(filePath) {
|
|
34
57
|
return new Promise((resolve, reject) => {
|
|
35
58
|
const h = crypto.createHash('sha256');
|
|
@@ -40,7 +63,8 @@ function hashFileStream(filePath) {
|
|
|
40
63
|
});
|
|
41
64
|
}
|
|
42
65
|
|
|
43
|
-
export async function buildManifest(dirPath, cachedManifest) {
|
|
66
|
+
export async function buildManifest(dirPath, cachedManifest, options = {}) {
|
|
67
|
+
const { exclude = [], include = [], checksum = false, sizeOnly = false } = options;
|
|
44
68
|
const cachedMap = new Map();
|
|
45
69
|
if (cachedManifest) {
|
|
46
70
|
const entries = Array.isArray(cachedManifest) ? cachedManifest : Object.values(cachedManifest);
|
|
@@ -72,6 +96,10 @@ export async function buildManifest(dirPath, cachedManifest) {
|
|
|
72
96
|
const relativePath = relBase ? `${relBase}/${dirent.name}` : dirent.name;
|
|
73
97
|
|
|
74
98
|
if (shouldIgnore(relativePath)) continue;
|
|
99
|
+
// User exclude/include patterns
|
|
100
|
+
if (exclude.length > 0 && matchesAny(relativePath, exclude)) {
|
|
101
|
+
if (include.length === 0 || !matchesAny(relativePath, include)) continue;
|
|
102
|
+
}
|
|
75
103
|
|
|
76
104
|
if (dirent.isDirectory()) {
|
|
77
105
|
await walk(fullPath, relativePath);
|
|
@@ -186,8 +214,8 @@ export function detectConflicts(localManifest, remoteManifest, baseManifest) {
|
|
|
186
214
|
}
|
|
187
215
|
|
|
188
216
|
export function applyDiff(diff, sourcePath, destPath, options = {}) {
|
|
189
|
-
const { dryRun = false } = options;
|
|
190
|
-
const results = { copied: [], deleted: [], conflicted: [], errors: [] };
|
|
217
|
+
const { dryRun = false, backup = false, update = false, ignoreExisting = false } = options;
|
|
218
|
+
const results = { copied: [], deleted: [], conflicted: [], skipped: [], errors: [] };
|
|
191
219
|
|
|
192
220
|
for (const entry of diff.added) {
|
|
193
221
|
const src = path.join(sourcePath, entry.relativePath);
|
|
@@ -208,12 +236,35 @@ export function applyDiff(diff, sourcePath, destPath, options = {}) {
|
|
|
208
236
|
for (const entry of diff.modified) {
|
|
209
237
|
const src = path.join(sourcePath, entry.relativePath);
|
|
210
238
|
const dst = path.join(destPath, entry.relativePath);
|
|
239
|
+
|
|
240
|
+
// --ignore-existing: skip files that exist on destination
|
|
241
|
+
if (ignoreExisting && fs.existsSync(dst)) {
|
|
242
|
+
results.skipped.push(entry.relativePath);
|
|
243
|
+
continue;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// --update: skip files newer on destination
|
|
247
|
+
if (update && fs.existsSync(dst)) {
|
|
248
|
+
try {
|
|
249
|
+
const dstStat = fs.statSync(dst);
|
|
250
|
+
const srcStat = fs.statSync(src);
|
|
251
|
+
if (dstStat.mtimeMs > srcStat.mtimeMs) {
|
|
252
|
+
results.skipped.push(entry.relativePath);
|
|
253
|
+
continue;
|
|
254
|
+
}
|
|
255
|
+
} catch {}
|
|
256
|
+
}
|
|
257
|
+
|
|
211
258
|
if (dryRun) {
|
|
212
259
|
results.copied.push(entry.relativePath);
|
|
213
260
|
continue;
|
|
214
261
|
}
|
|
215
262
|
try {
|
|
216
263
|
fs.mkdirSync(path.dirname(dst), { recursive: true });
|
|
264
|
+
// --backup: rename before overwriting
|
|
265
|
+
if (backup && fs.existsSync(dst)) {
|
|
266
|
+
fs.renameSync(dst, dst + '.bak');
|
|
267
|
+
}
|
|
217
268
|
fs.copyFileSync(src, dst);
|
|
218
269
|
results.copied.push(entry.relativePath);
|
|
219
270
|
} catch (err) {
|
|
@@ -261,4 +312,226 @@ export function applyDiff(diff, sourcePath, destPath, options = {}) {
|
|
|
261
312
|
return results;
|
|
262
313
|
}
|
|
263
314
|
|
|
315
|
+
// --- SyncSession: P2P sync over PAL/1.0 protocol ---
|
|
316
|
+
|
|
317
|
+
export class SyncSession extends EventEmitter {
|
|
318
|
+
constructor({ localPath, peerPK, transport, keyPair, syncPairId, options = {} }) {
|
|
319
|
+
super();
|
|
320
|
+
this.localPath = path.resolve(localPath);
|
|
321
|
+
this.peerPK = peerPK;
|
|
322
|
+
this.transport = transport;
|
|
323
|
+
this.keyPair = keyPair;
|
|
324
|
+
this.syncPairId = syncPairId || crypto.randomUUID();
|
|
325
|
+
this.options = options;
|
|
326
|
+
this.state = 'idle'; // idle → scanning → exchanging → transferring → done/error
|
|
327
|
+
this.localManifest = null;
|
|
328
|
+
this.remoteManifest = null;
|
|
329
|
+
this.diff = null;
|
|
330
|
+
this.generation = Date.now();
|
|
331
|
+
this._resolveRemoteManifest = null;
|
|
332
|
+
this._resolveFileData = null;
|
|
333
|
+
this._receivedFiles = new Map();
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
async start(cachedManifest) {
|
|
337
|
+
try {
|
|
338
|
+
this.state = 'scanning';
|
|
339
|
+
this.emit('state', 'scanning');
|
|
340
|
+
registerSession(this.syncPairId, this);
|
|
341
|
+
|
|
342
|
+
// Build local manifest (respects exclude/include/checksum options)
|
|
343
|
+
this.localManifest = await buildManifest(this.localPath, cachedManifest, this.options);
|
|
344
|
+
|
|
345
|
+
// Build protocol manifest and send via transport
|
|
346
|
+
this.state = 'exchanging';
|
|
347
|
+
this.emit('state', 'exchanging');
|
|
348
|
+
|
|
349
|
+
const protoManifest = buildProtocolManifest(this.localManifest, this.syncPairId, this.generation);
|
|
350
|
+
const envelope = createSyncManifestMsg(this.keyPair, this.peerPK, protoManifest);
|
|
351
|
+
await this.transport.send(envelope);
|
|
352
|
+
this.emit('manifestSent', { entries: this.localManifest.length });
|
|
353
|
+
|
|
354
|
+
return { localManifest: this.localManifest, syncPairId: this.syncPairId };
|
|
355
|
+
} catch (err) {
|
|
356
|
+
this.state = 'error';
|
|
357
|
+
this.emit('error', err);
|
|
358
|
+
throw err;
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
handleRemoteManifest(data) {
|
|
363
|
+
// Convert protocol manifest entries back to engine format
|
|
364
|
+
const entries = (data.entries || []).map(e => ({
|
|
365
|
+
relativePath: e.path || e.relativePath,
|
|
366
|
+
size: e.size,
|
|
367
|
+
hash: e.hash,
|
|
368
|
+
mtime: e.mtime,
|
|
369
|
+
}));
|
|
370
|
+
this.remoteManifest = entries;
|
|
371
|
+
this.emit('manifestReceived', { entries: entries.length, from: data.from });
|
|
372
|
+
|
|
373
|
+
if (this._resolveRemoteManifest) {
|
|
374
|
+
this._resolveRemoteManifest(entries);
|
|
375
|
+
this._resolveRemoteManifest = null;
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
waitForRemoteManifest(timeoutMs = 30000) {
|
|
380
|
+
if (this.remoteManifest) return Promise.resolve(this.remoteManifest);
|
|
381
|
+
return new Promise((resolve, reject) => {
|
|
382
|
+
this._resolveRemoteManifest = resolve;
|
|
383
|
+
const timer = setTimeout(() => {
|
|
384
|
+
this._resolveRemoteManifest = null;
|
|
385
|
+
reject(new Error('Timed out waiting for remote manifest'));
|
|
386
|
+
}, timeoutMs);
|
|
387
|
+
// Clean up timer if resolved
|
|
388
|
+
const origResolve = this._resolveRemoteManifest;
|
|
389
|
+
this._resolveRemoteManifest = (val) => {
|
|
390
|
+
clearTimeout(timer);
|
|
391
|
+
origResolve(val);
|
|
392
|
+
};
|
|
393
|
+
});
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
computeSync(baseManifest) {
|
|
397
|
+
if (!this.localManifest || !this.remoteManifest) {
|
|
398
|
+
throw new Error('Both local and remote manifests required');
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
const localMap = manifestToMap(this.localManifest);
|
|
402
|
+
const remoteMap = manifestToMap(this.remoteManifest);
|
|
403
|
+
const baseMap = baseManifest ? (Array.isArray(baseManifest) ? manifestToMap(baseManifest) : baseManifest) : {};
|
|
404
|
+
|
|
405
|
+
// Compute what we need from remote (files remote has that we don't or are different)
|
|
406
|
+
const diff = diffManifests(localMap, remoteMap);
|
|
407
|
+
const { conflicts, safeModified } = detectConflicts(localMap, remoteMap, baseMap);
|
|
408
|
+
|
|
409
|
+
// Separate real conflicts from safe modifications in the diff
|
|
410
|
+
if (conflicts.length > 0) {
|
|
411
|
+
diff.conflicts = conflicts;
|
|
412
|
+
diff.modified = diff.modified.filter(
|
|
413
|
+
m => !conflicts.some(c => c.relativePath === m.relativePath)
|
|
414
|
+
);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
this.diff = diff;
|
|
418
|
+
this.emit('diffComputed', {
|
|
419
|
+
added: diff.added.length,
|
|
420
|
+
modified: diff.modified.length,
|
|
421
|
+
deleted: diff.deleted.length,
|
|
422
|
+
conflicts: (diff.conflicts || []).length,
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
return diff;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
async requestFiles(filesToRequest) {
|
|
429
|
+
if (!filesToRequest || filesToRequest.length === 0) return;
|
|
430
|
+
|
|
431
|
+
this.state = 'transferring';
|
|
432
|
+
this.emit('state', 'transferring');
|
|
433
|
+
|
|
434
|
+
const requestPayload = filesToRequest.map(f => ({
|
|
435
|
+
path: f.relativePath || f,
|
|
436
|
+
mode: 'full',
|
|
437
|
+
}));
|
|
438
|
+
|
|
439
|
+
const envelope = createSyncRequestMsg(this.keyPair, this.peerPK, {
|
|
440
|
+
syncPairId: this.syncPairId,
|
|
441
|
+
files: requestPayload,
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
await this.transport.send(envelope);
|
|
445
|
+
this.emit('filesRequested', { count: requestPayload.length });
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
handleSyncRequest(data) {
|
|
449
|
+
// Peer is requesting files from us — read and stream them
|
|
450
|
+
const files = data.files || [];
|
|
451
|
+
this.emit('syncRequest', { from: data.from, files: files.length });
|
|
452
|
+
|
|
453
|
+
const fileData = {};
|
|
454
|
+
for (const req of files) {
|
|
455
|
+
const filePath = path.join(this.localPath, req.path);
|
|
456
|
+
try {
|
|
457
|
+
if (fs.existsSync(filePath)) {
|
|
458
|
+
fileData[req.path] = fs.readFileSync(filePath);
|
|
459
|
+
}
|
|
460
|
+
} catch (err) {
|
|
461
|
+
logger.warn(`Cannot read requested file: ${req.path} (${err.message})`);
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
this._receivedFiles = new Map(Object.entries(fileData));
|
|
466
|
+
this.emit('filesReady', { count: Object.keys(fileData).length });
|
|
467
|
+
return fileData;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
handleSyncDiff(data) {
|
|
471
|
+
this.emit('syncDiff', data);
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
handleConflict(data) {
|
|
475
|
+
const conflicts = data.conflicts || [];
|
|
476
|
+
this.emit('conflict', { count: conflicts.length, conflicts });
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
applyChanges(sourcePath, options = {}) {
|
|
480
|
+
if (!this.diff) throw new Error('No diff computed. Call computeSync() first.');
|
|
481
|
+
const mergedOpts = { ...this.options, ...options };
|
|
482
|
+
return applyDiff(this.diff, sourcePath || this.localPath, this.localPath, mergedOpts);
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
// Delta sync: request only changed blocks of modified files (4KB blocks)
|
|
486
|
+
async requestDelta(modifiedFiles) {
|
|
487
|
+
if (!modifiedFiles || modifiedFiles.length === 0) return [];
|
|
488
|
+
|
|
489
|
+
const deltaRequests = [];
|
|
490
|
+
for (const file of modifiedFiles) {
|
|
491
|
+
const localPath = path.join(this.localPath, file.relativePath);
|
|
492
|
+
if (!fs.existsSync(localPath)) continue;
|
|
493
|
+
|
|
494
|
+
try {
|
|
495
|
+
const { computeBlockHashes } = await import('../protocol/sync.js');
|
|
496
|
+
const localHashes = computeBlockHashes(localPath, 4096);
|
|
497
|
+
deltaRequests.push({
|
|
498
|
+
path: file.relativePath,
|
|
499
|
+
mode: 'delta',
|
|
500
|
+
blockSize: 4096,
|
|
501
|
+
localBlockHashes: localHashes.hashes,
|
|
502
|
+
localSize: localHashes.totalSize,
|
|
503
|
+
});
|
|
504
|
+
} catch {
|
|
505
|
+
// Fall back to full file if delta not available (e.g., not Pro)
|
|
506
|
+
deltaRequests.push({ path: file.relativePath, mode: 'full' });
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
if (deltaRequests.length > 0) {
|
|
511
|
+
const envelope = createSyncRequestMsg(this.keyPair, this.peerPK, {
|
|
512
|
+
syncPairId: this.syncPairId,
|
|
513
|
+
files: deltaRequests,
|
|
514
|
+
});
|
|
515
|
+
await this.transport.send(envelope);
|
|
516
|
+
this.emit('deltaRequested', { count: deltaRequests.length });
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
return deltaRequests;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
finish() {
|
|
523
|
+
this.state = 'done';
|
|
524
|
+
this.emit('state', 'done');
|
|
525
|
+
unregisterSession(this.syncPairId);
|
|
526
|
+
this.transport.close();
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
abort(reason) {
|
|
530
|
+
this.state = 'error';
|
|
531
|
+
this.emit('error', new Error(reason || 'Session aborted'));
|
|
532
|
+
unregisterSession(this.syncPairId);
|
|
533
|
+
this.transport.close();
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
|
|
264
537
|
export { formatSize } from '../utils/format.js';
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// Parse and validate sync CLI flags into engine options
|
|
2
|
+
|
|
3
|
+
export const SYNC_FLAGS = {
|
|
4
|
+
dryRun: { flag: '--dry-run', description: 'Show changes without applying', default: false },
|
|
5
|
+
delete: { flag: '--delete', description: 'Delete destination files not in source', default: false },
|
|
6
|
+
exclude: { flag: '--exclude <pattern>', description: 'Glob patterns to skip (repeatable)', default: [] },
|
|
7
|
+
include: { flag: '--include <pattern>', description: 'Override exclude for specific patterns (repeatable)', default: [] },
|
|
8
|
+
progress: { flag: '--progress', description: 'Show transfer progress (bytes, speed, ETA)', default: false },
|
|
9
|
+
verbose: { flag: '-v, --verbose', description: 'Per-file operation output', default: false },
|
|
10
|
+
checksum: { flag: '--checksum', description: 'Force hash comparison (skip mtime shortcut)', default: false },
|
|
11
|
+
sizeOnly: { flag: '--size-only', description: 'Compare by size only', default: false },
|
|
12
|
+
update: { flag: '--update', description: 'Skip files newer on destination', default: false },
|
|
13
|
+
ignoreExisting: { flag: '--ignore-existing', description: 'Skip files that exist on destination', default: false },
|
|
14
|
+
backup: { flag: '--backup', description: 'Rename before overwriting (.bak)', default: false },
|
|
15
|
+
partial: { flag: '--partial', description: 'Keep partial files for resume', default: false },
|
|
16
|
+
bandwidthLimit: { flag: '--bandwidth-limit <KBps>', description: 'Throttle transfer speed', default: 0 },
|
|
17
|
+
compress: { flag: '--compress', description: 'zlib compress chunks before transfer', default: false },
|
|
18
|
+
recursive: { flag: '--no-recursive', description: 'Flat sync (recursive is default)', default: true },
|
|
19
|
+
archive: { flag: '--archive', description: 'Shorthand for --recursive --checksum --delete', default: false },
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export function parseSyncOptions(opts) {
|
|
23
|
+
const options = {
|
|
24
|
+
dryRun: opts.dryRun || false,
|
|
25
|
+
delete: opts.delete || false,
|
|
26
|
+
exclude: normalizeArray(opts.exclude),
|
|
27
|
+
include: normalizeArray(opts.include),
|
|
28
|
+
progress: opts.progress || false,
|
|
29
|
+
verbose: opts.verbose || false,
|
|
30
|
+
checksum: opts.checksum || false,
|
|
31
|
+
sizeOnly: opts.sizeOnly || false,
|
|
32
|
+
update: opts.update || false,
|
|
33
|
+
ignoreExisting: opts.ignoreExisting || false,
|
|
34
|
+
backup: opts.backup || false,
|
|
35
|
+
partial: opts.partial || false,
|
|
36
|
+
bandwidthLimit: parseInt(opts.bandwidthLimit, 10) || 0,
|
|
37
|
+
compress: opts.compress || false,
|
|
38
|
+
recursive: opts.recursive !== false,
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
// --archive expands to --recursive --checksum --delete
|
|
42
|
+
if (opts.archive) {
|
|
43
|
+
options.recursive = true;
|
|
44
|
+
options.checksum = true;
|
|
45
|
+
options.delete = true;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Validate: --checksum and --size-only are mutually exclusive
|
|
49
|
+
if (options.checksum && options.sizeOnly) {
|
|
50
|
+
throw new Error('Cannot use --checksum and --size-only together');
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return options;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function normalizeArray(val) {
|
|
57
|
+
if (!val) return [];
|
|
58
|
+
if (Array.isArray(val)) return val;
|
|
59
|
+
return [val];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function addSyncFlagsToCommand(cmd) {
|
|
63
|
+
return cmd
|
|
64
|
+
.option('--dry-run', 'Show changes without applying')
|
|
65
|
+
.option('--delete', 'Delete destination files not in source')
|
|
66
|
+
.option('--exclude <pattern...>', 'Glob patterns to skip')
|
|
67
|
+
.option('--include <pattern...>', 'Override exclude for specific patterns')
|
|
68
|
+
.option('--progress', 'Show transfer progress')
|
|
69
|
+
.option('-v, --verbose', 'Per-file operation output')
|
|
70
|
+
.option('--checksum', 'Force hash comparison (skip mtime shortcut)')
|
|
71
|
+
.option('--size-only', 'Compare by size only')
|
|
72
|
+
.option('--update', 'Skip files newer on destination')
|
|
73
|
+
.option('--ignore-existing', 'Skip files that exist on destination')
|
|
74
|
+
.option('--backup', 'Rename before overwriting (.bak)')
|
|
75
|
+
.option('--partial', 'Keep partial files for resume')
|
|
76
|
+
.option('--bandwidth-limit <KBps>', 'Throttle transfer speed (KB/s)')
|
|
77
|
+
.option('--compress', 'Compress chunks before transfer')
|
|
78
|
+
.option('--no-recursive', 'Flat sync (recursive by default)')
|
|
79
|
+
.option('--archive', 'Shorthand for --recursive --checksum --delete');
|
|
80
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { on, off } from '../protocol/handler.js';
|
|
2
|
+
import logger from '../utils/logger.js';
|
|
3
|
+
|
|
4
|
+
// Registry of active sync sessions by syncPairId
|
|
5
|
+
const activeSessions = new Map();
|
|
6
|
+
|
|
7
|
+
// Bridge protocol handler events to sync engine sessions
|
|
8
|
+
const handlers = {
|
|
9
|
+
'sync.manifest': (data) => {
|
|
10
|
+
const session = activeSessions.get(data.syncPairId) || findSessionByPeer(data.from);
|
|
11
|
+
if (session) {
|
|
12
|
+
session.handleRemoteManifest(data);
|
|
13
|
+
} else {
|
|
14
|
+
logger.debug(`[sync] Received manifest from ${data.from?.slice(0, 8)} but no active session`);
|
|
15
|
+
pendingManifests.set(data.from, data);
|
|
16
|
+
}
|
|
17
|
+
},
|
|
18
|
+
'sync.diff': (data) => {
|
|
19
|
+
const session = activeSessions.get(data.syncPairId) || findSessionByPeer(data.from);
|
|
20
|
+
if (session?.handleSyncDiff) session.handleSyncDiff(data);
|
|
21
|
+
},
|
|
22
|
+
'sync.request': (data) => {
|
|
23
|
+
const session = activeSessions.get(data.syncPairId) || findSessionByPeer(data.from);
|
|
24
|
+
if (session?.handleSyncRequest) session.handleSyncRequest(data);
|
|
25
|
+
},
|
|
26
|
+
'sync.conflict': (data) => {
|
|
27
|
+
const session = activeSessions.get(data.syncPairId) || findSessionByPeer(data.from);
|
|
28
|
+
if (session?.handleConflict) session.handleConflict(data);
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
// Store manifests that arrive before a session is created
|
|
33
|
+
const pendingManifests = new Map();
|
|
34
|
+
|
|
35
|
+
function findSessionByPeer(peerPK) {
|
|
36
|
+
for (const session of activeSessions.values()) {
|
|
37
|
+
if (session.peerPK === peerPK) return session;
|
|
38
|
+
}
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
let registered = false;
|
|
43
|
+
|
|
44
|
+
export function registerSyncHandlers() {
|
|
45
|
+
if (registered) return;
|
|
46
|
+
for (const [type, handler] of Object.entries(handlers)) {
|
|
47
|
+
on(type, handler);
|
|
48
|
+
}
|
|
49
|
+
registered = true;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function unregisterSyncHandlers() {
|
|
53
|
+
if (!registered) return;
|
|
54
|
+
for (const [type, handler] of Object.entries(handlers)) {
|
|
55
|
+
off(type, handler);
|
|
56
|
+
}
|
|
57
|
+
registered = false;
|
|
58
|
+
activeSessions.clear();
|
|
59
|
+
pendingManifests.clear();
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function registerSession(syncPairId, session) {
|
|
63
|
+
activeSessions.set(syncPairId, session);
|
|
64
|
+
// Check for pending manifests from this peer
|
|
65
|
+
const pending = pendingManifests.get(session.peerPK);
|
|
66
|
+
if (pending) {
|
|
67
|
+
pendingManifests.delete(session.peerPK);
|
|
68
|
+
session.handleRemoteManifest(pending);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function unregisterSession(syncPairId) {
|
|
73
|
+
activeSessions.delete(syncPairId);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function getActiveSession(syncPairId) {
|
|
77
|
+
return activeSessions.get(syncPairId) || null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function getPendingManifest(peerPK) {
|
|
81
|
+
const manifest = pendingManifests.get(peerPK);
|
|
82
|
+
if (manifest) {
|
|
83
|
+
pendingManifests.delete(peerPK);
|
|
84
|
+
return manifest;
|
|
85
|
+
}
|
|
86
|
+
return null;
|
|
87
|
+
}
|