mirror of
https://github.com/tw93/Mole.git
synced 2026-02-04 15:04:42 +00:00
feat: implement installer cleanup functionality, add ZIP and file descriptor installer tests, and update README
This commit is contained in:
27
README.md
27
README.md
@@ -104,11 +104,11 @@ $ mo uninstall
|
|||||||
|
|
||||||
Select Apps to Remove
|
Select Apps to Remove
|
||||||
═══════════════════════════
|
═══════════════════════════
|
||||||
▶ ☑ Adobe Creative Cloud (9.4G) | Old
|
▶ ☑ Photoshop 2024 (4.2G) | Old
|
||||||
☐ WeChat (2.1G) | Recent
|
☐ IntelliJ IDEA (2.8G) | Recent
|
||||||
☐ Final Cut Pro (3.8G) | Recent
|
☐ Premiere Pro (3.4G) | Recent
|
||||||
|
|
||||||
Uninstalling: Adobe Creative Cloud
|
Uninstalling: Photoshop 2024
|
||||||
|
|
||||||
✓ Removed application
|
✓ Removed application
|
||||||
✓ Cleaned 52 related files across 12 locations
|
✓ Cleaned 52 related files across 12 locations
|
||||||
@@ -155,7 +155,7 @@ Analyze Disk ~/Documents | Total: 156.8GB
|
|||||||
4. ███░░░░░░░░░░░░░░░░ 10.8% | 📁 Documents 16.9GB
|
4. ███░░░░░░░░░░░░░░░░ 10.8% | 📁 Documents 16.9GB
|
||||||
5. ██░░░░░░░░░░░░░░░░░ 5.2% | 📄 backup_2023.zip 8.2GB
|
5. ██░░░░░░░░░░░░░░░░░ 5.2% | 📄 backup_2023.zip 8.2GB
|
||||||
|
|
||||||
↑↓←→ Navigate | O Open | F Show | ⌫ Delete | L Large(24) | Q Quit
|
↑↓←→ Navigate | O Open | F Show | ⌫ Delete | L Large files | Q Quit
|
||||||
```
|
```
|
||||||
|
|
||||||
### Live System Status
|
### Live System Status
|
||||||
@@ -223,6 +223,23 @@ When custom paths are configured, only those directories are scanned. Otherwise,
|
|||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
|
### Installer Cleanup
|
||||||
|
|
||||||
|
Find and remove large installer files scattered across Downloads, Desktop, Homebrew caches, iCloud, and Mail. Each file is labeled by source to help you know where the space is hiding.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mo installer
|
||||||
|
|
||||||
|
Select Installers to Remove - 3.8GB (5 selected)
|
||||||
|
|
||||||
|
➤ ● Photoshop_2024.dmg 1.2GB | Downloads
|
||||||
|
● IntelliJ_IDEA.dmg 850.6MB | Downloads
|
||||||
|
● Illustrator_Setup.pkg 920.4MB | Downloads
|
||||||
|
● PyCharm_Pro.dmg 640.5MB | Homebrew
|
||||||
|
● Acrobat_Reader.dmg 220.4MB | Downloads
|
||||||
|
○ AppCode_Legacy.zip 410.6MB | Downloads
|
||||||
|
```
|
||||||
|
|
||||||
## Quick Launchers
|
## Quick Launchers
|
||||||
|
|
||||||
Launch Mole commands instantly from Raycast or Alfred:
|
Launch Mole commands instantly from Raycast or Alfred:
|
||||||
|
|||||||
510
bin/installer.sh
510
bin/installer.sh
@@ -1,6 +1,6 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# Mole - Installer command
|
# Mole - Installer command
|
||||||
# Find and remove installer files (.dmg, .pkg, .mpkg, .iso, .xip, .zip)
|
# Find and remove installer files - .dmg, .pkg, .mpkg, .iso, .xip, .zip
|
||||||
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
@@ -33,18 +33,32 @@ readonly INSTALLER_SCAN_PATHS=(
|
|||||||
"$HOME/Public"
|
"$HOME/Public"
|
||||||
"$HOME/Library/Downloads"
|
"$HOME/Library/Downloads"
|
||||||
"/Users/Shared"
|
"/Users/Shared"
|
||||||
"/Users/Shared/Downloads" # Search one level deeper
|
"/Users/Shared/Downloads"
|
||||||
|
"$HOME/Library/Caches/Homebrew"
|
||||||
|
"$HOME/Library/Mobile Documents/com~apple~CloudDocs/Downloads"
|
||||||
|
"$HOME/Library/Containers/com.apple.mail/Data/Library/Mail Downloads"
|
||||||
|
"$HOME/Library/Application Support/Telegram Desktop"
|
||||||
|
"$HOME/Downloads/Telegram Desktop"
|
||||||
)
|
)
|
||||||
readonly MAX_ZIP_ENTRIES=5
|
readonly MAX_ZIP_ENTRIES=5
|
||||||
|
ZIP_LIST_CMD=()
|
||||||
|
|
||||||
# Check for installer payloads inside ZIP (single pass, fused size and pattern check)
|
if command -v zipinfo > /dev/null 2>&1; then
|
||||||
|
ZIP_LIST_CMD=(zipinfo -1)
|
||||||
|
elif command -v unzip > /dev/null 2>&1; then
|
||||||
|
ZIP_LIST_CMD=(unzip -Z -1)
|
||||||
|
fi
|
||||||
|
|
||||||
|
TERMINAL_WIDTH=0
|
||||||
|
|
||||||
|
# Check for installer payloads inside ZIP - single pass, fused size and pattern check
|
||||||
is_installer_zip() {
|
is_installer_zip() {
|
||||||
local zip="$1"
|
local zip="$1"
|
||||||
local cap="$MAX_ZIP_ENTRIES"
|
local cap="$MAX_ZIP_ENTRIES"
|
||||||
|
|
||||||
zipinfo -1 "$zip" >/dev/null 2>&1 || return 1
|
[[ ${#ZIP_LIST_CMD[@]} -gt 0 ]] || return 1
|
||||||
|
|
||||||
zipinfo -1 "$zip" 2>/dev/null \
|
if ! "${ZIP_LIST_CMD[@]}" "$zip" 2>/dev/null \
|
||||||
| head -n $((cap + 1)) \
|
| head -n $((cap + 1)) \
|
||||||
| awk -v cap="$cap" '
|
| awk -v cap="$cap" '
|
||||||
/\.(app|pkg|dmg|xip)(\/|$)/ { found=1 }
|
/\.(app|pkg|dmg|xip)(\/|$)/ { found=1 }
|
||||||
@@ -52,7 +66,28 @@ is_installer_zip() {
|
|||||||
if (NR > cap) exit 1
|
if (NR > cap) exit 1
|
||||||
exit found ? 0 : 1
|
exit found ? 0 : 1
|
||||||
}
|
}
|
||||||
'
|
'; then
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
handle_candidate_file() {
|
||||||
|
local file="$1"
|
||||||
|
|
||||||
|
[[ -L "$file" ]] && return 0 # Skip symlinks explicitly
|
||||||
|
case "$file" in
|
||||||
|
*.dmg|*.pkg|*.mpkg|*.iso|*.xip)
|
||||||
|
echo "$file"
|
||||||
|
;;
|
||||||
|
*.zip)
|
||||||
|
[[ -r "$file" ]] || return 0
|
||||||
|
if is_installer_zip "$file" 2>/dev/null; then
|
||||||
|
echo "$file"
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
esac
|
||||||
}
|
}
|
||||||
|
|
||||||
scan_installers_in_path() {
|
scan_installers_in_path() {
|
||||||
@@ -65,18 +100,7 @@ scan_installers_in_path() {
|
|||||||
|
|
||||||
if command -v fd > /dev/null 2>&1; then
|
if command -v fd > /dev/null 2>&1; then
|
||||||
while IFS= read -r file; do
|
while IFS= read -r file; do
|
||||||
[[ -L "$file" ]] && continue # Skip symlinks explicitly
|
handle_candidate_file "$file"
|
||||||
case "$file" in
|
|
||||||
*.dmg|*.pkg|*.mpkg|*.iso|*.xip)
|
|
||||||
echo "$file"
|
|
||||||
;;
|
|
||||||
*.zip)
|
|
||||||
[[ -r "$file" ]] || continue
|
|
||||||
if is_installer_zip "$file" 2>/dev/null; then
|
|
||||||
echo "$file"
|
|
||||||
fi
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
done < <(
|
done < <(
|
||||||
fd --no-ignore --hidden --type f --max-depth "$max_depth" \
|
fd --no-ignore --hidden --type f --max-depth "$max_depth" \
|
||||||
-e dmg -e pkg -e mpkg -e iso -e xip -e zip \
|
-e dmg -e pkg -e mpkg -e iso -e xip -e zip \
|
||||||
@@ -84,18 +108,7 @@ scan_installers_in_path() {
|
|||||||
)
|
)
|
||||||
else
|
else
|
||||||
while IFS= read -r file; do
|
while IFS= read -r file; do
|
||||||
[[ -L "$file" ]] && continue # Skip symlinks explicitly
|
handle_candidate_file "$file"
|
||||||
case "$file" in
|
|
||||||
*.dmg|*.pkg|*.mpkg|*.iso|*.xip)
|
|
||||||
echo "$file"
|
|
||||||
;;
|
|
||||||
*.zip)
|
|
||||||
[[ -r "$file" ]] || continue
|
|
||||||
if is_installer_zip "$file" 2>/dev/null; then
|
|
||||||
echo "$file"
|
|
||||||
fi
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
done < <(
|
done < <(
|
||||||
find "$path" -maxdepth "$max_depth" -type f \
|
find "$path" -maxdepth "$max_depth" -type f \
|
||||||
\( -name '*.dmg' -o -name '*.pkg' -o -name '*.mpkg' \
|
\( -name '*.dmg' -o -name '*.pkg' -o -name '*.mpkg' \
|
||||||
@@ -118,33 +131,100 @@ declare -i total_size_freed_kb=0
|
|||||||
# Global arrays for installer data
|
# Global arrays for installer data
|
||||||
declare -a INSTALLER_PATHS=()
|
declare -a INSTALLER_PATHS=()
|
||||||
declare -a INSTALLER_SIZES=()
|
declare -a INSTALLER_SIZES=()
|
||||||
|
declare -a INSTALLER_SOURCES=()
|
||||||
declare -a DISPLAY_NAMES=()
|
declare -a DISPLAY_NAMES=()
|
||||||
|
|
||||||
|
# Get source directory display name - for example "Downloads" or "Desktop"
|
||||||
|
get_source_display() {
|
||||||
|
local file_path="$1"
|
||||||
|
local dir_path="${file_path%/*}"
|
||||||
|
|
||||||
|
# Match against known paths and return friendly names
|
||||||
|
case "$dir_path" in
|
||||||
|
"$HOME/Downloads"*) echo "Downloads" ;;
|
||||||
|
"$HOME/Desktop"*) echo "Desktop" ;;
|
||||||
|
"$HOME/Documents"*) echo "Documents" ;;
|
||||||
|
"$HOME/Public"*) echo "Public" ;;
|
||||||
|
"$HOME/Library/Downloads"*) echo "Library" ;;
|
||||||
|
"/Users/Shared"*) echo "Shared" ;;
|
||||||
|
"$HOME/Library/Caches/Homebrew"*) echo "Homebrew" ;;
|
||||||
|
"$HOME/Library/Mobile Documents/com~apple~CloudDocs/Downloads"*) echo "iCloud" ;;
|
||||||
|
"$HOME/Library/Containers/com.apple.mail"*) echo "Mail" ;;
|
||||||
|
*"Telegram Desktop"*) echo "Telegram" ;;
|
||||||
|
*) echo "${dir_path##*/}" ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
get_terminal_width() {
|
||||||
|
if [[ $TERMINAL_WIDTH -le 0 ]]; then
|
||||||
|
TERMINAL_WIDTH=$(tput cols 2>/dev/null || echo 80)
|
||||||
|
fi
|
||||||
|
echo "$TERMINAL_WIDTH"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Format installer display with alignment - similar to purge command
|
||||||
|
format_installer_display() {
|
||||||
|
local filename="$1"
|
||||||
|
local size_str="$2"
|
||||||
|
local source="$3"
|
||||||
|
|
||||||
|
# Terminal width for alignment
|
||||||
|
local terminal_width
|
||||||
|
terminal_width=$(get_terminal_width)
|
||||||
|
local fixed_width=24 # Reserve for size and source
|
||||||
|
local available_width=$((terminal_width - fixed_width))
|
||||||
|
|
||||||
|
# Bounds check: 20-40 chars for filename
|
||||||
|
[[ $available_width -lt 20 ]] && available_width=20
|
||||||
|
[[ $available_width -gt 40 ]] && available_width=40
|
||||||
|
|
||||||
|
# Truncate filename if needed
|
||||||
|
local truncated_name
|
||||||
|
truncated_name=$(truncate_by_display_width "$filename" "$available_width")
|
||||||
|
local current_width
|
||||||
|
current_width=$(get_display_width "$truncated_name")
|
||||||
|
local char_count=${#truncated_name}
|
||||||
|
local padding=$((available_width - current_width))
|
||||||
|
local printf_width=$((char_count + padding))
|
||||||
|
|
||||||
|
# Format: "filename size | source"
|
||||||
|
printf "%-*s %8s | %-10s" "$printf_width" "$truncated_name" "$size_str" "$source"
|
||||||
|
}
|
||||||
|
|
||||||
# Collect all installers with their metadata
|
# Collect all installers with their metadata
|
||||||
collect_installers() {
|
collect_installers() {
|
||||||
printf '\n'
|
|
||||||
echo -e "${BLUE}━━━ Scanning for installers ━━━${NC}"
|
|
||||||
|
|
||||||
# Clear previous results
|
# Clear previous results
|
||||||
INSTALLER_PATHS=()
|
INSTALLER_PATHS=()
|
||||||
INSTALLER_SIZES=()
|
INSTALLER_SIZES=()
|
||||||
|
INSTALLER_SOURCES=()
|
||||||
DISPLAY_NAMES=()
|
DISPLAY_NAMES=()
|
||||||
|
|
||||||
|
# Start scanning with spinner
|
||||||
|
if [[ -t 1 ]]; then
|
||||||
|
start_inline_spinner "Scanning for installers..."
|
||||||
|
fi
|
||||||
|
|
||||||
# Scan all paths, deduplicate, and sort results
|
# Scan all paths, deduplicate, and sort results
|
||||||
local -a all_files=()
|
local -a all_files=()
|
||||||
local sorted_paths
|
|
||||||
sorted_paths=$(scan_all_installers | sort -u)
|
|
||||||
|
|
||||||
if [[ -z "$sorted_paths" ]]; then
|
|
||||||
echo -e " ${YELLOW}No installer files found${NC}"
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Read sorted results into array
|
|
||||||
while IFS= read -r file; do
|
while IFS= read -r file; do
|
||||||
[[ -z "$file" ]] && continue
|
[[ -z "$file" ]] && continue
|
||||||
all_files+=("$file")
|
all_files+=("$file")
|
||||||
done <<< "$sorted_paths"
|
done < <(scan_all_installers | sort -u)
|
||||||
|
|
||||||
|
if [[ -t 1 ]]; then
|
||||||
|
stop_inline_spinner
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ ${#all_files[@]} -eq 0 ]]; then
|
||||||
|
echo -e "${GREEN}${ICON_SUCCESS}${NC} Great! No installer files to clean"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Calculate sizes with spinner
|
||||||
|
if [[ -t 1 ]]; then
|
||||||
|
start_inline_spinner "Calculating sizes..."
|
||||||
|
fi
|
||||||
|
|
||||||
# Process each installer
|
# Process each installer
|
||||||
for file in "${all_files[@]}"; do
|
for file in "${all_files[@]}"; do
|
||||||
@@ -154,16 +234,253 @@ collect_installers() {
|
|||||||
file_size=$(get_file_size "$file")
|
file_size=$(get_file_size "$file")
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Store installer path and size in parallel arrays
|
# Get source directory
|
||||||
|
local source
|
||||||
|
source=$(get_source_display "$file")
|
||||||
|
|
||||||
|
# Format human readable size
|
||||||
|
local size_human
|
||||||
|
size_human=$(bytes_to_human "$file_size")
|
||||||
|
|
||||||
|
# Format display with alignment
|
||||||
|
local display
|
||||||
|
display=$(format_installer_display "$(basename "$file")" "$size_human" "$source")
|
||||||
|
|
||||||
|
# Store installer data in parallel arrays
|
||||||
INSTALLER_PATHS+=("$file")
|
INSTALLER_PATHS+=("$file")
|
||||||
INSTALLER_SIZES+=("$file_size")
|
INSTALLER_SIZES+=("$file_size")
|
||||||
DISPLAY_NAMES+=("$(basename "$file")")
|
INSTALLER_SOURCES+=("$source")
|
||||||
|
DISPLAY_NAMES+=("$display")
|
||||||
done
|
done
|
||||||
|
|
||||||
echo -e " ${GREEN}Found ${#INSTALLER_PATHS[@]} installer(s)${NC}"
|
if [[ -t 1 ]]; then
|
||||||
|
stop_inline_spinner
|
||||||
|
fi
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Installer selector with Select All / Invert support
|
||||||
|
select_installers() {
|
||||||
|
local -a items=("$@")
|
||||||
|
local total_items=${#items[@]}
|
||||||
|
local clear_line=$'\r\033[2K'
|
||||||
|
|
||||||
|
if [[ $total_items -eq 0 ]]; then
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Calculate items per page based on terminal height
|
||||||
|
_get_items_per_page() {
|
||||||
|
local term_height=24
|
||||||
|
if [[ -t 0 ]] || [[ -t 2 ]]; then
|
||||||
|
term_height=$(stty size </dev/tty 2>/dev/null | awk '{print $1}')
|
||||||
|
fi
|
||||||
|
if [[ -z "$term_height" || $term_height -le 0 ]]; then
|
||||||
|
if command -v tput > /dev/null 2>&1; then
|
||||||
|
term_height=$(tput lines 2>/dev/null || echo "24")
|
||||||
|
else
|
||||||
|
term_height=24
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
local reserved=6
|
||||||
|
local available=$((term_height - reserved))
|
||||||
|
if [[ $available -lt 3 ]]; then
|
||||||
|
echo 3
|
||||||
|
elif [[ $available -gt 50 ]]; then
|
||||||
|
echo 50
|
||||||
|
else
|
||||||
|
echo "$available"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
local items_per_page=$(_get_items_per_page)
|
||||||
|
local cursor_pos=0
|
||||||
|
local top_index=0
|
||||||
|
|
||||||
|
# Initialize selection (all unselected by default)
|
||||||
|
local -a selected=()
|
||||||
|
for ((i = 0; i < total_items; i++)); do
|
||||||
|
selected[i]=false
|
||||||
|
done
|
||||||
|
|
||||||
|
local original_stty=""
|
||||||
|
if [[ -t 0 ]] && command -v stty > /dev/null 2>&1; then
|
||||||
|
original_stty=$(stty -g 2>/dev/null || echo "")
|
||||||
|
fi
|
||||||
|
|
||||||
|
restore_terminal() {
|
||||||
|
trap - EXIT INT TERM
|
||||||
|
show_cursor
|
||||||
|
if [[ -n "${original_stty:-}" ]]; then
|
||||||
|
stty "${original_stty}" 2>/dev/null || stty sane 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
handle_interrupt() {
|
||||||
|
restore_terminal
|
||||||
|
exit 130
|
||||||
|
}
|
||||||
|
|
||||||
|
draw_menu() {
|
||||||
|
items_per_page=$(_get_items_per_page)
|
||||||
|
|
||||||
|
local max_top_index=0
|
||||||
|
if [[ $total_items -gt $items_per_page ]]; then
|
||||||
|
max_top_index=$((total_items - items_per_page))
|
||||||
|
fi
|
||||||
|
if [[ $top_index -gt $max_top_index ]]; then
|
||||||
|
top_index=$max_top_index
|
||||||
|
fi
|
||||||
|
if [[ $top_index -lt 0 ]]; then
|
||||||
|
top_index=0
|
||||||
|
fi
|
||||||
|
|
||||||
|
local visible_count=$((total_items - top_index))
|
||||||
|
[[ $visible_count -gt $items_per_page ]] && visible_count=$items_per_page
|
||||||
|
if [[ $cursor_pos -gt $((visible_count - 1)) ]]; then
|
||||||
|
cursor_pos=$((visible_count - 1))
|
||||||
|
fi
|
||||||
|
if [[ $cursor_pos -lt 0 ]]; then
|
||||||
|
cursor_pos=0
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf "\033[H"
|
||||||
|
|
||||||
|
# Calculate selected size and count
|
||||||
|
local selected_size=0
|
||||||
|
local selected_count=0
|
||||||
|
for ((i = 0; i < total_items; i++)); do
|
||||||
|
if [[ ${selected[i]} == true ]]; then
|
||||||
|
selected_size=$((selected_size + ${INSTALLER_SIZES[i]:-0}))
|
||||||
|
((selected_count++))
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
local selected_human
|
||||||
|
selected_human=$(bytes_to_human "$selected_size")
|
||||||
|
|
||||||
|
# Show position indicator if scrolling is needed
|
||||||
|
local scroll_indicator=""
|
||||||
|
if [[ $total_items -gt $items_per_page ]]; then
|
||||||
|
local current_pos=$((top_index + cursor_pos + 1))
|
||||||
|
scroll_indicator=" ${GRAY}[${current_pos}/${total_items}]${NC}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf "${PURPLE_BOLD}Select Installers to Remove${NC}%s ${GRAY}- ${selected_human} ($selected_count selected)${NC}\n" "$scroll_indicator"
|
||||||
|
printf "%s\n" "$clear_line"
|
||||||
|
|
||||||
|
# Calculate visible range
|
||||||
|
local end_index=$((top_index + visible_count))
|
||||||
|
|
||||||
|
# Draw only visible items
|
||||||
|
for ((i = top_index; i < end_index; i++)); do
|
||||||
|
local checkbox="$ICON_EMPTY"
|
||||||
|
[[ ${selected[i]} == true ]] && checkbox="$ICON_SOLID"
|
||||||
|
local rel_pos=$((i - top_index))
|
||||||
|
if [[ $rel_pos -eq $cursor_pos ]]; then
|
||||||
|
printf "%s${CYAN}${ICON_ARROW} %s %s${NC}\n" "$clear_line" "$checkbox" "${items[i]}"
|
||||||
|
else
|
||||||
|
printf "%s %s %s\n" "$clear_line" "$checkbox" "${items[i]}"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# Fill empty slots
|
||||||
|
local items_shown=$visible_count
|
||||||
|
for ((i = items_shown; i < items_per_page; i++)); do
|
||||||
|
printf "%s\n" "$clear_line"
|
||||||
|
done
|
||||||
|
|
||||||
|
printf "%s\n" "$clear_line"
|
||||||
|
printf "%s${GRAY}${ICON_NAV_UP}${ICON_NAV_DOWN} | Space Select | Enter Confirm | A All | I Invert | Q Quit${NC}\n" "$clear_line"
|
||||||
|
}
|
||||||
|
|
||||||
|
trap restore_terminal EXIT
|
||||||
|
trap handle_interrupt INT TERM
|
||||||
|
stty -echo -icanon intr ^C 2>/dev/null || true
|
||||||
|
hide_cursor
|
||||||
|
if [[ -t 1 ]]; then
|
||||||
|
printf "\033[2J\033[H" >&2
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Main loop
|
||||||
|
while true; do
|
||||||
|
draw_menu
|
||||||
|
|
||||||
|
IFS= read -r -s -n1 key || key=""
|
||||||
|
case "$key" in
|
||||||
|
$'\x1b')
|
||||||
|
IFS= read -r -s -n1 -t 1 key2 || key2=""
|
||||||
|
if [[ "$key2" == "[" ]]; then
|
||||||
|
IFS= read -r -s -n1 -t 1 key3 || key3=""
|
||||||
|
case "$key3" in
|
||||||
|
A) # Up arrow
|
||||||
|
if [[ $cursor_pos -gt 0 ]]; then
|
||||||
|
((cursor_pos--))
|
||||||
|
elif [[ $top_index -gt 0 ]]; then
|
||||||
|
((top_index--))
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
B) # Down arrow
|
||||||
|
local absolute_index=$((top_index + cursor_pos))
|
||||||
|
local last_index=$((total_items - 1))
|
||||||
|
if [[ $absolute_index -lt $last_index ]]; then
|
||||||
|
local visible_count=$((total_items - top_index))
|
||||||
|
[[ $visible_count -gt $items_per_page ]] && visible_count=$items_per_page
|
||||||
|
if [[ $cursor_pos -lt $((visible_count - 1)) ]]; then
|
||||||
|
((cursor_pos++))
|
||||||
|
elif [[ $((top_index + visible_count)) -lt $total_items ]]; then
|
||||||
|
((top_index++))
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
else
|
||||||
|
# ESC alone
|
||||||
|
restore_terminal
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
" ") # Space - toggle current item
|
||||||
|
local idx=$((top_index + cursor_pos))
|
||||||
|
if [[ ${selected[idx]} == true ]]; then
|
||||||
|
selected[idx]=false
|
||||||
|
else
|
||||||
|
selected[idx]=true
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
"a"|"A") # Select all
|
||||||
|
for ((i = 0; i < total_items; i++)); do
|
||||||
|
selected[i]=true
|
||||||
|
done
|
||||||
|
;;
|
||||||
|
"i"|"I") # Invert selection
|
||||||
|
for ((i = 0; i < total_items; i++)); do
|
||||||
|
if [[ ${selected[i]} == true ]]; then
|
||||||
|
selected[i]=false
|
||||||
|
else
|
||||||
|
selected[i]=true
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
;;
|
||||||
|
"q"|"Q"|$'\x03') # Quit or Ctrl-C
|
||||||
|
restore_terminal
|
||||||
|
return 1
|
||||||
|
;;
|
||||||
|
""|$'\n'|$'\r') # Enter - confirm
|
||||||
|
MOLE_SELECTION_RESULT=""
|
||||||
|
for ((i = 0; i < total_items; i++)); do
|
||||||
|
if [[ ${selected[i]} == true ]]; then
|
||||||
|
[[ -n "$MOLE_SELECTION_RESULT" ]] && MOLE_SELECTION_RESULT+=","
|
||||||
|
MOLE_SELECTION_RESULT+="$i"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
restore_terminal
|
||||||
|
return 0
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
# Show menu for user selection
|
# Show menu for user selection
|
||||||
show_installer_menu() {
|
show_installer_menu() {
|
||||||
if [[ ${#DISPLAY_NAMES[@]} -eq 0 ]]; then
|
if [[ ${#DISPLAY_NAMES[@]} -eq 0 ]]; then
|
||||||
@@ -172,14 +489,8 @@ show_installer_menu() {
|
|||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
local title="Select installers to remove"
|
|
||||||
MOLE_SELECTION_RESULT=""
|
MOLE_SELECTION_RESULT=""
|
||||||
paginated_multi_select "$title" "${DISPLAY_NAMES[@]}"
|
if ! select_installers "${DISPLAY_NAMES[@]}"; then
|
||||||
local selection_exit=$?
|
|
||||||
|
|
||||||
if [[ $selection_exit -ne 0 ]]; then
|
|
||||||
echo ""
|
|
||||||
echo -e "${YELLOW}Cancelled${NC}"
|
|
||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -196,12 +507,54 @@ delete_selected_installers() {
|
|||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
printf '\n'
|
# Calculate total size for confirmation
|
||||||
echo -e "${BLUE}━━━ Removing installers ━━━${NC}"
|
local confirm_size=0
|
||||||
|
for idx in "${selected_indices[@]}"; do
|
||||||
|
if [[ "$idx" =~ ^[0-9]+$ ]] && [[ $idx -lt ${#INSTALLER_SIZES[@]} ]]; then
|
||||||
|
confirm_size=$((confirm_size + ${INSTALLER_SIZES[$idx]:-0}))
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
local confirm_human
|
||||||
|
confirm_human=$(bytes_to_human "$confirm_size")
|
||||||
|
|
||||||
# Delete each selected installer
|
# Show files to be deleted
|
||||||
|
echo -e "${PURPLE_BOLD}Files to be removed:${NC}"
|
||||||
|
for idx in "${selected_indices[@]}"; do
|
||||||
|
if [[ "$idx" =~ ^[0-9]+$ ]] && [[ $idx -lt ${#INSTALLER_PATHS[@]} ]]; then
|
||||||
|
local file_path="${INSTALLER_PATHS[$idx]}"
|
||||||
|
local file_size="${INSTALLER_SIZES[$idx]}"
|
||||||
|
local size_human
|
||||||
|
size_human=$(bytes_to_human "$file_size")
|
||||||
|
echo -e " ${GREEN}${ICON_SUCCESS}${NC} $(basename "$file_path") ${GRAY}(${size_human})${NC}"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# Confirm deletion
|
||||||
|
echo ""
|
||||||
|
echo -ne "${PURPLE}${ICON_ARROW}${NC} Delete ${#selected_indices[@]} installer(s) (${confirm_human}) ${GREEN}Enter${NC} confirm, ${GRAY}ESC${NC} cancel: "
|
||||||
|
|
||||||
|
IFS= read -r -s -n1 confirm || confirm=""
|
||||||
|
case "$confirm" in
|
||||||
|
$'\e'|q|Q)
|
||||||
|
return 1
|
||||||
|
;;
|
||||||
|
""|$'\n'|$'\r')
|
||||||
|
printf "\r\033[K" # Clear prompt line
|
||||||
|
echo "" # Single line break
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
return 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# Delete each selected installer with spinner
|
||||||
total_deleted=0
|
total_deleted=0
|
||||||
total_size_freed_kb=0
|
total_size_freed_kb=0
|
||||||
|
|
||||||
|
if [[ -t 1 ]]; then
|
||||||
|
start_inline_spinner "Removing installers..."
|
||||||
|
fi
|
||||||
|
|
||||||
for idx in "${selected_indices[@]}"; do
|
for idx in "${selected_indices[@]}"; do
|
||||||
if [[ ! "$idx" =~ ^[0-9]+$ ]] || [[ $idx -ge ${#INSTALLER_PATHS[@]} ]]; then
|
if [[ ! "$idx" =~ ^[0-9]+$ ]] || [[ $idx -ge ${#INSTALLER_PATHS[@]} ]]; then
|
||||||
continue
|
continue
|
||||||
@@ -212,58 +565,68 @@ delete_selected_installers() {
|
|||||||
|
|
||||||
# Validate path before deletion
|
# Validate path before deletion
|
||||||
if ! validate_path_for_deletion "$file_path"; then
|
if ! validate_path_for_deletion "$file_path"; then
|
||||||
echo -e " ${RED}${ICON_FAILED}${NC} Cannot delete (invalid path): $(basename "$file_path")"
|
|
||||||
continue
|
continue
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Delete the file
|
# Delete the file
|
||||||
if safe_remove "$file_path" true; then
|
if safe_remove "$file_path" true; then
|
||||||
local human_size
|
|
||||||
human_size=$(bytes_to_human "$file_size")
|
|
||||||
total_size_freed_kb=$((total_size_freed_kb + ((file_size + 1023) / 1024)))
|
total_size_freed_kb=$((total_size_freed_kb + ((file_size + 1023) / 1024)))
|
||||||
echo -e " ${GREEN}${ICON_SUCCESS}${NC} Deleted: $(basename "$file_path") ${GRAY}($human_size)${NC}"
|
|
||||||
total_deleted=$((total_deleted + 1))
|
total_deleted=$((total_deleted + 1))
|
||||||
else
|
|
||||||
echo -e " ${RED}${ICON_FAILED}${NC} Failed to delete: $(basename "$file_path")"
|
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
|
|
||||||
|
if [[ -t 1 ]]; then
|
||||||
|
stop_inline_spinner
|
||||||
|
fi
|
||||||
|
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
# Perform the installers cleanup
|
# Perform the installers cleanup
|
||||||
perform_installers() {
|
perform_installers() {
|
||||||
|
# Enter alt screen for scanning and selection
|
||||||
|
if [[ -t 1 ]]; then
|
||||||
|
enter_alt_screen
|
||||||
|
printf "\033[2J\033[H" >&2
|
||||||
|
fi
|
||||||
|
|
||||||
# Collect installers
|
# Collect installers
|
||||||
if ! collect_installers; then
|
if ! collect_installers; then
|
||||||
|
if [[ -t 1 ]]; then leave_alt_screen; fi
|
||||||
return 2 # Nothing to clean
|
return 2 # Nothing to clean
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Show menu
|
# Show menu
|
||||||
if ! show_installer_menu; then
|
if ! show_installer_menu; then
|
||||||
|
if [[ -t 1 ]]; then leave_alt_screen; fi
|
||||||
return 1 # User cancelled
|
return 1 # User cancelled
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Leave alt screen before deletion (so confirmation and results are on main screen)
|
||||||
|
if [[ -t 1 ]]; then
|
||||||
|
leave_alt_screen
|
||||||
|
fi
|
||||||
|
|
||||||
# Delete selected
|
# Delete selected
|
||||||
delete_selected_installers
|
if ! delete_selected_installers; then
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
show_summary() {
|
show_summary() {
|
||||||
echo ""
|
local summary_heading="Installers cleaned"
|
||||||
local summary_heading="Cleanup complete"
|
|
||||||
local -a summary_details=()
|
local -a summary_details=()
|
||||||
|
|
||||||
if [[ $total_deleted -gt 0 ]]; then
|
if [[ $total_deleted -gt 0 ]]; then
|
||||||
local freed_mb
|
local freed_mb
|
||||||
freed_mb=$(echo "$total_size_freed_kb" | awk '{printf "%.2f", $1/1024}')
|
freed_mb=$(echo "$total_size_freed_kb" | awk '{printf "%.2f", $1/1024}')
|
||||||
|
|
||||||
summary_details+=("Installers removed: $total_deleted")
|
summary_details+=("Removed ${GREEN}$total_deleted${NC} installer(s), freed ${GREEN}${freed_mb}MB${NC}")
|
||||||
summary_details+=("Space freed: ${GREEN}${freed_mb}MB${NC}")
|
summary_details+=("Your Mac is cleaner now!")
|
||||||
summary_details+=("Free space now: $(get_free_space)")
|
|
||||||
else
|
else
|
||||||
summary_details+=("No installers were removed")
|
summary_details+=("No installers were removed")
|
||||||
summary_details+=("Free space now: $(get_free_space)")
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
print_summary_block "$summary_heading" "${summary_details[@]}"
|
print_summary_block "$summary_heading" "${summary_details[@]}"
|
||||||
@@ -284,13 +647,6 @@ main() {
|
|||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
|
|
||||||
# Clear screen for better UX
|
|
||||||
if [[ -t 1 ]]; then
|
|
||||||
printf '\033[2J\033[H'
|
|
||||||
fi
|
|
||||||
printf '\n'
|
|
||||||
echo -e "${PURPLE_BOLD}Clean Installer Files${NC}"
|
|
||||||
|
|
||||||
hide_cursor
|
hide_cursor
|
||||||
perform_installers
|
perform_installers
|
||||||
local exit_code=$?
|
local exit_code=$?
|
||||||
@@ -304,9 +660,7 @@ main() {
|
|||||||
printf '\n'
|
printf '\n'
|
||||||
;;
|
;;
|
||||||
2)
|
2)
|
||||||
printf '\n'
|
# Already handled by collect_installers
|
||||||
echo -e "${YELLOW}No installer files found in default locations${NC}"
|
|
||||||
printf '\n'
|
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
|
|
||||||
|
|||||||
@@ -56,7 +56,43 @@ if command -v bats > /dev/null 2>&1 && [ -d "tests" ]; then
|
|||||||
export TERM="xterm-256color"
|
export TERM="xterm-256color"
|
||||||
fi
|
fi
|
||||||
if [[ $# -eq 0 ]]; then
|
if [[ $# -eq 0 ]]; then
|
||||||
set -- tests
|
fd_available=0
|
||||||
|
zip_available=0
|
||||||
|
zip_list_available=0
|
||||||
|
if command -v fd > /dev/null 2>&1; then
|
||||||
|
fd_available=1
|
||||||
|
fi
|
||||||
|
if command -v zip > /dev/null 2>&1; then
|
||||||
|
zip_available=1
|
||||||
|
fi
|
||||||
|
if command -v zipinfo > /dev/null 2>&1 || command -v unzip > /dev/null 2>&1; then
|
||||||
|
zip_list_available=1
|
||||||
|
fi
|
||||||
|
|
||||||
|
TEST_FILES=()
|
||||||
|
while IFS= read -r file; do
|
||||||
|
case "$file" in
|
||||||
|
tests/installer_fd.bats)
|
||||||
|
if [[ $fd_available -eq 1 ]]; then
|
||||||
|
TEST_FILES+=("$file")
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
tests/installer_zip.bats)
|
||||||
|
if [[ $zip_available -eq 1 && $zip_list_available -eq 1 ]]; then
|
||||||
|
TEST_FILES+=("$file")
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
TEST_FILES+=("$file")
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done < <(find tests -type f -name '*.bats' | sort)
|
||||||
|
|
||||||
|
if [[ ${#TEST_FILES[@]} -gt 0 ]]; then
|
||||||
|
set -- "${TEST_FILES[@]}"
|
||||||
|
else
|
||||||
|
set -- tests
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
use_color=false
|
use_color=false
|
||||||
if [[ -t 1 && "${TERM:-}" != "dumb" ]]; then
|
if [[ -t 1 && "${TERM:-}" != "dumb" ]]; then
|
||||||
|
|||||||
@@ -47,187 +47,6 @@ setup() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
# Test scan_installers_in_path function directly
|
# Test scan_installers_in_path function directly
|
||||||
# Tests are duplicated to cover both fd and find code paths
|
|
||||||
|
|
||||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
||||||
# Tests using fd (when available)
|
|
||||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
||||||
|
|
||||||
@test "scan_installers_in_path (fd): finds .dmg files" {
|
|
||||||
if ! command -v fd > /dev/null 2>&1; then
|
|
||||||
skip "fd not available on this system"
|
|
||||||
fi
|
|
||||||
|
|
||||||
touch "$HOME/Downloads/Chrome.dmg"
|
|
||||||
|
|
||||||
run bash -euo pipefail -c '
|
|
||||||
export MOLE_TEST_MODE=1
|
|
||||||
source "$1"
|
|
||||||
scan_installers_in_path "$2"
|
|
||||||
' bash "$PROJECT_ROOT/bin/installer.sh" "$HOME/Downloads"
|
|
||||||
|
|
||||||
[ "$status" -eq 0 ]
|
|
||||||
[[ "$output" == *"Chrome.dmg"* ]]
|
|
||||||
}
|
|
||||||
|
|
||||||
@test "scan_installers_in_path (fd): finds multiple installer types" {
|
|
||||||
if ! command -v fd > /dev/null 2>&1; then
|
|
||||||
skip "fd not available on this system"
|
|
||||||
fi
|
|
||||||
|
|
||||||
touch "$HOME/Downloads/App1.dmg"
|
|
||||||
touch "$HOME/Downloads/App2.pkg"
|
|
||||||
touch "$HOME/Downloads/App3.iso"
|
|
||||||
touch "$HOME/Downloads/App.mpkg"
|
|
||||||
|
|
||||||
run bash -euo pipefail -c '
|
|
||||||
export MOLE_TEST_MODE=1
|
|
||||||
source "$1"
|
|
||||||
scan_installers_in_path "$2"
|
|
||||||
' bash "$PROJECT_ROOT/bin/installer.sh" "$HOME/Downloads"
|
|
||||||
|
|
||||||
[ "$status" -eq 0 ]
|
|
||||||
[[ "$output" == *"App1.dmg"* ]]
|
|
||||||
[[ "$output" == *"App2.pkg"* ]]
|
|
||||||
[[ "$output" == *"App3.iso"* ]]
|
|
||||||
[[ "$output" == *"App.mpkg"* ]]
|
|
||||||
}
|
|
||||||
|
|
||||||
@test "scan_installers_in_path (fd): respects max depth" {
|
|
||||||
if ! command -v fd > /dev/null 2>&1; then
|
|
||||||
skip "fd not available on this system"
|
|
||||||
fi
|
|
||||||
|
|
||||||
mkdir -p "$HOME/Downloads/level1/level2/level3"
|
|
||||||
touch "$HOME/Downloads/shallow.dmg"
|
|
||||||
touch "$HOME/Downloads/level1/mid.dmg"
|
|
||||||
touch "$HOME/Downloads/level1/level2/deep.dmg"
|
|
||||||
touch "$HOME/Downloads/level1/level2/level3/too-deep.dmg"
|
|
||||||
|
|
||||||
run bash -euo pipefail -c '
|
|
||||||
export MOLE_TEST_MODE=1
|
|
||||||
source "$1"
|
|
||||||
scan_installers_in_path "$2"
|
|
||||||
' bash "$PROJECT_ROOT/bin/installer.sh" "$HOME/Downloads"
|
|
||||||
|
|
||||||
[ "$status" -eq 0 ]
|
|
||||||
# Default max depth is 2
|
|
||||||
[[ "$output" == *"shallow.dmg"* ]]
|
|
||||||
[[ "$output" == *"mid.dmg"* ]]
|
|
||||||
[[ "$output" == *"deep.dmg"* ]]
|
|
||||||
[[ "$output" != *"too-deep.dmg"* ]]
|
|
||||||
}
|
|
||||||
|
|
||||||
@test "scan_installers_in_path (fd): honors MOLE_INSTALLER_SCAN_MAX_DEPTH" {
|
|
||||||
if ! command -v fd > /dev/null 2>&1; then
|
|
||||||
skip "fd not available on this system"
|
|
||||||
fi
|
|
||||||
|
|
||||||
mkdir -p "$HOME/Downloads/level1"
|
|
||||||
touch "$HOME/Downloads/top.dmg"
|
|
||||||
touch "$HOME/Downloads/level1/nested.dmg"
|
|
||||||
|
|
||||||
run env MOLE_INSTALLER_SCAN_MAX_DEPTH=1 bash -euo pipefail -c "
|
|
||||||
export MOLE_TEST_MODE=1
|
|
||||||
source \"\$1\"
|
|
||||||
scan_installers_in_path \"\$2\"
|
|
||||||
" bash "$PROJECT_ROOT/bin/installer.sh" "$HOME/Downloads"
|
|
||||||
|
|
||||||
[ "$status" -eq 0 ]
|
|
||||||
[[ "$output" == *"top.dmg"* ]]
|
|
||||||
[[ "$output" != *"nested.dmg"* ]]
|
|
||||||
}
|
|
||||||
|
|
||||||
@test "scan_installers_in_path (fd): handles non-existent directory" {
|
|
||||||
if ! command -v fd > /dev/null 2>&1; then
|
|
||||||
skip "fd not available on this system"
|
|
||||||
fi
|
|
||||||
|
|
||||||
run bash -euo pipefail -c '
|
|
||||||
export MOLE_TEST_MODE=1
|
|
||||||
source "$1"
|
|
||||||
scan_installers_in_path "$2"
|
|
||||||
' bash "$PROJECT_ROOT/bin/installer.sh" "$HOME/NonExistent"
|
|
||||||
|
|
||||||
[ "$status" -eq 0 ]
|
|
||||||
[[ -z "$output" ]]
|
|
||||||
}
|
|
||||||
|
|
||||||
@test "scan_installers_in_path (fd): ignores non-installer files" {
|
|
||||||
if ! command -v fd > /dev/null 2>&1; then
|
|
||||||
skip "fd not available on this system"
|
|
||||||
fi
|
|
||||||
|
|
||||||
touch "$HOME/Downloads/document.pdf"
|
|
||||||
touch "$HOME/Downloads/image.jpg"
|
|
||||||
touch "$HOME/Downloads/archive.tar.gz"
|
|
||||||
touch "$HOME/Downloads/Installer.dmg"
|
|
||||||
|
|
||||||
run bash -euo pipefail -c '
|
|
||||||
export MOLE_TEST_MODE=1
|
|
||||||
source "$1"
|
|
||||||
scan_installers_in_path "$2"
|
|
||||||
' bash "$PROJECT_ROOT/bin/installer.sh" "$HOME/Downloads"
|
|
||||||
|
|
||||||
[ "$status" -eq 0 ]
|
|
||||||
[[ "$output" != *"document.pdf"* ]]
|
|
||||||
[[ "$output" != *"image.jpg"* ]]
|
|
||||||
[[ "$output" != *"archive.tar.gz"* ]]
|
|
||||||
[[ "$output" == *"Installer.dmg"* ]]
|
|
||||||
}
|
|
||||||
|
|
||||||
@test "scan_installers_in_path (fd): handles filenames with spaces" {
|
|
||||||
if ! command -v fd > /dev/null 2>&1; then
|
|
||||||
skip "fd not available on this system"
|
|
||||||
fi
|
|
||||||
|
|
||||||
touch "$HOME/Downloads/My App Installer.dmg"
|
|
||||||
|
|
||||||
run bash -euo pipefail -c '
|
|
||||||
export MOLE_TEST_MODE=1
|
|
||||||
source "$1"
|
|
||||||
scan_installers_in_path "$2"
|
|
||||||
' bash "$PROJECT_ROOT/bin/installer.sh" "$HOME/Downloads"
|
|
||||||
|
|
||||||
[ "$status" -eq 0 ]
|
|
||||||
[[ "$output" == *"My App Installer.dmg"* ]]
|
|
||||||
}
|
|
||||||
|
|
||||||
@test "scan_installers_in_path (fd): handles filenames with special characters" {
|
|
||||||
if ! command -v fd > /dev/null 2>&1; then
|
|
||||||
skip "fd not available on this system"
|
|
||||||
fi
|
|
||||||
|
|
||||||
touch "$HOME/Downloads/App-v1.2.3_beta.pkg"
|
|
||||||
|
|
||||||
run bash -euo pipefail -c '
|
|
||||||
export MOLE_TEST_MODE=1
|
|
||||||
source "$1"
|
|
||||||
scan_installers_in_path "$2"
|
|
||||||
' bash "$PROJECT_ROOT/bin/installer.sh" "$HOME/Downloads"
|
|
||||||
|
|
||||||
[ "$status" -eq 0 ]
|
|
||||||
[[ "$output" == *"App-v1.2.3_beta.pkg"* ]]
|
|
||||||
}
|
|
||||||
|
|
||||||
@test "scan_installers_in_path (fd): returns empty for directory with no installers" {
|
|
||||||
if ! command -v fd > /dev/null 2>&1; then
|
|
||||||
skip "fd not available on this system"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Create some non-installer files
|
|
||||||
touch "$HOME/Downloads/document.pdf"
|
|
||||||
touch "$HOME/Downloads/image.png"
|
|
||||||
|
|
||||||
run bash -euo pipefail -c '
|
|
||||||
export MOLE_TEST_MODE=1
|
|
||||||
source "$1"
|
|
||||||
scan_installers_in_path "$2"
|
|
||||||
' bash "$PROJECT_ROOT/bin/installer.sh" "$HOME/Downloads"
|
|
||||||
|
|
||||||
[ "$status" -eq 0 ]
|
|
||||||
[[ -z "$output" ]]
|
|
||||||
}
|
|
||||||
|
|
||||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
# Tests using find (forced fallback by hiding fd)
|
# Tests using find (forced fallback by hiding fd)
|
||||||
@@ -332,126 +151,6 @@ setup() {
|
|||||||
[[ "$output" == *"Installer.dmg"* ]]
|
[[ "$output" == *"Installer.dmg"* ]]
|
||||||
}
|
}
|
||||||
|
|
||||||
# Test ZIP installer detection
|
|
||||||
|
|
||||||
@test "is_installer_zip: rejects ZIP with installer content but too many entries" {
|
|
||||||
if ! command -v zipinfo > /dev/null 2>&1; then
|
|
||||||
skip "zipinfo not available"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Create a ZIP with too many files (exceeds MAX_ZIP_ENTRIES=5)
|
|
||||||
# Include a .app file to have installer content
|
|
||||||
mkdir -p "$HOME/Downloads/large-app"
|
|
||||||
touch "$HOME/Downloads/large-app/MyApp.app"
|
|
||||||
for i in {1..9}; do
|
|
||||||
touch "$HOME/Downloads/large-app/file$i.txt"
|
|
||||||
done
|
|
||||||
(cd "$HOME/Downloads" && zip -q -r large-installer.zip large-app)
|
|
||||||
|
|
||||||
run bash -euo pipefail -c '
|
|
||||||
export MOLE_TEST_MODE=1
|
|
||||||
source "$1"
|
|
||||||
if is_installer_zip "'"$HOME/Downloads/large-installer.zip"'"; then
|
|
||||||
echo "INSTALLER"
|
|
||||||
else
|
|
||||||
echo "NOT_INSTALLER"
|
|
||||||
fi
|
|
||||||
' bash "$PROJECT_ROOT/bin/installer.sh"
|
|
||||||
|
|
||||||
[ "$status" -eq 0 ]
|
|
||||||
[[ "$output" == "NOT_INSTALLER" ]]
|
|
||||||
}
|
|
||||||
|
|
||||||
@test "is_installer_zip: detects ZIP with app content" {
|
|
||||||
if ! command -v zipinfo > /dev/null 2>&1; then
|
|
||||||
skip "zipinfo not available"
|
|
||||||
fi
|
|
||||||
|
|
||||||
mkdir -p "$HOME/Downloads/app-content"
|
|
||||||
touch "$HOME/Downloads/app-content/MyApp.app"
|
|
||||||
(cd "$HOME/Downloads" && zip -q -r app.zip app-content)
|
|
||||||
|
|
||||||
run bash -euo pipefail -c '
|
|
||||||
export MOLE_TEST_MODE=1
|
|
||||||
source "$1"
|
|
||||||
if is_installer_zip "'"$HOME/Downloads/app.zip"'"; then
|
|
||||||
echo "INSTALLER"
|
|
||||||
else
|
|
||||||
echo "NOT_INSTALLER"
|
|
||||||
fi
|
|
||||||
' bash "$PROJECT_ROOT/bin/installer.sh"
|
|
||||||
|
|
||||||
[ "$status" -eq 0 ]
|
|
||||||
[[ "$output" == "INSTALLER" ]]
|
|
||||||
}
|
|
||||||
|
|
||||||
@test "is_installer_zip: rejects ZIP with only regular files" {
|
|
||||||
if ! command -v zipinfo > /dev/null 2>&1; then
|
|
||||||
skip "zipinfo not available"
|
|
||||||
fi
|
|
||||||
|
|
||||||
mkdir -p "$HOME/Downloads/data"
|
|
||||||
touch "$HOME/Downloads/data/file1.txt"
|
|
||||||
touch "$HOME/Downloads/data/file2.pdf"
|
|
||||||
(cd "$HOME/Downloads" && zip -q -r data.zip data)
|
|
||||||
|
|
||||||
run bash -euo pipefail -c '
|
|
||||||
export MOLE_TEST_MODE=1
|
|
||||||
source "$1"
|
|
||||||
if is_installer_zip "'"$HOME/Downloads/data.zip"'"; then
|
|
||||||
echo "INSTALLER"
|
|
||||||
else
|
|
||||||
echo "NOT_INSTALLER"
|
|
||||||
fi
|
|
||||||
' bash "$PROJECT_ROOT/bin/installer.sh"
|
|
||||||
|
|
||||||
[ "$status" -eq 0 ]
|
|
||||||
[[ "$output" == "NOT_INSTALLER" ]]
|
|
||||||
}
|
|
||||||
|
|
||||||
# Integration tests: ZIP scanning inside scan_all_installers
|
|
||||||
|
|
||||||
@test "scan_all_installers: finds installer ZIP in Downloads" {
|
|
||||||
if ! command -v zipinfo > /dev/null 2>&1; then
|
|
||||||
skip "zipinfo not available"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Create a valid installer ZIP (contains .app)
|
|
||||||
mkdir -p "$HOME/Downloads/app-content"
|
|
||||||
touch "$HOME/Downloads/app-content/MyApp.app"
|
|
||||||
(cd "$HOME/Downloads" && zip -q -r installer.zip app-content)
|
|
||||||
|
|
||||||
run bash -euo pipefail -c '
|
|
||||||
export MOLE_TEST_MODE=1
|
|
||||||
source "$1"
|
|
||||||
scan_all_installers
|
|
||||||
' bash "$PROJECT_ROOT/bin/installer.sh"
|
|
||||||
|
|
||||||
[ "$status" -eq 0 ]
|
|
||||||
[[ "$output" == *"installer.zip"* ]]
|
|
||||||
}
|
|
||||||
|
|
||||||
@test "scan_all_installers: ignores non-installer ZIP in Downloads" {
|
|
||||||
if ! command -v zipinfo > /dev/null 2>&1; then
|
|
||||||
skip "zipinfo not available"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Create a non-installer ZIP (only regular files)
|
|
||||||
mkdir -p "$HOME/Downloads/data"
|
|
||||||
touch "$HOME/Downloads/data/file1.txt"
|
|
||||||
touch "$HOME/Downloads/data/file2.pdf"
|
|
||||||
(cd "$HOME/Downloads" && zip -q -r data.zip data)
|
|
||||||
|
|
||||||
run bash -euo pipefail -c '
|
|
||||||
export MOLE_TEST_MODE=1
|
|
||||||
source "$1"
|
|
||||||
scan_all_installers
|
|
||||||
' bash "$PROJECT_ROOT/bin/installer.sh"
|
|
||||||
|
|
||||||
[ "$status" -eq 0 ]
|
|
||||||
[[ "$output" != *"data.zip"* ]]
|
|
||||||
}
|
|
||||||
|
|
||||||
@test "scan_all_installers: handles missing paths gracefully" {
|
@test "scan_all_installers: handles missing paths gracefully" {
|
||||||
# Don't create all scan directories, some may not exist
|
# Don't create all scan directories, some may not exist
|
||||||
# Only create Downloads, delete others if they exist
|
# Only create Downloads, delete others if they exist
|
||||||
@@ -520,109 +219,8 @@ setup() {
|
|||||||
[[ -z "$output" ]]
|
[[ -z "$output" ]]
|
||||||
}
|
}
|
||||||
|
|
||||||
# Failure path tests for scan_installers_in_path
|
|
||||||
|
|
||||||
@test "scan_installers_in_path: skips corrupt ZIP files" {
|
|
||||||
if ! command -v zipinfo > /dev/null 2>&1; then
|
|
||||||
skip "zipinfo not available"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Create a corrupt ZIP file by just writing garbage data
|
|
||||||
echo "This is not a valid ZIP file" > "$HOME/Downloads/corrupt.zip"
|
|
||||||
|
|
||||||
run bash -euo pipefail -c '
|
|
||||||
export MOLE_TEST_MODE=1
|
|
||||||
source "$1"
|
|
||||||
scan_installers_in_path "$2"
|
|
||||||
' bash "$PROJECT_ROOT/bin/installer.sh" "$HOME/Downloads"
|
|
||||||
|
|
||||||
# Should succeed (return 0) and silently skip the corrupt ZIP
|
|
||||||
[ "$status" -eq 0 ]
|
|
||||||
# Output should be empty since corrupt.zip is not a valid installer
|
|
||||||
[[ -z "$output" ]]
|
|
||||||
}
|
|
||||||
|
|
||||||
@test "scan_installers_in_path: handles permission-denied files" {
|
|
||||||
if ! command -v zipinfo > /dev/null 2>&1; then
|
|
||||||
skip "zipinfo not available"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Create a valid installer ZIP
|
|
||||||
mkdir -p "$HOME/Downloads/app-content"
|
|
||||||
touch "$HOME/Downloads/app-content/MyApp.app"
|
|
||||||
(cd "$HOME/Downloads" && zip -q -r readable.zip app-content)
|
|
||||||
|
|
||||||
# Create a readable installer ZIP alongside a permission-denied file
|
|
||||||
mkdir -p "$HOME/Downloads/restricted-app"
|
|
||||||
touch "$HOME/Downloads/restricted-app/App.app"
|
|
||||||
(cd "$HOME/Downloads" && zip -q -r restricted.zip restricted-app)
|
|
||||||
|
|
||||||
# Remove read permissions from restricted.zip
|
|
||||||
chmod 000 "$HOME/Downloads/restricted.zip"
|
|
||||||
|
|
||||||
run bash -euo pipefail -c '
|
|
||||||
export MOLE_TEST_MODE=1
|
|
||||||
source "$1"
|
|
||||||
scan_installers_in_path "$2"
|
|
||||||
' bash "$PROJECT_ROOT/bin/installer.sh" "$HOME/Downloads"
|
|
||||||
|
|
||||||
# Should succeed and find the readable.zip but skip restricted.zip
|
|
||||||
[ "$status" -eq 0 ]
|
|
||||||
[[ "$output" == *"readable.zip"* ]]
|
|
||||||
[[ "$output" != *"restricted.zip"* ]]
|
|
||||||
|
|
||||||
# Cleanup: restore permissions for teardown
|
|
||||||
chmod 644 "$HOME/Downloads/restricted.zip"
|
|
||||||
}
|
|
||||||
|
|
||||||
@test "scan_installers_in_path: finds installer ZIP alongside corrupt ZIPs" {
|
|
||||||
if ! command -v zipinfo > /dev/null 2>&1; then
|
|
||||||
skip "zipinfo not available"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Create a valid installer ZIP
|
|
||||||
mkdir -p "$HOME/Downloads/app-content"
|
|
||||||
touch "$HOME/Downloads/app-content/MyApp.app"
|
|
||||||
(cd "$HOME/Downloads" && zip -q -r valid-installer.zip app-content)
|
|
||||||
|
|
||||||
# Create a corrupt ZIP
|
|
||||||
echo "garbage data" > "$HOME/Downloads/corrupt.zip"
|
|
||||||
|
|
||||||
run bash -euo pipefail -c '
|
|
||||||
export MOLE_TEST_MODE=1
|
|
||||||
source "$1"
|
|
||||||
scan_installers_in_path "$2"
|
|
||||||
' bash "$PROJECT_ROOT/bin/installer.sh" "$HOME/Downloads"
|
|
||||||
|
|
||||||
# Should find the valid ZIP and silently skip the corrupt one
|
|
||||||
[ "$status" -eq 0 ]
|
|
||||||
[[ "$output" == *"valid-installer.zip"* ]]
|
|
||||||
[[ "$output" != *"corrupt.zip"* ]]
|
|
||||||
}
|
|
||||||
|
|
||||||
# Symlink handling tests
|
# Symlink handling tests
|
||||||
|
|
||||||
@test "scan_installers_in_path (fd): skips symlinks to regular files" {
|
|
||||||
if ! command -v fd > /dev/null 2>&1; then
|
|
||||||
skip "fd not available on this system"
|
|
||||||
fi
|
|
||||||
|
|
||||||
touch "$HOME/Downloads/real.dmg"
|
|
||||||
ln -s "$HOME/Downloads/real.dmg" "$HOME/Downloads/symlink.dmg"
|
|
||||||
ln -s /nonexistent "$HOME/Downloads/dangling.lnk"
|
|
||||||
|
|
||||||
run bash -euo pipefail -c '
|
|
||||||
export MOLE_TEST_MODE=1
|
|
||||||
source "$1"
|
|
||||||
scan_installers_in_path "$2"
|
|
||||||
' bash "$PROJECT_ROOT/bin/installer.sh" "$HOME/Downloads"
|
|
||||||
|
|
||||||
[ "$status" -eq 0 ]
|
|
||||||
[[ "$output" == *"real.dmg"* ]]
|
|
||||||
[[ "$output" != *"symlink.dmg"* ]]
|
|
||||||
[[ "$output" != *"dangling.lnk"* ]]
|
|
||||||
}
|
|
||||||
|
|
||||||
@test "scan_installers_in_path (fallback find): skips symlinks to regular files" {
|
@test "scan_installers_in_path (fallback find): skips symlinks to regular files" {
|
||||||
touch "$HOME/Downloads/real.dmg"
|
touch "$HOME/Downloads/real.dmg"
|
||||||
ln -s "$HOME/Downloads/real.dmg" "$HOME/Downloads/symlink.dmg"
|
ln -s "$HOME/Downloads/real.dmg" "$HOME/Downloads/symlink.dmg"
|
||||||
|
|||||||
245
tests/installer_fd.bats
Normal file
245
tests/installer_fd.bats
Normal file
@@ -0,0 +1,245 @@
|
|||||||
|
#!/usr/bin/env bats
|
||||||
|
|
||||||
|
setup_file() {
|
||||||
|
PROJECT_ROOT="$(cd "${BATS_TEST_DIRNAME}/.." && pwd)"
|
||||||
|
export PROJECT_ROOT
|
||||||
|
|
||||||
|
ORIGINAL_HOME="${HOME:-}"
|
||||||
|
export ORIGINAL_HOME
|
||||||
|
|
||||||
|
HOME="$(mktemp -d "${BATS_TEST_DIRNAME}/tmp-installers-home.XXXXXX")"
|
||||||
|
export HOME
|
||||||
|
|
||||||
|
mkdir -p "$HOME"
|
||||||
|
|
||||||
|
if command -v fd > /dev/null 2>&1; then
|
||||||
|
FD_AVAILABLE=1
|
||||||
|
else
|
||||||
|
FD_AVAILABLE=0
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
teardown_file() {
|
||||||
|
rm -rf "$HOME"
|
||||||
|
if [[ -n "${ORIGINAL_HOME:-}" ]]; then
|
||||||
|
export HOME="$ORIGINAL_HOME"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
setup() {
|
||||||
|
export TERM="xterm-256color"
|
||||||
|
export MO_DEBUG=0
|
||||||
|
|
||||||
|
# Create standard scan directories
|
||||||
|
mkdir -p "$HOME/Downloads"
|
||||||
|
mkdir -p "$HOME/Desktop"
|
||||||
|
mkdir -p "$HOME/Documents"
|
||||||
|
mkdir -p "$HOME/Public"
|
||||||
|
mkdir -p "$HOME/Library/Downloads"
|
||||||
|
|
||||||
|
# Clear previous test files
|
||||||
|
rm -rf "${HOME:?}/Downloads"/*
|
||||||
|
rm -rf "${HOME:?}/Desktop"/*
|
||||||
|
rm -rf "${HOME:?}/Documents"/*
|
||||||
|
}
|
||||||
|
|
||||||
|
require_fd() {
|
||||||
|
[[ "${FD_AVAILABLE:-0}" -eq 1 ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "scan_installers_in_path (fd): finds .dmg files" {
|
||||||
|
if ! require_fd; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
touch "$HOME/Downloads/Chrome.dmg"
|
||||||
|
|
||||||
|
run bash -euo pipefail -c '
|
||||||
|
export MOLE_TEST_MODE=1
|
||||||
|
source "$1"
|
||||||
|
scan_installers_in_path "$2"
|
||||||
|
' bash "$PROJECT_ROOT/bin/installer.sh" "$HOME/Downloads"
|
||||||
|
|
||||||
|
[ "$status" -eq 0 ]
|
||||||
|
[[ "$output" == *"Chrome.dmg"* ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "scan_installers_in_path (fd): finds multiple installer types" {
|
||||||
|
if ! require_fd; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
touch "$HOME/Downloads/App1.dmg"
|
||||||
|
touch "$HOME/Downloads/App2.pkg"
|
||||||
|
touch "$HOME/Downloads/App3.iso"
|
||||||
|
touch "$HOME/Downloads/App.mpkg"
|
||||||
|
|
||||||
|
run bash -euo pipefail -c '
|
||||||
|
export MOLE_TEST_MODE=1
|
||||||
|
source "$1"
|
||||||
|
scan_installers_in_path "$2"
|
||||||
|
' bash "$PROJECT_ROOT/bin/installer.sh" "$HOME/Downloads"
|
||||||
|
|
||||||
|
[ "$status" -eq 0 ]
|
||||||
|
[[ "$output" == *"App1.dmg"* ]]
|
||||||
|
[[ "$output" == *"App2.pkg"* ]]
|
||||||
|
[[ "$output" == *"App3.iso"* ]]
|
||||||
|
[[ "$output" == *"App.mpkg"* ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "scan_installers_in_path (fd): respects max depth" {
|
||||||
|
if ! require_fd; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p "$HOME/Downloads/level1/level2/level3"
|
||||||
|
touch "$HOME/Downloads/shallow.dmg"
|
||||||
|
touch "$HOME/Downloads/level1/mid.dmg"
|
||||||
|
touch "$HOME/Downloads/level1/level2/deep.dmg"
|
||||||
|
touch "$HOME/Downloads/level1/level2/level3/too-deep.dmg"
|
||||||
|
|
||||||
|
run bash -euo pipefail -c '
|
||||||
|
export MOLE_TEST_MODE=1
|
||||||
|
source "$1"
|
||||||
|
scan_installers_in_path "$2"
|
||||||
|
' bash "$PROJECT_ROOT/bin/installer.sh" "$HOME/Downloads"
|
||||||
|
|
||||||
|
[ "$status" -eq 0 ]
|
||||||
|
# Default max depth is 2
|
||||||
|
[[ "$output" == *"shallow.dmg"* ]]
|
||||||
|
[[ "$output" == *"mid.dmg"* ]]
|
||||||
|
[[ "$output" == *"deep.dmg"* ]]
|
||||||
|
[[ "$output" != *"too-deep.dmg"* ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "scan_installers_in_path (fd): honors MOLE_INSTALLER_SCAN_MAX_DEPTH" {
|
||||||
|
if ! require_fd; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p "$HOME/Downloads/level1"
|
||||||
|
touch "$HOME/Downloads/top.dmg"
|
||||||
|
touch "$HOME/Downloads/level1/nested.dmg"
|
||||||
|
|
||||||
|
run env MOLE_INSTALLER_SCAN_MAX_DEPTH=1 bash -euo pipefail -c "
|
||||||
|
export MOLE_TEST_MODE=1
|
||||||
|
source \"\$1\"
|
||||||
|
scan_installers_in_path \"\$2\"
|
||||||
|
" bash "$PROJECT_ROOT/bin/installer.sh" "$HOME/Downloads"
|
||||||
|
|
||||||
|
[ "$status" -eq 0 ]
|
||||||
|
[[ "$output" == *"top.dmg"* ]]
|
||||||
|
[[ "$output" != *"nested.dmg"* ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "scan_installers_in_path (fd): handles non-existent directory" {
|
||||||
|
if ! require_fd; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
run bash -euo pipefail -c '
|
||||||
|
export MOLE_TEST_MODE=1
|
||||||
|
source "$1"
|
||||||
|
scan_installers_in_path "$2"
|
||||||
|
' bash "$PROJECT_ROOT/bin/installer.sh" "$HOME/NonExistent"
|
||||||
|
|
||||||
|
[ "$status" -eq 0 ]
|
||||||
|
[[ -z "$output" ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "scan_installers_in_path (fd): ignores non-installer files" {
|
||||||
|
if ! require_fd; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
touch "$HOME/Downloads/document.pdf"
|
||||||
|
touch "$HOME/Downloads/image.jpg"
|
||||||
|
touch "$HOME/Downloads/archive.tar.gz"
|
||||||
|
touch "$HOME/Downloads/Installer.dmg"
|
||||||
|
|
||||||
|
run bash -euo pipefail -c '
|
||||||
|
export MOLE_TEST_MODE=1
|
||||||
|
source "$1"
|
||||||
|
scan_installers_in_path "$2"
|
||||||
|
' bash "$PROJECT_ROOT/bin/installer.sh" "$HOME/Downloads"
|
||||||
|
|
||||||
|
[ "$status" -eq 0 ]
|
||||||
|
[[ "$output" != *"document.pdf"* ]]
|
||||||
|
[[ "$output" != *"image.jpg"* ]]
|
||||||
|
[[ "$output" != *"archive.tar.gz"* ]]
|
||||||
|
[[ "$output" == *"Installer.dmg"* ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "scan_installers_in_path (fd): handles filenames with spaces" {
|
||||||
|
if ! require_fd; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
touch "$HOME/Downloads/My App Installer.dmg"
|
||||||
|
|
||||||
|
run bash -euo pipefail -c '
|
||||||
|
export MOLE_TEST_MODE=1
|
||||||
|
source "$1"
|
||||||
|
scan_installers_in_path "$2"
|
||||||
|
' bash "$PROJECT_ROOT/bin/installer.sh" "$HOME/Downloads"
|
||||||
|
|
||||||
|
[ "$status" -eq 0 ]
|
||||||
|
[[ "$output" == *"My App Installer.dmg"* ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "scan_installers_in_path (fd): handles filenames with special characters" {
|
||||||
|
if ! require_fd; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
touch "$HOME/Downloads/App-v1.2.3_beta.pkg"
|
||||||
|
|
||||||
|
run bash -euo pipefail -c '
|
||||||
|
export MOLE_TEST_MODE=1
|
||||||
|
source "$1"
|
||||||
|
scan_installers_in_path "$2"
|
||||||
|
' bash "$PROJECT_ROOT/bin/installer.sh" "$HOME/Downloads"
|
||||||
|
|
||||||
|
[ "$status" -eq 0 ]
|
||||||
|
[[ "$output" == *"App-v1.2.3_beta.pkg"* ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "scan_installers_in_path (fd): returns empty for directory with no installers" {
|
||||||
|
if ! require_fd; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Create some non-installer files
|
||||||
|
touch "$HOME/Downloads/document.pdf"
|
||||||
|
touch "$HOME/Downloads/image.png"
|
||||||
|
|
||||||
|
run bash -euo pipefail -c '
|
||||||
|
export MOLE_TEST_MODE=1
|
||||||
|
source "$1"
|
||||||
|
scan_installers_in_path "$2"
|
||||||
|
' bash "$PROJECT_ROOT/bin/installer.sh" "$HOME/Downloads"
|
||||||
|
|
||||||
|
[ "$status" -eq 0 ]
|
||||||
|
[[ -z "$output" ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "scan_installers_in_path (fd): skips symlinks to regular files" {
|
||||||
|
if ! require_fd; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
touch "$HOME/Downloads/real.dmg"
|
||||||
|
ln -s "$HOME/Downloads/real.dmg" "$HOME/Downloads/symlink.dmg"
|
||||||
|
ln -s /nonexistent "$HOME/Downloads/dangling.lnk"
|
||||||
|
|
||||||
|
run bash -euo pipefail -c '
|
||||||
|
export MOLE_TEST_MODE=1
|
||||||
|
source "$1"
|
||||||
|
scan_installers_in_path "$2"
|
||||||
|
' bash "$PROJECT_ROOT/bin/installer.sh" "$HOME/Downloads"
|
||||||
|
|
||||||
|
[ "$status" -eq 0 ]
|
||||||
|
[[ "$output" == *"real.dmg"* ]]
|
||||||
|
[[ "$output" != *"symlink.dmg"* ]]
|
||||||
|
[[ "$output" != *"dangling.lnk"* ]]
|
||||||
|
}
|
||||||
312
tests/installer_zip.bats
Normal file
312
tests/installer_zip.bats
Normal file
@@ -0,0 +1,312 @@
|
|||||||
|
#!/usr/bin/env bats
|
||||||
|
|
||||||
|
setup_file() {
|
||||||
|
PROJECT_ROOT="$(cd "${BATS_TEST_DIRNAME}/.." && pwd)"
|
||||||
|
export PROJECT_ROOT
|
||||||
|
|
||||||
|
ORIGINAL_HOME="${HOME:-}"
|
||||||
|
export ORIGINAL_HOME
|
||||||
|
|
||||||
|
HOME="$(mktemp -d "${BATS_TEST_DIRNAME}/tmp-installers-home.XXXXXX")"
|
||||||
|
export HOME
|
||||||
|
|
||||||
|
mkdir -p "$HOME"
|
||||||
|
|
||||||
|
if command -v zip > /dev/null 2>&1; then
|
||||||
|
ZIP_AVAILABLE=1
|
||||||
|
else
|
||||||
|
ZIP_AVAILABLE=0
|
||||||
|
fi
|
||||||
|
if command -v zipinfo > /dev/null 2>&1 || command -v unzip > /dev/null 2>&1; then
|
||||||
|
ZIP_LIST_AVAILABLE=1
|
||||||
|
else
|
||||||
|
ZIP_LIST_AVAILABLE=0
|
||||||
|
fi
|
||||||
|
if command -v unzip > /dev/null 2>&1; then
|
||||||
|
UNZIP_AVAILABLE=1
|
||||||
|
else
|
||||||
|
UNZIP_AVAILABLE=0
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
teardown_file() {
|
||||||
|
rm -rf "$HOME"
|
||||||
|
if [[ -n "${ORIGINAL_HOME:-}" ]]; then
|
||||||
|
export HOME="$ORIGINAL_HOME"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
setup() {
|
||||||
|
export TERM="xterm-256color"
|
||||||
|
export MO_DEBUG=0
|
||||||
|
|
||||||
|
# Create standard scan directories
|
||||||
|
mkdir -p "$HOME/Downloads"
|
||||||
|
mkdir -p "$HOME/Desktop"
|
||||||
|
mkdir -p "$HOME/Documents"
|
||||||
|
mkdir -p "$HOME/Public"
|
||||||
|
mkdir -p "$HOME/Library/Downloads"
|
||||||
|
|
||||||
|
# Clear previous test files
|
||||||
|
rm -rf "${HOME:?}/Downloads"/*
|
||||||
|
rm -rf "${HOME:?}/Desktop"/*
|
||||||
|
rm -rf "${HOME:?}/Documents"/*
|
||||||
|
}
|
||||||
|
|
||||||
|
zip_list_available() {
|
||||||
|
[[ "${ZIP_LIST_AVAILABLE:-0}" -eq 1 ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
require_zip_list() {
|
||||||
|
zip_list_available
|
||||||
|
}
|
||||||
|
|
||||||
|
require_zip_support() {
|
||||||
|
[[ "${ZIP_AVAILABLE:-0}" -eq 1 && "${ZIP_LIST_AVAILABLE:-0}" -eq 1 ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
require_unzip_support() {
|
||||||
|
[[ "${ZIP_AVAILABLE:-0}" -eq 1 && "${UNZIP_AVAILABLE:-0}" -eq 1 ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
# Test ZIP installer detection
|
||||||
|
|
||||||
|
@test "is_installer_zip: rejects ZIP with installer content but too many entries" {
|
||||||
|
if ! require_zip_support; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Create a ZIP with too many files (exceeds MAX_ZIP_ENTRIES=5)
|
||||||
|
# Include a .app file to have installer content
|
||||||
|
mkdir -p "$HOME/Downloads/large-app"
|
||||||
|
touch "$HOME/Downloads/large-app/MyApp.app"
|
||||||
|
for i in {1..9}; do
|
||||||
|
touch "$HOME/Downloads/large-app/file$i.txt"
|
||||||
|
done
|
||||||
|
(cd "$HOME/Downloads" && zip -q -r large-installer.zip large-app)
|
||||||
|
|
||||||
|
run bash -euo pipefail -c '
|
||||||
|
export MOLE_TEST_MODE=1
|
||||||
|
source "$1"
|
||||||
|
if is_installer_zip "'"$HOME/Downloads/large-installer.zip"'"; then
|
||||||
|
echo "INSTALLER"
|
||||||
|
else
|
||||||
|
echo "NOT_INSTALLER"
|
||||||
|
fi
|
||||||
|
' bash "$PROJECT_ROOT/bin/installer.sh"
|
||||||
|
|
||||||
|
[ "$status" -eq 0 ]
|
||||||
|
[[ "$output" == "NOT_INSTALLER" ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "is_installer_zip: detects ZIP with app content" {
|
||||||
|
if ! require_zip_support; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p "$HOME/Downloads/app-content"
|
||||||
|
touch "$HOME/Downloads/app-content/MyApp.app"
|
||||||
|
(cd "$HOME/Downloads" && zip -q -r app.zip app-content)
|
||||||
|
|
||||||
|
run bash -euo pipefail -c '
|
||||||
|
export MOLE_TEST_MODE=1
|
||||||
|
source "$1"
|
||||||
|
if is_installer_zip "'"$HOME/Downloads/app.zip"'"; then
|
||||||
|
echo "INSTALLER"
|
||||||
|
else
|
||||||
|
echo "NOT_INSTALLER"
|
||||||
|
fi
|
||||||
|
' bash "$PROJECT_ROOT/bin/installer.sh"
|
||||||
|
|
||||||
|
[ "$status" -eq 0 ]
|
||||||
|
[[ "$output" == "INSTALLER" ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "is_installer_zip: rejects ZIP with only regular files" {
|
||||||
|
if ! require_zip_support; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p "$HOME/Downloads/data"
|
||||||
|
touch "$HOME/Downloads/data/file1.txt"
|
||||||
|
touch "$HOME/Downloads/data/file2.pdf"
|
||||||
|
(cd "$HOME/Downloads" && zip -q -r data.zip data)
|
||||||
|
|
||||||
|
run bash -euo pipefail -c '
|
||||||
|
export MOLE_TEST_MODE=1
|
||||||
|
source "$1"
|
||||||
|
if is_installer_zip "'"$HOME/Downloads/data.zip"'"; then
|
||||||
|
echo "INSTALLER"
|
||||||
|
else
|
||||||
|
echo "NOT_INSTALLER"
|
||||||
|
fi
|
||||||
|
' bash "$PROJECT_ROOT/bin/installer.sh"
|
||||||
|
|
||||||
|
[ "$status" -eq 0 ]
|
||||||
|
[[ "$output" == "NOT_INSTALLER" ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "is_installer_zip: returns NOT_INSTALLER when ZIP list command is unavailable" {
|
||||||
|
touch "$HOME/Downloads/empty.zip"
|
||||||
|
|
||||||
|
run bash -euo pipefail -c '
|
||||||
|
export MOLE_TEST_MODE=1
|
||||||
|
source "$1"
|
||||||
|
ZIP_LIST_CMD=()
|
||||||
|
if is_installer_zip "$2"; then
|
||||||
|
echo "INSTALLER"
|
||||||
|
else
|
||||||
|
echo "NOT_INSTALLER"
|
||||||
|
fi
|
||||||
|
' bash "$PROJECT_ROOT/bin/installer.sh" "$HOME/Downloads/empty.zip"
|
||||||
|
|
||||||
|
[ "$status" -eq 0 ]
|
||||||
|
[[ "$output" == "NOT_INSTALLER" ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "is_installer_zip: works with unzip list command" {
|
||||||
|
if ! require_unzip_support; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p "$HOME/Downloads/app-content"
|
||||||
|
touch "$HOME/Downloads/app-content/MyApp.app"
|
||||||
|
(cd "$HOME/Downloads" && zip -q -r app.zip app-content)
|
||||||
|
|
||||||
|
run bash -euo pipefail -c '
|
||||||
|
export MOLE_TEST_MODE=1
|
||||||
|
source "$1"
|
||||||
|
ZIP_LIST_CMD=(unzip -Z -1)
|
||||||
|
if is_installer_zip "$2"; then
|
||||||
|
echo "INSTALLER"
|
||||||
|
else
|
||||||
|
echo "NOT_INSTALLER"
|
||||||
|
fi
|
||||||
|
' bash "$PROJECT_ROOT/bin/installer.sh" "$HOME/Downloads/app.zip"
|
||||||
|
|
||||||
|
[ "$status" -eq 0 ]
|
||||||
|
[[ "$output" == "INSTALLER" ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
# Integration tests: ZIP scanning inside scan_all_installers
|
||||||
|
|
||||||
|
@test "scan_all_installers: finds installer ZIP in Downloads" {
|
||||||
|
if ! require_zip_support; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Create a valid installer ZIP (contains .app)
|
||||||
|
mkdir -p "$HOME/Downloads/app-content"
|
||||||
|
touch "$HOME/Downloads/app-content/MyApp.app"
|
||||||
|
(cd "$HOME/Downloads" && zip -q -r installer.zip app-content)
|
||||||
|
|
||||||
|
run bash -euo pipefail -c '
|
||||||
|
export MOLE_TEST_MODE=1
|
||||||
|
source "$1"
|
||||||
|
scan_all_installers
|
||||||
|
' bash "$PROJECT_ROOT/bin/installer.sh"
|
||||||
|
|
||||||
|
[ "$status" -eq 0 ]
|
||||||
|
[[ "$output" == *"installer.zip"* ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "scan_all_installers: ignores non-installer ZIP in Downloads" {
|
||||||
|
if ! require_zip_support; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Create a non-installer ZIP (only regular files)
|
||||||
|
mkdir -p "$HOME/Downloads/data"
|
||||||
|
touch "$HOME/Downloads/data/file1.txt"
|
||||||
|
touch "$HOME/Downloads/data/file2.pdf"
|
||||||
|
(cd "$HOME/Downloads" && zip -q -r data.zip data)
|
||||||
|
|
||||||
|
run bash -euo pipefail -c '
|
||||||
|
export MOLE_TEST_MODE=1
|
||||||
|
source "$1"
|
||||||
|
scan_all_installers
|
||||||
|
' bash "$PROJECT_ROOT/bin/installer.sh"
|
||||||
|
|
||||||
|
[ "$status" -eq 0 ]
|
||||||
|
[[ "$output" != *"data.zip"* ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
# Failure path tests for scan_installers_in_path
|
||||||
|
|
||||||
|
@test "scan_installers_in_path: skips corrupt ZIP files" {
|
||||||
|
if ! require_zip_list; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Create a corrupt ZIP file by just writing garbage data
|
||||||
|
echo "This is not a valid ZIP file" > "$HOME/Downloads/corrupt.zip"
|
||||||
|
|
||||||
|
run bash -euo pipefail -c '
|
||||||
|
export MOLE_TEST_MODE=1
|
||||||
|
source "$1"
|
||||||
|
scan_installers_in_path "$2"
|
||||||
|
' bash "$PROJECT_ROOT/bin/installer.sh" "$HOME/Downloads"
|
||||||
|
|
||||||
|
# Should succeed (return 0) and silently skip the corrupt ZIP
|
||||||
|
[ "$status" -eq 0 ]
|
||||||
|
# Output should be empty since corrupt.zip is not a valid installer
|
||||||
|
[[ -z "$output" ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "scan_installers_in_path: handles permission-denied files" {
|
||||||
|
if ! require_zip_support; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Create a valid installer ZIP
|
||||||
|
mkdir -p "$HOME/Downloads/app-content"
|
||||||
|
touch "$HOME/Downloads/app-content/MyApp.app"
|
||||||
|
(cd "$HOME/Downloads" && zip -q -r readable.zip app-content)
|
||||||
|
|
||||||
|
# Create a readable installer ZIP alongside a permission-denied file
|
||||||
|
mkdir -p "$HOME/Downloads/restricted-app"
|
||||||
|
touch "$HOME/Downloads/restricted-app/App.app"
|
||||||
|
(cd "$HOME/Downloads" && zip -q -r restricted.zip restricted-app)
|
||||||
|
|
||||||
|
# Remove read permissions from restricted.zip
|
||||||
|
chmod 000 "$HOME/Downloads/restricted.zip"
|
||||||
|
|
||||||
|
run bash -euo pipefail -c '
|
||||||
|
export MOLE_TEST_MODE=1
|
||||||
|
source "$1"
|
||||||
|
scan_installers_in_path "$2"
|
||||||
|
' bash "$PROJECT_ROOT/bin/installer.sh" "$HOME/Downloads"
|
||||||
|
|
||||||
|
# Should succeed and find the readable.zip but skip restricted.zip
|
||||||
|
[ "$status" -eq 0 ]
|
||||||
|
[[ "$output" == *"readable.zip"* ]]
|
||||||
|
[[ "$output" != *"restricted.zip"* ]]
|
||||||
|
|
||||||
|
# Cleanup: restore permissions for teardown
|
||||||
|
chmod 644 "$HOME/Downloads/restricted.zip"
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "scan_installers_in_path: finds installer ZIP alongside corrupt ZIPs" {
|
||||||
|
if ! require_zip_support; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Create a valid installer ZIP
|
||||||
|
mkdir -p "$HOME/Downloads/app-content"
|
||||||
|
touch "$HOME/Downloads/app-content/MyApp.app"
|
||||||
|
(cd "$HOME/Downloads" && zip -q -r valid-installer.zip app-content)
|
||||||
|
|
||||||
|
# Create a corrupt ZIP
|
||||||
|
echo "garbage data" > "$HOME/Downloads/corrupt.zip"
|
||||||
|
|
||||||
|
run bash -euo pipefail -c '
|
||||||
|
export MOLE_TEST_MODE=1
|
||||||
|
source "$1"
|
||||||
|
scan_installers_in_path "$2"
|
||||||
|
' bash "$PROJECT_ROOT/bin/installer.sh" "$HOME/Downloads"
|
||||||
|
|
||||||
|
# Should find the valid ZIP and silently skip the corrupt one
|
||||||
|
[ "$status" -eq 0 ]
|
||||||
|
[[ "$output" == *"valid-installer.zip"* ]]
|
||||||
|
[[ "$output" != *"corrupt.zip"* ]]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user