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 mut check_only = args.iter().any(|a| a == "--check");
8 let insecure = args.iter().any(|a| a == "--insecure");
9 let quiet = args.iter().any(|a| a == "--quiet");
10 let skip_rules = args.iter().any(|a| a == "--skip-rules");
11 let scheduled = args.iter().any(|a| a == "--scheduled");
15
16 if let Some(pos) = args.iter().position(|a| a == "--schedule") {
18 let sub = args.get(pos + 1).map_or("", String::as_str);
19 match sub {
20 "off" | "disable" => {
21 if let Err(e) = crate::core::update_scheduler::remove_schedule() {
22 eprintln!(" \x1b[31m✗\x1b[0m Failed to disable auto-updates: {e}");
23 std::process::exit(1);
24 }
25 crate::core::update_scheduler::set_auto_update(false, false, 6);
26 println!(" \x1b[32m✓\x1b[0m Auto-updates disabled.");
27 println!(" \x1b[2mRe-enable anytime: lean-ctx update --schedule\x1b[0m");
28 return;
29 }
30 "status" => {
31 let info = crate::core::update_scheduler::schedule_status();
32 println!();
33 println!(" {info}");
34 println!();
35 return;
36 }
37 "notify" => {
38 let cfg = crate::core::config::Config::load();
39 let hours = cfg.updates.check_interval_hours;
40 match crate::core::update_scheduler::install_schedule(hours) {
41 Ok(info) => {
42 crate::core::update_scheduler::set_auto_update(true, true, hours);
43 println!(" \x1b[32m✓\x1b[0m Update notifications enabled ({info})");
44 println!(
45 " \x1b[2mYou'll be notified but updates won't install automatically.\x1b[0m"
46 );
47 }
48 Err(e) => {
49 eprintln!(" \x1b[31m✗\x1b[0m {e}");
50 std::process::exit(1);
51 }
52 }
53 return;
54 }
55 _ => {
56 let hours = if sub.is_empty() {
57 6
58 } else {
59 sub.trim_end_matches('h')
60 .parse::<u64>()
61 .unwrap_or(6)
62 .clamp(1, 168)
63 };
64 match crate::core::update_scheduler::install_schedule(hours) {
65 Ok(info) => {
66 crate::core::update_scheduler::set_auto_update(true, false, hours);
67 println!();
68 println!(" \x1b[32m✓\x1b[0m {info}");
69 println!(" \x1b[2mDisable anytime: lean-ctx update --schedule off\x1b[0m");
70 println!();
71 }
72 Err(e) => {
73 eprintln!(" \x1b[31m✗\x1b[0m Failed to enable auto-updates: {e}");
74 std::process::exit(1);
75 }
76 }
77 return;
78 }
79 }
80 }
81
82 let target_version: Option<String> = match parse_target_version(args) {
87 None => None,
88 Some(v) if looks_like_version(v) => Some(v.trim_start_matches('v').to_string()),
89 Some(other) => {
90 eprintln!(" \x1b[31m✗\x1b[0m '{other}' is not a valid version.");
91 eprintln!(
92 " \x1b[2mUsage: lean-ctx update [<version>] (e.g. lean-ctx update 3.8.5)\x1b[0m"
93 );
94 eprintln!(
95 " \x1b[2mAvailable versions: https://github.com/yvgude/lean-ctx/releases\x1b[0m"
96 );
97 std::process::exit(1);
98 }
99 };
100 let pinned = target_version.is_some();
101
102 if (quiet || scheduled) && !check_only {
110 let cfg = crate::core::config::Config::load();
111 match automatic_update_gate(cfg.updates.auto_update, cfg.updates.notify_only) {
112 AutoUpdateGate::Skip => {
113 if let Err(e) = crate::core::update_scheduler::remove_schedule() {
114 tracing::warn!(
115 "auto-update disabled in config; failed to remove orphaned scheduler: {e}"
116 );
117 } else {
118 tracing::info!(
119 "auto-update disabled (updates.auto_update=false): skipped scheduled update and removed orphaned scheduler"
120 );
121 }
122 return;
123 }
124 AutoUpdateGate::NotifyOnly => {
125 check_only = true;
126 }
127 AutoUpdateGate::Proceed => {}
128 }
129 }
130
131 if !quiet {
132 println!();
133 println!(" \x1b[1m◆ lean-ctx updater\x1b[0m \x1b[2mv{CURRENT_VERSION}\x1b[0m");
134 println!(" \x1b[2mChecking github.com/yvgude/lean-ctx …\x1b[0m");
135 }
136
137 let release = match fetch_release(target_version.as_deref()) {
138 Ok(r) => r,
139 Err(e) => {
140 if let Some(v) = &target_version {
141 tracing::error!("Could not fetch lean-ctx v{v}: {e}");
142 tracing::error!(
143 "Check the version exists: https://github.com/yvgude/lean-ctx/releases"
144 );
145 } else {
146 tracing::error!("Error fetching release info: {e}");
147 }
148 std::process::exit(1);
149 }
150 };
151
152 let target_tag = if let Some(t) = release["tag_name"].as_str() {
153 t.trim_start_matches('v').to_string()
154 } else {
155 tracing::error!("Could not parse release tag from GitHub API.");
156 std::process::exit(1);
157 };
158
159 if target_tag == CURRENT_VERSION {
160 if quiet {
161 return;
162 }
163 if pinned {
164 println!(" \x1b[32m✓\x1b[0m Already on v{CURRENT_VERSION}.");
165 } else {
166 println!(" \x1b[32m✓\x1b[0m Already up to date (v{CURRENT_VERSION}).");
167 }
168 println!(
169 " \x1b[2mIf your IDE still uses an older version, restart it to reconnect the MCP server.\x1b[0m"
170 );
171 println!();
172 if !check_only {
173 if skip_rules {
174 println!(
175 " \x1b[36m\x1b[1mRefreshing setup (shell hook, MCP configs — rules skipped)…\x1b[0m"
176 );
177 } else {
178 println!(
179 " \x1b[36m\x1b[1mRefreshing setup (shell hook, MCP configs, rules)…\x1b[0m"
180 );
181 }
182 post_update_rewire(skip_rules);
183 println!();
184 }
185 return;
186 }
187
188 if !quiet {
189 if pinned {
190 println!(
191 " Switching: v{CURRENT_VERSION} → \x1b[1;36mv{target_tag}\x1b[0m \x1b[2m(data & logs preserved)\x1b[0m"
192 );
193 } else {
194 println!(" Update available: v{CURRENT_VERSION} → \x1b[1;32mv{target_tag}\x1b[0m");
195 }
196 }
197
198 if check_only {
199 if pinned {
200 println!("Run 'lean-ctx update {target_tag}' to install.");
201 } else {
202 println!("Run 'lean-ctx update' to install.");
203 }
204 return;
205 }
206
207 let asset_name = platform_asset_name();
208 if !quiet {
209 println!(" \x1b[2mDownloading {asset_name} …\x1b[0m");
210 }
211
212 let Some(download_url) = find_asset_url(&release, &asset_name) else {
213 tracing::error!(
214 "No binary found for this platform ({asset_name}) in v{target_tag}. Download manually: https://github.com/yvgude/lean-ctx/releases"
215 );
216 std::process::exit(1);
217 };
218
219 let bytes = match download_bytes(&download_url) {
220 Ok(b) => b,
221 Err(e) => {
222 tracing::error!("Download failed: {e}");
223 std::process::exit(1);
224 }
225 };
226
227 if let Err(e) = verify_download_integrity(&release, &asset_name, &bytes) {
228 if insecure {
229 tracing::warn!("Integrity verification failed: {e}");
230 tracing::warn!("Proceeding due to --insecure");
231 } else {
232 tracing::error!("Integrity verification failed: {e}");
233 tracing::error!(
234 "Refusing to install an unverifiable binary. Re-run with `lean-ctx update --insecure` or download manually: https://github.com/yvgude/lean-ctx/releases"
235 );
236 std::process::exit(1);
237 }
238 }
239
240 let current_exe = match std::env::current_exe() {
241 Ok(p) => p,
242 Err(e) => {
243 tracing::error!("Cannot locate current executable: {e}");
244 std::process::exit(1);
245 }
246 };
247
248 if let Err(e) = replace_binary(&bytes, &asset_name, ¤t_exe) {
249 tracing::error!("Failed to replace binary: {e}");
250 tracing::warn!("Continuing with a setup refresh so your wiring stays correct");
251 post_update_rewire(skip_rules);
252 std::process::exit(1);
253 }
254
255 if quiet {
256 println!(" lean-ctx v{CURRENT_VERSION} → v{target_tag}");
257 } else {
258 println!();
259 if pinned {
260 println!(" \x1b[1;32m✓ Now running lean-ctx v{target_tag}\x1b[0m");
261 } else {
262 println!(" \x1b[1;32m✓ Updated to lean-ctx v{target_tag}\x1b[0m");
263 }
264 println!(" \x1b[2mBinary: {}\x1b[0m", current_exe.display());
265 }
266
267 if !quiet {
268 println!();
269 if skip_rules {
270 println!(
271 " \x1b[36m\x1b[1mRefreshing setup (shell hook, MCP configs — rules skipped)…\x1b[0m"
272 );
273 } else {
274 println!(" \x1b[36m\x1b[1mRefreshing setup (shell hook, MCP configs, rules)…\x1b[0m");
275 }
276 }
277 post_update_rewire(skip_rules);
278
279 if !quiet {
280 println!();
281 crate::terminal_ui::print_logo_animated();
282 println!();
283 println!(
284 " \x1b[33m\x1b[1m⟳ Restart your IDE and shell to activate the new version.\x1b[0m"
285 );
286 println!(
287 " \x1b[2mClose and re-open Cursor, VS Code, Claude Code, etc. completely.\x1b[0m"
288 );
289 println!(" \x1b[2mThe MCP server must reconnect to use the updated binary.\x1b[0m");
290 println!(
291 " \x1b[2m{}\x1b[0m",
292 crate::shell_hook::reload_aliases_hint()
293 );
294 }
295 println!();
296
297 if !quiet
298 && !crate::core::update_scheduler::has_user_decided()
299 && std::io::IsTerminal::is_terminal(&std::io::stdin())
300 {
301 print!(" Want to get updates like this automatically? \x1b[1m[y/N]\x1b[0m ");
302 use std::io::Write;
303 std::io::stdout().flush().ok();
304 let mut input = String::new();
305 if std::io::stdin().read_line(&mut input).is_ok() {
306 let answer = input.trim().to_lowercase();
307 if answer == "y" || answer == "yes" {
308 let cfg = crate::core::config::Config::load();
309 let hours = cfg.updates.check_interval_hours;
310 match crate::core::update_scheduler::install_schedule(hours) {
311 Ok(info) => {
312 crate::core::update_scheduler::set_auto_update(true, false, hours);
313 println!(" \x1b[32m✓\x1b[0m {info}");
314 println!(" \x1b[2mDisable anytime: lean-ctx update --schedule off\x1b[0m");
315 }
316 Err(e) => println!(" \x1b[33m⚠\x1b[0m Could not set up scheduler: {e}"),
317 }
318 } else {
319 crate::core::update_scheduler::set_auto_update(false, false, 6);
320 println!(" \x1b[2m○ Skipped — enable later: lean-ctx update --schedule\x1b[0m");
321 }
322 }
323 }
324}
325
326#[derive(Debug, Clone, Copy, PartialEq, Eq)]
328enum AutoUpdateGate {
329 Proceed,
331 Skip,
333 NotifyOnly,
335}
336
337fn automatic_update_gate(auto_update: bool, notify_only: bool) -> AutoUpdateGate {
340 if !auto_update {
341 AutoUpdateGate::Skip
342 } else if notify_only {
343 AutoUpdateGate::NotifyOnly
344 } else {
345 AutoUpdateGate::Proceed
346 }
347}
348
349fn verify_download_integrity(
350 release: &serde_json::Value,
351 asset_name: &str,
352 bytes: &[u8],
353) -> Result<(), String> {
354 #[cfg(not(feature = "secure-update"))]
355 {
356 let _ = (release, asset_name, bytes);
357 return Err("secure-update feature disabled (sha256 verification unavailable)".to_string());
358 }
359
360 #[cfg(feature = "secure-update")]
361 {
362 let computed = sha256_hex(bytes);
363
364 let Some((checksum_url, kind)) = find_checksum_asset_url(release, asset_name) else {
365 return Err(
366 "no checksum asset found for this release (expected SHA256SUMS or *.sha256)"
367 .to_string(),
368 );
369 };
370 let checksum_bytes = download_bytes(&checksum_url)?;
371 let checksum_text = String::from_utf8_lossy(&checksum_bytes).to_string();
372
373 let expected = match kind {
374 ChecksumAssetKind::SingleSha256 => parse_single_sha256(&checksum_text),
375 ChecksumAssetKind::Sha256Sums => parse_sha256sums(&checksum_text, asset_name),
376 }
377 .ok_or_else(|| format!("checksum file did not contain an entry for {asset_name}"))?;
378
379 if !constant_time_eq(computed.as_bytes(), expected.as_bytes()) {
380 return Err(format!(
381 "sha256 mismatch for {asset_name}: expected {expected}, got {computed}"
382 ));
383 }
384 Ok(())
385 }
386}
387
388#[derive(Debug, Clone, Copy)]
389enum ChecksumAssetKind {
390 Sha256Sums,
391 SingleSha256,
392}
393
394fn find_checksum_asset_url(
395 release: &serde_json::Value,
396 asset_name: &str,
397) -> Option<(String, ChecksumAssetKind)> {
398 let candidates = [
400 format!("{asset_name}.sha256"),
401 format!("{asset_name}.sha256.txt"),
402 "SHA256SUMS".to_string(),
403 "SHA256SUMS.txt".to_string(),
404 "sha256sums.txt".to_string(),
405 "checksums.txt".to_string(),
406 ];
407
408 for c in candidates {
409 if let Some(url) = find_asset_url(release, &c) {
410 let kind = if c.to_lowercase().contains("sha256sums")
411 || c.to_uppercase() == "SHA256SUMS"
412 || c.to_lowercase().contains("checksums")
413 {
414 ChecksumAssetKind::Sha256Sums
415 } else {
416 ChecksumAssetKind::SingleSha256
417 };
418 return Some((url, kind));
419 }
420 }
421 None
422}
423
424fn parse_single_sha256(text: &str) -> Option<String> {
425 let t = text.trim();
426 let first = t.split_whitespace().next().unwrap_or("").trim();
427 if first.len() == 64 && first.chars().all(|c| c.is_ascii_hexdigit()) {
428 Some(first.to_ascii_lowercase())
429 } else {
430 None
431 }
432}
433
434fn parse_sha256sums(text: &str, asset_name: &str) -> Option<String> {
435 for line in text.lines() {
436 let l = line.trim();
437 if l.is_empty() || l.starts_with('#') {
438 continue;
439 }
440 let mut parts = l.split_whitespace();
441 let hash = parts.next().unwrap_or("");
442 let file = parts.next().unwrap_or("");
443 if file == asset_name && hash.len() == 64 && hash.chars().all(|c| c.is_ascii_hexdigit()) {
444 return Some(hash.to_ascii_lowercase());
445 }
446 }
447 None
448}
449
450fn sha256_hex(bytes: &[u8]) -> String {
451 use sha2::{Digest, Sha256};
452 let mut h = Sha256::new();
453 h.update(bytes);
454 let out = h.finalize();
455 hex_lower(&out)
456}
457
458fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
459 if a.len() != b.len() {
460 return false;
461 }
462 a.iter()
463 .zip(b.iter())
464 .fold(0u8, |acc, (x, y)| acc | (x ^ y))
465 == 0
466}
467
468fn hex_lower(bytes: &[u8]) -> String {
469 const HEX: &[u8; 16] = b"0123456789abcdef";
470 let mut out = String::with_capacity(bytes.len() * 2);
471 for &b in bytes {
472 out.push(HEX[(b >> 4) as usize] as char);
473 out.push(HEX[(b & 0x0f) as usize] as char);
474 }
475 out
476}
477
478fn post_update_rewire(skip_rules: bool) {
479 #[cfg(target_os = "macos")]
485 rewrap_launchagents_for_tcc();
486
487 if crate::core::config::Config::load_global()
491 .proxy_enabled
492 .is_none()
493 && crate::proxy_autostart::is_installed()
494 {
495 match crate::core::config::Config::update_global(|c| c.proxy_enabled = Some(true)) {
496 Ok(_) => {
497 eprintln!(" \u{2139} Proxy was already active \u{2014} keeping enabled.");
498 eprintln!(" Disable anytime: lean-ctx proxy disable");
499 }
500 Err(e) => tracing::warn!("could not persist proxy_enabled during update: {e}"),
501 }
502 }
503
504 let cfg = crate::core::config::Config::load();
506 let proxy_active = cfg.proxy_enabled == Some(true);
507
508 let effective_skip_rules = if skip_rules {
511 true
512 } else {
513 !cfg.setup.should_inject_rules()
514 };
515
516 if proxy_active {
518 restart_proxy_if_running();
519 wait_for_proxy_health(crate::proxy_setup::default_port());
520 }
521
522 let opts = crate::setup::SetupOptions {
524 non_interactive: true,
525 yes: true,
526 fix: true,
527 skip_proxy: !proxy_active,
528 skip_rules: effective_skip_rules,
529 ..Default::default()
530 };
531 if let Err(e) = crate::setup::run_setup_with_options(opts) {
532 tracing::error!("Setup refresh error: {e}");
533 }
534}
535
536#[cfg(target_os = "macos")]
542fn rewrap_launchagents_for_tcc() {
543 if crate::proxy_autostart::is_installed() {
544 crate::proxy_autostart::install(crate::proxy_setup::default_port(), true);
545 }
546 if crate::daemon_autostart::is_installed() {
547 crate::daemon_autostart::install(true);
548 }
549 if crate::core::update_scheduler::schedule_status().enabled {
550 let hours = crate::core::config::Config::load()
551 .updates
552 .check_interval_hours;
553 if let Err(e) = crate::core::update_scheduler::install_schedule(hours) {
554 tracing::warn!("#356 re-wrap of auto-update LaunchAgent failed: {e}");
555 }
556 }
557}
558
559fn wait_for_proxy_health(port: u16) {
560 let max_attempts = 20;
561 for i in 0..max_attempts {
562 if is_proxy_reachable(port) {
563 println!(" \x1b[32m✓\x1b[0m Proxy healthy on port {port}");
564 return;
565 }
566 if i == 0 {
567 print!(" \x1b[2mWaiting for proxy to become healthy");
568 }
569 print!(".");
570 use std::io::Write;
571 std::io::stdout().flush().ok();
572 std::thread::sleep(std::time::Duration::from_millis(500));
573 }
574 println!();
575 eprintln!(
576 " \x1b[33m⚠\x1b[0m Proxy did not respond within {}s — writing env vars anyway",
577 max_attempts / 2
578 );
579 eprintln!(" If Claude Code shows connection errors, run: lean-ctx proxy start");
580}
581
582fn restart_proxy_if_running() {
583 let port = crate::proxy_setup::default_port();
584
585 if restart_managed_proxy() {
586 return;
587 }
588
589 if is_proxy_reachable(port) {
590 println!(
591 " \x1b[33m⟳\x1b[0m Proxy running on port {port} — restart it to use the new binary:"
592 );
593 println!(" \x1b[1mlean-ctx proxy start --port={port}\x1b[0m");
594 }
595}
596
597fn restart_managed_proxy() -> bool {
600 #[cfg(target_os = "macos")]
601 {
602 let plist_path = dirs::home_dir()
603 .unwrap_or_default()
604 .join("Library/LaunchAgents/com.leanctx.proxy.plist");
605 if plist_path.exists() {
606 if crate::core::launchd::bootstrap("com.leanctx.proxy", &plist_path) {
607 println!(" \x1b[32m✓\x1b[0m Proxy restarted (LaunchAgent)");
608 } else {
609 println!(" \x1b[33m⚠\x1b[0m Could not restart proxy LaunchAgent");
610 }
611 return true;
612 }
613 }
614
615 #[cfg(target_os = "linux")]
616 {
617 let service_path = dirs::home_dir()
618 .unwrap_or_default()
619 .join(".config/systemd/user/lean-ctx-proxy.service");
620 if service_path.exists() {
621 let result = std::process::Command::new("systemctl")
622 .args(["--user", "restart", "lean-ctx-proxy"])
623 .output();
624 match result {
625 Ok(o) if o.status.success() => {
626 println!(" \x1b[32m✓\x1b[0m Proxy restarted (systemd)");
627 }
628 _ => {
629 println!(" \x1b[33m⚠\x1b[0m Could not restart proxy systemd service");
630 }
631 }
632 return true;
633 }
634 }
635
636 false
637}
638
639fn is_proxy_reachable(port: u16) -> bool {
640 ureq::get(&format!("http://127.0.0.1:{port}/health"))
641 .call()
642 .is_ok()
643}
644
645fn release_api_url(version: Option<&str>) -> String {
649 match version {
650 None => GITHUB_API_RELEASES.to_string(),
651 Some(v) => {
652 let core = v.trim_start_matches('v');
653 format!("https://api.github.com/repos/yvgude/lean-ctx/releases/tags/v{core}")
654 }
655 }
656}
657
658fn fetch_release(version: Option<&str>) -> Result<serde_json::Value, String> {
661 let response = ureq::get(&release_api_url(version))
662 .header("User-Agent", &format!("lean-ctx/{CURRENT_VERSION}"))
663 .header("Accept", "application/vnd.github.v3+json")
664 .call()
665 .map_err(|e| e.to_string())?;
666
667 response
668 .into_body()
669 .read_to_string()
670 .map_err(|e| e.to_string())
671 .and_then(|s| serde_json::from_str(&s).map_err(|e| e.to_string()))
672}
673
674fn parse_target_version(args: &[String]) -> Option<&str> {
678 args.iter()
679 .map(String::as_str)
680 .find(|a| !a.starts_with('-'))
681}
682
683fn looks_like_version(s: &str) -> bool {
686 let core = s.strip_prefix('v').unwrap_or(s);
687 core.contains('.')
688 && core.starts_with(|c: char| c.is_ascii_digit())
689 && core
690 .chars()
691 .all(|c| c.is_ascii_digit() || c == '.' || c == '-' || c.is_ascii_alphabetic())
692}
693
694fn find_asset_url(release: &serde_json::Value, asset_name: &str) -> Option<String> {
695 release["assets"]
696 .as_array()?
697 .iter()
698 .find(|a| a["name"].as_str() == Some(asset_name))
699 .and_then(|a| a["browser_download_url"].as_str())
700 .map(std::string::ToString::to_string)
701}
702
703fn download_bytes(url: &str) -> Result<Vec<u8>, String> {
704 let response = ureq::get(url)
705 .header("User-Agent", &format!("lean-ctx/{CURRENT_VERSION}"))
706 .call()
707 .map_err(|e| e.to_string())?;
708
709 let mut bytes = Vec::new();
710 response
711 .into_body()
712 .into_reader()
713 .read_to_end(&mut bytes)
714 .map_err(|e| e.to_string())?;
715 Ok(bytes)
716}
717
718fn replace_binary(
719 archive_bytes: &[u8],
720 asset_name: &str,
721 current_exe: &std::path::Path,
722) -> Result<(), String> {
723 let binary_bytes = if std::path::Path::new(asset_name)
724 .extension()
725 .is_some_and(|e| e.eq_ignore_ascii_case("zip"))
726 {
727 extract_from_zip(archive_bytes)?
728 } else {
729 extract_from_tar_gz(archive_bytes)?
730 };
731
732 let tmp_path = current_exe.with_extension("tmp");
733 std::fs::write(&tmp_path, &binary_bytes).map_err(|e| e.to_string())?;
734
735 #[cfg(unix)]
736 {
737 use std::os::unix::fs::PermissionsExt;
738 let _ = std::fs::set_permissions(&tmp_path, std::fs::Permissions::from_mode(0o755));
739 }
740
741 #[cfg(windows)]
746 {
747 let old_path = current_exe.with_extension("old.exe");
748 let _ = std::fs::remove_file(&old_path);
749
750 match std::fs::rename(current_exe, &old_path) {
751 Ok(()) => {
752 if let Err(e) = std::fs::rename(&tmp_path, current_exe) {
753 let _ = std::fs::rename(&old_path, current_exe);
754 let _ = std::fs::remove_file(&tmp_path);
755 return Err(format!("Cannot place new binary: {e}"));
756 }
757 let _ = std::fs::remove_file(&old_path);
758 return Ok(());
759 }
760 Err(_) => {
761 eprintln!("\nBinary is locked. Stopping managed lean-ctx processes...");
763 stop_managed_windows_processes();
764
765 std::thread::sleep(std::time::Duration::from_millis(1500));
767
768 let _ = std::fs::remove_file(&old_path);
770 match std::fs::rename(current_exe, &old_path) {
771 Ok(()) => {
772 if let Err(e) = std::fs::rename(&tmp_path, current_exe) {
773 let _ = std::fs::rename(&old_path, current_exe);
774 let _ = std::fs::remove_file(&tmp_path);
775 return Err(format!("Cannot place new binary: {e}"));
776 }
777 let _ = std::fs::remove_file(&old_path);
778 return Ok(());
779 }
780 Err(_) => {
781 print_blocking_processes(current_exe);
783 return deferred_windows_update(&tmp_path, current_exe);
784 }
785 }
786 }
787 }
788 }
789
790 #[cfg(not(windows))]
791 {
792 #[cfg(target_os = "macos")]
797 {
798 let _ = std::fs::remove_file(current_exe);
799 }
800
801 std::fs::rename(&tmp_path, current_exe).map_err(|e| {
802 let _ = std::fs::remove_file(&tmp_path);
803 format!("Cannot replace binary (permission denied?): {e}")
804 })?;
805
806 #[cfg(target_os = "macos")]
809 {
810 let _ = crate::core::codesign::sign_binary(current_exe);
811 }
812
813 Ok(())
814 }
815}
816
817#[cfg(windows)]
820fn stop_managed_windows_processes() {
821 let stop_result = std::process::Command::new("lean-ctx").arg("stop").output();
823
824 match stop_result {
825 Ok(out) if out.status.success() => {
826 eprintln!(" Managed processes stopped.");
827 }
828 _ => {
829 for pattern in &["proxy start", "serve "] {
832 let _ = std::process::Command::new("taskkill")
833 .args([
834 "/F",
835 "/FI",
836 &format!("WINDOWTITLE eq *{pattern}*"),
837 "/IM",
838 "lean-ctx.exe",
839 ])
840 .output();
841 }
842 eprintln!(" Attempted to stop lean-ctx processes via taskkill.");
843 }
844 }
845}
846
847#[cfg(windows)]
849fn print_blocking_processes(target_exe: &std::path::Path) {
850 let target_name = target_exe
851 .file_name()
852 .and_then(|n| n.to_str())
853 .unwrap_or("lean-ctx.exe");
854
855 let output = std::process::Command::new("tasklist")
856 .args([
857 "/FI",
858 &format!("IMAGENAME eq {target_name}"),
859 "/V",
860 "/FO",
861 "CSV",
862 ])
863 .output();
864
865 if let Ok(out) = output {
866 let stdout = String::from_utf8_lossy(&out.stdout);
867 let lines: Vec<&str> = stdout.lines().skip(1).collect(); if !lines.is_empty() {
869 eprintln!("\n Blocking lean-ctx processes:");
870 for line in &lines {
871 let fields: Vec<&str> = line.split(',').collect();
873 if fields.len() >= 2 {
874 let pid = fields[1].trim_matches('"');
875 eprintln!(" PID {pid}");
876 }
877 }
878 eprintln!("\n To stop manually: taskkill /F /PID <pid> (or close your editor)");
879 }
880 }
881}
882
883#[cfg(windows)]
887fn deferred_windows_update(
888 staged_path: &std::path::Path,
889 target_exe: &std::path::Path,
890) -> Result<(), String> {
891 let pending_path = target_exe.with_file_name("lean-ctx-pending.exe");
892 std::fs::rename(staged_path, &pending_path).map_err(|e| {
893 let _ = std::fs::remove_file(staged_path);
894 format!("Cannot stage update: {e}")
895 })?;
896
897 let target_str = target_exe.display().to_string();
898 let pending_str = pending_path.display().to_string();
899 let old_str = target_exe.with_extension("old.exe").display().to_string();
900 let max_retries = 60;
901
902 let script = generate_deferred_bat_script(&target_str, &pending_str, &old_str, max_retries);
903
904 let script_path = target_exe.with_file_name("lean-ctx-update.bat");
905 std::fs::write(&script_path, &script)
906 .map_err(|e| format!("Cannot write update script: {e}"))?;
907
908 let _ = std::process::Command::new("cmd")
909 .args(["/C", "start", "/MIN", &script_path.display().to_string()])
910 .spawn();
911
912 println!("\nThe binary is still in use (likely by your editor's MCP server).");
913 println!("A background update has been scheduled (timeout: {max_retries}s).");
914 println!("Close your editor and the update will complete automatically.");
915 println!("\nIf it times out, run: lean-ctx update");
916 println!("Update script: {}", script_path.display());
917
918 Ok(())
919}
920
921fn extract_from_tar_gz(data: &[u8]) -> Result<Vec<u8>, String> {
922 use flate2::read::GzDecoder;
923
924 let gz = GzDecoder::new(data);
925 let mut archive = tar::Archive::new(gz);
926
927 for entry in archive.entries().map_err(|e| e.to_string())? {
928 let mut entry = entry.map_err(|e| e.to_string())?;
929 let path = entry.path().map_err(|e| e.to_string())?;
930 let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
931
932 if name == "lean-ctx" || name == "lean-ctx.exe" {
933 let mut bytes = Vec::new();
934 entry.read_to_end(&mut bytes).map_err(|e| e.to_string())?;
935 return Ok(bytes);
936 }
937 }
938 Err("lean-ctx binary not found inside archive".to_string())
939}
940
941fn extract_from_zip(data: &[u8]) -> Result<Vec<u8>, String> {
942 use std::io::Cursor;
943
944 let cursor = Cursor::new(data);
945 let mut zip = zip::ZipArchive::new(cursor).map_err(|e| e.to_string())?;
946
947 for i in 0..zip.len() {
948 let mut file = zip.by_index(i).map_err(|e| e.to_string())?;
949 let name = file.name().to_string();
950 if name == "lean-ctx.exe" || name == "lean-ctx" {
951 let mut bytes = Vec::new();
952 file.read_to_end(&mut bytes).map_err(|e| e.to_string())?;
953 return Ok(bytes);
954 }
955 }
956 Err("lean-ctx binary not found inside zip archive".to_string())
957}
958
959#[cfg(any(windows, test))]
961fn generate_deferred_bat_script(
962 target: &str,
963 pending: &str,
964 old: &str,
965 max_retries: u32,
966) -> String {
967 format!(
968 r#"@echo off
969setlocal
970set "RETRIES=0"
971set "MAX_RETRIES={max_retries}"
972
973echo lean-ctx update: waiting for binary to be released (timeout: %MAX_RETRIES%s)...
974echo.
975echo Blocking processes:
976tasklist /FI "IMAGENAME eq lean-ctx.exe" /V /NH 2>nul
977echo.
978echo Close your editor (Cursor, VS Code, etc.) to release the binary,
979echo or stop manually: lean-ctx stop
980echo.
981
982:retry
983if %RETRIES% GEQ %MAX_RETRIES% goto timeout
984set /a RETRIES+=1
985timeout /t 1 /nobreak >nul
986move /Y "{target}" "{old}" >nul 2>&1
987if errorlevel 1 (
988 if %RETRIES% EQU 10 echo Still waiting... (%RETRIES%/%MAX_RETRIES%s)
989 if %RETRIES% EQU 30 echo Still waiting... (%RETRIES%/%MAX_RETRIES%s) — try closing your editor
990 if %RETRIES% EQU 50 echo Still waiting... (%RETRIES%/%MAX_RETRIES%s) — timeout approaching
991 goto retry
992)
993
994move /Y "{pending}" "{target}" >nul 2>&1
995if errorlevel 1 (
996 move /Y "{old}" "{target}" >nul 2>&1
997 echo.
998 echo Update failed: could not place new binary.
999 echo Please close all editors and run: lean-ctx update
1000 pause
1001 exit /b 1
1002)
1003del /f "{old}" >nul 2>&1
1004echo.
1005echo Updated successfully!
1006goto cleanup
1007
1008:timeout
1009echo.
1010echo Update timed out after %MAX_RETRIES% seconds.
1011echo The new binary is staged at: {pending}
1012echo.
1013echo To complete the update manually:
1014echo 1. Close your editor (Cursor, VS Code, etc.)
1015echo 2. Run: move /Y "{pending}" "{target}"
1016echo.
1017echo Or run: lean-ctx update --force
1018echo.
1019pause
1020exit /b 1
1021
1022:cleanup
1023del "%~f0" >nul 2>&1
1024"#
1025 )
1026}
1027
1028fn detect_linux_libc() -> &'static str {
1029 let output = std::process::Command::new("ldd").arg("--version").output();
1030 if let Ok(out) = output {
1031 let text = String::from_utf8_lossy(&out.stdout);
1032 let stderr = String::from_utf8_lossy(&out.stderr);
1033 let combined = format!("{text}{stderr}");
1034 for line in combined.lines() {
1035 if let Some(ver) = line.split_whitespace().last() {
1036 let parts: Vec<&str> = ver.split('.').collect();
1037 if parts.len() == 2
1038 && let (Ok(major), Ok(minor)) =
1039 (parts[0].parse::<u32>(), parts[1].parse::<u32>())
1040 {
1041 if major > 2 || (major == 2 && minor >= 35) {
1042 return "gnu";
1043 }
1044 return "musl";
1045 }
1046 }
1047 }
1048 }
1049 "musl"
1050}
1051
1052fn platform_asset_name() -> String {
1053 let os = std::env::consts::OS;
1054 let arch = std::env::consts::ARCH;
1055
1056 let target = match (os, arch) {
1057 ("macos", "aarch64") => "aarch64-apple-darwin".to_string(),
1058 ("macos", "x86_64") => "x86_64-apple-darwin".to_string(),
1059 ("linux", "x86_64") => format!("x86_64-unknown-linux-{}", detect_linux_libc()),
1060 ("linux", "aarch64") => format!("aarch64-unknown-linux-{}", detect_linux_libc()),
1061 ("windows", "x86_64") => "x86_64-pc-windows-msvc".to_string(),
1062 _ => {
1063 tracing::error!(
1064 "Unsupported platform: {os}/{arch}. Download manually from \
1065 https://github.com/yvgude/lean-ctx/releases/latest"
1066 );
1067 std::process::exit(1);
1068 }
1069 };
1070
1071 if os == "windows" {
1072 format!("lean-ctx-{target}.zip")
1073 } else {
1074 format!("lean-ctx-{target}.tar.gz")
1075 }
1076}
1077
1078#[cfg(test)]
1079mod tests {
1080 use super::*;
1081
1082 #[test]
1083 fn auto_update_disabled_skips_and_cleans_up() {
1084 assert_eq!(automatic_update_gate(false, false), AutoUpdateGate::Skip);
1086 assert_eq!(automatic_update_gate(false, true), AutoUpdateGate::Skip);
1088 }
1089
1090 #[test]
1091 fn notify_only_downgrades_to_check() {
1092 assert_eq!(
1093 automatic_update_gate(true, true),
1094 AutoUpdateGate::NotifyOnly
1095 );
1096 }
1097
1098 #[test]
1099 fn auto_update_enabled_proceeds() {
1100 assert_eq!(automatic_update_gate(true, false), AutoUpdateGate::Proceed);
1101 }
1102
1103 #[test]
1104 fn bat_script_has_timeout_guard() {
1105 let script = generate_deferred_bat_script(
1106 r"C:\bin\lean-ctx.exe",
1107 r"C:\bin\lean-ctx-pending.exe",
1108 r"C:\bin\lean-ctx.old.exe",
1109 60,
1110 );
1111 assert!(script.contains("set \"MAX_RETRIES=60\""));
1112 assert!(script.contains(":timeout"), "must have timeout label");
1113 assert!(
1114 script.contains("timed out after"),
1115 "must show timeout message"
1116 );
1117 }
1118
1119 #[test]
1120 fn bat_script_shows_blocking_processes() {
1121 let script = generate_deferred_bat_script("t", "p", "o", 30);
1122 assert!(script.contains("tasklist"), "must list blocking processes");
1123 assert!(
1124 script.contains("lean-ctx stop"),
1125 "must suggest lean-ctx stop"
1126 );
1127 }
1128
1129 #[test]
1130 fn bat_script_has_progress_indicators() {
1131 let script = generate_deferred_bat_script("t", "p", "o", 60);
1132 assert!(script.contains("Still waiting"));
1133 assert!(script.contains("RETRIES"));
1134 }
1135
1136 #[test]
1137 fn bat_script_provides_manual_recovery() {
1138 let script = generate_deferred_bat_script(
1139 r"C:\bin\lean-ctx.exe",
1140 r"C:\bin\lean-ctx-pending.exe",
1141 r"C:\bin\lean-ctx.old.exe",
1142 60,
1143 );
1144 assert!(script.contains(r"move /Y"));
1145 assert!(
1146 script.contains("lean-ctx-pending.exe"),
1147 "must show where the pending binary is"
1148 );
1149 assert!(
1150 script.contains("lean-ctx update"),
1151 "must suggest re-running update"
1152 );
1153 }
1154
1155 #[test]
1156 fn bat_script_no_infinite_loop() {
1157 let script = generate_deferred_bat_script("t", "p", "o", 10);
1158 assert!(script.contains("if %RETRIES% GEQ %MAX_RETRIES% goto timeout"));
1159 assert!(
1160 !script.contains(":retry\ntimeout"),
1161 "must not be an infinite loop"
1162 );
1163 }
1164
1165 #[test]
1166 fn release_url_latest_when_no_version() {
1167 assert_eq!(release_api_url(None), GITHUB_API_RELEASES);
1169 }
1170
1171 #[test]
1172 fn release_url_pins_specific_tag() {
1173 assert_eq!(
1175 release_api_url(Some("3.8.5")),
1176 "https://api.github.com/repos/yvgude/lean-ctx/releases/tags/v3.8.5"
1177 );
1178 assert_eq!(
1180 release_api_url(Some("v3.8.5")),
1181 "https://api.github.com/repos/yvgude/lean-ctx/releases/tags/v3.8.5"
1182 );
1183 }
1184
1185 #[test]
1186 fn parse_target_version_peels_positional_only() {
1187 let flags_only = [String::from("--check"), String::from("--quiet")];
1188 assert_eq!(parse_target_version(&flags_only), None);
1189
1190 let with_version = [String::from("3.8.5"), String::from("--check")];
1191 assert_eq!(parse_target_version(&with_version), Some("3.8.5"));
1192
1193 let flag_then_version = [String::from("--insecure"), String::from("v3.8.5")];
1195 assert_eq!(parse_target_version(&flag_then_version), Some("v3.8.5"));
1196 }
1197
1198 #[test]
1199 fn looks_like_version_accepts_releases_rejects_typos() {
1200 assert!(looks_like_version("3.8.5"));
1201 assert!(looks_like_version("v3.8.5"));
1202 assert!(looks_like_version("3.8.5-rc1"));
1203 assert!(!looks_like_version("--check"));
1205 assert!(!looks_like_version("latest"));
1206 assert!(!looks_like_version("3"));
1207 }
1208}