From bfc6909cfaf1ca439aabc205f08741c37b56bde5 Mon Sep 17 00:00:00 2001 From: Erik Kundt Date: Wed, 20 Dec 2023 15:40:46 +0100 Subject: [PATCH] lib: Add support for unified commands with args --- Cargo.lock | 13 +-- Cargo.toml | 3 +- bin/main.rs | 6 +- bin/terminal.rs | 134 ++++++++++++++++++++++++++++++ bin/terminal/args.rs | 194 +++++++++++++++++++++++++++++++++++++++++++ bin/terminal/io.rs | 62 ++++++++++++++ 6 files changed, 404 insertions(+), 8 deletions(-) create mode 100644 bin/terminal.rs create mode 100644 bin/terminal/args.rs create mode 100644 bin/terminal/io.rs diff --git a/Cargo.lock b/Cargo.lock index 5209e190..339532d2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -682,9 +682,9 @@ checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" [[package]] name = "flate2" -version = "1.0.26" +version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b9429470923de8e8cbd4d2dc513535400b4b3fef0319fb5c4e1f520a7bef743" +checksum = "46303f565772937ffe1d394a4fac6f411c6013172fadde9dcdb1e147a086940e" dependencies = [ "crc32fast", "miniz_oxide", @@ -1662,6 +1662,7 @@ dependencies = [ "radicle-term", "simple-logging", "textwrap 0.16.0", + "thiserror", "timeago", "tui-realm-stdlib", "tui-realm-textarea", @@ -2143,9 +2144,9 @@ dependencies = [ [[package]] name = "tar" -version = "0.4.39" +version = "0.4.40" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec96d2ffad078296368d46ff1cb309be1c23c513b4ab0e22a45de0185275ac96" +checksum = "b16afcea1f22891c49a00c751c7b63b2233284064f11a200fc624137c51e2ddb" dependencies = [ "filetime", "libc", @@ -2798,9 +2799,9 @@ dependencies = [ [[package]] name = "xattr" -version = "0.2.3" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d1526bbe5aaeb5eb06885f4d987bcdfa5e23187055de9b83fe00156a821fabc" +checksum = "fbc6ab6ec1907d1a901cdbcd2bd4cb9e7d64ce5c9739cbb97d3c391acd8c7fae" dependencies = [ "libc", ] diff --git a/Cargo.toml b/Cargo.toml index 88a197c9..55cd1bc7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,12 +15,13 @@ anyhow = { version = "1" } inquire = { version = "0.6.2", default-features = false, features = ["termion", "editor"] } lexopt = { version = "0.2" } log = { version = "0.4.19" } -simple-logging = { version = "2.0.2" } radicle = { git = "https://github.com/radicle-dev/heartwood" } radicle-term = { git = "https://github.com/radicle-dev/heartwood", package = "radicle-term" } radicle-surf = { version = "0.18.0" } +simple-logging = { version = "2.0.2" } timeago = { version = "0.4.1" } textwrap = { version = "0.16.0" } +thiserror = { version = "1" } tuirealm = { version = "1.9.0", default-features = false, features = [ "with-termion" ] } tui-realm-stdlib = { version = "1.2.0", default-features = false, features = [ "with-termion" ] } tui-realm-textarea = { git = "https://github.com/erak/tui-realm-textarea.git", default-features = false, features = [ "with-termion", "clipboard" ] } diff --git a/bin/main.rs b/bin/main.rs index 8a55e803..4c0518f0 100644 --- a/bin/main.rs +++ b/bin/main.rs @@ -1,3 +1,7 @@ +// use radicle::profile; + +mod terminal; + fn main() { - + } \ No newline at end of file diff --git a/bin/terminal.rs b/bin/terminal.rs new file mode 100644 index 00000000..1e43606d --- /dev/null +++ b/bin/terminal.rs @@ -0,0 +1,134 @@ +pub mod args; +pub use args::{Args, Error, Help}; +pub mod io; + +use std::ffi::OsString; +use std::process; + +use radicle_term as term; + +use radicle::profile::Profile; + +/// Context passed to all commands. +pub trait Context { + /// Return the currently active profile, or an error if no profile is active. + fn profile(&self) -> Result; +} + +impl Context for Profile { + fn profile(&self) -> Result { + Ok(self.clone()) + } +} + +impl Context for F +where + F: Fn() -> Result, +{ + fn profile(&self) -> Result { + self() + } +} + +/// A command that can be run. +pub trait Command { + /// Run the command, given arguments and a context. + fn run(self, args: A, context: C) -> anyhow::Result<()>; +} + +impl Command for F +where + F: FnOnce(A, C) -> anyhow::Result<()>, +{ + fn run(self, args: A, context: C) -> anyhow::Result<()> { + self(args, context) + } +} + +pub fn run_command(help: Help, cmd: C) -> ! +where + A: Args, + C: Command anyhow::Result>, +{ + let args = std::env::args_os().skip(1).collect(); + + run_command_args(help, cmd, args) +} + +pub fn run_command_args(help: Help, cmd: C, args: Vec) -> ! +where + A: Args, + C: Command anyhow::Result>, +{ + let options = match A::from_args(args) { + Ok((opts, unparsed)) => { + if let Err(err) = args::finish(unparsed) { + term::error(err); + process::exit(1); + } + opts + } + Err(err) => { + let hint = match err.downcast_ref::() { + Some(Error::Help) => { + term::help(help.name, help.version, help.description, help.usage); + process::exit(0); + } + Some(Error::HelpManual { name }) => { + let Ok(status) = term::manual(name) else { + term::error(format!("rad {}: failed to load manual page", help.name)); + process::exit(1); + }; + process::exit(status.code().unwrap_or(0)); + } + Some(Error::Usage) => { + term::usage(help.name, help.usage); + process::exit(1); + } + Some(Error::WithHint { hint, .. }) => Some(hint), + None => None, + }; + term::error(format!("rad {}: {err}", help.name)); + + if let Some(hint) = hint { + term::hint(hint); + } + process::exit(1); + } + }; + + match cmd.run(options, self::profile) { + Ok(()) => process::exit(0), + Err(err) => { + fail(help.name, &err); + process::exit(1); + } + } +} + +/// Get the default profile. Fails if there is no profile. +pub fn profile() -> Result { + match Profile::load() { + Ok(profile) => Ok(profile), + Err(radicle::profile::Error::NotFound(path)) => Err(args::Error::WithHint { + err: anyhow::anyhow!("Radicle profile not found in '{}'.", path.display()), + hint: "To setup your radicle profile, run `rad auth`.", + } + .into()), + Err(radicle::profile::Error::Config(e)) => Err(e.into()), + Err(e) => Err(anyhow::anyhow!("Could not load radicle profile: {e}")), + } +} + +pub fn fail(_name: &str, error: &anyhow::Error) { + let err = error.to_string(); + let err = err.trim_end(); + + for line in err.lines() { + term::error(line); + } + + if let Some(Error::WithHint { hint, .. }) = error.downcast_ref::() { + term::hint(hint); + } +} diff --git a/bin/terminal/args.rs b/bin/terminal/args.rs new file mode 100644 index 00000000..59152d47 --- /dev/null +++ b/bin/terminal/args.rs @@ -0,0 +1,194 @@ +use std::ffi::OsString; +use std::str::FromStr; +use std::time; + +use anyhow::anyhow; + +use radicle::cob::{self, issue, patch}; +use radicle::crypto; +use radicle::git::RefString; +use radicle::node::{Address, Alias}; +use radicle::prelude::{Did, Id, NodeId}; + +/// Git revision parameter. Supports extended SHA-1 syntax. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Rev(String); + +impl From for Rev { + fn from(value: String) -> Self { + Rev(value) + } +} + +#[derive(thiserror::Error, Debug)] +pub enum Error { + /// If this error is returned from argument parsing, help is displayed. + #[error("help invoked")] + Help, + /// If this error is returned from argument parsing, the manual page is displayed. + #[error("help manual invoked")] + HelpManual { name: &'static str }, + /// If this error is returned from argument parsing, usage is displayed. + #[error("usage invoked")] + Usage, + /// An error with a hint. + #[error("{err}")] + WithHint { + err: anyhow::Error, + hint: &'static str, + }, +} + +pub struct Help { + pub name: &'static str, + pub description: &'static str, + pub version: &'static str, + pub usage: &'static str, +} + +pub trait Args: Sized { + fn from_env() -> anyhow::Result { + let args: Vec<_> = std::env::args_os().skip(1).collect(); + + match Self::from_args(args) { + Ok((opts, unparsed)) => { + self::finish(unparsed)?; + + Ok(opts) + } + Err(err) => Err(err), + } + } + + fn from_args(args: Vec) -> anyhow::Result<(Self, Vec)>; +} + +pub fn parse_value(flag: &str, value: OsString) -> anyhow::Result +where + ::Err: std::error::Error, +{ + value + .into_string() + .map_err(|_| anyhow!("the value specified for '--{}' is not valid UTF-8", flag))? + .parse() + .map_err(|e| anyhow!("invalid value specified for '--{}' ({})", flag, e)) +} + +pub fn format(arg: lexopt::Arg) -> OsString { + match arg { + lexopt::Arg::Long(flag) => format!("--{flag}").into(), + lexopt::Arg::Short(flag) => format!("-{flag}").into(), + lexopt::Arg::Value(val) => val, + } +} + +pub fn finish(unparsed: Vec) -> anyhow::Result<()> { + if let Some(arg) = unparsed.first() { + return Err(anyhow::anyhow!( + "unexpected argument `{}`", + arg.to_string_lossy() + )); + } + Ok(()) +} + +pub fn refstring(flag: &str, value: OsString) -> anyhow::Result { + RefString::try_from( + value + .into_string() + .map_err(|_| anyhow!("the value specified for '--{}' is not valid UTF-8", flag))?, + ) + .map_err(|_| { + anyhow!( + "the value specified for '--{}' is not a valid ref string", + flag + ) + }) +} + +pub fn did(val: &OsString) -> anyhow::Result { + let val = val.to_string_lossy(); + let Ok(peer) = Did::from_str(&val) else { + if crypto::PublicKey::from_str(&val).is_ok() { + return Err(anyhow!("expected DID, did you mean 'did:key:{val}'?")); + } else { + return Err(anyhow!("invalid DID '{}', expected 'did:key'", val)); + } + }; + Ok(peer) +} + +pub fn nid(val: &OsString) -> anyhow::Result { + let val = val.to_string_lossy(); + NodeId::from_str(&val).map_err(|_| anyhow!("invalid Node ID '{}'", val)) +} + +pub fn rid(val: &OsString) -> anyhow::Result { + let val = val.to_string_lossy(); + Id::from_str(&val).map_err(|_| anyhow!("invalid Repository ID '{}'", val)) +} + +pub fn pubkey(val: &OsString) -> anyhow::Result { + let Ok(did) = did(val) else { + let nid = nid(val)?; + return Ok(nid); + }; + Ok(did.as_key().to_owned()) +} + +pub fn addr(val: &OsString) -> anyhow::Result
{ + let val = val.to_string_lossy(); + Address::from_str(&val).map_err(|_| anyhow!("invalid address '{}'", val)) +} + +pub fn number(val: &OsString) -> anyhow::Result { + let val = val.to_string_lossy(); + usize::from_str(&val).map_err(|_| anyhow!("invalid number '{}'", val)) +} + +pub fn seconds(val: &OsString) -> anyhow::Result { + let val = val.to_string_lossy(); + let secs = u64::from_str(&val).map_err(|_| anyhow!("invalid number of seconds '{}'", val))?; + + Ok(time::Duration::from_secs(secs)) +} + +pub fn string(val: &OsString) -> String { + val.to_string_lossy().to_string() +} + +pub fn rev(val: &OsString) -> anyhow::Result { + let s = val.to_str().ok_or(anyhow!("invalid git rev {val:?}"))?; + Ok(Rev::from(s.to_owned())) +} + +pub fn oid(val: &OsString) -> anyhow::Result { + let s = string(val); + let _ = radicle::git::Oid::from_str(&s).map_err(|_| anyhow!("invalid git oid '{s}'"))?; + + Ok(Rev::from(s)) +} + +pub fn alias(val: &OsString) -> anyhow::Result { + let val = val.as_os_str(); + let val = val + .to_str() + .ok_or_else(|| anyhow!("alias must be valid UTF-8"))?; + + Alias::from_str(val).map_err(|e| e.into()) +} + +pub fn issue(val: &OsString) -> anyhow::Result { + let val = val.to_string_lossy(); + issue::IssueId::from_str(&val).map_err(|_| anyhow!("invalid Issue ID '{}'", val)) +} + +pub fn patch(val: &OsString) -> anyhow::Result { + let val = val.to_string_lossy(); + patch::PatchId::from_str(&val).map_err(|_| anyhow!("invalid Patch ID '{}'", val)) +} + +pub fn cob(val: &OsString) -> anyhow::Result { + let val = val.to_string_lossy(); + cob::ObjectId::from_str(&val).map_err(|_| anyhow!("invalid Object ID '{}'", val)) +} diff --git a/bin/terminal/io.rs b/bin/terminal/io.rs new file mode 100644 index 00000000..407b8828 --- /dev/null +++ b/bin/terminal/io.rs @@ -0,0 +1,62 @@ +use radicle::crypto::ssh::keystore::MemorySigner; +use radicle::crypto::{ssh::Keystore, Signer}; +use radicle::profile::env::RAD_PASSPHRASE; +use radicle::profile::Profile; + +use radicle_term::io::*; +use radicle_term::spinner; + +use inquire::validator; + +/// Validates secret key passphrases. +#[derive(Clone)] +pub struct PassphraseValidator { + keystore: Keystore, +} + +impl PassphraseValidator { + /// Create a new validator. + pub fn new(keystore: Keystore) -> Self { + Self { keystore } + } +} + +impl inquire::validator::StringValidator for PassphraseValidator { + fn validate( + &self, + input: &str, + ) -> Result { + let passphrase = Passphrase::from(input.to_owned()); + if self.keystore.is_valid_passphrase(&passphrase)? { + Ok(validator::Validation::Valid) + } else { + Ok(validator::Validation::Invalid( + validator::ErrorMessage::from("Invalid passphrase, please try again"), + )) + } + } +} + +/// Get the signer. First we try getting it from ssh-agent, otherwise we prompt the user, +/// if we're connected to a TTY. +pub fn signer(profile: &Profile) -> anyhow::Result> { + if let Ok(signer) = profile.signer() { + return Ok(signer); + } + let validator = PassphraseValidator::new(profile.keystore.clone()); + let passphrase = match passphrase(validator) { + Ok(p) => p, + Err(inquire::InquireError::NotTTY) => { + return Err(anyhow::anyhow!( + "running in non-interactive mode, please set `{RAD_PASSPHRASE}` to unseal your key", + )); + } + Err(e) => return Err(e.into()), + }; + let spinner = spinner("Unsealing key..."); + let signer = MemorySigner::load(&profile.keystore, Some(passphrase))?; + + spinner.finish(); + + Ok(signer.boxed()) +}