From 8ce874ae2b443755ad21a6494e82226e845858a9 Mon Sep 17 00:00:00 2001 From: Fadhlan Ridhwanallah Date: Wed, 8 Apr 2026 15:55:44 +0700 Subject: [PATCH 1/4] 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 2/4] 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 3/4] 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 4/4] 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); + } }); }