diff --git a/Cargo.toml b/Cargo.toml index f29644d6..90b8a172 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -38,7 +38,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" diff --git a/README.ja-JP.md b/README.ja-JP.md index 745f3d59..92bfd0cb 100644 --- a/README.ja-JP.md +++ b/README.ja-JP.md @@ -53,6 +53,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 e47b6d0f..5d908629 100644 --- a/README.md +++ b/README.md @@ -66,6 +66,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 93a848fd..7fa6bca5 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -61,6 +61,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 bc7430fb..cdd7cb4f 100644 --- a/crates/cli/src/lib.rs +++ b/crates/cli/src/lib.rs @@ -247,6 +247,9 @@ struct UpdateArgs { /// Only check the latest release; do not download or replace binaries. #[arg(long)] check: bool, + /// Proxy URL to use for update HTTP requests. + #[arg(long, value_name = "URL")] + proxy: Option, } #[derive(Debug, Args)] @@ -576,7 +579,7 @@ fn run() -> Result<()> { Ok(()) } Some(Commands::Metrics(args)) => run_metrics_command(args), - Some(Commands::Update(args)) => update::run_update(args.beta, args.check), + Some(Commands::Update(args)) => update::run_update(args.beta, args.check, args.proxy), None => { let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides); let forwarded = root_tui_passthrough(&cli)?; @@ -1840,7 +1843,8 @@ mod tests { cli.command, Some(Commands::Update(UpdateArgs { beta: false, - check: false + check: false, + proxy: None })) )); @@ -1849,7 +1853,8 @@ mod tests { cli.command, Some(Commands::Update(UpdateArgs { beta: true, - check: false + check: false, + proxy: None })) )); @@ -1858,9 +1863,18 @@ mod tests { cli.command, Some(Commands::Update(UpdateArgs { beta: false, - check: true + check: true, + proxy: None })) )); + + let cli = parse_ok(&["codewhale", "update", "--proxy", "socks5://127.0.0.1:1080"]); + let Some(Commands::Update(args)) = cli.command else { + panic!("expected update command"); + }; + assert!(!args.beta); + assert!(!args.check); + assert_eq!(args.proxy.as_deref(), Some("socks5://127.0.0.1:1080")); } #[test] diff --git a/crates/cli/src/update.rs b/crates/cli/src/update.rs index 25060779..ed228e47 100644 --- a/crates/cli/src/update.rs +++ b/crates/cli/src/update.rs @@ -12,27 +12,31 @@ 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, + compare_release_versions, is_beta_tag, mirror_asset_url, resolve_release_query, + update_is_needed, update_network_fallback_hint, }; +use reqwest::Proxy; use std::io::Write; /// Run the self-update workflow. -pub fn run_update(beta: bool, check_only: bool) -> Result<()> { +pub fn run_update(beta: bool, check_only: 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 = proxy_arg + .as_deref() + .map(validate_and_build_proxy) + .transpose()?; println!("Checking for {} updates...", channel.label()); 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)?; + let latest_tag = latest_release_tag(channel, proxy.as_ref()) + .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}."); @@ -50,7 +54,8 @@ pub fn run_update(beta: bool, check_only: bool) -> Result<()> { } // 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.as_ref()).with_context(update_network_fallback_hint)?; let release = &fetched.release; let latest_tag = &release.tag_name; println!("Latest {} release: {latest_tag}", channel.label()); @@ -71,8 +76,8 @@ pub fn run_update(beta: bool, check_only: 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.as_ref()) + .with_context(|| { format!( "failed to download {}\n{}", checksum_asset.name, @@ -107,13 +112,14 @@ pub fn run_update(beta: bool, check_only: bool) -> Result<()> { })?; println!("Downloading {}...", asset.name); - let bytes = download_url(&asset.browser_download_url).with_context(|| { - format!( - "failed to download {}\n{}", - asset.name, - update_network_fallback_hint() - ) - })?; + let bytes = + download_url(&asset.browser_download_url, proxy.as_ref()).with_context(|| { + format!( + "failed to download {}\n{}", + asset.name, + update_network_fallback_hint() + ) + })?; if let Some(checksums) = &checksum_manifest { let expected = checksums @@ -335,15 +341,36 @@ struct Asset { browser_download_url: String, } -fn update_http_client() -> Result { - reqwest::blocking::Client::builder() +/// Validate the proxy URL format and build a proxy for update HTTP requests. +pub(crate) fn validate_and_build_proxy(proxy_str: &str) -> Result { + let proxy_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" + ) + })?; + Proxy::all(proxy_url).context("failed to configure update proxy") +} + +fn update_http_client(proxy: Option<&Proxy>) -> Result { + let mut builder = reqwest::blocking::Client::builder(); + if let Some(proxy) = proxy { + builder = builder.proxy(proxy.clone()); + } + builder .user_agent(UPDATE_USER_AGENT) .build() .context("failed to build update HTTP client") } +fn latest_release_tag(channel: ReleaseChannel, proxy: Option<&Proxy>) -> Result { + match fetch_latest_release(channel, proxy)? { + FetchedRelease { release, .. } => Ok(release.tag_name), + } +} + /// Fetch the latest release metadata from GitHub. -fn fetch_latest_release(channel: ReleaseChannel) -> Result { +fn fetch_latest_release(channel: ReleaseChannel, proxy: Option<&Proxy>) -> Result { match resolve_release_query(channel) { ReleaseQuery::Mirror { base_url, version } => Ok(FetchedRelease { release: release_from_mirror_base_url( @@ -355,11 +382,11 @@ fn fetch_latest_release(channel: ReleaseChannel) -> Result { source: UpdateReleaseSource::Mirror { base_url }, }), ReleaseQuery::GitHubLatest { url } => Ok(FetchedRelease { - release: fetch_latest_release_from_url(url)?, + release: fetch_latest_release_from_url(url, proxy)?, source: UpdateReleaseSource::GitHub, }), ReleaseQuery::GitHubReleaseList { url } => Ok(FetchedRelease { - release: fetch_latest_beta_release_from_url(url)?, + release: fetch_latest_beta_release_from_url(url, proxy)?, source: UpdateReleaseSource::GitHub, }), } @@ -392,8 +419,25 @@ fn release_from_mirror_base_url( } } -fn fetch_latest_release_from_url(url: &str) -> Result { - let body = fetch_release_json_blocking(url, "release info")?; +fn fetch_release_json(url: &str, description: &str, proxy: Option<&Proxy>) -> Result { + let client = update_http_client(proxy)?; + 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 body from {url}"))?; + if !status.is_success() { + bail!("failed to fetch {description} from {url}: HTTP {status}\n{body}"); + } + Ok(body) +} + +fn fetch_latest_release_from_url(url: &str, proxy: Option<&Proxy>) -> Result { + let body = fetch_release_json(url, "release info", proxy)?; let release: Release = serde_json::from_str(&body).with_context(|| { format!("failed to parse release JSON from GitHub API. Response: {body}") })?; @@ -401,8 +445,8 @@ fn fetch_latest_release_from_url(url: &str) -> Result { Ok(release) } -fn fetch_latest_beta_release_from_url(url: &str) -> Result { - let body = fetch_release_json_blocking(url, "release list")?; +fn fetch_latest_beta_release_from_url(url: &str, proxy: Option<&Proxy>) -> Result { + let body = fetch_release_json(url, "release list", proxy)?; // 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(|| { @@ -416,8 +460,8 @@ fn fetch_latest_beta_release_from_url(url: &str) -> Result { } /// Download a URL to bytes. -fn download_url(url: &str) -> Result> { - let client = update_http_client()?; +fn download_url(url: &str, proxy: Option<&Proxy>) -> Result> { + let client = update_http_client(proxy)?; let response = client .get(url) .send() @@ -978,6 +1022,19 @@ E3B0C44298FC1C149AFBF4C8996FB92427AE41E4649B934CA495991B7852B855 *codewhale-win (format!("http://{addr}/release"), request_rx, handle) } + #[test] + fn validate_and_build_proxy_accepts_supported_proxy_urls() { + validate_and_build_proxy("http://localhost:7897").expect("http proxy"); + validate_and_build_proxy("https://proxy.example.com:8080").expect("https proxy"); + validate_and_build_proxy("socks5://127.0.0.1:1080").expect("socks proxy"); + } + + #[test] + fn validate_and_build_proxy_rejects_malformed_urls() { + let err = validate_and_build_proxy("not a valid url").expect_err("malformed URL"); + assert!(err.to_string().contains("invalid proxy URL")); + } + #[test] fn fetch_latest_release_from_url_reads_mocked_release_json() { let body = br#"{ @@ -988,7 +1045,7 @@ 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); @@ -1011,7 +1068,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"), @@ -1032,7 +1089,7 @@ E3B0C44298FC1C149AFBF4C8996FB92427AE41E4649B934CA495991B7852B855 *codewhale-win ]"#; 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"); + 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); @@ -1053,7 +1110,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"), @@ -1066,7 +1124,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");