diff --git a/Cargo.lock b/Cargo.lock index e2d1ad83..52bb42c4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -851,6 +851,7 @@ dependencies = [ "codewhale-state", "dirs", "reqwest", + "semver", "serde", "serde_json", "sha2 0.10.9", diff --git a/Cargo.toml b/Cargo.toml index 78d560a0..ac727f04 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -41,6 +41,7 @@ reqwest = { version = "0.13.1", default-features = false, features = ["json", "r rusqlite = { version = "0.32.1", features = ["bundled"] } serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0.149" +semver = "1.0.28" thiserror = "2.0" tokio = { version = "1.49.0", features = ["full"] } toml = "0.9.7" diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index 1355ac04..e2006806 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -37,6 +37,7 @@ dirs.workspace = true serde.workspace = true serde_json.workspace = true reqwest = { workspace = true, features = ["blocking"] } +semver.workspace = true tokio.workspace = true sha2.workspace = true tempfile = "3.16" diff --git a/crates/cli/src/lib.rs b/crates/cli/src/lib.rs index 6ca5f06c..6c3dc8b1 100644 --- a/crates/cli/src/lib.rs +++ b/crates/cli/src/lib.rs @@ -232,7 +232,14 @@ The command prints the completion script to stdout; redirect it to a path your s /// Print a usage rollup from the audit log and session store. Metrics(MetricsArgs), /// Check for and apply updates to the `codewhale` binary. - Update, + Update(UpdateArgs), +} + +#[derive(Debug, Args)] +struct UpdateArgs { + /// Update to the latest beta release instead of the latest stable release. + #[arg(long)] + beta: bool, } #[derive(Debug, Args)] @@ -557,7 +564,7 @@ fn run() -> Result<()> { Ok(()) } Some(Commands::Metrics(args)) => run_metrics_command(args), - Some(Commands::Update) => update::run_update(), + Some(Commands::Update(args)) => update::run_update(args.beta), None => { let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides); let forwarded = root_tui_passthrough(&cli)?; @@ -1790,6 +1797,21 @@ mod tests { )); } + #[test] + fn parses_update_beta_flag() { + let cli = parse_ok(&["codewhale", "update"]); + assert!(matches!( + cli.command, + Some(Commands::Update(UpdateArgs { beta: false })) + )); + + let cli = parse_ok(&["codewhale", "update", "--beta"]); + assert!(matches!( + cli.command, + Some(Commands::Update(UpdateArgs { beta: true })) + )); + } + #[test] fn parses_model_command_matrix() { let cli = parse_ok(&["deepseek", "model", "list"]); diff --git a/crates/cli/src/update.rs b/crates/cli/src/update.rs index c9d3e481..d9ee4131 100644 --- a/crates/cli/src/update.rs +++ b/crates/cli/src/update.rs @@ -13,6 +13,7 @@ use std::io::Write; const CHECKSUM_MANIFEST_ASSET: &str = "codewhale-artifacts-sha256.txt"; const LATEST_RELEASE_URL: &str = "https://api.github.com/repos/Hmbown/CodeWhale/releases/latest"; +const RELEASES_URL: &str = "https://api.github.com/repos/Hmbown/CodeWhale/releases?per_page=100"; const CNB_REPO_URL: &str = "https://cnb.cool/codewhale.net/codewhale"; const RELEASE_BASE_URL_ENV: &str = "DEEPSEEK_TUI_RELEASE_BASE_URL"; const LEGACY_RELEASE_BASE_URL_ENV: &str = "DEEPSEEK_RELEASE_BASE_URL"; @@ -21,18 +22,34 @@ const LEGACY_UPDATE_VERSION_ENV: &str = "DEEPSEEK_VERSION"; const UPDATE_USER_AGENT: &str = "codewhale-updater"; /// Run the self-update workflow. -pub fn run_update() -> Result<()> { +pub fn run_update(beta: bool) -> Result<()> { let current_exe = std::env::current_exe().context("failed to determine current executable path")?; let targets = update_targets_for_exe(¤t_exe); + let channel = ReleaseChannel::from_beta_flag(beta); + let current_version = env!("CARGO_PKG_VERSION"); - println!("Checking for updates..."); + println!("Checking for {} updates...", channel.label()); println!("Current binary: {}", current_exe.display()); + println!("Current version: v{current_version}"); // Step 1: Fetch latest release metadata - let release = fetch_latest_release().with_context(update_network_fallback_hint)?; + let fetched = fetch_latest_release(channel).with_context(update_network_fallback_hint)?; + let release = &fetched.release; let latest_tag = &release.tag_name; - println!("Latest release: {latest_tag}"); + println!("Latest {} release: {latest_tag}", channel.label()); + + if let ReleaseSource::Mirror { base_url } = &fetched.source { + if channel == ReleaseChannel::Beta { + println!( + "Using release mirror {}; --beta does not select GitHub beta releases in mirror mode.", + base_url + ); + } + } else if !update_is_needed(channel, current_version, latest_tag)? { + println!("Already up to date; no download needed."); + return Ok(()); + } // Step 2: Download the aggregated SHA256 checksum manifest if available let checksum_manifest = match select_checksum_manifest_asset(&release) { @@ -122,6 +139,37 @@ pub fn run_update() -> Result<()> { Ok(()) } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ReleaseChannel { + Stable, + Beta, +} + +impl ReleaseChannel { + fn from_beta_flag(beta: bool) -> Self { + if beta { Self::Beta } else { Self::Stable } + } + + fn label(self) -> &'static str { + match self { + Self::Stable => "stable", + Self::Beta => "beta", + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct FetchedRelease { + release: Release, + source: ReleaseSource, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum ReleaseSource { + GitHub, + Mirror { base_url: String }, +} + pub(crate) fn release_arch_for_rust_arch(arch: &str) -> &str { match arch { "aarch64" => "arm64", @@ -275,14 +323,16 @@ fn expected_sha256_from_manifest(text: &str, asset_name: &str) -> Result } /// GitHub release metadata. -#[derive(serde::Deserialize, Debug)] +#[derive(serde::Deserialize, Debug, Clone, PartialEq, Eq)] struct Release { tag_name: String, + #[serde(default)] + prerelease: bool, assets: Vec, } /// A single release asset. -#[derive(serde::Deserialize, Debug)] +#[derive(serde::Deserialize, Debug, Clone, PartialEq, Eq)] struct Asset { name: String, browser_download_url: String, @@ -296,17 +346,27 @@ fn update_http_client() -> Result { } /// Fetch the latest release metadata from GitHub. -fn fetch_latest_release() -> Result { +fn fetch_latest_release(channel: ReleaseChannel) -> Result { if let Some(base_url) = release_base_url_from_env() { let version = update_version_from_env().unwrap_or_else(|| env!("CARGO_PKG_VERSION").into()); - return Ok(release_from_mirror_base_url( - &base_url, - &version, - std::env::consts::OS, - std::env::consts::ARCH, - )); + return Ok(FetchedRelease { + release: release_from_mirror_base_url( + &base_url, + &version, + std::env::consts::OS, + std::env::consts::ARCH, + ), + source: ReleaseSource::Mirror { base_url }, + }); } - fetch_latest_release_from_url(LATEST_RELEASE_URL) + let release = match channel { + ReleaseChannel::Stable => fetch_latest_release_from_url(LATEST_RELEASE_URL), + ReleaseChannel::Beta => fetch_latest_beta_release_from_url(RELEASES_URL), + }?; + Ok(FetchedRelease { + release, + source: ReleaseSource::GitHub, + }) } fn release_base_url_from_env() -> Option { @@ -345,7 +405,11 @@ fn release_from_mirror_base_url( }); } - Release { tag_name, assets } + Release { + tag_name, + prerelease: false, + assets, + } } fn mirror_asset_url(base_url: &str, asset_name: &str) -> String { @@ -388,6 +452,81 @@ fn fetch_latest_release_from_url(url: &str) -> Result { Ok(release) } +fn fetch_latest_beta_release_from_url(url: &str) -> Result { + let client = update_http_client()?; + let response = client + .get(url) + .header(reqwest::header::ACCEPT, "application/vnd.github+json") + .send() + .with_context(|| format!("failed to fetch release list from {url}"))?; + let status = response.status(); + let body = response + .text() + .with_context(|| format!("failed to read release list response from {url}"))?; + + if !status.is_success() { + bail!("GitHub release list request failed with HTTP {status}: {body}"); + } + + // GitHub caps this endpoint at 100 releases per page. CodeWhale uses the + // first page as the latest-beta search window, matching GitHub's ordering. + let releases: Vec = serde_json::from_str(&body).with_context(|| { + format!("failed to parse release list JSON from GitHub API. Response: {body}") + })?; + + releases + .into_iter() + .find(is_beta_release) + .context("no beta release found in GitHub releases") +} + +fn is_beta_release(release: &Release) -> bool { + release.tag_name.to_ascii_lowercase().contains("beta") +} + +fn update_is_needed( + channel: ReleaseChannel, + current_version: &str, + latest_tag: &str, +) -> Result { + let current = parse_release_version(current_version) + .with_context(|| format!("failed to parse current version {current_version:?}"))?; + let latest = parse_release_version(latest_tag) + .with_context(|| format!("failed to parse latest release tag {latest_tag:?}"))?; + + match channel { + ReleaseChannel::Stable => Ok(current < latest), + ReleaseChannel::Beta => { + if current == latest { + return Ok(false); + } + let latest_is_beta = version_is_beta(&latest); + let current_is_stable = current.pre.is_empty(); + let same_release_line = current.major == latest.major + && current.minor == latest.minor + && current.patch == latest.patch; + if current > latest && !(current_is_stable && same_release_line) { + return Ok(false); + } + Ok(latest_is_beta) + } + } +} + +fn parse_release_version(value: &str) -> Result { + let version = value + .trim() + .trim_start_matches('v') + .split_whitespace() + .next() + .unwrap_or(""); + semver::Version::parse(version).with_context(|| format!("invalid semver: {value:?}")) +} + +fn version_is_beta(version: &semver::Version) -> bool { + version.pre.as_str().to_ascii_lowercase().contains("beta") +} + /// Download a URL to bytes. fn download_url(url: &str) -> Result> { let client = update_http_client()?; @@ -837,6 +976,62 @@ E3B0C44298FC1C149AFBF4C8996FB92427AE41E4649B934CA495991B7852B855 *codewhale-win ); } + #[test] + fn stable_update_is_needed_only_when_latest_is_newer() { + assert!(update_is_needed(ReleaseChannel::Stable, "0.8.45", "v0.8.46").unwrap()); + assert!(update_is_needed(ReleaseChannel::Stable, "0.8.45", "v0.9.0-beta.1").unwrap()); + assert!(!update_is_needed(ReleaseChannel::Stable, "0.8.45", "v0.8.45").unwrap()); + assert!(!update_is_needed(ReleaseChannel::Stable, "0.9.0", "v0.9.0-beta.1").unwrap()); + assert!( + !update_is_needed(ReleaseChannel::Stable, "0.9.0-beta.2", "v0.9.0-beta.1").unwrap() + ); + } + + #[test] + fn beta_update_allows_switching_from_same_stable_to_beta() { + assert!(update_is_needed(ReleaseChannel::Beta, "1.0.0", "v1.0.0-beta.2").unwrap()); + assert!(!update_is_needed(ReleaseChannel::Beta, "1.0.0-beta.2", "v1.0.0-beta.2").unwrap()); + assert!(!update_is_needed(ReleaseChannel::Beta, "1.0.0-beta.3", "v1.0.0-beta.2").unwrap()); + assert!(update_is_needed(ReleaseChannel::Beta, "1.0.0-beta.2", "v1.0.0-beta.3").unwrap()); + assert!(!update_is_needed(ReleaseChannel::Beta, "2.0.0", "v1.0.0-beta.3").unwrap()); + assert!(!update_is_needed(ReleaseChannel::Beta, "1.0.0-rc.1", "v1.0.0-beta.3").unwrap()); + } + + #[test] + fn parse_release_version_accepts_tags_and_build_suffixes() { + assert_eq!( + parse_release_version("v0.9.0-beta.1").unwrap(), + semver::Version::parse("0.9.0-beta.1").unwrap() + ); + assert_eq!( + parse_release_version("0.8.45 (abcdef123456)").unwrap(), + semver::Version::parse("0.8.45").unwrap() + ); + } + + #[test] + fn beta_release_detection_requires_beta_tag() { + let rc_prerelease = Release { + tag_name: "v0.9.0-rc.1".to_string(), + prerelease: true, + assets: vec![], + }; + let beta_tag = Release { + tag_name: "v0.9.0-beta.1".to_string(), + prerelease: false, + assets: vec![], + }; + let stable = Release { + tag_name: "v0.9.0".to_string(), + prerelease: false, + assets: vec![], + }; + + assert!(!is_beta_release(&rc_prerelease)); + assert!(is_beta_release(&beta_tag)); + assert!(!is_beta_release(&stable)); + } + #[test] fn update_fallback_hint_points_china_users_to_cnb_and_asset_mirrors() { let hint = update_network_fallback_hint(); @@ -919,6 +1114,48 @@ E3B0C44298FC1C149AFBF4C8996FB92427AE41E4649B934CA495991B7852B855 *codewhale-win handle.join().expect("test server thread"); } + #[test] + fn fetch_latest_beta_release_from_url_selects_first_beta_release() { + let body = br#"[ + { "tag_name": "v0.9.0", "prerelease": false, "assets": [] }, + { "tag_name": "v0.9.0-rc.1", "prerelease": true, "assets": [] }, + { "tag_name": "v0.9.0-beta.2", "prerelease": true, "assets": [ + { "name": "codewhale-linux-x64", "browser_download_url": "http://example.invalid/codewhale-linux-x64" } + ] }, + { "tag_name": "v0.9.0-beta.1", "prerelease": true, "assets": [] } + ]"#; + let (url, request_rx, handle) = serve_http_once("200 OK", "application/json", body); + let release = + fetch_latest_beta_release_from_url(&url).expect("beta release JSON should parse"); + + assert_eq!(release.tag_name, "v0.9.0-beta.2"); + assert!(release.prerelease); + + let request = request_rx.recv().expect("captured request"); + let request_lower = request.to_ascii_lowercase(); + assert!(request.starts_with("GET /release "), "got {request:?}"); + assert!( + request_lower.contains("accept: application/vnd.github+json"), + "got {request:?}" + ); + handle.join().expect("test server thread"); + } + + #[test] + fn fetch_latest_beta_release_from_url_reports_missing_beta() { + let body = br#"[ + { "tag_name": "v0.9.0", "prerelease": false, "assets": [] } + ]"#; + let (url, _request_rx, handle) = serve_http_once("200 OK", "application/json", body); + let err = fetch_latest_beta_release_from_url(&url).expect_err("missing beta should fail"); + + assert!( + err.to_string().contains("no beta release found"), + "unexpected error: {err:#}" + ); + handle.join().expect("test server thread"); + } + #[test] fn download_url_reads_binary_body_with_updater_user_agent() { let (url, request_rx, handle) =