fundaia 0.10.0

Command line for the Fundaia deployment platform: projects, services, variables, deployments, logs and metrics
use std::time::Duration;

use anyhow::{bail, Context, Result};
use owo_colors::OwoColorize;

use crate::api::client::ApiClient;
use crate::api::models::{CollectedAuthorization, StartedAuthorization};
use crate::config::{normalise_url, Config, Profile};
use crate::render::theme;

/// Signing in from a terminal, through the browser.
///
/// The command line cannot do a passkey — there is no authenticator behind a
/// pipe — so the credential is minted where one works and carried back. What
/// this runs is the device flow: ask for a code, send the person to a page,
/// then wait.
///
/// Two things about the waiting are deliberate. The code is printed *before*
/// the browser opens, because the browser may open on the wrong profile, on
/// another machine, or not at all — and the code is what makes the request
/// findable in every one of those cases. And the poll interval comes from the
/// server rather than from here, so a busy instance can slow every client down
/// without shipping a new binary.
pub async fn run(url: &str, profile_name: &str, open_browser: bool) -> Result<()> {
    let base = normalise_url(url);
    let client = ApiClient::new(&base, None)?;

    let started: StartedAuthorization = client
        .post(
            "/api/auth/cli/authorizations",
            &serde_json::json!({
                "clientName": "fundaia",
                "clientHost": machine_name(),
            }),
        )
        .await
        .with_context(|| format!("could not reach {base}"))?;

    println!();
    println!(
        "  {}  {}",
        "Código:".style(theme::muted()),
        started.user_code.style(theme::strong())
    );
    println!(
        "  {}  {}",
        "Página:".style(theme::muted()),
        started.verification_uri.style(theme::accent())
    );
    println!();

    if open_browser {
        match open::that_detached(&started.verification_uri_complete) {
            Ok(()) => println!("  {}", "Abriendo el navegador…".style(theme::muted())),
            // Never fatal: the address is already on screen, and a machine with
            // no browser is exactly the machine this has to keep working on.
            Err(_) => println!(
                "  {}",
                "Abre esa página para autorizar.".style(theme::muted())
            ),
        }
    } else {
        println!(
            "  {}",
            "Abre esa página para autorizar.".style(theme::muted())
        );
    }

    println!("  {}", "Esperando…".style(theme::muted()));

    let token = wait_for_token(&client, &started).await?;

    let mut config = Config::load()?;
    config.set(
        profile_name,
        Profile {
            url: base.clone(),
            token: token.clone(),
        },
    );
    config.current = Some(profile_name.to_owned());
    let path = config.save()?;

    let identity = ApiClient::new(&base, Some(token))?
        .whoami()
        .await
        .ok()
        .flatten();
    let who = identity
        .map(|principal| principal.display_name)
        .unwrap_or_else(|| "esta instancia".to_owned());

    println!();
    println!(
        "  {} Conectado a {} como {}",
        theme::CHECK.style(theme::success()),
        base.style(theme::strong()),
        who.style(theme::strong())
    );
    println!(
        "  {} {}",
        "Guardado en".style(theme::muted()),
        path.display().style(theme::muted())
    );
    println!();

    Ok(())
}

async fn wait_for_token(client: &ApiClient, started: &StartedAuthorization) -> Result<String> {
    // Never faster than a second whatever the server suggests: a client that
    // polls in a tight loop is a client that gets rate limited by its own
    // eagerness, and the person is reading a screen anyway.
    let interval = Duration::from_secs(started.interval_seconds.max(1));

    loop {
        tokio::time::sleep(interval).await;

        let collected: CollectedAuthorization = client
            .post(
                "/api/auth/cli/collect",
                &serde_json::json!({ "deviceSecret": started.device_secret }),
            )
            .await?;

        match collected.status.as_str() {
            "pending" => continue,
            "approved" => {
                return collected.token.ok_or_else(|| {
                    anyhow::anyhow!("the server approved the request but sent no token")
                })
            }
            "denied" => bail!("the request was rejected in the browser"),
            "expired" => bail!("the code expired — run `fundaia login` again"),
            other => bail!("the server answered with an unexpected status: {other}"),
        }
    }
}

/// The machine's name, shown on the approval screen so the request is placeable.
fn machine_name() -> String {
    hostname::get()
        .ok()
        .and_then(|name| name.into_string().ok())
        .unwrap_or_else(|| "una máquina sin nombre".to_owned())
}

/// Forgets a profile, locally.
///
/// Local only, and it says so: the token stays valid on the server until it is
/// revoked there. Pretending otherwise would be the more dangerous lie.
pub fn logout(profile_name: Option<&str>) -> Result<()> {
    let mut config = Config::load()?;
    let name = profile_name
        .map(str::to_owned)
        .or_else(|| config.current.clone())
        .unwrap_or_else(|| "default".to_owned());

    if config.profiles.remove(&name).is_none() {
        bail!("there is no profile called `{name}`");
    }

    if config.current.as_deref() == Some(name.as_str()) {
        config.current = config.profiles.keys().next().cloned();
    }

    config.save()?;

    println!(
        "  {} Perfil `{name}` olvidado en esta máquina.",
        theme::CHECK.style(theme::success())
    );
    println!(
        "  {}",
        "El token sigue siendo válido: revócalo en la web si quieres retirarlo del todo."
            .style(theme::muted())
    );

    Ok(())
}