#!/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" <> "$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 "$@"