fix: package runtime deps and validate pydantic_core (#1336)
* fix: bundle runtime deps for packaged app * fix: verify pydantic_core binary in bundled python * fix: bundle minimatch by using esm import * chore: throw on command failures in packager * chore: drop redundant PATH filter in packager * Use shared platform helper for packager * Use platform helper in resolvePlatforms * Harden packaging helpers --------- Co-authored-by: StillKnotKnown <stillknotknown@users.noreply.github.com>
This commit is contained in:
@@ -25,6 +25,12 @@ export default defineConfig({
|
||||
exclude: [
|
||||
'uuid',
|
||||
'chokidar',
|
||||
'dotenv',
|
||||
'electron-log',
|
||||
'proper-lockfile',
|
||||
'semver',
|
||||
'zod',
|
||||
'@anthropic-ai/sdk',
|
||||
'kuzu',
|
||||
'electron-updater',
|
||||
'@electron-toolkit/utils',
|
||||
|
||||
@@ -31,11 +31,11 @@
|
||||
"python:download": "node scripts/download-python.cjs",
|
||||
"python:download:all": "node scripts/download-python.cjs --all",
|
||||
"python:verify": "node scripts/verify-python-bundling.cjs",
|
||||
"package": "npm run python:download && electron-vite build && electron-builder --publish never",
|
||||
"package:mac": "npm run python:download && electron-vite build && electron-builder --mac --publish never",
|
||||
"package:win": "npm run python:download && electron-vite build && electron-builder --win --publish never",
|
||||
"package:linux": "npm run python:download && electron-vite build && electron-builder --linux --publish never",
|
||||
"package:flatpak": "npm run python:download && electron-vite build && electron-builder --linux flatpak --publish never",
|
||||
"package": "node scripts/package-with-python.cjs",
|
||||
"package:mac": "node scripts/package-with-python.cjs --mac",
|
||||
"package:win": "node scripts/package-with-python.cjs --win",
|
||||
"package:linux": "node scripts/package-with-python.cjs --linux",
|
||||
"package:flatpak": "node scripts/package-with-python.cjs --linux flatpak",
|
||||
"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",
|
||||
@@ -156,6 +156,9 @@
|
||||
"out/**/*",
|
||||
"package.json"
|
||||
],
|
||||
"asarUnpack": [
|
||||
"out/main/node_modules/@lydell/node-pty-*/**"
|
||||
],
|
||||
"extraResources": [
|
||||
{
|
||||
"from": "resources/icon.ico",
|
||||
|
||||
@@ -21,6 +21,7 @@ const path = require('path');
|
||||
const { spawnSync } = require('child_process');
|
||||
const os = require('os');
|
||||
const nodeCrypto = require('crypto');
|
||||
const { toNodePlatform } = require('../src/shared/platform.cjs');
|
||||
|
||||
// Python version to bundle (must be 3.10+ for claude-agent-sdk, 3.12+ for full Graphiti support)
|
||||
const PYTHON_VERSION = '3.12.8';
|
||||
@@ -165,18 +166,6 @@ function toElectronBuilderPlatform(nodePlatform) {
|
||||
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.
|
||||
@@ -464,6 +453,71 @@ function formatBytes(bytes) {
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
function hasPackage(sitePackagesDir, pkg) {
|
||||
const pkgPath = path.join(sitePackagesDir, pkg);
|
||||
const initPath = path.join(pkgPath, '__init__.py');
|
||||
const moduleFile = path.join(sitePackagesDir, pkg + '.py');
|
||||
return (fs.existsSync(pkgPath) && fs.existsSync(initPath)) || fs.existsSync(moduleFile);
|
||||
}
|
||||
|
||||
function hasPydanticCoreBinary(sitePackagesDir) {
|
||||
const pkgDir = path.join(sitePackagesDir, 'pydantic_core');
|
||||
if (!fs.existsSync(pkgDir)) return false;
|
||||
|
||||
let entries;
|
||||
try {
|
||||
entries = fs.readdirSync(pkgDir);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
return entries.some((name) => {
|
||||
if (!name.startsWith('_pydantic_core')) return false;
|
||||
const lower = name.toLowerCase();
|
||||
return lower.endsWith('.so') || lower.endsWith('.pyd') || lower.endsWith('.dylib');
|
||||
});
|
||||
}
|
||||
|
||||
function getPinnedPydanticCoreVersion(sitePackagesDir) {
|
||||
let entries;
|
||||
try {
|
||||
entries = fs.readdirSync(sitePackagesDir);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
const distInfo = entries.find((entry) => {
|
||||
return entry.startsWith('pydantic-')
|
||||
&& !entry.startsWith('pydantic_core-')
|
||||
&& entry.endsWith('.dist-info');
|
||||
});
|
||||
if (!distInfo) return null;
|
||||
|
||||
const metadataPath = path.join(sitePackagesDir, distInfo, 'METADATA');
|
||||
if (!fs.existsSync(metadataPath)) return null;
|
||||
|
||||
let metadata;
|
||||
try {
|
||||
metadata = fs.readFileSync(metadataPath, 'utf-8');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
for (const line of metadata.split(/\r?\n/)) {
|
||||
if (!line.startsWith('Requires-Dist: pydantic-core')) continue;
|
||||
const match = line.match(/pydantic-core==([0-9A-Za-z.+-]+)/);
|
||||
if (match) return match[1];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function isCriticalPackageMissing(sitePackagesDir, pkg) {
|
||||
if (pkg === 'pydantic_core') {
|
||||
return !hasPackage(sitePackagesDir, pkg) || !hasPydanticCoreBinary(sitePackagesDir);
|
||||
}
|
||||
return !hasPackage(sitePackagesDir, pkg);
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip unnecessary files from site-packages to reduce bundle size.
|
||||
* This removes tests, docs, cache files, and other non-essential content.
|
||||
@@ -797,6 +851,44 @@ function installPackages(pythonBin, requirementsPath, targetSitePackages) {
|
||||
// Strip unnecessary files
|
||||
stripSitePackages(targetSitePackages);
|
||||
|
||||
if (!hasPydanticCoreBinary(targetSitePackages)) {
|
||||
console.warn('[download-python] pydantic_core binary missing after strip; reinstalling pydantic-core...');
|
||||
const pinnedVersion = getPinnedPydanticCoreVersion(targetSitePackages);
|
||||
const coreSpec = pinnedVersion ? `pydantic-core==${pinnedVersion}` : 'pydantic-core';
|
||||
if (pinnedVersion) {
|
||||
console.log(`[download-python] Reinstalling pydantic-core ${pinnedVersion} to match pydantic metadata`);
|
||||
} else {
|
||||
console.warn('[download-python] Unable to determine pydantic-core pin; reinstalling latest');
|
||||
}
|
||||
const pipArgs = [
|
||||
'-m', 'pip', 'install',
|
||||
'--no-compile',
|
||||
'--only-binary', 'pydantic-core',
|
||||
'--no-deps',
|
||||
'--target', targetSitePackages,
|
||||
coreSpec,
|
||||
];
|
||||
const result = spawnSync(pythonBin, pipArgs, {
|
||||
stdio: 'inherit',
|
||||
env: {
|
||||
...process.env,
|
||||
PYTHONDONTWRITEBYTECODE: '1',
|
||||
PYTHONIOENCODING: 'utf-8',
|
||||
},
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
throw new Error(`Failed to reinstall pydantic-core: ${result.error.message}`);
|
||||
}
|
||||
if (result.status !== 0) {
|
||||
throw new Error(`pydantic-core reinstall failed with exit code ${result.status}`);
|
||||
}
|
||||
|
||||
if (!hasPydanticCoreBinary(targetSitePackages)) {
|
||||
throw new Error('pydantic_core binary missing after reinstall');
|
||||
}
|
||||
}
|
||||
|
||||
// Remove bin/Scripts directory (we don't need console scripts)
|
||||
const binDir = path.join(targetSitePackages, 'bin');
|
||||
const scriptsDir = path.join(targetSitePackages, 'Scripts');
|
||||
@@ -862,14 +954,7 @@ async function downloadPython(targetPlatform, targetArch, options = {}) {
|
||||
// while this script validates it during build to ensure it's bundled
|
||||
const criticalPackages = ['claude_agent_sdk', 'dotenv', 'pydantic_core']
|
||||
.concat(PLATFORM_CRITICAL_PACKAGES[info.nodePlatform] || []);
|
||||
const missingPackages = criticalPackages.filter(pkg => {
|
||||
const pkgPath = path.join(sitePackagesDir, pkg);
|
||||
const initPath = path.join(pkgPath, '__init__.py');
|
||||
// For single-file modules (like pywintypes.py), check for the file directly
|
||||
const moduleFile = path.join(sitePackagesDir, pkg + '.py');
|
||||
// Package is valid if directory+__init__.py exists OR single-file module exists
|
||||
return !(fs.existsSync(pkgPath) && fs.existsSync(initPath)) && !fs.existsSync(moduleFile);
|
||||
});
|
||||
const missingPackages = criticalPackages.filter(pkg => isCriticalPackageMissing(sitePackagesDir, pkg));
|
||||
|
||||
if (missingPackages.length > 0) {
|
||||
console.log(`[download-python] Critical packages missing or incomplete: ${missingPackages.join(', ')}`);
|
||||
@@ -969,14 +1054,7 @@ async function downloadPython(targetPlatform, targetArch, options = {}) {
|
||||
// while this script validates it during build to ensure it's bundled
|
||||
const criticalPackages = ['claude_agent_sdk', 'dotenv', 'pydantic_core']
|
||||
.concat(PLATFORM_CRITICAL_PACKAGES[info.nodePlatform] || []);
|
||||
const postInstallMissing = criticalPackages.filter(pkg => {
|
||||
const pkgPath = path.join(sitePackagesDir, pkg);
|
||||
const initPath = path.join(pkgPath, '__init__.py');
|
||||
// For single-file modules (like pywintypes.py), check for the file directly
|
||||
const moduleFile = path.join(sitePackagesDir, pkg + '.py');
|
||||
// Package is valid if directory+__init__.py exists OR single-file module exists
|
||||
return !(fs.existsSync(pkgPath) && fs.existsSync(initPath)) && !fs.existsSync(moduleFile);
|
||||
});
|
||||
const postInstallMissing = criticalPackages.filter(pkg => isCriticalPackageMissing(sitePackagesDir, pkg));
|
||||
|
||||
if (postInstallMissing.length > 0) {
|
||||
throw new Error(`Package installation failed - missing critical packages: ${postInstallMissing.join(', ')}`);
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Packaging script that downloads bundled Python, stages runtime modules,
|
||||
* and builds the Electron app for the requested platforms and architectures.
|
||||
*
|
||||
* Usage: node scripts/package-with-python.cjs [--mac|--win|--linux] [--x64|--arm64|--universal]
|
||||
*/
|
||||
const { spawnSync } = require('child_process');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
|
||||
const { isWindows, getCurrentPlatform, toNodePlatform } = require('../src/shared/platform.cjs');
|
||||
const { downloadPython } = require('./download-python.cjs');
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
|
||||
const PLATFORM_FLAGS = new Map([
|
||||
['--mac', 'mac'],
|
||||
['--win', 'win'],
|
||||
['--windows', 'win'],
|
||||
['--linux', 'linux'],
|
||||
]);
|
||||
|
||||
const ARCH_FLAGS = new Map([
|
||||
['--x64', 'x64'],
|
||||
['--arm64', 'arm64'],
|
||||
['--universal', 'universal'],
|
||||
]);
|
||||
|
||||
function mapHostPlatform(platform) {
|
||||
const map = { darwin: 'mac', win32: 'win', linux: 'linux' };
|
||||
return map[platform] || platform;
|
||||
}
|
||||
|
||||
function resolvePlatforms() {
|
||||
const platforms = new Set();
|
||||
for (const arg of args) {
|
||||
const mapped = PLATFORM_FLAGS.get(arg);
|
||||
if (mapped) platforms.add(mapped);
|
||||
}
|
||||
|
||||
if (platforms.size === 0) {
|
||||
platforms.add(mapHostPlatform(getCurrentPlatform()));
|
||||
}
|
||||
|
||||
return [...platforms];
|
||||
}
|
||||
|
||||
function resolveArchs() {
|
||||
const archs = new Set();
|
||||
let wantsUniversal = false;
|
||||
|
||||
for (const arg of args) {
|
||||
const mapped = ARCH_FLAGS.get(arg);
|
||||
if (!mapped) continue;
|
||||
if (mapped === 'universal') {
|
||||
wantsUniversal = true;
|
||||
} else {
|
||||
archs.add(mapped);
|
||||
}
|
||||
}
|
||||
|
||||
if (wantsUniversal) {
|
||||
archs.add('x64');
|
||||
archs.add('arm64');
|
||||
}
|
||||
|
||||
if (archs.size === 0) {
|
||||
archs.add(os.arch());
|
||||
}
|
||||
|
||||
for (const arch of archs) {
|
||||
if (!['x64', 'arm64'].includes(arch)) {
|
||||
throw new Error(
|
||||
`Host architecture '${arch}' is not supported for bundled Python. Please specify --x64 or --arm64 explicitly.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return [...archs];
|
||||
}
|
||||
|
||||
function buildEnv(frontendDir) {
|
||||
const binDir = path.join(frontendDir, 'node_modules', '.bin');
|
||||
const rootBinDir = path.join(frontendDir, '..', '..', 'node_modules', '.bin');
|
||||
const pathParts = [binDir, rootBinDir];
|
||||
const pathValue = process.env.PATH
|
||||
? `${pathParts.join(path.delimiter)}${path.delimiter}${process.env.PATH}`
|
||||
: pathParts.join(path.delimiter);
|
||||
return { ...process.env, PATH: pathValue };
|
||||
}
|
||||
|
||||
function runCommand(command, commandArgs, cwd, env) {
|
||||
const bin = isWindows() ? `${command}.cmd` : command;
|
||||
const result = spawnSync(bin, commandArgs, {
|
||||
cwd,
|
||||
env,
|
||||
stdio: 'inherit',
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
throw result.error;
|
||||
}
|
||||
|
||||
if (result.status !== 0) {
|
||||
const code = result.status ?? 1;
|
||||
throw new Error(`Command "${command}" failed with exit code ${code}.`);
|
||||
}
|
||||
}
|
||||
|
||||
function resolvePackageDir(baseDir, pkgName) {
|
||||
return path.join(baseDir, 'node_modules', ...pkgName.split('/'));
|
||||
}
|
||||
|
||||
function copyPackage(fromDir, toDir) {
|
||||
if (!fs.existsSync(fromDir)) {
|
||||
throw new Error(`Required package not found: ${fromDir}`);
|
||||
}
|
||||
|
||||
if (fs.existsSync(toDir)) {
|
||||
fs.rmSync(toDir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
fs.mkdirSync(path.dirname(toDir), { recursive: true });
|
||||
fs.cpSync(fromDir, toDir, { recursive: true, dereference: true });
|
||||
}
|
||||
|
||||
function readPackageJson(pkgDir) {
|
||||
const pkgPath = path.join(pkgDir, 'package.json');
|
||||
if (!fs.existsSync(pkgPath)) return null;
|
||||
return JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
|
||||
}
|
||||
|
||||
function stageRuntimePackages(frontendDir, platform, arch) {
|
||||
const rootDir = path.join(frontendDir, '..', '..');
|
||||
const nodePlatform = toNodePlatform(platform);
|
||||
const packages = [
|
||||
'@lydell/node-pty',
|
||||
`@lydell/node-pty-${nodePlatform}-${arch}`,
|
||||
'minimatch',
|
||||
];
|
||||
const outMainDir = path.join(frontendDir, 'out', 'main');
|
||||
const outModulesDir = path.join(outMainDir, 'node_modules');
|
||||
|
||||
if (!fs.existsSync(outMainDir)) {
|
||||
throw new Error('Missing build output. Run electron-vite build before staging node-pty.');
|
||||
}
|
||||
|
||||
fs.mkdirSync(outModulesDir, { recursive: true });
|
||||
|
||||
const staged = new Set();
|
||||
|
||||
function stagePackage(pkgName) {
|
||||
if (staged.has(pkgName)) return;
|
||||
staged.add(pkgName);
|
||||
|
||||
const rootDirPath = resolvePackageDir(rootDir, pkgName);
|
||||
if (!fs.existsSync(rootDirPath)) {
|
||||
throw new Error(`Missing ${pkgName} in workspace. Run npm install before packaging.`);
|
||||
}
|
||||
|
||||
const localDir = path.join(outModulesDir, ...pkgName.split('/'));
|
||||
console.log(`[package] Staging ${pkgName} into build output...`);
|
||||
copyPackage(rootDirPath, localDir);
|
||||
|
||||
const pkgJson = readPackageJson(rootDirPath);
|
||||
if (!pkgJson) return;
|
||||
|
||||
const deps = pkgJson.dependencies || {};
|
||||
const optionalDeps = pkgJson.optionalDependencies || {};
|
||||
|
||||
for (const depName of Object.keys(deps)) {
|
||||
stagePackage(depName);
|
||||
}
|
||||
|
||||
for (const depName of Object.keys(optionalDeps)) {
|
||||
const optionalPath = resolvePackageDir(rootDir, depName);
|
||||
if (fs.existsSync(optionalPath)) {
|
||||
stagePackage(depName);
|
||||
} else {
|
||||
console.log(`[package] Skipping optional dependency not installed: ${depName}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const pkgName of packages) {
|
||||
stagePackage(pkgName);
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const frontendDir = path.join(__dirname, '..');
|
||||
const env = buildEnv(frontendDir);
|
||||
|
||||
const platforms = resolvePlatforms();
|
||||
const archs = resolveArchs();
|
||||
|
||||
for (const platform of platforms) {
|
||||
for (const arch of archs) {
|
||||
await downloadPython(platform, arch);
|
||||
}
|
||||
}
|
||||
|
||||
runCommand('electron-vite', ['build'], frontendDir, env);
|
||||
|
||||
for (const platform of platforms) {
|
||||
for (const arch of archs) {
|
||||
stageRuntimePackages(frontendDir, platform, arch);
|
||||
}
|
||||
}
|
||||
|
||||
const builderArgs = [...args];
|
||||
const hasPublishFlag = builderArgs.some((arg) => arg === '--publish' || arg.startsWith('--publish='));
|
||||
if (!hasPublishFlag) {
|
||||
builderArgs.push('--publish', 'never');
|
||||
}
|
||||
|
||||
runCommand('electron-builder', builderArgs, frontendDir, env);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(`[package] Error: ${err.message}`);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@@ -2,11 +2,9 @@ import { ipcMain, BrowserWindow, shell, app } from 'electron';
|
||||
import { IPC_CHANNELS, AUTO_BUILD_PATHS, DEFAULT_APP_SETTINGS, DEFAULT_FEATURE_MODELS, DEFAULT_FEATURE_THINKING, MODEL_ID_MAP, THINKING_BUDGET_MAP, getSpecsDir } from '../../../shared/constants';
|
||||
import type { IPCResult, WorktreeStatus, WorktreeDiff, WorktreeDiffFile, WorktreeMergeResult, WorktreeDiscardResult, WorktreeListResult, WorktreeListItem, WorktreeCreatePROptions, WorktreeCreatePRResult, SupportedIDE, SupportedTerminal, AppSettings } from '../../../shared/types';
|
||||
import path from 'path';
|
||||
import { minimatch } from 'minimatch';
|
||||
import { existsSync, readdirSync, statSync, readFileSync } from 'fs';
|
||||
import { execSync, execFileSync, spawn, spawnSync, exec, execFile } from 'child_process';
|
||||
import { createRequire } from 'module';
|
||||
const require = createRequire(import.meta.url);
|
||||
const { minimatch } = require('minimatch');
|
||||
import { projectStore } from '../../project-store';
|
||||
import { getConfiguredPythonPath, PythonEnvManager, pythonEnvManager as pythonEnvManagerSingleton } from '../../python-env-manager';
|
||||
import { getEffectiveSourcePath } from '../../updater/path-resolver';
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
// CommonJS wrapper for build scripts that cannot import TypeScript directly.
|
||||
'use strict';
|
||||
|
||||
function getCurrentPlatform() {
|
||||
const p = process.platform;
|
||||
if (p === 'win32' || p === 'darwin' || p === 'linux') {
|
||||
return p;
|
||||
}
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
function isWindows() {
|
||||
return getCurrentPlatform() === 'win32';
|
||||
}
|
||||
|
||||
function isMacOS() {
|
||||
return getCurrentPlatform() === 'darwin';
|
||||
}
|
||||
|
||||
function isLinux() {
|
||||
return getCurrentPlatform() === 'linux';
|
||||
}
|
||||
|
||||
function isUnix() {
|
||||
return isMacOS() || isLinux();
|
||||
}
|
||||
|
||||
function toNodePlatform(platform) {
|
||||
const map = {
|
||||
mac: 'darwin',
|
||||
win: 'win32',
|
||||
darwin: 'darwin',
|
||||
win32: 'win32',
|
||||
linux: 'linux',
|
||||
};
|
||||
return map[platform] || platform;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getCurrentPlatform,
|
||||
isWindows,
|
||||
isMacOS,
|
||||
isLinux,
|
||||
isUnix,
|
||||
toNodePlatform,
|
||||
};
|
||||
Reference in New Issue
Block a user