diff --git a/install b/install index 2cf359ba0..8bbf4a6e7 100755 --- a/install +++ b/install @@ -205,11 +205,26 @@ else if [ -z "$requested_version" ]; then url="https://github.com/AltimateAI/altimate-code/releases/latest/download/$filename" - specific_version=$(curl -s https://api.github.com/repos/AltimateAI/altimate-code/releases/latest | sed -n 's/.*"tag_name": *"v\([^"]*\)".*/\1/p') - - if [[ $? -ne 0 || -z "$specific_version" ]]; then - echo -e "${RED}Failed to fetch version information${NC}" - exit 1 + # The download above resolves "latest" server-side, so this API call only + # feeds the version display and the already-installed short-circuit. A + # transient api.github.com blip or the unauthenticated rate limit + # (60/hr/IP) must NOT abort the install — retry a few times with --fail + # (so a 504 retries instead of parsing an error body), then proceed + # without the version string. + # + # --max-time 10 bounds a dead-air socket (curl's default has no transfer + # cap), and the trailing `|| true` is load-bearing: under `set -euo + # pipefail`, a failing `curl --fail` propagates through the pipeline and + # the assignment, so `set -e` would abort the script before the loop can + # retry or degrade. `|| true` lets the failure resolve to an empty string. + specific_version="" + for attempt in 1 2 3; do + specific_version=$(curl -fsSL --max-time 10 https://api.github.com/repos/AltimateAI/altimate-code/releases/latest 2>/dev/null | sed -n 's/.*"tag_name": *"v\([^"]*\)".*/\1/p' || true) + [ -n "$specific_version" ] && break + [ "$attempt" -lt 3 ] && sleep "$attempt" + done + if [ -z "$specific_version" ]; then + echo -e "${MUTED}Could not resolve the latest version from GitHub (API unavailable) — installing the latest release anyway.${NC}" fi else # Strip leading 'v' if present @@ -255,11 +270,14 @@ check_version() { if [ -n "$probe" ]; then installed_version=$("$probe" --version 2>/dev/null || echo "") - if [[ "$installed_version" != "$specific_version" ]]; then - print_message info "${MUTED}Installed version: ${NC}$installed_version." - else + # Only short-circuit on a real version match. When the latest version + # couldn't be resolved (API unavailable → specific_version empty), never + # treat an empty==empty as "already installed" — fall through and reinstall. + if [ -n "$specific_version" ] && [[ "$installed_version" == "$specific_version" ]]; then print_message info "${MUTED}Version ${NC}$specific_version${MUTED} already installed${NC}" exit 0 + elif [ -n "$installed_version" ]; then + print_message info "${MUTED}Installed version: ${NC}$installed_version." fi fi } @@ -357,7 +375,7 @@ download_with_progress() { } download_and_install() { - print_message info "\n${MUTED}Installing ${NC}altimate ${MUTED}version: ${NC}$specific_version" + print_message info "\n${MUTED}Installing ${NC}altimate ${MUTED}version: ${NC}${specific_version:-latest}" local tmp_dir="${TMPDIR:-/tmp}/altimate_install_$$" mkdir -p "$tmp_dir" diff --git a/install.ps1 b/install.ps1 index bcab7223f..9f0cc0972 100644 --- a/install.ps1 +++ b/install.ps1 @@ -112,16 +112,32 @@ function Test-Avx2 { # --------------------------------------------------------------------------- if ([string]::IsNullOrWhiteSpace($Version)) { $useLatest = $true - try { - $rel = Invoke-RestMethod -Uri "https://api.github.com/repos/AltimateAI/altimate-code/releases/latest" -Headers @{ "User-Agent" = "altimate-install" } - $specificVersion = ($rel.tag_name -replace '^v', '') - } catch { - Write-Err "Failed to fetch version information" - exit 1 + # The download below resolves "latest" server-side (releases/latest/download), + # so this API call only feeds the version-string display and the + # already-installed short-circuit. A transient api.github.com blip or the + # unauthenticated rate limit (60/hr/IP) must NOT abort the install — retry a + # few times, then proceed without the version string. + $specificVersion = "" + for ($attempt = 1; $attempt -le 3; $attempt++) { + try { + # -TimeoutSec 10 bounds a stuck socket: Invoke-RestMethod defaults to 100s + # on PS 5.1 and is effectively unbounded on PS 7+, so without it three + # back-to-back retries on dead air could freeze for minutes. + $rel = Invoke-RestMethod -Uri "https://api.github.com/repos/AltimateAI/altimate-code/releases/latest" -Headers @{ "User-Agent" = "altimate-install" } -TimeoutSec 10 + $specificVersion = ($rel.tag_name -replace '^v', '') + if (-not [string]::IsNullOrWhiteSpace($specificVersion)) { break } + } catch {} + if ($attempt -lt 3) { Start-Sleep -Seconds $attempt } } if ([string]::IsNullOrWhiteSpace($specificVersion)) { - Write-Err "Failed to fetch version information" - exit 1 + Write-Muted "Could not resolve the latest version from GitHub (API unavailable) — installing the latest release anyway." + # Reset to $null (not ""): the already-installed short-circuit below compares + # $installedVersion -eq $specificVersion. If the version probe of a missing or + # corrupt binary also yields "", an "" -eq "" match would falsely report + # "already installed" and skip the reinstall. $null -eq "" is $false, so the + # comparison correctly falls through; the banner still shows "latest" because + # if ($specificVersion) treats $null as falsy. + $specificVersion = $null } } else { $useLatest = $false @@ -177,7 +193,7 @@ function Install-Target { } Write-Host "" - Write-Host "Installing $App version: $specificVersion" + Write-Host "Installing $App version: $(if ($specificVersion) { $specificVersion } else { 'latest' })" $tmpDir = Join-Path ([System.IO.Path]::GetTempPath()) "altimate_install_$PID" New-Item -ItemType Directory -Force -Path $tmpDir | Out-Null diff --git a/packages/opencode/test/install/version-fetch-resilience.test.ts b/packages/opencode/test/install/version-fetch-resilience.test.ts new file mode 100644 index 000000000..6f492fc2b --- /dev/null +++ b/packages/opencode/test/install/version-fetch-resilience.test.ts @@ -0,0 +1,67 @@ +/** + * Latest-version resolution must be resilient, in BOTH installers. + * + * The `latest` install path hits api.github.com/.../releases/latest only for the + * version-string display + the already-installed short-circuit — the download + * itself uses releases/latest/download/ (server-side latest). A transient + * 504 or the 60/hr/IP unauthenticated rate limit must NOT abort the install: + * retry a few times, then degrade gracefully and install latest anyway. + */ +import { describe, test, expect } from "bun:test" +import { readFileSync } from "node:fs" +import { join } from "node:path" + +const REPO_ROOT = join(import.meta.dir, "../../../..") +const BASH = readFileSync(join(REPO_ROOT, "install"), "utf-8") +const PS1 = readFileSync(join(REPO_ROOT, "install.ps1"), "utf-8") + +describe("bash installer — latest-version fetch is non-fatal", () => { + test("retries the releases/latest API call", () => { + expect(BASH).toContain("for attempt in 1 2 3") + // --fail so a 504 errors out (and retries) instead of parsing an error body. + expect(BASH).toContain("curl -fsSL --max-time 10 https://api.github.com") + }) + + test("the retry assignment absorbs curl failure so set -e can't abort it", () => { + // Under `set -euo pipefail`, a failing `curl --fail` propagates through the + // pipeline + assignment and aborts the script before the loop can retry or + // degrade. The trailing `|| true` keeps the retry loop alive. + expect(BASH).toMatch(/curl -fsSL --max-time 10 https:\/\/api\.github\.com[^\n]*\|\| true/) + }) + + test("bounds the API call with a transfer timeout", () => { + expect(BASH).toContain("--max-time 10") + }) + + test("degrades gracefully instead of exiting on API failure", () => { + expect(BASH).toContain("installing the latest release anyway") + // The old fatal hard-fail must be gone from the latest path. + expect(BASH).not.toContain("Failed to fetch version information") + }) + + test("only short-circuits as already-installed on a real version match", () => { + expect(BASH).toContain('[ -n "$specific_version" ] && [[ "$installed_version" == "$specific_version" ]]') + }) +}) + +describe("PowerShell installer — latest-version fetch is non-fatal", () => { + test("retries the releases/latest API call", () => { + expect(PS1).toContain("for ($attempt = 1; $attempt -le 3; $attempt++)") + }) + + test("bounds the API call with a request timeout", () => { + expect(PS1).toContain("-TimeoutSec 10") + }) + + test("degrades gracefully instead of exiting on API failure", () => { + expect(PS1).toContain("installing the latest release anyway") + // The old fatal hard-fail must be gone. + expect(PS1).not.toContain("Failed to fetch version information") + }) + + test("resets the unresolved version to $null so empty==empty can't false-match", () => { + // $installedVersion -eq $specificVersion with both "" would falsely report + // "already installed" for a missing/corrupt binary; $null -eq "" is $false. + expect(PS1).toContain("$specificVersion = $null") + }) +})