Skip to content

Commit

Permalink
Provide a prompt when the new version is available. (stellar#1571)
Browse files Browse the repository at this point in the history
  • Loading branch information
overcat authored Sep 2, 2024
1 parent d90cd78 commit bb36419
Show file tree
Hide file tree
Showing 8 changed files with 264 additions and 3 deletions.
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion cmd/soroban-cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ hyper-tls = "0.5"
http = "0.2.9"
regex = "1.6.0"
wasm-opt = { version = "0.114.0", optional = true }
chrono = "0.4.27"
chrono = { version = "0.4.27", features = ["serde"]}
rpassword = "7.2.0"
dirs = "4.0.0"
toml = "0.5.9"
Expand Down Expand Up @@ -121,6 +121,7 @@ flate2 = "1.0.30"
bytesize = "1.3.0"
humantime = "2.1.0"
phf = { version = "0.11.2", features = ["macros"] }
semver = "1.0.0"
glob = "0.3.1"

# For hyper-tls
Expand Down
9 changes: 9 additions & 0 deletions cmd/soroban-cli/src/cli.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
use clap::CommandFactory;
use dotenvy::dotenv;
use std::thread;
use tracing_subscriber::{fmt, EnvFilter};

use crate::upgrade_check::upgrade_check;
use crate::{commands, Root};

#[tokio::main]
Expand Down Expand Up @@ -70,6 +72,13 @@ pub async fn main() {
.expect("Failed to set the global tracing subscriber");
}

// Spawn a thread to check if a new version exists.
// It depends on logger, so we need to place it after
// the code block that initializes the logger.
thread::spawn(move || {
upgrade_check(root.global_args.quiet);
});

if let Err(e) = root.run().await {
eprintln!("error: {e}");
std::process::exit(1);
Expand Down
8 changes: 6 additions & 2 deletions cmd/soroban-cli/src/config/locator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,10 @@ pub enum Error {
CannotAccessAliasConfigFile,
#[error("cannot parse contract ID {0}: {1}")]
CannotParseContractId(String, DecodeError),
#[error("Failed to read upgrade check file: {path}: {error}")]
UpgradeCheckReadFailed { path: PathBuf, error: io::Error },
#[error("Failed to write upgrade check file: {path}: {error}")]
UpgradeCheckWriteFailed { path: PathBuf, error: io::Error },
}

#[derive(Debug, clap::Args, Default, Clone)]
Expand Down Expand Up @@ -330,7 +334,7 @@ impl Args {
}
}

fn ensure_directory(dir: PathBuf) -> Result<PathBuf, Error> {
pub fn ensure_directory(dir: PathBuf) -> Result<PathBuf, Error> {
let parent = dir.parent().ok_or(Error::HomeDirNotFound)?;
std::fs::create_dir_all(parent).map_err(|_| dir_creation_failed(parent))?;
Ok(dir)
Expand Down Expand Up @@ -453,7 +457,7 @@ impl KeyType {
}
}

fn global_config_path() -> Result<PathBuf, Error> {
pub fn global_config_path() -> Result<PathBuf, Error> {
Ok(if let Ok(config_home) = std::env::var("XDG_CONFIG_HOME") {
PathBuf::from_str(&config_home).map_err(|_| Error::XdgConfigHome(config_home))?
} else {
Expand Down
1 change: 1 addition & 0 deletions cmd/soroban-cli/src/config/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ pub mod data;
pub mod locator;
pub mod network;
pub mod secret;
pub mod upgrade_check;

#[derive(thiserror::Error, Debug)]
pub enum Error {
Expand Down
86 changes: 86 additions & 0 deletions cmd/soroban-cli/src/config/upgrade_check.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
use crate::config::locator;
use chrono::{DateTime, Utc};
use jsonrpsee_core::Serialize;
use semver::Version;
use serde::Deserialize;
use serde_json;
use std::fs;

const FILE_NAME: &str = "upgrade_check.json";

/// The `UpgradeCheck` struct represents the state of the upgrade check.
/// This state is global and stored in the `upgrade_check.json` file in
/// the global configuration directory.
#[derive(Serialize, Deserialize, Debug, PartialEq)]
pub struct UpgradeCheck {
/// The time of the latest check for a new version of the CLI.
pub latest_check_time: DateTime<Utc>,
/// The latest stable version of the CLI available on crates.io.
pub max_stable_version: Version,
/// The latest version of the CLI available on crates.io, including pre-releases.
pub max_version: Version,
}

impl Default for UpgradeCheck {
fn default() -> Self {
Self {
latest_check_time: DateTime::<Utc>::UNIX_EPOCH,
max_stable_version: Version::new(0, 0, 0),
max_version: Version::new(0, 0, 0),
}
}
}

impl UpgradeCheck {
/// Loads the state of the upgrade check from the global configuration directory.
/// If the file doesn't exist, returns a default instance of `UpgradeCheck`.
pub fn load() -> Result<Self, locator::Error> {
let path = locator::global_config_path()?.join(FILE_NAME);
if !path.exists() {
return Ok(Self::default());
}
let data = fs::read(&path)
.map_err(|error| locator::Error::UpgradeCheckReadFailed { path, error })?;
Ok(serde_json::from_slice(data.as_slice())?)
}

/// Saves the state of the upgrade check to the `upgrade_check.json` file in the global configuration directory.
pub fn save(&self) -> Result<(), locator::Error> {
let path = locator::global_config_path()?.join(FILE_NAME);
let path = locator::ensure_directory(path)?;
let data = serde_json::to_string(self).map_err(|_| locator::Error::ConfigSerialization)?;
fs::write(&path, data)
.map_err(|error| locator::Error::UpgradeCheckWriteFailed { path, error })
}
}

#[cfg(test)]
mod tests {
use super::*;
use std::env;

#[test]
fn test_upgrade_check_load_save() {
// Set the `XDG_CONFIG_HOME` environment variable to a temporary directory
let temp_dir = tempfile::tempdir().unwrap();
env::set_var("XDG_CONFIG_HOME", temp_dir.path());
// Test default loading
let default_check = UpgradeCheck::load().unwrap();
assert_eq!(default_check, UpgradeCheck::default());
assert_eq!(
default_check.latest_check_time,
DateTime::<Utc>::from_timestamp_millis(0).unwrap()
);
assert_eq!(default_check.max_stable_version, Version::new(0, 0, 0));

// Test saving and loading
let saved_check = UpgradeCheck {
latest_check_time: DateTime::<Utc>::from_timestamp(1_234_567_890, 0).unwrap(),
max_stable_version: Version::new(1, 2, 3),
max_version: Version::parse("1.2.4-rc.1").unwrap(),
};
saved_check.save().unwrap();
let loaded_check = UpgradeCheck::load().unwrap();
assert_eq!(loaded_check, saved_check);
}
}
1 change: 1 addition & 0 deletions cmd/soroban-cli/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ pub mod log;
pub mod print;
pub mod signer;
pub mod toid;
pub mod upgrade_check;
pub mod utils;
pub mod wasm;

Expand Down
158 changes: 158 additions & 0 deletions cmd/soroban-cli/src/upgrade_check.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
use crate::config::upgrade_check::UpgradeCheck;
use crate::print::Print;
use semver::Version;
use serde::Deserialize;
use std::error::Error;
use std::io::IsTerminal;
use std::time::Duration;

const MINIMUM_CHECK_INTERVAL: Duration = Duration::from_secs(60 * 60 * 24); // 1 day
const CRATES_IO_API_URL: &str = "https://crates.io/api/v1/crates/";
const REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
const NO_UPDATE_CHECK_ENV_VAR: &str = "STELLAR_NO_UPDATE_CHECK";

#[derive(Deserialize)]
struct CrateResponse {
#[serde(rename = "crate")]
crate_: Crate,
}

#[derive(Deserialize)]
struct Crate {
#[serde(rename = "max_stable_version")]
max_stable_version: Version,
#[serde(rename = "max_version")]
max_version: Version, // This is the latest version, including pre-releases
}

/// Fetch the latest stable version of the crate from crates.io
fn fetch_latest_crate_info() -> Result<Crate, Box<dyn Error>> {
let crate_name = env!("CARGO_PKG_NAME");
let url = format!("{CRATES_IO_API_URL}{crate_name}");
let response = ureq::get(&url).timeout(REQUEST_TIMEOUT).call()?;
let crate_data: CrateResponse = response.into_json()?;
Ok(crate_data.crate_)
}

/// Print a warning if a new version of the CLI is available
pub fn upgrade_check(quiet: bool) {
// We should skip the upgrade check if we're not in a tty environment.
if !std::io::stderr().is_terminal() {
return;
}

// We should skip the upgrade check if the user has disabled it by setting
// the environment variable (STELLAR_NO_UPDATE_CHECK)
if std::env::var(NO_UPDATE_CHECK_ENV_VAR).is_ok() {
return;
}

tracing::debug!("start upgrade check");

let current_version = crate::commands::version::pkg();

let mut stats = UpgradeCheck::load().unwrap_or_else(|e| {
tracing::error!("Failed to load upgrade check data: {e}");
UpgradeCheck::default()
});

let now = chrono::Utc::now();
// Skip fetch from crates.io if we've checked recently
if now - MINIMUM_CHECK_INTERVAL >= stats.latest_check_time {
match fetch_latest_crate_info() {
Ok(c) => {
stats = UpgradeCheck {
latest_check_time: now,
max_stable_version: c.max_stable_version,
max_version: c.max_version,
};
}
Err(e) => {
tracing::error!("Failed to fetch stellar-cli info from crates.io: {e}");
// Only update the latest check time if the fetch failed
// This way we don't spam the user with errors
stats.latest_check_time = now;
}
}

if let Err(e) = stats.save() {
tracing::error!("Failed to save upgrade check data: {e}");
}
}

let current_version = Version::parse(current_version).unwrap();
let latest_version = get_latest_version(&current_version, &stats);

if *latest_version > current_version {
let printer = Print::new(quiet);
printer.warnln(format!(
"A new release of stellar-cli is available: {current_version} -> {latest_version}"
));
}

tracing::debug!("finished upgrade check");
}

fn get_latest_version<'a>(current_version: &Version, stats: &'a UpgradeCheck) -> &'a Version {
if current_version.pre.is_empty() {
// If we are currently using a non-preview version
&stats.max_stable_version
} else {
// If we are currently using a preview version
if stats.max_stable_version > *current_version {
// If there is a new stable version available, we should use that instead
&stats.max_stable_version
} else {
&stats.max_version
}
}
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn test_fetch_latest_stable_version() {
let _ = fetch_latest_crate_info().unwrap();
}

#[test]
fn test_get_latest_version() {
let stats = UpgradeCheck {
latest_check_time: chrono::Utc::now(),
max_stable_version: Version::parse("1.0.0").unwrap(),
max_version: Version::parse("1.1.0-rc.1").unwrap(),
};

// When using a non-preview version
let current_version = Version::parse("0.9.0").unwrap();
let latest_version = get_latest_version(&current_version, &stats);
assert_eq!(*latest_version, Version::parse("1.0.0").unwrap());

// When using a preview version and a new stable version is available
let current_version = Version::parse("0.9.0-rc.1").unwrap();
let latest_version = get_latest_version(&current_version, &stats);
assert_eq!(*latest_version, Version::parse("1.0.0").unwrap());

// When using a preview version and no new stable version is available
let current_version = Version::parse("1.1.0-beta.1").unwrap();
let latest_version = get_latest_version(&current_version, &stats);
assert_eq!(*latest_version, Version::parse("1.1.0-rc.1").unwrap());
}

#[test]
fn test_semver_compare() {
assert!(Version::parse("0.1.0").unwrap() < Version::parse("0.2.0").unwrap());
assert!(Version::parse("0.1.0").unwrap() < Version::parse("0.1.1").unwrap());
assert!(Version::parse("0.1.0").unwrap() > Version::parse("0.1.0-rc.1").unwrap());
assert!(Version::parse("0.1.1-rc.1").unwrap() > Version::parse("0.1.0").unwrap());
assert!(Version::parse("0.1.0-rc.2").unwrap() > Version::parse("0.1.0-rc.1").unwrap());
assert!(Version::parse("0.1.0-rc.2").unwrap() > Version::parse("0.1.0-beta.2").unwrap());
assert!(Version::parse("0.1.0-beta.2").unwrap() > Version::parse("0.1.0-alpha.2").unwrap());
assert_eq!(
Version::parse("0.1.0-beta.2").unwrap(),
Version::parse("0.1.0-beta.2").unwrap()
);
}
}

0 comments on commit bb36419

Please sign in to comment.