feat(update): add check-only release diagnostics
Add `codewhale update --check` so users can compare the installed version with the latest release without downloading or replacing binaries. Surface the same release check in `codewhale doctor`, and share release lookup, mirror handling, timeout, and version comparison logic between update and doctor.
This commit is contained in:
Generated
+13
@@ -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",
|
||||
|
||||
@@ -9,6 +9,7 @@ members = [
|
||||
"crates/hooks",
|
||||
"crates/mcp",
|
||||
"crates/protocol",
|
||||
"crates/release",
|
||||
"crates/secrets",
|
||||
"crates/state",
|
||||
"crates/tools",
|
||||
|
||||
@@ -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
|
||||
|
||||
+21
-3
@@ -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
|
||||
}))
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
+61
-192
@@ -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<reqwest::blocking::Client> {
|
||||
|
||||
/// Fetch the latest release metadata from GitHub.
|
||||
fn fetch_latest_release(channel: ReleaseChannel) -> Result<FetchedRelease> {
|
||||
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<String> {
|
||||
// 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<String> {
|
||||
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://<mirror>/<release-assets>/ {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<Release> {
|
||||
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<Release> {
|
||||
}
|
||||
|
||||
fn fetch_latest_beta_release_from_url(url: &str) -> Result<Release> {
|
||||
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<Release> = serde_json::from_str(&body).with_context(|| {
|
||||
@@ -501,57 +411,10 @@ fn fetch_latest_beta_release_from_url(url: &str) -> Result<Release> {
|
||||
|
||||
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<bool> {
|
||||
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<semver::Version> {
|
||||
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<Vec<u8>> {
|
||||
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}");
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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<String> {
|
||||
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<String> {
|
||||
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://<mirror>/<release-assets>/ {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<String> {
|
||||
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<String> {
|
||||
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<String>,
|
||||
url: &str,
|
||||
description: &str,
|
||||
) -> Result<String> {
|
||||
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<String> {
|
||||
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<String> {
|
||||
let releases: Vec<ReleaseListEntry> = 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<String> {
|
||||
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<String> {
|
||||
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<std::cmp::Ordering> {
|
||||
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<bool> {
|
||||
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<semver::Version> {
|
||||
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:#}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user