@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.
- package/LICENSE +21 -0
- package/README.md +111 -0
- package/api.ts +24 -0
- package/dist/index.js +75 -0
- package/package.json +78 -0
- package/src/commands/profile.ts +457 -0
- package/src/commands/realm/create.ts +245 -0
- package/src/commands/realm/index.ts +16 -0
- package/src/commands/realm/pull.ts +245 -0
- package/src/commands/realm/push.ts +379 -0
- package/src/commands/realm/sync.ts +587 -0
- package/src/commands/run-command.ts +186 -0
- package/src/index.ts +47 -0
- package/src/lib/auth.ts +169 -0
- package/src/lib/boxel-cli-client.ts +631 -0
- package/src/lib/checkpoint-manager.ts +609 -0
- package/src/lib/colors.ts +9 -0
- package/src/lib/profile-manager.ts +583 -0
- package/src/lib/realm-sync-base.ts +647 -0
- package/src/lib/sync-logic.ts +169 -0
- package/src/lib/sync-manifest.ts +81 -0
|
@@ -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
|
+
}
|