fix(cli): deepseek update refreshes sibling TUI binary alongside dispatcher

Before this change, `deepseek update` would replace the running
dispatcher binary at `~/.cargo/bin/deepseek` but leave the sibling
`~/.cargo/bin/deepseek-tui` at whatever version was installed last.
The dispatcher then reported the new release while the TUI binary it
shells out to for every interactive turn stayed pinned to the old
build — most visible on Volta-managed npm installs and on any flow
that uses `deepseek update` instead of re-running both
`cargo install --path crates/{cli,tui}`.

The updater now enumerates the running binary plus an existing
colocated sibling up front, fetches and SHA256-verifies every needed
release asset before replacing anything on disk, then swaps the
sibling first and the running dispatcher last so a partial network
failure can't leave the launcher updated while the TUI remains stale.
The success message lists every refreshed binary by full path.

Tests cover sibling target detection (dispatcher present + sibling TUI
present → both targeted) and the no-sibling fallback (dispatcher only
→ single target).

Harvested from PR #1492 by @NorethSea

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hunter Bown
2026-05-12 00:23:27 -05:00
parent f94bedf1ea
commit 23d3a71126
2 changed files with 161 additions and 38 deletions
+18
View File
@@ -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
+143 -38
View File
@@ -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(&current_exe);
println!("Checking for updates...");
println!("Current binary: {}", current_exe.display());
let binary_name =
release_asset_stem_for(&current_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::<Vec<_>>()
.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::<Vec<_>>()
.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(&current_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::<Vec<_>>()
.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<UpdateTarget> {
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<HashMap<String, String>> {
Ok(checksums)
}
#[cfg(test)]
fn expected_sha256_from_manifest(text: &str, asset_name: &str) -> Result<String> {
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::<Vec<_>>();
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(