pal-explorer-cli 0.4.7 → 0.4.8

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.
@@ -1,7 +1,6 @@
1
1
  import chalk from 'chalk';
2
2
  import path from 'path';
3
3
  import fs from 'fs';
4
- import os from 'os';
5
4
  import config from '../utils/config.js';
6
5
  import logger from '../utils/logger.js';
7
6
  import { buildManifest as buildDiffManifest, diffManifests as diffFileManifests, diffSummary } from '../core/fileDiff.js';
@@ -13,6 +12,7 @@ import {
13
12
  detectConflicts,
14
13
  applyDiff,
15
14
  formatSize,
15
+ SyncSession,
16
16
  } from '../core/syncEngine.js';
17
17
  import {
18
18
  getSyncPairs,
@@ -26,7 +26,9 @@ import {
26
26
  addSyncHistoryEntry,
27
27
  getSyncHistory,
28
28
  } from '../core/syncState.js';
29
- import { getPrimaryServer } from '../core/discoveryClient.js';
29
+ import { selectSyncTransport } from '../core/syncTransport.js';
30
+ import { registerSyncHandlers } from '../core/syncProtocolHandlers.js';
31
+ import { parseSyncOptions, addSyncFlagsToCommand } from '../core/syncOptions.js';
30
32
 
31
33
  function getIdentity() {
32
34
  const identity = config.get('identity');
@@ -36,6 +38,17 @@ function getIdentity() {
36
38
  return identity;
37
39
  }
38
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
+
39
52
  function findPal(palName) {
40
53
  const friends = getFriends();
41
54
  const pal = friends.find(f => f.name === palName || f.id === palName || f.handle === palName);
@@ -56,68 +69,156 @@ function validateDir(dirPath) {
56
69
  return abs;
57
70
  }
58
71
 
59
- async function sendMessage(toHandle, body) {
60
- const identity = getIdentity();
61
- const serverUrl = getPrimaryServer();
62
- const res = await fetch(`${serverUrl}/messages`, {
63
- method: 'POST',
64
- headers: { 'Content-Type': 'application/json' },
65
- body: JSON.stringify({
66
- toHandle,
67
- fromHandle: identity.handle,
68
- payload: body,
69
- }),
70
- signal: AbortSignal.timeout(15000),
71
- });
72
- if (!res.ok) {
73
- const err = await res.json().catch(() => ({}));
74
- throw new Error(`Discovery server error: ${res.status} ${err.error || res.statusText}`);
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`));
75
91
  }
76
92
  }
77
93
 
78
- async function getMessages() {
79
- const identity = getIdentity();
80
- const serverUrl = getPrimaryServer();
81
- const timestamp = String(Date.now());
82
- const message = `${identity.handle}:${timestamp}`;
83
- let headers = {};
84
- try {
85
- const { signMessage } = await import('../crypto/chatEncryption.js');
86
- const pk = identity.publicKey;
87
- const sk = config.get('identity')?.privateKey || (await import('../core/identity.js')).then(m => m.getIdentity()).then(i => i?.privateKey);
88
- const privateKey = typeof sk === 'string' ? sk : await sk;
89
- if (privateKey) {
90
- const sig = signMessage(message, privateKey);
91
- headers = { 'x-public-key': pk, 'x-timestamp': timestamp, 'x-signature': sig };
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}`));
92
102
  }
93
- } catch {}
94
- const res = await fetch(`${serverUrl}/messages/${encodeURIComponent(identity.handle)}`, { headers, signal: AbortSignal.timeout(10000) });
95
- if (!res.ok) {
96
- throw new Error(`Discovery server error: ${res.status} ${res.statusText}`);
97
103
  }
98
- return res.json();
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
+ }
99
195
  }
100
196
 
101
197
  // --- pe sync push <path> <pal> ---
102
198
 
103
- async function pushSync(dirPath, palName) {
104
- let absolutePath, pal;
199
+ async function pushSync(dirPath, palName, opts) {
200
+ let absolutePath, pal, identity, keyPair;
105
201
  try {
106
202
  absolutePath = validateDir(dirPath);
107
203
  pal = findPal(palName);
204
+ identity = getIdentity();
205
+ keyPair = getKeyPair();
108
206
  } catch (err) {
109
207
  console.log(chalk.red(err.message));
110
208
  process.exitCode = 1;
111
209
  return;
112
210
  }
113
211
 
212
+ const options = parseSyncOptions(opts);
114
213
  const palHandle = pal.handle || pal.name;
115
- const palId = pal.id;
214
+ const palId = pal.id || pal.publicKey;
215
+
216
+ registerSyncHandlers();
116
217
 
117
218
  console.log(chalk.blue(`Scanning ${absolutePath}...`));
118
219
  const existingPair = findSyncPair(absolutePath, palId);
119
220
  const cachedManifest = existingPair ? getSyncManifest(existingPair.id) : null;
120
- const manifest = await buildManifest(absolutePath, cachedManifest);
221
+ const manifest = await buildManifest(absolutePath, cachedManifest, options);
121
222
  console.log(chalk.gray(` ${manifest.length} file(s) indexed`));
122
223
 
123
224
  const pair = addSyncPair(absolutePath, palId, `sync:${palHandle}`);
@@ -142,60 +243,33 @@ async function pushSync(dirPath, palName) {
142
243
  console.log(chalk.gray(' First sync for this pair.'));
143
244
  }
144
245
 
145
- // Look up or create a share for this directory so we have a magnet link
146
- let allShares = config.get('shares') || [];
147
- let dirShare = allShares.find(s => s.path === absolutePath && s.magnet);
148
- let magnet = dirShare?.magnet || null;
149
-
150
- // If no share exists, auto-create a hidden sync share
151
- if (!dirShare) {
152
- const existing = allShares.find(s => s.path === absolutePath);
153
- if (!existing) {
154
- const { addShare } = await import('../core/shares.js');
155
- try {
156
- const autoShare = addShare(absolutePath, 'folder', 'private', { read: true }, [{ name: pal.name, id: palId, handle: palHandle }], { recursive: true });
157
- autoShare.syncHidden = true;
158
- allShares = config.get('shares') || [];
159
- const idx = allShares.findIndex(s => s.id === autoShare.id);
160
- if (idx !== -1) { allShares[idx].syncHidden = true; config.set('shares', allShares); }
161
- console.log(chalk.gray(` Auto-created sync share for ${path.basename(absolutePath)}`));
162
- } catch {}
163
- }
246
+ if (options.dryRun) {
247
+ console.log(chalk.yellow('Dry run no changes sent.'));
248
+ return;
164
249
  }
165
250
 
166
- // Seed the directory to get a magnet link if we don't have one
167
- if (!magnet) {
168
- console.log(chalk.blue('Seeding directory for P2P sync...'));
169
- try {
170
- const WebTorrent = (await import('webtorrent')).default;
171
- const client = new WebTorrent();
172
- magnet = await new Promise((resolve, reject) => {
173
- const timeout = setTimeout(() => { client.destroy(); reject(new Error('Seed timeout')); }, 30000);
174
- client.seed(absolutePath, { name: path.basename(absolutePath) }, (torrent) => {
175
- clearTimeout(timeout);
176
- // Persist magnet to share config
177
- const shares = config.get('shares') || [];
178
- const entry = shares.find(s => s.path === absolutePath);
179
- if (entry) { entry.magnet = torrent.magnetURI; config.set('shares', shares); }
180
- console.log(chalk.green(` Seeding: ${torrent.magnetURI.slice(0, 60)}...`));
181
- // Keep seeding in background (don't destroy client)
182
- resolve(torrent.magnetURI);
183
- });
184
- });
185
- } catch (err) {
186
- console.log(chalk.yellow(` Could not seed: ${err.message}. Sending manifest without magnet.`));
187
- }
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;
188
260
  }
189
261
 
190
- console.log(chalk.blue(`Sending manifest to ${pal.name}...`));
262
+ const session = new SyncSession({
263
+ localPath: absolutePath,
264
+ peerPK: palId,
265
+ transport,
266
+ keyPair,
267
+ syncPairId: pair.id,
268
+ options,
269
+ });
270
+
191
271
  try {
192
- await sendMessage(palHandle, {
193
- type: 'sync-manifest',
194
- syncPairId: pair.id,
195
- manifest,
196
- magnet,
197
- timestamp: new Date().toISOString(),
198
- });
272
+ await session.start(cachedManifest);
199
273
 
200
274
  saveSyncManifest(pair.id, manifest);
201
275
  updateSyncPair(pair.id, {
@@ -212,220 +286,196 @@ async function pushSync(dirPath, palName) {
212
286
  totalFiles: manifest.length,
213
287
  });
214
288
 
289
+ session.finish();
215
290
  console.log(chalk.green(`Push complete. Manifest sent to ${pal.name}.`));
216
291
  console.log(chalk.gray(`Sync pair: ${pair.id}`));
217
- console.log(chalk.gray('Pal can run `pe sync pull` to fetch changes.'));
218
292
  } catch (err) {
293
+ session.abort(err.message);
219
294
  updateSyncPair(pair.id, { status: 'error' });
220
295
  logger.error(`Push sync failed: ${err.message}`);
221
- console.log(chalk.yellow(`Could not reach discovery server: ${err.message}`));
222
- console.log(chalk.gray('Manifest built locally. Will sync when server is available.'));
296
+ console.log(chalk.red(`Push failed: ${err.message}`));
223
297
  process.exitCode = 1;
224
298
  }
225
299
  }
226
300
 
227
301
  // --- pe sync pull <path> <pal> ---
228
302
 
229
- async function pullSync(dirPath, palName) {
230
- let absolutePath, pal;
303
+ async function pullSync(dirPath, palName, opts) {
304
+ let absolutePath, pal, identity, keyPair;
231
305
  try {
232
306
  absolutePath = validateDir(dirPath);
233
307
  pal = findPal(palName);
308
+ identity = getIdentity();
309
+ keyPair = getKeyPair();
234
310
  } catch (err) {
235
311
  console.log(chalk.red(err.message));
236
312
  process.exitCode = 1;
237
313
  return;
238
314
  }
239
315
 
316
+ const options = parseSyncOptions(opts);
240
317
  const palHandle = pal.handle || pal.name;
241
- const palId = pal.id;
318
+ const palId = pal.id || pal.publicKey;
319
+
320
+ registerSyncHandlers();
242
321
 
243
- console.log(chalk.blue(`Checking messages from ${pal.name}...`));
322
+ console.log(chalk.blue(`Pulling from ${pal.name} to ${absolutePath}...`));
244
323
 
245
- let messages;
324
+ let transport;
246
325
  try {
247
- messages = await getMessages();
326
+ transport = await selectSyncTransport(palId, { peerHandle: palHandle });
327
+ console.log(chalk.gray(` Transport: ${transport.type}`));
248
328
  } catch (err) {
249
- logger.error(`Pull sync failed: ${err.message}`);
250
- console.log(chalk.red(`Could not reach discovery server: ${err.message}`));
329
+ console.log(chalk.red(`Cannot reach ${pal.name}: ${err.message}`));
251
330
  process.exitCode = 1;
252
331
  return;
253
332
  }
254
333
 
255
- const syncMessages = (Array.isArray(messages) ? messages : [])
256
- .map(m => {
257
- try {
258
- let data = m.payload || m.body;
259
- // Handle double-encoded JSON (string of JSON string)
260
- if (typeof data === 'string') {
261
- data = JSON.parse(data);
262
- if (typeof data === 'string') data = JSON.parse(data);
263
- }
264
- return { ...m, parsed: data };
265
- } catch { return null; }
266
- })
267
- .filter(m => m && m.parsed?.type === 'sync-manifest' && (m.fromHandle || m.from) === palHandle)
268
- .sort((a, b) => new Date(b.parsed.timestamp) - new Date(a.parsed.timestamp));
269
-
270
- if (syncMessages.length === 0) {
271
- console.log(chalk.yellow(`No sync manifests from ${pal.name}. Ask them to run \`pe sync push\`.`));
272
- return;
273
- }
274
-
275
- const latest = syncMessages[0].parsed;
276
- const remoteManifest = latest.manifest;
277
- console.log(chalk.gray(` Found manifest from ${latest.timestamp} (${remoteManifest.length} files)`));
334
+ const existingPair = findSyncPair(absolutePath, palId);
335
+ const cachedManifest = existingPair ? getSyncManifest(existingPair.id) : null;
336
+ const pair = addSyncPair(absolutePath, palId, `sync:${palHandle}`);
278
337
 
279
- console.log(chalk.blue(`Scanning local ${absolutePath}...`));
280
- const existingPullPair = findSyncPair(absolutePath, palId);
281
- const cachedPullManifest = existingPullPair ? getSyncManifest(existingPullPair.id) : null;
282
- const localManifest = await buildManifest(absolutePath, cachedPullManifest);
283
- console.log(chalk.gray(` ${localManifest.length} local file(s)`));
338
+ const session = new SyncSession({
339
+ localPath: absolutePath,
340
+ peerPK: palId,
341
+ transport,
342
+ keyPair,
343
+ syncPairId: pair.id,
344
+ options,
345
+ });
284
346
 
285
- const localMap = manifestToMap(localManifest);
286
- const remoteMap = manifestToMap(remoteManifest);
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)'));
287
353
 
288
- const pair = addSyncPair(absolutePath, palId, `sync:${palHandle}`);
289
- const baseMap = getLastSyncedManifestMap(pair.id);
354
+ saveSyncManifest(pair.id, localManifest);
355
+ updateSyncPair(pair.id, {
356
+ lastSync: new Date().toISOString(),
357
+ status: 'synced',
358
+ lastDirection: 'pull',
359
+ });
290
360
 
291
- const { conflicts } = detectConflicts(localMap, remoteMap, baseMap);
292
- const diff = diffManifests(localMap, remoteMap);
361
+ addSyncHistoryEntry(pair.id, {
362
+ direction: 'pull',
363
+ totalFiles: localManifest.length,
364
+ });
293
365
 
294
- if (conflicts.length > 0) {
295
- diff.conflicts = conflicts;
296
- diff.modified = diff.modified.filter(
297
- m => !conflicts.some(c => c.relativePath === m.relativePath)
298
- );
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;
299
374
  }
375
+ }
376
+
377
+ // --- pe sync watch <path> <pal> ---
300
378
 
301
- const totalChanges = diff.added.length + diff.modified.length + diff.deleted.length + (diff.conflicts?.length || 0);
302
- if (totalChanges === 0) {
303
- console.log(chalk.green('Already in sync. No changes to pull.'));
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;
304
389
  return;
305
390
  }
306
391
 
307
- console.log(chalk.white(` Changes to apply:`));
308
- if (diff.added.length) console.log(chalk.green(` + ${diff.added.length} new file(s)`));
309
- if (diff.modified.length) console.log(chalk.yellow(` ~ ${diff.modified.length} modified file(s)`));
310
- if (diff.deleted.length) console.log(chalk.red(` - ${diff.deleted.length} deleted file(s)`));
311
- if (conflicts.length) console.log(chalk.magenta(` ! ${conflicts.length} conflict(s) (both sides changed)`));
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}`);
312
399
 
313
- const magnet = latest.magnet || null;
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;
314
406
 
315
- if (magnet) {
316
- // P2P sync: download the shared directory via WebTorrent, then apply diff
317
- console.log(chalk.blue('Downloading via P2P...'));
407
+ const triggerSync = async () => {
408
+ if (syncing) return;
409
+ syncing = true;
318
410
 
319
411
  try {
320
- const WebTorrent = (await import('webtorrent')).default;
321
- const tmpDir = path.join(os.tmpdir(), `pe-sync-${pair.id}-${Date.now()}`);
322
- fs.mkdirSync(tmpDir, { recursive: true });
323
-
324
- const client = new WebTorrent();
325
- await new Promise((resolve, reject) => {
326
- const timeout = setTimeout(() => {
327
- client.destroy();
328
- reject(new Error('Download timed out after 5 minutes'));
329
- }, 5 * 60 * 1000);
330
-
331
- client.add(magnet, { path: tmpDir }, (torrent) => {
332
- console.log(chalk.gray(` Downloading ${torrent.files.length} file(s)...`));
333
-
334
- torrent.on('done', async () => {
335
- clearTimeout(timeout);
336
- console.log(chalk.green(' Download complete. Applying changes...'));
337
-
338
- // The torrent downloads into tmpDir/<torrent.name>/
339
- const sourcePath = path.join(tmpDir, torrent.name);
340
- const results = applyDiff(diff, sourcePath, absolutePath);
341
-
342
- if (results.copied.length) {
343
- console.log(chalk.green(` Copied ${results.copied.length} file(s)`));
344
- }
345
- if (results.deleted.length) {
346
- console.log(chalk.red(` Deleted ${results.deleted.length} file(s)`));
347
- }
348
- if (results.conflicted.length) {
349
- console.log(chalk.magenta(` ${results.conflicted.length} conflict(s) saved with .sync-conflict suffix`));
350
- for (const c of results.conflicted) {
351
- console.log(chalk.gray(` ${c.original} -> ${c.conflictFile}`));
352
- }
353
- }
354
- if (results.errors.length) {
355
- console.log(chalk.red(` ${results.errors.length} error(s):`));
356
- for (const e of results.errors) {
357
- console.log(chalk.red(` ${e.file}: ${e.error}`));
358
- }
359
- }
360
-
361
- const newManifest = await buildManifest(absolutePath, getSyncManifest(pair.id));
362
- saveSyncManifest(pair.id, newManifest);
363
- updateSyncPair(pair.id, {
364
- lastSync: new Date().toISOString(),
365
- status: 'synced',
366
- lastDirection: 'pull',
367
- });
368
-
369
- addSyncHistoryEntry(pair.id, {
370
- direction: 'pull',
371
- filesAdded: results.copied.length,
372
- filesModified: 0,
373
- filesDeleted: results.deleted.length,
374
- conflicts: results.conflicted.length,
375
- totalFiles: newManifest.length,
376
- });
377
-
378
- client.destroy();
379
- // Clean up temp dir
380
- fs.rmSync(tmpDir, { recursive: true, force: true });
381
- console.log(chalk.green('Pull complete.'));
382
- resolve();
383
- });
412
+ const lastManifest = getSyncManifest(pair.id);
413
+ const manifest = await buildManifest(absolutePath, lastManifest, options);
384
414
 
385
- torrent.on('error', (err) => {
386
- clearTimeout(timeout);
387
- client.destroy();
388
- reject(err);
389
- });
390
- });
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,
391
438
  });
392
- } catch (err) {
393
- logger.error(`P2P sync download failed: ${err.message}`);
394
- console.log(chalk.yellow(`P2P download failed: ${err.message}`));
395
- console.log(chalk.gray('Saving diff for manual resolution. Use `pe sync status` to review.'));
396
439
 
397
- saveSyncManifest(pair.id, remoteManifest);
440
+ await session.start(lastManifest);
441
+
442
+ saveSyncManifest(pair.id, manifest);
398
443
  updateSyncPair(pair.id, {
399
444
  lastSync: new Date().toISOString(),
400
- status: 'pending-download',
401
- lastDirection: 'pull',
402
- pendingDiff: {
403
- added: diff.added.length,
404
- modified: diff.modified.length,
405
- deleted: diff.deleted.length,
406
- conflicts: conflicts.length,
407
- },
445
+ status: 'synced',
446
+ lastDirection: 'push',
447
+ });
448
+
449
+ addSyncHistoryEntry(pair.id, {
450
+ direction: 'push',
451
+ totalFiles: manifest.length,
452
+ auto: true,
408
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;
409
462
  }
410
- } else {
411
- // No magnet available — pal needs to share the directory first
412
- console.log(chalk.yellow('No magnet link in manifest. Pal must share the directory first.'));
413
- console.log(chalk.gray('Ask them to run `pe share <path>` then `pe sync push` again.'));
414
- console.log(chalk.gray('Diff summary saved. Use `pe sync status` to review.'));
463
+ };
415
464
 
416
- saveSyncManifest(pair.id, remoteManifest);
417
- updateSyncPair(pair.id, {
418
- lastSync: new Date().toISOString(),
419
- status: 'pending-download',
420
- lastDirection: 'pull',
421
- pendingDiff: {
422
- added: diff.added.length,
423
- modified: diff.modified.length,
424
- deleted: diff.deleted.length,
425
- conflicts: conflicts.length,
426
- },
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);
427
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;
428
476
  }
477
+
478
+ triggerSync();
429
479
  }
430
480
 
431
481
  // --- pe sync status <path> ---
@@ -434,7 +484,7 @@ async function syncStatus(dirPath) {
434
484
  if (!dirPath) {
435
485
  const pairs = getSyncPairs();
436
486
  if (pairs.length === 0) {
437
- console.log(chalk.gray('No sync pairs configured. Use `pe sync push <path> <pal>` to start.'));
487
+ console.log(chalk.gray('No sync pairs configured. Use `pe sync <path> <pal>` to start.'));
438
488
  return;
439
489
  }
440
490
 
@@ -472,7 +522,7 @@ async function syncStatus(dirPath) {
472
522
  const pairs = getSyncPairs().filter(p => p.localPath === absolutePath);
473
523
  if (pairs.length === 0) {
474
524
  console.log(chalk.yellow(`No sync pairs for ${absolutePath}.`));
475
- console.log(chalk.gray('Use `pe sync push <path> <pal>` to create one.'));
525
+ console.log(chalk.gray('Use `pe sync <path> <pal>` to create one.'));
476
526
  return;
477
527
  }
478
528
 
@@ -520,115 +570,21 @@ async function syncStatus(dirPath) {
520
570
  if (history.length > 0) {
521
571
  console.log(chalk.gray(` Last ${Math.min(history.length, 3)} syncs:`));
522
572
  for (const h of history.slice(0, 3)) {
523
- const dir = h.direction === 'push' ? chalk.blue('PUSH') : chalk.magenta('PULL');
573
+ const dir = h.direction === 'push' ? chalk.blue('PUSH') :
574
+ h.direction === 'pull' ? chalk.magenta('PULL') :
575
+ chalk.cyan('SYNC');
524
576
  const changes = [];
525
577
  if (h.filesAdded) changes.push(chalk.green(`+${h.filesAdded}`));
526
578
  if (h.filesModified) changes.push(chalk.yellow(`~${h.filesModified}`));
527
579
  if (h.filesDeleted) changes.push(chalk.red(`-${h.filesDeleted}`));
528
580
  if (h.conflicts) changes.push(chalk.magenta(`!${h.conflicts}`));
529
- console.log(chalk.gray(` ${h.timestamp} ${dir} ${changes.join(' ')}`));
581
+ console.log(chalk.gray(` ${h.timestamp} ${dir} ${changes.join(' ')}${h.auto ? chalk.gray(' [auto]') : ''}`));
530
582
  }
531
583
  }
532
584
  console.log('');
533
585
  }
534
586
  }
535
587
 
536
- // --- pe sync watch <path> <pal> ---
537
-
538
- function watchSync(dirPath, palName) {
539
- let absolutePath, pal;
540
- try {
541
- absolutePath = validateDir(dirPath);
542
- pal = findPal(palName);
543
- } catch (err) {
544
- console.log(chalk.red(err.message));
545
- process.exitCode = 1;
546
- return;
547
- }
548
-
549
- const palHandle = pal.handle || pal.name;
550
- const palId = pal.id;
551
- const pair = addSyncPair(absolutePath, palId, `sync:${palHandle}`);
552
-
553
- console.log(chalk.blue(`Watching ${absolutePath} for changes...`));
554
- console.log(chalk.gray(`Syncing to ${pal.name}. Press Ctrl+C to stop.`));
555
-
556
- let debounceTimer = null;
557
- const debounceMs = 2000;
558
- let syncing = false;
559
-
560
- const triggerSync = async () => {
561
- if (syncing) return;
562
- syncing = true;
563
-
564
- try {
565
- const lastManifest = getSyncManifest(pair.id);
566
- const manifest = await buildManifest(absolutePath, lastManifest);
567
-
568
- if (lastManifest) {
569
- const lastMap = manifestToMap(lastManifest);
570
- const currentMap = manifestToMap(manifest);
571
- const diff = diffManifests(lastMap, currentMap);
572
- const total = diff.added.length + diff.modified.length + diff.deleted.length;
573
- if (total === 0) {
574
- syncing = false;
575
- return;
576
- }
577
- console.log(chalk.blue(`Change detected: ${chalk.green(`+${diff.added.length}`)} ${chalk.yellow(`~${diff.modified.length}`)} ${chalk.red(`-${diff.deleted.length}`)}`));
578
- } else {
579
- console.log(chalk.blue(`Initial sync: ${manifest.length} file(s)`));
580
- }
581
-
582
- // Include magnet if available from existing share
583
- const watchShares = config.get('shares') || [];
584
- const watchShare = watchShares.find(s => s.path === absolutePath && s.magnet);
585
-
586
- await sendMessage(palHandle, {
587
- type: 'sync-manifest',
588
- syncPairId: pair.id,
589
- manifest,
590
- magnet: watchShare?.magnet || null,
591
- timestamp: new Date().toISOString(),
592
- });
593
-
594
- saveSyncManifest(pair.id, manifest);
595
- updateSyncPair(pair.id, {
596
- lastSync: new Date().toISOString(),
597
- status: 'synced',
598
- lastDirection: 'push',
599
- });
600
-
601
- addSyncHistoryEntry(pair.id, {
602
- direction: 'push',
603
- totalFiles: manifest.length,
604
- auto: true,
605
- });
606
-
607
- console.log(chalk.green(`Synced to ${pal.name} at ${new Date().toLocaleTimeString()}`));
608
- } catch (err) {
609
- logger.error(`Watch sync failed: ${err.message}`);
610
- console.log(chalk.yellow(`Sync failed: ${err.message}`));
611
- } finally {
612
- syncing = false;
613
- }
614
- };
615
-
616
- try {
617
- const watcher = fs.watch(absolutePath, { recursive: true }, (_event, filename) => {
618
- if (filename && /node_modules|\.git/.test(filename)) return;
619
- if (debounceTimer) clearTimeout(debounceTimer);
620
- debounceTimer = setTimeout(triggerSync, debounceMs);
621
- });
622
- process.on('SIGINT', () => { watcher.close(); process.exit(0); });
623
- } catch (err) {
624
- console.log(chalk.red(`Watch failed: ${err.message}`));
625
- process.exitCode = 1;
626
- return;
627
- }
628
-
629
- triggerSync();
630
- }
631
-
632
588
  // --- pe sync remove <id> ---
633
589
 
634
590
  function removePair(id) {
@@ -643,6 +599,14 @@ function removePair(id) {
643
599
  console.log(chalk.green(`Removed sync pair ${id}.`));
644
600
  }
645
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
+
646
610
  function formatSyncBytes(b) {
647
611
  if (!b) return '0 B';
648
612
  if (b < 1024) return b + ' B';
@@ -656,31 +620,55 @@ function formatSyncBytes(b) {
656
620
  export default function syncCommand(program) {
657
621
  const cmd = program
658
622
  .command('sync')
659
- .description('sync directories with pals');
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);
660
638
 
661
- cmd
639
+ const pushCmd = cmd
662
640
  .command('push <path> <pal>')
663
- .description('push local changes to a pal')
664
- .action(async (syncPath, pal) => {
665
- if (!syncPath || !syncPath.trim()) {
641
+ .description('push local changes to a pal (one-way)')
642
+ .action(async (syncPath, pal, cliOpts) => {
643
+ if (!syncPath?.trim()) {
666
644
  console.log(chalk.red('Error: path cannot be empty.'));
667
645
  process.exitCode = 1;
668
646
  return;
669
647
  }
670
- await pushSync(syncPath, pal);
648
+ await pushSync(syncPath, pal, cliOpts);
671
649
  });
650
+ addSyncFlagsToCommand(pushCmd);
672
651
 
673
- cmd
652
+ const pullCmd = cmd
674
653
  .command('pull <path> <pal>')
675
- .description('pull changes from a pal')
676
- .action(async (syncPath, pal) => {
677
- if (!syncPath || !syncPath.trim()) {
654
+ .description('pull changes from a pal (one-way)')
655
+ .action(async (syncPath, pal, cliOpts) => {
656
+ if (!syncPath?.trim()) {
678
657
  console.log(chalk.red('Error: path cannot be empty.'));
679
658
  process.exitCode = 1;
680
659
  return;
681
660
  }
682
- await pullSync(syncPath, pal);
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);
683
670
  });
671
+ addSyncFlagsToCommand(watchCmd);
684
672
 
685
673
  cmd
686
674
  .command('status [path]')
@@ -689,13 +677,6 @@ export default function syncCommand(program) {
689
677
  syncStatus(syncPath);
690
678
  });
691
679
 
692
- cmd
693
- .command('watch <path> <pal>')
694
- .description('watch directory and auto-push on changes')
695
- .action((syncPath, pal) => {
696
- watchSync(syncPath, pal);
697
- });
698
-
699
680
  cmd
700
681
  .command('list')
701
682
  .description('list all sync pairs')
@@ -743,7 +724,9 @@ export default function syncCommand(program) {
743
724
  console.log(chalk.gray(' No history yet.'));
744
725
  } else {
745
726
  for (const h of history) {
746
- const dir = h.direction === 'push' ? chalk.blue('PUSH') : chalk.magenta('PULL');
727
+ const dir = h.direction === 'push' ? chalk.blue('PUSH') :
728
+ h.direction === 'pull' ? chalk.magenta('PULL') :
729
+ chalk.cyan('SYNC');
747
730
  const changes = [];
748
731
  if (h.filesAdded) changes.push(chalk.green(`+${h.filesAdded}`));
749
732
  if (h.filesModified) changes.push(chalk.yellow(`~${h.filesModified}`));
@@ -761,7 +744,7 @@ export default function syncCommand(program) {
761
744
  .command('diff <localPath> <remotePath>')
762
745
  .description('compare local and remote folders, show what needs to transfer')
763
746
  .option('--json', 'Output as JSON')
764
- .action(async (localPath, remotePath, opts) => {
747
+ .action(async (localPath, remotePath, cmdOpts) => {
765
748
  try {
766
749
  console.log(chalk.blue('Building local manifest...'));
767
750
  const localManifest = buildDiffManifest(localPath);
@@ -771,7 +754,7 @@ export default function syncCommand(program) {
771
754
  const diff = diffFileManifests(localManifest, remoteManifest);
772
755
  const summary = diffSummary(diff);
773
756
 
774
- if (opts.json) {
757
+ if (cmdOpts.json) {
775
758
  console.log(JSON.stringify({ diff, summary }, null, 2));
776
759
  return;
777
760
  }
@@ -802,26 +785,195 @@ export default function syncCommand(program) {
802
785
  }
803
786
  });
804
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
+
805
953
  cmd.addHelpText('after', `
806
954
  ${chalk.cyan('Examples:')}
807
- $ pe sync push ./project alice Push local changes to alice
808
- $ pe sync pull ./project alice Pull alice's changes locally
809
- $ pe sync status ./project Show what changed since last sync
810
- $ pe sync status Show all sync pairs
811
- $ pe sync watch ./project alice Watch and auto-push on changes
812
- $ pe sync list List all sync pairs
813
- $ pe sync remove <id> Remove a sync pair
814
- $ pe sync diff ./local ./remote Compare two folders, show transfer needed
815
- $ pe sync history View sync history for all pairs
816
- $ pe sync history <id> View sync history for a specific pair
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
817
971
 
818
972
  ${chalk.cyan('How it works:')}
819
- 1. ${chalk.white('push')} scans your directory, computes file hashes, and sends
820
- the manifest to your pal via the discovery server.
821
- 2. ${chalk.white('pull')} checks for manifests from your pal, compares with
822
- your local files, and applies changes.
823
- 3. If both sides modified the same file, a ${chalk.magenta('.sync-conflict')} copy
824
- is created so you don't lose either version.
825
- 4. ${chalk.white('watch')} monitors your directory and auto-pushes on changes.
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.
826
978
  `);
827
979
  }