diff --git a/main.py b/main.py index d0a66112..a5987d53 100755 --- a/main.py +++ b/main.py @@ -16,10 +16,31 @@ try: from colorama import init as colorama_init, Fore, Back, Style colorama_init(autoreset=True) except ImportError: - class Dummy: - def __getattr__(self, name): return '' - Fore = Back = Style = Dummy() + # Minimal ANSI fallback if colorama is not available + class Style: + RESET_ALL = "\033[0m" + BRIGHT = "\033[1m" + DIM = "\033[2m" + class Fore: + BLACK = "\033[30m" + RED = "\033[31m" + GREEN = "\033[32m" + YELLOW = "\033[33m" + BLUE = "\033[34m" + MAGENTA = "\033[35m" + CYAN = "\033[36m" + WHITE = "\033[37m" + + class Back: + BLACK = "\033[40m" + RED = "\033[41m" + GREEN = "\033[42m" + YELLOW = "\033[43m" + BLUE = "\033[44m" + MAGENTA = "\033[45m" + CYAN = "\033[46m" + WHITE = "\033[47m" def color_text(text, color): return f"{color}{text}{Style.RESET_ALL}" @@ -94,20 +115,23 @@ def show_full_help_for_all(cli_dir, available): print() for folder, cmd in available: - # Build module path (cli..) + # Build file path (e.g. "meta/j2/compiler.py") + file_path = f"{folder + '/' if folder else ''}{cmd}.py" + + # Build subcommand (spaces instead of slashes, no .py) if folder: - rel = os.path.join(folder, f"{cmd}.py") + subcommand = f"{folder.replace('/', ' ')} {cmd}" else: - rel = f"{cmd}.py" - - module = "cli." + rel[:-3].replace(os.sep, ".") - - header_path = f"{folder + '/' if folder else ''}{cmd}.py" - print(color_text("=" * 80, Fore.MAGENTA)) - print(color_text(f"Command: {header_path}", Fore.MAGENTA + Style.BRIGHT)) - print(color_text("-" * 80, Fore.MAGENTA)) + subcommand = cmd + # Colorful header + print(color_text("=" * 80, Fore.BLUE + Style.BRIGHT)) + print(color_text(f"Subcommand: {subcommand}", Fore.YELLOW + Style.BRIGHT)) + print(color_text(f"File: {file_path}", Fore.CYAN)) + print(color_text("-" * 80, Fore.BLUE)) + try: + module = "cli." + file_path[:-3].replace(os.sep, ".") result = subprocess.run( [sys.executable, "-m", module, "--help"], capture_output=True, @@ -119,13 +143,92 @@ def show_full_help_for_all(cli_dir, available): if result.stderr: print(color_text(result.stderr.rstrip(), Fore.RED)) except Exception as e: - print(color_text(f"Failed to get help for {header_path}: {e}", Fore.RED)) + print(color_text(f"Failed to get help for {file_path}: {e}", Fore.RED)) print() # extra spacer between commands def git_clean_repo(): subprocess.run(['git', 'clean', '-Xfd'], check=True) +def print_global_help(available, cli_dir): + """ + Print the standard global help screen for the Infinito.Nexus CLI. + This is used by both --help and --help-all. + """ + print(color_text("Infinito.Nexus CLI ๐Ÿฆซ๐ŸŒ๐Ÿ–ฅ๏ธ", Fore.CYAN + Style.BRIGHT)) + print() + print(color_text("Your Gateway to Automated IT Infrastructure Setup", Style.DIM)) + print() + print(color_text( + "Usage: infinito " + "[--sound] " + "[--no-signal] " + "[--log] " + "[--git-clean] " + "[--infinite] " + "[--help-all] " + "[--alarm-timeout ] " + "[-h|--help] " + " [options]", + Fore.GREEN + )) + print() + # Use bright style for headings + print(color_text("Options:", Style.BRIGHT)) + print(color_text(" --sound Play startup melody and warning sounds", Fore.YELLOW)) + print(color_text(" --no-signal Suppress success/failure signals", Fore.YELLOW)) + print(color_text(" --log Log all proxied command output to logfile.log", Fore.YELLOW)) + print(color_text(" --git-clean Remove all Git-ignored files before running", Fore.YELLOW)) + print(color_text(" --infinite Run the proxied command in an infinite loop", Fore.YELLOW)) + print(color_text(" --help-all Show full --help for all CLI commands", Fore.YELLOW)) + print(color_text(" --alarm-timeout Stop warnings and exit after N seconds (default: 60)", Fore.YELLOW)) + print(color_text(" -h, --help Show this help message and exit", Fore.YELLOW)) + print() + print(color_text("Available commands:", Style.BRIGHT)) + print() + + current_folder = None + for folder, cmd in available: + if folder != current_folder: + if folder: + print(color_text(f"{folder}/", Fore.MAGENTA)) + print() + current_folder = folder + desc = extract_description_via_help( + os.path.join(cli_dir, *(folder.split('/') if folder else []), f"{cmd}.py") + ) + print(color_text(format_command_help(cmd, desc, indent=2), ''), "\n") + + print() + print(color_text( + "๐Ÿ”— You can chain subcommands by specifying nested directories,", + Fore.CYAN + )) + print(color_text( + " e.g. `infinito build defaults users` โ†’", + Fore.CYAN + )) + print(color_text( + " corresponds to `cli/build/defaults/users.py`.", + Fore.CYAN + )) + print() + print(color_text( + "Infinito.Nexus is a product of Kevin Veen-Birkenbach, https://cybermaster.space .\n", + Style.DIM + )) + print(color_text( + "Test and use productively on https://infinito.nexus .\n", + Style.DIM + )) + print(color_text( + "For commercial use, a license agreement with Kevin Veen-Birkenbach is required. \n", + Style.DIM + )) + print(color_text("License: https://s.infinito.nexus/license", Style.DIM)) + print() + print(color_text("๐ŸŽ‰๐ŸŒˆ Happy IT Infrastructuring! ๐Ÿš€๐Ÿ”งโœจ", Fore.MAGENTA + Style.BRIGHT)) + print() def play_start_intro(): Sound.play_start_sound() @@ -227,86 +330,18 @@ if __name__ == "__main__": # Global "show help for all commands" mode if help_all: + # 1) Print the normal global help (same as --help) + print_global_help(available, cli_dir) + + # 2) Then print detailed --help for all subcommands + print(color_text("Full detailed help for all subcommands:", Style.BRIGHT)) + print() show_full_help_for_all(cli_dir, available) sys.exit(0) - # Global help if not args or args[0] in ('-h', '--help'): - print(color_text("Infinito.Nexus CLI ๐Ÿฆซ๐ŸŒ๐Ÿ–ฅ๏ธ", Fore.CYAN + Style.BRIGHT)) - print() - print(color_text("Your Gateway to Automated IT Infrastructure Setup", Style.DIM)) - print() - print(color_text( - "Usage: infinito " - "[--sound] " - "[--no-signal] " - "[--log] " - "[--git-clean] " - "[--infinite] " - "[--help-all] " - "[--alarm-timeout ] " - "[-h|--help] " - " [options]", - Fore.GREEN - )) - print() - # Use bright style for headings - print(color_text("Options:", Style.BRIGHT)) - print(color_text(" --sound Play startup melody and warning sounds", Fore.YELLOW)) - print(color_text(" --no-signal Suppress success/failure signals", Fore.YELLOW)) - print(color_text(" --log Log all proxied command output to logfile.log", Fore.YELLOW)) - print(color_text(" --git-clean Remove all Git-ignored files before running", Fore.YELLOW)) - print(color_text(" --infinite Run the proxied command in an infinite loop", Fore.YELLOW)) - print(color_text(" --help-all Show full --help for all CLI commands", Fore.YELLOW)) - print(color_text(" --alarm-timeout Stop warnings and exit after N seconds (default: 60)", Fore.YELLOW)) - print(color_text(" -h, --help Show this help message and exit", Fore.YELLOW)) - print() - print(color_text("Available commands:", Style.BRIGHT)) - print() - - current_folder = None - for folder, cmd in available: - if folder != current_folder: - if folder: - print(color_text(f"{folder}/", Fore.MAGENTA)) - print() - current_folder = folder - desc = extract_description_via_help( - os.path.join(cli_dir, *(folder.split('/') if folder else []), f"{cmd}.py") - ) - print(color_text(format_command_help(cmd, desc, indent=2), ''), "\n") - - print() - print(color_text( - "๐Ÿ”— You can chain subcommands by specifying nested directories,", - Fore.CYAN - )) - print(color_text( - " e.g. `infinito build defaults users` โ†’", - Fore.CYAN - )) - print(color_text( - " corresponds to `cli/build/defaults/users.py`.", - Fore.CYAN - )) - print() - print(color_text( - "Infinito.Nexus is a product of Kevin Veen-Birkenbach, https://cybermaster.space .\n", - Style.DIM - )) - print(color_text( - "Test and use productively on https://infinito.nexus .\n", - Style.DIM - )) - print(color_text( - "For commercial use, a license agreement with Kevin Veen-Birkenbach is required. \n", - Style.DIM - )) - print(color_text("License: https://s.infinito.nexus/license", Style.DIM)) - print() - print(color_text("๐ŸŽ‰๐ŸŒˆ Happy IT Infrastructuring! ๐Ÿš€๐Ÿ”งโœจ", Fore.MAGENTA + Style.BRIGHT)) - print() + print_global_help(available, cli_dir) sys.exit(0) # Directory-specific help diff --git a/tests/unit/test_main.py b/tests/unit/test_main.py index 0ee1cad3..dc7efa12 100644 --- a/tests/unit/test_main.py +++ b/tests/unit/test_main.py @@ -146,6 +146,79 @@ class TestMainHelpers(unittest.TestCase): description = main.extract_description_via_help("/fake/path/empty.py") self.assertEqual(description, "-") + @mock.patch('main.extract_description_via_help') + @mock.patch('main.format_command_help') + @mock.patch('builtins.print') + def test_print_global_help_uses_helpers_per_command(self, mock_print, mock_fmt, mock_extract): + """ + print_global_help() should call extract_description_via_help() and + format_command_help() once per available command, with correct paths. + """ + cli_dir = "/tmp/cli" + available = [ + (None, "rootcmd"), + ("meta/j2", "compiler"), + ] + + mock_extract.return_value = "DESC" + mock_fmt.side_effect = lambda name, desc, **kwargs: f"{name}:{desc}" + + main.print_global_help(available, cli_dir) + + # extract_description_via_help should be called with the correct .py paths + expected_paths = [ + os.path.join(cli_dir, "rootcmd.py"), + os.path.join(cli_dir, "meta", "j2", "compiler.py"), + ] + called_paths = [call.args[0] for call in mock_extract.call_args_list] + self.assertEqual(expected_paths, called_paths) + + # format_command_help should be called for both commands, in order + called_names = [call.args[0] for call in mock_fmt.call_args_list] + self.assertEqual(["rootcmd", "compiler"], called_names) + + @mock.patch('builtins.print') + def test__play_in_child_failure_returns_false_and_prints_warning(self, mock_print): + """ + _play_in_child() should return False and print a diagnostic + when the child exitcode is non-zero. + """ + + class FakeProcess: + def __init__(self, target=None, args=None): + self.exitcode = 1 + def start(self): + pass + def join(self): + pass + + with mock.patch('main.Process', FakeProcess): + ok = main._play_in_child("play_warning_sound") + + self.assertFalse(ok) + self.assertTrue( + any("[sound] child" in str(c.args[0]) for c in mock_print.call_args_list), + "Expected a diagnostic print when exitcode != 0", + ) + + @mock.patch('main._play_in_child') + @mock.patch('main.time.sleep') + def test_failure_with_warning_loop_no_signal_skips_sounds_and_exits( + self, mock_sleep, mock_play + ): + """ + When no_signal=True, failure_with_warning_loop() should not call + _play_in_child at all and should exit after the timeout. + """ + + # Simulate time.monotonic jumping past the timeout immediately + with mock.patch('main.time.monotonic', side_effect=[0.0, 100.0]): + with mock.patch('main.sys.exit', side_effect=SystemExit) as mock_exit: + with self.assertRaises(SystemExit): + main.failure_with_warning_loop(no_signal=True, sound_enabled=True, alarm_timeout=1) + + mock_play.assert_not_called() + mock_exit.assert_called() # ensure we attempted to exit if __name__ == "__main__": unittest.main()