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