import { spawn } from "node:child_process"; import { constants as fsConstants } from "node:fs"; import { access } from "node:fs/promises"; import path from "node:path"; import process from "node:process"; import type { AppConfig } from "./config.js"; export interface PythonCommandSpec { command: string; args: string[]; viaCmdShell?: boolean; } function shouldUseCmdShell(command: string): boolean { if (process.platform !== "win32") { return false; } const lower = command.toLowerCase(); return !path.isAbsolute(command) || lower.endsWith(".bat") || lower.endsWith(".cmd"); } function splitCommand(command: string): string[] { const parts = command.match(/(?:[^\s"]+|"[^"]*")+/g) ?? []; return parts.map((part) => part.replace(/^"(.*)"$/, "$1")); } function quoteWindowsCmdArg(value: string): string { if (!/[ \t"&()<>^|]/.test(value)) { return value; } return `"${value.replace(/"/g, '""')}"`; } function buildWindowsCommandLine(parts: string[]): string { return parts.map((part) => quoteWindowsCmdArg(part)).join(" "); } export function buildPythonInvocation(spec: PythonCommandSpec, extraArgs: string[]): PythonCommandSpec { if (process.platform === "win32" && spec.viaCmdShell) { return { command: "cmd.exe", args: ["/d", "/s", "/c", buildWindowsCommandLine([spec.command, ...spec.args, ...extraArgs])], }; } return { command: spec.command, args: [...spec.args, ...extraArgs], }; } async function canRun(command: string, args: string[], viaCmdShell = false): Promise { const invocation = viaCmdShell ? { command: "cmd.exe", args: ["/d", "/s", "/c", buildWindowsCommandLine([command, ...args, "--version"])], } : { command, args: [...args, "--version"], }; return await new Promise((resolve) => { const child = spawn(invocation.command, invocation.args, { stdio: ["ignore", "ignore", "ignore"], windowsHide: true, }); child.on("error", () => { resolve(false); }); child.on("exit", (code) => { resolve(code === 0); }); }); } async function captureStdout(command: string, args: string[]): Promise { return await new Promise((resolve) => { const child = spawn(command, args, { stdio: ["ignore", "pipe", "ignore"], windowsHide: true, }); let stdout = ""; child.stdout.on("data", (chunk: Buffer) => { stdout += chunk.toString(); }); child.on("error", () => { resolve(null); }); child.on("exit", (code) => { if (code === 0) { resolve(stdout); return; } resolve(null); }); }); } async function resolveWindowsExecutable(name: string): Promise { const stdout = await captureStdout("cmd.exe", ["/d", "/s", "/c", `where ${name}`]); if (!stdout) { return null; } const candidates = stdout .split(/\r?\n/) .map((line) => line.trim()) .filter((line) => line.length > 0); for (const candidate of candidates) { try { await access(candidate, fsConstants.F_OK); return candidate; } catch { // ignore } } return null; } async function fileExists(target: string): Promise { try { await access(target, fsConstants.X_OK); return true; } catch { return false; } } export async function resolvePythonCommand(config: AppConfig): Promise<{ command: string; args: string[] }> { return await resolveWorkerPythonCommand(config); } export async function resolveBasePythonCommand(config: AppConfig): Promise { const configured = config.LOCAL_AI_PYTHON?.trim(); if (configured) { const [command, ...args] = splitCommand(configured); if (!command) { throw new Error("LOCAL_AI_PYTHON 값이 비어 있습니다."); } return { command, args, viaCmdShell: shouldUseCmdShell(command), }; } const venvPath = resolveVenvPythonPath(config); if (await fileExists(venvPath)) { return { command: venvPath, args: [] }; } if (process.platform === "win32") { return { command: "python", args: [], viaCmdShell: true, }; } const unixCandidates = [ { command: "python3", args: [] as string[] }, { command: "python", args: [] as string[] }, ]; for (const candidate of unixCandidates) { if (await canRun(candidate.command, candidate.args)) { return candidate; } } throw new Error("사용 가능한 Python 실행기를 찾지 못했습니다. `python3 --version` 또는 `python --version` 이 먼저 동작해야 합니다."); } export async function resolveWorkerPythonCommand(config: AppConfig): Promise { const venvPath = resolveVenvPythonPath(config); if (await fileExists(venvPath)) { return { command: venvPath, args: [] }; } return await resolveBasePythonCommand(config); } export function resolveVenvPythonPath(config: AppConfig): string { const root = path.resolve(process.cwd(), config.LOCAL_AI_VENV_PATH); if (process.platform === "win32") { return path.join(root, "Scripts", "python.exe"); } return path.join(root, "bin", "python"); } export function resolveWorkerScript(name: string): string { return path.resolve(process.cwd(), "python", name); }