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