Map of tool name → absolute path or null. */ public static function detect(): array { static $cache = null; if ( null !== $cache ) { return $cache; } $cache = []; foreach ( array_merge( self::REQUIRED, self::OPTIONAL ) as $bin ) { $cache[ $bin ] = self::which( $bin ); } return $cache; } public static function path( string $bin ): ?string { $tools = self::detect(); return $tools[ $bin ] ?? null; } public static function missing_required(): array { $tools = self::detect(); $missing = []; foreach ( self::REQUIRED as $bin ) { if ( empty( $tools[ $bin ] ) ) { $missing[] = $bin; } } return $missing; } public static function has_avif(): bool { return ! empty( self::path( 'avifenc' ) ); } private static function which( string $bin ): ?string { // Common locations first, then fall back to PATH lookup. foreach ( [ '/usr/bin/', '/usr/local/bin/', '/opt/local/bin/' ] as $prefix ) { $candidate = $prefix . $bin; if ( is_executable( $candidate ) ) { return $candidate; } } if ( ! function_exists( 'shell_exec' ) ) { return null; } $out = trim( (string) @shell_exec( 'command -v ' . escapeshellarg( $bin ) . ' 2>/dev/null' ) ); return $out !== '' && is_executable( $out ) ? $out : null; } }