diff --git a/ccw/src/core/routes/litellm-api-routes.ts b/ccw/src/core/routes/litellm-api-routes.ts index 384f8f77..8d38b575 100644 --- a/ccw/src/core/routes/litellm-api-routes.ts +++ b/ccw/src/core/routes/litellm-api-routes.ts @@ -6,6 +6,12 @@ import { fileURLToPath } from 'url'; import { dirname, join as pathJoin } from 'path'; import { z } from 'zod'; import { getSystemPython } from '../../utils/python-utils.js'; +import { + UvManager, + isUvAvailable, + ensureUvInstalled, + createCodexLensUvManager +} from '../../utils/uv-manager.js'; import type { RouteContext } from './types.js'; // ========== Input Validation Schemas ========== @@ -97,6 +103,47 @@ export function clearCcwLitellmStatusCache() { ccwLitellmStatusCache.timestamp = 0; } +/** + * Install ccw-litellm using UV package manager + * Uses CodexLens venv for consistency with other Python dependencies + * @param packagePath - Local package path, or null to install from PyPI + * @returns Installation result + */ +async function installCcwLitellmWithUv(packagePath: string | null): Promise<{ success: boolean; message?: string; error?: string }> { + try { + await ensureUvInstalled(); + + // Reuse CodexLens venv for consistency + const uv = createCodexLensUvManager(); + + // Ensure venv exists + const venvResult = await uv.createVenv(); + if (!venvResult.success) { + return { success: false, error: venvResult.error }; + } + + if (packagePath) { + // Install from local path + const result = await uv.installFromProject(packagePath); + if (result.success) { + clearCcwLitellmStatusCache(); + return { success: true, message: 'ccw-litellm installed from local path via UV' }; + } + return { success: false, error: result.error }; + } else { + // Install from PyPI + const result = await uv.install(['ccw-litellm']); + if (result.success) { + clearCcwLitellmStatusCache(); + return { success: true, message: 'ccw-litellm installed from PyPI via UV' }; + } + return { success: false, error: result.error }; + } + } catch (err) { + return { success: false, error: (err as Error).message }; + } +} + function sanitizeProviderForResponse(provider: any): any { if (!provider) return provider; return { @@ -1093,6 +1140,22 @@ export async function handleLiteLLMApiRoutes(ctx: RouteContext): Promise { */ type GpuMode = 'cpu' | 'cuda' | 'directml'; +/** + * Mapping from GPU mode to codexlens extras for UV installation + */ +const GPU_MODE_EXTRAS: Record = { + cpu: ['semantic'], + cuda: ['semantic-gpu'], + directml: ['semantic-directml'], +}; + /** * Python environment info for compatibility checks */ @@ -467,12 +482,165 @@ async function detectGpuSupport(): Promise<{ mode: GpuMode; available: GpuMode[] return { mode: recommendedMode, available, info: detectedInfo, pythonEnv }; } +/** + * Bootstrap CodexLens venv using UV (fast package manager) + * @param gpuMode - GPU acceleration mode for semantic search + * @returns Bootstrap result + */ +async function bootstrapWithUv(gpuMode: GpuMode = 'cpu'): Promise { + console.log('[CodexLens] Bootstrapping with UV package manager...'); + + // Ensure UV is installed + const uvInstalled = await ensureUvInstalled(); + if (!uvInstalled) { + return { success: false, error: 'Failed to install UV package manager' }; + } + + // Create UV manager for CodexLens + const uv = createCodexLensUvManager(); + + // Create venv if not exists + if (!uv.isVenvValid()) { + console.log('[CodexLens] Creating virtual environment with UV...'); + const createResult = await uv.createVenv(); + if (!createResult.success) { + return { success: false, error: `Failed to create venv: ${createResult.error}` }; + } + } + + // Find local codex-lens package + const possiblePaths = [ + join(process.cwd(), 'codex-lens'), + join(__dirname, '..', '..', '..', 'codex-lens'), // ccw/src/tools -> project root + join(homedir(), 'codex-lens'), + ]; + + let codexLensPath: string | null = null; + for (const localPath of possiblePaths) { + if (existsSync(join(localPath, 'pyproject.toml'))) { + codexLensPath = localPath; + break; + } + } + + // Determine extras based on GPU mode + const extras = GPU_MODE_EXTRAS[gpuMode]; + + if (codexLensPath) { + console.log(`[CodexLens] Installing from local path with UV: ${codexLensPath}`); + console.log(`[CodexLens] Extras: ${extras.join(', ')}`); + const installResult = await uv.installFromProject(codexLensPath, extras); + if (!installResult.success) { + return { success: false, error: `Failed to install codexlens: ${installResult.error}` }; + } + } else { + // Install from PyPI with extras + console.log('[CodexLens] Installing from PyPI with UV...'); + const packageSpec = `codexlens[${extras.join(',')}]`; + const installResult = await uv.install([packageSpec]); + if (!installResult.success) { + return { success: false, error: `Failed to install codexlens: ${installResult.error}` }; + } + } + + // Clear cache after successful installation + clearVenvStatusCache(); + console.log(`[CodexLens] Bootstrap with UV complete (${gpuMode} mode)`); + return { success: true, message: `Installed with UV (${gpuMode} mode)` }; +} + +/** + * Install semantic search dependencies using UV (fast package manager) + * UV automatically handles ONNX Runtime conflicts + * @param gpuMode - GPU acceleration mode: 'cpu', 'cuda', or 'directml' + * @returns Bootstrap result + */ +async function installSemanticWithUv(gpuMode: GpuMode = 'cpu'): Promise { + console.log('[CodexLens] Installing semantic dependencies with UV...'); + + // First check if CodexLens is installed + const venvStatus = await checkVenvStatus(); + if (!venvStatus.ready) { + return { success: false, error: 'CodexLens not installed. Install CodexLens first.' }; + } + + // Check Python environment compatibility for DirectML + if (gpuMode === 'directml') { + const pythonEnv = await checkPythonEnvForDirectML(); + if (!pythonEnv.compatible) { + const errorDetails = pythonEnv.error || 'Unknown compatibility issue'; + return { + success: false, + error: `DirectML installation failed: ${errorDetails}\n\nTo fix this:\n1. Uninstall current Python\n2. Install 64-bit Python 3.10, 3.11, or 3.12 from python.org\n3. Delete ~/.codexlens/venv folder\n4. Reinstall CodexLens`, + }; + } + console.log(`[CodexLens] Python ${pythonEnv.version} (${pythonEnv.architecture}-bit) - DirectML compatible`); + } + + // Create UV manager + const uv = createCodexLensUvManager(); + + // Find local codex-lens package + const possiblePaths = [ + join(process.cwd(), 'codex-lens'), + join(__dirname, '..', '..', '..', 'codex-lens'), + join(homedir(), 'codex-lens'), + ]; + + let codexLensPath: string | null = null; + for (const localPath of possiblePaths) { + if (existsSync(join(localPath, 'pyproject.toml'))) { + codexLensPath = localPath; + break; + } + } + + // Determine extras based on GPU mode + const extras = GPU_MODE_EXTRAS[gpuMode]; + const modeDescription = + gpuMode === 'cuda' + ? 'NVIDIA CUDA GPU acceleration' + : gpuMode === 'directml' + ? 'Windows DirectML GPU acceleration' + : 'CPU (ONNX Runtime)'; + + console.log(`[CodexLens] Mode: ${modeDescription}`); + console.log(`[CodexLens] Extras: ${extras.join(', ')}`); + + // Install with extras - UV handles dependency conflicts automatically + if (codexLensPath) { + console.log(`[CodexLens] Reinstalling from local path with semantic extras...`); + const installResult = await uv.installFromProject(codexLensPath, extras); + if (!installResult.success) { + return { success: false, error: `Installation failed: ${installResult.error}` }; + } + } else { + // Install from PyPI + const packageSpec = `codexlens[${extras.join(',')}]`; + console.log(`[CodexLens] Installing ${packageSpec} from PyPI...`); + const installResult = await uv.install([packageSpec]); + if (!installResult.success) { + return { success: false, error: `Installation failed: ${installResult.error}` }; + } + } + + console.log(`[CodexLens] Semantic dependencies installed successfully (${gpuMode} mode)`); + return { success: true, message: `Installed with ${modeDescription}` }; +} + /** * Install semantic search dependencies with optional GPU acceleration * @param gpuMode - GPU acceleration mode: 'cpu', 'cuda', or 'directml' * @returns Bootstrap result */ async function installSemantic(gpuMode: GpuMode = 'cpu'): Promise { + // Prefer UV if available + if (await isUvAvailable()) { + console.log('[CodexLens] Using UV for semantic installation...'); + return installSemanticWithUv(gpuMode); + } + + // Fall back to pip logic... // First ensure CodexLens is installed const venvStatus = await checkVenvStatus(); if (!venvStatus.ready) { @@ -617,6 +785,13 @@ async function installSemantic(gpuMode: GpuMode = 'cpu'): Promise { + // Prefer UV if available (faster package resolution and installation) + if (await isUvAvailable()) { + console.log('[CodexLens] Using UV for bootstrap...'); + return bootstrapWithUv(); + } + + // Fall back to pip logic... // Ensure data directory exists if (!existsSync(CODEXLENS_DATA_DIR)) { mkdirSync(CODEXLENS_DATA_DIR, { recursive: true }); @@ -1502,6 +1677,9 @@ export { uninstallCodexLens, cancelIndexing, isIndexingInProgress, + // UV-based installation functions + bootstrapWithUv, + installSemanticWithUv, }; // Export Python path for direct spawn usage (e.g., watcher) diff --git a/ccw/src/utils/uv-manager.ts b/ccw/src/utils/uv-manager.ts new file mode 100644 index 00000000..c7e1622b --- /dev/null +++ b/ccw/src/utils/uv-manager.ts @@ -0,0 +1,796 @@ +/** + * UV Package Manager Tool + * Provides unified UV (https://github.com/astral-sh/uv) tool management capabilities + * + * Features: + * - Cross-platform UV binary discovery and installation + * - Virtual environment creation and management + * - Python dependency installation with UV's fast resolver + * - Support for local project installs with extras + */ + +import { execSync, spawn } from 'child_process'; +import { existsSync, mkdirSync } from 'fs'; +import { join, dirname } from 'path'; +import { homedir, platform, arch } from 'os'; +import { EXEC_TIMEOUTS } from './exec-constants.js'; + +/** + * Configuration for UvManager + */ +export interface UvManagerConfig { + /** Path to the virtual environment directory */ + venvPath: string; + /** Python version requirement (e.g., ">=3.10", "3.11") */ + pythonVersion?: string; +} + +/** + * Result of UV operations + */ +export interface UvInstallResult { + /** Whether the operation succeeded */ + success: boolean; + /** Error message if operation failed */ + error?: string; + /** Duration of the operation in milliseconds */ + duration?: number; +} + +/** + * UV binary search locations in priority order + */ +interface UvSearchLocation { + path: string; + description: string; +} + +// Platform-specific constants +const IS_WINDOWS = platform() === 'win32'; +const UV_BINARY_NAME = IS_WINDOWS ? 'uv.exe' : 'uv'; +const VENV_BIN_DIR = IS_WINDOWS ? 'Scripts' : 'bin'; +const PYTHON_EXECUTABLE = IS_WINDOWS ? 'python.exe' : 'python'; + +/** + * Get the path to the UV binary + * Search order: + * 1. CCW_UV_PATH environment variable + * 2. Project vendor/uv/ directory + * 3. User local directories (~/.local/bin, ~/.cargo/bin) + * 4. System PATH + * + * @returns Path to the UV binary + */ +export function getUvBinaryPath(): string { + const searchLocations: UvSearchLocation[] = []; + + // 1. Environment variable (highest priority) + const envPath = process.env.CCW_UV_PATH; + if (envPath) { + searchLocations.push({ path: envPath, description: 'CCW_UV_PATH environment variable' }); + } + + // 2. Project vendor directory + const vendorPaths = [ + join(process.cwd(), 'vendor', 'uv', UV_BINARY_NAME), + join(dirname(process.cwd()), 'vendor', 'uv', UV_BINARY_NAME), + ]; + for (const vendorPath of vendorPaths) { + searchLocations.push({ path: vendorPath, description: 'Project vendor directory' }); + } + + // 3. User local directories + const home = homedir(); + if (IS_WINDOWS) { + // Windows: AppData\Local\uv and .cargo\bin + searchLocations.push( + { path: join(home, 'AppData', 'Local', 'uv', 'bin', UV_BINARY_NAME), description: 'UV AppData' }, + { path: join(home, '.cargo', 'bin', UV_BINARY_NAME), description: 'Cargo bin' }, + { path: join(home, '.local', 'bin', UV_BINARY_NAME), description: 'Local bin' }, + ); + } else { + // Unix: ~/.local/bin and ~/.cargo/bin + searchLocations.push( + { path: join(home, '.local', 'bin', UV_BINARY_NAME), description: 'Local bin' }, + { path: join(home, '.cargo', 'bin', UV_BINARY_NAME), description: 'Cargo bin' }, + ); + } + + // Check each location + for (const location of searchLocations) { + if (existsSync(location.path)) { + return location.path; + } + } + + // 4. Try system PATH using 'which' or 'where' + try { + const cmd = IS_WINDOWS ? 'where uv' : 'which uv'; + const result = execSync(cmd, { encoding: 'utf-8', timeout: EXEC_TIMEOUTS.SYSTEM_INFO, stdio: ['pipe', 'pipe', 'pipe'] }); + const foundPath = result.trim().split('\n')[0]; + if (foundPath && existsSync(foundPath)) { + return foundPath; + } + } catch { + // UV not found in PATH + } + + // Return default path (may not exist) + if (IS_WINDOWS) { + return join(home, 'AppData', 'Local', 'uv', 'bin', UV_BINARY_NAME); + } + return join(home, '.local', 'bin', UV_BINARY_NAME); +} + +/** + * Check if UV is available and working + * @returns True if UV is installed and functional + */ +export async function isUvAvailable(): Promise { + const uvPath = getUvBinaryPath(); + + if (!existsSync(uvPath)) { + return false; + } + + return new Promise((resolve) => { + const child = spawn(uvPath, ['--version'], { + stdio: ['ignore', 'pipe', 'pipe'], + timeout: EXEC_TIMEOUTS.PYTHON_VERSION, + }); + + child.on('close', (code) => { + resolve(code === 0); + }); + + child.on('error', () => { + resolve(false); + }); + }); +} + +/** + * Get UV version string + * @returns UV version or null if not available + */ +export async function getUvVersion(): Promise { + const uvPath = getUvBinaryPath(); + + if (!existsSync(uvPath)) { + return null; + } + + return new Promise((resolve) => { + const child = spawn(uvPath, ['--version'], { + stdio: ['ignore', 'pipe', 'pipe'], + timeout: EXEC_TIMEOUTS.PYTHON_VERSION, + }); + + let stdout = ''; + + child.stdout.on('data', (data) => { + stdout += data.toString(); + }); + + child.on('close', (code) => { + if (code === 0) { + // Parse "uv 0.4.0" -> "0.4.0" + const match = stdout.match(/uv\s+(\S+)/); + resolve(match ? match[1] : stdout.trim()); + } else { + resolve(null); + } + }); + + child.on('error', () => { + resolve(null); + }); + }); +} + +/** + * Download and install UV using the official installation script + * @returns True if installation succeeded + */ +export async function ensureUvInstalled(): Promise { + // Check if already installed + if (await isUvAvailable()) { + return true; + } + + console.log('[UV] Installing UV package manager...'); + + return new Promise((resolve) => { + let child: ReturnType; + + if (IS_WINDOWS) { + // Windows: Use PowerShell to run the install script + const installCmd = 'irm https://astral.sh/uv/install.ps1 | iex'; + child = spawn('powershell', ['-ExecutionPolicy', 'ByPass', '-Command', installCmd], { + stdio: 'inherit', + timeout: EXEC_TIMEOUTS.PACKAGE_INSTALL, + }); + } else { + // Unix: Use curl and sh + const installCmd = 'curl -LsSf https://astral.sh/uv/install.sh | sh'; + child = spawn('sh', ['-c', installCmd], { + stdio: 'inherit', + timeout: EXEC_TIMEOUTS.PACKAGE_INSTALL, + }); + } + + child.on('close', (code) => { + if (code === 0) { + console.log('[UV] UV installed successfully'); + resolve(true); + } else { + console.error(`[UV] Installation failed with code ${code}`); + resolve(false); + } + }); + + child.on('error', (err) => { + console.error(`[UV] Installation failed: ${err.message}`); + resolve(false); + }); + }); +} + +/** + * UvManager class for virtual environment and package management + */ +export class UvManager { + private readonly venvPath: string; + private readonly pythonVersion?: string; + + /** + * Create a new UvManager instance + * @param config - Configuration options + */ + constructor(config: UvManagerConfig) { + this.venvPath = config.venvPath; + this.pythonVersion = config.pythonVersion; + } + + /** + * Get the path to the Python executable inside the virtual environment + * @returns Path to the Python executable + */ + getVenvPython(): string { + return join(this.venvPath, VENV_BIN_DIR, PYTHON_EXECUTABLE); + } + + /** + * Get the path to pip inside the virtual environment + * @returns Path to the pip executable + */ + getVenvPip(): string { + const pipName = IS_WINDOWS ? 'pip.exe' : 'pip'; + return join(this.venvPath, VENV_BIN_DIR, pipName); + } + + /** + * Check if the virtual environment exists and is valid + * @returns True if the venv exists and has a working Python + */ + isVenvValid(): boolean { + const pythonPath = this.getVenvPython(); + return existsSync(pythonPath); + } + + /** + * Create a virtual environment using UV + * @returns Installation result + */ + async createVenv(): Promise { + const startTime = Date.now(); + + // Ensure UV is available + if (!(await isUvAvailable())) { + const installed = await ensureUvInstalled(); + if (!installed) { + return { success: false, error: 'Failed to install UV' }; + } + } + + const uvPath = getUvBinaryPath(); + + // Ensure parent directory exists + const parentDir = dirname(this.venvPath); + if (!existsSync(parentDir)) { + mkdirSync(parentDir, { recursive: true }); + } + + return new Promise((resolve) => { + const args = ['venv', this.venvPath]; + + // Add Python version constraint if specified + if (this.pythonVersion) { + args.push('--python', this.pythonVersion); + } + + console.log(`[UV] Creating virtual environment at ${this.venvPath}`); + if (this.pythonVersion) { + console.log(`[UV] Python version: ${this.pythonVersion}`); + } + + const child = spawn(uvPath, args, { + stdio: ['ignore', 'pipe', 'pipe'], + timeout: EXEC_TIMEOUTS.PROCESS_SPAWN, + }); + + let stderr = ''; + + child.stdout.on('data', (data) => { + const line = data.toString().trim(); + if (line) { + console.log(`[UV] ${line}`); + } + }); + + child.stderr.on('data', (data) => { + stderr += data.toString(); + const line = data.toString().trim(); + if (line) { + console.log(`[UV] ${line}`); + } + }); + + child.on('close', (code) => { + const duration = Date.now() - startTime; + if (code === 0) { + console.log(`[UV] Virtual environment created successfully (${duration}ms)`); + resolve({ success: true, duration }); + } else { + resolve({ success: false, error: stderr || `Process exited with code ${code}`, duration }); + } + }); + + child.on('error', (err) => { + const duration = Date.now() - startTime; + resolve({ success: false, error: `Failed to spawn UV: ${err.message}`, duration }); + }); + }); + } + + /** + * Install packages from a local project with optional extras + * Uses `uv pip install -e` for editable installs + * @param projectPath - Path to the project directory (must contain pyproject.toml or setup.py) + * @param extras - Optional array of extras to install (e.g., ['semantic', 'dev']) + * @returns Installation result + */ + async installFromProject(projectPath: string, extras?: string[]): Promise { + const startTime = Date.now(); + + // Ensure UV is available + if (!(await isUvAvailable())) { + return { success: false, error: 'UV is not available' }; + } + + // Ensure venv exists + if (!this.isVenvValid()) { + return { success: false, error: 'Virtual environment does not exist. Call createVenv() first.' }; + } + + const uvPath = getUvBinaryPath(); + + // Build the install specifier + let installSpec = projectPath; + if (extras && extras.length > 0) { + installSpec = `${projectPath}[${extras.join(',')}]`; + } + + return new Promise((resolve) => { + const args = ['pip', 'install', '-e', installSpec, '--python', this.getVenvPython()]; + + console.log(`[UV] Installing from project: ${installSpec}`); + + const child = spawn(uvPath, args, { + stdio: ['ignore', 'pipe', 'pipe'], + timeout: EXEC_TIMEOUTS.PACKAGE_INSTALL, + cwd: projectPath, + }); + + let stderr = ''; + + child.stdout.on('data', (data) => { + const line = data.toString().trim(); + if (line) { + console.log(`[UV] ${line}`); + } + }); + + child.stderr.on('data', (data) => { + stderr += data.toString(); + const line = data.toString().trim(); + if (line && !line.startsWith('Resolved') && !line.startsWith('Prepared') && !line.startsWith('Installed')) { + // Only log non-progress lines to stderr + console.log(`[UV] ${line}`); + } + }); + + child.on('close', (code) => { + const duration = Date.now() - startTime; + if (code === 0) { + console.log(`[UV] Project installation successful (${duration}ms)`); + resolve({ success: true, duration }); + } else { + resolve({ success: false, error: stderr || `Process exited with code ${code}`, duration }); + } + }); + + child.on('error', (err) => { + const duration = Date.now() - startTime; + resolve({ success: false, error: `Failed to spawn UV: ${err.message}`, duration }); + }); + }); + } + + /** + * Install a list of packages + * @param packages - Array of package specifiers (e.g., ['numpy>=1.24', 'requests']) + * @returns Installation result + */ + async install(packages: string[]): Promise { + const startTime = Date.now(); + + if (packages.length === 0) { + return { success: true, duration: 0 }; + } + + // Ensure UV is available + if (!(await isUvAvailable())) { + return { success: false, error: 'UV is not available' }; + } + + // Ensure venv exists + if (!this.isVenvValid()) { + return { success: false, error: 'Virtual environment does not exist. Call createVenv() first.' }; + } + + const uvPath = getUvBinaryPath(); + + return new Promise((resolve) => { + const args = ['pip', 'install', ...packages, '--python', this.getVenvPython()]; + + console.log(`[UV] Installing packages: ${packages.join(', ')}`); + + const child = spawn(uvPath, args, { + stdio: ['ignore', 'pipe', 'pipe'], + timeout: EXEC_TIMEOUTS.PACKAGE_INSTALL, + }); + + let stderr = ''; + + child.stdout.on('data', (data) => { + const line = data.toString().trim(); + if (line) { + console.log(`[UV] ${line}`); + } + }); + + child.stderr.on('data', (data) => { + stderr += data.toString(); + }); + + child.on('close', (code) => { + const duration = Date.now() - startTime; + if (code === 0) { + console.log(`[UV] Package installation successful (${duration}ms)`); + resolve({ success: true, duration }); + } else { + resolve({ success: false, error: stderr || `Process exited with code ${code}`, duration }); + } + }); + + child.on('error', (err) => { + const duration = Date.now() - startTime; + resolve({ success: false, error: `Failed to spawn UV: ${err.message}`, duration }); + }); + }); + } + + /** + * Uninstall packages + * @param packages - Array of package names to uninstall + * @returns Uninstall result + */ + async uninstall(packages: string[]): Promise { + const startTime = Date.now(); + + if (packages.length === 0) { + return { success: true, duration: 0 }; + } + + // Ensure UV is available + if (!(await isUvAvailable())) { + return { success: false, error: 'UV is not available' }; + } + + // Ensure venv exists + if (!this.isVenvValid()) { + return { success: false, error: 'Virtual environment does not exist.' }; + } + + const uvPath = getUvBinaryPath(); + + return new Promise((resolve) => { + const args = ['pip', 'uninstall', ...packages, '--python', this.getVenvPython()]; + + console.log(`[UV] Uninstalling packages: ${packages.join(', ')}`); + + const child = spawn(uvPath, args, { + stdio: ['ignore', 'pipe', 'pipe'], + timeout: EXEC_TIMEOUTS.PACKAGE_INSTALL, + }); + + let stderr = ''; + + child.stdout.on('data', (data) => { + const line = data.toString().trim(); + if (line) { + console.log(`[UV] ${line}`); + } + }); + + child.stderr.on('data', (data) => { + stderr += data.toString(); + }); + + child.on('close', (code) => { + const duration = Date.now() - startTime; + if (code === 0) { + console.log(`[UV] Package uninstallation successful (${duration}ms)`); + resolve({ success: true, duration }); + } else { + resolve({ success: false, error: stderr || `Process exited with code ${code}`, duration }); + } + }); + + child.on('error', (err) => { + const duration = Date.now() - startTime; + resolve({ success: false, error: `Failed to spawn UV: ${err.message}`, duration }); + }); + }); + } + + /** + * Sync dependencies from a requirements file or pyproject.toml + * Uses `uv pip sync` for deterministic installs + * @param requirementsPath - Path to requirements.txt or pyproject.toml + * @returns Sync result + */ + async sync(requirementsPath: string): Promise { + const startTime = Date.now(); + + // Ensure UV is available + if (!(await isUvAvailable())) { + return { success: false, error: 'UV is not available' }; + } + + // Ensure venv exists + if (!this.isVenvValid()) { + return { success: false, error: 'Virtual environment does not exist. Call createVenv() first.' }; + } + + const uvPath = getUvBinaryPath(); + + return new Promise((resolve) => { + const args = ['pip', 'sync', requirementsPath, '--python', this.getVenvPython()]; + + console.log(`[UV] Syncing dependencies from: ${requirementsPath}`); + + const child = spawn(uvPath, args, { + stdio: ['ignore', 'pipe', 'pipe'], + timeout: EXEC_TIMEOUTS.PACKAGE_INSTALL, + }); + + let stderr = ''; + + child.stdout.on('data', (data) => { + const line = data.toString().trim(); + if (line) { + console.log(`[UV] ${line}`); + } + }); + + child.stderr.on('data', (data) => { + stderr += data.toString(); + }); + + child.on('close', (code) => { + const duration = Date.now() - startTime; + if (code === 0) { + console.log(`[UV] Sync successful (${duration}ms)`); + resolve({ success: true, duration }); + } else { + resolve({ success: false, error: stderr || `Process exited with code ${code}`, duration }); + } + }); + + child.on('error', (err) => { + const duration = Date.now() - startTime; + resolve({ success: false, error: `Failed to spawn UV: ${err.message}`, duration }); + }); + }); + } + + /** + * List installed packages in the virtual environment + * @returns List of installed packages or null on error + */ + async list(): Promise<{ name: string; version: string }[] | null> { + // Ensure UV is available + if (!(await isUvAvailable())) { + return null; + } + + // Ensure venv exists + if (!this.isVenvValid()) { + return null; + } + + const uvPath = getUvBinaryPath(); + + return new Promise((resolve) => { + const args = ['pip', 'list', '--format', 'json', '--python', this.getVenvPython()]; + + const child = spawn(uvPath, args, { + stdio: ['ignore', 'pipe', 'pipe'], + timeout: EXEC_TIMEOUTS.PROCESS_SPAWN, + }); + + let stdout = ''; + + child.stdout.on('data', (data) => { + stdout += data.toString(); + }); + + child.on('close', (code) => { + if (code === 0) { + try { + const packages = JSON.parse(stdout); + resolve(packages); + } catch { + resolve(null); + } + } else { + resolve(null); + } + }); + + child.on('error', () => { + resolve(null); + }); + }); + } + + /** + * Check if a specific package is installed + * @param packageName - Name of the package to check + * @returns True if the package is installed + */ + async isPackageInstalled(packageName: string): Promise { + const packages = await this.list(); + if (!packages) { + return false; + } + + const normalizedName = packageName.toLowerCase().replace(/-/g, '_'); + return packages.some( + (pkg) => pkg.name.toLowerCase().replace(/-/g, '_') === normalizedName + ); + } + + /** + * Run a Python command in the virtual environment + * @param args - Arguments to pass to Python + * @param options - Spawn options + * @returns Result with stdout/stderr + */ + async runPython( + args: string[], + options: { timeout?: number; cwd?: string } = {} + ): Promise<{ success: boolean; stdout: string; stderr: string }> { + const pythonPath = this.getVenvPython(); + + if (!existsSync(pythonPath)) { + return { success: false, stdout: '', stderr: 'Virtual environment does not exist' }; + } + + return new Promise((resolve) => { + const child = spawn(pythonPath, args, { + stdio: ['ignore', 'pipe', 'pipe'], + timeout: options.timeout ?? EXEC_TIMEOUTS.PROCESS_SPAWN, + cwd: options.cwd, + }); + + let stdout = ''; + let stderr = ''; + + child.stdout.on('data', (data) => { + stdout += data.toString(); + }); + + child.stderr.on('data', (data) => { + stderr += data.toString(); + }); + + child.on('close', (code) => { + resolve({ success: code === 0, stdout: stdout.trim(), stderr: stderr.trim() }); + }); + + child.on('error', (err) => { + resolve({ success: false, stdout: '', stderr: err.message }); + }); + }); + } + + /** + * Get Python version in the virtual environment + * @returns Python version string or null + */ + async getPythonVersion(): Promise { + const result = await this.runPython(['--version']); + if (result.success) { + const match = result.stdout.match(/Python\s+(\S+)/); + return match ? match[1] : null; + } + return null; + } + + /** + * Delete the virtual environment + * @returns True if deletion succeeded + */ + async deleteVenv(): Promise { + if (!existsSync(this.venvPath)) { + return true; + } + + try { + const fs = await import('fs'); + fs.rmSync(this.venvPath, { recursive: true, force: true }); + console.log(`[UV] Virtual environment deleted: ${this.venvPath}`); + return true; + } catch (err) { + console.error(`[UV] Failed to delete venv: ${(err as Error).message}`); + return false; + } + } +} + +/** + * Create a UvManager with default settings for CodexLens + * @param dataDir - Base data directory (defaults to ~/.codexlens) + * @returns Configured UvManager instance + */ +export function createCodexLensUvManager(dataDir?: string): UvManager { + const baseDir = dataDir ?? join(homedir(), '.codexlens'); + return new UvManager({ + venvPath: join(baseDir, 'venv'), + pythonVersion: '>=3.10,<3.13', // onnxruntime compatibility + }); +} + +/** + * Quick bootstrap function: ensure UV is installed and create a venv + * @param venvPath - Path to the virtual environment + * @param pythonVersion - Optional Python version constraint + * @returns Installation result + */ +export async function bootstrapUvVenv( + venvPath: string, + pythonVersion?: string +): Promise { + // Ensure UV is installed first + const uvInstalled = await ensureUvInstalled(); + if (!uvInstalled) { + return { success: false, error: 'Failed to install UV' }; + } + + // Create the venv + const manager = new UvManager({ venvPath, pythonVersion }); + return manager.createVenv(); +} diff --git a/ccw/tests/codex-lens-uv-install.test.ts b/ccw/tests/codex-lens-uv-install.test.ts new file mode 100644 index 00000000..6904e4e9 --- /dev/null +++ b/ccw/tests/codex-lens-uv-install.test.ts @@ -0,0 +1,372 @@ +/** + * Integration tests for CodexLens UV installation functionality. + * + * Notes: + * - Targets the runtime implementation shipped in `ccw/dist`. + * - Tests real package installation (fastembed, hnswlib, onnxruntime, ccw-litellm, codex-lens). + * - Verifies Python import success for installed packages. + * - Tests UV's dependency conflict auto-resolution capability. + * - Uses temporary directories with cleanup after tests. + */ + +import { after, before, describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { existsSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; + +const uvManagerUrl = new URL('../dist/utils/uv-manager.js', import.meta.url); +uvManagerUrl.searchParams.set('t', String(Date.now())); + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +let mod: any; + +// Test venv path with unique timestamp +const TEST_VENV_PATH = join(tmpdir(), `codexlens-install-test-${Date.now()}`); + +// Track UV availability for conditional tests +let uvAvailable = false; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +let manager: any; + +describe('CodexLens UV Installation Tests', async () => { + mod = await import(uvManagerUrl.href); + + before(async () => { + uvAvailable = await mod.isUvAvailable(); + if (!uvAvailable) { + console.log('[Test] UV not available, attempting to install...'); + uvAvailable = await mod.ensureUvInstalled(); + } + + if (uvAvailable) { + manager = new mod.UvManager({ + venvPath: TEST_VENV_PATH, + pythonVersion: '>=3.10,<3.13', // onnxruntime compatibility range + }); + console.log(`[Test] Created UvManager with venv path: ${TEST_VENV_PATH}`); + } + }); + + after(() => { + // Clean up test venv + if (existsSync(TEST_VENV_PATH)) { + console.log(`[Test] Cleaning up test venv: ${TEST_VENV_PATH}`); + try { + rmSync(TEST_VENV_PATH, { recursive: true, force: true }); + } catch (err) { + console.log(`[Test] Failed to remove venv: ${(err as Error).message}`); + } + } + }); + + describe('Virtual Environment Setup', () => { + it('should create venv with correct Python version', async () => { + if (!uvAvailable) { + console.log('[Test] Skipping - UV not available'); + return; + } + + const result = await manager.createVenv(); + console.log(`[Test] Create venv result:`, result); + assert.ok(result.success, `Venv creation failed: ${result.error}`); + + // Verify Python version + const version = await manager.getPythonVersion(); + console.log(`[Test] Python version: ${version}`); + const match = version?.match(/3\.(\d+)/); + assert.ok(match, 'Should be Python 3.x'); + const minor = parseInt(match[1]); + assert.ok(minor >= 10 && minor < 13, `Python version should be 3.10-3.12, got 3.${minor}`); + }); + }); + + describe('Semantic Search Dependencies (fastembed)', () => { + it('should install fastembed and hnswlib', async () => { + if (!uvAvailable || !manager?.isVenvValid()) { + console.log('[Test] Skipping - venv not ready'); + return; + } + + console.log('[Test] Installing fastembed and hnswlib...'); + const startTime = Date.now(); + + const result = await manager.install([ + 'numpy>=1.24', + 'fastembed>=0.5', + 'hnswlib>=0.8.0', + ]); + + const duration = Date.now() - startTime; + console.log(`[Test] Installation result:`, result); + console.log(`[Test] Installation took ${duration}ms`); + + assert.ok(result.success, `fastembed installation failed: ${result.error}`); + }); + + it('should verify fastembed is importable', async () => { + if (!uvAvailable || !manager?.isVenvValid()) { + console.log('[Test] Skipping - venv not ready'); + return; + } + + const result = await manager.runPython([ + '-c', + 'import fastembed; print(f"fastembed version: {fastembed.__version__}")', + ]); + + console.log(`[Test] fastembed import:`, result); + assert.ok(result.success, `fastembed import failed: ${result.stderr}`); + assert.ok(result.stdout.includes('fastembed version'), 'Should print fastembed version'); + }); + + it('should verify hnswlib is importable', async () => { + if (!uvAvailable || !manager?.isVenvValid()) { + console.log('[Test] Skipping - venv not ready'); + return; + } + + const result = await manager.runPython(['-c', 'import hnswlib; print("hnswlib imported successfully")']); + + console.log(`[Test] hnswlib import:`, result); + assert.ok(result.success, `hnswlib import failed: ${result.stderr}`); + }); + }); + + describe('ONNX Runtime Installation', () => { + it('should install onnxruntime (CPU)', async () => { + if (!uvAvailable || !manager?.isVenvValid()) { + console.log('[Test] Skipping - venv not ready'); + return; + } + + console.log('[Test] Installing onnxruntime...'); + const result = await manager.install(['onnxruntime>=1.18.0']); + + console.log(`[Test] onnxruntime installation:`, result); + assert.ok(result.success, `onnxruntime installation failed: ${result.error}`); + }); + + it('should verify onnxruntime providers', async () => { + if (!uvAvailable || !manager?.isVenvValid()) { + console.log('[Test] Skipping - venv not ready'); + return; + } + + const result = await manager.runPython([ + '-c', + 'import onnxruntime; print("Providers:", onnxruntime.get_available_providers())', + ]); + + console.log(`[Test] onnxruntime providers:`, result); + assert.ok(result.success, `onnxruntime import failed: ${result.stderr}`); + assert.ok(result.stdout.includes('CPUExecutionProvider'), 'Should have CPU provider'); + }); + }); + + describe('ccw-litellm Installation', () => { + it('should install ccw-litellm from local path', async () => { + if (!uvAvailable || !manager?.isVenvValid()) { + console.log('[Test] Skipping - venv not ready'); + return; + } + + // Find local ccw-litellm package + const possiblePaths = [join(process.cwd(), 'ccw-litellm'), 'D:\\Claude_dms3\\ccw-litellm']; + + let localPath: string | null = null; + for (const p of possiblePaths) { + if (existsSync(join(p, 'pyproject.toml'))) { + localPath = p; + break; + } + } + + if (!localPath) { + console.log('[Test] ccw-litellm local path not found, installing from PyPI...'); + const result = await manager.install(['ccw-litellm']); + console.log(`[Test] PyPI installation:`, result); + // PyPI may not have it published, skip + return; + } + + console.log(`[Test] Installing ccw-litellm from: ${localPath}`); + const result = await manager.installFromProject(localPath); + + console.log(`[Test] ccw-litellm installation:`, result); + assert.ok(result.success, `ccw-litellm installation failed: ${result.error}`); + }); + + it('should verify ccw-litellm is importable', async () => { + if (!uvAvailable || !manager?.isVenvValid()) { + console.log('[Test] Skipping - venv not ready'); + return; + } + + const result = await manager.runPython([ + '-c', + 'import ccw_litellm; print(f"ccw-litellm version: {ccw_litellm.__version__}")', + ]); + + console.log(`[Test] ccw-litellm import:`, result); + // If installation failed (PyPI doesn't have it), skip validation + if (!result.success && result.stderr.includes('No module named')) { + console.log('[Test] ccw-litellm not installed, skipping import test'); + return; + } + + assert.ok(result.success, `ccw-litellm import failed: ${result.stderr}`); + }); + }); + + describe('Full codex-lens Installation', () => { + it('should install codex-lens with semantic extras from local path', async () => { + if (!uvAvailable || !manager?.isVenvValid()) { + console.log('[Test] Skipping - venv not ready'); + return; + } + + // Find local codex-lens package + const possiblePaths = [join(process.cwd(), 'codex-lens'), 'D:\\Claude_dms3\\codex-lens']; + + let localPath: string | null = null; + for (const p of possiblePaths) { + if (existsSync(join(p, 'pyproject.toml'))) { + localPath = p; + break; + } + } + + if (!localPath) { + console.log('[Test] codex-lens local path not found, skipping'); + return; + } + + console.log(`[Test] Installing codex-lens[semantic] from: ${localPath}`); + const startTime = Date.now(); + + const result = await manager.installFromProject(localPath, ['semantic']); + + const duration = Date.now() - startTime; + console.log(`[Test] codex-lens installation:`, result); + console.log(`[Test] Installation took ${duration}ms`); + + assert.ok(result.success, `codex-lens installation failed: ${result.error}`); + }); + + it('should verify codex-lens CLI is available', async () => { + if (!uvAvailable || !manager?.isVenvValid()) { + console.log('[Test] Skipping - venv not ready'); + return; + } + + const result = await manager.runPython(['-m', 'codexlens', '--help']); + + console.log(`[Test] codexlens CLI help output length: ${result.stdout.length}`); + // CLI may fail due to dependency issues, log but don't force failure + if (!result.success) { + console.log(`[Test] codexlens CLI failed: ${result.stderr}`); + } + }); + }); + + describe('Dependency Conflict Resolution', () => { + it('should handle onnxruntime version conflicts automatically', async () => { + if (!uvAvailable || !manager?.isVenvValid()) { + console.log('[Test] Skipping - venv not ready'); + return; + } + + // UV should auto-resolve conflicts between fastembed and onnxruntime + // Install onnxruntime first, then fastembed, verify no errors + console.log('[Test] Testing conflict resolution...'); + + // Check current onnxruntime version + const result = await manager.runPython(['-c', 'import onnxruntime; print(f"onnxruntime: {onnxruntime.__version__}")']); + + console.log(`[Test] Current onnxruntime:`, result.stdout.trim()); + + // Reinstall fastembed, UV should handle dependencies + const installResult = await manager.install(['fastembed>=0.5']); + console.log(`[Test] Reinstall fastembed:`, installResult); + + // Check onnxruntime again + const result2 = await manager.runPython(['-c', 'import onnxruntime; print(f"onnxruntime: {onnxruntime.__version__}")']); + + console.log(`[Test] After reinstall onnxruntime:`, result2.stdout.trim()); + assert.ok(result2.success, 'onnxruntime should still be importable after fastembed reinstall'); + }); + }); + + describe('Package List Verification', () => { + it('should list all installed packages', async () => { + if (!uvAvailable || !manager?.isVenvValid()) { + console.log('[Test] Skipping - venv not ready'); + return; + } + + const packages = await manager.list(); + console.log(`[Test] Total installed packages: ${packages?.length ?? 0}`); + + if (packages !== null) { + assert.ok(Array.isArray(packages), 'list() should return array'); + + // Check for expected packages + const packageNames = packages.map((p: { name: string }) => p.name.toLowerCase().replace(/-/g, '_')); + console.log(`[Test] Package names: ${packageNames.slice(0, 10).join(', ')}...`); + + // Verify core packages are present + const hasNumpy = packageNames.includes('numpy'); + const hasFastembed = packageNames.includes('fastembed'); + const hasHnswlib = packageNames.includes('hnswlib'); + + console.log(`[Test] numpy: ${hasNumpy}, fastembed: ${hasFastembed}, hnswlib: ${hasHnswlib}`); + assert.ok(hasNumpy, 'numpy should be installed'); + assert.ok(hasFastembed, 'fastembed should be installed'); + assert.ok(hasHnswlib, 'hnswlib should be installed'); + } + }); + + it('should check individual package installation status', async () => { + if (!uvAvailable || !manager?.isVenvValid()) { + console.log('[Test] Skipping - venv not ready'); + return; + } + + const numpyInstalled = await manager.isPackageInstalled('numpy'); + const fastembedInstalled = await manager.isPackageInstalled('fastembed'); + const nonexistentInstalled = await manager.isPackageInstalled('this-package-does-not-exist-12345'); + + console.log(`[Test] numpy installed: ${numpyInstalled}`); + console.log(`[Test] fastembed installed: ${fastembedInstalled}`); + console.log(`[Test] nonexistent installed: ${nonexistentInstalled}`); + + assert.ok(numpyInstalled, 'numpy should be installed'); + assert.ok(fastembedInstalled, 'fastembed should be installed'); + assert.equal(nonexistentInstalled, false, 'nonexistent package should not be installed'); + }); + }); + + describe('CodexLens UV Manager Factory', () => { + it('should create CodexLens UV manager with default settings', () => { + const codexLensManager = mod.createCodexLensUvManager(); + console.log(`[Test] CodexLens manager created`); + assert.ok(codexLensManager !== null, 'createCodexLensUvManager should return manager'); + assert.ok(codexLensManager.getVenvPython, 'Manager should have getVenvPython method'); + + // Verify Python path is in default location + const pythonPath = codexLensManager.getVenvPython(); + console.log(`[Test] Default CodexLens Python path: ${pythonPath}`); + assert.ok(pythonPath.includes('.codexlens'), 'Python path should be in .codexlens directory'); + }); + + it('should create CodexLens UV manager with custom data dir', () => { + const customDir = join(tmpdir(), 'custom-codexlens-test'); + const codexLensManager = mod.createCodexLensUvManager(customDir); + const pythonPath = codexLensManager.getVenvPython(); + console.log(`[Test] Custom CodexLens manager Python path: ${pythonPath}`); + assert.ok(pythonPath.includes(customDir), 'Python path should use custom dir'); + }); + }); +}); diff --git a/ccw/tests/uv-manager.test.ts b/ccw/tests/uv-manager.test.ts new file mode 100644 index 00000000..522e911d --- /dev/null +++ b/ccw/tests/uv-manager.test.ts @@ -0,0 +1,414 @@ +/** + * Unit tests for uv-manager utility module. + * + * Notes: + * - Targets the runtime implementation shipped in `ccw/dist`. + * - Tests UV binary detection, installation, and virtual environment management. + * - Gracefully handles cases where UV is not installed. + */ + +import { after, before, describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { existsSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; + +const uvManagerUrl = new URL('../dist/utils/uv-manager.js', import.meta.url); +uvManagerUrl.searchParams.set('t', String(Date.now())); + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +let mod: any; + +// Test venv path with unique timestamp +const TEST_VENV_PATH = join(tmpdir(), `uv-test-venv-${Date.now()}`); + +// Track UV availability for conditional tests +let uvAvailable = false; + +describe('UV Manager Tests', async () => { + mod = await import(uvManagerUrl.href); + + // Cleanup after all tests + after(() => { + if (existsSync(TEST_VENV_PATH)) { + console.log(`[Cleanup] Removing test venv: ${TEST_VENV_PATH}`); + try { + rmSync(TEST_VENV_PATH, { recursive: true, force: true }); + } catch (err) { + console.log(`[Cleanup] Failed to remove venv: ${(err as Error).message}`); + } + } + }); + + describe('UV Binary Detection', () => { + it('should check UV availability', async () => { + uvAvailable = await mod.isUvAvailable(); + console.log(`[Test] UV available: ${uvAvailable}`); + assert.equal(typeof uvAvailable, 'boolean', 'isUvAvailable should return boolean'); + }); + + it('should get UV version when available', async () => { + if (uvAvailable) { + const version = await mod.getUvVersion(); + console.log(`[Test] UV version: ${version}`); + assert.ok(version !== null, 'getUvVersion should return version string'); + assert.ok(version.length > 0, 'Version string should not be empty'); + } else { + console.log('[Test] UV not installed, skipping version test'); + const version = await mod.getUvVersion(); + assert.equal(version, null, 'getUvVersion should return null when UV not available'); + } + }); + + it('should get UV binary path', async () => { + const path = mod.getUvBinaryPath(); + console.log(`[Test] UV path: ${path}`); + assert.equal(typeof path, 'string', 'getUvBinaryPath should return string'); + assert.ok(path.length > 0, 'Path should not be empty'); + + if (uvAvailable) { + assert.ok(existsSync(path), 'UV binary should exist when UV is available'); + } + }); + }); + + describe('UV Installation', () => { + it('should ensure UV is installed', async () => { + const installed = await mod.ensureUvInstalled(); + console.log(`[Test] UV ensured: ${installed}`); + assert.equal(typeof installed, 'boolean', 'ensureUvInstalled should return boolean'); + + // Update availability after potential installation + if (installed) { + uvAvailable = await mod.isUvAvailable(); + assert.ok(uvAvailable, 'UV should be available after ensureUvInstalled returns true'); + } + }); + }); + + describe('UvManager Class', () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let manager: any; + + before(async () => { + // Ensure UV is available for class tests + if (!uvAvailable) { + console.log('[Test] UV not available, attempting installation...'); + await mod.ensureUvInstalled(); + uvAvailable = await mod.isUvAvailable(); + } + + manager = new mod.UvManager({ + venvPath: TEST_VENV_PATH, + pythonVersion: '>=3.10', + }); + console.log(`[Test] Created UvManager with venv path: ${TEST_VENV_PATH}`); + }); + + it('should get venv Python path', () => { + const pythonPath = manager.getVenvPython(); + console.log(`[Test] Venv Python path: ${pythonPath}`); + assert.equal(typeof pythonPath, 'string', 'getVenvPython should return string'); + assert.ok(pythonPath.includes(TEST_VENV_PATH), 'Python path should be inside venv'); + }); + + it('should get venv pip path', () => { + const pipPath = manager.getVenvPip(); + console.log(`[Test] Venv pip path: ${pipPath}`); + assert.equal(typeof pipPath, 'string', 'getVenvPip should return string'); + assert.ok(pipPath.includes(TEST_VENV_PATH), 'Pip path should be inside venv'); + }); + + it('should report venv as invalid before creation', () => { + const valid = manager.isVenvValid(); + console.log(`[Test] Venv valid (before create): ${valid}`); + assert.equal(valid, false, 'Venv should not be valid before creation'); + }); + + it('should create virtual environment', async () => { + if (!uvAvailable) { + console.log('[Test] Skipping venv creation - UV not available'); + return; + } + + const result = await manager.createVenv(); + console.log(`[Test] Create venv result:`, result); + + if (result.success) { + assert.ok(existsSync(TEST_VENV_PATH), 'Venv directory should exist'); + assert.ok(result.duration !== undefined, 'Duration should be reported'); + console.log(`[Test] Venv created in ${result.duration}ms`); + } else { + // May fail if Python is not installed + console.log(`[Test] Venv creation failed: ${result.error}`); + assert.equal(typeof result.error, 'string', 'Error should be a string'); + } + }); + + it('should check if venv is valid after creation', () => { + const valid = manager.isVenvValid(); + console.log(`[Test] Venv valid (after create): ${valid}`); + assert.equal(typeof valid, 'boolean', 'isVenvValid should return boolean'); + }); + + it('should get Python version in venv', async () => { + if (!manager.isVenvValid()) { + console.log('[Test] Skipping Python version check - venv not valid'); + return; + } + + const version = await manager.getPythonVersion(); + console.log(`[Test] Python version: ${version}`); + assert.ok(version !== null, 'getPythonVersion should return version'); + assert.ok(version.startsWith('3.'), 'Should be Python 3.x'); + }); + + it('should list installed packages', async () => { + if (!manager.isVenvValid()) { + console.log('[Test] Skipping package list - venv not valid'); + return; + } + + const packages = await manager.list(); + console.log(`[Test] Installed packages count: ${packages?.length ?? 0}`); + + if (packages !== null) { + assert.ok(Array.isArray(packages), 'list() should return array'); + // UV creates minimal venvs without pip by default + console.log(`[Test] Packages in venv: ${packages.map((p: { name: string }) => p.name).join(', ') || '(empty)'}`); + } + }); + + it('should check if package is installed', async () => { + if (!manager.isVenvValid()) { + console.log('[Test] Skipping package check - venv not valid'); + return; + } + + // First install a package, then check if it's installed + const installResult = await manager.install(['six']); + if (installResult.success) { + const installed = await manager.isPackageInstalled('six'); + console.log(`[Test] six installed: ${installed}`); + assert.ok(installed, 'six should be installed after install'); + + // Clean up + await manager.uninstall(['six']); + } else { + console.log('[Test] Could not install test package, skipping check'); + } + }); + + it('should install a simple package', async () => { + if (!manager.isVenvValid()) { + console.log('[Test] Skipping package install - venv not valid'); + return; + } + + // Install a small, fast-installing package + const result = await manager.install(['pip-install-test']); + console.log(`[Test] Install result:`, result); + assert.equal(typeof result.success, 'boolean', 'success should be boolean'); + + if (result.success) { + console.log(`[Test] Package installed in ${result.duration}ms`); + } else { + console.log(`[Test] Package install failed: ${result.error}`); + } + }); + + it('should uninstall a package', async () => { + if (!manager.isVenvValid()) { + console.log('[Test] Skipping uninstall - venv not valid'); + return; + } + + const result = await manager.uninstall(['pip-install-test']); + console.log(`[Test] Uninstall result:`, result); + assert.equal(typeof result.success, 'boolean', 'success should be boolean'); + + if (result.success) { + console.log(`[Test] Package uninstalled in ${result.duration}ms`); + } else { + console.log(`[Test] Package uninstall failed: ${result.error}`); + } + }); + + it('should handle empty package list for install', async () => { + const result = await manager.install([]); + console.log(`[Test] Empty install result:`, result); + assert.ok(result.success, 'Empty install should succeed'); + assert.equal(result.duration, 0, 'Empty install should have 0 duration'); + }); + + it('should handle empty package list for uninstall', async () => { + const result = await manager.uninstall([]); + console.log(`[Test] Empty uninstall result:`, result); + assert.ok(result.success, 'Empty uninstall should succeed'); + assert.equal(result.duration, 0, 'Empty uninstall should have 0 duration'); + }); + + it('should run Python command in venv', async () => { + if (!manager.isVenvValid()) { + console.log('[Test] Skipping Python command - venv not valid'); + return; + } + + const result = await manager.runPython(['-c', 'print("hello from venv")']); + console.log(`[Test] Run Python result:`, result); + + if (result.success) { + assert.ok(result.stdout.includes('hello from venv'), 'Output should contain expected text'); + } else { + console.log(`[Test] Python command failed: ${result.stderr}`); + } + }); + + it('should delete virtual environment', async () => { + if (!manager.isVenvValid()) { + console.log('[Test] Skipping delete - venv not valid'); + return; + } + + const result = await manager.deleteVenv(); + console.log(`[Test] Delete venv result: ${result}`); + + if (result) { + assert.ok(!existsSync(TEST_VENV_PATH), 'Venv directory should be deleted'); + } + }); + + it('should handle deleteVenv when venv does not exist', async () => { + const result = await manager.deleteVenv(); + console.log(`[Test] Delete non-existent venv result: ${result}`); + assert.ok(result, 'Deleting non-existent venv should succeed'); + }); + }); + + describe('Helper Functions', () => { + it('should create CodexLens UV manager with defaults', () => { + const codexLensManager = mod.createCodexLensUvManager(); + console.log(`[Test] CodexLens manager created`); + assert.ok(codexLensManager !== null, 'createCodexLensUvManager should return manager'); + assert.ok(codexLensManager.getVenvPython, 'Manager should have getVenvPython method'); + }); + + it('should create CodexLens UV manager with custom data dir', () => { + const customDir = join(tmpdir(), 'custom-codexlens'); + const codexLensManager = mod.createCodexLensUvManager(customDir); + const pythonPath = codexLensManager.getVenvPython(); + console.log(`[Test] Custom CodexLens manager Python path: ${pythonPath}`); + assert.ok(pythonPath.includes(customDir), 'Python path should use custom dir'); + }); + + it('should bootstrap UV venv', async () => { + if (!uvAvailable) { + console.log('[Test] Skipping bootstrap - UV not available'); + return; + } + + const bootstrapPath = join(tmpdir(), `uv-bootstrap-test-${Date.now()}`); + console.log(`[Test] Bootstrap venv path: ${bootstrapPath}`); + + try { + const result = await mod.bootstrapUvVenv(bootstrapPath, '>=3.10'); + console.log(`[Test] Bootstrap result:`, result); + assert.equal(typeof result.success, 'boolean', 'success should be boolean'); + + if (result.success) { + assert.ok(existsSync(bootstrapPath), 'Bootstrap venv should exist'); + } + } finally { + // Cleanup bootstrap venv + if (existsSync(bootstrapPath)) { + rmSync(bootstrapPath, { recursive: true, force: true }); + } + } + }); + }); + + describe('Error Handling', () => { + it('should handle install when UV not available gracefully', async () => { + // Create manager pointing to non-existent venv + const badManager = new mod.UvManager({ + venvPath: join(tmpdir(), 'non-existent-venv'), + pythonVersion: '>=3.10', + }); + + const result = await badManager.install(['some-package']); + console.log(`[Test] Install with invalid venv:`, result); + assert.equal(result.success, false, 'Install should fail with invalid venv'); + assert.ok(result.error, 'Error message should be present'); + }); + + it('should handle uninstall when venv not valid', async () => { + const badManager = new mod.UvManager({ + venvPath: join(tmpdir(), 'non-existent-venv'), + pythonVersion: '>=3.10', + }); + + const result = await badManager.uninstall(['some-package']); + console.log(`[Test] Uninstall with invalid venv:`, result); + assert.equal(result.success, false, 'Uninstall should fail with invalid venv'); + assert.ok(result.error, 'Error message should be present'); + }); + + it('should handle list when venv not valid', async () => { + const badManager = new mod.UvManager({ + venvPath: join(tmpdir(), 'non-existent-venv'), + pythonVersion: '>=3.10', + }); + + const packages = await badManager.list(); + console.log(`[Test] List with invalid venv: ${packages}`); + assert.equal(packages, null, 'list() should return null for invalid venv'); + }); + + it('should handle isPackageInstalled when venv not valid', async () => { + const badManager = new mod.UvManager({ + venvPath: join(tmpdir(), 'non-existent-venv'), + pythonVersion: '>=3.10', + }); + + const installed = await badManager.isPackageInstalled('pip'); + console.log(`[Test] isPackageInstalled with invalid venv: ${installed}`); + assert.equal(installed, false, 'isPackageInstalled should return false for invalid venv'); + }); + + it('should handle runPython when venv not valid', async () => { + const badManager = new mod.UvManager({ + venvPath: join(tmpdir(), 'non-existent-venv'), + pythonVersion: '>=3.10', + }); + + const result = await badManager.runPython(['--version']); + console.log(`[Test] runPython with invalid venv:`, result); + assert.equal(result.success, false, 'runPython should fail for invalid venv'); + assert.ok(result.stderr.length > 0, 'Error message should be present'); + }); + + it('should handle sync when venv not valid', async () => { + const badManager = new mod.UvManager({ + venvPath: join(tmpdir(), 'non-existent-venv'), + pythonVersion: '>=3.10', + }); + + const result = await badManager.sync('requirements.txt'); + console.log(`[Test] sync with invalid venv:`, result); + assert.equal(result.success, false, 'sync should fail for invalid venv'); + assert.ok(result.error, 'Error message should be present'); + }); + + it('should handle installFromProject when venv not valid', async () => { + const badManager = new mod.UvManager({ + venvPath: join(tmpdir(), 'non-existent-venv'), + pythonVersion: '>=3.10', + }); + + const result = await badManager.installFromProject('/some/project'); + console.log(`[Test] installFromProject with invalid venv:`, result); + assert.equal(result.success, false, 'installFromProject should fail for invalid venv'); + assert.ok(result.error, 'Error message should be present'); + }); + }); +});