pal-explorer-cli 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (156) hide show
  1. package/LICENSE.md +18 -0
  2. package/README.md +314 -0
  3. package/bin/pal.js +230 -0
  4. package/extensions/@palexplorer/analytics/README.md +45 -0
  5. package/extensions/@palexplorer/analytics/docs/MONETIZATION.md +14 -0
  6. package/extensions/@palexplorer/analytics/docs/PLAN.md +23 -0
  7. package/extensions/@palexplorer/analytics/docs/PRIVACY.md +38 -0
  8. package/extensions/@palexplorer/analytics/extension.json +27 -0
  9. package/extensions/@palexplorer/analytics/index.js +186 -0
  10. package/extensions/@palexplorer/analytics/test/analytics.test.js +82 -0
  11. package/extensions/@palexplorer/audit/extension.json +17 -0
  12. package/extensions/@palexplorer/audit/index.js +2 -0
  13. package/extensions/@palexplorer/auth-email/extension.json +17 -0
  14. package/extensions/@palexplorer/auth-email/index.js +102 -0
  15. package/extensions/@palexplorer/auth-oauth/extension.json +16 -0
  16. package/extensions/@palexplorer/auth-oauth/index.js +199 -0
  17. package/extensions/@palexplorer/chat/extension.json +17 -0
  18. package/extensions/@palexplorer/chat/index.js +2 -0
  19. package/extensions/@palexplorer/discovery/extension.json +16 -0
  20. package/extensions/@palexplorer/discovery/index.js +111 -0
  21. package/extensions/@palexplorer/email-notifications/extension.json +23 -0
  22. package/extensions/@palexplorer/email-notifications/index.js +242 -0
  23. package/extensions/@palexplorer/explorer-integration/extension.json +13 -0
  24. package/extensions/@palexplorer/explorer-integration/index.js +122 -0
  25. package/extensions/@palexplorer/groups/extension.json +17 -0
  26. package/extensions/@palexplorer/groups/index.js +2 -0
  27. package/extensions/@palexplorer/networks/extension.json +17 -0
  28. package/extensions/@palexplorer/networks/index.js +2 -0
  29. package/extensions/@palexplorer/share-links/extension.json +17 -0
  30. package/extensions/@palexplorer/share-links/index.js +2 -0
  31. package/extensions/@palexplorer/sync/extension.json +17 -0
  32. package/extensions/@palexplorer/sync/index.js +2 -0
  33. package/extensions/@palexplorer/user-mgmt/extension.json +17 -0
  34. package/extensions/@palexplorer/user-mgmt/index.js +2 -0
  35. package/extensions/@palexplorer/vfs/extension.json +17 -0
  36. package/extensions/@palexplorer/vfs/index.js +167 -0
  37. package/lib/capabilities.js +263 -0
  38. package/lib/commands/analytics.js +175 -0
  39. package/lib/commands/api-keys.js +131 -0
  40. package/lib/commands/audit.js +235 -0
  41. package/lib/commands/auth.js +137 -0
  42. package/lib/commands/backup.js +76 -0
  43. package/lib/commands/billing.js +148 -0
  44. package/lib/commands/chat.js +217 -0
  45. package/lib/commands/cloud-backup.js +231 -0
  46. package/lib/commands/comment.js +99 -0
  47. package/lib/commands/completion.js +203 -0
  48. package/lib/commands/compliance.js +218 -0
  49. package/lib/commands/config.js +136 -0
  50. package/lib/commands/connect.js +44 -0
  51. package/lib/commands/dept.js +294 -0
  52. package/lib/commands/device.js +146 -0
  53. package/lib/commands/download.js +226 -0
  54. package/lib/commands/explorer.js +178 -0
  55. package/lib/commands/extension.js +970 -0
  56. package/lib/commands/favorite.js +90 -0
  57. package/lib/commands/federation.js +270 -0
  58. package/lib/commands/file.js +533 -0
  59. package/lib/commands/group.js +271 -0
  60. package/lib/commands/gui-share.js +29 -0
  61. package/lib/commands/init.js +61 -0
  62. package/lib/commands/invite.js +59 -0
  63. package/lib/commands/list.js +59 -0
  64. package/lib/commands/log.js +116 -0
  65. package/lib/commands/nearby.js +108 -0
  66. package/lib/commands/network.js +251 -0
  67. package/lib/commands/notify.js +198 -0
  68. package/lib/commands/org.js +273 -0
  69. package/lib/commands/pal.js +180 -0
  70. package/lib/commands/permissions.js +216 -0
  71. package/lib/commands/pin.js +97 -0
  72. package/lib/commands/protocol.js +357 -0
  73. package/lib/commands/rbac.js +147 -0
  74. package/lib/commands/recover.js +36 -0
  75. package/lib/commands/register.js +171 -0
  76. package/lib/commands/relay.js +131 -0
  77. package/lib/commands/remote.js +368 -0
  78. package/lib/commands/revoke.js +50 -0
  79. package/lib/commands/scanner.js +280 -0
  80. package/lib/commands/schedule.js +344 -0
  81. package/lib/commands/scim.js +203 -0
  82. package/lib/commands/search.js +181 -0
  83. package/lib/commands/serve.js +438 -0
  84. package/lib/commands/server.js +350 -0
  85. package/lib/commands/share-link.js +199 -0
  86. package/lib/commands/share.js +323 -0
  87. package/lib/commands/sso.js +200 -0
  88. package/lib/commands/status.js +136 -0
  89. package/lib/commands/stream.js +562 -0
  90. package/lib/commands/su.js +187 -0
  91. package/lib/commands/sync.js +827 -0
  92. package/lib/commands/transfers.js +152 -0
  93. package/lib/commands/uninstall.js +188 -0
  94. package/lib/commands/update.js +204 -0
  95. package/lib/commands/user.js +276 -0
  96. package/lib/commands/vfs.js +84 -0
  97. package/lib/commands/web.js +52 -0
  98. package/lib/commands/webhook.js +180 -0
  99. package/lib/commands/whoami.js +59 -0
  100. package/lib/commands/workspace.js +121 -0
  101. package/lib/core/accessLog.js +54 -0
  102. package/lib/core/analytics.js +99 -0
  103. package/lib/core/backup.js +84 -0
  104. package/lib/core/billing.js +336 -0
  105. package/lib/core/bitfieldStore.js +53 -0
  106. package/lib/core/connectionManager.js +182 -0
  107. package/lib/core/dhtDiscovery.js +148 -0
  108. package/lib/core/discoveryClient.js +408 -0
  109. package/lib/core/extensionAnalyzer.js +357 -0
  110. package/lib/core/extensionSandbox.js +250 -0
  111. package/lib/core/extensionWorkerHost.js +166 -0
  112. package/lib/core/extensions.js +1082 -0
  113. package/lib/core/fileDiff.js +69 -0
  114. package/lib/core/groups.js +119 -0
  115. package/lib/core/identity.js +340 -0
  116. package/lib/core/mdnsService.js +126 -0
  117. package/lib/core/networks.js +81 -0
  118. package/lib/core/permissions.js +109 -0
  119. package/lib/core/pro.js +27 -0
  120. package/lib/core/resolver.js +74 -0
  121. package/lib/core/serverList.js +224 -0
  122. package/lib/core/sharePolicy.js +69 -0
  123. package/lib/core/shares.js +325 -0
  124. package/lib/core/signalingServer.js +441 -0
  125. package/lib/core/streamTransport.js +106 -0
  126. package/lib/core/su.js +55 -0
  127. package/lib/core/syncEngine.js +264 -0
  128. package/lib/core/syncState.js +159 -0
  129. package/lib/core/transfers.js +259 -0
  130. package/lib/core/users.js +225 -0
  131. package/lib/core/vfs.js +216 -0
  132. package/lib/core/webServer.js +702 -0
  133. package/lib/core/webrtcStream.js +396 -0
  134. package/lib/crypto/chatEncryption.js +57 -0
  135. package/lib/crypto/shareEncryption.js +195 -0
  136. package/lib/crypto/sharePassword.js +35 -0
  137. package/lib/crypto/streamEncryption.js +189 -0
  138. package/lib/package.json +1 -0
  139. package/lib/protocol/envelope.js +271 -0
  140. package/lib/protocol/handler.js +191 -0
  141. package/lib/protocol/index.js +27 -0
  142. package/lib/protocol/messages.js +247 -0
  143. package/lib/protocol/negotiation.js +127 -0
  144. package/lib/protocol/policy.js +142 -0
  145. package/lib/protocol/router.js +86 -0
  146. package/lib/protocol/sync.js +122 -0
  147. package/lib/utils/cli.js +15 -0
  148. package/lib/utils/config.js +123 -0
  149. package/lib/utils/configIntegrity.js +87 -0
  150. package/lib/utils/downloadDir.js +9 -0
  151. package/lib/utils/explorer.js +83 -0
  152. package/lib/utils/format.js +12 -0
  153. package/lib/utils/help.js +357 -0
  154. package/lib/utils/logger.js +103 -0
  155. package/lib/utils/torrent.js +203 -0
  156. package/package.json +71 -0
@@ -0,0 +1,827 @@
1
+ import chalk from 'chalk';
2
+ import path from 'path';
3
+ import fs from 'fs';
4
+ import os from 'os';
5
+ import config from '../utils/config.js';
6
+ import logger from '../utils/logger.js';
7
+ import { buildManifest as buildDiffManifest, diffManifests as diffFileManifests, diffSummary } from '../core/fileDiff.js';
8
+ import { getFriends } from '../core/users.js';
9
+ import {
10
+ buildManifest,
11
+ diffManifests,
12
+ manifestToMap,
13
+ detectConflicts,
14
+ applyDiff,
15
+ formatSize,
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 { getPrimaryServer } from '../core/discoveryClient.js';
30
+
31
+ function getIdentity() {
32
+ const identity = config.get('identity');
33
+ if (!identity?.handle) {
34
+ throw new Error('Not registered. Run `pe init` and `pe register` first.');
35
+ }
36
+ return identity;
37
+ }
38
+
39
+ function findPal(palName) {
40
+ const friends = getFriends();
41
+ const pal = friends.find(f => f.name === palName || f.id === palName || f.handle === palName);
42
+ if (!pal) {
43
+ throw new Error(`Pal '${palName}' not found. Add them first with \`pe pal add\`.`);
44
+ }
45
+ return pal;
46
+ }
47
+
48
+ function validateDir(dirPath) {
49
+ const abs = path.resolve(dirPath);
50
+ if (!fs.existsSync(abs)) {
51
+ throw new Error(`Path not found: ${abs}`);
52
+ }
53
+ if (!fs.statSync(abs).isDirectory()) {
54
+ throw new Error('Sync only works with directories. Use `pe share` for single files.');
55
+ }
56
+ return abs;
57
+ }
58
+
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}`);
75
+ }
76
+ }
77
+
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 };
92
+ }
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
+ }
98
+ return res.json();
99
+ }
100
+
101
+ // --- pe sync push <path> <pal> ---
102
+
103
+ async function pushSync(dirPath, palName) {
104
+ let absolutePath, pal;
105
+ try {
106
+ absolutePath = validateDir(dirPath);
107
+ pal = findPal(palName);
108
+ } catch (err) {
109
+ console.log(chalk.red(err.message));
110
+ process.exitCode = 1;
111
+ return;
112
+ }
113
+
114
+ const palHandle = pal.handle || pal.name;
115
+ const palId = pal.id;
116
+
117
+ console.log(chalk.blue(`Scanning ${absolutePath}...`));
118
+ const existingPair = findSyncPair(absolutePath, palId);
119
+ const cachedManifest = existingPair ? getSyncManifest(existingPair.id) : null;
120
+ const manifest = await buildManifest(absolutePath, cachedManifest);
121
+ console.log(chalk.gray(` ${manifest.length} file(s) indexed`));
122
+
123
+ const pair = addSyncPair(absolutePath, palId, `sync:${palHandle}`);
124
+ const lastManifest = getSyncManifest(pair.id);
125
+ const manifestMap = manifestToMap(manifest);
126
+
127
+ let added = 0, modified = 0, deleted = 0;
128
+ if (lastManifest) {
129
+ const lastMap = manifestToMap(lastManifest);
130
+ const diff = diffManifests(lastMap, manifestMap);
131
+ added = diff.added.length;
132
+ modified = diff.modified.length;
133
+ deleted = diff.deleted.length;
134
+
135
+ if (added === 0 && modified === 0 && deleted === 0) {
136
+ console.log(chalk.green('Already in sync. No changes to push.'));
137
+ return;
138
+ }
139
+
140
+ console.log(chalk.white(` Changes: ${chalk.green(`+${added}`)} added, ${chalk.yellow(`~${modified}`)} modified, ${chalk.red(`-${deleted}`)} deleted`));
141
+ } else {
142
+ console.log(chalk.gray(' First sync for this pair.'));
143
+ }
144
+
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
+ }
164
+ }
165
+
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
+ }
188
+ }
189
+
190
+ console.log(chalk.blue(`Sending manifest to ${pal.name}...`));
191
+ try {
192
+ await sendMessage(palHandle, {
193
+ type: 'sync-manifest',
194
+ syncPairId: pair.id,
195
+ manifest,
196
+ magnet,
197
+ timestamp: new Date().toISOString(),
198
+ });
199
+
200
+ saveSyncManifest(pair.id, manifest);
201
+ updateSyncPair(pair.id, {
202
+ lastSync: new Date().toISOString(),
203
+ status: 'synced',
204
+ lastDirection: 'push',
205
+ });
206
+
207
+ addSyncHistoryEntry(pair.id, {
208
+ direction: 'push',
209
+ filesAdded: added || manifest.length,
210
+ filesModified: modified,
211
+ filesDeleted: deleted,
212
+ totalFiles: manifest.length,
213
+ });
214
+
215
+ console.log(chalk.green(`Push complete. Manifest sent to ${pal.name}.`));
216
+ console.log(chalk.gray(`Sync pair: ${pair.id}`));
217
+ console.log(chalk.gray('Pal can run `pe sync pull` to fetch changes.'));
218
+ } catch (err) {
219
+ updateSyncPair(pair.id, { status: 'error' });
220
+ 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.'));
223
+ process.exitCode = 1;
224
+ }
225
+ }
226
+
227
+ // --- pe sync pull <path> <pal> ---
228
+
229
+ async function pullSync(dirPath, palName) {
230
+ let absolutePath, pal;
231
+ try {
232
+ absolutePath = validateDir(dirPath);
233
+ pal = findPal(palName);
234
+ } catch (err) {
235
+ console.log(chalk.red(err.message));
236
+ process.exitCode = 1;
237
+ return;
238
+ }
239
+
240
+ const palHandle = pal.handle || pal.name;
241
+ const palId = pal.id;
242
+
243
+ console.log(chalk.blue(`Checking messages from ${pal.name}...`));
244
+
245
+ let messages;
246
+ try {
247
+ messages = await getMessages();
248
+ } catch (err) {
249
+ logger.error(`Pull sync failed: ${err.message}`);
250
+ console.log(chalk.red(`Could not reach discovery server: ${err.message}`));
251
+ process.exitCode = 1;
252
+ return;
253
+ }
254
+
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)`));
278
+
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)`));
284
+
285
+ const localMap = manifestToMap(localManifest);
286
+ const remoteMap = manifestToMap(remoteManifest);
287
+
288
+ const pair = addSyncPair(absolutePath, palId, `sync:${palHandle}`);
289
+ const baseMap = getLastSyncedManifestMap(pair.id);
290
+
291
+ const { conflicts } = detectConflicts(localMap, remoteMap, baseMap);
292
+ const diff = diffManifests(localMap, remoteMap);
293
+
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
+ );
299
+ }
300
+
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.'));
304
+ return;
305
+ }
306
+
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)`));
312
+
313
+ const magnet = latest.magnet || null;
314
+
315
+ if (magnet) {
316
+ // P2P sync: download the shared directory via WebTorrent, then apply diff
317
+ console.log(chalk.blue('Downloading via P2P...'));
318
+
319
+ 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
+ });
384
+
385
+ torrent.on('error', (err) => {
386
+ clearTimeout(timeout);
387
+ client.destroy();
388
+ reject(err);
389
+ });
390
+ });
391
+ });
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
+
397
+ saveSyncManifest(pair.id, remoteManifest);
398
+ updateSyncPair(pair.id, {
399
+ 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
+ },
408
+ });
409
+ }
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.'));
415
+
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
+ },
427
+ });
428
+ }
429
+ }
430
+
431
+ // --- pe sync status <path> ---
432
+
433
+ async function syncStatus(dirPath) {
434
+ if (!dirPath) {
435
+ const pairs = getSyncPairs();
436
+ if (pairs.length === 0) {
437
+ console.log(chalk.gray('No sync pairs configured. Use `pe sync push <path> <pal>` to start.'));
438
+ return;
439
+ }
440
+
441
+ const friends = getFriends();
442
+ console.log('');
443
+ console.log(chalk.cyan.bold('Sync Pairs'));
444
+ console.log('');
445
+
446
+ for (const pair of pairs) {
447
+ const pal = friends.find(f => f.id === pair.palId);
448
+ const palName = pal?.name || pair.palId;
449
+ const statusColor =
450
+ pair.status === 'synced' ? chalk.green :
451
+ pair.status === 'error' ? chalk.red :
452
+ pair.status === 'pending-download' ? chalk.yellow :
453
+ chalk.gray;
454
+
455
+ console.log(` ${chalk.white(pair.localPath)} ${chalk.gray('->')} ${chalk.yellow(palName)}`);
456
+ console.log(` Status: ${statusColor(pair.status)} Direction: ${pair.lastDirection || '-'} Last: ${pair.lastSync || 'never'}`);
457
+ console.log(` ${chalk.gray(`ID: ${pair.id}`)}`);
458
+ }
459
+ console.log('');
460
+ return;
461
+ }
462
+
463
+ let absolutePath;
464
+ try {
465
+ absolutePath = validateDir(dirPath);
466
+ } catch (err) {
467
+ console.log(chalk.red(err.message));
468
+ process.exitCode = 1;
469
+ return;
470
+ }
471
+
472
+ const pairs = getSyncPairs().filter(p => p.localPath === absolutePath);
473
+ if (pairs.length === 0) {
474
+ console.log(chalk.yellow(`No sync pairs for ${absolutePath}.`));
475
+ console.log(chalk.gray('Use `pe sync push <path> <pal>` to create one.'));
476
+ return;
477
+ }
478
+
479
+ const friends = getFriends();
480
+ console.log('');
481
+ console.log(chalk.cyan.bold(`Sync Status: ${absolutePath}`));
482
+ console.log('');
483
+
484
+ const statusCached = pairs.length === 1 ? getSyncManifest(pairs[0].id) : null;
485
+ const currentManifest = await buildManifest(absolutePath, statusCached);
486
+ const currentMap = manifestToMap(currentManifest);
487
+
488
+ for (const pair of pairs) {
489
+ const pal = friends.find(f => f.id === pair.palId);
490
+ const palName = pal?.name || pair.palId;
491
+ const lastManifest = getSyncManifest(pair.id);
492
+
493
+ console.log(` ${chalk.yellow(palName)} (${pair.status})`);
494
+
495
+ if (!lastManifest) {
496
+ console.log(chalk.gray(' No previous sync. All files will be synced.'));
497
+ console.log(` ${chalk.white(`${currentManifest.length} file(s) to push`)}`);
498
+ } else {
499
+ const lastMap = manifestToMap(lastManifest);
500
+ const diff = diffManifests(lastMap, currentMap);
501
+ const total = diff.added.length + diff.modified.length + diff.deleted.length;
502
+
503
+ if (total === 0) {
504
+ console.log(chalk.green(' In sync. No local changes.'));
505
+ } else {
506
+ console.log(` ${chalk.green(`+${diff.added.length}`)} added ${chalk.yellow(`~${diff.modified.length}`)} modified ${chalk.red(`-${diff.deleted.length}`)} deleted`);
507
+ if (diff.added.length <= 5) {
508
+ for (const a of diff.added) console.log(chalk.green(` + ${a.relativePath}`));
509
+ }
510
+ if (diff.modified.length <= 5) {
511
+ for (const m of diff.modified) console.log(chalk.yellow(` ~ ${m.relativePath}`));
512
+ }
513
+ if (diff.deleted.length <= 5) {
514
+ for (const d of diff.deleted) console.log(chalk.red(` - ${d.relativePath}`));
515
+ }
516
+ }
517
+ }
518
+
519
+ const history = getSyncHistory(pair.id);
520
+ if (history.length > 0) {
521
+ console.log(chalk.gray(` Last ${Math.min(history.length, 3)} syncs:`));
522
+ for (const h of history.slice(0, 3)) {
523
+ const dir = h.direction === 'push' ? chalk.blue('PUSH') : chalk.magenta('PULL');
524
+ const changes = [];
525
+ if (h.filesAdded) changes.push(chalk.green(`+${h.filesAdded}`));
526
+ if (h.filesModified) changes.push(chalk.yellow(`~${h.filesModified}`));
527
+ if (h.filesDeleted) changes.push(chalk.red(`-${h.filesDeleted}`));
528
+ if (h.conflicts) changes.push(chalk.magenta(`!${h.conflicts}`));
529
+ console.log(chalk.gray(` ${h.timestamp} ${dir} ${changes.join(' ')}`));
530
+ }
531
+ }
532
+ console.log('');
533
+ }
534
+ }
535
+
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
+ // --- pe sync remove <id> ---
633
+
634
+ function removePair(id) {
635
+ const pairs = getSyncPairs();
636
+ const pair = pairs.find(p => p.id === id);
637
+ if (!pair) {
638
+ console.log(chalk.red(`Sync pair '${id}' not found.`));
639
+ process.exitCode = 1;
640
+ return;
641
+ }
642
+ removeSyncPair(id);
643
+ console.log(chalk.green(`Removed sync pair ${id}.`));
644
+ }
645
+
646
+ function formatSyncBytes(b) {
647
+ if (!b) return '0 B';
648
+ if (b < 1024) return b + ' B';
649
+ if (b < 1048576) return (b / 1024).toFixed(1) + ' KB';
650
+ if (b < 1073741824) return (b / 1048576).toFixed(1) + ' MB';
651
+ return (b / 1073741824).toFixed(2) + ' GB';
652
+ }
653
+
654
+ // --- Command registration ---
655
+
656
+ export default function syncCommand(program) {
657
+ const cmd = program
658
+ .command('sync')
659
+ .description('sync directories with pals');
660
+
661
+ cmd
662
+ .command('push <path> <pal>')
663
+ .description('push local changes to a pal')
664
+ .action(async (syncPath, pal) => {
665
+ if (!syncPath || !syncPath.trim()) {
666
+ console.log(chalk.red('Error: path cannot be empty.'));
667
+ process.exitCode = 1;
668
+ return;
669
+ }
670
+ await pushSync(syncPath, pal);
671
+ });
672
+
673
+ cmd
674
+ .command('pull <path> <pal>')
675
+ .description('pull changes from a pal')
676
+ .action(async (syncPath, pal) => {
677
+ if (!syncPath || !syncPath.trim()) {
678
+ console.log(chalk.red('Error: path cannot be empty.'));
679
+ process.exitCode = 1;
680
+ return;
681
+ }
682
+ await pullSync(syncPath, pal);
683
+ });
684
+
685
+ cmd
686
+ .command('status [path]')
687
+ .description('show sync status and changes since last sync')
688
+ .action((syncPath) => {
689
+ syncStatus(syncPath);
690
+ });
691
+
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
+ cmd
700
+ .command('list')
701
+ .description('list all sync pairs')
702
+ .action(() => {
703
+ syncStatus();
704
+ });
705
+
706
+ cmd
707
+ .command('remove <id>')
708
+ .description('remove a sync pair')
709
+ .action((id) => {
710
+ removePair(id);
711
+ });
712
+
713
+ cmd
714
+ .command('history [pairId]')
715
+ .description('view sync history')
716
+ .action((pairId) => {
717
+ const pairs = getSyncPairs();
718
+ if (pairs.length === 0) {
719
+ console.log(chalk.gray('No sync pairs configured.'));
720
+ return;
721
+ }
722
+
723
+ const friends = getFriends();
724
+ const targetPairs = pairId ? pairs.filter(p => p.id === pairId) : pairs;
725
+ if (targetPairs.length === 0) {
726
+ console.log(chalk.red(`Sync pair '${pairId}' not found.`));
727
+ process.exitCode = 1;
728
+ return;
729
+ }
730
+
731
+ console.log('');
732
+ console.log(chalk.cyan.bold('Sync History'));
733
+ console.log('');
734
+
735
+ for (const pair of targetPairs) {
736
+ const pal = friends.find(f => f.id === pair.palId);
737
+ const palName = pal?.name || pair.palId;
738
+ console.log(` ${chalk.white(pair.localPath)} ${chalk.gray('->')} ${chalk.yellow(palName)}`);
739
+ console.log(` ${chalk.gray(`ID: ${pair.id}`)}`);
740
+
741
+ const history = getSyncHistory(pair.id);
742
+ if (history.length === 0) {
743
+ console.log(chalk.gray(' No history yet.'));
744
+ } else {
745
+ for (const h of history) {
746
+ const dir = h.direction === 'push' ? chalk.blue('PUSH') : chalk.magenta('PULL');
747
+ const changes = [];
748
+ if (h.filesAdded) changes.push(chalk.green(`+${h.filesAdded}`));
749
+ if (h.filesModified) changes.push(chalk.yellow(`~${h.filesModified}`));
750
+ if (h.filesDeleted) changes.push(chalk.red(`-${h.filesDeleted}`));
751
+ if (h.conflicts) changes.push(chalk.magenta(`!${h.conflicts}`));
752
+ if (h.totalFiles) changes.push(chalk.gray(`(${h.totalFiles} total)`));
753
+ console.log(` ${chalk.gray(h.timestamp)} ${dir} ${changes.join(' ')}${h.auto ? chalk.gray(' [auto]') : ''}`);
754
+ }
755
+ }
756
+ console.log('');
757
+ }
758
+ });
759
+
760
+ cmd
761
+ .command('diff <localPath> <remotePath>')
762
+ .description('compare local and remote folders, show what needs to transfer')
763
+ .option('--json', 'Output as JSON')
764
+ .action(async (localPath, remotePath, opts) => {
765
+ try {
766
+ console.log(chalk.blue('Building local manifest...'));
767
+ const localManifest = buildDiffManifest(localPath);
768
+ console.log(chalk.blue('Building remote manifest...'));
769
+ const remoteManifest = buildDiffManifest(remotePath);
770
+
771
+ const diff = diffFileManifests(localManifest, remoteManifest);
772
+ const summary = diffSummary(diff);
773
+
774
+ if (opts.json) {
775
+ console.log(JSON.stringify({ diff, summary }, null, 2));
776
+ return;
777
+ }
778
+
779
+ console.log();
780
+ if (diff.added.length > 0) {
781
+ console.log(chalk.green(` + ${diff.added.length} new files`));
782
+ diff.added.forEach(f => console.log(chalk.green(` + ${f.path} (${formatSyncBytes(f.size)})`)));
783
+ }
784
+ if (diff.modified.length > 0) {
785
+ console.log(chalk.yellow(` ~ ${diff.modified.length} modified files`));
786
+ diff.modified.forEach(f => console.log(chalk.yellow(` ~ ${f.path} (${formatSyncBytes(f.size)})`)));
787
+ }
788
+ if (diff.deleted.length > 0) {
789
+ console.log(chalk.red(` - ${diff.deleted.length} deleted files`));
790
+ diff.deleted.forEach(f => console.log(chalk.red(` - ${f.path}`)));
791
+ }
792
+ if (diff.unchanged.length > 0) {
793
+ console.log(chalk.gray(` = ${diff.unchanged.length} unchanged files`));
794
+ }
795
+
796
+ console.log();
797
+ console.log(chalk.white(` Transfer needed: ${formatSyncBytes(summary.transferBytes)} (${summary.addedCount + summary.modifiedCount} files)`));
798
+ console.log(chalk.gray(` Skipping: ${summary.skippedFiles} files already up to date`));
799
+ } catch (err) {
800
+ console.error(chalk.red(`Error: ${err.message}`));
801
+ process.exit(1);
802
+ }
803
+ });
804
+
805
+ cmd.addHelpText('after', `
806
+ ${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
817
+
818
+ ${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.
826
+ `);
827
+ }