@cardstack/boxel-cli 0.2.0-unstable.446 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cardstack/boxel-cli",
3
- "version": "0.2.0-unstable.446",
3
+ "version": "0.2.0-unstable.448",
4
4
  "license": "MIT",
5
5
  "description": "CLI tools for Boxel workspace management",
6
6
  "main": "./dist/index.js",
@@ -53,9 +53,9 @@
53
53
  "typescript": "~5.9.3",
54
54
  "vite": "^6.3.2",
55
55
  "vitest": "^2.1.9",
56
+ "@cardstack/local-types": "0.0.0",
56
57
  "@cardstack/postgres": "0.0.0",
57
- "@cardstack/runtime-common": "1.0.0",
58
- "@cardstack/local-types": "0.0.0"
58
+ "@cardstack/runtime-common": "1.0.0"
59
59
  },
60
60
  "publishConfig": {
61
61
  "access": "public",
@@ -6,14 +6,21 @@ import {
6
6
  } from '../../lib/profile-manager';
7
7
  import { ensureTrailingSlash } from '@cardstack/runtime-common/paths';
8
8
  import { SupportedMimeType } from '@cardstack/runtime-common/supported-mime-type';
9
+ import { isBinaryFilename } from '@cardstack/runtime-common/infer-content-type';
9
10
  import { FG_RED, DIM, RESET } from '../../lib/colors';
10
11
  import { cliLog } from '../../lib/cli-log';
11
12
 
12
13
  export interface ReadResult {
13
14
  ok: boolean;
14
15
  status?: number;
15
- /** Raw text content of the file. */
16
+ /** Raw text content of the file. Populated for non-binary paths. */
16
17
  content?: string;
18
+ /**
19
+ * Raw bytes. Populated when the requested path is a binary filename
20
+ * (PNG, PDF, font, etc.) — see `isBinaryFilename`. Mutually exclusive
21
+ * with `content`.
22
+ */
23
+ bytes?: Uint8Array;
17
24
  error?: string;
18
25
  }
19
26
 
@@ -27,8 +34,10 @@ interface ReadCliOptions {
27
34
  }
28
35
 
29
36
  /**
30
- * Read a file from a realm. Always returns the raw text content.
31
- * Callers should parse the content themselves if needed (e.g. JSON).
37
+ * Read a file from a realm. Returns raw text in `content` for text files;
38
+ * returns raw bytes in `bytes` for binary files (PNG / PDF / font / etc.,
39
+ * per `isBinaryFilename`). Callers should parse the content themselves
40
+ * if needed (e.g. JSON).
32
41
  *
33
42
  * Uses the per-realm JWT via `ProfileManager.authedRealmFetch`.
34
43
  */
@@ -70,6 +79,11 @@ export async function read(
70
79
  };
71
80
  }
72
81
 
82
+ if (isBinaryFilename(path)) {
83
+ let bytes = new Uint8Array(await response.arrayBuffer());
84
+ return { ok: true, status: response.status, bytes };
85
+ }
86
+
73
87
  let text = await response.text();
74
88
  return { ok: true, status: response.status, content: text };
75
89
  }
@@ -96,9 +110,30 @@ export function registerReadCommand(parent: Command): void {
96
110
  }
97
111
 
98
112
  if (opts.json) {
99
- cliLog.output(JSON.stringify(result, null, 2));
113
+ let serializable: Record<string, unknown> = {
114
+ ok: result.ok,
115
+ status: result.status,
116
+ error: result.error,
117
+ };
118
+ if (result.content !== undefined) {
119
+ serializable.content = result.content;
120
+ }
121
+ if (result.bytes !== undefined) {
122
+ // Buffer.from(typedArray) shares memory, then toString('base64')
123
+ // copies into a base64 string — fine for the JSON output path.
124
+ serializable.bytesBase64 = Buffer.from(
125
+ result.bytes.buffer,
126
+ result.bytes.byteOffset,
127
+ result.bytes.byteLength,
128
+ ).toString('base64');
129
+ }
130
+ cliLog.output(JSON.stringify(serializable, null, 2));
100
131
  } else if (result.ok) {
101
- cliLog.output(result.content ?? '');
132
+ if (result.bytes !== undefined) {
133
+ process.stdout.write(result.bytes);
134
+ } else {
135
+ cliLog.output(result.content ?? '');
136
+ }
102
137
  } else {
103
138
  console.error(
104
139
  `${DIM}Status:${RESET} ${result.status ?? '(no status)'}`,
@@ -7,6 +7,7 @@ import {
7
7
  } from '../../lib/profile-manager';
8
8
  import { ensureTrailingSlash } from '@cardstack/runtime-common/paths';
9
9
  import { SupportedMimeType } from '@cardstack/runtime-common/supported-mime-type';
10
+ import { isBinaryFilename } from '@cardstack/runtime-common/infer-content-type';
10
11
  import { FG_GREEN, FG_RED, DIM, RESET } from '../../lib/colors';
11
12
  import { cliLog } from '../../lib/cli-log';
12
13
 
@@ -26,15 +27,19 @@ interface WriteCliOptions {
26
27
  }
27
28
 
28
29
  /**
29
- * Write a file to a realm. Content is sent as-is with card+source MIME type.
30
- * Path should include the file extension.
30
+ * Write a file to a realm. Path should include the file extension.
31
+ *
32
+ * String content is sent with the card+source MIME type (the text path
33
+ * .gts / .json / .md / etc. always took). Binary content (a `Uint8Array`,
34
+ * including the `Buffer` subclass) is sent with `application/octet-stream`,
35
+ * which the realm-server routes to `upsertBinaryFile` and writes verbatim.
31
36
  *
32
37
  * Uses the per-realm JWT via `ProfileManager.authedRealmFetch`.
33
38
  */
34
39
  export async function write(
35
40
  realmUrl: string,
36
41
  path: string,
37
- content: string,
42
+ content: string | Uint8Array,
38
43
  options?: WriteCommandOptions,
39
44
  ): Promise<WriteResult> {
40
45
  let pm = options?.profileManager ?? getProfileManager();
@@ -47,15 +52,38 @@ export async function write(
47
52
  }
48
53
 
49
54
  let url = new URL(path, ensureTrailingSlash(realmUrl)).href;
55
+ let isBinary = typeof content !== 'string';
56
+
57
+ // Defense-in-depth for programmatic callers (BoxelClient.write, tests).
58
+ // The CLI wrapper has an earlier guard against `--file image.png` →
59
+ // `notes.md` style misuse, but the library function is also reachable
60
+ // without going through that branch. Reject the mismatch here so raw
61
+ // bytes never land at a text extension (corrupt-on-read) and a UTF-8
62
+ // string never lands at a binary extension (corrupt-on-write).
63
+ let pathIsBinary = isBinaryFilename(path);
64
+ if (pathIsBinary !== isBinary) {
65
+ return {
66
+ ok: false,
67
+ error:
68
+ `Path ${path} is ${pathIsBinary ? 'binary' : 'text'} by extension ` +
69
+ `but content is ${isBinary ? 'bytes' : 'a string'}. ` +
70
+ `Refusing to write to avoid silent corruption.`,
71
+ };
72
+ }
50
73
 
51
74
  try {
52
75
  let response = await pm.authedRealmFetch(url, {
53
76
  method: 'POST',
54
- headers: {
55
- Accept: SupportedMimeType.CardSource,
56
- 'Content-Type': SupportedMimeType.CardSource,
57
- },
58
- body: content,
77
+ headers: isBinary
78
+ ? { 'Content-Type': SupportedMimeType.OctetStream }
79
+ : {
80
+ Accept: SupportedMimeType.CardSource,
81
+ 'Content-Type': SupportedMimeType.CardSource,
82
+ },
83
+ // Both branches of `content: string | Uint8Array` are valid
84
+ // BodyInit values, but TS narrows them as a union that doesn't
85
+ // unify against the fetch signature without a hint.
86
+ body: content as BodyInit,
59
87
  });
60
88
 
61
89
  if (!response.ok) {
@@ -103,10 +131,30 @@ export function registerWriteCommand(parent: Command): void {
103
131
  )
104
132
  .option('--json', 'Output raw JSON response')
105
133
  .action(async (filePath: string, opts: WriteCliOptions) => {
106
- let content: string;
134
+ let content: string | Uint8Array;
107
135
  if (opts.file) {
136
+ // Refuse a source/destination binary-classification mismatch
137
+ // (e.g., `write notes.md --file image.png`) — otherwise raw
138
+ // bytes would land at a text extension and corrupt-on-read.
139
+ const srcIsBinary = isBinaryFilename(opts.file);
140
+ const dstIsBinary = isBinaryFilename(filePath);
141
+ if (srcIsBinary !== dstIsBinary) {
142
+ stderr(
143
+ `${FG_RED}Error:${RESET} source file ${opts.file} is ${
144
+ srcIsBinary ? 'binary' : 'text'
145
+ } but destination path ${filePath} is ${
146
+ dstIsBinary ? 'binary' : 'text'
147
+ }. Refusing to write to avoid silent corruption — rename the destination to match.`,
148
+ );
149
+ process.exit(1);
150
+ }
108
151
  try {
109
- content = readFileSync(opts.file, 'utf-8');
152
+ // Binary source files are read as raw bytes so write() can
153
+ // hand them to the realm unchanged; forcing utf-8 would
154
+ // corrupt PNG / PDF / font / etc. payloads silently.
155
+ content = srcIsBinary
156
+ ? readFileSync(opts.file)
157
+ : readFileSync(opts.file, 'utf-8');
110
158
  } catch (err) {
111
159
  stderr(
112
160
  `${FG_RED}Error:${RESET} Could not read file: ${err instanceof Error ? err.message : String(err)}`,
@@ -200,6 +200,23 @@ class RealmPusher extends RealmSyncBase {
200
200
 
201
201
  const result = await this.uploadFilesAtomic(filesToUpload, addPaths);
202
202
 
203
+ // Record every file the server actually wrote before surfacing
204
+ // errors. uploadFilesAtomic can return both `succeeded` and
205
+ // `error` when the atomic text batch lands but a per-file
206
+ // binary POST fails — dropping the manifest update in that
207
+ // case would force a re-add on the next push (409 cascade).
208
+ if (result.succeeded.length > 0) {
209
+ const uploaded = await Promise.all(
210
+ result.succeeded.map(async (rel) => ({
211
+ rel,
212
+ hash: await computeFileHash(filesToUpload.get(rel)!),
213
+ })),
214
+ );
215
+ for (const { rel, hash } of uploaded) {
216
+ newManifest.files[rel] = hash;
217
+ }
218
+ }
219
+
203
220
  if (result.error) {
204
221
  uploadFailed = true;
205
222
  this.hasError = true;
@@ -215,16 +232,6 @@ class RealmPusher extends RealmSyncBase {
215
232
  }
216
233
  console.error(` ${hint}`);
217
234
  }
218
- } else if (result.succeeded.length > 0) {
219
- const uploaded = await Promise.all(
220
- result.succeeded.map(async (rel) => ({
221
- rel,
222
- hash: await computeFileHash(filesToUpload.get(rel)!),
223
- })),
224
- );
225
- for (const { rel, hash } of uploaded) {
226
- newManifest.files[rel] = hash;
227
- }
228
235
  }
229
236
  }
230
237
 
@@ -270,7 +277,11 @@ class RealmPusher extends RealmSyncBase {
270
277
  }
271
278
  }
272
279
 
273
- if (!this.options.dryRun && !uploadFailed && filesToUpload.size > 0) {
280
+ // Refresh mtimes and save the manifest even on partial failure —
281
+ // newManifest.files only contains files the server actually wrote
282
+ // (unchanged carry-overs + succeeded uploads), so persisting it
283
+ // is always safe and avoids re-uploading text files that landed.
284
+ if (!this.options.dryRun && filesToUpload.size > 0) {
274
285
  try {
275
286
  const freshMtimes = await this.getRemoteMtimes();
276
287
  for (const rel of Object.keys(newManifest.files)) {
@@ -291,7 +302,7 @@ class RealmPusher extends RealmSyncBase {
291
302
  delete newManifest.remoteMtimes;
292
303
  }
293
304
 
294
- if (!this.options.dryRun && !uploadFailed) {
305
+ if (!this.options.dryRun) {
295
306
  await saveManifest(this.options.localDir, newManifest);
296
307
  }
297
308
 
@@ -314,14 +314,16 @@ class RealmSyncer extends RealmSyncBase {
314
314
  }
315
315
 
316
316
  const result = await this.uploadFilesAtomic(filesToUpload, addPaths);
317
+ // Record every file the server actually wrote, even when other
318
+ // files in the same batch failed — see push.ts for the symmetric
319
+ // reasoning.
320
+ this.pushedFiles.push(...result.succeeded);
317
321
  if (result.error) {
318
322
  this.hasError = true;
319
323
  console.error(result.error.message);
320
324
  for (const entry of result.error.perFile) {
321
325
  console.error(` ${entry.path}: ${entry.title}`);
322
326
  }
323
- } else {
324
- this.pushedFiles.push(...result.succeeded);
325
327
  }
326
328
  }
327
329
 
@@ -371,8 +373,12 @@ class RealmSyncer extends RealmSyncBase {
371
373
  );
372
374
  }
373
375
 
374
- // Phase 6: Update manifest
375
- if (!this.options.dryRun && !this.hasError) {
376
+ // Phase 6: Update manifest. Persist even on partial failure — we
377
+ // only record hashes for files the server actually wrote
378
+ // (pushedFiles + pulledFiles), so the manifest stays consistent
379
+ // with the realm and the next sync won't re-attempt successful
380
+ // files.
381
+ if (!this.options.dryRun) {
376
382
  // Build updated hashes from prior manifest + current local files + executed ops.
377
383
  // Start with the previous manifest so that files deleted locally but not
378
384
  // propagated (no --delete) retain their entries and aren't re-pulled next sync.
@@ -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