xbp 10.28.0

XBP is a zero-config build pack that can also interact with proxies, kafka, sockets, synthetic monitors.
Documentation
use crate::cli::ui::Loader;
use crate::config::{ApiConfig, SshConfig};
use crate::utils::open_with_default_handler;
use reqwest::Client;
use serde::{Deserialize, Serialize};
use tokio::time::{sleep, Duration};

#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct CliLoginRequestPayload {
    device_name: Option<String>,
    hostname: Option<String>,
    platform: Option<String>,
}

#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct CliLoginRequestResponse {
    browser_url: String,
    expires_at: String,
    flow_id: String,
    poll_interval_seconds: u64,
    poll_token: String,
}

#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct CliLoginPollRequest {
    flow_id: String,
    poll_token: String,
}

#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct CliLoginPollResponse {
    status: String,
    access_token: Option<String>,
    expires_at: Option<String>,
}

#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct CliAuthSessionResponse {
    user: CliAuthSessionUser,
    token: CliAuthSessionToken,
}

#[derive(Debug, Deserialize)]
struct CliAuthSessionUser {
    name: String,
    email: String,
}

#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct CliAuthSessionToken {
    label: Option<String>,
    prefix: String,
    expires_at: Option<String>,
}

fn current_hostname() -> Option<String> {
    for key in ["HOSTNAME", "COMPUTERNAME"] {
        if let Ok(value) = std::env::var(key) {
            let trimmed = value.trim();
            if !trimmed.is_empty() {
                return Some(trimmed.to_string());
            }
        }
    }

    None
}

fn build_device_name(hostname: Option<&str>) -> Option<String> {
    let username = std::env::var("USERNAME")
        .or_else(|_| std::env::var("USER"))
        .ok()
        .map(|value| value.trim().to_string())
        .filter(|value| !value.is_empty());

    match (username, hostname) {
        (Some(user), Some(host)) => Some(format!("{}@{}", user, host)),
        (Some(user), None) => Some(user),
        (None, Some(host)) => Some(host.to_string()),
        (None, None) => None,
    }
}

fn current_platform() -> String {
    std::env::consts::OS.to_string()
}

async fn request_cli_login_flow(
    client: &Client,
    api: &ApiConfig,
) -> Result<CliLoginRequestResponse, String> {
    let hostname = current_hostname();
    let payload = CliLoginRequestPayload {
        device_name: build_device_name(hostname.as_deref()),
        hostname,
        platform: Some(current_platform()),
    };

    client
        .post(api.cli_auth_request_endpoint())
        .json(&payload)
        .send()
        .await
        .map_err(|e| format!("Failed to start CLI login flow: {}", e))?
        .error_for_status()
        .map_err(|e| format!("CLI login flow request failed: {}", e))?
        .json::<CliLoginRequestResponse>()
        .await
        .map_err(|e| format!("Failed to parse CLI login flow response: {}", e))
}

async fn poll_cli_login_flow(
    client: &Client,
    api: &ApiConfig,
    flow: &CliLoginRequestResponse,
) -> Result<CliLoginPollResponse, String> {
    client
        .post(api.cli_auth_poll_endpoint())
        .json(&CliLoginPollRequest {
            flow_id: flow.flow_id.clone(),
            poll_token: flow.poll_token.clone(),
        })
        .send()
        .await
        .map_err(|e| format!("Failed to poll CLI login flow: {}", e))?
        .error_for_status()
        .map_err(|e| format!("CLI login flow polling failed: {}", e))?
        .json::<CliLoginPollResponse>()
        .await
        .map_err(|e| format!("Failed to parse CLI login poll response: {}", e))
}

async fn fetch_cli_session(
    client: &Client,
    api: &ApiConfig,
    token: &str,
) -> Result<CliAuthSessionResponse, String> {
    client
        .get(api.cli_auth_session_endpoint())
        .bearer_auth(token)
        .send()
        .await
        .map_err(|e| format!("Failed to verify CLI login token: {}", e))?
        .error_for_status()
        .map_err(|e| format!("CLI login token verification failed: {}", e))?
        .json::<CliAuthSessionResponse>()
        .await
        .map_err(|e| format!("Failed to parse CLI session response: {}", e))
}

fn save_cli_token(token: String) -> Result<(), String> {
    let mut config = SshConfig::load().unwrap_or_else(|_| SshConfig::new());
    config.xbp_api_token = Some(token);
    config.save()
}

pub async fn run_login() -> Result<(), String> {
    let api = ApiConfig::load();
    let client = Client::builder()
        .timeout(Duration::from_secs(20))
        .build()
        .map_err(|e| format!("Failed to create HTTP client: {}", e))?;

    let request_loader = Loader::start("Creating browser login request");
    let flow = request_cli_login_flow(&client, &api).await?;
    request_loader.success_with("Browser login request created");

    println!("Open this URL to authorize the CLI:");
    println!("{}", flow.browser_url);
    println!();
    println!("Flow expires at: {}", flow.expires_at);

    if let Err(error) = open_with_default_handler(&flow.browser_url) {
        println!("Could not open the browser automatically: {}", error);
    }

    let poll_loader = Loader::start("Waiting for browser approval");
    loop {
        let response = poll_cli_login_flow(&client, &api, &flow).await?;

        match response.status.as_str() {
            "pending" => {
                let expiry = response
                    .expires_at
                    .as_deref()
                    .unwrap_or(flow.expires_at.as_str());
                poll_loader.update(&format!("Waiting for approval (expires {})", expiry));
                sleep(Duration::from_secs(flow.poll_interval_seconds.max(1))).await;
            }
            "expired" => {
                poll_loader.fail("expired");
                return Err(format!(
                    "The login request expired. Open {} and run `xbp login` again.",
                    api.cli_auth_browser_url(&flow.flow_id)
                ));
            }
            "completed" => {
                poll_loader.fail("already used");
                return Err(
                    "This login request was already completed. Run `xbp login` again.".to_string(),
                );
            }
            "approved" => {
                let access_token = response.access_token.ok_or_else(|| {
                    "CLI login flow reported approval without an access token.".to_string()
                })?;
                let verified = fetch_cli_session(&client, &api, &access_token).await?;
                save_cli_token(access_token)?;
                poll_loader.success_with("CLI login approved");

                println!(
                    "Signed in as {} <{}>",
                    verified.user.name, verified.user.email
                );
                if let Some(label) = verified.token.label.as_deref() {
                    println!("CLI token label: {}", label);
                }
                println!("CLI token prefix: {}", verified.token.prefix);
                if let Some(expires_at) = verified.token.expires_at.as_deref() {
                    println!("CLI token expires at: {}", expires_at);
                }

                return Ok(());
            }
            other => {
                poll_loader.fail(other);
                return Err(format!("Unexpected CLI login status: {}", other));
            }
        }
    }
}