diff --git a/src/commands/watch.ts b/src/commands/watch.ts index c2eb631..f7ac588 100644 --- a/src/commands/watch.ts +++ b/src/commands/watch.ts @@ -6,6 +6,7 @@ import { resolveWorkspace } from '../lib/workspace-resolver.js'; import { CheckpointManager, type CheckpointChange } from '../lib/checkpoint-manager.js'; import { createHash } from 'crypto'; import { getEditingFiles } from '../lib/edit-lock.js'; +import { getProfileManager, formatProfileBadge } from '../lib/profile-manager.js'; interface WatchOptions { interval?: number; @@ -38,16 +39,22 @@ export async function watchCommand( const intervalMs = (options.interval || 30) * 1000; const debounceMs = (options.debounce ?? 5) * 1000; - // Initialize Matrix client (shared across all realms) - const matrixUrl = process.env.MATRIX_URL; - const username = process.env.MATRIX_USERNAME; - const password = process.env.MATRIX_PASSWORD; + // Get credentials from profile manager (falls back to env vars) + const profileManager = getProfileManager(); + const credentials = await profileManager.getActiveCredentials(); - if (!matrixUrl || !username || !password) { - console.error('Missing required environment variables: MATRIX_URL, MATRIX_USERNAME, MATRIX_PASSWORD'); + if (!credentials) { + console.error('No credentials found. Run "boxel profile add" or set environment variables.'); process.exit(1); } + const { matrixUrl, username, password, profileId } = credentials; + + // Show active profile if using one + if (profileId) { + console.log(`${formatProfileBadge(profileId)}\n`); + } + const matrixClient = new MatrixClient({ matrixURL: new URL(matrixUrl), username, @@ -170,7 +177,11 @@ export async function watchCommand( const fileResponse = await fetch(fileUrl, { headers: { 'Authorization': realm.jwt, - 'Accept': file.endsWith('.json') ? 'application/vnd.card+json' : '*/*', + 'Accept': file.endsWith('.json') + ? 'application/vnd.card+json' + : file.endsWith('.gts') + ? 'application/vnd.card+source' + : '*/*', }, }); diff --git a/src/lib/profile-manager.ts b/src/lib/profile-manager.ts new file mode 100644 index 0000000..e50992c --- /dev/null +++ b/src/lib/profile-manager.ts @@ -0,0 +1,365 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; + +const CONFIG_DIR = path.join(os.homedir(), '.boxel-cli'); +const PROFILES_FILE = path.join(CONFIG_DIR, 'profiles.json'); + +// 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'; + +export interface Profile { + displayName: string; + matrixUrl: string; + realmServerUrl: string; + password: string; // Stored in plaintext - file should have restricted permissions + // Username extracted from profile ID (e.g., @ctse:stack.cards -> ctse) +} + +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 + * @example @ctse:boxel.ai -> boxel.ai + */ +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}ยท${RESET} ${FG_MAGENTA}${env}${RESET}${DIM}]${RESET}`; +} + +export class ProfileManager { + private config: ProfilesConfig; + + constructor() { + this.config = this.loadConfig(); + } + + private ensureConfigDir(): void { + if (!fs.existsSync(CONFIG_DIR)) { + fs.mkdirSync(CONFIG_DIR, { recursive: true }); + } + } + + private loadConfig(): ProfilesConfig { + if (fs.existsSync(PROFILES_FILE)) { + try { + const data = fs.readFileSync(PROFILES_FILE, 'utf-8'); + return JSON.parse(data); + } catch { + // Corrupted file, start fresh + } + } + return { profiles: {}, activeProfile: null }; + } + + private saveConfig(): void { + this.ensureConfigDir(); + fs.writeFileSync(PROFILES_FILE, JSON.stringify(this.config, null, 2), { mode: 0o600 }); + // Ensure file permissions are restricted (owner read/write only) + try { + fs.chmodSync(PROFILES_FILE, 0o600); + } catch { + // Ignore permission errors on Windows + } + } + + /** + * Get all profile IDs + */ + listProfiles(): string[] { + return Object.keys(this.config.profiles); + } + + /** + * Get a profile by ID + */ + getProfile(profileId: string): Profile | undefined { + return this.config.profiles[profileId]; + } + + /** + * Get the active profile ID + */ + getActiveProfileId(): string | null { + return this.config.activeProfile; + } + + /** + * Get the active profile + */ + 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 }; + } + + /** + * Add a new profile + */ + async addProfile( + matrixId: string, + password: string, + displayName?: string, + matrixUrl?: string, + realmServerUrl?: string + ): Promise { + const env = getEnvironmentFromMatrixId(matrixId); + const username = getUsernameFromMatrixId(matrixId); + + // Default URLs based on environment + 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} ยท ${domain}`, + matrixUrl: matrixUrl || defaultMatrixUrl, + realmServerUrl: realmServerUrl || defaultRealmUrl, + password, + }; + + this.config.profiles[matrixId] = profile; + + // If no active profile, make this one active + if (!this.config.activeProfile) { + this.config.activeProfile = matrixId; + } + + this.saveConfig(); + } + + /** + * Remove a profile + */ + async removeProfile(profileId: string): Promise { + if (!this.config.profiles[profileId]) { + return false; + } + + delete this.config.profiles[profileId]; + + // If this was the active profile, clear it + 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; + } + + /** + * Switch to a different profile + */ + switchProfile(profileId: string): boolean { + if (!this.config.profiles[profileId]) { + return false; + } + this.config.activeProfile = profileId; + this.saveConfig(); + return true; + } + + /** + * Get credentials for the active profile + * Falls back to environment variables if no profile is active + */ + async getActiveCredentials(): Promise<{ + matrixUrl: string; + username: string; + password: string; + realmServerUrl: string; + profileId: string | null; + } | null> { + // First check for active profile + 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, + }; + } + + // Fall back to environment variables + 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; + } + + /** + * Get password for a specific profile + */ + async getPassword(profileId: string): Promise { + const profile = this.config.profiles[profileId]; + return profile?.password || null; + } + + /** + * Update password for a profile + */ + async updatePassword(profileId: string, password: string): Promise { + if (!this.config.profiles[profileId]) { + return false; + } + this.config.profiles[profileId].password = password; + this.saveConfig(); + return true; + } + + /** + * Update profile display name + */ + updateDisplayName(profileId: string, displayName: string): boolean { + if (!this.config.profiles[profileId]) { + return false; + } + this.config.profiles[profileId].displayName = displayName; + this.saveConfig(); + return true; + } + + /** + * Migrate from .env file to profile + * Returns the created profile ID or null if no env vars found + */ + 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; + } + + // Determine environment from URL + const isProduction = matrixUrl.includes('boxel.ai'); + const domain = isProduction ? 'boxel.ai' : 'stack.cards'; + const matrixId = `@${username}:${domain}`; + + // Don't duplicate if profile already exists + if (this.config.profiles[matrixId]) { + return matrixId; + } + + await this.addProfile(matrixId, password, undefined, matrixUrl, realmServerUrl); + return matrixId; + } + + /** + * Print current profile status + */ + printStatus(): void { + const active = this.getActiveProfile(); + if (active) { + const env = getEnvironmentFromMatrixId(active.id); + 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(): ProfileManager { + if (!_instance) { + _instance = new ProfileManager(); + } + return _instance; +} diff --git a/src/lib/realm-sync-base.ts b/src/lib/realm-sync-base.ts index 34e96ce..d778e93 100644 --- a/src/lib/realm-sync-base.ts +++ b/src/lib/realm-sync-base.ts @@ -330,10 +330,17 @@ export abstract class RealmSyncBase { const url = this.buildFileUrl(relativePath); const jwt = await this.realmAuthClient.getJWT(); + // Use appropriate Accept header based on file type + const acceptHeader = relativePath.endsWith('.json') + ? SupportedMimeType.CardJson + : relativePath.endsWith('.gts') + ? SupportedMimeType.CardSource + : '*/*'; + const response = await fetch(url, { headers: { Authorization: jwt, - Accept: SupportedMimeType.CardSource, + Accept: acceptHeader, }, }); @@ -507,6 +514,20 @@ export async function validateMatrixEnvVars(workspaceUrl: string): Promise<{ username: string; password: string; }> { + // Try profile manager first + const { getProfileManager } = await import('./profile-manager.js'); + const profileManager = getProfileManager(); + const credentials = await profileManager.getActiveCredentials(); + + if (credentials) { + return { + matrixUrl: credentials.matrixUrl, + username: credentials.username, + password: credentials.password, + }; + } + + // Fall back to environment variables const matrixUrl = process.env.MATRIX_URL; const envUsername = process.env.MATRIX_USERNAME; let password = process.env.MATRIX_PASSWORD; @@ -515,6 +536,7 @@ export async function validateMatrixEnvVars(workspaceUrl: string): Promise<{ if (!matrixUrl) { console.error('MATRIX_URL environment variable is required'); + console.error('Or run "boxel profile add" to create a profile.'); process.exit(1); }