493 lines
17 KiB
Bash
Executable File
493 lines
17 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
set -euo pipefail
|
|
# An installer script for GitHub releases
|
|
# Usage: github-release-installer [--test|-t|--debug|-d|--dry-run|-n] "App Name" "binary_name" "repo/name"
|
|
|
|
# Globals
|
|
GITHUB_API_BASE_URL="https://api.github.com"
|
|
GITHUB_TOKEN="${GITHUB_TOKEN:-}"
|
|
GITHUB_TOKEN_FILE="${GITHUB_TOKEN_FILE:-$HOME/.github_public_token.env}"
|
|
CURL_OPTS=("-s" "-L")
|
|
|
|
# User authentication for GitHub API requests
|
|
if [[ -z $GITHUB_TOKEN ]]; then
|
|
[[ -f $GITHUB_TOKEN_FILE ]] && source $GITHUB_TOKEN_FILE
|
|
fi
|
|
|
|
# If we have a GitHub token, use it for authentication
|
|
[[ -n $GITHUB_TOKEN ]] && CURL_OPTS+=("--header" "Authorization: token $GITHUB_TOKEN")
|
|
|
|
# Load github-specific curl options, if present
|
|
[[ -f $HOME/.curlrc-github ]] && CURL_OPTS+=("--config" "$HOME/.curlrc-github")
|
|
|
|
function discover_system_and_architecture() {
|
|
# Detect system and architecture, unless overridden
|
|
SYSTEM="${SYSTEM:-$(uname -s)}"
|
|
ARCH="${ARCH:-$(uname -m)}"
|
|
|
|
case "${SYSTEM,,}" in
|
|
linux)
|
|
SYSTEM=Linux
|
|
SYSTEM_RE="linux"
|
|
ROOT_USER=root
|
|
ROOT_GROUP=root
|
|
|
|
case "${ARCH,,}" in
|
|
x86_64 | amd64)
|
|
ARCH="Intel 64-bit"
|
|
ARCH_RE="(x64|amd64)"
|
|
;;
|
|
aarch64 | arm64)
|
|
ARCH="ARM 64-bit"
|
|
ARCH_RE="arm64"
|
|
;;
|
|
*)
|
|
echo "This script only supports x86_64 and arm64 architectures." >&2
|
|
return 1
|
|
;;
|
|
esac
|
|
;;
|
|
|
|
darwin|osx|macos)
|
|
SYSTEM=MacOS
|
|
SYSTEM_RE="(darwin|osx)"
|
|
ROOT_USER=root
|
|
ROOT_GROUP=wheel
|
|
|
|
case "${ARCH,,}" in
|
|
x86_64)
|
|
ARCH="Intel 64-bit"
|
|
ARCH_RE="(x86_64|x64|amd64|universal)"
|
|
;;
|
|
arm64)
|
|
ARCH="ARM 64-bit"
|
|
ARCH_RE="(arm64|universal)"
|
|
;;
|
|
*)
|
|
echo "This script only supports x86_64 and arm64 architectures." >&2
|
|
return 1
|
|
;;
|
|
esac
|
|
;;
|
|
|
|
*)
|
|
echo "This script only supports Linux and macOS." >&2
|
|
return 1
|
|
;;
|
|
esac
|
|
|
|
return 0
|
|
}
|
|
|
|
function github_assets() {
|
|
if [[ -z $REPO_NAME ]]; then
|
|
echo "ERROR: Repository name is required to list GitHub assets." >&2
|
|
return 1
|
|
fi
|
|
|
|
# Expand combined asset types
|
|
if [[ -z $ASSET_TYPE ]]; then
|
|
# Configure asset selector to match operating system packages and archives
|
|
case $SYSTEM in
|
|
Linux)
|
|
ASSET_TYPE='\.(deb|rpm|zip|tar\.gz|tgz)$'
|
|
;;
|
|
MacOS)
|
|
ASSET_TYPE='\.(pkg|zip|tar\.gz|tgz)$'
|
|
;;
|
|
*)
|
|
ASSET_TYPE='\.(zip|tar\.gz|tgz)$'
|
|
;;
|
|
esac
|
|
elif [[ $ASSET_TYPE == "archive" ]]; then
|
|
ASSET_TYPE='\.(zip|tar\.gz|tgz)$'
|
|
elif [[ $ASSET_TYPE == "package" ]]; then
|
|
ASSET_TYPE='\.(deb|pkg|rpm)$'
|
|
fi
|
|
|
|
# Check, if the asset type has a '$' at the end
|
|
if [[ ! $ASSET_TYPE =~ \$\$?$ ]]; then
|
|
ASSET_TYPE="${ASSET_TYPE}$"
|
|
fi
|
|
|
|
# Asset type should start with a dot
|
|
if [[ ! $ASSET_TYPE =~ ^\\\. ]]; then
|
|
ASSET_TYPE="\.$ASSET_TYPE"
|
|
fi
|
|
|
|
# Preprocess GitHub releases API response
|
|
if [[ -z $ASSET_NAME || $ASSET_NAME =~ ^~ ]]; then
|
|
# No asset name provided, or asset name is a regex pattern
|
|
if [[ -z $ASSET_NAME ]]; then
|
|
PATTERN=".*"
|
|
[[ $TEST_MODE -gt 1 ]] && echo "[DEBUG]: Filtering assets for system regex: '$SYSTEM_RE' and architecture regex: '$ARCH_RE'." >&2
|
|
else
|
|
PATTERN="${ASSET_NAME:1}"
|
|
[[ $TEST_MODE -gt 1 ]] && echo "[DEBUG]: Filtering assets for system regex: '$SYSTEM_RE', architecture regex: '$ARCH_RE', asset type regex: '$ASSET_TYPE'." >&2
|
|
fi
|
|
|
|
curl "${CURL_OPTS[@]}" "$GITHUB_API_BASE_URL/repos/$REPO_NAME/releases/latest" |
|
|
jq -r \
|
|
--arg PATTERN "$PATTERN" \
|
|
--arg SYSTEM "$SYSTEM_RE" \
|
|
--arg ARCH "$ARCH_RE" \
|
|
--arg ASSET_TYPE "$ASSET_TYPE" \
|
|
'{version: .tag_name | sub("^v"; ""; "i"), assets: [.assets[] | {name, browser_download_url} | select(.name | test("^" + $PATTERN + ".*" + $SYSTEM + "[_-]" + $ARCH + ".*" + $ASSET_TYPE; "i"))]}'
|
|
else
|
|
# Specific asset name provided
|
|
curl "${CURL_OPTS[@]}" "$GITHUB_API_BASE_URL/repos/$REPO_NAME/releases/latest" |
|
|
jq -r \
|
|
--arg ASSET_NAME "$ASSET_NAME" \
|
|
'{version: .tag_name | sub("^v"; ""; "i"), assets: [.assets[] | {name, browser_download_url} | select(.name == $ASSET_NAME)]}'
|
|
fi
|
|
}
|
|
|
|
function define_sudo_cmd() {
|
|
if [[ $(id -u) -ne 0 ]]; then
|
|
# Not running as root, need sudo for installation
|
|
SUDOCMD="sudo"
|
|
if ! command -v sudo &> /dev/null; then
|
|
echo "ERROR: sudo is not installed."
|
|
return 1
|
|
fi
|
|
else
|
|
SUDOCMD=""
|
|
fi
|
|
}
|
|
|
|
function github_release_installer() {
|
|
if ! command -v jq &> /dev/null || ! command -v curl &> /dev/null; then
|
|
echo "ERROR: jq or curl is not installed."
|
|
exit 1
|
|
fi
|
|
|
|
# Check, if required variables are set
|
|
if [[ -z $APP_NAME || -z $BINARY_NAME || -z $REPO_NAME ]]; then
|
|
# Check, if the values were provided as function arguments
|
|
if [[ $# -ne 3 ]]; then
|
|
echo "ERROR: APP_NAME, BINARY_NAME, and REPO_NAME must be set before calling github_release_installer." >&2
|
|
return 1
|
|
fi
|
|
|
|
# Set from function arguments
|
|
APP_NAME="$1"
|
|
BINARY_NAME="$2"
|
|
REPO_NAME="$3"
|
|
fi
|
|
|
|
# Detect system and architecture
|
|
discover_system_and_architecture || return 1
|
|
|
|
echo "Detected system: $SYSTEM, architecture: $ARCH"
|
|
|
|
# Download GitHub releases API response
|
|
GITHUB_API_RESPONSE="$(github_assets)"
|
|
if [[ -z $GITHUB_API_RESPONSE ]]; then
|
|
echo "ERROR: Could not download GitHub releases for '$APP_NAME'." >&2
|
|
return 1
|
|
fi
|
|
[[ $TEST_MODE -gt 1 ]] && echo "[DEBUG]: GitHub API response for '$APP_NAME' is:" && cat <<< "$GITHUB_API_RESPONSE" >&2
|
|
|
|
if [[ $TEST_MODE -gt 1 ]]; then
|
|
echo "The filtered GitHub API response for '$APP_NAME' is:"
|
|
echo "$GITHUB_API_RESPONSE"
|
|
fi
|
|
|
|
VERSION=$(jq -r .version <<< "$GITHUB_API_RESPONSE")
|
|
if [[ -z $VERSION ]]; then
|
|
echo "ERROR: Could not determine the latest version of '$APP_NAME'." >&2
|
|
return 1
|
|
fi
|
|
|
|
[[ $TEST_MODE -gt 1 ]] && echo "[DEBUG]: Latest version of '$APP_NAME' is $VERSION."
|
|
[[ $TEST_MODE -gt 1 ]] && echo "[DEBUG]: Asset name to download: ${ASSET_NAME:-<not specified>}."
|
|
|
|
NUM_ASSETS=$(jq -r '.assets | length' <<< "$GITHUB_API_RESPONSE")
|
|
if [[ $NUM_ASSETS -eq 0 ]]; then
|
|
echo "ERROR: No assets found for '$APP_NAME' version $VERSION for $SYSTEM $ARCH." >&2
|
|
return 1
|
|
elif [[ $NUM_ASSETS -gt 1 ]]; then
|
|
echo "WARNING: Multiple assets found for '$APP_NAME' version $VERSION for $SYSTEM $ARCH."
|
|
echo -e "Please specify an asset name from the list below:\n"
|
|
jq -r '.assets[] | "- \(.name)"' <<< "$GITHUB_API_RESPONSE"
|
|
echo ""
|
|
return 0
|
|
fi
|
|
|
|
if [[ -z $ASSET_NAME ]]; then
|
|
# No asset name provided, check how many assets are available
|
|
DOWNLOAD_URL=$(jq -r '.assets[].browser_download_url' <<< "$GITHUB_API_RESPONSE")
|
|
elif [[ $ASSET_NAME =~ ^~ ]]; then
|
|
# Asset name is a regex pattern
|
|
PATTERN="${ASSET_NAME:1}"
|
|
[[ $TEST_MODE -gt 0 ]] && echo "[DEBUG]: Using regex pattern to find asset: $PATTERN" >&2
|
|
DOWNLOAD_URL=$(jq -r --arg PATTERN "$PATTERN" '.assets[] | select(.name | test($PATTERN; "i")) | .browser_download_url' <<< "$GITHUB_API_RESPONSE")
|
|
else
|
|
DOWNLOAD_URL=$(jq -r --arg ASSET_NAME "$ASSET_NAME" '.assets[] | select(.name == $ASSET_NAME) | .browser_download_url' <<< "$GITHUB_API_RESPONSE")
|
|
fi
|
|
|
|
if [[ -z $DOWNLOAD_URL ]]; then
|
|
echo "ERROR: Could not find a suitable download URL for '$APP_NAME' version $VERSION for $SYSTEM $ARCH." >&2
|
|
return 1
|
|
fi
|
|
|
|
# If in test mode, exit here
|
|
[[ $TEST_MODE -gt 0 ]] && return 0
|
|
|
|
# if output directory is specified, just download the file there
|
|
if [[ -n $OUTPUT_DIR ]]; then
|
|
if [[ ! -d $OUTPUT_DIR ]]; then
|
|
echo "ERROR: Output directory '$OUTPUT_DIR' does not exist." >&2
|
|
return 1
|
|
fi
|
|
# Remove "-s" from CURL_OPTS to show download progress
|
|
CURL_OPTS=("${CURL_OPTS[@]:1}")
|
|
OUTPUT_NAME=$(basename "$DOWNLOAD_URL")
|
|
OUTPUT_PATH="$OUTPUT_DIR/$OUTPUT_NAME"
|
|
echo "Downloading '$APP_NAME' version $VERSION to '$OUTPUT_PATH'..."
|
|
# Execute curl to download the file and exit
|
|
exec curl "${CURL_OPTS[@]}" -o "$OUTPUT_PATH" "$DOWNLOAD_URL"
|
|
fi
|
|
|
|
echo -e "Will download '$APP_NAME' version: $VERSION\nDownload URL: \"$DOWNLOAD_URL\"."
|
|
|
|
define_sudo_cmd || return 1
|
|
|
|
TMP=$(mktemp -d -t github-release-installer-XXXXXX)
|
|
|
|
# Ensure temporary artifacts are cleaned up on exit
|
|
trap "test -d $TMP && rm -rf $TMP" EXIT
|
|
|
|
# Change to temporary directory
|
|
cd "$TMP" || return 1
|
|
# Create extraction directory
|
|
mkdir -p $TMP/extracted || return 1
|
|
|
|
echo "Downloading '$APP_NAME' version $VERSION..."
|
|
|
|
# Check, if the binary name is different from the original
|
|
if grep -q ":" <<< "$BINARY_NAME"; then
|
|
ORIGINAL_NAME="${BINARY_NAME%%:*}"
|
|
DESTINATION_NAME="${BINARY_NAME#*:}"
|
|
else
|
|
ORIGINAL_NAME="$BINARY_NAME"
|
|
DESTINATION_NAME="$BINARY_NAME"
|
|
fi
|
|
|
|
# Check, the file type
|
|
if [[ $DOWNLOAD_URL == *.zip ]]; then
|
|
curl "${CURL_OPTS[@]}" -o $TMP/archive.zip $DOWNLOAD_URL || return 1
|
|
echo "Uncompressing ZIP archive of '$APP_NAME'..."
|
|
unzip -q $TMP/archive.zip -d $TMP/extracted || return 1
|
|
elif [[ $DOWNLOAD_URL =~ \.(tar\.gz|tgz)$ ]]; then
|
|
curl "${CURL_OPTS[@]}" -o $TMP/archive.tgz $DOWNLOAD_URL || return 1
|
|
echo "Uncompressing GZIPped TAR archive of '$APP_NAME'..."
|
|
tar -x -z -C $TMP/extracted -f $TMP/archive.tgz || return 1
|
|
elif [[ $DOWNLOAD_URL =~ deb$ ]]; then
|
|
echo "Downloading Debian package of '$APP_NAME'..."
|
|
curl "${CURL_OPTS[@]}" -LO $DOWNLOAD_URL || return 1
|
|
# Install the deb package to extract its contents
|
|
echo "Installing Debian package of '$APP_NAME'..."
|
|
$SUDOCMD dpkg -i $(basename $DOWNLOAD_URL)
|
|
return $?
|
|
elif [[ $DOWNLOAD_URL =~ pkg$ ]]; then
|
|
echo "Downloading macOS package of '$APP_NAME'..."
|
|
curl "${CURL_OPTS[@]}" -LO $DOWNLOAD_URL || return 1
|
|
# Install the pkg package to extract its contents
|
|
echo "Installing macOS package of '$APP_NAME'..."
|
|
$SUDOCMD installer -pkg $(basename $DOWNLOAD_URL) -target /
|
|
return $?
|
|
elif [[ $DOWNLOAD_URL =~ rpm$ ]]; then
|
|
echo "Downloading RPM package of '$APP_NAME'..."
|
|
curl "${CURL_OPTS[@]}" -LO $DOWNLOAD_URL || return 1
|
|
# Install the rpm package to extract its contents
|
|
echo "Installing RPM package of '$APP_NAME'..."
|
|
$SUDOCMD rpm -i $(basename $DOWNLOAD_URL)
|
|
return $?
|
|
else
|
|
echo "Downloading binary release of '$APP_NAME'..."
|
|
(cd $TMP/extracted && curl "${CURL_OPTS[@]}" -LO $DOWNLOAD_URL) || return 1
|
|
return 0
|
|
fi
|
|
|
|
# Find the binary file by looking for a file with the exact name
|
|
BINARY_PATH=$(find $TMP/extracted -name "$ORIGINAL_NAME" -type f)
|
|
|
|
if [[ -z $BINARY_PATH ]]; then
|
|
# Try with system and architecture suffix
|
|
BINARY_PATH=$(find $TMP/extracted -name "${ORIGINAL_NAME}-${SYSTEM}-${ARCH}" -type f)
|
|
fi
|
|
|
|
if [[ -z $BINARY_PATH ]]; then
|
|
echo "ERROR: Could not find binary file '$ORIGINAL_NAME' to install. Specify the correct binary name with the -b option." >&2
|
|
echo ""
|
|
echo "Available files in the extracted archive:"
|
|
find $TMP/extracted -type f | sed "s|$TMP/extracted/||"
|
|
echo ""
|
|
return 1
|
|
fi
|
|
|
|
echo "Installing '$APP_NAME' binary "$ORIGINAL_NAME" to /usr/local/bin/$DESTINATION_NAME..."
|
|
$SUDOCMD install -m 755 $BINARY_PATH /usr/local/bin/$DESTINATION_NAME
|
|
}
|
|
|
|
function usage() {
|
|
cat << EOF
|
|
Usage: github-release-installer [options] "repo/name"
|
|
|
|
Options:
|
|
--dry-run Run in test mode. Optional level (default: 9).
|
|
Or set TEST_MODE environment variable to a number > 0.
|
|
-l --list "repo/name" List available assets for the given repository.
|
|
-a, --app-name "App Name" Name of the application (optional).
|
|
-b, --binary-name "binary_name[:destination_name]" Name of the binary to install and optional destination name (optional).
|
|
-j, --releases-json Only download the releases JSON file.
|
|
-o, --output-directory Only download the file to the specified directory.
|
|
-f, --file-name "file_name" Name of the file to download (optional).
|
|
-t, --type "type" Type of the asset to download: archive (zip, tar.gz, tgz) or package (deb, pkg, rpm) (optional).
|
|
|
|
Example:
|
|
github-release-installer -a "GitHub CLI" -b gh cli/cli
|
|
EOF
|
|
}
|
|
|
|
# Default values
|
|
APP_NAME=""
|
|
BINARY_NAME=""
|
|
REPO_NAME=""
|
|
ASSET_NAME=""
|
|
ASSET_TYPE=""
|
|
OUTPUT_DIR=""
|
|
RELEASES_ONLY=0
|
|
SYSTEM=""
|
|
ARCH=""
|
|
|
|
# Use value from environment variable or default to 0
|
|
TEST_MODE=${TEST_MODE:-0}
|
|
|
|
# Check, if we have been executed or sourced.
|
|
if [[ "${BASH_SOURCE[0]}" != "${0}" ]]; then
|
|
return 0
|
|
fi
|
|
|
|
shopt -s nocasematch
|
|
# Parse command line arguments
|
|
while [[ $# -gt 0 ]]; do
|
|
case "$1" in
|
|
-h|--help)
|
|
usage
|
|
exit 0
|
|
;;
|
|
--dry-run)
|
|
# Check, if the next argument is present and is a number
|
|
if [[ ${2:-} =~ ^[0-9]+$ ]]; then
|
|
TEST_MODE="$2"
|
|
shift 2
|
|
else
|
|
TEST_MODE=1
|
|
shift
|
|
fi
|
|
;;
|
|
-l|--list)
|
|
REPO_NAME="$2"
|
|
shift 2
|
|
curl "${CURL_OPTS[@]}" "$GITHUB_API_BASE_URL/repos/$REPO_NAME/releases/latest" |
|
|
jq -r '"- \(.assets[].browser_download_url)"'
|
|
exit $?
|
|
;;
|
|
-a|--app-name)
|
|
APP_NAME="$2"
|
|
shift 2
|
|
;;
|
|
-b|--binary-name)
|
|
BINARY_NAME="$2"
|
|
shift 2
|
|
;;
|
|
-o|--output-directory)
|
|
OUTPUT_DIR="$2"
|
|
shift 2
|
|
# That trick mimicks realpath
|
|
OUTPUT_DIR="$(cd "$OUTPUT_DIR" && pwd)"
|
|
;;
|
|
-j|--releases-json)
|
|
RELEASES_ONLY=1
|
|
shift
|
|
;;
|
|
-f|--file-name)
|
|
ASSET_NAME="$2"
|
|
shift 2
|
|
;;
|
|
-t|--type)
|
|
if [[ $2 =~ ^(zip|gzip|gz|tar|tar\.gz|tgz|deb|pkg|rpm)$ ]]; then
|
|
ASSET_TYPE="$2$"
|
|
elif [[ $2 =~ ^(archive|package)$ ]]; then
|
|
ASSET_TYPE="$2"
|
|
else
|
|
echo "ERROR: Unknown asset type: $2" >&2
|
|
usage
|
|
exit 1
|
|
fi
|
|
shift 2
|
|
;;
|
|
# Override system and architecture detection
|
|
--system)
|
|
SYSTEM="$2"
|
|
shift 2
|
|
;;
|
|
--arch)
|
|
ARCH="$2"
|
|
shift 2
|
|
;;
|
|
-*)
|
|
echo "Unknown option: $1" >&2
|
|
usage
|
|
exit 1
|
|
;;
|
|
*)
|
|
break
|
|
;;
|
|
esac
|
|
done
|
|
|
|
# First positional argument is REPO_NAME
|
|
REPO_NAME="${1:-}"
|
|
shift
|
|
|
|
if [[ -z $REPO_NAME ]]; then
|
|
echo "ERROR: Repository name is required." >&2
|
|
usage
|
|
exit 1
|
|
fi
|
|
|
|
# If APP_NAME or BINARY_NAME are missing, try to infer them
|
|
# Use last part of REPO_NAME as BINARY_NAME if not provided
|
|
if [[ -z $BINARY_NAME ]]; then
|
|
BINARY_NAME="${REPO_NAME##*/}"
|
|
fi
|
|
|
|
# Use capitalized last part of REPO_NAME as APP_NAME if not provided
|
|
if [[ -z $APP_NAME ]]; then
|
|
APP_NAME="${REPO_NAME##*/}"
|
|
APP_NAME="${APP_NAME^[a-z]}"
|
|
fi
|
|
|
|
if [[ $RELEASES_ONLY -ne 0 ]]; then
|
|
URL="$GITHUB_API_BASE_URL/repos/$REPO_NAME/releases/latest"
|
|
echo "Downloading GitHub releases for '$APP_NAME' from $URL..."
|
|
|
|
OUTPUT_PATH="${BINARY_NAME}.releases.json"
|
|
if [[ -n $OUTPUT_DIR ]]; then
|
|
OUTPUT_PATH="$OUTPUT_DIR/$OUTPUT_PATH"
|
|
fi
|
|
|
|
if ! curl "${CURL_OPTS[@]}" -o "$OUTPUT_PATH" "$URL"; then
|
|
echo "ERROR: Could not download GitHub releases for '$APP_NAME' from $URL." >&2
|
|
exit 1
|
|
fi
|
|
echo "Downloaded GitHub releases to $OUTPUT_PATH."
|
|
exit 0
|
|
fi
|
|
|
|
github_release_installer
|
|
exit $?
|