#!/usr/bin/env bash # Grademe CLI installer. Read before running: https://grademe.io/install.sh # Downloads one native executable. No sudo, npm or telemetry. # Alpine needs bash, curl, libgcc and libstdc++ (installed by you, not this script). # Adds the command directory to your shell profile when needed; prints each edit. # Native installs check for updates in the background, used on the next launch. # Re-running installs the current release; the previous version is kept on disk. set -euo pipefail umask 077 fail() { printf 'grademe: %s\n' "$*" >&2; exit 1; } main() ( local base_url install_dir data_dir platform arch version asset expected actual reported stage lock release_dir old_target channel command_name profile link_dir='' local accent='' bold='' reset='' mark='>' done_mark='OK' progress=0 pending=0 background="${COLORFGBG:-0}" if [[ -t 1 && "${TERM:-dumb}" != dumb && -z "${NO_COLOR+x}" ]]; then accent=$'\033[32m'; bold=$'\033[1m'; reset=$'\033[0m' # Shell output cannot consume CSS: match the CLI's semantic success accent # on true-color terminals, with the terminal's own green as a fallback. case "${COLORTERM:-}" in truecolor|24bit) case "${background##*;}" in 7|9|10|11|12|13|14|15) accent=$'\033[38;2;4;120;87m' ;; *) accent=$'\033[38;2;52;211;153m' ;; esac ;; esac case "${LC_ALL:-${LC_CTYPE:-${LANG:-}}}" in *UTF-8*|*utf8*) mark='›'; done_mark='✓' ;; esac if [[ "${GRADEME_REDUCED_MOTION:-0}" != 1 ]]; then progress=1; fi fi clear_step() { if [[ "$pending" = 1 ]]; then printf '\r\033[2K'; pending=0; fi; } step() { clear_step printf ' %s%s%s %s' "$accent" "$mark" "$reset" "$1" if [[ "$progress" = 1 ]]; then pending=1; else printf '\n'; fi } fail() { clear_step; printf 'grademe: %s\n' "$*" >&2; exit 1; } channel="${GRADEME_INSTALL_CHANNEL:-stable}" case "$channel" in stable) command_name=grademe ;; preview) command_name=grademe-preview ;; *) fail 'Use the stable or preview channel.' ;; esac base_url="${GRADEME_INSTALL_BASE_URL:-https://grademe.io/cli/releases}" if [[ "$channel" = preview && -z "${GRADEME_INSTALL_BASE_URL:-}" ]]; then base_url="$base_url/preview"; fi install_dir="${GRADEME_INSTALL_DIR:-$HOME/.local/bin}" data_dir="${GRADEME_INSTALL_DATA_DIR:-$HOME/.local/share/$command_name}" case "$base_url" in https://*) ;; http://*) [[ "$base_url" =~ ^http://(127\.0\.0\.1|localhost):[0-9]+(/[^\?\#]*)?$ ]] || fail 'HTTP is allowed only on loopback for local tests.' ;; *) fail 'The release URL must use HTTPS (HTTP is allowed only on loopback for local tests).' ;; esac case "$base_url:$install_dir:$data_dir" in *$'\n'*|*$'\r'*|*$'\t'*) fail 'Installation paths and URLs must not contain control characters.' ;; esac [[ "$install_dir" = /* && "$data_dir" = /* && "$data_dir" != / ]] || fail 'Use absolute installation paths.' for tool in curl gzip uname mktemp awk cmp; do command -v "$tool" >/dev/null || fail "Missing required tool: $tool"; done if command -v sha256sum >/dev/null; then hash() { sha256sum "$1" | awk '{print $1}'; } elif command -v shasum >/dev/null; then hash() { shasum -a 256 "$1" | awk '{print $1}'; } else fail 'Install shasum or sha256sum to verify downloads.'; fi case "$(uname -s)" in Darwin) platform=darwin ;; Linux) platform=linux ;; *) fail 'Supported systems: macOS and Linux.' ;; esac case "$(uname -m)" in arm64|aarch64) arch=arm64 ;; x86_64|amd64) arch=x64 ;; *) fail 'Supported architectures: arm64 and x86_64.' ;; esac # Detect musl without executing any downloaded content. # musl ldd may return nonzero for --version even while identifying itself. if [[ "$platform" = linux ]] && { [[ -f /etc/alpine-release ]] || { command -v ldd >/dev/null && [[ "$(ldd --version 2>&1 || true)" = *musl* ]]; }; }; then arch="$arch-musl"; fi asset="grademe-$platform-$arch.gz" mkdir -p "$install_dir" "$data_dir/versions" install_dir="$(cd "$install_dir" && pwd -P)" data_dir="$(cd "$data_dir" && pwd -P)" lock="$data_dir/.install-lock" mkdir "$lock" 2>/dev/null || fail "Another installer is running. If it stopped unexpectedly, remove $lock and retry." stage="$(mktemp -d "$data_dir/.install.XXXXXX")" || { rmdir "$lock"; fail 'Cannot create the staging directory.'; } # Both targets are private installer-created directories, never user paths. trap 'clear_step; rm -rf -- "$stage"; if [[ -n "$link_dir" ]]; then rm -rf -- "$link_dir"; fi; rmdir "$lock" 2>/dev/null || true' EXIT trap 'exit 130' INT trap 'exit 143' TERM download() { local protocols='=https' case "$base_url" in http://*) protocols='=http,https' ;; esac curl --fail --silent --show-error --location --proto "$protocols" --proto-redir "$protocols" \ --connect-timeout 15 --max-time 300 --retry 2 "$base_url/$1" -o "$2" 2> "$stage/download-error" || { clear_step; cat "$stage/download-error" >&2; return 1; } } printf '\n %s%s%s %sGrademe CLI%s\n' "$accent" "$mark" "$reset" "$bold" "$reset" printf ' %s / %s / %s\n\n' "$platform" "$arch" "$channel" step 'Finding the latest release...' download latest.txt "$stage/latest.txt" || fail 'No release is available at this address. Your existing installation was not changed.' version="$(cat "$stage/latest.txt")" version="${version%$'\r'}" [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[A-Za-z0-9.]+)?$ ]] || fail 'Invalid release version.' if [[ "$channel" = stable && "$version" = *-* ]]; then fail 'The stable channel cannot install a preview.'; fi download "$version/checksums.txt" "$stage/checksums.txt" expected="$(awk -v file="$asset" '$2 == file { print $1 }' "$stage/checksums.txt")" [[ "$expected" =~ ^[a-f0-9]{64}$ ]] || fail 'Missing or invalid release checksum.' step "Downloading v$version..." download "$version/$asset" "$stage/grademe.gz" step 'Verifying the download...' actual="$(hash "$stage/grademe.gz")" [[ "$actual" = "$expected" ]] || fail 'Checksum mismatch. Your existing installation was not changed.' gzip -dc "$stage/grademe.gz" > "$stage/grademe" chmod 755 "$stage/grademe" if ! reported="$("$stage/grademe" --version 2> "$stage/runtime-error")"; then if [[ "$arch" = *-musl ]] && grep -Eq 'Error loading shared library (libstdc\+\+\.so\.6|libgcc_s\.so\.1):' "$stage/runtime-error"; then fail 'Missing C++ runtime libraries. On Alpine, ask your administrator to run: apk add --no-cache libstdc++ libgcc. On other musl systems, install these libraries with your package manager, then retry. Your existing installation was not changed.' fi clear_step cat "$stage/runtime-error" >&2 fail 'The downloaded executable could not start. Your existing installation was not changed.' fi [[ "$reported" = "grademe $version" ]] || fail 'The downloaded executable reported an unexpected version. Your existing installation was not changed.' step 'Installing the command...' old_target='' if [[ -L "$install_dir/$command_name" ]]; then old_target="$(readlink "$install_dir/$command_name")" case "$old_target" in "$data_dir/versions/"*/grademe) ;; *) fail 'An unmanaged grademe command already exists; choose GRADEME_INSTALL_DIR.' ;; esac elif [[ -e "$install_dir/$command_name" ]]; then fail 'An unmanaged grademe command already exists; choose GRADEME_INSTALL_DIR.'; fi release_dir="$data_dir/versions/$version-${expected:0:12}" if [[ -e "$release_dir" ]]; then cmp -s "$stage/grademe" "$release_dir/grademe" || fail 'Existing release contents differ; refusing to overwrite them.' else mkdir "$release_dir" mv "$stage/grademe" "$release_dir/grademe" fi # The new symlink is created beside its destination for an atomic rename. json_string() { local value="$1"; value="${value//\\/\\\\}"; value="${value//\"/\\\"}"; printf '"%s"' "$value"; } printf '{"format":1,"baseUrl":%s,"installDir":%s,"commandName":%s,"target":"%s-%s"}\n' \ "$(json_string "$base_url")" "$(json_string "$install_dir")" "$(json_string "$command_name")" "$platform" "$arch" > "$stage/installation.json" mv -f "$stage/installation.json" "$data_dir/installation.json" if [[ -n "$old_target" && "$old_target" != "$release_dir/grademe" ]]; then printf '%s' "$old_target" > "$stage/previous.txt" mv -f "$stage/previous.txt" "$data_dir/previous.txt" fi link_dir="$(mktemp -d "$install_dir/.grademe-link.XXXXXX")" ln -s "$release_dir/grademe" "$link_dir/grademe" mv -f "$link_dir/grademe" "$install_dir/$command_name" rmdir "$link_dir" link_dir='' clear_step printf ' %s%s%s %sInstalled Grademe %s%s\n' "$accent" "$done_mark" "$reset" "$bold" "$version" "$reset" printf ' %s/%s\n\n' "$install_dir" "$command_name" if [[ -n "$old_target" && "$old_target" != "$release_dir/grademe" ]]; then printf 'Previous executable retained: %s\n' "$old_target"; fi case ":$PATH:" in *":$install_dir:"*) printf 'Run: %s\n' "$command_name" ;; *) profile='' if [[ "${GRADEME_INSTALL_NO_MODIFY_PATH:-0}" != 1 ]]; then case "${SHELL:-}" in */zsh) profile="${ZDOTDIR:-$HOME}/.zshrc" ;; */bash) profile="$HOME/.bashrc" ;; esac fi if [[ -n "$profile" ]]; then add_profile_path() { if ! grep -Fq "# Grademe CLI PATH: $install_dir" "$1" 2>/dev/null; then printf '\n# Grademe CLI PATH: %s\nexport PATH=%q:"$PATH"\n' "$install_dir" "$install_dir" >> "$1" printf 'Added the command directory to %s\n' "$1" fi } add_profile_path "$profile" if [[ "${SHELL:-}" = */bash ]]; then if [[ -f "$HOME/.bash_profile" ]]; then add_profile_path "$HOME/.bash_profile" elif [[ -f "$HOME/.bash_login" ]]; then add_profile_path "$HOME/.bash_login" else add_profile_path "$HOME/.profile"; fi fi fi printf '\nOpen a new terminal, or run this in your current bash/zsh session:\n' printf ' export PATH=%q:"$PATH"\n' "$install_dir" printf 'Then run: %s\n' "$command_name" ;; esac printf 'Automatic updates apply on the next launch. Disable: GRADEME_NO_AUTO_UPDATE=1\n' ) main "$@"