#!/bin/sh # shellcheck shell=dash set -eu # Pinner CLI installer # Usage: curl -fsSL https://get.pinner.xyz | sh # curl -fsSL https://get.pinner.xyz | sh -s -- --system # curl -fsSL https://get.pinner.xyz | sh -s -- --bin-dir /usr/local/bin PROGRAM_NAME="pinner" ARCHIVE_NAME="pinner-cli" REPO="LumeWeb/pinner-cli" BASE_URL="https://github.com/${REPO}/releases/download" VERSION_URL="https://get.pinner.xyz/version" # --- Shell compatibility guard ----------------------------------------------- if [ -n "${ZSH_VERSION+x}" ]; then echo "Error: Running with zsh is not supported. Please use sh." >&2 exit 1 elif [ -n "${BASH_VERSION+x}" ] && [ -z "${POSIXLY_CORRECT+x}" ]; then echo "Warning: Running with non-POSIX bash may cause issues. Please use sh." >&2 fi # --- Globals ----------------------------------------------------------------- INSTALL_DIR="" OPT_SYSTEM=0 OPT_ARCH="" OPT_BASE_URL="" OPT_UNINSTALL=0 OPT_DEBUG=0 OPT_NO_PKG=0 DETECTED_SHELL="" RC_FILE="" # Directory of this script (for CI local version file fallback) SCRIPT_DIR="$(cd "$(dirname "$0")" 2> /dev/null && pwd || echo .)" BREW_PACKAGE="lumeweb/tap/pinner" # CI mode env vars (read once at startup, like PINNER_VERSION/PINNER_INSTALL) PINNER_BREW_TAP="${PINNER_BREW_TAP:-}" PINNER_BREW_FORMULA="${PINNER_BREW_FORMULA:-$BREW_PACKAGE}" # --- Helper functions -------------------------------------------------------- info() { printf '\033[1;34m[info]\033[0m %s\n' "$1" } warn() { printf '\033[1;33m[warn]\033[0m %s\n' "$1" >&2 } error() { printf '\033[1;31m[error]\033[0m %s\n' "$1" >&2 } completed() { printf '\033[1;32m[ok]\033[0m %s\n' "$1" } need_cmd() { if ! check_cmd "$1"; then error "Required command '$1' not found. Please install it and try again." exit 1 fi } check_cmd() { command -v "$1" > /dev/null 2>&1 } ensure() { if ! "$@"; then error "Command failed: $*" exit 1 fi } ignore() { "$@" 2> /dev/null || true } # --- Download abstraction ---------------------------------------------------- curl_is_snap() { _curl_path="$(command -v curl 2> /dev/null || true)" case "${_curl_path}" in /snap/*) return 0 ;; *) return 1 ;; esac } curl_tls_flags() { if curl --proto =https --tlsv1.2 --help > /dev/null 2>&1; then printf '%s' "--proto =https --tlsv1.2" fi } download() { _url="$1" _file="$2" if check_cmd curl && ! curl_is_snap; then # shellcheck disable=SC2046 curl --fail --silent --location $(curl_tls_flags) --connect-timeout 30 --max-time 300 --output "$_file" "$_url" elif check_cmd wget; then wget --quiet --timeout=30 --output-document="$_file" "$_url" elif check_cmd fetch; then fetch --quiet --timeout=30 --output="$_file" "$_url" else error "No download tool found. Install curl, wget, or fetch." exit 1 fi } download_or_fail() { if ! download "$1" "$2"; then error "Download failed: $1" error "Check your network connection and that the version/architecture is correct." exit 1 fi } download_or_warn() { if ! download "$1" "$2"; then return 1 fi return 0 } # --- Platform detection ------------------------------------------------------ detect_platform() { _os="$(uname -s | tr '[:upper:]' '[:lower:]')" case "$_os" in linux) printf '%s' "linux" ;; darwin) printf '%s' "darwin" ;; msys_nt*|cygwin_nt*|mingw*) error "Windows is not supported. Use install.ps1 instead." exit 1 ;; *) error "Unsupported operating system: $_os" exit 1 ;; esac } detect_arch() { _arch="$(uname -m)" case "$_arch" in x86_64|x64|amd64) printf '%s' "amd64" ;; aarch64|arm64) printf '%s' "arm64" ;; *) error "Unsupported architecture: $_arch" exit 1 ;; esac } # Rosetta 2 detection: if running under x86_64 emulation on Apple Silicon detect_rosetta() { if [ "$(uname -s)" = "Darwin" ] && [ "$(uname -m)" = "x86_64" ]; then if (sysctl hw.optional.arm64 2> /dev/null || true) | grep -q ': 1'; then printf '%s' "arm64" return 0 fi fi printf '%s' "" } check_32bit() { _bits="$(getconf LONG_BIT 2> /dev/null || true)" if [ "$_bits" = "32" ]; then error "32-bit systems are not supported." exit 1 fi } # --- Version detection ------------------------------------------------------- fetch_url() { if check_cmd curl; then curl -fsSL "$1" 2> /dev/null || true elif check_cmd wget; then wget -qO- "$1" 2> /dev/null || true fi } clean_version() { printf '%s' "$1" | sed 's/^v//' | tr -d '[:space:]' } get_latest_version() { _ver="" # CI mode: read from local version file (avoids network dependency) if [ -n "${CI:-}" ] && [ -f "${SCRIPT_DIR:-.}/version" ]; then _ver="$(cat "${SCRIPT_DIR:-.}/version" 2> /dev/null || true)" fi # Primary: version endpoint if [ -z "$_ver" ]; then _ver="$(fetch_url "$VERSION_URL")" fi _ver="$(clean_version "$_ver")" # Fallback: GitHub API if [ -z "$_ver" ]; then _api_url="https://api.github.com/repos/${REPO}/releases/latest" _ver="$(fetch_url "$_api_url" | grep '"tag_name"' | head -n1 | sed 's/.*"tag_name"[[:space:]]*:[[:space:]]*"\(.*\)".*/\1/')" _ver="$(clean_version "$_ver")" fi if [ -z "$_ver" ]; then error "Could not determine the latest version." exit 1 fi printf '%s' "$_ver" } # --- SHA256 verification ----------------------------------------------------- compute_sha256() { _file="$1" if check_cmd sha256sum; then sha256sum "$_file" | cut -d' ' -f1 elif check_cmd shasum; then shasum -a 256 "$_file" | cut -d' ' -f1 else error "No SHA256 tool found. Install sha256sum or shasum." exit 1 fi } verify_checksum() { _archive="$1" _checksums="$2" _expected="$(grep "${ARCHIVE_NAME}_${VERSION}_${PLATFORM}_${ARCH}.tar.gz" "$_checksums" | cut -d' ' -f1)" if [ -z "$_expected" ]; then error "Could not find checksum for ${ARCHIVE_NAME}_${VERSION}_${PLATFORM}_${ARCH}.tar.gz" exit 1 fi _actual="$(compute_sha256 "$_archive")" if [ "$_expected" != "$_actual" ]; then error "SHA256 verification failed!" error " Expected: $_expected" error " Actual: $_actual" rm -f "$_archive" exit 1 fi } # --- Install directory ------------------------------------------------------- default_install_dir() { printf '%s' "${HOME}/.local/bin" } resolve_install_dir() { if [ -n "$INSTALL_DIR" ]; then printf '%s' "$INSTALL_DIR" elif [ -n "${PINNER_INSTALL:-}" ]; then printf '%s' "$PINNER_INSTALL" elif [ "$OPT_SYSTEM" = 1 ]; then printf '%s' "/usr/local/bin" else default_install_dir fi } test_writable() { _dir="$1" if [ ! -d "$_dir" ]; then return 1 fi _test_file="${_dir}/.pinner_install_test_$$" if touch "$_test_file" 2> /dev/null; then rm -f "$_test_file" return 0 else return 1 fi } elevate_priv() { if [ "$(id -u)" = 0 ]; then # Already root return 0 fi if check_cmd sudo && sudo -v 2> /dev/null; then return 0 fi error "Install directory is not writable and sudo is not available." error "Try: sh install.sh --bin-dir ~/bin or mkdir -p ~/.local/bin" exit 1 } # --- PATH configuration ------------------------------------------------------ detect_shell() { _shell="$(printf '%s' "${SHELL:-}" | sed 's|.*/||')" case "$_shell" in bash) printf '%s' "bash" ;; zsh) printf '%s' "zsh" ;; fish) printf '%s' "fish" ;; *) printf '%s' "bash" ;; esac } detect_rc_file() { _shell="$1" case "$_shell" in bash) printf '%s' "${HOME}/.bashrc" ;; zsh) printf '%s' "${HOME}/.zshrc" ;; fish) printf '%s' "${HOME}/.config/fish/config.fish" ;; *) printf '%s' "${HOME}/.bashrc" ;; esac } configure_path() { _dir="$1" _shell="$2" _rc="$3" # Check if already in PATH case ":${PATH}:" in *":${_dir}:"*) return 0 ;; esac # Check if already in RC file if [ -f "$_rc" ] && grep -q "$_dir" "$_rc" 2> /dev/null; then return 0 fi # Create RC file if needed if [ ! -f "$_rc" ]; then _rc_dir="$(dirname "$_rc")" mkdir -p "$_rc_dir" 2> /dev/null || true fi case "$_shell" in fish) _line="set -gx PATH $_dir \$PATH" ;; *) _line="export PATH=\"${_dir}:\$PATH\"" ;; esac printf '\n%s\n' "$_line" >> "$_rc" info "Added $_dir to PATH in $_rc" info "Run 'source $_rc' or start a new shell to update your PATH." } # --- Shell completions ------------------------------------------------------- install_completions() { _extract_dir="$1" _shell="$2" _completions_dir="${_extract_dir}/completions" if [ ! -d "$_completions_dir" ]; then warn "No completions directory found in archive." return 0 fi case "$_shell" in bash) _dest="${HOME}/.local/share/bash-completion/completions/${PROGRAM_NAME}" _src="${_completions_dir}/${PROGRAM_NAME}.bash" ;; zsh) _dest="${HOME}/.local/share/zsh/site-functions/_${PROGRAM_NAME}" _src="${_completions_dir}/${PROGRAM_NAME}.zsh" ;; fish) _dest="${HOME}/.local/share/fish/vendor_completions.d/${PROGRAM_NAME}.fish" _src="${_completions_dir}/${PROGRAM_NAME}.fish" ;; *) return 0 ;; esac if [ ! -f "$_src" ]; then warn "Completion file not found: $_src" return 0 fi _dest_dir="$(dirname "$_dest")" mkdir -p "$_dest_dir" 2> /dev/null || true if [ -w "$_dest_dir" ]; then cp "$_src" "$_dest" completed "Installed $_shell completions to $_dest" else warn "Cannot write to $_dest_dir. Skipping completions." fi } # --- Uninstall --------------------------------------------------------------- uninstall() { _dir="$1" _shell="$2" _rc="$3" _binary="${_dir}/${PROGRAM_NAME}" if [ -f "$_binary" ]; then rm -f "$_binary" info "Removed $_binary" else warn "$_binary not found." fi case "$_shell" in bash) rm -f "${HOME}/.local/share/bash-completion/completions/${PROGRAM_NAME}" ;; zsh) rm -f "${HOME}/.local/share/zsh/site-functions/_${PROGRAM_NAME}" ;; fish) rm -f "${HOME}/.local/share/fish/vendor_completions.d/${PROGRAM_NAME}.fish" ;; esac if [ -f "$_rc" ]; then _tmp_rc="$(mktemp)" grep -v "$_dir" "$_rc" > "$_tmp_rc" 2> /dev/null || true if ! cmp -s "$_rc" "$_tmp_rc"; then mv "$_tmp_rc" "$_rc" info "Removed PATH entry from $_rc" else rm -f "$_tmp_rc" fi fi completed "Pinner CLI has been uninstalled." exit 0 } # --- Flag parsing ------------------------------------------------------------ usage() { cat <&2 exit 1 ;; esac shift done } # --- Edge case detection ----------------------------------------------------- detect_wsl() { if [ -f /proc/version ]; then case "$(cat /proc/version 2> /dev/null)" in *microsoft*|*Microsoft*) info "Detected Windows Subsystem for Linux (WSL). Installing Linux binary." ;; esac fi } detect_root() { if [ "$(id -u)" = 0 ]; then warn "Running as root. This is unnecessary for default install to ~/.local/bin." fi } detect_existing() { _dir="$1" _existing="${_dir}/${PROGRAM_NAME}" if [ -x "$_existing" ]; then _current_ver="$("$_existing" --version 2> /dev/null | head -n1 | sed 's/^[^0-9]*\([0-9][0-9.]*\).*/\1/' || true)" if [ -n "$_current_ver" ]; then info "Upgrading from v$_current_ver to v${VERSION}" else info "Replacing existing installation in $_dir" fi fi } # Check if this is a first-time install (no config file exists yet) is_new_install() { _config_dir="${HOME}/.config/pinner" [ ! -f "${_config_dir}/config.yaml" ] } # Show post-install next-steps guidance show_next_steps() { if check_cmd "$PROGRAM_NAME"; then if is_new_install; then printf '\n' info "First time? Run 'pinner setup' to configure authentication and settings." else info "Run 'pinner --help' to get started." fi else printf '\n' info "Run 'source ${RC_FILE}' or start a new shell to use pinner." if is_new_install; then info "Then run 'pinner setup' for first-time configuration." fi fi } # --- Package manager install ------------------------------------------------- try_homebrew_install() { if [ "$(id -u)" = 0 ]; then return 1 fi if ! check_cmd brew; then return 1 fi info "Detected Homebrew. Installing via brew..." if [ -n "$PINNER_BREW_TAP" ] && [ -d "$PINNER_BREW_TAP" ]; then if ! brew tap lumeweb/tap "$PINNER_BREW_TAP"; then warn "brew tap (local) failed. Falling back to binary install." return 1 fi else if ! brew tap lumeweb/tap; then warn "brew tap failed. Falling back to binary install." return 1 fi fi if brew list "$PINNER_BREW_FORMULA" 2>/dev/null; then info "$PINNER_BREW_FORMULA is already installed via Homebrew." completed "Pinner CLI installed via Homebrew." show_next_steps return 0 fi if ! brew install "$PINNER_BREW_FORMULA"; then warn "brew install failed. Falling back to binary install." return 1 fi completed "Pinner CLI installed via Homebrew." show_next_steps return 0 } try_pkg_install() { _pm_cmd="$1" _ext="$2" _install_cmd="$3" _install_arg="$4" if ! check_cmd "$_pm_cmd"; then return 1 fi if [ "$(id -u)" != 0 ] && ! check_cmd sudo; then return 1 fi info "Detected $_pm_cmd. Installing .${_ext} package..." _pkg_name="${ARCHIVE_NAME}_${VERSION}_${PLATFORM}_${ARCH}.${_ext}" _pkg_url="${OPT_BASE_URL:-${BASE_URL}}/v${VERSION}/${_pkg_name}" _pkg_file="${_tmpdir}/${_pkg_name}" if ! download_or_warn "$_pkg_url" "$_pkg_file"; then warn "$_ext package download failed. Falling back to binary install." return 1 fi if [ "$(id -u)" = 0 ]; then if ! "$_install_cmd" "$_install_arg" "$_pkg_file" 2> /dev/null; then warn "$_pm_cmd install failed. Falling back to binary install." return 1 fi else if ! sudo "$_install_cmd" "$_install_arg" "$_pkg_file" 2> /dev/null; then warn "$_pm_cmd install failed. Falling back to binary install." return 1 fi fi completed "Pinner CLI installed via $_pm_cmd." show_next_steps return 0 } try_dpkg_install() { try_pkg_install dpkg deb dpkg "-i" } try_rpm_install() { try_pkg_install rpm rpm rpm "-i" } # --- Main -------------------------------------------------------------------- main() { parse_flags "$@" if [ "$OPT_DEBUG" = 1 ]; then set -x fi # Prerequisites need_cmd uname need_cmd tar need_cmd mktemp need_cmd chmod need_cmd rm need_cmd mkdir need_cmd cat # Detect platform and architecture PLATFORM="$(detect_platform)" check_32bit ARCH="${OPT_ARCH:-$(detect_arch)}" # Rosetta 2 override if [ -z "$OPT_ARCH" ]; then _rosetta="$(detect_rosetta)" if [ -n "$_rosetta" ]; then ARCH="$_rosetta" info "Detected Apple Silicon under Rosetta 2. Using arm64." fi fi # Validate arch case "$ARCH" in amd64|arm64) ;; *) error "Invalid architecture: $ARCH. Must be amd64 or arm64." exit 1 ;; esac # Resolve install directory _install_dir="$(resolve_install_dir)" DETECTED_SHELL="$(detect_shell)" RC_FILE="$(detect_rc_file "$DETECTED_SHELL")" # Handle uninstall if [ "$OPT_UNINSTALL" = 1 ]; then uninstall "$_install_dir" "$DETECTED_SHELL" "$RC_FILE" fi # Edge case detection detect_wsl detect_root # Version: PINNER_VERSION override > version endpoint/file fallback if [ -n "${PINNER_VERSION:-}" ]; then VERSION="$PINNER_VERSION" else VERSION="$(get_latest_version)" fi info "Installing Pinner CLI v${VERSION} for ${PLATFORM}/${ARCH}" # Create temp directory early (needed for package manager downloads) _tmpdir="$(mktemp -d)" trap 'rm -rf "$_tmpdir"' EXIT # Try package manager install if [ "$OPT_NO_PKG" = 0 ]; then if [ "$PLATFORM" = "darwin" ]; then if try_homebrew_install; then exit 0 fi elif [ "$PLATFORM" = "linux" ]; then if try_dpkg_install; then exit 0 fi if try_rpm_install; then exit 0 fi fi fi # Construct download URL _dl_base="${OPT_BASE_URL:-${BASE_URL}}" _archive_name="${ARCHIVE_NAME}_${VERSION}_${PLATFORM}_${ARCH}.tar.gz" _archive_url="${_dl_base}/v${VERSION}/${_archive_name}" _checksums_url="${_dl_base}/v${VERSION}/checksums.txt" _archive="${_tmpdir}/${_archive_name}" _checksums="${_tmpdir}/checksums.txt" info "Downloading ${_archive_name}..." download_or_fail "$_archive_url" "$_archive" if [ ! -f "$_archive" ]; then error "Download failed. File not found: $_archive" error "Check that version v${VERSION} exists for ${PLATFORM}/${ARCH}." exit 1 fi info "Downloading checksums..." download_or_fail "$_checksums_url" "$_checksums" # Verify SHA256 info "Verifying SHA256 checksum..." verify_checksum "$_archive" "$_checksums" completed "Checksum verified." # Extract info "Extracting..." tar -xzf "$_archive" -C "$_tmpdir" # Find the binary _binary="${_tmpdir}/${PROGRAM_NAME}" if [ ! -f "$_binary" ]; then # Try with strip-components in case of nested directory _binary="$(find "$_tmpdir" -name "$PROGRAM_NAME" -type f 2> /dev/null | head -n1)" fi if [ ! -f "$_binary" ]; then error "Could not find '${PROGRAM_NAME}' binary in archive." exit 1 fi # Make executable if ! chmod +x "$_binary" 2> /dev/null; then error "Cannot make binary executable. The filesystem may be mounted noexec." error "Try: sh install.sh --bin-dir ~/bin" exit 1 fi # Ensure install directory exists if [ ! -d "$_install_dir" ]; then if [ "$(id -u)" = 0 ]; then mkdir -p "$_install_dir" elif test_writable "$(dirname "$_install_dir")"; then mkdir -p "$_install_dir" else elevate_priv sudo mkdir -p "$_install_dir" fi fi # Detect existing installation detect_existing "$_install_dir" # Install binary if [ "$(id -u)" = 0 ]; then mv "$_binary" "${_install_dir}/${PROGRAM_NAME}" elif test_writable "$_install_dir"; then mv "$_binary" "${_install_dir}/${PROGRAM_NAME}" else elevate_priv sudo mv "$_binary" "${_install_dir}/${PROGRAM_NAME}" fi completed "Installed ${PROGRAM_NAME} to ${_install_dir}/${PROGRAM_NAME}" # Install completions install_completions "$_tmpdir" "$DETECTED_SHELL" # Configure PATH configure_path "$_install_dir" "$DETECTED_SHELL" "$RC_FILE" # Success printf '\n' completed "Pinner CLI v${VERSION} installed successfully!" show_next_steps } main "$@"