Skip to main content

lean_ctx/core/
updater.rs

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    // Handle --schedule subcommand
12    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, &current_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    // Prefer per-asset checksum (asset.ext.sha256) if present.
274    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    // PHASE 1: Restart proxy BEFORE writing env vars.
366    // This eliminates the race window where ANTHROPIC_BASE_URL points to a
367    // proxy that hasn't started yet (reported by khhaliil in PR #234).
368    if proxy_active {
369        restart_proxy_if_running();
370        wait_for_proxy_health(crate::proxy_setup::default_port());
371    }
372
373    // PHASE 2: Now that the proxy is confirmed healthy (or timed out),
374    // run setup which writes ANTHROPIC_BASE_URL into Claude Code settings.
375    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
425/// Restart proxy managed by launchd (macOS) or systemd (Linux).
426/// Returns `true` if a managed service was found and restarted.
427fn 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    // On Windows, a running executable can be renamed but not overwritten.
545    // Move the current binary out of the way first, then move the new one in.
546    // If the file is locked (MCP server running), try stopping managed processes
547    // first, then schedule a deferred update as last resort.
548    #[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                // Binary is locked. Try to stop managed processes first.
565                eprintln!("\nBinary is locked. Stopping managed lean-ctx processes...");
566                stop_managed_windows_processes();
567
568                // Brief wait for processes to release file handles.
569                std::thread::sleep(std::time::Duration::from_millis(1500));
570
571                // Retry after stopping.
572                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                        // Still locked (likely MCP server held by editor).
585                        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        // On macOS, rename-over-running-binary causes SIGKILL because the kernel
596        // re-validates code pages against the (now different) on-disk file.
597        // Unlinking first is safe: the kernel keeps the old memory-mapped pages
598        // from the deleted inode, while the new file gets a fresh inode at the path.
599        #[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", "-", &current_exe.display().to_string()])
613                .output();
614        }
615
616        Ok(())
617    }
618}
619
620/// Try to stop managed lean-ctx processes (proxy, serve, daemon) on Windows
621/// before attempting a deferred update.
622#[cfg(windows)]
623fn stop_managed_windows_processes() {
624    // Try `lean-ctx stop` first — it's the cleanest shutdown path.
625    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            // Fallback: taskkill for known process types (proxy, serve).
633            // MCP servers managed by editors can't be killed safely.
634            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/// Print which lean-ctx.exe processes are blocking the update on Windows.
651#[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(); // skip CSV header
671        if !lines.is_empty() {
672            eprintln!("\n  Blocking lean-ctx processes:");
673            for line in &lines {
674                // CSV: "Image Name","PID","Session Name","Session#","Mem Usage","Status","User Name","CPU Time","Window Title"
675                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/// On Windows, when the binary is locked by an MCP server, we can't rename it.
687/// Instead, stage the new binary and spawn a background cmd process that waits
688/// for the lock to be released (with a timeout), then performs the swap.
689#[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/// Generate the deferred update batch script content (extracted for testability).
763#[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}