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:
@@ -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
@@ -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::<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(¤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::<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(
|
||||
|
||||
Reference in New Issue
Block a user