@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cardstack/boxel-cli",
3
- "version": "0.2.0-unstable.425",
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. 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)}`,
@@ -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
- if (manager.getProfile(matrixId)) {
462
- console.log(
463
- `${FG_YELLOW}Profile ${matrixId} already exists. Updating password.${RESET}`,
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
- await manager.updatePassword(matrixId, password);
466
- if (displayName) {
467
- manager.updateDisplayName(matrixId, displayName);
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
- `${FG_YELLOW}Profile ${formatProfileBadge(result.profileId)} already exists.${RESET} Password has been updated if it changed.`,
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
- 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.
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
- let response = await fetch(userRealmsAccountDataUrl(matrixAuth), {
160
+ response = await fetch(userRealmsAccountDataUrl(matrixAuth), {
143
161
  headers: { Authorization: `Bearer ${matrixAuth.accessToken}` },
144
162
  });
145
- if (!response.ok) {
146
- return [];
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
  );