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    // ONE-TIME migration: if proxy_enabled is None but autostart is already installed,
357    // grandfather the user in (they had proxy from a previous version).
358    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
398/// Restart proxy managed by launchd (macOS) or systemd (Linux).
399/// Returns `true` if a managed service was found and restarted.
400fn 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    // On Windows, a running executable can be renamed but not overwritten.
518    // Move the current binary out of the way first, then move the new one in.
519    // If the file is locked (MCP server running), try stopping managed processes
520    // first, then schedule a deferred update as last resort.
521    #[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                // Binary is locked. Try to stop managed processes first.
538                eprintln!("\nBinary is locked. Stopping managed lean-ctx processes...");
539                stop_managed_windows_processes();
540
541                // Brief wait for processes to release file handles.
542                std::thread::sleep(std::time::Duration::from_millis(1500));
543
544                // Retry after stopping.
545                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                        // Still locked (likely MCP server held by editor).
558                        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        // On macOS, rename-over-running-binary causes SIGKILL because the kernel
569        // re-validates code pages against the (now different) on-disk file.
570        // Unlinking first is safe: the kernel keeps the old memory-mapped pages
571        // from the deleted inode, while the new file gets a fresh inode at the path.
572        #[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", "-", &current_exe.display().to_string()])
586                .output();
587        }
588
589        Ok(())
590    }
591}
592
593/// Try to stop managed lean-ctx processes (proxy, serve, daemon) on Windows
594/// before attempting a deferred update.
595#[cfg(windows)]
596fn stop_managed_windows_processes() {
597    // Try `lean-ctx stop` first — it's the cleanest shutdown path.
598    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            // Fallback: taskkill for known process types (proxy, serve).
606            // MCP servers managed by editors can't be killed safely.
607            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/// Print which lean-ctx.exe processes are blocking the update on Windows.
624#[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(); // skip CSV header
644        if !lines.is_empty() {
645            eprintln!("\n  Blocking lean-ctx processes:");
646            for line in &lines {
647                // CSV: "Image Name","PID","Session Name","Session#","Mem Usage","Status","User Name","CPU Time","Window Title"
648                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/// On Windows, when the binary is locked by an MCP server, we can't rename it.
660/// Instead, stage the new binary and spawn a background cmd process that waits
661/// for the lock to be released (with a timeout), then performs the swap.
662#[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/// Generate the deferred update batch script content (extracted for testability).
736#[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}