Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
8ce874a
CS-10615: Reimplement `boxel profile` command
FadhlanR Apr 8, 2026
1d9086f
Update imports
FadhlanR Apr 8, 2026
a102b7d
Address PR #4354 review feedback
FadhlanR Apr 9, 2026
4530572
Improve promptPassword error handling and stdin cleanup
FadhlanR Apr 9, 2026
0593e8a
CS-10619: Reimplement `boxel realm create` command
jurgenwerk Apr 9, 2026
cf18949
Replace mocked tests with integration tests against real realm server
jurgenwerk Apr 9, 2026
48baf00
Add test services to boxel-cli CI for integration tests
jurgenwerk Apr 9, 2026
b95848e
Add prepare-test-pg step to boxel-cli CI
jurgenwerk Apr 9, 2026
6958d62
Hardcode test PG port 55436 in test:integration script
jurgenwerk Apr 9, 2026
a799a0e
Use wrapper script for integration tests (same pattern as realm-server)
jurgenwerk Apr 9, 2026
281e49d
Merge branch 'main' into cs-10619-reimplement-boxel-realm-create-comm…
jurgenwerk Apr 10, 2026
02c832c
Move auth logic from createRealm into ProfileManager
jurgenwerk Apr 10, 2026
1d8d5a5
Add authedFetch to ProfileManager
jurgenwerk Apr 10, 2026
27e3fad
Hardcode test realm server port instead of using testPort()
jurgenwerk Apr 10, 2026
738c538
Inline test realm server URL
jurgenwerk Apr 10, 2026
d1a726c
Lint fix
jurgenwerk Apr 10, 2026
a98a49d
Address PR review feedback
jurgenwerk Apr 10, 2026
d4f076f
Lint fix
jurgenwerk Apr 10, 2026
cdf7619
Only store the newly created realm's JWT, not all realm tokens
jurgenwerk Apr 10, 2026
185c005
Fix CI: use curl loop instead of npx wait-on
jurgenwerk Apr 10, 2026
0e805aa
Use background-action for service wait (same pattern as other CI jobs)
jurgenwerk Apr 10, 2026
0e1b492
Use noop prerenderer in CLI integration tests
jurgenwerk Apr 10, 2026
6ec0edc
Simplify boxel-cli CI: only start Matrix + test PG
jurgenwerk Apr 10, 2026
6cdc3a6
Remove unnecessary comment about CI
jurgenwerk Apr 10, 2026
861f39b
Remove unnecessary 60s beforeAll timeout
jurgenwerk Apr 10, 2026
d09eaf1
Rename registerRealmInDashboard to addToUserRealms
jurgenwerk Apr 10, 2026
cb93163
Add unit tests for realm token storage in ProfileManager
jurgenwerk Apr 10, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 11 additions & 2 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -797,8 +797,17 @@ jobs:
- name: Build
run: pnpm build
working-directory: packages/boxel-cli
- name: Run tests
run: pnpm test
- name: Run unit tests
run: pnpm test:unit
working-directory: packages/boxel-cli
- name: Start Matrix
run: pnpm start:matrix
working-directory: packages/realm-server
- name: Create realm users
run: pnpm register-realm-users
working-directory: packages/matrix
- name: Run integration tests
run: pnpm test:integration
working-directory: packages/boxel-cli

deploy:
Expand Down
3 changes: 3 additions & 0 deletions packages/boxel-cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
},
"devDependencies": {
"@cardstack/local-types": "workspace:*",
"@cardstack/postgres": "workspace:*",
"@cardstack/runtime-common": "workspace:*",
"@types/node": "catalog:",
"@typescript-eslint/eslint-plugin": "catalog:",
Expand All @@ -59,6 +60,8 @@
"lint:js:fix": "eslint . --report-unused-disable-directives --fix",
"lint:types": "tsc --noEmit",
"test": "vitest run",
"test:unit": "vitest run --exclude tests/integration/**",
"test:integration": "./tests/scripts/run-integration-with-test-pg.sh",
"test:watch": "vitest",
"version:patch": "npm version patch",
"version:minor": "npm version minor",
Expand Down
131 changes: 131 additions & 0 deletions packages/boxel-cli/src/commands/realm/create.ts
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}/`;
}
10 changes: 10 additions & 0 deletions packages/boxel-cli/src/commands/realm/index.ts
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);
}
3 changes: 3 additions & 0 deletions packages/boxel-cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { Command } from 'commander';
import { readFileSync } from 'fs';
import { resolve } from 'path';
import { profileCommand } from './commands/profile';
import { registerRealmCommand } from './commands/realm/index';

const pkg = JSON.parse(
readFileSync(resolve(__dirname, '../package.json'), 'utf-8'),
Expand Down Expand Up @@ -39,4 +40,6 @@ program
},
);

registerRealmCommand(program);

program.parse();
169 changes: 169 additions & 0 deletions packages/boxel-cli/src/lib/auth.ts
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
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve existing realm list when account-data read fails

If reading Matrix account data fails (network error, malformed JSON, or any non-OK response), this code falls through with existingRealms = [] and still performs the PUT, which can overwrite the user's app.boxel.realms event with only the newly created realm and silently drop previously saved realms from the dashboard. The safer behavior is to abort/update with a warning when the read is not trustworthy (except the explicit "not found" case), rather than "starting fresh" and writing back.

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}`,
);
}
}
}
Loading
Loading