@cardstack/boxel-cli 0.1.4 → 0.2.0-unstable.298

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.
@@ -0,0 +1,668 @@
1
+ import type { Command } from 'commander';
2
+ import * as fs from 'fs/promises';
3
+ import * as path from 'path';
4
+ import {
5
+ RealmSyncBase,
6
+ isProtectedFile,
7
+ type SyncOptions,
8
+ } from '../../lib/realm-sync-base';
9
+ import {
10
+ classifyLocal,
11
+ classifyRemote,
12
+ type SideStatus,
13
+ } from '../../lib/sync-logic';
14
+ import {
15
+ computeFileHash,
16
+ isValidManifest,
17
+ loadManifest,
18
+ saveManifest,
19
+ pathExists,
20
+ type SyncManifest,
21
+ } from '../../lib/sync-manifest';
22
+ import type { ProfileManager } from '../../lib/profile-manager';
23
+ import type { RealmAuthenticator } from '../../lib/realm-authenticator';
24
+ import { resolveRealmAuthenticator } from '../../lib/auth-resolver';
25
+ import { resolveRealmSecretSeed } from '../../lib/prompt';
26
+ import {
27
+ FG_GREEN,
28
+ FG_YELLOW,
29
+ FG_CYAN,
30
+ FG_RED,
31
+ DIM,
32
+ RESET,
33
+ } from '../../lib/colors';
34
+
35
+ export type StatusFileState =
36
+ | 'new-remote'
37
+ | 'modified-remote'
38
+ | 'new-local'
39
+ | 'modified-local'
40
+ | 'conflict'
41
+ | 'deleted-local'
42
+ | 'deleted-remote';
43
+
44
+ export interface StatusEntry {
45
+ file: string;
46
+ status: StatusFileState;
47
+ }
48
+
49
+ export interface StatusResult {
50
+ localDir: string;
51
+ realmUrl: string;
52
+ manifestMtime?: number;
53
+ changes: StatusEntry[];
54
+ pulled: string[];
55
+ inSync: boolean;
56
+ hasError: boolean;
57
+ error?: string;
58
+ }
59
+
60
+ export interface StatusAllEntry extends StatusResult {
61
+ skipped?: 'no-manifest' | 'malformed' | 'fetch-failed';
62
+ }
63
+
64
+ export interface StatusAllResult {
65
+ rootDir: string;
66
+ workspaces: StatusAllEntry[];
67
+ hasError: boolean;
68
+ error?: string;
69
+ }
70
+
71
+ export interface StatusCommandOptions {
72
+ pull?: boolean;
73
+ all?: boolean;
74
+ profileManager?: ProfileManager;
75
+ realmSecretSeed?: string;
76
+ authenticator?: RealmAuthenticator;
77
+ }
78
+
79
+ interface StatusInspectorOptions extends SyncOptions {
80
+ pull?: boolean;
81
+ }
82
+
83
+ const ALL_IGNORED_DIRS = new Set([
84
+ 'node_modules',
85
+ '.git',
86
+ '.boxel-history',
87
+ '.cache',
88
+ '.vscode',
89
+ 'dist',
90
+ 'build',
91
+ 'tmp',
92
+ ]);
93
+
94
+ const DEFAULT_MAX_DEPTH = 6;
95
+
96
+ function mapSideStatusToUserStatus(
97
+ local: SideStatus,
98
+ remote: SideStatus,
99
+ ): StatusFileState | null {
100
+ if (local === 'unchanged' && remote === 'unchanged') return null;
101
+ if (local === 'deleted' && remote === 'deleted') return null;
102
+
103
+ if (local === 'unchanged' && remote === 'added') return 'new-remote';
104
+ if (local === 'unchanged' && remote === 'changed') return 'modified-remote';
105
+ if (local === 'unchanged' && remote === 'deleted') return 'deleted-remote';
106
+
107
+ if (local === 'added' && remote === 'unchanged') return 'new-local';
108
+ if (local === 'changed' && remote === 'unchanged') return 'modified-local';
109
+ if (local === 'deleted' && remote === 'unchanged') return 'deleted-local';
110
+
111
+ if (local === 'changed' && remote === 'changed') return 'conflict';
112
+ if (local === 'added' && remote === 'added') return 'conflict';
113
+ if (local === 'changed' && remote === 'added') return 'conflict';
114
+ if (local === 'added' && remote === 'changed') return 'conflict';
115
+ if (local === 'changed' && remote === 'deleted') return 'conflict';
116
+ if (local === 'deleted' && remote === 'changed') return 'conflict';
117
+
118
+ // Defensive cross-states (unlikely in practice)
119
+ if (local === 'added' && remote === 'deleted') return 'new-local';
120
+ if (local === 'deleted' && remote === 'added') return 'new-remote';
121
+
122
+ return null;
123
+ }
124
+
125
+ class RealmStatusInspector extends RealmSyncBase {
126
+ changes: StatusEntry[] = [];
127
+ pulled: string[] = [];
128
+ hasError = false;
129
+ error?: string;
130
+ remoteMtimes: Map<string, number> = new Map();
131
+
132
+ constructor(
133
+ private statusOptions: StatusInspectorOptions,
134
+ private loadedManifest: SyncManifest,
135
+ authenticator: RealmAuthenticator,
136
+ ) {
137
+ super(statusOptions, authenticator);
138
+ }
139
+
140
+ async sync(): Promise<void> {
141
+ let localFilesWithMtimes;
142
+ let remoteFileList: Map<string, boolean> | undefined;
143
+ try {
144
+ [localFilesWithMtimes, this.remoteMtimes, remoteFileList] =
145
+ await Promise.all([
146
+ this.getLocalFileListWithMtimes(),
147
+ this.getRemoteMtimes(),
148
+ this.getRemoteFileList(),
149
+ ]);
150
+ } catch (err) {
151
+ this.hasError = true;
152
+ this.error =
153
+ err instanceof Error
154
+ ? `Failed to fetch realm state: ${err.message}`
155
+ : `Failed to fetch realm state: ${String(err)}`;
156
+ return;
157
+ }
158
+
159
+ // Fall back to directory listing when `_mtimes` is unavailable, so
160
+ // remote-existing files don't get classified as `deleted-remote`.
161
+ // Mirrors `sync.ts`. The placeholder mtime (0) lands them in
162
+ // `classifyRemote`'s "known in manifest.files → changed" branch,
163
+ // which we render as `modified-remote` — noisy but visible, vs.
164
+ // silently misreporting deletions.
165
+ if (
166
+ remoteFileList &&
167
+ this.remoteMtimes.size === 0 &&
168
+ remoteFileList.size > 0
169
+ ) {
170
+ for (const [filePath] of remoteFileList) {
171
+ this.remoteMtimes.set(filePath, 0);
172
+ }
173
+ }
174
+
175
+ const localFiles = new Map<string, string>();
176
+ for (const [rel, info] of localFilesWithMtimes) {
177
+ localFiles.set(rel, info.path);
178
+ }
179
+
180
+ const localHashes = new Map<string, string>();
181
+ await Promise.all(
182
+ Array.from(localFiles.entries()).map(async ([rel, absPath]) => {
183
+ if (!isProtectedFile(rel)) {
184
+ localHashes.set(rel, await computeFileHash(absPath));
185
+ }
186
+ }),
187
+ );
188
+
189
+ const allPaths = new Set<string>();
190
+ for (const p of localFiles.keys()) allPaths.add(p);
191
+ for (const p of this.remoteMtimes.keys()) allPaths.add(p);
192
+ for (const p of Object.keys(this.loadedManifest.files)) allPaths.add(p);
193
+ if (this.loadedManifest.remoteMtimes) {
194
+ for (const p of Object.keys(this.loadedManifest.remoteMtimes))
195
+ allPaths.add(p);
196
+ }
197
+
198
+ for (const relativePath of allPaths) {
199
+ if (isProtectedFile(relativePath)) continue;
200
+ const localStatus = classifyLocal(
201
+ relativePath,
202
+ localHashes,
203
+ this.loadedManifest,
204
+ );
205
+ const remoteStatus = classifyRemote(
206
+ relativePath,
207
+ this.remoteMtimes,
208
+ this.loadedManifest,
209
+ );
210
+ const userStatus = mapSideStatusToUserStatus(localStatus, remoteStatus);
211
+ if (userStatus !== null) {
212
+ this.changes.push({ file: relativePath, status: userStatus });
213
+ }
214
+ }
215
+ this.changes.sort((a, b) => a.file.localeCompare(b.file));
216
+
217
+ if (this.statusOptions.pull) {
218
+ await this.performSafePull();
219
+ }
220
+ }
221
+
222
+ private async performSafePull(): Promise<void> {
223
+ const safe = this.changes.filter(
224
+ (c) => c.status === 'new-remote' || c.status === 'modified-remote',
225
+ );
226
+ if (safe.length === 0) {
227
+ return;
228
+ }
229
+
230
+ const failures: Array<{ file: string; message: string }> = [];
231
+ for (const change of safe) {
232
+ const localPath = path.join(this.options.localDir, change.file);
233
+ try {
234
+ await this.downloadFile(change.file, localPath);
235
+ this.pulled.push(change.file);
236
+ const newHash = await computeFileHash(localPath);
237
+ this.loadedManifest.files[change.file] = newHash;
238
+ const mtime = this.remoteMtimes.get(change.file);
239
+ if (mtime !== undefined) {
240
+ this.loadedManifest.remoteMtimes =
241
+ this.loadedManifest.remoteMtimes ?? {};
242
+ this.loadedManifest.remoteMtimes[change.file] = mtime;
243
+ }
244
+ } catch (err) {
245
+ this.hasError = true;
246
+ const msg = err instanceof Error ? err.message : String(err);
247
+ failures.push({ file: change.file, message: msg });
248
+ console.error(` ${FG_RED}✗ ${change.file}${RESET} (${msg})`);
249
+ }
250
+ }
251
+
252
+ if (failures.length > 0) {
253
+ this.error = `Failed to pull ${failures.length} file(s): ${failures
254
+ .map((f) => `${f.file} (${f.message})`)
255
+ .join('; ')}`;
256
+ }
257
+
258
+ if (this.pulled.length > 0) {
259
+ await saveManifest(this.options.localDir, this.loadedManifest);
260
+ }
261
+ }
262
+ }
263
+
264
+ export async function status(
265
+ localDir: string,
266
+ options: StatusCommandOptions,
267
+ ): Promise<StatusResult> {
268
+ const baseResult: StatusResult = {
269
+ localDir,
270
+ realmUrl: '',
271
+ changes: [],
272
+ pulled: [],
273
+ inSync: false,
274
+ hasError: false,
275
+ };
276
+
277
+ const manifestPath = path.join(localDir, '.boxel-sync.json');
278
+ if (!(await pathExists(manifestPath))) {
279
+ return {
280
+ ...baseResult,
281
+ hasError: true,
282
+ error: `No .boxel-sync.json found in ${localDir}. Run: boxel realm sync ${localDir} <realm-url>`,
283
+ };
284
+ }
285
+
286
+ const manifest = await loadManifest(localDir);
287
+ if (!manifest) {
288
+ return {
289
+ ...baseResult,
290
+ hasError: true,
291
+ error: `Malformed .boxel-sync.json in ${localDir}`,
292
+ };
293
+ }
294
+
295
+ let manifestMtime: number | undefined;
296
+ try {
297
+ manifestMtime = (await fs.stat(manifestPath)).mtimeMs;
298
+ } catch {
299
+ // best-effort only
300
+ }
301
+
302
+ let authenticator: RealmAuthenticator;
303
+ if (options.authenticator) {
304
+ authenticator = options.authenticator;
305
+ } else {
306
+ const resolution = resolveRealmAuthenticator({
307
+ realmUrl: manifest.realmUrl,
308
+ realmSecretSeed: options.realmSecretSeed,
309
+ profileManager: options.profileManager,
310
+ });
311
+ if (!resolution.ok) {
312
+ return {
313
+ ...baseResult,
314
+ realmUrl: manifest.realmUrl,
315
+ manifestMtime,
316
+ hasError: true,
317
+ error: resolution.error,
318
+ };
319
+ }
320
+ authenticator = resolution.authenticator;
321
+ }
322
+
323
+ const inspector = new RealmStatusInspector(
324
+ {
325
+ realmUrl: manifest.realmUrl,
326
+ localDir,
327
+ pull: options.pull,
328
+ },
329
+ manifest,
330
+ authenticator,
331
+ );
332
+ await inspector.sync();
333
+
334
+ return {
335
+ localDir,
336
+ realmUrl: manifest.realmUrl,
337
+ manifestMtime,
338
+ changes: inspector.changes,
339
+ pulled: inspector.pulled.slice().sort(),
340
+ inSync: !inspector.hasError && inspector.changes.length === 0,
341
+ hasError: inspector.hasError,
342
+ error: inspector.error,
343
+ };
344
+ }
345
+
346
+ async function findSyncDirs(root: string, maxDepth: number): Promise<string[]> {
347
+ const found: string[] = [];
348
+
349
+ async function walk(dir: string, depth: number): Promise<void> {
350
+ if (depth > maxDepth) return;
351
+ let entries: import('fs').Dirent[];
352
+ try {
353
+ entries = (await fs.readdir(dir, {
354
+ withFileTypes: true,
355
+ })) as import('fs').Dirent[];
356
+ } catch {
357
+ return;
358
+ }
359
+
360
+ const hasManifest = entries.some(
361
+ (e) => e.isFile() && e.name === '.boxel-sync.json',
362
+ );
363
+ if (hasManifest) {
364
+ found.push(dir);
365
+ return;
366
+ }
367
+
368
+ for (const entry of entries) {
369
+ if (!entry.isDirectory()) continue;
370
+ if (ALL_IGNORED_DIRS.has(entry.name)) continue;
371
+ await walk(path.join(dir, entry.name), depth + 1);
372
+ }
373
+ }
374
+
375
+ await walk(root, 0);
376
+ found.sort();
377
+ return found;
378
+ }
379
+
380
+ export async function statusAll(
381
+ rootDir: string,
382
+ options: StatusCommandOptions,
383
+ ): Promise<StatusAllResult> {
384
+ if (options.pull) {
385
+ return {
386
+ rootDir,
387
+ workspaces: [],
388
+ hasError: true,
389
+ error: 'Cannot use --pull with --all',
390
+ };
391
+ }
392
+
393
+ const envDepth = process.env.BOXEL_STATUS_ALL_MAX_DEPTH;
394
+ const parsedDepth = envDepth !== undefined ? Number(envDepth) : NaN;
395
+ const maxDepth =
396
+ Number.isFinite(parsedDepth) && parsedDepth >= 0
397
+ ? parsedDepth
398
+ : DEFAULT_MAX_DEPTH;
399
+ const dirs = await findSyncDirs(rootDir, maxDepth);
400
+
401
+ const workspaces: StatusAllEntry[] = [];
402
+ let hasError = false;
403
+
404
+ for (const dir of dirs) {
405
+ const manifestPath = path.join(dir, '.boxel-sync.json');
406
+ let rawContent: string;
407
+ try {
408
+ rawContent = await fs.readFile(manifestPath, 'utf8');
409
+ } catch {
410
+ workspaces.push({
411
+ localDir: dir,
412
+ realmUrl: '',
413
+ changes: [],
414
+ pulled: [],
415
+ inSync: false,
416
+ hasError: true,
417
+ skipped: 'no-manifest',
418
+ });
419
+ hasError = true;
420
+ continue;
421
+ }
422
+
423
+ let parsed: unknown;
424
+ try {
425
+ parsed = JSON.parse(rawContent);
426
+ } catch {
427
+ parsed = undefined;
428
+ }
429
+ if (!isValidManifest(parsed)) {
430
+ workspaces.push({
431
+ localDir: dir,
432
+ realmUrl: '',
433
+ changes: [],
434
+ pulled: [],
435
+ inSync: false,
436
+ hasError: true,
437
+ skipped: 'malformed',
438
+ });
439
+ hasError = true;
440
+ continue;
441
+ }
442
+
443
+ const result = await status(dir, {
444
+ profileManager: options.profileManager,
445
+ realmSecretSeed: options.realmSecretSeed,
446
+ authenticator: options.authenticator,
447
+ });
448
+ const entry: StatusAllEntry = { ...result };
449
+ if (result.hasError) {
450
+ entry.skipped = 'fetch-failed';
451
+ hasError = true;
452
+ }
453
+ workspaces.push(entry);
454
+ }
455
+
456
+ return { rootDir, workspaces, hasError };
457
+ }
458
+
459
+ function renderStatus(result: StatusResult): void {
460
+ if (result.hasError && result.error) {
461
+ console.error(`${FG_RED}Error:${RESET} ${result.error}`);
462
+ return;
463
+ }
464
+
465
+ console.log(`Realm: ${result.realmUrl}`);
466
+ console.log(`Local: ${result.localDir}`);
467
+ if (result.manifestMtime) {
468
+ console.log(
469
+ `${DIM}Manifest updated:${RESET} ${new Date(result.manifestMtime).toISOString()}`,
470
+ );
471
+ }
472
+ console.log('');
473
+
474
+ if (result.changes.length === 0) {
475
+ console.log(`${FG_GREEN}✓ In sync${RESET}`);
476
+ return;
477
+ }
478
+
479
+ const buckets: Record<StatusFileState, string[]> = {
480
+ 'new-remote': [],
481
+ 'modified-remote': [],
482
+ 'new-local': [],
483
+ 'modified-local': [],
484
+ conflict: [],
485
+ 'deleted-local': [],
486
+ 'deleted-remote': [],
487
+ };
488
+ for (const c of result.changes) buckets[c.status].push(c.file);
489
+
490
+ if (buckets['new-remote'].length > 0) {
491
+ console.log(
492
+ `${FG_CYAN}↓ New on remote (${buckets['new-remote'].length}):${RESET}`,
493
+ );
494
+ for (const f of buckets['new-remote']) console.log(` + ${f}`);
495
+ console.log('');
496
+ }
497
+ if (buckets['modified-remote'].length > 0) {
498
+ console.log(
499
+ `${FG_CYAN}↓ Modified on remote (${buckets['modified-remote'].length}):${RESET}`,
500
+ );
501
+ for (const f of buckets['modified-remote']) console.log(` ~ ${f}`);
502
+ console.log('');
503
+ }
504
+ if (buckets['new-local'].length > 0) {
505
+ console.log(
506
+ `${FG_GREEN}↑ New locally (${buckets['new-local'].length}):${RESET}`,
507
+ );
508
+ for (const f of buckets['new-local']) console.log(` + ${f}`);
509
+ console.log('');
510
+ }
511
+ if (buckets['modified-local'].length > 0) {
512
+ console.log(
513
+ `${FG_GREEN}↑ Modified locally (${buckets['modified-local'].length}):${RESET}`,
514
+ );
515
+ for (const f of buckets['modified-local']) console.log(` ~ ${f}`);
516
+ console.log('');
517
+ }
518
+ if (buckets.conflict.length > 0) {
519
+ console.log(
520
+ `${FG_YELLOW}⚠ Conflicts (${buckets.conflict.length}):${RESET}`,
521
+ );
522
+ for (const f of buckets.conflict) console.log(` ! ${f}`);
523
+ console.log('');
524
+ }
525
+ if (buckets['deleted-local'].length > 0) {
526
+ console.log(
527
+ `${FG_RED}- Deleted locally (${buckets['deleted-local'].length}):${RESET}`,
528
+ );
529
+ for (const f of buckets['deleted-local']) console.log(` - ${f}`);
530
+ console.log('');
531
+ }
532
+ if (buckets['deleted-remote'].length > 0) {
533
+ console.log(
534
+ `${FG_RED}- Deleted on remote (${buckets['deleted-remote'].length}):${RESET}`,
535
+ );
536
+ for (const f of buckets['deleted-remote']) console.log(` - ${f}`);
537
+ console.log('');
538
+ }
539
+
540
+ if (result.pulled.length > 0) {
541
+ console.log(`${FG_CYAN}Pulled ${result.pulled.length} file(s):${RESET}`);
542
+ for (const f of result.pulled) console.log(` ✓ ${f}`);
543
+ }
544
+ }
545
+
546
+ function renderStatusAll(result: StatusAllResult): void {
547
+ if (result.error) {
548
+ console.error(`${FG_RED}Error:${RESET} ${result.error}`);
549
+ return;
550
+ }
551
+ if (result.workspaces.length === 0) {
552
+ console.log(
553
+ `No .boxel-sync.json directories found under ${result.rootDir}.`,
554
+ );
555
+ return;
556
+ }
557
+ for (const ws of result.workspaces) {
558
+ if (ws.skipped) {
559
+ console.log(`${FG_YELLOW}${ws.localDir}${RESET} [${ws.skipped}]`);
560
+ if (ws.error) console.log(` ${DIM}${ws.error}${RESET}`);
561
+ console.log('');
562
+ continue;
563
+ }
564
+ const counts = {
565
+ newRemote: 0,
566
+ modRemote: 0,
567
+ newLocal: 0,
568
+ modLocal: 0,
569
+ conflict: 0,
570
+ delLocal: 0,
571
+ delRemote: 0,
572
+ };
573
+ for (const c of ws.changes) {
574
+ if (c.status === 'new-remote') counts.newRemote++;
575
+ else if (c.status === 'modified-remote') counts.modRemote++;
576
+ else if (c.status === 'new-local') counts.newLocal++;
577
+ else if (c.status === 'modified-local') counts.modLocal++;
578
+ else if (c.status === 'conflict') counts.conflict++;
579
+ else if (c.status === 'deleted-local') counts.delLocal++;
580
+ else if (c.status === 'deleted-remote') counts.delRemote++;
581
+ }
582
+ console.log(`${ws.localDir} ${DIM}${ws.realmUrl}${RESET}`);
583
+ if (ws.inSync) {
584
+ console.log(` ${FG_GREEN}✓ in sync${RESET}`);
585
+ } else {
586
+ const parts: string[] = [];
587
+ if (counts.newRemote > 0)
588
+ parts.push(`${FG_CYAN}↓+${counts.newRemote}${RESET}`);
589
+ if (counts.modRemote > 0)
590
+ parts.push(`${FG_CYAN}↓~${counts.modRemote}${RESET}`);
591
+ if (counts.newLocal > 0)
592
+ parts.push(`${FG_GREEN}↑+${counts.newLocal}${RESET}`);
593
+ if (counts.modLocal > 0)
594
+ parts.push(`${FG_GREEN}↑~${counts.modLocal}${RESET}`);
595
+ if (counts.conflict > 0)
596
+ parts.push(`${FG_YELLOW}⚠${counts.conflict}${RESET}`);
597
+ if (counts.delLocal > 0)
598
+ parts.push(`${FG_RED}-L${counts.delLocal}${RESET}`);
599
+ if (counts.delRemote > 0)
600
+ parts.push(`${FG_RED}-R${counts.delRemote}${RESET}`);
601
+ console.log(` ${parts.join(' ')}`);
602
+ }
603
+ console.log('');
604
+ }
605
+ }
606
+
607
+ export function registerStatusCommand(sync: Command): void {
608
+ sync
609
+ .command('status')
610
+ .aliases(['st'])
611
+ .description('Show pending changes between a local sync dir and its realm')
612
+ .argument(
613
+ '[local-dir]',
614
+ 'Local sync directory (defaults to current working directory)',
615
+ )
616
+ .option('--pull', 'Download safe remote changes and update manifest')
617
+ .option(
618
+ '--all',
619
+ 'Recursively report all .boxel-sync.json dirs under the current directory',
620
+ )
621
+ .option(
622
+ '--realm-secret-seed',
623
+ 'Administrative auth: prompt for a realm secret seed and mint a JWT locally instead of using a Matrix profile (env: BOXEL_REALM_SECRET_SEED)',
624
+ )
625
+ .action(
626
+ async (
627
+ localDir: string | undefined,
628
+ options: {
629
+ pull?: boolean;
630
+ all?: boolean;
631
+ realmSecretSeed?: boolean;
632
+ },
633
+ ) => {
634
+ const realmSecretSeed = await resolveRealmSecretSeed(
635
+ options.realmSecretSeed === true,
636
+ );
637
+
638
+ if (options.all) {
639
+ if (options.pull) {
640
+ console.error(
641
+ `${FG_RED}Error:${RESET} Cannot use --pull with --all`,
642
+ );
643
+ process.exit(1);
644
+ }
645
+ const result = await statusAll(localDir ?? process.cwd(), {
646
+ all: true,
647
+ realmSecretSeed,
648
+ });
649
+ renderStatusAll(result);
650
+ if (result.hasError) {
651
+ process.exit(2);
652
+ }
653
+ return;
654
+ }
655
+
656
+ const result = await status(localDir ?? process.cwd(), {
657
+ pull: options.pull,
658
+ realmSecretSeed,
659
+ });
660
+ renderStatus(result);
661
+ if (result.hasError) {
662
+ // Missing/malformed manifest = config error (1).
663
+ // Pull-with-partial-failures = partial error (2).
664
+ process.exit(result.pulled.length > 0 ? 2 : 1);
665
+ }
666
+ },
667
+ );
668
+ }
@@ -520,8 +520,8 @@ export interface SyncResult {
520
520
  error?: string;
521
521
  }
522
522
 
523
- export function registerSyncCommand(realm: Command): void {
524
- realm
523
+ export function registerSyncCommand(realm: Command): Command {
524
+ const syncCmd = realm
525
525
  .command('sync')
526
526
  .description(
527
527
  'Bidirectional sync between a local directory and a Boxel realm',
@@ -578,6 +578,7 @@ export function registerSyncCommand(realm: Command): void {
578
578
  console.log('Sync completed successfully');
579
579
  },
580
580
  );
581
+ return syncCmd;
581
582
  }
582
583
 
583
584
  /**