diff --git a/CHANGELOG.md b/CHANGELOG.md index 7422c991..3f011096 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,24 @@ have to work with?" — and the answer is now closer to "everything you'd reach for from a shell, including the document formats the real world uses." +### Fixed + +- **`deepseek update` now refreshes the companion TUI binary + alongside the dispatcher** (harvested from PR #1492 by + **@NorethSea**). Closes the documented two-binary footgun: + `~/.cargo/bin/deepseek` would update to the latest dispatcher, + but `~/.cargo/bin/deepseek-tui` would stay at the previously + installed version, so users saw the dispatcher report a new + release while the TUI runtime they actually interacted with + reported the old version. Most painful for Volta-managed npm + installs and any maintainer flow that calls `update` instead of + re-running both `cargo install --path crates/{cli,tui}`. The + updater now enumerates colocated binaries up front, downloads + and verifies every release asset before replacing anything, + then swaps the sibling first and the running dispatcher last so + a partial network failure cannot leave the launcher updated + while the TUI remains stale. + ### Changed - **`read_file` now extracts PDFs in pure Rust by default — no diff --git a/crates/cli/src/update.rs b/crates/cli/src/update.rs index 08769efe..57c20653 100644 --- a/crates/cli/src/update.rs +++ b/crates/cli/src/update.rs @@ -6,7 +6,7 @@ //! replaces the currently running binary. use std::collections::HashMap; -use std::path::Path; +use std::path::{Path, PathBuf}; use anyhow::{Context, Result, bail}; use std::io::Write; @@ -19,47 +19,25 @@ const UPDATE_USER_AGENT: &str = "deepseek-tui-updater"; pub fn run_update() -> Result<()> { let current_exe = std::env::current_exe().context("failed to determine current executable path")?; + let targets = update_targets_for_exe(¤t_exe); println!("Checking for updates..."); println!("Current binary: {}", current_exe.display()); - let binary_name = - release_asset_stem_for(¤t_exe, std::env::consts::OS, std::env::consts::ARCH); - // Step 1: Fetch latest release metadata let release = fetch_latest_release()?; let latest_tag = &release.tag_name; println!("Latest release: {latest_tag}"); - // Step 2: Find the matching asset - let asset = select_platform_asset(&release, &binary_name).with_context(|| { - format!( - "no asset found for platform {binary_name} in release {latest_tag}. \ - Available assets: {}", - release - .assets - .iter() - .map(|a| a.name.as_str()) - .collect::>() - .join(", ") - ) - })?; - - println!("Downloading {}...", asset.name); - - // Step 3: Download the asset - let bytes = download_url(&asset.browser_download_url) - .with_context(|| format!("failed to download {}", asset.name))?; - - // Step 4: Download the aggregated SHA256 checksum manifest if available - let expected_hash = match select_checksum_manifest_asset(&release) { + // Step 2: Download the aggregated SHA256 checksum manifest if available + let checksum_manifest = match select_checksum_manifest_asset(&release) { Some(checksum_asset) => { println!("Downloading {}...", checksum_asset.name); let checksum_bytes = download_url(&checksum_asset.browser_download_url) .with_context(|| format!("failed to download {}", checksum_asset.name))?; let checksum_text = std::str::from_utf8(&checksum_bytes) .with_context(|| format!("{} is not valid UTF-8", checksum_asset.name))?; - Some(expected_sha256_from_manifest(checksum_text, &asset.name)?) + Some(parse_checksum_manifest(checksum_text)?) } None => { println!(" (no SHA256 checksum manifest found; skipping verification)"); @@ -67,24 +45,62 @@ pub fn run_update() -> Result<()> { } }; - // Step 5: Verify checksum if available - if let Some(expected) = &expected_hash { - let actual = sha256_hex(&bytes); - if !actual.eq_ignore_ascii_case(expected) { - bail!("SHA256 mismatch!\n expected: {expected}\n actual: {actual}"); + // Step 3: Download and verify every colocated binary in the install. + let mut downloads = Vec::new(); + for target in &targets { + let asset = select_platform_asset(&release, &target.asset_stem).with_context(|| { + format!( + "no asset found for platform {} in release {latest_tag}. \ + Available assets: {}", + target.asset_stem, + release + .assets + .iter() + .map(|a| a.name.as_str()) + .collect::>() + .join(", ") + ) + })?; + + println!("Downloading {}...", asset.name); + let bytes = download_url(&asset.browser_download_url) + .with_context(|| format!("failed to download {}", asset.name))?; + + if let Some(checksums) = &checksum_manifest { + let expected = checksums + .get(&asset.name) + .with_context(|| format!("checksum manifest is missing {}", asset.name))?; + let actual = sha256_hex(&bytes); + if !actual.eq_ignore_ascii_case(expected) { + bail!( + "SHA256 mismatch for {}!\n expected: {expected}\n actual: {actual}", + asset.name + ); + } } + + downloads.push((target.path.clone(), asset.name.clone(), bytes)); + } + + if checksum_manifest.is_some() { println!("SHA256 checksum verified."); } - // Step 6: Replace the current binary atomically - replace_binary(¤t_exe, &bytes)?; + // Step 4: Replace binaries atomically after all downloads verify. + for (path, _, bytes) in downloads.iter().rev() { + replace_binary(path, bytes)?; + } println!( "\nāœ… Successfully updated to {latest_tag}!\n\ - New binary: {}\n\ + Updated binaries:\n{}\n\ \n\ Restart the application to use the new version.", - current_exe.display() + downloads + .iter() + .map(|(path, asset, _)| format!(" - {} ({asset})", path.display())) + .collect::>() + .join("\n") ); Ok(()) @@ -110,12 +126,62 @@ pub(crate) fn binary_prefix_for_exe(current_exe: &Path) -> &'static str { } } -pub(crate) fn release_asset_stem_for(current_exe: &Path, os: &str, rust_arch: &str) -> String { - let prefix = binary_prefix_for_exe(current_exe); +fn sibling_prefix_for(prefix: &str) -> &'static str { + if prefix == "deepseek-tui" { + "deepseek" + } else { + "deepseek-tui" + } +} + +fn sibling_binary_path(current_exe: &Path, sibling_prefix: &str) -> PathBuf { + current_exe.with_file_name(format!("{sibling_prefix}{}", std::env::consts::EXE_SUFFIX)) +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct UpdateTarget { + path: PathBuf, + asset_stem: String, +} + +fn update_targets_for_exe(current_exe: &Path) -> Vec { + let current_prefix = binary_prefix_for_exe(current_exe); + let mut targets = vec![UpdateTarget { + path: current_exe.to_path_buf(), + asset_stem: release_asset_stem_for_prefix( + current_prefix, + std::env::consts::OS, + std::env::consts::ARCH, + ), + }]; + + let sibling_prefix = sibling_prefix_for(current_prefix); + let sibling = sibling_binary_path(current_exe, sibling_prefix); + if sibling.exists() { + targets.push(UpdateTarget { + path: sibling, + asset_stem: release_asset_stem_for_prefix( + sibling_prefix, + std::env::consts::OS, + std::env::consts::ARCH, + ), + }); + } + + targets +} + +fn release_asset_stem_for_prefix(prefix: &str, os: &str, rust_arch: &str) -> String { let arch = release_arch_for_rust_arch(rust_arch); format!("{prefix}-{os}-{arch}") } +#[cfg(test)] +fn release_asset_stem_for(current_exe: &Path, os: &str, rust_arch: &str) -> String { + let prefix = binary_prefix_for_exe(current_exe); + release_asset_stem_for_prefix(prefix, os, rust_arch) +} + pub(crate) fn asset_matches_platform(asset_name: &str, binary_name: &str) -> bool { if asset_name.ends_with(".sha256") { return false; @@ -174,6 +240,7 @@ fn parse_checksum_manifest(text: &str) -> Result> { Ok(checksums) } +#[cfg(test)] fn expected_sha256_from_manifest(text: &str, asset_name: &str) -> Result { let checksums = parse_checksum_manifest(text)?; checksums @@ -422,6 +489,44 @@ mod tests { } } + #[test] + fn update_targets_include_existing_sibling_tui_for_dispatcher() { + let dir = tempfile::TempDir::new().unwrap(); + let dispatcher = dir + .path() + .join(format!("deepseek{}", std::env::consts::EXE_SUFFIX)); + let tui = dir + .path() + .join(format!("deepseek-tui{}", std::env::consts::EXE_SUFFIX)); + std::fs::write(&dispatcher, b"dispatcher").unwrap(); + std::fs::write(&tui, b"tui").unwrap(); + + let targets = update_targets_for_exe(&dispatcher); + let paths = targets + .iter() + .map(|target| target.path.as_path()) + .collect::>(); + + assert_eq!(paths, vec![dispatcher.as_path(), tui.as_path()]); + assert!(targets[0].asset_stem.starts_with("deepseek-")); + assert!(targets[1].asset_stem.starts_with("deepseek-tui-")); + } + + #[test] + fn update_targets_skip_missing_sibling() { + let dir = tempfile::TempDir::new().unwrap(); + let dispatcher = dir + .path() + .join(format!("deepseek{}", std::env::consts::EXE_SUFFIX)); + std::fs::write(&dispatcher, b"dispatcher").unwrap(); + + let targets = update_targets_for_exe(&dispatcher); + + assert_eq!(targets.len(), 1); + assert_eq!(targets[0].path, dispatcher); + assert!(targets[0].asset_stem.starts_with("deepseek-")); + } + #[test] fn test_asset_matching_accepts_binary_assets_and_rejects_checksums() { assert!(asset_matches_platform(