Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Feature] Leo CLI new version update notification #28345

Open
wants to merge 4 commits into
base: mainnet
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 61 additions & 2 deletions leo/cli/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> = OnceLock::new();
static HELP_UPDATE_VERSION_STRING: OnceLock<String> = 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 <[email protected]>", version)]
#[clap(name = "leo", author = "The Leo Team <[email protected]>", 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,
Expand Down Expand Up @@ -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));
Expand All @@ -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::{
Expand Down
152 changes: 144 additions & 8 deletions leo/cli/helpers/updater.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> {
Expand Down Expand Up @@ -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<Option<String>, 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<Option<String>, 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<bool, CliError> {
// 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<bool, CliError> {
match fs::read_to_string(last_check_file) {
Ok(contents) => {
// Parse the last check timestamp from the file.
let last_check = contents
.parse::<u64>()
.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<u64, CliError> {
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<PathBuf, CliError> {
Self::get_cache_dir().map(|dir| dir.join(Self::LEO_CACHE_VERSION_FILE))
}

/// Get the cache directory for Leo.
fn get_cache_dir() -> Result<PathBuf, CliError> {
dirs::cache_dir()
.ok_or_else(|| CliError::cli_runtime_error("Failed to get cache directory".to_string()))
.map(|dir| dir.join("leo"))
}
}
Loading