diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 243f893..975afd4 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "0.1.14" + ".": "0.1.15" } diff --git a/CHANGELOG.md b/CHANGELOG.md index 5677392..4920761 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,16 @@ This file is maintained automatically by [release-please](https://github.com/goo GitHub Releases use this file as the release notes (full history through that tag). +## [0.1.15](https://github.com/anyrouter-dev/cli/compare/v0.1.14...v0.1.15) (2026-09-16) + + +### Bug Fixes + +* **cli:** keep Claude HUD floors on virtual presets end-to-end (ANTHROPIC_MODEL, extra-body min_context, compact window, no catalog remap) +* **cli:** bare `anyr update` keeps the config channel; only `--beta`/`--stable` persist a switch +* **cli:** skip a broken latest GitHub release (checksum / missing asset) and install the next good build on that channel + + ## [0.1.14](https://github.com/anyrouter-dev/cli/compare/v0.1.13...v0.1.14) (2026-09-15) diff --git a/Cargo.lock b/Cargo.lock index 818c862..5a78169 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -16,7 +16,7 @@ checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" [[package]] name = "anyr-cli" -version = "0.1.13" +version = "0.1.15" dependencies = [ "crossterm", "libc", diff --git a/Cargo.toml b/Cargo.toml index 38f94b2..1799066 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "anyr-cli" -version = "0.1.14" # x-release-please-version +version = "0.1.15" # x-release-please-version edition = "2021" description = "AnyRouter CLI — native binary" license = "MIT" diff --git a/package.json b/package.json index 83898ee..f3e54d6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@anyr/cli", - "version": "0.1.14", + "version": "0.1.15", "description": "AnyRouter CLI — native binary", "license": "MIT", "homepage": "https://anyrouter.dev/cli", diff --git a/src/channel.rs b/src/channel.rs index 6964972..2622b0b 100644 --- a/src/channel.rs +++ b/src/channel.rs @@ -281,6 +281,39 @@ pub fn select_latest_release(releases: &[Release], channel: Channel) -> Result bool { + match channel { + Channel::Stable => !rel.prerelease, + Channel::Beta => rel.prerelease, + } +} + +/// Newest-first releases on `channel` that look installable. +/// Prefers rows that list `asset`; otherwise any non-empty asset list. +/// Empty-asset stables such as v0.1.11 are omitted so we do not 404 `/latest`. +pub fn channel_update_candidates( + releases: &[Release], + channel: Channel, + asset: &str, +) -> Vec { + let mut named: Vec<(Version, Release)> = releases + .iter() + .filter(|rel| channel_matches(rel, channel)) + .filter(|rel| rel.assets.iter().any(|a| a.name == asset)) + .filter_map(|rel| parse_version(&rel.tag_name).map(|v| (v, rel.clone()))) + .collect(); + if named.is_empty() { + named = releases + .iter() + .filter(|rel| channel_matches(rel, channel)) + .filter(|rel| !rel.assets.is_empty()) + .filter_map(|rel| parse_version(&rel.tag_name).map(|v| (v, rel.clone()))) + .collect(); + } + named.sort_by(|a, b| b.0.cmp(&a.0)); + named.into_iter().map(|(_, r)| r).collect() +} + /// Latest release on `channel` that has binaries. /// Prefers a release that lists `asset`; otherwise any non-empty asset list. /// Empty-asset stables such as v0.1.11 are skipped so we do not 404 `/latest`. @@ -289,29 +322,16 @@ pub fn select_latest_release_with_asset( channel: Channel, asset: &str, ) -> 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!( + channel_update_candidates(releases, channel, asset) + .into_iter() + .next() + .ok_or_else(|| match channel { + Channel::Stable => 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}.")), - }, - } + ), + Channel::Beta => format!("No beta (prerelease) has {asset}."), + }) } fn href_end(s: &str) -> usize { @@ -625,6 +645,22 @@ mod tests { {"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 channel_update_candidates_newest_first_skips_empty() { + let json = r#"[ + {"tag_name":"v0.1.14","prerelease":false,"assets":[{"name":"anyr-linux-x86_64","browser_download_url":"https://example/0.1.14"}]}, + {"tag_name":"v0.1.13","prerelease":false,"assets":[{"name":"anyr-linux-x86_64","browser_download_url":"https://example/0.1.13"}]}, + {"tag_name":"v0.1.12","prerelease":false,"assets":[]}, + {"tag_name":"v0.1.15-beta.1","prerelease":true,"assets":[{"name":"anyr-linux-x86_64","browser_download_url":"https://example/beta"}]} +]"#; + let rels = parse_releases(json).unwrap(); + let tags: Vec<_> = channel_update_candidates(&rels, Channel::Stable, "anyr-linux-x86_64") + .into_iter() + .map(|r| r.tag_name) + .collect(); + assert_eq!(tags, vec!["v0.1.14".to_string(), "v0.1.13".to_string()]); + } + #[test] fn select_latest_with_asset_skips_empty_stable() { let rels = parse_releases(EMPTY_STABLE).unwrap(); diff --git a/src/cmd/launch.rs b/src/cmd/launch.rs index 26ea941..2539c23 100644 --- a/src/cmd/launch.rs +++ b/src/cmd/launch.rs @@ -10,10 +10,10 @@ use crate::key::{ }; use crate::parse::{get_string_flag, ParsedArgs}; use crate::spawn::{ - apply_model_id_routing, apply_routing_env, build_tool_env, catalog_model_id, - default_profile_for_env, effort_args_for, env_command_path, is_auto_model, model_args_for, - normalize_effort, prepare_pi_wrapper, provider_args_for, render_dry_run, resolve_tool, - spawn_child, BuildToolEnvInput, + apply_model_id_routing, apply_routing_env, build_tool_env, catalog_context_window, + catalog_model_id, default_profile_for_env, display_model_id, effort_args_for, env_command_path, + is_auto_model, is_virtual_preset, model_args_for, normalize_effort, prepare_pi_wrapper, + provider_args_for, render_dry_run, resolve_tool, spawn_child, BuildToolEnvInput, }; use crate::term; @@ -33,28 +33,26 @@ pub(crate) fn resolve_session_model( env: &BTreeMap, ) -> ResolvedModel { let requested = catalog_model_id(requested); - if !catalog_lookup_enabled(env) { + let id = if is_auto_model(&requested) { + display_model_id(&requested) + } else { + requested + }; + if is_virtual_preset(&id) || !catalog_lookup_enabled(env) { + // Virtual anyrouter/* stays the preset + floor suffix. Do not remap + // onto a catalog SKU or inherit auto's 200k listing window. return ResolvedModel { - id: requested, + id, context_window: None, }; } let Ok(models) = fetch_models(base, key) else { return ResolvedModel { - id: requested, + id, context_window: None, }; }; - // Keep `auto` / `anyrouter/auto` as-is so the gateway applies the full - // preset failover chain on the first turn — pinning a single concrete model - // here would defeat anyrouter/auto's cross-model failover (north star: - // "fewer models that work"). The live `/models` lookup still contributes the - // context-window probe used for Claude's 1M-context suffix decision below. - let id = requested; - let context_window = models - .iter() - .find(|m| is_auto_model(&m.id)) - .and_then(|m| m.context_length); + let context_window = catalog_context_window(&id, &models); ResolvedModel { id, context_window } } diff --git a/src/help.rs b/src/help.rs index 9fdbd51..8acfc2d 100644 --- a/src/help.rs +++ b/src/help.rs @@ -433,6 +433,9 @@ Usage: {bin} upgrade [--check] [--beta|--stable] [--channel stable|beta] [--dry-run] {bin} update [--beta|--stable] (alias) +Bare `{bin} update` / `{bin} update --check` keep the config channel. +Only `--beta` or `--stable` switch the channel and persist it. + Switch channel and update: {bin} update --beta follow GitHub prereleases (persist + install) {bin} update --stable follow latest non-prerelease (persist + install) @@ -463,8 +466,10 @@ While installing, a spinner ticks with the from → to versions and channel: --check reports current vs latest without installing. --fixture / ANYR_RELEASES_JSON skips the network (tests / dry-run). ---channel stable|beta overrides the config file for this run only. +--channel stable|beta overrides the config file for this run only (does not persist). --beta / --stable write channel: into the config, then install that channel. +If the newest build fails checksum, is missing, or is corrupt, the next +good release on the same channel is installed instead of aborting. "; const RELAY: &str = "\ diff --git a/src/spawn.rs b/src/spawn.rs index 0948903..59bb700 100644 --- a/src/spawn.rs +++ b/src/spawn.rs @@ -403,16 +403,14 @@ pub fn build_tool_env(input: BuildToolEnvInput<'_>) -> BTreeMap ); } if input.tool_name == "claude" { - env.insert( - "ANTHROPIC_MODEL".into(), - model_id_for_tool("claude", input.model, input.min_context), - ); + let anthropic_model = model_id_for_tool("claude", input.model, input.min_context); + env.insert("ANTHROPIC_MODEL".into(), anthropic_model); env.insert( "CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY".into(), // Discovery remaps unknown ids (including virtual `anyrouter/*`) // onto a catalog SKU such as Laguna. Keep it off for presets so the // gateway still sees the virtual id + extra-body min_context. - if input.tool.enable_gateway_model_discovery && !is_virtual_preset(input.model) { + if claude_gateway_discovery_enabled(input.tool, input.model) { "1" } else { "0" @@ -449,15 +447,7 @@ pub fn build_tool_env(input: BuildToolEnvInput<'_>) -> BTreeMap alias(&input.profile.claude_fable, input.profile.claude_fable()), ); env.insert("CLAUDE_CODE_SUBAGENT_MODEL".into(), haiku); - let floor = peel_context_window_suffixes(input.model) - .1 - .or(input.min_context); - let wants_compact = if is_virtual_preset(input.model) { - floor.is_some_and(|n| n >= MIN_1M_CONTEXT) - } else { - claude_wants_1m(input.context_window) - }; - if wants_compact { + if claude_wants_auto_compact(input.model, input.min_context, input.context_window) { env.insert("CLAUDE_CODE_AUTO_COMPACT_WINDOW".into(), "1000000".into()); } // Label each picker entry with its role; otherwise four identical IDs @@ -559,13 +549,55 @@ pub fn session_model_label(model: &str) -> String { pub fn claude_wants_1m(context_window: Option) -> bool { match context_window { Some(n) => n >= MIN_1M_CONTEXT, - // Unknown: we no longer append `[1m]` for Claude (third-party catalog - // ids 404 on the suffix), but this predicate still drives the - // `CLAUDE_CODE_AUTO_COMPACT_WINDOW` env var in `build_tool_env`. + // Unknown concrete window: still enable compact so a 1M session is not + // truncated at Claude's 200k default. Virtual presets use the floor. None => true, } } +fn merged_floor(model: &str, min_context: Option) -> Option { + match (peel_context_window_suffixes(model).1, min_context) { + (Some(a), Some(b)) => Some(a.max(b)), + (a, b) => a.or(b), + } +} + +/// Compact when the routing floor is ≥ 1M. Concrete ids without a floor still +/// use catalog `context_length` (unknown counts as yes). +pub fn claude_wants_auto_compact( + model: &str, + min_context: Option, + context_window: Option, +) -> bool { + if merged_floor(model, min_context).is_some_and(|n| n >= MIN_1M_CONTEXT) { + return true; + } + if is_virtual_preset(model) { + return false; + } + claude_wants_1m(context_window) +} + +pub fn claude_gateway_discovery_enabled(tool: &ToolConfig, model: &str) -> bool { + tool.enable_gateway_model_discovery && !is_virtual_preset(model) +} + +/// Catalog `context_length` for a concrete id. Virtual `anyrouter/*` must not +/// inherit auto's 200k listing — that would paint the HUD `[200k]`. +pub fn catalog_context_window( + requested: &str, + models: &[crate::http::CatalogModel], +) -> Option { + let id = catalog_model_id(requested); + if is_virtual_preset(&id) { + return None; + } + models + .iter() + .find(|m| catalog_model_id(&m.id) == id) + .and_then(|m| m.context_length) +} + /// Spell a token floor as `[1m]` / `[500k]` (same as `--model` suffixes). pub fn context_floor_suffix(n: i64) -> Option { if n <= 0 { @@ -580,11 +612,9 @@ pub fn context_floor_suffix(n: i64) -> Option { None } -/// Agent-specific model id. -/// -/// Claude + virtual presets (`anyrouter/auto`, `free`, …) keep `[1m]` / `[500k]` -/// on `ANTHROPIC_MODEL` so Claude Code's HUD does not invent `[200k]` from its -/// default window. Concrete catalog ids still drop the suffix (they 404). +/// Peel `[Nm]`/`[Nk]` → catalog id. Claude virtual presets re-attach the floor +/// so the HUD shows `[1m]`/`[500k]` instead of catalog 200k. Concrete ids never +/// get an invented suffix (those SKUs 404). pub fn model_id_for_tool(tool_name: &str, model: &str, min_context: Option) -> String { let (peeled, peeled_floor) = peel_context_window_suffixes(model); let id = catalog_model_id(&peeled); @@ -594,11 +624,10 @@ pub fn model_id_for_tool(tool_name: &str, model: &str, min_context: Option) id }; if tool_name == "claude" && is_virtual_preset(&catalog) { - let floor = match (peeled_floor, min_context) { + if let Some(n) = match (peeled_floor, min_context) { (Some(a), Some(b)) => Some(a.max(b)), (a, b) => a.or(b), - }; - if let Some(n) = floor { + } { if let Some(sfx) = context_floor_suffix(n) { return format!("{catalog}{sfx}"); } @@ -1547,6 +1576,41 @@ mod tests { .map(String::as_str), Some("1000000") ); + assert!(!claude_gateway_discovery_enabled( + &tool, + "anyrouter/auto[1m]" + )); + let auto_row = crate::http::CatalogModel { + id: "anyrouter/auto".into(), + name: None, + owned_by: None, + context_length: Some(200_000), + }; + let ox = crate::http::CatalogModel { + id: "stealth/ox-alpha".into(), + name: None, + owned_by: None, + context_length: Some(1_000_000), + }; + assert_eq!( + catalog_context_window("anyrouter/auto[1m]", &[auto_row.clone(), ox.clone()]), + None, + "virtual preset must not inherit catalog 200k" + ); + assert_eq!( + catalog_context_window("stealth/ox-alpha", &[auto_row, ox]), + Some(1_000_000) + ); + assert!(claude_wants_auto_compact( + "anyrouter/auto", + Some(1_000_000), + Some(200_000) + )); + assert!(!claude_wants_auto_compact( + "anyrouter/auto[500k]", + None, + Some(200_000) + )); let half = build_tool_env(BuildToolEnvInput { tool_name: "claude", diff --git a/src/upgrade.rs b/src/upgrade.rs index f3e2220..c301a08 100644 --- a/src/upgrade.rs +++ b/src/upgrade.rs @@ -13,10 +13,10 @@ use std::time::{Duration, SystemTime, UNIX_EPOCH}; use sha2::{Digest, Sha256}; use crate::channel::{ - asset_name, current_arch, current_os, github_token, merge_expanded_assets, parse_checksums, - 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, + asset_name, channel_update_candidates, current_arch, current_os, github_token, + merge_expanded_assets, parse_checksums, 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}; use crate::http::{http_get_github, http_get_web}; @@ -166,6 +166,12 @@ fn resolve_channel(parsed: &ParsedArgs, env: &BTreeMap) -> Resul if let Some(flag) = get_string_flag(&parsed.flags, "channel") { return Channel::parse(&flag); } + // Config wins over ANYR_CHANNEL so bare `anyr update` does not jump tracks + // when a stale env is set. Only --beta/--stable persist a switch. + let path = resolve_config_path(None, env); + if let Some(ch) = load_config_if_present(&path).and_then(|c| c.channel) { + return Channel::parse(&ch); + } if let Some(v) = env .get("ANYR_CHANNEL") .map(|s| s.trim()) @@ -173,10 +179,6 @@ fn resolve_channel(parsed: &ParsedArgs, env: &BTreeMap) -> Resul { return Channel::parse(v); } - let path = resolve_config_path(None, env); - if let Some(ch) = load_config_if_present(&path).and_then(|c| c.channel) { - return Channel::parse(&ch); - } Ok(Channel::Stable) } @@ -338,9 +340,7 @@ fn verify_checksum_file( actual_hex: &str, ) -> Result<(), String> { let Some(expected) = map.get(asset_name) else { - return Err(format!( - "checksums.txt has no entry for {asset_name}; download aborted" - )); + return Err(format!("checksums.txt has no entry for {asset_name}")); }; let expected = expected.trim().to_ascii_lowercase(); let actual = actual_hex.trim().to_ascii_lowercase(); @@ -348,7 +348,7 @@ fn verify_checksum_file( Ok(()) } else { Err(format!( - "checksum mismatch for {asset_name}: expected {expected}, got {actual}; download aborted" + "checksum mismatch for {asset_name}: expected {expected}, got {actual}" )) } } @@ -411,6 +411,51 @@ fn fetch_checksums_body(url: &str) -> Result, String> { } } +/// Errors that mean this GitHub release is unusable; try the next on the channel. +pub fn skippable_release_error(err: &str) -> bool { + let e = err.to_ascii_lowercase(); + e.contains("checksum") + || e.contains("no entry for") + || e.contains("no binary assets") + || e.contains("download http") + || e.contains("download failed") + || e.contains("checksums download") + || e.contains("could not save download") + || e.contains("could not hash") + || e.contains("could not write") +} + +/// Newest-first install: warn and skip a broken latest (checksum / missing +/// asset / corrupt) rather than aborting the whole update. +fn try_releases( + candidates: &[Release], + os: &str, + arch: &str, + mut install: impl FnMut(&str) -> Result, + mut warn: impl FnMut(&str), +) -> Result<(Release, PathBuf), String> { + let mut last = String::from("no installable release on this channel"); + for (i, rel) in candidates.iter().enumerate() { + let url = release_asset_url(rel, os, arch); + match install(&url) { + Ok(path) => return Ok((rel.clone(), path)), + Err(err) if skippable_release_error(&err) => { + last = err.clone(); + let next = candidates.get(i + 1).map(|r| r.tag_name.as_str()); + match next { + Some(tag) => warn(&format!( + "warning: skipped {} ({err}); trying {tag}", + rel.tag_name + )), + None => warn(&format!("warning: skipped {} ({err})", rel.tag_name)), + } + } + Err(err) => return Err(err), + } + } + Err(last) +} + /// Verify `tmp` against sibling `checksums.txt`. 404 → warn and skip (legacy). fn verify_downloaded_asset(url: &str, tmp: &Path) -> Result<(), String> { let checksums = checksums_url(url); @@ -571,29 +616,42 @@ fn run_auto(parsed: &ParsedArgs, env: &BTreeMap) -> Result rel, - Err(_) => { - write_stamp(env); - return Ok(0); - } + let os = current_os(); + let arch = current_arch(); + let asset = asset_name(os, arch); + let candidates = channel_update_candidates(&releases, channel, &asset); + let Some(latest) = candidates.first() else { + write_stamp(env); + return Ok(0); }; let latest_ver = latest.version_str().to_string(); write_stamp(env); - if !needs_upgrade(VERSION, &latest_ver) { + if !needs_upgrade(VERSION, &latest_ver) + && candidates + .iter() + .all(|r| !needs_upgrade(VERSION, r.version_str())) + { return Ok(0); } let dry = parsed.flag_true("dry-run") || fixture.is_some(); + let installable: Vec = candidates + .into_iter() + .filter(|r| needs_upgrade(VERSION, r.version_str())) + .collect(); + if installable.is_empty() { + return Ok(0); + } if dry { - println!("would update {VERSION} -> {latest_ver}"); + println!("would update {VERSION} -> {}", installable[0].version_str()); return Ok(0); } - let url = release_asset_url(&latest, current_os(), current_arch()); - match replace_current_binary(&url) { - Ok(_) => { - write_notice(env, &latest_ver); - println!("updated {VERSION} -> {latest_ver}"); + match try_releases(&installable, os, arch, replace_current_binary, |msg| { + eprintln!("{msg}") + }) { + Ok((rel, _)) => { + let ver = rel.version_str().to_string(); + write_notice(env, &ver); + println!("updated {VERSION} -> {ver}"); Ok(0) } Err(_) => Ok(0), @@ -704,16 +762,23 @@ pub fn run(parsed: &ParsedArgs, env: &BTreeMap) -> Result) -> Result = if switch.is_some() { + candidates + .into_iter() + .filter(|r| !version_eq(VERSION, r.version_str())) + .collect() + } else { + candidates + .into_iter() + .filter(|r| needs_upgrade(VERSION, r.version_str())) + .collect() + }; + + if installable.is_empty() { println!( "{} Already up to date ({}, {} channel)", term::ok("✔"), @@ -748,18 +825,29 @@ pub fn run(parsed: &ParsedArgs, env: &BTreeMap) -> Result { - spinner.succeed(&updated_line(latest_ver)); + match try_releases(&installable, os, arch, replace_current_binary, |msg| { + eprintln!("{msg}") + }) { + Ok((rel, _)) => { + spinner.succeed(&updated_line(rel.version_str())); + if rel.tag_name != installable[0].tag_name { + eprintln!( + "Installed {} after a newer {} {} release failed verification.", + rel.tag_name, + channel.as_str(), + installable[0].tag_name + ); + } Ok(0) } Err(err) => { @@ -957,7 +1045,14 @@ mod tests { env.insert("ANYR_CHANNEL".into(), "stable".into()); assert_eq!( resolve_channel(&parsed_check(), &env).unwrap(), - Channel::Stable + Channel::Beta, + "bare update must keep config channel over ANYR_CHANNEL" + ); + let (mut env, _) = isolated_home(); + env.insert("ANYR_CHANNEL".into(), "beta".into()); + assert_eq!( + resolve_channel(&parsed_check(), &env).unwrap(), + Channel::Beta ); } @@ -1190,8 +1285,7 @@ mod tests { fs::write(&tmp, b"downloaded-bytes-must-not-appear-in-error").unwrap(); let err = abort_download( &tmp, - "checksum mismatch for anyr-linux-x86_64: expected aaa, got bbb; download aborted" - .into(), + "checksum mismatch for anyr-linux-x86_64: expected aaa, got bbb".into(), ) .unwrap_err(); assert!(!tmp.exists(), "temp file must be removed on checksum abort"); @@ -1202,4 +1296,71 @@ mod tests { ); let _ = fs::remove_dir_all(&dir); } + + #[test] + fn skippable_release_error_covers_checksum_and_missing() { + assert!(skippable_release_error( + "checksum mismatch for anyr-linux-x86_64: expected 097463, got 330c0ada" + )); + assert!(skippable_release_error( + "checksums.txt has no entry for anyr-linux-x86_64" + )); + assert!(skippable_release_error( + "Release v0.1.11 has no binary assets uploaded yet. Try `anyr update --beta`." + )); + assert!(skippable_release_error( + "download HTTP 500 from https://example" + )); + assert!(!skippable_release_error( + "could not replace /home/me/.local/bin/anyr: permission denied" + )); + } + + #[test] + fn try_releases_skips_checksum_mismatch_and_installs_next() { + let rels = vec![ + Release { + tag_name: "v0.1.14".into(), + prerelease: false, + assets: vec![crate::channel::ReleaseAsset { + name: "anyr-linux-x86_64".into(), + browser_download_url: "https://example/v0.1.14/anyr-linux-x86_64".into(), + }], + }, + Release { + tag_name: "v0.1.13".into(), + prerelease: false, + assets: vec![crate::channel::ReleaseAsset { + name: "anyr-linux-x86_64".into(), + browser_download_url: "https://example/v0.1.13/anyr-linux-x86_64".into(), + }], + }, + ]; + let mut warns = Vec::new(); + let (picked, path) = try_releases( + &rels, + "linux", + "x86_64", + |url| { + if url.contains("0.1.14") { + Err( + "checksum mismatch for anyr-linux-x86_64: expected 097463, got 330c0ada" + .into(), + ) + } else { + Ok(PathBuf::from("/tmp/anyr")) + } + }, + |w| warns.push(w.to_string()), + ) + .unwrap(); + assert_eq!(picked.tag_name, "v0.1.13"); + assert_eq!(path, PathBuf::from("/tmp/anyr")); + assert!( + warns + .iter() + .any(|w| w.contains("v0.1.14") && w.contains("trying v0.1.13")), + "{warns:?}" + ); + } } diff --git a/tests/cli.rs b/tests/cli.rs index 8e71589..72fa0e3 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -1484,6 +1484,32 @@ fn upgrade_does_not_print_full_sk_ar_key() { assert!(!combined.contains(key), "full key leaked:\n{combined}"); } +#[test] +fn update_check_keeps_config_channel_without_switch_flags() { + let home = std::env::temp_dir().join(format!("anyr-keep-ch-{}", std::process::id())); + let _ = std::fs::create_dir_all(&home); + std::fs::write( + home.join("config.yaml"), + "active_profile: default\nchannel: beta\nprofiles:\n default:\n api_key: x\n", + ) + .expect("write config"); + let out = anyr() + .args(["update", "--check"]) + .env("ANYR_RELEASES_JSON", fixture_path()) + .env("ANYROUTER_HOME", &home) + .env("ANYR_CHANNEL", "stable") + .output() + .expect("update --check keep channel"); + let stdout = String::from_utf8_lossy(&out.stdout); + let stderr = String::from_utf8_lossy(&out.stderr); + assert_eq!(out.status.code().unwrap_or(1), 0, "{stdout}{stderr}"); + assert!(stdout.contains("channel: beta"), "{stdout}"); + assert!(!stdout.contains("channel set to"), "{stdout}"); + let cfg = std::fs::read_to_string(home.join("config.yaml")).expect("config"); + assert!(cfg.contains("channel: beta"), "{cfg}"); + assert!(!cfg.contains("channel: stable"), "{cfg}"); +} + #[test] fn upgrade_check_reads_channel_from_config() { let home = std::env::temp_dir().join(format!("anyr-ch-{}", std::process::id()));