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 mut 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    let skip_rules = args.iter().any(|a| a == "--skip-rules");
11    // The scheduler invokes `update --quiet --scheduled`. `--quiet` alone also
12    // marks an automatic run for backward compatibility with schedulers that
13    // were installed before `--scheduled` existed.
14    let scheduled = args.iter().any(|a| a == "--scheduled");
15
16    // Handle --schedule subcommand
17    if let Some(pos) = args.iter().position(|a| a == "--schedule") {
18        let sub = args.get(pos + 1).map_or("", String::as_str);
19        match sub {
20            "off" | "disable" => {
21                if let Err(e) = crate::core::update_scheduler::remove_schedule() {
22                    eprintln!("  \x1b[31m✗\x1b[0m Failed to disable auto-updates: {e}");
23                    std::process::exit(1);
24                }
25                crate::core::update_scheduler::set_auto_update(false, false, 6);
26                println!("  \x1b[32m✓\x1b[0m Auto-updates disabled.");
27                println!("  \x1b[2mRe-enable anytime: lean-ctx update --schedule\x1b[0m");
28                return;
29            }
30            "status" => {
31                let info = crate::core::update_scheduler::schedule_status();
32                println!();
33                println!("  {info}");
34                println!();
35                return;
36            }
37            "notify" => {
38                let cfg = crate::core::config::Config::load();
39                let hours = cfg.updates.check_interval_hours;
40                match crate::core::update_scheduler::install_schedule(hours) {
41                    Ok(info) => {
42                        crate::core::update_scheduler::set_auto_update(true, true, hours);
43                        println!("  \x1b[32m✓\x1b[0m Update notifications enabled ({info})");
44                        println!(
45                            "  \x1b[2mYou'll be notified but updates won't install automatically.\x1b[0m"
46                        );
47                    }
48                    Err(e) => {
49                        eprintln!("  \x1b[31m✗\x1b[0m {e}");
50                        std::process::exit(1);
51                    }
52                }
53                return;
54            }
55            _ => {
56                let hours = if sub.is_empty() {
57                    6
58                } else {
59                    sub.trim_end_matches('h')
60                        .parse::<u64>()
61                        .unwrap_or(6)
62                        .clamp(1, 168)
63                };
64                match crate::core::update_scheduler::install_schedule(hours) {
65                    Ok(info) => {
66                        crate::core::update_scheduler::set_auto_update(true, false, hours);
67                        println!();
68                        println!("  \x1b[32m✓\x1b[0m {info}");
69                        println!("  \x1b[2mDisable anytime: lean-ctx update --schedule off\x1b[0m");
70                        println!();
71                    }
72                    Err(e) => {
73                        eprintln!("  \x1b[31m✗\x1b[0m Failed to enable auto-updates: {e}");
74                        std::process::exit(1);
75                    }
76                }
77                return;
78            }
79        }
80    }
81
82    // #447: `lean-ctx update <version>` installs a specific tagged release
83    // instead of the latest (e.g. to compare against an older build). Only the
84    // binary is swapped — data, config and logs are left untouched, exactly
85    // like a normal update.
86    let target_version: Option<String> = match parse_target_version(args) {
87        None => None,
88        Some(v) if looks_like_version(v) => Some(v.trim_start_matches('v').to_string()),
89        Some(other) => {
90            eprintln!("  \x1b[31m✗\x1b[0m '{other}' is not a valid version.");
91            eprintln!(
92                "  \x1b[2mUsage: lean-ctx update [<version>]   (e.g. lean-ctx update 3.8.5)\x1b[0m"
93            );
94            eprintln!(
95                "  \x1b[2mAvailable versions: https://github.com/yvgude/lean-ctx/releases\x1b[0m"
96            );
97            std::process::exit(1);
98        }
99    };
100    let pinned = target_version.is_some();
101
102    // #335: An automatic run (`--quiet`/`--scheduled`) must obey config.toml.
103    // A user who sets `updates.auto_update = false` after a scheduler was
104    // installed expects auto-updates to stop. Since editing config doesn't
105    // uninstall the scheduler, the next scheduled tick re-checks config here,
106    // self-heals (removes the orphaned scheduler) and bails. `notify_only`
107    // downgrades the run to a check (never installs). Manual `lean-ctx update`
108    // (no `--quiet`/`--scheduled`) is an explicit action and always proceeds.
109    if (quiet || scheduled) && !check_only {
110        let cfg = crate::core::config::Config::load();
111        match automatic_update_gate(cfg.updates.auto_update, cfg.updates.notify_only) {
112            AutoUpdateGate::Skip => {
113                if let Err(e) = crate::core::update_scheduler::remove_schedule() {
114                    tracing::warn!(
115                        "auto-update disabled in config; failed to remove orphaned scheduler: {e}"
116                    );
117                } else {
118                    tracing::info!(
119                        "auto-update disabled (updates.auto_update=false): skipped scheduled update and removed orphaned scheduler"
120                    );
121                }
122                return;
123            }
124            AutoUpdateGate::NotifyOnly => {
125                check_only = true;
126            }
127            AutoUpdateGate::Proceed => {}
128        }
129    }
130
131    if !quiet {
132        println!();
133        println!("  \x1b[1m◆ lean-ctx updater\x1b[0m  \x1b[2mv{CURRENT_VERSION}\x1b[0m");
134        println!("  \x1b[2mChecking github.com/yvgude/lean-ctx …\x1b[0m");
135    }
136
137    let release = match fetch_release(target_version.as_deref()) {
138        Ok(r) => r,
139        Err(e) => {
140            if let Some(v) = &target_version {
141                tracing::error!("Could not fetch lean-ctx v{v}: {e}");
142                tracing::error!(
143                    "Check the version exists: https://github.com/yvgude/lean-ctx/releases"
144                );
145            } else {
146                tracing::error!("Error fetching release info: {e}");
147            }
148            std::process::exit(1);
149        }
150    };
151
152    let target_tag = if let Some(t) = release["tag_name"].as_str() {
153        t.trim_start_matches('v').to_string()
154    } else {
155        tracing::error!("Could not parse release tag from GitHub API.");
156        std::process::exit(1);
157    };
158
159    if target_tag == CURRENT_VERSION {
160        if quiet {
161            return;
162        }
163        if pinned {
164            println!("  \x1b[32m✓\x1b[0m Already on v{CURRENT_VERSION}.");
165        } else {
166            println!("  \x1b[32m✓\x1b[0m Already up to date (v{CURRENT_VERSION}).");
167        }
168        println!(
169            "  \x1b[2mIf your IDE still uses an older version, restart it to reconnect the MCP server.\x1b[0m"
170        );
171        println!();
172        if !check_only {
173            if skip_rules {
174                println!(
175                    "  \x1b[36m\x1b[1mRefreshing setup (shell hook, MCP configs — rules skipped)…\x1b[0m"
176                );
177            } else {
178                println!(
179                    "  \x1b[36m\x1b[1mRefreshing setup (shell hook, MCP configs, rules)…\x1b[0m"
180                );
181            }
182            post_update_rewire(skip_rules);
183            println!();
184        }
185        return;
186    }
187
188    if !quiet {
189        if pinned {
190            println!(
191                "  Switching: v{CURRENT_VERSION} → \x1b[1;36mv{target_tag}\x1b[0m  \x1b[2m(data & logs preserved)\x1b[0m"
192            );
193        } else {
194            println!("  Update available: v{CURRENT_VERSION} → \x1b[1;32mv{target_tag}\x1b[0m");
195        }
196    }
197
198    if check_only {
199        if pinned {
200            println!("Run 'lean-ctx update {target_tag}' to install.");
201        } else {
202            println!("Run 'lean-ctx update' to install.");
203        }
204        return;
205    }
206
207    let asset_name = platform_asset_name();
208    if !quiet {
209        println!("  \x1b[2mDownloading {asset_name} …\x1b[0m");
210    }
211
212    let Some(download_url) = find_asset_url(&release, &asset_name) else {
213        tracing::error!(
214            "No binary found for this platform ({asset_name}) in v{target_tag}. Download manually: https://github.com/yvgude/lean-ctx/releases"
215        );
216        std::process::exit(1);
217    };
218
219    let bytes = match download_bytes(&download_url) {
220        Ok(b) => b,
221        Err(e) => {
222            tracing::error!("Download failed: {e}");
223            std::process::exit(1);
224        }
225    };
226
227    if let Err(e) = verify_download_integrity(&release, &asset_name, &bytes) {
228        if insecure {
229            tracing::warn!("Integrity verification failed: {e}");
230            tracing::warn!("Proceeding due to --insecure");
231        } else {
232            tracing::error!("Integrity verification failed: {e}");
233            tracing::error!(
234                "Refusing to install an unverifiable binary. Re-run with `lean-ctx update --insecure` or download manually: https://github.com/yvgude/lean-ctx/releases"
235            );
236            std::process::exit(1);
237        }
238    }
239
240    let current_exe = match std::env::current_exe() {
241        Ok(p) => p,
242        Err(e) => {
243            tracing::error!("Cannot locate current executable: {e}");
244            std::process::exit(1);
245        }
246    };
247
248    if let Err(e) = replace_binary(&bytes, &asset_name, &current_exe) {
249        tracing::error!("Failed to replace binary: {e}");
250        tracing::warn!("Continuing with a setup refresh so your wiring stays correct");
251        post_update_rewire(skip_rules);
252        std::process::exit(1);
253    }
254
255    if quiet {
256        println!("  lean-ctx v{CURRENT_VERSION} → v{target_tag}");
257    } else {
258        println!();
259        if pinned {
260            println!("  \x1b[1;32m✓ Now running lean-ctx v{target_tag}\x1b[0m");
261        } else {
262            println!("  \x1b[1;32m✓ Updated to lean-ctx v{target_tag}\x1b[0m");
263        }
264        println!("  \x1b[2mBinary: {}\x1b[0m", current_exe.display());
265    }
266
267    if !quiet {
268        println!();
269        if skip_rules {
270            println!(
271                "  \x1b[36m\x1b[1mRefreshing setup (shell hook, MCP configs — rules skipped)…\x1b[0m"
272            );
273        } else {
274            println!("  \x1b[36m\x1b[1mRefreshing setup (shell hook, MCP configs, rules)…\x1b[0m");
275        }
276    }
277    post_update_rewire(skip_rules);
278
279    if !quiet {
280        println!();
281        crate::terminal_ui::print_logo_animated();
282        println!();
283        println!(
284            "  \x1b[33m\x1b[1m⟳ Restart your IDE and shell to activate the new version.\x1b[0m"
285        );
286        println!(
287            "    \x1b[2mClose and re-open Cursor, VS Code, Claude Code, etc. completely.\x1b[0m"
288        );
289        println!("    \x1b[2mThe MCP server must reconnect to use the updated binary.\x1b[0m");
290        println!(
291            "    \x1b[2m{}\x1b[0m",
292            crate::shell_hook::reload_aliases_hint()
293        );
294    }
295    println!();
296
297    if !quiet
298        && !crate::core::update_scheduler::has_user_decided()
299        && std::io::IsTerminal::is_terminal(&std::io::stdin())
300    {
301        print!("  Want to get updates like this automatically? \x1b[1m[y/N]\x1b[0m ");
302        use std::io::Write;
303        std::io::stdout().flush().ok();
304        let mut input = String::new();
305        if std::io::stdin().read_line(&mut input).is_ok() {
306            let answer = input.trim().to_lowercase();
307            if answer == "y" || answer == "yes" {
308                let cfg = crate::core::config::Config::load();
309                let hours = cfg.updates.check_interval_hours;
310                match crate::core::update_scheduler::install_schedule(hours) {
311                    Ok(info) => {
312                        crate::core::update_scheduler::set_auto_update(true, false, hours);
313                        println!("  \x1b[32m✓\x1b[0m {info}");
314                        println!("  \x1b[2mDisable anytime: lean-ctx update --schedule off\x1b[0m");
315                    }
316                    Err(e) => println!("  \x1b[33m⚠\x1b[0m Could not set up scheduler: {e}"),
317                }
318            } else {
319                crate::core::update_scheduler::set_auto_update(false, false, 6);
320                println!("  \x1b[2m○ Skipped — enable later: lean-ctx update --schedule\x1b[0m");
321            }
322        }
323    }
324}
325
326/// Outcome of the config gate applied to automatic (scheduled) update runs.
327#[derive(Debug, Clone, Copy, PartialEq, Eq)]
328enum AutoUpdateGate {
329    /// Install normally.
330    Proceed,
331    /// Auto-update disabled in config — skip and clean up the scheduler.
332    Skip,
333    /// Notify-only — check for a newer version but never install.
334    NotifyOnly,
335}
336
337/// Decide what a scheduled `update` run should do based on config. Pure helper
338/// so the precedence (`auto_update` wins over `notify_only`) is unit-testable.
339fn automatic_update_gate(auto_update: bool, notify_only: bool) -> AutoUpdateGate {
340    if !auto_update {
341        AutoUpdateGate::Skip
342    } else if notify_only {
343        AutoUpdateGate::NotifyOnly
344    } else {
345        AutoUpdateGate::Proceed
346    }
347}
348
349fn verify_download_integrity(
350    release: &serde_json::Value,
351    asset_name: &str,
352    bytes: &[u8],
353) -> Result<(), String> {
354    #[cfg(not(feature = "secure-update"))]
355    {
356        let _ = (release, asset_name, bytes);
357        return Err("secure-update feature disabled (sha256 verification unavailable)".to_string());
358    }
359
360    #[cfg(feature = "secure-update")]
361    {
362        let computed = sha256_hex(bytes);
363
364        let Some((checksum_url, kind)) = find_checksum_asset_url(release, asset_name) else {
365            return Err(
366                "no checksum asset found for this release (expected SHA256SUMS or *.sha256)"
367                    .to_string(),
368            );
369        };
370        let checksum_bytes = download_bytes(&checksum_url)?;
371        let checksum_text = String::from_utf8_lossy(&checksum_bytes).to_string();
372
373        let expected = match kind {
374            ChecksumAssetKind::SingleSha256 => parse_single_sha256(&checksum_text),
375            ChecksumAssetKind::Sha256Sums => parse_sha256sums(&checksum_text, asset_name),
376        }
377        .ok_or_else(|| format!("checksum file did not contain an entry for {asset_name}"))?;
378
379        if !constant_time_eq(computed.as_bytes(), expected.as_bytes()) {
380            return Err(format!(
381                "sha256 mismatch for {asset_name}: expected {expected}, got {computed}"
382            ));
383        }
384        Ok(())
385    }
386}
387
388#[derive(Debug, Clone, Copy)]
389enum ChecksumAssetKind {
390    Sha256Sums,
391    SingleSha256,
392}
393
394fn find_checksum_asset_url(
395    release: &serde_json::Value,
396    asset_name: &str,
397) -> Option<(String, ChecksumAssetKind)> {
398    // Prefer per-asset checksum (asset.ext.sha256) if present.
399    let candidates = [
400        format!("{asset_name}.sha256"),
401        format!("{asset_name}.sha256.txt"),
402        "SHA256SUMS".to_string(),
403        "SHA256SUMS.txt".to_string(),
404        "sha256sums.txt".to_string(),
405        "checksums.txt".to_string(),
406    ];
407
408    for c in candidates {
409        if let Some(url) = find_asset_url(release, &c) {
410            let kind = if c.to_lowercase().contains("sha256sums")
411                || c.to_uppercase() == "SHA256SUMS"
412                || c.to_lowercase().contains("checksums")
413            {
414                ChecksumAssetKind::Sha256Sums
415            } else {
416                ChecksumAssetKind::SingleSha256
417            };
418            return Some((url, kind));
419        }
420    }
421    None
422}
423
424fn parse_single_sha256(text: &str) -> Option<String> {
425    let t = text.trim();
426    let first = t.split_whitespace().next().unwrap_or("").trim();
427    if first.len() == 64 && first.chars().all(|c| c.is_ascii_hexdigit()) {
428        Some(first.to_ascii_lowercase())
429    } else {
430        None
431    }
432}
433
434fn parse_sha256sums(text: &str, asset_name: &str) -> Option<String> {
435    for line in text.lines() {
436        let l = line.trim();
437        if l.is_empty() || l.starts_with('#') {
438            continue;
439        }
440        let mut parts = l.split_whitespace();
441        let hash = parts.next().unwrap_or("");
442        let file = parts.next().unwrap_or("");
443        if file == asset_name && hash.len() == 64 && hash.chars().all(|c| c.is_ascii_hexdigit()) {
444            return Some(hash.to_ascii_lowercase());
445        }
446    }
447    None
448}
449
450fn sha256_hex(bytes: &[u8]) -> String {
451    use sha2::{Digest, Sha256};
452    let mut h = Sha256::new();
453    h.update(bytes);
454    let out = h.finalize();
455    hex_lower(&out)
456}
457
458fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
459    if a.len() != b.len() {
460        return false;
461    }
462    a.iter()
463        .zip(b.iter())
464        .fold(0u8, |acc, (x, y)| acc | (x ^ y))
465        == 0
466}
467
468fn hex_lower(bytes: &[u8]) -> String {
469    const HEX: &[u8; 16] = b"0123456789abcdef";
470    let mut out = String::with_capacity(bytes.len() * 2);
471    for &b in bytes {
472        out.push(HEX[(b >> 4) as usize] as char);
473        out.push(HEX[(b & 0x0f) as usize] as char);
474    }
475    out
476}
477
478fn post_update_rewire(skip_rules: bool) {
479    // #356: regenerate installed LaunchAgent plists so they adopt the new
480    // deny-~/Documents seatbelt wrapper. A plist is only (re)written on install,
481    // so without this an upgrade keeps the old unwrapped plist — and the TCC
482    // prompt — until the next manual enable. Idempotent: install rewrites the
483    // plist and re-bootstraps it.
484    #[cfg(target_os = "macos")]
485    rewrap_launchagents_for_tcc();
486
487    // The persist decision reads the GLOBAL file only and writes via
488    // update_global, so a project-local override is never leaked into the
489    // global config (#443).
490    if crate::core::config::Config::load_global()
491        .proxy_enabled
492        .is_none()
493        && crate::proxy_autostart::is_installed()
494    {
495        match crate::core::config::Config::update_global(|c| c.proxy_enabled = Some(true)) {
496            Ok(_) => {
497                eprintln!("  \u{2139} Proxy was already active \u{2014} keeping enabled.");
498                eprintln!("    Disable anytime: lean-ctx proxy disable");
499            }
500            Err(e) => tracing::warn!("could not persist proxy_enabled during update: {e}"),
501        }
502    }
503
504    // Runtime decisions use the effective (global + project-local) config.
505    let cfg = crate::core::config::Config::load();
506    let proxy_active = cfg.proxy_enabled == Some(true);
507
508    // Determine whether rules should be injected during rewire.
509    // CLI --skip-rules always wins. Otherwise, respect the config setting.
510    let effective_skip_rules = if skip_rules {
511        true
512    } else {
513        !cfg.setup.should_inject_rules()
514    };
515
516    // PHASE 1: Restart proxy BEFORE writing env vars.
517    if proxy_active {
518        restart_proxy_if_running();
519        wait_for_proxy_health(crate::proxy_setup::default_port());
520    }
521
522    // PHASE 2: Run setup which writes MCP configs (always) and rules (if opted in).
523    let opts = crate::setup::SetupOptions {
524        non_interactive: true,
525        yes: true,
526        fix: true,
527        skip_proxy: !proxy_active,
528        skip_rules: effective_skip_rules,
529        ..Default::default()
530    };
531    if let Err(e) = crate::setup::run_setup_with_options(opts) {
532        tracing::error!("Setup refresh error: {e}");
533    }
534}
535
536/// #356: rewrite every installed LaunchAgent plist (daemon, proxy, auto-updater)
537/// so an upgrade re-emits them with the deny-~/Documents seatbelt wrapper.
538/// plists are only generated on install, so an existing install would otherwise
539/// keep the pre-wrapper plist — and the prompt — until the next manual `enable`.
540/// Each `install` / `install_schedule` rewrites the plist and re-bootstraps it.
541#[cfg(target_os = "macos")]
542fn rewrap_launchagents_for_tcc() {
543    if crate::proxy_autostart::is_installed() {
544        crate::proxy_autostart::install(crate::proxy_setup::default_port(), true);
545    }
546    if crate::daemon_autostart::is_installed() {
547        crate::daemon_autostart::install(true);
548    }
549    if crate::core::update_scheduler::schedule_status().enabled {
550        let hours = crate::core::config::Config::load()
551            .updates
552            .check_interval_hours;
553        if let Err(e) = crate::core::update_scheduler::install_schedule(hours) {
554            tracing::warn!("#356 re-wrap of auto-update LaunchAgent failed: {e}");
555        }
556    }
557}
558
559fn wait_for_proxy_health(port: u16) {
560    let max_attempts = 20;
561    for i in 0..max_attempts {
562        if is_proxy_reachable(port) {
563            println!("  \x1b[32m✓\x1b[0m Proxy healthy on port {port}");
564            return;
565        }
566        if i == 0 {
567            print!("  \x1b[2mWaiting for proxy to become healthy");
568        }
569        print!(".");
570        use std::io::Write;
571        std::io::stdout().flush().ok();
572        std::thread::sleep(std::time::Duration::from_millis(500));
573    }
574    println!();
575    eprintln!(
576        "  \x1b[33m⚠\x1b[0m Proxy did not respond within {}s — writing env vars anyway",
577        max_attempts / 2
578    );
579    eprintln!("    If Claude Code shows connection errors, run: lean-ctx proxy start");
580}
581
582fn restart_proxy_if_running() {
583    let port = crate::proxy_setup::default_port();
584
585    if restart_managed_proxy() {
586        return;
587    }
588
589    if is_proxy_reachable(port) {
590        println!(
591            "  \x1b[33m⟳\x1b[0m Proxy running on port {port} — restart it to use the new binary:"
592        );
593        println!("    \x1b[1mlean-ctx proxy start --port={port}\x1b[0m");
594    }
595}
596
597/// Restart proxy managed by launchd (macOS) or systemd (Linux).
598/// Returns `true` if a managed service was found and restarted.
599fn restart_managed_proxy() -> bool {
600    #[cfg(target_os = "macos")]
601    {
602        let plist_path = dirs::home_dir()
603            .unwrap_or_default()
604            .join("Library/LaunchAgents/com.leanctx.proxy.plist");
605        if plist_path.exists() {
606            if crate::core::launchd::bootstrap("com.leanctx.proxy", &plist_path) {
607                println!("  \x1b[32m✓\x1b[0m Proxy restarted (LaunchAgent)");
608            } else {
609                println!("  \x1b[33m⚠\x1b[0m Could not restart proxy LaunchAgent");
610            }
611            return true;
612        }
613    }
614
615    #[cfg(target_os = "linux")]
616    {
617        let service_path = dirs::home_dir()
618            .unwrap_or_default()
619            .join(".config/systemd/user/lean-ctx-proxy.service");
620        if service_path.exists() {
621            let result = std::process::Command::new("systemctl")
622                .args(["--user", "restart", "lean-ctx-proxy"])
623                .output();
624            match result {
625                Ok(o) if o.status.success() => {
626                    println!("  \x1b[32m✓\x1b[0m Proxy restarted (systemd)");
627                }
628                _ => {
629                    println!("  \x1b[33m⚠\x1b[0m Could not restart proxy systemd service");
630                }
631            }
632            return true;
633        }
634    }
635
636    false
637}
638
639fn is_proxy_reachable(port: u16) -> bool {
640    ureq::get(&format!("http://127.0.0.1:{port}/health"))
641        .call()
642        .is_ok()
643}
644
645/// Builds the GitHub Releases API URL: the latest release when `version` is
646/// `None`, or a specific tag (`v{version}`) when pinned (#447). The leading
647/// `v` is normalised so both `3.8.5` and `v3.8.5` resolve to the `v3.8.5` tag.
648fn release_api_url(version: Option<&str>) -> String {
649    match version {
650        None => GITHUB_API_RELEASES.to_string(),
651        Some(v) => {
652            let core = v.trim_start_matches('v');
653            format!("https://api.github.com/repos/yvgude/lean-ctx/releases/tags/v{core}")
654        }
655    }
656}
657
658/// Fetches release metadata from GitHub. `version = None` returns the latest
659/// release; `Some(v)` returns the specific tagged release for version pinning.
660fn fetch_release(version: Option<&str>) -> Result<serde_json::Value, String> {
661    let response = ureq::get(&release_api_url(version))
662        .header("User-Agent", &format!("lean-ctx/{CURRENT_VERSION}"))
663        .header("Accept", "application/vnd.github.v3+json")
664        .call()
665        .map_err(|e| e.to_string())?;
666
667    response
668        .into_body()
669        .read_to_string()
670        .map_err(|e| e.to_string())
671        .and_then(|s| serde_json::from_str(&s).map_err(|e| e.to_string()))
672}
673
674/// Extracts an explicit version argument from `update` args, if present.
675/// Returns the first positional token (one that is not a `--flag`); the
676/// `--schedule` subcommand is consumed earlier so it never reaches here.
677fn parse_target_version(args: &[String]) -> Option<&str> {
678    args.iter()
679        .map(String::as_str)
680        .find(|a| !a.starts_with('-'))
681}
682
683/// True if `s` looks like a release version (optionally `v`-prefixed, e.g.
684/// `3.8.5` / `v3.8.5` / `3.8.5-rc1`), so typos are rejected before the API call.
685fn looks_like_version(s: &str) -> bool {
686    let core = s.strip_prefix('v').unwrap_or(s);
687    core.contains('.')
688        && core.starts_with(|c: char| c.is_ascii_digit())
689        && core
690            .chars()
691            .all(|c| c.is_ascii_digit() || c == '.' || c == '-' || c.is_ascii_alphabetic())
692}
693
694fn find_asset_url(release: &serde_json::Value, asset_name: &str) -> Option<String> {
695    release["assets"]
696        .as_array()?
697        .iter()
698        .find(|a| a["name"].as_str() == Some(asset_name))
699        .and_then(|a| a["browser_download_url"].as_str())
700        .map(std::string::ToString::to_string)
701}
702
703fn download_bytes(url: &str) -> Result<Vec<u8>, String> {
704    let response = ureq::get(url)
705        .header("User-Agent", &format!("lean-ctx/{CURRENT_VERSION}"))
706        .call()
707        .map_err(|e| e.to_string())?;
708
709    let mut bytes = Vec::new();
710    response
711        .into_body()
712        .into_reader()
713        .read_to_end(&mut bytes)
714        .map_err(|e| e.to_string())?;
715    Ok(bytes)
716}
717
718fn replace_binary(
719    archive_bytes: &[u8],
720    asset_name: &str,
721    current_exe: &std::path::Path,
722) -> Result<(), String> {
723    let binary_bytes = if std::path::Path::new(asset_name)
724        .extension()
725        .is_some_and(|e| e.eq_ignore_ascii_case("zip"))
726    {
727        extract_from_zip(archive_bytes)?
728    } else {
729        extract_from_tar_gz(archive_bytes)?
730    };
731
732    let tmp_path = current_exe.with_extension("tmp");
733    std::fs::write(&tmp_path, &binary_bytes).map_err(|e| e.to_string())?;
734
735    #[cfg(unix)]
736    {
737        use std::os::unix::fs::PermissionsExt;
738        let _ = std::fs::set_permissions(&tmp_path, std::fs::Permissions::from_mode(0o755));
739    }
740
741    // On Windows, a running executable can be renamed but not overwritten.
742    // Move the current binary out of the way first, then move the new one in.
743    // If the file is locked (MCP server running), try stopping managed processes
744    // first, then schedule a deferred update as last resort.
745    #[cfg(windows)]
746    {
747        let old_path = current_exe.with_extension("old.exe");
748        let _ = std::fs::remove_file(&old_path);
749
750        match std::fs::rename(current_exe, &old_path) {
751            Ok(()) => {
752                if let Err(e) = std::fs::rename(&tmp_path, current_exe) {
753                    let _ = std::fs::rename(&old_path, current_exe);
754                    let _ = std::fs::remove_file(&tmp_path);
755                    return Err(format!("Cannot place new binary: {e}"));
756                }
757                let _ = std::fs::remove_file(&old_path);
758                return Ok(());
759            }
760            Err(_) => {
761                // Binary is locked. Try to stop managed processes first.
762                eprintln!("\nBinary is locked. Stopping managed lean-ctx processes...");
763                stop_managed_windows_processes();
764
765                // Brief wait for processes to release file handles.
766                std::thread::sleep(std::time::Duration::from_millis(1500));
767
768                // Retry after stopping.
769                let _ = std::fs::remove_file(&old_path);
770                match std::fs::rename(current_exe, &old_path) {
771                    Ok(()) => {
772                        if let Err(e) = std::fs::rename(&tmp_path, current_exe) {
773                            let _ = std::fs::rename(&old_path, current_exe);
774                            let _ = std::fs::remove_file(&tmp_path);
775                            return Err(format!("Cannot place new binary: {e}"));
776                        }
777                        let _ = std::fs::remove_file(&old_path);
778                        return Ok(());
779                    }
780                    Err(_) => {
781                        // Still locked (likely MCP server held by editor).
782                        print_blocking_processes(current_exe);
783                        return deferred_windows_update(&tmp_path, current_exe);
784                    }
785                }
786            }
787        }
788    }
789
790    #[cfg(not(windows))]
791    {
792        // On macOS, rename-over-running-binary causes SIGKILL because the kernel
793        // re-validates code pages against the (now different) on-disk file.
794        // Unlinking first is safe: the kernel keeps the old memory-mapped pages
795        // from the deleted inode, while the new file gets a fresh inode at the path.
796        #[cfg(target_os = "macos")]
797        {
798            let _ = std::fs::remove_file(current_exe);
799        }
800
801        std::fs::rename(&tmp_path, current_exe).map_err(|e| {
802            let _ = std::fs::remove_file(&tmp_path);
803            format!("Cannot replace binary (permission denied?): {e}")
804        })?;
805
806        // #356: re-sign with the persistent identity when available so the
807        // macOS TCC grant survives the update; ad-hoc fallback keeps it runnable.
808        #[cfg(target_os = "macos")]
809        {
810            let _ = crate::core::codesign::sign_binary(current_exe);
811        }
812
813        Ok(())
814    }
815}
816
817/// Try to stop managed lean-ctx processes (proxy, serve, daemon) on Windows
818/// before attempting a deferred update.
819#[cfg(windows)]
820fn stop_managed_windows_processes() {
821    // Try `lean-ctx stop` first — it's the cleanest shutdown path.
822    let stop_result = std::process::Command::new("lean-ctx").arg("stop").output();
823
824    match stop_result {
825        Ok(out) if out.status.success() => {
826            eprintln!("  Managed processes stopped.");
827        }
828        _ => {
829            // Fallback: taskkill for known process types (proxy, serve).
830            // MCP servers managed by editors can't be killed safely.
831            for pattern in &["proxy start", "serve "] {
832                let _ = std::process::Command::new("taskkill")
833                    .args([
834                        "/F",
835                        "/FI",
836                        &format!("WINDOWTITLE eq *{pattern}*"),
837                        "/IM",
838                        "lean-ctx.exe",
839                    ])
840                    .output();
841            }
842            eprintln!("  Attempted to stop lean-ctx processes via taskkill.");
843        }
844    }
845}
846
847/// Print which lean-ctx.exe processes are blocking the update on Windows.
848#[cfg(windows)]
849fn print_blocking_processes(target_exe: &std::path::Path) {
850    let target_name = target_exe
851        .file_name()
852        .and_then(|n| n.to_str())
853        .unwrap_or("lean-ctx.exe");
854
855    let output = std::process::Command::new("tasklist")
856        .args([
857            "/FI",
858            &format!("IMAGENAME eq {target_name}"),
859            "/V",
860            "/FO",
861            "CSV",
862        ])
863        .output();
864
865    if let Ok(out) = output {
866        let stdout = String::from_utf8_lossy(&out.stdout);
867        let lines: Vec<&str> = stdout.lines().skip(1).collect(); // skip CSV header
868        if !lines.is_empty() {
869            eprintln!("\n  Blocking lean-ctx processes:");
870            for line in &lines {
871                // CSV: "Image Name","PID","Session Name","Session#","Mem Usage","Status","User Name","CPU Time","Window Title"
872                let fields: Vec<&str> = line.split(',').collect();
873                if fields.len() >= 2 {
874                    let pid = fields[1].trim_matches('"');
875                    eprintln!("    PID {pid}");
876                }
877            }
878            eprintln!("\n  To stop manually: taskkill /F /PID <pid>  (or close your editor)");
879        }
880    }
881}
882
883/// On Windows, when the binary is locked by an MCP server, we can't rename it.
884/// Instead, stage the new binary and spawn a background cmd process that waits
885/// for the lock to be released (with a timeout), then performs the swap.
886#[cfg(windows)]
887fn deferred_windows_update(
888    staged_path: &std::path::Path,
889    target_exe: &std::path::Path,
890) -> Result<(), String> {
891    let pending_path = target_exe.with_file_name("lean-ctx-pending.exe");
892    std::fs::rename(staged_path, &pending_path).map_err(|e| {
893        let _ = std::fs::remove_file(staged_path);
894        format!("Cannot stage update: {e}")
895    })?;
896
897    let target_str = target_exe.display().to_string();
898    let pending_str = pending_path.display().to_string();
899    let old_str = target_exe.with_extension("old.exe").display().to_string();
900    let max_retries = 60;
901
902    let script = generate_deferred_bat_script(&target_str, &pending_str, &old_str, max_retries);
903
904    let script_path = target_exe.with_file_name("lean-ctx-update.bat");
905    std::fs::write(&script_path, &script)
906        .map_err(|e| format!("Cannot write update script: {e}"))?;
907
908    let _ = std::process::Command::new("cmd")
909        .args(["/C", "start", "/MIN", &script_path.display().to_string()])
910        .spawn();
911
912    println!("\nThe binary is still in use (likely by your editor's MCP server).");
913    println!("A background update has been scheduled (timeout: {max_retries}s).");
914    println!("Close your editor and the update will complete automatically.");
915    println!("\nIf it times out, run: lean-ctx update");
916    println!("Update script: {}", script_path.display());
917
918    Ok(())
919}
920
921fn extract_from_tar_gz(data: &[u8]) -> Result<Vec<u8>, String> {
922    use flate2::read::GzDecoder;
923
924    let gz = GzDecoder::new(data);
925    let mut archive = tar::Archive::new(gz);
926
927    for entry in archive.entries().map_err(|e| e.to_string())? {
928        let mut entry = entry.map_err(|e| e.to_string())?;
929        let path = entry.path().map_err(|e| e.to_string())?;
930        let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
931
932        if name == "lean-ctx" || name == "lean-ctx.exe" {
933            let mut bytes = Vec::new();
934            entry.read_to_end(&mut bytes).map_err(|e| e.to_string())?;
935            return Ok(bytes);
936        }
937    }
938    Err("lean-ctx binary not found inside archive".to_string())
939}
940
941fn extract_from_zip(data: &[u8]) -> Result<Vec<u8>, String> {
942    use std::io::Cursor;
943
944    let cursor = Cursor::new(data);
945    let mut zip = zip::ZipArchive::new(cursor).map_err(|e| e.to_string())?;
946
947    for i in 0..zip.len() {
948        let mut file = zip.by_index(i).map_err(|e| e.to_string())?;
949        let name = file.name().to_string();
950        if name == "lean-ctx.exe" || name == "lean-ctx" {
951            let mut bytes = Vec::new();
952            file.read_to_end(&mut bytes).map_err(|e| e.to_string())?;
953            return Ok(bytes);
954        }
955    }
956    Err("lean-ctx binary not found inside zip archive".to_string())
957}
958
959/// Generate the deferred update batch script content (extracted for testability).
960#[cfg(any(windows, test))]
961fn generate_deferred_bat_script(
962    target: &str,
963    pending: &str,
964    old: &str,
965    max_retries: u32,
966) -> String {
967    format!(
968        r#"@echo off
969setlocal
970set "RETRIES=0"
971set "MAX_RETRIES={max_retries}"
972
973echo lean-ctx update: waiting for binary to be released (timeout: %MAX_RETRIES%s)...
974echo.
975echo Blocking processes:
976tasklist /FI "IMAGENAME eq lean-ctx.exe" /V /NH 2>nul
977echo.
978echo Close your editor (Cursor, VS Code, etc.) to release the binary,
979echo or stop manually:  lean-ctx stop
980echo.
981
982:retry
983if %RETRIES% GEQ %MAX_RETRIES% goto timeout
984set /a RETRIES+=1
985timeout /t 1 /nobreak >nul
986move /Y "{target}" "{old}" >nul 2>&1
987if errorlevel 1 (
988    if %RETRIES% EQU 10 echo   Still waiting... (%RETRIES%/%MAX_RETRIES%s)
989    if %RETRIES% EQU 30 echo   Still waiting... (%RETRIES%/%MAX_RETRIES%s) — try closing your editor
990    if %RETRIES% EQU 50 echo   Still waiting... (%RETRIES%/%MAX_RETRIES%s) — timeout approaching
991    goto retry
992)
993
994move /Y "{pending}" "{target}" >nul 2>&1
995if errorlevel 1 (
996    move /Y "{old}" "{target}" >nul 2>&1
997    echo.
998    echo Update failed: could not place new binary.
999    echo Please close all editors and run: lean-ctx update
1000    pause
1001    exit /b 1
1002)
1003del /f "{old}" >nul 2>&1
1004echo.
1005echo Updated successfully!
1006goto cleanup
1007
1008:timeout
1009echo.
1010echo Update timed out after %MAX_RETRIES% seconds.
1011echo The new binary is staged at: {pending}
1012echo.
1013echo To complete the update manually:
1014echo   1. Close your editor (Cursor, VS Code, etc.)
1015echo   2. Run: move /Y "{pending}" "{target}"
1016echo.
1017echo Or run: lean-ctx update --force
1018echo.
1019pause
1020exit /b 1
1021
1022:cleanup
1023del "%~f0" >nul 2>&1
1024"#
1025    )
1026}
1027
1028fn detect_linux_libc() -> &'static str {
1029    let output = std::process::Command::new("ldd").arg("--version").output();
1030    if let Ok(out) = output {
1031        let text = String::from_utf8_lossy(&out.stdout);
1032        let stderr = String::from_utf8_lossy(&out.stderr);
1033        let combined = format!("{text}{stderr}");
1034        for line in combined.lines() {
1035            if let Some(ver) = line.split_whitespace().last() {
1036                let parts: Vec<&str> = ver.split('.').collect();
1037                if parts.len() == 2
1038                    && let (Ok(major), Ok(minor)) =
1039                        (parts[0].parse::<u32>(), parts[1].parse::<u32>())
1040                {
1041                    if major > 2 || (major == 2 && minor >= 35) {
1042                        return "gnu";
1043                    }
1044                    return "musl";
1045                }
1046            }
1047        }
1048    }
1049    "musl"
1050}
1051
1052fn platform_asset_name() -> String {
1053    let os = std::env::consts::OS;
1054    let arch = std::env::consts::ARCH;
1055
1056    let target = match (os, arch) {
1057        ("macos", "aarch64") => "aarch64-apple-darwin".to_string(),
1058        ("macos", "x86_64") => "x86_64-apple-darwin".to_string(),
1059        ("linux", "x86_64") => format!("x86_64-unknown-linux-{}", detect_linux_libc()),
1060        ("linux", "aarch64") => format!("aarch64-unknown-linux-{}", detect_linux_libc()),
1061        ("windows", "x86_64") => "x86_64-pc-windows-msvc".to_string(),
1062        _ => {
1063            tracing::error!(
1064                "Unsupported platform: {os}/{arch}. Download manually from \
1065                https://github.com/yvgude/lean-ctx/releases/latest"
1066            );
1067            std::process::exit(1);
1068        }
1069    };
1070
1071    if os == "windows" {
1072        format!("lean-ctx-{target}.zip")
1073    } else {
1074        format!("lean-ctx-{target}.tar.gz")
1075    }
1076}
1077
1078#[cfg(test)]
1079mod tests {
1080    use super::*;
1081
1082    #[test]
1083    fn auto_update_disabled_skips_and_cleans_up() {
1084        // #335: auto_update=false → scheduled run must not install.
1085        assert_eq!(automatic_update_gate(false, false), AutoUpdateGate::Skip);
1086        // auto_update=false wins even if notify_only is also set.
1087        assert_eq!(automatic_update_gate(false, true), AutoUpdateGate::Skip);
1088    }
1089
1090    #[test]
1091    fn notify_only_downgrades_to_check() {
1092        assert_eq!(
1093            automatic_update_gate(true, true),
1094            AutoUpdateGate::NotifyOnly
1095        );
1096    }
1097
1098    #[test]
1099    fn auto_update_enabled_proceeds() {
1100        assert_eq!(automatic_update_gate(true, false), AutoUpdateGate::Proceed);
1101    }
1102
1103    #[test]
1104    fn bat_script_has_timeout_guard() {
1105        let script = generate_deferred_bat_script(
1106            r"C:\bin\lean-ctx.exe",
1107            r"C:\bin\lean-ctx-pending.exe",
1108            r"C:\bin\lean-ctx.old.exe",
1109            60,
1110        );
1111        assert!(script.contains("set \"MAX_RETRIES=60\""));
1112        assert!(script.contains(":timeout"), "must have timeout label");
1113        assert!(
1114            script.contains("timed out after"),
1115            "must show timeout message"
1116        );
1117    }
1118
1119    #[test]
1120    fn bat_script_shows_blocking_processes() {
1121        let script = generate_deferred_bat_script("t", "p", "o", 30);
1122        assert!(script.contains("tasklist"), "must list blocking processes");
1123        assert!(
1124            script.contains("lean-ctx stop"),
1125            "must suggest lean-ctx stop"
1126        );
1127    }
1128
1129    #[test]
1130    fn bat_script_has_progress_indicators() {
1131        let script = generate_deferred_bat_script("t", "p", "o", 60);
1132        assert!(script.contains("Still waiting"));
1133        assert!(script.contains("RETRIES"));
1134    }
1135
1136    #[test]
1137    fn bat_script_provides_manual_recovery() {
1138        let script = generate_deferred_bat_script(
1139            r"C:\bin\lean-ctx.exe",
1140            r"C:\bin\lean-ctx-pending.exe",
1141            r"C:\bin\lean-ctx.old.exe",
1142            60,
1143        );
1144        assert!(script.contains(r"move /Y"));
1145        assert!(
1146            script.contains("lean-ctx-pending.exe"),
1147            "must show where the pending binary is"
1148        );
1149        assert!(
1150            script.contains("lean-ctx update"),
1151            "must suggest re-running update"
1152        );
1153    }
1154
1155    #[test]
1156    fn bat_script_no_infinite_loop() {
1157        let script = generate_deferred_bat_script("t", "p", "o", 10);
1158        assert!(script.contains("if %RETRIES% GEQ %MAX_RETRIES% goto timeout"));
1159        assert!(
1160            !script.contains(":retry\ntimeout"),
1161            "must not be an infinite loop"
1162        );
1163    }
1164
1165    #[test]
1166    fn release_url_latest_when_no_version() {
1167        // #447: no pin → the canonical "latest" endpoint.
1168        assert_eq!(release_api_url(None), GITHUB_API_RELEASES);
1169    }
1170
1171    #[test]
1172    fn release_url_pins_specific_tag() {
1173        // #447: a bare version pins the `v`-prefixed tag …
1174        assert_eq!(
1175            release_api_url(Some("3.8.5")),
1176            "https://api.github.com/repos/yvgude/lean-ctx/releases/tags/v3.8.5"
1177        );
1178        // … and an already-`v`-prefixed version is normalised, not doubled.
1179        assert_eq!(
1180            release_api_url(Some("v3.8.5")),
1181            "https://api.github.com/repos/yvgude/lean-ctx/releases/tags/v3.8.5"
1182        );
1183    }
1184
1185    #[test]
1186    fn parse_target_version_peels_positional_only() {
1187        let flags_only = [String::from("--check"), String::from("--quiet")];
1188        assert_eq!(parse_target_version(&flags_only), None);
1189
1190        let with_version = [String::from("3.8.5"), String::from("--check")];
1191        assert_eq!(parse_target_version(&with_version), Some("3.8.5"));
1192
1193        // Order-independent: the positional is found after leading flags.
1194        let flag_then_version = [String::from("--insecure"), String::from("v3.8.5")];
1195        assert_eq!(parse_target_version(&flag_then_version), Some("v3.8.5"));
1196    }
1197
1198    #[test]
1199    fn looks_like_version_accepts_releases_rejects_typos() {
1200        assert!(looks_like_version("3.8.5"));
1201        assert!(looks_like_version("v3.8.5"));
1202        assert!(looks_like_version("3.8.5-rc1"));
1203        // Not versions: flags, words, and bare majors (too ambiguous to pin).
1204        assert!(!looks_like_version("--check"));
1205        assert!(!looks_like_version("latest"));
1206        assert!(!looks_like_version("3"));
1207    }
1208}