#!/bin/sh # TermTree installer for macOS and Linux. # # curl -fsSL https://termtree.com/install.sh | sh # # Windows is not covered by this script — download the MSI from # https://termtree.com/download instead. # # --------------------------------------------------------------------------- # Artifacts are served from termtree.com by the `/download/termtree/**` Firebase # rewrite onto the updater-server (taskhub#659), which 302s to the CDN. The # Download *page* at /download is a static file and is unaffected — Firebase # rewrites only fire when no static file matches. # # Exercised on macOS 15.7.4 (universal tar.gz), Ubuntu 22.04 amd64 (.deb, as # root without sudo), Fedora 40 (.rpm), Alpine 3.20 (AppImage fallback), and # Ubuntu aarch64 (the unsupported-architecture guard). # --------------------------------------------------------------------------- set -eu BASE_URL="${TERMTREE_DOWNLOAD_BASE:-https://termtree.com/download/termtree}" DOWNLOAD_PAGE="https://termtree.com/download" # Where the macOS .app is installed. Empty means the default: /Applications, # falling back to ~/Applications when /Applications is not writable. Set it to # install somewhere else, or to exercise this branch without touching a real # install -- replacing /Applications/TermTree.app while it is running swaps the # bundle underneath a live process. INSTALL_DIR="${TERMTREE_INSTALL_DIR:-}" info() { printf '%s\n' "$*"; } warn() { printf '%s\n' "$*" >&2; } die() { printf 'error: %s\n' "$*" >&2; exit 1; } need() { command -v "$1" >/dev/null 2>&1 || die "\`$1\` is required but was not found." } # Resolve how to escalate for a package install, BEFORE anything is downloaded. # Hardcoding `sudo` fails on the systems that need it least: running as root in # a container, a minimal server, or a provisioning script, where sudo is often # not installed at all. escalate() { if [ "$(id -u)" -eq 0 ]; then SUDO="" elif command -v sudo >/dev/null 2>&1; then SUDO="sudo" else die "This install needs root and \`sudo\` was not found. Re-run as root, or install the package yourself from $DOWNLOAD_PAGE" fi } # Whether $1 starts with the ELF magic number. A truncated download or an HTML # error page served as 200 both fail this; a real AppImage passes. is_elf() { [ -s "$1" ] || return 1 magic="$(dd if="$1" bs=4 count=1 2>/dev/null | od -An -tx1 | tr -d ' \n')" [ "$magic" = "7f454c46" ] } # Decide where the .app goes, BEFORE anything is downloaded, so an unusable # destination costs nothing. Sets $target. resolve_macos_target() { if [ -n "$INSTALL_DIR" ]; then # An explicit choice is honoured or fails loudly -- silently redirecting it # would put the app somewhere the caller did not ask for. mkdir -p "$INSTALL_DIR" 2>/dev/null || die "Could not create install directory: $INSTALL_DIR" [ -w "$INSTALL_DIR" ] || die "Install directory is not writable: $INSTALL_DIR" target="$INSTALL_DIR/TermTree.app" else target="/Applications/TermTree.app" if [ ! -w /Applications ]; then target="$HOME/Applications/TermTree.app" mkdir -p "$HOME/Applications" 2>/dev/null || die "Could not create $HOME/Applications" warn "/Applications is not writable; installing to $HOME/Applications instead." fi fi } # Download $1 (url) to $2 (destination path). fetch() { url="$1" dest="$2" info "Downloading $url" if command -v curl >/dev/null 2>&1; then curl -fSL --proto '=https' --connect-timeout 30 --retry 3 \ --progress-bar -o "$dest" "$url" elif command -v wget >/dev/null 2>&1; then # BusyBox wget (Alpine, which also ships no curl) rejects --show-progress, # and prints a progress bar of its own when not silenced. Probe rather than # assume: GNU wget is silent without it. if wget --help 2>&1 | grep -q -- --show-progress; then wget -q --show-progress -O "$dest" "$url" else wget -O "$dest" "$url" fi else die "Either \`curl\` or \`wget\` is required." fi } WORKDIR="" cleanup() { [ -n "$WORKDIR" ] && rm -rf "$WORKDIR"; } trap cleanup EXIT INT TERM WORKDIR="$(mktemp -d)" os="$(uname -s)" arch="$(uname -m)" install_macos() { # One universal build covers Intel and Apple Silicon, so architecture is not # part of the URL. The tar.gz is used rather than the DMG because it installs # without mounting a disk image or requiring a GUI session. need tar resolve_macos_target archive="$WORKDIR/TermTree.tar.gz" fetch "$BASE_URL/Darwin/universal/tar.gz" "$archive" info "Extracting" tar -xzf "$archive" -C "$WORKDIR" app="$(find "$WORKDIR" -maxdepth 2 -name 'TermTree.app' -print -quit)" [ -n "$app" ] || die "TermTree.app was not found in the downloaded archive." if [ -e "$target" ]; then info "Replacing existing installation at $target" rm -rf "$target" fi mv "$app" "$target" # Only LaunchServices sets com.apple.quarantine, so an archive fetched by # curl or wget carries none and this is a no-op for the piped one-liner. It # matters when someone downloads the tarball in a browser and runs the script # against it, where the attribute would make first launch fail with a # damaged-app warning instead of the normal prompt. if command -v xattr >/dev/null 2>&1; then xattr -dr com.apple.quarantine "$target" 2>/dev/null || true fi info "" info "TermTree installed to $target" case "$target" in /Applications/*|"$HOME"/Applications/*) info "Launch it from Spotlight, or run: open -a TermTree" ;; *) info "Launch it with: open \"$target\"" ;; esac } install_linux() { case "$arch" in x86_64|amd64) ;; *) die "Linux builds are available for x86_64 only (this machine is $arch). See $DOWNLOAD_PAGE" ;; esac if command -v apt-get >/dev/null 2>&1 && command -v dpkg >/dev/null 2>&1; then escalate pkg="$WORKDIR/termtree.deb" fetch "$BASE_URL/Ubuntu/amd64/deb" "$pkg" info "Installing" $SUDO apt-get install -y "$pkg" info "" info "TermTree installed. Launch it from your application menu, or run: termtree" return fi if command -v dnf >/dev/null 2>&1; then escalate pkg="$WORKDIR/termtree.rpm" fetch "$BASE_URL/Ubuntu/amd64/rpm" "$pkg" info "Installing" $SUDO dnf install -y "$pkg" info "" info "TermTree installed. Launch it from your application menu, or run: termtree" return fi # No supported package manager — fall back to the AppImage, which needs no # root and no distro-specific packaging. [ -n "${HOME:-}" ] || die "HOME is not set; cannot choose an install directory." bindir="${XDG_BIN_HOME:-$HOME/.local/bin}" mkdir -p "$bindir" || die "Could not create $bindir" target="$bindir/termtree" # Stage, verify, then swap -- the same shape the macOS and package branches # use. Downloading onto $target directly meant a truncated transfer left the # user's working binary as an executable stub, and a proxy interstitial served # as 200 was chmod +x'd and reported as a successful install. staged="$WORKDIR/termtree.appimage" fetch "$BASE_URL/Ubuntu/amd64/appimage" "$staged" is_elf "$staged" || die "The downloaded file is not a Linux executable. A proxy or captive portal may have replaced it. Try again, or download from $DOWNLOAD_PAGE" chmod +x "$staged" mv "$staged" "$target" info "" info "TermTree installed to $target" case ":$PATH:" in *":$bindir:"*) info "Launch it with: termtree" ;; *) warn "$bindir is not on your PATH. Add it, or run TermTree with: $target" ;; esac info "" info "The AppImage needs FUSE. If it fails to start, install it:" info " Debian/Ubuntu: sudo apt install libfuse2" info " Fedora: sudo dnf install fuse" } case "$os" in Darwin) install_macos ;; Linux) install_linux ;; *) die "Unsupported platform: $os. Downloads for all platforms are at $DOWNLOAD_PAGE" ;; esac