#!/bin/bash # Mole - Main CLI entrypoint. # Routes subcommands and interactive menu. # Handles update/remove flows. set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" source "$SCRIPT_DIR/lib/core/common.sh" source "$SCRIPT_DIR/lib/core/commands.sh" trap cleanup_temp_files EXIT INT TERM # Version and update helpers VERSION="1.17.0" MOLE_TAGLINE="Deep clean and optimize your Mac." is_touchid_configured() { local pam_sudo_file="/etc/pam.d/sudo" [[ -f "$pam_sudo_file" ]] && grep -q "pam_tid.so" "$pam_sudo_file" 2> /dev/null } get_latest_version() { curl -fsSL --connect-timeout 2 --max-time 3 -H "Cache-Control: no-cache" \ "https://raw.githubusercontent.com/tw93/mole/main/mole" 2> /dev/null | grep '^VERSION=' | head -1 | sed 's/VERSION="\(.*\)"/\1/' } get_latest_version_from_github() { local version version=$(curl -fsSL --connect-timeout 2 --max-time 3 \ "https://api.github.com/repos/tw93/mole/releases/latest" 2> /dev/null | grep '"tag_name"' | head -1 | sed -E 's/.*"([^"]+)".*/\1/') version="${version#v}" version="${version#V}" echo "$version" } # Install detection (Homebrew vs manual). is_homebrew_install() { local mole_path mole_path=$(command -v mole 2> /dev/null) || return 1 if [[ -L "$mole_path" ]] && readlink "$mole_path" | grep -q "Cellar/mole"; then if command -v brew > /dev/null 2>&1; then brew list --formula 2> /dev/null | grep -q "^mole$" && return 0 else return 1 fi fi if [[ -f "$mole_path" ]]; then case "$mole_path" in /opt/homebrew/bin/mole | /usr/local/bin/mole) if [[ -d /opt/homebrew/Cellar/mole ]] || [[ -d /usr/local/Cellar/mole ]]; then if command -v brew > /dev/null 2>&1; then brew list --formula 2> /dev/null | grep -q "^mole$" && return 0 else return 0 # Cellar exists, probably Homebrew install fi fi ;; esac fi if command -v brew > /dev/null 2>&1; then local brew_prefix brew_prefix=$(brew --prefix 2> /dev/null) if [[ -n "$brew_prefix" && "$mole_path" == "$brew_prefix/bin/mole" && -d "$brew_prefix/Cellar/mole" ]]; then brew list --formula 2> /dev/null | grep -q "^mole$" && return 0 fi fi return 1 } # Background update notice check_for_updates() { local msg_cache="$HOME/.cache/mole/update_message" ensure_user_dir "$(dirname "$msg_cache")" ensure_user_file "$msg_cache" ( local latest latest=$(get_latest_version_from_github) if [[ -z "$latest" ]]; then latest=$(get_latest_version) fi if [[ -n "$latest" && "$VERSION" != "$latest" && "$(printf '%s\n' "$VERSION" "$latest" | sort -V | head -1)" == "$VERSION" ]]; then printf "\nUpdate available: %s → %s, run %smo update%s\n\n" "$VERSION" "$latest" "$GREEN" "$NC" > "$msg_cache" else echo -n > "$msg_cache" fi ) & disown 2> /dev/null || true } show_update_notification() { local msg_cache="$HOME/.cache/mole/update_message" if [[ -f "$msg_cache" && -s "$msg_cache" ]]; then cat "$msg_cache" echo fi } # UI helpers show_brand_banner() { cat << EOF ${GREEN} __ __ _ ${NC} ${GREEN}| \/ | ___ | | ___ ${NC} ${GREEN}| |\/| |/ _ \| |/ _ \\${NC} ${GREEN}| | | | (_) | | __/${NC} ${BLUE}https://github.com/tw93/mole${NC} ${GREEN}|_| |_|\___/|_|\___|${NC} ${GREEN}${MOLE_TAGLINE}${NC} EOF } animate_mole_intro() { if [[ ! -t 1 ]]; then return fi clear_screen printf '\n' hide_cursor local -a mole_lines=() if is_christmas_season; then while IFS= read -r line; do mole_lines+=("$line") done << 'EOF' * /o\ {/\_/\} ____/ o o \ /~____ =o= / (______)__m_m) / \ __/ /\ \__ /__/ \__\_ EOF else while IFS= read -r line; do mole_lines+=("$line") done << 'EOF' /\_/\ ____/ o o \ /~____ =o= / (______)__m_m) / \ __/ /\ \__ /__/ \__\_ EOF fi local idx local hat_color="${RED}" local body_cutoff local body_color="${PURPLE}" local ground_color="${GREEN}" if is_christmas_season; then body_cutoff=6 for idx in "${!mole_lines[@]}"; do if ((idx < 3)); then printf "%s\n" "${hat_color}${mole_lines[$idx]}${NC}" elif ((idx < body_cutoff)); then printf "%s\n" "${body_color}${mole_lines[$idx]}${NC}" else printf "%s\n" "${ground_color}${mole_lines[$idx]}${NC}" fi sleep 0.1 done else body_cutoff=4 for idx in "${!mole_lines[@]}"; do if ((idx < body_cutoff)); then printf "%s\n" "${body_color}${mole_lines[$idx]}${NC}" else printf "%s\n" "${ground_color}${mole_lines[$idx]}${NC}" fi sleep 0.1 done fi printf '\n' sleep 0.5 printf '\033[2J\033[H' show_cursor } show_version() { local os_ver if command -v sw_vers > /dev/null; then os_ver=$(sw_vers -productVersion) else os_ver="Unknown" fi local arch arch=$(uname -m) local kernel kernel=$(uname -r) local sip_status if command -v csrutil > /dev/null; then sip_status=$(csrutil status 2> /dev/null | grep -o "enabled\|disabled" || echo "Unknown") sip_status="$(tr '[:lower:]' '[:upper:]' <<< "${sip_status:0:1}")${sip_status:1}" else sip_status="Unknown" fi local disk_free disk_free=$(df -h / 2> /dev/null | awk 'NR==2 {print $4}' || echo "Unknown") local install_method="Manual" if is_homebrew_install; then install_method="Homebrew" fi printf '\nMole version %s\n' "$VERSION" printf 'macOS: %s\n' "$os_ver" printf 'Architecture: %s\n' "$arch" printf 'Kernel: %s\n' "$kernel" printf 'SIP: %s\n' "$sip_status" printf 'Disk Free: %s\n' "$disk_free" printf 'Install: %s\n' "$install_method" printf 'Shell: %s\n\n' "${SHELL:-Unknown}" } show_help() { show_brand_banner echo printf "%s%s%s\n" "$BLUE" "COMMANDS" "$NC" printf " %s%-28s%s %s\n" "$GREEN" "mo" "$NC" "Main menu" for entry in "${MOLE_COMMANDS[@]}"; do local name="${entry%%:*}" local desc="${entry#*:}" local display="mo $name" [[ "$name" == "help" ]] && display="mo --help" [[ "$name" == "version" ]] && display="mo --version" printf " %s%-28s%s %s\n" "$GREEN" "$display" "$NC" "$desc" done echo printf " %s%-28s%s %s\n" "$GREEN" "mo clean --dry-run" "$NC" "Preview cleanup" printf " %s%-28s%s %s\n" "$GREEN" "mo clean --whitelist" "$NC" "Manage protected caches" printf " %s%-28s%s %s\n" "$GREEN" "mo optimize --dry-run" "$NC" "Preview optimization" printf " %s%-28s%s %s\n" "$GREEN" "mo optimize --whitelist" "$NC" "Manage protected items" printf " %s%-28s%s %s\n" "$GREEN" "mo purge --paths" "$NC" "Configure scan directories" printf " %s%-28s%s %s\n" "$GREEN" "mo installers --debug" "$NC" "Find installer files" echo printf "%s%s%s\n" "$BLUE" "OPTIONS" "$NC" printf " %s%-28s%s %s\n" "$GREEN" "--debug" "$NC" "Show detailed operation logs" echo } # Update flow (Homebrew or installer). update_mole() { local update_interrupted=false trap 'update_interrupted=true; echo ""; exit 130' INT TERM if is_homebrew_install; then update_via_homebrew "$VERSION" exit 0 fi local latest latest=$(get_latest_version_from_github) [[ -z "$latest" ]] && latest=$(get_latest_version) if [[ -z "$latest" ]]; then log_error "Unable to check for updates. Check network connection." echo -e "${YELLOW}Tip:${NC} Check if you can access GitHub (https://github.com)" echo -e "${YELLOW}Tip:${NC} Try again with: ${GRAY}mo update${NC}" exit 1 fi if [[ "$VERSION" == "$latest" ]]; then echo "" echo -e "${GREEN}${ICON_SUCCESS}${NC} Already on latest version (${VERSION})" echo "" exit 0 fi if [[ -t 1 ]]; then start_inline_spinner "Downloading latest version..." else echo "Downloading latest version..." fi local installer_url="https://raw.githubusercontent.com/tw93/mole/main/install.sh" local tmp_installer tmp_installer="$(mktemp_file)" || { log_error "Update failed" exit 1 } local download_error="" if command -v curl > /dev/null 2>&1; then download_error=$(curl -fsSL --connect-timeout 10 --max-time 60 "$installer_url" -o "$tmp_installer" 2>&1) || { local curl_exit=$? if [[ -t 1 ]]; then stop_inline_spinner; fi rm -f "$tmp_installer" log_error "Update failed (curl error: $curl_exit)" case $curl_exit in 6) echo -e "${YELLOW}Tip:${NC} Could not resolve host. Check DNS or network connection." ;; 7) echo -e "${YELLOW}Tip:${NC} Failed to connect. Check network or proxy settings." ;; 22) echo -e "${YELLOW}Tip:${NC} HTTP 404 Not Found. The installer may have moved." ;; 28) echo -e "${YELLOW}Tip:${NC} Connection timed out. Try again or check firewall." ;; *) echo -e "${YELLOW}Tip:${NC} Check network connection and try again." ;; esac echo -e "${YELLOW}Tip:${NC} URL: $installer_url" exit 1 } elif command -v wget > /dev/null 2>&1; then download_error=$(wget --timeout=10 --tries=3 -qO "$tmp_installer" "$installer_url" 2>&1) || { if [[ -t 1 ]]; then stop_inline_spinner; fi rm -f "$tmp_installer" log_error "Update failed (wget error)" echo -e "${YELLOW}Tip:${NC} Check network connection and try again." echo -e "${YELLOW}Tip:${NC} URL: $installer_url" exit 1 } else if [[ -t 1 ]]; then stop_inline_spinner; fi rm -f "$tmp_installer" log_error "curl or wget required" echo -e "${YELLOW}Tip:${NC} Install curl with: ${GRAY}brew install curl${NC}" exit 1 fi if [[ -t 1 ]]; then stop_inline_spinner; fi chmod +x "$tmp_installer" local mole_path mole_path="$(command -v mole 2> /dev/null || echo "$0")" local install_dir install_dir="$(cd "$(dirname "$mole_path")" && pwd)" local requires_sudo="false" if [[ ! -w "$install_dir" ]]; then requires_sudo="true" elif [[ -e "$install_dir/mole" && ! -w "$install_dir/mole" ]]; then requires_sudo="true" fi if [[ "$requires_sudo" == "true" ]]; then if ! request_sudo_access "Mole update requires admin access"; then log_error "Update aborted (admin access denied)" rm -f "$tmp_installer" exit 1 fi fi if [[ -t 1 ]]; then start_inline_spinner "Installing update..." else echo "Installing update..." fi process_install_output() { local output="$1" if [[ -t 1 ]]; then stop_inline_spinner; fi local filtered_output filtered_output=$(printf '%s\n' "$output" | sed '/^$/d') if [[ -n "$filtered_output" ]]; then printf '\n%s\n' "$filtered_output" fi if ! printf '%s\n' "$output" | grep -Eq "Updated to latest version|Already on latest version"; then local new_version new_version=$("$mole_path" --version 2> /dev/null | awk 'NF {print $NF}' || echo "") printf '\n%s\n\n' "${GREEN}${ICON_SUCCESS}${NC} Updated to latest version (${new_version:-unknown})" else printf '\n' fi } local install_output local update_tag="V${latest#V}" local config_dir="${MOLE_CONFIG_DIR:-$SCRIPT_DIR}" if [[ ! -f "$config_dir/lib/core/common.sh" ]]; then config_dir="$HOME/.config/mole" fi if install_output=$(MOLE_VERSION="$update_tag" "$tmp_installer" --prefix "$install_dir" --config "$config_dir" --update 2>&1); then process_install_output "$install_output" else if install_output=$(MOLE_VERSION="$update_tag" "$tmp_installer" --prefix "$install_dir" --config "$config_dir" 2>&1); then process_install_output "$install_output" else if [[ -t 1 ]]; then stop_inline_spinner; fi rm -f "$tmp_installer" log_error "Update failed" echo "$install_output" | tail -10 >&2 # Show last 10 lines of error exit 1 fi fi rm -f "$tmp_installer" rm -f "$HOME/.cache/mole/update_message" } # Remove flow (Homebrew + manual + config/cache). remove_mole() { if [[ -t 1 ]]; then start_inline_spinner "Detecting Mole installations..." else echo "Detecting installations..." fi local is_homebrew=false local brew_cmd="" local brew_has_mole="false" local -a manual_installs=() local -a alias_installs=() if command -v brew > /dev/null 2>&1; then brew_cmd="brew" elif [[ -x "/opt/homebrew/bin/brew" ]]; then brew_cmd="/opt/homebrew/bin/brew" elif [[ -x "/usr/local/bin/brew" ]]; then brew_cmd="/usr/local/bin/brew" fi if [[ -n "$brew_cmd" ]]; then if "$brew_cmd" list --formula 2> /dev/null | grep -q "^mole$"; then brew_has_mole="true" fi fi if [[ "$brew_has_mole" == "true" ]] || is_homebrew_install; then is_homebrew=true fi local found_mole found_mole=$(command -v mole 2> /dev/null || true) if [[ -n "$found_mole" && -f "$found_mole" ]]; then if [[ ! -L "$found_mole" ]] || ! readlink "$found_mole" | grep -q "Cellar/mole"; then manual_installs+=("$found_mole") fi fi local -a fallback_paths=( "/usr/local/bin/mole" "$HOME/.local/bin/mole" "/opt/local/bin/mole" ) for path in "${fallback_paths[@]}"; do if [[ -f "$path" && "$path" != "$found_mole" ]]; then if [[ ! -L "$path" ]] || ! readlink "$path" | grep -q "Cellar/mole"; then manual_installs+=("$path") fi fi done local found_mo found_mo=$(command -v mo 2> /dev/null || true) if [[ -n "$found_mo" && -f "$found_mo" ]]; then alias_installs+=("$found_mo") fi local -a alias_fallback=( "/usr/local/bin/mo" "$HOME/.local/bin/mo" "/opt/local/bin/mo" ) for alias in "${alias_fallback[@]}"; do if [[ -f "$alias" && "$alias" != "$found_mo" ]]; then alias_installs+=("$alias") fi done if [[ -t 1 ]]; then stop_inline_spinner fi printf '\n' local manual_count=${#manual_installs[@]} local alias_count=${#alias_installs[@]} if [[ "$is_homebrew" == "false" && ${manual_count:-0} -eq 0 && ${alias_count:-0} -eq 0 ]]; then printf '%s\n\n' "${YELLOW}No Mole installation detected${NC}" exit 0 fi echo -e "${YELLOW}Remove Mole${NC} - will delete the following:" if [[ "$is_homebrew" == "true" ]]; then echo " - Mole via Homebrew" fi for install in ${manual_installs[@]+"${manual_installs[@]}"} ${alias_installs[@]+"${alias_installs[@]}"}; do echo " - $install" done echo " - ~/.config/mole" echo " - ~/.cache/mole" echo -ne "${PURPLE}${ICON_ARROW}${NC} Press ${GREEN}Enter${NC} to confirm, ${GRAY}ESC${NC} to cancel: " IFS= read -r -s -n1 key || key="" drain_pending_input # Clean up any escape sequence remnants case "$key" in $'\e') exit 0 ;; "" | $'\n' | $'\r') printf "\r\033[K" # Clear the prompt line ;; *) exit 0 ;; esac local has_error=false if [[ "$is_homebrew" == "true" ]]; then if [[ -z "$brew_cmd" ]]; then log_error "Homebrew command not found. Please ensure Homebrew is installed and in your PATH." log_warning "You may need to manually run: brew uninstall --force mole" exit 1 fi log_admin "Attempting to uninstall Mole via Homebrew..." local brew_uninstall_output if ! brew_uninstall_output=$("$brew_cmd" uninstall --force mole 2>&1); then has_error=true log_error "Homebrew uninstallation failed:" printf "%s\n" "$brew_uninstall_output" | sed "s/^/${RED} | ${NC}/" >&2 log_warning "Please manually run: ${YELLOW}brew uninstall --force mole${NC}" echo "" # Add a blank line for readability else log_success "Mole uninstalled via Homebrew." fi fi if [[ ${manual_count:-0} -gt 0 ]]; then for install in "${manual_installs[@]}"; do if [[ -f "$install" ]]; then if [[ ! -w "$(dirname "$install")" ]]; then if ! sudo rm -f "$install" 2> /dev/null; then has_error=true fi else if ! rm -f "$install" 2> /dev/null; then has_error=true fi fi fi done fi if [[ ${alias_count:-0} -gt 0 ]]; then for alias in "${alias_installs[@]}"; do if [[ -f "$alias" ]]; then if [[ ! -w "$(dirname "$alias")" ]]; then if ! sudo rm -f "$alias" 2> /dev/null; then has_error=true fi else if ! rm -f "$alias" 2> /dev/null; then has_error=true fi fi fi done fi if [[ -d "$HOME/.cache/mole" ]]; then rm -rf "$HOME/.cache/mole" 2> /dev/null || true fi if [[ -d "$HOME/.config/mole" ]]; then rm -rf "$HOME/.config/mole" 2> /dev/null || true fi local final_message if [[ "$has_error" == "true" ]]; then final_message="${YELLOW}${ICON_ERROR} Mole uninstalled with some errors, thank you for using Mole!${NC}" else final_message="${GREEN}${ICON_SUCCESS} Mole uninstalled successfully, thank you for using Mole!${NC}" fi printf '\n%s\n\n' "$final_message" exit 0 } # Menu UI show_main_menu() { local selected="${1:-1}" local _full_draw="${2:-true}" # Kept for compatibility (unused) local banner="${MAIN_MENU_BANNER:-}" local update_message="${MAIN_MENU_UPDATE_MESSAGE:-}" if [[ -z "$banner" ]]; then banner="$(show_brand_banner)" MAIN_MENU_BANNER="$banner" fi printf '\033[H' local line="" printf '\r\033[2K\n' while IFS= read -r line || [[ -n "$line" ]]; do printf '\r\033[2K%s\n' "$line" done <<< "$banner" if [[ -n "$update_message" ]]; then while IFS= read -r line || [[ -n "$line" ]]; do printf '\r\033[2K%s\n' "$line" done <<< "$update_message" fi printf '\r\033[2K\n' printf '\r\033[2K%s\n' "$(show_menu_option 1 "Clean Free up disk space" "$([[ $selected -eq 1 ]] && echo true || echo false)")" printf '\r\033[2K%s\n' "$(show_menu_option 2 "Uninstall Remove apps completely" "$([[ $selected -eq 2 ]] && echo true || echo false)")" printf '\r\033[2K%s\n' "$(show_menu_option 3 "Optimize Check and maintain system" "$([[ $selected -eq 3 ]] && echo true || echo false)")" printf '\r\033[2K%s\n' "$(show_menu_option 4 "Analyze Explore disk usage" "$([[ $selected -eq 4 ]] && echo true || echo false)")" printf '\r\033[2K%s\n' "$(show_menu_option 5 "Status Monitor system health" "$([[ $selected -eq 5 ]] && echo true || echo false)")" if [[ -t 0 ]]; then printf '\r\033[2K\n' local controls="${GRAY}↑↓ | Enter | M More | " if ! is_touchid_configured; then controls="${controls}T TouchID" else controls="${controls}U Update" fi controls="${controls} | Q Quit${NC}" printf '\r\033[2K%s\n' "$controls" printf '\r\033[2K\n' fi printf '\033[J' } interactive_main_menu() { if [[ -t 1 ]]; then local tty_name tty_name=$(tty 2> /dev/null || echo "") if [[ -n "$tty_name" ]]; then local flag_file local cache_dir="$HOME/.cache/mole" ensure_user_dir "$cache_dir" flag_file="$cache_dir/intro_$(echo "$tty_name" | tr -c '[:alnum:]_' '_')" if [[ ! -f "$flag_file" ]]; then animate_mole_intro ensure_user_file "$flag_file" fi fi fi local current_option=1 local first_draw=true local brand_banner="" local msg_cache="$HOME/.cache/mole/update_message" local update_message="" brand_banner="$(show_brand_banner)" MAIN_MENU_BANNER="$brand_banner" if [[ -f "$msg_cache" && -s "$msg_cache" ]]; then update_message="$(cat "$msg_cache" 2> /dev/null || echo "")" fi MAIN_MENU_UPDATE_MESSAGE="$update_message" cleanup_and_exit() { show_cursor exit 0 } trap cleanup_and_exit INT hide_cursor while true; do show_main_menu $current_option "$first_draw" if [[ "$first_draw" == "true" ]]; then first_draw=false fi local key if ! key=$(read_key); then continue fi case "$key" in "UP") ((current_option > 1)) && ((current_option--)) ;; "DOWN") ((current_option < 5)) && ((current_option++)) ;; "ENTER") show_cursor case $current_option in 1) exec "$SCRIPT_DIR/bin/clean.sh" ;; 2) exec "$SCRIPT_DIR/bin/uninstall.sh" ;; 3) exec "$SCRIPT_DIR/bin/optimize.sh" ;; 4) exec "$SCRIPT_DIR/bin/analyze.sh" ;; 5) exec "$SCRIPT_DIR/bin/status.sh" ;; esac ;; "CHAR:1") show_cursor exec "$SCRIPT_DIR/bin/clean.sh" ;; "CHAR:2") show_cursor exec "$SCRIPT_DIR/bin/uninstall.sh" ;; "CHAR:3") show_cursor exec "$SCRIPT_DIR/bin/optimize.sh" ;; "CHAR:4") show_cursor exec "$SCRIPT_DIR/bin/analyze.sh" ;; "CHAR:5") show_cursor exec "$SCRIPT_DIR/bin/status.sh" ;; "MORE") show_cursor clear show_help exit 0 ;; "VERSION") show_cursor clear show_version exit 0 ;; "TOUCHID") show_cursor exec "$SCRIPT_DIR/bin/touchid.sh" ;; "UPDATE") show_cursor clear update_mole exit 0 ;; "QUIT") cleanup_and_exit ;; esac drain_pending_input done } # CLI dispatch main() { local -a args=() for arg in "$@"; do case "$arg" in --debug) export MO_DEBUG=1 ;; *) args+=("$arg") ;; esac done case "${args[0]:-""}" in "optimize") exec "$SCRIPT_DIR/bin/optimize.sh" "${args[@]:1}" ;; "clean") exec "$SCRIPT_DIR/bin/clean.sh" "${args[@]:1}" ;; "uninstall") exec "$SCRIPT_DIR/bin/uninstall.sh" "${args[@]:1}" ;; "analyze") exec "$SCRIPT_DIR/bin/analyze.sh" "${args[@]:1}" ;; "status") exec "$SCRIPT_DIR/bin/status.sh" "${args[@]:1}" ;; "purge") exec "$SCRIPT_DIR/bin/purge.sh" "${args[@]:1}" ;; "installers") exec "$SCRIPT_DIR/bin/installers.sh" "${args[@]:1}" ;; "touchid") exec "$SCRIPT_DIR/bin/touchid.sh" "${args[@]:1}" ;; "completion") exec "$SCRIPT_DIR/bin/completion.sh" "${args[@]:1}" ;; "update") update_mole exit 0 ;; "remove") remove_mole ;; "help" | "--help" | "-h") show_help exit 0 ;; "version" | "--version" | "-V") show_version exit 0 ;; "") check_for_updates interactive_main_menu ;; *) echo "Unknown command: ${args[0]}" echo "Use 'mole --help' for usage information." exit 1 ;; esac } main "$@"