From 6dd8394dfe8615d4858b01a0f3054cff3345c1fc Mon Sep 17 00:00:00 2001 From: AccMoment <1161677781@qq.com> Date: Wed, 27 May 2026 20:58:48 +0800 Subject: [PATCH 1/3] feat(update):Add proxy option to update command Update docs to introduce update command proxy options --- README.ja-JP.md | 3 ++ README.md | 3 ++ README.zh-CN.md | 2 + crates/cli/src/lib.rs | 39 ++++++++++++-- crates/cli/src/update.rs | 107 ++++++++++++++++++++++++++++++--------- 5 files changed, 128 insertions(+), 26 deletions(-) diff --git a/README.ja-JP.md b/README.ja-JP.md index 667aafb5..6b5d8abc 100644 --- a/README.ja-JP.md +++ b/README.ja-JP.md @@ -51,6 +51,9 @@ cargo install codewhale-cli --locked --force cargo install codewhale-tui --locked --force ``` +> codewhale update は --proxy をサポートしており、プロキシ経由で更新できます +> 例: codewhale update --proxy https://localhost:7897 + [![CI](https://github.com/Hmbown/CodeWhale/actions/workflows/ci.yml/badge.svg)](https://github.com/Hmbown/CodeWhale/actions/workflows/ci.yml) [![npm](https://img.shields.io/npm/v/codewhale)](https://www.npmjs.com/package/codewhale) [![crates.io](https://img.shields.io/crates/v/codewhale-cli?label=crates.io)](https://crates.io/crates/codewhale-cli) diff --git a/README.md b/README.md index 3213ee15..8a5baafc 100644 --- a/README.md +++ b/README.md @@ -64,6 +64,9 @@ cargo install codewhale-cli --locked --force cargo install codewhale-tui --locked --force ``` +> codewhale update now supports --proxy, update through a proxy +> eg: codewhale update --proxy https://localhost:7897 + [![CI](https://github.com/Hmbown/CodeWhale/actions/workflows/ci.yml/badge.svg)](https://github.com/Hmbown/CodeWhale/actions/workflows/ci.yml) [![npm](https://img.shields.io/npm/v/codewhale)](https://www.npmjs.com/package/codewhale) [![crates.io](https://img.shields.io/crates/v/codewhale-cli?label=crates.io)](https://crates.io/crates/codewhale-cli) diff --git a/README.zh-CN.md b/README.zh-CN.md index f079cc80..c4ded4f3 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -59,6 +59,8 @@ brew update && brew upgrade deepseek-tui cargo install codewhale-cli --locked --force cargo install codewhale-tui --locked --force ``` +> codewhale update 现在可添加 --proxy ,通过代理下载更新 +> eg: codewhale update --proxy https://localhost:7897 [![CI](https://github.com/Hmbown/CodeWhale/actions/workflows/ci.yml/badge.svg)](https://github.com/Hmbown/CodeWhale/actions/workflows/ci.yml) [![npm](https://img.shields.io/npm/v/codewhale)](https://www.npmjs.com/package/codewhale) diff --git a/crates/cli/src/lib.rs b/crates/cli/src/lib.rs index 9f98eebf..6d5cf17c 100644 --- a/crates/cli/src/lib.rs +++ b/crates/cli/src/lib.rs @@ -240,6 +240,8 @@ struct UpdateArgs { /// Update to the latest beta release instead of the latest stable release. #[arg(long)] beta: bool, + #[arg(long)] + proxy: Option, } #[derive(Debug, Args)] @@ -569,7 +571,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), None => { let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides); let forwarded = root_tui_passthrough(&cli)?; @@ -1817,13 +1819,19 @@ mod tests { let cli = parse_ok(&["codewhale", "update"]); assert!(matches!( cli.command, - Some(Commands::Update(UpdateArgs { beta: false })) + Some(Commands::Update(UpdateArgs { + beta: false, + proxy: None + })) )); let cli = parse_ok(&["codewhale", "update", "--beta"]); assert!(matches!( cli.command, - Some(Commands::Update(UpdateArgs { beta: true })) + Some(Commands::Update(UpdateArgs { + beta: true, + proxy: None + })) )); } @@ -2427,6 +2435,31 @@ mod tests { let _ = std::fs::remove_file(path); } + #[test] + fn udpate_parse_with_proxy() { + let cli = parse_ok(&["deepseek", "update", "--proxy", "http:localhost:7897"]); + + let args = match cli.command { + Some(Commands::Update(args)) => args, + other => panic!("expected Update with proxy, got {other:?}"), + }; + assert_eq!( + args.proxy.expect("should have proxy"), + "http:localhost:7897" + ); + } + + #[test] + fn udpate_parse_without_proxy() { + let cli = parse_ok(&["deepseek", "update"]); + + let args = match cli.command { + Some(Commands::Update(args)) => args, + other => panic!("expected Update, got {other:?}"), + }; + assert!(args.proxy.is_none()); + } + #[test] fn dispatch_keyring_recovery_self_heals_into_config_file() { use codewhale_secrets::{InMemoryKeyringStore, KeyringStore}; diff --git a/crates/cli/src/update.rs b/crates/cli/src/update.rs index 2ab35ef1..44b26537 100644 --- a/crates/cli/src/update.rs +++ b/crates/cli/src/update.rs @@ -4,11 +4,13 @@ //! `github.com/Hmbown/CodeWhale/releases/latest`, downloads the //! platform-correct binary, verifies its SHA256 checksum, and atomically //! replaces the currently running binary. - +use crate::UpdateArgs; use std::collections::HashMap; use std::path::{Path, PathBuf}; +use std::time::Duration; use anyhow::{Context, Result, bail}; +use reqwest::Proxy; use std::io::Write; const CHECKSUM_MANIFEST_ASSET: &str = "codewhale-artifacts-sha256.txt"; @@ -26,19 +28,27 @@ 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(args: UpdateArgs) -> Result<()> { + let beta = args.beta; 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"); + let proxy = if let Some(proxy_str) = &args.proxy { + validate_and_build_proxy(proxy_str)? + } else { + None + }; + 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 fetched = fetch_latest_release(channel).with_context(update_network_fallback_hint)?; + let fetched = + fetch_latest_release(channel, &proxy).with_context(update_network_fallback_hint)?; let release = &fetched.release; let latest_tag = &release.tag_name; println!("Latest {} release: {latest_tag}", channel.label()); @@ -59,8 +69,8 @@ pub fn run_update(beta: bool) -> Result<()> { let checksum_manifest = match select_checksum_manifest_asset(release) { Some(checksum_asset) => { println!("Downloading {}...", checksum_asset.name); - let checksum_bytes = - download_url(&checksum_asset.browser_download_url).with_context(|| { + let checksum_bytes = download_url(&checksum_asset.browser_download_url, &proxy) + .with_context(|| { format!( "failed to download {}\n{}", checksum_asset.name, @@ -95,7 +105,7 @@ pub fn run_update(beta: bool) -> Result<()> { })?; println!("Downloading {}...", asset.name); - let bytes = download_url(&asset.browser_download_url).with_context(|| { + let bytes = download_url(&asset.browser_download_url, &proxy).with_context(|| { format!( "failed to download {}\n{}", asset.name, @@ -174,6 +184,49 @@ enum ReleaseSource { Mirror { base_url: String }, } +// Validate the proxy URL and optionally test connectivity before proceeding. +fn validate_and_build_proxy(proxy_str: &str) -> Result> { + let valid_url = reqwest::Url::parse(proxy_str).with_context(|| { + format!( + "invalid proxy URL: {proxy_str}\n\ + Expected format: http://host:port, https://host:port, or socks5://host:port" + ) + })?; + + let proxy = reqwest::Proxy::all(valid_url)?; + + // Quick connectivity test through the proxy + let client = reqwest::blocking::Client::builder() + .proxy(proxy.clone()) + .user_agent(UPDATE_USER_AGENT) + .timeout(Duration::from_secs(10)) + .build() + .context("Could not build proxy HTTP client")?; + + match client.head(LATEST_RELEASE_URL).send() { + Ok(_) => Ok(Some(proxy)), + Err(e) => { + // Give a clear actionable error rather than a raw reqwest error + let hint = if e.is_timeout() || e.is_connect() { + "could not connect to the proxy server" + } else if e.is_request() { + "the request was sent but no response was received" + } else { + "an unexpected network error occurred" + }; + bail!( + "proxy connectivity failed: {hint}\n\ + Proxy URL: {proxy_str}\n\ + Details: {e}\n\ + Please verify:\n\ + - The proxy URL is correct\n\ + - The proxy server is running and reachable\n\ + - The proxy allows outbound connections to api.github.com" + ) + } + } +} + pub(crate) fn release_arch_for_rust_arch(arch: &str) -> &str { match arch { "aarch64" => "arm64", @@ -342,15 +395,21 @@ struct Asset { browser_download_url: String, } -fn update_http_client() -> Result { - reqwest::blocking::Client::builder() +fn update_http_client(proxy: &Option) -> Result { + let mut builder = reqwest::blocking::Client::builder(); + + if let Some(p) = proxy { + builder = builder.proxy(p.clone()); + } + + builder .user_agent(UPDATE_USER_AGENT) .build() .context("failed to build update HTTP client") } /// Fetch the latest release metadata from GitHub. -fn fetch_latest_release(channel: ReleaseChannel) -> Result { +fn fetch_latest_release(channel: ReleaseChannel, proxy: &Option) -> 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 { @@ -364,8 +423,8 @@ fn fetch_latest_release(channel: ReleaseChannel) -> Result { }); } let release = match channel { - ReleaseChannel::Stable => fetch_latest_release_from_url(LATEST_RELEASE_URL), - ReleaseChannel::Beta => fetch_latest_beta_release_from_url(RELEASES_URL), + ReleaseChannel::Stable => fetch_latest_release_from_url(LATEST_RELEASE_URL, proxy), + ReleaseChannel::Beta => fetch_latest_beta_release_from_url(RELEASES_URL, proxy), }?; Ok(FetchedRelease { release, @@ -454,8 +513,8 @@ fn update_network_fallback_hint() -> String { ) } -fn fetch_latest_release_from_url(url: &str) -> Result { - let client = update_http_client()?; +fn fetch_latest_release_from_url(url: &str, proxy: &Option) -> Result { + let client = update_http_client(proxy)?; let response = client .get(url) .header(reqwest::header::ACCEPT, "application/vnd.github+json") @@ -477,8 +536,8 @@ 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()?; +fn fetch_latest_beta_release_from_url(url: &str, proxy: &Option) -> Result { + let client = update_http_client(proxy)?; let response = client .get(url) .header(reqwest::header::ACCEPT, "application/vnd.github+json") @@ -553,8 +612,8 @@ fn version_is_beta(version: &semver::Version) -> bool { } /// Download a URL to bytes. -fn download_url(url: &str) -> Result> { - let client = update_http_client()?; +fn download_url(url: &str, proxy: &Option) -> Result> { + let client = update_http_client(proxy)?; let response = client .get(url) .send() @@ -1119,7 +1178,8 @@ E3B0C44298FC1C149AFBF4C8996FB92427AE41E4649B934CA495991B7852B855 *codewhale-win ] }"#; let (url, request_rx, handle) = serve_http_once("200 OK", "application/json", body); - let release = fetch_latest_release_from_url(&url).expect("release JSON should parse"); + let release = + fetch_latest_release_from_url(&url, &None).expect("release JSON should parse"); assert_eq!(release.tag_name, "v9.9.9"); assert_eq!(release.assets.len(), 2); @@ -1142,7 +1202,7 @@ E3B0C44298FC1C149AFBF4C8996FB92427AE41E4649B934CA495991B7852B855 *codewhale-win fn fetch_latest_release_from_url_reports_http_errors() { let (url, _request_rx, handle) = serve_http_once("500 Internal Server Error", "text/plain", b"server broke"); - let err = fetch_latest_release_from_url(&url).expect_err("HTTP 500 should fail"); + let err = fetch_latest_release_from_url(&url, &None).expect_err("HTTP 500 should fail"); assert!( err.to_string().contains("HTTP 500"), @@ -1162,8 +1222,8 @@ E3B0C44298FC1C149AFBF4C8996FB92427AE41E4649B934CA495991B7852B855 *codewhale-win { "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"); + let release = fetch_latest_beta_release_from_url(&url, &None) + .expect("beta release JSON should parse"); assert_eq!(release.tag_name, "v0.9.0-beta.2"); assert!(release.prerelease); @@ -1184,7 +1244,8 @@ E3B0C44298FC1C149AFBF4C8996FB92427AE41E4649B934CA495991B7852B855 *codewhale-win { "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"); + let err = + fetch_latest_beta_release_from_url(&url, &None).expect_err("missing beta should fail"); assert!( err.to_string().contains("no beta release found"), @@ -1197,7 +1258,7 @@ E3B0C44298FC1C149AFBF4C8996FB92427AE41E4649B934CA495991B7852B855 *codewhale-win fn download_url_reads_binary_body_with_updater_user_agent() { let (url, request_rx, handle) = serve_http_once("200 OK", "application/octet-stream", b"\0binary bytes"); - let bytes = download_url(&url).expect("binary download should succeed"); + let bytes = download_url(&url, &None).expect("binary download should succeed"); assert_eq!(bytes, b"\0binary bytes"); From 6532a383148d9da96681818d3732b7b1d200ef13 Mon Sep 17 00:00:00 2001 From: AccMoment <1161677781@qq.com> Date: Wed, 27 May 2026 21:54:44 +0800 Subject: [PATCH 2/3] avoid dependency reverse reimplement validate_and_build_proxy and add test --- crates/cli/src/lib.rs | 41 ++++++++++++++++++++++++++++++++---- crates/cli/src/update.rs | 45 ++++++---------------------------------- 2 files changed, 43 insertions(+), 43 deletions(-) diff --git a/crates/cli/src/lib.rs b/crates/cli/src/lib.rs index 6d5cf17c..64b70ba6 100644 --- a/crates/cli/src/lib.rs +++ b/crates/cli/src/lib.rs @@ -240,6 +240,7 @@ struct UpdateArgs { /// Update to the latest beta release instead of the latest stable release. #[arg(long)] beta: bool, + /// Optional proxy URL to use for all update HTTP requests (e.g. `http://host:port` or `socks5://host:port`). #[arg(long)] proxy: Option, } @@ -571,7 +572,7 @@ fn run() -> Result<()> { Ok(()) } Some(Commands::Metrics(args)) => run_metrics_command(args), - Some(Commands::Update(args)) => update::run_update(args), + Some(Commands::Update(args)) => update::run_update(args.beta, args.proxy), None => { let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides); let forwarded = root_tui_passthrough(&cli)?; @@ -1684,6 +1685,7 @@ fn read_api_key_from_stdin() -> Result { #[cfg(test)] mod tests { use super::*; + use crate::update::validate_and_build_proxy; use clap::error::ErrorKind; use std::ffi::OsString; use std::sync::{Mutex, OnceLock}; @@ -2436,16 +2438,47 @@ mod tests { } #[test] - fn udpate_parse_with_proxy() { - let cli = parse_ok(&["deepseek", "update", "--proxy", "http:localhost:7897"]); + fn update_parse_with_proxy() { + let cli = parse_ok(&["deepseek", "update", "--proxy", "http://localhost:7897"]); let args = match cli.command { Some(Commands::Update(args)) => args, other => panic!("expected Update with proxy, got {other:?}"), }; + assert_eq!( args.proxy.expect("should have proxy"), - "http:localhost:7897" + "http://localhost:7897" + ); + + // Valid HTTP proxy + assert!( + validate_and_build_proxy("http://localhost:7897").is_ok(), + "valid HTTP proxy should succeed" + ); + + // Valid HTTPS proxy + assert!( + validate_and_build_proxy("https://proxy.example.com:8080").is_ok(), + "valid HTTPS proxy should succeed" + ); + + // Valid SOCKS5 proxy + assert!( + validate_and_build_proxy("socks5://127.0.0.1:1080").is_ok(), + "valid SOCKS5 proxy should succeed" + ); + + // Invalid: empty URL + assert!( + validate_and_build_proxy("").is_err(), + "empty proxy URL should fail" + ); + + // Invalid: malformed URL + assert!( + validate_and_build_proxy("not a valid url").is_err(), + "malformed proxy URL should fail" ); } diff --git a/crates/cli/src/update.rs b/crates/cli/src/update.rs index 44b26537..9fe6eebc 100644 --- a/crates/cli/src/update.rs +++ b/crates/cli/src/update.rs @@ -4,10 +4,8 @@ //! `github.com/Hmbown/CodeWhale/releases/latest`, downloads the //! platform-correct binary, verifies its SHA256 checksum, and atomically //! replaces the currently running binary. -use crate::UpdateArgs; use std::collections::HashMap; use std::path::{Path, PathBuf}; -use std::time::Duration; use anyhow::{Context, Result, bail}; use reqwest::Proxy; @@ -28,16 +26,15 @@ const LEGACY_UPDATE_VERSION_ENV: &str = "DEEPSEEK_VERSION"; const UPDATE_USER_AGENT: &str = "codewhale-updater"; /// Run the self-update workflow. -pub fn run_update(args: UpdateArgs) -> Result<()> { - let beta = args.beta; +pub fn run_update(beta: bool, proxy_arg: Option) -> 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"); - let proxy = if let Some(proxy_str) = &args.proxy { - validate_and_build_proxy(proxy_str)? + let proxy = if let Some(proxy_str) = &proxy_arg { + Some(validate_and_build_proxy(proxy_str)?) } else { None }; @@ -184,8 +181,8 @@ enum ReleaseSource { Mirror { base_url: String }, } -// Validate the proxy URL and optionally test connectivity before proceeding. -fn validate_and_build_proxy(proxy_str: &str) -> Result> { +/// Validate the proxy URL and optionally test connectivity before proceeding. +pub(crate) fn validate_and_build_proxy(proxy_str: &str) -> Result { let valid_url = reqwest::Url::parse(proxy_str).with_context(|| { format!( "invalid proxy URL: {proxy_str}\n\ @@ -194,37 +191,7 @@ fn validate_and_build_proxy(proxy_str: &str) -> Result> { })?; let proxy = reqwest::Proxy::all(valid_url)?; - - // Quick connectivity test through the proxy - let client = reqwest::blocking::Client::builder() - .proxy(proxy.clone()) - .user_agent(UPDATE_USER_AGENT) - .timeout(Duration::from_secs(10)) - .build() - .context("Could not build proxy HTTP client")?; - - match client.head(LATEST_RELEASE_URL).send() { - Ok(_) => Ok(Some(proxy)), - Err(e) => { - // Give a clear actionable error rather than a raw reqwest error - let hint = if e.is_timeout() || e.is_connect() { - "could not connect to the proxy server" - } else if e.is_request() { - "the request was sent but no response was received" - } else { - "an unexpected network error occurred" - }; - bail!( - "proxy connectivity failed: {hint}\n\ - Proxy URL: {proxy_str}\n\ - Details: {e}\n\ - Please verify:\n\ - - The proxy URL is correct\n\ - - The proxy server is running and reachable\n\ - - The proxy allows outbound connections to api.github.com" - ) - } - } + Ok(proxy) } pub(crate) fn release_arch_for_rust_arch(arch: &str) -> &str { From 1c570e00caffb1782b34acc64cf1c9c08a5ba48f Mon Sep 17 00:00:00 2001 From: AccMoment <1161677781@qq.com> Date: Wed, 27 May 2026 22:09:20 +0800 Subject: [PATCH 3/3] enable reqwest socks features --- Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index dae89151..465181d0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -37,7 +37,7 @@ chrono = { version = "0.4.43", features = ["serde"] } clap = { version = "4.5.54", features = ["derive"] } clap_complete = "4.5" dirs = "6.0.0" -reqwest = { version = "0.13.1", default-features = false, features = ["json", "rustls"] } +reqwest = { version = "0.13.1", default-features = false, features = ["json", "rustls","socks"] } rusqlite = { version = "0.32.1", features = ["bundled"] } serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0.149"