diff --git a/Cargo.lock b/Cargo.lock index 239d0518..fcd2407b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -847,6 +847,7 @@ dependencies = [ "codewhale-config", "codewhale-execpolicy", "codewhale-mcp", + "codewhale-release", "codewhale-secrets", "codewhale-state", "dirs", @@ -931,6 +932,17 @@ dependencies = [ "serde_json", ] +[[package]] +name = "codewhale-release" +version = "0.8.46" +dependencies = [ + "anyhow", + "reqwest", + "semver", + "serde", + "serde_json", +] + [[package]] name = "codewhale-secrets" version = "0.8.46" @@ -983,6 +995,7 @@ dependencies = [ "clap", "clap_complete", "codewhale-config", + "codewhale-release", "codewhale-secrets", "codewhale-tools", "colored", diff --git a/Cargo.toml b/Cargo.toml index dae89151..f29644d6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,6 +9,7 @@ members = [ "crates/hooks", "crates/mcp", "crates/protocol", + "crates/release", "crates/secrets", "crates/state", "crates/tools", diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index 59d4da18..63a8ddb9 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -30,6 +30,7 @@ codewhale-app-server = { path = "../app-server", version = "0.8.46" } codewhale-config = { path = "../config", version = "0.8.46" } codewhale-execpolicy = { path = "../execpolicy", version = "0.8.46" } codewhale-mcp = { path = "../mcp", version = "0.8.46" } +codewhale-release = { path = "../release", version = "0.8.46" } codewhale-secrets = { path = "../secrets", version = "0.8.46" } codewhale-state = { path = "../state", version = "0.8.46" } chrono.workspace = true diff --git a/crates/cli/src/lib.rs b/crates/cli/src/lib.rs index db6e8c4a..5c630223 100644 --- a/crates/cli/src/lib.rs +++ b/crates/cli/src/lib.rs @@ -240,6 +240,9 @@ struct UpdateArgs { /// Update to the latest beta release instead of the latest stable release. #[arg(long)] beta: bool, + /// Only check the latest release; do not download or replace binaries. + #[arg(long)] + check: bool, } #[derive(Debug, Args)] @@ -569,7 +572,7 @@ fn run() -> Result<()> { Ok(()) } Some(Commands::Metrics(args)) => run_metrics_command(args), - Some(Commands::Update(args)) => update::run_update(args.beta), + Some(Commands::Update(args)) => update::run_update(args.beta, args.check), None => { let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides); let forwarded = root_tui_passthrough(&cli)?; @@ -1817,13 +1820,28 @@ mod tests { let cli = parse_ok(&["codewhale", "update"]); assert!(matches!( cli.command, - Some(Commands::Update(UpdateArgs { beta: false })) + Some(Commands::Update(UpdateArgs { + beta: false, + check: false + })) )); let cli = parse_ok(&["codewhale", "update", "--beta"]); assert!(matches!( cli.command, - Some(Commands::Update(UpdateArgs { beta: true })) + Some(Commands::Update(UpdateArgs { + beta: true, + check: false + })) + )); + + let cli = parse_ok(&["codewhale", "update", "--check"]); + assert!(matches!( + cli.command, + Some(Commands::Update(UpdateArgs { + beta: false, + check: true + })) )); } diff --git a/crates/cli/src/update.rs b/crates/cli/src/update.rs index 2ab35ef1..25060779 100644 --- a/crates/cli/src/update.rs +++ b/crates/cli/src/update.rs @@ -5,28 +5,21 @@ //! platform-correct binary, verifies its SHA256 checksum, and atomically //! replaces the currently running binary. +use std::cmp::Ordering; use std::collections::HashMap; use std::path::{Path, PathBuf}; use anyhow::{Context, Result, bail}; +use codewhale_release::{ + CHECKSUM_MANIFEST_ASSET, ReleaseChannel, ReleaseQuery, UPDATE_USER_AGENT, + compare_release_versions, fetch_release_json_blocking, is_beta_tag, + latest_release_tag_blocking, mirror_asset_url, resolve_release_query, update_is_needed, + update_network_fallback_hint, +}; 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 = "CODEWHALE_RELEASE_BASE_URL"; -const LEGACY_RELEASE_BASE_URL_ENV: &str = "DEEPSEEK_TUI_RELEASE_BASE_URL"; -const DEEPSEEK_RELEASE_BASE_URL_ENV: &str = "DEEPSEEK_RELEASE_BASE_URL"; -const CNB_MIRROR_ENV: &str = "CODEWHALE_USE_CNB_MIRROR"; -/// Base URL for CNB binary release asset downloads (China-friendly mirror). -const CNB_RELEASE_ASSET_BASE: &str = "https://cnb.cool/Hmbown/CodeWhale/-/releases"; -const UPDATE_VERSION_ENV: &str = "DEEPSEEK_TUI_VERSION"; -const LEGACY_UPDATE_VERSION_ENV: &str = "DEEPSEEK_VERSION"; -const UPDATE_USER_AGENT: &str = "codewhale-updater"; - /// Run the self-update workflow. -pub fn run_update(beta: bool) -> Result<()> { +pub fn run_update(beta: bool, check_only: bool) -> Result<()> { let current_exe = std::env::current_exe().context("failed to determine current executable path")?; let targets = update_targets_for_exe(¤t_exe); @@ -37,13 +30,32 @@ pub fn run_update(beta: bool) -> Result<()> { println!("Current binary: {}", current_exe.display()); println!("Current version: v{current_version}"); + if check_only { + let latest_tag = + latest_release_tag_blocking(channel).with_context(update_network_fallback_hint)?; + println!("Latest {} release: {latest_tag}", channel.label()); + if update_is_needed(channel, current_version, &latest_tag)? { + println!("Update available. Run `codewhale update` to install {latest_tag}."); + } else { + match compare_release_versions(current_version, &latest_tag)? { + Ordering::Greater => { + println!("Current build is newer than the latest published release."); + } + Ordering::Less | Ordering::Equal => { + println!("Already up to date."); + } + } + } + return Ok(()); + } + // Step 1: Fetch latest release metadata 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}", channel.label()); - if let ReleaseSource::Mirror { base_url } = &fetched.source { + if let UpdateReleaseSource::Mirror { base_url } = &fetched.source { if channel == ReleaseChannel::Beta { println!( "Using release mirror {}; --beta does not select GitHub beta releases in mirror mode.", @@ -143,33 +155,14 @@ pub fn run_update(beta: bool) -> 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, + source: UpdateReleaseSource, } #[derive(Debug, Clone, PartialEq, Eq)] -enum ReleaseSource { +enum UpdateReleaseSource { GitHub, Mirror { base_url: String }, } @@ -351,63 +344,25 @@ fn update_http_client() -> Result { /// Fetch the latest release metadata from GitHub. fn fetch_latest_release(channel: ReleaseChannel) -> Result { - let version = update_version_from_env().unwrap_or_else(|| env!("CARGO_PKG_VERSION").into()); - if let Some(base_url) = release_base_url_from_env(&version) { - return Ok(FetchedRelease { + match resolve_release_query(channel) { + ReleaseQuery::Mirror { base_url, version } => Ok(FetchedRelease { release: release_from_mirror_base_url( &base_url, &version, std::env::consts::OS, std::env::consts::ARCH, ), - source: ReleaseSource::Mirror { base_url }, - }); + source: UpdateReleaseSource::Mirror { base_url }, + }), + ReleaseQuery::GitHubLatest { url } => Ok(FetchedRelease { + release: fetch_latest_release_from_url(url)?, + source: UpdateReleaseSource::GitHub, + }), + ReleaseQuery::GitHubReleaseList { url } => Ok(FetchedRelease { + release: fetch_latest_beta_release_from_url(url)?, + source: UpdateReleaseSource::GitHub, + }), } - 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(version: &str) -> Option { - // Check canonical env first, then legacy envs - for env_name in [ - RELEASE_BASE_URL_ENV, - LEGACY_RELEASE_BASE_URL_ENV, - DEEPSEEK_RELEASE_BASE_URL_ENV, - ] { - if let Ok(value) = std::env::var(env_name) { - let trimmed = value.trim().to_string(); - if !trimmed.is_empty() { - return Some(trimmed); - } - } - } - // Auto-detect CNB mirror when CODEWHALE_USE_CNB_MIRROR is set - if std::env::var(CNB_MIRROR_ENV).is_ok() { - return Some(cnb_release_base_url(version)); - } - None -} - -fn cnb_release_base_url(version: &str) -> String { - format!( - "{}/v{}", - CNB_RELEASE_ASSET_BASE.trim_end_matches('/'), - version.trim_start_matches('v') - ) -} - -fn update_version_from_env() -> Option { - std::env::var(UPDATE_VERSION_ENV) - .ok() - .or_else(|| std::env::var(LEGACY_UPDATE_VERSION_ENV).ok()) - .map(|value| value.trim().trim_start_matches('v').to_string()) - .filter(|value| !value.is_empty()) } fn release_from_mirror_base_url( @@ -437,39 +392,8 @@ fn release_from_mirror_base_url( } } -fn mirror_asset_url(base_url: &str, asset_name: &str) -> String { - format!("{}/{}", base_url.trim_end_matches('/'), asset_name) -} - -fn update_network_fallback_hint() -> String { - format!( - "GitHub release downloads may be blocked or slow on this network.\n\ - For mainland China, use one of these fallback paths:\n\ - 1. Source build from the CNB mirror, installing both shipped binaries:\n\ - cargo install --git {CNB_REPO_URL} --tag vX.Y.Z codewhale-cli --locked --force\n\ - cargo install --git {CNB_REPO_URL} --tag vX.Y.Z codewhale-tui --locked --force\n\ - 2. Use a binary asset mirror:\n\ - {RELEASE_BASE_URL_ENV}=https://// {UPDATE_VERSION_ENV}=X.Y.Z codewhale update\n\ - The mirror directory must contain {CHECKSUM_MANIFEST_ASSET} and the platform binaries." - ) -} - fn fetch_latest_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 info from {url}"))?; - let status = response.status(); - let body = response - .text() - .with_context(|| format!("failed to read release response from {url}"))?; - - if !status.is_success() { - bail!("GitHub release request failed with HTTP {status}: {body}"); - } - + let body = fetch_release_json_blocking(url, "release info")?; let release: Release = serde_json::from_str(&body).with_context(|| { format!("failed to parse release JSON from GitHub API. Response: {body}") })?; @@ -478,21 +402,7 @@ fn fetch_latest_release_from_url(url: &str) -> Result { } 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}"); - } - + let body = fetch_release_json_blocking(url, "release list")?; // 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(|| { @@ -501,57 +411,10 @@ fn fetch_latest_beta_release_from_url(url: &str) -> Result { releases .into_iter() - .find(is_beta_release) + .find(|release| is_beta_tag(&release.tag_name)) .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()?; @@ -1004,11 +867,11 @@ E3B0C44298FC1C149AFBF4C8996FB92427AE41E4649B934CA495991B7852B855 *codewhale-win #[test] fn cnb_release_base_url_includes_tag_directory() { assert_eq!( - cnb_release_base_url("0.8.47"), + codewhale_release::cnb_release_base_url("0.8.47"), "https://cnb.cool/Hmbown/CodeWhale/-/releases/v0.8.47" ); assert_eq!( - cnb_release_base_url("v0.8.47"), + codewhale_release::cnb_release_base_url("v0.8.47"), "https://cnb.cool/Hmbown/CodeWhale/-/releases/v0.8.47" ); } @@ -1037,11 +900,11 @@ E3B0C44298FC1C149AFBF4C8996FB92427AE41E4649B934CA495991B7852B855 *codewhale-win #[test] fn parse_release_version_accepts_tags_and_build_suffixes() { assert_eq!( - parse_release_version("v0.9.0-beta.1").unwrap(), + codewhale_release::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(), + codewhale_release::parse_release_version("0.8.45 (abcdef123456)").unwrap(), semver::Version::parse("0.8.45").unwrap() ); } @@ -1064,18 +927,24 @@ E3B0C44298FC1C149AFBF4C8996FB92427AE41E4649B934CA495991B7852B855 *codewhale-win assets: vec![], }; - assert!(!is_beta_release(&rc_prerelease)); - assert!(is_beta_release(&beta_tag)); - assert!(!is_beta_release(&stable)); + assert!(!is_beta_tag(&rc_prerelease.tag_name)); + assert!(is_beta_tag(&beta_tag.tag_name)); + assert!(!is_beta_tag(&stable.tag_name)); } #[test] fn update_fallback_hint_points_china_users_to_cnb_and_asset_mirrors() { let hint = update_network_fallback_hint(); - assert!(hint.contains(CNB_REPO_URL), "{hint}"); - assert!(hint.contains(RELEASE_BASE_URL_ENV), "{hint}"); - assert!(hint.contains(UPDATE_VERSION_ENV), "{hint}"); + assert!(hint.contains(codewhale_release::CNB_REPO_URL), "{hint}"); + assert!( + hint.contains(codewhale_release::RELEASE_BASE_URL_ENV), + "{hint}" + ); + assert!( + hint.contains(codewhale_release::UPDATE_VERSION_ENV), + "{hint}" + ); assert!(hint.contains("codewhale-cli"), "{hint}"); assert!(hint.contains("codewhale-tui --locked"), "{hint}"); } diff --git a/crates/release/Cargo.toml b/crates/release/Cargo.toml new file mode 100644 index 00000000..67520686 --- /dev/null +++ b/crates/release/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "codewhale-release" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +description = "Shared CodeWhale release discovery and version comparison helpers" + +[dependencies] +anyhow.workspace = true +reqwest = { workspace = true, features = ["blocking"] } +semver.workspace = true +serde.workspace = true +serde_json.workspace = true diff --git a/crates/release/src/lib.rs b/crates/release/src/lib.rs new file mode 100644 index 00000000..327bb874 --- /dev/null +++ b/crates/release/src/lib.rs @@ -0,0 +1,369 @@ +use std::time::Duration; + +use anyhow::{Context, Result, bail}; +use serde::Deserialize; + +pub const CHECKSUM_MANIFEST_ASSET: &str = "codewhale-artifacts-sha256.txt"; +pub const LATEST_RELEASE_URL: &str = + "https://api.github.com/repos/Hmbown/CodeWhale/releases/latest"; +pub const RELEASES_URL: &str = + "https://api.github.com/repos/Hmbown/CodeWhale/releases?per_page=100"; +pub const CNB_REPO_URL: &str = "https://cnb.cool/codewhale.net/codewhale"; +pub const RELEASE_BASE_URL_ENV: &str = "CODEWHALE_RELEASE_BASE_URL"; +pub const LEGACY_RELEASE_BASE_URL_ENV: &str = "DEEPSEEK_TUI_RELEASE_BASE_URL"; +pub const DEEPSEEK_RELEASE_BASE_URL_ENV: &str = "DEEPSEEK_RELEASE_BASE_URL"; +pub const CNB_MIRROR_ENV: &str = "CODEWHALE_USE_CNB_MIRROR"; +pub const UPDATE_VERSION_ENV: &str = "DEEPSEEK_TUI_VERSION"; +pub const LEGACY_UPDATE_VERSION_ENV: &str = "DEEPSEEK_VERSION"; +pub const UPDATE_USER_AGENT: &str = "codewhale-updater"; + +const CNB_RELEASE_ASSET_BASE: &str = "https://cnb.cool/Hmbown/CodeWhale/-/releases"; +const RELEASE_METADATA_TIMEOUT: Duration = Duration::from_secs(5); + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ReleaseChannel { + Stable, + Beta, +} + +impl ReleaseChannel { + pub fn from_beta_flag(beta: bool) -> Self { + if beta { Self::Beta } else { Self::Stable } + } + + pub fn label(self) -> &'static str { + match self { + Self::Stable => "stable", + Self::Beta => "beta", + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ReleaseQuery { + Mirror { base_url: String, version: String }, + GitHubLatest { url: &'static str }, + GitHubReleaseList { url: &'static str }, +} + +pub fn resolve_release_query(channel: ReleaseChannel) -> ReleaseQuery { + let version = update_version_from_env().unwrap_or_else(|| env!("CARGO_PKG_VERSION").into()); + if let Some(base_url) = release_base_url_from_env(&version) { + return ReleaseQuery::Mirror { base_url, version }; + } + + match channel { + ReleaseChannel::Stable => ReleaseQuery::GitHubLatest { + url: LATEST_RELEASE_URL, + }, + ReleaseChannel::Beta => ReleaseQuery::GitHubReleaseList { url: RELEASES_URL }, + } +} + +pub fn release_base_url_from_env(version: &str) -> Option { + for env_name in [ + RELEASE_BASE_URL_ENV, + LEGACY_RELEASE_BASE_URL_ENV, + DEEPSEEK_RELEASE_BASE_URL_ENV, + ] { + if let Ok(value) = std::env::var(env_name) { + let trimmed = value.trim().to_string(); + if !trimmed.is_empty() { + return Some(trimmed); + } + } + } + + if std::env::var(CNB_MIRROR_ENV).is_ok() { + return Some(cnb_release_base_url(version)); + } + None +} + +pub fn cnb_release_base_url(version: &str) -> String { + format!( + "{}/v{}", + CNB_RELEASE_ASSET_BASE.trim_end_matches('/'), + version.trim_start_matches('v') + ) +} + +pub fn update_version_from_env() -> Option { + std::env::var(UPDATE_VERSION_ENV) + .ok() + .or_else(|| std::env::var(LEGACY_UPDATE_VERSION_ENV).ok()) + .map(|value| value.trim().trim_start_matches('v').to_string()) + .filter(|value| !value.is_empty()) +} + +pub fn mirror_asset_url(base_url: &str, asset_name: &str) -> String { + format!("{}/{}", base_url.trim_end_matches('/'), asset_name) +} + +pub fn update_network_fallback_hint() -> String { + format!( + "GitHub release downloads may be blocked or slow on this network.\n\ + For mainland China, use one of these fallback paths:\n\ + 1. Source build from the CNB mirror, installing both shipped binaries:\n\ + cargo install --git {CNB_REPO_URL} --tag vX.Y.Z codewhale-cli --locked --force\n\ + cargo install --git {CNB_REPO_URL} --tag vX.Y.Z codewhale-tui --locked --force\n\ + 2. Use a binary asset mirror:\n\ + {RELEASE_BASE_URL_ENV}=https://// {UPDATE_VERSION_ENV}=X.Y.Z codewhale update\n\ + The mirror directory must contain {CHECKSUM_MANIFEST_ASSET} and the platform binaries." + ) +} + +pub fn fetch_release_json_blocking(url: &str, description: &str) -> Result { + let client = reqwest::blocking::Client::builder() + .user_agent(UPDATE_USER_AGENT) + .timeout(RELEASE_METADATA_TIMEOUT) + .build() + .context("failed to build release check HTTP client")?; + let response = client + .get(url) + .header(reqwest::header::ACCEPT, "application/vnd.github+json") + .send() + .with_context(|| format!("failed to fetch {description} from {url}"))?; + let status = response.status(); + let body = response + .text() + .with_context(|| format!("failed to read {description} response from {url}")); + release_response_body(status, body, url, description) +} + +pub async fn fetch_release_json_async(url: &str, description: &str) -> Result { + let client = reqwest::Client::builder() + .user_agent(UPDATE_USER_AGENT) + .timeout(RELEASE_METADATA_TIMEOUT) + .build() + .context("failed to build release check HTTP client")?; + let response = client + .get(url) + .header(reqwest::header::ACCEPT, "application/vnd.github+json") + .send() + .await + .with_context(|| format!("failed to fetch {description} from {url}"))?; + let status = response.status(); + let body = response + .text() + .await + .with_context(|| format!("failed to read {description} response from {url}")); + release_response_body(status, body, url, description) +} + +fn release_response_body( + status: reqwest::StatusCode, + body: Result, + url: &str, + description: &str, +) -> Result { + let body = body.with_context(|| format!("failed to read {description} response from {url}"))?; + if !status.is_success() { + bail!("GitHub release request failed with HTTP {status}: {body}"); + } + Ok(body) +} + +#[derive(Deserialize)] +struct ReleaseTag { + tag_name: String, +} + +#[derive(Deserialize)] +struct ReleaseListEntry { + tag_name: String, +} + +pub fn latest_tag_from_release_json(body: &str) -> Result { + let release: ReleaseTag = serde_json::from_str(body).with_context(|| { + format!("failed to parse release JSON from GitHub API. Response: {body}") + })?; + Ok(release.tag_name) +} + +pub fn latest_beta_tag_from_release_list_json(body: &str) -> Result { + 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(|release| is_beta_tag(&release.tag_name)) + .map(|release| release.tag_name) + .context("no beta release found in GitHub releases") +} + +pub async fn latest_release_tag_async(channel: ReleaseChannel) -> Result { + match resolve_release_query(channel) { + ReleaseQuery::Mirror { version, .. } => Ok(format!("v{}", version.trim_start_matches('v'))), + ReleaseQuery::GitHubLatest { url } => { + let body = fetch_release_json_async(url, "latest release").await?; + latest_tag_from_release_json(&body) + } + ReleaseQuery::GitHubReleaseList { url } => { + let body = fetch_release_json_async(url, "release list").await?; + latest_beta_tag_from_release_list_json(&body) + } + } +} + +pub fn latest_release_tag_blocking(channel: ReleaseChannel) -> Result { + match resolve_release_query(channel) { + ReleaseQuery::Mirror { version, .. } => Ok(format!("v{}", version.trim_start_matches('v'))), + ReleaseQuery::GitHubLatest { url } => { + let body = fetch_release_json_blocking(url, "latest release")?; + latest_tag_from_release_json(&body) + } + ReleaseQuery::GitHubReleaseList { url } => { + let body = fetch_release_json_blocking(url, "release list")?; + latest_beta_tag_from_release_list_json(&body) + } + } +} + +pub fn compare_release_versions( + 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:?}"))?; + Ok(current.cmp(&latest)) +} + +pub 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) + } + } +} + +pub 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:?}")) +} + +pub fn is_beta_tag(tag_name: &str) -> bool { + tag_name.to_ascii_lowercase().contains("beta") +} + +fn version_is_beta(version: &semver::Version) -> bool { + version.pre.as_str().to_ascii_lowercase().contains("beta") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn cnb_release_base_url_includes_tag_directory() { + assert_eq!( + cnb_release_base_url("0.8.47"), + "https://cnb.cool/Hmbown/CodeWhale/-/releases/v0.8.47" + ); + assert_eq!( + cnb_release_base_url("v0.8.47"), + "https://cnb.cool/Hmbown/CodeWhale/-/releases/v0.8.47" + ); + } + + #[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 release_version_compare_ignores_v_prefix_and_build_sha() { + assert_eq!( + compare_release_versions("0.8.39 (eeccf7d)", "v0.8.39").unwrap(), + std::cmp::Ordering::Equal + ); + assert_eq!( + compare_release_versions("0.8.39", "v0.8.40").unwrap(), + std::cmp::Ordering::Less + ); + assert_eq!( + compare_release_versions("0.8.40", "v0.8.39").unwrap(), + std::cmp::Ordering::Greater + ); + } + + #[test] + fn latest_beta_tag_selects_first_beta_release() { + let body = r#"[ + { "tag_name": "v0.9.0" }, + { "tag_name": "v0.9.0-rc.1" }, + { "tag_name": "v0.9.0-beta.2" }, + { "tag_name": "v0.9.0-beta.1" } + ]"#; + assert_eq!( + latest_beta_tag_from_release_list_json(body).unwrap(), + "v0.9.0-beta.2" + ); + } + + #[test] + fn latest_beta_tag_reports_missing_beta() { + let body = r#"[{ "tag_name": "v0.9.0" }]"#; + let err = latest_beta_tag_from_release_list_json(body).expect_err("missing beta"); + assert!( + err.to_string().contains("no beta release found"), + "unexpected error: {err:#}" + ); + } +} diff --git a/crates/tui/Cargo.toml b/crates/tui/Cargo.toml index 6aa5486c..67d4042a 100644 --- a/crates/tui/Cargo.toml +++ b/crates/tui/Cargo.toml @@ -28,6 +28,7 @@ path = "src/bin/deepseek_tui_legacy_shim.rs" anyhow = "1.0.100" arboard = "3.4" codewhale-config = { path = "../config", version = "0.8.46" } +codewhale-release = { path = "../release", version = "0.8.46" } codewhale-secrets = { path = "../secrets", version = "0.8.46" } codewhale-tools = { path = "../tools", version = "0.8.46" } schemaui = { version = "0.12.0", default-features = false, optional = true } diff --git a/crates/tui/src/main.rs b/crates/tui/src/main.rs index 64ef7992..ae37526c 100644 --- a/crates/tui/src/main.rs +++ b/crates/tui/src/main.rs @@ -2074,6 +2074,51 @@ async fn run_doctor(config: &Config, workspace: &Path, config_path_override: Opt println!(" rust: {}", rustc_version()); println!(); + println!("{}", "Updates:".bold()); + let current_version = env!("CARGO_PKG_VERSION"); + println!(" · current: v{current_version}"); + match codewhale_release::latest_release_tag_async(codewhale_release::ReleaseChannel::Stable) + .await + { + Ok(latest_tag) => { + match codewhale_release::compare_release_versions(current_version, &latest_tag) { + Ok(std::cmp::Ordering::Less) => { + println!( + " {} latest: {latest_tag}", + "!".truecolor(sky_r, sky_g, sky_b) + ); + println!(" Update available. Run `codewhale update` to install."); + } + Ok(std::cmp::Ordering::Equal) => { + println!( + " {} latest: {latest_tag}", + "✓".truecolor(aqua_r, aqua_g, aqua_b) + ); + println!(" Already up to date."); + } + Ok(std::cmp::Ordering::Greater) => { + println!(" {} latest: {latest_tag}", "·".dimmed()); + println!(" Current build is newer than the latest published release."); + } + Err(err) => { + println!( + " {} latest: {latest_tag}", + "!".truecolor(sky_r, sky_g, sky_b) + ); + println!(" Version comparison failed: {err}"); + } + } + } + Err(err) => { + println!( + " {} latest release check failed: {err}", + "!".truecolor(sky_r, sky_g, sky_b) + ); + println!(" Run `codewhale update --check` to retry."); + } + } + println!(); + // Configuration summary println!("{}", "Configuration:".bold()); let config_path = config_path_override diff --git a/docs/INSTALL.md b/docs/INSTALL.md index 473618b5..0f5b8376 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -479,6 +479,9 @@ Cargo mirror setup in [Section 4](#4-install-via-cargo-any-tier-1-rust-target). assets. On networks where GitHub is blocked or unreliable, use the CNB source mirror instead and install both binaries from the release tag: +To check the latest release without downloading or replacing binaries, run +`codewhale update --check`. + ```bash cargo install --git https://cnb.cool/codewhale.net/codewhale --tag vX.Y.Z codewhale-cli --locked --force cargo install --git https://cnb.cool/codewhale.net/codewhale --tag vX.Y.Z codewhale-tui --locked --force