@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.
@@ -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
- throw new Error(
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: Array<{ path: string; status: number; title: string }>;
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
- entries.map(async ([relativePath, localPath]) => {
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 succeeded = (body['atomic:results'] ?? [])
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 succeeded) {
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 perFile = (errorBody.errors ?? []).map((e) => {
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: relMap.get(href) ?? href,
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
- error: {
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
- await fs.writeFile(localPath, content, 'utf8');
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