@cardstack/boxel-cli 0.2.0-unstable.446 → 0.2.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/dist/index.js +124 -108
- package/package.json +3 -3
- package/src/commands/file/read.ts +40 -5
- package/src/commands/file/write.ts +58 -10
- package/src/commands/realm/push.ts +23 -12
- package/src/commands/realm/sync.ts +10 -4
- package/src/lib/realm-sync-base.ts +212 -21
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cardstack/boxel-cli",
|
|
3
|
-
"version": "0.2.0
|
|
3
|
+
"version": "0.2.0",
|
|
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.
|
|
31
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
30
|
-
*
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|