#!/bin/sh # Install a verified release without changing Go, Git checkouts or shell profiles set -eu # Website builds fill this value from SITE_URL while source installs may override it DEFAULT_SITE_URL='https://next.termbackti.me' SITE_URL=${TERMBACKTIME_SITE_URL:-${SITE_URL:-$DEFAULT_SITE_URL}} version=latest bin_dir=${HOME:?HOME must be set}/.local/bin work= staged= usage() { printf '%s\n' \ 'Usage: install.sh [--version vX.Y.Z] [--bin-dir DIRECTORY]' \ '' \ 'Installs the latest stable release by default' \ 'For source installation: go install github.com/termbacktime/termbacktime@latest' } fail() { printf 'termbacktime: %s\n' "$*" >&2 exit 1 } # Remove only temporary files created by this invocation cleanup() { if [ -n "$work" ]; then rm -rf "$work" fi if [ -n "$staged" ]; then rm -f "$staged" fi } # Keep explicit release selection separate from the removed Go installer parse_arguments() { while [ "$#" -gt 0 ]; do case "$1" in --help | -h) usage exit 0 ;; --version | --bin-dir) [ "$#" -ge 2 ] || fail "Missing value for $1" case "$1" in --version) version=$2 ;; --bin-dir) bin_dir=$2 ;; esac shift 2 ;; *) usage >&2 fail "Unknown argument: $1. Positional Go versions are no longer supported." ;; esac done [ -n "$bin_dir" ] || fail 'Installation directory cannot be empty' case "$bin_dir" in /*) ;; *) bin_dir=$PWD/$bin_dir ;; esac } # Choose a binary that matches native hardware and userspace bitness detect_platform() { os=$(uname -s) machine=$(uname -m) case "$os" in Darwin) platform=darwin ;; Linux) platform=linux ;; FreeBSD) platform=freebsd ;; *) fail "Unsupported OS $os. Try: go install github.com/termbacktime/termbacktime@latest" ;; esac case "$machine" in x86_64 | amd64) arch=amd64 ;; aarch64 | arm64 | ARM64) arch=arm64 ;; i386 | i486 | i586 | i686) arch=386 ;; armv7*) arch=armv7 ;; armv6*) arch=armv6 ;; *) fail "Unsupported architecture $machine. Try: go install github.com/termbacktime/termbacktime@latest" ;; esac # Prefer native Apple Silicon binaries even when the installer runs under Rosetta if [ "$platform" = darwin ] && [ "$(sysctl -n hw.optional.arm64 2> /dev/null || true)" = 1 ]; then arch=arm64 fi # A 64-bit kernel can host a 32-bit userspace if [ "$platform" = linux ] && [ "$(getconf LONG_BIT 2> /dev/null || true)" = 32 ]; then case "$arch" in amd64) arch=386 ;; arm64) arch=armv7 ;; esac fi case "$platform-$arch" in darwin-amd64 | darwin-arm64 | linux-amd64 | linux-386 | linux-arm64 | linux-armv6 | linux-armv7 | freebsd-amd64 | freebsd-386) ;; *) fail "No binary for $platform-$arch. Try: go install github.com/termbacktime/termbacktime@latest" ;; esac } # Require HTTPS downloads and a supported SHA-256 implementation select_download_tools() { if command -v curl > /dev/null 2>&1; then fetch() { curl --fail --silent --show-error --location \ --proto '=https' --proto-redir '=https' \ --connect-timeout 15 --max-time 180 --retry 2 \ --output "$2" "$1" } elif command -v wget > /dev/null 2>&1; then fetch() { wget --https-only --timeout=30 --tries=3 -q -O "$2" "$1" } else fail 'Install curl or wget first' fi if command -v sha256sum > /dev/null 2>&1; then checksum() { sha256sum "$1" | awk '{print $1}' } elif command -v shasum > /dev/null 2>&1; then checksum() { shasum -a 256 "$1" | awk '{print $1}' } elif command -v sha256 > /dev/null 2>&1; then checksum() { sha256 -q "$1" } else fail 'A SHA-256 utility (sha256sum, shasum or sha256) is required' fi for utility in tar awk grep mktemp mkdir chmod mv cat rm; do command -v "$utility" > /dev/null 2>&1 || fail "Required utility is missing: $utility" done } # Resolve the stable pointer once so all downloads use the same release resolve_version() { if [ "$version" = latest ]; then if [ -n "$SITE_URL" ]; then case "$SITE_URL" in https://*) ;; *) fail 'SITE_URL must use HTTPS for installer downloads' ;; esac fetch "${SITE_URL%/}/_version" "$work/version" || fail 'Cannot resolve latest stable release' version=$(cat "$work/version") else fetch https://api.github.com/repos/termbacktime/termbacktime/releases/latest "$work/release.json" || fail 'Cannot resolve latest stable release' version=$(awk -F '\"' '/"tag_name"[[:space:]]*:/ {print $4}' "$work/release.json") fi case "$version" in *-*) fail 'Latest release must be stable' ;; esac fi printf '%s\n' "$version" | LC_ALL=C grep -Eq '^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z][0-9A-Za-z.-]*)?$' || fail 'Invalid release version' } # Download the archive and its checksum manifest from the exact release download_release() { archive=termbacktime_${version}_${platform}_${arch}.tar.gz base=https://github.com/termbacktime/termbacktime/releases/download/$version fetch "$base/$archive" "$work/release.tar.gz" || fail 'Release download failed' fetch "$base/SHA256SUMS" "$work/SHA256SUMS" || fail 'Checksum download failed' } # Verify the archive before extracting or executing any downloaded content verify_release() { expected=$(awk -v file="$archive" '$2 == file {print $1}' "$work/SHA256SUMS") printf '%s\n' "$expected" | LC_ALL=C grep -Eq '^[0-9a-f]{64}$' || fail 'Missing or ambiguous release checksum' [ "$(checksum "$work/release.tar.gz")" = "$expected" ] || fail 'Checksum mismatch; existing installation preserved' # Release archives contain exactly one regular executable, never links or paths [ "$(tar -tzf "$work/release.tar.gz")" = termbacktime ] || fail 'Unexpected archive contents' case "$(tar -tvzf "$work/release.tar.gz")" in -*) ;; *) fail 'Archive binary must be a regular file' ;; esac tar -xzf "$work/release.tar.gz" -C "$work" termbacktime [ -f "$work/termbacktime" ] && [ ! -L "$work/termbacktime" ] || fail 'Invalid binary' chmod 755 "$work/termbacktime" reported=$("$work/termbacktime" --version) || fail 'Downloaded binary cannot run on this system' case "$reported" in "termbacktime $version revision="*) ;; *) fail 'Binary version does not match release' ;; esac } # Stage on the destination filesystem so replacement is atomic install_binary() { [ ! -L "$bin_dir" ] || fail 'Installation directory must not be a symlink' mkdir -p "$bin_dir" destination=$bin_dir/termbacktime [ ! -L "$destination" ] || fail 'Refusing to replace a symlink' if [ -e "$destination" ] && [ ! -f "$destination" ]; then fail 'Destination is not a regular file' fi staged=$(mktemp "$bin_dir/.termbacktime.XXXXXXXX") cat "$work/termbacktime" > "$staged" chmod 755 "$staged" mv -f "$staged" "$destination" staged= } # Explain PATH changes without rewriting user shell profiles report_installation() { printf 'Installed %s to %s\n' "$version" "$destination" case ":$PATH:" in *":$bin_dir:"*) ;; *) printf 'Add this directory to PATH: %s\n' "$bin_dir" ;; esac active=$(command -v termbacktime || true) if [ -n "$active" ] && [ "$active" != "$destination" ]; then printf 'Another installation takes precedence on PATH: %s\n' "$active" fi } main() { parse_arguments "$@" detect_platform select_download_tools work=$(mktemp -d "${TMPDIR:-/tmp}/termbacktime.XXXXXXXX") trap cleanup EXIT trap 'exit 130' INT trap 'exit 143' TERM HUP resolve_version download_release verify_release install_binary report_installation } main "$@"