import { existsSync } from "node:fs"; import { rm } from "node:fs/promises"; import { homedir } from "node:os"; import { delimiter, join } from "node:path"; import { execFile } from "node:child_process"; const VENV_NAMES = [".venv", "venv"]; const SHARED_VENV_NAME = "pyvenv"; export function findProjectVenv(cwd: string): string | null { for (const name of VENV_NAMES) { const p = join(cwd, name); if (existsSync(p)) return p; } return null; } export function getSharedVenvPath(): string { return join(homedir(), ".pi", "agent", SHARED_VENV_NAME); } function getVenvBinDir(venvPath: string): string { return process.platform === "win32" ? join(venvPath, "Scripts") : join(venvPath, "bin"); } function getVenvPython(venvPath: string): string { const binDir = getVenvBinDir(venvPath); return process.platform === "win32" ? join(binDir, "python.exe") : join(binDir, "python"); } function execFileAsync( command: string, args: string[], timeoutMs = 10000 ): Promise<{ code: number; stdout: string; stderr: string }> { return new Promise((resolve) => { try { execFile(command, args, { timeout: timeoutMs }, (error, stdout, stderr) => { if (error) { resolve({ code: (error as any).code as number ?? 1, stdout: stdout.trim(), stderr: stderr.trim(), }); } else { resolve({ code: 0, stdout: stdout.trim(), stderr: stderr.trim() }); } }); } catch { resolve({ code: 1, stdout: "", stderr: "" }); } }); } export async function findValidPython(): Promise { const candidates = ["python3", "python", "py"]; for (const cmd of candidates) { const result = await execFileAsync(cmd, ["-c", "import sys; print(sys.executable)"], 5000); if (result.code === 0 && result.stdout.length > 0) { return cmd; } } return null; } export async function smokeTestVenv(venvPath: string): Promise { const python = getVenvPython(venvPath); if (!existsSync(python)) return false; const result = await execFileAsync(python, ["-c", "import sys; print(sys.executable)"], 5000); return result.code === 0 && result.stdout.length > 0; } export async function createVenv(pythonCmd: string, venvPath: string): Promise { const result = await execFileAsync(pythonCmd, ["-m", "venv", venvPath], 60000); if (result.code !== 0) { throw new Error( `Failed to create venv at ${venvPath}: ${result.stderr || result.stdout}` ); } } let originalPath: string | undefined; export function activateVenv(venvPath: string): void { // If a different venv is already active, do nothing if (process.env.VIRTUAL_ENV && process.env.VIRTUAL_ENV !== venvPath) { return; } const binDir = getVenvBinDir(venvPath); const pathKey = Object.keys(process.env).find((k) => k.toLowerCase() === "path") ?? "PATH"; const existingPath = process.env[pathKey] ?? ""; // Idempotency: already active if (existingPath.startsWith(`${binDir}${delimiter}`)) { return; } // Store original path on first activation if (originalPath === undefined) { originalPath = existingPath; } const newPath = existingPath ? `${binDir}${delimiter}${existingPath}` : binDir; process.env[pathKey] = newPath; process.env.VIRTUAL_ENV = venvPath; } export function getActiveVenv(): string | undefined { return process.env.VIRTUAL_ENV; } export function deactivateVenv(): void { if (originalPath !== undefined) { const pathKey = Object.keys(process.env).find((k) => k.toLowerCase() === "path") ?? "PATH"; process.env[pathKey] = originalPath; } delete process.env.VIRTUAL_ENV; } export async function ensureSharedVenv(): Promise { const sharedPath = getSharedVenvPath(); if (existsSync(sharedPath)) { if (await smokeTestVenv(sharedPath)) { return sharedPath; } // Dead venv — remove and recreate await rm(sharedPath, { recursive: true, force: true }); } const python = await findValidPython(); if (!python) return null; await createVenv(python, sharedPath); return sharedPath; }