Skip to content

Commit 40d065b

Browse files
authored
Merge pull request #4368 from cardstack/cs-10619-reimplement-boxel-realm-create-command-2
boxel cli: add `boxel realm create` command
2 parents ce84e77 + cb93163 commit 40d065b

15 files changed

Lines changed: 743 additions & 5 deletions

File tree

.github/workflows/ci.yaml

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -797,8 +797,17 @@ jobs:
797797
- name: Build
798798
run: pnpm build
799799
working-directory: packages/boxel-cli
800-
- name: Run tests
801-
run: pnpm test
800+
- name: Run unit tests
801+
run: pnpm test:unit
802+
working-directory: packages/boxel-cli
803+
- name: Start Matrix
804+
run: pnpm start:matrix
805+
working-directory: packages/realm-server
806+
- name: Create realm users
807+
run: pnpm register-realm-users
808+
working-directory: packages/matrix
809+
- name: Run integration tests
810+
run: pnpm test:integration
802811
working-directory: packages/boxel-cli
803812

804813
deploy:

packages/boxel-cli/package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
},
3434
"devDependencies": {
3535
"@cardstack/local-types": "workspace:*",
36+
"@cardstack/postgres": "workspace:*",
3637
"@cardstack/runtime-common": "workspace:*",
3738
"@types/node": "catalog:",
3839
"@typescript-eslint/eslint-plugin": "catalog:",
@@ -59,6 +60,8 @@
5960
"lint:js:fix": "eslint . --report-unused-disable-directives --fix",
6061
"lint:types": "tsc --noEmit",
6162
"test": "vitest run",
63+
"test:unit": "vitest run --exclude tests/integration/**",
64+
"test:integration": "./tests/scripts/run-integration-with-test-pg.sh",
6265
"test:watch": "vitest",
6366
"version:patch": "npm version patch",
6467
"version:minor": "npm version minor",
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
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, DIM, 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: CreateOptions,
27+
) => {
28+
await createRealm(realmName, displayName, options);
29+
},
30+
);
31+
}
32+
33+
export interface CreateOptions {
34+
background?: string;
35+
icon?: string;
36+
profileManager?: ProfileManager;
37+
}
38+
39+
export async function createRealm(
40+
realmName: string,
41+
displayName: string,
42+
options: CreateOptions,
43+
): Promise<void> {
44+
if (!REALM_NAME_PATTERN.test(realmName)) {
45+
console.error(
46+
'Error: realm name must contain only lowercase letters, numbers, and hyphens',
47+
);
48+
process.exit(1);
49+
}
50+
51+
let pm = options.profileManager ?? getProfileManager();
52+
let active = pm.getActiveProfile();
53+
if (!active) {
54+
console.error(
55+
'Error: no active profile. Run `boxel profile add` to create one.',
56+
);
57+
process.exit(1);
58+
}
59+
60+
let realmServerUrl = active.profile.realmServerUrl.replace(/\/$/, '');
61+
62+
let attributes: Record<string, string | undefined> = {
63+
endpoint: realmName,
64+
name: displayName,
65+
backgroundURL: options.background ?? getRandomBackgroundURL(),
66+
iconURL: options.icon ?? iconURLFor(displayName) ?? iconURLFor(realmName),
67+
};
68+
69+
let response: Response;
70+
try {
71+
response = await pm.authedFetch(`${realmServerUrl}/_create-realm`, {
72+
method: 'POST',
73+
headers: { 'Content-Type': 'application/vnd.api+json' },
74+
body: JSON.stringify({
75+
data: { type: 'realm', attributes },
76+
}),
77+
});
78+
} catch (e: unknown) {
79+
console.error(`Error: failed to connect to realm server`);
80+
console.error(e instanceof Error ? e.message : String(e));
81+
process.exit(1);
82+
}
83+
84+
if (!response.ok) {
85+
let errorBody = await response.text();
86+
console.error(`Error: realm server returned ${response.status}`);
87+
if (errorBody) {
88+
console.error(errorBody);
89+
}
90+
process.exit(1);
91+
}
92+
93+
let result = await response.json();
94+
let realmUrl = result?.data?.id;
95+
let normalizedRealmUrl = realmUrl ? ensureTrailingSlash(realmUrl) : undefined;
96+
97+
if (normalizedRealmUrl) {
98+
try {
99+
let serverToken = await pm.getOrRefreshServerToken();
100+
let token = await pm.fetchAndStoreRealmToken(
101+
normalizedRealmUrl,
102+
serverToken,
103+
);
104+
if (!token) {
105+
console.error(
106+
`${DIM}Warning: realm created but JWT not found in auth response.${RESET}`,
107+
);
108+
}
109+
} catch {
110+
console.error(
111+
`${DIM}Warning: realm created but could not obtain realm JWT.${RESET}`,
112+
);
113+
}
114+
115+
try {
116+
await pm.addToUserRealms(normalizedRealmUrl);
117+
} catch {
118+
console.error(
119+
`${DIM}Warning: could not register realm in dashboard. It may not appear until next login.${RESET}`,
120+
);
121+
}
122+
}
123+
124+
console.log(
125+
`${FG_GREEN}Realm created:${RESET} ${FG_CYAN}${realmUrl ?? realmName}${RESET}`,
126+
);
127+
}
128+
129+
function ensureTrailingSlash(url: string): string {
130+
return url.endsWith('/') ? url : `${url}/`;
131+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import type { Command } from 'commander';
2+
import { registerCreateCommand } from './create';
3+
4+
export function registerRealmCommand(program: Command): void {
5+
let realm = program
6+
.command('realm')
7+
.description('Manage realms on the realm server');
8+
9+
registerCreateCommand(realm);
10+
}

packages/boxel-cli/src/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { Command } from 'commander';
33
import { readFileSync } from 'fs';
44
import { resolve } from 'path';
55
import { profileCommand } from './commands/profile';
6+
import { registerRealmCommand } from './commands/realm/index';
67

78
const pkg = JSON.parse(
89
readFileSync(resolve(__dirname, '../package.json'), 'utf-8'),
@@ -39,4 +40,6 @@ program
3940
},
4041
);
4142

43+
registerRealmCommand(program);
44+
4245
program.parse();

packages/boxel-cli/src/lib/auth.ts

Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
export interface MatrixAuth {
2+
accessToken: string;
3+
deviceId: string;
4+
userId: string;
5+
matrixUrl: string;
6+
}
7+
8+
export type RealmTokens = Record<string, string>;
9+
10+
interface MatrixLoginResponse {
11+
access_token: string;
12+
device_id: string;
13+
user_id: string;
14+
}
15+
16+
import { APP_BOXEL_REALMS_EVENT_TYPE } from '@cardstack/runtime-common/matrix-constants';
17+
18+
export async function matrixLogin(
19+
matrixUrl: string,
20+
username: string,
21+
password: string,
22+
): Promise<MatrixAuth> {
23+
let response = await fetch(
24+
new URL('_matrix/client/v3/login', matrixUrl).href,
25+
{
26+
method: 'POST',
27+
headers: { 'Content-Type': 'application/json' },
28+
body: JSON.stringify({
29+
identifier: { type: 'm.id.user', user: username },
30+
password,
31+
type: 'm.login.password',
32+
}),
33+
},
34+
);
35+
36+
let json = (await response.json()) as MatrixLoginResponse;
37+
if (!response.ok) {
38+
throw new Error(
39+
`Matrix login failed: ${response.status} ${JSON.stringify(json)}`,
40+
);
41+
}
42+
43+
return {
44+
accessToken: json.access_token,
45+
deviceId: json.device_id,
46+
userId: json.user_id,
47+
matrixUrl,
48+
};
49+
}
50+
51+
async function getOpenIdToken(
52+
matrixAuth: MatrixAuth,
53+
): Promise<Record<string, unknown>> {
54+
let response = await fetch(
55+
new URL(
56+
`_matrix/client/v3/user/${encodeURIComponent(matrixAuth.userId)}/openid/request_token`,
57+
matrixAuth.matrixUrl,
58+
).href,
59+
{
60+
method: 'POST',
61+
headers: {
62+
'Content-Type': 'application/json',
63+
Authorization: `Bearer ${matrixAuth.accessToken}`,
64+
},
65+
body: '{}',
66+
},
67+
);
68+
69+
if (!response.ok) {
70+
let text = await response.text();
71+
throw new Error(`OpenID token request failed: ${response.status} ${text}`);
72+
}
73+
74+
return (await response.json()) as Record<string, unknown>;
75+
}
76+
77+
export async function getRealmServerToken(
78+
matrixAuth: MatrixAuth,
79+
realmServerUrl: string,
80+
): Promise<string> {
81+
let openIdToken = await getOpenIdToken(matrixAuth);
82+
let url = `${realmServerUrl.replace(/\/$/, '')}/_server-session`;
83+
84+
let response = await fetch(url, {
85+
method: 'POST',
86+
headers: {
87+
Accept: 'application/json',
88+
'Content-Type': 'application/json',
89+
},
90+
body: JSON.stringify(openIdToken),
91+
});
92+
93+
if (!response.ok) {
94+
let text = await response.text();
95+
throw new Error(`Realm server session failed: ${response.status} ${text}`);
96+
}
97+
98+
let token = response.headers.get('Authorization');
99+
if (!token) {
100+
throw new Error(
101+
'Realm server session response did not include an Authorization header',
102+
);
103+
}
104+
return token;
105+
}
106+
107+
export async function getRealmTokens(
108+
realmServerUrl: string,
109+
serverToken: string,
110+
): Promise<RealmTokens> {
111+
let url = `${realmServerUrl.replace(/\/$/, '')}/_realm-auth`;
112+
113+
let response = await fetch(url, {
114+
method: 'POST',
115+
headers: {
116+
Accept: 'application/json',
117+
'Content-Type': 'application/json',
118+
Authorization: serverToken,
119+
},
120+
});
121+
122+
if (!response.ok) {
123+
let text = await response.text();
124+
throw new Error(`Realm auth lookup failed: ${response.status} ${text}`);
125+
}
126+
127+
return (await response.json()) as RealmTokens;
128+
}
129+
130+
export async function addRealmToMatrixAccountData(
131+
matrixAuth: MatrixAuth,
132+
realmUrl: string,
133+
): Promise<void> {
134+
let accountDataUrl = new URL(
135+
`_matrix/client/v3/user/${encodeURIComponent(matrixAuth.userId)}/account_data/${APP_BOXEL_REALMS_EVENT_TYPE}`,
136+
matrixAuth.matrixUrl,
137+
).href;
138+
139+
let existingRealms: string[] = [];
140+
try {
141+
let getResponse = await fetch(accountDataUrl, {
142+
headers: { Authorization: `Bearer ${matrixAuth.accessToken}` },
143+
});
144+
if (getResponse.ok) {
145+
let data = (await getResponse.json()) as { realms?: string[] };
146+
existingRealms = Array.isArray(data.realms) ? [...data.realms] : [];
147+
}
148+
} catch {
149+
// Best-effort — if we can't read existing realms, start fresh
150+
}
151+
152+
if (!existingRealms.includes(realmUrl)) {
153+
existingRealms.push(realmUrl);
154+
let putResponse = await fetch(accountDataUrl, {
155+
method: 'PUT',
156+
headers: {
157+
'Content-Type': 'application/json',
158+
Authorization: `Bearer ${matrixAuth.accessToken}`,
159+
},
160+
body: JSON.stringify({ realms: existingRealms }),
161+
});
162+
if (!putResponse.ok) {
163+
let text = await putResponse.text();
164+
throw new Error(
165+
`Failed to update Matrix account data: ${putResponse.status} ${text}`,
166+
);
167+
}
168+
}
169+
}

0 commit comments

Comments
 (0)