1use std::io::Read as _;
15use std::path::Path;
16use std::process::{Command, Stdio};
17
18use anyhow::{Context, Result, anyhow, bail};
19use sha2::{Digest, Sha256};
20
21const GITHUB_REPO: &str = "utensils/claudex";
22
23fn parse_version(v: &str) -> Option<(u32, u32, u32)> {
27 let v = v.strip_prefix('v').unwrap_or(v);
28 let parts: Vec<&str> = v.split('.').collect();
29 if parts.len() != 3 {
30 return None;
31 }
32 Some((
33 parts[0].parse().ok()?,
34 parts[1].parse().ok()?,
35 parts[2].parse().ok()?,
36 ))
37}
38
39fn is_newer(current: &str, remote: &str) -> bool {
41 match (parse_version(current), parse_version(remote)) {
42 (Some(c), Some(r)) => r > c,
43 _ => false,
44 }
45}
46
47#[derive(Debug, PartialEq, Eq, Copy, Clone)]
50pub enum InstallKind {
51 Nix,
53 Cargo,
55 Homebrew,
57 Pacman,
63 Managed,
66}
67
68impl InstallKind {
69 fn label(self) -> &'static str {
70 match self {
71 Self::Nix => "Nix",
72 Self::Cargo => "cargo",
73 Self::Homebrew => "Homebrew",
74 Self::Pacman => "pacman (AUR)",
75 Self::Managed => "install.sh",
76 }
77 }
78}
79
80pub fn detect_install_kind(exe_path: &Path) -> InstallKind {
81 let p = exe_path.to_string_lossy();
82 if p.contains("/nix/store/") {
83 InstallKind::Nix
84 } else if p.contains("/Cellar/") || p.contains("/homebrew/") {
85 InstallKind::Homebrew
86 } else if p.contains("/.cargo/bin/") || p.contains("/cargo/bin/") {
87 InstallKind::Cargo
88 } else if cfg!(target_os = "linux")
89 && (p.starts_with("/usr/bin/") || p.starts_with("/usr/sbin/"))
90 {
91 InstallKind::Pacman
96 } else {
97 InstallKind::Managed
98 }
99}
100
101fn upgrade_hint(kind: InstallKind, target_tag: &str) -> Option<String> {
105 match kind {
106 InstallKind::Nix => Some(
107 " nix profile upgrade claudex\n \
108 or, if claudex is a flake input:\n \
109 nix flake update claudex"
110 .to_string(),
111 ),
112 InstallKind::Cargo => Some(format!(
113 " cargo install claudex-cli --version {} --force",
114 target_tag.trim_start_matches('v')
115 )),
116 InstallKind::Homebrew => Some(" brew upgrade claudex".to_string()),
117 InstallKind::Pacman => Some(
118 " paru -Syu claudex-bin # or claudex / claudex-git, depending on which AUR package you installed\n \
119 or with any other AUR helper / vanilla pacman:\n \
120 yay -Syu claudex-bin\n \
121 sudo pacman -Syu # if upstream syncs the package"
122 .to_string(),
123 ),
124 InstallKind::Managed => None,
125 }
126}
127
128fn detect_asset_name() -> Result<&'static str> {
133 match (std::env::consts::OS, std::env::consts::ARCH) {
134 ("macos", "aarch64") => Ok("claudex-aarch64-apple-darwin.tar.gz"),
135 ("macos", "x86_64") => Ok("claudex-x86_64-apple-darwin.tar.gz"),
136 ("linux", "x86_64") => Ok("claudex-x86_64-unknown-linux-gnu.tar.gz"),
137 ("linux", "aarch64") => Ok("claudex-aarch64-unknown-linux-gnu.tar.gz"),
138 (os, arch) => bail!("unsupported platform for self-update: {os}/{arch}"),
139 }
140}
141
142fn verify_checksum(sums: &str, asset: &str, data: &[u8]) -> Result<()> {
148 let expected = sums
149 .lines()
150 .find_map(|line| {
151 let (hash, name) = line.split_once(" ")?;
152 (name.trim() == asset).then(|| hash.trim().to_string())
153 })
154 .with_context(|| format!("asset {asset} not found in SHA256SUMS"))?;
155
156 let mut hasher = Sha256::new();
157 hasher.update(data);
158 let actual = format!("{:x}", hasher.finalize());
159
160 if actual != expected {
161 bail!(
162 "SHA-256 checksum mismatch for {asset}\n expected: {expected}\n actual: {actual}"
163 );
164 }
165 Ok(())
166}
167
168fn extract_binary_from_tarball(data: &[u8]) -> Result<Vec<u8>> {
173 let decoder = flate2::read::GzDecoder::new(data);
174 let mut archive = tar::Archive::new(decoder);
175
176 for entry in archive.entries()? {
177 let mut entry = entry?;
178 let path = entry.path()?;
179 if path.file_name().map(|n| n == "claudex").unwrap_or(false) {
180 let mut buf = Vec::new();
181 entry.read_to_end(&mut buf)?;
182 return Ok(buf);
183 }
184 }
185 bail!("'claudex' binary not found in release archive")
186}
187
188fn replace_binary(new_binary: &[u8], exe_path: &Path) -> Result<()> {
194 use std::os::unix::fs::PermissionsExt;
195
196 let exe_dir = exe_path
197 .parent()
198 .context("cannot determine binary directory")?;
199 let pid = std::process::id();
200 let tmp_path = exe_dir.join(format!(".claudex-update-{pid}"));
201 let backup_path = exe_path.with_extension("old");
202
203 std::fs::write(&tmp_path, new_binary).context("failed to write new binary to temp file")?;
204 std::fs::set_permissions(&tmp_path, std::fs::Permissions::from_mode(0o755))
205 .context("failed to set permissions on new binary")?;
206
207 std::fs::rename(exe_path, &backup_path).context("failed to move current binary to backup")?;
208
209 if let Err(e) = std::fs::rename(&tmp_path, exe_path) {
210 let _ = std::fs::rename(&backup_path, exe_path);
211 let _ = std::fs::remove_file(&tmp_path);
212 bail!("failed to install new binary: {e}");
213 }
214
215 let _ = std::fs::remove_file(&backup_path);
216
217 #[cfg(target_os = "macos")]
218 {
219 let _ = Command::new("xattr")
220 .args(["-d", "com.apple.quarantine"])
221 .arg(exe_path)
222 .output();
223 }
224
225 Ok(())
226}
227
228fn ensure_curl() -> Result<()> {
231 let ok = Command::new("curl")
232 .arg("--version")
233 .stdout(Stdio::null())
234 .stderr(Stdio::null())
235 .status()
236 .map(|s| s.success())
237 .unwrap_or(false);
238 if !ok {
239 bail!("`curl` is required for `claudex update` but was not found in PATH");
240 }
241 Ok(())
242}
243
244fn tag_from_redirect(url: &str) -> Option<String> {
247 let trimmed = url.trim().trim_end_matches('/');
248 let tag = trimmed.rsplit('/').next()?;
249 if tag.starts_with('v') && parse_version(tag).is_some() {
250 Some(tag.to_string())
251 } else {
252 None
253 }
254}
255
256fn fetch_latest_tag() -> Result<String> {
258 let url = format!("https://github.com/{GITHUB_REPO}/releases/latest");
259 let out = Command::new("curl")
260 .args(["-sLI", "-o", "/dev/null", "-w", "%{url_effective}", &url])
261 .output()
262 .context("failed to invoke curl")?;
263 if !out.status.success() {
264 bail!(
265 "curl failed while resolving latest release (exit {:?})",
266 out.status.code()
267 );
268 }
269 let final_url = String::from_utf8_lossy(&out.stdout);
270 tag_from_redirect(&final_url)
271 .ok_or_else(|| anyhow!("unexpected redirect target: {}", final_url.trim()))
272}
273
274fn fetch_url(url: &str) -> Result<Vec<u8>> {
275 let out = Command::new("curl")
276 .args(["-fsSL", url])
277 .output()
278 .context("failed to invoke curl")?;
279 if !out.status.success() {
280 let stderr = String::from_utf8_lossy(&out.stderr);
281 bail!("curl failed to fetch {url}:\n {}", stderr.trim());
282 }
283 Ok(out.stdout)
284}
285
286pub fn run(check: bool, force: bool, version: Option<String>) -> Result<()> {
289 let current = env!("CARGO_PKG_VERSION");
290 eprintln!("Current version: {current}");
291
292 ensure_curl()?;
293
294 let target_tag = match version.as_deref() {
295 Some(v) => {
296 if v.starts_with('v') {
297 v.to_string()
298 } else {
299 format!("v{v}")
300 }
301 }
302 None => {
303 eprintln!("Checking for updates...");
304 fetch_latest_tag()?
305 }
306 };
307 let remote = target_tag.strip_prefix('v').unwrap_or(&target_tag);
308
309 if !force && remote == current && version.is_none() {
311 eprintln!("✓ Already up to date ({current})");
312 return Ok(());
313 }
314
315 let action = if is_newer(current, remote) {
316 "Updating"
317 } else if remote == current {
318 "Reinstalling"
319 } else {
320 "Downgrading"
321 };
322
323 if check {
325 if is_newer(current, remote) {
326 eprintln!("→ New version available: {remote} (current: {current})");
327 } else if remote == current {
328 eprintln!("✓ Up to date ({current})");
329 } else {
330 eprintln!("→ Version {remote} is available (current: {current})");
331 }
332 return Ok(());
333 }
334
335 let exe_path = std::env::current_exe()?.canonicalize()?;
337 let kind = detect_install_kind(&exe_path);
338 if let Some(hint) = upgrade_hint(kind, &target_tag) {
339 eprintln!(
340 "claudex was installed via {} at {}.",
341 kind.label(),
342 exe_path.display()
343 );
344 eprintln!("In-place self-update isn't supported for this install source.");
345 eprintln!("To upgrade to {target_tag}, run:");
346 eprintln!("{hint}");
347 std::process::exit(1);
348 }
349
350 if let Some(exe_dir) = exe_path.parent() {
351 let probe = exe_dir.join(format!(".claudex-update-test-{}", std::process::id()));
352 match std::fs::write(&probe, b"") {
353 Ok(()) => {
354 let _ = std::fs::remove_file(&probe);
355 }
356 Err(_) => bail!(
357 "no write permission to {}. Re-run with sudo or reinstall with \
358 CLAUDEX_INSTALL_DIR pointing at a user-writable directory.",
359 exe_dir.display()
360 ),
361 }
362 }
363
364 eprintln!("{action}: {current} → {remote}");
365
366 let asset_name = detect_asset_name()?;
367 let asset_url =
368 format!("https://github.com/{GITHUB_REPO}/releases/download/{target_tag}/{asset_name}");
369 let sums_url =
370 format!("https://github.com/{GITHUB_REPO}/releases/download/{target_tag}/SHA256SUMS");
371
372 let archive = fetch_url(&asset_url)?;
373 let sums =
374 String::from_utf8(fetch_url(&sums_url)?).context("SHA256SUMS contained non-UTF-8 data")?;
375
376 verify_checksum(&sums, asset_name, &archive)?;
377 eprintln!("Checksum verified (SHA-256).");
378
379 let binary = extract_binary_from_tarball(&archive)?;
380 replace_binary(&binary, &exe_path)?;
381
382 eprintln!(
383 "✓ {action} complete: claudex {remote} ({})",
384 exe_path.display()
385 );
386 Ok(())
387}
388
389#[cfg(test)]
392mod tests {
393 use super::*;
394 use std::io::Write as _;
395
396 #[test]
399 fn parse_version_valid() {
400 assert_eq!(parse_version("0.6.1"), Some((0, 6, 1)));
401 assert_eq!(parse_version("v1.2.3"), Some((1, 2, 3)));
402 assert_eq!(parse_version("10.20.30"), Some((10, 20, 30)));
403 }
404
405 #[test]
406 fn parse_version_invalid() {
407 assert_eq!(parse_version(""), None);
408 assert_eq!(parse_version("1.2"), None);
409 assert_eq!(parse_version("1.2.3.4"), None);
410 assert_eq!(parse_version("abc"), None);
411 assert_eq!(parse_version("1.2.x"), None);
412 }
413
414 #[test]
415 fn is_newer_basic() {
416 assert!(is_newer("0.2.0", "0.3.0"));
417 assert!(is_newer("0.2.1", "0.2.2"));
418 assert!(!is_newer("0.2.1", "0.2.1"));
419 assert!(!is_newer("1.0.0", "0.9.9"));
420 }
421
422 #[test]
423 fn is_newer_tolerates_v_prefix() {
424 assert!(is_newer("0.2.0", "v0.3.0"));
425 assert!(is_newer("v0.2.0", "0.3.0"));
426 assert!(!is_newer("v0.3.0", "v0.2.0"));
427 }
428
429 #[test]
432 fn install_kind_nix() {
433 let p = Path::new("/nix/store/abc123-claudex/bin/claudex");
434 assert_eq!(detect_install_kind(p), InstallKind::Nix);
435 }
436
437 #[test]
438 fn install_kind_cargo() {
439 let p = Path::new("/Users/alice/.cargo/bin/claudex");
440 assert_eq!(detect_install_kind(p), InstallKind::Cargo);
441 }
442
443 #[test]
444 fn install_kind_homebrew_silicon() {
445 let p = Path::new("/opt/homebrew/bin/claudex");
446 assert_eq!(detect_install_kind(p), InstallKind::Homebrew);
447 }
448
449 #[test]
450 fn install_kind_homebrew_intel() {
451 let p = Path::new("/usr/local/Cellar/claudex/0.2.0/bin/claudex");
452 assert_eq!(detect_install_kind(p), InstallKind::Homebrew);
453 }
454
455 #[test]
456 fn install_kind_managed_local_bin() {
457 assert_eq!(
458 detect_install_kind(Path::new("/Users/alice/.local/bin/claudex")),
459 InstallKind::Managed,
460 );
461 }
462
463 #[test]
464 fn install_kind_managed_usr_local() {
465 assert_eq!(
468 detect_install_kind(Path::new("/usr/local/bin/claudex")),
469 InstallKind::Managed,
470 );
471 }
472
473 #[test]
474 #[cfg(target_os = "linux")]
475 fn install_kind_pacman_usr_bin() {
476 assert_eq!(
477 detect_install_kind(Path::new("/usr/bin/claudex")),
478 InstallKind::Pacman,
479 );
480 }
481
482 #[test]
483 #[cfg(target_os = "linux")]
484 fn install_kind_pacman_usr_sbin() {
485 assert_eq!(
487 detect_install_kind(Path::new("/usr/sbin/claudex")),
488 InstallKind::Pacman,
489 );
490 }
491
492 #[test]
493 #[cfg(not(target_os = "linux"))]
494 fn install_kind_usr_bin_is_managed_off_linux() {
495 assert_eq!(
499 detect_install_kind(Path::new("/usr/bin/claudex")),
500 InstallKind::Managed,
501 );
502 }
503
504 #[test]
505 fn upgrade_hint_per_kind() {
506 assert!(
507 upgrade_hint(InstallKind::Nix, "v1.2.3")
508 .unwrap()
509 .contains("nix")
510 );
511 let cargo = upgrade_hint(InstallKind::Cargo, "v1.2.3").unwrap();
512 assert!(cargo.contains("cargo install"));
513 assert!(cargo.contains("--version 1.2.3"));
514 assert!(
515 upgrade_hint(InstallKind::Homebrew, "v1.2.3")
516 .unwrap()
517 .contains("brew")
518 );
519 let pacman = upgrade_hint(InstallKind::Pacman, "v1.2.3").unwrap();
520 assert!(pacman.contains("paru"));
521 assert!(pacman.contains("claudex-bin"));
522 assert_eq!(upgrade_hint(InstallKind::Managed, "v1.2.3"), None);
523 }
524
525 #[test]
528 fn asset_name_is_valid_for_current_platform() {
529 let name = detect_asset_name();
530 assert!(name.is_ok(), "detect_asset_name failed: {name:?}");
531 let name = name.unwrap();
532 assert!(name.starts_with("claudex-"));
533 assert!(name.ends_with(".tar.gz"));
534 }
535
536 #[test]
539 fn verify_checksum_matches() {
540 let data = b"hello world";
541 let mut h = Sha256::new();
542 h.update(data);
543 let hash = format!("{:x}", h.finalize());
544 let sums = format!("{hash} test.tar.gz\n");
545 assert!(verify_checksum(&sums, "test.tar.gz", data).is_ok());
546 }
547
548 #[test]
549 fn verify_checksum_mismatch() {
550 let sums =
551 "0000000000000000000000000000000000000000000000000000000000000000 test.tar.gz\n";
552 let err = verify_checksum(sums, "test.tar.gz", b"data").unwrap_err();
553 assert!(err.to_string().contains("checksum mismatch"));
554 }
555
556 #[test]
557 fn verify_checksum_missing_asset() {
558 let sums = "abcdef1234567890 other.tar.gz\n";
559 let err = verify_checksum(sums, "missing.tar.gz", b"data").unwrap_err();
560 assert!(err.to_string().contains("not found"));
561 }
562
563 #[test]
564 fn verify_checksum_multi_line() {
565 let a = b"file-a";
566 let b = b"file-b";
567 let mut ha = Sha256::new();
568 ha.update(a);
569 let mut hb = Sha256::new();
570 hb.update(b);
571 let sums = format!(
572 "{ha:x} a.tar.gz\n{hb:x} b.tar.gz\n",
573 ha = ha.finalize(),
574 hb = hb.finalize(),
575 );
576 assert!(verify_checksum(&sums, "a.tar.gz", a).is_ok());
577 assert!(verify_checksum(&sums, "b.tar.gz", b).is_ok());
578 }
579
580 fn make_tarball(entries: &[(&str, &[u8])]) -> Vec<u8> {
583 let mut builder = tar::Builder::new(Vec::new());
584 for (name, data) in entries {
585 let mut header = tar::Header::new_gnu();
586 header.set_size(data.len() as u64);
587 header.set_mode(0o755);
588 header.set_cksum();
589 builder.append_data(&mut header, name, *data).unwrap();
590 }
591 let tar_bytes = builder.into_inner().unwrap();
592 let mut gz = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::fast());
593 gz.write_all(&tar_bytes).unwrap();
594 gz.finish().unwrap()
595 }
596
597 #[test]
598 fn extract_finds_claudex_binary() {
599 let expected = b"fake-claudex-bytes";
600 let archive = make_tarball(&[("claudex", expected)]);
601 assert_eq!(extract_binary_from_tarball(&archive).unwrap(), expected);
602 }
603
604 #[test]
605 fn extract_skips_sibling_files() {
606 let expected = b"the-real-claudex";
607 let archive = make_tarball(&[
608 ("README.md", b"docs"),
609 ("claudex", expected),
610 ("LICENSE", b"license"),
611 ]);
612 assert_eq!(extract_binary_from_tarball(&archive).unwrap(), expected);
613 }
614
615 #[test]
616 fn extract_errors_when_binary_missing() {
617 let archive = make_tarball(&[("not-claudex", b"oops")]);
618 let err = extract_binary_from_tarball(&archive).unwrap_err();
619 assert!(err.to_string().contains("not found in release archive"));
620 }
621
622 #[test]
625 fn replace_binary_swaps_and_preserves_perms() {
626 use std::os::unix::fs::PermissionsExt;
627 let dir = tempfile::tempdir().unwrap();
628 let exe = dir.path().join("claudex");
629 std::fs::write(&exe, b"old").unwrap();
630 std::fs::set_permissions(&exe, std::fs::Permissions::from_mode(0o755)).unwrap();
631
632 replace_binary(b"new-v2", &exe).unwrap();
633
634 assert_eq!(std::fs::read(&exe).unwrap(), b"new-v2");
635 let mode = std::fs::metadata(&exe).unwrap().permissions().mode() & 0o777;
636 assert_eq!(mode, 0o755);
637 assert!(!exe.with_extension("old").exists());
638 }
639
640 #[test]
641 fn replace_binary_leaves_no_temp_files() {
642 let dir = tempfile::tempdir().unwrap();
643 let exe = dir.path().join("claudex");
644 std::fs::write(&exe, b"orig").unwrap();
645
646 replace_binary(b"next", &exe).unwrap();
647
648 let stragglers: Vec<_> = std::fs::read_dir(dir.path())
649 .unwrap()
650 .filter_map(|e| e.ok())
651 .filter(|e| {
652 let name = e.file_name();
653 let s = name.to_string_lossy();
654 s.starts_with(".claudex-update-") || s.ends_with(".old")
655 })
656 .collect();
657 assert!(stragglers.is_empty(), "leftovers: {stragglers:?}");
658 }
659
660 #[test]
663 fn tag_from_redirect_strips_to_tag() {
664 assert_eq!(
665 tag_from_redirect("https://github.com/utensils/claudex/releases/tag/v0.2.0"),
666 Some("v0.2.0".to_string()),
667 );
668 }
669
670 #[test]
671 fn tag_from_redirect_trims_whitespace_and_trailing_slash() {
672 assert_eq!(
673 tag_from_redirect(" https://github.com/utensils/claudex/releases/tag/v1.2.3/\n"),
674 Some("v1.2.3".to_string()),
675 );
676 }
677
678 #[test]
679 fn tag_from_redirect_rejects_non_version_suffix() {
680 assert_eq!(tag_from_redirect("https://example.com/not-a-release"), None,);
681 assert_eq!(
683 tag_from_redirect("https://github.com/u/r/releases/tag/vdev"),
684 None,
685 );
686 }
687}