From b4f643ef26d9568044f343ca3537222726768f73 Mon Sep 17 00:00:00 2001 From: Bryan Newbold Date: Wed, 26 Oct 2022 23:28:35 -0700 Subject: cli: initial progress --- Cargo.lock | 66 ++++++++++++++++ adenosine-cli/Cargo.toml | 3 + adenosine-cli/README.md | 2 +- adenosine-cli/plan.txt | 6 +- adenosine-cli/src/lib.rs | 85 ++++++++++++++++++++ adenosine-cli/src/main.rs | 193 +++++++++++++++++++++++++++++++++++++++++++++- extra/adenosine.1 | 57 ++++++++++++++ extra/adenosine.1.scdoc | 40 ++++++++++ 8 files changed, 448 insertions(+), 4 deletions(-) create mode 100644 adenosine-cli/src/lib.rs create mode 100644 extra/adenosine.1 create mode 100644 extra/adenosine.1.scdoc diff --git a/Cargo.lock b/Cargo.lock index 713165c..40bc638 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9,9 +9,21 @@ dependencies = [ "anyhow", "atty", "colored_json", + "env_logger", + "log", "reqwest", "serde_json", "structopt", + "termcolor", +] + +[[package]] +name = "aho-corasick" +version = "0.7.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4f55bd91a0978cbfd91c457a164bab8b4001c833b7f323132c0a4e1922dd44e" +dependencies = [ + "memchr", ] [[package]] @@ -134,6 +146,19 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "env_logger" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c90bf5f19754d10198ccb95b70664fc925bd1fc090a0fd9a6ebc54acc8cd6272" +dependencies = [ + "atty", + "humantime", + "log", + "regex", + "termcolor", +] + [[package]] name = "fastrand" version = "1.8.0" @@ -298,6 +323,12 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c4a1e36c821dbe04574f602848a19f742f4fb3c98d40449f11bcad18d6b17421" +[[package]] +name = "humantime" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a3a5bfb195931eeb336b2a7b4d761daec841b97f947d34394601737a7bba5e4" + [[package]] name = "hyper" version = "0.14.20" @@ -584,6 +615,23 @@ dependencies = [ "bitflags", ] +[[package]] +name = "regex" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c4eb3267174b8c6c2f654116623910a0fef09c4753f8dd83db29c48a0df988b" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.6.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3f87b73ce11b1619a3c6332f45341e0047173771e8b8b73f87bfeefb7b56244" + [[package]] name = "remove_dir_all" version = "0.5.3" @@ -772,6 +820,15 @@ dependencies = [ "winapi", ] +[[package]] +name = "termcolor" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bab24d30b911b2376f3a13cc2cd443142f0c81dda04c118693e35b3835757755" +dependencies = [ + "winapi-util", +] + [[package]] name = "textwrap" version = "0.11.0" @@ -1039,6 +1096,15 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" +[[package]] +name = "winapi-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70ec6ce85bb158151cae5e5c87f95a8e97d2c0c4b001223f33a334e3ce5de178" +dependencies = [ + "winapi", +] + [[package]] name = "winapi-x86_64-pc-windows-gnu" version = "0.4.0" diff --git a/adenosine-cli/Cargo.toml b/adenosine-cli/Cargo.toml index 8d42961..8220e8f 100644 --- a/adenosine-cli/Cargo.toml +++ b/adenosine-cli/Cargo.toml @@ -15,11 +15,14 @@ structopt = "*" # NOTE: could try 'rustls-tls' feature instead of default native TLS? reqwest = { version = "0.11", features = ["blocking", "json"] } serde_json = "*" +log = "*" +env_logger = "*" # uncertain about these... anyhow = "1.0" atty = "0.2" colored_json = "*" +termcolor = "*" [package.metadata.deb] maintainer = "Bryan Newbold " diff --git a/adenosine-cli/README.md b/adenosine-cli/README.md index a5a692c..30fb999 100644 --- a/adenosine-cli/README.md +++ b/adenosine-cli/README.md @@ -2,4 +2,4 @@ `adenosine-cli`: atproto command-line client ============================================ - +Like the `http` command (HTTPie), but for the AT Protocol (). diff --git a/adenosine-cli/plan.txt b/adenosine-cli/plan.txt index fc7b6dc..4eb594c 100644 --- a/adenosine-cli/plan.txt +++ b/adenosine-cli/plan.txt @@ -6,7 +6,7 @@ just talks to the remote. config variables: - ATP_SERVICE: prefix, eg http://localhost:1234 + ATP_HOST: prefix, eg http://localhost:1234 ATP_AUTH_TOKEN: JWT string at-uri can be either a global `at://` URI string, or a user-local referene of @@ -63,3 +63,7 @@ file; '-' to read a JSON file from stdin. profile ? search-users +--- + +plan: +- first version which uses entirely schema-less / generic XRPC client diff --git a/adenosine-cli/src/lib.rs b/adenosine-cli/src/lib.rs new file mode 100644 index 0000000..704c1d9 --- /dev/null +++ b/adenosine-cli/src/lib.rs @@ -0,0 +1,85 @@ +use anyhow::anyhow; +pub use anyhow::Result; +use reqwest::header; +use serde_json::Value; +use std::collections::HashMap; +use std::str::FromStr; +use std::time::Duration; + +static APP_USER_AGENT: &str = concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION"),); + +#[derive(Debug, PartialEq, Eq, Clone)] +pub enum XrpcMethod { + Get, + Post, +} + +impl FromStr for XrpcMethod { + type Err = anyhow::Error; + + fn from_str(s: &str) -> Result { + match s { + "get" => Ok(XrpcMethod::Get), + "post" => Ok(XrpcMethod::Post), + _ => Err(anyhow!("unknown method: {}", s)), + } + } +} + +pub struct XrpcClient { + http_client: reqwest::blocking::Client, + host: String, +} + +impl XrpcClient { + pub fn new(host: String, auth_token: Option) -> Result { + let mut headers = header::HeaderMap::new(); + if let Some(token) = &auth_token { + let mut auth_value = header::HeaderValue::from_str(&format!("Bearer {}", token))?; + auth_value.set_sensitive(true); + headers.insert(header::AUTHORIZATION, auth_value); + }; + + let http_client = reqwest::blocking::Client::builder() + .default_headers(headers) + .user_agent(APP_USER_AGENT) + .timeout(Duration::from_secs(30)) + //.danger_accept_invalid_certs(true) + .build() + .expect("ERROR :: Could not build reqwest client"); + + Ok(XrpcClient { http_client, host }) + } + + pub fn get( + &self, + nsid: String, + params: Option>, + ) -> Result> { + let params: HashMap = params.unwrap_or(HashMap::new()); + let res = self + .http_client + .get(format!("{}/xrpc/{}", self.host, nsid)) + .query(¶ms) + .send()? + .error_for_status()?; + Ok(res.json()?) + } + + pub fn post( + &self, + nsid: String, + params: Option>, + body: Value, + ) -> Result> { + let params: HashMap = params.unwrap_or(HashMap::new()); + let res = self + .http_client + .get(format!("{}/xrpc/{}", self.host, nsid)) + .query(¶ms) + .json(&body) + .send()? + .error_for_status()?; + Ok(res.json()?) + } +} diff --git a/adenosine-cli/src/main.rs b/adenosine-cli/src/main.rs index e7a11a9..0246fd6 100644 --- a/adenosine-cli/src/main.rs +++ b/adenosine-cli/src/main.rs @@ -1,3 +1,192 @@ -fn main() { - println!("Hello, world!"); +use adenosine_cli::*; +use anyhow::anyhow; +use serde_json::Value; +use std::collections::HashMap; + +use colored_json::to_colored_json_auto; +use log::{self, debug, info}; +use std::io::Write; +use std::path::PathBuf; +use structopt::StructOpt; +use termcolor::{Color, ColorChoice, ColorSpec, StandardStream, WriteColor}; + +#[derive(StructOpt)] +#[structopt(rename_all = "kebab-case", about = "CLI interface for AT Protocol")] +struct Opt { + #[structopt( + global = true, + long = "--host", + env = "ATP_HOST", + default_value = "https://localhost:8080" + )] + atp_host: String, + + // API auth tokens can be generated from the account page in the fatcat.wiki web interface + #[structopt( + global = true, + long = "--auth-token", + env = "ATP_AUTH_TOKEN", + hide_env_values = true + )] + auth_token: Option, + + /// Log more messages. Pass multiple times for ever more verbosity + /// + /// By default, it'll only report errors. Passing `-v` one time also prints + /// warnings, `-vv` enables info logging, `-vvv` debug, and `-vvvv` trace. + #[structopt(global = true, long, short = "v", parse(from_occurrences))] + verbose: i8, + + #[structopt(long = "--shell-completions", hidden = true)] + shell_completions: Option, + + #[structopt(subcommand)] + cmd: Command, +} + +#[derive(StructOpt)] +enum AccountCommand { + /// Register a new account + Register { + #[structopt(long, short)] + email: String, + + #[structopt(long, short)] + username: String, + + #[structopt(long, short)] + password: String, + }, + Delete, + Login, + Logout, + Info, + CreateRevocationKey, +} + +#[derive(StructOpt)] +enum Command { + Get { + uri: String, + }, + + Xrpc { + method: XrpcMethod, + nsid: String, + params: Option, + }, + + /// Sub-commands for managing account + Account { + #[structopt(subcommand)] + cmd: AccountCommand, + }, + + /// Summarize connection and authentication with API + Status, +} + +fn main() -> Result<()> { + let opt = Opt::from_args(); + + let log_level = match opt.verbose { + std::i8::MIN..=-1 => "none", + 0 => "error", + 1 => "warn", + 2 => "info", + 3 => "debug", + 4..=std::i8::MAX => "trace", + }; + // hyper logging is very verbose, so crank that down even if everything else is more verbose + let log_filter = format!("{},hyper=error", log_level); + env_logger::Builder::from_env(env_logger::Env::default().default_filter_or(log_filter)) + .format_timestamp(None) + .init(); + + debug!("Args parsed, starting up"); + + #[cfg(windows)] + colored_json::enable_ansi_support(); + + if let Some(shell) = opt.shell_completions { + Opt::clap().gen_completions_to("adenosine", shell, &mut std::io::stdout()); + std::process::exit(0); + } + + if let Err(err) = run(opt) { + // Be graceful about some errors + if let Some(io_err) = err.root_cause().downcast_ref::() { + if let std::io::ErrorKind::BrokenPipe = io_err.kind() { + // presumably due to something like writing to stdout and piped to `head -n10` and + // stdout was closed + debug!("got BrokenPipe error, assuming stdout closed as expected and exiting with success"); + std::process::exit(0); + } + } + let mut color_stderr = StandardStream::stderr(if atty::is(atty::Stream::Stderr) { + ColorChoice::Auto + } else { + ColorChoice::Never + }); + color_stderr.set_color(ColorSpec::new().set_fg(Some(Color::Red)).set_bold(true))?; + eprintln!("Error: {:?}", err); + color_stderr.set_color(&ColorSpec::new())?; + std::process::exit(1); + } + Ok(()) +} + +fn run(opt: Opt) -> Result<()> { + let xrpc_client = XrpcClient::new(opt.atp_host, opt.auth_token)?; + + match opt.cmd { + Command::Xrpc { + method, + nsid, + params, + } => { + let body: Value = ().into(); + let res = match method { + // XXX: parse params + XrpcMethod::Get => xrpc_client.get(nsid, None)?, + XrpcMethod::Post => xrpc_client.post(nsid, None, body)?, + }; + if let Some(val) = res { + writeln!(&mut std::io::stdout(), "{}", to_colored_json_auto(&val)?)? + }; + } + Command::Get { uri } => { + println!("GET: {}", uri); + /* + let result = specifier.get_from_api(&mut api_client, expand, hide)?; + if toml { + writeln!(&mut std::io::stdout(), "{}", result.to_toml_string()?)? + } else { + // "if json" + writeln!( + &mut std::io::stdout(), + "{}", + to_colored_json_auto(&result.to_json_value()?)? + )? + } + */ + } + Command::Account { + cmd: + AccountCommand::Register { + email, + username, + password, + }, + } => { + println!( + "REGISTER: email={} username={} password={}", + email, username, password + ); + } + _ => { + unimplemented!("some command"); + } + } + Ok(()) } diff --git a/extra/adenosine.1 b/extra/adenosine.1 new file mode 100644 index 0000000..7a490d3 --- /dev/null +++ b/extra/adenosine.1 @@ -0,0 +1,57 @@ +.\" Generated by scdoc 1.11.1 +.\" Complete documentation for this program is not available as a GNU info page +.ie \n(.g .ds Aq \(aq +.el .ds Aq ' +.nh +.ad l +.\" Begin generated content: +.TH "adenosine" "1" "2022-10-27" "adenosine Client Tool Manual Page" +.P +.SH NAME +.P +adenosine - command-line client for AT protocol (atproto.\&com) +.P +.SH SYNOPSIS +.P +adenosine [OPTIONS] +.P +.SH DESCRIPTION +.P +TODO +.P +.SH COMMANDS +.P +.SS Other Commands +.P +.RS 4 +\fBstatus\fR [--json] +.RS 4 +Summarizes connection and authentication to the API server.\& Useful for debugging +.P +.RE +.RE +.SH OPTIONS +.P +\fB-h, --help\fR +.RS 4 +Prints help information +.P +.RE +\fB-V, --version\fR +.RS 4 +Prints version information +.P +.RE +\fB-v, --verbose\fR +.RS 4 +Pass many times for more log output +By default, it'll only report errors.\& Passing `-v` one time also prints warnings, `-vv` enables info logging, `-vvv` debug, and `-vvvv` trace.\& +.P +.RE +\fB--host \fR [env: ATP_HOST] +.P +\fB--auth-token \fR [env: ATP_AUTH_TOKEN] +.P +.SH EXAMPLES +.P +TODO diff --git a/extra/adenosine.1.scdoc b/extra/adenosine.1.scdoc new file mode 100644 index 0000000..2934e19 --- /dev/null +++ b/extra/adenosine.1.scdoc @@ -0,0 +1,40 @@ +adenosine(1) "adenosine Client Tool Manual Page" + +# NAME + +adenosine - command-line client for AT protocol (atproto.com) + +# SYNOPSIS + +adenosine [OPTIONS] + +# DESCRIPTION + +TODO + +# COMMANDS + +## Other Commands + + *status* [--json] + Summarizes connection and authentication to the API server. Useful for debugging + +# OPTIONS + +*-h, --help* + Prints help information + +*-V, --version* + Prints version information + +*-v, --verbose* + Pass many times for more log output + By default, it'll only report errors. Passing `-v` one time also prints warnings, `-vv` enables info logging, `-vvv` debug, and `-vvvv` trace. + +*--host * [env: ATP_HOST] + +*--auth-token * [env: ATP_AUTH_TOKEN] + +# EXAMPLES + +TODO -- cgit v1.2.3