-
Notifications
You must be signed in to change notification settings - Fork 12
boxel cli: add boxel realm create command
#4368
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
8ce874a
1d9086f
a102b7d
4530572
0593e8a
cf18949
48baf00
b95848e
6958d62
a799a0e
281e49d
02c832c
1d8d5a5
27e3fad
738c538
d1a726c
a98a49d
d4f076f
cdf7619
185c005
0e805aa
0e1b492
6ec0edc
6cdc3a6
861f39b
d09eaf1
cb93163
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,131 @@ | ||
| import type { Command } from 'commander'; | ||
| import { | ||
| iconURLFor, | ||
| getRandomBackgroundURL, | ||
| } from '@cardstack/runtime-common/realm-display-defaults'; | ||
| import { | ||
| getProfileManager, | ||
| type ProfileManager, | ||
| } from '../../lib/profile-manager'; | ||
| import { FG_GREEN, FG_CYAN, DIM, RESET } from '../../lib/colors'; | ||
|
|
||
| const REALM_NAME_PATTERN = /^[a-z0-9-]+$/; | ||
|
|
||
| export function registerCreateCommand(realm: Command): void { | ||
| realm | ||
| .command('create') | ||
| .description('Create a new realm on the realm server') | ||
| .argument('<realm-name>', 'realm name (lowercase, numbers, hyphens only)') | ||
| .argument('<display-name>', 'display name for the realm') | ||
| .option('--background <url>', 'background image URL') | ||
| .option('--icon <url>', 'icon image URL') | ||
| .action( | ||
| async ( | ||
| realmName: string, | ||
| displayName: string, | ||
| options: CreateOptions, | ||
| ) => { | ||
| await createRealm(realmName, displayName, options); | ||
| }, | ||
| ); | ||
| } | ||
|
|
||
| export interface CreateOptions { | ||
| background?: string; | ||
| icon?: string; | ||
| profileManager?: ProfileManager; | ||
| } | ||
|
|
||
| export async function createRealm( | ||
| realmName: string, | ||
| displayName: string, | ||
| options: CreateOptions, | ||
| ): Promise<void> { | ||
| if (!REALM_NAME_PATTERN.test(realmName)) { | ||
| console.error( | ||
| 'Error: realm name must contain only lowercase letters, numbers, and hyphens', | ||
| ); | ||
| process.exit(1); | ||
| } | ||
|
|
||
| let pm = options.profileManager ?? getProfileManager(); | ||
| let active = pm.getActiveProfile(); | ||
| if (!active) { | ||
| console.error( | ||
| 'Error: no active profile. Run `boxel profile add` to create one.', | ||
| ); | ||
| process.exit(1); | ||
| } | ||
|
|
||
| let realmServerUrl = active.profile.realmServerUrl.replace(/\/$/, ''); | ||
|
|
||
| let attributes: Record<string, string | undefined> = { | ||
| endpoint: realmName, | ||
| name: displayName, | ||
| backgroundURL: options.background ?? getRandomBackgroundURL(), | ||
| iconURL: options.icon ?? iconURLFor(displayName) ?? iconURLFor(realmName), | ||
| }; | ||
|
|
||
| let response: Response; | ||
| try { | ||
| response = await pm.authedFetch(`${realmServerUrl}/_create-realm`, { | ||
| method: 'POST', | ||
| headers: { 'Content-Type': 'application/vnd.api+json' }, | ||
| body: JSON.stringify({ | ||
| data: { type: 'realm', attributes }, | ||
| }), | ||
| }); | ||
| } catch (e: unknown) { | ||
| console.error(`Error: failed to connect to realm server`); | ||
| console.error(e instanceof Error ? e.message : String(e)); | ||
| process.exit(1); | ||
| } | ||
|
|
||
| if (!response.ok) { | ||
| let errorBody = await response.text(); | ||
| console.error(`Error: realm server returned ${response.status}`); | ||
| if (errorBody) { | ||
| console.error(errorBody); | ||
| } | ||
| process.exit(1); | ||
| } | ||
|
|
||
| let result = await response.json(); | ||
| let realmUrl = result?.data?.id; | ||
| let normalizedRealmUrl = realmUrl ? ensureTrailingSlash(realmUrl) : undefined; | ||
|
|
||
| if (normalizedRealmUrl) { | ||
| try { | ||
| let serverToken = await pm.getOrRefreshServerToken(); | ||
| let token = await pm.fetchAndStoreRealmToken( | ||
| normalizedRealmUrl, | ||
| serverToken, | ||
| ); | ||
| if (!token) { | ||
| console.error( | ||
| `${DIM}Warning: realm created but JWT not found in auth response.${RESET}`, | ||
| ); | ||
| } | ||
| } catch { | ||
| console.error( | ||
| `${DIM}Warning: realm created but could not obtain realm JWT.${RESET}`, | ||
| ); | ||
| } | ||
|
|
||
| try { | ||
| await pm.addToUserRealms(normalizedRealmUrl); | ||
| } catch { | ||
| console.error( | ||
| `${DIM}Warning: could not register realm in dashboard. It may not appear until next login.${RESET}`, | ||
| ); | ||
| } | ||
| } | ||
|
|
||
| console.log( | ||
| `${FG_GREEN}Realm created:${RESET} ${FG_CYAN}${realmUrl ?? realmName}${RESET}`, | ||
| ); | ||
| } | ||
|
|
||
| function ensureTrailingSlash(url: string): string { | ||
| return url.endsWith('/') ? url : `${url}/`; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,10 @@ | ||
| import type { Command } from 'commander'; | ||
| import { registerCreateCommand } from './create'; | ||
|
|
||
| export function registerRealmCommand(program: Command): void { | ||
| let realm = program | ||
| .command('realm') | ||
| .description('Manage realms on the realm server'); | ||
|
|
||
| registerCreateCommand(realm); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,169 @@ | ||
| export interface MatrixAuth { | ||
| accessToken: string; | ||
| deviceId: string; | ||
| userId: string; | ||
| matrixUrl: string; | ||
| } | ||
|
|
||
| export type RealmTokens = Record<string, string>; | ||
|
|
||
| interface MatrixLoginResponse { | ||
| access_token: string; | ||
| device_id: string; | ||
| user_id: string; | ||
| } | ||
|
|
||
| import { APP_BOXEL_REALMS_EVENT_TYPE } from '@cardstack/runtime-common/matrix-constants'; | ||
|
|
||
| export async function matrixLogin( | ||
| matrixUrl: string, | ||
| username: string, | ||
| password: string, | ||
| ): Promise<MatrixAuth> { | ||
| let response = await fetch( | ||
| new URL('_matrix/client/v3/login', matrixUrl).href, | ||
| { | ||
| method: 'POST', | ||
| headers: { 'Content-Type': 'application/json' }, | ||
| body: JSON.stringify({ | ||
| identifier: { type: 'm.id.user', user: username }, | ||
| password, | ||
| type: 'm.login.password', | ||
| }), | ||
| }, | ||
| ); | ||
|
|
||
| let json = (await response.json()) as MatrixLoginResponse; | ||
| if (!response.ok) { | ||
| throw new Error( | ||
| `Matrix login failed: ${response.status} ${JSON.stringify(json)}`, | ||
| ); | ||
| } | ||
|
|
||
| return { | ||
| accessToken: json.access_token, | ||
| deviceId: json.device_id, | ||
| userId: json.user_id, | ||
| matrixUrl, | ||
| }; | ||
| } | ||
|
|
||
| async function getOpenIdToken( | ||
| matrixAuth: MatrixAuth, | ||
| ): Promise<Record<string, unknown>> { | ||
| let response = await fetch( | ||
| new URL( | ||
| `_matrix/client/v3/user/${encodeURIComponent(matrixAuth.userId)}/openid/request_token`, | ||
| matrixAuth.matrixUrl, | ||
| ).href, | ||
| { | ||
| method: 'POST', | ||
| headers: { | ||
| 'Content-Type': 'application/json', | ||
| Authorization: `Bearer ${matrixAuth.accessToken}`, | ||
| }, | ||
| body: '{}', | ||
| }, | ||
| ); | ||
|
|
||
| if (!response.ok) { | ||
| let text = await response.text(); | ||
| throw new Error(`OpenID token request failed: ${response.status} ${text}`); | ||
| } | ||
|
|
||
| return (await response.json()) as Record<string, unknown>; | ||
| } | ||
|
|
||
| export async function getRealmServerToken( | ||
| matrixAuth: MatrixAuth, | ||
| realmServerUrl: string, | ||
| ): Promise<string> { | ||
| let openIdToken = await getOpenIdToken(matrixAuth); | ||
| let url = `${realmServerUrl.replace(/\/$/, '')}/_server-session`; | ||
|
|
||
| let response = await fetch(url, { | ||
| method: 'POST', | ||
| headers: { | ||
| Accept: 'application/json', | ||
| 'Content-Type': 'application/json', | ||
| }, | ||
| body: JSON.stringify(openIdToken), | ||
| }); | ||
|
|
||
| if (!response.ok) { | ||
| let text = await response.text(); | ||
| throw new Error(`Realm server session failed: ${response.status} ${text}`); | ||
| } | ||
|
|
||
| let token = response.headers.get('Authorization'); | ||
| if (!token) { | ||
| throw new Error( | ||
| 'Realm server session response did not include an Authorization header', | ||
| ); | ||
| } | ||
| return token; | ||
| } | ||
|
|
||
| export async function getRealmTokens( | ||
| realmServerUrl: string, | ||
| serverToken: string, | ||
| ): Promise<RealmTokens> { | ||
| let url = `${realmServerUrl.replace(/\/$/, '')}/_realm-auth`; | ||
|
|
||
| let response = await fetch(url, { | ||
| method: 'POST', | ||
| headers: { | ||
| Accept: 'application/json', | ||
| 'Content-Type': 'application/json', | ||
| Authorization: serverToken, | ||
| }, | ||
| }); | ||
|
|
||
| if (!response.ok) { | ||
| let text = await response.text(); | ||
| throw new Error(`Realm auth lookup failed: ${response.status} ${text}`); | ||
| } | ||
|
|
||
| return (await response.json()) as RealmTokens; | ||
| } | ||
|
|
||
| export async function addRealmToMatrixAccountData( | ||
| matrixAuth: MatrixAuth, | ||
| realmUrl: string, | ||
| ): Promise<void> { | ||
| let accountDataUrl = new URL( | ||
| `_matrix/client/v3/user/${encodeURIComponent(matrixAuth.userId)}/account_data/${APP_BOXEL_REALMS_EVENT_TYPE}`, | ||
| matrixAuth.matrixUrl, | ||
| ).href; | ||
|
|
||
| let existingRealms: string[] = []; | ||
| try { | ||
| let getResponse = await fetch(accountDataUrl, { | ||
| headers: { Authorization: `Bearer ${matrixAuth.accessToken}` }, | ||
| }); | ||
| if (getResponse.ok) { | ||
| let data = (await getResponse.json()) as { realms?: string[] }; | ||
| existingRealms = Array.isArray(data.realms) ? [...data.realms] : []; | ||
| } | ||
| } catch { | ||
| // Best-effort — if we can't read existing realms, start fresh | ||
| } | ||
|
|
||
| if (!existingRealms.includes(realmUrl)) { | ||
|
Comment on lines
+148
to
+152
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
If reading Matrix account data fails (network error, malformed JSON, or any non-OK response), this code falls through with Useful? React with 👍 / 👎. |
||
| existingRealms.push(realmUrl); | ||
| let putResponse = await fetch(accountDataUrl, { | ||
| method: 'PUT', | ||
| headers: { | ||
| 'Content-Type': 'application/json', | ||
| Authorization: `Bearer ${matrixAuth.accessToken}`, | ||
| }, | ||
| body: JSON.stringify({ realms: existingRealms }), | ||
| }); | ||
| if (!putResponse.ok) { | ||
| let text = await putResponse.text(); | ||
| throw new Error( | ||
| `Failed to update Matrix account data: ${putResponse.status} ${text}`, | ||
| ); | ||
| } | ||
| } | ||
jurgenwerk marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.