multiSSH3 5.95__py3-none-any.whl → 6.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
multiSSH3.py CHANGED
@@ -84,10 +84,10 @@ except Exception:
84
84
  print('Warning: functools.lru_cache is not available, multiSSH3 will run slower without cache.',file=sys.stderr)
85
85
  def cache_decorator(func):
86
86
  return func
87
- version = '5.95'
87
+ version = '6.01'
88
88
  VERSION = version
89
89
  __version__ = version
90
- COMMIT_DATE = '2025-10-21'
90
+ COMMIT_DATE = '2025-11-06'
91
91
 
92
92
  CONFIG_FILE_CHAIN = ['./multiSSH3.config.json',
93
93
  '~/multiSSH3.config.json',
@@ -99,7 +99,6 @@ CONFIG_FILE_CHAIN = ['./multiSSH3.config.json',
99
99
  ERRORS = []
100
100
 
101
101
  # TODO: Add terminal TUI
102
- # TODO: Change -fs behavior
103
102
 
104
103
  #%% ------------ Pre Helper Functions ----------------
105
104
  def eprint(*args, **kwargs):
@@ -322,7 +321,7 @@ DEFAULT_HOSTS = 'all'
322
321
  DEFAULT_USERNAME = None
323
322
  DEFAULT_PASSWORD = ''
324
323
  DEFAULT_IDENTITY_FILE = None
325
- DEDAULT_SSH_KEY_SEARCH_PATH = '~/.ssh/'
324
+ DEFAULT_SSH_KEY_SEARCH_PATH = '~/.ssh/'
326
325
  DEFAULT_USE_KEY = False
327
326
  DEFAULT_EXTRA_ARGS = None
328
327
  DEFAULT_ONE_ON_ONE = False
@@ -377,6 +376,24 @@ ERROR_MESSAGES_TO_IGNORE = [
377
376
  'Killed by signal',
378
377
  'Connection reset by peer',
379
378
  ]
379
+ __DEFAULT_COLOR_PALETTE = {
380
+ 'cyan': (86, 173, 188),
381
+ 'green': (114, 180, 43),
382
+ 'magenta': (140, 107, 200),
383
+ 'red': (196, 38, 94),
384
+ 'white': (227, 227, 221),
385
+ 'yellow': (179, 180, 43),
386
+ 'blue': (106, 126, 200),
387
+ 'bright_black': (102, 102, 102),
388
+ 'bright_blue': (129, 154, 255),
389
+ 'bright_cyan': (102, 217, 239),
390
+ 'bright_green': (126, 226, 46),
391
+ 'bright_magenta': (174, 129, 255),
392
+ 'bright_red': (249, 38, 114),
393
+ 'bright_white': (248, 248, 242),
394
+ 'bright_yellow': (226, 226, 46),
395
+ }
396
+ COLOR_PALETTE = __DEFAULT_COLOR_PALETTE.copy()
380
397
  _DEFAULT_CALLED = True
381
398
  _DEFAULT_RETURN_UNFINISHED = False
382
399
  _DEFAULT_UPDATE_UNREACHABLE_HOSTS = True
@@ -420,6 +437,11 @@ __thread_start_delay = 0
420
437
  _encoding = DEFAULT_ENCODING
421
438
  __returnZero = DEFAULT_RETURN_ZERO
422
439
  __running_threads = set()
440
+ __control_master_string = '''Host *
441
+ ControlMaster auto
442
+ ControlPath /run/user/%i/ssh_sockets_%C
443
+ ControlPersist 3600
444
+ '''
423
445
  if __resource_lib_available:
424
446
  # Get the current limits
425
447
  _, __system_nofile_limit = resource.getrlimit(resource.RLIMIT_NOFILE)
@@ -467,12 +489,12 @@ def check_path(program_name):
467
489
 
468
490
  [check_path(program) for program in _binCalled]
469
491
 
470
- def find_ssh_key_file(searchPath = DEDAULT_SSH_KEY_SEARCH_PATH):
492
+ def find_ssh_key_file(searchPath = DEFAULT_SSH_KEY_SEARCH_PATH):
471
493
  '''
472
494
  Find the ssh public key file
473
495
 
474
496
  Args:
475
- searchPath (str, optional): The path to search. Defaults to DEDAULT_SSH_KEY_SEARCH_PATH.
497
+ searchPath (str, optional): The path to search. Defaults to DEFAULT_SSH_KEY_SEARCH_PATH.
476
498
 
477
499
  Returns:
478
500
  str: The path to the ssh key file
@@ -931,6 +953,8 @@ def get_terminal_color_capability():
931
953
  return '24bit'
932
954
  elif "256" in term:
933
955
  return '256'
956
+ elif "16" in term:
957
+ return '16'
934
958
  try:
935
959
  curses.setupterm()
936
960
  colors = curses.tigetnum("colors")
@@ -949,96 +973,120 @@ def get_terminal_color_capability():
949
973
  return 'None'
950
974
 
951
975
  @cache_decorator
952
- def get_xterm256_palette():
953
- palette = []
954
- # 0–15: system colors (we'll just fill with dummy values;
955
- # you could fill in real RGB if you need to)
956
- system_colors = [
957
- (0, 0, 0), (128, 0, 0), (0, 128, 0), (128, 128, 0),
958
- (0, 0, 128), (128, 0, 128), (0, 128, 128), (192, 192, 192),
959
- (128, 128, 128), (255, 0, 0), (0, 255, 0), (255, 255, 0),
960
- (0, 0, 255), (255, 0, 255), (0, 255, 255), (255, 255, 255),
961
- ]
962
- palette.extend(system_colors)
963
- # 16–231: 6x6x6 color cube
964
- levels = [0, 95, 135, 175, 215, 255]
965
- for r in levels:
966
- for g in levels:
967
- for b in levels:
968
- palette.append((r, g, b))
969
- # 232–255: grayscale ramp, 24 steps from 8 to 238
970
- for i in range(24):
971
- level = 8 + i * 10
972
- palette.append((level, level, level))
973
- return palette
976
+ def rgb_to_ansi_color_string(r, g, b):
977
+ """
978
+ Return an ANSI escape sequence setting the foreground to (r,g,b)
979
+ approximated to the terminal's capability, or '' if none.
980
+ """
981
+ cap = get_terminal_color_capability()
982
+ if cap == 'None':
983
+ return ''
984
+ if cap == '24bit':
985
+ return f'\x1b[38;2;{r};{g};{b}m'
986
+ if cap == '256':
987
+ idx = _rgb_to_256_color(r, g, b)
988
+ return f'\x1b[38;5;{idx}m'
989
+ if cap == '16':
990
+ idx = _rgb_to_16_color(r, g, b)
991
+ # 0–7 = 30–37, 8–15 = 90–97
992
+ if idx < 8:
993
+ return f'\x1b[{30 + idx}m'
994
+ else:
995
+ return f'\x1b[{90 + (idx - 8)}m'
996
+ if cap == '8':
997
+ idx = _rgb_to_8_color(r, g, b)
998
+ return f'\x1b[{30 + idx}m'
999
+ return ''
974
1000
 
975
- @cache_decorator
976
- def rgb_to_xterm_index(r, g, b):
1001
+ def _rgb_to_256_color(r, g, b):
977
1002
  """
978
- Map 24-bit RGB to nearest xterm-256 color index.
979
- r, g, b should be in 0-255.
980
- Returns an int in 0-255.
1003
+ Map (r,g,b) to the 256-color cube or grayscale ramp.
981
1004
  """
982
- best_index = 0
1005
+ # if it’s already gray, use the 232–255 grayscale ramp
1006
+ if r == g == b:
1007
+ # 24 shades from 232 to 255
1008
+ return 232 + int(round(r / 255 * 23))
1009
+ # else map each channel to 0–5
1010
+ def to6(v):
1011
+ return int(round(v / 255 * 5))
1012
+ r6, g6, b6 = to6(r), to6(g), to6(b)
1013
+ return 16 + 36 * r6 + 6 * g6 + b6
1014
+
1015
+ def _rgb_to_16_color(r, g, b):
1016
+ """
1017
+ Pick the nearest of the 16 ANSI standard colors.
1018
+ Returns an index 0-15.
1019
+ """
1020
+ palette = [
1021
+ (0, 0, 0), # 0 black
1022
+ (128, 0, 0), # 1 red
1023
+ (0, 128, 0), # 2 green
1024
+ (128, 128, 0), # 3 yellow
1025
+ (0, 0, 128), # 4 blue
1026
+ (128, 0, 128), # 5 magenta
1027
+ (0, 128, 128), # 6 cyan
1028
+ (192, 192, 192), # 7 white (light gray)
1029
+ (128, 128, 128), # 8 bright black (dark gray)
1030
+ (255, 0, 0), # 9 bright red
1031
+ (0, 255, 0), # 10 bright green
1032
+ (255, 255, 0), # 11 bright yellow
1033
+ (0, 0, 255), # 12 bright blue
1034
+ (255, 0, 255), # 13 bright magenta
1035
+ (0, 255, 255), # 14 bright cyan
1036
+ (255, 255, 255), # 15 bright white
1037
+ ]
1038
+ best_idx = 0
983
1039
  best_dist = float('inf')
984
- for i, (pr, pg, pb) in enumerate(get_xterm256_palette()):
985
- dr = pr - r
986
- dg = pg - g
987
- db = pb - b
988
- dist = dr*dr + dg*dg + db*db
1040
+ for i, (pr, pg, pb) in enumerate(palette):
1041
+ dist = (r - pr)**2 + (g - pg)**2 + (b - pb)**2
989
1042
  if dist < best_dist:
990
1043
  best_dist = dist
991
- best_index = i
992
- return best_index
1044
+ best_idx = i
1045
+ return best_idx
993
1046
 
994
- @cache_decorator
995
- def hashable_to_color(n, brightness_threshold=500):
996
- hash_value = hash(str(n))
1047
+ def _rgb_to_8_color(r, g, b):
1048
+ """
1049
+ Reduce to 8 colors by mapping to the 16-color index then clamping 0-7.
1050
+ """
1051
+ return _rgb_to_16_color(r//2, g//2, b//2)
1052
+
1053
+
1054
+ def int_to_color(hash_value, min_brightness=100,max_brightness=220):
997
1055
  r = (hash_value >> 16) & 0xFF
998
1056
  g = (hash_value >> 8) & 0xFF
999
1057
  b = hash_value & 0xFF
1000
- if (r + g + b) < brightness_threshold:
1001
- return hashable_to_color(hash_value, brightness_threshold)
1058
+ brightness = math.sqrt(0.299 * r**2 + 0.587 * g**2 + 0.114 * b**2)
1059
+ if brightness < min_brightness:
1060
+ return int_to_color(hash(str(hash_value)), min_brightness, max_brightness)
1061
+ if brightness > max_brightness:
1062
+ return int_to_color(hash(str(hash_value)), min_brightness, max_brightness)
1002
1063
  return (r, g, b)
1003
1064
 
1004
- __previous_ansi_color_index = -1
1065
+ __previous_color_rgb = ()
1005
1066
  @cache_decorator
1006
- def string_to_unique_ansi_color(string):
1067
+ def int_to_unique_ansi_color(number):
1007
1068
  '''
1008
- Convert a string to a unique ANSI color code
1069
+ Convert a number to a unique ANSI color code
1009
1070
 
1010
1071
  Args:
1011
- string (str): The string to convert
1012
-
1072
+ number (int): The number to convert
1013
1073
  Returns:
1014
1074
  int: The ANSI color code
1015
1075
  '''
1016
- global __previous_ansi_color_index
1076
+ global __previous_color_rgb
1017
1077
  # Use a hash function to generate a consistent integer from the string
1018
1078
  color_capability = get_terminal_color_capability()
1019
- index = None
1020
1079
  if color_capability == 'None':
1021
1080
  return ''
1022
- elif color_capability == '16':
1023
- # Map to one of the 14 colors (31-37, 90-96), avoiding black and white
1024
- index = (hash(string) % 14) + 31
1025
- if index > 37:
1026
- index += 52 # Bright colors (90-97)
1027
- elif color_capability == '8':
1028
- index = (hash(string) % 6) + 31
1029
- r,g,b = hashable_to_color(string)
1030
- if color_capability == '256':
1031
- index = rgb_to_xterm_index(r,g,b)
1032
- if index:
1033
- if index == __previous_ansi_color_index:
1034
- return string_to_unique_ansi_color(hash(string))
1035
- __previous_ansi_color_index = index
1036
- if color_capability == '256':
1037
- return f'\033[38;5;{index}m'
1038
- else:
1039
- return f'\033[{index}m'
1081
+ if color_capability == '24bit':
1082
+ r, g, b = int_to_color(number)
1040
1083
  else:
1041
- return f'\033[38;2;{r};{g};{b}m'
1084
+ # for 256 colors and below, reduce brightness threshold as we do not have many color to work with
1085
+ r, g, b = int_to_color(number, min_brightness=70, max_brightness=190)
1086
+ if sum(abs(a - b) for a, b in zip((r, g, b), __previous_color_rgb)) <= 256:
1087
+ r, g, b = int_to_color(hash(str(number)))
1088
+ __previous_color_rgb = (r, g, b)
1089
+ return rgb_to_ansi_color_string(r, g, b)
1042
1090
 
1043
1091
  #%% ------------ Compacting Hostnames ----------------
1044
1092
  def __tokenize_hostname(hostname):
@@ -1388,14 +1436,14 @@ def compact_hostnames(Hostnames,verify = True):
1388
1436
  # hostSet = frozenset(Hostnames)
1389
1437
  # else:
1390
1438
  # hostSet = Hostnames
1391
- hostSet = frozenset(
1439
+ hostSet = frozenset(expand_hostnames(
1392
1440
  hostname.strip()
1393
1441
  for hostnames_str in Hostnames
1394
1442
  for hostname in hostnames_str.split(',')
1395
- )
1443
+ ))
1396
1444
  compact_hosts = __compact_hostnames(hostSet)
1397
1445
  if verify:
1398
- if set(expand_hostnames(compact_hosts)) != set(expand_hostnames(hostSet)):
1446
+ if frozenset(expand_hostnames(compact_hosts)) != hostSet:
1399
1447
  if not __global_suppress_printout:
1400
1448
  eprint(f"Error compacting hostnames: {hostSet} -> {compact_hosts}")
1401
1449
  compact_hosts = hostSet
@@ -1765,6 +1813,7 @@ def run_command(host, sem, timeout=60,passwds=None, retry_limit = 5):
1765
1813
  host.command = replace_magic_strings(host.command,['#I#'],str(host.i),case_sensitive=False)
1766
1814
  host.command = replace_magic_strings(host.command,['#PASSWD#','#PASSWORD#'],passwds,case_sensitive=False)
1767
1815
  host.command = replace_magic_strings(host.command,['#UUID#'],str(host.uuid),case_sensitive=False)
1816
+ host.command = replace_magic_strings(host.command,['#IP#'],str(host.ip),case_sensitive=False)
1768
1817
  formatedCMD = []
1769
1818
  if host.extraargs and isinstance(host.extraargs, str):
1770
1819
  extraargs = host.extraargs.split()
@@ -1963,8 +2012,6 @@ def run_command(host, sem, timeout=60,passwds=None, retry_limit = 5):
1963
2012
  stdout_thread.join(timeout=1)
1964
2013
  stderr_thread.join(timeout=1)
1965
2014
  stdin_thread.join(timeout=1)
1966
- # here we handle the rest of the stdout after the subprocess returns
1967
- host.output.append('Pipe Closed. Trying to read the rest of the stdout...')
1968
2015
  if not _emo:
1969
2016
  stdout = None
1970
2017
  stderr = None
@@ -1973,8 +2020,10 @@ def run_command(host, sem, timeout=60,passwds=None, retry_limit = 5):
1973
2020
  except subprocess.TimeoutExpired:
1974
2021
  pass
1975
2022
  if stdout:
2023
+ host.output.append('Trying to read the rest of the stdout...')
1976
2024
  __handle_reading_stream(io.BytesIO(stdout),host.stdout, host,host.stdout_buffer)
1977
2025
  if stderr:
2026
+ host.output.append('Trying to read the rest of the stderr...')
1978
2027
  __handle_reading_stream(io.BytesIO(stderr),host.stderr, host,host.stderr_buffer)
1979
2028
  # if the last line in host.stderr is Connection to * closed., we will remove it
1980
2029
  host.returncode = proc.poll()
@@ -2570,7 +2619,7 @@ def __generate_display(stdscr, hosts, lineToDisplay = -1,curserPosition = 0, min
2570
2619
  elif key == curses.KEY_EXIT or key == 27: # 27 is the key code for ESC
2571
2620
  # if the key is exit, we will exit the program
2572
2621
  return
2573
- elif key == curses.KEY_HELP or key == 63 or key == curses.KEY_F1 or key == 8: # 63 is the key code for ?
2622
+ elif key == curses.KEY_HELP or key == 63 or key == curses.KEY_F1 or key == 8: # 63 is the key code for ?, 8 is the key code for backspace
2574
2623
  # if the key is help, we will display the help message
2575
2624
  if not help_shown:
2576
2625
  help_panel.show()
@@ -2790,7 +2839,7 @@ def mergeOutput(merging_hostnames,outputs_by_hostname,output,diff_display_thresh
2790
2839
  indexes = Counter({hostname: 0 for hostname in merging_hostnames})
2791
2840
  working_index_keys = set(merging_hostnames)
2792
2841
  previousBuddies = set()
2793
- hostnameWrapper = textwrap.TextWrapper(width=line_length - 1, tabsize=4, replace_whitespace=False, drop_whitespace=False, break_on_hyphens=False,initial_indent='├─ ', subsequent_indent='│- ')
2842
+ hostnameWrapper = textwrap.TextWrapper(width=line_length -1, tabsize=4, replace_whitespace=False, drop_whitespace=False, break_on_hyphens=False,initial_indent=' ', subsequent_indent='- ')
2794
2843
  hostnameWrapper.wordsep_simple_re = re.compile(r'([,]+)')
2795
2844
  diff_display_item_count = max(1,int(max(map(len, outputs_by_hostname.values())) * (1 - diff_display_threshold)))
2796
2845
  def get_multiset_index_for_hostname(hostname):
@@ -2825,6 +2874,13 @@ def mergeOutput(merging_hostnames,outputs_by_hostname,output,diff_display_thresh
2825
2874
  # futures[hostname] # ensure it's initialized
2826
2875
  futures = {hostname: get_multiset_index_for_hostname(hostname) for hostname in merging_hostnames}
2827
2876
  currentLines = defaultdict(set)
2877
+ color_cap = get_terminal_color_capability()
2878
+ if color_cap == 'None':
2879
+ green_str = ''
2880
+ reset_str = ''
2881
+ else:
2882
+ green_str = rgb_to_ansi_color_string(*COLOR_PALETTE.get('green', __DEFAULT_COLOR_PALETTE['green']))
2883
+ reset_str = '\033[0m'
2828
2884
  for hostname in merging_hostnames:
2829
2885
  currentLines[outputs_by_hostname[hostname][0]].add(hostname)
2830
2886
  while indexes:
@@ -2862,12 +2918,18 @@ def mergeOutput(merging_hostnames,outputs_by_hostname,output,diff_display_thresh
2862
2918
  if buddy != previousBuddies:
2863
2919
  hostnameStr = ','.join(compact_hostnames(buddy))
2864
2920
  hostnameLines = hostnameWrapper.wrap(hostnameStr)
2865
- hostnameLines = [line.ljust(line_length - 1) + '│' for line in hostnameLines]
2866
- color = string_to_unique_ansi_color(hostnameStr) if len(buddy) < len(merging_hostnames) else ''
2867
- hostnameLines[0] = f"\033[0m{color}{hostnameLines[0]}"
2921
+ # hostnameLines = [line.ljust(line_length) for line in hostnameLines]
2922
+ if color_cap == 'None':
2923
+ hostnameLines[0] = f"{hostnameLines[0]}"
2924
+ elif len(buddy) < len(merging_hostnames):
2925
+ color = int_to_unique_ansi_color(hash(hostnameStr))
2926
+ hostnameLines[0] = f"{color}■{hostnameLines[0]}"
2927
+ hostnameLines[-1] += reset_str
2928
+ else:
2929
+ hostnameLines[0] = f"{green_str}■{reset_str}{hostnameLines[0]}"
2868
2930
  output.extend(hostnameLines)
2869
2931
  previousBuddies = buddy
2870
- output.append(lineToAdd.ljust(line_length - 1) + '│')
2932
+ output.append(lineToAdd)
2871
2933
  currentLines[lineToAdd].difference_update(buddy)
2872
2934
  if not currentLines[lineToAdd]:
2873
2935
  del currentLines[lineToAdd]
@@ -2891,16 +2953,27 @@ def mergeOutput(merging_hostnames,outputs_by_hostname,output,diff_display_thresh
2891
2953
 
2892
2954
  def mergeOutputs(outputs_by_hostname, merge_groups, remaining_hostnames, diff_display_threshold, line_length):
2893
2955
  output = []
2894
- output.append(('┌'+'─'*(line_length-2) + '┐'))
2956
+ color_cap = get_terminal_color_capability()
2957
+ if color_cap == 'None':
2958
+ color_line = ''
2959
+ color_reset = ''
2960
+ green_str = ''
2961
+ else:
2962
+ color_line = rgb_to_ansi_color_string(*COLOR_PALETTE.get('white', __DEFAULT_COLOR_PALETTE['white']))
2963
+ color_reset = '\033[0m'
2964
+ green_str = rgb_to_ansi_color_string(*COLOR_PALETTE.get('green', __DEFAULT_COLOR_PALETTE['green']))
2965
+ output.append(color_line+'─'*(line_length)+color_reset)
2966
+ hostnameWrapper = textwrap.TextWrapper(width=line_length - 1, tabsize=4, replace_whitespace=False, drop_whitespace=False, break_on_hyphens=False,initial_indent=' ', subsequent_indent='- ')
2967
+ hostnameWrapper.wordsep_simple_re = re.compile(r'([,]+)')
2895
2968
  for merging_hostnames in merge_groups:
2896
2969
  mergeOutput(merging_hostnames, outputs_by_hostname, output, diff_display_threshold,line_length)
2897
- output.append('\033[0m├'+'─'*(line_length-2) + '┤')
2970
+ output.append(color_line+'─'*(line_length)+color_reset)
2898
2971
  for hostname in remaining_hostnames:
2899
- hostnameLines = textwrap.wrap(hostname, width=line_length-1, tabsize=4, replace_whitespace=False, drop_whitespace=False,
2900
- initial_indent='├─ ', subsequent_indent='│- ')
2901
- output.extend(line.ljust(line_length - 1) + '│' for line in hostnameLines)
2902
- output.extend(line.ljust(line_length - 1) + '│' for line in outputs_by_hostname[hostname])
2903
- output.append('\033[0m├'+'─'*(line_length-2) + '┤')
2972
+ hostnameLines = hostnameWrapper.wrap(','.join(compact_hostnames([hostname])))
2973
+ hostnameLines[0] = f"{green_str}■{color_reset}{hostnameLines[0]}"
2974
+ output.extend(hostnameLines)
2975
+ output.extend(outputs_by_hostname[hostname])
2976
+ output.append(color_line+'─'*(line_length)+color_reset)
2904
2977
  if output:
2905
2978
  output.pop()
2906
2979
  # if output and output[0] and output[0].startswith('├'):
@@ -2917,7 +2990,7 @@ def pre_merge_hosts(hosts):
2917
2990
  # Create merged hosts
2918
2991
  merged_hosts = []
2919
2992
  for group in output_groups.values():
2920
- group[0].name = ','.join(host.name for host in group)
2993
+ group[0].name = ','.join(compact_hostnames(host.name for host in group))
2921
2994
  merged_hosts.append(group[0])
2922
2995
  return merged_hosts
2923
2996
 
@@ -2925,30 +2998,45 @@ def get_host_raw_output(hosts, terminal_width):
2925
2998
  outputs_by_hostname = {}
2926
2999
  line_bag_by_hostname = {}
2927
3000
  hostnames_by_line_bag_len = {}
2928
- text_wrapper = textwrap.TextWrapper(width=terminal_width - 2, tabsize=4, replace_whitespace=False, drop_whitespace=False,
2929
- initial_indent=' ', subsequent_indent='│-')
3001
+ text_wrapper = textwrap.TextWrapper(width=terminal_width - 1, tabsize=4, replace_whitespace=False, drop_whitespace=False,
3002
+ initial_indent=' ', subsequent_indent='-')
2930
3003
  max_length = 20
3004
+ color_cap = get_terminal_color_capability()
3005
+ if color_cap == 'None':
3006
+ color_reset_str = ''
3007
+ blue_str = ''
3008
+ cyan_str = ''
3009
+ green_str = ''
3010
+ red_str = ''
3011
+ else:
3012
+ color_reset_str = '\033[0m'
3013
+ blue_str = rgb_to_ansi_color_string(*COLOR_PALETTE.get('blue', __DEFAULT_COLOR_PALETTE['blue']))
3014
+ cyan_str = rgb_to_ansi_color_string(*COLOR_PALETTE.get('bright_cyan', __DEFAULT_COLOR_PALETTE['bright_cyan']))
3015
+ green_str = rgb_to_ansi_color_string(*COLOR_PALETTE.get('bright_green', __DEFAULT_COLOR_PALETTE['bright_green']))
3016
+ red_str = rgb_to_ansi_color_string(*COLOR_PALETTE.get('bright_red', __DEFAULT_COLOR_PALETTE['bright_red']))
2931
3017
  hosts = pre_merge_hosts(hosts)
2932
3018
  for host in hosts:
2933
- hostPrintOut = ["│█ EXECUTED COMMAND:"]
3019
+ max_length = max(max_length, len(max(host.name.split(','), key=len)) + 3)
3020
+ hostPrintOut = [f"{cyan_str}█{color_reset_str} EXECUTED COMMAND:"]
2934
3021
  for line in host.command.splitlines():
2935
3022
  hostPrintOut.extend(text_wrapper.wrap(line))
2936
3023
  # hostPrintOut.extend(itertools.chain.from_iterable(text_wrapper.wrap(line) for line in host['command'].splitlines()))
2937
3024
  lineBag = {(0,host.command)}
2938
3025
  prevLine = host.command
2939
3026
  if host.stdout:
2940
- hostPrintOut.append('│▓ STDOUT:')
3027
+ hostPrintOut.append(f'{blue_str}▓{color_reset_str} STDOUT:')
2941
3028
  # for line in host.stdout:
2942
3029
  # if len(line) < terminal_width - 2:
2943
- # hostPrintOut.append(f" {line}")
3030
+ # hostPrintOut.append(f" {line}")
2944
3031
  # else:
2945
3032
  # hostPrintOut.extend(text_wrapper.wrap(line))
2946
- hostPrintOut.extend(f" {line}" for line in host.stdout)
3033
+ hostPrintOut.extend(f" {line}" for line in host.stdout)
3034
+ max_length = max(max_length, max(map(len, host.stdout)))
2947
3035
  # hostPrintOut.extend(text_wrapper.wrap(line) for line in host.stdout)
2948
3036
  lineBag.add((prevLine,1))
2949
3037
  lineBag.add((1,host.stdout[0]))
2950
3038
  if len(host.stdout) > 1:
2951
- lineBag.update(itertools.pairwise(host.stdout))
3039
+ lineBag.update(zip(host.stdout, host.stdout[1:]))
2952
3040
  lineBag.update(host.stdout)
2953
3041
  prevLine = host.stdout[-1]
2954
3042
  if host.stderr:
@@ -2959,22 +3047,26 @@ def get_host_raw_output(hosts, terminal_width):
2959
3047
  elif host.stderr[-1].strip().endswith('No route to host'):
2960
3048
  host.stderr[-1] = 'Cannot find host!'
2961
3049
  if host.stderr:
2962
- hostPrintOut.append('│▒ STDERR:')
3050
+ hostPrintOut.append(f'{red_str}▒{color_reset_str} STDERR:')
2963
3051
  # for line in host.stderr:
2964
3052
  # if len(line) < terminal_width - 2:
2965
- # hostPrintOut.append(f" {line}")
3053
+ # hostPrintOut.append(f" {line}")
2966
3054
  # else:
2967
3055
  # hostPrintOut.extend(text_wrapper.wrap(line))
2968
- hostPrintOut.extend(f" {line}" for line in host.stderr)
3056
+ hostPrintOut.extend(f" {line}" for line in host.stderr)
3057
+ max_length = max(max_length, max(map(len, host.stderr)))
2969
3058
  lineBag.add((prevLine,2))
2970
3059
  lineBag.add((2,host.stderr[0]))
2971
3060
  lineBag.update(host.stderr)
2972
3061
  if len(host.stderr) > 1:
2973
- lineBag.update(itertools.pairwise(host.stderr))
3062
+ lineBag.update(zip(host.stderr, host.stderr[1:]))
2974
3063
  prevLine = host.stderr[-1]
2975
- hostPrintOut.append(f"│░ RETURN CODE: {host.returncode}")
3064
+ if host.returncode != 0:
3065
+ codeColor = red_str
3066
+ else:
3067
+ codeColor = green_str
3068
+ hostPrintOut.append(f"{codeColor}░{color_reset_str} RETURN CODE: {host.returncode}")
2976
3069
  lineBag.add((prevLine,f"{host.returncode}"))
2977
- max_length = max(max_length, max(map(len, hostPrintOut)))
2978
3070
  outputs_by_hostname[host.name] = hostPrintOut
2979
3071
  line_bag_by_hostname[host.name] = lineBag
2980
3072
  hostnames_by_line_bag_len.setdefault(len(lineBag), set()).add(host.name)
@@ -3015,6 +3107,7 @@ def form_merge_groups(hostnames_by_line_bag_len, sorted_hostnames_by_line_bag_le
3015
3107
  return merge_groups, remaining_hostnames
3016
3108
 
3017
3109
  def generate_output(hosts, usejson = False, greppable = False,quiet = False,encoding = _encoding,keyPressesIn = [[]]):
3110
+ color_cap = get_terminal_color_capability()
3018
3111
  if quiet:
3019
3112
  # remove hosts with returncode 0
3020
3113
  hosts = [host for host in hosts if host.returncode != 0]
@@ -3022,7 +3115,10 @@ def generate_output(hosts, usejson = False, greppable = False,quiet = False,enco
3022
3115
  if usejson:
3023
3116
  return '{"Success": true}'
3024
3117
  else:
3025
- return 'Success'
3118
+ if color_cap == 'None':
3119
+ return 'Success'
3120
+ else:
3121
+ return '\033[32mSuccess\033[0m'
3026
3122
  if usejson:
3027
3123
  # [print(dict(host)) for host in hosts]
3028
3124
  #print(json.dumps([dict(host) for host in hosts],indent=4))
@@ -3057,23 +3153,32 @@ def generate_output(hosts, usejson = False, greppable = False,quiet = False,enco
3057
3153
  except Exception:
3058
3154
  eprint("Warning: diff_display_threshold should be a float between 0 and 1. Setting to default value of 0.9")
3059
3155
  diff_display_threshold = 0.9
3156
+
3157
+ color_reset_str = '' if color_cap == 'None' else '\033[0m'
3158
+ white_str = '' if color_cap == 'None' else rgb_to_ansi_color_string(*COLOR_PALETTE.get('white', __DEFAULT_COLOR_PALETTE['white']))
3060
3159
  terminal_length = get_terminal_size()[0]
3061
3160
  outputs_by_hostname, line_bag_by_hostname, hostnames_by_line_bag_len, sorted_hostnames_by_line_bag_len_keys, line_length = get_host_raw_output(hosts,terminal_length)
3062
3161
  merge_groups ,remaining_hostnames = form_merge_groups(hostnames_by_line_bag_len, sorted_hostnames_by_line_bag_len_keys, line_bag_by_hostname, diff_display_threshold)
3063
3162
  outputs = mergeOutputs(outputs_by_hostname, merge_groups,remaining_hostnames, diff_display_threshold,line_length)
3064
3163
  if keyPressesIn[-1]:
3065
3164
  CMDsOut = [''.join(cmd).encode(encoding=encoding,errors='backslashreplace').decode(encoding=encoding,errors='backslashreplace').replace('\\n', '↵') for cmd in keyPressesIn if cmd]
3066
- outputs.append("├─ User Inputs:".ljust(line_length -1,'─')+'┤')
3165
+ outputs.append(color_reset_str + " User Inputs:".ljust(line_length,'─'))
3067
3166
  cmdOut = []
3068
3167
  for line in CMDsOut:
3069
3168
  cmdOut.extend(textwrap.wrap(line, width=line_length-1, tabsize=4, replace_whitespace=False, drop_whitespace=False,
3070
- initial_indent=' ', subsequent_indent='│-'))
3071
- outputs.extend(cmd.ljust(line_length -1)+'│' for cmd in cmdOut)
3169
+ initial_indent=' ', subsequent_indent='-'))
3170
+ outputs.extend(cmdOut)
3072
3171
  keyPressesIn[-1].clear()
3073
3172
  if not outputs:
3074
- rtnStr = 'Success' if quiet else ''
3173
+ if quiet:
3174
+ if color_cap == 'None':
3175
+ return 'Success'
3176
+ else:
3177
+ return '\033[32mSuccess\033[0m'
3178
+ else:
3179
+ rtnStr = ''
3075
3180
  else:
3076
- rtnStr = '\n'.join(outputs + [('\033[0m└'+'─'*(line_length-2)+'┘')])
3181
+ rtnStr = '\n'.join(outputs + [white_str + '─' * (line_length) + color_reset_str])
3077
3182
  return rtnStr
3078
3183
 
3079
3184
  def print_output(hosts,usejson = False,quiet = False,greppable = False):
@@ -3119,7 +3224,16 @@ def processRunOnHosts(timeout, password, max_connections, hosts, returnUnfinishe
3119
3224
  if total_sleeped > 0.1:
3120
3225
  break
3121
3226
  if any([host.returncode is None for host in hosts]):
3122
- curses.wrapper(curses_print, hosts, threads, min_char_len = curses_min_char_len, min_line_len = curses_min_line_len, single_window = single_window)
3227
+ try:
3228
+ curses.wrapper(curses_print, hosts, threads, min_char_len = curses_min_char_len, min_line_len = curses_min_line_len, single_window = single_window)
3229
+ except Exception:
3230
+ try:
3231
+ os.environ['TERM'] = 'xterm-256color'
3232
+ curses.wrapper(curses_print, hosts, threads, min_char_len = curses_min_char_len, min_line_len = curses_min_line_len, single_window = single_window)
3233
+ except Exception as e:
3234
+ eprint(f"Curses print error: {e}")
3235
+ import traceback
3236
+ print(traceback.format_exc())
3123
3237
  if not returnUnfinished:
3124
3238
  # wait until all hosts have a return code
3125
3239
  while any([host.returncode is None for host in hosts]):
@@ -3209,7 +3323,7 @@ def __formCommandArgStr(oneonone = DEFAULT_ONE_ON_ONE, timeout = DEFAULT_TIMEOUT
3209
3323
  no_env=DEFAULT_NO_ENV,greppable=DEFAULT_GREPPABLE_MODE,skip_hosts = DEFAULT_SKIP_HOSTS,
3210
3324
  file_sync = False, error_only = DEFAULT_ERROR_ONLY, identity_file = DEFAULT_IDENTITY_FILE,
3211
3325
  copy_id = False, unavailable_host_expiry = DEFAULT_UNAVAILABLE_HOST_EXPIRY, no_history = DEFAULT_NO_HISTORY,
3212
- history_file = DEFAULT_HISTORY_FILE, env_file = DEFAULT_ENV_FILES,
3326
+ history_file = DEFAULT_HISTORY_FILE, env_file = '', env_files = DEFAULT_ENV_FILES,
3213
3327
  repeat = DEFAULT_REPEAT,interval = DEFAULT_INTERVAL,
3214
3328
  shortend = False) -> str:
3215
3329
  argsList = []
@@ -3253,8 +3367,10 @@ def __formCommandArgStr(oneonone = DEFAULT_ONE_ON_ONE, timeout = DEFAULT_TIMEOUT
3253
3367
  argsList.append(f'--unavailable_host_expiry={unavailable_host_expiry}' if not shortend else f'-uhe={unavailable_host_expiry}')
3254
3368
  if no_env:
3255
3369
  argsList.append('--no_env')
3256
- if env_file and env_file != DEFAULT_ENV_FILES:
3257
- argsList.extend([f'--env_file="{ef}"' for ef in env_file] if not shortend else [f'-ef="{ef}"' for ef in env_file])
3370
+ if env_file:
3371
+ argsList.append(f'--env_file="{env_file}"' if not shortend else f'-ef="{env_file}"')
3372
+ if env_files:
3373
+ argsList.extend([f'--env_files="{ef}"' for ef in env_files] if not shortend else [f'-efs="{ef}"' for ef in env_files])
3258
3374
  if no_history:
3259
3375
  argsList.append('--no_history' if not shortend else '-nh')
3260
3376
  if history_file and history_file != DEFAULT_HISTORY_FILE:
@@ -3277,7 +3393,7 @@ def getStrCommand(hosts = DEFAULT_HOSTS,commands = None,oneonone = DEFAULT_ONE_O
3277
3393
  skip_hosts = DEFAULT_SKIP_HOSTS, curses_min_char_len = DEFAULT_CURSES_MINIMUM_CHAR_LEN, curses_min_line_len = DEFAULT_CURSES_MINIMUM_LINE_LEN,
3278
3394
  single_window = DEFAULT_SINGLE_WINDOW,file_sync = False,error_only = DEFAULT_ERROR_ONLY, identity_file = DEFAULT_IDENTITY_FILE,
3279
3395
  copy_id = False, unavailable_host_expiry = DEFAULT_UNAVAILABLE_HOST_EXPIRY,no_history = DEFAULT_NO_HISTORY,
3280
- history_file = DEFAULT_HISTORY_FILE, env_file = DEFAULT_ENV_FILES,
3396
+ history_file = DEFAULT_HISTORY_FILE, env_file = '', env_files = DEFAULT_ENV_FILES,
3281
3397
  repeat = DEFAULT_REPEAT,interval = DEFAULT_INTERVAL,
3282
3398
  shortend = False,tabSeperated = False):
3283
3399
  _ = called
@@ -3297,7 +3413,7 @@ def getStrCommand(hosts = DEFAULT_HOSTS,commands = None,oneonone = DEFAULT_ONE_O
3297
3413
  no_env=no_env, greppable=greppable,skip_hosts = skip_hosts,
3298
3414
  file_sync = file_sync,error_only = error_only, identity_file = identity_file,
3299
3415
  copy_id = copy_id, unavailable_host_expiry =unavailable_host_expiry,no_history = no_history,
3300
- history_file = history_file, env_file = env_file,
3416
+ history_file = history_file, env_file = env_file, env_files = env_files,
3301
3417
  repeat = repeat,interval = interval,
3302
3418
  shortend = shortend)
3303
3419
  commands = [command.replace('"', '\\"').replace('\n', '\\n').replace('\t', '\\t') for command in format_commands(commands)]
@@ -3356,48 +3472,48 @@ def run_command_on_hosts(hosts = DEFAULT_HOSTS,commands = None,oneonone = DEFAUL
3356
3472
  copy_id = False, unavailable_host_expiry = DEFAULT_UNAVAILABLE_HOST_EXPIRY,no_history = True,
3357
3473
  history_file = DEFAULT_HISTORY_FILE,
3358
3474
  ):
3359
- f'''
3360
- Run the command on the hosts, aka multissh. main function
3361
-
3362
- Args:
3363
- hosts (str/iterable): A string of hosts seperated by space or comma / iterable of hosts. Default to {DEFAULT_HOSTS}.
3364
- commands (list): A list of commands to run on the hosts. When using files, defines the destination of the files. Defaults to None.
3365
- oneonone (bool, optional): Whether to run the commands one on one. Defaults to {DEFAULT_ONE_ON_ONE}.
3366
- timeout (int, optional): The timeout for the command. Defaults to {DEFAULT_TIMEOUT}.
3367
- password (str, optional): The password for the hosts. Defaults to {DEFAULT_PASSWORD}.
3368
- no_watch (bool, optional): Whether to print the output. Defaults to {DEFAULT_NO_WATCH}.
3369
- json (bool, optional): Whether to print the output in JSON format. Defaults to {DEFAULT_JSON_MODE}.
3370
- called (bool, optional): Whether the function is called by another function. Defaults to {_DEFAULT_CALLED}.
3371
- max_connections (int, optional): The maximum number of concurrent SSH sessions. Defaults to 4 * os.cpu_count().
3372
- files (list, optional): A list of files to be copied to the hosts. Defaults to None.
3373
- ipmi (bool, optional): Whether to use IPMI to connect to the hosts. Defaults to {DEFAULT_IPMI}.
3374
- interface_ip_prefix (str, optional): The prefix of the IPMI interface. Defaults to {DEFAULT_INTERFACE_IP_PREFIX}.
3375
- returnUnfinished (bool, optional): Whether to return the unfinished hosts. Defaults to {_DEFAULT_RETURN_UNFINISHED}.
3376
- scp (bool, optional): Whether to use scp instead of rsync. Defaults to {DEFAULT_SCP}.
3377
- gather_mode (bool, optional): Whether to use gather mode. Defaults to False.
3378
- username (str, optional): The username to use to connect to the hosts. Defaults to {DEFAULT_USERNAME}.
3379
- extraargs (str, optional): Extra arguments to pass to the ssh / rsync / scp command. Defaults to {DEFAULT_EXTRA_ARGS}.
3380
- skipUnreachable (bool, optional): Whether to skip unreachable hosts. Defaults to {DEFAULT_SKIP_UNREACHABLE}.
3381
- no_env (bool, optional): Whether to not read the current sat system environment variables. (Will still read from files) Defaults to {DEFAULT_NO_ENV}.
3382
- greppable (bool, optional): Whether to print the output in greppable format. Defaults to {DEFAULT_GREPPABLE_MODE}.
3383
- willUpdateUnreachableHosts (bool, optional): Whether to update the global unavailable hosts. Defaults to {_DEFAULT_UPDATE_UNREACHABLE_HOSTS}.
3384
- no_start (bool, optional): Whether to return the hosts without starting the command. Defaults to {_DEFAULT_NO_START}.
3385
- skip_hosts (str, optional): The hosts to skip. Defaults to {DEFAULT_SKIP_HOSTS}.
3386
- min_char_len (int, optional): The minimum character per line of the curses output. Defaults to {DEFAULT_CURSES_MINIMUM_CHAR_LEN}.
3387
- min_line_len (int, optional): The minimum line number for each window of the curses output. Defaults to {DEFAULT_CURSES_MINIMUM_LINE_LEN}.
3388
- single_window (bool, optional): Whether to use a single window for the curses output. Defaults to {DEFAULT_SINGLE_WINDOW}.
3389
- file_sync (bool, optional): Whether to use file sync mode to sync directories. Defaults to {DEFAULT_FILE_SYNC}.
3390
- error_only (bool, optional): Whether to only print the error output. Defaults to {DEFAULT_ERROR_ONLY}.
3391
- quiet (bool, optional): Whether to suppress all verbose printout, added for compatibility, avoid using. Defaults to False.
3392
- identity_file (str, optional): The identity file to use for the ssh connection. Defaults to {DEFAULT_IDENTITY_FILE}.
3393
- copy_id (bool, optional): Whether to copy the id to the hosts. Defaults to False.
3394
- unavailable_host_expiry (int, optional): The time in seconds to keep the unavailable hosts in the global unavailable hosts. Defaults to {DEFAULT_UNAVAILABLE_HOST_EXPIRY}.
3395
- no_history (bool, optional): Whether to not save the history of the command. Defaults to True.
3396
- history_file (str, optional): The file to save the history of the command. Defaults to {DEFAULT_HISTORY_FILE}.
3475
+ """
3476
+ Run commands on multiple hosts via SSH or IPMI.
3477
+
3478
+ Parameters:
3479
+ hosts (str or iterable): Hosts to run the command on. Can be a string (comma/space-separated) or iterable. Default: DEFAULT_HOSTS.
3480
+ commands (list or None): List of commands to run on the hosts. If files are used, defines the destination. Default: None.
3481
+ oneonone (bool): If True, run each command on the corresponding host (1:1 mapping). Default: DEFAULT_ONE_ON_ONE.
3482
+ timeout (int): Timeout for each command in seconds. Default: DEFAULT_TIMEOUT.
3483
+ password (str): Password for SSH/IPMI authentication. Default: DEFAULT_PASSWORD.
3484
+ no_watch (bool): If True, do not use curses TUI; just print output. Default: DEFAULT_NO_WATCH.
3485
+ json (bool): If True, output results in JSON format. Default: DEFAULT_JSON_MODE.
3486
+ called (bool): If True, function is called programmatically (not CLI). Default: _DEFAULT_CALLED.
3487
+ max_connections (int): Maximum concurrent SSH sessions. Default: 4 * os.cpu_count().
3488
+ files (list or None): Files to copy to hosts. Default: None.
3489
+ ipmi (bool): Use IPMI instead of SSH. Default: DEFAULT_IPMI.
3490
+ interface_ip_prefix (str or None): Override IP prefix for host connection. Default: DEFAULT_INTERFACE_IP_PREFIX.
3491
+ returnUnfinished (bool): If True, return hosts even if not finished. Default: _DEFAULT_RETURN_UNFINISHED.
3492
+ scp (bool): Use scp for file transfer (instead of rsync). Default: DEFAULT_SCP.
3493
+ gather_mode (bool): Gather files from hosts (pull mode). Default: False.
3494
+ username (str or None): Username for SSH/IPMI. Default: DEFAULT_USERNAME.
3495
+ extraargs (str or list or None): Extra args for SSH/SCP/rsync. Default: DEFAULT_EXTRA_ARGS.
3496
+ skipUnreachable (bool): Skip hosts marked as unreachable. Default: DEFAULT_SKIP_UNREACHABLE.
3497
+ no_env (bool): Do not load environment variables from shell. Default: DEFAULT_NO_ENV.
3498
+ greppable (bool): Output in greppable table format. Default: DEFAULT_GREPPABLE_MODE.
3499
+ willUpdateUnreachableHosts (bool): Update global unreachable hosts file. Default: _DEFAULT_UPDATE_UNREACHABLE_HOSTS.
3500
+ no_start (bool): If True, return Host objects without running commands. Default: _DEFAULT_NO_START.
3501
+ skip_hosts (str or None): Hosts to skip. Default: DEFAULT_SKIP_HOSTS.
3502
+ curses_min_char_len (int): Minimum width per curses window. Default: DEFAULT_CURSES_MINIMUM_CHAR_LEN.
3503
+ curses_min_line_len (int): Minimum height per curses window. Default: DEFAULT_CURSES_MINIMUM_LINE_LEN.
3504
+ single_window (bool): Use a single curses window for all hosts. Default: DEFAULT_SINGLE_WINDOW.
3505
+ file_sync (bool): Enable file sync mode (sync directories). Default: DEFAULT_FILE_SYNC.
3506
+ error_only (bool): Only print error output. Default: DEFAULT_ERROR_ONLY.
3507
+ quiet (bool): Suppress all output (overrides other output options). Default: False.
3508
+ identity_file (str or None): SSH identity file. Default: DEFAULT_IDENTITY_FILE.
3509
+ copy_id (bool): Use ssh-copy-id to copy public key to hosts. Default: False.
3510
+ unavailable_host_expiry (int): Seconds to keep hosts marked as unavailable. Default: DEFAULT_UNAVAILABLE_HOST_EXPIRY.
3511
+ no_history (bool): Do not record command history. Default: True.
3512
+ history_file (str): File to store command history. Default: DEFAULT_HISTORY_FILE.
3397
3513
 
3398
3514
  Returns:
3399
- list: A list of Host objects
3400
- '''
3515
+ list: List of Host objects representing each host/command run.
3516
+ """
3401
3517
  global __globalUnavailableHosts
3402
3518
  global __global_suppress_printout
3403
3519
  global _no_env
@@ -3665,7 +3781,7 @@ def generate_default_config(args):
3665
3781
  'DEFAULT_USERNAME': args.username,
3666
3782
  'DEFAULT_PASSWORD': args.password,
3667
3783
  'DEFAULT_IDENTITY_FILE': args.key if args.key and not os.path.isdir(args.key) else DEFAULT_IDENTITY_FILE,
3668
- 'DEDAULT_SSH_KEY_SEARCH_PATH': args.key if args.key and os.path.isdir(args.key) else DEDAULT_SSH_KEY_SEARCH_PATH,
3784
+ 'DEFAULT_SSH_KEY_SEARCH_PATH': args.key if args.key and os.path.isdir(args.key) else DEFAULT_SSH_KEY_SEARCH_PATH,
3669
3785
  'DEFAULT_USE_KEY': args.use_key,
3670
3786
  'DEFAULT_EXTRA_ARGS': args.extraargs,
3671
3787
  'DEFAULT_ONE_ON_ONE': args.oneonone,
@@ -3689,7 +3805,7 @@ def generate_default_config(args):
3689
3805
  'DEFAULT_NO_OUTPUT': args.no_output,
3690
3806
  'DEFAULT_RETURN_ZERO': args.return_zero,
3691
3807
  'DEFAULT_NO_ENV': args.no_env,
3692
- 'DEFAULT_ENV_FILES': args.env_file,
3808
+ 'DEFAULT_ENV_FILES': args.env_files,
3693
3809
  'DEFAULT_NO_HISTORY': args.no_history,
3694
3810
  'DEFAULT_HISTORY_FILE': args.history_file,
3695
3811
  'DEFAULT_MAX_CONNECTIONS': args.max_connections if args.max_connections != 4 * os.cpu_count() else None,
@@ -3752,12 +3868,12 @@ def get_parser():
3752
3868
  parser.add_argument('commands', metavar='commands', type=str, nargs='*',default=None,help='the command to run on the hosts / the destination of the files #HOST# or #HOSTNAME# will be replaced with the host name.')
3753
3869
  parser.add_argument('-u','--username', type=str,help=f'The general username to use to connect to the hosts. Will get overwrote by individual username@host if specified. (default: {DEFAULT_USERNAME})',default=DEFAULT_USERNAME)
3754
3870
  parser.add_argument('-p', '--password', type=str,help=f'The password to use to connect to the hosts, (default: {DEFAULT_PASSWORD})',default=DEFAULT_PASSWORD)
3755
- parser.add_argument('-k','--key','--identity',nargs='?', type=str,help=f'The identity file to use to connect to the hosts. Implies --use_key. Specify a folder for program to search for a key. Use option without value to use {DEDAULT_SSH_KEY_SEARCH_PATH} (default: {DEFAULT_IDENTITY_FILE})',const=DEDAULT_SSH_KEY_SEARCH_PATH,default=DEFAULT_IDENTITY_FILE)
3871
+ parser.add_argument('-k','--key','--identity',nargs='?', type=str,help=f'The identity file to use to connect to the hosts. Implies --use_key. Specify a folder for program to search for a key. Use option without value to use {DEFAULT_SSH_KEY_SEARCH_PATH} (default: {DEFAULT_IDENTITY_FILE})',const=DEFAULT_SSH_KEY_SEARCH_PATH,default=DEFAULT_IDENTITY_FILE)
3756
3872
  parser.add_argument('-uk','--use_key', action='store_true', help=f'Attempt to use public key file to connect to the hosts. (default: {DEFAULT_USE_KEY})', default=DEFAULT_USE_KEY)
3757
3873
  parser.add_argument('-ea','--extraargs',type=str,help=f'Extra arguments to pass to the ssh / rsync / scp command. Put in one string for multiple arguments.Use "=" ! Ex. -ea="--delete" (default: {DEFAULT_EXTRA_ARGS})',default=DEFAULT_EXTRA_ARGS)
3758
3874
  parser.add_argument("-11",'--oneonone', action='store_true', help=f"Run one corresponding command on each host. (default: {DEFAULT_ONE_ON_ONE})", default=DEFAULT_ONE_ON_ONE)
3759
3875
  parser.add_argument("-f","--file", action='append', help="The file to be copied to the hosts. Use -f multiple times to copy multiple files")
3760
- parser.add_argument('-s','-fs','--file_sync', action='store_true', help=f'Operate in file sync mode, sync path in <COMMANDS> from this machine to <HOSTS>. Treat --file <FILE> and <COMMANDS> both as source and source and destination will be the same in this mode. Infer destination from source path. (default: {DEFAULT_FILE_SYNC})', default=DEFAULT_FILE_SYNC)
3876
+ parser.add_argument('-s','-fs','--file_sync',nargs='?', action='append', help=f'Operate in file sync mode, sync path in <COMMANDS> from this machine to <HOSTS>. Treat --file <FILE> and <COMMANDS> both as source and source and destination will be the same in this mode. Infer destination from source path. (default: {DEFAULT_FILE_SYNC})',const=True, default=[DEFAULT_FILE_SYNC])
3761
3877
  parser.add_argument('-W','--scp', action='store_true', help=f'Use scp for copying files instead of rsync. Need to use this on windows. (default: {DEFAULT_SCP})', default=DEFAULT_SCP)
3762
3878
  parser.add_argument('-G','-gm','--gather_mode', action='store_true', help='Gather files from the hosts instead of sending files to the hosts. Will send remote files specified in <FILE> to local path specified in <COMMANDS> (default: False)', default=False)
3763
3879
  #parser.add_argument("-d",'-c',"--destination", type=str, help="The destination of the files. Same as specify with commands. Added for compatibility. Use #HOST# or #HOSTNAME# to replace the host name in the destination")
@@ -3778,7 +3894,8 @@ def get_parser():
3778
3894
  parser.add_argument('-Q',"-no","--no_output", action='store_true', help=f"Do not print the output. (default: {DEFAULT_NO_OUTPUT})", default=DEFAULT_NO_OUTPUT)
3779
3895
  parser.add_argument('-Z','-rz','--return_zero', action='store_true', help=f"Return 0 even if there are errors. (default: {DEFAULT_RETURN_ZERO})", default=DEFAULT_RETURN_ZERO)
3780
3896
  parser.add_argument('-C','--no_env', action='store_true', help=f'Do not load the command line environment variables. (default: {DEFAULT_NO_ENV})', default=DEFAULT_NO_ENV)
3781
- parser.add_argument("--env_file", action='append', help=f"The files to load the mssh file based environment variables from. Can specify multiple. Load first to last. ( Still work with --no_env ) (default: {DEFAULT_ENV_FILES})", default=DEFAULT_ENV_FILES)
3897
+ parser.add_argument('-ef',"--env_file", type=str, help="Replace the env file look up chain with this env_file. ( Still work with --no_env ) (default: None)", default='')
3898
+ parser.add_argument('-efs',"--env_files", action='append', help=f"The files to load the mssh file based environment variables from. Can specify multiple. Load first to last. ( Still work with --no_env ) (default: {DEFAULT_ENV_FILES})")
3782
3899
  parser.add_argument("-m","--max_connections", type=int, help="Max number of connections to use (default: 4 * cpu_count)", default=DEFAULT_MAX_CONNECTIONS)
3783
3900
  parser.add_argument("-j","--json", action='store_true', help=F"Output in json format. (default: {DEFAULT_JSON_MODE})", default=DEFAULT_JSON_MODE)
3784
3901
  parser.add_argument('-w',"--success_hosts", action='store_true', help=f"Output the hosts that succeeded in summary as well. (default: {DEFAULT_PRINT_SUCCESS_HOSTS})", default=DEFAULT_PRINT_SUCCESS_HOSTS)
@@ -3799,6 +3916,7 @@ def get_parser():
3799
3916
  parser.add_argument('-e','--encoding', type=str, help=f'The encoding to use for the output. (default: {DEFAULT_ENCODING})', default=DEFAULT_ENCODING)
3800
3917
  parser.add_argument('-dt','--diff_display_threshold', type=float, help=f'The threshold of lines to display the diff when files differ. {{0-1}} Set to 0 to always display the diff. Set to 1 to disable diff. (Only merge same) (default: {DEFAULT_DIFF_DISPLAY_THRESHOLD})', default=DEFAULT_DIFF_DISPLAY_THRESHOLD)
3801
3918
  parser.add_argument('--force_truecolor', action='store_true', help=f'Force truecolor output even when not in a truecolor terminal. (default: {FORCE_TRUECOLOR})', default=FORCE_TRUECOLOR)
3919
+ parser.add_argument('--add_control_master_config', action='store_true', help='Add ControlMaster configuration to ~/.ssh/config to speed up multiple connections to the same host.')
3802
3920
  parser.add_argument("-V","--version", action='version', version=f'%(prog)s {version} @ {COMMIT_DATE} with [ {", ".join(_binPaths.keys())} ] by {AUTHOR} ({AUTHOR_EMAIL})')
3803
3921
  return parser
3804
3922
 
@@ -3828,7 +3946,18 @@ def process_args(args = None):
3828
3946
  args.no_history = True
3829
3947
  args.greppable = True
3830
3948
  args.error_only = True
3831
-
3949
+
3950
+ if args.file_sync:
3951
+ for path in args.file_sync:
3952
+ if path and isinstance(path, str):
3953
+ if args.file:
3954
+ if path not in args.file:
3955
+ args.file.append(path)
3956
+ else:
3957
+ args.file = [path]
3958
+ args.file_sync = any(args.file_sync)
3959
+ else:
3960
+ args.file_sync = False
3832
3961
  if args.unavailable_host_expiry <= 0:
3833
3962
  eprint(f"Warning: The unavailable host expiry time {args.unavailable_host_expiry} is less than 0, setting it to 10 seconds.")
3834
3963
  args.unavailable_host_expiry = 10
@@ -3847,7 +3976,7 @@ def process_config_file(args):
3847
3976
  else:
3848
3977
  configFileToWriteTo = args.config_file
3849
3978
  write_default_config(args,configFileToWriteTo)
3850
- if not args.commands:
3979
+ if not args.commands and not args.file:
3851
3980
  if configFileToWriteTo:
3852
3981
  with open(configFileToWriteTo,'r') as f:
3853
3982
  eprint(f"Config file content: \n{f.read()}")
@@ -3889,6 +4018,35 @@ def process_keys(args):
3889
4018
  eprint(f"Warning: Identity file {args.key!r} not found. Passing to ssh anyway. Proceed with caution.")
3890
4019
  return args
3891
4020
 
4021
+ def process_control_master_config(args):
4022
+ global __control_master_string
4023
+ if args.add_control_master_config:
4024
+ try:
4025
+ if not os.path.exists(os.path.expanduser('~/.ssh')):
4026
+ os.makedirs(os.path.expanduser('~/.ssh'),mode=0o700)
4027
+ ssh_config_file = os.path.expanduser('~/.ssh/config')
4028
+ if not os.path.exists(ssh_config_file):
4029
+ with open(ssh_config_file,'w') as f:
4030
+ f.write(__control_master_string)
4031
+ os.chmod(ssh_config_file,0o644)
4032
+ else:
4033
+ with open(ssh_config_file,'r') as f:
4034
+ ssh_config_content = f.readlines()
4035
+ if set(map(str.strip,ssh_config_content)).issuperset(set(map(str.strip,__control_master_string.splitlines()))):
4036
+ eprint("ControlMaster configuration already exists in ~/.ssh/config, skipping adding:")
4037
+ eprint(__control_master_string)
4038
+ else:
4039
+ with open(ssh_config_file,'a') as f:
4040
+ f.write('\n# Added by multiSSH3.py to speed up subsequent connections\n')
4041
+ f.write(__control_master_string)
4042
+ eprint("ControlMaster configuration added to ~/.ssh/config.")
4043
+ except Exception as e:
4044
+ eprint(f"Error adding ControlMaster configuration: {e}")
4045
+ import traceback
4046
+ traceback.print_exc()
4047
+ if not args.commands and not args.file:
4048
+ _exit_with_code(0, "Done configuring ControlMaster.")
4049
+ return args
3892
4050
 
3893
4051
  def set_global_with_args(args):
3894
4052
  global _emo
@@ -3904,7 +4062,10 @@ def set_global_with_args(args):
3904
4062
  global FORCE_TRUECOLOR
3905
4063
  _emo = False
3906
4064
  __ipmiiInterfaceIPPrefix = args.ipmi_interface_ip_prefix
3907
- _env_files = args.env_file
4065
+ if args.env_file:
4066
+ _env_files = [args.env_file]
4067
+ else:
4068
+ _env_files = DEFAULT_ENV_FILES.extend(args.env_files) if args.env_files else DEFAULT_ENV_FILES
3908
4069
  __DEBUG_MODE = args.debug
3909
4070
  _encoding = args.encoding
3910
4071
  if args.return_zero:
@@ -3925,6 +4086,7 @@ def main():
3925
4086
  args = process_config_file(args)
3926
4087
  args = process_commands(args)
3927
4088
  args = process_keys(args)
4089
+ args = process_control_master_config(args)
3928
4090
  set_global_with_args(args)
3929
4091
 
3930
4092
  if args.use_script_timeout:
@@ -3943,7 +4105,7 @@ def main():
3943
4105
  curses_min_char_len = args.window_width, curses_min_line_len = args.window_height,single_window=args.single_window,error_only=args.error_only,identity_file=args.key,
3944
4106
  copy_id=args.copy_id,unavailable_host_expiry=args.unavailable_host_expiry,no_history=args.no_history,
3945
4107
  history_file = args.history_file,
3946
- env_file = args.env_file,
4108
+ env_file = args.env_file,env_files = args.env_files,
3947
4109
  repeat = args.repeat,interval = args.interval)
3948
4110
  eprint('> ' + cmdStr)
3949
4111
  if args.error_only: