#!/bin/sh fail() { printf 'terra installer: %s\n' "$*" >&2; exit 1; } say() { printf '%s\n' "$*" >&2; } quote() { printf "'%s'" "$(printf '%s' "$1" | sed "s/'/'\\\\''/g")"; } sha256() { if command -v sha256sum >/dev/null 2>&1; then sha256sum < "$1" | awk '{print $1}' else shasum -a 256 < "$1" | awk '{print $1}' fi } process_start() { if [ -r "/proc/$1/stat" ]; then awk '{ sub(/.*\) /, ""); print $20 }' "/proc/$1/stat" 2>/dev/null else ps -o lstart= -p "$1" 2>/dev/null | awk 'NR == 1 { sub(/^ +/, ""); sub(/ +$/, ""); print }' fi } lock_abandoned() { owner=$(sed -n '1p' "$1/pid" 2>/dev/null) || owner= case "$owner" in ''|*[!0-9]*) [ -n "$(find "$1" -maxdepth 0 -mmin +60 2>/dev/null)" ]; return ;; esac kill -0 "$owner" 2>/dev/null || return 0 recorded=$(sed -n '2p' "$1/pid" 2>/dev/null) current=$(process_start "$owner") [ -n "$recorded" ] && [ -n "$current" ] && [ "$current" != "$recorded" ] } owned() { [ -f "$bin_dir/.terra-install" ] && [ ! -L "$bin_dir/.terra-install" ] && [ -f "$bin_dir/terra" ] && [ ! -L "$bin_dir/terra" ] && [ "$(sed -n '1p' "$bin_dir/.terra-install")" = terra-standalone-v1 ] && [ "$(sed -n '3p' "$bin_dir/.terra-install")" = "$(sha256 "$bin_dir/terra")" ] } path_block() ( profile=$1 begin='# >>> Terra CLI PATH >>>' end='# <<< Terra CLI PATH <<<' # Replacing a symlink would disconnect a dotfile manager's source file. [ ! -L "$profile" ] || { say "Leaving symlinked profile $profile unchanged."; return 1; } mkdir -p "$(dirname "$profile")" || return 1 profile_tmp=$(mktemp "$profile.terra.XXXXXXXX") || return 1 trap 'rm -f "$profile_tmp"' 0 trap 'exit 130' INT trap 'exit 143' TERM if [ -e "$profile" ]; then [ -f "$profile" ] && [ -w "$profile" ] || return 1 cp -p "$profile" "$profile_tmp" || return 1 awk -v begin="$begin" -v end="$end" ' $0 == begin { if (inside) exit 1; inside=1; next } $0 == end { if (!inside) exit 1; inside=0; next } !inside { print } END { if (inside) exit 1 } ' "$profile" > "$profile_tmp" || return 1 fi printf '\n%s\n%s\n%s\n' "$begin" "$path_line" "$end" >> "$profile_tmp" || return 1 mv -f "$profile_tmp" "$profile" || return 1 say "PATH configured in $profile" ) configure_path() { quoted_dir=$(quote "$bin_dir") path_line="case \$PATH: in $quoted_dir:*) ;; *) export PATH=$quoted_dir:\$PATH ;; esac" shell=${SHELL:-} case "${shell##*/}" in bash) path_block "$HOME/.bashrc" || return 1 # Bash reads only the first existing login profile. for login_profile in .bash_profile .bash_login .profile; do if [ -f "$HOME/$login_profile" ]; then path_block "$HOME/$login_profile" return $? fi done path_block "$HOME/.profile" ;; zsh) path_block "${ZDOTDIR:-$HOME}/.zshrc" ;; fish) fish_dir=$(printf '%s' "$bin_dir" | sed "s/\\\\/\\\\\\\\/g; s/'/\\\\'/g") path_line="fish_add_path --move --path '$fish_dir'" path_block "${XDG_CONFIG_HOME:-$HOME/.config}/fish/conf.d/terra.fish" ;; *) say "Unsupported shell; add $bin_dir to PATH manually."; return 1 ;; esac } cleanup() { status=$? if [ "$committing" = true ]; then rollback_failed=false for target in terra .terra-install terra-LICENSE; do if [ -e "$tmp_dir/old-$target" ] || [ -L "$tmp_dir/old-$target" ]; then mv -f "$tmp_dir/old-$target" "$bin_dir/$target" || rollback_failed=true else rm -f "$bin_dir/$target" || rollback_failed=true fi done if [ "$rollback_failed" = true ]; then say "Could not restore the previous installation; backups remain in $tmp_dir" exit 1 fi fi [ -z "$tmp_dir" ] || rm -rf "$tmp_dir" rm -f "$lock_dir/pid" rmdir "$lock_dir" || exit 1 exit "$status" } main() { set -eu umask 077 version='0.24.0' bin_dir=${TERRA_INSTALL_DIR:-${HOME:?HOME must be set}/.local/bin} modify_path=true [ "${TERRA_NO_MODIFY_PATH:-0}" != 1 ] || modify_path=false force=false while [ "$#" -gt 0 ]; do case "$1" in --help) cat <<'HELP' Install Terra on macOS or Linux without a package manager. Usage: install.sh [--version X.Y.Z] [--install-dir PATH] [--no-modify-path] [--force] [--help] Defaults: this installer's release, ~/.local/bin, shell PATH setup enabled. TERRA_INSTALL_DIR overrides the destination; TERRA_NO_MODIFY_PATH=1 skips setup. --force permits another installation alongside this one or replaces the destination. HELP return ;; --version|--install-dir) if [ "$#" -lt 2 ] || [ -z "$2" ]; then fail "$1 requires a value" fi case "$1" in --version) version=${2#v} ;; --install-dir) bin_dir=$2 ;; esac shift 2 ;; --no-modify-path) modify_path=false; shift ;; --force) force=true; shift ;; *) fail "unknown option: $1 (use --help)" ;; esac done case "$version" in *[!0-9.]*|'') fail 'expected a stable version X.Y.Z' ;; esac printf '%s\n' "$version" | grep -Eq '^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$' || fail 'expected a stable version X.Y.Z' case "$bin_dir" in /*) ;; *) fail '--install-dir must be an absolute path' ;; esac case "$bin_dir" in *' '*|*':'*) fail 'installation directory cannot contain a newline or colon' ;; esac for tool in curl tar gzip awk sed grep mktemp uname chmod mkdir mv cp cat dirname rm rmdir; do command -v "$tool" >/dev/null 2>&1 || fail "$tool is required" done command -v sha256sum >/dev/null 2>&1 || command -v shasum >/dev/null 2>&1 || fail 'sha256sum or shasum is required' case "$(uname -s)" in Darwin) os=mac-os; checksums=terra-mac-checksums.txt ;; Linux) os=linux; checksums=terra-linux-checksums.txt ;; *) fail 'supported platforms: macOS and Linux; use npm on Windows' ;; esac case "$(uname -m)" in x86_64|amd64) arch=x86_64 ;; arm64|aarch64) arch=arm64 ;; *) fail 'supported architectures: x86-64 and ARM64' ;; esac mkdir -p "$bin_dir" || fail "cannot create $bin_dir" bin_dir=$(cd "$bin_dir" && pwd -P) lock_dir="$bin_dir/.terra-install.lock" tmp_dir= committing=false # mkdir is atomic on both macOS and Linux, where flock is not always available. if ! mkdir "$lock_dir" 2>/dev/null; then lock_abandoned "$lock_dir" || fail "Another installer holds $lock_dir. Retry when it finishes. If it was killed, remove the lock directory only after confirming no installer is running." stale_dir="$lock_dir.stale.$$" ( recovery_dir="$lock_dir.recover" mkdir "$recovery_dir" || exit 1 trap 'rmdir "$recovery_dir"' 0 lock_abandoned "$lock_dir" && mv "$lock_dir" "$stale_dir" && rm -rf "$stale_dir" ) 2>/dev/null || fail "Another installer holds $lock_dir. Retry when it finishes. If it was killed, remove $lock_dir.recover only after confirming no installer is running." mkdir "$lock_dir" 2>/dev/null || fail "Another installer holds $lock_dir. Retry when it finishes." fi trap cleanup 0 trap 'exit 130' INT trap 'exit 143' TERM printf '%s\n%s\n' "$$" "$(process_start "$$")" > "$lock_dir/pid" || fail 'cannot record the lock owner' existing=$(command -v terra || true) if [ "$force" = false ]; then if [ -n "$existing" ] && [ "$existing" != "$bin_dir/terra" ]; then fail "Terra already exists at $existing. Update it with its package manager, remove it, or use --force to install alongside it." fi if [ -e "$bin_dir/terra" ] || [ -L "$bin_dir/terra" ]; then owned || fail "Unrecognized installation at $bin_dir/terra. Use --force to replace it." fi fi for target in terra .terra-install terra-LICENSE; do [ ! -d "$bin_dir/$target" ] || fail "$bin_dir/$target is a directory" done # Staging beside the destination keeps the final rename on one filesystem. tmp_dir=$(mktemp -d "$bin_dir/.terra-install.XXXXXXXX") || fail 'cannot create staging directory' archive="terra_${version}_${os}_${arch}.tar.gz" base="https://github.com/tryterra/terra-cli/releases/download/v$version" say "Downloading Terra $version for $os/$arch..." # Retries tolerate transient CDN failures; time bounds keep unattended installs finite. for file in "$archive" "$checksums"; do curl --disable --fail --silent --show-error --location --proto '=https' --proto-redir '=https' \ --connect-timeout 15 --max-time 180 --retry 3 --retry-max-time 300 \ "$base/$file" --output "$tmp_dir/$file" || fail "could not download $file" done expected=$(awk -v name="$archive" '$2 == name { n++; digest=$1 } END { if (n != 1) exit 1; print digest }' "$tmp_dir/$checksums") || fail 'missing or duplicate archive checksum' [ "${#expected}" -eq 64 ] || fail 'malformed archive checksum' case "$expected" in *[!0-9a-f]*) fail 'malformed archive checksum' ;; esac [ "$(sha256 "$tmp_dir/$archive")" = "$expected" ] || fail 'archive checksum mismatch' tar -xzf "$tmp_dir/$archive" -C "$tmp_dir" terra LICENSE || fail 'archive is missing terra or LICENSE' if [ ! -f "$tmp_dir/terra" ] || [ -L "$tmp_dir/terra" ]; then fail 'archive executable is not a regular file' fi if [ ! -f "$tmp_dir/LICENSE" ] || [ -L "$tmp_dir/LICENSE" ]; then fail 'archive license is not a regular file' fi chmod 755 "$tmp_dir/terra" TERRA_CLI_TELEMETRY_OPTOUT=1 TERRA_NO_UPDATE_NOTIFIER=1 "$tmp_dir/terra" version >/dev/null || fail 'downloaded executable cannot run on this machine' printf 'terra-standalone-v1\n%s\n%s\n' "$version" "$(sha256 "$tmp_dir/terra")" > "$tmp_dir/receipt" chmod 644 "$tmp_dir/receipt" "$tmp_dir/LICENSE" for target in terra .terra-install terra-LICENSE; do if [ -e "$bin_dir/$target" ] || [ -L "$bin_dir/$target" ]; then cp -pP "$bin_dir/$target" "$tmp_dir/old-$target" fi done committing=true mv -f "$tmp_dir/LICENSE" "$bin_dir/terra-LICENSE" mv -f "$tmp_dir/receipt" "$bin_dir/.terra-install" mv -f "$tmp_dir/terra" "$bin_dir/terra" committing=false say "Installed Terra $version at $bin_dir/terra" if [ "$(command -v terra || true)" = "$bin_dir/terra" ]; then say 'Then run: terra login' return fi path_configured=false if [ "$modify_path" = true ]; then if configure_path; then path_configured=true else say 'Installation succeeded, but PATH needs manual setup.' fi fi case "${SHELL:-}" in */fish) fish_dir=$(printf '%s' "$bin_dir" | sed "s/\\\\/\\\\\\\\/g; s/'/\\\\'/g") say "For this terminal: fish_add_path --move --path '$fish_dir'" ;; *) say "For this terminal: export PATH=$(quote "$bin_dir"):\$PATH" ;; esac [ "$path_configured" = false ] || say 'Or open a new terminal.' [ -z "$existing" ] || [ "$existing" = "$bin_dir/terra" ] || say "Another installation at $existing may take precedence until PATH is updated." say 'Then run: terra login' } # The complete function must arrive before a piped download can change the machine. main "$@"