vta-cli-common 0.2.0

Shared CLI command handlers and rendering helpers for VTA CLIs
Documentation
use ratatui::{
    layout::Constraint,
    style::{Color, Modifier, Style},
    text::{Line, Span, Text},
    widgets::{Block, Cell, Row, Table},
};
use vta_sdk::client::{CreateKeyRequest, VtaClient};
use vta_sdk::did_secrets::{DidSecretsBundle, SecretEntry};
use vta_sdk::keys::KeyType;

use crate::render::print_widget;

pub async fn cmd_key_create(
    client: &VtaClient,
    key_type: &str,
    derivation_path: Option<String>,
    mnemonic: Option<String>,
    label: Option<String>,
    context_id: Option<String>,
) -> Result<(), Box<dyn std::error::Error>> {
    let key_type = match key_type {
        "ed25519" => KeyType::Ed25519,
        "x25519" => KeyType::X25519,
        other => {
            return Err(format!("unknown key type '{other}', expected ed25519 or x25519").into());
        }
    };
    let req = CreateKeyRequest {
        key_type,
        derivation_path,
        key_id: None,
        mnemonic,
        label,
        context_id,
    };
    let resp = client.create_key(req).await?;
    println!("Key created:");
    println!("  Key ID:          {}", resp.key_id);
    println!("  Key Type:        {}", resp.key_type);
    println!("  Derivation Path: {}", resp.derivation_path);
    println!("  Public Key:      {}", resp.public_key);
    println!("  Status:          {}", resp.status);
    if let Some(label) = &resp.label {
        println!("  Label:           {label}");
    }
    println!("  Created At:      {}", resp.created_at);
    Ok(())
}

pub async fn cmd_key_get(
    client: &VtaClient,
    key_id: &str,
    secret: bool,
) -> Result<(), Box<dyn std::error::Error>> {
    if secret {
        let resp = client.get_key_secret(key_id).await?;
        println!("Key ID:               {}", resp.key_id);
        println!("Key Type:             {}", resp.key_type);
        println!("Public Key Multibase: {}", resp.public_key_multibase);
        println!("Secret Key Multibase: {}", resp.private_key_multibase);
    } else {
        let resp = client.get_key(key_id).await?;
        println!("Key ID:          {}", resp.key_id);
        println!("Key Type:        {}", resp.key_type);
        println!("Derivation Path: {}", resp.derivation_path);
        println!("Public Key:      {}", resp.public_key);
        println!("Status:          {}", resp.status);
        if let Some(label) = &resp.label {
            println!("Label:           {label}");
        }
        println!("Created At:      {}", resp.created_at);
        println!("Updated At:      {}", resp.updated_at);
    }
    Ok(())
}

pub async fn cmd_key_revoke(
    client: &VtaClient,
    key_id: &str,
) -> Result<(), Box<dyn std::error::Error>> {
    let resp = client.invalidate_key(key_id).await?;
    println!("Key revoked:");
    println!("  Key ID:     {}", resp.key_id);
    println!("  Status:     {}", resp.status);
    println!("  Updated At: {}", resp.updated_at);
    Ok(())
}

pub async fn cmd_key_rename(
    client: &VtaClient,
    key_id: &str,
    new_key_id: &str,
) -> Result<(), Box<dyn std::error::Error>> {
    let resp = client.rename_key(key_id, new_key_id).await?;
    println!("Key renamed:");
    println!("  Key ID:     {}", resp.key_id);
    println!("  Updated At: {}", resp.updated_at);
    Ok(())
}

pub async fn cmd_key_list(
    client: &VtaClient,
    offset: u64,
    limit: u64,
    status: Option<String>,
    context_id: Option<String>,
) -> Result<(), Box<dyn std::error::Error>> {
    let resp = client
        .list_keys(offset, limit, status.as_deref(), context_id.as_deref())
        .await?;

    if resp.keys.is_empty() {
        println!("No keys found.");
        return Ok(());
    }

    let end = (offset + resp.keys.len() as u64).min(resp.total);

    let dim = Style::default().fg(Color::DarkGray);
    let bold = Style::default()
        .fg(Color::White)
        .add_modifier(Modifier::BOLD);

    let rows: Vec<Row> = resp
        .keys
        .iter()
        .map(|key| {
            let label = key.label.clone().unwrap_or_else(|| "\u{2014}".into());
            let created = key.created_at.format("%Y-%m-%d").to_string();

            let status_span = match key.status {
                vta_sdk::keys::KeyStatus::Active => {
                    Span::styled(key.status.to_string(), Style::default().fg(Color::Green))
                }
                vta_sdk::keys::KeyStatus::Revoked => {
                    Span::styled(key.status.to_string(), Style::default().fg(Color::Red))
                }
            };

            let id_line = Line::from(vec![
                Span::styled("\u{25b8} ", Style::default().fg(Color::Cyan)),
                Span::styled(key.key_id.clone(), bold),
            ]);

            let detail_line = Line::from(vec![
                Span::raw("  "),
                Span::styled(label, Style::default().fg(Color::Yellow)),
                Span::styled("  \u{2502}  ", dim),
                Span::raw(key.key_type.to_string()),
                Span::styled("  \u{2502}  ", dim),
                status_span,
                Span::styled("  \u{2502}  ", dim),
                Span::styled(key.derivation_path.clone(), dim),
                Span::styled("  \u{2502}  ", dim),
                Span::styled(created, dim),
            ]);

            Row::new(vec![Cell::from(Text::from(vec![id_line, detail_line]))])
                .height(2)
                .bottom_margin(1)
        })
        .collect();

    let title = format!(" Keys ({}\u{2013}{} of {}) ", offset + 1, end, resp.total);

    let table = Table::new(rows, [Constraint::Min(1)])
        .block(Block::bordered().title(title).border_style(dim));

    let height = (resp.keys.len() as u16 * 3).saturating_sub(1) + 2;
    print_widget(table, height);

    Ok(())
}

pub async fn cmd_seeds_list(client: &VtaClient) -> Result<(), Box<dyn std::error::Error>> {
    let resp = client.list_seeds().await?;

    if resp.seeds.is_empty() {
        println!("No seed records found.");
        println!("  (pre-rotation state: using external seed store as generation 0)");
        println!("  Active seed ID: {}", resp.active_seed_id);
        return Ok(());
    }

    println!("{} seed generation(s):\n", resp.seeds.len());
    for seed in &resp.seeds {
        println!("  Seed ID:     {}", seed.id);
        println!("  Status:      {}", seed.status);
        println!(
            "  Created:     {}",
            seed.created_at.format("%Y-%m-%d %H:%M:%S UTC")
        );
        if let Some(retired_at) = seed.retired_at {
            println!(
                "  Retired:     {}",
                retired_at.format("%Y-%m-%d %H:%M:%S UTC")
            );
        }
        println!();
    }
    println!("Active seed ID: {}", resp.active_seed_id);

    Ok(())
}

pub async fn cmd_seeds_rotate(
    client: &VtaClient,
    mnemonic: Option<String>,
) -> Result<(), Box<dyn std::error::Error>> {
    let resp = client.rotate_seed(mnemonic).await?;

    println!("Seed rotated successfully.");
    println!("  Previous seed ID: {} (retired)", resp.previous_seed_id);
    println!("  New active seed ID: {}", resp.new_seed_id);

    Ok(())
}

pub async fn cmd_key_bundle(
    client: &VtaClient,
    context: &str,
) -> Result<(), Box<dyn std::error::Error>> {
    // 1. Get the context to find the DID
    let ctx = client.get_context(context).await?;
    let did = ctx
        .did
        .ok_or(format!("context '{context}' has no DID assigned"))?;

    // 2. List all active keys in the context
    let resp = client
        .list_keys(0, 10000, Some("active"), Some(context))
        .await?;
    if resp.keys.is_empty() {
        return Err("no active keys found in this context".into());
    }

    // 3. Get secrets for each key and build SecretEntry vec
    let mut secrets = Vec::new();
    for key in &resp.keys {
        let secret = client.get_key_secret(&key.key_id).await?;
        secrets.push(SecretEntry {
            key_id: secret.key_id,
            key_type: secret.key_type,
            private_key_multibase: secret.private_key_multibase,
        });
    }

    // 4. Build and encode the bundle
    let bundle = DidSecretsBundle { did, secrets };
    let encoded = bundle.encode().map_err(|e| format!("{e}"))?;

    // 5. Print with security warning
    eprintln!();
    eprintln!("\x1b[1;33m╔══════════════════════════════════════════════════════════╗");
    eprintln!("║  WARNING: The secrets bundle contains private keys.      ║");
    eprintln!("║  Store it securely and do not share it publicly.         ║");
    eprintln!("╚══════════════════════════════════════════════════════════╝\x1b[0m");
    eprintln!();
    println!("{encoded}");
    eprintln!();

    Ok(())
}

pub async fn cmd_key_secrets(
    client: &VtaClient,
    key_ids: Vec<String>,
    context: Option<String>,
) -> Result<(), Box<dyn std::error::Error>> {
    let key_ids = if key_ids.is_empty() {
        let ctx = context.as_deref().ok_or(
            "provide key IDs as arguments, or use --context to export all active keys in a context",
        )?;
        let resp = client
            .list_keys(0, 10000, Some("active"), Some(ctx))
            .await?;
        resp.keys.into_iter().map(|k| k.key_id).collect()
    } else {
        key_ids
    };
    if key_ids.is_empty() {
        println!("No active keys found.");
        return Ok(());
    }
    for (i, key_id) in key_ids.iter().enumerate() {
        if i > 0 {
            println!();
        }
        let resp = client.get_key_secret(key_id).await?;
        println!("Key ID:               {}", resp.key_id);
        println!("Key Type:             {}", resp.key_type);
        println!("Public Key Multibase: {}", resp.public_key_multibase);
        println!("Secret Key Multibase: {}", resp.private_key_multibase);
    }
    Ok(())
}