diff --git a/README.md b/README.md index 1f27de6..a08dfc0 100644 --- a/README.md +++ b/README.md @@ -34,15 +34,14 @@ Every PR runs the full test suite (`cargo test --all-targets`) on each of those platforms, plus LLVM coverage uploaded to [Codecov](https://codecov.io/gh/anyrouter-dev/cli). Every PR and GitHub Release gets a size + startup report (`anyr --version` / `--help`). -Manual (Linux x86_64 example): +Manual download of `https://github.com/anyrouter-dev/cli/releases/latest/download/anyr-linux-x86_64` +404s when the latest non-prerelease has no assets (v0.1.11 shipped empty). Use +`setup.sh` — it probes `/latest` and then picks a release that actually has +`anyr-linux-x86_64` (currently a `v0.1.12-beta.*` prerelease). Direct tag URLs: -```bash -curl -fsSL -o anyr \ - https://github.com/anyrouter-dev/cli/releases/latest/download/anyr-linux-x86_64 -chmod +x anyr -``` +`https://github.com/anyrouter-dev/cli/releases/download//anyr-linux-x86_64` -Hosted setup (same binary): +Hosted setup (same resolver): ```bash curl -fsSL https://anyrouter.dev/setup.sh | bash diff --git a/setup.sh b/setup.sh index c584069..cdace66 100755 --- a/setup.sh +++ b/setup.sh @@ -14,6 +14,7 @@ BIN_NAME="anyr" BIN_DIR="${ANYR_BIN_DIR:-${HOME}/.local/bin}" CHANNEL="${ANYR_CHANNEL:-stable}" VERSION="${ANYR_VERSION:-}" +CURL="${ANYR_CURL:-curl}" usage() { cat </dev/null 2>&1 || die "curl is required to download releases" +command -v "$CURL" >/dev/null 2>&1 || die "curl is required to download releases" os="$(detect_os)" arch="$(detect_arch)" asset="anyr-${os}-${arch}" -pick_beta_tag() { +github_token() { + if [ -n "${GH_TOKEN:-}" ]; then + printf '%s' "$GH_TOKEN" + elif [ -n "${GITHUB_TOKEN:-}" ]; then + printf '%s' "$GITHUB_TOKEN" + fi +} + +curl_ua() { + "$CURL" -A "anyr-setup" "$@" +} + +# Follow redirects. Empty /releases/latest 302s then 404s; a real asset ends 200. +asset_available() { + local url="$1" + local code + code="$(curl_ua -sI -L -o /dev/null -w '%{http_code}' "$url" || true)" + case "$code" in + 200 | 206) return 0 ;; + *) return 1 ;; + esac +} + +download_url_for_tag() { + printf '%s/releases/download/%s/%s' "$GITHUB" "$1" "$asset" +} + +# Authenticated REST only. Never call api.github.com without a token. +pick_tag_from_api() { + local token="$1" local json - json="$(curl -fsSL \ + json="$(curl_ua -fsSL \ -H "Accept: application/vnd.github+json" \ - -H "User-Agent: anyr-setup" \ - "${GITHUB_API}/releases")" - if command -v python3 >/dev/null 2>&1; then - printf '%s' "$json" | python3 -c ' + -H "Authorization: Bearer ${token}" \ + "${GITHUB_API}/releases")" || return 1 + command -v python3 >/dev/null 2>&1 || die "python3 is required to parse GitHub Releases JSON" + printf '%s' "$json" | python3 -c ' import json, sys +asset, channel = sys.argv[1], sys.argv[2] releases = json.load(sys.stdin) -for rel in releases: + +def tag_of(rel): if rel.get("draft"): - continue - if rel.get("prerelease"): - tag = rel.get("tag_name") or "" - if tag: - print(tag) + return "" + return rel.get("tag_name") or "" + +def has_asset(rel): + names = [a.get("name") for a in (rel.get("assets") or [])] + return asset in names + +def is_pre(rel): + return bool(rel.get("prerelease")) + +ordered = [r for r in releases if tag_of(r)] +# GitHub already returns newest-first; keep that order. +if channel == "beta": + for rel in ordered: + if is_pre(rel) and has_asset(rel): + print(tag_of(rel)) sys.exit(0) -sys.stderr.write("no beta (prerelease) found on GitHub Releases\n") + sys.stderr.write("no beta release has %s\n" % asset) + sys.exit(1) + +for rel in ordered: + if (not is_pre(rel)) and has_asset(rel): + print(tag_of(rel)) + sys.exit(0) +# Stable latest is often empty (v0.1.11). Use newest release that has binaries. +for rel in ordered: + if has_asset(rel): + print(tag_of(rel)) + sys.exit(0) +sys.stderr.write("no GitHub release has %s\n" % asset) sys.exit(1) +' "$asset" "$CHANNEL" +} + +list_tags_from_html() { + local html="$1" + if command -v python3 >/dev/null 2>&1; then + printf '%s' "$html" | python3 -c ' +import re, sys +html = sys.stdin.read() +seen = set() +tags = [] +for m in re.finditer(r"anyrouter-dev/cli/releases/tag/(v[0-9][^\"<>\s#?]*)", html): + tag = m.group(1).rstrip("/") + if tag in seen or "/" in tag: + continue + seen.add(tag) + tags.append(tag) + +def ver_key(tag): + s = tag[1:] if tag.startswith("v") else tag + core, _, pre = s.partition("-") + nums = [] + for p in core.split("."): + try: + nums.append(int(p)) + except ValueError: + nums.append(0) + while len(nums) < 3: + nums.append(0) + if pre: + pre_nums = [int(p) for p in re.split(r"[^0-9]+", pre) if p] + return (tuple(nums), 0, tuple(pre_nums)) + return (tuple(nums), 1, ()) + +tags.sort(key=ver_key, reverse=True) +for t in tags: + print(t) ' - else - die "python3 is required for ANYR_CHANNEL=beta" + return fi + printf '%s' "$html" | grep -oE 'anyrouter-dev/cli/releases/tag/v[^"<>[:space:]#?]+' \ + | sed 's|.*/||' | awk '!s[$0]++' } -if [ -n "$VERSION" ]; then - tag="v${VERSION#v}" - url="${GITHUB}/releases/download/${tag}/${asset}" -elif [ "$CHANNEL" = "beta" ]; then - tag="$(pick_beta_tag)" - [ -n "$tag" ] || die "no beta tag from GitHub API" - url="${GITHUB}/releases/download/${tag}/${asset}" -else - # stable: GitHub latest is the newest non-prerelease - url="${GITHUB}/releases/latest/download/${asset}" -fi +pick_tag_from_html() { + local html tag url + html="$(curl_ua -fsSL "${GITHUB}/releases")" \ + || die "could not fetch ${GITHUB}/releases (set GH_TOKEN to use the GitHub API)" + local tags + tags="$(list_tags_from_html "$html")" + [ -n "$tags" ] || die "no GitHub release tags found at ${GITHUB}/releases" + + if [ "$CHANNEL" = "beta" ]; then + while IFS= read -r tag; do + [ -n "$tag" ] || continue + case "$tag" in + *-*) + url="$(download_url_for_tag "$tag")" + if asset_available "$url"; then + printf '%s' "$tag" + return 0 + fi + ;; + esac + done <&2 + while IFS= read -r tag; do + [ -n "$tag" ] || continue + url="$(download_url_for_tag "$tag")" + if asset_available "$url"; then + printf '%s' "$tag" + return 0 + fi + done <&2 + fi + + if [ "$CHANNEL" = "stable" ]; then + url="${GITHUB}/releases/latest/download/${asset}" + if asset_available "$url"; then + printf '%s' "$url" + return 0 + fi + fi + + tag="$(pick_tag_from_html)" + [ -n "$tag" ] || die "could not resolve a GitHub release for ${asset}" + printf '%s' "$(download_url_for_tag "$tag")" +} echo "channel=${CHANNEL} os=${os} arch=${arch}" +url="$(resolve_download_url)" echo "Downloading ${url}" tmp="$(mktemp)" trap 'rm -f "$tmp"' EXIT -if ! curl -fsSL "$url" -o "$tmp"; then +if ! curl_ua -fsSL "$url" -o "$tmp"; then die "download failed: $url" fi if [ ! -s "$tmp" ]; then diff --git a/src/channel.rs b/src/channel.rs index bc3dedf..bcb5a72 100644 --- a/src/channel.rs +++ b/src/channel.rs @@ -2,13 +2,51 @@ //! Pure: parse fixture JSON, pick a release, build download URLs. No network. use std::cmp::Ordering; +use std::collections::BTreeMap; pub const GITHUB_REPO: &str = "anyrouter-dev/cli"; pub const GITHUB_RELEASES_API: &str = "https://api.github.com/repos/anyrouter-dev/cli/releases"; +pub const GITHUB_RELEASES_HTML: &str = "https://github.com/anyrouter-dev/cli/releases"; +pub const GITHUB_EXPANDED_ASSETS_PREFIX: &str = + "https://github.com/anyrouter-dev/cli/releases/expanded_assets/"; pub const GITHUB_DOWNLOAD_PREFIX: &str = "https://github.com/anyrouter-dev/cli/releases/download/"; pub const GITHUB_LATEST_DOWNLOAD: &str = "https://github.com/anyrouter-dev/cli/releases/latest/download"; +/// Token for `api.github.com`. Never send these to AnyRouter. +pub fn github_token(env: &BTreeMap) -> Option<&str> { + for key in ["GH_TOKEN", "GITHUB_TOKEN", "ANYR_GITHUB_TOKEN"] { + if let Some(v) = env.get(key).map(|s| s.trim()).filter(|s| !s.is_empty()) { + return Some(v); + } + } + None +} + +/// Actionable GitHub Releases HTTP error. Never the bare +/// `GitHub Releases API HTTP 403` line (rate-limited unauth quota). +pub fn releases_http_error(status: u16, body: &str) -> String { + let snippet: String = body + .trim() + .chars() + .filter(|c| !c.is_control()) + .take(160) + .collect(); + let rate_limited = status == 403 || status == 429; + let hint = if rate_limited { + " Unauthenticated api.github.com requests are rate-limited from some networks. \ +Set GH_TOKEN or GITHUB_TOKEN (public-repo scope) and retry, or install with: \ +curl -fsSL https://anyrouter.dev/setup.sh | bash" + } else { + "" + }; + if snippet.is_empty() { + format!("GitHub Releases returned HTTP {status}.{hint}") + } else { + format!("GitHub Releases returned HTTP {status}: {snippet}.{hint}") + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Channel { Stable, @@ -31,7 +69,7 @@ impl Channel { } } - pub fn from_env(env: &std::collections::BTreeMap) -> Result { + pub fn from_env(env: &BTreeMap) -> Result { match env.get("ANYR_CHANNEL") { Some(v) if !v.trim().is_empty() => Channel::parse(v), _ => Ok(Channel::Stable), @@ -243,6 +281,152 @@ pub fn select_latest_release(releases: &[Release], channel: Channel) -> Result Result { + let named: Vec = releases + .iter() + .filter(|rel| rel.assets.iter().any(|a| a.name == asset)) + .cloned() + .collect(); + if let Ok(rel) = select_latest_release(&named, channel) { + return Ok(rel); + } + let nonempty: Vec = releases + .iter() + .filter(|rel| !rel.assets.is_empty()) + .cloned() + .collect(); + match select_latest_release(&nonempty, channel) { + Ok(rel) => Ok(rel), + Err(_) => match channel { + Channel::Stable => Err(format!( + "No stable GitHub release has {asset} (latest non-prerelease may be empty). \ +Try `anyr update --beta`." + )), + Channel::Beta => Err(format!("No beta (prerelease) has {asset}.")), + }, + } +} + +fn href_end(s: &str) -> usize { + s.find(|c: char| { + matches!( + c, + '"' | '\'' | '<' | '>' | ' ' | '\n' | '\r' | '\t' | '#' | '?' + ) + }) + .unwrap_or(s.len()) +} + +fn find_all_after<'a>(html: &'a str, prefix: &str) -> Vec<(usize, &'a str)> { + let mut out = Vec::new(); + let mut search_from = 0; + while let Some(rel) = html[search_from..].find(prefix) { + let start = search_from + rel + prefix.len(); + let after = &html[start..]; + let end = href_end(after); + if end > 0 { + out.push((start, &after[..end])); + } + search_from = start + end.max(1); + } + out +} + +/// Tag names and prerelease flags from a GitHub `/releases` HTML page. +pub fn parse_release_tags_html(html: &str) -> Vec<(String, bool)> { + let found = find_all_after(html, "anyrouter-dev/cli/releases/tag/"); + let mut out = Vec::new(); + let mut seen = BTreeMap::new(); + for (i, (start, tag)) in found.iter().enumerate() { + if !tag.starts_with('v') || tag.contains('/') { + continue; + } + if seen.contains_key(*tag) { + continue; + } + let after_tag = start + tag.len(); + let next_start = found + .iter() + .skip(i + 1) + .map(|(s, _)| *s) + .find(|s| *s > after_tag) + .unwrap_or(html.len()); + let window = &html[after_tag..next_start.min(html.len())]; + // Hyphen tags are this repo's prereleases; also honor GitHub's label + // in the block that follows this tag (not the previous release). + let prerelease = tag.contains('-') || window.contains("Pre-release"); + seen.insert(tag.to_string(), prerelease); + out.push((tag.to_string(), prerelease)); + } + out +} + +/// Asset hrefs from a release page or `expanded_assets` HTML fragment. +pub fn parse_download_hrefs(html: &str) -> BTreeMap> { + let mut by_tag: BTreeMap> = BTreeMap::new(); + for (_, spec) in find_all_after(html, "anyrouter-dev/cli/releases/download/") { + let Some((tag, name)) = spec.split_once('/') else { + continue; + }; + if tag.is_empty() || name.is_empty() { + continue; + } + let url = format!("{GITHUB_DOWNLOAD_PREFIX}{tag}/{name}"); + let assets = by_tag.entry(tag.to_string()).or_default(); + if assets.iter().any(|a| a.name == name) { + continue; + } + assets.push(ReleaseAsset { + name: name.to_string(), + browser_download_url: url, + }); + } + by_tag +} + +/// GitHub `/releases` HTML → `Release` list (tags + any download hrefs present). +pub fn parse_releases_html(html: &str) -> Result, String> { + let mut by_tag = parse_download_hrefs(html); + let tags = parse_release_tags_html(html); + if tags.is_empty() && by_tag.is_empty() { + return Err("no GitHub release tags found in HTML".into()); + } + let mut out = Vec::new(); + for (tag, prerelease) in tags { + let assets = by_tag.remove(&tag).unwrap_or_default(); + out.push(Release { + tag_name: tag, + prerelease, + assets, + }); + } + for (tag, assets) in by_tag { + let prerelease = tag.contains('-'); + out.push(Release { + tag_name: tag, + prerelease, + assets, + }); + } + Ok(out) +} + +pub fn merge_expanded_assets(release: &mut Release, html: &str) { + let Some(assets) = parse_download_hrefs(html).remove(&release.tag_name) else { + return; + }; + if !assets.is_empty() { + release.assets = assets; + } +} + /// Download URL for `anyr-{os}-{arch}` on this release. /// Prefers `browser_download_url` from the asset list; otherwise constructs /// `https://github.com/anyrouter-dev/cli/releases/download/{tag}/{asset}`. @@ -312,10 +496,101 @@ mod tests { #[test] fn from_env_defaults_stable() { - let env = std::collections::BTreeMap::new(); + let env = BTreeMap::new(); assert_eq!(Channel::from_env(&env).unwrap(), Channel::Stable); - let mut env = std::collections::BTreeMap::new(); + let mut env = BTreeMap::new(); env.insert("ANYR_CHANNEL".into(), "beta".into()); assert_eq!(Channel::from_env(&env).unwrap(), Channel::Beta); } + + #[test] + fn github_token_prefers_gh_token() { + let mut env = BTreeMap::new(); + env.insert("GITHUB_TOKEN".into(), "ghs_other".into()); + env.insert("GH_TOKEN".into(), "ghs_preferred".into()); + assert_eq!(github_token(&env), Some("ghs_preferred")); + let empty = BTreeMap::new(); + assert_eq!(github_token(&empty), None); + } + + #[test] + fn releases_http_error_403_is_not_bare_api_line() { + let err = releases_http_error(403, "API rate limit exceeded for 1.2.3.4"); + assert_ne!(err, "GitHub Releases API HTTP 403"); + assert!(!err.starts_with("GitHub Releases API HTTP "), "{err}"); + assert!(err.contains("403"), "{err}"); + assert!( + err.contains("GH_TOKEN") || err.contains("GITHUB_TOKEN"), + "{err}" + ); + assert!(err.contains("rate-limited"), "{err}"); + assert!(err.contains("setup.sh"), "{err}"); + } + + #[test] + fn releases_http_error_other_status_keeps_code() { + let err = releases_http_error(500, ""); + assert!(err.contains("500"), "{err}"); + assert_ne!(err, "GitHub Releases API HTTP 500"); + assert!(!err.contains("GH_TOKEN"), "{err}"); + } + + const HTML_LISTING: &str = r#" +v0.1.11 +Latest +v0.1.12-beta.98 +Pre-release +anyr-linux-x86_64 +anyr-darwin-arm64 +"#; + + #[test] + fn parse_releases_html_skips_empty_stable_and_keeps_beta_assets() { + let rels = parse_releases_html(HTML_LISTING).unwrap(); + let stable = rels.iter().find(|r| r.tag_name == "v0.1.11").unwrap(); + assert!(!stable.prerelease); + assert!(stable.assets.is_empty(), "{stable:?}"); + let beta = rels + .iter() + .find(|r| r.tag_name == "v0.1.12-beta.98") + .unwrap(); + assert!(beta.prerelease); + assert!( + beta.assets.iter().any(|a| a.name == "anyr-linux-x86_64"), + "{beta:?}" + ); + assert!(beta.assets.iter().any(|a| a.browser_download_url + == "https://github.com/anyrouter-dev/cli/releases/download/v0.1.12-beta.98/anyr-linux-x86_64")); + } + + #[test] + fn parse_expanded_assets_html_lists_linux_x86_64() { + let html = r#"anyr-linux-x86_64"#; + let mut rel = Release { + tag_name: "v0.1.12-beta.98".into(), + prerelease: true, + assets: Vec::new(), + }; + merge_expanded_assets(&mut rel, html); + assert_eq!(rel.assets.len(), 1); + assert_eq!(rel.assets[0].name, "anyr-linux-x86_64"); + } + + const EMPTY_STABLE: &str = r#"[ + {"tag_name":"v0.1.11","prerelease":false,"assets":[]}, + {"tag_name":"v0.1.12-beta.98","prerelease":true,"assets":[{"name":"anyr-linux-x86_64","browser_download_url":"https://github.com/anyrouter-dev/cli/releases/download/v0.1.12-beta.98/anyr-linux-x86_64"}]} +]"#; + + #[test] + fn select_latest_with_asset_skips_empty_stable() { + let rels = parse_releases(EMPTY_STABLE).unwrap(); + let err = select_latest_release_with_asset(&rels, Channel::Stable, "anyr-linux-x86_64") + .unwrap_err(); + assert!(err.contains("anyr-linux-x86_64"), "{err}"); + assert!(err.contains("update --beta"), "{err}"); + assert!(!err.contains("GitHub Releases API HTTP 403"), "{err}"); + let beta = + select_latest_release_with_asset(&rels, Channel::Beta, "anyr-linux-x86_64").unwrap(); + assert_eq!(beta.tag_name, "v0.1.12-beta.98"); + } } diff --git a/src/http.rs b/src/http.rs index 5fa3530..13e05a7 100644 --- a/src/http.rs +++ b/src/http.rs @@ -57,6 +57,43 @@ pub fn http_get(url: &str, api_key: Option<&str>) -> Result<(u16, String), Strin into_status_body(with_auth(agent().get(url), api_key).call()) } +#[cfg(feature = "native")] +fn into_status_body_labeled( + result: Result, + service: &str, +) -> Result<(u16, String), String> { + match result { + Ok(resp) => { + let status = resp.status(); + let body = resp.into_string().unwrap_or_default(); + Ok((status, body)) + } + Err(ureq::Error::Status(code, resp)) => { + let body = resp.into_string().unwrap_or_default(); + Ok((code, body)) + } + Err(err) => Err(format!("Could not reach {service}: {err}")), + } +} + +/// Authenticated GitHub REST GET. `token` must be non-empty — never call +/// `api.github.com` without it (unauth quota returns 403 from shared IPs). +#[cfg(feature = "native")] +pub fn http_get_github(url: &str, token: &str) -> Result<(u16, String), String> { + let req = agent() + .get(url) + .set("Accept", "application/vnd.github+json") + .set("X-GitHub-Api-Version", "2022-11-28") + .set("Authorization", &format!("Bearer {token}")); + into_status_body_labeled(req.call(), "GitHub Releases") +} + +/// `github.com` HTML (releases listing / expanded_assets). Not the REST API. +#[cfg(feature = "native")] +pub fn http_get_web(url: &str) -> Result<(u16, String), String> { + into_status_body_labeled(agent().get(url).call(), "GitHub") +} + #[cfg(feature = "native")] pub fn http_post( url: &str, @@ -92,6 +129,16 @@ pub fn http_get(_url: &str, _api_key: Option<&str>) -> Result<(u16, String), Str no_network() } +#[cfg(not(feature = "native"))] +pub fn http_get_github(_url: &str, _token: &str) -> Result<(u16, String), String> { + no_network() +} + +#[cfg(not(feature = "native"))] +pub fn http_get_web(_url: &str) -> Result<(u16, String), String> { + no_network() +} + #[cfg(not(feature = "native"))] pub fn http_post( _url: &str, diff --git a/src/upgrade.rs b/src/upgrade.rs index 509b016..f9bb2e0 100644 --- a/src/upgrade.rs +++ b/src/upgrade.rs @@ -11,10 +11,12 @@ use std::path::{Path, PathBuf}; use std::time::{Duration, SystemTime, UNIX_EPOCH}; use crate::channel::{ - current_arch, current_os, release_asset_url, select_latest, Channel, GITHUB_RELEASES_API, + asset_name, current_arch, current_os, github_token, merge_expanded_assets, parse_releases, + parse_releases_html, release_asset_url, releases_http_error, select_latest_release_with_asset, + Channel, Release, GITHUB_EXPANDED_ASSETS_PREFIX, GITHUB_RELEASES_API, GITHUB_RELEASES_HTML, }; use crate::config::{resolve_config_path, write_config, Config}; -use crate::http::http_get; +use crate::http::{http_get_github, http_get_web}; use crate::key::load_config_if_present; use crate::parse::{get_string_flag, ParsedArgs}; use crate::spawn::redact_value; @@ -54,16 +56,84 @@ pub fn fixture_path(parsed: &ParsedArgs, env: &BTreeMap) -> Opti .filter(|s| !s.trim().is_empty()) } -pub fn load_releases_json(fixture: Option<&str>) -> Result { +/// Where `anyr update` lists GitHub Releases. Never unauthenticated REST. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ReleasesFetch { + Fixture, + AuthenticatedApi, + GithubHtml, +} + +impl ReleasesFetch { + pub fn from_context(fixture: Option<&str>, env: &BTreeMap) -> Self { + if fixture.is_some() { + Self::Fixture + } else if github_token(env).is_some() { + Self::AuthenticatedApi + } else { + Self::GithubHtml + } + } +} + +pub fn load_releases( + fixture: Option<&str>, + env: &BTreeMap, +) -> Result, String> { if let Some(path) = fixture { - return fs::read_to_string(path) - .map_err(|e| format!("could not read releases fixture {path}: {e}")); + let json = fs::read_to_string(path) + .map_err(|e| format!("could not read releases fixture {path}: {e}"))?; + return parse_releases(&json); + } + fetch_releases(env) +} + +fn fetch_releases(env: &BTreeMap) -> Result, String> { + let mut api_err = None; + if let Some(token) = github_token(env) { + match fetch_releases_api(token) { + Ok(rels) => return Ok(rels), + Err(err) => api_err = Some(err), + } + } + match fetch_releases_html() { + Ok(rels) => Ok(rels), + Err(html_err) => Err(match api_err { + Some(api) => format!( + "{api} Fell back to github.com/releases (not the REST API), which also failed: {html_err}" + ), + None => html_err, + }), } - let (status, body) = http_get(GITHUB_RELEASES_API, None)?; +} + +fn fetch_releases_api(token: &str) -> Result, String> { + let (status, body) = http_get_github(GITHUB_RELEASES_API, token)?; if !(200..300).contains(&status) { - return Err(format!("GitHub Releases API HTTP {status}")); + return Err(releases_http_error(status, &body)); + } + parse_releases(&body) +} + +fn fetch_releases_html() -> Result, String> { + let (status, html) = http_get_web(GITHUB_RELEASES_HTML)?; + if !(200..300).contains(&status) { + return Err(releases_http_error(status, &html)); + } + let mut releases = parse_releases_html(&html)?; + for rel in &mut releases { + if !rel.assets.is_empty() { + continue; + } + let url = format!("{GITHUB_EXPANDED_ASSETS_PREFIX}{}", rel.tag_name); + match http_get_web(&url) { + Ok((st, body)) if (200..300).contains(&st) => { + merge_expanded_assets(rel, &body); + } + _ => {} + } } - Ok(body) + Ok(releases) } /// `--beta` / `--stable` switch the persisted channel. Mutually exclusive with @@ -141,6 +211,9 @@ fn print_redacted_env(env: &BTreeMap) { "ANYR_RELEASES_JSON", "ANYROUTER_API_KEY", "ANYR_SETUP_BIN", + "GH_TOKEN", + "GITHUB_TOKEN", + "ANYR_GITHUB_TOKEN", ] { if let Some(value) = env.get(key) { println!("{key}={}", redact_printed_value(key, value)); @@ -368,14 +441,15 @@ fn run_auto(parsed: &ParsedArgs, env: &BTreeMap) -> Result j, Err(_) => { write_stamp(env); return Ok(0); } }; - let latest = match select_latest(&json, channel) { + let asset = asset_name(current_os(), current_arch()); + let latest = match select_latest_release_with_asset(&releases, channel, &asset) { Ok(rel) => rel, Err(_) => { write_stamp(env); @@ -503,10 +577,11 @@ pub fn run(parsed: &ParsedArgs, env: &BTreeMap) -> Result {} + other => panic!("expected Fixture, got {other:?}"), + } + + let mut env = BTreeMap::new(); + env.insert("GH_TOKEN".into(), "ghs_test".into()); + match ReleasesFetch::from_context(None, &env) { + ReleasesFetch::AuthenticatedApi => {} + other => panic!("expected AuthenticatedApi, got {other:?}"), + } + + match ReleasesFetch::from_context(None, &BTreeMap::new()) { + ReleasesFetch::GithubHtml => {} + other => panic!("token-less fetch must not hit unauth REST, got {other:?}"), + } + + fn label(src: ReleasesFetch) -> &'static str { + match src { + ReleasesFetch::Fixture => "fixture", + ReleasesFetch::AuthenticatedApi => "api", + ReleasesFetch::GithubHtml => "html", + } + } + assert_eq!(label(ReleasesFetch::GithubHtml), "html"); + } + + #[test] + fn load_releases_fixture_skips_empty_stable_for_linux_x86_64() { + let json = r#"[ + {"tag_name":"v0.1.11","prerelease":false,"assets":[]}, + {"tag_name":"v0.1.12-beta.98","prerelease":true,"assets":[{"name":"anyr-linux-x86_64","browser_download_url":"https://github.com/anyrouter-dev/cli/releases/download/v0.1.12-beta.98/anyr-linux-x86_64"}]} +]"#; + let dir = std::env::temp_dir().join(format!( + "anyr-empty-stable-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + let _ = fs::create_dir_all(&dir); + let path = dir.join("releases.json"); + fs::write(&path, json).unwrap(); + let rels = load_releases(Some(path.to_str().unwrap()), &BTreeMap::new()).unwrap(); + let err = select_latest_release_with_asset(&rels, Channel::Stable, "anyr-linux-x86_64") + .unwrap_err(); + assert_ne!(err, "GitHub Releases API HTTP 403"); + assert!(err.contains("update --beta"), "{err}"); + let beta = + select_latest_release_with_asset(&rels, Channel::Beta, "anyr-linux-x86_64").unwrap(); + assert_eq!(beta.tag_name, "v0.1.12-beta.98"); + let _ = fs::remove_dir_all(&dir); + } } diff --git a/tests/cli.rs b/tests/cli.rs index 229dbdf..fb76148 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -838,7 +838,7 @@ fn upgrade_check_flag_is_known() { )); std::fs::write( &fixture, - r#"[{"tag_name":"v0.1.0","prerelease":false,"draft":false,"assets":[{"name":"anyr-linux-x86_64"}]}]"#, + r#"[{"tag_name":"v0.1.0","prerelease":false,"draft":false,"assets":[{"name":"anyr-linux-x86_64","browser_download_url":"https://github.com/anyrouter-dev/cli/releases/download/v0.1.0/anyr-linux-x86_64"}]}]"#, ) .expect("write fixture"); let out = anyr() @@ -922,6 +922,69 @@ fn upgrade_check_beta_selects_prerelease() { assert!(stdout.contains("update available"), "{stdout}"); } +fn empty_latest_fixture() -> std::path::PathBuf { + std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("tests/fixtures/releases-empty-latest.json") +} + +#[test] +fn update_stable_empty_latest_is_actionable_not_http_403() { + let (code, stdout, stderr) = { + let out = anyr() + .args(["update", "--check", "--channel", "stable"]) + .env("ANYR_RELEASES_JSON", empty_latest_fixture()) + .env_remove("ANYR_CHANNEL") + .env_remove("GH_TOKEN") + .env_remove("GITHUB_TOKEN") + .output() + .expect("update --check empty stable"); + ( + out.status.code().unwrap_or(1), + String::from_utf8_lossy(&out.stdout).into_owned(), + String::from_utf8_lossy(&out.stderr).into_owned(), + ) + }; + let combined = format!("{stdout}{stderr}"); + assert_ne!(code, 0, "empty stable should fail:\n{combined}"); + assert!( + !combined.contains("GitHub Releases API HTTP 403"), + "bare 403:\n{combined}" + ); + assert!( + combined.contains("update --beta") || combined.contains("anyr-linux-x86_64"), + "{combined}" + ); +} + +#[test] +fn update_beta_empty_latest_catalog_selects_prerelease() { + let (code, stdout, stderr) = { + let out = anyr() + .args(["update", "--check", "--channel", "beta"]) + .env("ANYR_RELEASES_JSON", empty_latest_fixture()) + .env_remove("GH_TOKEN") + .env_remove("GITHUB_TOKEN") + .output() + .expect("update --check beta empty-latest catalog"); + ( + out.status.code().unwrap_or(1), + String::from_utf8_lossy(&out.stdout).into_owned(), + String::from_utf8_lossy(&out.stderr).into_owned(), + ) + }; + let combined = format!("{stdout}{stderr}"); + assert_eq!(code, 0, "{combined}"); + assert!( + !combined.contains("GitHub Releases API HTTP 403"), + "{combined}" + ); + assert!(stdout.contains("channel: beta"), "{stdout}"); + assert!( + stdout.contains("latest:") && stdout.contains("0.1.12-beta.98"), + "{stdout}" + ); +} + #[test] fn login_help_describes_device_and_paste() { let (code, stdout, stderr) = run(&["login", "--help"]); diff --git a/tests/fake-curl.py b/tests/fake-curl.py new file mode 100755 index 0000000..5505c7c --- /dev/null +++ b/tests/fake-curl.py @@ -0,0 +1,109 @@ +#!/usr/bin/env python3 +"""Stand-in curl for setup.sh tests. Never talks to the network.""" +from __future__ import annotations + +import os +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent +LISTING = ROOT / "fixtures" / "releases-listing.html" +API_JSON = ROOT / "fixtures" / "releases-empty-latest.json" +ELF = b"\x7fELF" + b"\x00" * 32 + + +def parse_args(argv: list[str]) -> tuple[str, dict]: + url = "" + opts: dict = { + "head": False, + "fail": False, + "output": None, + "write_out": None, + "headers": [], + } + i = 0 + while i < len(argv): + a = argv[i] + if a.startswith("http://") or a.startswith("https://"): + url = a + elif a in ("-o", "--output"): + i += 1 + opts["output"] = argv[i] + elif a in ("-w", "--write-out"): + i += 1 + opts["write_out"] = argv[i] + elif a == "-H": + i += 1 + opts["headers"].append(argv[i]) + elif a == "-A": + i += 1 + elif a.startswith("-") and not a.startswith("--"): + flags = a[1:] + if "I" in flags: + opts["head"] = True + if "f" in flags: + opts["fail"] = True + i += 1 + if not url: + sys.stderr.write("fake-curl: no URL\n") + sys.exit(2) + return url, opts + + +def classify(url: str) -> str: + if "api.github.com" in url: + return "api" + if "/releases/latest/download/" in url: + return "latest" + if "/releases/download/v0.1.11/" in url: + return "empty-tag" + if "/releases/download/v0.1.12-beta.98/" in url: + return "beta-asset" + if url.rstrip("/").endswith("/anyrouter-dev/cli/releases"): + return "listing" + if "/expanded_assets/" in url: + return "expanded" + return "other" + + +def body_and_code(url: str, headers: list[str]) -> tuple[int, bytes]: + kind = classify(url) + if kind == "api": + authed = any("Authorization:" in h for h in headers) + if not authed: + return 403, b'{"message":"API rate limit exceeded"}' + return 200, API_JSON.read_bytes() + if kind in ("latest", "empty-tag"): + return 404, b"Not Found" + if kind == "beta-asset": + return 200, ELF + if kind == "listing": + return 200, LISTING.read_bytes() + if kind == "expanded": + return 200, ( + b'anyr-linux-x86_64' + ) + return 404, b"unexpected URL: " + url.encode() + + +def main() -> None: + log_path = os.environ.get("FAKE_CURL_LOG") + if log_path: + with open(log_path, "a", encoding="utf-8") as fh: + fh.write(" ".join(sys.argv[1:]) + "\n") + url, opts = parse_args(sys.argv[1:]) + code, body = body_and_code(url, opts["headers"]) + if opts["write_out"]: + sys.stdout.write(opts["write_out"].replace("%{http_code}", str(code))) + if opts["output"]: + Path(opts["output"]).write_bytes(b"" if opts["head"] else body) + elif not opts["head"] and not opts["write_out"]: + sys.stdout.buffer.write(body) + if opts["fail"] and not (200 <= code < 400): + sys.exit(22) + sys.exit(0) + + +if __name__ == "__main__": + main() diff --git a/tests/fixtures/releases-empty-latest.json b/tests/fixtures/releases-empty-latest.json new file mode 100644 index 0000000..3929e88 --- /dev/null +++ b/tests/fixtures/releases-empty-latest.json @@ -0,0 +1,31 @@ +[ + { + "tag_name": "v0.1.11", + "prerelease": false, + "draft": false, + "assets": [] + }, + { + "tag_name": "v0.1.12-beta.98", + "prerelease": true, + "draft": false, + "assets": [ + { + "name": "anyr-linux-x86_64", + "browser_download_url": "https://github.com/anyrouter-dev/cli/releases/download/v0.1.12-beta.98/anyr-linux-x86_64" + }, + { + "name": "anyr-linux-arm64", + "browser_download_url": "https://github.com/anyrouter-dev/cli/releases/download/v0.1.12-beta.98/anyr-linux-arm64" + }, + { + "name": "anyr-darwin-x86_64", + "browser_download_url": "https://github.com/anyrouter-dev/cli/releases/download/v0.1.12-beta.98/anyr-darwin-x86_64" + }, + { + "name": "anyr-darwin-arm64", + "browser_download_url": "https://github.com/anyrouter-dev/cli/releases/download/v0.1.12-beta.98/anyr-darwin-arm64" + } + ] + } +] diff --git a/tests/fixtures/releases-listing.html b/tests/fixtures/releases-listing.html new file mode 100644 index 0000000..027abc3 --- /dev/null +++ b/tests/fixtures/releases-listing.html @@ -0,0 +1,9 @@ + + + + v0.1.11 + Latest + v0.1.12-beta.98 + Pre-release + + diff --git a/tests/setup-sh.test.sh b/tests/setup-sh.test.sh index 806ce30..762acc2 100644 --- a/tests/setup-sh.test.sh +++ b/tests/setup-sh.test.sh @@ -61,4 +61,101 @@ echo "$help_out" | grep -q 'AnyRouter CLI' echo "$help_out" | grep -q 'login' echo "$help_out" | grep -q 'claude' +# --- 404 / 403 paths: fake curl, no network --- +FAKE_CURL="${ROOT}/tests/fake-curl.py" +[ -f "$FAKE_CURL" ] || { + echo "missing ${FAKE_CURL}" >&2 + exit 1 +} +chmod +x "$FAKE_CURL" + +grep -q 'GH_TOKEN' "$SETUP" +grep -q 'never calls api.github.com' "$SETUP" + +: >"${tmpdir}/curl.log" +empty_latest_bin="${tmpdir}/bin-empty-latest" +mkdir -p "$empty_latest_bin" +echo "setup.sh stable with empty /latest (no token, no api.github.com)" +# CI injects GITHUB_TOKEN; this path must not use the REST API. +if ! env -u GH_TOKEN -u GITHUB_TOKEN \ + FAKE_CURL_LOG="${tmpdir}/curl.log" \ + ANYR_CURL="$FAKE_CURL" \ + ANYR_BIN_DIR="$empty_latest_bin" \ + bash "$SETUP"; then + echo "setup.sh failed on empty /latest fallback" >&2 + cat "${tmpdir}/curl.log" >&2 || true + exit 1 +fi +[ -x "${empty_latest_bin}/anyr" ] || { + echo "stable fallback did not install anyr" >&2 + exit 1 +} +if grep -q 'api.github.com' "${tmpdir}/curl.log"; then + echo "setup.sh must not call api.github.com without a token:" >&2 + cat "${tmpdir}/curl.log" >&2 + exit 1 +fi +if grep -q 'releases/download/v0.1.12-beta.98/' "${tmpdir}/curl.log"; then + : +else + echo "expected download of v0.1.12-beta.98 (release with binaries):" >&2 + cat "${tmpdir}/curl.log" >&2 + exit 1 +fi +python3 -c 'import sys; sys.exit(0 if open(sys.argv[1],"rb").read(4)==b"\x7fELF" else 1)' \ + "${empty_latest_bin}/anyr" || { + echo "installed file is not the fake ELF asset" >&2 + exit 1 +} + +: >"${tmpdir}/curl.log" +token_bin="${tmpdir}/bin-token" +mkdir -p "$token_bin" +echo "setup.sh stable with GH_TOKEN uses authenticated API" +env -u GITHUB_TOKEN \ + GH_TOKEN="ghs_test_token" \ + FAKE_CURL_LOG="${tmpdir}/curl.log" \ + ANYR_CURL="$FAKE_CURL" \ + ANYR_BIN_DIR="$token_bin" \ + bash "$SETUP" +[ -x "${token_bin}/anyr" ] +if grep -q 'Authorization: Bearer ghs_test_token' "${tmpdir}/curl.log"; then + : +else + echo "expected authenticated GitHub API request:" >&2 + cat "${tmpdir}/curl.log" >&2 + exit 1 +fi +if grep -q 'api.github.com' "${tmpdir}/curl.log"; then + : +else + echo "token path should call api.github.com:" >&2 + cat "${tmpdir}/curl.log" >&2 + exit 1 +fi + +: >"${tmpdir}/curl.log" +beta_bin="${tmpdir}/bin-beta" +mkdir -p "$beta_bin" +echo "setup.sh --channel beta (no token, no api.github.com)" +env -u GH_TOKEN -u GITHUB_TOKEN \ + ANYR_CHANNEL=beta \ + FAKE_CURL_LOG="${tmpdir}/curl.log" \ + ANYR_CURL="$FAKE_CURL" \ + ANYR_BIN_DIR="$beta_bin" \ + bash "$SETUP" +[ -x "${beta_bin}/anyr" ] +if grep -q 'api.github.com' "${tmpdir}/curl.log"; then + echo "beta without token must not call api.github.com:" >&2 + cat "${tmpdir}/curl.log" >&2 + exit 1 +fi +if grep -q 'releases/download/v0.1.12-beta.98/' "${tmpdir}/curl.log"; then + : +else + echo "expected beta download of v0.1.12-beta.98:" >&2 + cat "${tmpdir}/curl.log" >&2 + exit 1 +fi + echo "setup-sh.test.sh ok"