2024-12-13 00:03:25 +01:00
mod action ;
2025-01-08 10:24:15 +01:00
mod command ;
2024-12-12 19:45:45 +01:00
mod file ;
2024-12-17 05:49:37 +01:00
mod logger ;
2024-12-13 12:50:05 +01:00
mod os_string_builder ;
2024-12-13 00:03:25 +01:00
mod server ;
2024-12-12 11:59:00 +01:00
2024-12-13 00:03:25 +01:00
use crate ::action ::{ Action , FileAction , ServerActions } ;
2025-02-03 12:33:15 +01:00
use crate ::command ::{ CommandSpecificError , ExecutionError , LogRunnable } ;
2024-12-13 00:03:25 +01:00
use crate ::file ::{ FileMatcher , FileNameInfo } ;
2024-12-16 15:48:05 +01:00
use crate ::logger ::{ LogLevel , Logger } ;
2024-12-15 14:34:19 +01:00
use crate ::os_string_builder ::ReplaceWithOsStr ;
2025-02-03 00:29:23 +01:00
use crate ::server ::{ RelativeLocalPathAnker , ServerAddress } ;
2024-12-11 14:13:32 +01:00
use clap ::{ Parser , Subcommand , ValueEnum } ;
2024-12-12 11:59:00 +01:00
use lazy_regex ::{ lazy_regex , Lazy , Regex } ;
use server ::{ Server , ServerReference } ;
2024-12-11 21:27:13 +01:00
use std ::cell ::LazyCell ;
2025-02-03 17:55:34 +01:00
use std ::ffi ::{ OsStr , OsString } ;
2024-12-12 11:59:00 +01:00
use std ::hash ::Hash ;
2024-12-13 00:03:25 +01:00
use std ::io ::Write ;
use std ::iter ::once ;
2025-02-03 01:47:36 +01:00
use std ::os ::unix ::ffi ::OsStrExt ;
2025-02-02 14:16:16 +01:00
use std ::path ::{ Path , PathBuf } ;
2024-12-11 16:37:15 +01:00
use std ::str ::FromStr ;
2025-01-08 18:34:59 +01:00
use std ::{ env , fs , io } ;
2024-12-11 10:42:44 +01:00
2024-12-11 14:13:32 +01:00
const SERVERS_ENV_VAR : & str = " MSSH_SERVERS " ;
2024-12-15 14:34:19 +01:00
const EDITOR_ENV_VAR : & str = " MSSH_EDITOR " ;
const FILE_PLACEHOLDER : & str = " <file> " ;
2024-12-12 11:59:00 +01:00
type ShellCmd = std ::process ::Command ;
2024-12-11 10:42:44 +01:00
2024-12-11 14:13:32 +01:00
/// Uploads a file or executes a command on multiple configured servers
2024-12-11 10:42:44 +01:00
///
2024-12-11 14:13:32 +01:00
/// Servers must either be configured via environment variable or denote their server directory with
/// a double colon: crea:home/crea.
2024-12-11 10:42:44 +01:00
///
2024-12-11 14:13:32 +01:00
/// --- Configuration via environment variable ---
2024-12-11 10:42:44 +01:00
///
2024-12-15 14:34:19 +01:00
/// Use `MSSH_SERVERS="crea:home/crea,sky:sky,lobby:,city:city2"` to configure servers.
2024-12-11 10:42:44 +01:00
#[ derive(Parser, Debug) ]
#[ command(version, about, long_about) ]
struct Args {
2024-12-11 14:13:32 +01:00
/// The action to perform
#[ command(subcommand) ]
command : Command ,
/// The ssh names and optionally home directories of the servers to perform the action on
2025-02-03 16:08:36 +01:00
#[ arg(num_args = 0.., value_parser = ServerReference::from_str) ]
2024-12-11 16:37:15 +01:00
servers : Vec < ServerReference > ,
2024-12-16 15:48:05 +01:00
/// How verbose logging output should be
2024-12-17 05:38:39 +01:00
#[ arg(long, default_value = " info " , conflicts_with_all = [ " quiet " , " info " ] ) ]
log_level : LogLevel ,
2024-12-16 15:48:05 +01:00
/// Only log errors
2024-12-17 05:38:39 +01:00
#[ arg(short, long, default_value = " false " , conflicts_with_all = [ " info " ] ) ]
2024-12-16 15:48:05 +01:00
quiet : bool ,
/// Log additional debugging info
2024-12-17 05:49:37 +01:00
#[ arg(short = 'v', long, default_value = " false " ) ]
2024-12-16 15:48:05 +01:00
info : bool ,
2024-12-11 14:13:32 +01:00
}
#[ derive(Subcommand, Debug) ]
enum Command {
/// Upload a file to the servers
#[ command(visible_short_flag_alias = 'u') ]
Upload {
2025-02-02 22:04:59 +01:00
/// The files to upload
files : Vec < PathBuf > ,
2025-02-02 14:16:16 +01:00
/// The ssh server to get the file from.
///
/// When this option is set, the file path must be absolute, or relative to the server directory.
/// The upload-directory has no influence on where the file will be taken from.
#[ arg(short = 'S', long) ]
file_server : Option < ServerReference > ,
2024-12-11 14:13:32 +01:00
/// How to handle older versions of the file
2024-12-13 00:46:35 +01:00
#[ arg(short = 'a', long, default_value = " delete " , default_missing_value = " archive " , num_args = 0..=1) ]
2024-12-11 14:13:32 +01:00
old_version_policy : OldVersionPolicy ,
2024-12-12 12:07:07 +01:00
/// The directory where to upload to, relative to the server directory
2025-02-03 15:36:16 +01:00
#[ arg(short = 'd', long, default_value = " plugins " ) ]
2024-12-12 09:50:32 +01:00
upload_directory : PathBuf ,
2024-12-12 11:59:00 +01:00
/// Skip the confirmation dialog
2024-12-13 00:46:35 +01:00
#[ arg(long, default_value = " false " ) ]
2024-12-12 11:59:00 +01:00
no_confirm : bool ,
2024-12-12 12:07:07 +01:00
/// The prefix of the name of older versions of the file, which should be replaced or deleted
#[ arg(short, long) ]
file_name : Option < String > ,
2025-02-03 15:36:16 +01:00
/// Only upload files which are not present yet on the target server
#[ arg(short, long, default_value = " false " ) ]
pure : bool ,
2025-02-03 16:08:36 +01:00
/// The prefixes of the names of files not to upload
#[ arg(short, long, num_args = 0..) ]
exclude : Vec < String > ,
2024-12-11 14:13:32 +01:00
} ,
/// Execute a command on the servers
#[ command(visible_short_flag_alias = 'c') ]
2024-12-15 14:34:19 +01:00
#[ allow(clippy::enum_variant_names) ]
2024-12-11 14:13:32 +01:00
Command {
/// The command to execute
command : String ,
} ,
2024-12-15 14:34:19 +01:00
/// 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>"`
///
/// 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 `<file>`, e.g. "nano <file>".
///
/// If omitted, the command will be taken from the environment variable `MSSH_EDITOR`.
#[ arg(short, long) ]
editor : Option < String > ,
2025-02-01 13:52:34 +01:00
/// The directory where to save the file to.
///
/// Default directory is `~/Downloads`
#[ arg(short = 'd', long) ]
download_directory : Option < PathBuf > ,
2024-12-15 14:34:19 +01:00
/// Override existing files in the working directory
#[ arg(short = 'f', long = " override " , default_value = " false " ) ]
override_existing : bool ,
} ,
2024-12-11 14:13:32 +01:00
}
#[ derive(Debug, Copy, Clone, Eq, PartialEq, Hash, Default, ValueEnum) ]
enum OldVersionPolicy {
/// Ignore the existence of older versions
Ignore ,
/// Rename older versions: foo.jar -> foo.jarr
Archive ,
/// Delete older versions
#[ default ]
Delete ,
2024-12-11 10:42:44 +01:00
}
2025-01-15 13:38:56 +01:00
#[ macro_export ]
macro_rules ! input {
2025-01-15 13:45:49 +01:00
( $prompt : tt ) = > { {
print! ( $prompt ) ;
2025-01-15 13:38:56 +01:00
io ::stdout ( ) . flush ( ) . expect ( " failed to flush stdout " ) ;
let mut buf = String ::new ( ) ;
io ::stdin ( )
. read_line ( & mut buf )
. expect ( " failed to read stdin " ) ;
buf . trim ( ) . to_string ( )
} } ;
( ) = > {
input! ( )
} ;
}
2024-12-11 10:42:44 +01:00
fn main ( ) -> Result < ( ) , String > {
let args = Args ::parse ( ) ;
2024-12-17 05:49:37 +01:00
let logger = Logger {
//all the below options are conflicting with each other so an if else is fine
level : if args . quiet {
LogLevel ::Error
} else if args . info {
LogLevel ::Debug
} else {
args . log_level
} ,
} ;
2024-12-11 10:42:44 +01:00
2024-12-11 22:17:36 +01:00
let mut configured_servers = LazyCell ::new ( parse_server_configuration_from_env ) ;
let servers = args
. servers
. iter ( )
. map ( | server_reference | {
2025-02-02 14:16:16 +01:00
let server_identifier = server_reference . get_identifier ( ) ;
2024-12-11 22:17:36 +01:00
server_reference
. clone ( )
. try_resolve_lazy ( & mut configured_servers )
2025-02-02 14:16:16 +01:00
. map_err ( | msg | format! ( " Can't resolve server directory for ' {server_identifier} ': {msg} " ) )
2024-12-11 22:17:36 +01:00
. and_then ( | opt_server | {
opt_server . ok_or ( format! (
2025-02-02 14:16:16 +01:00
" no server directory has been configured for server '{server_identifier}' "
2024-12-11 22:17:36 +01:00
) )
} )
} )
. collect ::< Result < Vec < _ > , _ > > ( ) ? ;
2024-12-12 11:59:00 +01:00
match args . command {
Command ::Upload {
2025-02-03 01:47:36 +01:00
mut files ,
2025-02-02 14:16:16 +01:00
file_server ,
2024-12-12 11:59:00 +01:00
old_version_policy ,
2025-02-03 00:51:56 +01:00
mut upload_directory ,
2024-12-12 11:59:00 +01:00
no_confirm ,
2024-12-12 12:07:07 +01:00
file_name ,
2025-02-03 15:36:16 +01:00
pure ,
2025-02-03 16:08:36 +01:00
exclude ,
2024-12-12 11:59:00 +01:00
} = > {
2024-12-17 06:02:25 +01:00
require_non_empty_servers ( & servers ) ? ;
2025-02-03 01:47:36 +01:00
require_non_empty ( & files , " files to upload " ) ? ;
2025-01-08 10:24:15 +01:00
start_ssh_agent ( & logger ) ? ;
2024-12-13 00:03:25 +01:00
2025-02-02 14:16:16 +01:00
//resolve file server
let file_server = match file_server {
Some ( server_reference ) = > {
let file_server_identifier = server_reference . get_identifier ( ) . to_string ( ) ;
let server = server_reference . try_resolve_lazy ( & mut configured_servers )
. map_err ( | e | format! ( " Can't resolve server directory for file-server ' {file_server_identifier} ': {e} " ) ) ?
. ok_or_else ( | | format! ( " no server directory has been configured for file-server ' {file_server_identifier} ' " ) ) ? ;
Some ( server )
}
None = > None ,
} ;
2025-02-02 22:04:59 +01:00
//make sure files exist
2025-02-02 14:16:16 +01:00
match & file_server {
2025-02-02 22:04:59 +01:00
Some ( file_server ) = > match & file_server . address {
ServerAddress ::Ssh { ssh_address } = > {
2025-02-03 13:05:47 +01:00
//canonicalize remote files -> also makes sure they exist
files = files
. iter ( )
. map ( | file | {
let output = ShellCmd ::new ( " ssh " )
. arg ( ssh_address )
. arg ( osf! ( " realpath -e " ) + file_server . server_directory_path . join ( file ) )
. collect_full_output ( )
. map_err ( | e | format! ( " Failed to canonicalize files: {e} " ) ) ? ;
if ! output . status . success ( ) {
Err ( format! (
" Path doesn't match any files on file-server: {} " ,
file . to_string_lossy ( )
) ) ? ;
}
2025-02-03 12:33:15 +01:00
2025-02-03 13:05:47 +01:00
let denoted_files = output
. stdout
. split ( | & b | b = = b '\n' ) //split at line breaks
2025-02-03 13:14:40 +01:00
. filter ( | bytes | ! bytes . is_empty ( ) ) //needed since realpath sometimes gives us empty lines
2025-02-03 13:05:47 +01:00
. map ( | bytes | PathBuf ::from ( OsStr ::from_bytes ( bytes ) ) )
. collect ::< Vec < _ > > ( ) ;
Ok ( denoted_files )
} )
. collect ::< Result < Vec < _ > , String > > ( ) ?
. into_iter ( )
. flatten ( )
2025-02-03 01:47:36 +01:00
. collect ( ) ;
2025-02-02 22:04:59 +01:00
}
2025-02-03 12:33:15 +01:00
ServerAddress ::Localhost = > files
. iter ( )
. map ( | file | file_server . server_directory_path . join ( file ) )
. try_for_each ( check_local_file_exists ) ? ,
2025-02-02 22:04:59 +01:00
} ,
2025-02-03 12:33:15 +01:00
None = > files . iter ( ) . try_for_each ( check_local_file_exists ) ? ,
2025-02-02 14:16:16 +01:00
}
2025-02-02 22:04:59 +01:00
let file_details = files
2025-02-03 16:08:36 +01:00
. into_iter ( )
2025-02-02 22:04:59 +01:00
. map ( | file | {
FileNameInfo ::try_from ( file . clone ( ) )
2025-02-03 16:08:36 +01:00
. map ( | info | ( PathBuf ::from ( & file ) , info ) )
2025-02-02 22:04:59 +01:00
. map_err ( | e | format! ( " Bad file ' {} ': {e} " , file . to_string_lossy ( ) ) )
} )
2025-02-03 16:08:36 +01:00
. collect ::< Result < Vec < _ > , _ > > ( ) ?
. into_iter ( )
. filter ( | ( _ , info ) | {
! exclude
. iter ( )
. any ( | exclude | info . to_full_file_name ( ) . starts_with ( exclude ) )
} )
. collect ::< Vec < _ > > ( ) ;
log! ( logger , debug , " Files to upload: " ) ;
for ( file , _ ) in & file_details {
log! ( logger , debug , " - {} " , file . to_string_lossy ( ) ) ;
}
2024-12-13 00:03:25 +01:00
//create overview of what has to be done on each server
let actions = servers
. iter ( )
. map ( | server | {
2025-02-03 00:51:56 +01:00
//on local server canonicalize upload_directory
if let ServerAddress ::Localhost = & server . address {
//create upload directory if it doesn't exist
fs ::create_dir_all ( & upload_directory )
. map_err ( | e | format! ( " Failed to create upload-directory: {e} " ) ) ? ;
upload_directory = fs ::canonicalize ( & upload_directory )
. map_err ( | e | format! ( " failed to resolve upload-directory: {e} " ) ) ? ;
}
2024-12-13 18:09:17 +01:00
let working_directory = server . server_directory_path . join ( & upload_directory ) ;
2024-12-13 00:03:25 +01:00
Ok ( ServerActions {
server ,
actions : {
2025-02-03 17:55:34 +01:00
let present_file_names : Vec < OsString > = match & server . address {
ServerAddress ::Ssh { ssh_address } = > ShellCmd ::new ( " ls " )
. arg ( ssh_address )
. arg ( osf! ( " ls " ) + & working_directory )
. collect_output ( )
. map_err ( | e | {
format! (
" Failed to query present files on server {}: {e} " ,
server . get_name ( )
)
} ) ?
. stdout
. split ( | & b | b = = b '\n' )
. map ( | bytes | OsStr ::from_bytes ( bytes ) . to_os_string ( ) )
. collect ( ) ,
ServerAddress ::Localhost = > fs ::read_dir ( & working_directory )
. map_err ( | e | format! ( " Failed to get files in working directory: {e} " ) ) ?
. map ( | entry | entry . map_err ( | e | format! ( " Failed to access directory entry: {e} " ) ) )
. collect ::< Result < Vec < _ > , _ > > ( ) ?
. into_iter ( )
. filter_map ( | entry | {
if entry . path ( ) . is_file ( ) {
Some ( entry . file_name ( ) )
} else {
None
}
} )
. collect ( ) ,
2025-02-02 02:23:25 +01:00
} ;
2025-02-02 22:04:59 +01:00
file_details
. iter ( )
. flat_map ( | ( file , file_name_info ) | {
let mut file_matcher =
FileMatcher ::from ( file_name . as_ref ( ) . unwrap_or ( & file_name_info . name ) ) ;
if let Some ( extension ) = file_name_info . extension . as_ref ( ) {
file_matcher = file_matcher . and_extension ( extension ) ;
}
2024-12-13 00:03:25 +01:00
2025-02-03 17:55:34 +01:00
let file_name = OsString ::from ( file_name_info . to_full_file_name ( ) ) ;
2024-12-13 00:03:25 +01:00
2025-02-02 22:04:59 +01:00
let add_action = FileAction ::new ( file , Action ::Add ) . expect ( " path points to file " ) ;
2024-12-13 00:46:35 +01:00
2025-02-03 17:55:34 +01:00
if pure & & present_file_names . iter ( ) . any ( | file | * file = = file_name ) {
log! (
logger ,
debug ,
" file is already present on {}: {} " ,
server . get_name ( ) ,
file_name . to_string_lossy ( )
) ;
2025-02-03 15:36:16 +01:00
return vec! [ ] ; //ignore that file, since it is already present
}
2025-02-02 22:04:59 +01:00
match old_version_policy {
OldVersionPolicy ::Ignore = > {
2025-02-03 17:55:34 +01:00
if ! present_file_names . iter ( ) . any ( | file | * file = = file_name ) {
2025-02-03 15:36:16 +01:00
vec! [ add_action ] //file doesn't exist yet
2024-12-13 13:07:52 +01:00
} else {
2025-02-03 15:36:16 +01:00
vec! [ FileAction ::new ( & file_name , Action ::Replace )
. expect ( " path points to file " ) ]
}
2025-02-02 22:04:59 +01:00
}
2025-02-03 17:55:34 +01:00
OldVersionPolicy ::Archive = > {
//TODO avoid lossy match
present_file_names
. iter ( )
2025-02-03 18:01:54 +01:00
. filter ( | file | file_matcher . matches ( file ) )
2025-02-03 17:55:34 +01:00
. map ( | file | {
FileAction ::new (
file ,
Action ::rename ( format! (
" {}{} " ,
file . to_string_lossy ( ) ,
file . to_string_lossy ( ) . chars ( ) . last ( ) . unwrap_or ( '1' )
) ) ,
)
. expect ( " path points to file " )
} )
. chain ( once ( add_action ) )
. collect ( )
} ,
2025-02-02 22:04:59 +01:00
OldVersionPolicy ::Delete = > {
2025-02-03 17:55:34 +01:00
//TODO avoid lossy match
let mut actions = present_file_names . iter ( )
2025-02-03 18:01:54 +01:00
. filter ( | file | file_matcher . matches ( file ) )
2025-02-02 22:04:59 +01:00
. map ( | file | {
//special case -> file has the same name as current file, then we just need to replace it
2025-02-03 17:55:34 +01:00
if * file = = file_name {
2025-02-02 22:04:59 +01:00
FileAction ::new ( file , Action ::Replace ) . expect ( " path points to file " )
} else {
FileAction ::new ( file , Action ::Delete ) . expect ( " path points to file " )
}
} )
. collect ::< Vec < _ > > ( ) ;
if ! actions . iter ( ) . any ( | action | action . kind = = Action ::Replace ) {
actions . push ( add_action ) ;
2024-12-13 13:07:52 +01:00
}
2025-02-02 22:04:59 +01:00
actions
}
2024-12-13 13:07:52 +01:00
}
2025-02-02 22:04:59 +01:00
} )
. collect ( )
2024-12-13 00:03:25 +01:00
} ,
2024-12-13 18:09:17 +01:00
working_directory ,
2024-12-13 00:03:25 +01:00
} )
} )
2025-02-03 15:36:16 +01:00
. collect ::< Result < Vec < _ > , String > > ( ) ?
. into_iter ( )
. filter ( | server_actions | ! server_actions . actions . is_empty ( ) )
. collect ::< Vec < _ > > ( ) ;
2025-02-03 16:08:36 +01:00
if actions . is_empty ( ) {
2025-02-03 15:36:16 +01:00
log! ( logger , " Nothing to be done, everything is up to date " ) ;
2025-02-03 16:08:36 +01:00
return Ok ( ( ) ) ;
2025-02-03 15:36:16 +01:00
}
2024-12-13 00:03:25 +01:00
2024-12-17 06:02:25 +01:00
log! ( logger , " The following actions will be performed: " ) ;
2024-12-13 00:03:25 +01:00
for server_actions in & actions {
2024-12-17 06:02:25 +01:00
log! ( logger , " {server_actions} " ) ;
2024-12-13 00:03:25 +01:00
}
if ! no_confirm {
2025-01-15 18:18:01 +01:00
match input! ( " Continue? [Y|n] " ) . to_lowercase ( ) . as_str ( ) {
2024-12-13 00:03:25 +01:00
" n " | " no " = > {
2024-12-17 06:02:25 +01:00
log! ( logger , " Aborting... " ) ;
2024-12-13 00:03:25 +01:00
return Ok ( ( ) ) ;
}
_ = > { }
}
}
for server_actions in actions {
let server = server_actions . server ;
2025-02-02 02:23:25 +01:00
log! ( logger , " Performing actions on {}... " , server . get_name ( ) ) ;
2024-12-13 00:03:25 +01:00
for file_action in server_actions . actions {
match file_action . kind {
Action ::Add | Action ::Replace = > {
2025-02-02 14:16:16 +01:00
let scp_source = match & file_server {
2025-02-02 22:04:59 +01:00
Some ( file_server ) = > {
osf! ( match & file_server . address {
ServerAddress ::Ssh { ssh_address } = > format! ( " {ssh_address} : " ) ,
ServerAddress ::Localhost = > " " . to_string ( ) ,
} ) + file_server . server_directory_path . join ( & file_action . file )
}
None = > osf! ( & file_action . file ) ,
2025-02-02 14:16:16 +01:00
} ;
2025-02-02 02:23:25 +01:00
let scp_target = osf! ( match & server . address {
ServerAddress ::Ssh { ssh_address } = > format! ( " {ssh_address} : " ) ,
ServerAddress ::Localhost = > " " . to_string ( ) ,
} ) + & server_actions . working_directory ;
2024-12-13 00:03:25 +01:00
ShellCmd ::new ( " scp " )
2025-02-02 14:16:16 +01:00
. arg ( scp_source )
2025-02-02 02:23:25 +01:00
. arg ( scp_target )
2025-01-08 10:24:15 +01:00
. run ( & logger )
. map_err ( | e | format! ( " upload failure: {e} " ) ) ? ;
2024-12-13 00:03:25 +01:00
}
2025-02-02 02:23:25 +01:00
Action ::Delete = > match & server . address {
ServerAddress ::Ssh { ssh_address } = > {
ShellCmd ::new ( " ssh " )
. arg ( ssh_address )
. arg ( osf! ( " rm " ) + server_actions . working_directory . join ( & file_action . file ) )
. run ( & logger )
. map_err ( | e | format! ( " failed to delete old version: {e} " ) ) ? ;
}
ServerAddress ::Localhost = > {
fs ::remove_file ( server_actions . working_directory . join ( & file_action . file ) )
. map_err ( | e | format! ( " failed to delete old version: {e} " ) ) ? ;
}
} ,
Action ::Rename { new_name } = > match & server . address {
ServerAddress ::Ssh { ssh_address } = > {
ShellCmd ::new ( " ssh " )
. arg ( ssh_address )
. arg (
osf! ( " mv " )
+ server_actions . working_directory . join ( & file_action . file )
+ " "
+ server_actions . working_directory . join ( & new_name ) ,
)
. run ( & logger )
. map_err ( | e | format! ( " failed to rename: {e} " ) ) ? ;
}
ServerAddress ::Localhost = > {
let dir = & server_actions . working_directory ;
fs ::rename ( dir . join ( & file_action . file ) , dir . join ( & new_name ) )
. map_err ( | e | format! ( " failed to rename: {e} " ) ) ? ;
}
} ,
2024-12-13 00:03:25 +01:00
}
}
}
2024-12-17 06:02:25 +01:00
log! ( logger , " Done! " ) ;
2024-12-12 11:59:00 +01:00
}
Command ::Command { command } = > {
2025-01-08 10:24:15 +01:00
start_ssh_agent ( & logger ) ? ;
2024-12-17 06:02:25 +01:00
require_non_empty_servers ( & servers ) ? ;
2024-12-12 11:59:00 +01:00
for server in servers {
2025-02-02 02:23:25 +01:00
log! ( logger , " Running command on '{}'... " , server . get_name ( ) ) ;
match & server . address {
ServerAddress ::Ssh { ssh_address } = > {
ShellCmd ::new ( " ssh " )
. arg ( ssh_address )
. arg ( osf! ( " cd " ) + server . server_directory_path + " ; " + & command )
. run ( & logger )
. map_err ( | e | format! ( " {e} " ) ) ? ;
}
ServerAddress ::Localhost = > {
let mut command_args =
shell_words ::split ( & command ) . map_err ( | e | format! ( " failed to parse command: {e} " ) ) ? ;
ShellCmd ::new ( command_args . remove ( 0 ) )
. args ( & command_args )
. current_dir ( & server . server_directory_path )
. run ( & logger )
. map_err ( | e | format! ( " {e} " ) ) ? ;
}
}
2024-12-12 11:59:00 +01:00
}
2024-12-17 06:02:25 +01:00
log! ( logger , " Done! " ) ;
2024-12-12 11:59:00 +01:00
}
2024-12-15 14:34:19 +01:00
Command ::Editor {
file ,
editor ,
2025-02-01 13:52:34 +01:00
download_directory ,
2024-12-15 14:34:19 +01:00
override_existing ,
} = > {
2025-02-01 13:52:34 +01:00
//determine download directory
let download_directory = match download_directory {
Some ( download_directory ) = > download_directory ,
None = > {
2025-02-02 02:23:25 +01:00
let home_dir =
get_home_directory ( ) . map_err ( | e | format! ( " Can't determine download directory: {e} " ) ) ? ;
2025-02-01 13:52:34 +01:00
home_dir . join ( " Downloads " )
}
} ;
2024-12-15 14:34:19 +01:00
//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} " ) ) ) ? ;
2025-02-01 13:52:34 +01:00
fs ::create_dir_all ( & download_directory )
2024-12-15 14:34:19 +01:00
. map_err ( | e | format! ( " failed to create working directory: {e} " ) ) ? ;
2025-02-02 02:23:25 +01:00
//make sure file doesn't exist in working directory yet, or it will be overridden
2024-12-15 14:34:19 +01:00
let file_name = file
. file_name ( )
. ok_or ( " can only edit files, not directories " ) ? ;
2025-01-08 18:34:59 +01:00
' duplicate_check : {
if ! override_existing
2025-02-01 13:52:34 +01:00
& & fs ::read_dir ( & download_directory )
2025-01-08 18:34:59 +01:00
. map_err ( | e | format! ( " failed to open working directory: {e} " ) ) ?
. collect ::< Result < Vec < _ > , _ > > ( )
. map_err ( | e | format! ( " error while querying working directory contents: {e} " ) ) ?
. iter ( )
. any ( | entry | entry . file_name ( ) = = file_name )
{
2025-01-15 13:38:56 +01:00
let duplication_notification = format! (
" A file with the name {} already exists in {} " ,
file_name . to_string_lossy ( ) ,
2025-02-01 13:52:34 +01:00
download_directory . to_string_lossy ( )
2025-01-15 13:38:56 +01:00
) ;
2025-01-08 18:34:59 +01:00
if ! args . quiet {
2025-01-15 18:18:01 +01:00
match input! ( " {duplication_notification}. Do you want to replace it? [N|y] " )
2025-01-15 13:45:49 +01:00
. to_lowercase ( )
. as_str ( )
{
" y " | " yes " = > break 'duplicate_check ,
_ = > { }
}
2025-01-08 18:34:59 +01:00
}
2025-01-15 13:38:56 +01:00
2025-01-08 18:34:59 +01:00
return Err ( format! (
2025-01-15 13:45:49 +01:00
" {duplication_notification}. You can override it with --override or -f "
) ) ;
2025-01-08 18:34:59 +01:00
}
2024-12-15 14:34:19 +01:00
}
2024-12-17 06:02:25 +01:00
require_non_empty_servers ( & servers ) ? ;
2025-01-08 10:24:15 +01:00
start_ssh_agent ( & logger ) ? ;
2024-12-15 14:34:19 +01:00
for server in servers {
2025-02-02 02:23:25 +01:00
log! ( logger , " Getting file from {}... " , server . get_name ( ) ) ;
let file_source = osf! ( match & server . address {
ServerAddress ::Ssh { ssh_address } = > format! ( " {ssh_address} : " ) ,
ServerAddress ::Localhost = > " " . to_string ( ) ,
} ) + server . server_directory_path . join ( & file ) ;
2024-12-15 14:34:19 +01:00
ShellCmd ::new ( " scp " )
2025-02-02 02:23:25 +01:00
. arg ( & file_source )
2025-02-01 13:52:34 +01:00
. arg ( & download_directory )
2025-01-08 10:24:15 +01:00
. run ( & logger )
. map_err ( | e | format! ( " download failure: {e} " ) ) ? ;
2024-12-15 14:34:19 +01:00
//open file in editor
2025-02-02 02:23:25 +01:00
let mut editor_command_args = shell_words ::split ( & editor )
2024-12-15 14:34:19 +01:00
. map_err ( | e | format! ( " failed to parse editor command: {e} " ) ) ?
. into_iter ( )
2025-02-01 13:52:34 +01:00
. map ( | part | {
part . replace_with_os_str ( FILE_PLACEHOLDER , download_directory . join ( file_name ) )
} )
2024-12-15 14:34:19 +01:00
. collect ::< Vec < _ > > ( ) ;
2025-02-02 02:23:25 +01:00
let command = editor_command_args . remove ( 0 ) ;
2024-12-15 14:34:19 +01:00
ShellCmd ::new ( command )
2025-02-02 02:23:25 +01:00
. args ( editor_command_args )
2025-01-08 10:24:15 +01:00
. run ( & logger )
2024-12-15 14:34:19 +01:00
. map_err ( | e | format! ( " failed to open file in editor: {e} " ) ) ? ;
//upload file again
ShellCmd ::new ( " scp " )
2025-02-01 13:52:34 +01:00
. arg ( download_directory . join ( file_name ) )
2025-02-02 02:23:25 +01:00
. arg ( & file_source )
2025-01-08 10:24:15 +01:00
. run ( & logger )
. map_err ( | e | format! ( " failed to re-upload file: {e} " ) ) ? ;
2024-12-15 14:34:19 +01:00
}
2024-12-17 06:02:25 +01:00
log! ( logger , " Done! " ) ;
2024-12-15 14:34:19 +01:00
}
2024-12-12 11:59:00 +01:00
}
Ok ( ( ) )
}
2025-02-02 14:16:16 +01:00
fn check_local_file_exists < P > ( path : P ) -> Result < ( ) , String >
where
P : AsRef < Path > ,
{
let path = path . as_ref ( ) ;
if ! path . is_file ( ) {
return Err ( format! (
" {} does not point to a file " ,
path . to_string_lossy ( )
) ) ;
}
Ok ( ( ) )
}
2025-02-03 13:05:47 +01:00
#[ allow(dead_code) ]
2025-02-02 22:04:59 +01:00
fn check_file_exists_on_server < P , S , D > (
path : P ,
ssh_address : S ,
server_directory : D ,
) -> Result < ( ) , String >
where
P : AsRef < Path > ,
S : AsRef < str > ,
D : AsRef < Path > ,
{
let full_path = server_directory . as_ref ( ) . join ( path ) ;
match & ShellCmd ::new ( " ssh " )
. arg ( ssh_address . as_ref ( ) )
. arg ( osf! ( " test -f " ) + & full_path )
. collect_output ( )
{
Ok ( _ ) = > Ok ( ( ) ) , //file exists on file server
2025-02-03 12:33:15 +01:00
Err ( CommandSpecificError {
2025-02-02 22:04:59 +01:00
error : ExecutionError ::BadExitStatus ( _ ) , //test failed
..
} ) = > Err ( format! (
2025-02-03 02:19:01 +01:00
" File '{}' doesn't exist on file-server " ,
2025-02-02 22:04:59 +01:00
full_path . to_string_lossy ( )
) ) ,
Err ( e ) = > Err ( format! (
" Failed to check whether file exists on file-server: {e} "
) ) ,
}
}
2025-02-02 02:23:25 +01:00
fn get_home_directory ( ) -> Result < PathBuf , String > {
homedir ::my_home ( )
. map_err ( | e | format! ( " Failed to determine home directory: {e} " ) )
. and_then ( | home_dir | home_dir . ok_or ( " Failed to find home directory " . to_string ( ) ) )
}
2025-02-03 01:47:36 +01:00
fn require_non_empty_servers < T > ( servers : & [ T ] ) -> Result < ( ) , String > {
require_non_empty ( servers , " servers for this operation " )
}
fn require_non_empty < T > ( slice : & [ T ] , slice_name : & str ) -> Result < ( ) , String > {
if slice . is_empty ( ) {
Err ( format! (
" You did not provide any {slice_name}. Please see --help "
) ) ?
2024-12-17 06:02:25 +01:00
}
2025-02-03 01:47:36 +01:00
Ok ( ( ) )
2024-12-17 06:02:25 +01:00
}
2025-01-08 10:24:15 +01:00
fn start_ssh_agent ( logger : & Logger ) -> Result < ( ) , String > {
2024-12-12 11:59:00 +01:00
//start the ssh agent
let agent_output = ShellCmd ::new ( " ssh-agent " )
. arg ( " -s " )
2025-01-08 13:43:05 +01:00
. collect_output ( )
2024-12-12 11:59:00 +01:00
. map_err ( | e | format! ( " failed to start ssh agent: {e} " ) ) ? ;
2024-12-13 21:03:05 +01:00
let agent_stdout = String ::from_utf8_lossy ( & agent_output . stdout ) ;
if ! agent_output . status . success ( ) {
return Err ( " failed to start ssh agent; maybe try to run ssh-agent manually? " . to_string ( ) ) ;
}
2024-12-12 11:59:00 +01:00
//set the env vars from the agent
static ENV_VAR_REGEX : Lazy < Regex > = lazy_regex! ( " (.+?)=(.+?); " ) ;
2024-12-13 21:03:05 +01:00
for capture in ENV_VAR_REGEX . captures_iter ( & agent_stdout ) {
2024-12-12 11:59:00 +01:00
let ( _ , [ env_var , value ] ) = capture . extract ( ) ;
env ::set_var ( env_var , value ) ;
}
//add the ssh key
2025-01-08 13:43:05 +01:00
ShellCmd ::new ( " ssh-add " )
. run ( logger )
. map_err ( | e | format! ( " failed to add ssh-key: {e} " ) ) ? ;
2024-12-11 10:42:44 +01:00
Ok ( ( ) )
}
fn parse_server_configuration_from_env ( ) -> Result < Vec < Server > , String > {
env ::var ( SERVERS_ENV_VAR )
. map_err ( | _ | format! ( " Missing environment variable {} " , SERVERS_ENV_VAR ) )
. and_then ( | value | parse_server_configuration ( & value ) )
}
fn parse_server_configuration ( config_str : & str ) -> Result < Vec < Server > , String > {
2024-12-11 16:37:15 +01:00
config_str
. split ( ',' )
. map ( | server_entry | {
2025-02-03 00:29:23 +01:00
Server ::from_str ( server_entry , RelativeLocalPathAnker ::Home )
2024-12-11 16:37:15 +01:00
. map_err ( | e | format! ( " Invalid server entry ' {server_entry} ': {e} " ) )
2024-12-11 10:42:44 +01:00
} )
2024-12-11 16:37:15 +01:00
. collect ( )
2024-12-11 10:42:44 +01:00
}
#[ cfg(test) ]
mod test {
2024-12-12 11:59:00 +01:00
use crate ::parse_server_configuration ;
2025-02-02 02:23:25 +01:00
use crate ::server ::{ Server , ServerAddress } ;
use std ::fs ;
2024-12-11 10:42:44 +01:00
use std ::path ::PathBuf ;
#[ test ]
fn test_parse_server_configuration ( ) {
2024-12-11 14:13:32 +01:00
let servers =
2025-02-02 02:23:25 +01:00
parse_server_configuration ( " foo:bar,.:fizz/buzz " ) . expect ( " valid server configuration " ) ;
2024-12-11 14:13:32 +01:00
assert_eq! (
vec! [
Server {
2025-02-02 02:23:25 +01:00
address : ServerAddress ::Ssh {
ssh_address : " foo " . to_string ( )
} ,
2024-12-11 14:13:32 +01:00
server_directory_path : PathBuf ::from ( " bar " ) ,
} ,
Server {
2025-02-02 02:23:25 +01:00
address : ServerAddress ::Localhost ,
server_directory_path : PathBuf ::from ( " fizz/buzz " ) ,
2024-12-11 14:13:32 +01:00
}
] ,
servers
) ;
2024-12-11 10:42:44 +01:00
}
2024-12-13 12:50:05 +01:00
2025-02-02 02:23:25 +01:00
/// When we join an absolute path to a relative path, it becomes a relative path
2024-12-13 12:50:05 +01:00
#[ test ]
fn path_experiment ( ) {
let server_dir = PathBuf ::from ( " steptech " ) ;
let upload_dir = PathBuf ::from ( " /home " ) ; //absolute path
let joined = server_dir . join ( upload_dir ) ;
assert_eq! ( PathBuf ::from ( " /home " ) , joined ) ;
}
2025-02-02 02:23:25 +01:00
2025-02-03 00:51:56 +01:00
/// When renaming a file in a folder, the folder is relevant in the new name
2025-02-02 02:23:25 +01:00
#[ test ]
fn rename_experiment ( ) {
fs ::rename ( " test-ressources/files/test " , " test-ressources/files/test1 " )
. expect ( " failed to rename test file " ) ;
fs ::rename ( " test-ressources/files/test1 " , " test-ressources/files/test " )
. expect ( " failed to rename test1 file back to test " ) ;
}
2025-02-03 00:51:56 +01:00
#[ test ]
fn mkdir_experiment ( ) {
fs ::create_dir_all ( " ./test-ressources/files/../python " )
. expect ( " failed to create directory with relative path " ) ;
}
2024-12-11 10:42:44 +01:00
}