diff options
-rw-r--r-- | Cargo.lock | 66 | ||||
-rw-r--r-- | adenosine-cli/Cargo.toml | 3 | ||||
-rw-r--r-- | adenosine-cli/README.md | 2 | ||||
-rw-r--r-- | adenosine-cli/plan.txt | 6 | ||||
-rw-r--r-- | adenosine-cli/src/lib.rs | 85 | ||||
-rw-r--r-- | adenosine-cli/src/main.rs | 193 | ||||
-rw-r--r-- | extra/adenosine.1 | 57 | ||||
-rw-r--r-- | extra/adenosine.1.scdoc | 40 |
8 files changed, 448 insertions, 4 deletions
@@ -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]] @@ -135,6 +147,19 @@ dependencies = [ ] [[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" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -299,6 +324,12 @@ 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" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -585,6 +616,23 @@ dependencies = [ ] [[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" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -773,6 +821,15 @@ dependencies = [ ] [[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" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1040,6 +1097,15 @@ 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" source = "registry+https://github.com/rust-lang/crates.io-index" 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 <bnewbold@robocracy.org>" 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 (<https://atproto.com>). 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 <name>? search-users <query> +--- + +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<Self, Self::Err> { + 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<String>) -> Result<Self> { + 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<HashMap<String, String>>, + ) -> Result<Option<Value>> { + let params: HashMap<String, String> = 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<HashMap<String, String>>, + body: Value, + ) -> Result<Option<Value>> { + let params: HashMap<String, String> = 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<String>, + + /// 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::clap::Shell>, + + #[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<String>, + }, + + /// 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::<std::io::Error>() { + 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] <COMMAND> <ARGS> +.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 <atp-host>\fR [env: ATP_HOST] +.P +\fB--auth-token <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] <COMMAND> <ARGS> + +# 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 <atp-host>* [env: ATP_HOST] + +*--auth-token <auth-token>* [env: ATP_AUTH_TOKEN] + +# EXAMPLES + +TODO |