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 <stillknotknown@users.noreply.github.com>
Co-authored-by: Andy <119136210+AndyMik90@users.noreply.github.com>
This commit is contained in:
StillKnotKnown
2026-01-30 14:09:23 +02:00
committed by GitHub
parent cd423c65c7
commit bad1a9b2c4
5 changed files with 978 additions and 9 deletions
+3
View File
@@ -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:
+3
View File
@@ -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:
+33 -9
View File
@@ -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",
@@ -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,
};
@@ -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');
});
});
});