#!/usr/bin/env bash # # Installs / uninstalls qbeast-shell. # # curl -fsSL https://run.qbeast.cloud | bash # # Tack on "-s -- --uninstall" to remove it again. Run with --help for the flags. # # Env vars, same effect as the flags: # QBEAST_SHELL_VERSION, QBEAST_SHELL_PREFIX, QBEAST_SHELL_BIN_DIR # QBEAST_SHELL_BASE_URL point at a staging distribution or a local server set -euo pipefail BASE_URL="${QBEAST_SHELL_BASE_URL:-https://run.qbeast.cloud}" # Bumped at publish time. This script is republished and invalidated on every # release, so it doubles as the "latest" pointer. VERSION="${QBEAST_SHELL_VERSION:-0.2.0}" PREFIX="${QBEAST_SHELL_PREFIX:-$HOME/.local/share/qbeast-shell}" BIN_DIR="${QBEAST_SHELL_BIN_DIR:-$HOME/.local/bin}" MIN_JAVA=21 RECEIPT_NAME="install-receipt.json" # cached tokens and repl history. only --purge touches these STATE_PATHS=("$HOME/.qbeast" "$HOME/.spark_mcp_history") MODE="install" PURGE=0 ASSUME_YES="${QBEAST_SHELL_ASSUME_YES:-0}" LINK_BIN=1 FOREIGN_BIN="" log() { printf '==> %s\n' "$*" >&2; } warn() { printf 'warning: %s\n' "$*" >&2; } die() { printf 'error: %s\n' "$*" >&2; exit 1; } # when we're piped in from curl, stdin is the script itself, so prompts have to # go through /dev/tty. no tty means nobody is there to answer. tty_readable() { [ -c /dev/tty ] && { : < /dev/tty; } 2>/dev/null } confirm() { local prompt="$1" local reply="" if [ "$ASSUME_YES" -eq 1 ]; then return 0 fi if ! tty_readable; then warn "no terminal to ask on. re-run with --yes if you're sure." return 1 fi printf '%s [y/N] ' "$prompt" > /dev/tty read -r reply < /dev/tty || reply="" case "$reply" in [yY]|[yY][eE][sS]) return 0 ;; *) return 1 ;; esac } usage() { cat >&2 <<'USAGE' Usage: install.sh [options] --version install a specific release (default: the version this script was published with) --prefix install root (default: ~/.local/share/qbeast-shell) --bin-dir where the launcher is symlinked (default: ~/.local/bin) --base-url download root (default: https://run.qbeast.cloud) --uninstall remove qbeast-shell --purge with --uninstall, also delete cached tokens and REPL history -y, --yes overwrite an existing installation without prompting (also QBEAST_SHELL_ASSUME_YES=1; required when non-interactive) -h, --help show this help USAGE } while [ $# -gt 0 ]; do case "$1" in --version) VERSION="${2:-}"; shift 2 ;; --prefix) PREFIX="${2:-}"; shift 2 ;; --bin-dir) BIN_DIR="${2:-}"; shift 2 ;; --base-url) BASE_URL="${2:-}"; shift 2 ;; --uninstall) MODE="uninstall"; shift ;; --purge) PURGE=1; shift ;; -y|--yes) ASSUME_YES=1; shift ;; -h|--help) usage; exit 0 ;; *) usage; die "unknown option: $1" ;; esac done BASE_URL="${BASE_URL%/}" # grab one string field out of the receipt json. prints nothing if it's missing receipt_field() { [ -f "$1" ] || return 0 sed -n "s/.*\"$2\"[[:space:]]*:[[:space:]]*\"\([^\"]*\)\".*/\1/p" "$1" | head -n1 } # --- uninstall --- uninstall() { local receipt="$PREFIX/$RECEIPT_NAME" local link="$BIN_DIR/qbeast-shell" local removed=0 local recorded # if the install used --bin-dir the link isn't where we'd guess, so trust the receipt recorded="$(receipt_field "$receipt" bin)" if [ -n "$recorded" ]; then link="$recorded" fi # don't touch a symlink unless it points at the prefix we're deleting if [ -L "$link" ]; then local target target="$(readlink "$link")" case "$target" in "$PREFIX"/*) rm -f "$link" log "Removed $link" removed=1 ;; *) warn "$link doesn't point into $PREFIX, leaving it alone." ;; esac fi if [ -d "$PREFIX" ]; then rm -rf "$PREFIX" log "Removed $PREFIX" removed=1 fi if [ "$removed" -eq 0 ]; then warn "nothing found at $PREFIX." if command -v brew >/dev/null 2>&1 && brew list qbeast-shell >/dev/null 2>&1; then warn "looks like it came from Homebrew: brew uninstall qbeast-shell" fi fi local existing=() local p for p in "${STATE_PATHS[@]}"; do if [ -e "$p" ]; then existing+=("$p") fi done if [ "${#existing[@]}" -gt 0 ]; then if [ "$PURGE" -eq 1 ]; then rm -rf "${existing[@]}" log "Purged cached tokens and history: ${existing[*]}" else log "Kept your tokens and history (--purge deletes them): ${existing[*]}" fi fi log "qbeast-shell uninstalled." } if [ "$MODE" = "uninstall" ]; then uninstall exit 0 fi # --- install --- # brew only exists on some machines, so figure out what this box actually has pkg_manager() { local m for m in brew apt-get dnf yum zypper pacman apk; do if command -v "$m" >/dev/null 2>&1; then printf '%s' "$m" return fi done printf 'none' } # a command the user can copy paste, or nothing if we can't guess one install_hint() { case "$(pkg_manager)" in brew) printf 'brew install %s' "$1" ;; apt-get) printf 'sudo apt-get install -y %s' "$1" ;; dnf) printf 'sudo dnf install -y %s' "$1" ;; yum) printf 'sudo yum install -y %s' "$1" ;; zypper) printf 'sudo zypper install -y %s' "$1" ;; pacman) printf 'sudo pacman -S --noconfirm %s' "$1" ;; apk) printf 'sudo apk add %s' "$1" ;; *) printf '' ;; esac } # same idea, but every distro names its jdk package differently java_hint() { case "$(pkg_manager)" in brew) printf 'brew install openjdk@%s' "$MIN_JAVA" ;; apt-get) printf 'sudo apt-get install -y openjdk-%s-jdk' "$MIN_JAVA" ;; dnf) printf 'sudo dnf install -y java-%s-openjdk' "$MIN_JAVA" ;; yum) printf 'sudo yum install -y java-%s-openjdk' "$MIN_JAVA" ;; zypper) printf 'sudo zypper install -y java-%s-openjdk' "$MIN_JAVA" ;; pacman) printf 'sudo pacman -S --noconfirm jdk%s-openjdk' "$MIN_JAVA" ;; apk) printf 'sudo apk add openjdk%s' "$MIN_JAVA" ;; *) printf 'install Temurin %s from https://adoptium.net/temurin/releases/?version=%s' "$MIN_JAVA" "$MIN_JAVA" ;; esac } for cmd in curl unzip; do if ! command -v "$cmd" >/dev/null 2>&1; then hint="$(install_hint "$cmd")" if [ -n "$hint" ]; then die "'$cmd' is required but not installed. Install it with: $hint" else die "'$cmd' is required but not installed." fi fi done check_java() { local java_bin="" local raw local major # JAVA_HOME first then PATH, which is what the launcher does if [ -n "${JAVA_HOME:-}" ] && [ -x "$JAVA_HOME/bin/java" ]; then java_bin="$JAVA_HOME/bin/java" elif command -v java >/dev/null 2>&1; then java_bin="java" else warn "no java in JAVA_HOME or PATH. qbeast-shell needs Java ${MIN_JAVA}+:" warn " $(java_hint)" return fi raw="$("$java_bin" -version 2>&1 | head -n1 | sed -n 's/.*version "\([0-9][0-9.]*\).*/\1/p')" major="${raw%%.*}" # old style versions look like 1.8.x, so the real major is the second field if [ "$major" = "1" ]; then major="$(printf '%s' "$raw" | cut -d. -f2)" fi if [ -n "$major" ] && [ "$major" -lt "$MIN_JAVA" ] 2>/dev/null; then warn "found Java ${raw}, but qbeast-shell needs ${MIN_JAVA}+:" warn " $(java_hint)" fi } # fetch # -f so an HTTP error is a failure instead of an error page landing on disk # and then being handed to unzip. fetch() { curl -fsSL "$1" -o "$2" } sha256_of() { if command -v sha256sum >/dev/null 2>&1; then sha256sum "$1" | awk '{print $1}' elif command -v shasum >/dev/null 2>&1; then shasum -a 256 "$1" | awk '{print $1}' else return 1 fi } check_java # VERSION goes straight into a URL and a path, so keep it boring case "$VERSION" in *[!A-Za-z0-9._-]* | "" | -* | *..*) die "refusing suspicious version string: ${VERSION}" ;; esac # look for an old install before downloading anything, so saying no is cheap existing=() if [ -d "$PREFIX" ]; then prev_version="$(receipt_field "$PREFIX/$RECEIPT_NAME" version)" prev_date="$(receipt_field "$PREFIX/$RECEIPT_NAME" installed_at)" if [ -n "$prev_version" ]; then detail="version ${prev_version}" if [ -n "$prev_date" ]; then detail="${detail}, installed ${prev_date}" fi else detail="unrecognized contents, no install receipt" fi existing+=("$PREFIX (${detail})") fi if [ -d "${PREFIX}.old" ]; then existing+=("${PREFIX}.old (leftover from an interrupted run)") fi if [ -L "$BIN_DIR/qbeast-shell" ]; then link_target="$(readlink "$BIN_DIR/qbeast-shell")" case "$link_target" in "$PREFIX"/*) existing+=("$BIN_DIR/qbeast-shell -> $link_target") ;; *) FOREIGN_BIN="$link_target" ;; esac elif [ -e "$BIN_DIR/qbeast-shell" ]; then FOREIGN_BIN="$BIN_DIR/qbeast-shell (regular file)" fi if [ "${#existing[@]}" -gt 0 ]; then log "Existing qbeast-shell installation found:" for item in "${existing[@]}"; do printf ' %s\n' "$item" >&2 done confirm "Remove the above and install version ${VERSION}?" || die "aborted, nothing was changed." fi if [ -n "$FOREIGN_BIN" ]; then warn "${BIN_DIR}/qbeast-shell already exists and we didn't put it there:" warn " ${FOREIGN_BIN}" if confirm "Replace it?"; then LINK_BIN=1 else LINK_BIN=0 warn "ok, leaving it. the new install will only be at ${PREFIX}/bin/qbeast-shell." fi fi ZIP="qbeast-shell-${VERSION}.zip" ASSET_URL="${BASE_URL}/releases/qbeast-shell/${VERSION}/${ZIP}" TMP="$(mktemp -d)" trap 'rm -rf "$TMP"' EXIT log "Downloading ${ZIP}..." fetch "$ASSET_URL" "$TMP/$ZIP" \ || die "could not download ${ASSET_URL} (does version ${VERSION} exist?)" # published alongside every archive, so a missing one means a broken release fetch "${ASSET_URL}.sha256" "$TMP/${ZIP}.sha256" \ || die "no checksum published for ${ZIP}, refusing to install." expected="$(awk '{print $1}' "$TMP/${ZIP}.sha256")" actual="$(sha256_of "$TMP/$ZIP")" || die "no sha256sum or shasum available to verify the download." [ -n "$expected" ] || die "checksum file was empty." [ "$expected" = "$actual" ] || die "checksum mismatch for ${ZIP} (expected ${expected}, got ${actual})." log "Checksum verified." log "Installing to ${PREFIX}..." unzip -q "$TMP/$ZIP" -d "$TMP/unpacked" [ -f "$TMP/unpacked/bin/qbeast-shell" ] || die "unexpected archive layout: bin/qbeast-shell missing." mkdir -p "$(dirname "$PREFIX")" rm -rf "${PREFIX}.old" if [ -d "$PREFIX" ]; then mv "$PREFIX" "${PREFIX}.old" # if the move below blows up, put the old install back instead of leaving # the user with no shell at all trap 'rm -rf "$TMP"; [ -d "${PREFIX}.old" ] && [ ! -d "$PREFIX" ] && mv "${PREFIX}.old" "$PREFIX"' EXIT fi mv "$TMP/unpacked" "$PREFIX" rm -rf "${PREFIX}.old" trap 'rm -rf "$TMP"' EXIT chmod +x "$PREFIX/bin/qbeast-shell" if [ "$LINK_BIN" -eq 1 ]; then mkdir -p "$BIN_DIR" ln -sf "$PREFIX/bin/qbeast-shell" "$BIN_DIR/qbeast-shell" LAUNCHER="${BIN_DIR}/qbeast-shell" else # we left someone else's binary alone, so point at the prefix copy LAUNCHER="${PREFIX}/bin/qbeast-shell" fi # the uninstaller reads this back to undo exactly what we did cat > "$PREFIX/$RECEIPT_NAME" <&2 ;; esac fi