1use std::io::Read;
2
3const GITHUB_API_RELEASES: &str = "https://api.github.com/repos/yvgude/lean-ctx/releases/latest";
4const CURRENT_VERSION: &str = env!("CARGO_PKG_VERSION");
5
6pub fn run(args: &[String]) {
7 let check_only = args.iter().any(|a| a == "--check");
8 let insecure = args.iter().any(|a| a == "--insecure");
9
10 println!();
11 println!(" \x1b[1m◆ lean-ctx updater\x1b[0m \x1b[2mv{CURRENT_VERSION}\x1b[0m");
12 println!(" \x1b[2mChecking github.com/yvgude/lean-ctx …\x1b[0m");
13
14 let release = match fetch_latest_release() {
15 Ok(r) => r,
16 Err(e) => {
17 tracing::error!("Error fetching release info: {e}");
18 std::process::exit(1);
19 }
20 };
21
22 let latest_tag = if let Some(t) = release["tag_name"].as_str() {
23 t.trim_start_matches('v').to_string()
24 } else {
25 tracing::error!("Could not parse release tag from GitHub API.");
26 std::process::exit(1);
27 };
28
29 if latest_tag == CURRENT_VERSION {
30 println!(" \x1b[32m✓\x1b[0m Already up to date (v{CURRENT_VERSION}).");
31 println!(" \x1b[2mIf your IDE still uses an older version, restart it to reconnect the MCP server.\x1b[0m");
32 println!();
33 if !check_only {
34 println!(" \x1b[36m\x1b[1mRefreshing setup (shell hook, MCP configs, rules)…\x1b[0m");
35 post_update_rewire();
36 println!();
37 }
38 return;
39 }
40
41 println!(" Update available: v{CURRENT_VERSION} → \x1b[1;32mv{latest_tag}\x1b[0m");
42
43 if check_only {
44 println!("Run 'lean-ctx update' to install.");
45 return;
46 }
47
48 let asset_name = platform_asset_name();
49 println!(" \x1b[2mDownloading {asset_name} …\x1b[0m");
50
51 let Some(download_url) = find_asset_url(&release, &asset_name) else {
52 tracing::error!("No binary found for this platform ({asset_name}). Download manually: https://github.com/yvgude/lean-ctx/releases/latest");
53 std::process::exit(1);
54 };
55
56 let bytes = match download_bytes(&download_url) {
57 Ok(b) => b,
58 Err(e) => {
59 tracing::error!("Download failed: {e}");
60 std::process::exit(1);
61 }
62 };
63
64 if let Err(e) = verify_download_integrity(&release, &asset_name, &bytes) {
65 if insecure {
66 tracing::warn!("Integrity verification failed: {e}");
67 tracing::warn!("Proceeding due to --insecure");
68 } else {
69 tracing::error!("Integrity verification failed: {e}");
70 tracing::error!("Refusing to install an unverifiable binary. Re-run with `lean-ctx update --insecure` or download manually: https://github.com/yvgude/lean-ctx/releases/latest");
71 std::process::exit(1);
72 }
73 }
74
75 let current_exe = match std::env::current_exe() {
76 Ok(p) => p,
77 Err(e) => {
78 tracing::error!("Cannot locate current executable: {e}");
79 std::process::exit(1);
80 }
81 };
82
83 if let Err(e) = replace_binary(&bytes, &asset_name, ¤t_exe) {
84 tracing::error!("Failed to replace binary: {e}");
85 tracing::warn!("Continuing with a setup refresh so your wiring stays correct");
86 post_update_rewire();
87 std::process::exit(1);
88 }
89
90 println!();
91 println!(" \x1b[1;32m✓ Updated to lean-ctx v{latest_tag}\x1b[0m");
92 println!(" \x1b[2mBinary: {}\x1b[0m", current_exe.display());
93
94 println!();
95 println!(" \x1b[36m\x1b[1mRefreshing setup (shell hook, MCP configs, rules)…\x1b[0m");
96 post_update_rewire();
97
98 println!();
99 crate::terminal_ui::print_logo_animated();
100 println!();
101 println!(" \x1b[33m\x1b[1m⟳ Restart your IDE and shell to activate the new version.\x1b[0m");
102 println!(" \x1b[2mClose and re-open Cursor, VS Code, Claude Code, etc. completely.\x1b[0m");
103 println!(" \x1b[2mThe MCP server must reconnect to use the updated binary.\x1b[0m");
104 println!(
105 " \x1b[2mRun 'source ~/.zshrc' (or restart terminal) for updated shell aliases.\x1b[0m"
106 );
107 println!();
108}
109
110fn verify_download_integrity(
111 release: &serde_json::Value,
112 asset_name: &str,
113 bytes: &[u8],
114) -> Result<(), String> {
115 #[cfg(not(feature = "secure-update"))]
116 {
117 let _ = (release, asset_name, bytes);
118 return Err("secure-update feature disabled (sha256 verification unavailable)".to_string());
119 }
120
121 #[cfg(feature = "secure-update")]
122 {
123 let computed = sha256_hex(bytes);
124
125 let Some((checksum_url, kind)) = find_checksum_asset_url(release, asset_name) else {
126 return Err(
127 "no checksum asset found for this release (expected SHA256SUMS or *.sha256)"
128 .to_string(),
129 );
130 };
131 let checksum_bytes = download_bytes(&checksum_url)?;
132 let checksum_text = String::from_utf8_lossy(&checksum_bytes).to_string();
133
134 let expected = match kind {
135 ChecksumAssetKind::SingleSha256 => parse_single_sha256(&checksum_text),
136 ChecksumAssetKind::Sha256Sums => parse_sha256sums(&checksum_text, asset_name),
137 }
138 .ok_or_else(|| format!("checksum file did not contain an entry for {asset_name}"))?;
139
140 if !constant_time_eq(computed.as_bytes(), expected.as_bytes()) {
141 return Err(format!(
142 "sha256 mismatch for {asset_name}: expected {expected}, got {computed}"
143 ));
144 }
145 Ok(())
146 }
147}
148
149#[derive(Debug, Clone, Copy)]
150enum ChecksumAssetKind {
151 Sha256Sums,
152 SingleSha256,
153}
154
155fn find_checksum_asset_url(
156 release: &serde_json::Value,
157 asset_name: &str,
158) -> Option<(String, ChecksumAssetKind)> {
159 let candidates = [
161 format!("{asset_name}.sha256"),
162 format!("{asset_name}.sha256.txt"),
163 "SHA256SUMS".to_string(),
164 "SHA256SUMS.txt".to_string(),
165 "sha256sums.txt".to_string(),
166 "checksums.txt".to_string(),
167 ];
168
169 for c in candidates {
170 if let Some(url) = find_asset_url(release, &c) {
171 let kind = if c.to_lowercase().contains("sha256sums")
172 || c.to_uppercase() == "SHA256SUMS"
173 || c.to_lowercase().contains("checksums")
174 {
175 ChecksumAssetKind::Sha256Sums
176 } else {
177 ChecksumAssetKind::SingleSha256
178 };
179 return Some((url, kind));
180 }
181 }
182 None
183}
184
185fn parse_single_sha256(text: &str) -> Option<String> {
186 let t = text.trim();
187 let first = t.split_whitespace().next().unwrap_or("").trim();
188 if first.len() == 64 && first.chars().all(|c| c.is_ascii_hexdigit()) {
189 Some(first.to_ascii_lowercase())
190 } else {
191 None
192 }
193}
194
195fn parse_sha256sums(text: &str, asset_name: &str) -> Option<String> {
196 for line in text.lines() {
197 let l = line.trim();
198 if l.is_empty() || l.starts_with('#') {
199 continue;
200 }
201 let mut parts = l.split_whitespace();
202 let hash = parts.next().unwrap_or("");
203 let file = parts.next().unwrap_or("");
204 if file == asset_name && hash.len() == 64 && hash.chars().all(|c| c.is_ascii_hexdigit()) {
205 return Some(hash.to_ascii_lowercase());
206 }
207 }
208 None
209}
210
211fn sha256_hex(bytes: &[u8]) -> String {
212 use sha2::{Digest, Sha256};
213 let mut h = Sha256::new();
214 h.update(bytes);
215 let out = h.finalize();
216 hex_lower(&out)
217}
218
219fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
220 if a.len() != b.len() {
221 return false;
222 }
223 a.iter()
224 .zip(b.iter())
225 .fold(0u8, |acc, (x, y)| acc | (x ^ y))
226 == 0
227}
228
229fn hex_lower(bytes: &[u8]) -> String {
230 const HEX: &[u8; 16] = b"0123456789abcdef";
231 let mut out = String::with_capacity(bytes.len() * 2);
232 for &b in bytes {
233 out.push(HEX[(b >> 4) as usize] as char);
234 out.push(HEX[(b & 0x0f) as usize] as char);
235 }
236 out
237}
238
239fn post_update_rewire() {
240 let opts = crate::setup::SetupOptions {
243 non_interactive: true,
244 yes: true,
245 fix: true,
246 ..Default::default()
247 };
248 if let Err(e) = crate::setup::run_setup_with_options(opts) {
249 tracing::error!("Setup refresh error: {e}");
250 }
251
252 restart_proxy_if_running();
253}
254
255#[allow(dead_code)]
258fn restart_daemon_if_running() {
259 #[cfg(unix)]
260 {
261 if !crate::daemon::is_daemon_running() {
262 return;
263 }
264 println!(" \x1b[33m⟳\x1b[0m Restarting daemon with new binary…");
265 if let Err(e) = crate::daemon::stop_daemon() {
266 println!(" \x1b[33m⚠\x1b[0m Could not stop daemon: {e}");
267 return;
268 }
269 std::thread::sleep(std::time::Duration::from_millis(500));
270 match crate::daemon::start_daemon(&[]) {
271 Ok(()) => println!(" \x1b[32m✓\x1b[0m Daemon restarted"),
272 Err(e) => println!(" \x1b[33m⚠\x1b[0m Daemon restart failed: {e}"),
273 }
274 }
275}
276
277fn restart_proxy_if_running() {
278 let port = crate::proxy_setup::default_port();
279
280 if restart_managed_proxy() {
281 return;
282 }
283
284 if is_proxy_reachable(port) {
285 println!(
286 " \x1b[33m⟳\x1b[0m Proxy running on port {port} — restart it to use the new binary:"
287 );
288 println!(" \x1b[1mlean-ctx proxy start --port={port}\x1b[0m");
289 }
290}
291
292fn restart_managed_proxy() -> bool {
295 #[cfg(target_os = "macos")]
296 {
297 let plist_path = dirs::home_dir()
298 .unwrap_or_default()
299 .join("Library/LaunchAgents/com.leanctx.proxy.plist");
300 if plist_path.exists() {
301 let plist_str = plist_path.to_string_lossy().to_string();
302 let _ = std::process::Command::new("launchctl")
303 .args(["unload", &plist_str])
304 .output();
305 let result = std::process::Command::new("launchctl")
306 .args(["load", &plist_str])
307 .output();
308 match result {
309 Ok(o) if o.status.success() => {
310 println!(" \x1b[32m✓\x1b[0m Proxy restarted (LaunchAgent)");
311 }
312 _ => {
313 println!(" \x1b[33m⚠\x1b[0m Could not restart proxy LaunchAgent");
314 }
315 }
316 return true;
317 }
318 }
319
320 #[cfg(target_os = "linux")]
321 {
322 let service_path = dirs::home_dir()
323 .unwrap_or_default()
324 .join(".config/systemd/user/lean-ctx-proxy.service");
325 if service_path.exists() {
326 let result = std::process::Command::new("systemctl")
327 .args(["--user", "restart", "lean-ctx-proxy"])
328 .output();
329 match result {
330 Ok(o) if o.status.success() => {
331 println!(" \x1b[32m✓\x1b[0m Proxy restarted (systemd)");
332 }
333 _ => {
334 println!(" \x1b[33m⚠\x1b[0m Could not restart proxy systemd service");
335 }
336 }
337 return true;
338 }
339 }
340
341 false
342}
343
344fn is_proxy_reachable(port: u16) -> bool {
345 ureq::get(&format!("http://127.0.0.1:{port}/health"))
346 .call()
347 .is_ok()
348}
349
350fn fetch_latest_release() -> Result<serde_json::Value, String> {
351 let response = ureq::get(GITHUB_API_RELEASES)
352 .header("User-Agent", &format!("lean-ctx/{CURRENT_VERSION}"))
353 .header("Accept", "application/vnd.github.v3+json")
354 .call()
355 .map_err(|e| e.to_string())?;
356
357 response
358 .into_body()
359 .read_to_string()
360 .map_err(|e| e.to_string())
361 .and_then(|s| serde_json::from_str(&s).map_err(|e| e.to_string()))
362}
363
364fn find_asset_url(release: &serde_json::Value, asset_name: &str) -> Option<String> {
365 release["assets"]
366 .as_array()?
367 .iter()
368 .find(|a| a["name"].as_str() == Some(asset_name))
369 .and_then(|a| a["browser_download_url"].as_str())
370 .map(std::string::ToString::to_string)
371}
372
373fn download_bytes(url: &str) -> Result<Vec<u8>, String> {
374 let response = ureq::get(url)
375 .header("User-Agent", &format!("lean-ctx/{CURRENT_VERSION}"))
376 .call()
377 .map_err(|e| e.to_string())?;
378
379 let mut bytes = Vec::new();
380 response
381 .into_body()
382 .into_reader()
383 .read_to_end(&mut bytes)
384 .map_err(|e| e.to_string())?;
385 Ok(bytes)
386}
387
388fn replace_binary(
389 archive_bytes: &[u8],
390 asset_name: &str,
391 current_exe: &std::path::Path,
392) -> Result<(), String> {
393 let binary_bytes = if std::path::Path::new(asset_name)
394 .extension()
395 .is_some_and(|e| e.eq_ignore_ascii_case("zip"))
396 {
397 extract_from_zip(archive_bytes)?
398 } else {
399 extract_from_tar_gz(archive_bytes)?
400 };
401
402 let tmp_path = current_exe.with_extension("tmp");
403 std::fs::write(&tmp_path, &binary_bytes).map_err(|e| e.to_string())?;
404
405 #[cfg(unix)]
406 {
407 use std::os::unix::fs::PermissionsExt;
408 let _ = std::fs::set_permissions(&tmp_path, std::fs::Permissions::from_mode(0o755));
409 }
410
411 #[cfg(windows)]
415 {
416 let old_path = current_exe.with_extension("old.exe");
417 let _ = std::fs::remove_file(&old_path);
418
419 match std::fs::rename(current_exe, &old_path) {
420 Ok(()) => {
421 if let Err(e) = std::fs::rename(&tmp_path, current_exe) {
422 let _ = std::fs::rename(&old_path, current_exe);
423 let _ = std::fs::remove_file(&tmp_path);
424 return Err(format!("Cannot place new binary: {e}"));
425 }
426 let _ = std::fs::remove_file(&old_path);
427 return Ok(());
428 }
429 Err(_) => {
430 return deferred_windows_update(&tmp_path, current_exe);
431 }
432 }
433 }
434
435 #[cfg(not(windows))]
436 {
437 #[cfg(target_os = "macos")]
442 {
443 let _ = std::fs::remove_file(current_exe);
444 }
445
446 std::fs::rename(&tmp_path, current_exe).map_err(|e| {
447 let _ = std::fs::remove_file(&tmp_path);
448 format!("Cannot replace binary (permission denied?): {e}")
449 })?;
450
451 #[cfg(target_os = "macos")]
452 {
453 let _ = std::process::Command::new("codesign")
454 .args(["--force", "-s", "-", ¤t_exe.display().to_string()])
455 .output();
456 }
457
458 Ok(())
459 }
460}
461
462#[cfg(windows)]
466fn deferred_windows_update(
467 staged_path: &std::path::Path,
468 target_exe: &std::path::Path,
469) -> Result<(), String> {
470 let pending_path = target_exe.with_file_name("lean-ctx-pending.exe");
471 std::fs::rename(staged_path, &pending_path).map_err(|e| {
472 let _ = std::fs::remove_file(staged_path);
473 format!("Cannot stage update: {e}")
474 })?;
475
476 let target_str = target_exe.display().to_string();
477 let pending_str = pending_path.display().to_string();
478 let old_str = target_exe.with_extension("old.exe").display().to_string();
479
480 let script = format!(
481 r#"@echo off
482echo Waiting for lean-ctx to be released...
483:retry
484timeout /t 1 /nobreak >nul
485move /Y "{target}" "{old}" >nul 2>&1
486if errorlevel 1 goto retry
487move /Y "{pending}" "{target}" >nul 2>&1
488if errorlevel 1 (
489 move /Y "{old}" "{target}" >nul 2>&1
490 echo Update failed. Please close all editors and run: lean-ctx update
491 pause
492 exit /b 1
493)
494del /f "{old}" >nul 2>&1
495echo Updated successfully!
496del "%~f0" >nul 2>&1
497"#,
498 target = target_str,
499 pending = pending_str,
500 old = old_str,
501 );
502
503 let script_path = target_exe.with_file_name("lean-ctx-update.bat");
504 std::fs::write(&script_path, &script)
505 .map_err(|e| format!("Cannot write update script: {e}"))?;
506
507 let _ = std::process::Command::new("cmd")
508 .args(["/C", "start", "/MIN", &script_path.display().to_string()])
509 .spawn();
510
511 println!("\nThe binary is currently in use by your AI editor's MCP server.");
512 println!("A background update has been scheduled.");
513 println!(
514 "Close your editor (Cursor, VS Code, etc.) and the update will complete automatically."
515 );
516 println!("Or run the script manually: {}", script_path.display());
517
518 Ok(())
519}
520
521fn extract_from_tar_gz(data: &[u8]) -> Result<Vec<u8>, String> {
522 use flate2::read::GzDecoder;
523
524 let gz = GzDecoder::new(data);
525 let mut archive = tar::Archive::new(gz);
526
527 for entry in archive.entries().map_err(|e| e.to_string())? {
528 let mut entry = entry.map_err(|e| e.to_string())?;
529 let path = entry.path().map_err(|e| e.to_string())?;
530 let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
531
532 if name == "lean-ctx" || name == "lean-ctx.exe" {
533 let mut bytes = Vec::new();
534 entry.read_to_end(&mut bytes).map_err(|e| e.to_string())?;
535 return Ok(bytes);
536 }
537 }
538 Err("lean-ctx binary not found inside archive".to_string())
539}
540
541fn extract_from_zip(data: &[u8]) -> Result<Vec<u8>, String> {
542 use std::io::Cursor;
543
544 let cursor = Cursor::new(data);
545 let mut zip = zip::ZipArchive::new(cursor).map_err(|e| e.to_string())?;
546
547 for i in 0..zip.len() {
548 let mut file = zip.by_index(i).map_err(|e| e.to_string())?;
549 let name = file.name().to_string();
550 if name == "lean-ctx.exe" || name == "lean-ctx" {
551 let mut bytes = Vec::new();
552 file.read_to_end(&mut bytes).map_err(|e| e.to_string())?;
553 return Ok(bytes);
554 }
555 }
556 Err("lean-ctx binary not found inside zip archive".to_string())
557}
558
559fn detect_linux_libc() -> &'static str {
560 let output = std::process::Command::new("ldd").arg("--version").output();
561 if let Ok(out) = output {
562 let text = String::from_utf8_lossy(&out.stdout);
563 let stderr = String::from_utf8_lossy(&out.stderr);
564 let combined = format!("{text}{stderr}");
565 for line in combined.lines() {
566 if let Some(ver) = line.split_whitespace().last() {
567 let parts: Vec<&str> = ver.split('.').collect();
568 if parts.len() == 2 {
569 if let (Ok(major), Ok(minor)) =
570 (parts[0].parse::<u32>(), parts[1].parse::<u32>())
571 {
572 if major > 2 || (major == 2 && minor >= 35) {
573 return "gnu";
574 }
575 return "musl";
576 }
577 }
578 }
579 }
580 }
581 "musl"
582}
583
584fn platform_asset_name() -> String {
585 let os = std::env::consts::OS;
586 let arch = std::env::consts::ARCH;
587
588 let target = match (os, arch) {
589 ("macos", "aarch64") => "aarch64-apple-darwin".to_string(),
590 ("macos", "x86_64") => "x86_64-apple-darwin".to_string(),
591 ("linux", "x86_64") => format!("x86_64-unknown-linux-{}", detect_linux_libc()),
592 ("linux", "aarch64") => format!("aarch64-unknown-linux-{}", detect_linux_libc()),
593 ("windows", "x86_64") => "x86_64-pc-windows-msvc".to_string(),
594 _ => {
595 tracing::error!(
596 "Unsupported platform: {os}/{arch}. Download manually from \
597 https://github.com/yvgude/lean-ctx/releases/latest"
598 );
599 std::process::exit(1);
600 }
601 };
602
603 if os == "windows" {
604 format!("lean-ctx-{target}.zip")
605 } else {
606 format!("lean-ctx-{target}.tar.gz")
607 }
608}