diff --git a/apps/frontend/electron.vite.config.ts b/apps/frontend/electron.vite.config.ts index a430dd76..ee7fbf5d 100644 --- a/apps/frontend/electron.vite.config.ts +++ b/apps/frontend/electron.vite.config.ts @@ -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', diff --git a/apps/frontend/package.json b/apps/frontend/package.json index 454c2157..9cc7e827 100644 --- a/apps/frontend/package.json +++ b/apps/frontend/package.json @@ -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", diff --git a/apps/frontend/scripts/download-python.cjs b/apps/frontend/scripts/download-python.cjs index bd1b587d..c2e00c84 100644 --- a/apps/frontend/scripts/download-python.cjs +++ b/apps/frontend/scripts/download-python.cjs @@ -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(', ')}`); diff --git a/apps/frontend/scripts/package-with-python.cjs b/apps/frontend/scripts/package-with-python.cjs new file mode 100644 index 00000000..10883dbe --- /dev/null +++ b/apps/frontend/scripts/package-with-python.cjs @@ -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; +}); diff --git a/apps/frontend/src/main/ipc-handlers/task/worktree-handlers.ts b/apps/frontend/src/main/ipc-handlers/task/worktree-handlers.ts index 28b653cb..1171b837 100644 --- a/apps/frontend/src/main/ipc-handlers/task/worktree-handlers.ts +++ b/apps/frontend/src/main/ipc-handlers/task/worktree-handlers.ts @@ -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'; diff --git a/apps/frontend/src/shared/platform.cjs b/apps/frontend/src/shared/platform.cjs new file mode 100644 index 00000000..acf3ebfc --- /dev/null +++ b/apps/frontend/src/shared/platform.cjs @@ -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, +};