@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
@@ -0,0 +1,626 @@
1
+ import { InvalidArgumentError, type Command } from 'commander';
2
+ import * as fs from 'fs/promises';
3
+ import * as path from 'path';
4
+ import { RealmSyncBase, isProtectedFile } from '../../lib/realm-sync-base';
5
+ import {
6
+ CheckpointManager,
7
+ type Checkpoint,
8
+ type CheckpointChange,
9
+ } from '../../lib/checkpoint-manager';
10
+ import {
11
+ type SyncManifest,
12
+ computeFileHash,
13
+ loadManifest,
14
+ saveManifest,
15
+ } from '../../lib/sync-manifest';
16
+ import type { ProfileManager } from '../../lib/profile-manager';
17
+ import type { RealmAuthenticator } from '../../lib/realm-authenticator';
18
+ import { resolveRealmAuthenticator } from '../../lib/auth-resolver';
19
+ import { resolveRealmSecretSeed } from '../../lib/prompt';
20
+ import {
21
+ acquireWatchLock,
22
+ releaseWatchLock,
23
+ type WatchLockInfo,
24
+ } from '../../lib/watch-lock';
25
+ import {
26
+ FG_CYAN,
27
+ FG_GREEN,
28
+ FG_RED,
29
+ FG_YELLOW,
30
+ DIM,
31
+ RESET,
32
+ } from '../../lib/colors';
33
+
34
+ export interface WatchRealmSpec {
35
+ realmUrl: string;
36
+ localDir: string;
37
+ }
38
+
39
+ interface PendingChange {
40
+ status: 'added' | 'modified' | 'deleted';
41
+ mtime: number;
42
+ }
43
+
44
+ export interface FlushResult {
45
+ pulled: string[];
46
+ deleted: string[];
47
+ checkpoint: Checkpoint | null;
48
+ }
49
+
50
+ /**
51
+ * Watches a single realm by polling `_mtimes`, accumulating changes between
52
+ * ticks, and applying them in a debounced batch (download + delete + write
53
+ * a checkpoint). One instance per realm; `watchRealms()` orchestrates many.
54
+ */
55
+ export class RealmWatcher extends RealmSyncBase {
56
+ readonly name: string;
57
+ private readonly debounceMs: number;
58
+ private readonly checkpointManager: CheckpointManager;
59
+ private lastKnownMtimes = new Map<string, number>();
60
+ private pendingChanges = new Map<string, PendingChange>();
61
+ private debounceTimer: NodeJS.Timeout | null = null;
62
+ private isShutdown = false;
63
+
64
+ constructor(
65
+ spec: WatchRealmSpec,
66
+ authenticator: RealmAuthenticator,
67
+ options: { debounceMs: number },
68
+ ) {
69
+ super({ realmUrl: spec.realmUrl, localDir: spec.localDir }, authenticator);
70
+ this.debounceMs = options.debounceMs;
71
+ this.checkpointManager = new CheckpointManager(spec.localDir);
72
+ this.name = deriveRealmName(this.normalizedRealmUrl);
73
+ }
74
+
75
+ /** RealmSyncBase requires `sync()`. For the watcher, run one poll+apply. */
76
+ async sync(): Promise<void> {
77
+ await this.poll();
78
+ await this.flushPending();
79
+ }
80
+
81
+ // Override: base swallows errors → empty map, which the watcher would
82
+ // read as "every file deleted" and wipe the local dir on a network blip.
83
+ protected override async getRemoteMtimes(): Promise<Map<string, number>> {
84
+ const url = `${this.normalizedRealmUrl}_mtimes`;
85
+ const response = await this.authenticator.authedRealmFetch(url, {
86
+ headers: { Accept: 'application/vnd.api+json' },
87
+ });
88
+ if (!response.ok) {
89
+ throw new Error(
90
+ `_mtimes fetch failed for ${this.normalizedRealmUrl}: ${response.status} ${response.statusText}`,
91
+ );
92
+ }
93
+ const data = (await response.json()) as {
94
+ data?: { attributes?: { mtimes?: Record<string, number> } };
95
+ };
96
+ const mtimes = new Map<string, number>();
97
+ for (const [fileUrl, mtime] of Object.entries(
98
+ data.data?.attributes?.mtimes ?? {},
99
+ )) {
100
+ mtimes.set(fileUrl.replace(this.normalizedRealmUrl, ''), mtime);
101
+ }
102
+ return mtimes;
103
+ }
104
+
105
+ get localDir(): string {
106
+ return this.options.localDir;
107
+ }
108
+
109
+ get realmUrl(): string {
110
+ return this.normalizedRealmUrl;
111
+ }
112
+
113
+ get pendingCount(): number {
114
+ return this.pendingChanges.size;
115
+ }
116
+
117
+ /**
118
+ * Verify realm access (via the throw-on-error override), ensure the
119
+ * checkpoint history is initialized, and seed `lastKnownMtimes` from the
120
+ * on-disk manifest if one exists.
121
+ */
122
+ async initialize(): Promise<void> {
123
+ await this.getRemoteMtimes();
124
+
125
+ if (!(await this.checkpointManager.isInitialized())) {
126
+ await this.checkpointManager.init();
127
+ }
128
+
129
+ const manifest = await loadManifest(this.options.localDir);
130
+ if (
131
+ manifest &&
132
+ manifest.realmUrl === this.normalizedRealmUrl &&
133
+ manifest.remoteMtimes
134
+ ) {
135
+ for (const [file, mtime] of Object.entries(manifest.remoteMtimes)) {
136
+ this.lastKnownMtimes.set(file, mtime);
137
+ }
138
+ }
139
+ }
140
+
141
+ /**
142
+ * Poll the realm once and accumulate changes into `pendingChanges`. Returns
143
+ * true if the poll discovered changes that weren't already pending.
144
+ */
145
+ async poll(): Promise<boolean> {
146
+ const remoteMtimes = await this.getRemoteMtimes();
147
+ let hasNewChanges = false;
148
+
149
+ for (const [file, mtime] of remoteMtimes) {
150
+ if (isProtectedFile(file)) continue;
151
+ const last = this.lastKnownMtimes.get(file);
152
+ if (last === undefined) {
153
+ if (this.recordPending(file, { status: 'added', mtime })) {
154
+ hasNewChanges = true;
155
+ }
156
+ } else if (mtime > last) {
157
+ if (this.recordPending(file, { status: 'modified', mtime })) {
158
+ hasNewChanges = true;
159
+ }
160
+ }
161
+ }
162
+
163
+ for (const file of this.lastKnownMtimes.keys()) {
164
+ if (isProtectedFile(file)) continue;
165
+ if (!remoteMtimes.has(file)) {
166
+ const pending = this.pendingChanges.get(file);
167
+ if (pending?.status !== 'deleted') {
168
+ this.pendingChanges.set(file, { status: 'deleted', mtime: 0 });
169
+ hasNewChanges = true;
170
+ }
171
+ }
172
+ }
173
+
174
+ return hasNewChanges;
175
+ }
176
+
177
+ /** Apply all currently pending changes immediately, bypassing the debounce. */
178
+ async flushPending(): Promise<FlushResult> {
179
+ if (this.debounceTimer) {
180
+ clearTimeout(this.debounceTimer);
181
+ this.debounceTimer = null;
182
+ }
183
+
184
+ if (this.pendingChanges.size === 0) {
185
+ return { pulled: [], deleted: [], checkpoint: null };
186
+ }
187
+
188
+ // Snapshot then clear before any await — anything an interleaved poll()
189
+ // records during this flush rolls into the next one instead of being
190
+ // dropped by a trailing clear().
191
+ const drained = new Map(this.pendingChanges);
192
+ this.pendingChanges.clear();
193
+
194
+ const pulled: string[] = [];
195
+ const deleted: string[] = [];
196
+ const changes: CheckpointChange[] = [];
197
+
198
+ for (const [file, info] of drained) {
199
+ if (info.status === 'deleted') {
200
+ const localPath = path.join(this.options.localDir, file);
201
+ try {
202
+ await fs.unlink(localPath);
203
+ } catch (err: any) {
204
+ if (err.code !== 'ENOENT') throw err;
205
+ }
206
+ deleted.push(file);
207
+ changes.push({ file, status: 'deleted' });
208
+ } else {
209
+ const localPath = path.join(this.options.localDir, file);
210
+ await this.downloadFile(file, localPath);
211
+ pulled.push(file);
212
+ changes.push({ file, status: info.status });
213
+ }
214
+ }
215
+
216
+ for (const [file, info] of drained) {
217
+ if (info.status === 'deleted') {
218
+ this.lastKnownMtimes.delete(file);
219
+ } else {
220
+ this.lastKnownMtimes.set(file, info.mtime);
221
+ }
222
+ }
223
+
224
+ await this.persistManifest(pulled, deleted);
225
+
226
+ const checkpoint = await this.checkpointManager.createCheckpoint(
227
+ 'remote',
228
+ changes,
229
+ );
230
+
231
+ return { pulled, deleted, checkpoint };
232
+ }
233
+
234
+ /**
235
+ * Schedule a debounced flush. Subsequent calls reset the timer so a burst
236
+ * of changes lands in a single checkpoint.
237
+ */
238
+ scheduleFlush(onFlush?: (result: FlushResult) => void): void {
239
+ // Closes the race where a poll() in flight at cleanup() resolves AFTER
240
+ // shutdown() and would otherwise arm a new debounceTimer that nothing
241
+ // clears — i.e. work scheduled past the watcher's lifetime.
242
+ if (this.isShutdown) return;
243
+ if (this.debounceTimer) {
244
+ clearTimeout(this.debounceTimer);
245
+ }
246
+ this.debounceTimer = setTimeout(async () => {
247
+ this.debounceTimer = null;
248
+ try {
249
+ const result = await this.flushPending();
250
+ onFlush?.(result);
251
+ } catch (err) {
252
+ console.error(
253
+ `${FG_RED}[${this.name}] Error applying changes:${RESET}`,
254
+ err,
255
+ );
256
+ }
257
+ }, this.debounceMs);
258
+ }
259
+
260
+ shutdown(): void {
261
+ // Set the flag before clearing the timer so a concurrent scheduleFlush()
262
+ // racing the in-flight poll path observes the shutdown state.
263
+ this.isShutdown = true;
264
+ if (this.debounceTimer) {
265
+ clearTimeout(this.debounceTimer);
266
+ this.debounceTimer = null;
267
+ }
268
+ }
269
+
270
+ private recordPending(file: string, change: PendingChange): boolean {
271
+ const existing = this.pendingChanges.get(file);
272
+ if (existing && existing.mtime === change.mtime) {
273
+ return false;
274
+ }
275
+ this.pendingChanges.set(file, change);
276
+ return true;
277
+ }
278
+
279
+ // Mutate just the entries that changed in this flush instead of
280
+ // rehashing everything in lastKnownMtimes — keeps each apply O(changed).
281
+ private async persistManifest(
282
+ pulled: string[],
283
+ deleted: string[],
284
+ ): Promise<void> {
285
+ const prior = await loadManifest(this.options.localDir);
286
+ const files: Record<string, string> = prior?.files
287
+ ? { ...prior.files }
288
+ : {};
289
+
290
+ for (const file of deleted) {
291
+ delete files[file];
292
+ }
293
+ for (const file of pulled) {
294
+ const localPath = path.join(this.options.localDir, file);
295
+ try {
296
+ files[file] = await computeFileHash(localPath);
297
+ } catch (err: any) {
298
+ if (err.code !== 'ENOENT') throw err;
299
+ }
300
+ }
301
+
302
+ const remoteMtimes: Record<string, number> = {};
303
+ for (const [file, mtime] of this.lastKnownMtimes) {
304
+ if (mtime !== 0) {
305
+ remoteMtimes[file] = mtime;
306
+ }
307
+ }
308
+
309
+ const manifest: SyncManifest = {
310
+ realmUrl: this.normalizedRealmUrl,
311
+ files,
312
+ };
313
+ if (Object.keys(remoteMtimes).length > 0) {
314
+ manifest.remoteMtimes = remoteMtimes;
315
+ }
316
+ await saveManifest(this.options.localDir, manifest);
317
+ }
318
+ }
319
+
320
+ export interface WatchRealmsOptions {
321
+ intervalMs?: number;
322
+ debounceMs?: number;
323
+ quiet?: boolean;
324
+ profileManager?: ProfileManager;
325
+ /** Pre-resolved realm secret seed (resolve via `resolveRealmSecretSeed` first). */
326
+ realmSecretSeed?: string;
327
+ /** @internal Test hook: supply an already-constructed authenticator. */
328
+ authenticator?: RealmAuthenticator;
329
+ /** Stops the watch loop when aborted. SIGINT/SIGTERM are wired up when omitted. */
330
+ signal?: AbortSignal;
331
+ }
332
+
333
+ export interface WatchRealmsResult {
334
+ watchers: RealmWatcher[];
335
+ error?: string;
336
+ }
337
+
338
+ /**
339
+ * Programmatic entry point. Returns when the abort signal fires (or the
340
+ * process receives SIGINT/SIGTERM when no signal is supplied). The CLI
341
+ * passes a single spec; the array shape exists for programmatic / test
342
+ * use. The authenticator is resolved once (from `specs[0].realmUrl`) and
343
+ * shared across all specs — multi-realm callers must use realms that
344
+ * share a profile / secret seed.
345
+ */
346
+ export async function watchRealms(
347
+ specs: WatchRealmSpec[],
348
+ options: WatchRealmsOptions = {},
349
+ ): Promise<WatchRealmsResult> {
350
+ if (specs.length === 0) {
351
+ return { watchers: [], error: 'No realms provided to watch.' };
352
+ }
353
+
354
+ const intervalMs = options.intervalMs ?? 30_000;
355
+ const debounceMs = options.debounceMs ?? 5_000;
356
+ const quiet = options.quiet ?? false;
357
+
358
+ if (!Number.isFinite(intervalMs) || intervalMs <= 0) {
359
+ return { watchers: [], error: '`intervalMs` must be a positive number.' };
360
+ }
361
+ if (!Number.isFinite(debounceMs) || debounceMs < 0) {
362
+ return {
363
+ watchers: [],
364
+ error: '`debounceMs` must be a non-negative number.',
365
+ };
366
+ }
367
+
368
+ let authenticator: RealmAuthenticator;
369
+ if (options.authenticator) {
370
+ authenticator = options.authenticator;
371
+ } else {
372
+ const resolution = resolveRealmAuthenticator({
373
+ realmUrl: specs[0].realmUrl,
374
+ realmSecretSeed: options.realmSecretSeed,
375
+ profileManager: options.profileManager,
376
+ });
377
+ if (!resolution.ok) {
378
+ return { watchers: [], error: resolution.error };
379
+ }
380
+ authenticator = resolution.authenticator;
381
+ }
382
+
383
+ // Acquire one lock per spec.localDir before initializing any watcher, so a
384
+ // failure rolls back all earlier locks rather than leaving them dangling.
385
+ const lockedDirs: string[] = [];
386
+ for (const spec of specs) {
387
+ const result = await acquireWatchLock(spec.localDir, spec.realmUrl);
388
+ if (!result.ok) {
389
+ for (const dir of lockedDirs) await releaseWatchLock(dir);
390
+ return {
391
+ watchers: [],
392
+ error: formatLockedError(spec.localDir, result.existing),
393
+ };
394
+ }
395
+ if (result.staleOverwrote && !quiet) {
396
+ console.log(
397
+ `${DIM}[${timestamp()}] overwrote stale lock at ${spec.localDir}${RESET}`,
398
+ );
399
+ }
400
+ lockedDirs.push(spec.localDir);
401
+ }
402
+
403
+ const watchers: RealmWatcher[] = [];
404
+ for (const spec of specs) {
405
+ const watcher = new RealmWatcher(spec, authenticator, {
406
+ debounceMs,
407
+ });
408
+ try {
409
+ await watcher.initialize();
410
+ } catch (err) {
411
+ for (const w of watchers) w.shutdown();
412
+ for (const dir of lockedDirs) await releaseWatchLock(dir);
413
+ return {
414
+ watchers: [],
415
+ error: `Failed to initialize watch on ${spec.realmUrl}: ${
416
+ err instanceof Error ? err.message : String(err)
417
+ }`,
418
+ };
419
+ }
420
+ watchers.push(watcher);
421
+ }
422
+
423
+ if (!quiet) {
424
+ console.log(
425
+ `${FG_CYAN}\u21c5 Watching ${watchers.length} realm${watchers.length > 1 ? 's' : ''}:${RESET}`,
426
+ );
427
+ for (const w of watchers) {
428
+ console.log(` ${w.name} ${DIM}\u2192${RESET} ${w.localDir}`);
429
+ }
430
+ console.log(
431
+ ` ${DIM}Interval: ${intervalMs / 1000}s, Debounce: ${debounceMs / 1000}s${RESET}`,
432
+ );
433
+ console.log(` ${DIM}Press Ctrl+C to stop${RESET}\n`);
434
+ }
435
+
436
+ const tickAll = async () => {
437
+ await Promise.all(
438
+ watchers.map(async (w) => {
439
+ try {
440
+ const hasNew = await w.poll();
441
+ if (hasNew) {
442
+ if (!quiet) {
443
+ console.log(
444
+ `${DIM}[${timestamp()}]${RESET} [${w.name}] ${FG_YELLOW}\u26a1 ${w.pendingCount} change(s) detected${RESET}`,
445
+ );
446
+ }
447
+ w.scheduleFlush((result) => {
448
+ if (!quiet) logFlush(w.name, result);
449
+ });
450
+ }
451
+ } catch (err) {
452
+ console.error(
453
+ `${FG_RED}[${w.name}] poll error:${RESET}`,
454
+ err instanceof Error ? err.message : err,
455
+ );
456
+ }
457
+ }),
458
+ );
459
+ };
460
+
461
+ // Self-scheduling tick: the next setTimeout is only armed after the
462
+ // current tickAll resolves, so two polls can never overlap.
463
+ let stopped = false;
464
+ let timeoutId: NodeJS.Timeout | null = null;
465
+ const scheduleNextTick = () => {
466
+ if (stopped) return;
467
+ timeoutId = setTimeout(async () => {
468
+ timeoutId = null;
469
+ if (stopped) return;
470
+ await tickAll();
471
+ scheduleNextTick();
472
+ }, intervalMs);
473
+ };
474
+
475
+ await tickAll();
476
+ scheduleNextTick();
477
+
478
+ await new Promise<void>((resolve) => {
479
+ let sigintHandler: (() => void) | null = null;
480
+ let sigtermHandler: (() => void) | null = null;
481
+
482
+ const cleanup = async () => {
483
+ if (stopped) return;
484
+ stopped = true;
485
+ if (timeoutId !== null) {
486
+ clearTimeout(timeoutId);
487
+ timeoutId = null;
488
+ }
489
+ for (const w of watchers) w.shutdown();
490
+ if (sigintHandler) process.off('SIGINT', sigintHandler);
491
+ if (sigtermHandler) process.off('SIGTERM', sigtermHandler);
492
+ for (const dir of lockedDirs) {
493
+ try {
494
+ await releaseWatchLock(dir);
495
+ } catch {
496
+ // Best effort \u2014 a leftover lock will be detected as stale next run.
497
+ }
498
+ }
499
+ resolve();
500
+ };
501
+
502
+ if (options.signal) {
503
+ if (options.signal.aborted) {
504
+ void cleanup();
505
+ return;
506
+ }
507
+ options.signal.addEventListener('abort', () => void cleanup(), {
508
+ once: true,
509
+ });
510
+ } else {
511
+ sigintHandler = () => {
512
+ if (!quiet) console.log(`\n${FG_CYAN}\u21c5 Watch stopped${RESET}`);
513
+ void cleanup();
514
+ };
515
+ sigtermHandler = sigintHandler;
516
+ process.on('SIGINT', sigintHandler);
517
+ process.on('SIGTERM', sigtermHandler);
518
+ }
519
+ });
520
+
521
+ return { watchers };
522
+ }
523
+
524
+ function formatLockedError(localDir: string, info: WatchLockInfo): string {
525
+ return (
526
+ `A boxel realm watch process is already active for ${localDir} ` +
527
+ `(pid ${info.pid}, started ${info.startedAt}). Stop it before starting ` +
528
+ `a new one, or remove ${path.join(localDir, '.boxel-watch.lock')} if it's stale.`
529
+ );
530
+ }
531
+
532
+ function logFlush(name: string, result: FlushResult): void {
533
+ const total = result.pulled.length + result.deleted.length;
534
+ if (total === 0) return;
535
+ console.log(
536
+ `${DIM}[${timestamp()}]${RESET} [${name}] ${FG_GREEN}applied ${total} change(s)${RESET} (${result.pulled.length} pulled, ${result.deleted.length} deleted)`,
537
+ );
538
+ if (result.checkpoint) {
539
+ const tag = result.checkpoint.isMajor ? '[MAJOR]' : '[minor]';
540
+ console.log(
541
+ ` ${DIM}Checkpoint:${RESET} ${result.checkpoint.shortHash} ${tag} ${result.checkpoint.message}`,
542
+ );
543
+ }
544
+ }
545
+
546
+ function deriveRealmName(normalizedUrl: string): string {
547
+ const parts = normalizedUrl.replace(/\/$/, '').split('/');
548
+ return parts.slice(-2).join('/');
549
+ }
550
+
551
+ function timestamp(): string {
552
+ return new Date().toLocaleTimeString();
553
+ }
554
+
555
+ function parsePositiveSeconds(name: string): (value: string) => number {
556
+ return (value: string) => {
557
+ const n = Number.parseFloat(value);
558
+ if (!Number.isFinite(n) || n <= 0) {
559
+ throw new InvalidArgumentError(`${name} must be a positive number.`);
560
+ }
561
+ return n;
562
+ };
563
+ }
564
+
565
+ function parseNonNegativeSeconds(name: string): (value: string) => number {
566
+ return (value: string) => {
567
+ const n = Number.parseFloat(value);
568
+ if (!Number.isFinite(n) || n < 0) {
569
+ throw new InvalidArgumentError(`${name} must be a non-negative number.`);
570
+ }
571
+ return n;
572
+ };
573
+ }
574
+
575
+ export function registerWatchCommand(realm: Command): void {
576
+ realm
577
+ .command('watch')
578
+ .description(
579
+ 'Watch a Boxel realm for server-side changes and pull them into a local directory',
580
+ )
581
+ .argument(
582
+ '<realm-url>',
583
+ 'The URL of the realm to watch (e.g., https://app.boxel.ai/demo/)',
584
+ )
585
+ .argument('<local-dir>', 'The local directory to write changes into')
586
+ .option(
587
+ '-i, --interval <seconds>',
588
+ 'Polling interval in seconds',
589
+ parsePositiveSeconds('--interval'),
590
+ 30,
591
+ )
592
+ .option(
593
+ '-d, --debounce <seconds>',
594
+ 'Seconds to wait after a burst of changes before applying them',
595
+ parseNonNegativeSeconds('--debounce'),
596
+ 5,
597
+ )
598
+ .option(
599
+ '--realm-secret-seed',
600
+ 'Administrative auth: prompt for a realm secret seed and mint a JWT locally instead of using a Matrix profile (env: BOXEL_REALM_SECRET_SEED)',
601
+ )
602
+ .action(
603
+ async (
604
+ realmUrl: string,
605
+ localDir: string,
606
+ options: {
607
+ interval: number;
608
+ debounce: number;
609
+ realmSecretSeed?: boolean;
610
+ },
611
+ ) => {
612
+ const realmSecretSeed = await resolveRealmSecretSeed(
613
+ options.realmSecretSeed === true,
614
+ );
615
+ const result = await watchRealms([{ realmUrl, localDir }], {
616
+ intervalMs: options.interval * 1000,
617
+ debounceMs: options.debounce * 1000,
618
+ realmSecretSeed,
619
+ });
620
+ if (result.error) {
621
+ console.error(`${FG_RED}Error:${RESET} ${result.error}`);
622
+ process.exit(1);
623
+ }
624
+ },
625
+ );
626
+ }
@@ -1,6 +1,7 @@
1
1
  import type { Command } from 'commander';
2
2
  import { getProfileManager, type ProfileManager } from '../lib/profile-manager';
3
3
  import { FG_GREEN, FG_RED, FG_CYAN, DIM, RESET } from '../lib/colors';
4
+ import { cliLog } from '../lib/cli-log';
4
5
 
5
6
  export interface RunCommandResult {
6
7
  status: 'ready' | 'error' | 'unusable';
@@ -150,7 +151,7 @@ export function registerRunCommand(program: Command): void {
150
151
  }
151
152
 
152
153
  if (opts.json) {
153
- console.log(JSON.stringify(result, null, 2));
154
+ cliLog.output(JSON.stringify(result, null, 2));
154
155
  } else {
155
156
  console.log(
156
157
  `${DIM}Status:${RESET} ${statusColor(result.status)}${result.status}${RESET}`,
@@ -158,9 +159,9 @@ export function registerRunCommand(program: Command): void {
158
159
  if (result.result) {
159
160
  console.log(`${DIM}Result:${RESET}`);
160
161
  try {
161
- console.log(JSON.stringify(JSON.parse(result.result), null, 2));
162
+ cliLog.output(JSON.stringify(JSON.parse(result.result), null, 2));
162
163
  } catch {
163
- console.log(result.result);
164
+ cliLog.output(result.result);
164
165
  }
165
166
  }
166
167
  if (result.error) {