aura-gui 0.2.2

UI nativa acelerada por GPU para escritorio, Android e iOS, en un solo crate: estilo tipo Tailwind, componentes, sombras, animaciones y agentes de IA. Reposo a cero. Licencia AGPL-3.0 o comercial.
//! Inicio de sesión de las suscripciones (Claude, ChatGPT) con el navegador.
//!
//! Aura no maneja OAuth ni guarda tokens: cada agente oficial trae su propia CLI, que abre el navegador, hace el
//! inicio de sesión con la cuenta del usuario y guarda sus credenciales donde ella decide. Aquí solo se lanza esa CLI.

use crate::ai::exec_path;
use crate::ai::types::AiError;
use std::process::{Command, Stdio};
use std::time::{Duration, Instant};

/// Plazo para completar el inicio de sesión en el navegador.
const LOGIN_TIMEOUT: Duration = Duration::from_secs(300);

/// Cómo inicia sesión cada agente: con su CLI o con el método `authenticate` de ACP (el agente abre el navegador).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Login {
    Cli(LoginCommand),
    Acp { account: &'static str, method: &'static str },
}

impl Login {
    /// Cómo se llama la cuenta en los mensajes.
    pub fn account(&self) -> &'static str {
        match self {
            Login::Cli(command) => command.account,
            Login::Acp { account, .. } => account,
        }
    }
}

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct LoginCommand {
    pub program: &'static str,
    pub args: &'static [&'static str],
    pub account: &'static str,
}

/// Inicio de sesión del agente externo `name`, si es uno de los conocidos.
pub fn for_agent(name: &str) -> Option<Login> {
    match name {
        "claude" => Some(Login::Cli(LoginCommand { program: "claude", args: &["auth", "login"], account: "Claude" })),
        "codex" => Some(Login::Acp { account: "ChatGPT", method: "chatgpt" }),
        _ => None,
    }
}

fn last_line(bytes: &[u8]) -> String {
    String::from_utf8_lossy(bytes)
        .lines()
        .rev()
        .map(str::trim)
        .find(|line| !line.is_empty())
        .unwrap_or_default()
        .to_owned()
}

/// Ejecuta la CLI y espera a que el usuario termine en el navegador (bloqueante: llamar desde un hilo).
pub fn run_cli(command: &LoginCommand) -> Result<String, AiError> {
    let mut child = Command::new(exec_path::resolve(command.program))
        .args(command.args)
        .env("PATH", exec_path::child_path())
        .stdin(Stdio::null())
        .stdout(Stdio::piped())
        .stderr(Stdio::piped())
        .spawn()
        .map_err(|error| {
            AiError::Transport(format!(
                "no se encontró «{}» para iniciar sesión en {}: {error}",
                command.program, command.account
            ))
        })?;
    let started = Instant::now();
    let status = loop {
        match child.try_wait().map_err(|error| AiError::Transport(error.to_string()))? {
            Some(status) => break status,
            None if started.elapsed() > LOGIN_TIMEOUT => {
                let _ = child.kill();
                let _ = child.wait();
                return Err(AiError::Transport("se agotó el tiempo para iniciar sesión en el navegador".into()));
            }
            None => std::thread::sleep(Duration::from_millis(200)),
        }
    };
    let output = child.wait_with_output().map_err(|error| AiError::Transport(error.to_string()))?;
    if status.success() {
        Ok(last_line(&output.stdout))
    } else {
        Err(AiError::Transport(format!("el inicio de sesión falló: {}", last_line(&output.stderr))))
    }
}

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

    #[test]
    fn conoce_las_dos_suscripciones_y_nada_mas() {
        assert!(matches!(for_agent("claude"), Some(Login::Cli(command)) if command.args == ["auth", "login"]));
        assert_eq!(for_agent("codex"), Some(Login::Acp { account: "ChatGPT", method: "chatgpt" }));
        assert_eq!(for_agent("codex").unwrap().account(), "ChatGPT");
        assert!(for_agent("otro").is_none());
    }

    #[test]
    fn el_exito_devuelve_la_ultima_linea_y_el_fallo_el_error() {
        let ok = LoginCommand { program: "sh", args: &["-c", "echo uno; echo listo"], account: "X" };
        assert_eq!(run_cli(&ok).unwrap(), "listo");
        let fail = LoginCommand { program: "sh", args: &["-c", "echo no puedo >&2; exit 3"], account: "X" };
        assert!(run_cli(&fail).unwrap_err().to_string().contains("no puedo"));
        let missing = LoginCommand { program: "aura-no-existe-xyz", args: &[], account: "ChatGPT" };
        assert!(run_cli(&missing).unwrap_err().to_string().contains("ChatGPT"));
    }
}