From c7ba5a7b3f1dc94d3d106564e779a7363cbe4a7b Mon Sep 17 00:00:00 2001 From: xphoniex Date: Tue, 8 Nov 2022 09:25:44 +0000 Subject: [PATCH] Add `rad-auth`, `rad-help` Enable `rad-init`, `rad-checkout` commands as well. Signed-off-by: xphoniex --- radicle-cli/build.rs | 23 +++++ radicle-cli/src/commands.rs | 2 + radicle-cli/src/commands/auth.rs | 134 +++++++++++++++++++++++++ radicle-cli/src/commands/help.rs | 71 ++++++++++++++ radicle-cli/src/main.rs | 163 +++++++++++++++++++++++++++++++ radicle-cli/src/terminal.rs | 2 +- radicle-cli/src/terminal/io.rs | 8 +- 7 files changed, 397 insertions(+), 6 deletions(-) create mode 100644 radicle-cli/build.rs create mode 100644 radicle-cli/src/commands/auth.rs create mode 100644 radicle-cli/src/commands/help.rs create mode 100644 radicle-cli/src/main.rs diff --git a/radicle-cli/build.rs b/radicle-cli/build.rs new file mode 100644 index 00000000..fc6352c2 --- /dev/null +++ b/radicle-cli/build.rs @@ -0,0 +1,23 @@ +use std::process::Command; + +fn main() { + // Set a build-time `GIT_HEAD` env var which includes the commit id; + // such that we can tell which code is running. + let hash = Command::new("git") + .arg("rev-parse") + .arg("--short") + .arg("HEAD") + .output() + .ok() + .and_then(|output| { + if output.status.success() { + String::from_utf8(output.stdout).ok() + } else { + None + } + }) + .unwrap_or_else(|| String::from("unknown")); + + println!("cargo:rustc-env=GIT_HEAD={}", hash); + println!("cargo:rustc-rerun-if-changed=.git/HEAD"); +} diff --git a/radicle-cli/src/commands.rs b/radicle-cli/src/commands.rs index fb07cc55..019c7bbb 100644 --- a/radicle-cli/src/commands.rs +++ b/radicle-cli/src/commands.rs @@ -1,2 +1,4 @@ +pub mod auth; pub mod checkout; +pub mod help; pub mod init; diff --git a/radicle-cli/src/commands/auth.rs b/radicle-cli/src/commands/auth.rs new file mode 100644 index 00000000..c6c83696 --- /dev/null +++ b/radicle-cli/src/commands/auth.rs @@ -0,0 +1,134 @@ +#![allow(clippy::or_fun_call)] +use std::ffi::OsString; + +use anyhow::anyhow; + +use radicle::crypto::ssh; +use radicle::Profile; + +use crate::git; +use crate::terminal as term; +use crate::terminal::args::{Args, Error, Help}; + +pub const HELP: Help = Help { + name: "auth", + description: "Manage radicle identities and profiles", + version: env!("CARGO_PKG_VERSION"), + usage: r#" +Usage + + rad auth [...] + + A passphrase may be given via the environment variable `RAD_PASSPHRASE` or + via the standard input stream if `--stdin` is used. Using one of these + methods disables the passphrase prompt. + +Options + + --stdin Read passphrase from stdin (default: false) + --help Print help +"#, +}; + +#[derive(Debug)] +pub struct Options { + pub stdin: bool, +} + +impl Args for Options { + fn from_args(args: Vec) -> anyhow::Result<(Self, Vec)> { + use lexopt::prelude::*; + + let mut stdin = false; + let mut parser = lexopt::Parser::from_args(args); + + while let Some(arg) = parser.next()? { + match arg { + Long("stdin") => { + stdin = true; + } + Long("help") => { + return Err(Error::Help.into()); + } + _ => return Err(anyhow::anyhow!(arg.unexpected())), + } + } + + Ok((Options { stdin }, vec![])) + } +} + +pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> { + match ctx.profile() { + Ok(profile) => authenticate(&profile, options), + Err(_) => init(options), + } +} + +pub fn init(options: Options) -> anyhow::Result<()> { + term::headline("Initializing your 🌱 profile and identity"); + + if git::check_version().is_err() { + term::warning(&format!( + "Your git version is unsupported, please upgrade to {} or later", + git::VERSION_REQUIRED, + )); + term::blank(); + } + + let passphrase = term::read_passphrase(options.stdin, true)?; + let spinner = term::spinner("Creating your 🌱 Ed25519 keypair..."); + let profile = Profile::init(passphrase.as_str())?; + spinner.finish(); + + term::success!( + "Profile {} created.", + term::format::highlight(&profile.id().to_string()) + ); + + term::blank(); + term::info!( + "Your radicle Node ID is {}. This identifies your device.", + term::format::highlight(&profile.id().to_string()) + ); + + term::blank(); + term::tip!( + "To create a radicle project, run {} from a git repository.", + term::format::secondary("`rad init`") + ); + + Ok(()) +} + +pub fn authenticate(profile: &Profile, options: Options) -> anyhow::Result<()> { + let agent = ssh::agent::Agent::connect()?; + + term::headline(&format!( + "🌱 Authenticating as {}", + term::format::Identity::new(profile).styled() + )); + + let profile = &profile; + if !agent.signer(profile.public_key).is_ready()? { + term::warning("Adding your radicle key to ssh-agent..."); + + // TODO: We should show the spinner on the passphrase prompt, + // otherwise it seems like the passphrase is valid even if it isn't. + let passphrase = term::read_passphrase(options.stdin, false)?; + let spinner = term::spinner("Unlocking..."); + let mut agent = ssh::agent::Agent::connect()?; + let secret = profile + .keystore + .secret_key(passphrase)? + .ok_or_else(|| anyhow!("Key not found in {:?}", profile.keystore.path()))?; + agent.register(&secret)?; + spinner.finish(); + + term::success!("Radicle key added to ssh-agent"); + } else { + term::success!("Signing key already in ssh-agent"); + }; + + Ok(()) +} diff --git a/radicle-cli/src/commands/help.rs b/radicle-cli/src/commands/help.rs new file mode 100644 index 00000000..4192a37a --- /dev/null +++ b/radicle-cli/src/commands/help.rs @@ -0,0 +1,71 @@ +use std::ffi::OsString; + +use crate::terminal as term; +use crate::terminal::args::{Args, Error, Help}; + +use super::auth as rad_auth; +use super::checkout as rad_checkout; +use super::init as rad_init; + +pub const HELP: Help = Help { + name: "help", + description: "Radicle CLI help", + version: env!("CARGO_PKG_VERSION"), + usage: "Usage: rad help [--help]", +}; + +const COMMANDS: &[Help] = &[rad_auth::HELP, rad_init::HELP, rad_checkout::HELP, HELP]; + +#[derive(Default)] +pub struct Options {} + +impl Args for Options { + fn from_args(args: Vec) -> anyhow::Result<(Self, Vec)> { + use lexopt::prelude::*; + + let mut parser = lexopt::Parser::from_args(args); + + if let Some(arg) = parser.next()? { + match arg { + Long("help") => { + return Err(Error::Help.into()); + } + _ => return Err(anyhow::anyhow!(arg.unexpected())), + } + } + Ok((Options {}, vec![])) + } +} + +pub fn run(_options: Options, ctx: impl term::Context) -> anyhow::Result<()> { + println!("Usage: rad [--help]"); + + if ctx.profile().is_err() { + println!(); + println!( + "{}", + term::format::highlight("It looks like this is your first time using radicle.") + ); + println!( + "{}", + term::format::highlight("To get started, use `rad auth` to authenticate.") + ); + println!(); + } + + println!("Common `rad` commands used in various situations:"); + println!(); + + for help in COMMANDS { + println!( + "\t{} {}", + term::format::bold(format!("{:-12}", help.name)), + term::format::dim(help.description) + ); + } + println!(); + println!("See `rad --help` to learn about a specific command."); + println!(); + + Ok(()) +} diff --git a/radicle-cli/src/main.rs b/radicle-cli/src/main.rs new file mode 100644 index 00000000..9eb54dd0 --- /dev/null +++ b/radicle-cli/src/main.rs @@ -0,0 +1,163 @@ +use std::ffi::OsString; +use std::{io::ErrorKind, iter, process}; + +use anyhow::anyhow; + +use radicle_cli::commands::*; +use radicle_cli::terminal as term; + +use auth as rad_auth; +use checkout as rad_checkout; +use help as rad_help; +use init as rad_init; + +pub const NAME: &str = "rad"; +pub const VERSION: &str = env!("CARGO_PKG_VERSION"); +pub const DESCRIPTION: &str = "Radicle command line interface"; +pub const GIT_HEAD: &str = env!("GIT_HEAD"); + +#[derive(Debug)] +enum Command { + Other(Vec), + Help, + Version, +} + +fn main() { + match parse_args().map_err(Some).and_then(run) { + Ok(_) => process::exit(0), + Err(err) => { + if let Some(err) = err { + term::error(&format!("Error: rad: {}", err)); + } + process::exit(1); + } + } +} + +fn parse_args() -> anyhow::Result { + use lexopt::prelude::*; + + let mut parser = lexopt::Parser::from_env(); + let mut command = None; + + while let Some(arg) = parser.next()? { + match arg { + Long("help") | Short('h') => { + command = Some(Command::Help); + } + Long("version") => { + command = Some(Command::Version); + } + Value(val) if command.is_none() => { + if val == *"." { + command = Some(Command::Other(vec![OsString::from("inspect")])); + } else { + let args = iter::once(val) + .chain(iter::from_fn(|| parser.value().ok())) + .collect(); + + command = Some(Command::Other(args)) + } + } + _ => return Err(anyhow::anyhow!(arg.unexpected())), + } + } + + Ok(command.unwrap_or_else(|| Command::Other(vec![]))) +} + +fn print_version() { + if VERSION.contains("-dev") { + println!("{} {}+{}", NAME, VERSION, GIT_HEAD) + } else { + println!("{} {}", NAME, VERSION) + } +} + +fn print_help() -> anyhow::Result<()> { + print_version(); + println!("{}", DESCRIPTION); + println!(); + + rad_help::run(Default::default(), term::profile) +} + +fn run(command: Command) -> Result<(), Option> { + match command { + Command::Version => { + print_version(); + } + Command::Help => { + print_help()?; + } + Command::Other(args) => { + let exe = args.first(); + + if let Some(Some(exe)) = exe.map(|s| s.to_str()) { + run_other(exe, &args[1..])?; + } else { + print_help()?; + } + } + } + + Ok(()) +} + +fn run_other(exe: &str, args: &[OsString]) -> Result<(), Option> { + match exe { + "auth" => { + term::run_command_args::( + rad_auth::HELP, + "Authentication", + rad_auth::run, + args.to_vec(), + ); + } + "checkout" => { + term::run_command_args::( + rad_checkout::HELP, + "Checkout", + rad_checkout::run, + args.to_vec(), + ); + } + "help" => { + term::run_command_args::( + rad_help::HELP, + "Help", + rad_help::run, + args.to_vec(), + ); + } + "init" => { + term::run_command_args::( + rad_init::HELP, + "Initialization", + rad_init::run, + args.to_vec(), + ); + } + _ => { + let exe = format!("{}-{}", NAME, exe); + let status = process::Command::new(exe.clone()).args(args).status(); + + match status { + Ok(status) => { + if !status.success() { + return Err(None); + } + } + Err(err) => { + if let ErrorKind::NotFound = err.kind() { + return Err(Some(anyhow!("command `{}` not found", exe))); + } else { + return Err(Some(err.into())); + } + } + } + } + } + Ok(()) +} diff --git a/radicle-cli/src/terminal.rs b/radicle-cli/src/terminal.rs index 2302883e..783bba20 100644 --- a/radicle-cli/src/terminal.rs +++ b/radicle-cli/src/terminal.rs @@ -120,7 +120,7 @@ where } /// Get the default profile. Fails if there is no profile. -fn profile() -> Result { +pub fn profile() -> Result { let error = args::Error::WithHint { err: anyhow::anyhow!("Could not load radicle profile"), hint: "To setup your radicle profile, run `rad auth`.", diff --git a/radicle-cli/src/terminal/io.rs b/radicle-cli/src/terminal/io.rs index ed3197c0..bda6eaa3 100644 --- a/radicle-cli/src/terminal/io.rs +++ b/radicle-cli/src/terminal/io.rs @@ -33,7 +33,7 @@ macro_rules! success { #[macro_export] macro_rules! tip { ($($arg:tt)*) => ({ - $crate::io::tip_args(format_args!($($arg)*)); + $crate::terminal::io::tip_args(format_args!($($arg)*)); }) } @@ -370,10 +370,8 @@ where } pub fn markdown(content: &str) { - if !content.is_empty() { - if command::bat(["-p", "-l", "md"], content).is_err() { - blob(content); - } + if !content.is_empty() && command::bat(["-p", "-l", "md"], content).is_err() { + blob(content); } }