shared(prompts): fail on no TTY if interactivity was needed

Fixes #98
pull/121/head
Jake McGinty 2021-06-14 15:52:15 +09:00
parent 449b4b8278
commit 72ef070ef3
5 changed files with 120 additions and 135 deletions

1
Cargo.lock generated
View File

@ -982,6 +982,7 @@ name = "shared"
version = "1.3.1" version = "1.3.1"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"atty",
"colored", "colored",
"dialoguer", "dialoguer",
"indoc", "indoc",

View File

@ -1,4 +1,12 @@
use std::{collections::HashMap, fmt, fs::OpenOptions, io::{self, BufRead, BufReader, ErrorKind, Write}, net::IpAddr, path::{Path, PathBuf}, result}; use std::{
collections::HashMap,
fmt,
fs::OpenOptions,
io::{self, BufRead, BufReader, ErrorKind, Write},
net::IpAddr,
path::{Path, PathBuf},
result,
};
pub type Result<T> = result::Result<T, Box<dyn std::error::Error>>; pub type Result<T> = result::Result<T, Box<dyn std::error::Error>>;

View File

@ -8,6 +8,7 @@ version = "1.3.1"
[dependencies] [dependencies]
anyhow = "1" anyhow = "1"
atty = "0.2"
colored = "2.0" colored = "2.0"
dialoguer = "0.8" dialoguer = "0.8"
indoc = "1" indoc = "1"

View File

@ -9,13 +9,65 @@ use dialoguer::{theme::ColorfulTheme, Confirm, Input, Select};
use ipnetwork::IpNetwork; use ipnetwork::IpNetwork;
use lazy_static::lazy_static; use lazy_static::lazy_static;
use publicip::Preference; use publicip::Preference;
use std::{net::SocketAddr, time::SystemTime}; use std::{
fmt::{Debug, Display},
io,
net::SocketAddr,
str::FromStr,
time::SystemTime,
};
use wgctrl::{InterfaceName, KeyPair}; use wgctrl::{InterfaceName, KeyPair};
lazy_static! { lazy_static! {
pub static ref THEME: ColorfulTheme = ColorfulTheme::default(); pub static ref THEME: ColorfulTheme = ColorfulTheme::default();
} }
pub fn ensure_interactive(prompt: &str) -> Result<(), io::Error> {
if atty::is(atty::Stream::Stdin) {
Ok(())
} else {
Err(io::Error::new(io::ErrorKind::BrokenPipe, format!("Prompt \"{}\" failed because TTY isn't connected.", prompt)))
}
}
pub fn confirm(prompt: &str) -> Result<bool, io::Error> {
ensure_interactive(prompt)?;
Confirm::with_theme(&*THEME)
.wait_for_newline(true)
.with_prompt(prompt)
.default(false)
.interact()
}
pub fn select<'a, T: ToString>(prompt: &str, items: &'a [T]) -> Result<(usize, &'a T), io::Error> {
ensure_interactive(prompt)?;
let choice = Select::with_theme(&*THEME)
.with_prompt(prompt)
.items(items)
.interact()?;
Ok((choice, &items[choice]))
}
pub enum Prefill<T> {
Default(T),
Editable(String),
None
}
pub fn input<T>(prompt: &str, prefill: Prefill<T>) -> Result<T, io::Error>
where
T: Clone + FromStr + Display,
T::Err: Display + Debug,
{
ensure_interactive(prompt)?;
let mut input = Input::with_theme(&*THEME);
match prefill {
Prefill::Default(value) => input.default(value),
Prefill::Editable(value) => input.with_initial_text(value),
_ => &mut input
}.with_prompt(prompt).interact()
}
/// Bring up a prompt to create a new CIDR. Returns the peer request. /// Bring up a prompt to create a new CIDR. Returns the peer request.
pub fn add_cidr(cidrs: &[Cidr], request: &AddCidrOpts) -> Result<Option<CidrContents>, Error> { pub fn add_cidr(cidrs: &[Cidr], request: &AddCidrOpts) -> Result<Option<CidrContents>, Error> {
let parent_cidr = if let Some(ref parent_name) = request.parent { let parent_cidr = if let Some(ref parent_name) = request.parent {
@ -30,13 +82,13 @@ pub fn add_cidr(cidrs: &[Cidr], request: &AddCidrOpts) -> Result<Option<CidrCont
let name = if let Some(ref name) = request.name { let name = if let Some(ref name) = request.name {
name.clone() name.clone()
} else { } else {
Input::with_theme(&*THEME).with_prompt("Name").interact()? input("Name", Prefill::None)?
}; };
let cidr = if let Some(cidr) = request.cidr { let cidr = if let Some(cidr) = request.cidr {
cidr cidr
} else { } else {
Input::with_theme(&*THEME).with_prompt("CIDR").interact()? input("CIDR", Prefill::None)?
}; };
let cidr_request = CidrContents { let cidr_request = CidrContents {
@ -46,13 +98,7 @@ pub fn add_cidr(cidrs: &[Cidr], request: &AddCidrOpts) -> Result<Option<CidrCont
}; };
Ok( Ok(
if request.yes if request.yes || confirm(&format!("Create CIDR \"{}\"?", cidr_request.name))? {
|| Confirm::with_theme(&*THEME)
.wait_for_newline(true)
.with_prompt(&format!("Create CIDR \"{}\"?", cidr_request.name))
.default(false)
.interact()?
{
Some(cidr_request) Some(cidr_request)
} else { } else {
None None
@ -77,20 +123,10 @@ pub fn delete_cidr(cidrs: &[Cidr], peers: &[Peer], request: &DeleteCidrOpts) ->
.find(|cidr| &cidr.name == name) .find(|cidr| &cidr.name == name)
.ok_or_else(|| anyhow!("CIDR {} doesn't exist or isn't eligible for deletion", name))? .ok_or_else(|| anyhow!("CIDR {} doesn't exist or isn't eligible for deletion", name))?
} else { } else {
let cidr_index = Select::with_theme(&*THEME) select("Delete CIDR", &eligible_cidrs)?.1
.with_prompt("Delete CIDR")
.items(&eligible_cidrs)
.interact()?;
&eligible_cidrs[cidr_index]
}; };
if request.yes if request.yes || confirm(&format!("Delete CIDR \"{}\"?", cidr.name))? {
|| Confirm::with_theme(&*THEME)
.wait_for_newline(true)
.with_prompt(&format!("Delete CIDR \"{}\"?", cidr.name))
.default(false)
.interact()?
{
Ok(cidr.id) Ok(cidr.id)
} else { } else {
Err(anyhow!("Canceled")) Err(anyhow!("Canceled"))
@ -102,11 +138,7 @@ pub fn choose_cidr<'a>(cidrs: &'a [Cidr], text: &'static str) -> Result<&'a Cidr
.iter() .iter()
.filter(|cidr| cidr.name != "innernet-server") .filter(|cidr| cidr.name != "innernet-server")
.collect(); .collect();
let cidr_index = Select::with_theme(&*THEME) Ok(select(text, &eligible_cidrs)?.1)
.with_prompt(text)
.items(&eligible_cidrs)
.interact()?;
Ok(&eligible_cidrs[cidr_index])
} }
pub fn choose_association<'a>( pub fn choose_association<'a>(
@ -132,10 +164,7 @@ pub fn choose_association<'a>(
) )
}) })
.collect(); .collect();
let index = Select::with_theme(&*THEME) let (index, _) = select("Association", &names)?;
.with_prompt("Association")
.items(&names)
.interact()?;
Ok(&associations[index]) Ok(&associations[index])
} }
@ -145,16 +174,11 @@ pub fn add_association(cidrs: &[Cidr]) -> Result<Option<(&Cidr, &Cidr)>, Error>
let cidr2 = choose_cidr(cidrs, "Second CIDR")?; let cidr2 = choose_cidr(cidrs, "Second CIDR")?;
Ok( Ok(
if Confirm::with_theme(&*THEME) if confirm(&format!(
.wait_for_newline(true) "Add association: {} <=> {}?",
.with_prompt(&format!( cidr1.name.yellow().bold(),
"Add association: {} <=> {}?", cidr2.name.yellow().bold()
cidr1.name.yellow().bold(), ))? {
cidr2.name.yellow().bold()
))
.default(false)
.interact()?
{
Some((cidr1, cidr2)) Some((cidr1, cidr2))
} else { } else {
None None
@ -169,12 +193,7 @@ pub fn delete_association<'a>(
let association = choose_association(associations, cidrs)?; let association = choose_association(associations, cidrs)?;
Ok( Ok(
if Confirm::with_theme(&*THEME) if confirm(&format!("Delete association #{}?", association.id))? {
.wait_for_newline(true)
.with_prompt(&format!("Delete association #{}?", association.id))
.default(false)
.interact()?
{
Some(association) Some(association)
} else { } else {
None None
@ -215,35 +234,25 @@ pub fn add_peer(
} else if args.auto_ip { } else if args.auto_ip {
available_ip available_ip
} else { } else {
Input::with_theme(&*THEME) input("IP", Prefill::Default(available_ip))?
.with_prompt("IP")
.default(available_ip)
.interact()?
}; };
let name = if let Some(ref name) = args.name { let name = if let Some(ref name) = args.name {
name.clone() name.clone()
} else { } else {
Input::with_theme(&*THEME).with_prompt("Name").interact()? input("Name", Prefill::None)?
}; };
let is_admin = if let Some(is_admin) = args.admin { let is_admin = if let Some(is_admin) = args.admin {
is_admin is_admin
} else { } else {
Confirm::with_theme(&*THEME) confirm(&format!("Make {} an admin?", name))?
.wait_for_newline(true)
.with_prompt(&format!("Make {} an admin?", name))
.default(false)
.interact()?
}; };
let invite_expires = if let Some(ref invite_expires) = args.invite_expires { let invite_expires = if let Some(ref invite_expires) = args.invite_expires {
invite_expires.clone() invite_expires.clone()
} else { } else {
Input::with_theme(&*THEME) input("Invite expires after", Prefill::Default("14d".parse().map_err(|s: &str| anyhow!(s))?))?
.with_prompt("Invite expires after")
.default("14d".parse().map_err(|s: &str| anyhow!(s))?)
.interact()?
}; };
let default_keypair = KeyPair::generate(); let default_keypair = KeyPair::generate();
@ -261,13 +270,7 @@ pub fn add_peer(
}; };
Ok( Ok(
if args.yes if args.yes || confirm(&format!("Create peer {}?", peer_request.name.yellow()))? {
|| Confirm::with_theme(&*THEME)
.wait_for_newline(true)
.with_prompt(&format!("Create peer {}?", peer_request.name.yellow()))
.default(false)
.interact()?
{
Some((peer_request, default_keypair)) Some((peer_request, default_keypair))
} else { } else {
None None
@ -291,24 +294,20 @@ pub fn rename_peer(
.ok_or_else(|| anyhow!("Peer '{}' does not exist", name))? .ok_or_else(|| anyhow!("Peer '{}' does not exist", name))?
.clone() .clone()
} else { } else {
let peer_index = Select::with_theme(&*THEME) let (peer_index, _) = select(
.with_prompt("Peer to rename") "Peer to rename",
.items( &eligible_peers
&eligible_peers .iter()
.iter() .map(|ep| ep.name.clone())
.map(|ep| ep.name.clone()) .collect::<Vec<_>>(),
.collect::<Vec<_>>(), )?;
)
.interact()?;
eligible_peers[peer_index].clone() eligible_peers[peer_index].clone()
}; };
let old_name = old_peer.name.clone(); let old_name = old_peer.name.clone();
let new_name = if let Some(ref name) = args.new_name { let new_name = if let Some(ref name) = args.new_name {
name.clone() name.clone()
} else { } else {
Input::with_theme(&*THEME) input("New Name", Prefill::None)?
.with_prompt("New Name")
.interact()?
}; };
let mut new_peer = old_peer; let mut new_peer = old_peer;
@ -316,14 +315,11 @@ pub fn rename_peer(
Ok( Ok(
if args.yes if args.yes
|| Confirm::with_theme(&*THEME) || confirm(&format!(
.with_prompt(&format!( "Rename peer {} to {}?",
"Rename peer {} to {}?", old_name.yellow(),
old_name.yellow(), new_name.yellow()
new_name.yellow() ))?
))
.default(false)
.interact()?
{ {
Some((new_peer.contents, old_name)) Some((new_peer.contents, old_name))
} else { } else {
@ -344,26 +340,18 @@ pub fn enable_or_disable_peer(peers: &[Peer], enable: bool) -> Result<Option<Pee
.iter() .iter()
.map(|peer| format!("{} ({})", &peer.name, &peer.ip)) .map(|peer| format!("{} ({})", &peer.name, &peer.ip))
.collect(); .collect();
let index = Select::with_theme(&*THEME) let (index, _) = select(
.with_prompt(&format!( &format!("Peer to {}able", if enable { "en" } else { "dis" }),
"Peer to {}able", &peer_selection,
if enable { "en" } else { "dis" } )?;
))
.items(&peer_selection)
.interact()?;
let peer = enabled_peers[index]; let peer = enabled_peers[index];
Ok( Ok(
if Confirm::with_theme(&*THEME) if confirm(&format!(
.wait_for_newline(true) "{}able peer {}?",
.with_prompt(&format!( if enable { "En" } else { "Dis" },
"{}able peer {}?", peer.name.yellow()
if enable { "En" } else { "Dis" }, ))? {
peer.name.yellow()
))
.default(false)
.interact()?
{
Some(peer.clone()) Some(peer.clone())
} else { } else {
None None
@ -401,10 +389,7 @@ pub fn save_peer_invitation(
let invitation_save_path = if let Some(location) = config_location { let invitation_save_path = if let Some(location) = config_location {
location.clone() location.clone()
} else { } else {
Input::with_theme(&*THEME) input("Save peer invitation file as", Prefill::Default(format!("{}.toml", peer.name)))?
.with_prompt("Save peer invitation file as")
.default(format!("{}.toml", peer.name))
.interact()?
}; };
peer_invitation.write_to_path(&invitation_save_path, true, None)?; peer_invitation.write_to_path(&invitation_save_path, true, None)?;
@ -427,10 +412,7 @@ pub fn set_listen_port(
) -> Result<Option<Option<u16>>, Error> { ) -> Result<Option<Option<u16>>, Error> {
let listen_port = (!unset) let listen_port = (!unset)
.then(|| { .then(|| {
Input::with_theme(&*THEME) input("Listen port", Prefill::Default(interface.listen_port.unwrap_or(51820)))
.with_prompt("Listen port")
.default(interface.listen_port.unwrap_or(51820))
.interact()
}) })
.transpose()?; .transpose()?;
@ -469,32 +451,23 @@ pub fn ask_endpoint() -> Result<Endpoint, Error> {
None None
}; };
let mut endpoint_builder = Input::with_theme(&*THEME); Ok(input("External endpoint", match external_ip {
if let Some(ip) = external_ip { Some(ip) => Prefill::Editable(SocketAddr::new(ip, 51820).to_string()),
endpoint_builder.with_initial_text(SocketAddr::new(ip, 51820).to_string()); None => Prefill::None
} })?)
endpoint_builder
.with_prompt("External endpoint")
.interact()
.map_err(Into::into)
} }
pub fn override_endpoint(unset: bool) -> Result<Option<Option<Endpoint>>, Error> { pub fn override_endpoint(unset: bool) -> Result<Option<Option<Endpoint>>, Error> {
let endpoint = if !unset { Some(ask_endpoint()?) } else { None }; let endpoint = if !unset { Some(ask_endpoint()?) } else { None };
Ok( Ok(
if Confirm::with_theme(&*THEME) if confirm(
.wait_for_newline(true) &(if let Some(endpoint) = &endpoint {
.with_prompt( format!("Set external endpoint to {}?", endpoint)
&(if let Some(endpoint) = &endpoint { } else {
format!("Set external endpoint to {}?", endpoint) "Unset external endpoint to enable automatic endpoint discovery?".to_string()
} else { }),
"Unset external endpoint to enable automatic endpoint discovery?".to_string() )? {
}),
)
.default(false)
.interact()?
{
Some(endpoint) Some(endpoint)
} else { } else {
None None

View File

@ -137,7 +137,9 @@ pub fn add_route(interface: &InterfaceName, cidr: IpNetwork) -> Result<bool, Err
if !output.status.success() { if !output.status.success() {
Err(anyhow::anyhow!( Err(anyhow::anyhow!(
"failed to add route for device {} ({}): {}", "failed to add route for device {} ({}): {}",
&interface, real_interface, stderr &interface,
real_interface,
stderr
)) ))
} else { } else {
Ok(!stderr.contains("File exists")) Ok(!stderr.contains("File exists"))