mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-02-18 23:41:48 +01:00
Fixes #755. Adds interactive pauses and graphical popups (where available) to installer scripts when critical errors occur, ensuring troubleshooting guides are readable. Also clarifies 'build from source' instructions.
488 lines
11 KiB
Bash
488 lines
11 KiB
Bash
#!/usr/bin/env bash
|
|
#
|
|
# Shared common utilities for Pulse shell scripts.
|
|
# Provides logging, privilege escalation, command execution helpers, and cleanup management.
|
|
|
|
set -o errexit
|
|
set -o nounset
|
|
set -o pipefail
|
|
|
|
shopt -s extglob
|
|
|
|
declare -gA COMMON__LOG_LEVELS=(
|
|
[debug]=10
|
|
[info]=20
|
|
[warn]=30
|
|
[error]=40
|
|
)
|
|
|
|
declare -g COMMON__SCRIPT_PATH=""
|
|
declare -g COMMON__SCRIPT_DIR=""
|
|
declare -g COMMON__ORIGINAL_ARGS=()
|
|
|
|
declare -g COMMON__LOG_LEVEL="info"
|
|
declare -g COMMON__LOG_LEVEL_NUM=20
|
|
declare -g COMMON__DEBUG=false
|
|
declare -g COMMON__COLOR_ENABLED=true
|
|
|
|
declare -g COMMON__DRY_RUN=false
|
|
|
|
declare -ag COMMON__CLEANUP_DESCRIPTIONS=()
|
|
declare -ag COMMON__CLEANUP_COMMANDS=()
|
|
declare -g COMMON__CLEANUP_REGISTERED=false
|
|
|
|
# shellcheck disable=SC2034
|
|
declare -g COMMON__ANSI_RESET=""
|
|
# shellcheck disable=SC2034
|
|
declare -g COMMON__ANSI_INFO=""
|
|
# shellcheck disable=SC2034
|
|
declare -g COMMON__ANSI_WARN=""
|
|
# shellcheck disable=SC2034
|
|
declare -g COMMON__ANSI_ERROR=""
|
|
# shellcheck disable=SC2034
|
|
declare -g COMMON__ANSI_DEBUG=""
|
|
|
|
# common::init
|
|
# Initializes the common library. Call at the top of every script.
|
|
# Sets script metadata, log level, color handling, traps, and stores original args.
|
|
common::init() {
|
|
COMMON__SCRIPT_PATH="$(common::__resolve_script_path)"
|
|
COMMON__SCRIPT_DIR="$(dirname "${COMMON__SCRIPT_PATH}")"
|
|
COMMON__ORIGINAL_ARGS=("$@")
|
|
|
|
common::__configure_color
|
|
common::__configure_log_level
|
|
|
|
if [[ "${COMMON__DEBUG}" == true ]]; then
|
|
common::log_debug "Debug mode enabled"
|
|
fi
|
|
|
|
if [[ "${COMMON__CLEANUP_REGISTERED}" == false ]]; then
|
|
trap common::cleanup_run EXIT
|
|
COMMON__CLEANUP_REGISTERED=true
|
|
fi
|
|
}
|
|
|
|
# common::log_info "message"
|
|
# Logs informational messages. Printed to stdout.
|
|
common::log_info() {
|
|
common::__log "info" "$@"
|
|
}
|
|
|
|
# common::log_warn "message"
|
|
# Logs warning messages. Printed to stderr.
|
|
common::log_warn() {
|
|
common::__log "warn" "$@"
|
|
}
|
|
|
|
# common::log_error "message"
|
|
# Logs error messages. Printed to stderr.
|
|
common::log_error() {
|
|
common::__log "error" "$@"
|
|
}
|
|
|
|
# common::log_debug "message"
|
|
# Logs debug messages when log level is debug. Printed to stderr.
|
|
common::log_debug() {
|
|
common::__log "debug" "$@"
|
|
}
|
|
|
|
# common::fail "message" [--code N]
|
|
# Logs an error message and exits with provided code (default 1).
|
|
common::fail() {
|
|
local exit_code=1
|
|
local message_parts=()
|
|
while (($#)); do
|
|
case "$1" in
|
|
--code)
|
|
shift
|
|
exit_code="${1:-1}"
|
|
;;
|
|
*)
|
|
message_parts+=("$1")
|
|
;;
|
|
esac
|
|
shift || break
|
|
done
|
|
local message="${message_parts[*]}"
|
|
common::log_error "${message}"
|
|
|
|
if common::is_interactive; then
|
|
echo ""
|
|
if [[ -t 0 ]]; then
|
|
read -p "Press Enter to exit..."
|
|
elif [[ -e /dev/tty ]]; then
|
|
read -p "Press Enter to exit..." < /dev/tty
|
|
fi
|
|
fi
|
|
|
|
exit "${exit_code}"
|
|
}
|
|
|
|
# common::require_command cmd1 [cmd2 ...]
|
|
# Verifies that required commands exist. Fails if any are missing.
|
|
common::require_command() {
|
|
local missing=()
|
|
local cmd
|
|
for cmd in "$@"; do
|
|
if ! command -v "${cmd}" >/dev/null 2>&1; then
|
|
missing+=("${cmd}")
|
|
fi
|
|
done
|
|
|
|
if ((${#missing[@]} > 0)); then
|
|
common::fail "Missing required command(s): ${missing[*]}"
|
|
fi
|
|
}
|
|
|
|
# common::is_interactive
|
|
# Returns success when running in an interactive TTY or PULSE_FORCE_INTERACTIVE=1.
|
|
common::is_interactive() {
|
|
if [[ "${PULSE_FORCE_INTERACTIVE:-0}" == "1" ]]; then
|
|
return 0
|
|
fi
|
|
[[ -t 0 && -t 1 ]]
|
|
}
|
|
|
|
# common::ensure_root [--allow-sudo] [--args "${COMMON__ORIGINAL_ARGS[@]}"]
|
|
# Ensures the script is running with root privileges. Optionally re-execs with sudo.
|
|
common::ensure_root() {
|
|
local allow_sudo=false
|
|
local reexec_args=()
|
|
|
|
while (($#)); do
|
|
case "$1" in
|
|
--allow-sudo)
|
|
allow_sudo=true
|
|
;;
|
|
--args)
|
|
shift
|
|
reexec_args=("$@")
|
|
break
|
|
;;
|
|
*)
|
|
common::log_warn "Unknown argument to common::ensure_root: $1"
|
|
;;
|
|
esac
|
|
shift || break
|
|
done
|
|
|
|
if [[ "${EUID}" -eq 0 ]]; then
|
|
return 0
|
|
fi
|
|
|
|
if [[ "${allow_sudo}" == true ]]; then
|
|
if common::is_interactive; then
|
|
common::log_info "Escalating privileges with sudo..."
|
|
common::sudo_exec "${COMMON__SCRIPT_PATH}" "${reexec_args[@]}"
|
|
exit 0
|
|
fi
|
|
common::fail "Root privileges required; rerun with sudo or as root."
|
|
fi
|
|
|
|
common::fail "Root privileges required."
|
|
}
|
|
|
|
# common::sudo_exec command [args...]
|
|
# Executes a command via sudo, providing user guidance on failure.
|
|
common::sudo_exec() {
|
|
local sudo_cmd="${PULSE_SUDO_CMD:-sudo}"
|
|
if command -v "${sudo_cmd}" >/dev/null 2>&1; then
|
|
exec "${sudo_cmd}" -- "$@"
|
|
fi
|
|
|
|
cat <<'EOF' >&2
|
|
Unable to escalate privileges automatically because sudo is unavailable.
|
|
Please install sudo or rerun this script as root.
|
|
EOF
|
|
exit 1
|
|
}
|
|
|
|
# common::run [--label desc] [--retries N] [--backoff "1 2 4"] command [args...]
|
|
# Executes a command, respecting dry-run mode and retry policies.
|
|
common::run() {
|
|
local label=""
|
|
local retries=1
|
|
local backoff=()
|
|
local -a cmd=()
|
|
|
|
while (($#)); do
|
|
case "$1" in
|
|
--label)
|
|
label="$2"
|
|
shift 2
|
|
continue
|
|
;;
|
|
--retries)
|
|
retries="$2"
|
|
shift 2
|
|
continue
|
|
;;
|
|
--backoff)
|
|
read -r -a backoff <<<"$2"
|
|
shift 2
|
|
continue
|
|
;;
|
|
--)
|
|
shift
|
|
break
|
|
;;
|
|
-*)
|
|
common::log_warn "Unknown flag for common::run: $1"
|
|
shift
|
|
continue
|
|
;;
|
|
*)
|
|
break
|
|
;;
|
|
esac
|
|
done
|
|
|
|
cmd=("$@")
|
|
[[ -z "${label}" ]] && label="${cmd[*]}"
|
|
|
|
if [[ "${COMMON__DRY_RUN}" == true ]]; then
|
|
common::log_info "[dry-run] ${label}"
|
|
return 0
|
|
fi
|
|
|
|
local attempt=1
|
|
local exit_code=0
|
|
|
|
while (( attempt <= retries )); do
|
|
if "${cmd[@]}"; then
|
|
return 0
|
|
fi
|
|
|
|
exit_code=$?
|
|
if (( attempt == retries )); then
|
|
common::log_error "Command failed (${exit_code}): ${label}"
|
|
return "${exit_code}"
|
|
fi
|
|
|
|
local sleep_time="${backoff[$((attempt - 1))]:-1}"
|
|
common::log_warn "Command failed (${exit_code}): ${label} — retrying in ${sleep_time}s (attempt ${attempt}/${retries})"
|
|
sleep "${sleep_time}"
|
|
((attempt++))
|
|
done
|
|
|
|
return "${exit_code}"
|
|
}
|
|
|
|
# common::run_capture [--label desc] command [args...]
|
|
# Executes a command and captures stdout. Respects dry-run mode.
|
|
common::run_capture() {
|
|
local label=""
|
|
|
|
while (($#)); do
|
|
case "$1" in
|
|
--label)
|
|
label="$2"
|
|
shift 2
|
|
continue
|
|
;;
|
|
--)
|
|
shift
|
|
break
|
|
;;
|
|
-*)
|
|
common::log_warn "Unknown flag for common::run_capture: $1"
|
|
shift
|
|
continue
|
|
;;
|
|
*)
|
|
break
|
|
;;
|
|
esac
|
|
done
|
|
|
|
local -a cmd=("$@")
|
|
[[ -z "${label}" ]] && label="${cmd[*]}"
|
|
|
|
if [[ "${COMMON__DRY_RUN}" == true ]]; then
|
|
common::log_info "[dry-run] ${label}"
|
|
echo ""
|
|
return 0
|
|
fi
|
|
|
|
"${cmd[@]}"
|
|
}
|
|
|
|
# common::temp_dir <var> [--prefix name]
|
|
# Creates a temporary directory tracked for cleanup and assigns it to <var>.
|
|
common::temp_dir() {
|
|
local var_name=""
|
|
local prefix="pulse-"
|
|
|
|
if (($#)) && [[ $1 != --* ]]; then
|
|
var_name="$1"
|
|
shift
|
|
fi
|
|
|
|
while (($#)); do
|
|
case "$1" in
|
|
--prefix)
|
|
prefix="$2"
|
|
shift 2
|
|
continue
|
|
;;
|
|
*)
|
|
common::log_warn "Unknown argument to common::temp_dir: $1"
|
|
shift
|
|
continue
|
|
;;
|
|
esac
|
|
done
|
|
|
|
local dir
|
|
dir="$(mktemp -d -t "${prefix}XXXXXX")"
|
|
if (( "${BASH_SUBSHELL:-0}" > 0 )); then
|
|
common::log_warn "common::temp_dir invoked in subshell; cleanup handlers will not be registered automatically for ${dir}"
|
|
else
|
|
common::cleanup_push "Remove temp dir ${dir}" "rm -rf ${dir@Q}"
|
|
fi
|
|
|
|
if [[ -n "${var_name}" ]]; then
|
|
printf -v "${var_name}" '%s' "${dir}"
|
|
else
|
|
printf '%s\n' "${dir}"
|
|
fi
|
|
}
|
|
|
|
# common::cleanup_push "description" "command"
|
|
# Adds a cleanup handler executed in LIFO order on exit.
|
|
common::cleanup_push() {
|
|
local description="${1:-}"
|
|
local command="${2:-}"
|
|
|
|
if [[ -z "${command}" ]]; then
|
|
common::log_warn "Ignoring cleanup handler without command"
|
|
return
|
|
fi
|
|
|
|
COMMON__CLEANUP_DESCRIPTIONS+=("${description}")
|
|
COMMON__CLEANUP_COMMANDS+=("${command}")
|
|
}
|
|
|
|
# common::cleanup_run
|
|
# Executes registered cleanup handlers. Called automatically via EXIT trap.
|
|
common::cleanup_run() {
|
|
local idx=$(( ${#COMMON__CLEANUP_COMMANDS[@]} - 1 ))
|
|
while (( idx >= 0 )); do
|
|
local command="${COMMON__CLEANUP_COMMANDS[$idx]}"
|
|
local description="${COMMON__CLEANUP_DESCRIPTIONS[$idx]}"
|
|
if [[ -n "${description}" ]]; then
|
|
common::log_debug "Running cleanup: ${description}"
|
|
fi
|
|
eval "${command}"
|
|
((idx--))
|
|
done
|
|
|
|
COMMON__CLEANUP_COMMANDS=()
|
|
COMMON__CLEANUP_DESCRIPTIONS=()
|
|
}
|
|
|
|
# common::set_dry_run true|false
|
|
# Enables or disables global dry-run mode.
|
|
common::set_dry_run() {
|
|
local flag="${1:-false}"
|
|
case "${flag}" in
|
|
true|1|yes)
|
|
COMMON__DRY_RUN=true
|
|
;;
|
|
false|0|no)
|
|
COMMON__DRY_RUN=false
|
|
;;
|
|
*)
|
|
common::log_warn "Unknown dry-run flag: ${flag}"
|
|
COMMON__DRY_RUN=true
|
|
;;
|
|
esac
|
|
}
|
|
|
|
# common::is_dry_run
|
|
# Returns success when dry-run mode is active.
|
|
common::is_dry_run() {
|
|
[[ "${COMMON__DRY_RUN}" == true ]]
|
|
}
|
|
|
|
# Internal helper: configure color output.
|
|
common::__configure_color() {
|
|
if [[ "${PULSE_NO_COLOR:-0}" == "1" || ! -t 1 ]]; then
|
|
COMMON__COLOR_ENABLED=false
|
|
fi
|
|
|
|
if [[ "${COMMON__COLOR_ENABLED}" == true ]]; then
|
|
COMMON__ANSI_RESET=$'\033[0m'
|
|
COMMON__ANSI_INFO=$'\033[1;34m'
|
|
COMMON__ANSI_WARN=$'\033[1;33m'
|
|
COMMON__ANSI_ERROR=$'\033[1;31m'
|
|
COMMON__ANSI_DEBUG=$'\033[1;35m'
|
|
else
|
|
COMMON__ANSI_RESET=""
|
|
COMMON__ANSI_INFO=""
|
|
COMMON__ANSI_WARN=""
|
|
COMMON__ANSI_ERROR=""
|
|
COMMON__ANSI_DEBUG=""
|
|
fi
|
|
}
|
|
|
|
# Internal helper: set log level and debug flag.
|
|
common::__configure_log_level() {
|
|
if [[ "${PULSE_DEBUG:-0}" == "1" ]]; then
|
|
COMMON__DEBUG=true
|
|
COMMON__LOG_LEVEL="debug"
|
|
else
|
|
COMMON__LOG_LEVEL="${PULSE_LOG_LEVEL:-info}"
|
|
fi
|
|
|
|
COMMON__LOG_LEVEL="${COMMON__LOG_LEVEL,,}"
|
|
COMMON__LOG_LEVEL_NUM="${COMMON__LOG_LEVELS[${COMMON__LOG_LEVEL}]:-20}"
|
|
}
|
|
|
|
# Internal helper: generic logger.
|
|
common::__log() {
|
|
local level="$1"
|
|
shift
|
|
local message="$*"
|
|
|
|
local level_num="${COMMON__LOG_LEVELS[${level}]:-20}"
|
|
if (( level_num < COMMON__LOG_LEVEL_NUM )); then
|
|
return 0
|
|
fi
|
|
|
|
local timestamp
|
|
timestamp="$(date -u +"%Y-%m-%dT%H:%M:%SZ")"
|
|
local color=""
|
|
|
|
case "${level}" in
|
|
info) color="${COMMON__ANSI_INFO}" ;;
|
|
warn) color="${COMMON__ANSI_WARN}" ;;
|
|
error) color="${COMMON__ANSI_ERROR}" ;;
|
|
debug) color="${COMMON__ANSI_DEBUG}" ;;
|
|
esac
|
|
|
|
local formatted="[$timestamp] [${level^^}] ${message}"
|
|
if [[ "${level}" == "info" ]]; then
|
|
printf '%s%s%s\n' "${color}" "${formatted}" "${COMMON__ANSI_RESET}"
|
|
else
|
|
printf '%s%s%s\n' "${color}" "${formatted}" "${COMMON__ANSI_RESET}" >&2
|
|
fi
|
|
}
|
|
|
|
# Internal helper: determine absolute script path.
|
|
common::__resolve_script_path() {
|
|
local source="${BASH_SOURCE[1]:-${BASH_SOURCE[0]}}"
|
|
if [[ -z "${source}" ]]; then
|
|
pwd
|
|
return
|
|
fi
|
|
|
|
if [[ "${source}" == /* ]]; then
|
|
printf '%s\n' "${source}"
|
|
return
|
|
fi
|
|
|
|
printf '%s\n' "$(cd "$(dirname "${source}")" && pwd)/$(basename "${source}")"
|
|
}
|