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:
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -115,6 +115,7 @@ node_modules/
|
||||
dist/
|
||||
out/
|
||||
*.tsbuildinfo
|
||||
apps/frontend/python-runtime/
|
||||
|
||||
# Cache
|
||||
.cache/
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user