The habit I was trying to automate

SQLcl updates break things sometimes. Not often, but often enough that I stopped overwriting the last version every time a new one shipped.

For a while now my setup has looked like this:

  • Every version I download lives in its own folder under ~/Applications, named after its build number.
  • ~/Applications/sqlcl-latest is a symlink pointing at whichever version is active.
  • ~/bin/sql is a symlink that goes through sqlcl-latest, not straight to a version folder.

That last point is what makes rollback cheap. If a new release misbehaves, I repoint sqlcl-latest at an older folder and ~/bin/sql follows it automatically. No need to touch ~/bin/sql at all.

The manual side of this: download the zip, unzip it, rename the folder, flip the symlink. Fine the first few times. Tedious by the fourth.

What was already on disk

Before writing anything I looked at what I’d built up by hand over the past year:

~/Applications/sqlcl-25.1.1.113.2054/sqlcl/...
~/Applications/sqlcl-25.4.2.044.1837/sqlcl/...
~/Applications/sqlcl-26.2.1.222.1617/sqlcl/...
~/Applications/sqlcl-latest -> sqlcl-26.2.1.222.1617/
~/bin/sql -> /home/sthilaire/Applications/sqlcl-latest/sqlcl/bin/sql

Versions are included in each install sqlcl/bin/version.txt file with a RELEASE= line:

RELEASE=26.2.1.222.1617
JULIAN_BUILD_VERSION=26.2.1.222.1617
LATEST_BUILD=sqlcl-26.2.1.222.1617-2026-08-1016:17:10+0000

That file is a JVM-free way to read a version. Getting the same number out of sql -V means starting a JVM first, just to print a string that’s already sitting in a text file.

Checking for updates without downloading 120MB every time

SQLcl downloads always point at the same URL:

https://download.oracle.com/otn_software/java/sqldeveloper/sqlcl-latest.zip

There’s no version number in the response headers, just an ETag and a Last-Modified date. So the script sends a HEAD request first, compares the ETag to whatever it saved from the last run, and only pulls the full zip when it’s changed. On a “did anything ship” check, that turns a 120MB download into one HTTP round trip.

The script

~/Applications/scripts/sqlcl-update.sh, no arguments, run whenever I feel like checking:

#!/usr/bin/env bash
set -euo pipefail

DOWNLOAD_URL="https://download.oracle.com/otn_software/java/sqldeveloper/sqlcl-latest.zip"
APPS_DIR="$HOME/Applications"
LATEST_LINK="$APPS_DIR/sqlcl-latest"
BIN_LINK="$HOME/bin/sql"
ETAG_CACHE="$APPS_DIR/.sqlcl-latest.etag"

for cmd in curl unzip; do
  command -v "$cmd" >/dev/null 2>&1 || { echo "Error: '$cmd' is required but not found on PATH." >&2; exit 1; }
done

# JVM-free way to read a build's version: SQLcl bundles this file, no need to launch `sql -V`.
read_version() {
  local vfile="$1/sqlcl/bin/version.txt"
  [[ -f "$vfile" ]] || { echo ""; return 0; }
  { grep '^RELEASE=' "$vfile" || true; } | head -1 | cut -d= -f2 | tr -d '\r'
}

sync_bin_link() {
  local want="$LATEST_LINK/sqlcl/bin/sql"
  if [[ "$(readlink "$BIN_LINK" 2>/dev/null || true)" != "$want" ]]; then
    ln -sfn "$want" "$BIN_LINK"
    echo "Linked $BIN_LINK -> $want"
  fi
}

report_links() {
  echo "  $LATEST_LINK -> $(readlink "$LATEST_LINK")"
  echo "  $BIN_LINK -> $(readlink "$BIN_LINK")"
}

echo "Checking for latest SQLcl release..."

# No version number is available without downloading the zip, so an ETag
# comparison is the only cheap way to tell "nothing changed" from "check further".
headers="$(curl -fsSI "$DOWNLOAD_URL")" || { echo "Error: failed to reach $DOWNLOAD_URL" >&2; exit 1; }
etag="$(printf '%s' "$headers" | tr -d '\r' | { grep -i '^etag:' || true; } | sed -E 's/^[Ee][Tt][Aa][Gg]: *//; s/"//g')"
if [[ -z "$etag" ]]; then
  echo "Error: could not read an ETag from $DOWNLOAD_URL" >&2
  exit 1
fi

cached_etag=""
[[ -f "$ETAG_CACHE" ]] && cached_etag="$(cat "$ETAG_CACHE")"

if [[ "$etag" == "$cached_etag" && -e "$LATEST_LINK" ]]; then
  sync_bin_link
  echo "Already up to date: SQLcl $(read_version "$LATEST_LINK")"
  report_links
  exit 0
fi

previous_version=""
[[ -e "$LATEST_LINK" ]] && previous_version="$(read_version "$LATEST_LINK")"

tmpdir="$(mktemp -d)"
trap 'rm -rf "$tmpdir"' EXIT

echo "Downloading SQLcl..."
zipfile="$tmpdir/sqlcl-latest.zip"
curl -fsSL -o "$zipfile" "$DOWNLOAD_URL"

echo "Verifying archive..."
unzip -tq "$zipfile" || { echo "Error: downloaded archive failed integrity check." >&2; exit 1; }

echo "Extracting..."
extract_dir="$tmpdir/extract"
mkdir -p "$extract_dir"
unzip -q "$zipfile" -d "$extract_dir"

new_version="$(read_version "$extract_dir")"
if [[ -z "$new_version" ]]; then
  echo "Error: could not determine version from downloaded archive." >&2
  exit 1
fi

target="$APPS_DIR/sqlcl-$new_version"
if [[ -d "$target" ]]; then
  echo "SQLcl $new_version is already installed at $target"
else
  mkdir -p "$target"
  mv "$extract_dir/sqlcl" "$target/sqlcl"
  echo "Installed SQLcl $new_version to $target"
fi

ln -sfn "sqlcl-$new_version" "$LATEST_LINK"
sync_bin_link
echo "$etag" > "$ETAG_CACHE"

if [[ -n "$previous_version" && "$previous_version" != "$new_version" ]]; then
  echo "Updated: SQLcl $previous_version -> $new_version"
else
  echo "Active version: SQLcl $new_version"
fi
report_links

A few decisions worth explaining

Rollback stays manual

The script only ever repoints sqlcl-latest to a newly downloaded version. ~/bin/sql always points through sqlcl-latest, and the script will fix that symlink if it’s missing or wrong, but it never picks which version is active beyond “the newest one I just installed.” If I want to go back to an older build, I still do that by hand, exactly like before, by repointing sqlcl-latest myself.

Nothing gets touched until the download checks out

The zip goes into a temp directory first. unzip -tq confirms it isn’t corrupt before anything gets extracted or moved. A trap on exit cleans up the temp directory whether the run succeeds, fails, or gets interrupted. If any step fails, the existing symlinks and installed versions are left exactly as they were.

New folders use the full version string

The script always uses the full string, so there’s no ambiguity about which build a folder holds.

Testing

  • First run found a real update, 26.2.1.222.1617 to 26.2.2.233.1901, downloaded it, installed it, and updated both symlinks.
  • Running it again immediately finished in about 50 milliseconds. Same ETag, no download, symlinks confirmed correct.
  • I deleted ~/bin/sql and ran the script again. It noticed, recreated the symlink, and still skipped the download.
  • I manually repointed sqlcl-latest at one of the older versions, the way I would for a real rollback, then ran the script again. It left it alone. ~/bin/sql followed the older version through, exactly as intended.

That last case was the one I cared about most. A script that “helpfully” pushed me back to the newest version on every run would have defeated the reason I keep old versions around in the first place.

Hopefully you find this helpful.