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-latestis a symlink pointing at whichever version is active.~/bin/sqlis a symlink that goes throughsqlcl-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/sqland ran the script again. It noticed, recreated the symlink, and still skipped the download. - I manually repointed
sqlcl-latestat one of the older versions, the way I would for a real rollback, then ran the script again. It left it alone.~/bin/sqlfollowed 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.