From 8ce874ae2b443755ad21a6494e82226e845858a9 Mon Sep 17 00:00:00 2001 From: Fadhlan Ridhwanallah Date: Wed, 8 Apr 2026 15:55:44 +0700 Subject: [PATCH 01/26] CS-10615: Reimplement `boxel profile` command Port profile management from standalone boxel-cli into monorepo. Adds ProfileManager class for CRUD operations on ~/.boxel-cli/profiles.json with subcommands: list, add, switch, remove, migrate. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/boxel-cli/src/commands/profile.ts | 418 ++++++++++++++++++ packages/boxel-cli/src/index.ts | 26 ++ packages/boxel-cli/src/lib/profile-manager.ts | 367 +++++++++++++++ .../boxel-cli/tests/commands/profile.test.ts | 210 +++++++++ 4 files changed, 1021 insertions(+) create mode 100644 packages/boxel-cli/src/commands/profile.ts create mode 100644 packages/boxel-cli/src/lib/profile-manager.ts create mode 100644 packages/boxel-cli/tests/commands/profile.test.ts diff --git a/packages/boxel-cli/src/commands/profile.ts b/packages/boxel-cli/src/commands/profile.ts new file mode 100644 index 00000000000..bb617049aa4 --- /dev/null +++ b/packages/boxel-cli/src/commands/profile.ts @@ -0,0 +1,418 @@ +import * as readline from 'readline'; +import { Writable } from 'stream'; +import type { ProfileManager } from '../lib/profile-manager.js'; +import { + getProfileManager, + formatProfileBadge, + getEnvironmentFromMatrixId, + getEnvironmentShortLabel, + getUsernameFromMatrixId, +} from '../lib/profile-manager.js'; + +// ANSI color codes +const FG_GREEN = '\x1b[32m'; +const FG_YELLOW = '\x1b[33m'; +const FG_CYAN = '\x1b[36m'; +const FG_MAGENTA = '\x1b[35m'; +const FG_RED = '\x1b[31m'; +const DIM = '\x1b[2m'; +const BOLD = '\x1b[1m'; +const RESET = '\x1b[0m'; + +function prompt(question: string): Promise { + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + + return new Promise((resolve) => { + rl.question(question, (answer) => { + rl.close(); + resolve(answer.trim()); + }); + }); +} + +function promptPassword(question: string): Promise { + const mutableOutput = new Writable({ + write: (_chunk, _encoding, callback) => callback(), + }); + const rl = readline.createInterface({ + input: process.stdin, + output: mutableOutput, + terminal: true, + }); + + return new Promise((resolve) => { + const stdin = process.stdin; + if (stdin.isTTY) { + stdin.setRawMode(true); + } + + process.stdout.write(question); + let password = ''; + + const onData = (char: Buffer) => { + const c = char.toString(); + if (c === '\n' || c === '\r') { + stdin.removeListener('data', onData); + if (stdin.isTTY) { + stdin.setRawMode(false); + } + process.stdout.write('\n'); + rl.close(); + resolve(password); + } else if (c === '\u0003') { + // Ctrl+C + process.exit(); + } else if (c === '\u007F' || c === '\b') { + // Backspace + if (password.length > 0) { + password = password.slice(0, -1); + process.stdout.write('\b \b'); + } + } else { + password += c; + process.stdout.write('*'); + } + }; + + stdin.on('data', onData); + stdin.resume(); + }); +} + +export interface ProfileCommandOptions { + user?: string; + password?: string; + name?: string; +} + +export async function profileCommand( + subcommand?: string, + arg?: string, + options?: ProfileCommandOptions, +): Promise { + const manager = getProfileManager(); + + switch (subcommand) { + case 'list': + await listProfiles(manager); + break; + + case 'add': { + const password = options?.password || process.env.BOXEL_PASSWORD; + if (options?.user && password) { + await addProfileNonInteractive( + manager, + options.user, + password, + options.name, + ); + } else { + await addProfile(manager); + } + break; + } + + case 'switch': + if (!arg) { + console.error( + `${FG_RED}Error:${RESET} Please specify a profile to switch to.`, + ); + console.log(`Usage: boxel profile switch `); + console.log(`\nAvailable profiles:`); + await listProfiles(manager); + process.exit(1); + } + await switchProfile(manager, arg); + break; + + case 'remove': + if (!arg) { + console.error( + `${FG_RED}Error:${RESET} Please specify a profile to remove.`, + ); + process.exit(1); + } + await removeProfile(manager, arg); + break; + + case 'migrate': + await migrateFromEnv(manager); + break; + + default: + manager.printStatus(); + console.log(`\n${DIM}Commands:${RESET}`); + console.log( + ` ${FG_CYAN}boxel profile list${RESET} List all profiles`, + ); + console.log( + ` ${FG_CYAN}boxel profile add${RESET} Add a new profile`, + ); + console.log( + ` ${FG_CYAN}boxel profile switch${RESET} Switch active profile`, + ); + console.log( + ` ${FG_CYAN}boxel profile remove${RESET} Remove a profile`, + ); + console.log( + ` ${FG_CYAN}boxel profile migrate${RESET} Import from .env file`, + ); + } +} + +async function listProfiles(manager: ProfileManager): Promise { + const profiles = manager.listProfiles(); + const activeId = manager.getActiveProfileId(); + + if (profiles.length === 0) { + console.log(`\n${FG_YELLOW}No profiles configured.${RESET}`); + console.log(`Run ${FG_CYAN}boxel profile add${RESET} to create one.`); + return; + } + + console.log(`\n${BOLD}Saved Profiles:${RESET}\n`); + + for (const id of profiles) { + const profile = manager.getProfile(id)!; + const isActive = id === activeId; + const env = getEnvironmentFromMatrixId(id); + + const marker = isActive ? `${FG_GREEN}\u2605${RESET} ` : ' '; + const envLabel = getEnvironmentShortLabel(env); + const envColor = env === 'production' ? FG_MAGENTA : FG_CYAN; + + console.log(`${marker}${BOLD}${id}${RESET}`); + console.log(` ${DIM}Name:${RESET} ${profile.displayName}`); + console.log( + ` ${DIM}Environment:${RESET} ${envColor}${envLabel}${RESET}`, + ); + console.log(` ${DIM}Realm Server:${RESET} ${profile.realmServerUrl}`); + console.log(''); + } + + if (activeId) { + console.log(`${DIM}\u2605 = active profile${RESET}`); + } +} + +async function addProfile(manager: ProfileManager): Promise { + console.log(`\n${BOLD}Add New Profile${RESET}\n`); + + console.log(`Which environment?`); + console.log(` ${FG_CYAN}1${RESET}) Staging (realms-staging.stack.cards)`); + console.log(` ${FG_MAGENTA}2${RESET}) Production (app.boxel.ai)`); + + const envChoice = await prompt('\nChoice [1/2]: '); + const isProduction = envChoice === '2'; + + const domain = isProduction ? 'boxel.ai' : 'stack.cards'; + const defaultMatrixUrl = isProduction + ? 'https://matrix.boxel.ai' + : 'https://matrix-staging.stack.cards'; + const defaultRealmUrl = isProduction + ? 'https://app.boxel.ai/' + : 'https://realms-staging.stack.cards/'; + + console.log(`\nEnter your Boxel username (without @ or domain)`); + console.log(`${DIM}Example: ctse, aallen90${RESET}`); + const username = await prompt('Username: '); + + if (!username) { + console.error(`${FG_RED}Error:${RESET} Username is required.`); + process.exit(1); + } + + const matrixId = `@${username}:${domain}`; + + if (manager.getProfile(matrixId)) { + console.log(`\n${FG_YELLOW}Profile ${matrixId} already exists.${RESET}`); + const overwrite = await prompt('Overwrite? [y/N]: '); + if (overwrite.toLowerCase() !== 'y') { + console.log('Cancelled.'); + return; + } + } + + const password = await promptPassword('Password: '); + + if (!password) { + console.error(`${FG_RED}Error:${RESET} Password is required.`); + process.exit(1); + } + + const defaultDisplayName = `${username} \u00b7 ${domain}`; + const displayNameInput = await prompt( + `Display name [${defaultDisplayName}]: `, + ); + const displayName = displayNameInput || defaultDisplayName; + + await manager.addProfile( + matrixId, + password, + displayName, + defaultMatrixUrl, + defaultRealmUrl, + ); + + console.log( + `\n${FG_GREEN}\u2713${RESET} Profile created: ${formatProfileBadge(matrixId)}`, + ); + + if (manager.getActiveProfileId() === matrixId) { + console.log(`${DIM}This profile is now active.${RESET}`); + } else { + const switchNow = await prompt('Switch to this profile now? [Y/n]: '); + if (switchNow.toLowerCase() !== 'n') { + manager.switchProfile(matrixId); + console.log( + `${FG_GREEN}\u2713${RESET} Switched to ${formatProfileBadge(matrixId)}`, + ); + } + } +} + +async function switchProfile( + manager: ProfileManager, + profileId: string, +): Promise { + const profiles = manager.listProfiles(); + let matchedId = profileId; + + if (!profiles.includes(profileId)) { + const matches = profiles.filter((id) => { + const username = getUsernameFromMatrixId(id); + return id.includes(profileId) || username === profileId; + }); + + if (matches.length === 0) { + console.error(`${FG_RED}Error:${RESET} Profile not found: ${profileId}`); + console.log(`\nAvailable profiles:`); + for (const id of profiles) { + console.log(` ${id}`); + } + process.exit(1); + } else if (matches.length === 1) { + matchedId = matches[0]; + } else { + console.error(`${FG_RED}Error:${RESET} Ambiguous profile: ${profileId}`); + console.log(`\nMatching profiles:`); + for (const id of matches) { + console.log(` ${id}`); + } + process.exit(1); + } + } + + if (manager.switchProfile(matchedId)) { + console.log( + `${FG_GREEN}\u2713${RESET} Switched to ${formatProfileBadge(matchedId)}`, + ); + } else { + console.error(`${FG_RED}Error:${RESET} Failed to switch profile.`); + process.exit(1); + } +} + +async function removeProfile( + manager: ProfileManager, + profileId: string, +): Promise { + const profile = manager.getProfile(profileId); + if (!profile) { + console.error(`${FG_RED}Error:${RESET} Profile not found: ${profileId}`); + process.exit(1); + } + + const confirm = await prompt(`Remove profile ${profileId}? [y/N]: `); + if (confirm.toLowerCase() !== 'y') { + console.log('Cancelled.'); + return; + } + + if (await manager.removeProfile(profileId)) { + console.log(`${FG_GREEN}\u2713${RESET} Profile removed.`); + + const newActive = manager.getActiveProfileId(); + if (newActive) { + console.log(`Active profile is now: ${formatProfileBadge(newActive)}`); + } + } else { + console.error(`${FG_RED}Error:${RESET} Failed to remove profile.`); + process.exit(1); + } +} + +async function addProfileNonInteractive( + manager: ProfileManager, + matrixId: string, + password: string, + displayName?: string, +): Promise { + if (!matrixId.startsWith('@') || !matrixId.includes(':')) { + console.error( + `${FG_RED}Error:${RESET} Invalid Matrix ID format. Expected @user:domain`, + ); + process.exit(1); + } + + if (manager.getProfile(matrixId)) { + console.log( + `${FG_YELLOW}Profile ${matrixId} already exists. Updating password.${RESET}`, + ); + await manager.updatePassword(matrixId, password); + if (displayName) { + manager.updateDisplayName(matrixId, displayName); + } + console.log( + `${FG_GREEN}\u2713${RESET} Profile updated: ${formatProfileBadge(matrixId)}`, + ); + return; + } + + await manager.addProfile(matrixId, password, displayName); + console.log( + `${FG_GREEN}\u2713${RESET} Profile created: ${formatProfileBadge(matrixId)}`, + ); + + const activeId = manager.getActiveProfileId(); + if (activeId !== matrixId) { + console.log( + `${DIM}Use 'boxel profile switch ${matrixId}' to switch to this profile.${RESET}`, + ); + } +} + +async function migrateFromEnv(manager: ProfileManager): Promise { + console.log(`\n${BOLD}Migrate from .env${RESET}\n`); + + const matrixUrl = process.env.MATRIX_URL; + const username = process.env.MATRIX_USERNAME; + const password = process.env.MATRIX_PASSWORD; + + if (!matrixUrl || !username || !password) { + console.log( + `${FG_YELLOW}No complete credentials found in environment variables.${RESET}`, + ); + console.log( + `\nRequired variables: MATRIX_URL, MATRIX_USERNAME, MATRIX_PASSWORD, REALM_SERVER_URL`, + ); + return; + } + + const profileId = await manager.migrateFromEnv(); + if (profileId) { + console.log( + `${FG_GREEN}\u2713${RESET} Created profile: ${formatProfileBadge(profileId)}`, + ); + console.log( + `\n${DIM}You can now remove credentials from .env if desired.${RESET}`, + ); + } else { + console.log( + `${FG_YELLOW}Migration failed or profile already exists.${RESET}`, + ); + } +} diff --git a/packages/boxel-cli/src/index.ts b/packages/boxel-cli/src/index.ts index 61c50b9ec0a..b1350a2ab99 100644 --- a/packages/boxel-cli/src/index.ts +++ b/packages/boxel-cli/src/index.ts @@ -1,6 +1,8 @@ +import 'dotenv/config'; import { Command } from 'commander'; import { readFileSync } from 'fs'; import { resolve } from 'path'; +import { profileCommand } from './commands/profile.js'; const pkg = JSON.parse( readFileSync(resolve(__dirname, '../package.json'), 'utf-8'), @@ -13,4 +15,28 @@ program .description('CLI tools for Boxel workspace management') .version(pkg.version); +program + .command('profile') + .description('Manage saved profiles for different users/environments') + .argument('[subcommand]', 'list | add | switch | remove | migrate') + .argument('[arg]', 'Profile ID (for switch/remove)') + .option('-u, --user ', 'Matrix user ID (e.g., @user:boxel.ai)') + .option('-p, --password ', 'Password (for add command)') + .option('-n, --name ', 'Display name (for add command)') + .action( + async ( + subcommand?: string, + arg?: string, + options?: { user?: string; password?: string; name?: string }, + ) => { + if (options?.password) { + console.warn( + 'Warning: Supplying a password via -p/--password may expose it in shell history and process listings. ' + + 'For non-interactive usage, prefer the BOXEL_PASSWORD environment variable or use "boxel profile add" interactively.', + ); + } + await profileCommand(subcommand, arg, options); + }, + ); + program.parse(); diff --git a/packages/boxel-cli/src/lib/profile-manager.ts b/packages/boxel-cli/src/lib/profile-manager.ts new file mode 100644 index 00000000000..d2a8edda261 --- /dev/null +++ b/packages/boxel-cli/src/lib/profile-manager.ts @@ -0,0 +1,367 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; + +const DEFAULT_CONFIG_DIR = path.join(os.homedir(), '.boxel-cli'); +const PROFILES_FILENAME = 'profiles.json'; + +// ANSI color codes +const FG_YELLOW = '\x1b[33m'; +const FG_CYAN = '\x1b[36m'; +const FG_MAGENTA = '\x1b[35m'; +const DIM = '\x1b[2m'; +const BOLD = '\x1b[1m'; +const RESET = '\x1b[0m'; + +export interface Profile { + displayName: string; + matrixUrl: string; + realmServerUrl: string; + password: string; // Stored in plaintext - file should have restricted permissions +} + +export interface ProfilesConfig { + profiles: Record; + activeProfile: string | null; +} + +export type Environment = 'staging' | 'production' | 'unknown'; + +/** + * Extract environment from Matrix user ID + * @example @ctse:stack.cards -> staging + * @example @ctse:boxel.ai -> production + */ +export function getEnvironmentFromMatrixId(matrixId: string): Environment { + if (matrixId.endsWith(':stack.cards')) return 'staging'; + if (matrixId.endsWith(':boxel.ai')) return 'production'; + return 'unknown'; +} + +/** + * Extract username from Matrix user ID + * @example @ctse:stack.cards -> ctse + */ +export function getUsernameFromMatrixId(matrixId: string): string { + const match = matrixId.match(/^@([^:]+):/); + return match ? match[1] : matrixId; +} + +/** + * Get domain from Matrix user ID + * @example @ctse:stack.cards -> stack.cards + */ +export function getDomainFromMatrixId(matrixId: string): string { + const match = matrixId.match(/:([^:]+)$/); + return match ? match[1] : 'unknown'; +} + +/** + * Get environment emoji/label for display + */ +export function getEnvironmentLabel(env: Environment): string { + switch (env) { + case 'staging': + return 'stack.cards'; + case 'production': + return 'boxel.ai'; + default: + return 'unknown'; + } +} + +/** + * Get short environment label (uses domain) + */ +export function getEnvironmentShortLabel(env: Environment): string { + switch (env) { + case 'staging': + return 'stack.cards'; + case 'production': + return 'boxel.ai'; + default: + return 'unknown'; + } +} + +/** + * Format profile for display in command output + * @example [ctse · staging] + */ +export function formatProfileBadge(matrixId: string): string { + const username = getUsernameFromMatrixId(matrixId); + const env = getEnvironmentShortLabel(getEnvironmentFromMatrixId(matrixId)); + return `${DIM}[${RESET}${FG_CYAN}${username}${RESET} ${DIM}\u00b7${RESET} ${FG_MAGENTA}${env}${RESET}${DIM}]${RESET}`; +} + +export class ProfileManager { + private config: ProfilesConfig; + private configDir: string; + private profilesFile: string; + + constructor(configDir?: string) { + this.configDir = configDir || DEFAULT_CONFIG_DIR; + this.profilesFile = path.join(this.configDir, PROFILES_FILENAME); + this.config = this.loadConfig(); + } + + private ensureConfigDir(): void { + if (!fs.existsSync(this.configDir)) { + fs.mkdirSync(this.configDir, { recursive: true }); + } + } + + private loadConfig(): ProfilesConfig { + if (fs.existsSync(this.profilesFile)) { + try { + const data = fs.readFileSync(this.profilesFile, 'utf-8'); + return JSON.parse(data); + } catch { + // Corrupted file, start fresh + } + } + return { profiles: {}, activeProfile: null }; + } + + private saveConfig(): void { + this.ensureConfigDir(); + fs.writeFileSync(this.profilesFile, JSON.stringify(this.config, null, 2), { + mode: 0o600, + }); + try { + fs.chmodSync(this.profilesFile, 0o600); + } catch { + // Ignore permission errors on Windows + } + } + + listProfiles(): string[] { + return Object.keys(this.config.profiles); + } + + getProfile(profileId: string): Profile | undefined { + return this.config.profiles[profileId]; + } + + getActiveProfileId(): string | null { + return this.config.activeProfile; + } + + getActiveProfile(): { id: string; profile: Profile } | null { + const id = this.config.activeProfile; + if (!id) return null; + const profile = this.config.profiles[id]; + if (!profile) return null; + return { id, profile }; + } + + async addProfile( + matrixId: string, + password: string, + displayName?: string, + matrixUrl?: string, + realmServerUrl?: string, + ): Promise { + const env = getEnvironmentFromMatrixId(matrixId); + const username = getUsernameFromMatrixId(matrixId); + + const defaultMatrixUrl = + env === 'production' + ? 'https://matrix.boxel.ai' + : 'https://matrix-staging.stack.cards'; + const defaultRealmUrl = + env === 'production' + ? 'https://app.boxel.ai/' + : 'https://realms-staging.stack.cards/'; + + const domain = getDomainFromMatrixId(matrixId); + const profile: Profile = { + displayName: displayName || `${username} \u00b7 ${domain}`, + matrixUrl: matrixUrl || defaultMatrixUrl, + realmServerUrl: realmServerUrl || defaultRealmUrl, + password, + }; + + this.config.profiles[matrixId] = profile; + + if (!this.config.activeProfile) { + this.config.activeProfile = matrixId; + } + + this.saveConfig(); + } + + async removeProfile(profileId: string): Promise { + if (!this.config.profiles[profileId]) { + return false; + } + + delete this.config.profiles[profileId]; + + if (this.config.activeProfile === profileId) { + const remaining = Object.keys(this.config.profiles); + this.config.activeProfile = remaining.length > 0 ? remaining[0] : null; + } + + this.saveConfig(); + return true; + } + + switchProfile(profileId: string): boolean { + if (!this.config.profiles[profileId]) { + return false; + } + this.config.activeProfile = profileId; + this.saveConfig(); + return true; + } + + async getActiveCredentials(): Promise<{ + matrixUrl: string; + username: string; + password: string; + realmServerUrl: string; + profileId: string | null; + } | null> { + const active = this.getActiveProfile(); + if (active && active.profile.password) { + return { + matrixUrl: active.profile.matrixUrl, + username: getUsernameFromMatrixId(active.id), + password: active.profile.password, + realmServerUrl: active.profile.realmServerUrl, + profileId: active.id, + }; + } + + const matrixUrl = process.env.MATRIX_URL; + const username = process.env.MATRIX_USERNAME; + const password = process.env.MATRIX_PASSWORD; + let realmServerUrl = process.env.REALM_SERVER_URL; + + if (matrixUrl && username && password) { + if (!realmServerUrl) { + try { + const matrixUrlObj = new URL(matrixUrl); + if (matrixUrlObj.hostname.startsWith('matrix.')) { + realmServerUrl = `${matrixUrlObj.protocol}//app.${matrixUrlObj.hostname.slice(7)}/`; + } else if (matrixUrlObj.hostname.startsWith('matrix-staging.')) { + realmServerUrl = `${matrixUrlObj.protocol}//realms-staging.${matrixUrlObj.hostname.slice(15)}/`; + } else if (matrixUrlObj.hostname.startsWith('matrix-')) { + realmServerUrl = `${matrixUrlObj.protocol}//${matrixUrlObj.hostname.slice(7)}/`; + } + } catch { + // Invalid URL, will return null below + } + } + + if (realmServerUrl) { + return { + matrixUrl, + username, + password, + realmServerUrl, + profileId: null, + }; + } + } + + return null; + } + + async getPassword(profileId: string): Promise { + const profile = this.config.profiles[profileId]; + return profile?.password || null; + } + + async updatePassword(profileId: string, password: string): Promise { + if (!this.config.profiles[profileId]) { + return false; + } + this.config.profiles[profileId].password = password; + this.saveConfig(); + return true; + } + + updateDisplayName(profileId: string, displayName: string): boolean { + if (!this.config.profiles[profileId]) { + return false; + } + this.config.profiles[profileId].displayName = displayName; + this.saveConfig(); + return true; + } + + async migrateFromEnv(): Promise { + const matrixUrl = process.env.MATRIX_URL; + const username = process.env.MATRIX_USERNAME; + const password = process.env.MATRIX_PASSWORD; + const realmServerUrl = process.env.REALM_SERVER_URL; + + if (!matrixUrl || !username || !password || !realmServerUrl) { + return null; + } + + const isProduction = matrixUrl.includes('boxel.ai'); + const domain = isProduction ? 'boxel.ai' : 'stack.cards'; + const matrixId = `@${username}:${domain}`; + + if (this.config.profiles[matrixId]) { + return matrixId; + } + + await this.addProfile( + matrixId, + password, + undefined, + matrixUrl, + realmServerUrl, + ); + return matrixId; + } + + printStatus(): void { + const active = this.getActiveProfile(); + if (active) { + console.log( + `\n${BOLD}Active Profile:${RESET} ${formatProfileBadge(active.id)}`, + ); + console.log( + ` ${DIM}Display Name:${RESET} ${active.profile.displayName}`, + ); + console.log(` ${DIM}Matrix URL:${RESET} ${active.profile.matrixUrl}`); + console.log( + ` ${DIM}Realm Server:${RESET} ${active.profile.realmServerUrl}`, + ); + } else if (process.env.MATRIX_USERNAME) { + console.log( + `\n${BOLD}Using environment variables${RESET} (no profile active)`, + ); + console.log(` ${DIM}Username:${RESET} ${process.env.MATRIX_USERNAME}`); + } else { + console.log( + `\n${FG_YELLOW}No active profile and no environment variables set.${RESET}`, + ); + console.log( + `Run ${FG_CYAN}boxel profile add${RESET} to create a profile.`, + ); + } + } +} + +// Singleton instance +let _instance: ProfileManager | null = null; + +export function getProfileManager(configDir?: string): ProfileManager { + if (!_instance) { + _instance = new ProfileManager(configDir); + } + return _instance; +} + +/** + * Reset the singleton (useful for testing) + */ +export function resetProfileManager(): void { + _instance = null; +} diff --git a/packages/boxel-cli/tests/commands/profile.test.ts b/packages/boxel-cli/tests/commands/profile.test.ts new file mode 100644 index 00000000000..d81ab059076 --- /dev/null +++ b/packages/boxel-cli/tests/commands/profile.test.ts @@ -0,0 +1,210 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { + ProfileManager, + getEnvironmentFromMatrixId, + getUsernameFromMatrixId, + getDomainFromMatrixId, + getEnvironmentShortLabel, +} from '../../src/lib/profile-manager.js'; + +describe('ProfileManager', () => { + let tmpDir: string; + let manager: ProfileManager; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'boxel-profile-test-')); + manager = new ProfileManager(tmpDir); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('starts with no profiles', () => { + expect(manager.listProfiles()).toEqual([]); + expect(manager.getActiveProfileId()).toBeNull(); + expect(manager.getActiveProfile()).toBeNull(); + }); + + it('adds a profile and sets it as active when no other profiles exist', async () => { + await manager.addProfile( + '@testuser:stack.cards', + 'password123', + 'Test User', + ); + + expect(manager.listProfiles()).toEqual(['@testuser:stack.cards']); + expect(manager.getActiveProfileId()).toBe('@testuser:stack.cards'); + + const profile = manager.getProfile('@testuser:stack.cards'); + expect(profile).toBeDefined(); + expect(profile!.displayName).toBe('Test User'); + expect(profile!.password).toBe('password123'); + expect(profile!.matrixUrl).toBe('https://matrix-staging.stack.cards'); + expect(profile!.realmServerUrl).toBe('https://realms-staging.stack.cards/'); + }); + + it('adds a production profile with correct defaults', async () => { + await manager.addProfile('@testuser:boxel.ai', 'password123'); + + const profile = manager.getProfile('@testuser:boxel.ai'); + expect(profile).toBeDefined(); + expect(profile!.matrixUrl).toBe('https://matrix.boxel.ai'); + expect(profile!.realmServerUrl).toBe('https://app.boxel.ai/'); + expect(profile!.displayName).toBe('testuser \u00b7 boxel.ai'); + }); + + it('does not change active profile when adding a second profile', async () => { + await manager.addProfile('@first:stack.cards', 'pass1'); + await manager.addProfile('@second:stack.cards', 'pass2'); + + expect(manager.getActiveProfileId()).toBe('@first:stack.cards'); + expect(manager.listProfiles()).toHaveLength(2); + }); + + it('switches active profile', async () => { + await manager.addProfile('@first:stack.cards', 'pass1'); + await manager.addProfile('@second:stack.cards', 'pass2'); + + expect(manager.switchProfile('@second:stack.cards')).toBe(true); + expect(manager.getActiveProfileId()).toBe('@second:stack.cards'); + }); + + it('returns false when switching to nonexistent profile', () => { + expect(manager.switchProfile('@nonexistent:stack.cards')).toBe(false); + }); + + it('removes a profile', async () => { + await manager.addProfile('@testuser:stack.cards', 'password123'); + + expect(await manager.removeProfile('@testuser:stack.cards')).toBe(true); + expect(manager.listProfiles()).toEqual([]); + expect(manager.getActiveProfileId()).toBeNull(); + }); + + it('reassigns active profile after removing the active one', async () => { + await manager.addProfile('@first:stack.cards', 'pass1'); + await manager.addProfile('@second:stack.cards', 'pass2'); + manager.switchProfile('@first:stack.cards'); + + await manager.removeProfile('@first:stack.cards'); + + expect(manager.getActiveProfileId()).toBe('@second:stack.cards'); + }); + + it('returns false when removing nonexistent profile', async () => { + expect(await manager.removeProfile('@nonexistent:stack.cards')).toBe(false); + }); + + it('persists profiles to disk', async () => { + await manager.addProfile( + '@testuser:stack.cards', + 'password123', + 'Test User', + ); + + // Create a new manager pointing at the same config dir + const manager2 = new ProfileManager(tmpDir); + expect(manager2.listProfiles()).toEqual(['@testuser:stack.cards']); + expect(manager2.getActiveProfileId()).toBe('@testuser:stack.cards'); + + const profile = manager2.getProfile('@testuser:stack.cards'); + expect(profile!.password).toBe('password123'); + }); + + it('sets file permissions to 0600', async () => { + await manager.addProfile('@testuser:stack.cards', 'password123'); + + const profilesFile = path.join(tmpDir, 'profiles.json'); + const stats = fs.statSync(profilesFile); + // Check owner-only permissions (0600 = 0o600 = 384 decimal) + const mode = stats.mode & 0o777; + expect(mode).toBe(0o600); + }); + + it('gets active credentials from profile', async () => { + await manager.addProfile( + '@testuser:stack.cards', + 'password123', + 'Test User', + ); + + const creds = await manager.getActiveCredentials(); + expect(creds).not.toBeNull(); + expect(creds!.username).toBe('testuser'); + expect(creds!.password).toBe('password123'); + expect(creds!.matrixUrl).toBe('https://matrix-staging.stack.cards'); + expect(creds!.realmServerUrl).toBe('https://realms-staging.stack.cards/'); + expect(creds!.profileId).toBe('@testuser:stack.cards'); + }); + + it('returns null credentials when no profile and no env vars', async () => { + const creds = await manager.getActiveCredentials(); + expect(creds).toBeNull(); + }); + + it('updates password for existing profile', async () => { + await manager.addProfile('@testuser:stack.cards', 'oldpass'); + + expect( + await manager.updatePassword('@testuser:stack.cards', 'newpass'), + ).toBe(true); + + const profile = manager.getProfile('@testuser:stack.cards'); + expect(profile!.password).toBe('newpass'); + }); + + it('updates display name for existing profile', async () => { + await manager.addProfile('@testuser:stack.cards', 'pass', 'Old Name'); + + expect(manager.updateDisplayName('@testuser:stack.cards', 'New Name')).toBe( + true, + ); + + const profile = manager.getProfile('@testuser:stack.cards'); + expect(profile!.displayName).toBe('New Name'); + }); + + it('handles corrupted config file gracefully', async () => { + // Write invalid JSON to the config file + const profilesFile = path.join(tmpDir, 'profiles.json'); + fs.writeFileSync(profilesFile, 'not valid json{{{'); + + // Should start fresh without throwing + const freshManager = new ProfileManager(tmpDir); + expect(freshManager.listProfiles()).toEqual([]); + }); +}); + +describe('environment helpers', () => { + it('detects staging environment', () => { + expect(getEnvironmentFromMatrixId('@user:stack.cards')).toBe('staging'); + }); + + it('detects production environment', () => { + expect(getEnvironmentFromMatrixId('@user:boxel.ai')).toBe('production'); + }); + + it('detects unknown environment', () => { + expect(getEnvironmentFromMatrixId('@user:other.domain')).toBe('unknown'); + }); + + it('extracts username from matrix ID', () => { + expect(getUsernameFromMatrixId('@ctse:stack.cards')).toBe('ctse'); + expect(getUsernameFromMatrixId('@aallen90:boxel.ai')).toBe('aallen90'); + }); + + it('extracts domain from matrix ID', () => { + expect(getDomainFromMatrixId('@user:stack.cards')).toBe('stack.cards'); + expect(getDomainFromMatrixId('@user:boxel.ai')).toBe('boxel.ai'); + }); + + it('returns correct short labels', () => { + expect(getEnvironmentShortLabel('staging')).toBe('stack.cards'); + expect(getEnvironmentShortLabel('production')).toBe('boxel.ai'); + expect(getEnvironmentShortLabel('unknown')).toBe('unknown'); + }); +}); From 1d9086f9865206ed0035cf31c85471ed5e007ef0 Mon Sep 17 00:00:00 2001 From: Fadhlan Ridhwanallah Date: Wed, 8 Apr 2026 22:41:00 +0700 Subject: [PATCH 02/26] Update imports --- packages/boxel-cli/src/commands/profile.ts | 4 ++-- packages/boxel-cli/src/index.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/boxel-cli/src/commands/profile.ts b/packages/boxel-cli/src/commands/profile.ts index bb617049aa4..e7a08528552 100644 --- a/packages/boxel-cli/src/commands/profile.ts +++ b/packages/boxel-cli/src/commands/profile.ts @@ -1,13 +1,13 @@ import * as readline from 'readline'; import { Writable } from 'stream'; -import type { ProfileManager } from '../lib/profile-manager.js'; +import type { ProfileManager } from '../lib/profile-manager'; import { getProfileManager, formatProfileBadge, getEnvironmentFromMatrixId, getEnvironmentShortLabel, getUsernameFromMatrixId, -} from '../lib/profile-manager.js'; +} from '../lib/profile-manager'; // ANSI color codes const FG_GREEN = '\x1b[32m'; diff --git a/packages/boxel-cli/src/index.ts b/packages/boxel-cli/src/index.ts index b1350a2ab99..bec803f1696 100644 --- a/packages/boxel-cli/src/index.ts +++ b/packages/boxel-cli/src/index.ts @@ -2,7 +2,7 @@ import 'dotenv/config'; import { Command } from 'commander'; import { readFileSync } from 'fs'; import { resolve } from 'path'; -import { profileCommand } from './commands/profile.js'; +import { profileCommand } from './commands/profile'; const pkg = JSON.parse( readFileSync(resolve(__dirname, '../package.json'), 'utf-8'), From a102b7dabfb6d08c90e310a4b9b88058da964bd6 Mon Sep 17 00:00:00 2001 From: Fadhlan Ridhwanallah Date: Thu, 9 Apr 2026 11:52:18 +0700 Subject: [PATCH 03/26] Address PR #4354 review feedback - Extract shared ANSI color constants to src/lib/colors.ts - Remove duplicate getEnvironmentShortLabel (keep getEnvironmentLabel) - Remove fragile realmServerUrl inference from matrixUrl hostname pattern - Return distinct result from migrateFromEnv for new vs existing profiles - Update existing profile password on re-migration - Add REALM_SERVER_URL to migrate command precheck - Remove configDir param from singleton getProfileManager() - Fix promptPassword raw mode cleanup with try/finally pattern - Reject unknown domains in addProfile without explicit URLs - Validate JSON shape in loadConfig() - Skip Windows-incompatible file permission test Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/boxel-cli/src/commands/profile.ts | 106 +++++++++------- packages/boxel-cli/src/lib/colors.ts | 9 ++ packages/boxel-cli/src/lib/profile-manager.ts | 116 +++++++++--------- .../boxel-cli/tests/commands/profile.test.ts | 56 +++++++-- 4 files changed, 172 insertions(+), 115 deletions(-) create mode 100644 packages/boxel-cli/src/lib/colors.ts diff --git a/packages/boxel-cli/src/commands/profile.ts b/packages/boxel-cli/src/commands/profile.ts index e7a08528552..35048e691bf 100644 --- a/packages/boxel-cli/src/commands/profile.ts +++ b/packages/boxel-cli/src/commands/profile.ts @@ -5,19 +5,19 @@ import { getProfileManager, formatProfileBadge, getEnvironmentFromMatrixId, - getEnvironmentShortLabel, + getEnvironmentLabel, getUsernameFromMatrixId, } from '../lib/profile-manager'; - -// ANSI color codes -const FG_GREEN = '\x1b[32m'; -const FG_YELLOW = '\x1b[33m'; -const FG_CYAN = '\x1b[36m'; -const FG_MAGENTA = '\x1b[35m'; -const FG_RED = '\x1b[31m'; -const DIM = '\x1b[2m'; -const BOLD = '\x1b[1m'; -const RESET = '\x1b[0m'; +import { + FG_GREEN, + FG_YELLOW, + FG_CYAN, + FG_MAGENTA, + FG_RED, + DIM, + BOLD, + RESET, +} from '../lib/colors'; function prompt(question: string): Promise { const rl = readline.createInterface({ @@ -49,31 +49,41 @@ function promptPassword(question: string): Promise { stdin.setRawMode(true); } + const cleanup = () => { + stdin.removeListener('data', onData); + if (stdin.isTTY) { + stdin.setRawMode(false); + } + rl.close(); + }; + process.stdout.write(question); let password = ''; const onData = (char: Buffer) => { - const c = char.toString(); - if (c === '\n' || c === '\r') { - stdin.removeListener('data', onData); - if (stdin.isTTY) { - stdin.setRawMode(false); + try { + const c = char.toString(); + if (c === '\n' || c === '\r') { + cleanup(); + process.stdout.write('\n'); + resolve(password); + } else if (c === '\u0003') { + // Ctrl+C + cleanup(); + process.exit(); + } else if (c === '\u007F' || c === '\b') { + // Backspace + if (password.length > 0) { + password = password.slice(0, -1); + process.stdout.write('\b \b'); + } + } else { + password += c; + process.stdout.write('*'); } - process.stdout.write('\n'); - rl.close(); - resolve(password); - } else if (c === '\u0003') { - // Ctrl+C - process.exit(); - } else if (c === '\u007F' || c === '\b') { - // Backspace - if (password.length > 0) { - password = password.slice(0, -1); - process.stdout.write('\b \b'); - } - } else { - password += c; - process.stdout.write('*'); + } catch { + cleanup(); + throw new Error('Error reading password input'); } }; @@ -181,7 +191,7 @@ async function listProfiles(manager: ProfileManager): Promise { const env = getEnvironmentFromMatrixId(id); const marker = isActive ? `${FG_GREEN}\u2605${RESET} ` : ' '; - const envLabel = getEnvironmentShortLabel(env); + const envLabel = getEnvironmentLabel(env); const envColor = env === 'production' ? FG_MAGENTA : FG_CYAN; console.log(`${marker}${BOLD}${id}${RESET}`); @@ -391,8 +401,9 @@ async function migrateFromEnv(manager: ProfileManager): Promise { const matrixUrl = process.env.MATRIX_URL; const username = process.env.MATRIX_USERNAME; const password = process.env.MATRIX_PASSWORD; + const realmServerUrl = process.env.REALM_SERVER_URL; - if (!matrixUrl || !username || !password) { + if (!matrixUrl || !username || !password || !realmServerUrl) { console.log( `${FG_YELLOW}No complete credentials found in environment variables.${RESET}`, ); @@ -402,17 +413,24 @@ async function migrateFromEnv(manager: ProfileManager): Promise { return; } - const profileId = await manager.migrateFromEnv(); - if (profileId) { - console.log( - `${FG_GREEN}\u2713${RESET} Created profile: ${formatProfileBadge(profileId)}`, - ); - console.log( - `\n${DIM}You can now remove credentials from .env if desired.${RESET}`, - ); + const result = await manager.migrateFromEnv(); + if (result) { + if (result.created) { + console.log( + `${FG_GREEN}\u2713${RESET} Created profile: ${formatProfileBadge(result.profileId)}`, + ); + console.log( + `\n${DIM}You can now remove credentials from .env if desired.${RESET}`, + ); + } else { + console.log( + `${FG_YELLOW}Profile ${formatProfileBadge(result.profileId)} already exists.${RESET} Password has been updated if it changed.`, + ); + console.log( + `\n${DIM}Use 'boxel profile add -u ${result.profileId} -p ' to update other fields.${RESET}`, + ); + } } else { - console.log( - `${FG_YELLOW}Migration failed or profile already exists.${RESET}`, - ); + console.log(`${FG_YELLOW}Migration failed.${RESET}`); } } diff --git a/packages/boxel-cli/src/lib/colors.ts b/packages/boxel-cli/src/lib/colors.ts new file mode 100644 index 00000000000..c775d12227f --- /dev/null +++ b/packages/boxel-cli/src/lib/colors.ts @@ -0,0 +1,9 @@ +// ANSI color codes +export const FG_GREEN = '\x1b[32m'; +export const FG_YELLOW = '\x1b[33m'; +export const FG_CYAN = '\x1b[36m'; +export const FG_MAGENTA = '\x1b[35m'; +export const FG_RED = '\x1b[31m'; +export const DIM = '\x1b[2m'; +export const BOLD = '\x1b[1m'; +export const RESET = '\x1b[0m'; diff --git a/packages/boxel-cli/src/lib/profile-manager.ts b/packages/boxel-cli/src/lib/profile-manager.ts index d2a8edda261..b67eebb3396 100644 --- a/packages/boxel-cli/src/lib/profile-manager.ts +++ b/packages/boxel-cli/src/lib/profile-manager.ts @@ -1,23 +1,16 @@ import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; +import { FG_YELLOW, FG_CYAN, FG_MAGENTA, DIM, BOLD, RESET } from './colors'; const DEFAULT_CONFIG_DIR = path.join(os.homedir(), '.boxel-cli'); const PROFILES_FILENAME = 'profiles.json'; -// ANSI color codes -const FG_YELLOW = '\x1b[33m'; -const FG_CYAN = '\x1b[36m'; -const FG_MAGENTA = '\x1b[35m'; -const DIM = '\x1b[2m'; -const BOLD = '\x1b[1m'; -const RESET = '\x1b[0m'; - export interface Profile { displayName: string; matrixUrl: string; realmServerUrl: string; - password: string; // Stored in plaintext - file should have restricted permissions + password: string; // Stored in plaintext - file should have restricted permissions, this will be updated in CS-10642 } export interface ProfilesConfig { @@ -57,7 +50,7 @@ export function getDomainFromMatrixId(matrixId: string): string { } /** - * Get environment emoji/label for display + * Get environment label for display (uses domain) */ export function getEnvironmentLabel(env: Environment): string { switch (env) { @@ -70,27 +63,13 @@ export function getEnvironmentLabel(env: Environment): string { } } -/** - * Get short environment label (uses domain) - */ -export function getEnvironmentShortLabel(env: Environment): string { - switch (env) { - case 'staging': - return 'stack.cards'; - case 'production': - return 'boxel.ai'; - default: - return 'unknown'; - } -} - /** * Format profile for display in command output * @example [ctse · staging] */ export function formatProfileBadge(matrixId: string): string { const username = getUsernameFromMatrixId(matrixId); - const env = getEnvironmentShortLabel(getEnvironmentFromMatrixId(matrixId)); + const env = getEnvironmentLabel(getEnvironmentFromMatrixId(matrixId)); return `${DIM}[${RESET}${FG_CYAN}${username}${RESET} ${DIM}\u00b7${RESET} ${FG_MAGENTA}${env}${RESET}${DIM}]${RESET}`; } @@ -112,15 +91,36 @@ export class ProfileManager { } private loadConfig(): ProfilesConfig { + const defaultConfig: ProfilesConfig = { profiles: {}, activeProfile: null }; + if (fs.existsSync(this.profilesFile)) { try { const data = fs.readFileSync(this.profilesFile, 'utf-8'); - return JSON.parse(data); + const parsed: unknown = JSON.parse(data); + + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + const candidate = parsed as Record; + const profiles = + candidate.profiles && + typeof candidate.profiles === 'object' && + !Array.isArray(candidate.profiles) + ? (candidate.profiles as ProfilesConfig['profiles']) + : null; + const activeProfile = + candidate.activeProfile === null || + typeof candidate.activeProfile === 'string' + ? (candidate.activeProfile as string | null) + : null; + + if (profiles) { + return { profiles, activeProfile }; + } + } } catch { // Corrupted file, start fresh } } - return { profiles: {}, activeProfile: null }; + return defaultConfig; } private saveConfig(): void { @@ -165,6 +165,12 @@ export class ProfileManager { const env = getEnvironmentFromMatrixId(matrixId); const username = getUsernameFromMatrixId(matrixId); + if (env === 'unknown' && (!matrixUrl || !realmServerUrl)) { + throw new Error( + `Unknown domain in Matrix ID "${matrixId}". You must provide explicit --matrix-url and --realm-server-url for non-standard domains.`, + ); + } + const defaultMatrixUrl = env === 'production' ? 'https://matrix.boxel.ai' @@ -237,33 +243,16 @@ export class ProfileManager { const matrixUrl = process.env.MATRIX_URL; const username = process.env.MATRIX_USERNAME; const password = process.env.MATRIX_PASSWORD; - let realmServerUrl = process.env.REALM_SERVER_URL; - - if (matrixUrl && username && password) { - if (!realmServerUrl) { - try { - const matrixUrlObj = new URL(matrixUrl); - if (matrixUrlObj.hostname.startsWith('matrix.')) { - realmServerUrl = `${matrixUrlObj.protocol}//app.${matrixUrlObj.hostname.slice(7)}/`; - } else if (matrixUrlObj.hostname.startsWith('matrix-staging.')) { - realmServerUrl = `${matrixUrlObj.protocol}//realms-staging.${matrixUrlObj.hostname.slice(15)}/`; - } else if (matrixUrlObj.hostname.startsWith('matrix-')) { - realmServerUrl = `${matrixUrlObj.protocol}//${matrixUrlObj.hostname.slice(7)}/`; - } - } catch { - // Invalid URL, will return null below - } - } + const realmServerUrl = process.env.REALM_SERVER_URL; - if (realmServerUrl) { - return { - matrixUrl, - username, - password, - realmServerUrl, - profileId: null, - }; - } + if (matrixUrl && username && password && realmServerUrl) { + return { + matrixUrl, + username, + password, + realmServerUrl, + profileId: null, + }; } return null; @@ -292,7 +281,10 @@ export class ProfileManager { return true; } - async migrateFromEnv(): Promise { + async migrateFromEnv(): Promise<{ + profileId: string; + created: boolean; + } | null> { const matrixUrl = process.env.MATRIX_URL; const username = process.env.MATRIX_USERNAME; const password = process.env.MATRIX_PASSWORD; @@ -307,7 +299,12 @@ export class ProfileManager { const matrixId = `@${username}:${domain}`; if (this.config.profiles[matrixId]) { - return matrixId; + // Update password if it changed + if (this.config.profiles[matrixId].password !== password) { + this.config.profiles[matrixId].password = password; + this.saveConfig(); + } + return { profileId: matrixId, created: false }; } await this.addProfile( @@ -317,7 +314,7 @@ export class ProfileManager { matrixUrl, realmServerUrl, ); - return matrixId; + return { profileId: matrixId, created: true }; } printStatus(): void { @@ -349,12 +346,13 @@ export class ProfileManager { } } -// Singleton instance +// Singleton instance — callers needing a custom configDir should use +// `new ProfileManager(dir)` directly. let _instance: ProfileManager | null = null; -export function getProfileManager(configDir?: string): ProfileManager { +export function getProfileManager(): ProfileManager { if (!_instance) { - _instance = new ProfileManager(configDir); + _instance = new ProfileManager(); } return _instance; } diff --git a/packages/boxel-cli/tests/commands/profile.test.ts b/packages/boxel-cli/tests/commands/profile.test.ts index d81ab059076..f1769dbc0ac 100644 --- a/packages/boxel-cli/tests/commands/profile.test.ts +++ b/packages/boxel-cli/tests/commands/profile.test.ts @@ -7,7 +7,7 @@ import { getEnvironmentFromMatrixId, getUsernameFromMatrixId, getDomainFromMatrixId, - getEnvironmentShortLabel, + getEnvironmentLabel, } from '../../src/lib/profile-manager.js'; describe('ProfileManager', () => { @@ -115,15 +115,18 @@ describe('ProfileManager', () => { expect(profile!.password).toBe('password123'); }); - it('sets file permissions to 0600', async () => { - await manager.addProfile('@testuser:stack.cards', 'password123'); + it.skipIf(process.platform === 'win32')( + 'sets file permissions to 0600', + async () => { + await manager.addProfile('@testuser:stack.cards', 'password123'); - const profilesFile = path.join(tmpDir, 'profiles.json'); - const stats = fs.statSync(profilesFile); - // Check owner-only permissions (0600 = 0o600 = 384 decimal) - const mode = stats.mode & 0o777; - expect(mode).toBe(0o600); - }); + const profilesFile = path.join(tmpDir, 'profiles.json'); + const stats = fs.statSync(profilesFile); + // Check owner-only permissions (0600 = 0o600 = 384 decimal) + const mode = stats.mode & 0o777; + expect(mode).toBe(0o600); + }, + ); it('gets active credentials from profile', async () => { await manager.addProfile( @@ -177,6 +180,35 @@ describe('ProfileManager', () => { const freshManager = new ProfileManager(tmpDir); expect(freshManager.listProfiles()).toEqual([]); }); + + it('handles valid JSON with invalid shape gracefully', () => { + const profilesFile = path.join(tmpDir, 'profiles.json'); + fs.writeFileSync(profilesFile, JSON.stringify({ foo: 'bar' })); + + const freshManager = new ProfileManager(tmpDir); + expect(freshManager.listProfiles()).toEqual([]); + }); + + it('rejects unknown domains without explicit URLs', async () => { + await expect( + manager.addProfile('@alice:custom.domain', 'password123'), + ).rejects.toThrow(/Unknown domain/); + }); + + it('allows unknown domains with explicit URLs', async () => { + await manager.addProfile( + '@alice:custom.domain', + 'password123', + undefined, + 'https://matrix.custom.domain', + 'https://app.custom.domain/', + ); + + const profile = manager.getProfile('@alice:custom.domain'); + expect(profile).toBeDefined(); + expect(profile!.matrixUrl).toBe('https://matrix.custom.domain'); + expect(profile!.realmServerUrl).toBe('https://app.custom.domain/'); + }); }); describe('environment helpers', () => { @@ -203,8 +235,8 @@ describe('environment helpers', () => { }); it('returns correct short labels', () => { - expect(getEnvironmentShortLabel('staging')).toBe('stack.cards'); - expect(getEnvironmentShortLabel('production')).toBe('boxel.ai'); - expect(getEnvironmentShortLabel('unknown')).toBe('unknown'); + expect(getEnvironmentLabel('staging')).toBe('stack.cards'); + expect(getEnvironmentLabel('production')).toBe('boxel.ai'); + expect(getEnvironmentLabel('unknown')).toBe('unknown'); }); }); From 453057234e1e12ccae2c1a93a469d9c3215d0721 Mon Sep 17 00:00:00 2001 From: Fadhlan Ridhwanallah Date: Thu, 9 Apr 2026 12:02:07 +0700 Subject: [PATCH 04/26] Improve promptPassword error handling and stdin cleanup Wrap setup code in try/catch so raw mode is restored if anything throws between setRawMode(true) and the data handler. Pair stdin.resume() with stdin.pause() on cleanup to restore original flow state. Use reject() instead of throw for proper promise error propagation. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/boxel-cli/src/commands/profile.ts | 25 +++++++++++++++------- 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/packages/boxel-cli/src/commands/profile.ts b/packages/boxel-cli/src/commands/profile.ts index 35048e691bf..1cb96fab1cc 100644 --- a/packages/boxel-cli/src/commands/profile.ts +++ b/packages/boxel-cli/src/commands/profile.ts @@ -43,8 +43,10 @@ function promptPassword(question: string): Promise { terminal: true, }); - return new Promise((resolve) => { + return new Promise((resolve, reject) => { const stdin = process.stdin; + const wasFlowing = stdin.readableFlowing; + if (stdin.isTTY) { stdin.setRawMode(true); } @@ -55,11 +57,11 @@ function promptPassword(question: string): Promise { stdin.setRawMode(false); } rl.close(); + if (!wasFlowing) { + stdin.pause(); + } }; - process.stdout.write(question); - let password = ''; - const onData = (char: Buffer) => { try { const c = char.toString(); @@ -81,14 +83,21 @@ function promptPassword(question: string): Promise { password += c; process.stdout.write('*'); } - } catch { + } catch (e) { cleanup(); - throw new Error('Error reading password input'); + reject(e); } }; - stdin.on('data', onData); - stdin.resume(); + let password = ''; + try { + process.stdout.write(question); + stdin.on('data', onData); + stdin.resume(); + } catch (e) { + cleanup(); + reject(e); + } }); } From 0593e8a95e657bd0030f0c4d400edd713d689e6a Mon Sep 17 00:00:00 2001 From: Matic Jurglic Date: Thu, 9 Apr 2026 11:10:43 +0200 Subject: [PATCH 05/26] CS-10619: Reimplement `boxel realm create` command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement the full realm creation flow in boxel-cli: - Matrix login → OpenID token → realm server token → POST /_create-realm - Store realm JWT in profile store for subsequent commands - Cache realm server token to skip auth round-trips on repeat calls - Auto re-auth on 401 when cached server token expires - Register new realm in Matrix account data (Boxel dashboard visibility) - Default random background and letter-based icon when not provided - Add realm token storage (realmTokens, realmServerToken) to ProfileManager Co-Authored-By: Claude Opus 4.6 (1M context) --- .../boxel-cli/src/commands/realm/create.ts | 206 +++++++++ .../boxel-cli/src/commands/realm/index.ts | 10 + packages/boxel-cli/src/index.ts | 3 + packages/boxel-cli/src/lib/auth.ts | 165 +++++++ packages/boxel-cli/src/lib/profile-manager.ts | 33 ++ .../tests/commands/realm-create.test.ts | 416 ++++++++++++++++++ 6 files changed, 833 insertions(+) create mode 100644 packages/boxel-cli/src/commands/realm/create.ts create mode 100644 packages/boxel-cli/src/commands/realm/index.ts create mode 100644 packages/boxel-cli/src/lib/auth.ts create mode 100644 packages/boxel-cli/tests/commands/realm-create.test.ts diff --git a/packages/boxel-cli/src/commands/realm/create.ts b/packages/boxel-cli/src/commands/realm/create.ts new file mode 100644 index 00000000000..de845b6e9df --- /dev/null +++ b/packages/boxel-cli/src/commands/realm/create.ts @@ -0,0 +1,206 @@ +import type { Command } from 'commander'; +import { + iconURLFor, + getRandomBackgroundURL, +} from '@cardstack/runtime-common/realm-display-defaults'; +import { + getProfileManager, + getUsernameFromMatrixId, +} from '../../lib/profile-manager'; +import { + matrixLogin, + getRealmServerToken, + getRealmTokens, + addRealmToMatrixAccountData, +} from '../../lib/auth'; +import { FG_GREEN, FG_CYAN, DIM, RESET } from '../../lib/colors'; + +const ENDPOINT_PATTERN = /^[a-z0-9-]+$/; + +export function registerCreateCommand(realm: Command): void { + realm + .command('create') + .description('Create a new realm on the realm server') + .argument('', 'realm endpoint (lowercase, numbers, hyphens only)') + .argument('', 'display name for the realm') + .option('--background ', 'background image URL') + .option('--icon ', 'icon image URL') + .action(async (endpoint: string, name: string, options: CreateOptions) => { + await createRealm(endpoint, name, options); + }); +} + +interface CreateOptions { + background?: string; + icon?: string; +} + +export async function createRealm( + endpoint: string, + name: string, + options: CreateOptions, +): Promise { + if (!ENDPOINT_PATTERN.test(endpoint)) { + console.error( + 'Error: endpoint must contain only lowercase letters, numbers, and hyphens', + ); + process.exit(1); + } + + let pm = getProfileManager(); + let active = pm.getActiveProfile(); + if (!active) { + console.error( + 'Error: no active profile. Run `boxel profile add` to create one.', + ); + process.exit(1); + } + + let { id: profileId, profile } = active; + let username = getUsernameFromMatrixId(profileId); + let realmServerUrl = profile.realmServerUrl.replace(/\/$/, ''); + + // Try cached server token first, fall back to full Matrix auth + let serverToken = pm.getRealmServerToken(); + let matrixAuth; + + if (!serverToken) { + try { + matrixAuth = await matrixLogin( + profile.matrixUrl, + username, + profile.password, + ); + } catch (e: unknown) { + console.error( + `Error: Matrix login failed for ${profileId}`, + ); + console.error(e instanceof Error ? e.message : String(e)); + process.exit(1); + } + + try { + serverToken = await getRealmServerToken(matrixAuth, realmServerUrl); + pm.setRealmServerToken(serverToken); + } catch (e: unknown) { + console.error('Error: failed to obtain realm server token'); + console.error(e instanceof Error ? e.message : String(e)); + process.exit(1); + } + } + + // Build request attributes with default icon/background + let attributes: Record = { endpoint, name }; + attributes.backgroundURL = options.background ?? getRandomBackgroundURL(); + attributes.iconURL = options.icon ?? iconURLFor(name) ?? iconURLFor(endpoint) ?? ''; + + let url = `${realmServerUrl}/_create-realm`; + let body = JSON.stringify({ + data: { + type: 'realm', + attributes, + }, + }); + + let response: Response; + try { + response = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/vnd.api+json', + Authorization: serverToken, + }, + body, + }); + } catch (e: unknown) { + console.error(`Error: failed to connect to realm server at ${url}`); + console.error(e instanceof Error ? e.message : String(e)); + process.exit(1); + } + + // Cached token may be expired — re-auth and retry once + if (response.status === 401 && !matrixAuth) { + try { + matrixAuth = await matrixLogin( + profile.matrixUrl, + username, + profile.password, + ); + serverToken = await getRealmServerToken(matrixAuth, realmServerUrl); + pm.setRealmServerToken(serverToken); + } catch (e: unknown) { + console.error('Error: re-authentication failed'); + console.error(e instanceof Error ? e.message : String(e)); + process.exit(1); + } + + try { + response = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/vnd.api+json', + Authorization: serverToken, + }, + body, + }); + } catch (e: unknown) { + console.error(`Error: failed to connect to realm server at ${url}`); + 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; + + // Obtain and store the realm JWT + if (normalizedRealmUrl) { + try { + let realmTokenMap = await getRealmTokens(realmServerUrl, serverToken); + let realmJwt = realmTokenMap[normalizedRealmUrl]; + if (realmJwt) { + pm.setRealmToken(normalizedRealmUrl, realmJwt); + } + } catch { + // Non-fatal — realm was created but we couldn't persist the token + console.error( + `${DIM}Warning: realm created but could not obtain realm JWT. Run a command against the realm to re-authenticate.${RESET}`, + ); + } + + // Register realm in Matrix account data so it appears in the Boxel dashboard + try { + if (!matrixAuth) { + matrixAuth = await matrixLogin( + profile.matrixUrl, + username, + profile.password, + ); + } + await addRealmToMatrixAccountData(matrixAuth, normalizedRealmUrl); + } catch { + // Non-fatal — realm was created but won't appear in dashboard until next login + 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 ?? endpoint}${RESET}`, + ); +} + +function ensureTrailingSlash(url: string): string { + return url.endsWith('/') ? url : `${url}/`; +} diff --git a/packages/boxel-cli/src/commands/realm/index.ts b/packages/boxel-cli/src/commands/realm/index.ts new file mode 100644 index 00000000000..a435659a41d --- /dev/null +++ b/packages/boxel-cli/src/commands/realm/index.ts @@ -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); +} diff --git a/packages/boxel-cli/src/index.ts b/packages/boxel-cli/src/index.ts index bec803f1696..d19bfaf97dd 100644 --- a/packages/boxel-cli/src/index.ts +++ b/packages/boxel-cli/src/index.ts @@ -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'), @@ -39,4 +40,6 @@ program }, ); +registerRealmCommand(program); + program.parse(); diff --git a/packages/boxel-cli/src/lib/auth.ts b/packages/boxel-cli/src/lib/auth.ts new file mode 100644 index 00000000000..366e985ece0 --- /dev/null +++ b/packages/boxel-cli/src/lib/auth.ts @@ -0,0 +1,165 @@ +export interface MatrixAuth { + accessToken: string; + deviceId: string; + userId: string; + matrixUrl: string; +} + +export type RealmTokens = Record; + +interface MatrixLoginResponse { + access_token: string; + device_id: string; + user_id: string; +} + +const APP_BOXEL_REALMS_EVENT_TYPE = 'app.boxel.realms'; + +export async function matrixLogin( + matrixUrl: string, + username: string, + password: string, +): Promise { + 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> { + 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; +} + +export async function getRealmServerToken( + matrixAuth: MatrixAuth, + realmServerUrl: string, +): Promise { + 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 { + 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 { + 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)) { + existingRealms.push(realmUrl); + await fetch(accountDataUrl, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${matrixAuth.accessToken}`, + }, + body: JSON.stringify({ realms: existingRealms }), + }); + } +} diff --git a/packages/boxel-cli/src/lib/profile-manager.ts b/packages/boxel-cli/src/lib/profile-manager.ts index b67eebb3396..37d8ac34183 100644 --- a/packages/boxel-cli/src/lib/profile-manager.ts +++ b/packages/boxel-cli/src/lib/profile-manager.ts @@ -11,6 +11,8 @@ export interface Profile { matrixUrl: string; realmServerUrl: string; password: string; // Stored in plaintext - file should have restricted permissions, this will be updated in CS-10642 + realmTokens?: Record; + realmServerToken?: string; } export interface ProfilesConfig { @@ -281,6 +283,37 @@ export class ProfileManager { return true; } + setRealmToken(realmUrl: string, token: string): void { + let active = this.getActiveProfile(); + if (!active) { + return; + } + if (!active.profile.realmTokens) { + active.profile.realmTokens = {}; + } + active.profile.realmTokens[realmUrl] = token; + this.saveConfig(); + } + + getRealmToken(realmUrl: string): string | undefined { + let active = this.getActiveProfile(); + return active?.profile.realmTokens?.[realmUrl]; + } + + setRealmServerToken(token: string): void { + let active = this.getActiveProfile(); + if (!active) { + return; + } + active.profile.realmServerToken = token; + this.saveConfig(); + } + + getRealmServerToken(): string | undefined { + let active = this.getActiveProfile(); + return active?.profile.realmServerToken; + } + async migrateFromEnv(): Promise<{ profileId: string; created: boolean; diff --git a/packages/boxel-cli/tests/commands/realm-create.test.ts b/packages/boxel-cli/tests/commands/realm-create.test.ts new file mode 100644 index 00000000000..aed08257835 --- /dev/null +++ b/packages/boxel-cli/tests/commands/realm-create.test.ts @@ -0,0 +1,416 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { createRealm } from '../../src/commands/realm/create.js'; + +let mockProfile = { + displayName: 'Test User', + matrixUrl: 'https://matrix.example.com', + realmServerUrl: 'https://realm.example.com', + password: 'test-password', +}; + +let mockSetRealmToken = vi.fn(); +let mockSetRealmServerToken = vi.fn(); +let mockGetRealmServerToken = vi.fn().mockReturnValue(undefined); + +vi.mock('../../src/lib/profile-manager.js', () => ({ + getProfileManager: () => ({ + getActiveProfile: () => ({ + id: '@testuser:example.com', + profile: mockProfile, + }), + setRealmToken: mockSetRealmToken, + setRealmServerToken: mockSetRealmServerToken, + getRealmServerToken: (...args: unknown[]) => mockGetRealmServerToken(...args), + }), + getUsernameFromMatrixId: (id: string) => { + let match = id.match(/^@([^:]+):/); + return match ? match[1] : id; + }, +})); + +let mockMatrixLogin = vi.fn(); +let mockAuthGetRealmServerToken = vi.fn(); +let mockGetRealmTokens = vi.fn(); +let mockAddRealmToMatrixAccountData = vi.fn(); + +vi.mock('../../src/lib/auth.js', () => ({ + matrixLogin: (...args: unknown[]) => mockMatrixLogin(...args), + getRealmServerToken: (...args: unknown[]) => mockAuthGetRealmServerToken(...args), + getRealmTokens: (...args: unknown[]) => mockGetRealmTokens(...args), + addRealmToMatrixAccountData: (...args: unknown[]) => + mockAddRealmToMatrixAccountData(...args), +})); + +vi.mock('@cardstack/runtime-common/realm-display-defaults', () => ({ + iconURLFor: (word: string) => + word + ? `https://boxel-images.boxel.ai/icons/Letter-${word.charAt(0).toLowerCase()}.png` + : undefined, + getRandomBackgroundURL: () => + 'https://boxel-images.boxel.ai/background-images/4k-desert-dunes.jpg', +})); + +describe('realm create', () => { + let fetchSpy: ReturnType; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let exitSpy: any; + let errorSpy: ReturnType; + let logSpy: ReturnType; + + let matrixAuth = { + accessToken: 'matrix-access-token', + deviceId: 'device-1', + userId: '@testuser:example.com', + matrixUrl: 'https://matrix.example.com', + }; + + beforeEach(() => { + fetchSpy = vi.fn(); + vi.stubGlobal('fetch', fetchSpy); + exitSpy = vi + .spyOn(process, 'exit') + .mockImplementation((() => { + throw new Error('process.exit'); + }) as () => never); + errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + mockMatrixLogin.mockResolvedValue(matrixAuth); + mockAuthGetRealmServerToken.mockResolvedValue('Bearer server-jwt-token'); + mockGetRealmTokens.mockResolvedValue({ + 'https://realm.example.com/my-realm/': 'Bearer realm-jwt-token', + }); + mockAddRealmToMatrixAccountData.mockResolvedValue(undefined); + mockSetRealmToken.mockClear(); + mockSetRealmServerToken.mockClear(); + mockGetRealmServerToken.mockReturnValue(undefined); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('creates a realm via POST to /_create-realm with full auth flow', async () => { + fetchSpy.mockResolvedValue({ + ok: true, + json: async () => ({ + data: { + type: 'realm', + id: 'https://realm.example.com/my-realm/', + attributes: { endpoint: 'my-realm', name: 'My Realm' }, + }, + }), + }); + + await createRealm('my-realm', 'My Realm', {}); + + // Verify Matrix login was called with profile credentials + expect(mockMatrixLogin).toHaveBeenCalledWith( + 'https://matrix.example.com', + 'testuser', + 'test-password', + ); + + // Verify realm server token was obtained + expect(mockAuthGetRealmServerToken).toHaveBeenCalledWith( + matrixAuth, + 'https://realm.example.com', + ); + + // Verify the create-realm POST + expect(fetchSpy).toHaveBeenCalledOnce(); + let [url, init] = fetchSpy.mock.calls[0]; + expect(url).toBe('https://realm.example.com/_create-realm'); + expect(init.method).toBe('POST'); + expect(init.headers['Content-Type']).toBe('application/vnd.api+json'); + expect(init.headers['Authorization']).toBe('Bearer server-jwt-token'); + + let body = JSON.parse(init.body); + expect(body.data.attributes.endpoint).toBe('my-realm'); + expect(body.data.attributes.name).toBe('My Realm'); + }); + + it('uses server token (not profile token) for authentication', async () => { + fetchSpy.mockResolvedValue({ + ok: true, + json: async () => ({ + data: { type: 'realm', id: 'https://realm.example.com/ws/' }, + }), + }); + + await createRealm('ws', 'Workspace', {}); + + let [, init] = fetchSpy.mock.calls[0]; + expect(init.headers['Authorization']).toBe('Bearer server-jwt-token'); + }); + + it('stores the new realm JWT into the profile store', async () => { + fetchSpy.mockResolvedValue({ + ok: true, + json: async () => ({ + data: { type: 'realm', id: 'https://realm.example.com/my-realm/' }, + }), + }); + + mockGetRealmTokens.mockResolvedValue({ + 'https://realm.example.com/my-realm/': 'Bearer realm-jwt-123', + }); + + await createRealm('my-realm', 'Test', {}); + + expect(mockGetRealmTokens).toHaveBeenCalledWith( + 'https://realm.example.com', + 'Bearer server-jwt-token', + ); + expect(mockSetRealmToken).toHaveBeenCalledWith( + 'https://realm.example.com/my-realm/', + 'Bearer realm-jwt-123', + ); + }); + + it('registers the realm in Matrix account data', async () => { + fetchSpy.mockResolvedValue({ + ok: true, + json: async () => ({ + data: { type: 'realm', id: 'https://realm.example.com/my-realm/' }, + }), + }); + + await createRealm('my-realm', 'Test', {}); + + expect(mockAddRealmToMatrixAccountData).toHaveBeenCalledWith( + matrixAuth, + 'https://realm.example.com/my-realm/', + ); + }); + + it('passes --background and --icon options as backgroundURL and iconURL', async () => { + fetchSpy.mockResolvedValue({ + ok: true, + json: async () => ({ + data: { type: 'realm', id: 'https://realm.example.com/ws/' }, + }), + }); + + await createRealm('ws', 'Workspace', { + background: 'https://img.example.com/bg.png', + icon: 'https://img.example.com/icon.png', + }); + + let body = JSON.parse(fetchSpy.mock.calls[0][1].body); + expect(body.data.attributes.backgroundURL).toBe( + 'https://img.example.com/bg.png', + ); + expect(body.data.attributes.iconURL).toBe( + 'https://img.example.com/icon.png', + ); + }); + + it('uses random background and name-based icon when not provided', async () => { + fetchSpy.mockResolvedValue({ + ok: true, + json: async () => ({ + data: { type: 'realm', id: 'https://realm.example.com/ws/' }, + }), + }); + + await createRealm('ws', 'Workspace', {}); + + let body = JSON.parse(fetchSpy.mock.calls[0][1].body); + expect(body.data.attributes.backgroundURL).toBe( + 'https://boxel-images.boxel.ai/background-images/4k-desert-dunes.jpg', + ); + expect(body.data.attributes.iconURL).toBe( + 'https://boxel-images.boxel.ai/icons/Letter-w.png', + ); + }); + + it('rejects endpoints with uppercase letters', async () => { + await expect(createRealm('MyRealm', 'Test', {})).rejects.toThrow( + 'process.exit', + ); + + expect(exitSpy).toHaveBeenCalledWith(1); + expect(errorSpy).toHaveBeenCalledWith( + 'Error: endpoint must contain only lowercase letters, numbers, and hyphens', + ); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it('rejects endpoints with spaces', async () => { + await expect(createRealm('my realm', 'Test', {})).rejects.toThrow( + 'process.exit', + ); + + expect(exitSpy).toHaveBeenCalledWith(1); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it('rejects endpoints with special characters', async () => { + await expect(createRealm('my_realm!', 'Test', {})).rejects.toThrow( + 'process.exit', + ); + + expect(exitSpy).toHaveBeenCalledWith(1); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it('accepts valid endpoints with numbers and hyphens', async () => { + fetchSpy.mockResolvedValue({ + ok: true, + json: async () => ({ + data: { + type: 'realm', + id: 'https://realm.example.com/my-realm-123/', + }, + }), + }); + + mockGetRealmTokens.mockResolvedValue({ + 'https://realm.example.com/my-realm-123/': 'Bearer token-123', + }); + + await createRealm('my-realm-123', 'Test', {}); + + expect(fetchSpy).toHaveBeenCalledOnce(); + }); + + it('handles auth failure (401)', async () => { + fetchSpy.mockResolvedValue({ + ok: false, + status: 401, + text: async () => 'Unauthorized', + }); + + await expect(createRealm('test', 'Test', {})).rejects.toThrow( + 'process.exit', + ); + + expect(exitSpy).toHaveBeenCalledWith(1); + expect(errorSpy).toHaveBeenCalledWith( + 'Error: realm server returned 401', + ); + }); + + it('handles server error (500)', async () => { + fetchSpy.mockResolvedValue({ + ok: false, + status: 500, + text: async () => 'Internal Server Error', + }); + + await expect(createRealm('test', 'Test', {})).rejects.toThrow( + 'process.exit', + ); + + expect(exitSpy).toHaveBeenCalledWith(1); + expect(errorSpy).toHaveBeenCalledWith( + 'Error: realm server returned 500', + ); + }); + + it('handles network errors', async () => { + fetchSpy.mockRejectedValue(new Error('ECONNREFUSED')); + + await expect(createRealm('test', 'Test', {})).rejects.toThrow( + 'process.exit', + ); + + expect(exitSpy).toHaveBeenCalledWith(1); + expect(errorSpy).toHaveBeenCalledWith( + 'Error: failed to connect to realm server at https://realm.example.com/_create-realm', + ); + }); + + it('strips trailing slash from realm server URL', async () => { + mockProfile = { + displayName: 'Test User', + matrixUrl: 'https://matrix.example.com', + realmServerUrl: 'https://realm.example.com/', + password: 'test-password', + }; + + fetchSpy.mockResolvedValue({ + ok: true, + json: async () => ({ + data: { type: 'realm', id: 'https://realm.example.com/ws/' }, + }), + }); + + await createRealm('ws', 'Test', {}); + + expect(fetchSpy.mock.calls[0][0]).toBe( + 'https://realm.example.com/_create-realm', + ); + }); + + it('handles Matrix login failure', async () => { + mockMatrixLogin.mockRejectedValue( + new Error('Matrix login failed: 401 {"errcode":"M_FORBIDDEN"}'), + ); + + await expect(createRealm('test', 'Test', {})).rejects.toThrow( + 'process.exit', + ); + + expect(exitSpy).toHaveBeenCalledWith(1); + expect(errorSpy).toHaveBeenCalledWith( + 'Error: Matrix login failed for @testuser:example.com', + ); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it('handles realm server token failure', async () => { + mockAuthGetRealmServerToken.mockRejectedValue( + new Error('Realm server session failed: 500'), + ); + + await expect(createRealm('test', 'Test', {})).rejects.toThrow( + 'process.exit', + ); + + expect(exitSpy).toHaveBeenCalledWith(1); + expect(errorSpy).toHaveBeenCalledWith( + 'Error: failed to obtain realm server token', + ); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it('still succeeds when realm token fetch fails (non-fatal)', async () => { + fetchSpy.mockResolvedValue({ + ok: true, + json: async () => ({ + data: { type: 'realm', id: 'https://realm.example.com/ws/' }, + }), + }); + + mockGetRealmTokens.mockRejectedValue(new Error('token fetch failed')); + + await createRealm('ws', 'Test', {}); + + // Realm was created successfully + expect(logSpy).toHaveBeenCalled(); + // But token wasn't stored + expect(mockSetRealmToken).not.toHaveBeenCalled(); + }); + + it('ensures trailing slash on realm URL for token storage', async () => { + fetchSpy.mockResolvedValue({ + ok: true, + json: async () => ({ + data: { type: 'realm', id: 'https://realm.example.com/ws' }, + }), + }); + + mockGetRealmTokens.mockResolvedValue({ + 'https://realm.example.com/ws/': 'Bearer ws-token', + }); + + await createRealm('ws', 'Test', {}); + + expect(mockSetRealmToken).toHaveBeenCalledWith( + 'https://realm.example.com/ws/', + 'Bearer ws-token', + ); + }); +}); From cf18949bd34be242f58c6cab6b89595284359ed8 Mon Sep 17 00:00:00 2001 From: Matic Jurglic Date: Thu, 9 Apr 2026 13:45:30 +0200 Subject: [PATCH 06/26] Replace mocked tests with integration tests against real realm server - Remove mocked unit tests (tests/commands/realm-create.test.ts) - Add integration tests that start their own realm server on port 4446 using realm-server test helpers (PostgreSQL + Synapse required) - Register a fresh Matrix user per test run for full auth flow testing - Add test:unit and test:integration scripts for split CI execution - Rename endpoint to realmName in create.ts for clarity - Add @cardstack/postgres devDependency and vitest realm-server alias Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/boxel-cli/package.json | 3 + .../boxel-cli/src/commands/realm/create.ts | 38 +- .../tests/commands/realm-create.test.ts | 416 ------------------ .../boxel-cli/tests/helpers/integration.ts | 125 ++++++ .../tests/helpers/setup-realm-server.ts | 3 + .../tests/integration/realm-create.test.ts | 65 +++ packages/boxel-cli/vitest.config.mjs | 7 + pnpm-lock.yaml | 9 +- 8 files changed, 233 insertions(+), 433 deletions(-) delete mode 100644 packages/boxel-cli/tests/commands/realm-create.test.ts create mode 100644 packages/boxel-cli/tests/helpers/integration.ts create mode 100644 packages/boxel-cli/tests/helpers/setup-realm-server.ts create mode 100644 packages/boxel-cli/tests/integration/realm-create.test.ts diff --git a/packages/boxel-cli/package.json b/packages/boxel-cli/package.json index 009c3d9f339..7f5ec281c9b 100644 --- a/packages/boxel-cli/package.json +++ b/packages/boxel-cli/package.json @@ -33,6 +33,7 @@ }, "devDependencies": { "@cardstack/local-types": "workspace:*", + "@cardstack/postgres": "workspace:*", "@cardstack/runtime-common": "workspace:*", "@types/node": "catalog:", "@typescript-eslint/eslint-plugin": "catalog:", @@ -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": "PGHOST=localhost PGPORT=55436 PGUSER=postgres vitest run tests/integration/**", "test:watch": "vitest", "version:patch": "npm version patch", "version:minor": "npm version minor", diff --git a/packages/boxel-cli/src/commands/realm/create.ts b/packages/boxel-cli/src/commands/realm/create.ts index de845b6e9df..9fecbbf2cad 100644 --- a/packages/boxel-cli/src/commands/realm/create.ts +++ b/packages/boxel-cli/src/commands/realm/create.ts @@ -6,6 +6,7 @@ import { import { getProfileManager, getUsernameFromMatrixId, + type ProfileManager, } from '../../lib/profile-manager'; import { matrixLogin, @@ -15,39 +16,42 @@ import { } from '../../lib/auth'; import { FG_GREEN, FG_CYAN, DIM, RESET } from '../../lib/colors'; -const ENDPOINT_PATTERN = /^[a-z0-9-]+$/; +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 endpoint (lowercase, numbers, hyphens only)') - .argument('', 'display name for the realm') + .argument('', 'realm name (lowercase, numbers, hyphens only)') + .argument('', 'display name for the realm') .option('--background ', 'background image URL') .option('--icon ', 'icon image URL') - .action(async (endpoint: string, name: string, options: CreateOptions) => { - await createRealm(endpoint, name, options); - }); + .action( + async (realmName: string, displayName: string, options: CreateOptions) => { + await createRealm(realmName, displayName, options); + }, + ); } -interface CreateOptions { +export interface CreateOptions { background?: string; icon?: string; + profileManager?: ProfileManager; } export async function createRealm( - endpoint: string, - name: string, + realmName: string, + displayName: string, options: CreateOptions, ): Promise { - if (!ENDPOINT_PATTERN.test(endpoint)) { + if (!REALM_NAME_PATTERN.test(realmName)) { console.error( - 'Error: endpoint must contain only lowercase letters, numbers, and hyphens', + 'Error: realm name must contain only lowercase letters, numbers, and hyphens', ); process.exit(1); } - let pm = getProfileManager(); + let pm = options.profileManager ?? getProfileManager(); let active = pm.getActiveProfile(); if (!active) { console.error( @@ -90,9 +94,13 @@ export async function createRealm( } // Build request attributes with default icon/background - let attributes: Record = { endpoint, name }; + let attributes: Record = { + endpoint: realmName, + name: displayName, + }; attributes.backgroundURL = options.background ?? getRandomBackgroundURL(); - attributes.iconURL = options.icon ?? iconURLFor(name) ?? iconURLFor(endpoint) ?? ''; + attributes.iconURL = + options.icon ?? iconURLFor(displayName) ?? iconURLFor(realmName) ?? ''; let url = `${realmServerUrl}/_create-realm`; let body = JSON.stringify({ @@ -197,7 +205,7 @@ export async function createRealm( } console.log( - `${FG_GREEN}Realm created:${RESET} ${FG_CYAN}${realmUrl ?? endpoint}${RESET}`, + `${FG_GREEN}Realm created:${RESET} ${FG_CYAN}${realmUrl ?? realmName}${RESET}`, ); } diff --git a/packages/boxel-cli/tests/commands/realm-create.test.ts b/packages/boxel-cli/tests/commands/realm-create.test.ts deleted file mode 100644 index aed08257835..00000000000 --- a/packages/boxel-cli/tests/commands/realm-create.test.ts +++ /dev/null @@ -1,416 +0,0 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { createRealm } from '../../src/commands/realm/create.js'; - -let mockProfile = { - displayName: 'Test User', - matrixUrl: 'https://matrix.example.com', - realmServerUrl: 'https://realm.example.com', - password: 'test-password', -}; - -let mockSetRealmToken = vi.fn(); -let mockSetRealmServerToken = vi.fn(); -let mockGetRealmServerToken = vi.fn().mockReturnValue(undefined); - -vi.mock('../../src/lib/profile-manager.js', () => ({ - getProfileManager: () => ({ - getActiveProfile: () => ({ - id: '@testuser:example.com', - profile: mockProfile, - }), - setRealmToken: mockSetRealmToken, - setRealmServerToken: mockSetRealmServerToken, - getRealmServerToken: (...args: unknown[]) => mockGetRealmServerToken(...args), - }), - getUsernameFromMatrixId: (id: string) => { - let match = id.match(/^@([^:]+):/); - return match ? match[1] : id; - }, -})); - -let mockMatrixLogin = vi.fn(); -let mockAuthGetRealmServerToken = vi.fn(); -let mockGetRealmTokens = vi.fn(); -let mockAddRealmToMatrixAccountData = vi.fn(); - -vi.mock('../../src/lib/auth.js', () => ({ - matrixLogin: (...args: unknown[]) => mockMatrixLogin(...args), - getRealmServerToken: (...args: unknown[]) => mockAuthGetRealmServerToken(...args), - getRealmTokens: (...args: unknown[]) => mockGetRealmTokens(...args), - addRealmToMatrixAccountData: (...args: unknown[]) => - mockAddRealmToMatrixAccountData(...args), -})); - -vi.mock('@cardstack/runtime-common/realm-display-defaults', () => ({ - iconURLFor: (word: string) => - word - ? `https://boxel-images.boxel.ai/icons/Letter-${word.charAt(0).toLowerCase()}.png` - : undefined, - getRandomBackgroundURL: () => - 'https://boxel-images.boxel.ai/background-images/4k-desert-dunes.jpg', -})); - -describe('realm create', () => { - let fetchSpy: ReturnType; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - let exitSpy: any; - let errorSpy: ReturnType; - let logSpy: ReturnType; - - let matrixAuth = { - accessToken: 'matrix-access-token', - deviceId: 'device-1', - userId: '@testuser:example.com', - matrixUrl: 'https://matrix.example.com', - }; - - beforeEach(() => { - fetchSpy = vi.fn(); - vi.stubGlobal('fetch', fetchSpy); - exitSpy = vi - .spyOn(process, 'exit') - .mockImplementation((() => { - throw new Error('process.exit'); - }) as () => never); - errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); - logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); - - mockMatrixLogin.mockResolvedValue(matrixAuth); - mockAuthGetRealmServerToken.mockResolvedValue('Bearer server-jwt-token'); - mockGetRealmTokens.mockResolvedValue({ - 'https://realm.example.com/my-realm/': 'Bearer realm-jwt-token', - }); - mockAddRealmToMatrixAccountData.mockResolvedValue(undefined); - mockSetRealmToken.mockClear(); - mockSetRealmServerToken.mockClear(); - mockGetRealmServerToken.mockReturnValue(undefined); - }); - - afterEach(() => { - vi.restoreAllMocks(); - }); - - it('creates a realm via POST to /_create-realm with full auth flow', async () => { - fetchSpy.mockResolvedValue({ - ok: true, - json: async () => ({ - data: { - type: 'realm', - id: 'https://realm.example.com/my-realm/', - attributes: { endpoint: 'my-realm', name: 'My Realm' }, - }, - }), - }); - - await createRealm('my-realm', 'My Realm', {}); - - // Verify Matrix login was called with profile credentials - expect(mockMatrixLogin).toHaveBeenCalledWith( - 'https://matrix.example.com', - 'testuser', - 'test-password', - ); - - // Verify realm server token was obtained - expect(mockAuthGetRealmServerToken).toHaveBeenCalledWith( - matrixAuth, - 'https://realm.example.com', - ); - - // Verify the create-realm POST - expect(fetchSpy).toHaveBeenCalledOnce(); - let [url, init] = fetchSpy.mock.calls[0]; - expect(url).toBe('https://realm.example.com/_create-realm'); - expect(init.method).toBe('POST'); - expect(init.headers['Content-Type']).toBe('application/vnd.api+json'); - expect(init.headers['Authorization']).toBe('Bearer server-jwt-token'); - - let body = JSON.parse(init.body); - expect(body.data.attributes.endpoint).toBe('my-realm'); - expect(body.data.attributes.name).toBe('My Realm'); - }); - - it('uses server token (not profile token) for authentication', async () => { - fetchSpy.mockResolvedValue({ - ok: true, - json: async () => ({ - data: { type: 'realm', id: 'https://realm.example.com/ws/' }, - }), - }); - - await createRealm('ws', 'Workspace', {}); - - let [, init] = fetchSpy.mock.calls[0]; - expect(init.headers['Authorization']).toBe('Bearer server-jwt-token'); - }); - - it('stores the new realm JWT into the profile store', async () => { - fetchSpy.mockResolvedValue({ - ok: true, - json: async () => ({ - data: { type: 'realm', id: 'https://realm.example.com/my-realm/' }, - }), - }); - - mockGetRealmTokens.mockResolvedValue({ - 'https://realm.example.com/my-realm/': 'Bearer realm-jwt-123', - }); - - await createRealm('my-realm', 'Test', {}); - - expect(mockGetRealmTokens).toHaveBeenCalledWith( - 'https://realm.example.com', - 'Bearer server-jwt-token', - ); - expect(mockSetRealmToken).toHaveBeenCalledWith( - 'https://realm.example.com/my-realm/', - 'Bearer realm-jwt-123', - ); - }); - - it('registers the realm in Matrix account data', async () => { - fetchSpy.mockResolvedValue({ - ok: true, - json: async () => ({ - data: { type: 'realm', id: 'https://realm.example.com/my-realm/' }, - }), - }); - - await createRealm('my-realm', 'Test', {}); - - expect(mockAddRealmToMatrixAccountData).toHaveBeenCalledWith( - matrixAuth, - 'https://realm.example.com/my-realm/', - ); - }); - - it('passes --background and --icon options as backgroundURL and iconURL', async () => { - fetchSpy.mockResolvedValue({ - ok: true, - json: async () => ({ - data: { type: 'realm', id: 'https://realm.example.com/ws/' }, - }), - }); - - await createRealm('ws', 'Workspace', { - background: 'https://img.example.com/bg.png', - icon: 'https://img.example.com/icon.png', - }); - - let body = JSON.parse(fetchSpy.mock.calls[0][1].body); - expect(body.data.attributes.backgroundURL).toBe( - 'https://img.example.com/bg.png', - ); - expect(body.data.attributes.iconURL).toBe( - 'https://img.example.com/icon.png', - ); - }); - - it('uses random background and name-based icon when not provided', async () => { - fetchSpy.mockResolvedValue({ - ok: true, - json: async () => ({ - data: { type: 'realm', id: 'https://realm.example.com/ws/' }, - }), - }); - - await createRealm('ws', 'Workspace', {}); - - let body = JSON.parse(fetchSpy.mock.calls[0][1].body); - expect(body.data.attributes.backgroundURL).toBe( - 'https://boxel-images.boxel.ai/background-images/4k-desert-dunes.jpg', - ); - expect(body.data.attributes.iconURL).toBe( - 'https://boxel-images.boxel.ai/icons/Letter-w.png', - ); - }); - - it('rejects endpoints with uppercase letters', async () => { - await expect(createRealm('MyRealm', 'Test', {})).rejects.toThrow( - 'process.exit', - ); - - expect(exitSpy).toHaveBeenCalledWith(1); - expect(errorSpy).toHaveBeenCalledWith( - 'Error: endpoint must contain only lowercase letters, numbers, and hyphens', - ); - expect(fetchSpy).not.toHaveBeenCalled(); - }); - - it('rejects endpoints with spaces', async () => { - await expect(createRealm('my realm', 'Test', {})).rejects.toThrow( - 'process.exit', - ); - - expect(exitSpy).toHaveBeenCalledWith(1); - expect(fetchSpy).not.toHaveBeenCalled(); - }); - - it('rejects endpoints with special characters', async () => { - await expect(createRealm('my_realm!', 'Test', {})).rejects.toThrow( - 'process.exit', - ); - - expect(exitSpy).toHaveBeenCalledWith(1); - expect(fetchSpy).not.toHaveBeenCalled(); - }); - - it('accepts valid endpoints with numbers and hyphens', async () => { - fetchSpy.mockResolvedValue({ - ok: true, - json: async () => ({ - data: { - type: 'realm', - id: 'https://realm.example.com/my-realm-123/', - }, - }), - }); - - mockGetRealmTokens.mockResolvedValue({ - 'https://realm.example.com/my-realm-123/': 'Bearer token-123', - }); - - await createRealm('my-realm-123', 'Test', {}); - - expect(fetchSpy).toHaveBeenCalledOnce(); - }); - - it('handles auth failure (401)', async () => { - fetchSpy.mockResolvedValue({ - ok: false, - status: 401, - text: async () => 'Unauthorized', - }); - - await expect(createRealm('test', 'Test', {})).rejects.toThrow( - 'process.exit', - ); - - expect(exitSpy).toHaveBeenCalledWith(1); - expect(errorSpy).toHaveBeenCalledWith( - 'Error: realm server returned 401', - ); - }); - - it('handles server error (500)', async () => { - fetchSpy.mockResolvedValue({ - ok: false, - status: 500, - text: async () => 'Internal Server Error', - }); - - await expect(createRealm('test', 'Test', {})).rejects.toThrow( - 'process.exit', - ); - - expect(exitSpy).toHaveBeenCalledWith(1); - expect(errorSpy).toHaveBeenCalledWith( - 'Error: realm server returned 500', - ); - }); - - it('handles network errors', async () => { - fetchSpy.mockRejectedValue(new Error('ECONNREFUSED')); - - await expect(createRealm('test', 'Test', {})).rejects.toThrow( - 'process.exit', - ); - - expect(exitSpy).toHaveBeenCalledWith(1); - expect(errorSpy).toHaveBeenCalledWith( - 'Error: failed to connect to realm server at https://realm.example.com/_create-realm', - ); - }); - - it('strips trailing slash from realm server URL', async () => { - mockProfile = { - displayName: 'Test User', - matrixUrl: 'https://matrix.example.com', - realmServerUrl: 'https://realm.example.com/', - password: 'test-password', - }; - - fetchSpy.mockResolvedValue({ - ok: true, - json: async () => ({ - data: { type: 'realm', id: 'https://realm.example.com/ws/' }, - }), - }); - - await createRealm('ws', 'Test', {}); - - expect(fetchSpy.mock.calls[0][0]).toBe( - 'https://realm.example.com/_create-realm', - ); - }); - - it('handles Matrix login failure', async () => { - mockMatrixLogin.mockRejectedValue( - new Error('Matrix login failed: 401 {"errcode":"M_FORBIDDEN"}'), - ); - - await expect(createRealm('test', 'Test', {})).rejects.toThrow( - 'process.exit', - ); - - expect(exitSpy).toHaveBeenCalledWith(1); - expect(errorSpy).toHaveBeenCalledWith( - 'Error: Matrix login failed for @testuser:example.com', - ); - expect(fetchSpy).not.toHaveBeenCalled(); - }); - - it('handles realm server token failure', async () => { - mockAuthGetRealmServerToken.mockRejectedValue( - new Error('Realm server session failed: 500'), - ); - - await expect(createRealm('test', 'Test', {})).rejects.toThrow( - 'process.exit', - ); - - expect(exitSpy).toHaveBeenCalledWith(1); - expect(errorSpy).toHaveBeenCalledWith( - 'Error: failed to obtain realm server token', - ); - expect(fetchSpy).not.toHaveBeenCalled(); - }); - - it('still succeeds when realm token fetch fails (non-fatal)', async () => { - fetchSpy.mockResolvedValue({ - ok: true, - json: async () => ({ - data: { type: 'realm', id: 'https://realm.example.com/ws/' }, - }), - }); - - mockGetRealmTokens.mockRejectedValue(new Error('token fetch failed')); - - await createRealm('ws', 'Test', {}); - - // Realm was created successfully - expect(logSpy).toHaveBeenCalled(); - // But token wasn't stored - expect(mockSetRealmToken).not.toHaveBeenCalled(); - }); - - it('ensures trailing slash on realm URL for token storage', async () => { - fetchSpy.mockResolvedValue({ - ok: true, - json: async () => ({ - data: { type: 'realm', id: 'https://realm.example.com/ws' }, - }), - }); - - mockGetRealmTokens.mockResolvedValue({ - 'https://realm.example.com/ws/': 'Bearer ws-token', - }); - - await createRealm('ws', 'Test', {}); - - expect(mockSetRealmToken).toHaveBeenCalledWith( - 'https://realm.example.com/ws/', - 'Bearer ws-token', - ); - }); -}); diff --git a/packages/boxel-cli/tests/helpers/integration.ts b/packages/boxel-cli/tests/helpers/integration.ts new file mode 100644 index 00000000000..16d4475a70c --- /dev/null +++ b/packages/boxel-cli/tests/helpers/integration.ts @@ -0,0 +1,125 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { ProfileManager } from '../../src/lib/profile-manager'; +import { + prepareTestDB, + createTestPgAdapter, + createVirtualNetwork, + runTestRealmServer, + closeServer, + stopTestPrerenderServer, + matrixURL, + matrixRegistrationSecret, + testPort, +} from '#realm-server/tests/helpers/index'; +import { registerUser } from '#realm-server/synapse'; +import { + PgQueuePublisher, + PgQueueRunner, + type PgAdapter, +} from '@cardstack/postgres'; +import type { Server } from 'http'; + +export const TEST_REALM_SERVER_PORT = testPort(4446); +export const TEST_REALM_SERVER_URL = `http://127.0.0.1:${TEST_REALM_SERVER_PORT}`; + +const TEST_USERNAME = `cli-test-${Date.now()}`; +const TEST_PASSWORD = 'test-password-for-cli'; + +let testRealmHttpServer: Server | undefined; +let dbAdapter: PgAdapter | undefined; +let publisher: PgQueuePublisher | undefined; +let runner: PgQueueRunner | undefined; + +export async function startTestRealmServer(): Promise { + prepareTestDB(); + dbAdapter = await createTestPgAdapter(); + publisher = new PgQueuePublisher(dbAdapter); + runner = new PgQueueRunner({ + adapter: dbAdapter, + workerId: 'cli-test-worker', + }); + + let virtualNetwork = createVirtualNetwork(); + let realmURL = new URL(`${TEST_REALM_SERVER_URL}/test/`); + + let { testRealmHttpServer: server } = await runTestRealmServer({ + testRealmDir: fs.mkdtempSync(path.join(os.tmpdir(), 'boxel-cli-realm-')), + realmsRootPath: fs.mkdtempSync( + path.join(os.tmpdir(), 'boxel-cli-realms-root-'), + ), + realmURL, + virtualNetwork, + publisher, + runner, + dbAdapter, + matrixURL, + permissions: { + '*': ['read', 'write'], + }, + }); + + testRealmHttpServer = server; + + // Register a test user in Synapse so CLI can do a full Matrix login + await registerUser({ + matrixURL, + displayname: 'CLI Test User', + username: TEST_USERNAME, + password: TEST_PASSWORD, + registrationSecret: matrixRegistrationSecret, + }); +} + +export async function stopTestRealmServer(): Promise { + if (testRealmHttpServer) { + await closeServer(testRealmHttpServer); + testRealmHttpServer = undefined; + } + await stopTestPrerenderServer(); + if (publisher) { + await publisher.destroy(); + publisher = undefined; + } + if (runner) { + await runner.destroy(); + runner = undefined; + } + if (dbAdapter) { + await dbAdapter.close(); + dbAdapter = undefined; + } +} + +export function createTestProfileDir(): { + dir: string; + cleanup: () => void; + profileManager: ProfileManager; +} { + let dir = fs.mkdtempSync(path.join(os.tmpdir(), 'boxel-cli-test-')); + let profileManager = new ProfileManager(dir); + return { + dir, + cleanup: () => fs.rmSync(dir, { recursive: true, force: true }), + profileManager, + }; +} + +export async function setupTestProfile(pm: ProfileManager): Promise { + let matrixId = `@${TEST_USERNAME}:localhost`; + await pm.addProfile( + matrixId, + TEST_PASSWORD, + 'CLI Test User', + matrixURL.href, + `${TEST_REALM_SERVER_URL}/`, + ); + return matrixId; +} + +export function uniqueRealmName(): string { + let ts = Date.now().toString(36); + let rand = Math.random().toString(36).slice(2, 6); + return `cli-test-${ts}-${rand}`; +} diff --git a/packages/boxel-cli/tests/helpers/setup-realm-server.ts b/packages/boxel-cli/tests/helpers/setup-realm-server.ts new file mode 100644 index 00000000000..7cba1404b6f --- /dev/null +++ b/packages/boxel-cli/tests/helpers/setup-realm-server.ts @@ -0,0 +1,3 @@ +import { makeLogDefinitions } from '@cardstack/runtime-common'; + +(globalThis as any)._logDefinitions = makeLogDefinitions('*=warn'); diff --git a/packages/boxel-cli/tests/integration/realm-create.test.ts b/packages/boxel-cli/tests/integration/realm-create.test.ts new file mode 100644 index 00000000000..e63e9452546 --- /dev/null +++ b/packages/boxel-cli/tests/integration/realm-create.test.ts @@ -0,0 +1,65 @@ +import '../helpers/setup-realm-server'; +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { createRealm } from '../../src/commands/realm/create'; +import { + startTestRealmServer, + stopTestRealmServer, + createTestProfileDir, + setupTestProfile, + uniqueRealmName, +} from '../helpers/integration'; +import type { ProfileManager } from '../../src/lib/profile-manager'; + +let profileManager: ProfileManager; +let cleanup: () => void; + +beforeAll(async () => { + await startTestRealmServer(); + + let testProfile = createTestProfileDir(); + profileManager = testProfile.profileManager; + cleanup = testProfile.cleanup; + await setupTestProfile(profileManager); +}, 60000); + +afterAll(async () => { + cleanup?.(); + await stopTestRealmServer(); +}); + +describe('realm create (integration)', () => { + it('creates a realm and stores the JWT in the profile', async () => { + let realmName = uniqueRealmName(); + + await createRealm(realmName, `Test ${realmName}`, { profileManager }); + + let active = profileManager.getActiveProfile()!; + let realmTokens = active.profile.realmTokens ?? {}; + let storedToken = Object.entries(realmTokens).find(([url]) => + url.includes(realmName), + )?.[1]; + + expect(storedToken).toBeDefined(); + expect(storedToken!.length).toBeGreaterThan(0); + expect(profileManager.getRealmServerToken()).toBeDefined(); + }); + + it('creates another realm reusing the cached server token', async () => { + let cachedToken = profileManager.getRealmServerToken(); + expect(cachedToken).toBeDefined(); + + let realmName = uniqueRealmName(); + + await createRealm(realmName, `Test ${realmName}`, { profileManager }); + + // Server token was reused, not re-fetched + expect(profileManager.getRealmServerToken()).toBe(cachedToken); + + let active = profileManager.getActiveProfile()!; + let realmTokens = active.profile.realmTokens ?? {}; + let storedToken = Object.entries(realmTokens).find(([url]) => + url.includes(realmName), + )?.[1]; + expect(storedToken).toBeDefined(); + }); +}); diff --git a/packages/boxel-cli/vitest.config.mjs b/packages/boxel-cli/vitest.config.mjs index c7d25ff9c42..f83c9f75dd3 100644 --- a/packages/boxel-cli/vitest.config.mjs +++ b/packages/boxel-cli/vitest.config.mjs @@ -1,11 +1,18 @@ import { defineConfig } from 'vitest/config'; +import { resolve } from 'path'; export default defineConfig({ + resolve: { + alias: { + '#realm-server': resolve(import.meta.dirname, '../realm-server'), + }, + }, test: { globals: true, setupFiles: [], include: ['**/tests/**/*.ts'], exclude: ['tests/helpers/**', 'node_modules'], + testTimeout: 30000, sequence: { hooks: 'list', }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c311b0c39f3..fd2356d2050 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1035,6 +1035,9 @@ importers: '@cardstack/local-types': specifier: workspace:* version: link:../local-types + '@cardstack/postgres': + specifier: workspace:* + version: link:../postgres '@cardstack/runtime-common': specifier: workspace:* version: link:../runtime-common @@ -6097,6 +6100,7 @@ packages: '@tsconfig/ember@3.0.1': resolution: {integrity: sha512-IBoECN9o9StxTZSy12eNSPdqiH5VzngD5Qx9YQDfteiXk9XyJhnyRQuBoU/MQCVnqau9fJpgKoA8Sy/0qItFXw==} + deprecated: Please use @ember/app-tsconfig or @ember/library-tsconfig instead. These live at https://github.com/ember-cli/tsconfigs '@tsconfig/node10@1.0.12': resolution: {integrity: sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==} @@ -6803,6 +6807,7 @@ packages: '@xmldom/xmldom@0.8.11': resolution: {integrity: sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==} engines: {node: '>=10.0.0'} + deprecated: this version has critical issues, please update to the latest version '@xtuc/ieee754@1.2.0': resolution: {integrity: sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==} @@ -7370,7 +7375,7 @@ packages: basic-ftp@5.1.0: resolution: {integrity: sha512-RkaJzeJKDbaDWTIPiJwubyljaEPwpVWkm9Rt5h9Nd6h7tEXTJ3VB4qxdZBioV7JO5yLUaOKwz7vDOzlncUsegw==} engines: {node: '>=10.0.0'} - deprecated: Security vulnerability fixed in 5.2.0, please upgrade + deprecated: Security vulnerability fixed in 5.2.1, please upgrade before-after-hook@2.2.3: resolution: {integrity: sha512-NzUnlZexiaH/46WDhANlyR2bXRopNg4F/zuSA3OpZnllCUgRaOF2znDioDWrmbNVsuZk6l9pMquQB38cfBZwkQ==} @@ -10415,7 +10420,7 @@ packages: glob@8.1.0: resolution: {integrity: sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==} engines: {node: '>=12'} - deprecated: Glob versions prior to v9 are no longer supported + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me glob@9.3.5: resolution: {integrity: sha512-e1LleDykUz2Iu+MTYdkSsuWX8lvAjAcs0Xef0lNIu0S2wOAzuTxCJtcd9S3cijlwYF18EsU3rzb8jPVobxDh9Q==} From 48baf00c26f6affa8df147877bc608397df36014 Mon Sep 17 00:00:00 2001 From: Matic Jurglic Date: Thu, 9 Apr 2026 14:07:38 +0200 Subject: [PATCH 07/26] Add test services to boxel-cli CI for integration tests - Update CI job to start realm-server test services (PostgreSQL, Synapse, host-dist, prerenderer) before running integration tests - Split CI into test:unit (always) and test:integration (with services) - Use env-var defaults for PGHOST/PGPORT/PGUSER so CI and local both work Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/ci.yaml | 24 +++++++++++++++++++++--- packages/boxel-cli/package.json | 2 +- 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 2a9d8d3f5f2..ee924509fa3 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -842,7 +842,7 @@ jobs: boxel-cli-test: name: Boxel CLI Tests - needs: change-check + needs: [change-check, test-web-assets] if: needs.change-check.outputs.boxel-cli == 'true' || github.ref == 'refs/heads/main' || needs.change-check.outputs.run_all == 'true' runs-on: ubuntu-latest concurrency: @@ -851,11 +851,29 @@ jobs: steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - uses: ./.github/actions/init + - name: Download test web assets + uses: actions/download-artifact@b14cf4c92620c250e1c074ab0a5800e37df86765 # 4.2.0 + with: + name: ${{ needs.test-web-assets.outputs.artifact_name }} + path: .test-web-assets-artifact + - name: Restore test web assets into workspace + shell: bash + run: | + shopt -s dotglob + cp -a .test-web-assets-artifact/. ./ - 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 test services + run: mise run test-services:realm-server | tee -a /tmp/server.log & + - 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: diff --git a/packages/boxel-cli/package.json b/packages/boxel-cli/package.json index 7f5ec281c9b..71f0a43f16a 100644 --- a/packages/boxel-cli/package.json +++ b/packages/boxel-cli/package.json @@ -61,7 +61,7 @@ "lint:types": "tsc --noEmit", "test": "vitest run", "test:unit": "vitest run --exclude tests/integration/**", - "test:integration": "PGHOST=localhost PGPORT=55436 PGUSER=postgres vitest run tests/integration/**", + "test:integration": "PGHOST=${PGHOST:-localhost} PGPORT=${PGPORT:-55436} PGUSER=${PGUSER:-postgres} vitest run tests/integration/**", "test:watch": "vitest", "version:patch": "npm version patch", "version:minor": "npm version minor", From b95848e09796108a1e972db41f8ce0ba7c532ff3 Mon Sep 17 00:00:00 2001 From: Matic Jurglic Date: Thu, 9 Apr 2026 14:23:19 +0200 Subject: [PATCH 08/26] Add prepare-test-pg step to boxel-cli CI The integration tests need the boxel_migrated_template database which is created by the realm-server's prepare-test-pg.sh script (separate Docker PostgreSQL on port 55436). Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/ci.yaml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index ee924509fa3..8f9fe3e6467 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -872,6 +872,9 @@ jobs: - name: Create realm users run: pnpm register-realm-users working-directory: packages/matrix + - name: Prepare test PostgreSQL + run: bash tests/scripts/prepare-test-pg.sh + working-directory: packages/realm-server - name: Run integration tests run: pnpm test:integration working-directory: packages/boxel-cli From 6958d62aa80c8f2b0ae61064441bad3c34839ffc Mon Sep 17 00:00:00 2001 From: Matic Jurglic Date: Thu, 9 Apr 2026 14:36:00 +0200 Subject: [PATCH 09/26] Hardcode test PG port 55436 in test:integration script Mise sets PGPORT=5435 for the dev PG, which caused ${PGPORT:-55436} to resolve to 5435 in CI. The test PG with boxel_migrated_template is always on port 55436. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/boxel-cli/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/boxel-cli/package.json b/packages/boxel-cli/package.json index 71f0a43f16a..7f5ec281c9b 100644 --- a/packages/boxel-cli/package.json +++ b/packages/boxel-cli/package.json @@ -61,7 +61,7 @@ "lint:types": "tsc --noEmit", "test": "vitest run", "test:unit": "vitest run --exclude tests/integration/**", - "test:integration": "PGHOST=${PGHOST:-localhost} PGPORT=${PGPORT:-55436} PGUSER=${PGUSER:-postgres} vitest run tests/integration/**", + "test:integration": "PGHOST=localhost PGPORT=55436 PGUSER=postgres vitest run tests/integration/**", "test:watch": "vitest", "version:patch": "npm version patch", "version:minor": "npm version minor", From a799a0eb0aa662b466654642796f5d0f5b2f4166 Mon Sep 17 00:00:00 2001 From: Matic Jurglic Date: Thu, 9 Apr 2026 14:37:39 +0200 Subject: [PATCH 10/26] Use wrapper script for integration tests (same pattern as realm-server) Replace inline env vars with a shell script that mirrors realm-server's run-qunit-with-test-pg.sh: prepare test PG, set PGPORT=55436, run tests, clean up on exit. Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/ci.yaml | 3 --- packages/boxel-cli/package.json | 2 +- .../tests/scripts/run-integration-with-test-pg.sh | 12 ++++++++++++ 3 files changed, 13 insertions(+), 4 deletions(-) create mode 100755 packages/boxel-cli/tests/scripts/run-integration-with-test-pg.sh diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 8f9fe3e6467..ee924509fa3 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -872,9 +872,6 @@ jobs: - name: Create realm users run: pnpm register-realm-users working-directory: packages/matrix - - name: Prepare test PostgreSQL - run: bash tests/scripts/prepare-test-pg.sh - working-directory: packages/realm-server - name: Run integration tests run: pnpm test:integration working-directory: packages/boxel-cli diff --git a/packages/boxel-cli/package.json b/packages/boxel-cli/package.json index 7f5ec281c9b..3030d5dd599 100644 --- a/packages/boxel-cli/package.json +++ b/packages/boxel-cli/package.json @@ -61,7 +61,7 @@ "lint:types": "tsc --noEmit", "test": "vitest run", "test:unit": "vitest run --exclude tests/integration/**", - "test:integration": "PGHOST=localhost PGPORT=55436 PGUSER=postgres vitest run 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", diff --git a/packages/boxel-cli/tests/scripts/run-integration-with-test-pg.sh b/packages/boxel-cli/tests/scripts/run-integration-with-test-pg.sh new file mode 100755 index 00000000000..3c2a731f65f --- /dev/null +++ b/packages/boxel-cli/tests/scripts/run-integration-with-test-pg.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REALM_SERVER_SCRIPTS="$(cd "$SCRIPT_DIR/../../../realm-server/tests/scripts" && pwd)" + +"${REALM_SERVER_SCRIPTS}/prepare-test-pg.sh" +trap '"${REALM_SERVER_SCRIPTS}/stop-test-pg.sh" >/dev/null 2>&1 || true' EXIT INT TERM + +NODE_NO_WARNINGS=1 \ +PGPORT=55436 \ + vitest run tests/integration/** From 02c832c9953c84a571e98e0a12654b7b67e43be7 Mon Sep 17 00:00:00 2001 From: Matic Jurglic Date: Fri, 10 Apr 2026 10:13:02 +0200 Subject: [PATCH 11/26] Move auth logic from createRealm into ProfileManager MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ProfileManager now owns the full auth flow: - getOrRefreshServerToken() — cached token or Matrix login → server token - refreshServerToken() — force re-auth (for 401 retry) - fetchAndStoreRealmTokens() — get realm JWTs and persist them - registerRealmInDashboard() — Matrix account data registration createRealm no longer imports or knows about Matrix login, OpenID tokens, or server session endpoints. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../boxel-cli/src/commands/realm/create.ts | 83 +++++-------------- packages/boxel-cli/src/lib/profile-manager.ts | 56 +++++++++++++ packages/boxel-cli/tsconfig.json | 2 +- 3 files changed, 79 insertions(+), 62 deletions(-) diff --git a/packages/boxel-cli/src/commands/realm/create.ts b/packages/boxel-cli/src/commands/realm/create.ts index 9fecbbf2cad..fa24edb9ef7 100644 --- a/packages/boxel-cli/src/commands/realm/create.ts +++ b/packages/boxel-cli/src/commands/realm/create.ts @@ -5,15 +5,8 @@ import { } from '@cardstack/runtime-common/realm-display-defaults'; import { getProfileManager, - getUsernameFromMatrixId, type ProfileManager, } from '../../lib/profile-manager'; -import { - matrixLogin, - getRealmServerToken, - getRealmTokens, - addRealmToMatrixAccountData, -} from '../../lib/auth'; import { FG_GREEN, FG_CYAN, DIM, RESET } from '../../lib/colors'; const REALM_NAME_PATTERN = /^[a-z0-9-]+$/; @@ -27,7 +20,11 @@ export function registerCreateCommand(realm: Command): void { .option('--background ', 'background image URL') .option('--icon ', 'icon image URL') .action( - async (realmName: string, displayName: string, options: CreateOptions) => { + async ( + realmName: string, + displayName: string, + options: CreateOptions, + ) => { await createRealm(realmName, displayName, options); }, ); @@ -60,37 +57,15 @@ export async function createRealm( process.exit(1); } - let { id: profileId, profile } = active; - let username = getUsernameFromMatrixId(profileId); - let realmServerUrl = profile.realmServerUrl.replace(/\/$/, ''); - - // Try cached server token first, fall back to full Matrix auth - let serverToken = pm.getRealmServerToken(); - let matrixAuth; - - if (!serverToken) { - try { - matrixAuth = await matrixLogin( - profile.matrixUrl, - username, - profile.password, - ); - } catch (e: unknown) { - console.error( - `Error: Matrix login failed for ${profileId}`, - ); - console.error(e instanceof Error ? e.message : String(e)); - process.exit(1); - } + let realmServerUrl = active.profile.realmServerUrl.replace(/\/$/, ''); - try { - serverToken = await getRealmServerToken(matrixAuth, realmServerUrl); - pm.setRealmServerToken(serverToken); - } catch (e: unknown) { - console.error('Error: failed to obtain realm server token'); - console.error(e instanceof Error ? e.message : String(e)); - process.exit(1); - } + let serverToken: string; + try { + serverToken = await pm.getOrRefreshServerToken(); + } catch (e: unknown) { + console.error('Error: authentication failed'); + console.error(e instanceof Error ? e.message : String(e)); + process.exit(1); } // Build request attributes with default icon/background @@ -127,15 +102,9 @@ export async function createRealm( } // Cached token may be expired — re-auth and retry once - if (response.status === 401 && !matrixAuth) { + if (response.status === 401) { try { - matrixAuth = await matrixLogin( - profile.matrixUrl, - username, - profile.password, - ); - serverToken = await getRealmServerToken(matrixAuth, realmServerUrl); - pm.setRealmServerToken(serverToken); + serverToken = await pm.refreshServerToken(); } catch (e: unknown) { console.error('Error: re-authentication failed'); console.error(e instanceof Error ? e.message : String(e)); @@ -174,30 +143,22 @@ export async function createRealm( // Obtain and store the realm JWT if (normalizedRealmUrl) { try { - let realmTokenMap = await getRealmTokens(realmServerUrl, serverToken); - let realmJwt = realmTokenMap[normalizedRealmUrl]; - if (realmJwt) { - pm.setRealmToken(normalizedRealmUrl, realmJwt); + let tokens = await pm.fetchAndStoreRealmTokens(serverToken); + if (!tokens[normalizedRealmUrl]) { + console.error( + `${DIM}Warning: realm created but JWT not found in auth response.${RESET}`, + ); } } catch { - // Non-fatal — realm was created but we couldn't persist the token console.error( - `${DIM}Warning: realm created but could not obtain realm JWT. Run a command against the realm to re-authenticate.${RESET}`, + `${DIM}Warning: realm created but could not obtain realm JWT.${RESET}`, ); } // Register realm in Matrix account data so it appears in the Boxel dashboard try { - if (!matrixAuth) { - matrixAuth = await matrixLogin( - profile.matrixUrl, - username, - profile.password, - ); - } - await addRealmToMatrixAccountData(matrixAuth, normalizedRealmUrl); + await pm.registerRealmInDashboard(normalizedRealmUrl); } catch { - // Non-fatal — realm was created but won't appear in dashboard until next login console.error( `${DIM}Warning: could not register realm in dashboard. It may not appear until next login.${RESET}`, ); diff --git a/packages/boxel-cli/src/lib/profile-manager.ts b/packages/boxel-cli/src/lib/profile-manager.ts index 37d8ac34183..91e51d8dbe0 100644 --- a/packages/boxel-cli/src/lib/profile-manager.ts +++ b/packages/boxel-cli/src/lib/profile-manager.ts @@ -2,6 +2,13 @@ import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; import { FG_YELLOW, FG_CYAN, FG_MAGENTA, DIM, BOLD, RESET } from './colors'; +import { + matrixLogin, + getRealmServerToken as fetchRealmServerToken, + getRealmTokens, + addRealmToMatrixAccountData, + type MatrixAuth, +} from './auth'; const DEFAULT_CONFIG_DIR = path.join(os.homedir(), '.boxel-cli'); const PROFILES_FILENAME = 'profiles.json'; @@ -314,6 +321,55 @@ export class ProfileManager { return active?.profile.realmServerToken; } + private async loginToMatrix(): Promise { + let active = this.getActiveProfile(); + if (!active) { + throw new Error('No active profile'); + } + let { id, profile } = active; + let username = getUsernameFromMatrixId(id); + return matrixLogin(profile.matrixUrl, username, profile.password); + } + + async getOrRefreshServerToken(): Promise { + let cached = this.getRealmServerToken(); + if (cached) { + return cached; + } + let matrixAuth = await this.loginToMatrix(); + let active = this.getActiveProfile()!; + let realmServerUrl = active.profile.realmServerUrl.replace(/\/$/, ''); + let token = await fetchRealmServerToken(matrixAuth, realmServerUrl); + this.setRealmServerToken(token); + return token; + } + + async refreshServerToken(): Promise { + let matrixAuth = await this.loginToMatrix(); + let active = this.getActiveProfile()!; + let realmServerUrl = active.profile.realmServerUrl.replace(/\/$/, ''); + let token = await fetchRealmServerToken(matrixAuth, realmServerUrl); + this.setRealmServerToken(token); + return token; + } + + async fetchAndStoreRealmTokens( + serverToken: string, + ): Promise> { + let active = this.getActiveProfile()!; + let realmServerUrl = active.profile.realmServerUrl.replace(/\/$/, ''); + let tokens = await getRealmTokens(realmServerUrl, serverToken); + for (let [realmUrl, token] of Object.entries(tokens)) { + this.setRealmToken(realmUrl, token); + } + return tokens; + } + + async registerRealmInDashboard(realmUrl: string): Promise { + let matrixAuth = await this.loginToMatrix(); + await addRealmToMatrixAccountData(matrixAuth, realmUrl); + } + async migrateFromEnv(): Promise<{ profileId: string; created: boolean; diff --git a/packages/boxel-cli/tsconfig.json b/packages/boxel-cli/tsconfig.json index 119f4a05aae..b2e7cf4c9cf 100644 --- a/packages/boxel-cli/tsconfig.json +++ b/packages/boxel-cli/tsconfig.json @@ -22,5 +22,5 @@ "types": ["@cardstack/local-types", "node"] }, "include": ["./**/*"], - "exclude": ["node_modules", "dist"] + "exclude": ["node_modules", "dist", "tests/helpers/integration.ts", "tests/helpers/setup-realm-server.ts", "tests/integration"] } From 1d8d5a5a5f9013067764a06caa226aee29705b35 Mon Sep 17 00:00:00 2001 From: Matic Jurglic Date: Fri, 10 Apr 2026 10:22:50 +0200 Subject: [PATCH 12/26] Add authedFetch to ProfileManager authedFetch wraps fetch with automatic server token injection and 401 retry. createRealm now uses pm.authedFetch() instead of manually getting tokens and handling re-auth. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../boxel-cli/src/commands/realm/create.ts | 60 +++---------------- packages/boxel-cli/src/lib/profile-manager.ts | 21 +++++++ 2 files changed, 28 insertions(+), 53 deletions(-) diff --git a/packages/boxel-cli/src/commands/realm/create.ts b/packages/boxel-cli/src/commands/realm/create.ts index fa24edb9ef7..6919e04271f 100644 --- a/packages/boxel-cli/src/commands/realm/create.ts +++ b/packages/boxel-cli/src/commands/realm/create.ts @@ -59,16 +59,6 @@ export async function createRealm( let realmServerUrl = active.profile.realmServerUrl.replace(/\/$/, ''); - let serverToken: string; - try { - serverToken = await pm.getOrRefreshServerToken(); - } catch (e: unknown) { - console.error('Error: authentication failed'); - console.error(e instanceof Error ? e.message : String(e)); - process.exit(1); - } - - // Build request attributes with default icon/background let attributes: Record = { endpoint: realmName, name: displayName, @@ -77,56 +67,21 @@ export async function createRealm( attributes.iconURL = options.icon ?? iconURLFor(displayName) ?? iconURLFor(realmName) ?? ''; - let url = `${realmServerUrl}/_create-realm`; - let body = JSON.stringify({ - data: { - type: 'realm', - attributes, - }, - }); - let response: Response; try { - response = await fetch(url, { + response = await pm.authedFetch(`${realmServerUrl}/_create-realm`, { method: 'POST', - headers: { - 'Content-Type': 'application/vnd.api+json', - Authorization: serverToken, - }, - body, + 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 at ${url}`); + console.error(`Error: failed to connect to realm server`); console.error(e instanceof Error ? e.message : String(e)); process.exit(1); } - // Cached token may be expired — re-auth and retry once - if (response.status === 401) { - try { - serverToken = await pm.refreshServerToken(); - } catch (e: unknown) { - console.error('Error: re-authentication failed'); - console.error(e instanceof Error ? e.message : String(e)); - process.exit(1); - } - - try { - response = await fetch(url, { - method: 'POST', - headers: { - 'Content-Type': 'application/vnd.api+json', - Authorization: serverToken, - }, - body, - }); - } catch (e: unknown) { - console.error(`Error: failed to connect to realm server at ${url}`); - 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}`); @@ -140,9 +95,9 @@ export async function createRealm( let realmUrl = result?.data?.id; let normalizedRealmUrl = realmUrl ? ensureTrailingSlash(realmUrl) : undefined; - // Obtain and store the realm JWT if (normalizedRealmUrl) { try { + let serverToken = await pm.getOrRefreshServerToken(); let tokens = await pm.fetchAndStoreRealmTokens(serverToken); if (!tokens[normalizedRealmUrl]) { console.error( @@ -155,7 +110,6 @@ export async function createRealm( ); } - // Register realm in Matrix account data so it appears in the Boxel dashboard try { await pm.registerRealmInDashboard(normalizedRealmUrl); } catch { diff --git a/packages/boxel-cli/src/lib/profile-manager.ts b/packages/boxel-cli/src/lib/profile-manager.ts index 91e51d8dbe0..e66b9cf2c98 100644 --- a/packages/boxel-cli/src/lib/profile-manager.ts +++ b/packages/boxel-cli/src/lib/profile-manager.ts @@ -353,6 +353,27 @@ export class ProfileManager { return token; } + async authedFetch( + input: string | URL | Request, + init?: RequestInit, + ): Promise { + let token = await this.getOrRefreshServerToken(); + let headers = new Headers(init?.headers); + if (!headers.has('Authorization')) { + headers.set('Authorization', token); + } + + let response = await fetch(input, { ...init, headers }); + + if (response.status === 401) { + token = await this.refreshServerToken(); + headers.set('Authorization', token); + response = await fetch(input, { ...init, headers }); + } + + return response; + } + async fetchAndStoreRealmTokens( serverToken: string, ): Promise> { From 27e3fad279e10d1f067747bc6fe4c9653ba665d5 Mon Sep 17 00:00:00 2001 From: Matic Jurglic Date: Fri, 10 Apr 2026 10:51:32 +0200 Subject: [PATCH 13/26] Hardcode test realm server port instead of using testPort() Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/boxel-cli/tests/helpers/integration.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/boxel-cli/tests/helpers/integration.ts b/packages/boxel-cli/tests/helpers/integration.ts index 16d4475a70c..48610a32233 100644 --- a/packages/boxel-cli/tests/helpers/integration.ts +++ b/packages/boxel-cli/tests/helpers/integration.ts @@ -11,7 +11,6 @@ import { stopTestPrerenderServer, matrixURL, matrixRegistrationSecret, - testPort, } from '#realm-server/tests/helpers/index'; import { registerUser } from '#realm-server/synapse'; import { @@ -21,7 +20,7 @@ import { } from '@cardstack/postgres'; import type { Server } from 'http'; -export const TEST_REALM_SERVER_PORT = testPort(4446); +const TEST_REALM_SERVER_PORT = 4446; export const TEST_REALM_SERVER_URL = `http://127.0.0.1:${TEST_REALM_SERVER_PORT}`; const TEST_USERNAME = `cli-test-${Date.now()}`; From 738c538d43cb43dd1115438f2255375419e13f99 Mon Sep 17 00:00:00 2001 From: Matic Jurglic Date: Fri, 10 Apr 2026 11:04:25 +0200 Subject: [PATCH 14/26] Inline test realm server URL Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/boxel-cli/tests/helpers/integration.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/boxel-cli/tests/helpers/integration.ts b/packages/boxel-cli/tests/helpers/integration.ts index 48610a32233..e03d47f0737 100644 --- a/packages/boxel-cli/tests/helpers/integration.ts +++ b/packages/boxel-cli/tests/helpers/integration.ts @@ -20,8 +20,7 @@ import { } from '@cardstack/postgres'; import type { Server } from 'http'; -const TEST_REALM_SERVER_PORT = 4446; -export const TEST_REALM_SERVER_URL = `http://127.0.0.1:${TEST_REALM_SERVER_PORT}`; +export const TEST_REALM_SERVER_URL = 'http://127.0.0.1:4446'; const TEST_USERNAME = `cli-test-${Date.now()}`; const TEST_PASSWORD = 'test-password-for-cli'; From d1a726c1c1a85bbbf41293f59cb9c789c9e4cd1c Mon Sep 17 00:00:00 2001 From: Matic Jurglic Date: Fri, 10 Apr 2026 11:11:05 +0200 Subject: [PATCH 15/26] Lint fix --- packages/boxel-cli/src/lib/auth.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/boxel-cli/src/lib/auth.ts b/packages/boxel-cli/src/lib/auth.ts index 366e985ece0..4747ee82b3b 100644 --- a/packages/boxel-cli/src/lib/auth.ts +++ b/packages/boxel-cli/src/lib/auth.ts @@ -92,9 +92,7 @@ export async function getRealmServerToken( if (!response.ok) { let text = await response.text(); - throw new Error( - `Realm server session failed: ${response.status} ${text}`, - ); + throw new Error(`Realm server session failed: ${response.status} ${text}`); } let token = response.headers.get('Authorization'); From a98a49dac550dbebde9a2b12561a29badf0d7559 Mon Sep 17 00:00:00 2001 From: Matic Jurglic Date: Fri, 10 Apr 2026 11:27:10 +0200 Subject: [PATCH 16/26] Address PR review feedback - Import APP_BOXEL_REALMS_EVENT_TYPE from runtime-common instead of duplicating the string constant - Check PUT response in addRealmToMatrixAccountData and throw on failure - Preserve Request headers in authedFetch when input is a Request object - Allow iconURL to be undefined instead of sending empty string - Run integration tests single-forked to prevent port collisions - Add wait-on step in CI before running integration tests - Include boxel-cli in test-web-assets trigger condition Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/ci.yaml | 4 +++- packages/boxel-cli/src/commands/realm/create.ts | 8 ++++---- packages/boxel-cli/src/lib/auth.ts | 10 ++++++++-- packages/boxel-cli/src/lib/profile-manager.ts | 17 +++++++++++------ .../scripts/run-integration-with-test-pg.sh | 2 +- 5 files changed, 27 insertions(+), 14 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 47986c38f7f..6d135f8cc18 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -119,7 +119,7 @@ jobs: test-web-assets: name: Build test web assets needs: change-check - if: needs.change-check.outputs.boxel == 'true' || needs.change-check.outputs.boxel-ui == 'true' || needs.change-check.outputs.matrix == 'true' || needs.change-check.outputs.realm-server == 'true' || needs.change-check.outputs.vscode-boxel-tools == 'true' || needs.change-check.outputs.workspace-sync-cli == 'true' || github.ref == 'refs/heads/main' || needs.change-check.outputs.run_all == 'true' + if: needs.change-check.outputs.boxel == 'true' || needs.change-check.outputs.boxel-ui == 'true' || needs.change-check.outputs.boxel-cli == 'true' || needs.change-check.outputs.matrix == 'true' || needs.change-check.outputs.realm-server == 'true' || needs.change-check.outputs.vscode-boxel-tools == 'true' || needs.change-check.outputs.workspace-sync-cli == 'true' || github.ref == 'refs/heads/main' || needs.change-check.outputs.run_all == 'true' uses: ./.github/workflows/test-web-assets.yaml concurrency: group: ci-test-web-assets-${{ github.head_ref || github.run_id }} @@ -812,6 +812,8 @@ jobs: working-directory: packages/boxel-cli - name: Start test services run: mise run test-services:realm-server | tee -a /tmp/server.log & + - name: Wait for test services + run: npx wait-on http-get://localhost:4201/base/_readiness-check?acceptHeader=application%2Fvnd.api%2Bjson http://localhost:8008 --timeout 300000 - name: Create realm users run: pnpm register-realm-users working-directory: packages/matrix diff --git a/packages/boxel-cli/src/commands/realm/create.ts b/packages/boxel-cli/src/commands/realm/create.ts index 6919e04271f..4c9041cee3d 100644 --- a/packages/boxel-cli/src/commands/realm/create.ts +++ b/packages/boxel-cli/src/commands/realm/create.ts @@ -59,13 +59,13 @@ export async function createRealm( let realmServerUrl = active.profile.realmServerUrl.replace(/\/$/, ''); - let attributes: Record = { + let attributes: Record = { endpoint: realmName, name: displayName, + backgroundURL: options.background ?? getRandomBackgroundURL(), + iconURL: + options.icon ?? iconURLFor(displayName) ?? iconURLFor(realmName), }; - attributes.backgroundURL = options.background ?? getRandomBackgroundURL(); - attributes.iconURL = - options.icon ?? iconURLFor(displayName) ?? iconURLFor(realmName) ?? ''; let response: Response; try { diff --git a/packages/boxel-cli/src/lib/auth.ts b/packages/boxel-cli/src/lib/auth.ts index 4747ee82b3b..830d684a329 100644 --- a/packages/boxel-cli/src/lib/auth.ts +++ b/packages/boxel-cli/src/lib/auth.ts @@ -13,7 +13,7 @@ interface MatrixLoginResponse { user_id: string; } -const APP_BOXEL_REALMS_EVENT_TYPE = 'app.boxel.realms'; +import { APP_BOXEL_REALMS_EVENT_TYPE } from '@cardstack/runtime-common/matrix-constants'; export async function matrixLogin( matrixUrl: string, @@ -151,7 +151,7 @@ export async function addRealmToMatrixAccountData( if (!existingRealms.includes(realmUrl)) { existingRealms.push(realmUrl); - await fetch(accountDataUrl, { + let putResponse = await fetch(accountDataUrl, { method: 'PUT', headers: { 'Content-Type': 'application/json', @@ -159,5 +159,11 @@ export async function addRealmToMatrixAccountData( }, 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}`, + ); + } } } diff --git a/packages/boxel-cli/src/lib/profile-manager.ts b/packages/boxel-cli/src/lib/profile-manager.ts index e66b9cf2c98..c58ed4b4021 100644 --- a/packages/boxel-cli/src/lib/profile-manager.ts +++ b/packages/boxel-cli/src/lib/profile-manager.ts @@ -358,17 +358,22 @@ export class ProfileManager { init?: RequestInit, ): Promise { let token = await this.getOrRefreshServerToken(); - let headers = new Headers(init?.headers); - if (!headers.has('Authorization')) { - headers.set('Authorization', token); + let baseHeaders = + input instanceof Request ? new Headers(input.headers) : new Headers(); + let initHeaders = new Headers(init?.headers); + for (let [key, value] of initHeaders) { + baseHeaders.set(key, value); + } + if (!baseHeaders.has('Authorization')) { + baseHeaders.set('Authorization', token); } - let response = await fetch(input, { ...init, headers }); + let response = await fetch(input, { ...init, headers: baseHeaders }); if (response.status === 401) { token = await this.refreshServerToken(); - headers.set('Authorization', token); - response = await fetch(input, { ...init, headers }); + baseHeaders.set('Authorization', token); + response = await fetch(input, { ...init, headers: baseHeaders }); } return response; diff --git a/packages/boxel-cli/tests/scripts/run-integration-with-test-pg.sh b/packages/boxel-cli/tests/scripts/run-integration-with-test-pg.sh index 3c2a731f65f..64f9c618501 100755 --- a/packages/boxel-cli/tests/scripts/run-integration-with-test-pg.sh +++ b/packages/boxel-cli/tests/scripts/run-integration-with-test-pg.sh @@ -9,4 +9,4 @@ trap '"${REALM_SERVER_SCRIPTS}/stop-test-pg.sh" >/dev/null 2>&1 || true' EXIT IN NODE_NO_WARNINGS=1 \ PGPORT=55436 \ - vitest run tests/integration/** + vitest run --pool=forks --poolOptions.forks.singleFork tests/integration/** From d4f076f61adfa0b1d4f6da350b527aeb62e5c91b Mon Sep 17 00:00:00 2001 From: Matic Jurglic Date: Fri, 10 Apr 2026 11:41:32 +0200 Subject: [PATCH 17/26] Lint fix --- packages/boxel-cli/src/commands/realm/create.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/boxel-cli/src/commands/realm/create.ts b/packages/boxel-cli/src/commands/realm/create.ts index 4c9041cee3d..ddf14225b5c 100644 --- a/packages/boxel-cli/src/commands/realm/create.ts +++ b/packages/boxel-cli/src/commands/realm/create.ts @@ -63,8 +63,7 @@ export async function createRealm( endpoint: realmName, name: displayName, backgroundURL: options.background ?? getRandomBackgroundURL(), - iconURL: - options.icon ?? iconURLFor(displayName) ?? iconURLFor(realmName), + iconURL: options.icon ?? iconURLFor(displayName) ?? iconURLFor(realmName), }; let response: Response; From cdf76199d0e4d91444b86666be4ce4e5114ffccb Mon Sep 17 00:00:00 2001 From: Matic Jurglic Date: Fri, 10 Apr 2026 11:53:20 +0200 Subject: [PATCH 18/26] Only store the newly created realm's JWT, not all realm tokens fetchAndStoreRealmToken now takes a specific realm URL and only persists that one token, instead of dumping every accessible realm token into profiles.json. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/boxel-cli/src/commands/realm/create.ts | 7 +++++-- packages/boxel-cli/src/lib/profile-manager.ts | 10 ++++++---- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/packages/boxel-cli/src/commands/realm/create.ts b/packages/boxel-cli/src/commands/realm/create.ts index ddf14225b5c..2ff8f1b629a 100644 --- a/packages/boxel-cli/src/commands/realm/create.ts +++ b/packages/boxel-cli/src/commands/realm/create.ts @@ -97,8 +97,11 @@ export async function createRealm( if (normalizedRealmUrl) { try { let serverToken = await pm.getOrRefreshServerToken(); - let tokens = await pm.fetchAndStoreRealmTokens(serverToken); - if (!tokens[normalizedRealmUrl]) { + let token = await pm.fetchAndStoreRealmToken( + normalizedRealmUrl, + serverToken, + ); + if (!token) { console.error( `${DIM}Warning: realm created but JWT not found in auth response.${RESET}`, ); diff --git a/packages/boxel-cli/src/lib/profile-manager.ts b/packages/boxel-cli/src/lib/profile-manager.ts index c58ed4b4021..e257a816ba6 100644 --- a/packages/boxel-cli/src/lib/profile-manager.ts +++ b/packages/boxel-cli/src/lib/profile-manager.ts @@ -379,16 +379,18 @@ export class ProfileManager { return response; } - async fetchAndStoreRealmTokens( + async fetchAndStoreRealmToken( + realmUrl: string, serverToken: string, - ): Promise> { + ): Promise { let active = this.getActiveProfile()!; let realmServerUrl = active.profile.realmServerUrl.replace(/\/$/, ''); let tokens = await getRealmTokens(realmServerUrl, serverToken); - for (let [realmUrl, token] of Object.entries(tokens)) { + let token = tokens[realmUrl]; + if (token) { this.setRealmToken(realmUrl, token); } - return tokens; + return token; } async registerRealmInDashboard(realmUrl: string): Promise { From 185c005b96344ca65b591e964417e6d0a8b64dd7 Mon Sep 17 00:00:00 2001 From: Matic Jurglic Date: Fri, 10 Apr 2026 12:09:25 +0200 Subject: [PATCH 19/26] Fix CI: use curl loop instead of npx wait-on wait-on is not a dependency of boxel-cli so npx can't find it. Replace with a simple curl polling loop. Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/ci.yaml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 6d135f8cc18..f8abfb62605 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -813,7 +813,9 @@ jobs: - name: Start test services run: mise run test-services:realm-server | tee -a /tmp/server.log & - name: Wait for test services - run: npx wait-on http-get://localhost:4201/base/_readiness-check?acceptHeader=application%2Fvnd.api%2Bjson http://localhost:8008 --timeout 300000 + run: | + timeout 300 bash -c 'until curl -sf http://localhost:8008 > /dev/null 2>&1; do sleep 2; done' + timeout 300 bash -c 'until curl -sf -H "Accept: application/vnd.api+json" http://localhost:4201/base/_readiness-check > /dev/null 2>&1; do sleep 2; done' - name: Create realm users run: pnpm register-realm-users working-directory: packages/matrix From 0e805aa72e9f00c6a867c51e82620b3c840f0c87 Mon Sep 17 00:00:00 2001 From: Matic Jurglic Date: Fri, 10 Apr 2026 12:10:25 +0200 Subject: [PATCH 20/26] Use background-action for service wait (same pattern as other CI jobs) Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/ci.yaml | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index f8abfb62605..3c0d5678751 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -811,11 +811,13 @@ jobs: run: pnpm test:unit working-directory: packages/boxel-cli - name: Start test services - run: mise run test-services:realm-server | tee -a /tmp/server.log & - - name: Wait for test services - run: | - timeout 300 bash -c 'until curl -sf http://localhost:8008 > /dev/null 2>&1; do sleep 2; done' - timeout 300 bash -c 'until curl -sf -H "Accept: application/vnd.api+json" http://localhost:4201/base/_readiness-check > /dev/null 2>&1; do sleep 2; done' + uses: JarvusInnovations/background-action@2428e7b970a846423095c79d43f759abf979a635 # 1.0.7 + with: + run: mise run test-services:realm-server | tee -a /tmp/server.log & + wait-for: 5m + wait-on: | + http-get://localhost:4201/base/_readiness-check?acceptHeader=application%2Fvnd.api%2Bjson + http://localhost:8008 - name: Create realm users run: pnpm register-realm-users working-directory: packages/matrix From 0e1b4928403830138ee41c9e4e750eed6a57761b Mon Sep 17 00:00:00 2001 From: Matic Jurglic Date: Fri, 10 Apr 2026 12:53:07 +0200 Subject: [PATCH 21/26] Use noop prerenderer in CLI integration tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CLI tests only need _create-realm and _realm-auth — no card rendering. Replacing the real prerenderer with a no-op stub avoids launching Chrome, which fails in CI and was the slowest part of the tests (~15s → ~2.4s). Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/boxel-cli/tests/helpers/integration.ts | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/packages/boxel-cli/tests/helpers/integration.ts b/packages/boxel-cli/tests/helpers/integration.ts index e03d47f0737..fbf2561f97e 100644 --- a/packages/boxel-cli/tests/helpers/integration.ts +++ b/packages/boxel-cli/tests/helpers/integration.ts @@ -8,7 +8,6 @@ import { createVirtualNetwork, runTestRealmServer, closeServer, - stopTestPrerenderServer, matrixURL, matrixRegistrationSecret, } from '#realm-server/tests/helpers/index'; @@ -18,8 +17,19 @@ import { PgQueueRunner, type PgAdapter, } from '@cardstack/postgres'; +import type { Prerenderer } from '@cardstack/runtime-common'; import type { Server } from 'http'; +// CLI tests don't need card rendering — stub out the prerenderer +// so we don't launch Chrome (which fails in CI). +const noopPrerenderer: Prerenderer = { + prerenderCard: async () => ({ html: '', status: 200 }) as any, + prerenderModule: async () => ({ html: '', status: 200 }) as any, + prerenderFileExtract: async () => ({ html: '', status: 200 }) as any, + prerenderFileRender: async () => ({ html: '', status: 200 }) as any, + runCommand: async () => ({ status: 'ready' }), +}; + export const TEST_REALM_SERVER_URL = 'http://127.0.0.1:4446'; const TEST_USERNAME = `cli-test-${Date.now()}`; @@ -56,6 +66,7 @@ export async function startTestRealmServer(): Promise { permissions: { '*': ['read', 'write'], }, + prerenderer: noopPrerenderer, }); testRealmHttpServer = server; @@ -75,7 +86,6 @@ export async function stopTestRealmServer(): Promise { await closeServer(testRealmHttpServer); testRealmHttpServer = undefined; } - await stopTestPrerenderServer(); if (publisher) { await publisher.destroy(); publisher = undefined; From 6ec0edcab00895ca9e7cd2c5d1e9ca249a1f8dd2 Mon Sep 17 00:00:00 2001 From: Matic Jurglic Date: Fri, 10 Apr 2026 13:00:51 +0200 Subject: [PATCH 22/26] Simplify boxel-cli CI: only start Matrix + test PG With the noop prerenderer, we don't need test-web-assets, host-dist, icons, prerender services, or the dev realm server. Just Matrix (for user registration/login) and the test PG (started by the integration test script). Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/ci.yaml | 25 +++++-------------------- 1 file changed, 5 insertions(+), 20 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 3c0d5678751..5f837a40a1b 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -119,7 +119,7 @@ jobs: test-web-assets: name: Build test web assets needs: change-check - if: needs.change-check.outputs.boxel == 'true' || needs.change-check.outputs.boxel-ui == 'true' || needs.change-check.outputs.boxel-cli == 'true' || needs.change-check.outputs.matrix == 'true' || needs.change-check.outputs.realm-server == 'true' || needs.change-check.outputs.vscode-boxel-tools == 'true' || needs.change-check.outputs.workspace-sync-cli == 'true' || github.ref == 'refs/heads/main' || needs.change-check.outputs.run_all == 'true' + if: needs.change-check.outputs.boxel == 'true' || needs.change-check.outputs.boxel-ui == 'true' || needs.change-check.outputs.matrix == 'true' || needs.change-check.outputs.realm-server == 'true' || needs.change-check.outputs.vscode-boxel-tools == 'true' || needs.change-check.outputs.workspace-sync-cli == 'true' || github.ref == 'refs/heads/main' || needs.change-check.outputs.run_all == 'true' uses: ./.github/workflows/test-web-assets.yaml concurrency: group: ci-test-web-assets-${{ github.head_ref || github.run_id }} @@ -785,7 +785,7 @@ jobs: boxel-cli-test: name: Boxel CLI Tests - needs: [change-check, test-web-assets] + needs: change-check if: needs.change-check.outputs.boxel-cli == 'true' || github.ref == 'refs/heads/main' || needs.change-check.outputs.run_all == 'true' runs-on: ubuntu-latest concurrency: @@ -794,30 +794,15 @@ jobs: steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - uses: ./.github/actions/init - - name: Download test web assets - uses: actions/download-artifact@b14cf4c92620c250e1c074ab0a5800e37df86765 # 4.2.0 - with: - name: ${{ needs.test-web-assets.outputs.artifact_name }} - path: .test-web-assets-artifact - - name: Restore test web assets into workspace - shell: bash - run: | - shopt -s dotglob - cp -a .test-web-assets-artifact/. ./ - name: Build run: pnpm build working-directory: packages/boxel-cli - name: Run unit tests run: pnpm test:unit working-directory: packages/boxel-cli - - name: Start test services - uses: JarvusInnovations/background-action@2428e7b970a846423095c79d43f759abf979a635 # 1.0.7 - with: - run: mise run test-services:realm-server | tee -a /tmp/server.log & - wait-for: 5m - wait-on: | - http-get://localhost:4201/base/_readiness-check?acceptHeader=application%2Fvnd.api%2Bjson - http://localhost:8008 + - 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 From 6cdc3a646d37338f4d7364bbc1fbb659d3f3980a Mon Sep 17 00:00:00 2001 From: Matic Jurglic Date: Fri, 10 Apr 2026 13:28:18 +0200 Subject: [PATCH 23/26] Remove unnecessary comment about CI Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/boxel-cli/tests/helpers/integration.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/boxel-cli/tests/helpers/integration.ts b/packages/boxel-cli/tests/helpers/integration.ts index fbf2561f97e..99249af38af 100644 --- a/packages/boxel-cli/tests/helpers/integration.ts +++ b/packages/boxel-cli/tests/helpers/integration.ts @@ -21,7 +21,7 @@ import type { Prerenderer } from '@cardstack/runtime-common'; import type { Server } from 'http'; // CLI tests don't need card rendering — stub out the prerenderer -// so we don't launch Chrome (which fails in CI). +// so we don't launch Chrome. const noopPrerenderer: Prerenderer = { prerenderCard: async () => ({ html: '', status: 200 }) as any, prerenderModule: async () => ({ html: '', status: 200 }) as any, From 861f39bde5bc42cc1de953a84e0b1972ff6aad04 Mon Sep 17 00:00:00 2001 From: Matic Jurglic Date: Fri, 10 Apr 2026 13:29:20 +0200 Subject: [PATCH 24/26] Remove unnecessary 60s beforeAll timeout Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/boxel-cli/tests/integration/realm-create.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/boxel-cli/tests/integration/realm-create.test.ts b/packages/boxel-cli/tests/integration/realm-create.test.ts index e63e9452546..80320dd8f31 100644 --- a/packages/boxel-cli/tests/integration/realm-create.test.ts +++ b/packages/boxel-cli/tests/integration/realm-create.test.ts @@ -20,7 +20,7 @@ beforeAll(async () => { profileManager = testProfile.profileManager; cleanup = testProfile.cleanup; await setupTestProfile(profileManager); -}, 60000); +}); afterAll(async () => { cleanup?.(); From d09eaf18c96059207ec7d6f9cd3826eef45cf534 Mon Sep 17 00:00:00 2001 From: Matic Jurglic Date: Fri, 10 Apr 2026 13:42:12 +0200 Subject: [PATCH 25/26] Rename registerRealmInDashboard to addToUserRealms Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/boxel-cli/src/commands/realm/create.ts | 2 +- packages/boxel-cli/src/lib/profile-manager.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/boxel-cli/src/commands/realm/create.ts b/packages/boxel-cli/src/commands/realm/create.ts index 2ff8f1b629a..2ecf80685f5 100644 --- a/packages/boxel-cli/src/commands/realm/create.ts +++ b/packages/boxel-cli/src/commands/realm/create.ts @@ -113,7 +113,7 @@ export async function createRealm( } try { - await pm.registerRealmInDashboard(normalizedRealmUrl); + await pm.addToUserRealms(normalizedRealmUrl); } catch { console.error( `${DIM}Warning: could not register realm in dashboard. It may not appear until next login.${RESET}`, diff --git a/packages/boxel-cli/src/lib/profile-manager.ts b/packages/boxel-cli/src/lib/profile-manager.ts index e257a816ba6..2f5bebb58e5 100644 --- a/packages/boxel-cli/src/lib/profile-manager.ts +++ b/packages/boxel-cli/src/lib/profile-manager.ts @@ -393,7 +393,7 @@ export class ProfileManager { return token; } - async registerRealmInDashboard(realmUrl: string): Promise { + async addToUserRealms(realmUrl: string): Promise { let matrixAuth = await this.loginToMatrix(); await addRealmToMatrixAccountData(matrixAuth, realmUrl); } From cb93163344882cb467a8b44d834a14ab3d9274fc Mon Sep 17 00:00:00 2001 From: Matic Jurglic Date: Fri, 10 Apr 2026 13:43:25 +0200 Subject: [PATCH 26/26] Add unit tests for realm token storage in ProfileManager MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tests setRealmToken, getRealmToken, setRealmServerToken, getRealmServerToken — including disk persistence and edge cases. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../boxel-cli/tests/commands/profile.test.ts | 71 +++++++++++++++++++ 1 file changed, 71 insertions(+) diff --git a/packages/boxel-cli/tests/commands/profile.test.ts b/packages/boxel-cli/tests/commands/profile.test.ts index f1769dbc0ac..25c8cc2ea2e 100644 --- a/packages/boxel-cli/tests/commands/profile.test.ts +++ b/packages/boxel-cli/tests/commands/profile.test.ts @@ -211,6 +211,77 @@ describe('ProfileManager', () => { }); }); +describe('token storage', () => { + let tmpDir: string; + let manager: ProfileManager; + + beforeEach(async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'boxel-profile-test-')); + manager = new ProfileManager(tmpDir); + await manager.addProfile( + '@test:localhost', + 'pass', + 'Test', + 'http://localhost:8008', + 'http://localhost:4201/', + ); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('stores and retrieves a realm token', () => { + manager.setRealmToken('http://localhost:4201/my-realm/', 'jwt-123'); + expect(manager.getRealmToken('http://localhost:4201/my-realm/')).toBe( + 'jwt-123', + ); + }); + + it('stores and retrieves a realm server token', () => { + manager.setRealmServerToken('server-jwt-456'); + expect(manager.getRealmServerToken()).toBe('server-jwt-456'); + }); + + it('persists realm tokens to disk', () => { + manager.setRealmToken('http://localhost:4201/my-realm/', 'jwt-123'); + + let manager2 = new ProfileManager(tmpDir); + expect(manager2.getRealmToken('http://localhost:4201/my-realm/')).toBe( + 'jwt-123', + ); + }); + + it('persists realm server token to disk', () => { + manager.setRealmServerToken('server-jwt-456'); + + let manager2 = new ProfileManager(tmpDir); + expect(manager2.getRealmServerToken()).toBe('server-jwt-456'); + }); + + it('stores multiple realm tokens independently', () => { + manager.setRealmToken('http://localhost:4201/realm-a/', 'jwt-a'); + manager.setRealmToken('http://localhost:4201/realm-b/', 'jwt-b'); + + expect(manager.getRealmToken('http://localhost:4201/realm-a/')).toBe( + 'jwt-a', + ); + expect(manager.getRealmToken('http://localhost:4201/realm-b/')).toBe( + 'jwt-b', + ); + }); + + it('returns undefined for unknown realm token', () => { + expect( + manager.getRealmToken('http://localhost:4201/nonexistent/'), + ).toBeUndefined(); + }); + + it('returns undefined for realm server token when not set', () => { + expect(manager.getRealmServerToken()).toBeUndefined(); + }); +}); + describe('environment helpers', () => { it('detects staging environment', () => { expect(getEnvironmentFromMatrixId('@user:stack.cards')).toBe('staging');