xbp 10.57.0

XBP is a zero-config build pack that can also interact with proxies, kafka, sockets, synthetic monitors.
Documentation
use crate::commands::cli_session::fetch_cloudflare_credentials_from_dashboard;
use crate::commands::cloudflare_config::{
    ensure_cloudflare_credentials_interactive, is_interactive_terminal,
};
use crate::config::{
    cloudflare_api_token_candidates, cloudflare_api_token_candidates_builds_first,
    resolve_cloudflare_account_id, resolve_cloudflare_api_token,
};
use xbp_providers::cloudflare::CloudflareClient;

#[derive(Debug, Clone, Default)]
pub struct CloudflareCredentialOverrides {
    pub token: Option<String>,
    pub account_id: Option<String>,
}

#[derive(Debug, Clone)]
pub struct ResolvedCloudflareCredentials {
    pub token: String,
    pub account_id: String,
    pub token_source: String,
}

pub async fn resolve_cloudflare_credentials(
    overrides: CloudflareCredentialOverrides,
    require_account_id: bool,
) -> Result<(String, String), String> {
    let resolved = resolve_cloudflare_credentials_detailed(overrides, require_account_id).await?;
    Ok((resolved.token, resolved.account_id))
}

pub async fn resolve_cloudflare_credentials_detailed(
    overrides: CloudflareCredentialOverrides,
    require_account_id: bool,
) -> Result<ResolvedCloudflareCredentials, String> {
    match try_resolve_cloudflare_credentials_detailed(&overrides, require_account_id).await {
        Ok(credentials) => Ok(credentials),
        Err(error) if is_interactive_terminal() && overrides_are_empty(&overrides) => {
            ensure_cloudflare_credentials_interactive().await?;
            try_resolve_cloudflare_credentials_detailed(&overrides, require_account_id)
                .await
                .map_err(|_| error)
        }
        Err(error) => Err(error),
    }
}

pub async fn build_cloudflare_client(
    overrides: CloudflareCredentialOverrides,
    require_account_id: bool,
) -> Result<CloudflareClient, String> {
    let (token, account_id) = resolve_cloudflare_credentials(overrides, require_account_id).await?;
    CloudflareClient::new(token, account_id)
}

/// All distinct tokens to try for APIs that are picky about scopes (Workers Builds).
/// Order: CLI override → env → global config → dashboard-linked token.
pub async fn cloudflare_credential_try_list(
    overrides: CloudflareCredentialOverrides,
    require_account_id: bool,
) -> Result<Vec<ResolvedCloudflareCredentials>, String> {
    cloudflare_credential_try_list_inner(overrides, require_account_id, false).await
}

/// Like [`cloudflare_credential_try_list`], but tries user tokens (`cfut_`) before
/// account-owned tokens (`cfat_`) so Workers Builds is more likely to succeed when both
/// are configured.
pub async fn cloudflare_credential_try_list_builds_first(
    overrides: CloudflareCredentialOverrides,
    require_account_id: bool,
) -> Result<Vec<ResolvedCloudflareCredentials>, String> {
    cloudflare_credential_try_list_inner(overrides, require_account_id, true).await
}

async fn cloudflare_credential_try_list_inner(
    overrides: CloudflareCredentialOverrides,
    require_account_id: bool,
    prefer_user_for_builds: bool,
) -> Result<Vec<ResolvedCloudflareCredentials>, String> {
    let account_id_override = normalize_override(overrides.account_id.clone());
    let account_id = resolve_account_id(account_id_override.clone(), require_account_id)?;
    let mut out = Vec::new();
    let mut seen = std::collections::BTreeSet::new();

    if let Some(token) = normalize_override(overrides.token.clone()) {
        if seen.insert(token.clone()) {
            out.push(ResolvedCloudflareCredentials {
                token,
                account_id: account_id.clone(),
                token_source: "CLI --token override".into(),
            });
        }
    }

    let candidates = if prefer_user_for_builds {
        cloudflare_api_token_candidates_builds_first()
    } else {
        cloudflare_api_token_candidates()
    };
    for (source, token) in candidates {
        if seen.insert(token.clone()) {
            out.push(ResolvedCloudflareCredentials {
                token,
                account_id: account_id.clone(),
                token_source: source,
            });
        }
    }

    if let Some(credentials) = fetch_cloudflare_credentials_from_dashboard().await? {
        let token = credentials.access_token.trim().to_string();
        if !token.is_empty() && seen.insert(token.clone()) {
            let acc = if account_id.is_empty() {
                credentials.account_id
            } else {
                account_id.clone()
            };
            out.push(ResolvedCloudflareCredentials {
                token,
                account_id: acc,
                token_source: "dashboard-linked (xbp login / cloudflare login)".into(),
            });
        }
    }

    if out.is_empty() {
        return Err(missing_cloudflare_credentials_message());
    }
    Ok(out)
}

async fn try_resolve_cloudflare_credentials_detailed(
    overrides: &CloudflareCredentialOverrides,
    require_account_id: bool,
) -> Result<ResolvedCloudflareCredentials, String> {
    let token_override = normalize_override(overrides.token.clone());
    let account_id_override = normalize_override(overrides.account_id.clone());

    if let Some(token) = token_override {
        let account_id = resolve_account_id(account_id_override, require_account_id)?;
        return Ok(ResolvedCloudflareCredentials {
            token,
            account_id,
            token_source: "CLI --token override".into(),
        });
    }

    if let Some((source, token)) = cloudflare_api_token_candidates().into_iter().next() {
        let account_id = resolve_account_id(account_id_override, require_account_id)?;
        return Ok(ResolvedCloudflareCredentials {
            token,
            account_id,
            token_source: source,
        });
    }

    // Keep resolve_cloudflare_api_token path for any future sources it gains.
    if let Some(token) = resolve_cloudflare_api_token() {
        let account_id = resolve_account_id(account_id_override, require_account_id)?;
        return Ok(ResolvedCloudflareCredentials {
            token,
            account_id,
            token_source: "resolved Cloudflare token".into(),
        });
    }

    if let Some(credentials) = fetch_cloudflare_credentials_from_dashboard().await? {
        let account_id = account_id_override.unwrap_or(credentials.account_id);
        return Ok(ResolvedCloudflareCredentials {
            token: credentials.access_token,
            account_id,
            token_source: "dashboard-linked (xbp login / cloudflare login)".into(),
        });
    }

    Err(missing_cloudflare_credentials_message())
}

fn resolve_account_id(
    account_id_override: Option<String>,
    require_account_id: bool,
) -> Result<String, String> {
    if let Some(account_id) = account_id_override.or_else(resolve_cloudflare_account_id) {
        return Ok(account_id);
    }

    if require_account_id {
        return Err(missing_cloudflare_account_id_message());
    }

    Ok(String::new())
}

fn overrides_are_empty(overrides: &CloudflareCredentialOverrides) -> bool {
    overrides.token.is_none() && overrides.account_id.is_none()
}

fn normalize_override(value: Option<String>) -> Option<String> {
    value
        .map(|value| value.trim().to_string())
        .filter(|value| !value.is_empty())
}

fn missing_cloudflare_credentials_message() -> String {
    "Cloudflare credentials are not configured.".to_string()
}

fn missing_cloudflare_account_id_message() -> String {
    "Cloudflare account ID is not configured.".to_string()
}

#[cfg(test)]
mod tests {
    use super::{normalize_override, overrides_are_empty, CloudflareCredentialOverrides};

    #[test]
    fn normalize_override_trims_and_rejects_empty_values() {
        assert_eq!(
            normalize_override(Some("  token  ".to_string())),
            Some("token".to_string())
        );
        assert_eq!(normalize_override(Some("   ".to_string())), None);
        assert_eq!(normalize_override(None), None);
    }

    #[test]
    fn detects_empty_overrides() {
        assert!(overrides_are_empty(
            &CloudflareCredentialOverrides::default()
        ));
        assert!(!overrides_are_empty(&CloudflareCredentialOverrides {
            token: Some("token".to_string()),
            account_id: None,
        }));
    }
}