/** * Python Discovery Service * * Discovers Python environments on the system (conda, pyenv, venv, system). * Node.js port of the Python PythonDiscoveryService. */ import { PythonEnvironment, PythonEnvType, CacheInfo, InstallKernelResult } from './types'; import { CondaLocatorContext } from './conda-locations'; export interface DiscoveryServiceOptions { cacheFile?: string; cacheTtlHours?: number; versionCheckTimeoutMs?: number; ipykernelCheckTimeoutMs?: number; kernelInstallTimeoutMs?: number; registrationTimeoutMs?: number; /** Test seam: override home dir / env vars for the conda locator. */ condaLocator?: { home?: string; env?: Record; }; /** * Test seam: conda-binary lookup for the install planner. The default * (findCondaLikeBinaries) scans absolute well-known roots too, so a CI * runner's system conda (/usr/share/miniconda) would leak into fixtures. */ condaBinaryFinder?: (ctx: CondaLocatorContext) => Promise; } export declare class PythonDiscoveryService { private cache; private cacheTimestamp; private cacheFile; private cacheTtlHours; private versionCheckTimeoutMs; private ipykernelCheckTimeoutMs; private kernelInstallTimeoutMs; private registrationTimeoutMs; private condaLocatorOverrides; private condaBinaryFinder; private backgroundRefreshInProgress; constructor(options?: DiscoveryServiceOptions); /** Context for the filesystem conda locator (test seam applied). */ condaContext(): CondaLocatorContext; /** * Load cache from disk */ private loadCache; /** * Save environments to cache */ saveToCache(environments: Record): void; /** * Get environments from cache (returns null if empty) */ getFromCache(): Record | null; /** * Check if cache is still valid */ isCacheValid(): boolean; /** * Get cache info */ getCacheInfo(): CacheInfo & { refreshing: boolean; }; /** * Run a command asynchronously with timeout. `onData` receives every * stdout/stderr chunk as it arrives (interleaved), for callers that * surface live progress (the ipykernel install modal). */ private runCommand; /** * Generate display name for an environment */ generateDisplayName(version: string, envType: PythonEnvType, envName: string | null): string; /** * Sort environments by type priority and name */ sortEnvironments(envs: PythonEnvironment[]): PythonEnvironment[]; /** * Check if a python path exists */ pythonExists(pythonPath: string): boolean; /** * Async existence check. The discovery scan MUST use this (not existsSync): * env dirs often live on network filesystems (GPFS) where each sync stat can * take 10-500ms, and the scan runs dozens — enough to freeze the entire * event loop for seconds (observed as "terminal dead during autosave"). */ private fsExists; /** * Get common conda base paths to search */ getCondaBasePaths(): string[]; /** * Get common system python paths */ getSystemPythonPaths(): string[]; /** * Get pyenv versions path */ getPyenvVersionsPath(): string; /** * Get common virtualenv paths */ getVirtualenvPaths(): string[]; /** * Generate a unique kernel name from path */ generateKernelName(pythonPath: string, version: string): string; /** * Simple string hash function */ private hashString; /** * Parse Python version from version string */ parseVersionString(versionOutput: string): string; /** * Get Python version from executable */ private getPythonVersion; /** * Check if ipykernel is installed */ private checkIpykernel; /** * Probe an interpreter for the two facts that drive kernel provisioning, in a * single Python invocation (keeps discovery cheap — one spawn per env): * - whether `ipykernel` is importable * - whether the interpreter is PEP 668 "externally managed" (an * `EXTERNALLY-MANAGED` marker beside the stdlib), which blocks pip installs. * * Detecting the marker file is capability-based and stable: we ask Python * itself rather than fingerprinting tool names by path, so it keeps working as * new package managers (uv, pixi, …) appear. */ private probeEnvironment; /** * Refine an environment's type using stable path markers that the coarse * discovery scans miss. Only used to pick a better label / guidance hint — * the safe-vs-unsafe install decision relies on `externallyManaged`, not this. * * A venv is left as-is: its `bin/python` symlinks to (and `sysconfig` resolves * to) its base interpreter, so following the symlink to e.g. a uv-managed build * must NOT relabel the venv itself as "uv". */ private classifyEnvType; /** * Build a copy-pasteable command that makes `ipykernel` available for an env, * tailored to the detected ecosystem. After the user runs it (and clicks * Refresh), the env shows up with ipykernel and Nebula offers one-click * Register — Nebula never installs packages itself. * * Returns null when ipykernel is already present (nothing to do but Register). */ private buildInstallHint; /** * Public probe for kernel provisioning: does this interpreter have ipykernel, * can we install into it, and if not — what should the user run instead? * Uses the discovery cache for env-type context (better hints) but always * probes the interpreter live, so a just-installed ipykernel is seen * immediately even with a stale cache. */ probeForKernel(pythonPath: string): Promise<{ hasIpykernel: boolean; externallyManaged: boolean; installHint: string | null; }>; /** * Find conda environments — pure filesystem forensics (see conda-locations.ts). * conda/mamba are never executed: envs come from ~/.conda/environments.txt, * .condarc envs_dirs, well-known roots, the CONDA_ and MAMBA_ env vars, and * roots derived from conda-like binaries found on PATH. */ private findCondaEnvs; /** * Find pyenv Python versions */ private findPyenvVersions; /** * Find virtualenvs in common locations */ private findVirtualenvs; /** * Find system Python installations */ private findSystemPythons; /** * Enrich a candidate with version and ipykernel info */ private enrichEnvironment; /** * Perform the actual discovery (internal, always runs full scan) */ private performDiscovery; /** * Trigger background refresh (non-blocking) */ private triggerBackgroundRefresh; /** * Discover all Python environments * * Uses stale-while-revalidate pattern: * - If cache exists (even stale): return immediately, refresh in background if stale * - If no cache: block and perform full discovery * - If forceRefresh: block and perform full discovery */ discover(options?: { forceRefresh?: boolean; }): Promise; /** * Check if background refresh is in progress */ isRefreshing(): boolean; /** * Register a Python environment as a Jupyter kernel. * * Policy (capability-based, see ADR in the kernel onboarding flow): * - If `ipykernel` is already importable → just register the kernelspec. * This is the safe, universal path and works for every ecosystem. * - If `ipykernel` is missing and the interpreter is PEP 668 externally * managed → refuse, with a `needs_ipykernel`/`externally_managed` code so * the UI shows guidance instead of a raw pip traceback. Nebula does not * install into managed interpreters. * - If `ipykernel` is missing but the interpreter is writable (a plain venv, * conda env, …) → install it, then register. This keeps the API usable for * deliberate callers (MCP, scripts); the UI funnels everything through the * register-only path. */ installKernel(pythonPath: string, kernelName?: string): Promise; /** * Probe a manually-entered interpreter path, classify it (conda-meta → * conda, pyvenv.cfg → venv, else system + the usual uv/pixi refinement), * and persist it into the discovery cache so it shows up in the picker from * now on. VSCode's "Enter interpreter path…" equivalent. */ probeAndRemember(pythonPath: string): Promise; /** * Pick ONE installer for ipykernel, up front (VSCode's shape — no fallback * chain at run time, so failures are attributable and honest): * 1. conda env → a conda-like binary (conda/mamba/micromamba; PATH first, * then known roots) with `-p ` — correct for named, base and * path-based envs alike. * 2. uv on PATH → `uv pip install --python ` — works on ANY env, * including conda envs without a conda binary and envs without pip. * 3. the env's own pip. */ planIpykernelInstall(pythonPath: string): Promise<{ kind: 'conda' | 'uv' | 'pip'; argv: string[]; }>; /** * Install ipykernel into an environment with the planned installer, then * VERIFY it actually became importable. Refuses PEP 668 externally-managed * interpreters up front (with guidance). Failure carries the installer's * output — no silent fallback to a different installer. */ installIpykernel(pythonPath: string, onOutput?: (chunk: string) => void): Promise<{ installer: 'none' | 'conda' | 'uv' | 'pip'; message: string; }>; /** Extract combined stdout+stderr text from a child_process error. */ private errorOutput; /** Detect PEP 668 "externally managed" refusals generically (not tool-specific). */ private isExternallyManagedError; } export declare const pythonDiscovery: PythonDiscoveryService;