diff options
Diffstat (limited to 'adenosine-cli/src/bin')
| -rw-r--r-- | adenosine-cli/src/bin/adenosine.rs | 519 | 
1 files changed, 519 insertions, 0 deletions
| 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<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 { +        #[structopt(long, short)] +        username: String, + +        #[structopt(long, short)] +        password: String, +    }, +    Logout, +    Info, +    // TODO: CreateRevocationKey or CreateDid +} + +#[derive(StructOpt)] +enum RepoCommand { +    Root { +        did: Option<DidOrHost>, +    }, +    Export { +        did: Option<DidOrHost>, +        #[structopt(long)] +        from: Option<String>, +    }, +    Import { +        // TODO: could accept either path or stdin? +        #[structopt(long)] +        did: Option<DidOrHost>, +    }, +} + +#[derive(StructOpt)] +enum BskyCommand { +    Feed { name: Option<DidOrHost> }, +    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<String>, +    }, + +    Ls { +        uri: AtUri, +    }, + +    Create { +        collection: String, +        fields: String, +    }, +    Update { +        uri: AtUri, +        fields: String, +    }, +    Delete { +        uri: AtUri, +    }, + +    Describe { +        name: Option<DidOrHost>, +    }, + +    Resolve { +        name: DidOrHost, +    }, + +    Xrpc { +        method: XrpcMethod, +        nsid: String, +        fields: Option<String>, +    }, + +    /// 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::<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 print_result_json(result: Option<Value>) -> 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<String, String> = HashMap::new(); +    let jwt_did: Option<String> = 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: <configured>"); +            } 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<String, String> = 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(()) +} | 
