tovuk 0.1.104

Deploy Rust workers, static frontends, and full-stack services to Tovuk.
use super::{
    api_commands::api_request,
    args::CliOptions,
    constants::{DEFAULT_LOGIN_EXPIRES_SECONDS, DEFAULT_LOGIN_INTERVAL_SECONDS},
    errors::{Result, agent_error, internal_error, print_json},
    project::{encode_component, number_alias, open_url, progress, string_alias, string_field},
};
use reqwest::Method;
use serde_json::{Value, json};
use std::{
    thread,
    time::{Duration, Instant},
};

mod keychain;
mod token_store;

use token_store::{read_stored_token, write_session_token};

pub(crate) fn read_or_login_token(cli: &CliOptions) -> Result<String> {
    let token = read_stored_token(cli);
    if !token.is_empty() {
        return Ok(token);
    }
    login_and_store(cli).map(|session| session.token)
}

pub(crate) fn login(cli: &CliOptions) -> Result<()> {
    if !cli.token.trim().is_empty() {
        write_session_token(cli.token.trim())?;
        print_login_success(cli, "saved", "")?;
        return Ok(());
    }
    let session = login_and_store(cli)?;
    print_login_success(cli, "logged_in", &session.email)?;
    Ok(())
}

struct StoredSession {
    token: String,
    email: String,
}

fn login_and_store(cli: &CliOptions) -> Result<StoredSession> {
    let start = api_request(cli, Method::POST, "/v1/login/device", None, None)?;
    let login_url = string_alias(&start, &["loginUrl", "login_url"]);
    let user_code = string_alias(&start, &["userCode", "user_code"]);
    let device_code = string_alias(&start, &["deviceCode", "device_code"]);
    if login_url.is_empty() {
        return Err(agent_error(
            "login_failed",
            "Tovuk login did not return a browser URL.",
            "Retry `tovuk login`. If it keeps failing, check Tovuk status.",
            cli.output.json,
        ));
    }
    open_url(&login_url);
    print_login_started(cli, &start, &login_url, &user_code)?;

    let session = poll_login(cli, &device_code, &start)?;
    let token = string_field(&session, "token");
    if token.is_empty() {
        return Err(agent_error(
            "login_failed",
            "Tovuk login did not return a session token.",
            "Run `tovuk login` again and complete the browser login.",
            cli.output.json,
        ));
    }
    let email = string_field(&session, "email");
    write_session_token(&token)?;
    progress(cli, &logged_in_message(&session));
    Ok(StoredSession { token, email })
}

fn login_wait_message(user_code: &str) -> String {
    format!(
        "waiting for browser login code {}",
        if user_code.is_empty() {
            "TOVUK"
        } else {
            user_code
        }
    )
}

fn logged_in_message(session: &Value) -> String {
    let email = string_field(session, "email");
    format!("logged in as {}", user_or_fallback(&email))
}

fn print_login_started(
    cli: &CliOptions,
    start: &Value,
    login_url: &str,
    user_code: &str,
) -> Result<()> {
    if cli.output.json {
        print_json_event(&login_started_payload(start, login_url, user_code))?;
        return Ok(());
    }
    progress(cli, "opened browser login");
    progress(cli, &login_wait_message(user_code));
    Ok(())
}

fn print_login_success(cli: &CliOptions, status: &str, email: &str) -> Result<()> {
    if cli.output.json {
        return print_json(&login_success_payload(status, email));
    }
    if status == "saved" {
        println!("saved Tovuk session token");
        return Ok(());
    }
    Ok(())
}

fn login_started_payload(start: &Value, login_url: &str, user_code: &str) -> Value {
    let verification_uri = string_alias(start, &["verificationUri", "verification_uri"]);
    json!({
        "event": "login_started",
        "ok": true,
        "status": "waiting_for_browser_login",
        "login_url": login_url,
        "verification_uri": optional_string(verification_uri),
        "user_code": optional_string(user_code.to_owned()),
        "expires_in_seconds": number_alias(start, &["expiresInSeconds", "expires_in_seconds"])
            .unwrap_or(DEFAULT_LOGIN_EXPIRES_SECONDS),
        "poll_interval_seconds": number_alias(start, &["intervalSeconds", "interval_seconds"])
            .unwrap_or(DEFAULT_LOGIN_INTERVAL_SECONDS),
        "agent_instruction": "Open login_url, complete Tovuk browser login, then keep waiting for this command to finish. Stdout remains reserved for the final command JSON.",
    })
}

fn login_success_payload(status: &str, email: &str) -> Value {
    json!({
        "ok": true,
        "status": status,
        "email": optional_string(email.to_owned()),
        "agent_instruction": "Tovuk session is saved. Continue with the original command.",
    })
}

fn optional_string(value: String) -> Value {
    if value.is_empty() {
        Value::Null
    } else {
        Value::String(value)
    }
}

fn user_or_fallback(email: &str) -> &str {
    if email.is_empty() {
        "Tovuk user"
    } else {
        email
    }
}

fn print_json_event(value: &Value) -> Result<()> {
    let source = serde_json::to_string(value).map_err(|error| internal_error(error.to_string()))?;
    eprintln!("{source}");
    Ok(())
}

fn poll_login(cli: &CliOptions, device_code: &str, start: &Value) -> Result<Value> {
    if device_code.is_empty() {
        return Err(agent_error(
            "login_failed",
            "Tovuk login did not return a device code.",
            "Retry `tovuk login`. If it keeps failing, check Tovuk status.",
            cli.output.json,
        ));
    }

    let expires_seconds = number_alias(start, &["expiresInSeconds", "expires_in_seconds"])
        .unwrap_or(DEFAULT_LOGIN_EXPIRES_SECONDS);
    let mut interval_seconds = number_alias(start, &["intervalSeconds", "interval_seconds"])
        .unwrap_or(DEFAULT_LOGIN_INTERVAL_SECONDS);
    let deadline = Instant::now() + Duration::from_secs(expires_seconds);
    while Instant::now() < deadline {
        thread::sleep(Duration::from_secs(interval_seconds));
        let response = api_request(
            cli,
            Method::GET,
            &format!("/v1/login/device/{}", encode_component(device_code)),
            None,
            None,
        )?;
        let status = string_field(&response, "status");
        if status == "complete" {
            return Ok(response);
        }
        if status == "expired" {
            return login_expired(cli);
        }
        interval_seconds = number_alias(&response, &["intervalSeconds", "interval_seconds"])
            .unwrap_or(DEFAULT_LOGIN_INTERVAL_SECONDS)
            .max(DEFAULT_LOGIN_INTERVAL_SECONDS);
    }
    login_expired(cli)
}

fn login_expired(cli: &CliOptions) -> Result<Value> {
    Err(agent_error(
        "login_expired",
        "Tovuk login expired before it completed.",
        "Run `tovuk login` again and finish the browser login in the newly opened tab.",
        cli.output.json,
    ))
}

#[cfg(test)]
mod tests {
    use super::{login_started_payload, login_success_payload};
    use crate::cli::constants::{DEFAULT_LOGIN_EXPIRES_SECONDS, DEFAULT_LOGIN_INTERVAL_SECONDS};
    use serde_json::{Value, json};

    #[test]
    fn login_started_payload_is_agent_readable_without_standalone_device_code() {
        let start = json!({
            "loginUrl": "https://tovuk.com/login?device_code=secret",
            "verificationUri": "https://tovuk.com/login",
            "userCode": "TOVUK-123",
            "deviceCode": "secret-device-code",
            "expiresInSeconds": 900,
            "intervalSeconds": 2
        });

        let payload = login_started_payload(
            &start,
            "https://tovuk.com/login?device_code=secret",
            "TOVUK-123",
        );

        assert_eq!(payload["event"], "login_started");
        assert_eq!(payload["status"], "waiting_for_browser_login");
        assert_eq!(
            payload["login_url"],
            "https://tovuk.com/login?device_code=secret"
        );
        assert_eq!(payload["verification_uri"], "https://tovuk.com/login");
        assert_eq!(payload["user_code"], "TOVUK-123");
        assert_eq!(payload["expires_in_seconds"], 900);
        assert_eq!(payload["poll_interval_seconds"], 2);
        assert!(payload.get("agent_instruction").is_some());
        assert!(payload.get("deviceCode").is_none());
        assert!(payload.get("device_code").is_none());
    }

    #[test]
    fn login_started_payload_defaults_missing_optional_fields() {
        let payload = login_started_payload(&json!({}), "https://tovuk.com/login", "");

        assert_eq!(payload["verification_uri"], Value::Null);
        assert_eq!(payload["user_code"], Value::Null);
        assert_eq!(payload["expires_in_seconds"], DEFAULT_LOGIN_EXPIRES_SECONDS);
        assert_eq!(
            payload["poll_interval_seconds"],
            DEFAULT_LOGIN_INTERVAL_SECONDS
        );
    }

    #[test]
    fn login_success_payload_excludes_session_token() {
        let payload = login_success_payload("logged_in", "ada@example.com");

        assert_eq!(payload["ok"], true);
        assert_eq!(payload["status"], "logged_in");
        assert_eq!(payload["email"], "ada@example.com");
        assert!(payload.get("token").is_none());
    }
}