From b38b2930b167ba95ea87c7e22894bd81d1b5fa1e Mon Sep 17 00:00:00 2001 From: Alon Gubkin Date: Tue, 25 Aug 2026 08:02:07 -0700 Subject: [PATCH 1/5] feat: add CLI self-upgrade command --- Cargo.lock | 13 + Cargo.toml | 2 + crates/alien-cli/Cargo.toml | 4 +- crates/alien-cli/src/commands/mod.rs | 2 + crates/alien-cli/src/commands/upgrade.rs | 372 +++++++++++++++++++++++ crates/alien-cli/src/error.rs | 13 + crates/alien-cli/src/lib.rs | 26 +- packages/alien-cli-npm/bin/alien.js | 2 +- 8 files changed, 429 insertions(+), 5 deletions(-) create mode 100644 crates/alien-cli/src/commands/upgrade.rs 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..9355fd5f1 --- /dev/null +++ b/crates/alien-cli/src/commands/upgrade.rs @@ -0,0 +1,372 @@ +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).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) -> 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 stable <= current && !args.force { + 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()), + })?; + } + staged_file + .flush() + .await + .into_alien_error() + .context(ErrorData::UpgradeFailed { + message: format!("Could not flush the staged CLI at {}", staged_exe.display()), + })?; + let actual_checksum = hex::encode(hasher.finalize()); + verify_checksum_value(&actual_checksum, &expected_checksum)?; + make_executable(&staged_exe)?; + 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(), + })?; + + println!("Alien was upgraded successfully to v{stable}."); + 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 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 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 From 2a479ce12ecbd62ac4ac04b11ffca16c01b0a2fe Mon Sep 17 00:00:00 2001 From: Alon Gubkin Date: Tue, 25 Aug 2026 12:47:26 -0700 Subject: [PATCH 2/5] fix: prevent forced CLI downgrades --- crates/alien-cli/src/commands/upgrade.rs | 28 ++++++++++++++++++++---- 1 file changed, 24 insertions(+), 4 deletions(-) diff --git a/crates/alien-cli/src/commands/upgrade.rs b/crates/alien-cli/src/commands/upgrade.rs index 9355fd5f1..4cd8c7f5e 100644 --- a/crates/alien-cli/src/commands/upgrade.rs +++ b/crates/alien-cli/src/commands/upgrade.rs @@ -130,8 +130,14 @@ async fn upgrade_standalone(args: &UpgradeArgs) -> Result<()> { ), })?; - if stable <= current && !args.force { - println!("Alien is already up to date (v{current})."); + 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(()); } @@ -190,11 +196,14 @@ async fn upgrade_standalone(args: &UpgradeArgs) -> Result<()> { })?; } staged_file - .flush() + .sync_all() .await .into_alien_error() .context(ErrorData::UpgradeFailed { - message: format!("Could not flush the staged CLI at {}", staged_exe.display()), + 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)?; @@ -224,6 +233,10 @@ fn parse_release_version(value: &str) -> Result { }) } +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!( @@ -362,6 +375,13 @@ mod tests { 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!( From 0069b4be81f4a0c5dfd66c1acd1ee893e02f5307 Mon Sep 17 00:00:00 2001 From: Alon Gubkin Date: Tue, 25 Aug 2026 13:08:37 -0700 Subject: [PATCH 3/5] fix: synchronize standalone CLI replacement --- crates/alien-cli/src/commands/upgrade.rs | 33 +++++++++++++++++++++--- 1 file changed, 30 insertions(+), 3 deletions(-) diff --git a/crates/alien-cli/src/commands/upgrade.rs b/crates/alien-cli/src/commands/upgrade.rs index 4cd8c7f5e..fb88070d4 100644 --- a/crates/alien-cli/src/commands/upgrade.rs +++ b/crates/alien-cli/src/commands/upgrade.rs @@ -50,7 +50,7 @@ pub async fn upgrade_task(args: UpgradeArgs) -> Result<()> { InstallMethod::Homebrew => { upgrade_with_package_manager(&args, "brew", &["upgrade", "alienplatform/tap/alien"]) } - InstallMethod::Standalone => upgrade_standalone(&args).await, + InstallMethod::Standalone => upgrade_standalone(&args, ¤t_exe).await, } } @@ -96,7 +96,7 @@ fn upgrade_with_package_manager( Ok(()) } -async fn upgrade_standalone(args: &UpgradeArgs) -> Result<()> { +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(); @@ -195,6 +195,7 @@ async fn upgrade_standalone(args: &UpgradeArgs) -> Result<()> { message: format!("Could not write the staged CLI at {}", staged_exe.display()), })?; } + make_executable(&staged_exe)?; staged_file .sync_all() .await @@ -207,7 +208,6 @@ async fn upgrade_standalone(args: &UpgradeArgs) -> Result<()> { })?; let actual_checksum = hex::encode(hasher.finalize()); verify_checksum_value(&actual_checksum, &expected_checksum)?; - make_executable(&staged_exe)?; validate_download(&staged_exe, &stable)?; self_replace::self_replace(&staged_exe) .into_alien_error() @@ -215,11 +215,38 @@ async fn upgrade_standalone(args: &UpgradeArgs) -> Result<()> { message: "Could not replace the current Alien executable; check that it is writable" .to_string(), })?; + sync_replacement_directory(current_exe)?; println!("Alien was upgraded successfully to v{stable}."); Ok(()) } +#[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 { From 1aabcf6732a0c9d64763bd7d3d97c22793863f25 Mon Sep 17 00:00:00 2001 From: Alon Gubkin Date: Tue, 25 Aug 2026 13:13:40 -0700 Subject: [PATCH 4/5] fix: synchronize installed CLI executable --- crates/alien-cli/src/commands/upgrade.rs | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/crates/alien-cli/src/commands/upgrade.rs b/crates/alien-cli/src/commands/upgrade.rs index fb88070d4..206867a7a 100644 --- a/crates/alien-cli/src/commands/upgrade.rs +++ b/crates/alien-cli/src/commands/upgrade.rs @@ -215,12 +215,25 @@ async fn upgrade_standalone(args: &UpgradeArgs, current_exe: &Path) -> Result<() message: "Could not replace the current Alien executable; check that it is writable" .to_string(), })?; - sync_replacement_directory(current_exe)?; + sync_replacement(current_exe)?; 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(|| { From 5b2809e0da7732e15d60289702e7e3ab4dd93988 Mon Sep 17 00:00:00 2001 From: Alon Gubkin Date: Tue, 25 Aug 2026 13:18:36 -0700 Subject: [PATCH 5/5] fix: report completed CLI replacements accurately --- crates/alien-cli/src/commands/upgrade.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/crates/alien-cli/src/commands/upgrade.rs b/crates/alien-cli/src/commands/upgrade.rs index 206867a7a..658d395f2 100644 --- a/crates/alien-cli/src/commands/upgrade.rs +++ b/crates/alien-cli/src/commands/upgrade.rs @@ -215,7 +215,11 @@ async fn upgrade_standalone(args: &UpgradeArgs, current_exe: &Path) -> Result<() message: "Could not replace the current Alien executable; check that it is writable" .to_string(), })?; - sync_replacement(current_exe)?; + 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(())