Skip to content

Instantly share code, notes, and snippets.

@jhillacre
Last active December 11, 2025 17:22
Show Gist options
  • Select an option

  • Save jhillacre/415c6541459496152c904d6394442dda to your computer and use it in GitHub Desktop.

Select an option

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`.
#!/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