Files
cursor-nixos-flake/update-cursor.sh
T
Olivier fe9d8ba55a fix(ci): refresh AppImage hashes when Cursor version is unchanged
Cursor can republish the same semver with a new build; the updater
previously skipped prefetch when the version matched, leaving stale
sha256 values. In CI, always re-prefetch and commit when flake.nix drifts.
2026-06-02 23:03:37 -03:00

376 lines
13 KiB
Bash
Executable File

#!/usr/bin/env bash
set -euo pipefail
# Script to manually update Cursor version/hashes used by the flake
# Usage:
# ./update-cursor.sh # auto: pick highest of ("latest" pointer, current major.minor pointer)
# ./update-cursor.sh 2.2.44 # pin exact version
# ./update-cursor.sh latest # follow the "latest" pointer only
# ./update-cursor.sh 2.2 # follow a major.minor pointer only
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
FLAKE_FILE="$SCRIPT_DIR/flake.nix"
MANAGED_BEGIN="# BEGIN managed by ./update-cursor.sh"
MANAGED_END="# END managed by ./update-cursor.sh"
INDENT=" "
CHANNEL_FOR_SYSTEM_x86_64_linux="linux-x64"
CHANNEL_FOR_SYSTEM_aarch64_linux="linux-arm64"
# Extract Cursor semver from a resolved download URL (Location header).
extract_version_from_url() {
local url="${1:-}"
echo "$url" | grep -Eo 'Cursor-[0-9]+\.[0-9]+\.[0-9]+' | sed 's/Cursor-//' || true
}
# Get a redirect URL for a given pointer (e.g. latest, 2.2, 2.2.44) and channel.
get_redirect_url() {
local channel="${1:?channel is required}"
local pointer="${2:?pointer is required}"
local url="https://api2.cursor.sh/updates/download/golden/${channel}/cursor/${pointer}"
# Avoid stale CDN/proxy cache results for fast-moving pointers like "latest".
curl -sS -I \
-H 'Cache-Control: no-cache' \
-H 'Pragma: no-cache' \
"$url" \
| awk 'BEGIN{IGNORECASE=1} $1=="location:" {print $2; exit}' \
| tr -d '\r\n'
}
# Resolve an exact version to a stable downloads.cursor.com URL.
#
# Guardrail: Cursor's API has been observed to sometimes redirect an "exact version" URL
# (e.g. /cursor/2.2.44) to an older AppImage (e.g. 2.2.43). When that happens, we retry via
# the major.minor pointer (e.g. /cursor/2.2) and only proceed if it resolves to the requested
# version.
resolve_download_url_for_version() {
local channel="${1:?channel is required}"
local version="${2:?version is required}"
local redirect_url resolved_version
redirect_url="$(get_redirect_url "$channel" "$version")"
if [[ -z "$redirect_url" ]]; then
echo "Error: Could not resolve Location header for ${channel} ${version}" >&2
return 1
fi
resolved_version="$(extract_version_from_url "$redirect_url")"
if [[ "$resolved_version" == "$version" ]]; then
echo "$redirect_url"
return 0
fi
local minor_pointer fallback_redirect fallback_resolved
minor_pointer="$(get_track_from_version "$version")"
echo "Warning: ${channel} ${version} resolved to ${resolved_version:-unknown}; retrying via pointer ${minor_pointer}" >&2
fallback_redirect="$(get_redirect_url "$channel" "$minor_pointer")"
if [[ -z "$fallback_redirect" ]]; then
echo "Error: Could not resolve Location header for fallback ${channel} ${minor_pointer}" >&2
return 1
fi
fallback_resolved="$(extract_version_from_url "$fallback_redirect")"
if [[ "$fallback_resolved" != "$version" ]]; then
echo "Error: Could not resolve requested version $version for channel $channel." >&2
echo "Tried:" >&2
echo " - /cursor/${version} -> ${resolved_version:-unknown} ($redirect_url)" >&2
echo " - /cursor/${minor_pointer} -> ${fallback_resolved:-unknown} ($fallback_redirect)" >&2
echo "Upstream pointers/caches are inconsistent; try again later." >&2
return 1
fi
echo "$fallback_redirect"
}
# Resolve a pointer (e.g. latest, 2.2) into an actual semver (e.g. 2.2.44).
get_latest_version() {
local track="${1:-latest}"
local redirect_url
# Version discovery can just use linux-x64; the resolved version is shared across architectures.
redirect_url="$(get_redirect_url "$CHANNEL_FOR_SYSTEM_x86_64_linux" "$track")"
if [[ -n "$redirect_url" ]]; then
# Extract version from URL like: .../Cursor-2.2.44-x86_64.AppImage
echo "$redirect_url" | grep -Eo 'Cursor-[0-9]+\.[0-9]+\.[0-9]+' | sed 's/Cursor-//'
else
echo "Error: Could not get redirect URL for track: $track" >&2
return 1
fi
}
# True if arg looks like a full semver (e.g. 2.2.44).
is_full_semver() {
[[ "${1:-}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]
}
# True if arg looks like a pointer we can resolve (e.g. latest, 2.2).
is_pointer() {
[[ "${1:-}" == "latest" || "${1:-}" =~ ^[0-9]+\.[0-9]+$ ]]
}
is_ci() {
[[ -n "${CI:-}" || -n "${GITHUB_ACTIONS:-}" || -n "${GITEA_ACTIONS:-}" ]]
}
write_version_output() {
local value="$1"
if [[ -n "${GITHUB_OUTPUT:-}" ]]; then
echo "CURSOR_VERSION_INFO=$value" >> "$GITHUB_OUTPUT"
fi
}
# Function to get current version from flake.nix managed block
get_current_version() {
awk -v begin="$MANAGED_BEGIN" -v end="$MANAGED_END" '
$0 ~ begin {inblock=1; next}
$0 ~ end {inblock=0}
inblock && match($0, /version = "([^"]+)";/, m) { print m[1]; exit }
' "$FLAKE_FILE"
}
# Derive major.minor track from a full version (e.g. 2.2.43 -> 2.2).
get_track_from_version() {
local version="${1:?version is required}"
echo "$version" | awk -F. '{print $1 "." $2}'
}
# Return the highest of the provided dotted versions using natural version sort.
semver_max() {
# Accepts N args; prints the max.
printf '%s\n' "$@" | grep -E '^[0-9]+\.[0-9]+\.[0-9]+$' | sort -V | tail -n1
}
# Function to compute sha256 for a given system+version
prefetch_sha256() {
local system="$1"
local url="$2"
local channel=""
case "$system" in
x86_64-linux) channel="$CHANNEL_FOR_SYSTEM_x86_64_linux" ;;
aarch64-linux) channel="$CHANNEL_FOR_SYSTEM_aarch64_linux" ;;
*) echo "Error: Unsupported system: $system" >&2; return 1 ;;
esac
if ! command -v nix-prefetch-url >/dev/null 2>&1; then
echo "Error: nix-prefetch-url not found. Please install Nix (or nix-prefetch-url) and retry." >&2
return 1
fi
# IMPORTANT: this function is captured by command substitution. Keep stdout as the hash only.
echo "Prefetching ($system) from resolved URL: $url" >&2
local out hash
out="$(nix-prefetch-url --type sha256 "$url" 2>&1)"
echo "$out" >&2
# Extract the Nix base32 sha256 hash (52 chars, Nix alphabet).
hash="$(echo "$out" | grep -Eo '[0-9abcdfghijklmnpqrsvwxyz]{52}' | tail -n1 || true)"
if [[ -z "$hash" ]]; then
echo "Error: Could not parse sha256 from nix-prefetch-url output for $system" >&2
return 1
fi
echo "$hash"
}
# Update flake.nix managed block with a new version + per-system urls/hashes
update_release() {
local version="$1"
echo "Computing hashes for version: $version"
local url_x86 url_aarch64 sha_x86 sha_aarch64
url_x86="$(resolve_download_url_for_version "$CHANNEL_FOR_SYSTEM_x86_64_linux" "$version")"
url_aarch64="$(resolve_download_url_for_version "$CHANNEL_FOR_SYSTEM_aarch64_linux" "$version")"
sha_x86="$(prefetch_sha256 x86_64-linux "$url_x86")"
sha_aarch64="$(prefetch_sha256 aarch64-linux "$url_aarch64")"
echo "x86_64-linux sha256: $sha_x86"
echo "aarch64-linux sha256: $sha_aarch64"
if ! grep -qF "$MANAGED_BEGIN" "$FLAKE_FILE" || ! grep -qF "$MANAGED_END" "$FLAKE_FILE"; then
echo "Error: Could not find managed block markers in $FLAKE_FILE" >&2
echo "Expected lines containing:" >&2
echo " $MANAGED_BEGIN" >&2
echo " $MANAGED_END" >&2
return 1
fi
# Create backup
cp "$FLAKE_FILE" "$FLAKE_FILE.backup"
local tmp_file
tmp_file="$(mktemp -t cursor-flake-update-XXXXXX)"
local inblock=0
while IFS= read -r line; do
if [[ "$inblock" -eq 0 && "$line" == *"$MANAGED_BEGIN"* ]]; then
printf '%s\n' "$line" >> "$tmp_file"
cat >> "$tmp_file" <<EOF
${INDENT}version = "$version";
${INDENT}sources = {
${INDENT} x86_64-linux = {
${INDENT} url = "$url_x86";
${INDENT} sha256 = "$sha_x86";
${INDENT} };
${INDENT} aarch64-linux = {
${INDENT} url = "$url_aarch64";
${INDENT} sha256 = "$sha_aarch64";
${INDENT} };
${INDENT}};
EOF
inblock=1
continue
fi
if [[ "$inblock" -eq 1 && "$line" == *"$MANAGED_END"* ]]; then
printf '%s\n' "$line" >> "$tmp_file"
inblock=0
continue
fi
if [[ "$inblock" -eq 1 ]]; then
continue
fi
printf '%s\n' "$line" >> "$tmp_file"
done < "$FLAKE_FILE"
mv "$tmp_file" "$FLAKE_FILE"
echo "Updated flake.nix managed block with version $version"
}
# Function to test the flake
test_flake() {
echo "Testing flake..."
if command -v nix >/dev/null 2>&1; then
nix flake check --no-build
echo "Flake check passed!"
else
echo "Warning: nix command not found. Skipping flake check."
fi
}
# Main logic
main() {
local target_version="${1:-}"
echo "Cursor Flake Updater"
echo "==================="
# Get current version
local current_version
current_version=$(get_current_version)
echo "Current version: $current_version"
# Determine target version
if [[ -n "$target_version" ]]; then
if is_full_semver "$target_version"; then
echo "Target version (pinned): $target_version"
elif is_pointer "$target_version"; then
echo "Target pointer: $target_version"
target_version="$(get_latest_version "$target_version")"
echo "Resolved version: $target_version"
else
echo "Error: Invalid argument: $target_version" >&2
echo "Expected: full version (e.g. 2.2.44) or pointer (latest or 2.2)" >&2
exit 1
fi
else
# Auto mode:
# - Check "latest" to allow moving to new major/minor (e.g. 2.3.x)
# - Check current major.minor line to avoid missing patches when "latest" lags (e.g. 2.2.44 vs latest=2.2.43)
# - Also check latest's own major.minor line (if latest=2.3.0 but 2.3 pointer is 2.3.1)
local current_track v_latest latest_track v_latest_track v_current_track
current_track="$(get_track_from_version "$current_version")"
echo "Fetching pointers: latest, $current_track"
v_latest="$(get_latest_version latest)"
latest_track="$(get_track_from_version "$v_latest")"
v_current_track="$(get_latest_version "$current_track")"
if [[ "$latest_track" != "$current_track" ]]; then
echo "Latest is on a different minor: $latest_track (also checking it)"
v_latest_track="$(get_latest_version "$latest_track")"
else
v_latest_track="$v_latest"
fi
target_version="$(semver_max "$v_latest" "$v_current_track" "$v_latest_track")"
echo "Resolved: latest=$v_latest pointer($current_track)=$v_current_track pointer($latest_track)=$v_latest_track -> chosen=$target_version"
fi
# Same semver but Cursor may republish the AppImage (hash/url drift). In CI, always re-prefetch.
if [[ "$target_version" == "$current_version" ]]; then
if is_ci; then
echo "Version unchanged ($current_version); re-prefetching URLs and hashes for upstream drift..."
update_release "$target_version"
rm -f "$FLAKE_FILE.backup"
if git diff --quiet -- "$FLAKE_FILE" 2>/dev/null; then
echo "Hashes and URLs already match upstream."
write_version_output "no_update"
exit 0
fi
test_flake
echo "Refreshed hashes for Cursor $target_version"
write_version_output "completed:$current_version:$target_version"
exit 0
fi
echo "No update needed. Current version is up to date."
write_version_output "no_update"
exit 0
fi
echo "Update needed: $current_version -> $target_version"
if is_ci; then
echo "Running in CI mode, auto-confirming update..."
REPLY="y"
else
read -p "Do you want to proceed with the update? (y/N): " -n 1 -r
echo
fi
if [[ $REPLY =~ ^[Yy]$ ]]; then
update_release "$target_version"
rm -f "$FLAKE_FILE.backup"
test_flake
echo "Update completed successfully!"
write_version_output "completed:$current_version:$target_version"
echo "You can now commit the changes:"
echo " git add flake.nix"
echo " git commit -m \"Update Cursor to version $target_version\""
else
echo "Update cancelled."
exit 1
fi
}
# Check dependencies
check_dependencies() {
local missing_deps=()
if ! command -v curl >/dev/null 2>&1; then
missing_deps+=("curl")
fi
if [[ ! -f "$FLAKE_FILE" ]]; then
missing_deps+=("flake.nix (missing file)")
fi
if [[ ${#missing_deps[@]} -gt 0 ]]; then
echo "Error: Missing required dependencies: ${missing_deps[*]}"
echo "Please install them and try again."
exit 1
fi
}
# Run main function
check_dependencies
main "$@"