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");