use std::time::Duration;
use shep_client::SshOptions;
use shep_core::paths::ShepPaths;
use crate::cli::{Commands, Format};
use crate::client::emit_error_locked;
use crate::exit::ExitCode;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum HostScope {
Reaches,
LocalOnly,
}
pub(crate) fn host_scope(command: &Commands) -> HostScope {
match command {
Commands::Flock(_)
| Commands::Describe(_)
| Commands::Fold(_)
| Commands::Stop(_)
| Commands::Thatlldo(_)
| Commands::Restart(_)
| Commands::Reload(_)
| Commands::Delete(_)
| Commands::Disable(_)
| Commands::Stock(_)
| Commands::Trigger(_)
| Commands::Signal(_)
| Commands::Whisper(_)
| Commands::Bleats(_)
| Commands::Lookout(_) => HostScope::Reaches,
Commands::Enable(args) if args.exec.is_some() => HostScope::LocalOnly,
Commands::Enable(_) => HostScope::Reaches,
Commands::Start(_) => HostScope::Reaches,
Commands::Muster => HostScope::Reaches,
Commands::Add(_)
| Commands::Serve(_)
| Commands::Dogs(_)
| Commands::Adopt(_)
| Commands::Rehome(_)
| Commands::Whistle
| Commands::Reopen(_)
| Commands::Flush(_)
| Commands::Barks(_)
| Commands::Set(_)
| Commands::Get(_)
| Commands::Unset(_)
| Commands::Secret(_)
| Commands::Host(_)
| Commands::Copy(_)
| Commands::Ping
| Commands::Kill
| Commands::Save
| Commands::Init(_)
| Commands::Runtime(_)
| Commands::Dev(_)
| Commands::Import(_)
| Commands::Startup(_)
| Commands::Unstartup(_)
| Commands::Completions(_)
| Commands::Welcome
| Commands::Style(_)
| Commands::Daemon(_)
| Commands::Dog(_)
| Commands::Schema
| Commands::DialStdio(_) => HostScope::LocalOnly,
}
}
pub(crate) const HOST_REFUSAL: &str = "`--host` is not supported by this command yet, \
and it would have run on this machine, not on the host";
pub(crate) fn refuse_host(fmt: Format) -> ExitCode {
emit_error_locked(fmt, ExitCode::Usage, HOST_REFUSAL);
ExitCode::Usage
}
pub(crate) fn remote_word(word: &str) -> String {
let safe = |b: &u8| b.is_ascii_alphanumeric() || b"_./:@%+=-".contains(b);
if !word.is_empty() && word.as_bytes().iter().all(safe) {
return word.to_string();
}
format!("'{}'", word.replace('\'', r"'\''"))
}
pub(crate) fn ssh_hint(dest: &str, words: &[String]) -> String {
let quoted: Vec<String> = words.iter().map(|word| remote_word(word)).collect();
let command = quoted.join(" ");
if quoted
.iter()
.zip(words)
.all(|(quoted, word)| quoted == word)
{
format!("ssh {dest} {command}")
} else {
format!("ssh {dest} {}", remote_word(&command))
}
}
pub(crate) const HOST_INTERRUPT_GRACE: Duration = Duration::from_millis(250);
pub(crate) async fn interrupt_within_grace(interrupt: impl Future<Output = ()>) -> bool {
tokio::time::timeout(HOST_INTERRUPT_GRACE, interrupt)
.await
.is_ok()
}
#[derive(Debug)]
pub(crate) enum Link<'a> {
Local(&'a ShepPaths),
Host {
dest: &'a str,
options: SshOptions,
},
}
impl<'a> Link<'a> {
pub(crate) fn new(paths: &'a ShepPaths, host: Option<&'a str>) -> Self {
match host {
Some(dest) => Self::to_host(dest),
None => Self::Local(paths),
}
}
pub(crate) fn to_host(dest: &'a str) -> Self {
Self::Host {
dest,
options: SshOptions::default(),
}
}
pub(crate) fn to_host_booting(dest: &'a str) -> Self {
Self::Host {
dest,
options: SshOptions::default().autostart(true),
}
}
}
#[cfg(test)]
mod tests {
use clap::{CommandFactory, Parser};
use super::*;
use crate::cli::Cli;
const SAMPLES: &[&[&str]] = &[
&["start", "web"],
&["add", "web"],
&["serve", "dist"],
&["stop", "web"],
&["restart", "web"],
&["reload", "web"],
&["delete", "web"],
&["stock", "web", "2"],
&["flock"],
&["dogs"],
&["enable", "metrics"],
&["disable", "metrics"],
&["adopt", "/bin/true"],
&["rehome", "metrics"],
&["describe", "web"],
&["trigger", "web", "run"],
&["signal", "web", "TERM"],
&["whisper", "web", "hi"],
&["fold", "web"],
&["bleats", "web"],
&["lookout"],
&["whistle"],
&["reopen"],
&["flush", "web"],
&["barks"],
&["set", "k", "v"],
&["get", "k"],
&["unset", "k"],
&["secret", "list"],
&["host", "list"],
&["copy", "web", "web2"],
&["ping"],
&["kill"],
&["save"],
&["muster"],
&["init"],
&["runtime"],
&["dev"],
&["import", "env", "--app", "web", ".env"],
&["startup"],
&["unstartup"],
&["completions", "bash"],
&["welcome"],
&["style"],
&["thatlldo", "web"],
&["daemon"],
&["dog", "metrics"],
&["schema"],
&["dial-stdio"],
];
fn parsed_with_host(sample: &[&str]) -> Cli {
let argv: Vec<&str> = ["shep", "--host", "amp2"]
.into_iter()
.chain(sample.iter().copied())
.collect();
Cli::try_parse_from(&argv).unwrap_or_else(|err| panic!("{argv:?} must parse: {err}"))
}
const REACHING: &[&[&str]] = &[
&["flock"],
&["flock", "--follow"],
&["describe", "web"],
&["fold", "web"],
&["bleats", "web"],
&["bleats", "web", "--no-follow"],
&["lookout"],
];
const CHANGING: &[&[&str]] = &[
&["stop", "web"],
&["thatlldo", "web"],
&["restart", "web"],
&["reload", "web"],
&["delete", "web"],
];
const MARKING: &[&[&str]] = &[
&["enable", "web"],
&["disable", "web"],
&["stock", "web", "2"],
&["signal", "web", "TERM"],
&["whisper", "web", "hi"],
&["trigger", "web", "run"],
];
const STARTING: &[&[&str]] = &[&["start", "web"], &["start"]];
const MUSTERING: &[&[&str]] = &[&["muster"]];
#[test]
fn start_reaches_a_host_and_add_does_not() {
for argv in STARTING {
let cli = parsed_with_host(argv);
assert_eq!(host_scope(&cli.command), HostScope::Reaches, "{argv:?}");
}
let add = parsed_with_host(&["add", "web"]);
assert_eq!(host_scope(&add.command), HostScope::LocalOnly);
}
#[test]
fn lookout_reaches_a_host() {
let cli = parsed_with_host(&["lookout"]);
assert_eq!(host_scope(&cli.command), HostScope::Reaches);
}
#[test]
fn muster_reaches_a_host_and_save_does_not() {
let muster = parsed_with_host(&["muster"]);
assert_eq!(host_scope(&muster.command), HostScope::Reaches);
let save = parsed_with_host(&["save"]);
assert_eq!(host_scope(&save.command), HostScope::LocalOnly);
}
#[test]
fn a_remote_word_is_quoted_only_when_the_shell_would_split_it() {
assert_eq!(remote_word("./api"), "./api");
assert_eq!(remote_word("my api"), "'my api'");
assert_eq!(remote_word("it's"), r"'it'\''s'");
assert_eq!(remote_word(""), "''");
}
#[test]
fn an_ssh_hint_quotes_for_both_shells_only_when_a_word_needs_it() {
let words = |list: &[&str]| list.iter().map(ToString::to_string).collect::<Vec<_>>();
assert_eq!(
ssh_hint("amp2", &words(&["shep", "start", "./api"])),
"ssh amp2 shep start ./api"
);
assert_eq!(
ssh_hint("amp2", &words(&["shep", "start", "./my api.js"])),
r"ssh amp2 'shep start '\''./my api.js'\'''"
);
}
#[test]
fn enable_disable_stock_signal_whisper_and_trigger_reach_a_host() {
for argv in MARKING {
let cli = parsed_with_host(argv);
assert_eq!(host_scope(&cli.command), HostScope::Reaches, "{argv:?}");
}
}
#[test]
fn enable_with_the_hidden_exec_form_is_refused_over_a_host() {
let cli = parsed_with_host(&["enable", "mydog", "--exec", "/bin/true"]);
assert_eq!(host_scope(&cli.command), HostScope::LocalOnly);
}
#[test]
fn stop_restart_reload_and_delete_reach_a_host() {
for argv in CHANGING {
let cli = parsed_with_host(argv);
assert_eq!(host_scope(&cli.command), HostScope::Reaches, "{argv:?}");
}
}
#[test]
fn read_verbs_reach_a_host() {
for argv in REACHING {
let cli = parsed_with_host(argv);
assert_eq!(host_scope(&cli.command), HostScope::Reaches, "{argv:?}");
}
for sample in SAMPLES {
if REACHING
.iter()
.chain(CHANGING)
.chain(MARKING)
.chain(STARTING)
.chain(MUSTERING)
.any(|argv| argv[0] == sample[0])
{
continue;
}
let cli = parsed_with_host(sample);
assert_eq!(host_scope(&cli.command), HostScope::LocalOnly, "{sample:?}");
}
}
#[test]
fn every_verb_has_a_sample_argv_here() {
let mut in_tree: Vec<String> = Cli::command()
.get_subcommands()
.map(|sub| sub.get_name().to_owned())
.filter(|name| name != "help")
.collect();
let mut sampled: Vec<String> = SAMPLES.iter().map(|row| row[0].to_owned()).collect();
in_tree.sort();
sampled.sort();
assert_eq!(sampled, in_tree);
}
}