@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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cardstack/boxel-cli",
|
|
3
|
-
"version": "0.2.0-unstable.
|
|
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,8 +53,8 @@
|
|
|
53
53
|
"typescript": "~5.9.3",
|
|
54
54
|
"vite": "^6.3.2",
|
|
55
55
|
"vitest": "^2.1.9",
|
|
56
|
-
"@cardstack/postgres": "0.0.0",
|
|
57
56
|
"@cardstack/local-types": "0.0.0",
|
|
57
|
+
"@cardstack/postgres": "0.0.0",
|
|
58
58
|
"@cardstack/runtime-common": "1.0.0"
|
|
59
59
|
},
|
|
60
60
|
"publishConfig": {
|
|
@@ -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)}`,
|
package/src/commands/profile.ts
CHANGED
|
@@ -80,7 +80,7 @@ function validateUrl(input: string, label: string): string {
|
|
|
80
80
|
|
|
81
81
|
// Matches scripts/env-slug.sh: lowercase, "/" -> "-", strip chars outside
|
|
82
82
|
// [a-z0-9-], collapse runs of "-", trim leading/trailing "-".
|
|
83
|
-
function computeEnvSlug(name: string): string {
|
|
83
|
+
export function computeEnvSlug(name: string): string {
|
|
84
84
|
return name
|
|
85
85
|
.toLowerCase()
|
|
86
86
|
.replace(/\//g, '-')
|
|
@@ -91,7 +91,7 @@ function computeEnvSlug(name: string): string {
|
|
|
91
91
|
|
|
92
92
|
// Derive URLs from BOXEL_ENVIRONMENT using the same ".${slug}.localhost"
|
|
93
93
|
// pattern that mise-tasks/lib/env-vars.sh produces for env-mode local dev.
|
|
94
|
-
function resolveBoxelEnvironment(): EnvironmentDefaults | null {
|
|
94
|
+
export function resolveBoxelEnvironment(): EnvironmentDefaults | null {
|
|
95
95
|
const raw = process.env.BOXEL_ENVIRONMENT;
|
|
96
96
|
if (!raw || !raw.trim()) return null;
|
|
97
97
|
const slug = computeEnvSlug(raw);
|
|
@@ -458,14 +458,28 @@ async function addProfileNonInteractive(
|
|
|
458
458
|
process.exit(1);
|
|
459
459
|
}
|
|
460
460
|
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
461
|
+
const isUpdate = Boolean(manager.getProfile(matrixId));
|
|
462
|
+
|
|
463
|
+
// addProfile performs a real matrixLogin and persists the resulting
|
|
464
|
+
// access token (the password never lands on disk). It also handles the
|
|
465
|
+
// create-vs-reauth split uniformly: re-running it on an existing profile
|
|
466
|
+
// refreshes the stored token while preserving cached realm tokens.
|
|
467
|
+
try {
|
|
468
|
+
await manager.addProfile(
|
|
469
|
+
matrixId,
|
|
470
|
+
password,
|
|
471
|
+
displayName,
|
|
472
|
+
matrixUrl,
|
|
473
|
+
realmServerUrl,
|
|
464
474
|
);
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
475
|
+
} catch (err) {
|
|
476
|
+
console.error(
|
|
477
|
+
`${FG_RED}Error:${RESET} ${err instanceof Error ? err.message : String(err)}`,
|
|
478
|
+
);
|
|
479
|
+
process.exit(1);
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
if (isUpdate) {
|
|
469
483
|
if (matrixUrl || realmServerUrl) {
|
|
470
484
|
const urlsChanged = manager.updateUrls(matrixId, {
|
|
471
485
|
matrixUrl,
|
|
@@ -483,20 +497,6 @@ async function addProfileNonInteractive(
|
|
|
483
497
|
return;
|
|
484
498
|
}
|
|
485
499
|
|
|
486
|
-
try {
|
|
487
|
-
await manager.addProfile(
|
|
488
|
-
matrixId,
|
|
489
|
-
password,
|
|
490
|
-
displayName,
|
|
491
|
-
matrixUrl,
|
|
492
|
-
realmServerUrl,
|
|
493
|
-
);
|
|
494
|
-
} catch (err) {
|
|
495
|
-
console.error(
|
|
496
|
-
`${FG_RED}Error:${RESET} ${err instanceof Error ? err.message : String(err)}`,
|
|
497
|
-
);
|
|
498
|
-
process.exit(1);
|
|
499
|
-
}
|
|
500
500
|
console.log(
|
|
501
501
|
`${FG_GREEN}\u2713${RESET} Profile created: ${formatProfileBadge(matrixId)}`,
|
|
502
502
|
);
|
|
@@ -538,7 +538,7 @@ async function migrateFromEnv(manager: ProfileManager): Promise<void> {
|
|
|
538
538
|
);
|
|
539
539
|
} else {
|
|
540
540
|
console.log(
|
|
541
|
-
`${
|
|
541
|
+
`${FG_GREEN}\u2713${RESET} Refreshed profile: ${formatProfileBadge(result.profileId)}`,
|
|
542
542
|
);
|
|
543
543
|
console.log(
|
|
544
544
|
`\n${DIM}Use 'boxel profile add -u ${result.profileId} -p <password>' to update other fields.${RESET}`,
|
|
@@ -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.
|
package/src/lib/auth.ts
CHANGED
|
@@ -7,6 +7,17 @@ export interface MatrixAuth {
|
|
|
7
7
|
|
|
8
8
|
export type RealmTokens = Record<string, string>;
|
|
9
9
|
|
|
10
|
+
// Thrown when Matrix rejects an access token (401/403). Callers can catch
|
|
11
|
+
// this specifically to drive interactive re-auth without parsing messages.
|
|
12
|
+
export class MatrixAuthError extends Error {
|
|
13
|
+
status: number;
|
|
14
|
+
constructor(status: number, message: string) {
|
|
15
|
+
super(message);
|
|
16
|
+
this.name = 'MatrixAuthError';
|
|
17
|
+
this.status = status;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
10
21
|
interface MatrixLoginResponse {
|
|
11
22
|
access_token: string;
|
|
12
23
|
device_id: string;
|
|
@@ -69,6 +80,12 @@ async function getOpenIdToken(
|
|
|
69
80
|
|
|
70
81
|
if (!response.ok) {
|
|
71
82
|
let text = await response.text();
|
|
83
|
+
if (response.status === 401 || response.status === 403) {
|
|
84
|
+
throw new MatrixAuthError(
|
|
85
|
+
response.status,
|
|
86
|
+
`OpenID token request failed: ${response.status} ${text}`,
|
|
87
|
+
);
|
|
88
|
+
}
|
|
72
89
|
throw new Error(`OpenID token request failed: ${response.status} ${text}`);
|
|
73
90
|
}
|
|
74
91
|
|
|
@@ -138,17 +155,30 @@ function userRealmsAccountDataUrl(matrixAuth: MatrixAuth): string {
|
|
|
138
155
|
export async function getUserRealmsFromMatrixAccountData(
|
|
139
156
|
matrixAuth: MatrixAuth,
|
|
140
157
|
): Promise<string[]> {
|
|
158
|
+
let response: Response;
|
|
141
159
|
try {
|
|
142
|
-
|
|
160
|
+
response = await fetch(userRealmsAccountDataUrl(matrixAuth), {
|
|
143
161
|
headers: { Authorization: `Bearer ${matrixAuth.accessToken}` },
|
|
144
162
|
});
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
163
|
+
} catch {
|
|
164
|
+
// Network unreachable / DNS / similar — treat as empty (best-effort).
|
|
165
|
+
return [];
|
|
166
|
+
}
|
|
167
|
+
if (response.status === 401 || response.status === 403) {
|
|
168
|
+
let text = await response.text();
|
|
169
|
+
throw new MatrixAuthError(
|
|
170
|
+
response.status,
|
|
171
|
+
`Matrix account_data fetch failed: ${response.status} ${text}`,
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
if (!response.ok) {
|
|
175
|
+
// 404 just means the event has never been set — return empty list.
|
|
176
|
+
return [];
|
|
177
|
+
}
|
|
178
|
+
try {
|
|
148
179
|
let data = (await response.json()) as { realms?: string[] };
|
|
149
180
|
return Array.isArray(data.realms) ? [...data.realms] : [];
|
|
150
181
|
} catch {
|
|
151
|
-
// Best-effort — treat unreachable account data as an empty list
|
|
152
182
|
return [];
|
|
153
183
|
}
|
|
154
184
|
}
|
|
@@ -171,6 +201,12 @@ export async function addRealmToMatrixAccountData(
|
|
|
171
201
|
});
|
|
172
202
|
if (!putResponse.ok) {
|
|
173
203
|
let text = await putResponse.text();
|
|
204
|
+
if (putResponse.status === 401 || putResponse.status === 403) {
|
|
205
|
+
throw new MatrixAuthError(
|
|
206
|
+
putResponse.status,
|
|
207
|
+
`Failed to update Matrix account data: ${putResponse.status} ${text}`,
|
|
208
|
+
);
|
|
209
|
+
}
|
|
174
210
|
throw new Error(
|
|
175
211
|
`Failed to update Matrix account data: ${putResponse.status} ${text}`,
|
|
176
212
|
);
|
|
@@ -205,6 +241,12 @@ export async function removeRealmFromMatrixAccountData(
|
|
|
205
241
|
});
|
|
206
242
|
if (!putResponse.ok) {
|
|
207
243
|
let text = await putResponse.text();
|
|
244
|
+
if (putResponse.status === 401 || putResponse.status === 403) {
|
|
245
|
+
throw new MatrixAuthError(
|
|
246
|
+
putResponse.status,
|
|
247
|
+
`Failed to update Matrix account data: ${putResponse.status} ${text}`,
|
|
248
|
+
);
|
|
249
|
+
}
|
|
208
250
|
throw new Error(
|
|
209
251
|
`Failed to update Matrix account data: ${putResponse.status} ${text}`,
|
|
210
252
|
);
|