@cardstack/boxel-cli 0.0.1 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/README.md +124 -0
  2. package/api.ts +3 -0
  3. package/bin/boxel.js +15 -0
  4. package/dist/index.js +107 -66
  5. package/package.json +31 -24
  6. package/src/commands/file/delete.ts +110 -0
  7. package/src/commands/file/index.ts +20 -0
  8. package/src/commands/file/lint.ts +235 -0
  9. package/src/commands/file/list.ts +121 -0
  10. package/src/commands/file/read.ts +113 -0
  11. package/src/commands/file/touch.ts +222 -0
  12. package/src/commands/file/write.ts +152 -0
  13. package/src/commands/profile.ts +199 -106
  14. package/src/commands/read-transpiled.ts +120 -0
  15. package/src/commands/realm/cancel-indexing.ts +113 -0
  16. package/src/commands/realm/create.ts +1 -4
  17. package/src/commands/realm/history.ts +388 -0
  18. package/src/commands/realm/index.ts +12 -0
  19. package/src/commands/realm/list.ts +156 -0
  20. package/src/commands/realm/pull.ts +51 -17
  21. package/src/commands/realm/push.ts +52 -16
  22. package/src/commands/realm/remove.ts +281 -0
  23. package/src/commands/realm/sync.ts +153 -60
  24. package/src/commands/realm/wait-for-ready.ts +120 -0
  25. package/src/commands/realm/watch.ts +626 -0
  26. package/src/commands/run-command.ts +4 -3
  27. package/src/commands/search.ts +160 -0
  28. package/src/index.ts +60 -2
  29. package/src/lib/auth-resolver.ts +58 -0
  30. package/src/lib/auth.ts +56 -12
  31. package/src/lib/boxel-cli-client.ts +135 -279
  32. package/src/lib/cli-log.ts +132 -0
  33. package/src/lib/colors.ts +14 -9
  34. package/src/lib/find-checkpoint.ts +65 -0
  35. package/src/lib/profile-manager.ts +49 -4
  36. package/src/lib/prompt.ts +133 -0
  37. package/src/lib/realm-authenticator.ts +12 -0
  38. package/src/lib/realm-sync-base.ts +47 -10
  39. package/src/lib/seed-auth.ts +214 -0
  40. package/src/lib/watch-lock.ts +81 -0
  41. package/LICENSE +0 -21
@@ -8,10 +8,10 @@ import {
8
8
  CheckpointManager,
9
9
  type CheckpointChange,
10
10
  } from '../../lib/checkpoint-manager';
11
- import {
12
- getProfileManager,
13
- type ProfileManager,
14
- } from '../../lib/profile-manager';
11
+ import type { ProfileManager } from '../../lib/profile-manager';
12
+ import type { RealmAuthenticator } from '../../lib/realm-authenticator';
13
+ import { resolveRealmAuthenticator } from '../../lib/auth-resolver';
14
+ import { resolveRealmSecretSeed } from '../../lib/prompt';
15
15
  import {
16
16
  type SyncManifest,
17
17
  computeFileHash,
@@ -46,12 +46,17 @@ interface BiSyncOptions extends SyncOptions {
46
46
 
47
47
  class RealmSyncer extends RealmSyncBase {
48
48
  hasError = false;
49
+ pushedFiles: string[] = [];
50
+ pulledFiles: string[] = [];
51
+ remoteDeletedFiles: string[] = [];
52
+ localDeletedFiles: string[] = [];
53
+ skippedConflicts: string[] = [];
49
54
 
50
55
  constructor(
51
56
  private syncOptions: BiSyncOptions,
52
- profileManager: ProfileManager,
57
+ authenticator: RealmAuthenticator,
53
58
  ) {
54
- super(syncOptions, profileManager);
59
+ super(syncOptions, authenticator);
55
60
  }
56
61
 
57
62
  private get conflictStrategy(): ConflictStrategy | null {
@@ -74,7 +79,7 @@ class RealmSyncer extends RealmSyncBase {
74
79
  console.error('Failed to access realm:', error);
75
80
  throw new Error(
76
81
  'Cannot proceed with sync: Authentication or access failed. ' +
77
- 'Please check your Matrix credentials and realm permissions.',
82
+ 'Please check your credentials and realm permissions.',
78
83
  );
79
84
  }
80
85
  console.log('Realm access verified');
@@ -194,7 +199,6 @@ class RealmSyncer extends RealmSyncBase {
194
199
  }
195
200
 
196
201
  // Resolve conflicts
197
- const skippedConflicts: string[] = [];
198
202
  for (const c of conflicts) {
199
203
  const resolved = resolveConflict(
200
204
  c,
@@ -219,7 +223,7 @@ class RealmSyncer extends RealmSyncBase {
219
223
  // deleted on both sides
220
224
  break;
221
225
  default:
222
- skippedConflicts.push(c.relativePath);
226
+ this.skippedConflicts.push(c.relativePath);
223
227
  break;
224
228
  }
225
229
  }
@@ -238,11 +242,11 @@ class RealmSyncer extends RealmSyncBase {
238
242
  console.log(
239
243
  ` ${FG_RED}↓ Delete local:${RESET} ${toPullDelete.length} file(s)`,
240
244
  );
241
- if (skippedConflicts.length > 0) {
245
+ if (this.skippedConflicts.length > 0) {
242
246
  console.log(
243
- ` ${FG_YELLOW}⚠ Conflicts skipped:${RESET} ${skippedConflicts.length} file(s)`,
247
+ ` ${FG_YELLOW}⚠ Conflicts skipped:${RESET} ${this.skippedConflicts.length} file(s)`,
244
248
  );
245
- for (const p of skippedConflicts) {
249
+ for (const p of this.skippedConflicts) {
246
250
  console.log(` ${p}`);
247
251
  }
248
252
  console.log(
@@ -260,7 +264,7 @@ class RealmSyncer extends RealmSyncBase {
260
264
  if (
261
265
  !this.options.dryRun &&
262
266
  !effectiveManifest &&
263
- skippedConflicts.length === 0
267
+ this.skippedConflicts.length === 0
264
268
  ) {
265
269
  // First sync with no changes needed - still write manifest
266
270
  await this.writeManifest(localHashes, remoteMtimes);
@@ -269,11 +273,6 @@ class RealmSyncer extends RealmSyncBase {
269
273
  }
270
274
 
271
275
  // 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
276
  // Downloads (pulls)
278
277
  if (toPull.length > 0) {
279
278
  console.log(`\nPulling ${toPull.length} file(s)...`);
@@ -292,7 +291,7 @@ class RealmSyncer extends RealmSyncBase {
292
291
  }),
293
292
  ),
294
293
  );
295
- pulledFiles.push(...results.filter((f): f is string => f !== null));
294
+ this.pulledFiles.push(...results.filter((f): f is string => f !== null));
296
295
  }
297
296
 
298
297
  // Uploads (pushes) via atomic
@@ -322,7 +321,7 @@ class RealmSyncer extends RealmSyncBase {
322
321
  console.error(` ${entry.path}: ${entry.title}`);
323
322
  }
324
323
  } else {
325
- pushedFiles.push(...result.succeeded);
324
+ this.pushedFiles.push(...result.succeeded);
326
325
  }
327
326
  }
328
327
 
@@ -343,7 +342,7 @@ class RealmSyncer extends RealmSyncBase {
343
342
  }),
344
343
  ),
345
344
  );
346
- remoteDeletedFiles.push(
345
+ this.remoteDeletedFiles.push(
347
346
  ...deleteResults.filter((f): f is string => f !== null),
348
347
  );
349
348
  }
@@ -367,7 +366,7 @@ class RealmSyncer extends RealmSyncBase {
367
366
  }
368
367
  }),
369
368
  );
370
- localDeletedFiles.push(
369
+ this.localDeletedFiles.push(
371
370
  ...localDeleteResults.filter((f): f is string => f !== null),
372
371
  );
373
372
  }
@@ -388,24 +387,24 @@ class RealmSyncer extends RealmSyncBase {
388
387
  updatedHashes.set(rel, hash);
389
388
  }
390
389
  // Recompute hashes for pushed files (content may have been normalized)
391
- for (const rel of pushedFiles) {
390
+ for (const rel of this.pushedFiles) {
392
391
  const absPath = localFiles.get(rel);
393
392
  if (absPath) {
394
393
  updatedHashes.set(rel, await computeFileHash(absPath));
395
394
  }
396
395
  }
397
396
  // Add hashes for pulled files (newly downloaded)
398
- for (const rel of pulledFiles) {
397
+ for (const rel of this.pulledFiles) {
399
398
  const absPath = path.join(this.options.localDir, rel);
400
399
  updatedHashes.set(rel, await computeFileHash(absPath));
401
400
  }
402
401
  // 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);
402
+ for (const rel of this.remoteDeletedFiles) updatedHashes.delete(rel);
403
+ for (const rel of this.localDeletedFiles) updatedHashes.delete(rel);
405
404
 
406
405
  // Refresh remote mtimes after pushes
407
406
  let freshMtimes = remoteMtimes;
408
- if (pushedFiles.length > 0 || remoteDeletedFiles.length > 0) {
407
+ if (this.pushedFiles.length > 0 || this.remoteDeletedFiles.length > 0) {
409
408
  try {
410
409
  freshMtimes = await this.getRemoteMtimes();
411
410
  } catch {
@@ -419,19 +418,19 @@ class RealmSyncer extends RealmSyncBase {
419
418
  // Phase 7: Checkpoint
420
419
  if (!this.options.dryRun) {
421
420
  const allChanges: CheckpointChange[] = [
422
- ...pushedFiles.map((f) => ({
421
+ ...this.pushedFiles.map((f) => ({
423
422
  file: f,
424
423
  status: 'modified' as const,
425
424
  })),
426
- ...pulledFiles.map((f) => ({
425
+ ...this.pulledFiles.map((f) => ({
427
426
  file: f,
428
427
  status: 'modified' as const,
429
428
  })),
430
- ...remoteDeletedFiles.map((f) => ({
429
+ ...this.remoteDeletedFiles.map((f) => ({
431
430
  file: f,
432
431
  status: 'deleted' as const,
433
432
  })),
434
- ...localDeletedFiles.map((f) => ({
433
+ ...this.localDeletedFiles.map((f) => ({
435
434
  file: f,
436
435
  status: 'deleted' as const,
437
436
  })),
@@ -491,6 +490,28 @@ export interface SyncCommandOptions {
491
490
  delete?: boolean;
492
491
  dryRun?: boolean;
493
492
  profileManager?: ProfileManager;
493
+ /**
494
+ * Pre-resolved realm secret seed for administrative access. When set, the
495
+ * CLI mints a JWT locally and skips Matrix login + /_server-session +
496
+ * /_realm-auth. The `--realm-secret-seed` CLI flag is resolved via
497
+ * `resolveRealmSecretSeed` (env var or interactive prompt) before being
498
+ * passed here.
499
+ */
500
+ realmSecretSeed?: string;
501
+ /**
502
+ * @internal Test hook: supply an already-constructed authenticator.
503
+ */
504
+ authenticator?: RealmAuthenticator;
505
+ }
506
+
507
+ export interface SyncResult {
508
+ pushed: string[];
509
+ pulled: string[];
510
+ remoteDeleted: string[];
511
+ localDeleted: string[];
512
+ skippedConflicts: string[];
513
+ hasError: boolean;
514
+ error?: string;
494
515
  }
495
516
 
496
517
  export function registerSyncCommand(realm: Command): void {
@@ -509,6 +530,10 @@ export function registerSyncCommand(realm: Command): void {
509
530
  .option('--prefer-newest', 'Resolve conflicts by keeping newest version')
510
531
  .option('--delete', 'Sync deletions both ways')
511
532
  .option('--dry-run', 'Preview without making changes')
533
+ .option(
534
+ '--realm-secret-seed',
535
+ 'Administrative auth: prompt for a realm secret seed and mint a JWT locally instead of using a Matrix profile (env: BOXEL_REALM_SECRET_SEED)',
536
+ )
512
537
  .action(
513
538
  async (
514
539
  localDir: string,
@@ -519,47 +544,83 @@ export function registerSyncCommand(realm: Command): void {
519
544
  preferNewest?: boolean;
520
545
  delete?: boolean;
521
546
  dryRun?: boolean;
547
+ realmSecretSeed?: boolean;
522
548
  },
523
549
  ) => {
524
- await syncCommand(localDir, realmUrl, options);
550
+ const realmSecretSeed = await resolveRealmSecretSeed(
551
+ options.realmSecretSeed === true,
552
+ );
553
+ let result = await sync(localDir, realmUrl, {
554
+ preferLocal: options.preferLocal,
555
+ preferRemote: options.preferRemote,
556
+ preferNewest: options.preferNewest,
557
+ delete: options.delete,
558
+ dryRun: options.dryRun,
559
+ realmSecretSeed,
560
+ });
561
+ let hasPartialResults =
562
+ (Array.isArray(result.pushed) && result.pushed.length > 0) ||
563
+ (Array.isArray(result.pulled) && result.pulled.length > 0) ||
564
+ (Array.isArray(result.remoteDeleted) &&
565
+ result.remoteDeleted.length > 0) ||
566
+ (Array.isArray(result.localDeleted) &&
567
+ result.localDeleted.length > 0);
568
+ if (result.error) {
569
+ console.error(`Error: ${result.error}`);
570
+ process.exit(hasPartialResults ? 2 : 1);
571
+ }
572
+ console.log('Sync completed successfully');
525
573
  },
526
574
  );
527
575
  }
528
576
 
529
- export async function syncCommand(
577
+ /**
578
+ * Programmatic bidirectional sync. Returns a structured result instead
579
+ * of exiting the process, so callers (BoxelCLIClient, factory, tests)
580
+ * can branch on outcomes. The CLI command registration above wraps this
581
+ * and translates results into exit codes.
582
+ */
583
+ export async function sync(
530
584
  localDir: string,
531
585
  realmUrl: string,
532
586
  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);
587
+ ): Promise<SyncResult> {
588
+ let authenticator: RealmAuthenticator;
589
+ if (options.authenticator) {
590
+ authenticator = options.authenticator;
591
+ } else {
592
+ const resolution = resolveRealmAuthenticator({
593
+ realmUrl,
594
+ realmSecretSeed: options.realmSecretSeed,
595
+ profileManager: options.profileManager,
596
+ });
597
+ if (!resolution.ok) {
598
+ return emptyResult({ error: resolution.error });
599
+ }
600
+ authenticator = resolution.authenticator;
541
601
  }
542
602
 
543
- // Validate mutually exclusive strategies
544
603
  const strategies = [
545
604
  options.preferLocal,
546
605
  options.preferRemote,
547
606
  options.preferNewest,
548
607
  ].filter(Boolean);
549
608
  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);
609
+ return emptyResult({
610
+ error:
611
+ 'Only one conflict strategy can be specified (--prefer-local, --prefer-remote, or --prefer-newest).',
612
+ });
554
613
  }
555
614
 
556
615
  if (!(await pathExists(localDir))) {
557
- console.error(`Local directory does not exist: ${localDir}`);
558
- process.exit(1);
616
+ return emptyResult({
617
+ error: `Local directory does not exist: ${localDir}`,
618
+ });
559
619
  }
560
620
 
621
+ let syncer: RealmSyncer | undefined;
561
622
  try {
562
- const syncer = new RealmSyncer(
623
+ syncer = new RealmSyncer(
563
624
  {
564
625
  realmUrl,
565
626
  localDir,
@@ -569,19 +630,51 @@ export async function syncCommand(
569
630
  deleteSync: options.delete,
570
631
  dryRun: options.dryRun,
571
632
  },
572
- pm,
633
+ authenticator,
573
634
  );
574
-
575
635
  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
636
  } catch (error) {
584
- console.error('Sync failed:', error);
585
- process.exit(1);
637
+ return {
638
+ pushed: syncer?.pushedFiles.slice().sort() ?? [],
639
+ pulled: syncer?.pulledFiles.slice().sort() ?? [],
640
+ remoteDeleted: syncer?.remoteDeletedFiles.slice().sort() ?? [],
641
+ localDeleted: syncer?.localDeletedFiles.slice().sort() ?? [],
642
+ skippedConflicts: syncer?.skippedConflicts.slice().sort() ?? [],
643
+ hasError: true,
644
+ error: `Sync failed: ${error instanceof Error ? error.message : String(error)}`,
645
+ };
586
646
  }
647
+
648
+ return {
649
+ pushed: syncer.pushedFiles.slice().sort(),
650
+ pulled: syncer.pulledFiles.slice().sort(),
651
+ remoteDeleted: syncer.remoteDeletedFiles.slice().sort(),
652
+ localDeleted: syncer.localDeletedFiles.slice().sort(),
653
+ skippedConflicts: syncer.skippedConflicts.slice().sort(),
654
+ hasError: syncer.hasError,
655
+ error: syncer.hasError ? buildSyncErrorMessage(syncer) : undefined,
656
+ };
657
+ }
658
+
659
+ function buildSyncErrorMessage(syncer: RealmSyncer): string {
660
+ let summary = [
661
+ `${syncer.pushedFiles.length} pushed`,
662
+ `${syncer.pulledFiles.length} pulled`,
663
+ `${syncer.remoteDeletedFiles.length} remote deleted`,
664
+ `${syncer.localDeletedFiles.length} local deleted`,
665
+ `${syncer.skippedConflicts.length} conflicts skipped`,
666
+ ].join(', ');
667
+
668
+ return `Sync completed with errors. ${summary}.`;
669
+ }
670
+ function emptyResult(partial: Pick<SyncResult, 'error'>): SyncResult {
671
+ return {
672
+ pushed: [],
673
+ pulled: [],
674
+ remoteDeleted: [],
675
+ localDeleted: [],
676
+ skippedConflicts: [],
677
+ hasError: true,
678
+ ...partial,
679
+ };
587
680
  }
@@ -0,0 +1,120 @@
1
+ import { InvalidArgumentError, type Command } from 'commander';
2
+ import {
3
+ getProfileManager,
4
+ NO_ACTIVE_PROFILE_ERROR,
5
+ type ProfileManager,
6
+ } from '../../lib/profile-manager';
7
+ import { ensureTrailingSlash } from '@cardstack/runtime-common/paths';
8
+ import { SupportedMimeType } from '@cardstack/runtime-common/supported-mime-type';
9
+ import { FG_GREEN, FG_RED, RESET } from '../../lib/colors';
10
+
11
+ export interface WaitForReadyResult {
12
+ ready: boolean;
13
+ error?: string;
14
+ }
15
+
16
+ export interface WaitForReadyCommandOptions {
17
+ timeoutMs?: number;
18
+ profileManager?: ProfileManager;
19
+ }
20
+
21
+ interface WaitForReadyCliOptions {
22
+ realm: string;
23
+ timeout?: number;
24
+ }
25
+
26
+ function parseTimeoutOption(value: string): number {
27
+ let n = Number.parseInt(value, 10);
28
+ if (!Number.isFinite(n) || n < 0 || String(n) !== value.trim()) {
29
+ throw new InvalidArgumentError(
30
+ '--timeout must be a non-negative integer (milliseconds).',
31
+ );
32
+ }
33
+ return n;
34
+ }
35
+
36
+ /**
37
+ * Poll a realm's `_readiness-check` endpoint until it responds OK or the
38
+ * timeout is reached.
39
+ *
40
+ * Uses the per-realm JWT via `ProfileManager.authedRealmFetch`.
41
+ */
42
+ export async function waitForReady(
43
+ realmUrl: string,
44
+ options: WaitForReadyCommandOptions = {},
45
+ ): Promise<WaitForReadyResult> {
46
+ let timeoutMs = options.timeoutMs ?? 30_000;
47
+ if (!Number.isFinite(timeoutMs) || timeoutMs < 0) {
48
+ return {
49
+ ready: false,
50
+ error: `Invalid timeoutMs: must be a finite, non-negative number (got ${options.timeoutMs}).`,
51
+ };
52
+ }
53
+ let pm = options.profileManager ?? getProfileManager();
54
+ let active = pm.getActiveProfile();
55
+ if (!active) {
56
+ return {
57
+ ready: false,
58
+ error: NO_ACTIVE_PROFILE_ERROR,
59
+ };
60
+ }
61
+
62
+ let readinessUrl = `${ensureTrailingSlash(realmUrl)}_readiness-check`;
63
+ let startedAt = Date.now();
64
+
65
+ while (Date.now() - startedAt < timeoutMs) {
66
+ try {
67
+ let response = await pm.authedRealmFetch(readinessUrl, {
68
+ method: 'GET',
69
+ headers: { Accept: SupportedMimeType.RealmInfo },
70
+ });
71
+ if (response.ok) {
72
+ return { ready: true };
73
+ }
74
+ } catch {
75
+ // retry
76
+ }
77
+ let remaining = timeoutMs - (Date.now() - startedAt);
78
+ if (remaining <= 0) break;
79
+ await new Promise((r) => setTimeout(r, Math.min(1000, remaining)));
80
+ }
81
+
82
+ return {
83
+ ready: false,
84
+ error: `Realm not ready after ${timeoutMs}ms: ${readinessUrl}`,
85
+ };
86
+ }
87
+
88
+ export function registerWaitForReadyCommand(realm: Command): void {
89
+ realm
90
+ .command('wait-for-ready')
91
+ .description(
92
+ 'Poll a realm readiness-check endpoint until it responds OK or the timeout is reached',
93
+ )
94
+ .requiredOption('--realm <realm-url>', 'The realm URL to check')
95
+ .option(
96
+ '--timeout <ms>',
97
+ 'Timeout in milliseconds (default: 30000)',
98
+ parseTimeoutOption,
99
+ )
100
+ .action(async (opts: WaitForReadyCliOptions) => {
101
+ let result: WaitForReadyResult;
102
+ try {
103
+ result = await waitForReady(opts.realm, { timeoutMs: opts.timeout });
104
+ } catch (err) {
105
+ console.error(
106
+ `${FG_RED}Error:${RESET} ${err instanceof Error ? err.message : String(err)}`,
107
+ );
108
+ process.exit(1);
109
+ }
110
+
111
+ if (result.ready) {
112
+ console.log(`${FG_GREEN}Realm is ready.${RESET}`);
113
+ } else {
114
+ console.error(
115
+ `${FG_RED}Error:${RESET} ${result.error ?? 'Realm not ready'}`,
116
+ );
117
+ process.exit(1);
118
+ }
119
+ });
120
+ }