@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.
- package/README.md +124 -0
- package/api.ts +3 -0
- package/bin/boxel.js +15 -0
- package/dist/index.js +107 -66
- package/package.json +31 -24
- package/src/commands/file/delete.ts +110 -0
- package/src/commands/file/index.ts +20 -0
- package/src/commands/file/lint.ts +235 -0
- package/src/commands/file/list.ts +121 -0
- package/src/commands/file/read.ts +113 -0
- package/src/commands/file/touch.ts +222 -0
- package/src/commands/file/write.ts +152 -0
- package/src/commands/profile.ts +199 -106
- package/src/commands/read-transpiled.ts +120 -0
- package/src/commands/realm/cancel-indexing.ts +113 -0
- package/src/commands/realm/create.ts +1 -4
- package/src/commands/realm/history.ts +388 -0
- package/src/commands/realm/index.ts +12 -0
- package/src/commands/realm/list.ts +156 -0
- package/src/commands/realm/pull.ts +51 -17
- package/src/commands/realm/push.ts +52 -16
- package/src/commands/realm/remove.ts +281 -0
- package/src/commands/realm/sync.ts +153 -60
- package/src/commands/realm/wait-for-ready.ts +120 -0
- package/src/commands/realm/watch.ts +626 -0
- package/src/commands/run-command.ts +4 -3
- package/src/commands/search.ts +160 -0
- package/src/index.ts +60 -2
- package/src/lib/auth-resolver.ts +58 -0
- package/src/lib/auth.ts +56 -12
- package/src/lib/boxel-cli-client.ts +135 -279
- package/src/lib/cli-log.ts +132 -0
- package/src/lib/colors.ts +14 -9
- package/src/lib/find-checkpoint.ts +65 -0
- package/src/lib/profile-manager.ts +49 -4
- package/src/lib/prompt.ts +133 -0
- package/src/lib/realm-authenticator.ts +12 -0
- package/src/lib/realm-sync-base.ts +47 -10
- package/src/lib/seed-auth.ts +214 -0
- package/src/lib/watch-lock.ts +81 -0
- 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
|
-
|
|
13
|
-
|
|
14
|
-
} from '../../lib/
|
|
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
|
-
|
|
57
|
+
authenticator: RealmAuthenticator,
|
|
53
58
|
) {
|
|
54
|
-
super(syncOptions,
|
|
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
|
|
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
|
|
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
|
-
|
|
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<
|
|
534
|
-
let
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
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
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
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
|
-
|
|
558
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
585
|
-
|
|
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
|
+
}
|