diff --git a/crates/bsk-cli/src/cli/update.rs b/crates/bsk-cli/src/cli/update.rs index d827a3e..962a4a8 100644 --- a/crates/bsk-cli/src/cli/update.rs +++ b/crates/bsk-cli/src/cli/update.rs @@ -5,6 +5,11 @@ use std::io::{Cursor, Read, Write}; use std::path::{Path, PathBuf}; use std::time::{Duration, SystemTime}; +#[cfg(windows)] +use std::os::windows::process::CommandExt; +#[cfg(windows)] +use std::process::{Command, Stdio}; + use anyhow::{Context, Result, bail}; use clap::Args; use flate2::read::GzDecoder; @@ -265,20 +270,33 @@ fn run(args: UpdateArgs, format: Format) -> Result<()> { InstallAction::Replaced => "replaced", InstallAction::Staged => "staged", }; + let (status, message) = match action { + InstallAction::Replaced => ( + "updated", + format!( + "updated bsk from {} to {}", + candidate.current, candidate.latest + ), + ), + InstallAction::Staged => ( + "staged", + format!( + "staged bsk {} for replacement after this command exits", + candidate.latest + ), + ), + }; render_report( format, &UpdateReport { - status: "updated", + status, current_version: candidate.current.to_string(), latest_version: Some(candidate.latest.to_string()), release_url: candidate.release_url, asset_url: Some(candidate.asset.url), install_action: Some(action_label), - message: format!( - "updated bsk from {} to {}", - candidate.current, candidate.latest - ), + message, }, ) } @@ -345,7 +363,7 @@ fn install_candidate_with_client( crate::daemon::start::run_stop().context("stop bsk daemon before update")?; } - let action = replace_binary_at_path(&target, &binary)?; + let action = replace_binary_for_update(&target, &binary, daemon_was_running)?; if daemon_was_running && matches!(action, InstallAction::Replaced) { crate::daemon::start::run_start(StartArgs::default()) @@ -649,9 +667,7 @@ fn render_report(format: Format, report: &UpdateReport) -> Result<()> { println!("release: {release_url}"); } if matches!(report.install_action, Some("staged")) { - println!( - "restart your terminal and run `bsk --version` to verify the staged update" - ); + println!("the detached update helper will apply the replacement after exit"); } } Format::Json => { @@ -672,13 +688,22 @@ pub fn extract_bsk_binary(archive_bytes: &[u8], kind: ArchiveKind) -> Result Result { + replace_binary_for_update(target, binary, false) +} + +fn replace_binary_for_update( + target: &Path, + binary: &[u8], + restart_daemon: bool, +) -> Result { #[cfg(windows)] { - stage_windows_replacement(target, binary) + stage_windows_replacement(target, binary, restart_daemon) } #[cfg(not(windows))] { + let _ = restart_daemon; replace_binary_atomically(target, binary) } } @@ -721,15 +746,35 @@ fn replace_binary_atomically(target: &Path, binary: &[u8]) -> Result Result { +fn stage_windows_replacement( + target: &Path, + binary: &[u8], + restart_daemon: bool, +) -> Result { let paths = staged_replacement_paths(target, std::process::id())?; std::fs::write(&paths.binary_path, binary) .with_context(|| format!("write {}", paths.binary_path.display()))?; std::fs::write( &paths.script_path, - windows_replacement_script(target, &paths.binary_path), + windows_replacement_script(target, &paths.binary_path, restart_daemon), ) .with_context(|| format!("write {}", paths.script_path.display()))?; + + const CREATE_NO_WINDOW: u32 = 0x0800_0000; + let command = format!("\"{}\"", paths.script_path.display()); + if let Err(err) = Command::new("cmd.exe") + .args(["/D", "/S", "/C"]) + .arg(command) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .creation_flags(CREATE_NO_WINDOW) + .spawn() + { + let _ = std::fs::remove_file(&paths.binary_path); + let _ = std::fs::remove_file(&paths.script_path); + return Err(err).context("launch detached Windows update helper"); + } Ok(InstallAction::Staged) } @@ -746,19 +791,26 @@ pub fn staged_replacement_paths(target: &Path, pid: u32) -> Result String { +#[cfg(any(windows, test))] +fn windows_replacement_script(target: &Path, staged_binary: &Path, restart_daemon: bool) -> String { + let target = target.display().to_string().replace('%', "%%"); + let staged_binary = staged_binary.display().to_string().replace('%', "%%"); + let restart = if restart_daemon { + format!("start \"\" /B \"{target}\" daemon start >nul 2>nul\r\n") + } else { + String::new() + }; format!( "@echo off\r\n\ setlocal\r\n\ :retry\r\n\ - move /Y \"{}\" \"{}\" >nul 2>nul\r\n\ + move /Y \"{staged_binary}\" \"{target}\" >nul 2>nul\r\n\ if errorlevel 1 (\r\n\ timeout /t 1 /nobreak >nul\r\n\ goto retry\r\n\ - )\r\n", - staged_binary.display(), - target.display() + )\r\n\ + {restart}\ + del /F /Q \"%~f0\" >nul 2>nul\r\n" ) } @@ -945,6 +997,32 @@ mod tests { ); } + #[test] + fn windows_replacement_script_retries_restarts_and_cleans_itself() { + let script = windows_replacement_script( + Path::new(r"C:\Program Files\bsk.exe"), + Path::new(r"C:\Program Files\bsk.exe.update-42"), + true, + ); + + assert!(script.contains(":retry")); + assert!(script.contains("move /Y")); + assert!(script.contains(r#"start "" /B "C:\Program Files\bsk.exe" daemon start"#)); + assert!(script.contains(r#"del /F /Q "%~f0""#)); + } + + #[test] + fn windows_replacement_script_omits_restart_when_daemon_was_not_running() { + let script = windows_replacement_script( + Path::new(r"C:\bsk.exe"), + Path::new(r"C:\bsk.exe.update-42"), + false, + ); + + assert!(!script.contains("daemon start")); + assert!(script.contains(r#"del /F /Q "%~f0""#)); + } + #[test] fn cache_freshness_uses_epoch_seconds() { let cache = UpdateCheckCache {