offline-cli 0.3.3

The Offline Protocol developer CLI
//! `offline keys`: list and create application API keys.

use anyhow::{bail, Result};
use clap::{Args, Subcommand};
use serde_json::json;

use crate::commands::provision::{client_from_config, developer_session, resolve_app_id};
use crate::config::CliConfig;
use crate::output;

#[derive(Subcommand)]
pub enum KeysCommand {
    /// List API keys for an application
    List(ListArgs),
    /// Create a new application API key
    Create(CreateArgs),
}

#[derive(Args)]
pub struct ListArgs {
    #[arg(long)]
    pub app_id: Option<String>,
    #[arg(long)]
    pub json: bool,
}

#[derive(Args)]
pub struct CreateArgs {
    pub alias: String,
    #[arg(long, default_value = "all")]
    pub product: String,
    #[arg(long, default_value = "full_access")]
    pub permissions: String,
    #[arg(long)]
    pub app_id: Option<String>,
    #[arg(long)]
    pub json: bool,
    /// Store the new key as the active CLI credential
    #[arg(long)]
    pub set_default: bool,
}

pub async fn run(command: KeysCommand) -> Result<()> {
    match command {
        KeysCommand::List(list) => list_keys(list).await,
        KeysCommand::Create(create) => create_key(create).await,
    }
}

async fn list_keys(args: ListArgs) -> Result<()> {
    let mut config = CliConfig::load()?;
    let token = developer_session(&mut config, "keys").await?;
    let app_id = resolve_app_id(&config, args.app_id.as_deref())?;
    let client = client_from_config(&config).await?;
    let keys = client.list_api_keys(&token, &app_id).await?;

    if args.json {
        output::json(&json!({ "appId": app_id, "keys": keys }))?;
        return Ok(());
    }

    output::heading(&format!("api keys for {app_id}"));
    if keys.is_empty() {
        output::note("no keys — run `offline keys create \"CLI default\"`");
        return Ok(());
    }
    for key in &keys {
        let name = key.name.as_deref().unwrap_or("(unnamed)");
        let product = key.product.as_deref().unwrap_or("?");
        let status = key.status.as_deref().unwrap_or("?");
        println!("  {name}  product={product}  status={status}");
    }
    Ok(())
}

async fn create_key(args: CreateArgs) -> Result<()> {
    let mut config = CliConfig::load()?;
    let token = developer_session(&mut config, "keys").await?;
    let app_id = resolve_app_id(&config, args.app_id.as_deref())?;
    let client = client_from_config(&config).await?;
    let product = match args.product.as_str() {
        "all" | "offline_id" | "mesh_sdk" | "proof_of_location" => args.product.clone(),
        other => bail!(
            "unsupported product `{other}` — use all, offline_id, mesh_sdk, or proof_of_location"
        ),
    };
    let key = client
        .create_api_key(&token, &app_id, &args.alias, &product, &args.permissions)
        .await?;

    let api_key = key
        .key
        .clone()
        .filter(|k| !k.is_empty())
        .ok_or_else(|| anyhow::anyhow!("create API key response missing key value"))?;

    if args.set_default {
        config.app_keys.insert(app_id.clone(), api_key.clone());
        config.api_key = Some(api_key.clone());
        config.app_id = Some(app_id.clone());
        config.save()?;
    }

    if args.json {
        output::json(&json!({
            "appId": app_id,
            "key": key,
            "setDefault": args.set_default,
        }))?;
        return Ok(());
    }

    output::success(&format!(
        "created API key `{}` for application `{app_id}`",
        args.alias
    ));
    output::warn("Copy this key now — it will not be shown again:");
    println!("  {api_key}");
    if !args.set_default {
        output::note("Run with --set-default to store it as the active CLI credential");
    }
    Ok(())
}