@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,457 @@
1
+ import * as readline from 'readline';
2
+ import { Writable } from 'stream';
3
+ import type { ProfileManager } from '../lib/profile-manager';
4
+ import {
5
+ getProfileManager,
6
+ formatProfileBadge,
7
+ getEnvironmentFromMatrixId,
8
+ getEnvironmentLabel,
9
+ getUsernameFromMatrixId,
10
+ } from '../lib/profile-manager';
11
+ import {
12
+ FG_GREEN,
13
+ FG_YELLOW,
14
+ FG_CYAN,
15
+ FG_MAGENTA,
16
+ FG_RED,
17
+ DIM,
18
+ BOLD,
19
+ RESET,
20
+ } from '../lib/colors';
21
+
22
+ function prompt(question: string): Promise<string> {
23
+ const rl = readline.createInterface({
24
+ input: process.stdin,
25
+ output: process.stdout,
26
+ });
27
+
28
+ return new Promise((resolve) => {
29
+ rl.question(question, (answer) => {
30
+ rl.close();
31
+ resolve(answer.trim());
32
+ });
33
+ });
34
+ }
35
+
36
+ function promptPassword(question: string): Promise<string> {
37
+ const mutableOutput = new Writable({
38
+ write: (_chunk, _encoding, callback) => callback(),
39
+ });
40
+ const rl = readline.createInterface({
41
+ input: process.stdin,
42
+ output: mutableOutput,
43
+ terminal: true,
44
+ });
45
+
46
+ return new Promise((resolve, reject) => {
47
+ const stdin = process.stdin;
48
+ const wasFlowing = stdin.readableFlowing;
49
+
50
+ if (stdin.isTTY) {
51
+ stdin.setRawMode(true);
52
+ }
53
+
54
+ const cleanup = () => {
55
+ stdin.removeListener('data', onData);
56
+ if (stdin.isTTY) {
57
+ stdin.setRawMode(false);
58
+ }
59
+ rl.close();
60
+ if (!wasFlowing) {
61
+ stdin.pause();
62
+ }
63
+ };
64
+
65
+ const onData = (char: Buffer) => {
66
+ try {
67
+ const c = char.toString();
68
+ if (c === '\n' || c === '\r') {
69
+ cleanup();
70
+ process.stdout.write('\n');
71
+ resolve(password);
72
+ } else if (c === '\u0003') {
73
+ // Ctrl+C
74
+ cleanup();
75
+ process.exit();
76
+ } else if (c === '\u007F' || c === '\b') {
77
+ // Backspace
78
+ if (password.length > 0) {
79
+ password = password.slice(0, -1);
80
+ process.stdout.write('\b \b');
81
+ }
82
+ } else {
83
+ password += c;
84
+ process.stdout.write('*');
85
+ }
86
+ } catch (e) {
87
+ cleanup();
88
+ reject(e);
89
+ }
90
+ };
91
+
92
+ let password = '';
93
+ try {
94
+ process.stdout.write(question);
95
+ stdin.on('data', onData);
96
+ stdin.resume();
97
+ } catch (e) {
98
+ cleanup();
99
+ reject(e);
100
+ }
101
+ });
102
+ }
103
+
104
+ export interface ProfileCommandOptions {
105
+ user?: string;
106
+ password?: string;
107
+ name?: string;
108
+ }
109
+
110
+ export async function profileCommand(
111
+ subcommand?: string,
112
+ arg?: string,
113
+ options?: ProfileCommandOptions,
114
+ ): Promise<void> {
115
+ const manager = getProfileManager();
116
+
117
+ switch (subcommand) {
118
+ case 'list':
119
+ await listProfiles(manager);
120
+ break;
121
+
122
+ case 'add': {
123
+ const password = options?.password || process.env.BOXEL_PASSWORD;
124
+ if (options?.user && password) {
125
+ await addProfileNonInteractive(
126
+ manager,
127
+ options.user,
128
+ password,
129
+ options.name,
130
+ );
131
+ } else {
132
+ await addProfile(manager);
133
+ }
134
+ break;
135
+ }
136
+
137
+ case 'switch':
138
+ if (!arg) {
139
+ console.error(
140
+ `${FG_RED}Error:${RESET} Please specify a profile to switch to.`,
141
+ );
142
+ console.log(`Usage: boxel profile switch <profile-id>`);
143
+ console.log(`\nAvailable profiles:`);
144
+ await listProfiles(manager);
145
+ process.exit(1);
146
+ }
147
+ await switchProfile(manager, arg);
148
+ break;
149
+
150
+ case 'remove':
151
+ if (!arg) {
152
+ console.error(
153
+ `${FG_RED}Error:${RESET} Please specify a profile to remove.`,
154
+ );
155
+ process.exit(1);
156
+ }
157
+ await removeProfile(manager, arg);
158
+ break;
159
+
160
+ case 'migrate':
161
+ await migrateFromEnv(manager);
162
+ break;
163
+
164
+ default:
165
+ manager.printStatus();
166
+ console.log(`\n${DIM}Commands:${RESET}`);
167
+ console.log(
168
+ ` ${FG_CYAN}boxel profile list${RESET} List all profiles`,
169
+ );
170
+ console.log(
171
+ ` ${FG_CYAN}boxel profile add${RESET} Add a new profile`,
172
+ );
173
+ console.log(
174
+ ` ${FG_CYAN}boxel profile switch${RESET} Switch active profile`,
175
+ );
176
+ console.log(
177
+ ` ${FG_CYAN}boxel profile remove${RESET} Remove a profile`,
178
+ );
179
+ console.log(
180
+ ` ${FG_CYAN}boxel profile migrate${RESET} Import from .env file`,
181
+ );
182
+ }
183
+ }
184
+
185
+ async function listProfiles(manager: ProfileManager): Promise<void> {
186
+ const profiles = manager.listProfiles();
187
+ const activeId = manager.getActiveProfileId();
188
+
189
+ if (profiles.length === 0) {
190
+ console.log(`\n${FG_YELLOW}No profiles configured.${RESET}`);
191
+ console.log(`Run ${FG_CYAN}boxel profile add${RESET} to create one.`);
192
+ return;
193
+ }
194
+
195
+ console.log(`\n${BOLD}Saved Profiles:${RESET}\n`);
196
+
197
+ for (const id of profiles) {
198
+ const profile = manager.getProfile(id)!;
199
+ const isActive = id === activeId;
200
+ const env = getEnvironmentFromMatrixId(id);
201
+
202
+ const marker = isActive ? `${FG_GREEN}\u2605${RESET} ` : ' ';
203
+ const envLabel = getEnvironmentLabel(env);
204
+ const envColor = env === 'production' ? FG_MAGENTA : FG_CYAN;
205
+
206
+ console.log(`${marker}${BOLD}${id}${RESET}`);
207
+ console.log(` ${DIM}Name:${RESET} ${profile.displayName}`);
208
+ console.log(
209
+ ` ${DIM}Environment:${RESET} ${envColor}${envLabel}${RESET}`,
210
+ );
211
+ console.log(` ${DIM}Realm Server:${RESET} ${profile.realmServerUrl}`);
212
+ console.log('');
213
+ }
214
+
215
+ if (activeId) {
216
+ console.log(`${DIM}\u2605 = active profile${RESET}`);
217
+ }
218
+ }
219
+
220
+ async function addProfile(manager: ProfileManager): Promise<void> {
221
+ console.log(`\n${BOLD}Add New Profile${RESET}\n`);
222
+
223
+ console.log(`Which environment?`);
224
+ console.log(` ${FG_CYAN}1${RESET}) Staging (realms-staging.stack.cards)`);
225
+ console.log(` ${FG_MAGENTA}2${RESET}) Production (app.boxel.ai)`);
226
+ console.log(` ${FG_GREEN}3${RESET}) Local (localhost:4201)`);
227
+
228
+ const envChoice = await prompt('\nChoice [1/2/3]: ');
229
+ const isProduction = envChoice === '2';
230
+ const isLocal = envChoice === '3';
231
+
232
+ let domain: string;
233
+ let defaultMatrixUrl: string;
234
+ let defaultRealmUrl: string;
235
+
236
+ if (isLocal) {
237
+ domain = 'localhost';
238
+ defaultMatrixUrl = 'http://localhost:8008';
239
+ defaultRealmUrl = 'http://localhost:4201/';
240
+ } else if (isProduction) {
241
+ domain = 'boxel.ai';
242
+ defaultMatrixUrl = 'https://matrix.boxel.ai';
243
+ defaultRealmUrl = 'https://app.boxel.ai/';
244
+ } else {
245
+ domain = 'stack.cards';
246
+ defaultMatrixUrl = 'https://matrix-staging.stack.cards';
247
+ defaultRealmUrl = 'https://realms-staging.stack.cards/';
248
+ }
249
+
250
+ console.log(`\nEnter your Boxel username (without @ or domain)`);
251
+ console.log(`${DIM}Example: ctse, aallen90${RESET}`);
252
+ const username = await prompt('Username: ');
253
+
254
+ if (!username) {
255
+ console.error(`${FG_RED}Error:${RESET} Username is required.`);
256
+ process.exit(1);
257
+ }
258
+
259
+ const matrixId = `@${username}:${domain}`;
260
+
261
+ if (manager.getProfile(matrixId)) {
262
+ console.log(`\n${FG_YELLOW}Profile ${matrixId} already exists.${RESET}`);
263
+ const overwrite = await prompt('Overwrite? [y/N]: ');
264
+ if (overwrite.toLowerCase() !== 'y') {
265
+ console.log('Cancelled.');
266
+ return;
267
+ }
268
+ }
269
+
270
+ const password = await promptPassword('Password: ');
271
+
272
+ if (!password) {
273
+ console.error(`${FG_RED}Error:${RESET} Password is required.`);
274
+ process.exit(1);
275
+ }
276
+
277
+ const defaultDisplayName = `${username} \u00b7 ${domain}`;
278
+ const displayNameInput = await prompt(
279
+ `Display name [${defaultDisplayName}]: `,
280
+ );
281
+ const displayName = displayNameInput || defaultDisplayName;
282
+
283
+ await manager.addProfile(
284
+ matrixId,
285
+ password,
286
+ displayName,
287
+ defaultMatrixUrl,
288
+ defaultRealmUrl,
289
+ );
290
+
291
+ console.log(
292
+ `\n${FG_GREEN}\u2713${RESET} Profile created: ${formatProfileBadge(matrixId)}`,
293
+ );
294
+
295
+ if (manager.getActiveProfileId() === matrixId) {
296
+ console.log(`${DIM}This profile is now active.${RESET}`);
297
+ } else {
298
+ const switchNow = await prompt('Switch to this profile now? [Y/n]: ');
299
+ if (switchNow.toLowerCase() !== 'n') {
300
+ manager.switchProfile(matrixId);
301
+ console.log(
302
+ `${FG_GREEN}\u2713${RESET} Switched to ${formatProfileBadge(matrixId)}`,
303
+ );
304
+ }
305
+ }
306
+ }
307
+
308
+ async function switchProfile(
309
+ manager: ProfileManager,
310
+ profileId: string,
311
+ ): Promise<void> {
312
+ const profiles = manager.listProfiles();
313
+ let matchedId = profileId;
314
+
315
+ if (!profiles.includes(profileId)) {
316
+ const matches = profiles.filter((id) => {
317
+ const username = getUsernameFromMatrixId(id);
318
+ return id.includes(profileId) || username === profileId;
319
+ });
320
+
321
+ if (matches.length === 0) {
322
+ console.error(`${FG_RED}Error:${RESET} Profile not found: ${profileId}`);
323
+ console.log(`\nAvailable profiles:`);
324
+ for (const id of profiles) {
325
+ console.log(` ${id}`);
326
+ }
327
+ process.exit(1);
328
+ } else if (matches.length === 1) {
329
+ matchedId = matches[0];
330
+ } else {
331
+ console.error(`${FG_RED}Error:${RESET} Ambiguous profile: ${profileId}`);
332
+ console.log(`\nMatching profiles:`);
333
+ for (const id of matches) {
334
+ console.log(` ${id}`);
335
+ }
336
+ process.exit(1);
337
+ }
338
+ }
339
+
340
+ if (manager.switchProfile(matchedId)) {
341
+ console.log(
342
+ `${FG_GREEN}\u2713${RESET} Switched to ${formatProfileBadge(matchedId)}`,
343
+ );
344
+ } else {
345
+ console.error(`${FG_RED}Error:${RESET} Failed to switch profile.`);
346
+ process.exit(1);
347
+ }
348
+ }
349
+
350
+ async function removeProfile(
351
+ manager: ProfileManager,
352
+ profileId: string,
353
+ ): Promise<void> {
354
+ const profile = manager.getProfile(profileId);
355
+ if (!profile) {
356
+ console.error(`${FG_RED}Error:${RESET} Profile not found: ${profileId}`);
357
+ process.exit(1);
358
+ }
359
+
360
+ const confirm = await prompt(`Remove profile ${profileId}? [y/N]: `);
361
+ if (confirm.toLowerCase() !== 'y') {
362
+ console.log('Cancelled.');
363
+ return;
364
+ }
365
+
366
+ if (await manager.removeProfile(profileId)) {
367
+ console.log(`${FG_GREEN}\u2713${RESET} Profile removed.`);
368
+
369
+ const newActive = manager.getActiveProfileId();
370
+ if (newActive) {
371
+ console.log(`Active profile is now: ${formatProfileBadge(newActive)}`);
372
+ }
373
+ } else {
374
+ console.error(`${FG_RED}Error:${RESET} Failed to remove profile.`);
375
+ process.exit(1);
376
+ }
377
+ }
378
+
379
+ async function addProfileNonInteractive(
380
+ manager: ProfileManager,
381
+ matrixId: string,
382
+ password: string,
383
+ displayName?: string,
384
+ ): Promise<void> {
385
+ if (!matrixId.startsWith('@') || !matrixId.includes(':')) {
386
+ console.error(
387
+ `${FG_RED}Error:${RESET} Invalid Matrix ID format. Expected @user:domain`,
388
+ );
389
+ process.exit(1);
390
+ }
391
+
392
+ if (manager.getProfile(matrixId)) {
393
+ console.log(
394
+ `${FG_YELLOW}Profile ${matrixId} already exists. Updating password.${RESET}`,
395
+ );
396
+ await manager.updatePassword(matrixId, password);
397
+ if (displayName) {
398
+ manager.updateDisplayName(matrixId, displayName);
399
+ }
400
+ console.log(
401
+ `${FG_GREEN}\u2713${RESET} Profile updated: ${formatProfileBadge(matrixId)}`,
402
+ );
403
+ return;
404
+ }
405
+
406
+ await manager.addProfile(matrixId, password, displayName);
407
+ console.log(
408
+ `${FG_GREEN}\u2713${RESET} Profile created: ${formatProfileBadge(matrixId)}`,
409
+ );
410
+
411
+ const activeId = manager.getActiveProfileId();
412
+ if (activeId !== matrixId) {
413
+ console.log(
414
+ `${DIM}Use 'boxel profile switch ${matrixId}' to switch to this profile.${RESET}`,
415
+ );
416
+ }
417
+ }
418
+
419
+ async function migrateFromEnv(manager: ProfileManager): Promise<void> {
420
+ console.log(`\n${BOLD}Migrate from .env${RESET}\n`);
421
+
422
+ const matrixUrl = process.env.MATRIX_URL;
423
+ const username = process.env.MATRIX_USERNAME;
424
+ const password = process.env.MATRIX_PASSWORD;
425
+ const realmServerUrl = process.env.REALM_SERVER_URL;
426
+
427
+ if (!matrixUrl || !username || !password || !realmServerUrl) {
428
+ console.log(
429
+ `${FG_YELLOW}No complete credentials found in environment variables.${RESET}`,
430
+ );
431
+ console.log(
432
+ `\nRequired variables: MATRIX_URL, MATRIX_USERNAME, MATRIX_PASSWORD, REALM_SERVER_URL`,
433
+ );
434
+ return;
435
+ }
436
+
437
+ const result = await manager.migrateFromEnv();
438
+ if (result) {
439
+ if (result.created) {
440
+ console.log(
441
+ `${FG_GREEN}\u2713${RESET} Created profile: ${formatProfileBadge(result.profileId)}`,
442
+ );
443
+ console.log(
444
+ `\n${DIM}You can now remove credentials from .env if desired.${RESET}`,
445
+ );
446
+ } else {
447
+ console.log(
448
+ `${FG_YELLOW}Profile ${formatProfileBadge(result.profileId)} already exists.${RESET} Password has been updated if it changed.`,
449
+ );
450
+ console.log(
451
+ `\n${DIM}Use 'boxel profile add -u ${result.profileId} -p <password>' to update other fields.${RESET}`,
452
+ );
453
+ }
454
+ } else {
455
+ console.log(`${FG_YELLOW}Migration failed.${RESET}`);
456
+ }
457
+ }
@@ -0,0 +1,245 @@
1
+ import type { Command } from 'commander';
2
+ import {
3
+ iconURLFor,
4
+ getRandomBackgroundURL,
5
+ } from '@cardstack/runtime-common/realm-display-defaults';
6
+ import {
7
+ getProfileManager,
8
+ type ProfileManager,
9
+ } from '../../lib/profile-manager';
10
+ import { FG_GREEN, FG_CYAN, RESET } from '../../lib/colors';
11
+
12
+ const REALM_NAME_PATTERN = /^[a-z0-9-]+$/;
13
+
14
+ export function registerCreateCommand(realm: Command): void {
15
+ realm
16
+ .command('create')
17
+ .description('Create a new realm on the realm server')
18
+ .argument('<realm-name>', 'realm name (lowercase, numbers, hyphens only)')
19
+ .argument('<display-name>', 'display name for the realm')
20
+ .option('--background <url>', 'background image URL')
21
+ .option('--icon <url>', 'icon image URL')
22
+ .action(
23
+ async (
24
+ realmName: string,
25
+ displayName: string,
26
+ options: CreateCommandOptions,
27
+ ) => {
28
+ await executeCreateRealmCommand(realmName, displayName, options);
29
+ },
30
+ );
31
+ }
32
+
33
+ export interface CreateOptions {
34
+ background?: string;
35
+ icon?: string;
36
+ profileManager?: ProfileManager;
37
+ /** Wait for the realm to pass its readiness check (default: false). */
38
+ waitForReady?: boolean;
39
+ }
40
+
41
+ interface CreateCommandOptions {
42
+ background?: string;
43
+ icon?: string;
44
+ }
45
+
46
+ export interface CreateRealmResult {
47
+ realmUrl: string;
48
+ created: boolean;
49
+ realmToken?: string;
50
+ }
51
+
52
+ /**
53
+ * Core realm creation logic. Returns result on success, throws on failure.
54
+ * No console output or process.exit — suitable for programmatic use.
55
+ *
56
+ * Handles "already exists" gracefully by returning `created: false`
57
+ * with an authorization token for the existing realm.
58
+ */
59
+ export async function createRealm(
60
+ realmName: string,
61
+ displayName: string,
62
+ options: CreateOptions = {},
63
+ ): Promise<CreateRealmResult> {
64
+ let pm = options.profileManager ?? getProfileManager();
65
+ let active = pm.getActiveProfile();
66
+ if (!active) {
67
+ throw new Error(
68
+ 'No active profile. Run `boxel profile add` to create one.',
69
+ );
70
+ }
71
+
72
+ let realmServerUrl = active.profile.realmServerUrl.replace(/\/$/, '');
73
+
74
+ let attributes: Record<string, string | undefined> = {
75
+ endpoint: realmName,
76
+ name: displayName,
77
+ backgroundURL: options.background ?? getRandomBackgroundURL(),
78
+ iconURL: options.icon ?? iconURLFor(displayName) ?? iconURLFor(realmName),
79
+ };
80
+
81
+ let response = await pm.authedRealmServerFetch(
82
+ `${realmServerUrl}/_create-realm`,
83
+ {
84
+ method: 'POST',
85
+ headers: { 'Content-Type': 'application/vnd.api+json' },
86
+ body: JSON.stringify({
87
+ data: { type: 'realm', attributes },
88
+ }),
89
+ },
90
+ );
91
+
92
+ if (!response.ok) {
93
+ let errorBody: string;
94
+ try {
95
+ errorBody = await response.text();
96
+ } catch {
97
+ errorBody = 'server returned a non-serialized object body';
98
+ }
99
+ if (errorBody.includes('[object Object]')) {
100
+ errorBody = 'server returned a non-serialized object body';
101
+ }
102
+
103
+ if (errorBody.includes('already exists')) {
104
+ let realmUrl = extractRealmUrlFromError(
105
+ errorBody,
106
+ realmServerUrl,
107
+ realmName,
108
+ );
109
+ let realmToken = await fetchRealmToken(pm, realmUrl);
110
+
111
+ try {
112
+ await pm.addToUserRealms(realmUrl);
113
+ } catch {
114
+ // Non-critical
115
+ }
116
+
117
+ return { realmUrl, created: false, realmToken };
118
+ }
119
+
120
+ throw new Error(`Realm server returned ${response.status}: ${errorBody}`);
121
+ }
122
+
123
+ let result = await response.json();
124
+ let rawRealmUrl = result?.data?.id;
125
+ if (typeof rawRealmUrl !== 'string' || rawRealmUrl.trim() === '') {
126
+ throw new Error(
127
+ `Realm server response did not include a realm URL (data.id) for "${realmName}".`,
128
+ );
129
+ }
130
+ let realmUrl = ensureTrailingSlash(rawRealmUrl);
131
+
132
+ let realmToken = await fetchRealmToken(pm, realmUrl);
133
+
134
+ try {
135
+ await pm.addToUserRealms(realmUrl);
136
+ } catch {
137
+ // Non-critical — realm still works without dashboard registration
138
+ }
139
+
140
+ if (options.waitForReady && realmToken) {
141
+ await waitForRealmReady(realmUrl, realmToken);
142
+ }
143
+
144
+ return {
145
+ realmUrl,
146
+ created: true,
147
+ realmToken,
148
+ };
149
+ }
150
+
151
+ async function fetchRealmToken(
152
+ pm: ProfileManager,
153
+ realmUrl: string,
154
+ ): Promise<string | undefined> {
155
+ try {
156
+ let serverToken = await pm.getOrRefreshServerToken();
157
+ return await pm.fetchAndStoreRealmToken(realmUrl, serverToken);
158
+ } catch {
159
+ return undefined;
160
+ }
161
+ }
162
+
163
+ async function waitForRealmReady(
164
+ realmUrl: string,
165
+ authorization: string,
166
+ ): Promise<void> {
167
+ let readinessUrl = new URL('_readiness-check', realmUrl).href;
168
+ let timeoutMs = 15_000;
169
+ let retryDelayMs = 250;
170
+ let startedAt = Date.now();
171
+ let lastError: string | undefined;
172
+
173
+ while (Date.now() - startedAt < timeoutMs) {
174
+ try {
175
+ let response = await fetch(readinessUrl, {
176
+ headers: {
177
+ Accept: 'application/vnd.api+json',
178
+ Authorization: authorization,
179
+ },
180
+ });
181
+
182
+ if (response.ok) {
183
+ return;
184
+ }
185
+
186
+ lastError = `HTTP ${response.status}`;
187
+ } catch (error) {
188
+ lastError = error instanceof Error ? error.message : String(error);
189
+ }
190
+
191
+ await new Promise((resolve) => setTimeout(resolve, retryDelayMs));
192
+ }
193
+
194
+ throw new Error(
195
+ `Timed out waiting for realm ${realmUrl} to become ready${
196
+ lastError ? `: ${lastError}` : ''
197
+ }`,
198
+ );
199
+ }
200
+
201
+ /**
202
+ * CLI entry point for `boxel realm create`. Validates input, calls createRealm,
203
+ * formats output, and exits on error.
204
+ */
205
+ async function executeCreateRealmCommand(
206
+ realmName: string,
207
+ displayName: string,
208
+ options: CreateCommandOptions,
209
+ ): Promise<void> {
210
+ if (!REALM_NAME_PATTERN.test(realmName)) {
211
+ console.error(
212
+ 'Error: realm name must contain only lowercase letters, numbers, and hyphens',
213
+ );
214
+ process.exit(1);
215
+ }
216
+
217
+ try {
218
+ let result = await createRealm(realmName, displayName, options);
219
+ let verb = result.created ? 'created' : 'already exists';
220
+ console.log(
221
+ `${FG_GREEN}Realm ${verb}:${RESET} ${FG_CYAN}${result.realmUrl}${RESET}`,
222
+ );
223
+ } catch (e: unknown) {
224
+ console.error(`Error: ${e instanceof Error ? e.message : String(e)}`);
225
+ process.exit(1);
226
+ }
227
+ }
228
+
229
+ function extractRealmUrlFromError(
230
+ errorBody: string,
231
+ realmServerUrl: string,
232
+ endpoint: string,
233
+ ): string {
234
+ let urlMatch = errorBody.match(/'(https?:\/\/[^']+)'/);
235
+ if (urlMatch) {
236
+ return ensureTrailingSlash(urlMatch[1]);
237
+ }
238
+ throw new Error(
239
+ `Could not determine realm URL from server error response for endpoint "${endpoint}" on "${realmServerUrl}". The response did not include an explicit realm URL.`,
240
+ );
241
+ }
242
+
243
+ function ensureTrailingSlash(url: string): string {
244
+ return url.endsWith('/') ? url : `${url}/`;
245
+ }
@@ -0,0 +1,16 @@
1
+ import type { Command } from 'commander';
2
+ import { registerCreateCommand } from './create';
3
+ import { registerPullCommand } from './pull';
4
+ import { registerPushCommand } from './push';
5
+ import { registerSyncCommand } from './sync';
6
+
7
+ export function registerRealmCommand(program: Command): void {
8
+ let realm = program
9
+ .command('realm')
10
+ .description('Manage realms on the realm server');
11
+
12
+ registerCreateCommand(realm);
13
+ registerPullCommand(realm);
14
+ registerPushCommand(realm);
15
+ registerSyncCommand(realm);
16
+ }