#!/usr/bin/env bash set -eo pipefail # Bash 3 (macOS) treats empty arrays as unset under nounset. : "${HOME:?HOME must be set}" REPO="Sebi/ssh-netbox-wrapper" BASE_URL="https://git.zb-server.de" BINARY="netssh" info() { printf ' %s\n' "$*"; } die() { printf 'error: %s\n' "$*" >&2; exit 1; } need() { command -v "$1" >/dev/null 2>&1 || die "'$1' is required but not installed"; } # Check the complete toolchain before any network request or filesystem change. MISSING=() for program in curl uname sed awk mktemp rm dirname readlink mkdir cat install mv cp chmod cmp; do if ! command -v "$program" >/dev/null 2>&1; then MISSING+=("$program"); fi done if command -v sha256sum >/dev/null 2>&1; then SHA256=(sha256sum) elif command -v shasum >/dev/null 2>&1; then SHA256=(shasum -a 256) else MISSING+=("sha256sum or shasum (SHA-256 verification)") fi if [[ ${#MISSING[@]} -gt 0 ]]; then printf 'error: required programs are missing:\n' >&2 printf ' - %s\n' "${MISSING[@]}" >&2 die "install these dependencies using your package manager, then run the installer again" fi # Re-running the one-liner updates the existing installation in its current path. if [[ -z "${INSTALL_DIR:-}" ]]; then EXISTING=$(command -v "$BINARY" || true) if [[ "$EXISTING" == /* && -f "$EXISTING" ]]; then INSTALL_DIR=$(dirname "$EXISTING") else INSTALL_DIR=/usr/local/bin fi fi [[ "$INSTALL_DIR" == /* ]] || INSTALL_DIR="$PWD/$INSTALL_DIR" # Check sudo only when the destination needs elevated permissions, also before # downloading. For a new directory, inspect its nearest existing parent. PRIVILEGE=() INSTALL_PARENT=$INSTALL_DIR while [[ ! -e "$INSTALL_PARENT" ]]; do INSTALL_PARENT=$(dirname "$INSTALL_PARENT"); done [[ -d "$INSTALL_PARENT" ]] || die "installation path is not a directory: $INSTALL_PARENT" if [[ ! -w "$INSTALL_PARENT" || ! -x "$INSTALL_PARENT" ]]; then need sudo PRIVILEGE=(sudo) fi case "$(uname -s)" in Linux) PLATFORM_OS=linux ;; Darwin) PLATFORM_OS=darwin ;; *) die "unsupported OS: $(uname -s)" ;; esac case "$(uname -m)" in x86_64|amd64) PLATFORM_ARCH=amd64 ;; aarch64|arm64) PLATFORM_ARCH=arm64 ;; *) die "unsupported architecture: $(uname -m)" ;; esac printf 'netssh install / update\n' info "platform : $PLATFORM_OS/$PLATFORM_ARCH" info "target : $INSTALL_DIR/$BINARY" API_URL="$BASE_URL/api/v1/repos/$REPO/releases/latest" RELEASE=$(curl -fsSL "$API_URL") || die "could not fetch latest release from $API_URL" TAG=$(printf '%s\n' "$RELEASE" | sed -n 's/.*"tag_name"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p') [[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+([.+-][a-zA-Z0-9.+-]+)?$ ]] || die "invalid or missing release tag" ASSET="${BINARY}_${PLATFORM_OS}_${PLATFORM_ARCH}" DOWNLOAD_URL="$BASE_URL/$REPO/releases/download/$TAG" info "version : $TAG" TMP=$(mktemp -d) STAGED="" cleanup() { rm -rf "$TMP" if [[ -n "$STAGED" ]]; then "${PRIVILEGE[@]}" rm -f "$STAGED"; fi } trap cleanup EXIT info "downloading $ASSET..." curl -fL --progress-bar -o "$TMP/$ASSET" "$DOWNLOAD_URL/$ASSET" curl -fsSL -o "$TMP/checksums.txt" "$DOWNLOAD_URL/checksums.txt" \ || die "could not fetch checksums; installation aborted" EXPECTED=$(awk -v asset="$ASSET" '$2 == asset || $2 == "*" asset {print tolower($1)}' "$TMP/checksums.txt") [[ "$EXPECTED" =~ ^[a-f0-9]{64}$ ]] || die "missing, duplicate, or invalid checksum; installation aborted" ACTUAL=$("${SHA256[@]}" "$TMP/$ASSET") ACTUAL=${ACTUAL%% *} [[ "$ACTUAL" == "$EXPECTED" ]] || die "checksum mismatch; installation aborted" info "checksum OK" # Only standalone commands and our managed blocks are removed. Unrelated content # and commands which merely mention netssh are retained. cat > "$TMP/clean-profile.awk" <<'AWK' function legacy(s) { if (s ~ /^([^ \t]*\/)?netssh[ \t]+shell-init[ \t]*(#.*)?$/) return 1 if (s ~ /^(source|\.)[ \t]+["']?[^;|&]*netssh\/aliases\.sh["']?([ \t]+2>\/dev\/null)?[ \t]*(#.*)?$/) return 1 if (s ~ /^eval[ \t]+["']?\$\(([^ \t]*\/)?netssh[ \t]+alias([ \t]+--shell[ \t]+(bash|zsh))?\)["']?[ \t]*(#.*)?$/) return 1 if (s ~ /^([^ \t]*\/)?netssh[ \t]+alias([ \t]+--shell[ \t]+fish)?[ \t]*\|[ \t]*source[ \t]*(#.*)?$/) return 1 if (s ~ /^source[ \t]+<\(([^ \t]*\/)?netssh[ \t]+alias\)[ \t]*(#.*)?$/) return 1 if (s ~ /^alias[ \t]+[A-Za-z0-9_.-]+(=|[ \t]+)["']netssh([ \t]+[^"']+)?["'][ \t]*(#.*)?$/) return 1 return 0 } { s = $0 sub(/^[ \t]+/, "", s); sub(/[ \t\r]+$/, "", s) if (s == "# >>> netssh >>>") { if (inside) bad = 1; inside = 1; next } if (s == "# <<< netssh <<<") { if (!inside) bad = 1; inside = 0; next } if (!inside && !legacy(s)) print $0 } END { if (inside || bad) exit 1 } AWK # POSIX-style single quoting also works for these paths in fish, except embedded # quotes, which fish escapes with a backslash instead of concatenated quotes. quote_sh() { printf "'%s'" "${1//\'/\'\\\'\'}"; } quote_fish() { local value=${1//\\/\\\\} value=${value//\'/\\\'} printf "'%s'" "$value" } resolve_profile() { local path=$1 target steps=0 while [[ -L "$path" ]]; do steps=$((steps + 1)) [[ "$steps" -le 40 ]] || die "symlink loop in $1" target=$(readlink "$path") if [[ "$target" == /* ]]; then path=$target; else path="$(dirname "$path")/$target"; fi done printf '%s\n' "$path" } PROFILE_PATHS=() PROFILE_TARGETS=() PROFILE_DATA=() SHELLS=() CURRENT_SHELL=${SHELL:-} CURRENT_SHELL=${CURRENT_SHELL##*/} CONFIG_DIR=${XDG_CONFIG_HOME:-$HOME/.config} if [[ "$PLATFORM_OS" == darwin ]]; then CACHE_DIR=${XDG_CACHE_HOME:-$HOME/Library/Caches} else CACHE_DIR=${XDG_CACHE_HOME:-$HOME/.cache} fi ZSH_DIR=${ZDOTDIR:-$HOME} plan_profile() { local path=$1 kind=$2 target data previous count if [[ ! -e "$path" && ! -L "$path" ]] && { [[ -z "$kind" ]] || [[ "$kind" != "$CURRENT_SHELL" ]]; }; then return; fi target=$(resolve_profile "$path") # A dotfile manager may point two profile names at the same file. for previous in "${PROFILE_TARGETS[@]}"; do [[ "$previous" != "$target" ]] || return 0; done data="$TMP/profile-${#PROFILE_PATHS[@]}" if [[ -e "$target" ]]; then awk -f "$TMP/clean-profile.awk" "$target" > "$data" \ || die "unclosed or nested netssh block in $path; profiles were not changed" else : > "$data" fi if [[ -n "$kind" ]]; then count=0 for previous in "${SHELLS[@]}"; do [[ "$previous" != "$kind" ]] || count=1; done if [[ "$count" == 0 ]]; then SHELLS+=("$kind"); fi { printf '# >>> netssh >>>\n' if [[ "$kind" == fish ]]; then printf 'set -gx PATH %s $PATH\n' "$(quote_fish "$INSTALL_DIR")" printf '%s shell-init\n' "$(quote_fish "$INSTALL_DIR/$BINARY")" else printf 'export PATH=%s:"$PATH"\n' "$(quote_sh "$INSTALL_DIR")" printf '%s shell-init\n' "$(quote_sh "$INSTALL_DIR/$BINARY")" printf '[ ! -f %s ] || . %s\n' "$(quote_sh "$CACHE_DIR/netssh/aliases.sh")" "$(quote_sh "$CACHE_DIR/netssh/aliases.sh")" fi printf '# <<< netssh <<<\n' } >> "$data" fi PROFILE_PATHS+=("$path"); PROFILE_TARGETS+=("$target"); PROFILE_DATA+=("$data") } # Preflight every profile before installing the binary or modifying any profile. plan_profile "$HOME/.bashrc" bash plan_profile "$HOME/.bash_profile" "" plan_profile "$HOME/.bash_login" "" plan_profile "$HOME/.profile" "" plan_profile "$ZSH_DIR/.zshrc" zsh plan_profile "$ZSH_DIR/.zprofile" "" plan_profile "$ZSH_DIR/.zshenv" "" if [[ "$ZSH_DIR" != "$HOME" ]]; then plan_profile "$HOME/.zshrc" "" plan_profile "$HOME/.zprofile" "" plan_profile "$HOME/.zshenv" "" fi plan_profile "$CONFIG_DIR/fish/config.fish" fish if [[ ${#PRIVILEGE[@]} -gt 0 ]]; then info "sudo required to write to $INSTALL_DIR" fi "${PRIVILEGE[@]}" mkdir -p "$INSTALL_DIR" # Stage on the destination filesystem to replace a running binary atomically. STAGED=$("${PRIVILEGE[@]}" mktemp "$INSTALL_DIR/.netssh.XXXXXX") "${PRIVILEGE[@]}" install -m 755 "$TMP/$ASSET" "$STAGED" "${PRIVILEGE[@]}" mv -f "$STAGED" "$INSTALL_DIR/$BINARY" STAGED="" # Regenerate aliases, including the configured command alias, as the calling user. # An empty cache clears obsolete generated aliases. Never run the wizard here. write_aliases() { local kind=$1 path=$2 staged "$INSTALL_DIR/$BINARY" alias --shell "$kind" > "$TMP/aliases-$kind" \ || die "could not regenerate $kind aliases; profiles were not changed" mkdir -p "$(dirname "$path")" staged=$(mktemp "$(dirname "$path")/.netssh.XXXXXX") cat "$TMP/aliases-$kind" > "$staged" chmod 600 "$staged" mv -f "$staged" "$path" } write_aliases bash "$CACHE_DIR/netssh/aliases.sh" for kind in "${SHELLS[@]}"; do if [[ "$kind" == fish ]]; then write_aliases fish "$CONFIG_DIR/fish/conf.d/netssh.fish"; fi done for ((index=0; index<${#PROFILE_PATHS[@]}; index++)); do target=${PROFILE_TARGETS[$index]} data=${PROFILE_DATA[$index]} if [[ -e "$target" ]] && cmp -s "$target" "$data"; then continue; fi mkdir -p "$(dirname "$target")" if [[ -e "$target" ]]; then backup=$(mktemp "$target.netssh-backup.XXXXXX") cp -p "$target" "$backup" info "backup : $backup" fi staged=$(mktemp "$(dirname "$target")/.netssh.XXXXXX") if [[ -e "$target" ]]; then cp -p "$target" "$staged"; else chmod 644 "$staged"; fi cat "$data" > "$staged" mv -f "$staged" "$target" info "updated : ${PROFILE_PATHS[$index]}" done for kind in "${SHELLS[@]}"; do if ! "$INSTALL_DIR/$BINARY" completion install --shell "$kind"; then info "warning: could not update $kind completion" fi done printf '\nnetssh %s installed / updated at %s/%s\n' "$TAG" "$INSTALL_DIR" "$BINARY" info "Open a new terminal to load the current hooks and aliases."