@cardstack/boxel-cli 0.2.0-unstable.425 → 0.2.0-unstable.448
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/dist/index.js +131 -115
- package/package.json +2 -2
- package/src/commands/file/read.ts +40 -5
- package/src/commands/file/write.ts +58 -10
- package/src/commands/profile.ts +24 -24
- package/src/commands/realm/push.ts +23 -12
- package/src/commands/realm/sync.ts +10 -4
- package/src/lib/auth.ts +47 -5
- package/src/lib/profile-manager.ts +235 -109
- package/src/lib/realm-sync-base.ts +212 -21
|
@@ -3,6 +3,7 @@ import * as fs from 'fs/promises';
|
|
|
3
3
|
import * as path from 'path';
|
|
4
4
|
import ignoreModule from 'ignore';
|
|
5
5
|
import pLimit from 'p-limit';
|
|
6
|
+
import { isBinaryFilename } from '@cardstack/runtime-common/infer-content-type';
|
|
6
7
|
|
|
7
8
|
const ignore = (ignoreModule as any).default || ignoreModule;
|
|
8
9
|
type Ignore = ReturnType<typeof ignoreModule>;
|
|
@@ -42,10 +43,38 @@ function decodeAtomicResultId(id: string): string {
|
|
|
42
43
|
}
|
|
43
44
|
}
|
|
44
45
|
|
|
46
|
+
// Builds a structured upload error: the message embeds the response
|
|
47
|
+
// status + statusText + a snippet of the response body (the realm
|
|
48
|
+
// returns useful detail there — size limits, missing scopes, etc.),
|
|
49
|
+
// and a `status` property is attached so the batch helper can route
|
|
50
|
+
// the failure without re-parsing the message.
|
|
51
|
+
async function throwUploadError(
|
|
52
|
+
response: Response,
|
|
53
|
+
relativePath: string,
|
|
54
|
+
): Promise<never> {
|
|
55
|
+
const bodyText = await response.text().catch(() => '');
|
|
56
|
+
const message = `Failed to upload ${relativePath}: ${response.status} ${response.statusText}${
|
|
57
|
+
bodyText ? ` — ${bodyText.slice(0, 200)}` : ''
|
|
58
|
+
}`;
|
|
59
|
+
const err = new Error(message) as Error & {
|
|
60
|
+
status?: number;
|
|
61
|
+
body?: string;
|
|
62
|
+
};
|
|
63
|
+
err.status = response.status;
|
|
64
|
+
err.body = bodyText;
|
|
65
|
+
throw err;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Shared shape for per-file upload errors that need to bubble back to
|
|
69
|
+
// callers (push.ts / sync.ts) so they can format hints and decide which
|
|
70
|
+
// successes to persist alongside the failures.
|
|
71
|
+
type UploadFailure = { path: string; status: number; title: string };
|
|
72
|
+
|
|
45
73
|
export const SupportedMimeType = {
|
|
46
74
|
CardSource: 'application/vnd.card+source',
|
|
47
75
|
DirectoryListing: 'application/vnd.api+json',
|
|
48
76
|
Mtimes: 'application/vnd.api+json',
|
|
77
|
+
OctetStream: 'application/octet-stream',
|
|
49
78
|
} as const;
|
|
50
79
|
|
|
51
80
|
export interface SyncOptions {
|
|
@@ -396,6 +425,12 @@ export abstract class RealmSyncBase {
|
|
|
396
425
|
return;
|
|
397
426
|
}
|
|
398
427
|
|
|
428
|
+
if (isBinaryFilename(relativePath)) {
|
|
429
|
+
await this.uploadBinaryFile(relativePath, localPath);
|
|
430
|
+
console.log(` Uploaded: ${relativePath}`);
|
|
431
|
+
return;
|
|
432
|
+
}
|
|
433
|
+
|
|
399
434
|
const content = await fs.readFile(localPath, 'utf8');
|
|
400
435
|
const url = this.buildFileUrl(relativePath);
|
|
401
436
|
|
|
@@ -409,14 +444,39 @@ export abstract class RealmSyncBase {
|
|
|
409
444
|
});
|
|
410
445
|
|
|
411
446
|
if (!response.ok) {
|
|
412
|
-
|
|
413
|
-
`Failed to upload: ${response.status} ${response.statusText}`,
|
|
414
|
-
);
|
|
447
|
+
await throwUploadError(response, relativePath);
|
|
415
448
|
}
|
|
416
449
|
|
|
417
450
|
console.log(` Uploaded: ${relativePath}`);
|
|
418
451
|
}
|
|
419
452
|
|
|
453
|
+
// Uploads a single binary file (PNG, PDF, font, etc.) per the host
|
|
454
|
+
// pattern: a per-file POST with Content-Type: application/octet-stream
|
|
455
|
+
// and the raw bytes as the body. The realm-server routes octet-stream
|
|
456
|
+
// POSTs to upsertBinaryFile, which writes the bytes verbatim without
|
|
457
|
+
// any string conversion. Used by both uploadFile (single-shot) and
|
|
458
|
+
// uploadFilesAtomic (mixed-batch fallback for the binary entries it
|
|
459
|
+
// splits out of the atomic JSON payload).
|
|
460
|
+
protected async uploadBinaryFile(
|
|
461
|
+
relativePath: string,
|
|
462
|
+
localPath: string,
|
|
463
|
+
): Promise<void> {
|
|
464
|
+
const bytes = await fs.readFile(localPath);
|
|
465
|
+
const url = this.buildFileUrl(relativePath);
|
|
466
|
+
|
|
467
|
+
const response = await this.authenticator.authedRealmFetch(url, {
|
|
468
|
+
method: 'POST',
|
|
469
|
+
headers: {
|
|
470
|
+
'Content-Type': SupportedMimeType.OctetStream,
|
|
471
|
+
},
|
|
472
|
+
body: bytes,
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
if (!response.ok) {
|
|
476
|
+
await throwUploadError(response, relativePath);
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
|
|
420
480
|
// Batched upload via the realm's /_atomic endpoint. Returns the set of
|
|
421
481
|
// paths the server reported as written plus an optional error payload
|
|
422
482
|
// when the whole batch was rejected. The atomic endpoint validates
|
|
@@ -430,7 +490,7 @@ export abstract class RealmSyncBase {
|
|
|
430
490
|
succeeded: string[];
|
|
431
491
|
error?: {
|
|
432
492
|
status: number;
|
|
433
|
-
perFile:
|
|
493
|
+
perFile: UploadFailure[];
|
|
434
494
|
message: string;
|
|
435
495
|
};
|
|
436
496
|
}> {
|
|
@@ -449,8 +509,137 @@ export abstract class RealmSyncBase {
|
|
|
449
509
|
return { succeeded: [] };
|
|
450
510
|
}
|
|
451
511
|
|
|
512
|
+
// The /_atomic endpoint embeds each file's content inside a JSON
|
|
513
|
+
// `attributes.content` string, which can't carry raw binary bytes.
|
|
514
|
+
// Match the host pattern: keep /_atomic for text files only, and
|
|
515
|
+
// for each binary file fall back to a per-file octet-stream POST
|
|
516
|
+
// (the same wire format `uploadBinaryFile` uses for a single binary).
|
|
517
|
+
// The two batches run concurrently — neither helper rejects, so the
|
|
518
|
+
// outer Promise.all just joins their structured results.
|
|
519
|
+
const textEntries: Array<[string, string]> = [];
|
|
520
|
+
const binaryEntries: Array<[string, string]> = [];
|
|
521
|
+
for (const entry of entries) {
|
|
522
|
+
if (isBinaryFilename(entry[0])) {
|
|
523
|
+
binaryEntries.push(entry);
|
|
524
|
+
} else {
|
|
525
|
+
textEntries.push(entry);
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
const [binaryOutcome, textOutcome] = await Promise.all([
|
|
530
|
+
this.uploadBinaryBatch(binaryEntries),
|
|
531
|
+
this.uploadTextAtomic(textEntries, addPaths),
|
|
532
|
+
]);
|
|
533
|
+
|
|
534
|
+
const succeeded = [...textOutcome.succeeded, ...binaryOutcome.succeeded];
|
|
535
|
+
|
|
536
|
+
if (textOutcome.fatal) {
|
|
537
|
+
return {
|
|
538
|
+
succeeded,
|
|
539
|
+
error: {
|
|
540
|
+
status: textOutcome.fatal.status,
|
|
541
|
+
perFile: [...textOutcome.failed, ...binaryOutcome.failed],
|
|
542
|
+
message: textOutcome.fatal.message,
|
|
543
|
+
},
|
|
544
|
+
};
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
if (binaryOutcome.failed.length > 0) {
|
|
548
|
+
return {
|
|
549
|
+
succeeded,
|
|
550
|
+
error: {
|
|
551
|
+
status: binaryOutcome.failed[0].status,
|
|
552
|
+
perFile: binaryOutcome.failed,
|
|
553
|
+
message: `Binary upload failed for ${binaryOutcome.failed.length} file(s)`,
|
|
554
|
+
},
|
|
555
|
+
};
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
return { succeeded };
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
// Fan out the per-file octet-stream POSTs for the binary slice of an
|
|
562
|
+
// atomic batch. Each upload is wrapped in try/catch so a single failure
|
|
563
|
+
// is folded into the result instead of rejecting the fan-out;
|
|
564
|
+
// Promise.allSettled is used at the boundary as defense-in-depth so a
|
|
565
|
+
// future change that drops the inner catch still surfaces a structured
|
|
566
|
+
// failure rather than silently aborting other in-flight uploads.
|
|
567
|
+
private async uploadBinaryBatch(
|
|
568
|
+
binaryEntries: Array<[string, string]>,
|
|
569
|
+
): Promise<{ succeeded: string[]; failed: UploadFailure[] }> {
|
|
570
|
+
if (binaryEntries.length === 0) {
|
|
571
|
+
return { succeeded: [], failed: [] };
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
const settled = await Promise.allSettled(
|
|
575
|
+
binaryEntries.map(([relativePath, localPath]) =>
|
|
576
|
+
this.remoteLimit(async () => {
|
|
577
|
+
try {
|
|
578
|
+
await this.uploadBinaryFile(relativePath, localPath);
|
|
579
|
+
console.log(` Uploaded: ${relativePath}`);
|
|
580
|
+
return { relativePath, ok: true as const };
|
|
581
|
+
} catch (err) {
|
|
582
|
+
const errWithStatus = err as { status?: number };
|
|
583
|
+
const status =
|
|
584
|
+
typeof errWithStatus?.status === 'number'
|
|
585
|
+
? errWithStatus.status
|
|
586
|
+
: 500;
|
|
587
|
+
const title = err instanceof Error ? err.message : String(err);
|
|
588
|
+
return {
|
|
589
|
+
relativePath,
|
|
590
|
+
ok: false as const,
|
|
591
|
+
status,
|
|
592
|
+
title,
|
|
593
|
+
};
|
|
594
|
+
}
|
|
595
|
+
}),
|
|
596
|
+
),
|
|
597
|
+
);
|
|
598
|
+
|
|
599
|
+
const succeeded: string[] = [];
|
|
600
|
+
const failed: UploadFailure[] = [];
|
|
601
|
+
for (let i = 0; i < settled.length; i++) {
|
|
602
|
+
const outcome = settled[i];
|
|
603
|
+
if (outcome.status === 'fulfilled') {
|
|
604
|
+
if (outcome.value.ok) {
|
|
605
|
+
succeeded.push(outcome.value.relativePath);
|
|
606
|
+
} else {
|
|
607
|
+
failed.push({
|
|
608
|
+
path: outcome.value.relativePath,
|
|
609
|
+
status: outcome.value.status,
|
|
610
|
+
title: outcome.value.title,
|
|
611
|
+
});
|
|
612
|
+
}
|
|
613
|
+
} else {
|
|
614
|
+
failed.push({
|
|
615
|
+
path: binaryEntries[i][0],
|
|
616
|
+
status: 500,
|
|
617
|
+
title: String(outcome.reason),
|
|
618
|
+
});
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
return { succeeded, failed };
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
// POST the text slice of a mixed batch to /_atomic and decode the
|
|
626
|
+
// result. `fatal` is set when the server rejected the whole batch
|
|
627
|
+
// (non-201) — callers map that to a top-level error message; `failed`
|
|
628
|
+
// carries the per-operation errors the server returned alongside.
|
|
629
|
+
private async uploadTextAtomic(
|
|
630
|
+
textEntries: Array<[string, string]>,
|
|
631
|
+
addPaths: Set<string>,
|
|
632
|
+
): Promise<{
|
|
633
|
+
succeeded: string[];
|
|
634
|
+
failed: UploadFailure[];
|
|
635
|
+
fatal?: { status: number; message: string };
|
|
636
|
+
}> {
|
|
637
|
+
if (textEntries.length === 0) {
|
|
638
|
+
return { succeeded: [], failed: [] };
|
|
639
|
+
}
|
|
640
|
+
|
|
452
641
|
const operations = await Promise.all(
|
|
453
|
-
|
|
642
|
+
textEntries.map(async ([relativePath, localPath]) => {
|
|
454
643
|
const content = await fs.readFile(localPath, 'utf8');
|
|
455
644
|
return {
|
|
456
645
|
op: addPaths.has(relativePath)
|
|
@@ -478,27 +667,28 @@ export abstract class RealmSyncBase {
|
|
|
478
667
|
body: JSON.stringify({ 'atomic:operations': operations }),
|
|
479
668
|
});
|
|
480
669
|
|
|
670
|
+
const hrefToRelative = new Map(
|
|
671
|
+
textEntries.map(([rel]) => [this.buildFileUrl(rel), rel]),
|
|
672
|
+
);
|
|
673
|
+
|
|
481
674
|
if (response.status === 201) {
|
|
482
675
|
const body = (await response.json()) as {
|
|
483
676
|
'atomic:results'?: Array<{ data?: { id?: string } }>;
|
|
484
677
|
};
|
|
485
|
-
const hrefToRelative = new Map(
|
|
486
|
-
entries.map(([rel]) => [this.buildFileUrl(rel), rel]),
|
|
487
|
-
);
|
|
488
678
|
// The realm normalizes hrefs: a path with a space goes out as
|
|
489
679
|
// `Knowledge Articles/...` but comes back URL-encoded as
|
|
490
680
|
// `Knowledge%20Articles/...`. Decode the response id before the
|
|
491
681
|
// map lookup so we resolve back to the original relative path
|
|
492
682
|
// instead of falling through to the raw encoded URL.
|
|
493
|
-
const
|
|
683
|
+
const atomicSucceeded = (body['atomic:results'] ?? [])
|
|
494
684
|
.map((r) => r.data?.id)
|
|
495
685
|
.filter((id): id is string => typeof id === 'string')
|
|
496
686
|
.map((id) => decodeAtomicResultId(id))
|
|
497
687
|
.map((id) => hrefToRelative.get(id) ?? id);
|
|
498
|
-
for (const rel of
|
|
688
|
+
for (const rel of atomicSucceeded) {
|
|
499
689
|
console.log(` Uploaded: ${rel}`);
|
|
500
690
|
}
|
|
501
|
-
return { succeeded };
|
|
691
|
+
return { succeeded: atomicSucceeded, failed: [] };
|
|
502
692
|
}
|
|
503
693
|
|
|
504
694
|
let errorBody: {
|
|
@@ -510,15 +700,12 @@ export abstract class RealmSyncBase {
|
|
|
510
700
|
// ignore JSON parse failures — fall through to the generic message
|
|
511
701
|
}
|
|
512
702
|
|
|
513
|
-
const
|
|
703
|
+
const failed: UploadFailure[] = (errorBody.errors ?? []).map((e) => {
|
|
514
704
|
const detail = e.detail ?? '';
|
|
515
705
|
const match = detail.match(/Resource (\S+) /);
|
|
516
706
|
const href = match ? decodeAtomicResultId(match[1]) : '';
|
|
517
|
-
const relMap = new Map(
|
|
518
|
-
entries.map(([rel]) => [this.buildFileUrl(rel), rel]),
|
|
519
|
-
);
|
|
520
707
|
return {
|
|
521
|
-
path:
|
|
708
|
+
path: hrefToRelative.get(href) ?? href,
|
|
522
709
|
status: e.status ?? response.status,
|
|
523
710
|
title: e.title ?? 'Error',
|
|
524
711
|
};
|
|
@@ -526,9 +713,9 @@ export abstract class RealmSyncBase {
|
|
|
526
713
|
|
|
527
714
|
return {
|
|
528
715
|
succeeded: [],
|
|
529
|
-
|
|
716
|
+
failed,
|
|
717
|
+
fatal: {
|
|
530
718
|
status: response.status,
|
|
531
|
-
perFile,
|
|
532
719
|
message: `Atomic upload failed: ${response.status} ${response.statusText}`,
|
|
533
720
|
},
|
|
534
721
|
};
|
|
@@ -559,12 +746,16 @@ export abstract class RealmSyncBase {
|
|
|
559
746
|
);
|
|
560
747
|
}
|
|
561
748
|
|
|
562
|
-
const content = await response.text();
|
|
563
|
-
|
|
564
749
|
const localDir = path.dirname(localPath);
|
|
565
750
|
await fs.mkdir(localDir, { recursive: true });
|
|
566
751
|
|
|
567
|
-
|
|
752
|
+
if (isBinaryFilename(relativePath)) {
|
|
753
|
+
const buffer = Buffer.from(await response.arrayBuffer());
|
|
754
|
+
await fs.writeFile(localPath, buffer);
|
|
755
|
+
} else {
|
|
756
|
+
const content = await response.text();
|
|
757
|
+
await fs.writeFile(localPath, content, 'utf8');
|
|
758
|
+
}
|
|
568
759
|
console.log(` Downloaded: ${relativePath}`);
|
|
569
760
|
}
|
|
570
761
|
|