diff --git a/src/commands/config.rs b/src/commands/config.rs index f5c2a612d..855dedb18 100644 --- a/src/commands/config.rs +++ b/src/commands/config.rs @@ -9,7 +9,7 @@ use cloudflare::endpoints::user::{GetUserDetails, GetUserTokenStatus}; use cloudflare::framework::apiclient::ApiClient; use crate::http; -use crate::settings::global_user::{get_global_config_path, GlobalUser}; +use crate::settings::{get_global_config_path, global_user::GlobalUser}; use crate::terminal::{message, styles}; // set the permissions on the dir, we want to avoid that other user reads to file diff --git a/src/lib.rs b/src/lib.rs index 6e4f9da8a..ea5eea417 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -16,6 +16,7 @@ pub mod settings; pub mod tail; pub mod terminal; pub mod upload; +pub mod version; pub mod watch; pub mod wranglerjs; diff --git a/src/main.rs b/src/main.rs index e1bf6904d..0f30ec8da 100644 --- a/src/main.rs +++ b/src/main.rs @@ -19,9 +19,11 @@ use wrangler::settings; use wrangler::settings::global_user::GlobalUser; use wrangler::settings::toml::TargetType; use wrangler::terminal::{emoji, interactive, message, styles}; +use wrangler::version::background_check_for_updates; fn main() -> Result<(), ExitFailure> { env_logger::init(); + let latest_version_receiver = background_check_for_updates(); if let Ok(me) = env::current_exe() { // If we're actually running as the installer then execute our // self-installation, otherwise just continue as usual. @@ -34,7 +36,23 @@ fn main() -> Result<(), ExitFailure> { installer::install(); } } - Ok(run()?) + run()?; + if let Ok(latest_version) = latest_version_receiver.try_recv() { + let latest_version = styles::highlight(latest_version.to_string()); + let new_version_available = format!( + "A new version of Wrangler ({}) is available!", + latest_version + ); + let update_message = "You can learn more about updating here:".to_string(); + let update_docs_url = + styles::url("https://developers.cloudflare.com/workers/quickstart#updating-the-cli"); + + message::billboard(&format!( + "{}\n{}\n{}", + new_version_available, update_message, update_docs_url + )); + } + Ok(()) } #[allow(clippy::cognitive_complexity)] diff --git a/src/settings/global_config.rs b/src/settings/global_config.rs new file mode 100644 index 000000000..70402fe61 --- /dev/null +++ b/src/settings/global_config.rs @@ -0,0 +1,24 @@ +use std::env; +use std::path::{Path, PathBuf}; + +pub const DEFAULT_CONFIG_FILE_NAME: &str = "default.toml"; + +pub fn get_wrangler_home_dir() -> Result { + let config_dir = if let Ok(value) = env::var("WRANGLER_HOME") { + log::info!("Using $WRANGLER_HOME: {}", value); + Path::new(&value).to_path_buf() + } else { + log::info!("No $WRANGLER_HOME detected, using $HOME"); + dirs::home_dir() + .expect("Could not find home directory") + .join(".wrangler") + }; + Ok(config_dir) +} + +pub fn get_global_config_path() -> Result { + let home_dir = get_wrangler_home_dir()?; + let global_config_file = home_dir.join("config").join(DEFAULT_CONFIG_FILE_NAME); + log::info!("Using global config file: {}", global_config_file.display()); + Ok(global_config_file) +} diff --git a/src/settings/global_user.rs b/src/settings/global_user.rs index faee2b4ad..5bb6e1d7a 100644 --- a/src/settings/global_user.rs +++ b/src/settings/global_user.rs @@ -1,15 +1,12 @@ -use std::env; use std::fs; use std::path::{Path, PathBuf}; use cloudflare::framework::auth::Credentials; use serde::{Deserialize, Serialize}; -use crate::settings::{Environment, QueryEnvironment}; +use crate::settings::{get_global_config_path, Environment, QueryEnvironment}; use crate::terminal::{emoji, styles}; -const DEFAULT_CONFIG_FILE_NAME: &str = "default.toml"; - const CF_API_TOKEN: &str = "CF_API_TOKEN"; const CF_API_KEY: &str = "CF_API_KEY"; const CF_EMAIL: &str = "CF_EMAIL"; @@ -133,28 +130,13 @@ impl From for Credentials { } } -pub fn get_global_config_path() -> Result { - let home_dir = if let Ok(value) = env::var("WRANGLER_HOME") { - log::info!("Using $WRANGLER_HOME: {}", value); - Path::new(&value).to_path_buf() - } else { - log::info!("No $WRANGLER_HOME detected, using $HOME"); - dirs::home_dir() - .expect("Could not find home directory") - .join(".wrangler") - }; - let global_config_file = home_dir.join("config").join(DEFAULT_CONFIG_FILE_NAME); - log::info!("Using global config file: {}", global_config_file.display()); - Ok(global_config_file) -} - #[cfg(test)] mod tests { use super::*; use std::fs::File; use tempfile::tempdir; - use crate::settings::environment::MockEnvironment; + use crate::settings::{environment::MockEnvironment, DEFAULT_CONFIG_FILE_NAME}; #[test] fn it_can_prioritize_token_input() { diff --git a/src/settings/mod.rs b/src/settings/mod.rs index 5e2285e4c..3cfe1c803 100644 --- a/src/settings/mod.rs +++ b/src/settings/mod.rs @@ -1,7 +1,9 @@ pub mod binding; mod environment; +mod global_config; pub mod global_user; pub mod metadata; pub mod toml; pub use environment::{Environment, QueryEnvironment}; +pub use global_config::{get_global_config_path, get_wrangler_home_dir, DEFAULT_CONFIG_FILE_NAME}; diff --git a/src/terminal/styles.rs b/src/terminal/styles.rs index 12dea0c6a..50bd0571b 100644 --- a/src/terminal/styles.rs +++ b/src/terminal/styles.rs @@ -1,13 +1,13 @@ use console::{style, StyledObject}; -pub fn url(msg: &str) -> StyledObject<&str> { +pub fn url(msg: D) -> StyledObject { style(msg).blue().bold() } -pub fn warning(msg: &str) -> StyledObject<&str> { +pub fn warning(msg: D) -> StyledObject { style(msg).red().bold() } -pub fn highlight(msg: &str) -> StyledObject<&str> { +pub fn highlight(msg: D) -> StyledObject { style(msg).yellow().bold() } diff --git a/src/version/mod.rs b/src/version/mod.rs new file mode 100644 index 000000000..53baec09a --- /dev/null +++ b/src/version/mod.rs @@ -0,0 +1,157 @@ +use std::fs; +use std::path::PathBuf; +use std::str::FromStr; +use std::sync::mpsc; +use std::thread; +use std::time::SystemTime; + +use crate::settings::get_wrangler_home_dir; + +use reqwest::header::USER_AGENT; +use semver::Version; +use serde::{Deserialize, Serialize}; + +const ONE_DAY: u64 = 60 * 60 * 24; + +pub fn background_check_for_updates() -> mpsc::Receiver { + let (sender, receiver) = mpsc::channel(); + + let _detached_thread = thread::spawn(move || match check_wrangler_versions() { + Ok(wrangler_versions) => { + // If the wrangler version has not been checked within the last day and the versions + // are different, print out an update message + if wrangler_versions.is_outdated() { + let _ = sender.send(wrangler_versions.latest); + } + } + Err(e) => log::debug!("could not determine if update is needed:\n{}", e), + }); + + receiver +} + +#[derive(Debug, Clone)] +struct WranglerVersion { + /// currently installed version of wrangler + pub current: Version, + + /// latest version of wrangler on crates.io + pub latest: Version, + + /// set to true if wrangler version has been checked within a day + pub checked: bool, +} + +impl WranglerVersion { + pub fn is_outdated(&self) -> bool { + !self.checked && (self.current != self.latest) + } +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +struct LastCheckedVersion { + /// latest version as of last time we checked + latest_version: String, + + /// the last time we asked crates.io for the latest version + last_checked: SystemTime, +} + +impl FromStr for LastCheckedVersion { + type Err = toml::de::Error; + + fn from_str(serialized_toml: &str) -> Result { + toml::from_str(serialized_toml) + } +} + +fn get_installed_version() -> Result { + let version = option_env!("CARGO_PKG_VERSION").unwrap_or_else(|| "unknown"); + let parsed_version = Version::parse(version)?; + Ok(parsed_version) +} + +fn check_wrangler_versions() -> Result { + let config_dir = get_wrangler_home_dir()?; + let version_file = config_dir.join("version.toml"); + let current_time = SystemTime::now(); + + let mut checked = false; + let current = get_installed_version()?; + + let latest = match get_version_disk(&version_file) { + Some(last_checked_version) => { + let time_since_last_checked = + current_time.duration_since(last_checked_version.last_checked)?; + + if time_since_last_checked.as_secs() < ONE_DAY { + checked = true; + Version::parse(&last_checked_version.latest_version)? + } else { + get_latest_version(¤t.to_string(), &version_file, current_time)? + } + } + // If version.toml doesn't exist, fetch latest version + None => get_latest_version(¤t.to_string(), &version_file, current_time)?, + }; + + Ok(WranglerVersion { + current, + latest, + checked, + }) +} + +/// Reads version out of version file, is `None` if file does not exist or is corrupted +fn get_version_disk(version_file: &PathBuf) -> Option { + match fs::read_to_string(&version_file) { + Ok(contents) => match LastCheckedVersion::from_str(&contents) { + Ok(last_checked_version) => Some(last_checked_version), + Err(_) => None, + }, + Err(_) => None, + } +} + +fn get_latest_version( + installed_version: &str, + version_file: &PathBuf, + current_time: SystemTime, +) -> Result { + let latest_version = get_latest_version_from_api(installed_version)?; + let updated_file_contents = toml::to_string(&LastCheckedVersion { + latest_version: latest_version.to_string(), + last_checked: current_time, + })?; + fs::write(&version_file, updated_file_contents)?; + Ok(latest_version) +} + +fn get_latest_version_from_api(installed_version: &str) -> Result { + let url = "https://crates.io/api/v1/crates/wrangler"; + let user_agent = format!( + "wrangler/{} ({})", + installed_version, + env!("CARGO_PKG_REPOSITORY") + ); + let response = reqwest::blocking::Client::new() + .get(url) + .header(USER_AGENT, user_agent) + .send()? + .error_for_status()?; + let text = response.text()?; + let crt: ApiResponse = serde_json::from_str(&text)?; + let version = Version::parse(&crt.info.max_version)?; + Ok(version) +} + +#[derive(Deserialize, Debug)] +struct ApiResponse { + #[serde(rename = "crate")] + info: CrateInformation, +} + +#[derive(Deserialize, Debug)] +struct CrateInformation { + max_version: String, +}