#!/bin/bash # Mole - Main CLI entrypoint. # Routes subcommands and interactive menu. # Handles update/remove flows. set -euo pipefail # User state and installed tools must never run with inherited root privileges. # Individual maintenance operations request administrator access themselves. if [[ "$EUID" -eq 0 ]]; then printf '%s\n' 'Run Mole without sudo; it requests administrator access when needed.' >&2 exit 1 fi # Refuse an unknown working directory before path resolution or user state. # Keep valid cwd unchanged: analyze and other commands accept relative paths. # Bash may print its own startup diagnostic before this script can run. # install.sh and mo update verify a new install with --version and --help # from the caller's cwd, so those two answer from / when Mole was invoked by # an absolute path; refusing them reported a completed update as failed. if ! /bin/pwd -P > /dev/null 2>&1; then case "${1:-}" in --version | -V | version | --help | -h | help) [[ "${BASH_SOURCE[0]}" == /* ]] && cd / ;; esac if ! /bin/pwd -P > /dev/null 2>&1; then printf '%s\n' 'Cannot access the current directory. Run cd ~, then retry Mole.' >&2 exit 1 fi fi # Resolve where lib/ lives. bash 3.2 BASH_SOURCE[0] names the invoked path, # not the physical target. # Prefer the invoked directory when it already holds lib/ (Homebrew prefix # layout: bin/mo -> Cellar/.../mole, with lib next to bin/). Otherwise walk # the symlink for unpinned custom layouts such as # /usr/local/bin/mole -> /opt/mole/mole. Official install.sh and Homebrew # still pin SCRIPT_DIR with sed. _mole_invoked="${BASH_SOURCE[0]}" _mole_invoked_dir="$(cd "$(dirname "$_mole_invoked")" && pwd)" if [[ -f "$_mole_invoked_dir/lib/core/common.sh" ]]; then SCRIPT_PATH="$_mole_invoked_dir/$(basename "$_mole_invoked")" elif command -v readlink > /dev/null 2>&1 && readlink -f / > /dev/null 2>&1; then # GNU readlink -f (Linux coreutils): one-shot full canonicalization. SCRIPT_PATH="$(readlink -f "$_mole_invoked")" else # Portable fallback for BSD readlink (macOS), which lacks -f. Walk each # symlink level, resolving relative targets against their parent dir. SOURCE="$_mole_invoked" _mole_link_hops=0 while [ -L "$SOURCE" ]; do _mole_link_hops=$((_mole_link_hops + 1)) if [ "$_mole_link_hops" -gt 32 ]; then printf '%s\n' 'mole: too many symlink hops resolving the entrypoint' >&2 exit 1 fi DIR="$(cd -P "$(dirname "$SOURCE")" && pwd)" SOURCE="$(readlink "$SOURCE")" [[ "$SOURCE" != /* ]] && SOURCE="$DIR/$SOURCE" done unset _mole_link_hops SCRIPT_PATH="$(cd -P "$(dirname "$SOURCE")" && pwd)/$(basename "$SOURCE")" fi unset _mole_invoked _mole_invoked_dir SCRIPT_DIR="$(dirname "$SCRIPT_PATH")" # The path of THIS entrypoint, captured before anything is sourced. The update # flow reinstalls over the exact `mole` that was invoked, so it must resolve the # entrypoint, not the lib file it happens to run from. `BASH_SOURCE[0]` inside a # sourced function names that function's file, so the value has to be taken here. MOLE_ENTRY_SCRIPT="${BASH_SOURCE[0]}" declare -a MOLE_CLI_ARGS=() MOLE_CLI_DEBUG=false mole_collect_cli_args() { local arg MOLE_CLI_ARGS=() MOLE_CLI_DEBUG=false for arg in "$@"; do case "$arg" in --debug) MOLE_CLI_DEBUG=true ;; *) MOLE_CLI_ARGS+=("$arg") ;; esac done } mole_dispatch_history_early() { [[ "${MOLE_TEST_MODE:-0}" == "1" && "${MOLE_SKIP_MAIN:-0}" == "1" ]] && return 0 mole_collect_cli_args "$@" [[ "${MOLE_CLI_ARGS[0]:-}" == "history" ]] || return 0 [[ "$MOLE_CLI_DEBUG" == "true" ]] && export MO_DEBUG=1 if [[ "${#MOLE_CLI_ARGS[@]}" -gt 1 ]]; then exec "$SCRIPT_DIR/bin/history.sh" "${MOLE_CLI_ARGS[@]:1}" fi exec "$SCRIPT_DIR/bin/history.sh" } mole_dispatch_history_early "$@" 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.71.0" MOLE_TAGLINE="Deep clean and optimize your Mac." is_touchid_configured() { mole_pam_enables_touchid /etc/pam.d/sudo || mole_pam_enables_touchid /etc/pam.d/sudo_local } # Update and remove flows live in lib/manage/{update,remove}.sh; this file # stays a router. They are sourced (not exec'd) because the interactive menu # and the update banner call them in-process. source "$SCRIPT_DIR/lib/manage/update.sh" source "$SCRIPT_DIR/lib/manage/remove.sh" # Build the main-menu controls hint line. Pure so the show/hide logic can be # tested without a tty (the caller only renders it inside `[[ -t 0 ]]`). # Args: $1 touchid_configured (true/false), $2 show_update (true/false). _main_menu_controls_line() { local touchid_configured="$1" local show_update="$2" local controls="${GRAY}↑↓ | Enter | M More | V Version" if [[ "$touchid_configured" != "true" ]]; then controls="${controls} | T TouchID" fi if [[ "$show_update" == "true" ]]; then controls="${controls} | U Update" fi controls="${controls} | Q Quit${NC}" printf '%s' "$controls" } # Render-time look of the cached update notice: a blank line above and the # command in green. The cache and the after-command line stay plain text. _main_menu_update_notice() { local message="$1" case "$message" in *"mo update --nightly") message="${message%mo update --nightly}${GREEN}mo update --nightly${NC}" ;; *"mo update") message="${message%mo update}${GREEN}mo update${NC}" ;; esac printf '\n%s' "$message" } # 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 update_message="$(_main_menu_update_notice "$update_message")" 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 Refresh caches and services" "$([[ $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 touchid_state=false is_touchid_configured && touchid_state=true local controls controls=$(_main_menu_controls_line "$touchid_state" "${MAIN_MENU_SHOW_UPDATE:-false}") printf '\r\033[2K%s\n' "$controls" printf '\r\033[2K\n' fi printf '\033[J' } interactive_main_menu() { local original_stty="" if [[ -t 0 ]]; then original_stty=$(stty -g 2> /dev/null || true) 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" update_message="$(read_update_message_cache "$msg_cache")" MAIN_MENU_UPDATE_MESSAGE="$update_message" MAIN_MENU_SHOW_UPDATE="$([[ -n "$update_message" ]] && echo true || echo false)" cleanup_and_exit() { # An interrupted read_key subshell can leave echo and canonical input off. if [[ -n "$original_stty" ]]; then stty "$original_stty" 2> /dev/null || true fi show_cursor exit 0 } launch_menu_command() { show_cursor drain_pending_input 0.1 run_mole_command "$@" } trap cleanup_and_exit INT hide_cursor while true; do update_message="$(read_update_message_cache "$msg_cache")" if [[ "$update_message" != "$MAIN_MENU_UPDATE_MESSAGE" ]]; then MAIN_MENU_UPDATE_MESSAGE="$update_message" MAIN_MENU_SHOW_UPDATE="$([[ -n "$update_message" ]] && echo true || echo false)" first_draw=true fi if [[ "$first_draw" == "true" ]]; then show_main_menu $current_option "$first_draw" first_draw=false fi local key if ! key=$(read_key 1); then continue fi first_draw=true case "$key" in "UP") ((current_option > 1)) && ((current_option--)) ;; "DOWN") ((current_option < 5)) && ((current_option++)) ;; "ENTER") case $current_option in 1) launch_menu_command "$SCRIPT_DIR/bin/clean.sh" ;; 2) launch_menu_command "$SCRIPT_DIR/bin/uninstall.sh" ;; 3) launch_menu_command "$SCRIPT_DIR/bin/optimize.sh" ;; 4) launch_menu_command "$SCRIPT_DIR/bin/analyze.sh" ;; 5) launch_menu_command "$SCRIPT_DIR/bin/status.sh" ;; esac ;; "CHAR:1") launch_menu_command "$SCRIPT_DIR/bin/clean.sh" ;; "CHAR:2") launch_menu_command "$SCRIPT_DIR/bin/uninstall.sh" ;; "CHAR:3") launch_menu_command "$SCRIPT_DIR/bin/optimize.sh" ;; "CHAR:4") launch_menu_command "$SCRIPT_DIR/bin/analyze.sh" ;; "CHAR:5") launch_menu_command "$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") [[ "${MAIN_MENU_SHOW_UPDATE:-false}" == "true" ]] || continue show_cursor clear update_mole exit 0 ;; "QUIT") cleanup_and_exit ;; esac drain_pending_input done } # CLI dispatch main() { local -a args=() mole_collect_cli_args "$@" [[ "$MOLE_CLI_DEBUG" == "true" ]] && export MO_DEBUG=1 if [[ "${#MOLE_CLI_ARGS[@]}" -gt 0 ]]; then args=("${MOLE_CLI_ARGS[@]}") fi case "${args[0]:-""}" in "optimize" | "optimise") run_mole_command "$SCRIPT_DIR/bin/optimize.sh" "${args[@]:1}" ;; "clean") run_mole_command "$SCRIPT_DIR/bin/clean.sh" "${args[@]:1}" ;; "uninstall") run_mole_command "$SCRIPT_DIR/bin/uninstall.sh" "${args[@]:1}" ;; "analyze" | "analyse") run_mole_command "$SCRIPT_DIR/bin/analyze.sh" "${args[@]:1}" ;; "status") run_mole_command "$SCRIPT_DIR/bin/status.sh" "${args[@]:1}" ;; "purge") run_mole_command "$SCRIPT_DIR/bin/purge.sh" "${args[@]:1}" ;; "installer") run_mole_command "$SCRIPT_DIR/bin/installer.sh" "${args[@]:1}" ;; "slim") run_mole_command "$SCRIPT_DIR/bin/slim.sh" "${args[@]:1}" ;; "touchid") exec "$SCRIPT_DIR/bin/touchid.sh" "${args[@]:1}" ;; "completion") exec "$SCRIPT_DIR/bin/completion.sh" "${args[@]:1}" ;; "update") local force_update=false local nightly_update=false for arg in "${args[@]:1}"; do case "$arg" in --force | -f) force_update=true ;; --nightly) nightly_update=true ;; --help | -h) cat << 'EOF' Usage: mo update [--force] [--nightly] Update Mole to the latest release. Options: -f, --force Force reinstall latest stable version --nightly Install latest unreleased main branch build -h, --help Show this help message EOF exit 0 ;; *) echo "Unknown update option: $arg" >&2 echo "Use 'mole update [--force] [--nightly]' for supported options." >&2 exit 1 ;; esac done update_mole "$force_update" "$nightly_update" exit 0 ;; "remove") local dry_run_remove=false for arg in "${args[@]:1}"; do case "$arg" in "--dry-run" | "-n") dry_run_remove=true ;; "--help" | "-h") cat << 'EOF' Usage: mo remove [--dry-run] Remove Mole from this Mac. Operation logs in ~/Library/Logs/mole are kept. Options: -n, --dry-run Preview Mole removal -h, --help Show this help message EOF exit 0 ;; *) echo "Unknown remove option: $arg" >&2 echo "Use 'mole remove [--dry-run]' for supported options." >&2 exit 1 ;; esac done remove_mole "$dry_run_remove" ;; "help" | "--help" | "-h") show_help exit 0 ;; "version" | "--version" | "-V") show_version exit 0 ;; "") check_for_updates interactive_main_menu ;; *) # Errors belong on stderr so `mo $cmd > out` leaves the diagnostic # visible instead of burying it in the redirected file. echo "Unknown command: ${args[0]}" >&2 echo "Use 'mole --help' for usage information." >&2 exit 1 ;; esac } if [[ "${MOLE_TEST_MODE:-0}" == "1" && "${MOLE_SKIP_MAIN:-0}" == "1" ]]; then : else main "$@" fi