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 let quiet = args.iter().any(|a| a == "--quiet");
10
11 if let Some(pos) = args.iter().position(|a| a == "--schedule") {
13 let sub = args.get(pos + 1).map_or("", String::as_str);
14 match sub {
15 "off" | "disable" => {
16 if let Err(e) = crate::core::update_scheduler::remove_schedule() {
17 eprintln!(" \x1b[31m✗\x1b[0m Failed to disable auto-updates: {e}");
18 std::process::exit(1);
19 }
20 crate::core::update_scheduler::set_auto_update(false, false, 6);
21 println!(" \x1b[32m✓\x1b[0m Auto-updates disabled.");
22 println!(" \x1b[2mRe-enable anytime: lean-ctx update --schedule\x1b[0m");
23 return;
24 }
25 "status" => {
26 let info = crate::core::update_scheduler::schedule_status();
27 println!();
28 println!(" {info}");
29 println!();
30 return;
31 }
32 "notify" => {
33 let cfg = crate::core::config::Config::load();
34 let hours = cfg.updates.check_interval_hours;
35 match crate::core::update_scheduler::install_schedule(hours) {
36 Ok(info) => {
37 crate::core::update_scheduler::set_auto_update(true, true, hours);
38 println!(" \x1b[32m✓\x1b[0m Update notifications enabled ({info})");
39 println!(" \x1b[2mYou'll be notified but updates won't install automatically.\x1b[0m");
40 }
41 Err(e) => {
42 eprintln!(" \x1b[31m✗\x1b[0m {e}");
43 std::process::exit(1);
44 }
45 }
46 return;
47 }
48 _ => {
49 let hours = if sub.is_empty() {
50 6
51 } else {
52 sub.trim_end_matches('h')
53 .parse::<u64>()
54 .unwrap_or(6)
55 .clamp(1, 168)
56 };
57 match crate::core::update_scheduler::install_schedule(hours) {
58 Ok(info) => {
59 crate::core::update_scheduler::set_auto_update(true, false, hours);
60 println!();
61 println!(" \x1b[32m✓\x1b[0m {info}");
62 println!(" \x1b[2mDisable anytime: lean-ctx update --schedule off\x1b[0m");
63 println!();
64 }
65 Err(e) => {
66 eprintln!(" \x1b[31m✗\x1b[0m Failed to enable auto-updates: {e}");
67 std::process::exit(1);
68 }
69 }
70 return;
71 }
72 }
73 }
74
75 if !quiet {
76 println!();
77 println!(" \x1b[1m◆ lean-ctx updater\x1b[0m \x1b[2mv{CURRENT_VERSION}\x1b[0m");
78 println!(" \x1b[2mChecking github.com/yvgude/lean-ctx …\x1b[0m");
79 }
80
81 let release = match fetch_latest_release() {
82 Ok(r) => r,
83 Err(e) => {
84 tracing::error!("Error fetching release info: {e}");
85 std::process::exit(1);
86 }
87 };
88
89 let latest_tag = if let Some(t) = release["tag_name"].as_str() {
90 t.trim_start_matches('v').to_string()
91 } else {
92 tracing::error!("Could not parse release tag from GitHub API.");
93 std::process::exit(1);
94 };
95
96 if latest_tag == CURRENT_VERSION {
97 if quiet {
98 return;
99 }
100 println!(" \x1b[32m✓\x1b[0m Already up to date (v{CURRENT_VERSION}).");
101 println!(" \x1b[2mIf your IDE still uses an older version, restart it to reconnect the MCP server.\x1b[0m");
102 println!();
103 if !check_only {
104 println!(" \x1b[36m\x1b[1mRefreshing setup (shell hook, MCP configs, rules)…\x1b[0m");
105 post_update_rewire();
106 println!();
107 }
108 return;
109 }
110
111 if !quiet {
112 println!(" Update available: v{CURRENT_VERSION} → \x1b[1;32mv{latest_tag}\x1b[0m");
113 }
114
115 if check_only {
116 println!("Run 'lean-ctx update' to install.");
117 return;
118 }
119
120 let asset_name = platform_asset_name();
121 if !quiet {
122 println!(" \x1b[2mDownloading {asset_name} …\x1b[0m");
123 }
124
125 let Some(download_url) = find_asset_url(&release, &asset_name) else {
126 tracing::error!("No binary found for this platform ({asset_name}). Download manually: https://github.com/yvgude/lean-ctx/releases/latest");
127 std::process::exit(1);
128 };
129
130 let bytes = match download_bytes(&download_url) {
131 Ok(b) => b,
132 Err(e) => {
133 tracing::error!("Download failed: {e}");
134 std::process::exit(1);
135 }
136 };
137
138 if let Err(e) = verify_download_integrity(&release, &asset_name, &bytes) {
139 if insecure {
140 tracing::warn!("Integrity verification failed: {e}");
141 tracing::warn!("Proceeding due to --insecure");
142 } else {
143 tracing::error!("Integrity verification failed: {e}");
144 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");
145 std::process::exit(1);
146 }
147 }
148
149 let current_exe = match std::env::current_exe() {
150 Ok(p) => p,
151 Err(e) => {
152 tracing::error!("Cannot locate current executable: {e}");
153 std::process::exit(1);
154 }
155 };
156
157 if let Err(e) = replace_binary(&bytes, &asset_name, ¤t_exe) {
158 tracing::error!("Failed to replace binary: {e}");
159 tracing::warn!("Continuing with a setup refresh so your wiring stays correct");
160 post_update_rewire();
161 std::process::exit(1);
162 }
163
164 if quiet {
165 println!(" lean-ctx v{CURRENT_VERSION} → v{latest_tag}");
166 } else {
167 println!();
168 println!(" \x1b[1;32m✓ Updated to lean-ctx v{latest_tag}\x1b[0m");
169 println!(" \x1b[2mBinary: {}\x1b[0m", current_exe.display());
170 }
171
172 if !quiet {
173 println!();
174 println!(" \x1b[36m\x1b[1mRefreshing setup (shell hook, MCP configs, rules)…\x1b[0m");
175 }
176 post_update_rewire();
177
178 if !quiet {
179 println!();
180 crate::terminal_ui::print_logo_animated();
181 println!();
182 println!(
183 " \x1b[33m\x1b[1m⟳ Restart your IDE and shell to activate the new version.\x1b[0m"
184 );
185 println!(
186 " \x1b[2mClose and re-open Cursor, VS Code, Claude Code, etc. completely.\x1b[0m"
187 );
188 println!(" \x1b[2mThe MCP server must reconnect to use the updated binary.\x1b[0m");
189 println!(
190 " \x1b[2mRun 'source ~/.zshrc' (or restart terminal) for updated shell aliases.\x1b[0m"
191 );
192 }
193 println!();
194
195 if !quiet
196 && !crate::core::update_scheduler::has_user_decided()
197 && std::io::IsTerminal::is_terminal(&std::io::stdin())
198 {
199 print!(" Want to get updates like this automatically? \x1b[1m[y/N]\x1b[0m ");
200 use std::io::Write;
201 std::io::stdout().flush().ok();
202 let mut input = String::new();
203 if std::io::stdin().read_line(&mut input).is_ok() {
204 let answer = input.trim().to_lowercase();
205 if answer == "y" || answer == "yes" {
206 let cfg = crate::core::config::Config::load();
207 let hours = cfg.updates.check_interval_hours;
208 match crate::core::update_scheduler::install_schedule(hours) {
209 Ok(info) => {
210 crate::core::update_scheduler::set_auto_update(true, false, hours);
211 println!(" \x1b[32m✓\x1b[0m {info}");
212 println!(" \x1b[2mDisable anytime: lean-ctx update --schedule off\x1b[0m");
213 }
214 Err(e) => println!(" \x1b[33m⚠\x1b[0m Could not set up scheduler: {e}"),
215 }
216 } else {
217 crate::core::update_scheduler::set_auto_update(false, false, 6);
218 println!(" \x1b[2m○ Skipped — enable later: lean-ctx update --schedule\x1b[0m");
219 }
220 }
221 }
222}
223
224fn verify_download_integrity(
225 release: &serde_json::Value,
226 asset_name: &str,
227 bytes: &[u8],
228) -> Result<(), String> {
229 #[cfg(not(feature = "secure-update"))]
230 {
231 let _ = (release, asset_name, bytes);
232 return Err("secure-update feature disabled (sha256 verification unavailable)".to_string());
233 }
234
235 #[cfg(feature = "secure-update")]
236 {
237 let computed = sha256_hex(bytes);
238
239 let Some((checksum_url, kind)) = find_checksum_asset_url(release, asset_name) else {
240 return Err(
241 "no checksum asset found for this release (expected SHA256SUMS or *.sha256)"
242 .to_string(),
243 );
244 };
245 let checksum_bytes = download_bytes(&checksum_url)?;
246 let checksum_text = String::from_utf8_lossy(&checksum_bytes).to_string();
247
248 let expected = match kind {
249 ChecksumAssetKind::SingleSha256 => parse_single_sha256(&checksum_text),
250 ChecksumAssetKind::Sha256Sums => parse_sha256sums(&checksum_text, asset_name),
251 }
252 .ok_or_else(|| format!("checksum file did not contain an entry for {asset_name}"))?;
253
254 if !constant_time_eq(computed.as_bytes(), expected.as_bytes()) {
255 return Err(format!(
256 "sha256 mismatch for {asset_name}: expected {expected}, got {computed}"
257 ));
258 }
259 Ok(())
260 }
261}
262
263#[derive(Debug, Clone, Copy)]
264enum ChecksumAssetKind {
265 Sha256Sums,
266 SingleSha256,
267}
268
269fn find_checksum_asset_url(
270 release: &serde_json::Value,
271 asset_name: &str,
272) -> Option<(String, ChecksumAssetKind)> {
273 let candidates = [
275 format!("{asset_name}.sha256"),
276 format!("{asset_name}.sha256.txt"),
277 "SHA256SUMS".to_string(),
278 "SHA256SUMS.txt".to_string(),
279 "sha256sums.txt".to_string(),
280 "checksums.txt".to_string(),
281 ];
282
283 for c in candidates {
284 if let Some(url) = find_asset_url(release, &c) {
285 let kind = if c.to_lowercase().contains("sha256sums")
286 || c.to_uppercase() == "SHA256SUMS"
287 || c.to_lowercase().contains("checksums")
288 {
289 ChecksumAssetKind::Sha256Sums
290 } else {
291 ChecksumAssetKind::SingleSha256
292 };
293 return Some((url, kind));
294 }
295 }
296 None
297}
298
299fn parse_single_sha256(text: &str) -> Option<String> {
300 let t = text.trim();
301 let first = t.split_whitespace().next().unwrap_or("").trim();
302 if first.len() == 64 && first.chars().all(|c| c.is_ascii_hexdigit()) {
303 Some(first.to_ascii_lowercase())
304 } else {
305 None
306 }
307}
308
309fn parse_sha256sums(text: &str, asset_name: &str) -> Option<String> {
310 for line in text.lines() {
311 let l = line.trim();
312 if l.is_empty() || l.starts_with('#') {
313 continue;
314 }
315 let mut parts = l.split_whitespace();
316 let hash = parts.next().unwrap_or("");
317 let file = parts.next().unwrap_or("");
318 if file == asset_name && hash.len() == 64 && hash.chars().all(|c| c.is_ascii_hexdigit()) {
319 return Some(hash.to_ascii_lowercase());
320 }
321 }
322 None
323}
324
325fn sha256_hex(bytes: &[u8]) -> String {
326 use sha2::{Digest, Sha256};
327 let mut h = Sha256::new();
328 h.update(bytes);
329 let out = h.finalize();
330 hex_lower(&out)
331}
332
333fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
334 if a.len() != b.len() {
335 return false;
336 }
337 a.iter()
338 .zip(b.iter())
339 .fold(0u8, |acc, (x, y)| acc | (x ^ y))
340 == 0
341}
342
343fn hex_lower(bytes: &[u8]) -> String {
344 const HEX: &[u8; 16] = b"0123456789abcdef";
345 let mut out = String::with_capacity(bytes.len() * 2);
346 for &b in bytes {
347 out.push(HEX[(b >> 4) as usize] as char);
348 out.push(HEX[(b & 0x0f) as usize] as char);
349 }
350 out
351}
352
353fn post_update_rewire() {
354 let mut cfg = crate::core::config::Config::load();
355
356 if cfg.proxy_enabled.is_none() && crate::proxy_autostart::is_installed() {
357 cfg.proxy_enabled = Some(true);
358 let _ = cfg.save();
359 eprintln!(" \u{2139} Proxy was already active \u{2014} keeping enabled.");
360 eprintln!(" Disable anytime: lean-ctx proxy disable");
361 }
362
363 let proxy_active = cfg.proxy_enabled == Some(true);
364
365 if proxy_active {
369 restart_proxy_if_running();
370 wait_for_proxy_health(crate::proxy_setup::default_port());
371 }
372
373 let opts = crate::setup::SetupOptions {
376 non_interactive: true,
377 yes: true,
378 fix: true,
379 skip_proxy: !proxy_active,
380 ..Default::default()
381 };
382 if let Err(e) = crate::setup::run_setup_with_options(opts) {
383 tracing::error!("Setup refresh error: {e}");
384 }
385}
386
387fn wait_for_proxy_health(port: u16) {
388 let max_attempts = 20;
389 for i in 0..max_attempts {
390 if is_proxy_reachable(port) {
391 println!(" \x1b[32m✓\x1b[0m Proxy healthy on port {port}");
392 return;
393 }
394 if i == 0 {
395 print!(" \x1b[2mWaiting for proxy to become healthy");
396 }
397 print!(".");
398 use std::io::Write;
399 std::io::stdout().flush().ok();
400 std::thread::sleep(std::time::Duration::from_millis(500));
401 }
402 println!();
403 eprintln!(
404 " \x1b[33m⚠\x1b[0m Proxy did not respond within {}s — writing env vars anyway",
405 max_attempts / 2
406 );
407 eprintln!(" If Claude Code shows connection errors, run: lean-ctx proxy start");
408}
409
410fn restart_proxy_if_running() {
411 let port = crate::proxy_setup::default_port();
412
413 if restart_managed_proxy() {
414 return;
415 }
416
417 if is_proxy_reachable(port) {
418 println!(
419 " \x1b[33m⟳\x1b[0m Proxy running on port {port} — restart it to use the new binary:"
420 );
421 println!(" \x1b[1mlean-ctx proxy start --port={port}\x1b[0m");
422 }
423}
424
425fn restart_managed_proxy() -> bool {
428 #[cfg(target_os = "macos")]
429 {
430 let plist_path = dirs::home_dir()
431 .unwrap_or_default()
432 .join("Library/LaunchAgents/com.leanctx.proxy.plist");
433 if plist_path.exists() {
434 let plist_str = plist_path.to_string_lossy().to_string();
435 let _ = std::process::Command::new("launchctl")
436 .args(["unload", &plist_str])
437 .output();
438 let result = std::process::Command::new("launchctl")
439 .args(["load", &plist_str])
440 .output();
441 match result {
442 Ok(o) if o.status.success() => {
443 println!(" \x1b[32m✓\x1b[0m Proxy restarted (LaunchAgent)");
444 }
445 _ => {
446 println!(" \x1b[33m⚠\x1b[0m Could not restart proxy LaunchAgent");
447 }
448 }
449 return true;
450 }
451 }
452
453 #[cfg(target_os = "linux")]
454 {
455 let service_path = dirs::home_dir()
456 .unwrap_or_default()
457 .join(".config/systemd/user/lean-ctx-proxy.service");
458 if service_path.exists() {
459 let result = std::process::Command::new("systemctl")
460 .args(["--user", "restart", "lean-ctx-proxy"])
461 .output();
462 match result {
463 Ok(o) if o.status.success() => {
464 println!(" \x1b[32m✓\x1b[0m Proxy restarted (systemd)");
465 }
466 _ => {
467 println!(" \x1b[33m⚠\x1b[0m Could not restart proxy systemd service");
468 }
469 }
470 return true;
471 }
472 }
473
474 false
475}
476
477fn is_proxy_reachable(port: u16) -> bool {
478 ureq::get(&format!("http://127.0.0.1:{port}/health"))
479 .call()
480 .is_ok()
481}
482
483fn fetch_latest_release() -> Result<serde_json::Value, String> {
484 let response = ureq::get(GITHUB_API_RELEASES)
485 .header("User-Agent", &format!("lean-ctx/{CURRENT_VERSION}"))
486 .header("Accept", "application/vnd.github.v3+json")
487 .call()
488 .map_err(|e| e.to_string())?;
489
490 response
491 .into_body()
492 .read_to_string()
493 .map_err(|e| e.to_string())
494 .and_then(|s| serde_json::from_str(&s).map_err(|e| e.to_string()))
495}
496
497fn find_asset_url(release: &serde_json::Value, asset_name: &str) -> Option<String> {
498 release["assets"]
499 .as_array()?
500 .iter()
501 .find(|a| a["name"].as_str() == Some(asset_name))
502 .and_then(|a| a["browser_download_url"].as_str())
503 .map(std::string::ToString::to_string)
504}
505
506fn download_bytes(url: &str) -> Result<Vec<u8>, String> {
507 let response = ureq::get(url)
508 .header("User-Agent", &format!("lean-ctx/{CURRENT_VERSION}"))
509 .call()
510 .map_err(|e| e.to_string())?;
511
512 let mut bytes = Vec::new();
513 response
514 .into_body()
515 .into_reader()
516 .read_to_end(&mut bytes)
517 .map_err(|e| e.to_string())?;
518 Ok(bytes)
519}
520
521fn replace_binary(
522 archive_bytes: &[u8],
523 asset_name: &str,
524 current_exe: &std::path::Path,
525) -> Result<(), String> {
526 let binary_bytes = if std::path::Path::new(asset_name)
527 .extension()
528 .is_some_and(|e| e.eq_ignore_ascii_case("zip"))
529 {
530 extract_from_zip(archive_bytes)?
531 } else {
532 extract_from_tar_gz(archive_bytes)?
533 };
534
535 let tmp_path = current_exe.with_extension("tmp");
536 std::fs::write(&tmp_path, &binary_bytes).map_err(|e| e.to_string())?;
537
538 #[cfg(unix)]
539 {
540 use std::os::unix::fs::PermissionsExt;
541 let _ = std::fs::set_permissions(&tmp_path, std::fs::Permissions::from_mode(0o755));
542 }
543
544 #[cfg(windows)]
549 {
550 let old_path = current_exe.with_extension("old.exe");
551 let _ = std::fs::remove_file(&old_path);
552
553 match std::fs::rename(current_exe, &old_path) {
554 Ok(()) => {
555 if let Err(e) = std::fs::rename(&tmp_path, current_exe) {
556 let _ = std::fs::rename(&old_path, current_exe);
557 let _ = std::fs::remove_file(&tmp_path);
558 return Err(format!("Cannot place new binary: {e}"));
559 }
560 let _ = std::fs::remove_file(&old_path);
561 return Ok(());
562 }
563 Err(_) => {
564 eprintln!("\nBinary is locked. Stopping managed lean-ctx processes...");
566 stop_managed_windows_processes();
567
568 std::thread::sleep(std::time::Duration::from_millis(1500));
570
571 let _ = std::fs::remove_file(&old_path);
573 match std::fs::rename(current_exe, &old_path) {
574 Ok(()) => {
575 if let Err(e) = std::fs::rename(&tmp_path, current_exe) {
576 let _ = std::fs::rename(&old_path, current_exe);
577 let _ = std::fs::remove_file(&tmp_path);
578 return Err(format!("Cannot place new binary: {e}"));
579 }
580 let _ = std::fs::remove_file(&old_path);
581 return Ok(());
582 }
583 Err(_) => {
584 print_blocking_processes(current_exe);
586 return deferred_windows_update(&tmp_path, current_exe);
587 }
588 }
589 }
590 }
591 }
592
593 #[cfg(not(windows))]
594 {
595 #[cfg(target_os = "macos")]
600 {
601 let _ = std::fs::remove_file(current_exe);
602 }
603
604 std::fs::rename(&tmp_path, current_exe).map_err(|e| {
605 let _ = std::fs::remove_file(&tmp_path);
606 format!("Cannot replace binary (permission denied?): {e}")
607 })?;
608
609 #[cfg(target_os = "macos")]
610 {
611 let _ = std::process::Command::new("codesign")
612 .args(["--force", "-s", "-", ¤t_exe.display().to_string()])
613 .output();
614 }
615
616 Ok(())
617 }
618}
619
620#[cfg(windows)]
623fn stop_managed_windows_processes() {
624 let stop_result = std::process::Command::new("lean-ctx").arg("stop").output();
626
627 match stop_result {
628 Ok(out) if out.status.success() => {
629 eprintln!(" Managed processes stopped.");
630 }
631 _ => {
632 for pattern in &["proxy start", "serve "] {
635 let _ = std::process::Command::new("taskkill")
636 .args([
637 "/F",
638 "/FI",
639 &format!("WINDOWTITLE eq *{pattern}*"),
640 "/IM",
641 "lean-ctx.exe",
642 ])
643 .output();
644 }
645 eprintln!(" Attempted to stop lean-ctx processes via taskkill.");
646 }
647 }
648}
649
650#[cfg(windows)]
652fn print_blocking_processes(target_exe: &std::path::Path) {
653 let target_name = target_exe
654 .file_name()
655 .and_then(|n| n.to_str())
656 .unwrap_or("lean-ctx.exe");
657
658 let output = std::process::Command::new("tasklist")
659 .args([
660 "/FI",
661 &format!("IMAGENAME eq {target_name}"),
662 "/V",
663 "/FO",
664 "CSV",
665 ])
666 .output();
667
668 if let Ok(out) = output {
669 let stdout = String::from_utf8_lossy(&out.stdout);
670 let lines: Vec<&str> = stdout.lines().skip(1).collect(); if !lines.is_empty() {
672 eprintln!("\n Blocking lean-ctx processes:");
673 for line in &lines {
674 let fields: Vec<&str> = line.split(',').collect();
676 if fields.len() >= 2 {
677 let pid = fields[1].trim_matches('"');
678 eprintln!(" PID {pid}");
679 }
680 }
681 eprintln!("\n To stop manually: taskkill /F /PID <pid> (or close your editor)");
682 }
683 }
684}
685
686#[cfg(windows)]
690fn deferred_windows_update(
691 staged_path: &std::path::Path,
692 target_exe: &std::path::Path,
693) -> Result<(), String> {
694 let pending_path = target_exe.with_file_name("lean-ctx-pending.exe");
695 std::fs::rename(staged_path, &pending_path).map_err(|e| {
696 let _ = std::fs::remove_file(staged_path);
697 format!("Cannot stage update: {e}")
698 })?;
699
700 let target_str = target_exe.display().to_string();
701 let pending_str = pending_path.display().to_string();
702 let old_str = target_exe.with_extension("old.exe").display().to_string();
703 let max_retries = 60;
704
705 let script = generate_deferred_bat_script(&target_str, &pending_str, &old_str, max_retries);
706
707 let script_path = target_exe.with_file_name("lean-ctx-update.bat");
708 std::fs::write(&script_path, &script)
709 .map_err(|e| format!("Cannot write update script: {e}"))?;
710
711 let _ = std::process::Command::new("cmd")
712 .args(["/C", "start", "/MIN", &script_path.display().to_string()])
713 .spawn();
714
715 println!("\nThe binary is still in use (likely by your editor's MCP server).");
716 println!("A background update has been scheduled (timeout: {max_retries}s).");
717 println!("Close your editor and the update will complete automatically.");
718 println!("\nIf it times out, run: lean-ctx update");
719 println!("Update script: {}", script_path.display());
720
721 Ok(())
722}
723
724fn extract_from_tar_gz(data: &[u8]) -> Result<Vec<u8>, String> {
725 use flate2::read::GzDecoder;
726
727 let gz = GzDecoder::new(data);
728 let mut archive = tar::Archive::new(gz);
729
730 for entry in archive.entries().map_err(|e| e.to_string())? {
731 let mut entry = entry.map_err(|e| e.to_string())?;
732 let path = entry.path().map_err(|e| e.to_string())?;
733 let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
734
735 if name == "lean-ctx" || name == "lean-ctx.exe" {
736 let mut bytes = Vec::new();
737 entry.read_to_end(&mut bytes).map_err(|e| e.to_string())?;
738 return Ok(bytes);
739 }
740 }
741 Err("lean-ctx binary not found inside archive".to_string())
742}
743
744fn extract_from_zip(data: &[u8]) -> Result<Vec<u8>, String> {
745 use std::io::Cursor;
746
747 let cursor = Cursor::new(data);
748 let mut zip = zip::ZipArchive::new(cursor).map_err(|e| e.to_string())?;
749
750 for i in 0..zip.len() {
751 let mut file = zip.by_index(i).map_err(|e| e.to_string())?;
752 let name = file.name().to_string();
753 if name == "lean-ctx.exe" || name == "lean-ctx" {
754 let mut bytes = Vec::new();
755 file.read_to_end(&mut bytes).map_err(|e| e.to_string())?;
756 return Ok(bytes);
757 }
758 }
759 Err("lean-ctx binary not found inside zip archive".to_string())
760}
761
762#[cfg(any(windows, test))]
764fn generate_deferred_bat_script(
765 target: &str,
766 pending: &str,
767 old: &str,
768 max_retries: u32,
769) -> String {
770 format!(
771 r#"@echo off
772setlocal
773set "RETRIES=0"
774set "MAX_RETRIES={max_retries}"
775
776echo lean-ctx update: waiting for binary to be released (timeout: %MAX_RETRIES%s)...
777echo.
778echo Blocking processes:
779tasklist /FI "IMAGENAME eq lean-ctx.exe" /V /NH 2>nul
780echo.
781echo Close your editor (Cursor, VS Code, etc.) to release the binary,
782echo or stop manually: lean-ctx stop
783echo.
784
785:retry
786if %RETRIES% GEQ %MAX_RETRIES% goto timeout
787set /a RETRIES+=1
788timeout /t 1 /nobreak >nul
789move /Y "{target}" "{old}" >nul 2>&1
790if errorlevel 1 (
791 if %RETRIES% EQU 10 echo Still waiting... (%RETRIES%/%MAX_RETRIES%s)
792 if %RETRIES% EQU 30 echo Still waiting... (%RETRIES%/%MAX_RETRIES%s) — try closing your editor
793 if %RETRIES% EQU 50 echo Still waiting... (%RETRIES%/%MAX_RETRIES%s) — timeout approaching
794 goto retry
795)
796
797move /Y "{pending}" "{target}" >nul 2>&1
798if errorlevel 1 (
799 move /Y "{old}" "{target}" >nul 2>&1
800 echo.
801 echo Update failed: could not place new binary.
802 echo Please close all editors and run: lean-ctx update
803 pause
804 exit /b 1
805)
806del /f "{old}" >nul 2>&1
807echo.
808echo Updated successfully!
809goto cleanup
810
811:timeout
812echo.
813echo Update timed out after %MAX_RETRIES% seconds.
814echo The new binary is staged at: {pending}
815echo.
816echo To complete the update manually:
817echo 1. Close your editor (Cursor, VS Code, etc.)
818echo 2. Run: move /Y "{pending}" "{target}"
819echo.
820echo Or run: lean-ctx update --force
821echo.
822pause
823exit /b 1
824
825:cleanup
826del "%~f0" >nul 2>&1
827"#
828 )
829}
830
831fn detect_linux_libc() -> &'static str {
832 let output = std::process::Command::new("ldd").arg("--version").output();
833 if let Ok(out) = output {
834 let text = String::from_utf8_lossy(&out.stdout);
835 let stderr = String::from_utf8_lossy(&out.stderr);
836 let combined = format!("{text}{stderr}");
837 for line in combined.lines() {
838 if let Some(ver) = line.split_whitespace().last() {
839 let parts: Vec<&str> = ver.split('.').collect();
840 if parts.len() == 2 {
841 if let (Ok(major), Ok(minor)) =
842 (parts[0].parse::<u32>(), parts[1].parse::<u32>())
843 {
844 if major > 2 || (major == 2 && minor >= 35) {
845 return "gnu";
846 }
847 return "musl";
848 }
849 }
850 }
851 }
852 }
853 "musl"
854}
855
856fn platform_asset_name() -> String {
857 let os = std::env::consts::OS;
858 let arch = std::env::consts::ARCH;
859
860 let target = match (os, arch) {
861 ("macos", "aarch64") => "aarch64-apple-darwin".to_string(),
862 ("macos", "x86_64") => "x86_64-apple-darwin".to_string(),
863 ("linux", "x86_64") => format!("x86_64-unknown-linux-{}", detect_linux_libc()),
864 ("linux", "aarch64") => format!("aarch64-unknown-linux-{}", detect_linux_libc()),
865 ("windows", "x86_64") => "x86_64-pc-windows-msvc".to_string(),
866 _ => {
867 tracing::error!(
868 "Unsupported platform: {os}/{arch}. Download manually from \
869 https://github.com/yvgude/lean-ctx/releases/latest"
870 );
871 std::process::exit(1);
872 }
873 };
874
875 if os == "windows" {
876 format!("lean-ctx-{target}.zip")
877 } else {
878 format!("lean-ctx-{target}.tar.gz")
879 }
880}
881
882#[cfg(test)]
883mod tests {
884 use super::*;
885
886 #[test]
887 fn bat_script_has_timeout_guard() {
888 let script = generate_deferred_bat_script(
889 r"C:\bin\lean-ctx.exe",
890 r"C:\bin\lean-ctx-pending.exe",
891 r"C:\bin\lean-ctx.old.exe",
892 60,
893 );
894 assert!(script.contains("set \"MAX_RETRIES=60\""));
895 assert!(script.contains(":timeout"), "must have timeout label");
896 assert!(
897 script.contains("timed out after"),
898 "must show timeout message"
899 );
900 }
901
902 #[test]
903 fn bat_script_shows_blocking_processes() {
904 let script = generate_deferred_bat_script("t", "p", "o", 30);
905 assert!(script.contains("tasklist"), "must list blocking processes");
906 assert!(
907 script.contains("lean-ctx stop"),
908 "must suggest lean-ctx stop"
909 );
910 }
911
912 #[test]
913 fn bat_script_has_progress_indicators() {
914 let script = generate_deferred_bat_script("t", "p", "o", 60);
915 assert!(script.contains("Still waiting"));
916 assert!(script.contains("RETRIES"));
917 }
918
919 #[test]
920 fn bat_script_provides_manual_recovery() {
921 let script = generate_deferred_bat_script(
922 r"C:\bin\lean-ctx.exe",
923 r"C:\bin\lean-ctx-pending.exe",
924 r"C:\bin\lean-ctx.old.exe",
925 60,
926 );
927 assert!(script.contains(r"move /Y"));
928 assert!(
929 script.contains("lean-ctx-pending.exe"),
930 "must show where the pending binary is"
931 );
932 assert!(
933 script.contains("lean-ctx update"),
934 "must suggest re-running update"
935 );
936 }
937
938 #[test]
939 fn bat_script_no_infinite_loop() {
940 let script = generate_deferred_bat_script("t", "p", "o", 10);
941 assert!(script.contains("if %RETRIES% GEQ %MAX_RETRIES% goto timeout"));
942 assert!(
943 !script.contains(":retry\ntimeout"),
944 "must not be an infinite loop"
945 );
946 }
947}