diff --git a/Cargo.toml b/Cargo.toml index e5ee82bf..0ab976d3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,9 +24,13 @@ dirs = "5.0.1" serde = { version = "1.0.210", features = ["derive"] } serde_json = "1.0.128" reqwest = { version = "0.12.8", default-features = false, features = [ - "rustls-tls", "json" + "rustls-tls", + "json", ] } -chrono = { version = "0.4.38", features = ["serde"], default-features = false } +chrono = { version = "0.4.38", features = [ + "serde", + "clock", +], default-features = false } graphql_client = { version = "0.14.0", features = ["reqwest-rustls"] } paste = "1.0.15" tokio = { version = "1.40.0", features = ["full"] } diff --git a/src/commands/check_updates.rs b/src/commands/check_updates.rs new file mode 100644 index 00000000..70437682 --- /dev/null +++ b/src/commands/check_updates.rs @@ -0,0 +1,34 @@ +use crate::check_update; + +use super::*; +use serde_json::json; + +/// Test the update check +#[derive(Parser)] +pub struct Args {} + +pub async fn command(_args: Args, json: bool) -> Result<()> { + let mut configs = Configs::new()?; + + if json { + let result = configs.check_update(true).await; + + let json = json!({ + "latest_version": result.ok().flatten().as_ref(), + "current_version": env!("CARGO_PKG_VERSION"), + }); + + println!("{}", serde_json::to_string_pretty(&json)?); + + return Ok(()); + } + + let is_latest = check_update!(configs, true); + if is_latest { + println!( + "You are on the latest version of the CLI, v{}", + env!("CARGO_PKG_VERSION") + ); + } + Ok(()) +} diff --git a/src/commands/init.rs b/src/commands/init.rs index 43646951..10e05f00 100644 --- a/src/commands/init.rs +++ b/src/commands/init.rs @@ -1,6 +1,6 @@ use std::fmt::Display; -use crate::util::prompt::prompt_select; +use crate::{check_update, util::prompt::prompt_select}; use super::{queries::user_projects::UserProjectsMeTeamsEdgesNode, *}; @@ -15,6 +15,9 @@ pub struct Args { pub async fn command(args: Args, _json: bool) -> Result<()> { let mut configs = Configs::new()?; + + check_update!(configs); + let client = GQLClient::new_authorized(&configs)?; let vars = queries::user_projects::Variables {}; diff --git a/src/commands/link.rs b/src/commands/link.rs index 48ba2536..051fed9b 100644 --- a/src/commands/link.rs +++ b/src/commands/link.rs @@ -2,6 +2,7 @@ use colored::*; use std::fmt::Display; use crate::{ + check_update, errors::RailwayError, util::prompt::{fake_select, prompt_options, prompt_options_skippable}, }; @@ -37,6 +38,9 @@ pub struct Args { pub async fn command(args: Args, _json: bool) -> Result<()> { let mut configs = Configs::new()?; + + check_update!(configs); + let client = GQLClient::new_authorized(&configs)?; let me = post_graphql::( &client, diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 6aa5dc44..080097c0 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -29,3 +29,5 @@ pub mod up; pub mod variables; pub mod volume; pub mod whoami; + +pub mod check_updates; diff --git a/src/commands/run.rs b/src/commands/run.rs index 41ed0692..230d203d 100644 --- a/src/commands/run.rs +++ b/src/commands/run.rs @@ -2,6 +2,7 @@ use anyhow::bail; use is_terminal::IsTerminal; use crate::{ + check_update, controllers::{ environment::get_matched_environment, project::{ensure_project_and_environment_exist, get_project}, @@ -73,7 +74,11 @@ async fn get_service( } pub async fn command(args: Args, _json: bool) -> Result<()> { - let configs = Configs::new()?; + // only needs to be mutable for the update check + let mut configs = Configs::new()?; + check_update!(configs); + let configs = configs; // so we make it immutable again + let client = GQLClient::new_authorized(&configs)?; let linked_project = configs.get_linked_project().await?; diff --git a/src/config.rs b/src/config.rs index 7e8b67d2..b1fcf3ba 100644 --- a/src/config.rs +++ b/src/config.rs @@ -7,8 +7,10 @@ use std::{ }; use anyhow::{Context, Result}; +use chrono::{DateTime, Utc}; use colored::Colorize; use inquire::ui::{Attributes, RenderConfig, StyleSheet, Styled}; +use is_terminal::IsTerminal; use serde::{Deserialize, Serialize}; use crate::{ @@ -42,6 +44,7 @@ pub struct RailwayUser { pub struct RailwayConfig { pub projects: BTreeMap, pub user: RailwayUser, + pub last_update_check: Option>, } #[derive(Debug)] @@ -57,6 +60,13 @@ pub enum Environment { Dev, } +#[derive(Deserialize)] +struct GithubApiRelease { + tag_name: String, +} + +const GITHUB_API_RELEASE_URL: &str = "https://api.github.com/repos/railwayapp/cli/releases/latest"; + impl Configs { pub fn new() -> Result { let environment = Self::get_environment_id(); @@ -79,6 +89,7 @@ impl Configs { RailwayConfig { projects: BTreeMap::new(), user: RailwayUser { token: None }, + last_update_check: None, } }); @@ -95,6 +106,7 @@ impl Configs { root_config: RailwayConfig { projects: BTreeMap::new(), user: RailwayUser { token: None }, + last_update_check: None, }, }) } @@ -103,6 +115,7 @@ impl Configs { self.root_config = RailwayConfig { projects: BTreeMap::new(), user: RailwayUser { token: None }, + last_update_check: None, }; Ok(()) } @@ -313,4 +326,43 @@ impl Configs { Ok(()) } + + pub async fn check_update(&mut self, force: bool) -> anyhow::Result> { + // outputting would break json output on CI + if !std::io::stdout().is_terminal() && !force { + return Ok(None); + } + + let should_update = if let Some(last_update_check) = self.root_config.last_update_check { + Utc::now().date_naive() != last_update_check.date_naive() || force + } else { + true + }; + + if !should_update { + return Ok(None); + } + + let client = reqwest::Client::new(); + let response = client + .get(GITHUB_API_RELEASE_URL) + .header("User-Agent", "railwayapp") + .send() + .await?; + + self.root_config.last_update_check = Some(Utc::now()); + self.write() + .context("Failed to save time since last update check")?; + + let response = response.json::().await?; + let latest_version = response.tag_name.trim_start_matches('v'); + + let current_version = env!("CARGO_PKG_VERSION"); + + if latest_version == current_version { + return Ok(None); + } + + Ok(Some(latest_version.to_owned())) + } } diff --git a/src/macros.rs b/src/macros.rs index 164d492a..2e007516 100644 --- a/src/macros.rs +++ b/src/macros.rs @@ -42,3 +42,25 @@ macro_rules! interact_or { } }; } + +#[macro_export] +macro_rules! check_update { + ($obj:expr, $force:expr) => {{ + let result = $obj.check_update($force).await; + + if let Ok(Some(latest_version)) = result { + println!( + "{} v{} visit {} for more info", + "New version available:".green().bold(), + latest_version.yellow(), + "https://docs.railway.com/guides/cli".purple(), + ); + false + } else { + true + } + }}; + ($configs:expr) => {{ + check_update!($configs, false); + }}; +} diff --git a/src/main.rs b/src/main.rs index 0a7cde75..146f629c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -21,6 +21,7 @@ mod macros; #[derive(Parser)] #[clap(author, version, about, long_about = None)] #[clap(propagate_version = true)] +// #[clap(author, about, long_about = None)] pub struct Args { #[clap(subcommand)] command: Commands, @@ -58,11 +59,29 @@ commands_enum!( variables, whoami, volume, - redeploy + redeploy, + check_updates ); #[tokio::main] async fn main() -> Result<()> { + // intercept the args + { + let args: Vec = std::env::args().collect(); + + let flags: Vec = vec!["--version", "-V", "-h", "--help", "help"] + .into_iter() + .map(|s| s.to_string()) + .collect(); + + let check_version = args.into_iter().any(|arg| flags.contains(&arg)); + + if check_version { + let mut configs = Configs::new()?; + check_update!(configs, false); + } + } + let cli = Args::parse(); match Commands::exec(cli).await {