import { existsSync } from "node:fs"; import { join } from "node:path"; import { log } from "../utils/cli.ts"; import { spawn } from "../utils/subprocess.ts"; import type { PrepDefinition, PrepOptions, PythonPackageManager, PythonPrepResult, } from "./types.ts"; interface PythonConfig { file: string; tool: PythonPackageManager; installCmd: string[]; } // python dependency file patterns in priority order const PYTHON_CONFIGS: PythonConfig[] = [ { file: "requirements.txt", tool: "pip", installCmd: ["pip", "install", "-r", "requirements.txt"], }, { file: "pyproject.toml", tool: "pip", installCmd: ["pip", "install", "."], }, { file: "Pipfile", tool: "pipenv", installCmd: ["pipenv", "install"], }, { file: "Pipfile.lock", tool: "pipenv", installCmd: ["pipenv", "sync"], }, { file: "poetry.lock", tool: "poetry", installCmd: ["poetry", "install", "--no-interaction"], }, { file: "setup.py", tool: "pip", installCmd: ["pip", "install", "-e", "."], }, ]; // tool install commands (via pip) const TOOL_INSTALL_COMMANDS: Record = { pipenv: ["pip", "install", "pipenv"], poetry: ["pip", "install", "poetry"], }; async function isCommandAvailable(command: string): Promise { const result = await spawn({ cmd: "which", args: [command], env: { PATH: process.env.PATH || "" }, }); return result.exitCode === 0; } async function installTool(name: string): Promise { const installCmd = TOOL_INSTALL_COMMANDS[name]; if (!installCmd) { // tool doesn't need installation (e.g., pip) return null; } log.info(`» installing ${name}...`); const [cmd, ...args] = installCmd; const result = await spawn({ cmd, args, env: { PATH: process.env.PATH || "", HOME: process.env.HOME || "" }, onStderr: (chunk) => process.stderr.write(chunk), }); if (result.exitCode !== 0) { return result.stderr || `failed to install ${name}`; } log.info(`» installed ${name}`); return null; } export const installPythonDependencies: PrepDefinition = { name: "installPythonDependencies", shouldRun: async () => { // check if python is available const hasPython = (await isCommandAvailable("python3")) || (await isCommandAvailable("python")); if (!hasPython) { return false; } // check if any python config file exists const cwd = process.cwd(); return PYTHON_CONFIGS.some((config) => existsSync(join(cwd, config.file))); }, run: async (options: PrepOptions): Promise => { const cwd = process.cwd(); // find the first matching config const config = PYTHON_CONFIGS.find((c) => existsSync(join(cwd, c.file))); if (!config) { return { language: "python", packageManager: "pip", configFile: "unknown", dependenciesInstalled: false, issues: ["no python config file found"], }; } log.info(`» detected python config: ${config.file} (using ${config.tool})`); // SECURITY: when shell is disabled, skip ALL python dependency installation. // every python install path can potentially execute arbitrary code: // - setup.py / pyproject.toml: directly execute build backends // - requirements.txt: can contain "-e ." or local path references that // trigger setup.py execution // - Pipfile/poetry.lock: can contain path dependencies pointing to local // directories with malicious setup.py // - source distributions from PyPI also execute setup.py // there is no equivalent of npm's --ignore-scripts for pip. if (options.ignoreScripts) { log.info( `» skipping python install (shell disabled, python packages can execute arbitrary code)` ); return { language: "python", packageManager: config.tool, configFile: config.file, dependenciesInstalled: false, issues: [ `skipped: python dependency installation can execute arbitrary code (setup.py, build backends, local path references), which is blocked when shell is disabled`, ], }; } // check if the tool is available, install if needed const isAvailable = await isCommandAvailable(config.tool); if (!isAvailable) { log.info(`» ${config.tool} not found, attempting to install...`); const installError = await installTool(config.tool); if (installError) { return { language: "python", packageManager: config.tool, configFile: config.file, dependenciesInstalled: false, issues: [installError], }; } } // run the install command const [cmd, ...args] = config.installCmd; const fullCommand = `${cmd} ${args.join(" ")}`; log.info(`» running: ${fullCommand}`); const result = await spawn({ cmd, args, env: { PATH: process.env.PATH || "", HOME: process.env.HOME || "" }, }); const output = [result.stdout, result.stderr].filter(Boolean).join("\n").trim(); if (output) { log.startGroup(`${fullCommand} output`); log.info(output); log.endGroup(); } if (result.exitCode !== 0) { return { language: "python", packageManager: config.tool, configFile: config.file, dependenciesInstalled: false, issues: [output || `${cmd} exited with code ${result.exitCode}`], }; } return { language: "python", packageManager: config.tool, configFile: config.file, dependenciesInstalled: true, issues: [], }; }, };