diff --git a/leo/cli/cli.rs b/leo/cli/cli.rs index f883589b36..4d4d89e9fd 100644 --- a/leo/cli/cli.rs +++ b/leo/cli/cli.rs @@ -17,11 +17,64 @@ use crate::cli::{commands::*, context::*, helpers::*}; use clap::Parser; use leo_errors::Result; -use std::{path::PathBuf, process::exit}; +use self_update::version::bump_is_greater; +use std::{path::PathBuf, process::exit, sync::OnceLock}; + +static VERSION_UPDATE_VERSION_STRING: OnceLock = OnceLock::new(); +static HELP_UPDATE_VERSION_STRING: OnceLock = OnceLock::new(); + +/// Generates a static string containing an update notification to be shown before the help message. +/// +/// OnceLock is used because we need a thread-safe way to lazily initialize a static string. +fn show_update_notification_before_help() -> &'static str { + HELP_UPDATE_VERSION_STRING.get_or_init(|| { + // Get the current version of the package. + let current_version = env!("CARGO_PKG_VERSION"); + let mut help_output = String::new(); + + // Attempt to read the latest version. + if let Ok(Some(latest_version)) = updater::Updater::read_latest_version() { + // Check if the latest version is greater than the current version. + if let Ok(true) = bump_is_greater(current_version, &latest_version) { + // If a newer version is available, get the update message. + if let Ok(Some(update_message)) = updater::Updater::get_cli_string() { + // Append the update message to the help output. + help_output.push_str(&update_message); + help_output.push('\n'); + } + } + } + help_output + }) +} + +/// Generates a static string containing the current version and an update notification if available. +/// +/// OnceLock is used because we need a thread-safe way to lazily initialize a static string. +fn show_version_with_update_notification() -> &'static str { + VERSION_UPDATE_VERSION_STRING.get_or_init(|| { + // Get the current version of the package. + let current_version = env!("CARGO_PKG_VERSION"); + let mut version_output = format!("{} \n", current_version); + + // Attempt to read the latest version. + if let Ok(Some(latest_version)) = updater::Updater::read_latest_version() { + // Check if the latest version is greater than the current version. + if let Ok(true) = bump_is_greater(current_version, &latest_version) { + // If a newer version is available, get the update message. + if let Ok(Some(update_message)) = updater::Updater::get_cli_string() { + // Append the update message to the version output. + version_output.push_str(&update_message); + } + } + } + version_output + }) +} /// CLI Arguments entry point - includes global parameters and subcommands #[derive(Parser, Debug)] -#[clap(name = "leo", author = "The Leo Team ", version)] +#[clap(name = "leo", author = "The Leo Team ", version = show_version_with_update_notification(), before_help = show_update_notification_before_help())] pub struct CLI { #[clap(short, global = true, help = "Print additional information for debugging")] debug: bool, @@ -124,6 +177,11 @@ pub fn run_with_args(cli: CLI) -> Result<()> { })?; } + // Check for updates. If not forced, it checks once per day. + if let Ok(true) = updater::Updater::check_for_updates(false) { + let _ = updater::Updater::print_cli(); + } + // Get custom root folder and create context for it. // If not specified, default context will be created in cwd. let context = handle_error(Context::new(cli.path, cli.home, false)); @@ -143,6 +201,7 @@ pub fn run_with_args(cli: CLI) -> Result<()> { Commands::Update { command } => command.try_execute(context), } } + #[cfg(test)] mod tests { use crate::cli::{ diff --git a/leo/cli/helpers/updater.rs b/leo/cli/helpers/updater.rs index f1284a4389..fa32735bfe 100644 --- a/leo/cli/helpers/updater.rs +++ b/leo/cli/helpers/updater.rs @@ -19,15 +19,25 @@ use leo_errors::{CliError, Result}; use std::fmt::Write as _; use colored::Colorize; +use dirs; use self_update::{backends::github, version::bump_is_greater, Status}; +use std::{ + fs, + path::{Path, PathBuf}, + time::{Duration, SystemTime, UNIX_EPOCH}, +}; pub struct Updater; // TODO Add logic for users to easily select release versions. impl Updater { const LEO_BIN_NAME: &'static str = "leo"; + const LEO_CACHE_LAST_CHECK_FILE: &'static str = "leo_cache_last_update_check"; + const LEO_CACHE_VERSION_FILE: &'static str = "leo_cache_latest_version"; const LEO_REPO_NAME: &'static str = "leo"; const LEO_REPO_OWNER: &'static str = "AleoHQ"; + // 24 hours + const LEO_UPDATE_CHECK_INTERVAL: Duration = Duration::from_secs(24 * 60 * 60); /// Show all available releases for `leo`. pub fn show_available_releases() -> Result { @@ -85,15 +95,141 @@ impl Updater { } } - /// Display the CLI message, if the Leo configuration allows. - pub fn print_cli() { - // If the auto update configuration is off, notify the user to update leo. - if let Ok(latest_version) = Self::update_available() { - let mut message = "šŸŸ¢ A new version is available! Run".bold().green().to_string(); - message += &" `leo update` ".bold().white(); - message += &format!("to update to v{latest_version}.").bold().green(); + /// Read the latest version from the version file. + pub fn read_latest_version() -> Result, CliError> { + let version_file_path = Self::get_version_file_path()?; + match fs::read_to_string(version_file_path) { + Ok(version) => Ok(Some(version.trim().to_string())), + Err(_) => Ok(None), + } + } + + /// Generate the CLI message if a new version is available. + pub fn get_cli_string() -> Result, CliError> { + if let Some(latest_version) = Self::read_latest_version()? { + let colorized_message = format!( + "\nšŸŸ¢ {} {} {}", + "A new version is available! Run".bold().green(), + "`leo update`".bold().white(), + format!("to update to v{}.", latest_version).bold().green() + ); + Ok(Some(colorized_message)) + } else { + Ok(None) + } + } - tracing::info!("\n{}\n", message); + /// Display the CLI message if a new version is available. + pub fn print_cli() -> Result<(), CliError> { + if let Some(message) = Self::get_cli_string()? { + println!("{}", message); } + Ok(()) + } + + /// Check for updates, respecting the update interval. (Currently once per day.) + /// If a new version is found, write it to a cache file and alert in every call. + pub fn check_for_updates(force: bool) -> Result { + // Get the cache directory and relevant file paths. + let cache_dir = Self::get_cache_dir()?; + let last_check_file = cache_dir.join(Self::LEO_CACHE_LAST_CHECK_FILE); + let version_file = Self::get_version_file_path()?; + + // Determine if we should check for updates. + let should_check = force || Self::should_check_for_updates(&last_check_file)?; + + if should_check { + match Self::update_available() { + Ok(latest_version) => { + // A new version is available + Self::update_check_files(&cache_dir, &last_check_file, &version_file, &latest_version)?; + Ok(true) + } + Err(_) => { + // No new version available or error occurred + // We'll treat both cases as "no update" for simplicity + Self::update_check_files(&cache_dir, &last_check_file, &version_file, env!("CARGO_PKG_VERSION"))?; + Ok(false) + } + } + } else if version_file.exists() { + if let Ok(stored_version) = fs::read_to_string(&version_file) { + let current_version = env!("CARGO_PKG_VERSION"); + Ok(bump_is_greater(current_version, stored_version.trim()).map_err(CliError::self_update_error)?) + } else { + // If we can't read the file, assume no update is available + Ok(false) + } + } else { + Ok(false) + } + } + + /// Updates the check files with the latest version information and timestamp. + /// + /// This function creates the cache directory if it doesn't exist, writes the current time + /// to the last check file, and writes the latest version to the version file. + fn update_check_files( + cache_dir: &Path, + last_check_file: &Path, + version_file: &Path, + latest_version: &str, + ) -> Result<(), CliError> { + // Recursively create the cache directory and all of its parent components if they are missing. + fs::create_dir_all(cache_dir).map_err(CliError::cli_io_error)?; + + // Get the current time. + let current_time = Self::get_current_time()?; + + // Write the current time to the last check file. + fs::write(last_check_file, current_time.to_string()).map_err(CliError::cli_io_error)?; + + // Write the latest version to the version file. + fs::write(version_file, latest_version).map_err(CliError::cli_io_error)?; + + Ok(()) + } + + /// Determines if an update check should be performed based on the last check time. + /// + /// This function reads the last check timestamp from a file and compares it with + /// the current time to decide if enough time has passed for a new check. + fn should_check_for_updates(last_check_file: &Path) -> Result { + match fs::read_to_string(last_check_file) { + Ok(contents) => { + // Parse the last check timestamp from the file. + let last_check = contents + .parse::() + .map_err(|e| CliError::cli_runtime_error(format!("Failed to parse last check time: {}", e)))?; + + // Get the current time. + let current_time = Self::get_current_time()?; + + // Check if enough time has passed since the last check. + Ok(current_time.saturating_sub(last_check) > Self::LEO_UPDATE_CHECK_INTERVAL.as_secs()) + } + // If we can't read the file, assume we should check + Err(_) => Ok(true), + } + } + + /// Gets the current system time as seconds since the Unix epoch. + fn get_current_time() -> Result { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(|e| CliError::cli_runtime_error(format!("System time error: {}", e))) + .map(|duration| duration.as_secs()) + } + + /// Get the path to the file storing the latest version information. + fn get_version_file_path() -> Result { + Self::get_cache_dir().map(|dir| dir.join(Self::LEO_CACHE_VERSION_FILE)) + } + + /// Get the cache directory for Leo. + fn get_cache_dir() -> Result { + dirs::cache_dir() + .ok_or_else(|| CliError::cli_runtime_error("Failed to get cache directory".to_string())) + .map(|dir| dir.join("leo")) } }