term: Remove dependency on `anyhow`
As a library crate, it is bad to return such generic errors. To get there, errors from `inquire` are downcasted. This allowed to clean up the public interface of `radicle-term`, so that it does also not leak the `inquire::InquireError` in its public API.
This commit is contained in:
parent
edd88a59bf
commit
2127782b74
|
|
@ -2991,7 +2991,6 @@ name = "radicle-term"
|
||||||
version = "0.15.0"
|
version = "0.15.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anstyle-query",
|
"anstyle-query",
|
||||||
"anyhow",
|
|
||||||
"crossbeam-channel",
|
"crossbeam-channel",
|
||||||
"crossterm 0.29.0",
|
"crossterm 0.29.0",
|
||||||
"git2",
|
"git2",
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,6 @@ rust-version = "1.81.0"
|
||||||
|
|
||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
amplify = { version = "4.0.0", default-features = false }
|
amplify = { version = "4.0.0", default-features = false }
|
||||||
anyhow = "1"
|
|
||||||
bstr = "1.3"
|
bstr = "1.3"
|
||||||
bytes = "1"
|
bytes = "1"
|
||||||
chrono = { version = "0.4.26", default-features = false }
|
chrono = { version = "0.4.26", default-features = false }
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ name = "rad"
|
||||||
path = "src/main.rs"
|
path = "src/main.rs"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
anyhow = { workspace = true }
|
anyhow = "1"
|
||||||
chrono = { workspace = true, features = ["clock", "std"] }
|
chrono = { workspace = true, features = ["clock", "std"] }
|
||||||
dunce = { workspace = true }
|
dunce = { workspace = true }
|
||||||
git-ref-format = { version = "0.3.0", features = ["macro"] }
|
git-ref-format = { version = "0.3.0", features = ["macro"] }
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
#![allow(clippy::or_fun_call)]
|
#![allow(clippy::or_fun_call)]
|
||||||
use std::ffi::OsString;
|
use std::ffi::OsString;
|
||||||
use std::ops::Not as _;
|
|
||||||
use std::str::FromStr;
|
use std::str::FromStr;
|
||||||
|
|
||||||
use anyhow::{anyhow, Context};
|
use anyhow::{anyhow, Context};
|
||||||
|
|
@ -90,26 +89,28 @@ pub fn init(options: Options) -> anyhow::Result<()> {
|
||||||
term::blank();
|
term::blank();
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
anyhow::bail!("a Git installation is required for Radicle to run");
|
anyhow::bail!("A Git installation is required for Radicle to run.");
|
||||||
}
|
}
|
||||||
|
|
||||||
let alias: Alias = if let Some(alias) = options.alias {
|
let alias: Alias = if let Some(alias) = options.alias {
|
||||||
alias
|
alias
|
||||||
} else {
|
} else {
|
||||||
let user = env::var("USER").ok().and_then(|u| Alias::from_str(&u).ok());
|
let user = env::var("USER").ok().and_then(|u| Alias::from_str(&u).ok());
|
||||||
term::input(
|
let user = term::input(
|
||||||
"Enter your alias:",
|
"Enter your alias:",
|
||||||
user,
|
user,
|
||||||
Some("This is your node alias. You can always change it later"),
|
Some("This is your node alias. You can always change it later"),
|
||||||
)?
|
)?;
|
||||||
|
|
||||||
|
user.ok_or_else(|| anyhow::anyhow!("An alias is required for Radicle to run."))?
|
||||||
};
|
};
|
||||||
let home = profile::home()?;
|
let home = profile::home()?;
|
||||||
let passphrase = if options.stdin {
|
let passphrase = if options.stdin {
|
||||||
term::passphrase_stdin()
|
Some(term::passphrase_stdin()?)
|
||||||
} else {
|
} else {
|
||||||
term::passphrase_confirm("Enter a passphrase:", env::RAD_PASSPHRASE)
|
term::passphrase_confirm("Enter a passphrase:", env::RAD_PASSPHRASE)?
|
||||||
}?;
|
};
|
||||||
let passphrase = passphrase.trim().is_empty().not().then_some(passphrase);
|
let passphrase = passphrase.filter(|passphrase| !passphrase.trim().is_empty());
|
||||||
let spinner = term::spinner("Creating your Ed25519 keypair...");
|
let spinner = term::spinner("Creating your Ed25519 keypair...");
|
||||||
let profile = Profile::init(home, alias, passphrase.clone(), env::seed())?;
|
let profile = Profile::init(home, alias, passphrase.clone(), env::seed())?;
|
||||||
let mut agent = true;
|
let mut agent = true;
|
||||||
|
|
@ -187,8 +188,14 @@ pub fn authenticate(options: Options, profile: &Profile) -> anyhow::Result<()> {
|
||||||
phrase
|
phrase
|
||||||
} else if options.stdin {
|
} else if options.stdin {
|
||||||
term::passphrase_stdin()?
|
term::passphrase_stdin()?
|
||||||
} else {
|
} else if let Some(passphrase) =
|
||||||
term::io::passphrase(term::io::PassphraseValidator::new(profile.keystore.clone()))?
|
term::io::passphrase(term::io::PassphraseValidator::new(profile.keystore.clone()))?
|
||||||
|
{
|
||||||
|
passphrase
|
||||||
|
} else {
|
||||||
|
anyhow::bail!(
|
||||||
|
"A passphrase is required to read your Radicle key. Unable to continue."
|
||||||
|
)
|
||||||
};
|
};
|
||||||
register(&mut agent, profile, passphrase)?;
|
register(&mut agent, profile, passphrase)?;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -243,18 +243,24 @@ pub fn init(
|
||||||
let name: ProjectName = match options.name {
|
let name: ProjectName = match options.name {
|
||||||
Some(name) => name,
|
Some(name) => name,
|
||||||
None => {
|
None => {
|
||||||
let default = path.file_name().map(|f| f.to_string_lossy().to_string());
|
let default = path
|
||||||
term::input(
|
.file_name()
|
||||||
|
.and_then(|f| f.to_str())
|
||||||
|
.and_then(|f| ProjectName::try_from(f).ok());
|
||||||
|
let name = term::input(
|
||||||
"Name",
|
"Name",
|
||||||
default,
|
default,
|
||||||
Some("The name of your repository, eg. 'acme'"),
|
Some("The name of your repository, eg. 'acme'"),
|
||||||
)?
|
)?;
|
||||||
.try_into()?
|
|
||||||
|
name.ok_or_else(|| anyhow::anyhow!("A project name is required."))?
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let description = match options.description {
|
let description = match options.description {
|
||||||
Some(desc) => desc,
|
Some(desc) => desc,
|
||||||
None => term::input("Description", None, Some("You may leave this blank"))?,
|
None => {
|
||||||
|
term::input("Description", None, Some("You may leave this blank"))?.unwrap_or_default()
|
||||||
|
}
|
||||||
};
|
};
|
||||||
let branch = match options.branch {
|
let branch = match options.branch {
|
||||||
Some(branch) => branch,
|
Some(branch) => branch,
|
||||||
|
|
@ -262,7 +268,8 @@ pub fn init(
|
||||||
"Default branch",
|
"Default branch",
|
||||||
Some(default_branch),
|
Some(default_branch),
|
||||||
Some("Please specify an existing branch"),
|
Some("Please specify an existing branch"),
|
||||||
)?,
|
)?
|
||||||
|
.unwrap_or_default(),
|
||||||
None => default_branch,
|
None => default_branch,
|
||||||
};
|
};
|
||||||
let branch = RefString::try_from(branch.clone())
|
let branch = RefString::try_from(branch.clone())
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ use localtime::LocalTime;
|
||||||
|
|
||||||
use radicle::node;
|
use radicle::node;
|
||||||
use radicle::node::{Address, ConnectResult, Handle as _, NodeId};
|
use radicle::node::{Address, ConnectResult, Handle as _, NodeId};
|
||||||
|
use radicle::profile::env::RAD_PASSPHRASE;
|
||||||
use radicle::Node;
|
use radicle::Node;
|
||||||
use radicle::{profile, Profile};
|
use radicle::{profile, Profile};
|
||||||
|
|
||||||
|
|
@ -39,10 +40,12 @@ pub fn start(
|
||||||
let validator = term::io::PassphraseValidator::new(profile.keystore.clone());
|
let validator = term::io::PassphraseValidator::new(profile.keystore.clone());
|
||||||
let passphrase = if let Some(phrase) = profile::env::passphrase() {
|
let passphrase = if let Some(phrase) = profile::env::passphrase() {
|
||||||
phrase
|
phrase
|
||||||
} else if let Ok(phrase) = term::io::passphrase(validator) {
|
} else if let Some(phrase) = term::io::passphrase(validator)? {
|
||||||
phrase
|
phrase
|
||||||
} else {
|
} else {
|
||||||
anyhow::bail!("your radicle passphrase is required to start your node");
|
anyhow::bail!(
|
||||||
|
"A passphrase is required to read your Radicle key in order to start the node. Unable to continue. Consider setting the environment variable `{RAD_PASSPHRASE}`."
|
||||||
|
);
|
||||||
};
|
};
|
||||||
Some((profile::env::RAD_PASSPHRASE, passphrase))
|
Some((profile::env::RAD_PASSPHRASE, passphrase))
|
||||||
} else {
|
} else {
|
||||||
|
|
|
||||||
|
|
@ -49,14 +49,13 @@ pub fn signer(profile: &Profile) -> anyhow::Result<BoxedDevice> {
|
||||||
return Ok(signer);
|
return Ok(signer);
|
||||||
}
|
}
|
||||||
let validator = PassphraseValidator::new(profile.keystore.clone());
|
let validator = PassphraseValidator::new(profile.keystore.clone());
|
||||||
let passphrase = match passphrase(validator) {
|
let passphrase = match passphrase(validator)? {
|
||||||
Ok(p) => p,
|
Some(p) => p,
|
||||||
Err(inquire::InquireError::NotTTY) => {
|
None => {
|
||||||
anyhow::bail!(
|
anyhow::bail!(
|
||||||
"running in non-interactive mode, please set `{RAD_PASSPHRASE}` to unseal your key",
|
"A passphrase is required to read your Radicle key. Unable to continue. Consider setting the environment variable `{RAD_PASSPHRASE}`.",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Err(e) => return Err(e.into()),
|
|
||||||
};
|
};
|
||||||
let spinner = spinner("Unsealing key...");
|
let spinner = spinner("Unsealing key...");
|
||||||
let signer = MemorySigner::load(&profile.keystore, Some(passphrase))?;
|
let signer = MemorySigner::load(&profile.keystore, Some(passphrase))?;
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,6 @@ rust-version.workspace = true
|
||||||
default = ["git2"]
|
default = ["git2"]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
anyhow = { workspace = true }
|
|
||||||
anstyle-query = "1.0.0"
|
anstyle-query = "1.0.0"
|
||||||
crossterm = "0.29.0"
|
crossterm = "0.29.0"
|
||||||
inquire = { version = "0.7.4", default-features = false, features = ["crossterm", "editor"] }
|
inquire = { version = "0.7.4", default-features = false, features = ["crossterm", "editor"] }
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ use inquire::ui::{ErrorMessageRenderConfig, StyleSheet, Styled};
|
||||||
use inquire::validator;
|
use inquire::validator;
|
||||||
use inquire::InquireError;
|
use inquire::InquireError;
|
||||||
use inquire::{ui::Color, ui::RenderConfig, Confirm, CustomType, Password};
|
use inquire::{ui::Color, ui::RenderConfig, Confirm, CustomType, Password};
|
||||||
|
use thiserror::Error;
|
||||||
use zeroize::Zeroizing;
|
use zeroize::Zeroizing;
|
||||||
|
|
||||||
use crate::format;
|
use crate::format;
|
||||||
|
|
@ -223,54 +224,130 @@ pub fn abort<D: fmt::Display>(prompt: D) -> bool {
|
||||||
ask(prompt, false)
|
ask(prompt, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn input<S, E>(message: &str, default: Option<S>, help: Option<&str>) -> anyhow::Result<S>
|
#[non_exhaustive]
|
||||||
|
#[derive(Error, Debug)]
|
||||||
|
pub enum InputError<Custom> {
|
||||||
|
#[error(transparent)]
|
||||||
|
Io(#[from] io::Error),
|
||||||
|
|
||||||
|
#[error(transparent)]
|
||||||
|
Custom(Custom),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<InputError<std::convert::Infallible>> for io::Error {
|
||||||
|
fn from(val: InputError<std::convert::Infallible>) -> Self {
|
||||||
|
match val {
|
||||||
|
InputError::Io(err) => err,
|
||||||
|
InputError::Custom(_) => unreachable!("infallible cannot be constructed"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Prompts the user for input. If the user cancels the operation,
|
||||||
|
/// the operation is interrupted, or no suitable terminal is found,
|
||||||
|
/// then `Ok(None)` is returned.
|
||||||
|
pub fn input<T, E>(
|
||||||
|
message: &str,
|
||||||
|
default: Option<T>,
|
||||||
|
help: Option<&str>,
|
||||||
|
) -> Result<Option<T>, InputError<E>>
|
||||||
where
|
where
|
||||||
S: fmt::Display + std::str::FromStr<Err = E> + Clone,
|
T: fmt::Display + std::str::FromStr<Err = E> + Clone,
|
||||||
E: fmt::Debug + fmt::Display,
|
E: std::error::Error + Send + Sync + 'static,
|
||||||
{
|
{
|
||||||
let mut input = CustomType::<S>::new(message).with_render_config(*CONFIG);
|
let mut input = CustomType::<T>::new(message).with_render_config(*CONFIG);
|
||||||
|
|
||||||
input.default = default;
|
input.default = default;
|
||||||
input.help_message = help;
|
input.help_message = help;
|
||||||
|
|
||||||
let value = input.prompt()?;
|
match input.prompt() {
|
||||||
|
Ok(value) => Ok(Some(value)),
|
||||||
|
Err(err) => handle_inquire_error(err),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Ok(value)
|
/// If the [`InquireError`] value is one of the variants:
|
||||||
|
/// [`InquireError::OperationCanceled`], [`InquireError::OperationInterrupted`],
|
||||||
|
/// [`InquireError::NotTTY`], then the returned result is `None` – note that no
|
||||||
|
/// `Some` value is returned.
|
||||||
|
///
|
||||||
|
/// Otherwise, the error is converted into our own domain error: [`InputError`].
|
||||||
|
fn handle_inquire_error<T, E>(error: InquireError) -> Result<Option<T>, InputError<E>>
|
||||||
|
where
|
||||||
|
E: std::error::Error + Send + Sync + 'static,
|
||||||
|
{
|
||||||
|
use InquireError::*;
|
||||||
|
|
||||||
|
let inner = match error {
|
||||||
|
OperationCanceled | OperationInterrupted | NotTTY => None,
|
||||||
|
InvalidConfiguration(err) => {
|
||||||
|
// This case not reachable, as long as the configuration passed
|
||||||
|
// to `prompt` is valid.
|
||||||
|
// The configuration is *mostly* taken from `CONFIG`,
|
||||||
|
// except for the added `CustomType` being prompted for.
|
||||||
|
// We demand that these must not depend on user input in
|
||||||
|
// a way that makes the configuration invalid.
|
||||||
|
// If this is the case, `CONFIG` should be reassessed, or
|
||||||
|
// the caller must control their input for the `CustomType`
|
||||||
|
// better. In any case, such errors are not recoverable,
|
||||||
|
// and certainly the user cannot do anything in that
|
||||||
|
// situation. Their input should not affect the config,
|
||||||
|
// that's the whole idea!
|
||||||
|
panic!("{err}")
|
||||||
|
}
|
||||||
|
IO(err) => Some(InputError::Io(err)),
|
||||||
|
Custom(err) => {
|
||||||
|
match err.downcast::<E>() {
|
||||||
|
Ok(err) => Some(InputError::Custom(*err)),
|
||||||
|
Err(err) => {
|
||||||
|
// `inquire` guarantees that we do not end up here:
|
||||||
|
// https://github.com/mikaelmello/inquire/blob/4ac91f3e1fc8b29fc17845f9204ea1d1f9e335aa/README.md?plain=1#L109
|
||||||
|
panic!("inquire returned an unexpected error: {err:?}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
match inner {
|
||||||
|
Some(err) => Err(err),
|
||||||
|
None => Ok(None),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn passphrase<V: validator::StringValidator + 'static>(
|
pub fn passphrase<V: validator::StringValidator + 'static>(
|
||||||
validate: V,
|
validate: V,
|
||||||
) -> Result<Passphrase, inquire::InquireError> {
|
) -> io::Result<Option<Passphrase>> {
|
||||||
Ok(Passphrase::from(
|
match Password::new("Passphrase:")
|
||||||
Password::new("Passphrase:")
|
|
||||||
.with_render_config(*CONFIG)
|
.with_render_config(*CONFIG)
|
||||||
.with_display_mode(inquire::PasswordDisplayMode::Masked)
|
.with_display_mode(inquire::PasswordDisplayMode::Masked)
|
||||||
.without_confirmation()
|
.without_confirmation()
|
||||||
.with_validator(validate)
|
.with_validator(validate)
|
||||||
.prompt()?,
|
.prompt()
|
||||||
))
|
{
|
||||||
|
Ok(p) => Ok(Some(Passphrase::from(p))),
|
||||||
|
Err(err) => handle_inquire_error(err).map_err(InputError::into),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn passphrase_confirm<K: AsRef<OsStr>>(
|
pub fn passphrase_confirm<K: AsRef<OsStr>>(prompt: &str, var: K) -> io::Result<Option<Passphrase>> {
|
||||||
prompt: &str,
|
|
||||||
var: K,
|
|
||||||
) -> Result<Passphrase, anyhow::Error> {
|
|
||||||
if let Ok(p) = env::var(var) {
|
if let Ok(p) = env::var(var) {
|
||||||
Ok(Passphrase::from(p))
|
return Ok(Some(Passphrase::from(p)));
|
||||||
} else {
|
}
|
||||||
Ok(Passphrase::from(
|
|
||||||
Password::new(prompt)
|
match Password::new(prompt)
|
||||||
.with_render_config(*CONFIG)
|
.with_render_config(*CONFIG)
|
||||||
.with_display_mode(inquire::PasswordDisplayMode::Masked)
|
.with_display_mode(inquire::PasswordDisplayMode::Masked)
|
||||||
.with_custom_confirmation_message("Repeat passphrase:")
|
.with_custom_confirmation_message("Repeat passphrase:")
|
||||||
.with_custom_confirmation_error_message("The passphrases don't match.")
|
.with_custom_confirmation_error_message("The passphrases don't match.")
|
||||||
.with_help_message("Leave this blank to keep your radicle key unencrypted")
|
.with_help_message("Leave this blank to keep your radicle key unencrypted")
|
||||||
.prompt()?,
|
.prompt()
|
||||||
))
|
{
|
||||||
|
Ok(p) => Ok(Some(Passphrase::from(p))),
|
||||||
|
Err(err) => handle_inquire_error(err).map_err(InputError::into),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn passphrase_stdin() -> Result<Passphrase, anyhow::Error> {
|
pub fn passphrase_stdin() -> io::Result<Passphrase> {
|
||||||
let mut input = String::new();
|
let mut input = String::new();
|
||||||
std::io::stdin().read_line(&mut input)?;
|
std::io::stdin().read_line(&mut input)?;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,12 @@ pub enum ProjectError {
|
||||||
#[serde(try_from = "String", into = "String")]
|
#[serde(try_from = "String", into = "String")]
|
||||||
pub struct ProjectName(String);
|
pub struct ProjectName(String);
|
||||||
|
|
||||||
|
impl std::fmt::Display for ProjectName {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
||||||
|
self.0.fmt(f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl From<ProjectName> for String {
|
impl From<ProjectName> for String {
|
||||||
fn from(value: ProjectName) -> Self {
|
fn from(value: ProjectName) -> Self {
|
||||||
value.0
|
value.0
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue