diff --git a/Cargo.toml b/Cargo.toml index d753c67..87c08de 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,3 +6,4 @@ edition = "2021" [dependencies] clap = { version = "4.5.23", features = ["derive"] } lazy-regex = "3.3.0" +shell-words = "1.1.0" diff --git a/README.md b/README.md index b00cf39..66e0b1b 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,11 @@ # multi-ssh -An application to upload a file or perform a command on multiple servers via ssh and scp. +An application to upload or edit a file or perform a command on multiple servers via ssh and scp. ## Configuration Set the environment variable `MSSH_SERVERS` to configure the server directories of your ssh servers. +Use the `MSSH_EDITOR` variable to configure your editor command. Furthermore, ensure that in your shell the following commands are set up and working: - ssh - scp @@ -13,10 +14,11 @@ Furthermore, ensure that in your shell the following commands are set up and wor Ergo you should be able to connect to your desired servers via ssh and be able to start an ssh agent. -### Linux example +### Environment variable setup example for linux ```bash export MSSH_SERVERS="crea:server/creative2,sky:sky,city:city2" +export MSSH_EDITOR="kate -b " # is the placeholder for the file name ``` ## Usage @@ -58,6 +60,15 @@ For detailed usage of the upload feature see multi-ssh -u --help ``` +### Editor example + +Edit the server properties of steptech +```bash +multi-ssh steptech -e server.properties +``` +Note that the above command will create a `.mssh` folder in your current working directory. That directory can be +changed with the `-d` flag. + ## Building from source This is a rust project so you will need [rust and cargo](https://doc.rust-lang.org/cargo/getting-started/installation.html). diff --git a/src/main.rs b/src/main.rs index 64399d2..87f3bd0 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,19 +5,23 @@ mod server; use crate::action::{Action, FileAction, ServerActions}; use crate::file::{FileMatcher, FileNameInfo}; +use crate::os_string_builder::ReplaceWithOsStr; use clap::{Parser, Subcommand, ValueEnum}; use lazy_regex::{lazy_regex, Lazy, Regex}; use server::{Server, ServerReference}; use std::cell::LazyCell; -use std::env; use std::hash::Hash; use std::io::Write; use std::iter::once; use std::path::PathBuf; use std::process::Stdio; use std::str::FromStr; +use std::{env, fs}; const SERVERS_ENV_VAR: &str = "MSSH_SERVERS"; +const EDITOR_ENV_VAR: &str = "MSSH_EDITOR"; +const FILE_PLACEHOLDER: &str = ""; + type ShellCmd = std::process::Command; /// Uploads a file or executes a command on multiple configured servers @@ -27,7 +31,7 @@ type ShellCmd = std::process::Command; /// /// --- Configuration via environment variable --- /// -/// Use MSSH_SERVERS="crea:home/crea,sky:sky,lobby:,city:city2" to configure servers. +/// Use `MSSH_SERVERS="crea:home/crea,sky:sky,lobby:,city:city2"` to configure servers. #[derive(Parser, Debug)] #[command(version, about, long_about)] struct Args { @@ -61,10 +65,35 @@ enum Command { }, /// Execute a command on the servers #[command(visible_short_flag_alias = 'c')] + #[allow(clippy::enum_variant_names)] Command { /// The command to execute command: String, }, + /// Open a file in your local editor and upload it afterward. + /// + /// The editor will be opened for every server in the order provided by you. + /// The command to start the editor can be configured via environment variable `MSSH_EDITOR`: + /// + /// `export MSSH_EDITOR="nano "` + /// + /// File is a placeholder that will be replaced with the actual name of the file. + #[command(visible_short_flag_alias = 'e')] + Editor { + /// A path to the file to edit, relative to the server directory + file: PathBuf, + /// The command to start the editor. Supports the placeholder ``, e.g. "nano ". + /// + /// If omitted, the command will be taken from the environment variable `MSSH_EDITOR`. + #[arg(short, long)] + editor: Option, + /// The directory where to save the file to + #[arg(short = 'd', long, default_value = ".mssh/downloads")] + working_directory: PathBuf, + /// Override existing files in the working directory + #[arg(short = 'f', long = "override", default_value = "false")] + override_existing: bool, + }, } #[derive(Debug, Copy, Clone, Eq, PartialEq, Hash, Default, ValueEnum)] @@ -107,12 +136,11 @@ fn main() -> Result<(), String> { no_confirm, file_name, } => { - if servers.is_empty() { println!("Please provide some servers to upload to. See --help"); - return Ok(()) + return Ok(()); } - + start_ssh_agent()?; let file_name_info = @@ -278,6 +306,70 @@ fn main() -> Result<(), String> { } println!("Done!"); } + Command::Editor { + file, + editor, + working_directory, + override_existing, + } => { + //get editor + let editor = editor.ok_or(()).or_else(|_| env::var(EDITOR_ENV_VAR).map_err(|e| format!("You have not specified an editor. Please do so using the --editor flag or the {EDITOR_ENV_VAR} environment variable: {e}")))?; + + fs::create_dir_all(&working_directory) + .map_err(|e| format!("failed to create working directory: {e}"))?; + + //make sure file doesn't exist in working directory yet, or will be overridden + let file_name = file + .file_name() + .ok_or("can only edit files, not directories")?; + if !override_existing + && fs::read_dir(&working_directory) + .map_err(|e| format!("failed to open working directory: {e}"))? + .collect::, _>>() + .map_err(|e| format!("error while querying working directory contents: {e}"))? + .iter() + .any(|entry| entry.file_name() == file_name) + { + return Err(format!( + "A file with the name {} already exists in {}. You can override it with --override", + file_name.to_string_lossy(), + working_directory.to_string_lossy() + )); + } + + start_ssh_agent()?; + + for server in servers { + println!("Downloading file from {}...", server.ssh_name); + ShellCmd::new("scp") + .arg(osf!(&server.ssh_name) + ":" + server.server_directory_path.join(&file)) + .arg(&working_directory) + .status() + .map_err(|e| format!("failed to download file: {e}"))?; + + //open file in editor + let mut shell_args = shell_words::split(&editor) + .map_err(|e| format!("failed to parse editor command: {e}"))? + .into_iter() + .map(|part| part.replace_with_os_str(FILE_PLACEHOLDER, working_directory.join(file_name))) + .collect::>(); + + let command = shell_args.remove(0); + ShellCmd::new(command) + .args(shell_args) + .status() + .map_err(|e| format!("failed to open file in editor: {e}"))?; + + //upload file again + ShellCmd::new("scp") + .arg(working_directory.join(file_name)) + .arg(osf!(&server.ssh_name) + ":" + server.server_directory_path.join(&file)) + .status() + .map_err(|e| format!("failed to upload file again: {e}"))?; + } + + println!("Done!"); + } } Ok(()) diff --git a/src/os_string_builder.rs b/src/os_string_builder.rs index 182b2df..a701f6f 100644 --- a/src/os_string_builder.rs +++ b/src/os_string_builder.rs @@ -3,6 +3,24 @@ use std::fmt::{Debug, Formatter}; use std::hash::{Hash, Hasher}; use std::ops::{Add, AddAssign}; +pub trait ReplaceWithOsStr<'a, Pattern = &'a str> { + #[must_use] + fn replace_with_os_str(&'a self, pattern: Pattern, replacement: impl AsRef) -> OsString; +} + +impl<'a> ReplaceWithOsStr<'a> for str { + fn replace_with_os_str(&'a self, pattern: &'a str, replacement: impl AsRef) -> OsString { + let mut parts = self.split(pattern).collect::>(); + let mut builder = OsStringBuilder::from(parts.remove(0)); + let replacement = replacement.as_ref(); + for part in parts { + builder += replacement; + builder += part; + } + builder.build() + } +} + #[derive(Clone, Default, Eq)] pub struct OsStringBuilder { result: OsString, @@ -86,7 +104,7 @@ macro_rules! osf { } #[cfg(test)] -mod test { +mod test_builder { use std::path::PathBuf; #[test] @@ -102,3 +120,17 @@ mod test { assert_eq!(osf!("cd ") + foo.join(&bar), "cd foo/bar"); } } + +#[cfg(test)] +mod test_replace_with_os_str { + use crate::os_string_builder::ReplaceWithOsStr; + use std::ffi::OsString; + use std::path::PathBuf; + + #[test] + fn test_replace() { + let file = PathBuf::from("foo.txt"); + + assert_eq!(OsString::from("nano foo.txt"), "nano ".replace_with_os_str("", file)); + } +}