openlatch-provider 0.1.0

Self-service onboarding CLI + runtime daemon for OpenLatch Editors and Providers
//! Cross-command helpers — `ApiClient` factory, hard-confirm prompt, etc.

use std::time::Duration;

use secrecy::SecretString;

use crate::api::client::ApiClient;
use crate::auth::{CredentialStore, FileCredentialStore, KeyringCredentialStore};
use crate::config::{self, Config};
use crate::error::{OlError, OL_4200_TOKEN_EXPIRED};

const DEFAULT_API_URL: &str = "https://api.openlatch.ai";

/// Build an [`ApiClient`] using the keychain (or env / file fallback) for
/// the bearer token and the active profile / env var for the base URL.
pub async fn make_client() -> Result<ApiClient, OlError> {
    let token = retrieve_token().await?;
    let cfg = Config::load().unwrap_or_default();
    let api_url = effective_api_url(&cfg);
    ApiClient::new(api_url, token)
}

pub async fn retrieve_token() -> Result<SecretString, OlError> {
    let store = KeyringCredentialStore::new();
    if let Ok(s) = store.retrieve_async().await {
        return Ok(s);
    }
    if let Ok(val) = std::env::var("OPENLATCH_TOKEN") {
        if !val.is_empty() {
            return Ok(SecretString::from(val));
        }
    }
    let path = config::provider_dir().join("credentials.enc");
    let machine_id = config::machine_id_or_init().unwrap_or_else(|_| "unknown".into());
    FileCredentialStore::new(path, machine_id)
        .retrieve()
        .map_err(|_| {
            OlError::new(
                OL_4200_TOKEN_EXPIRED,
                "no editor token found in keyring, OPENLATCH_TOKEN env, or credentials file",
            )
            .with_suggestion("Run `openlatch-provider login` to authenticate.")
        })
}

pub fn effective_api_url(cfg: &Config) -> String {
    if let Ok(env) = std::env::var("OPENLATCH_API_URL") {
        if !env.is_empty() {
            return env;
        }
    }
    if let Some(profile) = cfg.profiles.get("default") {
        if let Some(ref url) = profile.api_url {
            return url.clone();
        }
    }
    DEFAULT_API_URL.to_string()
}

/// Prompt the user to retype `expected` to confirm a destructive op. When
/// `--yes` is set, skip the prompt and return `Ok(())`. When stdin is not a
/// TTY, return an error so CI scripts must use `--yes` explicitly.
pub fn hard_confirm(expected: &str, interactive: bool, yes: bool) -> Result<(), OlError> {
    if yes {
        return Ok(());
    }
    if !interactive {
        return Err(OlError::new(
            OL_4200_TOKEN_EXPIRED,
            "destructive operation requires --yes in non-interactive mode",
        ));
    }
    use std::io::{BufRead, Write};
    let mut stdout = std::io::stdout();
    write!(
        stdout,
        "Type `{expected}` to confirm (or anything else to cancel): "
    )
    .ok();
    stdout.flush().ok();
    let stdin = std::io::stdin();
    let mut line = String::new();
    stdin
        .lock()
        .read_line(&mut line)
        .map_err(|e| OlError::new(OL_4200_TOKEN_EXPIRED, format!("read confirmation: {e}")))?;
    let typed = line.trim();
    if typed != expected {
        return Err(OlError::new(
            OL_4200_TOKEN_EXPIRED,
            "confirmation text did not match — operation cancelled",
        ));
    }
    Ok(())
}

/// Idle helper for ergonomic testing — returns a delay used by tests that
/// need a tiny pause; in production code this is a no-op.
#[doc(hidden)]
pub async fn _yield_short() {
    tokio::time::sleep(Duration::from_millis(1)).await;
}