#!/usr/bin/env bash # Download and install a platform-matched, checksum-verified Actspan bundle. set -euo pipefail usage() { cat <<'EOF' Usage: bash install.sh [options] --version TAG Release version (default: latest stable release) --download-base URL HTTPS release root (default: https://downloads.actspan.com/cli) --repo OWNER/REPO Use GitHub releases from this private repository --bin-dir DIR Command directory (default: ~/.local/bin) --install-dir DIR Persistent bundles (default: ~/.local/share/actspan) --github-cli Use authenticated GitHub CLI for private release access -h, --help Show this help Requires Bash, jq, unzip, and sha256sum or shasum. Public downloads use HTTPS without a GitHub account. --repo or --github-cli selects the private GitHub flow. Supports published macOS amd64/arm64 and Linux amd64/arm64 bundles for glibc and musl. No sudo or shell profile changes. Existing unmanaged commands are preserved. EOF } die() { printf 'Error: %s\n' "$*" >&2; exit 1; } need() { command -v "$1" >/dev/null 2>&1 || die "Required command '$1' is missing. Install it and retry."; } version=latest repository=senatechnologies/genaiops download_base=https://d3jf0s2r29lnlr.cloudfront.net/cli source_kind=public explicit_public=0 explicit_github=0 bin_dir="${HOME}/.local/bin" install_dir="${HOME}/.local/share/actspan" use_gh=0 work_dir= stage_dir= link_dir= activated=0 cleanup() { [[ -z "$work_dir" ]] || rm -rf -- "$work_dir" [[ -z "$link_dir" ]] || rm -rf -- "$link_dir" if [[ "$activated" == 0 && -n "$stage_dir" ]]; then # A signal can arrive after the atomic link switch but before activated=1. if [[ -L "${link_path:-}" && "$(readlink "$link_path")" == "$stage_dir/bundle/actspan" ]]; then return fi rm -rf -- "$stage_dir" fi } trap cleanup EXIT trap 'exit 130' INT trap 'exit 143' TERM while [[ $# -gt 0 ]]; do case "$1" in --version|--repo|--download-base|--bin-dir|--install-dir) [[ $# -ge 2 && -n "$2" && "$2" != --* ]] || die "$1 requires a value." case "$1" in --version) version="$2" ;; --repo) repository="$2"; source_kind=github; explicit_github=1 ;; --download-base) download_base="$2"; source_kind=public; explicit_public=1 ;; --bin-dir) bin_dir="$2" ;; --install-dir) install_dir="$2" ;; esac shift 2 ;; --github-cli) use_gh=1; source_kind=github; explicit_github=1; shift ;; -h|--help) usage; exit 0 ;; *) die "Unknown option '$1'. Use --help." ;; esac done [[ "$repository" =~ ^[A-Za-z0-9_-][A-Za-z0-9_.-]*/[A-Za-z0-9_-][A-Za-z0-9_.-]*$ ]] || die "Repository must be OWNER/REPO." [[ "$version" =~ ^[A-Za-z0-9][A-Za-z0-9._+-]*$ ]] || die "Invalid release tag '$version'. Use a version tag such as 0.2.0." [[ "$explicit_public" == 0 || "$explicit_github" == 0 ]] || die "--download-base cannot be combined with --repo or --github-cli." download_base="${download_base%/}" if [[ "$source_kind" == public ]]; then [[ "$download_base" =~ ^https://[A-Za-z0-9.-]+(:[0-9]+)?(/[A-Za-z0-9_-]+)*$ ]] || die "--download-base must be an HTTPS URL without credentials, query, or traversal." fi operating_system="$(uname -s)" architecture="$(uname -m)" case "$operating_system" in Darwin) if [[ "$architecture" == x86_64 ]] && command -v sysctl >/dev/null 2>&1; then if [[ "$(sysctl -in sysctl.proc_translated 2>/dev/null || true)" == 1 ]]; then architecture=arm64 fi fi case "$architecture" in arm64|aarch64) platform=macos-arm64 ;; x86_64|amd64) platform=macos-amd64 ;; *) die "No published macOS binary for architecture '$architecture'. Supported architectures are amd64 (Intel) and arm64 (Apple Silicon)." ;; esac ;; Linux) case "$architecture" in x86_64|amd64) architecture=amd64 ;; arm64|aarch64) architecture=arm64 ;; *) die "No published Linux binary for architecture '$architecture'. Supported architectures are amd64 (x86_64) and arm64 (aarch64)." ;; esac libc=glibc libc_description= if command -v ldd >/dev/null 2>&1; then libc_description="$(ldd --version 2>&1 || true)" if printf '%s' "$libc_description" | grep -qi musl; then libc=musl fi fi if [[ "$libc" == glibc ]] && ! printf '%s' "$libc_description" | grep -Eqi 'glibc|GNU libc'; then for musl_loader in /lib/ld-musl-*.so.1 /usr/lib/ld-musl-*.so.1; do if [[ -e "$musl_loader" ]]; then libc=musl break fi done fi platform="linux-$architecture" [[ "$libc" == glibc ]] || platform="$platform-musl" ;; *) die "Unsupported operating system '$operating_system'. Published binaries support macOS and Linux. On Windows, use the Python package or a compatible WSL Linux environment." ;; esac need jq need unzip if command -v sha256sum >/dev/null 2>&1; then checksum_command=sha256sum else need shasum checksum_command=shasum fi if [[ "$use_gh" == 1 ]]; then need gh; else need curl; fi umask 077 work_dir="$(mktemp -d "${TMPDIR:-/tmp}/actspan-install.XXXXXXXX")" work_dir="$(cd "$work_dir" && pwd -P)" api_path="repos/$repository/releases/latest" [[ "$version" == latest ]] || api_path="repos/$repository/releases/tags/$version" fetch_https() { curl --fail --silent --show-error --location --retry 2 \ --proto '=https' --proto-redir '=https' --connect-timeout 15 --max-time 600 \ --output "$2" "$1" } fetch_release() { if [[ "$source_kind" == public ]]; then manifest_path="$download_base/latest.json" [[ "$version" == latest ]] || manifest_path="$download_base/releases/$version/manifest.json" fetch_https "$manifest_path" "$work_dir/manifest.json" || return 1 jq -e ' select(.schema == 1 and (.version | type == "string") and (.assets | type == "array")) | {tag_name: .version, draft: false, assets: .assets} ' "$work_dir/manifest.json" > "$work_dir/release.json" || return 1 elif [[ "$use_gh" == 1 ]]; then gh api --hostname github.com "$api_path" > "$work_dir/release.json" else curl --fail --silent --show-error --location --retry 2 \ --proto '=https' --proto-redir '=https' --connect-timeout 15 --max-time 300 \ --output "$work_dir/release.json" "https://api.github.com/$api_path" fi } if ! fetch_release; then if [[ "$source_kind" == public ]]; then die "Cannot load release '$version' from '$download_base'. Check the download URL and try again." fi die "Cannot load release '$version' from '$repository'. Check the tag and access. For a private repository, authenticate gh and use --github-cli." fi jq -e 'type == "object" and (.tag_name | type == "string") and (.assets | type == "array") and .draft != true' \ "$work_dir/release.json" >/dev/null || die "Invalid or draft release metadata." release_tag="$(jq -r '.tag_name' "$work_dir/release.json")" [[ "$release_tag" =~ ^[A-Za-z0-9][A-Za-z0-9._+-]*$ ]] || die "Invalid release tag in metadata." [[ "$version" == latest || "$release_tag" == "$version" ]] || die "Release metadata does not match requested tag '$version'." # Select actual assets: a release tag does not have to equal the package version. jq -e --arg platform "$platform" ' [.assets[] | select(.name | type == "string") | select(.name | test("^actspan-[0-9][0-9A-Za-z.+_-]*-" + $platform + "\\.zip$"))] | if length == 1 then .[0] else error("Expected exactly one matching platform ZIP") end ' "$work_dir/release.json" > "$work_dir/archive.json" || die "Release '$release_tag' has no unique ZIP for '$platform'. Check its release assets." archive_name="$(jq -r '.name' "$work_dir/archive.json")" checksums_name="${archive_name%.zip}_SHA256SUMS" jq -e --arg name "$checksums_name" ' [.assets[] | select(.name == $name)] | if length == 1 then .[0] else error("Expected exactly one matching checksum asset") end ' "$work_dir/release.json" > "$work_dir/checksums.json" || die "Missing or ambiguous checksum asset '$checksums_name'." fetch_asset() { local metadata="$1" destination="$2" asset_id asset_url asset_name if [[ "$source_kind" == public ]]; then asset_name="$(jq -er '.name | select(type == "string")' "$metadata")" || return 1 [[ "$asset_name" =~ ^[A-Za-z0-9][A-Za-z0-9._+-]*$ ]] || return 1 fetch_https "$download_base/releases/$release_tag/$asset_name" "$destination" elif [[ "$use_gh" == 1 ]]; then asset_id="$(jq -er '.id | select(type == "number" and . > 0 and . == floor)' "$metadata")" || return 1 gh api --hostname github.com -H 'Accept: application/octet-stream' \ "repos/$repository/releases/assets/$asset_id" > "$destination" else asset_url="$(jq -er '.browser_download_url | select(type == "string")' "$metadata")" || return 1 case "$asset_url" in https://github.com/*/releases/download/*) ;; *) printf 'Invalid GitHub asset URL.\n' >&2; return 1 ;; esac curl --fail --silent --show-error --location --retry 2 \ --proto '=https' --proto-redir '=https' --connect-timeout 15 --max-time 600 \ --output "$destination" "$asset_url" fi } printf 'Release: %s\nPlatform: %s\nAsset: %s\n' "$release_tag" "$platform" "$archive_name" fetch_asset "$work_dir/checksums.json" "$work_dir/checksums" || die "Could not download '$checksums_name'." fetch_asset "$work_dir/archive.json" "$work_dir/$archive_name" || die "Could not download '$archive_name'." expected_digest="$(awk -v name="$archive_name" 'NF == 2 && ($2 == name || $2 == "*" name) {print $1}' "$work_dir/checksums")" [[ "$expected_digest" =~ ^[[:xdigit:]]{64}$ ]] || die "Checksum file must contain exactly one SHA256 entry for '$archive_name'." if [[ "$checksum_command" == sha256sum ]]; then actual_digest="$(sha256sum "$work_dir/$archive_name" | awk '{print $1}')" else actual_digest="$(shasum -a 256 "$work_dir/$archive_name" | awk '{print $1}')" fi [[ "$(printf '%s' "$expected_digest" | tr '[:upper:]' '[:lower:]')" == "$actual_digest" ]] || die "SHA256 checksum mismatch; installation was not changed." printf 'SHA256 checksum verified.\n' # Check member paths before extraction; permit only the complete bundle tree. unzip -Z1 "$work_dir/$archive_name" > "$work_dir/members" || die "Invalid ZIP archive." [[ -s "$work_dir/members" ]] || die "Empty ZIP archive." while IFS= read -r member; do case "$member" in actspan|actspan/*) ;; *) die "Unsafe archive path '$member'." ;; esac case "/$member/" in */../*|*/./*|*\\*) die "Unsafe archive path '$member'." ;; esac done < "$work_dir/members" mkdir "$work_dir/extracted" unzip -q "$work_dir/$archive_name" -d "$work_dir/extracted" || die "Could not extract the bundle." bundle="$work_dir/extracted/actspan" [[ -d "$bundle" && ! -L "$bundle" && -f "$bundle/actspan" && ! -L "$bundle/actspan" && -d "$bundle/_internal" ]] || die "Invalid bundle: expected actspan executable and adjacent _internal directory." # PyInstaller bundles legitimately contain relative symlinks (notably on macOS). # Reject links that escape the bundle before running the installed executable. find "$bundle" -type l -print > "$work_dir/symlinks" while IFS= read -r link; do target="$(readlink "$link")" [[ "$target" != /* ]] || die "Archive contains an absolute symlink." if [[ -d "$link" ]]; then resolved="$(cd "$link" && pwd -P)" elif [[ -f "$link" ]]; then resolved="$(cd "$(dirname "$link")/$(dirname "$target")" && pwd -P)/$(basename "$target")" else die "Archive contains a broken symlink." fi case "$resolved" in "$bundle"|"$bundle"/*) ;; *) die "Archive symlink escapes the bundle." ;; esac done < "$work_dir/symlinks" mkdir -p -- "$install_dir" "$bin_dir" || die "Cannot create installation directories. Choose writable --install-dir and --bin-dir paths." install_dir="$(cd "$install_dir" && pwd -P)" bin_dir="$(cd "$bin_dir" && pwd -P)" link_path="$bin_dir/actspan" if [[ -L "$link_path" ]]; then previous="$(readlink "$link_path")" case "$previous" in "$install_dir"/*/bundle/actspan) [[ -f "$(dirname "$(dirname "$previous")")/.actspan-installer" ]] || die "Existing actspan symlink is unmanaged. Choose another --bin-dir." ;; *) die "Existing actspan symlink is unmanaged. Choose another --bin-dir." ;; esac elif [[ -e "$link_path" ]]; then die "Existing '$link_path' is not managed by this installer. Choose another --bin-dir." fi stage_dir="$(mktemp -d "$install_dir/${archive_name%.zip}.XXXXXXXX")" mv "$bundle" "$stage_dir/bundle" chmod u+x "$stage_dir/bundle/actspan" printf 'Checking the downloaded CLI...\n' "$stage_dir/bundle/actspan" --version || die "The binary cannot run on this machine. Check OS/libc compatibility. Any previous installation remains active." printf '%s\n' "$source_kind $release_tag $archive_name" > "$stage_dir/.actspan-installer" # Switch only after verification. Keep previous bundles available for rollback. link_dir="$(mktemp -d "$bin_dir/.actspan-link.XXXXXXXX")" ln -s "$stage_dir/bundle/actspan" "$link_dir/actspan" mv -f "$link_dir/actspan" "$link_path" activated=1 printf '\nInstalled: %s\nBundle: %s\n' "$link_path" "$stage_dir/bundle" if [[ ":$PATH:" != *":$bin_dir:"* ]]; then printf 'Run this in your current shell:\n export PATH=%q:"$PATH"\n' "$bin_dir" printf 'Add the same line to your shell profile for future sessions.\n' printf 'Then run: actspan --version\n' else printf 'Run: actspan --version\n' fi