From bad1a9b2c4ce51d5a047d1ee9f756fce5f9aca25 Mon Sep 17 00:00:00 2001 From: StillKnotKnown <192589389+StillKnotKnown@users.noreply.github.com> Date: Fri, 30 Jan 2026 14:09:23 +0200 Subject: [PATCH] fix: align Linux package builds (AppImage/deb/Flatpak) with target-specific extraResources (#1623) * fix: align Linux package builds (AppImage/deb/Flatpak) with target-specific extraResources The .deb, AppImage, and Flatpak builds had inconsistent package inclusion due to ${os}-${arch} template variables in global extraResources not expanding consistently for all Linux targets. Changes: - Move Python runtime from global extraResources to target-specific config - Add platform-specific extraResources for mac, win, appImage, deb, flatpak - Create verify-linux-packages.cjs script to inspect package contents - Add verify:linux and test:verify-linux npm scripts - Update release.yml and beta-release.yml CI/CD workflows with verification This ensures electron-builder processes resources correctly for each package type, avoiding template variable resolution issues and enabling early detection of missing critical files (secretstorage, pydantic_core, claude_agent_sdk). * style: apply Biome formatting to verify-linux-packages.cjs * fix: address PR review feedback - Fix Windows Python path: use python directory, not python.exe directly - Move Linux extraResources to linux block (not appImage/deb targets) - Remove redundant __dirname shadowing in verification script - Tighten file pattern matching to reduce false positives - Export CRITICAL_PACKAGES for use in tests - Only run main() when script is executed directly The appImage and deb blocks are target options in electron-builder, not platform options. They don't support extraResources. Use build.linux.extraResources instead for all Linux targets. The Windows tarball extracts with a 'python' directory containing python.exe, so copy the entire directory, not the exe file directly. * fix: use ${os} template variable for platform paths Use ${os} instead of hardcoded platform names (darwin, win32) to match the directory structure created by download-python.cjs. The download script uses electron-builder platform names (mac, win, linux) via toElectronBuilderPlatform(), so the extraResources paths must use ${os} which resolves to these same names: - macOS: ${os} -> mac (not darwin) - Windows: ${os} -> win (not win32) - Linux: ${os} -> linux * fix: address PR review feedback for verification script - Export verification functions (findPackages, verifyFileList, verifyAppImage, verifyDeb, verifyFlatpak) - Extract common verification logic to reduce duplication (DRY) - Fix pattern matching to avoid false positives: - Python binary check explicitly excludes python-site-packages - Backend check requires resources/ prefix - Package check requires python-site-packages/ prefix - Update tests to use actual exported functions instead of re-implementing logic - Fix test data to match real package file formats (AppImage uses './' prefix) - All 12 unit tests now pass This addresses all issues raised by CodeRabbit AI and Auto Claude PR reviews. * refactor: extract Flatpak size threshold to named constant Add FLATPAK_MIN_SIZE_MB constant (50 MB) alongside CRITICAL_PACKAGES. This makes the threshold self-documenting and centralized for easier maintenance. Also improves error message to include expected size for clarity. * fix: add maxBuffer to spawnSync and remove Flatpak early return - Add 50MB maxBuffer to bsdtar spawnSync call for large AppImages - Add 50MB maxBuffer to dpkg-deb spawnSync call for large deb packages - Remove early return in verifyFlatpak when flatpak CLI is missing - File existence/size checks now run even without flatpak CLI Fixes CodeRabbit review feedback. * test: fix test to actually call findPackages and address low severity issues - Fix test to properly call findPackages() with mocked fs.existsSync/readdirSync - Add test for missing dist directory handling - Add test for duplicate package warnings - Change commandExists to use POSIX-compliant 'command -v' instead of 'which' - Add warnings for duplicate packages in findPackages function - Remove unused imports and variables from tests Fixes AI review findings: - NEW-001 [MEDIUM]: Test now calls findPackages function - NEW-003 [LOW]: Duplicate packages now trigger warnings - NEW-005 [LOW]: commandExists now uses POSIX-compliant 'command -v' * security: fix shell injection vulnerability in commandExists - Change from shell interpolation (sh -c 'command -v \$cmd') to direct which call - Use spawnSync with argument array to avoid shell interpretation - This prevents potential command injection if function is called with untrusted input Fixes CodeRabbit security review finding. * fix: normalize paths to handle trailing slashes from archive tools - Add normalizePath helper to remove trailing slashes - Update Python binary and backend directory checks to use normalized paths - Add test case for paths with trailing slashes (bsdtar/dpkg-deb output) Fixes CodeRabbit review finding about archive tools emitting directories with trailing slashes like './resources/python/' or 'resources/python/'. Now handles: - './resources/python' - './resources/python/' - 'resources/python' - 'resources/python/' * fix: fail CI when critical verification tools are missing - Add critical flag when bsdtar or dpkg-deb is missing - Update main function to fail when critical verifications are skipped - Provide helpful installation instructions when tools are missing Fixes Sentry review finding about CI false positives. The script now exits with error code 1 when AppImage or deb verification is skipped due to missing required tools (bsdtar, dpkg-deb). Note: flatpak CLI remains non-critical since basic file/size checks still work without it. Regarding CodeRabbit's suggestion to use platform helpers: - This is a standalone .cjs script that runs in Node.js (not Electron) - Platform helpers (findExecutable) are TypeScript modules for the app - The 'which' command is universally available on Linux CI systems * fix: add spawnSync error checks and improve Flatpak tests MEDIUM severity fixes: - Add result.error checks in verifyAppImage for spawnSync failures - Add result.error checks in verifyDeb for spawnSync failures - These catch OS-level spawn failures (permission denied, memory issues) LOW severity fix: - Refactor Flatpak tests to call actual verifyFlatpak function - Mock fs.existsSync and fs.statSync similar to findPackages tests - Add test case for non-existent Flatpak files Increases test coverage confidence by testing actual function implementation instead of manually reimplementing logic in tests. Fixes AI review findings NEW-CODE-001, NEW-CODE-002, NEW-CODE-003. * test: add spawnSync mock coverage for verifyAppImage and verifyDeb - Add test coverage for verifyAppImage and verifyDeb functions - Mock spawnSync at module level by clearing require cache - Test cases cover: - Successful extraction - result.error set (OS-level spawn failures) - result.status !== 0 (tool returns error) - Missing required tools (bsdtar, dpkg-deb) Fixes AI review finding NEW-001 [MEDIUM] about missing test coverage for the newly-added error handling code. Increases test count from 16 to 24 tests. --------- Co-authored-by: StillKnotKnown Co-authored-by: Andy <119136210+AndyMik90@users.noreply.github.com> --- .github/workflows/beta-release.yml | 3 + .github/workflows/release.yml | 3 + apps/frontend/package.json | 42 +- .../scripts/verify-linux-packages.cjs | 406 +++++++++++++ .../scripts/verify-linux-packages.test.mjs | 533 ++++++++++++++++++ 5 files changed, 978 insertions(+), 9 deletions(-) create mode 100644 apps/frontend/scripts/verify-linux-packages.cjs create mode 100644 apps/frontend/scripts/verify-linux-packages.test.mjs diff --git a/.github/workflows/beta-release.yml b/.github/workflows/beta-release.yml index 11b4e7b6..85901979 100644 --- a/.github/workflows/beta-release.yml +++ b/.github/workflows/beta-release.yml @@ -447,6 +447,9 @@ jobs: SENTRY_TRACES_SAMPLE_RATE: ${{ secrets.SENTRY_TRACES_SAMPLE_RATE }} SENTRY_PROFILES_SAMPLE_RATE: ${{ secrets.SENTRY_PROFILES_SAMPLE_RATE }} + - name: Verify Linux packages + run: cd apps/frontend && npm run verify:linux + - name: Upload artifacts uses: actions/upload-artifact@v4 with: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 53203f5c..72531e52 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -383,6 +383,9 @@ jobs: SENTRY_TRACES_SAMPLE_RATE: ${{ secrets.SENTRY_TRACES_SAMPLE_RATE }} SENTRY_PROFILES_SAMPLE_RATE: ${{ secrets.SENTRY_PROFILES_SAMPLE_RATE }} + - name: Verify Linux packages + run: cd apps/frontend && npm run verify:linux + - name: Upload artifacts uses: actions/upload-artifact@v4 with: diff --git a/apps/frontend/package.json b/apps/frontend/package.json index 9860bd59..5b25da63 100644 --- a/apps/frontend/package.json +++ b/apps/frontend/package.json @@ -36,6 +36,8 @@ "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", + "verify:linux": "node scripts/verify-linux-packages.cjs dist", + "test:verify-linux": "node --test scripts/verify-linux-packages.test.mjs", "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", @@ -165,14 +167,6 @@ "from": "resources/icon.ico", "to": "icon.ico" }, - { - "from": "python-runtime/${os}-${arch}/python", - "to": "python" - }, - { - "from": "python-runtime/${os}-${arch}/site-packages", - "to": "python-site-packages" - }, { "from": "../backend", "to": "backend", @@ -202,6 +196,16 @@ "target": [ "dmg", "zip" + ], + "extraResources": [ + { + "from": "python-runtime/${os}-${arch}/python", + "to": "python" + }, + { + "from": "python-runtime/${os}-${arch}/site-packages", + "to": "python-site-packages" + } ] }, "win": { @@ -209,6 +213,16 @@ "target": [ "nsis", "zip" + ], + "extraResources": [ + { + "from": "python-runtime/${os}-${arch}/python", + "to": "python" + }, + { + "from": "python-runtime/${os}-${arch}/site-packages", + "to": "python-site-packages" + } ] }, "linux": { @@ -218,7 +232,17 @@ "deb", "flatpak" ], - "category": "Development" + "category": "Development", + "extraResources": [ + { + "from": "python-runtime/${os}-${arch}/python", + "to": "python" + }, + { + "from": "python-runtime/${os}-${arch}/site-packages", + "to": "python-site-packages" + } + ] }, "flatpak": { "runtime": "org.freedesktop.Platform", diff --git a/apps/frontend/scripts/verify-linux-packages.cjs b/apps/frontend/scripts/verify-linux-packages.cjs new file mode 100644 index 00000000..4f3fb2b2 --- /dev/null +++ b/apps/frontend/scripts/verify-linux-packages.cjs @@ -0,0 +1,406 @@ +#!/usr/bin/env node +/** + * Verify Linux package contents to ensure alignment between AppImage, deb, and Flatpak. + * + * This script extracts and inspects each Linux package format to verify that critical + * files (Python binary, backend code, Python packages) are present and correctly bundled. + * + * Usage: node scripts/verify-linux-packages.cjs [dist-dir] + */ + +const fs = require('fs'); +const path = require('path'); +const { spawnSync } = require('child_process'); + +// Critical Python packages that must be present +const CRITICAL_PACKAGES = [ + 'secretstorage', // Linux OAuth token storage + 'pydantic_core', + 'claude_agent_sdk', + 'dotenv', +]; + +// Minimum expected Flatpak file size (50 MB) +// Flatpak files are large OCI archives; anything smaller is suspicious +// Based on observed minimum sizes of valid builds +const FLATPAK_MIN_SIZE_MB = 50; + +// Colors for terminal output +const colors = { + reset: '\x1b[0m', + red: '\x1b[31m', + green: '\x1b[32m', + yellow: '\x1b[33m', + blue: '\x1b[34m', + cyan: '\x1b[36m', +}; + +function log(message, color = colors.reset) { + console.log(`${color}${message}${colors.reset}`); +} + +function logSuccess(message) { + log(`✓ ${message}`, colors.green); +} + +function logError(message) { + log(`✗ ${message}`, colors.red); +} + +function logWarning(message) { + log(`⚠ ${message}`, colors.yellow); +} + +function logInfo(message) { + log(`ℹ ${message}`, colors.cyan); +} + +/** + * Check if a command exists + * Uses 'which' directly without shell interpolation to prevent command injection + */ +function commandExists(cmd) { + const result = spawnSync('which', [cmd], { stdio: 'ignore' }); + return result.status === 0; +} + +/** + * Find all Linux packages in the dist directory + */ +function findPackages(distDir) { + const packages = { + appImage: null, + deb: null, + flatpak: null, + }; + + if (!fs.existsSync(distDir)) { + logError(`Distribution directory not found: ${distDir}`); + return packages; + } + + const files = fs.readdirSync(distDir); + + for (const file of files) { + const fullPath = path.join(distDir, file); + + if (file.endsWith('.AppImage')) { + if (!packages.appImage) { + packages.appImage = fullPath; + } else { + logWarning(`Multiple AppImage files found, using first: ${path.basename(packages.appImage)}`); + } + } else if (file.endsWith('.deb')) { + if (!packages.deb) { + packages.deb = fullPath; + } else { + logWarning(`Multiple deb files found, using first: ${path.basename(packages.deb)}`); + } + } else if (file.endsWith('.flatpak')) { + if (!packages.flatpak) { + packages.flatpak = fullPath; + } else { + logWarning(`Multiple Flatpak files found, using first: ${path.basename(packages.flatpak)}`); + } + } + } + + return packages; +} + +/** + * Common file list verification logic + * @param {string[]} files - List of files from package + * @param {string} packageType - Type of package (for error messages) + * @returns {Object} Verification result with verified flag and issues array + * + * File formats: + * - AppImage (bsdtar): './resources/python', './resources/backend/file.py' + * - deb (dpkg-deb -c): 'resources/python', 'resources/backend/file.py' (in last column) + */ +function verifyFileList(files, packageType) { + const issues = []; + + // Normalize paths by removing trailing slashes (archive tools commonly add these) + const normalizePath = (p) => p.replace(/\/+$/, ''); + + // Check for Python binary directory + // AppImage: './resources/python' or './resources/python/' (with trailing slash) + // deb: 'resources/python' or 'resources/python/' (with trailing slash) + // Must NOT match 'resources/python-site-packages' + const pythonBinFound = files.some((f) => { + const normalized = normalizePath(f); + return ( + (normalized === './resources/python' || + normalized === 'resources/python' || + normalized.endsWith('/resources/python')) && + !f.includes('python-site-packages') + ); + }); + if (!pythonBinFound) { + issues.push(`Python binary directory not found in ${packageType}`); + } + + // Check for backend directory (must be under resources/) + const backendFound = files.some((f) => { + const normalized = normalizePath(f); + return ( + f.includes('./resources/backend/') || + f.includes('resources/backend/') || + normalized === './resources/backend' || + normalized === 'resources/backend' + ); + }); + if (!backendFound) { + issues.push(`Backend directory not found in ${packageType}`); + } + + // Check for critical Python packages (must be under python-site-packages/) + for (const pkg of CRITICAL_PACKAGES) { + // Match: './resources/python-site-packages/secretstorage/__init__.py' + // Match: 'resources/python-site-packages/secretstorage/__init__.py' + // Don't match: '/some/other/path/secretstorage/' + const found = files.some( + (f) => f.includes(`python-site-packages/${pkg}/`) || f.includes(`python-site-packages/${pkg}.`), + ); + if (!found) { + issues.push(`Python package not found: ${pkg}`); + } + } + + return { + verified: issues.length === 0, + issues, + fileCount: files.filter((f) => f.trim()).length, + }; +} + +/** + * Verify AppImage contents using bsdtar (libarchive) + */ +function verifyAppImage(appImagePath) { + logInfo(`Verifying AppImage: ${path.basename(appImagePath)}`); + + // Check if bsdtar is available + if (!commandExists('bsdtar')) { + logWarning('bsdtar not found. Install with: sudo apt-get install libarchive-tools'); + logWarning('Skipping AppImage verification'); + return { verified: false, reason: 'bsdtar not available', critical: true }; + } + + // Extract file list from AppImage using bsdtar + const result = spawnSync('bsdtar', ['-t', '-f', appImagePath], { + stdio: 'pipe', + encoding: 'utf-8', + maxBuffer: 50 * 1024 * 1024, // 50MB buffer for large file listings + }); + + // Check for spawn errors (e.g., permission denied, memory issues) + if (result.error) { + logError(`Failed to execute bsdtar: ${result.error.message}`); + return { verified: false, reason: `Command execution failed: ${result.error.message}` }; + } + + if (result.status !== 0) { + logError(`Failed to read AppImage: ${result.stderr}`); + return { verified: false, reason: 'Failed to extract file list' }; + } + + const files = result.stdout.split('\n'); + return verifyFileList(files, 'AppImage'); +} + +/** + * Verify deb package contents + */ +function verifyDeb(debPath) { + logInfo(`Verifying deb package: ${path.basename(debPath)}`); + + // Check if dpkg is available + if (!commandExists('dpkg-deb')) { + logWarning('dpkg-deb not found. Skipping deb verification'); + return { verified: false, reason: 'dpkg-deb not available', critical: true }; + } + + // List contents of deb package + const result = spawnSync('dpkg-deb', ['-c', debPath], { + stdio: 'pipe', + encoding: 'utf-8', + maxBuffer: 50 * 1024 * 1024, // 50MB buffer for large file listings + }); + + // Check for spawn errors (e.g., permission denied, memory issues) + if (result.error) { + logError(`Failed to execute dpkg-deb: ${result.error.message}`); + return { verified: false, reason: `Command execution failed: ${result.error.message}` }; + } + + if (result.status !== 0) { + logError(`Failed to read deb package: ${result.stderr}`); + return { verified: false, reason: 'Failed to extract file list' }; + } + + const files = result.stdout.split('\n'); + return verifyFileList(files, 'deb package'); +} + +/** + * Verify Flatpak package contents + * Note: Flatpak is more complex to inspect, so we do basic validation + */ +function verifyFlatpak(flatpakPath) { + logInfo(`Verifying Flatpak package: ${path.basename(flatpakPath)}`); + + const issues = []; + + // Check if flatpak command is available for detailed validation + const hasFlatpakCli = commandExists('flatpak'); + if (!hasFlatpakCli) { + logWarning('flatpak command not found. Skipping detailed Flatpak verification'); + // Continue with basic file existence/size checks + } + + // Check if file exists and is not empty + if (!fs.existsSync(flatpakPath)) { + return { verified: false, issues: ['Flatpak file does not exist'] }; + } + + const stats = fs.statSync(flatpakPath); + if (stats.size === 0) { + return { verified: false, issues: ['Flatpak file is empty'] }; + } + + // Flatpak files are large OCI archives, so we just verify file size and basic structure + // Detailed content inspection would require mounting or extracting the flatpak + if (stats.size < FLATPAK_MIN_SIZE_MB * 1024 * 1024) { + // Less than minimum size is suspicious + issues.push( + `Flatpak file seems too small (${(stats.size / 1024 / 1024).toFixed(2)} MB, expected at least ${FLATPAK_MIN_SIZE_MB} MB)`, + ); + } + + return { + verified: issues.length === 0, + issues, + size: stats.size, + }; +} + +/** + * Main verification function + */ +function main() { + const distDir = process.argv[2] || path.join(__dirname, '..', 'dist'); + + log('\n=== Linux Package Verification ===\n', colors.blue); + logInfo(`Distribution directory: ${distDir}\n`); + + const packages = findPackages(distDir); + + // Report found packages + if (packages.appImage) { + logSuccess(`Found AppImage: ${path.basename(packages.appImage)}`); + } else { + logWarning('No AppImage found'); + } + + if (packages.deb) { + logSuccess(`Found deb: ${path.basename(packages.deb)}`); + } else { + logWarning('No deb package found'); + } + + if (packages.flatpak) { + logSuccess(`Found Flatpak: ${path.basename(packages.flatpak)}`); + } else { + logWarning('No Flatpak package found'); + } + + if (!packages.appImage && !packages.deb && !packages.flatpak) { + logError('\nNo Linux packages found to verify!'); + process.exit(1); + } + + log(''); + + // Verify each package + const results = {}; + + if (packages.appImage) { + results.appImage = verifyAppImage(packages.appImage); + } + + if (packages.deb) { + results.deb = verifyDeb(packages.deb); + } + + if (packages.flatpak) { + results.flatpak = verifyFlatpak(packages.flatpak); + } + + // Print results + log('\n=== Verification Results ===\n', colors.blue); + + let hasFailures = false; + let hasCriticalSkips = false; + + for (const [type, result] of Object.entries(results)) { + if (result.reason) { + if (result.critical) { + logError(`${type}: CRITICAL - SKIPPED (${result.reason})`); + hasCriticalSkips = true; + } else { + logWarning(`${type}: SKIPPED (${result.reason})`); + } + } else if (result.verified) { + logSuccess(`${type}: VERIFIED`); + if (result.fileCount) { + logInfo(` Files: ${result.fileCount}`); + } + if (result.size) { + logInfo(` Size: ${(result.size / 1024 / 1024).toFixed(2)} MB`); + } + } else { + logError(`${type}: FAILED`); + hasFailures = true; + for (const issue of result.issues || []) { + logError(` - ${issue}`); + } + } + } + + log(''); + + if (hasFailures || hasCriticalSkips) { + logError('\n=== VERIFICATION FAILED ===\n'); + if (hasFailures) { + log('Some packages are missing critical files. This will cause runtime errors.\n', colors.red); + } + if (hasCriticalSkips) { + log('Some packages could not be verified due to missing required tools.\n', colors.red); + log('Install required tools:\n', colors.red); + log(' - bsdtar: sudo apt-get install libarchive-tools\n', colors.red); + log(' - dpkg-deb: sudo apt-get install dpkg\n', colors.red); + } + process.exit(1); + } else { + logSuccess('\n=== ALL PACKAGES VERIFIED ===\n'); + log('All Linux packages contain the required files.\n', colors.green); + process.exit(0); + } +} + +// Only run main if this file is executed directly (not imported) +if (require.main === module) { + main(); +} + +// Export for testing +module.exports = { + CRITICAL_PACKAGES, + findPackages, + verifyFileList, + verifyAppImage, + verifyDeb, + verifyFlatpak, +}; diff --git a/apps/frontend/scripts/verify-linux-packages.test.mjs b/apps/frontend/scripts/verify-linux-packages.test.mjs new file mode 100644 index 00000000..0d0ebc17 --- /dev/null +++ b/apps/frontend/scripts/verify-linux-packages.test.mjs @@ -0,0 +1,533 @@ +/** + * Tests for verify-linux-packages.cjs + * + * These tests cover the core logic by calling the actual exported functions. + */ + +import { describe, it, mock } from 'node:test'; +import assert from 'node:assert'; +import fs from 'node:fs'; +import { createRequire } from 'node:module'; +import { fileURLToPath } from 'node:url'; +import { dirname } from 'node:path'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +const require = createRequire(import.meta.url); + +// Get child_process and save original spawnSync +const childProcess = require('child_process'); +const originalSpawnSync = childProcess.spawnSync; + +// Helper to reload the verification module with a mocked spawnSync +function loadWithMockedSpawnSync(mockFn) { + // Set the mock before requiring + childProcess.spawnSync = mockFn; + // Clear the module cache + delete require.cache[require.resolve('./verify-linux-packages.cjs')]; + // Re-require the module + return require('./verify-linux-packages.cjs'); +} + +function restoreSpawnSync() { + childProcess.spawnSync = originalSpawnSync; + delete require.cache[require.resolve('./verify-linux-packages.cjs')]; +} + +// Load the module normally for tests that don't need spawnSync mocking +const { + CRITICAL_PACKAGES, + findPackages, + verifyFileList, + verifyFlatpak, +} = require('./verify-linux-packages.cjs'); + +describe('verify-linux-packages', () => { + describe('package finding logic', () => { + it('should identify all three Linux package types', () => { + // Test that findPackages correctly identifies .AppImage, .deb, and .flatpak files + const mockFiles = [ + 'Auto-Claude-2.7.5-linux-x86_64.AppImage', + 'auto-claude_2.7.5_amd64.deb', + 'com.autoclaude.ui_2.7.5_linux_x86_64.flatpak', + 'latest-mac.yml', + 'latest.yml', + ]; + + const distDir = '/test/dist'; + + // Mock fs.existsSync to return true (directory exists) + const existsSync = mock.method(fs, 'existsSync', mock.fn(() => true)); + // Mock fs.readdirSync to return our test files + const readdirSync = mock.method(fs, 'readdirSync', mock.fn(() => mockFiles)); + + try { + const result = findPackages(distDir); + + // Verify the expected results + assert.equal(result.appImage, '/test/dist/Auto-Claude-2.7.5-linux-x86_64.AppImage'); + assert.equal(result.deb, '/test/dist/auto-claude_2.7.5_amd64.deb'); + assert.equal(result.flatpak, '/test/dist/com.autoclaude.ui_2.7.5_linux_x86_64.flatpak'); + } finally { + existsSync.mock.restore(); + readdirSync.mock.restore(); + } + }); + + it('should handle missing packages gracefully', () => { + // Test behavior when packages are missing + const mockFiles = ['latest-mac.yml', 'latest.yml']; + const distDir = '/test/dist'; + + const existsSync = mock.method(fs, 'existsSync', mock.fn(() => true)); + const readdirSync = mock.method(fs, 'readdirSync', mock.fn(() => mockFiles)); + + try { + const result = findPackages(distDir); + + assert.equal(result.appImage, null); + assert.equal(result.deb, null); + assert.equal(result.flatpak, null); + } finally { + existsSync.mock.restore(); + readdirSync.mock.restore(); + } + }); + + it('should handle missing dist directory', () => { + // Test behavior when dist directory doesn't exist + const distDir = '/test/dist'; + + const existsSync = mock.method(fs, 'existsSync', mock.fn(() => false)); + + try { + const result = findPackages(distDir); + + // Should return empty packages object without error + assert.equal(result.appImage, null); + assert.equal(result.deb, null); + assert.equal(result.flatpak, null); + } finally { + existsSync.mock.restore(); + } + }); + + it('should warn about duplicate packages', () => { + // Test behavior when multiple packages of same type exist + const mockFiles = [ + 'Auto-Claude-2.7.5-linux-x86_64.AppImage', + 'Auto-Claude-2.7.5-linux-x86_64.AppImage', // Duplicate + 'auto-claude_2.7.5_amd64.deb', + 'auto-claude_2.7.5_amd64.deb', // Duplicate + 'com.autoclaude.ui_2.7.5_linux_x86_64.flatpak', + ]; + const distDir = '/test/dist'; + + const existsSync = mock.method(fs, 'existsSync', mock.fn(() => true)); + const readdirSync = mock.method(fs, 'readdirSync', mock.fn(() => mockFiles)); + + try { + const result = findPackages(distDir); + + // Should still find packages, using first occurrence + assert.equal(result.appImage, '/test/dist/Auto-Claude-2.7.5-linux-x86_64.AppImage'); + assert.equal(result.deb, '/test/dist/auto-claude_2.7.5_amd64.deb'); + assert.equal(result.flatpak, '/test/dist/com.autoclaude.ui_2.7.5_linux_x86_64.flatpak'); + } finally { + existsSync.mock.restore(); + readdirSync.mock.restore(); + } + }); + }); + + describe('critical packages list', () => { + it('should contain all required Linux packages', () => { + assert.ok(CRITICAL_PACKAGES.includes('secretstorage'), 'secretstorage must be present for Linux OAuth'); + assert.ok(CRITICAL_PACKAGES.includes('pydantic_core'), 'pydantic_core must be present'); + assert.ok(CRITICAL_PACKAGES.includes('claude_agent_sdk'), 'claude_agent_sdk must be present'); + assert.ok(CRITICAL_PACKAGES.includes('dotenv'), 'dotenv must be present'); + }); + }); + + describe('file content verification logic', () => { + it('should detect Python binary in file list', () => { + // AppImage format uses './' prefix + const mockFiles = [ + 'usr/bin/auto-claude', + './resources/python', + './resources/backend/core/client.py', + './resources/python-site-packages/secretstorage/__init__.py', + './resources/python-site-packages/pydantic_core/__init__.py', + './resources/python-site-packages/claude_agent_sdk/__init__.py', + './resources/python-site-packages/dotenv/__init__.py', + ]; + + const result = verifyFileList(mockFiles, 'test-package'); + assert.ok(result.verified, 'Should detect Python binary directory'); + assert.equal(result.issues.length, 0); + }); + + it('should detect Python binary with trailing slashes', () => { + // Archive tools like bsdtar/dpkg-deb commonly output directories with trailing slashes + const mockFiles = [ + 'usr/bin/auto-claude', + './resources/python/', // Trailing slash + 'resources/backend/', // Trailing slash + './resources/python-site-packages/secretstorage/__init__.py', + './resources/python-site-packages/pydantic_core/__init__.py', + './resources/python-site-packages/claude_agent_sdk/__init__.py', + './resources/python-site-packages/dotenv/__init__.py', + ]; + + const result = verifyFileList(mockFiles, 'test-package'); + assert.ok(result.verified, 'Should detect Python binary directory with trailing slash'); + assert.equal(result.issues.length, 0); + }); + + it('should detect backend directory in file list', () => { + const mockFiles = [ + 'usr/bin/auto-claude', + './resources/python', + './resources/backend/core/client.py', + './resources/python-site-packages/secretstorage/__init__.py', + './resources/python-site-packages/pydantic_core/__init__.py', + './resources/python-site-packages/claude_agent_sdk/__init__.py', + './resources/python-site-packages/dotenv/__init__.py', + ]; + + const result = verifyFileList(mockFiles, 'test-package'); + assert.ok(result.verified, 'Should detect backend directory'); + assert.equal(result.issues.length, 0); + }); + + it('should detect critical Python packages', () => { + const mockFiles = [ + 'usr/bin/auto-claude', + './resources/python', + './resources/backend/core/client.py', + './resources/python-site-packages/secretstorage/__init__.py', + './resources/python-site-packages/pydantic_core/__init__.py', + './resources/python-site-packages/claude_agent_sdk/__init__.py', + './resources/python-site-packages/dotenv/__init__.py', + ]; + + const result = verifyFileList(mockFiles, 'test-package'); + assert.ok(result.verified, 'Should detect all critical packages'); + assert.equal(result.issues.length, 0); + }); + + it('should report missing packages', () => { + const mockFiles = [ + 'usr/bin/auto-claude', + './resources/python', + './resources/backend/core/client.py', + './resources/python-site-packages/dotenv/__init__.py', + ]; + + const result = verifyFileList(mockFiles, 'test-package'); + + assert.ok(!result.verified, 'Should fail verification'); + assert.ok(result.issues.includes('Python package not found: secretstorage')); + assert.ok(result.issues.includes('Python package not found: pydantic_core')); + assert.ok(result.issues.includes('Python package not found: claude_agent_sdk')); + assert.ok(!result.issues.some((i) => i.includes('dotenv'))); + }); + + it('should not match python-site-packages when looking for python binary', () => { + const mockFiles = [ + 'usr/bin/auto-claude', + './resources/python-site-packages/secretstorage/__init__.py', + './resources/python-site-packages/pydantic_core/__init__.py', + './resources/python-site-packages/claude_agent_sdk/__init__.py', + './resources/python-site-packages/dotenv/__init__.py', + // Note: NO './resources/python' entry + ]; + + const result = verifyFileList(mockFiles, 'test-package'); + + assert.ok(!result.verified, 'Should fail verification'); + assert.ok(result.issues.some((i) => i.includes('Python binary directory not found'))); + }); + + it('should not match unrelated paths when looking for packages', () => { + const mockFiles = [ + 'usr/bin/auto-claude', + './resources/python', + './resources/backend/core/client.py', + // These paths end with package names but are NOT under python-site-packages + './some/other/path/secretstorage/file.txt', + './unrelated/dotenv/config', + './another/pydantic_core/standalone/__init__.py', + ]; + + const result = verifyFileList(mockFiles, 'test-package'); + + assert.ok(!result.verified, 'Should fail verification'); + assert.ok(result.issues.some((i) => i.includes('Python package not found: secretstorage'))); + }); + }); + + describe('Flatpak file validation', () => { + it('should reject empty Flatpak files', () => { + const flatpakPath = '/test/app.flatpak'; + const mockStat = { size: 0 }; + + // Mock fs.existsSync to return true + const existsSync = mock.method(fs, 'existsSync', mock.fn(() => true)); + // Mock fs.statSync to return empty file stats + const statSync = mock.method(fs, 'statSync', mock.fn(() => mockStat)); + + try { + const result = verifyFlatpak(flatpakPath); + + assert.ok(!result.verified, 'Should reject empty Flatpak files'); + assert.ok(result.issues.includes('Flatpak file is empty')); + } finally { + existsSync.mock.restore(); + statSync.mock.restore(); + } + }); + + it('should warn about suspiciously small Flatpak files', () => { + const flatpakPath = '/test/app.flatpak'; + const mockStat = { size: 10 * 1024 * 1024 }; // 10 MB + + const existsSync = mock.method(fs, 'existsSync', mock.fn(() => true)); + const statSync = mock.method(fs, 'statSync', mock.fn(() => mockStat)); + + try { + const result = verifyFlatpak(flatpakPath); + + assert.ok(!result.verified, 'Should fail verification for too-small files'); + assert.ok(result.issues.some((i) => i.includes('too small'))); + } finally { + existsSync.mock.restore(); + statSync.mock.restore(); + } + }); + + it('should accept reasonable Flatpak file sizes', () => { + const flatpakPath = '/test/app.flatpak'; + const mockStat = { size: 133 * 1024 * 1024 }; // 133 MB (typical size) + + const existsSync = mock.method(fs, 'existsSync', mock.fn(() => true)); + const statSync = mock.method(fs, 'statSync', mock.fn(() => mockStat)); + + try { + const result = verifyFlatpak(flatpakPath); + + assert.ok(result.verified, 'Should accept reasonable Flatpak file sizes'); + assert.equal(result.issues.length, 0); + } finally { + existsSync.mock.restore(); + statSync.mock.restore(); + } + }); + + it('should handle non-existent Flatpak files', () => { + const flatpakPath = '/test/nonexistent.flatpak'; + + const existsSync = mock.method(fs, 'existsSync', mock.fn(() => false)); + + try { + const result = verifyFlatpak(flatpakPath); + + assert.ok(!result.verified, 'Should reject non-existent Flatpak files'); + assert.ok(result.issues.includes('Flatpak file does not exist')); + } finally { + existsSync.mock.restore(); + } + }); + }); + + describe('AppImage verification', () => { + const appImagePath = '/test/Auto-Claude-2.7.5-linux-x86_64.AppImage'; + + it('should successfully verify valid AppImage', () => { + const mockFiles = [ + './resources/python', + './resources/backend/core/client.py', + './resources/python-site-packages/secretstorage/__init__.py', + './resources/python-site-packages/pydantic_core/__init__.py', + './resources/python-site-packages/claude_agent_sdk/__init__.py', + './resources/python-site-packages/dotenv/__init__.py', + ]; + + const mockFn = (cmd, args) => { + if (cmd === 'which' && args[0] === 'bsdtar') { + return { status: 0, stdout: '/usr/bin/bsdtar', stderr: '' }; + } + if (cmd === 'bsdtar') { + return { status: 0, stdout: mockFiles.join('\n'), stderr: '', error: undefined }; + } + return { status: 1, stderr: 'Unknown command' }; + }; + + const { verifyAppImage } = loadWithMockedSpawnSync(mockFn); + const result = verifyAppImage(appImagePath); + + restoreSpawnSync(); + + assert.ok(result.verified, 'Should verify valid AppImage'); + assert.equal(result.issues.length, 0); + assert.equal(result.fileCount, mockFiles.length); + }); + + it('should handle spawn errors (OS-level failures)', () => { + const spawnError = new Error('EACCES: permission denied'); + + const mockFn = (cmd) => { + if (cmd === 'which') { + return { status: 0, stdout: '/usr/bin/bsdtar', stderr: '', error: undefined }; + } + if (cmd === 'bsdtar') { + return { status: null, stdout: '', stderr: '', error: spawnError }; + } + return { status: 1, stderr: 'Unknown command' }; + }; + + const { verifyAppImage } = loadWithMockedSpawnSync(mockFn); + const result = verifyAppImage(appImagePath); + + restoreSpawnSync(); + + assert.ok(!result.verified, 'Should fail on spawn error'); + assert.ok(result.reason.includes('Command execution failed')); + assert.ok(result.reason.includes('permission denied')); + }); + + it('should handle non-zero exit status from bsdtar', () => { + const mockFn = (cmd) => { + if (cmd === 'which') { + return { status: 0, stdout: '/usr/bin/bsdtar', stderr: '', error: undefined }; + } + if (cmd === 'bsdtar') { + return { status: 1, stdout: '', stderr: 'bsdtar: Error: Not an AppImage file', error: undefined }; + } + return { status: 1, stderr: 'Unknown command' }; + }; + + const { verifyAppImage } = loadWithMockedSpawnSync(mockFn); + const result = verifyAppImage(appImagePath); + + restoreSpawnSync(); + + assert.ok(!result.verified, 'Should fail on non-zero status'); + assert.equal(result.reason, 'Failed to extract file list'); + }); + + it('should handle missing bsdtar tool', () => { + const mockFn = (cmd) => { + if (cmd === 'which') { + return { status: 1, stdout: '', stderr: '', error: undefined }; + } + return { status: 1, stderr: 'Unknown command' }; + }; + + const { verifyAppImage } = loadWithMockedSpawnSync(mockFn); + const result = verifyAppImage(appImagePath); + + restoreSpawnSync(); + + assert.ok(!result.verified, 'Should fail when bsdtar is missing'); + assert.equal(result.reason, 'bsdtar not available'); + assert.ok(result.critical, 'Should be marked as critical'); + }); + }); + + describe('deb package verification', () => { + const debPath = '/test/auto-claude_2.7.5_amd64.deb'; + + it('should successfully verify valid deb package', () => { + const mockFiles = [ + 'drwxr-xr-x root/root 0 2025-01-01 00:00 ./resources/python', + '-rw-r--r-- root/root 1234 2025-01-01 00:00 ./resources/backend/core/client.py', + '-rw-r--r-- root/root 567 2025-01-01 00:00 ./resources/python-site-packages/secretstorage/__init__.py', + '-rw-r--r-- root/root 789 2025-01-01 00:00 ./resources/python-site-packages/pydantic_core/__init__.py', + '-rw-r--r-- root/root 456 2025-01-01 00:00 ./resources/python-site-packages/claude_agent_sdk/__init__.py', + '-rw-r--r-- root/root 321 2025-01-01 00:00 ./resources/python-site-packages/dotenv/__init__.py', + ]; + + const mockFn = (cmd, args) => { + if (cmd === 'which' && args[0] === 'dpkg-deb') { + return { status: 0, stdout: '/usr/bin/dpkg-deb', stderr: '', error: undefined }; + } + if (cmd === 'dpkg-deb') { + return { status: 0, stdout: mockFiles.join('\n'), stderr: '', error: undefined }; + } + return { status: 1, stderr: 'Unknown command' }; + }; + + const { verifyDeb } = loadWithMockedSpawnSync(mockFn); + const result = verifyDeb(debPath); + + restoreSpawnSync(); + + assert.ok(result.verified, 'Should verify valid deb package'); + assert.equal(result.issues.length, 0); + assert.equal(result.fileCount, mockFiles.length); + }); + + it('should handle spawn errors (OS-level failures)', () => { + const spawnError = new Error('ENOMEM: Cannot allocate memory'); + + const mockFn = (cmd) => { + if (cmd === 'which') { + return { status: 0, stdout: '/usr/bin/dpkg-deb', stderr: '', error: undefined }; + } + if (cmd === 'dpkg-deb') { + return { status: null, stdout: '', stderr: '', error: spawnError }; + } + return { status: 1, stderr: 'Unknown command' }; + }; + + const { verifyDeb } = loadWithMockedSpawnSync(mockFn); + const result = verifyDeb(debPath); + + restoreSpawnSync(); + + assert.ok(!result.verified, 'Should fail on spawn error'); + assert.ok(result.reason.includes('Command execution failed')); + assert.ok(result.reason.includes('Cannot allocate memory')); + }); + + it('should handle non-zero exit status from dpkg-deb', () => { + const mockFn = (cmd) => { + if (cmd === 'which') { + return { status: 0, stdout: '/usr/bin/dpkg-deb', stderr: '', error: undefined }; + } + if (cmd === 'dpkg-deb') { + return { status: 2, stdout: '', stderr: 'dpkg-deb: error: cannot read archive', error: undefined }; + } + return { status: 1, stderr: 'Unknown command' }; + }; + + const { verifyDeb } = loadWithMockedSpawnSync(mockFn); + const result = verifyDeb(debPath); + + restoreSpawnSync(); + + assert.ok(!result.verified, 'Should fail on non-zero status'); + assert.equal(result.reason, 'Failed to extract file list'); + }); + + it('should handle missing dpkg-deb tool', () => { + const mockFn = (cmd) => { + if (cmd === 'which') { + return { status: 1, stdout: '', stderr: '', error: undefined }; + } + return { status: 1, stderr: 'Unknown command' }; + }; + + const { verifyDeb } = loadWithMockedSpawnSync(mockFn); + const result = verifyDeb(debPath); + + restoreSpawnSync(); + + assert.ok(!result.verified, 'Should fail when dpkg-deb is missing'); + assert.equal(result.reason, 'dpkg-deb not available'); + assert.ok(result.critical, 'Should be marked as critical'); + }); + }); +});