pal-explorer-cli 0.4.11 → 0.4.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. package/README.md +149 -149
  2. package/bin/pal.js +63 -2
  3. package/extensions/@palexplorer/analytics/extension.json +20 -1
  4. package/extensions/@palexplorer/analytics/index.js +19 -9
  5. package/extensions/@palexplorer/audit/extension.json +14 -0
  6. package/extensions/@palexplorer/auth-email/extension.json +15 -0
  7. package/extensions/@palexplorer/auth-oauth/extension.json +15 -0
  8. package/extensions/@palexplorer/chat/extension.json +14 -0
  9. package/extensions/@palexplorer/discovery/extension.json +17 -0
  10. package/extensions/@palexplorer/discovery/index.js +1 -1
  11. package/extensions/@palexplorer/email-notifications/extension.json +23 -0
  12. package/extensions/@palexplorer/groups/extension.json +15 -0
  13. package/extensions/@palexplorer/share-links/extension.json +15 -0
  14. package/extensions/@palexplorer/sync/extension.json +16 -0
  15. package/extensions/@palexplorer/user-mgmt/extension.json +15 -0
  16. package/lib/capabilities.js +24 -24
  17. package/lib/commands/analytics.js +175 -175
  18. package/lib/commands/api-keys.js +131 -131
  19. package/lib/commands/audit.js +235 -235
  20. package/lib/commands/auth.js +137 -137
  21. package/lib/commands/backup.js +76 -76
  22. package/lib/commands/billing.js +148 -148
  23. package/lib/commands/chat.js +217 -217
  24. package/lib/commands/cloud-backup.js +231 -231
  25. package/lib/commands/comment.js +99 -99
  26. package/lib/commands/completion.js +203 -203
  27. package/lib/commands/compliance.js +218 -218
  28. package/lib/commands/config.js +136 -136
  29. package/lib/commands/connect.js +44 -44
  30. package/lib/commands/dept.js +294 -294
  31. package/lib/commands/device.js +146 -146
  32. package/lib/commands/download.js +240 -226
  33. package/lib/commands/explorer.js +178 -178
  34. package/lib/commands/extension.js +1060 -970
  35. package/lib/commands/favorite.js +90 -90
  36. package/lib/commands/federation.js +270 -270
  37. package/lib/commands/file.js +533 -533
  38. package/lib/commands/group.js +271 -271
  39. package/lib/commands/gui-share.js +29 -29
  40. package/lib/commands/init.js +61 -61
  41. package/lib/commands/invite.js +59 -59
  42. package/lib/commands/list.js +58 -58
  43. package/lib/commands/log.js +116 -116
  44. package/lib/commands/nearby.js +108 -108
  45. package/lib/commands/network.js +251 -251
  46. package/lib/commands/notify.js +198 -198
  47. package/lib/commands/org.js +273 -273
  48. package/lib/commands/pal.js +403 -180
  49. package/lib/commands/permissions.js +216 -216
  50. package/lib/commands/pin.js +97 -97
  51. package/lib/commands/protocol.js +357 -357
  52. package/lib/commands/rbac.js +147 -147
  53. package/lib/commands/recover.js +36 -36
  54. package/lib/commands/register.js +171 -171
  55. package/lib/commands/relay.js +131 -131
  56. package/lib/commands/remote.js +368 -368
  57. package/lib/commands/revoke.js +50 -50
  58. package/lib/commands/scanner.js +280 -280
  59. package/lib/commands/schedule.js +344 -344
  60. package/lib/commands/scim.js +203 -203
  61. package/lib/commands/search.js +181 -181
  62. package/lib/commands/serve.js +438 -438
  63. package/lib/commands/server.js +350 -350
  64. package/lib/commands/share-link.js +199 -199
  65. package/lib/commands/share.js +336 -323
  66. package/lib/commands/sso.js +200 -200
  67. package/lib/commands/status.js +145 -145
  68. package/lib/commands/stream.js +562 -562
  69. package/lib/commands/su.js +187 -187
  70. package/lib/commands/sync.js +979 -979
  71. package/lib/commands/transfers.js +152 -152
  72. package/lib/commands/uninstall.js +188 -188
  73. package/lib/commands/update.js +204 -204
  74. package/lib/commands/user.js +276 -276
  75. package/lib/commands/vfs.js +84 -84
  76. package/lib/commands/web-login.js +79 -79
  77. package/lib/commands/web.js +52 -52
  78. package/lib/commands/webhook.js +180 -180
  79. package/lib/commands/whoami.js +59 -59
  80. package/lib/commands/workspace.js +121 -121
  81. package/lib/core/billing.js +16 -5
  82. package/lib/core/dhtDiscovery.js +9 -2
  83. package/lib/core/discoveryClient.js +13 -7
  84. package/lib/core/extensions.js +142 -1
  85. package/lib/core/identity.js +33 -2
  86. package/lib/core/imageProcessor.js +109 -0
  87. package/lib/core/imageTorrent.js +167 -0
  88. package/lib/core/permissions.js +1 -1
  89. package/lib/core/pro.js +11 -4
  90. package/lib/core/serverList.js +4 -1
  91. package/lib/core/shares.js +12 -1
  92. package/lib/core/signalingServer.js +14 -2
  93. package/lib/core/su.js +1 -1
  94. package/lib/core/users.js +1 -1
  95. package/lib/protocol/messages.js +12 -3
  96. package/lib/utils/explorer.js +1 -1
  97. package/lib/utils/help.js +357 -357
  98. package/lib/utils/torrent.js +1 -0
  99. package/package.json +4 -3
@@ -1,979 +1,979 @@
1
- import chalk from 'chalk';
2
- import path from 'path';
3
- import fs from 'fs';
4
- import config from '../utils/config.js';
5
- import logger from '../utils/logger.js';
6
- import { buildManifest as buildDiffManifest, diffManifests as diffFileManifests, diffSummary } from '../core/fileDiff.js';
7
- import { getFriends } from '../core/users.js';
8
- import {
9
- buildManifest,
10
- diffManifests,
11
- manifestToMap,
12
- detectConflicts,
13
- applyDiff,
14
- formatSize,
15
- SyncSession,
16
- } from '../core/syncEngine.js';
17
- import {
18
- getSyncPairs,
19
- addSyncPair,
20
- removeSyncPair,
21
- updateSyncPair,
22
- findSyncPair,
23
- saveSyncManifest,
24
- getSyncManifest,
25
- getLastSyncedManifestMap,
26
- addSyncHistoryEntry,
27
- getSyncHistory,
28
- } from '../core/syncState.js';
29
- import { selectSyncTransport } from '../core/syncTransport.js';
30
- import { registerSyncHandlers } from '../core/syncProtocolHandlers.js';
31
- import { parseSyncOptions, addSyncFlagsToCommand } from '../core/syncOptions.js';
32
-
33
- function getIdentity() {
34
- const identity = config.get('identity');
35
- if (!identity?.handle) {
36
- throw new Error('Not registered. Run `pe init` and `pe register` first.');
37
- }
38
- return identity;
39
- }
40
-
41
- function getKeyPair() {
42
- const identity = config.get('identity');
43
- if (!identity?.publicKey || !identity?.privateKey) {
44
- throw new Error('Identity keys not configured. Run `pe init` first.');
45
- }
46
- return {
47
- publicKey: Buffer.from(identity.publicKey, 'hex'),
48
- secretKey: Buffer.from(identity.privateKey, 'hex'),
49
- };
50
- }
51
-
52
- function findPal(palName) {
53
- const friends = getFriends();
54
- const pal = friends.find(f => f.name === palName || f.id === palName || f.handle === palName);
55
- if (!pal) {
56
- throw new Error(`Pal '${palName}' not found. Add them first with \`pe pal add\`.`);
57
- }
58
- return pal;
59
- }
60
-
61
- function validateDir(dirPath) {
62
- const abs = path.resolve(dirPath);
63
- if (!fs.existsSync(abs)) {
64
- throw new Error(`Path not found: ${abs}`);
65
- }
66
- if (!fs.statSync(abs).isDirectory()) {
67
- throw new Error('Sync only works with directories. Use `pe share` for single files.');
68
- }
69
- return abs;
70
- }
71
-
72
- function printDiffSummary(diff, verbose) {
73
- if (diff.added.length) {
74
- console.log(chalk.green(` + ${diff.added.length} new file(s)`));
75
- if (verbose) diff.added.forEach(a => console.log(chalk.green(` + ${a.relativePath}`)));
76
- }
77
- if (diff.modified.length) {
78
- console.log(chalk.yellow(` ~ ${diff.modified.length} modified file(s)`));
79
- if (verbose) diff.modified.forEach(m => console.log(chalk.yellow(` ~ ${m.relativePath}`)));
80
- }
81
- if (diff.deleted.length) {
82
- console.log(chalk.red(` - ${diff.deleted.length} deleted file(s)`));
83
- if (verbose) diff.deleted.forEach(d => console.log(chalk.red(` - ${d.relativePath}`)));
84
- }
85
- if ((diff.conflicts || []).length) {
86
- console.log(chalk.magenta(` ! ${diff.conflicts.length} conflict(s)`));
87
- if (verbose) diff.conflicts.forEach(c => console.log(chalk.magenta(` ! ${c.relativePath}`)));
88
- }
89
- if ((diff.skipped || []).length && verbose) {
90
- console.log(chalk.gray(` = ${diff.skipped.length} skipped`));
91
- }
92
- }
93
-
94
- function printResults(results) {
95
- if (results.copied.length) console.log(chalk.green(` Copied ${results.copied.length} file(s)`));
96
- if (results.deleted.length) console.log(chalk.red(` Deleted ${results.deleted.length} file(s)`));
97
- if (results.skipped?.length) console.log(chalk.gray(` Skipped ${results.skipped.length} file(s)`));
98
- if (results.conflicted.length) {
99
- console.log(chalk.magenta(` ${results.conflicted.length} conflict(s) saved with .sync-conflict suffix`));
100
- for (const c of results.conflicted) {
101
- if (c.original) console.log(chalk.gray(` ${c.original} -> ${c.conflictFile}`));
102
- }
103
- }
104
- if (results.errors.length) {
105
- console.log(chalk.red(` ${results.errors.length} error(s):`));
106
- for (const e of results.errors) console.log(chalk.red(` ${e.file}: ${e.error}`));
107
- }
108
- }
109
-
110
- // --- pe sync <path> <pal> (bi-directional, default) ---
111
-
112
- async function biSync(dirPath, palName, opts) {
113
- let absolutePath, pal, identity, keyPair;
114
- try {
115
- absolutePath = validateDir(dirPath);
116
- pal = findPal(palName);
117
- identity = getIdentity();
118
- keyPair = getKeyPair();
119
- } catch (err) {
120
- console.log(chalk.red(err.message));
121
- process.exitCode = 1;
122
- return;
123
- }
124
-
125
- const options = parseSyncOptions(opts);
126
- const palHandle = pal.handle || pal.name;
127
- const palId = pal.id || pal.publicKey;
128
-
129
- registerSyncHandlers();
130
-
131
- console.log(chalk.blue(`Syncing ${absolutePath} with ${pal.name}...`));
132
-
133
- let transport;
134
- try {
135
- transport = await selectSyncTransport(palId, { peerHandle: palHandle });
136
- console.log(chalk.gray(` Transport: ${transport.type}`));
137
- } catch (err) {
138
- console.log(chalk.red(`Cannot reach ${pal.name}: ${err.message}`));
139
- process.exitCode = 1;
140
- return;
141
- }
142
-
143
- const existingPair = findSyncPair(absolutePath, palId);
144
- const cachedManifest = existingPair ? getSyncManifest(existingPair.id) : null;
145
- const pair = addSyncPair(absolutePath, palId, `sync:${palHandle}`);
146
-
147
- const session = new SyncSession({
148
- localPath: absolutePath,
149
- peerPK: palId,
150
- transport,
151
- keyPair,
152
- syncPairId: pair.id,
153
- options,
154
- });
155
-
156
- if (options.progress) {
157
- session.on('state', (s) => console.log(chalk.gray(` [${s}]`)));
158
- session.on('manifestSent', (d) => console.log(chalk.gray(` Sent manifest (${d.entries} files)`)));
159
- session.on('manifestReceived', (d) => console.log(chalk.gray(` Received manifest (${d.entries} files)`)));
160
- }
161
-
162
- try {
163
- const { localManifest } = await session.start(cachedManifest);
164
- console.log(chalk.gray(` ${localManifest.length} local file(s) indexed`));
165
-
166
- // For bi-directional sync, we need the remote manifest
167
- // In real P2P: remote peer would respond with their manifest
168
- // Here we send ours and wait for theirs
169
- console.log(chalk.blue(` Waiting for ${pal.name}'s manifest...`));
170
- console.log(chalk.gray(' (Manifest sent via PAL/1.0 protocol)'));
171
-
172
- // Save local state
173
- saveSyncManifest(pair.id, localManifest);
174
- updateSyncPair(pair.id, {
175
- lastSync: new Date().toISOString(),
176
- status: 'synced',
177
- lastDirection: 'sync',
178
- });
179
-
180
- addSyncHistoryEntry(pair.id, {
181
- direction: 'sync',
182
- totalFiles: localManifest.length,
183
- });
184
-
185
- session.finish();
186
- console.log(chalk.green(`Sync manifest sent to ${pal.name}.`));
187
- console.log(chalk.gray(`Sync pair: ${pair.id}`));
188
- } catch (err) {
189
- session.abort(err.message);
190
- updateSyncPair(pair.id, { status: 'error' });
191
- logger.error(`Sync failed: ${err.message}`);
192
- console.log(chalk.red(`Sync failed: ${err.message}`));
193
- process.exitCode = 1;
194
- }
195
- }
196
-
197
- // --- pe sync push <path> <pal> ---
198
-
199
- async function pushSync(dirPath, palName, opts) {
200
- let absolutePath, pal, identity, keyPair;
201
- try {
202
- absolutePath = validateDir(dirPath);
203
- pal = findPal(palName);
204
- identity = getIdentity();
205
- keyPair = getKeyPair();
206
- } catch (err) {
207
- console.log(chalk.red(err.message));
208
- process.exitCode = 1;
209
- return;
210
- }
211
-
212
- const options = parseSyncOptions(opts);
213
- const palHandle = pal.handle || pal.name;
214
- const palId = pal.id || pal.publicKey;
215
-
216
- registerSyncHandlers();
217
-
218
- console.log(chalk.blue(`Scanning ${absolutePath}...`));
219
- const existingPair = findSyncPair(absolutePath, palId);
220
- const cachedManifest = existingPair ? getSyncManifest(existingPair.id) : null;
221
- const manifest = await buildManifest(absolutePath, cachedManifest, options);
222
- console.log(chalk.gray(` ${manifest.length} file(s) indexed`));
223
-
224
- const pair = addSyncPair(absolutePath, palId, `sync:${palHandle}`);
225
- const lastManifest = getSyncManifest(pair.id);
226
- const manifestMap = manifestToMap(manifest);
227
-
228
- let added = 0, modified = 0, deleted = 0;
229
- if (lastManifest) {
230
- const lastMap = manifestToMap(lastManifest);
231
- const diff = diffManifests(lastMap, manifestMap);
232
- added = diff.added.length;
233
- modified = diff.modified.length;
234
- deleted = diff.deleted.length;
235
-
236
- if (added === 0 && modified === 0 && deleted === 0) {
237
- console.log(chalk.green('Already in sync. No changes to push.'));
238
- return;
239
- }
240
-
241
- console.log(chalk.white(` Changes: ${chalk.green(`+${added}`)} added, ${chalk.yellow(`~${modified}`)} modified, ${chalk.red(`-${deleted}`)} deleted`));
242
- } else {
243
- console.log(chalk.gray(' First sync for this pair.'));
244
- }
245
-
246
- if (options.dryRun) {
247
- console.log(chalk.yellow('Dry run — no changes sent.'));
248
- return;
249
- }
250
-
251
- // Select transport and send via PAL/1.0 protocol
252
- let transport;
253
- try {
254
- transport = await selectSyncTransport(palId, { peerHandle: palHandle });
255
- console.log(chalk.gray(` Transport: ${transport.type}`));
256
- } catch (err) {
257
- console.log(chalk.red(`Cannot reach ${pal.name}: ${err.message}`));
258
- process.exitCode = 1;
259
- return;
260
- }
261
-
262
- const session = new SyncSession({
263
- localPath: absolutePath,
264
- peerPK: palId,
265
- transport,
266
- keyPair,
267
- syncPairId: pair.id,
268
- options,
269
- });
270
-
271
- try {
272
- await session.start(cachedManifest);
273
-
274
- saveSyncManifest(pair.id, manifest);
275
- updateSyncPair(pair.id, {
276
- lastSync: new Date().toISOString(),
277
- status: 'synced',
278
- lastDirection: 'push',
279
- });
280
-
281
- addSyncHistoryEntry(pair.id, {
282
- direction: 'push',
283
- filesAdded: added || manifest.length,
284
- filesModified: modified,
285
- filesDeleted: deleted,
286
- totalFiles: manifest.length,
287
- });
288
-
289
- session.finish();
290
- console.log(chalk.green(`Push complete. Manifest sent to ${pal.name}.`));
291
- console.log(chalk.gray(`Sync pair: ${pair.id}`));
292
- } catch (err) {
293
- session.abort(err.message);
294
- updateSyncPair(pair.id, { status: 'error' });
295
- logger.error(`Push sync failed: ${err.message}`);
296
- console.log(chalk.red(`Push failed: ${err.message}`));
297
- process.exitCode = 1;
298
- }
299
- }
300
-
301
- // --- pe sync pull <path> <pal> ---
302
-
303
- async function pullSync(dirPath, palName, opts) {
304
- let absolutePath, pal, identity, keyPair;
305
- try {
306
- absolutePath = validateDir(dirPath);
307
- pal = findPal(palName);
308
- identity = getIdentity();
309
- keyPair = getKeyPair();
310
- } catch (err) {
311
- console.log(chalk.red(err.message));
312
- process.exitCode = 1;
313
- return;
314
- }
315
-
316
- const options = parseSyncOptions(opts);
317
- const palHandle = pal.handle || pal.name;
318
- const palId = pal.id || pal.publicKey;
319
-
320
- registerSyncHandlers();
321
-
322
- console.log(chalk.blue(`Pulling from ${pal.name} to ${absolutePath}...`));
323
-
324
- let transport;
325
- try {
326
- transport = await selectSyncTransport(palId, { peerHandle: palHandle });
327
- console.log(chalk.gray(` Transport: ${transport.type}`));
328
- } catch (err) {
329
- console.log(chalk.red(`Cannot reach ${pal.name}: ${err.message}`));
330
- process.exitCode = 1;
331
- return;
332
- }
333
-
334
- const existingPair = findSyncPair(absolutePath, palId);
335
- const cachedManifest = existingPair ? getSyncManifest(existingPair.id) : null;
336
- const pair = addSyncPair(absolutePath, palId, `sync:${palHandle}`);
337
-
338
- const session = new SyncSession({
339
- localPath: absolutePath,
340
- peerPK: palId,
341
- transport,
342
- keyPair,
343
- syncPairId: pair.id,
344
- options,
345
- });
346
-
347
- try {
348
- // Start session (sends our manifest to peer)
349
- const { localManifest } = await session.start(cachedManifest);
350
- console.log(chalk.gray(` ${localManifest.length} local file(s) indexed`));
351
- console.log(chalk.blue(` Manifest sent, waiting for ${pal.name}'s response...`));
352
- console.log(chalk.gray(' (Pull request sent via PAL/1.0 protocol)'));
353
-
354
- saveSyncManifest(pair.id, localManifest);
355
- updateSyncPair(pair.id, {
356
- lastSync: new Date().toISOString(),
357
- status: 'synced',
358
- lastDirection: 'pull',
359
- });
360
-
361
- addSyncHistoryEntry(pair.id, {
362
- direction: 'pull',
363
- totalFiles: localManifest.length,
364
- });
365
-
366
- session.finish();
367
- console.log(chalk.green(`Pull request sent to ${pal.name}.`));
368
- } catch (err) {
369
- session.abort(err.message);
370
- updateSyncPair(pair.id, { status: 'error' });
371
- logger.error(`Pull sync failed: ${err.message}`);
372
- console.log(chalk.red(`Pull failed: ${err.message}`));
373
- process.exitCode = 1;
374
- }
375
- }
376
-
377
- // --- pe sync watch <path> <pal> ---
378
-
379
- async function watchSync(dirPath, palName, opts) {
380
- let absolutePath, pal, keyPair;
381
- try {
382
- absolutePath = validateDir(dirPath);
383
- pal = findPal(palName);
384
- getIdentity();
385
- keyPair = getKeyPair();
386
- } catch (err) {
387
- console.log(chalk.red(err.message));
388
- process.exitCode = 1;
389
- return;
390
- }
391
-
392
- const options = parseSyncOptions(opts);
393
- const palHandle = pal.handle || pal.name;
394
- const palId = pal.id || pal.publicKey;
395
-
396
- registerSyncHandlers();
397
-
398
- const pair = addSyncPair(absolutePath, palId, `sync:${palHandle}`);
399
-
400
- console.log(chalk.blue(`Watching ${absolutePath} for changes...`));
401
- console.log(chalk.gray(`Syncing to ${pal.name}. Press Ctrl+C to stop.`));
402
-
403
- let debounceTimer = null;
404
- const debounceMs = 2000;
405
- let syncing = false;
406
-
407
- const triggerSync = async () => {
408
- if (syncing) return;
409
- syncing = true;
410
-
411
- try {
412
- const lastManifest = getSyncManifest(pair.id);
413
- const manifest = await buildManifest(absolutePath, lastManifest, options);
414
-
415
- if (lastManifest) {
416
- const lastMap = manifestToMap(lastManifest);
417
- const currentMap = manifestToMap(manifest);
418
- const diff = diffManifests(lastMap, currentMap);
419
- const total = diff.added.length + diff.modified.length + diff.deleted.length;
420
- if (total === 0) {
421
- syncing = false;
422
- return;
423
- }
424
- console.log(chalk.blue(`Change detected: ${chalk.green(`+${diff.added.length}`)} ${chalk.yellow(`~${diff.modified.length}`)} ${chalk.red(`-${diff.deleted.length}`)}`));
425
- } else {
426
- console.log(chalk.blue(`Initial sync: ${manifest.length} file(s)`));
427
- }
428
-
429
- // Select transport and send via PAL/1.0
430
- const transport = await selectSyncTransport(palId, { peerHandle: palHandle });
431
- const session = new SyncSession({
432
- localPath: absolutePath,
433
- peerPK: palId,
434
- transport,
435
- keyPair,
436
- syncPairId: pair.id,
437
- options,
438
- });
439
-
440
- await session.start(lastManifest);
441
-
442
- saveSyncManifest(pair.id, manifest);
443
- updateSyncPair(pair.id, {
444
- lastSync: new Date().toISOString(),
445
- status: 'synced',
446
- lastDirection: 'push',
447
- });
448
-
449
- addSyncHistoryEntry(pair.id, {
450
- direction: 'push',
451
- totalFiles: manifest.length,
452
- auto: true,
453
- });
454
-
455
- session.finish();
456
- console.log(chalk.green(`Synced to ${pal.name} at ${new Date().toLocaleTimeString()}`));
457
- } catch (err) {
458
- logger.error(`Watch sync failed: ${err.message}`);
459
- console.log(chalk.yellow(`Sync failed: ${err.message}`));
460
- } finally {
461
- syncing = false;
462
- }
463
- };
464
-
465
- try {
466
- const watcher = fs.watch(absolutePath, { recursive: true }, (_event, filename) => {
467
- if (filename && /node_modules|\.git/.test(filename)) return;
468
- if (debounceTimer) clearTimeout(debounceTimer);
469
- debounceTimer = setTimeout(triggerSync, debounceMs);
470
- });
471
- process.on('SIGINT', () => { watcher.close(); process.exit(0); });
472
- } catch (err) {
473
- console.log(chalk.red(`Watch failed: ${err.message}`));
474
- process.exitCode = 1;
475
- return;
476
- }
477
-
478
- triggerSync();
479
- }
480
-
481
- // --- pe sync status <path> ---
482
-
483
- async function syncStatus(dirPath) {
484
- if (!dirPath) {
485
- const pairs = getSyncPairs();
486
- if (pairs.length === 0) {
487
- console.log(chalk.gray('No sync pairs configured. Use `pe sync <path> <pal>` to start.'));
488
- return;
489
- }
490
-
491
- const friends = getFriends();
492
- console.log('');
493
- console.log(chalk.cyan.bold('Sync Pairs'));
494
- console.log('');
495
-
496
- for (const pair of pairs) {
497
- const pal = friends.find(f => f.id === pair.palId);
498
- const palName = pal?.name || pair.palId;
499
- const statusColor =
500
- pair.status === 'synced' ? chalk.green :
501
- pair.status === 'error' ? chalk.red :
502
- pair.status === 'pending-download' ? chalk.yellow :
503
- chalk.gray;
504
-
505
- console.log(` ${chalk.white(pair.localPath)} ${chalk.gray('->')} ${chalk.yellow(palName)}`);
506
- console.log(` Status: ${statusColor(pair.status)} Direction: ${pair.lastDirection || '-'} Last: ${pair.lastSync || 'never'}`);
507
- console.log(` ${chalk.gray(`ID: ${pair.id}`)}`);
508
- }
509
- console.log('');
510
- return;
511
- }
512
-
513
- let absolutePath;
514
- try {
515
- absolutePath = validateDir(dirPath);
516
- } catch (err) {
517
- console.log(chalk.red(err.message));
518
- process.exitCode = 1;
519
- return;
520
- }
521
-
522
- const pairs = getSyncPairs().filter(p => p.localPath === absolutePath);
523
- if (pairs.length === 0) {
524
- console.log(chalk.yellow(`No sync pairs for ${absolutePath}.`));
525
- console.log(chalk.gray('Use `pe sync <path> <pal>` to create one.'));
526
- return;
527
- }
528
-
529
- const friends = getFriends();
530
- console.log('');
531
- console.log(chalk.cyan.bold(`Sync Status: ${absolutePath}`));
532
- console.log('');
533
-
534
- const statusCached = pairs.length === 1 ? getSyncManifest(pairs[0].id) : null;
535
- const currentManifest = await buildManifest(absolutePath, statusCached);
536
- const currentMap = manifestToMap(currentManifest);
537
-
538
- for (const pair of pairs) {
539
- const pal = friends.find(f => f.id === pair.palId);
540
- const palName = pal?.name || pair.palId;
541
- const lastManifest = getSyncManifest(pair.id);
542
-
543
- console.log(` ${chalk.yellow(palName)} (${pair.status})`);
544
-
545
- if (!lastManifest) {
546
- console.log(chalk.gray(' No previous sync. All files will be synced.'));
547
- console.log(` ${chalk.white(`${currentManifest.length} file(s) to push`)}`);
548
- } else {
549
- const lastMap = manifestToMap(lastManifest);
550
- const diff = diffManifests(lastMap, currentMap);
551
- const total = diff.added.length + diff.modified.length + diff.deleted.length;
552
-
553
- if (total === 0) {
554
- console.log(chalk.green(' In sync. No local changes.'));
555
- } else {
556
- console.log(` ${chalk.green(`+${diff.added.length}`)} added ${chalk.yellow(`~${diff.modified.length}`)} modified ${chalk.red(`-${diff.deleted.length}`)} deleted`);
557
- if (diff.added.length <= 5) {
558
- for (const a of diff.added) console.log(chalk.green(` + ${a.relativePath}`));
559
- }
560
- if (diff.modified.length <= 5) {
561
- for (const m of diff.modified) console.log(chalk.yellow(` ~ ${m.relativePath}`));
562
- }
563
- if (diff.deleted.length <= 5) {
564
- for (const d of diff.deleted) console.log(chalk.red(` - ${d.relativePath}`));
565
- }
566
- }
567
- }
568
-
569
- const history = getSyncHistory(pair.id);
570
- if (history.length > 0) {
571
- console.log(chalk.gray(` Last ${Math.min(history.length, 3)} syncs:`));
572
- for (const h of history.slice(0, 3)) {
573
- const dir = h.direction === 'push' ? chalk.blue('PUSH') :
574
- h.direction === 'pull' ? chalk.magenta('PULL') :
575
- chalk.cyan('SYNC');
576
- const changes = [];
577
- if (h.filesAdded) changes.push(chalk.green(`+${h.filesAdded}`));
578
- if (h.filesModified) changes.push(chalk.yellow(`~${h.filesModified}`));
579
- if (h.filesDeleted) changes.push(chalk.red(`-${h.filesDeleted}`));
580
- if (h.conflicts) changes.push(chalk.magenta(`!${h.conflicts}`));
581
- console.log(chalk.gray(` ${h.timestamp} ${dir} ${changes.join(' ')}${h.auto ? chalk.gray(' [auto]') : ''}`));
582
- }
583
- }
584
- console.log('');
585
- }
586
- }
587
-
588
- // --- pe sync remove <id> ---
589
-
590
- function removePair(id) {
591
- const pairs = getSyncPairs();
592
- const pair = pairs.find(p => p.id === id);
593
- if (!pair) {
594
- console.log(chalk.red(`Sync pair '${id}' not found.`));
595
- process.exitCode = 1;
596
- return;
597
- }
598
- removeSyncPair(id);
599
- console.log(chalk.green(`Removed sync pair ${id}.`));
600
- }
601
-
602
- function parseInterval(str) {
603
- const match = str.match(/^(\d+)(s|m|h)$/);
604
- if (!match) return null;
605
- const [, num, unit] = match;
606
- const multipliers = { s: 1000, m: 60000, h: 3600000 };
607
- return parseInt(num, 10) * multipliers[unit];
608
- }
609
-
610
- function formatSyncBytes(b) {
611
- if (!b) return '0 B';
612
- if (b < 1024) return b + ' B';
613
- if (b < 1048576) return (b / 1024).toFixed(1) + ' KB';
614
- if (b < 1073741824) return (b / 1048576).toFixed(1) + ' MB';
615
- return (b / 1073741824).toFixed(2) + ' GB';
616
- }
617
-
618
- // --- Command registration ---
619
-
620
- export default function syncCommand(program) {
621
- const cmd = program
622
- .command('sync')
623
- .description('sync directories with pals (rsync-like P2P sync)');
624
-
625
- // Default: pe sync <path> <pal> — bi-directional
626
- const defaultSync = cmd
627
- .command('start <path> <pal>')
628
- .description('bi-directional sync with a pal (default)')
629
- .action(async (syncPath, pal, cliOpts) => {
630
- if (!syncPath?.trim()) {
631
- console.log(chalk.red('Error: path cannot be empty.'));
632
- process.exitCode = 1;
633
- return;
634
- }
635
- await biSync(syncPath, pal, cliOpts);
636
- });
637
- addSyncFlagsToCommand(defaultSync);
638
-
639
- const pushCmd = cmd
640
- .command('push <path> <pal>')
641
- .description('push local changes to a pal (one-way)')
642
- .action(async (syncPath, pal, cliOpts) => {
643
- if (!syncPath?.trim()) {
644
- console.log(chalk.red('Error: path cannot be empty.'));
645
- process.exitCode = 1;
646
- return;
647
- }
648
- await pushSync(syncPath, pal, cliOpts);
649
- });
650
- addSyncFlagsToCommand(pushCmd);
651
-
652
- const pullCmd = cmd
653
- .command('pull <path> <pal>')
654
- .description('pull changes from a pal (one-way)')
655
- .action(async (syncPath, pal, cliOpts) => {
656
- if (!syncPath?.trim()) {
657
- console.log(chalk.red('Error: path cannot be empty.'));
658
- process.exitCode = 1;
659
- return;
660
- }
661
- await pullSync(syncPath, pal, cliOpts);
662
- });
663
- addSyncFlagsToCommand(pullCmd);
664
-
665
- const watchCmd = cmd
666
- .command('watch <path> <pal>')
667
- .description('watch directory and auto-sync on changes')
668
- .action(async (syncPath, pal, cliOpts) => {
669
- await watchSync(syncPath, pal, cliOpts);
670
- });
671
- addSyncFlagsToCommand(watchCmd);
672
-
673
- cmd
674
- .command('status [path]')
675
- .description('show sync status and changes since last sync')
676
- .action((syncPath) => {
677
- syncStatus(syncPath);
678
- });
679
-
680
- cmd
681
- .command('list')
682
- .description('list all sync pairs')
683
- .action(() => {
684
- syncStatus();
685
- });
686
-
687
- cmd
688
- .command('remove <id>')
689
- .description('remove a sync pair')
690
- .action((id) => {
691
- removePair(id);
692
- });
693
-
694
- cmd
695
- .command('history [pairId]')
696
- .description('view sync history')
697
- .action((pairId) => {
698
- const pairs = getSyncPairs();
699
- if (pairs.length === 0) {
700
- console.log(chalk.gray('No sync pairs configured.'));
701
- return;
702
- }
703
-
704
- const friends = getFriends();
705
- const targetPairs = pairId ? pairs.filter(p => p.id === pairId) : pairs;
706
- if (targetPairs.length === 0) {
707
- console.log(chalk.red(`Sync pair '${pairId}' not found.`));
708
- process.exitCode = 1;
709
- return;
710
- }
711
-
712
- console.log('');
713
- console.log(chalk.cyan.bold('Sync History'));
714
- console.log('');
715
-
716
- for (const pair of targetPairs) {
717
- const pal = friends.find(f => f.id === pair.palId);
718
- const palName = pal?.name || pair.palId;
719
- console.log(` ${chalk.white(pair.localPath)} ${chalk.gray('->')} ${chalk.yellow(palName)}`);
720
- console.log(` ${chalk.gray(`ID: ${pair.id}`)}`);
721
-
722
- const history = getSyncHistory(pair.id);
723
- if (history.length === 0) {
724
- console.log(chalk.gray(' No history yet.'));
725
- } else {
726
- for (const h of history) {
727
- const dir = h.direction === 'push' ? chalk.blue('PUSH') :
728
- h.direction === 'pull' ? chalk.magenta('PULL') :
729
- chalk.cyan('SYNC');
730
- const changes = [];
731
- if (h.filesAdded) changes.push(chalk.green(`+${h.filesAdded}`));
732
- if (h.filesModified) changes.push(chalk.yellow(`~${h.filesModified}`));
733
- if (h.filesDeleted) changes.push(chalk.red(`-${h.filesDeleted}`));
734
- if (h.conflicts) changes.push(chalk.magenta(`!${h.conflicts}`));
735
- if (h.totalFiles) changes.push(chalk.gray(`(${h.totalFiles} total)`));
736
- console.log(` ${chalk.gray(h.timestamp)} ${dir} ${changes.join(' ')}${h.auto ? chalk.gray(' [auto]') : ''}`);
737
- }
738
- }
739
- console.log('');
740
- }
741
- });
742
-
743
- cmd
744
- .command('diff <localPath> <remotePath>')
745
- .description('compare local and remote folders, show what needs to transfer')
746
- .option('--json', 'Output as JSON')
747
- .action(async (localPath, remotePath, cmdOpts) => {
748
- try {
749
- console.log(chalk.blue('Building local manifest...'));
750
- const localManifest = buildDiffManifest(localPath);
751
- console.log(chalk.blue('Building remote manifest...'));
752
- const remoteManifest = buildDiffManifest(remotePath);
753
-
754
- const diff = diffFileManifests(localManifest, remoteManifest);
755
- const summary = diffSummary(diff);
756
-
757
- if (cmdOpts.json) {
758
- console.log(JSON.stringify({ diff, summary }, null, 2));
759
- return;
760
- }
761
-
762
- console.log();
763
- if (diff.added.length > 0) {
764
- console.log(chalk.green(` + ${diff.added.length} new files`));
765
- diff.added.forEach(f => console.log(chalk.green(` + ${f.path} (${formatSyncBytes(f.size)})`)));
766
- }
767
- if (diff.modified.length > 0) {
768
- console.log(chalk.yellow(` ~ ${diff.modified.length} modified files`));
769
- diff.modified.forEach(f => console.log(chalk.yellow(` ~ ${f.path} (${formatSyncBytes(f.size)})`)));
770
- }
771
- if (diff.deleted.length > 0) {
772
- console.log(chalk.red(` - ${diff.deleted.length} deleted files`));
773
- diff.deleted.forEach(f => console.log(chalk.red(` - ${f.path}`)));
774
- }
775
- if (diff.unchanged.length > 0) {
776
- console.log(chalk.gray(` = ${diff.unchanged.length} unchanged files`));
777
- }
778
-
779
- console.log();
780
- console.log(chalk.white(` Transfer needed: ${formatSyncBytes(summary.transferBytes)} (${summary.addedCount + summary.modifiedCount} files)`));
781
- console.log(chalk.gray(` Skipping: ${summary.skippedFiles} files already up to date`));
782
- } catch (err) {
783
- console.error(chalk.red(`Error: ${err.message}`));
784
- process.exit(1);
785
- }
786
- });
787
-
788
- // --- pe sync profile save/list/use ---
789
- const profileCmd = cmd.command('profile').description('manage sync flag profiles');
790
-
791
- profileCmd
792
- .command('save <name>')
793
- .description('save current flags as a named profile')
794
- .action((name, _opts, profileCommand) => {
795
- // Collect flags from parent command context
796
- const parentOpts = profileCommand.parent?.parent?.opts() || {};
797
- const profiles = config.get('syncProfiles') || {};
798
- const profile = {};
799
- for (const key of ['exclude', 'include', 'delete', 'checksum', 'sizeOnly', 'update',
800
- 'ignoreExisting', 'backup', 'compress', 'bandwidthLimit', 'archive']) {
801
- if (parentOpts[key] !== undefined) profile[key] = parentOpts[key];
802
- }
803
- profiles[name] = profile;
804
- config.set('syncProfiles', profiles);
805
- console.log(chalk.green(`Profile '${name}' saved.`));
806
- if (Object.keys(profile).length === 0) {
807
- console.log(chalk.gray(' (empty profile — use with sync flags to save them)'));
808
- } else {
809
- for (const [k, v] of Object.entries(profile)) {
810
- console.log(chalk.gray(` ${k}: ${JSON.stringify(v)}`));
811
- }
812
- }
813
- });
814
-
815
- profileCmd
816
- .command('list')
817
- .description('list saved profiles')
818
- .action(() => {
819
- const profiles = config.get('syncProfiles') || {};
820
- const names = Object.keys(profiles);
821
- if (names.length === 0) {
822
- console.log(chalk.gray('No saved profiles. Use `pe sync profile save <name>` to create one.'));
823
- return;
824
- }
825
- console.log('');
826
- console.log(chalk.cyan.bold('Sync Profiles'));
827
- for (const name of names) {
828
- const p = profiles[name];
829
- const flags = Object.entries(p).map(([k, v]) => `${k}=${JSON.stringify(v)}`).join(', ');
830
- console.log(` ${chalk.white(name)}: ${chalk.gray(flags || '(empty)')}`);
831
- }
832
- console.log('');
833
- });
834
-
835
- profileCmd
836
- .command('use <name>')
837
- .description('show flags for a profile (use with eval or copy)')
838
- .action((name) => {
839
- const profiles = config.get('syncProfiles') || {};
840
- const profile = profiles[name];
841
- if (!profile) {
842
- console.log(chalk.red(`Profile '${name}' not found.`));
843
- process.exitCode = 1;
844
- return;
845
- }
846
- const flags = [];
847
- if (profile.exclude) {
848
- const arr = Array.isArray(profile.exclude) ? profile.exclude : [profile.exclude];
849
- arr.forEach(p => flags.push(`--exclude "${p}"`));
850
- }
851
- if (profile.include) {
852
- const arr = Array.isArray(profile.include) ? profile.include : [profile.include];
853
- arr.forEach(p => flags.push(`--include "${p}"`));
854
- }
855
- if (profile.delete) flags.push('--delete');
856
- if (profile.checksum) flags.push('--checksum');
857
- if (profile.sizeOnly) flags.push('--size-only');
858
- if (profile.update) flags.push('--update');
859
- if (profile.ignoreExisting) flags.push('--ignore-existing');
860
- if (profile.backup) flags.push('--backup');
861
- if (profile.compress) flags.push('--compress');
862
- if (profile.bandwidthLimit) flags.push(`--bandwidth-limit ${profile.bandwidthLimit}`);
863
- if (profile.archive) flags.push('--archive');
864
-
865
- console.log(flags.join(' ') || '(no flags)');
866
- });
867
-
868
- profileCmd
869
- .command('delete <name>')
870
- .description('delete a saved profile')
871
- .action((name) => {
872
- const profiles = config.get('syncProfiles') || {};
873
- if (!profiles[name]) {
874
- console.log(chalk.red(`Profile '${name}' not found.`));
875
- process.exitCode = 1;
876
- return;
877
- }
878
- delete profiles[name];
879
- config.set('syncProfiles', profiles);
880
- console.log(chalk.green(`Profile '${name}' deleted.`));
881
- });
882
-
883
- // --- pe sync schedule <path> <pal> --every <interval> ---
884
- cmd
885
- .command('schedule <path> <pal>')
886
- .description('background sync at intervals')
887
- .requiredOption('--every <interval>', 'sync interval (e.g., 5m, 1h, 30s)')
888
- .action(async (syncPath, pal, schedOpts) => {
889
- let absolutePath, palObj, keyPair;
890
- try {
891
- absolutePath = validateDir(syncPath);
892
- palObj = findPal(pal);
893
- getIdentity();
894
- keyPair = getKeyPair();
895
- } catch (err) {
896
- console.log(chalk.red(err.message));
897
- process.exitCode = 1;
898
- return;
899
- }
900
-
901
- const intervalMs = parseInterval(schedOpts.every);
902
- if (!intervalMs || intervalMs < 5000) {
903
- console.log(chalk.red('Invalid interval. Minimum: 5s. Examples: 30s, 5m, 1h'));
904
- process.exitCode = 1;
905
- return;
906
- }
907
-
908
- const palHandle = palObj.handle || palObj.name;
909
- const palId = palObj.id || palObj.publicKey;
910
- const pair = addSyncPair(absolutePath, palId, `sync:${palHandle}`);
911
-
912
- registerSyncHandlers();
913
-
914
- console.log(chalk.blue(`Scheduled sync: ${absolutePath} -> ${palObj.name} every ${schedOpts.every}`));
915
- console.log(chalk.gray('Press Ctrl+C to stop.'));
916
-
917
- const doSync = async () => {
918
- try {
919
- const transport = await selectSyncTransport(palId, { peerHandle: palHandle });
920
- const lastManifest = getSyncManifest(pair.id);
921
- const session = new SyncSession({
922
- localPath: absolutePath,
923
- peerPK: palId,
924
- transport,
925
- keyPair,
926
- syncPairId: pair.id,
927
- });
928
- await session.start(lastManifest);
929
- const manifest = session.localManifest;
930
- saveSyncManifest(pair.id, manifest);
931
- updateSyncPair(pair.id, {
932
- lastSync: new Date().toISOString(),
933
- status: 'synced',
934
- lastDirection: 'push',
935
- });
936
- addSyncHistoryEntry(pair.id, {
937
- direction: 'push',
938
- totalFiles: manifest.length,
939
- auto: true,
940
- });
941
- session.finish();
942
- console.log(chalk.green(` Synced at ${new Date().toLocaleTimeString()} (${manifest.length} files)`));
943
- } catch (err) {
944
- console.log(chalk.yellow(` Sync failed: ${err.message}`));
945
- }
946
- };
947
-
948
- doSync();
949
- const timer = setInterval(doSync, intervalMs);
950
- process.on('SIGINT', () => { clearInterval(timer); process.exit(0); });
951
- });
952
-
953
- cmd.addHelpText('after', `
954
- ${chalk.cyan('Examples:')}
955
- $ pe sync start ./project alice Bi-directional sync with alice
956
- $ pe sync push ./project alice Push local changes to alice
957
- $ pe sync pull ./project alice Pull alice's changes locally
958
- $ pe sync watch ./project alice Watch and auto-sync on changes
959
- $ pe sync status ./project Show what changed since last sync
960
- $ pe sync status Show all sync pairs
961
-
962
- ${chalk.cyan('rsync-like flags:')}
963
- $ pe sync push ./project alice --dry-run Preview without applying
964
- $ pe sync push ./project alice --exclude "*.log" Skip log files
965
- $ pe sync push ./project alice --delete Remove files not in source
966
- $ pe sync push ./project alice --compress Compress transfer
967
- $ pe sync push ./project alice --progress Show progress
968
- $ pe sync push ./project alice --archive --recursive --checksum --delete
969
- $ pe sync push ./project alice --backup Create .bak before overwriting
970
- $ pe sync push ./project alice --bandwidth-limit 500 Throttle to 500 KB/s
971
-
972
- ${chalk.cyan('How it works:')}
973
- 1. Sync uses PAL/1.0 protocol over LAN (TCP:7474) or WebRTC (internet).
974
- 2. No discovery server required for LAN sync.
975
- 3. Manifests are signed and encrypted (Ed25519 + XChaCha20-Poly1305).
976
- 4. Conflicts create ${chalk.magenta('.sync-conflict')} copies — no data loss.
977
- 5. ${chalk.white('watch')} monitors your directory and auto-syncs on changes.
978
- `);
979
- }
1
+ import chalk from 'chalk';
2
+ import path from 'path';
3
+ import fs from 'fs';
4
+ import config from '../utils/config.js';
5
+ import logger from '../utils/logger.js';
6
+ import { buildManifest as buildDiffManifest, diffManifests as diffFileManifests, diffSummary } from '../core/fileDiff.js';
7
+ import { getFriends } from '../core/users.js';
8
+ import {
9
+ buildManifest,
10
+ diffManifests,
11
+ manifestToMap,
12
+ detectConflicts,
13
+ applyDiff,
14
+ formatSize,
15
+ SyncSession,
16
+ } from '../core/syncEngine.js';
17
+ import {
18
+ getSyncPairs,
19
+ addSyncPair,
20
+ removeSyncPair,
21
+ updateSyncPair,
22
+ findSyncPair,
23
+ saveSyncManifest,
24
+ getSyncManifest,
25
+ getLastSyncedManifestMap,
26
+ addSyncHistoryEntry,
27
+ getSyncHistory,
28
+ } from '../core/syncState.js';
29
+ import { selectSyncTransport } from '../core/syncTransport.js';
30
+ import { registerSyncHandlers } from '../core/syncProtocolHandlers.js';
31
+ import { parseSyncOptions, addSyncFlagsToCommand } from '../core/syncOptions.js';
32
+
33
+ function getIdentity() {
34
+ const identity = config.get('identity');
35
+ if (!identity?.handle) {
36
+ throw new Error('Not registered. Run `pal init` and `pal register` first.');
37
+ }
38
+ return identity;
39
+ }
40
+
41
+ function getKeyPair() {
42
+ const identity = config.get('identity');
43
+ if (!identity?.publicKey || !identity?.privateKey) {
44
+ throw new Error('Identity keys not configured. Run `pal init` first.');
45
+ }
46
+ return {
47
+ publicKey: Buffer.from(identity.publicKey, 'hex'),
48
+ secretKey: Buffer.from(identity.privateKey, 'hex'),
49
+ };
50
+ }
51
+
52
+ function findPal(palName) {
53
+ const friends = getFriends();
54
+ const pal = friends.find(f => f.name === palName || f.id === palName || f.handle === palName);
55
+ if (!pal) {
56
+ throw new Error(`Pal '${palName}' not found. Add them first with \`pal pal add\`.`);
57
+ }
58
+ return pal;
59
+ }
60
+
61
+ function validateDir(dirPath) {
62
+ const abs = path.resolve(dirPath);
63
+ if (!fs.existsSync(abs)) {
64
+ throw new Error(`Path not found: ${abs}`);
65
+ }
66
+ if (!fs.statSync(abs).isDirectory()) {
67
+ throw new Error('Sync only works with directories. Use `pal share` for single files.');
68
+ }
69
+ return abs;
70
+ }
71
+
72
+ function printDiffSummary(diff, verbose) {
73
+ if (diff.added.length) {
74
+ console.log(chalk.green(` + ${diff.added.length} new file(s)`));
75
+ if (verbose) diff.added.forEach(a => console.log(chalk.green(` + ${a.relativePath}`)));
76
+ }
77
+ if (diff.modified.length) {
78
+ console.log(chalk.yellow(` ~ ${diff.modified.length} modified file(s)`));
79
+ if (verbose) diff.modified.forEach(m => console.log(chalk.yellow(` ~ ${m.relativePath}`)));
80
+ }
81
+ if (diff.deleted.length) {
82
+ console.log(chalk.red(` - ${diff.deleted.length} deleted file(s)`));
83
+ if (verbose) diff.deleted.forEach(d => console.log(chalk.red(` - ${d.relativePath}`)));
84
+ }
85
+ if ((diff.conflicts || []).length) {
86
+ console.log(chalk.magenta(` ! ${diff.conflicts.length} conflict(s)`));
87
+ if (verbose) diff.conflicts.forEach(c => console.log(chalk.magenta(` ! ${c.relativePath}`)));
88
+ }
89
+ if ((diff.skipped || []).length && verbose) {
90
+ console.log(chalk.gray(` = ${diff.skipped.length} skipped`));
91
+ }
92
+ }
93
+
94
+ function printResults(results) {
95
+ if (results.copied.length) console.log(chalk.green(` Copied ${results.copied.length} file(s)`));
96
+ if (results.deleted.length) console.log(chalk.red(` Deleted ${results.deleted.length} file(s)`));
97
+ if (results.skipped?.length) console.log(chalk.gray(` Skipped ${results.skipped.length} file(s)`));
98
+ if (results.conflicted.length) {
99
+ console.log(chalk.magenta(` ${results.conflicted.length} conflict(s) saved with .sync-conflict suffix`));
100
+ for (const c of results.conflicted) {
101
+ if (c.original) console.log(chalk.gray(` ${c.original} -> ${c.conflictFile}`));
102
+ }
103
+ }
104
+ if (results.errors.length) {
105
+ console.log(chalk.red(` ${results.errors.length} error(s):`));
106
+ for (const e of results.errors) console.log(chalk.red(` ${e.file}: ${e.error}`));
107
+ }
108
+ }
109
+
110
+ // --- pal sync <path> <pal> (bi-directional, default) ---
111
+
112
+ async function biSync(dirPath, palName, opts) {
113
+ let absolutePath, pal, identity, keyPair;
114
+ try {
115
+ absolutePath = validateDir(dirPath);
116
+ pal = findPal(palName);
117
+ identity = await getIdentity();
118
+ keyPair = getKeyPair();
119
+ } catch (err) {
120
+ console.log(chalk.red(err.message));
121
+ process.exitCode = 1;
122
+ return;
123
+ }
124
+
125
+ const options = parseSyncOptions(opts);
126
+ const palHandle = pal.handle || pal.name;
127
+ const palId = pal.id || pal.publicKey;
128
+
129
+ registerSyncHandlers();
130
+
131
+ console.log(chalk.blue(`Syncing ${absolutePath} with ${pal.name}...`));
132
+
133
+ let transport;
134
+ try {
135
+ transport = await selectSyncTransport(palId, { peerHandle: palHandle });
136
+ console.log(chalk.gray(` Transport: ${transport.type}`));
137
+ } catch (err) {
138
+ console.log(chalk.red(`Cannot reach ${pal.name}: ${err.message}`));
139
+ process.exitCode = 1;
140
+ return;
141
+ }
142
+
143
+ const existingPair = findSyncPair(absolutePath, palId);
144
+ const cachedManifest = existingPair ? getSyncManifest(existingPair.id) : null;
145
+ const pair = addSyncPair(absolutePath, palId, `sync:${palHandle}`);
146
+
147
+ const session = new SyncSession({
148
+ localPath: absolutePath,
149
+ peerPK: palId,
150
+ transport,
151
+ keyPair,
152
+ syncPairId: pair.id,
153
+ options,
154
+ });
155
+
156
+ if (options.progress) {
157
+ session.on('state', (s) => console.log(chalk.gray(` [${s}]`)));
158
+ session.on('manifestSent', (d) => console.log(chalk.gray(` Sent manifest (${d.entries} files)`)));
159
+ session.on('manifestReceived', (d) => console.log(chalk.gray(` Received manifest (${d.entries} files)`)));
160
+ }
161
+
162
+ try {
163
+ const { localManifest } = await session.start(cachedManifest);
164
+ console.log(chalk.gray(` ${localManifest.length} local file(s) indexed`));
165
+
166
+ // For bi-directional sync, we need the remote manifest
167
+ // In real P2P: remote peer would respond with their manifest
168
+ // Here we send ours and wait for theirs
169
+ console.log(chalk.blue(` Waiting for ${pal.name}'s manifest...`));
170
+ console.log(chalk.gray(' (Manifest sent via PAL/1.0 protocol)'));
171
+
172
+ // Save local state
173
+ saveSyncManifest(pair.id, localManifest);
174
+ updateSyncPair(pair.id, {
175
+ lastSync: new Date().toISOString(),
176
+ status: 'synced',
177
+ lastDirection: 'sync',
178
+ });
179
+
180
+ addSyncHistoryEntry(pair.id, {
181
+ direction: 'sync',
182
+ totalFiles: localManifest.length,
183
+ });
184
+
185
+ session.finish();
186
+ console.log(chalk.green(`Sync manifest sent to ${pal.name}.`));
187
+ console.log(chalk.gray(`Sync pair: ${pair.id}`));
188
+ } catch (err) {
189
+ session.abort(err.message);
190
+ updateSyncPair(pair.id, { status: 'error' });
191
+ logger.error(`Sync failed: ${err.message}`);
192
+ console.log(chalk.red(`Sync failed: ${err.message}`));
193
+ process.exitCode = 1;
194
+ }
195
+ }
196
+
197
+ // --- pal sync push <path> <pal> ---
198
+
199
+ async function pushSync(dirPath, palName, opts) {
200
+ let absolutePath, pal, identity, keyPair;
201
+ try {
202
+ absolutePath = validateDir(dirPath);
203
+ pal = findPal(palName);
204
+ identity = await getIdentity();
205
+ keyPair = getKeyPair();
206
+ } catch (err) {
207
+ console.log(chalk.red(err.message));
208
+ process.exitCode = 1;
209
+ return;
210
+ }
211
+
212
+ const options = parseSyncOptions(opts);
213
+ const palHandle = pal.handle || pal.name;
214
+ const palId = pal.id || pal.publicKey;
215
+
216
+ registerSyncHandlers();
217
+
218
+ console.log(chalk.blue(`Scanning ${absolutePath}...`));
219
+ const existingPair = findSyncPair(absolutePath, palId);
220
+ const cachedManifest = existingPair ? getSyncManifest(existingPair.id) : null;
221
+ const manifest = await buildManifest(absolutePath, cachedManifest, options);
222
+ console.log(chalk.gray(` ${manifest.length} file(s) indexed`));
223
+
224
+ const pair = addSyncPair(absolutePath, palId, `sync:${palHandle}`);
225
+ const lastManifest = getSyncManifest(pair.id);
226
+ const manifestMap = manifestToMap(manifest);
227
+
228
+ let added = 0, modified = 0, deleted = 0;
229
+ if (lastManifest) {
230
+ const lastMap = manifestToMap(lastManifest);
231
+ const diff = diffManifests(lastMap, manifestMap);
232
+ added = diff.added.length;
233
+ modified = diff.modified.length;
234
+ deleted = diff.deleted.length;
235
+
236
+ if (added === 0 && modified === 0 && deleted === 0) {
237
+ console.log(chalk.green('Already in sync. No changes to push.'));
238
+ return;
239
+ }
240
+
241
+ console.log(chalk.white(` Changes: ${chalk.green(`+${added}`)} added, ${chalk.yellow(`~${modified}`)} modified, ${chalk.red(`-${deleted}`)} deleted`));
242
+ } else {
243
+ console.log(chalk.gray(' First sync for this pair.'));
244
+ }
245
+
246
+ if (options.dryRun) {
247
+ console.log(chalk.yellow('Dry run — no changes sent.'));
248
+ return;
249
+ }
250
+
251
+ // Select transport and send via PAL/1.0 protocol
252
+ let transport;
253
+ try {
254
+ transport = await selectSyncTransport(palId, { peerHandle: palHandle });
255
+ console.log(chalk.gray(` Transport: ${transport.type}`));
256
+ } catch (err) {
257
+ console.log(chalk.red(`Cannot reach ${pal.name}: ${err.message}`));
258
+ process.exitCode = 1;
259
+ return;
260
+ }
261
+
262
+ const session = new SyncSession({
263
+ localPath: absolutePath,
264
+ peerPK: palId,
265
+ transport,
266
+ keyPair,
267
+ syncPairId: pair.id,
268
+ options,
269
+ });
270
+
271
+ try {
272
+ await session.start(cachedManifest);
273
+
274
+ saveSyncManifest(pair.id, manifest);
275
+ updateSyncPair(pair.id, {
276
+ lastSync: new Date().toISOString(),
277
+ status: 'synced',
278
+ lastDirection: 'push',
279
+ });
280
+
281
+ addSyncHistoryEntry(pair.id, {
282
+ direction: 'push',
283
+ filesAdded: added || manifest.length,
284
+ filesModified: modified,
285
+ filesDeleted: deleted,
286
+ totalFiles: manifest.length,
287
+ });
288
+
289
+ session.finish();
290
+ console.log(chalk.green(`Push complete. Manifest sent to ${pal.name}.`));
291
+ console.log(chalk.gray(`Sync pair: ${pair.id}`));
292
+ } catch (err) {
293
+ session.abort(err.message);
294
+ updateSyncPair(pair.id, { status: 'error' });
295
+ logger.error(`Push sync failed: ${err.message}`);
296
+ console.log(chalk.red(`Push failed: ${err.message}`));
297
+ process.exitCode = 1;
298
+ }
299
+ }
300
+
301
+ // --- pal sync pull <path> <pal> ---
302
+
303
+ async function pullSync(dirPath, palName, opts) {
304
+ let absolutePath, pal, identity, keyPair;
305
+ try {
306
+ absolutePath = validateDir(dirPath);
307
+ pal = findPal(palName);
308
+ identity = await getIdentity();
309
+ keyPair = getKeyPair();
310
+ } catch (err) {
311
+ console.log(chalk.red(err.message));
312
+ process.exitCode = 1;
313
+ return;
314
+ }
315
+
316
+ const options = parseSyncOptions(opts);
317
+ const palHandle = pal.handle || pal.name;
318
+ const palId = pal.id || pal.publicKey;
319
+
320
+ registerSyncHandlers();
321
+
322
+ console.log(chalk.blue(`Pulling from ${pal.name} to ${absolutePath}...`));
323
+
324
+ let transport;
325
+ try {
326
+ transport = await selectSyncTransport(palId, { peerHandle: palHandle });
327
+ console.log(chalk.gray(` Transport: ${transport.type}`));
328
+ } catch (err) {
329
+ console.log(chalk.red(`Cannot reach ${pal.name}: ${err.message}`));
330
+ process.exitCode = 1;
331
+ return;
332
+ }
333
+
334
+ const existingPair = findSyncPair(absolutePath, palId);
335
+ const cachedManifest = existingPair ? getSyncManifest(existingPair.id) : null;
336
+ const pair = addSyncPair(absolutePath, palId, `sync:${palHandle}`);
337
+
338
+ const session = new SyncSession({
339
+ localPath: absolutePath,
340
+ peerPK: palId,
341
+ transport,
342
+ keyPair,
343
+ syncPairId: pair.id,
344
+ options,
345
+ });
346
+
347
+ try {
348
+ // Start session (sends our manifest to peer)
349
+ const { localManifest } = await session.start(cachedManifest);
350
+ console.log(chalk.gray(` ${localManifest.length} local file(s) indexed`));
351
+ console.log(chalk.blue(` Manifest sent, waiting for ${pal.name}'s response...`));
352
+ console.log(chalk.gray(' (Pull request sent via PAL/1.0 protocol)'));
353
+
354
+ saveSyncManifest(pair.id, localManifest);
355
+ updateSyncPair(pair.id, {
356
+ lastSync: new Date().toISOString(),
357
+ status: 'synced',
358
+ lastDirection: 'pull',
359
+ });
360
+
361
+ addSyncHistoryEntry(pair.id, {
362
+ direction: 'pull',
363
+ totalFiles: localManifest.length,
364
+ });
365
+
366
+ session.finish();
367
+ console.log(chalk.green(`Pull request sent to ${pal.name}.`));
368
+ } catch (err) {
369
+ session.abort(err.message);
370
+ updateSyncPair(pair.id, { status: 'error' });
371
+ logger.error(`Pull sync failed: ${err.message}`);
372
+ console.log(chalk.red(`Pull failed: ${err.message}`));
373
+ process.exitCode = 1;
374
+ }
375
+ }
376
+
377
+ // --- pal sync watch <path> <pal> ---
378
+
379
+ async function watchSync(dirPath, palName, opts) {
380
+ let absolutePath, pal, keyPair;
381
+ try {
382
+ absolutePath = validateDir(dirPath);
383
+ pal = findPal(palName);
384
+ getIdentity();
385
+ keyPair = getKeyPair();
386
+ } catch (err) {
387
+ console.log(chalk.red(err.message));
388
+ process.exitCode = 1;
389
+ return;
390
+ }
391
+
392
+ const options = parseSyncOptions(opts);
393
+ const palHandle = pal.handle || pal.name;
394
+ const palId = pal.id || pal.publicKey;
395
+
396
+ registerSyncHandlers();
397
+
398
+ const pair = addSyncPair(absolutePath, palId, `sync:${palHandle}`);
399
+
400
+ console.log(chalk.blue(`Watching ${absolutePath} for changes...`));
401
+ console.log(chalk.gray(`Syncing to ${pal.name}. Press Ctrl+C to stop.`));
402
+
403
+ let debounceTimer = null;
404
+ const debounceMs = 2000;
405
+ let syncing = false;
406
+
407
+ const triggerSync = async () => {
408
+ if (syncing) return;
409
+ syncing = true;
410
+
411
+ try {
412
+ const lastManifest = getSyncManifest(pair.id);
413
+ const manifest = await buildManifest(absolutePath, lastManifest, options);
414
+
415
+ if (lastManifest) {
416
+ const lastMap = manifestToMap(lastManifest);
417
+ const currentMap = manifestToMap(manifest);
418
+ const diff = diffManifests(lastMap, currentMap);
419
+ const total = diff.added.length + diff.modified.length + diff.deleted.length;
420
+ if (total === 0) {
421
+ syncing = false;
422
+ return;
423
+ }
424
+ console.log(chalk.blue(`Change detected: ${chalk.green(`+${diff.added.length}`)} ${chalk.yellow(`~${diff.modified.length}`)} ${chalk.red(`-${diff.deleted.length}`)}`));
425
+ } else {
426
+ console.log(chalk.blue(`Initial sync: ${manifest.length} file(s)`));
427
+ }
428
+
429
+ // Select transport and send via PAL/1.0
430
+ const transport = await selectSyncTransport(palId, { peerHandle: palHandle });
431
+ const session = new SyncSession({
432
+ localPath: absolutePath,
433
+ peerPK: palId,
434
+ transport,
435
+ keyPair,
436
+ syncPairId: pair.id,
437
+ options,
438
+ });
439
+
440
+ await session.start(lastManifest);
441
+
442
+ saveSyncManifest(pair.id, manifest);
443
+ updateSyncPair(pair.id, {
444
+ lastSync: new Date().toISOString(),
445
+ status: 'synced',
446
+ lastDirection: 'push',
447
+ });
448
+
449
+ addSyncHistoryEntry(pair.id, {
450
+ direction: 'push',
451
+ totalFiles: manifest.length,
452
+ auto: true,
453
+ });
454
+
455
+ session.finish();
456
+ console.log(chalk.green(`Synced to ${pal.name} at ${new Date().toLocaleTimeString()}`));
457
+ } catch (err) {
458
+ logger.error(`Watch sync failed: ${err.message}`);
459
+ console.log(chalk.yellow(`Sync failed: ${err.message}`));
460
+ } finally {
461
+ syncing = false;
462
+ }
463
+ };
464
+
465
+ try {
466
+ const watcher = fs.watch(absolutePath, { recursive: true }, (_event, filename) => {
467
+ if (filename && /node_modules|\.git/.test(filename)) return;
468
+ if (debounceTimer) clearTimeout(debounceTimer);
469
+ debounceTimer = setTimeout(triggerSync, debounceMs);
470
+ });
471
+ process.on('SIGINT', () => { watcher.close(); process.exit(0); });
472
+ } catch (err) {
473
+ console.log(chalk.red(`Watch failed: ${err.message}`));
474
+ process.exitCode = 1;
475
+ return;
476
+ }
477
+
478
+ triggerSync();
479
+ }
480
+
481
+ // --- pal sync status <path> ---
482
+
483
+ async function syncStatus(dirPath) {
484
+ if (!dirPath) {
485
+ const pairs = getSyncPairs();
486
+ if (pairs.length === 0) {
487
+ console.log(chalk.gray('No sync pairs configured. Use `pal sync <path> <pal>` to start.'));
488
+ return;
489
+ }
490
+
491
+ const friends = getFriends();
492
+ console.log('');
493
+ console.log(chalk.cyan.bold('Sync Pairs'));
494
+ console.log('');
495
+
496
+ for (const pair of pairs) {
497
+ const pal = friends.find(f => f.id === pair.palId);
498
+ const palName = pal?.name || pair.palId;
499
+ const statusColor =
500
+ pair.status === 'synced' ? chalk.green :
501
+ pair.status === 'error' ? chalk.red :
502
+ pair.status === 'pending-download' ? chalk.yellow :
503
+ chalk.gray;
504
+
505
+ console.log(` ${chalk.white(pair.localPath)} ${chalk.gray('->')} ${chalk.yellow(palName)}`);
506
+ console.log(` Status: ${statusColor(pair.status)} Direction: ${pair.lastDirection || '-'} Last: ${pair.lastSync || 'never'}`);
507
+ console.log(` ${chalk.gray(`ID: ${pair.id}`)}`);
508
+ }
509
+ console.log('');
510
+ return;
511
+ }
512
+
513
+ let absolutePath;
514
+ try {
515
+ absolutePath = validateDir(dirPath);
516
+ } catch (err) {
517
+ console.log(chalk.red(err.message));
518
+ process.exitCode = 1;
519
+ return;
520
+ }
521
+
522
+ const pairs = getSyncPairs().filter(p => p.localPath === absolutePath);
523
+ if (pairs.length === 0) {
524
+ console.log(chalk.yellow(`No sync pairs for ${absolutePath}.`));
525
+ console.log(chalk.gray('Use `pal sync <path> <pal>` to create one.'));
526
+ return;
527
+ }
528
+
529
+ const friends = getFriends();
530
+ console.log('');
531
+ console.log(chalk.cyan.bold(`Sync Status: ${absolutePath}`));
532
+ console.log('');
533
+
534
+ const statusCached = pairs.length === 1 ? getSyncManifest(pairs[0].id) : null;
535
+ const currentManifest = await buildManifest(absolutePath, statusCached);
536
+ const currentMap = manifestToMap(currentManifest);
537
+
538
+ for (const pair of pairs) {
539
+ const pal = friends.find(f => f.id === pair.palId);
540
+ const palName = pal?.name || pair.palId;
541
+ const lastManifest = getSyncManifest(pair.id);
542
+
543
+ console.log(` ${chalk.yellow(palName)} (${pair.status})`);
544
+
545
+ if (!lastManifest) {
546
+ console.log(chalk.gray(' No previous sync. All files will be synced.'));
547
+ console.log(` ${chalk.white(`${currentManifest.length} file(s) to push`)}`);
548
+ } else {
549
+ const lastMap = manifestToMap(lastManifest);
550
+ const diff = diffManifests(lastMap, currentMap);
551
+ const total = diff.added.length + diff.modified.length + diff.deleted.length;
552
+
553
+ if (total === 0) {
554
+ console.log(chalk.green(' In sync. No local changes.'));
555
+ } else {
556
+ console.log(` ${chalk.green(`+${diff.added.length}`)} added ${chalk.yellow(`~${diff.modified.length}`)} modified ${chalk.red(`-${diff.deleted.length}`)} deleted`);
557
+ if (diff.added.length <= 5) {
558
+ for (const a of diff.added) console.log(chalk.green(` + ${a.relativePath}`));
559
+ }
560
+ if (diff.modified.length <= 5) {
561
+ for (const m of diff.modified) console.log(chalk.yellow(` ~ ${m.relativePath}`));
562
+ }
563
+ if (diff.deleted.length <= 5) {
564
+ for (const d of diff.deleted) console.log(chalk.red(` - ${d.relativePath}`));
565
+ }
566
+ }
567
+ }
568
+
569
+ const history = getSyncHistory(pair.id);
570
+ if (history.length > 0) {
571
+ console.log(chalk.gray(` Last ${Math.min(history.length, 3)} syncs:`));
572
+ for (const h of history.slice(0, 3)) {
573
+ const dir = h.direction === 'push' ? chalk.blue('PUSH') :
574
+ h.direction === 'pull' ? chalk.magenta('PULL') :
575
+ chalk.cyan('SYNC');
576
+ const changes = [];
577
+ if (h.filesAdded) changes.push(chalk.green(`+${h.filesAdded}`));
578
+ if (h.filesModified) changes.push(chalk.yellow(`~${h.filesModified}`));
579
+ if (h.filesDeleted) changes.push(chalk.red(`-${h.filesDeleted}`));
580
+ if (h.conflicts) changes.push(chalk.magenta(`!${h.conflicts}`));
581
+ console.log(chalk.gray(` ${h.timestamp} ${dir} ${changes.join(' ')}${h.auto ? chalk.gray(' [auto]') : ''}`));
582
+ }
583
+ }
584
+ console.log('');
585
+ }
586
+ }
587
+
588
+ // --- pal sync remove <id> ---
589
+
590
+ function removePair(id) {
591
+ const pairs = getSyncPairs();
592
+ const pair = pairs.find(p => p.id === id);
593
+ if (!pair) {
594
+ console.log(chalk.red(`Sync pair '${id}' not found.`));
595
+ process.exitCode = 1;
596
+ return;
597
+ }
598
+ removeSyncPair(id);
599
+ console.log(chalk.green(`Removed sync pair ${id}.`));
600
+ }
601
+
602
+ function parseInterval(str) {
603
+ const match = str.match(/^(\d+)(s|m|h)$/);
604
+ if (!match) return null;
605
+ const [, num, unit] = match;
606
+ const multipliers = { s: 1000, m: 60000, h: 3600000 };
607
+ return parseInt(num, 10) * multipliers[unit];
608
+ }
609
+
610
+ function formatSyncBytes(b) {
611
+ if (!b) return '0 B';
612
+ if (b < 1024) return b + ' B';
613
+ if (b < 1048576) return (b / 1024).toFixed(1) + ' KB';
614
+ if (b < 1073741824) return (b / 1048576).toFixed(1) + ' MB';
615
+ return (b / 1073741824).toFixed(2) + ' GB';
616
+ }
617
+
618
+ // --- Command registration ---
619
+
620
+ export default function syncCommand(program) {
621
+ const cmd = program
622
+ .command('sync')
623
+ .description('sync directories with pals (rsync-like P2P sync)');
624
+
625
+ // Default: pal sync <path> <pal> — bi-directional
626
+ const defaultSync = cmd
627
+ .command('start <path> <pal>')
628
+ .description('bi-directional sync with a pal (default)')
629
+ .action(async (syncPath, pal, cliOpts) => {
630
+ if (!syncPath?.trim()) {
631
+ console.log(chalk.red('Error: path cannot be empty.'));
632
+ process.exitCode = 1;
633
+ return;
634
+ }
635
+ await biSync(syncPath, pal, cliOpts);
636
+ });
637
+ addSyncFlagsToCommand(defaultSync);
638
+
639
+ const pushCmd = cmd
640
+ .command('push <path> <pal>')
641
+ .description('push local changes to a pal (one-way)')
642
+ .action(async (syncPath, pal, cliOpts) => {
643
+ if (!syncPath?.trim()) {
644
+ console.log(chalk.red('Error: path cannot be empty.'));
645
+ process.exitCode = 1;
646
+ return;
647
+ }
648
+ await pushSync(syncPath, pal, cliOpts);
649
+ });
650
+ addSyncFlagsToCommand(pushCmd);
651
+
652
+ const pullCmd = cmd
653
+ .command('pull <path> <pal>')
654
+ .description('pull changes from a pal (one-way)')
655
+ .action(async (syncPath, pal, cliOpts) => {
656
+ if (!syncPath?.trim()) {
657
+ console.log(chalk.red('Error: path cannot be empty.'));
658
+ process.exitCode = 1;
659
+ return;
660
+ }
661
+ await pullSync(syncPath, pal, cliOpts);
662
+ });
663
+ addSyncFlagsToCommand(pullCmd);
664
+
665
+ const watchCmd = cmd
666
+ .command('watch <path> <pal>')
667
+ .description('watch directory and auto-sync on changes')
668
+ .action(async (syncPath, pal, cliOpts) => {
669
+ await watchSync(syncPath, pal, cliOpts);
670
+ });
671
+ addSyncFlagsToCommand(watchCmd);
672
+
673
+ cmd
674
+ .command('status [path]')
675
+ .description('show sync status and changes since last sync')
676
+ .action((syncPath) => {
677
+ syncStatus(syncPath);
678
+ });
679
+
680
+ cmd
681
+ .command('list')
682
+ .description('list all sync pairs')
683
+ .action(() => {
684
+ syncStatus();
685
+ });
686
+
687
+ cmd
688
+ .command('remove <id>')
689
+ .description('remove a sync pair')
690
+ .action((id) => {
691
+ removePair(id);
692
+ });
693
+
694
+ cmd
695
+ .command('history [pairId]')
696
+ .description('view sync history')
697
+ .action((pairId) => {
698
+ const pairs = getSyncPairs();
699
+ if (pairs.length === 0) {
700
+ console.log(chalk.gray('No sync pairs configured.'));
701
+ return;
702
+ }
703
+
704
+ const friends = getFriends();
705
+ const targetPairs = pairId ? pairs.filter(p => p.id === pairId) : pairs;
706
+ if (targetPairs.length === 0) {
707
+ console.log(chalk.red(`Sync pair '${pairId}' not found.`));
708
+ process.exitCode = 1;
709
+ return;
710
+ }
711
+
712
+ console.log('');
713
+ console.log(chalk.cyan.bold('Sync History'));
714
+ console.log('');
715
+
716
+ for (const pair of targetPairs) {
717
+ const pal = friends.find(f => f.id === pair.palId);
718
+ const palName = pal?.name || pair.palId;
719
+ console.log(` ${chalk.white(pair.localPath)} ${chalk.gray('->')} ${chalk.yellow(palName)}`);
720
+ console.log(` ${chalk.gray(`ID: ${pair.id}`)}`);
721
+
722
+ const history = getSyncHistory(pair.id);
723
+ if (history.length === 0) {
724
+ console.log(chalk.gray(' No history yet.'));
725
+ } else {
726
+ for (const h of history) {
727
+ const dir = h.direction === 'push' ? chalk.blue('PUSH') :
728
+ h.direction === 'pull' ? chalk.magenta('PULL') :
729
+ chalk.cyan('SYNC');
730
+ const changes = [];
731
+ if (h.filesAdded) changes.push(chalk.green(`+${h.filesAdded}`));
732
+ if (h.filesModified) changes.push(chalk.yellow(`~${h.filesModified}`));
733
+ if (h.filesDeleted) changes.push(chalk.red(`-${h.filesDeleted}`));
734
+ if (h.conflicts) changes.push(chalk.magenta(`!${h.conflicts}`));
735
+ if (h.totalFiles) changes.push(chalk.gray(`(${h.totalFiles} total)`));
736
+ console.log(` ${chalk.gray(h.timestamp)} ${dir} ${changes.join(' ')}${h.auto ? chalk.gray(' [auto]') : ''}`);
737
+ }
738
+ }
739
+ console.log('');
740
+ }
741
+ });
742
+
743
+ cmd
744
+ .command('diff <localPath> <remotePath>')
745
+ .description('compare local and remote folders, show what needs to transfer')
746
+ .option('--json', 'Output as JSON')
747
+ .action(async (localPath, remotePath, cmdOpts) => {
748
+ try {
749
+ console.log(chalk.blue('Building local manifest...'));
750
+ const localManifest = buildDiffManifest(localPath);
751
+ console.log(chalk.blue('Building remote manifest...'));
752
+ const remoteManifest = buildDiffManifest(remotePath);
753
+
754
+ const diff = diffFileManifests(localManifest, remoteManifest);
755
+ const summary = diffSummary(diff);
756
+
757
+ if (cmdOpts.json) {
758
+ console.log(JSON.stringify({ diff, summary }, null, 2));
759
+ return;
760
+ }
761
+
762
+ console.log();
763
+ if (diff.added.length > 0) {
764
+ console.log(chalk.green(` + ${diff.added.length} new files`));
765
+ diff.added.forEach(f => console.log(chalk.green(` + ${f.path} (${formatSyncBytes(f.size)})`)));
766
+ }
767
+ if (diff.modified.length > 0) {
768
+ console.log(chalk.yellow(` ~ ${diff.modified.length} modified files`));
769
+ diff.modified.forEach(f => console.log(chalk.yellow(` ~ ${f.path} (${formatSyncBytes(f.size)})`)));
770
+ }
771
+ if (diff.deleted.length > 0) {
772
+ console.log(chalk.red(` - ${diff.deleted.length} deleted files`));
773
+ diff.deleted.forEach(f => console.log(chalk.red(` - ${f.path}`)));
774
+ }
775
+ if (diff.unchanged.length > 0) {
776
+ console.log(chalk.gray(` = ${diff.unchanged.length} unchanged files`));
777
+ }
778
+
779
+ console.log();
780
+ console.log(chalk.white(` Transfer needed: ${formatSyncBytes(summary.transferBytes)} (${summary.addedCount + summary.modifiedCount} files)`));
781
+ console.log(chalk.gray(` Skipping: ${summary.skippedFiles} files already up to date`));
782
+ } catch (err) {
783
+ console.error(chalk.red(`Error: ${err.message}`));
784
+ process.exit(1);
785
+ }
786
+ });
787
+
788
+ // --- pal sync profile save/list/use ---
789
+ const profileCmd = cmd.command('profile').description('manage sync flag profiles');
790
+
791
+ profileCmd
792
+ .command('save <name>')
793
+ .description('save current flags as a named profile')
794
+ .action((name, _opts, profileCommand) => {
795
+ // Collect flags from parent command context
796
+ const parentOpts = profileCommand.parent?.parent?.opts() || {};
797
+ const profiles = config.get('syncProfiles') || {};
798
+ const profile = {};
799
+ for (const key of ['exclude', 'include', 'delete', 'checksum', 'sizeOnly', 'update',
800
+ 'ignoreExisting', 'backup', 'compress', 'bandwidthLimit', 'archive']) {
801
+ if (parentOpts[key] !== undefined) profile[key] = parentOpts[key];
802
+ }
803
+ profiles[name] = profile;
804
+ config.set('syncProfiles', profiles);
805
+ console.log(chalk.green(`Profile '${name}' saved.`));
806
+ if (Object.keys(profile).length === 0) {
807
+ console.log(chalk.gray(' (empty profile — use with sync flags to save them)'));
808
+ } else {
809
+ for (const [k, v] of Object.entries(profile)) {
810
+ console.log(chalk.gray(` ${k}: ${JSON.stringify(v)}`));
811
+ }
812
+ }
813
+ });
814
+
815
+ profileCmd
816
+ .command('list')
817
+ .description('list saved profiles')
818
+ .action(() => {
819
+ const profiles = config.get('syncProfiles') || {};
820
+ const names = Object.keys(profiles);
821
+ if (names.length === 0) {
822
+ console.log(chalk.gray('No saved profiles. Use `pal sync profile save <name>` to create one.'));
823
+ return;
824
+ }
825
+ console.log('');
826
+ console.log(chalk.cyan.bold('Sync Profiles'));
827
+ for (const name of names) {
828
+ const p = profiles[name];
829
+ const flags = Object.entries(p).map(([k, v]) => `${k}=${JSON.stringify(v)}`).join(', ');
830
+ console.log(` ${chalk.white(name)}: ${chalk.gray(flags || '(empty)')}`);
831
+ }
832
+ console.log('');
833
+ });
834
+
835
+ profileCmd
836
+ .command('use <name>')
837
+ .description('show flags for a profile (use with eval or copy)')
838
+ .action((name) => {
839
+ const profiles = config.get('syncProfiles') || {};
840
+ const profile = profiles[name];
841
+ if (!profile) {
842
+ console.log(chalk.red(`Profile '${name}' not found.`));
843
+ process.exitCode = 1;
844
+ return;
845
+ }
846
+ const flags = [];
847
+ if (profile.exclude) {
848
+ const arr = Array.isArray(profile.exclude) ? profile.exclude : [profile.exclude];
849
+ arr.forEach(p => flags.push(`--exclude "${p}"`));
850
+ }
851
+ if (profile.include) {
852
+ const arr = Array.isArray(profile.include) ? profile.include : [profile.include];
853
+ arr.forEach(p => flags.push(`--include "${p}"`));
854
+ }
855
+ if (profile.delete) flags.push('--delete');
856
+ if (profile.checksum) flags.push('--checksum');
857
+ if (profile.sizeOnly) flags.push('--size-only');
858
+ if (profile.update) flags.push('--update');
859
+ if (profile.ignoreExisting) flags.push('--ignore-existing');
860
+ if (profile.backup) flags.push('--backup');
861
+ if (profile.compress) flags.push('--compress');
862
+ if (profile.bandwidthLimit) flags.push(`--bandwidth-limit ${profile.bandwidthLimit}`);
863
+ if (profile.archive) flags.push('--archive');
864
+
865
+ console.log(flags.join(' ') || '(no flags)');
866
+ });
867
+
868
+ profileCmd
869
+ .command('delete <name>')
870
+ .description('delete a saved profile')
871
+ .action((name) => {
872
+ const profiles = config.get('syncProfiles') || {};
873
+ if (!profiles[name]) {
874
+ console.log(chalk.red(`Profile '${name}' not found.`));
875
+ process.exitCode = 1;
876
+ return;
877
+ }
878
+ delete profiles[name];
879
+ config.set('syncProfiles', profiles);
880
+ console.log(chalk.green(`Profile '${name}' deleted.`));
881
+ });
882
+
883
+ // --- pal sync schedule <path> <pal> --every <interval> ---
884
+ cmd
885
+ .command('schedule <path> <pal>')
886
+ .description('background sync at intervals')
887
+ .requiredOption('--every <interval>', 'sync interval (e.g., 5m, 1h, 30s)')
888
+ .action(async (syncPath, pal, schedOpts) => {
889
+ let absolutePath, palObj, keyPair;
890
+ try {
891
+ absolutePath = validateDir(syncPath);
892
+ palObj = findPal(pal);
893
+ getIdentity();
894
+ keyPair = getKeyPair();
895
+ } catch (err) {
896
+ console.log(chalk.red(err.message));
897
+ process.exitCode = 1;
898
+ return;
899
+ }
900
+
901
+ const intervalMs = parseInterval(schedOpts.every);
902
+ if (!intervalMs || intervalMs < 5000) {
903
+ console.log(chalk.red('Invalid interval. Minimum: 5s. Examples: 30s, 5m, 1h'));
904
+ process.exitCode = 1;
905
+ return;
906
+ }
907
+
908
+ const palHandle = palObj.handle || palObj.name;
909
+ const palId = palObj.id || palObj.publicKey;
910
+ const pair = addSyncPair(absolutePath, palId, `sync:${palHandle}`);
911
+
912
+ registerSyncHandlers();
913
+
914
+ console.log(chalk.blue(`Scheduled sync: ${absolutePath} -> ${palObj.name} every ${schedOpts.every}`));
915
+ console.log(chalk.gray('Press Ctrl+C to stop.'));
916
+
917
+ const doSync = async () => {
918
+ try {
919
+ const transport = await selectSyncTransport(palId, { peerHandle: palHandle });
920
+ const lastManifest = getSyncManifest(pair.id);
921
+ const session = new SyncSession({
922
+ localPath: absolutePath,
923
+ peerPK: palId,
924
+ transport,
925
+ keyPair,
926
+ syncPairId: pair.id,
927
+ });
928
+ await session.start(lastManifest);
929
+ const manifest = session.localManifest;
930
+ saveSyncManifest(pair.id, manifest);
931
+ updateSyncPair(pair.id, {
932
+ lastSync: new Date().toISOString(),
933
+ status: 'synced',
934
+ lastDirection: 'push',
935
+ });
936
+ addSyncHistoryEntry(pair.id, {
937
+ direction: 'push',
938
+ totalFiles: manifest.length,
939
+ auto: true,
940
+ });
941
+ session.finish();
942
+ console.log(chalk.green(` Synced at ${new Date().toLocaleTimeString()} (${manifest.length} files)`));
943
+ } catch (err) {
944
+ console.log(chalk.yellow(` Sync failed: ${err.message}`));
945
+ }
946
+ };
947
+
948
+ doSync();
949
+ const timer = setInterval(doSync, intervalMs);
950
+ process.on('SIGINT', () => { clearInterval(timer); process.exit(0); });
951
+ });
952
+
953
+ cmd.addHelpText('after', `
954
+ ${chalk.cyan('Examples:')}
955
+ $ pal sync start ./project alice Bi-directional sync with alice
956
+ $ pal sync push ./project alice Push local changes to alice
957
+ $ pal sync pull ./project alice Pull alice's changes locally
958
+ $ pal sync watch ./project alice Watch and auto-sync on changes
959
+ $ pal sync status ./project Show what changed since last sync
960
+ $ pal sync status Show all sync pairs
961
+
962
+ ${chalk.cyan('rsync-like flags:')}
963
+ $ pal sync push ./project alice --dry-run Preview without applying
964
+ $ pal sync push ./project alice --exclude "*.log" Skip log files
965
+ $ pal sync push ./project alice --delete Remove files not in source
966
+ $ pal sync push ./project alice --compress Compress transfer
967
+ $ pal sync push ./project alice --progress Show progress
968
+ $ pal sync push ./project alice --archive --recursive --checksum --delete
969
+ $ pal sync push ./project alice --backup Create .bak before overwriting
970
+ $ pal sync push ./project alice --bandwidth-limit 500 Throttle to 500 KB/s
971
+
972
+ ${chalk.cyan('How it works:')}
973
+ 1. Sync uses PAL/1.0 protocol over LAN (TCP:7474) or WebRTC (internet).
974
+ 2. No discovery server required for LAN sync.
975
+ 3. Manifests are signed and encrypted (Ed25519 + XChaCha20-Poly1305).
976
+ 4. Conflicts create ${chalk.magenta('.sync-conflict')} copies — no data loss.
977
+ 5. ${chalk.white('watch')} monitors your directory and auto-syncs on changes.
978
+ `);
979
+ }