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