201 lines
5.2 KiB
TypeScript
201 lines
5.2 KiB
TypeScript
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 {
|
|
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<boolean> {
|
|
const invocation = viaCmdShell
|
|
? {
|
|
command: "cmd.exe",
|
|
args: ["/d", "/s", "/c", buildWindowsCommandLine([command, ...args, "--version"])],
|
|
}
|
|
: {
|
|
command,
|
|
args: [...args, "--version"],
|
|
};
|
|
|
|
return await new Promise<boolean>((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<string | null> {
|
|
return await new Promise<string | null>((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<string | null> {
|
|
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<boolean> {
|
|
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<PythonCommandSpec> {
|
|
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<PythonCommandSpec> {
|
|
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);
|
|
}
|