Last active
December 11, 2025 17:22
-
-
Save jhillacre/415c6541459496152c904d6394442dda to your computer and use it in GitHub Desktop.
Minimal Bash updater for JetBrains tarball IDEs: fetches release metadata, installs to `~/WebStorm`/`~/PyCharm`, supports version/build targeting + per-IDE pinning, backups previous install, skips up-to-date unless `--force`.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/bin/bash | |
| set -Eeuo pipefail | |
| shopt -s inherit_errexit | |
| trap 'echo -e "\033[1;31m Error on line $LINENO\033[0m"; exit 1' ERR | |
| # ANSI colors | |
| BLUE='\033[1;38;2;0;120;247m' | |
| ORANGE='\033[1;38;2;255;127;0m' | |
| GREEN='\033[1;32m' | |
| RED='\033[1;31m' | |
| RESET='\033[0m' | |
| PIN_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/update-jetbrains" | |
| mkdir -p "$PIN_DIR" | |
| VERBOSE=false | |
| TARGET_VERSION="" | |
| TARGET_BUILD="" | |
| PIN_REQUESTED=false | |
| PIN_MODE="" | |
| CLEAR_PIN=false | |
| SHOW_PIN=false | |
| FORCE=false | |
| vlog() { | |
| if [[ "${VERBOSE:-false}" == true ]]; then | |
| local first="$1"; shift | |
| echo -e "${BLUE}[verbose] ${first}${RESET} ${ORANGE}$*${RESET}" | |
| fi | |
| return 0 | |
| } | |
| echoError() { local first="$1"; shift; echo -e "${RED}[error] ${first}${RESET} $*" >&2; } | |
| echoSuccess(){ local first="$1"; shift; echo -e "${GREEN}[success] ${first}${RESET} $*"; } | |
| info() { local fixed="$1"; shift; echo -e "${BLUE}${fixed}${RESET} ${ORANGE}$*${RESET}"; } | |
| usage() { | |
| local prog | |
| prog="$(basename "$0")" | |
| info "Usage:" "$prog [options] {pycharm|webstorm}" | |
| echo -e "Options:" | |
| echo -e " -v, --verbose Enable verbose output" | |
| echo -e " --version <ver> Select version by prefix or exact (e.g. 2025.2 or 2025.2.1.1)" | |
| echo -e " --build <build> Select exact build (e.g. 252.25557.178). Overrides --version" | |
| echo -e " --pin Save the resolved version or build as a pinned target for this IDE." | |
| echo -e " If neither is provided, pins the resolved build" | |
| echo -e " --show-pin Show pinned target for this IDE (if any)" | |
| echo -e " --clear-pin Remove pinned target for this IDE" | |
| echo -e " --force Reinstall even if the requested build matches the installed build" | |
| echo | |
| echo -e "Examples:" | |
| echo -e " $prog webstorm # Update to latest (unless pinned)" | |
| echo -e " $prog --version 2025.1 webstorm # Install newest 2025.1.x release" | |
| echo -e " $prog --build 251.28293.44 pycharm --pin" | |
| echo -e " # Install that build and pin it" | |
| echo -e " $prog pycharm --show-pin # Show pinned build for PyCharm" | |
| echo -e " $prog pycharm --clear-pin # Remove pin for PyCharm" | |
| exit 1 | |
| } | |
| save_pin() { # args: mode build version | |
| local mode="$1" b="$2" v="$3" | |
| { | |
| echo "mode=$mode" | |
| [[ -n "$b" ]] && echo "build=$b" | |
| [[ -n "$v" ]] && echo "version=$v" | |
| } > "$PIN_FILE" | |
| if [[ "$mode" == "version" ]]; then | |
| echoSuccess "Pinned" "version $v for $ide (will float to newest $v.* build)" | |
| else | |
| echoSuccess "Pinned" "build $b (version ${v:-unknown}) for $ide" | |
| fi | |
| } | |
| clear_pin() { | |
| if [[ -f "$PIN_FILE" ]]; then | |
| rm -f "$PIN_FILE" | |
| echoSuccess "Cleared pin" "for $ide" | |
| else | |
| info "No pin set" "for $ide" | |
| fi | |
| } | |
| show_pin() { | |
| if [[ -f "$PIN_FILE" ]]; then | |
| local mode b v | |
| mode="$(grep -E '^mode=' "$PIN_FILE" | sed 's/^mode=//')" | |
| b="$(grep -E '^build=' "$PIN_FILE" | sed 's/^build=//')" | |
| v="$(grep -E '^version=' "$PIN_FILE" | sed 's/^version=//')" | |
| if [[ "$mode" == "version" && -n "$v" ]]; then | |
| info "Pinned target for $ide:" "version $v (last resolved build ${b:-unknown})" | |
| elif [[ -n "$b" ]]; then | |
| # Back-compat: old pins without mode are treated as build pins | |
| info "Pinned target for $ide:" "build $b (version ${v:-unknown})" | |
| else | |
| info "Pinned target for $ide:" "<invalid pin file>" | |
| fi | |
| else | |
| info "Pinned target for $ide:" "<none>" | |
| fi | |
| } | |
| ide="" | |
| # Parse args | |
| while [[ $# -gt 0 ]]; do | |
| case "$1" in | |
| -v|--verbose) VERBOSE=true ;; | |
| --version) shift; TARGET_VERSION="${1:-}"; [[ -z "$TARGET_VERSION" ]] && usage ;; | |
| --build) shift; TARGET_BUILD="${1:-}"; [[ -z "$TARGET_BUILD" ]] && usage ;; | |
| --pin) PIN_REQUESTED=true ;; | |
| --clear-pin) CLEAR_PIN=true ;; | |
| --show-pin) SHOW_PIN=true ;; | |
| --force) FORCE=true ;; | |
| pycharm|webstorm) | |
| if [[ -n "$ide" ]]; then echoError "Multiple IDEs specified."; usage; fi | |
| ide="$1" | |
| ;; | |
| -*) | |
| echoError "Unknown option:" "$1"; usage ;; | |
| *) | |
| echoError "Unexpected argument:" "$1"; usage ;; | |
| esac | |
| shift | |
| done | |
| [[ -z "$ide" ]] && usage | |
| # Product metadata | |
| if [[ "$ide" == "webstorm" ]]; then | |
| product_code="WS" | |
| install_dir="$HOME/Webstorm" | |
| elif [[ "$ide" == "pycharm" ]]; then | |
| product_code="PCP" # PyCharm Professional | |
| install_dir="$HOME/Pycharm" | |
| fi | |
| install_parent="$(dirname "$install_dir")" | |
| install_base="$(basename "$install_dir")" | |
| prune_backups() { | |
| # Keep only the most recent "${install_base}_backup_*" directory in install_parent | |
| local -a backups=() | |
| # List matching backup dirs sorted by mtime (newest first) | |
| mapfile -t backups < <(ls -1dt -- "$install_parent/${install_base}_backup_"* 2>/dev/null || true) | |
| # Nothing or only one? nothing to do | |
| (( ${#backups[@]} <= 1 )) && return 0 | |
| # Keep newest (index 0), remove the rest | |
| info "Pruning backups:" "keeping $(basename "${backups[0]}"), removing $(( ${#backups[@]} - 1 )) older" | |
| for b in "${backups[@]:1}"; do | |
| rm -rf -- "$b" | |
| done | |
| } | |
| PIN_FILE="$PIN_DIR/${ide}.pin" | |
| if $SHOW_PIN; then | |
| show_pin | |
| exit 0 | |
| fi | |
| if $CLEAR_PIN; then | |
| clear_pin | |
| exit 0 | |
| fi | |
| # We want the full list so we can pick a specific version/build locally. | |
| metadata_url="https://data.services.jetbrains.com/products/releases?code=${product_code}&type=release&latest=false" | |
| # Fetch metadata | |
| json=$(curl -sf "$metadata_url") || { echoError "Failed to fetch metadata from" "$metadata_url"; exit 1; } | |
| # Compute upstream latest (for drift info) | |
| latest_obj=$(jq -er ' | |
| def b: (.build | split(".") | map(tonumber)); | |
| .'"${product_code}"' | sort_by(b) | .[-1] | |
| ' <<< "$json") | |
| latest_build=$(jq -er '.build' <<< "$latest_obj") | |
| latest_version=$(jq -er '.version' <<< "$latest_obj") | |
| if [[ -z "${TARGET_BUILD:-}" && -z "${TARGET_VERSION:-}" && -f "$PIN_FILE" ]]; then | |
| PIN_MODE="$(grep -E '^mode=' "$PIN_FILE" | sed 's/^mode=//')" | |
| if [[ "$PIN_MODE" == "version" ]]; then | |
| TARGET_VERSION="$(grep -E '^version=' "$PIN_FILE" | sed 's/^version=//')" | |
| info "Using pinned target" "version ${TARGET_VERSION}" | |
| else | |
| # Back-compat or explicit build-mode | |
| TARGET_BUILD="$(grep -E '^build=' "$PIN_FILE" | sed 's/^build=//')" | |
| info "Using pinned target" "build ${TARGET_BUILD}" | |
| fi | |
| fi | |
| vlog "IDE:" "$ide" | |
| vlog "Install directory:" "$install_dir" | |
| vlog "Metadata URL:" "$metadata_url" | |
| vlog "Requested build:" "${TARGET_BUILD:-<none>}" | |
| vlog "Requested version:" "${TARGET_VERSION:-<none>}" | |
| # Select release (by build > by version prefix > newest) | |
| # Build selection is exact; version selection uses startswith to allow prefixes like "2025.2". | |
| select_jq=" | |
| def b: (.build | split(\".\") | map(tonumber)); | |
| .${product_code} | |
| | ( if (\"$TARGET_BUILD\" != \"\") then | |
| map(select(.build == \"$TARGET_BUILD\")) | |
| elif (\"$TARGET_VERSION\" != \"\") then | |
| map(select(.version | startswith(\"$TARGET_VERSION\"))) | |
| else | |
| . | |
| end ) | |
| | sort_by(b) | .[-1] | |
| | {link: .downloads.linux.link, version: .version, build: .build} | |
| " | |
| selected=$(jq -er "$select_jq" <<< "$json" 2>/dev/null || true) | |
| if [[ -z "${selected:-}" || "$selected" == "null" ]]; then | |
| echoError "Could not find a release matching" \ | |
| "${TARGET_BUILD:+build=$TARGET_BUILD}" \ | |
| "${TARGET_VERSION:+version=$TARGET_VERSION}" | |
| echo -e "${BLUE}Available recent versions (top 10):${RESET}" | |
| jq -r ".${product_code} | sort_by(.date) | reverse | .[:10][] | \" • \(.version) (build \(.build))\"" <<< "$json" | |
| exit 1 | |
| fi | |
| download_url=$(jq -er '.link' <<< "$selected") | |
| version=$( jq -er '.version' <<< "$selected") | |
| build=$( jq -er '.build' <<< "$selected") | |
| [[ -z "$download_url" || -z "$version" || -z "$build" ]] && { echoError "Selected release missing fields"; exit 1; } | |
| info "Resolved release:" "$ide $version (build $build)" | |
| vlog "Download URL:" "$download_url" | |
| # show drift from upstream latest | |
| if [[ "$build" != "$latest_build" ]]; then | |
| info "Note:" "Upstream latest is $latest_version (build $latest_build)" | |
| fi | |
| # If user asked to pin this resolved target, save it | |
| if $PIN_REQUESTED; then | |
| if [[ -n "$TARGET_BUILD" ]]; then | |
| save_pin "build" "$build" "$version" # exact build pin | |
| elif [[ -n "$TARGET_VERSION" ]]; then | |
| save_pin "version" "" "$TARGET_VERSION" # version pin (floats to newest build within that version) | |
| else | |
| save_pin "build" "$build" "$version" # default: build pin of resolved selection | |
| fi | |
| fi | |
| # Check installed version | |
| if [[ -f "$install_dir/build.txt" ]]; then | |
| current_build=$(<"$install_dir/build.txt") | |
| normalized_current_build="${current_build#*-}" | |
| vlog "Installed build:" "$current_build" | |
| vlog "Normalized installed build:" "$normalized_current_build" | |
| if [[ "$normalized_current_build" == "$build" ]]; then | |
| if [[ "$FORCE" == true ]]; then | |
| info "Force reinstall requested:" "continuing despite matching build $build" | |
| else | |
| echoSuccess "Already on requested build" | |
| info "IDE:" "$ide"; info "Version:" "$version"; info "Build:" "$build" | |
| exit 0 | |
| fi | |
| else | |
| info "Installed build:" "${current_build:-<none>}" | |
| info "Target build:" "$build" | |
| fi | |
| fi | |
| # Temp dir and archive | |
| temp_dir=$(mktemp -d) | |
| archive_path="$temp_dir/$ide.tar.gz" | |
| info "Downloading..." | |
| info "Version:" "$version" | |
| vlog "Temp dir:" "$temp_dir" | |
| vlog "Archive path:" "$archive_path" | |
| curl -sfL "$download_url" -o "$archive_path" || { echoError "Failed to download" "$download_url"; exit 1; } | |
| info "Extracting archive..." | |
| tar -xzf "$archive_path" -C "$temp_dir" || { echoError "Failed to extract tarball" "$archive_path"; exit 1; } | |
| # Locate extracted directory | |
| extracted_dir=$(find "$temp_dir" -maxdepth 1 -type d -iname "$ide*" | head -n 1) | |
| if [[ -z "$extracted_dir" || ! -d "$extracted_dir" ]]; then | |
| echoError "Could not locate extracted directory in" "$temp_dir" "(${extracted_dir:-<none>})" | |
| ls -la "$temp_dir" | |
| exit 1 | |
| fi | |
| vlog "Extracted directory:" "$extracted_dir" | |
| # Backup existing | |
| if [[ -d "$install_dir" ]]; then | |
| backup="${install_dir}_backup_$(date +%s)" | |
| info "Backing up existing install..." | |
| info "Backup dir:" "$backup" | |
| mv "$install_dir" "$backup" | |
| prune_backups | |
| fi | |
| info "Installing..." | |
| mv "$extracted_dir" "$install_dir" | |
| rm -rf "$temp_dir" | |
| echoSuccess "Update complete:" "$ide $version (build $build)" |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment