@cardstack/boxel-cli 0.2.0-unstable.425 → 0.2.0-unstable.446
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 +85 -85
- package/package.json +3 -3
- package/src/commands/profile.ts +24 -24
- package/src/lib/auth.ts +47 -5
- package/src/lib/profile-manager.ts +235 -109
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.446",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"description": "CLI tools for Boxel workspace management",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -54,8 +54,8 @@
|
|
|
54
54
|
"vite": "^6.3.2",
|
|
55
55
|
"vitest": "^2.1.9",
|
|
56
56
|
"@cardstack/postgres": "0.0.0",
|
|
57
|
-
"@cardstack/
|
|
58
|
-
"@cardstack/
|
|
57
|
+
"@cardstack/runtime-common": "1.0.0",
|
|
58
|
+
"@cardstack/local-types": "0.0.0"
|
|
59
59
|
},
|
|
60
60
|
"publishConfig": {
|
|
61
61
|
"access": "public",
|
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}`,
|
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
|
);
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
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
|
-
|
|
229
|
-
displayName: displayName || `${username} \u00b7 ${domain}`,
|
|
255
|
+
return {
|
|
230
256
|
matrixUrl: matrixUrl || defaultMatrixUrl,
|
|
231
257
|
realmServerUrl: realmServerUrl || defaultRealmUrl,
|
|
232
|
-
|
|
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
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
551
|
-
|
|
686
|
+
await this.withMatrixAuthRecovery((auth) =>
|
|
687
|
+
addRealmToMatrixAccountData(auth, realmUrl),
|
|
688
|
+
);
|
|
552
689
|
}
|
|
553
690
|
|
|
554
691
|
async removeFromUserRealms(realmUrl: string): Promise<boolean> {
|
|
555
|
-
|
|
556
|
-
|
|
692
|
+
return this.withMatrixAuthRecovery((auth) =>
|
|
693
|
+
removeRealmFromMatrixAccountData(auth, realmUrl),
|
|
694
|
+
);
|
|
557
695
|
}
|
|
558
696
|
|
|
559
697
|
async getUserRealms(): Promise<string[]> {
|
|
560
|
-
|
|
561
|
-
|
|
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
|
-
|
|
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
|
|
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}`,
|