cli/src/main.rs
Jake Cooper 15e738f2fa
Show telemetry notice on install instead of first run (#832)
Move the telemetry opt-out notice from a runtime banner (shown on first
CLI command) to the install script so users see it exactly once at
install time. Remove the Notices JSON persistence that tracked whether
the banner had been shown.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 18:45:07 +09:00

408 lines
12 KiB
Rust

use std::cmp::Ordering;
use anyhow::Result;
use clap::error::ErrorKind;
mod commands;
use commands::*;
use config::Configs;
use is_terminal::IsTerminal;
use util::{check_update::UpdateCheck, compare_semver::compare_semver};
mod client;
mod config;
mod consts;
mod controllers;
mod errors;
mod gql;
mod oauth;
mod subscription;
mod table;
mod util;
mod workspace;
#[macro_use]
mod macros;
mod telemetry;
// Generates the commands based on the modules in the commands directory
// Specify the modules you want to include in the commands_enum! macro
commands!(
add,
bucket,
completion,
connect,
delete,
deploy,
deployment,
dev(develop),
domain,
docs,
down,
environment(env),
init,
link,
list,
login,
logout,
logs,
mcp,
open,
project,
run(local),
service,
shell,
ssh,
starship,
status,
telemetry_cmd(telemetry),
unlink,
up,
upgrade,
variable(variables, vars, var),
whoami,
volume,
redeploy,
restart,
scale,
check_updates,
functions(function, func, fn, funcs, fns)
);
fn spawn_update_task() -> tokio::task::JoinHandle<anyhow::Result<Option<String>>> {
tokio::spawn(async move {
// outputting would break json output on CI
if !std::io::stdout().is_terminal() {
anyhow::bail!("Stdout is not a terminal");
}
let latest_version = util::check_update::check_update(false).await?;
Ok(latest_version)
})
}
async fn handle_update_task(
handle: Option<tokio::task::JoinHandle<anyhow::Result<Option<String>>>>,
) {
if let Some(handle) = handle {
match handle.await {
Ok(Ok(_)) => {} // Task completed successfully
Ok(Err(e)) => {
if !std::io::stdout().is_terminal() {
eprintln!("Failed to check for updates (not fatal)");
eprintln!("{e}");
}
}
Err(e) => {
eprintln!("Check Updates: Task panicked or failed to execute.");
eprintln!("{e}");
}
}
}
}
#[tokio::main]
async fn main() -> Result<()> {
let args = build_args().try_get_matches();
let check_updates_handle = if std::io::stdout().is_terminal() {
let update = UpdateCheck::read().unwrap_or_default();
if let Some(latest_version) = update.latest_version {
if matches!(
compare_semver(env!("CARGO_PKG_VERSION"), &latest_version),
Ordering::Less
) {
println!(
"{} v{} visit {} for more info",
"New version available:".green().bold(),
latest_version.yellow(),
"https://docs.railway.com/guides/cli".purple(),
);
}
let update = UpdateCheck {
last_update_check: Some(chrono::Utc::now()),
latest_version: None,
};
update
.write()
.context("Failed to save time since last update check")?;
}
Some(spawn_update_task())
} else {
None
};
// https://github.com/clap-rs/clap/blob/cb2352f84a7663f32a89e70f01ad24446d5fa1e2/clap_builder/src/error/mod.rs#L210-L215
let cli = match args {
Ok(args) => args,
// Clap's source code specifically says that these errors should be
// printed to stdout and exit with a status of 0.
Err(e) if e.kind() == ErrorKind::DisplayHelp || e.kind() == ErrorKind::DisplayVersion => {
println!("{e}");
handle_update_task(check_updates_handle).await;
std::process::exit(0);
}
Err(e) => {
eprintln!("{e}");
handle_update_task(check_updates_handle).await;
std::process::exit(2); // The default behavior is exit 2
}
};
// Commands that do not require authentication -- skip token refresh for these.
const NO_AUTH_COMMANDS: &[&str] = &[
"login",
"logout",
"completion",
"docs",
"upgrade",
"telemetry_cmd",
"check_updates",
];
let needs_refresh = cli
.subcommand_name()
.map(|cmd| !NO_AUTH_COMMANDS.contains(&cmd))
.unwrap_or(false);
if needs_refresh {
if let Ok(mut configs) = Configs::new() {
if let Err(e) = client::ensure_valid_token(&mut configs).await {
eprintln!("{}: {e}", "Warning: failed to refresh OAuth token".yellow());
}
}
}
let exec_result = exec_cli(cli).await;
if let Err(e) = exec_result {
if e.root_cause().to_string() == inquire::InquireError::OperationInterrupted.to_string() {
return Ok(()); // Exit gracefully if interrupted
}
eprintln!("{e:?}");
handle_update_task(check_updates_handle).await;
std::process::exit(1);
}
handle_update_task(check_updates_handle).await;
Ok(())
}
#[cfg(test)]
mod cli_tests {
use super::*;
fn parse(args: &[&str]) -> Result<clap::ArgMatches, clap::Error> {
let mut full_args = vec!["railway"];
full_args.extend(args);
build_args().try_get_matches_from(full_args)
}
fn assert_parses(args: &[&str]) {
assert!(
parse(args).is_ok(),
"Command should parse: railway {}",
args.join(" ")
);
}
fn assert_subcommand(args: &[&str], expected: &str) {
let matches = parse(args).unwrap_or_else(|_| panic!("Failed to parse: {:?}", args));
assert_eq!(
matches.subcommand_name(),
Some(expected),
"Expected subcommand '{}' for args {:?}",
expected,
args
);
}
mod backwards_compat {
use super::*;
#[test]
fn root_commands_exist() {
assert_subcommand(&["logs"], "logs");
assert_subcommand(&["list"], "list");
assert_subcommand(&["delete"], "delete");
assert_subcommand(&["restart"], "restart");
assert_subcommand(&["scale"], "scale");
assert_subcommand(&["link"], "link");
assert_subcommand(&["up"], "up");
assert_subcommand(&["redeploy"], "redeploy");
}
#[test]
fn variable_aliases() {
assert_subcommand(&["variable"], "variable");
assert_subcommand(&["variables"], "variable");
assert_subcommand(&["vars"], "variable");
assert_subcommand(&["var"], "variable");
}
#[test]
fn logs_http_flag_parses() {
assert_parses(&["logs", "--http"]);
assert_parses(&["logs", "--http", "--lines", "50"]);
assert_parses(&["service", "logs", "--http"]);
}
#[test]
fn logs_http_examples_parse() {
assert_parses(&["logs", "--http", "--lines", "50"]);
assert_parses(&[
"logs",
"--http",
"--filter",
"@path:/api/users @httpStatus:200",
]);
assert_parses(&[
"logs",
"--http",
"--json",
"--filter",
"@requestId:abcd1234",
]);
assert_parses(&[
"service",
"logs",
"--http",
"--lines",
"10",
"--filter",
"@httpStatus:404",
]);
}
#[test]
fn variable_legacy_flags() {
assert_parses(&["variable", "--set", "KEY=value"]);
assert_parses(&["variable", "--set", "KEY=value", "--set", "KEY2=value2"]);
assert_parses(&["variable", "-s", "myservice"]);
assert_parses(&["variable", "-e", "production"]);
assert_parses(&["variable", "--kv"]);
assert_parses(&["variable", "--json"]);
assert_parses(&["variable", "--skip-deploys", "--set", "KEY=value"]);
assert_parses(&["variables", "--set", "KEY=value"]); // via alias
}
#[test]
fn environment_implicit_link() {
assert_parses(&["environment", "production"]); // legacy positional
assert_parses(&["env", "production"]); // alias
}
#[test]
fn service_implicit_link() {
assert_parses(&["service"]); // prompts for link
assert_parses(&["service", "myservice"]); // legacy positional link
}
#[test]
fn functions_aliases() {
assert_subcommand(&["functions", "list"], "functions");
assert_subcommand(&["function", "list"], "functions");
assert_subcommand(&["func", "list"], "functions");
assert_subcommand(&["fn", "list"], "functions");
assert_subcommand(&["funcs", "list"], "functions");
assert_subcommand(&["fns", "list"], "functions");
}
#[test]
fn dev_run_aliases() {
assert_subcommand(&["dev"], "dev");
assert_subcommand(&["develop"], "dev");
assert_subcommand(&["run"], "run");
assert_subcommand(&["local"], "run");
}
#[test]
fn variable_set_from_stdin_legacy() {
assert_parses(&["variable", "--set-from-stdin", "MY_KEY"]);
assert_parses(&["variable", "--set-from-stdin", "KEY", "-s", "myservice"]);
assert_parses(&["variable", "--set-from-stdin", "KEY", "--skip-deploys"]);
assert_parses(&["variables", "--set-from-stdin", "KEY"]);
}
#[test]
fn variable_list_kv_format() {
assert_parses(&["variable", "--kv"]);
assert_parses(&["variable", "-k"]);
assert_parses(&["variables", "--kv"]);
}
}
mod new_commands {
use super::*;
#[test]
fn variable_subcommands() {
assert_parses(&["variable", "list"]);
assert_parses(&["variable", "list", "-s", "myservice"]);
assert_parses(&["variable", "list", "--json"]);
assert_parses(&["variable", "set", "KEY=value"]);
assert_parses(&["variable", "set", "KEY=value", "KEY2=value2"]); // multiple
assert_parses(&["variable", "set", "A=1", "B=2", "C=3", "--skip-deploys"]);
assert_parses(&["variable", "set", "KEY", "--stdin"]);
assert_parses(&["variable", "set", "KEY=value", "--skip-deploys"]);
assert_parses(&["variable", "delete", "KEY"]);
assert_parses(&["variable", "rm", "KEY"]); // alias
assert_parses(&["variable", "delete", "KEY", "--json"]);
}
#[test]
fn environment_link_subcommand() {
assert_parses(&["environment", "link"]);
assert_parses(&["environment", "link", "production"]);
assert_parses(&["environment", "link", "--json"]);
}
#[test]
fn service_subcommands() {
assert_parses(&["service", "link"]);
assert_parses(&["service", "status"]);
assert_parses(&["service", "status", "--all"]);
assert_parses(&["service", "status", "--json"]);
assert_parses(&["service", "logs"]);
assert_parses(&["service", "logs", "-s", "myservice"]);
assert_parses(&["service", "redeploy"]);
assert_parses(&["service", "redeploy", "-s", "myservice"]);
assert_parses(&["service", "restart"]);
assert_parses(&["service", "scale"]);
}
#[test]
fn project_subcommands() {
assert_parses(&["project", "list"]);
assert_parses(&["project", "ls"]); // alias
assert_parses(&["project", "list", "--json"]);
assert_parses(&["project", "link"]);
assert_parses(&["project", "delete"]);
assert_parses(&["project", "rm"]); // alias
assert_parses(&["project", "delete", "-y"]);
}
#[test]
fn variable_list_aliases() {
assert_parses(&["variable", "ls"]);
assert_parses(&["variable", "ls", "--kv"]);
assert_parses(&["variable", "ls", "-s", "myservice"]);
}
#[test]
fn variable_delete_remove_alias() {
assert_parses(&["variable", "remove", "KEY"]);
}
#[test]
fn variable_set_stdin_key_only() {
assert_parses(&["variable", "set", "KEY", "--stdin"]);
assert_parses(&["variable", "set", "MY_VAR", "--stdin", "-s", "myservice"]);
assert_parses(&["variable", "set", "SECRET", "--stdin", "--skip-deploys"]);
}
}
}