diff options
-rw-r--r-- | adenosine-cli/Cargo.toml | 4 | ||||
-rw-r--r-- | adenosine-cli/src/bin/adenosine.rs (renamed from adenosine-cli/src/main.rs) | 149 | ||||
-rw-r--r-- | adenosine-cli/src/identifiers.rs | 140 | ||||
-rw-r--r-- | adenosine-cli/src/lib.rs | 3 |
4 files changed, 258 insertions, 38 deletions
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/main.rs b/adenosine-cli/src/bin/adenosine.rs index eea16bc..1d23c27 100644 --- a/adenosine-cli/src/main.rs +++ b/adenosine-cli/src/bin/adenosine.rs @@ -66,39 +66,37 @@ enum AccountCommand { }, Logout, Info, - // TODO: CreateRevocationKey, + // TODO: CreateRevocationKey or CreateDid } #[derive(StructOpt)] enum RepoCommand { Root { - #[structopt(long)] - did: String, + did: Option<DidOrHost>, }, Export { - #[structopt(long)] - did: String, + did: Option<DidOrHost>, #[structopt(long)] from: Option<String>, }, Import { - // XXX: either path or stdin + // TODO: could accept either path or stdin? #[structopt(long)] - did: String, + did: Option<DidOrHost>, }, } #[derive(StructOpt)] enum BskyCommand { - Feed { name: Option<String> }, + Feed { name: Option<DidOrHost> }, Notifications, Post { text: String }, - Repost { uri: String }, - Like { uri: String }, + Repost { uri: AtUri }, + Like { uri: AtUri }, // TODO: Repost { uri: String, }, - Follow { uri: String }, + Follow { uri: DidOrHost }, // TODO: Unfollow { uri: String, }, - /* + /* TODO: Follows { name: String, }, @@ -106,44 +104,47 @@ enum BskyCommand { name: String, }, */ - Profile { name: String }, + Profile { name: DidOrHost }, SearchUsers { query: String }, } #[derive(StructOpt)] enum Command { Get { - uri: String, + uri: AtUri, + + #[structopt(long)] + cid: Option<String>, }, Ls { - uri: String, + uri: AtUri, }, Create { collection: String, - body: String, + fields: String, }, Update { - uri: String, - params: String, + uri: AtUri, + fields: String, }, Delete { - uri: String, + uri: AtUri, }, Describe { - name: String, + name: Option<DidOrHost>, }, Resolve { - name: String, + name: DidOrHost, }, Xrpc { method: XrpcMethod, nsid: String, - params: Option<String>, + fields: Option<String>, }, /// Sub-commands for managing account @@ -248,34 +249,90 @@ fn run(opt: Opt) -> Result<()> { None } Command::Describe { name } => { - params.insert("user".to_string(), 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<String, String> = HashMap::new(); - params.insert("name".to_string(), name); + params.insert("name".to_string(), name.to_string()); xrpc_client.get("com.atproto.resolveName", Some(params))? } - Command::Get { uri } => { - println!("GET: {}", uri); - None + 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 } => { - unimplemented!() + // 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, body } => { + Command::Create { collection, fields } => { + params.insert("collection".to_string(), collection); unimplemented!() } - Command::Update { uri, params } => { + 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 } => { - unimplemented!() + 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, - params, + fields, } => { let body: Value = ().into(); match method { @@ -322,12 +379,22 @@ fn run(opt: Opt) -> Result<()> { 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); @@ -342,6 +409,11 @@ fn run(opt: Opt) -> Result<()> { 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", @@ -353,7 +425,7 @@ fn run(opt: Opt) -> Result<()> { cmd: BskyCommand::Feed { name }, } => { if let Some(name) = name { - params.insert("author".to_string(), 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)? @@ -390,7 +462,8 @@ fn run(opt: Opt) -> Result<()> { "com.atproto.repoCreateRecord", Some(params), json!({ - "subject": uri, + "subject": uri.to_string(), + // TODO: "createdAt": now_timestamp(), }), )? } @@ -406,7 +479,8 @@ fn run(opt: Opt) -> Result<()> { "com.atproto.repoCreateRecord", Some(params), json!({ - "subject": uri, + "subject": uri.to_string(), + // TODO: "createdAt": now_timestamp(), }), )? } @@ -422,14 +496,15 @@ fn run(opt: Opt) -> Result<()> { "com.atproto.repoCreateRecord", Some(params), json!({ - "subject": uri, + "subject": uri.to_string(), + // TODO: "createdAt": now_timestamp(), }), )? } Command::Bsky { cmd: BskyCommand::Profile { name }, } => { - params.insert("name".to_string(), name); + params.insert("name".to_string(), name.to_string()); xrpc_client.get("app.bsky.getProfile", Some(params))? } Command::Bsky { 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: <https://w3c.github.io/did-core/#did-syntax> + /// + /// Lazy partial hostname regex, isn't very correct. + fn from_str(s: &str) -> Result<Self, Self::Err> { + 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<String>, + pub record: Option<String>, + pub fragment: Option<String>, +} + +impl FromStr for AtUri { + type Err = anyhow::Error; + + fn from_str(s: &str) -> Result<Self, Self::Err> { + 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)] |