#!/bin/sh set -eu REPO="garagon/aguara" BINARY="aguara" main() { need_cmd curl need_cmd tar need_checksum_tool os=$(detect_os) arch=$(detect_arch) if [ -n "${VERSION:-}" ]; then version="$VERSION" else version=$(get_latest_version) fi # GoReleaser strips the v prefix in archive names version_stripped=$(echo "$version" | sed 's/^v//') archive="${BINARY}_${version_stripped}_${os}_${arch}.tar.gz" url="https://github.com/${REPO}/releases/download/${version}/${archive}" checksums_url="https://github.com/${REPO}/releases/download/${version}/checksums.txt" install_dir="${INSTALL_DIR:-}" if [ -z "$install_dir" ]; then install_dir="$HOME/.local/bin" fi tmpdir=$(mktemp -d) trap 'rm -rf "$tmpdir"' EXIT log "Installing ${BINARY} ${version} (${os}/${arch})" # Download archive log "Downloading ${archive}..." download "$url" "${tmpdir}/${archive}" # Download and verify checksum log "Verifying checksum..." download "$checksums_url" "${tmpdir}/checksums.txt" verify_checksum "$tmpdir" "$archive" # Extract binary. `-o` instructs tar not to restore archive # ownership; without it, hardened containers running with # `--cap-drop ALL` (no CAP_CHOWN) fail on `Cannot change ownership` # when the archive records a uid/gid the runtime cannot apply. # `-o` is POSIX-portable across GNU, BSD, and BusyBox tar. tar -xzof "${tmpdir}/${archive}" -C "$tmpdir" if [ ! -f "${tmpdir}/${BINARY}" ]; then err "binary not found in archive" fi # Install mkdir -p "$install_dir" if [ -w "$install_dir" ]; then mv "${tmpdir}/${BINARY}" "${install_dir}/${BINARY}" else log "Elevated permissions required to install to ${install_dir}" sudo mv "${tmpdir}/${BINARY}" "${install_dir}/${BINARY}" fi chmod +x "${install_dir}/${BINARY}" # Verify if "${install_dir}/${BINARY}" version >/dev/null 2>&1; then installed_version=$("${install_dir}/${BINARY}" version 2>/dev/null || true) log "Installed ${BINARY} ${installed_version} to ${install_dir}/${BINARY}" else log "Installed ${BINARY} to ${install_dir}/${BINARY}" fi # PATH check case ":${PATH}:" in *":${install_dir}:"*) ;; *) warn "${install_dir} is not in your PATH" printf '\n Add this to your shell config (~/.bashrc, ~/.zshrc, etc.):\n' printf '\n export PATH="%s:$PATH"\n\n' "$install_dir" printf ' Then restart your terminal or run: source ~/.zshrc\n\n' ;; esac } detect_os() { uname_s=$(uname -s) case "$uname_s" in Linux*) echo "linux" ;; Darwin*) echo "darwin" ;; *) err "unsupported OS: ${uname_s}. Use 'go install' instead." ;; esac } detect_arch() { uname_m=$(uname -m) case "$uname_m" in x86_64|amd64) echo "amd64" ;; aarch64|arm64) echo "arm64" ;; *) err "unsupported architecture: ${uname_m}" ;; esac } # download fetches a URL into a local file with bounded time and retries. # Used for release artifacts (archive, checksums.txt). Times out at 120s # so a hung TCP connection cannot stall the install indefinitely. download() { url="$1" output="$2" curl -fsSL \ --max-time 120 \ --retry 3 \ --retry-delay 2 \ --retry-connrefused \ -o "$output" "$url" \ || err "failed to download: ${url}" } get_latest_version() { api_url="https://api.github.com/repos/${REPO}/releases/latest" # GitHub API has a 60/h anonymous rate limit per IP. CI runners share # IP pools and exhaust this quickly; sending GITHUB_TOKEN raises the # ceiling to 5000/h authenticated. Token is never echoed or logged. if [ -n "${GITHUB_TOKEN:-}" ]; then response=$(curl -fsSL \ --max-time 30 \ --retry 3 \ --retry-delay 2 \ --retry-connrefused \ -H "Authorization: Bearer ${GITHUB_TOKEN}" \ "$api_url") \ || err "failed to fetch latest version from GitHub" else response=$(curl -fsSL \ --max-time 30 \ --retry 3 \ --retry-delay 2 \ --retry-connrefused \ "$api_url") \ || err "failed to fetch latest version from GitHub (set GITHUB_TOKEN to avoid anonymous rate limits)" fi version=$(echo "$response" | sed -n 's/.*"tag_name": *"\([^"]*\)".*/\1/p') if [ -z "$version" ]; then err "could not determine latest version" fi echo "$version" } # verify_checksum aborts on any failure, including missing tooling. # Skipping checksum verification would let a network MITM swap the binary, # so a missing sha256sum/shasum is a hard error rather than a warning. verify_checksum() { dir="$1" file="$2" # Exact filename match on column 2, not substring grep. Substring # match picked up sibling artifacts that share a prefix - e.g. # `aguara_0.14.2_linux_amd64.tar.gz` also matches # `aguara_0.14.2_linux_amd64.tar.gz.sbom.json`, so `awk '{print $1}'` # returned two concatenated checksums and every comparison failed. expected=$(awk -v f="$file" '$2==f {print $1; exit}' "${dir}/checksums.txt") if [ -z "$expected" ]; then err "checksum not found for ${file} in checksums.txt" fi if command -v sha256sum >/dev/null 2>&1; then actual=$(sha256sum "${dir}/${file}" | awk '{print $1}') elif command -v shasum >/dev/null 2>&1; then actual=$(shasum -a 256 "${dir}/${file}" | awk '{print $1}') else # Should be unreachable: need_checksum_tool runs at startup. err "no sha256 tool available; install coreutils or perl-Digest-SHA" fi if [ "$actual" != "$expected" ]; then err "checksum mismatch: expected ${expected}, got ${actual}" fi } need_cmd() { if ! command -v "$1" >/dev/null 2>&1; then err "required command not found: $1" fi } # need_checksum_tool aborts the install up front if neither sha256sum nor # shasum is available, rather than discovering it after downloading. # macOS ships shasum by default; Linux distros ship sha256sum via coreutils. need_checksum_tool() { if command -v sha256sum >/dev/null 2>&1; then return; fi if command -v shasum >/dev/null 2>&1; then return; fi err "no sha256 tool found (need sha256sum or shasum). Install coreutils (Linux) or perl-Digest-SHA, then retry." } log() { printf ' \033[1;32m>\033[0m %s\n' "$1" } warn() { printf ' \033[1;33m!\033[0m %s\n' "$1" } err() { printf ' \033[1;31mx\033[0m %s\n' "$1" >&2 exit 1 } main