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