diff --git a/Cargo.lock b/Cargo.lock index 77dce57c36..70cea3dcfd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2353,17 +2353,6 @@ version = "3.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" -[[package]] -name = "etcetera" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26c7b13d0780cb82722fd59f6f57f925e143427e4a75313a6c77243bf5326ae6" -dependencies = [ - "cfg-if", - "home", - "windows-sys 0.59.0", -] - [[package]] name = "ethnum" version = "1.5.3" @@ -3322,23 +3311,6 @@ dependencies = [ "tokio", "tokio-rustls", "tower-service", - "webpki-roots", -] - -[[package]] -name = "hyper-tls" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" -dependencies = [ - "bytes", - "http-body-util", - "hyper", - "hyper-util", - "native-tls", - "tokio", - "tokio-native-tls", - "tower-service", ] [[package]] @@ -5031,7 +5003,6 @@ dependencies = [ "umask", "unicode-segmentation", "unicode-width 0.2.2", - "update-informer", "ureq", "url", "uu_cp", @@ -7969,7 +7940,6 @@ checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ "base64 0.22.1", "bytes", - "futures-channel", "futures-core", "futures-util", "h2", @@ -7978,11 +7948,9 @@ dependencies = [ "http-body-util", "hyper", "hyper-rustls", - "hyper-tls", "hyper-util", "js-sys", "log", - "native-tls", "percent-encoding", "pin-project-lite", "quinn", @@ -7994,7 +7962,6 @@ dependencies = [ "serde_urlencoded", "sync_wrapper", "tokio", - "tokio-native-tls", "tokio-rustls", "tokio-util", "tower", @@ -8005,7 +7972,6 @@ dependencies = [ "wasm-bindgen-futures", "wasm-streams", "web-sys", - "webpki-roots", ] [[package]] @@ -9476,16 +9442,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "tokio-native-tls" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" -dependencies = [ - "native-tls", - "tokio", -] - [[package]] name = "tokio-rustls" version = "0.26.4" @@ -9932,20 +9888,6 @@ version = "0.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6d49784317cd0d1ee7ec5c716dd598ec5b4483ea832a2dced265471cc0f690ae" -[[package]] -name = "update-informer" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67b27dcf766dc6ad64c2085201626e1a7955dc1983532bfc8406d552903ace2a" -dependencies = [ - "etcetera", - "reqwest", - "semver", - "serde", - "serde_json", - "ureq", -] - [[package]] name = "ureq" version = "3.4.0" diff --git a/Cargo.toml b/Cargo.toml index c57d2e5c7a..688a5cd882 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -277,7 +277,6 @@ toml_edit = "0.25.13" kdl = { version = "6.7.1", features = ["v1"] } trash = "=5.2.9" typetag = "0.2.21" -update-informer = { version = "1.3.0", default-features = false, features = ["github", "ureq"] } umask = "2.1" unicode-segmentation = "1.13" unicode-width = "0.2" diff --git a/crates/nu-command/Cargo.toml b/crates/nu-command/Cargo.toml index 01b5cfa42e..aab4a4d84c 100644 --- a/crates/nu-command/Cargo.toml +++ b/crates/nu-command/Cargo.toml @@ -127,7 +127,6 @@ toml = { workspace = true, features = ["preserve_order"] } toml_edit = { workspace = true } kdl = { workspace = true } unicode-segmentation = { workspace = true } -update-informer = { workspace = true, optional = true } ureq = { workspace = true, default-features = false, features = [ "charset", "gzip", @@ -240,18 +239,16 @@ network = [ "dns-lookup", "multipart-rs", "nu-test-support/network", - "update-informer", "ureq", "uuid", ] -native-tls = ["dep:native-tls", "update-informer/native-tls", "ureq/native-tls"] +native-tls = ["dep:native-tls", "ureq/native-tls"] rustls-tls = [ "dep:rustls-native-certs", "dep:rustls", "dep:webpki-roots", "nu-test-support/rustls-tls", - "update-informer/rustls-tls", "ureq/rustls", ] diff --git a/crates/nu-command/src/network/http/mod.rs b/crates/nu-command/src/network/http/mod.rs index 1f8e40beaa..8afa95ffc5 100644 --- a/crates/nu-command/src/network/http/mod.rs +++ b/crates/nu-command/src/network/http/mod.rs @@ -1,4 +1,4 @@ -mod client; +pub(super) mod client; mod delete; mod get; mod head; diff --git a/crates/nu-command/src/network/version_check.rs b/crates/nu-command/src/network/version_check.rs index 1c97ee4a4e..8b293493e7 100644 --- a/crates/nu-command/src/network/version_check.rs +++ b/crates/nu-command/src/network/version_check.rs @@ -1,10 +1,11 @@ +use crate::network::http::client::{RedirectMode, http_client, send_request_no_body}; use nu_engine::command_prelude::*; +use nu_protocol::Signals; +use nu_protocol::shell_error::generic::GenericError; +use semver::Version; use serde::Deserialize; -use update_informer::{ - Check, Package, Registry, Result as UpdateResult, - http_client::{GenericHttpClient, HttpClient}, - registry, -}; +use serde::de::DeserializeOwned; +use std::borrow::Cow; #[derive(Clone)] pub struct VersionCheck; @@ -19,7 +20,12 @@ impl Command for VersionCheck { } fn extra_description(&self) -> &str { - "If you're running nushell nightly, `version check` will check to see if you are running the latest nightly version. If you are running the nushell release, `version check` will check to see if you're running the latest release version." + "If you're running nushell nightly, `version check` will check to see if you are running + the latest nightly version. If you are running the nushell release, `version check` will check + to see if you're running the latest release version. + +If `GITHUB_TOKEN` (or `GH_TOKEN`) is set in the environment, it will be used to authenticate the + check against the GitHub API. When both are set, `GH_TOKEN` takes precedence, as with the `gh` CLI." } fn signature(&self) -> Signature { @@ -38,95 +44,222 @@ impl Command for VersionCheck { fn run( &self, - _engine_state: &EngineState, - _stack: &mut Stack, - _call: &Call, + engine_state: &EngineState, + stack: &mut Stack, + call: &Call, _input: PipelineData, ) -> Result { - let version_check = check_for_latest_nushell_version(); + let agent = http_client(false, RedirectMode::Follow, None, engine_state, stack)?; + let token = github_token(engine_state, stack); + let signals = engine_state.signals(); + let version_check = check_for_latest_nushell_version(&agent, signals, token, call.head)?; Ok(version_check.into_pipeline_data()) } } -pub struct NuShellNightly; +#[derive(Deserialize, Debug)] +struct GitHubRelease { + tag_name: String, +} -impl Registry for NuShellNightly { - const NAME: &'static str = "nushell/nightly"; +/// Gets a JSON document from GitHub's API without treating non-2xx responses as success. +/// +/// Errors from the HTTP stack are passed along as they are; a non-2xx status or an unparsable +/// body is reported with a hint based on what happened. +fn github_get_json( + agent: &ureq::Agent, + url: &str, + token: Option<&str>, + signals: &Signals, + span: Span, +) -> Result { + let mut response = send_github_request(agent, url, token, signals, span)?; - fn get_latest_version( - http_client: GenericHttpClient, - pkg: &Package, - ) -> UpdateResult> { - #[derive(Deserialize, Debug)] - struct Response { - tag_name: String, + // GitHub rejects an invalid token with a 401 status, so an authorized request that fails with + // one is retried unauthenticated; without a token the check works with the unauthenticated + // API rate limit. + if response.status().as_u16() == 401 && token.is_some() { + response = send_github_request(agent, url, None, signals, span)?; + } + + let status = response.status(); + if !status.is_success() { + return Err(github_check_error( + format!("http status: {status}"), + github_status_help(status.as_u16()), + span, + )); + } + + response.body_mut().read_json::().map_err(|err| { + github_check_error( + err.to_string(), + "The response from GitHub is not the expected release data", + span, + ) + }) +} + +/// Sends a GET request to the GitHub API. +fn send_github_request( + agent: &ureq::Agent, + url: &str, + token: Option<&str>, + signals: &Signals, + span: Span, +) -> Result, ShellError> { + let (response, _) = + send_request_no_body(build_github_request(agent, url, token), span, span, signals); + response +} + +fn build_github_request( + agent: &ureq::Agent, + url: &str, + token: Option<&str>, +) -> ureq::RequestBuilder { + let mut request = agent + .get(url) + .header("Accept", "application/vnd.github.v3+json"); + if let Some(token) = token { + request = request.header("Authorization", format!("Bearer {token}")); + } + request +} + +/// Help for errors from the status the GitHub API reported. +fn github_status_help(status: u16) -> &'static str { + match status { + 401 => { + "GitHub rejected the request as unauthorized; if GITHUB_TOKEN (or GH_TOKEN) is set, it is likely invalid or expired" } - - let url = format!("https://api.github.com/repos/{pkg}/releases"); - let versions = http_client - .add_header("Accept", "application/vnd.github.v3+json") - .add_header("User-Agent", "update-informer") - .get::>(&url)?; - - if let Some(v) = versions.first() { - // The nightly repo tags look like "0.102.0-nightly.4+23dc1b6" - // We want to return the "0.102.0-nightly.4" part because hustcer - // is changing the cargo.toml package.version to be that syntax - let up_through_plus = match v.tag_name.split('+').next() { - Some(v) => v, - None => &v.tag_name, - }; - return Ok(Some(up_through_plus.to_string())); + 403 | 429 => { + "GitHub is rate limiting you; a valid GITHUB_TOKEN (or GH_TOKEN) raises the request limit" } - - Ok(None) + _ => "GitHub may be having API problems", } } -pub fn check_for_latest_nushell_version() -> Value { +/// Creates the error `version check` reports when the check with GitHub failed. +fn github_check_error( + msg: impl Into>, + help: &'static str, + span: Span, +) -> ShellError { + ShellError::Generic( + GenericError::new("failed to check for the latest version", msg, span).with_help(help), + ) +} + +/// Looks up `GITHUB_TOKEN` (or `GH_TOKEN`), skipping variables that do not hold a non-empty token. +/// +/// `GH_TOKEN` takes precedence, as with the `gh` CLI. +fn github_token(engine_state: &EngineState, stack: &Stack) -> Option { + ["GH_TOKEN", "GITHUB_TOKEN"].iter().find_map(|name| { + stack + .get_env_var(engine_state, name) + .and_then(|value| value.coerce_str().ok()) + .map(|token| token.trim().to_owned()) + .filter(|token| !token.is_empty()) + }) +} + +fn check_for_latest_nushell_version( + agent: &ureq::Agent, + signals: &Signals, + token: Option, + span: Span, +) -> Result { let current_version = env!("CARGO_PKG_VERSION").to_string(); let mut rec = Record::new(); - if current_version.contains("nightly") { - rec.push("channel", Value::test_string("nightly")); + let latest_tag = if current_version.contains("nightly") { + rec.push("channel", Value::string("nightly", span)); - let nightly_pkg_name = "nushell/nightly"; - // The .interval() determines how long the cached check lives. Setting it to std::time::Duration::ZERO - // means that there is essentially no cache and it will check for a new version each time you run nushell. - // Since this is run on demand, there isn't really a need to cache the check. - let informer = - update_informer::new(NuShellNightly, nightly_pkg_name, current_version.clone()) - .interval(std::time::Duration::ZERO); + let url = "https://api.github.com/repos/nushell/nightly/releases"; + let releases: Vec = + github_get_json(agent, url, token.as_deref(), signals, span)?; - if let Ok(Some(new_version)) = informer.check_version() { - rec.push("current", Value::test_bool(false)); - rec.push("latest", Value::test_string(format!("{new_version}"))); - Value::test_record(rec) - } else { - rec.push("current", Value::test_bool(true)); - rec.push("latest", Value::test_string(current_version.clone())); - Value::test_record(rec) - } + releases.first().map(|release| { + // Nightly repo tags look like "0.102.0-nightly.4+23dc1b6", but the running + // nightly build reports its version as "0.102.0-nightly.4", so the build + // metadata of the tag is stripped before the comparison. + release + .tag_name + .split('+') + .next() + .unwrap_or(&release.tag_name) + .to_owned() + }) } else { - rec.push("channel", Value::test_string("release")); + rec.push("channel", Value::string("release", span)); - let normal_pkg_name = "nushell/nushell"; - // By default, this update request is cached for 24 hours so it won't check for a new version - // each time you run nushell. Since this is run on demand, there isn't really a need to cache the check which - // is why we set the interval to std::time::Duration::ZERO. - let informer = - update_informer::new(registry::GitHub, normal_pkg_name, current_version.clone()) - .interval(std::time::Duration::ZERO); + let url = "https://api.github.com/repos/nushell/nushell/releases/latest"; + let release: GitHubRelease = github_get_json(agent, url, token.as_deref(), signals, span)?; + Some(release.tag_name) + }; - if let Ok(Some(new_version)) = informer.check_version() { - rec.push("current", Value::test_bool(false)); - rec.push("latest", Value::test_string(format!("{new_version}"))); - Value::test_record(rec) - } else { - rec.push("current", Value::test_bool(true)); - rec.push("latest", Value::test_string(current_version.clone())); - Value::test_record(rec) + let latest = latest_tag + .map(|tag| { + Version::parse(tag.strip_prefix('v').unwrap_or(&tag)).map_err(|err| { + github_check_error( + err.to_string(), + "The release reported by GitHub does not use a valid version", + span, + ) + }) + }) + .transpose()?; + let current = Version::parse(¤t_version).map_err(|err| { + github_check_error( + err.to_string(), + "The running version of nushell is not a valid semver version", + span, + ) + })?; + + match latest { + Some(latest) if latest > current => { + rec.push("current", Value::bool(false, span)); + rec.push("latest", Value::string(format!("v{latest}"), span)); + } + _ => { + rec.push("current", Value::bool(true, span)); + rec.push("latest", Value::string(current_version, span)); } } + + Ok(Value::record(rec, span)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn github_token_takes_gh_token_first_like_gh_cli() { + let engine_state = EngineState::new(); + let mut stack = Stack::new(); + stack.add_env_var("GITHUB_TOKEN".to_owned(), Value::test_string("first")); + stack.add_env_var("GH_TOKEN".to_owned(), Value::test_string("second")); + + assert_eq!( + github_token(&engine_state, &stack).as_deref(), + Some("second") + ); + } + + #[test] + fn github_token_falls_through_empty_values_to_the_next_name() { + let engine_state = EngineState::new(); + let mut stack = Stack::new(); + stack.add_env_var("GH_TOKEN".to_owned(), Value::test_string(" ")); + stack.add_env_var("GITHUB_TOKEN".to_owned(), Value::test_string("token")); + + assert_eq!( + github_token(&engine_state, &stack).as_deref(), + Some("token") + ); + } }