@cardstack/boxel-cli 0.0.1
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/LICENSE +21 -0
- package/README.md +111 -0
- package/api.ts +24 -0
- package/dist/index.js +75 -0
- package/package.json +78 -0
- package/src/commands/profile.ts +457 -0
- package/src/commands/realm/create.ts +245 -0
- package/src/commands/realm/index.ts +16 -0
- package/src/commands/realm/pull.ts +245 -0
- package/src/commands/realm/push.ts +379 -0
- package/src/commands/realm/sync.ts +587 -0
- package/src/commands/run-command.ts +186 -0
- package/src/index.ts +47 -0
- package/src/lib/auth.ts +169 -0
- package/src/lib/boxel-cli-client.ts +631 -0
- package/src/lib/checkpoint-manager.ts +609 -0
- package/src/lib/colors.ts +9 -0
- package/src/lib/profile-manager.ts +583 -0
- package/src/lib/realm-sync-base.ts +647 -0
- package/src/lib/sync-logic.ts +169 -0
- package/src/lib/sync-manifest.ts +81 -0
|
@@ -0,0 +1,587 @@
|
|
|
1
|
+
import type { Command } from 'commander';
|
|
2
|
+
import {
|
|
3
|
+
RealmSyncBase,
|
|
4
|
+
isProtectedFile,
|
|
5
|
+
type SyncOptions,
|
|
6
|
+
} from '../../lib/realm-sync-base';
|
|
7
|
+
import {
|
|
8
|
+
CheckpointManager,
|
|
9
|
+
type CheckpointChange,
|
|
10
|
+
} from '../../lib/checkpoint-manager';
|
|
11
|
+
import {
|
|
12
|
+
getProfileManager,
|
|
13
|
+
type ProfileManager,
|
|
14
|
+
} from '../../lib/profile-manager';
|
|
15
|
+
import {
|
|
16
|
+
type SyncManifest,
|
|
17
|
+
computeFileHash,
|
|
18
|
+
loadManifest,
|
|
19
|
+
saveManifest,
|
|
20
|
+
pathExists,
|
|
21
|
+
} from '../../lib/sync-manifest';
|
|
22
|
+
import * as path from 'path';
|
|
23
|
+
import {
|
|
24
|
+
FG_GREEN,
|
|
25
|
+
FG_YELLOW,
|
|
26
|
+
FG_RED,
|
|
27
|
+
FG_CYAN,
|
|
28
|
+
DIM,
|
|
29
|
+
RESET,
|
|
30
|
+
} from '../../lib/colors';
|
|
31
|
+
import {
|
|
32
|
+
classifyLocal,
|
|
33
|
+
classifyRemote,
|
|
34
|
+
determineAction,
|
|
35
|
+
resolveConflict,
|
|
36
|
+
type FileClassification,
|
|
37
|
+
type ConflictStrategy,
|
|
38
|
+
} from '../../lib/sync-logic';
|
|
39
|
+
|
|
40
|
+
interface BiSyncOptions extends SyncOptions {
|
|
41
|
+
preferLocal?: boolean;
|
|
42
|
+
preferRemote?: boolean;
|
|
43
|
+
preferNewest?: boolean;
|
|
44
|
+
deleteSync?: boolean;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
class RealmSyncer extends RealmSyncBase {
|
|
48
|
+
hasError = false;
|
|
49
|
+
|
|
50
|
+
constructor(
|
|
51
|
+
private syncOptions: BiSyncOptions,
|
|
52
|
+
profileManager: ProfileManager,
|
|
53
|
+
) {
|
|
54
|
+
super(syncOptions, profileManager);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
private get conflictStrategy(): ConflictStrategy | null {
|
|
58
|
+
if (this.syncOptions.preferLocal) return 'prefer-local';
|
|
59
|
+
if (this.syncOptions.preferRemote) return 'prefer-remote';
|
|
60
|
+
if (this.syncOptions.preferNewest) return 'prefer-newest';
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async sync(): Promise<void> {
|
|
65
|
+
console.log(
|
|
66
|
+
`Starting sync between ${this.options.localDir} and ${this.options.realmUrl}`,
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
console.log('Testing realm access...');
|
|
70
|
+
let remoteFileList: Map<string, boolean> | undefined;
|
|
71
|
+
try {
|
|
72
|
+
remoteFileList = await this.getRemoteFileList('');
|
|
73
|
+
} catch (error) {
|
|
74
|
+
console.error('Failed to access realm:', error);
|
|
75
|
+
throw new Error(
|
|
76
|
+
'Cannot proceed with sync: Authentication or access failed. ' +
|
|
77
|
+
'Please check your Matrix credentials and realm permissions.',
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
console.log('Realm access verified');
|
|
81
|
+
|
|
82
|
+
// Phase 1: Gather state (single local traversal — derive localFiles from mtimes result)
|
|
83
|
+
const [localFilesWithMtimes, remoteMtimes, manifest] = await Promise.all([
|
|
84
|
+
this.getLocalFileListWithMtimes(),
|
|
85
|
+
this.getRemoteMtimes(),
|
|
86
|
+
loadManifest(this.options.localDir),
|
|
87
|
+
]);
|
|
88
|
+
|
|
89
|
+
const localFiles = new Map<string, string>();
|
|
90
|
+
for (const [rel, info] of localFilesWithMtimes) {
|
|
91
|
+
localFiles.set(rel, info.path);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Fall back to file listing when _mtimes endpoint is unavailable
|
|
95
|
+
if (remoteMtimes.size === 0 && remoteFileList && remoteFileList.size > 0) {
|
|
96
|
+
console.log(
|
|
97
|
+
'Remote mtimes unavailable, falling back to file listing for remote detection',
|
|
98
|
+
);
|
|
99
|
+
for (const [filePath] of remoteFileList) {
|
|
100
|
+
remoteMtimes.set(filePath, 0);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
console.log(`Found ${localFiles.size} local files`);
|
|
105
|
+
console.log(`Found ${remoteMtimes.size} remote files`);
|
|
106
|
+
|
|
107
|
+
if (manifest && manifest.realmUrl !== this.normalizedRealmUrl) {
|
|
108
|
+
console.warn(
|
|
109
|
+
`${FG_YELLOW}Warning:${RESET} Manifest realm URL (${manifest.realmUrl}) differs from target (${this.normalizedRealmUrl}). Treating as first sync.`,
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const effectiveManifest =
|
|
114
|
+
manifest && manifest.realmUrl === this.normalizedRealmUrl
|
|
115
|
+
? manifest
|
|
116
|
+
: null;
|
|
117
|
+
|
|
118
|
+
// Compute local file hashes
|
|
119
|
+
const localHashes = new Map<string, string>();
|
|
120
|
+
await Promise.all(
|
|
121
|
+
Array.from(localFiles.entries()).map(async ([rel, absPath]) => {
|
|
122
|
+
if (!isProtectedFile(rel)) {
|
|
123
|
+
localHashes.set(rel, await computeFileHash(absPath));
|
|
124
|
+
}
|
|
125
|
+
}),
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
// Phase 2: Classify each file
|
|
129
|
+
const allPaths = new Set<string>();
|
|
130
|
+
for (const p of localFiles.keys()) allPaths.add(p);
|
|
131
|
+
for (const p of remoteMtimes.keys()) allPaths.add(p);
|
|
132
|
+
if (effectiveManifest) {
|
|
133
|
+
for (const p of Object.keys(effectiveManifest.files)) allPaths.add(p);
|
|
134
|
+
if (effectiveManifest.remoteMtimes) {
|
|
135
|
+
for (const p of Object.keys(effectiveManifest.remoteMtimes))
|
|
136
|
+
allPaths.add(p);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const classifications: FileClassification[] = [];
|
|
141
|
+
|
|
142
|
+
for (const relativePath of allPaths) {
|
|
143
|
+
if (isProtectedFile(relativePath)) continue;
|
|
144
|
+
|
|
145
|
+
const localStatus = classifyLocal(
|
|
146
|
+
relativePath,
|
|
147
|
+
localHashes,
|
|
148
|
+
effectiveManifest,
|
|
149
|
+
);
|
|
150
|
+
const remoteStatus = classifyRemote(
|
|
151
|
+
relativePath,
|
|
152
|
+
remoteMtimes,
|
|
153
|
+
effectiveManifest,
|
|
154
|
+
);
|
|
155
|
+
|
|
156
|
+
const action = determineAction(
|
|
157
|
+
localStatus,
|
|
158
|
+
remoteStatus,
|
|
159
|
+
this.syncOptions,
|
|
160
|
+
);
|
|
161
|
+
|
|
162
|
+
classifications.push({ relativePath, localStatus, remoteStatus, action });
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Phase 3: Summarize and resolve conflicts
|
|
166
|
+
const toPush: string[] = [];
|
|
167
|
+
const toPull: string[] = [];
|
|
168
|
+
const toPushDelete: string[] = [];
|
|
169
|
+
const toPullDelete: string[] = [];
|
|
170
|
+
const conflicts: FileClassification[] = [];
|
|
171
|
+
let noopCount = 0;
|
|
172
|
+
|
|
173
|
+
for (const c of classifications) {
|
|
174
|
+
switch (c.action) {
|
|
175
|
+
case 'push':
|
|
176
|
+
toPush.push(c.relativePath);
|
|
177
|
+
break;
|
|
178
|
+
case 'pull':
|
|
179
|
+
toPull.push(c.relativePath);
|
|
180
|
+
break;
|
|
181
|
+
case 'push-delete':
|
|
182
|
+
toPushDelete.push(c.relativePath);
|
|
183
|
+
break;
|
|
184
|
+
case 'pull-delete':
|
|
185
|
+
toPullDelete.push(c.relativePath);
|
|
186
|
+
break;
|
|
187
|
+
case 'conflict':
|
|
188
|
+
conflicts.push(c);
|
|
189
|
+
break;
|
|
190
|
+
case 'noop':
|
|
191
|
+
noopCount++;
|
|
192
|
+
break;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Resolve conflicts
|
|
197
|
+
const skippedConflicts: string[] = [];
|
|
198
|
+
for (const c of conflicts) {
|
|
199
|
+
const resolved = resolveConflict(
|
|
200
|
+
c,
|
|
201
|
+
localFilesWithMtimes,
|
|
202
|
+
remoteMtimes,
|
|
203
|
+
this.conflictStrategy,
|
|
204
|
+
);
|
|
205
|
+
switch (resolved) {
|
|
206
|
+
case 'push':
|
|
207
|
+
toPush.push(c.relativePath);
|
|
208
|
+
break;
|
|
209
|
+
case 'pull':
|
|
210
|
+
toPull.push(c.relativePath);
|
|
211
|
+
break;
|
|
212
|
+
case 'push-delete':
|
|
213
|
+
toPushDelete.push(c.relativePath);
|
|
214
|
+
break;
|
|
215
|
+
case 'pull-delete':
|
|
216
|
+
toPullDelete.push(c.relativePath);
|
|
217
|
+
break;
|
|
218
|
+
case 'noop':
|
|
219
|
+
// deleted on both sides
|
|
220
|
+
break;
|
|
221
|
+
default:
|
|
222
|
+
skippedConflicts.push(c.relativePath);
|
|
223
|
+
break;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Print summary
|
|
228
|
+
console.log(`\n${DIM}Sync plan:${RESET}`);
|
|
229
|
+
if (toPush.length > 0)
|
|
230
|
+
console.log(` ${FG_GREEN}↑ Push:${RESET} ${toPush.length} file(s)`);
|
|
231
|
+
if (toPull.length > 0)
|
|
232
|
+
console.log(` ${FG_CYAN}↓ Pull:${RESET} ${toPull.length} file(s)`);
|
|
233
|
+
if (toPushDelete.length > 0)
|
|
234
|
+
console.log(
|
|
235
|
+
` ${FG_RED}↑ Delete remote:${RESET} ${toPushDelete.length} file(s)`,
|
|
236
|
+
);
|
|
237
|
+
if (toPullDelete.length > 0)
|
|
238
|
+
console.log(
|
|
239
|
+
` ${FG_RED}↓ Delete local:${RESET} ${toPullDelete.length} file(s)`,
|
|
240
|
+
);
|
|
241
|
+
if (skippedConflicts.length > 0) {
|
|
242
|
+
console.log(
|
|
243
|
+
` ${FG_YELLOW}⚠ Conflicts skipped:${RESET} ${skippedConflicts.length} file(s)`,
|
|
244
|
+
);
|
|
245
|
+
for (const p of skippedConflicts) {
|
|
246
|
+
console.log(` ${p}`);
|
|
247
|
+
}
|
|
248
|
+
console.log(
|
|
249
|
+
` ${DIM}Use --prefer-local, --prefer-remote, or --prefer-newest to resolve.${RESET}`,
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
if (noopCount > 0)
|
|
253
|
+
console.log(` ${DIM}Unchanged: ${noopCount} file(s)${RESET}`);
|
|
254
|
+
|
|
255
|
+
const totalOps =
|
|
256
|
+
toPush.length + toPull.length + toPushDelete.length + toPullDelete.length;
|
|
257
|
+
|
|
258
|
+
if (totalOps === 0) {
|
|
259
|
+
console.log('\nEverything is up to date');
|
|
260
|
+
if (
|
|
261
|
+
!this.options.dryRun &&
|
|
262
|
+
!effectiveManifest &&
|
|
263
|
+
skippedConflicts.length === 0
|
|
264
|
+
) {
|
|
265
|
+
// First sync with no changes needed - still write manifest
|
|
266
|
+
await this.writeManifest(localHashes, remoteMtimes);
|
|
267
|
+
}
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// Phase 5: Execute operations (order: pulls, pushes, remote deletes, local deletes)
|
|
272
|
+
const pulledFiles: string[] = [];
|
|
273
|
+
const pushedFiles: string[] = [];
|
|
274
|
+
const remoteDeletedFiles: string[] = [];
|
|
275
|
+
const localDeletedFiles: string[] = [];
|
|
276
|
+
|
|
277
|
+
// Downloads (pulls)
|
|
278
|
+
if (toPull.length > 0) {
|
|
279
|
+
console.log(`\nPulling ${toPull.length} file(s)...`);
|
|
280
|
+
const results = await Promise.all(
|
|
281
|
+
toPull.map((rel) =>
|
|
282
|
+
this.remoteLimit(async () => {
|
|
283
|
+
try {
|
|
284
|
+
const localPath = path.join(this.options.localDir, rel);
|
|
285
|
+
await this.downloadFile(rel, localPath);
|
|
286
|
+
return rel;
|
|
287
|
+
} catch (error) {
|
|
288
|
+
this.hasError = true;
|
|
289
|
+
console.error(`Error downloading ${rel}:`, error);
|
|
290
|
+
return null;
|
|
291
|
+
}
|
|
292
|
+
}),
|
|
293
|
+
),
|
|
294
|
+
);
|
|
295
|
+
pulledFiles.push(...results.filter((f): f is string => f !== null));
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// Uploads (pushes) via atomic
|
|
299
|
+
if (toPush.length > 0) {
|
|
300
|
+
console.log(`\nPushing ${toPush.length} file(s)...`);
|
|
301
|
+
const filesToUpload = new Map<string, string>();
|
|
302
|
+
for (const rel of toPush) {
|
|
303
|
+
const absPath = localFiles.get(rel);
|
|
304
|
+
if (absPath) filesToUpload.set(rel, absPath);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// Determine add vs update based on whether file exists in manifest or on remote
|
|
308
|
+
const addPaths = new Set<string>();
|
|
309
|
+
for (const rel of filesToUpload.keys()) {
|
|
310
|
+
const inManifest = effectiveManifest?.files[rel] !== undefined;
|
|
311
|
+
const existsOnRemote = remoteMtimes.has(rel);
|
|
312
|
+
if (!inManifest && !existsOnRemote) {
|
|
313
|
+
addPaths.add(rel);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
const result = await this.uploadFilesAtomic(filesToUpload, addPaths);
|
|
318
|
+
if (result.error) {
|
|
319
|
+
this.hasError = true;
|
|
320
|
+
console.error(result.error.message);
|
|
321
|
+
for (const entry of result.error.perFile) {
|
|
322
|
+
console.error(` ${entry.path}: ${entry.title}`);
|
|
323
|
+
}
|
|
324
|
+
} else {
|
|
325
|
+
pushedFiles.push(...result.succeeded);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// Remote deletions
|
|
330
|
+
if (toPushDelete.length > 0) {
|
|
331
|
+
console.log(`\nDeleting ${toPushDelete.length} remote file(s)...`);
|
|
332
|
+
const deleteResults = await Promise.all(
|
|
333
|
+
toPushDelete.map((rel) =>
|
|
334
|
+
this.remoteLimit(async () => {
|
|
335
|
+
try {
|
|
336
|
+
await this.deleteFile(rel);
|
|
337
|
+
return rel;
|
|
338
|
+
} catch (error) {
|
|
339
|
+
this.hasError = true;
|
|
340
|
+
console.error(`Error deleting remote ${rel}:`, error);
|
|
341
|
+
return null;
|
|
342
|
+
}
|
|
343
|
+
}),
|
|
344
|
+
),
|
|
345
|
+
);
|
|
346
|
+
remoteDeletedFiles.push(
|
|
347
|
+
...deleteResults.filter((f): f is string => f !== null),
|
|
348
|
+
);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// Local deletions
|
|
352
|
+
if (toPullDelete.length > 0) {
|
|
353
|
+
console.log(`\nDeleting ${toPullDelete.length} local file(s)...`);
|
|
354
|
+
const localDeleteResults = await Promise.all(
|
|
355
|
+
toPullDelete.map(async (rel) => {
|
|
356
|
+
try {
|
|
357
|
+
const localPath = localFiles.get(rel);
|
|
358
|
+
if (localPath) {
|
|
359
|
+
await this.deleteLocalFile(localPath);
|
|
360
|
+
return rel;
|
|
361
|
+
}
|
|
362
|
+
return null;
|
|
363
|
+
} catch (error) {
|
|
364
|
+
this.hasError = true;
|
|
365
|
+
console.error(`Error deleting local ${rel}:`, error);
|
|
366
|
+
return null;
|
|
367
|
+
}
|
|
368
|
+
}),
|
|
369
|
+
);
|
|
370
|
+
localDeletedFiles.push(
|
|
371
|
+
...localDeleteResults.filter((f): f is string => f !== null),
|
|
372
|
+
);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// Phase 6: Update manifest
|
|
376
|
+
if (!this.options.dryRun && !this.hasError) {
|
|
377
|
+
// Build updated hashes from prior manifest + current local files + executed ops.
|
|
378
|
+
// Start with the previous manifest so that files deleted locally but not
|
|
379
|
+
// propagated (no --delete) retain their entries and aren't re-pulled next sync.
|
|
380
|
+
const updatedHashes = new Map<string, string>();
|
|
381
|
+
if (effectiveManifest) {
|
|
382
|
+
for (const [rel, hash] of Object.entries(effectiveManifest.files)) {
|
|
383
|
+
updatedHashes.set(rel, hash);
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
// Overlay current local file hashes (covers new, changed, and unchanged local files)
|
|
387
|
+
for (const [rel, hash] of localHashes) {
|
|
388
|
+
updatedHashes.set(rel, hash);
|
|
389
|
+
}
|
|
390
|
+
// Recompute hashes for pushed files (content may have been normalized)
|
|
391
|
+
for (const rel of pushedFiles) {
|
|
392
|
+
const absPath = localFiles.get(rel);
|
|
393
|
+
if (absPath) {
|
|
394
|
+
updatedHashes.set(rel, await computeFileHash(absPath));
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
// Add hashes for pulled files (newly downloaded)
|
|
398
|
+
for (const rel of pulledFiles) {
|
|
399
|
+
const absPath = path.join(this.options.localDir, rel);
|
|
400
|
+
updatedHashes.set(rel, await computeFileHash(absPath));
|
|
401
|
+
}
|
|
402
|
+
// Remove files that were actually deleted (propagated deletions only)
|
|
403
|
+
for (const rel of remoteDeletedFiles) updatedHashes.delete(rel);
|
|
404
|
+
for (const rel of localDeletedFiles) updatedHashes.delete(rel);
|
|
405
|
+
|
|
406
|
+
// Refresh remote mtimes after pushes
|
|
407
|
+
let freshMtimes = remoteMtimes;
|
|
408
|
+
if (pushedFiles.length > 0 || remoteDeletedFiles.length > 0) {
|
|
409
|
+
try {
|
|
410
|
+
freshMtimes = await this.getRemoteMtimes();
|
|
411
|
+
} catch {
|
|
412
|
+
console.warn('Could not refresh remote mtimes after sync');
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
await this.writeManifest(updatedHashes, freshMtimes);
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// Phase 7: Checkpoint
|
|
420
|
+
if (!this.options.dryRun) {
|
|
421
|
+
const allChanges: CheckpointChange[] = [
|
|
422
|
+
...pushedFiles.map((f) => ({
|
|
423
|
+
file: f,
|
|
424
|
+
status: 'modified' as const,
|
|
425
|
+
})),
|
|
426
|
+
...pulledFiles.map((f) => ({
|
|
427
|
+
file: f,
|
|
428
|
+
status: 'modified' as const,
|
|
429
|
+
})),
|
|
430
|
+
...remoteDeletedFiles.map((f) => ({
|
|
431
|
+
file: f,
|
|
432
|
+
status: 'deleted' as const,
|
|
433
|
+
})),
|
|
434
|
+
...localDeletedFiles.map((f) => ({
|
|
435
|
+
file: f,
|
|
436
|
+
status: 'deleted' as const,
|
|
437
|
+
})),
|
|
438
|
+
];
|
|
439
|
+
|
|
440
|
+
if (allChanges.length > 0) {
|
|
441
|
+
const checkpointManager = new CheckpointManager(this.options.localDir);
|
|
442
|
+
const checkpoint = await checkpointManager.createCheckpoint(
|
|
443
|
+
'local',
|
|
444
|
+
allChanges,
|
|
445
|
+
);
|
|
446
|
+
if (checkpoint) {
|
|
447
|
+
const tag = checkpoint.isMajor ? '[MAJOR]' : '[minor]';
|
|
448
|
+
console.log(
|
|
449
|
+
`\nCheckpoint created: ${checkpoint.shortHash} ${tag} ${checkpoint.message}`,
|
|
450
|
+
);
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
console.log('\nSync completed');
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
private async writeManifest(
|
|
459
|
+
hashes: Map<string, string>,
|
|
460
|
+
remoteMtimes: Map<string, number>,
|
|
461
|
+
): Promise<void> {
|
|
462
|
+
const manifest: SyncManifest = {
|
|
463
|
+
realmUrl: this.normalizedRealmUrl,
|
|
464
|
+
files: {},
|
|
465
|
+
remoteMtimes: {},
|
|
466
|
+
};
|
|
467
|
+
|
|
468
|
+
for (const [rel, hash] of hashes) {
|
|
469
|
+
manifest.files[rel] = hash;
|
|
470
|
+
const mtime = remoteMtimes.get(rel);
|
|
471
|
+
if (mtime !== undefined && mtime !== 0) {
|
|
472
|
+
manifest.remoteMtimes![rel] = mtime;
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
if (
|
|
477
|
+
manifest.remoteMtimes &&
|
|
478
|
+
Object.keys(manifest.remoteMtimes).length === 0
|
|
479
|
+
) {
|
|
480
|
+
delete manifest.remoteMtimes;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
await saveManifest(this.options.localDir, manifest);
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
export interface SyncCommandOptions {
|
|
488
|
+
preferLocal?: boolean;
|
|
489
|
+
preferRemote?: boolean;
|
|
490
|
+
preferNewest?: boolean;
|
|
491
|
+
delete?: boolean;
|
|
492
|
+
dryRun?: boolean;
|
|
493
|
+
profileManager?: ProfileManager;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
export function registerSyncCommand(realm: Command): void {
|
|
497
|
+
realm
|
|
498
|
+
.command('sync')
|
|
499
|
+
.description(
|
|
500
|
+
'Bidirectional sync between a local directory and a Boxel realm',
|
|
501
|
+
)
|
|
502
|
+
.argument('<local-dir>', 'The local directory to sync')
|
|
503
|
+
.argument(
|
|
504
|
+
'<realm-url>',
|
|
505
|
+
'The URL of the target realm (e.g., https://app.boxel.ai/demo/)',
|
|
506
|
+
)
|
|
507
|
+
.option('--prefer-local', 'Resolve conflicts by keeping local version')
|
|
508
|
+
.option('--prefer-remote', 'Resolve conflicts by keeping remote version')
|
|
509
|
+
.option('--prefer-newest', 'Resolve conflicts by keeping newest version')
|
|
510
|
+
.option('--delete', 'Sync deletions both ways')
|
|
511
|
+
.option('--dry-run', 'Preview without making changes')
|
|
512
|
+
.action(
|
|
513
|
+
async (
|
|
514
|
+
localDir: string,
|
|
515
|
+
realmUrl: string,
|
|
516
|
+
options: {
|
|
517
|
+
preferLocal?: boolean;
|
|
518
|
+
preferRemote?: boolean;
|
|
519
|
+
preferNewest?: boolean;
|
|
520
|
+
delete?: boolean;
|
|
521
|
+
dryRun?: boolean;
|
|
522
|
+
},
|
|
523
|
+
) => {
|
|
524
|
+
await syncCommand(localDir, realmUrl, options);
|
|
525
|
+
},
|
|
526
|
+
);
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
export async function syncCommand(
|
|
530
|
+
localDir: string,
|
|
531
|
+
realmUrl: string,
|
|
532
|
+
options: SyncCommandOptions,
|
|
533
|
+
): Promise<void> {
|
|
534
|
+
let pm = options.profileManager ?? getProfileManager();
|
|
535
|
+
let active = pm.getActiveProfile();
|
|
536
|
+
if (!active) {
|
|
537
|
+
console.error(
|
|
538
|
+
'Error: no active profile. Run `boxel profile add` to create one.',
|
|
539
|
+
);
|
|
540
|
+
process.exit(1);
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
// Validate mutually exclusive strategies
|
|
544
|
+
const strategies = [
|
|
545
|
+
options.preferLocal,
|
|
546
|
+
options.preferRemote,
|
|
547
|
+
options.preferNewest,
|
|
548
|
+
].filter(Boolean);
|
|
549
|
+
if (strategies.length > 1) {
|
|
550
|
+
console.error(
|
|
551
|
+
'Error: only one conflict strategy can be specified (--prefer-local, --prefer-remote, or --prefer-newest)',
|
|
552
|
+
);
|
|
553
|
+
process.exit(1);
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
if (!(await pathExists(localDir))) {
|
|
557
|
+
console.error(`Local directory does not exist: ${localDir}`);
|
|
558
|
+
process.exit(1);
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
try {
|
|
562
|
+
const syncer = new RealmSyncer(
|
|
563
|
+
{
|
|
564
|
+
realmUrl,
|
|
565
|
+
localDir,
|
|
566
|
+
preferLocal: options.preferLocal,
|
|
567
|
+
preferRemote: options.preferRemote,
|
|
568
|
+
preferNewest: options.preferNewest,
|
|
569
|
+
deleteSync: options.delete,
|
|
570
|
+
dryRun: options.dryRun,
|
|
571
|
+
},
|
|
572
|
+
pm,
|
|
573
|
+
);
|
|
574
|
+
|
|
575
|
+
await syncer.sync();
|
|
576
|
+
|
|
577
|
+
if (syncer.hasError) {
|
|
578
|
+
console.log('Sync did not complete successfully. View logs for details');
|
|
579
|
+
process.exit(2);
|
|
580
|
+
} else {
|
|
581
|
+
console.log('Sync completed successfully');
|
|
582
|
+
}
|
|
583
|
+
} catch (error) {
|
|
584
|
+
console.error('Sync failed:', error);
|
|
585
|
+
process.exit(1);
|
|
586
|
+
}
|
|
587
|
+
}
|