openlatch-provider 0.1.0

Self-service onboarding CLI + runtime daemon for OpenLatch Editors and Providers
//! `bindings list / rotate-secret / delete-secret / probe / metrics / delete`.

use crate::api::bindings;
use crate::api::probe;
use crate::auth::binding_secrets::{
    default_file_dir, BindingSecretStore, FileBindingSecretStore, KeyringBindingSecretStore,
};
use crate::cli::commands::shared::{hard_confirm, make_client};
use crate::cli::{BindingsAction, GlobalArgs};
use crate::config;
use crate::error::OlError;
use crate::ui::color;
use crate::ui::output::OutputConfig;
use secrecy::SecretString;
use tabled::Table;

pub async fn run(g: &GlobalArgs, action: BindingsAction) -> Result<(), OlError> {
    match action {
        BindingsAction::List => list(g).await,
        BindingsAction::RotateSecret { id, yes } => rotate_secret(g, &id, yes).await,
        BindingsAction::DeleteSecret { id, yes } => delete_secret(g, &id, yes).await,
        BindingsAction::Probe { id } => probe_cmd(g, &id).await,
        BindingsAction::Metrics { id } => metrics_cmd(g, &id).await,
        BindingsAction::Delete { id, yes } => delete(g, &id, yes).await,
    }
}

async fn list(g: &GlobalArgs) -> Result<(), OlError> {
    let out = OutputConfig::resolve(g);
    let client = make_client().await?;
    let bindings = bindings::list(&client).await?;
    if out.is_machine() {
        out.print_json(&bindings);
        return Ok(());
    }
    if bindings.is_empty() {
        out.print_info("No bindings found.");
        return Ok(());
    }
    let rows: Vec<crate::ui::tables::BindingRow> = bindings
        .into_iter()
        .map(|b| crate::ui::tables::BindingRow {
            id: b.id,
            tool: b.tool,
            provider: b.provider,
            state: b.state.unwrap_or_else(|| "active".into()),
            score: b
                .routing_score
                .map(|s| format!("{s:.1}"))
                .unwrap_or_else(|| "".into()),
        })
        .collect();
    println!("{}", Table::new(rows));
    Ok(())
}

async fn rotate_secret(g: &GlobalArgs, id: &str, yes_flag: bool) -> Result<(), OlError> {
    let out = OutputConfig::resolve(g);
    let yes = g.yes || yes_flag;
    if g.dry_run {
        out.print_step(&format!(
            "--dry-run: would POST /api/v1/editor/bindings/{id}/regenerate-secret"
        ));
        return Ok(());
    }
    if !yes && !out.interactive {
        return Err(OlError::new(
            crate::error::OL_4200_TOKEN_EXPIRED,
            "rotating a secret in non-interactive mode requires --yes",
        ));
    }
    let client = make_client().await?;
    let reveal = bindings::regenerate_secret(&client, id).await?;
    persist_secret_locally(&reveal.binding_id, &reveal.secret)?;

    if out.is_machine() {
        out.print_json(&reveal);
        return Ok(());
    }

    println!();
    println!(
        "{} {}",
        color::yellow("", out.color),
        color::bold("STORE THIS SECRET — it will not be shown again", out.color)
    );
    println!();
    println!(
        "  {} {}",
        color::cyan("🔑", out.color),
        color::bold(&reveal.secret, out.color)
    );
    println!();
    out.print_step(&format!("Secret stored locally for binding {id}"));
    crate::telemetry::capture_global(crate::telemetry::Event::binding_secret_rotated());
    Ok(())
}

async fn delete_secret(g: &GlobalArgs, id: &str, yes_flag: bool) -> Result<(), OlError> {
    let out = OutputConfig::resolve(g);
    let yes = g.yes || yes_flag;
    if g.dry_run {
        out.print_step(&format!(
            "--dry-run: would DELETE /api/v1/editor/bindings/{id}/secret"
        ));
        return Ok(());
    }
    hard_confirm(id, out.interactive, yes)?;
    let client = make_client().await?;
    match bindings::delete_secret(&client, id).await {
        Ok(()) => {}
        Err(e) if e.code.code == "OL-4234" => {
            out.print_step(&format!(
                "Binding `{id}` already in secret_missing state — server-side no-op"
            ));
        }
        Err(e) => return Err(e),
    }
    // Always clear the local store too.
    let _ = clear_local_secret(id);
    out.print_step(&format!("Secret deleted for binding {id}"));
    Ok(())
}

async fn probe_cmd(g: &GlobalArgs, id: &str) -> Result<(), OlError> {
    let out = OutputConfig::resolve(g);
    let client = make_client().await?;
    let detail = bindings::get(&client, id).await?;
    let url = detail.endpoint_url.unwrap_or_default();
    if url.is_empty() {
        return Err(OlError::new(
            crate::error::OL_4212_INVALID_ENDPOINT_URL,
            format!("binding `{id}` has no endpoint_url"),
        ));
    }
    let result = probe::probe(&url);
    match (out.is_machine(), &result) {
        (true, Ok(report)) => out.print_json(report),
        (true, Err(e)) => out.print_json(&serde_json::json!({
            "passed": false,
            "endpoint_url": url,
            "error": { "code": e.code.code, "message": &e.message }
        })),
        (false, Ok(report)) => {
            out.print_step(&format!(
                "Probe passed for {url} ({} resolved)",
                report.resolved_ips.len()
            ));
            for f in &report.findings {
                out.print_substep(&format!("[{}] {}", f.code, f.message));
            }
        }
        (false, Err(e)) => {
            out.print_error(e);
        }
    }
    result.map(|_| ())
}

async fn metrics_cmd(g: &GlobalArgs, id: &str) -> Result<(), OlError> {
    let out = OutputConfig::resolve(g);
    let client = make_client().await?;
    let metrics = bindings::metrics(&client, id).await?;
    if out.is_machine() {
        out.print_json(&metrics);
        return Ok(());
    }
    out.print_step(&format!("Binding {} metrics", metrics.binding_id));
    if let Some(latency) = metrics.latency_ms {
        if let Some(p50) = latency.p50 {
            out.print_substep(&format!("Latency p50: {p50} ms"));
        }
        if let Some(p95) = latency.p95 {
            out.print_substep(&format!("Latency p95: {p95} ms"));
        }
        if let Some(p99) = latency.p99 {
            out.print_substep(&format!("Latency p99: {p99} ms"));
        }
    }
    if let Some(s) = metrics.success_rate_24h {
        out.print_substep(&format!("Success rate (24h): {:.1}%", s * 100.0));
    }
    if let Some(t) = metrics.thumbs_down_7d {
        out.print_substep(&format!("Thumbs-down (7d): {:.1}%", t * 100.0));
    }
    if let Some(score) = metrics.score {
        out.print_substep(&format!("Routing score: {score:.1}"));
    }
    if let Some(p) = metrics.active_penalty {
        out.print_substep(&format!("Active penalty: {p:.1}"));
    }
    Ok(())
}

async fn delete(g: &GlobalArgs, id: &str, yes_flag: bool) -> Result<(), OlError> {
    let out = OutputConfig::resolve(g);
    let yes = g.yes || yes_flag;
    if g.dry_run {
        out.print_step(&format!(
            "--dry-run: would DELETE /api/v1/editor/bindings/{id}"
        ));
        return Ok(());
    }
    hard_confirm(id, out.interactive, yes)?;
    let client = make_client().await?;
    match bindings::delete(&client, id).await {
        Ok(()) => {
            out.print_step(&format!("Deleted binding `{id}`"));
            let _ = clear_local_secret(id);
            Ok(())
        }
        Err(e) if e.code.code == "OL-4234" => {
            out.print_step(&format!("Binding `{id}` already absent — no-op"));
            Ok(())
        }
        Err(e) => Err(e),
    }
}

// ---------------------------------------------------------------------------
// Local secret store helpers
// ---------------------------------------------------------------------------

fn persist_secret_locally(binding_id: &str, secret: &str) -> Result<(), OlError> {
    let secret = SecretString::from(secret.to_string());
    let keyring = KeyringBindingSecretStore::new();
    if let Ok(()) = keyring.store(binding_id, secret.clone()) {
        return Ok(());
    }
    let dir = default_file_dir(&config::provider_dir());
    let machine_id = config::machine_id_or_init().unwrap_or_else(|_| "unknown".into());
    let file = FileBindingSecretStore::new(dir, machine_id);
    file.store(binding_id, secret)
}

fn clear_local_secret(binding_id: &str) -> Result<(), OlError> {
    let _ = KeyringBindingSecretStore::new().delete(binding_id);
    let dir = default_file_dir(&config::provider_dir());
    let machine_id = config::machine_id_or_init().unwrap_or_else(|_| "unknown".into());
    FileBindingSecretStore::new(dir, machine_id).delete(binding_id)
}