/** * Cross-platform Python interpreter resolver. * * Provides utilities to detect the available Python interpreter on the current * system and to normalize shell commands that reference `python`/`python3` so * that they use whichever interpreter is actually installed. * * On Windows the canonical names differ (`py -3`, `python`, `python3`), so * hard-coded `python3` invocations fail with exit 127. This module detects the * working interpreter once (cached for the process lifetime) and rewrites * commands accordingly. * * @module python-resolver */ import { spawnSync } from "node:child_process"; /** Cached result of `detectPythonExecutable`. `undefined` means not yet probed. */ let cached; /** * Returns the first working Python invocation on this system, or `null` if no * Python interpreter is found. * * Probe order: * - Windows: `py -3` → `python` → `python3` * - All other platforms: `python3` → `python` * * The result is cached for the lifetime of the process to avoid repeated * `spawnSync` calls. */ export function detectPythonExecutable() { if (cached !== undefined) return cached; const candidates = process.platform === "win32" ? ["py -3", "python", "python3"] : ["python3", "python"]; for (const candidate of candidates) { const [bin, ...args] = candidate.split(" "); const r = spawnSync(bin, [...args, "--version"], { stdio: "ignore" }); if (!r.error && r.status === 0) { cached = candidate; return candidate; } } cached = null; return null; } /** * Rewrites a shell command string so that leading `python`/`python3`/`py` * tokens at command boundaries are replaced with the interpreter returned by * `detectPythonExecutable`. * * Only tokens at command boundaries (start of string, or after `&&`, `||`, * `;`) are rewritten — mid-string occurrences (e.g. file paths containing * "python") are left intact. * * When no Python interpreter is detected, the command is returned unchanged so * that the caller receives a meaningful "command not found" error rather than a * silent no-op. * * @param command - The shell command string to normalize. * @returns The command with Python interpreter tokens rewritten, or the * original command if no rewrite is needed. */ export function normalizePythonCommand(command) { const executable = detectPythonExecutable(); if (!executable) return command; // Split on common shell separators to handle compound commands. // We reconstruct the string preserving the original separators. return command.replace( /(^\s*|(?:&&|\|\||;)\s*)(?:python3?|py(?:\s+-\d+)?)(?=\s|$)/g, (_match, pre) => `${pre}${executable}`, ); }