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 if target_tag == CURRENT_VERSION {
212 println!(" Installing GPU binary for v{CURRENT_VERSION}…");
213 } else {
214 println!(" Update available: v{CURRENT_VERSION} → \x1b[1;32mv{target_tag}\x1b[0m");
215 }
216 }
217
218 let asset_name = match mode {
219 UpdateMode::Normal => platform_asset_name(),
220 UpdateMode::EnableGpu => match gpu_platform_asset_name() {
221 Ok(name) => name,
222 Err(e) => {
223 tracing::error!("{e}");
224 std::process::exit(1);
225 }
226 },
227 };
228
229 if check_only {
230 match mode {
231 UpdateMode::Normal if pinned => {
232 println!("Run 'lean-ctx update {target_tag}' to install.");
233 }
234 UpdateMode::Normal => println!("Run 'lean-ctx update' to install."),
235 UpdateMode::EnableGpu => println!("Run 'lean-ctx enable-gpu' to install {asset_name}."),
236 }
237 return;
238 }
239
240 if !quiet {
241 println!(" \x1b[2mDownloading {asset_name} …\x1b[0m");
242 }
243
244 let Some(download_url) = find_asset_url(&release, &asset_name) else {
245 tracing::error!(
246 "No binary found for this platform ({asset_name}) in v{target_tag}. Download manually: https://github.com/yvgude/lean-ctx/releases"
247 );
248 std::process::exit(1);
249 };
250
251 let bytes = match download_bytes(&download_url) {
252 Ok(b) => b,
253 Err(e) => {
254 tracing::error!("Download failed: {e}");
255 std::process::exit(1);
256 }
257 };
258
259 if let Err(e) = verify_download_integrity(&release, &asset_name, &bytes) {
260 if insecure {
261 tracing::warn!("Integrity verification failed: {e}");
262 tracing::warn!("Proceeding due to --insecure");
263 } else {
264 tracing::error!("Integrity verification failed: {e}");
265 tracing::error!(
266 "Refusing to install an unverifiable binary. Re-run with `lean-ctx update --insecure` or download manually: https://github.com/yvgude/lean-ctx/releases"
267 );
268 std::process::exit(1);
269 }
270 }
271
272 let current_exe = match std::env::current_exe() {
273 Ok(p) => p,
274 Err(e) => {
275 tracing::error!("Cannot locate current executable: {e}");
276 std::process::exit(1);
277 }
278 };
279
280 if let Err(e) = replace_binary(&bytes, &asset_name, ¤t_exe) {
281 tracing::error!("Failed to replace binary: {e}");
282 tracing::warn!("Continuing with a setup refresh so your wiring stays correct");
283 post_update_rewire(skip_rules);
284 std::process::exit(1);
285 }
286
287 if quiet {
288 println!(" lean-ctx v{CURRENT_VERSION} → v{target_tag}");
289 } else {
290 println!();
291 if pinned {
292 println!(" \x1b[1;32m✓ Now running lean-ctx v{target_tag}\x1b[0m");
293 } else if mode == UpdateMode::EnableGpu {
294 println!(" \x1b[1;32m✓ Enabled lean-ctx GPU binary v{target_tag}\x1b[0m");
295 } else {
296 println!(" \x1b[1;32m✓ Updated to lean-ctx v{target_tag}\x1b[0m");
297 }
298 println!(" \x1b[2mBinary: {}\x1b[0m", current_exe.display());
299 if mode == UpdateMode::EnableGpu {
300 println!(
301 " \x1b[2mSet ORT_DYLIB_PATH to your ONNX Runtime GPU lib; lean-ctx auto-detects it.\x1b[0m"
302 );
303 }
304 }
305
306 if !quiet {
307 println!();
308 if skip_rules {
309 println!(
310 " \x1b[36m\x1b[1mRefreshing setup (shell hook, MCP configs — rules skipped)…\x1b[0m"
311 );
312 } else {
313 println!(" \x1b[36m\x1b[1mRefreshing setup (shell hook, MCP configs, rules)…\x1b[0m");
314 }
315 }
316 post_update_rewire(skip_rules);
317
318 if !quiet {
319 println!();
320 crate::terminal_ui::print_logo_animated();
321 println!();
322 println!(
323 " \x1b[33m\x1b[1m⟳ Restart your IDE and shell to activate the new version.\x1b[0m"
324 );
325 println!(
326 " \x1b[2mClose and re-open Cursor, VS Code, Claude Code, etc. completely.\x1b[0m"
327 );
328 println!(" \x1b[2mThe MCP server must reconnect to use the updated binary.\x1b[0m");
329 println!(
330 " \x1b[2m{}\x1b[0m",
331 crate::shell_hook::reload_aliases_hint()
332 );
333 }
334 println!();
335
336 if !quiet
337 && !crate::core::update_scheduler::has_user_decided()
338 && std::io::IsTerminal::is_terminal(&std::io::stdin())
339 {
340 print!(" Want to get updates like this automatically? \x1b[1m[y/N]\x1b[0m ");
341 use std::io::Write;
342 std::io::stdout().flush().ok();
343 let mut input = String::new();
344 if std::io::stdin().read_line(&mut input).is_ok() {
345 let answer = input.trim().to_lowercase();
346 if answer == "y" || answer == "yes" {
347 let cfg = crate::core::config::Config::load();
348 let hours = cfg.updates.check_interval_hours;
349 match crate::core::update_scheduler::install_schedule(hours) {
350 Ok(info) => {
351 crate::core::update_scheduler::set_auto_update(true, false, hours);
352 println!(" \x1b[32m✓\x1b[0m {info}");
353 println!(" \x1b[2mDisable anytime: lean-ctx update --schedule off\x1b[0m");
354 }
355 Err(e) => println!(" \x1b[33m⚠\x1b[0m Could not set up scheduler: {e}"),
356 }
357 } else {
358 crate::core::update_scheduler::set_auto_update(false, false, 6);
359 println!(" \x1b[2m○ Skipped — enable later: lean-ctx update --schedule\x1b[0m");
360 }
361 }
362 }
363}
364
365#[derive(Debug, Clone, Copy, PartialEq, Eq)]
367enum AutoUpdateGate {
368 Proceed,
370 Skip,
372 NotifyOnly,
374}
375
376fn automatic_update_gate(auto_update: bool, notify_only: bool) -> AutoUpdateGate {
379 if !auto_update {
380 AutoUpdateGate::Skip
381 } else if notify_only {
382 AutoUpdateGate::NotifyOnly
383 } else {
384 AutoUpdateGate::Proceed
385 }
386}
387
388fn verify_download_integrity(
389 release: &serde_json::Value,
390 asset_name: &str,
391 bytes: &[u8],
392) -> Result<(), String> {
393 #[cfg(not(feature = "secure-update"))]
394 {
395 let _ = (release, asset_name, bytes);
396 return Err("secure-update feature disabled (sha256 verification unavailable)".to_string());
397 }
398
399 #[cfg(feature = "secure-update")]
400 {
401 let computed = sha256_hex(bytes);
402
403 let Some((checksum_url, kind)) = find_checksum_asset_url(release, asset_name) else {
404 return Err(
405 "no checksum asset found for this release (expected SHA256SUMS or *.sha256)"
406 .to_string(),
407 );
408 };
409 let checksum_bytes = download_bytes(&checksum_url)?;
410 let checksum_text = String::from_utf8_lossy(&checksum_bytes).to_string();
411
412 let expected = match kind {
413 ChecksumAssetKind::SingleSha256 => parse_single_sha256(&checksum_text),
414 ChecksumAssetKind::Sha256Sums => parse_sha256sums(&checksum_text, asset_name),
415 }
416 .ok_or_else(|| format!("checksum file did not contain an entry for {asset_name}"))?;
417
418 if !constant_time_eq(computed.as_bytes(), expected.as_bytes()) {
419 return Err(format!(
420 "sha256 mismatch for {asset_name}: expected {expected}, got {computed}"
421 ));
422 }
423 Ok(())
424 }
425}
426
427#[derive(Debug, Clone, Copy)]
428enum ChecksumAssetKind {
429 Sha256Sums,
430 SingleSha256,
431}
432
433fn find_checksum_asset_url(
434 release: &serde_json::Value,
435 asset_name: &str,
436) -> Option<(String, ChecksumAssetKind)> {
437 let candidates = [
439 format!("{asset_name}.sha256"),
440 format!("{asset_name}.sha256.txt"),
441 "SHA256SUMS".to_string(),
442 "SHA256SUMS.txt".to_string(),
443 "sha256sums.txt".to_string(),
444 "checksums.txt".to_string(),
445 ];
446
447 for c in candidates {
448 if let Some(url) = find_asset_url(release, &c) {
449 let kind = if c.to_lowercase().contains("sha256sums")
450 || c.to_uppercase() == "SHA256SUMS"
451 || c.to_lowercase().contains("checksums")
452 {
453 ChecksumAssetKind::Sha256Sums
454 } else {
455 ChecksumAssetKind::SingleSha256
456 };
457 return Some((url, kind));
458 }
459 }
460 None
461}
462
463fn parse_single_sha256(text: &str) -> Option<String> {
464 let t = text.trim();
465 let first = t.split_whitespace().next().unwrap_or("").trim();
466 if first.len() == 64 && first.chars().all(|c| c.is_ascii_hexdigit()) {
467 Some(first.to_ascii_lowercase())
468 } else {
469 None
470 }
471}
472
473fn parse_sha256sums(text: &str, asset_name: &str) -> Option<String> {
474 for line in text.lines() {
475 let l = line.trim();
476 if l.is_empty() || l.starts_with('#') {
477 continue;
478 }
479 let mut parts = l.split_whitespace();
480 let hash = parts.next().unwrap_or("");
481 let file = parts.next().unwrap_or("");
482 if file == asset_name && hash.len() == 64 && hash.chars().all(|c| c.is_ascii_hexdigit()) {
483 return Some(hash.to_ascii_lowercase());
484 }
485 }
486 None
487}
488
489fn sha256_hex(bytes: &[u8]) -> String {
490 use sha2::{Digest, Sha256};
491 let mut h = Sha256::new();
492 h.update(bytes);
493 let out = h.finalize();
494 hex_lower(&out)
495}
496
497fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
498 if a.len() != b.len() {
499 return false;
500 }
501 a.iter()
502 .zip(b.iter())
503 .fold(0u8, |acc, (x, y)| acc | (x ^ y))
504 == 0
505}
506
507fn hex_lower(bytes: &[u8]) -> String {
508 const HEX: &[u8; 16] = b"0123456789abcdef";
509 let mut out = String::with_capacity(bytes.len() * 2);
510 for &b in bytes {
511 out.push(HEX[(b >> 4) as usize] as char);
512 out.push(HEX[(b & 0x0f) as usize] as char);
513 }
514 out
515}
516
517fn migrate_shadow_mode_default() {
522 let Ok(state) = crate::core::paths::state_dir() else {
523 return;
524 };
525 let marker = state.join("shadow_mode_migrated");
526 if marker.exists() {
527 return;
528 }
529
530 let global = crate::core::config::Config::load_global();
532 let raw_toml = crate::core::config::Config::path()
533 .and_then(|p| std::fs::read_to_string(p).ok())
534 .unwrap_or_default();
535
536 let explicitly_set = raw_toml.lines().any(|line| {
537 let trimmed = line.trim();
538 trimmed.starts_with("shadow_mode") && trimmed.contains('=')
539 });
540
541 if !explicitly_set && !global.shadow_mode {
542 match crate::core::config::Config::update_global(|c| c.shadow_mode = true) {
545 Ok(_) => {
546 eprintln!(" \u{2139} shadow_mode enabled (recommended default since v3.9.9).");
547 eprintln!(" Disable: lean-ctx config set shadow_mode false");
548 }
549 Err(e) => tracing::warn!("could not enable shadow_mode during update: {e}"),
550 }
551 } else if explicitly_set && !global.shadow_mode {
552 eprintln!(" \u{2139} shadow_mode is false in your config. Since v3.9.9, true is the");
553 eprintln!(" recommended default (60%+ more token savings). Enable:");
554 eprintln!(" lean-ctx config set shadow_mode true");
555 }
556
557 if let Some(parent) = marker.parent() {
559 let _ = std::fs::create_dir_all(parent);
560 }
561 let _ = std::fs::write(&marker, "migrated");
562}
563
564pub fn migrate_shadow_mode_default_public() {
567 migrate_shadow_mode_default();
568}
569
570fn post_update_rewire(skip_rules: bool) {
571 #[cfg(target_os = "macos")]
577 rewrap_launchagents_for_tcc();
578
579 if crate::core::config::Config::load_global()
583 .proxy_enabled
584 .is_none()
585 && crate::proxy_autostart::is_installed()
586 {
587 match crate::core::config::Config::update_global(|c| c.proxy_enabled = Some(true)) {
588 Ok(_) => {
589 eprintln!(" \u{2139} Proxy was already active \u{2014} keeping enabled.");
590 eprintln!(" Disable anytime: lean-ctx proxy disable");
591 }
592 Err(e) => tracing::warn!("could not persist proxy_enabled during update: {e}"),
593 }
594 }
595
596 migrate_shadow_mode_default();
599
600 let cfg = crate::core::config::Config::load();
602 let proxy_active = cfg.proxy_enabled == Some(true);
603
604 let effective_skip_rules = if skip_rules {
607 true
608 } else {
609 !cfg.setup.should_inject_rules()
610 };
611
612 if proxy_active {
614 restart_proxy_if_running();
615 wait_for_proxy_health(crate::proxy_setup::default_port());
616 }
617
618 if !cfg.shell_hook_disabled_effective() {
622 eprintln!(" \u{2139} Refreshing shell hooks in ~/.zshenv / ~/.bashenv.");
623 eprintln!(" Set shell_hook_disabled=true to prevent this in future updates.");
624 }
625 let opts = crate::setup::SetupOptions {
626 non_interactive: true,
627 yes: true,
628 fix: true,
629 skip_proxy: !proxy_active,
630 skip_rules: effective_skip_rules,
631 ..Default::default()
632 };
633 if let Err(e) = crate::setup::run_setup_with_options(opts) {
634 tracing::error!("Setup refresh error: {e}");
635 }
636}
637
638#[cfg(target_os = "macos")]
644fn rewrap_launchagents_for_tcc() {
645 if crate::proxy_autostart::is_installed() {
646 crate::proxy_autostart::install(crate::proxy_setup::default_port(), true);
647 }
648 if crate::daemon_autostart::is_installed() {
649 crate::daemon_autostart::install(true);
650 }
651 if crate::core::update_scheduler::schedule_status().enabled {
652 let hours = crate::core::config::Config::load()
653 .updates
654 .check_interval_hours;
655 if let Err(e) = crate::core::update_scheduler::install_schedule(hours) {
656 tracing::warn!("#356 re-wrap of auto-update LaunchAgent failed: {e}");
657 }
658 }
659}
660
661fn wait_for_proxy_health(port: u16) {
662 let max_attempts = 20;
663 for i in 0..max_attempts {
664 if is_proxy_reachable(port) {
665 println!(" \x1b[32m✓\x1b[0m Proxy healthy on port {port}");
666 return;
667 }
668 if i == 0 {
669 print!(" \x1b[2mWaiting for proxy to become healthy");
670 }
671 print!(".");
672 use std::io::Write;
673 std::io::stdout().flush().ok();
674 std::thread::sleep(std::time::Duration::from_millis(500));
675 }
676 println!();
677 eprintln!(
678 " \x1b[33m⚠\x1b[0m Proxy did not respond within {}s — writing env vars anyway",
679 max_attempts / 2
680 );
681 eprintln!(" If Claude Code shows connection errors, run: lean-ctx proxy start");
682}
683
684fn restart_proxy_if_running() {
685 let port = crate::proxy_setup::default_port();
686
687 if restart_managed_proxy() {
688 return;
689 }
690
691 if is_proxy_reachable(port) {
692 println!(
693 " \x1b[33m⟳\x1b[0m Proxy running on port {port} — restart it to use the new binary:"
694 );
695 println!(" \x1b[1mlean-ctx proxy start --port={port}\x1b[0m");
696 }
697}
698
699fn restart_managed_proxy() -> bool {
702 #[cfg(target_os = "macos")]
703 {
704 let plist_path = dirs::home_dir()
705 .unwrap_or_default()
706 .join("Library/LaunchAgents/com.leanctx.proxy.plist");
707 if plist_path.exists() {
708 if crate::core::launchd::bootstrap("com.leanctx.proxy", &plist_path) {
709 println!(" \x1b[32m✓\x1b[0m Proxy restarted (LaunchAgent)");
710 } else {
711 println!(" \x1b[33m⚠\x1b[0m Could not restart proxy LaunchAgent");
712 }
713 return true;
714 }
715 }
716
717 #[cfg(target_os = "linux")]
718 {
719 let service_path = dirs::home_dir()
720 .unwrap_or_default()
721 .join(".config/systemd/user/lean-ctx-proxy.service");
722 if service_path.exists() {
723 let result = std::process::Command::new("systemctl")
724 .args(["--user", "restart", "lean-ctx-proxy"])
725 .output();
726 match result {
727 Ok(o) if o.status.success() => {
728 println!(" \x1b[32m✓\x1b[0m Proxy restarted (systemd)");
729 }
730 _ => {
731 println!(" \x1b[33m⚠\x1b[0m Could not restart proxy systemd service");
732 }
733 }
734 return true;
735 }
736 }
737
738 false
739}
740
741fn is_proxy_reachable(port: u16) -> bool {
742 ureq::get(&format!("http://127.0.0.1:{port}/health"))
743 .call()
744 .is_ok()
745}
746
747fn release_api_url(version: Option<&str>) -> String {
751 match version {
752 None => GITHUB_API_RELEASES.to_string(),
753 Some(v) => {
754 let core = v.trim_start_matches('v');
755 format!("https://api.github.com/repos/yvgude/lean-ctx/releases/tags/v{core}")
756 }
757 }
758}
759
760fn https_agent() -> ureq::Agent {
764 crate::core::http_client::ureq_agent_with_timeouts(
771 Some(std::time::Duration::from_secs(15)),
772 Some(std::time::Duration::from_secs(20)),
773 Some(std::time::Duration::from_secs(30)),
774 )
775}
776
777fn fetch_release(version: Option<&str>) -> Result<serde_json::Value, String> {
780 let response = https_agent()
781 .get(&release_api_url(version))
782 .header("User-Agent", &format!("lean-ctx/{CURRENT_VERSION}"))
783 .header("Accept", "application/vnd.github.v3+json")
784 .call()
785 .map_err(|e| e.to_string())?;
786
787 response
788 .into_body()
789 .read_to_string()
790 .map_err(|e| e.to_string())
791 .and_then(|s| serde_json::from_str(&s).map_err(|e| e.to_string()))
792}
793
794fn parse_target_version(args: &[String]) -> Option<&str> {
798 args.iter()
799 .map(String::as_str)
800 .find(|a| !a.starts_with('-'))
801}
802
803fn looks_like_version(s: &str) -> bool {
806 let core = s.strip_prefix('v').unwrap_or(s);
807 core.contains('.')
808 && core.starts_with(|c: char| c.is_ascii_digit())
809 && core
810 .chars()
811 .all(|c| c.is_ascii_digit() || c == '.' || c == '-' || c.is_ascii_alphabetic())
812}
813
814fn find_asset_url(release: &serde_json::Value, asset_name: &str) -> Option<String> {
815 release["assets"]
816 .as_array()?
817 .iter()
818 .find(|a| a["name"].as_str() == Some(asset_name))
819 .and_then(|a| a["browser_download_url"].as_str())
820 .map(std::string::ToString::to_string)
821}
822
823fn download_bytes(url: &str) -> Result<Vec<u8>, String> {
824 let response = https_agent()
825 .get(url)
826 .header("User-Agent", &format!("lean-ctx/{CURRENT_VERSION}"))
827 .call()
828 .map_err(|e| e.to_string())?;
829
830 let mut bytes = Vec::new();
831 response
832 .into_body()
833 .into_reader()
834 .read_to_end(&mut bytes)
835 .map_err(|e| e.to_string())?;
836 Ok(bytes)
837}
838
839fn replace_binary(
840 archive_bytes: &[u8],
841 asset_name: &str,
842 current_exe: &std::path::Path,
843) -> Result<(), String> {
844 let binary_bytes = if std::path::Path::new(asset_name)
845 .extension()
846 .is_some_and(|e| e.eq_ignore_ascii_case("zip"))
847 {
848 extract_from_zip(archive_bytes)?
849 } else {
850 extract_from_tar_gz(archive_bytes)?
851 };
852
853 let tmp_path = current_exe.with_extension("tmp");
854 std::fs::write(&tmp_path, &binary_bytes).map_err(|e| e.to_string())?;
855
856 #[cfg(unix)]
857 {
858 use std::os::unix::fs::PermissionsExt;
859 let _ = std::fs::set_permissions(&tmp_path, std::fs::Permissions::from_mode(0o755));
860 }
861
862 #[cfg(windows)]
867 {
868 let old_path = current_exe.with_extension("old.exe");
869 let _ = std::fs::remove_file(&old_path);
870
871 match std::fs::rename(current_exe, &old_path) {
872 Ok(()) => {
873 if let Err(e) = std::fs::rename(&tmp_path, current_exe) {
874 let _ = std::fs::rename(&old_path, current_exe);
875 let _ = std::fs::remove_file(&tmp_path);
876 return Err(format!("Cannot place new binary: {e}"));
877 }
878 let _ = std::fs::remove_file(&old_path);
879 return Ok(());
880 }
881 Err(_) => {
882 eprintln!("\nBinary is locked. Stopping managed lean-ctx processes...");
884 stop_managed_windows_processes();
885
886 std::thread::sleep(std::time::Duration::from_millis(1500));
888
889 let _ = std::fs::remove_file(&old_path);
891 match std::fs::rename(current_exe, &old_path) {
892 Ok(()) => {
893 if let Err(e) = std::fs::rename(&tmp_path, current_exe) {
894 let _ = std::fs::rename(&old_path, current_exe);
895 let _ = std::fs::remove_file(&tmp_path);
896 return Err(format!("Cannot place new binary: {e}"));
897 }
898 let _ = std::fs::remove_file(&old_path);
899 return Ok(());
900 }
901 Err(_) => {
902 print_blocking_processes(current_exe);
904 return deferred_windows_update(&tmp_path, current_exe);
905 }
906 }
907 }
908 }
909 }
910
911 #[cfg(not(windows))]
912 {
913 #[cfg(target_os = "macos")]
918 {
919 let _ = std::fs::remove_file(current_exe);
920 }
921
922 std::fs::rename(&tmp_path, current_exe).map_err(|e| {
923 let _ = std::fs::remove_file(&tmp_path);
924 format!("Cannot replace binary (permission denied?): {e}")
925 })?;
926
927 #[cfg(target_os = "macos")]
930 {
931 let _ = crate::core::codesign::sign_binary(current_exe);
932 }
933
934 Ok(())
935 }
936}
937
938#[cfg(windows)]
941fn stop_managed_windows_processes() {
942 let stop_result = std::process::Command::new("lean-ctx").arg("stop").output();
944
945 match stop_result {
946 Ok(out) if out.status.success() => {
947 eprintln!(" Managed processes stopped.");
948 }
949 _ => {
950 for pattern in &["proxy start", "serve "] {
953 let _ = std::process::Command::new("taskkill")
954 .args([
955 "/F",
956 "/FI",
957 &format!("WINDOWTITLE eq *{pattern}*"),
958 "/IM",
959 "lean-ctx.exe",
960 ])
961 .output();
962 }
963 eprintln!(" Attempted to stop lean-ctx processes via taskkill.");
964 }
965 }
966}
967
968#[cfg(windows)]
970fn print_blocking_processes(target_exe: &std::path::Path) {
971 let target_name = target_exe
972 .file_name()
973 .and_then(|n| n.to_str())
974 .unwrap_or("lean-ctx.exe");
975
976 let output = std::process::Command::new("tasklist")
977 .args([
978 "/FI",
979 &format!("IMAGENAME eq {target_name}"),
980 "/V",
981 "/FO",
982 "CSV",
983 ])
984 .output();
985
986 if let Ok(out) = output {
987 let stdout = String::from_utf8_lossy(&out.stdout);
988 let lines: Vec<&str> = stdout.lines().skip(1).collect(); if !lines.is_empty() {
990 eprintln!("\n Blocking lean-ctx processes:");
991 for line in &lines {
992 let fields: Vec<&str> = line.split(',').collect();
994 if fields.len() >= 2 {
995 let pid = fields[1].trim_matches('"');
996 eprintln!(" PID {pid}");
997 }
998 }
999 eprintln!("\n To stop manually: taskkill /F /PID <pid> (or close your editor)");
1000 }
1001 }
1002}
1003
1004#[cfg(windows)]
1008fn deferred_windows_update(
1009 staged_path: &std::path::Path,
1010 target_exe: &std::path::Path,
1011) -> Result<(), String> {
1012 let pending_path = target_exe.with_file_name("lean-ctx-pending.exe");
1013 std::fs::rename(staged_path, &pending_path).map_err(|e| {
1014 let _ = std::fs::remove_file(staged_path);
1015 format!("Cannot stage update: {e}")
1016 })?;
1017
1018 let target_str = target_exe.display().to_string();
1019 let pending_str = pending_path.display().to_string();
1020 let old_str = target_exe.with_extension("old.exe").display().to_string();
1021 let max_retries = 60;
1022
1023 let script = generate_deferred_bat_script(&target_str, &pending_str, &old_str, max_retries);
1024
1025 let script_path = target_exe.with_file_name("lean-ctx-update.bat");
1026 std::fs::write(&script_path, &script)
1027 .map_err(|e| format!("Cannot write update script: {e}"))?;
1028
1029 let _ = std::process::Command::new("cmd")
1030 .args(["/C", "start", "/MIN", &script_path.display().to_string()])
1031 .spawn();
1032
1033 println!("\nThe binary is still in use (likely by your editor's MCP server).");
1034 println!("A background update has been scheduled (timeout: {max_retries}s).");
1035 println!("Close your editor and the update will complete automatically.");
1036 println!("\nIf it times out, run: lean-ctx update");
1037 println!("Update script: {}", script_path.display());
1038
1039 Ok(())
1040}
1041
1042fn extract_from_tar_gz(data: &[u8]) -> Result<Vec<u8>, String> {
1043 use flate2::read::GzDecoder;
1044
1045 let gz = GzDecoder::new(data);
1046 let mut archive = tar::Archive::new(gz);
1047
1048 for entry in archive.entries().map_err(|e| e.to_string())? {
1049 let mut entry = entry.map_err(|e| e.to_string())?;
1050 let path = entry.path().map_err(|e| e.to_string())?;
1051 let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
1052
1053 if name == "lean-ctx" || name == "lean-ctx.exe" {
1054 let mut bytes = Vec::new();
1055 entry.read_to_end(&mut bytes).map_err(|e| e.to_string())?;
1056 return Ok(bytes);
1057 }
1058 }
1059 Err("lean-ctx binary not found inside archive".to_string())
1060}
1061
1062fn extract_from_zip(data: &[u8]) -> Result<Vec<u8>, String> {
1063 use std::io::Cursor;
1064
1065 let cursor = Cursor::new(data);
1066 let mut zip = zip::ZipArchive::new(cursor).map_err(|e| e.to_string())?;
1067
1068 for i in 0..zip.len() {
1069 let mut file = zip.by_index(i).map_err(|e| e.to_string())?;
1070 let name = file.name().to_string();
1071 if name == "lean-ctx.exe" || name == "lean-ctx" {
1072 let mut bytes = Vec::new();
1073 file.read_to_end(&mut bytes).map_err(|e| e.to_string())?;
1074 return Ok(bytes);
1075 }
1076 }
1077 Err("lean-ctx binary not found inside zip archive".to_string())
1078}
1079
1080#[cfg(any(windows, test))]
1082fn generate_deferred_bat_script(
1083 target: &str,
1084 pending: &str,
1085 old: &str,
1086 max_retries: u32,
1087) -> String {
1088 format!(
1089 r#"@echo off
1090setlocal
1091set "RETRIES=0"
1092set "MAX_RETRIES={max_retries}"
1093
1094echo lean-ctx update: waiting for binary to be released (timeout: %MAX_RETRIES%s)...
1095echo.
1096echo Blocking processes:
1097tasklist /FI "IMAGENAME eq lean-ctx.exe" /V /NH 2>nul
1098echo.
1099echo Close your editor (Cursor, VS Code, etc.) to release the binary,
1100echo or stop manually: lean-ctx stop
1101echo.
1102
1103:retry
1104if %RETRIES% GEQ %MAX_RETRIES% goto timeout
1105set /a RETRIES+=1
1106timeout /t 1 /nobreak >nul
1107move /Y "{target}" "{old}" >nul 2>&1
1108if errorlevel 1 (
1109 if %RETRIES% EQU 10 echo Still waiting... (%RETRIES%/%MAX_RETRIES%s)
1110 if %RETRIES% EQU 30 echo Still waiting... (%RETRIES%/%MAX_RETRIES%s) — try closing your editor
1111 if %RETRIES% EQU 50 echo Still waiting... (%RETRIES%/%MAX_RETRIES%s) — timeout approaching
1112 goto retry
1113)
1114
1115move /Y "{pending}" "{target}" >nul 2>&1
1116if errorlevel 1 (
1117 move /Y "{old}" "{target}" >nul 2>&1
1118 echo.
1119 echo Update failed: could not place new binary.
1120 echo Please close all editors and run: lean-ctx update
1121 pause
1122 exit /b 1
1123)
1124del /f "{old}" >nul 2>&1
1125echo.
1126echo Updated successfully!
1127goto cleanup
1128
1129:timeout
1130echo.
1131echo Update timed out after %MAX_RETRIES% seconds.
1132echo The new binary is staged at: {pending}
1133echo.
1134echo To complete the update manually:
1135echo 1. Close your editor (Cursor, VS Code, etc.)
1136echo 2. Run: move /Y "{pending}" "{target}"
1137echo.
1138echo Or run: lean-ctx update --force
1139echo.
1140pause
1141exit /b 1
1142
1143:cleanup
1144del "%~f0" >nul 2>&1
1145"#
1146 )
1147}
1148
1149fn detect_linux_libc() -> &'static str {
1150 let output = std::process::Command::new("ldd").arg("--version").output();
1151 if let Ok(out) = output {
1152 let text = String::from_utf8_lossy(&out.stdout);
1153 let stderr = String::from_utf8_lossy(&out.stderr);
1154 let combined = format!("{text}{stderr}");
1155 for line in combined.lines() {
1156 if let Some(ver) = line.split_whitespace().last() {
1157 let parts: Vec<&str> = ver.split('.').collect();
1158 if parts.len() == 2
1159 && let (Ok(major), Ok(minor)) =
1160 (parts[0].parse::<u32>(), parts[1].parse::<u32>())
1161 {
1162 if major > 2 || (major == 2 && minor >= 35) {
1163 return "gnu";
1164 }
1165 return "musl";
1166 }
1167 }
1168 }
1169 }
1170 "musl"
1171}
1172
1173fn platform_asset_name() -> String {
1174 let os = std::env::consts::OS;
1175 let arch = std::env::consts::ARCH;
1176
1177 let target = match (os, arch) {
1178 ("macos", "aarch64") => "aarch64-apple-darwin".to_string(),
1179 ("macos", "x86_64") => "x86_64-apple-darwin".to_string(),
1180 ("linux", "x86_64") => {
1181 let libc = detect_linux_libc();
1182 if current_build_prefers_gpu_asset() && libc == "gnu" {
1183 "x86_64-unknown-linux-gnu-cuda".to_string()
1184 } else {
1185 format!("x86_64-unknown-linux-{libc}")
1186 }
1187 }
1188 ("linux", "aarch64") => format!("aarch64-unknown-linux-{}", detect_linux_libc()),
1189 ("windows", "x86_64") => "x86_64-pc-windows-msvc".to_string(),
1190 _ => {
1191 tracing::error!(
1192 "Unsupported platform: {os}/{arch}. Download manually from \
1193 https://github.com/yvgude/lean-ctx/releases/latest"
1194 );
1195 std::process::exit(1);
1196 }
1197 };
1198
1199 if os == "windows" {
1200 format!("lean-ctx-{target}.zip")
1201 } else {
1202 format!("lean-ctx-{target}.tar.gz")
1203 }
1204}
1205
1206fn gpu_platform_asset_name() -> Result<String, String> {
1207 if std::env::consts::OS != "linux" || std::env::consts::ARCH != "x86_64" {
1208 return Err(
1209 "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."
1210 .to_string(),
1211 );
1212 }
1213 if detect_linux_libc() != "gnu" {
1214 return Err(
1215 "CUDA binary requires GNU libc Linux. This system detected musl; use the CPU binary or build with --features ort-cuda."
1216 .to_string(),
1217 );
1218 }
1219 Ok("lean-ctx-x86_64-unknown-linux-gnu-cuda.tar.gz".to_string())
1220}
1221
1222fn current_build_prefers_gpu_asset() -> bool {
1223 cfg!(feature = "ort-cuda")
1224}
1225
1226#[cfg(test)]
1227mod tests {
1228 use super::*;
1229
1230 #[test]
1231 fn auto_update_disabled_skips_and_cleans_up() {
1232 assert_eq!(automatic_update_gate(false, false), AutoUpdateGate::Skip);
1234 assert_eq!(automatic_update_gate(false, true), AutoUpdateGate::Skip);
1236 }
1237
1238 #[test]
1239 fn notify_only_downgrades_to_check() {
1240 assert_eq!(
1241 automatic_update_gate(true, true),
1242 AutoUpdateGate::NotifyOnly
1243 );
1244 }
1245
1246 #[test]
1247 fn auto_update_enabled_proceeds() {
1248 assert_eq!(automatic_update_gate(true, false), AutoUpdateGate::Proceed);
1249 }
1250
1251 #[test]
1252 fn bat_script_has_timeout_guard() {
1253 let script = generate_deferred_bat_script(
1254 r"C:\bin\lean-ctx.exe",
1255 r"C:\bin\lean-ctx-pending.exe",
1256 r"C:\bin\lean-ctx.old.exe",
1257 60,
1258 );
1259 assert!(script.contains("set \"MAX_RETRIES=60\""));
1260 assert!(script.contains(":timeout"), "must have timeout label");
1261 assert!(
1262 script.contains("timed out after"),
1263 "must show timeout message"
1264 );
1265 }
1266
1267 #[test]
1268 fn bat_script_shows_blocking_processes() {
1269 let script = generate_deferred_bat_script("t", "p", "o", 30);
1270 assert!(script.contains("tasklist"), "must list blocking processes");
1271 assert!(
1272 script.contains("lean-ctx stop"),
1273 "must suggest lean-ctx stop"
1274 );
1275 }
1276
1277 #[test]
1278 fn bat_script_has_progress_indicators() {
1279 let script = generate_deferred_bat_script("t", "p", "o", 60);
1280 assert!(script.contains("Still waiting"));
1281 assert!(script.contains("RETRIES"));
1282 }
1283
1284 #[test]
1285 fn bat_script_provides_manual_recovery() {
1286 let script = generate_deferred_bat_script(
1287 r"C:\bin\lean-ctx.exe",
1288 r"C:\bin\lean-ctx-pending.exe",
1289 r"C:\bin\lean-ctx.old.exe",
1290 60,
1291 );
1292 assert!(script.contains(r"move /Y"));
1293 assert!(
1294 script.contains("lean-ctx-pending.exe"),
1295 "must show where the pending binary is"
1296 );
1297 assert!(
1298 script.contains("lean-ctx update"),
1299 "must suggest re-running update"
1300 );
1301 }
1302
1303 #[test]
1304 fn bat_script_no_infinite_loop() {
1305 let script = generate_deferred_bat_script("t", "p", "o", 10);
1306 assert!(script.contains("if %RETRIES% GEQ %MAX_RETRIES% goto timeout"));
1307 assert!(
1308 !script.contains(":retry\ntimeout"),
1309 "must not be an infinite loop"
1310 );
1311 }
1312
1313 #[test]
1314 fn release_url_latest_when_no_version() {
1315 assert_eq!(release_api_url(None), GITHUB_API_RELEASES);
1317 }
1318
1319 #[test]
1320 fn release_url_pins_specific_tag() {
1321 assert_eq!(
1323 release_api_url(Some("3.8.5")),
1324 "https://api.github.com/repos/yvgude/lean-ctx/releases/tags/v3.8.5"
1325 );
1326 assert_eq!(
1328 release_api_url(Some("v3.8.5")),
1329 "https://api.github.com/repos/yvgude/lean-ctx/releases/tags/v3.8.5"
1330 );
1331 }
1332
1333 #[test]
1334 fn parse_target_version_peels_positional_only() {
1335 let flags_only = [String::from("--check"), String::from("--quiet")];
1336 assert_eq!(parse_target_version(&flags_only), None);
1337
1338 let with_version = [String::from("3.8.5"), String::from("--check")];
1339 assert_eq!(parse_target_version(&with_version), Some("3.8.5"));
1340
1341 let flag_then_version = [String::from("--insecure"), String::from("v3.8.5")];
1343 assert_eq!(parse_target_version(&flag_then_version), Some("v3.8.5"));
1344 }
1345
1346 #[test]
1347 fn looks_like_version_accepts_releases_rejects_typos() {
1348 assert!(looks_like_version("3.8.5"));
1349 assert!(looks_like_version("v3.8.5"));
1350 assert!(looks_like_version("3.8.5-rc1"));
1351 assert!(!looks_like_version("--check"));
1353 assert!(!looks_like_version("latest"));
1354 assert!(!looks_like_version("3"));
1355 }
1356}