Radish alpha
h
Radicle Heartwood Protocol & Stack
Radicle
Git (anonymous pull)
Log in to clone via SSH
Add `radicle-cli` crate's `terminal` module
Alexis Sellier committed 3 years ago
commit 223d36086ec861e181c43ee413a51baf141b2813
parent 357fc79b41b6381266f81ab7e508bf4e85419816
15 files changed +1215 -3
modified Cargo.lock
@@ -280,6 +280,20 @@ dependencies = [
]

[[package]]
+
name = "console"
+
version = "0.15.2"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "c050367d967ced717c04b65d8c619d863ef9292ce0c5760028655a2fb298718c"
+
dependencies = [
+
 "encode_unicode",
+
 "lazy_static",
+
 "libc",
+
 "terminal_size",
+
 "unicode-width",
+
 "winapi",
+
]
+

+
[[package]]
name = "const-oid"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -460,6 +474,17 @@ dependencies = [
]

[[package]]
+
name = "dialoguer"
+
version = "0.10.2"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "a92e7e37ecef6857fdc0c0c5d42fd5b0938e46590c2183cc92dd310a6d078eb1"
+
dependencies = [
+
 "console",
+
 "tempfile",
+
 "zeroize",
+
]
+

+
[[package]]
name = "digest"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -545,6 +570,12 @@ dependencies = [
]

[[package]]
+
name = "encode_unicode"
+
version = "0.3.6"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "a357d28ed41a50f9c765dbfe56cbc04a64e53e5fc58ba79fbc34c10ef3df831f"
+

+
[[package]]
name = "fastrand"
version = "1.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -894,6 +925,18 @@ dependencies = [
]

[[package]]
+
name = "indicatif"
+
version = "0.16.2"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "2d207dc617c7a380ab07ff572a6e52fa202a2a8f355860ac9c38e23f8196be1b"
+
dependencies = [
+
 "console",
+
 "lazy_static",
+
 "number_prefix",
+
 "regex",
+
]
+

+
[[package]]
name = "inout"
version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1190,6 +1233,12 @@ dependencies = [
]

[[package]]
+
name = "number_prefix"
+
version = "0.4.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "830b246a0e5f20af87141b25c173cd1b609bd7779a4617d6ec582abaf90870f3"
+

+
[[package]]
name = "olpc-cjson"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1443,6 +1492,23 @@ dependencies = [
]

[[package]]
+
name = "radicle-cli"
+
version = "0.8.0"
+
dependencies = [
+
 "anyhow",
+
 "console",
+
 "dialoguer",
+
 "indicatif",
+
 "lexopt",
+
 "log",
+
 "radicle",
+
 "radicle-cob",
+
 "radicle-crypto",
+
 "thiserror",
+
 "zeroize",
+
]
+

+
[[package]]
name = "radicle-cob"
version = "0.1.0"
dependencies = [
@@ -2014,6 +2080,16 @@ dependencies = [
]

[[package]]
+
name = "terminal_size"
+
version = "0.1.17"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "633c1a546cee861a1a6d0dc69ebeca693bf4296661ba7852b9d21d159e0506df"
+
dependencies = [
+
 "libc",
+
 "winapi",
+
]
+

+
[[package]]
name = "thiserror"
version = "1.0.37"
source = "registry+https://github.com/rust-lang/crates.io-index"
modified Cargo.toml
@@ -2,6 +2,7 @@
members = [
  "radicle",
  "radicle-cob",
+
  "radicle-cli",
  "radicle-crypto",
  "radicle-httpd",
  "radicle-node",
added radicle-cli/Cargo.toml
@@ -0,0 +1,28 @@
+
[package]
+
name = "radicle-cli"
+
license = "MIT OR Apache-2.0"
+
version = "0.8.0"
+
authors = ["Alexis Sellier <alexis@radicle.xyz>"]
+
edition = "2021"
+

+
[dependencies]
+
anyhow = { version = "1" }
+
console = { version = "0.15" }
+
dialoguer = { version = "0.10.0" }
+
indicatif = { version = "0.16.2" }
+
lexopt = { version = "0.2" }
+
log = { version = "0.4", features = ["std"] }
+
thiserror = { version = "1" }
+
zeroize = { version = "1.1" }
+

+
[dependencies.radicle]
+
version = "0"
+
path = "../radicle"
+

+
[dependencies.radicle-cob]
+
version = "0"
+
path = "../radicle-cob"
+

+
[dependencies.radicle-crypto]
+
version = "0"
+
path = "../radicle-crypto"
added radicle-cli/src/lib.rs
@@ -0,0 +1,2 @@
+
#![allow(clippy::collapsible_if)]
+
pub mod terminal;
added radicle-cli/src/terminal.rs
@@ -0,0 +1,174 @@
+
pub mod args;
+
pub mod command;
+
pub mod format;
+
pub mod io;
+
pub mod patch;
+
pub mod spinner;
+
pub mod table;
+
pub mod textbox;
+

+
use std::ffi::OsString;
+
use std::process;
+

+
use dialoguer::console::style;
+
use radicle::profile::Profile;
+

+
pub use args::{Args, Error, Help};
+
pub use console::measure_text_width as text_width;
+
pub use dialoguer::Editor;
+
pub use io::*;
+
pub use spinner::{spinner, Spinner};
+
pub use table::Table;
+
pub use textbox::TextBox;
+

+
/// 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<Profile, anyhow::Error>;
+
}
+

+
impl Context for Profile {
+
    fn profile(&self) -> Result<Profile, anyhow::Error> {
+
        Ok(self.clone())
+
    }
+
}
+

+
impl<F> Context for F
+
where
+
    F: Fn() -> Result<Profile, anyhow::Error>,
+
{
+
    fn profile(&self) -> Result<Profile, anyhow::Error> {
+
        self()
+
    }
+
}
+

+
/// A command that can be run.
+
pub trait Command<A: Args, C: Context> {
+
    /// Run the command, given arguments and a context.
+
    fn run(self, args: A, context: C) -> anyhow::Result<()>;
+
}
+

+
impl<F, A: Args, C: Context> Command<A, C> 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<A, C>(help: Help, action: &str, cmd: C) -> !
+
where
+
    A: Args,
+
    C: Command<A, fn() -> anyhow::Result<Profile>>,
+
{
+
    let args = std::env::args_os().into_iter().skip(1).collect();
+

+
    run_command_args(help, action, cmd, args)
+
}
+

+
pub fn run_command_args<A, C>(help: Help, action: &str, cmd: C, args: Vec<OsString>) -> !
+
where
+
    A: Args,
+
    C: Command<A, fn() -> anyhow::Result<Profile>>,
+
{
+
    use io as term;
+

+
    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) => {
+
            match err.downcast_ref::<Error>() {
+
                Some(Error::Help) => {
+
                    term::help(help.name, help.version, help.description, help.usage);
+
                    process::exit(0);
+
                }
+
                Some(Error::Usage) => {
+
                    term::usage(help.name, help.usage);
+
                    process::exit(1);
+
                }
+
                _ => {}
+
            };
+
            eprintln!(
+
                "{} {} {} {}",
+
                style("==").red(),
+
                style("Error:").red(),
+
                style(format!("rad-{}:", help.name)).red(),
+
                style(&err).red()
+
            );
+

+
            if let Some(Error::WithHint { hint, .. }) = err.downcast_ref::<Error>() {
+
                eprintln!("{}", style(hint).yellow());
+
            }
+

+
            process::exit(1);
+
        }
+
    };
+

+
    match cmd.run(options, self::profile) {
+
        Ok(()) => process::exit(0),
+
        Err(err) => {
+
            term::fail(&format!("{} failed", action), &err);
+
            process::exit(1);
+
        }
+
    }
+
}
+

+
/// Get the default profile. Fails if there is no profile.
+
fn profile() -> Result<Profile, anyhow::Error> {
+
    let error = args::Error::WithHint {
+
        err: anyhow::anyhow!("Could not load radicle profile"),
+
        hint: "To setup your radicle profile, run `rad auth`.",
+
    };
+

+
    match Profile::load() {
+
        Ok(profile) => Ok(profile),
+
        Err(_) => Err(error.into()),
+
    }
+
}
+

+
#[derive(Debug, PartialEq, Eq, Copy, Clone)]
+
pub enum Interactive {
+
    Yes,
+
    No,
+
}
+

+
impl Default for Interactive {
+
    fn default() -> Self {
+
        Interactive::No
+
    }
+
}
+

+
impl Interactive {
+
    pub fn yes(&self) -> bool {
+
        (*self).into()
+
    }
+

+
    pub fn no(&self) -> bool {
+
        !self.yes()
+
    }
+
}
+

+
impl From<Interactive> for bool {
+
    fn from(c: Interactive) -> Self {
+
        match c {
+
            Interactive::Yes => true,
+
            Interactive::No => false,
+
        }
+
    }
+
}
+

+
impl From<bool> for Interactive {
+
    fn from(b: bool) -> Self {
+
        if b {
+
            Interactive::Yes
+
        } else {
+
            Interactive::No
+
        }
+
    }
+
}
added radicle-cli/src/terminal/args.rs
@@ -0,0 +1,73 @@
+
use std::ffi::OsString;
+
use std::str::FromStr;
+

+
use anyhow::anyhow;
+

+
#[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, 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<Self> {
+
        let args: Vec<_> = std::env::args_os().into_iter().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<OsString>) -> anyhow::Result<(Self, Vec<OsString>)>;
+
}
+

+
pub fn parse_value<T: FromStr>(flag: &str, value: OsString) -> anyhow::Result<T>
+
where
+
    <T as FromStr>::Err: std::error::Error,
+
{
+
    value
+
        .into_string()
+
        .map_err(|_| anyhow!("the value specified for '--{}' is not valid unicode", 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<OsString>) -> anyhow::Result<()> {
+
    if let Some(arg) = unparsed.first() {
+
        return Err(anyhow::anyhow!(
+
            "unexpected argument `{}`",
+
            arg.to_string_lossy()
+
        ));
+
    }
+
    Ok(())
+
}
added radicle-cli/src/terminal/command.rs
@@ -0,0 +1,19 @@
+
use std::io::Write;
+
use std::process::{Command, Stdio};
+

+
pub fn bat<S: AsRef<std::ffi::OsStr>>(
+
    args: impl IntoIterator<Item = S>,
+
    stdin: &str,
+
) -> anyhow::Result<()> {
+
    let mut child = Command::new("bat")
+
        .stdin(Stdio::piped())
+
        .args(args)
+
        .spawn()?;
+

+
    let writer = child.stdin.as_mut().unwrap();
+
    writer.write_all(stdin.as_bytes())?;
+

+
    child.wait()?;
+

+
    Ok(())
+
}
added radicle-cli/src/terminal/format.rs
@@ -0,0 +1,138 @@
+
use std::fmt;
+

+
use dialoguer::console::style;
+
use radicle::node::NodeId;
+
use radicle::profile::Profile;
+
use radicle_cob::ObjectId;
+

+
use crate::terminal as term;
+

+
/// Format a node id to be more compact.
+
pub fn node(node: &NodeId) -> String {
+
    let node = node.to_human();
+
    let start = node.chars().take(7).collect::<String>();
+
    let end = node.chars().skip(node.len() - 7).collect::<String>();
+

+
    format!("{}…{}", start, end)
+
}
+

+
/// Format a git Oid.
+
pub fn oid(oid: &radicle::git::Oid) -> String {
+
    format!("{:.7}", oid)
+
}
+

+
/// Format a COB id.
+
pub fn cob(id: &ObjectId) -> String {
+
    format!("{:.11}", id.to_string())
+
}
+

+
/// Identity formatter that takes a profile and displays it as
+
/// `<node-id> (<username>)` depending on the configuration.
+
pub struct Identity<'a> {
+
    profile: &'a Profile,
+
    /// If true, node id is printed in its compact form.
+
    short: bool,
+
    /// If true, node id and username are printed using the terminal's
+
    /// styled formatters.
+
    styled: bool,
+
}
+

+
impl<'a> Identity<'a> {
+
    pub fn new(profile: &'a Profile) -> Self {
+
        Self {
+
            profile,
+
            short: false,
+
            styled: false,
+
        }
+
    }
+

+
    pub fn short(mut self) -> Self {
+
        self.short = true;
+
        self
+
    }
+

+
    pub fn styled(mut self) -> Self {
+
        self.styled = true;
+
        self
+
    }
+
}
+

+
impl<'a> fmt::Display for Identity<'a> {
+
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+
        let username = "(me)";
+
        let node_id = match self.short {
+
            true => self::node(self.profile.id()),
+
            false => self.profile.id().to_human(),
+
        };
+

+
        if self.styled {
+
            write!(
+
                f,
+
                "{} {}",
+
                term::format::highlight(node_id),
+
                term::format::dim(username)
+
            )
+
        } else {
+
            write!(f, "{} {}", node_id, username)
+
        }
+
    }
+
}
+

+
pub fn negative<D: std::fmt::Display>(msg: D) -> String {
+
    style(msg).red().bright().to_string()
+
}
+

+
pub fn positive<D: std::fmt::Display>(msg: D) -> String {
+
    style(msg).green().bright().to_string()
+
}
+

+
pub fn secondary<D: std::fmt::Display>(msg: D) -> String {
+
    style(msg).blue().bright().to_string()
+
}
+

+
pub fn tertiary<D: std::fmt::Display>(msg: D) -> String {
+
    style(msg).cyan().to_string()
+
}
+

+
pub fn tertiary_bold<D: std::fmt::Display>(msg: D) -> String {
+
    style(msg).cyan().bold().to_string()
+
}
+

+
pub fn yellow<D: std::fmt::Display>(msg: D) -> String {
+
    style(msg).yellow().to_string()
+
}
+

+
pub fn highlight<D: std::fmt::Display>(input: D) -> String {
+
    style(input).green().bright().to_string()
+
}
+

+
pub fn badge_primary<D: std::fmt::Display>(input: D) -> String {
+
    style(format!(" {} ", input))
+
        .magenta()
+
        .reverse()
+
        .to_string()
+
}
+

+
pub fn badge_positive<D: std::fmt::Display>(input: D) -> String {
+
    style(format!(" {} ", input)).green().reverse().to_string()
+
}
+

+
pub fn badge_negative<D: std::fmt::Display>(input: D) -> String {
+
    style(format!(" {} ", input)).red().reverse().to_string()
+
}
+

+
pub fn badge_secondary<D: std::fmt::Display>(input: D) -> String {
+
    style(format!(" {} ", input)).blue().reverse().to_string()
+
}
+

+
pub fn bold<D: std::fmt::Display>(input: D) -> String {
+
    style(input).white().bright().bold().to_string()
+
}
+

+
pub fn dim<D: std::fmt::Display>(input: D) -> String {
+
    style(input).dim().to_string()
+
}
+

+
pub fn italic<D: std::fmt::Display>(input: D) -> String {
+
    style(input).italic().dim().to_string()
+
}
added radicle-cli/src/terminal/io.rs
@@ -0,0 +1,382 @@
+
use std::fmt;
+
use std::str::FromStr;
+

+
use radicle::crypto::ssh::keystore::Passphrase;
+
use radicle::crypto::Signer;
+
use radicle::profile::env::RAD_PASSPHRASE;
+
use radicle::profile::Profile;
+

+
use dialoguer::{console::style, console::Style, theme::ColorfulTheme, Input, Password};
+
use radicle_crypto::ssh::keystore::MemorySigner;
+

+
use super::command;
+
use super::format;
+
use super::spinner::spinner;
+
use super::Error;
+

+
pub const TAB: &str = "   ";
+

+
#[macro_export]
+
macro_rules! info {
+
    ($($arg:tt)*) => ({
+
        println!("{}", format_args!($($arg)*));
+
    })
+
}
+

+
#[macro_export]
+
macro_rules! success {
+
    ($($arg:tt)*) => ({
+
        $crate::terminal::io::success_args(format_args!($($arg)*));
+
    })
+
}
+

+
#[macro_export]
+
macro_rules! tip {
+
    ($($arg:tt)*) => ({
+
        $crate::io::tip_args(format_args!($($arg)*));
+
    })
+
}
+

+
pub use info;
+
pub use success;
+
pub use tip;
+

+
pub fn success_args(args: fmt::Arguments) {
+
    println!("{} {}", style("ok").green().reverse(), args);
+
}
+

+
pub fn tip_args(args: fmt::Arguments) {
+
    println!(
+
        "{} {}",
+
        style("=>").blue(),
+
        style(format!("{}", args)).dim()
+
    );
+
}
+

+
pub fn width() -> usize {
+
    let (_, rows) = console::Term::stdout().size();
+
    rows as usize
+
}
+

+
pub fn headline(headline: &str) {
+
    println!();
+
    println!("{}", style(headline).bold());
+
    println!();
+
}
+

+
pub fn blob(text: impl fmt::Display) {
+
    println!("{}", style(text.to_string().trim()).dim());
+
}
+

+
pub fn blank() {
+
    println!()
+
}
+

+
pub fn print(msg: impl fmt::Display) {
+
    println!("{}", msg);
+
}
+

+
pub fn prefixed(prefix: &str, text: &str) -> String {
+
    text.split('\n')
+
        .map(|line| format!("{}{}\n", prefix, line))
+
        .collect()
+
}
+

+
pub fn help(name: &str, version: &str, description: &str, usage: &str) {
+
    println!("rad-{} {}\n{}\n{}", name, version, description, usage);
+
}
+

+
pub fn usage(name: &str, usage: &str) {
+
    eprintln!(
+
        "{} {}\n{}",
+
        style("==").red(),
+
        style(format!("Error: rad-{}: invalid usage", name)).red(),
+
        style(prefixed(TAB, usage)).red().dim()
+
    );
+
}
+

+
pub fn eprintln(prefix: impl fmt::Display, msg: impl fmt::Display) {
+
    eprintln!("{} {}", prefix, msg);
+
}
+

+
pub fn indented(msg: &str) {
+
    println!("{}{}", TAB, msg);
+
}
+

+
pub fn subcommand(msg: impl fmt::Display) {
+
    println!("{} {}", style("$").dim(), style(msg).dim());
+
}
+

+
pub fn warning(warning: &str) {
+
    eprintln!(
+
        "{} {} {}",
+
        style("**").yellow(),
+
        style("Warning:").yellow().bold(),
+
        style(warning).yellow()
+
    );
+
}
+

+
pub fn error(error: impl fmt::Display) {
+
    eprintln!("{} {}", style("==").red(), style(error).red());
+
}
+

+
pub fn fail(header: &str, error: &anyhow::Error) {
+
    let err = error.to_string();
+
    let err = err.trim_end();
+
    let separator = if err.len() > 160 || err.contains('\n') {
+
        "\n"
+
    } else {
+
        " "
+
    };
+

+
    eprintln!(
+
        "{} {}{}{}",
+
        style("==").red(),
+
        style(header).red().reverse(),
+
        separator,
+
        style(error).red().bold(),
+
    );
+

+
    let cause = error.root_cause();
+
    if cause.to_string() != error.to_string() {
+
        eprintln!(
+
            "{} {}",
+
            style("==").red().dim(),
+
            style(error.root_cause()).red().dim()
+
        );
+
        blank();
+
    }
+

+
    if let Some(Error::WithHint { hint, .. }) = error.downcast_ref::<Error>() {
+
        eprintln!("{} {}", style("==").yellow(), style(hint).yellow(),);
+
        blank();
+
    }
+
}
+

+
pub fn ask<D: fmt::Display>(prompt: D, default: bool) -> bool {
+
    dialoguer::Confirm::new()
+
        .with_prompt(format!("{} {}", style(" ⤷".to_owned()).cyan(), prompt))
+
        .wait_for_newline(false)
+
        .default(true)
+
        .default(default)
+
        .interact()
+
        .unwrap_or_default()
+
}
+

+
pub fn confirm<D: fmt::Display>(prompt: D) -> bool {
+
    ask(format::tertiary(prompt), true)
+
}
+

+
pub fn abort<D: fmt::Display>(prompt: D) -> bool {
+
    ask(format::tertiary(prompt), false)
+
}
+

+
/// Get the signer. First we try getting it from ssh-agent, otherwise we prompt the user.
+
pub fn signer(profile: &Profile) -> anyhow::Result<Box<dyn Signer>> {
+
    let signer = if let Ok(signer) = profile.signer() {
+
        signer.boxed()
+
    } else {
+
        let passphrase = secret_input();
+
        let spinner = spinner("Unsealing key...");
+
        let signer = MemorySigner::load(&profile.keystore, passphrase)?;
+

+
        spinner.finish();
+
        signer.boxed()
+
    };
+
    Ok(signer)
+
}
+

+
pub fn theme() -> ColorfulTheme {
+
    ColorfulTheme {
+
        success_prefix: style("ok".to_owned()).for_stderr().green().reverse(),
+
        prompt_prefix: style(" ⤷".to_owned()).cyan().dim().for_stderr(),
+
        prompt_suffix: style("·".to_owned()).cyan().for_stderr(),
+
        prompt_style: Style::new().cyan().bold().for_stderr(),
+
        active_item_style: Style::new().for_stderr().yellow().reverse(),
+
        active_item_prefix: style("*".to_owned()).yellow().for_stderr(),
+
        picked_item_prefix: style("*".to_owned()).yellow().for_stderr(),
+
        inactive_item_prefix: style(" ".to_string()).for_stderr(),
+
        inactive_item_style: Style::new().yellow().for_stderr(),
+
        error_prefix: style("⤹  Error:".to_owned()).red().for_stderr(),
+
        success_suffix: style("·".to_owned()).cyan().for_stderr(),
+

+
        ..ColorfulTheme::default()
+
    }
+
}
+

+
pub fn text_input<S, E>(message: &str, default: Option<S>) -> anyhow::Result<S>
+
where
+
    S: fmt::Display + std::str::FromStr<Err = E> + Clone,
+
    E: fmt::Debug + fmt::Display,
+
{
+
    let theme = theme();
+
    let mut input: Input<S> = Input::with_theme(&theme);
+

+
    let value = match default {
+
        Some(default) => input
+
            .with_prompt(message)
+
            .with_initial_text(default.to_string())
+
            .interact_text()?,
+
        None => input.with_prompt(message).interact_text()?,
+
    };
+
    Ok(value)
+
}
+

+
#[derive(Debug, Default, Clone)]
+
pub struct Optional<T> {
+
    option: Option<T>,
+
}
+

+
impl<T: fmt::Display> fmt::Display for Optional<T> {
+
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+
        if let Some(val) = &self.option {
+
            write!(f, "{}", val)
+
        } else {
+
            write!(f, "")
+
        }
+
    }
+
}
+

+
impl<T: FromStr> FromStr for Optional<T> {
+
    type Err = <T as FromStr>::Err;
+

+
    fn from_str(s: &str) -> Result<Self, Self::Err> {
+
        if s.is_empty() {
+
            return Ok(Optional { option: None });
+
        }
+
        let val: T = s.parse()?;
+

+
        Ok(Self { option: Some(val) })
+
    }
+
}
+

+
pub fn text_input_optional<S, E>(
+
    message: &str,
+
    initial: Option<String>,
+
) -> anyhow::Result<Option<S>>
+
where
+
    S: fmt::Display + fmt::Debug + FromStr<Err = E> + Clone,
+
    E: fmt::Debug + fmt::Display,
+
{
+
    let theme = theme();
+
    let mut input: Input<Optional<S>> = Input::with_theme(&theme);
+

+
    if let Some(init) = initial {
+
        input.with_initial_text(init);
+
    }
+
    let value = input
+
        .with_prompt(message)
+
        .allow_empty(true)
+
        .interact_text()?;
+

+
    Ok(value.option)
+
}
+

+
pub fn secret_input() -> Passphrase {
+
    secret_input_with_prompt("Passphrase")
+
}
+

+
// TODO: This prompt shows success just for entering a password,
+
// even if the password is later found out to be wrong.
+
// We should handle this differently.
+
pub fn secret_input_with_prompt(prompt: &str) -> Passphrase {
+
    Passphrase::from(
+
        Password::with_theme(&theme())
+
            .allow_empty_password(true)
+
            .with_prompt(prompt)
+
            .interact()
+
            .unwrap(),
+
    )
+
}
+

+
pub fn secret_input_with_confirmation() -> Passphrase {
+
    Passphrase::from(
+
        Password::with_theme(&theme())
+
            .with_prompt("Passphrase")
+
            .with_confirmation("Repeat passphrase", "Error: the passphrases don't match.")
+
            .interact()
+
            .unwrap(),
+
    )
+
}
+

+
pub fn secret_stdin() -> Result<Passphrase, anyhow::Error> {
+
    let mut input = String::new();
+
    std::io::stdin().read_line(&mut input)?;
+

+
    Ok(Passphrase::from(input.trim_end().to_owned()))
+
}
+

+
pub fn read_passphrase(stdin: bool, confirm: bool) -> Result<Passphrase, anyhow::Error> {
+
    let passphrase = match read_passphrase_from_env_var() {
+
        Ok(input) => input,
+
        _ => {
+
            if stdin {
+
                secret_stdin()?
+
            } else if confirm {
+
                secret_input_with_confirmation()
+
            } else {
+
                secret_input()
+
            }
+
        }
+
    };
+

+
    Ok(passphrase)
+
}
+

+
pub fn read_passphrase_from_env_var() -> Result<Passphrase, anyhow::Error> {
+
    let passphrase = std::env::var(RAD_PASSPHRASE)?;
+

+
    Ok(Passphrase::from(passphrase.trim_end().to_owned()))
+
}
+

+
pub fn select<'a, T>(options: &'a [T], active: &'a T) -> Option<&'a T>
+
where
+
    T: fmt::Display + Eq + PartialEq,
+
{
+
    let theme = theme();
+
    let active = options.iter().position(|o| o == active);
+
    let mut selection = dialoguer::Select::with_theme(&theme);
+

+
    if let Some(active) = active {
+
        selection.default(active);
+
    }
+
    let result = selection
+
        .items(&options.iter().map(|p| p.to_string()).collect::<Vec<_>>())
+
        .interact_opt()
+
        .unwrap();
+

+
    result.map(|i| &options[i])
+
}
+

+
pub fn select_with_prompt<'a, T>(prompt: &str, options: &'a [T], active: &'a T) -> Option<&'a T>
+
where
+
    T: fmt::Display + Eq + PartialEq,
+
{
+
    let theme = theme();
+
    let active = options.iter().position(|o| o == active);
+
    let mut selection = dialoguer::Select::with_theme(&theme);
+

+
    selection.with_prompt(prompt);
+

+
    if let Some(active) = active {
+
        selection.default(active);
+
    }
+
    let result = selection
+
        .items(&options.iter().map(|p| p.to_string()).collect::<Vec<_>>())
+
        .interact_opt()
+
        .unwrap();
+

+
    result.map(|i| &options[i])
+
}
+

+
pub fn markdown(content: &str) {
+
    if !content.is_empty() {
+
        if command::bat(["-p", "-l", "md"], content).is_err() {
+
            blob(content);
+
        }
+
    }
+
}
+

+
fn _info(args: std::fmt::Arguments) {
+
    println!("{}", args);
+
}
added radicle-cli/src/terminal/patch.rs
@@ -0,0 +1,86 @@
+
use radicle::git;
+

+
use crate::terminal as term;
+

+
/// How a comment is to be supplied by the user for a patch or issue on the terminal.
+
#[derive(Clone, Debug)]
+
pub enum Comment {
+
    /// Prompt user to write comment in editor.
+
    Edit,
+
    /// Don't leave a comment.
+
    Blank,
+
    /// Use the following string as comment.
+
    Text(String),
+
}
+

+
impl Comment {
+
    /// Get the comment as a string according to the method.
+
    pub fn get(self, help: &str) -> String {
+
        let comment = match self {
+
            Comment::Edit => term::Editor::new()
+
                .require_save(true)
+
                .trim_newlines(true)
+
                .extension(".markdown")
+
                .edit(help)
+
                .unwrap(),
+
            Comment::Blank => None,
+
            Comment::Text(c) => Some(c),
+
        };
+
        let comment = comment.unwrap_or_default().replace(help, "");
+
        let comment = comment.trim();
+

+
        comment.to_owned()
+
    }
+

+
    pub fn append(&mut self, arg: &str) {
+
        if let Comment::Text(v) = self {
+
            v.extend(["\n\n", arg]);
+
        } else {
+
            *self = Comment::Text(arg.into());
+
        };
+
    }
+
}
+

+
impl Default for Comment {
+
    fn default() -> Self {
+
        Self::Edit
+
    }
+
}
+

+
/// List the given commits in a table.
+
pub fn list_commits(commits: &[git::raw::Commit]) -> anyhow::Result<()> {
+
    let mut table = term::Table::default();
+

+
    for commit in commits {
+
        let message = commit
+
            .summary_bytes()
+
            .unwrap_or_else(|| commit.message_bytes());
+
        table.push([
+
            term::format::secondary(term::format::oid(&commit.id().into())),
+
            term::format::italic(String::from_utf8_lossy(message)),
+
        ]);
+
    }
+
    table.render();
+

+
    Ok(())
+
}
+

+
/// Print commits ahead and behind.
+
pub fn print_commits_ahead_behind(
+
    repo: &git::raw::Repository,
+
    left: git::raw::Oid,
+
    right: git::raw::Oid,
+
) -> anyhow::Result<()> {
+
    let (ahead, behind) = repo.graph_ahead_behind(left, right)?;
+

+
    term::info!(
+
        "{} commit(s) ahead, {} commit(s) behind",
+
        term::format::positive(ahead),
+
        if behind > 0 {
+
            term::format::negative(behind)
+
        } else {
+
            term::format::dim(behind)
+
        }
+
    );
+
    Ok(())
+
}
added radicle-cli/src/terminal/spinner.rs
@@ -0,0 +1,77 @@
+
use dialoguer::console::style;
+
use indicatif::{ProgressBar, ProgressFinish, ProgressStyle};
+

+
use crate::terminal as term;
+

+
pub struct Spinner {
+
    progress: ProgressBar,
+
    message: String,
+
}
+

+
impl Drop for Spinner {
+
    fn drop(&mut self) {
+
        // TODO: Set error that will be output on fail.
+
        if !self.progress.is_finished() {
+
            self.set_failed();
+
        }
+
    }
+
}
+

+
impl Spinner {
+
    pub fn finish(&self) {
+
        self.progress.finish_and_clear();
+
        term::success!("{}", &self.message);
+
    }
+

+
    pub fn done(self) {
+
        self.progress.finish_and_clear();
+
        term::info!("{}", &self.message);
+
    }
+

+
    pub fn failed(mut self) {
+
        self.set_failed();
+
    }
+

+
    pub fn error(self, err: anyhow::Error) -> anyhow::Error {
+
        self.progress.finish_and_clear();
+
        term::eprintln(style("!!").red().reverse(), style(&err).red());
+

+
        err
+
    }
+

+
    pub fn clear(self) {
+
        self.progress.finish_and_clear();
+
    }
+

+
    pub fn message(&mut self, msg: impl Into<String>) {
+
        let msg = msg.into();
+

+
        self.progress.set_message(msg.clone());
+
        self.message = msg;
+
    }
+

+
    pub fn set_failed(&mut self) {
+
        self.progress.finish_and_clear();
+
        term::eprintln(style("!!").red().reverse(), &self.message);
+
    }
+
}
+

+
pub fn spinner(message: impl ToString) -> Spinner {
+
    let message = message.to_string();
+
    let style = ProgressStyle::default_spinner()
+
        .tick_strings(&[
+
            &style("\\ ").yellow().to_string(),
+
            &style("| ").yellow().to_string(),
+
            &style("/ ").yellow().to_string(),
+
            &style("| ").yellow().to_string(),
+
        ])
+
        .template("{spinner} {msg}")
+
        .on_finish(ProgressFinish::AndClear);
+

+
    let progress = ProgressBar::new(!0);
+
    progress.set_style(style);
+
    progress.enable_steady_tick(99);
+
    progress.set_message(message.clone());
+

+
    Spinner { message, progress }
+
}
added radicle-cli/src/terminal/table.rs
@@ -0,0 +1,80 @@
+
use std::fmt::Write;
+

+
use crate::terminal as term;
+

+
#[derive(Debug, Default)]
+
pub struct TableOptions {
+
    pub overflow: bool,
+
}
+

+
#[derive(Debug)]
+
pub struct Table<const W: usize> {
+
    rows: Vec<[String; W]>,
+
    widths: [usize; W],
+
    opts: TableOptions,
+
}
+

+
impl<const W: usize> Table<W> {
+
    pub fn new(opts: TableOptions) -> Self {
+
        Self {
+
            rows: Vec::new(),
+
            widths: [0; W],
+
            opts,
+
        }
+
    }
+

+
    pub fn default() -> Self {
+
        Self {
+
            rows: Vec::new(),
+
            widths: [0; W],
+
            opts: TableOptions::default(),
+
        }
+
    }
+

+
    pub fn push(&mut self, row: [String; W]) {
+
        for (i, cell) in row.iter().enumerate() {
+
            self.widths[i] = self.widths[i].max(console::measure_text_width(cell));
+
        }
+
        self.rows.push(row);
+
    }
+

+
    pub fn render(self) {
+
        let width = term::width(); // Terminal width.
+

+
        for row in &self.rows {
+
            let mut output = String::new();
+
            let cells = row.len();
+

+
            for (i, cell) in row.iter().enumerate() {
+
                if i == cells - 1 || self.opts.overflow {
+
                    write!(output, "{}", cell).ok();
+
                } else {
+
                    write!(
+
                        output,
+
                        "{} ",
+
                        console::pad_str(cell, self.widths[i], console::Alignment::Left, None)
+
                    )
+
                    .ok();
+
                }
+
            }
+
            println!("{}", console::truncate_str(&output, width - 1, "…"));
+
        }
+
    }
+

+
    pub fn render_tree(self) {
+
        for (r, row) in self.rows.iter().enumerate() {
+
            if r != self.rows.len() - 1 {
+
                print!("├── ");
+
            } else {
+
                print!("└── ");
+
            }
+
            for (i, cell) in row.iter().enumerate() {
+
                print!(
+
                    "{} ",
+
                    console::pad_str(cell, self.widths[i], console::Alignment::Left, None)
+
                );
+
            }
+
            println!();
+
        }
+
    }
+
}
added radicle-cli/src/terminal/textbox.rs
@@ -0,0 +1,74 @@
+
use std::fmt;
+

+
use crate::terminal as term;
+

+
pub struct TextBox {
+
    pub body: String,
+
    first: bool,
+
    last: bool,
+
}
+

+
impl TextBox {
+
    pub fn new(body: String) -> Self {
+
        Self {
+
            body,
+
            first: true,
+
            last: true,
+
        }
+
    }
+

+
    /// Is this text box the last one in the list?
+
    pub fn last(mut self, connect: bool) -> Self {
+
        self.last = connect;
+
        self
+
    }
+

+
    /// Is this text box the first one in the list?
+
    pub fn first(mut self, connect: bool) -> Self {
+
        self.first = connect;
+
        self
+
    }
+
}
+

+
impl fmt::Display for TextBox {
+
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+
        let mut width = self
+
            .body
+
            .lines()
+
            .map(console::measure_text_width)
+
            .max()
+
            .unwrap_or(0)
+
            + 2;
+
        if term::width() < width + 2 {
+
            width = term::width() - 2
+
        }
+

+
        let (connector, header_width) = if !self.first {
+
            ("┴", width - 1)
+
        } else {
+
            ("", width)
+
        };
+
        writeln!(f, "┌{}{}┐", connector, "─".repeat(header_width))?;
+

+
        for l in self.body.lines() {
+
            writeln!(
+
                f,
+
                "│ {}│",
+
                console::pad_str(l, width - 1, console::Alignment::Left, Some("…"))
+
            )?;
+
        }
+

+
        let (connector, footer_width) = if !self.last {
+
            ("┬", width - 1)
+
        } else {
+
            ("", width)
+
        };
+

+
        writeln!(f, "└{}{}┘", connector, "─".repeat(footer_width))?;
+

+
        if !self.last {
+
            writeln!(f, " │")?;
+
        }
+
        Ok(())
+
    }
+
}
modified radicle-node/src/service.rs
@@ -35,6 +35,7 @@ use crate::service::message::{NodeAnnouncement, RefsAnnouncement};
use crate::storage;
use crate::storage::{Inventory, ReadRepository, RefUpdate, WriteRepository, WriteStorage};

+
pub use crate::node::NodeId;
pub use crate::service::config::{Config, Network};
pub use crate::service::message::{Message, ZeroBytes};
pub use crate::service::session::Session;
@@ -66,9 +67,6 @@ pub const MAX_TIME_DELTA: LocalDuration = LocalDuration::from_mins(60);
/// Maximum attempts to connect to a peer before we give up.
pub const MAX_CONNECTION_ATTEMPTS: usize = 3;

-
/// Network node identifier.
-
pub type NodeId = crypto::PublicKey;
-

/// A service event.
#[derive(Debug, Clone)]
pub enum Event {
modified radicle/src/node.rs
@@ -6,6 +6,7 @@ use std::io::{BufRead, BufReader, Write};
use std::os::unix::net::UnixStream;
use std::path::Path;

+
use crate::crypto::PublicKey;
use crate::identity::Id;

pub use features::Features;
@@ -33,6 +34,9 @@ pub trait Handle {
    fn shutdown(self) -> Result<(), Error>;
}

+
/// Public node & device identifier.
+
pub type NodeId = PublicKey;
+

/// Node controller.
#[derive(Debug)]
pub struct Node {