@cardstack/boxel-cli 0.0.1 → 0.1.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.
Files changed (42) 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 +35 -26
  6. package/src/build-program.ts +91 -0
  7. package/src/commands/file/delete.ts +110 -0
  8. package/src/commands/file/index.ts +20 -0
  9. package/src/commands/file/lint.ts +235 -0
  10. package/src/commands/file/list.ts +121 -0
  11. package/src/commands/file/read.ts +113 -0
  12. package/src/commands/file/touch.ts +222 -0
  13. package/src/commands/file/write.ts +152 -0
  14. package/src/commands/profile.ts +199 -106
  15. package/src/commands/read-transpiled.ts +120 -0
  16. package/src/commands/realm/cancel-indexing.ts +113 -0
  17. package/src/commands/realm/create.ts +1 -4
  18. package/src/commands/realm/history.ts +388 -0
  19. package/src/commands/realm/index.ts +12 -0
  20. package/src/commands/realm/list.ts +156 -0
  21. package/src/commands/realm/pull.ts +51 -17
  22. package/src/commands/realm/push.ts +79 -27
  23. package/src/commands/realm/remove.ts +281 -0
  24. package/src/commands/realm/sync.ts +160 -60
  25. package/src/commands/realm/wait-for-ready.ts +120 -0
  26. package/src/commands/realm/watch.ts +626 -0
  27. package/src/commands/run-command.ts +4 -3
  28. package/src/commands/search.ts +160 -0
  29. package/src/index.ts +16 -38
  30. package/src/lib/auth-resolver.ts +58 -0
  31. package/src/lib/auth.ts +56 -12
  32. package/src/lib/boxel-cli-client.ts +146 -279
  33. package/src/lib/cli-log.ts +132 -0
  34. package/src/lib/colors.ts +14 -9
  35. package/src/lib/find-checkpoint.ts +65 -0
  36. package/src/lib/profile-manager.ts +49 -4
  37. package/src/lib/prompt.ts +133 -0
  38. package/src/lib/realm-authenticator.ts +12 -0
  39. package/src/lib/realm-sync-base.ts +122 -16
  40. package/src/lib/seed-auth.ts +214 -0
  41. package/src/lib/watch-lock.ts +81 -0
  42. 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
  })),
@@ -490,7 +489,35 @@ export interface SyncCommandOptions {
490
489
  preferNewest?: boolean;
491
490
  delete?: boolean;
492
491
  dryRun?: boolean;
492
+ /**
493
+ * Append `?waitForIndex=true` to the `_atomic` upload so the
494
+ * realm-server returns only after the indexer has processed the
495
+ * batch. See `SyncOptions.waitForIndex` for the rationale.
496
+ */
497
+ waitForIndex?: boolean;
493
498
  profileManager?: ProfileManager;
499
+ /**
500
+ * Pre-resolved realm secret seed for administrative access. When set, the
501
+ * CLI mints a JWT locally and skips Matrix login + /_server-session +
502
+ * /_realm-auth. The `--realm-secret-seed` CLI flag is resolved via
503
+ * `resolveRealmSecretSeed` (env var or interactive prompt) before being
504
+ * passed here.
505
+ */
506
+ realmSecretSeed?: string;
507
+ /**
508
+ * @internal Test hook: supply an already-constructed authenticator.
509
+ */
510
+ authenticator?: RealmAuthenticator;
511
+ }
512
+
513
+ export interface SyncResult {
514
+ pushed: string[];
515
+ pulled: string[];
516
+ remoteDeleted: string[];
517
+ localDeleted: string[];
518
+ skippedConflicts: string[];
519
+ hasError: boolean;
520
+ error?: string;
494
521
  }
495
522
 
496
523
  export function registerSyncCommand(realm: Command): void {
@@ -509,6 +536,10 @@ export function registerSyncCommand(realm: Command): void {
509
536
  .option('--prefer-newest', 'Resolve conflicts by keeping newest version')
510
537
  .option('--delete', 'Sync deletions both ways')
511
538
  .option('--dry-run', 'Preview without making changes')
539
+ .option(
540
+ '--realm-secret-seed',
541
+ 'Administrative auth: prompt for a realm secret seed and mint a JWT locally instead of using a Matrix profile (env: BOXEL_REALM_SECRET_SEED)',
542
+ )
512
543
  .action(
513
544
  async (
514
545
  localDir: string,
@@ -519,47 +550,83 @@ export function registerSyncCommand(realm: Command): void {
519
550
  preferNewest?: boolean;
520
551
  delete?: boolean;
521
552
  dryRun?: boolean;
553
+ realmSecretSeed?: boolean;
522
554
  },
523
555
  ) => {
524
- await syncCommand(localDir, realmUrl, options);
556
+ const realmSecretSeed = await resolveRealmSecretSeed(
557
+ options.realmSecretSeed === true,
558
+ );
559
+ let result = await sync(localDir, realmUrl, {
560
+ preferLocal: options.preferLocal,
561
+ preferRemote: options.preferRemote,
562
+ preferNewest: options.preferNewest,
563
+ delete: options.delete,
564
+ dryRun: options.dryRun,
565
+ realmSecretSeed,
566
+ });
567
+ let hasPartialResults =
568
+ (Array.isArray(result.pushed) && result.pushed.length > 0) ||
569
+ (Array.isArray(result.pulled) && result.pulled.length > 0) ||
570
+ (Array.isArray(result.remoteDeleted) &&
571
+ result.remoteDeleted.length > 0) ||
572
+ (Array.isArray(result.localDeleted) &&
573
+ result.localDeleted.length > 0);
574
+ if (result.error) {
575
+ console.error(`Error: ${result.error}`);
576
+ process.exit(hasPartialResults ? 2 : 1);
577
+ }
578
+ console.log('Sync completed successfully');
525
579
  },
526
580
  );
527
581
  }
528
582
 
529
- export async function syncCommand(
583
+ /**
584
+ * Programmatic bidirectional sync. Returns a structured result instead
585
+ * of exiting the process, so callers (BoxelCLIClient, factory, tests)
586
+ * can branch on outcomes. The CLI command registration above wraps this
587
+ * and translates results into exit codes.
588
+ */
589
+ export async function sync(
530
590
  localDir: string,
531
591
  realmUrl: string,
532
592
  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);
593
+ ): Promise<SyncResult> {
594
+ let authenticator: RealmAuthenticator;
595
+ if (options.authenticator) {
596
+ authenticator = options.authenticator;
597
+ } else {
598
+ const resolution = resolveRealmAuthenticator({
599
+ realmUrl,
600
+ realmSecretSeed: options.realmSecretSeed,
601
+ profileManager: options.profileManager,
602
+ });
603
+ if (!resolution.ok) {
604
+ return emptyResult({ error: resolution.error });
605
+ }
606
+ authenticator = resolution.authenticator;
541
607
  }
542
608
 
543
- // Validate mutually exclusive strategies
544
609
  const strategies = [
545
610
  options.preferLocal,
546
611
  options.preferRemote,
547
612
  options.preferNewest,
548
613
  ].filter(Boolean);
549
614
  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);
615
+ return emptyResult({
616
+ error:
617
+ 'Only one conflict strategy can be specified (--prefer-local, --prefer-remote, or --prefer-newest).',
618
+ });
554
619
  }
555
620
 
556
621
  if (!(await pathExists(localDir))) {
557
- console.error(`Local directory does not exist: ${localDir}`);
558
- process.exit(1);
622
+ return emptyResult({
623
+ error: `Local directory does not exist: ${localDir}`,
624
+ });
559
625
  }
560
626
 
627
+ let syncer: RealmSyncer | undefined;
561
628
  try {
562
- const syncer = new RealmSyncer(
629
+ syncer = new RealmSyncer(
563
630
  {
564
631
  realmUrl,
565
632
  localDir,
@@ -568,20 +635,53 @@ export async function syncCommand(
568
635
  preferNewest: options.preferNewest,
569
636
  deleteSync: options.delete,
570
637
  dryRun: options.dryRun,
638
+ waitForIndex: options.waitForIndex,
571
639
  },
572
- pm,
640
+ authenticator,
573
641
  );
574
-
575
642
  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
643
  } catch (error) {
584
- console.error('Sync failed:', error);
585
- process.exit(1);
644
+ return {
645
+ pushed: syncer?.pushedFiles.slice().sort() ?? [],
646
+ pulled: syncer?.pulledFiles.slice().sort() ?? [],
647
+ remoteDeleted: syncer?.remoteDeletedFiles.slice().sort() ?? [],
648
+ localDeleted: syncer?.localDeletedFiles.slice().sort() ?? [],
649
+ skippedConflicts: syncer?.skippedConflicts.slice().sort() ?? [],
650
+ hasError: true,
651
+ error: `Sync failed: ${error instanceof Error ? error.message : String(error)}`,
652
+ };
586
653
  }
654
+
655
+ return {
656
+ pushed: syncer.pushedFiles.slice().sort(),
657
+ pulled: syncer.pulledFiles.slice().sort(),
658
+ remoteDeleted: syncer.remoteDeletedFiles.slice().sort(),
659
+ localDeleted: syncer.localDeletedFiles.slice().sort(),
660
+ skippedConflicts: syncer.skippedConflicts.slice().sort(),
661
+ hasError: syncer.hasError,
662
+ error: syncer.hasError ? buildSyncErrorMessage(syncer) : undefined,
663
+ };
664
+ }
665
+
666
+ function buildSyncErrorMessage(syncer: RealmSyncer): string {
667
+ let summary = [
668
+ `${syncer.pushedFiles.length} pushed`,
669
+ `${syncer.pulledFiles.length} pulled`,
670
+ `${syncer.remoteDeletedFiles.length} remote deleted`,
671
+ `${syncer.localDeletedFiles.length} local deleted`,
672
+ `${syncer.skippedConflicts.length} conflicts skipped`,
673
+ ].join(', ');
674
+
675
+ return `Sync completed with errors. ${summary}.`;
676
+ }
677
+ function emptyResult(partial: Pick<SyncResult, 'error'>): SyncResult {
678
+ return {
679
+ pushed: [],
680
+ pulled: [],
681
+ remoteDeleted: [],
682
+ localDeleted: [],
683
+ skippedConflicts: [],
684
+ hasError: true,
685
+ ...partial,
686
+ };
587
687
  }
@@ -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
+ }