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:
StillKnotKnown
2026-01-19 11:38:28 +02:00
committed by GitHub
parent 86ba02466e
commit 141f44f619
6 changed files with 392 additions and 36 deletions
+6
View File
@@ -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',
+8 -5
View File
@@ -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",
+106 -28
View File
@@ -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';
+46
View File
@@ -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,
};