aboutsummaryrefslogtreecommitdiffstats
path: root/adenosine-cli
diff options
context:
space:
mode:
authorBryan Newbold <bnewbold@robocracy.org>2022-10-26 23:28:35 -0700
committerBryan Newbold <bnewbold@robocracy.org>2022-10-26 23:28:57 -0700
commitb4f643ef26d9568044f343ca3537222726768f73 (patch)
tree8965a2a75f0ac153cb33d9eea6fcea7ee1360a76 /adenosine-cli
parentfea10888148094b64c7ca96ec670b68b55beaef5 (diff)
downloadadenosine-b4f643ef26d9568044f343ca3537222726768f73.tar.gz
adenosine-b4f643ef26d9568044f343ca3537222726768f73.zip
cli: initial progress
Diffstat (limited to 'adenosine-cli')
-rw-r--r--adenosine-cli/Cargo.toml3
-rw-r--r--adenosine-cli/README.md2
-rw-r--r--adenosine-cli/plan.txt6
-rw-r--r--adenosine-cli/src/lib.rs85
-rw-r--r--adenosine-cli/src/main.rs193
5 files changed, 285 insertions, 4 deletions
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(&params)
+ .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(&params)
+ .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(())
}