@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.
@@ -5,6 +5,7 @@ import jwt from 'jsonwebtoken';
5
5
  import { FG_YELLOW, FG_CYAN, FG_MAGENTA, DIM, BOLD, RESET } from './colors';
6
6
  import {
7
7
  matrixLogin,
8
+ MatrixAuthError,
8
9
  getRealmServerToken as fetchRealmServerToken,
9
10
  getRealmTokens,
10
11
  addRealmToMatrixAccountData,
@@ -12,8 +13,15 @@ import {
12
13
  getUserRealmsFromMatrixAccountData,
13
14
  type MatrixAuth,
14
15
  } from './auth';
16
+ import { promptPassword as defaultPromptPassword } from './prompt';
15
17
  import type { RealmAuthenticator } from './realm-authenticator';
16
18
 
19
+ export interface ProfileManagerDeps {
20
+ matrixLogin?: typeof matrixLogin;
21
+ promptPassword?: (question: string) => Promise<string>;
22
+ isTty?: () => boolean;
23
+ }
24
+
17
25
  const DEFAULT_CONFIG_DIR = path.join(os.homedir(), '.boxel-cli');
18
26
  const PROFILES_FILENAME = 'profiles.json';
19
27
 
@@ -49,7 +57,9 @@ export interface Profile {
49
57
  displayName: string;
50
58
  matrixUrl: string;
51
59
  realmServerUrl: string;
52
- password: string; // Stored in plaintext - file should have restricted permissions, this will be updated in CS-10642
60
+ matrixAccessToken: string;
61
+ matrixUserId: string;
62
+ matrixDeviceId: string;
53
63
  realmTokens?: Record<string, string>;
54
64
  realmServerToken?: string;
55
65
  }
@@ -121,11 +131,17 @@ export class ProfileManager implements RealmAuthenticator {
121
131
  private config: ProfilesConfig;
122
132
  private configDir: string;
123
133
  private profilesFile: string;
134
+ private matrixLoginFn: typeof matrixLogin;
135
+ private promptPasswordFn: (question: string) => Promise<string>;
136
+ private isTtyFn: () => boolean;
124
137
 
125
- constructor(configDir?: string) {
138
+ constructor(configDir?: string, deps?: ProfileManagerDeps) {
126
139
  this.configDir = configDir || DEFAULT_CONFIG_DIR;
127
140
  this.profilesFile = path.join(this.configDir, PROFILES_FILENAME);
128
141
  this.config = this.loadConfig();
142
+ this.matrixLoginFn = deps?.matrixLogin ?? matrixLogin;
143
+ this.promptPasswordFn = deps?.promptPassword ?? defaultPromptPassword;
144
+ this.isTtyFn = deps?.isTty ?? (() => Boolean(process.stdin.isTTY));
129
145
  }
130
146
 
131
147
  private ensureConfigDir(): void {
@@ -199,13 +215,20 @@ export class ProfileManager implements RealmAuthenticator {
199
215
  return { id, profile };
200
216
  }
201
217
 
202
- async addProfile(
218
+ // Resolve {matrixUrl, realmServerUrl, displayName} from environment defaults
219
+ // and caller-provided overrides. Shared by `addProfile` and
220
+ // `addProfileWithAuth` so both paths agree on naming + URL inference.
221
+ private resolveProfileSlots(
203
222
  matrixId: string,
204
- password: string,
205
- displayName?: string,
206
- matrixUrl?: string,
207
- realmServerUrl?: string,
208
- ): Promise<void> {
223
+ displayName: string | undefined,
224
+ matrixUrl: string | undefined,
225
+ realmServerUrl: string | undefined,
226
+ ): {
227
+ matrixUrl: string;
228
+ realmServerUrl: string;
229
+ displayName: string;
230
+ username: string;
231
+ } {
209
232
  const env = getEnvironmentFromMatrixId(matrixId);
210
233
  const username = getUsernameFromMatrixId(matrixId);
211
234
 
@@ -215,21 +238,62 @@ export class ProfileManager implements RealmAuthenticator {
215
238
  );
216
239
  }
217
240
 
218
- const defaultMatrixUrl =
219
- env === 'production'
220
- ? 'https://matrix.boxel.ai'
221
- : 'https://matrix-staging.stack.cards';
222
- const defaultRealmUrl =
223
- env === 'production'
224
- ? 'https://app.boxel.ai/'
225
- : 'https://realms-staging.stack.cards/';
241
+ let defaultMatrixUrl: string;
242
+ let defaultRealmUrl: string;
243
+ if (env === 'production') {
244
+ defaultMatrixUrl = 'https://matrix.boxel.ai';
245
+ defaultRealmUrl = 'https://app.boxel.ai/';
246
+ } else if (env === 'local') {
247
+ defaultMatrixUrl = 'http://localhost:8008';
248
+ defaultRealmUrl = 'http://localhost:4201/';
249
+ } else {
250
+ defaultMatrixUrl = 'https://matrix-staging.stack.cards';
251
+ defaultRealmUrl = 'https://realms-staging.stack.cards/';
252
+ }
226
253
 
227
254
  const domain = getDomainFromMatrixId(matrixId);
228
- const profile: Profile = {
229
- displayName: displayName || `${username} \u00b7 ${domain}`,
255
+ return {
230
256
  matrixUrl: matrixUrl || defaultMatrixUrl,
231
257
  realmServerUrl: realmServerUrl || defaultRealmUrl,
232
- password,
258
+ displayName: displayName || `${username} \u00b7 ${domain}`,
259
+ username,
260
+ };
261
+ }
262
+
263
+ // Persist a profile from an already-acquired MatrixAuth. The token is
264
+ // stored; the original password (if any) never reaches this function. Used
265
+ // directly by tests, and as the "store" half of `addProfile`.
266
+ // When re-authing an existing profile we keep its cached realm tokens \u2014 a
267
+ // fresh access token doesn't invalidate the realm-server JWT. But if the
268
+ // matrix or realm-server URL changed, the cached tokens were minted against
269
+ // the old servers and must be dropped.
270
+ async addProfileWithAuth(
271
+ matrixId: string,
272
+ auth: MatrixAuth,
273
+ displayName?: string,
274
+ realmServerUrl?: string,
275
+ ): Promise<void> {
276
+ const slots = this.resolveProfileSlots(
277
+ matrixId,
278
+ displayName,
279
+ auth.matrixUrl,
280
+ realmServerUrl,
281
+ );
282
+
283
+ const existing = this.config.profiles[matrixId];
284
+ const urlsChanged =
285
+ !!existing &&
286
+ (existing.matrixUrl !== slots.matrixUrl ||
287
+ existing.realmServerUrl !== slots.realmServerUrl);
288
+ const profile: Profile = {
289
+ displayName: slots.displayName,
290
+ matrixUrl: slots.matrixUrl,
291
+ realmServerUrl: slots.realmServerUrl,
292
+ matrixAccessToken: auth.accessToken,
293
+ matrixUserId: auth.userId,
294
+ matrixDeviceId: auth.deviceId,
295
+ realmTokens: urlsChanged ? undefined : existing?.realmTokens,
296
+ realmServerToken: urlsChanged ? undefined : existing?.realmServerToken,
233
297
  };
234
298
 
235
299
  this.config.profiles[matrixId] = profile;
@@ -241,6 +305,44 @@ export class ProfileManager implements RealmAuthenticator {
241
305
  this.saveConfig();
242
306
  }
243
307
 
308
+ async addProfile(
309
+ matrixId: string,
310
+ password: string,
311
+ displayName?: string,
312
+ matrixUrl?: string,
313
+ realmServerUrl?: string,
314
+ ): Promise<void> {
315
+ // On re-auth, default omitted args to the existing profile's stored
316
+ // values so we don't silently reset display name or URLs to defaults.
317
+ const existing = this.config.profiles[matrixId];
318
+ const slots = this.resolveProfileSlots(
319
+ matrixId,
320
+ displayName ?? existing?.displayName,
321
+ matrixUrl ?? existing?.matrixUrl,
322
+ realmServerUrl ?? existing?.realmServerUrl,
323
+ );
324
+
325
+ const auth = await this.matrixLoginFn(
326
+ slots.matrixUrl,
327
+ slots.username,
328
+ password,
329
+ );
330
+
331
+ if (auth.userId !== matrixId) {
332
+ throw new Error(
333
+ `Matrix returned userId "${auth.userId}" but profile was added as "${matrixId}". ` +
334
+ `Check the Matrix ID and try again.`,
335
+ );
336
+ }
337
+
338
+ await this.addProfileWithAuth(
339
+ matrixId,
340
+ auth,
341
+ slots.displayName,
342
+ slots.realmServerUrl,
343
+ );
344
+ }
345
+
244
346
  async removeProfile(profileId: string): Promise<boolean> {
245
347
  if (!this.config.profiles[profileId]) {
246
348
  return false;
@@ -266,56 +368,6 @@ export class ProfileManager implements RealmAuthenticator {
266
368
  return true;
267
369
  }
268
370
 
269
- async getActiveCredentials(): Promise<{
270
- matrixUrl: string;
271
- username: string;
272
- password: string;
273
- realmServerUrl: string;
274
- profileId: string | null;
275
- } | null> {
276
- const active = this.getActiveProfile();
277
- if (active && active.profile.password) {
278
- return {
279
- matrixUrl: active.profile.matrixUrl,
280
- username: getUsernameFromMatrixId(active.id),
281
- password: active.profile.password,
282
- realmServerUrl: active.profile.realmServerUrl,
283
- profileId: active.id,
284
- };
285
- }
286
-
287
- const matrixUrl = process.env.MATRIX_URL;
288
- const username = process.env.MATRIX_USERNAME;
289
- const password = process.env.MATRIX_PASSWORD;
290
- const realmServerUrl = process.env.REALM_SERVER_URL;
291
-
292
- if (matrixUrl && username && password && realmServerUrl) {
293
- return {
294
- matrixUrl,
295
- username,
296
- password,
297
- realmServerUrl,
298
- profileId: null,
299
- };
300
- }
301
-
302
- return null;
303
- }
304
-
305
- async getPassword(profileId: string): Promise<string | null> {
306
- const profile = this.config.profiles[profileId];
307
- return profile?.password || null;
308
- }
309
-
310
- async updatePassword(profileId: string, password: string): Promise<boolean> {
311
- if (!this.config.profiles[profileId]) {
312
- return false;
313
- }
314
- this.config.profiles[profileId].password = password;
315
- this.saveConfig();
316
- return true;
317
- }
318
-
319
371
  updateDisplayName(profileId: string, displayName: string): boolean {
320
372
  if (!this.config.profiles[profileId]) {
321
373
  return false;
@@ -385,14 +437,92 @@ export class ProfileManager implements RealmAuthenticator {
385
437
  return active?.profile.realmServerToken;
386
438
  }
387
439
 
388
- private async loginToMatrix(): Promise<MatrixAuth> {
389
- let active = this.getActiveProfile();
390
- if (!active) {
391
- throw new Error('No active profile');
440
+ // Return the Matrix credentials stored for a profile. Sync — reads only
441
+ // the in-memory `this.config`, which is populated by the constructor.
442
+ // Throws when the profile has no stored token yet (e.g. a pre-CS-10725
443
+ // profile still on disk from before the password→token swap).
444
+ getStoredMatrixAuth(profileId?: string): MatrixAuth {
445
+ const targetId = profileId ?? this.config.activeProfile ?? undefined;
446
+ const profile = targetId ? this.config.profiles[targetId] : undefined;
447
+ if (!targetId || !profile) {
448
+ throw new Error(NO_ACTIVE_PROFILE_ERROR);
449
+ }
450
+ if (!profile.matrixAccessToken) {
451
+ throw new Error(
452
+ `Profile "${targetId}" has no stored Matrix access token. ` +
453
+ `Run \`boxel profile add\` to re-authenticate.`,
454
+ );
455
+ }
456
+ return {
457
+ accessToken: profile.matrixAccessToken,
458
+ userId: profile.matrixUserId,
459
+ deviceId: profile.matrixDeviceId,
460
+ matrixUrl: profile.matrixUrl,
461
+ };
462
+ }
463
+
464
+ // When the stored access token gets rejected by Matrix (revoked, expired,
465
+ // server-side device deletion), prompt the user for their password on a
466
+ // TTY, run matrixLogin again, persist the new tokens, and return the
467
+ // refreshed MatrixAuth. Non-TTY contexts get a clear "re-add the profile"
468
+ // error instead of hanging on a prompt that can never be answered.
469
+ async reAuthenticate(profileId?: string): Promise<MatrixAuth> {
470
+ const targetId = profileId ?? this.config.activeProfile ?? undefined;
471
+ const profile = targetId ? this.config.profiles[targetId] : undefined;
472
+ if (!targetId || !profile) {
473
+ throw new Error(NO_ACTIVE_PROFILE_ERROR);
474
+ }
475
+
476
+ if (!this.isTtyFn()) {
477
+ throw new Error(
478
+ `Stored Matrix token for "${targetId}" is no longer valid. ` +
479
+ `Run \`boxel profile add -u ${targetId} -p <password>\` to re-authenticate.`,
480
+ );
481
+ }
482
+
483
+ console.log(
484
+ `\n${FG_YELLOW}Stored Matrix session for ${formatProfileBadge(targetId)} has expired.${RESET}`,
485
+ );
486
+ const password = await this.promptPasswordFn(`Password for ${targetId}: `);
487
+ if (!password) {
488
+ throw new Error('Re-authentication cancelled: password is required.');
489
+ }
490
+
491
+ const username = getUsernameFromMatrixId(targetId);
492
+ const auth = await this.matrixLoginFn(
493
+ profile.matrixUrl,
494
+ username,
495
+ password,
496
+ );
497
+ await this.addProfileWithAuth(
498
+ targetId,
499
+ auth,
500
+ profile.displayName,
501
+ profile.realmServerUrl,
502
+ );
503
+ return this.getStoredMatrixAuth(targetId);
504
+ }
505
+
506
+ // Wrap a realm-server-token fetch in the standard "if Matrix says 401,
507
+ // re-auth and retry once" recovery. Centralised so getOrRefreshServerToken
508
+ // and refreshServerToken share the same behaviour.
509
+ private async fetchRealmServerTokenWithReauth(): Promise<string> {
510
+ const matrixAuth = this.getStoredMatrixAuth();
511
+ const active = this.getActiveProfile()!;
512
+ const realmServerUrl = active.profile.realmServerUrl.replace(/\/$/, '');
513
+ try {
514
+ const token = await fetchRealmServerToken(matrixAuth, realmServerUrl);
515
+ this.setRealmServerToken(token);
516
+ return token;
517
+ } catch (e) {
518
+ if (!(e instanceof MatrixAuthError)) {
519
+ throw e;
520
+ }
521
+ const freshAuth = await this.reAuthenticate();
522
+ const token = await fetchRealmServerToken(freshAuth, realmServerUrl);
523
+ this.setRealmServerToken(token);
524
+ return token;
392
525
  }
393
- let { id, profile } = active;
394
- let username = getUsernameFromMatrixId(id);
395
- return matrixLogin(profile.matrixUrl, username, profile.password);
396
526
  }
397
527
 
398
528
  async getOrRefreshServerToken(): Promise<string> {
@@ -400,21 +530,11 @@ export class ProfileManager implements RealmAuthenticator {
400
530
  if (cached && !isJwtNearExpiry(cached)) {
401
531
  return cached;
402
532
  }
403
- let matrixAuth = await this.loginToMatrix();
404
- let active = this.getActiveProfile()!;
405
- let realmServerUrl = active.profile.realmServerUrl.replace(/\/$/, '');
406
- let token = await fetchRealmServerToken(matrixAuth, realmServerUrl);
407
- this.setRealmServerToken(token);
408
- return token;
533
+ return this.fetchRealmServerTokenWithReauth();
409
534
  }
410
535
 
411
536
  async refreshServerToken(): Promise<string> {
412
- let matrixAuth = await this.loginToMatrix();
413
- let active = this.getActiveProfile()!;
414
- let realmServerUrl = active.profile.realmServerUrl.replace(/\/$/, '');
415
- let token = await fetchRealmServerToken(matrixAuth, realmServerUrl);
416
- this.setRealmServerToken(token);
417
- return token;
537
+ return this.fetchRealmServerTokenWithReauth();
418
538
  }
419
539
 
420
540
  private findRealmTokenForUrl(url: string): string | undefined {
@@ -546,19 +666,38 @@ export class ProfileManager implements RealmAuthenticator {
546
666
  return token;
547
667
  }
548
668
 
669
+ // Run a Matrix call that uses the stored access token, falling back to
670
+ // interactive re-auth + retry on a 401 (revoked / expired token).
671
+ private async withMatrixAuthRecovery<T>(
672
+ fn: (matrixAuth: MatrixAuth) => Promise<T>,
673
+ ): Promise<T> {
674
+ try {
675
+ return await fn(this.getStoredMatrixAuth());
676
+ } catch (e) {
677
+ if (!(e instanceof MatrixAuthError)) {
678
+ throw e;
679
+ }
680
+ const freshAuth = await this.reAuthenticate();
681
+ return fn(freshAuth);
682
+ }
683
+ }
684
+
549
685
  async addToUserRealms(realmUrl: string): Promise<void> {
550
- let matrixAuth = await this.loginToMatrix();
551
- await addRealmToMatrixAccountData(matrixAuth, realmUrl);
686
+ await this.withMatrixAuthRecovery((auth) =>
687
+ addRealmToMatrixAccountData(auth, realmUrl),
688
+ );
552
689
  }
553
690
 
554
691
  async removeFromUserRealms(realmUrl: string): Promise<boolean> {
555
- let matrixAuth = await this.loginToMatrix();
556
- return removeRealmFromMatrixAccountData(matrixAuth, realmUrl);
692
+ return this.withMatrixAuthRecovery((auth) =>
693
+ removeRealmFromMatrixAccountData(auth, realmUrl),
694
+ );
557
695
  }
558
696
 
559
697
  async getUserRealms(): Promise<string[]> {
560
- let matrixAuth = await this.loginToMatrix();
561
- return getUserRealmsFromMatrixAccountData(matrixAuth);
698
+ return this.withMatrixAuthRecovery((auth) =>
699
+ getUserRealmsFromMatrixAccountData(auth),
700
+ );
562
701
  }
563
702
 
564
703
  async migrateFromEnv(): Promise<{
@@ -578,15 +717,7 @@ export class ProfileManager implements RealmAuthenticator {
578
717
  const domain = isProduction ? 'boxel.ai' : 'stack.cards';
579
718
  const matrixId = `@${username}:${domain}`;
580
719
 
581
- if (this.config.profiles[matrixId]) {
582
- // Update password if it changed
583
- if (this.config.profiles[matrixId].password !== password) {
584
- this.config.profiles[matrixId].password = password;
585
- this.saveConfig();
586
- }
587
- return { profileId: matrixId, created: false };
588
- }
589
-
720
+ const created = !this.config.profiles[matrixId];
590
721
  await this.addProfile(
591
722
  matrixId,
592
723
  password,
@@ -594,7 +725,7 @@ export class ProfileManager implements RealmAuthenticator {
594
725
  matrixUrl,
595
726
  realmServerUrl,
596
727
  );
597
- return { profileId: matrixId, created: true };
728
+ return { profileId: matrixId, created };
598
729
  }
599
730
 
600
731
  printStatus(): void {
@@ -610,11 +741,6 @@ export class ProfileManager implements RealmAuthenticator {
610
741
  console.log(
611
742
  ` ${DIM}Realm Server:${RESET} ${active.profile.realmServerUrl}`,
612
743
  );
613
- } else if (process.env.MATRIX_USERNAME) {
614
- console.log(
615
- `\n${BOLD}Using environment variables${RESET} (no profile active)`,
616
- );
617
- console.log(` ${DIM}Username:${RESET} ${process.env.MATRIX_USERNAME}`);
618
744
  } else {
619
745
  console.log(
620
746
  `\n${FG_YELLOW}No active profile and no environment variables set.${RESET}`,