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
9    println!();
10    println!("  \x1b[1m◆ lean-ctx updater\x1b[0m  \x1b[2mv{CURRENT_VERSION}\x1b[0m");
11    println!("  \x1b[2mChecking github.com/yvgude/lean-ctx …\x1b[0m");
12
13    let release = match fetch_latest_release() {
14        Ok(r) => r,
15        Err(e) => {
16            eprintln!("Error fetching release info: {e}");
17            std::process::exit(1);
18        }
19    };
20
21    let latest_tag = match release["tag_name"].as_str() {
22        Some(t) => t.trim_start_matches('v').to_string(),
23        None => {
24            eprintln!("Could not parse release tag from GitHub API.");
25            std::process::exit(1);
26        }
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        return;
34    }
35
36    println!("  Update available: v{CURRENT_VERSION} → \x1b[1;32mv{latest_tag}\x1b[0m");
37
38    if check_only {
39        println!("Run 'lean-ctx update' to install.");
40        return;
41    }
42
43    let asset_name = platform_asset_name();
44    println!("  \x1b[2mDownloading {asset_name} …\x1b[0m");
45
46    let download_url = match find_asset_url(&release, &asset_name) {
47        Some(u) => u,
48        None => {
49            eprintln!("No binary found for this platform ({asset_name}).");
50            eprintln!("Download manually: https://github.com/yvgude/lean-ctx/releases/latest");
51            std::process::exit(1);
52        }
53    };
54
55    let bytes = match download_bytes(&download_url) {
56        Ok(b) => b,
57        Err(e) => {
58            eprintln!("Download failed: {e}");
59            std::process::exit(1);
60        }
61    };
62
63    let current_exe = match std::env::current_exe() {
64        Ok(p) => p,
65        Err(e) => {
66            eprintln!("Cannot locate current executable: {e}");
67            std::process::exit(1);
68        }
69    };
70
71    if let Err(e) = replace_binary(&bytes, &asset_name, &current_exe) {
72        eprintln!("Failed to replace binary: {e}");
73        std::process::exit(1);
74    }
75
76    println!();
77    crate::terminal_ui::print_logo_animated();
78    println!("  \x1b[1;32m✓ Updated to lean-ctx v{latest_tag}\x1b[0m");
79    println!("  \x1b[2mBinary: {}\x1b[0m", current_exe.display());
80    println!();
81    println!("  \x1b[33m\x1b[1m⟳ Restart your IDE / AI tool to activate the new version.\x1b[0m");
82    println!("    \x1b[2mClose and re-open Cursor, VS Code, Claude Code, etc. completely.\x1b[0m");
83    println!("    \x1b[2mThe MCP server must reconnect to use the updated binary.\x1b[0m");
84    println!();
85    println!("    \x1b[2mAgent rules will be updated automatically on the next IDE start.\x1b[0m");
86    println!();
87}
88
89fn fetch_latest_release() -> Result<serde_json::Value, String> {
90    let response = ureq::get(GITHUB_API_RELEASES)
91        .header("User-Agent", &format!("lean-ctx/{CURRENT_VERSION}"))
92        .header("Accept", "application/vnd.github.v3+json")
93        .call()
94        .map_err(|e| e.to_string())?;
95
96    response
97        .into_body()
98        .read_to_string()
99        .map_err(|e| e.to_string())
100        .and_then(|s| serde_json::from_str(&s).map_err(|e| e.to_string()))
101}
102
103fn find_asset_url(release: &serde_json::Value, asset_name: &str) -> Option<String> {
104    release["assets"]
105        .as_array()?
106        .iter()
107        .find(|a| a["name"].as_str() == Some(asset_name))
108        .and_then(|a| a["browser_download_url"].as_str())
109        .map(|s| s.to_string())
110}
111
112fn download_bytes(url: &str) -> Result<Vec<u8>, String> {
113    let response = ureq::get(url)
114        .header("User-Agent", &format!("lean-ctx/{CURRENT_VERSION}"))
115        .call()
116        .map_err(|e| e.to_string())?;
117
118    let mut bytes = Vec::new();
119    response
120        .into_body()
121        .into_reader()
122        .read_to_end(&mut bytes)
123        .map_err(|e| e.to_string())?;
124    Ok(bytes)
125}
126
127fn replace_binary(
128    archive_bytes: &[u8],
129    asset_name: &str,
130    current_exe: &std::path::Path,
131) -> Result<(), String> {
132    let binary_bytes = if asset_name.ends_with(".zip") {
133        extract_from_zip(archive_bytes)?
134    } else {
135        extract_from_tar_gz(archive_bytes)?
136    };
137
138    let tmp_path = current_exe.with_extension("tmp");
139    std::fs::write(&tmp_path, &binary_bytes).map_err(|e| e.to_string())?;
140
141    #[cfg(unix)]
142    {
143        use std::os::unix::fs::PermissionsExt;
144        let _ = std::fs::set_permissions(&tmp_path, std::fs::Permissions::from_mode(0o755));
145    }
146
147    // On Windows, a running executable can be renamed but not overwritten.
148    // Move the current binary out of the way first, then move the new one in.
149    // If the file is locked (MCP server running), schedule a deferred update.
150    #[cfg(windows)]
151    {
152        let old_path = current_exe.with_extension("old.exe");
153        let _ = std::fs::remove_file(&old_path);
154
155        match std::fs::rename(current_exe, &old_path) {
156            Ok(()) => {
157                if let Err(e) = std::fs::rename(&tmp_path, current_exe) {
158                    let _ = std::fs::rename(&old_path, current_exe);
159                    let _ = std::fs::remove_file(&tmp_path);
160                    return Err(format!("Cannot place new binary: {e}"));
161                }
162                let _ = std::fs::remove_file(&old_path);
163                return Ok(());
164            }
165            Err(_) => {
166                return deferred_windows_update(&tmp_path, current_exe);
167            }
168        }
169    }
170
171    #[cfg(not(windows))]
172    {
173        std::fs::rename(&tmp_path, current_exe).map_err(|e| {
174            let _ = std::fs::remove_file(&tmp_path);
175            format!("Cannot replace binary (permission denied?): {e}")
176        })
177    }
178}
179
180/// On Windows, when the binary is locked by an MCP server, we can't rename it.
181/// Instead, stage the new binary and spawn a background cmd process that waits
182/// for the lock to be released, then performs the swap.
183#[cfg(windows)]
184fn deferred_windows_update(
185    staged_path: &std::path::Path,
186    target_exe: &std::path::Path,
187) -> Result<(), String> {
188    let pending_path = target_exe.with_file_name("lean-ctx-pending.exe");
189    std::fs::rename(staged_path, &pending_path).map_err(|e| {
190        let _ = std::fs::remove_file(staged_path);
191        format!("Cannot stage update: {e}")
192    })?;
193
194    let target_str = target_exe.display().to_string();
195    let pending_str = pending_path.display().to_string();
196    let old_str = target_exe.with_extension("old.exe").display().to_string();
197
198    let script = format!(
199        r#"@echo off
200echo Waiting for lean-ctx to be released...
201:retry
202timeout /t 1 /nobreak >nul
203move /Y "{target}" "{old}" >nul 2>&1
204if errorlevel 1 goto retry
205move /Y "{pending}" "{target}" >nul 2>&1
206if errorlevel 1 (
207    move /Y "{old}" "{target}" >nul 2>&1
208    echo Update failed. Please close all editors and run: lean-ctx update
209    pause
210    exit /b 1
211)
212del /f "{old}" >nul 2>&1
213echo Updated successfully!
214del "%~f0" >nul 2>&1
215"#,
216        target = target_str,
217        pending = pending_str,
218        old = old_str,
219    );
220
221    let script_path = target_exe.with_file_name("lean-ctx-update.bat");
222    std::fs::write(&script_path, &script)
223        .map_err(|e| format!("Cannot write update script: {e}"))?;
224
225    let _ = std::process::Command::new("cmd")
226        .args(["/C", "start", "/MIN", &script_path.display().to_string()])
227        .spawn();
228
229    println!("\nThe binary is currently in use by your AI editor's MCP server.");
230    println!("A background update has been scheduled.");
231    println!(
232        "Close your editor (Cursor, VS Code, etc.) and the update will complete automatically."
233    );
234    println!("Or run the script manually: {}", script_path.display());
235
236    Ok(())
237}
238
239fn extract_from_tar_gz(data: &[u8]) -> Result<Vec<u8>, String> {
240    use flate2::read::GzDecoder;
241
242    let gz = GzDecoder::new(data);
243    let mut archive = tar::Archive::new(gz);
244
245    for entry in archive.entries().map_err(|e| e.to_string())? {
246        let mut entry = entry.map_err(|e| e.to_string())?;
247        let path = entry.path().map_err(|e| e.to_string())?;
248        let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
249
250        if name == "lean-ctx" || name == "lean-ctx.exe" {
251            let mut bytes = Vec::new();
252            entry.read_to_end(&mut bytes).map_err(|e| e.to_string())?;
253            return Ok(bytes);
254        }
255    }
256    Err("lean-ctx binary not found inside archive".to_string())
257}
258
259fn extract_from_zip(data: &[u8]) -> Result<Vec<u8>, String> {
260    use std::io::Cursor;
261
262    let cursor = Cursor::new(data);
263    let mut zip = zip::ZipArchive::new(cursor).map_err(|e| e.to_string())?;
264
265    for i in 0..zip.len() {
266        let mut file = zip.by_index(i).map_err(|e| e.to_string())?;
267        let name = file.name().to_string();
268        if name == "lean-ctx.exe" || name == "lean-ctx" {
269            let mut bytes = Vec::new();
270            file.read_to_end(&mut bytes).map_err(|e| e.to_string())?;
271            return Ok(bytes);
272        }
273    }
274    Err("lean-ctx binary not found inside zip archive".to_string())
275}
276
277fn detect_linux_libc() -> &'static str {
278    let output = std::process::Command::new("ldd").arg("--version").output();
279    if let Ok(out) = output {
280        let text = String::from_utf8_lossy(&out.stdout);
281        let stderr = String::from_utf8_lossy(&out.stderr);
282        let combined = format!("{text}{stderr}");
283        for line in combined.lines() {
284            if let Some(ver) = line.split_whitespace().last() {
285                let parts: Vec<&str> = ver.split('.').collect();
286                if parts.len() == 2 {
287                    if let (Ok(major), Ok(minor)) =
288                        (parts[0].parse::<u32>(), parts[1].parse::<u32>())
289                    {
290                        if major > 2 || (major == 2 && minor >= 35) {
291                            return "gnu";
292                        }
293                        return "musl";
294                    }
295                }
296            }
297        }
298    }
299    "musl"
300}
301
302fn platform_asset_name() -> String {
303    let os = std::env::consts::OS;
304    let arch = std::env::consts::ARCH;
305
306    let target = match (os, arch) {
307        ("macos", "aarch64") => "aarch64-apple-darwin".to_string(),
308        ("macos", "x86_64") => "x86_64-apple-darwin".to_string(),
309        ("linux", "x86_64") => format!("x86_64-unknown-linux-{}", detect_linux_libc()),
310        ("linux", "aarch64") => format!("aarch64-unknown-linux-{}", detect_linux_libc()),
311        ("windows", "x86_64") => "x86_64-pc-windows-msvc".to_string(),
312        _ => {
313            eprintln!(
314                "Unsupported platform: {os}/{arch}. Download manually from \
315                https://github.com/yvgude/lean-ctx/releases/latest"
316            );
317            std::process::exit(1);
318        }
319    };
320
321    if os == "windows" {
322        format!("lean-ctx-{target}.zip")
323    } else {
324        format!("lean-ctx-{target}.tar.gz")
325    }
326}