summaryrefslogblamecommitdiff
path: root/makima/src/daemon/setup.rs
blob: 3daf779990a40475757cf22190c849e708bdc8b3 (plain) (tree)
1
2
3
4
5


                                                                         

                                                                      
































                                                        















                                                                  




                                       

                                     








                                                           


                                             































































                                                                                 
                                                                 

                                                      
                                                  









                                                              


                                                               
 










                                                          





























                                                                                





















                                                                                                                                                  



























                                                                                         
                                                               


                                                                                                                   







                                                                                     
         
































                                                                                                           







                                                 

               
























                                                                                                 
                  





                                                      
         






                                                                             
         












                                                                      



         


















                                                                                                                                      





















                                                                                                                                     
//! Setup and dependency checking for the makima daemon.
//!
//! This module provides functionality to check for required dependencies
//! (Claude Code, git) and optional tools (gh) when the daemon starts,
//! and verify git authentication capabilities.

use std::process::Stdio;
use tokio::process::Command;

/// ANSI color codes for terminal output
mod colors {
    pub const RED: &str = "\x1b[31m";
    pub const GREEN: &str = "\x1b[32m";
    pub const YELLOW: &str = "\x1b[33m";
    pub const RESET: &str = "\x1b[0m";
    pub const BOLD: &str = "\x1b[1m";
}

/// Information about a dependency's installation status
#[derive(Debug, Clone)]
pub struct DependencyInfo {
    pub name: &'static str,
    pub version: Option<String>,
    pub installed: bool,
    pub critical: bool,
}

impl DependencyInfo {
    fn new(name: &'static str, critical: bool) -> Self {
        Self {
            name,
            version: None,
            installed: false,
            critical,
        }
    }
}

/// Result of checking git authentication against remote origins
#[derive(Debug, Clone)]
pub struct GitAuthCheckResult {
    /// Whether basic git credential helper is configured
    pub credential_helper_configured: bool,
    /// Results of testing authentication against specific origins
    pub origin_results: Vec<GitOriginAuthResult>,
}

#[derive(Debug, Clone)]
pub struct GitOriginAuthResult {
    pub origin_url: String,
    pub can_authenticate: bool,
    pub error: Option<String>,
}

/// Result of checking all dependencies
#[derive(Debug)]
pub struct DependencyCheckResult {
    pub claude: DependencyInfo,
    pub git: DependencyInfo,
    pub git_auth: GitAuthCheckResult,
    pub gh: DependencyInfo,
}

impl DependencyCheckResult {
    /// Check if all critical dependencies are installed
    pub fn all_critical_installed(&self) -> bool {
        (!self.claude.critical || self.claude.installed) &&
        (!self.git.critical || self.git.installed)
    }

    /// Check if gh (GitHub CLI) is available
    pub fn gh_available(&self) -> bool {
        self.gh.installed
    }
}

/// Check the version of a command by running it with --version
async fn get_command_version(command: &str, args: &[&str]) -> Option<String> {
    let result = Command::new(command)
        .args(args)
        .stdout(Stdio::piped())
        .stderr(Stdio::piped())
        .output()
        .await;

    match result {
        Ok(output) if output.status.success() => {
            let stdout = String::from_utf8_lossy(&output.stdout);
            let stderr = String::from_utf8_lossy(&output.stderr);

            // Parse version from output (different tools output differently)
            let combined = format!("{}{}", stdout, stderr);
            extract_version(&combined)
        }
        _ => None,
    }
}

/// Extract a version number from command output
fn extract_version(output: &str) -> Option<String> {
    // Look for common version patterns
    // e.g., "git version 2.40.0", "v1.2.3", "npm 10.0.0"
    let output = output.trim();

    // Try to find version number pattern (digits separated by dots)
    for word in output.split_whitespace() {
        let word = word.trim_start_matches('v');
        if word.chars().next().map(|c| c.is_ascii_digit()).unwrap_or(false) {
            // Check if it looks like a version number
            let parts: Vec<&str> = word.split('.').collect();
            if parts.len() >= 1 && parts.iter().all(|p| {
                let p = p.split('-').next().unwrap_or("");
                p.chars().all(|c| c.is_ascii_digit())
            }) {
                // Clean up any trailing non-version chars
                let clean_version = word
                    .chars()
                    .take_while(|c| c.is_ascii_digit() || *c == '.' || *c == '-')
                    .collect::<String>();
                if !clean_version.is_empty() {
                    return Some(clean_version);
                }
            }
        }
    }

    // If we couldn't parse a version but got output, just return "installed"
    if !output.is_empty() {
        Some("installed".to_string())
    } else {
        None
    }
}

/// Check all dependencies and return their status
pub async fn check_dependencies() -> DependencyCheckResult {
    // Run all checks in parallel for speed
    let (claude_version, git_version, gh_version) = tokio::join!(
        get_command_version("claude", &["--version"]),
        get_command_version("git", &["--version"]),
        get_command_version("gh", &["--version"]),
    );

    let mut claude = DependencyInfo::new("Claude Code", true);
    claude.version = claude_version;
    claude.installed = claude.version.is_some();

    let mut git = DependencyInfo::new("git", true);
    git.version = git_version;
    git.installed = git.version.is_some();

    let mut gh = DependencyInfo::new("gh (GitHub CLI)", false);
    gh.version = gh_version;
    gh.installed = gh.version.is_some();

    // Check git authentication (only if git is installed)
    let git_auth = if git.installed {
        check_git_auth().await
    } else {
        GitAuthCheckResult {
            credential_helper_configured: false,
            origin_results: vec![],
        }
    };

    DependencyCheckResult { claude, git, git_auth, gh }
}

/// Display a nice summary of dependency status
pub fn print_dependency_summary(result: &DependencyCheckResult) {
    fn format_dep(dep: &DependencyInfo) -> String {
        if dep.installed {
            let version = dep.version.as_deref().unwrap_or("installed");
            format!(
                "      {}: {} {}{}",
                dep.name,
                version,
                colors::GREEN,
                colors::RESET
            )
        } else {
            let status = if dep.critical { "MISSING" } else { "not found" };
            let color = if dep.critical { colors::RED } else { colors::YELLOW };
            format!(
                "      {}: {}{}{} {}",
                dep.name,
                color,
                status,
                colors::RESET,
                if dep.critical { "(required)" } else { "(optional)" }
            )
        }
    }

    eprintln!("{}", format_dep(&result.claude));
    eprintln!("{}", format_dep(&result.git));

    // Print git auth status
    if result.git.installed {
        if result.git_auth.credential_helper_configured {
            eprintln!("      git credentials: {}configured{}", colors::GREEN, colors::RESET);
        } else {
            eprintln!("      git credentials: {}no credential helper configured{} (git may prompt for passwords)", colors::YELLOW, colors::RESET);
        }
    }

    // Print origin auth results if any
    for origin in &result.git_auth.origin_results {
        if origin.can_authenticate {
            eprintln!("      git origin {}: {}ok{}", origin.origin_url, colors::GREEN, colors::RESET);
        } else {
            let err_msg = origin.error.as_deref().unwrap_or("authentication failed");
            let err_msg = if err_msg.len() > 80 { &err_msg[..80] } else { err_msg };
            eprintln!("      git origin {}: {}failed{} ({})", origin.origin_url, colors::RED, colors::RESET, err_msg.trim());
        }
    }

    eprintln!("{}", format_dep(&result.gh));
}

/// Detect the current operating system
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum OperatingSystem {
    MacOS,
    Linux,
    Windows,
    Unknown,
}

impl OperatingSystem {
    pub fn detect() -> Self {
        #[cfg(target_os = "macos")]
        return OperatingSystem::MacOS;

        #[cfg(target_os = "linux")]
        return OperatingSystem::Linux;

        #[cfg(target_os = "windows")]
        return OperatingSystem::Windows;

        #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
        return OperatingSystem::Unknown;
    }
}

/// Print instructions for installing Claude Code
pub fn print_claude_install_instructions(os: OperatingSystem) {
    eprintln!();
    eprintln!("{}{}Claude Code is required to run the makima daemon.{}", colors::BOLD, colors::RED, colors::RESET);
    eprintln!();
    eprintln!("Install Claude Code:");
    match os {
        OperatingSystem::MacOS => {
            eprintln!("  {}brew install claude-code{}", colors::BOLD, colors::RESET);
            eprintln!("  Or visit: https://docs.anthropic.com/en/docs/claude-code");
        }
        _ => {
            eprintln!("  Visit: https://docs.anthropic.com/en/docs/claude-code");
        }
    }
    eprintln!();
    eprintln!("For more information, visit: https://docs.anthropic.com/en/docs/claude-code");
}

/// Print instructions for installing git
pub fn print_git_install_instructions(os: OperatingSystem) {
    eprintln!();
    eprintln!("{}{}git is required to run the makima daemon.{}", colors::BOLD, colors::RED, colors::RESET);
    eprintln!();

    match os {
        OperatingSystem::MacOS => {
            eprintln!("Install git on macOS:");
            eprintln!("  {}xcode-select --install{}", colors::BOLD, colors::RESET);
            eprintln!("  Or: {}brew install git{}", colors::BOLD, colors::RESET);
        }
        OperatingSystem::Linux => {
            eprintln!("Install git on Linux:");
            eprintln!("  Debian/Ubuntu: {}sudo apt-get install git{}", colors::BOLD, colors::RESET);
            eprintln!("  Fedora:        {}sudo dnf install git{}", colors::BOLD, colors::RESET);
            eprintln!("  Arch:          {}sudo pacman -S git{}", colors::BOLD, colors::RESET);
        }
        OperatingSystem::Windows => {
            eprintln!("Install git on Windows:");
            eprintln!("  Download from: https://git-scm.com/download/win");
        }
        OperatingSystem::Unknown => {
            eprintln!("Install git from: https://git-scm.com/downloads");
        }
    }
}

/// Check git authentication capabilities
async fn check_git_auth() -> GitAuthCheckResult {
    // Check if a credential helper is configured
    let cred_helper = Command::new("git")
        .args(["config", "credential.helper"])
        .stdout(Stdio::piped())
        .stderr(Stdio::piped())
        .output()
        .await;

    let credential_helper_configured = match cred_helper {
        Ok(output) => {
            output.status.success() && !String::from_utf8_lossy(&output.stdout).trim().is_empty()
        }
        _ => false,
    };

    GitAuthCheckResult {
        credential_helper_configured,
        origin_results: vec![],
    }
}

/// Test git authentication against a specific remote URL.
/// Uses `git ls-remote --exit-code <url>` with a timeout to verify access.
pub async fn check_git_origin_auth(url: &str) -> GitOriginAuthResult {
    let result = tokio::time::timeout(
        std::time::Duration::from_secs(15),
        Command::new("git")
            .args(["ls-remote", "--exit-code", url, "HEAD"])
            .stdout(Stdio::piped())
            .stderr(Stdio::piped())
            .output()
    ).await;

    match result {
        Ok(Ok(output)) if output.status.success() => {
            GitOriginAuthResult {
                origin_url: url.to_string(),
                can_authenticate: true,
                error: None,
            }
        }
        Ok(Ok(output)) => {
            let stderr = String::from_utf8_lossy(&output.stderr).to_string();
            GitOriginAuthResult {
                origin_url: url.to_string(),
                can_authenticate: false,
                error: Some(stderr),
            }
        }
        Ok(Err(e)) => {
            GitOriginAuthResult {
                origin_url: url.to_string(),
                can_authenticate: false,
                error: Some(format!("Failed to run git: {}", e)),
            }
        }
        Err(_) => {
            GitOriginAuthResult {
                origin_url: url.to_string(),
                can_authenticate: false,
                error: Some("Timed out after 15 seconds".to_string()),
            }
        }
    }
}

/// Print warnings about git authentication issues (non-fatal)
pub fn print_git_auth_warnings(result: &DependencyCheckResult) {
    if !result.git_auth.credential_helper_configured && result.git.installed {
        eprintln!();
        eprintln!("      {}WARNING:{} No git credential helper configured.", colors::YELLOW, colors::RESET);
        eprintln!("      Git operations may prompt for passwords interactively.");
        eprintln!("      Configure a credential helper: git config --global credential.helper store");
    }
    for origin in &result.git_auth.origin_results {
        if !origin.can_authenticate {
            eprintln!();
            eprintln!("      {}WARNING:{} Cannot authenticate with git origin: {}", colors::YELLOW, colors::RESET, origin.origin_url);
            if let Some(ref err) = origin.error {
                let err_trimmed = err.trim();
                if !err_trimmed.is_empty() {
                    eprintln!("      Error: {}", if err_trimmed.len() > 120 { &err_trimmed[..120] } else { err_trimmed });
                }
            }
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_extract_version() {
        assert_eq!(extract_version("git version 2.40.0"), Some("2.40.0".to_string()));
        assert_eq!(extract_version("v1.2.3"), Some("1.2.3".to_string()));
        assert_eq!(extract_version("10.0.0"), Some("10.0.0".to_string()));
        assert_eq!(extract_version("npm 10.2.4"), Some("10.2.4".to_string()));
    }

    #[test]
    fn test_os_detection() {
        let os = OperatingSystem::detect();
        // Just verify it doesn't panic
        assert!(matches!(os, OperatingSystem::MacOS | OperatingSystem::Linux | OperatingSystem::Windows | OperatingSystem::Unknown));
    }
}