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, ¤t_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 #[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#[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}