Skip to content

Commit ec13e9b

Browse files
feat: add check updates command (#577)
* feat: add check updates command * feat: run check_update on required commands * review: added return value to check_updates simplified check_updates command fix: more user-friendly error message for check_updates on Config write failure * refactor: check updates macro -> function * lint: cargo fmt * review: remove extra clap comment * feat: check for updates in background and print on next run feat: check for updates in background and print on next run remove updates check from init, link and run move functions around in main.rs * Add check to see if the cli has been updated * fix: CI / Tests (Windows) * fix: match try_parse() error handling behaviour with parse() * nit: move tests below impl * fix: compare_semver: handle different length versions * chore: remove big comment --------- Co-authored-by: Jake Runzer <jakerunzer@gmail.com>
1 parent c5ccbaf commit ec13e9b

File tree

11 files changed

+463
-16
lines changed

11 files changed

+463
-16
lines changed

Cargo.toml

+4-1
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,10 @@ reqwest = { version = "0.12.12", default-features = false, features = [
2727
"rustls-tls",
2828
"json",
2929
] }
30-
chrono = { version = "0.4.39", features = ["serde"], default-features = false }
30+
chrono = { version = "0.4.39", features = [
31+
"serde",
32+
"clock",
33+
], default-features = false }
3134
graphql_client = { version = "0.14.0", features = ["reqwest-rustls"] }
3235
paste = "1.0.15"
3336
tokio = { version = "1.42.0", features = ["full"] }

src/commands/check_updates.rs

+34
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
use crate::util::check_update::check_update;
2+
3+
use super::*;
4+
use serde_json::json;
5+
6+
/// Test the update check
7+
#[derive(Parser)]
8+
pub struct Args {}
9+
10+
pub async fn command(_args: Args, json: bool) -> Result<()> {
11+
let mut configs = Configs::new()?;
12+
13+
if json {
14+
let result = configs.check_update(true).await;
15+
16+
let json = json!({
17+
"latest_version": result.ok().flatten().as_ref(),
18+
"current_version": env!("CARGO_PKG_VERSION"),
19+
});
20+
21+
println!("{}", serde_json::to_string_pretty(&json)?);
22+
23+
return Ok(());
24+
}
25+
26+
let is_latest = check_update(&mut configs, true).await?;
27+
if is_latest {
28+
println!(
29+
"You are on the latest version of the CLI, v{}",
30+
env!("CARGO_PKG_VERSION")
31+
);
32+
}
33+
Ok(())
34+
}

src/commands/init.rs

+1
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ pub struct Args {
1515

1616
pub async fn command(args: Args, _json: bool) -> Result<()> {
1717
let mut configs = Configs::new()?;
18+
1819
let client = GQLClient::new_authorized(&configs)?;
1920

2021
let vars = queries::user_projects::Variables {};

src/commands/link.rs

+1
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ pub struct Args {
3838

3939
pub async fn command(args: Args, _json: bool) -> Result<()> {
4040
let mut configs = Configs::new()?;
41+
4142
let client = GQLClient::new_authorized(&configs)?;
4243
let me = post_graphql::<queries::UserProjects, _>(
4344
&client,

src/commands/mod.rs

+2
Original file line numberDiff line numberDiff line change
@@ -30,3 +30,5 @@ pub mod up;
3030
pub mod variables;
3131
pub mod volume;
3232
pub mod whoami;
33+
34+
pub mod check_updates;

src/commands/run.rs

+2
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,9 @@ async fn get_service(
7373
}
7474

7575
pub async fn command(args: Args, _json: bool) -> Result<()> {
76+
// only needs to be mutable for the update check
7677
let configs = Configs::new()?;
78+
7779
let client = GQLClient::new_authorized(&configs)?;
7880
let linked_project = configs.get_linked_project().await?;
7981

src/config.rs

+52-2
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,23 @@
11
use std::{
2+
cmp::Ordering,
23
collections::BTreeMap,
3-
fs,
4-
fs::{create_dir_all, File},
4+
fs::{self, create_dir_all, File},
55
io::Read,
66
path::PathBuf,
77
};
88

99
use anyhow::{Context, Result};
10+
use chrono::{DateTime, Utc};
1011
use colored::Colorize;
1112
use inquire::ui::{Attributes, RenderConfig, StyleSheet, Styled};
13+
use is_terminal::IsTerminal;
1214
use serde::{Deserialize, Serialize};
1315

1416
use crate::{
1517
client::{post_graphql, GQLClient},
1618
commands::queries,
1719
errors::RailwayError,
20+
util::compare_semver::compare_semver,
1821
};
1922

2023
#[derive(Serialize, Deserialize, Debug, Clone)]
@@ -42,6 +45,8 @@ pub struct RailwayUser {
4245
pub struct RailwayConfig {
4346
pub projects: BTreeMap<String, LinkedProject>,
4447
pub user: RailwayUser,
48+
pub last_update_check: Option<DateTime<Utc>>,
49+
pub new_version_available: Option<String>,
4550
}
4651

4752
#[derive(Debug)]
@@ -57,6 +62,13 @@ pub enum Environment {
5762
Dev,
5863
}
5964

65+
#[derive(Deserialize)]
66+
struct GithubApiRelease {
67+
tag_name: String,
68+
}
69+
70+
const GITHUB_API_RELEASE_URL: &str = "https://api.github.com/repos/railwayapp/cli/releases/latest";
71+
6072
pub const SSH_CONNECTION_TIMEOUT_SECS: u64 = 10;
6173
pub const SSH_MESSAGE_TIMEOUT_SECS: u64 = 5;
6274
pub const SSH_RECONNECT_DELAY_SECS: u64 = 1;
@@ -85,6 +97,8 @@ impl Configs {
8597
RailwayConfig {
8698
projects: BTreeMap::new(),
8799
user: RailwayUser { token: None },
100+
last_update_check: None,
101+
new_version_available: None,
88102
}
89103
});
90104

@@ -101,6 +115,8 @@ impl Configs {
101115
root_config: RailwayConfig {
102116
projects: BTreeMap::new(),
103117
user: RailwayUser { token: None },
118+
last_update_check: None,
119+
new_version_available: None,
104120
},
105121
})
106122
}
@@ -109,6 +125,8 @@ impl Configs {
109125
self.root_config = RailwayConfig {
110126
projects: BTreeMap::new(),
111127
user: RailwayUser { token: None },
128+
last_update_check: None,
129+
new_version_available: None,
112130
};
113131
Ok(())
114132
}
@@ -325,4 +343,36 @@ impl Configs {
325343

326344
Ok(())
327345
}
346+
347+
pub async fn check_update(&mut self, force: bool) -> anyhow::Result<Option<String>> {
348+
// outputting would break json output on CI
349+
if !std::io::stdout().is_terminal() && !force {
350+
return Ok(None);
351+
}
352+
353+
if let Some(last_update_check) = self.root_config.last_update_check {
354+
if Utc::now().date_naive() == last_update_check.date_naive() && !force {
355+
return Ok(None);
356+
}
357+
}
358+
359+
let client = reqwest::Client::new();
360+
let response = client
361+
.get(GITHUB_API_RELEASE_URL)
362+
.header("User-Agent", "railwayapp")
363+
.send()
364+
.await?;
365+
366+
self.root_config.last_update_check = Some(Utc::now());
367+
self.write()
368+
.context("Failed to save time since last update check")?;
369+
370+
let response = response.json::<GithubApiRelease>().await?;
371+
let latest_version = response.tag_name.trim_start_matches('v');
372+
373+
match compare_semver(env!("CARGO_PKG_VERSION"), &latest_version) {
374+
Ordering::Less => Ok(Some(latest_version.to_owned())),
375+
_ => Ok(None),
376+
}
377+
}
328378
}

src/main.rs

+87-13
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,12 @@
1+
use std::cmp::Ordering;
2+
13
use anyhow::Result;
2-
use clap::{Parser, Subcommand};
4+
use clap::{error::ErrorKind, Parser, Subcommand};
35

46
mod commands;
57
use commands::*;
8+
use is_terminal::IsTerminal;
9+
use util::compare_semver::compare_semver;
610

711
mod client;
812
mod config;
@@ -59,27 +63,97 @@ commands_enum!(
5963
variables,
6064
whoami,
6165
volume,
62-
redeploy
66+
redeploy,
67+
check_updates
6368
);
6469

70+
fn spawn_update_task(mut configs: Configs) -> tokio::task::JoinHandle<Result<(), anyhow::Error>> {
71+
tokio::spawn(async move {
72+
if !std::io::stdout().is_terminal() {
73+
return Ok::<(), anyhow::Error>(());
74+
}
75+
76+
let result = configs.check_update(false).await;
77+
if let Ok(Some(latest_version)) = result {
78+
configs.root_config.new_version_available = Some(latest_version);
79+
}
80+
configs.write()?;
81+
Ok::<(), anyhow::Error>(())
82+
})
83+
}
84+
85+
async fn handle_update_task(handle: Option<tokio::task::JoinHandle<Result<(), anyhow::Error>>>) {
86+
if let Some(handle) = handle {
87+
match handle.await {
88+
Ok(Ok(_)) => {} // Task completed successfully
89+
Ok(Err(e)) => {
90+
if !std::io::stdout().is_terminal() {
91+
eprintln!("Failed to check for updates (not fatal)");
92+
eprintln!("{}", e);
93+
}
94+
}
95+
Err(e) => {
96+
eprintln!("Check Updates: Task panicked or failed to execute.");
97+
eprintln!("{}", e);
98+
}
99+
}
100+
}
101+
}
102+
65103
#[tokio::main]
66104
async fn main() -> Result<()> {
67-
let cli = Args::parse();
105+
// Avoid grabbing configs multiple times, and avoid grabbing configs if we're not in a terminal
106+
let mut check_updates_handle: Option<tokio::task::JoinHandle<Result<(), anyhow::Error>>> = None;
107+
if std::io::stdout().is_terminal() {
108+
let mut configs = Configs::new()?;
109+
if let Some(new_version_available) = &configs.root_config.new_version_available {
110+
match compare_semver(env!("CARGO_PKG_VERSION"), &new_version_available) {
111+
Ordering::Less => {
112+
println!(
113+
"{} v{} visit {} for more info",
114+
"New version available:".green().bold(),
115+
new_version_available.yellow(),
116+
"https://docs.railway.com/guides/cli".purple(),
117+
);
118+
}
119+
_ => {
120+
configs.root_config.new_version_available = None;
121+
configs.write()?;
122+
}
123+
}
124+
}
125+
check_updates_handle = Some(spawn_update_task(configs));
126+
}
68127

69-
match Commands::exec(cli).await {
70-
Ok(_) => {}
128+
// https://github.com/clap-rs/clap/blob/cb2352f84a7663f32a89e70f01ad24446d5fa1e2/clap_builder/src/error/mod.rs#L210-L215
129+
let cli = match Args::try_parse() {
130+
Ok(args) => args,
131+
// Clap's source code specifically says that these errors should be
132+
// printed to stdout and exit with a status of 0.
133+
Err(e) if e.kind() == ErrorKind::DisplayHelp || e.kind() == ErrorKind::DisplayVersion => {
134+
println!("{}", e);
135+
handle_update_task(check_updates_handle).await;
136+
std::process::exit(0);
137+
}
71138
Err(e) => {
72-
// If the user cancels the operation, we want to exit successfully
73-
// This can happen if Ctrl+C is pressed during a prompt
74-
if e.root_cause().to_string() == inquire::InquireError::OperationInterrupted.to_string()
75-
{
76-
return Ok(());
77-
}
139+
eprintln!("{}", e);
140+
handle_update_task(check_updates_handle).await;
141+
std::process::exit(2); // The default behavior is exit 2
142+
}
143+
};
78144

79-
eprintln!("{:?}", e);
80-
std::process::exit(1);
145+
let exec_result = Commands::exec(cli).await;
146+
147+
if let Err(e) = exec_result {
148+
if e.root_cause().to_string() == inquire::InquireError::OperationInterrupted.to_string() {
149+
return Ok(()); // Exit gracefully if interrupted
81150
}
151+
eprintln!("{:?}", e);
152+
handle_update_task(check_updates_handle).await;
153+
std::process::exit(1);
82154
}
83155

156+
handle_update_task(check_updates_handle).await;
157+
84158
Ok(())
85159
}

src/util/check_update.rs

+16
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
use colored::Colorize;
2+
3+
pub async fn check_update(configs: &mut crate::Configs, force: bool) -> anyhow::Result<bool> {
4+
let result = configs.check_update(force).await;
5+
if let Ok(Some(latest_version)) = result {
6+
println!(
7+
"{} v{} visit {} for more info",
8+
"New version available:".green().bold(),
9+
latest_version.yellow(),
10+
"https://docs.railway.com/guides/cli".purple(),
11+
);
12+
Ok(false)
13+
} else {
14+
Ok(true)
15+
}
16+
}

0 commit comments

Comments
 (0)