#!/usr/bin/env bash # HealOps CLI installer # Usage: curl -fsSL https://install.healops.ai | bash [ -n "${BASH_VERSION:-}" ] || { printf '%s\n' "Error: install-healops.sh requires bash. Run 'bash install-healops.sh' or pipe it into bash." >&2 exit 1 } set -euo pipefail if [ -t 1 ]; then COLOR_RESET=$'\033[0m' COLOR_BOLD=$'\033[1m' COLOR_RED=$'\033[31m' COLOR_GREEN=$'\033[32m' COLOR_YELLOW=$'\033[33m' COLOR_CYAN=$'\033[36m' SUCCESS_MARK="✓" else COLOR_RESET="" COLOR_BOLD="" COLOR_RED="" COLOR_GREEN="" COLOR_YELLOW="" COLOR_CYAN="" SUCCESS_MARK="Success:" fi # GitHub repo that publishes the release binaries REPO="${HEALOPS_INSTALL_REPO:-healopss/opensre}" DEFAULT_INSTALL_DIR="${HOME}/.local/bin" USER_INSTALL_DIR_CANDIDATES="${HEALOPS_USER_INSTALL_DIR_CANDIDATES:-$HOME/.local/bin:$HOME/bin}" SYSTEM_INSTALL_DIR_CANDIDATES="${HEALOPS_SYSTEM_INSTALL_DIR_CANDIDATES:-/opt/homebrew/bin:/usr/local/bin:/opt/local/bin}" INSTALL_DIR="${HEALOPS_INSTALL_DIR:-}" INSTALL_DIR_OVERRIDE=0 INSTALL_CHANNEL="${HEALOPS_INSTALL_CHANNEL:-release}" # Name of the binary inside the release archive ARCHIVE_BIN_NAME="opensre" # Name installed on the user's PATH INSTALL_BIN_NAME="healops" INSTALL_WITH_SUDO=0 requested_version="${HEALOPS_VERSION:-}" [ -n "$INSTALL_DIR" ] && INSTALL_DIR_OVERRIDE=1 requested_version="${requested_version#v}" log() { printf '%s\n' "$*"; } warn() { printf '%sWarning:%s %s\n' "${COLOR_YELLOW:-}" "${COLOR_RESET:-}" "$*" >&2; } die() { printf '%sError:%s %s\n' "${COLOR_RED:-}" "${COLOR_RESET:-}" "$*" >&2; exit 1; } success() { printf '%s%s %s%s\n' "${COLOR_GREEN:-}" "${SUCCESS_MARK:-Success:}" "$*" "${COLOR_RESET:-}"; } step() { printf '%s%s%s\n' "${COLOR_CYAN:-}" "$*" "${COLOR_RESET:-}"; } usage() { cat <<'EOF' Usage: install-healops.sh [--main] [--version ] [--install-dir ] Installs the HealOps CLI. Options: --main Install the rolling build published from the main branch. --version Install a specific release version (for example 2026.4.29). --install-dir Install into a specific directory. -h, --help Show this help text. Examples: curl -fsSL https://install.healops.ai | bash curl -fsSL https://install.healops.ai | bash -s -- --main curl -fsSL https://install.healops.ai | bash -s -- --version 2026.4.29 EOF } parse_args() { while [ "$#" -gt 0 ]; do case "$1" in --main) INSTALL_CHANNEL="main" ;; --release) INSTALL_CHANNEL="release" ;; --version) [ "$#" -ge 2 ] || die "--version requires a value." requested_version="${2#v}"; shift ;; --install-dir) [ "$#" -ge 2 ] || die "--install-dir requires a value." INSTALL_DIR="$2"; INSTALL_DIR_OVERRIDE=1; shift ;; -h|--help) usage; exit 0 ;; *) die "Unknown argument: $1" ;; esac shift done case "$INSTALL_CHANNEL" in release|main) ;; *) die "Unsupported install channel: ${INSTALL_CHANNEL}" ;; esac if [ "$INSTALL_CHANNEL" = "main" ] && [ -n "$requested_version" ]; then die "--version cannot be combined with --main." fi } parse_args "$@" need_cmd() { command -v "$1" >/dev/null 2>&1 || die "'$1' is required but was not found in PATH."; } need_cmd curl need_cmd grep need_cmd sed need_cmd tr need_cmd uname CURL_FLAGS=(--fail --silent --show-error --location --retry 3 --retry-delay 1) download_to() { curl "${CURL_FLAGS[@]}" -o "$2" "$1"; } download_text() { curl "${CURL_FLAGS[@]}" \ -H "Accept: application/vnd.github+json" \ -H "User-Agent: healops-install-script" \ "$1" } fetch_release_json() { local version="${1:-}" local api_url if [ "$INSTALL_CHANNEL" = "main" ]; then api_url="https://api.github.com/repos/${REPO}/releases/tags/nightly" elif [ -n "$version" ]; then api_url="https://api.github.com/repos/${REPO}/releases/tags/v${version}" else api_url="https://api.github.com/repos/${REPO}/releases/latest" fi download_text "$api_url" } extract_tag_name() { printf '%s\n' "$1" | sed -n '/"tag_name"[[:space:]]*:/{ s/.*"tag_name":[[:space:]]*"v\{0,1\}\([^"]*\)".*/\1/p; q }' } release_has_asset() { printf '%s' "$1" | tr -d '\r\n\t ' | grep -F "\"name\":\"${2}\"" >/dev/null 2>&1 } build_archive_name() { local version="$1" local asset_arch="$2" local archive_version="$version" [ "$INSTALL_CHANNEL" = "main" ] && archive_version="main" if [ "$platform" = "windows" ]; then printf 'opensre_%s_windows-%s.zip\n' "$archive_version" "$asset_arch" return fi printf 'opensre_%s_%s-%s.tar.gz\n' "$archive_version" "$platform" "$asset_arch" } path_has_dir() { case ":$PATH:" in *":$1:"*) return 0 ;; esac return 1 } is_candidate_dir_writable() { local dir="$1" if [ -d "$dir" ]; then [ -w "$dir" ]; return; fi local parent_dir="${dir%/*}" [ -n "$parent_dir" ] || parent_dir="/" [ -d "$parent_dir" ] && [ -w "$parent_dir" ] } select_writable_path_candidate_from_list() { local candidate_list="$1" local old_ifs="$IFS" local dir IFS=':' for dir in $candidate_list; do [ -n "$dir" ] || continue if path_has_dir "$dir" && is_candidate_dir_writable "$dir"; then printf '%s\n' "$dir"; IFS="$old_ifs"; return 0 fi done IFS="$old_ifs" return 1 } select_path_candidate_for_sudo() { local candidate_list="$1" local old_ifs="$IFS" local dir command -v sudo >/dev/null 2>&1 || return 1 [ "${EUID:-0}" -ne 0 ] || return 1 [ "$INSTALL_DIR_OVERRIDE" -eq 0 ] || return 1 IFS=':' for dir in $candidate_list; do [ -n "$dir" ] || continue if path_has_dir "$dir"; then printf '%s\n' "$dir"; IFS="$old_ifs"; return 0 fi done IFS="$old_ifs" return 1 } resolve_install_dir() { local existing_bin="" existing_dir="" [ -n "$INSTALL_DIR" ] && return [ "$platform" = "windows" ] && { INSTALL_DIR="$DEFAULT_INSTALL_DIR"; return; } if command -v healops >/dev/null 2>&1; then existing_bin="$(command -v healops || true)" existing_dir="${existing_bin%/*}" if [ -n "$existing_dir" ] && path_has_dir "$existing_dir" && is_candidate_dir_writable "$existing_dir"; then INSTALL_DIR="$existing_dir"; return fi fi if INSTALL_DIR="$(select_writable_path_candidate_from_list "$USER_INSTALL_DIR_CANDIDATES")"; then return; fi if INSTALL_DIR="$(select_writable_path_candidate_from_list "$SYSTEM_INSTALL_DIR_CANDIDATES")"; then return; fi if [ -n "${existing_dir:-}" ] && path_has_dir "$existing_dir" && command -v sudo >/dev/null 2>&1 && [ "${EUID:-0}" -ne 0 ] && [ "$INSTALL_DIR_OVERRIDE" -eq 0 ]; then INSTALL_DIR="$existing_dir"; INSTALL_WITH_SUDO=1; return fi if INSTALL_DIR="$(select_path_candidate_for_sudo "$SYSTEM_INSTALL_DIR_CANDIDATES")"; then INSTALL_WITH_SUDO=1; return fi INSTALL_DIR="$DEFAULT_INSTALL_DIR" } ps_escape() { printf '%s' "$1" | sed "s/'/''/g"; } to_windows_path() { command -v cygpath >/dev/null 2>&1 && { cygpath -w "$1"; return; } die "PowerShell archive extraction requires 'cygpath' when 'unzip' is unavailable." } extract_zip() { local archive_path="$1" destination_dir="$2" if command -v unzip >/dev/null 2>&1; then unzip -q "$archive_path" -d "$destination_dir"; return; fi local ap dp ap="$(ps_escape "$(to_windows_path "$archive_path")")" dp="$(ps_escape "$(to_windows_path "$destination_dir")")" if command -v powershell.exe >/dev/null 2>&1; then powershell.exe -NoLogo -NoProfile -NonInteractive -Command \ "Expand-Archive -LiteralPath '$ap' -DestinationPath '$dp' -Force" >/dev/null; return fi pwsh -NoLogo -NoProfile -NonInteractive -Command \ "Expand-Archive -LiteralPath '$ap' -DestinationPath '$dp' -Force" >/dev/null 2>&1 \ || die "A zip extractor is required on Windows. Install 'unzip'." } extract_archive() { local archive_path="$1" destination_dir="$2" if [ "$platform" = "windows" ]; then extract_zip "$archive_path" "$destination_dir"; return; fi need_cmd tar tar -xzf "$archive_path" -C "$destination_dir" } verify_checksum() { local checksum_path="$1" archive_path="$2" local archive_dir="${archive_path%/*}" local checksum_name="${checksum_path##*/}" local normalized="${checksum_path}.normalized" tr -d '\r' < "$checksum_path" > "$normalized" checksum_path="$normalized" checksum_name="${checksum_path##*/}" if command -v sha256sum >/dev/null 2>&1; then (cd "$archive_dir" && sha256sum -c "$checksum_name") >/dev/null \ || die "Checksum verification failed for '${archive_path##*/}'."; return fi if command -v shasum >/dev/null 2>&1; then (cd "$archive_dir" && shasum -a 256 -c "$checksum_name") >/dev/null \ || die "Checksum verification failed for '${archive_path##*/}'."; return fi if command -v openssl >/dev/null 2>&1; then local expected actual expected="$(sed -n 's/^\([0-9A-Fa-f]\{64\}\)[[:space:]][[:space:]]*.*/\1/p' "$checksum_path")" [ -n "$expected" ] || die "Checksum file '${checksum_name}' is malformed." actual="$(openssl dgst -sha256 "$archive_path" | sed 's/^.*= //')" [ "$expected" = "$actual" ] || die "Checksum verification failed for '${archive_path##*/}'."; return fi warn "No checksum verifier found (sha256sum, shasum, or openssl). Skipping checksum verification." } run_with_privilege() { [ "$INSTALL_WITH_SUDO" -eq 1 ] && { sudo "$@"; return; } "$@" } install_binary() { local source_path="$1" destination_path="$2" if command -v install >/dev/null 2>&1; then run_with_privilege install -m 0755 "$source_path" "$destination_path"; return fi run_with_privilege cp "$source_path" "$destination_path" run_with_privilege chmod 0755 "$destination_path" 2>/dev/null || true } # Find the opensre binary inside the archive, then install it as healops get_binary_path_from_archive() { local extraction_root="$1" local search_name="$2" local direct_path="${extraction_root}/${search_name}" local candidates=() [ -f "$direct_path" ] && { printf '%s\n' "$direct_path"; return; } need_cmd find while IFS= read -r c; do candidates+=("$c"); done \ < <(find "$extraction_root" -type f -name "$search_name") case "${#candidates[@]}" in 1) printf '%s\n' "${candidates[0]}" ;; 0) die "Archive did not contain '${search_name}'." ;; *) die "Found multiple '${search_name}' files after extraction." ;; esac } verify_binary_version() { local binary_path="$1" expected_version="${2:-}" local version_output actual_version version_output="$("$binary_path" --version 2>&1)" \ || die "Failed to execute '${binary_path##*/} --version': ${version_output}" actual_version="$(printf '%s\n' "$version_output" \ | sed -n 's/.*\([0-9][0-9][0-9][0-9]\.[0-9][0-9]*\.[0-9][0-9]*\).*/\1/p' | head -n 1)" if [ -z "$expected_version" ]; then [ -n "$actual_version" ] && printf '%s\n' "$actual_version" || printf 'main\n' return fi case "$version_output" in *"$expected_version"*) printf '%s\n' "$expected_version" ;; *) if [ -n "$requested_version" ] || [ -z "$actual_version" ]; then die "Downloaded binary version mismatch. Expected '${expected_version}' but got: ${version_output}" fi warn "Latest release reports v${expected_version}, downloaded binary reports v${actual_version}. Installing anyway." printf '%s\n' "$actual_version" ;; esac } configure_path() { case ":$PATH:" in *":${INSTALL_DIR}:"*) return ;; esac if [ "$platform" = "windows" ]; then warn "'${INSTALL_DIR}' is not in PATH. Add it to run healops from any terminal." return fi local rc_file="" path_line="" shell_name="${SHELL##*/}" case "$shell_name" in zsh) rc_file="${HOME}/.zshrc"; path_line="export PATH=\"\$PATH:${INSTALL_DIR}\"" ;; bash) if [ "$platform" = "darwin" ]; then rc_file="${HOME}/.bash_profile" else rc_file="${HOME}/.bashrc"; fi path_line="export PATH=\"\$PATH:${INSTALL_DIR}\"" ;; fish) rc_file="${HOME}/.config/fish/config.fish"; path_line="fish_add_path \"${INSTALL_DIR}\"" ;; *) log "Add the following line to your shell profile to use healops:" log " export PATH=\"\$PATH:${INSTALL_DIR}\"" return ;; esac local rc_dir="${rc_file%/*}" [ "$rc_dir" != "$rc_file" ] && [ ! -d "$rc_dir" ] && mkdir -p "$rc_dir" local marker="# Added by healops installer" if [ -f "$rc_file" ] && grep -qF "$marker" "$rc_file" && grep -qF "${INSTALL_DIR}" "$rc_file"; then return fi printf '\n%s\n%s\n' "$marker" "$path_line" >> "$rc_file" log "" log "healops has been added to PATH in ${rc_file}." log "To apply now, run: source \"${rc_file}\"" log "Or open a new terminal." } print_success_screen() { local version="$1" local sep="────────────────────────────────────────────" [ -t 1 ] || sep="--------------------------------------------" log "" log "$sep" success "Welcome to HealOps" if [ "$version" = "main" ]; then log " ${COLOR_BOLD:-}healops (main build) installed successfully${COLOR_RESET:-}" else log " ${COLOR_BOLD:-}healops v${version} installed successfully${COLOR_RESET:-}" fi log "$sep" log "" log "Next steps:" log " 1. Run healops onboard" log " Set up your LLM provider and connect your observability integrations." log "" log " 2. Run healops (no subcommand)" log " Starts the interactive shell — describe an incident at the prompt." log "" log " 3. Optional — one-shot investigation from a file:" log " healops investigate -i path/to/alert.json" log "" log "Docs: https://docs.healops.ai" log "" } # ── Platform detection ─────────────────────────────────────────────────────── os="$(uname -s)" arch="$(uname -m)" case "$os" in Linux) platform="linux" ;; Darwin) platform="darwin" ;; MINGW*|MSYS*|CYGWIN*) platform="windows"; ARCHIVE_BIN_NAME="opensre.exe"; log "Detected Windows (${os})." ;; *) die "Unsupported operating system: $os" ;; esac case "$arch" in x86_64|amd64) target_arch="x64" ;; arm64|aarch64) target_arch="arm64" ;; *) die "Unsupported architecture: $arch" ;; esac resolve_install_dir version="$requested_version" release_tag="" if [ "$INSTALL_CHANNEL" = "main" ]; then step "[1/4] Fetching latest main build metadata..." elif [ -n "$version" ]; then step "[1/4] Fetching release metadata for v${version}..." else step "[1/4] Fetching latest release version..." fi release_json="$(fetch_release_json "$version")" \ || die "Failed to query release metadata from GitHub." if [ "$INSTALL_CHANNEL" = "main" ]; then release_tag="$(extract_tag_name "$release_json")" [ -n "$release_tag" ] || die "Failed to determine the main build tag." else [ -z "$version" ] && version="$(extract_tag_name "$release_json")" release_tag="v${version}" [ -n "$version" ] || die "Failed to determine the release version." fi asset_arch="$target_arch" archive="$(build_archive_name "$version" "$asset_arch")" if [ "$platform" = "windows" ] && [ "$target_arch" = "arm64" ] && ! release_has_asset "$release_json" "$archive"; then fallback_archive="$(build_archive_name "$version" "x64")" if release_has_asset "$release_json" "$fallback_archive"; then asset_arch="x64"; archive="$fallback_archive" warn "Windows ARM64 artifact not published for v${version}; falling back to x64." fi fi release_has_asset "$release_json" "$archive" \ || die "Release v${version} does not include asset '${archive}'." download_url="https://github.com/${REPO}/releases/download/${release_tag}/${archive}" checksum_asset="${archive}.sha256" checksum_url="${download_url}.sha256" if [ "$INSTALL_CHANNEL" = "main" ]; then step "[2/4] Preparing healops main build (${platform}/${target_arch})..." else step "[2/4] Preparing healops v${version} (${platform}/${target_arch})..." fi [ "$asset_arch" != "$target_arch" ] && log "Using release asset built for ${platform}/${asset_arch}." step "[3/4] Downloading release archive..." log " ${download_url}" need_cmd mktemp tmp_dir="$(mktemp -d)" cleanup() { [ -n "${tmp_dir:-}" ] && [ -d "$tmp_dir" ] && rm -rf "$tmp_dir"; } trap cleanup EXIT archive_path="${tmp_dir}/${archive}" download_to "$download_url" "$archive_path" || die "Failed to download '${archive}'." if release_has_asset "$release_json" "$checksum_asset"; then checksum_path="${tmp_dir}/${checksum_asset}" download_to "$checksum_url" "$checksum_path" || die "Failed to download checksum '${checksum_asset}'." verify_checksum "$checksum_path" "$archive_path" else warn "Release is missing checksum asset '${checksum_asset}'." fi [ "$INSTALL_WITH_SUDO" -eq 1 ] && \ log "Installing into ${INSTALL_DIR} with sudo so 'healops' is available immediately." step "[4/4] Installing binary..." run_with_privilege mkdir -p "$INSTALL_DIR" extract_archive "$archive_path" "$tmp_dir" # Archive contains the binary as ARCHIVE_BIN_NAME; install it as INSTALL_BIN_NAME binary_path="$(get_binary_path_from_archive "$tmp_dir" "$ARCHIVE_BIN_NAME")" if [ "$INSTALL_CHANNEL" = "main" ]; then installed_version="$(verify_binary_version "$binary_path")" else installed_version="$(verify_binary_version "$binary_path" "$version")" fi install_binary "$binary_path" "${INSTALL_DIR}/${INSTALL_BIN_NAME}" success "Installed healops v${installed_version} to ${INSTALL_DIR}/${INSTALL_BIN_NAME}" configure_path print_success_screen "$installed_version"