cln-commando-cli 0.1.0

A tiny CLI for calling Core Lightning RPC methods over Commando/LNSocket
use std::fs;
use std::io::{self, Read};
use std::str::FromStr;
use std::time::Duration;

use anyhow::{anyhow, bail, Context, Result};
use clap::Parser;
use lnsocket::bitcoin::secp256k1::{rand, PublicKey, SecretKey};
use lnsocket::commando::CallOpts;
use lnsocket::{CommandoClient, LNSocket};
use serde_json::Value;

#[derive(Debug, Parser)]
#[command(version, about, long_about = None)]
struct Args {
    /// Connection string in the form NODE_ID@HOST[:PORT].
    #[arg(
        short,
        long,
        env = "CLN_COMMANDO_CONNECT",
        value_name = "NODE_ID@HOST[:PORT]"
    )]
    connect: String,

    /// Base64 rune for the remote CLN node.
    #[arg(short, long, env = "CLN_COMMANDO_RUNE")]
    rune: String,

    /// Hex secret key for this client identity. If omitted, a fresh random key is used.
    /// Use this if your rune has an id= restriction on the caller node id.
    #[arg(long, env = "CLN_COMMANDO_SECRET_KEY", value_name = "HEX")]
    secret_key: Option<String>,

    /// Call timeout in seconds.
    #[arg(short = 't', long, default_value_t = 30)]
    timeout: u64,

    /// Retry count for the command after reconnects.
    #[arg(long, default_value_t = 3)]
    retries: usize,

    /// Pretty-print JSON output.
    #[arg(short, long)]
    pretty: bool,

    /// Do not print a trailing newline.
    #[arg(short = 'n', long)]
    no_newline: bool,

    /// RPC method name, e.g. getinfo, listfunds, listbundles.
    method: String,

    /// JSON params. Omit for {}, use '-' for stdin, or '@file.json' for a file.
    #[arg(value_name = "JSON|@FILE|-")]
    params: Option<String>,
}

#[tokio::main]
async fn main() -> Result<()> {
    let args = Args::parse();

    let (their_pubkey, addr) = parse_connect(&args.connect)?;
    let our_key = parse_or_generate_secret(args.secret_key.as_deref())?;
    let params = read_params(args.params.as_deref())?;

    let sock = LNSocket::connect_and_init(our_key, their_pubkey, &addr)
        .await
        .map_err(|e| anyhow!("connecting to {addr}: {e}"))?;

    let client = CommandoClient::spawn(sock, args.rune);
    let opts = CallOpts::new()
        .timeout(Duration::from_secs(args.timeout))
        .retry(args.retries);

    let result = client
        .call_with_opts(args.method, params, opts)
        .await
        .map_err(|e| anyhow!("commando RPC failed: {e}"))?;

    if args.pretty {
        print_json(serde_json::to_string_pretty(&result)?, args.no_newline);
    } else {
        print_json(serde_json::to_string(&result)?, args.no_newline);
    }

    Ok(())
}

fn print_json(s: String, no_newline: bool) {
    if no_newline {
        print!("{s}");
    } else {
        println!("{s}");
    }
}

fn parse_connect(connect: &str) -> Result<(PublicKey, String)> {
    let (pk, host) = connect
        .split_once('@')
        .ok_or_else(|| anyhow!("--connect must be NODE_ID@HOST[:PORT]"))?;

    let pubkey = PublicKey::from_str(pk).context("invalid node id in --connect")?;

    if host.is_empty() {
        bail!("host is empty in --connect");
    }

    let addr = if host.rsplit_once(':').is_some() {
        host.to_owned()
    } else {
        format!("{host}:9735")
    };

    Ok((pubkey, addr))
}

fn parse_or_generate_secret(secret: Option<&str>) -> Result<SecretKey> {
    match secret {
        Some(s) => SecretKey::from_str(s).context("invalid --secret-key hex"),
        None => Ok(SecretKey::new(&mut rand::thread_rng())),
    }
}

fn read_params(arg: Option<&str>) -> Result<Value> {
    let Some(arg) = arg else {
        return Ok(serde_json::json!({}));
    };

    let text = match arg {
        "-" => {
            let mut s = String::new();
            io::stdin()
                .read_to_string(&mut s)
                .context("reading params from stdin")?;
            s
        }
        s if s.starts_with('@') => {
            let path = s.strip_prefix('@').unwrap();
            fs::read_to_string(path).with_context(|| format!("reading params from {path}"))?
        }
        s => s.to_owned(),
    };

    if text.trim().is_empty() {
        Ok(serde_json::json!({}))
    } else {
        serde_json::from_str(&text).context("params are not valid JSON")
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    const PK: &str = "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798";

    #[test]
    fn default_port() {
        let (_, addr) = parse_connect(&format!("{PK}@example.com")).unwrap();
        assert_eq!(addr, "example.com:9735");
    }

    #[test]
    fn explicit_port() {
        let (_, addr) = parse_connect(&format!("{PK}@example.com:19735")).unwrap();
        assert_eq!(addr, "example.com:19735");
    }

    #[test]
    fn no_params_is_object() {
        assert_eq!(read_params(None).unwrap(), serde_json::json!({}));
    }

    #[test]
    fn object_params() {
        assert_eq!(
            read_params(Some(r#"{"id":"x"}"#)).unwrap(),
            serde_json::json!({"id": "x"})
        );
    }

    #[test]
    fn array_params() {
        assert_eq!(
            read_params(Some(r#"[1,"x"]"#)).unwrap(),
            serde_json::json!([1, "x"])
        );
    }
}