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, ¤t_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 #[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 #[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", "-", ¤t_exe.display().to_string()])
218 .output();
219 }
220
221 Ok(())
222 }
223}
224
225#[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}