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() {
359 cfg.proxy_enabled = Some(true);
360 let _ = cfg.save();
361 eprintln!(" \u{2139} Proxy was already active \u{2014} keeping enabled.");
362 eprintln!(" Disable anytime: lean-ctx proxy disable");
363 }
364
365 let skip_proxy = cfg.proxy_enabled != Some(true);
366
367 let opts = crate::setup::SetupOptions {
368 non_interactive: true,
369 yes: true,
370 fix: true,
371 skip_proxy,
372 ..Default::default()
373 };
374 if let Err(e) = crate::setup::run_setup_with_options(opts) {
375 tracing::error!("Setup refresh error: {e}");
376 }
377
378 if cfg.proxy_enabled == Some(true) {
379 restart_proxy_if_running();
380 }
381}
382
383fn restart_proxy_if_running() {
384 let port = crate::proxy_setup::default_port();
385
386 if restart_managed_proxy() {
387 return;
388 }
389
390 if is_proxy_reachable(port) {
391 println!(
392 " \x1b[33m⟳\x1b[0m Proxy running on port {port} — restart it to use the new binary:"
393 );
394 println!(" \x1b[1mlean-ctx proxy start --port={port}\x1b[0m");
395 }
396}
397
398fn restart_managed_proxy() -> bool {
401 #[cfg(target_os = "macos")]
402 {
403 let plist_path = dirs::home_dir()
404 .unwrap_or_default()
405 .join("Library/LaunchAgents/com.leanctx.proxy.plist");
406 if plist_path.exists() {
407 let plist_str = plist_path.to_string_lossy().to_string();
408 let _ = std::process::Command::new("launchctl")
409 .args(["unload", &plist_str])
410 .output();
411 let result = std::process::Command::new("launchctl")
412 .args(["load", &plist_str])
413 .output();
414 match result {
415 Ok(o) if o.status.success() => {
416 println!(" \x1b[32m✓\x1b[0m Proxy restarted (LaunchAgent)");
417 }
418 _ => {
419 println!(" \x1b[33m⚠\x1b[0m Could not restart proxy LaunchAgent");
420 }
421 }
422 return true;
423 }
424 }
425
426 #[cfg(target_os = "linux")]
427 {
428 let service_path = dirs::home_dir()
429 .unwrap_or_default()
430 .join(".config/systemd/user/lean-ctx-proxy.service");
431 if service_path.exists() {
432 let result = std::process::Command::new("systemctl")
433 .args(["--user", "restart", "lean-ctx-proxy"])
434 .output();
435 match result {
436 Ok(o) if o.status.success() => {
437 println!(" \x1b[32m✓\x1b[0m Proxy restarted (systemd)");
438 }
439 _ => {
440 println!(" \x1b[33m⚠\x1b[0m Could not restart proxy systemd service");
441 }
442 }
443 return true;
444 }
445 }
446
447 false
448}
449
450fn is_proxy_reachable(port: u16) -> bool {
451 ureq::get(&format!("http://127.0.0.1:{port}/health"))
452 .call()
453 .is_ok()
454}
455
456fn fetch_latest_release() -> Result<serde_json::Value, String> {
457 let response = ureq::get(GITHUB_API_RELEASES)
458 .header("User-Agent", &format!("lean-ctx/{CURRENT_VERSION}"))
459 .header("Accept", "application/vnd.github.v3+json")
460 .call()
461 .map_err(|e| e.to_string())?;
462
463 response
464 .into_body()
465 .read_to_string()
466 .map_err(|e| e.to_string())
467 .and_then(|s| serde_json::from_str(&s).map_err(|e| e.to_string()))
468}
469
470fn find_asset_url(release: &serde_json::Value, asset_name: &str) -> Option<String> {
471 release["assets"]
472 .as_array()?
473 .iter()
474 .find(|a| a["name"].as_str() == Some(asset_name))
475 .and_then(|a| a["browser_download_url"].as_str())
476 .map(std::string::ToString::to_string)
477}
478
479fn download_bytes(url: &str) -> Result<Vec<u8>, String> {
480 let response = ureq::get(url)
481 .header("User-Agent", &format!("lean-ctx/{CURRENT_VERSION}"))
482 .call()
483 .map_err(|e| e.to_string())?;
484
485 let mut bytes = Vec::new();
486 response
487 .into_body()
488 .into_reader()
489 .read_to_end(&mut bytes)
490 .map_err(|e| e.to_string())?;
491 Ok(bytes)
492}
493
494fn replace_binary(
495 archive_bytes: &[u8],
496 asset_name: &str,
497 current_exe: &std::path::Path,
498) -> Result<(), String> {
499 let binary_bytes = if std::path::Path::new(asset_name)
500 .extension()
501 .is_some_and(|e| e.eq_ignore_ascii_case("zip"))
502 {
503 extract_from_zip(archive_bytes)?
504 } else {
505 extract_from_tar_gz(archive_bytes)?
506 };
507
508 let tmp_path = current_exe.with_extension("tmp");
509 std::fs::write(&tmp_path, &binary_bytes).map_err(|e| e.to_string())?;
510
511 #[cfg(unix)]
512 {
513 use std::os::unix::fs::PermissionsExt;
514 let _ = std::fs::set_permissions(&tmp_path, std::fs::Permissions::from_mode(0o755));
515 }
516
517 #[cfg(windows)]
522 {
523 let old_path = current_exe.with_extension("old.exe");
524 let _ = std::fs::remove_file(&old_path);
525
526 match std::fs::rename(current_exe, &old_path) {
527 Ok(()) => {
528 if let Err(e) = std::fs::rename(&tmp_path, current_exe) {
529 let _ = std::fs::rename(&old_path, current_exe);
530 let _ = std::fs::remove_file(&tmp_path);
531 return Err(format!("Cannot place new binary: {e}"));
532 }
533 let _ = std::fs::remove_file(&old_path);
534 return Ok(());
535 }
536 Err(_) => {
537 eprintln!("\nBinary is locked. Stopping managed lean-ctx processes...");
539 stop_managed_windows_processes();
540
541 std::thread::sleep(std::time::Duration::from_millis(1500));
543
544 let _ = std::fs::remove_file(&old_path);
546 match std::fs::rename(current_exe, &old_path) {
547 Ok(()) => {
548 if let Err(e) = std::fs::rename(&tmp_path, current_exe) {
549 let _ = std::fs::rename(&old_path, current_exe);
550 let _ = std::fs::remove_file(&tmp_path);
551 return Err(format!("Cannot place new binary: {e}"));
552 }
553 let _ = std::fs::remove_file(&old_path);
554 return Ok(());
555 }
556 Err(_) => {
557 print_blocking_processes(current_exe);
559 return deferred_windows_update(&tmp_path, current_exe);
560 }
561 }
562 }
563 }
564 }
565
566 #[cfg(not(windows))]
567 {
568 #[cfg(target_os = "macos")]
573 {
574 let _ = std::fs::remove_file(current_exe);
575 }
576
577 std::fs::rename(&tmp_path, current_exe).map_err(|e| {
578 let _ = std::fs::remove_file(&tmp_path);
579 format!("Cannot replace binary (permission denied?): {e}")
580 })?;
581
582 #[cfg(target_os = "macos")]
583 {
584 let _ = std::process::Command::new("codesign")
585 .args(["--force", "-s", "-", ¤t_exe.display().to_string()])
586 .output();
587 }
588
589 Ok(())
590 }
591}
592
593#[cfg(windows)]
596fn stop_managed_windows_processes() {
597 let stop_result = std::process::Command::new("lean-ctx").arg("stop").output();
599
600 match stop_result {
601 Ok(out) if out.status.success() => {
602 eprintln!(" Managed processes stopped.");
603 }
604 _ => {
605 for pattern in &["proxy start", "serve "] {
608 let _ = std::process::Command::new("taskkill")
609 .args([
610 "/F",
611 "/FI",
612 &format!("WINDOWTITLE eq *{pattern}*"),
613 "/IM",
614 "lean-ctx.exe",
615 ])
616 .output();
617 }
618 eprintln!(" Attempted to stop lean-ctx processes via taskkill.");
619 }
620 }
621}
622
623#[cfg(windows)]
625fn print_blocking_processes(target_exe: &std::path::Path) {
626 let target_name = target_exe
627 .file_name()
628 .and_then(|n| n.to_str())
629 .unwrap_or("lean-ctx.exe");
630
631 let output = std::process::Command::new("tasklist")
632 .args([
633 "/FI",
634 &format!("IMAGENAME eq {target_name}"),
635 "/V",
636 "/FO",
637 "CSV",
638 ])
639 .output();
640
641 if let Ok(out) = output {
642 let stdout = String::from_utf8_lossy(&out.stdout);
643 let lines: Vec<&str> = stdout.lines().skip(1).collect(); if !lines.is_empty() {
645 eprintln!("\n Blocking lean-ctx processes:");
646 for line in &lines {
647 let fields: Vec<&str> = line.split(',').collect();
649 if fields.len() >= 2 {
650 let pid = fields[1].trim_matches('"');
651 eprintln!(" PID {pid}");
652 }
653 }
654 eprintln!("\n To stop manually: taskkill /F /PID <pid> (or close your editor)");
655 }
656 }
657}
658
659#[cfg(windows)]
663fn deferred_windows_update(
664 staged_path: &std::path::Path,
665 target_exe: &std::path::Path,
666) -> Result<(), String> {
667 let pending_path = target_exe.with_file_name("lean-ctx-pending.exe");
668 std::fs::rename(staged_path, &pending_path).map_err(|e| {
669 let _ = std::fs::remove_file(staged_path);
670 format!("Cannot stage update: {e}")
671 })?;
672
673 let target_str = target_exe.display().to_string();
674 let pending_str = pending_path.display().to_string();
675 let old_str = target_exe.with_extension("old.exe").display().to_string();
676 let max_retries = 60;
677
678 let script = generate_deferred_bat_script(&target_str, &pending_str, &old_str, max_retries);
679
680 let script_path = target_exe.with_file_name("lean-ctx-update.bat");
681 std::fs::write(&script_path, &script)
682 .map_err(|e| format!("Cannot write update script: {e}"))?;
683
684 let _ = std::process::Command::new("cmd")
685 .args(["/C", "start", "/MIN", &script_path.display().to_string()])
686 .spawn();
687
688 println!("\nThe binary is still in use (likely by your editor's MCP server).");
689 println!("A background update has been scheduled (timeout: {max_retries}s).");
690 println!("Close your editor and the update will complete automatically.");
691 println!("\nIf it times out, run: lean-ctx update");
692 println!("Update script: {}", script_path.display());
693
694 Ok(())
695}
696
697fn extract_from_tar_gz(data: &[u8]) -> Result<Vec<u8>, String> {
698 use flate2::read::GzDecoder;
699
700 let gz = GzDecoder::new(data);
701 let mut archive = tar::Archive::new(gz);
702
703 for entry in archive.entries().map_err(|e| e.to_string())? {
704 let mut entry = entry.map_err(|e| e.to_string())?;
705 let path = entry.path().map_err(|e| e.to_string())?;
706 let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
707
708 if name == "lean-ctx" || name == "lean-ctx.exe" {
709 let mut bytes = Vec::new();
710 entry.read_to_end(&mut bytes).map_err(|e| e.to_string())?;
711 return Ok(bytes);
712 }
713 }
714 Err("lean-ctx binary not found inside archive".to_string())
715}
716
717fn extract_from_zip(data: &[u8]) -> Result<Vec<u8>, String> {
718 use std::io::Cursor;
719
720 let cursor = Cursor::new(data);
721 let mut zip = zip::ZipArchive::new(cursor).map_err(|e| e.to_string())?;
722
723 for i in 0..zip.len() {
724 let mut file = zip.by_index(i).map_err(|e| e.to_string())?;
725 let name = file.name().to_string();
726 if name == "lean-ctx.exe" || name == "lean-ctx" {
727 let mut bytes = Vec::new();
728 file.read_to_end(&mut bytes).map_err(|e| e.to_string())?;
729 return Ok(bytes);
730 }
731 }
732 Err("lean-ctx binary not found inside zip archive".to_string())
733}
734
735#[cfg(any(windows, test))]
737fn generate_deferred_bat_script(
738 target: &str,
739 pending: &str,
740 old: &str,
741 max_retries: u32,
742) -> String {
743 format!(
744 r#"@echo off
745setlocal
746set "RETRIES=0"
747set "MAX_RETRIES={max_retries}"
748
749echo lean-ctx update: waiting for binary to be released (timeout: %MAX_RETRIES%s)...
750echo.
751echo Blocking processes:
752tasklist /FI "IMAGENAME eq lean-ctx.exe" /V /NH 2>nul
753echo.
754echo Close your editor (Cursor, VS Code, etc.) to release the binary,
755echo or stop manually: lean-ctx stop
756echo.
757
758:retry
759if %RETRIES% GEQ %MAX_RETRIES% goto timeout
760set /a RETRIES+=1
761timeout /t 1 /nobreak >nul
762move /Y "{target}" "{old}" >nul 2>&1
763if errorlevel 1 (
764 if %RETRIES% EQU 10 echo Still waiting... (%RETRIES%/%MAX_RETRIES%s)
765 if %RETRIES% EQU 30 echo Still waiting... (%RETRIES%/%MAX_RETRIES%s) — try closing your editor
766 if %RETRIES% EQU 50 echo Still waiting... (%RETRIES%/%MAX_RETRIES%s) — timeout approaching
767 goto retry
768)
769
770move /Y "{pending}" "{target}" >nul 2>&1
771if errorlevel 1 (
772 move /Y "{old}" "{target}" >nul 2>&1
773 echo.
774 echo Update failed: could not place new binary.
775 echo Please close all editors and run: lean-ctx update
776 pause
777 exit /b 1
778)
779del /f "{old}" >nul 2>&1
780echo.
781echo Updated successfully!
782goto cleanup
783
784:timeout
785echo.
786echo Update timed out after %MAX_RETRIES% seconds.
787echo The new binary is staged at: {pending}
788echo.
789echo To complete the update manually:
790echo 1. Close your editor (Cursor, VS Code, etc.)
791echo 2. Run: move /Y "{pending}" "{target}"
792echo.
793echo Or run: lean-ctx update --force
794echo.
795pause
796exit /b 1
797
798:cleanup
799del "%~f0" >nul 2>&1
800"#
801 )
802}
803
804fn detect_linux_libc() -> &'static str {
805 let output = std::process::Command::new("ldd").arg("--version").output();
806 if let Ok(out) = output {
807 let text = String::from_utf8_lossy(&out.stdout);
808 let stderr = String::from_utf8_lossy(&out.stderr);
809 let combined = format!("{text}{stderr}");
810 for line in combined.lines() {
811 if let Some(ver) = line.split_whitespace().last() {
812 let parts: Vec<&str> = ver.split('.').collect();
813 if parts.len() == 2 {
814 if let (Ok(major), Ok(minor)) =
815 (parts[0].parse::<u32>(), parts[1].parse::<u32>())
816 {
817 if major > 2 || (major == 2 && minor >= 35) {
818 return "gnu";
819 }
820 return "musl";
821 }
822 }
823 }
824 }
825 }
826 "musl"
827}
828
829fn platform_asset_name() -> String {
830 let os = std::env::consts::OS;
831 let arch = std::env::consts::ARCH;
832
833 let target = match (os, arch) {
834 ("macos", "aarch64") => "aarch64-apple-darwin".to_string(),
835 ("macos", "x86_64") => "x86_64-apple-darwin".to_string(),
836 ("linux", "x86_64") => format!("x86_64-unknown-linux-{}", detect_linux_libc()),
837 ("linux", "aarch64") => format!("aarch64-unknown-linux-{}", detect_linux_libc()),
838 ("windows", "x86_64") => "x86_64-pc-windows-msvc".to_string(),
839 _ => {
840 tracing::error!(
841 "Unsupported platform: {os}/{arch}. Download manually from \
842 https://github.com/yvgude/lean-ctx/releases/latest"
843 );
844 std::process::exit(1);
845 }
846 };
847
848 if os == "windows" {
849 format!("lean-ctx-{target}.zip")
850 } else {
851 format!("lean-ctx-{target}.tar.gz")
852 }
853}
854
855#[cfg(test)]
856mod tests {
857 use super::*;
858
859 #[test]
860 fn bat_script_has_timeout_guard() {
861 let script = generate_deferred_bat_script(
862 r"C:\bin\lean-ctx.exe",
863 r"C:\bin\lean-ctx-pending.exe",
864 r"C:\bin\lean-ctx.old.exe",
865 60,
866 );
867 assert!(script.contains("set \"MAX_RETRIES=60\""));
868 assert!(script.contains(":timeout"), "must have timeout label");
869 assert!(
870 script.contains("timed out after"),
871 "must show timeout message"
872 );
873 }
874
875 #[test]
876 fn bat_script_shows_blocking_processes() {
877 let script = generate_deferred_bat_script("t", "p", "o", 30);
878 assert!(script.contains("tasklist"), "must list blocking processes");
879 assert!(
880 script.contains("lean-ctx stop"),
881 "must suggest lean-ctx stop"
882 );
883 }
884
885 #[test]
886 fn bat_script_has_progress_indicators() {
887 let script = generate_deferred_bat_script("t", "p", "o", 60);
888 assert!(script.contains("Still waiting"));
889 assert!(script.contains("RETRIES"));
890 }
891
892 #[test]
893 fn bat_script_provides_manual_recovery() {
894 let script = generate_deferred_bat_script(
895 r"C:\bin\lean-ctx.exe",
896 r"C:\bin\lean-ctx-pending.exe",
897 r"C:\bin\lean-ctx.old.exe",
898 60,
899 );
900 assert!(script.contains(r"move /Y"));
901 assert!(
902 script.contains("lean-ctx-pending.exe"),
903 "must show where the pending binary is"
904 );
905 assert!(
906 script.contains("lean-ctx update"),
907 "must suggest re-running update"
908 );
909 }
910
911 #[test]
912 fn bat_script_no_infinite_loop() {
913 let script = generate_deferred_bat_script("t", "p", "o", 10);
914 assert!(script.contains("if %RETRIES% GEQ %MAX_RETRIES% goto timeout"));
915 assert!(
916 !script.contains(":retry\ntimeout"),
917 "must not be an infinite loop"
918 );
919 }
920}