@cardstack/boxel-cli 0.0.1

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.
@@ -0,0 +1,583 @@
1
+ import * as fs from 'fs';
2
+ import * as path from 'path';
3
+ import * as os from 'os';
4
+ import { FG_YELLOW, FG_CYAN, FG_MAGENTA, DIM, BOLD, RESET } from './colors';
5
+ import {
6
+ matrixLogin,
7
+ getRealmServerToken as fetchRealmServerToken,
8
+ getRealmTokens,
9
+ addRealmToMatrixAccountData,
10
+ type MatrixAuth,
11
+ } from './auth';
12
+
13
+ const DEFAULT_CONFIG_DIR = path.join(os.homedir(), '.boxel-cli');
14
+ const PROFILES_FILENAME = 'profiles.json';
15
+
16
+ export interface Profile {
17
+ displayName: string;
18
+ matrixUrl: string;
19
+ realmServerUrl: string;
20
+ password: string; // Stored in plaintext - file should have restricted permissions, this will be updated in CS-10642
21
+ realmTokens?: Record<string, string>;
22
+ realmServerToken?: string;
23
+ }
24
+
25
+ export interface ProfilesConfig {
26
+ profiles: Record<string, Profile>;
27
+ activeProfile: string | null;
28
+ }
29
+
30
+ export type Environment = 'staging' | 'production' | 'local' | 'unknown';
31
+
32
+ /**
33
+ * Extract environment from Matrix user ID
34
+ * @example @ctse:stack.cards -> staging
35
+ * @example @ctse:boxel.ai -> production
36
+ */
37
+ export function getEnvironmentFromMatrixId(matrixId: string): Environment {
38
+ if (matrixId.endsWith(':stack.cards')) return 'staging';
39
+ if (matrixId.endsWith(':boxel.ai')) return 'production';
40
+ if (matrixId.endsWith(':localhost')) return 'local';
41
+ return 'unknown';
42
+ }
43
+
44
+ /**
45
+ * Extract username from Matrix user ID
46
+ * @example @ctse:stack.cards -> ctse
47
+ */
48
+ export function getUsernameFromMatrixId(matrixId: string): string {
49
+ const match = matrixId.match(/^@([^:]+):/);
50
+ return match ? match[1] : matrixId;
51
+ }
52
+
53
+ /**
54
+ * Get domain from Matrix user ID
55
+ * @example @ctse:stack.cards -> stack.cards
56
+ */
57
+ export function getDomainFromMatrixId(matrixId: string): string {
58
+ const match = matrixId.match(/:([^:]+)$/);
59
+ return match ? match[1] : 'unknown';
60
+ }
61
+
62
+ /**
63
+ * Get environment label for display (uses domain)
64
+ */
65
+ export function getEnvironmentLabel(env: Environment): string {
66
+ switch (env) {
67
+ case 'staging':
68
+ return 'stack.cards';
69
+ case 'production':
70
+ return 'boxel.ai';
71
+ case 'local':
72
+ return 'localhost';
73
+ default:
74
+ return 'unknown';
75
+ }
76
+ }
77
+
78
+ /**
79
+ * Format profile for display in command output
80
+ * @example [ctse · staging]
81
+ */
82
+ export function formatProfileBadge(matrixId: string): string {
83
+ const username = getUsernameFromMatrixId(matrixId);
84
+ const env = getEnvironmentLabel(getEnvironmentFromMatrixId(matrixId));
85
+ return `${DIM}[${RESET}${FG_CYAN}${username}${RESET} ${DIM}\u00b7${RESET} ${FG_MAGENTA}${env}${RESET}${DIM}]${RESET}`;
86
+ }
87
+
88
+ export class ProfileManager {
89
+ private config: ProfilesConfig;
90
+ private configDir: string;
91
+ private profilesFile: string;
92
+
93
+ constructor(configDir?: string) {
94
+ this.configDir = configDir || DEFAULT_CONFIG_DIR;
95
+ this.profilesFile = path.join(this.configDir, PROFILES_FILENAME);
96
+ this.config = this.loadConfig();
97
+ }
98
+
99
+ private ensureConfigDir(): void {
100
+ if (!fs.existsSync(this.configDir)) {
101
+ fs.mkdirSync(this.configDir, { recursive: true });
102
+ }
103
+ }
104
+
105
+ private loadConfig(): ProfilesConfig {
106
+ const defaultConfig: ProfilesConfig = { profiles: {}, activeProfile: null };
107
+
108
+ if (fs.existsSync(this.profilesFile)) {
109
+ try {
110
+ const data = fs.readFileSync(this.profilesFile, 'utf-8');
111
+ const parsed: unknown = JSON.parse(data);
112
+
113
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
114
+ const candidate = parsed as Record<string, unknown>;
115
+ const profiles =
116
+ candidate.profiles &&
117
+ typeof candidate.profiles === 'object' &&
118
+ !Array.isArray(candidate.profiles)
119
+ ? (candidate.profiles as ProfilesConfig['profiles'])
120
+ : null;
121
+ const activeProfile =
122
+ candidate.activeProfile === null ||
123
+ typeof candidate.activeProfile === 'string'
124
+ ? (candidate.activeProfile as string | null)
125
+ : null;
126
+
127
+ if (profiles) {
128
+ return { profiles, activeProfile };
129
+ }
130
+ }
131
+ } catch {
132
+ // Corrupted file, start fresh
133
+ }
134
+ }
135
+ return defaultConfig;
136
+ }
137
+
138
+ private saveConfig(): void {
139
+ this.ensureConfigDir();
140
+ fs.writeFileSync(this.profilesFile, JSON.stringify(this.config, null, 2), {
141
+ mode: 0o600,
142
+ });
143
+ try {
144
+ fs.chmodSync(this.profilesFile, 0o600);
145
+ } catch {
146
+ // Ignore permission errors on Windows
147
+ }
148
+ }
149
+
150
+ listProfiles(): string[] {
151
+ return Object.keys(this.config.profiles);
152
+ }
153
+
154
+ getProfile(profileId: string): Profile | undefined {
155
+ return this.config.profiles[profileId];
156
+ }
157
+
158
+ getActiveProfileId(): string | null {
159
+ return this.config.activeProfile;
160
+ }
161
+
162
+ getActiveProfile(): { id: string; profile: Profile } | null {
163
+ const id = this.config.activeProfile;
164
+ if (!id) return null;
165
+ const profile = this.config.profiles[id];
166
+ if (!profile) return null;
167
+ return { id, profile };
168
+ }
169
+
170
+ async addProfile(
171
+ matrixId: string,
172
+ password: string,
173
+ displayName?: string,
174
+ matrixUrl?: string,
175
+ realmServerUrl?: string,
176
+ ): Promise<void> {
177
+ const env = getEnvironmentFromMatrixId(matrixId);
178
+ const username = getUsernameFromMatrixId(matrixId);
179
+
180
+ if (env === 'unknown' && (!matrixUrl || !realmServerUrl)) {
181
+ throw new Error(
182
+ `Unknown domain in Matrix ID "${matrixId}". You must provide explicit --matrix-url and --realm-server-url for non-standard domains.`,
183
+ );
184
+ }
185
+
186
+ const defaultMatrixUrl =
187
+ env === 'production'
188
+ ? 'https://matrix.boxel.ai'
189
+ : 'https://matrix-staging.stack.cards';
190
+ const defaultRealmUrl =
191
+ env === 'production'
192
+ ? 'https://app.boxel.ai/'
193
+ : 'https://realms-staging.stack.cards/';
194
+
195
+ const domain = getDomainFromMatrixId(matrixId);
196
+ const profile: Profile = {
197
+ displayName: displayName || `${username} \u00b7 ${domain}`,
198
+ matrixUrl: matrixUrl || defaultMatrixUrl,
199
+ realmServerUrl: realmServerUrl || defaultRealmUrl,
200
+ password,
201
+ };
202
+
203
+ this.config.profiles[matrixId] = profile;
204
+
205
+ if (!this.config.activeProfile) {
206
+ this.config.activeProfile = matrixId;
207
+ }
208
+
209
+ this.saveConfig();
210
+ }
211
+
212
+ async removeProfile(profileId: string): Promise<boolean> {
213
+ if (!this.config.profiles[profileId]) {
214
+ return false;
215
+ }
216
+
217
+ delete this.config.profiles[profileId];
218
+
219
+ if (this.config.activeProfile === profileId) {
220
+ const remaining = Object.keys(this.config.profiles);
221
+ this.config.activeProfile = remaining.length > 0 ? remaining[0] : null;
222
+ }
223
+
224
+ this.saveConfig();
225
+ return true;
226
+ }
227
+
228
+ switchProfile(profileId: string): boolean {
229
+ if (!this.config.profiles[profileId]) {
230
+ return false;
231
+ }
232
+ this.config.activeProfile = profileId;
233
+ this.saveConfig();
234
+ return true;
235
+ }
236
+
237
+ async getActiveCredentials(): Promise<{
238
+ matrixUrl: string;
239
+ username: string;
240
+ password: string;
241
+ realmServerUrl: string;
242
+ profileId: string | null;
243
+ } | null> {
244
+ const active = this.getActiveProfile();
245
+ if (active && active.profile.password) {
246
+ return {
247
+ matrixUrl: active.profile.matrixUrl,
248
+ username: getUsernameFromMatrixId(active.id),
249
+ password: active.profile.password,
250
+ realmServerUrl: active.profile.realmServerUrl,
251
+ profileId: active.id,
252
+ };
253
+ }
254
+
255
+ const matrixUrl = process.env.MATRIX_URL;
256
+ const username = process.env.MATRIX_USERNAME;
257
+ const password = process.env.MATRIX_PASSWORD;
258
+ const realmServerUrl = process.env.REALM_SERVER_URL;
259
+
260
+ if (matrixUrl && username && password && realmServerUrl) {
261
+ return {
262
+ matrixUrl,
263
+ username,
264
+ password,
265
+ realmServerUrl,
266
+ profileId: null,
267
+ };
268
+ }
269
+
270
+ return null;
271
+ }
272
+
273
+ async getPassword(profileId: string): Promise<string | null> {
274
+ const profile = this.config.profiles[profileId];
275
+ return profile?.password || null;
276
+ }
277
+
278
+ async updatePassword(profileId: string, password: string): Promise<boolean> {
279
+ if (!this.config.profiles[profileId]) {
280
+ return false;
281
+ }
282
+ this.config.profiles[profileId].password = password;
283
+ this.saveConfig();
284
+ return true;
285
+ }
286
+
287
+ updateDisplayName(profileId: string, displayName: string): boolean {
288
+ if (!this.config.profiles[profileId]) {
289
+ return false;
290
+ }
291
+ this.config.profiles[profileId].displayName = displayName;
292
+ this.saveConfig();
293
+ return true;
294
+ }
295
+
296
+ setRealmToken(realmUrl: string, token: string): void {
297
+ let active = this.getActiveProfile();
298
+ if (!active) {
299
+ return;
300
+ }
301
+ if (!active.profile.realmTokens) {
302
+ active.profile.realmTokens = {};
303
+ }
304
+ active.profile.realmTokens[realmUrl] = token;
305
+ this.saveConfig();
306
+ }
307
+
308
+ getRealmToken(realmUrl: string): string | undefined {
309
+ let active = this.getActiveProfile();
310
+ return active?.profile.realmTokens?.[realmUrl];
311
+ }
312
+
313
+ setRealmServerToken(token: string): void {
314
+ let active = this.getActiveProfile();
315
+ if (!active) {
316
+ return;
317
+ }
318
+ active.profile.realmServerToken = token;
319
+ this.saveConfig();
320
+ }
321
+
322
+ getRealmServerToken(): string | undefined {
323
+ let active = this.getActiveProfile();
324
+ return active?.profile.realmServerToken;
325
+ }
326
+
327
+ private async loginToMatrix(): Promise<MatrixAuth> {
328
+ let active = this.getActiveProfile();
329
+ if (!active) {
330
+ throw new Error('No active profile');
331
+ }
332
+ let { id, profile } = active;
333
+ let username = getUsernameFromMatrixId(id);
334
+ return matrixLogin(profile.matrixUrl, username, profile.password);
335
+ }
336
+
337
+ async getOrRefreshServerToken(): Promise<string> {
338
+ let cached = this.getRealmServerToken();
339
+ if (cached) {
340
+ return cached;
341
+ }
342
+ let matrixAuth = await this.loginToMatrix();
343
+ let active = this.getActiveProfile()!;
344
+ let realmServerUrl = active.profile.realmServerUrl.replace(/\/$/, '');
345
+ let token = await fetchRealmServerToken(matrixAuth, realmServerUrl);
346
+ this.setRealmServerToken(token);
347
+ return token;
348
+ }
349
+
350
+ async refreshServerToken(): Promise<string> {
351
+ let matrixAuth = await this.loginToMatrix();
352
+ let active = this.getActiveProfile()!;
353
+ let realmServerUrl = active.profile.realmServerUrl.replace(/\/$/, '');
354
+ let token = await fetchRealmServerToken(matrixAuth, realmServerUrl);
355
+ this.setRealmServerToken(token);
356
+ return token;
357
+ }
358
+
359
+ private findRealmTokenForUrl(url: string): string | undefined {
360
+ let active = this.getActiveProfile();
361
+ let realmTokens = active?.profile.realmTokens;
362
+ if (!realmTokens) {
363
+ return undefined;
364
+ }
365
+ for (let [realmUrl, token] of Object.entries(realmTokens)) {
366
+ if (url.startsWith(realmUrl) && token) {
367
+ return token;
368
+ }
369
+ }
370
+ return undefined;
371
+ }
372
+
373
+ private async fetchAndStoreAllRealmTokens(): Promise<void> {
374
+ let serverToken = await this.getOrRefreshServerToken();
375
+ let active = this.getActiveProfile()!;
376
+ let realmServerUrl = active.profile.realmServerUrl.replace(/\/$/, '');
377
+ let tokens = await getRealmTokens(realmServerUrl, serverToken);
378
+ for (let [realmUrl, token] of Object.entries(tokens)) {
379
+ this.setRealmToken(realmUrl, token);
380
+ }
381
+ }
382
+
383
+ async getRealmTokenForUrl(url: string): Promise<string | undefined> {
384
+ let realmToken = this.findRealmTokenForUrl(url);
385
+ if (realmToken) {
386
+ return realmToken;
387
+ }
388
+
389
+ try {
390
+ await this.fetchAndStoreAllRealmTokens();
391
+ } catch {
392
+ // Token prefetch failed (e.g. expired server token) — caller will handle 401 retry
393
+ return undefined;
394
+ }
395
+ return this.findRealmTokenForUrl(url);
396
+ }
397
+
398
+ private buildHeaders(
399
+ input: string | URL | Request,
400
+ init: RequestInit | undefined,
401
+ token: string,
402
+ ): Headers {
403
+ let baseHeaders =
404
+ input instanceof Request ? new Headers(input.headers) : new Headers();
405
+ let initHeaders = new Headers(init?.headers);
406
+ for (let [key, value] of initHeaders) {
407
+ baseHeaders.set(key, value);
408
+ }
409
+ if (!baseHeaders.has('Authorization')) {
410
+ baseHeaders.set('Authorization', token);
411
+ }
412
+ return baseHeaders;
413
+ }
414
+
415
+ async authedRealmFetch(
416
+ input: string | URL | Request,
417
+ init?: RequestInit,
418
+ ): Promise<Response> {
419
+ let url =
420
+ input instanceof Request
421
+ ? input.url
422
+ : input instanceof URL
423
+ ? input.href
424
+ : input;
425
+
426
+ let token = await this.getRealmTokenForUrl(url);
427
+ if (token) {
428
+ let headers = this.buildHeaders(input, init, token);
429
+ let response = await fetch(input, { ...init, headers });
430
+
431
+ if (response.status !== 401) {
432
+ return response;
433
+ }
434
+ }
435
+
436
+ // Either no cached realm token (e.g. server token was expired during
437
+ // prefetch) or the request got a 401. Refresh everything and retry.
438
+ let active = this.getActiveProfile();
439
+ if (active) {
440
+ active.profile.realmTokens = {};
441
+ active.profile.realmServerToken = undefined;
442
+ this.saveConfig();
443
+ }
444
+ await this.fetchAndStoreAllRealmTokens();
445
+ token = this.findRealmTokenForUrl(url);
446
+ if (!token) {
447
+ throw new Error(
448
+ `No realm token available for ${url}. The realm may not be accessible.`,
449
+ );
450
+ }
451
+ let headers = this.buildHeaders(input, init, token);
452
+ let response = await fetch(input, { ...init, headers });
453
+
454
+ return response;
455
+ }
456
+
457
+ async authedRealmServerFetch(
458
+ input: string | URL | Request,
459
+ init?: RequestInit,
460
+ ): Promise<Response> {
461
+ let token = await this.getOrRefreshServerToken();
462
+ let headers = this.buildHeaders(input, init, token);
463
+ let response = await fetch(input, { ...init, headers });
464
+
465
+ if (response.status === 401) {
466
+ token = await this.refreshServerToken();
467
+ headers = this.buildHeaders(input, init, token);
468
+ response = await fetch(input, { ...init, headers });
469
+ }
470
+
471
+ return response;
472
+ }
473
+
474
+ async fetchAndStoreRealmToken(
475
+ realmUrl: string,
476
+ serverToken: string,
477
+ ): Promise<string | undefined> {
478
+ let active = this.getActiveProfile()!;
479
+ let realmServerUrl = active.profile.realmServerUrl.replace(/\/$/, '');
480
+ let tokens = await getRealmTokens(realmServerUrl, serverToken);
481
+ let token = tokens[realmUrl];
482
+ if (token) {
483
+ this.setRealmToken(realmUrl, token);
484
+ }
485
+ return token;
486
+ }
487
+
488
+ async addToUserRealms(realmUrl: string): Promise<void> {
489
+ let matrixAuth = await this.loginToMatrix();
490
+ await addRealmToMatrixAccountData(matrixAuth, realmUrl);
491
+ }
492
+
493
+ async migrateFromEnv(): Promise<{
494
+ profileId: string;
495
+ created: boolean;
496
+ } | null> {
497
+ const matrixUrl = process.env.MATRIX_URL;
498
+ const username = process.env.MATRIX_USERNAME;
499
+ const password = process.env.MATRIX_PASSWORD;
500
+ const realmServerUrl = process.env.REALM_SERVER_URL;
501
+
502
+ if (!matrixUrl || !username || !password || !realmServerUrl) {
503
+ return null;
504
+ }
505
+
506
+ const isProduction = matrixUrl.includes('boxel.ai');
507
+ const domain = isProduction ? 'boxel.ai' : 'stack.cards';
508
+ const matrixId = `@${username}:${domain}`;
509
+
510
+ if (this.config.profiles[matrixId]) {
511
+ // Update password if it changed
512
+ if (this.config.profiles[matrixId].password !== password) {
513
+ this.config.profiles[matrixId].password = password;
514
+ this.saveConfig();
515
+ }
516
+ return { profileId: matrixId, created: false };
517
+ }
518
+
519
+ await this.addProfile(
520
+ matrixId,
521
+ password,
522
+ undefined,
523
+ matrixUrl,
524
+ realmServerUrl,
525
+ );
526
+ return { profileId: matrixId, created: true };
527
+ }
528
+
529
+ printStatus(): void {
530
+ const active = this.getActiveProfile();
531
+ if (active) {
532
+ console.log(
533
+ `\n${BOLD}Active Profile:${RESET} ${formatProfileBadge(active.id)}`,
534
+ );
535
+ console.log(
536
+ ` ${DIM}Display Name:${RESET} ${active.profile.displayName}`,
537
+ );
538
+ console.log(` ${DIM}Matrix URL:${RESET} ${active.profile.matrixUrl}`);
539
+ console.log(
540
+ ` ${DIM}Realm Server:${RESET} ${active.profile.realmServerUrl}`,
541
+ );
542
+ } else if (process.env.MATRIX_USERNAME) {
543
+ console.log(
544
+ `\n${BOLD}Using environment variables${RESET} (no profile active)`,
545
+ );
546
+ console.log(` ${DIM}Username:${RESET} ${process.env.MATRIX_USERNAME}`);
547
+ } else {
548
+ console.log(
549
+ `\n${FG_YELLOW}No active profile and no environment variables set.${RESET}`,
550
+ );
551
+ console.log(
552
+ `Run ${FG_CYAN}boxel profile add${RESET} to create a profile.`,
553
+ );
554
+ }
555
+ }
556
+ }
557
+
558
+ // Singleton instance — callers needing a custom configDir should use
559
+ // `new ProfileManager(dir)` directly.
560
+ let _instance: ProfileManager | null = null;
561
+
562
+ export function getProfileManager(): ProfileManager {
563
+ if (!_instance) {
564
+ _instance = new ProfileManager();
565
+ }
566
+ return _instance;
567
+ }
568
+
569
+ /**
570
+ * Reset the singleton (useful for testing)
571
+ */
572
+ export function resetProfileManager(): void {
573
+ _instance = null;
574
+ }
575
+
576
+ /**
577
+ * Replace the singleton with a ProfileManager using a custom config directory.
578
+ * Useful for tests that need an isolated profile without touching the real
579
+ * ~/.boxel-cli/profiles.json.
580
+ */
581
+ export function setProfileManager(configDir: string): void {
582
+ _instance = new ProfileManager(configDir);
583
+ }