diff --git a/packages/boxel-cli/src/commands/profile.ts b/packages/boxel-cli/src/commands/profile.ts new file mode 100644 index 00000000000..1cb96fab1cc --- /dev/null +++ b/packages/boxel-cli/src/commands/profile.ts @@ -0,0 +1,445 @@ +import * as readline from 'readline'; +import { Writable } from 'stream'; +import type { ProfileManager } from '../lib/profile-manager'; +import { + getProfileManager, + formatProfileBadge, + getEnvironmentFromMatrixId, + getEnvironmentLabel, + getUsernameFromMatrixId, +} from '../lib/profile-manager'; +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({ + 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, reject) => { + const stdin = process.stdin; + const wasFlowing = stdin.readableFlowing; + + if (stdin.isTTY) { + stdin.setRawMode(true); + } + + const cleanup = () => { + stdin.removeListener('data', onData); + if (stdin.isTTY) { + stdin.setRawMode(false); + } + rl.close(); + if (!wasFlowing) { + stdin.pause(); + } + }; + + const onData = (char: Buffer) => { + 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('*'); + } + } catch (e) { + cleanup(); + reject(e); + } + }; + + let password = ''; + try { + process.stdout.write(question); + stdin.on('data', onData); + stdin.resume(); + } catch (e) { + cleanup(); + reject(e); + } + }); +} + +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 = getEnvironmentLabel(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; + const realmServerUrl = process.env.REALM_SERVER_URL; + + if (!matrixUrl || !username || !password || !realmServerUrl) { + 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 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.${RESET}`); + } +} diff --git a/packages/boxel-cli/src/index.ts b/packages/boxel-cli/src/index.ts index 61c50b9ec0a..bec803f1696 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'; 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/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 new file mode 100644 index 00000000000..b67eebb3396 --- /dev/null +++ b/packages/boxel-cli/src/lib/profile-manager.ts @@ -0,0 +1,365 @@ +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'; + +export interface Profile { + displayName: string; + matrixUrl: string; + realmServerUrl: string; + password: string; // Stored in plaintext - file should have restricted permissions, this will be updated in CS-10642 +} + +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 label for display (uses domain) + */ +export function getEnvironmentLabel(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 = getEnvironmentLabel(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 { + const defaultConfig: ProfilesConfig = { profiles: {}, activeProfile: null }; + + if (fs.existsSync(this.profilesFile)) { + try { + const data = fs.readFileSync(this.profilesFile, 'utf-8'); + 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 defaultConfig; + } + + 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); + + 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' + : '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; + const realmServerUrl = process.env.REALM_SERVER_URL; + + if (matrixUrl && username && password && 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<{ + profileId: string; + created: boolean; + } | null> { + 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]) { + // 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( + matrixId, + password, + undefined, + matrixUrl, + realmServerUrl, + ); + return { profileId: matrixId, created: true }; + } + + 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 — callers needing a custom configDir should use +// `new ProfileManager(dir)` directly. +let _instance: ProfileManager | null = null; + +export function getProfileManager(): ProfileManager { + if (!_instance) { + _instance = new ProfileManager(); + } + 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..f1769dbc0ac --- /dev/null +++ b/packages/boxel-cli/tests/commands/profile.test.ts @@ -0,0 +1,242 @@ +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, + getEnvironmentLabel, +} 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.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); + }, + ); + + 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([]); + }); + + 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', () => { + 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(getEnvironmentLabel('staging')).toBe('stack.cards'); + expect(getEnvironmentLabel('production')).toBe('boxel.ai'); + expect(getEnvironmentLabel('unknown')).toBe('unknown'); + }); +});