aboutsummaryrefslogtreecommitdiffstats
path: root/adenosine-cli
diff options
context:
space:
mode:
Diffstat (limited to 'adenosine-cli')
-rw-r--r--adenosine-cli/Cargo.toml4
-rw-r--r--adenosine-cli/src/bin/adenosine.rs (renamed from adenosine-cli/src/main.rs)149
-rw-r--r--adenosine-cli/src/identifiers.rs140
-rw-r--r--adenosine-cli/src/lib.rs3
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)]