diff --git a/Cargo.lock b/Cargo.lock index c75e1087e..fd397ddbe 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4789,6 +4789,7 @@ dependencies = [ "futures", "futures-util", "gix", + "glob", "heck 0.5.0", "hex", "home", @@ -4809,6 +4810,7 @@ dependencies = [ "regex", "rpassword", "rust-embed", + "semver", "sep5", "serde", "serde-aux", diff --git a/FULL_HELP_DOCS.md b/FULL_HELP_DOCS.md index c13098192..ab348886b 100644 --- a/FULL_HELP_DOCS.md +++ b/FULL_HELP_DOCS.md @@ -175,6 +175,7 @@ Utilities to manage contract aliases * `remove` — Remove contract alias * `add` — Add contract alias * `show` — Show the contract id associated with a given alias +* `ls` — List all aliases @@ -240,6 +241,19 @@ Show the contract id associated with a given alias +## `stellar contract alias ls` + +List all aliases + +**Usage:** `stellar contract alias ls [OPTIONS]` + +###### **Options:** + +* `--global` — Use global config +* `--config-dir ` — Location of config directory, default is "." + + + ## `stellar contract bindings` Generate code client bindings for a contract diff --git a/cmd/soroban-cli/Cargo.toml b/cmd/soroban-cli/Cargo.toml index 611898ecd..584ad0655 100644 --- a/cmd/soroban-cli/Cargo.toml +++ b/cmd/soroban-cli/Cargo.toml @@ -82,7 +82,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" @@ -123,8 +123,11 @@ bytesize = "1.3.0" humantime = "2.1.0" phf = { version = "0.11.2", features = ["macros"] } crossterm = "0.27.0" +semver = "1.0.0" +glob = "0.3.1" open = "5.3.0" url = "2.5.2" + # For hyper-tls [target.'cfg(unix)'.dependencies] openssl = { version = "=0.10.55", features = ["vendored"] } diff --git a/cmd/soroban-cli/src/cli.rs b/cmd/soroban-cli/src/cli.rs index 2d42a173b..44739de6a 100644 --- a/cmd/soroban-cli/src/cli.rs +++ b/cmd/soroban-cli/src/cli.rs @@ -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] @@ -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); diff --git a/cmd/soroban-cli/src/commands/contract/alias.rs b/cmd/soroban-cli/src/commands/contract/alias.rs index bea808cb4..7d9213097 100644 --- a/cmd/soroban-cli/src/commands/contract/alias.rs +++ b/cmd/soroban-cli/src/commands/contract/alias.rs @@ -1,6 +1,7 @@ use crate::commands::global; pub mod add; +pub mod ls; pub mod remove; pub mod show; @@ -14,6 +15,9 @@ pub enum Cmd { /// Show the contract id associated with a given alias Show(show::Cmd), + + /// List all aliases + Ls(ls::Cmd), } #[derive(thiserror::Error, Debug)] @@ -26,6 +30,9 @@ pub enum Error { #[error(transparent)] Show(#[from] show::Error), + + #[error(transparent)] + Ls(#[from] ls::Error), } impl Cmd { @@ -34,6 +41,7 @@ impl Cmd { Cmd::Remove(remove) => remove.run(global_args)?, Cmd::Add(add) => add.run(global_args)?, Cmd::Show(show) => show.run(global_args)?, + Cmd::Ls(ls) => ls.run()?, } Ok(()) } diff --git a/cmd/soroban-cli/src/commands/contract/alias/ls.rs b/cmd/soroban-cli/src/commands/contract/alias/ls.rs new file mode 100644 index 000000000..8d59ff2c5 --- /dev/null +++ b/cmd/soroban-cli/src/commands/contract/alias/ls.rs @@ -0,0 +1,104 @@ +use std::collections::HashMap; +use std::fmt::Debug; +use std::{fs, process}; + +use clap::{command, Parser}; + +use crate::commands::config::network; +use crate::config::{alias, locator}; + +#[derive(Parser, Debug, Clone)] +#[group(skip)] +pub struct Cmd { + #[command(flatten)] + pub config_locator: locator::Args, +} + +#[derive(thiserror::Error, Debug)] +pub enum Error { + #[error(transparent)] + Locator(#[from] locator::Error), + + #[error(transparent)] + Network(#[from] network::Error), + + #[error(transparent)] + PatternError(#[from] glob::PatternError), + + #[error(transparent)] + GlobError(#[from] glob::GlobError), + + #[error(transparent)] + IoError(#[from] std::io::Error), +} + +#[derive(Debug, Clone)] +struct AliasEntry { + alias: String, + contract: String, +} + +impl Cmd { + pub fn run(&self) -> Result<(), Error> { + let config_dir = self.config_locator.config_dir()?; + let pattern = config_dir + .join("contract-ids") + .join("*.json") + .to_string_lossy() + .into_owned(); + + let paths = glob::glob(&pattern)?; + let mut found = false; + let mut map: HashMap> = HashMap::new(); + + for path in paths { + let path = path?; + + if let Some(alias) = path.file_stem() { + let alias = alias.to_string_lossy().into_owned(); + let content = fs::read_to_string(path)?; + let data: alias::Data = serde_json::from_str(&content).unwrap_or_default(); + + for network_passphrase in data.ids.keys() { + let network_passphrase = network_passphrase.to_string(); + let contract = data + .ids + .get(&network_passphrase) + .map(ToString::to_string) + .unwrap_or_default(); + let entry = AliasEntry { + alias: alias.clone(), + contract, + }; + + let list = map.entry(network_passphrase.clone()).or_default(); + + list.push(entry.clone()); + } + } + } + + for network_passphrase in map.keys() { + if let Some(list) = map.clone().get_mut(network_passphrase) { + println!("ℹ️ Aliases available for network '{network_passphrase}'"); + + list.sort_by(|a, b| a.alias.cmp(&b.alias)); + + for entry in list { + found = true; + println!("{}: {}", entry.alias, entry.contract); + } + + println!(); + } + } + + if !found { + eprintln!("⚠️ No aliases defined for network"); + + process::exit(1); + } + + Ok(()) + } +} diff --git a/cmd/soroban-cli/src/config/locator.rs b/cmd/soroban-cli/src/config/locator.rs index c29cdfb2e..a6394ed9c 100644 --- a/cmd/soroban-cli/src/config/locator.rs +++ b/cmd/soroban-cli/src/config/locator.rs @@ -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)] @@ -338,7 +342,7 @@ impl Args { } } -fn ensure_directory(dir: PathBuf) -> Result { +pub fn ensure_directory(dir: PathBuf) -> Result { let parent = dir.parent().ok_or(Error::HomeDirNotFound)?; std::fs::create_dir_all(parent).map_err(|_| dir_creation_failed(parent))?; Ok(dir) @@ -461,7 +465,7 @@ impl KeyType { } } -fn global_config_path() -> Result { +pub fn global_config_path() -> Result { Ok(if let Ok(config_home) = std::env::var("XDG_CONFIG_HOME") { PathBuf::from_str(&config_home).map_err(|_| Error::XdgConfigHome(config_home))? } else { diff --git a/cmd/soroban-cli/src/config/mod.rs b/cmd/soroban-cli/src/config/mod.rs index 825acd565..129a374fb 100644 --- a/cmd/soroban-cli/src/config/mod.rs +++ b/cmd/soroban-cli/src/config/mod.rs @@ -16,6 +16,7 @@ pub mod locator; pub mod network; pub mod secret; pub mod sign_with; +pub mod upgrade_check; #[derive(thiserror::Error, Debug)] pub enum Error { diff --git a/cmd/soroban-cli/src/config/upgrade_check.rs b/cmd/soroban-cli/src/config/upgrade_check.rs new file mode 100644 index 000000000..d07b0fea8 --- /dev/null +++ b/cmd/soroban-cli/src/config/upgrade_check.rs @@ -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, + /// 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::::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 { + 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::::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::::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); + } +} diff --git a/cmd/soroban-cli/src/lib.rs b/cmd/soroban-cli/src/lib.rs index 84e1e6eb8..2a6a591ca 100644 --- a/cmd/soroban-cli/src/lib.rs +++ b/cmd/soroban-cli/src/lib.rs @@ -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; diff --git a/cmd/soroban-cli/src/upgrade_check.rs b/cmd/soroban-cli/src/upgrade_check.rs new file mode 100644 index 000000000..ecd1a4adc --- /dev/null +++ b/cmd/soroban-cli/src/upgrade_check.rs @@ -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> { + 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(¤t_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(¤t_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(¤t_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(¤t_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() + ); + } +}