Merge pull request #2281 from AccMoment/add_proxy_option_to_update_command

feat(update):Add proxy option to update command
This commit is contained in:
Hunter Bown
2026-05-30 23:51:39 -07:00
committed by GitHub
6 changed files with 117 additions and 37 deletions
+1 -1
View File
@@ -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"
+3
View File
@@ -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)
+3
View File
@@ -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)
+2
View File
@@ -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)
+18 -4
View File
@@ -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<String>,
}
#[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]
+90 -32
View File
@@ -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<String>) -> Result<()> {
let current_exe =
std::env::current_exe().context("failed to determine current executable path")?;
let targets = update_targets_for_exe(&current_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> {
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<Proxy> {
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<reqwest::blocking::Client> {
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<String> {
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<FetchedRelease> {
fn fetch_latest_release(channel: ReleaseChannel, proxy: Option<&Proxy>) -> Result<FetchedRelease> {
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<FetchedRelease> {
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<Release> {
let body = fetch_release_json_blocking(url, "release info")?;
fn fetch_release_json(url: &str, description: &str, proxy: Option<&Proxy>) -> Result<String> {
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<Release> {
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<Release> {
Ok(release)
}
fn fetch_latest_beta_release_from_url(url: &str) -> Result<Release> {
let body = fetch_release_json_blocking(url, "release list")?;
fn fetch_latest_beta_release_from_url(url: &str, proxy: Option<&Proxy>) -> Result<Release> {
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<Release> = serde_json::from_str(&body).with_context(|| {
@@ -416,8 +460,8 @@ fn fetch_latest_beta_release_from_url(url: &str) -> Result<Release> {
}
/// Download a URL to bytes.
fn download_url(url: &str) -> Result<Vec<u8>> {
let client = update_http_client()?;
fn download_url(url: &str, proxy: Option<&Proxy>) -> Result<Vec<u8>> {
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");