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.
- package/bin/pal.js +231 -231
- package/extensions/@palexplorer/analytics/extension.json +1 -1
- package/extensions/@palexplorer/discovery/extension.json +1 -0
- package/extensions/@palexplorer/explorer-integration/extension.json +1 -1
- package/extensions/@palexplorer/networks/extension.json +1 -1
- package/extensions/@palexplorer/vfs/extension.json +1 -1
- package/lib/commands/sync.js +541 -389
- package/lib/core/extensions.js +11 -22
- package/lib/core/syncEngine.js +276 -3
- package/lib/core/syncOptions.js +80 -0
- package/lib/core/syncProtocolHandlers.js +87 -0
- package/lib/core/syncTransport.js +203 -0
- package/package.json +1 -1
package/lib/commands/sync.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
})
|
|
70
|
-
|
|
71
|
-
}
|
|
72
|
-
if (
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
|
|
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
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
-
//
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
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(`
|
|
322
|
+
console.log(chalk.blue(`Pulling from ${pal.name} to ${absolutePath}...`));
|
|
244
323
|
|
|
245
|
-
let
|
|
324
|
+
let transport;
|
|
246
325
|
try {
|
|
247
|
-
|
|
326
|
+
transport = await selectSyncTransport(palId, { peerHandle: palHandle });
|
|
327
|
+
console.log(chalk.gray(` Transport: ${transport.type}`));
|
|
248
328
|
} catch (err) {
|
|
249
|
-
|
|
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
|
|
256
|
-
|
|
257
|
-
|
|
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
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
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
|
-
|
|
286
|
-
|
|
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
|
-
|
|
289
|
-
|
|
354
|
+
saveSyncManifest(pair.id, localManifest);
|
|
355
|
+
updateSyncPair(pair.id, {
|
|
356
|
+
lastSync: new Date().toISOString(),
|
|
357
|
+
status: 'synced',
|
|
358
|
+
lastDirection: 'pull',
|
|
359
|
+
});
|
|
290
360
|
|
|
291
|
-
|
|
292
|
-
|
|
361
|
+
addSyncHistoryEntry(pair.id, {
|
|
362
|
+
direction: 'pull',
|
|
363
|
+
totalFiles: localManifest.length,
|
|
364
|
+
});
|
|
293
365
|
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
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
|
-
|
|
302
|
-
|
|
303
|
-
|
|
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
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
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
|
-
|
|
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
|
-
|
|
316
|
-
|
|
317
|
-
|
|
407
|
+
const triggerSync = async () => {
|
|
408
|
+
if (syncing) return;
|
|
409
|
+
syncing = true;
|
|
318
410
|
|
|
319
411
|
try {
|
|
320
|
-
const
|
|
321
|
-
const
|
|
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
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
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
|
-
|
|
440
|
+
await session.start(lastManifest);
|
|
441
|
+
|
|
442
|
+
saveSyncManifest(pair.id, manifest);
|
|
398
443
|
updateSyncPair(pair.id, {
|
|
399
444
|
lastSync: new Date().toISOString(),
|
|
400
|
-
status: '
|
|
401
|
-
lastDirection: '
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
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
|
-
}
|
|
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
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
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
|
|
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
|
|
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') :
|
|
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
|
|
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
|
|
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') :
|
|
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,
|
|
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 (
|
|
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
|
|
808
|
-
$ pe sync
|
|
809
|
-
$ pe sync
|
|
810
|
-
$ pe sync
|
|
811
|
-
$ pe sync
|
|
812
|
-
$ pe sync
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
$ pe sync
|
|
816
|
-
$ pe sync
|
|
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.
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
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
|
}
|