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 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 #[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 #[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", "-", ¤t_exe.display().to_string()])
286 .output();
287 }
288
289 Ok(())
290 }
291}
292
293#[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}