Skip to main content

ai_agent/utils/
native_installer.rs

1//! Native installer utilities.
2
3use crate::constants::env::system;
4use std::path::Path;
5use std::process::Command;
6use std::sync::Mutex;
7use std::time::SystemTime;
8
9use serde::{Deserialize, Serialize};
10
11use crate::utils::platform::detect_platform as get_platform;
12
13lazy_static::lazy_static! {
14    static ref OS_RELEASE_CACHE: Mutex<Option<OsRelease>> = Mutex::new(None);
15    static ref PACKAGE_MANAGER_CACHE: Mutex<Option<PackageManager>> = Mutex::new(None);
16}
17
18#[derive(Debug, Clone, Serialize, Deserialize)]
19pub struct VersionLockContent {
20    pub pid: u32,
21    pub version: String,
22    pub exec_path: String,
23    pub acquired_at: u64,
24}
25
26#[derive(Debug, Clone)]
27pub struct LockInfo {
28    pub version: String,
29    pub pid: u32,
30    pub is_process_running: bool,
31    pub exec_path: String,
32    pub acquired_at: SystemTime,
33    pub lock_file_path: String,
34}
35
36#[derive(Debug, Clone)]
37pub struct OsRelease {
38    pub id: String,
39    pub id_like: Vec<String>,
40}
41
42#[derive(Debug, Clone, Copy, PartialEq, Eq)]
43pub enum PackageManager {
44    Homebrew,
45    Winget,
46    Pacman,
47    Deb,
48    Rpm,
49    Apk,
50    Mise,
51    Asdf,
52    Unknown,
53}
54
55impl PackageManager {
56    pub fn as_str(&self) -> &'static str {
57        match self {
58            PackageManager::Homebrew => "homebrew",
59            PackageManager::Winget => "winget",
60            PackageManager::Pacman => "pacman",
61            PackageManager::Deb => "deb",
62            PackageManager::Rpm => "rpm",
63            PackageManager::Apk => "apk",
64            PackageManager::Mise => "mise",
65            PackageManager::Asdf => "asdf",
66            PackageManager::Unknown => "unknown",
67        }
68    }
69}
70
71const FALLBACK_STALE_MS: u64 = 2 * 60 * 60 * 1000;
72
73pub fn is_pid_based_locking_enabled() -> bool {
74    if let Ok(val) = std::env::var(system::ENABLE_PID_BASED_VERSION_LOCKING) {
75        let val_lower = val.to_lowercase();
76        if val_lower == "true" || val_lower == "1" || val_lower == "yes" {
77            return true;
78        }
79        if val_lower == "false" || val_lower == "0" || val_lower == "no" || val_lower.is_empty() {
80            return false;
81        }
82    }
83    false
84}
85
86#[cfg(unix)]
87pub fn is_process_running(pid: u32) -> bool {
88    if pid <= 1 {
89        return false;
90    }
91    unsafe {
92        let result = libc::kill(pid as libc::pid_t, 0);
93        result == 0 || std::io::Error::last_os_error().raw_os_error() == Some(libc::EPERM as i32)
94    }
95}
96
97#[cfg(not(unix))]
98pub fn is_process_running(pid: u32) -> bool {
99    if pid <= 1 {
100        return false;
101    }
102    pid == std::process::id()
103}
104
105fn is_claude_process(pid: u32, expected_exec_path: &str) -> bool {
106    if !is_process_running(pid) {
107        return false;
108    }
109    if pid == std::process::id() {
110        return true;
111    }
112    true
113}
114
115pub fn read_lock_content(lock_file_path: &str) -> Option<VersionLockContent> {
116    let path = Path::new(lock_file_path);
117    match std::fs::read_to_string(path) {
118        Ok(content) => {
119            if content.trim().is_empty() {
120                return None;
121            }
122            match serde_json::from_str::<VersionLockContent>(&content) {
123                Ok(parsed) => {
124                    if parsed.version.is_empty() || parsed.exec_path.is_empty() {
125                        None
126                    } else {
127                        Some(parsed)
128                    }
129                }
130                Err(_) => None,
131            }
132        }
133        Err(_) => None,
134    }
135}
136
137pub fn is_lock_active(lock_file_path: &str) -> bool {
138    let content = match read_lock_content(lock_file_path) {
139        Some(c) => c,
140        None => return false,
141    };
142
143    let pid = content.pid;
144
145    if !is_process_running(pid) {
146        return false;
147    }
148
149    if !is_claude_process(pid, &content.exec_path) {
150        return false;
151    }
152
153    let path = Path::new(lock_file_path);
154    if let Ok(metadata) = std::fs::metadata(path) {
155        if let Ok(modified) = metadata.modified() {
156            let age = SystemTime::now()
157                .duration_since(modified)
158                .unwrap_or(std::time::Duration::ZERO)
159                .as_millis() as u64;
160
161            if age > FALLBACK_STALE_MS {
162                if !is_process_running(pid) {
163                    return false;
164                }
165            }
166        }
167    }
168
169    true
170}
171
172pub fn try_acquire_lock(
173    version_path: &str,
174    lock_file_path: &str,
175) -> Option<Box<dyn FnOnce() + Send>> {
176    let version_name = Path::new(version_path)
177        .file_name()
178        .and_then(|n| n.to_str())
179        .unwrap_or("unknown")
180        .to_string();
181
182    if is_lock_active(lock_file_path) {
183        if read_lock_content(lock_file_path).is_some() {
184            return None;
185        }
186    }
187
188    let lock_content = VersionLockContent {
189        pid: std::process::id(),
190        version: version_name.clone(),
191        exec_path: std::env::current_exe()
192            .ok()
193            .and_then(|p| p.to_str().map(|s| s.to_string()))
194            .unwrap_or_default(),
195        acquired_at: SystemTime::now()
196            .duration_since(SystemTime::UNIX_EPOCH)
197            .unwrap()
198            .as_millis() as u64,
199    };
200
201    let json = match serde_json::to_string_pretty(&lock_content) {
202        Ok(j) => j,
203        Err(_) => return None,
204    };
205
206    if let Err(_) = std::fs::write(lock_file_path, &json) {
207        return None;
208    }
209
210    if let Some(verify_content) = read_lock_content(lock_file_path) {
211        if verify_content.pid != std::process::id() {
212            return None;
213        }
214    } else {
215        return None;
216    }
217
218    let lock_path = lock_file_path.to_string();
219    Some(Box::new(move || {
220        if let Some(current_content) = read_lock_content(&lock_path) {
221            if current_content.pid == std::process::id() {
222                let _ = std::fs::remove_file(&lock_path);
223            }
224        }
225    }))
226}
227
228pub fn acquire_process_lifetime_lock(version_path: &str, lock_file_path: &str) -> bool {
229    if let Some(release) = try_acquire_lock(version_path, lock_file_path) {
230        std::mem::forget(release);
231        true
232    } else {
233        false
234    }
235}
236
237pub fn with_lock<F>(version_path: &str, lock_file_path: &str, callback: F) -> bool
238where
239    F: FnOnce() + Send,
240{
241    if let Some(_release) = try_acquire_lock(version_path, lock_file_path) {
242        callback();
243        true
244    } else {
245        false
246    }
247}
248
249pub fn get_all_lock_info(locks_dir: &str) -> Vec<LockInfo> {
250    let mut lock_infos = Vec::new();
251    let path = Path::new(locks_dir);
252
253    if !path.is_dir() {
254        return lock_infos;
255    }
256
257    if let Ok(lock_files) = std::fs::read_dir(path) {
258        for lock_entry in lock_files.flatten() {
259            let lock_file_path = lock_entry.path();
260            if let Some(file_name) = lock_file_path.file_name() {
261                if file_name.to_string_lossy().ends_with(".lock") {
262                    if let Some(content) = read_lock_content(&lock_file_path.to_string_lossy()) {
263                        lock_infos.push(LockInfo {
264                            version: content.version,
265                            pid: content.pid,
266                            is_process_running: is_process_running(content.pid),
267                            exec_path: content.exec_path,
268                            acquired_at: SystemTime::UNIX_EPOCH
269                                + std::time::Duration::from_millis(content.acquired_at),
270                            lock_file_path: lock_file_path.to_string_lossy().to_string(),
271                        });
272                    }
273                }
274            }
275        }
276    }
277
278    lock_infos
279}
280
281pub fn cleanup_stale_locks(locks_dir: &str) -> usize {
282    let path = Path::new(locks_dir);
283    let mut cleaned_count = 0;
284
285    if !path.is_dir() {
286        return 0;
287    }
288
289    if let Ok(lock_entries) = std::fs::read_dir(path) {
290        for lock_entry in lock_entries.flatten() {
291            let lock_path = lock_entry.path();
292            if let Some(file_name) = lock_path.file_name() {
293                if file_name.to_string_lossy().ends_with(".lock") {
294                    if lock_path.is_dir() {
295                        if std::fs::remove_dir_all(&lock_path).is_ok() {
296                            cleaned_count += 1;
297                        }
298                    } else if !is_lock_active(&lock_path.to_string_lossy()) {
299                        if std::fs::remove_file(&lock_path).is_ok() {
300                            cleaned_count += 1;
301                        }
302                    }
303                }
304            }
305        }
306    }
307
308    cleaned_count
309}
310
311fn is_distro_family(os_release: &OsRelease, families: &[&str]) -> bool {
312    if families.iter().any(|f| *f == os_release.id) {
313        return true;
314    }
315    os_release
316        .id_like
317        .iter()
318        .any(|like| families.contains(&like.as_str()))
319}
320
321pub fn get_os_release() -> Option<OsRelease> {
322    let mut cache = OS_RELEASE_CACHE.lock().ok()?;
323    if cache.is_some() {
324        return cache.clone();
325    }
326
327    let content = std::fs::read_to_string("/etc/os-release").ok()?;
328
329    let mut id = String::new();
330    let mut id_like = Vec::new();
331
332    for line in content.lines() {
333        if let Some(prefix) = line.strip_prefix("ID=") {
334            id = prefix.trim_matches('"').trim_matches('\'').to_string();
335        }
336        if let Some(prefix) = line.strip_prefix("ID_LIKE=") {
337            let value = prefix.trim_matches('"').trim_matches('\'');
338            id_like = value.split_whitespace().map(|s| s.to_string()).collect();
339        }
340    }
341
342    if id.is_empty() {
343        return None;
344    }
345
346    let result = OsRelease { id, id_like };
347    *cache = Some(result.clone());
348    Some(result)
349}
350
351pub fn detect_mise() -> bool {
352    let exec_path = std::env::current_exe()
353        .ok()
354        .and_then(|p| p.to_str().map(|s| s.to_string()))
355        .unwrap_or_default();
356
357    exec_path.contains("/mise/installs/") || exec_path.contains("\\mise\\installs\\")
358}
359
360pub fn detect_asdf() -> bool {
361    let exec_path = std::env::current_exe()
362        .ok()
363        .and_then(|p| p.to_str().map(|s| s.to_string()))
364        .unwrap_or_default();
365
366    exec_path.contains("/.asdf/installs/")
367        || exec_path.contains("\\.asdf\\installs\\")
368        || exec_path.contains("/asdf/installs/")
369        || exec_path.contains("\\asdf\\installs\\")
370}
371
372pub fn detect_homebrew() -> bool {
373    let platform = get_platform();
374
375    if platform != "macos" && platform != "linux" && platform != "wsl" {
376        return false;
377    }
378
379    let exec_path = std::env::current_exe()
380        .ok()
381        .and_then(|p| p.to_str().map(|s| s.to_string()))
382        .unwrap_or_default();
383
384    exec_path.contains("/Caskroom/")
385}
386
387pub fn detect_winget() -> bool {
388    let platform = get_platform();
389
390    if platform != "windows" {
391        return false;
392    }
393
394    let exec_path = std::env::current_exe()
395        .ok()
396        .and_then(|p| p.to_str().map(|s| s.to_string()))
397        .unwrap_or_default();
398
399    exec_path.contains("Microsoft\\WinGet\\Packages")
400        || exec_path.contains("Microsoft/WinGet/Packages")
401        || exec_path.contains("Microsoft\\WinGet\\Links")
402        || exec_path.contains("Microsoft/WinGet/Links")
403}
404
405pub async fn detect_pacman() -> bool {
406    let platform = get_platform();
407
408    if platform != "linux" {
409        return false;
410    }
411
412    if let Some(os_release) = get_os_release() {
413        if !is_distro_family(&os_release, &["arch"]) {
414            return false;
415        }
416    }
417
418    let exec_path = std::env::current_exe()
419        .ok()
420        .and_then(|p| p.to_str().map(|s| s.to_string()))
421        .unwrap_or_default();
422
423    let output = tokio::process::Command::new("pacman")
424        .args(["-Qo", &exec_path])
425        .output()
426        .await;
427
428    match output {
429        Ok(o) => o.status.success() && !o.stdout.is_empty(),
430        Err(_) => false,
431    }
432}
433
434pub async fn detect_deb() -> bool {
435    let platform = get_platform();
436
437    if platform != "linux" {
438        return false;
439    }
440
441    if let Some(os_release) = get_os_release() {
442        if !is_distro_family(&os_release, &["debian"]) {
443            return false;
444        }
445    }
446
447    let exec_path = std::env::current_exe()
448        .ok()
449        .and_then(|p| p.to_str().map(|s| s.to_string()))
450        .unwrap_or_default();
451
452    let output = tokio::process::Command::new("dpkg")
453        .args(["-S", &exec_path])
454        .output()
455        .await;
456
457    match output {
458        Ok(o) => o.status.success() && !o.stdout.is_empty(),
459        Err(_) => false,
460    }
461}
462
463pub async fn detect_rpm() -> bool {
464    let platform = get_platform();
465
466    if platform != "linux" {
467        return false;
468    }
469
470    if let Some(os_release) = get_os_release() {
471        if !is_distro_family(&os_release, &["fedora", "rhel", "suse"]) {
472            return false;
473        }
474    }
475
476    let exec_path = std::env::current_exe()
477        .ok()
478        .and_then(|p| p.to_str().map(|s| s.to_string()))
479        .unwrap_or_default();
480
481    let output = tokio::process::Command::new("rpm")
482        .args(["-qf", &exec_path])
483        .output()
484        .await;
485
486    match output {
487        Ok(o) => o.status.success() && !o.stdout.is_empty(),
488        Err(_) => false,
489    }
490}
491
492pub async fn detect_apk() -> bool {
493    let platform = get_platform();
494
495    if platform != "linux" {
496        return false;
497    }
498
499    if let Some(os_release) = get_os_release() {
500        if !is_distro_family(&os_release, &["alpine"]) {
501            return false;
502        }
503    }
504
505    let exec_path = std::env::current_exe()
506        .ok()
507        .and_then(|p| p.to_str().map(|s| s.to_string()))
508        .unwrap_or_default();
509
510    let output = tokio::process::Command::new("apk")
511        .args(["info", "--who-owns", &exec_path])
512        .output()
513        .await;
514
515    match output {
516        Ok(o) => o.status.success() && !o.stdout.is_empty(),
517        Err(_) => false,
518    }
519}
520
521pub async fn get_package_manager() -> PackageManager {
522    if let Ok(cache) = PACKAGE_MANAGER_CACHE.lock() {
523        if let Some(pm) = *cache {
524            return pm;
525        }
526    }
527
528    let pm = get_package_manager_impl().await;
529
530    if let Ok(mut cache) = PACKAGE_MANAGER_CACHE.lock() {
531        *cache = Some(pm);
532    }
533
534    pm
535}
536
537async fn get_package_manager_impl() -> PackageManager {
538    if detect_homebrew() {
539        return PackageManager::Homebrew;
540    }
541
542    if detect_winget() {
543        return PackageManager::Winget;
544    }
545
546    if detect_mise() {
547        return PackageManager::Mise;
548    }
549
550    if detect_asdf() {
551        return PackageManager::Asdf;
552    }
553
554    if detect_pacman().await {
555        return PackageManager::Pacman;
556    }
557
558    if detect_apk().await {
559        return PackageManager::Apk;
560    }
561
562    if detect_deb().await {
563        return PackageManager::Deb;
564    }
565
566    if detect_rpm().await {
567        return PackageManager::Rpm;
568    }
569
570    PackageManager::Unknown
571}
572
573pub fn is_native_installer_available() -> bool {
574    #[cfg(target_os = "macos")]
575    {
576        Command::new("brew")
577            .arg("--version")
578            .output()
579            .map(|o| o.status.success())
580            .unwrap_or(false)
581    }
582
583    #[cfg(target_os = "linux")]
584    {
585        Command::new("apt-get")
586            .arg("--version")
587            .output()
588            .map(|o| o.status.success())
589            .unwrap_or(false)
590    }
591
592    #[cfg(not(any(target_os = "macos", target_os = "linux")))]
593    {
594        false
595    }
596}
597
598pub fn install_package(package: &str) -> Result<(), String> {
599    #[cfg(target_os = "macos")]
600    {
601        let output = Command::new("brew")
602            .args(["install", package])
603            .output()
604            .map_err(|e| e.to_string())?;
605
606        if output.status.success() {
607            Ok(())
608        } else {
609            Err(String::from_utf8_lossy(&output.stderr).to_string())
610        }
611    }
612
613    #[cfg(target_os = "linux")]
614    {
615        let output = Command::new("sudo")
616            .args(["apt-get", "install", "-y", package])
617            .output()
618            .map_err(|e| e.to_string())?;
619
620        if output.status.success() {
621            Ok(())
622        } else {
623            Err(String::from_utf8_lossy(&output.stderr).to_string())
624        }
625    }
626
627    #[cfg(not(any(target_os = "macos", target_os = "linux")))]
628    {
629        Err("Unsupported platform".to_string())
630    }
631}