diff --git a/Cargo.lock b/Cargo.lock index ee3729c..94b0292 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -87,6 +87,15 @@ version = "1.0.95" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34ac096ce696dc2fcabef30516bb13c0a68a11d30131d3df6f04711467681b04" +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +dependencies = [ + "derive_arbitrary", +] + [[package]] name = "arboard" version = "3.6.1" @@ -203,9 +212,9 @@ dependencies = [ [[package]] name = "bumpalo" -version = "3.16.0" +version = "3.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "79296716171880943b8470b5f8d03aa55eb2e645a4874bdbb28adb49162e012c" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" [[package]] name = "bytemuck" @@ -741,6 +750,17 @@ dependencies = [ "syn 2.0.93", ] +[[package]] +name = "derive_arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.93", +] + [[package]] name = "derive_builder" version = "0.20.2" @@ -1911,9 +1931,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.22" +version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7a70ba024b9dc04c27ea2f0c0548feb474ec5c54bba33a7f72f873a39d07b24" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" [[package]] name = "lru" @@ -2391,6 +2411,7 @@ dependencies = [ "derive-new", "derive_more", "dirs", + "flate2", "fs2", "futures 0.3.31", "futures-util", @@ -2436,6 +2457,7 @@ dependencies = [ "url", "which", "winapi", + "zip", ] [[package]] @@ -4281,12 +4303,41 @@ dependencies = [ "syn 2.0.93", ] +[[package]] +name = "zip" +version = "2.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50" +dependencies = [ + "arbitrary", + "crc32fast", + "crossbeam-utils", + "displaydoc", + "flate2", + "indexmap", + "memchr", + "thiserror 2.0.9", + "zopfli", +] + [[package]] name = "zmij" version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" +[[package]] +name = "zopfli" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edfc5ee405f504cd4984ecc6f14d02d55cfda60fa4b689434ef4102aae150cd7" +dependencies = [ + "bumpalo", + "crc32fast", + "log", + "simd-adler32", +] + [[package]] name = "zune-core" version = "0.4.12" diff --git a/Cargo.toml b/Cargo.toml index a4933c7..dc9ce67 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,6 +22,8 @@ arboard = "3" clap = { version = "4.5.23", features = ["derive", "suggestions", "cargo"] } colored = "2.2.0" dirs = "5.0.1" +flate2 = "1" +zip = { version = "2", default-features = false, features = ["deflate"] } serde = { version = "1.0.217", features = ["derive"] } serde_json = "1.0.134" serde_yaml = "0.9" @@ -76,6 +78,8 @@ winapi = { version = "0.3.9", features = [ "processthreadsapi", "handleapi", "winerror", + "winbase", + "winnt", ] } strum = { version = "0.26.3", features = ["derive"] } structstruck = "0.4.1" diff --git a/build.rs b/build.rs index 22c8b11..e65e19a 100644 --- a/build.rs +++ b/build.rs @@ -4,4 +4,9 @@ fn main() { println!("cargo:rerun-if-changed=src/gql/mutations/strings"); println!("cargo:rerun-if-changed=src/gql/subscriptions/strings"); println!("cargo:rerun-if-changed=src/gql/schema.json"); + + // Expose the compile-time target triple so the self-updater fetches the + // correct release asset (respects ABI: gnu vs musl, msvc vs gnu, etc.). + let target = std::env::var("TARGET").unwrap(); + println!("cargo:rustc-env=BUILD_TARGET={target}"); } diff --git a/src/commands/autoupdate.rs b/src/commands/autoupdate.rs new file mode 100644 index 0000000..d4c2ccd --- /dev/null +++ b/src/commands/autoupdate.rs @@ -0,0 +1,307 @@ +use super::*; +use crate::config::Configs; +use crate::telemetry::{Preferences, is_auto_update_disabled_by_env}; +use crate::util::check_update::UpdateCheck; +use crate::util::install_method::InstallMethod; + +/// Manage auto-update preferences +#[derive(Parser)] +pub struct Args { + #[clap(subcommand)] + command: Commands, +} + +#[derive(Parser)] +enum Commands { + /// Enable automatic updates + Enable, + /// Disable automatic updates + Disable, + /// Show current auto-update status + Status, + /// Skip the current pending version (useful if a release is broken) + Skip, +} + +fn pending_version(update: &UpdateCheck, staged_version: Option) -> Option { + update.latest_version.clone().or(staged_version) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum BackgroundUpdateKind { + Download, + PackageManager, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct BackgroundUpdate { + pid: u32, + kind: BackgroundUpdateKind, +} + +fn running_background_update() -> Option { + let download_pid_path = crate::util::self_update::download_update_pid_path().ok()?; + if let Some(pid) = crate::util::check_update::is_background_update_running(&download_pid_path) { + return Some(BackgroundUpdate { + pid, + kind: BackgroundUpdateKind::Download, + }); + } + + let package_pid_path = crate::util::self_update::package_update_pid_path().ok()?; + crate::util::check_update::is_background_update_running(&package_pid_path).map(|pid| { + BackgroundUpdate { + pid, + kind: BackgroundUpdateKind::PackageManager, + } + }) +} + +fn disable_in_flight_message(update: BackgroundUpdate) -> String { + match update.kind { + BackgroundUpdateKind::Download => format!( + "Note: background download (PID {}) is already running and may still finish staging an update. \ + Disabling auto-updates prevents future automatic updates and automatic apply.", + update.pid + ), + BackgroundUpdateKind::PackageManager => format!( + "Note: background package-manager update (PID {}) is already running and may still finish. \ + Disabling auto-updates only prevents future automatic updates.", + update.pid + ), + } +} + +fn skip_in_flight_message(update: BackgroundUpdate, version: &str) -> Option { + match update.kind { + BackgroundUpdateKind::Download => None, + BackgroundUpdateKind::PackageManager => Some(format!( + "Note: background package-manager update (PID {}) is already running and may still finish installing v{}. \ + Future auto-updates will skip this version.", + update.pid, version + )), + } +} + +fn background_update_status_message(update: BackgroundUpdate, auto_update_enabled: bool) -> String { + match (update.kind, auto_update_enabled) { + (BackgroundUpdateKind::Download, true) => { + format!( + "Background update: downloading and staging (PID {})", + update.pid + ) + } + (BackgroundUpdateKind::Download, false) => format!( + "Background update: downloading and staging (PID {}; started before auto-updates were disabled and may still finish)", + update.pid + ), + (BackgroundUpdateKind::PackageManager, true) => { + format!( + "Background update: package manager running (PID {})", + update.pid + ) + } + (BackgroundUpdateKind::PackageManager, false) => format!( + "Background update: package manager running (PID {}; started before auto-updates were disabled and may still finish)", + update.pid + ), + } +} + +fn enable_status_message(env_disabled: bool, ci: bool) -> (&'static str, bool) { + if env_disabled { + ( + "Auto-update preference enabled, but updates remain disabled by RAILWAY_NO_AUTO_UPDATE.", + false, + ) + } else if ci { + ( + "Auto-update preference enabled, but updates remain disabled in this CI environment.", + false, + ) + } else { + ("Auto-updates enabled.", true) + } +} + +fn manual_upgrade_hint() -> &'static str { + "Manual upgrade is still available via `railway upgrade --yes`." +} + +fn should_show_manual_upgrade_hint(method: InstallMethod) -> bool { + method.can_self_update() || method.can_auto_upgrade() +} + +pub async fn command(args: Args) -> Result<()> { + match args.command { + Commands::Enable => { + let mut prefs = Preferences::read(); + prefs.auto_update_disabled = false; + prefs.write().context("Failed to save preferences")?; + let env_disabled = is_auto_update_disabled_by_env(); + let ci = Configs::env_is_ci(); + let (message, effective_enabled) = enable_status_message(env_disabled, ci); + if effective_enabled { + println!("{}", message.green()); + } else { + println!("{}", message.yellow()); + } + let update = UpdateCheck::read_normalized(); + if let Some(ref skipped) = update.skipped_version { + println!( + "Note: v{skipped} is still skipped from rollback; auto-update resumes on next release." + ); + } + } + Commands::Disable => { + let mut prefs = Preferences::read(); + prefs.auto_update_disabled = true; + prefs.write().context("Failed to save preferences")?; + // Clean any staged binary so it isn't applied on next launch. + // Best-effort: if a background download holds the lock, the staged + // dir will be left behind but try_apply_staged() checks the + // preference and won't apply it. + let _ = crate::util::self_update::clean_staged(); + println!("{}", "Auto-updates disabled.".yellow()); + if let Some(update) = running_background_update() { + println!("{}", disable_in_flight_message(update)); + } + } + Commands::Skip => { + let update = UpdateCheck::read_normalized(); + let staged_version = crate::util::self_update::validated_staged_version(); + if let Some(version) = pending_version(&update, staged_version) { + UpdateCheck::skip_version(&version); + let _ = crate::util::self_update::clean_staged(); + println!( + "Skipping v{version}. Auto-update will resume when a newer version is released.", + ); + if let Some(update) = running_background_update() { + if let Some(message) = skip_in_flight_message(update, &version) { + println!("{message}"); + } + } + } else { + println!("No pending update to skip."); + } + } + Commands::Status => { + let prefs = Preferences::read(); + let env_disabled = is_auto_update_disabled_by_env(); + let method = InstallMethod::detect(); + + let ci = Configs::env_is_ci(); + let auto_update_enabled = !env_disabled && !ci && !prefs.auto_update_disabled; + + let disabled_reason: Option = if env_disabled { + Some("disabled by RAILWAY_NO_AUTO_UPDATE".into()) + } else if ci { + Some("disabled in CI environment".into()) + } else if prefs.auto_update_disabled { + Some(format!( + "disabled via {}", + "railway autoupdate disable".bold() + )) + } else { + None + }; + + if let Some(reason) = &disabled_reason { + println!("Auto-updates: {} ({reason})", "disabled".yellow()); + if should_show_manual_upgrade_hint(method) { + println!("{}", manual_upgrade_hint()); + } + } else { + println!("Auto-updates: {}", "enabled".green()); + } + + println!("Install method: {}", method.name().bold()); + println!("Update strategy: {}", method.update_strategy()); + + let update = UpdateCheck::read_normalized(); + + if let Some(ref version) = update.latest_version { + println!("Latest known version: {}", format!("v{version}").cyan()); + } + + if let Some(ref staged) = crate::util::self_update::validated_staged_version() { + if auto_update_enabled { + println!( + "Staged update: {} (will apply on next run)", + format!("v{staged}").green() + ); + } else { + println!( + "Staged update: {} (ready, but auto-updates are currently disabled)", + format!("v{staged}").yellow() + ); + } + } + + if let Some(ref skipped) = update.skipped_version { + println!( + "Skipped version: {} (rolled back; auto-update resumes on next release)", + format!("v{skipped}").yellow() + ); + } + + if let Some(last_check) = update.last_update_check { + let ago = chrono::Utc::now() - last_check; + let label = if ago.num_hours() < 1 { + format!("{}m ago", ago.num_minutes()) + } else if ago.num_hours() < 24 { + format!("{}h ago", ago.num_hours()) + } else { + format!("{}d ago", ago.num_days()) + }; + println!("Last check: {}", label); + } + + if let Some(update) = running_background_update() { + println!( + "{}", + background_update_status_message(update, auto_update_enabled) + ); + } + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn pending_version_prefers_cached_latest() { + let update = UpdateCheck { + latest_version: Some("1.2.3".to_string()), + ..Default::default() + }; + + assert_eq!( + pending_version(&update, Some("1.2.2".to_string())).as_deref(), + Some("1.2.3") + ); + } + + #[test] + fn pending_version_falls_back_to_staged_update() { + let update = UpdateCheck::default(); + + assert_eq!( + pending_version(&update, Some("1.2.3".to_string())).as_deref(), + Some("1.2.3") + ); + } + + #[test] + fn manual_upgrade_hint_is_hidden_for_unknown_install_method() { + assert!(!should_show_manual_upgrade_hint(InstallMethod::Unknown)); + } + + #[test] + fn manual_upgrade_hint_is_shown_for_auto_upgrade_methods() { + assert!(should_show_manual_upgrade_hint(InstallMethod::Npm)); + } +} diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 5ae5b94..957aaba 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -9,6 +9,7 @@ pub fn get_dynamic_args(cmd: clap::Command) -> clap::Command { } pub mod add; +pub mod autoupdate; pub mod bucket; pub mod completion; pub mod connect; diff --git a/src/commands/telemetry_cmd.rs b/src/commands/telemetry_cmd.rs index f0defa1..5b53a31 100644 --- a/src/commands/telemetry_cmd.rs +++ b/src/commands/telemetry_cmd.rs @@ -23,13 +23,13 @@ pub async fn command(args: Args) -> Result<()> { Commands::Enable => { let mut prefs = Preferences::read(); prefs.telemetry_disabled = false; - prefs.write(); + prefs.write().context("Failed to save preferences")?; println!("{}", "Telemetry enabled.".green()); } Commands::Disable => { let mut prefs = Preferences::read(); prefs.telemetry_disabled = true; - prefs.write(); + prefs.write().context("Failed to save preferences")?; println!("{}", "Telemetry disabled.".yellow()); } Commands::Status => { diff --git a/src/commands/upgrade.rs b/src/commands/upgrade.rs index 9751987..fb3eb50 100644 --- a/src/commands/upgrade.rs +++ b/src/commands/upgrade.rs @@ -1,128 +1,94 @@ use std::process::Command; -use crate::{consts::NON_INTERACTIVE_FAILURE, interact_or}; +use is_terminal::IsTerminal; + +use crate::util::install_method::InstallMethod; use super::*; -/// Upgrade the Railway CLI to the latest version +/// Upgrade the Railway CLI to the latest version. +/// Use `--yes` for non-interactive agent/script usage. #[derive(Parser)] pub struct Args { /// Check install method without upgrading - #[clap(long)] + #[clap(long, conflicts_with = "rollback")] check: bool, + + /// Rollback to the previous version + #[clap(long, conflicts_with = "check")] + rollback: bool, + + /// Run without interactive prompts (useful for agents/scripts) + #[clap(short = 'y', long = "yes")] + yes: bool, } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum InstallMethod { - Homebrew, - Npm, - Bun, - Cargo, - Shell, - Scoop, - Unknown, +fn validate_interaction(yes: bool, is_tty: bool) -> Result<()> { + if !yes && !is_tty { + bail!( + "Cannot run `railway upgrade` in non-interactive mode. Use `--yes` to continue without prompts." + ); + } + + Ok(()) } -impl InstallMethod { - fn detect() -> Self { - let exe_path = match std::env::current_exe() { - Ok(path) => path, - Err(_) => return InstallMethod::Unknown, - }; - - let path_str = exe_path.to_string_lossy().to_lowercase(); - - // Check for Homebrew (macOS/Linux) - if path_str.contains("homebrew") - || path_str.contains("cellar") - || path_str.contains("linuxbrew") - { - return InstallMethod::Homebrew; - } - - // Check for Bun global install (must be before npm since bun uses node_modules internally) - if path_str.contains(".bun") { - return InstallMethod::Bun; - } - - // Check for npm global install - if path_str.contains("node_modules") - || path_str.contains("npm") - || path_str.contains(".npm") - { - return InstallMethod::Npm; - } - - // Check for Cargo install - if path_str.contains(".cargo") && path_str.contains("bin") { - return InstallMethod::Cargo; - } - - // Check for Scoop (Windows) - if path_str.contains("scoop") { - return InstallMethod::Scoop; - } - - // Check for shell script install (typically in /usr/local/bin or ~/.local/bin) - if path_str.contains("/usr/local/bin") || path_str.contains("/.local/bin") { - return InstallMethod::Shell; - } - - // Check for Windows Program Files (shell install) - if path_str.contains("program files") || path_str.contains("programfiles") { - return InstallMethod::Shell; - } - - InstallMethod::Unknown +fn fail_if_non_interactive_requested(yes: bool, message: &str) -> Result<()> { + if yes { + bail!(message.to_string()); } - fn name(&self) -> &'static str { - match self { - InstallMethod::Homebrew => "Homebrew", - InstallMethod::Npm => "npm", - InstallMethod::Bun => "Bun", - InstallMethod::Cargo => "Cargo", - InstallMethod::Shell => "Shell script", - InstallMethod::Scoop => "Scoop", - InstallMethod::Unknown => "Unknown", - } + Ok(()) +} + +fn retry_command(rollback: bool, yes: bool, elevated: bool) -> String { + let mut parts = Vec::new(); + + if elevated { + parts.push("sudo"); } - fn upgrade_command(&self) -> Option<&'static str> { - match self { - InstallMethod::Homebrew => Some("brew upgrade railway"), - InstallMethod::Npm => Some("npm update -g @railway/cli"), - InstallMethod::Bun => Some("bun update -g @railway/cli"), - InstallMethod::Cargo => Some("cargo install railwayapp"), - InstallMethod::Scoop => Some("scoop update railway"), - InstallMethod::Shell => Some("bash <(curl -fsSL cli.new)"), - InstallMethod::Unknown => None, - } + parts.push("railway"); + parts.push("upgrade"); + + if rollback { + parts.push("--rollback"); } - fn can_auto_upgrade(&self) -> bool { - matches!( - self, - InstallMethod::Homebrew - | InstallMethod::Npm - | InstallMethod::Bun - | InstallMethod::Cargo - | InstallMethod::Scoop - ) + if yes { + parts.push("--yes"); } + + parts.join(" ") } fn run_upgrade_command(method: InstallMethod) -> Result<()> { - let (program, args): (&str, Vec<&str>) = match method { - InstallMethod::Homebrew => ("brew", vec!["upgrade", "railway"]), - InstallMethod::Npm => ("npm", vec!["update", "-g", "@railway/cli"]), - InstallMethod::Bun => ("bun", vec!["update", "-g", "@railway/cli"]), - InstallMethod::Cargo => ("cargo", vec!["install", "railwayapp"]), - InstallMethod::Scoop => ("scoop", vec!["update", "railway"]), - InstallMethod::Shell | InstallMethod::Unknown => { - bail!("Cannot auto-upgrade for this install method"); - } - }; + let (program, args) = method + .package_manager_command() + .context("Cannot auto-upgrade for this install method")?; + + // Coordinate with background auto-updates: acquire the same lock and + // check the PID file used by spawn_package_manager_update() so we + // don't run two package-manager processes against the same global install. + use fs2::FileExt; + + let lock_path = crate::util::self_update::package_update_lock_path()?; + if let Some(parent) = lock_path.parent() { + std::fs::create_dir_all(parent)?; + } + let lock_file = + std::fs::File::create(&lock_path).context("Failed to create package-update lock file")?; + lock_file.try_lock_exclusive().map_err(|_| { + anyhow::anyhow!("A background update is already in progress. Please try again shortly.") + })?; + + let pid_path = crate::util::self_update::package_update_pid_path()?; + if let Some(pid) = crate::util::check_update::is_background_update_running(&pid_path) { + bail!( + "A background update (pid {pid}) is already running. \ + Please wait for it to finish or try again shortly." + ); + } println!("{} {} {}", "Running:".bold(), program, args.join(" ")); println!(); @@ -132,6 +98,9 @@ fn run_upgrade_command(method: InstallMethod) -> Result<()> { .status() .context(format!("Failed to execute {}", program))?; + // Clean up stale PID file from a previous background updater. + let _ = std::fs::remove_file(&pid_path); + if !status.success() { bail!( "Upgrade command failed with exit code: {}", @@ -160,7 +129,39 @@ pub async fn command(args: Args) -> Result<()> { return Ok(()); } - interact_or!(NON_INTERACTIVE_FAILURE); + if args.rollback { + if !method.can_self_update() { + bail!( + "Rollback is only supported for shell-script installs.\n\ + Detected install method: {}. Use your package manager to \ + install a specific version instead.", + method.name() + ); + } + validate_interaction(args.yes, std::io::stdout().is_terminal())?; + if !method.can_write_binary() { + println!( + "{}", + "Cannot rollback: the CLI binary is not writable by the current user.".yellow() + ); + println!(); + if cfg!(windows) { + println!("To rollback, run the terminal as Administrator and retry:"); + println!(" {}", retry_command(true, args.yes, false).bold()); + } else { + println!("To rollback, re-run with elevated permissions:"); + println!(" {}", retry_command(true, args.yes, true).bold()); + } + fail_if_non_interactive_requested( + args.yes, + "Rollback could not be completed because the CLI binary is not writable by the current user.", + )?; + return Ok(()); + } + return crate::util::self_update::rollback(args.yes); + } + + validate_interaction(args.yes, std::io::stdout().is_terminal())?; println!( "{} {} ({})", @@ -169,7 +170,35 @@ pub async fn command(args: Args) -> Result<()> { method.name() ); + // Order matters: check self-update first, then unknown, then package manager. match method { + method if method.can_self_update() && method.can_write_binary() => { + println!(); + crate::util::self_update::self_update_interactive().await?; + } + method if method.can_self_update() => { + // Shell install but binary location not writable by current user + println!(); + println!( + "{}", + "Cannot upgrade: the CLI binary is not writable by the current user.".yellow() + ); + println!(); + if cfg!(windows) { + println!("To upgrade, run the terminal as Administrator and retry:"); + println!(" {}", retry_command(false, args.yes, false).bold()); + } else { + println!("To upgrade, either:"); + println!(" 1. Re-run with elevated permissions:"); + println!(" {}", retry_command(false, args.yes, true).bold()); + println!(" 2. Reinstall using the install script:"); + println!(" {}", "bash <(curl -fsSL cli.new)".bold()); + } + fail_if_non_interactive_requested( + args.yes, + "Upgrade could not be completed because the CLI binary is not writable by the current user.", + )?; + } InstallMethod::Unknown => { println!(); println!( @@ -199,27 +228,77 @@ pub async fn command(args: Args) -> Result<()> { "For more information, visit: {}", "https://docs.railway.com/guides/cli".purple() ); - } - InstallMethod::Shell => { - println!(); - println!( - "{}", - "Detected shell script installation. To upgrade, run:".yellow() - ); - println!(); - println!(" {}", "bash <(curl -fsSL cli.new)".cyan()); - println!(); - println!( - "For more information, visit: {}", - "https://docs.railway.com/guides/cli".purple() - ); + fail_if_non_interactive_requested( + args.yes, + "Automatic upgrade could not be completed because the install method could not be detected.", + )?; } method if method.can_auto_upgrade() => { println!(); run_upgrade_command(method)?; } - _ => unreachable!(), + InstallMethod::Shell => { + // Shell install on a platform where self-update is unsupported + // (e.g. FreeBSD). Show the reinstall command. + println!(); + println!( + "{}", + "Self-update is not available on this platform. To upgrade, re-run the install script:".yellow() + ); + println!(); + println!(" {}", "bash <(curl -fsSL cli.new)".cyan()); + fail_if_non_interactive_requested( + args.yes, + "Automatic upgrade could not be completed because self-update is not available on this platform.", + )?; + } + _ => { + println!(); + println!( + "{}", + "Could not determine an upgrade strategy for this install method.".yellow() + ); + println!( + "Please upgrade manually. For more information, visit: {}", + "https://docs.railway.com/guides/cli".purple() + ); + fail_if_non_interactive_requested( + args.yes, + "Automatic upgrade could not be completed for this install method.", + )?; + } } Ok(()) } + +#[cfg(test)] +mod tests { + use super::{Args, validate_interaction}; + use clap::Parser; + + #[test] + fn parser_rejects_check_and_rollback_together() { + let result = Args::try_parse_from(["railway", "--check", "--rollback"]); + + assert!(result.is_err()); + } + + #[test] + fn parser_accepts_yes_with_rollback() { + let result = Args::try_parse_from(["railway", "--yes", "--rollback"]); + + assert!(result.is_ok()); + } + + #[test] + fn interactive_upgrade_does_not_require_yes() { + assert!(validate_interaction(false, true).is_ok()); + } + + #[test] + fn non_interactive_upgrade_requires_yes() { + assert!(validate_interaction(false, false).is_err()); + assert!(validate_interaction(true, false).is_ok()); + } +} diff --git a/src/consts.rs b/src/consts.rs index 541c2e6..7882792 100644 --- a/src/consts.rs +++ b/src/consts.rs @@ -7,6 +7,7 @@ pub const RAILWAY_API_TOKEN_ENV: &str = "RAILWAY_API_TOKEN"; pub const RAILWAY_PROJECT_ID_ENV: &str = "RAILWAY_PROJECT_ID"; pub const RAILWAY_ENVIRONMENT_ID_ENV: &str = "RAILWAY_ENVIRONMENT_ID"; pub const RAILWAY_SERVICE_ID_ENV: &str = "RAILWAY_SERVICE_ID"; +pub const RAILWAY_STAGE_UPDATE_ENV: &str = "_RAILWAY_STAGE_UPDATE"; pub const TICK_STRING: &str = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏ "; pub const NON_INTERACTIVE_FAILURE: &str = "This command is only available in interactive mode"; diff --git a/src/main.rs b/src/main.rs index f66604b..2076e8b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -29,6 +29,7 @@ mod telemetry; // Specify the modules you want to include in the commands_enum! macro commands!( add, + autoupdate, bucket, completion, connect, @@ -69,68 +70,211 @@ commands!( functions(function, func, fn, funcs, fns) ); -fn spawn_update_task() -> tokio::task::JoinHandle>> { +/// Groups the state needed to decide whether and how to check for / dispatch +/// a background update. +struct UpdateContext { + known_version: Option, + auto_update_enabled: bool, + skipped_version: Option, + check_gate_armed: bool, +} + +/// Routes a pending version to the appropriate background updater. +fn try_dispatch_update( + version: &str, + skipped_version: Option<&str>, + method: &util::install_method::InstallMethod, +) { + if skipped_version == Some(version) { + return; + } + if method.can_self_update() && method.can_write_binary() { + let _ = util::self_update::spawn_background_download(version); + } else if method.can_auto_run_package_manager() { + let _ = util::check_update::spawn_package_manager_update(*method); + } +} + +fn spawn_update_task( + ctx: UpdateContext, +) -> tokio::task::JoinHandle>> { 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 method = util::install_method::InstallMethod::detect(); + + // Safe to eagerly dispatch from cache: the gate means no API call + // will race with a newer version during this invocation. + if ctx.auto_update_enabled && ctx.check_gate_armed { + if let Some(ref version) = ctx.known_version { + try_dispatch_update(version, ctx.skipped_version.as_deref(), &method); + } + } + + // Skip the network check entirely when auto-update is disabled + // and there is no TTY to show a banner on (e.g. CI / scripts). + let (from_cache, latest_version) = + if !ctx.auto_update_enabled && !std::io::stdout().is_terminal() { + (ctx.known_version.is_some(), ctx.known_version) + } else { + match util::check_update::check_update(false).await { + Ok(Some(v)) => (false, Some(v)), + Ok(None) | Err(_) => (ctx.known_version.is_some(), ctx.known_version), + } + }; + + if let Some(ref version) = latest_version { + if ctx.auto_update_enabled && !from_cache { + try_dispatch_update(version, ctx.skipped_version.as_deref(), &method); + } } - let latest_version = util::check_update::check_update(false).await?; Ok(latest_version) }) } +/// Waits for the background update task to finish, but no longer than a +/// couple of seconds so that short-lived commands are not noticeably delayed. +/// The heavy download work runs in a detached process, so this timeout only +/// gates the fast version-check API call. async fn handle_update_task( handle: Option>>>, ) { + use std::time::Duration; + 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}"); - } + match tokio::time::timeout(Duration::from_secs(1), handle).await { + Ok(Ok(Ok(_))) => {} + Ok(Ok(Err(_))) | Ok(Err(_)) => {} // update error or task panic — non-fatal + Err(_) => {} // timeout — the API check was slow; next invocation retries } } } +/// Runs in a detached child process to download and stage an update. +async fn background_stage_update(version: &str) -> Result<()> { + use util::check_update::UpdateCheck; + + let result = async { + if telemetry::is_auto_update_disabled() { + return Ok(()); + } + + match util::self_update::download_and_stage(version).await { + Ok(true) => {} // Staged successfully; cache stays until try_apply_staged() succeeds. + Ok(false) => {} // Lock held by another process, will retry + Err(_) => UpdateCheck::record_download_failure(), + } + Ok(()) + } + .await; + + if let Ok(pid_path) = util::self_update::download_update_pid_path() { + let _ = std::fs::remove_file(pid_path); + } + + result +} + #[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(); + // Internal: detached background download spawned by a prior invocation. + if let Ok(version) = std::env::var(consts::RAILWAY_STAGE_UPDATE_ENV) { + return background_stage_update(&version).await; + } - if let Some(latest_version) = update.latest_version { - if matches!( - compare_semver(env!("CARGO_PKG_VERSION"), &latest_version), - Ordering::Less - ) { - println!( + let args = build_args().try_get_matches(); + let is_tty = std::io::stdout().is_terminal(); + // Help, version, and parse-error paths are read-only: no staged-binary + // apply, no background update spawn, no extra latency. + let is_help_or_error = args.as_ref().is_err(); + + // Peek at the subcommand early so we can skip the staged-update + // apply and background updater when the user is explicitly managing + // updates (`railway upgrade` or `railway autoupdate`). + // Check raw args too so that help/error paths (where clap returns Err) + // are also detected — e.g. `railway upgrade --help` should not apply + // a staged update as a side effect. + let raw_subcommand = std::env::args().nth(1).filter(|a| !a.starts_with('-')); + + let is_update_management_cmd = matches!( + raw_subcommand.as_deref(), + Some("upgrade" | "autoupdate" | "check_updates" | "check-updates") + ); + // Bare `railway` and `railway help` show help — treat as read-only so + // first-time users don't trigger update side effects. + let is_read_only_invocation = is_help_or_error + || raw_subcommand.is_none() + || matches!(raw_subcommand.as_deref(), Some("help")); + let auto_update_enabled = !telemetry::is_auto_update_disabled(); + + // Non-TTY invocations are a supported path for coding agents and other + // automated CLI users. They are allowed to refresh the update cache and + // kick off background installs, but we keep staged-binary apply TTY-only + // so the running binary never changes under a scripted invocation. + let auto_applied_version = + if auto_update_enabled && is_tty && !is_update_management_cmd && !is_read_only_invocation { + util::self_update::try_apply_staged() + } else { + None + }; + + let update = UpdateCheck::read_normalized(); + let skipped_version = update.skipped_version.clone(); + let check_gate_armed = update + .last_update_check + .map(|t| (chrono::Utc::now() - t) < chrono::Duration::hours(12)) + .unwrap_or(false); + + // Pass any pending version to spawn_update_task so it can skip the + // 12h short-circuit and retry a download that timed out in a + // prior run. The background task clears latest_version on success. + // + // If the running binary has already caught up to (or surpassed) the + // cached version, clear the stale cache so spawn_update_task falls + // through to a fresh check_update() and can discover newer releases. + let known_pending = update.latest_version; + + // Show the "new version available" banner only for TTY users. Coding + // agents and other non-interactive callers should still refresh update + // state in the background, but they should not receive human-facing + // upgrade prompts in command output. + // + // When auto-update is disabled via preference, we still show the banner + // to cautious interactive users who want release visibility. Suppress it + // when disabled via env var or CI, where extra output is noise. + let env_or_ci_suppressed = telemetry::is_auto_update_disabled_by_env() || Configs::env_is_ci(); + if is_tty && !env_or_ci_suppressed { + if let Some(ref latest_version) = known_pending { + let is_skipped = skipped_version.as_deref() == Some(latest_version.as_str()); + if !is_skipped + && matches!( + compare_semver(env!("CARGO_PKG_VERSION"), latest_version), + Ordering::Less + ) + { + eprintln!( "{} 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 { + // Spawn the background version check for all invocations (including + // non-TTY) so the version cache stays fresh for both humans and coding + // agents. Non-TTY callers are a first-class auto-update path: they may + // trigger background downloads/package-manager installs, but staged-binary + // apply and user-facing banners remain TTY-only. + let check_updates_handle = if is_update_management_cmd || is_read_only_invocation { None + } else { + Some(spawn_update_task(UpdateContext { + known_version: known_pending, + auto_update_enabled, + skipped_version, + check_gate_armed, + })) }; // https://github.com/clap-rs/clap/blob/cb2352f84a7663f32a89e70f01ad24446d5fa1e2/clap_builder/src/error/mod.rs#L210-L215 @@ -157,6 +301,7 @@ async fn main() -> Result<()> { "completion", "docs", "upgrade", + "autoupdate", "telemetry_cmd", "check_updates", ]; @@ -176,6 +321,22 @@ async fn main() -> Result<()> { let exec_result = exec_cli(cli).await; + // Send telemetry for silent auto-update apply (after auth is available). + if let Some(ref version) = auto_applied_version { + telemetry::send(telemetry::CliTrackEvent { + command: "autoupdate_apply".to_string(), + sub_command: Some(version.clone()), + success: true, + error_message: None, + duration_ms: 0, + cli_version: env!("CARGO_PKG_VERSION"), + os: std::env::consts::OS, + arch: std::env::consts::ARCH, + is_ci: Configs::env_is_ci(), + }) + .await; + } + if let Err(e) = exec_result { if e.root_cause().to_string() == inquire::InquireError::OperationInterrupted.to_string() { return Ok(()); // Exit gracefully if interrupted diff --git a/src/telemetry.rs b/src/telemetry.rs index 1b05b48..c16e662 100644 --- a/src/telemetry.rs +++ b/src/telemetry.rs @@ -1,3 +1,5 @@ +use anyhow::Context; + use crate::client::{GQLClient, post_graphql}; use crate::config::Configs; use crate::gql::mutations::{self, cli_event_track}; @@ -25,6 +27,8 @@ fn env_var_is_truthy(name: &str) -> bool { pub struct Preferences { #[serde(default)] pub telemetry_disabled: bool, + #[serde(default)] + pub auto_update_disabled: bool, } impl Preferences { @@ -39,12 +43,10 @@ impl Preferences { .unwrap_or_default() } - pub fn write(&self) { - if let Some(path) = Self::path() { - let _ = serde_json::to_string(self) - .ok() - .map(|contents| std::fs::write(path, contents)); - } + pub fn write(&self) -> anyhow::Result<()> { + let path = Self::path().context("Failed to determine home directory")?; + let contents = serde_json::to_string(self)?; + crate::util::write_atomic(&path, &contents) } } @@ -52,6 +54,16 @@ pub fn is_telemetry_disabled_by_env() -> bool { env_var_is_truthy("DO_NOT_TRACK") || env_var_is_truthy("RAILWAY_NO_TELEMETRY") } +pub fn is_auto_update_disabled_by_env() -> bool { + env_var_is_truthy("RAILWAY_NO_AUTO_UPDATE") +} + +pub fn is_auto_update_disabled() -> bool { + is_auto_update_disabled_by_env() + || Preferences::read().auto_update_disabled + || crate::config::Configs::env_is_ci() +} + fn is_telemetry_disabled() -> bool { is_telemetry_disabled_by_env() || Preferences::read().telemetry_disabled } diff --git a/src/util/check_update.rs b/src/util/check_update.rs index a683109..1e69f96 100644 --- a/src/util/check_update.rs +++ b/src/util/check_update.rs @@ -5,23 +5,147 @@ use dirs::home_dir; use super::compare_semver::compare_semver; +/// Best-effort write — logs a warning on failure but does not propagate. +/// Used by cache mutation methods where a write failure is non-fatal. +fn try_write(update: &UpdateCheck) { + if let Err(e) = update.write() { + eprintln!("warning: failed to write update cache: {e}"); + } +} + #[derive(serde::Serialize, serde::Deserialize, Default)] pub struct UpdateCheck { pub last_update_check: Option>, pub latest_version: Option, + /// Number of consecutive download failures for the cached version. + /// After 3 failures the version is cleared to force a fresh API check. + #[serde(default)] + pub download_failures: u32, + /// Version the user rolled back from. Auto-update skips this version + /// and resumes normally once a newer release is published. + #[serde(default)] + pub skipped_version: Option, + /// Timestamp of the last package-manager spawn. We only re-spawn if + /// this is older than 1 hour, preventing rapid-fire retries when + /// multiple CLI invocations happen before the update finishes. + #[serde(default)] + pub last_package_manager_spawn: Option>, } impl UpdateCheck { + fn has_stale_latest_version(&self) -> bool { + self.latest_version + .as_deref() + .map(|latest| { + !matches!( + compare_semver(env!("CARGO_PKG_VERSION"), latest), + Ordering::Less + ) + }) + .unwrap_or(false) + } + + fn clear_latest_fields(&mut self) { + self.latest_version = None; + self.download_failures = 0; + self.last_package_manager_spawn = None; + self.last_update_check = None; + } + pub fn write(&self) -> anyhow::Result<()> { let home = home_dir().context("Failed to get home directory")?; let path = home.join(".railway/version.json"); - let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap(); - let pid = std::process::id(); - // almost guaranteed no collision- can be upgraded to uuid if necessary. - let tmp_path = path.with_extension(format!("tmp.{pid}-{nanos}.json")); let contents = serde_json::to_string_pretty(&self)?; - std::fs::write(&tmp_path, contents)?; - std::fs::rename(&tmp_path, &path)?; - Ok(()) + super::write_atomic(&path, &contents) + } + + /// Read-modify-write helper: reads cached state (or default), applies + /// the mutation, and writes back. + fn mutate(f: impl FnOnce(&mut Self)) { + let mut update = Self::read().unwrap_or_default(); + f(&mut update); + try_write(&update); + } + + /// Update the check timestamp, optionally preserving (or clearing) the + /// cached pending version. Resets the failure counter. + pub fn persist_latest(version: Option<&str>) { + Self::mutate(|u| { + u.last_update_check = Some(chrono::Utc::now()); + // Reset package-manager spawn gate when the target version changes + // so the new version gets an immediate attempt. + if u.latest_version.as_deref() != version { + u.last_package_manager_spawn = None; + } + u.latest_version = version.map(String::from); + u.download_failures = 0; + }); + } + + /// Read the cached update state and clear any pending version that is no + /// longer ahead of the currently running binary. + pub fn read_normalized() -> Self { + let mut update = Self::read().unwrap_or_default(); + if update.has_stale_latest_version() { + update.clear_latest_fields(); + try_write(&update); + } + update + } + + /// Record a version to skip during auto-update (set after rollback). + /// Clears `last_update_check` so the next invocation re-checks immediately. + pub fn skip_version(version: &str) { + Self::mutate(|u| { + u.skipped_version = Some(version.to_string()); + u.last_package_manager_spawn = None; + u.last_update_check = None; + }); + } + + /// Reset cached update state after a successful upgrade or auto-apply. + pub fn clear_after_update() { + Self::mutate(|u| { + u.last_update_check = Some(chrono::Utc::now()); + u.latest_version = None; + u.download_failures = 0; + u.last_package_manager_spawn = None; + u.skipped_version = None; + }); + } + + /// Max consecutive download failures before clearing the cached version. + const MAX_DOWNLOAD_FAILURES: u32 = 3; + + /// Record a failed download attempt. After [`Self::MAX_DOWNLOAD_FAILURES`] + /// consecutive failures the cached pending version is cleared so the next + /// invocation re-checks the GitHub API instead of retrying a stale version. + pub fn record_download_failure() { + Self::mutate(|u| { + u.download_failures += 1; + if u.download_failures >= Self::MAX_DOWNLOAD_FAILURES { + u.latest_version = None; + u.last_update_check = None; + u.download_failures = 0; + } + }); + } + + /// Record that a package-manager update was just spawned. + pub fn record_package_manager_spawn() { + Self::mutate(|u| { + u.last_package_manager_spawn = Some(chrono::Utc::now()); + }); + } + + /// Returns `true` if enough time has passed since the last package-manager + /// spawn to allow another attempt (or if no spawn has been recorded). + pub fn should_spawn_package_manager() -> bool { + Self::read() + .map(|u| match u.last_package_manager_spawn { + Some(t) => (chrono::Utc::now() - t) >= chrono::Duration::hours(1), + None => true, + }) + .unwrap_or(true) } pub fn read() -> anyhow::Result { @@ -42,12 +166,15 @@ pub async fn check_update(force: bool) -> anyhow::Result> { let update = UpdateCheck::read().unwrap_or_default(); if let Some(last_update_check) = update.last_update_check { - if chrono::Utc::now().date_naive() == last_update_check.date_naive() && !force { - bail!("Update check already ran today"); + // 12-hour gate: avoid hitting the GitHub API on every invocation. + if (chrono::Utc::now() - last_update_check) < chrono::Duration::hours(12) && !force { + return Ok(None); } } - let client = reqwest::Client::new(); + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(30)) + .build()?; let response = client .get(GITHUB_API_RELEASE_URL) .header("User-Agent", "railwayapp") @@ -58,13 +185,227 @@ pub async fn check_update(force: bool) -> anyhow::Result> { match compare_semver(env!("CARGO_PKG_VERSION"), latest_version) { Ordering::Less => { - let update = UpdateCheck { - last_update_check: Some(chrono::Utc::now()), - latest_version: Some(latest_version.to_owned()), - }; - update.write()?; + // Re-read state from disk so we don't overwrite fields that + // were changed while the network request was in flight (e.g. + // `skipped_version` set by a concurrent rollback). + let mut fresh = UpdateCheck::read().unwrap_or_default(); + // Don't arm the daily gate when the latest release is the version + // the user rolled back from — keep checking so a fix release + // published shortly after is discovered promptly. + if fresh.skipped_version.as_deref() != Some(latest_version) { + fresh.last_update_check = Some(chrono::Utc::now()); + } + // Reset package-manager spawn gate when a genuinely new version + // appears so it gets an immediate attempt. + if fresh.latest_version.as_deref() != Some(latest_version) { + fresh.last_package_manager_spawn = None; + } + fresh.latest_version = Some(latest_version.to_owned()); + fresh.download_failures = 0; + fresh.write()?; Ok(Some(latest_version.to_string())) } - _ => Ok(None), + _ => { + // Record the check time so we don't re-check on every invocation. + UpdateCheck::persist_latest(None); + Ok(None) + } + } +} + +/// Spawns a fully detached package manager process to update the CLI. +/// Used for npm, Bun, and Scoop installs where the package manager is fast. +/// The child process runs independently — if the update succeeds, the next +/// CLI invocation will be the new version and the "new version available" +/// notification will stop appearing. +pub fn spawn_package_manager_update( + method: super::install_method::InstallMethod, +) -> anyhow::Result<()> { + let (program, args) = method + .package_manager_command() + .context("No package manager command for this install method")?; + + if which::which(program).is_err() { + bail!("Package manager '{program}' not found in PATH"); + } + + // Acquire a file lock to serialize the PID-check-spawn-write sequence, + // preventing two concurrent invocations from both launching an updater. + use fs2::FileExt; + + let lock_path = super::self_update::package_update_lock_path()?; + if let Some(parent) = lock_path.parent() { + std::fs::create_dir_all(parent)?; + } + let lock_file = + std::fs::File::create(&lock_path).context("Failed to create package-update lock file")?; + lock_file + .try_lock_exclusive() + .map_err(|_| anyhow::anyhow!("Another update process is starting. Please try again."))?; + + // Re-check after acquiring the lock: the user may have run + // `railway autoupdate disable` while we were waiting. + if crate::telemetry::is_auto_update_disabled() { + bail!("Auto-updates were disabled while waiting for lock"); + } + + // Only spawn once per hour to avoid rapid-fire retries when multiple + // CLI invocations happen before the update finishes. + if !UpdateCheck::should_spawn_package_manager() { + bail!("Package-manager update was spawned recently; waiting before retrying"); + } + + // Guard against an already-running updater. + let pid_path = super::self_update::package_update_pid_path()?; + if let Some(pid) = is_background_update_running(&pid_path) { + bail!("Another update process (pid {pid}) is already running"); + } + + let log_path = super::self_update::auto_update_log_path()?; + + let mut cmd = std::process::Command::new(program); + cmd.args(&args); + + let child = super::spawn_detached(&mut cmd, &log_path)?; + let child_pid = child.id(); + // Intentionally leak the Child handle — we never wait on the detached + // process. On Unix this is harmless; on Windows it leaks a HANDLE, + // which is acceptable for a single short-lived spawn per invocation. + std::mem::forget(child); + + // Record the child PID + timestamp so future invocations can detect an + // in-flight update and expire stale entries. + let now = chrono::Utc::now().timestamp(); + let _ = std::fs::write(&pid_path, format!("{child_pid} {now}")); + + // Record spawn time so we don't re-spawn within the next hour. + UpdateCheck::record_package_manager_spawn(); + + // Lock is released on drop after the PID file is written. + + Ok(()) +} + +/// Maximum age in seconds for a PID file entry before it's considered stale. +const PID_STALENESS_TTL_SECS: i64 = 600; + +/// Parse a PID file containing `"{pid} {timestamp}"`. +pub fn parse_pid_file(contents: &str) -> Option<(u32, i64)> { + let mut parts = contents.split_whitespace(); + let pid = parts.next()?.parse().ok()?; + let ts = parts.next()?.parse().ok()?; + Some((pid, ts)) +} + +/// Returns `true` if a background package-manager update is currently running, +/// based on the PID file at the given path. +pub fn is_background_update_running(pid_path: &std::path::Path) -> Option { + let contents = std::fs::read_to_string(pid_path).ok()?; + let (pid, ts) = parse_pid_file(&contents)?; + let age_secs = chrono::Utc::now().timestamp().saturating_sub(ts); + if age_secs < PID_STALENESS_TTL_SECS && is_pid_alive(pid) { + Some(pid) + } else { + None + } +} + +/// Check whether a process with the given PID is still running. +pub fn is_pid_alive(pid: u32) -> bool { + #[cfg(unix)] + { + use nix::sys::signal::kill; + use nix::unistd::Pid; + // Signal 0 checks existence without delivering a signal. + // EPERM means the process exists but we lack permission to signal it. + matches!( + kill(Pid::from_raw(pid as i32), None), + Ok(()) | Err(nix::errno::Errno::EPERM) + ) + } + #[cfg(windows)] + { + use winapi::um::handleapi::CloseHandle; + use winapi::um::processthreadsapi::{GetExitCodeProcess, OpenProcess}; + use winapi::um::winnt::PROCESS_QUERY_INFORMATION; + // GetExitCodeProcess returns STILL_ACTIVE (259) while the process runs. + const STILL_ACTIVE: u32 = 259; + unsafe { + let handle = OpenProcess(PROCESS_QUERY_INFORMATION, 0, pid); + if handle.is_null() { + // Process doesn't exist or we have no permission to query it. + return false; + } + let mut exit_code: u32 = 0; + let ok = GetExitCodeProcess(handle, &mut exit_code as *mut u32 as *mut _) != 0; + CloseHandle(handle); + ok && exit_code == STILL_ACTIVE + } + } + #[cfg(not(any(unix, windows)))] + { + // Conservative fallback for other platforms (e.g. FreeBSD): assume + // alive and let the 10-minute staleness TTL expire the entry. + let _ = pid; + true + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn next_version(version: &str) -> String { + let mut parts = version + .split('-') + .next() + .unwrap_or(version) + .split('.') + .map(|part| part.parse::().unwrap_or(0)) + .collect::>(); + parts.resize(3, 0); + + for idx in (0..parts.len()).rev() { + if parts[idx] < u8::MAX { + parts[idx] += 1; + for part in parts.iter_mut().skip(idx + 1) { + *part = 0; + } + return format!("{}.{}.{}", parts[0], parts[1], parts[2]); + } + } + + "255.255.255-rc.1".to_string() + } + + #[test] + fn stale_latest_version_is_detected_and_cleared() { + let mut update = UpdateCheck { + last_update_check: Some(chrono::Utc::now()), + latest_version: Some(env!("CARGO_PKG_VERSION").to_string()), + download_failures: 2, + skipped_version: Some("0.1.0".to_string()), + last_package_manager_spawn: Some(chrono::Utc::now()), + }; + + assert!(update.has_stale_latest_version()); + + update.clear_latest_fields(); + + assert!(update.latest_version.is_none()); + assert_eq!(update.download_failures, 0); + assert!(update.last_package_manager_spawn.is_none()); + assert!(update.last_update_check.is_none()); + assert_eq!(update.skipped_version.as_deref(), Some("0.1.0")); + } + + #[test] + fn newer_latest_version_is_not_stale() { + let update = UpdateCheck { + latest_version: Some(next_version(env!("CARGO_PKG_VERSION"))), + ..Default::default() + }; + + assert!(!update.has_stale_latest_version()); } } diff --git a/src/util/install_method.rs b/src/util/install_method.rs new file mode 100644 index 0000000..a0c9ec2 --- /dev/null +++ b/src/util/install_method.rs @@ -0,0 +1,251 @@ +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum InstallMethod { + Homebrew, + Npm, + Bun, + Cargo, + Shell, + Scoop, + Unknown, +} + +impl InstallMethod { + pub fn detect() -> Self { + let exe_path = match std::env::current_exe() { + Ok(path) => path, + Err(_) => return InstallMethod::Unknown, + }; + + // Resolve symlinks so that e.g. /usr/local/bin/railway (Intel + // Homebrew symlink) is followed to /usr/local/Cellar/… and + // correctly classified as Homebrew rather than Shell. + let exe_path = exe_path.canonicalize().unwrap_or(exe_path); + + let path_str = exe_path.to_string_lossy().to_lowercase(); + + if path_str.contains("homebrew") + || path_str.contains("cellar") + || path_str.contains("linuxbrew") + { + return InstallMethod::Homebrew; + } + + // Check for Bun global install (must be before npm since bun uses node_modules internally) + if path_str.contains(".bun") { + return InstallMethod::Bun; + } + + // pnpm paths contain "npm" as a substring — check before npm. + if path_str.contains("pnpm") { + return InstallMethod::Unknown; + } + + if path_str.contains("node_modules") + || path_str.contains("npm") + || path_str.contains(".npm") + { + return InstallMethod::Npm; + } + + if path_str.contains(".cargo") && path_str.contains("bin") { + return InstallMethod::Cargo; + } + + if path_str.contains("scoop") { + return InstallMethod::Scoop; + } + + // Cargo's `CARGO_INSTALL_ROOT` can place binaries in standard paths + // like /usr/local/bin or ~/.local/bin. Check for the `.crates.toml` + // marker *before* the shell-path heuristic so these are not + // misclassified as Shell installs. + if exe_path + .parent() + .and_then(|bin| bin.parent()) + .map(|root| root.join(".crates.toml").exists()) + .unwrap_or(false) + { + return InstallMethod::Cargo; + } + + if path_str.contains("/usr/local/bin") || path_str.contains("/.local/bin") { + return InstallMethod::Shell; + } + + if path_str.contains("program files") || path_str.contains("programfiles") { + return InstallMethod::Shell; + } + + // Paths owned by system package managers — must be checked before + // the catch-all so we don't misclassify them as Shell. + const SYSTEM_PATHS: &[&str] = &[ + "/usr/bin", + "/usr/sbin", + "/nix/", + "nix-profile", + "/snap/", + "/flatpak/", + ]; + if SYSTEM_PATHS.iter().any(|p| path_str.contains(p)) { + return InstallMethod::Unknown; + } + + // Version managers install binaries under their own directory trees. + // Exclude them so the catch-all doesn't misclassify a managed binary + // as a shell install and attempt to self-replace it. + const VERSION_MANAGER_PATHS: &[&str] = &[ + ".asdf/", ".mise/", ".rtx/", ".proto/", ".volta/", ".fnm/", ".nodenv/", ".rbenv/", + ".pyenv/", + ]; + if VERSION_MANAGER_PATHS.iter().any(|p| path_str.contains(p)) { + return InstallMethod::Unknown; + } + + // Catch-all: if the binary lives in any directory named "bin" and no + // package manager, system path, or version manager was detected, it + // was most likely installed via the shell installer (possibly with a + // custom --bin-dir like ~/tools/bin or /opt/railway/bin). + // Note: Cargo's CARGO_INSTALL_ROOT is already caught by the + // `.crates.toml` check above, so no need to re-check here. + if exe_path + .parent() + .and_then(|p| p.file_name()) + .map(|n| n == "bin") + .unwrap_or(false) + { + return InstallMethod::Shell; + } + + InstallMethod::Unknown + } + + pub fn name(&self) -> &'static str { + match self { + InstallMethod::Homebrew => "Homebrew", + InstallMethod::Npm => "npm", + InstallMethod::Bun => "Bun", + InstallMethod::Cargo => "Cargo", + InstallMethod::Shell => "Shell script", + InstallMethod::Scoop => "Scoop", + InstallMethod::Unknown => "Unknown", + } + } + + pub fn upgrade_command(&self) -> Option { + if let Some((program, args)) = self.package_manager_command() { + return Some(format!("{} {}", program, args.join(" "))); + } + match self { + InstallMethod::Shell => Some("bash <(curl -fsSL cli.new)".to_string()), + _ => None, + } + } + + pub fn can_auto_upgrade(&self) -> bool { + matches!( + self, + InstallMethod::Homebrew + | InstallMethod::Npm + | InstallMethod::Bun + | InstallMethod::Cargo + | InstallMethod::Scoop + ) + } + + /// Whether this install method supports direct binary self-update + /// (download from GitHub Releases and replace in place). + /// Only Shell installs on platforms with published release assets qualify. + /// Unknown means we don't know where the binary came from, so + /// self-updating it could conflict with an undetected package manager. + pub fn can_self_update(&self) -> bool { + matches!(self, InstallMethod::Shell) && is_self_update_platform() + } + + /// Whether the current process can write to the directory containing the + /// binary. Returns `false` for paths like `/usr/local/bin` that were + /// installed with `sudo` and are not writable by the current user. + pub fn can_write_binary(&self) -> bool { + let exe_path = match std::env::current_exe() { + Ok(p) => p, + Err(_) => return false, + }; + let dir = match exe_path.parent() { + Some(d) => d, + None => return false, + }; + + // Try creating a temp file in the same directory — the most reliable + // cross-platform writability check (accounts for ACLs, mount flags…). + let probe = dir.join(".railway-write-probe"); + let writable = std::fs::File::create(&probe).is_ok(); + let _ = std::fs::remove_file(&probe); + writable + } + + /// Whether this install method supports auto-running the package manager + /// in the background. Homebrew and Cargo are excluded because they can + /// take several minutes and would keep a detached process alive far longer + /// than is acceptable for a transparent background update. + /// + /// Also checks that the package manager's global install directory is + /// writable by the current user, so we don't spawn a doomed `npm update -g` + /// (installed via `sudo`) that fails immediately on every invocation. + pub fn can_auto_run_package_manager(&self) -> bool { + if !matches!( + self, + InstallMethod::Npm | InstallMethod::Bun | InstallMethod::Scoop + ) { + return false; + } + + // Probe writability of the directory containing the binary — if we + // can't write there, the package manager update will fail anyway. + self.can_write_binary() + } + + /// Human-readable description of the auto-update strategy for this install method. + /// Reflects the actual runtime behaviour by checking platform support and + /// binary writability, so `autoupdate status` never overpromises. + pub fn update_strategy(&self) -> &'static str { + match self { + InstallMethod::Shell if self.can_self_update() && self.can_write_binary() => { + "Background download + auto-swap" + } + InstallMethod::Shell if self.can_self_update() => { + "Notification only (binary not writable)" + } + InstallMethod::Shell => "Notification only (unsupported platform)", + InstallMethod::Npm | InstallMethod::Bun | InstallMethod::Scoop + if self.can_auto_run_package_manager() => + { + "Auto-run package manager" + } + InstallMethod::Npm | InstallMethod::Bun | InstallMethod::Scoop => { + "Notification only (binary not writable)" + } + InstallMethod::Homebrew | InstallMethod::Cargo | InstallMethod::Unknown => { + "Notification only (manual upgrade)" + } + } + } + + /// Returns the program and arguments to run the package manager upgrade. + pub fn package_manager_command(&self) -> Option<(&'static str, Vec<&'static str>)> { + match self { + InstallMethod::Homebrew => Some(("brew", vec!["upgrade", "railway"])), + InstallMethod::Npm => Some(("npm", vec!["update", "-g", "@railway/cli"])), + InstallMethod::Bun => Some(("bun", vec!["update", "-g", "@railway/cli"])), + InstallMethod::Cargo => Some(("cargo", vec!["install", "railwayapp", "--locked"])), + InstallMethod::Scoop => Some(("scoop", vec!["update", "railway"])), + InstallMethod::Shell | InstallMethod::Unknown => None, + } + } +} + +/// Returns `true` when the release pipeline publishes a binary for the +/// current OS, i.e. self-update can actually download an asset. +/// FreeBSD is recognized by the install script but no release asset is +/// published, so it must not enter the self-update path. +fn is_self_update_platform() -> bool { + matches!(std::env::consts::OS, "macos" | "linux" | "windows") +} diff --git a/src/util/mod.rs b/src/util/mod.rs index db3220a..14265fd 100644 --- a/src/util/mod.rs +++ b/src/util/mod.rs @@ -1,9 +1,96 @@ pub mod check_update; pub mod compare_semver; +pub mod install_method; pub mod logs; pub mod progress; pub mod prompt; pub mod retry; +pub mod self_update; pub mod time; pub mod two_factor; pub mod watcher; + +/// Spawns a command in a fully detached process group so it survives after the +/// parent exits and Ctrl+C does not propagate. stdout/stderr are redirected to +/// the given log file. +pub fn spawn_detached( + cmd: &mut std::process::Command, + log_path: &std::path::Path, +) -> anyhow::Result { + if let Some(parent) = log_path.parent() { + std::fs::create_dir_all(parent)?; + } + let log_file = std::fs::File::create(log_path)?; + let log_stderr = log_file.try_clone()?; + + cmd.stdin(std::process::Stdio::null()) + .stdout(log_file) + .stderr(log_stderr); + + #[cfg(unix)] + { + use std::os::unix::process::CommandExt; + cmd.process_group(0); + } + #[cfg(windows)] + { + use std::os::windows::process::CommandExt; + const CREATE_NEW_PROCESS_GROUP: u32 = 0x0000_0200; + cmd.creation_flags(CREATE_NEW_PROCESS_GROUP); + } + + cmd.spawn().map_err(Into::into) +} + +/// Atomically writes `contents` to `path` via a temp file + rename. +/// The temp filename includes PID and nanosecond timestamp to avoid +/// collisions between concurrent processes. +pub fn write_atomic(path: &std::path::Path, contents: &str) -> anyhow::Result<()> { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + let pid = std::process::id(); + let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default(); + let tmp_path = path.with_extension(format!("tmp.{pid}-{nanos}.json")); + std::fs::write(&tmp_path, contents)?; + rename_replacing(&tmp_path, path)?; + Ok(()) +} + +/// Renames `from` to `to`, overwriting `to` if it already exists. +/// On Unix `std::fs::rename` already replaces the destination atomically. +/// On Windows we use `MoveFileExW` with `MOVEFILE_REPLACE_EXISTING` for an +/// atomic single-syscall replace. +pub fn rename_replacing(from: &std::path::Path, to: &std::path::Path) -> std::io::Result<()> { + #[cfg(not(windows))] + { + std::fs::rename(from, to) + } + #[cfg(windows)] + { + use std::os::windows::ffi::OsStrExt; + use winapi::um::winbase::{MOVEFILE_REPLACE_EXISTING, MoveFileExW}; + + fn to_wide(path: &std::path::Path) -> Vec { + path.as_os_str() + .encode_wide() + .chain(std::iter::once(0)) + .collect() + } + + let from_wide = to_wide(from); + let to_wide = to_wide(to); + let ret = unsafe { + MoveFileExW( + from_wide.as_ptr(), + to_wide.as_ptr(), + MOVEFILE_REPLACE_EXISTING, + ) + }; + if ret == 0 { + Err(std::io::Error::last_os_error()) + } else { + Ok(()) + } + } +} diff --git a/src/util/self_update.rs b/src/util/self_update.rs new file mode 100644 index 0000000..451cb20 --- /dev/null +++ b/src/util/self_update.rs @@ -0,0 +1,969 @@ +use std::fs; +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result, bail}; +use colored::Colorize; + +/// Maximum age for a staged update before it's considered stale and cleaned up. +const STAGED_UPDATE_MAX_AGE_DAYS: i64 = 7; + +fn railway_dir() -> Result { + let home = dirs::home_dir().context("Failed to get home directory")?; + Ok(home.join(".railway")) +} + +fn staged_update_dir() -> Result { + Ok(railway_dir()?.join("staged-update")) +} + +fn backups_dir() -> Result { + Ok(railway_dir()?.join("backups")) +} + +pub fn update_lock_path() -> Result { + Ok(railway_dir()?.join("update.lock")) +} + +pub fn package_update_pid_path() -> Result { + Ok(railway_dir()?.join("package-update.pid")) +} + +pub fn download_update_pid_path() -> Result { + Ok(railway_dir()?.join("download-update.pid")) +} + +pub fn package_update_lock_path() -> Result { + Ok(railway_dir()?.join("package-update.lock")) +} + +pub fn auto_update_log_path() -> Result { + Ok(railway_dir()?.join("auto-update.log")) +} + +/// Returns the compile-time target triple of this binary, ensuring the +/// self-updater fetches the exact same ABI variant (e.g. gnu vs musl). +/// The value is set by `build.rs` via `BUILD_TARGET`. +fn detect_target_triple() -> Result<&'static str> { + Ok(env!("BUILD_TARGET")) +} + +const RELEASE_BASE_URL: &str = "https://github.com/railwayapp/cli/releases/download"; + +fn release_asset_name(version: &str, target: &str) -> String { + // i686-pc-windows-gnu is cross-compiled on Linux and only ships as tar.gz. + let ext = if target.contains("windows") && target != "i686-pc-windows-gnu" { + "zip" + } else { + "tar.gz" + }; + format!("railway-v{version}-{target}.{ext}") +} + +fn release_url(version: &str, asset_name: &str) -> String { + format!("{RELEASE_BASE_URL}/v{version}/{asset_name}") +} + +fn binary_name() -> &'static str { + if cfg!(target_os = "windows") { + "railway.exe" + } else { + "railway" + } +} + +fn acquire_update_lock( + lock_path: &Path, + wait_for_lock: bool, + busy_message: &str, +) -> Result { + use fs2::FileExt; + + if let Some(parent) = lock_path.parent() { + fs::create_dir_all(parent)?; + } + + let lock_file = + std::fs::File::create(lock_path).context("Failed to create update lock file")?; + + if wait_for_lock { + lock_file + .lock_exclusive() + .with_context(|| busy_message.to_string())?; + } else { + lock_file + .try_lock_exclusive() + .map_err(|_| anyhow::anyhow!(busy_message.to_string()))?; + } + + Ok(lock_file) +} + +fn shell_update_busy_message_for_pid_path(pid_path: &Path) -> String { + match crate::util::check_update::is_background_update_running(pid_path) { + Some(pid) => format!( + "A background shell update (PID {pid}) is already running. Please wait for it to finish or try again shortly." + ), + None => "A background update is already in progress. Please try again shortly.".to_string(), + } +} + +fn shell_update_busy_message() -> String { + match download_update_pid_path() { + Ok(pid_path) => shell_update_busy_message_for_pid_path(&pid_path), + Err(_) => { + "A background update is already in progress. Please try again shortly.".to_string() + } + } +} + +#[derive(serde::Serialize, serde::Deserialize)] +struct StagedUpdate { + version: String, + target: String, + staged_at: chrono::DateTime, +} + +impl StagedUpdate { + fn read() -> Result> { + let path = staged_update_dir()?.join("update.json"); + match fs::read_to_string(&path) { + Ok(contents) => { + let update: Self = serde_json::from_str(&contents) + .context("Failed to parse staged update metadata")?; + Ok(Some(update)) + } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(e) => Err(e).context("Failed to read staged update metadata"), + } + } + + fn write(&self) -> Result<()> { + let path = staged_update_dir()?.join("update.json"); + let contents = serde_json::to_string_pretty(self)?; + super::write_atomic(&path, &contents) + } + + fn clean() -> Result<()> { + let dir = staged_update_dir()?; + match fs::remove_dir_all(&dir) { + Ok(()) => Ok(()), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(e) => Err(e).context("Failed to clean staged update directory"), + } + } + + fn is_stale(&self) -> bool { + let max_age = chrono::Duration::days(STAGED_UPDATE_MAX_AGE_DAYS); + chrono::Utc::now() - self.staged_at > max_age + } +} + +/// Public entry point for cleaning staged updates (e.g., when auto-updates are disabled). +pub fn clean_staged() -> Result<()> { + StagedUpdate::clean() +} + +/// Returns the version string of a staged update only if it is still valid +/// for application on this machine. Invalid staged updates are cleaned up +/// by the shared validator so status reporting matches runtime behavior. +pub fn validated_staged_version() -> Option { + validate_staged().ok().map(|staged| staged.version) +} + +struct BackgroundPidGuard { + path: PathBuf, +} + +impl BackgroundPidGuard { + fn create(path: PathBuf) -> Result { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + let now = chrono::Utc::now().timestamp(); + let pid = std::process::id(); + fs::write(&path, format!("{pid} {now}"))?; + Ok(Self { path }) + } +} + +impl Drop for BackgroundPidGuard { + fn drop(&mut self) { + let _ = fs::remove_file(&self.path); + } +} + +/// Downloads and stages the update, assuming the caller already holds the +/// update lock. Shared by [`download_and_stage`] (background path) and +/// [`self_update_interactive`] (interactive path). +async fn download_and_stage_inner(version: &str, timeout_secs: u64) -> Result<()> { + let target = detect_target_triple()?; + + // Authoritative post-lock re-check: `download_and_stage` also checks this + // before acquiring the lock as a fast path, but this check is the one that + // matters for correctness since no other process can modify staged state + // while we hold the lock. + if let Ok(Some(staged)) = StagedUpdate::read() { + if staged.version == version && staged.target == target { + if staged_update_dir() + .map(|d| d.join(binary_name()).exists()) + .unwrap_or(false) + { + return Ok(()); + } + // Metadata exists but binary is missing — clean and re-download. + let _ = StagedUpdate::clean(); + } + } + + let asset_name = release_asset_name(version, target); + let url = release_url(version, &asset_name); + + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(timeout_secs)) + .build()?; + + let response = client + .get(&url) + .header("User-Agent", "railwayapp") + .send() + .await + .context("Failed to download update")?; + + if !response.status().is_success() { + bail!("Failed to download update: HTTP {}", response.status()); + } + + let bytes = response + .bytes() + .await + .context("Failed to read update response")?; + + let dir = staged_update_dir()?; + fs::create_dir_all(&dir)?; + + let bin_name = binary_name(); + let extract_and_write = || -> Result<()> { + if asset_name.ends_with(".zip") { + extract_from_zip(&bytes, bin_name, &dir)?; + } else { + extract_from_tar_gz(&bytes, bin_name, &dir)?; + } + + StagedUpdate { + version: version.to_string(), + target: target.to_string(), + staged_at: chrono::Utc::now(), + } + .write()?; + + Ok(()) + }; + + if let Err(e) = extract_and_write() { + let _ = StagedUpdate::clean(); + return Err(e); + } + + Ok(()) +} + +/// Downloads the release tarball for the given version and extracts the binary +/// to the staged update directory. Cleans up on partial failure. +/// Uses file locking to prevent concurrent CLI processes from racing. +/// +/// Returns `Ok(true)` when the update was staged (or was already staged for +/// this version/target). Returns `Ok(false)` when another process holds the +/// update lock — the caller should **not** treat this as a completed update. +pub async fn download_and_stage(version: &str) -> Result { + use fs2::FileExt; + + let target = detect_target_triple()?; + + if let Ok(Some(staged)) = StagedUpdate::read() { + if staged.version == version && staged.target == target { + if staged_update_dir() + .map(|d| d.join(binary_name()).exists()) + .unwrap_or(false) + { + return Ok(true); + } + // Metadata exists but binary is missing — clean and re-download. + let _ = StagedUpdate::clean(); + } + } + + let lock_path = update_lock_path()?; + if let Some(parent) = lock_path.parent() { + fs::create_dir_all(parent)?; + } + let lock_file = + std::fs::File::create(&lock_path).context("Failed to create update lock file")?; + if lock_file.try_lock_exclusive().is_err() { + // Another process is already staging or applying an update. + return Ok(false); + } + + // Re-check after acquiring the lock: the user may have run + // `railway autoupdate disable` while we were waiting. + if crate::telemetry::is_auto_update_disabled() { + return Ok(false); + } + + let _pid_guard = BackgroundPidGuard::create(download_update_pid_path()?) + .context("Failed to record background download PID")?; + + download_and_stage_inner(version, 30).await?; + + Ok(true) +} + +/// Spawns a detached child process that downloads and stages the update. +/// The child runs independently of the parent — it survives after the +/// parent exits, so slow downloads are not killed by the exit timeout. +pub fn spawn_background_download(version: &str) -> Result<()> { + let exe = std::env::current_exe().context("Failed to get current exe path")?; + let log_path = auto_update_log_path()?; + + let mut cmd = std::process::Command::new(exe); + cmd.env(crate::consts::RAILWAY_STAGE_UPDATE_ENV, version); + + let child = super::spawn_detached(&mut cmd, &log_path)?; + // Intentionally leak the Child handle — we never wait on the detached + // process. On Unix this is harmless; on Windows it leaks a HANDLE, + // which is acceptable for a single short-lived spawn per invocation. + std::mem::forget(child); + Ok(()) +} + +fn extract_from_tar_gz(bytes: &[u8], bin_name: &str, dest_dir: &Path) -> Result<()> { + use flate2::read::GzDecoder; + + let decoder = GzDecoder::new(bytes); + let mut archive = tar::Archive::new(decoder); + + for entry in archive.entries().context("Failed to read tar entries")? { + let mut entry = entry.context("Failed to read tar entry")?; + let path = entry.path().context("Failed to read entry path")?; + + if path.file_name().and_then(|n| n.to_str()) == Some(bin_name) { + let dest_path = dest_dir.join(bin_name); + let mut file = + fs::File::create(&dest_path).context("Failed to create staged binary file")?; + std::io::copy(&mut entry, &mut file).context("Failed to write staged binary")?; + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(&dest_path, fs::Permissions::from_mode(0o755))?; + } + + return Ok(()); + } + } + + bail!("Binary '{bin_name}' not found in archive"); +} + +fn extract_from_zip(bytes: &[u8], bin_name: &str, dest_dir: &Path) -> Result<()> { + use std::io::Cursor; + + let cursor = Cursor::new(bytes); + let mut archive = zip::ZipArchive::new(cursor).context("Failed to read zip archive")?; + + for i in 0..archive.len() { + let mut file = archive.by_index(i).context("Failed to read zip entry")?; + let path = file.mangled_name(); + + if path.file_name().and_then(|n| n.to_str()) == Some(bin_name) { + let dest_path = dest_dir.join(bin_name); + let mut out = + fs::File::create(&dest_path).context("Failed to create staged binary file")?; + std::io::copy(&mut file, &mut out).context("Failed to write staged binary")?; + return Ok(()); + } + } + + bail!("Binary '{bin_name}' not found in zip archive"); +} + +const BACKUP_PREFIX: &str = "railway-v"; + +/// Parse a backup filename. +/// Handles both `railway-v{ver}` and `railway-v{ver}_{target}[.exe]` formats. +fn parse_backup_filename(entry: &fs::DirEntry) -> (String, Option) { + let raw = entry.file_name().to_string_lossy().into_owned(); + let stem = raw + .trim_start_matches(BACKUP_PREFIX) + .trim_end_matches(".exe"); + match stem.split_once('_') { + Some((ver, target)) => (ver.to_string(), Some(target.to_string())), + None => (stem.to_string(), None), + } +} + +fn list_backups(dir: &Path) -> Result> { + let mut entries: Vec<_> = fs::read_dir(dir)? + .filter_map(|e| e.ok()) + .filter(|e| e.file_name().to_string_lossy().starts_with(BACKUP_PREFIX)) + .collect(); + + // Sort by version (oldest first) so prune_backups can drop the leading entries. + entries.sort_by(|a, b| { + crate::util::compare_semver::compare_semver( + &parse_backup_filename(a).0, + &parse_backup_filename(b).0, + ) + }); + + Ok(entries) +} + +fn create_backup(source: &Path, destination: &Path) -> Result<()> { + if let Err(link_err) = fs::hard_link(source, destination) { + // hard_link fails if the backup already exists or across filesystems — + // fall back to copy, but fail closed if that also fails so we never + // replace the running binary without a rollback point. + fs::copy(source, destination) + .map(|_| ()) + .map_err(|copy_err| { + anyhow::anyhow!( + "Failed to back up current binary (hard link: {link_err}; copy: {copy_err})" + ) + })?; + } + + Ok(()) +} + +fn backup_current_binary_no_prune() -> Result<()> { + let current_exe = std::env::current_exe().context("Failed to get current exe path")?; + let current_version = env!("CARGO_PKG_VERSION"); + let target = detect_target_triple()?; + let dir = backups_dir()?; + fs::create_dir_all(&dir)?; + + let backup_name = if cfg!(target_os = "windows") { + format!("{BACKUP_PREFIX}{current_version}_{target}.exe") + } else { + format!("{BACKUP_PREFIX}{current_version}_{target}") + }; + let backup_path = dir.join(&backup_name); + + create_backup(¤t_exe, &backup_path).context("Failed to create rollback backup")?; + + Ok(()) +} + +fn backup_current_binary() -> Result<()> { + let target = detect_target_triple()?; + backup_current_binary_no_prune()?; + prune_backups(&backups_dir()?, 3, target)?; + Ok(()) +} + +fn prune_backups(dir: &Path, keep: usize, target: &str) -> Result<()> { + let entries: Vec<_> = list_backups(dir)? + .into_iter() + .filter(|entry| { + let (_, backup_target) = parse_backup_filename(entry); + match backup_target { + Some(backup_target) => backup_target == target, + // Backups created before target tracking was added are assumed + // to belong to the current machine's target, matching rollback(). + None => true, + } + }) + .collect(); + + if entries.len() <= keep { + return Ok(()); + } + + let to_remove = entries.len() - keep; + for entry in entries.into_iter().take(to_remove) { + let _ = fs::remove_file(entry.path()); + } + + Ok(()) +} + +/// Cleans up leftover `.old.exe` from a previous Windows binary replacement. +#[cfg(windows)] +fn clean_old_binary() { + if let Ok(exe) = std::env::current_exe() { + let old_path = exe.with_extension("old.exe"); + let _ = fs::remove_file(&old_path); + } +} + +/// Atomically replaces the binary at `target` with the binary at `source`. +/// On Unix: copies to a temp file in the same directory, then renames (atomic). +/// On Windows: renames running binary to .old, copies new one in, cleans up .old on next run. +fn replace_binary(source: &Path, target: &Path) -> Result<()> { + #[cfg(unix)] + { + let exe_dir = target.parent().context("Failed to get binary directory")?; + let pid = std::process::id(); + let tmp_path = exe_dir.join(format!(".railway-tmp-{pid}")); + + fs::copy(source, &tmp_path).context("Failed to copy new binary")?; + + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(&tmp_path, fs::Permissions::from_mode(0o755))?; + + super::rename_replacing(&tmp_path, target).context( + "Failed to replace binary. You may need to run with sudo or use `railway upgrade` manually.", + )?; + } + + #[cfg(windows)] + { + let old_path = target.with_extension("old.exe"); + let _ = fs::remove_file(&old_path); + fs::rename(target, &old_path).context("Failed to rename current binary")?; + if let Err(e) = fs::copy(source, target) { + let _ = fs::rename(&old_path, target); + bail!("Failed to install new binary: {e}"); + } + } + + #[cfg(not(any(unix, windows)))] + { + bail!("Self-update is not supported on this platform"); + } + + Ok(()) +} + +/// Applies a staged update by atomically replacing the current binary. +/// Returns Ok(version) on success. +fn apply_staged_update() -> Result { + let staged = StagedUpdate::read()?.context("No staged update found")?; + + // Verify the staged binary matches the current platform. + let current_target = detect_target_triple()?; + if staged.target != current_target { + StagedUpdate::clean()?; + bail!( + "Staged update is for {}, but this machine is {}", + staged.target, + current_target + ); + } + + let staged_binary = staged_update_dir()?.join(binary_name()); + if !staged_binary.exists() { + bail!("Staged binary not found"); + } + + backup_current_binary()?; + + let current_exe = std::env::current_exe().context("Failed to get current exe path")?; + replace_binary(&staged_binary, ¤t_exe)?; + + let version = staged.version.clone(); + StagedUpdate::clean()?; + + Ok(version) +} + +/// Reads and validates the staged update. Returns `Ok(staged)` when the +/// staged binary is safe to apply, or an `Err` describing why not. +/// Cleans up the staged directory when the update is stale, wrong-platform, +/// not-newer, or skipped. +fn validate_staged() -> Result { + let staged = StagedUpdate::read()?.context("No staged update found")?; + + if staged.is_stale() { + let _ = StagedUpdate::clean(); + bail!("Staged update is too old"); + } + + let current_target = detect_target_triple()?; + if staged.target != current_target { + let _ = StagedUpdate::clean(); + bail!( + "Staged update is for {}, but this machine is {current_target}", + staged.target + ); + } + + if !matches!( + crate::util::compare_semver::compare_semver(env!("CARGO_PKG_VERSION"), &staged.version), + std::cmp::Ordering::Less + ) { + let _ = StagedUpdate::clean(); + bail!("You are already on the latest version"); + } + + if let Ok(check) = crate::util::check_update::UpdateCheck::read() { + if check.skipped_version.as_deref() == Some(staged.version.as_str()) { + let _ = StagedUpdate::clean(); + bail!("v{} was previously rolled back", staged.version); + } + } + + if !staged_update_dir()?.join(binary_name()).exists() { + let _ = StagedUpdate::clean(); + bail!("Staged binary missing from disk"); + } + + Ok(staged) +} + +/// Try to apply a previously staged self-update. +/// Uses file locking to prevent concurrent CLI instances from racing. +/// Returns the applied version on success, `None` otherwise. +pub fn try_apply_staged() -> Option { + use fs2::FileExt; + + let lock_path = match update_lock_path() { + Ok(p) => p, + Err(_) => return None, + }; + + let lock_file = match std::fs::File::create(&lock_path) { + Ok(f) => f, + Err(_) => return None, + }; + + if lock_file.try_lock_exclusive().is_err() { + return None; + } + + // Validate after acquiring the lock so another process can't delete or + // replace the staged binary between validation and apply. + if validate_staged().is_err() { + return None; + } + + let result = match apply_staged_update() { + Ok(version) => { + crate::util::check_update::UpdateCheck::clear_after_update(); + + // Clean up the .old.exe left over from the previous binary + // replacement — only worth doing after a successful apply. + #[cfg(windows)] + clean_old_binary(); + + eprintln!( + "{} v{} (active on next run)", + "Auto-updated Railway CLI to".green().bold(), + version, + ); + Some(version) + } + Err(e) => { + if e.to_string().contains("Staged binary not found") { + let _ = StagedUpdate::clean(); + } + // Other errors kept for retry; STAGED_UPDATE_MAX_AGE_DAYS handles permanent failures. + None + } + }; + + drop(lock_file); + result +} + +pub async fn self_update_interactive() -> Result<()> { + // Try the network check first. If it fails and an update is already + // staged on disk, apply that instead of surfacing a network error. + let (latest_version, update_check_failed) = + match crate::util::check_update::check_update(true).await { + Ok(Some(v)) => (Some(v), false), + Ok(None) => (None, false), + Err(_) => { + // Network failure — fall through and try the staged update. + (None, true) + } + }; + + let lock_path = update_lock_path()?; + let busy_message = shell_update_busy_message(); + let lock_file = acquire_update_lock(&lock_path, false, &busy_message)?; + + if let Some(ref version) = latest_version { + println!("{} v{}...", "Downloading".green().bold(), version); + download_and_stage_inner(version, 120).await?; + } else { + match finalize_explicit_upgrade_fallback(validate_staged(), update_check_failed)? { + Some(staged) => { + println!("Applying previously downloaded v{}...", staged.version); + } + None => { + println!("{}", "Railway CLI is already up to date.".green()); + return Ok(()); + } + } + } + + let version = apply_staged_update()?; + + crate::util::check_update::UpdateCheck::clear_after_update(); + + drop(lock_file); + + println!("{} v{}", "Successfully updated to".green().bold(), version); + + Ok(()) +} + +fn finalize_explicit_upgrade_fallback( + staged: Result, + update_check_failed: bool, +) -> Result> { + match staged { + Ok(staged) => Ok(Some(staged)), + Err(_) if !update_check_failed => Ok(None), + Err(err) => Err(err).context("Update check failed and no valid staged update is available"), + } +} + +fn choose_rollback_candidate( + candidates: Vec<(String, std::path::PathBuf)>, + non_interactive: bool, +) -> Result<(String, std::path::PathBuf)> { + if candidates.len() == 1 { + return Ok(candidates.into_iter().next().unwrap()); + } + + if non_interactive { + return candidates + .into_iter() + .next() + .context("No rollback candidates found"); + } + + let labels: Vec = candidates.iter().map(|(v, _)| v.clone()).collect(); + let selected = inquire::Select::new("Select version to roll back to:", labels) + .prompt() + .context("Rollback cancelled")?; + candidates + .into_iter() + .find(|(v, _)| *v == selected) + .context("Selected rollback candidate was not found") +} + +pub fn rollback(non_interactive: bool) -> Result<()> { + // Acquire the update lock first so background auto-update processes cannot + // stage or apply while we are building the candidate list or prompting. + let lock_path = update_lock_path()?; + let busy_message = shell_update_busy_message(); + let lock_file = acquire_update_lock(&lock_path, false, &busy_message)?; + + let dir = backups_dir()?; + let current_target = detect_target_triple()?; + + // Back up the current binary so the rollback itself can be undone. + // Use the no-prune variant so candidates aren't removed before the user + // sees the picker. + backup_current_binary_no_prune()?; + + let entries = list_backups(&dir)?; + let current_version = env!("CARGO_PKG_VERSION"); + + // Collect (version_string, path) pairs, newest-first, excluding current. + // Backup filenames are either the old format "railway-v{ver}" or the new + // format "railway-v{ver}_{target}". Old-format backups (no target) are + // assumed to match the current target since they were created locally + // before target tracking was added. + let candidates: Vec<(String, std::path::PathBuf)> = entries + .iter() + .rev() + .filter_map(|e| { + let (ver, backup_target) = parse_backup_filename(e); + + if ver == current_version { + return None; + } + + // Filter out backups built for a different architecture. + if let Some(t) = backup_target { + if t != current_target { + return None; + } + } + + Some((ver, e.path())) + }) + .collect(); + + if candidates.is_empty() { + bail!( + "All backups match the current version (v{current_version}). Nothing to roll back to." + ); + } + + let (version, backup_path) = choose_rollback_candidate(candidates, non_interactive)?; + + println!("{} v{}...", "Rolling back to".yellow().bold(), version); + + let current_exe = std::env::current_exe().context("Failed to get current exe path")?; + replace_binary(&backup_path, ¤t_exe)?; + + // Clean staged updates so the rolled-back binary doesn't immediately re-apply. + let _ = StagedUpdate::clean(); + + // Record the current version as skipped so auto-update doesn't + // re-download and re-apply the version the user just rolled back from. + // Auto-update resumes once a newer release supersedes the skipped version. + crate::util::check_update::UpdateCheck::skip_version(current_version); + + // Prune after rollback succeeds so the candidate list wasn't reduced + // before the user picked. + let _ = prune_backups(&dir, 3, current_target); + + drop(lock_file); + + println!("{} v{}", "Rolled back to".green().bold(), version); + println!( + "Auto-updates will skip v{}. Run {} to disable all auto-updates.", + current_version, + "railway autoupdate disable".bold() + ); + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn prune_backups_removes_oldest() { + let dir = tempfile::tempdir().unwrap(); + + for i in 0..5u32 { + let path = dir.path().join(format!("railway-v1.{i}.0")); + fs::write(&path, format!("binary-{i}")).unwrap(); + } + + prune_backups(dir.path(), 3, "x86_64-unknown-linux-musl").unwrap(); + + let remaining = list_backups(dir.path()).unwrap(); + assert_eq!(remaining.len(), 3); + + let names: Vec<_> = remaining + .iter() + .map(|e| e.file_name().to_string_lossy().to_string()) + .collect(); + assert!(!names.contains(&"railway-v1.0.0".to_string())); + assert!(!names.contains(&"railway-v1.1.0".to_string())); + } + + #[test] + fn prune_backups_noop_when_fewer_than_keep() { + let dir = tempfile::tempdir().unwrap(); + + for i in 0..2 { + let path = dir.path().join(format!("railway-v1.{i}.0")); + fs::write(&path, "binary").unwrap(); + } + + prune_backups(dir.path(), 3, "x86_64-unknown-linux-musl").unwrap(); + assert_eq!(list_backups(dir.path()).unwrap().len(), 2); + } + + #[test] + fn list_backups_ignores_unrelated_files() { + let dir = tempfile::tempdir().unwrap(); + + fs::write(dir.path().join("railway-v1.0.0"), "binary").unwrap(); + fs::write(dir.path().join("railway-v2.0.0"), "binary").unwrap(); + fs::write(dir.path().join("unrelated.txt"), "text").unwrap(); + fs::write(dir.path().join("railway.conf"), "config").unwrap(); + + assert_eq!(list_backups(dir.path()).unwrap().len(), 2); + } + + #[test] + fn create_backup_fails_when_no_backup_can_be_created() { + let dir = tempfile::tempdir().unwrap(); + let source = dir.path().join("missing-source"); + let destination = dir.path().join("backup"); + + let err = create_backup(&source, &destination) + .unwrap_err() + .to_string(); + + assert!(err.contains("Failed to back up current binary")); + assert!(!destination.exists()); + } + + #[test] + fn non_blocking_update_lock_fails_fast_when_held() { + let dir = tempfile::tempdir().unwrap(); + let lock_path = dir.path().join("update.lock"); + let _first = acquire_update_lock(&lock_path, true, "should acquire").unwrap(); + + let err = acquire_update_lock(&lock_path, false, "busy") + .unwrap_err() + .to_string(); + + assert_eq!(err, "busy"); + } + + #[test] + fn explicit_upgrade_fallback_returns_success_when_already_up_to_date() { + let result = finalize_explicit_upgrade_fallback(Err(anyhow::anyhow!("no staged")), false); + + assert!(result.unwrap().is_none()); + } + + #[test] + fn explicit_upgrade_fallback_preserves_network_failure() { + let err = match finalize_explicit_upgrade_fallback(Err(anyhow::anyhow!("no staged")), true) + { + Ok(_) => panic!("expected network failure to propagate"), + Err(err) => err.to_string(), + }; + + assert!(err.contains("Update check failed")); + } + + #[test] + fn prune_backups_only_removes_entries_for_current_target() { + let dir = tempfile::tempdir().unwrap(); + + for version in ["1.0.0", "1.1.0"] { + let path = dir + .path() + .join(format!("railway-v{version}_x86_64-unknown-linux-musl")); + fs::write(&path, format!("linux-{version}")).unwrap(); + } + + for version in ["2.0.0", "2.1.0"] { + let path = dir + .path() + .join(format!("railway-v{version}_aarch64-apple-darwin")); + fs::write(&path, format!("mac-{version}")).unwrap(); + } + + prune_backups(dir.path(), 1, "x86_64-unknown-linux-musl").unwrap(); + + let names: Vec<_> = list_backups(dir.path()) + .unwrap() + .iter() + .map(|e| e.file_name().to_string_lossy().to_string()) + .collect(); + + assert!(!names.contains(&"railway-v1.0.0_x86_64-unknown-linux-musl".to_string())); + assert!(names.contains(&"railway-v1.1.0_x86_64-unknown-linux-musl".to_string())); + assert!(names.contains(&"railway-v2.0.0_aarch64-apple-darwin".to_string())); + assert!(names.contains(&"railway-v2.1.0_aarch64-apple-darwin".to_string())); + } + + #[test] + fn choose_rollback_candidate_prefers_newest_in_non_interactive_mode() { + let candidates = vec![ + ("2.0.0".to_string(), PathBuf::from("/tmp/railway-v2.0.0")), + ("1.9.0".to_string(), PathBuf::from("/tmp/railway-v1.9.0")), + ]; + + let (version, path) = choose_rollback_candidate(candidates, true).unwrap(); + + assert_eq!(version, "2.0.0"); + assert_eq!(path, PathBuf::from("/tmp/railway-v2.0.0")); + } +}