diff --git a/Cargo.lock b/Cargo.lock index 9e1677cbd..5f225b209 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -374,6 +374,8 @@ dependencies = [ "rand_distr", "reqwest 0.12.28", "rustls 0.23.43", + "self-replace", + "semver", "serde", "serde_json", "serde_yaml", @@ -8815,6 +8817,17 @@ dependencies = [ "libc", ] +[[package]] +name = "self-replace" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03ec815b5eab420ab893f63393878d89c90fdd94c0bcc44c07abb8ad95552fb7" +dependencies = [ + "fastrand", + "tempfile", + "windows-sys 0.52.0", +] + [[package]] name = "semver" version = "1.0.28" diff --git a/Cargo.toml b/Cargo.toml index 1f2d00327..fd6ba0ffe 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -160,6 +160,8 @@ rand = "0.9" rand_distr = "0.5" regex = "1" reqwest = { version = "0.12.2", default-features = false } +semver = "1" +self-replace = "1.5" rkyv = "0.8.0" rstest = "0.26.0" schemars = { version = "0.8", features = ["indexmap2"] } diff --git a/crates/alien-cli/Cargo.toml b/crates/alien-cli/Cargo.toml index bec00fa89..0b2067e03 100644 --- a/crates/alien-cli/Cargo.toml +++ b/crates/alien-cli/Cargo.toml @@ -76,7 +76,9 @@ oauth2 = { workspace = true, optional = true } portpicker = { version = "0.1" } rand = { workspace = true } rand_distr = { workspace = true } -reqwest = { workspace = true, default-features = false, features = ["json", "rustls-tls-webpki-roots"] } +reqwest = { workspace = true, default-features = false, features = ["json", "rustls-tls-webpki-roots", "stream"] } +semver = { workspace = true } +self-replace = { workspace = true } rustls = { version = "0.23", default-features = false, features = ["ring"] } zip = { workspace = true } axum = { workspace = true, features = ["tokio", "http2", "query"] } diff --git a/crates/alien-cli/src/commands/mod.rs b/crates/alien-cli/src/commands/mod.rs index 6a3145dc1..414d3b3f9 100644 --- a/crates/alien-cli/src/commands/mod.rs +++ b/crates/alien-cli/src/commands/mod.rs @@ -22,6 +22,7 @@ pub mod release; pub mod releases; pub mod render; pub mod status; +pub mod upgrade; #[cfg(feature = "platform")] pub mod usage; pub mod vault; @@ -71,6 +72,7 @@ pub use release::{release_command, ReleaseArgs}; pub use releases::{releases_task, ReleasesArgs}; pub use render::{render_task, RenderArgs}; pub use status::{status_task, StatusArgs}; +pub use upgrade::{upgrade_task, UpgradeArgs}; #[cfg(feature = "platform")] pub use usage::{usage_task, UsageArgs}; pub use vault::{vault_remote_task, vault_task, VaultArgs, VaultRemoteArgs}; diff --git a/crates/alien-cli/src/commands/upgrade.rs b/crates/alien-cli/src/commands/upgrade.rs new file mode 100644 index 000000000..658d395f2 --- /dev/null +++ b/crates/alien-cli/src/commands/upgrade.rs @@ -0,0 +1,436 @@ +use crate::error::{ErrorData, Result}; +use alien_error::{Context, IntoAlienError}; +use clap::Parser; +use futures::StreamExt; +use reqwest::header::HeaderMap; +use semver::Version; +use sha2::{Digest, Sha256}; +use std::env; +use std::fs; +use std::path::Path; +use std::process::Command; +use tokio::io::AsyncWriteExt; + +const DEFAULT_RELEASES_URL: &str = "https://releases.alien.dev"; +const INSTALL_METHOD_ENV: &str = "ALIEN_INSTALL_METHOD"; +const RELEASES_URL_ENV: &str = "ALIEN_RELEASES_URL"; + +#[derive(Parser, Debug, Clone)] +pub struct UpgradeArgs { + /// Check what would be installed without changing anything + #[arg(long)] + pub dry_run: bool, + + /// Reinstall even when the stable version matches the current version + #[arg(long)] + pub force: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum InstallMethod { + Npm, + Homebrew, + Standalone, +} + +pub async fn upgrade_task(args: UpgradeArgs) -> Result<()> { + let current_exe = env::current_exe() + .into_alien_error() + .context(ErrorData::UpgradeFailed { + message: "Could not locate the running Alien executable".to_string(), + })?; + let method = detect_install_method(¤t_exe); + + match method { + InstallMethod::Npm => upgrade_with_package_manager( + &args, + "npm", + &["install", "-g", "@alienplatform/cli@latest"], + ), + InstallMethod::Homebrew => { + upgrade_with_package_manager(&args, "brew", &["upgrade", "alienplatform/tap/alien"]) + } + InstallMethod::Standalone => upgrade_standalone(&args, ¤t_exe).await, + } +} + +fn detect_install_method(current_exe: &Path) -> InstallMethod { + if env::var(INSTALL_METHOD_ENV).as_deref() == Ok("npm") { + return InstallMethod::Npm; + } + + let path = current_exe.to_string_lossy().replace('\\', "/"); + if path.contains("/Cellar/alien/") || path.contains("/Caskroom/alien/") { + InstallMethod::Homebrew + } else { + InstallMethod::Standalone + } +} + +fn upgrade_with_package_manager( + args: &UpgradeArgs, + program: &str, + command_args: &[&str], +) -> Result<()> { + let rendered = format!("{program} {}", command_args.join(" ")); + if args.dry_run { + println!("Would upgrade Alien with `{rendered}`"); + return Ok(()); + } + + println!("Upgrading Alien with `{rendered}`..."); + let status = Command::new(program) + .args(command_args) + .status() + .into_alien_error() + .context(ErrorData::UpgradeFailed { + message: format!("Could not run `{rendered}`"), + })?; + if !status.success() { + return Err(alien_error::AlienError::new(ErrorData::UpgradeFailed { + message: format!("`{rendered}` exited with {status}"), + })); + } + + println!("Alien was upgraded successfully. Restart it to use the new version."); + Ok(()) +} + +async fn upgrade_standalone(args: &UpgradeArgs, current_exe: &Path) -> Result<()> { + let releases_url = + env::var(RELEASES_URL_ENV).unwrap_or_else(|_| DEFAULT_RELEASES_URL.to_string()); + let client = reqwest::Client::new(); + let stable_url = format!("{releases_url}/channels/stable"); + let stable = client + .get(&stable_url) + .send() + .await + .into_alien_error() + .context(ErrorData::UpgradeFailed { + message: format!("Could not fetch the stable channel from {stable_url}"), + })? + .error_for_status() + .into_alien_error() + .context(ErrorData::UpgradeFailed { + message: format!("The stable channel request failed: {stable_url}"), + })? + .text() + .await + .into_alien_error() + .context(ErrorData::UpgradeFailed { + message: format!("Could not read the stable channel response from {stable_url}"), + })?; + let stable = parse_release_version(stable.trim())?; + let current = Version::parse(env!("CARGO_PKG_VERSION")) + .into_alien_error() + .context(ErrorData::UpgradeFailed { + message: format!( + "The current CLI version is invalid: {}", + env!("CARGO_PKG_VERSION") + ), + })?; + + if !should_install(&stable, ¤t, args.force) { + if stable < current { + println!( + "Alien v{current} is newer than the stable release (v{stable}); leaving it unchanged." + ); + } else { + println!("Alien is already up to date (v{current})."); + } + return Ok(()); + } + + let artifact_url = artifact_url(&releases_url, &stable)?; + if args.dry_run { + println!("Would upgrade Alien from v{current} to v{stable}"); + println!(" {artifact_url}"); + return Ok(()); + } + + println!("Upgrading Alien from v{current} to v{stable}..."); + let response = client + .get(&artifact_url) + .send() + .await + .into_alien_error() + .context(ErrorData::UpgradeFailed { + message: format!("Could not download {artifact_url}"), + })? + .error_for_status() + .into_alien_error() + .context(ErrorData::UpgradeFailed { + message: format!("The release download failed: {artifact_url}"), + })?; + let expected_checksum = checksum_header(response.headers())?; + let temp_dir = tempfile::Builder::new() + .prefix("alien-upgrade-") + .tempdir() + .into_alien_error() + .context(ErrorData::UpgradeFailed { + message: "Could not create a temporary upgrade directory".to_string(), + })?; + let staged_exe = temp_dir.path().join(executable_name()); + let mut staged_file = tokio::fs::File::create(&staged_exe) + .await + .into_alien_error() + .context(ErrorData::UpgradeFailed { + message: format!( + "Could not create the staged CLI at {}", + staged_exe.display() + ), + })?; + let mut hasher = Sha256::new(); + let mut stream = response.bytes_stream(); + while let Some(chunk) = stream.next().await { + let chunk = chunk.into_alien_error().context(ErrorData::UpgradeFailed { + message: format!("Could not read the release download from {artifact_url}"), + })?; + hasher.update(&chunk); + staged_file + .write_all(&chunk) + .await + .into_alien_error() + .context(ErrorData::UpgradeFailed { + message: format!("Could not write the staged CLI at {}", staged_exe.display()), + })?; + } + make_executable(&staged_exe)?; + staged_file + .sync_all() + .await + .into_alien_error() + .context(ErrorData::UpgradeFailed { + message: format!( + "Could not synchronize the staged CLI at {}", + staged_exe.display() + ), + })?; + let actual_checksum = hex::encode(hasher.finalize()); + verify_checksum_value(&actual_checksum, &expected_checksum)?; + validate_download(&staged_exe, &stable)?; + self_replace::self_replace(&staged_exe) + .into_alien_error() + .context(ErrorData::UpgradeFailed { + message: "Could not replace the current Alien executable; check that it is writable" + .to_string(), + })?; + if let Err(error) = sync_replacement(current_exe) { + eprintln!( + "Warning: Alien was replaced, but the change could not be fully synchronized to disk: {error}" + ); + } + + println!("Alien was upgraded successfully to v{stable}."); + Ok(()) +} + +fn sync_replacement(current_exe: &Path) -> Result<()> { + fs::File::open(current_exe) + .and_then(|file| file.sync_all()) + .into_alien_error() + .context(ErrorData::UpgradeFailed { + message: format!( + "Alien was replaced, but the installed executable could not be synchronized: {}", + current_exe.display() + ), + })?; + sync_replacement_directory(current_exe) +} + +#[cfg(unix)] +fn sync_replacement_directory(current_exe: &Path) -> Result<()> { + let directory = current_exe.parent().ok_or_else(|| { + alien_error::AlienError::new(ErrorData::UpgradeFailed { + message: format!( + "Could not locate the installation directory for {}", + current_exe.display() + ), + }) + })?; + fs::File::open(directory) + .and_then(|file| file.sync_all()) + .into_alien_error() + .context(ErrorData::UpgradeFailed { + message: format!( + "Alien was replaced, but its installation directory could not be synchronized: {}", + directory.display() + ), + }) +} + +#[cfg(not(unix))] +fn sync_replacement_directory(_current_exe: &Path) -> Result<()> { + Ok(()) +} + +fn parse_release_version(value: &str) -> Result { + let version = value.strip_prefix('v').ok_or_else(|| { + alien_error::AlienError::new(ErrorData::UpgradeFailed { + message: format!("The stable channel returned an invalid version: {value}"), + }) + })?; + Version::parse(version) + .into_alien_error() + .context(ErrorData::UpgradeFailed { + message: format!("The stable channel returned an invalid version: {value}"), + }) +} + +fn should_install(stable: &Version, current: &Version, force: bool) -> bool { + stable > current || (stable == current && force) +} + +fn artifact_url(releases_url: &str, version: &Version) -> Result { + let (os, arch) = platform()?; + Ok(format!( + "{releases_url}/alien/v{version}/{os}-{arch}/{}", + executable_name() + )) +} + +fn platform() -> Result<(&'static str, &'static str)> { + let os = match env::consts::OS { + "linux" => "linux", + "macos" => "darwin", + "windows" => "windows", + other => return unsupported_platform(other, env::consts::ARCH), + }; + let arch = match env::consts::ARCH { + "x86_64" => "x86_64", + "aarch64" => "aarch64", + other => return unsupported_platform(os, other), + }; + if os == "darwin" && arch == "x86_64" { + return unsupported_platform(os, arch); + } + if os == "windows" && arch != "x86_64" { + return unsupported_platform(os, arch); + } + Ok((os, arch)) +} + +fn unsupported_platform(os: &str, arch: &str) -> Result { + Err(alien_error::AlienError::new(ErrorData::UpgradeFailed { + message: format!("No Alien CLI release is published for {os}-{arch}"), + })) +} + +fn executable_name() -> &'static str { + if cfg!(windows) { + "alien.exe" + } else { + "alien" + } +} + +fn checksum_header(headers: &HeaderMap) -> Result { + headers + .get("x-amz-meta-sha256") + .and_then(|value| value.to_str().ok()) + .filter(|value| value.len() == 64 && value.bytes().all(|byte| byte.is_ascii_hexdigit())) + .map(str::to_owned) + .ok_or_else(|| { + alien_error::AlienError::new(ErrorData::UpgradeFailed { + message: "The release download did not include a valid SHA-256 checksum" + .to_string(), + }) + }) +} + +#[cfg(test)] +fn verify_checksum(bytes: &[u8], expected: &str) -> Result<()> { + let actual = hex::encode(Sha256::digest(bytes)); + verify_checksum_value(&actual, expected) +} + +fn verify_checksum_value(actual: &str, expected: &str) -> Result<()> { + if actual.eq_ignore_ascii_case(expected) { + Ok(()) + } else { + Err(alien_error::AlienError::new(ErrorData::UpgradeFailed { + message: format!("Release checksum mismatch: expected {expected}, got {actual}"), + })) + } +} + +fn validate_download(path: &Path, expected: &Version) -> Result<()> { + let output = Command::new(path) + .arg("--version") + .output() + .into_alien_error() + .context(ErrorData::UpgradeFailed { + message: format!("Could not run the downloaded CLI at {}", path.display()), + })?; + let stdout = String::from_utf8_lossy(&output.stdout); + let valid = output.status.success() + && stdout + .split_whitespace() + .last() + .and_then(|value| value.strip_prefix('v').or(Some(value))) + .and_then(|value| Version::parse(value).ok()) + .as_ref() + == Some(expected); + if valid { + Ok(()) + } else { + Err(alien_error::AlienError::new(ErrorData::UpgradeFailed { + message: format!( + "The downloaded CLI failed validation for v{expected}: {}", + stdout.trim() + ), + })) + } +} + +#[cfg(unix)] +fn make_executable(path: &Path) -> Result<()> { + use std::os::unix::fs::PermissionsExt; + + fs::set_permissions(path, fs::Permissions::from_mode(0o755)) + .into_alien_error() + .context(ErrorData::UpgradeFailed { + message: format!("Could not make {} executable", path.display()), + }) +} + +#[cfg(windows)] +fn make_executable(_path: &Path) -> Result<()> { + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn stable_channel_requires_v_prefixed_semver() { + assert_eq!( + parse_release_version("v3.3.18").unwrap(), + Version::new(3, 3, 18) + ); + assert!(parse_release_version("latest").is_err()); + } + + #[test] + fn checksum_verification_rejects_modified_download() { + let checksum = hex::encode(Sha256::digest(b"release")); + assert!(verify_checksum(b"release", &checksum).is_ok()); + assert!(verify_checksum(b"modified", &checksum).is_err()); + } + + #[test] + fn force_reinstalls_current_version_without_downgrading() { + let current = Version::new(3, 3, 18); + assert!(should_install(¤t, ¤t, true)); + assert!(!should_install(&Version::new(3, 3, 17), ¤t, true)); + } + + #[test] + fn homebrew_install_is_detected_from_cellar_path() { + assert_eq!( + detect_install_method(Path::new("/opt/homebrew/Cellar/alien/3.3.18/bin/alien")), + InstallMethod::Homebrew + ); + } +} diff --git a/crates/alien-cli/src/error.rs b/crates/alien-cli/src/error.rs index bc0a3265c..a47c2e715 100644 --- a/crates/alien-cli/src/error.rs +++ b/crates/alien-cli/src/error.rs @@ -307,6 +307,19 @@ pub enum ErrorData { url: Option, }, + /// CLI upgrade failed. + #[error( + code = "UPGRADE_FAILED", + message = "Upgrade failed: {message}", + retryable = "inherit", + internal = "inherit", + human = "transparent" + )] + UpgradeFailed { + /// Description of the upgrade failure + message: String, + }, + /// Generic error for uncommon cases. #[error( code = "GENERIC_ERROR", diff --git a/crates/alien-cli/src/lib.rs b/crates/alien-cli/src/lib.rs index 9b8a27c74..330d6043e 100644 --- a/crates/alien-cli/src/lib.rs +++ b/crates/alien-cli/src/lib.rs @@ -41,9 +41,10 @@ use crate::commands::{ ensure_server_running_for_dev_session, ensure_server_running_with_env, fetch_all_dev_deployment_live_states, init_task, logs_task, onboard_task, prepare_dev_session_deployment, release_command, releases_task, render_task, status_task, - vault_remote_task, vault_task, whoami_task, write_dev_status, BuildArgs, BuildSubcommand, - CliEnvVar, CommandsArgs, DebugArgs, DeployArgs, DeploymentsArgs, DestroyArgs, InitArgs, - LogsArgs, OnboardArgs, ReleaseArgs, ReleasesArgs, RenderArgs, StatusArgs, WhoamiArgs, + upgrade_task, vault_remote_task, vault_task, whoami_task, write_dev_status, BuildArgs, + BuildSubcommand, CliEnvVar, CommandsArgs, DebugArgs, DeployArgs, DeploymentsArgs, DestroyArgs, + InitArgs, LogsArgs, OnboardArgs, ReleaseArgs, ReleasesArgs, RenderArgs, StatusArgs, + UpgradeArgs, WhoamiArgs, }; use crate::error::{ErrorData, Result}; use crate::execution_context::ExecutionMode; @@ -181,6 +182,9 @@ pub enum Commands { Dev(DevCommand), /// Show current authenticated user information Whoami(WhoamiArgs), + /// Upgrade the Alien CLI to the latest stable version + #[command(visible_alias = "update")] + Upgrade(UpgradeArgs), #[cfg(feature = "platform")] #[command(flatten)] @@ -553,6 +557,16 @@ mod tests { .expect("dev release --version should parse"); } + #[test] + fn update_alias_parses_as_upgrade() { + let cli = Cli::try_parse_from(["alien", "update", "--dry-run"]) + .expect("update alias should parse"); + assert!(matches!( + cli.command, + Some(Commands::Upgrade(UpgradeArgs { dry_run: true, .. })) + )); + } + #[cfg(feature = "platform")] #[test] fn packages_list_flags_parse() { @@ -1516,6 +1530,11 @@ pub async fn run_cli(cli: Cli) -> Result<()> { return render_task(args).await; } + // Upgrades do not need a project or authentication context. + if let Some(Commands::Upgrade(args)) = cli.command { + return upgrade_task(args).await; + } + // Handle dev command early — it creates its own execution context. if let Some(Commands::Dev(dev_cmd)) = cli.command { return handle_dev_command(dev_cmd).await; @@ -1576,6 +1595,7 @@ pub async fn run_cli(cli: Cli) -> Result<()> { Some(Commands::Init(_)) => unreachable!("handled before ctx resolution"), Some(Commands::Serve(_)) => unreachable!("handled before ctx resolution"), Some(Commands::Render(_)) => unreachable!("handled before ctx resolution"), + Some(Commands::Upgrade(_)) => unreachable!("handled before ctx resolution"), Some(Commands::Build(args)) => build_command(args).await?, Some(Commands::Release(args)) => release_command(args, ctx).await?, Some(Commands::Onboard(args)) => onboard_task(args, ctx).await?, diff --git a/packages/alien-cli-npm/bin/alien.js b/packages/alien-cli-npm/bin/alien.js index e61ffe941..ae7057f89 100644 --- a/packages/alien-cli-npm/bin/alien.js +++ b/packages/alien-cli-npm/bin/alien.js @@ -58,7 +58,7 @@ if (!binPath) { // Spawn the binary, forwarding all arguments and stdio const child = spawn(binPath, process.argv.slice(2), { stdio: "inherit", - env: process.env, + env: { ...process.env, ALIEN_INSTALL_METHOD: "npm" }, }) // Forward signals to the child process