#!/bin/bash
# Mole - Installer for manual installs.
# Fetches source/binaries and installs to prefix.
# Supports update and edge installs.
set -euo pipefail
# The installed entrypoint refuses to run as root, so a root install would
# write every file and then fail its own verification probe with the wrong
# cause. Refuse up front with the same message, before anything is written.
refuse_root_invocation() {
local uid="${1:-0}"
[[ "$uid" -eq 0 ]] || return 0
printf '%s\n' 'Run Mole without sudo; it requests administrator access when needed.' >&2
return 1
}
refuse_root_invocation "${EUID:-0}" || exit 1
# Honor https://no-color.org: any non-empty NO_COLOR disables ANSI escapes.
if [[ -n "${NO_COLOR:-}" ]]; then
GREEN=''
BLUE=''
YELLOW=''
RED=''
NC=''
else
GREEN='\033[0;32m'
BLUE='\033[0;34m'
YELLOW='\033[1;33m'
RED='\033[0;31m'
NC='\033[0m'
fi
_SPINNER_PID=""
start_line_spinner() {
local msg="$1"
# A progress line only means something while it is being watched. When the
# output is captured, as `mo update` does, every one of these lands in the
# final block as a stale "Downloading..." / "Verifying..." line that the
# very next line contradicts. Say nothing there and let the result lines
# speak; a tty still gets the animation.
[[ -t 1 ]] || return 0
local chars="|/-\\"
# shellcheck disable=SC1003
[[ -z "$chars" ]] && chars='|/-\\'
local i=0
(while true; do
c="${chars:$((i % ${#chars})):1}"
printf "\r${BLUE}%s${NC} %s" "$c" "$msg"
i=$((i + 1))
sleep 0.12
done) &
_SPINNER_PID=$!
}
stop_line_spinner() { if [[ -n "$_SPINNER_PID" ]]; then
kill "$_SPINNER_PID" 2> /dev/null || true
wait "$_SPINNER_PID" 2> /dev/null || true
_SPINNER_PID=""
printf "\r\033[K"
fi; }
VERBOSE=1
# Icons duplicated from lib/core/common.sh (install.sh runs standalone).
# Avoid readonly to prevent conflicts when sourcing common.sh later.
ICON_SUCCESS="✓"
ICON_ADMIN="●"
ICON_CONFIRM="◎"
ICON_ERROR="☻"
log_info() { [[ ${VERBOSE} -eq 1 ]] && echo -e "${BLUE}$1${NC}"; }
log_success() { [[ ${VERBOSE} -eq 1 ]] && echo -e "${GREEN}${ICON_SUCCESS}${NC} $1"; }
log_warning() { [[ ${VERBOSE} -eq 1 ]] && echo -e "${YELLOW}$1${NC}"; }
log_error() { echo -e "${YELLOW}${ICON_ERROR}${NC} $1"; }
log_admin() { [[ ${VERBOSE} -eq 1 ]] && echo -e "${BLUE}${ICON_ADMIN}${NC} $1"; }
log_confirm() { [[ ${VERBOSE} -eq 1 ]] && echo -e "${BLUE}${ICON_CONFIRM}${NC} $1"; }
curl_download_with_retry() {
local url="$1"
local output_file="$2"
local attempt=1
local max_attempts=3
local curl_exit=0
# Capture curl's stderr per attempt and surface it only when the download
# ultimately fails: a transient TLS reset that the retry recovers from is
# not news, and printing it made successful installs look broken.
local curl_err=""
curl_err="$(mktemp "${TMPDIR:-/tmp}/mole-curl-err.XXXXXX")" || curl_err=""
while true; do
if curl -fsSL --connect-timeout 10 --max-time 60 "$url" -o "$output_file" \
2> "${curl_err:-/dev/null}"; then
[[ -n "$curl_err" ]] && rm -f "$curl_err" # SAFE: exact mktemp stderr file created above
return 0
else
curl_exit=$?
fi
rm -f "$output_file" 2> /dev/null || true
case "$curl_exit" in
6 | 7 | 18 | 28 | 35 | 52 | 55 | 56) ;;
*)
[[ -s "$curl_err" ]] && cat "$curl_err" >&2
[[ -n "$curl_err" ]] && rm -f "$curl_err" # SAFE: exact mktemp stderr file created above
return "$curl_exit"
;;
esac
if [[ "$attempt" -ge "$max_attempts" ]]; then
[[ -s "$curl_err" ]] && cat "$curl_err" >&2
[[ -n "$curl_err" ]] && rm -f "$curl_err" # SAFE: exact mktemp stderr file created above
return "$curl_exit"
fi
sleep 1 || {
[[ -n "$curl_err" ]] && rm -f "$curl_err" # SAFE: exact mktemp stderr file created above
return "$curl_exit"
}
attempt=$((attempt + 1))
done
}
safe_rm() {
local target="${1:-}"
local tmp_root
if [[ -z "$target" ]]; then
log_error "safe_rm: empty path"
return 1
fi
if [[ ! -e "$target" ]]; then
return 0
fi
tmp_root="${TMPDIR:-/tmp}"
while [[ "$tmp_root" != "/" && "$tmp_root" == */ ]]; do
tmp_root="${tmp_root%/}"
done
case "$target" in
"$tmp_root" | /tmp)
log_error "safe_rm: refusing to remove temp root: $target"
return 1
;;
"$tmp_root"/* | /tmp/*) ;;
*)
log_error "safe_rm: refusing to remove non-temp path: $target"
return 1
;;
esac
if [[ -d "$target" ]]; then
find "$target" -depth \( -type f -o -type l \) -exec rm -f {} + 2> /dev/null || true
find "$target" -depth -type d -exec rmdir {} + 2> /dev/null || true
else
rm -f "$target" 2> /dev/null || true
fi
}
# Install defaults
INSTALL_DIR="/usr/local/bin"
CONFIG_DIR="$HOME/.config/mole"
SOURCE_DIR=""
SOURCE_COMMIT_HASH=""
INSTALL_LOCK_PATH=""
INSTALL_LOCK_CONTROL=""
INSTALL_LOCK_HOLDER_PID=""
INSTALL_LOCK_USE_SUDO=false
# Why the last acquire_install_lock attempt failed. A bare "the lock is busy"
# message sent one reporter reverse-engineering the ancestor check by hand
# (#1335), so callers report the actual cause instead.
INSTALL_LOCK_FAILURE=""
INSTALL_LOCK_UNSAFE_ANCESTOR=""
# Which of the ancestor check's five independent rejections fired. They need
# different commands: chown does not clear an ACL and chmod does not turn a
# symlink into a directory, so one shared "it is writable" sentence sends the
# user to run something that changes nothing and retry into the same refusal.
INSTALL_LOCK_UNSAFE_ANCESTOR_REASON=""
INSTALL_SOURCE_TMP=""
ACTION="install"
# Resolve source dir (local checkout, env override, or download).
needs_sudo() {
if [[ -e "$INSTALL_DIR" ]]; then
[[ ! -w "$INSTALL_DIR" ]]
return
fi
local parent_dir
parent_dir="$(dirname "$INSTALL_DIR")"
[[ ! -w "$parent_dir" ]]
}
ensure_sudo_ready() {
if [[ "${MOLE_TEST_MODE:-0}" == "1" || "${MOLE_TEST_NO_AUTH:-0}" == "1" ]]; then
log_error "Admin access required, blocked in test mode"
return 1
fi
if [[ "${MOLE_ASSUME_SUDO_AUTH:-0}" == "1" ]]; then
sudo -n -v
return
fi
sudo -v
}
maybe_sudo() {
if needs_sudo; then
if [[ "${MOLE_TEST_MODE:-0}" == "1" || "${MOLE_TEST_NO_AUTH:-0}" == "1" ]]; then
log_error "Admin access required, blocked in test mode"
return 1
fi
if [[ "${MOLE_ASSUME_SUDO_AUTH:-0}" == "1" ]]; then
sudo -n "$@"
else
sudo "$@"
fi
else
"$@"
fi
}
# Keep verification of a newly installed executable bounded. The installer
# cannot source lib/core/timeout.sh until after it has installed the source
# tree, so use a small standalone wrapper for --version/--help probes.
run_install_probe_with_timeout() {
local duration="${1:-5}"
shift || true
[[ $# -gt 0 ]] || return 125
[[ "$duration" =~ ^[0-9]+(\.[0-9]+)?$ ]] || duration=5
local candidate timeout_bin=""
for candidate in gtimeout timeout; do
if command -v "$candidate" > /dev/null 2>&1; then
timeout_bin=$(command -v "$candidate")
break
fi
done
if [[ -n "$timeout_bin" ]]; then
"$timeout_bin" -k 1 "$duration" "$@"
return $?
fi
# Stock macOS has no timeout utility, but it does ship Perl. Supervise the
# probe from outside its process group so a shell wrapper and every child
# holding inherited output descriptors are terminated at the deadline.
local perl_bin=""
perl_bin=$(command -v perl 2> /dev/null || true)
[[ -n "$perl_bin" ]] || return 125
# shellcheck disable=SC2016 # Perl source is intentionally single-quoted.
"$perl_bin" -MPOSIX=:sys_wait_h,setpgid -MTime::HiRes=time,sleep -e '
my ($duration, @command) = @ARGV;
exit 125 unless @command;
my $pid = fork();
exit 125 unless defined $pid;
if ($pid == 0) {
my $setpgid_status = POSIX::setpgid(0, 0);
exit 126 unless defined $setpgid_status && $setpgid_status == 0;
exec { $command[0] } @command;
exit 127;
}
POSIX::setpgid($pid, $pid);
my $deadline = time() + $duration;
while (time() < $deadline) {
my $result = waitpid($pid, WNOHANG);
if ($result == $pid) {
exit(($? & 127) ? 128 + ($? & 127) : $? >> 8);
}
sleep(0.02);
}
kill "TERM", -$pid;
my $grace = time() + 1;
while (time() < $grace) {
my $result = waitpid($pid, WNOHANG);
exit 124 if $result == $pid;
sleep(0.02);
}
kill "KILL", -$pid;
waitpid($pid, 0);
exit 124;
' "$duration" "$@"
}
install_lock_has_unsafe_ancestor() {
local use_sudo="$1"
local probe="$INSTALL_DIR"
local current_uid owner_uid mode acl_listing
current_uid=$(id -u 2> /dev/null || true)
INSTALL_LOCK_UNSAFE_ANCESTOR="$probe"
INSTALL_LOCK_UNSAFE_ANCESTOR_REASON="unreadable"
[[ "$current_uid" =~ ^[0-9]+$ ]] || return 0
while true; do
# Record the directory under inspection so a rejection can name it.
INSTALL_LOCK_UNSAFE_ANCESTOR="$probe"
INSTALL_LOCK_UNSAFE_ANCESTOR_REASON="symlink"
[[ ! -L "$probe" ]] || return 0
owner_uid=$(/usr/bin/stat -f%u "$probe" 2> /dev/null || true)
mode=$(/usr/bin/stat -f%Lp "$probe" 2> /dev/null || true)
INSTALL_LOCK_UNSAFE_ANCESTOR_REASON="unreadable"
[[ "$owner_uid" =~ ^[0-9]+$ && "$mode" =~ ^[0-7]+$ ]] || return 0
if [[ "$use_sudo" == "true" || ${EUID:-0} -eq 0 ]]; then
INSTALL_LOCK_UNSAFE_ANCESTOR_REASON="not_root_owned"
[[ "$owner_uid" -eq 0 ]] || return 0
INSTALL_LOCK_UNSAFE_ANCESTOR_REASON="writable"
(((8#$mode & 0022) == 0)) || return 0
elif [[ "$owner_uid" -ne 0 && "$owner_uid" -ne "$current_uid" ]]; then
INSTALL_LOCK_UNSAFE_ANCESTOR_REASON="foreign_owner"
return 0
else
# A regular installer already writes through this tree. Accept
# group-writable prefixes, but keep world-writable paths closed.
INSTALL_LOCK_UNSAFE_ANCESTOR_REASON="writable"
(((8#$mode & 0002) == 0)) || return 0
fi
INSTALL_LOCK_UNSAFE_ANCESTOR_REASON="unreadable"
acl_listing=$(/bin/ls -lde "$probe" 2> /dev/null) || return 0
INSTALL_LOCK_UNSAFE_ANCESTOR_REASON="acl"
if printf '%s\n' "$acl_listing" |
/usr/bin/grep -Eq '^[[:space:]]+[0-9]+:.*[[:space:]]allow[[:space:]]'; then
return 0
fi
[[ "$probe" == "/" ]] && break
local parent_probe="${probe%/*}"
INSTALL_LOCK_UNSAFE_ANCESTOR_REASON="unreadable"
[[ "$parent_probe" != "$probe" ]] || return 0
probe="$parent_probe"
[[ -n "$probe" ]] || probe="/"
done
INSTALL_LOCK_UNSAFE_ANCESTOR=""
INSTALL_LOCK_UNSAFE_ANCESTOR_REASON=""
return 1
}
install_lock_process_start() {
local pid="$1"
LC_ALL=C /bin/ps -p "$pid" -o lstart= 2> /dev/null |
/usr/bin/sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//' | /usr/bin/head -1
}
install_lock_command() {
local use_sudo="$1"
shift
if [[ "$use_sudo" != "true" ]]; then
"$@"
return
fi
if [[ "${MOLE_TEST_MODE:-0}" == "1" || "${MOLE_TEST_NO_AUTH:-0}" == "1" ]]; then
return 1
fi
if [[ "${MOLE_ASSUME_SUDO_AUTH:-0}" == "1" ]]; then
sudo -n "$@"
else
sudo "$@"
fi
}
# One interactive recovery for a lapsed sudo session, through the controlling
# terminal so it works even when the installer's stdio is captured by a caller.
# Returns non-zero without prompting when there is no terminal to ask on.
install_lock_reauthenticate() {
if [[ "${MOLE_TEST_MODE:-0}" == "1" || "${MOLE_TEST_NO_AUTH:-0}" == "1" ]]; then
return 1
fi
[[ -r /dev/tty && -w /dev/tty ]] || return 1
# shellcheck disable=SC2024 # sudo's own prompt belongs on the same terminal.
sudo -v < /dev/tty > /dev/tty 2> /dev/tty
}
install_lock_current_shell_pid() {
local variable_name="$1"
local pid_file current_pid=""
pid_file=$(/usr/bin/mktemp /tmp/mole-install-pid.XXXXXX) || return 1
if ! /bin/sh -c 'printf "%s\n" "$PPID" > "$1"' sh "$pid_file"; then
safe_rm "$pid_file"
return 1
fi
IFS= read -r current_pid < "$pid_file" || true
safe_rm "$pid_file"
[[ "$current_pid" =~ ^[0-9]+$ ]] || return 1
printf -v "$variable_name" '%s' "$current_pid"
}
install_lock_prepare_dir() {
local use_sudo="$1"
local lock_dir="$INSTALL_DIR/.mole-update.lock"
local expected_uid owner_uid mode acl_listing
expected_uid=$(id -u 2> /dev/null || true)
[[ "$expected_uid" =~ ^[0-9]+$ ]] || return 1
if [[ ! -e "$lock_dir" && ! -L "$lock_dir" ]]; then
install_lock_command "$use_sudo" mkdir -m 0700 "$lock_dir" 2> /dev/null || return 1
fi
[[ -d "$lock_dir" && ! -L "$lock_dir" ]] || return 1
# macOS applies inherited ACLs even when mkdir requests mode 0700. Remove
# them before touching the lock file, then verify that no ACL entry remains.
# Once this succeeds, only the expected owner can mutate directory entries.
install_lock_command "$use_sudo" /bin/chmod -N "$lock_dir" 2> /dev/null || return 1
acl_listing=$(install_lock_command "$use_sudo" /bin/ls -lde "$lock_dir" 2> /dev/null) || return 1
if printf '%s\n' "$acl_listing" | /usr/bin/grep -Eq '^[[:space:]]+[0-9]+:'; then
return 1
fi
owner_uid=$(install_lock_command "$use_sudo" /usr/bin/stat -f%u "$lock_dir" 2> /dev/null || true)
mode=$(install_lock_command "$use_sudo" /usr/bin/stat -f%Lp "$lock_dir" 2> /dev/null || true)
if [[ "$use_sudo" == "true" ]]; then
expected_uid=0
fi
[[ "$owner_uid" == "$expected_uid" && "$mode" =~ ^[0-7]+$ ]] || return 1
(((8#$mode & 0077) == 0)) || return 1
}
install_lock_read_owner() {
local lock_path="$1"
local use_sudo="$2"
install_lock_command "$use_sudo" /bin/test -f "$lock_path" 2> /dev/null || return 1
! install_lock_command "$use_sudo" /bin/test -L "$lock_path" 2> /dev/null || return 1
install_lock_command "$use_sudo" cat "$lock_path" 2> /dev/null
}
install_lock_remove_control() {
local control_path="$1"
local use_sudo="$2"
local control_prefix="$INSTALL_DIR/.mole-update.lock/control."
local control_suffix
[[ -n "$control_path" ]] || return 0
[[ "$control_path" == "$control_prefix"* ]] || return 1
control_suffix="${control_path#"$control_prefix"}"
[[ -n "$control_suffix" && "$control_suffix" != */* ]] || return 1
install_lock_command "$use_sudo" /bin/test -f "$control_path" 2> /dev/null || return 1
! install_lock_command "$use_sudo" /bin/test -L "$control_path" 2> /dev/null || return 1
install_lock_command "$use_sudo" /bin/rm -f "$control_path" 2> /dev/null # SAFE: exact mktemp-created install lock control file.
}
acquire_install_lock() {
local lock_path="$INSTALL_DIR/.mole-update.lock/kernel.lock"
local control_path holder_pid owner_pid owner_start token owner_value="" attempt=0
local use_sudo=false
if [[ ${EUID:-0} -ne 0 && ! -w "$INSTALL_DIR" ]]; then
use_sudo=true
fi
INSTALL_LOCK_FAILURE="busy"
if install_lock_has_unsafe_ancestor "$use_sudo"; then
INSTALL_LOCK_FAILURE="unsafe_ancestor"
return 1
fi
# Probe admin access before the first privileged lock step. Without this the
# denial surfaces as a swallowed `sudo -n` inside install_lock_prepare_dir
# and gets reported as a busy lock.
if [[ "$use_sudo" == "true" ]] &&
! install_lock_command "$use_sudo" /usr/bin/true 2> /dev/null; then
# A caller that set MOLE_ASSUME_SUDO_AUTH did authenticate, but the
# session can lapse before this point on a slow download or when the
# keepalive dies. Recover once through the controlling terminal instead
# of failing an install that only needs the password again. Without a
# terminal this is a no-op and the refusal stands.
if ! install_lock_reauthenticate ||
! install_lock_command "$use_sudo" /usr/bin/true 2> /dev/null; then
INSTALL_LOCK_FAILURE="no_admin"
return 1
fi
fi
if ! install_lock_prepare_dir "$use_sudo"; then
INSTALL_LOCK_FAILURE="lock_dir"
return 1
fi
if install_lock_command "$use_sudo" /bin/test -e "$lock_path" 2> /dev/null ||
install_lock_command "$use_sudo" /bin/test -L "$lock_path" 2> /dev/null; then
INSTALL_LOCK_FAILURE="lock_path"
install_lock_command "$use_sudo" /bin/test -f "$lock_path" 2> /dev/null || return 1
! install_lock_command "$use_sudo" /bin/test -L "$lock_path" 2> /dev/null || return 1
INSTALL_LOCK_FAILURE="busy"
fi
# /usr/bin/lockf gives a kernel lock the OS drops even if the holder is
# killed -9, so prefer it. It only ships with newer macOS, and requiring it
# made both install and update exit before writing a single file on every
# older release (#1348). Where it is absent, an atomic mkdir inside the lock
# directory provides the same mutual exclusion; what it does not provide is
# release-on-death, so that path reclaims a mutex whose recorded owner is
# provably gone. Only a system with neither is refused.
local mutex_dir=""
if [[ ! -x /usr/bin/lockf ]]; then
if [[ ! -x /bin/mkdir ]]; then
INSTALL_LOCK_FAILURE="no_mutex"
return 1
fi
mutex_dir="$INSTALL_DIR/.mole-update.lock/holder"
fi
install_lock_current_shell_pid owner_pid || return 1
owner_start=$(install_lock_process_start "$owner_pid")
[[ -n "$owner_start" ]] || return 1
control_path=$(install_lock_command "$use_sudo" /usr/bin/mktemp "$INSTALL_DIR/.mole-update.lock/control.XXXXXX") || return 1
token="$owner_pid|$owner_start|${control_path##*.}"
# shellcheck disable=SC2016 # The lock-holder shell expands these values.
local holder_script='
token="$1"
owner_pid="$2"
owner_start="$3"
lock_path="$4"
control_path="$5"
mutex_dir="$6"
current_start=""
if [ ! -f "$control_path" ]; then
exit 1
fi
if [ -n "$mutex_dir" ] && ! /bin/mkdir "$mutex_dir" 2>/dev/null; then
# Occupied. Reclaim only against proof the recorded owner is gone:
# a live pid whose start time still matches is a real concurrent
# writer, and a reused pid is why the start time is compared too.
previous=$(/bin/cat "$lock_path" 2>/dev/null || true)
previous_pid=${previous%%|*}
previous_rest=${previous#*|}
previous_start=${previous_rest%%|*}
owner_gone=1
if [ -n "$previous_pid" ] && kill -0 "$previous_pid" 2>/dev/null; then
current_start=$(LC_ALL=C /bin/ps -p "$previous_pid" -o lstart= 2>/dev/null |
/usr/bin/sed -e "s/^[[:space:]]*//" -e "s/[[:space:]]*$//" | /usr/bin/head -1)
if [ "$current_start" = "$previous_start" ]; then
owner_gone=0
fi
fi
[ "$owner_gone" = 1 ] || exit 1
/bin/rmdir "$mutex_dir" 2>/dev/null || exit 1
/bin/mkdir "$mutex_dir" 2>/dev/null || exit 1
fi
if ! printf "%s\n" "$token" > "$lock_path"; then
[ -n "$mutex_dir" ] && /bin/rmdir "$mutex_dir" 2>/dev/null
exit 1
fi
while [ -f "$control_path" ]; do
kill -0 "$owner_pid" 2>/dev/null || break
current_start=$(LC_ALL=C /bin/ps -p "$owner_pid" -o lstart= 2>/dev/null |
/usr/bin/sed -e "s/^[[:space:]]*//" -e "s/[[:space:]]*$//" | /usr/bin/head -1)
[ "$current_start" = "$owner_start" ] || break
/bin/sleep 0.1
done
/bin/rm -f "$control_path" # SAFE: exact mktemp-created install lock control file.
[ -n "$mutex_dir" ] && /bin/rmdir "$mutex_dir" 2>/dev/null
exit 0
'
if [[ -n "$mutex_dir" ]]; then
install_lock_command "$use_sudo" /bin/sh -c "$holder_script" \
sh "$token" "$owner_pid" "$owner_start" "$lock_path" "$control_path" "$mutex_dir" &
else
install_lock_command "$use_sudo" /usr/bin/lockf -k -s -t 0 -w "$lock_path" /bin/sh -c "$holder_script" \
sh "$token" "$owner_pid" "$owner_start" "$lock_path" "$control_path" "" &
fi
holder_pid=$!
while [[ "$attempt" -lt 100 ]]; do
if ! kill -0 "$holder_pid" 2> /dev/null; then
wait "$holder_pid" 2> /dev/null || true
install_lock_remove_control "$control_path" "$use_sudo" || true
return 1
fi
owner_value=$(install_lock_read_owner "$lock_path" "$use_sudo" || true)
if [[ "$owner_value" == "$token" ]]; then
INSTALL_LOCK_PATH="$lock_path"
INSTALL_LOCK_CONTROL="$control_path"
INSTALL_LOCK_HOLDER_PID="$holder_pid"
INSTALL_LOCK_USE_SUDO="$use_sudo"
INSTALL_LOCK_FAILURE=""
return 0
fi
/bin/sleep 0.05
attempt=$((attempt + 1))
done
install_lock_remove_control "$control_path" "$use_sudo" || true
wait "$holder_pid" 2> /dev/null || true
return 1
}
# One rejection reason, one remedy. Callers must not print a bare lock message.
report_install_lock_failure() {
local install_lock_dir
case "$INSTALL_LOCK_FAILURE" in
no_admin)
log_error "Admin access to $INSTALL_DIR is required but not available"
log_error "Cache credentials first, then retry: sudo -v && mo update"
;;
unsafe_ancestor)
install_lock_dir="${INSTALL_LOCK_UNSAFE_ANCESTOR:-
}"
case "$INSTALL_LOCK_UNSAFE_ANCESTOR_REASON" in
symlink)
log_error "$install_lock_dir is a symlink, so a privileged write through it cannot be trusted"
log_error "See where it points, install under a real directory, then retry: ls -ld $install_lock_dir"
;;
not_root_owned)
log_error "$install_lock_dir is not owned by root, so a privileged write there cannot be trusted"
log_error "Restore ownership, then retry: sudo chown root:wheel $install_lock_dir"
;;
foreign_owner)
log_error "$install_lock_dir is owned by neither root nor you"
log_error "Take ownership, then retry: sudo chown $(id -un) $install_lock_dir"
;;
acl)
log_error "$install_lock_dir carries an ACL that grants access beyond its mode"
log_error "Clear it, then retry: sudo chmod -N $install_lock_dir"
;;
unreadable)
log_error "$install_lock_dir could not be inspected, so it cannot be cleared for a privileged write"
log_error "Look at it, then retry: sudo ls -lde $install_lock_dir"
;;
writable)
log_error "$install_lock_dir is writable by someone other than root"
log_error "Close it, then retry: sudo chmod go-w $install_lock_dir"
;;
*)
log_error "$install_lock_dir cannot be trusted for a privileged write"
log_error "Inspect it, then retry: sudo ls -lde $install_lock_dir"
;;
esac
;;
lock_dir)
log_error "$INSTALL_DIR/.mole-update.lock is not a usable lock directory"
log_error "Inspect it, then retry: sudo ls -lde $INSTALL_DIR/.mole-update.lock"
;;
lock_path)
log_error "$INSTALL_DIR/.mole-update.lock/kernel.lock is not a regular file"
log_error "Remove it, then retry: sudo rm -f $INSTALL_DIR/.mole-update.lock/kernel.lock"
;;
no_mutex)
log_error "Neither /usr/bin/lockf nor /bin/mkdir is usable, so concurrent installs cannot be kept apart"
log_error "Check that /bin and /usr/bin are intact, then retry: ls -l /bin/mkdir /usr/bin/lockf"
;;
*)
log_error "Another install or update is running, wait for it to finish and retry"
;;
esac
}
release_install_lock() {
[[ -n "$INSTALL_LOCK_CONTROL" ]] &&
install_lock_remove_control "$INSTALL_LOCK_CONTROL" "$INSTALL_LOCK_USE_SUDO" || true
[[ -n "$INSTALL_LOCK_HOLDER_PID" ]] && wait "$INSTALL_LOCK_HOLDER_PID" 2> /dev/null || true
INSTALL_LOCK_PATH=""
INSTALL_LOCK_CONTROL=""
INSTALL_LOCK_HOLDER_PID=""
INSTALL_LOCK_USE_SUDO=false
}
cleanup_installer() {
stop_line_spinner 2> /dev/null || true
release_install_lock
if [[ -n "$INSTALL_SOURCE_TMP" ]]; then
# Teardown never decides the exit status. This runs from the EXIT trap
# under `set -e`, so a refusal here used to turn a finished, verified
# install into exit 1 and every caller that checks the status, package
# managers included, read it as a failed install (#1343). safe_rm still
# prints why it refused; it just no longer overrides the verdict the
# install itself already reached.
safe_rm "$INSTALL_SOURCE_TMP" || true
INSTALL_SOURCE_TMP=""
fi
}
get_remote_main_commit_hash() {
command -v curl > /dev/null 2>&1 || return 1
local response=""
local commit_hash=""
response=$(curl -fsSL --connect-timeout 3 --max-time 5 \
"https://api.github.com/repos/tw93/mole/commits/main" 2> /dev/null || true)
commit_hash=$(printf '%s\n' "$response" |
sed -n 's/.*"sha"[[:space:]]*:[[:space:]]*"\([a-f0-9]\{40\}\)".*/\1/p' | head -1)
[[ "$commit_hash" =~ ^[0-9a-f]{40}$ ]] || return 1
printf '%s\n' "$commit_hash"
}
source_archive_url() {
local branch="$1"
local source_commit="${2:-}"
if [[ "$branch" == "main" && "$source_commit" =~ ^[0-9a-f]{40}$ ]]; then
printf 'https://github.com/tw93/mole/archive/%s.tar.gz\n' "$source_commit"
elif [[ "$branch" == "main" || "$branch" == "dev" ]]; then
printf 'https://github.com/tw93/mole/archive/refs/heads/%s.tar.gz\n' "$branch"
else
printf 'https://github.com/tw93/mole/archive/refs/tags/%s.tar.gz\n' "$branch"
fi
}
resolve_source_dir() {
if [[ -n "$SOURCE_DIR" && -d "$SOURCE_DIR" && -f "$SOURCE_DIR/mole" ]]; then
return 0
fi
if [[ -n "${BASH_SOURCE[0]:-}" && -f "${BASH_SOURCE[0]}" ]]; then
local script_dir
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
if [[ -f "$script_dir/mole" ]]; then
SOURCE_DIR="$script_dir"
return 0
fi
fi
if [[ -n "${CLEAN_SOURCE_DIR:-}" && -d "$CLEAN_SOURCE_DIR" && -f "$CLEAN_SOURCE_DIR/mole" ]]; then
SOURCE_DIR="$CLEAN_SOURCE_DIR"
return 0
fi
local tmp
# Derive the directory from the same TMPDIR safe_rm gates on. A bare
# `mktemp -d` ignores TMPDIR on macOS and always lands in the Darwin
# per-user temp dir, so with TMPDIR unset the two disagreed and cleanup
# refused to remove what the installer had just created (#1343). Homebrew
# strips TMPDIR from the environment, which made that deterministic there.
tmp="$(mktemp -d "${TMPDIR:-/tmp}/mole.XXXXXX")"
INSTALL_SOURCE_TMP="$tmp"
local branch="${MOLE_VERSION:-}"
if [[ -z "$branch" ]]; then
branch="$(get_latest_release_tag || true)"
fi
if [[ -z "$branch" ]]; then
branch="$(get_latest_release_tag_from_git || true)"
fi
if [[ -z "$branch" ]]; then
# Both release-tag lookups failed (typically GitHub API rate limits
# while codeload still works). Keep the install usable, but say
# loudly that this is now a nightly source install, not a release.
log_warning "Could not resolve the latest release tag; installing from main (nightly source)"
branch="main"
fi
if [[ "$branch" != "main" && "$branch" != "dev" ]]; then
branch="$(normalize_release_tag "$branch")"
fi
local source_commit=""
if [[ "$branch" == "main" ]]; then
source_commit="${MOLE_INSTALL_COMMIT:-}"
if [[ -n "$source_commit" && ! "$source_commit" =~ ^[0-9a-f]{40}$ ]]; then
log_error "Invalid pinned source commit"
exit 1
fi
if [[ -z "$source_commit" ]]; then
source_commit=$(get_remote_main_commit_hash || true)
fi
if [[ "$source_commit" =~ ^[0-9a-f]{40}$ ]]; then
SOURCE_COMMIT_HASH="$source_commit"
fi
fi
local url
url=$(source_archive_url "$branch" "$source_commit")
start_line_spinner "Fetching Mole source, ${branch}..."
if command -v curl > /dev/null 2>&1; then
if curl_download_with_retry "$url" "$tmp/mole.tar.gz" 2> /dev/null; then
if tar -xzf "$tmp/mole.tar.gz" -C "$tmp" 2> /dev/null; then
stop_line_spinner
local extracted_dir
extracted_dir=$(find "$tmp" -mindepth 1 -maxdepth 1 -type d | head -n 1)
if [[ -n "$extracted_dir" && -f "$extracted_dir/mole" ]]; then
SOURCE_DIR="$extracted_dir"
return 0
fi
fi
else
stop_line_spinner
# Only exit early for version tags (not for main/dev branches)
if [[ "$branch" != "main" && "$branch" != "dev" ]]; then
log_error "Failed to fetch version ${branch}. Check if tag exists."
exit 1
fi
fi
fi
stop_line_spinner
start_line_spinner "Cloning Mole source..."
if command -v git > /dev/null 2>&1; then
local clone_succeeded=false
if [[ -n "$source_commit" ]]; then
if git init -q "$tmp/mole" > /dev/null 2>&1 &&
git -C "$tmp/mole" remote add origin https://github.com/tw93/mole.git > /dev/null 2>&1 &&
git -C "$tmp/mole" fetch -q --depth=1 origin "$source_commit" > /dev/null 2>&1 &&
git -C "$tmp/mole" checkout -q --detach FETCH_HEAD > /dev/null 2>&1; then
clone_succeeded=true
fi
else
local git_args=("--depth=1")
if [[ "$branch" != "main" ]]; then
git_args+=("--branch" "$branch")
fi
if git clone "${git_args[@]}" https://github.com/tw93/mole.git "$tmp/mole" > /dev/null 2>&1; then
clone_succeeded=true
fi
fi
if [[ "$clone_succeeded" == "true" ]]; then
stop_line_spinner
SOURCE_DIR="$tmp/mole"
SOURCE_COMMIT_HASH=$(git -C "$SOURCE_DIR" rev-parse HEAD 2> /dev/null || true)
return 0
fi
fi
stop_line_spinner
log_error "Failed to fetch source files. Ensure curl or git is available."
exit 1
}
# Version helpers
get_source_version() {
local source_mole="$SOURCE_DIR/mole"
if [[ -f "$source_mole" ]]; then
sed -n 's/^VERSION="\(.*\)"$/\1/p' "$source_mole" | head -n1
fi
}
get_source_commit_hash() {
if [[ "$SOURCE_COMMIT_HASH" =~ ^[0-9a-f]{7,40}$ ]]; then
printf '%s\n' "$SOURCE_COMMIT_HASH"
return 0
fi
# Try to get from local git repo first
if [[ -d "$SOURCE_DIR/.git" ]]; then
git -C "$SOURCE_DIR" rev-parse --short HEAD 2> /dev/null && return
fi
return 1
}
get_latest_release_tag() {
local tag
if ! command -v curl > /dev/null 2>&1; then
return 1
fi
tag=$(curl -fsSL --connect-timeout 2 --max-time 3 \
"https://api.github.com/repos/tw93/mole/releases/latest" 2> /dev/null |
sed -n 's/.*"tag_name":[[:space:]]*"\([^"]*\)".*/\1/p' | head -n1)
if [[ -z "$tag" || ! "$tag" =~ ^V[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
return 1
fi
printf '%s\n' "$tag"
}
get_latest_release_tag_from_git() {
if ! command -v git > /dev/null 2>&1; then
return 1
fi
# Only plain macOS tags: the Windows CLI publishes V-windows in the
# same repository, and sort -V ranks V1.60.0-windows above V1.60.0.
git ls-remote --tags --refs https://github.com/tw93/mole.git 2> /dev/null |
awk -F/ '{print $NF}' |
grep -E '^V[0-9]+\.[0-9]+\.[0-9]+$' |
sort -V |
tail -n 1
}
normalize_release_tag() {
local tag="$1"
while [[ "$tag" =~ ^[vV] ]]; do
tag="${tag#v}"
tag="${tag#V}"
done
if [[ -n "$tag" ]]; then
printf 'V%s\n' "$tag"
fi
}
release_checksums_url() {
local tag="$1"
printf 'https://github.com/tw93/mole/releases/download/%s/SHA256SUMS\n' "$tag"
}
download_release_checksums() {
local tag="$1"
local output_file="$2"
local url
url="$(release_checksums_url "$tag")"
curl_download_with_retry "$url" "$output_file"
}
# Verify the Sigstore/GitHub Actions build-provenance attestation for a release
# asset. Returns:
# 0 - attestation verified
# 1 - verification failed (asset has no matching attestation, or signature invalid)
# 2 - cannot verify (gh CLI missing or unauthenticated); caller decides policy
#
# The release workflow generates attestations via actions/attest-build-provenance
# covering SHA256SUMS, the per-arch binaries, and the homebrew tarballs.
# Verifying the SHA256SUMS file is sufficient: the binary's sha256 is then
# anchored to that attested file by verify_release_asset_checksum().
verify_release_attestation() {
local file="$1"
if ! command -v gh > /dev/null 2>&1; then
return 2
fi
if ! gh auth status > /dev/null 2>&1; then
return 2
fi
# Bind provenance to this exact repository rather than accepting an
# attestation from any repository owned by tw93. --deny-self-hosted-runners
# also blocks attestations produced by self-hosted runners, which a repo
# compromise could otherwise introduce as a sidechannel.
if gh attestation verify "$file" \
--repo tw93/Mole \
--deny-self-hosted-runners \
> /dev/null 2>&1; then
return 0
fi
return 1
}
extract_release_checksum() {
local checksums_file="$1"
local asset_name="$2"
awk -v asset="$asset_name" '$2 == asset { print $1; found = 1; exit } END { exit found ? 0 : 1 }' "$checksums_file"
}
calculate_file_sha256() {
local file="$1"
if command -v shasum > /dev/null 2>&1; then
shasum -a 256 "$file" | awk '{print $1; exit}'
return
fi
if command -v sha256sum > /dev/null 2>&1; then
sha256sum "$file" | awk '{print $1; exit}'
return
fi
return 1
}
verify_release_asset_checksum() {
local tag="$1"
local asset_name="$2"
local file="$3"
local checksums_file
checksums_file="$(mktemp "${TMPDIR:-/tmp}/mole-checksums.XXXXXX")" || return 1
local expected=""
local actual=""
local result=1
local attestation_status=2
# The checksums fetch and the attestation check are separate network round
# trips that can take several seconds each. With nothing on screen this is
# the longest silent stretch of an install and reads as a hang, so keep a
# spinner up until the first real line prints.
start_line_spinner "Verifying ${asset_name}..."
if download_release_checksums "$tag" "$checksums_file" > /dev/null 2>&1; then
# Anchor the SHA256SUMS file to its GitHub Actions build-provenance
# attestation before reading checksums from it. If gh is available,
# an attestation mismatch is fatal; without gh, fall through to
# checksum-only verification (matches prior behavior).
verify_release_attestation "$checksums_file"
attestation_status=$?
if [[ "$attestation_status" -eq 1 ]]; then
stop_line_spinner
log_error "Release attestation verification failed for ${asset_name}"
rm -f "$checksums_file"
return 1
fi
if [[ "$attestation_status" -eq 2 && "${MOLE_REQUIRE_ATTESTATION:-0}" == "1" ]]; then
stop_line_spinner
log_error "MOLE_REQUIRE_ATTESTATION=1 set but gh CLI unavailable or unauthenticated"
rm -f "$checksums_file"
return 1
fi
expected=$(extract_release_checksum "$checksums_file" "$asset_name" 2> /dev/null || true)
actual=$(calculate_file_sha256 "$file" 2> /dev/null || true)
if [[ -n "$expected" && -n "$actual" && "$expected" == "$actual" ]]; then
result=0
if [[ "$attestation_status" -eq 0 ]]; then
stop_line_spinner
log_success "Verified ${asset_name} · sha256 + attestation"
fi
fi
fi
stop_line_spinner
rm -f "$checksums_file"
return "$result"
}
get_installed_version() {
local binary="$INSTALL_DIR/mole"
if [[ -x "$binary" ]]; then
local version
version=$(run_install_probe_with_timeout 5 "$binary" --version 2> /dev/null |
awk '/Mole version/ {print $NF; exit}' || true)
if [[ -n "$version" ]]; then
echo "$version"
else
LC_ALL=C sed -n 's/^VERSION="\(.*\)"$/\1/p' "$binary" | head -n1
fi
fi
}
resolve_install_channel() {
case "${MOLE_VERSION:-}" in
main | latest)
printf 'nightly\n'
return 0
;;
dev)
printf 'dev\n'
return 0
;;
esac
if [[ "${MOLE_EDGE_INSTALL:-}" == "true" ]]; then
printf 'nightly\n'
return 0
fi
printf 'stable\n'
}
write_install_channel_metadata() {
local channel="$1"
local commit_hash="${2:-}"
local install_receipt="${3:-}"
local metadata_file="$CONFIG_DIR/install_channel"
if [[ -n "$install_receipt" && ! "$install_receipt" =~ ^(heal|update)-[0-9]+-[0-9]+-[0-9]+$ ]]; then
return 1
fi
mkdir -p "$CONFIG_DIR" 2> /dev/null || return 1
local tmp_file
tmp_file=$(mktemp "${CONFIG_DIR}/install_channel.XXXXXX") || return 1
# Use a plain if/fi so the block's exit code reflects only I/O failure.
# The previous form `[[ -n "$h" ]] && printf ...` returned 1 whenever the
# commit hash was empty (the stable channel always omits it), which made
# the redirect look like it had failed and tripped the warning.
{
printf 'CHANNEL=%s\n' "$channel"
if [[ -n "$commit_hash" ]]; then
printf 'COMMIT_HASH=%s\n' "$commit_hash"
fi
if [[ -n "$install_receipt" ]]; then
printf 'INSTALL_RECEIPT=%s\n' "$install_receipt"
fi
} > "$tmp_file" || {
rm -f "$tmp_file" 2> /dev/null || true
return 1
}
mv -f "$tmp_file" "$metadata_file" || {
rm -f "$tmp_file" 2> /dev/null || true
return 1
}
}
# CLI parsing (supports main, the legacy latest alias, and version tokens).
parse_args() {
local -a args=("$@")
local version_token=""
local i skip_next=false
for i in "${!args[@]}"; do
local token="${args[$i]}"
[[ -z "$token" ]] && continue
# Skip values for options that take arguments
if [[ "$skip_next" == "true" ]]; then
skip_next=false
continue
fi
if [[ "$token" == "--prefix" || "$token" == "--config" ]]; then
skip_next=true
continue
fi
if [[ "$token" == -* ]]; then
continue
fi
if [[ -n "$version_token" ]]; then
log_error "Unexpected argument: $token"
exit 1
fi
case "$token" in
latest | main)
export MOLE_VERSION="main"
export MOLE_EDGE_INSTALL="true"
version_token="$token"
unset 'args[$i]'
;;
dev)
export MOLE_VERSION="dev"
export MOLE_EDGE_INSTALL="true"
version_token="$token"
unset 'args[$i]'
;;
[0-9]* | V[0-9]* | v[0-9]*)
export MOLE_VERSION="$token"
version_token="$token"
unset 'args[$i]'
;;
*)
log_error "Unknown option: $token"
exit 1
;;
esac
done
if [[ ${#args[@]} -gt 0 ]]; then
set -- ${args[@]+"${args[@]}"}
else
set --
fi
while [[ $# -gt 0 ]]; do
case $1 in
--prefix)
if [[ -z "${2:-}" ]]; then
log_error "Missing value for --prefix"
exit 1
fi
INSTALL_DIR="$2"
shift 2
;;
--config)
if [[ -z "${2:-}" ]]; then
log_error "Missing value for --config"
exit 1
fi
CONFIG_DIR="$2"
shift 2
;;
--update)
ACTION="update"
shift 1
;;
--verbose | -v)
VERBOSE=1
shift 1
;;
--help | -h)
log_error "Unknown option: $1"
exit 1
;;
*)
log_error "Unknown option: $1"
exit 1
;;
esac
done
}
normalize_install_dir() {
case "$INSTALL_DIR" in
/*) return 0 ;;
esac
local physical_cwd
physical_cwd=$(pwd -P 2> /dev/null) || return 1
INSTALL_DIR="$physical_cwd/$INSTALL_DIR"
}
# Environment checks and directory setup
check_requirements() {
if [[ "$OSTYPE" != "darwin"* ]]; then
log_error "This tool is designed for macOS only"
exit 1
fi
local minimum_macos_major=12
local macos_version=""
local macos_version_rc=0
macos_version=$(run_install_probe_with_timeout 2 \
/usr/bin/sw_vers -productVersion 2> /dev/null) || macos_version_rc=$?
if [[ $macos_version_rc -ne 0 ||
! "$macos_version" =~ ^[0-9]+(\.[0-9]+){0,2}$ ]]; then
log_error "Could not determine a supported macOS version (probe exit $macos_version_rc)"
log_error "Verify sw_vers works, then retry the installation"
exit 1
fi
local macos_major="${macos_version%%.*}"
macos_major=$((10#$macos_major))
if [[ $macos_major -lt $minimum_macos_major ]]; then
log_error "Mole requires macOS $minimum_macos_major or newer; found $macos_version"
log_error "Upgrade macOS before installing this release"
exit 1
fi
if homebrew_owns_mole; then
local mole_path
mole_path=$(command -v mole 2> /dev/null || true)
local is_homebrew_binary=false
if [[ -n "$mole_path" && -L "$mole_path" ]]; then
if readlink "$mole_path" | grep -q "Cellar/mole"; then
is_homebrew_binary=true
fi
fi
if [[ "$is_homebrew_binary" == "true" ]]; then
if [[ "$ACTION" == "update" ]]; then
return 0
fi
echo -e "${YELLOW}Mole is installed via Homebrew${NC}"
echo ""
echo "Choose one:"
echo -e " 1. Update via Homebrew: ${GREEN}brew upgrade mole${NC}"
echo -e " 2. Switch to manual: ${GREEN}brew uninstall --force mole${NC} then re-run this"
echo ""
exit 1
else
log_warning "Cleaning up stale Homebrew installation..."
brew uninstall --force mole > /dev/null 2>&1 || true
fi
fi
if [[ ! -d "$(dirname "$INSTALL_DIR")" ]]; then
log_error "Parent directory $(dirname "$INSTALL_DIR") does not exist"
exit 1
fi
}
create_directories() {
if [[ ! -d "$INSTALL_DIR" ]]; then
maybe_sudo mkdir -p "$INSTALL_DIR"
fi
if ! mkdir -p "$CONFIG_DIR" "$CONFIG_DIR/bin" "$CONFIG_DIR/lib"; then
log_error "Failed to create config directory: $CONFIG_DIR"
exit 1
fi
}
# Binary install helpers
build_binary_from_source() {
local binary_name="$1"
local target_path="$2"
local cmd_dir=""
case "$binary_name" in
analyze)
cmd_dir="cmd/analyze"
;;
status)
cmd_dir="cmd/status"
;;
*)
return 1
;;
esac
if ! command -v go > /dev/null 2>&1; then
return 1
fi
if [[ ! -d "$SOURCE_DIR/$cmd_dir" ]]; then
return 1
fi
start_line_spinner "Building ${binary_name} from source..."
if (cd "$SOURCE_DIR" && go build -ldflags="-s -w" -o "$target_path" "./$cmd_dir" > /dev/null 2>&1); then
if [[ -t 1 ]]; then stop_line_spinner; fi
chmod +x "$target_path"
log_success "Built ${binary_name} from source"
return 0
fi
if [[ -t 1 ]]; then stop_line_spinner; fi
log_warning "Failed to build ${binary_name} from source"
return 1
}
install_staged_binary() {
local staged_path="$1"
local target_path="$2"
chmod +x "$staged_path" || return 1
xattr -c "$staged_path" 2> /dev/null || true
mv -f "$staged_path" "$target_path"
}
download_binary() {
local binary_name="$1"
local target_path="$CONFIG_DIR/bin/${binary_name}-go"
local staged_path
staged_path=$(mktemp "$CONFIG_DIR/bin/.${binary_name}-go.XXXXXX") || return 1
local arch
arch=$(uname -m)
local arch_suffix="amd64"
if [[ "$arch" == "arm64" ]]; then
arch_suffix="arm64"
fi
if [[ -f "$SOURCE_DIR/bin/${binary_name}-go" ]]; then
if ! cp "$SOURCE_DIR/bin/${binary_name}-go" "$staged_path" ||
! install_staged_binary "$staged_path" "$target_path"; then
rm -f "$staged_path"
return 1
fi
log_success "Installed local ${binary_name} binary"
return 0
elif [[ -f "$SOURCE_DIR/bin/${binary_name}-darwin-${arch_suffix}" ]]; then
if ! cp "$SOURCE_DIR/bin/${binary_name}-darwin-${arch_suffix}" "$staged_path" ||
! install_staged_binary "$staged_path" "$target_path"; then
rm -f "$staged_path"
return 1
fi
log_success "Installed local ${binary_name} binary"
return 0
fi
if [[ "${MOLE_EDGE_INSTALL:-}" == "true" ]]; then
if build_binary_from_source "$binary_name" "$staged_path" &&
install_staged_binary "$staged_path" "$target_path"; then
return 0
fi
rm -f "$staged_path"
fi
local version
version=$(get_source_version)
if [[ -z "$version" ]]; then
log_warning "Could not determine version for ${binary_name}, trying local build"
if build_binary_from_source "$binary_name" "$staged_path" &&
install_staged_binary "$staged_path" "$target_path"; then
return 0
fi
rm -f "$staged_path"
return 1
fi
local release_tag
release_tag="$(normalize_release_tag "$version")"
local asset_name="${binary_name}-darwin-${arch_suffix}"
local url="https://github.com/tw93/mole/releases/download/${release_tag}/${asset_name}"
# Skip preflight network checks to avoid false negatives.
start_line_spinner "Downloading ${binary_name}..."
# Both download attempts are followed by another route, so a failure here
# is a step in the flow rather than the end of it. Surfacing curl's raw
# stderr made the ordinary "tag not published yet" case print a 404 the
# user cannot act on, immediately above the line explaining the fallback.
if curl_download_with_retry "$url" "$staged_path" 2> /dev/null; then
if [[ -t 1 ]]; then stop_line_spinner; fi
if verify_release_asset_checksum "$release_tag" "$asset_name" "$staged_path" &&
install_staged_binary "$staged_path" "$target_path"; then
log_success "Installed ${binary_name}"
return 0
fi
rm -f "$staged_path"
# Integrity failure is fatal, never a downgrade. The asset arrived
# but its SHA256SUMS/attestation check did not pass; a blocked or
# tampered checksums file must not be able to reroute the install
# onto an unverified source build (classic verification-stripping
# downgrade). Explicit source builds remain available via
# MOLE_VERSION=main / MOLE_EDGE_INSTALL=true.
log_error "Verification failed for ${binary_name}; aborting instead of falling back to an unverified build"
log_error "Retry later, or opt into a source build explicitly: MOLE_VERSION=main ./install.sh (piping from curl: | bash -s -- main)"
return 1
fi
rm -f "$staged_path"
if [[ -t 1 ]]; then stop_line_spinner; fi
local fallback_tag
fallback_tag=$(get_latest_release_tag 2> /dev/null || true)
if [[ -n "$fallback_tag" && "$fallback_tag" != "$release_tag" ]]; then
local fallback_url="https://github.com/tw93/mole/releases/download/${fallback_tag}/${asset_name}"
start_line_spinner "Retrying ${binary_name} from ${fallback_tag}..."
if curl_download_with_retry "$fallback_url" "$staged_path" 2> /dev/null; then
if [[ -t 1 ]]; then stop_line_spinner; fi
if verify_release_asset_checksum "$fallback_tag" "$asset_name" "$staged_path" &&
install_staged_binary "$staged_path" "$target_path"; then
log_success "Installed ${binary_name} from ${fallback_tag} · v${version} not yet published"
return 0
fi
rm -f "$staged_path"
if [[ -t 1 ]]; then stop_line_spinner; fi
# Same integrity contract as the primary tag above: the fallback
# asset arrived but did not verify, which is evidence of tampering
# or a corrupted checksums file, not of unavailability. Only a
# plain download failure may continue into the source build.
log_error "Verification failed for ${binary_name} from ${fallback_tag}; aborting instead of falling back to an unverified build"
log_error "Retry later, or opt into a source build explicitly: MOLE_VERSION=main ./install.sh (piping from curl: | bash -s -- main)"
return 1
fi
rm -f "$staged_path"
if [[ -t 1 ]]; then stop_line_spinner; fi
fi
log_warning "Could not download ${binary_name} binary, v${version}, trying local build"
if build_binary_from_source "$binary_name" "$staged_path" &&
install_staged_binary "$staged_path" "$target_path"; then
return 0
fi
rm -f "$staged_path"
log_error "Failed to install ${binary_name} binary"
return 1
}
# File installation (bin/lib/scripts + go helpers).
install_files() {
resolve_source_dir
local source_dir_abs
local install_dir_abs
local config_dir_abs
source_dir_abs="$(cd "$SOURCE_DIR" && pwd)"
install_dir_abs="$(cd "$INSTALL_DIR" && pwd)"
config_dir_abs="$(cd "$CONFIG_DIR" && pwd)"
if [[ -f "$SOURCE_DIR/mole" ]]; then
if [[ "$source_dir_abs" != "$install_dir_abs" ]]; then
if needs_sudo; then
log_admin "Admin access required for /usr/local/bin"
if [[ "${MOLE_TEST_MODE:-0}" == "1" || "${MOLE_TEST_NO_AUTH:-0}" == "1" ]]; then
log_error "Admin access required, blocked in test mode"
return 1
fi
# Explicit failure checks throughout this function: callers run
# `install_files || {...}`, and bash disables errexit inside a
# function invoked that way, so a failed sudo would otherwise be
# swallowed and the install would report success while leaving
# the old entry script in place (the 1.45.0 -> 1.47.0 fake
# "Updated to latest version" incident).
if ! ensure_sudo_ready; then
log_error "Admin access to $INSTALL_DIR is required but not available"
log_error "Cache credentials first, then retry: sudo -v && mo update"
return 1
fi
fi
# Atomic update: copy to temporary name first, then move
if ! maybe_sudo cp "$SOURCE_DIR/mole" "$INSTALL_DIR/mole.new" ||
! maybe_sudo chmod +x "$INSTALL_DIR/mole.new" ||
! maybe_sudo mv -f "$INSTALL_DIR/mole.new" "$INSTALL_DIR/mole"; then
log_error "Failed to install mole to $INSTALL_DIR (admin access missing or denied)"
log_error "Cache credentials first, then retry: sudo -v && mo update"
return 1
fi
fi
else
log_error "mole executable not found in ${SOURCE_DIR:-unknown}"
exit 1
fi
if [[ -f "$SOURCE_DIR/mo" && "$source_dir_abs" != "$install_dir_abs" ]]; then
if ! maybe_sudo cp "$SOURCE_DIR/mo" "$INSTALL_DIR/mo.new" ||
! maybe_sudo chmod +x "$INSTALL_DIR/mo.new" ||
! maybe_sudo mv -f "$INSTALL_DIR/mo.new" "$INSTALL_DIR/mo"; then
log_error "Failed to install mo alias to $INSTALL_DIR (admin access missing or denied)"
return 1
fi
fi
if [[ -d "$SOURCE_DIR/bin" ]]; then
local source_bin_abs="$(cd "$SOURCE_DIR/bin" && pwd)"
local config_bin_abs="$(cd "$CONFIG_DIR/bin" && pwd)"
if [[ "$source_bin_abs" != "$config_bin_abs" ]]; then
local -a bin_files=("$SOURCE_DIR/bin"/*)
if [[ ${#bin_files[@]} -gt 0 ]]; then
cp -r "${bin_files[@]}" "$CONFIG_DIR/bin/"
for file in "$CONFIG_DIR/bin/"*; do
[[ -e "$file" ]] && chmod +x "$file"
done
fi
fi
fi
if [[ -d "$SOURCE_DIR/lib" ]]; then
local source_lib_abs="$(cd "$SOURCE_DIR/lib" && pwd)"
local config_lib_abs="$(cd "$CONFIG_DIR/lib" && pwd)"
if [[ "$source_lib_abs" != "$config_lib_abs" ]]; then
local -a lib_files=("$SOURCE_DIR/lib"/*)
if [[ ${#lib_files[@]} -gt 0 ]]; then
cp -r "${lib_files[@]}" "$CONFIG_DIR/lib/"
fi
fi
fi
if [[ "$config_dir_abs" != "$source_dir_abs" ]]; then
for file in README.md LICENSE install.sh; do
if [[ -f "$SOURCE_DIR/$file" ]]; then
cp -f "$SOURCE_DIR/$file" "$CONFIG_DIR/"
fi
done
fi
if [[ -f "$CONFIG_DIR/install.sh" ]]; then
chmod +x "$CONFIG_DIR/install.sh"
fi
if [[ "$source_dir_abs" != "$install_dir_abs" ]]; then
# Use absolute /usr/bin/sed (always BSD on macOS) so PATH-shadowed
# GNU sed from Homebrew gnu-sed does not break the -i '' syntax.
# Serialize a literal Bash word first, then escape the sed replacement.
# Config paths may contain shell syntax or sed delimiters as data.
# The C locale makes bash 3.2 %q escape every non-ASCII byte as octal;
# in a UTF-8 locale it leaves lead bytes raw and escapes only some
# continuation bytes, so the pinned line is not valid UTF-8.
local config_word config_replacement
config_word=$(
export LC_ALL=C
printf '%q' "$config_dir_abs"
)
config_replacement=${config_word//\\/\\\\}
config_replacement=${config_replacement//&/\\&}
config_replacement=${config_replacement//|/\\|}
if ! maybe_sudo /usr/bin/sed -i '' "s|^SCRIPT_DIR=.*|SCRIPT_DIR=$config_replacement|" "$INSTALL_DIR/mole"; then
log_error "Failed to point $INSTALL_DIR/mole at $CONFIG_DIR"
return 1
fi
fi
# One line for the whole file install. Reporting the entry script, the mo
# alias, the modules and the libraries separately described the layout
# rather than the outcome, and every one of them is a hard failure that
# returns above if it goes wrong.
log_success "Installed to $INSTALL_DIR"
local helper_install_marker="$CONFIG_DIR/.helper_install_incomplete"
: > "$helper_install_marker"
if ! download_binary "analyze"; then
exit 1
fi
if ! download_binary "status"; then
exit 1
fi
rm -f "$helper_install_marker"
}
# Verification and PATH hint
verify_installation() {
if [[ -x "$INSTALL_DIR/mole" ]] && [[ -f "$CONFIG_DIR/lib/core/common.sh" ]]; then
# A runnable old entry script also passes --help, so cross-check the
# installed version against the source that was just installed. A
# mismatch means the entry script was not actually replaced (for
# example a swallowed sudo failure) and the install is a mixed-version
# state that must not be reported as success.
local expected_version installed_version
expected_version="$(get_source_version 2> /dev/null || true)"
installed_version="$(get_installed_version 2> /dev/null || true)"
if [[ -n "$expected_version" && -n "$installed_version" && "$expected_version" != "$installed_version" ]]; then
log_error "Installed mole reports $installed_version but $expected_version was expected"
log_error "The entry script at $INSTALL_DIR/mole was not replaced; retry with: sudo -v && mo update"
exit 1
fi
if run_install_probe_with_timeout 5 "$INSTALL_DIR/mole" --help > /dev/null 2>&1; then
return 0
else
log_error "Installed Mole did not answer the bounded help probe"
return 1
fi
else
log_error "Installation verification failed"
exit 1
fi
}
setup_path() {
if [[ ":$PATH:" == *":$INSTALL_DIR:"* ]]; then
return
fi
if [[ "$INSTALL_DIR" != "/usr/local/bin" ]]; then
log_warning "$INSTALL_DIR is not in your PATH"
echo ""
echo "To use mole from anywhere, add this line to your shell profile:"
echo "export PATH=\"$INSTALL_DIR:\$PATH\""
echo ""
echo "For example, add it to ~/.zshrc or ~/.bash_profile"
fi
}
print_usage_summary() {
local action="$1"
local new_version="$2"
local previous_version="${3:-}"
if [[ ${VERBOSE} -ne 1 ]]; then
return
fi
# A usage cheat sheet is for someone watching a fresh install scroll by.
# `mo update` runs this same installer with its output captured, so on a
# tty-less run the block arrives after the fact and tells an existing user
# the nine commands they already use, followed by a second success line
# contradicting nothing. The caller prints its own result there.
[[ -t 1 ]] || return 0
echo ""
local message="Mole ${action} successfully"
if [[ "$action" == "updated" && -n "$previous_version" && -n "$new_version" && "$previous_version" != "$new_version" ]]; then
message+=", ${previous_version} -> ${new_version}"
elif [[ -n "$new_version" ]]; then
message+=", version ${new_version}"
fi
log_confirm "$message"
echo ""
echo "Usage:"
if [[ ":$PATH:" == *":$INSTALL_DIR:"* ]]; then
echo " mo # Interactive menu"
echo " mo clean # Deep cleanup"
echo " mo uninstall # Remove apps + leftovers"
echo " mo optimize # Check and maintain system"
echo " mo analyze # Explore disk usage"
echo " mo status # Monitor system health"
echo " mo touchid # Configure Touch ID for sudo"
echo " mo update # Update to latest version"
echo " mo --help # Show all commands"
else
echo " $INSTALL_DIR/mo # Interactive menu"
echo " $INSTALL_DIR/mo clean # Deep cleanup"
echo " $INSTALL_DIR/mo uninstall # Remove apps + leftovers"
echo " $INSTALL_DIR/mo optimize # Check and maintain system"
echo " $INSTALL_DIR/mo analyze # Explore disk usage"
echo " $INSTALL_DIR/mo status # Monitor system health"
echo " $INSTALL_DIR/mo touchid # Configure Touch ID for sudo"
echo " $INSTALL_DIR/mo update # Update to latest version"
echo " $INSTALL_DIR/mo --help # Show all commands"
fi
echo ""
}
# Whether Homebrew owns a mole install, decided from the Cellar on disk and
# never by running `brew`: the brew entry point resets the user's sudo
# timestamp as a security measure, and invoking it after the update flow's
# pre-authentication is exactly what killed the handed-over ticket within
# five seconds and forced a second password prompt on every update, with
# the ticket dying between installer start and the first privileged step.
homebrew_owns_mole() {
local -a brew_prefixes=("${HOMEBREW_PREFIX:-}" /opt/homebrew /usr/local)
# A custom prefix need not export HOMEBREW_PREFIX, and `brew list mole`
# used to find those installs. Derive the prefix from brew's own location
# instead: reading the path is not running the binary.
local brew_bin=""
brew_bin=$(command -v brew 2> /dev/null || true)
if [[ -n "$brew_bin" ]]; then
brew_prefixes+=("$(dirname "$(dirname "$brew_bin")")")
fi
local brew_prefix
for brew_prefix in "${brew_prefixes[@]}"; do
[[ -n "$brew_prefix" && -d "$brew_prefix/Cellar/mole" ]] && return 0
done
return 1
}
# Main install/update flows
perform_install() {
check_requirements
resolve_source_dir
local source_version
source_version="$(get_source_version || true)"
create_directories
acquire_install_lock || {
report_install_lock_failure
exit 1
}
install_files
verify_installation
setup_path
local installed_version
installed_version="$(get_installed_version || true)"
if [[ -z "$installed_version" ]]; then
installed_version="$source_version"
fi
local install_channel commit_hash=""
install_channel="$(resolve_install_channel)"
if [[ "$install_channel" == "nightly" ]]; then
commit_hash=$(get_source_commit_hash || true)
fi
if ! write_install_channel_metadata "$install_channel" "$commit_hash" "${MOLE_INSTALL_RECEIPT:-}"; then
log_warning "Could not write install channel metadata"
fi
# Edge installs get a suffix to make the version explicit.
if [[ "${MOLE_EDGE_INSTALL:-}" == "true" ]]; then
installed_version="${installed_version}-edge"
echo ""
local branch_name="${MOLE_VERSION:-main}"
log_warning "Edge version installed on ${branch_name} branch"
log_info "This is a testing version; use 'mo update' to switch to stable"
fi
print_usage_summary "installed" "$installed_version"
}
perform_update() {
check_requirements
if homebrew_owns_mole; then
if [[ "$EUID" -eq 0 ]]; then
log_error "Run the Homebrew update without sudo."
return 1
fi
resolve_source_dir 2> /dev/null || true
local current_version
current_version=$(get_installed_version || echo "unknown")
if [[ -f "$SOURCE_DIR/lib/core/common.sh" ]]; then
# shellcheck disable=SC1090,SC1091
source "$SOURCE_DIR/lib/core/common.sh"
update_via_homebrew "$current_version"
else
log_error "Cannot update Homebrew-managed Mole without full installation"
echo ""
echo "Please update via Homebrew:"
echo -e " ${GREEN}brew upgrade mole${NC}"
exit 1
fi
exit 0
fi
local installed_version
installed_version="$(get_installed_version || true)"
if [[ -z "$installed_version" ]]; then
log_warning "Mole is not currently installed in $INSTALL_DIR. Running fresh installation."
perform_install
return
fi
resolve_source_dir
local target_version
target_version="$(get_source_version || true)"
if [[ -z "$target_version" ]]; then
log_error "Unable to determine the latest Mole version."
exit 1
fi
if [[ "$installed_version" == "$target_version" ]]; then
echo -e "${GREEN}${ICON_SUCCESS}${NC} Already on latest version, $installed_version"
exit 0
fi
local old_verbose=$VERBOSE
VERBOSE=0
create_directories || {
VERBOSE=$old_verbose
log_error "Failed to create directories"
exit 1
}
acquire_install_lock || {
VERBOSE=$old_verbose
report_install_lock_failure
exit 1
}
install_files || {
VERBOSE=$old_verbose
log_error "Failed to install files"
exit 1
}
verify_installation || {
VERBOSE=$old_verbose
log_error "Failed to verify installation"
exit 1
}
setup_path
VERBOSE=$old_verbose
local updated_version
updated_version="$(get_installed_version || true)"
if [[ -z "$updated_version" ]]; then
updated_version="$target_version"
fi
local install_channel commit_hash=""
install_channel="$(resolve_install_channel)"
if [[ "$install_channel" == "nightly" ]]; then
commit_hash=$(get_source_commit_hash || true)
fi
if ! write_install_channel_metadata "$install_channel" "$commit_hash" "${MOLE_INSTALL_RECEIPT:-}"; then
log_warning "Could not write install channel metadata"
fi
echo -e "${GREEN}${ICON_SUCCESS}${NC} Updated to latest version, $updated_version"
}
main() {
parse_args "$@"
normalize_install_dir || {
log_error "Could not resolve the installation directory: $INSTALL_DIR"
exit 1
}
trap 'cleanup_installer' EXIT
trap 'cleanup_installer; exit 130' INT TERM
case "$ACTION" in
update)
perform_update
;;
*)
perform_install
;;
esac
}
# Dispatch when executed, directly or as `bash install.sh`, and when piped
# (`curl ... | bash`, where BASH_SOURCE[0] is empty). Sourcing only loads
# definitions for tests. Kept as the final line so a truncated download never
# reaches main.
[[ -n "${BASH_SOURCE[0]:-}" && "${BASH_SOURCE[0]}" != "$0" ]] || main "$@"