2026-02-17 06:08:20 +00:00
|
|
|
import { spawn } from 'child_process';
|
|
|
|
|
import * as path from 'path';
|
|
|
|
|
import { DependencyStatus } from '../types/settings';
|
|
|
|
|
|
|
|
|
|
interface PythonResult {
|
|
|
|
|
stdout: string;
|
|
|
|
|
stderr: string;
|
|
|
|
|
exitCode: number;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Execute a Python script using spawn() with argument arrays.
|
|
|
|
|
* NEVER uses exec(). NEVER interpolates user data into shell strings.
|
|
|
|
|
* shell: false (the default) prevents shell interpretation of arguments.
|
|
|
|
|
*/
|
|
|
|
|
export function runPythonScript(
|
|
|
|
|
pythonPath: string,
|
|
|
|
|
scriptPath: string,
|
|
|
|
|
args: string[],
|
|
|
|
|
env?: Record<string, string>
|
|
|
|
|
): Promise<PythonResult> {
|
|
|
|
|
return new Promise((resolve, reject) => {
|
|
|
|
|
const child = spawn(pythonPath, [scriptPath, ...args], {
|
|
|
|
|
shell: false,
|
|
|
|
|
env: {
|
|
|
|
|
...process.env,
|
|
|
|
|
PYTHONUTF8: '1',
|
|
|
|
|
...env,
|
|
|
|
|
},
|
|
|
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
let stdout = '';
|
|
|
|
|
let stderr = '';
|
|
|
|
|
|
|
|
|
|
child.stdout.on('data', (data: Buffer) => {
|
|
|
|
|
stdout += data.toString();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
child.stderr.on('data', (data: Buffer) => {
|
|
|
|
|
stderr += data.toString();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
child.on('error', (err: Error) => {
|
|
|
|
|
reject(err);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
child.on('close', (code: number | null) => {
|
|
|
|
|
resolve({
|
|
|
|
|
stdout: stdout.trim(),
|
|
|
|
|
stderr: stderr.trim(),
|
|
|
|
|
exitCode: code ?? 1,
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get the absolute path to a bundled Python wrapper script.
|
|
|
|
|
*/
|
|
|
|
|
export function getPythonScriptPath(scriptName: string, pluginDir: string): string {
|
|
|
|
|
return path.join(pluginDir, 'python', scriptName);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Check Python installation and package versions using check_install.py.
|
|
|
|
|
* Returns the status and the resolved python path (which may differ from
|
|
|
|
|
* the configured path if the python3 fallback was used).
|
|
|
|
|
*/
|
|
|
|
|
export async function checkDependencies(
|
|
|
|
|
pythonPath: string,
|
|
|
|
|
pluginDir: string
|
|
|
|
|
): Promise<{ status: DependencyStatus; resolvedPythonPath: string }> {
|
|
|
|
|
const status: DependencyStatus = {
|
|
|
|
|
pythonInstalled: false,
|
|
|
|
|
pythonVersion: null,
|
|
|
|
|
markitdownInstalled: false,
|
|
|
|
|
markitdownVersion: null,
|
|
|
|
|
};
|
|
|
|
|
|
2026-03-18 00:11:42 +00:00
|
|
|
// Guard against empty or whitespace-only paths
|
|
|
|
|
const trimmed = pythonPath.trim();
|
|
|
|
|
if (!trimmed) {
|
|
|
|
|
return { status, resolvedPythonPath: pythonPath };
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-17 06:08:20 +00:00
|
|
|
const scriptPath = getPythonScriptPath('check_install.py', pluginDir);
|
|
|
|
|
|
2026-03-18 00:11:42 +00:00
|
|
|
// Build candidate list: configured path first, then platform-specific fallbacks.
|
|
|
|
|
// GUI apps (Obsidian/Electron) often don't inherit the user's shell PATH,
|
|
|
|
|
// so we try well-known Python locations to find one with markitdown installed.
|
|
|
|
|
const pathsToTry = [trimmed];
|
|
|
|
|
if (trimmed === 'python' || trimmed === 'python3') {
|
|
|
|
|
// Generic name — add common platform-specific locations
|
|
|
|
|
const isWin = process.platform === 'win32';
|
|
|
|
|
if (isWin) {
|
|
|
|
|
// Windows: try python, python3, and common install locations
|
|
|
|
|
if (trimmed === 'python') pathsToTry.push('python3');
|
|
|
|
|
const localAppData = process.env.LOCALAPPDATA || '';
|
|
|
|
|
if (localAppData) {
|
|
|
|
|
// Standard Python installer locations
|
|
|
|
|
for (const ver of ['313', '312', '311', '310', '39']) {
|
|
|
|
|
pathsToTry.push(`${localAppData}\\Programs\\Python\\Python${ver}\\python.exe`);
|
|
|
|
|
}
|
|
|
|
|
// Microsoft Store Python
|
|
|
|
|
pathsToTry.push(`${localAppData}\\Microsoft\\WindowsApps\\python3.exe`);
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
// macOS / Linux: try python3, python, and common framework/brew/system locations
|
|
|
|
|
if (trimmed === 'python') pathsToTry.push('python3');
|
|
|
|
|
else pathsToTry.push('python');
|
|
|
|
|
// Homebrew (Apple Silicon + Intel)
|
|
|
|
|
pathsToTry.push('/opt/homebrew/bin/python3');
|
|
|
|
|
pathsToTry.push('/usr/local/bin/python3');
|
|
|
|
|
// Framework installs (python.org macOS installer)
|
|
|
|
|
for (const ver of ['3.13', '3.12', '3.11', '3.10', '3.9']) {
|
|
|
|
|
pathsToTry.push(`/Library/Frameworks/Python.framework/Versions/${ver}/bin/python3`);
|
|
|
|
|
}
|
|
|
|
|
// Linux common paths
|
|
|
|
|
pathsToTry.push('/usr/bin/python3');
|
|
|
|
|
}
|
2026-02-17 06:08:20 +00:00
|
|
|
}
|
|
|
|
|
|
2026-03-18 00:11:42 +00:00
|
|
|
// Deduplicate while preserving order
|
|
|
|
|
const seen = new Set<string>();
|
|
|
|
|
const uniquePaths = pathsToTry.filter(p => {
|
|
|
|
|
if (seen.has(p)) return false;
|
|
|
|
|
seen.add(p);
|
|
|
|
|
return true;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Try each candidate; prefer the first one that has markitdown installed.
|
|
|
|
|
// If none has markitdown, fall back to the first working Python.
|
|
|
|
|
let firstWorkingPython: { status: DependencyStatus; resolvedPythonPath: string } | null = null;
|
|
|
|
|
|
|
|
|
|
for (const tryPath of uniquePaths) {
|
2026-02-17 06:08:20 +00:00
|
|
|
try {
|
|
|
|
|
const result = await runPythonScript(tryPath, scriptPath, ['--check', 'all']);
|
|
|
|
|
|
|
|
|
|
if (result.exitCode === 0 && result.stdout) {
|
|
|
|
|
const data = JSON.parse(result.stdout);
|
2026-03-18 00:11:42 +00:00
|
|
|
const depStatus: DependencyStatus = {
|
|
|
|
|
pythonInstalled: true,
|
|
|
|
|
pythonVersion: data.python_version ?? null,
|
|
|
|
|
markitdownInstalled: false,
|
|
|
|
|
markitdownVersion: null,
|
|
|
|
|
};
|
2026-02-17 06:08:20 +00:00
|
|
|
|
|
|
|
|
if (data.packages?.markitdown) {
|
2026-03-18 00:11:42 +00:00
|
|
|
depStatus.markitdownInstalled = data.packages.markitdown.installed;
|
|
|
|
|
depStatus.markitdownVersion = data.packages.markitdown.version ?? null;
|
2026-02-17 06:08:20 +00:00
|
|
|
}
|
|
|
|
|
|
2026-03-18 00:11:42 +00:00
|
|
|
// If this Python has markitdown, use it immediately
|
|
|
|
|
if (depStatus.markitdownInstalled) {
|
|
|
|
|
return { status: depStatus, resolvedPythonPath: tryPath };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Otherwise, remember the first working Python as a fallback
|
|
|
|
|
if (!firstWorkingPython) {
|
|
|
|
|
firstWorkingPython = { status: depStatus, resolvedPythonPath: tryPath };
|
|
|
|
|
}
|
2026-02-17 06:08:20 +00:00
|
|
|
}
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.debug(`markitdown: ${tryPath} failed:`, err);
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-18 00:11:42 +00:00
|
|
|
// No Python with markitdown found — return the first working Python (or the default)
|
|
|
|
|
return firstWorkingPython ?? { status, resolvedPythonPath: pythonPath };
|
2026-02-17 06:08:20 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Install a Python package using install_package.py.
|
|
|
|
|
*/
|
|
|
|
|
export async function installPackage(
|
|
|
|
|
pythonPath: string,
|
|
|
|
|
pluginDir: string,
|
|
|
|
|
packageSpec: string,
|
|
|
|
|
onProgress?: (line: string) => void
|
|
|
|
|
): Promise<boolean> {
|
2026-03-18 00:11:42 +00:00
|
|
|
if (!pythonPath.trim()) return false;
|
|
|
|
|
|
2026-02-17 06:08:20 +00:00
|
|
|
return new Promise((resolve, reject) => {
|
|
|
|
|
const scriptPath = getPythonScriptPath('install_package.py', pluginDir);
|
|
|
|
|
const child = spawn(pythonPath, [scriptPath, '--package', packageSpec], {
|
|
|
|
|
shell: false,
|
|
|
|
|
env: {
|
|
|
|
|
...process.env,
|
|
|
|
|
PYTHONUTF8: '1',
|
|
|
|
|
},
|
|
|
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
let lastLine = '';
|
|
|
|
|
|
|
|
|
|
child.stdout.on('data', (data: Buffer) => {
|
|
|
|
|
const lines = data.toString().split('\n');
|
|
|
|
|
for (const line of lines) {
|
|
|
|
|
if (line.trim()) {
|
|
|
|
|
lastLine = line.trim();
|
|
|
|
|
onProgress?.(line.trim());
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
child.stderr.on('data', (data: Buffer) => {
|
|
|
|
|
const lines = data.toString().split('\n');
|
|
|
|
|
for (const line of lines) {
|
|
|
|
|
if (line.trim()) {
|
|
|
|
|
onProgress?.(line.trim());
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
child.on('error', (err: Error) => {
|
|
|
|
|
reject(err);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
child.on('close', (code: number | null) => {
|
|
|
|
|
if (code === 0) {
|
|
|
|
|
// Check if the last line is a success JSON
|
|
|
|
|
try {
|
|
|
|
|
const result = JSON.parse(lastLine);
|
|
|
|
|
resolve(result.success === true);
|
|
|
|
|
} catch {
|
|
|
|
|
resolve(true);
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
resolve(false);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
}
|