| 262 | // GitHub fetch — kept thin, not unit-tested |
| 263 | |
| 264 | async fn fetch_latest(env: &dyn EnvLookup) -> Result<String, CliError> { |
| 265 | let url = "https://api.github.com/repos/atomicdotdev/atomic/releases/latest"; |
| 266 | let ua = concat!("atomic-cli/", env!("CARGO_PKG_VERSION")); |
| 267 | |
| 268 | let client = reqwest::Client::builder() |
| 269 | .timeout(std::time::Duration::from_secs(10)) |
| 270 | .build() |
| 271 | .map_err(|e| CliError::RemoteError { |
| 272 | message: format!("HTTP client init failed: {e}"), |
| 273 | url: Some(url.to_string()), |
| 274 | })?; |
| 275 | |
| 276 | let mut req = client |
| 277 | .get(url) |
| 278 | .header(reqwest::header::USER_AGENT, ua) |
| 279 | .header(reqwest::header::ACCEPT, "application/vnd.github+json"); |
| 280 | |
| 281 | // Authenticated requests get 5000/hr instead of 60/hr. Useful for CI |
| 282 | // running `atomic update --check`. Empty/whitespace tokens are |
| 283 | // ignored so a stray `export GITHUB_TOKEN=` doesn't break auth. |
| 284 | if let Some(token) = env.var("GITHUB_TOKEN") { |
| 285 | if !token.trim().is_empty() { |
| 286 | req = req.bearer_auth(token); |
| 287 | } |
| 288 | } |
| 289 | |
| 290 | let resp = req.send().await.map_err(|e| CliError::RemoteError { |
| 291 | message: format!("Failed to reach GitHub: {e}"), |
| 292 | url: Some(url.to_string()), |
| 293 | })?; |
| 294 | |
| 295 | if resp.status() == reqwest::StatusCode::FORBIDDEN { |
| 296 | return Err(CliError::RemoteError { |
| 297 | message: "GitHub API rate limit hit (60/hr unauthenticated, 5000/hr with GITHUB_TOKEN). Try again later or export GITHUB_TOKEN.".to_string(), |
| 298 | url: Some(url.to_string()), |
| 299 | }); |
| 300 | } |
| 301 | if !resp.status().is_success() { |
| 302 | return Err(CliError::RemoteError { |
| 303 | message: format!("GitHub returned HTTP {}", resp.status()), |
| 304 | url: Some(url.to_string()), |
| 305 | }); |
| 306 | } |
| 307 | |
| 308 | let release: GithubReleaseLatest = resp.json().await.map_err(|e| CliError::RemoteError { |
| 309 | message: format!("Failed to parse GitHub response: {e}"), |
| 310 | url: Some(url.to_string()), |
| 311 | })?; |
| 312 | |
| 313 | Ok(release.tag_name) |
| 314 | } |
| 315 | |
| 316 | // Message formatting — all printing helpers are pure so tests can capture output |
| 317 | |