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    println!("  \x1b[1;32m✓ Updated to lean-ctx v{latest_tag}\x1b[0m");
78    println!("  \x1b[2mBinary: {}\x1b[0m", current_exe.display());
79
80    println!();
81    println!("  \x1b[36m\x1b[1mUpdating agent rules & hooks…\x1b[0m");
82    post_update_refresh();
83
84    println!();
85    crate::terminal_ui::print_logo_animated();
86    println!();
87    println!("  \x1b[33m\x1b[1m⟳ Restart your IDE and shell to activate the new version.\x1b[0m");
88    println!("    \x1b[2mClose and re-open Cursor, VS Code, Claude Code, etc. completely.\x1b[0m");
89    println!("    \x1b[2mThe MCP server must reconnect to use the updated binary.\x1b[0m");
90    println!(
91        "    \x1b[2mRun 'source ~/.zshrc' (or restart terminal) for updated shell aliases.\x1b[0m"
92    );
93    println!();
94}
95
96fn post_update_refresh() {
97    if let Some(home) = dirs::home_dir() {
98        let rules_result = crate::rules_inject::inject_all_rules(&home);
99        let rules_count = rules_result.injected.len() + rules_result.updated.len();
100        if rules_count > 0 {
101            let names: Vec<String> = rules_result
102                .injected
103                .iter()
104                .chain(rules_result.updated.iter())
105                .cloned()
106                .collect();
107            println!("    \x1b[32m✓\x1b[0m Rules updated: {}", names.join(", "));
108        }
109        if !rules_result.already.is_empty() {
110            println!(
111                "    \x1b[32m✓\x1b[0m Rules up-to-date: {}",
112                rules_result.already.join(", ")
113            );
114        }
115
116        crate::hooks::refresh_installed_hooks();
117        println!("    \x1b[32m✓\x1b[0m Hook scripts refreshed");
118
119        refresh_shell_aliases(&home);
120    }
121}
122
123fn refresh_shell_aliases(home: &std::path::Path) {
124    let binary = std::env::current_exe()
125        .map(|p| p.to_string_lossy().to_string())
126        .unwrap_or_else(|_| "lean-ctx".to_string());
127    let bash_binary = crate::hooks::to_bash_compatible_path(&binary);
128
129    let shell_configs: &[(&str, &str)] = &[
130        (".zshrc", "zsh"),
131        (".bashrc", "bash"),
132        (".config/fish/config.fish", "fish"),
133    ];
134
135    let mut updated = false;
136
137    for (rc_file, shell_name) in shell_configs {
138        let rc_path = home.join(rc_file);
139        if !rc_path.exists() {
140            continue;
141        }
142        let content = match std::fs::read_to_string(&rc_path) {
143            Ok(c) => c,
144            Err(_) => continue,
145        };
146        if !content.contains("lean-ctx shell hook") {
147            continue;
148        }
149
150        match *shell_name {
151            "zsh" => crate::cli::init_posix(true, &bash_binary),
152            "bash" => crate::cli::init_posix(false, &bash_binary),
153            "fish" => crate::cli::init_fish(&bash_binary),
154            _ => continue,
155        }
156        println!("    \x1b[32m✓\x1b[0m Shell aliases updated (~/{rc_file})");
157        updated = true;
158    }
159
160    #[cfg(windows)]
161    {
162        let ps_profile = home
163            .join("Documents")
164            .join("PowerShell")
165            .join("Microsoft.PowerShell_profile.ps1");
166        if ps_profile.exists() {
167            if let Ok(content) = std::fs::read_to_string(&ps_profile) {
168                if content.contains("lean-ctx shell hook") {
169                    crate::cli::init_powershell(&binary);
170                    println!("    \x1b[32m✓\x1b[0m PowerShell aliases updated");
171                    updated = true;
172                }
173            }
174        }
175    }
176
177    if !updated {
178        println!(
179            "    \x1b[2m—\x1b[0m No shell aliases to refresh (run 'lean-ctx setup' to install)"
180        );
181    }
182}
183
184fn fetch_latest_release() -> Result<serde_json::Value, String> {
185    let response = ureq::get(GITHUB_API_RELEASES)
186        .header("User-Agent", &format!("lean-ctx/{CURRENT_VERSION}"))
187        .header("Accept", "application/vnd.github.v3+json")
188        .call()
189        .map_err(|e| e.to_string())?;
190
191    response
192        .into_body()
193        .read_to_string()
194        .map_err(|e| e.to_string())
195        .and_then(|s| serde_json::from_str(&s).map_err(|e| e.to_string()))
196}
197
198fn find_asset_url(release: &serde_json::Value, asset_name: &str) -> Option<String> {
199    release["assets"]
200        .as_array()?
201        .iter()
202        .find(|a| a["name"].as_str() == Some(asset_name))
203        .and_then(|a| a["browser_download_url"].as_str())
204        .map(|s| s.to_string())
205}
206
207fn download_bytes(url: &str) -> Result<Vec<u8>, String> {
208    let response = ureq::get(url)
209        .header("User-Agent", &format!("lean-ctx/{CURRENT_VERSION}"))
210        .call()
211        .map_err(|e| e.to_string())?;
212
213    let mut bytes = Vec::new();
214    response
215        .into_body()
216        .into_reader()
217        .read_to_end(&mut bytes)
218        .map_err(|e| e.to_string())?;
219    Ok(bytes)
220}
221
222fn replace_binary(
223    archive_bytes: &[u8],
224    asset_name: &str,
225    current_exe: &std::path::Path,
226) -> Result<(), String> {
227    let binary_bytes = if asset_name.ends_with(".zip") {
228        extract_from_zip(archive_bytes)?
229    } else {
230        extract_from_tar_gz(archive_bytes)?
231    };
232
233    let tmp_path = current_exe.with_extension("tmp");
234    std::fs::write(&tmp_path, &binary_bytes).map_err(|e| e.to_string())?;
235
236    #[cfg(unix)]
237    {
238        use std::os::unix::fs::PermissionsExt;
239        let _ = std::fs::set_permissions(&tmp_path, std::fs::Permissions::from_mode(0o755));
240    }
241
242    // On Windows, a running executable can be renamed but not overwritten.
243    // Move the current binary out of the way first, then move the new one in.
244    // If the file is locked (MCP server running), schedule a deferred update.
245    #[cfg(windows)]
246    {
247        let old_path = current_exe.with_extension("old.exe");
248        let _ = std::fs::remove_file(&old_path);
249
250        match std::fs::rename(current_exe, &old_path) {
251            Ok(()) => {
252                if let Err(e) = std::fs::rename(&tmp_path, current_exe) {
253                    let _ = std::fs::rename(&old_path, current_exe);
254                    let _ = std::fs::remove_file(&tmp_path);
255                    return Err(format!("Cannot place new binary: {e}"));
256                }
257                let _ = std::fs::remove_file(&old_path);
258                return Ok(());
259            }
260            Err(_) => {
261                return deferred_windows_update(&tmp_path, current_exe);
262            }
263        }
264    }
265
266    #[cfg(not(windows))]
267    {
268        // On macOS, rename-over-running-binary causes SIGKILL because the kernel
269        // re-validates code pages against the (now different) on-disk file.
270        // Unlinking first is safe: the kernel keeps the old memory-mapped pages
271        // from the deleted inode, while the new file gets a fresh inode at the path.
272        #[cfg(target_os = "macos")]
273        {
274            let _ = std::fs::remove_file(current_exe);
275        }
276
277        std::fs::rename(&tmp_path, current_exe).map_err(|e| {
278            let _ = std::fs::remove_file(&tmp_path);
279            format!("Cannot replace binary (permission denied?): {e}")
280        })?;
281
282        #[cfg(target_os = "macos")]
283        {
284            let _ = std::process::Command::new("codesign")
285                .args(["--force", "-s", "-", &current_exe.display().to_string()])
286                .output();
287        }
288
289        Ok(())
290    }
291}
292
293/// On Windows, when the binary is locked by an MCP server, we can't rename it.
294/// Instead, stage the new binary and spawn a background cmd process that waits
295/// for the lock to be released, then performs the swap.
296#[cfg(windows)]
297fn deferred_windows_update(
298    staged_path: &std::path::Path,
299    target_exe: &std::path::Path,
300) -> Result<(), String> {
301    let pending_path = target_exe.with_file_name("lean-ctx-pending.exe");
302    std::fs::rename(staged_path, &pending_path).map_err(|e| {
303        let _ = std::fs::remove_file(staged_path);
304        format!("Cannot stage update: {e}")
305    })?;
306
307    let target_str = target_exe.display().to_string();
308    let pending_str = pending_path.display().to_string();
309    let old_str = target_exe.with_extension("old.exe").display().to_string();
310
311    let script = format!(
312        r#"@echo off
313echo Waiting for lean-ctx to be released...
314:retry
315timeout /t 1 /nobreak >nul
316move /Y "{target}" "{old}" >nul 2>&1
317if errorlevel 1 goto retry
318move /Y "{pending}" "{target}" >nul 2>&1
319if errorlevel 1 (
320    move /Y "{old}" "{target}" >nul 2>&1
321    echo Update failed. Please close all editors and run: lean-ctx update
322    pause
323    exit /b 1
324)
325del /f "{old}" >nul 2>&1
326echo Updated successfully!
327del "%~f0" >nul 2>&1
328"#,
329        target = target_str,
330        pending = pending_str,
331        old = old_str,
332    );
333
334    let script_path = target_exe.with_file_name("lean-ctx-update.bat");
335    std::fs::write(&script_path, &script)
336        .map_err(|e| format!("Cannot write update script: {e}"))?;
337
338    let _ = std::process::Command::new("cmd")
339        .args(["/C", "start", "/MIN", &script_path.display().to_string()])
340        .spawn();
341
342    println!("\nThe binary is currently in use by your AI editor's MCP server.");
343    println!("A background update has been scheduled.");
344    println!(
345        "Close your editor (Cursor, VS Code, etc.) and the update will complete automatically."
346    );
347    println!("Or run the script manually: {}", script_path.display());
348
349    Ok(())
350}
351
352fn extract_from_tar_gz(data: &[u8]) -> Result<Vec<u8>, String> {
353    use flate2::read::GzDecoder;
354
355    let gz = GzDecoder::new(data);
356    let mut archive = tar::Archive::new(gz);
357
358    for entry in archive.entries().map_err(|e| e.to_string())? {
359        let mut entry = entry.map_err(|e| e.to_string())?;
360        let path = entry.path().map_err(|e| e.to_string())?;
361        let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
362
363        if name == "lean-ctx" || name == "lean-ctx.exe" {
364            let mut bytes = Vec::new();
365            entry.read_to_end(&mut bytes).map_err(|e| e.to_string())?;
366            return Ok(bytes);
367        }
368    }
369    Err("lean-ctx binary not found inside archive".to_string())
370}
371
372fn extract_from_zip(data: &[u8]) -> Result<Vec<u8>, String> {
373    use std::io::Cursor;
374
375    let cursor = Cursor::new(data);
376    let mut zip = zip::ZipArchive::new(cursor).map_err(|e| e.to_string())?;
377
378    for i in 0..zip.len() {
379        let mut file = zip.by_index(i).map_err(|e| e.to_string())?;
380        let name = file.name().to_string();
381        if name == "lean-ctx.exe" || name == "lean-ctx" {
382            let mut bytes = Vec::new();
383            file.read_to_end(&mut bytes).map_err(|e| e.to_string())?;
384            return Ok(bytes);
385        }
386    }
387    Err("lean-ctx binary not found inside zip archive".to_string())
388}
389
390fn detect_linux_libc() -> &'static str {
391    let output = std::process::Command::new("ldd").arg("--version").output();
392    if let Ok(out) = output {
393        let text = String::from_utf8_lossy(&out.stdout);
394        let stderr = String::from_utf8_lossy(&out.stderr);
395        let combined = format!("{text}{stderr}");
396        for line in combined.lines() {
397            if let Some(ver) = line.split_whitespace().last() {
398                let parts: Vec<&str> = ver.split('.').collect();
399                if parts.len() == 2 {
400                    if let (Ok(major), Ok(minor)) =
401                        (parts[0].parse::<u32>(), parts[1].parse::<u32>())
402                    {
403                        if major > 2 || (major == 2 && minor >= 35) {
404                            return "gnu";
405                        }
406                        return "musl";
407                    }
408                }
409            }
410        }
411    }
412    "musl"
413}
414
415fn platform_asset_name() -> String {
416    let os = std::env::consts::OS;
417    let arch = std::env::consts::ARCH;
418
419    let target = match (os, arch) {
420        ("macos", "aarch64") => "aarch64-apple-darwin".to_string(),
421        ("macos", "x86_64") => "x86_64-apple-darwin".to_string(),
422        ("linux", "x86_64") => format!("x86_64-unknown-linux-{}", detect_linux_libc()),
423        ("linux", "aarch64") => format!("aarch64-unknown-linux-{}", detect_linux_libc()),
424        ("windows", "x86_64") => "x86_64-pc-windows-msvc".to_string(),
425        _ => {
426            eprintln!(
427                "Unsupported platform: {os}/{arch}. Download manually from \
428                https://github.com/yvgude/lean-ctx/releases/latest"
429            );
430            std::process::exit(1);
431        }
432    };
433
434    if os == "windows" {
435        format!("lean-ctx-{target}.zip")
436    } else {
437        format!("lean-ctx-{target}.tar.gz")
438    }
439}