#!/usr/bin/env bash # Install only the headless CLI. This script never starts Gimmer or opens an account. # Keep this file compatible with the Bash 3.2 shipped with macOS. set -euo pipefail export LC_ALL=C gimmer_fail() { printf 'Gimmer CLI installation failed: %s\n' "$*" >&2 exit 1 } gimmer_cleanup() { # Only remove the private directory created by this invocation, never its parent. if [ -n "${gimmer_work_dir:-}" ] && [ -d "$gimmer_work_dir" ] && [ ! -L "$gimmer_work_dir" ]; then rm -rf -- "$gimmer_work_dir" fi } gimmer_validate_path() { # Reject ambiguous paths and symlink components before creating directories. local candidate="$1" part cursor="" old_ifs="$IFS" case "$candidate" in /*) ;; *) gimmer_fail 'Use an absolute installation directory.' ;; esac case "$candidate" in *[$'\001'-$'\037'$'\177']*|*:*|*'//'*) gimmer_fail 'The installation path contains unsupported characters.' ;; esac IFS=/ read -r -a gimmer_path_parts <<< "$candidate" IFS="$old_ifs" for part in "${gimmer_path_parts[@]}"; do [ -n "$part" ] || continue case "$part" in .|..) gimmer_fail 'The installation path must not contain dot segments.' ;; esac cursor="$cursor/$part" [ ! -L "$cursor" ] || gimmer_fail 'The installation directory must not contain symlinks.' if [ -e "$cursor" ] && [ ! -d "$cursor" ]; then gimmer_fail 'A component of the installation directory is not a directory.' fi done [ -n "$cursor" ] || gimmer_fail 'Installing into the filesystem root is not supported.' } gimmer_detect_platform() { local system machine translated system=$(uname -s) machine=$(uname -m) case "$system:$machine" in Linux:x86_64|Linux:amd64) gimmer_platform=linux-amd64 gimmer_asset=gimmer-cli-linux gimmer_hash_tool=sha256sum ;; Darwin:arm64|Darwin:aarch64|Darwin:x86_64) # A shell running under Rosetta still needs the native Apple Silicon binary. translated=0 if [ "$machine" = x86_64 ] && command -v sysctl >/dev/null 2>&1; then translated=$(sysctl -in sysctl.proc_translated 2>/dev/null || printf '0') fi if [ "$machine" = x86_64 ] && [ "$translated" != 1 ]; then gimmer_platform=darwin-amd64 else gimmer_platform=darwin-arm64 fi gimmer_asset="gimmer-cli-${gimmer_platform}" gimmer_hash_tool=shasum ;; *) gimmer_fail "Unsupported platform: $system $machine. Available builds: Ubuntu/Linux x86_64, macOS Intel and Apple Silicon." ;; esac command -v "$gimmer_hash_tool" >/dev/null 2>&1 || gimmer_fail "Required command not found: $gimmer_hash_tool." } gimmer_validate_directory_access() { local permissions [ -O "$gimmer_install_dir" ] && [ -w "$gimmer_install_dir" ] || gimmer_fail 'The installation directory must be writable and owned by you.' # POSIX ls permission columns are portable across GNU/Linux and BSD/macOS. # Shared writable destinations would let another user replace a checked target. permissions=$(ls -ld -- "$gimmer_install_dir") [ "${permissions:5:1}" != w ] && [ "${permissions:8:1}" != w ] || gimmer_fail 'The installation directory must not be writable by other users.' } gimmer_download() { # Disable per-user curl configuration and prohibit plaintext redirect downgrades. curl --disable --fail --silent --show-error --location --proto '=https' --proto-redir '=https' \ --tlsv1.2 --connect-timeout 15 --max-time "$4" --retry 2 --max-filesize "$3" --output "$2" "$1" } gimmer_read_manifest() { local manifest="$1" line extra reconstructed [ "$(wc -c < "$manifest" | tr -d '[:space:]')" -le 1024 ] || gimmer_fail 'The release manifest is too large.' [ "$(wc -l < "$manifest" | tr -d '[:space:]')" = 1 ] || gimmer_fail 'The release manifest must contain exactly one line.' IFS= read -r line < "$manifest" || gimmer_fail 'The release manifest is incomplete.' IFS=$'\t' read -r gimmer_schema gimmer_tag gimmer_manifest_asset gimmer_sha gimmer_size extra <<< "$line" [ "$gimmer_schema" = gimmer-cli-v1 ] || gimmer_fail 'Unsupported release manifest format.' [[ "$gimmer_tag" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]] || gimmer_fail 'Invalid release version.' [ "${#gimmer_tag}" -le 40 ] || gimmer_fail 'Invalid release version.' [ "$gimmer_manifest_asset" = "$gimmer_asset" ] || gimmer_fail 'The release asset does not match this platform.' [[ "$gimmer_sha" =~ ^[0-9a-f]{64}$ ]] || gimmer_fail 'The release checksum is missing or invalid.' [[ "$gimmer_size" =~ ^[1-9][0-9]*$ ]] || gimmer_fail 'The release size is missing or invalid.' [ "${#gimmer_size}" -le 10 ] && [ "$gimmer_size" -le 1073741824 ] || gimmer_fail 'The release size is outside the supported range.' [ -z "$extra" ] || gimmer_fail 'The release manifest contains extra fields.' # Byte-for-byte comparison also rejects collapsed tabs, embedded NULs and trailing bytes. reconstructed="$gimmer_work_dir/manifest-checked" printf '%s\t%s\t%s\t%s\t%s\n' "$gimmer_schema" "$gimmer_tag" "$gimmer_asset" "$gimmer_sha" "$gimmer_size" > "$reconstructed" cmp -s "$manifest" "$reconstructed" || gimmer_fail 'The release manifest is malformed.' } gimmer_update_path() { local profile quoted_dir marker marker='# >>> Gimmer CLI PATH >>>' case "${SHELL:-}" in */zsh) profile="$HOME/.zshrc" ;; */bash|'') if [ "$(uname -s)" = Darwin ]; then profile="$HOME/.bash_profile" else profile="$HOME/.bashrc" fi ;; *) printf 'Your shell PATH was not changed automatically. Add the installation directory using your shell configuration.\n' return ;; esac if [ -L "$profile" ] || { [ -e "$profile" ] && { [ ! -f "$profile" ] || [ ! -O "$profile" ]; }; }; then printf 'PATH was not changed: %s is not a regular file owned by you.\n' "$profile" >&2 return fi printf -v quoted_dir '%q' "$gimmer_install_dir" if [ -f "$profile" ] && grep -Fqx "$marker" "$profile"; then if ! grep -Fqx "_gimmer_cli_bin=$quoted_dir" "$profile"; then printf 'An existing Gimmer CLI PATH entry was preserved in %s. Update it for the new directory manually.\n' "$profile" >&2 fi return fi # Append a small managed block without rewriting any existing shell configuration. if ! printf '\n%s\n_gimmer_cli_bin=%s\ncase ":$PATH:" in\n *":$_gimmer_cli_bin:"*) ;;\n *) export PATH="$_gimmer_cli_bin:$PATH" ;;\nesac\nunset _gimmer_cli_bin\n# <<< Gimmer CLI PATH <<<\n' "$marker" "$quoted_dir" >> "$profile"; then printf 'PATH could not be updated in %s; add the installation directory manually.\n' "$profile" >&2 return fi printf 'Added the CLI directory to %s. Open a new terminal to use gimmer-cli.\n' "$profile" } gimmer_main() { local modify_path=1 destination bytes actual_sha binary_url [ -n "${HOME:-}" ] || gimmer_fail 'HOME is not set.' gimmer_install_dir="$HOME/.local/bin" gimmer_work_dir="" while [ "$#" -gt 0 ]; do case "$1" in --install-dir) [ "$#" -ge 2 ] || gimmer_fail '--install-dir requires an absolute directory.' gimmer_install_dir="$2" shift 2 ;; --no-modify-path) modify_path=0; shift ;; --help|-h) printf 'Usage: bash cli.sh [--install-dir /absolute/path] [--no-modify-path]\nInstalls the verified headless CLI without starting services or accessing account data.\n' return ;; *) gimmer_fail "Unknown option: $1" ;; esac done gimmer_install_dir="${gimmer_install_dir%/}" gimmer_validate_path "$gimmer_install_dir" gimmer_detect_platform command -v curl >/dev/null 2>&1 || gimmer_fail 'curl is required. On Ubuntu, install it with: sudo apt-get install curl' mkdir -p -- "$gimmer_install_dir" || gimmer_fail 'Cannot create the installation directory. Choose a writable directory; sudo is not required.' gimmer_validate_directory_access destination="$gimmer_install_dir/gimmer-cli" if [ -L "$destination" ] || { [ -e "$destination" ] && [ ! -f "$destination" ]; }; then gimmer_fail 'Refusing to replace a symlink or non-regular file named gimmer-cli.' fi umask 077 gimmer_work_dir=$(mktemp -d "$gimmer_install_dir/.gimmer-cli-install.XXXXXXXX") || gimmer_fail 'Cannot create a private staging directory.' trap gimmer_cleanup EXIT trap 'exit 130' INT trap 'exit 143' TERM printf 'Downloading Gimmer CLI for %s...\n' "$gimmer_platform" gimmer_download "https://gimmer.com/download/cli/$gimmer_platform" "$gimmer_work_dir/manifest" 1024 30 || gimmer_fail 'Could not retrieve a verified release manifest. Please retry later.' gimmer_read_manifest "$gimmer_work_dir/manifest" binary_url="https://github.com/GimmerBot/Gimmer/releases/download/$gimmer_tag/$gimmer_asset" gimmer_download "$binary_url" "$gimmer_work_dir/gimmer-cli" "$gimmer_size" 600 || gimmer_fail 'The CLI download did not complete. The existing installation was kept.' bytes=$(wc -c < "$gimmer_work_dir/gimmer-cli" | tr -d '[:space:]') [ "$bytes" = "$gimmer_size" ] || gimmer_fail 'Downloaded size does not match the release. The existing installation was kept.' if [ "$gimmer_hash_tool" = shasum ]; then actual_sha=$(shasum -a 256 < "$gimmer_work_dir/gimmer-cli") else actual_sha=$(sha256sum < "$gimmer_work_dir/gimmer-cli") fi actual_sha="${actual_sha%% *}" [ "$actual_sha" = "$gimmer_sha" ] || gimmer_fail 'SHA-256 verification failed. The existing installation was kept.' chmod 755 "$gimmer_work_dir/gimmer-cli" # Stage on the destination filesystem so the final rename is atomic, even on upgrade. if [ -L "$destination" ] || { [ -e "$destination" ] && [ ! -f "$destination" ]; }; then gimmer_fail 'The installation target changed; no file was replaced.' fi mv -f -- "$gimmer_work_dir/gimmer-cli" "$destination" || gimmer_fail 'Could not replace the CLI. Stop any running CLI process and retry.' printf 'Installed Gimmer CLI %s at %s\n' "$gimmer_tag" "$destination" if [ "$modify_path" = 1 ]; then gimmer_update_path fi printf '\nThis installer did not start Gimmer or change your account.\n' printf 'Open a new terminal, or run this in the current terminal:\n export PATH=%q:"$PATH"\n' "$gimmer_install_dir" printf '\nThen run:\n gimmer-cli --help\n gimmer-cli server start\n\nKeep the server running and use a second terminal for Mobile pairing.\n' } # Invoke only after the entire function body has arrived, including when piped to Bash. gimmer_main "$@"