返回 DeepSeek-TUI-2026
update.rs
根目录 / crates / cli / src / update.rs
1 //! Self-update for the `deepseek` binary.
2 //!
3 //! The `update` subcommand fetches the latest release from
4 //! `github.com/Hmbown/DeepSeek-TUI/releases/latest`, downloads the
5 //! platform-correct binary, verifies its SHA256 checksum, and atomically
6 //! replaces the currently running binary.
7
8 use std::path::Path;
9 use std::process::Command;
10
11 use anyhow::{Context, Result, bail};
12 use std::io::Write;
13
14 /// Run the self-update workflow.
15 pub fn run_update() -> Result<()> {
16 let current_exe =
17 std::env::current_exe().context("failed to determine current executable path")?;
18
19 println!("Checking for updates...");
20 println!("Current binary: {}", current_exe.display());
21
22 let binary_name =
23 release_asset_stem_for(&current_exe, std::env::consts::OS, std::env::consts::ARCH);
24
25 // Step 1: Fetch latest release metadata
26 let release = fetch_latest_release()?;
27 let latest_tag = &release.tag_name;
28 println!("Latest release: {latest_tag}");
29
30 // Step 2: Find the matching asset
31 let asset = select_platform_asset(&release, &binary_name).with_context(|| {
32 format!(
33 "no asset found for platform {binary_name} in release {latest_tag}. \
34 Available assets: {}",
35 release
36 .assets
37 .iter()
38 .map(|a| a.name.as_str())
39 .collect::<Vec<_>>()
40 .join(", ")
41 )
42 })?;
43
44 println!("Downloading {}...", asset.name);
45
46 // Step 3: Download the asset
47 let bytes = download_url(&asset.browser_download_url)
48 .with_context(|| format!("failed to download {}", asset.name))?;
49
50 // Step 4: Download the SHA256 checksum file if available
51 let sha_url = format!("{}.sha256", asset.browser_download_url);
52 let expected_hash = match download_url(&sha_url) {
53 Ok(sha_bytes) => {
54 let sha_text = String::from_utf8_lossy(&sha_bytes);
55 // Parse "hash filename" format
56 sha_text.split_whitespace().next().map(|s| s.to_string())
57 }
58 Err(_) => {
59 println!(" (no SHA256 checksum file found; skipping verification)");
60 None
61 }
62 };
63
64 // Step 5: Verify checksum if available
65 if let Some(expected) = &expected_hash {
66 let actual = sha256_hex(&bytes);
67 if !actual.eq_ignore_ascii_case(expected) {
68 bail!("SHA256 mismatch!\n expected: {expected}\n actual: {actual}");
69 }
70 println!("SHA256 checksum verified.");
71 }
72
73 // Step 6: Replace the current binary atomically
74 replace_binary(&current_exe, &bytes)?;
75
76 println!(
77 "\n✅ Successfully updated to {latest_tag}!\n\
78 New binary: {}\n\
79 \n\
80 Restart the application to use the new version.",
81 current_exe.display()
82 );
83
84 Ok(())
85 }
86
87 pub(crate) fn release_arch_for_rust_arch(arch: &str) -> &str {
88 match arch {
89 "aarch64" => "arm64",
90 "x86_64" => "x64",
91 other => other,
92 }
93 }
94
95 pub(crate) fn binary_prefix_for_exe(current_exe: &Path) -> &'static str {
96 let exe_name = current_exe
97 .file_name()
98 .and_then(|name| name.to_str())
99 .unwrap_or("deepseek");
100 if exe_name.contains("deepseek-tui") {
101 "deepseek-tui"
102 } else {
103 "deepseek"
104 }
105 }
106
107 pub(crate) fn release_asset_stem_for(current_exe: &Path, os: &str, rust_arch: &str) -> String {
108 let prefix = binary_prefix_for_exe(current_exe);
109 let arch = release_arch_for_rust_arch(rust_arch);
110 format!("{prefix}-{os}-{arch}")
111 }
112
113 pub(crate) fn asset_matches_platform(asset_name: &str, binary_name: &str) -> bool {
114 if asset_name.ends_with(".sha256") {
115 return false;
116 }
117 asset_name == binary_name
118 || asset_name == format!("{binary_name}.exe")
119 || asset_name.starts_with(&format!("{binary_name}."))
120 }
121
122 fn select_platform_asset<'a>(release: &'a Release, binary_name: &str) -> Option<&'a Asset> {
123 release
124 .assets
125 .iter()
126 .find(|asset| asset_matches_platform(&asset.name, binary_name))
127 }
128
129 /// GitHub release metadata.
130 #[derive(serde::Deserialize, Debug)]
131 struct Release {
132 tag_name: String,
133 assets: Vec<Asset>,
134 }
135
136 /// A single release asset.
137 #[derive(serde::Deserialize, Debug)]
138 struct Asset {
139 name: String,
140 browser_download_url: String,
141 }
142
143 /// Fetch the latest release metadata from GitHub.
144 fn fetch_latest_release() -> Result<Release> {
145 let url = "https://api.github.com/repos/Hmbown/DeepSeek-TUI/releases/latest";
146 let output = Command::new("curl")
147 .args([
148 "-sSfL",
149 "-H",
150 "Accept: application/vnd.github+json",
151 "-H",
152 "User-Agent: deepseek-tui-updater",
153 url,
154 ])
155 .output()
156 .context("failed to run curl to fetch release info")?;
157
158 if !output.status.success() {
159 let stderr = String::from_utf8_lossy(&output.stderr);
160 bail!("curl failed: {stderr}");
161 }
162
163 let body = String::from_utf8_lossy(&output.stdout);
164 let release: Release = serde_json::from_str(&body).with_context(|| {
165 format!("failed to parse release JSON from GitHub API. Response: {body}")
166 })?;
167
168 Ok(release)
169 }
170
171 /// Download a URL to bytes using curl.
172 fn download_url(url: &str) -> Result<Vec<u8>> {
173 let output = Command::new("curl")
174 .args(["-sSfL", url])
175 .output()
176 .with_context(|| format!("failed to download {url}"))?;
177
178 if !output.status.success() {
179 let stderr = String::from_utf8_lossy(&output.stderr);
180 bail!("curl download failed: {stderr}");
181 }
182
183 Ok(output.stdout)
184 }
185
186 /// Compute the SHA256 hex digest of data.
187 fn sha256_hex(data: &[u8]) -> String {
188 use sha2::Digest;
189 let hash = sha2::Sha256::digest(data);
190 format!("{hash:x}")
191 }
192
193 /// Replace the running binary.
194 ///
195 /// Writes the new binary to a secure temp file in the target directory, then
196 /// installs it in place. Unix can atomically replace the executable path. On
197 /// Windows, replacing a running executable can fail, so rename the current file
198 /// out of the way before moving the new binary into the original path.
199 fn replace_binary(target: &Path, new_bytes: &[u8]) -> Result<()> {
200 let parent = target
201 .parent()
202 .filter(|path| !path.as_os_str().is_empty())
203 .unwrap_or_else(|| Path::new("."));
204
205 let mut tmp = tempfile::Builder::new()
206 .prefix(".deepseek-update-")
207 .tempfile_in(parent)
208 .with_context(|| format!("failed to create temp file in {}", parent.display()))?;
209 tmp.write_all(new_bytes)
210 .with_context(|| format!("failed to write temp file at {}", tmp.path().display()))?;
211
212 // Preserve permissions from the original binary (if it exists)
213 if target.exists() {
214 if let Ok(meta) = std::fs::metadata(target) {
215 let _ = std::fs::set_permissions(tmp.path(), meta.permissions());
216 }
217 } else {
218 #[cfg(unix)]
219 {
220 use std::os::unix::fs::PermissionsExt;
221 let _ = std::fs::set_permissions(tmp.path(), std::fs::Permissions::from_mode(0o755));
222 }
223 }
224
225 #[cfg(windows)]
226 {
227 let backup = backup_path_for(target);
228 if target.exists() {
229 std::fs::rename(target, &backup).with_context(|| {
230 format!(
231 "failed to move current executable {} to {}",
232 target.display(),
233 backup.display()
234 )
235 })?;
236 }
237
238 if let Err(err) = tmp.persist(target) {
239 if backup.exists() {
240 let _ = std::fs::rename(&backup, target);
241 }
242 bail!(
243 "failed to install new binary at {}: {}",
244 target.display(),
245 err.error
246 );
247 }
248
249 let _ = std::fs::remove_file(&backup);
250 }
251
252 #[cfg(not(windows))]
253 {
254 tmp.persist(target)
255 .map_err(|err| err.error)
256 .with_context(|| format!("failed to rename temp file to {}", target.display()))?;
257 }
258
259 Ok(())
260 }
261
262 #[cfg(windows)]
263 fn backup_path_for(target: &Path) -> std::path::PathBuf {
264 let pid = std::process::id();
265 for index in 0..100 {
266 let mut candidate = target.to_path_buf();
267 let suffix = if index == 0 {
268 format!("old-{pid}")
269 } else {
270 format!("old-{pid}-{index}")
271 };
272 candidate.set_extension(suffix);
273 if !candidate.exists() {
274 return candidate;
275 }
276 }
277 target.with_extension(format!("old-{pid}-fallback"))
278 }
279
280 #[cfg(test)]
281 mod tests {
282 use super::*;
283
284 /// Verify the arch mapping used when constructing asset names.
285 /// The mapping must use release-asset naming (arm64/x64), not Rust
286 /// stdlib constants (aarch64/x86_64).
287 #[test]
288 fn test_arch_mapping() {
289 assert_eq!(release_arch_for_rust_arch("aarch64"), "arm64");
290 assert_eq!(release_arch_for_rust_arch("x86_64"), "x64");
291 // Pass-through for unknown arches
292 assert_eq!(release_arch_for_rust_arch("riscv64"), "riscv64");
293 // The currently-compiled arch maps to a release asset name
294 let compiled_arch = std::env::consts::ARCH;
295 let asset_arch = release_arch_for_rust_arch(compiled_arch);
296 // Must not contain the raw Rust constant names
297 assert!(
298 !asset_arch.contains("aarch64") && !asset_arch.contains("x86_64"),
299 "asset arch '{asset_arch}' still uses raw Rust constant name"
300 );
301 }
302
303 /// Verify binary prefix detection for dispatcher vs TUI binary.
304 #[test]
305 fn test_binary_prefix_detection() {
306 // TUI binary should use deepseek-tui prefix
307 assert_eq!(
308 binary_prefix_for_exe(Path::new("deepseek-tui")),
309 "deepseek-tui"
310 );
311 assert_eq!(
312 binary_prefix_for_exe(Path::new("deepseek-tui.exe")),
313 "deepseek-tui"
314 );
315 assert_eq!(
316 binary_prefix_for_exe(Path::new("/usr/local/bin/deepseek-tui")),
317 "deepseek-tui"
318 );
319
320 // Dispatcher binary should use deepseek prefix
321 assert_eq!(binary_prefix_for_exe(Path::new("deepseek")), "deepseek");
322 assert_eq!(binary_prefix_for_exe(Path::new("deepseek.exe")), "deepseek");
323 assert_eq!(
324 binary_prefix_for_exe(Path::new("/usr/local/bin/deepseek")),
325 "deepseek"
326 );
327
328 // Fallback for unknown names
329 assert_eq!(binary_prefix_for_exe(Path::new("other-binary")), "deepseek");
330 }
331
332 #[test]
333 fn test_release_asset_stem_for_supported_platforms() {
334 let cases = [
335 ("deepseek", "macos", "aarch64", "deepseek-macos-arm64"),
336 ("deepseek", "macos", "x86_64", "deepseek-macos-x64"),
337 ("deepseek", "linux", "x86_64", "deepseek-linux-x64"),
338 ("deepseek", "windows", "x86_64", "deepseek-windows-x64"),
339 (
340 "deepseek-tui",
341 "macos",
342 "aarch64",
343 "deepseek-tui-macos-arm64",
344 ),
345 ("deepseek-tui", "linux", "x86_64", "deepseek-tui-linux-x64"),
346 ];
347
348 for (exe, os, arch, expected) in cases {
349 assert_eq!(release_asset_stem_for(Path::new(exe), os, arch), expected);
350 }
351 }
352
353 #[test]
354 fn test_asset_matching_accepts_binary_assets_and_rejects_checksums() {
355 assert!(asset_matches_platform(
356 "deepseek-macos-arm64",
357 "deepseek-macos-arm64"
358 ));
359 assert!(asset_matches_platform(
360 "deepseek-macos-arm64.tar.gz",
361 "deepseek-macos-arm64"
362 ));
363 assert!(asset_matches_platform(
364 "deepseek-tui-windows-x64.exe",
365 "deepseek-tui-windows-x64"
366 ));
367 assert!(!asset_matches_platform(
368 "deepseek-tui-windows-x64.exe.sha256",
369 "deepseek-tui-windows-x64"
370 ));
371 assert!(!asset_matches_platform(
372 "deepseek-macos-aarch64.tar.gz",
373 "deepseek-macos-arm64"
374 ));
375 }
376
377 #[test]
378 fn test_sha256_hex_known_value() {
379 let data = b"hello";
380 let hash = sha256_hex(data);
381 assert_eq!(
382 hash,
383 "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"
384 );
385 }
386
387 #[test]
388 fn test_sha256_hex_empty() {
389 let hash = sha256_hex(b"");
390 assert_eq!(
391 hash,
392 "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
393 );
394 }
395
396 #[test]
397 fn test_replace_binary_creates_and_replaces() {
398 let dir = tempfile::TempDir::new().unwrap();
399 let target = dir.path().join("deepseek-test");
400 // Write initial content
401 std::fs::write(&target, b"old binary").unwrap();
402
403 replace_binary(&target, b"new binary content").unwrap();
404 let content = std::fs::read_to_string(&target).unwrap();
405 assert_eq!(content, "new binary content");
406 }
407
408 #[test]
409 fn test_replace_binary_creates_new_file() {
410 let dir = tempfile::TempDir::new().unwrap();
411 let target = dir.path().join("deepseek-new-test");
412
413 replace_binary(&target, b"fresh binary").unwrap();
414 let content = std::fs::read_to_string(&target).unwrap();
415 assert_eq!(content, "fresh binary");
416 }
417
418 /// Mocked GitHub release payload covering both the dispatcher (`deepseek`)
419 /// and the legacy TUI (`deepseek-tui`) binaries across our published
420 /// platform/arch matrix, plus a checksum sibling that must never be picked
421 /// as the primary binary.
422 fn mocked_release() -> Release {
423 let json = r#"{
424 "tag_name": "v0.8.8",
425 "assets": [
426 { "name": "deepseek-linux-x64", "browser_download_url": "https://example.invalid/deepseek-linux-x64" },
427 { "name": "deepseek-macos-x64", "browser_download_url": "https://example.invalid/deepseek-macos-x64" },
428 { "name": "deepseek-macos-arm64", "browser_download_url": "https://example.invalid/deepseek-macos-arm64" },
429 { "name": "deepseek-windows-x64.exe", "browser_download_url": "https://example.invalid/deepseek-windows-x64.exe" },
430 { "name": "deepseek-windows-x64.exe.sha256", "browser_download_url": "https://example.invalid/deepseek-windows-x64.exe.sha256" },
431 { "name": "deepseek-tui-linux-x64", "browser_download_url": "https://example.invalid/deepseek-tui-linux-x64" },
432 { "name": "deepseek-tui-macos-x64", "browser_download_url": "https://example.invalid/deepseek-tui-macos-x64" },
433 { "name": "deepseek-tui-macos-arm64", "browser_download_url": "https://example.invalid/deepseek-tui-macos-arm64" },
434 { "name": "deepseek-tui-windows-x64.exe","browser_download_url": "https://example.invalid/deepseek-tui-windows-x64.exe" }
435 ]
436 }"#;
437 serde_json::from_str(json).expect("mock release JSON")
438 }
439
440 #[test]
441 fn mocked_release_selects_dispatcher_asset_for_supported_platforms() {
442 let release = mocked_release();
443 let cases = [
444 ("macos", "aarch64", "deepseek-macos-arm64"),
445 ("macos", "x86_64", "deepseek-macos-x64"),
446 ("linux", "x86_64", "deepseek-linux-x64"),
447 ("windows", "x86_64", "deepseek-windows-x64.exe"),
448 ];
449
450 for (os, arch, expected) in cases {
451 let stem = release_asset_stem_for(Path::new("/usr/local/bin/deepseek"), os, arch);
452 let asset = select_platform_asset(&release, &stem)
453 .unwrap_or_else(|| panic!("no asset for {os}/{arch} (stem {stem})"));
454 assert_eq!(asset.name, expected, "{os}/{arch}");
455 }
456 }
457
458 #[test]
459 fn mocked_release_selects_tui_asset_when_tui_binary_invokes_update() {
460 let release = mocked_release();
461 let stem =
462 release_asset_stem_for(Path::new("/usr/local/bin/deepseek-tui"), "macos", "aarch64");
463 let asset = select_platform_asset(&release, &stem).expect("TUI platform asset");
464 assert_eq!(asset.name, "deepseek-tui-macos-arm64");
465 }
466 }
467
467 lines RUST