lobe-cli 0.1.4

Lobe — local-first HTTP performance profiling CLI for developers. Spins up a capture proxy, records DNS/TCP/TLS/TTFB/download phases per request, and flags anomalies against grounded network baselines.
//! `lobe login` — prompt the user for a token they minted from the web,
//! verify it against the Convex API, and persist it to
//! `~/.config/lobe/config.toml`.

use std::io::{self, BufRead, Write};

use lobe_core::error::{Result, TloxError};
use serde::Deserialize;

use crate::config::{self, DEFAULT_API_URL, DEFAULT_WEB_URL};

#[derive(Debug, Deserialize)]
struct WhoAmIResponse {
    user: WhoAmIUser,
}

#[derive(Debug, Deserialize)]
struct WhoAmIUser {
    email: String,
    #[serde(default)]
    #[allow(dead_code)]
    name: Option<String>,
    tier: String,
}

/// Handle `lobe login`.
///
/// - If `token` is provided, use it directly (non-interactive mode).
/// - Otherwise, print instructions and read a token from stdin.
///
/// Verifies against `<api_url>/cli/whoami`. Saves on success.
pub async fn run_login(
    token: Option<String>,
    web_url: Option<String>,
    api_url: Option<String>,
) -> Result<()> {
    // Load existing config so we don't wipe URL overrides on login.
    let mut config = config::load()?;
    if let Some(url) = web_url {
        config.urls.web = url;
    }
    if let Some(url) = api_url {
        config.urls.api = url;
    }

    let token = match token {
        Some(t) => t,
        None => prompt_for_token(&config.urls.web)?,
    };

    let token = token.trim().to_string();
    if !token.starts_with("lobe_") {
        return Err(TloxError::Auth(
            "invalid token — expected a value starting with `lobe_`".to_string(),
        ));
    }

    // Verify the token round-trips before we save it.
    let user = verify_token(&config.urls.api, &token).await?;

    config.auth.token = Some(token);
    config.auth.email = Some(user.email.clone());
    config::save(&config)?;

    println!("✓  Hippocampus authorized · {} ({} tier)", user.email, user.tier);
    Ok(())
}

fn prompt_for_token(web_url: &str) -> Result<String> {
    let default_url_note = if web_url == DEFAULT_WEB_URL {
        String::new()
    } else {
        format!(" (default: {DEFAULT_WEB_URL})")
    };
    println!();
    println!("Visit this URL in your browser to mint a token:");
    println!();
    println!("    {web_url}/cli-auth{default_url_note}");
    println!();
    print!("Paste your token here: ");
    io::stdout().flush()?;

    let mut line = String::new();
    io::stdin().lock().read_line(&mut line)?;
    Ok(line)
}

async fn verify_token(api_url: &str, token: &str) -> Result<WhoAmIUser> {
    let default_url_note = if api_url == DEFAULT_API_URL {
        String::new()
    } else {
        format!(" (using {api_url})")
    };

    let url = format!("{api_url}/cli/whoami");
    let client = reqwest::Client::new();
    let response = client
        .get(&url)
        .header("Authorization", format!("Bearer {token}"))
        .send()
        .await
        .map_err(|e| TloxError::Network(format!("failed to reach Lobe cloud{default_url_note}: {e}")))?;

    let status = response.status();
    if status == reqwest::StatusCode::UNAUTHORIZED {
        return Err(TloxError::Auth(
            "token was rejected — mint a fresh one at /cli-auth".to_string(),
        ));
    }
    if !status.is_success() {
        return Err(TloxError::Network(format!(
            "unexpected response from /cli/whoami: {status}"
        )));
    }

    let payload: WhoAmIResponse = response
        .json()
        .await
        .map_err(|e| TloxError::Network(format!("could not parse /cli/whoami response: {e}")))?;

    Ok(payload.user)
}