openlatch-provider 0.2.1

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;
}

// ---------------------------------------------------------------------------
// File-tail helpers — shared by `tools logs` (per-binding tool log) and
// `events tail` (runtime audit JSONL). Two commands, same plumbing.
// ---------------------------------------------------------------------------

/// Read the last `n` lines of a file. Errors are surfaced as `OL-4270`.
pub async fn read_last_lines(
    path: &std::path::Path,
    n: usize,
) -> Result<std::collections::VecDeque<String>, OlError> {
    use tokio::io::AsyncBufReadExt;
    let file = tokio::fs::File::open(path).await.map_err(|e| {
        OlError::new(
            crate::error::OL_4270_CONFIG_UNREADABLE,
            format!("open {}: {e}", path.display()),
        )
    })?;
    let reader = tokio::io::BufReader::new(file);
    let mut lines = reader.lines();
    let mut buf: std::collections::VecDeque<String> = std::collections::VecDeque::with_capacity(n);
    while let Some(line) = lines.next_line().await.transpose() {
        let line = line.map_err(|e| {
            OlError::new(
                crate::error::OL_4270_CONFIG_UNREADABLE,
                format!("read {}: {e}", path.display()),
            )
        })?;
        if buf.len() == n {
            buf.pop_front();
        }
        buf.push_back(line);
    }
    Ok(buf)
}

/// Print the last `n` lines of a file to stdout verbatim.
pub async fn print_tail(path: &std::path::Path, n: usize) -> Result<(), OlError> {
    for line in read_last_lines(path, n).await? {
        println!("{line}");
    }
    Ok(())
}

/// Print the last `tail` lines, then poll the file every 250 ms for new
/// lines and emit them as they arrive. Loops forever; the caller relies on
/// Ctrl-C / SIGINT to terminate the process.
pub async fn follow_file(path: &std::path::Path, tail: usize) -> Result<(), OlError> {
    print_tail(path, tail).await?;
    use tokio::io::AsyncBufReadExt;
    use tokio::io::AsyncSeekExt;
    let mut file = tokio::fs::File::open(path).await.map_err(|e| {
        OlError::new(
            crate::error::OL_4270_CONFIG_UNREADABLE,
            format!("open {}: {e}", path.display()),
        )
    })?;
    file.seek(std::io::SeekFrom::End(0)).await.map_err(|e| {
        OlError::new(
            crate::error::OL_4270_CONFIG_UNREADABLE,
            format!("seek: {e}"),
        )
    })?;
    let mut reader = tokio::io::BufReader::new(file);
    let mut line = String::new();
    loop {
        line.clear();
        let read = reader.read_line(&mut line).await.map_err(|e| {
            OlError::new(
                crate::error::OL_4270_CONFIG_UNREADABLE,
                format!("read {}: {e}", path.display()),
            )
        })?;
        if read == 0 {
            tokio::time::sleep(Duration::from_millis(250)).await;
            continue;
        }
        if line.ends_with('\n') {
            line.pop();
            if line.ends_with('\r') {
                line.pop();
            }
        }
        println!("{line}");
    }
}