Skip to main content

lean_ctx/core/
updater.rs

1use std::io::Read;
2
3const GITHUB_API_RELEASES: &str = "https://api.github.com/repos/yvgude/lean-ctx/releases/latest";
4const CURRENT_VERSION: &str = env!("CARGO_PKG_VERSION");
5
6pub fn run(args: &[String]) {
7    let check_only = args.iter().any(|a| a == "--check");
8    let insecure = args.iter().any(|a| a == "--insecure");
9
10    println!();
11    println!("  \x1b[1m◆ lean-ctx updater\x1b[0m  \x1b[2mv{CURRENT_VERSION}\x1b[0m");
12    println!("  \x1b[2mChecking github.com/yvgude/lean-ctx …\x1b[0m");
13
14    let release = match fetch_latest_release() {
15        Ok(r) => r,
16        Err(e) => {
17            tracing::error!("Error fetching release info: {e}");
18            std::process::exit(1);
19        }
20    };
21
22    let latest_tag = if let Some(t) = release["tag_name"].as_str() {
23        t.trim_start_matches('v').to_string()
24    } else {
25        tracing::error!("Could not parse release tag from GitHub API.");
26        std::process::exit(1);
27    };
28
29    if latest_tag == CURRENT_VERSION {
30        println!("  \x1b[32m✓\x1b[0m Already up to date (v{CURRENT_VERSION}).");
31        println!("  \x1b[2mIf your IDE still uses an older version, restart it to reconnect the MCP server.\x1b[0m");
32        println!();
33        if !check_only {
34            println!("  \x1b[36m\x1b[1mRefreshing setup (shell hook, MCP configs, rules)…\x1b[0m");
35            post_update_rewire();
36            println!();
37        }
38        return;
39    }
40
41    println!("  Update available: v{CURRENT_VERSION} → \x1b[1;32mv{latest_tag}\x1b[0m");
42
43    if check_only {
44        println!("Run 'lean-ctx update' to install.");
45        return;
46    }
47
48    let asset_name = platform_asset_name();
49    println!("  \x1b[2mDownloading {asset_name} …\x1b[0m");
50
51    let Some(download_url) = find_asset_url(&release, &asset_name) else {
52        tracing::error!("No binary found for this platform ({asset_name}). Download manually: https://github.com/yvgude/lean-ctx/releases/latest");
53        std::process::exit(1);
54    };
55
56    let bytes = match download_bytes(&download_url) {
57        Ok(b) => b,
58        Err(e) => {
59            tracing::error!("Download failed: {e}");
60            std::process::exit(1);
61        }
62    };
63
64    if let Err(e) = verify_download_integrity(&release, &asset_name, &bytes) {
65        if insecure {
66            tracing::warn!("Integrity verification failed: {e}");
67            tracing::warn!("Proceeding due to --insecure");
68        } else {
69            tracing::error!("Integrity verification failed: {e}");
70            tracing::error!("Refusing to install an unverifiable binary. Re-run with `lean-ctx update --insecure` or download manually: https://github.com/yvgude/lean-ctx/releases/latest");
71            std::process::exit(1);
72        }
73    }
74
75    let current_exe = match std::env::current_exe() {
76        Ok(p) => p,
77        Err(e) => {
78            tracing::error!("Cannot locate current executable: {e}");
79            std::process::exit(1);
80        }
81    };
82
83    if let Err(e) = replace_binary(&bytes, &asset_name, &current_exe) {
84        tracing::error!("Failed to replace binary: {e}");
85        tracing::warn!("Continuing with a setup refresh so your wiring stays correct");
86        post_update_rewire();
87        std::process::exit(1);
88    }
89
90    println!();
91    println!("  \x1b[1;32m✓ Updated to lean-ctx v{latest_tag}\x1b[0m");
92    println!("  \x1b[2mBinary: {}\x1b[0m", current_exe.display());
93
94    println!();
95    println!("  \x1b[36m\x1b[1mRefreshing setup (shell hook, MCP configs, rules)…\x1b[0m");
96    post_update_rewire();
97
98    println!();
99    crate::terminal_ui::print_logo_animated();
100    println!();
101    println!("  \x1b[33m\x1b[1m⟳ Restart your IDE and shell to activate the new version.\x1b[0m");
102    println!("    \x1b[2mClose and re-open Cursor, VS Code, Claude Code, etc. completely.\x1b[0m");
103    println!("    \x1b[2mThe MCP server must reconnect to use the updated binary.\x1b[0m");
104    println!(
105        "    \x1b[2mRun 'source ~/.zshrc' (or restart terminal) for updated shell aliases.\x1b[0m"
106    );
107    println!();
108}
109
110fn verify_download_integrity(
111    release: &serde_json::Value,
112    asset_name: &str,
113    bytes: &[u8],
114) -> Result<(), String> {
115    #[cfg(not(feature = "secure-update"))]
116    {
117        let _ = (release, asset_name, bytes);
118        return Err("secure-update feature disabled (sha256 verification unavailable)".to_string());
119    }
120
121    #[cfg(feature = "secure-update")]
122    {
123        let computed = sha256_hex(bytes);
124
125        let Some((checksum_url, kind)) = find_checksum_asset_url(release, asset_name) else {
126            return Err(
127                "no checksum asset found for this release (expected SHA256SUMS or *.sha256)"
128                    .to_string(),
129            );
130        };
131        let checksum_bytes = download_bytes(&checksum_url)?;
132        let checksum_text = String::from_utf8_lossy(&checksum_bytes).to_string();
133
134        let expected = match kind {
135            ChecksumAssetKind::SingleSha256 => parse_single_sha256(&checksum_text),
136            ChecksumAssetKind::Sha256Sums => parse_sha256sums(&checksum_text, asset_name),
137        }
138        .ok_or_else(|| format!("checksum file did not contain an entry for {asset_name}"))?;
139
140        if !constant_time_eq(computed.as_bytes(), expected.as_bytes()) {
141            return Err(format!(
142                "sha256 mismatch for {asset_name}: expected {expected}, got {computed}"
143            ));
144        }
145        Ok(())
146    }
147}
148
149#[derive(Debug, Clone, Copy)]
150enum ChecksumAssetKind {
151    Sha256Sums,
152    SingleSha256,
153}
154
155fn find_checksum_asset_url(
156    release: &serde_json::Value,
157    asset_name: &str,
158) -> Option<(String, ChecksumAssetKind)> {
159    // Prefer per-asset checksum (asset.ext.sha256) if present.
160    let candidates = [
161        format!("{asset_name}.sha256"),
162        format!("{asset_name}.sha256.txt"),
163        "SHA256SUMS".to_string(),
164        "SHA256SUMS.txt".to_string(),
165        "sha256sums.txt".to_string(),
166        "checksums.txt".to_string(),
167    ];
168
169    for c in candidates {
170        if let Some(url) = find_asset_url(release, &c) {
171            let kind = if c.to_lowercase().contains("sha256sums")
172                || c.to_uppercase() == "SHA256SUMS"
173                || c.to_lowercase().contains("checksums")
174            {
175                ChecksumAssetKind::Sha256Sums
176            } else {
177                ChecksumAssetKind::SingleSha256
178            };
179            return Some((url, kind));
180        }
181    }
182    None
183}
184
185fn parse_single_sha256(text: &str) -> Option<String> {
186    let t = text.trim();
187    let first = t.split_whitespace().next().unwrap_or("").trim();
188    if first.len() == 64 && first.chars().all(|c| c.is_ascii_hexdigit()) {
189        Some(first.to_ascii_lowercase())
190    } else {
191        None
192    }
193}
194
195fn parse_sha256sums(text: &str, asset_name: &str) -> Option<String> {
196    for line in text.lines() {
197        let l = line.trim();
198        if l.is_empty() || l.starts_with('#') {
199            continue;
200        }
201        let mut parts = l.split_whitespace();
202        let hash = parts.next().unwrap_or("");
203        let file = parts.next().unwrap_or("");
204        if file == asset_name && hash.len() == 64 && hash.chars().all(|c| c.is_ascii_hexdigit()) {
205            return Some(hash.to_ascii_lowercase());
206        }
207    }
208    None
209}
210
211fn sha256_hex(bytes: &[u8]) -> String {
212    use sha2::{Digest, Sha256};
213    let mut h = Sha256::new();
214    h.update(bytes);
215    let out = h.finalize();
216    hex_lower(&out)
217}
218
219fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
220    if a.len() != b.len() {
221        return false;
222    }
223    a.iter()
224        .zip(b.iter())
225        .fold(0u8, |acc, (x, y)| acc | (x ^ y))
226        == 0
227}
228
229fn hex_lower(bytes: &[u8]) -> String {
230    const HEX: &[u8; 16] = b"0123456789abcdef";
231    let mut out = String::with_capacity(bytes.len() * 2);
232    for &b in bytes {
233        out.push(HEX[(b >> 4) as usize] as char);
234        out.push(HEX[(b & 0x0f) as usize] as char);
235    }
236    out
237}
238
239fn post_update_rewire() {
240    // run_setup_with_options now handles daemon restart internally,
241    // so no separate restart_daemon_if_running() call needed.
242    let opts = crate::setup::SetupOptions {
243        non_interactive: true,
244        yes: true,
245        fix: true,
246        ..Default::default()
247    };
248    if let Err(e) = crate::setup::run_setup_with_options(opts) {
249        tracing::error!("Setup refresh error: {e}");
250    }
251
252    restart_proxy_if_running();
253}
254
255// Daemon lifecycle is now centralized in setup::run_setup_with_options.
256// Kept as a standalone utility for cases outside the full setup flow.
257#[allow(dead_code)]
258fn restart_daemon_if_running() {
259    #[cfg(unix)]
260    {
261        if !crate::daemon::is_daemon_running() {
262            return;
263        }
264        println!("  \x1b[33m⟳\x1b[0m Restarting daemon with new binary…");
265        if let Err(e) = crate::daemon::stop_daemon() {
266            println!("  \x1b[33m⚠\x1b[0m Could not stop daemon: {e}");
267            return;
268        }
269        std::thread::sleep(std::time::Duration::from_millis(500));
270        match crate::daemon::start_daemon(&[]) {
271            Ok(()) => println!("  \x1b[32m✓\x1b[0m Daemon restarted"),
272            Err(e) => println!("  \x1b[33m⚠\x1b[0m Daemon restart failed: {e}"),
273        }
274    }
275}
276
277fn restart_proxy_if_running() {
278    let port = crate::proxy_setup::default_port();
279
280    if restart_managed_proxy() {
281        return;
282    }
283
284    if is_proxy_reachable(port) {
285        println!(
286            "  \x1b[33m⟳\x1b[0m Proxy running on port {port} — restart it to use the new binary:"
287        );
288        println!("    \x1b[1mlean-ctx proxy start --port={port}\x1b[0m");
289    }
290}
291
292/// Restart proxy managed by launchd (macOS) or systemd (Linux).
293/// Returns `true` if a managed service was found and restarted.
294fn restart_managed_proxy() -> bool {
295    #[cfg(target_os = "macos")]
296    {
297        let plist_path = dirs::home_dir()
298            .unwrap_or_default()
299            .join("Library/LaunchAgents/com.leanctx.proxy.plist");
300        if plist_path.exists() {
301            let plist_str = plist_path.to_string_lossy().to_string();
302            let _ = std::process::Command::new("launchctl")
303                .args(["unload", &plist_str])
304                .output();
305            let result = std::process::Command::new("launchctl")
306                .args(["load", &plist_str])
307                .output();
308            match result {
309                Ok(o) if o.status.success() => {
310                    println!("  \x1b[32m✓\x1b[0m Proxy restarted (LaunchAgent)");
311                }
312                _ => {
313                    println!("  \x1b[33m⚠\x1b[0m Could not restart proxy LaunchAgent");
314                }
315            }
316            return true;
317        }
318    }
319
320    #[cfg(target_os = "linux")]
321    {
322        let service_path = dirs::home_dir()
323            .unwrap_or_default()
324            .join(".config/systemd/user/lean-ctx-proxy.service");
325        if service_path.exists() {
326            let result = std::process::Command::new("systemctl")
327                .args(["--user", "restart", "lean-ctx-proxy"])
328                .output();
329            match result {
330                Ok(o) if o.status.success() => {
331                    println!("  \x1b[32m✓\x1b[0m Proxy restarted (systemd)");
332                }
333                _ => {
334                    println!("  \x1b[33m⚠\x1b[0m Could not restart proxy systemd service");
335                }
336            }
337            return true;
338        }
339    }
340
341    false
342}
343
344fn is_proxy_reachable(port: u16) -> bool {
345    ureq::get(&format!("http://127.0.0.1:{port}/health"))
346        .call()
347        .is_ok()
348}
349
350fn fetch_latest_release() -> Result<serde_json::Value, String> {
351    let response = ureq::get(GITHUB_API_RELEASES)
352        .header("User-Agent", &format!("lean-ctx/{CURRENT_VERSION}"))
353        .header("Accept", "application/vnd.github.v3+json")
354        .call()
355        .map_err(|e| e.to_string())?;
356
357    response
358        .into_body()
359        .read_to_string()
360        .map_err(|e| e.to_string())
361        .and_then(|s| serde_json::from_str(&s).map_err(|e| e.to_string()))
362}
363
364fn find_asset_url(release: &serde_json::Value, asset_name: &str) -> Option<String> {
365    release["assets"]
366        .as_array()?
367        .iter()
368        .find(|a| a["name"].as_str() == Some(asset_name))
369        .and_then(|a| a["browser_download_url"].as_str())
370        .map(std::string::ToString::to_string)
371}
372
373fn download_bytes(url: &str) -> Result<Vec<u8>, String> {
374    let response = ureq::get(url)
375        .header("User-Agent", &format!("lean-ctx/{CURRENT_VERSION}"))
376        .call()
377        .map_err(|e| e.to_string())?;
378
379    let mut bytes = Vec::new();
380    response
381        .into_body()
382        .into_reader()
383        .read_to_end(&mut bytes)
384        .map_err(|e| e.to_string())?;
385    Ok(bytes)
386}
387
388fn replace_binary(
389    archive_bytes: &[u8],
390    asset_name: &str,
391    current_exe: &std::path::Path,
392) -> Result<(), String> {
393    let binary_bytes = if std::path::Path::new(asset_name)
394        .extension()
395        .is_some_and(|e| e.eq_ignore_ascii_case("zip"))
396    {
397        extract_from_zip(archive_bytes)?
398    } else {
399        extract_from_tar_gz(archive_bytes)?
400    };
401
402    let tmp_path = current_exe.with_extension("tmp");
403    std::fs::write(&tmp_path, &binary_bytes).map_err(|e| e.to_string())?;
404
405    #[cfg(unix)]
406    {
407        use std::os::unix::fs::PermissionsExt;
408        let _ = std::fs::set_permissions(&tmp_path, std::fs::Permissions::from_mode(0o755));
409    }
410
411    // On Windows, a running executable can be renamed but not overwritten.
412    // Move the current binary out of the way first, then move the new one in.
413    // If the file is locked (MCP server running), schedule a deferred update.
414    #[cfg(windows)]
415    {
416        let old_path = current_exe.with_extension("old.exe");
417        let _ = std::fs::remove_file(&old_path);
418
419        match std::fs::rename(current_exe, &old_path) {
420            Ok(()) => {
421                if let Err(e) = std::fs::rename(&tmp_path, current_exe) {
422                    let _ = std::fs::rename(&old_path, current_exe);
423                    let _ = std::fs::remove_file(&tmp_path);
424                    return Err(format!("Cannot place new binary: {e}"));
425                }
426                let _ = std::fs::remove_file(&old_path);
427                return Ok(());
428            }
429            Err(_) => {
430                return deferred_windows_update(&tmp_path, current_exe);
431            }
432        }
433    }
434
435    #[cfg(not(windows))]
436    {
437        // On macOS, rename-over-running-binary causes SIGKILL because the kernel
438        // re-validates code pages against the (now different) on-disk file.
439        // Unlinking first is safe: the kernel keeps the old memory-mapped pages
440        // from the deleted inode, while the new file gets a fresh inode at the path.
441        #[cfg(target_os = "macos")]
442        {
443            let _ = std::fs::remove_file(current_exe);
444        }
445
446        std::fs::rename(&tmp_path, current_exe).map_err(|e| {
447            let _ = std::fs::remove_file(&tmp_path);
448            format!("Cannot replace binary (permission denied?): {e}")
449        })?;
450
451        #[cfg(target_os = "macos")]
452        {
453            let _ = std::process::Command::new("codesign")
454                .args(["--force", "-s", "-", &current_exe.display().to_string()])
455                .output();
456        }
457
458        Ok(())
459    }
460}
461
462/// On Windows, when the binary is locked by an MCP server, we can't rename it.
463/// Instead, stage the new binary and spawn a background cmd process that waits
464/// for the lock to be released, then performs the swap.
465#[cfg(windows)]
466fn deferred_windows_update(
467    staged_path: &std::path::Path,
468    target_exe: &std::path::Path,
469) -> Result<(), String> {
470    let pending_path = target_exe.with_file_name("lean-ctx-pending.exe");
471    std::fs::rename(staged_path, &pending_path).map_err(|e| {
472        let _ = std::fs::remove_file(staged_path);
473        format!("Cannot stage update: {e}")
474    })?;
475
476    let target_str = target_exe.display().to_string();
477    let pending_str = pending_path.display().to_string();
478    let old_str = target_exe.with_extension("old.exe").display().to_string();
479
480    let script = format!(
481        r#"@echo off
482echo Waiting for lean-ctx to be released...
483:retry
484timeout /t 1 /nobreak >nul
485move /Y "{target}" "{old}" >nul 2>&1
486if errorlevel 1 goto retry
487move /Y "{pending}" "{target}" >nul 2>&1
488if errorlevel 1 (
489    move /Y "{old}" "{target}" >nul 2>&1
490    echo Update failed. Please close all editors and run: lean-ctx update
491    pause
492    exit /b 1
493)
494del /f "{old}" >nul 2>&1
495echo Updated successfully!
496del "%~f0" >nul 2>&1
497"#,
498        target = target_str,
499        pending = pending_str,
500        old = old_str,
501    );
502
503    let script_path = target_exe.with_file_name("lean-ctx-update.bat");
504    std::fs::write(&script_path, &script)
505        .map_err(|e| format!("Cannot write update script: {e}"))?;
506
507    let _ = std::process::Command::new("cmd")
508        .args(["/C", "start", "/MIN", &script_path.display().to_string()])
509        .spawn();
510
511    println!("\nThe binary is currently in use by your AI editor's MCP server.");
512    println!("A background update has been scheduled.");
513    println!(
514        "Close your editor (Cursor, VS Code, etc.) and the update will complete automatically."
515    );
516    println!("Or run the script manually: {}", script_path.display());
517
518    Ok(())
519}
520
521fn extract_from_tar_gz(data: &[u8]) -> Result<Vec<u8>, String> {
522    use flate2::read::GzDecoder;
523
524    let gz = GzDecoder::new(data);
525    let mut archive = tar::Archive::new(gz);
526
527    for entry in archive.entries().map_err(|e| e.to_string())? {
528        let mut entry = entry.map_err(|e| e.to_string())?;
529        let path = entry.path().map_err(|e| e.to_string())?;
530        let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
531
532        if name == "lean-ctx" || name == "lean-ctx.exe" {
533            let mut bytes = Vec::new();
534            entry.read_to_end(&mut bytes).map_err(|e| e.to_string())?;
535            return Ok(bytes);
536        }
537    }
538    Err("lean-ctx binary not found inside archive".to_string())
539}
540
541fn extract_from_zip(data: &[u8]) -> Result<Vec<u8>, String> {
542    use std::io::Cursor;
543
544    let cursor = Cursor::new(data);
545    let mut zip = zip::ZipArchive::new(cursor).map_err(|e| e.to_string())?;
546
547    for i in 0..zip.len() {
548        let mut file = zip.by_index(i).map_err(|e| e.to_string())?;
549        let name = file.name().to_string();
550        if name == "lean-ctx.exe" || name == "lean-ctx" {
551            let mut bytes = Vec::new();
552            file.read_to_end(&mut bytes).map_err(|e| e.to_string())?;
553            return Ok(bytes);
554        }
555    }
556    Err("lean-ctx binary not found inside zip archive".to_string())
557}
558
559fn detect_linux_libc() -> &'static str {
560    let output = std::process::Command::new("ldd").arg("--version").output();
561    if let Ok(out) = output {
562        let text = String::from_utf8_lossy(&out.stdout);
563        let stderr = String::from_utf8_lossy(&out.stderr);
564        let combined = format!("{text}{stderr}");
565        for line in combined.lines() {
566            if let Some(ver) = line.split_whitespace().last() {
567                let parts: Vec<&str> = ver.split('.').collect();
568                if parts.len() == 2 {
569                    if let (Ok(major), Ok(minor)) =
570                        (parts[0].parse::<u32>(), parts[1].parse::<u32>())
571                    {
572                        if major > 2 || (major == 2 && minor >= 35) {
573                            return "gnu";
574                        }
575                        return "musl";
576                    }
577                }
578            }
579        }
580    }
581    "musl"
582}
583
584fn platform_asset_name() -> String {
585    let os = std::env::consts::OS;
586    let arch = std::env::consts::ARCH;
587
588    let target = match (os, arch) {
589        ("macos", "aarch64") => "aarch64-apple-darwin".to_string(),
590        ("macos", "x86_64") => "x86_64-apple-darwin".to_string(),
591        ("linux", "x86_64") => format!("x86_64-unknown-linux-{}", detect_linux_libc()),
592        ("linux", "aarch64") => format!("aarch64-unknown-linux-{}", detect_linux_libc()),
593        ("windows", "x86_64") => "x86_64-pc-windows-msvc".to_string(),
594        _ => {
595            tracing::error!(
596                "Unsupported platform: {os}/{arch}. Download manually from \
597                https://github.com/yvgude/lean-ctx/releases/latest"
598            );
599            std::process::exit(1);
600        }
601    };
602
603    if os == "windows" {
604        format!("lean-ctx-{target}.zip")
605    } else {
606        format!("lean-ctx-{target}.tar.gz")
607    }
608}