From dc24074377875b7f500b20e4f6088930f2eef4ea Mon Sep 17 00:00:00 2001 From: Bryan Newbold Date: Thu, 27 Oct 2022 18:27:16 -0700 Subject: CLI: progress (identifiers, ls, delete) --- adenosine-cli/Cargo.toml | 4 +- adenosine-cli/src/bin/adenosine.rs | 519 +++++++++++++++++++++++++++++++++++++ adenosine-cli/src/identifiers.rs | 140 ++++++++++ adenosine-cli/src/lib.rs | 3 + adenosine-cli/src/main.rs | 444 ------------------------------- 5 files changed, 665 insertions(+), 445 deletions(-) create mode 100644 adenosine-cli/src/bin/adenosine.rs create mode 100644 adenosine-cli/src/identifiers.rs delete mode 100644 adenosine-cli/src/main.rs (limited to 'adenosine-cli') diff --git a/adenosine-cli/Cargo.toml b/adenosine-cli/Cargo.toml index 54a2442..bce0ae0 100644 --- a/adenosine-cli/Cargo.toml +++ b/adenosine-cli/Cargo.toml @@ -3,7 +3,7 @@ name = "adenosine-cli" description = "Simple informal CLI client for AT protocol and bsky.app" keywords = ["atproto"] categories = [] -default-run = "adenosine-cli" +default-run = "adenosine" version.workspace = true edition.workspace = true @@ -19,6 +19,8 @@ structopt = "*" reqwest = { version = "0.11", features = ["blocking", "json"] } serde_json = "*" base64 = "*" +regex = "*" +lazy_static = "*" # uncertain about these... anyhow = "1.0" diff --git a/adenosine-cli/src/bin/adenosine.rs b/adenosine-cli/src/bin/adenosine.rs new file mode 100644 index 0000000..1d23c27 --- /dev/null +++ b/adenosine-cli/src/bin/adenosine.rs @@ -0,0 +1,519 @@ +use adenosine_cli::*; +use anyhow::anyhow; +use serde_json::{json, Value}; +use std::collections::HashMap; + +use colored_json::to_colored_json_auto; +use log::{self, debug}; +use std::io::Write; +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 { + #[structopt(long, short)] + username: String, + + #[structopt(long, short)] + password: String, + }, + Logout, + Info, + // TODO: CreateRevocationKey or CreateDid +} + +#[derive(StructOpt)] +enum RepoCommand { + Root { + did: Option, + }, + Export { + did: Option, + #[structopt(long)] + from: Option, + }, + Import { + // TODO: could accept either path or stdin? + #[structopt(long)] + did: Option, + }, +} + +#[derive(StructOpt)] +enum BskyCommand { + Feed { name: Option }, + Notifications, + Post { text: String }, + Repost { uri: AtUri }, + Like { uri: AtUri }, + // TODO: Repost { uri: String, }, + Follow { uri: DidOrHost }, + // TODO: Unfollow { uri: String, }, + /* TODO: + Follows { + name: String, + }, + Followers { + name: String, + }, + */ + Profile { name: DidOrHost }, + SearchUsers { query: String }, +} + +#[derive(StructOpt)] +enum Command { + Get { + uri: AtUri, + + #[structopt(long)] + cid: Option, + }, + + Ls { + uri: AtUri, + }, + + Create { + collection: String, + fields: String, + }, + Update { + uri: AtUri, + fields: String, + }, + Delete { + uri: AtUri, + }, + + Describe { + name: Option, + }, + + Resolve { + name: DidOrHost, + }, + + Xrpc { + method: XrpcMethod, + nsid: String, + fields: Option, + }, + + /// Sub-commands for managing account + Account { + #[structopt(subcommand)] + cmd: AccountCommand, + }, + + Repo { + #[structopt(subcommand)] + cmd: RepoCommand, + }, + + Bsky { + #[structopt(subcommand)] + cmd: BskyCommand, + }, + + /// 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 print_result_json(result: Option) -> Result<()> { + if let Some(val) = result { + writeln!(&mut std::io::stdout(), "{}", to_colored_json_auto(&val)?)? + }; + Ok(()) +} + +fn run(opt: Opt) -> Result<()> { + let xrpc_client = XrpcClient::new(opt.atp_host.clone(), opt.auth_token.clone())?; + let mut params: HashMap = HashMap::new(); + let jwt_did: Option = if let Some(ref token) = opt.auth_token { + Some(parse_did_from_jwt(token)?) + } else { + None + }; + + let result = match opt.cmd { + Command::Status => { + // XXX + println!("Configuration"); + println!(" ATP_HOST: {}", opt.atp_host); + if opt.auth_token.is_some() { + println!(" ATP_AUTH_TOKEN: "); + } else { + println!(" ATP_AUTH_TOKEN:"); + } + // TODO: parse JWT? + // TODO: connection, auth check + // TODO: account username, did, etc + None + } + Command::Describe { name } => { + let name = name + .map(|v| v.to_string()) + .or(jwt_did) + .ok_or(anyhow!("expected a name or auth token"))?; + params.insert("user".to_string(), name.to_string()); + xrpc_client.get("com.atproto.repoDescribe", Some(params))? + } + Command::Resolve { name } => { + let mut params: HashMap = HashMap::new(); + params.insert("name".to_string(), name.to_string()); + xrpc_client.get("com.atproto.resolveName", Some(params))? + } + Command::Get { uri, cid } => { + params.insert("did".to_string(), uri.repository.to_string()); + params.insert( + "collection".to_string(), + uri.collection.ok_or(anyhow!("collection required"))?, + ); + params.insert( + "rkey".to_string(), + uri.record.ok_or(anyhow!("record key required"))?, + ); + if let Some(c) = cid { + params.insert("cid".to_string(), c); + } + xrpc_client.post("com.atproto.repoGetRecord", Some(params), json!({}))? + } + Command::Ls { uri } => { + // TODO: option to print fully-qualified path? + if !uri.collection.is_some() { + // if a repository, but no collection, list the collections + params.insert("user".to_string(), uri.repository.to_string()); + let describe = xrpc_client + .get("com.atproto.repoDescribe", Some(params))? + .ok_or(anyhow!("expected a repoDescribe response"))?; + for c in describe["collections"] + .as_array() + .ok_or(anyhow!("expected collection list"))? + { + println!( + "{}", + c.as_str() + .ok_or(anyhow!("expected collection as a JSON string"))? + ); + } + } else if uri.collection.is_some() && !uri.record.is_some() { + // if a collection, but no record, list the records (with extracted timestamps) + } else { + return Err(anyhow!("got too much of a URI to 'ls'")); + } + None + } + Command::Create { collection, fields } => { + params.insert("collection".to_string(), collection); + unimplemented!() + } + Command::Update { uri, fields } => { + params.insert("did".to_string(), uri.repository.to_string()); + params.insert( + "collection".to_string(), + uri.collection.ok_or(anyhow!("collection required"))?, + ); + params.insert( + "rkey".to_string(), + uri.record.ok_or(anyhow!("record key required"))?, + ); + unimplemented!() + } + Command::Delete { uri } => { + params.insert("did".to_string(), uri.repository.to_string()); + params.insert( + "collection".to_string(), + uri.collection.ok_or(anyhow!("collection required"))?, + ); + params.insert( + "rkey".to_string(), + uri.record.ok_or(anyhow!("record key required"))?, + ); + xrpc_client.post("com.atproto.repoDeleteRecord", Some(params), json!({}))? + } + Command::Xrpc { + method, + nsid, + fields, + } => { + let body: Value = ().into(); + match method { + // XXX: parse params + XrpcMethod::Get => xrpc_client.get(&nsid, None)?, + XrpcMethod::Post => xrpc_client.post(&nsid, None, body)?, + } + } + Command::Account { + cmd: + AccountCommand::Register { + email, + username, + password, + }, + } => xrpc_client.post( + "com.atproto.createAccount", + None, + json!({ + "email": email, + "username": username, + "password": password, + }), + )?, + Command::Account { + cmd: AccountCommand::Login { username, password }, + } => xrpc_client.post( + "com.atproto.createSession", + None, + json!({ + "username": username, + "password": password, + }), + )?, + Command::Account { + cmd: AccountCommand::Logout, + } => xrpc_client.post("com.atproto.deleteSession", None, json!({}))?, + Command::Account { + cmd: AccountCommand::Delete, + } => xrpc_client.post("com.atproto.deleteAccount", None, json!({}))?, + Command::Account { + cmd: AccountCommand::Info, + } => xrpc_client.get("com.atproto.getAccount", None)?, + Command::Repo { + cmd: RepoCommand::Root { did }, + } => { + let did = match did { + Some(DidOrHost::Host(_)) => return Err(anyhow!("expected a DID, not a hostname")), + Some(v) => v.to_string(), + None => jwt_did.ok_or(anyhow!("expected a DID"))?, + }; + params.insert("did".to_string(), did); + xrpc_client.get("com.atproto.syncGetRoot", Some(params))? + } + Command::Repo { + cmd: RepoCommand::Export { did, from }, + } => { + let did = match did { + Some(DidOrHost::Host(_)) => return Err(anyhow!("expected a DID, not a hostname")), + Some(v) => v.to_string(), + None => jwt_did.ok_or(anyhow!("expected a DID"))?, + }; + params.insert("did".to_string(), did); + if let Some(from) = from { + params.insert("from".to_string(), from); + }; + xrpc_client.get_to_writer( + "com.atproto.syncGetRepo", + Some(params), + &mut std::io::stdout(), + )?; + None + } + Command::Repo { + cmd: RepoCommand::Import { did }, + } => { + let did = match did { + Some(DidOrHost::Host(_)) => return Err(anyhow!("expected a DID, not a hostname")), + Some(v) => v.to_string(), + None => jwt_did.ok_or(anyhow!("expected a DID"))?, + }; + params.insert("did".to_string(), did); + xrpc_client.post_cbor_from_reader( + "com.atproto.syncUpdateRepo", + Some(params), + &mut std::io::stdin(), + )? + } + Command::Bsky { + cmd: BskyCommand::Feed { name }, + } => { + if let Some(name) = name { + params.insert("author".to_string(), name.to_string()); + xrpc_client.get("app.bsky.getAuthorFeed", Some(params))? + } else { + xrpc_client.get("app.bsky.getHomeFeed", None)? + } + } + Command::Bsky { + cmd: BskyCommand::Notifications, + } => xrpc_client.get("app.bsky.getNotifications", None)?, + Command::Bsky { + cmd: BskyCommand::Post { text }, + } => { + params.insert( + "did".to_string(), + jwt_did.ok_or(anyhow!("need auth token"))?, + ); + params.insert("collection".to_string(), "app.bsky.post".to_string()); + xrpc_client.post( + "com.atproto.repoCreateRecord", + Some(params), + json!({ + "text": text, + }), + )? + } + Command::Bsky { + cmd: BskyCommand::Repost { uri }, + } => { + params.insert( + "did".to_string(), + jwt_did.ok_or(anyhow!("need auth token"))?, + ); + params.insert("collection".to_string(), "app.bsky.repost".to_string()); + xrpc_client.post( + "com.atproto.repoCreateRecord", + Some(params), + json!({ + "subject": uri.to_string(), + // TODO: "createdAt": now_timestamp(), + }), + )? + } + Command::Bsky { + cmd: BskyCommand::Like { uri }, + } => { + params.insert( + "did".to_string(), + jwt_did.ok_or(anyhow!("need auth token"))?, + ); + params.insert("collection".to_string(), "app.bsky.like".to_string()); + xrpc_client.post( + "com.atproto.repoCreateRecord", + Some(params), + json!({ + "subject": uri.to_string(), + // TODO: "createdAt": now_timestamp(), + }), + )? + } + Command::Bsky { + cmd: BskyCommand::Follow { uri }, + } => { + params.insert( + "did".to_string(), + jwt_did.ok_or(anyhow!("need auth token"))?, + ); + params.insert("collection".to_string(), "app.bsky.follow".to_string()); + xrpc_client.post( + "com.atproto.repoCreateRecord", + Some(params), + json!({ + "subject": uri.to_string(), + // TODO: "createdAt": now_timestamp(), + }), + )? + } + Command::Bsky { + cmd: BskyCommand::Profile { name }, + } => { + params.insert("name".to_string(), name.to_string()); + xrpc_client.get("app.bsky.getProfile", Some(params))? + } + Command::Bsky { + cmd: BskyCommand::SearchUsers { query }, + } => { + params.insert("term".to_string(), query); + xrpc_client.get("app.bsky.getUsersSearch", Some(params))? + } + }; + print_result_json(result)?; + Ok(()) +} diff --git a/adenosine-cli/src/identifiers.rs b/adenosine-cli/src/identifiers.rs new file mode 100644 index 0000000..7129ba5 --- /dev/null +++ b/adenosine-cli/src/identifiers.rs @@ -0,0 +1,140 @@ +use anyhow::{anyhow, Result}; +use lazy_static::lazy_static; +use regex::Regex; +use std::fmt; +use std::str::FromStr; + +#[derive(Debug, PartialEq, Eq, Clone)] +pub enum DidOrHost { + Did(String, String), + Host(String), +} + +impl FromStr for DidOrHost { + type Err = anyhow::Error; + + /// DID syntax is specified in: + /// + /// Lazy partial hostname regex, isn't very correct. + fn from_str(s: &str) -> Result { + lazy_static! { + static ref DID_RE: Regex = + Regex::new(r"^did:([a-z]{1,64}):([a-zA-Z0-9\-.]{1,1024})$").unwrap(); + } + lazy_static! { + static ref HOSTNAME_RE: Regex = + Regex::new(r"^[A-Za-z][A-Za-z0-9-]*(\.[A-Za-z][A-Za-z0-9-]*)+$").unwrap(); + } + if let Some(caps) = DID_RE.captures(s) { + Ok(Self::Did(caps[1].to_string(), caps[2].to_string())) + } else if HOSTNAME_RE.is_match(s) { + Ok(Self::Host(s.to_string())) + } else { + Err(anyhow!("does not match as a DID or hostname: {}", s)) + } + } +} + +impl fmt::Display for DidOrHost { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Host(v) => write!(f, "{}", v), + Self::Did(m, v) => write!(f, "did:{}:{}", m, v), + } + } +} + +#[test] +fn test_didorhost() { + assert_eq!( + DidOrHost::from_str("hyde.test").unwrap(), + DidOrHost::Host("hyde.test".to_string()) + ); + assert_eq!( + DidOrHost::from_str("did:method:blah").unwrap(), + DidOrHost::Did("method".to_string(), "blah".to_string()) + ); + + assert!(DidOrHost::from_str("barestring").is_err()); + assert!(DidOrHost::from_str("did:partial:").is_err()); + assert!(DidOrHost::from_str("").is_err()); + assert!(DidOrHost::from_str(" ").is_err()); + assert!(DidOrHost::from_str("1234").is_err()); + + assert!(DidOrHost::from_str("mutli.part.domain").is_ok()); + assert!(DidOrHost::from_str("did:is:weird").is_ok()); + assert!(DidOrHost::from_str("did:plc:bv6ggog3tya2z3vxsub7hnal").is_ok()); +} + +#[derive(Debug, PartialEq, Eq, Clone)] +pub struct AtUri { + pub repository: DidOrHost, + pub collection: Option, + pub record: Option, + pub fragment: Option, +} + +impl FromStr for AtUri { + type Err = anyhow::Error; + + fn from_str(s: &str) -> Result { + lazy_static! { + static ref ATURI_RE: Regex = Regex::new(r"^at://([a-zA-Z0-9:_\.-]+)(/([a-zA-Z0-9\.]+))?(/([a-zA-Z0-9\.-]+))?(#([a-zA-Z0-9/-]+))?$").unwrap(); + } + if let Some(caps) = ATURI_RE.captures(s) { + let uri = AtUri { + repository: DidOrHost::from_str(&caps[1])?, + collection: caps.get(3).map(|v| v.as_str().to_string()), + record: caps.get(5).map(|v| v.as_str().to_string()), + fragment: caps.get(7).map(|v| v.as_str().to_string()), + }; + Ok(uri) + } else { + Err(anyhow!("couldn't parse as an at:// URI: {}", s)) + } + } +} + +impl fmt::Display for AtUri { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "at://{}", self.repository)?; + if let Some(ref c) = self.collection { + write!(f, "/{}", c)?; + }; + if let Some(ref r) = self.record { + write!(f, "/{}", r)?; + }; + if let Some(ref v) = self.fragment { + write!(f, "#{}", v)?; + }; + Ok(()) + } +} + +#[test] +fn test_aturi() { + assert!(AtUri::from_str("at://bob.com").is_ok()); + assert!(AtUri::from_str("at://did:plc:bv6ggog3tya2z3vxsub7hnal").is_ok()); + assert!(AtUri::from_str("at://bob.com/io.example.song").is_ok()); + assert!(AtUri::from_str("at://bob.com/io.example.song/3yI5-c1z-cc2p-1a").is_ok()); + assert!(AtUri::from_str("at://bob.com/io.example.song/3yI5-c1z-cc2p-1a#/title").is_ok()); + + let uri = AtUri { + repository: DidOrHost::Did("some".to_string(), "thing".to_string()), + collection: Some("com.atproto.record".to_string()), + record: Some("asdf-123".to_string()), + fragment: Some("/path".to_string()), + }; + assert_eq!( + "at://did:some:thing/com.atproto.record/asdf-123#/path", + uri.to_string() + ); + println!("{:?}", AtUri::from_str(&uri.to_string())); + assert!(AtUri::from_str(&uri.to_string()).is_ok()); + + let uri = AtUri::from_str("at://bob.com/io.example.song/3yI5-c1z-cc2p-1a#/title").unwrap(); + assert_eq!(uri.repository, DidOrHost::Host("bob.com".to_string())); + assert_eq!(uri.collection, Some("io.example.song".to_string())); + assert_eq!(uri.record, Some("3yI5-c1z-cc2p-1a".to_string())); + assert_eq!(uri.fragment, Some("/title".to_string())); +} diff --git a/adenosine-cli/src/lib.rs b/adenosine-cli/src/lib.rs index 06fade5..8f462da 100644 --- a/adenosine-cli/src/lib.rs +++ b/adenosine-cli/src/lib.rs @@ -6,6 +6,9 @@ use std::collections::HashMap; use std::str::FromStr; use std::time::Duration; +mod identifiers; +pub use identifiers::{AtUri, DidOrHost}; + static APP_USER_AGENT: &str = concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION"),); #[derive(Debug, PartialEq, Eq, Clone)] diff --git a/adenosine-cli/src/main.rs b/adenosine-cli/src/main.rs deleted file mode 100644 index eea16bc..0000000 --- a/adenosine-cli/src/main.rs +++ /dev/null @@ -1,444 +0,0 @@ -use adenosine_cli::*; -use anyhow::anyhow; -use serde_json::{json, Value}; -use std::collections::HashMap; - -use colored_json::to_colored_json_auto; -use log::{self, debug}; -use std::io::Write; -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 { - #[structopt(long, short)] - username: String, - - #[structopt(long, short)] - password: String, - }, - Logout, - Info, - // TODO: CreateRevocationKey, -} - -#[derive(StructOpt)] -enum RepoCommand { - Root { - #[structopt(long)] - did: String, - }, - Export { - #[structopt(long)] - did: String, - #[structopt(long)] - from: Option, - }, - Import { - // XXX: either path or stdin - #[structopt(long)] - did: String, - }, -} - -#[derive(StructOpt)] -enum BskyCommand { - Feed { name: Option }, - Notifications, - Post { text: String }, - Repost { uri: String }, - Like { uri: String }, - // TODO: Repost { uri: String, }, - Follow { uri: String }, - // TODO: Unfollow { uri: String, }, - /* - Follows { - name: String, - }, - Followers { - name: String, - }, - */ - Profile { name: String }, - SearchUsers { query: String }, -} - -#[derive(StructOpt)] -enum Command { - Get { - uri: String, - }, - - Ls { - uri: String, - }, - - Create { - collection: String, - body: String, - }, - Update { - uri: String, - params: String, - }, - Delete { - uri: String, - }, - - Describe { - name: String, - }, - - Resolve { - name: String, - }, - - Xrpc { - method: XrpcMethod, - nsid: String, - params: Option, - }, - - /// Sub-commands for managing account - Account { - #[structopt(subcommand)] - cmd: AccountCommand, - }, - - Repo { - #[structopt(subcommand)] - cmd: RepoCommand, - }, - - Bsky { - #[structopt(subcommand)] - cmd: BskyCommand, - }, - - /// 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 print_result_json(result: Option) -> Result<()> { - if let Some(val) = result { - writeln!(&mut std::io::stdout(), "{}", to_colored_json_auto(&val)?)? - }; - Ok(()) -} - -fn run(opt: Opt) -> Result<()> { - let xrpc_client = XrpcClient::new(opt.atp_host.clone(), opt.auth_token.clone())?; - let mut params: HashMap = HashMap::new(); - let jwt_did: Option = if let Some(ref token) = opt.auth_token { - Some(parse_did_from_jwt(token)?) - } else { - None - }; - - let result = match opt.cmd { - Command::Status => { - // XXX - println!("Configuration"); - println!(" ATP_HOST: {}", opt.atp_host); - if opt.auth_token.is_some() { - println!(" ATP_AUTH_TOKEN: "); - } else { - println!(" ATP_AUTH_TOKEN:"); - } - // TODO: parse JWT? - // TODO: connection, auth check - // TODO: account username, did, etc - None - } - Command::Describe { name } => { - params.insert("user".to_string(), name); - xrpc_client.get("com.atproto.repoDescribe", Some(params))? - } - Command::Resolve { name } => { - let mut params: HashMap = HashMap::new(); - params.insert("name".to_string(), name); - xrpc_client.get("com.atproto.resolveName", Some(params))? - } - Command::Get { uri } => { - println!("GET: {}", uri); - None - } - Command::Ls { uri } => { - unimplemented!() - } - Command::Create { collection, body } => { - unimplemented!() - } - Command::Update { uri, params } => { - unimplemented!() - } - Command::Delete { uri } => { - unimplemented!() - } - Command::Xrpc { - method, - nsid, - params, - } => { - let body: Value = ().into(); - match method { - // XXX: parse params - XrpcMethod::Get => xrpc_client.get(&nsid, None)?, - XrpcMethod::Post => xrpc_client.post(&nsid, None, body)?, - } - } - Command::Account { - cmd: - AccountCommand::Register { - email, - username, - password, - }, - } => xrpc_client.post( - "com.atproto.createAccount", - None, - json!({ - "email": email, - "username": username, - "password": password, - }), - )?, - Command::Account { - cmd: AccountCommand::Login { username, password }, - } => xrpc_client.post( - "com.atproto.createSession", - None, - json!({ - "username": username, - "password": password, - }), - )?, - Command::Account { - cmd: AccountCommand::Logout, - } => xrpc_client.post("com.atproto.deleteSession", None, json!({}))?, - Command::Account { - cmd: AccountCommand::Delete, - } => xrpc_client.post("com.atproto.deleteAccount", None, json!({}))?, - Command::Account { - cmd: AccountCommand::Info, - } => xrpc_client.get("com.atproto.getAccount", None)?, - Command::Repo { - cmd: RepoCommand::Root { did }, - } => { - params.insert("did".to_string(), did); - xrpc_client.get("com.atproto.syncGetRoot", Some(params))? - } - Command::Repo { - cmd: RepoCommand::Export { did, from }, - } => { - params.insert("did".to_string(), did); - if let Some(from) = from { - params.insert("from".to_string(), from); - }; - xrpc_client.get_to_writer( - "com.atproto.syncGetRepo", - Some(params), - &mut std::io::stdout(), - )?; - None - } - Command::Repo { - cmd: RepoCommand::Import { did }, - } => { - params.insert("did".to_string(), did); - xrpc_client.post_cbor_from_reader( - "com.atproto.syncUpdateRepo", - Some(params), - &mut std::io::stdin(), - )? - } - Command::Bsky { - cmd: BskyCommand::Feed { name }, - } => { - if let Some(name) = name { - params.insert("author".to_string(), name); - xrpc_client.get("app.bsky.getAuthorFeed", Some(params))? - } else { - xrpc_client.get("app.bsky.getHomeFeed", None)? - } - } - Command::Bsky { - cmd: BskyCommand::Notifications, - } => xrpc_client.get("app.bsky.getNotifications", None)?, - Command::Bsky { - cmd: BskyCommand::Post { text }, - } => { - params.insert( - "did".to_string(), - jwt_did.ok_or(anyhow!("need auth token"))?, - ); - params.insert("collection".to_string(), "app.bsky.post".to_string()); - xrpc_client.post( - "com.atproto.repoCreateRecord", - Some(params), - json!({ - "text": text, - }), - )? - } - Command::Bsky { - cmd: BskyCommand::Repost { uri }, - } => { - params.insert( - "did".to_string(), - jwt_did.ok_or(anyhow!("need auth token"))?, - ); - params.insert("collection".to_string(), "app.bsky.repost".to_string()); - xrpc_client.post( - "com.atproto.repoCreateRecord", - Some(params), - json!({ - "subject": uri, - }), - )? - } - Command::Bsky { - cmd: BskyCommand::Like { uri }, - } => { - params.insert( - "did".to_string(), - jwt_did.ok_or(anyhow!("need auth token"))?, - ); - params.insert("collection".to_string(), "app.bsky.like".to_string()); - xrpc_client.post( - "com.atproto.repoCreateRecord", - Some(params), - json!({ - "subject": uri, - }), - )? - } - Command::Bsky { - cmd: BskyCommand::Follow { uri }, - } => { - params.insert( - "did".to_string(), - jwt_did.ok_or(anyhow!("need auth token"))?, - ); - params.insert("collection".to_string(), "app.bsky.follow".to_string()); - xrpc_client.post( - "com.atproto.repoCreateRecord", - Some(params), - json!({ - "subject": uri, - }), - )? - } - Command::Bsky { - cmd: BskyCommand::Profile { name }, - } => { - params.insert("name".to_string(), name); - xrpc_client.get("app.bsky.getProfile", Some(params))? - } - Command::Bsky { - cmd: BskyCommand::SearchUsers { query }, - } => { - params.insert("term".to_string(), query); - xrpc_client.get("app.bsky.getUsersSearch", Some(params))? - } - }; - print_result_json(result)?; - Ok(()) -} -- cgit v1.2.3