feat(python): bundle Python 3.12 with packaged Electron app (#284)

* feat(python): bundle Python 3.12 with packaged Electron app

Resolves issue #258 where users with Python aliases couldn't run the app
because shell aliases aren't visible to Electron's subprocess calls.

Changes:
- Add download-python.cjs script to fetch python-build-standalone
- Bundle Python 3.12.8 in extraResources for packaged apps
- Update python-detector.ts to prioritize bundled Python
- Add Python caching to CI workflows for faster builds

Packaged apps now include Python (~35MB), eliminating the need for users
to have Python installed. Dev mode still falls back to system Python.

Closes #258

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: address PR review feedback for Python bundling

Security improvements:
- Add SHA256 checksum verification for downloaded Python binaries
- Replace execSync with spawnSync to prevent command injection
- Add input validation to prevent log injection from CLI args
- Add download timeout (5 minutes) and redirect limit (10)
- Proper file/connection cleanup on errors

Bug fixes:
- Fix platform naming mismatch: use "mac"/"win" (electron-builder)
  instead of "darwin"/"win32" (Node.js) for output directories
- Handle empty path edge case in parsePythonCommand

Improvements:
- Add restore-keys to CI cache steps for better cache hit rates
- Improve error messages and logging

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix mac node.js naming

* security: add SHA256 checksums for all Python platforms

Fetched actual checksums from python-build-standalone release:
- darwin-arm64: abe1de24...
- darwin-x64: 867c1af1...
- win32-x64: 1a702b34...
- linux-x64: 698e53b2...
- linux-arm64: fb983ec8...

All platforms now have cryptographic verification for downloaded
Python binaries, eliminating the supply chain risk.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* chore: add python-runtime to root .gitignore

Ensures bundled Python runtime is ignored from both root and
frontend .gitignore files.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Andy
2025-12-25 22:38:05 +01:00
committed by GitHub
parent d98e28305d
commit 7f19c2e1f3
8 changed files with 654 additions and 19 deletions
+32
View File
@@ -110,6 +110,14 @@ jobs:
- name: Install dependencies
run: cd apps/frontend && npm ci
- name: Cache bundled Python
uses: actions/cache@v4
with:
path: apps/frontend/python-runtime
key: python-bundle-${{ runner.os }}-x64-3.12.8
restore-keys: |
python-bundle-${{ runner.os }}-x64-
- name: Build application
run: cd apps/frontend && npm run build
@@ -178,6 +186,14 @@ jobs:
- name: Install dependencies
run: cd apps/frontend && npm ci
- name: Cache bundled Python
uses: actions/cache@v4
with:
path: apps/frontend/python-runtime
key: python-bundle-${{ runner.os }}-arm64-3.12.8
restore-keys: |
python-bundle-${{ runner.os }}-arm64-
- name: Build application
run: cd apps/frontend && npm run build
@@ -246,6 +262,14 @@ jobs:
- name: Install dependencies
run: cd apps/frontend && npm ci
- name: Cache bundled Python
uses: actions/cache@v4
with:
path: apps/frontend/python-runtime
key: python-bundle-${{ runner.os }}-x64-3.12.8
restore-keys: |
python-bundle-${{ runner.os }}-x64-
- name: Build application
run: cd apps/frontend && npm run build
@@ -290,6 +314,14 @@ jobs:
- name: Install dependencies
run: cd apps/frontend && npm ci
- name: Cache bundled Python
uses: actions/cache@v4
with:
path: apps/frontend/python-runtime
key: python-bundle-${{ runner.os }}-x64-3.12.8
restore-keys: |
python-bundle-${{ runner.os }}-x64-
- name: Build application
run: cd apps/frontend && npm run build
+32
View File
@@ -43,6 +43,14 @@ jobs:
- name: Install dependencies
run: cd apps/frontend && npm ci
- name: Cache bundled Python
uses: actions/cache@v4
with:
path: apps/frontend/python-runtime
key: python-bundle-${{ runner.os }}-x64-3.12.8
restore-keys: |
python-bundle-${{ runner.os }}-x64-
- name: Build application
run: cd apps/frontend && npm run build
@@ -112,6 +120,14 @@ jobs:
- name: Install dependencies
run: cd apps/frontend && npm ci
- name: Cache bundled Python
uses: actions/cache@v4
with:
path: apps/frontend/python-runtime
key: python-bundle-${{ runner.os }}-arm64-3.12.8
restore-keys: |
python-bundle-${{ runner.os }}-arm64-
- name: Build application
run: cd apps/frontend && npm run build
@@ -181,6 +197,14 @@ jobs:
- name: Install dependencies
run: cd apps/frontend && npm ci
- name: Cache bundled Python
uses: actions/cache@v4
with:
path: apps/frontend/python-runtime
key: python-bundle-${{ runner.os }}-x64-3.12.8
restore-keys: |
python-bundle-${{ runner.os }}-x64-
- name: Build application
run: cd apps/frontend && npm run build
@@ -226,6 +250,14 @@ jobs:
- name: Install dependencies
run: cd apps/frontend && npm ci
- name: Cache bundled Python
uses: actions/cache@v4
with:
path: apps/frontend/python-runtime
key: python-bundle-${{ runner.os }}-x64-3.12.8
restore-keys: |
python-bundle-${{ runner.os }}-x64-
- name: Build application
run: cd apps/frontend && npm run build
+1
View File
@@ -115,6 +115,7 @@ node_modules/
dist/
out/
*.tsbuildinfo
apps/frontend/python-runtime/
# Cache
.cache/
+3
View File
@@ -6,6 +6,9 @@ out/
dist/
build/
# Bundled Python runtime (downloaded during packaging)
python-runtime/
# Compiled TypeScript (source files are .ts)
src/**/*.js
src/**/*.js.map
+10 -4
View File
@@ -28,10 +28,12 @@
"start:mcp": "electron . --remote-debugging-port=9222",
"preview": "electron-vite preview",
"rebuild": "electron-rebuild",
"package": "electron-vite build && electron-builder",
"package:mac": "electron-vite build && electron-builder --mac",
"package:win": "electron-vite build && electron-builder --win",
"package:linux": "electron-vite build && electron-builder --linux",
"python:download": "node scripts/download-python.cjs",
"python:download:all": "node scripts/download-python.cjs --all",
"package": "npm run python:download && electron-vite build && electron-builder",
"package:mac": "npm run python:download && electron-vite build && electron-builder --mac",
"package:win": "npm run python:download && electron-vite build && electron-builder --win",
"package:linux": "npm run python:download && electron-vite build && electron-builder --linux",
"start:packaged:mac": "open dist/mac-arm64/Auto-Claude.app || open dist/mac/Auto-Claude.app",
"start:packaged:win": "start \"\" \"dist\\win-unpacked\\Auto-Claude.exe\"",
"start:packaged:linux": "./dist/linux-unpacked/auto-claude",
@@ -150,6 +152,10 @@
"from": "resources/icon.ico",
"to": "icon.ico"
},
{
"from": "python-runtime/${os}-${arch}/python",
"to": "python"
},
{
"from": "../backend",
"to": "backend",
+479
View File
@@ -0,0 +1,479 @@
#!/usr/bin/env node
/**
* Download Python from python-build-standalone for bundling with the Electron app.
*
* This script downloads a standalone Python distribution that can be bundled
* with the packaged Electron app, eliminating the need for users to have
* Python installed on their system.
*
* Usage:
* node scripts/download-python.cjs [--platform <platform>] [--arch <arch>]
*
* Platforms: darwin/mac, win32/win, linux
* Architectures: x64, arm64
*
* If not specified, uses current platform/arch.
*/
const https = require('https');
const fs = require('fs');
const path = require('path');
const { spawnSync } = require('child_process');
const os = require('os');
const nodeCrypto = require('crypto');
// Python version to bundle (must be 3.10+ for claude-agent-sdk, 3.12+ for full Graphiti support)
const PYTHON_VERSION = '3.12.8';
// python-build-standalone release tag
const RELEASE_TAG = '20241219';
// Base URL for downloads
const BASE_URL = `https://github.com/indygreg/python-build-standalone/releases/download/${RELEASE_TAG}`;
// Output directory for downloaded Python (relative to frontend root)
const OUTPUT_DIR = 'python-runtime';
// SHA256 checksums for verification (from python-build-standalone release)
// These must be updated when changing PYTHON_VERSION or RELEASE_TAG
// Get checksums from: https://github.com/indygreg/python-build-standalone/releases/download/{RELEASE_TAG}/SHA256SUMS
const CHECKSUMS = {
'darwin-arm64': 'abe1de2494bb8b243fd507944f4d50292848fa00685d5288c858a72623a16635',
'darwin-x64': '867c1af10f204224b571f8f2593fc9eb580fe0c2376224d1096ebe855ad8c722',
'win32-x64': '1a702b3463cf87ec0d2e33902a47e95456053b0178fe96bd673c1dbb554f5d15',
'linux-x64': '698e53b264a9bcd35cfa15cd680c4d78b0878fa529838844b5ffd0cd661d6bc2',
'linux-arm64': 'fb983ec85952513f5f013674fcbf4306b1a142c50fcfd914c2c3f00c61a874b0',
};
// Map Node.js platform names to electron-builder platform names
function toElectronBuilderPlatform(nodePlatform) {
const map = {
'darwin': 'mac',
'win32': 'win',
'linux': 'linux',
};
return map[nodePlatform] || nodePlatform;
}
// Map electron-builder platform names to Node.js platform names (for internal use)
function toNodePlatform(platform) {
const map = {
'mac': 'darwin',
'win': 'win32',
'darwin': 'darwin',
'win32': 'win32',
'linux': 'linux',
};
return map[platform] || platform;
}
/**
* Get the download URL for a specific platform/arch combination.
* python-build-standalone uses specific naming conventions.
*
* @param {string} platform - Node.js platform (darwin, win32, linux)
* @param {string} arch - Architecture (x64, arm64)
*/
function getDownloadInfo(platform, arch) {
// Normalize platform to Node.js naming for internal lookups
const nodePlatform = toNodePlatform(platform);
const version = PYTHON_VERSION;
// Map platform/arch to python-build-standalone naming
const configs = {
'darwin-arm64': {
filename: `cpython-${version}+${RELEASE_TAG}-aarch64-apple-darwin-install_only_stripped.tar.gz`,
extractDir: 'python',
},
'darwin-x64': {
filename: `cpython-${version}+${RELEASE_TAG}-x86_64-apple-darwin-install_only_stripped.tar.gz`,
extractDir: 'python',
},
'win32-x64': {
filename: `cpython-${version}+${RELEASE_TAG}-x86_64-pc-windows-msvc-install_only_stripped.tar.gz`,
extractDir: 'python',
},
'linux-x64': {
filename: `cpython-${version}+${RELEASE_TAG}-x86_64-unknown-linux-gnu-install_only_stripped.tar.gz`,
extractDir: 'python',
},
'linux-arm64': {
filename: `cpython-${version}+${RELEASE_TAG}-aarch64-unknown-linux-gnu-install_only_stripped.tar.gz`,
extractDir: 'python',
},
};
const key = `${nodePlatform}-${arch}`;
const config = configs[key];
if (!config) {
throw new Error(`Unsupported platform/arch combination: ${key}. Supported: ${Object.keys(configs).join(', ')}`);
}
// Use electron-builder platform naming for output directory
const ebPlatform = toElectronBuilderPlatform(nodePlatform);
return {
url: `${BASE_URL}/${config.filename}`,
filename: config.filename,
extractDir: config.extractDir,
outputDir: `${ebPlatform}-${arch}`, // e.g., "mac-arm64", "win-x64", "linux-x64"
nodePlatform, // For internal checks (darwin, win32, linux)
checksum: CHECKSUMS[key],
};
}
/**
* Download a file from URL to destination path.
* Includes timeout handling, redirect limits, and proper cleanup.
*/
function downloadFile(url, destPath) {
const DOWNLOAD_TIMEOUT = 300000; // 5 minutes
const MAX_REDIRECTS = 10;
return new Promise((resolve, reject) => {
console.log(`[download-python] Downloading from: ${url}`);
let file = null;
let redirectCount = 0;
let currentRequest = null;
const cleanup = () => {
if (file) {
file.close();
file = null;
}
if (fs.existsSync(destPath)) {
try {
fs.unlinkSync(destPath);
} catch {
// Ignore cleanup errors
}
}
};
const request = (urlString) => {
if (++redirectCount > MAX_REDIRECTS) {
cleanup();
reject(new Error(`Too many redirects (max ${MAX_REDIRECTS})`));
return;
}
// Create file stream only on first request
if (!file) {
file = fs.createWriteStream(destPath);
}
currentRequest = https.get(urlString, { timeout: DOWNLOAD_TIMEOUT }, (response) => {
// Handle redirects (GitHub uses them)
if (response.statusCode >= 300 && response.statusCode < 400 && response.headers.location) {
console.log(`[download-python] Following redirect...`);
response.resume(); // Consume response to free up memory
request(response.headers.location);
return;
}
if (response.statusCode !== 200) {
cleanup();
reject(new Error(`Download failed with status ${response.statusCode}`));
return;
}
const totalSize = parseInt(response.headers['content-length'], 10);
let downloadedSize = 0;
let lastPercent = 0;
response.on('data', (chunk) => {
downloadedSize += chunk.length;
if (totalSize > 0) {
const percent = Math.floor((downloadedSize / totalSize) * 100);
if (percent >= lastPercent + 10) {
console.log(`[download-python] Progress: ${percent}%`);
lastPercent = percent;
}
}
});
response.pipe(file);
file.on('finish', () => {
file.close();
file = null;
console.log(`[download-python] Download complete: ${destPath}`);
resolve();
});
file.on('error', (err) => {
cleanup();
reject(err);
});
});
currentRequest.on('error', (err) => {
cleanup();
reject(err);
});
currentRequest.on('timeout', () => {
currentRequest.destroy();
cleanup();
reject(new Error(`Download timeout after ${DOWNLOAD_TIMEOUT / 1000} seconds`));
});
};
request(url);
});
}
/**
* Verify file checksum.
*/
function verifyChecksum(filePath, expectedChecksum) {
if (!expectedChecksum) {
console.log(`[download-python] Warning: No checksum available for verification`);
return true;
}
console.log(`[download-python] Verifying checksum...`);
const fileBuffer = fs.readFileSync(filePath);
const hash = nodeCrypto.createHash('sha256').update(fileBuffer).digest('hex');
if (hash !== expectedChecksum) {
throw new Error(`Checksum mismatch! Expected: ${expectedChecksum}, Got: ${hash}`);
}
console.log(`[download-python] Checksum verified: ${hash.substring(0, 16)}...`);
return true;
}
/**
* Extract a tar.gz file using spawnSync for safety.
*/
function extractTarGz(archivePath, destDir) {
console.log(`[download-python] Extracting to: ${destDir}`);
// Ensure destination exists
fs.mkdirSync(destDir, { recursive: true });
// Use tar command with array arguments (safer than string interpolation)
const result = spawnSync('tar', ['-xzf', archivePath, '-C', destDir], {
stdio: 'inherit',
});
if (result.error) {
throw new Error(`Failed to extract archive: ${result.error.message}`);
}
if (result.status !== 0) {
throw new Error(`Failed to extract archive: tar exited with code ${result.status}`);
}
console.log(`[download-python] Extraction complete`);
}
/**
* Verify Python binary works by checking its version.
*/
function verifyPythonBinary(pythonBin) {
const result = spawnSync(pythonBin, ['--version'], { encoding: 'utf-8' });
if (result.error) {
throw result.error;
}
if (result.status !== 0) {
throw new Error(`Python verification failed with exit code ${result.status}`);
}
// Version output may be on stdout or stderr depending on Python version
const version = (result.stdout || result.stderr || '').trim();
return version;
}
/**
* Main function to download and set up Python.
*/
async function downloadPython(targetPlatform, targetArch) {
const platform = targetPlatform || os.platform();
const arch = targetArch || os.arch();
const info = getDownloadInfo(platform, arch);
console.log(`[download-python] Setting up Python ${PYTHON_VERSION} for ${info.outputDir}`);
const frontendDir = path.join(__dirname, '..');
const runtimeDir = path.join(frontendDir, OUTPUT_DIR);
const platformDir = path.join(runtimeDir, info.outputDir);
// Check if already downloaded
const pythonBin = info.nodePlatform === 'win32'
? path.join(platformDir, 'python', 'python.exe')
: path.join(platformDir, 'python', 'bin', 'python3');
if (fs.existsSync(pythonBin)) {
console.log(`[download-python] Python already exists at ${pythonBin}`);
// Verify it works
try {
const version = verifyPythonBinary(pythonBin);
console.log(`[download-python] Verified: ${version}`);
return { success: true, pythonPath: pythonBin };
} catch {
console.log(`[download-python] Existing Python is broken, re-downloading...`);
// Remove broken installation
fs.rmSync(platformDir, { recursive: true, force: true });
}
}
// Create directories
fs.mkdirSync(platformDir, { recursive: true });
// Download
const archivePath = path.join(runtimeDir, info.filename);
let needsDownload = true;
if (fs.existsSync(archivePath)) {
console.log(`[download-python] Found cached archive: ${archivePath}`);
// Verify cached archive checksum
try {
verifyChecksum(archivePath, info.checksum);
needsDownload = false;
} catch (err) {
console.log(`[download-python] Cached archive failed verification: ${err.message}`);
fs.unlinkSync(archivePath);
}
}
if (needsDownload) {
await downloadFile(info.url, archivePath);
// Verify downloaded file
verifyChecksum(archivePath, info.checksum);
}
// Extract
extractTarGz(archivePath, platformDir);
// Verify binary exists
if (!fs.existsSync(pythonBin)) {
throw new Error(`Python binary not found after extraction: ${pythonBin}`);
}
// Make executable on Unix
if (info.nodePlatform !== 'win32') {
fs.chmodSync(pythonBin, 0o755);
}
// Verify it works
const version = verifyPythonBinary(pythonBin);
console.log(`[download-python] Installed: ${version}`);
return { success: true, pythonPath: pythonBin };
}
/**
* Download Python for all platforms (for CI/CD builds).
*/
async function downloadAllPlatforms() {
const platforms = [
{ platform: 'darwin', arch: 'arm64' },
{ platform: 'darwin', arch: 'x64' },
{ platform: 'win32', arch: 'x64' },
{ platform: 'linux', arch: 'x64' },
{ platform: 'linux', arch: 'arm64' },
];
console.log(`[download-python] Downloading Python for all platforms...`);
for (const { platform, arch } of platforms) {
try {
await downloadPython(platform, arch);
} catch (error) {
console.error(`[download-python] Failed for ${platform}-${arch}: ${error.message}`);
throw error;
}
}
console.log(`[download-python] All platforms downloaded successfully!`);
}
// Valid platforms and architectures (for input validation)
const VALID_PLATFORMS = ['darwin', 'mac', 'win32', 'win', 'linux'];
const VALID_ARCHS = ['x64', 'arm64'];
/**
* Validate and sanitize CLI input to prevent log injection.
*/
function validateInput(value, validValues, name) {
if (value === null) return null;
// Remove any control characters or newlines (ASCII 0-31 and 127)
// eslint-disable-next-line no-control-regex
const sanitized = String(value).replace(/[\x00-\x1f\x7f]/g, '');
if (!validValues.includes(sanitized)) {
throw new Error(`Invalid ${name}: "${sanitized}". Valid values: ${validValues.join(', ')}`);
}
return sanitized;
}
// CLI handling
async function main() {
const args = process.argv.slice(2);
let platform = null;
let arch = null;
let allPlatforms = false;
for (let i = 0; i < args.length; i++) {
if (args[i] === '--platform' && args[i + 1]) {
platform = args[++i];
} else if (args[i] === '--arch' && args[i + 1]) {
arch = args[++i];
} else if (args[i] === '--all') {
allPlatforms = true;
} else if (args[i] === '--help' || args[i] === '-h') {
console.log(`
Usage: node download-python.cjs [options]
Options:
--platform <platform> Target platform (darwin/mac, win32/win, linux)
--arch <arch> Target architecture (x64, arm64)
--all Download for all supported platforms
--help, -h Show this help message
If no options specified, downloads for the current platform/arch.
Examples:
node download-python.cjs # Current platform
node download-python.cjs --platform darwin --arch arm64
node download-python.cjs --platform mac --arch arm64 # Electron-builder style
node download-python.cjs --all # All platforms (for CI)
`);
process.exit(0);
}
}
try {
// Validate inputs before use
platform = validateInput(platform, VALID_PLATFORMS, 'platform');
arch = validateInput(arch, VALID_ARCHS, 'arch');
if (allPlatforms) {
await downloadAllPlatforms();
} else {
await downloadPython(platform, arch);
}
console.log('[download-python] Done!');
} catch (error) {
console.error(`[download-python] Error: ${error.message}`);
process.exit(1);
}
}
// Export for use in other scripts
module.exports = { downloadPython, downloadAllPlatforms, getDownloadInfo };
// Run if called directly
if (require.main === module) {
main();
}
+78 -8
View File
@@ -1,5 +1,36 @@
import { execSync } from 'child_process';
import { existsSync } from 'fs';
import path from 'path';
import { app } from 'electron';
/**
* Get the path to the bundled Python executable.
* For packaged apps, Python is bundled in the resources directory.
*
* @returns The path to bundled Python, or null if not found/not packaged
*/
export function getBundledPythonPath(): string | null {
// Only check for bundled Python in packaged apps
if (!app.isPackaged) {
return null;
}
const resourcesPath = process.resourcesPath;
const isWindows = process.platform === 'win32';
// Bundled Python location in packaged app
const pythonPath = isWindows
? path.join(resourcesPath, 'python', 'python.exe')
: path.join(resourcesPath, 'python', 'bin', 'python3');
if (existsSync(pythonPath)) {
console.log(`[Python] Found bundled Python at: ${pythonPath}`);
return pythonPath;
}
console.log(`[Python] Bundled Python not found at: ${pythonPath}`);
return null;
}
/**
* Find the first existing Homebrew Python installation.
@@ -13,9 +44,9 @@ function findHomebrewPython(): string | null {
'/usr/local/bin/python3' // Intel Mac
];
for (const path of homebrewPaths) {
if (existsSync(path)) {
return path;
for (const pythonPath of homebrewPaths) {
if (existsSync(pythonPath)) {
return pythonPath;
}
}
@@ -24,13 +55,34 @@ function findHomebrewPython(): string | null {
/**
* Detect and return the best available Python command.
* Tries multiple candidates and returns the first one that works with Python 3.
* Priority order:
* 1. Bundled Python (for packaged apps)
* 2. System Python (Homebrew on macOS, standard paths on other platforms)
*
* @returns The Python command to use, or null if none found
*/
export function findPythonCommand(): string | null {
const isWindows = process.platform === 'win32';
// 1. Check for bundled Python first (packaged apps only)
const bundledPython = getBundledPythonPath();
if (bundledPython) {
try {
const validation = validatePythonVersion(bundledPython);
if (validation.valid) {
console.log(`[Python] Using bundled Python: ${bundledPython} (${validation.version})`);
return bundledPython;
} else {
console.warn(`[Python] Bundled Python version issue: ${validation.message}`);
}
} catch (err) {
console.warn(`[Python] Bundled Python error: ${err}`);
}
}
// 2. Fall back to system Python
console.log(`[Python] Searching for system Python...`);
// Build candidate list prioritizing Homebrew Python on macOS
let candidates: string[];
if (isWindows) {
@@ -47,7 +99,7 @@ export function findPythonCommand(): string | null {
// Validate version meets minimum requirement (Python 3.10+)
const validation = validatePythonVersion(cmd);
if (validation.valid) {
console.log(`[Python] Found valid Python: ${cmd} (${validation.version})`);
console.log(`[Python] Found valid system Python: ${cmd} (${validation.version})`);
return cmd;
} else {
console.warn(`[Python] ${cmd} version too old: ${validation.message}`);
@@ -134,11 +186,18 @@ function validatePythonVersion(pythonCmd: string): {
/**
* Get the default Python command for the current platform.
* This is a synchronous fallback that doesn't test if Python actually exists.
* Prioritizes bundled Python in packaged apps, then falls back to system Python.
*
* @returns The default Python command for this platform
*/
export function getDefaultPythonCommand(): string {
// Check for bundled Python first
const bundledPython = getBundledPythonPath();
if (bundledPython) {
return bundledPython;
}
// Fall back to system Python
if (process.platform === 'win32') {
return 'python';
}
@@ -151,13 +210,24 @@ export function getDefaultPythonCommand(): string {
*
* @param pythonPath - The Python command string (e.g., "python3", "py -3", "/path/with spaces/python")
* @returns Tuple of [command, baseArgs] ready for use with spawn()
* @throws Error if pythonPath is empty or only whitespace
*/
export function parsePythonCommand(pythonPath: string): [string, string[]] {
// Remove any surrounding quotes first
let cleanPath = pythonPath.trim();
// Validate input is not empty
if (cleanPath === '') {
throw new Error('Python command cannot be empty');
}
if ((cleanPath.startsWith('"') && cleanPath.endsWith('"')) ||
(cleanPath.startsWith("'") && cleanPath.endsWith("'"))) {
cleanPath = cleanPath.slice(1, -1);
// Validate again after quote removal
if (cleanPath === '') {
throw new Error('Python command cannot be empty');
}
}
// If the path points to an actual file, use it directly (handles paths with spaces)
@@ -179,8 +249,8 @@ export function parsePythonCommand(pythonPath: string): [string, string[]] {
// Otherwise, split on spaces for commands like "py -3"
const parts = cleanPath.split(' ').filter(p => p.length > 0);
if (parts.length === 0) {
// Return empty string for empty input, not the original uncleaned path
return [cleanPath, []];
// This shouldn't happen after earlier validation, but guard anyway
throw new Error('Python command cannot be empty');
}
const command = parts[0];
const baseArgs = parts.slice(1);
+19 -7
View File
@@ -3,7 +3,7 @@ import { existsSync } from 'fs';
import path from 'path';
import { EventEmitter } from 'events';
import { app } from 'electron';
import { findPythonCommand } from './python-detector';
import { findPythonCommand, getBundledPythonPath } from './python-detector';
export interface PythonEnvStatus {
ready: boolean;
@@ -97,8 +97,9 @@ export class PythonEnvManager extends EventEmitter {
}
/**
* Find system Python 3.10+
* Find Python 3.10+ (bundled or system).
* Uses the shared python-detector logic which validates version requirements.
* Priority: bundled Python (packaged apps) > system Python
*/
private findSystemPython(): string | null {
const pythonCmd = findPythonCommand();
@@ -106,6 +107,13 @@ export class PythonEnvManager extends EventEmitter {
return null;
}
// If this is the bundled Python path, use it directly
const bundledPath = getBundledPythonPath();
if (bundledPath && pythonCmd === bundledPath) {
console.log(`[PythonEnvManager] Using bundled Python: ${bundledPath}`);
return bundledPath;
}
try {
// Get the actual executable path from the command
// For commands like "py -3", we need to resolve to the actual executable
@@ -130,11 +138,15 @@ export class PythonEnvManager extends EventEmitter {
const systemPython = this.findSystemPython();
if (!systemPython) {
this.emit(
'error',
'Python 3.10+ not found. Please install Python 3.10 or higher (required by claude-agent-sdk).\n\n' +
'Download: https://www.python.org/downloads/'
);
const isPackaged = app.isPackaged;
const errorMsg = isPackaged
? 'Python not found. The bundled Python may be corrupted.\n\n' +
'Please try reinstalling the application, or install Python 3.10+ manually:\n' +
'https://www.python.org/downloads/'
: 'Python 3.10+ not found. Please install Python 3.10 or higher.\n\n' +
'This is required for development mode. Download from:\n' +
'https://www.python.org/downloads/';
this.emit('error', errorMsg);
return false;
}